@flatkit/player 0.1.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/LICENSE +21 -0
- package/dist/chunk-BXKJAHHO.js +1681 -0
- package/dist/chunk-BXKJAHHO.js.map +1 -0
- package/dist/debug.d.ts +28 -0
- package/dist/debug.js +193 -0
- package/dist/debug.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/player-Bxx8wsSa.d.ts +252 -0
- package/package.json +51 -0
|
@@ -0,0 +1,1681 @@
|
|
|
1
|
+
// src/drawScene.ts
|
|
2
|
+
import { regionBBox } from "@flatkit/engine/bbox";
|
|
3
|
+
import { regionPaint } from "@flatkit/engine/paint";
|
|
4
|
+
import { cssFilterString } from "@flatkit/engine/filters";
|
|
5
|
+
import { containerLayers, getSymbol, hiddenLayerIds, isContainer, isGroup, isInstance, isText, isImage, isRegion, maskMap, guideMap } from "@flatkit/engine/layers";
|
|
6
|
+
import { pathToBezier, transformPath } from "@flatkit/engine/path";
|
|
7
|
+
import { resolveInstanceFrame } from "@flatkit/engine/timeline";
|
|
8
|
+
import { resolveLayerAt } from "@flatkit/engine/cel";
|
|
9
|
+
import { apply, compose, IDENTITY } from "@flatkit/engine/transform";
|
|
10
|
+
var clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
11
|
+
var scaleOf = (ctx) => {
|
|
12
|
+
const m = ctx.getTransform();
|
|
13
|
+
return Math.hypot(m.a, m.b) || 1;
|
|
14
|
+
};
|
|
15
|
+
function applyTransform(ctx, t) {
|
|
16
|
+
ctx.transform(t.a, t.b, t.c, t.d, t.e, t.f);
|
|
17
|
+
}
|
|
18
|
+
var scratchPool = [];
|
|
19
|
+
var scratchTop = 0;
|
|
20
|
+
var SCRATCH_BUCKET = 256;
|
|
21
|
+
function acquireScratch(w, h, maxW, maxH) {
|
|
22
|
+
if (typeof document === "undefined") return null;
|
|
23
|
+
const bw = Math.min(maxW, Math.max(SCRATCH_BUCKET, Math.ceil(w / SCRATCH_BUCKET) * SCRATCH_BUCKET));
|
|
24
|
+
const bh = Math.min(maxH, Math.max(SCRATCH_BUCKET, Math.ceil(h / SCRATCH_BUCKET) * SCRATCH_BUCKET));
|
|
25
|
+
let canvas = scratchPool[scratchTop];
|
|
26
|
+
if (!canvas) {
|
|
27
|
+
canvas = document.createElement("canvas");
|
|
28
|
+
scratchPool[scratchTop] = canvas;
|
|
29
|
+
}
|
|
30
|
+
const ctx = canvas.getContext("2d");
|
|
31
|
+
if (!ctx) return null;
|
|
32
|
+
if (canvas.width !== bw || canvas.height !== bh) {
|
|
33
|
+
canvas.width = bw;
|
|
34
|
+
canvas.height = bh;
|
|
35
|
+
} else {
|
|
36
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
37
|
+
ctx.globalCompositeOperation = "source-over";
|
|
38
|
+
ctx.globalAlpha = 1;
|
|
39
|
+
ctx.filter = "none";
|
|
40
|
+
ctx.clearRect(0, 0, bw, bh);
|
|
41
|
+
}
|
|
42
|
+
scratchTop++;
|
|
43
|
+
return { canvas, ctx };
|
|
44
|
+
}
|
|
45
|
+
function releaseScratch() {
|
|
46
|
+
if (scratchTop > 0) scratchTop--;
|
|
47
|
+
}
|
|
48
|
+
function filterSpreadPx(filters, scale) {
|
|
49
|
+
if (!filters || filters.length === 0) return 0;
|
|
50
|
+
let m = 0;
|
|
51
|
+
for (const f of filters) {
|
|
52
|
+
if (f.type === "blur") m += Math.max(0, f.radius) * 3;
|
|
53
|
+
else if (f.type === "glow") m += Math.max(0, f.blur) * 2;
|
|
54
|
+
else if (f.type === "shadow") m += Math.abs(f.dx) + Math.abs(f.dy) + Math.max(0, f.blur) * 2;
|
|
55
|
+
}
|
|
56
|
+
return m * scale;
|
|
57
|
+
}
|
|
58
|
+
function expandRect(acc, m, x0, y0, x1, y1) {
|
|
59
|
+
const pts = [apply(m, { x: x0, y: y0 }), apply(m, { x: x1, y: y0 }), apply(m, { x: x1, y: y1 }), apply(m, { x: x0, y: y1 })];
|
|
60
|
+
for (const p of pts) {
|
|
61
|
+
if (p.x < acc.minX) acc.minX = p.x;
|
|
62
|
+
if (p.y < acc.minY) acc.minY = p.y;
|
|
63
|
+
if (p.x > acc.maxX) acc.maxX = p.x;
|
|
64
|
+
if (p.y > acc.maxY) acc.maxY = p.y;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function accumDevBBox(doc, items, frame, matrix, seen, acc, rctx) {
|
|
68
|
+
for (const it of items) {
|
|
69
|
+
if (it.hidden) continue;
|
|
70
|
+
if (isContainer(it)) {
|
|
71
|
+
if (isInstance(it) && seen.has(it.symbolId)) continue;
|
|
72
|
+
const t = compose(matrix, it.transform);
|
|
73
|
+
if (isInstance(it)) {
|
|
74
|
+
const sym = getSymbol(doc, it.symbolId);
|
|
75
|
+
const local = sym?.timeline ? resolveInstanceFrame(it.playback, frame, sym.timeline.durationFrames) : frame;
|
|
76
|
+
const sub = { fps: subFps(sym?.timeline?.fps, rctx), expr: rctx.expr };
|
|
77
|
+
const next = /* @__PURE__ */ new Set([...seen, it.symbolId]);
|
|
78
|
+
for (const l of containerLayers(doc, it)) if (l.visible) accumDevBBox(doc, resolveLayerAt(l, local, { fps: sub.fps, ctx: sub.expr, parent: t }), local, t, next, acc, sub);
|
|
79
|
+
} else if (isGroup(it) && it.timeline) {
|
|
80
|
+
const sub = { fps: subFps(it.timeline.fps, rctx), expr: rctx.expr };
|
|
81
|
+
for (const l of it.layers) if (l.visible) accumDevBBox(doc, resolveLayerAt(l, frame, { fps: sub.fps, ctx: sub.expr, parent: t }), frame, t, seen, acc, sub);
|
|
82
|
+
} else {
|
|
83
|
+
for (const l of containerLayers(doc, it)) if (l.visible) accumDevBBox(doc, resolveLayerAt(l, frame, { fps: rctx.fps, ctx: rctx.expr, parent: t }), frame, t, seen, acc, rctx);
|
|
84
|
+
}
|
|
85
|
+
} else if (isText(it)) {
|
|
86
|
+
expandRect(acc, compose(matrix, it.transform), 0, 0, it.box.w, it.box.h);
|
|
87
|
+
} else if (isImage(it)) {
|
|
88
|
+
expandRect(acc, compose(matrix, it.transform), 0, 0, it.w, it.h);
|
|
89
|
+
} else {
|
|
90
|
+
const b = regionBBox(it);
|
|
91
|
+
if (b) expandRect(acc, matrix, b.minX, b.minY, b.maxX, b.maxY);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
var matOf = (m) => ({ a: m.a, b: m.b, c: m.c, d: m.d, e: m.e, f: m.f });
|
|
96
|
+
var staticMemo = /* @__PURE__ */ new WeakMap();
|
|
97
|
+
var hasExpr = (it) => "expressions" in it && !!it.expressions && Object.keys(it.expressions).length > 0;
|
|
98
|
+
function layersStatic(doc, layers, seen) {
|
|
99
|
+
for (const l of layers) {
|
|
100
|
+
if (l.cels && l.cels.length) return false;
|
|
101
|
+
for (const it of l.items) if (!isRenderStatic(doc, it, seen)) return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
function isRenderStatic(doc, it, seen = /* @__PURE__ */ new Set()) {
|
|
106
|
+
const memo = staticMemo.get(it);
|
|
107
|
+
if (memo !== void 0) return memo;
|
|
108
|
+
if (isText(it) && it.bind || hasExpr(it)) {
|
|
109
|
+
staticMemo.set(it, false);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
let result = true;
|
|
113
|
+
if (isInstance(it)) {
|
|
114
|
+
if (seen.has(it.symbolId)) return true;
|
|
115
|
+
result = getSymbol(doc, it.symbolId)?.timeline ? false : layersStatic(doc, containerLayers(doc, it), /* @__PURE__ */ new Set([...seen, it.symbolId]));
|
|
116
|
+
} else if (isGroup(it)) {
|
|
117
|
+
result = it.timeline ? false : layersStatic(doc, it.layers, seen);
|
|
118
|
+
}
|
|
119
|
+
staticMemo.set(it, result);
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
function filterCacheSlot(rctx, doc, it, ctx, tint, filterStr) {
|
|
123
|
+
if (!rctx.filterCache || typeof document === "undefined" || !isRenderStatic(doc, it)) return void 0;
|
|
124
|
+
const m = ctx.getTransform();
|
|
125
|
+
const r = (n) => Math.round(n * 100) / 100;
|
|
126
|
+
const sig = `${r(m.a)},${r(m.b)},${r(m.c)},${r(m.d)},${r(m.e)},${r(m.f)}|${tint ? `${tint.color}:${tint.amount}` : ""}|${filterStr}|${rctx.imageEpoch ?? 0}`;
|
|
127
|
+
return { map: rctx.filterCache, id: it.id, sig };
|
|
128
|
+
}
|
|
129
|
+
function compositeFiltered(ctx, opacity, tint, filters, scale, devBBox, draw, cache) {
|
|
130
|
+
const prev = cache ? cache.map.get(cache.id) : void 0;
|
|
131
|
+
if (prev && prev.canvas && prev.sig === cache.sig) {
|
|
132
|
+
ctx.save();
|
|
133
|
+
ctx.globalAlpha *= opacity;
|
|
134
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
135
|
+
ctx.drawImage(prev.canvas, 0, 0, prev.ow, prev.oh, prev.ox, prev.oy, prev.ow, prev.oh);
|
|
136
|
+
ctx.restore();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const stable = !!(prev && prev.sig === cache.sig);
|
|
140
|
+
const filterStr = cssFilterString(filters, scale);
|
|
141
|
+
const cw = ctx.canvas.width;
|
|
142
|
+
const ch = ctx.canvas.height;
|
|
143
|
+
let ox = 0, oy = 0, ow = cw, oh = ch;
|
|
144
|
+
if (devBBox && devBBox.minX <= devBBox.maxX) {
|
|
145
|
+
const m = filterSpreadPx(filters, scale);
|
|
146
|
+
ox = Math.max(0, Math.floor(devBBox.minX - m));
|
|
147
|
+
oy = Math.max(0, Math.floor(devBBox.minY - m));
|
|
148
|
+
ow = Math.max(1, Math.min(cw, Math.ceil(devBBox.maxX + m)) - ox);
|
|
149
|
+
oh = Math.max(1, Math.min(ch, Math.ceil(devBBox.maxY + m)) - oy);
|
|
150
|
+
if (ow >= cw && oh >= ch) {
|
|
151
|
+
ox = 0;
|
|
152
|
+
oy = 0;
|
|
153
|
+
ow = cw;
|
|
154
|
+
oh = ch;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const scratch = acquireScratch(ow, oh, cw, ch);
|
|
158
|
+
if (!scratch) {
|
|
159
|
+
ctx.save();
|
|
160
|
+
ctx.globalAlpha *= opacity;
|
|
161
|
+
draw(ctx);
|
|
162
|
+
ctx.restore();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const octx = scratch.ctx;
|
|
166
|
+
try {
|
|
167
|
+
const dev = ctx.getTransform();
|
|
168
|
+
octx.setTransform(dev.a, dev.b, dev.c, dev.d, dev.e - ox, dev.f - oy);
|
|
169
|
+
draw(octx);
|
|
170
|
+
if (tint) {
|
|
171
|
+
octx.setTransform(1, 0, 0, 1, 0, 0);
|
|
172
|
+
octx.globalCompositeOperation = "source-atop";
|
|
173
|
+
octx.globalAlpha = clamp01(tint.amount);
|
|
174
|
+
octx.fillStyle = tint.color;
|
|
175
|
+
octx.fillRect(0, 0, ow, oh);
|
|
176
|
+
}
|
|
177
|
+
const store = cache && stable && typeof document !== "undefined" ? ensureCacheCanvas(cache, ox, oy, ow, oh) : null;
|
|
178
|
+
const cctx = store ? store.canvas.getContext("2d") : null;
|
|
179
|
+
if (store && cctx) {
|
|
180
|
+
cctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
181
|
+
cctx.globalAlpha = 1;
|
|
182
|
+
cctx.globalCompositeOperation = "source-over";
|
|
183
|
+
cctx.filter = filterStr || "none";
|
|
184
|
+
cctx.clearRect(0, 0, store.canvas.width, store.canvas.height);
|
|
185
|
+
cctx.drawImage(scratch.canvas, 0, 0, ow, oh, 0, 0, ow, oh);
|
|
186
|
+
store.sig = cache.sig;
|
|
187
|
+
store.ox = ox;
|
|
188
|
+
store.oy = oy;
|
|
189
|
+
store.ow = ow;
|
|
190
|
+
store.oh = oh;
|
|
191
|
+
ctx.save();
|
|
192
|
+
ctx.globalAlpha *= opacity;
|
|
193
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
194
|
+
ctx.drawImage(store.canvas, 0, 0, ow, oh, ox, oy, ow, oh);
|
|
195
|
+
ctx.restore();
|
|
196
|
+
} else {
|
|
197
|
+
if (cache) cache.map.set(cache.id, { sig: cache.sig, ox, oy, ow, oh });
|
|
198
|
+
ctx.save();
|
|
199
|
+
ctx.globalAlpha *= opacity;
|
|
200
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
201
|
+
if (filterStr) ctx.filter = filterStr;
|
|
202
|
+
ctx.drawImage(scratch.canvas, 0, 0, ow, oh, ox, oy, ow, oh);
|
|
203
|
+
ctx.restore();
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
releaseScratch();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function ensureCacheCanvas(cache, ox, oy, ow, oh) {
|
|
210
|
+
const e = cache.map.get(cache.id);
|
|
211
|
+
if (e?.canvas && e.canvas.width >= ow && e.canvas.height >= oh) return e;
|
|
212
|
+
const canvas = document.createElement("canvas");
|
|
213
|
+
canvas.width = Math.max(1, ow);
|
|
214
|
+
canvas.height = Math.max(1, oh);
|
|
215
|
+
const fresh = { canvas, sig: "", ox, oy, ow, oh };
|
|
216
|
+
cache.map.set(cache.id, fresh);
|
|
217
|
+
return fresh;
|
|
218
|
+
}
|
|
219
|
+
function compositeMasked(ctx, opacity, devBBox, blit, drawContent, drawMatter) {
|
|
220
|
+
const cw = ctx.canvas.width;
|
|
221
|
+
const ch = ctx.canvas.height;
|
|
222
|
+
let ox = 0, oy = 0, ow = cw, oh = ch;
|
|
223
|
+
if (devBBox && devBBox.minX <= devBBox.maxX) {
|
|
224
|
+
ox = Math.max(0, Math.floor(devBBox.minX));
|
|
225
|
+
oy = Math.max(0, Math.floor(devBBox.minY));
|
|
226
|
+
ow = Math.max(1, Math.min(cw, Math.ceil(devBBox.maxX)) - ox);
|
|
227
|
+
oh = Math.max(1, Math.min(ch, Math.ceil(devBBox.maxY)) - oy);
|
|
228
|
+
if (ow >= cw && oh >= ch) {
|
|
229
|
+
ox = 0;
|
|
230
|
+
oy = 0;
|
|
231
|
+
ow = cw;
|
|
232
|
+
oh = ch;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const scratch = acquireScratch(ow, oh, cw, ch);
|
|
236
|
+
if (!scratch) {
|
|
237
|
+
ctx.save();
|
|
238
|
+
ctx.globalAlpha *= opacity;
|
|
239
|
+
drawContent(ctx);
|
|
240
|
+
ctx.restore();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const octx = scratch.ctx;
|
|
244
|
+
try {
|
|
245
|
+
const dev = ctx.getTransform();
|
|
246
|
+
octx.setTransform(dev.a, dev.b, dev.c, dev.d, dev.e - ox, dev.f - oy);
|
|
247
|
+
drawContent(octx);
|
|
248
|
+
octx.setTransform(dev.a, dev.b, dev.c, dev.d, dev.e - ox, dev.f - oy);
|
|
249
|
+
octx.globalCompositeOperation = "destination-in";
|
|
250
|
+
drawMatter(octx);
|
|
251
|
+
octx.globalCompositeOperation = "source-over";
|
|
252
|
+
ctx.save();
|
|
253
|
+
ctx.globalAlpha *= opacity;
|
|
254
|
+
ctx.globalCompositeOperation = blit;
|
|
255
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
256
|
+
ctx.drawImage(scratch.canvas, 0, 0, ow, oh, ox, oy, ow, oh);
|
|
257
|
+
ctx.restore();
|
|
258
|
+
} finally {
|
|
259
|
+
releaseScratch();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function itemsHaveGlyph(doc, items, seen = /* @__PURE__ */ new Set()) {
|
|
263
|
+
for (const it of items) {
|
|
264
|
+
if (it.hidden) continue;
|
|
265
|
+
if (isText(it) || isImage(it)) return true;
|
|
266
|
+
if (isContainer(it)) {
|
|
267
|
+
if (isInstance(it) && seen.has(it.symbolId)) continue;
|
|
268
|
+
const next = isInstance(it) ? /* @__PURE__ */ new Set([...seen, it.symbolId]) : seen;
|
|
269
|
+
for (const l of containerLayers(doc, it)) if (l.visible && itemsHaveGlyph(doc, resolveLayerAt(l, 0, {}), next)) return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
var subFps = (tlFps, parent) => tlFps ?? parent.fps;
|
|
275
|
+
var MAX_NEST = 256;
|
|
276
|
+
function renderContainerChildren(ctx, doc, it, frame, hidden, seen, rctx, parent = IDENTITY, depth = 0) {
|
|
277
|
+
if (isInstance(it)) {
|
|
278
|
+
const sym = getSymbol(doc, it.symbolId);
|
|
279
|
+
const local = rctx.freezeNested ? 0 : sym?.timeline ? resolveInstanceFrame(it.playback, frame, sym.timeline.durationFrames) : frame;
|
|
280
|
+
renderLayers(ctx, doc, containerLayers(doc, it), local, hidden, seen, { fps: subFps(sym?.timeline?.fps, rctx), expr: rctx.expr, freezeNested: rctx.freezeNested, image: rctx.image, filterCache: rctx.filterCache, imageEpoch: rctx.imageEpoch, itemState: rctx.itemState }, parent, depth + 1);
|
|
281
|
+
} else if (isGroup(it) && it.timeline) {
|
|
282
|
+
renderLayers(ctx, doc, it.layers, rctx.freezeNested ? 0 : frame, hidden, seen, { fps: subFps(it.timeline.fps, rctx), expr: rctx.expr, freezeNested: rctx.freezeNested, image: rctx.image, filterCache: rctx.filterCache, imageEpoch: rctx.imageEpoch, itemState: rctx.itemState }, parent, depth + 1);
|
|
283
|
+
} else {
|
|
284
|
+
renderLayers(ctx, doc, containerLayers(doc, it), frame, hidden, seen, rctx, parent, depth + 1);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function regionPath(region, dx = 0, dy = 0) {
|
|
288
|
+
const path = new Path2D();
|
|
289
|
+
for (const sub of region.path.subpaths) {
|
|
290
|
+
const bz = pathToBezier(sub);
|
|
291
|
+
if (!bz) continue;
|
|
292
|
+
path.moveTo(bz.start.x + dx, bz.start.y + dy);
|
|
293
|
+
for (const s of bz.segs) path.bezierCurveTo(s.c1.x + dx, s.c1.y + dy, s.c2.x + dx, s.c2.y + dy, s.p.x + dx, s.p.y + dy);
|
|
294
|
+
if (sub.closed) path.closePath();
|
|
295
|
+
}
|
|
296
|
+
return path;
|
|
297
|
+
}
|
|
298
|
+
function collectShape(doc, items, frame, matrix, seen, path, rctx) {
|
|
299
|
+
for (const it of items) {
|
|
300
|
+
if (it.hidden) continue;
|
|
301
|
+
if (isContainer(it)) {
|
|
302
|
+
if (isInstance(it) && seen.has(it.symbolId)) continue;
|
|
303
|
+
const t = compose(matrix, it.transform);
|
|
304
|
+
if (isInstance(it)) {
|
|
305
|
+
const sym = getSymbol(doc, it.symbolId);
|
|
306
|
+
const local = sym?.timeline ? resolveInstanceFrame(it.playback, frame, sym.timeline.durationFrames) : frame;
|
|
307
|
+
const sub = { fps: subFps(sym?.timeline?.fps, rctx), expr: rctx.expr };
|
|
308
|
+
const next = /* @__PURE__ */ new Set([...seen, it.symbolId]);
|
|
309
|
+
for (const l of containerLayers(doc, it)) if (l.visible) collectShape(doc, resolveLayerAt(l, local, { fps: sub.fps, ctx: sub.expr, parent: t }), local, t, next, path, sub);
|
|
310
|
+
} else if (isGroup(it) && it.timeline) {
|
|
311
|
+
const sub = { fps: subFps(it.timeline.fps, rctx), expr: rctx.expr };
|
|
312
|
+
for (const l of it.layers) if (l.visible) collectShape(doc, resolveLayerAt(l, frame, { fps: sub.fps, ctx: sub.expr, parent: t }), frame, t, seen, path, sub);
|
|
313
|
+
} else {
|
|
314
|
+
for (const l of containerLayers(doc, it)) if (l.visible) collectShape(doc, resolveLayerAt(l, frame, { fps: rctx.fps, ctx: rctx.expr, parent: t }), frame, t, seen, path, rctx);
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
path.addPath(regionPath(it), new DOMMatrix([matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f]));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function guidePathOf(guide, frame, rctx) {
|
|
322
|
+
const items = resolveLayerAt(guide, frame, { fps: rctx.fps, ctx: rctx.expr });
|
|
323
|
+
const subpaths = [];
|
|
324
|
+
for (const it of items) {
|
|
325
|
+
if (!isRegion(it)) continue;
|
|
326
|
+
const path = it.xform ? transformPath(it.path, it.xform) : it.path;
|
|
327
|
+
subpaths.push(...path.subpaths);
|
|
328
|
+
}
|
|
329
|
+
return subpaths.length ? { subpaths } : null;
|
|
330
|
+
}
|
|
331
|
+
function paintStyle(ctx, paint, bbox, fallback) {
|
|
332
|
+
if (paint.type === "solid") return paint.color;
|
|
333
|
+
const b = paint.box ?? bbox;
|
|
334
|
+
if (!b) return fallback;
|
|
335
|
+
const w = b.maxX - b.minX;
|
|
336
|
+
const h = b.maxY - b.minY;
|
|
337
|
+
let g;
|
|
338
|
+
if (paint.type === "linear") {
|
|
339
|
+
const cx = (b.minX + b.maxX) / 2;
|
|
340
|
+
const cy = (b.minY + b.maxY) / 2;
|
|
341
|
+
const a = paint.angle * Math.PI / 180;
|
|
342
|
+
const ux = Math.cos(a);
|
|
343
|
+
const uy = Math.sin(a);
|
|
344
|
+
const half = (Math.abs(w * ux) + Math.abs(h * uy)) / 2;
|
|
345
|
+
g = ctx.createLinearGradient(cx - ux * half, cy - uy * half, cx + ux * half, cy + uy * half);
|
|
346
|
+
} else {
|
|
347
|
+
const cx = b.minX + paint.cx * w;
|
|
348
|
+
const cy = b.minY + paint.cy * h;
|
|
349
|
+
g = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(1e-4, paint.r * Math.max(w, h)));
|
|
350
|
+
}
|
|
351
|
+
for (const s of paint.stops) g.addColorStop(clamp01(s.offset), s.color);
|
|
352
|
+
return g;
|
|
353
|
+
}
|
|
354
|
+
function fillStyleFor(ctx, region) {
|
|
355
|
+
return paintStyle(ctx, regionPaint(region), regionBBox(region), region.color);
|
|
356
|
+
}
|
|
357
|
+
function renderItems(ctx, doc, items, frame, hidden, seen, rctx, parent = IDENTITY, depth = 0) {
|
|
358
|
+
for (const it of items) {
|
|
359
|
+
if (hidden?.has(it.id)) continue;
|
|
360
|
+
if (it.hidden) continue;
|
|
361
|
+
const opacity = it.opacity ?? 1;
|
|
362
|
+
if (opacity <= 0) continue;
|
|
363
|
+
const blend = "blend" in it ? it.blend : void 0;
|
|
364
|
+
const op = blend === "add" ? "lighter" : blend === "screen" ? "screen" : blend === "multiply" ? "multiply" : null;
|
|
365
|
+
if (op) {
|
|
366
|
+
ctx.save();
|
|
367
|
+
ctx.globalCompositeOperation = op;
|
|
368
|
+
}
|
|
369
|
+
const pm = rctx.preview?.ids.has(it.id) ? rctx.preview.m : null;
|
|
370
|
+
if (pm) ctx.save();
|
|
371
|
+
if (pm) applyTransform(ctx, pm);
|
|
372
|
+
renderOneItem(ctx, doc, it, frame, hidden, seen, rctx, opacity, parent, depth);
|
|
373
|
+
if (pm) ctx.restore();
|
|
374
|
+
if (op) ctx.restore();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function renderOneItem(ctx, doc, it, frame, hidden, seen, rctx, opacity, parent = IDENTITY, depth = 0) {
|
|
378
|
+
{
|
|
379
|
+
if (isContainer(it)) {
|
|
380
|
+
if (isInstance(it) && seen.has(it.symbolId)) return;
|
|
381
|
+
const next = isInstance(it) ? /* @__PURE__ */ new Set([...seen, it.symbolId]) : seen;
|
|
382
|
+
const ctm = it.transform;
|
|
383
|
+
const childParent = compose(parent, ctm);
|
|
384
|
+
const tint = it.tint && it.tint.amount > 1e-3 ? it.tint : null;
|
|
385
|
+
const scale = scaleOf(ctx);
|
|
386
|
+
const filterStr = cssFilterString(it.filters, scale);
|
|
387
|
+
if (tint || filterStr) {
|
|
388
|
+
const acc = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
389
|
+
accumDevBBox(doc, [it], frame, matOf(ctx.getTransform()), seen, acc, rctx);
|
|
390
|
+
const devBBox = acc.minX <= acc.maxX ? acc : null;
|
|
391
|
+
const cache = filterCacheSlot(rctx, doc, it, ctx, tint, filterStr);
|
|
392
|
+
compositeFiltered(ctx, opacity, tint, it.filters, scale, devBBox, (octx) => {
|
|
393
|
+
applyTransform(octx, ctm);
|
|
394
|
+
renderContainerChildren(octx, doc, it, frame, hidden, next, rctx, childParent, depth);
|
|
395
|
+
}, cache);
|
|
396
|
+
} else {
|
|
397
|
+
ctx.save();
|
|
398
|
+
ctx.globalAlpha *= opacity;
|
|
399
|
+
applyTransform(ctx, ctm);
|
|
400
|
+
renderContainerChildren(ctx, doc, it, frame, hidden, next, rctx, childParent, depth);
|
|
401
|
+
ctx.restore();
|
|
402
|
+
}
|
|
403
|
+
} else if (isText(it)) {
|
|
404
|
+
const acc = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
405
|
+
expandRect(acc, compose(matOf(ctx.getTransform()), it.transform), 0, 0, it.box.w, it.box.h);
|
|
406
|
+
paintLeaf(ctx, it.tint, it.filters, opacity, acc, scaleOf(ctx), (c) => paintText(c, it));
|
|
407
|
+
} else if (isImage(it)) {
|
|
408
|
+
const src = rctx.image?.(it.assetId) ?? null;
|
|
409
|
+
const acc = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
410
|
+
expandRect(acc, compose(matOf(ctx.getTransform()), it.transform), 0, 0, it.w, it.h);
|
|
411
|
+
paintLeaf(ctx, it.tint, it.filters, opacity, acc, scaleOf(ctx), (c) => paintImage(c, it, src));
|
|
412
|
+
} else {
|
|
413
|
+
const reg = it;
|
|
414
|
+
if (reg.filters?.length) {
|
|
415
|
+
const lb = regionBBox(reg);
|
|
416
|
+
const acc = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
417
|
+
if (lb) expandRect(acc, matOf(ctx.getTransform()), lb.minX, lb.minY, lb.maxX, lb.maxY);
|
|
418
|
+
paintLeaf(ctx, void 0, reg.filters, opacity, acc, scaleOf(ctx), (c) => paintRegion(c, reg));
|
|
419
|
+
} else {
|
|
420
|
+
ctx.save();
|
|
421
|
+
if (opacity < 1) ctx.globalAlpha *= opacity;
|
|
422
|
+
paintRegion(ctx, reg);
|
|
423
|
+
ctx.restore();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function paintRegion(c, reg) {
|
|
429
|
+
const path = regionPath(reg);
|
|
430
|
+
if (!reg.noFill) {
|
|
431
|
+
c.fillStyle = fillStyleFor(c, reg);
|
|
432
|
+
c.fill(path, "evenodd");
|
|
433
|
+
}
|
|
434
|
+
if (reg.stroke) {
|
|
435
|
+
const s = reg.stroke;
|
|
436
|
+
c.lineWidth = s.width;
|
|
437
|
+
c.lineCap = s.cap ?? "round";
|
|
438
|
+
c.lineJoin = s.join ?? "round";
|
|
439
|
+
if (s.miterLimit != null) c.miterLimit = s.miterLimit;
|
|
440
|
+
c.setLineDash(s.dash ?? []);
|
|
441
|
+
c.strokeStyle = paintStyle(c, s.paint, regionBBox(reg), reg.color);
|
|
442
|
+
c.stroke(path);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
var textFont = (t) => `${t.italic ? "italic " : ""}${t.weight ?? 400} ${t.size}px ${t.font}`;
|
|
446
|
+
function paintText(ctx, t) {
|
|
447
|
+
ctx.save();
|
|
448
|
+
applyTransform(ctx, t.transform);
|
|
449
|
+
ctx.fillStyle = t.color;
|
|
450
|
+
ctx.textBaseline = "top";
|
|
451
|
+
ctx.font = textFont(t);
|
|
452
|
+
ctx.textAlign = t.align;
|
|
453
|
+
const x = t.align === "left" ? 0 : t.align === "right" ? t.box.w : t.box.w / 2;
|
|
454
|
+
const lh = t.size * t.lineHeight;
|
|
455
|
+
const lines = t.wrap && t.box.w > 0 ? wrapLines(ctx, t.content, t.box.w) : t.content.split("\n");
|
|
456
|
+
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], x, i * lh);
|
|
457
|
+
ctx.restore();
|
|
458
|
+
}
|
|
459
|
+
function wrapLines(ctx, content, maxW) {
|
|
460
|
+
const out = [];
|
|
461
|
+
for (const para of content.split("\n")) {
|
|
462
|
+
const words = para.split(/\s+/).filter(Boolean);
|
|
463
|
+
if (words.length === 0) {
|
|
464
|
+
out.push("");
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
let line = words[0];
|
|
468
|
+
for (let i = 1; i < words.length; i++) {
|
|
469
|
+
const test = line + " " + words[i];
|
|
470
|
+
if (ctx.measureText(test).width <= maxW) line = test;
|
|
471
|
+
else {
|
|
472
|
+
out.push(line);
|
|
473
|
+
line = words[i];
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
out.push(line);
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
function paintImage(ctx, im, src) {
|
|
481
|
+
ctx.save();
|
|
482
|
+
applyTransform(ctx, im.transform);
|
|
483
|
+
if (src) ctx.drawImage(src, 0, 0, im.w, im.h);
|
|
484
|
+
else {
|
|
485
|
+
ctx.fillStyle = "rgba(127,131,140,0.18)";
|
|
486
|
+
ctx.fillRect(0, 0, im.w, im.h);
|
|
487
|
+
}
|
|
488
|
+
ctx.restore();
|
|
489
|
+
}
|
|
490
|
+
function paintLeaf(ctx, tint, filters, opacity, devBBox, scale, draw) {
|
|
491
|
+
const t = tint && tint.amount > 1e-3 ? tint : null;
|
|
492
|
+
const filterStr = cssFilterString(filters, scale);
|
|
493
|
+
if (!t && !filterStr) {
|
|
494
|
+
ctx.save();
|
|
495
|
+
if (opacity < 1) ctx.globalAlpha *= opacity;
|
|
496
|
+
draw(ctx);
|
|
497
|
+
ctx.restore();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
compositeFiltered(ctx, opacity, t, filters, scale, devBBox.minX <= devBBox.maxX ? devBBox : null, draw);
|
|
501
|
+
}
|
|
502
|
+
function renderLayers(ctx, doc, layers, frame, hidden, seen, rctx, parent = IDENTITY, depth = 0) {
|
|
503
|
+
if (depth > MAX_NEST) return;
|
|
504
|
+
const hid = hiddenLayerIds(layers);
|
|
505
|
+
const masks = maskMap(layers);
|
|
506
|
+
const guides = guideMap(layers);
|
|
507
|
+
const clipCache = /* @__PURE__ */ new Map();
|
|
508
|
+
const maskCache = /* @__PURE__ */ new Map();
|
|
509
|
+
const guideCache = /* @__PURE__ */ new Map();
|
|
510
|
+
for (const layer of layers) {
|
|
511
|
+
if (hid.has(layer.id)) continue;
|
|
512
|
+
if (layer.isMask) continue;
|
|
513
|
+
if (layer.isGuide) continue;
|
|
514
|
+
const a = ctx.globalAlpha;
|
|
515
|
+
ctx.globalAlpha = a * layer.opacity;
|
|
516
|
+
const guideLayer = guides.get(layer.id);
|
|
517
|
+
let guidePath;
|
|
518
|
+
if (guideLayer) {
|
|
519
|
+
guidePath = guideCache.get(guideLayer.id);
|
|
520
|
+
if (guidePath === void 0) {
|
|
521
|
+
guidePath = guidePathOf(guideLayer, frame, rctx);
|
|
522
|
+
guideCache.set(guideLayer.id, guidePath);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const items = resolveLayerAt(layer, frame, { fps: rctx.fps, ctx: rctx.expr, guide: guidePath ?? void 0, orient: layer.orientToGuide, parent, itemState: rctx.itemState });
|
|
526
|
+
const mask = masks.get(layer.id);
|
|
527
|
+
if (mask) {
|
|
528
|
+
let mi = maskCache.get(mask.id);
|
|
529
|
+
if (!mi) {
|
|
530
|
+
const mItems = resolveLayerAt(mask, frame, { fps: rctx.fps, ctx: rctx.expr });
|
|
531
|
+
mi = { items: mItems, glyph: itemsHaveGlyph(doc, mItems, /* @__PURE__ */ new Set()) };
|
|
532
|
+
maskCache.set(mask.id, mi);
|
|
533
|
+
}
|
|
534
|
+
if (mi.glyph) {
|
|
535
|
+
const acc = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
536
|
+
accumDevBBox(doc, mi.items, frame, matOf(ctx.getTransform()), seen, acc, rctx);
|
|
537
|
+
const matter = mi.items;
|
|
538
|
+
const blit = items.some((it) => "blend" in it && it.blend === "add") ? "lighter" : "source-over";
|
|
539
|
+
compositeMasked(
|
|
540
|
+
ctx,
|
|
541
|
+
1,
|
|
542
|
+
acc.minX <= acc.maxX ? acc : null,
|
|
543
|
+
blit,
|
|
544
|
+
(octx) => renderItems(octx, doc, items, frame, hidden, seen, rctx, parent, depth),
|
|
545
|
+
(octx) => renderItems(octx, doc, matter, frame, hidden, seen, rctx, parent, depth)
|
|
546
|
+
);
|
|
547
|
+
} else {
|
|
548
|
+
let clip = clipCache.get(mask.id);
|
|
549
|
+
if (!clip) {
|
|
550
|
+
const p = new Path2D();
|
|
551
|
+
collectShape(doc, mi.items, frame, IDENTITY, /* @__PURE__ */ new Set(), p, rctx);
|
|
552
|
+
clip = p;
|
|
553
|
+
clipCache.set(mask.id, clip);
|
|
554
|
+
}
|
|
555
|
+
ctx.save();
|
|
556
|
+
ctx.clip(clip, "evenodd");
|
|
557
|
+
renderItems(ctx, doc, items, frame, hidden, seen, rctx, parent, depth);
|
|
558
|
+
ctx.restore();
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
renderItems(ctx, doc, items, frame, hidden, seen, rctx, parent, depth);
|
|
562
|
+
}
|
|
563
|
+
ctx.globalAlpha = a;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/player.ts
|
|
568
|
+
import { resolveInstanceFrame as resolveInstanceFrame3, scheduleSounds } from "@flatkit/engine/timeline";
|
|
569
|
+
import { compileExpr, evalExpr, exprScope } from "@flatkit/engine/expr";
|
|
570
|
+
import { runActions, MAX_SEND_TEXT, SEND_EVENT_NAME } from "@flatkit/engine/actions";
|
|
571
|
+
import { containerLayers as containerLayers3, getSymbol as getSymbol3, isGroup as isGroup3, isInstance as isInstance3, isText as isText3 } from "@flatkit/engine/layers";
|
|
572
|
+
import { withCels } from "@flatkit/engine/migrateCel";
|
|
573
|
+
import { sanitizeDoc } from "@flatkit/engine/validateDoc";
|
|
574
|
+
import { applyInstanceBinds } from "@flatkit/engine/instanceBind";
|
|
575
|
+
import { importedFunctions } from "@flatkit/engine/stdlib";
|
|
576
|
+
import { namedChannels, objectChannelsById, objectParentTransform } from "@flatkit/engine/sceneRefs";
|
|
577
|
+
import { itemBoundsByName, itemBoundsById, dropZoneBounds, tracePathByName, groupTargets } from "@flatkit/engine/groups";
|
|
578
|
+
import { projectToPath, samplePathAt } from "@flatkit/engine/path";
|
|
579
|
+
import { apply as apply3, invert as invert2, spaceConversions, IDENTITY as IDENTITY3 } from "@flatkit/engine/transform";
|
|
580
|
+
|
|
581
|
+
// src/hit.ts
|
|
582
|
+
import { containerLayers as containerLayers2, getSymbol as getSymbol2, hiddenLayerIds as hiddenLayerIds2, isContainer as isContainer2, isGroup as isGroup2, isInstance as isInstance2, isText as isText2, isImage as isImage2, maskMap as maskMap2, guideMap as guideMap2 } from "@flatkit/engine/layers";
|
|
583
|
+
import { apply as apply2, invert, compose as compose2, IDENTITY as IDENTITY2 } from "@flatkit/engine/transform";
|
|
584
|
+
import { resolveInstanceFrame as resolveInstanceFrame2 } from "@flatkit/engine/timeline";
|
|
585
|
+
import { resolveLayerAt as resolveLayerAt2 } from "@flatkit/engine/cel";
|
|
586
|
+
import { pathToPolygons } from "@flatkit/engine/path";
|
|
587
|
+
function pointInMask(mask, frame, fps, ctx, pt) {
|
|
588
|
+
let hasContainer = false;
|
|
589
|
+
for (const it of resolveLayerAt2(mask, frame, { fps, ctx })) {
|
|
590
|
+
if (it.hidden) continue;
|
|
591
|
+
if (isContainer2(it)) {
|
|
592
|
+
hasContainer = true;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (pointInPolygons(pathToPolygons(it.path), pt)) return true;
|
|
596
|
+
}
|
|
597
|
+
return hasContainer;
|
|
598
|
+
}
|
|
599
|
+
function pointInBox(transform, w, h, pt) {
|
|
600
|
+
const p = apply2(invert(transform), pt);
|
|
601
|
+
return p.x >= 0 && p.x <= w && p.y >= 0 && p.y <= h;
|
|
602
|
+
}
|
|
603
|
+
function pointInPolygons(rings, pt) {
|
|
604
|
+
let inside = false;
|
|
605
|
+
for (const ring of rings) {
|
|
606
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
607
|
+
const a = ring[i];
|
|
608
|
+
const b = ring[j];
|
|
609
|
+
if (a.y > pt.y !== b.y > pt.y) {
|
|
610
|
+
const x = a.x + (pt.y - a.y) / (b.y - a.y) * (b.x - a.x);
|
|
611
|
+
if (pt.x < x) inside = !inside;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return inside;
|
|
616
|
+
}
|
|
617
|
+
function distToSeg(p, a, b) {
|
|
618
|
+
const dx = b.x - a.x;
|
|
619
|
+
const dy = b.y - a.y;
|
|
620
|
+
const l2 = dx * dx + dy * dy;
|
|
621
|
+
if (l2 === 0) return Math.hypot(p.x - a.x, p.y - a.y);
|
|
622
|
+
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2));
|
|
623
|
+
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
|
624
|
+
}
|
|
625
|
+
function pointNearPath(path, pt, dist) {
|
|
626
|
+
for (const sp of path.subpaths) {
|
|
627
|
+
const ring = pathToPolygons({ subpaths: [sp] })[0];
|
|
628
|
+
if (!ring || ring.length < 2) continue;
|
|
629
|
+
const segCount = sp.closed ? ring.length : ring.length - 1;
|
|
630
|
+
for (let i = 0; i < segCount; i++) if (distToSeg(pt, ring[i], ring[(i + 1) % ring.length]) <= dist) return true;
|
|
631
|
+
}
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
var hittable = (it) => !it.hidden && (it.opacity ?? 1) > 0.01 && !it.noHit;
|
|
635
|
+
function hitRegion(r, pt) {
|
|
636
|
+
if (r.noFill && r.stroke) return pointNearPath(r.path, pt, r.stroke.width / 2 + 4);
|
|
637
|
+
return pointInPolygons(pathToPolygons(r.path), pt);
|
|
638
|
+
}
|
|
639
|
+
function subScopeFrame(it, sym, frame, freeze) {
|
|
640
|
+
const subScope = isInstance2(it) || isGroup2(it) && !!it.timeline;
|
|
641
|
+
if (!subScope) return frame;
|
|
642
|
+
if (freeze) return 0;
|
|
643
|
+
return isInstance2(it) && sym?.timeline ? resolveInstanceFrame2(it.playback, frame, sym.timeline.durationFrames) : frame;
|
|
644
|
+
}
|
|
645
|
+
var MAX_NEST2 = 256;
|
|
646
|
+
function collectInScope(doc, layers, timeline, frame, ctx, pt, seen, freeze, out, parent = IDENTITY2, depth = 0) {
|
|
647
|
+
if (depth > MAX_NEST2) return;
|
|
648
|
+
const fps = timeline?.fps ?? 24;
|
|
649
|
+
const hid = hiddenLayerIds2(layers);
|
|
650
|
+
const masks = maskMap2(layers);
|
|
651
|
+
const guides = guideMap2(layers);
|
|
652
|
+
for (let li = layers.length - 1; li >= 0; li--) {
|
|
653
|
+
const layer = layers[li];
|
|
654
|
+
if (hid.has(layer.id) || layer.isMask) continue;
|
|
655
|
+
const mask = masks.get(layer.id);
|
|
656
|
+
if (mask && !pointInMask(mask, frame, fps, ctx, pt)) continue;
|
|
657
|
+
const gl = guides.get(layer.id);
|
|
658
|
+
const guide = gl ? guidePathOf(gl, frame, { fps, expr: ctx }) ?? void 0 : void 0;
|
|
659
|
+
const items = resolveLayerAt2(layer, frame, { fps, ctx, guide, orient: layer.orientToGuide, parent });
|
|
660
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
661
|
+
const it = items[i];
|
|
662
|
+
if (!hittable(it)) continue;
|
|
663
|
+
if (isContainer2(it)) {
|
|
664
|
+
if (isInstance2(it) && seen.has(it.symbolId)) continue;
|
|
665
|
+
const local = apply2(invert(it.transform), pt);
|
|
666
|
+
const inst = isInstance2(it);
|
|
667
|
+
const sym = inst ? getSymbol2(doc, it.symbolId) : void 0;
|
|
668
|
+
const subTl = inst ? sym?.timeline : isGroup2(it) && it.timeline ? it.timeline : timeline;
|
|
669
|
+
const subFrame = subScopeFrame(it, sym, frame, freeze);
|
|
670
|
+
const next = inst ? /* @__PURE__ */ new Set([...seen, it.symbolId]) : seen;
|
|
671
|
+
const deeper = [];
|
|
672
|
+
collectInScope(doc, containerLayers2(doc, it), subTl, subFrame, ctx, local, next, freeze, deeper, compose2(parent, it.transform), depth + 1);
|
|
673
|
+
for (const d of deeper) out.push([it.id, ...d]);
|
|
674
|
+
} else if (isText2(it)) {
|
|
675
|
+
if (pointInBox(it.transform, it.box.w, it.box.h, pt)) out.push([it.id]);
|
|
676
|
+
} else if (isImage2(it)) {
|
|
677
|
+
if (pointInBox(it.transform, it.w, it.h, pt)) out.push([it.id]);
|
|
678
|
+
} else {
|
|
679
|
+
if (hitRegion(it, pt)) out.push([it.id]);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function hitChains(doc, frame, ctx, worldPt) {
|
|
685
|
+
const out = [];
|
|
686
|
+
collectInScope(doc, doc.layers, doc.timeline, frame, ctx, worldPt, /* @__PURE__ */ new Set(), false, out);
|
|
687
|
+
return out;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/player.ts
|
|
691
|
+
function fit(cssW, cssH, docW, docH, pad) {
|
|
692
|
+
const scale = Math.max(1e-4, Math.min((cssW - 2 * pad) / docW, (cssH - 2 * pad) / docH));
|
|
693
|
+
return { tx: (cssW - docW * scale) / 2, ty: (cssH - docH * scale) / 2, scale };
|
|
694
|
+
}
|
|
695
|
+
var playerImgCache = /* @__PURE__ */ new Map();
|
|
696
|
+
var isEmbeddedData = (data) => !!data && data.startsWith("data:");
|
|
697
|
+
function sameOriginAssetResolver(baseUrl) {
|
|
698
|
+
let base;
|
|
699
|
+
try {
|
|
700
|
+
base = new URL(baseUrl);
|
|
701
|
+
} catch {
|
|
702
|
+
return () => null;
|
|
703
|
+
}
|
|
704
|
+
return (asset) => {
|
|
705
|
+
const data = asset.data;
|
|
706
|
+
if (typeof data !== "string" || !data) return null;
|
|
707
|
+
if (data.startsWith("data:")) return data;
|
|
708
|
+
if (data.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(data)) return null;
|
|
709
|
+
let url;
|
|
710
|
+
try {
|
|
711
|
+
url = new URL(data, base);
|
|
712
|
+
} catch {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
return url.origin === base.origin ? url.href : null;
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
function cloneVars(vars) {
|
|
719
|
+
return new Map(Object.entries(vars ?? {}).map(([k, v]) => [k, Array.isArray(v) ? [...v] : v]));
|
|
720
|
+
}
|
|
721
|
+
function cloneVarMap(m) {
|
|
722
|
+
const out = /* @__PURE__ */ new Map();
|
|
723
|
+
for (const [k, v] of m) out.set(k, Array.isArray(v) ? [...v] : v);
|
|
724
|
+
return out;
|
|
725
|
+
}
|
|
726
|
+
function lerpVars(prev, cur, alpha) {
|
|
727
|
+
const out = /* @__PURE__ */ new Map();
|
|
728
|
+
for (const [k, c] of cur) {
|
|
729
|
+
const p = prev.get(k);
|
|
730
|
+
if (typeof c === "number" && typeof p === "number") out.set(k, p + (c - p) * alpha);
|
|
731
|
+
else if (Array.isArray(c) && Array.isArray(p) && p.length === c.length) out.set(k, c.map((ci, i) => p[i] + (ci - p[i]) * alpha));
|
|
732
|
+
else out.set(k, c);
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
var SIM_HZ = 60;
|
|
737
|
+
var SIM_STEP = 1 / SIM_HZ;
|
|
738
|
+
var SIM_MAX_STEPS = 30;
|
|
739
|
+
var CLICK_EVENTS = ["click"];
|
|
740
|
+
var HOVER_EVENTS = ["enter", "leave"];
|
|
741
|
+
var GRAB_EVENTS = ["press", "release", "drag", "longpress"];
|
|
742
|
+
var LONGPRESS_MS = 500;
|
|
743
|
+
var LONGPRESS_TOL = 6;
|
|
744
|
+
function simSteps(acc, dt, step, max) {
|
|
745
|
+
let a = acc + dt;
|
|
746
|
+
let n = 0;
|
|
747
|
+
while (a >= step && n < max) {
|
|
748
|
+
a -= step;
|
|
749
|
+
n++;
|
|
750
|
+
}
|
|
751
|
+
if (n >= max) a = 0;
|
|
752
|
+
return { steps: n, acc: a };
|
|
753
|
+
}
|
|
754
|
+
var playerAudioCtx = null;
|
|
755
|
+
var playerAudioBuffers = /* @__PURE__ */ new Map();
|
|
756
|
+
function getAudioCtx() {
|
|
757
|
+
if (!playerAudioCtx) playerAudioCtx = new (window.AudioContext ?? window.webkitAudioContext)();
|
|
758
|
+
return playerAudioCtx;
|
|
759
|
+
}
|
|
760
|
+
var FlatPlayer = class {
|
|
761
|
+
constructor(canvas, doc, opts = {}) {
|
|
762
|
+
this.canvas = canvas;
|
|
763
|
+
const ctx = canvas.getContext("2d");
|
|
764
|
+
if (!ctx) throw new Error("FlatPlayer: 2D context unavailable");
|
|
765
|
+
this.ctx = ctx;
|
|
766
|
+
this.doc = applyInstanceBinds(withCels(sanitizeDoc(doc)));
|
|
767
|
+
this.loop = opts.loop ?? true;
|
|
768
|
+
this.pad = opts.padding ?? 0;
|
|
769
|
+
this.audioOn = opts.audio ?? true;
|
|
770
|
+
this.onEvent = opts.onEvent;
|
|
771
|
+
this.renderOn = opts.render ?? true;
|
|
772
|
+
this.imageProvider = opts.image;
|
|
773
|
+
this.resolveAsset = opts.resolveAsset ?? ((a) => isEmbeddedData(a.data) ? a.data : null);
|
|
774
|
+
this.vars = cloneVars(doc.variables);
|
|
775
|
+
this.buildFunctions();
|
|
776
|
+
this.measure();
|
|
777
|
+
this.render();
|
|
778
|
+
this.fireLoad();
|
|
779
|
+
window.addEventListener("resize", this.onResize);
|
|
780
|
+
if (opts.input ?? true) {
|
|
781
|
+
globalThis.addEventListener("keydown", this.onKeyDown);
|
|
782
|
+
globalThis.addEventListener("keyup", this.onKeyUp);
|
|
783
|
+
this.canvas.addEventListener("pointermove", this.onPointerMove);
|
|
784
|
+
this.canvas.addEventListener("pointerdown", this.onPointerDown);
|
|
785
|
+
this.canvas.addEventListener("pointerup", this.onPointerUp);
|
|
786
|
+
this.canvas.addEventListener("pointercancel", this.onPointerCancel);
|
|
787
|
+
this.canvas.addEventListener("pointerleave", this.onPointerLeave);
|
|
788
|
+
}
|
|
789
|
+
if (opts.autoplay) this.play();
|
|
790
|
+
}
|
|
791
|
+
canvas;
|
|
792
|
+
ctx;
|
|
793
|
+
doc;
|
|
794
|
+
loop;
|
|
795
|
+
pad;
|
|
796
|
+
dpr = 1;
|
|
797
|
+
cssW = 0;
|
|
798
|
+
cssH = 0;
|
|
799
|
+
view = { tx: 0, ty: 0, scale: 1 };
|
|
800
|
+
frame = 0;
|
|
801
|
+
playing = false;
|
|
802
|
+
raf = 0;
|
|
803
|
+
last = 0;
|
|
804
|
+
simAcc = 0;
|
|
805
|
+
// time accumulator for the fixed-step simulation (onEnterFrame)
|
|
806
|
+
// Render interpolation (anti-judder): we draw the motion driven by `onEnterFrame` at the INTERPOLATED
|
|
807
|
+
// position between the two last sim steps, by `simAlpha = simAcc/SIM_STEP`. Otherwise a 60 Hz sim
|
|
808
|
+
// on a 120 Hz screen (ProMotion) stutters. Cf. "Fix Your Timestep" (Gaffer).
|
|
809
|
+
prevSimVars = null;
|
|
810
|
+
simAlpha = 1;
|
|
811
|
+
simActive = false;
|
|
812
|
+
audioOn;
|
|
813
|
+
onEvent;
|
|
814
|
+
renderOn = true;
|
|
815
|
+
imageProvider;
|
|
816
|
+
resolveAsset;
|
|
817
|
+
// Gesture recording (`--record`): we play by hand, capture down/up/cancel + the `move`s
|
|
818
|
+
// DURING a drag (reduced volume), with `wait`s (elapsed frames) between them -> replayable script.
|
|
819
|
+
recording = null;
|
|
820
|
+
recordFrame = 0;
|
|
821
|
+
// Perf: cache of static filtered composites (set dressing). `imageEpoch` bumps on each decoded image
|
|
822
|
+
// -> invalidates the entries depending on an asset that just loaded. Cleared when the doc changes.
|
|
823
|
+
filterCache = /* @__PURE__ */ new Map();
|
|
824
|
+
imageEpoch = 0;
|
|
825
|
+
activeSources = [];
|
|
826
|
+
// -- Interaction (Layer B) --
|
|
827
|
+
vars = /* @__PURE__ */ new Map();
|
|
828
|
+
procs = /* @__PURE__ */ new Map();
|
|
829
|
+
// fn name(p) { ... }
|
|
830
|
+
valueFuncs = [];
|
|
831
|
+
// fn name(p) = expr (compiled)
|
|
832
|
+
funcDepth = 0;
|
|
833
|
+
// anti-recursion guard (procedures + value functions)
|
|
834
|
+
mouse = { x: 0, y: 0, dx: 0, dy: 0 };
|
|
835
|
+
// dx/dy = movement SINCE the last tick (reset to 0 after onEnterFrame) -> "is the mouse moving this frame?"
|
|
836
|
+
heldKeys = /* @__PURE__ */ new Set();
|
|
837
|
+
keyProxy = new Proxy(
|
|
838
|
+
{},
|
|
839
|
+
{ get: (_t, k) => typeof k === "string" && this.heldKeys.has(k) ? 1 : 0 }
|
|
840
|
+
);
|
|
841
|
+
hovered = null;
|
|
842
|
+
hoverIds = /* @__PURE__ */ new Set();
|
|
843
|
+
// ALL ids in the topmost hit chain under the pointer (for self.hovered feedback, handler-independent)
|
|
844
|
+
selfChannels = null;
|
|
845
|
+
// `self` in a handler = the targeted object's channels (set for the duration of runActions)
|
|
846
|
+
selfParent = null;
|
|
847
|
+
// world transform of the targeted object's parent -> toLocal/toGlobal conversions
|
|
848
|
+
// namedChannels resolves the WHOLE scene (costly); memoized per frame -- otherwise recomputed on every
|
|
849
|
+
// evalNumber (hundreds/frame in a game) -> stutter. Invalidated by inputs (cf. bustNamed).
|
|
850
|
+
namedCache = null;
|
|
851
|
+
namedFrame = Number.NaN;
|
|
852
|
+
// -- Grabbing (drag / press / long-press) --
|
|
853
|
+
grabbed = null;
|
|
854
|
+
// grabbed item (between pointerdown and pointerup)
|
|
855
|
+
grabStart = { x: 0, y: 0 };
|
|
856
|
+
// world point of the grab (long-press tolerance)
|
|
857
|
+
dragActive = null;
|
|
858
|
+
// "drag" interactor in progress (parentInv cached at grab time)
|
|
859
|
+
// `reveal` coverage PERSISTED per target across grabs → true monotonicity (a child scratching with
|
|
860
|
+
// several short strokes keeps accumulating instead of resetting to the current stroke each grab).
|
|
861
|
+
revealStates = /* @__PURE__ */ new Map();
|
|
862
|
+
longPressTimer = null;
|
|
863
|
+
lastFrameInt = -1;
|
|
864
|
+
onResize = () => {
|
|
865
|
+
this.measure();
|
|
866
|
+
this.render();
|
|
867
|
+
};
|
|
868
|
+
/** Invalidates the named-objects cache (input changed outside of a frame advance). */
|
|
869
|
+
bustNamed() {
|
|
870
|
+
this.namedFrame = Number.NaN;
|
|
871
|
+
}
|
|
872
|
+
onKeyDown = (e) => {
|
|
873
|
+
this.heldKeys.add(e.key);
|
|
874
|
+
if (e.key === " ") this.heldKeys.add("Space");
|
|
875
|
+
this.bustNamed();
|
|
876
|
+
};
|
|
877
|
+
onKeyUp = (e) => {
|
|
878
|
+
this.heldKeys.delete(e.key);
|
|
879
|
+
if (e.key === " ") this.heldKeys.delete("Space");
|
|
880
|
+
this.bustNamed();
|
|
881
|
+
};
|
|
882
|
+
onPointerLeave = () => {
|
|
883
|
+
if (this.grabbed) {
|
|
884
|
+
const id = this.grabbed;
|
|
885
|
+
this.clearGrab();
|
|
886
|
+
this.fireEvent(id, "release");
|
|
887
|
+
}
|
|
888
|
+
if (this.hovered) {
|
|
889
|
+
this.fireEvent(this.hovered, "leave");
|
|
890
|
+
this.hovered = null;
|
|
891
|
+
}
|
|
892
|
+
this.hoverIds.clear();
|
|
893
|
+
this.render();
|
|
894
|
+
};
|
|
895
|
+
worldPoint(e) {
|
|
896
|
+
const r = this.canvas.getBoundingClientRect();
|
|
897
|
+
return { x: (e.clientX - r.left - this.view.tx) / this.view.scale, y: (e.clientY - r.top - this.view.ty) / this.view.scale };
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Target of an event at a point: we walk ALL the hit chains (top to bottom) and, within each,
|
|
901
|
+
* from deepest to root. The first item carrying a handler for `event` wins. This way a click
|
|
902
|
+
* "falls through" a non-interactive item placed on top down to the clickable one below, instead
|
|
903
|
+
* of being swallowed by it.
|
|
904
|
+
*/
|
|
905
|
+
pickTarget(chains, events) {
|
|
906
|
+
const inter = this.doc.interactions;
|
|
907
|
+
if (!inter) return null;
|
|
908
|
+
for (const chain of chains) {
|
|
909
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
910
|
+
if (inter.some((x) => x.targetId === chain[i] && events.includes(x.event))) return chain[i];
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
interactorFor(id) {
|
|
916
|
+
return this.doc.interactors?.find((x) => x.targetId === id);
|
|
917
|
+
}
|
|
918
|
+
/** Is the drag active? `enabled` absent = always; otherwise true iff the expression is != 0. */
|
|
919
|
+
interactorEnabled(it) {
|
|
920
|
+
return !it.enabled || this.evalNumber(it.enabled) !== 0;
|
|
921
|
+
}
|
|
922
|
+
/** Topmost grabbable item carrying an ACTIVE (drag) interactor, at a point. */
|
|
923
|
+
pickInteractor(chains) {
|
|
924
|
+
const ins = this.doc.interactors;
|
|
925
|
+
if (!ins?.length) return null;
|
|
926
|
+
for (const chain of chains) for (let i = chain.length - 1; i >= 0; i--) if (ins.some((x) => x.targetId === chain[i] && this.interactorEnabled(x))) return chain[i];
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
/** Applies the current drag: position = pointer + grab offset, then snap, then confine; writes varX/varY.
|
|
930
|
+
* Snap/confine are in WORLD space (where the visual grid and the zone bbox live); the result is brought back into
|
|
931
|
+
* the object's PARENT space (`parentInv` cached at grab time -- no scene walk per movement). */
|
|
932
|
+
/** Writes a gesture output: simple variable `name`, or array element `name[idx]` (idx is EVALUATED).
|
|
933
|
+
* The indexed form is the natural output under `each` (e.g. `drag hx[i], hy[i]` -> `hx[0]`... after unfolding). */
|
|
934
|
+
writeOut(target, value) {
|
|
935
|
+
const lb = target.indexOf("[");
|
|
936
|
+
if (lb < 0) {
|
|
937
|
+
this.vars.set(target, value);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const a = this.vars.get(target.slice(0, lb));
|
|
941
|
+
if (!Array.isArray(a)) return;
|
|
942
|
+
const i = Math.round(this.evalNumber(target.slice(lb + 1, target.lastIndexOf("]"))));
|
|
943
|
+
if (i >= 0 && i < a.length) a[i] = value;
|
|
944
|
+
}
|
|
945
|
+
applyDrag(p) {
|
|
946
|
+
const d = this.dragActive;
|
|
947
|
+
if (!d) return;
|
|
948
|
+
if (d.it.axis === "turn") {
|
|
949
|
+
const piv = d.it.pivot ?? { x: 0, y: 0 };
|
|
950
|
+
let a = Math.atan2(p.y - piv.y, p.x - piv.x) * 180 / Math.PI;
|
|
951
|
+
if (d.it.grid && d.it.grid > 0) a = Math.round(a / d.it.grid) * d.it.grid;
|
|
952
|
+
if (d.it.varX) this.writeOut(d.it.varX, a);
|
|
953
|
+
this.bustNamed();
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (d.it.axis === "trace") {
|
|
957
|
+
const path = d.tracePath;
|
|
958
|
+
if (path && path.subpaths.length) {
|
|
959
|
+
const t = projectToPath(path, p);
|
|
960
|
+
const near = samplePathAt(path, t).point;
|
|
961
|
+
const tol = d.it.grid && d.it.grid > 0 ? d.it.grid : 24;
|
|
962
|
+
if (Math.hypot(p.x - near.x, p.y - near.y) <= tol) d.traceMaxT = Math.max(d.traceMaxT ?? 0, t);
|
|
963
|
+
if (d.it.varX) this.writeOut(d.it.varX, d.traceMaxT ?? 0);
|
|
964
|
+
this.bustNamed();
|
|
965
|
+
}
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (d.it.axis === "reveal") {
|
|
969
|
+
const g = d.revealGrid;
|
|
970
|
+
const cells = d.revealCells;
|
|
971
|
+
if (g && cells) {
|
|
972
|
+
const brush = d.it.grid && d.it.grid > 0 ? d.it.grid : 24;
|
|
973
|
+
const c0 = Math.max(0, Math.floor((p.x - brush - g.minX) / g.cell));
|
|
974
|
+
const c1 = Math.min(g.cols - 1, Math.floor((p.x + brush - g.minX) / g.cell));
|
|
975
|
+
const r0 = Math.max(0, Math.floor((p.y - brush - g.minY) / g.cell));
|
|
976
|
+
const r1 = Math.min(g.rows - 1, Math.floor((p.y + brush - g.minY) / g.cell));
|
|
977
|
+
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
|
|
978
|
+
const cx = g.minX + (c + 0.5) * g.cell;
|
|
979
|
+
const cy = g.minY + (r + 0.5) * g.cell;
|
|
980
|
+
if (Math.hypot(p.x - cx, p.y - cy) <= brush) cells.add(r * g.cols + c);
|
|
981
|
+
}
|
|
982
|
+
if (d.it.varX) this.writeOut(d.it.varX, cells.size / (g.cols * g.rows));
|
|
983
|
+
this.bustNamed();
|
|
984
|
+
}
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (d.it.axis === "link") {
|
|
988
|
+
if (d.it.varX) this.writeOut(d.it.varX, p.x);
|
|
989
|
+
if (d.it.varY) this.writeOut(d.it.varY, p.y);
|
|
990
|
+
this.bustNamed();
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
let x = p.x + d.offX;
|
|
994
|
+
let y = p.y + d.offY;
|
|
995
|
+
if (d.it.grid && d.it.grid > 0) {
|
|
996
|
+
x = Math.round(x / d.it.grid) * d.it.grid;
|
|
997
|
+
y = Math.round(y / d.it.grid) * d.it.grid;
|
|
998
|
+
}
|
|
999
|
+
if (d.it.confine) {
|
|
1000
|
+
const b = itemBoundsByName(this.doc, d.it.confine);
|
|
1001
|
+
if (b) {
|
|
1002
|
+
x = Math.max(b.minX, Math.min(b.maxX, x));
|
|
1003
|
+
y = Math.max(b.minY, Math.min(b.maxY, y));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
const local = apply3(d.parentInv, { x, y });
|
|
1007
|
+
if (d.it.axis !== "y" && d.it.varX) this.writeOut(d.it.varX, local.x);
|
|
1008
|
+
if (d.it.axis !== "x" && d.it.varY) this.writeOut(d.it.varY, local.y);
|
|
1009
|
+
this.bustNamed();
|
|
1010
|
+
}
|
|
1011
|
+
/** Reveal state for a target: the grid of cells (side = brush) over its WORLD bbox, with the ticked cells
|
|
1012
|
+
* PERSISTED across grabs (`revealStates`) so coverage accumulates monotonically over several strokes. */
|
|
1013
|
+
revealGridFor(id, it) {
|
|
1014
|
+
const cached = this.revealStates.get(id);
|
|
1015
|
+
if (cached) return cached;
|
|
1016
|
+
const b = itemBoundsById(this.doc, id);
|
|
1017
|
+
if (!b) return {};
|
|
1018
|
+
const cell = it.grid && it.grid > 0 ? it.grid : 24;
|
|
1019
|
+
const cols = Math.max(1, Math.ceil((b.maxX - b.minX) / cell));
|
|
1020
|
+
const rows = Math.max(1, Math.ceil((b.maxY - b.minY) / cell));
|
|
1021
|
+
const state = { revealCells: /* @__PURE__ */ new Set(), revealGrid: { minX: b.minX, minY: b.minY, cell, cols, rows } };
|
|
1022
|
+
this.revealStates.set(id, state);
|
|
1023
|
+
return state;
|
|
1024
|
+
}
|
|
1025
|
+
/** On release of a `link`: 1st target (named child of the group) containing the pointer -> index 1..n (0 = none).
|
|
1026
|
+
* If linked, the wire end (endX/endY) sticks to the target center; otherwise it stays at the pointer (the author handles
|
|
1027
|
+
* the "return" via target == 0). Several links coexist: one `link` interactor per source object. */
|
|
1028
|
+
resolveLink(it, pointer) {
|
|
1029
|
+
const targets = it.confine ? groupTargets(this.doc, it.confine) : [];
|
|
1030
|
+
let hit = 0;
|
|
1031
|
+
for (let i = 0; i < targets.length; i++) {
|
|
1032
|
+
const b = targets[i].bbox;
|
|
1033
|
+
if (pointer.x >= b.minX && pointer.x <= b.maxX && pointer.y >= b.minY && pointer.y <= b.maxY) {
|
|
1034
|
+
hit = i + 1;
|
|
1035
|
+
if (it.varX) this.writeOut(it.varX, (b.minX + b.maxX) / 2);
|
|
1036
|
+
if (it.varY) this.writeOut(it.varY, (b.minY + b.maxY) / 2);
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (it.varT) this.writeOut(it.varT, hit);
|
|
1041
|
+
this.bustNamed();
|
|
1042
|
+
}
|
|
1043
|
+
/** On release: `when dropped on Zone` whose tested point falls within the zone bbox.
|
|
1044
|
+
* Tested point = the object's CENTER by default, or the POINTER if `at pointer`. Zone = the group's
|
|
1045
|
+
* explicit `hitbox` if present, otherwise the (static) bbox of its content. */
|
|
1046
|
+
fireDrops(id, pointer) {
|
|
1047
|
+
const drops = this.doc.interactions?.filter((x) => x.targetId === id && x.event === "drop");
|
|
1048
|
+
if (!drops?.length) return;
|
|
1049
|
+
const pos = objectChannelsById(this.doc, id, this.frame, this.exprCtx(), this.fps);
|
|
1050
|
+
const center = { x: pos?.x ?? pointer.x, y: pos?.y ?? pointer.y };
|
|
1051
|
+
for (const d of drops) {
|
|
1052
|
+
if (!d.over) continue;
|
|
1053
|
+
const t = d.atPointer ? pointer : center;
|
|
1054
|
+
const b = dropZoneBounds(this.doc, d.over);
|
|
1055
|
+
if (b && t.x >= b.minX && t.x <= b.maxX && t.y >= b.minY && t.y <= b.maxY) runActions(d.actions, this.host);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
fireEvent(id, event) {
|
|
1059
|
+
const matched = this.doc.interactions?.filter((x) => x.targetId === id && x.event === event);
|
|
1060
|
+
if (!matched?.length) return;
|
|
1061
|
+
const prevSelf = this.selfChannels;
|
|
1062
|
+
const prevParent = this.selfParent;
|
|
1063
|
+
this.selfChannels = null;
|
|
1064
|
+
this.selfParent = null;
|
|
1065
|
+
const ctx = this.exprCtx();
|
|
1066
|
+
this.selfChannels = objectChannelsById(this.doc, id, this.frame, ctx, this.fps) ?? null;
|
|
1067
|
+
this.selfParent = objectParentTransform(this.doc, id, this.frame, ctx, this.fps) ?? IDENTITY3;
|
|
1068
|
+
for (const x of matched) runActions(x.actions, this.host);
|
|
1069
|
+
this.selfChannels = prevSelf;
|
|
1070
|
+
this.selfParent = prevParent;
|
|
1071
|
+
}
|
|
1072
|
+
onPointerMove = (e) => {
|
|
1073
|
+
const p = this.worldPoint(e);
|
|
1074
|
+
this.mouse.dx += p.x - this.mouse.x;
|
|
1075
|
+
this.mouse.dy += p.y - this.mouse.y;
|
|
1076
|
+
this.mouse.x = p.x;
|
|
1077
|
+
this.mouse.y = p.y;
|
|
1078
|
+
this.bustNamed();
|
|
1079
|
+
if (this.grabbed) {
|
|
1080
|
+
this.record("move", p, e.pointerId);
|
|
1081
|
+
if (Math.hypot(p.x - this.grabStart.x, p.y - this.grabStart.y) > LONGPRESS_TOL) this.cancelLongPress();
|
|
1082
|
+
this.canvas.style.cursor = "grabbing";
|
|
1083
|
+
this.applyDrag(p);
|
|
1084
|
+
this.fireEvent(this.grabbed, "drag");
|
|
1085
|
+
this.render();
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (this.doc.interactions?.length || this.doc.interactors?.length) {
|
|
1089
|
+
const chains = hitChains(this.doc, this.frame, this.exprCtx(), p);
|
|
1090
|
+
this.hoverIds = new Set(chains[0] ?? []);
|
|
1091
|
+
this.canvas.style.cursor = this.pickTarget(chains, GRAB_EVENTS) ?? this.pickInteractor(chains) ? "grab" : this.pickTarget(chains, CLICK_EVENTS) ? "pointer" : "default";
|
|
1092
|
+
const hov = this.pickTarget(chains, HOVER_EVENTS);
|
|
1093
|
+
if (hov !== this.hovered) {
|
|
1094
|
+
if (this.hovered) this.fireEvent(this.hovered, "leave");
|
|
1095
|
+
if (hov) this.fireEvent(hov, "enter");
|
|
1096
|
+
this.hovered = hov;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
this.render();
|
|
1100
|
+
};
|
|
1101
|
+
onPointerDown = (e) => {
|
|
1102
|
+
if (!this.doc.interactions?.length && !this.doc.interactors?.length) return;
|
|
1103
|
+
const p = this.worldPoint(e);
|
|
1104
|
+
this.record("down", p, e.pointerId);
|
|
1105
|
+
const chains = hitChains(this.doc, this.frame, this.exprCtx(), p);
|
|
1106
|
+
const clickId = this.pickTarget(chains, CLICK_EVENTS);
|
|
1107
|
+
const grabId = this.pickTarget(chains, GRAB_EVENTS) ?? this.pickInteractor(chains);
|
|
1108
|
+
if (clickId) this.fireEvent(clickId, "click");
|
|
1109
|
+
if (grabId) {
|
|
1110
|
+
this.grabbed = grabId;
|
|
1111
|
+
this.grabStart = p;
|
|
1112
|
+
const inter = this.interactorFor(grabId);
|
|
1113
|
+
if (inter && this.interactorEnabled(inter)) {
|
|
1114
|
+
const ctx = this.exprCtx();
|
|
1115
|
+
const pos = objectChannelsById(this.doc, grabId, this.frame, ctx, this.fps);
|
|
1116
|
+
const parent = objectParentTransform(this.doc, grabId, this.frame, ctx, this.fps) ?? IDENTITY3;
|
|
1117
|
+
this.dragActive = { it: inter, offX: (pos?.x ?? p.x) - p.x, offY: (pos?.y ?? p.y) - p.y, parentInv: invert2(parent), ...inter.axis === "trace" ? { tracePath: inter.confine ? tracePathByName(this.doc, inter.confine) : null, traceMaxT: 0 } : {}, ...inter.axis === "reveal" ? this.revealGridFor(grabId, inter) : {} };
|
|
1118
|
+
}
|
|
1119
|
+
this.canvas.setPointerCapture?.(e.pointerId);
|
|
1120
|
+
this.fireEvent(grabId, "press");
|
|
1121
|
+
this.cancelLongPress();
|
|
1122
|
+
this.longPressTimer = setTimeout(() => {
|
|
1123
|
+
this.longPressTimer = null;
|
|
1124
|
+
if (this.grabbed === grabId) {
|
|
1125
|
+
this.fireEvent(grabId, "longpress");
|
|
1126
|
+
this.render();
|
|
1127
|
+
}
|
|
1128
|
+
}, LONGPRESS_MS);
|
|
1129
|
+
}
|
|
1130
|
+
if (clickId || grabId) this.render();
|
|
1131
|
+
};
|
|
1132
|
+
onPointerUp = (e) => {
|
|
1133
|
+
if (!this.grabbed) return;
|
|
1134
|
+
const id = this.grabbed;
|
|
1135
|
+
const p = this.worldPoint(e);
|
|
1136
|
+
this.record("up", p, e.pointerId);
|
|
1137
|
+
this.canvas.releasePointerCapture?.(e.pointerId);
|
|
1138
|
+
if (this.dragActive?.it.axis === "link") this.resolveLink(this.dragActive.it, p);
|
|
1139
|
+
this.fireEvent(id, "release");
|
|
1140
|
+
if (this.dragActive) this.fireDrops(id, p);
|
|
1141
|
+
this.clearGrab();
|
|
1142
|
+
this.render();
|
|
1143
|
+
};
|
|
1144
|
+
// Interrupted gesture (canceled touch, OS gesture): we release WITHOUT a drop (the pointer did not "let go" on a target).
|
|
1145
|
+
onPointerCancel = (e) => {
|
|
1146
|
+
if (!this.grabbed) return;
|
|
1147
|
+
const id = this.grabbed;
|
|
1148
|
+
this.record("cancel", this.worldPoint(e), e.pointerId);
|
|
1149
|
+
this.canvas.releasePointerCapture?.(e.pointerId);
|
|
1150
|
+
this.fireEvent(id, "release");
|
|
1151
|
+
this.clearGrab();
|
|
1152
|
+
this.render();
|
|
1153
|
+
};
|
|
1154
|
+
cancelLongPress() {
|
|
1155
|
+
if (this.longPressTimer !== null) {
|
|
1156
|
+
clearTimeout(this.longPressTimer);
|
|
1157
|
+
this.longPressTimer = null;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
clearGrab() {
|
|
1161
|
+
this.grabbed = null;
|
|
1162
|
+
this.dragActive = null;
|
|
1163
|
+
this.cancelLongPress();
|
|
1164
|
+
}
|
|
1165
|
+
/** Surface exposed to the action interpreter (frame-actions, future onClick). */
|
|
1166
|
+
host = {
|
|
1167
|
+
play: () => this.play(),
|
|
1168
|
+
pause: () => this.pause(),
|
|
1169
|
+
seek: (f) => this.seek(f),
|
|
1170
|
+
labelFrame: (name) => this.doc.timeline?.labels?.find((l) => l.name === name)?.frame,
|
|
1171
|
+
setVar: (name, v) => {
|
|
1172
|
+
this.vars.set(name, v);
|
|
1173
|
+
},
|
|
1174
|
+
setIndex: (name, i, v) => {
|
|
1175
|
+
const a = this.vars.get(name);
|
|
1176
|
+
if (Array.isArray(a) && i >= 0 && i < a.length) a[i] = v;
|
|
1177
|
+
},
|
|
1178
|
+
callProc: (name, args) => this.callProc(name, args),
|
|
1179
|
+
evalNumber: (src) => this.evalNumber(src),
|
|
1180
|
+
emit: (name, value) => this.emit(name, value),
|
|
1181
|
+
textContent: (itemId) => this.textContent(itemId),
|
|
1182
|
+
playSound: (assetId) => this.playSound(assetId)
|
|
1183
|
+
};
|
|
1184
|
+
/**
|
|
1185
|
+
* Timelines of "active" symbols (referenced by >=1 instance), deduplicated,
|
|
1186
|
+
* with a representative local frame. NB (v1): single playhead -> the symbol
|
|
1187
|
+
* actions share the global state; gotoFrame/play acts on the root.
|
|
1188
|
+
*/
|
|
1189
|
+
activeSymbolTimelines(rootFrame) {
|
|
1190
|
+
const out = [];
|
|
1191
|
+
const seenSym = /* @__PURE__ */ new Set();
|
|
1192
|
+
const walk = (layers, frame, seen) => {
|
|
1193
|
+
for (const layer of layers) {
|
|
1194
|
+
for (const it of layer.items) {
|
|
1195
|
+
if (isInstance3(it)) {
|
|
1196
|
+
if (seen.has(it.symbolId)) continue;
|
|
1197
|
+
const sym = getSymbol3(this.doc, it.symbolId);
|
|
1198
|
+
const local = sym?.timeline ? resolveInstanceFrame3(it.playback, frame, sym.timeline.durationFrames) : frame;
|
|
1199
|
+
if (sym?.timeline && !seenSym.has(it.symbolId)) {
|
|
1200
|
+
seenSym.add(it.symbolId);
|
|
1201
|
+
out.push({ tl: sym.timeline, frame: local });
|
|
1202
|
+
}
|
|
1203
|
+
walk(containerLayers3(this.doc, it), local, /* @__PURE__ */ new Set([...seen, it.symbolId]));
|
|
1204
|
+
} else if (isGroup3(it)) {
|
|
1205
|
+
walk(it.layers, frame, seen);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
walk(this.doc.layers, rootFrame, /* @__PURE__ */ new Set());
|
|
1211
|
+
return out;
|
|
1212
|
+
}
|
|
1213
|
+
/** Actions on load (onLoad): root + active symbols. */
|
|
1214
|
+
fireLoad() {
|
|
1215
|
+
let changed = false;
|
|
1216
|
+
if (this.doc.timeline?.onLoad?.length) {
|
|
1217
|
+
runActions(this.doc.timeline.onLoad, this.host);
|
|
1218
|
+
changed = true;
|
|
1219
|
+
}
|
|
1220
|
+
for (const s of this.activeSymbolTimelines(0)) {
|
|
1221
|
+
if (s.tl.onLoad?.length) {
|
|
1222
|
+
runActions(s.tl.onLoad, this.host);
|
|
1223
|
+
changed = true;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (changed) this.render();
|
|
1227
|
+
}
|
|
1228
|
+
/** (Re)compiles the available functions: imported packages (`use ...`) + doc functions (`fn ...`,
|
|
1229
|
+
* which TAKE PRECEDENCE on a name clash). -> procedures + value functions. */
|
|
1230
|
+
buildFunctions() {
|
|
1231
|
+
this.procs.clear();
|
|
1232
|
+
this.valueFuncs = [];
|
|
1233
|
+
for (const f of [...importedFunctions(this.doc.imports), ...this.doc.functions ?? []]) {
|
|
1234
|
+
if (f.kind === "proc") this.procs.set(f.name, { params: f.params, body: f.body });
|
|
1235
|
+
else this.valueFuncs.push({ name: f.name, params: f.params, comp: compileExpr(f.expr) });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/** Calls a procedure `fn name(p) { ... }`: binds the params (save/restore), bounds the recursion. */
|
|
1239
|
+
callProc(name, args) {
|
|
1240
|
+
const f = this.procs.get(name);
|
|
1241
|
+
if (!f || this.funcDepth > 64) return;
|
|
1242
|
+
const saved = f.params.map((p) => [p, this.vars.get(p)]);
|
|
1243
|
+
f.params.forEach((p, i) => this.vars.set(p, args[i] ?? 0));
|
|
1244
|
+
this.funcDepth++;
|
|
1245
|
+
runActions(f.body, this.host);
|
|
1246
|
+
this.funcDepth--;
|
|
1247
|
+
for (const [p, v] of saved) {
|
|
1248
|
+
if (v === void 0) this.vars.delete(p);
|
|
1249
|
+
else this.vars.set(p, v);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
/** Runtime context for expressions: variables (flattened), mouse, keys, random, value functions,
|
|
1253
|
+
* + scene objects by name (`Hero.x`, cf. sceneRefs). */
|
|
1254
|
+
exprCtx(vars = this.vars) {
|
|
1255
|
+
const interp = vars !== this.vars;
|
|
1256
|
+
const ctx = { mouse: this.mouse, keys: this.keyProxy, random: () => Math.random() };
|
|
1257
|
+
for (const [k, v] of vars) ctx[k] = v;
|
|
1258
|
+
for (const vf of this.valueFuncs) {
|
|
1259
|
+
ctx[vf.name] = (...args) => {
|
|
1260
|
+
if (this.funcDepth > 64 || !vf.comp.ok) return Number.NaN;
|
|
1261
|
+
const local = exprScope(ctx, this.frame / this.fps, this.frame);
|
|
1262
|
+
vf.params.forEach((p, i) => {
|
|
1263
|
+
local[p] = args[i] ?? 0;
|
|
1264
|
+
});
|
|
1265
|
+
this.funcDepth++;
|
|
1266
|
+
const r = evalExpr(vf.comp.node, local, Number.NaN);
|
|
1267
|
+
this.funcDepth--;
|
|
1268
|
+
return r;
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
if (interp) {
|
|
1272
|
+
const named = namedChannels(this.doc, this.frame, ctx, this.fps);
|
|
1273
|
+
for (const name in named) if (!(name in ctx)) ctx[name] = named[name];
|
|
1274
|
+
} else {
|
|
1275
|
+
if (!this.namedCache || this.namedFrame !== this.frame) {
|
|
1276
|
+
this.namedCache = namedChannels(this.doc, this.frame, ctx, this.fps);
|
|
1277
|
+
this.namedFrame = this.frame;
|
|
1278
|
+
}
|
|
1279
|
+
for (const name in this.namedCache) if (!(name in ctx)) ctx[name] = this.namedCache[name];
|
|
1280
|
+
}
|
|
1281
|
+
if (this.selfChannels) ctx.self = this.selfChannels;
|
|
1282
|
+
if (this.selfParent) Object.assign(ctx, spaceConversions(this.selfParent));
|
|
1283
|
+
return ctx;
|
|
1284
|
+
}
|
|
1285
|
+
evalNumber(src) {
|
|
1286
|
+
const c = compileExpr(src);
|
|
1287
|
+
if (!c.ok) return 0;
|
|
1288
|
+
return evalExpr(c.node, exprScope(this.exprCtx(), this.frame / this.fps, this.frame), 0);
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Emits a `send` event toward the host. Silent no-op if no `onEvent` is provided
|
|
1292
|
+
* (e.g. editor preview). Defense-in-depth validation (the parser already guarantees the name):
|
|
1293
|
+
* conforming name, finite number (NaN -> 0, DSL convention), text <= MAX_SEND_TEXT (truncated).
|
|
1294
|
+
* If the host callback throws, we catch and log -- the player does not break.
|
|
1295
|
+
*/
|
|
1296
|
+
emit(name, value) {
|
|
1297
|
+
if (!this.onEvent || !SEND_EVENT_NAME.test(name)) return;
|
|
1298
|
+
let v = value;
|
|
1299
|
+
if (typeof v === "number") {
|
|
1300
|
+
if (!Number.isFinite(v)) v = 0;
|
|
1301
|
+
} else if (typeof v === "string" && v.length > MAX_SEND_TEXT) v = v.slice(0, MAX_SEND_TEXT);
|
|
1302
|
+
try {
|
|
1303
|
+
this.onEvent(v === void 0 ? { name } : { name, value: v });
|
|
1304
|
+
} catch (e) {
|
|
1305
|
+
console.error("FlatPlayer: the onEvent callback threw an exception", e);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/** Live content of a Text item resolved by id OR name (for `text("...")`). `''` + warning if absent. */
|
|
1309
|
+
textContent(key) {
|
|
1310
|
+
const t = this.findText(key);
|
|
1311
|
+
if (!t) {
|
|
1312
|
+
console.warn(`FlatPlayer: text("${key}") -- no Text item "${key}" (id or name) in the document`);
|
|
1313
|
+
return "";
|
|
1314
|
+
}
|
|
1315
|
+
return t.content.length > MAX_SEND_TEXT ? t.content.slice(0, MAX_SEND_TEXT) : t.content;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Looks up a Text item in the scene (layers + groups) and the library symbols.
|
|
1319
|
+
* Resolves by `id` first (stable id set via `text "..." as "<id>"`), then by `name` as a fallback --
|
|
1320
|
+
* like the rest of the text format references by name (`object "x"`, `instance "Sym" as "y"`).
|
|
1321
|
+
*/
|
|
1322
|
+
findText(key) {
|
|
1323
|
+
const scan = (layers, match) => {
|
|
1324
|
+
for (const layer of layers) {
|
|
1325
|
+
for (const it of layer.items) {
|
|
1326
|
+
if (isText3(it)) {
|
|
1327
|
+
if (match(it)) return it;
|
|
1328
|
+
} else if (isGroup3(it)) {
|
|
1329
|
+
const f = scan(it.layers, match);
|
|
1330
|
+
if (f) return f;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return void 0;
|
|
1335
|
+
};
|
|
1336
|
+
const find = (match) => {
|
|
1337
|
+
const inScene = scan(this.doc.layers, match);
|
|
1338
|
+
if (inScene) return inScene;
|
|
1339
|
+
for (const s of this.doc.symbols) {
|
|
1340
|
+
const f = scan(s.layers, match);
|
|
1341
|
+
if (f) return f;
|
|
1342
|
+
}
|
|
1343
|
+
return void 0;
|
|
1344
|
+
};
|
|
1345
|
+
return find((t) => t.id === key) ?? find((t) => t.name === key);
|
|
1346
|
+
}
|
|
1347
|
+
/** Plays an audio clip (asset) as a one-shot (`sound "id"` DSL). No-op if audio is off / asset absent. */
|
|
1348
|
+
playSound(assetId) {
|
|
1349
|
+
if (!this.audioOn) return;
|
|
1350
|
+
const c = getAudioCtx();
|
|
1351
|
+
if (c.state === "suspended") void c.resume();
|
|
1352
|
+
const buf = playerAudioBuffers.get(assetId);
|
|
1353
|
+
if (!buf || buf === "loading") {
|
|
1354
|
+
this.decodeAudio(assetId);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
const src = c.createBufferSource();
|
|
1358
|
+
src.buffer = buf;
|
|
1359
|
+
src.connect(c.destination);
|
|
1360
|
+
src.onended = () => {
|
|
1361
|
+
this.activeSources = this.activeSources.filter((s) => s !== src);
|
|
1362
|
+
};
|
|
1363
|
+
src.start();
|
|
1364
|
+
this.activeSources.push(src);
|
|
1365
|
+
}
|
|
1366
|
+
get fps() {
|
|
1367
|
+
return Math.max(1, this.doc.timeline?.fps ?? 24);
|
|
1368
|
+
}
|
|
1369
|
+
get duration() {
|
|
1370
|
+
return Math.max(1, this.doc.timeline?.durationFrames ?? 1);
|
|
1371
|
+
}
|
|
1372
|
+
get currentFrame() {
|
|
1373
|
+
return this.frame;
|
|
1374
|
+
}
|
|
1375
|
+
get isPlaying() {
|
|
1376
|
+
return this.playing;
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Reads a state variable (Layer B) from the host: score, difficulty... `undefined` if absent.
|
|
1380
|
+
* Returns a COPY of the arrays (the host cannot mutate the internal state by reference).
|
|
1381
|
+
*/
|
|
1382
|
+
getVar(name) {
|
|
1383
|
+
const v = this.vars.get(name);
|
|
1384
|
+
return Array.isArray(v) ? [...v] : v;
|
|
1385
|
+
}
|
|
1386
|
+
/** Snapshot (copied) of all the state variables -- for debugging / the headless harness. */
|
|
1387
|
+
allVars() {
|
|
1388
|
+
const out = {};
|
|
1389
|
+
for (const [k, v] of this.vars) out[k] = Array.isArray(v) ? [...v] : v;
|
|
1390
|
+
return out;
|
|
1391
|
+
}
|
|
1392
|
+
// -- Gesture recording (`--record`) --
|
|
1393
|
+
/** Starts recording (clears the previous one). Play the activity by hand, then `stopRecording()`. */
|
|
1394
|
+
startRecording() {
|
|
1395
|
+
this.recording = [];
|
|
1396
|
+
this.recordFrame = this.frame;
|
|
1397
|
+
}
|
|
1398
|
+
/** Stops recording and returns the gesture script (replayable by `--play` / `playHeadless`). */
|
|
1399
|
+
stopRecording() {
|
|
1400
|
+
const r = this.recording ?? [];
|
|
1401
|
+
this.recording = null;
|
|
1402
|
+
return r;
|
|
1403
|
+
}
|
|
1404
|
+
get isRecording() {
|
|
1405
|
+
return this.recording != null;
|
|
1406
|
+
}
|
|
1407
|
+
/** Center (RESOLVED origin, expressions included) of a named object, in world coords -- for the
|
|
1408
|
+
* semantic gestures `drag`/`tap` by name. `null` if the object does not exist. */
|
|
1409
|
+
objectCenter(name) {
|
|
1410
|
+
this.exprCtx();
|
|
1411
|
+
const ch = this.namedCache?.[name];
|
|
1412
|
+
return ch ? { x: ch.x, y: ch.y } : null;
|
|
1413
|
+
}
|
|
1414
|
+
/** Captures a gesture (no-op outside recording). Inserts a `wait` = frames elapsed since the last gesture. */
|
|
1415
|
+
record(type, p, id) {
|
|
1416
|
+
if (!this.recording) return;
|
|
1417
|
+
const dframes = Math.round(this.frame - this.recordFrame);
|
|
1418
|
+
if (dframes > 0) {
|
|
1419
|
+
this.recording.push({ type: "wait", frames: dframes });
|
|
1420
|
+
this.recordFrame = this.frame;
|
|
1421
|
+
}
|
|
1422
|
+
const r = (n) => Math.round(n * 100) / 100;
|
|
1423
|
+
this.recording.push({ type, x: r(p.x), y: r(p.y), ...id !== 1 ? { id } : {} });
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Writes a state variable (Layer B) from the host, then redraws -> bidirectional
|
|
1427
|
+
* driving channel (the host sets the difficulty, injects a value, etc.). Clones the arrays.
|
|
1428
|
+
*/
|
|
1429
|
+
setVar(name, value) {
|
|
1430
|
+
this.vars.set(name, Array.isArray(value) ? [...value] : value);
|
|
1431
|
+
this.bustNamed();
|
|
1432
|
+
this.render();
|
|
1433
|
+
}
|
|
1434
|
+
/** Replaces the played document (resets the framing + the variables, keeps the frame). */
|
|
1435
|
+
load(doc) {
|
|
1436
|
+
this.doc = applyInstanceBinds(withCels(sanitizeDoc(doc)));
|
|
1437
|
+
this.vars = cloneVars(doc.variables);
|
|
1438
|
+
this.namedCache = null;
|
|
1439
|
+
this.filterCache.clear();
|
|
1440
|
+
this.revealStates.clear();
|
|
1441
|
+
this.bustNamed();
|
|
1442
|
+
this.buildFunctions();
|
|
1443
|
+
this.measure();
|
|
1444
|
+
this.render();
|
|
1445
|
+
this.fireLoad();
|
|
1446
|
+
}
|
|
1447
|
+
measure() {
|
|
1448
|
+
const r = this.canvas.getBoundingClientRect();
|
|
1449
|
+
this.dpr = window.devicePixelRatio || 1;
|
|
1450
|
+
this.cssW = r.width;
|
|
1451
|
+
this.cssH = r.height;
|
|
1452
|
+
this.canvas.width = Math.max(1, Math.round(r.width * this.dpr));
|
|
1453
|
+
this.canvas.height = Math.max(1, Math.round(r.height * this.dpr));
|
|
1454
|
+
this.view = fit(r.width, r.height, this.doc.width, this.doc.height, this.pad);
|
|
1455
|
+
}
|
|
1456
|
+
/** Draws the current frame (pure, without advancing time). */
|
|
1457
|
+
render() {
|
|
1458
|
+
if (!this.renderOn) return;
|
|
1459
|
+
const { ctx, doc, view, dpr } = this;
|
|
1460
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1461
|
+
ctx.clearRect(0, 0, this.cssW, this.cssH);
|
|
1462
|
+
ctx.save();
|
|
1463
|
+
ctx.translate(view.tx, view.ty);
|
|
1464
|
+
ctx.scale(view.scale, view.scale);
|
|
1465
|
+
if (doc.background) {
|
|
1466
|
+
ctx.fillStyle = doc.background;
|
|
1467
|
+
ctx.fillRect(0, 0, doc.width, doc.height);
|
|
1468
|
+
}
|
|
1469
|
+
ctx.beginPath();
|
|
1470
|
+
ctx.rect(0, 0, doc.width, doc.height);
|
|
1471
|
+
ctx.clip();
|
|
1472
|
+
const expr = this.playing && this.simActive && this.prevSimVars && this.simAlpha < 1 ? this.exprCtx(lerpVars(this.prevSimVars, this.vars, this.simAlpha)) : this.exprCtx();
|
|
1473
|
+
renderLayers(ctx, doc, doc.layers, this.frame, null, /* @__PURE__ */ new Set(), { fps: this.fps, expr, image: (id) => this.imageFor(id), filterCache: this.filterCache, imageEpoch: this.imageEpoch, itemState: (id) => this.itemStateFor(id) });
|
|
1474
|
+
ctx.restore();
|
|
1475
|
+
}
|
|
1476
|
+
/** Interaction state of an item for `self.hovered`/`self.grabbed`/`self.pressed` in its channel exprs.
|
|
1477
|
+
* Returns undefined when the item is neither hovered nor grabbed (the cheap, common path → flags 0). */
|
|
1478
|
+
itemStateFor(id) {
|
|
1479
|
+
const hovered = this.hoverIds.has(id) ? 1 : 0;
|
|
1480
|
+
const grabbed = this.grabbed === id ? 1 : 0;
|
|
1481
|
+
return hovered || grabbed ? { hovered, grabbed, pressed: grabbed } : void 0;
|
|
1482
|
+
}
|
|
1483
|
+
// Decoded image of an asset (module cache). `null` while not loaded -> re-render on decode.
|
|
1484
|
+
imageFor(assetId) {
|
|
1485
|
+
if (this.imageProvider) return this.imageProvider(assetId);
|
|
1486
|
+
const a = this.doc.assets?.find((x) => x.id === assetId);
|
|
1487
|
+
if (!a) return null;
|
|
1488
|
+
const url = this.resolveAsset(a);
|
|
1489
|
+
if (url == null) return null;
|
|
1490
|
+
let img = playerImgCache.get(a.id);
|
|
1491
|
+
if (!img) {
|
|
1492
|
+
img = new Image();
|
|
1493
|
+
img.onload = () => {
|
|
1494
|
+
this.imageEpoch++;
|
|
1495
|
+
this.render();
|
|
1496
|
+
};
|
|
1497
|
+
img.src = url;
|
|
1498
|
+
playerImgCache.set(a.id, img);
|
|
1499
|
+
}
|
|
1500
|
+
return img.complete && img.naturalWidth > 0 ? img : null;
|
|
1501
|
+
}
|
|
1502
|
+
seek(frame) {
|
|
1503
|
+
this.frame = Math.max(0, Math.min(this.duration, frame));
|
|
1504
|
+
this.lastFrameInt = Math.floor(this.frame);
|
|
1505
|
+
if (this.playing) this.startAudio(this.frame);
|
|
1506
|
+
this.render();
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Advances the simulation by `steps` FIXED steps (60 Hz) without RAF: runs `onEnterFrame`
|
|
1510
|
+
* (root + active symbols) and advances the playhead as if real time elapsed.
|
|
1511
|
+
* Used by the headless mode (`--play`, `wait` gesture) to let a physics simulation
|
|
1512
|
+
* unfold between two gestures -- in Node, requestAnimationFrame does not exist.
|
|
1513
|
+
*/
|
|
1514
|
+
stepSim(steps) {
|
|
1515
|
+
const rootSim = this.doc.timeline?.onEnterFrame;
|
|
1516
|
+
for (let i = 0; i < Math.max(0, Math.floor(steps)); i++) {
|
|
1517
|
+
let f = this.frame + SIM_STEP * this.fps;
|
|
1518
|
+
if (f >= this.duration) f = this.loop ? f % this.duration : this.duration;
|
|
1519
|
+
this.frame = f;
|
|
1520
|
+
const symSims = this.activeSymbolTimelines(f).filter((s) => s.tl.onEnterFrame?.length);
|
|
1521
|
+
if (rootSim?.length) runActions(rootSim, this.host);
|
|
1522
|
+
for (const s of symSims) runActions(s.tl.onEnterFrame, this.host);
|
|
1523
|
+
this.mouse.dx = 0;
|
|
1524
|
+
this.mouse.dy = 0;
|
|
1525
|
+
this.fireFrameActions();
|
|
1526
|
+
}
|
|
1527
|
+
this.render();
|
|
1528
|
+
}
|
|
1529
|
+
// -- Audio --
|
|
1530
|
+
get audioEnabled() {
|
|
1531
|
+
return this.audioOn;
|
|
1532
|
+
}
|
|
1533
|
+
/** Enables/disables audio (cuts immediately if off; (re)starts if on and playing). */
|
|
1534
|
+
setAudio(on) {
|
|
1535
|
+
if (on === this.audioOn) return;
|
|
1536
|
+
this.audioOn = on;
|
|
1537
|
+
if (!on) this.stopAudio();
|
|
1538
|
+
else if (this.playing) this.startAudio(this.frame);
|
|
1539
|
+
}
|
|
1540
|
+
stopAudio() {
|
|
1541
|
+
for (const s of this.activeSources) {
|
|
1542
|
+
try {
|
|
1543
|
+
s.stop();
|
|
1544
|
+
} catch {
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
this.activeSources = [];
|
|
1548
|
+
}
|
|
1549
|
+
decodeAudio(assetId) {
|
|
1550
|
+
if (playerAudioBuffers.has(assetId)) return;
|
|
1551
|
+
const a = this.doc.assets?.find((x) => x.id === assetId);
|
|
1552
|
+
if (!a) return;
|
|
1553
|
+
const url = this.resolveAsset(a);
|
|
1554
|
+
if (url == null) return;
|
|
1555
|
+
playerAudioBuffers.set(assetId, "loading");
|
|
1556
|
+
fetch(url).then((r) => r.arrayBuffer()).then((b) => getAudioCtx().decodeAudioData(b)).then((buf) => playerAudioBuffers.set(assetId, buf)).catch(() => playerAudioBuffers.delete(assetId));
|
|
1557
|
+
}
|
|
1558
|
+
/** (Re)schedules the audio clips for a playback starting from `fromFrame`. */
|
|
1559
|
+
startAudio(fromFrame) {
|
|
1560
|
+
this.stopAudio();
|
|
1561
|
+
const sounds = this.doc.timeline?.sounds;
|
|
1562
|
+
if (!this.audioOn || !sounds?.length) return;
|
|
1563
|
+
const c = getAudioCtx();
|
|
1564
|
+
if (c.state === "suspended") void c.resume();
|
|
1565
|
+
const now = c.currentTime + 0.03;
|
|
1566
|
+
for (const sch of scheduleSounds(sounds, this.fps, fromFrame, now)) {
|
|
1567
|
+
const buf = playerAudioBuffers.get(sch.clip.assetId);
|
|
1568
|
+
if (!buf || buf === "loading") {
|
|
1569
|
+
this.decodeAudio(sch.clip.assetId);
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
if (!sch.clip.loop && sch.offset >= buf.duration) continue;
|
|
1573
|
+
const src = c.createBufferSource();
|
|
1574
|
+
src.buffer = buf;
|
|
1575
|
+
src.loop = !!sch.clip.loop;
|
|
1576
|
+
const g = c.createGain();
|
|
1577
|
+
g.gain.value = sch.clip.gain ?? 1;
|
|
1578
|
+
src.connect(g).connect(c.destination);
|
|
1579
|
+
src.start(sch.when, Math.max(0, sch.offset));
|
|
1580
|
+
this.activeSources.push(src);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
/** Triggers the frame-actions when the playhead enters a new whole frame. */
|
|
1584
|
+
fireFrameActions() {
|
|
1585
|
+
const fi = Math.floor(this.frame);
|
|
1586
|
+
if (fi === this.lastFrameInt) return;
|
|
1587
|
+
this.lastFrameInt = fi;
|
|
1588
|
+
const fa = this.doc.timeline?.frameActions;
|
|
1589
|
+
if (fa) {
|
|
1590
|
+
for (const e of fa) if (e.frame === fi) runActions(e.actions, this.host);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
play() {
|
|
1594
|
+
if (this.playing) return;
|
|
1595
|
+
this.playing = true;
|
|
1596
|
+
this.last = performance.now();
|
|
1597
|
+
this.simAcc = 0;
|
|
1598
|
+
this.prevSimVars = null;
|
|
1599
|
+
this.simAlpha = 1;
|
|
1600
|
+
this.simActive = false;
|
|
1601
|
+
this.startAudio(this.frame);
|
|
1602
|
+
const tick = (now) => {
|
|
1603
|
+
if (!this.playing) return;
|
|
1604
|
+
const dt = Math.min((now - this.last) / 1e3, 0.25);
|
|
1605
|
+
this.last = now;
|
|
1606
|
+
let f = this.frame + dt * this.fps;
|
|
1607
|
+
if (f >= this.duration) {
|
|
1608
|
+
if (this.loop) {
|
|
1609
|
+
f %= this.duration;
|
|
1610
|
+
this.startAudio(f);
|
|
1611
|
+
} else {
|
|
1612
|
+
f = this.duration;
|
|
1613
|
+
this.playing = false;
|
|
1614
|
+
this.stopAudio();
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
this.frame = f;
|
|
1618
|
+
const symTLs = this.activeSymbolTimelines(f);
|
|
1619
|
+
const rootSim = this.doc.timeline?.onEnterFrame;
|
|
1620
|
+
const symSims = symTLs.filter((s) => s.tl.onEnterFrame?.length);
|
|
1621
|
+
if (rootSim?.length || symSims.length) {
|
|
1622
|
+
this.simActive = true;
|
|
1623
|
+
const { steps, acc } = simSteps(this.simAcc, dt, SIM_STEP, SIM_MAX_STEPS);
|
|
1624
|
+
this.simAcc = acc;
|
|
1625
|
+
for (let i = 0; i < steps && this.playing; i++) {
|
|
1626
|
+
this.prevSimVars = cloneVarMap(this.vars);
|
|
1627
|
+
if (rootSim?.length) runActions(rootSim, this.host);
|
|
1628
|
+
for (const s of symSims) runActions(s.tl.onEnterFrame, this.host);
|
|
1629
|
+
}
|
|
1630
|
+
this.simAlpha = Math.min(1, this.simAcc / SIM_STEP);
|
|
1631
|
+
} else {
|
|
1632
|
+
this.simAcc = 0;
|
|
1633
|
+
this.simActive = false;
|
|
1634
|
+
this.prevSimVars = null;
|
|
1635
|
+
}
|
|
1636
|
+
this.mouse.dx = 0;
|
|
1637
|
+
this.mouse.dy = 0;
|
|
1638
|
+
this.fireFrameActions();
|
|
1639
|
+
this.render();
|
|
1640
|
+
if (this.playing) this.raf = requestAnimationFrame(tick);
|
|
1641
|
+
};
|
|
1642
|
+
this.raf = requestAnimationFrame(tick);
|
|
1643
|
+
}
|
|
1644
|
+
pause() {
|
|
1645
|
+
this.playing = false;
|
|
1646
|
+
this.simActive = false;
|
|
1647
|
+
cancelAnimationFrame(this.raf);
|
|
1648
|
+
this.stopAudio();
|
|
1649
|
+
}
|
|
1650
|
+
toggle() {
|
|
1651
|
+
if (this.playing) this.pause();
|
|
1652
|
+
else this.play();
|
|
1653
|
+
}
|
|
1654
|
+
stop() {
|
|
1655
|
+
this.pause();
|
|
1656
|
+
this.seek(0);
|
|
1657
|
+
}
|
|
1658
|
+
/** Releases the listeners. To be called when the player is no longer used. */
|
|
1659
|
+
destroy() {
|
|
1660
|
+
this.pause();
|
|
1661
|
+
window.removeEventListener("resize", this.onResize);
|
|
1662
|
+
globalThis.removeEventListener("keydown", this.onKeyDown);
|
|
1663
|
+
globalThis.removeEventListener("keyup", this.onKeyUp);
|
|
1664
|
+
this.canvas.removeEventListener("pointermove", this.onPointerMove);
|
|
1665
|
+
this.canvas.removeEventListener("pointerdown", this.onPointerDown);
|
|
1666
|
+
this.canvas.removeEventListener("pointerup", this.onPointerUp);
|
|
1667
|
+
this.canvas.removeEventListener("pointercancel", this.onPointerCancel);
|
|
1668
|
+
this.canvas.removeEventListener("pointerleave", this.onPointerLeave);
|
|
1669
|
+
this.cancelLongPress();
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1673
|
+
export {
|
|
1674
|
+
applyTransform,
|
|
1675
|
+
regionPath,
|
|
1676
|
+
renderItems,
|
|
1677
|
+
renderLayers,
|
|
1678
|
+
sameOriginAssetResolver,
|
|
1679
|
+
FlatPlayer
|
|
1680
|
+
};
|
|
1681
|
+
//# sourceMappingURL=chunk-BXKJAHHO.js.map
|