@arcadekit/render-three 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,855 @@
1
+ import * as THREE from "three";
2
+ import { Data, Effect, Result, Schema } from "effect";
3
+ // src/camera.ts
4
+ /** Keeps the camera at a fixed offset from a target on the ground plane, looking at it. Top-down and isometric games. */
5
+ const followTarget = (camera, target, offset) => {
6
+ camera.position.set(target.x + offset.x, offset.y, target.z + offset.z);
7
+ camera.lookAt(target.x, 0, target.z);
8
+ };
9
+ /**
10
+ * Circles the camera around a point on the ground plane at a fixed height, looking at it.
11
+ * `angle` is in radians; 0 puts the camera on the point's +z side. Menu backdrops and
12
+ * showcase views.
13
+ */
14
+ const orbitAround = (camera, center, options) => {
15
+ camera.position.set(center.x + Math.sin(options.angle) * options.radius, options.height, center.z + Math.cos(options.angle) * options.radius);
16
+ camera.lookAt(center.x, 0, center.z);
17
+ };
18
+ /**
19
+ * Places a camera above a rectangle on the ground plane, looking at its center, so that all of
20
+ * it shows at the camera's current aspect ratio. Board, puzzle and arena games. Call it every
21
+ * frame (it allocates nothing), or at least after each resize.
22
+ *
23
+ * A perspective camera keeps its field of view and moves back far enough. An orthographic
24
+ * camera (`Stage` with `camera: "orthographic"`) changes its frustum to fit instead.
25
+ * With a tilt, the fit is still worked out as if looking straight down. That leaves an
26
+ * orthographic camera a little extra room. It does not hold for a perspective camera: a small
27
+ * tilt (below the tangent of half its field of view) can push the near edge of the bounds off
28
+ * the screen, so give it more margin.
29
+ */
30
+ const fitCamera = (camera, bounds, options = {}) => {
31
+ const margin = options.margin ?? 0;
32
+ const tilt = options.tilt ?? 0;
33
+ const centerX = (bounds.minX + bounds.maxX) / 2;
34
+ const centerZ = (bounds.minZ + bounds.maxZ) / 2;
35
+ const halfWidth = (bounds.maxX - bounds.minX) / 2 + margin;
36
+ const halfDepth = (bounds.maxZ - bounds.minZ) / 2 + margin;
37
+ let height;
38
+ if (isPerspective(camera)) {
39
+ const halfTan = Math.tan(camera.fov * Math.PI / 360);
40
+ height = Math.max(halfDepth / halfTan, halfWidth / (halfTan * camera.aspect));
41
+ } else {
42
+ const aspect = (camera.right - camera.left) / (camera.top - camera.bottom);
43
+ const halfHeight = Math.max(halfDepth, halfWidth / aspect);
44
+ camera.top = halfHeight;
45
+ camera.bottom = -halfHeight;
46
+ camera.left = -halfHeight * aspect;
47
+ camera.right = halfHeight * aspect;
48
+ camera.updateProjectionMatrix();
49
+ height = 4 * Math.max(halfWidth, halfDepth, 1);
50
+ }
51
+ camera.position.set(centerX, height, centerZ + height * tilt);
52
+ camera.lookAt(centerX, 0, centerZ);
53
+ };
54
+ const isPerspective = (camera) => camera.isPerspectiveCamera === true;
55
+ // src/instanced-sprites.ts
56
+ /** Frames per row of the frame table texture; each frame takes 2 texels in a column. */
57
+ const TABLE_COLUMNS = 256;
58
+ /** How far a layer's depth band reaches either side of its middle. Bands are 1 apart. */
59
+ const BAND = .45;
60
+ const VERTEX = `
61
+ // x, z, lift, layer
62
+ attribute vec4 aPlace;
63
+ // frame, flipX, flash
64
+ attribute vec3 aFrame;
65
+ // r, g, b, alpha
66
+ attribute vec4 aTint;
67
+ uniform sampler2D uFrames;
68
+ uniform vec2 uSheetSize;
69
+ uniform vec2 uAnchor;
70
+ uniform float uUnitsPerPixel;
71
+ uniform float uSnap;
72
+ uniform float uLayer;
73
+ uniform float uDepthScale;
74
+ varying vec2 vUv;
75
+ varying vec4 vTint;
76
+ varying float vFlash;
77
+
78
+ ivec2 tableCell(int frame, int row) {
79
+ return ivec2(frame % ${TABLE_COLUMNS}, (frame / ${TABLE_COLUMNS}) * 2 + row);
80
+ }
81
+
82
+ void main() {
83
+ int frame = int(aFrame.x + 0.5);
84
+ // x, y, width, height in the image
85
+ vec4 rect = texelFetch(uFrames, tableCell(frame, 0), 0);
86
+ // offset x, y in the untrimmed frame; its width, height
87
+ vec4 source = texelFetch(uFrames, tableCell(frame, 1), 0);
88
+ // uv.y is 1 at the top of the quad; pixel rows count down from the top.
89
+ vec2 corner = vec2(uv.x, 1.0 - uv.y);
90
+ vec2 pixel = source.xy + corner * rect.zw;
91
+ if (aFrame.y > 0.5) {
92
+ pixel.x = source.z - pixel.x;
93
+ }
94
+ vec2 anchor = floor(uAnchor * source.zw + 0.5);
95
+ vec2 local = (pixel - anchor) * uUnitsPerPixel;
96
+ // Up the screen is -z, so a lifted sprite moves toward -z; its depth stays at its feet.
97
+ vec2 base = vec2(aPlace.x, aPlace.y - aPlace.z);
98
+ if (uSnap > 0.5) {
99
+ base = floor(base / uUnitsPerPixel + 0.5) * uUnitsPerPixel;
100
+ }
101
+ float depth = uLayer + aPlace.w + 0.5 + clamp(aPlace.y * uDepthScale, -${BAND}, ${BAND});
102
+ vUv = (rect.xy + corner * rect.zw) / uSheetSize;
103
+ vTint = aTint;
104
+ vFlash = aFrame.z;
105
+ gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(base.x + local.x, depth, base.y + local.y, 1.0);
106
+ }`;
107
+ const FRAGMENT = `
108
+ uniform sampler2D uMap;
109
+ uniform float uAlphaTest;
110
+ uniform float uBlend;
111
+ varying vec2 vUv;
112
+ varying vec4 vTint;
113
+ varying float vFlash;
114
+
115
+ void main() {
116
+ vec4 texel = texture2D(uMap, vUv);
117
+ float alpha = texel.a * vTint.a;
118
+ if (alpha < uAlphaTest) discard;
119
+ vec3 color = mix(texel.rgb * vTint.rgb, vec3(1.0), vFlash);
120
+ gl_FragColor = vec4(color, uBlend > 0.5 ? alpha : 1.0);
121
+ #include <colorspace_fragment>
122
+ }`;
123
+ /**
124
+ * Draws up to `capacity` sprites from one sheet in a single draw call, as quads on the ground
125
+ * plane facing a straight-down camera (`PixelCamera`). Each sprite has a position, a frame,
126
+ * a horizontal flip, a tint, a flash toward white, an alpha, a lift and a layer. Within a layer
127
+ * a sprite lower on screen (larger z) draws in front.
128
+ *
129
+ * Each frame, call `set` for every sprite to draw (it resets that sprite's look), then the
130
+ * other setters for those that need them, then `commit(count)`. Nothing allocates per frame.
131
+ * Add `mesh` to the scene. `dispose` (or `disposeObject`) frees its own geometry, material and
132
+ * frame table, never the sheet's texture: the sheet's owner frees that.
133
+ */
134
+ var InstancedSprites = class {
135
+ mesh;
136
+ capacity;
137
+ sheet;
138
+ frameCount;
139
+ place;
140
+ frame;
141
+ tint;
142
+ frameTable;
143
+ constructor(sheet, options) {
144
+ this.sheet = sheet;
145
+ this.capacity = options.capacity;
146
+ this.frameCount = sheet.data.frames.length;
147
+ const transparent = options.transparent ?? false;
148
+ const quad = new THREE.PlaneGeometry(1, 1);
149
+ const geometry = new THREE.InstancedBufferGeometry();
150
+ geometry.index = quad.index;
151
+ geometry.setAttribute("position", quad.getAttribute("position"));
152
+ geometry.setAttribute("uv", quad.getAttribute("uv"));
153
+ quad.dispose();
154
+ this.place = dynamicAttribute(geometry, "aPlace", this.capacity, 4);
155
+ this.frame = dynamicAttribute(geometry, "aFrame", this.capacity, 3);
156
+ this.tint = dynamicAttribute(geometry, "aTint", this.capacity, 4);
157
+ geometry.instanceCount = 0;
158
+ this.frameTable = frameTable(sheet.data);
159
+ const anchor = options.anchor ?? {
160
+ x: .5,
161
+ y: .5
162
+ };
163
+ const material = new THREE.ShaderMaterial({
164
+ vertexShader: VERTEX,
165
+ fragmentShader: FRAGMENT,
166
+ uniforms: {
167
+ uMap: { value: sheet.texture },
168
+ uFrames: { value: this.frameTable },
169
+ uSheetSize: { value: new THREE.Vector2(sheet.data.width, sheet.data.height) },
170
+ uAnchor: { value: new THREE.Vector2(anchor.x, anchor.y) },
171
+ uUnitsPerPixel: { value: options.unitsPerPixel ?? 1 / 16 },
172
+ uSnap: { value: options.snap ?? true ? 1 : 0 },
173
+ uLayer: { value: options.layer ?? 0 },
174
+ uDepthScale: { value: BAND / (options.sortRange ?? 256) },
175
+ uAlphaTest: { value: options.alphaTest ?? (transparent ? .01 : .5) },
176
+ uBlend: { value: transparent ? 1 : 0 }
177
+ },
178
+ transparent,
179
+ depthWrite: !transparent,
180
+ side: THREE.DoubleSide
181
+ });
182
+ this.mesh = new THREE.Mesh(geometry, material);
183
+ this.mesh.frustumCulled = false;
184
+ }
185
+ /**
186
+ * Places sprite `index` with its feet (its anchor) at (x, z) on the ground, showing frame
187
+ * `frame` (an index into `sheet.data.frames`, as `frameAt` gives), mirrored if `flipX`.
188
+ * Resets its tint, alpha, flash, lift and layer. A frame outside the sheet draws frame 0.
189
+ */
190
+ set(index, x, z, frame, flipX = false) {
191
+ const shown = frame >= 0 && frame < this.frameCount ? frame : 0;
192
+ write4(this.place, index, x, z, 0, 0);
193
+ write3(this.frame, index, shown, flipX ? 1 : 0, 0);
194
+ write4(this.tint, index, 1, 1, 1, 1);
195
+ }
196
+ /** Multiplies sprite `index`'s colors by `color` (white leaves them), and sets its alpha. */
197
+ setTint(index, color, alpha = 1) {
198
+ write4(this.tint, index, color.r, color.g, color.b, alpha);
199
+ }
200
+ /** Sets sprite `index`'s alpha (0 to 1) and keeps its tint. Opaque sprites hide below `alphaTest`. */
201
+ setAlpha(index, alpha) {
202
+ this.tint.array[index * 4 + 3] = alpha;
203
+ }
204
+ /** Blends sprite `index` toward white: 0 is its own colors, 1 a white silhouette. A hit flash. */
205
+ setFlash(index, amount) {
206
+ this.frame.array[index * 3 + 2] = amount;
207
+ }
208
+ /**
209
+ * Draws sprite `index` `lift` world units up the screen, as if floating: a bat. Its depth
210
+ * stays where its feet are, so it sorts with the things standing near it.
211
+ */
212
+ setLift(index, lift) {
213
+ this.place.array[index * 4 + 2] = lift;
214
+ }
215
+ /** Puts sprite `index` in another layer, added to the batch's own. See `InstancedSpritesOptions.layer`. */
216
+ setLayer(index, layer) {
217
+ this.place.array[index * 4 + 3] = layer;
218
+ }
219
+ /** Shows the first `count` sprites and uploads them. */
220
+ commit(count) {
221
+ const shown = Math.max(0, Math.min(count, this.capacity));
222
+ this.mesh.geometry.instanceCount = shown;
223
+ for (const attribute of [
224
+ this.place,
225
+ this.frame,
226
+ this.tint
227
+ ]) {
228
+ attribute.clearUpdateRanges();
229
+ attribute.addUpdateRange(0, Math.max(1, shown) * attribute.itemSize);
230
+ attribute.needsUpdate = true;
231
+ }
232
+ }
233
+ /** Removes the mesh and frees its geometry, material and frame table. Never the sheet. */
234
+ dispose() {
235
+ this.mesh.removeFromParent();
236
+ this.mesh.geometry.dispose();
237
+ this.mesh.material.dispose();
238
+ this.frameTable.dispose();
239
+ }
240
+ };
241
+ const dynamicAttribute = (geometry, name, capacity, size) => {
242
+ const attribute = new THREE.InstancedBufferAttribute(new Float32Array(Math.max(1, capacity) * size), size);
243
+ attribute.setUsage(THREE.DynamicDrawUsage);
244
+ geometry.setAttribute(name, attribute);
245
+ return attribute;
246
+ };
247
+ const write4 = (attribute, index, a, b, c, d) => {
248
+ const array = attribute.array;
249
+ const at = index * 4;
250
+ array[at] = a;
251
+ array[at + 1] = b;
252
+ array[at + 2] = c;
253
+ array[at + 3] = d;
254
+ };
255
+ const write3 = (attribute, index, a, b, c) => {
256
+ const array = attribute.array;
257
+ const at = index * 3;
258
+ array[at] = a;
259
+ array[at + 1] = b;
260
+ array[at + 2] = c;
261
+ };
262
+ /**
263
+ * The frame table the vertex shader reads: frame i at column i % TABLE_COLUMNS, rows
264
+ * 2 * floor(i / TABLE_COLUMNS) (its rect in the image) and the row after (its offset in the
265
+ * untrimmed frame, and that frame's size).
266
+ */
267
+ const frameTableData = (sheet) => {
268
+ const count = sheet.frames.length;
269
+ const width = Math.min(Math.max(1, count), TABLE_COLUMNS);
270
+ const height = 2 * Math.max(1, Math.ceil(count / TABLE_COLUMNS));
271
+ const data = new Float32Array(width * height * 4);
272
+ for (const [index, frame] of sheet.frames.entries()) {
273
+ const column = index % TABLE_COLUMNS;
274
+ const row = 2 * Math.floor(index / TABLE_COLUMNS);
275
+ data.set([
276
+ frame.x,
277
+ frame.y,
278
+ frame.width,
279
+ frame.height
280
+ ], (row * width + column) * 4);
281
+ data.set([
282
+ frame.offsetX,
283
+ frame.offsetY,
284
+ frame.sourceWidth,
285
+ frame.sourceHeight
286
+ ], ((row + 1) * width + column) * 4);
287
+ }
288
+ return {
289
+ data,
290
+ width,
291
+ height
292
+ };
293
+ };
294
+ const frameTable = (sheet) => {
295
+ const { data, width, height } = frameTableData(sheet);
296
+ const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.FloatType);
297
+ texture.needsUpdate = true;
298
+ return texture;
299
+ };
300
+ // src/mesh-instances.ts
301
+ const DEFAULT_COLOR = new THREE.Color("#ffffff");
302
+ /**
303
+ * Draws up to `capacity` copies of one mesh in a single draw call.
304
+ * Call `set` (and optionally `setColor`) for the instances that changed, then `commit(count)`
305
+ * to show the first `count` instances and upload the changes.
306
+ */
307
+ var MeshInstances = class {
308
+ mesh;
309
+ capacity;
310
+ matrix = new THREE.Matrix4();
311
+ constructor(geometry, material, capacity) {
312
+ this.capacity = capacity;
313
+ this.mesh = new THREE.InstancedMesh(geometry, material, capacity);
314
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
315
+ this.mesh.setColorAt(0, DEFAULT_COLOR);
316
+ this.mesh.count = 0;
317
+ this.mesh.frustumCulled = false;
318
+ }
319
+ /**
320
+ * Places instance `index` at (x, y, z) with a uniform `scale` and a rotation around the vertical axis.
321
+ * For a fixed tilt every instance shares (a flat ring lying on the floor), rotate the geometry once instead.
322
+ */
323
+ set(index, x, y, z, scale = 1, rotationY = 0) {
324
+ if (rotationY === 0) this.matrix.makeScale(scale, scale, scale);
325
+ else {
326
+ this.matrix.makeRotationY(rotationY);
327
+ if (scale !== 1) this.matrix.scale(SCALE.set(scale, scale, scale));
328
+ }
329
+ this.matrix.setPosition(x, y, z);
330
+ this.mesh.setMatrixAt(index, this.matrix);
331
+ }
332
+ setColor(index, color) {
333
+ this.mesh.setColorAt(index, color);
334
+ }
335
+ /** Shows the first `count` instances and uploads this frame's changes. */
336
+ commit(count) {
337
+ this.mesh.count = Math.min(count, this.capacity);
338
+ this.mesh.instanceMatrix.needsUpdate = true;
339
+ if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
340
+ }
341
+ dispose() {
342
+ this.mesh.removeFromParent();
343
+ this.mesh.geometry.dispose();
344
+ const materials = Array.isArray(this.mesh.material) ? this.mesh.material : [this.mesh.material];
345
+ for (const material of materials) material.dispose();
346
+ }
347
+ };
348
+ const SCALE = new THREE.Vector3();
349
+ // src/pixel-camera.ts
350
+ /** How high above the ground the camera sits. Anything from y = -HEIGHT to just below it is drawn. */
351
+ const HEIGHT = 100;
352
+ /**
353
+ * Screen pixels per sprite pixel that best shows `viewHeight` world units on a drawing buffer
354
+ * `bufferHeight` pixels tall: the nearest whole number, at least 1.
355
+ */
356
+ const pixelScale = (bufferHeight, viewHeight, pixelsPerUnit) => Math.max(1, Math.round(bufferHeight / (viewHeight * pixelsPerUnit)));
357
+ /**
358
+ * The largest whole number of screen pixels per sprite pixel at which a `width` x `depth` world
359
+ * unit area fits a `bufferWidth` x `bufferHeight` drawing buffer, at least 1 (a buffer too
360
+ * small for the area at 1 crops it).
361
+ */
362
+ const fitPixelScale = (bufferWidth, bufferHeight, width, depth, pixelsPerUnit) => Math.max(1, Math.floor(Math.min(bufferWidth / (width * pixelsPerUnit), bufferHeight / (depth * pixelsPerUnit))));
363
+ /**
364
+ * Rounds a world position to the nearest pixel edge, at `pixelsPerUnit` pixels per world unit:
365
+ * sprite pixels with the sheet's density, or screen pixels with the camera's.
366
+ */
367
+ const snapToPixel = (value, pixelsPerUnit) => Math.round(value * pixelsPerUnit) / pixelsPerUnit;
368
+ /**
369
+ * The frustum for a `bufferWidth` x `bufferHeight` drawing buffer at `pixelsPerWorldUnit`
370
+ * screen pixels per world unit, around a camera at the origin. An odd size gets its spare
371
+ * pixel on the right or bottom, so the camera's position stays on a screen pixel's corner.
372
+ */
373
+ const pixelFrustum = (bufferWidth, bufferHeight, pixelsPerWorldUnit) => {
374
+ const halfWidth = Math.floor(bufferWidth / 2);
375
+ const halfHeight = Math.floor(bufferHeight / 2);
376
+ return {
377
+ left: -halfWidth / pixelsPerWorldUnit,
378
+ right: (bufferWidth - halfWidth) / pixelsPerWorldUnit,
379
+ top: halfHeight / pixelsPerWorldUnit,
380
+ bottom: -(bufferHeight - halfHeight) / pixelsPerWorldUnit
381
+ };
382
+ };
383
+ /**
384
+ * An orthographic camera looking straight down at the ground plane (y = 0), with +x to the
385
+ * right and +z down the screen, so a larger z is lower on screen. It keeps sprite pixels whole:
386
+ * `pixelScale` screen pixels per sprite pixel, a whole number, and a position on whole screen
387
+ * pixels. Give it the drawing buffer's size with `setBufferSize` (a `Stage` made with
388
+ * `camera: "pixel"` does, on every resize), then `follow` a point or `fit` an area each frame.
389
+ * Don't move it with `fitCamera` or `lookAt`: they undo the snapping.
390
+ */
391
+ var PixelCamera = class extends THREE.OrthographicCamera {
392
+ isPixelCamera = true;
393
+ pixelsPerUnitValue;
394
+ viewHeightValue;
395
+ screenScale = 1;
396
+ bufferWidth = 1;
397
+ bufferHeight = 1;
398
+ targetX = 0;
399
+ targetZ = 0;
400
+ /** Set by `fit`, cleared by `follow`. */
401
+ fitted = null;
402
+ constructor(options = {}) {
403
+ super(-1, 1, 1, -1, 1, 200);
404
+ this.pixelsPerUnitValue = options.pixelsPerUnit ?? 16;
405
+ this.viewHeightValue = options.viewHeight ?? 20;
406
+ this.rotation.set(-Math.PI / 2, 0, 0);
407
+ this.update();
408
+ }
409
+ /** Sprite pixels per world unit. */
410
+ get pixelsPerUnit() {
411
+ return this.pixelsPerUnitValue;
412
+ }
413
+ /** Screen pixels per sprite pixel: a whole number, at least 1. */
414
+ get pixelScale() {
415
+ return this.screenScale;
416
+ }
417
+ /** Screen (drawing buffer) pixels per world unit. */
418
+ get screenPixelsPerUnit() {
419
+ return this.pixelsPerUnitValue * this.screenScale;
420
+ }
421
+ /** The ground height to aim for, in world units. Takes effect at once. */
422
+ setViewHeight(viewHeight) {
423
+ this.viewHeightValue = viewHeight;
424
+ this.update();
425
+ }
426
+ /** The drawing buffer's size in pixels (`renderer.getDrawingBufferSize`), not CSS pixels. */
427
+ setBufferSize(width, height) {
428
+ this.bufferWidth = Math.max(1, Math.round(width));
429
+ this.bufferHeight = Math.max(1, Math.round(height));
430
+ this.update();
431
+ }
432
+ /** Centres the view on a ground point, snapped to the nearest screen pixel. Survivors' hero. */
433
+ follow(x, z) {
434
+ this.fitted = null;
435
+ this.targetX = x;
436
+ this.targetZ = z;
437
+ this.update();
438
+ }
439
+ /**
440
+ * Shows all of an area plus `margin` world units around it, at the largest whole pixel scale
441
+ * that fits, centred on it. It keeps fitting on resize until the next `follow`. A puzzle board.
442
+ */
443
+ fit(bounds, margin = 0) {
444
+ this.fitted = {
445
+ minX: bounds.minX - margin,
446
+ maxX: bounds.maxX + margin,
447
+ minZ: bounds.minZ - margin,
448
+ maxZ: bounds.maxZ + margin
449
+ };
450
+ this.targetX = (bounds.minX + bounds.maxX) / 2;
451
+ this.targetZ = (bounds.minZ + bounds.maxZ) / 2;
452
+ this.update();
453
+ }
454
+ update() {
455
+ const perUnit = this.pixelsPerUnitValue;
456
+ this.screenScale = this.fitted ? fitPixelScale(this.bufferWidth, this.bufferHeight, this.fitted.maxX - this.fitted.minX, this.fitted.maxZ - this.fitted.minZ, perUnit) : pixelScale(this.bufferHeight, this.viewHeightValue, perUnit);
457
+ const screenPerUnit = perUnit * this.screenScale;
458
+ const frustum = pixelFrustum(this.bufferWidth, this.bufferHeight, screenPerUnit);
459
+ this.left = frustum.left;
460
+ this.right = frustum.right;
461
+ this.top = frustum.top;
462
+ this.bottom = frustum.bottom;
463
+ this.updateProjectionMatrix();
464
+ this.position.set(snapToPixel(this.targetX, screenPerUnit), HEIGHT, snapToPixel(this.targetZ, screenPerUnit));
465
+ this.updateMatrixWorld();
466
+ }
467
+ };
468
+ // src/sprite-sheet.ts
469
+ const SpriteSheetErrorBase = Data.TaggedError("SpriteSheetError");
470
+ /** A sprite sheet that couldn't be fetched, decoded or checked. `file` is the JSON or image that failed. */
471
+ var SpriteSheetError = class extends SpriteSheetErrorBase {};
472
+ const Count = Schema.Int;
473
+ const Rect = Schema.Struct({
474
+ x: Count,
475
+ y: Count,
476
+ w: Count,
477
+ h: Count
478
+ });
479
+ const Size = Schema.Struct({
480
+ w: Count,
481
+ h: Count
482
+ });
483
+ const AsepriteFrame = Schema.Struct({
484
+ filename: Schema.optional(Schema.String),
485
+ frame: Rect,
486
+ rotated: Schema.optional(Schema.Boolean),
487
+ spriteSourceSize: Schema.optional(Rect),
488
+ sourceSize: Schema.optional(Size),
489
+ duration: Schema.optional(Schema.Finite)
490
+ });
491
+ const AsepriteSheet = Schema.Struct({
492
+ frames: Schema.Union([Schema.Array(AsepriteFrame), Schema.Record(Schema.String, AsepriteFrame)]),
493
+ meta: Schema.Struct({
494
+ image: Schema.String,
495
+ size: Size,
496
+ frameTags: Schema.optional(Schema.Array(Schema.Struct({
497
+ name: Schema.String,
498
+ from: Count,
499
+ to: Count,
500
+ direction: Schema.optional(Schema.Literals([
501
+ "forward",
502
+ "reverse",
503
+ "pingpong",
504
+ "pingpong_reverse"
505
+ ]))
506
+ })))
507
+ })
508
+ });
509
+ const decodeAseprite = Schema.decodeUnknownResult(AsepriteSheet);
510
+ /** Aseprite's default frame duration, in ms, for exports that leave it out. */
511
+ const DEFAULT_DURATION_MS = 100;
512
+ /**
513
+ * Parses an Aseprite JSON export (frames as an array or a hash) and checks it: every frame
514
+ * inside the image, every tag inside the frames, no two tags with one name. `file` names the
515
+ * JSON in the error. Rotated frames (a TexturePacker option) aren't supported. A tag's
516
+ * `repeat` is ignored: animations loop.
517
+ */
518
+ const decodeSpriteSheet = (file, json) => {
519
+ const fail = (message) => Effect.fail(new SpriteSheetError({
520
+ file,
521
+ message: `The sprite sheet ${file} is invalid: ${message}`
522
+ }));
523
+ const decoded = decodeAseprite(json);
524
+ if (Result.isFailure(decoded)) return fail(decoded.failure.message);
525
+ const { meta } = decoded.success;
526
+ if (meta.size.w < 1 || meta.size.h < 1) return fail(`meta.size is ${meta.size.w}x${meta.size.h}`);
527
+ const entries = Array.isArray(decoded.success.frames) ? decoded.success.frames.map((frame, index) => [frame.filename ?? String(index), frame]) : Object.entries(decoded.success.frames);
528
+ if (entries.length === 0) return fail("it has no frames");
529
+ const frames = [];
530
+ const frameByName = /* @__PURE__ */ new Map();
531
+ for (const [name, frame] of entries) {
532
+ const problem = frameProblem(frame, meta.size);
533
+ if (problem !== void 0) return fail(`frame ${frames.length} (${name}) ${problem}`);
534
+ const { x, y, w, h } = frame.frame;
535
+ const placed = frame.spriteSourceSize ?? {
536
+ x: 0,
537
+ y: 0,
538
+ w,
539
+ h
540
+ };
541
+ const source = frame.sourceSize ?? {
542
+ w,
543
+ h
544
+ };
545
+ if (!frameByName.has(name)) frameByName.set(name, frames.length);
546
+ frames.push({
547
+ name,
548
+ x,
549
+ y,
550
+ width: w,
551
+ height: h,
552
+ offsetX: placed.x,
553
+ offsetY: placed.y,
554
+ sourceWidth: source.w,
555
+ sourceHeight: source.h,
556
+ duration: (frame.duration ?? DEFAULT_DURATION_MS) / 1e3
557
+ });
558
+ }
559
+ const tags = readTags(meta.frameTags ?? [], frames);
560
+ if (typeof tags === "string") return fail(tags);
561
+ return Effect.succeed({
562
+ image: meta.image,
563
+ width: meta.size.w,
564
+ height: meta.size.h,
565
+ frames,
566
+ frameByName,
567
+ tags
568
+ });
569
+ };
570
+ /** The tags by name, or what's wrong with them. */
571
+ const readTags = (frameTags, frames) => {
572
+ const tags = /* @__PURE__ */ new Map();
573
+ for (const tag of frameTags) {
574
+ if (tags.has(tag.name)) return `two tags are named "${tag.name}"`;
575
+ if (tag.from < 0 || tag.from > tag.to || tag.to >= frames.length) return `tag "${tag.name}" runs from frame ${tag.from} to ${tag.to}, but the sheet has frames 0 to ${frames.length - 1}`;
576
+ const shape = {
577
+ from: tag.from,
578
+ to: tag.to,
579
+ direction: tag.direction ?? "forward"
580
+ };
581
+ let duration = 0;
582
+ for (let step = 0; step < loopLength(shape); step += 1) duration += frames[frameOfStep(shape, step)]?.duration ?? 0;
583
+ tags.set(tag.name, {
584
+ name: tag.name,
585
+ ...shape,
586
+ duration
587
+ });
588
+ }
589
+ return tags;
590
+ };
591
+ /** What's wrong with one frame, or undefined. */
592
+ const frameProblem = (frame, image) => {
593
+ const { x, y, w, h } = frame.frame;
594
+ if (frame.rotated === true) return "is rotated, which isn't supported";
595
+ if (w < 1 || h < 1) return `is ${w}x${h}`;
596
+ if (x < 0 || y < 0 || x + w > image.w || y + h > image.h) return `at ${x},${y} size ${w}x${h} lies outside the ${image.w}x${image.h} image`;
597
+ if (frame.duration !== void 0 && frame.duration <= 0) return `lasts ${frame.duration} ms`;
598
+ const placed = frame.spriteSourceSize;
599
+ const source = frame.sourceSize ?? {
600
+ w,
601
+ h
602
+ };
603
+ if (placed !== void 0 && (placed.x < 0 || placed.y < 0 || placed.x + w > source.w || placed.y + h > source.h)) return `is placed at ${placed.x},${placed.y} outside its ${source.w}x${source.h} source size`;
604
+ };
605
+ const isPingpong = (tag) => tag.direction === "pingpong" || tag.direction === "pingpong_reverse";
606
+ /** Frames in one loop: a pingpong over 0..3 plays 0 1 2 3 2 1. */
607
+ const loopLength = (tag) => {
608
+ const count = tag.to - tag.from + 1;
609
+ return isPingpong(tag) && count > 1 ? 2 * count - 2 : count;
610
+ };
611
+ /** The frame index shown at `step` of a loop. */
612
+ const frameOfStep = (tag, step) => {
613
+ const count = tag.to - tag.from + 1;
614
+ const back = step - count + 1;
615
+ switch (tag.direction) {
616
+ case "forward": return tag.from + step;
617
+ case "reverse": return tag.to - step;
618
+ case "pingpong": return step < count ? tag.from + step : tag.to - back;
619
+ case "pingpong_reverse": return step < count ? tag.to - step : tag.from + back;
620
+ }
621
+ };
622
+ /**
623
+ * The frame index (into `sheet.frames`) a tag shows `seconds` into its animation, looping,
624
+ * from each frame's duration and the tag's direction. Allocates nothing: call it per sprite
625
+ * per frame. Negative times count back from the end of a loop.
626
+ */
627
+ const frameAt = (sheet, tag, seconds) => {
628
+ if (tag.from === tag.to || !(tag.duration > 0)) return tag.from;
629
+ let left = seconds % tag.duration;
630
+ if (left < 0) left += tag.duration;
631
+ const steps = loopLength(tag);
632
+ for (let step = 0; step < steps; step += 1) {
633
+ const index = frameOfStep(tag, step);
634
+ left -= sheet.frames[index]?.duration ?? 0;
635
+ if (left < 0) return index;
636
+ }
637
+ return frameOfStep(tag, steps - 1);
638
+ };
639
+ // src/stage.ts
640
+ /** A perspective camera's field of view, in degrees. Set `stage.camera.fov` to change it. */
641
+ const FOV = 50;
642
+ /** The near and far planes of a perspective or orthographic camera. */
643
+ const NEAR = .1;
644
+ const FAR = 400;
645
+ /**
646
+ * Caps devicePixelRatio for a perspective or orthographic stage; 2 keeps phones sharp without
647
+ * rendering 9x the pixels. A pixel stage isn't capped: see `drawingBuffer`.
648
+ */
649
+ const MAX_PIXEL_RATIO = 2;
650
+ /**
651
+ * A renderer, scene and camera that fill a container element and resize with it.
652
+ * The camera is perspective unless `camera: "orthographic"` or `"pixel"` is passed; on resize, an
653
+ * orthographic camera keeps its view height and widens or narrows to the new aspect ratio, and a
654
+ * pixel camera picks its pixel scale again. The pixel ratio is read again on every resize, and
655
+ * a pixel stage sizes its drawing buffer to the canvas's exact device pixels, where the others
656
+ * cap the ratio at 2 (see `drawingBuffer`). The camera is public: change its fields (a
657
+ * perspective camera's `fov`, `near` and `far`) and call `updateProjectionMatrix()`. So is the
658
+ * scene: set `stage.scene.fog` for fog.
659
+ * `dispose()` frees every GPU resource in the scene, except kept textures (see `keepTexture`),
660
+ * and removes the canvas.
661
+ */
662
+ var Stage = class {
663
+ renderer;
664
+ scene = new THREE.Scene();
665
+ camera;
666
+ container;
667
+ resizeObserver;
668
+ constructor(container, options = {}) {
669
+ this.container = container;
670
+ const pixel = options.camera === "pixel";
671
+ this.renderer = new THREE.WebGLRenderer({
672
+ antialias: !pixel,
673
+ powerPreference: "high-performance"
674
+ });
675
+ const canvas = this.renderer.domElement;
676
+ canvas.style.display = "block";
677
+ canvas.style.width = "100%";
678
+ canvas.style.height = "100%";
679
+ if (pixel) canvas.style.imageRendering = "pixelated";
680
+ container.append(canvas);
681
+ if (pixel) this.camera = new PixelCamera({
682
+ ...options.pixelsPerUnit === void 0 ? {} : { pixelsPerUnit: options.pixelsPerUnit },
683
+ ...options.viewHeight === void 0 ? {} : { viewHeight: options.viewHeight }
684
+ });
685
+ else if (options.camera === "orthographic") {
686
+ const half = (options.viewHeight ?? 10) / 2;
687
+ this.camera = new THREE.OrthographicCamera(-half, half, half, -half, NEAR, FAR);
688
+ } else this.camera = new THREE.PerspectiveCamera(FOV, 1, NEAR, FAR);
689
+ if (options.background !== void 0) this.scene.background = new THREE.Color(options.background);
690
+ this.resizeObserver = new ResizeObserver((entries) => this.resize(entries.at(-1)));
691
+ try {
692
+ this.resizeObserver.observe(container, { box: "device-pixel-content-box" });
693
+ } catch {
694
+ this.resizeObserver.observe(container);
695
+ }
696
+ this.resize(void 0);
697
+ }
698
+ render() {
699
+ this.renderer.render(this.scene, this.camera);
700
+ }
701
+ dispose() {
702
+ this.resizeObserver.disconnect();
703
+ disposeObject(this.scene);
704
+ this.renderer.dispose();
705
+ this.renderer.domElement.remove();
706
+ }
707
+ resize(entry) {
708
+ const width = Math.max(1, this.container.clientWidth);
709
+ const height = Math.max(1, this.container.clientHeight);
710
+ const camera = this.camera;
711
+ const target = drawingBuffer(camera instanceof PixelCamera, width, height, window.devicePixelRatio, MAX_PIXEL_RATIO, entry?.devicePixelContentBoxSize?.[0]);
712
+ this.renderer.setPixelRatio(target.pixelRatio);
713
+ this.renderer.setSize(target.width, target.height, false);
714
+ const aspect = width / height;
715
+ if (camera instanceof PixelCamera) {
716
+ const buffer = this.renderer.getDrawingBufferSize(BUFFER_SIZE);
717
+ camera.setBufferSize(buffer.x, buffer.y);
718
+ } else if (camera instanceof THREE.OrthographicCamera) {
719
+ const centerX = (camera.left + camera.right) / 2;
720
+ const halfWidth = (camera.top - camera.bottom) / 2 * aspect;
721
+ camera.left = centerX - halfWidth;
722
+ camera.right = centerX + halfWidth;
723
+ } else camera.aspect = aspect;
724
+ camera.updateProjectionMatrix();
725
+ }
726
+ };
727
+ const BUFFER_SIZE = new THREE.Vector2();
728
+ /**
729
+ * The drawing buffer for a canvas `width` x `height` CSS pixels: the pixel ratio and size to
730
+ * give `renderer.setSize`. A pixel stage never caps the ratio, since a smaller buffer is
731
+ * stretched onto the screen by a fraction and sprite pixels would cover uneven numbers of
732
+ * screen pixels; where the browser reports the canvas's device pixels (`device`), it uses
733
+ * those, as width * ratio can round a pixel off from the browser's size at a fractional ratio.
734
+ */
735
+ const drawingBuffer = (pixel, width, height, devicePixelRatio, maxPixelRatio, device) => {
736
+ if (!pixel) return {
737
+ pixelRatio: Math.min(devicePixelRatio, maxPixelRatio),
738
+ width,
739
+ height
740
+ };
741
+ if (device === void 0) return {
742
+ pixelRatio: devicePixelRatio,
743
+ width,
744
+ height
745
+ };
746
+ return {
747
+ pixelRatio: 1,
748
+ width: Math.max(1, device.inlineSize),
749
+ height: Math.max(1, device.blockSize)
750
+ };
751
+ };
752
+ const KEEP = "arcadekitKeep";
753
+ /**
754
+ * Marks a texture that something outside the scene owns, such as a sprite sheet the app
755
+ * loaded: `disposeObject` and `Stage.dispose` leave it alone, and its owner disposes it.
756
+ * Returns the texture.
757
+ */
758
+ const keepTexture = (texture) => {
759
+ texture.userData[KEEP] = true;
760
+ return texture;
761
+ };
762
+ /** Whether `keepTexture` marked this texture. */
763
+ const isKeptTexture = (texture) => texture.userData[KEEP] === true;
764
+ /**
765
+ * Frees the geometries, materials and textures of `root` and everything under it: the textures
766
+ * a material holds (its maps, and a shader material's texture uniforms) and, for a scene, its
767
+ * background and environment. Textures marked with `keepTexture` are skipped.
768
+ * `Stage.dispose` calls it on the whole scene. A view that removes an object from the
769
+ * scene calls it on that object, which `Stage.dispose` no longer reaches.
770
+ */
771
+ const disposeObject = (root) => {
772
+ const textures = /* @__PURE__ */ new Set();
773
+ if (root instanceof THREE.Scene) {
774
+ addTexture(textures, root.background);
775
+ addTexture(textures, root.environment);
776
+ }
777
+ root.traverse((object) => {
778
+ if (object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points) {
779
+ object.geometry.dispose();
780
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
781
+ for (const material of materials) {
782
+ for (const value of Object.values(material)) addTexture(textures, value);
783
+ if (material instanceof THREE.ShaderMaterial) for (const uniform of Object.values(material.uniforms)) addTexture(textures, uniform.value);
784
+ material.dispose();
785
+ }
786
+ }
787
+ });
788
+ for (const texture of textures) texture.dispose();
789
+ };
790
+ const addTexture = (textures, value) => {
791
+ if (value instanceof THREE.Texture && !isKeptTexture(value)) textures.add(value);
792
+ };
793
+ // src/sprite-loader.ts
794
+ /**
795
+ * Makes a sheet from parsed data and an image that's already in memory, such as a canvas the
796
+ * art was painted on. `loadSpriteSheet` uses it after fetching both files.
797
+ */
798
+ const makeSpriteSheet = (data, image) => {
799
+ const texture = new THREE.Texture(image);
800
+ texture.magFilter = THREE.NearestFilter;
801
+ texture.minFilter = THREE.NearestFilter;
802
+ texture.generateMipmaps = false;
803
+ texture.colorSpace = THREE.SRGBColorSpace;
804
+ texture.flipY = false;
805
+ texture.premultiplyAlpha = false;
806
+ texture.name = data.image;
807
+ texture.needsUpdate = true;
808
+ keepTexture(texture);
809
+ return {
810
+ data,
811
+ texture,
812
+ dispose: () => {
813
+ texture.dispose();
814
+ if (typeof ImageBitmap !== "undefined" && image instanceof ImageBitmap) image.close();
815
+ }
816
+ };
817
+ };
818
+ /**
819
+ * Loads an Aseprite sheet: fetches the JSON at `jsonUrl`, then the image its `meta.image`
820
+ * names, relative to the JSON. Fails with a `SpriteSheetError` naming the file when either
821
+ * can't be fetched, the JSON is invalid, or the image's size isn't `meta.size`.
822
+ */
823
+ const loadSpriteSheet = (jsonUrl) => Effect.gen(function* () {
824
+ const json = yield* fetchFile(jsonUrl, (response) => response.json());
825
+ const data = yield* decodeSpriteSheet(jsonUrl, json);
826
+ const imageUrl = new URL(data.image, new URL(jsonUrl, globalThis.location.href)).href;
827
+ const image = yield* fetchFile(imageUrl, async (response) => createImageBitmap(await response.blob(), {
828
+ premultiplyAlpha: "none",
829
+ colorSpaceConversion: "none"
830
+ }));
831
+ const { width, height } = image;
832
+ if (width !== data.width || height !== data.height) {
833
+ image.close();
834
+ return yield* new SpriteSheetError({
835
+ file: imageUrl,
836
+ message: `The sprite sheet image ${imageUrl} is ${width}x${height}, but ${jsonUrl} says ${data.width}x${data.height}`
837
+ });
838
+ }
839
+ return makeSpriteSheet(data, image);
840
+ });
841
+ const fetchFile = (file, read) => Effect.tryPromise({
842
+ try: async (signal) => {
843
+ const response = await fetch(file, { signal });
844
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
845
+ return await read(response);
846
+ },
847
+ catch: (cause) => new SpriteSheetError({
848
+ file,
849
+ message: `The sprite sheet file ${file} can't be loaded: ${cause instanceof Error ? cause.message : String(cause)}`,
850
+ cause
851
+ })
852
+ });
853
+ export { InstancedSprites, MeshInstances, PixelCamera, SpriteSheetError, Stage, decodeSpriteSheet, disposeObject, fitCamera, fitPixelScale, followTarget, frameAt, isKeptTexture, keepTexture, loadSpriteSheet, makeSpriteSheet, orbitAround, pixelFrustum, pixelScale, snapToPixel };
854
+
855
+ //# sourceMappingURL=index.js.map