@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/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/index.d.ts +374 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +855 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -3
- package/src/camera.ts +104 -0
- package/src/index.ts +43 -0
- package/src/instanced-sprites.ts +330 -0
- package/src/mesh-instances.ts +79 -0
- package/src/pixel-camera.ts +196 -0
- package/src/sprite-loader.ts +103 -0
- package/src/sprite-sheet.ts +312 -0
- package/src/stage.ts +262 -0
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
|