@blorkfield/overlay-core 0.4.3 → 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.
package/README.md CHANGED
@@ -48,6 +48,15 @@ Static obstacles track how many dynamic objects are resting on them. When the ac
48
48
 
49
49
  The floor can be divided into independent segments, each with its own pressure threshold. When a segment receives too much weight, it collapses and objects fall through.
50
50
 
51
+ | Option | Description |
52
+ |--------|-------------|
53
+ | `thickness` | Segment height in pixels (single value or array per segment) |
54
+ | `color` | Segment fill color (single value or array per segment) - makes floor visible |
55
+ | `minIntegrity` | Minimum segments required. When remaining segments drop below this, all collapse |
56
+ | `segmentWidths` | Proportional widths for each segment (array that sums to 1.0, e.g., `[0.2, 0.3, 0.5]`) |
57
+
58
+ Example: With 10 segments and `minIntegrity: 7`, once 4 segments have collapsed (leaving 6), all remaining segments collapse together.
59
+
51
60
  ## Quick Start
52
61
 
53
62
  ```typescript
@@ -72,6 +81,8 @@ scene.start();
72
81
 
73
82
  ## Spawning Objects
74
83
 
84
+ All objects are created through `spawnObject()` (or `spawnObjectAsync()` for images). The same config supports canvas-rendered shapes, image-based shapes, and DOM elements.
85
+
75
86
  ### Basic Shapes
76
87
 
77
88
  ```typescript
@@ -118,6 +129,57 @@ const id = await scene.spawnObjectAsync({
118
129
  });
119
130
  ```
120
131
 
132
+ ### DOM Elements
133
+
134
+ Pass a DOM element via the `element` property to link it to physics. The element will move with the physics body when it becomes dynamic.
135
+
136
+ ```typescript
137
+ const contentBox = document.getElementById('content-box');
138
+
139
+ scene.spawnObject({
140
+ element: contentBox,
141
+ x: boxX,
142
+ y: boxY,
143
+ width: contentBox.offsetWidth,
144
+ height: contentBox.offsetHeight,
145
+ tags: ['grabable'],
146
+ pressureThreshold: { value: 50 },
147
+ shadow: { opacity: 0.3 },
148
+ clickToFall: { clicks: 5 }
149
+ });
150
+ ```
151
+
152
+ When a DOM element collapses:
153
+ - The element's CSS transform is updated each frame to follow physics
154
+ - Shadow creates a cloned DOM element at the original position
155
+
156
+ ### Pressure, Shadow, and Click Behavior
157
+
158
+ These options work on any spawned object (shapes, images, or DOM elements):
159
+
160
+ ```typescript
161
+ scene.spawnObject({
162
+ x: 200,
163
+ y: 300,
164
+ width: 150,
165
+ height: 30,
166
+ fillStyle: '#333',
167
+ tags: ['grabable'],
168
+
169
+ // Collapse when 20 units of pressure accumulate
170
+ pressureThreshold: { value: 20 },
171
+
172
+ // This object contributes 5 pressure when resting on something
173
+ weight: 5,
174
+
175
+ // Leave a faded copy when collapsed (true = 0.3 opacity default)
176
+ shadow: { opacity: 0.3 },
177
+
178
+ // Collapse after being clicked 3 times
179
+ clickToFall: { clicks: 3 }
180
+ });
181
+ ```
182
+
121
183
  ## Text Obstacles
122
184
 
123
185
  ### PNG Based Text
@@ -263,7 +325,10 @@ const scene = new OverlayScene(canvas, {
263
325
  background: '#16213e',
264
326
  floorConfig: {
265
327
  segments: 10,
266
- threshold: 100
328
+ threshold: 100,
329
+ thickness: 20,
330
+ color: '#3a4a6a', // Makes floor visible
331
+ minIntegrity: 7 // All collapse if fewer than 7 remain
267
332
  },
268
333
  despawnBelowFloor: 1.0
269
334
  });
@@ -276,7 +341,11 @@ const scene = new OverlayScene(canvas, {
276
341
  | `debug` | false | Show collision wireframes |
277
342
  | `background` | transparent | Canvas background color |
278
343
  | `floorConfig.segments` | 1 | Number of floor segments |
279
- | `floorConfig.threshold` | none | Pressure threshold for floor collapse |
344
+ | `floorConfig.threshold` | none | Pressure threshold for collapse (number or array per segment) |
345
+ | `floorConfig.thickness` | 50 | Floor thickness in pixels (number or array per segment) |
346
+ | `floorConfig.color` | none | Floor color - makes segments visible (string or array per segment) |
347
+ | `floorConfig.minIntegrity` | none | Minimum segments required, otherwise all collapse |
348
+ | `floorConfig.segmentWidths` | none | Proportional widths for each segment (array that sums to 1.0) |
280
349
  | `despawnBelowFloor` | 1.0 | Distance below floor to despawn objects (as fraction of height) |
281
350
 
282
351
  ## Pressure Tracking
package/dist/index.cjs CHANGED
@@ -508,7 +508,7 @@ function createBodyFromVertices(id, x, y, vertices, renderOptions) {
508
508
  function createBoundariesWithFloorConfig(bounds, floorConfig) {
509
509
  const width = bounds.right - bounds.left;
510
510
  const height = bounds.bottom - bounds.top;
511
- const options = { isStatic: true, render: { visible: false } };
511
+ const wallOptions = { isStatic: true, render: { visible: false } };
512
512
  const walls = [
513
513
  // Left wall
514
514
  import_matter_js2.default.Bodies.rectangle(
@@ -516,7 +516,7 @@ function createBoundariesWithFloorConfig(bounds, floorConfig) {
516
516
  bounds.top + height / 2,
517
517
  BOUNDARY_THICKNESS,
518
518
  height,
519
- { ...options, label: "leftWall" }
519
+ { ...wallOptions, label: "leftWall" }
520
520
  ),
521
521
  // Right wall
522
522
  import_matter_js2.default.Bodies.rectangle(
@@ -524,23 +524,44 @@ function createBoundariesWithFloorConfig(bounds, floorConfig) {
524
524
  bounds.top + height / 2,
525
525
  BOUNDARY_THICKNESS,
526
526
  height,
527
- { ...options, label: "rightWall" }
527
+ { ...wallOptions, label: "rightWall" }
528
528
  )
529
529
  ];
530
530
  const segmentCount = floorConfig?.segments ?? 1;
531
- const segmentWidth = width / segmentCount;
532
531
  const floorSegments = [];
532
+ let segmentWidths;
533
+ if (floorConfig?.segmentWidths && floorConfig.segmentWidths.length === segmentCount) {
534
+ const sum = floorConfig.segmentWidths.reduce((a, b) => a + b, 0);
535
+ segmentWidths = floorConfig.segmentWidths.map((w) => w / sum * width);
536
+ } else {
537
+ const equalWidth = width / segmentCount;
538
+ segmentWidths = Array(segmentCount).fill(equalWidth);
539
+ }
540
+ let currentX = bounds.left;
533
541
  for (let i = 0; i < segmentCount; i++) {
534
- const segmentX = bounds.left + (i + 0.5) * segmentWidth;
542
+ const segmentWidth = segmentWidths[i];
543
+ const thickness = floorConfig?.thickness !== void 0 ? Array.isArray(floorConfig.thickness) ? floorConfig.thickness[i] ?? BOUNDARY_THICKNESS : floorConfig.thickness : BOUNDARY_THICKNESS;
544
+ const color = floorConfig?.color !== void 0 ? Array.isArray(floorConfig.color) ? floorConfig.color[i] : floorConfig.color : void 0;
545
+ const segmentX = currentX + segmentWidth / 2;
546
+ const segmentY = bounds.bottom - thickness / 2;
547
+ const segmentOptions = {
548
+ isStatic: true,
549
+ label: `floor-segment-${i}`,
550
+ render: {
551
+ visible: color !== void 0,
552
+ fillStyle: color ?? "#888888"
553
+ }
554
+ };
535
555
  floorSegments.push(
536
556
  import_matter_js2.default.Bodies.rectangle(
537
557
  segmentX,
538
- bounds.bottom + BOUNDARY_THICKNESS / 2,
558
+ segmentY,
539
559
  segmentWidth,
540
- BOUNDARY_THICKNESS,
541
- { ...options, label: `floor-segment-${i}` }
560
+ thickness,
561
+ segmentOptions
542
562
  )
543
563
  );
564
+ currentX += segmentWidth;
544
565
  }
545
566
  return { walls, floorSegments };
546
567
  }
@@ -1204,6 +1225,9 @@ var OverlayScene = class {
1204
1225
  if (this.config.wrapHorizontal && entry.tags.includes("falling")) {
1205
1226
  wrapHorizontal(entry.body, this.config.bounds);
1206
1227
  }
1228
+ if (entry.domElement && entry.tags.includes("falling")) {
1229
+ this.updateDOMElementTransform(entry);
1230
+ }
1207
1231
  }
1208
1232
  if (!this.config.debug) {
1209
1233
  this.drawTTFGlyphs();
@@ -1229,6 +1253,7 @@ var OverlayScene = class {
1229
1253
  this.boundaries = [...boundariesResult.walls, ...boundariesResult.floorSegments];
1230
1254
  this.floorSegments = boundariesResult.floorSegments;
1231
1255
  import_matter_js5.default.Composite.add(this.engine.world, this.boundaries);
1256
+ this.checkInitialFloorIntegrity();
1232
1257
  this.mouse = import_matter_js5.default.Mouse.create(canvas);
1233
1258
  this.mouseConstraint = import_matter_js5.default.MouseConstraint.create(this.engine, {
1234
1259
  mouse: this.mouse,
@@ -1347,7 +1372,8 @@ var OverlayScene = class {
1347
1372
  if (onObstacles.has(dyn.id)) continue;
1348
1373
  const dynBounds = dyn.body.bounds;
1349
1374
  const horizontalOverlap = dynBounds.max.x > segmentBounds.min.x && dynBounds.min.x < segmentBounds.max.x;
1350
- if (horizontalOverlap) {
1375
+ const nearFloor = dynBounds.max.y >= segmentBounds.min.y - 10;
1376
+ if (horizontalOverlap && nearFloor) {
1351
1377
  resting.add(dyn.id);
1352
1378
  }
1353
1379
  }
@@ -1373,17 +1399,50 @@ var OverlayScene = class {
1373
1399
  const objectIds = this.floorSegmentPressure.get(i);
1374
1400
  const pressure = objectIds ? this.calculateWeightedPressure(objectIds) : 0;
1375
1401
  if (pressure >= threshold) {
1376
- this.collapseFloorSegment(i, pressure, threshold);
1402
+ this.collapseFloorSegment(i, `pressure ${pressure} >= threshold ${threshold}`);
1377
1403
  }
1378
1404
  }
1379
1405
  }
1380
1406
  /** Collapse a single floor segment */
1381
- collapseFloorSegment(index, pressure, threshold) {
1407
+ collapseFloorSegment(index, reason) {
1382
1408
  if (this.collapsedSegments.has(index)) return;
1383
1409
  this.collapsedSegments.add(index);
1384
1410
  const segment = this.floorSegments[index];
1385
1411
  import_matter_js5.default.Composite.remove(this.engine.world, segment);
1386
- console.log(`[Pressure] Floor segment ${index} collapsed! (pressure: ${pressure} >= ${threshold})`);
1412
+ logger.debug("OverlayScene", `Floor segment ${index} collapsed: ${reason}`);
1413
+ this.checkFloorIntegrity();
1414
+ }
1415
+ /** Check if floor integrity requirement is violated and collapse all remaining if so */
1416
+ checkFloorIntegrity() {
1417
+ const minIntegrity = this.config.floorConfig?.minIntegrity;
1418
+ if (minIntegrity === void 0) return;
1419
+ const totalSegments = this.floorSegments.length;
1420
+ const remainingSegments = totalSegments - this.collapsedSegments.size;
1421
+ if (remainingSegments < minIntegrity && remainingSegments > 0) {
1422
+ logger.debug("OverlayScene", `Floor integrity failed: ${remainingSegments} remaining < ${minIntegrity} required. Collapsing all.`);
1423
+ for (let i = 0; i < totalSegments; i++) {
1424
+ if (!this.collapsedSegments.has(i)) {
1425
+ this.collapsedSegments.add(i);
1426
+ const segment = this.floorSegments[i];
1427
+ import_matter_js5.default.Composite.remove(this.engine.world, segment);
1428
+ logger.debug("OverlayScene", `Floor segment ${i} collapsed: integrity failure cascade`);
1429
+ }
1430
+ }
1431
+ }
1432
+ }
1433
+ /** Check floor integrity on initialization (handles minIntegrity > segments) */
1434
+ checkInitialFloorIntegrity() {
1435
+ const minIntegrity = this.config.floorConfig?.minIntegrity;
1436
+ if (minIntegrity === void 0) return;
1437
+ const totalSegments = this.floorSegments.length;
1438
+ if (totalSegments < minIntegrity) {
1439
+ logger.debug("OverlayScene", `Floor integrity impossible: ${totalSegments} segments < ${minIntegrity} required. Collapsing all immediately.`);
1440
+ for (let i = 0; i < totalSegments; i++) {
1441
+ this.collapsedSegments.add(i);
1442
+ const segment = this.floorSegments[i];
1443
+ import_matter_js5.default.Composite.remove(this.engine.world, segment);
1444
+ }
1445
+ }
1387
1446
  }
1388
1447
  /** Log a summary of pressure on all obstacles, grouped by word */
1389
1448
  logPressureSummary() {
@@ -1498,6 +1557,15 @@ var OverlayScene = class {
1498
1557
  if (!entry.originalPosition) return;
1499
1558
  const opacity = entry.shadow?.opacity ?? 0.3;
1500
1559
  const shadowId = `shadow-${entry.id}`;
1560
+ if (entry.domElement) {
1561
+ const shadowElement = entry.domElement.cloneNode(true);
1562
+ shadowElement.style.opacity = String(opacity);
1563
+ shadowElement.style.pointerEvents = "none";
1564
+ shadowElement.style.transform = entry.domOriginalTransform || "";
1565
+ entry.domElement.parentNode?.insertBefore(shadowElement, entry.domElement);
1566
+ entry.domShadowElement = shadowElement;
1567
+ return;
1568
+ }
1501
1569
  if (entry.ttfGlyph) {
1502
1570
  const body = import_matter_js5.default.Bodies.circle(entry.originalPosition.x, entry.originalPosition.y, 1, {
1503
1571
  isStatic: true,
@@ -1630,6 +1698,7 @@ var OverlayScene = class {
1630
1698
  this.collapsedSegments.clear();
1631
1699
  this.floorSegmentPressure.clear();
1632
1700
  import_matter_js5.default.Composite.add(this.engine.world, this.boundaries);
1701
+ this.checkInitialFloorIntegrity();
1633
1702
  this.render.options.width = width;
1634
1703
  this.render.options.height = height;
1635
1704
  this.render.canvas.width = width;
@@ -1646,6 +1715,21 @@ var OverlayScene = class {
1646
1715
  * Without 'falling' tag, object is static.
1647
1716
  */
1648
1717
  spawnObject(config) {
1718
+ if (config.element) {
1719
+ const result = this.addDOMObstacleInternal({
1720
+ element: config.element,
1721
+ x: config.x,
1722
+ y: config.y,
1723
+ width: config.width,
1724
+ height: config.height,
1725
+ tags: config.tags,
1726
+ pressureThreshold: config.pressureThreshold,
1727
+ weight: config.weight,
1728
+ shadow: config.shadow === true ? { opacity: 0.3 } : config.shadow || void 0,
1729
+ clickToFall: config.clickToFall
1730
+ });
1731
+ return result.id;
1732
+ }
1649
1733
  const id = crypto.randomUUID();
1650
1734
  const tags = config.tags ?? [];
1651
1735
  const isStatic = !tags.includes("falling");
@@ -1665,6 +1749,17 @@ var OverlayScene = class {
1665
1749
  } else {
1666
1750
  body = createObstacle(id, config, isStatic);
1667
1751
  }
1752
+ let pressureThreshold;
1753
+ if (config.pressureThreshold) {
1754
+ pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
1755
+ }
1756
+ let shadow;
1757
+ if (config.shadow === true) {
1758
+ shadow = { opacity: 0.3 };
1759
+ } else if (config.shadow && typeof config.shadow === "object") {
1760
+ shadow = { opacity: config.shadow.opacity ?? 0.3 };
1761
+ }
1762
+ const clicksRemaining = config.clickToFall?.clicks;
1668
1763
  const entry = {
1669
1764
  id,
1670
1765
  body,
@@ -1672,10 +1767,17 @@ var OverlayScene = class {
1672
1767
  spawnTime: performance.now(),
1673
1768
  ttl: config.ttl,
1674
1769
  despawnEffect: config.despawnEffect,
1675
- weight: config.weight ?? 1
1770
+ weight: config.weight ?? 1,
1771
+ pressureThreshold,
1772
+ shadow,
1773
+ originalPosition: shadow || clicksRemaining !== void 0 ? { x: config.x, y: config.y } : void 0,
1774
+ clicksRemaining
1676
1775
  };
1677
1776
  this.objects.set(id, entry);
1678
1777
  import_matter_js5.default.Composite.add(this.engine.world, body);
1778
+ if (isStatic && pressureThreshold !== void 0) {
1779
+ this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
1780
+ }
1679
1781
  return id;
1680
1782
  }
1681
1783
  /**
@@ -1702,6 +1804,17 @@ var OverlayScene = class {
1702
1804
  } else {
1703
1805
  body = await createObstacleAsync(id, config, isStatic);
1704
1806
  }
1807
+ let pressureThreshold;
1808
+ if (config.pressureThreshold) {
1809
+ pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
1810
+ }
1811
+ let shadow;
1812
+ if (config.shadow === true) {
1813
+ shadow = { opacity: 0.3 };
1814
+ } else if (config.shadow && typeof config.shadow === "object") {
1815
+ shadow = { opacity: config.shadow.opacity ?? 0.3 };
1816
+ }
1817
+ const clicksRemaining = config.clickToFall?.clicks;
1705
1818
  const entry = {
1706
1819
  id,
1707
1820
  body,
@@ -1709,10 +1822,17 @@ var OverlayScene = class {
1709
1822
  spawnTime: performance.now(),
1710
1823
  ttl: config.ttl,
1711
1824
  despawnEffect: config.despawnEffect,
1712
- weight: config.weight ?? 1
1825
+ weight: config.weight ?? 1,
1826
+ pressureThreshold,
1827
+ shadow,
1828
+ originalPosition: shadow || clicksRemaining !== void 0 ? { x: config.x, y: config.y } : void 0,
1829
+ clicksRemaining
1713
1830
  };
1714
1831
  this.objects.set(id, entry);
1715
1832
  import_matter_js5.default.Composite.add(this.engine.world, body);
1833
+ if (isStatic && pressureThreshold !== void 0) {
1834
+ this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
1835
+ }
1716
1836
  return id;
1717
1837
  }
1718
1838
  /**
@@ -1971,6 +2091,80 @@ var OverlayScene = class {
1971
2091
  areFontsInitialized() {
1972
2092
  return this.fontsInitialized;
1973
2093
  }
2094
+ // ==================== DOM OBSTACLE METHODS (INTERNAL) ====================
2095
+ /**
2096
+ * Internal: Attach a DOM element to physics.
2097
+ * Called by spawnObject when element is provided.
2098
+ */
2099
+ addDOMObstacleInternal(config) {
2100
+ const { element, x, y } = config;
2101
+ const width = config.width ?? element.offsetWidth;
2102
+ const height = config.height ?? element.offsetHeight;
2103
+ const tags = config.tags ?? [];
2104
+ const isStatic = !tags.includes("falling");
2105
+ const body = import_matter_js5.default.Bodies.rectangle(x, y, width, height, {
2106
+ isStatic,
2107
+ label: `dom-${crypto.randomUUID().slice(0, 8)}`,
2108
+ render: { visible: false }
2109
+ // Don't render the body, DOM element is the visual
2110
+ });
2111
+ const id = body.label;
2112
+ let pressureThreshold;
2113
+ if (config.pressureThreshold) {
2114
+ pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
2115
+ }
2116
+ const shadow = config.shadow ? { opacity: config.shadow.opacity ?? 0.3 } : void 0;
2117
+ const clicksRemaining = config.clickToFall?.clicks;
2118
+ const originalTransform = element.style.transform || "";
2119
+ element.style.position = "absolute";
2120
+ element.style.transformOrigin = "center center";
2121
+ const entry = {
2122
+ id,
2123
+ body,
2124
+ tags,
2125
+ spawnTime: performance.now(),
2126
+ pressureThreshold,
2127
+ weight: config.weight ?? 1,
2128
+ shadow,
2129
+ originalPosition: shadow || clicksRemaining !== void 0 ? { x, y } : void 0,
2130
+ clicksRemaining,
2131
+ domElement: element,
2132
+ domOriginalTransform: originalTransform
2133
+ };
2134
+ this.objects.set(id, entry);
2135
+ import_matter_js5.default.Composite.add(this.engine.world, body);
2136
+ this.updateDOMElementTransform(entry);
2137
+ if (isStatic && pressureThreshold !== void 0) {
2138
+ this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
2139
+ }
2140
+ if (clicksRemaining !== void 0) {
2141
+ const clickHandler = () => {
2142
+ const currentEntry = this.objects.get(id);
2143
+ if (!currentEntry) return;
2144
+ if (currentEntry.tags.includes("falling")) return;
2145
+ if (currentEntry.clicksRemaining === void 0) return;
2146
+ currentEntry.clicksRemaining--;
2147
+ logger.debug("OverlayScene", `Click on DOM element: ${currentEntry.clicksRemaining} clicks remaining`);
2148
+ if (currentEntry.clicksRemaining <= 0) {
2149
+ this.collapseObstacle(currentEntry);
2150
+ element.removeEventListener("click", clickHandler);
2151
+ }
2152
+ };
2153
+ element.addEventListener("click", clickHandler);
2154
+ }
2155
+ return {
2156
+ id,
2157
+ shadowElement: null
2158
+ // Will be populated on collapse
2159
+ };
2160
+ }
2161
+ /**
2162
+ * Get the shadow element for a DOM obstacle (available after collapse).
2163
+ */
2164
+ getDOMObstacleShadow(id) {
2165
+ const entry = this.objects.get(id);
2166
+ return entry?.domShadowElement ?? null;
2167
+ }
1974
2168
  // ==================== TEXT OBSTACLE METHODS ====================
1975
2169
  /**
1976
2170
  * Create text obstacles from a string. Each character becomes an individual obstacle
@@ -2046,11 +2240,61 @@ var OverlayScene = class {
2046
2240
  maxDimension = Math.max(maxDimension, dims.width, dims.height);
2047
2241
  }
2048
2242
  if (maxDimension === 0) maxDimension = 100;
2243
+ const calculateLineWidth = (line) => {
2244
+ let width = 0;
2245
+ for (const char of line) {
2246
+ if (char === " ") {
2247
+ width += 20;
2248
+ } else if (/^[A-Za-z0-9]$/.test(char)) {
2249
+ const dims = charDimensions.get(char);
2250
+ if (dims) {
2251
+ const scale = letterSize / Math.max(dims.width, dims.height);
2252
+ const scaledWidth = dims.width * scale;
2253
+ const extraSpacing = config.letterSpacing !== void 0 ? config.letterSpacing - scaledWidth : 0;
2254
+ width += scaledWidth + Math.max(0, extraSpacing);
2255
+ }
2256
+ }
2257
+ }
2258
+ return width;
2259
+ };
2260
+ const lineWidths = lines.map((line) => calculateLineWidth(line));
2261
+ const align = config.align ?? "left";
2262
+ const maxLineWidth = Math.max(...lineWidths, 0);
2263
+ let boundsLeft;
2264
+ let boundsRight;
2265
+ switch (align) {
2266
+ case "center":
2267
+ boundsLeft = config.x - maxLineWidth / 2;
2268
+ boundsRight = config.x + maxLineWidth / 2;
2269
+ break;
2270
+ case "right":
2271
+ boundsLeft = config.x - maxLineWidth;
2272
+ boundsRight = config.x;
2273
+ break;
2274
+ default:
2275
+ boundsLeft = config.x;
2276
+ boundsRight = config.x + maxLineWidth;
2277
+ }
2278
+ const boundsTop = config.y - letterSize / 2;
2279
+ const totalHeight = lines.length > 0 ? (lines.length - 1) * lineHeight + letterSize : 0;
2280
+ const boundsBottom = boundsTop + totalHeight;
2049
2281
  let currentY = config.y;
2050
2282
  let globalCharIndex = 0;
2051
- for (const line of lines) {
2283
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
2284
+ const line = lines[lineIndex];
2052
2285
  const chars = line.split("");
2053
- let currentX = config.x;
2286
+ const lineWidth = lineWidths[lineIndex];
2287
+ let currentX;
2288
+ switch (align) {
2289
+ case "center":
2290
+ currentX = config.x - lineWidth / 2;
2291
+ break;
2292
+ case "right":
2293
+ currentX = config.x - lineWidth;
2294
+ break;
2295
+ default:
2296
+ currentX = config.x;
2297
+ }
2054
2298
  if (inWord) {
2055
2299
  currentWordIndex++;
2056
2300
  inWord = false;
@@ -2166,12 +2410,21 @@ var OverlayScene = class {
2166
2410
  letterColor,
2167
2411
  lineCount: lines.length
2168
2412
  });
2413
+ const bounds = {
2414
+ left: boundsLeft,
2415
+ right: boundsRight,
2416
+ top: boundsTop,
2417
+ bottom: boundsBottom,
2418
+ width: boundsRight - boundsLeft,
2419
+ height: boundsBottom - boundsTop
2420
+ };
2169
2421
  return {
2170
2422
  letterIds,
2171
2423
  stringTag,
2172
2424
  wordTags,
2173
2425
  letterMap,
2174
- letterDebugInfo: debugInfo
2426
+ letterDebugInfo: debugInfo,
2427
+ bounds
2175
2428
  };
2176
2429
  }
2177
2430
  /**
@@ -2244,10 +2497,43 @@ var OverlayScene = class {
2244
2497
  const fontInfo = this.fonts.find((f) => f.fontUrl === fontUrl);
2245
2498
  const fontFamily = fontInfo?.name ?? "sans-serif";
2246
2499
  const lines = text.split("\n");
2500
+ const lineWidths = lines.map((line) => measureText(loadedFont, line, fontSize));
2501
+ const align = config.align ?? "left";
2502
+ const maxLineWidth = Math.max(...lineWidths, 0);
2503
+ let boundsLeft;
2504
+ let boundsRight;
2505
+ switch (align) {
2506
+ case "center":
2507
+ boundsLeft = x - maxLineWidth / 2;
2508
+ boundsRight = x + maxLineWidth / 2;
2509
+ break;
2510
+ case "right":
2511
+ boundsLeft = x - maxLineWidth;
2512
+ boundsRight = x;
2513
+ break;
2514
+ default:
2515
+ boundsLeft = x;
2516
+ boundsRight = x + maxLineWidth;
2517
+ }
2518
+ const boundsTop = y - fontSize * 0.8;
2519
+ const totalHeight = lines.length > 0 ? (lines.length - 1) * lineHeight + fontSize : 0;
2520
+ const boundsBottom = boundsTop + totalHeight;
2247
2521
  let currentY = y;
2248
2522
  let globalCharIndex = 0;
2249
- for (const line of lines) {
2250
- let currentX = x;
2523
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
2524
+ const line = lines[lineIndex];
2525
+ const lineWidth = lineWidths[lineIndex];
2526
+ let currentX;
2527
+ switch (align) {
2528
+ case "center":
2529
+ currentX = x - lineWidth / 2;
2530
+ break;
2531
+ case "right":
2532
+ currentX = x - lineWidth;
2533
+ break;
2534
+ default:
2535
+ currentX = x;
2536
+ }
2251
2537
  if (inWord) {
2252
2538
  currentWordIndex++;
2253
2539
  inWord = false;
@@ -2357,12 +2643,21 @@ var OverlayScene = class {
2357
2643
  wordTags,
2358
2644
  lineCount: lines.length
2359
2645
  });
2646
+ const bounds = {
2647
+ left: boundsLeft,
2648
+ right: boundsRight,
2649
+ top: boundsTop,
2650
+ bottom: boundsBottom,
2651
+ width: boundsRight - boundsLeft,
2652
+ height: boundsBottom - boundsTop
2653
+ };
2360
2654
  return {
2361
2655
  letterIds,
2362
2656
  stringTag,
2363
2657
  wordTags,
2364
2658
  letterMap,
2365
- letterDebugInfo: []
2659
+ letterDebugInfo: [],
2660
+ bounds
2366
2661
  };
2367
2662
  }
2368
2663
  /**
@@ -2467,6 +2762,22 @@ var OverlayScene = class {
2467
2762
  ctx.restore();
2468
2763
  }
2469
2764
  }
2765
+ /**
2766
+ * Update a DOM element's CSS transform to match its physics body position and rotation.
2767
+ */
2768
+ updateDOMElementTransform(entry) {
2769
+ if (!entry.domElement) return;
2770
+ const body = entry.body;
2771
+ const x = body.position.x;
2772
+ const y = body.position.y;
2773
+ const angle = body.angle;
2774
+ const angleDeg = angle * (180 / Math.PI);
2775
+ const width = entry.domElement.offsetWidth;
2776
+ const height = entry.domElement.offsetHeight;
2777
+ entry.domElement.style.left = `${x - width / 2}px`;
2778
+ entry.domElement.style.top = `${y - height / 2}px`;
2779
+ entry.domElement.style.transform = `rotate(${angleDeg}deg)`;
2780
+ }
2470
2781
  checkTTLExpiration() {
2471
2782
  const now = performance.now();
2472
2783
  const expiredObjects = [];