@blorkfield/overlay-core 0.8.11 → 0.10.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/index.js CHANGED
@@ -5,7 +5,8 @@ import Matter5 from "matter-js";
5
5
  import Matter from "matter-js";
6
6
  function createEngine(gravity) {
7
7
  const engine = Matter.Engine.create();
8
- engine.gravity.y = gravity;
8
+ engine.gravity.x = gravity.x;
9
+ engine.gravity.y = -gravity.y;
9
10
  return engine;
10
11
  }
11
12
  function createRender(engine, canvas, config) {
@@ -455,6 +456,7 @@ function createBodyFromVertices(id, x, y, vertices, renderOptions) {
455
456
  restitution: 0.3,
456
457
  friction: 0.1,
457
458
  frictionAir: 0.01,
459
+ density: 5e-3,
458
460
  label: `entity:${id}`,
459
461
  render: renderOptions
460
462
  });
@@ -547,6 +549,7 @@ function createCircleEntity(id, config) {
547
549
  restitution: 0.3,
548
550
  friction: 0.1,
549
551
  frictionAir: 0.01,
552
+ density: 5e-3,
550
553
  label: `entity:${id}`,
551
554
  render: createFillRenderOptions(config)
552
555
  });
@@ -558,6 +561,7 @@ function createCircleEntityWithSprite(id, config, imageWidth, imageHeight) {
558
561
  restitution: 0.3,
559
562
  friction: 0.1,
560
563
  frictionAir: 0.01,
564
+ density: 5e-3,
561
565
  label: `entity:${id}`,
562
566
  render: createSpriteRenderOptions(config, imageWidth, imageHeight)
563
567
  });
@@ -988,8 +992,7 @@ var EffectManager = class {
988
992
  const objectConfig = this.selectObjectConfig(config.objectConfigs);
989
993
  if (!objectConfig) continue;
990
994
  const radius = this.calculateRadius(objectConfig);
991
- const tags = [...objectConfig.objectConfig.tags ?? []];
992
- if (!tags.includes("falling")) tags.push("falling");
995
+ const tags = (objectConfig.objectConfig.tags ?? []).filter((t) => t !== "static");
993
996
  const fullConfig = {
994
997
  ...objectConfig.objectConfig,
995
998
  x: originX,
@@ -1028,8 +1031,7 @@ var EffectManager = class {
1028
1031
  const radius = this.calculateRadius(objectConfig);
1029
1032
  const x = this.randomInRange(spawnAreaStart + radius, spawnAreaStart + spawnAreaWidth - radius);
1030
1033
  const y = bounds.top - radius;
1031
- const tags = [...objectConfig.objectConfig.tags ?? []];
1032
- if (!tags.includes("falling")) tags.push("falling");
1034
+ const tags = (objectConfig.objectConfig.tags ?? []).filter((t) => t !== "static");
1033
1035
  const fullConfig = {
1034
1036
  ...objectConfig.objectConfig,
1035
1037
  x,
@@ -1054,8 +1056,7 @@ var EffectManager = class {
1054
1056
  const objectConfig = this.selectObjectConfig(config.objectConfigs);
1055
1057
  if (!objectConfig) return;
1056
1058
  const radius = this.calculateRadius(objectConfig);
1057
- const tags = [...objectConfig.objectConfig.tags ?? []];
1058
- if (!tags.includes("falling")) tags.push("falling");
1059
+ const tags = (objectConfig.objectConfig.tags ?? []).filter((t) => t !== "static");
1059
1060
  const fullConfig = {
1060
1061
  ...objectConfig.objectConfig,
1061
1062
  x: config.origin.x,
@@ -1407,6 +1408,12 @@ var OverlayScene = class {
1407
1408
  this.grabHistoryRadius = 20;
1408
1409
  // Number of physics substeps per frame — more substeps = better collision at high speeds, more CPU
1409
1410
  this.substeps = 2;
1411
+ // Tracks only the bodies with a gravity override — engine gravity handles everyone else
1412
+ this.gravityOverrideEntries = /* @__PURE__ */ new Set();
1413
+ // Tracks only the bodies with follow_window tag — avoids scanning all objects each frame
1414
+ this.followWindowEntries = /* @__PURE__ */ new Set();
1415
+ // IDs of static objects that have pressure thresholds — empty = skip updatePressure entirely
1416
+ this.pressureObstacleIds = /* @__PURE__ */ new Set();
1410
1417
  /** Handle mouse down - start grab via programmatic API */
1411
1418
  this.handleMouseDown = (event) => {
1412
1419
  const rect = this.canvas.getBoundingClientRect();
@@ -1438,7 +1445,7 @@ var OverlayScene = class {
1438
1445
  for (const body of bodies) {
1439
1446
  const entry = this.findObjectByBody(body);
1440
1447
  if (!entry) continue;
1441
- if (entry.tags.includes("falling")) continue;
1448
+ if (!entry.tags.includes("static")) continue;
1442
1449
  if (entry.clicksRemaining === void 0) continue;
1443
1450
  entry.clicksRemaining--;
1444
1451
  const name = this.getObstacleDisplayName(entry);
@@ -1484,13 +1491,23 @@ var OverlayScene = class {
1484
1491
  // ==================== PRIVATE ====================
1485
1492
  this.loop = () => {
1486
1493
  const substepDelta = 1e3 / 60 / this.substeps;
1494
+ const scale = this.engine.gravity.scale;
1487
1495
  for (let i = 0; i < this.substeps; i++) {
1496
+ for (const entry of this.gravityOverrideEntries) {
1497
+ if (entry.body.isStatic || entry.body.isSleeping) continue;
1498
+ const g = entry.gravityOverride;
1499
+ Matter5.Body.applyForce(entry.body, entry.body.position, {
1500
+ x: entry.body.mass * (g.x - this.engine.gravity.x) * scale,
1501
+ y: entry.body.mass * (g.y - this.engine.gravity.y) * scale
1502
+ });
1503
+ }
1488
1504
  Matter5.Engine.update(this.engine, substepDelta);
1489
1505
  }
1490
1506
  this.effectManager.update();
1491
- this.checkTTLExpiration();
1492
- this.checkDespawnBelowFloor();
1493
- this.updatePressure();
1507
+ this.checkExpiration();
1508
+ if (this.pressureObstacleIds.size > 0 || this.floorSegments.length > 0) {
1509
+ this.updatePressure();
1510
+ }
1494
1511
  if (this.grabbedObjectId && this.lastGrabMousePosition) {
1495
1512
  const entry = this.objects.get(this.grabbedObjectId);
1496
1513
  const mouseTarget = this.followTargets.get("mouse");
@@ -1504,27 +1521,39 @@ var OverlayScene = class {
1504
1521
  this.lastGrabMousePosition = { x: mouseTarget.x, y: mouseTarget.y };
1505
1522
  }
1506
1523
  }
1507
- for (const entry of this.objects.values()) {
1508
- const isDragging = this.grabbedObjectId === entry.id;
1509
- if (!isDragging) {
1510
- for (const tag of entry.tags) {
1511
- const key = tag === "follow_window" ? "mouse" : tag.startsWith("follow-") ? tag.slice(7) : null;
1512
- if (key) {
1513
- const target = this.followTargets.get(key);
1514
- if (target) {
1515
- const grounded = this.isGrounded(entry.body);
1516
- if (grounded) {
1517
- const direction = Math.sign(target.x - entry.body.position.x);
1518
- Matter5.Body.applyForce(entry.body, entry.body.position, { x: 1e-3 * direction, y: 0 });
1519
- }
1520
- }
1524
+ for (const entry of this.followWindowEntries) {
1525
+ if (this.grabbedObjectId === entry.id) continue;
1526
+ const targetKey = entry.followTarget ?? "mouse";
1527
+ let targetPos;
1528
+ targetPos = this.followTargets.get(targetKey);
1529
+ if (!targetPos) {
1530
+ const targetEntry = this.objects.get(targetKey);
1531
+ if (targetEntry) targetPos = targetEntry.body.position;
1532
+ }
1533
+ if (!targetPos) {
1534
+ for (const e of this.objects.values()) {
1535
+ if (e !== entry && e.tags.includes(targetKey)) {
1536
+ targetPos = e.body.position;
1537
+ break;
1521
1538
  }
1522
1539
  }
1523
1540
  }
1524
- if (this.config.wrapHorizontal && entry.tags.includes("falling")) {
1541
+ if (targetPos) {
1542
+ const dx = targetPos.x - entry.body.position.x;
1543
+ const dy = targetPos.y - entry.body.position.y;
1544
+ const dist = Math.sqrt(dx * dx + dy * dy);
1545
+ if (dist > 0) {
1546
+ const speedMult = entry.speedOverride ?? 1;
1547
+ const mag = 1e-3 * speedMult / dist;
1548
+ Matter5.Body.applyForce(entry.body, entry.body.position, { x: mag * dx, y: mag * dy });
1549
+ }
1550
+ }
1551
+ }
1552
+ for (const entry of this.objects.values()) {
1553
+ if (this.config.wrapHorizontal && !entry.tags.includes("static")) {
1525
1554
  wrapHorizontal(entry.body, this.config.bounds);
1526
1555
  }
1527
- if (entry.domElement && entry.tags.includes("falling")) {
1556
+ if (entry.domElement && !entry.tags.includes("static")) {
1528
1557
  this.updateDOMElementTransform(entry);
1529
1558
  }
1530
1559
  if (entry.tags.includes("grabable") && !entry.body.isStatic) {
@@ -1547,7 +1576,7 @@ var OverlayScene = class {
1547
1576
  };
1548
1577
  this.canvas = canvas;
1549
1578
  this.config = {
1550
- gravity: 1,
1579
+ gravity: { x: 0, y: -1 },
1551
1580
  wrapHorizontal: true,
1552
1581
  debug: false,
1553
1582
  ...config
@@ -1621,7 +1650,7 @@ var OverlayScene = class {
1621
1650
  const obstacles = [];
1622
1651
  const dynamics = [];
1623
1652
  for (const entry of this.objects.values()) {
1624
- if (entry.tags.includes("falling")) {
1653
+ if (!entry.tags.includes("static")) {
1625
1654
  if (Math.abs(entry.body.velocity.y) < 2) {
1626
1655
  dynamics.push(entry);
1627
1656
  }
@@ -1844,16 +1873,17 @@ var OverlayScene = class {
1844
1873
  }
1845
1874
  /** Convert a static obstacle to dynamic (make it fall) */
1846
1875
  collapseObstacle(entry) {
1847
- if (entry.tags.includes("falling")) return;
1876
+ if (!entry.tags.includes("static")) return;
1848
1877
  const name = this.getObstacleDisplayName(entry);
1849
1878
  console.log(`[Pressure] Collapsed: ${name}`);
1850
1879
  if (entry.shadow && entry.originalPosition) {
1851
1880
  this.createShadow(entry);
1852
1881
  }
1853
- entry.tags.push("falling");
1882
+ entry.tags = entry.tags.filter((t) => t !== "static");
1854
1883
  Matter5.Body.setStatic(entry.body, false);
1855
1884
  entry.pressureThreshold = void 0;
1856
1885
  entry.wordCollapseTag = void 0;
1886
+ this.pressureObstacleIds.delete(entry.id);
1857
1887
  }
1858
1888
  /** Create a static shadow copy of an obstacle at its original position */
1859
1889
  async createShadow(entry) {
@@ -1898,6 +1928,7 @@ var OverlayScene = class {
1898
1928
  id: shadowId,
1899
1929
  body,
1900
1930
  tags: ["shadow"],
1931
+ entityTag: shadowId,
1901
1932
  spawnTime: performance.now(),
1902
1933
  weight: 0,
1903
1934
  ttfGlyph: {
@@ -1925,6 +1956,7 @@ var OverlayScene = class {
1925
1956
  id: shadowId,
1926
1957
  body: result.body,
1927
1958
  tags: ["shadow"],
1959
+ entityTag: shadowId,
1928
1960
  spawnTime: performance.now(),
1929
1961
  weight: 0
1930
1962
  // Shadows don't contribute to pressure
@@ -1966,10 +1998,6 @@ var OverlayScene = class {
1966
1998
  }
1967
1999
  return null;
1968
2000
  }
1969
- /** Check if a body is grounded (low vertical velocity indicates resting on something) */
1970
- isGrounded(body) {
1971
- return Math.abs(body.velocity.y) < 0.5;
1972
- }
1973
2001
  start() {
1974
2002
  Matter5.Render.run(this.render);
1975
2003
  this.loop();
@@ -1993,6 +2021,9 @@ var OverlayScene = class {
1993
2021
  Matter5.Events.off(this.engine, "collisionStart", this.handleCollisionStart);
1994
2022
  Matter5.Engine.clear(this.engine);
1995
2023
  this.objects.clear();
2024
+ this.gravityOverrideEntries.clear();
2025
+ this.followWindowEntries.clear();
2026
+ this.pressureObstacleIds.clear();
1996
2027
  this.obstaclePressure.clear();
1997
2028
  this.previousPressure.clear();
1998
2029
  this.pressureLogTimer = 0;
@@ -2013,6 +2044,106 @@ var OverlayScene = class {
2013
2044
  }
2014
2045
  }
2015
2046
  }
2047
+ /**
2048
+ * Set gravity at runtime. Y axis uses physical convention: negative = down, positive = up.
2049
+ * @example
2050
+ * scene.setGravity({ x: 0, y: -1 }); // Normal downward gravity
2051
+ * scene.setGravity({ x: 0, y: 1 }); // Upward gravity
2052
+ * scene.setGravity({ x: 1, y: 0 }); // Rightward gravity
2053
+ * scene.setGravity({ x: 0, y: 0 }); // Zero gravity
2054
+ */
2055
+ setGravity(gravity) {
2056
+ this.config.gravity = gravity;
2057
+ this.engine.gravity.x = gravity.x;
2058
+ this.engine.gravity.y = -gravity.y;
2059
+ }
2060
+ /**
2061
+ * Set or clear a per-object gravity override at runtime.
2062
+ * Pass `null` to remove the override and restore scene gravity for that object.
2063
+ */
2064
+ setObjectGravityOverride(id, gravity) {
2065
+ const entry = this.objects.get(id);
2066
+ if (!entry) return;
2067
+ if (gravity === null) {
2068
+ this.removeTag(id, "gravity_override");
2069
+ } else {
2070
+ entry.gravityOverride = gravity;
2071
+ this.addTag(id, "gravity_override");
2072
+ }
2073
+ }
2074
+ /**
2075
+ * Set or change the follow target for an object's 'follow_window' tag.
2076
+ * Target can be 'mouse', an entity ID, or a tag string (follows first matching entity).
2077
+ * Adds 'follow_window' tag if not already present.
2078
+ */
2079
+ setFollowWindowTarget(id, target) {
2080
+ const entry = this.objects.get(id);
2081
+ if (!entry) return;
2082
+ entry.followTarget = target;
2083
+ this.addTag(id, "follow_window");
2084
+ }
2085
+ /**
2086
+ * Set or change the speed multiplier for an object's movement (follow_window and future
2087
+ * movement behaviors). Pass null to remove the override and reset to default speed.
2088
+ * Negative values cause the object to run away from its target.
2089
+ * Adds 'speed_override' tag if not already present.
2090
+ */
2091
+ setObjectSpeedOverride(id, speed) {
2092
+ const entry = this.objects.get(id);
2093
+ if (!entry) return;
2094
+ if (speed === null) {
2095
+ this.removeTag(id, "speed_override");
2096
+ } else {
2097
+ entry.speedOverride = speed;
2098
+ this.addTag(id, "speed_override");
2099
+ }
2100
+ }
2101
+ /**
2102
+ * Set or change the physics mass for an object. Pass null to remove the override and restore
2103
+ * the original density-based mass. Adds 'mass_override' tag if not already present.
2104
+ * Higher mass resists applied forces (including follow forces) more strongly.
2105
+ */
2106
+ setObjectMassOverride(id, mass) {
2107
+ const entry = this.objects.get(id);
2108
+ if (!entry) return;
2109
+ if (mass === null) {
2110
+ this.removeTag(id, "mass_override");
2111
+ } else {
2112
+ if (entry.originalMass === void 0) entry.originalMass = entry.body.mass;
2113
+ entry.massOverride = mass;
2114
+ Matter5.Body.setMass(entry.body, mass);
2115
+ this.addTag(id, "mass_override");
2116
+ }
2117
+ }
2118
+ /**
2119
+ * Set the angular velocity (spin) of an object in radians per second.
2120
+ * Positive = counter-clockwise, negative = clockwise.
2121
+ */
2122
+ setObjectAngularVelocity(id, omega) {
2123
+ const entry = this.objects.get(id);
2124
+ if (!entry) return;
2125
+ Matter5.Body.setAngularVelocity(entry.body, omega);
2126
+ }
2127
+ /**
2128
+ * Set the absolute scale of an object on each axis. Both physics collision shape and
2129
+ * sprite rendering are updated together. Scaling changes the body's mass proportionally
2130
+ * (area scales by x*y). Use setObjectMassOverride afterwards if you need a fixed mass.
2131
+ * @param x - Scale factor on the X axis (1 = original size)
2132
+ * @param y - Scale factor on the Y axis (1 = original size)
2133
+ */
2134
+ setObjectScale(id, x, y) {
2135
+ const entry = this.objects.get(id);
2136
+ if (!entry) return;
2137
+ const currentX = entry.scaleX ?? 1;
2138
+ const currentY = entry.scaleY ?? 1;
2139
+ Matter5.Body.scale(entry.body, x / currentX, y / currentY);
2140
+ if (entry.body.render.sprite && entry.baseSpriteSX !== void 0) {
2141
+ entry.body.render.sprite.xScale = entry.baseSpriteSX * x;
2142
+ entry.body.render.sprite.yScale = entry.baseSpriteSY * y;
2143
+ }
2144
+ entry.scaleX = x;
2145
+ entry.scaleY = y;
2146
+ }
2016
2147
  /**
2017
2148
  * Update the background configuration at runtime.
2018
2149
  */
@@ -2048,10 +2179,9 @@ var OverlayScene = class {
2048
2179
  /**
2049
2180
  * Spawn an object synchronously.
2050
2181
  * Object behavior is determined by tags:
2051
- * - 'falling': Object is dynamic (affected by gravity)
2182
+ * - 'static': Object is not affected by gravity (obstacle). Without this tag, object is dynamic by default.
2052
2183
  * - 'follow_window': Object follows mouse when grounded (walks toward mouse)
2053
2184
  * - 'grabable': Object can be grabbed and moved with mouse
2054
- * Without 'falling' tag, object is static.
2055
2185
  */
2056
2186
  spawnObject(config) {
2057
2187
  if (config.element) {
@@ -2070,8 +2200,20 @@ var OverlayScene = class {
2070
2200
  return result.id;
2071
2201
  }
2072
2202
  const id = crypto.randomUUID();
2073
- const tags = config.tags ?? [];
2074
- const isStatic = !tags.includes("falling");
2203
+ const entityDescriptor = config.imageUrl ? config.imageUrl.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "image" : config.radius ? "circle" : config.shape?.type ?? "rect";
2204
+ const entityTag = `${entityDescriptor.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase().slice(0, 16)}-${id.slice(0, 4)}`;
2205
+ const tags = [...config.tags ?? []];
2206
+ tags.push(entityTag);
2207
+ if (config.gravityOverride && !tags.includes("gravity_override")) {
2208
+ tags.push("gravity_override");
2209
+ }
2210
+ if (config.speedOverride !== void 0 && !tags.includes("speed_override")) {
2211
+ tags.push("speed_override");
2212
+ }
2213
+ if (config.massOverride !== void 0 && !tags.includes("mass_override")) {
2214
+ tags.push("mass_override");
2215
+ }
2216
+ const isStatic = tags.includes("static");
2075
2217
  logger.debug("OverlayScene", `Spawning object`, {
2076
2218
  id,
2077
2219
  tags,
@@ -2088,6 +2230,10 @@ var OverlayScene = class {
2088
2230
  } else {
2089
2231
  body = createObstacle(id, config, isStatic);
2090
2232
  }
2233
+ const naturalMass = body.mass;
2234
+ if (config.massOverride !== void 0) {
2235
+ Matter5.Body.setMass(body, config.massOverride);
2236
+ }
2091
2237
  let pressureThreshold;
2092
2238
  if (config.pressureThreshold) {
2093
2239
  pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
@@ -2110,12 +2256,25 @@ var OverlayScene = class {
2110
2256
  pressureThreshold,
2111
2257
  shadow,
2112
2258
  originalPosition: shadow || clicksRemaining !== void 0 ? { x: config.x, y: config.y } : void 0,
2113
- clicksRemaining
2259
+ clicksRemaining,
2260
+ gravityOverride: config.gravityOverride,
2261
+ followTarget: tags.includes("follow_window") ? config.followTarget ?? "mouse" : void 0,
2262
+ speedOverride: tags.includes("speed_override") ? config.speedOverride ?? 1 : void 0,
2263
+ massOverride: tags.includes("mass_override") ? config.massOverride : void 0,
2264
+ originalMass: tags.includes("mass_override") ? naturalMass : void 0,
2265
+ entityTag,
2266
+ scaleX: 1,
2267
+ scaleY: 1,
2268
+ baseSpriteSX: body.render.sprite?.xScale,
2269
+ baseSpriteSY: body.render.sprite?.yScale
2114
2270
  };
2115
2271
  this.objects.set(id, entry);
2272
+ if (config.gravityOverride) this.gravityOverrideEntries.add(entry);
2273
+ if (tags.includes("follow_window")) this.followWindowEntries.add(entry);
2116
2274
  Matter5.Composite.add(this.engine.world, body);
2117
2275
  if (isStatic && pressureThreshold !== void 0) {
2118
2276
  this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
2277
+ this.pressureObstacleIds.add(id);
2119
2278
  }
2120
2279
  this.emitLifecycleEvent("objectSpawned", this.toObjectState(entry));
2121
2280
  return id;
@@ -2126,8 +2285,20 @@ var OverlayScene = class {
2126
2285
  */
2127
2286
  async spawnObjectAsync(config) {
2128
2287
  const id = crypto.randomUUID();
2129
- const tags = config.tags ?? [];
2130
- const isStatic = !tags.includes("falling");
2288
+ const entityDescriptor = config.imageUrl ? config.imageUrl.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "image" : config.radius ? "circle" : config.shape?.type ?? "rect";
2289
+ const entityTag = `${entityDescriptor.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase().slice(0, 16)}-${id.slice(0, 4)}`;
2290
+ const tags = [...config.tags ?? []];
2291
+ tags.push(entityTag);
2292
+ if (config.gravityOverride && !tags.includes("gravity_override")) {
2293
+ tags.push("gravity_override");
2294
+ }
2295
+ if (config.speedOverride !== void 0 && !tags.includes("speed_override")) {
2296
+ tags.push("speed_override");
2297
+ }
2298
+ if (config.massOverride !== void 0 && !tags.includes("mass_override")) {
2299
+ tags.push("mass_override");
2300
+ }
2301
+ const isStatic = tags.includes("static");
2131
2302
  logger.debug("OverlayScene", `Spawning object async`, {
2132
2303
  id,
2133
2304
  tags,
@@ -2144,6 +2315,10 @@ var OverlayScene = class {
2144
2315
  } else {
2145
2316
  body = await createObstacleAsync(id, config, isStatic);
2146
2317
  }
2318
+ const naturalMass = body.mass;
2319
+ if (config.massOverride !== void 0) {
2320
+ Matter5.Body.setMass(body, config.massOverride);
2321
+ }
2147
2322
  let pressureThreshold;
2148
2323
  if (config.pressureThreshold) {
2149
2324
  pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
@@ -2166,26 +2341,39 @@ var OverlayScene = class {
2166
2341
  pressureThreshold,
2167
2342
  shadow,
2168
2343
  originalPosition: shadow || clicksRemaining !== void 0 ? { x: config.x, y: config.y } : void 0,
2169
- clicksRemaining
2344
+ clicksRemaining,
2345
+ gravityOverride: config.gravityOverride,
2346
+ followTarget: tags.includes("follow_window") ? config.followTarget ?? "mouse" : void 0,
2347
+ speedOverride: tags.includes("speed_override") ? config.speedOverride ?? 1 : void 0,
2348
+ massOverride: tags.includes("mass_override") ? config.massOverride : void 0,
2349
+ originalMass: tags.includes("mass_override") ? naturalMass : void 0,
2350
+ entityTag,
2351
+ scaleX: 1,
2352
+ scaleY: 1,
2353
+ baseSpriteSX: body.render.sprite?.xScale,
2354
+ baseSpriteSY: body.render.sprite?.yScale
2170
2355
  };
2171
2356
  this.objects.set(id, entry);
2357
+ if (config.gravityOverride) this.gravityOverrideEntries.add(entry);
2358
+ if (tags.includes("follow_window")) this.followWindowEntries.add(entry);
2172
2359
  Matter5.Composite.add(this.engine.world, body);
2173
2360
  if (isStatic && pressureThreshold !== void 0) {
2174
2361
  this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
2362
+ this.pressureObstacleIds.add(id);
2175
2363
  }
2176
2364
  this.emitLifecycleEvent("objectSpawned", this.toObjectState(entry));
2177
2365
  return id;
2178
2366
  }
2179
2367
  /**
2180
- * Add 'falling' tag to an object, making it dynamic (affected by gravity).
2368
+ * Make an object dynamic (remove 'static' tag, affected by gravity).
2181
2369
  * Also adds 'grabable' tag so released objects can be dragged.
2182
2370
  * This is the tag-based replacement for releaseObstacle().
2183
2371
  */
2184
2372
  addFallingTag(id) {
2185
2373
  const entry = this.objects.get(id);
2186
2374
  if (!entry) return;
2187
- if (!entry.tags.includes("falling")) {
2188
- entry.tags.push("falling");
2375
+ if (entry.tags.includes("static")) {
2376
+ entry.tags = entry.tags.filter((t) => t !== "static");
2189
2377
  Matter5.Body.setStatic(entry.body, false);
2190
2378
  }
2191
2379
  if (!entry.tags.includes("grabable")) {
@@ -2193,34 +2381,66 @@ var OverlayScene = class {
2193
2381
  }
2194
2382
  }
2195
2383
  /**
2196
- * Add a tag to an object.
2384
+ * Add a tag to an object. Tags drive behavior — adding a tag activates the associated effect.
2385
+ * For 'gravity_override', also pass a gravityOverride value via setObjectGravityOverride first,
2386
+ * or the tag will default to {x:0, y:0} (hovering).
2197
2387
  */
2198
2388
  addTag(id, tag) {
2199
2389
  const entry = this.objects.get(id);
2200
2390
  if (!entry) return;
2201
2391
  if (!entry.tags.includes(tag)) {
2202
2392
  entry.tags.push(tag);
2203
- if (tag === "falling") {
2204
- Matter5.Body.setStatic(entry.body, false);
2393
+ if (tag === "static") {
2394
+ Matter5.Body.setStatic(entry.body, true);
2395
+ } else if (tag === "gravity_override") {
2396
+ if (!entry.gravityOverride) entry.gravityOverride = { x: 0, y: 0 };
2397
+ this.gravityOverrideEntries.add(entry);
2398
+ } else if (tag === "follow_window") {
2399
+ if (!entry.followTarget) entry.followTarget = "mouse";
2400
+ this.followWindowEntries.add(entry);
2401
+ } else if (tag === "speed_override") {
2402
+ if (entry.speedOverride === void 0) entry.speedOverride = 1;
2403
+ } else if (tag === "mass_override") {
2404
+ if (entry.originalMass === void 0) entry.originalMass = entry.body.mass;
2405
+ if (entry.massOverride === void 0) entry.massOverride = Math.round(entry.body.mass);
2406
+ Matter5.Body.setMass(entry.body, entry.massOverride);
2205
2407
  }
2206
2408
  }
2207
2409
  }
2208
2410
  /**
2209
- * Remove a tag from an object.
2411
+ * Remove a tag from an object. Tags drive behavior — removing a tag deactivates the associated effect.
2210
2412
  */
2211
2413
  removeTag(id, tag) {
2212
2414
  const entry = this.objects.get(id);
2213
2415
  if (!entry) return;
2416
+ if (tag === entry.entityTag) {
2417
+ logger.warn("OverlayScene", `Cannot remove entity tag '${tag}' from '${id}' \u2014 entity tags are permanent identifiers and cannot be removed`);
2418
+ return;
2419
+ }
2214
2420
  const index = entry.tags.indexOf(tag);
2215
2421
  if (index !== -1) {
2216
2422
  entry.tags.splice(index, 1);
2217
- if (tag === "falling") {
2218
- Matter5.Body.setStatic(entry.body, true);
2423
+ if (tag === "static") {
2424
+ Matter5.Body.setStatic(entry.body, false);
2425
+ } else if (tag === "gravity_override") {
2426
+ this.gravityOverrideEntries.delete(entry);
2427
+ entry.gravityOverride = void 0;
2428
+ } else if (tag === "follow_window") {
2429
+ entry.followTarget = void 0;
2430
+ this.followWindowEntries.delete(entry);
2431
+ } else if (tag === "speed_override") {
2432
+ entry.speedOverride = void 0;
2433
+ } else if (tag === "mass_override") {
2434
+ if (entry.originalMass !== void 0) {
2435
+ Matter5.Body.setMass(entry.body, entry.originalMass);
2436
+ }
2437
+ entry.massOverride = void 0;
2438
+ entry.originalMass = void 0;
2219
2439
  }
2220
2440
  }
2221
2441
  }
2222
2442
  /**
2223
- * Release an object (add 'falling' tag to make it dynamic).
2443
+ * Release an object (remove 'static' tag to make it dynamic).
2224
2444
  * Convenience method - equivalent to addFallingTag().
2225
2445
  */
2226
2446
  releaseObject(id) {
@@ -2235,7 +2455,7 @@ var OverlayScene = class {
2235
2455
  }
2236
2456
  }
2237
2457
  /**
2238
- * Release all static objects (add 'falling' and 'grabable' tags).
2458
+ * Release all static objects (remove 'static' tag, add 'grabable' tag).
2239
2459
  */
2240
2460
  releaseAllObjects() {
2241
2461
  for (const [id] of this.objects) {
@@ -2243,7 +2463,7 @@ var OverlayScene = class {
2243
2463
  }
2244
2464
  }
2245
2465
  /**
2246
- * Release objects by tag (add 'falling' and 'grabable' tags to matching objects).
2466
+ * Release objects by tag (remove 'static' tag, add 'grabable' tag to matching objects).
2247
2467
  */
2248
2468
  releaseObjectsByTag(tag) {
2249
2469
  for (const [id, entry] of this.objects) {
@@ -2257,6 +2477,9 @@ var OverlayScene = class {
2257
2477
  if (!entry) return;
2258
2478
  this.emitLifecycleEvent("objectRemoved", this.toObjectState(entry));
2259
2479
  Matter5.Composite.remove(this.engine.world, entry.body);
2480
+ this.gravityOverrideEntries.delete(entry);
2481
+ this.followWindowEntries.delete(entry);
2482
+ this.pressureObstacleIds.delete(id);
2260
2483
  this.objects.delete(id);
2261
2484
  }
2262
2485
  removeObjects(ids) {
@@ -2269,6 +2492,9 @@ var OverlayScene = class {
2269
2492
  Matter5.Composite.remove(this.engine.world, entry.body);
2270
2493
  }
2271
2494
  this.objects.clear();
2495
+ this.gravityOverrideEntries.clear();
2496
+ this.followWindowEntries.clear();
2497
+ this.pressureObstacleIds.clear();
2272
2498
  }
2273
2499
  removeObjectsByTag(tag) {
2274
2500
  const toRemove = [];
@@ -2426,14 +2652,14 @@ var OverlayScene = class {
2426
2652
  }
2427
2653
  }
2428
2654
  /**
2429
- * Set the velocity of an object.
2655
+ * Set the velocity of an object. Y axis uses physical convention: negative = down, positive = up.
2430
2656
  * @param objectId - The ID of the object
2431
2657
  * @param velocity - The velocity vector to set
2432
2658
  */
2433
2659
  setVelocity(objectId, velocity) {
2434
2660
  const entry = this.objects.get(objectId);
2435
2661
  if (!entry) return;
2436
- Matter5.Body.setVelocity(entry.body, velocity);
2662
+ Matter5.Body.setVelocity(entry.body, { x: velocity.x, y: -velocity.y });
2437
2663
  }
2438
2664
  /**
2439
2665
  * Set the position of an object.
@@ -2656,7 +2882,7 @@ var OverlayScene = class {
2656
2882
  const width = config.width ?? element.offsetWidth;
2657
2883
  const height = config.height ?? element.offsetHeight;
2658
2884
  const tags = config.tags ?? [];
2659
- const isStatic = !tags.includes("falling");
2885
+ const isStatic = tags.includes("static");
2660
2886
  const body = Matter5.Bodies.rectangle(x, y, width, height, {
2661
2887
  isStatic,
2662
2888
  label: `dom-${crypto.randomUUID().slice(0, 8)}`,
@@ -2664,6 +2890,8 @@ var OverlayScene = class {
2664
2890
  // Don't render the body, DOM element is the visual
2665
2891
  });
2666
2892
  const id = body.label;
2893
+ const entityTag = `dom-${id.slice(4, 8)}`;
2894
+ tags.push(entityTag);
2667
2895
  let pressureThreshold;
2668
2896
  if (config.pressureThreshold) {
2669
2897
  pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
@@ -2677,6 +2905,7 @@ var OverlayScene = class {
2677
2905
  id,
2678
2906
  body,
2679
2907
  tags,
2908
+ entityTag,
2680
2909
  spawnTime: performance.now(),
2681
2910
  pressureThreshold,
2682
2911
  weight: config.weight ?? 1,
@@ -2691,12 +2920,13 @@ var OverlayScene = class {
2691
2920
  this.updateDOMElementTransform(entry);
2692
2921
  if (isStatic && pressureThreshold !== void 0) {
2693
2922
  this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
2923
+ this.pressureObstacleIds.add(id);
2694
2924
  }
2695
2925
  if (clicksRemaining !== void 0) {
2696
2926
  const clickHandler = () => {
2697
2927
  const currentEntry = this.objects.get(id);
2698
2928
  if (!currentEntry) return;
2699
- if (currentEntry.tags.includes("falling")) return;
2929
+ if (!currentEntry.tags.includes("static")) return;
2700
2930
  if (currentEntry.clicksRemaining === void 0) return;
2701
2931
  currentEntry.clicksRemaining--;
2702
2932
  logger.debug("OverlayScene", `Click on DOM element: ${currentEntry.clicksRemaining} clicks remaining`);
@@ -2741,8 +2971,13 @@ var OverlayScene = class {
2741
2971
  const fontName = config.fontName ?? this.getDefaultFont()?.name ?? "handwritten";
2742
2972
  const basePath = `${fontsBasePath}${fontName}/`;
2743
2973
  const stringTag = config.stringTag ?? `str-${crypto.randomUUID().slice(0, 8)}`;
2744
- const baseTags = config.tags ?? [];
2745
- const isStatic = !baseTags.includes("falling");
2974
+ const isStatic = config.isStatic !== false;
2975
+ const baseTags = [...config.tags ?? []];
2976
+ if (isStatic && !baseTags.includes("static")) baseTags.push("static");
2977
+ else if (!isStatic) {
2978
+ const i = baseTags.indexOf("static");
2979
+ if (i !== -1) baseTags.splice(i, 1);
2980
+ }
2746
2981
  const letterColor = config.letterColor;
2747
2982
  const letterIds = [];
2748
2983
  const letterMap = /* @__PURE__ */ new Map();
@@ -2884,8 +3119,10 @@ var OverlayScene = class {
2884
3119
  const resolvedChar = charFileNames.get(char) ?? char;
2885
3120
  const originalImageUrl = `${basePath}${resolvedChar}.png`;
2886
3121
  const imageUrl = letterColor ? await tintImage(originalImageUrl, letterColor) : originalImageUrl;
2887
- const tags = [...config.tags ?? [], stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
2888
3122
  const id = crypto.randomUUID();
3123
+ const safeChar = char.match(/[a-zA-Z0-9]/) ? char.toLowerCase() : "sym";
3124
+ const entityTag = `letter-${safeChar}-${id.slice(0, 4)}`;
3125
+ const tags = [...baseTags, entityTag, stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
2889
3126
  const objectConfig = {
2890
3127
  x: centerX,
2891
3128
  y: centerY,
@@ -2931,10 +3168,12 @@ var OverlayScene = class {
2931
3168
  originalPosition: shadow || clicksRemaining !== void 0 ? { x: centerX, y: centerY } : void 0,
2932
3169
  imageUrl: shadow || clicksRemaining !== void 0 ? imageUrl : void 0,
2933
3170
  imageSize: shadow || clicksRemaining !== void 0 ? letterSize : void 0,
2934
- clicksRemaining
3171
+ clicksRemaining,
3172
+ entityTag
2935
3173
  };
2936
3174
  this.objects.set(id, entry);
2937
3175
  Matter5.Composite.add(this.engine.world, result.body);
3176
+ if (pressureThreshold !== void 0) this.pressureObstacleIds.add(id);
2938
3177
  letterIds.push(id);
2939
3178
  letterMap.set(`${char}-${globalCharIndex}`, id);
2940
3179
  debugInfo.push({
@@ -2985,15 +3224,13 @@ var OverlayScene = class {
2985
3224
  }
2986
3225
  /**
2987
3226
  * Spawn falling text objects from a string.
2988
- * Same as addTextObstacles but with 'falling' tag (objects fall with gravity).
3227
+ * Same as addTextObstacles but ensures objects are dynamic (removes 'static' tag if present).
2989
3228
  */
2990
3229
  async spawnFallingTextObstacles(config) {
2991
- const tags = [...config.tags ?? []];
2992
- if (!tags.includes("falling")) tags.push("falling");
2993
- return this.addTextObstacles({ ...config, tags });
3230
+ return this.addTextObstacles({ ...config, isStatic: false });
2994
3231
  }
2995
3232
  /**
2996
- * Release all letters in a word (add 'falling' tag so they fall).
3233
+ * Release all letters in a word (remove 'static' tag so they fall).
2997
3234
  * @param wordTag - The word tag returned from addTextObstacles
2998
3235
  */
2999
3236
  releaseTextObstacles(wordTag) {
@@ -3040,8 +3277,13 @@ var OverlayScene = class {
3040
3277
  const { x, y, fontSize, fontUrl } = config;
3041
3278
  const text = config.text.replace(/\\n/g, "\n");
3042
3279
  const stringTag = config.stringTag ?? `str-${crypto.randomUUID().slice(0, 8)}`;
3043
- const baseTags = config.tags ?? [];
3044
- const isStatic = !baseTags.includes("falling");
3280
+ const isStatic = config.isStatic !== false;
3281
+ const baseTags = [...config.tags ?? []];
3282
+ if (isStatic && !baseTags.includes("static")) baseTags.push("static");
3283
+ else if (!isStatic) {
3284
+ const i = baseTags.indexOf("static");
3285
+ if (i !== -1) baseTags.splice(i, 1);
3286
+ }
3045
3287
  const fillColor = config.fillColor ?? "#ffffff";
3046
3288
  const fillColors = config.fillColors;
3047
3289
  const lineHeight = config.lineHeight ?? fontSize * 1.2;
@@ -3115,7 +3357,9 @@ var OverlayScene = class {
3115
3357
  const wordTag = `${stringTag}-word-${currentWordIndex}`;
3116
3358
  wordTagsSet.add(wordTag);
3117
3359
  const id = crypto.randomUUID();
3118
- const tags = [...config.tags ?? [], stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
3360
+ const safeChar = char.match(/[a-zA-Z0-9]/) ? char.toLowerCase() : "sym";
3361
+ const entityTag = `letter-${safeChar}-${id.slice(0, 4)}`;
3362
+ const tags = [...baseTags, entityTag, stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
3119
3363
  const bbox = glyphData.boundingBox;
3120
3364
  const glyphWidth = bbox ? bbox.x2 - bbox.x1 : glyphData.advanceWidth;
3121
3365
  const glyphHeight = bbox ? bbox.y2 - bbox.y1 : fontSize;
@@ -3177,10 +3421,12 @@ var OverlayScene = class {
3177
3421
  weight,
3178
3422
  shadow,
3179
3423
  originalPosition: shadow || clicksRemaining !== void 0 ? { x: body.position.x, y: body.position.y } : void 0,
3180
- clicksRemaining
3424
+ clicksRemaining,
3425
+ entityTag
3181
3426
  };
3182
3427
  this.objects.set(id, entry);
3183
3428
  Matter5.Composite.add(this.engine.world, body);
3429
+ if (pressureThreshold !== void 0) this.pressureObstacleIds.add(id);
3184
3430
  letterIds.push(id);
3185
3431
  letterMap.set(`${char}-${globalCharIndex}`, id);
3186
3432
  currentX += glyphData.advanceWidth;
@@ -3220,12 +3466,10 @@ var OverlayScene = class {
3220
3466
  }
3221
3467
  /**
3222
3468
  * Spawn falling TTF text objects.
3223
- * Same as addTTFTextObstacles but with 'falling' tag (objects fall with gravity).
3469
+ * Same as addTTFTextObstacles but ensures objects are dynamic (removes 'static' tag if present).
3224
3470
  */
3225
3471
  async spawnFallingTTFTextObstacles(config) {
3226
- const tags = [...config.tags ?? []];
3227
- if (!tags.includes("falling")) tags.push("falling");
3228
- return this.addTTFTextObstacles({ ...config, tags });
3472
+ return this.addTTFTextObstacles({ ...config, isStatic: false });
3229
3473
  }
3230
3474
  // ==================== COMBINED TAG METHODS ====================
3231
3475
  removeAllByTag(tag) {
@@ -3336,37 +3580,29 @@ var OverlayScene = class {
3336
3580
  entry.domElement.style.setProperty("top", `${y - height / 2}px`, "important");
3337
3581
  entry.domElement.style.setProperty("transform", `rotate(${angleDeg}deg)`, "important");
3338
3582
  }
3339
- checkTTLExpiration() {
3583
+ /** TTL expiration + below-floor despawn in a single O(N) pass */
3584
+ checkExpiration() {
3340
3585
  const now = performance.now();
3341
- const expiredObjects = [];
3342
- for (const [id, entry] of this.objects) {
3343
- if (entry.ttl !== void 0 && now - entry.spawnTime >= entry.ttl) {
3344
- expiredObjects.push(id);
3345
- }
3346
- }
3347
- for (const id of expiredObjects) {
3348
- this.removeObject(id);
3349
- }
3350
- }
3351
- /** Despawn objects that have fallen below the floor by the configured distance */
3352
- checkDespawnBelowFloor() {
3353
3586
  const despawnDistance = this.config.despawnBelowFloor ?? 1;
3354
3587
  const containerHeight = this.config.bounds.bottom - this.config.bounds.top;
3355
3588
  const despawnY = this.config.bounds.bottom + containerHeight * despawnDistance;
3356
- const toDespawn = [];
3589
+ const toRemove = [];
3357
3590
  for (const [id, entry] of this.objects) {
3358
- if (entry.body.position.y > despawnY) {
3359
- toDespawn.push(id);
3591
+ if (entry.ttl !== void 0 && now - entry.spawnTime >= entry.ttl) {
3592
+ toRemove.push(id);
3593
+ } else if (entry.body.position.y > despawnY) {
3594
+ toRemove.push(id);
3360
3595
  }
3361
3596
  }
3362
- for (const id of toDespawn) {
3597
+ for (const id of toRemove) {
3363
3598
  this.removeObject(id);
3364
3599
  }
3365
3600
  }
3366
3601
  fireUpdateCallbacks() {
3602
+ if (this.updateCallbacks.length === 0) return;
3367
3603
  const objects = [];
3368
- this.objects.forEach((entry) => {
3369
- if (entry.tags.includes("falling")) {
3604
+ for (const entry of this.objects.values()) {
3605
+ if (!entry.tags.includes("static")) {
3370
3606
  objects.push({
3371
3607
  id: entry.id,
3372
3608
  x: entry.body.position.x,
@@ -3375,28 +3611,37 @@ var OverlayScene = class {
3375
3611
  tags: entry.tags
3376
3612
  });
3377
3613
  }
3378
- });
3614
+ }
3379
3615
  const data = { objects };
3380
- this.updateCallbacks.forEach((cb) => cb(data));
3616
+ for (const cb of this.updateCallbacks) cb(data);
3381
3617
  }
3382
3618
  };
3383
3619
 
3384
3620
  // src/tags.ts
3385
- var TAG_FALLING = "falling";
3621
+ var TAG_STATIC = "static";
3386
3622
  var TAG_FOLLOW_WINDOW = "follow_window";
3387
3623
  var TAG_GRABABLE = "grabable";
3624
+ var TAG_GRAVITY_OVERRIDE = "gravity_override";
3625
+ var TAG_SPEED_OVERRIDE = "speed_override";
3626
+ var TAG_MASS_OVERRIDE = "mass_override";
3388
3627
  var TAGS = {
3389
- FALLING: TAG_FALLING,
3628
+ STATIC: TAG_STATIC,
3390
3629
  FOLLOW_WINDOW: TAG_FOLLOW_WINDOW,
3391
- GRABABLE: TAG_GRABABLE
3630
+ GRABABLE: TAG_GRABABLE,
3631
+ GRAVITY_OVERRIDE: TAG_GRAVITY_OVERRIDE,
3632
+ SPEED_OVERRIDE: TAG_SPEED_OVERRIDE,
3633
+ MASS_OVERRIDE: TAG_MASS_OVERRIDE
3392
3634
  };
3393
3635
  export {
3394
3636
  BackgroundManager,
3395
3637
  OverlayScene,
3396
3638
  TAGS,
3397
- TAG_FALLING,
3398
3639
  TAG_FOLLOW_WINDOW,
3399
3640
  TAG_GRABABLE,
3641
+ TAG_GRAVITY_OVERRIDE,
3642
+ TAG_MASS_OVERRIDE,
3643
+ TAG_SPEED_OVERRIDE,
3644
+ TAG_STATIC,
3400
3645
  clearFontCache,
3401
3646
  getGlyphData,
3402
3647
  getKerning,