@almadar/ui 5.42.0 → 5.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/avl/index.js CHANGED
@@ -31040,6 +31040,78 @@ var init_GameOverScreen = __esm({
31040
31040
  GameOverScreen.displayName = "GameOverScreen";
31041
31041
  }
31042
31042
  });
31043
+ function useRenderInterpolation(options = {}) {
31044
+ const tickIntervalMs = options.tickIntervalMs ?? 1e3 / 30;
31045
+ const prevRef = useRef(null);
31046
+ const currRef = useRef(null);
31047
+ const rafRef = useRef(null);
31048
+ const onSnapshot = useCallback((entities) => {
31049
+ prevRef.current = currRef.current;
31050
+ currRef.current = { entities, arrivedAt: performance.now() };
31051
+ }, []);
31052
+ const getInterpolated = useCallback((now2) => {
31053
+ const out = /* @__PURE__ */ new Map();
31054
+ const curr = currRef.current;
31055
+ if (!curr) return out;
31056
+ const prev = prevRef.current;
31057
+ if (!prev) {
31058
+ for (const e of curr.entities) out.set(e.id, { x: e.x, y: e.y });
31059
+ return out;
31060
+ }
31061
+ const rawAlpha = (now2 - curr.arrivedAt) / tickIntervalMs;
31062
+ const alpha = rawAlpha < 0 ? 0 : rawAlpha > 1 ? 1 : rawAlpha;
31063
+ const prevMap = /* @__PURE__ */ new Map();
31064
+ for (const e of prev.entities) prevMap.set(e.id, e);
31065
+ for (const c of curr.entities) {
31066
+ const p2 = prevMap.get(c.id);
31067
+ if (!p2) {
31068
+ out.set(c.id, { x: c.x, y: c.y });
31069
+ } else {
31070
+ out.set(c.id, {
31071
+ x: p2.x + (c.x - p2.x) * alpha,
31072
+ y: p2.y + (c.y - p2.y) * alpha
31073
+ });
31074
+ }
31075
+ }
31076
+ return out;
31077
+ }, [tickIntervalMs]);
31078
+ const startLoop = useCallback(
31079
+ (draw) => {
31080
+ let active = true;
31081
+ const loop = () => {
31082
+ if (!active) return;
31083
+ try {
31084
+ draw(getInterpolated(performance.now()));
31085
+ } catch {
31086
+ }
31087
+ rafRef.current = requestAnimationFrame(loop);
31088
+ };
31089
+ rafRef.current = requestAnimationFrame(loop);
31090
+ return () => {
31091
+ active = false;
31092
+ if (rafRef.current !== null) {
31093
+ cancelAnimationFrame(rafRef.current);
31094
+ rafRef.current = null;
31095
+ }
31096
+ };
31097
+ },
31098
+ [getInterpolated]
31099
+ );
31100
+ useEffect(() => {
31101
+ return () => {
31102
+ if (rafRef.current !== null) {
31103
+ cancelAnimationFrame(rafRef.current);
31104
+ rafRef.current = null;
31105
+ }
31106
+ };
31107
+ }, []);
31108
+ return { onSnapshot, getInterpolated, startLoop };
31109
+ }
31110
+ var init_useRenderInterpolation = __esm({
31111
+ "hooks/useRenderInterpolation.ts"() {
31112
+ "use client";
31113
+ }
31114
+ });
31043
31115
  function PlatformerCanvas({
31044
31116
  player,
31045
31117
  platforms = [
@@ -31108,8 +31180,43 @@ function PlatformerCanvas({
31108
31180
  y: 336,
31109
31181
  width: 32,
31110
31182
  height: 48,
31183
+ vx: 0,
31184
+ vy: 0,
31185
+ grounded: true,
31111
31186
  facingRight: true
31112
31187
  };
31188
+ const playerRef = useRef(resolvedPlayer);
31189
+ playerRef.current = resolvedPlayer;
31190
+ const interp = useRenderInterpolation();
31191
+ useEffect(() => {
31192
+ interp.onSnapshot([{ id: "player", x: resolvedPlayer.x, y: resolvedPlayer.y }]);
31193
+ }, [resolvedPlayer.x, resolvedPlayer.y]);
31194
+ const propsRef = useRef({
31195
+ platforms,
31196
+ worldWidth,
31197
+ worldHeight,
31198
+ canvasWidth,
31199
+ canvasHeight,
31200
+ followCamera,
31201
+ bgColor,
31202
+ playerSprite,
31203
+ tileSprites,
31204
+ backgroundImage,
31205
+ assetBaseUrl
31206
+ });
31207
+ propsRef.current = {
31208
+ platforms,
31209
+ worldWidth,
31210
+ worldHeight,
31211
+ canvasWidth,
31212
+ canvasHeight,
31213
+ followCamera,
31214
+ bgColor,
31215
+ playerSprite,
31216
+ tileSprites,
31217
+ backgroundImage,
31218
+ assetBaseUrl
31219
+ };
31113
31220
  const handleKeyDown = useCallback((e) => {
31114
31221
  if (keysRef.current.has(e.code)) return;
31115
31222
  keysRef.current.add(e.code);
@@ -31158,124 +31265,149 @@ function PlatformerCanvas({
31158
31265
  canvas.width = canvasWidth * dpr;
31159
31266
  canvas.height = canvasHeight * dpr;
31160
31267
  ctx.scale(dpr, dpr);
31161
- let camX = 0;
31162
- let camY = 0;
31163
- if (followCamera) {
31164
- camX = Math.max(0, Math.min(resolvedPlayer.x - canvasWidth / 2, worldWidth - canvasWidth));
31165
- camY = Math.max(0, Math.min(resolvedPlayer.y - canvasHeight / 2 - 50, worldHeight - canvasHeight));
31166
- }
31167
- const bgImg = backgroundImage ? loadImage(backgroundImage) : null;
31168
- if (bgImg) {
31169
- ctx.drawImage(bgImg, 0, 0, canvasWidth, canvasHeight);
31170
- } else if (bgColor) {
31171
- ctx.fillStyle = bgColor;
31172
- ctx.fillRect(0, 0, canvasWidth, canvasHeight);
31173
- } else {
31174
- const grad = ctx.createLinearGradient(0, 0, 0, canvasHeight);
31175
- grad.addColorStop(0, SKY_GRADIENT_TOP);
31176
- grad.addColorStop(1, SKY_GRADIENT_BOTTOM);
31177
- ctx.fillStyle = grad;
31178
- ctx.fillRect(0, 0, canvasWidth, canvasHeight);
31179
- }
31180
- ctx.strokeStyle = GRID_COLOR;
31181
- ctx.lineWidth = 1;
31182
- const gridSize = 32;
31183
- for (let gx = -camX % gridSize; gx < canvasWidth; gx += gridSize) {
31184
- ctx.beginPath();
31185
- ctx.moveTo(gx, 0);
31186
- ctx.lineTo(gx, canvasHeight);
31187
- ctx.stroke();
31188
- }
31189
- for (let gy = -camY % gridSize; gy < canvasHeight; gy += gridSize) {
31190
- ctx.beginPath();
31191
- ctx.moveTo(0, gy);
31192
- ctx.lineTo(canvasWidth, gy);
31193
- ctx.stroke();
31194
- }
31195
- for (const plat of platforms) {
31196
- const px = plat.x - camX;
31197
- const py = plat.y - camY;
31198
- const platType = plat.type ?? "ground";
31199
- const spriteUrl = tileSprites?.[platType];
31200
- const tileImg = spriteUrl ? loadImage(spriteUrl) : null;
31201
- if (tileImg) {
31202
- const tileW = tileImg.naturalWidth;
31203
- const tileH = tileImg.naturalHeight;
31204
- const scaleH = plat.height / tileH;
31205
- const scaledW = tileW * scaleH;
31206
- for (let tx = 0; tx < plat.width; tx += scaledW) {
31207
- const drawW = Math.min(scaledW, plat.width - tx);
31208
- const srcW = drawW / scaleH;
31209
- ctx.drawImage(tileImg, 0, 0, srcW, tileH, px + tx, py, drawW, plat.height);
31210
- }
31268
+ }, [canvasWidth, canvasHeight]);
31269
+ useEffect(() => {
31270
+ const drawFrame = (positions) => {
31271
+ const canvas = canvasRef.current;
31272
+ if (!canvas) return;
31273
+ const ctx = canvas.getContext("2d");
31274
+ if (!ctx) return;
31275
+ const {
31276
+ platforms: plats,
31277
+ worldWidth: ww,
31278
+ worldHeight: wh,
31279
+ canvasWidth: cw,
31280
+ canvasHeight: ch,
31281
+ followCamera: fc,
31282
+ bgColor: bg,
31283
+ playerSprite: pSprite,
31284
+ tileSprites: tSprites,
31285
+ backgroundImage: bgImg
31286
+ } = propsRef.current;
31287
+ const auth = playerRef.current;
31288
+ const interped = positions.get("player");
31289
+ const px = interped?.x ?? auth.x;
31290
+ const py = interped?.y ?? auth.y;
31291
+ let camX = 0;
31292
+ let camY = 0;
31293
+ if (fc) {
31294
+ camX = Math.max(0, Math.min(px - cw / 2, ww - cw));
31295
+ camY = Math.max(0, Math.min(py - ch / 2 - 50, wh - ch));
31296
+ }
31297
+ const bgImage = bgImg ? loadImage(bgImg) : null;
31298
+ if (bgImage) {
31299
+ ctx.drawImage(bgImage, 0, 0, cw, ch);
31300
+ } else if (bg) {
31301
+ ctx.fillStyle = bg;
31302
+ ctx.fillRect(0, 0, cw, ch);
31211
31303
  } else {
31212
- const color = PLATFORM_COLORS[platType] ?? PLATFORM_COLORS.ground;
31213
- ctx.fillStyle = color;
31214
- ctx.fillRect(px, py, plat.width, plat.height);
31215
- ctx.fillStyle = "rgba(255, 255, 255, 0.15)";
31216
- ctx.fillRect(px, py, plat.width, 3);
31217
- ctx.fillStyle = "rgba(0, 0, 0, 0.3)";
31218
- ctx.fillRect(px, py + plat.height - 2, plat.width, 2);
31219
- if (platType === "hazard") {
31220
- ctx.strokeStyle = "#e74c3c";
31221
- ctx.lineWidth = 2;
31222
- for (let sx = px; sx < px + plat.width; sx += 12) {
31304
+ const grad = ctx.createLinearGradient(0, 0, 0, ch);
31305
+ grad.addColorStop(0, SKY_GRADIENT_TOP);
31306
+ grad.addColorStop(1, SKY_GRADIENT_BOTTOM);
31307
+ ctx.fillStyle = grad;
31308
+ ctx.fillRect(0, 0, cw, ch);
31309
+ }
31310
+ ctx.strokeStyle = GRID_COLOR;
31311
+ ctx.lineWidth = 1;
31312
+ const gridSize = 32;
31313
+ for (let gx = -camX % gridSize; gx < cw; gx += gridSize) {
31314
+ ctx.beginPath();
31315
+ ctx.moveTo(gx, 0);
31316
+ ctx.lineTo(gx, ch);
31317
+ ctx.stroke();
31318
+ }
31319
+ for (let gy = -camY % gridSize; gy < ch; gy += gridSize) {
31320
+ ctx.beginPath();
31321
+ ctx.moveTo(0, gy);
31322
+ ctx.lineTo(cw, gy);
31323
+ ctx.stroke();
31324
+ }
31325
+ for (const plat of plats) {
31326
+ const platX = plat.x - camX;
31327
+ const platY = plat.y - camY;
31328
+ const platType = plat.type ?? "ground";
31329
+ const spriteUrl = tSprites?.[platType];
31330
+ const tileImg = spriteUrl ? loadImage(spriteUrl) : null;
31331
+ if (tileImg) {
31332
+ const tileW = tileImg.naturalWidth;
31333
+ const tileH = tileImg.naturalHeight;
31334
+ const scaleH = plat.height / tileH;
31335
+ const scaledW = tileW * scaleH;
31336
+ for (let tx = 0; tx < plat.width; tx += scaledW) {
31337
+ const drawW = Math.min(scaledW, plat.width - tx);
31338
+ const srcW = drawW / scaleH;
31339
+ ctx.drawImage(tileImg, 0, 0, srcW, tileH, platX + tx, platY, drawW, plat.height);
31340
+ }
31341
+ } else {
31342
+ const color = PLATFORM_COLORS[platType] ?? PLATFORM_COLORS.ground;
31343
+ ctx.fillStyle = color;
31344
+ ctx.fillRect(platX, platY, plat.width, plat.height);
31345
+ ctx.fillStyle = "rgba(255, 255, 255, 0.15)";
31346
+ ctx.fillRect(platX, platY, plat.width, 3);
31347
+ ctx.fillStyle = "rgba(0, 0, 0, 0.3)";
31348
+ ctx.fillRect(platX, platY + plat.height - 2, plat.width, 2);
31349
+ if (platType === "hazard") {
31350
+ ctx.strokeStyle = "#e74c3c";
31351
+ ctx.lineWidth = 2;
31352
+ for (let sx = platX; sx < platX + plat.width; sx += 12) {
31353
+ ctx.beginPath();
31354
+ ctx.moveTo(sx, platY);
31355
+ ctx.lineTo(sx + 6, platY + plat.height);
31356
+ ctx.stroke();
31357
+ }
31358
+ }
31359
+ if (platType === "goal") {
31360
+ ctx.fillStyle = "rgba(241, 196, 15, 0.5)";
31223
31361
  ctx.beginPath();
31224
- ctx.moveTo(sx, py);
31225
- ctx.lineTo(sx + 6, py + plat.height);
31226
- ctx.stroke();
31362
+ ctx.arc(platX + plat.width / 2, platY - 10, 8, 0, Math.PI * 2);
31363
+ ctx.fill();
31227
31364
  }
31228
31365
  }
31229
- if (platType === "goal") {
31230
- ctx.fillStyle = "rgba(241, 196, 15, 0.5)";
31231
- ctx.beginPath();
31232
- ctx.arc(px + plat.width / 2, py - 10, 8, 0, Math.PI * 2);
31233
- ctx.fill();
31234
- }
31235
31366
  }
31236
- }
31237
- const pw = resolvedPlayer.width ?? 24;
31238
- const ph = resolvedPlayer.height ?? 32;
31239
- const ppx = resolvedPlayer.x - camX;
31240
- const ppy = resolvedPlayer.y - camY;
31241
- const facingRight = resolvedPlayer.facingRight ?? true;
31242
- const playerImg = playerSprite ? loadImage(playerSprite) : null;
31243
- if (playerImg) {
31244
- ctx.save();
31245
- if (!facingRight) {
31246
- ctx.translate(ppx + pw, ppy);
31247
- ctx.scale(-1, 1);
31248
- ctx.drawImage(playerImg, 0, 0, pw, ph);
31367
+ const pw = auth.width ?? 24;
31368
+ const ph = auth.height ?? 32;
31369
+ const ppx = px - camX;
31370
+ const ppy = py - camY;
31371
+ const facingRight = auth.facingRight ?? true;
31372
+ const playerImg = pSprite ? loadImage(pSprite) : null;
31373
+ if (playerImg) {
31374
+ ctx.save();
31375
+ if (!facingRight) {
31376
+ ctx.translate(ppx + pw, ppy);
31377
+ ctx.scale(-1, 1);
31378
+ ctx.drawImage(playerImg, 0, 0, pw, ph);
31379
+ } else {
31380
+ ctx.drawImage(playerImg, ppx, ppy, pw, ph);
31381
+ }
31382
+ ctx.restore();
31249
31383
  } else {
31250
- ctx.drawImage(playerImg, ppx, ppy, pw, ph);
31384
+ ctx.fillStyle = PLAYER_COLOR;
31385
+ const radius = Math.min(pw, ph) * 0.25;
31386
+ ctx.beginPath();
31387
+ ctx.moveTo(ppx + radius, ppy);
31388
+ ctx.lineTo(ppx + pw - radius, ppy);
31389
+ ctx.quadraticCurveTo(ppx + pw, ppy, ppx + pw, ppy + radius);
31390
+ ctx.lineTo(ppx + pw, ppy + ph - radius);
31391
+ ctx.quadraticCurveTo(ppx + pw, ppy + ph, ppx + pw - radius, ppy + ph);
31392
+ ctx.lineTo(ppx + radius, ppy + ph);
31393
+ ctx.quadraticCurveTo(ppx, ppy + ph, ppx, ppy + ph - radius);
31394
+ ctx.lineTo(ppx, ppy + radius);
31395
+ ctx.quadraticCurveTo(ppx, ppy, ppx + radius, ppy);
31396
+ ctx.fill();
31397
+ const eyeY = ppy + ph * 0.3;
31398
+ const eyeSize = 3;
31399
+ const eyeOffsetX = facingRight ? pw * 0.55 : pw * 0.2;
31400
+ ctx.fillStyle = PLAYER_EYE_COLOR;
31401
+ ctx.beginPath();
31402
+ ctx.arc(ppx + eyeOffsetX, eyeY, eyeSize, 0, Math.PI * 2);
31403
+ ctx.fill();
31404
+ ctx.beginPath();
31405
+ ctx.arc(ppx + eyeOffsetX + 7, eyeY, eyeSize, 0, Math.PI * 2);
31406
+ ctx.fill();
31251
31407
  }
31252
- ctx.restore();
31253
- } else {
31254
- ctx.fillStyle = PLAYER_COLOR;
31255
- const radius = Math.min(pw, ph) * 0.25;
31256
- ctx.beginPath();
31257
- ctx.moveTo(ppx + radius, ppy);
31258
- ctx.lineTo(ppx + pw - radius, ppy);
31259
- ctx.quadraticCurveTo(ppx + pw, ppy, ppx + pw, ppy + radius);
31260
- ctx.lineTo(ppx + pw, ppy + ph - radius);
31261
- ctx.quadraticCurveTo(ppx + pw, ppy + ph, ppx + pw - radius, ppy + ph);
31262
- ctx.lineTo(ppx + radius, ppy + ph);
31263
- ctx.quadraticCurveTo(ppx, ppy + ph, ppx, ppy + ph - radius);
31264
- ctx.lineTo(ppx, ppy + radius);
31265
- ctx.quadraticCurveTo(ppx, ppy, ppx + radius, ppy);
31266
- ctx.fill();
31267
- const eyeY = ppy + ph * 0.3;
31268
- const eyeSize = 3;
31269
- const eyeOffsetX = facingRight ? pw * 0.55 : pw * 0.2;
31270
- ctx.fillStyle = PLAYER_EYE_COLOR;
31271
- ctx.beginPath();
31272
- ctx.arc(ppx + eyeOffsetX, eyeY, eyeSize, 0, Math.PI * 2);
31273
- ctx.fill();
31274
- ctx.beginPath();
31275
- ctx.arc(ppx + eyeOffsetX + 7, eyeY, eyeSize, 0, Math.PI * 2);
31276
- ctx.fill();
31277
- }
31278
- }, [player, platforms, worldWidth, worldHeight, canvasWidth, canvasHeight, followCamera, bgColor, playerSprite, tileSprites, backgroundImage, assetBaseUrl, loadedImages]);
31408
+ };
31409
+ return interp.startLoop(drawFrame);
31410
+ }, [interp.startLoop, loadImage]);
31279
31411
  return /* @__PURE__ */ jsx(
31280
31412
  "canvas",
31281
31413
  {
@@ -31293,6 +31425,7 @@ var init_PlatformerCanvas = __esm({
31293
31425
  init_cn();
31294
31426
  init_useEventBus();
31295
31427
  init_verificationRegistry();
31428
+ init_useRenderInterpolation();
31296
31429
  PLATFORM_COLORS = {
31297
31430
  ground: "#4a7c59",
31298
31431
  platform: "#7c6b4a",
@@ -31675,13 +31808,13 @@ var init_MapView = __esm({
31675
31808
  shadowSize: [41, 41]
31676
31809
  });
31677
31810
  L.Marker.prototype.options.icon = defaultIcon;
31678
- const { useEffect: useEffect78, useRef: useRef70, useCallback: useCallback117, useState: useState115 } = React91__default;
31811
+ const { useEffect: useEffect79, useRef: useRef71, useCallback: useCallback118, useState: useState115 } = React91__default;
31679
31812
  const { Typography: Typography2 } = await Promise.resolve().then(() => (init_Typography(), Typography_exports));
31680
31813
  const { useEventBus: useEventBus3 } = await Promise.resolve().then(() => (init_useEventBus(), useEventBus_exports));
31681
31814
  function MapUpdater({ centerLat, centerLng, zoom }) {
31682
31815
  const map = useMap();
31683
- const prevRef = useRef70({ centerLat, centerLng, zoom });
31684
- useEffect78(() => {
31816
+ const prevRef = useRef71({ centerLat, centerLng, zoom });
31817
+ useEffect79(() => {
31685
31818
  const prev = prevRef.current;
31686
31819
  if (prev.centerLat !== centerLat || prev.centerLng !== centerLng || prev.zoom !== zoom) {
31687
31820
  map.setView([centerLat, centerLng], zoom);
@@ -31692,7 +31825,7 @@ var init_MapView = __esm({
31692
31825
  }
31693
31826
  function MapClickHandler({ onMapClick }) {
31694
31827
  const map = useMap();
31695
- useEffect78(() => {
31828
+ useEffect79(() => {
31696
31829
  if (!onMapClick) return;
31697
31830
  const handler = (e) => {
31698
31831
  onMapClick(e.latlng.lat, e.latlng.lng);
@@ -31721,7 +31854,7 @@ var init_MapView = __esm({
31721
31854
  }) {
31722
31855
  const eventBus = useEventBus3();
31723
31856
  const [clickedPosition, setClickedPosition] = useState115(null);
31724
- const handleMapClick = useCallback117((lat, lng) => {
31857
+ const handleMapClick = useCallback118((lat, lng) => {
31725
31858
  if (showClickedPin) {
31726
31859
  setClickedPosition({ lat, lng });
31727
31860
  }
@@ -31730,7 +31863,7 @@ var init_MapView = __esm({
31730
31863
  eventBus.emit(`UI:${mapClickEvent}`, { latitude: lat, longitude: lng });
31731
31864
  }
31732
31865
  }, [onMapClick, mapClickEvent, eventBus, showClickedPin]);
31733
- const handleMarkerClick = useCallback117((marker) => {
31866
+ const handleMarkerClick = useCallback118((marker) => {
31734
31867
  onMarkerClick?.(marker);
31735
31868
  if (markerClickEvent) {
31736
31869
  eventBus.emit(`UI:${markerClickEvent}`, { ...marker });
@@ -47193,11 +47326,13 @@ function SimulationCanvas({
47193
47326
  height = 400,
47194
47327
  running,
47195
47328
  speed = 1,
47329
+ bodies: externalBodies,
47196
47330
  className
47197
47331
  }) {
47198
47332
  const preset = useMemo(() => resolvePreset(presetProp), [presetProp]);
47199
47333
  const canvasRef = useRef(null);
47200
47334
  const bodiesRef = useRef(structuredClone(preset.bodies));
47335
+ const interp = useRenderInterpolation();
47201
47336
  useEffect(() => {
47202
47337
  bodiesRef.current = structuredClone(preset.bodies);
47203
47338
  }, [preset]);
@@ -47295,6 +47430,67 @@ function SimulationCanvas({
47295
47430
  }
47296
47431
  }, [width, height, preset]);
47297
47432
  useEffect(() => {
47433
+ if (!externalBodies) return;
47434
+ interp.onSnapshot(externalBodies.map((b) => ({ id: b.id, x: b.x, y: b.y })));
47435
+ }, [externalBodies]);
47436
+ const presetRef = useRef(preset);
47437
+ presetRef.current = preset;
47438
+ const widthRef = useRef(width);
47439
+ widthRef.current = width;
47440
+ const heightRef = useRef(height);
47441
+ heightRef.current = height;
47442
+ useEffect(() => {
47443
+ if (!externalBodies) return;
47444
+ const drawInterpolated = (positions) => {
47445
+ const canvas = canvasRef.current;
47446
+ if (!canvas) return;
47447
+ const ctx = canvas.getContext("2d");
47448
+ if (!ctx) return;
47449
+ const bodies = bodiesRef.current;
47450
+ const p2 = presetRef.current;
47451
+ const w = widthRef.current;
47452
+ const h = heightRef.current;
47453
+ ctx.clearRect(0, 0, w, h);
47454
+ ctx.fillStyle = p2.backgroundColor ?? "#1a1a2e";
47455
+ ctx.fillRect(0, 0, w, h);
47456
+ if (p2.constraints) {
47457
+ for (const c of p2.constraints) {
47458
+ const a = bodies[c.bodyA];
47459
+ const b = bodies[c.bodyB];
47460
+ if (a && b) {
47461
+ const aPos = positions.get(a.id) ?? a;
47462
+ const bPos = positions.get(b.id) ?? b;
47463
+ ctx.beginPath();
47464
+ ctx.moveTo(aPos.x, aPos.y);
47465
+ ctx.lineTo(bPos.x, bPos.y);
47466
+ ctx.strokeStyle = "#533483";
47467
+ ctx.lineWidth = 1;
47468
+ ctx.setLineDash([4, 4]);
47469
+ ctx.stroke();
47470
+ ctx.setLineDash([]);
47471
+ }
47472
+ }
47473
+ }
47474
+ for (const body of bodies) {
47475
+ const pos = positions.get(body.id) ?? body;
47476
+ ctx.beginPath();
47477
+ ctx.arc(pos.x, pos.y, body.radius, 0, Math.PI * 2);
47478
+ ctx.fillStyle = body.color ?? "#e94560";
47479
+ ctx.fill();
47480
+ if (p2.showVelocity) {
47481
+ ctx.beginPath();
47482
+ ctx.moveTo(pos.x, pos.y);
47483
+ ctx.lineTo(pos.x + body.vx * 0.1, pos.y + body.vy * 0.1);
47484
+ ctx.strokeStyle = "#16213e";
47485
+ ctx.lineWidth = 2;
47486
+ ctx.stroke();
47487
+ }
47488
+ }
47489
+ };
47490
+ return interp.startLoop(drawInterpolated);
47491
+ }, [externalBodies !== void 0, interp.startLoop]);
47492
+ useEffect(() => {
47493
+ if (externalBodies !== void 0) return;
47298
47494
  if (!running) return;
47299
47495
  let raf;
47300
47496
  const loop = () => {
@@ -47304,10 +47500,11 @@ function SimulationCanvas({
47304
47500
  };
47305
47501
  raf = requestAnimationFrame(loop);
47306
47502
  return () => cancelAnimationFrame(raf);
47307
- }, [running, step, draw]);
47503
+ }, [running, step, draw, externalBodies]);
47308
47504
  useEffect(() => {
47505
+ if (externalBodies !== void 0) return;
47309
47506
  draw();
47310
- }, [draw]);
47507
+ }, [draw, externalBodies]);
47311
47508
  useEffect(() => {
47312
47509
  if (typeof window === "undefined") return;
47313
47510
  const canvas = canvasRef.current;
@@ -47333,6 +47530,7 @@ var init_SimulationCanvas = __esm({
47333
47530
  init_atoms2();
47334
47531
  init_verificationRegistry();
47335
47532
  init_presets();
47533
+ init_useRenderInterpolation();
47336
47534
  SimulationCanvas.displayName = "SimulationCanvas";
47337
47535
  }
47338
47536
  });
@@ -3,18 +3,24 @@
3
3
  *
4
4
  * Self-contained 2D physics canvas for educational presets.
5
5
  * Runs its own Euler integration loop — no external physics hook needed.
6
+ * When `bodies` prop is provided, an external model drives positions at ~30Hz
7
+ * and the interpolation bridge smooths them to 60fps.
6
8
  */
7
9
  import React from 'react';
8
- import type { PhysicsPreset } from './presets/types';
10
+ import type { PhysicsPreset, PhysicsBody } from './presets/types';
9
11
  export interface SimulationCanvasProps {
10
12
  preset: string | PhysicsPreset;
11
13
  width?: number;
12
14
  height?: number;
13
15
  running: boolean;
14
16
  speed?: number;
17
+ /** External model-authoritative body snapshots (~30Hz). When provided,
18
+ * the self-simulation loop is bypassed and positions are interpolated
19
+ * to 60fps between consecutive snapshots. */
20
+ bodies?: readonly Pick<PhysicsBody, 'id' | 'x' | 'y'>[];
15
21
  className?: string;
16
22
  }
17
- export declare function SimulationCanvas({ preset: presetProp, width, height, running, speed, className, }: SimulationCanvasProps): React.JSX.Element;
23
+ export declare function SimulationCanvas({ preset: presetProp, width, height, running, speed, bodies: externalBodies, className, }: SimulationCanvasProps): React.JSX.Element;
18
24
  export declare namespace SimulationCanvas {
19
25
  var displayName: string;
20
26
  }