@blorkfield/overlay-core 0.3.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/README.md +417 -0
- package/dist/index.cjs +2519 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +700 -0
- package/dist/index.d.ts +700 -0
- package/dist/index.js +2474 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2474 @@
|
|
|
1
|
+
// src/OverlayScene.ts
|
|
2
|
+
import Matter5 from "matter-js";
|
|
3
|
+
|
|
4
|
+
// src/engine.ts
|
|
5
|
+
import Matter from "matter-js";
|
|
6
|
+
function createEngine(gravity) {
|
|
7
|
+
const engine = Matter.Engine.create();
|
|
8
|
+
engine.gravity.y = gravity;
|
|
9
|
+
return engine;
|
|
10
|
+
}
|
|
11
|
+
function createRender(engine, canvas, config) {
|
|
12
|
+
const render = Matter.Render.create({
|
|
13
|
+
canvas,
|
|
14
|
+
engine,
|
|
15
|
+
options: {
|
|
16
|
+
width: config.bounds.right - config.bounds.left,
|
|
17
|
+
height: config.bounds.bottom - config.bounds.top,
|
|
18
|
+
wireframes: config.debug ?? false,
|
|
19
|
+
background: config.background ?? "transparent"
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return render;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/bodies.ts
|
|
26
|
+
import Matter2 from "matter-js";
|
|
27
|
+
|
|
28
|
+
// src/logger.ts
|
|
29
|
+
var LOG_LEVELS = {
|
|
30
|
+
debug: 0,
|
|
31
|
+
info: 1,
|
|
32
|
+
warn: 2,
|
|
33
|
+
error: 3
|
|
34
|
+
};
|
|
35
|
+
var currentLevel = "info";
|
|
36
|
+
function setLogLevel(level) {
|
|
37
|
+
currentLevel = level;
|
|
38
|
+
}
|
|
39
|
+
function getLogLevel() {
|
|
40
|
+
return currentLevel;
|
|
41
|
+
}
|
|
42
|
+
function shouldLog(level) {
|
|
43
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
44
|
+
}
|
|
45
|
+
function formatMessage(prefix, message, data) {
|
|
46
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[1].slice(0, -1);
|
|
47
|
+
const base = `[${timestamp}] [${prefix}] ${message}`;
|
|
48
|
+
return data !== void 0 ? `${base} ${JSON.stringify(data)}` : base;
|
|
49
|
+
}
|
|
50
|
+
var logger = {
|
|
51
|
+
debug(prefix, message, data) {
|
|
52
|
+
if (shouldLog("debug")) {
|
|
53
|
+
console.debug(formatMessage(prefix, message, data));
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
info(prefix, message, data) {
|
|
57
|
+
if (shouldLog("info")) {
|
|
58
|
+
console.info(formatMessage(prefix, message, data));
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
warn(prefix, message, data) {
|
|
62
|
+
if (shouldLog("warn")) {
|
|
63
|
+
console.warn(formatMessage(prefix, message, data));
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
error(prefix, message, data) {
|
|
67
|
+
if (shouldLog("error")) {
|
|
68
|
+
console.error(formatMessage(prefix, message, data));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// src/imageClip.ts
|
|
74
|
+
var LOG_PREFIX = "ImageClip";
|
|
75
|
+
var ALPHA_THRESHOLD = 128;
|
|
76
|
+
var contourCache = /* @__PURE__ */ new Map();
|
|
77
|
+
var CACHE_MAX_SIZE = 100;
|
|
78
|
+
var CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
79
|
+
function loadImage(url) {
|
|
80
|
+
logger.debug(LOG_PREFIX, `Loading image: ${url}`);
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const img = new Image();
|
|
83
|
+
img.crossOrigin = "anonymous";
|
|
84
|
+
img.onload = () => {
|
|
85
|
+
logger.debug(LOG_PREFIX, `Image loaded successfully`, { width: img.width, height: img.height });
|
|
86
|
+
resolve(img);
|
|
87
|
+
};
|
|
88
|
+
img.onerror = () => {
|
|
89
|
+
logger.error(LOG_PREFIX, `Failed to load image: ${url}`);
|
|
90
|
+
reject(new Error(`Failed to load image: ${url}`));
|
|
91
|
+
};
|
|
92
|
+
img.src = url;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function getImageAlphaData(img) {
|
|
96
|
+
const canvas = document.createElement("canvas");
|
|
97
|
+
canvas.width = img.width;
|
|
98
|
+
canvas.height = img.height;
|
|
99
|
+
const ctx = canvas.getContext("2d");
|
|
100
|
+
ctx.drawImage(img, 0, 0);
|
|
101
|
+
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
|
102
|
+
return { data: imageData.data, width: img.width, height: img.height };
|
|
103
|
+
}
|
|
104
|
+
function getAlpha(data, width, x, y) {
|
|
105
|
+
if (x < 0 || y < 0 || x >= width) return 0;
|
|
106
|
+
const idx = (y * width + x) * 4 + 3;
|
|
107
|
+
return data[idx] ?? 0;
|
|
108
|
+
}
|
|
109
|
+
function isSolid(data, width, x, y) {
|
|
110
|
+
return getAlpha(data, width, x, y) >= ALPHA_THRESHOLD;
|
|
111
|
+
}
|
|
112
|
+
function extractContour(data, width, height) {
|
|
113
|
+
logger.debug(LOG_PREFIX, `Extracting contour from image`, { width, height });
|
|
114
|
+
let startX = -1;
|
|
115
|
+
let startY = -1;
|
|
116
|
+
outer: for (let y2 = 0; y2 < height; y2++) {
|
|
117
|
+
for (let x2 = 0; x2 < width; x2++) {
|
|
118
|
+
if (isSolid(data, width, x2, y2)) {
|
|
119
|
+
startX = x2;
|
|
120
|
+
startY = y2;
|
|
121
|
+
break outer;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (startX === -1) {
|
|
126
|
+
logger.warn(LOG_PREFIX, `No solid pixels found in image`);
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
logger.debug(LOG_PREFIX, `Found starting point`, { startX, startY });
|
|
130
|
+
const contour = [];
|
|
131
|
+
let x = startX;
|
|
132
|
+
let y = startY;
|
|
133
|
+
let dir = 0;
|
|
134
|
+
const dx = [1, 0, -1, 0];
|
|
135
|
+
const dy = [0, 1, 0, -1];
|
|
136
|
+
do {
|
|
137
|
+
contour.push({ x, y });
|
|
138
|
+
for (let i = 0; i < 4; i++) {
|
|
139
|
+
const newDir = (dir + 3 + i) % 4;
|
|
140
|
+
const nx = x + dx[newDir];
|
|
141
|
+
const ny = y + dy[newDir];
|
|
142
|
+
if (nx >= 0 && nx < width && ny >= 0 && ny < height && isSolid(data, width, nx, ny)) {
|
|
143
|
+
x = nx;
|
|
144
|
+
y = ny;
|
|
145
|
+
dir = newDir;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (contour.length > width * height) {
|
|
150
|
+
logger.warn(LOG_PREFIX, `Contour extraction hit safety limit, breaking`);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
} while (x !== startX || y !== startY);
|
|
154
|
+
logger.debug(LOG_PREFIX, `Contour extracted`, { pointCount: contour.length });
|
|
155
|
+
return contour;
|
|
156
|
+
}
|
|
157
|
+
function simplifyContour(points, epsilon) {
|
|
158
|
+
if (points.length < 3) return points;
|
|
159
|
+
let maxDist = 0;
|
|
160
|
+
let maxIdx = 0;
|
|
161
|
+
const first = points[0];
|
|
162
|
+
const last = points[points.length - 1];
|
|
163
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
164
|
+
const dist = perpendicularDistance(points[i], first, last);
|
|
165
|
+
if (dist > maxDist) {
|
|
166
|
+
maxDist = dist;
|
|
167
|
+
maxIdx = i;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (maxDist > epsilon) {
|
|
171
|
+
const left = simplifyContour(points.slice(0, maxIdx + 1), epsilon);
|
|
172
|
+
const right = simplifyContour(points.slice(maxIdx), epsilon);
|
|
173
|
+
return left.slice(0, -1).concat(right);
|
|
174
|
+
}
|
|
175
|
+
return [first, last];
|
|
176
|
+
}
|
|
177
|
+
function perpendicularDistance(point, lineStart, lineEnd) {
|
|
178
|
+
const dx = lineEnd.x - lineStart.x;
|
|
179
|
+
const dy = lineEnd.y - lineStart.y;
|
|
180
|
+
const mag = Math.sqrt(dx * dx + dy * dy);
|
|
181
|
+
if (mag === 0) {
|
|
182
|
+
return Math.sqrt((point.x - lineStart.x) ** 2 + (point.y - lineStart.y) ** 2);
|
|
183
|
+
}
|
|
184
|
+
const u = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / (mag * mag);
|
|
185
|
+
const closestX = lineStart.x + u * dx;
|
|
186
|
+
const closestY = lineStart.y + u * dy;
|
|
187
|
+
return Math.sqrt((point.x - closestX) ** 2 + (point.y - closestY) ** 2);
|
|
188
|
+
}
|
|
189
|
+
function getClipBounds(vertices) {
|
|
190
|
+
if (vertices.length === 0) {
|
|
191
|
+
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
192
|
+
}
|
|
193
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
194
|
+
for (const v of vertices) {
|
|
195
|
+
minX = Math.min(minX, v.x);
|
|
196
|
+
minY = Math.min(minY, v.y);
|
|
197
|
+
maxX = Math.max(maxX, v.x);
|
|
198
|
+
maxY = Math.max(maxY, v.y);
|
|
199
|
+
}
|
|
200
|
+
return { minX, minY, maxX, maxY };
|
|
201
|
+
}
|
|
202
|
+
function normalizeVertices(vertices, targetSize, imageWidth, imageHeight) {
|
|
203
|
+
if (vertices.length === 0) return [];
|
|
204
|
+
let centerX;
|
|
205
|
+
let centerY;
|
|
206
|
+
let refWidth;
|
|
207
|
+
let refHeight;
|
|
208
|
+
if (imageWidth !== void 0 && imageHeight !== void 0) {
|
|
209
|
+
centerX = imageWidth / 2;
|
|
210
|
+
centerY = imageHeight / 2;
|
|
211
|
+
refWidth = imageWidth;
|
|
212
|
+
refHeight = imageHeight;
|
|
213
|
+
} else {
|
|
214
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
215
|
+
for (const v of vertices) {
|
|
216
|
+
minX = Math.min(minX, v.x);
|
|
217
|
+
minY = Math.min(minY, v.y);
|
|
218
|
+
maxX = Math.max(maxX, v.x);
|
|
219
|
+
maxY = Math.max(maxY, v.y);
|
|
220
|
+
}
|
|
221
|
+
centerX = (minX + maxX) / 2;
|
|
222
|
+
centerY = (minY + maxY) / 2;
|
|
223
|
+
refWidth = maxX - minX;
|
|
224
|
+
refHeight = maxY - minY;
|
|
225
|
+
}
|
|
226
|
+
const scale = targetSize / Math.max(refWidth, refHeight);
|
|
227
|
+
return vertices.map((v) => ({
|
|
228
|
+
x: (v.x - centerX) * scale,
|
|
229
|
+
y: (v.y - centerY) * scale
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
function scaleVertices(vertices, targetSize) {
|
|
233
|
+
return vertices.map((v) => ({
|
|
234
|
+
x: v.x * targetSize,
|
|
235
|
+
y: v.y * targetSize
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
function cleanupCache() {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
for (const [key, entry] of contourCache) {
|
|
241
|
+
if (now - entry.timestamp > CACHE_TTL_MS) {
|
|
242
|
+
contourCache.delete(key);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (contourCache.size > CACHE_MAX_SIZE) {
|
|
246
|
+
const entries = Array.from(contourCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
247
|
+
const toRemove = entries.slice(0, entries.length - CACHE_MAX_SIZE);
|
|
248
|
+
for (const [key] of toRemove) {
|
|
249
|
+
contourCache.delete(key);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function getVerticesAndDimensionsFromImage(imageUrl, targetSize) {
|
|
254
|
+
const cached = contourCache.get(imageUrl);
|
|
255
|
+
if (cached) {
|
|
256
|
+
logger.debug(LOG_PREFIX, `Cache hit for image`, { imageUrl, targetSize, cachedVertices: cached.vertices.length });
|
|
257
|
+
cached.timestamp = Date.now();
|
|
258
|
+
const scale = targetSize / Math.max(cached.imageWidth, cached.imageHeight);
|
|
259
|
+
const imageCenterX = cached.imageWidth / 2;
|
|
260
|
+
const imageCenterY = cached.imageHeight / 2;
|
|
261
|
+
const clipCenterX = (cached.clipBounds.minX + cached.clipBounds.maxX) / 2;
|
|
262
|
+
const clipCenterY = (cached.clipBounds.minY + cached.clipBounds.maxY) / 2;
|
|
263
|
+
return {
|
|
264
|
+
vertices: scaleVertices(cached.vertices, targetSize),
|
|
265
|
+
imageWidth: cached.imageWidth,
|
|
266
|
+
imageHeight: cached.imageHeight,
|
|
267
|
+
clipBounds: cached.clipBounds,
|
|
268
|
+
clipOffset: {
|
|
269
|
+
x: (clipCenterX - imageCenterX) * scale,
|
|
270
|
+
y: (clipCenterY - imageCenterY) * scale
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
logger.info(LOG_PREFIX, `Extracting vertices from image (cache miss)`, { imageUrl, targetSize });
|
|
275
|
+
try {
|
|
276
|
+
const img = await loadImage(imageUrl);
|
|
277
|
+
const { data, width, height } = getImageAlphaData(img);
|
|
278
|
+
const contour = extractContour(data, width, height);
|
|
279
|
+
if (contour.length < 3) {
|
|
280
|
+
logger.warn(LOG_PREFIX, `Contour has insufficient points`, { pointCount: contour.length });
|
|
281
|
+
const emptyClipBounds = { minX: 0, minY: 0, maxX: width, maxY: height };
|
|
282
|
+
return {
|
|
283
|
+
vertices: [],
|
|
284
|
+
imageWidth: width,
|
|
285
|
+
imageHeight: height,
|
|
286
|
+
clipBounds: emptyClipBounds,
|
|
287
|
+
clipOffset: { x: 0, y: 0 }
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const clipBounds = getClipBounds(contour);
|
|
291
|
+
const epsilon = Math.max(width, height) / 50;
|
|
292
|
+
const simplified = simplifyContour(contour, epsilon);
|
|
293
|
+
logger.debug(LOG_PREFIX, `Simplified contour`, { original: contour.length, simplified: simplified.length, epsilon });
|
|
294
|
+
if (simplified.length < 3) {
|
|
295
|
+
logger.warn(LOG_PREFIX, `Simplified contour has insufficient points`, { pointCount: simplified.length });
|
|
296
|
+
return {
|
|
297
|
+
vertices: [],
|
|
298
|
+
imageWidth: width,
|
|
299
|
+
imageHeight: height,
|
|
300
|
+
clipBounds,
|
|
301
|
+
clipOffset: { x: 0, y: 0 }
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const unitVertices = normalizeVertices(simplified, 1, width, height);
|
|
305
|
+
contourCache.set(imageUrl, {
|
|
306
|
+
vertices: unitVertices,
|
|
307
|
+
imageWidth: width,
|
|
308
|
+
imageHeight: height,
|
|
309
|
+
clipBounds,
|
|
310
|
+
timestamp: Date.now()
|
|
311
|
+
});
|
|
312
|
+
cleanupCache();
|
|
313
|
+
logger.info(LOG_PREFIX, `Vertices extracted and cached`, {
|
|
314
|
+
imageUrl,
|
|
315
|
+
vertexCount: unitVertices.length,
|
|
316
|
+
width,
|
|
317
|
+
height,
|
|
318
|
+
clipBounds
|
|
319
|
+
});
|
|
320
|
+
const scale = targetSize / Math.max(width, height);
|
|
321
|
+
const imageCenterX = width / 2;
|
|
322
|
+
const imageCenterY = height / 2;
|
|
323
|
+
const clipCenterX = (clipBounds.minX + clipBounds.maxX) / 2;
|
|
324
|
+
const clipCenterY = (clipBounds.minY + clipBounds.maxY) / 2;
|
|
325
|
+
return {
|
|
326
|
+
vertices: scaleVertices(unitVertices, targetSize),
|
|
327
|
+
imageWidth: width,
|
|
328
|
+
imageHeight: height,
|
|
329
|
+
clipBounds,
|
|
330
|
+
clipOffset: {
|
|
331
|
+
x: (clipCenterX - imageCenterX) * scale,
|
|
332
|
+
y: (clipCenterY - imageCenterY) * scale
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
} catch (error) {
|
|
336
|
+
logger.error(LOG_PREFIX, `Failed to extract vertices from image`, { error: String(error) });
|
|
337
|
+
return {
|
|
338
|
+
vertices: [],
|
|
339
|
+
imageWidth: 0,
|
|
340
|
+
imageHeight: 0,
|
|
341
|
+
clipBounds: { minX: 0, minY: 0, maxX: 0, maxY: 0 },
|
|
342
|
+
clipOffset: { x: 0, y: 0 }
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
var tintedImageCache = /* @__PURE__ */ new Map();
|
|
347
|
+
var TINTED_CACHE_MAX_SIZE = 200;
|
|
348
|
+
function parseColor(color) {
|
|
349
|
+
const canvas = document.createElement("canvas");
|
|
350
|
+
canvas.width = 1;
|
|
351
|
+
canvas.height = 1;
|
|
352
|
+
const ctx = canvas.getContext("2d");
|
|
353
|
+
ctx.fillStyle = color;
|
|
354
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
355
|
+
const data = ctx.getImageData(0, 0, 1, 1).data;
|
|
356
|
+
return { r: data[0], g: data[1], b: data[2] };
|
|
357
|
+
}
|
|
358
|
+
async function tintImage(imageUrl, color) {
|
|
359
|
+
const cacheKey = `${imageUrl}:${color}`;
|
|
360
|
+
const cached = tintedImageCache.get(cacheKey);
|
|
361
|
+
if (cached) {
|
|
362
|
+
logger.debug(LOG_PREFIX, `Tinted image cache hit`, { imageUrl, color });
|
|
363
|
+
return cached;
|
|
364
|
+
}
|
|
365
|
+
logger.debug(LOG_PREFIX, `Tinting image`, { imageUrl, color });
|
|
366
|
+
try {
|
|
367
|
+
const img = await loadImage(imageUrl);
|
|
368
|
+
const canvas = document.createElement("canvas");
|
|
369
|
+
canvas.width = img.width;
|
|
370
|
+
canvas.height = img.height;
|
|
371
|
+
const ctx = canvas.getContext("2d");
|
|
372
|
+
ctx.drawImage(img, 0, 0);
|
|
373
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
374
|
+
const data = imageData.data;
|
|
375
|
+
const rgb = parseColor(color);
|
|
376
|
+
if (!rgb) {
|
|
377
|
+
logger.warn(LOG_PREFIX, `Failed to parse color, returning original image`, { color });
|
|
378
|
+
return imageUrl;
|
|
379
|
+
}
|
|
380
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
381
|
+
const alpha = data[i + 3];
|
|
382
|
+
if (alpha > 0) {
|
|
383
|
+
data[i] = rgb.r;
|
|
384
|
+
data[i + 1] = rgb.g;
|
|
385
|
+
data[i + 2] = rgb.b;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
ctx.putImageData(imageData, 0, 0);
|
|
389
|
+
const dataUrl = canvas.toDataURL("image/png");
|
|
390
|
+
tintedImageCache.set(cacheKey, dataUrl);
|
|
391
|
+
if (tintedImageCache.size > TINTED_CACHE_MAX_SIZE) {
|
|
392
|
+
const firstKey = tintedImageCache.keys().next().value;
|
|
393
|
+
if (firstKey) {
|
|
394
|
+
tintedImageCache.delete(firstKey);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
logger.debug(LOG_PREFIX, `Image tinted successfully`, { imageUrl, color });
|
|
398
|
+
return dataUrl;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
logger.error(LOG_PREFIX, `Failed to tint image`, { error: String(error) });
|
|
401
|
+
return imageUrl;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/bodies.ts
|
|
406
|
+
var BOUNDARY_THICKNESS = 50;
|
|
407
|
+
var LOG_PREFIX2 = "Bodies";
|
|
408
|
+
var DEFAULT_RADIUS = 20;
|
|
409
|
+
var SHAPE_SIDES = {
|
|
410
|
+
triangle: 3,
|
|
411
|
+
rectangle: 4,
|
|
412
|
+
pentagon: 5,
|
|
413
|
+
hexagon: 6,
|
|
414
|
+
octagon: 8
|
|
415
|
+
};
|
|
416
|
+
function generatePolygonVertices(sides, radius, aspectRatio) {
|
|
417
|
+
if (sides === 4 && aspectRatio !== void 0 && aspectRatio !== 1) {
|
|
418
|
+
const width = radius * Math.sqrt(2 * aspectRatio / (1 + aspectRatio));
|
|
419
|
+
const height = width / aspectRatio;
|
|
420
|
+
return [
|
|
421
|
+
{ x: -width, y: -height },
|
|
422
|
+
{ x: width, y: -height },
|
|
423
|
+
{ x: width, y: height },
|
|
424
|
+
{ x: -width, y: height }
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
const vertices = [];
|
|
428
|
+
const angleStep = 2 * Math.PI / sides;
|
|
429
|
+
const startAngle = -Math.PI / 2;
|
|
430
|
+
for (let i = 0; i < sides; i++) {
|
|
431
|
+
const angle = startAngle + i * angleStep;
|
|
432
|
+
vertices.push({
|
|
433
|
+
x: radius * Math.cos(angle),
|
|
434
|
+
y: radius * Math.sin(angle)
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return vertices;
|
|
438
|
+
}
|
|
439
|
+
function getShapeVertices(shape, radius) {
|
|
440
|
+
if (shape.vertices && shape.vertices.length >= 3) {
|
|
441
|
+
logger.debug(LOG_PREFIX2, `Using custom vertices`, { count: shape.vertices.length });
|
|
442
|
+
return shape.vertices;
|
|
443
|
+
}
|
|
444
|
+
const sides = shape.sides ?? SHAPE_SIDES[shape.type];
|
|
445
|
+
if (!sides || sides < 3) {
|
|
446
|
+
logger.warn(LOG_PREFIX2, `Invalid polygon: need sides >= 3`, { type: shape.type, sides });
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
logger.debug(LOG_PREFIX2, `Generating polygon`, { type: shape.type, sides, aspectRatio: shape.aspectRatio });
|
|
450
|
+
return generatePolygonVertices(sides, radius, shape.aspectRatio);
|
|
451
|
+
}
|
|
452
|
+
function createBodyFromVertices(id, x, y, vertices, renderOptions) {
|
|
453
|
+
const matterVertices = vertices.map((v) => ({ x: v.x, y: v.y }));
|
|
454
|
+
const body = Matter2.Bodies.fromVertices(x, y, [matterVertices], {
|
|
455
|
+
restitution: 0.3,
|
|
456
|
+
friction: 0.1,
|
|
457
|
+
frictionAir: 0.01,
|
|
458
|
+
label: `entity:${id}`,
|
|
459
|
+
render: renderOptions
|
|
460
|
+
});
|
|
461
|
+
Matter2.Body.setPosition(body, { x, y });
|
|
462
|
+
return body;
|
|
463
|
+
}
|
|
464
|
+
function createBoundariesWithFloorConfig(bounds, floorConfig) {
|
|
465
|
+
const width = bounds.right - bounds.left;
|
|
466
|
+
const height = bounds.bottom - bounds.top;
|
|
467
|
+
const options = { isStatic: true, render: { visible: false } };
|
|
468
|
+
const walls = [
|
|
469
|
+
// Left wall
|
|
470
|
+
Matter2.Bodies.rectangle(
|
|
471
|
+
bounds.left - BOUNDARY_THICKNESS / 2,
|
|
472
|
+
bounds.top + height / 2,
|
|
473
|
+
BOUNDARY_THICKNESS,
|
|
474
|
+
height,
|
|
475
|
+
{ ...options, label: "leftWall" }
|
|
476
|
+
),
|
|
477
|
+
// Right wall
|
|
478
|
+
Matter2.Bodies.rectangle(
|
|
479
|
+
bounds.right + BOUNDARY_THICKNESS / 2,
|
|
480
|
+
bounds.top + height / 2,
|
|
481
|
+
BOUNDARY_THICKNESS,
|
|
482
|
+
height,
|
|
483
|
+
{ ...options, label: "rightWall" }
|
|
484
|
+
)
|
|
485
|
+
];
|
|
486
|
+
const segmentCount = floorConfig?.segments ?? 1;
|
|
487
|
+
const segmentWidth = width / segmentCount;
|
|
488
|
+
const floorSegments = [];
|
|
489
|
+
for (let i = 0; i < segmentCount; i++) {
|
|
490
|
+
const segmentX = bounds.left + (i + 0.5) * segmentWidth;
|
|
491
|
+
floorSegments.push(
|
|
492
|
+
Matter2.Bodies.rectangle(
|
|
493
|
+
segmentX,
|
|
494
|
+
bounds.bottom + BOUNDARY_THICKNESS / 2,
|
|
495
|
+
segmentWidth,
|
|
496
|
+
BOUNDARY_THICKNESS,
|
|
497
|
+
{ ...options, label: `floor-segment-${i}` }
|
|
498
|
+
)
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return { walls, floorSegments };
|
|
502
|
+
}
|
|
503
|
+
function createFillRenderOptions(config) {
|
|
504
|
+
return {
|
|
505
|
+
fillStyle: config.fillStyle ?? "#ff0000"
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function createSpriteRenderOptions(config, imageWidth, imageHeight) {
|
|
509
|
+
const radius = config.radius ?? DEFAULT_RADIUS;
|
|
510
|
+
const targetSize = radius * 2;
|
|
511
|
+
const maxDim = Math.max(imageWidth, imageHeight);
|
|
512
|
+
const spriteScale = targetSize / maxDim;
|
|
513
|
+
return {
|
|
514
|
+
sprite: {
|
|
515
|
+
texture: config.imageUrl,
|
|
516
|
+
xScale: spriteScale,
|
|
517
|
+
yScale: spriteScale
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function createCircleEntity(id, config) {
|
|
522
|
+
const radius = config.radius ?? DEFAULT_RADIUS;
|
|
523
|
+
logger.debug(LOG_PREFIX2, `Creating circle entity`, { id, radius });
|
|
524
|
+
return Matter2.Bodies.circle(config.x, config.y, radius, {
|
|
525
|
+
restitution: 0.3,
|
|
526
|
+
friction: 0.1,
|
|
527
|
+
frictionAir: 0.01,
|
|
528
|
+
label: `entity:${id}`,
|
|
529
|
+
render: createFillRenderOptions(config)
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
function createCircleEntityWithSprite(id, config, imageWidth, imageHeight) {
|
|
533
|
+
const radius = config.radius ?? DEFAULT_RADIUS;
|
|
534
|
+
logger.debug(LOG_PREFIX2, `Creating circle entity with sprite`, { id, radius, imageWidth, imageHeight });
|
|
535
|
+
return Matter2.Bodies.circle(config.x, config.y, radius, {
|
|
536
|
+
restitution: 0.3,
|
|
537
|
+
friction: 0.1,
|
|
538
|
+
frictionAir: 0.01,
|
|
539
|
+
label: `entity:${id}`,
|
|
540
|
+
render: createSpriteRenderOptions(config, imageWidth, imageHeight)
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
function createEntity(id, config) {
|
|
544
|
+
const shape = config.shape;
|
|
545
|
+
if (!shape || shape.type === "circle") {
|
|
546
|
+
return createCircleEntity(id, config);
|
|
547
|
+
}
|
|
548
|
+
if (config.imageUrl) {
|
|
549
|
+
logger.warn(LOG_PREFIX2, `Image provided but using sync createEntity - shape won't be extracted. Use createEntityAsync for image shape extraction.`);
|
|
550
|
+
}
|
|
551
|
+
const radius = config.radius ?? DEFAULT_RADIUS;
|
|
552
|
+
const vertices = getShapeVertices(shape, radius);
|
|
553
|
+
if (!vertices) {
|
|
554
|
+
logger.warn(LOG_PREFIX2, `Failed to get vertices, falling back to circle`, { type: shape.type });
|
|
555
|
+
return createCircleEntity(id, config);
|
|
556
|
+
}
|
|
557
|
+
logger.info(LOG_PREFIX2, `Creating polygon entity`, { id, type: shape.type, vertices: vertices.length });
|
|
558
|
+
return createBodyFromVertices(id, config.x, config.y, vertices, createFillRenderOptions(config));
|
|
559
|
+
}
|
|
560
|
+
async function createEntityAsync(id, config) {
|
|
561
|
+
const shape = config.shape;
|
|
562
|
+
if (shape?.type === "circle") {
|
|
563
|
+
logger.info(LOG_PREFIX2, `Creating circle entity (explicit)`, { id });
|
|
564
|
+
return createCircleEntity(id, config);
|
|
565
|
+
}
|
|
566
|
+
if (config.imageUrl) {
|
|
567
|
+
const radius = config.radius ?? DEFAULT_RADIUS;
|
|
568
|
+
logger.debug(LOG_PREFIX2, `Attempting to extract shape from image`, { id, imageUrl: config.imageUrl });
|
|
569
|
+
const { vertices, imageWidth, imageHeight } = await getVerticesAndDimensionsFromImage(config.imageUrl, radius * 2);
|
|
570
|
+
if (vertices.length >= 3) {
|
|
571
|
+
logger.debug(LOG_PREFIX2, `Image shape extraction succeeded`, { id, vertices: vertices.length, imageWidth, imageHeight });
|
|
572
|
+
return createBodyFromVertices(id, config.x, config.y, vertices, createSpriteRenderOptions(config, imageWidth, imageHeight));
|
|
573
|
+
}
|
|
574
|
+
logger.warn(LOG_PREFIX2, `Image shape extraction failed, falling back to circle`, { id, verticesFound: vertices.length });
|
|
575
|
+
return createCircleEntityWithSprite(id, config, imageWidth, imageHeight);
|
|
576
|
+
}
|
|
577
|
+
if (shape) {
|
|
578
|
+
const shapeRadius = config.radius ?? DEFAULT_RADIUS;
|
|
579
|
+
const vertices = getShapeVertices(shape, shapeRadius);
|
|
580
|
+
if (vertices) {
|
|
581
|
+
logger.info(LOG_PREFIX2, `Creating polygon entity`, { id, type: shape.type, vertices: vertices.length });
|
|
582
|
+
return createBodyFromVertices(id, config.x, config.y, vertices, createFillRenderOptions(config));
|
|
583
|
+
}
|
|
584
|
+
logger.warn(LOG_PREFIX2, `Failed to get vertices from shape config, falling back to circle`, { type: shape.type });
|
|
585
|
+
}
|
|
586
|
+
logger.info(LOG_PREFIX2, `Creating circle entity (default)`, { id });
|
|
587
|
+
return createCircleEntity(id, config);
|
|
588
|
+
}
|
|
589
|
+
function createObstacle(id, config, isStatic = true) {
|
|
590
|
+
const width = config.width ?? 100;
|
|
591
|
+
const height = config.height ?? 20;
|
|
592
|
+
return Matter2.Bodies.rectangle(config.x, config.y, width, height, {
|
|
593
|
+
isStatic,
|
|
594
|
+
label: `obstacle:${id}`,
|
|
595
|
+
render: {
|
|
596
|
+
visible: true,
|
|
597
|
+
fillStyle: config.fillStyle ?? "#4a4a6a"
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
async function getImageDimensions(imageUrl) {
|
|
602
|
+
const img = await loadImage(imageUrl);
|
|
603
|
+
return { width: img.width, height: img.height };
|
|
604
|
+
}
|
|
605
|
+
async function createBoxObstacleWithInfo(id, config, isStatic = true) {
|
|
606
|
+
const size = config.size ?? 50;
|
|
607
|
+
const { vertices, imageWidth, imageHeight, clipBounds, clipOffset } = await getVerticesAndDimensionsFromImage(config.imageUrl, size);
|
|
608
|
+
const maxDim = Math.max(imageWidth, imageHeight);
|
|
609
|
+
const spriteScale = size / maxDim;
|
|
610
|
+
const scaledWidth = imageWidth * spriteScale;
|
|
611
|
+
const scaledHeight = imageHeight * spriteScale;
|
|
612
|
+
let body;
|
|
613
|
+
if (vertices.length >= 3) {
|
|
614
|
+
const worldVertices = vertices.map((v) => ({
|
|
615
|
+
x: config.x + v.x,
|
|
616
|
+
y: config.y + v.y
|
|
617
|
+
}));
|
|
618
|
+
body = Matter2.Bodies.fromVertices(config.x, config.y, [worldVertices], {
|
|
619
|
+
isStatic,
|
|
620
|
+
label: `obstacle:${id}`,
|
|
621
|
+
render: {
|
|
622
|
+
sprite: {
|
|
623
|
+
texture: config.imageUrl,
|
|
624
|
+
xScale: spriteScale,
|
|
625
|
+
yScale: spriteScale
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
const spriteOffsetX = (config.x - body.position.x) / scaledWidth;
|
|
630
|
+
const spriteOffsetY = (config.y - body.position.y) / scaledHeight;
|
|
631
|
+
if (body.render.sprite) {
|
|
632
|
+
body.render.sprite.xOffset = 0.5 + spriteOffsetX;
|
|
633
|
+
body.render.sprite.yOffset = 0.5 + spriteOffsetY;
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
body = Matter2.Bodies.rectangle(config.x, config.y, scaledWidth, scaledHeight, {
|
|
637
|
+
isStatic,
|
|
638
|
+
label: `obstacle:${id}`,
|
|
639
|
+
render: {
|
|
640
|
+
sprite: {
|
|
641
|
+
texture: config.imageUrl,
|
|
642
|
+
xScale: spriteScale,
|
|
643
|
+
yScale: spriteScale
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
body,
|
|
650
|
+
imageWidth,
|
|
651
|
+
imageHeight,
|
|
652
|
+
scaledWidth,
|
|
653
|
+
scaledHeight,
|
|
654
|
+
clipBounds,
|
|
655
|
+
clipOffset
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
async function createObstacleAsync(id, config, isStatic = true) {
|
|
659
|
+
if (!config.imageUrl) {
|
|
660
|
+
return createObstacle(id, config, isStatic);
|
|
661
|
+
}
|
|
662
|
+
const size = config.size ?? 50;
|
|
663
|
+
logger.info(LOG_PREFIX2, `Creating image-based obstacle`, { id, imageUrl: config.imageUrl, size });
|
|
664
|
+
const { vertices, imageWidth, imageHeight } = await getVerticesAndDimensionsFromImage(config.imageUrl, size);
|
|
665
|
+
if (vertices.length >= 3) {
|
|
666
|
+
logger.info(LOG_PREFIX2, `Image obstacle shape extraction succeeded`, { id, vertices: vertices.length, imageWidth, imageHeight });
|
|
667
|
+
const matterVertices = vertices.map((v) => ({ x: v.x, y: v.y }));
|
|
668
|
+
const maxDim = Math.max(imageWidth, imageHeight);
|
|
669
|
+
const spriteScale = size / maxDim;
|
|
670
|
+
const body = Matter2.Bodies.fromVertices(config.x, config.y, [matterVertices], {
|
|
671
|
+
isStatic,
|
|
672
|
+
label: `obstacle:${id}`,
|
|
673
|
+
render: {
|
|
674
|
+
sprite: {
|
|
675
|
+
texture: config.imageUrl,
|
|
676
|
+
xScale: spriteScale,
|
|
677
|
+
yScale: spriteScale
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
Matter2.Body.setPosition(body, { x: config.x, y: config.y });
|
|
682
|
+
return body;
|
|
683
|
+
}
|
|
684
|
+
logger.warn(LOG_PREFIX2, `Image obstacle shape extraction failed, falling back to rectangle`, { id });
|
|
685
|
+
return createObstacle(id, config, isStatic);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// src/fontLoader.ts
|
|
689
|
+
import opentype from "opentype.js";
|
|
690
|
+
var LOG_PREFIX3 = "FontLoader";
|
|
691
|
+
var fontCache = /* @__PURE__ */ new Map();
|
|
692
|
+
async function loadFont(fontUrl) {
|
|
693
|
+
const cached = fontCache.get(fontUrl);
|
|
694
|
+
if (cached) {
|
|
695
|
+
logger.debug(LOG_PREFIX3, `Font cache hit`, { fontUrl });
|
|
696
|
+
return cached;
|
|
697
|
+
}
|
|
698
|
+
logger.info(LOG_PREFIX3, `Loading font`, { fontUrl });
|
|
699
|
+
const font = await opentype.load(fontUrl);
|
|
700
|
+
const loadedFont = {
|
|
701
|
+
font,
|
|
702
|
+
unitsPerEm: font.unitsPerEm,
|
|
703
|
+
ascender: font.ascender,
|
|
704
|
+
descender: font.descender
|
|
705
|
+
};
|
|
706
|
+
fontCache.set(fontUrl, loadedFont);
|
|
707
|
+
logger.info(LOG_PREFIX3, `Font loaded`, { fontUrl, unitsPerEm: font.unitsPerEm });
|
|
708
|
+
return loadedFont;
|
|
709
|
+
}
|
|
710
|
+
function glyphToVertices(glyph, fontSize, unitsPerEm) {
|
|
711
|
+
const path = glyph.getPath(0, 0, fontSize);
|
|
712
|
+
const commands = path.commands;
|
|
713
|
+
if (commands.length === 0) {
|
|
714
|
+
return [];
|
|
715
|
+
}
|
|
716
|
+
const vertices = [];
|
|
717
|
+
let currentX = 0;
|
|
718
|
+
let currentY = 0;
|
|
719
|
+
for (const cmd of commands) {
|
|
720
|
+
switch (cmd.type) {
|
|
721
|
+
case "M":
|
|
722
|
+
currentX = cmd.x;
|
|
723
|
+
currentY = cmd.y;
|
|
724
|
+
vertices.push({ x: currentX, y: currentY });
|
|
725
|
+
break;
|
|
726
|
+
case "L":
|
|
727
|
+
currentX = cmd.x;
|
|
728
|
+
currentY = cmd.y;
|
|
729
|
+
vertices.push({ x: currentX, y: currentY });
|
|
730
|
+
break;
|
|
731
|
+
case "Q":
|
|
732
|
+
{
|
|
733
|
+
const steps = 4;
|
|
734
|
+
for (let t = 1; t <= steps; t++) {
|
|
735
|
+
const tNorm = t / steps;
|
|
736
|
+
const x = (1 - tNorm) * (1 - tNorm) * currentX + 2 * (1 - tNorm) * tNorm * cmd.x1 + tNorm * tNorm * cmd.x;
|
|
737
|
+
const y = (1 - tNorm) * (1 - tNorm) * currentY + 2 * (1 - tNorm) * tNorm * cmd.y1 + tNorm * tNorm * cmd.y;
|
|
738
|
+
vertices.push({ x, y });
|
|
739
|
+
}
|
|
740
|
+
currentX = cmd.x;
|
|
741
|
+
currentY = cmd.y;
|
|
742
|
+
}
|
|
743
|
+
break;
|
|
744
|
+
case "C":
|
|
745
|
+
{
|
|
746
|
+
const steps = 6;
|
|
747
|
+
for (let t = 1; t <= steps; t++) {
|
|
748
|
+
const tNorm = t / steps;
|
|
749
|
+
const mt = 1 - tNorm;
|
|
750
|
+
const x = mt * mt * mt * currentX + 3 * mt * mt * tNorm * cmd.x1 + 3 * mt * tNorm * tNorm * cmd.x2 + tNorm * tNorm * tNorm * cmd.x;
|
|
751
|
+
const y = mt * mt * mt * currentY + 3 * mt * mt * tNorm * cmd.y1 + 3 * mt * tNorm * tNorm * cmd.y2 + tNorm * tNorm * tNorm * cmd.y;
|
|
752
|
+
vertices.push({ x, y });
|
|
753
|
+
}
|
|
754
|
+
currentX = cmd.x;
|
|
755
|
+
currentY = cmd.y;
|
|
756
|
+
}
|
|
757
|
+
break;
|
|
758
|
+
case "Z":
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return simplifyVertices(vertices, fontSize / 50);
|
|
763
|
+
}
|
|
764
|
+
function simplifyVertices(vertices, minDistance) {
|
|
765
|
+
if (vertices.length < 3) return vertices;
|
|
766
|
+
const result = [vertices[0]];
|
|
767
|
+
for (let i = 1; i < vertices.length; i++) {
|
|
768
|
+
const last = result[result.length - 1];
|
|
769
|
+
const curr = vertices[i];
|
|
770
|
+
const dist = Math.sqrt((curr.x - last.x) ** 2 + (curr.y - last.y) ** 2);
|
|
771
|
+
if (dist >= minDistance) {
|
|
772
|
+
result.push(curr);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return result;
|
|
776
|
+
}
|
|
777
|
+
function getGlyphData(loadedFont, char, fontSize) {
|
|
778
|
+
const { font, unitsPerEm } = loadedFont;
|
|
779
|
+
const glyph = font.charToGlyph(char);
|
|
780
|
+
if (!glyph) {
|
|
781
|
+
logger.warn(LOG_PREFIX3, `Glyph not found for character`, { char });
|
|
782
|
+
return {
|
|
783
|
+
vertices: [],
|
|
784
|
+
advanceWidth: fontSize / 2,
|
|
785
|
+
leftSideBearing: 0,
|
|
786
|
+
boundingBox: null
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const scale = fontSize / unitsPerEm;
|
|
790
|
+
const advanceWidth = (glyph.advanceWidth ?? 0) * scale;
|
|
791
|
+
const leftSideBearing = (glyph.leftSideBearing ?? 0) * scale;
|
|
792
|
+
const bbox = glyph.getBoundingBox();
|
|
793
|
+
const boundingBox = bbox ? {
|
|
794
|
+
x1: bbox.x1 * scale,
|
|
795
|
+
y1: bbox.y1 * scale,
|
|
796
|
+
x2: bbox.x2 * scale,
|
|
797
|
+
y2: bbox.y2 * scale
|
|
798
|
+
} : null;
|
|
799
|
+
const vertices = glyphToVertices(glyph, fontSize, unitsPerEm);
|
|
800
|
+
return {
|
|
801
|
+
vertices,
|
|
802
|
+
advanceWidth,
|
|
803
|
+
leftSideBearing,
|
|
804
|
+
boundingBox
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
function getKerning(loadedFont, char1, char2, fontSize) {
|
|
808
|
+
const { font, unitsPerEm } = loadedFont;
|
|
809
|
+
const glyph1 = font.charToGlyph(char1);
|
|
810
|
+
const glyph2 = font.charToGlyph(char2);
|
|
811
|
+
if (!glyph1 || !glyph2) return 0;
|
|
812
|
+
const kerning = font.getKerningValue(glyph1, glyph2);
|
|
813
|
+
const scale = fontSize / unitsPerEm;
|
|
814
|
+
return kerning * scale;
|
|
815
|
+
}
|
|
816
|
+
function measureText(loadedFont, text, fontSize) {
|
|
817
|
+
let width = 0;
|
|
818
|
+
const chars = text.split("");
|
|
819
|
+
for (let i = 0; i < chars.length; i++) {
|
|
820
|
+
const glyphData = getGlyphData(loadedFont, chars[i], fontSize);
|
|
821
|
+
width += glyphData.advanceWidth;
|
|
822
|
+
if (i < chars.length - 1) {
|
|
823
|
+
width += getKerning(loadedFont, chars[i], chars[i + 1], fontSize);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return width;
|
|
827
|
+
}
|
|
828
|
+
function clearFontCache() {
|
|
829
|
+
fontCache.clear();
|
|
830
|
+
logger.debug(LOG_PREFIX3, `Font cache cleared`);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// src/entity.ts
|
|
834
|
+
import Matter3 from "matter-js";
|
|
835
|
+
var MOUSE_FORCE = 1e-3;
|
|
836
|
+
function applyMouseForce(entity, mouseX, grounded) {
|
|
837
|
+
if (!grounded) return;
|
|
838
|
+
const direction = Math.sign(mouseX - entity.position.x);
|
|
839
|
+
Matter3.Body.applyForce(entity, entity.position, { x: MOUSE_FORCE * direction, y: 0 });
|
|
840
|
+
}
|
|
841
|
+
function wrapHorizontal(entity, bounds) {
|
|
842
|
+
if (entity.position.x < bounds.left) {
|
|
843
|
+
Matter3.Body.setPosition(entity, { x: bounds.right, y: entity.position.y });
|
|
844
|
+
} else if (entity.position.x > bounds.right) {
|
|
845
|
+
Matter3.Body.setPosition(entity, { x: bounds.left, y: entity.position.y });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/EffectManager.ts
|
|
850
|
+
import Matter4 from "matter-js";
|
|
851
|
+
var EffectManager = class {
|
|
852
|
+
constructor(bounds, spawnObjectAsync, getBody) {
|
|
853
|
+
this.effects = /* @__PURE__ */ new Map();
|
|
854
|
+
this.bounds = bounds;
|
|
855
|
+
this.spawnObjectAsync = spawnObjectAsync;
|
|
856
|
+
this.getBody = getBody;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Update bounds when scene resizes
|
|
860
|
+
*/
|
|
861
|
+
setBounds(bounds) {
|
|
862
|
+
this.bounds = bounds;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Add or update an effect configuration
|
|
866
|
+
*/
|
|
867
|
+
setEffect(config) {
|
|
868
|
+
const existing = this.effects.get(config.id);
|
|
869
|
+
if (existing) {
|
|
870
|
+
const wasDisabled = !existing.config.enabled;
|
|
871
|
+
const nowEnabled = config.enabled;
|
|
872
|
+
if (wasDisabled && nowEnabled) {
|
|
873
|
+
existing.lastSpawnTime = Date.now();
|
|
874
|
+
existing.spawnAccumulator = 0;
|
|
875
|
+
}
|
|
876
|
+
existing.config = config;
|
|
877
|
+
logger.debug("EffectManager", `Updated effect: ${config.id}`, { type: config.type, enabled: config.enabled });
|
|
878
|
+
} else {
|
|
879
|
+
this.effects.set(config.id, {
|
|
880
|
+
config,
|
|
881
|
+
lastSpawnTime: Date.now(),
|
|
882
|
+
spawnAccumulator: 0
|
|
883
|
+
});
|
|
884
|
+
logger.debug("EffectManager", `Added effect: ${config.id}`, { type: config.type, enabled: config.enabled });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Remove an effect
|
|
889
|
+
*/
|
|
890
|
+
removeEffect(id) {
|
|
891
|
+
this.effects.delete(id);
|
|
892
|
+
logger.debug("EffectManager", `Removed effect: ${id}`);
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Enable or disable an effect
|
|
896
|
+
*/
|
|
897
|
+
setEffectEnabled(id, enabled) {
|
|
898
|
+
const state = this.effects.get(id);
|
|
899
|
+
if (state) {
|
|
900
|
+
state.config.enabled = enabled;
|
|
901
|
+
if (enabled) {
|
|
902
|
+
state.lastSpawnTime = Date.now();
|
|
903
|
+
state.spawnAccumulator = 0;
|
|
904
|
+
}
|
|
905
|
+
logger.debug("EffectManager", `Effect ${id} ${enabled ? "enabled" : "disabled"}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Get current effect configuration
|
|
910
|
+
*/
|
|
911
|
+
getEffect(id) {
|
|
912
|
+
return this.effects.get(id)?.config;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Get all effect IDs
|
|
916
|
+
*/
|
|
917
|
+
getEffectIds() {
|
|
918
|
+
return Array.from(this.effects.keys());
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Check if an effect is enabled
|
|
922
|
+
*/
|
|
923
|
+
isEffectEnabled(id) {
|
|
924
|
+
return this.effects.get(id)?.config.enabled ?? false;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Called each frame to update effects and spawn objects
|
|
928
|
+
*/
|
|
929
|
+
update() {
|
|
930
|
+
const now = Date.now();
|
|
931
|
+
for (const state of this.effects.values()) {
|
|
932
|
+
if (!state.config.enabled) continue;
|
|
933
|
+
switch (state.config.type) {
|
|
934
|
+
case "burst":
|
|
935
|
+
this.updateBurstEffect(state, now);
|
|
936
|
+
break;
|
|
937
|
+
case "rain":
|
|
938
|
+
this.updateRainEffect(state, now);
|
|
939
|
+
break;
|
|
940
|
+
case "stream":
|
|
941
|
+
this.updateStreamEffect(state, now);
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
updateBurstEffect(state, now) {
|
|
947
|
+
const config = state.config;
|
|
948
|
+
const elapsed = now - state.lastSpawnTime;
|
|
949
|
+
if (elapsed >= config.burstInterval) {
|
|
950
|
+
state.lastSpawnTime = now;
|
|
951
|
+
this.spawnBurst(config);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
updateRainEffect(state, now) {
|
|
955
|
+
const config = state.config;
|
|
956
|
+
const elapsed = Math.min(now - state.lastSpawnTime, 100);
|
|
957
|
+
state.lastSpawnTime = now;
|
|
958
|
+
const deltaSeconds = elapsed / 1e3;
|
|
959
|
+
state.spawnAccumulator += config.spawnRate * deltaSeconds;
|
|
960
|
+
while (state.spawnAccumulator >= 1) {
|
|
961
|
+
state.spawnAccumulator -= 1;
|
|
962
|
+
this.spawnRainObject(config);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
async spawnBurst(config) {
|
|
966
|
+
const { bounds } = this;
|
|
967
|
+
const originX = config.origin?.x ?? this.randomInRange(bounds.left + 50, bounds.right - 50);
|
|
968
|
+
const originY = config.origin?.y ?? this.randomInRange(bounds.top + 50, bounds.bottom - 100);
|
|
969
|
+
logger.debug("EffectManager", `Spawning burst at (${originX.toFixed(0)}, ${originY.toFixed(0)})`, { count: config.burstCount });
|
|
970
|
+
const spawnData = [];
|
|
971
|
+
for (let i = 0; i < config.burstCount; i++) {
|
|
972
|
+
const objectConfig = this.selectObjectConfig(config.objectConfigs);
|
|
973
|
+
if (!objectConfig) continue;
|
|
974
|
+
const radius = this.calculateRadius(objectConfig);
|
|
975
|
+
const tags = [...objectConfig.objectConfig.tags ?? []];
|
|
976
|
+
if (!tags.includes("falling")) tags.push("falling");
|
|
977
|
+
const fullConfig = {
|
|
978
|
+
...objectConfig.objectConfig,
|
|
979
|
+
x: originX,
|
|
980
|
+
y: originY,
|
|
981
|
+
radius,
|
|
982
|
+
tags
|
|
983
|
+
};
|
|
984
|
+
spawnData.push({
|
|
985
|
+
config: fullConfig,
|
|
986
|
+
angle: Math.random() * Math.PI * 2
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
const ids = await Promise.all(
|
|
990
|
+
spawnData.map((data) => this.spawnObjectAsync(data.config))
|
|
991
|
+
);
|
|
992
|
+
for (let i = 0; i < ids.length; i++) {
|
|
993
|
+
const body = this.getBody(ids[i]);
|
|
994
|
+
if (body) {
|
|
995
|
+
const angle = spawnData[i].angle;
|
|
996
|
+
const force = config.burstForce;
|
|
997
|
+
Matter4.Body.setVelocity(body, {
|
|
998
|
+
x: Math.cos(angle) * force,
|
|
999
|
+
y: Math.sin(angle) * force
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
spawnRainObject(config) {
|
|
1005
|
+
const { bounds } = this;
|
|
1006
|
+
const spawnWidth = config.spawnWidth ?? 1;
|
|
1007
|
+
const totalWidth = bounds.right - bounds.left;
|
|
1008
|
+
const spawnAreaWidth = totalWidth * spawnWidth;
|
|
1009
|
+
const spawnAreaStart = bounds.left + (totalWidth - spawnAreaWidth) / 2;
|
|
1010
|
+
const objectConfig = this.selectObjectConfig(config.objectConfigs);
|
|
1011
|
+
if (!objectConfig) return;
|
|
1012
|
+
const radius = this.calculateRadius(objectConfig);
|
|
1013
|
+
const x = this.randomInRange(spawnAreaStart + radius, spawnAreaStart + spawnAreaWidth - radius);
|
|
1014
|
+
const y = bounds.top - radius;
|
|
1015
|
+
const tags = [...objectConfig.objectConfig.tags ?? []];
|
|
1016
|
+
if (!tags.includes("falling")) tags.push("falling");
|
|
1017
|
+
const fullConfig = {
|
|
1018
|
+
...objectConfig.objectConfig,
|
|
1019
|
+
x,
|
|
1020
|
+
y,
|
|
1021
|
+
radius,
|
|
1022
|
+
tags
|
|
1023
|
+
};
|
|
1024
|
+
this.spawnObjectAsync(fullConfig);
|
|
1025
|
+
}
|
|
1026
|
+
updateStreamEffect(state, now) {
|
|
1027
|
+
const config = state.config;
|
|
1028
|
+
const elapsed = Math.min(now - state.lastSpawnTime, 100);
|
|
1029
|
+
state.lastSpawnTime = now;
|
|
1030
|
+
const deltaSeconds = elapsed / 1e3;
|
|
1031
|
+
state.spawnAccumulator += config.spawnRate * deltaSeconds;
|
|
1032
|
+
while (state.spawnAccumulator >= 1) {
|
|
1033
|
+
state.spawnAccumulator -= 1;
|
|
1034
|
+
this.spawnStreamObject(config);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
async spawnStreamObject(config) {
|
|
1038
|
+
const objectConfig = this.selectObjectConfig(config.objectConfigs);
|
|
1039
|
+
if (!objectConfig) return;
|
|
1040
|
+
const radius = this.calculateRadius(objectConfig);
|
|
1041
|
+
const tags = [...objectConfig.objectConfig.tags ?? []];
|
|
1042
|
+
if (!tags.includes("falling")) tags.push("falling");
|
|
1043
|
+
const fullConfig = {
|
|
1044
|
+
...objectConfig.objectConfig,
|
|
1045
|
+
x: config.origin.x,
|
|
1046
|
+
y: config.origin.y,
|
|
1047
|
+
radius,
|
|
1048
|
+
tags
|
|
1049
|
+
};
|
|
1050
|
+
const dirLength = Math.sqrt(config.direction.x ** 2 + config.direction.y ** 2);
|
|
1051
|
+
const normalizedDir = dirLength > 0 ? { x: config.direction.x / dirLength, y: config.direction.y / dirLength } : { x: 0, y: 1 };
|
|
1052
|
+
const baseAngle = Math.atan2(normalizedDir.y, normalizedDir.x);
|
|
1053
|
+
const spreadAngle = (Math.random() * 2 - 1) * config.coneAngle;
|
|
1054
|
+
const finalAngle = baseAngle + spreadAngle;
|
|
1055
|
+
const id = await this.spawnObjectAsync(fullConfig);
|
|
1056
|
+
const body = this.getBody(id);
|
|
1057
|
+
if (body) {
|
|
1058
|
+
Matter4.Body.setVelocity(body, {
|
|
1059
|
+
x: Math.cos(finalAngle) * config.force,
|
|
1060
|
+
y: Math.sin(finalAngle) * config.force
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Select an object config based on probability weights
|
|
1066
|
+
*/
|
|
1067
|
+
selectObjectConfig(configs) {
|
|
1068
|
+
if (configs.length === 0) return null;
|
|
1069
|
+
const totalWeight = configs.reduce((sum, c) => sum + c.probability, 0);
|
|
1070
|
+
if (totalWeight === 0) return configs[0];
|
|
1071
|
+
let random = Math.random() * totalWeight;
|
|
1072
|
+
for (const config of configs) {
|
|
1073
|
+
random -= config.probability;
|
|
1074
|
+
if (random <= 0) return config;
|
|
1075
|
+
}
|
|
1076
|
+
return configs[configs.length - 1];
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Calculate radius based on scale range
|
|
1080
|
+
*/
|
|
1081
|
+
calculateRadius(config) {
|
|
1082
|
+
const baseRadius = config.baseRadius ?? 20;
|
|
1083
|
+
const scale = this.randomInRange(config.minScale, config.maxScale);
|
|
1084
|
+
return baseRadius * scale;
|
|
1085
|
+
}
|
|
1086
|
+
randomInRange(min, max) {
|
|
1087
|
+
return min + Math.random() * (max - min);
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
// src/OverlayScene.ts
|
|
1092
|
+
var OverlayScene = class {
|
|
1093
|
+
constructor(canvas, config) {
|
|
1094
|
+
/** All scene objects (unified - no more entity/obstacle distinction) */
|
|
1095
|
+
this.objects = /* @__PURE__ */ new Map();
|
|
1096
|
+
this.boundaries = [];
|
|
1097
|
+
this.updateCallbacks = [];
|
|
1098
|
+
this.mouseX = 0;
|
|
1099
|
+
this.animationFrameId = null;
|
|
1100
|
+
this.mouse = null;
|
|
1101
|
+
this.mouseConstraint = null;
|
|
1102
|
+
this.fonts = [];
|
|
1103
|
+
this.fontsInitialized = false;
|
|
1104
|
+
this.letterDebugInfo = /* @__PURE__ */ new Map();
|
|
1105
|
+
// wordTag -> debug info
|
|
1106
|
+
// Pressure tracking: maps obstacle ID -> Set of dynamic object IDs resting on it
|
|
1107
|
+
this.obstaclePressure = /* @__PURE__ */ new Map();
|
|
1108
|
+
this.previousPressure = /* @__PURE__ */ new Map();
|
|
1109
|
+
this.pressureLogTimer = 0;
|
|
1110
|
+
// Floor segment tracking
|
|
1111
|
+
this.floorSegments = [];
|
|
1112
|
+
this.floorSegmentPressure = /* @__PURE__ */ new Map();
|
|
1113
|
+
// segment index -> object IDs
|
|
1114
|
+
this.collapsedSegments = /* @__PURE__ */ new Set();
|
|
1115
|
+
/** Filter drag events - only allow grabbing objects with 'grabable' tag */
|
|
1116
|
+
this.handleStartDrag = (event) => {
|
|
1117
|
+
const body = event.body;
|
|
1118
|
+
if (!body) return;
|
|
1119
|
+
const entry = this.findObjectByBody(body);
|
|
1120
|
+
if (!entry || !entry.tags.includes("grabable")) {
|
|
1121
|
+
if (this.mouseConstraint) {
|
|
1122
|
+
this.mouseConstraint.constraint.bodyB = null;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
/** Handle canvas clicks for click-to-fall behavior */
|
|
1127
|
+
this.handleCanvasClick = (event) => {
|
|
1128
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1129
|
+
const x = event.clientX - rect.left;
|
|
1130
|
+
const y = event.clientY - rect.top;
|
|
1131
|
+
const bodies = Matter5.Query.point(
|
|
1132
|
+
Matter5.Composite.allBodies(this.engine.world),
|
|
1133
|
+
{ x, y }
|
|
1134
|
+
);
|
|
1135
|
+
for (const body of bodies) {
|
|
1136
|
+
const entry = this.findObjectByBody(body);
|
|
1137
|
+
if (!entry) continue;
|
|
1138
|
+
if (entry.tags.includes("falling")) continue;
|
|
1139
|
+
if (entry.clicksRemaining === void 0) continue;
|
|
1140
|
+
entry.clicksRemaining--;
|
|
1141
|
+
const name = this.getObstacleDisplayName(entry);
|
|
1142
|
+
logger.debug("OverlayScene", `Click on ${name}: ${entry.clicksRemaining} clicks remaining`);
|
|
1143
|
+
if (entry.clicksRemaining <= 0) {
|
|
1144
|
+
this.collapseObstacle(entry);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
// ==================== PRIVATE ====================
|
|
1149
|
+
this.loop = () => {
|
|
1150
|
+
this.effectManager.update();
|
|
1151
|
+
this.checkTTLExpiration();
|
|
1152
|
+
this.checkDespawnBelowFloor();
|
|
1153
|
+
this.updatePressure();
|
|
1154
|
+
const mouseX = this.mouse?.position.x ?? this.mouseX;
|
|
1155
|
+
for (const entry of this.objects.values()) {
|
|
1156
|
+
const isDragging = this.mouseConstraint?.body === entry.body;
|
|
1157
|
+
if (!isDragging && entry.tags.includes("follow")) {
|
|
1158
|
+
applyMouseForce(entry.body, mouseX, this.isGrounded(entry.body));
|
|
1159
|
+
}
|
|
1160
|
+
if (this.config.wrapHorizontal && entry.tags.includes("falling")) {
|
|
1161
|
+
wrapHorizontal(entry.body, this.config.bounds);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
if (!this.config.debug) {
|
|
1165
|
+
this.drawTTFGlyphs();
|
|
1166
|
+
}
|
|
1167
|
+
if (this.config.debug) {
|
|
1168
|
+
this.drawDebugOverlays();
|
|
1169
|
+
}
|
|
1170
|
+
this.fireUpdateCallbacks();
|
|
1171
|
+
this.animationFrameId = requestAnimationFrame(this.loop);
|
|
1172
|
+
};
|
|
1173
|
+
this.canvas = canvas;
|
|
1174
|
+
this.config = {
|
|
1175
|
+
gravity: 1,
|
|
1176
|
+
wrapHorizontal: true,
|
|
1177
|
+
debug: false,
|
|
1178
|
+
background: "transparent",
|
|
1179
|
+
...config
|
|
1180
|
+
};
|
|
1181
|
+
this.engine = createEngine(this.config.gravity);
|
|
1182
|
+
this.render = createRender(this.engine, canvas, this.config);
|
|
1183
|
+
this.runner = Matter5.Runner.create();
|
|
1184
|
+
const boundariesResult = createBoundariesWithFloorConfig(this.config.bounds, this.config.floorConfig);
|
|
1185
|
+
this.boundaries = [...boundariesResult.walls, ...boundariesResult.floorSegments];
|
|
1186
|
+
this.floorSegments = boundariesResult.floorSegments;
|
|
1187
|
+
Matter5.Composite.add(this.engine.world, this.boundaries);
|
|
1188
|
+
this.mouse = Matter5.Mouse.create(canvas);
|
|
1189
|
+
this.mouseConstraint = Matter5.MouseConstraint.create(this.engine, {
|
|
1190
|
+
mouse: this.mouse,
|
|
1191
|
+
constraint: {
|
|
1192
|
+
stiffness: 0.2,
|
|
1193
|
+
render: { visible: false }
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
Matter5.Composite.add(this.engine.world, this.mouseConstraint);
|
|
1197
|
+
Matter5.Events.on(this.mouseConstraint, "startdrag", this.handleStartDrag);
|
|
1198
|
+
canvas.addEventListener("click", this.handleCanvasClick);
|
|
1199
|
+
this.render.mouse = this.mouse;
|
|
1200
|
+
this.effectManager = new EffectManager(
|
|
1201
|
+
this.config.bounds,
|
|
1202
|
+
(cfg) => this.spawnObjectAsync(cfg),
|
|
1203
|
+
(id) => this.objects.get(id)?.body ?? null
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
static createContainer(parent, options = {}) {
|
|
1207
|
+
const canvas = document.createElement("canvas");
|
|
1208
|
+
let width;
|
|
1209
|
+
let height;
|
|
1210
|
+
if (options.fullscreen !== false && !options.width && !options.height) {
|
|
1211
|
+
width = parent.clientWidth;
|
|
1212
|
+
height = parent.clientHeight;
|
|
1213
|
+
canvas.style.width = "100%";
|
|
1214
|
+
canvas.style.height = "100%";
|
|
1215
|
+
} else {
|
|
1216
|
+
width = options.width ?? 800;
|
|
1217
|
+
height = options.height ?? 600;
|
|
1218
|
+
}
|
|
1219
|
+
canvas.width = width;
|
|
1220
|
+
canvas.height = height;
|
|
1221
|
+
parent.appendChild(canvas);
|
|
1222
|
+
return {
|
|
1223
|
+
canvas,
|
|
1224
|
+
bounds: { top: 0, bottom: height, left: 0, right: width }
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
/** Get a display name for an obstacle (letter char or short ID) */
|
|
1228
|
+
getObstacleDisplayName(entry) {
|
|
1229
|
+
const letterTag = entry.tags.find((t) => t.startsWith("letter-") && !t.startsWith("letter-index-"));
|
|
1230
|
+
if (letterTag) return letterTag.replace("letter-", "");
|
|
1231
|
+
if (entry.ttfGlyph) return entry.ttfGlyph.char;
|
|
1232
|
+
return entry.id.slice(0, 4);
|
|
1233
|
+
}
|
|
1234
|
+
/** Update pressure tracking - check which dynamic objects rest on static obstacles */
|
|
1235
|
+
updatePressure() {
|
|
1236
|
+
const obstacles = [];
|
|
1237
|
+
const dynamics = [];
|
|
1238
|
+
for (const entry of this.objects.values()) {
|
|
1239
|
+
if (entry.tags.includes("falling")) {
|
|
1240
|
+
if (Math.abs(entry.body.velocity.y) < 2) {
|
|
1241
|
+
dynamics.push(entry);
|
|
1242
|
+
}
|
|
1243
|
+
} else {
|
|
1244
|
+
obstacles.push(entry);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const newPressure = /* @__PURE__ */ new Map();
|
|
1248
|
+
for (const obstacle of obstacles) {
|
|
1249
|
+
const resting = /* @__PURE__ */ new Set();
|
|
1250
|
+
const obsBounds = obstacle.body.bounds;
|
|
1251
|
+
for (const dyn of dynamics) {
|
|
1252
|
+
const dynBounds = dyn.body.bounds;
|
|
1253
|
+
const tolerance = 10;
|
|
1254
|
+
const dynBottom = dynBounds.max.y;
|
|
1255
|
+
const obsTop = obsBounds.min.y;
|
|
1256
|
+
const obsBottom = obsBounds.max.y;
|
|
1257
|
+
const verticallyOn = dynBottom >= obsTop - tolerance && dynBottom <= obsBottom;
|
|
1258
|
+
const horizontalOverlap = dynBounds.max.x > obsBounds.min.x && dynBounds.min.x < obsBounds.max.x;
|
|
1259
|
+
if (verticallyOn && horizontalOverlap) {
|
|
1260
|
+
resting.add(dyn.id);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if (resting.size > 0) {
|
|
1264
|
+
newPressure.set(obstacle.id, resting);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
this.checkPressureThresholds(obstacles, newPressure);
|
|
1268
|
+
this.updateFloorSegmentPressure(dynamics);
|
|
1269
|
+
this.checkFloorSegmentThresholds();
|
|
1270
|
+
this.obstaclePressure = newPressure;
|
|
1271
|
+
this.previousPressure.clear();
|
|
1272
|
+
for (const [id, set] of newPressure) {
|
|
1273
|
+
this.previousPressure.set(id, set.size);
|
|
1274
|
+
}
|
|
1275
|
+
this.pressureLogTimer++;
|
|
1276
|
+
if (this.pressureLogTimer >= 120) {
|
|
1277
|
+
this.pressureLogTimer = 0;
|
|
1278
|
+
this.logPressureSummary();
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
/** Update pressure tracking for each floor segment */
|
|
1282
|
+
updateFloorSegmentPressure(dynamics) {
|
|
1283
|
+
this.floorSegmentPressure.clear();
|
|
1284
|
+
const onObstacles = /* @__PURE__ */ new Set();
|
|
1285
|
+
for (const objectIds of this.obstaclePressure.values()) {
|
|
1286
|
+
for (const id of objectIds) {
|
|
1287
|
+
onObstacles.add(id);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
for (let i = 0; i < this.floorSegments.length; i++) {
|
|
1291
|
+
if (this.collapsedSegments.has(i)) continue;
|
|
1292
|
+
const segment = this.floorSegments[i];
|
|
1293
|
+
const segmentBounds = segment.bounds;
|
|
1294
|
+
const resting = /* @__PURE__ */ new Set();
|
|
1295
|
+
for (const dyn of dynamics) {
|
|
1296
|
+
if (onObstacles.has(dyn.id)) continue;
|
|
1297
|
+
const dynBounds = dyn.body.bounds;
|
|
1298
|
+
const horizontalOverlap = dynBounds.max.x > segmentBounds.min.x && dynBounds.min.x < segmentBounds.max.x;
|
|
1299
|
+
if (horizontalOverlap) {
|
|
1300
|
+
resting.add(dyn.id);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (resting.size > 0) {
|
|
1304
|
+
this.floorSegmentPressure.set(i, resting);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/** Check floor segment thresholds and collapse segments that exceed them */
|
|
1309
|
+
checkFloorSegmentThresholds() {
|
|
1310
|
+
const floorConfig = this.config.floorConfig;
|
|
1311
|
+
const legacyThreshold = this.config.floorThreshold;
|
|
1312
|
+
if (!floorConfig?.threshold && legacyThreshold === void 0) return;
|
|
1313
|
+
for (let i = 0; i < this.floorSegments.length; i++) {
|
|
1314
|
+
if (this.collapsedSegments.has(i)) continue;
|
|
1315
|
+
let threshold;
|
|
1316
|
+
if (floorConfig?.threshold !== void 0) {
|
|
1317
|
+
threshold = Array.isArray(floorConfig.threshold) ? floorConfig.threshold[i] : floorConfig.threshold;
|
|
1318
|
+
} else if (legacyThreshold !== void 0) {
|
|
1319
|
+
threshold = legacyThreshold;
|
|
1320
|
+
}
|
|
1321
|
+
if (threshold === void 0) continue;
|
|
1322
|
+
const objectIds = this.floorSegmentPressure.get(i);
|
|
1323
|
+
const pressure = objectIds ? this.calculateWeightedPressure(objectIds) : 0;
|
|
1324
|
+
if (pressure >= threshold) {
|
|
1325
|
+
this.collapseFloorSegment(i, pressure, threshold);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/** Collapse a single floor segment */
|
|
1330
|
+
collapseFloorSegment(index, pressure, threshold) {
|
|
1331
|
+
if (this.collapsedSegments.has(index)) return;
|
|
1332
|
+
this.collapsedSegments.add(index);
|
|
1333
|
+
const segment = this.floorSegments[index];
|
|
1334
|
+
Matter5.Composite.remove(this.engine.world, segment);
|
|
1335
|
+
console.log(`[Pressure] Floor segment ${index} collapsed! (pressure: ${pressure} >= ${threshold})`);
|
|
1336
|
+
}
|
|
1337
|
+
/** Log a summary of pressure on all obstacles, grouped by word */
|
|
1338
|
+
logPressureSummary() {
|
|
1339
|
+
if (this.obstaclePressure.size === 0 && this.floorSegmentPressure.size === 0 && this.collapsedSegments.size === 0) return;
|
|
1340
|
+
const wordPressure = /* @__PURE__ */ new Map();
|
|
1341
|
+
for (const [obstacleId, objectIds] of this.obstaclePressure) {
|
|
1342
|
+
const entry = this.objects.get(obstacleId);
|
|
1343
|
+
if (!entry) continue;
|
|
1344
|
+
const wordTag = entry.tags.find((t) => t.includes("-word-"));
|
|
1345
|
+
const groupKey = wordTag ?? "other";
|
|
1346
|
+
const letter = this.getObstacleDisplayName(entry);
|
|
1347
|
+
const pressure = this.calculateWeightedPressure(objectIds);
|
|
1348
|
+
if (!wordPressure.has(groupKey)) {
|
|
1349
|
+
wordPressure.set(groupKey, []);
|
|
1350
|
+
}
|
|
1351
|
+
wordPressure.get(groupKey).push(`${letter}:${pressure}`);
|
|
1352
|
+
}
|
|
1353
|
+
const parts = [];
|
|
1354
|
+
for (const [wordTag, letters] of wordPressure) {
|
|
1355
|
+
const match = wordTag.match(/-word-(\d+)$/);
|
|
1356
|
+
const wordLabel = match ? `w${match[1]}` : wordTag.slice(0, 8);
|
|
1357
|
+
parts.push(`[${wordLabel}: ${letters.join(" ")}]`);
|
|
1358
|
+
}
|
|
1359
|
+
if (this.floorSegmentPressure.size > 0 || this.collapsedSegments.size > 0) {
|
|
1360
|
+
const floorConfig = this.config.floorConfig;
|
|
1361
|
+
const legacyThreshold = this.config.floorThreshold;
|
|
1362
|
+
let thresholdDisplay = "\u221E";
|
|
1363
|
+
if (floorConfig?.threshold !== void 0 && !Array.isArray(floorConfig.threshold)) {
|
|
1364
|
+
thresholdDisplay = floorConfig.threshold;
|
|
1365
|
+
} else if (legacyThreshold !== void 0) {
|
|
1366
|
+
thresholdDisplay = legacyThreshold;
|
|
1367
|
+
}
|
|
1368
|
+
const segmentParts = [];
|
|
1369
|
+
for (let i = 0; i < this.floorSegments.length; i++) {
|
|
1370
|
+
if (this.collapsedSegments.has(i)) {
|
|
1371
|
+
segmentParts.push(`s${i}=X`);
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
const objectIds = this.floorSegmentPressure.get(i);
|
|
1375
|
+
const pressure = objectIds ? this.calculateWeightedPressure(objectIds) : 0;
|
|
1376
|
+
if (pressure > 0) {
|
|
1377
|
+
segmentParts.push(`s${i}=${pressure}`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (segmentParts.length > 0) {
|
|
1381
|
+
parts.push(`[floor(t=${thresholdDisplay}): ${segmentParts.join(" ")}]`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (parts.length > 0) {
|
|
1385
|
+
console.log("[Pressure]", parts.join(" "));
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
/** Calculate weighted pressure from a set of object IDs */
|
|
1389
|
+
calculateWeightedPressure(objectIds) {
|
|
1390
|
+
let total = 0;
|
|
1391
|
+
for (const id of objectIds) {
|
|
1392
|
+
const entry = this.objects.get(id);
|
|
1393
|
+
if (entry) {
|
|
1394
|
+
total += entry.weight;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return total;
|
|
1398
|
+
}
|
|
1399
|
+
/** Check pressure thresholds and collapse obstacles that exceed them */
|
|
1400
|
+
checkPressureThresholds(obstacles, pressure) {
|
|
1401
|
+
const wordPressure = /* @__PURE__ */ new Map();
|
|
1402
|
+
const wordObstacles = /* @__PURE__ */ new Map();
|
|
1403
|
+
for (const obstacle of obstacles) {
|
|
1404
|
+
if (obstacle.pressureThreshold === void 0) continue;
|
|
1405
|
+
const objectsOnObstacle = pressure.get(obstacle.id);
|
|
1406
|
+
const obstaclePressure = objectsOnObstacle ? this.calculateWeightedPressure(objectsOnObstacle) : 0;
|
|
1407
|
+
if (obstacle.wordCollapseTag) {
|
|
1408
|
+
const currentTotal = wordPressure.get(obstacle.wordCollapseTag) ?? 0;
|
|
1409
|
+
wordPressure.set(obstacle.wordCollapseTag, currentTotal + obstaclePressure);
|
|
1410
|
+
if (!wordObstacles.has(obstacle.wordCollapseTag)) {
|
|
1411
|
+
wordObstacles.set(obstacle.wordCollapseTag, []);
|
|
1412
|
+
}
|
|
1413
|
+
wordObstacles.get(obstacle.wordCollapseTag).push(obstacle);
|
|
1414
|
+
} else {
|
|
1415
|
+
if (obstaclePressure >= obstacle.pressureThreshold) {
|
|
1416
|
+
this.collapseObstacle(obstacle);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
for (const [wordTag, total] of wordPressure) {
|
|
1421
|
+
const wordObs = wordObstacles.get(wordTag);
|
|
1422
|
+
if (!wordObs || wordObs.length === 0) continue;
|
|
1423
|
+
const threshold = wordObs[0].pressureThreshold;
|
|
1424
|
+
if (threshold !== void 0 && total >= threshold) {
|
|
1425
|
+
for (const obs of wordObs) {
|
|
1426
|
+
this.collapseObstacle(obs);
|
|
1427
|
+
}
|
|
1428
|
+
console.log(`[Pressure] Word collapsed! ${wordTag} (total: ${total} >= ${threshold})`);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
/** Convert a static obstacle to dynamic (make it fall) */
|
|
1433
|
+
collapseObstacle(entry) {
|
|
1434
|
+
if (entry.tags.includes("falling")) return;
|
|
1435
|
+
const name = this.getObstacleDisplayName(entry);
|
|
1436
|
+
console.log(`[Pressure] Collapsed: ${name}`);
|
|
1437
|
+
if (entry.shadow && entry.originalPosition) {
|
|
1438
|
+
this.createShadow(entry);
|
|
1439
|
+
}
|
|
1440
|
+
entry.tags.push("falling");
|
|
1441
|
+
Matter5.Body.setStatic(entry.body, false);
|
|
1442
|
+
entry.pressureThreshold = void 0;
|
|
1443
|
+
entry.wordCollapseTag = void 0;
|
|
1444
|
+
}
|
|
1445
|
+
/** Create a static shadow copy of an obstacle at its original position */
|
|
1446
|
+
async createShadow(entry) {
|
|
1447
|
+
if (!entry.originalPosition) return;
|
|
1448
|
+
const opacity = entry.shadow?.opacity ?? 0.3;
|
|
1449
|
+
const shadowId = `shadow-${entry.id}`;
|
|
1450
|
+
if (entry.ttfGlyph) {
|
|
1451
|
+
const body = Matter5.Bodies.circle(entry.originalPosition.x, entry.originalPosition.y, 1, {
|
|
1452
|
+
isStatic: true,
|
|
1453
|
+
isSensor: true,
|
|
1454
|
+
// Don't collide
|
|
1455
|
+
label: `shadow:${shadowId}`,
|
|
1456
|
+
render: { visible: false }
|
|
1457
|
+
});
|
|
1458
|
+
const shadowEntry2 = {
|
|
1459
|
+
id: shadowId,
|
|
1460
|
+
body,
|
|
1461
|
+
tags: ["shadow"],
|
|
1462
|
+
spawnTime: performance.now(),
|
|
1463
|
+
weight: 0,
|
|
1464
|
+
ttfGlyph: {
|
|
1465
|
+
...entry.ttfGlyph,
|
|
1466
|
+
fillColor: this.applyOpacityToColor(entry.ttfGlyph.fillColor, opacity)
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
this.objects.set(shadowId, shadowEntry2);
|
|
1470
|
+
Matter5.Composite.add(this.engine.world, body);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (!entry.imageUrl) return;
|
|
1474
|
+
const result = await createBoxObstacleWithInfo(shadowId, {
|
|
1475
|
+
x: entry.originalPosition.x,
|
|
1476
|
+
y: entry.originalPosition.y,
|
|
1477
|
+
imageUrl: entry.imageUrl,
|
|
1478
|
+
size: entry.imageSize ?? 50,
|
|
1479
|
+
tags: ["shadow"]
|
|
1480
|
+
}, true);
|
|
1481
|
+
result.body.isSensor = true;
|
|
1482
|
+
if (result.body.render.sprite) {
|
|
1483
|
+
result.body.render.opacity = opacity;
|
|
1484
|
+
}
|
|
1485
|
+
const shadowEntry = {
|
|
1486
|
+
id: shadowId,
|
|
1487
|
+
body: result.body,
|
|
1488
|
+
tags: ["shadow"],
|
|
1489
|
+
spawnTime: performance.now(),
|
|
1490
|
+
weight: 0
|
|
1491
|
+
// Shadows don't contribute to pressure
|
|
1492
|
+
};
|
|
1493
|
+
this.objects.set(shadowId, shadowEntry);
|
|
1494
|
+
Matter5.Composite.add(this.engine.world, result.body);
|
|
1495
|
+
}
|
|
1496
|
+
/** Apply opacity to a CSS color string */
|
|
1497
|
+
applyOpacityToColor(color, opacity) {
|
|
1498
|
+
if (color.startsWith("#")) {
|
|
1499
|
+
const hex = color.slice(1);
|
|
1500
|
+
let r, g, b;
|
|
1501
|
+
if (hex.length === 3) {
|
|
1502
|
+
r = parseInt(hex[0] + hex[0], 16);
|
|
1503
|
+
g = parseInt(hex[1] + hex[1], 16);
|
|
1504
|
+
b = parseInt(hex[2] + hex[2], 16);
|
|
1505
|
+
} else {
|
|
1506
|
+
r = parseInt(hex.slice(0, 2), 16);
|
|
1507
|
+
g = parseInt(hex.slice(2, 4), 16);
|
|
1508
|
+
b = parseInt(hex.slice(4, 6), 16);
|
|
1509
|
+
}
|
|
1510
|
+
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
|
1511
|
+
}
|
|
1512
|
+
if (color.startsWith("rgb")) {
|
|
1513
|
+
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
1514
|
+
if (match) {
|
|
1515
|
+
return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return color;
|
|
1519
|
+
}
|
|
1520
|
+
/** Find an object entry by its Matter.js body (handles compound body parts) */
|
|
1521
|
+
findObjectByBody(body) {
|
|
1522
|
+
const rootBody = body.parent ?? body;
|
|
1523
|
+
for (const entry of this.objects.values()) {
|
|
1524
|
+
if (entry.body === body || entry.body === rootBody) {
|
|
1525
|
+
return entry;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
/** Check if a body is grounded (low vertical velocity indicates resting on something) */
|
|
1531
|
+
isGrounded(body) {
|
|
1532
|
+
return Math.abs(body.velocity.y) < 0.5;
|
|
1533
|
+
}
|
|
1534
|
+
start() {
|
|
1535
|
+
Matter5.Render.run(this.render);
|
|
1536
|
+
Matter5.Runner.run(this.runner, this.engine);
|
|
1537
|
+
this.loop();
|
|
1538
|
+
}
|
|
1539
|
+
stop() {
|
|
1540
|
+
if (this.animationFrameId !== null) {
|
|
1541
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
1542
|
+
this.animationFrameId = null;
|
|
1543
|
+
}
|
|
1544
|
+
Matter5.Render.stop(this.render);
|
|
1545
|
+
Matter5.Runner.stop(this.runner);
|
|
1546
|
+
}
|
|
1547
|
+
destroy() {
|
|
1548
|
+
this.stop();
|
|
1549
|
+
if (this.mouseConstraint) {
|
|
1550
|
+
Matter5.Events.off(this.mouseConstraint, "startdrag", this.handleStartDrag);
|
|
1551
|
+
}
|
|
1552
|
+
this.canvas.removeEventListener("click", this.handleCanvasClick);
|
|
1553
|
+
Matter5.Engine.clear(this.engine);
|
|
1554
|
+
this.objects.clear();
|
|
1555
|
+
this.obstaclePressure.clear();
|
|
1556
|
+
this.previousPressure.clear();
|
|
1557
|
+
this.pressureLogTimer = 0;
|
|
1558
|
+
this.floorSegmentPressure.clear();
|
|
1559
|
+
this.collapsedSegments.clear();
|
|
1560
|
+
this.updateCallbacks = [];
|
|
1561
|
+
}
|
|
1562
|
+
setDebug(enabled) {
|
|
1563
|
+
this.config.debug = enabled;
|
|
1564
|
+
this.render.options.wireframes = enabled;
|
|
1565
|
+
for (const [, entry] of this.objects) {
|
|
1566
|
+
if (entry.ttfGlyph && entry.body.render) {
|
|
1567
|
+
entry.body.render.visible = enabled;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
resize(width, height) {
|
|
1572
|
+
this.canvas.width = width;
|
|
1573
|
+
this.canvas.height = height;
|
|
1574
|
+
this.config.bounds = { top: 0, bottom: height, left: 0, right: width };
|
|
1575
|
+
Matter5.Composite.remove(this.engine.world, this.boundaries);
|
|
1576
|
+
const boundariesResult = createBoundariesWithFloorConfig(this.config.bounds, this.config.floorConfig);
|
|
1577
|
+
this.boundaries = [...boundariesResult.walls, ...boundariesResult.floorSegments];
|
|
1578
|
+
this.floorSegments = boundariesResult.floorSegments;
|
|
1579
|
+
this.collapsedSegments.clear();
|
|
1580
|
+
this.floorSegmentPressure.clear();
|
|
1581
|
+
Matter5.Composite.add(this.engine.world, this.boundaries);
|
|
1582
|
+
this.render.options.width = width;
|
|
1583
|
+
this.render.options.height = height;
|
|
1584
|
+
this.render.canvas.width = width;
|
|
1585
|
+
this.render.canvas.height = height;
|
|
1586
|
+
this.effectManager.setBounds(this.config.bounds);
|
|
1587
|
+
}
|
|
1588
|
+
// ==================== OBJECT METHODS ====================
|
|
1589
|
+
/**
|
|
1590
|
+
* Spawn an object synchronously.
|
|
1591
|
+
* Object behavior is determined by tags:
|
|
1592
|
+
* - 'falling': Object is dynamic (affected by gravity)
|
|
1593
|
+
* - 'follow': Object follows mouse when grounded
|
|
1594
|
+
* - 'grabable': Object can be dragged
|
|
1595
|
+
* Without 'falling' tag, object is static.
|
|
1596
|
+
*/
|
|
1597
|
+
spawnObject(config) {
|
|
1598
|
+
const id = crypto.randomUUID();
|
|
1599
|
+
const tags = config.tags ?? [];
|
|
1600
|
+
const isStatic = !tags.includes("falling");
|
|
1601
|
+
logger.debug("OverlayScene", `Spawning object`, {
|
|
1602
|
+
id,
|
|
1603
|
+
tags,
|
|
1604
|
+
isStatic,
|
|
1605
|
+
shape: config.shape?.type ?? (config.radius ? "circle" : "rectangle"),
|
|
1606
|
+
ttl: config.ttl
|
|
1607
|
+
});
|
|
1608
|
+
let body;
|
|
1609
|
+
if (config.radius) {
|
|
1610
|
+
body = createEntity(id, config);
|
|
1611
|
+
if (isStatic) {
|
|
1612
|
+
Matter5.Body.setStatic(body, true);
|
|
1613
|
+
}
|
|
1614
|
+
} else {
|
|
1615
|
+
body = createObstacle(id, config, isStatic);
|
|
1616
|
+
}
|
|
1617
|
+
const entry = {
|
|
1618
|
+
id,
|
|
1619
|
+
body,
|
|
1620
|
+
tags,
|
|
1621
|
+
spawnTime: performance.now(),
|
|
1622
|
+
ttl: config.ttl,
|
|
1623
|
+
despawnEffect: config.despawnEffect,
|
|
1624
|
+
weight: config.weight ?? 1
|
|
1625
|
+
};
|
|
1626
|
+
this.objects.set(id, entry);
|
|
1627
|
+
Matter5.Composite.add(this.engine.world, body);
|
|
1628
|
+
return id;
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Spawn an object asynchronously. Required for image-based shapes that need
|
|
1632
|
+
* shape extraction from image alpha channel.
|
|
1633
|
+
*/
|
|
1634
|
+
async spawnObjectAsync(config) {
|
|
1635
|
+
const id = crypto.randomUUID();
|
|
1636
|
+
const tags = config.tags ?? [];
|
|
1637
|
+
const isStatic = !tags.includes("falling");
|
|
1638
|
+
logger.debug("OverlayScene", `Spawning object async`, {
|
|
1639
|
+
id,
|
|
1640
|
+
tags,
|
|
1641
|
+
isStatic,
|
|
1642
|
+
shape: config.shape?.type ?? (config.radius ? "circle" : "rectangle"),
|
|
1643
|
+
ttl: config.ttl
|
|
1644
|
+
});
|
|
1645
|
+
let body;
|
|
1646
|
+
if (config.radius) {
|
|
1647
|
+
body = await createEntityAsync(id, config);
|
|
1648
|
+
if (isStatic) {
|
|
1649
|
+
Matter5.Body.setStatic(body, true);
|
|
1650
|
+
}
|
|
1651
|
+
} else {
|
|
1652
|
+
body = await createObstacleAsync(id, config, isStatic);
|
|
1653
|
+
}
|
|
1654
|
+
const entry = {
|
|
1655
|
+
id,
|
|
1656
|
+
body,
|
|
1657
|
+
tags,
|
|
1658
|
+
spawnTime: performance.now(),
|
|
1659
|
+
ttl: config.ttl,
|
|
1660
|
+
despawnEffect: config.despawnEffect,
|
|
1661
|
+
weight: config.weight ?? 1
|
|
1662
|
+
};
|
|
1663
|
+
this.objects.set(id, entry);
|
|
1664
|
+
Matter5.Composite.add(this.engine.world, body);
|
|
1665
|
+
return id;
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Add 'falling' tag to an object, making it dynamic (affected by gravity).
|
|
1669
|
+
* Also adds 'grabable' tag so released objects can be dragged.
|
|
1670
|
+
* This is the tag-based replacement for releaseObstacle().
|
|
1671
|
+
*/
|
|
1672
|
+
addFallingTag(id) {
|
|
1673
|
+
const entry = this.objects.get(id);
|
|
1674
|
+
if (!entry) return;
|
|
1675
|
+
if (!entry.tags.includes("falling")) {
|
|
1676
|
+
entry.tags.push("falling");
|
|
1677
|
+
Matter5.Body.setStatic(entry.body, false);
|
|
1678
|
+
}
|
|
1679
|
+
if (!entry.tags.includes("grabable")) {
|
|
1680
|
+
entry.tags.push("grabable");
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Add a tag to an object.
|
|
1685
|
+
*/
|
|
1686
|
+
addTag(id, tag) {
|
|
1687
|
+
const entry = this.objects.get(id);
|
|
1688
|
+
if (!entry) return;
|
|
1689
|
+
if (!entry.tags.includes(tag)) {
|
|
1690
|
+
entry.tags.push(tag);
|
|
1691
|
+
if (tag === "falling") {
|
|
1692
|
+
Matter5.Body.setStatic(entry.body, false);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Remove a tag from an object.
|
|
1698
|
+
*/
|
|
1699
|
+
removeTag(id, tag) {
|
|
1700
|
+
const entry = this.objects.get(id);
|
|
1701
|
+
if (!entry) return;
|
|
1702
|
+
const index = entry.tags.indexOf(tag);
|
|
1703
|
+
if (index !== -1) {
|
|
1704
|
+
entry.tags.splice(index, 1);
|
|
1705
|
+
if (tag === "falling") {
|
|
1706
|
+
Matter5.Body.setStatic(entry.body, true);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Release an object (add 'falling' tag to make it dynamic).
|
|
1712
|
+
* Convenience method - equivalent to addFallingTag().
|
|
1713
|
+
*/
|
|
1714
|
+
releaseObject(id) {
|
|
1715
|
+
this.addFallingTag(id);
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Release multiple objects by their IDs.
|
|
1719
|
+
*/
|
|
1720
|
+
releaseObjects(ids) {
|
|
1721
|
+
for (const id of ids) {
|
|
1722
|
+
this.releaseObject(id);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Release all static objects (add 'falling' and 'grabable' tags).
|
|
1727
|
+
*/
|
|
1728
|
+
releaseAllObjects() {
|
|
1729
|
+
for (const [id] of this.objects) {
|
|
1730
|
+
this.addFallingTag(id);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Release objects by tag (add 'falling' and 'grabable' tags to matching objects).
|
|
1735
|
+
*/
|
|
1736
|
+
releaseObjectsByTag(tag) {
|
|
1737
|
+
for (const [id, entry] of this.objects) {
|
|
1738
|
+
if (entry.tags.includes(tag)) {
|
|
1739
|
+
this.addFallingTag(id);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
removeObject(id) {
|
|
1744
|
+
const entry = this.objects.get(id);
|
|
1745
|
+
if (!entry) return;
|
|
1746
|
+
Matter5.Composite.remove(this.engine.world, entry.body);
|
|
1747
|
+
this.objects.delete(id);
|
|
1748
|
+
}
|
|
1749
|
+
removeObjects(ids) {
|
|
1750
|
+
for (const id of ids) {
|
|
1751
|
+
this.removeObject(id);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
removeAllObjects() {
|
|
1755
|
+
for (const entry of this.objects.values()) {
|
|
1756
|
+
Matter5.Composite.remove(this.engine.world, entry.body);
|
|
1757
|
+
}
|
|
1758
|
+
this.objects.clear();
|
|
1759
|
+
}
|
|
1760
|
+
removeObjectsByTag(tag) {
|
|
1761
|
+
const toRemove = [];
|
|
1762
|
+
for (const [id, entry] of this.objects) {
|
|
1763
|
+
if (entry.tags.includes(tag)) {
|
|
1764
|
+
Matter5.Composite.remove(this.engine.world, entry.body);
|
|
1765
|
+
toRemove.push(id);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
toRemove.forEach((id) => this.objects.delete(id));
|
|
1769
|
+
this.letterDebugInfo.delete(tag);
|
|
1770
|
+
}
|
|
1771
|
+
getObjectIds() {
|
|
1772
|
+
return Array.from(this.objects.keys());
|
|
1773
|
+
}
|
|
1774
|
+
getObjectIdsByTag(tag) {
|
|
1775
|
+
const ids = [];
|
|
1776
|
+
for (const [id, entry] of this.objects) {
|
|
1777
|
+
if (entry.tags.includes(tag)) {
|
|
1778
|
+
ids.push(id);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return ids;
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Get all unique tags currently in use by objects in the scene.
|
|
1785
|
+
*/
|
|
1786
|
+
getAllTags() {
|
|
1787
|
+
const tagsSet = /* @__PURE__ */ new Set();
|
|
1788
|
+
for (const entry of this.objects.values()) {
|
|
1789
|
+
for (const tag of entry.tags) {
|
|
1790
|
+
tagsSet.add(tag);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return Array.from(tagsSet).sort();
|
|
1794
|
+
}
|
|
1795
|
+
setMousePosition(x, _y) {
|
|
1796
|
+
this.mouseX = x;
|
|
1797
|
+
}
|
|
1798
|
+
// ==================== PRESSURE TRACKING METHODS ====================
|
|
1799
|
+
/**
|
|
1800
|
+
* Get the current pressure (number of objects resting) on an obstacle.
|
|
1801
|
+
* @param obstacleId - The ID of the obstacle
|
|
1802
|
+
* @returns Number of objects currently resting on the obstacle
|
|
1803
|
+
*/
|
|
1804
|
+
getPressure(obstacleId) {
|
|
1805
|
+
return this.obstaclePressure.get(obstacleId)?.size ?? 0;
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Get the IDs of all objects currently resting on an obstacle.
|
|
1809
|
+
* @param obstacleId - The ID of the obstacle
|
|
1810
|
+
* @returns Array of object IDs resting on the obstacle
|
|
1811
|
+
*/
|
|
1812
|
+
getObjectsRestingOn(obstacleId) {
|
|
1813
|
+
const set = this.obstaclePressure.get(obstacleId);
|
|
1814
|
+
return set ? Array.from(set) : [];
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Get all obstacles that have pressure (at least one object resting on them).
|
|
1818
|
+
* @returns Map of obstacle ID -> pressure count
|
|
1819
|
+
*/
|
|
1820
|
+
getAllPressure() {
|
|
1821
|
+
const result = /* @__PURE__ */ new Map();
|
|
1822
|
+
for (const [id, set] of this.obstaclePressure) {
|
|
1823
|
+
if (set.size > 0) {
|
|
1824
|
+
result.set(id, set.size);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return result;
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Get pressure summary for all obstacles with their display names (letters).
|
|
1831
|
+
* Useful for debugging and visualization.
|
|
1832
|
+
* @returns Array of { id, name, pressure } objects
|
|
1833
|
+
*/
|
|
1834
|
+
getPressureSummary() {
|
|
1835
|
+
const summary = [];
|
|
1836
|
+
for (const [id, set] of this.obstaclePressure) {
|
|
1837
|
+
if (set.size > 0) {
|
|
1838
|
+
const entry = this.objects.get(id);
|
|
1839
|
+
summary.push({
|
|
1840
|
+
id,
|
|
1841
|
+
name: entry ? this.getObstacleDisplayName(entry) : id.slice(0, 4),
|
|
1842
|
+
pressure: set.size
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return summary;
|
|
1847
|
+
}
|
|
1848
|
+
// ==================== FONT MANAGEMENT METHODS ====================
|
|
1849
|
+
/**
|
|
1850
|
+
* Initialize fonts by loading the font manifest.
|
|
1851
|
+
* Should be called before using text obstacles if you want automatic font detection.
|
|
1852
|
+
* @param fontsBasePath Base URL path for fonts directory (default: '/fonts/')
|
|
1853
|
+
*/
|
|
1854
|
+
async initializeFonts(fontsBasePath = "/fonts/") {
|
|
1855
|
+
if (this.fontsInitialized) {
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
try {
|
|
1859
|
+
const manifestUrl = `${fontsBasePath}fonts.json`;
|
|
1860
|
+
const response = await fetch(manifestUrl);
|
|
1861
|
+
if (!response.ok) {
|
|
1862
|
+
logger.warn("OverlayScene", `Failed to load fonts manifest from ${manifestUrl}: ${response.status}`);
|
|
1863
|
+
this.fonts = [];
|
|
1864
|
+
this.fontsInitialized = true;
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const manifest = await response.json();
|
|
1868
|
+
this.fonts = manifest.fonts || [];
|
|
1869
|
+
for (const font of this.fonts) {
|
|
1870
|
+
if (font.type === "ttf" && font.fontUrl) {
|
|
1871
|
+
try {
|
|
1872
|
+
const fontFace = new FontFace(font.name, `url(${font.fontUrl})`);
|
|
1873
|
+
await fontFace.load();
|
|
1874
|
+
document.fonts.add(fontFace);
|
|
1875
|
+
logger.debug("OverlayScene", `Loaded TTF font: ${font.name}`);
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
logger.warn("OverlayScene", `Failed to load TTF font ${font.name}: ${err}`);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
this.fontsInitialized = true;
|
|
1882
|
+
logger.info("OverlayScene", `Loaded ${this.fonts.length} fonts`, { fonts: this.fonts.map((f) => f.name) });
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
logger.warn("OverlayScene", `Error loading fonts manifest: ${error}`);
|
|
1885
|
+
this.fonts = [];
|
|
1886
|
+
this.fontsInitialized = true;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Get list of available fonts.
|
|
1891
|
+
* Returns empty array if fonts have not been initialized.
|
|
1892
|
+
*/
|
|
1893
|
+
getAvailableFonts() {
|
|
1894
|
+
return [...this.fonts];
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Get font by index. Returns undefined if index is out of bounds.
|
|
1898
|
+
* @param index Font index (0-based)
|
|
1899
|
+
*/
|
|
1900
|
+
getFontByIndex(index) {
|
|
1901
|
+
return this.fonts[index];
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Get font by name. Returns undefined if font not found.
|
|
1905
|
+
* @param name Font name to find
|
|
1906
|
+
*/
|
|
1907
|
+
getFontByName(name) {
|
|
1908
|
+
return this.fonts.find((f) => f.name === name);
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Get the default font (first available font).
|
|
1912
|
+
* Returns undefined if no fonts are available.
|
|
1913
|
+
*/
|
|
1914
|
+
getDefaultFont() {
|
|
1915
|
+
return this.fonts[0];
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Check if fonts have been initialized.
|
|
1919
|
+
*/
|
|
1920
|
+
areFontsInitialized() {
|
|
1921
|
+
return this.fontsInitialized;
|
|
1922
|
+
}
|
|
1923
|
+
// ==================== TEXT OBSTACLE METHODS ====================
|
|
1924
|
+
/**
|
|
1925
|
+
* Create text obstacles from a string. Each character becomes an individual obstacle
|
|
1926
|
+
* with shape extracted from the corresponding letter PNG image.
|
|
1927
|
+
* Supported characters: A-Z, a-z, 0-9 (spaces handled, unsupported chars skipped)
|
|
1928
|
+
* Case is preserved: uses lowercase PNG if available, falls back to uppercase (and vice versa).
|
|
1929
|
+
* Supports multiline text with \n characters.
|
|
1930
|
+
*
|
|
1931
|
+
* Letter positioning is based on original PNG dimensions:
|
|
1932
|
+
* - Each letter's PNG width controls its horizontal spacing
|
|
1933
|
+
* - The clipped shape is positioned correctly within the original bounds
|
|
1934
|
+
* - This allows fine control of letter spacing via PNG canvas size
|
|
1935
|
+
*/
|
|
1936
|
+
async addTextObstacles(config) {
|
|
1937
|
+
const text = config.text.replace(/\\n/g, "\n");
|
|
1938
|
+
const letterSize = config.letterSize;
|
|
1939
|
+
const lineHeight = config.lineHeight ?? letterSize * 1.2;
|
|
1940
|
+
const fontsBasePath = config.fontsBasePath ?? "/fonts/";
|
|
1941
|
+
const fontName = config.fontName ?? this.getDefaultFont()?.name ?? "handwritten";
|
|
1942
|
+
const basePath = `${fontsBasePath}${fontName}/`;
|
|
1943
|
+
const stringTag = config.stringTag ?? `str-${crypto.randomUUID().slice(0, 8)}`;
|
|
1944
|
+
const baseTags = config.tags ?? [];
|
|
1945
|
+
const isStatic = !baseTags.includes("falling");
|
|
1946
|
+
const letterColor = config.letterColor;
|
|
1947
|
+
const letterIds = [];
|
|
1948
|
+
const letterMap = /* @__PURE__ */ new Map();
|
|
1949
|
+
const debugInfo = [];
|
|
1950
|
+
const wordTagsSet = /* @__PURE__ */ new Set();
|
|
1951
|
+
let currentWordIndex = 0;
|
|
1952
|
+
let inWord = false;
|
|
1953
|
+
const lines = text.split("\n");
|
|
1954
|
+
const uniqueChars = /* @__PURE__ */ new Set();
|
|
1955
|
+
for (const line of lines) {
|
|
1956
|
+
for (const char of line) {
|
|
1957
|
+
if (/^[A-Za-z0-9]$/.test(char)) {
|
|
1958
|
+
uniqueChars.add(char);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
const charDimensions = /* @__PURE__ */ new Map();
|
|
1963
|
+
const charFileNames = /* @__PURE__ */ new Map();
|
|
1964
|
+
await Promise.all(
|
|
1965
|
+
Array.from(uniqueChars).map(async (char) => {
|
|
1966
|
+
const imageUrl = `${basePath}${char}.png`;
|
|
1967
|
+
try {
|
|
1968
|
+
const dims = await getImageDimensions(imageUrl);
|
|
1969
|
+
charDimensions.set(char, dims);
|
|
1970
|
+
charFileNames.set(char, char);
|
|
1971
|
+
} catch {
|
|
1972
|
+
if (/^[A-Za-z]$/.test(char)) {
|
|
1973
|
+
const fallbackChar = char === char.toLowerCase() ? char.toUpperCase() : char.toLowerCase();
|
|
1974
|
+
const fallbackUrl = `${basePath}${fallbackChar}.png`;
|
|
1975
|
+
try {
|
|
1976
|
+
const dims = await getImageDimensions(fallbackUrl);
|
|
1977
|
+
charDimensions.set(char, dims);
|
|
1978
|
+
charFileNames.set(char, fallbackChar);
|
|
1979
|
+
logger.debug("OverlayScene", `Using fallback ${fallbackChar} for ${char}`);
|
|
1980
|
+
} catch (fallbackError) {
|
|
1981
|
+
logger.warn("OverlayScene", `Failed to load char ${char} (tried ${fallbackChar} too)`, { error: String(fallbackError) });
|
|
1982
|
+
charDimensions.set(char, { width: 100, height: 100 });
|
|
1983
|
+
charFileNames.set(char, char);
|
|
1984
|
+
}
|
|
1985
|
+
} else {
|
|
1986
|
+
logger.warn("OverlayScene", `Failed to load dimensions for char ${char}`);
|
|
1987
|
+
charDimensions.set(char, { width: 100, height: 100 });
|
|
1988
|
+
charFileNames.set(char, char);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
})
|
|
1992
|
+
);
|
|
1993
|
+
let maxDimension = 0;
|
|
1994
|
+
for (const dims of charDimensions.values()) {
|
|
1995
|
+
maxDimension = Math.max(maxDimension, dims.width, dims.height);
|
|
1996
|
+
}
|
|
1997
|
+
if (maxDimension === 0) maxDimension = 100;
|
|
1998
|
+
let currentY = config.y;
|
|
1999
|
+
let globalCharIndex = 0;
|
|
2000
|
+
for (const line of lines) {
|
|
2001
|
+
const chars = line.split("");
|
|
2002
|
+
let currentX = config.x;
|
|
2003
|
+
if (inWord) {
|
|
2004
|
+
currentWordIndex++;
|
|
2005
|
+
inWord = false;
|
|
2006
|
+
}
|
|
2007
|
+
for (let i = 0; i < chars.length; i++) {
|
|
2008
|
+
const char = chars[i];
|
|
2009
|
+
if (char === " ") {
|
|
2010
|
+
currentX += 20;
|
|
2011
|
+
globalCharIndex++;
|
|
2012
|
+
if (inWord) {
|
|
2013
|
+
currentWordIndex++;
|
|
2014
|
+
inWord = false;
|
|
2015
|
+
}
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
if (!/^[A-Za-z0-9]$/.test(char)) {
|
|
2019
|
+
globalCharIndex++;
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
inWord = true;
|
|
2023
|
+
const wordTag = `${stringTag}-word-${currentWordIndex}`;
|
|
2024
|
+
wordTagsSet.add(wordTag);
|
|
2025
|
+
const dims = charDimensions.get(char);
|
|
2026
|
+
const scale = letterSize / Math.max(dims.width, dims.height);
|
|
2027
|
+
const scaledWidth = dims.width * scale;
|
|
2028
|
+
const scaledHeight = dims.height * scale;
|
|
2029
|
+
const boxX = currentX;
|
|
2030
|
+
const boxY = currentY - scaledHeight / 2;
|
|
2031
|
+
const centerX = currentX + scaledWidth / 2;
|
|
2032
|
+
const centerY = currentY;
|
|
2033
|
+
const resolvedChar = charFileNames.get(char) ?? char;
|
|
2034
|
+
const originalImageUrl = `${basePath}${resolvedChar}.png`;
|
|
2035
|
+
const imageUrl = letterColor ? await tintImage(originalImageUrl, letterColor) : originalImageUrl;
|
|
2036
|
+
const tags = [...config.tags ?? [], stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
|
|
2037
|
+
const id = crypto.randomUUID();
|
|
2038
|
+
const objectConfig = {
|
|
2039
|
+
x: centerX,
|
|
2040
|
+
y: centerY,
|
|
2041
|
+
imageUrl,
|
|
2042
|
+
size: letterSize,
|
|
2043
|
+
tags,
|
|
2044
|
+
ttl: config.ttl
|
|
2045
|
+
};
|
|
2046
|
+
const result = await createBoxObstacleWithInfo(id, objectConfig, isStatic);
|
|
2047
|
+
let pressureThreshold;
|
|
2048
|
+
let wordCollapseTag;
|
|
2049
|
+
if (config.pressureThreshold) {
|
|
2050
|
+
const pt = config.pressureThreshold;
|
|
2051
|
+
if (Array.isArray(pt.value)) {
|
|
2052
|
+
pressureThreshold = pt.value[letterIds.length];
|
|
2053
|
+
} else {
|
|
2054
|
+
pressureThreshold = pt.value;
|
|
2055
|
+
if (pt.wordCollapse) {
|
|
2056
|
+
wordCollapseTag = wordTag;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
let weight = 1;
|
|
2061
|
+
if (config.weight) {
|
|
2062
|
+
if (Array.isArray(config.weight.value)) {
|
|
2063
|
+
weight = config.weight.value[letterIds.length] ?? 1;
|
|
2064
|
+
} else {
|
|
2065
|
+
weight = config.weight.value;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
const shadow = config.shadow ? { opacity: config.shadow.opacity ?? 0.3 } : void 0;
|
|
2069
|
+
const clicksRemaining = config.clickToFall?.clicks;
|
|
2070
|
+
const entry = {
|
|
2071
|
+
id,
|
|
2072
|
+
body: result.body,
|
|
2073
|
+
tags,
|
|
2074
|
+
spawnTime: performance.now(),
|
|
2075
|
+
ttl: config.ttl,
|
|
2076
|
+
pressureThreshold,
|
|
2077
|
+
wordCollapseTag,
|
|
2078
|
+
weight,
|
|
2079
|
+
shadow,
|
|
2080
|
+
originalPosition: shadow || clicksRemaining !== void 0 ? { x: centerX, y: centerY } : void 0,
|
|
2081
|
+
imageUrl: shadow || clicksRemaining !== void 0 ? imageUrl : void 0,
|
|
2082
|
+
imageSize: shadow || clicksRemaining !== void 0 ? letterSize : void 0,
|
|
2083
|
+
clicksRemaining
|
|
2084
|
+
};
|
|
2085
|
+
this.objects.set(id, entry);
|
|
2086
|
+
Matter5.Composite.add(this.engine.world, result.body);
|
|
2087
|
+
letterIds.push(id);
|
|
2088
|
+
letterMap.set(`${char}-${globalCharIndex}`, id);
|
|
2089
|
+
debugInfo.push({
|
|
2090
|
+
char,
|
|
2091
|
+
id,
|
|
2092
|
+
originalWidth: dims.width,
|
|
2093
|
+
originalHeight: dims.height,
|
|
2094
|
+
scaledWidth,
|
|
2095
|
+
scaledHeight,
|
|
2096
|
+
boxX,
|
|
2097
|
+
boxY,
|
|
2098
|
+
centerX,
|
|
2099
|
+
centerY
|
|
2100
|
+
});
|
|
2101
|
+
const extraSpacing = config.letterSpacing !== void 0 ? config.letterSpacing - scaledWidth : 0;
|
|
2102
|
+
currentX += scaledWidth + Math.max(0, extraSpacing);
|
|
2103
|
+
globalCharIndex++;
|
|
2104
|
+
}
|
|
2105
|
+
currentY += lineHeight;
|
|
2106
|
+
}
|
|
2107
|
+
this.letterDebugInfo.set(stringTag, debugInfo);
|
|
2108
|
+
const wordTags = Array.from(wordTagsSet);
|
|
2109
|
+
logger.info("OverlayScene", `Created text obstacles`, {
|
|
2110
|
+
text: text.replace(/\n/g, "\\n"),
|
|
2111
|
+
fontName,
|
|
2112
|
+
letterCount: letterIds.length,
|
|
2113
|
+
stringTag,
|
|
2114
|
+
wordTags,
|
|
2115
|
+
letterColor,
|
|
2116
|
+
lineCount: lines.length
|
|
2117
|
+
});
|
|
2118
|
+
return {
|
|
2119
|
+
letterIds,
|
|
2120
|
+
stringTag,
|
|
2121
|
+
wordTags,
|
|
2122
|
+
letterMap,
|
|
2123
|
+
letterDebugInfo: debugInfo
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Spawn falling text objects from a string.
|
|
2128
|
+
* Same as addTextObstacles but with 'falling' tag (objects fall with gravity).
|
|
2129
|
+
*/
|
|
2130
|
+
async spawnFallingTextObstacles(config) {
|
|
2131
|
+
const tags = [...config.tags ?? []];
|
|
2132
|
+
if (!tags.includes("falling")) tags.push("falling");
|
|
2133
|
+
return this.addTextObstacles({ ...config, tags });
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* Release all letters in a word (add 'falling' tag so they fall).
|
|
2137
|
+
* @param wordTag - The word tag returned from addTextObstacles
|
|
2138
|
+
*/
|
|
2139
|
+
releaseTextObstacles(wordTag) {
|
|
2140
|
+
this.releaseObjectsByTag(wordTag);
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* Release letters one by one with a delay between each.
|
|
2144
|
+
* @param wordTag - The word tag returned from addTextObstacles
|
|
2145
|
+
* @param delayMs - Delay between releasing each letter (default: 100ms)
|
|
2146
|
+
* @param reverse - If true, release from end to start (default: false)
|
|
2147
|
+
*/
|
|
2148
|
+
async releaseTextObstaclesSequentially(wordTag, delayMs = 100, reverse = false) {
|
|
2149
|
+
const ids = this.getObjectIdsByTag(wordTag);
|
|
2150
|
+
if (reverse) ids.reverse();
|
|
2151
|
+
for (const id of ids) {
|
|
2152
|
+
this.releaseObject(id);
|
|
2153
|
+
if (delayMs > 0) {
|
|
2154
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Get letter debug info for a word.
|
|
2160
|
+
* Returns the debug info array for the given word tag, or undefined if not found.
|
|
2161
|
+
* Debug info includes original dimension boxes for each letter.
|
|
2162
|
+
*/
|
|
2163
|
+
getLetterDebugInfo(wordTag) {
|
|
2164
|
+
return this.letterDebugInfo.get(wordTag);
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Get all stored letter debug info (all words).
|
|
2168
|
+
* Returns a map of wordTag -> debug info array.
|
|
2169
|
+
*/
|
|
2170
|
+
getAllLetterDebugInfo() {
|
|
2171
|
+
return new Map(this.letterDebugInfo);
|
|
2172
|
+
}
|
|
2173
|
+
// ==================== TTF FONT TEXT METHODS ====================
|
|
2174
|
+
/**
|
|
2175
|
+
* Create text obstacles from a TTF/OTF font file.
|
|
2176
|
+
* Uses proper font metrics for spacing, kerning, and glyph outlines for collision.
|
|
2177
|
+
* Supports multiline text with \n characters.
|
|
2178
|
+
*/
|
|
2179
|
+
async addTTFTextObstacles(config) {
|
|
2180
|
+
const { x, y, fontSize, fontUrl } = config;
|
|
2181
|
+
const text = config.text.replace(/\\n/g, "\n");
|
|
2182
|
+
const stringTag = config.stringTag ?? `str-${crypto.randomUUID().slice(0, 8)}`;
|
|
2183
|
+
const baseTags = config.tags ?? [];
|
|
2184
|
+
const isStatic = !baseTags.includes("falling");
|
|
2185
|
+
const fillColor = config.fillColor ?? "#ffffff";
|
|
2186
|
+
const lineHeight = config.lineHeight ?? fontSize * 1.2;
|
|
2187
|
+
const letterIds = [];
|
|
2188
|
+
const letterMap = /* @__PURE__ */ new Map();
|
|
2189
|
+
const wordTagsSet = /* @__PURE__ */ new Set();
|
|
2190
|
+
let currentWordIndex = 0;
|
|
2191
|
+
let inWord = false;
|
|
2192
|
+
const loadedFont = await loadFont(fontUrl);
|
|
2193
|
+
const fontInfo = this.fonts.find((f) => f.fontUrl === fontUrl);
|
|
2194
|
+
const fontFamily = fontInfo?.name ?? "sans-serif";
|
|
2195
|
+
const lines = text.split("\n");
|
|
2196
|
+
let currentY = y;
|
|
2197
|
+
let globalCharIndex = 0;
|
|
2198
|
+
for (const line of lines) {
|
|
2199
|
+
let currentX = x;
|
|
2200
|
+
if (inWord) {
|
|
2201
|
+
currentWordIndex++;
|
|
2202
|
+
inWord = false;
|
|
2203
|
+
}
|
|
2204
|
+
const chars = line.split("");
|
|
2205
|
+
for (let i = 0; i < chars.length; i++) {
|
|
2206
|
+
const char = chars[i];
|
|
2207
|
+
const glyphData = getGlyphData(loadedFont, char, fontSize);
|
|
2208
|
+
if (glyphData.vertices.length < 3) {
|
|
2209
|
+
currentX += glyphData.advanceWidth;
|
|
2210
|
+
if (i < chars.length - 1) {
|
|
2211
|
+
currentX += getKerning(loadedFont, char, chars[i + 1], fontSize);
|
|
2212
|
+
}
|
|
2213
|
+
globalCharIndex++;
|
|
2214
|
+
if (char === " " && inWord) {
|
|
2215
|
+
currentWordIndex++;
|
|
2216
|
+
inWord = false;
|
|
2217
|
+
}
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
2220
|
+
inWord = true;
|
|
2221
|
+
const wordTag = `${stringTag}-word-${currentWordIndex}`;
|
|
2222
|
+
wordTagsSet.add(wordTag);
|
|
2223
|
+
const id = crypto.randomUUID();
|
|
2224
|
+
const tags = [...config.tags ?? [], stringTag, wordTag, `letter-${char}`, `letter-index-${globalCharIndex}`];
|
|
2225
|
+
const bbox = glyphData.boundingBox;
|
|
2226
|
+
const glyphWidth = bbox ? bbox.x2 - bbox.x1 : glyphData.advanceWidth;
|
|
2227
|
+
const glyphHeight = bbox ? bbox.y2 - bbox.y1 : fontSize;
|
|
2228
|
+
const glyphCenterX = currentX + (bbox ? bbox.x1 + glyphWidth / 2 : glyphData.advanceWidth / 2);
|
|
2229
|
+
const glyphCenterY = currentY - (bbox ? (bbox.y1 + bbox.y2) / 2 : fontSize / 2);
|
|
2230
|
+
const worldVertices = glyphData.vertices.map((v) => ({
|
|
2231
|
+
x: currentX + v.x,
|
|
2232
|
+
y: currentY + v.y
|
|
2233
|
+
}));
|
|
2234
|
+
const body = Matter5.Bodies.fromVertices(glyphCenterX, glyphCenterY, [worldVertices], {
|
|
2235
|
+
isStatic,
|
|
2236
|
+
label: `obstacle:${id}`,
|
|
2237
|
+
render: {
|
|
2238
|
+
visible: false
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
const offsetX = currentX - body.position.x;
|
|
2242
|
+
const offsetY = currentY - body.position.y;
|
|
2243
|
+
let pressureThreshold;
|
|
2244
|
+
let wordCollapseTag;
|
|
2245
|
+
if (config.pressureThreshold) {
|
|
2246
|
+
const pt = config.pressureThreshold;
|
|
2247
|
+
if (Array.isArray(pt.value)) {
|
|
2248
|
+
pressureThreshold = pt.value[letterIds.length];
|
|
2249
|
+
} else {
|
|
2250
|
+
pressureThreshold = pt.value;
|
|
2251
|
+
if (pt.wordCollapse) {
|
|
2252
|
+
wordCollapseTag = wordTag;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
let weight = 1;
|
|
2257
|
+
if (config.weight) {
|
|
2258
|
+
if (Array.isArray(config.weight.value)) {
|
|
2259
|
+
weight = config.weight.value[letterIds.length] ?? 1;
|
|
2260
|
+
} else {
|
|
2261
|
+
weight = config.weight.value;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
const shadow = config.shadow ? { opacity: config.shadow.opacity ?? 0.3 } : void 0;
|
|
2265
|
+
const clicksRemaining = config.clickToFall?.clicks;
|
|
2266
|
+
const entry = {
|
|
2267
|
+
id,
|
|
2268
|
+
body,
|
|
2269
|
+
tags,
|
|
2270
|
+
spawnTime: performance.now(),
|
|
2271
|
+
ttl: config.ttl,
|
|
2272
|
+
ttfGlyph: {
|
|
2273
|
+
char,
|
|
2274
|
+
fontSize,
|
|
2275
|
+
fontFamily,
|
|
2276
|
+
fillColor,
|
|
2277
|
+
offsetX,
|
|
2278
|
+
offsetY
|
|
2279
|
+
},
|
|
2280
|
+
pressureThreshold,
|
|
2281
|
+
wordCollapseTag,
|
|
2282
|
+
weight,
|
|
2283
|
+
shadow,
|
|
2284
|
+
originalPosition: shadow || clicksRemaining !== void 0 ? { x: body.position.x, y: body.position.y } : void 0,
|
|
2285
|
+
clicksRemaining
|
|
2286
|
+
};
|
|
2287
|
+
this.objects.set(id, entry);
|
|
2288
|
+
Matter5.Composite.add(this.engine.world, body);
|
|
2289
|
+
letterIds.push(id);
|
|
2290
|
+
letterMap.set(`${char}-${globalCharIndex}`, id);
|
|
2291
|
+
currentX += glyphData.advanceWidth;
|
|
2292
|
+
if (i < chars.length - 1) {
|
|
2293
|
+
currentX += getKerning(loadedFont, char, chars[i + 1], fontSize);
|
|
2294
|
+
}
|
|
2295
|
+
globalCharIndex++;
|
|
2296
|
+
}
|
|
2297
|
+
currentY += lineHeight;
|
|
2298
|
+
}
|
|
2299
|
+
const wordTags = Array.from(wordTagsSet);
|
|
2300
|
+
logger.info("OverlayScene", `Created TTF text obstacles`, {
|
|
2301
|
+
text: text.replace(/\n/g, "\\n"),
|
|
2302
|
+
fontUrl,
|
|
2303
|
+
fontSize,
|
|
2304
|
+
letterCount: letterIds.length,
|
|
2305
|
+
stringTag,
|
|
2306
|
+
wordTags,
|
|
2307
|
+
lineCount: lines.length
|
|
2308
|
+
});
|
|
2309
|
+
return {
|
|
2310
|
+
letterIds,
|
|
2311
|
+
stringTag,
|
|
2312
|
+
wordTags,
|
|
2313
|
+
letterMap,
|
|
2314
|
+
letterDebugInfo: []
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Spawn falling TTF text objects.
|
|
2319
|
+
* Same as addTTFTextObstacles but with 'falling' tag (objects fall with gravity).
|
|
2320
|
+
*/
|
|
2321
|
+
async spawnFallingTTFTextObstacles(config) {
|
|
2322
|
+
const tags = [...config.tags ?? []];
|
|
2323
|
+
if (!tags.includes("falling")) tags.push("falling");
|
|
2324
|
+
return this.addTTFTextObstacles({ ...config, tags });
|
|
2325
|
+
}
|
|
2326
|
+
// ==================== COMBINED TAG METHODS ====================
|
|
2327
|
+
removeAllByTag(tag) {
|
|
2328
|
+
this.removeObjectsByTag(tag);
|
|
2329
|
+
}
|
|
2330
|
+
removeAll() {
|
|
2331
|
+
this.removeAllObjects();
|
|
2332
|
+
}
|
|
2333
|
+
// ==================== CALLBACKS ====================
|
|
2334
|
+
onUpdate(callback) {
|
|
2335
|
+
this.updateCallbacks.push(callback);
|
|
2336
|
+
}
|
|
2337
|
+
// ==================== EFFECT METHODS ====================
|
|
2338
|
+
/**
|
|
2339
|
+
* Add or update an effect configuration.
|
|
2340
|
+
* Effects are persistent spawning mechanisms that run until disabled.
|
|
2341
|
+
*/
|
|
2342
|
+
setEffect(config) {
|
|
2343
|
+
this.effectManager.setEffect(config);
|
|
2344
|
+
}
|
|
2345
|
+
/**
|
|
2346
|
+
* Remove an effect by ID
|
|
2347
|
+
*/
|
|
2348
|
+
removeEffect(id) {
|
|
2349
|
+
this.effectManager.removeEffect(id);
|
|
2350
|
+
}
|
|
2351
|
+
/**
|
|
2352
|
+
* Enable or disable an effect
|
|
2353
|
+
*/
|
|
2354
|
+
setEffectEnabled(id, enabled) {
|
|
2355
|
+
this.effectManager.setEffectEnabled(id, enabled);
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Get an effect configuration by ID
|
|
2359
|
+
*/
|
|
2360
|
+
getEffect(id) {
|
|
2361
|
+
return this.effectManager.getEffect(id);
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2364
|
+
* Get all effect IDs
|
|
2365
|
+
*/
|
|
2366
|
+
getEffectIds() {
|
|
2367
|
+
return this.effectManager.getEffectIds();
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Check if an effect is currently enabled
|
|
2371
|
+
*/
|
|
2372
|
+
isEffectEnabled(id) {
|
|
2373
|
+
return this.effectManager.isEffectEnabled(id);
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* Draw debug overlays for letter original dimension boxes
|
|
2377
|
+
*/
|
|
2378
|
+
drawDebugOverlays() {
|
|
2379
|
+
const ctx = this.canvas.getContext("2d");
|
|
2380
|
+
if (!ctx) return;
|
|
2381
|
+
for (const [, debugInfos] of this.letterDebugInfo) {
|
|
2382
|
+
for (const info of debugInfos) {
|
|
2383
|
+
const object = this.objects.get(info.id);
|
|
2384
|
+
if (!object) continue;
|
|
2385
|
+
const body = object.body;
|
|
2386
|
+
ctx.save();
|
|
2387
|
+
ctx.strokeStyle = "#00ffff";
|
|
2388
|
+
ctx.lineWidth = 2;
|
|
2389
|
+
ctx.setLineDash([5, 5]);
|
|
2390
|
+
ctx.translate(body.position.x, body.position.y);
|
|
2391
|
+
ctx.rotate(body.angle);
|
|
2392
|
+
const halfWidth = info.scaledWidth / 2;
|
|
2393
|
+
const halfHeight = info.scaledHeight / 2;
|
|
2394
|
+
ctx.strokeRect(-halfWidth, -halfHeight, info.scaledWidth, info.scaledHeight);
|
|
2395
|
+
ctx.restore();
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* Draw TTF glyphs using canvas fillText for clean text rendering
|
|
2401
|
+
*/
|
|
2402
|
+
drawTTFGlyphs() {
|
|
2403
|
+
const ctx = this.canvas.getContext("2d");
|
|
2404
|
+
if (!ctx) return;
|
|
2405
|
+
for (const [, entry] of this.objects) {
|
|
2406
|
+
if (!entry.ttfGlyph) continue;
|
|
2407
|
+
const { char, fontSize, fontFamily, fillColor, offsetX, offsetY } = entry.ttfGlyph;
|
|
2408
|
+
const body = entry.body;
|
|
2409
|
+
ctx.save();
|
|
2410
|
+
ctx.translate(body.position.x, body.position.y);
|
|
2411
|
+
ctx.rotate(body.angle);
|
|
2412
|
+
ctx.font = `${fontSize}px "${fontFamily}"`;
|
|
2413
|
+
ctx.fillStyle = fillColor;
|
|
2414
|
+
ctx.textBaseline = "alphabetic";
|
|
2415
|
+
ctx.fillText(char, offsetX, offsetY);
|
|
2416
|
+
ctx.restore();
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
checkTTLExpiration() {
|
|
2420
|
+
const now = performance.now();
|
|
2421
|
+
const expiredObjects = [];
|
|
2422
|
+
for (const [id, entry] of this.objects) {
|
|
2423
|
+
if (entry.ttl !== void 0 && now - entry.spawnTime >= entry.ttl) {
|
|
2424
|
+
expiredObjects.push(id);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
for (const id of expiredObjects) {
|
|
2428
|
+
this.removeObject(id);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
/** Despawn objects that have fallen below the floor by the configured distance */
|
|
2432
|
+
checkDespawnBelowFloor() {
|
|
2433
|
+
const despawnDistance = this.config.despawnBelowFloor ?? 1;
|
|
2434
|
+
const containerHeight = this.config.bounds.bottom - this.config.bounds.top;
|
|
2435
|
+
const despawnY = this.config.bounds.bottom + containerHeight * despawnDistance;
|
|
2436
|
+
const toDespawn = [];
|
|
2437
|
+
for (const [id, entry] of this.objects) {
|
|
2438
|
+
if (entry.body.position.y > despawnY) {
|
|
2439
|
+
toDespawn.push(id);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
for (const id of toDespawn) {
|
|
2443
|
+
this.removeObject(id);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
fireUpdateCallbacks() {
|
|
2447
|
+
const objects = [];
|
|
2448
|
+
this.objects.forEach((entry) => {
|
|
2449
|
+
if (entry.tags.includes("falling")) {
|
|
2450
|
+
objects.push({
|
|
2451
|
+
id: entry.id,
|
|
2452
|
+
x: entry.body.position.x,
|
|
2453
|
+
y: entry.body.position.y,
|
|
2454
|
+
angle: entry.body.angle,
|
|
2455
|
+
tags: entry.tags
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
});
|
|
2459
|
+
const data = { objects };
|
|
2460
|
+
this.updateCallbacks.forEach((cb) => cb(data));
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
export {
|
|
2464
|
+
OverlayScene,
|
|
2465
|
+
clearFontCache,
|
|
2466
|
+
getGlyphData,
|
|
2467
|
+
getKerning,
|
|
2468
|
+
getLogLevel,
|
|
2469
|
+
loadFont,
|
|
2470
|
+
logger,
|
|
2471
|
+
measureText,
|
|
2472
|
+
setLogLevel
|
|
2473
|
+
};
|
|
2474
|
+
//# sourceMappingURL=index.js.map
|