@flare-engine/render 0.2.0 → 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/dist/index.mjs CHANGED
@@ -1,632 +1,324 @@
1
- // src/draw-order.ts
2
- function sortByDrawOrder(items, length) {
3
- const n = length ?? items.length;
4
- for (let i = 1; i < n; i++) {
5
- const item = items[i];
6
- let j = i - 1;
7
- while (j >= 0 && items[j].z > item.z) {
8
- items[j + 1] = items[j];
9
- j--;
10
- }
11
- items[j + 1] = item;
12
- }
13
- return items;
14
- }
15
- function drawOrderCompare(a, b) {
16
- return a.z - b.z;
17
- }
18
-
19
- // src/camera2d.ts
1
+ import { defineComponent } from "@flare-engine/ecs";
2
+ //#region src/camera2d.ts
3
+ /** Simple pseudo-random for deterministic shake. Avoids Math.random() allocs. */
20
4
  function pseudoRandom(seed) {
21
- const x = Math.sin(seed * 12.9898 + seed * 78.233) * 43758.5453;
22
- return x - Math.floor(x);
5
+ const x = Math.sin(seed * 12.9898 + seed * 78.233) * 43758.5453;
6
+ return x - Math.floor(x);
23
7
  }
24
- var PERM = new Uint8Array(256);
8
+ const PERM = /* @__PURE__ */ new Uint8Array(256);
25
9
  {
26
- for (let i = 0; i < 256; i++) PERM[i] = i;
27
- let s = 2654435769;
28
- for (let i = 255; i > 0; i--) {
29
- s = (s ^ s >>> 15) >>> 0;
30
- s = Math.imul(s, 2246822507) >>> 0;
31
- s = (s ^ s >>> 13) >>> 0;
32
- const j = s % (i + 1);
33
- const t = PERM[i];
34
- PERM[i] = PERM[j];
35
- PERM[j] = t;
36
- }
10
+ for (let i = 0; i < 256; i++) PERM[i] = i;
11
+ let s = 2654435769;
12
+ for (let i = 255; i > 0; i--) {
13
+ s = (s ^ s >>> 15) >>> 0;
14
+ s = Math.imul(s, 2246822507) >>> 0;
15
+ s = (s ^ s >>> 13) >>> 0;
16
+ const j = s % (i + 1);
17
+ const t = PERM[i];
18
+ PERM[i] = PERM[j];
19
+ PERM[j] = t;
20
+ }
37
21
  }
38
- var S = Math.SQRT1_2;
39
- var GRADS = [
40
- [1, 0],
41
- [S, S],
42
- [0, 1],
43
- [-S, S],
44
- [-1, 0],
45
- [-S, -S],
46
- [0, -1],
47
- [S, -S]
22
+ const S = Math.SQRT1_2;
23
+ const GRADS = [
24
+ [1, 0],
25
+ [S, S],
26
+ [0, 1],
27
+ [-S, S],
28
+ [-1, 0],
29
+ [-S, -S],
30
+ [0, -1],
31
+ [S, -S]
48
32
  ];
49
33
  function grad2(hash, x, y) {
50
- const g = GRADS[hash & 7];
51
- return g[0] * x + g[1] * y;
34
+ const g = GRADS[hash & 7];
35
+ return g[0] * x + g[1] * y;
52
36
  }
53
37
  function fade(t) {
54
- return t * t * t * (t * (t * 6 - 15) + 10);
38
+ return t * t * t * (t * (t * 6 - 15) + 10);
55
39
  }
40
+ /** 2D value-gradient noise in [-1, 1]. */
56
41
  function noise2d(x, y) {
57
- const xi = Math.floor(x) & 255;
58
- const yi = Math.floor(y) & 255;
59
- const xf = x - Math.floor(x);
60
- const yf = y - Math.floor(y);
61
- const u = fade(xf);
62
- const v = fade(yf);
63
- const aa = PERM[PERM[xi] + yi];
64
- const ab = PERM[PERM[xi] + yi + 1];
65
- const ba = PERM[PERM[xi + 1 & 255] + yi];
66
- const bb = PERM[PERM[xi + 1 & 255] + yi + 1];
67
- const x1 = xf;
68
- const y1 = yf;
69
- const x2 = xf - 1;
70
- const y2 = yf - 1;
71
- const n00 = grad2(aa, x1, y1);
72
- const n10 = grad2(ba, x2, y1);
73
- const n01 = grad2(ab, x1, y2);
74
- const n11 = grad2(bb, x2, y2);
75
- const lerpX0 = n00 + u * (n10 - n00);
76
- const lerpX1 = n01 + u * (n11 - n01);
77
- return lerpX0 + v * (lerpX1 - lerpX0);
42
+ const xi = Math.floor(x) & 255;
43
+ const yi = Math.floor(y) & 255;
44
+ const xf = x - Math.floor(x);
45
+ const yf = y - Math.floor(y);
46
+ const u = fade(xf);
47
+ const v = fade(yf);
48
+ const aa = PERM[PERM[xi] + yi];
49
+ const ab = PERM[PERM[xi] + yi + 1];
50
+ const ba = PERM[PERM[xi + 1 & 255] + yi];
51
+ const bb = PERM[PERM[xi + 1 & 255] + yi + 1];
52
+ const x1 = xf;
53
+ const y1 = yf;
54
+ const x2 = xf - 1;
55
+ const y2 = yf - 1;
56
+ const n00 = grad2(aa, x1, y1);
57
+ const n10 = grad2(ba, x2, y1);
58
+ const n01 = grad2(ab, x1, y2);
59
+ const n11 = grad2(bb, x2, y2);
60
+ const lerpX0 = n00 + u * (n10 - n00);
61
+ return lerpX0 + v * (n01 + u * (n11 - n01) - lerpX0);
78
62
  }
63
+ /** 2-octave FBM for shake displacement — result in [-1, 1]. */
79
64
  function fbmNoise2d(x, y) {
80
- return 0.6667 * noise2d(x, y) + 0.3333 * noise2d(x * 2, y * 2);
65
+ return .6667 * noise2d(x, y) + .3333 * noise2d(x * 2, y * 2);
81
66
  }
67
+ /**
68
+ * Camera2D — 2D camera with position, zoom, rotation, follow, and shake.
69
+ * Pure data + methods — no Skia dependency. The renderer reads camera state
70
+ * to build the view transform.
71
+ */
82
72
  var Camera2D = class {
83
- /** Camera position (world-space center). */
84
- x = 0;
85
- y = 0;
86
- /** Zoom level (1 = 100%). */
87
- zoom = 1;
88
- /** Rotation in radians. */
89
- rotation = 0;
90
- /** Viewport width in pixels. */
91
- viewportWidth = 0;
92
- /** Viewport height in pixels. */
93
- viewportHeight = 0;
94
- /** Optional world bounds to clamp camera position. */
95
- bounds = null;
96
- _follow = null;
97
- _shake = null;
98
- _shakePerlin = null;
99
- _shakeSeed = 0;
100
- _shakePerlinSeed = 0;
101
- _motionScale = 1;
102
- /** Set viewport dimensions. */
103
- setViewport(width, height) {
104
- this.viewportWidth = width;
105
- this.viewportHeight = height;
106
- }
107
- /** Start following a target position. */
108
- follow(target, options) {
109
- this._follow = { target, options: options ?? {} };
110
- }
111
- /** Stop following. */
112
- unfollow() {
113
- this._follow = null;
114
- }
115
- /**
116
- * Global motion multiplier applied to subsequent `shake()` calls. Scales
117
- * both amplitude and duration. Default 1 (no-op). 0 fully suppresses
118
- * shake. Intended to be driven by `@flare-engine/a11y` reduced-motion so
119
- * callers don't have to guard every shake site.
120
- *
121
- * Only affects future `shake()` calls — does not retroactively modify an
122
- * in-flight shake.
123
- *
124
- * Clamped to `>= 0`.
125
- */
126
- setMotionScale(scale) {
127
- this._motionScale = scale < 0 ? 0 : scale;
128
- }
129
- /** Current motion scale (default 1). */
130
- get motionScale() {
131
- return this._motionScale;
132
- }
133
- /** Start camera shake. */
134
- shake(intensity, duration) {
135
- const k = this._motionScale;
136
- if (k <= 0) {
137
- this._shake = null;
138
- return;
139
- }
140
- this._shake = {
141
- intensity: intensity * k,
142
- duration: duration * k,
143
- elapsed: 0,
144
- offsetX: 0,
145
- offsetY: 0
146
- };
147
- this._shakeSeed++;
148
- }
149
- /**
150
- * Start a Perlin-noise-based camera shake. Overwrites any in-flight
151
- * shakePerlin (same semantics as shake()). Respects motionScale.
152
- */
153
- shakePerlin(intensity, duration) {
154
- const k = this._motionScale;
155
- if (k <= 0) {
156
- this._shakePerlin = null;
157
- return;
158
- }
159
- this._shakePerlin = {
160
- intensity: intensity * k,
161
- duration: duration * k,
162
- elapsed: 0,
163
- offsetX: 0,
164
- offsetY: 0
165
- };
166
- this._shakePerlinSeed++;
167
- }
168
- /**
169
- * Update camera. Call once per frame.
170
- * @param dt Delta time in seconds.
171
- */
172
- update(dt) {
173
- if (this._follow) {
174
- const { target, options } = this._follow;
175
- const smoothing = options.smoothing ?? 0.1;
176
- const offsetX = options.offset?.x ?? 0;
177
- const offsetY = options.offset?.y ?? 0;
178
- const goalX = target.x + offsetX;
179
- const goalY = target.y + offsetY;
180
- if (options.deadZone) {
181
- const dz = options.deadZone;
182
- const relX = target.x - this.x;
183
- const relY = target.y - this.y;
184
- if (relX < dz.x) {
185
- this.x += (goalX - this.x - (dz.x - relX)) * (1 - smoothing ** dt);
186
- } else if (relX > dz.x + dz.width) {
187
- this.x += (goalX - this.x + (relX - dz.x - dz.width)) * (1 - smoothing ** dt);
188
- }
189
- if (relY < dz.y) {
190
- this.y += (goalY - this.y - (dz.y - relY)) * (1 - smoothing ** dt);
191
- } else if (relY > dz.y + dz.height) {
192
- this.y += (goalY - this.y + (relY - dz.y - dz.height)) * (1 - smoothing ** dt);
193
- }
194
- } else {
195
- const factor = 1 - smoothing ** dt;
196
- this.x += (goalX - this.x) * factor;
197
- this.y += (goalY - this.y) * factor;
198
- }
199
- }
200
- if (this.bounds) {
201
- this._clampToBounds();
202
- }
203
- if (this._shake) {
204
- this._shake.elapsed += dt;
205
- if (this._shake.elapsed >= this._shake.duration) {
206
- this._shake = null;
207
- } else {
208
- const progress = this._shake.elapsed / this._shake.duration;
209
- const decay = 1 - progress;
210
- const seed = this._shakeSeed + this._shake.elapsed * 100;
211
- this._shake.offsetX = (pseudoRandom(seed) * 2 - 1) * this._shake.intensity * decay;
212
- this._shake.offsetY = (pseudoRandom(seed + 1) * 2 - 1) * this._shake.intensity * decay;
213
- }
214
- }
215
- if (this._shakePerlin) {
216
- this._shakePerlin.elapsed += dt;
217
- if (this._shakePerlin.elapsed >= this._shakePerlin.duration) {
218
- this._shakePerlin = null;
219
- } else {
220
- const progress = this._shakePerlin.elapsed / this._shakePerlin.duration;
221
- const decay = 1 - progress;
222
- const t = this._shakePerlin.elapsed * 4.7 + this._shakePerlinSeed * 17.3;
223
- this._shakePerlin.offsetX = fbmNoise2d(t, 0) * this._shakePerlin.intensity * decay;
224
- this._shakePerlin.offsetY = fbmNoise2d(0, t + 5.4) * this._shakePerlin.intensity * decay;
225
- }
226
- }
227
- }
228
- /** Get the effective camera X (including shake + shakePerlin offsets). */
229
- get effectiveX() {
230
- return this.x + (this._shake?.offsetX ?? 0) + (this._shakePerlin?.offsetX ?? 0);
231
- }
232
- /** Get the effective camera Y (including shake + shakePerlin offsets). */
233
- get effectiveY() {
234
- return this.y + (this._shake?.offsetY ?? 0) + (this._shakePerlin?.offsetY ?? 0);
235
- }
236
- /** Convert world coordinates to screen coordinates. */
237
- worldToScreen(worldX, worldY) {
238
- return this.worldToScreenInto(worldX, worldY, { x: 0, y: 0 });
239
- }
240
- /** Zero-alloc variant — writes screen coordinates into `out`. */
241
- worldToScreenInto(worldX, worldY, out) {
242
- const cos = Math.cos(-this.rotation);
243
- const sin = Math.sin(-this.rotation);
244
- const dx = (worldX - this.effectiveX) * this.zoom;
245
- const dy = (worldY - this.effectiveY) * this.zoom;
246
- out.x = cos * dx - sin * dy + this.viewportWidth / 2;
247
- out.y = sin * dx + cos * dy + this.viewportHeight / 2;
248
- return out;
249
- }
250
- /** Convert screen coordinates to world coordinates. */
251
- screenToWorld(screenX, screenY) {
252
- return this.screenToWorldInto(screenX, screenY, { x: 0, y: 0 });
253
- }
254
- /** Zero-alloc variant — writes world coordinates into `out`. */
255
- screenToWorldInto(screenX, screenY, out) {
256
- const cx = screenX - this.viewportWidth / 2;
257
- const cy = screenY - this.viewportHeight / 2;
258
- const cos = Math.cos(this.rotation);
259
- const sin = Math.sin(this.rotation);
260
- const rx = cos * cx - sin * cy;
261
- const ry = sin * cx + cos * cy;
262
- out.x = rx / this.zoom + this.effectiveX;
263
- out.y = ry / this.zoom + this.effectiveY;
264
- return out;
265
- }
266
- /** Get the visible world-space bounding rect (allocates a fresh Rect). */
267
- getBounds() {
268
- return this.getBoundsInto({ x: 0, y: 0, width: 0, height: 0 });
269
- }
270
- /** Zero-alloc variant — writes the visible bounds into `out`. */
271
- getBoundsInto(out) {
272
- const halfW = this.viewportWidth / (2 * this.zoom);
273
- const halfH = this.viewportHeight / (2 * this.zoom);
274
- out.x = this.effectiveX - halfW;
275
- out.y = this.effectiveY - halfH;
276
- out.width = halfW * 2;
277
- out.height = halfH * 2;
278
- return out;
279
- }
280
- _clampToBounds() {
281
- const b = this.bounds;
282
- const halfW = this.viewportWidth / (2 * this.zoom);
283
- const halfH = this.viewportHeight / (2 * this.zoom);
284
- const minX = b.x + halfW;
285
- const maxX = b.x + b.width - halfW;
286
- const minY = b.y + halfH;
287
- const maxY = b.y + b.height - halfH;
288
- if (minX <= maxX) {
289
- this.x = Math.max(minX, Math.min(maxX, this.x));
290
- } else {
291
- this.x = b.x + b.width / 2;
292
- }
293
- if (minY <= maxY) {
294
- this.y = Math.max(minY, Math.min(maxY, this.y));
295
- } else {
296
- this.y = b.y + b.height / 2;
297
- }
298
- }
299
- };
300
-
301
- // src/renderer.ts
302
- var Renderer = class {
303
- /** Current frame stats. */
304
- stats = { drawCalls: 0, batchCount: 0 };
305
- _canvas = null;
306
- _inFrame = false;
307
- /** Set the canvas to render to. Called once when canvas is available. */
308
- setCanvas(canvas) {
309
- this._canvas = canvas;
310
- }
311
- /** Get the current canvas. Throws if not in a frame. */
312
- get canvas() {
313
- if (!this._canvas) {
314
- throw new Error("Renderer: no canvas set");
315
- }
316
- return this._canvas;
317
- }
318
- /** Whether we are currently between begin() and end(). */
319
- get inFrame() {
320
- return this._inFrame;
321
- }
322
- /**
323
- * Begin a new frame. Clears the canvas and resets stats.
324
- * @param clearColor 32-bit ARGB color to clear with (default black = 0xFF000000).
325
- */
326
- begin(clearColor = 4278190080) {
327
- if (this._inFrame) {
328
- throw new Error("Renderer: begin() called while already in frame");
329
- }
330
- this._inFrame = true;
331
- this.stats.drawCalls = 0;
332
- this.stats.batchCount = 0;
333
- this.canvas.save();
334
- this.canvas.clear(clearColor);
335
- }
336
- /**
337
- * End the current frame. Restores canvas state.
338
- */
339
- end() {
340
- if (!this._inFrame) {
341
- throw new Error("Renderer: end() called without begin()");
342
- }
343
- this.canvas.restore();
344
- this._inFrame = false;
345
- }
346
- /**
347
- * Apply camera transform to the canvas.
348
- * Call after begin() and before drawing.
349
- */
350
- applyCamera(cameraX, cameraY, zoom, rotation, viewportWidth, viewportHeight) {
351
- const c = this.canvas;
352
- c.translate(viewportWidth / 2, viewportHeight / 2);
353
- c.scale(zoom, zoom);
354
- if (rotation !== 0) {
355
- c.rotate(-rotation, 0, 0);
356
- }
357
- c.translate(-cameraX, -cameraY);
358
- }
359
- /** Increment draw call counter. Called by batching systems. */
360
- recordDrawCall() {
361
- this.stats.drawCalls++;
362
- }
363
- /** Increment batch counter. */
364
- recordBatch() {
365
- this.stats.batchCount++;
366
- }
73
+ /** Camera position (world-space center). */
74
+ x = 0;
75
+ y = 0;
76
+ /** Zoom level (1 = 100%). */
77
+ zoom = 1;
78
+ /** Rotation in radians. */
79
+ rotation = 0;
80
+ /** Viewport width in pixels. */
81
+ viewportWidth = 0;
82
+ /** Viewport height in pixels. */
83
+ viewportHeight = 0;
84
+ /** Optional world bounds to clamp camera position. */
85
+ bounds = null;
86
+ _follow = null;
87
+ _shake = null;
88
+ _shakePerlin = null;
89
+ _shakeSeed = 0;
90
+ _shakePerlinSeed = 0;
91
+ _motionScale = 1;
92
+ /** Set viewport dimensions. */
93
+ setViewport(width, height) {
94
+ this.viewportWidth = width;
95
+ this.viewportHeight = height;
96
+ }
97
+ /** Start following a target position. */
98
+ follow(target, options) {
99
+ this._follow = {
100
+ target,
101
+ options: options ?? {}
102
+ };
103
+ }
104
+ /** Stop following. */
105
+ unfollow() {
106
+ this._follow = null;
107
+ }
108
+ /**
109
+ * Global motion multiplier applied to subsequent `shake()` calls. Scales
110
+ * both amplitude and duration. Default 1 (no-op). 0 fully suppresses
111
+ * shake. Intended to be driven by `@flare-engine/a11y` reduced-motion so
112
+ * callers don't have to guard every shake site.
113
+ *
114
+ * Only affects future `shake()` calls — does not retroactively modify an
115
+ * in-flight shake.
116
+ *
117
+ * Clamped to `>= 0`.
118
+ */
119
+ setMotionScale(scale) {
120
+ this._motionScale = scale < 0 ? 0 : scale;
121
+ }
122
+ /** Current motion scale (default 1). */
123
+ get motionScale() {
124
+ return this._motionScale;
125
+ }
126
+ /** Start camera shake. */
127
+ shake(intensity, duration) {
128
+ const k = this._motionScale;
129
+ if (k <= 0) {
130
+ this._shake = null;
131
+ return;
132
+ }
133
+ this._shake = {
134
+ intensity: intensity * k,
135
+ duration: duration * k,
136
+ elapsed: 0,
137
+ offsetX: 0,
138
+ offsetY: 0
139
+ };
140
+ this._shakeSeed++;
141
+ }
142
+ /**
143
+ * Start a Perlin-noise-based camera shake. Overwrites any in-flight
144
+ * shakePerlin (same semantics as shake()). Respects motionScale.
145
+ */
146
+ shakePerlin(intensity, duration) {
147
+ const k = this._motionScale;
148
+ if (k <= 0) {
149
+ this._shakePerlin = null;
150
+ return;
151
+ }
152
+ this._shakePerlin = {
153
+ intensity: intensity * k,
154
+ duration: duration * k,
155
+ elapsed: 0,
156
+ offsetX: 0,
157
+ offsetY: 0
158
+ };
159
+ this._shakePerlinSeed++;
160
+ }
161
+ /**
162
+ * Update camera. Call once per frame.
163
+ * @param dt Delta time in seconds.
164
+ */
165
+ update(dt) {
166
+ if (this._follow) {
167
+ const { target, options } = this._follow;
168
+ const smoothing = options.smoothing ?? .1;
169
+ const offsetX = options.offset?.x ?? 0;
170
+ const offsetY = options.offset?.y ?? 0;
171
+ const goalX = target.x + offsetX;
172
+ const goalY = target.y + offsetY;
173
+ if (options.deadZone) {
174
+ const dz = options.deadZone;
175
+ const relX = target.x - this.x;
176
+ const relY = target.y - this.y;
177
+ if (relX < dz.x) this.x += (relX + offsetX - dz.x) * (1 - smoothing ** dt);
178
+ else if (relX > dz.x + dz.width) this.x += (relX + offsetX - dz.x - dz.width) * (1 - smoothing ** dt);
179
+ if (relY < dz.y) this.y += (relY + offsetY - dz.y) * (1 - smoothing ** dt);
180
+ else if (relY > dz.y + dz.height) this.y += (relY + offsetY - dz.y - dz.height) * (1 - smoothing ** dt);
181
+ } else {
182
+ const factor = 1 - smoothing ** dt;
183
+ this.x += (goalX - this.x) * factor;
184
+ this.y += (goalY - this.y) * factor;
185
+ }
186
+ }
187
+ if (this.bounds) this._clampToBounds();
188
+ if (this._shake) {
189
+ this._shake.elapsed += dt;
190
+ if (this._shake.elapsed >= this._shake.duration) this._shake = null;
191
+ else {
192
+ const decay = 1 - this._shake.elapsed / this._shake.duration;
193
+ const seed = this._shakeSeed + this._shake.elapsed * 100;
194
+ this._shake.offsetX = (pseudoRandom(seed) * 2 - 1) * this._shake.intensity * decay;
195
+ this._shake.offsetY = (pseudoRandom(seed + 1) * 2 - 1) * this._shake.intensity * decay;
196
+ }
197
+ }
198
+ if (this._shakePerlin) {
199
+ this._shakePerlin.elapsed += dt;
200
+ if (this._shakePerlin.elapsed >= this._shakePerlin.duration) this._shakePerlin = null;
201
+ else {
202
+ const decay = 1 - this._shakePerlin.elapsed / this._shakePerlin.duration;
203
+ const t = this._shakePerlin.elapsed * 4.7 + this._shakePerlinSeed * 17.3;
204
+ this._shakePerlin.offsetX = fbmNoise2d(t, 0) * this._shakePerlin.intensity * decay;
205
+ this._shakePerlin.offsetY = fbmNoise2d(0, t + 5.4) * this._shakePerlin.intensity * decay;
206
+ }
207
+ }
208
+ }
209
+ /** Get the effective camera X (including shake + shakePerlin offsets). */
210
+ get effectiveX() {
211
+ return this.x + (this._shake?.offsetX ?? 0) + (this._shakePerlin?.offsetX ?? 0);
212
+ }
213
+ /** Get the effective camera Y (including shake + shakePerlin offsets). */
214
+ get effectiveY() {
215
+ return this.y + (this._shake?.offsetY ?? 0) + (this._shakePerlin?.offsetY ?? 0);
216
+ }
217
+ /** Convert world coordinates to screen coordinates. */
218
+ worldToScreen(worldX, worldY) {
219
+ return this.worldToScreenInto(worldX, worldY, {
220
+ x: 0,
221
+ y: 0
222
+ });
223
+ }
224
+ /** Zero-alloc variant — writes screen coordinates into `out`. */
225
+ worldToScreenInto(worldX, worldY, out) {
226
+ const cos = Math.cos(-this.rotation);
227
+ const sin = Math.sin(-this.rotation);
228
+ const dx = (worldX - this.effectiveX) * this.zoom;
229
+ const dy = (worldY - this.effectiveY) * this.zoom;
230
+ out.x = cos * dx - sin * dy + this.viewportWidth / 2;
231
+ out.y = sin * dx + cos * dy + this.viewportHeight / 2;
232
+ return out;
233
+ }
234
+ /** Convert screen coordinates to world coordinates. */
235
+ screenToWorld(screenX, screenY) {
236
+ return this.screenToWorldInto(screenX, screenY, {
237
+ x: 0,
238
+ y: 0
239
+ });
240
+ }
241
+ /** Zero-alloc variant — writes world coordinates into `out`. */
242
+ screenToWorldInto(screenX, screenY, out) {
243
+ const cx = screenX - this.viewportWidth / 2;
244
+ const cy = screenY - this.viewportHeight / 2;
245
+ const cos = Math.cos(this.rotation);
246
+ const sin = Math.sin(this.rotation);
247
+ const rx = cos * cx - sin * cy;
248
+ const ry = sin * cx + cos * cy;
249
+ out.x = rx / this.zoom + this.effectiveX;
250
+ out.y = ry / this.zoom + this.effectiveY;
251
+ return out;
252
+ }
253
+ /** Get the visible world-space bounding rect (allocates a fresh Rect). */
254
+ getBounds() {
255
+ return this.getBoundsInto({
256
+ x: 0,
257
+ y: 0,
258
+ width: 0,
259
+ height: 0
260
+ });
261
+ }
262
+ /** Zero-alloc variant — writes the visible bounds into `out`. */
263
+ getBoundsInto(out) {
264
+ const halfW = this.viewportWidth / (2 * this.zoom);
265
+ const halfH = this.viewportHeight / (2 * this.zoom);
266
+ out.x = this.effectiveX - halfW;
267
+ out.y = this.effectiveY - halfH;
268
+ out.width = halfW * 2;
269
+ out.height = halfH * 2;
270
+ return out;
271
+ }
272
+ _clampToBounds() {
273
+ const b = this.bounds;
274
+ const halfW = this.viewportWidth / (2 * this.zoom);
275
+ const halfH = this.viewportHeight / (2 * this.zoom);
276
+ const minX = b.x + halfW;
277
+ const maxX = b.x + b.width - halfW;
278
+ const minY = b.y + halfH;
279
+ const maxY = b.y + b.height - halfH;
280
+ if (minX <= maxX) this.x = Math.max(minX, Math.min(maxX, this.x));
281
+ else this.x = b.x + b.width / 2;
282
+ if (minY <= maxY) this.y = Math.max(minY, Math.min(maxY, this.y));
283
+ else this.y = b.y + b.height / 2;
284
+ }
367
285
  };
368
-
369
- // src/sprite-batch.ts
370
- var SpriteBatch = class {
371
- _commands = [];
372
- _pool = [];
373
- _batches = [];
374
- /** Number of queued draw commands. */
375
- get count() {
376
- return this._commands.length;
377
- }
378
- /** Queue a sprite draw from a frame.
379
- *
380
- * `anchorX` / `anchorY` define the pivot point of the sprite as a
381
- * fraction of its destination size — `(0, 0)` = top-left (default),
382
- * `(0.5, 0.5)` = center, `(1, 1)` = bottom-right. `dstX` / `dstY`
383
- * then refer to the anchor point instead of the top-left corner.
384
- *
385
- * `alpha` is a per-instance opacity multiplier in [0, 1]. Default 1. */
386
- draw(image, frame, dstX, dstY, dstW, dstH, effect, anchorX = 0, anchorY = 0, alpha = 1, rotation = 0, stretch = false) {
387
- const cmd = this._acquire();
388
- const w = dstW ?? frame.w;
389
- const h = dstH ?? frame.h;
390
- cmd.image = image;
391
- cmd.srcX = frame.x;
392
- cmd.srcY = frame.y;
393
- cmd.srcW = frame.w;
394
- cmd.srcH = frame.h;
395
- cmd.dstX = dstX - anchorX * w;
396
- cmd.dstY = dstY - anchorY * h;
397
- cmd.dstW = w;
398
- cmd.dstH = h;
399
- cmd.effect = effect;
400
- cmd.alpha = alpha;
401
- cmd.rotation = rotation;
402
- cmd.anchorX = anchorX;
403
- cmd.anchorY = anchorY;
404
- cmd.stretch = stretch;
405
- this._commands.push(cmd);
406
- }
407
- /** Queue a raw sprite draw with explicit src/dst rects. `dstX` / `dstY`
408
- * refer to the anchor point — offset by `anchorX * dstW` / `anchorY * dstH`.
409
- *
410
- * `alpha` is a per-instance opacity multiplier in [0, 1]. Default 1. */
411
- drawRaw(image, srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH, effect, anchorX = 0, anchorY = 0, alpha = 1, rotation = 0, stretch = false) {
412
- const cmd = this._acquire();
413
- cmd.image = image;
414
- cmd.srcX = srcX;
415
- cmd.srcY = srcY;
416
- cmd.srcW = srcW;
417
- cmd.srcH = srcH;
418
- cmd.dstX = dstX - anchorX * dstW;
419
- cmd.dstY = dstY - anchorY * dstH;
420
- cmd.dstW = dstW;
421
- cmd.dstH = dstH;
422
- cmd.effect = effect;
423
- cmd.alpha = alpha;
424
- cmd.rotation = rotation;
425
- cmd.anchorX = anchorX;
426
- cmd.anchorY = anchorY;
427
- cmd.stretch = stretch;
428
- this._commands.push(cmd);
429
- }
430
- /**
431
- * Flush all queued commands, grouped by (texture, effect, alpha) identity.
432
- *
433
- * @param drawAtlas Callback invoked once per batch. Receives
434
- * `(image, commands)` — all commands in a batch share the same texture,
435
- * the same effect (readable from `commands[0].effect`), and the same
436
- * alpha (readable from `commands[0].alpha`).
437
- * @param renderer Optional renderer to record stats.
438
- */
439
- flush(drawAtlas, renderer) {
440
- if (this._commands.length === 0) return;
441
- const batches = this._batches;
442
- for (const cmd of this._commands) {
443
- let batch;
444
- for (let i = 0; i < batches.length; i++) {
445
- const b = batches[i];
446
- if (b.image === cmd.image && b.effect === cmd.effect && b.alpha === cmd.alpha) {
447
- batch = b;
448
- break;
449
- }
450
- }
451
- if (!batch) {
452
- batch = { image: cmd.image, effect: cmd.effect, alpha: cmd.alpha, commands: [] };
453
- batches.push(batch);
454
- }
455
- batch.commands.push(cmd);
456
- }
457
- for (let i = 0; i < batches.length; i++) {
458
- const batch = batches[i];
459
- drawAtlas(batch.image, batch.commands);
460
- if (renderer) {
461
- renderer.recordDrawCall();
462
- renderer.recordBatch();
463
- }
464
- }
465
- for (const cmd of this._commands) {
466
- this._pool.push(cmd);
467
- }
468
- this._commands.length = 0;
469
- for (let i = 0; i < batches.length; i++) {
470
- batches[i].commands.length = 0;
471
- }
472
- batches.length = 0;
473
- }
474
- /** Clear all queued commands without drawing. */
475
- clear() {
476
- for (const cmd of this._commands) {
477
- this._pool.push(cmd);
478
- }
479
- this._commands.length = 0;
480
- }
481
- _acquire() {
482
- if (this._pool.length > 0) {
483
- return this._pool.pop();
484
- }
485
- return {
486
- image: null,
487
- srcX: 0,
488
- srcY: 0,
489
- srcW: 0,
490
- srcH: 0,
491
- dstX: 0,
492
- dstY: 0,
493
- dstW: 0,
494
- dstH: 0,
495
- effect: void 0,
496
- alpha: 1,
497
- rotation: 0,
498
- anchorX: 0,
499
- anchorY: 0,
500
- stretch: false
501
- };
502
- }
503
- };
504
-
505
- // src/sprite-effects.ts
506
- function tint(color, amount) {
507
- return { type: "tint", color, amount };
508
- }
509
- function flash(color, durationSec) {
510
- return { type: "flash", color, duration: durationSec, elapsed: 0 };
511
- }
512
- function grayscale(amount) {
513
- return { type: "grayscale", amount };
514
- }
515
- function outline(color, width) {
516
- return { type: "outline", color, width };
517
- }
518
- function custom(shader, uniforms) {
519
- return { type: "custom", shader, uniforms };
520
- }
521
- function water(color, foamColor, waveSpeed, waveAmplitude) {
522
- return { type: "water", color, foamColor, waveSpeed, waveAmplitude };
523
- }
524
- function fog(color, density = 0.5, speed = 0.3, scale = 2) {
525
- return { type: "fog", color, density, speed, scale };
526
- }
527
- function fire(opts) {
528
- return {
529
- type: "fire",
530
- color: opts.color,
531
- tipColor: opts.tipColor,
532
- speed: opts.speed ?? 1,
533
- intensity: opts.intensity ?? 0.8
534
- };
535
- }
536
- function dropShadow(opts) {
537
- return {
538
- type: "drop-shadow",
539
- color: opts.color,
540
- offsetX: opts.offsetX ?? 0,
541
- offsetY: opts.offsetY ?? 6,
542
- blur: opts.blur ?? 4
543
- };
544
- }
545
- function roofsCity(opts) {
546
- return {
547
- type: "roofs-city",
548
- skyColor: opts.skyColor,
549
- roofColor: opts.roofColor,
550
- scrollOffsetY: opts.scrollOffsetY ?? 0,
551
- density: opts.density ?? 6,
552
- resolutionW: opts.resolutionW ?? 0,
553
- resolutionH: opts.resolutionH ?? 0
554
- };
555
- }
556
- function forestCanopy(opts) {
557
- return {
558
- type: "forest-canopy",
559
- skyColor: opts.skyColor,
560
- leafColor: opts.leafColor,
561
- scrollOffsetY: opts.scrollOffsetY ?? 0,
562
- density: opts.density ?? 5,
563
- resolutionW: opts.resolutionW ?? 0,
564
- resolutionH: opts.resolutionH ?? 0
565
- };
566
- }
567
- function riverWave(opts) {
568
- return {
569
- type: "river-wave",
570
- waterColor: opts.waterColor,
571
- foamColor: opts.foamColor,
572
- scrollOffsetY: opts.scrollOffsetY ?? 0,
573
- waveScale: opts.waveScale ?? 2,
574
- resolutionW: opts.resolutionW ?? 0,
575
- resolutionH: opts.resolutionH ?? 0
576
- };
577
- }
578
- function moonCrater(opts) {
579
- return {
580
- type: "moon-crater",
581
- moonColor: opts.moonColor,
582
- craterColor: opts.craterColor,
583
- scrollOffsetY: opts.scrollOffsetY ?? 0,
584
- craterCount: opts.craterCount ?? 4,
585
- resolutionW: opts.resolutionW ?? 0,
586
- resolutionH: opts.resolutionH ?? 0
587
- };
588
- }
589
- function caveInterior(opts) {
590
- return {
591
- type: "cave-interior",
592
- caveColor: opts.caveColor,
593
- glowColor: opts.glowColor,
594
- scrollOffsetY: opts.scrollOffsetY ?? 0,
595
- stalactiteCount: opts.stalactiteCount ?? 8,
596
- resolutionW: opts.resolutionW ?? 0,
597
- resolutionH: opts.resolutionH ?? 0
598
- };
599
- }
600
- function mountainHills(opts) {
601
- return {
602
- type: "mountain-hills",
603
- skyColor: opts.skyColor,
604
- hillColor: opts.hillColor,
605
- scrollOffsetY: opts.scrollOffsetY ?? 0,
606
- ridgeScale: opts.ridgeScale ?? 1.5,
607
- resolutionW: opts.resolutionW ?? 0,
608
- resolutionH: opts.resolutionH ?? 0
609
- };
610
- }
611
- function updateEffect(effect, dt) {
612
- if (effect.type === "flash") {
613
- const elapsed = effect.elapsed + dt;
614
- if (elapsed >= effect.duration) return null;
615
- return { type: "flash", color: effect.color, duration: effect.duration, elapsed };
616
- }
617
- return effect;
286
+ //#endregion
287
+ //#region src/draw-order.ts
288
+ /**
289
+ * Sort an array of drawables by z-index (ascending = back to front).
290
+ * Uses insertion sort which is fast for nearly-sorted arrays
291
+ * (typical in game rendering where order rarely changes).
292
+ *
293
+ * Mutates the input array in place. Returns the same array.
294
+ *
295
+ * @param items The array to sort in place.
296
+ * @param length Optional upper bound — when provided, only sorts `items[0..length)`.
297
+ * Use this when passing a pooled array that may hold stale tail entries.
298
+ */
299
+ function sortByDrawOrder(items, length) {
300
+ const n = length ?? items.length;
301
+ for (let i = 1; i < n; i++) {
302
+ const item = items[i];
303
+ let j = i - 1;
304
+ while (j >= 0 && items[j].z > item.z) {
305
+ items[j + 1] = items[j];
306
+ j--;
307
+ }
308
+ items[j + 1] = item;
309
+ }
310
+ return items;
618
311
  }
619
- function flashIntensity(effect) {
620
- if (effect.duration <= 0) return 0;
621
- const t = effect.elapsed / effect.duration;
622
- return Math.max(0, 1 - t);
312
+ /**
313
+ * Compare function for use with Array.sort when insertion sort isn't desired.
314
+ * Stable relative order for equal z values (returns 0).
315
+ */
316
+ function drawOrderCompare(a, b) {
317
+ return a.z - b.z;
623
318
  }
624
-
625
- // src/shaders.ts
626
- var FIRE_SKSL = (
627
- /* sksl */
628
- `
629
- uniform shader src;
319
+ //#endregion
320
+ //#region src/shaders.ts
321
+ const FIRE_SKSL = `
630
322
  uniform float2 resolution;
631
323
  uniform float3 color;
632
324
  uniform float3 tipColor;
@@ -652,21 +344,15 @@ float fbm(float2 p) {
652
344
  }
653
345
 
654
346
  half4 main(float2 coord) {
655
- half4 srcColor = src.eval(coord);
656
347
  float2 uv = coord / max(resolution, float2(1.0));
657
348
  float2 np = float2(uv.x * 4.0, uv.y * 6.0 - time * speed * 2.0);
658
349
  float n = fbm(np);
659
350
  float flameMask = smoothstep(0.0, intensity, n * (1.0 - uv.y));
660
351
  float3 fireRgb = mix(color, tipColor, uv.y);
661
- half4 fireColor = half4(fireRgb.r, fireRgb.g, fireRgb.b, flameMask);
662
- return half4(fireColor.rgb, fireColor.a * srcColor.a);
352
+ return half4(fireRgb.r, fireRgb.g, fireRgb.b, flameMask);
663
353
  }
664
- `
665
- );
666
- var FOG_SKSL = (
667
- /* sksl */
668
- `
669
- uniform shader src;
354
+ `;
355
+ const FOG_SKSL = `
670
356
  uniform float2 resolution;
671
357
  uniform float3 color;
672
358
  uniform float density;
@@ -704,16 +390,10 @@ half4 main(float2 coord) {
704
390
  float2 uv = coord / max(resolution, float2(1.0));
705
391
  float2 drift = float2(time * speed, 0.3 * time * speed);
706
392
  float n = clamp(fbm(uv * scale + drift), 0.0, 1.0);
707
- half4 srcColor = src.eval(coord);
708
- half3 blended = mix(srcColor.rgb, half3(color.r, color.g, color.b), half(density * n));
709
- return half4(blended, srcColor.a);
393
+ return half4(color.r, color.g, color.b, clamp(density * n, 0.0, 1.0));
710
394
  }
711
- `
712
- );
713
- var WATER_SKSL = (
714
- /* sksl */
715
- `
716
- uniform shader src;
395
+ `;
396
+ const WATER_SKSL = `
717
397
  uniform float2 resolution;
718
398
  uniform float3 color;
719
399
  uniform float3 foamColor;
@@ -722,25 +402,16 @@ uniform float waveAmplitude;
722
402
  uniform float time;
723
403
 
724
404
  half4 main(float2 coord) {
725
- float2 uv = coord / resolution;
726
- float wave1 = sin(uv.x * 6.2832 * 3.0 + time * waveSpeed * 2.0) * waveAmplitude;
727
- float wave2 = sin(uv.x * 6.2832 * 5.0 - time * waveSpeed * 1.3 + 1.5) * waveAmplitude * 0.5;
728
- float totalWave = wave1 + wave2;
729
- float2 displaced = float2(uv.x, uv.y + totalWave);
730
- displaced = clamp(displaced, float2(0.0), float2(1.0));
731
- half4 c = src.eval(displaced * resolution);
405
+ float2 uv = coord / max(resolution, float2(1.0));
732
406
  float waveDeriv = cos(uv.x * 6.2832 * 3.0 + time * waveSpeed * 2.0) * waveAmplitude * 2.0
733
407
  + cos(uv.x * 6.2832 * 5.0 - time * waveSpeed * 1.3 + 1.5) * waveAmplitude;
734
408
  float foam = smoothstep(waveAmplitude * 0.6, waveAmplitude * 1.5, waveDeriv);
735
- half3 tinted = mix(c.rgb, half3(color.r, color.g, color.b), half(0.35));
409
+ half3 tinted = half3(color.r, color.g, color.b);
736
410
  half3 final = mix(tinted, half3(foamColor.r, foamColor.g, foamColor.b), half(foam));
737
- return half4(final, c.a);
411
+ return half4(final, 1.0);
738
412
  }
739
- `
740
- );
741
- var ROOFS_CITY_SKSL = (
742
- /* sksl */
743
- `
413
+ `;
414
+ const ROOFS_CITY_SKSL = `
744
415
  uniform float2 resolution;
745
416
  uniform float3 skyColor;
746
417
  uniform float3 roofColor;
@@ -767,11 +438,8 @@ half4 main(float2 coord) {
767
438
  half(buildings));
768
439
  return half4(col, 1.0);
769
440
  }
770
- `
771
- );
772
- var FOREST_CANOPY_SKSL = (
773
- /* sksl */
774
- `
441
+ `;
442
+ const FOREST_CANOPY_SKSL = `
775
443
  uniform float2 resolution;
776
444
  uniform float3 skyColor;
777
445
  uniform float3 leafColor;
@@ -799,11 +467,8 @@ half4 main(float2 coord) {
799
467
  half(canopy));
800
468
  return half4(col, 1.0);
801
469
  }
802
- `
803
- );
804
- var RIVER_WAVE_SKSL = (
805
- /* sksl */
806
- `
470
+ `;
471
+ const RIVER_WAVE_SKSL = `
807
472
  uniform float2 resolution;
808
473
  uniform float3 waterColor;
809
474
  uniform float3 foamColor;
@@ -826,11 +491,8 @@ half4 main(float2 coord) {
826
491
  half3 col = mix(base, half3(foamColor.r, foamColor.g, foamColor.b), half(crest * 0.6));
827
492
  return half4(col, 1.0);
828
493
  }
829
- `
830
- );
831
- var MOON_CRATER_SKSL = (
832
- /* sksl */
833
- `
494
+ `;
495
+ const MOON_CRATER_SKSL = `
834
496
  uniform float2 resolution;
835
497
  uniform float3 moonColor;
836
498
  uniform float3 craterColor;
@@ -862,11 +524,8 @@ half4 main(float2 coord) {
862
524
  half(c));
863
525
  return half4(col, 1.0);
864
526
  }
865
- `
866
- );
867
- var CAVE_INTERIOR_SKSL = (
868
- /* sksl */
869
- `
527
+ `;
528
+ const CAVE_INTERIOR_SKSL = `
870
529
  uniform float2 resolution;
871
530
  uniform float3 caveColor;
872
531
  uniform float3 glowColor;
@@ -896,11 +555,8 @@ half4 main(float2 coord) {
896
555
  col = mix(col, half3(caveColor.r, caveColor.g, caveColor.b) * half(0.3), half(stalac));
897
556
  return half4(col, 1.0);
898
557
  }
899
- `
900
- );
901
- var MOUNTAIN_HILLS_SKSL = (
902
- /* sksl */
903
- `
558
+ `;
559
+ const MOUNTAIN_HILLS_SKSL = `
904
560
  uniform float2 resolution;
905
561
  uniform float3 skyColor;
906
562
  uniform float3 hillColor;
@@ -934,757 +590,1195 @@ half4 main(float2 coord) {
934
590
  half(mountains));
935
591
  return half4(col, 1.0);
936
592
  }
937
- `
938
- );
939
-
940
- // src/frame-renderer.ts
941
- var BLEND_SRC_OVER = 3;
942
- var BLEND_SRC_IN = 5;
943
- var OUTLINE_OFFSETS = [
944
- [1, 0],
945
- [-1, 0],
946
- [0, 1],
947
- [0, -1],
948
- [Math.SQRT1_2, Math.SQRT1_2],
949
- [Math.SQRT1_2, -Math.SQRT1_2],
950
- [-Math.SQRT1_2, Math.SQRT1_2],
951
- [-Math.SQRT1_2, -Math.SQRT1_2]
593
+ `;
594
+ //#endregion
595
+ //#region src/sprite-effects.ts
596
+ /** Create a tint effect. */
597
+ function tint(color, amount) {
598
+ return {
599
+ type: "tint",
600
+ color,
601
+ amount
602
+ };
603
+ }
604
+ /** Create a flash effect. */
605
+ function flash(color, durationSec) {
606
+ return {
607
+ type: "flash",
608
+ color,
609
+ duration: durationSec,
610
+ elapsed: 0
611
+ };
612
+ }
613
+ /** Create a grayscale effect. */
614
+ function grayscale(amount) {
615
+ return {
616
+ type: "grayscale",
617
+ amount
618
+ };
619
+ }
620
+ /** Create an outline effect. */
621
+ function outline(color, width) {
622
+ return {
623
+ type: "outline",
624
+ color,
625
+ width
626
+ };
627
+ }
628
+ /** Create a custom shader effect. `shader` must be pre-compiled; compiling
629
+ * SkSL per frame is expensive. `uniforms` may be mutated externally between
630
+ * frames to animate parameters. */
631
+ function custom(shader, uniforms) {
632
+ return {
633
+ type: "custom",
634
+ shader,
635
+ uniforms
636
+ };
637
+ }
638
+ /** Create a water surface effect. Time is passed to the renderer each frame
639
+ * via `beginFrame(w, h, time)` — the effect itself is time-invariant. */
640
+ function water(color, foamColor, waveSpeed, waveAmplitude) {
641
+ return {
642
+ type: "water",
643
+ color,
644
+ foamColor,
645
+ waveSpeed,
646
+ waveAmplitude
647
+ };
648
+ }
649
+ /** Create a fog / volumetric haze effect. Time is passed to the renderer each
650
+ * frame via `beginFrame(w, h, time)` — the effect itself is time-invariant. */
651
+ function fog(color, density = .5, speed = .3, scale = 2) {
652
+ return {
653
+ type: "fog",
654
+ color,
655
+ density,
656
+ speed,
657
+ scale
658
+ };
659
+ }
660
+ /** Create a fire / flame effect. Time is passed to the renderer each
661
+ * frame via `beginFrame(w, h, time)` — the effect itself is time-invariant. */
662
+ function fire(opts) {
663
+ return {
664
+ type: "fire",
665
+ color: opts.color,
666
+ tipColor: opts.tipColor,
667
+ speed: opts.speed ?? 1,
668
+ intensity: opts.intensity ?? .8
669
+ };
670
+ }
671
+ /** Create a drop-shadow effect. Defaults to a soft shadow offset downward for
672
+ * grounding; `color.a` is the shadow opacity. */
673
+ function dropShadow(opts) {
674
+ return {
675
+ type: "drop-shadow",
676
+ color: opts.color,
677
+ offsetX: opts.offsetX ?? 0,
678
+ offsetY: opts.offsetY ?? 6,
679
+ blur: opts.blur ?? 4
680
+ };
681
+ }
682
+ /** Create a rooftop city skyline effect. */
683
+ function roofsCity(opts) {
684
+ return {
685
+ type: "roofs-city",
686
+ skyColor: opts.skyColor,
687
+ roofColor: opts.roofColor,
688
+ scrollOffsetY: opts.scrollOffsetY ?? 0,
689
+ density: opts.density ?? 6,
690
+ resolutionW: opts.resolutionW ?? 0,
691
+ resolutionH: opts.resolutionH ?? 0
692
+ };
693
+ }
694
+ /** Create a forest canopy effect. */
695
+ function forestCanopy(opts) {
696
+ return {
697
+ type: "forest-canopy",
698
+ skyColor: opts.skyColor,
699
+ leafColor: opts.leafColor,
700
+ scrollOffsetY: opts.scrollOffsetY ?? 0,
701
+ density: opts.density ?? 5,
702
+ resolutionW: opts.resolutionW ?? 0,
703
+ resolutionH: opts.resolutionH ?? 0
704
+ };
705
+ }
706
+ /** Create a river wave surface effect. */
707
+ function riverWave(opts) {
708
+ return {
709
+ type: "river-wave",
710
+ waterColor: opts.waterColor,
711
+ foamColor: opts.foamColor,
712
+ scrollOffsetY: opts.scrollOffsetY ?? 0,
713
+ waveScale: opts.waveScale ?? 2,
714
+ resolutionW: opts.resolutionW ?? 0,
715
+ resolutionH: opts.resolutionH ?? 0
716
+ };
717
+ }
718
+ /** Create a moon crater surface effect. */
719
+ function moonCrater(opts) {
720
+ return {
721
+ type: "moon-crater",
722
+ moonColor: opts.moonColor,
723
+ craterColor: opts.craterColor,
724
+ scrollOffsetY: opts.scrollOffsetY ?? 0,
725
+ craterCount: opts.craterCount ?? 4,
726
+ resolutionW: opts.resolutionW ?? 0,
727
+ resolutionH: opts.resolutionH ?? 0
728
+ };
729
+ }
730
+ /** Create a cave interior effect. */
731
+ function caveInterior(opts) {
732
+ return {
733
+ type: "cave-interior",
734
+ caveColor: opts.caveColor,
735
+ glowColor: opts.glowColor,
736
+ scrollOffsetY: opts.scrollOffsetY ?? 0,
737
+ stalactiteCount: opts.stalactiteCount ?? 8,
738
+ resolutionW: opts.resolutionW ?? 0,
739
+ resolutionH: opts.resolutionH ?? 0
740
+ };
741
+ }
742
+ /** Create a mountain / hill ridge effect. */
743
+ function mountainHills(opts) {
744
+ return {
745
+ type: "mountain-hills",
746
+ skyColor: opts.skyColor,
747
+ hillColor: opts.hillColor,
748
+ scrollOffsetY: opts.scrollOffsetY ?? 0,
749
+ ridgeScale: opts.ridgeScale ?? 1.5,
750
+ resolutionW: opts.resolutionW ?? 0,
751
+ resolutionH: opts.resolutionH ?? 0
752
+ };
753
+ }
754
+ /**
755
+ * Advance a time-based effect by `dt` seconds.
756
+ *
757
+ * Returns a new effect object if state changed (e.g. flash elapsed time
758
+ * advances), the same reference if the effect is time-invariant (tint,
759
+ * grayscale, outline, custom), or `null` if the effect has expired.
760
+ *
761
+ * Effects are immutable — callers must replace their reference with the
762
+ * returned value. Sharing an effect instance across entities is safe.
763
+ */
764
+ function updateEffect(effect, dt) {
765
+ if (effect.type === "flash") {
766
+ const elapsed = effect.elapsed + dt;
767
+ if (elapsed >= effect.duration) return null;
768
+ return {
769
+ type: "flash",
770
+ color: effect.color,
771
+ duration: effect.duration,
772
+ elapsed
773
+ };
774
+ }
775
+ return effect;
776
+ }
777
+ /**
778
+ * Get the current intensity of a flash effect (1 at start, 0 at end).
779
+ */
780
+ function flashIntensity(effect) {
781
+ if (effect.duration <= 0) return 0;
782
+ const t = effect.elapsed / effect.duration;
783
+ return Math.max(0, 1 - t);
784
+ }
785
+ //#endregion
786
+ //#region src/frame-renderer.ts
787
+ /** See the dev-flag note at the top of this file. */
788
+ const G = globalThis;
789
+ const BLEND_SRC_OVER = 3;
790
+ const BLEND_SRC_IN = 5;
791
+ const OUTLINE_OFFSETS = [
792
+ [1, 0],
793
+ [-1, 0],
794
+ [0, 1],
795
+ [0, -1],
796
+ [Math.SQRT1_2, Math.SQRT1_2],
797
+ [Math.SQRT1_2, -Math.SQRT1_2],
798
+ [-Math.SQRT1_2, Math.SQRT1_2],
799
+ [-Math.SQRT1_2, -Math.SQRT1_2]
952
800
  ];
801
+ let _xformUnrotatedCount = 0;
802
+ let _xformRotatedCount = 0;
803
+ /**
804
+ * Create a `FrameRenderer` backed by the given structural Skia module.
805
+ *
806
+ * Both `@flare-engine/react` (passing the real RN-Skia module) and
807
+ * `@flare-engine/render-web` (passing a CanvasKit shim) use this factory.
808
+ */
953
809
  function createFrameRenderer(Skia) {
954
- const defaultPaint = Skia.Paint();
955
- let tintPaint = null;
956
- let flashPaint = null;
957
- let grayscalePaint = null;
958
- let outlinePaint = null;
959
- let customPaint = null;
960
- let waterPaint = null;
961
- let waterRuntimeEffect = null;
962
- let fogPaint = null;
963
- let fogRuntimeEffect = null;
964
- let firePaint = null;
965
- let fireRuntimeEffect = null;
966
- let dropShadowPaint = null;
967
- let roofsCityPaint = null;
968
- let roofsCityEffect = null;
969
- let forestCanopyPaint = null;
970
- let forestCanopyEffect = null;
971
- let riverWavePaint = null;
972
- let riverWaveEffect = null;
973
- let moonCraterPaint = null;
974
- let moonCraterEffect = null;
975
- let caveInteriorPaint = null;
976
- let caveInteriorEffect = null;
977
- let mountainHillsPaint = null;
978
- let mountainHillsEffect = null;
979
- const roofsCityUniforms = new Array(10).fill(0);
980
- const forestCanopyUniforms = new Array(10).fill(0);
981
- const riverWaveUniforms = new Array(10).fill(0);
982
- const moonCraterUniforms = new Array(10).fill(0);
983
- const caveInteriorUniforms = new Array(10).fill(0);
984
- const mountainHillsUniforms = new Array(10).fill(0);
985
- const recorder = Skia.PictureRecorder();
986
- const recordingBounds = Skia.XYWHRect(0, 0, 0, 0);
987
- const srcRects = [];
988
- const xforms = [];
989
- const stretchSrcRects = [];
990
- const stretchDstRects = [];
991
- let stretchPaint = null;
992
- const clearColor = new Float32Array(4);
993
- const tintColor = new Float32Array(4);
994
- const flashColor = new Float32Array(4);
995
- const outlineColor = new Float32Array(4);
996
- const dropShadowColor = new Float32Array(4);
997
- const grayMatrix = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0];
998
- const customUniforms = [];
999
- const waterUniforms = new Array(11).fill(0);
1000
- const fogUniforms = new Array(9).fill(0);
1001
- const fireUniforms = new Array(11).fill(0);
1002
- let currentFrameTime = 0;
1003
- let current = null;
1004
- let _boundRuntime = null;
1005
- let _boundChain = null;
1006
- const _loggedOnce = /* @__PURE__ */ new Set();
1007
- function logOnce(msg) {
1008
- if (_loggedOnce.has(msg)) return;
1009
- _loggedOnce.add(msg);
1010
- console.warn(`[postfx] ${msg}`);
1011
- }
1012
- const canvas = {
1013
- save: () => current?.save() ?? 0,
1014
- restore: () => {
1015
- current?.restore();
1016
- },
1017
- translate: (x, y) => {
1018
- current?.translate(x, y);
1019
- },
1020
- scale: (sx, sy) => {
1021
- current?.scale(sx, sy);
1022
- },
1023
- rotate: (radians, cx, cy) => {
1024
- current?.rotate(radians, cx, cy);
1025
- },
1026
- clear: (argb) => {
1027
- if (current === null) return;
1028
- clearColor[0] = (argb >>> 16 & 255) / 255;
1029
- clearColor[1] = (argb >>> 8 & 255) / 255;
1030
- clearColor[2] = (argb & 255) / 255;
1031
- clearColor[3] = (argb >>> 24 & 255) / 255;
1032
- current.clear(clearColor);
1033
- }
1034
- };
1035
- function ensurePoolCapacity(n) {
1036
- while (srcRects.length < n) {
1037
- srcRects.push(Skia.XYWHRect(0, 0, 0, 0));
1038
- xforms.push(Skia.RSXform(1, 0, 0, 0));
1039
- }
1040
- }
1041
- function ensureStretchPoolCapacity(n) {
1042
- while (stretchSrcRects.length < n) {
1043
- stretchSrcRects.push(Skia.XYWHRect(0, 0, 0, 0));
1044
- stretchDstRects.push(Skia.XYWHRect(0, 0, 0, 0));
1045
- }
1046
- }
1047
- function fillSrcRects(commands, n) {
1048
- for (let i = 0; i < n; i++) {
1049
- const cmd = commands[i];
1050
- srcRects[i]?.setXYWH(cmd.srcX, cmd.srcY, cmd.srcW, cmd.srcH);
1051
- }
1052
- }
1053
- function fillXforms(commands, n, dx, dy) {
1054
- for (let i = 0; i < n; i++) {
1055
- const cmd = commands[i];
1056
- const scale = cmd.srcW === 0 ? 1 : cmd.dstW / cmd.srcW;
1057
- if (cmd.rotation === 0) {
1058
- xforms[i]?.set(scale, 0, cmd.dstX + dx, cmd.dstY + dy);
1059
- } else {
1060
- const c = Math.cos(cmd.rotation);
1061
- const s = Math.sin(cmd.rotation);
1062
- const scos = scale * c;
1063
- const ssin = scale * s;
1064
- const px = cmd.dstX + cmd.anchorX * cmd.dstW + dx;
1065
- const py = cmd.dstY + cmd.anchorY * cmd.dstH + dy;
1066
- const axSrc = cmd.anchorX * cmd.srcW;
1067
- const aySrc = cmd.anchorY * cmd.srcH;
1068
- xforms[i]?.set(
1069
- scos,
1070
- ssin,
1071
- px - scos * axSrc + ssin * aySrc,
1072
- py - ssin * axSrc - scos * aySrc
1073
- );
1074
- }
1075
- }
1076
- }
1077
- function applyTint(effect) {
1078
- if (tintPaint === null) tintPaint = Skia.Paint();
1079
- writeColor(tintColor, effect.color, effect.amount);
1080
- tintPaint.setColorFilter(Skia.ColorFilter.MakeBlend(tintColor, BLEND_SRC_OVER));
1081
- return tintPaint;
1082
- }
1083
- function applyFlash(effect) {
1084
- if (flashPaint === null) flashPaint = Skia.Paint();
1085
- writeColor(flashColor, effect.color, flashIntensity(effect));
1086
- flashPaint.setColorFilter(Skia.ColorFilter.MakeBlend(flashColor, BLEND_SRC_OVER));
1087
- return flashPaint;
1088
- }
1089
- function applyGrayscale(effect) {
1090
- if (grayscalePaint === null) grayscalePaint = Skia.Paint();
1091
- writeGrayscaleMatrix(grayMatrix, effect.amount);
1092
- grayscalePaint.setColorFilter(Skia.ColorFilter.MakeMatrix(grayMatrix));
1093
- return grayscalePaint;
1094
- }
1095
- function applyOutlinePaint(effect) {
1096
- if (outlinePaint === null) outlinePaint = Skia.Paint();
1097
- writeColor(outlineColor, effect.color, 1);
1098
- outlinePaint.setColorFilter(Skia.ColorFilter.MakeBlend(outlineColor, BLEND_SRC_IN));
1099
- return outlinePaint;
1100
- }
1101
- function applyCustom(effect) {
1102
- if (customPaint === null) customPaint = Skia.Paint();
1103
- const shader = effect.shader;
1104
- const floatCount = shader.getUniformFloatCount();
1105
- while (customUniforms.length < floatCount) customUniforms.push(0);
1106
- customUniforms.length = floatCount;
1107
- fillCustomUniforms(effect, shader, customUniforms);
1108
- customPaint.setShader(shader.makeShader(customUniforms));
1109
- return customPaint;
1110
- }
1111
- function applyWater(effect, time) {
1112
- if (Skia.RuntimeEffect === void 0) return defaultPaint;
1113
- if (waterRuntimeEffect === null) {
1114
- const compiled = Skia.RuntimeEffect.Make(WATER_SKSL);
1115
- if (compiled === null) return defaultPaint;
1116
- waterRuntimeEffect = compiled;
1117
- }
1118
- if (waterPaint === null) waterPaint = Skia.Paint();
1119
- waterUniforms[0] = 0;
1120
- waterUniforms[1] = 0;
1121
- waterUniforms[2] = effect.color.r;
1122
- waterUniforms[3] = effect.color.g;
1123
- waterUniforms[4] = effect.color.b;
1124
- waterUniforms[5] = effect.foamColor.r;
1125
- waterUniforms[6] = effect.foamColor.g;
1126
- waterUniforms[7] = effect.foamColor.b;
1127
- waterUniforms[8] = effect.waveSpeed;
1128
- waterUniforms[9] = effect.waveAmplitude;
1129
- waterUniforms[10] = time;
1130
- waterPaint.setShader(waterRuntimeEffect.makeShader(waterUniforms));
1131
- return waterPaint;
1132
- }
1133
- function applyFog(effect, time) {
1134
- if (Skia.RuntimeEffect === void 0) return defaultPaint;
1135
- if (fogRuntimeEffect === null) {
1136
- const compiled = Skia.RuntimeEffect.Make(FOG_SKSL);
1137
- if (compiled === null) return defaultPaint;
1138
- fogRuntimeEffect = compiled;
1139
- }
1140
- if (fogPaint === null) fogPaint = Skia.Paint();
1141
- fogUniforms[0] = 0;
1142
- fogUniforms[1] = 0;
1143
- fogUniforms[2] = effect.color.r;
1144
- fogUniforms[3] = effect.color.g;
1145
- fogUniforms[4] = effect.color.b;
1146
- fogUniforms[5] = effect.density;
1147
- fogUniforms[6] = effect.speed;
1148
- fogUniforms[7] = effect.scale;
1149
- fogUniforms[8] = time;
1150
- fogPaint.setShader(fogRuntimeEffect.makeShader(fogUniforms));
1151
- return fogPaint;
1152
- }
1153
- function applyFire(effect, time) {
1154
- if (Skia.RuntimeEffect === void 0) return defaultPaint;
1155
- if (fireRuntimeEffect === null) {
1156
- const compiled = Skia.RuntimeEffect.Make(FIRE_SKSL);
1157
- if (compiled === null) return defaultPaint;
1158
- fireRuntimeEffect = compiled;
1159
- }
1160
- if (firePaint === null) firePaint = Skia.Paint();
1161
- fireUniforms[0] = 0;
1162
- fireUniforms[1] = 0;
1163
- fireUniforms[2] = effect.color.r;
1164
- fireUniforms[3] = effect.color.g;
1165
- fireUniforms[4] = effect.color.b;
1166
- fireUniforms[5] = effect.tipColor.r;
1167
- fireUniforms[6] = effect.tipColor.g;
1168
- fireUniforms[7] = effect.tipColor.b;
1169
- fireUniforms[8] = effect.speed;
1170
- fireUniforms[9] = effect.intensity;
1171
- fireUniforms[10] = time;
1172
- firePaint.setShader(fireRuntimeEffect.makeShader(fireUniforms));
1173
- return firePaint;
1174
- }
1175
- function applyDropShadow(effect) {
1176
- if (Skia.ImageFilter === void 0) return defaultPaint;
1177
- if (dropShadowPaint === null) dropShadowPaint = Skia.Paint();
1178
- writeColor(dropShadowColor, effect.color, 1);
1179
- dropShadowPaint.setImageFilter(
1180
- Skia.ImageFilter.MakeDropShadow(
1181
- effect.offsetX,
1182
- effect.offsetY,
1183
- effect.blur,
1184
- effect.blur,
1185
- dropShadowColor,
1186
- null
1187
- )
1188
- );
1189
- return dropShadowPaint;
1190
- }
1191
- function applyRoofsCity(effect) {
1192
- if (Skia.RuntimeEffect === void 0) return defaultPaint;
1193
- if (roofsCityEffect === null) {
1194
- const compiled = Skia.RuntimeEffect.Make(ROOFS_CITY_SKSL);
1195
- if (compiled === null) return defaultPaint;
1196
- roofsCityEffect = compiled;
1197
- }
1198
- if (roofsCityPaint === null) roofsCityPaint = Skia.Paint();
1199
- roofsCityUniforms[0] = effect.resolutionW;
1200
- roofsCityUniforms[1] = effect.resolutionH;
1201
- roofsCityUniforms[2] = effect.skyColor.r;
1202
- roofsCityUniforms[3] = effect.skyColor.g;
1203
- roofsCityUniforms[4] = effect.skyColor.b;
1204
- roofsCityUniforms[5] = effect.roofColor.r;
1205
- roofsCityUniforms[6] = effect.roofColor.g;
1206
- roofsCityUniforms[7] = effect.roofColor.b;
1207
- roofsCityUniforms[8] = effect.scrollOffsetY;
1208
- roofsCityUniforms[9] = effect.density;
1209
- roofsCityPaint.setShader(roofsCityEffect.makeShader(roofsCityUniforms));
1210
- return roofsCityPaint;
1211
- }
1212
- function applyForestCanopy(effect) {
1213
- if (Skia.RuntimeEffect === void 0) return defaultPaint;
1214
- if (forestCanopyEffect === null) {
1215
- const compiled = Skia.RuntimeEffect.Make(FOREST_CANOPY_SKSL);
1216
- if (compiled === null) return defaultPaint;
1217
- forestCanopyEffect = compiled;
1218
- }
1219
- if (forestCanopyPaint === null) forestCanopyPaint = Skia.Paint();
1220
- forestCanopyUniforms[0] = effect.resolutionW;
1221
- forestCanopyUniforms[1] = effect.resolutionH;
1222
- forestCanopyUniforms[2] = effect.skyColor.r;
1223
- forestCanopyUniforms[3] = effect.skyColor.g;
1224
- forestCanopyUniforms[4] = effect.skyColor.b;
1225
- forestCanopyUniforms[5] = effect.leafColor.r;
1226
- forestCanopyUniforms[6] = effect.leafColor.g;
1227
- forestCanopyUniforms[7] = effect.leafColor.b;
1228
- forestCanopyUniforms[8] = effect.scrollOffsetY;
1229
- forestCanopyUniforms[9] = effect.density;
1230
- forestCanopyPaint.setShader(forestCanopyEffect.makeShader(forestCanopyUniforms));
1231
- return forestCanopyPaint;
1232
- }
1233
- function applyRiverWave(effect) {
1234
- if (Skia.RuntimeEffect === void 0) return defaultPaint;
1235
- if (riverWaveEffect === null) {
1236
- const compiled = Skia.RuntimeEffect.Make(RIVER_WAVE_SKSL);
1237
- if (compiled === null) return defaultPaint;
1238
- riverWaveEffect = compiled;
1239
- }
1240
- if (riverWavePaint === null) riverWavePaint = Skia.Paint();
1241
- riverWaveUniforms[0] = effect.resolutionW;
1242
- riverWaveUniforms[1] = effect.resolutionH;
1243
- riverWaveUniforms[2] = effect.waterColor.r;
1244
- riverWaveUniforms[3] = effect.waterColor.g;
1245
- riverWaveUniforms[4] = effect.waterColor.b;
1246
- riverWaveUniforms[5] = effect.foamColor.r;
1247
- riverWaveUniforms[6] = effect.foamColor.g;
1248
- riverWaveUniforms[7] = effect.foamColor.b;
1249
- riverWaveUniforms[8] = effect.scrollOffsetY;
1250
- riverWaveUniforms[9] = effect.waveScale;
1251
- riverWavePaint.setShader(riverWaveEffect.makeShader(riverWaveUniforms));
1252
- return riverWavePaint;
1253
- }
1254
- function applyMoonCrater(effect) {
1255
- if (Skia.RuntimeEffect === void 0) return defaultPaint;
1256
- if (moonCraterEffect === null) {
1257
- const compiled = Skia.RuntimeEffect.Make(MOON_CRATER_SKSL);
1258
- if (compiled === null) return defaultPaint;
1259
- moonCraterEffect = compiled;
1260
- }
1261
- if (moonCraterPaint === null) moonCraterPaint = Skia.Paint();
1262
- moonCraterUniforms[0] = effect.resolutionW;
1263
- moonCraterUniforms[1] = effect.resolutionH;
1264
- moonCraterUniforms[2] = effect.moonColor.r;
1265
- moonCraterUniforms[3] = effect.moonColor.g;
1266
- moonCraterUniforms[4] = effect.moonColor.b;
1267
- moonCraterUniforms[5] = effect.craterColor.r;
1268
- moonCraterUniforms[6] = effect.craterColor.g;
1269
- moonCraterUniforms[7] = effect.craterColor.b;
1270
- moonCraterUniforms[8] = effect.scrollOffsetY;
1271
- moonCraterUniforms[9] = effect.craterCount;
1272
- moonCraterPaint.setShader(moonCraterEffect.makeShader(moonCraterUniforms));
1273
- return moonCraterPaint;
1274
- }
1275
- function applyCaveInterior(effect) {
1276
- if (Skia.RuntimeEffect === void 0) return defaultPaint;
1277
- if (caveInteriorEffect === null) {
1278
- const compiled = Skia.RuntimeEffect.Make(CAVE_INTERIOR_SKSL);
1279
- if (compiled === null) return defaultPaint;
1280
- caveInteriorEffect = compiled;
1281
- }
1282
- if (caveInteriorPaint === null) caveInteriorPaint = Skia.Paint();
1283
- caveInteriorUniforms[0] = effect.resolutionW;
1284
- caveInteriorUniforms[1] = effect.resolutionH;
1285
- caveInteriorUniforms[2] = effect.caveColor.r;
1286
- caveInteriorUniforms[3] = effect.caveColor.g;
1287
- caveInteriorUniforms[4] = effect.caveColor.b;
1288
- caveInteriorUniforms[5] = effect.glowColor.r;
1289
- caveInteriorUniforms[6] = effect.glowColor.g;
1290
- caveInteriorUniforms[7] = effect.glowColor.b;
1291
- caveInteriorUniforms[8] = effect.scrollOffsetY;
1292
- caveInteriorUniforms[9] = effect.stalactiteCount;
1293
- caveInteriorPaint.setShader(caveInteriorEffect.makeShader(caveInteriorUniforms));
1294
- return caveInteriorPaint;
1295
- }
1296
- function applyMountainHills(effect) {
1297
- if (Skia.RuntimeEffect === void 0) return defaultPaint;
1298
- if (mountainHillsEffect === null) {
1299
- const compiled = Skia.RuntimeEffect.Make(MOUNTAIN_HILLS_SKSL);
1300
- if (compiled === null) return defaultPaint;
1301
- mountainHillsEffect = compiled;
1302
- }
1303
- if (mountainHillsPaint === null) mountainHillsPaint = Skia.Paint();
1304
- mountainHillsUniforms[0] = effect.resolutionW;
1305
- mountainHillsUniforms[1] = effect.resolutionH;
1306
- mountainHillsUniforms[2] = effect.skyColor.r;
1307
- mountainHillsUniforms[3] = effect.skyColor.g;
1308
- mountainHillsUniforms[4] = effect.skyColor.b;
1309
- mountainHillsUniforms[5] = effect.hillColor.r;
1310
- mountainHillsUniforms[6] = effect.hillColor.g;
1311
- mountainHillsUniforms[7] = effect.hillColor.b;
1312
- mountainHillsUniforms[8] = effect.scrollOffsetY;
1313
- mountainHillsUniforms[9] = effect.ridgeScale;
1314
- mountainHillsPaint.setShader(mountainHillsEffect.makeShader(mountainHillsUniforms));
1315
- return mountainHillsPaint;
1316
- }
1317
- function drawOutlined(cvs, image, commands, n, effect, alpha) {
1318
- const paint = applyOutlinePaint(effect);
1319
- paint.setAlphaf(alpha);
1320
- const srcs = srcRects.length === n ? srcRects : srcRects.slice(0, n);
1321
- for (let k = 0; k < OUTLINE_OFFSETS.length; k++) {
1322
- const offset = OUTLINE_OFFSETS[k];
1323
- fillXforms(commands, n, offset[0] * effect.width, offset[1] * effect.width);
1324
- const dsts = xforms.length === n ? xforms : xforms.slice(0, n);
1325
- cvs.drawAtlas(image, srcs, dsts, paint);
1326
- }
1327
- paint.setAlphaf(1);
1328
- defaultPaint.setAlphaf(alpha);
1329
- fillXforms(commands, n, 0, 0);
1330
- const mainDsts = xforms.length === n ? xforms : xforms.slice(0, n);
1331
- cvs.drawAtlas(image, srcs, mainDsts, defaultPaint);
1332
- defaultPaint.setAlphaf(1);
1333
- }
1334
- function resolveEffectPaint(effect) {
1335
- switch (effect.type) {
1336
- case "tint":
1337
- return applyTint(effect);
1338
- case "flash":
1339
- return applyFlash(effect);
1340
- case "grayscale":
1341
- return applyGrayscale(effect);
1342
- case "custom":
1343
- return applyCustom(effect);
1344
- case "water":
1345
- return applyWater(effect, currentFrameTime);
1346
- case "fog":
1347
- return applyFog(effect, currentFrameTime);
1348
- case "fire":
1349
- return applyFire(effect, currentFrameTime);
1350
- case "drop-shadow":
1351
- return applyDropShadow(effect);
1352
- case "outline":
1353
- return applyOutlinePaint(effect);
1354
- case "roofs-city":
1355
- return applyRoofsCity(effect);
1356
- case "forest-canopy":
1357
- return applyForestCanopy(effect);
1358
- case "river-wave":
1359
- return applyRiverWave(effect);
1360
- case "moon-crater":
1361
- return applyMoonCrater(effect);
1362
- case "cave-interior":
1363
- return applyCaveInterior(effect);
1364
- case "mountain-hills":
1365
- return applyMountainHills(effect);
1366
- }
1367
- }
1368
- return {
1369
- canvas,
1370
- beginFrame(viewportWidth, viewportHeight, time = 0, pixelDensity = 1) {
1371
- if (viewportWidth === 0 || viewportHeight === 0) return;
1372
- _boundRuntime = null;
1373
- _boundChain = null;
1374
- currentFrameTime = time;
1375
- const pd = pixelDensity;
1376
- recordingBounds.setXYWH(0, 0, viewportWidth * pd, viewportHeight * pd);
1377
- current = recorder.beginRecording(recordingBounds);
1378
- if (pd !== 1) current.scale(pd, pd);
1379
- },
1380
- drawAtlas(image, commands) {
1381
- if (current === null) return;
1382
- const n = commands.length;
1383
- if (n === 0) return;
1384
- const effect = commands[0]?.effect;
1385
- const alpha = commands[0]?.alpha ?? 1;
1386
- if (effect?.type === "roofs-city" || effect?.type === "forest-canopy" || effect?.type === "river-wave" || effect?.type === "moon-crater" || effect?.type === "cave-interior" || effect?.type === "mountain-hills") {
1387
- const paint2 = resolveEffectPaint(effect);
1388
- paint2.setAlphaf(alpha);
1389
- for (let i = 0; i < n; i++) {
1390
- const cmd = commands[i];
1391
- const rect = Skia.XYWHRect(cmd.dstX, cmd.dstY, cmd.dstW, cmd.dstH);
1392
- current.drawRect(rect, paint2);
1393
- }
1394
- paint2.setAlphaf(1);
1395
- return;
1396
- }
1397
- if (commands[0]?.stretch) {
1398
- if (stretchPaint === null) stretchPaint = Skia.Paint();
1399
- ensureStretchPoolCapacity(n);
1400
- stretchPaint.setAlphaf(alpha);
1401
- for (let i = 0; i < n; i++) {
1402
- const cmd = commands[i];
1403
- const srcR = stretchSrcRects[i];
1404
- const dstR = stretchDstRects[i];
1405
- srcR.setXYWH(cmd.srcX, cmd.srcY, cmd.srcW, cmd.srcH);
1406
- dstR.setXYWH(cmd.dstX, cmd.dstY, cmd.dstW, cmd.dstH);
1407
- current.drawImageRect(image, srcR, dstR, stretchPaint);
1408
- }
1409
- stretchPaint.setAlphaf(1);
1410
- return;
1411
- }
1412
- ensurePoolCapacity(n);
1413
- fillSrcRects(commands, n);
1414
- if (effect?.type === "outline") {
1415
- drawOutlined(current, image, commands, n, effect, alpha);
1416
- return;
1417
- }
1418
- fillXforms(commands, n, 0, 0);
1419
- const srcs = srcRects.length === n ? srcRects : srcRects.slice(0, n);
1420
- const dsts = xforms.length === n ? xforms : xforms.slice(0, n);
1421
- const paint = effect === void 0 ? defaultPaint : resolveEffectPaint(effect);
1422
- paint.setAlphaf(alpha);
1423
- current.drawAtlas(image, srcs, dsts, paint);
1424
- paint.setAlphaf(1);
1425
- },
1426
- setPostFxBinding(runtime, chain) {
1427
- _boundRuntime = runtime;
1428
- _boundChain = chain;
1429
- },
1430
- buildPostFxPasses(runtime, chain, width, height) {
1431
- const rt = runtime ?? _boundRuntime;
1432
- const ch = chain ?? _boundChain;
1433
- if (rt === null || !rt.supported) return [];
1434
- if (rt.buildPass === void 0) {
1435
- logOnce("buildPostFxPasses: runtime.supported but buildPass absent \u2014 no passes");
1436
- return [];
1437
- }
1438
- if (ch === null) return [];
1439
- const passes = rt.buildPass(ch, width, height);
1440
- if (passes.length === 0) return [];
1441
- const out = [];
1442
- for (const pass of passes) {
1443
- const shader = pass.shader;
1444
- const uniformCount = typeof shader.getUniformCount === "function" ? shader.getUniformCount() : 0;
1445
- const uniforms = {};
1446
- for (let i = 0; i < uniformCount; i++) {
1447
- const name = typeof shader.getUniformName === "function" ? shader.getUniformName(i) : String(i);
1448
- const info = typeof shader.getUniform === "function" ? shader.getUniform(i) : { columns: 1, rows: 1, slot: i };
1449
- const size = info.columns * info.rows;
1450
- if (size === 1) {
1451
- uniforms[name] = pass.uniforms[info.slot] ?? 0;
1452
- } else {
1453
- const vec = [];
1454
- for (let k = 0; k < size; k++) {
1455
- vec.push(pass.uniforms[info.slot + k] ?? 0);
1456
- }
1457
- uniforms[name] = vec;
1458
- }
1459
- }
1460
- out.push({
1461
- id: pass.id,
1462
- source: shader,
1463
- uniforms,
1464
- children: pass.children
1465
- });
1466
- }
1467
- return out;
1468
- },
1469
- endFrame() {
1470
- if (current === null) return null;
1471
- current = null;
1472
- return recorder.finishRecordingAsPicture();
1473
- }
1474
- };
810
+ const defaultPaint = Skia.Paint();
811
+ let tintPaint = null;
812
+ let flashPaint = null;
813
+ let grayscalePaint = null;
814
+ let outlinePaint = null;
815
+ let customPaint = null;
816
+ let waterPaint = null;
817
+ let waterRuntimeEffect = null;
818
+ let fogPaint = null;
819
+ let fogRuntimeEffect = null;
820
+ let firePaint = null;
821
+ let fireRuntimeEffect = null;
822
+ let dropShadowPaint = null;
823
+ let roofsCityPaint = null;
824
+ let roofsCityEffect = null;
825
+ let forestCanopyPaint = null;
826
+ let forestCanopyEffect = null;
827
+ let riverWavePaint = null;
828
+ let riverWaveEffect = null;
829
+ let moonCraterPaint = null;
830
+ let moonCraterEffect = null;
831
+ let caveInteriorPaint = null;
832
+ let caveInteriorEffect = null;
833
+ let mountainHillsPaint = null;
834
+ let mountainHillsEffect = null;
835
+ const roofsCityUniforms = new Array(10).fill(0);
836
+ const forestCanopyUniforms = new Array(10).fill(0);
837
+ const riverWaveUniforms = new Array(10).fill(0);
838
+ const moonCraterUniforms = new Array(10).fill(0);
839
+ const caveInteriorUniforms = new Array(10).fill(0);
840
+ const mountainHillsUniforms = new Array(10).fill(0);
841
+ const recorder = Skia.PictureRecorder();
842
+ const recordingBounds = Skia.XYWHRect(0, 0, 0, 0);
843
+ const srcRects = [];
844
+ const xforms = [];
845
+ const stretchSrcRects = [];
846
+ const stretchDstRects = [];
847
+ let stretchPaint = null;
848
+ const zoneBgRects = [];
849
+ let _cachedSrcSlice = null;
850
+ let _cachedSrcSliceLen = 0;
851
+ let _cachedXformSlice = null;
852
+ let _cachedXformSliceLen = 0;
853
+ const clearColor = /* @__PURE__ */ new Float32Array(4);
854
+ const tintColor = /* @__PURE__ */ new Float32Array(4);
855
+ const flashColor = /* @__PURE__ */ new Float32Array(4);
856
+ const outlineColor = /* @__PURE__ */ new Float32Array(4);
857
+ const dropShadowColor = /* @__PURE__ */ new Float32Array(4);
858
+ const grayMatrix = [
859
+ 1,
860
+ 0,
861
+ 0,
862
+ 0,
863
+ 0,
864
+ 0,
865
+ 1,
866
+ 0,
867
+ 0,
868
+ 0,
869
+ 0,
870
+ 0,
871
+ 1,
872
+ 0,
873
+ 0,
874
+ 0,
875
+ 0,
876
+ 0,
877
+ 1,
878
+ 0
879
+ ];
880
+ const customUniforms = [];
881
+ const waterUniforms = new Array(11).fill(0);
882
+ const fogUniforms = new Array(9).fill(0);
883
+ const fireUniforms = new Array(11).fill(0);
884
+ let currentFrameTime = 0;
885
+ let currentSpriteW = 0;
886
+ let currentSpriteH = 0;
887
+ let current = null;
888
+ let _boundRuntime = null;
889
+ let _boundChain = null;
890
+ const _loggedOnce = /* @__PURE__ */ new Set();
891
+ function logOnce(msg) {
892
+ if (_loggedOnce.has(msg)) return;
893
+ _loggedOnce.add(msg);
894
+ console.warn(`[postfx] ${msg}`);
895
+ }
896
+ const canvas = {
897
+ save: () => current?.save() ?? 0,
898
+ restore: () => {
899
+ current?.restore();
900
+ },
901
+ translate: (x, y) => {
902
+ current?.translate(x, y);
903
+ },
904
+ scale: (sx, sy) => {
905
+ current?.scale(sx, sy);
906
+ },
907
+ rotate: (degrees, cx, cy) => {
908
+ current?.rotate(degrees, cx, cy);
909
+ },
910
+ clear: (argb) => {
911
+ if (current === null) return;
912
+ clearColor[0] = (argb >>> 16 & 255) / 255;
913
+ clearColor[1] = (argb >>> 8 & 255) / 255;
914
+ clearColor[2] = (argb & 255) / 255;
915
+ clearColor[3] = (argb >>> 24 & 255) / 255;
916
+ current.clear(clearColor);
917
+ }
918
+ };
919
+ function ensurePoolCapacity(n) {
920
+ while (srcRects.length < n) {
921
+ srcRects.push(Skia.XYWHRect(0, 0, 0, 0));
922
+ xforms.push(Skia.RSXform(1, 0, 0, 0));
923
+ }
924
+ }
925
+ function ensureStretchPoolCapacity(n) {
926
+ while (stretchSrcRects.length < n) {
927
+ stretchSrcRects.push(Skia.XYWHRect(0, 0, 0, 0));
928
+ stretchDstRects.push(Skia.XYWHRect(0, 0, 0, 0));
929
+ }
930
+ }
931
+ function ensureZoneBgPoolCapacity(n) {
932
+ while (zoneBgRects.length < n) zoneBgRects.push(Skia.XYWHRect(0, 0, 0, 0));
933
+ }
934
+ function getCachedSrcSlice(n) {
935
+ if (_cachedSrcSlice !== null && _cachedSrcSliceLen === n) return _cachedSrcSlice;
936
+ _cachedSrcSlice = srcRects.length === n ? srcRects : srcRects.slice(0, n);
937
+ _cachedSrcSliceLen = n;
938
+ return _cachedSrcSlice;
939
+ }
940
+ function getCachedXformSlice(n) {
941
+ if (_cachedXformSlice !== null && _cachedXformSliceLen === n) return _cachedXformSlice;
942
+ _cachedXformSlice = xforms.length === n ? xforms : xforms.slice(0, n);
943
+ _cachedXformSliceLen = n;
944
+ return _cachedXformSlice;
945
+ }
946
+ function fillSrcRects(commands, n) {
947
+ for (let i = 0; i < n; i++) {
948
+ const cmd = commands[i];
949
+ srcRects[i]?.setXYWH(cmd.srcX, cmd.srcY, cmd.srcW, cmd.srcH);
950
+ }
951
+ }
952
+ function fillXforms(commands, n, dx, dy) {
953
+ for (let i = 0; i < n; i++) {
954
+ const cmd = commands[i];
955
+ const scale = cmd.srcW === 0 ? 1 : cmd.dstW / cmd.srcW;
956
+ if (cmd.rotation === 0) {
957
+ xforms[i]?.set(scale, 0, cmd.dstX + dx, cmd.dstY + dy);
958
+ if (G.__DEV__) _xformUnrotatedCount++;
959
+ } else {
960
+ const c = Math.cos(cmd.rotation);
961
+ const s = Math.sin(cmd.rotation);
962
+ const scos = scale * c;
963
+ const ssin = scale * s;
964
+ const px = cmd.dstX + cmd.anchorX * cmd.dstW + dx;
965
+ const py = cmd.dstY + cmd.anchorY * cmd.dstH + dy;
966
+ const axSrc = cmd.anchorX * cmd.srcW;
967
+ const aySrc = cmd.anchorY * cmd.srcH;
968
+ xforms[i]?.set(scos, ssin, px - scos * axSrc + ssin * aySrc, py - ssin * axSrc - scos * aySrc);
969
+ if (G.__DEV__) _xformRotatedCount++;
970
+ }
971
+ }
972
+ }
973
+ function applyTint(effect) {
974
+ if (tintPaint === null) tintPaint = Skia.Paint();
975
+ writeColor(tintColor, effect.color, effect.amount);
976
+ tintPaint.setColorFilter(Skia.ColorFilter.MakeBlend(tintColor, BLEND_SRC_OVER));
977
+ return tintPaint;
978
+ }
979
+ function applyFlash(effect) {
980
+ if (flashPaint === null) flashPaint = Skia.Paint();
981
+ writeColor(flashColor, effect.color, flashIntensity(effect));
982
+ flashPaint.setColorFilter(Skia.ColorFilter.MakeBlend(flashColor, BLEND_SRC_OVER));
983
+ return flashPaint;
984
+ }
985
+ function applyGrayscale(effect) {
986
+ if (grayscalePaint === null) grayscalePaint = Skia.Paint();
987
+ writeGrayscaleMatrix(grayMatrix, effect.amount);
988
+ grayscalePaint.setColorFilter(Skia.ColorFilter.MakeMatrix(grayMatrix));
989
+ return grayscalePaint;
990
+ }
991
+ function applyOutlinePaint(effect) {
992
+ if (outlinePaint === null) outlinePaint = Skia.Paint();
993
+ writeColor(outlineColor, effect.color, 1);
994
+ outlinePaint.setColorFilter(Skia.ColorFilter.MakeBlend(outlineColor, BLEND_SRC_IN));
995
+ return outlinePaint;
996
+ }
997
+ function applyCustom(effect) {
998
+ if (customPaint === null) customPaint = Skia.Paint();
999
+ const shader = effect.shader;
1000
+ const floatCount = shader.getUniformFloatCount();
1001
+ while (customUniforms.length < floatCount) customUniforms.push(0);
1002
+ customUniforms.length = floatCount;
1003
+ fillCustomUniforms(effect, shader, customUniforms);
1004
+ customPaint.setShader(shader.makeShader(customUniforms));
1005
+ return customPaint;
1006
+ }
1007
+ function applyWater(effect, time) {
1008
+ if (Skia.RuntimeEffect === void 0) return defaultPaint;
1009
+ if (waterRuntimeEffect === null) {
1010
+ const compiled = Skia.RuntimeEffect.Make(WATER_SKSL);
1011
+ if (compiled === null) return defaultPaint;
1012
+ waterRuntimeEffect = compiled;
1013
+ }
1014
+ if (waterPaint === null) waterPaint = Skia.Paint();
1015
+ waterUniforms[0] = currentSpriteW;
1016
+ waterUniforms[1] = currentSpriteH;
1017
+ waterUniforms[2] = effect.color.r;
1018
+ waterUniforms[3] = effect.color.g;
1019
+ waterUniforms[4] = effect.color.b;
1020
+ waterUniforms[5] = effect.foamColor.r;
1021
+ waterUniforms[6] = effect.foamColor.g;
1022
+ waterUniforms[7] = effect.foamColor.b;
1023
+ waterUniforms[8] = effect.waveSpeed;
1024
+ waterUniforms[9] = effect.waveAmplitude;
1025
+ waterUniforms[10] = time;
1026
+ waterPaint.setShader(waterRuntimeEffect.makeShader(waterUniforms));
1027
+ return waterPaint;
1028
+ }
1029
+ function applyFog(effect, time) {
1030
+ if (Skia.RuntimeEffect === void 0) return defaultPaint;
1031
+ if (fogRuntimeEffect === null) {
1032
+ const compiled = Skia.RuntimeEffect.Make(FOG_SKSL);
1033
+ if (compiled === null) return defaultPaint;
1034
+ fogRuntimeEffect = compiled;
1035
+ }
1036
+ if (fogPaint === null) fogPaint = Skia.Paint();
1037
+ fogUniforms[0] = currentSpriteW;
1038
+ fogUniforms[1] = currentSpriteH;
1039
+ fogUniforms[2] = effect.color.r;
1040
+ fogUniforms[3] = effect.color.g;
1041
+ fogUniforms[4] = effect.color.b;
1042
+ fogUniforms[5] = effect.density;
1043
+ fogUniforms[6] = effect.speed;
1044
+ fogUniforms[7] = effect.scale;
1045
+ fogUniforms[8] = time;
1046
+ fogPaint.setShader(fogRuntimeEffect.makeShader(fogUniforms));
1047
+ return fogPaint;
1048
+ }
1049
+ function applyFire(effect, time) {
1050
+ if (Skia.RuntimeEffect === void 0) return defaultPaint;
1051
+ if (fireRuntimeEffect === null) {
1052
+ const compiled = Skia.RuntimeEffect.Make(FIRE_SKSL);
1053
+ if (compiled === null) return defaultPaint;
1054
+ fireRuntimeEffect = compiled;
1055
+ }
1056
+ if (firePaint === null) firePaint = Skia.Paint();
1057
+ fireUniforms[0] = currentSpriteW;
1058
+ fireUniforms[1] = currentSpriteH;
1059
+ fireUniforms[2] = effect.color.r;
1060
+ fireUniforms[3] = effect.color.g;
1061
+ fireUniforms[4] = effect.color.b;
1062
+ fireUniforms[5] = effect.tipColor.r;
1063
+ fireUniforms[6] = effect.tipColor.g;
1064
+ fireUniforms[7] = effect.tipColor.b;
1065
+ fireUniforms[8] = effect.speed;
1066
+ fireUniforms[9] = effect.intensity;
1067
+ fireUniforms[10] = time;
1068
+ firePaint.setShader(fireRuntimeEffect.makeShader(fireUniforms));
1069
+ return firePaint;
1070
+ }
1071
+ function applyDropShadow(effect) {
1072
+ if (Skia.ImageFilter === void 0) return defaultPaint;
1073
+ if (dropShadowPaint === null) dropShadowPaint = Skia.Paint();
1074
+ writeColor(dropShadowColor, effect.color, 1);
1075
+ dropShadowPaint.setImageFilter(Skia.ImageFilter.MakeDropShadow(effect.offsetX, effect.offsetY, effect.blur, effect.blur, dropShadowColor, null));
1076
+ return dropShadowPaint;
1077
+ }
1078
+ function applyRoofsCity(effect) {
1079
+ if (Skia.RuntimeEffect === void 0) return defaultPaint;
1080
+ if (roofsCityEffect === null) {
1081
+ const compiled = Skia.RuntimeEffect.Make(ROOFS_CITY_SKSL);
1082
+ if (compiled === null) return defaultPaint;
1083
+ roofsCityEffect = compiled;
1084
+ }
1085
+ if (roofsCityPaint === null) roofsCityPaint = Skia.Paint();
1086
+ roofsCityUniforms[0] = effect.resolutionW;
1087
+ roofsCityUniforms[1] = effect.resolutionH;
1088
+ roofsCityUniforms[2] = effect.skyColor.r;
1089
+ roofsCityUniforms[3] = effect.skyColor.g;
1090
+ roofsCityUniforms[4] = effect.skyColor.b;
1091
+ roofsCityUniforms[5] = effect.roofColor.r;
1092
+ roofsCityUniforms[6] = effect.roofColor.g;
1093
+ roofsCityUniforms[7] = effect.roofColor.b;
1094
+ roofsCityUniforms[8] = effect.scrollOffsetY;
1095
+ roofsCityUniforms[9] = effect.density;
1096
+ roofsCityPaint.setShader(roofsCityEffect.makeShader(roofsCityUniforms));
1097
+ return roofsCityPaint;
1098
+ }
1099
+ function applyForestCanopy(effect) {
1100
+ if (Skia.RuntimeEffect === void 0) return defaultPaint;
1101
+ if (forestCanopyEffect === null) {
1102
+ const compiled = Skia.RuntimeEffect.Make(FOREST_CANOPY_SKSL);
1103
+ if (compiled === null) return defaultPaint;
1104
+ forestCanopyEffect = compiled;
1105
+ }
1106
+ if (forestCanopyPaint === null) forestCanopyPaint = Skia.Paint();
1107
+ forestCanopyUniforms[0] = effect.resolutionW;
1108
+ forestCanopyUniforms[1] = effect.resolutionH;
1109
+ forestCanopyUniforms[2] = effect.skyColor.r;
1110
+ forestCanopyUniforms[3] = effect.skyColor.g;
1111
+ forestCanopyUniforms[4] = effect.skyColor.b;
1112
+ forestCanopyUniforms[5] = effect.leafColor.r;
1113
+ forestCanopyUniforms[6] = effect.leafColor.g;
1114
+ forestCanopyUniforms[7] = effect.leafColor.b;
1115
+ forestCanopyUniforms[8] = effect.scrollOffsetY;
1116
+ forestCanopyUniforms[9] = effect.density;
1117
+ forestCanopyPaint.setShader(forestCanopyEffect.makeShader(forestCanopyUniforms));
1118
+ return forestCanopyPaint;
1119
+ }
1120
+ function applyRiverWave(effect) {
1121
+ if (Skia.RuntimeEffect === void 0) return defaultPaint;
1122
+ if (riverWaveEffect === null) {
1123
+ const compiled = Skia.RuntimeEffect.Make(RIVER_WAVE_SKSL);
1124
+ if (compiled === null) return defaultPaint;
1125
+ riverWaveEffect = compiled;
1126
+ }
1127
+ if (riverWavePaint === null) riverWavePaint = Skia.Paint();
1128
+ riverWaveUniforms[0] = effect.resolutionW;
1129
+ riverWaveUniforms[1] = effect.resolutionH;
1130
+ riverWaveUniforms[2] = effect.waterColor.r;
1131
+ riverWaveUniforms[3] = effect.waterColor.g;
1132
+ riverWaveUniforms[4] = effect.waterColor.b;
1133
+ riverWaveUniforms[5] = effect.foamColor.r;
1134
+ riverWaveUniforms[6] = effect.foamColor.g;
1135
+ riverWaveUniforms[7] = effect.foamColor.b;
1136
+ riverWaveUniforms[8] = effect.scrollOffsetY;
1137
+ riverWaveUniforms[9] = effect.waveScale;
1138
+ riverWavePaint.setShader(riverWaveEffect.makeShader(riverWaveUniforms));
1139
+ return riverWavePaint;
1140
+ }
1141
+ function applyMoonCrater(effect) {
1142
+ if (Skia.RuntimeEffect === void 0) return defaultPaint;
1143
+ if (moonCraterEffect === null) {
1144
+ const compiled = Skia.RuntimeEffect.Make(MOON_CRATER_SKSL);
1145
+ if (compiled === null) return defaultPaint;
1146
+ moonCraterEffect = compiled;
1147
+ }
1148
+ if (moonCraterPaint === null) moonCraterPaint = Skia.Paint();
1149
+ moonCraterUniforms[0] = effect.resolutionW;
1150
+ moonCraterUniforms[1] = effect.resolutionH;
1151
+ moonCraterUniforms[2] = effect.moonColor.r;
1152
+ moonCraterUniforms[3] = effect.moonColor.g;
1153
+ moonCraterUniforms[4] = effect.moonColor.b;
1154
+ moonCraterUniforms[5] = effect.craterColor.r;
1155
+ moonCraterUniforms[6] = effect.craterColor.g;
1156
+ moonCraterUniforms[7] = effect.craterColor.b;
1157
+ moonCraterUniforms[8] = effect.scrollOffsetY;
1158
+ moonCraterUniforms[9] = effect.craterCount;
1159
+ moonCraterPaint.setShader(moonCraterEffect.makeShader(moonCraterUniforms));
1160
+ return moonCraterPaint;
1161
+ }
1162
+ function applyCaveInterior(effect) {
1163
+ if (Skia.RuntimeEffect === void 0) return defaultPaint;
1164
+ if (caveInteriorEffect === null) {
1165
+ const compiled = Skia.RuntimeEffect.Make(CAVE_INTERIOR_SKSL);
1166
+ if (compiled === null) return defaultPaint;
1167
+ caveInteriorEffect = compiled;
1168
+ }
1169
+ if (caveInteriorPaint === null) caveInteriorPaint = Skia.Paint();
1170
+ caveInteriorUniforms[0] = effect.resolutionW;
1171
+ caveInteriorUniforms[1] = effect.resolutionH;
1172
+ caveInteriorUniforms[2] = effect.caveColor.r;
1173
+ caveInteriorUniforms[3] = effect.caveColor.g;
1174
+ caveInteriorUniforms[4] = effect.caveColor.b;
1175
+ caveInteriorUniforms[5] = effect.glowColor.r;
1176
+ caveInteriorUniforms[6] = effect.glowColor.g;
1177
+ caveInteriorUniforms[7] = effect.glowColor.b;
1178
+ caveInteriorUniforms[8] = effect.scrollOffsetY;
1179
+ caveInteriorUniforms[9] = effect.stalactiteCount;
1180
+ caveInteriorPaint.setShader(caveInteriorEffect.makeShader(caveInteriorUniforms));
1181
+ return caveInteriorPaint;
1182
+ }
1183
+ function applyMountainHills(effect) {
1184
+ if (Skia.RuntimeEffect === void 0) return defaultPaint;
1185
+ if (mountainHillsEffect === null) {
1186
+ const compiled = Skia.RuntimeEffect.Make(MOUNTAIN_HILLS_SKSL);
1187
+ if (compiled === null) return defaultPaint;
1188
+ mountainHillsEffect = compiled;
1189
+ }
1190
+ if (mountainHillsPaint === null) mountainHillsPaint = Skia.Paint();
1191
+ mountainHillsUniforms[0] = effect.resolutionW;
1192
+ mountainHillsUniforms[1] = effect.resolutionH;
1193
+ mountainHillsUniforms[2] = effect.skyColor.r;
1194
+ mountainHillsUniforms[3] = effect.skyColor.g;
1195
+ mountainHillsUniforms[4] = effect.skyColor.b;
1196
+ mountainHillsUniforms[5] = effect.hillColor.r;
1197
+ mountainHillsUniforms[6] = effect.hillColor.g;
1198
+ mountainHillsUniforms[7] = effect.hillColor.b;
1199
+ mountainHillsUniforms[8] = effect.scrollOffsetY;
1200
+ mountainHillsUniforms[9] = effect.ridgeScale;
1201
+ mountainHillsPaint.setShader(mountainHillsEffect.makeShader(mountainHillsUniforms));
1202
+ return mountainHillsPaint;
1203
+ }
1204
+ function drawOutlined(cvs, image, commands, n, effect, alpha) {
1205
+ const paint = applyOutlinePaint(effect);
1206
+ paint.setAlphaf(alpha);
1207
+ const srcs = getCachedSrcSlice(n);
1208
+ for (let k = 0; k < OUTLINE_OFFSETS.length; k++) {
1209
+ const offset = OUTLINE_OFFSETS[k];
1210
+ fillXforms(commands, n, offset[0] * effect.width, offset[1] * effect.width);
1211
+ const dsts = getCachedXformSlice(n);
1212
+ cvs.drawAtlas(image, srcs, dsts, paint);
1213
+ }
1214
+ paint.setAlphaf(1);
1215
+ defaultPaint.setAlphaf(alpha);
1216
+ fillXforms(commands, n, 0, 0);
1217
+ const mainDsts = getCachedXformSlice(n);
1218
+ cvs.drawAtlas(image, srcs, mainDsts, defaultPaint);
1219
+ defaultPaint.setAlphaf(1);
1220
+ }
1221
+ function resolveEffectPaint(effect) {
1222
+ switch (effect.type) {
1223
+ case "tint": return applyTint(effect);
1224
+ case "flash": return applyFlash(effect);
1225
+ case "grayscale": return applyGrayscale(effect);
1226
+ case "custom": return applyCustom(effect);
1227
+ case "water": return applyWater(effect, currentFrameTime);
1228
+ case "fog": return applyFog(effect, currentFrameTime);
1229
+ case "fire": return applyFire(effect, currentFrameTime);
1230
+ case "drop-shadow": return applyDropShadow(effect);
1231
+ case "outline": return applyOutlinePaint(effect);
1232
+ case "roofs-city": return applyRoofsCity(effect);
1233
+ case "forest-canopy": return applyForestCanopy(effect);
1234
+ case "river-wave": return applyRiverWave(effect);
1235
+ case "moon-crater": return applyMoonCrater(effect);
1236
+ case "cave-interior": return applyCaveInterior(effect);
1237
+ case "mountain-hills": return applyMountainHills(effect);
1238
+ }
1239
+ }
1240
+ return {
1241
+ canvas,
1242
+ beginFrame(viewportWidth, viewportHeight, time = 0, pixelDensity = 1) {
1243
+ if (viewportWidth === 0 || viewportHeight === 0) return;
1244
+ _boundRuntime = null;
1245
+ _boundChain = null;
1246
+ currentFrameTime = time;
1247
+ const pd = pixelDensity;
1248
+ recordingBounds.setXYWH(0, 0, viewportWidth * pd, viewportHeight * pd);
1249
+ current = recorder.beginRecording(recordingBounds);
1250
+ if (pd !== 1) current.scale(pd, pd);
1251
+ },
1252
+ drawAtlas(image, commands) {
1253
+ if (current === null) return;
1254
+ const n = commands.length;
1255
+ if (n === 0) return;
1256
+ const effect = commands[0]?.effect;
1257
+ const alpha = commands[0]?.alpha ?? 1;
1258
+ currentSpriteW = commands[0]?.dstW ?? 0;
1259
+ currentSpriteH = commands[0]?.dstH ?? 0;
1260
+ if (effect?.type === "roofs-city" || effect?.type === "forest-canopy" || effect?.type === "river-wave" || effect?.type === "moon-crater" || effect?.type === "cave-interior" || effect?.type === "mountain-hills") {
1261
+ const paint = resolveEffectPaint(effect);
1262
+ paint.setAlphaf(alpha);
1263
+ ensureZoneBgPoolCapacity(n);
1264
+ for (let i = 0; i < n; i++) {
1265
+ const cmd = commands[i];
1266
+ zoneBgRects[i].setXYWH(cmd.dstX, cmd.dstY, cmd.dstW, cmd.dstH);
1267
+ current.drawRect(zoneBgRects[i], paint);
1268
+ }
1269
+ paint.setAlphaf(1);
1270
+ return;
1271
+ }
1272
+ if (commands[0]?.stretch) {
1273
+ ensureStretchPoolCapacity(n);
1274
+ if (stretchPaint === null) stretchPaint = Skia.Paint();
1275
+ const sp = stretchPaint;
1276
+ const sPaint = effect === void 0 || effect.type === "outline" ? sp : resolveEffectPaint(effect);
1277
+ sPaint.setAlphaf(alpha);
1278
+ for (let i = 0; i < n; i++) {
1279
+ const cmd = commands[i];
1280
+ const srcR = stretchSrcRects[i];
1281
+ const dstR = stretchDstRects[i];
1282
+ srcR.setXYWH(cmd.srcX, cmd.srcY, cmd.srcW, cmd.srcH);
1283
+ dstR.setXYWH(cmd.dstX, cmd.dstY, cmd.dstW, cmd.dstH);
1284
+ current.drawImageRect(image, srcR, dstR, sPaint);
1285
+ }
1286
+ sPaint.setAlphaf(1);
1287
+ return;
1288
+ }
1289
+ ensurePoolCapacity(n);
1290
+ fillSrcRects(commands, n);
1291
+ if (effect?.type === "outline") {
1292
+ drawOutlined(current, image, commands, n, effect, alpha);
1293
+ return;
1294
+ }
1295
+ fillXforms(commands, n, 0, 0);
1296
+ const srcs = getCachedSrcSlice(n);
1297
+ const dsts = getCachedXformSlice(n);
1298
+ const paint = effect === void 0 ? defaultPaint : resolveEffectPaint(effect);
1299
+ paint.setAlphaf(alpha);
1300
+ current.drawAtlas(image, srcs, dsts, paint);
1301
+ paint.setAlphaf(1);
1302
+ },
1303
+ setPostFxBinding(runtime, chain) {
1304
+ _boundRuntime = runtime;
1305
+ _boundChain = chain;
1306
+ },
1307
+ hasPostFxBinding() {
1308
+ return _boundRuntime !== null && !!_boundRuntime.supported && _boundRuntime.buildPass !== void 0 && _boundChain !== null;
1309
+ },
1310
+ buildPostFxPasses(runtime, chain, width, height) {
1311
+ const rt = runtime ?? _boundRuntime;
1312
+ const ch = chain ?? _boundChain;
1313
+ if (rt === null || !rt.supported) return [];
1314
+ if (rt.buildPass === void 0) {
1315
+ logOnce("buildPostFxPasses: runtime.supported but buildPass absent — no passes");
1316
+ return [];
1317
+ }
1318
+ if (ch === null) return [];
1319
+ const passes = rt.buildPass(ch, width, height);
1320
+ if (passes.length === 0) return [];
1321
+ const out = [];
1322
+ for (const pass of passes) {
1323
+ const shader = pass.shader;
1324
+ const uniformCount = typeof shader.getUniformCount === "function" ? shader.getUniformCount() : 0;
1325
+ const uniforms = {};
1326
+ for (let i = 0; i < uniformCount; i++) {
1327
+ const name = typeof shader.getUniformName === "function" ? shader.getUniformName(i) : String(i);
1328
+ const info = typeof shader.getUniform === "function" ? shader.getUniform(i) : {
1329
+ columns: 1,
1330
+ rows: 1,
1331
+ slot: i
1332
+ };
1333
+ const size = info.columns * info.rows;
1334
+ if (size === 1) uniforms[name] = pass.uniforms[info.slot] ?? 0;
1335
+ else {
1336
+ const vec = [];
1337
+ for (let k = 0; k < size; k++) vec.push(pass.uniforms[info.slot + k] ?? 0);
1338
+ uniforms[name] = vec;
1339
+ }
1340
+ }
1341
+ out.push({
1342
+ id: pass.id,
1343
+ source: shader,
1344
+ uniforms,
1345
+ children: pass.children
1346
+ });
1347
+ }
1348
+ return out;
1349
+ },
1350
+ endFrame() {
1351
+ if (current === null) return null;
1352
+ current = null;
1353
+ return recorder.finishRecordingAsPicture();
1354
+ }
1355
+ };
1356
+ }
1357
+ /** Zero both branch counters unconditionally (no __DEV__ guard). */
1358
+ function resetXformCounters() {
1359
+ _xformUnrotatedCount = 0;
1360
+ _xformRotatedCount = 0;
1361
+ }
1362
+ /**
1363
+ * Cumulative `fillXforms` branch hit counts since the last reset. Both stay 0
1364
+ * when `__DEV__` is falsy or absent.
1365
+ *
1366
+ * These count transform WRITES, not sprites: a run drawn with an outline effect
1367
+ * passes through `fillXforms` 9 times (8 offset passes + 1 main pass), so it
1368
+ * contributes 9 hits per command — the transform work the renderer actually
1369
+ * performs. The counters are process-global: every `createFrameRenderer`
1370
+ * instance increments the same pair, so readings sum across live renderers.
1371
+ *
1372
+ * Allocates one object per call — never call it from the render loop.
1373
+ */
1374
+ function getXformCounters() {
1375
+ return {
1376
+ rotated: _xformRotatedCount,
1377
+ unrotated: _xformUnrotatedCount
1378
+ };
1475
1379
  }
1476
1380
  function writeColor(out, color, alphaMul) {
1477
- out[0] = color.r;
1478
- out[1] = color.g;
1479
- out[2] = color.b;
1480
- out[3] = color.a * alphaMul;
1381
+ out[0] = color.r;
1382
+ out[1] = color.g;
1383
+ out[2] = color.b;
1384
+ out[3] = color.a * alphaMul;
1481
1385
  }
1482
1386
  function writeGrayscaleMatrix(out, amount) {
1483
- const inv = 1 - amount;
1484
- const lr = 0.299 * amount;
1485
- const lg = 0.587 * amount;
1486
- const lb = 0.114 * amount;
1487
- out[0] = inv + lr;
1488
- out[1] = lg;
1489
- out[2] = lb;
1490
- out[3] = 0;
1491
- out[4] = 0;
1492
- out[5] = lr;
1493
- out[6] = inv + lg;
1494
- out[7] = lb;
1495
- out[8] = 0;
1496
- out[9] = 0;
1497
- out[10] = lr;
1498
- out[11] = lg;
1499
- out[12] = inv + lb;
1500
- out[13] = 0;
1501
- out[14] = 0;
1502
- out[15] = 0;
1503
- out[16] = 0;
1504
- out[17] = 0;
1505
- out[18] = 1;
1506
- out[19] = 0;
1387
+ const inv = 1 - amount;
1388
+ const lr = .299 * amount;
1389
+ const lg = .587 * amount;
1390
+ const lb = .114 * amount;
1391
+ out[0] = inv + lr;
1392
+ out[1] = lg;
1393
+ out[2] = lb;
1394
+ out[3] = 0;
1395
+ out[4] = 0;
1396
+ out[5] = lr;
1397
+ out[6] = inv + lg;
1398
+ out[7] = lb;
1399
+ out[8] = 0;
1400
+ out[9] = 0;
1401
+ out[10] = lr;
1402
+ out[11] = lg;
1403
+ out[12] = inv + lb;
1404
+ out[13] = 0;
1405
+ out[14] = 0;
1406
+ out[15] = 0;
1407
+ out[16] = 0;
1408
+ out[17] = 0;
1409
+ out[18] = 1;
1410
+ out[19] = 0;
1507
1411
  }
1508
1412
  function fillCustomUniforms(effect, shader, out) {
1509
- const count = shader.getUniformCount();
1510
- for (let i = 0; i < out.length; i++) out[i] = 0;
1511
- for (let i = 0; i < count; i++) {
1512
- const info = shader.getUniform(i);
1513
- const name = shader.getUniformName(i);
1514
- const value = effect.uniforms[name];
1515
- const size = info.columns * info.rows;
1516
- if (value === void 0) continue;
1517
- if (typeof value === "number") {
1518
- out[info.slot] = value;
1519
- continue;
1520
- }
1521
- for (let k = 0; k < size; k++) {
1522
- out[info.slot + k] = value[k] ?? 0;
1523
- }
1524
- }
1413
+ const count = shader.getUniformCount();
1414
+ for (let i = 0; i < out.length; i++) out[i] = 0;
1415
+ for (let i = 0; i < count; i++) {
1416
+ const info = shader.getUniform(i);
1417
+ const name = shader.getUniformName(i);
1418
+ const value = effect.uniforms[name];
1419
+ const size = info.columns * info.rows;
1420
+ if (value === void 0) continue;
1421
+ if (typeof value === "number") {
1422
+ out[info.slot] = value;
1423
+ continue;
1424
+ }
1425
+ for (let k = 0; k < size; k++) out[info.slot + k] = value[k] ?? 0;
1426
+ }
1525
1427
  }
1526
-
1527
- // src/sprite-font.ts
1428
+ //#endregion
1429
+ //#region src/render-system.ts
1430
+ /**
1431
+ * Create a render system function.
1432
+ *
1433
+ * This is not a defineSystem() wrapper because it needs extra parameters
1434
+ * (SpriteBatch) beyond what the ECS system interface provides. Instead,
1435
+ * it returns a function to call in your game loop.
1436
+ *
1437
+ * The returned function only **queues** sprite draws into `batch` — it
1438
+ * does not call `batch.flush()`. Flushing is owned by the frame owner
1439
+ * (e.g. `<GameView>`) so that all draw sources (ECS, particles, custom)
1440
+ * can contribute to a single flush at the render phase.
1441
+ *
1442
+ * @param world The ECS world.
1443
+ * @param query Query that matches entities with Position + Sprite.
1444
+ * @param positionDef ComponentDef for Position.
1445
+ * @param spriteDef ComponentDef for Sprite.
1446
+ * @param batch SpriteBatch instance to queue draws into.
1447
+ */
1448
+ function createRenderSystem(world, query, positionDef, spriteDef, batch) {
1449
+ const entries = [];
1450
+ return () => {
1451
+ let i = 0;
1452
+ for (const entityId of query) {
1453
+ const sprite = world.get(entityId, spriteDef);
1454
+ if (!sprite) continue;
1455
+ if (i >= entries.length) entries.push({
1456
+ entityId,
1457
+ z: 0
1458
+ });
1459
+ const entry = entries[i];
1460
+ entry.entityId = entityId;
1461
+ entry.z = sprite.z;
1462
+ i++;
1463
+ }
1464
+ const count = i;
1465
+ if (entries.length > count * 2 && entries.length > 64) entries.length = count;
1466
+ sortByDrawOrder(entries, count);
1467
+ for (let j = 0; j < count; j++) {
1468
+ const entry = entries[j];
1469
+ const pos = world.get(entry.entityId, positionDef);
1470
+ const sprite = world.get(entry.entityId, spriteDef);
1471
+ if (!pos || !sprite) continue;
1472
+ const anchor = sprite.anchor;
1473
+ batch.draw(sprite.image, sprite.frame, pos.x, pos.y, sprite.width, sprite.height, sprite.effect, anchor ? anchor.x : 0, anchor ? anchor.y : 0, sprite.alpha ?? 1, sprite.rotation ?? 0, sprite.stretch ?? false);
1474
+ }
1475
+ };
1476
+ }
1477
+ /** Canonical Sprite component — queried by the render system alongside `Position`. */
1478
+ const Sprite = defineComponent("Sprite", () => ({
1479
+ image: null,
1480
+ frame: {
1481
+ name: "",
1482
+ x: 0,
1483
+ y: 0,
1484
+ w: 0,
1485
+ h: 0
1486
+ },
1487
+ z: 0
1488
+ }));
1489
+ //#endregion
1490
+ //#region src/renderer.ts
1491
+ /**
1492
+ * Renderer — manages the Skia Canvas lifecycle for a frame.
1493
+ * `begin()` to start, draw your content, `end()` to finish.
1494
+ */
1495
+ var Renderer = class {
1496
+ /** Current frame stats. */
1497
+ stats = {
1498
+ drawCalls: 0,
1499
+ batchCount: 0
1500
+ };
1501
+ _canvas = null;
1502
+ _inFrame = false;
1503
+ /** Set the canvas to render to. Called once when canvas is available. */
1504
+ setCanvas(canvas) {
1505
+ this._canvas = canvas;
1506
+ }
1507
+ /** Get the current canvas. Throws if not in a frame. */
1508
+ get canvas() {
1509
+ if (!this._canvas) throw new Error("Renderer: no canvas set");
1510
+ return this._canvas;
1511
+ }
1512
+ /** Whether we are currently between begin() and end(). */
1513
+ get inFrame() {
1514
+ return this._inFrame;
1515
+ }
1516
+ /**
1517
+ * Begin a new frame. Clears the canvas and resets stats.
1518
+ * @param clearColor 32-bit ARGB color to clear with (default black = 0xFF000000).
1519
+ */
1520
+ begin(clearColor = 4278190080) {
1521
+ if (this._inFrame) throw new Error("Renderer: begin() called while already in frame");
1522
+ this._inFrame = true;
1523
+ this.stats.drawCalls = 0;
1524
+ this.stats.batchCount = 0;
1525
+ this.canvas.save();
1526
+ this.canvas.clear(clearColor);
1527
+ }
1528
+ /**
1529
+ * End the current frame. Restores canvas state.
1530
+ */
1531
+ end() {
1532
+ if (!this._inFrame) throw new Error("Renderer: end() called without begin()");
1533
+ this.canvas.restore();
1534
+ this._inFrame = false;
1535
+ }
1536
+ /**
1537
+ * Apply camera transform to the canvas.
1538
+ * Call after begin() and before drawing.
1539
+ */
1540
+ applyCamera(cameraX, cameraY, zoom, rotation, viewportWidth, viewportHeight) {
1541
+ const c = this.canvas;
1542
+ c.translate(viewportWidth / 2, viewportHeight / 2);
1543
+ c.scale(zoom, zoom);
1544
+ if (rotation !== 0) c.rotate(-rotation * 180 / Math.PI, 0, 0);
1545
+ c.translate(-cameraX, -cameraY);
1546
+ }
1547
+ /** Increment draw call counter. Invoked in tandem with recordBatch, once per
1548
+ * flush run in SpriteBatch.flush. */
1549
+ recordDrawCall() {
1550
+ this.stats.drawCalls++;
1551
+ }
1552
+ /** Increment batch counter. Invoked in tandem with recordDrawCall, once per
1553
+ * flush run in SpriteBatch.flush. */
1554
+ recordBatch() {
1555
+ this.stats.batchCount++;
1556
+ }
1557
+ };
1558
+ //#endregion
1559
+ //#region src/sprite-batch.ts
1560
+ /**
1561
+ * SpriteBatch — collects sprite draw commands and groups them by texture
1562
+ * (and optionally by effect) for efficient batched rendering.
1563
+ *
1564
+ * Usage:
1565
+ * 1. Call `draw()` for each sprite, optionally passing an effect
1566
+ * 2. Call `flush()` with an atlas draw callback to execute all batched draws
1567
+ * 3. Batching groups consecutive commands by `(image, effect, alpha, stretch)`
1568
+ * identity, preserving global insertion (z-sorted) order. Sharing an effect
1569
+ * reference and the same alpha across consecutive entities maximizes batch
1570
+ * size while keeping sprites in correct draw order.
1571
+ *
1572
+ * The actual Skia Atlas draw call is delegated to a callback so this module
1573
+ * remains testable without Skia.
1574
+ */
1575
+ var SpriteBatch = class {
1576
+ _commands = [];
1577
+ _pool = [];
1578
+ _batches = [];
1579
+ _allocCount = 0;
1580
+ /** Number of queued draw commands. */
1581
+ get count() {
1582
+ return this._commands.length;
1583
+ }
1584
+ /** Cumulative number of SpriteDrawCommand objects allocated because the internal
1585
+ * command pool was empty. Steady state should be 0 after warm-up. */
1586
+ get allocCount() {
1587
+ return this._allocCount;
1588
+ }
1589
+ /** Queue a sprite draw from a frame.
1590
+ *
1591
+ * `anchorX` / `anchorY` define the pivot point of the sprite as a
1592
+ * fraction of its destination size — `(0, 0)` = top-left (default),
1593
+ * `(0.5, 0.5)` = center, `(1, 1)` = bottom-right. `dstX` / `dstY`
1594
+ * then refer to the anchor point instead of the top-left corner.
1595
+ *
1596
+ * `alpha` is a per-instance opacity multiplier in [0, 1]. Default 1. */
1597
+ draw(image, frame, dstX, dstY, dstW, dstH, effect, anchorX = 0, anchorY = 0, alpha = 1, rotation = 0, stretch = false) {
1598
+ const cmd = this._acquire();
1599
+ const w = dstW ?? frame.w;
1600
+ const h = dstH ?? frame.h;
1601
+ cmd.image = image;
1602
+ cmd.srcX = frame.x;
1603
+ cmd.srcY = frame.y;
1604
+ cmd.srcW = frame.w;
1605
+ cmd.srcH = frame.h;
1606
+ cmd.dstX = dstX - anchorX * w;
1607
+ cmd.dstY = dstY - anchorY * h;
1608
+ cmd.dstW = w;
1609
+ cmd.dstH = h;
1610
+ cmd.effect = effect;
1611
+ cmd.alpha = alpha;
1612
+ cmd.rotation = rotation;
1613
+ cmd.anchorX = anchorX;
1614
+ cmd.anchorY = anchorY;
1615
+ cmd.stretch = stretch;
1616
+ this._commands.push(cmd);
1617
+ }
1618
+ /** Queue a raw sprite draw with explicit src/dst rects. `dstX` / `dstY`
1619
+ * refer to the anchor point — offset by `anchorX * dstW` / `anchorY * dstH`.
1620
+ *
1621
+ * `alpha` is a per-instance opacity multiplier in [0, 1]. Default 1. */
1622
+ drawRaw(image, srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH, effect, anchorX = 0, anchorY = 0, alpha = 1, rotation = 0, stretch = false) {
1623
+ const cmd = this._acquire();
1624
+ cmd.image = image;
1625
+ cmd.srcX = srcX;
1626
+ cmd.srcY = srcY;
1627
+ cmd.srcW = srcW;
1628
+ cmd.srcH = srcH;
1629
+ cmd.dstX = dstX - anchorX * dstW;
1630
+ cmd.dstY = dstY - anchorY * dstH;
1631
+ cmd.dstW = dstW;
1632
+ cmd.dstH = dstH;
1633
+ cmd.effect = effect;
1634
+ cmd.alpha = alpha;
1635
+ cmd.rotation = rotation;
1636
+ cmd.anchorX = anchorX;
1637
+ cmd.anchorY = anchorY;
1638
+ cmd.stretch = stretch;
1639
+ this._commands.push(cmd);
1640
+ }
1641
+ /**
1642
+ * Flush all queued commands in insertion order, grouping maximal consecutive
1643
+ * runs that share the same `(image, effect, alpha, stretch)` key into a
1644
+ * single `drawAtlas` call. This preserves global z-order (the caller is
1645
+ * responsible for queuing in z-sorted order) while still batching runs of
1646
+ * same-key sprites for efficiency.
1647
+ *
1648
+ * @param drawAtlas Callback invoked once per run. Receives
1649
+ * `(image, commands)` — all commands in a run share the same key and are
1650
+ * contiguous in the original insertion order.
1651
+ * @param renderer Optional renderer to record stats.
1652
+ */
1653
+ flush(drawAtlas, renderer) {
1654
+ if (this._commands.length === 0) return;
1655
+ let run = this._batches[0];
1656
+ if (run === void 0) {
1657
+ run = {
1658
+ image: null,
1659
+ effect: void 0,
1660
+ alpha: 1,
1661
+ stretch: false,
1662
+ commands: []
1663
+ };
1664
+ this._batches[0] = run;
1665
+ }
1666
+ for (const cmd of this._commands) {
1667
+ if (run.commands.length > 0 && (cmd.image !== run.image || cmd.effect !== run.effect || cmd.alpha !== run.alpha || cmd.stretch !== run.stretch)) {
1668
+ drawAtlas(run.image, run.commands);
1669
+ if (renderer) {
1670
+ renderer.recordDrawCall();
1671
+ renderer.recordBatch();
1672
+ }
1673
+ run.commands.length = 0;
1674
+ }
1675
+ run.image = cmd.image;
1676
+ run.effect = cmd.effect;
1677
+ run.alpha = cmd.alpha;
1678
+ run.stretch = cmd.stretch;
1679
+ run.commands.push(cmd);
1680
+ }
1681
+ if (run.commands.length > 0) {
1682
+ drawAtlas(run.image, run.commands);
1683
+ if (renderer) {
1684
+ renderer.recordDrawCall();
1685
+ renderer.recordBatch();
1686
+ }
1687
+ run.commands.length = 0;
1688
+ }
1689
+ for (const cmd of this._commands) this._pool.push(cmd);
1690
+ this._commands.length = 0;
1691
+ }
1692
+ /** Clear all queued commands without drawing. */
1693
+ clear() {
1694
+ for (const cmd of this._commands) this._pool.push(cmd);
1695
+ this._commands.length = 0;
1696
+ }
1697
+ _acquire() {
1698
+ if (this._pool.length > 0) return this._pool.pop();
1699
+ this._allocCount++;
1700
+ return {
1701
+ image: null,
1702
+ srcX: 0,
1703
+ srcY: 0,
1704
+ srcW: 0,
1705
+ srcH: 0,
1706
+ dstX: 0,
1707
+ dstY: 0,
1708
+ dstW: 0,
1709
+ dstH: 0,
1710
+ effect: void 0,
1711
+ alpha: 1,
1712
+ rotation: 0,
1713
+ anchorX: 0,
1714
+ anchorY: 0,
1715
+ stretch: false
1716
+ };
1717
+ }
1718
+ };
1719
+ //#endregion
1720
+ //#region src/sprite-font.ts
1721
+ /**
1722
+ * Parse BMFont JSON format (common output from tools like Hiero, BMFont, msdf-bmfont).
1723
+ */
1528
1724
  function parseBMFontJSON(json) {
1529
- const data = json;
1530
- const glyphs = /* @__PURE__ */ new Map();
1531
- for (const c of data.chars) {
1532
- glyphs.set(c.id, {
1533
- id: c.id,
1534
- x: c.x,
1535
- y: c.y,
1536
- width: c.width,
1537
- height: c.height,
1538
- xOffset: c.xoffset,
1539
- yOffset: c.yoffset,
1540
- xAdvance: c.xadvance
1541
- });
1542
- }
1543
- const kernings = /* @__PURE__ */ new Map();
1544
- if (data.kernings) {
1545
- for (const k of data.kernings) {
1546
- kernings.set(k.first << 16 | k.second, k.amount);
1547
- }
1548
- }
1549
- return {
1550
- lineHeight: data.common.lineHeight,
1551
- base: data.common.base,
1552
- glyphs,
1553
- kernings
1554
- };
1725
+ const data = json;
1726
+ const glyphs = /* @__PURE__ */ new Map();
1727
+ for (const c of data.chars) glyphs.set(c.id, {
1728
+ id: c.id,
1729
+ x: c.x,
1730
+ y: c.y,
1731
+ width: c.width,
1732
+ height: c.height,
1733
+ xOffset: c.xoffset,
1734
+ yOffset: c.yoffset,
1735
+ xAdvance: c.xadvance
1736
+ });
1737
+ const kernings = /* @__PURE__ */ new Map();
1738
+ if (data.kernings) for (const k of data.kernings) kernings.set(k.first << 16 | k.second, k.amount);
1739
+ return {
1740
+ lineHeight: data.common.lineHeight,
1741
+ base: data.common.base,
1742
+ glyphs,
1743
+ kernings
1744
+ };
1555
1745
  }
1746
+ /** Get kerning between two characters. */
1556
1747
  function getKerning(font, first, second) {
1557
- return font.kernings.get(first << 16 | second) ?? 0;
1748
+ return font.kernings.get(first << 16 | second) ?? 0;
1558
1749
  }
1750
+ /** Measure the width of a text string in pixels. */
1559
1751
  function measureText(font, text) {
1560
- let width = 0;
1561
- let prevCode = -1;
1562
- for (let i = 0; i < text.length; i++) {
1563
- const code = text.charCodeAt(i);
1564
- const glyph = font.glyphs.get(code);
1565
- if (!glyph) continue;
1566
- if (prevCode >= 0) {
1567
- width += getKerning(font, prevCode, code);
1568
- }
1569
- width += glyph.xAdvance;
1570
- prevCode = code;
1571
- }
1572
- return width;
1752
+ let width = 0;
1753
+ let prevCode = -1;
1754
+ for (let i = 0; i < text.length; i++) {
1755
+ const code = text.charCodeAt(i);
1756
+ const glyph = font.glyphs.get(code);
1757
+ if (!glyph) continue;
1758
+ if (prevCode >= 0) width += getKerning(font, prevCode, code);
1759
+ width += glyph.xAdvance;
1760
+ prevCode = code;
1761
+ }
1762
+ return width;
1573
1763
  }
1764
+ /**
1765
+ * Draw text using a SpriteBatch.
1766
+ * Characters are batched as individual sprites from the font texture.
1767
+ */
1574
1768
  function drawText(batch, font, image, text, x, y, scale = 1) {
1575
- let cursorX = x;
1576
- let prevCode = -1;
1577
- for (let i = 0; i < text.length; i++) {
1578
- const code = text.charCodeAt(i);
1579
- const glyph = font.glyphs.get(code);
1580
- if (!glyph) continue;
1581
- if (prevCode >= 0) {
1582
- cursorX += getKerning(font, prevCode, code) * scale;
1583
- }
1584
- batch.drawRaw(
1585
- image,
1586
- glyph.x,
1587
- glyph.y,
1588
- glyph.width,
1589
- glyph.height,
1590
- cursorX + glyph.xOffset * scale,
1591
- y + glyph.yOffset * scale,
1592
- glyph.width * scale,
1593
- glyph.height * scale
1594
- );
1595
- cursorX += glyph.xAdvance * scale;
1596
- prevCode = code;
1597
- }
1769
+ let cursorX = x;
1770
+ let prevCode = -1;
1771
+ for (let i = 0; i < text.length; i++) {
1772
+ const code = text.charCodeAt(i);
1773
+ const glyph = font.glyphs.get(code);
1774
+ if (!glyph) continue;
1775
+ if (prevCode >= 0) cursorX += getKerning(font, prevCode, code) * scale;
1776
+ batch.drawRaw(image, glyph.x, glyph.y, glyph.width, glyph.height, cursorX + glyph.xOffset * scale, y + glyph.yOffset * scale, glyph.width * scale, glyph.height * scale);
1777
+ cursorX += glyph.xAdvance * scale;
1778
+ prevCode = code;
1779
+ }
1598
1780
  }
1781
+ //#endregion
1782
+ export { CAVE_INTERIOR_SKSL, Camera2D, FIRE_SKSL, FOG_SKSL, FOREST_CANOPY_SKSL, MOON_CRATER_SKSL, MOUNTAIN_HILLS_SKSL, RIVER_WAVE_SKSL, ROOFS_CITY_SKSL, Renderer, Sprite, SpriteBatch, WATER_SKSL, caveInterior, createFrameRenderer, createRenderSystem, custom, drawOrderCompare, drawText, dropShadow, fire, flash, flashIntensity, fog, forestCanopy, getKerning, getXformCounters, grayscale, measureText, moonCrater, mountainHills, outline, parseBMFontJSON, resetXformCounters, riverWave, roofsCity, sortByDrawOrder, tint, updateEffect, water };
1599
1783
 
1600
- // src/render-system.ts
1601
- import { defineComponent } from "@flare-engine/ecs";
1602
- function createRenderSystem(world, query, positionDef, spriteDef, batch) {
1603
- const entries = [];
1604
- return () => {
1605
- let i = 0;
1606
- for (const entityId of query) {
1607
- const sprite = world.get(entityId, spriteDef);
1608
- if (!sprite) continue;
1609
- if (i >= entries.length) {
1610
- entries.push({ entityId, z: 0 });
1611
- }
1612
- const entry = entries[i];
1613
- entry.entityId = entityId;
1614
- entry.z = sprite.z;
1615
- i++;
1616
- }
1617
- const count = i;
1618
- if (entries.length > count * 2 && entries.length > 64) {
1619
- entries.length = count;
1620
- }
1621
- sortByDrawOrder(entries, count);
1622
- for (let j = 0; j < count; j++) {
1623
- const entry = entries[j];
1624
- const pos = world.get(entry.entityId, positionDef);
1625
- const sprite = world.get(entry.entityId, spriteDef);
1626
- if (!pos || !sprite) continue;
1627
- const anchor = sprite.anchor;
1628
- batch.draw(
1629
- sprite.image,
1630
- sprite.frame,
1631
- pos.x,
1632
- pos.y,
1633
- sprite.width,
1634
- sprite.height,
1635
- sprite.effect,
1636
- anchor ? anchor.x : 0,
1637
- anchor ? anchor.y : 0,
1638
- sprite.alpha ?? 1,
1639
- sprite.rotation ?? 0,
1640
- sprite.stretch ?? false
1641
- );
1642
- }
1643
- };
1644
- }
1645
- var Sprite = defineComponent("Sprite", () => ({
1646
- image: null,
1647
- frame: { name: "", x: 0, y: 0, w: 0, h: 0 },
1648
- z: 0
1649
- }));
1650
- export {
1651
- CAVE_INTERIOR_SKSL,
1652
- Camera2D,
1653
- FIRE_SKSL,
1654
- FOG_SKSL,
1655
- FOREST_CANOPY_SKSL,
1656
- MOON_CRATER_SKSL,
1657
- MOUNTAIN_HILLS_SKSL,
1658
- RIVER_WAVE_SKSL,
1659
- ROOFS_CITY_SKSL,
1660
- Renderer,
1661
- Sprite,
1662
- SpriteBatch,
1663
- WATER_SKSL,
1664
- caveInterior,
1665
- createFrameRenderer,
1666
- createRenderSystem,
1667
- custom,
1668
- drawOrderCompare,
1669
- drawText,
1670
- dropShadow,
1671
- fire,
1672
- flash,
1673
- flashIntensity,
1674
- fog,
1675
- forestCanopy,
1676
- getKerning,
1677
- grayscale,
1678
- measureText,
1679
- moonCrater,
1680
- mountainHills,
1681
- outline,
1682
- parseBMFontJSON,
1683
- riverWave,
1684
- roofsCity,
1685
- sortByDrawOrder,
1686
- tint,
1687
- updateEffect,
1688
- water
1689
- };
1690
1784
  //# sourceMappingURL=index.mjs.map