cubeforge 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +299 -235
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -137,6 +137,7 @@ var ECSWorld = class {
137
137
  return id;
138
138
  }
139
139
  destroyEntity(id) {
140
+ if (!this.componentIndex.has(id)) return;
140
141
  const comps = this.componentIndex.get(id);
141
142
  if (comps) {
142
143
  for (const type of comps.keys()) this.dirtyTypes.add(type);
@@ -152,10 +153,32 @@ var ECSWorld = class {
152
153
  this.componentIndex.delete(id);
153
154
  this.entityArchetype.delete(id);
154
155
  this.dirtyAll = true;
156
+ if (this.destroyListeners.size > 0) {
157
+ for (const cb of Array.from(this.destroyListeners)) cb(id);
158
+ }
155
159
  }
156
160
  hasEntity(id) {
157
161
  return this.componentIndex.has(id);
158
162
  }
163
+ /**
164
+ * Subscribe to entity-destroyed events. The callback fires after the entity
165
+ * has been removed from all archetypes and component storage. Returns an
166
+ * unsubscribe function.
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * const off = ecs.onDestroyEntity((id) => console.log('gone', id))
171
+ * // ...
172
+ * off()
173
+ * ```
174
+ */
175
+ onDestroyEntity(cb) {
176
+ this.destroyListeners.add(cb);
177
+ return () => {
178
+ this.destroyListeners.delete(cb);
179
+ };
180
+ }
181
+ destroyListeners = /* @__PURE__ */ new Set();
159
182
  addComponent(id, component) {
160
183
  const comps = this.componentIndex.get(id);
161
184
  if (!comps) return;
@@ -872,12 +895,12 @@ async function preloadManifest(manifest, assets) {
872
895
  return;
873
896
  }
874
897
  let done = 0;
875
- const tick = () => {
898
+ const tick2 = () => {
876
899
  done++;
877
900
  manifest.onProgress?.(done / total);
878
901
  };
879
- const imageLoads = imageUrls.map((src) => assets.loadImage(src).then(tick, tick));
880
- const audioLoads = audioUrls.map((src) => assets.loadAudio(src).then(tick, tick));
902
+ const imageLoads = imageUrls.map((src) => assets.loadImage(src).then(tick2, tick2));
903
+ const audioLoads = audioUrls.map((src) => assets.loadAudio(src).then(tick2, tick2));
881
904
  await Promise.allSettled([...imageLoads, ...audioLoads]);
882
905
  }
883
906
 
@@ -915,8 +938,8 @@ function heuristic(ac, ar, bc, br) {
915
938
  const dr = Math.abs(ar - br);
916
939
  return dc + dr + (Math.SQRT2 - 2) * Math.min(dc, dr);
917
940
  }
918
- function findPath(grid, start, goal) {
919
- const sc = worldToCell(grid, start.x, start.y);
941
+ function findPath(grid, start2, goal) {
942
+ const sc = worldToCell(grid, start2.x, start2.y);
920
943
  const gc = worldToCell(grid, goal.x, goal.y);
921
944
  if (!isWalkable(grid, sc.col, sc.row) || !isWalkable(grid, gc.col, gc.row)) {
922
945
  return [];
@@ -1340,9 +1363,9 @@ function tween(from, to, duration, ease = Ease.linear, onUpdate, onComplete, opt
1340
1363
  }
1341
1364
  const t = duration > 0 ? elapsed / duration : 1;
1342
1365
  const easedT = ease(t);
1343
- const start = forward ? from : to;
1366
+ const start2 = forward ? from : to;
1344
1367
  const end = forward ? to : from;
1345
- onUpdate(start + (end - start) * easedT);
1368
+ onUpdate(start2 + (end - start2) * easedT);
1346
1369
  }
1347
1370
  };
1348
1371
  }
@@ -1382,10 +1405,10 @@ function createTimeline(opts) {
1382
1405
  }
1383
1406
  const timeline = {
1384
1407
  add(entry, addOpts) {
1385
- const start = resolveStartTime(addOpts);
1408
+ const start2 = resolveStartTime(addOpts);
1386
1409
  const delay = addOpts?.delay ?? 0;
1387
- segments.push({ startTime: start, delay, entry, started: false, complete: false });
1388
- cursor = start + delay + entry.duration;
1410
+ segments.push({ startTime: start2, delay, entry, started: false, complete: false });
1411
+ cursor = start2 + delay + entry.duration;
1389
1412
  return timeline;
1390
1413
  },
1391
1414
  addLabel(name) {
@@ -1393,12 +1416,12 @@ function createTimeline(opts) {
1393
1416
  return timeline;
1394
1417
  },
1395
1418
  addParallel(entries, addOpts) {
1396
- const start = resolveStartTime(addOpts);
1419
+ const start2 = resolveStartTime(addOpts);
1397
1420
  const delay = addOpts?.delay ?? 0;
1398
1421
  let maxEnd = cursor;
1399
1422
  for (const entry of entries) {
1400
- segments.push({ startTime: start, delay, entry, started: false, complete: false });
1401
- const end = start + delay + entry.duration;
1423
+ segments.push({ startTime: start2, delay, entry, started: false, complete: false });
1424
+ const end = start2 + delay + entry.duration;
1402
1425
  if (end > maxEnd) maxEnd = end;
1403
1426
  }
1404
1427
  cursor = maxEnd;
@@ -5410,7 +5433,7 @@ function useAudioScheduler(opts) {
5410
5433
  nextBeatIndexRef.current++;
5411
5434
  }
5412
5435
  };
5413
- const start = () => {
5436
+ const start2 = () => {
5414
5437
  if (isRunningRef.current) return;
5415
5438
  const ctx = getAudioCtx();
5416
5439
  if (ctx.state === "suspended") void ctx.resume();
@@ -5438,7 +5461,7 @@ function useAudioScheduler(opts) {
5438
5461
  return () => barHandlers.current.delete(handler);
5439
5462
  };
5440
5463
  return {
5441
- start,
5464
+ start: start2,
5442
5465
  stop,
5443
5466
  onBeat,
5444
5467
  onBar,
@@ -9031,7 +9054,7 @@ function TriangleCollider({
9031
9054
  // src/components/SegmentCollider.tsx
9032
9055
  import { useEffect as useEffect44, useContext as useContext34 } from "react";
9033
9056
  function SegmentCollider({
9034
- start,
9057
+ start: start2,
9035
9058
  end,
9036
9059
  isTrigger = false,
9037
9060
  layer = "default",
@@ -9048,7 +9071,7 @@ function SegmentCollider({
9048
9071
  useEffect44(() => {
9049
9072
  engine.ecs.addComponent(
9050
9073
  entityId,
9051
- createSegmentCollider(start, end, {
9074
+ createSegmentCollider(start2, end, {
9052
9075
  isTrigger,
9053
9076
  layer,
9054
9077
  mask,
@@ -9393,7 +9416,7 @@ function useWorldQuery(...components) {
9393
9416
  const mountedRef = useRef17(true);
9394
9417
  useEffect50(() => {
9395
9418
  mountedRef.current = true;
9396
- const tick = () => {
9419
+ const tick2 = () => {
9397
9420
  if (!mountedRef.current) return;
9398
9421
  const next = engine.ecs.query(...components);
9399
9422
  const prev = prevRef.current;
@@ -9401,9 +9424,9 @@ function useWorldQuery(...components) {
9401
9424
  prevRef.current = next;
9402
9425
  setResult([...next]);
9403
9426
  }
9404
- rafRef.current = requestAnimationFrame(tick);
9427
+ rafRef.current = requestAnimationFrame(tick2);
9405
9428
  };
9406
- rafRef.current = requestAnimationFrame(tick);
9429
+ rafRef.current = requestAnimationFrame(tick2);
9407
9430
  return () => {
9408
9431
  mountedRef.current = false;
9409
9432
  cancelAnimationFrame(rafRef.current);
@@ -9518,7 +9541,7 @@ function useProfiler() {
9518
9541
  const prevTimeRef = useRef19(0);
9519
9542
  useEffect53(() => {
9520
9543
  if (!engine) return;
9521
- let rafId;
9544
+ let rafId2;
9522
9545
  const frameTimes = frameTimesRef.current;
9523
9546
  const sample = (now) => {
9524
9547
  if (prevTimeRef.current > 0) {
@@ -9542,11 +9565,11 @@ function useProfiler() {
9542
9565
  systemTimings: new Map(engine.systemTimings)
9543
9566
  });
9544
9567
  }
9545
- rafId = requestAnimationFrame(sample);
9568
+ rafId2 = requestAnimationFrame(sample);
9546
9569
  };
9547
- rafId = requestAnimationFrame(sample);
9570
+ rafId2 = requestAnimationFrame(sample);
9548
9571
  return () => {
9549
- cancelAnimationFrame(rafId);
9572
+ cancelAnimationFrame(rafId2);
9550
9573
  frameTimes.length = 0;
9551
9574
  prevTimeRef.current = 0;
9552
9575
  lastUpdateRef.current = 0;
@@ -9802,7 +9825,7 @@ function useTimer(duration, onComplete, opts) {
9802
9825
  if (engine.ecs.hasEntity(eid)) engine.ecs.destroyEntity(eid);
9803
9826
  };
9804
9827
  }, [engine.ecs]);
9805
- const start = useCallback5(() => {
9828
+ const start2 = useCallback5(() => {
9806
9829
  timerRef.current.start();
9807
9830
  }, []);
9808
9831
  const stop = useCallback5(() => {
@@ -9812,7 +9835,7 @@ function useTimer(duration, onComplete, opts) {
9812
9835
  timerRef.current.reset();
9813
9836
  }, []);
9814
9837
  return {
9815
- start,
9838
+ start: start2,
9816
9839
  stop,
9817
9840
  reset,
9818
9841
  get isRunning() {
@@ -9966,16 +9989,16 @@ function useSceneTransition(initialScene, defaultTransition) {
9966
9989
  const effect = transState.effect;
9967
9990
  if (!effect || effect.type === "instant") return;
9968
9991
  const duration = (effect.duration ?? 0.4) / 2;
9969
- let rafId;
9970
- let start = performance.now();
9992
+ let rafId2;
9993
+ let start2 = performance.now();
9971
9994
  const animate = (now) => {
9972
- const elapsed = (now - start) / 1e3;
9995
+ const elapsed = (now - start2) / 1e3;
9973
9996
  const t = Math.min(elapsed / duration, 1);
9974
9997
  if (transRef.current.phase === "out") {
9975
9998
  setTransState((prev) => ({ ...prev, progress: t }));
9976
9999
  if (t >= 1) {
9977
10000
  transRef.current.pendingAction?.();
9978
- start = performance.now();
10001
+ start2 = performance.now();
9979
10002
  setTransState((prev) => ({ ...prev, phase: "in", progress: 1, pendingAction: null }));
9980
10003
  }
9981
10004
  } else if (transRef.current.phase === "in") {
@@ -9985,10 +10008,10 @@ function useSceneTransition(initialScene, defaultTransition) {
9985
10008
  return;
9986
10009
  }
9987
10010
  }
9988
- rafId = requestAnimationFrame(animate);
10011
+ rafId2 = requestAnimationFrame(animate);
9989
10012
  };
9990
- rafId = requestAnimationFrame(animate);
9991
- return () => cancelAnimationFrame(rafId);
10013
+ rafId2 = requestAnimationFrame(animate);
10014
+ return () => cancelAnimationFrame(rafId2);
9992
10015
  }, [transState.phase, transState.effect]);
9993
10016
  const startTransition = useCallback7(
9994
10017
  (effect, action) => {
@@ -10340,11 +10363,23 @@ function useHistory(options) {
10340
10363
  }
10341
10364
 
10342
10365
  // src/hooks/useSelection.tsx
10343
- import { createContext as createContext2, useCallback as useCallback12, useContext as useContext55, useMemo as useMemo12, useState as useState15 } from "react";
10366
+ import { createContext as createContext2, useCallback as useCallback12, useContext as useContext55, useEffect as useEffect64, useMemo as useMemo12, useState as useState15 } from "react";
10344
10367
  import { jsx as jsx15 } from "react/jsx-runtime";
10345
10368
  var SelectionContext = createContext2(null);
10346
10369
  function Selection({ initial, onChange, children }) {
10370
+ const engine = useContext55(EngineContext);
10347
10371
  const [selected, setSelected] = useState15(initial ?? []);
10372
+ useEffect64(() => {
10373
+ if (!engine) return;
10374
+ return engine.ecs.onDestroyEntity((destroyedId) => {
10375
+ setSelected((prev) => {
10376
+ if (!prev.includes(destroyedId)) return prev;
10377
+ const next = prev.filter((id) => id !== destroyedId);
10378
+ onChange?.(next);
10379
+ return next;
10380
+ });
10381
+ });
10382
+ }, [engine, onChange]);
10348
10383
  const commit = useCallback12(
10349
10384
  (next) => {
10350
10385
  setSelected(next);
@@ -10403,7 +10438,38 @@ function useSelection() {
10403
10438
  }
10404
10439
 
10405
10440
  // src/components/TransformHandles.tsx
10406
- import { useContext as useContext56, useEffect as useEffect64, useRef as useRef28 } from "react";
10441
+ import { useContext as useContext56, useEffect as useEffect66, useRef as useRef28 } from "react";
10442
+
10443
+ // src/hooks/useOverlayTick.ts
10444
+ import { useEffect as useEffect65 } from "react";
10445
+ var callbacks = /* @__PURE__ */ new Set();
10446
+ var rafId = 0;
10447
+ function tick() {
10448
+ for (const cb of callbacks) cb();
10449
+ if (callbacks.size > 0) rafId = requestAnimationFrame(tick);
10450
+ else rafId = 0;
10451
+ }
10452
+ function start() {
10453
+ if (rafId === 0) rafId = requestAnimationFrame(tick);
10454
+ }
10455
+ function register(cb) {
10456
+ callbacks.add(cb);
10457
+ start();
10458
+ return () => {
10459
+ callbacks.delete(cb);
10460
+ if (callbacks.size === 0 && rafId !== 0) {
10461
+ cancelAnimationFrame(rafId);
10462
+ rafId = 0;
10463
+ }
10464
+ };
10465
+ }
10466
+ function useOverlayTick(callback, deps = []) {
10467
+ useEffect65(() => {
10468
+ return register(callback);
10469
+ }, deps);
10470
+ }
10471
+
10472
+ // src/components/TransformHandles.tsx
10407
10473
  import { Fragment as Fragment8, jsx as jsx16, jsxs as jsxs8 } from "react/jsx-runtime";
10408
10474
  function TransformHandles({
10409
10475
  showRotationHandle = true,
@@ -10416,47 +10482,41 @@ function TransformHandles({
10416
10482
  const selection = useSelection();
10417
10483
  const overlayRef = useRef28(null);
10418
10484
  const dragRef = useRef28(null);
10419
- useEffect64(() => {
10485
+ useOverlayTick(() => {
10420
10486
  if (!engine) return;
10421
10487
  const overlay = overlayRef.current;
10422
10488
  if (!overlay) return;
10423
10489
  const canvas = engine.canvas;
10424
- let rafId = 0;
10425
- const tick = () => {
10426
- const rect = canvas.getBoundingClientRect();
10427
- overlay.style.left = `${rect.left + window.scrollX}px`;
10428
- overlay.style.top = `${rect.top + window.scrollY}px`;
10429
- overlay.style.width = `${rect.width}px`;
10430
- overlay.style.height = `${rect.height}px`;
10431
- for (const id of selection.selected) {
10432
- const node = overlay.querySelector(`[data-selection-id="${id}"]`);
10433
- if (!node) continue;
10434
- const bounds = getEntityBounds(engine.ecs, id);
10435
- const t = engine.ecs.getComponent(id, "Transform");
10436
- if (!bounds || !t) {
10437
- node.style.display = "none";
10438
- continue;
10439
- }
10440
- const screen = worldToScreenCss(engine, t.x, t.y);
10441
- if (!screen) {
10442
- node.style.display = "none";
10443
- continue;
10444
- }
10445
- const w = bounds.baseWidth * Math.abs(t.scaleX) * screen.zoom;
10446
- const h = bounds.baseHeight * Math.abs(t.scaleY) * screen.zoom;
10447
- node.style.display = "block";
10448
- node.style.left = `${screen.x}px`;
10449
- node.style.top = `${screen.y}px`;
10450
- node.style.width = `${w}px`;
10451
- node.style.height = `${h}px`;
10452
- node.style.transform = `translate(-50%, -50%) rotate(${t.rotation}rad)`;
10453
- }
10454
- rafId = requestAnimationFrame(tick);
10455
- };
10456
- rafId = requestAnimationFrame(tick);
10457
- return () => cancelAnimationFrame(rafId);
10490
+ const rect = canvas.getBoundingClientRect();
10491
+ overlay.style.left = `${rect.left + window.scrollX}px`;
10492
+ overlay.style.top = `${rect.top + window.scrollY}px`;
10493
+ overlay.style.width = `${rect.width}px`;
10494
+ overlay.style.height = `${rect.height}px`;
10495
+ for (const id of selection.selected) {
10496
+ const node = overlay.querySelector(`[data-selection-id="${id}"]`);
10497
+ if (!node) continue;
10498
+ const bounds = getEntityBounds(engine.ecs, id);
10499
+ const t = engine.ecs.getComponent(id, "Transform");
10500
+ if (!bounds || !t) {
10501
+ node.style.display = "none";
10502
+ continue;
10503
+ }
10504
+ const screen = worldToScreenCss(engine, t.x, t.y);
10505
+ if (!screen) {
10506
+ node.style.display = "none";
10507
+ continue;
10508
+ }
10509
+ const w = bounds.baseWidth * Math.abs(t.scaleX) * screen.zoom;
10510
+ const h = bounds.baseHeight * Math.abs(t.scaleY) * screen.zoom;
10511
+ node.style.display = "block";
10512
+ node.style.left = `${screen.x}px`;
10513
+ node.style.top = `${screen.y}px`;
10514
+ node.style.width = `${w}px`;
10515
+ node.style.height = `${h}px`;
10516
+ node.style.transform = `translate(-50%, -50%) rotate(${t.rotation}rad)`;
10517
+ }
10458
10518
  }, [engine, selection.selected]);
10459
- useEffect64(() => {
10519
+ useEffect66(() => {
10460
10520
  if (!engine) return;
10461
10521
  const onMove = (e) => {
10462
10522
  const drag = dragRef.current;
@@ -10771,7 +10831,7 @@ function getBounds(ecs, id) {
10771
10831
  }
10772
10832
 
10773
10833
  // src/components/EditableText.tsx
10774
- import { useContext as useContext58, useEffect as useEffect65, useRef as useRef29 } from "react";
10834
+ import { useContext as useContext58, useEffect as useEffect67, useRef as useRef29 } from "react";
10775
10835
  import { jsx as jsx17 } from "react/jsx-runtime";
10776
10836
  function EditableText({
10777
10837
  value,
@@ -10797,39 +10857,33 @@ function EditableText({
10797
10857
  const entityId = useContext58(EntityContext);
10798
10858
  const containerRef = useRef29(null);
10799
10859
  const inputRef = useRef29(null);
10800
- useEffect65(() => {
10860
+ useOverlayTick(() => {
10801
10861
  if (!engine || entityId === null || entityId === void 0) return;
10802
10862
  const container = containerRef.current;
10803
10863
  if (!container) return;
10804
- let rafId = 0;
10805
- const tick = () => {
10806
- const t = engine.ecs.getComponent(entityId, "Transform");
10807
- if (t) {
10808
- const screen = worldToScreenCss2(engine, t.x, t.y);
10809
- if (screen) {
10810
- const zoom = screen.zoom;
10811
- const cssW = width * zoom;
10812
- const cssH = height * zoom;
10813
- container.style.display = "block";
10814
- container.style.left = `${screen.x - cssW / 2}px`;
10815
- container.style.top = `${screen.y - cssH / 2}px`;
10816
- container.style.width = `${cssW}px`;
10817
- container.style.height = `${cssH}px`;
10818
- container.style.transform = `rotate(${t.rotation}rad)`;
10819
- container.style.fontSize = `${fontSize * zoom}px`;
10820
- } else {
10821
- container.style.display = "none";
10822
- }
10864
+ const t = engine.ecs.getComponent(entityId, "Transform");
10865
+ if (t) {
10866
+ const screen = worldToScreenCss2(engine, t.x, t.y);
10867
+ if (screen) {
10868
+ const zoom = screen.zoom;
10869
+ const cssW = width * zoom;
10870
+ const cssH = height * zoom;
10871
+ container.style.display = "block";
10872
+ container.style.left = `${screen.x - cssW / 2}px`;
10873
+ container.style.top = `${screen.y - cssH / 2}px`;
10874
+ container.style.width = `${cssW}px`;
10875
+ container.style.height = `${cssH}px`;
10876
+ container.style.transform = `rotate(${t.rotation}rad)`;
10877
+ container.style.fontSize = `${fontSize * zoom}px`;
10878
+ } else {
10879
+ container.style.display = "none";
10823
10880
  }
10824
- const rect = engine.canvas.getBoundingClientRect();
10825
- container.style.setProperty("--cubeforge-canvas-left", `${rect.left + window.scrollX}px`);
10826
- container.style.setProperty("--cubeforge-canvas-top", `${rect.top + window.scrollY}px`);
10827
- rafId = requestAnimationFrame(tick);
10828
- };
10829
- rafId = requestAnimationFrame(tick);
10830
- return () => cancelAnimationFrame(rafId);
10881
+ }
10882
+ const rect = engine.canvas.getBoundingClientRect();
10883
+ container.style.setProperty("--cubeforge-canvas-left", `${rect.left + window.scrollX}px`);
10884
+ container.style.setProperty("--cubeforge-canvas-top", `${rect.top + window.scrollY}px`);
10831
10885
  }, [engine, entityId, width, height, fontSize]);
10832
- useEffect65(() => {
10886
+ useEffect67(() => {
10833
10887
  if (autoFocus) inputRef.current?.focus();
10834
10888
  }, [autoFocus]);
10835
10889
  if (!engine) return null;
@@ -10960,7 +11014,7 @@ function copyRegion(source, region, scale) {
10960
11014
  }
10961
11015
 
10962
11016
  // src/components/A11yNode.tsx
10963
- import { useContext as useContext59, useEffect as useEffect66, useRef as useRef30 } from "react";
11017
+ import { useContext as useContext59, useRef as useRef30 } from "react";
10964
11018
  import { jsx as jsx18 } from "react/jsx-runtime";
10965
11019
  function A11yNode({
10966
11020
  label,
@@ -10977,29 +11031,21 @@ function A11yNode({
10977
11031
  const engine = useContext59(EngineContext);
10978
11032
  const entityId = useContext59(EntityContext);
10979
11033
  const nodeRef = useRef30(null);
10980
- useEffect66(() => {
11034
+ useOverlayTick(() => {
10981
11035
  if (!engine || entityId === null || entityId === void 0) return;
10982
11036
  const node = nodeRef.current;
10983
11037
  if (!node) return;
10984
- let rafId = 0;
10985
- const tick = () => {
10986
- const t = engine.ecs.getComponent(entityId, "Transform");
10987
- if (t) {
10988
- const screen = worldToScreenCss3(engine, t.x, t.y);
10989
- if (screen) {
10990
- const size = bounds ?? deriveBounds(engine.ecs, entityId) ?? { width: 16, height: 16 };
10991
- const w = size.width * Math.abs(t.scaleX) * screen.zoom;
10992
- const h = size.height * Math.abs(t.scaleY) * screen.zoom;
10993
- node.style.left = `${screen.x - Math.max(w, 1) / 2}px`;
10994
- node.style.top = `${screen.y - Math.max(h, 1) / 2}px`;
10995
- node.style.width = `${Math.max(w, 1)}px`;
10996
- node.style.height = `${Math.max(h, 1)}px`;
10997
- }
10998
- }
10999
- rafId = requestAnimationFrame(tick);
11000
- };
11001
- rafId = requestAnimationFrame(tick);
11002
- return () => cancelAnimationFrame(rafId);
11038
+ const t = engine.ecs.getComponent(entityId, "Transform");
11039
+ if (!t) return;
11040
+ const screen = worldToScreenCss3(engine, t.x, t.y);
11041
+ if (!screen) return;
11042
+ const size = bounds ?? deriveBounds(engine.ecs, entityId) ?? { width: 16, height: 16 };
11043
+ const w = size.width * Math.abs(t.scaleX) * screen.zoom;
11044
+ const h = size.height * Math.abs(t.scaleY) * screen.zoom;
11045
+ node.style.left = `${screen.x - Math.max(w, 1) / 2}px`;
11046
+ node.style.top = `${screen.y - Math.max(h, 1) / 2}px`;
11047
+ node.style.width = `${Math.max(w, 1)}px`;
11048
+ node.style.height = `${Math.max(h, 1)}px`;
11003
11049
  }, [engine, entityId, bounds]);
11004
11050
  if (!engine || entityId === null || entityId === void 0) return null;
11005
11051
  const effectiveRole = role ?? (onActivate ? "button" : "presentation");
@@ -11073,7 +11119,7 @@ function worldToScreenCss3(engine, wx, wy) {
11073
11119
  }
11074
11120
 
11075
11121
  // src/components/VectorPath.tsx
11076
- import { useContext as useContext60, useEffect as useEffect67, useRef as useRef31 } from "react";
11122
+ import { useContext as useContext60, useRef as useRef31 } from "react";
11077
11123
  import { jsx as jsx19 } from "react/jsx-runtime";
11078
11124
  function VectorPath({
11079
11125
  d,
@@ -11090,37 +11136,29 @@ function VectorPath({
11090
11136
  const engine = useContext60(EngineContext);
11091
11137
  const entityId = useContext60(EntityContext);
11092
11138
  const svgRef = useRef31(null);
11093
- useEffect67(() => {
11139
+ useOverlayTick(() => {
11094
11140
  if (!engine || entityId === null || entityId === void 0) return;
11095
11141
  const svg = svgRef.current;
11096
11142
  if (!svg) return;
11097
- let rafId = 0;
11098
- const tick = () => {
11099
- const t = engine.ecs.getComponent(entityId, "Transform");
11100
- if (t) {
11101
- const screen = worldToScreenCss4(engine, t.x, t.y);
11102
- if (screen) {
11103
- svg.style.display = visible ? "block" : "none";
11104
- const rect = engine.canvas.getBoundingClientRect();
11105
- svg.style.left = `${rect.left + window.scrollX}px`;
11106
- svg.style.top = `${rect.top + window.scrollY}px`;
11107
- svg.style.width = `${rect.width}px`;
11108
- svg.style.height = `${rect.height}px`;
11109
- const path = svg.querySelector("path");
11110
- if (path) {
11111
- const degrees = t.rotation * 180 / Math.PI;
11112
- path.setAttribute(
11113
- "transform",
11114
- `translate(${screen.x} ${screen.y}) rotate(${degrees}) scale(${t.scaleX * screen.zoom} ${t.scaleY * screen.zoom})`
11115
- );
11116
- path.setAttribute("stroke-width", `${strokeWidth * screen.zoom}`);
11117
- }
11118
- }
11119
- }
11120
- rafId = requestAnimationFrame(tick);
11121
- };
11122
- rafId = requestAnimationFrame(tick);
11123
- return () => cancelAnimationFrame(rafId);
11143
+ const t = engine.ecs.getComponent(entityId, "Transform");
11144
+ if (!t) return;
11145
+ const screen = worldToScreenCss4(engine, t.x, t.y);
11146
+ if (!screen) return;
11147
+ svg.style.display = visible ? "block" : "none";
11148
+ const rect = engine.canvas.getBoundingClientRect();
11149
+ svg.style.left = `${rect.left + window.scrollX}px`;
11150
+ svg.style.top = `${rect.top + window.scrollY}px`;
11151
+ svg.style.width = `${rect.width}px`;
11152
+ svg.style.height = `${rect.height}px`;
11153
+ const path = svg.querySelector("path");
11154
+ if (path) {
11155
+ const degrees = t.rotation * 180 / Math.PI;
11156
+ path.setAttribute(
11157
+ "transform",
11158
+ `translate(${screen.x} ${screen.y}) rotate(${degrees}) scale(${t.scaleX * screen.zoom} ${t.scaleY * screen.zoom})`
11159
+ );
11160
+ path.setAttribute("stroke-width", `${strokeWidth * screen.zoom}`);
11161
+ }
11124
11162
  }, [engine, entityId, visible, strokeWidth]);
11125
11163
  if (!engine) return null;
11126
11164
  const svgStyle = {
@@ -11356,12 +11394,16 @@ function useTurnSystem({
11356
11394
  const [activeIndex, setActiveIndex] = useState17(initialIndex % players.length);
11357
11395
  const [turn, setTurn] = useState17(0);
11358
11396
  const [isPending, setIsPending] = useState17(false);
11359
- const pendingTimer = useRef33(null);
11397
+ const pendingRafId = useRef33(null);
11398
+ const pendingRemaining = useRef33(0);
11399
+ const pendingTarget = useRef33(null);
11400
+ const pendingLastTime = useRef33(0);
11360
11401
  const clearPending = useCallback15(() => {
11361
- if (pendingTimer.current) {
11362
- clearTimeout(pendingTimer.current);
11363
- pendingTimer.current = null;
11402
+ if (pendingRafId.current !== null) {
11403
+ cancelAnimationFrame(pendingRafId.current);
11404
+ pendingRafId.current = null;
11364
11405
  }
11406
+ pendingTarget.current = null;
11365
11407
  setIsPending(false);
11366
11408
  }, []);
11367
11409
  const startedOnce = useRef33(false);
@@ -11391,13 +11433,29 @@ function useTurnSystem({
11391
11433
  return;
11392
11434
  }
11393
11435
  setIsPending(true);
11394
- pendingTimer.current = setTimeout(() => {
11395
- pendingTimer.current = null;
11396
- setIsPending(false);
11397
- applyChange(nextIndex);
11398
- }, aiDelay * 1e3);
11436
+ pendingTarget.current = nextIndex;
11437
+ pendingRemaining.current = aiDelay;
11438
+ pendingLastTime.current = performance.now();
11439
+ const tick2 = (now) => {
11440
+ const dt = (now - pendingLastTime.current) / 1e3;
11441
+ pendingLastTime.current = now;
11442
+ const paused = engine?.loop.isPaused ?? false;
11443
+ if (!paused) {
11444
+ pendingRemaining.current -= dt;
11445
+ }
11446
+ if (pendingRemaining.current <= 0 && pendingTarget.current !== null) {
11447
+ const target = pendingTarget.current;
11448
+ pendingTarget.current = null;
11449
+ pendingRafId.current = null;
11450
+ setIsPending(false);
11451
+ applyChange(target);
11452
+ return;
11453
+ }
11454
+ pendingRafId.current = requestAnimationFrame(tick2);
11455
+ };
11456
+ pendingRafId.current = requestAnimationFrame(tick2);
11399
11457
  },
11400
- [aiDelay, applyChange, clearPending]
11458
+ [aiDelay, applyChange, clearPending, engine]
11401
11459
  );
11402
11460
  const nextTurn = useCallback15(() => scheduleChange(activeIndex + 1), [scheduleChange, activeIndex]);
11403
11461
  const prevTurn = useCallback15(() => scheduleChange(activeIndex - 1), [scheduleChange, activeIndex]);
@@ -11422,7 +11480,7 @@ function useTurnSystem({
11422
11480
  }, [players.length, initialIndex, clearPending, engine]);
11423
11481
  useEffect68(
11424
11482
  () => () => {
11425
- if (pendingTimer.current) clearTimeout(pendingTimer.current);
11483
+ if (pendingRafId.current !== null) cancelAnimationFrame(pendingRafId.current);
11426
11484
  },
11427
11485
  []
11428
11486
  );
@@ -11526,15 +11584,23 @@ function screenToWorld(engine, cssX, cssY) {
11526
11584
 
11527
11585
  // src/hooks/useDragDrop.ts
11528
11586
  import { useContext as useContext64, useEffect as useEffect70, useRef as useRef34, useState as useState19 } from "react";
11529
- var activeDrag = null;
11530
- function notifyDragSubscribers() {
11531
- if (!activeDrag) return;
11532
- for (const cb of activeDrag.subscribers) cb();
11587
+ var dragStateByEngine = /* @__PURE__ */ new WeakMap();
11588
+ function getActiveDrag(engine) {
11589
+ return dragStateByEngine.get(engine) ?? null;
11533
11590
  }
11534
- function subscribeToActiveDrag(cb) {
11535
- if (!activeDrag) return () => void 0;
11536
- activeDrag.subscribers.add(cb);
11537
- return () => activeDrag?.subscribers.delete(cb);
11591
+ function setActiveDrag(engine, drag) {
11592
+ dragStateByEngine.set(engine, drag);
11593
+ }
11594
+ function notifyDragSubscribers(engine) {
11595
+ const drag = dragStateByEngine.get(engine);
11596
+ if (!drag) return;
11597
+ for (const cb of drag.subscribers) cb();
11598
+ }
11599
+ function subscribeToActiveDrag(engine, cb) {
11600
+ const drag = dragStateByEngine.get(engine);
11601
+ if (!drag) return () => void 0;
11602
+ drag.subscribers.add(cb);
11603
+ return () => dragStateByEngine.get(engine)?.subscribers.delete(cb);
11538
11604
  }
11539
11605
  function useDraggable(options) {
11540
11606
  const engine = useContext64(EngineContext);
@@ -11571,7 +11637,7 @@ function useDraggable(options) {
11571
11637
  startEntityY = t.y;
11572
11638
  startWorldX = world.x;
11573
11639
  startWorldY = world.y;
11574
- activeDrag = {
11640
+ setActiveDrag(engine, {
11575
11641
  entityId,
11576
11642
  tag: optsRef.current?.tag ?? null,
11577
11643
  pointerX: e.clientX,
@@ -11579,14 +11645,15 @@ function useDraggable(options) {
11579
11645
  worldX: world.x,
11580
11646
  worldY: world.y,
11581
11647
  subscribers: /* @__PURE__ */ new Set()
11582
- };
11648
+ });
11583
11649
  setIsDragging(true);
11584
11650
  setPosition({ x: t.x, y: t.y });
11585
11651
  optsRef.current?.onDragStart?.({ entityId, x: t.x, y: t.y });
11586
11652
  engine.loop.markDirty();
11587
11653
  };
11588
11654
  const onPointerMove = (e) => {
11589
- if (!dragging || !activeDrag) return;
11655
+ const drag = getActiveDrag(engine);
11656
+ if (!dragging || !drag) return;
11590
11657
  const t = engine.ecs.getComponent(entityId, "Transform");
11591
11658
  if (!t) return;
11592
11659
  const rect = canvas.getBoundingClientRect();
@@ -11607,11 +11674,11 @@ function useDraggable(options) {
11607
11674
  }
11608
11675
  t.x = nx;
11609
11676
  t.y = ny;
11610
- activeDrag.pointerX = e.clientX;
11611
- activeDrag.pointerY = e.clientY;
11612
- activeDrag.worldX = world.x;
11613
- activeDrag.worldY = world.y;
11614
- notifyDragSubscribers();
11677
+ drag.pointerX = e.clientX;
11678
+ drag.pointerY = e.clientY;
11679
+ drag.worldX = world.x;
11680
+ drag.worldY = world.y;
11681
+ notifyDragSubscribers(engine);
11615
11682
  setPosition({ x: nx, y: ny });
11616
11683
  optsRef.current?.onDrag?.({ entityId, x: nx, y: ny });
11617
11684
  engine.loop.markDirty();
@@ -11620,10 +11687,11 @@ function useDraggable(options) {
11620
11687
  if (!dragging) return;
11621
11688
  dragging = false;
11622
11689
  const t = engine.ecs.getComponent(entityId, "Transform");
11623
- const droppedOn = findDroppableUnder(engine, activeDrag);
11690
+ const drag = getActiveDrag(engine);
11691
+ const droppedOn = findDroppableUnder(engine, drag);
11624
11692
  const finalX = t?.x ?? startEntityX;
11625
11693
  const finalY = t?.y ?? startEntityY;
11626
- activeDrag = null;
11694
+ setActiveDrag(engine, null);
11627
11695
  setIsDragging(false);
11628
11696
  optsRef.current?.onDragEnd?.({
11629
11697
  entityId,
@@ -11658,7 +11726,8 @@ function useDroppable(options) {
11658
11726
  if (!engine || entityId === null || entityId === void 0 || options?.disabled) return;
11659
11727
  let currentlyOver = false;
11660
11728
  const check = () => {
11661
- if (!activeDrag) {
11729
+ const drag = getActiveDrag(engine);
11730
+ if (!drag) {
11662
11731
  if (currentlyOver) {
11663
11732
  currentlyOver = false;
11664
11733
  setIsOver(false);
@@ -11669,15 +11738,15 @@ function useDroppable(options) {
11669
11738
  return;
11670
11739
  }
11671
11740
  const accepts = optsRef.current?.accepts;
11672
- if (accepts && (!activeDrag.tag || !accepts.includes(activeDrag.tag))) return;
11741
+ if (accepts && (!drag.tag || !accepts.includes(drag.tag))) return;
11673
11742
  const t = engine.ecs.getComponent(entityId, "Transform");
11674
11743
  if (!t) return;
11675
11744
  const bounds = optsRef.current?.bounds ?? deriveBounds3(engine.ecs, entityId);
11676
11745
  if (!bounds) return;
11677
11746
  const halfW = bounds.width * Math.abs(t.scaleX) / 2;
11678
11747
  const halfH = bounds.height * Math.abs(t.scaleY) / 2;
11679
- const inside = Math.abs(activeDrag.worldX - t.x) <= halfW && Math.abs(activeDrag.worldY - t.y) <= halfH;
11680
- const dragId = activeDrag.entityId;
11748
+ const inside = Math.abs(drag.worldX - t.x) <= halfW && Math.abs(drag.worldY - t.y) <= halfH;
11749
+ const dragId = drag.entityId;
11681
11750
  if (inside && !currentlyOver) {
11682
11751
  currentlyOver = true;
11683
11752
  setIsOver(true);
@@ -11690,12 +11759,13 @@ function useDroppable(options) {
11690
11759
  optsRef.current?.onLeave?.({ droppedEntityId: dragId });
11691
11760
  }
11692
11761
  };
11693
- const unsubscribe = subscribeToActiveDrag(check);
11762
+ const unsubscribe = subscribeToActiveDrag(engine, check);
11694
11763
  const onUp = () => {
11695
- if (!activeDrag || !currentlyOver) return;
11696
- const dragId = activeDrag.entityId;
11697
- const dragX = activeDrag.worldX;
11698
- const dragY = activeDrag.worldY;
11764
+ const drag = getActiveDrag(engine);
11765
+ if (!drag || !currentlyOver) return;
11766
+ const dragId = drag.entityId;
11767
+ const dragX = drag.worldX;
11768
+ const dragY = drag.worldY;
11699
11769
  currentlyOver = false;
11700
11770
  setIsOver(false);
11701
11771
  setHoveredBy(null);
@@ -11949,39 +12019,33 @@ function FocusRing({
11949
12019
  mq.addEventListener("change", cb);
11950
12020
  return () => mq.removeEventListener("change", cb);
11951
12021
  }, []);
11952
- useEffect72(() => {
12022
+ useOverlayTick(() => {
11953
12023
  if (!engine) return;
11954
12024
  const ring = ringRef.current;
11955
12025
  if (!ring) return;
11956
- let rafId = 0;
11957
- const tick = () => {
11958
- if (focused === null) {
11959
- ring.style.display = "none";
11960
- } else {
11961
- const t = engine.ecs.getComponent(focused, "Transform");
11962
- if (t) {
11963
- const screen = worldToScreenCss5(engine, t.x, t.y);
11964
- const bounds = deriveBounds4(engine.ecs, focused) ?? { width: 16, height: 16 };
11965
- if (screen) {
11966
- const w = bounds.width * Math.abs(t.scaleX) * screen.zoom + padding * 2;
11967
- const h = bounds.height * Math.abs(t.scaleY) * screen.zoom + padding * 2;
11968
- ring.style.display = "block";
11969
- ring.style.left = `${screen.x - w / 2}px`;
11970
- ring.style.top = `${screen.y - h / 2}px`;
11971
- ring.style.width = `${w}px`;
11972
- ring.style.height = `${h}px`;
11973
- ring.style.transform = `rotate(${t.rotation}rad)`;
11974
- } else {
11975
- ring.style.display = "none";
11976
- }
11977
- } else {
11978
- ring.style.display = "none";
11979
- }
11980
- }
11981
- rafId = requestAnimationFrame(tick);
11982
- };
11983
- rafId = requestAnimationFrame(tick);
11984
- return () => cancelAnimationFrame(rafId);
12026
+ if (focused === null) {
12027
+ ring.style.display = "none";
12028
+ return;
12029
+ }
12030
+ const t = engine.ecs.getComponent(focused, "Transform");
12031
+ if (!t) {
12032
+ ring.style.display = "none";
12033
+ return;
12034
+ }
12035
+ const screen = worldToScreenCss5(engine, t.x, t.y);
12036
+ if (!screen) {
12037
+ ring.style.display = "none";
12038
+ return;
12039
+ }
12040
+ const bounds = deriveBounds4(engine.ecs, focused) ?? { width: 16, height: 16 };
12041
+ const w = bounds.width * Math.abs(t.scaleX) * screen.zoom + padding * 2;
12042
+ const h = bounds.height * Math.abs(t.scaleY) * screen.zoom + padding * 2;
12043
+ ring.style.display = "block";
12044
+ ring.style.left = `${screen.x - w / 2}px`;
12045
+ ring.style.top = `${screen.y - h / 2}px`;
12046
+ ring.style.width = `${w}px`;
12047
+ ring.style.height = `${h}px`;
12048
+ ring.style.transform = `rotate(${t.rotation}rad)`;
11985
12049
  }, [engine, focused, padding]);
11986
12050
  if (!engine) return null;
11987
12051
  const style = {
@@ -12497,7 +12561,7 @@ function usePathfinding() {
12497
12561
  (grid, col, row, walkable) => setWalkable(grid, col, row, walkable),
12498
12562
  []
12499
12563
  );
12500
- const findPath$ = useCallback23((grid, start, goal) => findPath(grid, start, goal), []);
12564
+ const findPath$ = useCallback23((grid, start2, goal) => findPath(grid, start2, goal), []);
12501
12565
  return { createGrid: createGrid$, setWalkable: setWalkable$, findPath: findPath$ };
12502
12566
  }
12503
12567
 
@@ -12759,7 +12823,7 @@ function useDialogue() {
12759
12823
  const [active, setActive] = useState27(false);
12760
12824
  const [currentId, setCurrentId] = useState27(null);
12761
12825
  const scriptRef = useRef41(null);
12762
- const start = useCallback28((script, startId) => {
12826
+ const start2 = useCallback28((script, startId) => {
12763
12827
  scriptRef.current = script;
12764
12828
  const id = startId ?? Object.keys(script)[0];
12765
12829
  setCurrentId(id);
@@ -12801,7 +12865,7 @@ function useDialogue() {
12801
12865
  scriptRef.current = null;
12802
12866
  }, []);
12803
12867
  const current = scriptRef.current && currentId ? scriptRef.current[currentId] ?? null : null;
12804
- return { active, current, currentId, start, advance, close };
12868
+ return { active, current, currentId, start: start2, advance, close };
12805
12869
  }
12806
12870
 
12807
12871
  // ../gameplay/src/components/DialogueBox.tsx
@@ -13069,11 +13133,11 @@ function useTween(opts) {
13069
13133
  }
13070
13134
  runningRef.current = false;
13071
13135
  }, []);
13072
- const start = useCallback31(() => {
13136
+ const start2 = useCallback31(() => {
13073
13137
  stop();
13074
13138
  runningRef.current = true;
13075
13139
  startTimeRef.current = performance.now();
13076
- const tick = (now) => {
13140
+ const tick2 = (now) => {
13077
13141
  if (!runningRef.current) return;
13078
13142
  const { from, to, duration, ease, onUpdate, onComplete } = optsRef.current;
13079
13143
  const easeFn = ease ?? Ease.linear;
@@ -13086,14 +13150,14 @@ function useTween(opts) {
13086
13150
  rafRef.current = null;
13087
13151
  onComplete?.();
13088
13152
  } else {
13089
- rafRef.current = requestAnimationFrame(tick);
13153
+ rafRef.current = requestAnimationFrame(tick2);
13090
13154
  }
13091
13155
  };
13092
- rafRef.current = requestAnimationFrame(tick);
13156
+ rafRef.current = requestAnimationFrame(tick2);
13093
13157
  }, [stop]);
13094
13158
  useEffect78(() => {
13095
13159
  if (opts.autoStart) {
13096
- start();
13160
+ start2();
13097
13161
  }
13098
13162
  }, []);
13099
13163
  useEffect78(() => {
@@ -13106,7 +13170,7 @@ function useTween(opts) {
13106
13170
  };
13107
13171
  }, []);
13108
13172
  return {
13109
- start,
13173
+ start: start2,
13110
13174
  stop,
13111
13175
  get isRunning() {
13112
13176
  return runningRef.current;
@@ -13717,12 +13781,12 @@ var ClientPrediction = class {
13717
13781
  * @param inputs - Input state this tick. Stored for re-simulation when a
13718
13782
  * server correction arrives. Pass an empty object if not using re-simulation.
13719
13783
  */
13720
- saveFrame(tick, inputs = {}) {
13721
- this._frames.set(tick, {
13784
+ saveFrame(tick2, inputs = {}) {
13785
+ this._frames.set(tick2, {
13722
13786
  snapshot: this._world.getSnapshot(),
13723
13787
  inputs
13724
13788
  });
13725
- const minTick = tick - this._bufferSize;
13789
+ const minTick = tick2 - this._bufferSize;
13726
13790
  for (const key of this._frames.keys()) {
13727
13791
  if (key < minTick) this._frames.delete(key);
13728
13792
  }
@@ -13731,8 +13795,8 @@ var ClientPrediction = class {
13731
13795
  * Restore world state to the snapshot saved at `tick`.
13732
13796
  * Returns `true` if the snapshot was found, `false` otherwise.
13733
13797
  */
13734
- rollbackTo(tick) {
13735
- const entry = this._frames.get(tick);
13798
+ rollbackTo(tick2) {
13799
+ const entry = this._frames.get(tick2);
13736
13800
  if (!entry) return false;
13737
13801
  this._world.restoreSnapshot(entry.snapshot);
13738
13802
  return true;
@@ -13748,10 +13812,10 @@ var ClientPrediction = class {
13748
13812
  * @param serverSnapshot - Authoritative world state from the server.
13749
13813
  * @param tick - The tick the server snapshot corresponds to.
13750
13814
  */
13751
- applyCorrection(serverSnapshot, tick) {
13815
+ applyCorrection(serverSnapshot, tick2) {
13752
13816
  this._world.restoreSnapshot(serverSnapshot);
13753
13817
  if (this._simulate) {
13754
- const laterTicks = [...this._frames.keys()].filter((t) => t > tick).sort((a, b) => a - b);
13818
+ const laterTicks = [...this._frames.keys()].filter((t) => t > tick2).sort((a, b) => a - b);
13755
13819
  for (const t of laterTicks) {
13756
13820
  const entry = this._frames.get(t);
13757
13821
  if (entry) {
@@ -13760,7 +13824,7 @@ var ClientPrediction = class {
13760
13824
  }
13761
13825
  }
13762
13826
  for (const key of this._frames.keys()) {
13763
- if (key <= tick) this._frames.delete(key);
13827
+ if (key <= tick2) this._frames.delete(key);
13764
13828
  }
13765
13829
  }
13766
13830
  /** Number of frames currently held in the buffer. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cubeforge",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "React-first 2D browser game engine",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",