@blorkfield/overlay-core 0.9.0 → 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.cjs CHANGED
@@ -33,10 +33,12 @@ __export(index_exports, {
33
33
  BackgroundManager: () => BackgroundManager,
34
34
  OverlayScene: () => OverlayScene,
35
35
  TAGS: () => TAGS,
36
- TAG_FALLING: () => TAG_FALLING,
37
36
  TAG_FOLLOW_WINDOW: () => TAG_FOLLOW_WINDOW,
38
37
  TAG_GRABABLE: () => TAG_GRABABLE,
39
38
  TAG_GRAVITY_OVERRIDE: () => TAG_GRAVITY_OVERRIDE,
39
+ TAG_MASS_OVERRIDE: () => TAG_MASS_OVERRIDE,
40
+ TAG_SPEED_OVERRIDE: () => TAG_SPEED_OVERRIDE,
41
+ TAG_STATIC: () => TAG_STATIC,
40
42
  clearFontCache: () => clearFontCache,
41
43
  getGlyphData: () => getGlyphData,
42
44
  getKerning: () => getKerning,
@@ -56,7 +58,7 @@ var import_matter_js = __toESM(require("matter-js"), 1);
56
58
  function createEngine(gravity) {
57
59
  const engine = import_matter_js.default.Engine.create();
58
60
  engine.gravity.x = gravity.x;
59
- engine.gravity.y = gravity.y;
61
+ engine.gravity.y = -gravity.y;
60
62
  return engine;
61
63
  }
62
64
  function createRender(engine, canvas, config) {
@@ -506,6 +508,7 @@ function createBodyFromVertices(id, x, y, vertices, renderOptions) {
506
508
  restitution: 0.3,
507
509
  friction: 0.1,
508
510
  frictionAir: 0.01,
511
+ density: 5e-3,
509
512
  label: `entity:${id}`,
510
513
  render: renderOptions
511
514
  });
@@ -598,6 +601,7 @@ function createCircleEntity(id, config) {
598
601
  restitution: 0.3,
599
602
  friction: 0.1,
600
603
  frictionAir: 0.01,
604
+ density: 5e-3,
601
605
  label: `entity:${id}`,
602
606
  render: createFillRenderOptions(config)
603
607
  });
@@ -609,6 +613,7 @@ function createCircleEntityWithSprite(id, config, imageWidth, imageHeight) {
609
613
  restitution: 0.3,
610
614
  friction: 0.1,
611
615
  frictionAir: 0.01,
616
+ density: 5e-3,
612
617
  label: `entity:${id}`,
613
618
  render: createSpriteRenderOptions(config, imageWidth, imageHeight)
614
619
  });
@@ -1039,8 +1044,7 @@ var EffectManager = class {
1039
1044
  const objectConfig = this.selectObjectConfig(config.objectConfigs);
1040
1045
  if (!objectConfig) continue;
1041
1046
  const radius = this.calculateRadius(objectConfig);
1042
- const tags = [...objectConfig.objectConfig.tags ?? []];
1043
- if (!tags.includes("falling")) tags.push("falling");
1047
+ const tags = (objectConfig.objectConfig.tags ?? []).filter((t) => t !== "static");
1044
1048
  const fullConfig = {
1045
1049
  ...objectConfig.objectConfig,
1046
1050
  x: originX,
@@ -1079,8 +1083,7 @@ var EffectManager = class {
1079
1083
  const radius = this.calculateRadius(objectConfig);
1080
1084
  const x = this.randomInRange(spawnAreaStart + radius, spawnAreaStart + spawnAreaWidth - radius);
1081
1085
  const y = bounds.top - radius;
1082
- const tags = [...objectConfig.objectConfig.tags ?? []];
1083
- if (!tags.includes("falling")) tags.push("falling");
1086
+ const tags = (objectConfig.objectConfig.tags ?? []).filter((t) => t !== "static");
1084
1087
  const fullConfig = {
1085
1088
  ...objectConfig.objectConfig,
1086
1089
  x,
@@ -1105,8 +1108,7 @@ var EffectManager = class {
1105
1108
  const objectConfig = this.selectObjectConfig(config.objectConfigs);
1106
1109
  if (!objectConfig) return;
1107
1110
  const radius = this.calculateRadius(objectConfig);
1108
- const tags = [...objectConfig.objectConfig.tags ?? []];
1109
- if (!tags.includes("falling")) tags.push("falling");
1111
+ const tags = (objectConfig.objectConfig.tags ?? []).filter((t) => t !== "static");
1110
1112
  const fullConfig = {
1111
1113
  ...objectConfig.objectConfig,
1112
1114
  x: config.origin.x,
@@ -1460,6 +1462,10 @@ var OverlayScene = class {
1460
1462
  this.substeps = 2;
1461
1463
  // Tracks only the bodies with a gravity override — engine gravity handles everyone else
1462
1464
  this.gravityOverrideEntries = /* @__PURE__ */ new Set();
1465
+ // Tracks only the bodies with follow_window tag — avoids scanning all objects each frame
1466
+ this.followWindowEntries = /* @__PURE__ */ new Set();
1467
+ // IDs of static objects that have pressure thresholds — empty = skip updatePressure entirely
1468
+ this.pressureObstacleIds = /* @__PURE__ */ new Set();
1463
1469
  /** Handle mouse down - start grab via programmatic API */
1464
1470
  this.handleMouseDown = (event) => {
1465
1471
  const rect = this.canvas.getBoundingClientRect();
@@ -1491,7 +1497,7 @@ var OverlayScene = class {
1491
1497
  for (const body of bodies) {
1492
1498
  const entry = this.findObjectByBody(body);
1493
1499
  if (!entry) continue;
1494
- if (entry.tags.includes("falling")) continue;
1500
+ if (!entry.tags.includes("static")) continue;
1495
1501
  if (entry.clicksRemaining === void 0) continue;
1496
1502
  entry.clicksRemaining--;
1497
1503
  const name = this.getObstacleDisplayName(entry);
@@ -1550,9 +1556,10 @@ var OverlayScene = class {
1550
1556
  import_matter_js5.default.Engine.update(this.engine, substepDelta);
1551
1557
  }
1552
1558
  this.effectManager.update();
1553
- this.checkTTLExpiration();
1554
- this.checkDespawnBelowFloor();
1555
- this.updatePressure();
1559
+ this.checkExpiration();
1560
+ if (this.pressureObstacleIds.size > 0 || this.floorSegments.length > 0) {
1561
+ this.updatePressure();
1562
+ }
1556
1563
  if (this.grabbedObjectId && this.lastGrabMousePosition) {
1557
1564
  const entry = this.objects.get(this.grabbedObjectId);
1558
1565
  const mouseTarget = this.followTargets.get("mouse");
@@ -1566,27 +1573,39 @@ var OverlayScene = class {
1566
1573
  this.lastGrabMousePosition = { x: mouseTarget.x, y: mouseTarget.y };
1567
1574
  }
1568
1575
  }
1569
- for (const entry of this.objects.values()) {
1570
- const isDragging = this.grabbedObjectId === entry.id;
1571
- if (!isDragging) {
1572
- for (const tag of entry.tags) {
1573
- const key = tag === "follow_window" ? "mouse" : tag.startsWith("follow-") ? tag.slice(7) : null;
1574
- if (key) {
1575
- const target = this.followTargets.get(key);
1576
- if (target) {
1577
- const grounded = this.isGrounded(entry.body);
1578
- if (grounded) {
1579
- const direction = Math.sign(target.x - entry.body.position.x);
1580
- import_matter_js5.default.Body.applyForce(entry.body, entry.body.position, { x: 1e-3 * direction, y: 0 });
1581
- }
1582
- }
1576
+ for (const entry of this.followWindowEntries) {
1577
+ if (this.grabbedObjectId === entry.id) continue;
1578
+ const targetKey = entry.followTarget ?? "mouse";
1579
+ let targetPos;
1580
+ targetPos = this.followTargets.get(targetKey);
1581
+ if (!targetPos) {
1582
+ const targetEntry = this.objects.get(targetKey);
1583
+ if (targetEntry) targetPos = targetEntry.body.position;
1584
+ }
1585
+ if (!targetPos) {
1586
+ for (const e of this.objects.values()) {
1587
+ if (e !== entry && e.tags.includes(targetKey)) {
1588
+ targetPos = e.body.position;
1589
+ break;
1583
1590
  }
1584
1591
  }
1585
1592
  }
1586
- if (this.config.wrapHorizontal && entry.tags.includes("falling")) {
1593
+ if (targetPos) {
1594
+ const dx = targetPos.x - entry.body.position.x;
1595
+ const dy = targetPos.y - entry.body.position.y;
1596
+ const dist = Math.sqrt(dx * dx + dy * dy);
1597
+ if (dist > 0) {
1598
+ const speedMult = entry.speedOverride ?? 1;
1599
+ const mag = 1e-3 * speedMult / dist;
1600
+ import_matter_js5.default.Body.applyForce(entry.body, entry.body.position, { x: mag * dx, y: mag * dy });
1601
+ }
1602
+ }
1603
+ }
1604
+ for (const entry of this.objects.values()) {
1605
+ if (this.config.wrapHorizontal && !entry.tags.includes("static")) {
1587
1606
  wrapHorizontal(entry.body, this.config.bounds);
1588
1607
  }
1589
- if (entry.domElement && entry.tags.includes("falling")) {
1608
+ if (entry.domElement && !entry.tags.includes("static")) {
1590
1609
  this.updateDOMElementTransform(entry);
1591
1610
  }
1592
1611
  if (entry.tags.includes("grabable") && !entry.body.isStatic) {
@@ -1609,7 +1628,7 @@ var OverlayScene = class {
1609
1628
  };
1610
1629
  this.canvas = canvas;
1611
1630
  this.config = {
1612
- gravity: { x: 0, y: 1 },
1631
+ gravity: { x: 0, y: -1 },
1613
1632
  wrapHorizontal: true,
1614
1633
  debug: false,
1615
1634
  ...config
@@ -1683,7 +1702,7 @@ var OverlayScene = class {
1683
1702
  const obstacles = [];
1684
1703
  const dynamics = [];
1685
1704
  for (const entry of this.objects.values()) {
1686
- if (entry.tags.includes("falling")) {
1705
+ if (!entry.tags.includes("static")) {
1687
1706
  if (Math.abs(entry.body.velocity.y) < 2) {
1688
1707
  dynamics.push(entry);
1689
1708
  }
@@ -1906,16 +1925,17 @@ var OverlayScene = class {
1906
1925
  }
1907
1926
  /** Convert a static obstacle to dynamic (make it fall) */
1908
1927
  collapseObstacle(entry) {
1909
- if (entry.tags.includes("falling")) return;
1928
+ if (!entry.tags.includes("static")) return;
1910
1929
  const name = this.getObstacleDisplayName(entry);
1911
1930
  console.log(`[Pressure] Collapsed: ${name}`);
1912
1931
  if (entry.shadow && entry.originalPosition) {
1913
1932
  this.createShadow(entry);
1914
1933
  }
1915
- entry.tags.push("falling");
1934
+ entry.tags = entry.tags.filter((t) => t !== "static");
1916
1935
  import_matter_js5.default.Body.setStatic(entry.body, false);
1917
1936
  entry.pressureThreshold = void 0;
1918
1937
  entry.wordCollapseTag = void 0;
1938
+ this.pressureObstacleIds.delete(entry.id);
1919
1939
  }
1920
1940
  /** Create a static shadow copy of an obstacle at its original position */
1921
1941
  async createShadow(entry) {
@@ -1960,6 +1980,7 @@ var OverlayScene = class {
1960
1980
  id: shadowId,
1961
1981
  body,
1962
1982
  tags: ["shadow"],
1983
+ entityTag: shadowId,
1963
1984
  spawnTime: performance.now(),
1964
1985
  weight: 0,
1965
1986
  ttfGlyph: {
@@ -1987,6 +2008,7 @@ var OverlayScene = class {
1987
2008
  id: shadowId,
1988
2009
  body: result.body,
1989
2010
  tags: ["shadow"],
2011
+ entityTag: shadowId,
1990
2012
  spawnTime: performance.now(),
1991
2013
  weight: 0
1992
2014
  // Shadows don't contribute to pressure
@@ -2028,10 +2050,6 @@ var OverlayScene = class {
2028
2050
  }
2029
2051
  return null;
2030
2052
  }
2031
- /** Check if a body is grounded (low vertical velocity indicates resting on something) */
2032
- isGrounded(body) {
2033
- return Math.abs(body.velocity.y) < 0.5;
2034
- }
2035
2053
  start() {
2036
2054
  import_matter_js5.default.Render.run(this.render);
2037
2055
  this.loop();
@@ -2055,6 +2073,9 @@ var OverlayScene = class {
2055
2073
  import_matter_js5.default.Events.off(this.engine, "collisionStart", this.handleCollisionStart);
2056
2074
  import_matter_js5.default.Engine.clear(this.engine);
2057
2075
  this.objects.clear();
2076
+ this.gravityOverrideEntries.clear();
2077
+ this.followWindowEntries.clear();
2078
+ this.pressureObstacleIds.clear();
2058
2079
  this.obstaclePressure.clear();
2059
2080
  this.previousPressure.clear();
2060
2081
  this.pressureLogTimer = 0;
@@ -2076,17 +2097,17 @@ var OverlayScene = class {
2076
2097
  }
2077
2098
  }
2078
2099
  /**
2079
- * Set gravity at runtime. Supports any direction including negative values.
2100
+ * Set gravity at runtime. Y axis uses physical convention: negative = down, positive = up.
2080
2101
  * @example
2081
- * scene.setGravity({ x: 0, y: 1 }); // Normal downward gravity
2082
- * scene.setGravity({ x: 0, y: -1 }); // Upward gravity
2083
- * scene.setGravity({ x: 1, y: 0 }); // Sideways gravity
2102
+ * scene.setGravity({ x: 0, y: -1 }); // Normal downward gravity
2103
+ * scene.setGravity({ x: 0, y: 1 }); // Upward gravity
2104
+ * scene.setGravity({ x: 1, y: 0 }); // Rightward gravity
2084
2105
  * scene.setGravity({ x: 0, y: 0 }); // Zero gravity
2085
2106
  */
2086
2107
  setGravity(gravity) {
2087
2108
  this.config.gravity = gravity;
2088
2109
  this.engine.gravity.x = gravity.x;
2089
- this.engine.gravity.y = gravity.y;
2110
+ this.engine.gravity.y = -gravity.y;
2090
2111
  }
2091
2112
  /**
2092
2113
  * Set or clear a per-object gravity override at runtime.
@@ -2096,18 +2117,85 @@ var OverlayScene = class {
2096
2117
  const entry = this.objects.get(id);
2097
2118
  if (!entry) return;
2098
2119
  if (gravity === null) {
2099
- entry.gravityOverride = void 0;
2100
- this.gravityOverrideEntries.delete(entry);
2101
- const idx = entry.tags.indexOf("gravity_override");
2102
- if (idx !== -1) entry.tags.splice(idx, 1);
2120
+ this.removeTag(id, "gravity_override");
2103
2121
  } else {
2104
2122
  entry.gravityOverride = gravity;
2105
- this.gravityOverrideEntries.add(entry);
2106
- if (!entry.tags.includes("gravity_override")) {
2107
- entry.tags.push("gravity_override");
2108
- }
2123
+ this.addTag(id, "gravity_override");
2124
+ }
2125
+ }
2126
+ /**
2127
+ * Set or change the follow target for an object's 'follow_window' tag.
2128
+ * Target can be 'mouse', an entity ID, or a tag string (follows first matching entity).
2129
+ * Adds 'follow_window' tag if not already present.
2130
+ */
2131
+ setFollowWindowTarget(id, target) {
2132
+ const entry = this.objects.get(id);
2133
+ if (!entry) return;
2134
+ entry.followTarget = target;
2135
+ this.addTag(id, "follow_window");
2136
+ }
2137
+ /**
2138
+ * Set or change the speed multiplier for an object's movement (follow_window and future
2139
+ * movement behaviors). Pass null to remove the override and reset to default speed.
2140
+ * Negative values cause the object to run away from its target.
2141
+ * Adds 'speed_override' tag if not already present.
2142
+ */
2143
+ setObjectSpeedOverride(id, speed) {
2144
+ const entry = this.objects.get(id);
2145
+ if (!entry) return;
2146
+ if (speed === null) {
2147
+ this.removeTag(id, "speed_override");
2148
+ } else {
2149
+ entry.speedOverride = speed;
2150
+ this.addTag(id, "speed_override");
2151
+ }
2152
+ }
2153
+ /**
2154
+ * Set or change the physics mass for an object. Pass null to remove the override and restore
2155
+ * the original density-based mass. Adds 'mass_override' tag if not already present.
2156
+ * Higher mass resists applied forces (including follow forces) more strongly.
2157
+ */
2158
+ setObjectMassOverride(id, mass) {
2159
+ const entry = this.objects.get(id);
2160
+ if (!entry) return;
2161
+ if (mass === null) {
2162
+ this.removeTag(id, "mass_override");
2163
+ } else {
2164
+ if (entry.originalMass === void 0) entry.originalMass = entry.body.mass;
2165
+ entry.massOverride = mass;
2166
+ import_matter_js5.default.Body.setMass(entry.body, mass);
2167
+ this.addTag(id, "mass_override");
2109
2168
  }
2110
2169
  }
2170
+ /**
2171
+ * Set the angular velocity (spin) of an object in radians per second.
2172
+ * Positive = counter-clockwise, negative = clockwise.
2173
+ */
2174
+ setObjectAngularVelocity(id, omega) {
2175
+ const entry = this.objects.get(id);
2176
+ if (!entry) return;
2177
+ import_matter_js5.default.Body.setAngularVelocity(entry.body, omega);
2178
+ }
2179
+ /**
2180
+ * Set the absolute scale of an object on each axis. Both physics collision shape and
2181
+ * sprite rendering are updated together. Scaling changes the body's mass proportionally
2182
+ * (area scales by x*y). Use setObjectMassOverride afterwards if you need a fixed mass.
2183
+ * @param x - Scale factor on the X axis (1 = original size)
2184
+ * @param y - Scale factor on the Y axis (1 = original size)
2185
+ */
2186
+ setObjectScale(id, x, y) {
2187
+ const entry = this.objects.get(id);
2188
+ if (!entry) return;
2189
+ const currentX = entry.scaleX ?? 1;
2190
+ const currentY = entry.scaleY ?? 1;
2191
+ import_matter_js5.default.Body.scale(entry.body, x / currentX, y / currentY);
2192
+ if (entry.body.render.sprite && entry.baseSpriteSX !== void 0) {
2193
+ entry.body.render.sprite.xScale = entry.baseSpriteSX * x;
2194
+ entry.body.render.sprite.yScale = entry.baseSpriteSY * y;
2195
+ }
2196
+ entry.scaleX = x;
2197
+ entry.scaleY = y;
2198
+ }
2111
2199
  /**
2112
2200
  * Update the background configuration at runtime.
2113
2201
  */
@@ -2143,10 +2231,9 @@ var OverlayScene = class {
2143
2231
  /**
2144
2232
  * Spawn an object synchronously.
2145
2233
  * Object behavior is determined by tags:
2146
- * - 'falling': Object is dynamic (affected by gravity)
2234
+ * - 'static': Object is not affected by gravity (obstacle). Without this tag, object is dynamic by default.
2147
2235
  * - 'follow_window': Object follows mouse when grounded (walks toward mouse)
2148
2236
  * - 'grabable': Object can be grabbed and moved with mouse
2149
- * Without 'falling' tag, object is static.
2150
2237
  */
2151
2238
  spawnObject(config) {
2152
2239
  if (config.element) {
@@ -2165,11 +2252,20 @@ var OverlayScene = class {
2165
2252
  return result.id;
2166
2253
  }
2167
2254
  const id = crypto.randomUUID();
2255
+ const entityDescriptor = config.imageUrl ? config.imageUrl.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "image" : config.radius ? "circle" : config.shape?.type ?? "rect";
2256
+ const entityTag = `${entityDescriptor.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase().slice(0, 16)}-${id.slice(0, 4)}`;
2168
2257
  const tags = [...config.tags ?? []];
2258
+ tags.push(entityTag);
2169
2259
  if (config.gravityOverride && !tags.includes("gravity_override")) {
2170
2260
  tags.push("gravity_override");
2171
2261
  }
2172
- const isStatic = !tags.includes("falling");
2262
+ if (config.speedOverride !== void 0 && !tags.includes("speed_override")) {
2263
+ tags.push("speed_override");
2264
+ }
2265
+ if (config.massOverride !== void 0 && !tags.includes("mass_override")) {
2266
+ tags.push("mass_override");
2267
+ }
2268
+ const isStatic = tags.includes("static");
2173
2269
  logger.debug("OverlayScene", `Spawning object`, {
2174
2270
  id,
2175
2271
  tags,
@@ -2186,6 +2282,10 @@ var OverlayScene = class {
2186
2282
  } else {
2187
2283
  body = createObstacle(id, config, isStatic);
2188
2284
  }
2285
+ const naturalMass = body.mass;
2286
+ if (config.massOverride !== void 0) {
2287
+ import_matter_js5.default.Body.setMass(body, config.massOverride);
2288
+ }
2189
2289
  let pressureThreshold;
2190
2290
  if (config.pressureThreshold) {
2191
2291
  pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
@@ -2209,13 +2309,24 @@ var OverlayScene = class {
2209
2309
  shadow,
2210
2310
  originalPosition: shadow || clicksRemaining !== void 0 ? { x: config.x, y: config.y } : void 0,
2211
2311
  clicksRemaining,
2212
- gravityOverride: config.gravityOverride
2312
+ gravityOverride: config.gravityOverride,
2313
+ followTarget: tags.includes("follow_window") ? config.followTarget ?? "mouse" : void 0,
2314
+ speedOverride: tags.includes("speed_override") ? config.speedOverride ?? 1 : void 0,
2315
+ massOverride: tags.includes("mass_override") ? config.massOverride : void 0,
2316
+ originalMass: tags.includes("mass_override") ? naturalMass : void 0,
2317
+ entityTag,
2318
+ scaleX: 1,
2319
+ scaleY: 1,
2320
+ baseSpriteSX: body.render.sprite?.xScale,
2321
+ baseSpriteSY: body.render.sprite?.yScale
2213
2322
  };
2214
2323
  this.objects.set(id, entry);
2215
2324
  if (config.gravityOverride) this.gravityOverrideEntries.add(entry);
2325
+ if (tags.includes("follow_window")) this.followWindowEntries.add(entry);
2216
2326
  import_matter_js5.default.Composite.add(this.engine.world, body);
2217
2327
  if (isStatic && pressureThreshold !== void 0) {
2218
2328
  this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
2329
+ this.pressureObstacleIds.add(id);
2219
2330
  }
2220
2331
  this.emitLifecycleEvent("objectSpawned", this.toObjectState(entry));
2221
2332
  return id;
@@ -2226,11 +2337,20 @@ var OverlayScene = class {
2226
2337
  */
2227
2338
  async spawnObjectAsync(config) {
2228
2339
  const id = crypto.randomUUID();
2340
+ const entityDescriptor = config.imageUrl ? config.imageUrl.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "image" : config.radius ? "circle" : config.shape?.type ?? "rect";
2341
+ const entityTag = `${entityDescriptor.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase().slice(0, 16)}-${id.slice(0, 4)}`;
2229
2342
  const tags = [...config.tags ?? []];
2343
+ tags.push(entityTag);
2230
2344
  if (config.gravityOverride && !tags.includes("gravity_override")) {
2231
2345
  tags.push("gravity_override");
2232
2346
  }
2233
- const isStatic = !tags.includes("falling");
2347
+ if (config.speedOverride !== void 0 && !tags.includes("speed_override")) {
2348
+ tags.push("speed_override");
2349
+ }
2350
+ if (config.massOverride !== void 0 && !tags.includes("mass_override")) {
2351
+ tags.push("mass_override");
2352
+ }
2353
+ const isStatic = tags.includes("static");
2234
2354
  logger.debug("OverlayScene", `Spawning object async`, {
2235
2355
  id,
2236
2356
  tags,
@@ -2247,6 +2367,10 @@ var OverlayScene = class {
2247
2367
  } else {
2248
2368
  body = await createObstacleAsync(id, config, isStatic);
2249
2369
  }
2370
+ const naturalMass = body.mass;
2371
+ if (config.massOverride !== void 0) {
2372
+ import_matter_js5.default.Body.setMass(body, config.massOverride);
2373
+ }
2250
2374
  let pressureThreshold;
2251
2375
  if (config.pressureThreshold) {
2252
2376
  pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
@@ -2270,27 +2394,38 @@ var OverlayScene = class {
2270
2394
  shadow,
2271
2395
  originalPosition: shadow || clicksRemaining !== void 0 ? { x: config.x, y: config.y } : void 0,
2272
2396
  clicksRemaining,
2273
- gravityOverride: config.gravityOverride
2397
+ gravityOverride: config.gravityOverride,
2398
+ followTarget: tags.includes("follow_window") ? config.followTarget ?? "mouse" : void 0,
2399
+ speedOverride: tags.includes("speed_override") ? config.speedOverride ?? 1 : void 0,
2400
+ massOverride: tags.includes("mass_override") ? config.massOverride : void 0,
2401
+ originalMass: tags.includes("mass_override") ? naturalMass : void 0,
2402
+ entityTag,
2403
+ scaleX: 1,
2404
+ scaleY: 1,
2405
+ baseSpriteSX: body.render.sprite?.xScale,
2406
+ baseSpriteSY: body.render.sprite?.yScale
2274
2407
  };
2275
2408
  this.objects.set(id, entry);
2276
2409
  if (config.gravityOverride) this.gravityOverrideEntries.add(entry);
2410
+ if (tags.includes("follow_window")) this.followWindowEntries.add(entry);
2277
2411
  import_matter_js5.default.Composite.add(this.engine.world, body);
2278
2412
  if (isStatic && pressureThreshold !== void 0) {
2279
2413
  this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
2414
+ this.pressureObstacleIds.add(id);
2280
2415
  }
2281
2416
  this.emitLifecycleEvent("objectSpawned", this.toObjectState(entry));
2282
2417
  return id;
2283
2418
  }
2284
2419
  /**
2285
- * Add 'falling' tag to an object, making it dynamic (affected by gravity).
2420
+ * Make an object dynamic (remove 'static' tag, affected by gravity).
2286
2421
  * Also adds 'grabable' tag so released objects can be dragged.
2287
2422
  * This is the tag-based replacement for releaseObstacle().
2288
2423
  */
2289
2424
  addFallingTag(id) {
2290
2425
  const entry = this.objects.get(id);
2291
2426
  if (!entry) return;
2292
- if (!entry.tags.includes("falling")) {
2293
- entry.tags.push("falling");
2427
+ if (entry.tags.includes("static")) {
2428
+ entry.tags = entry.tags.filter((t) => t !== "static");
2294
2429
  import_matter_js5.default.Body.setStatic(entry.body, false);
2295
2430
  }
2296
2431
  if (!entry.tags.includes("grabable")) {
@@ -2298,34 +2433,66 @@ var OverlayScene = class {
2298
2433
  }
2299
2434
  }
2300
2435
  /**
2301
- * Add a tag to an object.
2436
+ * Add a tag to an object. Tags drive behavior — adding a tag activates the associated effect.
2437
+ * For 'gravity_override', also pass a gravityOverride value via setObjectGravityOverride first,
2438
+ * or the tag will default to {x:0, y:0} (hovering).
2302
2439
  */
2303
2440
  addTag(id, tag) {
2304
2441
  const entry = this.objects.get(id);
2305
2442
  if (!entry) return;
2306
2443
  if (!entry.tags.includes(tag)) {
2307
2444
  entry.tags.push(tag);
2308
- if (tag === "falling") {
2309
- import_matter_js5.default.Body.setStatic(entry.body, false);
2445
+ if (tag === "static") {
2446
+ import_matter_js5.default.Body.setStatic(entry.body, true);
2447
+ } else if (tag === "gravity_override") {
2448
+ if (!entry.gravityOverride) entry.gravityOverride = { x: 0, y: 0 };
2449
+ this.gravityOverrideEntries.add(entry);
2450
+ } else if (tag === "follow_window") {
2451
+ if (!entry.followTarget) entry.followTarget = "mouse";
2452
+ this.followWindowEntries.add(entry);
2453
+ } else if (tag === "speed_override") {
2454
+ if (entry.speedOverride === void 0) entry.speedOverride = 1;
2455
+ } else if (tag === "mass_override") {
2456
+ if (entry.originalMass === void 0) entry.originalMass = entry.body.mass;
2457
+ if (entry.massOverride === void 0) entry.massOverride = Math.round(entry.body.mass);
2458
+ import_matter_js5.default.Body.setMass(entry.body, entry.massOverride);
2310
2459
  }
2311
2460
  }
2312
2461
  }
2313
2462
  /**
2314
- * Remove a tag from an object.
2463
+ * Remove a tag from an object. Tags drive behavior — removing a tag deactivates the associated effect.
2315
2464
  */
2316
2465
  removeTag(id, tag) {
2317
2466
  const entry = this.objects.get(id);
2318
2467
  if (!entry) return;
2468
+ if (tag === entry.entityTag) {
2469
+ logger.warn("OverlayScene", `Cannot remove entity tag '${tag}' from '${id}' \u2014 entity tags are permanent identifiers and cannot be removed`);
2470
+ return;
2471
+ }
2319
2472
  const index = entry.tags.indexOf(tag);
2320
2473
  if (index !== -1) {
2321
2474
  entry.tags.splice(index, 1);
2322
- if (tag === "falling") {
2323
- import_matter_js5.default.Body.setStatic(entry.body, true);
2475
+ if (tag === "static") {
2476
+ import_matter_js5.default.Body.setStatic(entry.body, false);
2477
+ } else if (tag === "gravity_override") {
2478
+ this.gravityOverrideEntries.delete(entry);
2479
+ entry.gravityOverride = void 0;
2480
+ } else if (tag === "follow_window") {
2481
+ entry.followTarget = void 0;
2482
+ this.followWindowEntries.delete(entry);
2483
+ } else if (tag === "speed_override") {
2484
+ entry.speedOverride = void 0;
2485
+ } else if (tag === "mass_override") {
2486
+ if (entry.originalMass !== void 0) {
2487
+ import_matter_js5.default.Body.setMass(entry.body, entry.originalMass);
2488
+ }
2489
+ entry.massOverride = void 0;
2490
+ entry.originalMass = void 0;
2324
2491
  }
2325
2492
  }
2326
2493
  }
2327
2494
  /**
2328
- * Release an object (add 'falling' tag to make it dynamic).
2495
+ * Release an object (remove 'static' tag to make it dynamic).
2329
2496
  * Convenience method - equivalent to addFallingTag().
2330
2497
  */
2331
2498
  releaseObject(id) {
@@ -2340,7 +2507,7 @@ var OverlayScene = class {
2340
2507
  }
2341
2508
  }
2342
2509
  /**
2343
- * Release all static objects (add 'falling' and 'grabable' tags).
2510
+ * Release all static objects (remove 'static' tag, add 'grabable' tag).
2344
2511
  */
2345
2512
  releaseAllObjects() {
2346
2513
  for (const [id] of this.objects) {
@@ -2348,7 +2515,7 @@ var OverlayScene = class {
2348
2515
  }
2349
2516
  }
2350
2517
  /**
2351
- * Release objects by tag (add 'falling' and 'grabable' tags to matching objects).
2518
+ * Release objects by tag (remove 'static' tag, add 'grabable' tag to matching objects).
2352
2519
  */
2353
2520
  releaseObjectsByTag(tag) {
2354
2521
  for (const [id, entry] of this.objects) {
@@ -2363,6 +2530,8 @@ var OverlayScene = class {
2363
2530
  this.emitLifecycleEvent("objectRemoved", this.toObjectState(entry));
2364
2531
  import_matter_js5.default.Composite.remove(this.engine.world, entry.body);
2365
2532
  this.gravityOverrideEntries.delete(entry);
2533
+ this.followWindowEntries.delete(entry);
2534
+ this.pressureObstacleIds.delete(id);
2366
2535
  this.objects.delete(id);
2367
2536
  }
2368
2537
  removeObjects(ids) {
@@ -2376,6 +2545,8 @@ var OverlayScene = class {
2376
2545
  }
2377
2546
  this.objects.clear();
2378
2547
  this.gravityOverrideEntries.clear();
2548
+ this.followWindowEntries.clear();
2549
+ this.pressureObstacleIds.clear();
2379
2550
  }
2380
2551
  removeObjectsByTag(tag) {
2381
2552
  const toRemove = [];
@@ -2533,14 +2704,14 @@ var OverlayScene = class {
2533
2704
  }
2534
2705
  }
2535
2706
  /**
2536
- * Set the velocity of an object.
2707
+ * Set the velocity of an object. Y axis uses physical convention: negative = down, positive = up.
2537
2708
  * @param objectId - The ID of the object
2538
2709
  * @param velocity - The velocity vector to set
2539
2710
  */
2540
2711
  setVelocity(objectId, velocity) {
2541
2712
  const entry = this.objects.get(objectId);
2542
2713
  if (!entry) return;
2543
- import_matter_js5.default.Body.setVelocity(entry.body, velocity);
2714
+ import_matter_js5.default.Body.setVelocity(entry.body, { x: velocity.x, y: -velocity.y });
2544
2715
  }
2545
2716
  /**
2546
2717
  * Set the position of an object.
@@ -2763,7 +2934,7 @@ var OverlayScene = class {
2763
2934
  const width = config.width ?? element.offsetWidth;
2764
2935
  const height = config.height ?? element.offsetHeight;
2765
2936
  const tags = config.tags ?? [];
2766
- const isStatic = !tags.includes("falling");
2937
+ const isStatic = tags.includes("static");
2767
2938
  const body = import_matter_js5.default.Bodies.rectangle(x, y, width, height, {
2768
2939
  isStatic,
2769
2940
  label: `dom-${crypto.randomUUID().slice(0, 8)}`,
@@ -2771,6 +2942,8 @@ var OverlayScene = class {
2771
2942
  // Don't render the body, DOM element is the visual
2772
2943
  });
2773
2944
  const id = body.label;
2945
+ const entityTag = `dom-${id.slice(4, 8)}`;
2946
+ tags.push(entityTag);
2774
2947
  let pressureThreshold;
2775
2948
  if (config.pressureThreshold) {
2776
2949
  pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
@@ -2784,6 +2957,7 @@ var OverlayScene = class {
2784
2957
  id,
2785
2958
  body,
2786
2959
  tags,
2960
+ entityTag,
2787
2961
  spawnTime: performance.now(),
2788
2962
  pressureThreshold,
2789
2963
  weight: config.weight ?? 1,
@@ -2798,12 +2972,13 @@ var OverlayScene = class {
2798
2972
  this.updateDOMElementTransform(entry);
2799
2973
  if (isStatic && pressureThreshold !== void 0) {
2800
2974
  this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
2975
+ this.pressureObstacleIds.add(id);
2801
2976
  }
2802
2977
  if (clicksRemaining !== void 0) {
2803
2978
  const clickHandler = () => {
2804
2979
  const currentEntry = this.objects.get(id);
2805
2980
  if (!currentEntry) return;
2806
- if (currentEntry.tags.includes("falling")) return;
2981
+ if (!currentEntry.tags.includes("static")) return;
2807
2982
  if (currentEntry.clicksRemaining === void 0) return;
2808
2983
  currentEntry.clicksRemaining--;
2809
2984
  logger.debug("OverlayScene", `Click on DOM element: ${currentEntry.clicksRemaining} clicks remaining`);
@@ -2848,8 +3023,13 @@ var OverlayScene = class {
2848
3023
  const fontName = config.fontName ?? this.getDefaultFont()?.name ?? "handwritten";
2849
3024
  const basePath = `${fontsBasePath}${fontName}/`;
2850
3025
  const stringTag = config.stringTag ?? `str-${crypto.randomUUID().slice(0, 8)}`;
2851
- const baseTags = config.tags ?? [];
2852
- const isStatic = !baseTags.includes("falling");
3026
+ const isStatic = config.isStatic !== false;
3027
+ const baseTags = [...config.tags ?? []];
3028
+ if (isStatic && !baseTags.includes("static")) baseTags.push("static");
3029
+ else if (!isStatic) {
3030
+ const i = baseTags.indexOf("static");
3031
+ if (i !== -1) baseTags.splice(i, 1);
3032
+ }
2853
3033
  const letterColor = config.letterColor;
2854
3034
  const letterIds = [];
2855
3035
  const letterMap = /* @__PURE__ */ new Map();
@@ -2991,8 +3171,10 @@ var OverlayScene = class {
2991
3171
  const resolvedChar = charFileNames.get(char) ?? char;
2992
3172
  const originalImageUrl = `${basePath}${resolvedChar}.png`;
2993
3173
  const imageUrl = letterColor ? await tintImage(originalImageUrl, letterColor) : originalImageUrl;
2994
- const tags = [...config.tags ?? [], stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
2995
3174
  const id = crypto.randomUUID();
3175
+ const safeChar = char.match(/[a-zA-Z0-9]/) ? char.toLowerCase() : "sym";
3176
+ const entityTag = `letter-${safeChar}-${id.slice(0, 4)}`;
3177
+ const tags = [...baseTags, entityTag, stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
2996
3178
  const objectConfig = {
2997
3179
  x: centerX,
2998
3180
  y: centerY,
@@ -3038,10 +3220,12 @@ var OverlayScene = class {
3038
3220
  originalPosition: shadow || clicksRemaining !== void 0 ? { x: centerX, y: centerY } : void 0,
3039
3221
  imageUrl: shadow || clicksRemaining !== void 0 ? imageUrl : void 0,
3040
3222
  imageSize: shadow || clicksRemaining !== void 0 ? letterSize : void 0,
3041
- clicksRemaining
3223
+ clicksRemaining,
3224
+ entityTag
3042
3225
  };
3043
3226
  this.objects.set(id, entry);
3044
3227
  import_matter_js5.default.Composite.add(this.engine.world, result.body);
3228
+ if (pressureThreshold !== void 0) this.pressureObstacleIds.add(id);
3045
3229
  letterIds.push(id);
3046
3230
  letterMap.set(`${char}-${globalCharIndex}`, id);
3047
3231
  debugInfo.push({
@@ -3092,15 +3276,13 @@ var OverlayScene = class {
3092
3276
  }
3093
3277
  /**
3094
3278
  * Spawn falling text objects from a string.
3095
- * Same as addTextObstacles but with 'falling' tag (objects fall with gravity).
3279
+ * Same as addTextObstacles but ensures objects are dynamic (removes 'static' tag if present).
3096
3280
  */
3097
3281
  async spawnFallingTextObstacles(config) {
3098
- const tags = [...config.tags ?? []];
3099
- if (!tags.includes("falling")) tags.push("falling");
3100
- return this.addTextObstacles({ ...config, tags });
3282
+ return this.addTextObstacles({ ...config, isStatic: false });
3101
3283
  }
3102
3284
  /**
3103
- * Release all letters in a word (add 'falling' tag so they fall).
3285
+ * Release all letters in a word (remove 'static' tag so they fall).
3104
3286
  * @param wordTag - The word tag returned from addTextObstacles
3105
3287
  */
3106
3288
  releaseTextObstacles(wordTag) {
@@ -3147,8 +3329,13 @@ var OverlayScene = class {
3147
3329
  const { x, y, fontSize, fontUrl } = config;
3148
3330
  const text = config.text.replace(/\\n/g, "\n");
3149
3331
  const stringTag = config.stringTag ?? `str-${crypto.randomUUID().slice(0, 8)}`;
3150
- const baseTags = config.tags ?? [];
3151
- const isStatic = !baseTags.includes("falling");
3332
+ const isStatic = config.isStatic !== false;
3333
+ const baseTags = [...config.tags ?? []];
3334
+ if (isStatic && !baseTags.includes("static")) baseTags.push("static");
3335
+ else if (!isStatic) {
3336
+ const i = baseTags.indexOf("static");
3337
+ if (i !== -1) baseTags.splice(i, 1);
3338
+ }
3152
3339
  const fillColor = config.fillColor ?? "#ffffff";
3153
3340
  const fillColors = config.fillColors;
3154
3341
  const lineHeight = config.lineHeight ?? fontSize * 1.2;
@@ -3222,7 +3409,9 @@ var OverlayScene = class {
3222
3409
  const wordTag = `${stringTag}-word-${currentWordIndex}`;
3223
3410
  wordTagsSet.add(wordTag);
3224
3411
  const id = crypto.randomUUID();
3225
- const tags = [...config.tags ?? [], stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
3412
+ const safeChar = char.match(/[a-zA-Z0-9]/) ? char.toLowerCase() : "sym";
3413
+ const entityTag = `letter-${safeChar}-${id.slice(0, 4)}`;
3414
+ const tags = [...baseTags, entityTag, stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
3226
3415
  const bbox = glyphData.boundingBox;
3227
3416
  const glyphWidth = bbox ? bbox.x2 - bbox.x1 : glyphData.advanceWidth;
3228
3417
  const glyphHeight = bbox ? bbox.y2 - bbox.y1 : fontSize;
@@ -3284,10 +3473,12 @@ var OverlayScene = class {
3284
3473
  weight,
3285
3474
  shadow,
3286
3475
  originalPosition: shadow || clicksRemaining !== void 0 ? { x: body.position.x, y: body.position.y } : void 0,
3287
- clicksRemaining
3476
+ clicksRemaining,
3477
+ entityTag
3288
3478
  };
3289
3479
  this.objects.set(id, entry);
3290
3480
  import_matter_js5.default.Composite.add(this.engine.world, body);
3481
+ if (pressureThreshold !== void 0) this.pressureObstacleIds.add(id);
3291
3482
  letterIds.push(id);
3292
3483
  letterMap.set(`${char}-${globalCharIndex}`, id);
3293
3484
  currentX += glyphData.advanceWidth;
@@ -3327,12 +3518,10 @@ var OverlayScene = class {
3327
3518
  }
3328
3519
  /**
3329
3520
  * Spawn falling TTF text objects.
3330
- * Same as addTTFTextObstacles but with 'falling' tag (objects fall with gravity).
3521
+ * Same as addTTFTextObstacles but ensures objects are dynamic (removes 'static' tag if present).
3331
3522
  */
3332
3523
  async spawnFallingTTFTextObstacles(config) {
3333
- const tags = [...config.tags ?? []];
3334
- if (!tags.includes("falling")) tags.push("falling");
3335
- return this.addTTFTextObstacles({ ...config, tags });
3524
+ return this.addTTFTextObstacles({ ...config, isStatic: false });
3336
3525
  }
3337
3526
  // ==================== COMBINED TAG METHODS ====================
3338
3527
  removeAllByTag(tag) {
@@ -3443,37 +3632,29 @@ var OverlayScene = class {
3443
3632
  entry.domElement.style.setProperty("top", `${y - height / 2}px`, "important");
3444
3633
  entry.domElement.style.setProperty("transform", `rotate(${angleDeg}deg)`, "important");
3445
3634
  }
3446
- checkTTLExpiration() {
3635
+ /** TTL expiration + below-floor despawn in a single O(N) pass */
3636
+ checkExpiration() {
3447
3637
  const now = performance.now();
3448
- const expiredObjects = [];
3449
- for (const [id, entry] of this.objects) {
3450
- if (entry.ttl !== void 0 && now - entry.spawnTime >= entry.ttl) {
3451
- expiredObjects.push(id);
3452
- }
3453
- }
3454
- for (const id of expiredObjects) {
3455
- this.removeObject(id);
3456
- }
3457
- }
3458
- /** Despawn objects that have fallen below the floor by the configured distance */
3459
- checkDespawnBelowFloor() {
3460
3638
  const despawnDistance = this.config.despawnBelowFloor ?? 1;
3461
3639
  const containerHeight = this.config.bounds.bottom - this.config.bounds.top;
3462
3640
  const despawnY = this.config.bounds.bottom + containerHeight * despawnDistance;
3463
- const toDespawn = [];
3641
+ const toRemove = [];
3464
3642
  for (const [id, entry] of this.objects) {
3465
- if (entry.body.position.y > despawnY) {
3466
- toDespawn.push(id);
3643
+ if (entry.ttl !== void 0 && now - entry.spawnTime >= entry.ttl) {
3644
+ toRemove.push(id);
3645
+ } else if (entry.body.position.y > despawnY) {
3646
+ toRemove.push(id);
3467
3647
  }
3468
3648
  }
3469
- for (const id of toDespawn) {
3649
+ for (const id of toRemove) {
3470
3650
  this.removeObject(id);
3471
3651
  }
3472
3652
  }
3473
3653
  fireUpdateCallbacks() {
3654
+ if (this.updateCallbacks.length === 0) return;
3474
3655
  const objects = [];
3475
- this.objects.forEach((entry) => {
3476
- if (entry.tags.includes("falling")) {
3656
+ for (const entry of this.objects.values()) {
3657
+ if (!entry.tags.includes("static")) {
3477
3658
  objects.push({
3478
3659
  id: entry.id,
3479
3660
  x: entry.body.position.x,
@@ -3482,32 +3663,38 @@ var OverlayScene = class {
3482
3663
  tags: entry.tags
3483
3664
  });
3484
3665
  }
3485
- });
3666
+ }
3486
3667
  const data = { objects };
3487
- this.updateCallbacks.forEach((cb) => cb(data));
3668
+ for (const cb of this.updateCallbacks) cb(data);
3488
3669
  }
3489
3670
  };
3490
3671
 
3491
3672
  // src/tags.ts
3492
- var TAG_FALLING = "falling";
3673
+ var TAG_STATIC = "static";
3493
3674
  var TAG_FOLLOW_WINDOW = "follow_window";
3494
3675
  var TAG_GRABABLE = "grabable";
3495
3676
  var TAG_GRAVITY_OVERRIDE = "gravity_override";
3677
+ var TAG_SPEED_OVERRIDE = "speed_override";
3678
+ var TAG_MASS_OVERRIDE = "mass_override";
3496
3679
  var TAGS = {
3497
- FALLING: TAG_FALLING,
3680
+ STATIC: TAG_STATIC,
3498
3681
  FOLLOW_WINDOW: TAG_FOLLOW_WINDOW,
3499
3682
  GRABABLE: TAG_GRABABLE,
3500
- GRAVITY_OVERRIDE: TAG_GRAVITY_OVERRIDE
3683
+ GRAVITY_OVERRIDE: TAG_GRAVITY_OVERRIDE,
3684
+ SPEED_OVERRIDE: TAG_SPEED_OVERRIDE,
3685
+ MASS_OVERRIDE: TAG_MASS_OVERRIDE
3501
3686
  };
3502
3687
  // Annotate the CommonJS export names for ESM import in node:
3503
3688
  0 && (module.exports = {
3504
3689
  BackgroundManager,
3505
3690
  OverlayScene,
3506
3691
  TAGS,
3507
- TAG_FALLING,
3508
3692
  TAG_FOLLOW_WINDOW,
3509
3693
  TAG_GRABABLE,
3510
3694
  TAG_GRAVITY_OVERRIDE,
3695
+ TAG_MASS_OVERRIDE,
3696
+ TAG_SPEED_OVERRIDE,
3697
+ TAG_STATIC,
3511
3698
  clearFontCache,
3512
3699
  getGlyphData,
3513
3700
  getKerning,