@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 +71 -2
- package/dist/index.cjs +331 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +86 -3
- package/dist/index.d.ts +86 -3
- package/dist/index.js +331 -20
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
{ ...
|
|
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
|
-
{ ...
|
|
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
|
|
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
|
-
|
|
558
|
+
segmentY,
|
|
539
559
|
segmentWidth,
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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
|
|
1402
|
+
this.collapseFloorSegment(i, `pressure ${pressure} >= threshold ${threshold}`);
|
|
1377
1403
|
}
|
|
1378
1404
|
}
|
|
1379
1405
|
}
|
|
1380
1406
|
/** Collapse a single floor segment */
|
|
1381
|
-
collapseFloorSegment(index,
|
|
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
|
-
|
|
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 (
|
|
2283
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
2284
|
+
const line = lines[lineIndex];
|
|
2052
2285
|
const chars = line.split("");
|
|
2053
|
-
|
|
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 (
|
|
2250
|
-
|
|
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 = [];
|