@arcadekit/render-three 0.0.0-stage → 0.2.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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/camera.ts","../src/instanced-sprites.ts","../src/mesh-instances.ts","../src/pixel-camera.ts","../src/sprite-sheet.ts","../src/stage.ts","../src/sprite-loader.ts"],"sourcesContent":["// Every helper here works on the ground plane (y = 0, Y up).\n// A side-view genre would need its own.\n\nimport type * as THREE from \"three\";\n\n/** Keeps the camera at a fixed offset from a target on the ground plane, looking at it. Top-down and isometric games. */\nexport const followTarget = (\n camera: THREE.Camera,\n target: { readonly x: number; readonly z: number },\n offset: { readonly x: number; readonly y: number; readonly z: number }\n): void => {\n camera.position.set(target.x + offset.x, offset.y, target.z + offset.z);\n camera.lookAt(target.x, 0, target.z);\n};\n\n/**\n * Circles the camera around a point on the ground plane at a fixed height, looking at it.\n * `angle` is in radians; 0 puts the camera on the point's +z side. Menu backdrops and\n * showcase views.\n */\nexport const orbitAround = (\n camera: THREE.Camera,\n center: { readonly x: number; readonly z: number },\n options: {\n readonly radius: number;\n readonly height: number;\n readonly angle: number;\n }\n): void => {\n camera.position.set(\n center.x + Math.sin(options.angle) * options.radius,\n options.height,\n center.z + Math.cos(options.angle) * options.radius\n );\n camera.lookAt(center.x, 0, center.z);\n};\n\n/** A rectangle on the ground plane (y = 0), in world units. */\nexport interface GroundBounds {\n readonly minX: number;\n readonly maxX: number;\n readonly minZ: number;\n readonly maxZ: number;\n}\n\nexport interface FitCameraOptions {\n /** World units of space left around the bounds on every side. Defaults to 0. */\n readonly margin?: number;\n /**\n * How far the camera sits back (toward +z) from straight overhead, as a fraction of its\n * height. 0 looks straight down. Defaults to 0.\n */\n readonly tilt?: number;\n}\n\n/**\n * Places a camera above a rectangle on the ground plane, looking at its center, so that all of\n * it shows at the camera's current aspect ratio. Board, puzzle and arena games. Call it every\n * frame (it allocates nothing), or at least after each resize.\n *\n * A perspective camera keeps its field of view and moves back far enough. An orthographic\n * camera (`Stage` with `camera: \"orthographic\"`) changes its frustum to fit instead.\n * With a tilt, the fit is still worked out as if looking straight down. That leaves an\n * orthographic camera a little extra room. It does not hold for a perspective camera: a small\n * tilt (below the tangent of half its field of view) can push the near edge of the bounds off\n * the screen, so give it more margin.\n */\nexport const fitCamera = (\n camera: THREE.PerspectiveCamera | THREE.OrthographicCamera,\n bounds: GroundBounds,\n options: FitCameraOptions = {}\n): void => {\n const margin = options.margin ?? 0;\n const tilt = options.tilt ?? 0;\n const centerX = (bounds.minX + bounds.maxX) / 2;\n const centerZ = (bounds.minZ + bounds.maxZ) / 2;\n const halfWidth = (bounds.maxX - bounds.minX) / 2 + margin;\n const halfDepth = (bounds.maxZ - bounds.minZ) / 2 + margin;\n let height: number;\n if (isPerspective(camera)) {\n const halfTan = Math.tan((camera.fov * Math.PI) / 360);\n height = Math.max(\n halfDepth / halfTan,\n halfWidth / (halfTan * camera.aspect)\n );\n } else {\n const aspect = (camera.right - camera.left) / (camera.top - camera.bottom);\n const halfHeight = Math.max(halfDepth, halfWidth / aspect);\n camera.top = halfHeight;\n camera.bottom = -halfHeight;\n camera.left = -halfHeight * aspect;\n camera.right = halfHeight * aspect;\n camera.updateProjectionMatrix();\n // Any height works for an orthographic camera; this one keeps the bounds well inside near/far.\n height = 4 * Math.max(halfWidth, halfDepth, 1);\n }\n camera.position.set(centerX, height, centerZ + height * tilt);\n camera.lookAt(centerX, 0, centerZ);\n};\n\nconst isPerspective = (\n camera: THREE.PerspectiveCamera | THREE.OrthographicCamera\n): camera is THREE.PerspectiveCamera =>\n (camera as THREE.PerspectiveCamera).isPerspectiveCamera === true;\n","import * as THREE from \"three\";\n\nimport type { SpriteSheet } from \"./sprite-loader\";\nimport type { SpriteSheetData } from \"./sprite-sheet\";\n\n/** Frames per row of the frame table texture; each frame takes 2 texels in a column. */\nconst TABLE_COLUMNS = 256;\n\n/** How far a layer's depth band reaches either side of its middle. Bands are 1 apart. */\nconst BAND = 0.45;\n\nconst VERTEX = `\n// x, z, lift, layer\nattribute vec4 aPlace;\n// frame, flipX, flash\nattribute vec3 aFrame;\n// r, g, b, alpha\nattribute vec4 aTint;\nuniform sampler2D uFrames;\nuniform vec2 uSheetSize;\nuniform vec2 uAnchor;\nuniform float uUnitsPerPixel;\nuniform float uSnap;\nuniform float uLayer;\nuniform float uDepthScale;\nvarying vec2 vUv;\nvarying vec4 vTint;\nvarying float vFlash;\n\nivec2 tableCell(int frame, int row) {\n return ivec2(frame % ${TABLE_COLUMNS}, (frame / ${TABLE_COLUMNS}) * 2 + row);\n}\n\nvoid main() {\n int frame = int(aFrame.x + 0.5);\n // x, y, width, height in the image\n vec4 rect = texelFetch(uFrames, tableCell(frame, 0), 0);\n // offset x, y in the untrimmed frame; its width, height\n vec4 source = texelFetch(uFrames, tableCell(frame, 1), 0);\n // uv.y is 1 at the top of the quad; pixel rows count down from the top.\n vec2 corner = vec2(uv.x, 1.0 - uv.y);\n vec2 pixel = source.xy + corner * rect.zw;\n if (aFrame.y > 0.5) {\n pixel.x = source.z - pixel.x;\n }\n vec2 anchor = floor(uAnchor * source.zw + 0.5);\n vec2 local = (pixel - anchor) * uUnitsPerPixel;\n // Up the screen is -z, so a lifted sprite moves toward -z; its depth stays at its feet.\n vec2 base = vec2(aPlace.x, aPlace.y - aPlace.z);\n if (uSnap > 0.5) {\n base = floor(base / uUnitsPerPixel + 0.5) * uUnitsPerPixel;\n }\n float depth = uLayer + aPlace.w + 0.5 + clamp(aPlace.y * uDepthScale, -${BAND}, ${BAND});\n vUv = (rect.xy + corner * rect.zw) / uSheetSize;\n vTint = aTint;\n vFlash = aFrame.z;\n gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(base.x + local.x, depth, base.y + local.y, 1.0);\n}`;\n\nconst FRAGMENT = `\nuniform sampler2D uMap;\nuniform float uAlphaTest;\nuniform float uBlend;\nvarying vec2 vUv;\nvarying vec4 vTint;\nvarying float vFlash;\n\nvoid main() {\n vec4 texel = texture2D(uMap, vUv);\n float alpha = texel.a * vTint.a;\n if (alpha < uAlphaTest) discard;\n vec3 color = mix(texel.rgb * vTint.rgb, vec3(1.0), vFlash);\n gl_FragColor = vec4(color, uBlend > 0.5 ? alpha : 1.0);\n #include <colorspace_fragment>\n}`;\n\nexport interface InstancedSpritesOptions {\n /** The most sprites it can draw at once. Fixed. */\n readonly capacity: number;\n /** World units per sprite pixel: 1/16 draws a 16 px sprite one unit wide. Defaults to 1/16. */\n readonly unitsPerPixel?: number;\n /**\n * The point of a frame a sprite's position marks, as a fraction of the frame's untrimmed size\n * from its top-left corner, rounded to a whole pixel. Defaults to the middle, (0.5, 0.5); a\n * character standing on its feet wants about (0.5, 0.9).\n */\n readonly anchor?: { readonly x: number; readonly y: number };\n /**\n * Layer added to every sprite's own layer. A higher layer draws over a lower one whatever\n * their positions: ground marks below characters, characters below projectiles. Use whole\n * numbers; layer 0 and up draw above anything at y = 0, such as a ground mesh. Defaults to 0.\n */\n readonly layer?: number;\n /**\n * Blend partly transparent pixels, for soft shadows and markers. Off by default: pixel art\n * draws opaque, with pixels below `alphaTest` cut out, and sorts by depth. A transparent batch\n * doesn't write depth, so its own sprites overlap in index order.\n */\n readonly transparent?: boolean;\n /** Pixels with less alpha (after the sprite's own alpha) aren't drawn. Defaults to 0.5, or 0.01 when transparent. */\n readonly alphaTest?: number;\n /** Rounds each sprite's position to whole sprite pixels, so the art never shimmers. Defaults to true. */\n readonly snap?: boolean;\n /**\n * How far from z = 0, in world units, depth still orders sprites within a layer: beyond it,\n * sprites tie. Defaults to 256.\n */\n readonly sortRange?: number;\n}\n\n/**\n * Draws up to `capacity` sprites from one sheet in a single draw call, as quads on the ground\n * plane facing a straight-down camera (`PixelCamera`). Each sprite has a position, a frame,\n * a horizontal flip, a tint, a flash toward white, an alpha, a lift and a layer. Within a layer\n * a sprite lower on screen (larger z) draws in front.\n *\n * Each frame, call `set` for every sprite to draw (it resets that sprite's look), then the\n * other setters for those that need them, then `commit(count)`. Nothing allocates per frame.\n * Add `mesh` to the scene. `dispose` (or `disposeObject`) frees its own geometry, material and\n * frame table, never the sheet's texture: the sheet's owner frees that.\n */\nexport class InstancedSprites {\n readonly mesh: THREE.Mesh<\n THREE.InstancedBufferGeometry,\n THREE.ShaderMaterial\n >;\n readonly capacity: number;\n readonly sheet: SpriteSheet;\n private readonly frameCount: number;\n private readonly place: THREE.InstancedBufferAttribute;\n private readonly frame: THREE.InstancedBufferAttribute;\n private readonly tint: THREE.InstancedBufferAttribute;\n private readonly frameTable: THREE.DataTexture;\n\n constructor(sheet: SpriteSheet, options: InstancedSpritesOptions) {\n this.sheet = sheet;\n this.capacity = options.capacity;\n this.frameCount = sheet.data.frames.length;\n const transparent = options.transparent ?? false;\n\n const quad = new THREE.PlaneGeometry(1, 1);\n const geometry = new THREE.InstancedBufferGeometry();\n geometry.index = quad.index;\n geometry.setAttribute(\"position\", quad.getAttribute(\"position\"));\n geometry.setAttribute(\"uv\", quad.getAttribute(\"uv\"));\n quad.dispose();\n this.place = dynamicAttribute(geometry, \"aPlace\", this.capacity, 4);\n this.frame = dynamicAttribute(geometry, \"aFrame\", this.capacity, 3);\n this.tint = dynamicAttribute(geometry, \"aTint\", this.capacity, 4);\n geometry.instanceCount = 0;\n\n this.frameTable = frameTable(sheet.data);\n const anchor = options.anchor ?? { x: 0.5, y: 0.5 };\n const material = new THREE.ShaderMaterial({\n vertexShader: VERTEX,\n fragmentShader: FRAGMENT,\n uniforms: {\n uMap: { value: sheet.texture },\n uFrames: { value: this.frameTable },\n uSheetSize: {\n value: new THREE.Vector2(sheet.data.width, sheet.data.height),\n },\n uAnchor: { value: new THREE.Vector2(anchor.x, anchor.y) },\n uUnitsPerPixel: { value: options.unitsPerPixel ?? 1 / 16 },\n uSnap: { value: (options.snap ?? true) ? 1 : 0 },\n uLayer: { value: options.layer ?? 0 },\n uDepthScale: { value: BAND / (options.sortRange ?? 256) },\n uAlphaTest: {\n value: options.alphaTest ?? (transparent ? 0.01 : 0.5),\n },\n uBlend: { value: transparent ? 1 : 0 },\n },\n transparent,\n depthWrite: !transparent,\n // A flipped sprite mirrors its quad, which turns it face down.\n side: THREE.DoubleSide,\n });\n this.mesh = new THREE.Mesh(geometry, material);\n // Sprites can be anywhere, so the quad's bounds mean nothing: never cull.\n this.mesh.frustumCulled = false;\n }\n\n /**\n * Places sprite `index` with its feet (its anchor) at (x, z) on the ground, showing frame\n * `frame` (an index into `sheet.data.frames`, as `frameAt` gives), mirrored if `flipX`.\n * Resets its tint, alpha, flash, lift and layer. A frame outside the sheet draws frame 0.\n */\n set(index: number, x: number, z: number, frame: number, flipX = false): void {\n const shown = frame >= 0 && frame < this.frameCount ? frame : 0;\n write4(this.place, index, x, z, 0, 0);\n write3(this.frame, index, shown, flipX ? 1 : 0, 0);\n write4(this.tint, index, 1, 1, 1, 1);\n }\n\n /** Multiplies sprite `index`'s colors by `color` (white leaves them), and sets its alpha. */\n setTint(index: number, color: THREE.Color, alpha = 1): void {\n write4(this.tint, index, color.r, color.g, color.b, alpha);\n }\n\n /** Sets sprite `index`'s alpha (0 to 1) and keeps its tint. Opaque sprites hide below `alphaTest`. */\n setAlpha(index: number, alpha: number): void {\n (this.tint.array as Float32Array)[index * 4 + 3] = alpha;\n }\n\n /** Blends sprite `index` toward white: 0 is its own colors, 1 a white silhouette. A hit flash. */\n setFlash(index: number, amount: number): void {\n (this.frame.array as Float32Array)[index * 3 + 2] = amount;\n }\n\n /**\n * Draws sprite `index` `lift` world units up the screen, as if floating: a bat. Its depth\n * stays where its feet are, so it sorts with the things standing near it.\n */\n setLift(index: number, lift: number): void {\n (this.place.array as Float32Array)[index * 4 + 2] = lift;\n }\n\n /** Puts sprite `index` in another layer, added to the batch's own. See `InstancedSpritesOptions.layer`. */\n setLayer(index: number, layer: number): void {\n (this.place.array as Float32Array)[index * 4 + 3] = layer;\n }\n\n /** Shows the first `count` sprites and uploads them. */\n commit(count: number): void {\n const shown = Math.max(0, Math.min(count, this.capacity));\n this.mesh.geometry.instanceCount = shown;\n for (const attribute of [this.place, this.frame, this.tint]) {\n attribute.clearUpdateRanges();\n attribute.addUpdateRange(0, Math.max(1, shown) * attribute.itemSize);\n attribute.needsUpdate = true;\n }\n }\n\n /** Removes the mesh and frees its geometry, material and frame table. Never the sheet. */\n dispose(): void {\n this.mesh.removeFromParent();\n this.mesh.geometry.dispose();\n this.mesh.material.dispose();\n this.frameTable.dispose();\n }\n}\n\nconst dynamicAttribute = (\n geometry: THREE.InstancedBufferGeometry,\n name: string,\n capacity: number,\n size: number\n): THREE.InstancedBufferAttribute => {\n const attribute = new THREE.InstancedBufferAttribute(\n new Float32Array(Math.max(1, capacity) * size),\n size\n );\n attribute.setUsage(THREE.DynamicDrawUsage);\n geometry.setAttribute(name, attribute);\n return attribute;\n};\n\nconst write4 = (\n attribute: THREE.InstancedBufferAttribute,\n index: number,\n a: number,\n b: number,\n c: number,\n d: number\n) => {\n const array = attribute.array as Float32Array;\n const at = index * 4;\n array[at] = a;\n array[at + 1] = b;\n array[at + 2] = c;\n array[at + 3] = d;\n};\n\nconst write3 = (\n attribute: THREE.InstancedBufferAttribute,\n index: number,\n a: number,\n b: number,\n c: number\n) => {\n const array = attribute.array as Float32Array;\n const at = index * 3;\n array[at] = a;\n array[at + 1] = b;\n array[at + 2] = c;\n};\n\n/**\n * The frame table the vertex shader reads: frame i at column i % TABLE_COLUMNS, rows\n * 2 * floor(i / TABLE_COLUMNS) (its rect in the image) and the row after (its offset in the\n * untrimmed frame, and that frame's size).\n */\nconst frameTableData = (\n sheet: SpriteSheetData\n): {\n readonly data: Float32Array;\n readonly width: number;\n readonly height: number;\n} => {\n const count = sheet.frames.length;\n const width = Math.min(Math.max(1, count), TABLE_COLUMNS);\n const height = 2 * Math.max(1, Math.ceil(count / TABLE_COLUMNS));\n const data = new Float32Array(width * height * 4);\n for (const [index, frame] of sheet.frames.entries()) {\n const column = index % TABLE_COLUMNS;\n const row = 2 * Math.floor(index / TABLE_COLUMNS);\n data.set(\n [frame.x, frame.y, frame.width, frame.height],\n (row * width + column) * 4\n );\n data.set(\n [frame.offsetX, frame.offsetY, frame.sourceWidth, frame.sourceHeight],\n ((row + 1) * width + column) * 4\n );\n }\n return { data, width, height };\n};\n\nconst frameTable = (sheet: SpriteSheetData): THREE.DataTexture => {\n const { data, width, height } = frameTableData(sheet);\n const texture = new THREE.DataTexture(\n data,\n width,\n height,\n THREE.RGBAFormat,\n THREE.FloatType\n );\n texture.needsUpdate = true;\n return texture;\n};\n","import * as THREE from \"three\";\n\nconst DEFAULT_COLOR = new THREE.Color(\"#ffffff\");\n\n/**\n * Draws up to `capacity` copies of one mesh in a single draw call.\n * Call `set` (and optionally `setColor`) for the instances that changed, then `commit(count)`\n * to show the first `count` instances and upload the changes.\n */\nexport class MeshInstances {\n readonly mesh: THREE.InstancedMesh;\n readonly capacity: number;\n private readonly matrix = new THREE.Matrix4();\n\n constructor(\n geometry: THREE.BufferGeometry,\n material: THREE.Material,\n capacity: number\n ) {\n this.capacity = capacity;\n this.mesh = new THREE.InstancedMesh(geometry, material, capacity);\n this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);\n // Allocates the per-instance color buffer.\n this.mesh.setColorAt(0, DEFAULT_COLOR);\n this.mesh.count = 0;\n // Instances can be anywhere, so the mesh's bounding sphere means nothing: never cull.\n this.mesh.frustumCulled = false;\n }\n\n /**\n * Places instance `index` at (x, y, z) with a uniform `scale` and a rotation around the vertical axis.\n * For a fixed tilt every instance shares (a flat ring lying on the floor), rotate the geometry once instead.\n */\n set(\n index: number,\n x: number,\n y: number,\n z: number,\n scale = 1,\n rotationY = 0\n ): void {\n if (rotationY === 0) {\n this.matrix.makeScale(scale, scale, scale);\n } else {\n this.matrix.makeRotationY(rotationY);\n if (scale !== 1) {\n this.matrix.scale(SCALE.set(scale, scale, scale));\n }\n }\n this.matrix.setPosition(x, y, z);\n this.mesh.setMatrixAt(index, this.matrix);\n }\n\n setColor(index: number, color: THREE.Color): void {\n this.mesh.setColorAt(index, color);\n }\n\n /** Shows the first `count` instances and uploads this frame's changes. */\n commit(count: number): void {\n this.mesh.count = Math.min(count, this.capacity);\n this.mesh.instanceMatrix.needsUpdate = true;\n if (this.mesh.instanceColor) {\n this.mesh.instanceColor.needsUpdate = true;\n }\n }\n\n dispose(): void {\n this.mesh.removeFromParent();\n this.mesh.geometry.dispose();\n const materials: THREE.Material[] = Array.isArray(this.mesh.material)\n ? this.mesh.material\n : [this.mesh.material];\n for (const material of materials) {\n material.dispose();\n }\n }\n}\n\nconst SCALE = new THREE.Vector3();\n","// A straight-down orthographic camera for pixel art. Every sprite pixel covers a whole number of\n// screen pixels, and the camera only ever moves by whole screen pixels, so the art never shimmers.\n\nimport * as THREE from \"three\";\n\nimport type { GroundBounds } from \"./camera\";\n\n/** How high above the ground the camera sits. Anything from y = -HEIGHT to just below it is drawn. */\nconst HEIGHT = 100;\n\n/**\n * Screen pixels per sprite pixel that best shows `viewHeight` world units on a drawing buffer\n * `bufferHeight` pixels tall: the nearest whole number, at least 1.\n */\nexport const pixelScale = (\n bufferHeight: number,\n viewHeight: number,\n pixelsPerUnit: number\n): number =>\n Math.max(1, Math.round(bufferHeight / (viewHeight * pixelsPerUnit)));\n\n/**\n * The largest whole number of screen pixels per sprite pixel at which a `width` x `depth` world\n * unit area fits a `bufferWidth` x `bufferHeight` drawing buffer, at least 1 (a buffer too\n * small for the area at 1 crops it).\n */\nexport const fitPixelScale = (\n bufferWidth: number,\n bufferHeight: number,\n width: number,\n depth: number,\n pixelsPerUnit: number\n): number =>\n Math.max(\n 1,\n Math.floor(\n Math.min(\n bufferWidth / (width * pixelsPerUnit),\n bufferHeight / (depth * pixelsPerUnit)\n )\n )\n );\n\n/**\n * Rounds a world position to the nearest pixel edge, at `pixelsPerUnit` pixels per world unit:\n * sprite pixels with the sheet's density, or screen pixels with the camera's.\n */\nexport const snapToPixel = (value: number, pixelsPerUnit: number): number =>\n Math.round(value * pixelsPerUnit) / pixelsPerUnit;\n\n/**\n * The frustum for a `bufferWidth` x `bufferHeight` drawing buffer at `pixelsPerWorldUnit`\n * screen pixels per world unit, around a camera at the origin. An odd size gets its spare\n * pixel on the right or bottom, so the camera's position stays on a screen pixel's corner.\n */\nexport const pixelFrustum = (\n bufferWidth: number,\n bufferHeight: number,\n pixelsPerWorldUnit: number\n): {\n readonly left: number;\n readonly right: number;\n readonly top: number;\n readonly bottom: number;\n} => {\n const halfWidth = Math.floor(bufferWidth / 2);\n const halfHeight = Math.floor(bufferHeight / 2);\n return {\n left: -halfWidth / pixelsPerWorldUnit,\n right: (bufferWidth - halfWidth) / pixelsPerWorldUnit,\n top: halfHeight / pixelsPerWorldUnit,\n bottom: -(bufferHeight - halfHeight) / pixelsPerWorldUnit,\n };\n};\n\nexport interface PixelCameraOptions {\n /** Sprite pixels per world unit: 16 draws a 16 px enemy one unit wide. Defaults to 16. */\n readonly pixelsPerUnit?: number;\n /**\n * About how many world units of ground to show top to bottom. The camera picks the whole\n * number of screen pixels per sprite pixel that comes nearest. Defaults to 20.\n */\n readonly viewHeight?: number;\n}\n\n/**\n * An orthographic camera looking straight down at the ground plane (y = 0), with +x to the\n * right and +z down the screen, so a larger z is lower on screen. It keeps sprite pixels whole:\n * `pixelScale` screen pixels per sprite pixel, a whole number, and a position on whole screen\n * pixels. Give it the drawing buffer's size with `setBufferSize` (a `Stage` made with\n * `camera: \"pixel\"` does, on every resize), then `follow` a point or `fit` an area each frame.\n * Don't move it with `fitCamera` or `lookAt`: they undo the snapping.\n */\nexport class PixelCamera extends THREE.OrthographicCamera {\n readonly isPixelCamera = true;\n private pixelsPerUnitValue: number;\n private viewHeightValue: number;\n private screenScale = 1;\n private bufferWidth = 1;\n private bufferHeight = 1;\n private targetX = 0;\n private targetZ = 0;\n /** Set by `fit`, cleared by `follow`. */\n private fitted: GroundBounds | null = null;\n\n constructor(options: PixelCameraOptions = {}) {\n super(-1, 1, 1, -1, 1, 2 * HEIGHT);\n this.pixelsPerUnitValue = options.pixelsPerUnit ?? 16;\n this.viewHeightValue = options.viewHeight ?? 20;\n // Straight down, with up on screen toward -z. Set directly: lookAt would tilt it a hair.\n this.rotation.set(-Math.PI / 2, 0, 0);\n this.update();\n }\n\n /** Sprite pixels per world unit. */\n get pixelsPerUnit(): number {\n return this.pixelsPerUnitValue;\n }\n\n /** Screen pixels per sprite pixel: a whole number, at least 1. */\n get pixelScale(): number {\n return this.screenScale;\n }\n\n /** Screen (drawing buffer) pixels per world unit. */\n get screenPixelsPerUnit(): number {\n return this.pixelsPerUnitValue * this.screenScale;\n }\n\n /** The ground height to aim for, in world units. Takes effect at once. */\n setViewHeight(viewHeight: number): void {\n this.viewHeightValue = viewHeight;\n this.update();\n }\n\n /** The drawing buffer's size in pixels (`renderer.getDrawingBufferSize`), not CSS pixels. */\n setBufferSize(width: number, height: number): void {\n this.bufferWidth = Math.max(1, Math.round(width));\n this.bufferHeight = Math.max(1, Math.round(height));\n this.update();\n }\n\n /** Centres the view on a ground point, snapped to the nearest screen pixel. Survivors' hero. */\n follow(x: number, z: number): void {\n this.fitted = null;\n this.targetX = x;\n this.targetZ = z;\n this.update();\n }\n\n /**\n * Shows all of an area plus `margin` world units around it, at the largest whole pixel scale\n * that fits, centred on it. It keeps fitting on resize until the next `follow`. A puzzle board.\n */\n fit(bounds: GroundBounds, margin = 0): void {\n this.fitted = {\n minX: bounds.minX - margin,\n maxX: bounds.maxX + margin,\n minZ: bounds.minZ - margin,\n maxZ: bounds.maxZ + margin,\n };\n this.targetX = (bounds.minX + bounds.maxX) / 2;\n this.targetZ = (bounds.minZ + bounds.maxZ) / 2;\n this.update();\n }\n\n private update(): void {\n const perUnit = this.pixelsPerUnitValue;\n this.screenScale = this.fitted\n ? fitPixelScale(\n this.bufferWidth,\n this.bufferHeight,\n this.fitted.maxX - this.fitted.minX,\n this.fitted.maxZ - this.fitted.minZ,\n perUnit\n )\n : pixelScale(this.bufferHeight, this.viewHeightValue, perUnit);\n const screenPerUnit = perUnit * this.screenScale;\n const frustum = pixelFrustum(\n this.bufferWidth,\n this.bufferHeight,\n screenPerUnit\n );\n this.left = frustum.left;\n this.right = frustum.right;\n this.top = frustum.top;\n this.bottom = frustum.bottom;\n this.updateProjectionMatrix();\n this.position.set(\n snapToPixel(this.targetX, screenPerUnit),\n HEIGHT,\n snapToPixel(this.targetZ, screenPerUnit)\n );\n this.updateMatrixWorld();\n }\n}\n","// Sprite sheet data: Aseprite's JSON export, parsed and checked. No DOM and no Three.js here,\n// so it runs anywhere; the loader (sprite-loader.ts) adds the image and its texture.\n\nimport { Data, Effect, Result, Schema } from \"effect\";\n\n// Named so the type files can be made one file at a time (isolatedDeclarations).\nconst SpriteSheetErrorBase: ReturnType<\n typeof Data.TaggedError<\"SpriteSheetError\">\n> = Data.TaggedError(\"SpriteSheetError\");\n\n/** A sprite sheet that couldn't be fetched, decoded or checked. `file` is the JSON or image that failed. */\nexport class SpriteSheetError extends SpriteSheetErrorBase<{\n readonly file: string;\n readonly message: string;\n readonly cause?: unknown;\n}> {}\n\n/** How a tag's frames play: Aseprite's tag directions. */\nexport type SpriteTagDirection =\n | \"forward\"\n | \"reverse\"\n | \"pingpong\"\n | \"pingpong_reverse\";\n\n/** One frame of a sheet. Every size and offset is in sprite pixels. */\nexport interface SpriteFrame {\n /** Aseprite's `filename`, or the frame's key in the \"hash\" form. */\n readonly name: string;\n /** Where the frame's pixels are in the image, from its top-left corner. */\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n /** Where those pixels sit in the untrimmed frame. 0, 0 unless the export trimmed it. */\n readonly offsetX: number;\n readonly offsetY: number;\n /** The untrimmed frame's size: what an anchor is a fraction of. */\n readonly sourceWidth: number;\n readonly sourceHeight: number;\n /** How long the frame shows in an animation, in seconds (Aseprite writes ms). */\n readonly duration: number;\n}\n\n/** A named run of frames: one animation, such as \"slime/move\". */\nexport interface SpriteTag {\n readonly name: string;\n /** First and last frame index, both included. */\n readonly from: number;\n readonly to: number;\n readonly direction: SpriteTagDirection;\n /** One whole loop, in seconds. A pingpong loop plays the middle frames twice. */\n readonly duration: number;\n}\n\n/** A parsed sheet: its frames in order, a lookup by frame name, and its tags by name. */\nexport interface SpriteSheetData {\n /** `meta.image` as written: the image's path relative to the JSON. */\n readonly image: string;\n /** The image's size in pixels (`meta.size`). */\n readonly width: number;\n readonly height: number;\n readonly frames: readonly SpriteFrame[];\n /** Frame index by name. If two frames share a name, the first wins. */\n readonly frameByName: ReadonlyMap<string, number>;\n readonly tags: ReadonlyMap<string, SpriteTag>;\n}\n\nconst Count = Schema.Int;\nconst Rect = Schema.Struct({ x: Count, y: Count, w: Count, h: Count });\nconst Size = Schema.Struct({ w: Count, h: Count });\nconst AsepriteFrame = Schema.Struct({\n filename: Schema.optional(Schema.String),\n frame: Rect,\n rotated: Schema.optional(Schema.Boolean),\n spriteSourceSize: Schema.optional(Rect),\n sourceSize: Schema.optional(Size),\n duration: Schema.optional(Schema.Finite),\n});\ntype AsepriteFrame = typeof AsepriteFrame.Type;\nconst AsepriteSheet = Schema.Struct({\n frames: Schema.Union([\n Schema.Array(AsepriteFrame),\n Schema.Record(Schema.String, AsepriteFrame),\n ]),\n meta: Schema.Struct({\n image: Schema.String,\n size: Size,\n frameTags: Schema.optional(\n Schema.Array(\n Schema.Struct({\n name: Schema.String,\n from: Count,\n to: Count,\n direction: Schema.optional(\n Schema.Literals([\n \"forward\",\n \"reverse\",\n \"pingpong\",\n \"pingpong_reverse\",\n ])\n ),\n })\n )\n ),\n }),\n});\nconst decodeAseprite = Schema.decodeUnknownResult(AsepriteSheet);\n\n/** Aseprite's default frame duration, in ms, for exports that leave it out. */\nconst DEFAULT_DURATION_MS = 100;\n\n/**\n * Parses an Aseprite JSON export (frames as an array or a hash) and checks it: every frame\n * inside the image, every tag inside the frames, no two tags with one name. `file` names the\n * JSON in the error. Rotated frames (a TexturePacker option) aren't supported. A tag's\n * `repeat` is ignored: animations loop.\n */\nexport const decodeSpriteSheet = (\n file: string,\n json: unknown\n): Effect.Effect<SpriteSheetData, SpriteSheetError> => {\n const fail = (message: string) =>\n Effect.fail(\n new SpriteSheetError({\n file,\n message: `The sprite sheet ${file} is invalid: ${message}`,\n })\n );\n const decoded = decodeAseprite(json);\n if (Result.isFailure(decoded)) {\n return fail(decoded.failure.message);\n }\n const { meta } = decoded.success;\n if (meta.size.w < 1 || meta.size.h < 1) {\n return fail(`meta.size is ${meta.size.w}x${meta.size.h}`);\n }\n const entries: (readonly [string, AsepriteFrame])[] = Array.isArray(\n decoded.success.frames\n )\n ? decoded.success.frames.map(\n (frame, index) => [frame.filename ?? String(index), frame] as const\n )\n : Object.entries(decoded.success.frames);\n if (entries.length === 0) {\n return fail(\"it has no frames\");\n }\n\n const frames: SpriteFrame[] = [];\n const frameByName = new Map<string, number>();\n for (const [name, frame] of entries) {\n const problem = frameProblem(frame, meta.size);\n if (problem !== undefined) {\n return fail(`frame ${frames.length} (${name}) ${problem}`);\n }\n const { x, y, w, h } = frame.frame;\n const placed = frame.spriteSourceSize ?? { x: 0, y: 0, w, h };\n const source = frame.sourceSize ?? { w, h };\n if (!frameByName.has(name)) {\n frameByName.set(name, frames.length);\n }\n frames.push({\n name,\n x,\n y,\n width: w,\n height: h,\n offsetX: placed.x,\n offsetY: placed.y,\n sourceWidth: source.w,\n sourceHeight: source.h,\n duration: (frame.duration ?? DEFAULT_DURATION_MS) / 1000,\n });\n }\n\n const tags = readTags(meta.frameTags ?? [], frames);\n if (typeof tags === \"string\") {\n return fail(tags);\n }\n\n return Effect.succeed({\n image: meta.image,\n width: meta.size.w,\n height: meta.size.h,\n frames,\n frameByName,\n tags,\n });\n};\n\n/** The tags by name, or what's wrong with them. */\nconst readTags = (\n frameTags: readonly {\n readonly name: string;\n readonly from: number;\n readonly to: number;\n readonly direction?: SpriteTagDirection | undefined;\n }[],\n frames: readonly SpriteFrame[]\n): Map<string, SpriteTag> | string => {\n const tags = new Map<string, SpriteTag>();\n for (const tag of frameTags) {\n if (tags.has(tag.name)) {\n return `two tags are named \"${tag.name}\"`;\n }\n if (tag.from < 0 || tag.from > tag.to || tag.to >= frames.length) {\n return `tag \"${tag.name}\" runs from frame ${tag.from} to ${tag.to}, but the sheet has frames 0 to ${frames.length - 1}`;\n }\n const shape = {\n from: tag.from,\n to: tag.to,\n direction: tag.direction ?? \"forward\",\n };\n let duration = 0;\n for (let step = 0; step < loopLength(shape); step += 1) {\n duration += frames[frameOfStep(shape, step)]?.duration ?? 0;\n }\n tags.set(tag.name, { name: tag.name, ...shape, duration });\n }\n return tags;\n};\n\n/** What's wrong with one frame, or undefined. */\nconst frameProblem = (\n frame: AsepriteFrame,\n image: { readonly w: number; readonly h: number }\n): string | undefined => {\n const { x, y, w, h } = frame.frame;\n if (frame.rotated === true) {\n return \"is rotated, which isn't supported\";\n }\n if (w < 1 || h < 1) {\n return `is ${w}x${h}`;\n }\n if (x < 0 || y < 0 || x + w > image.w || y + h > image.h) {\n return `at ${x},${y} size ${w}x${h} lies outside the ${image.w}x${image.h} image`;\n }\n if (frame.duration !== undefined && frame.duration <= 0) {\n return `lasts ${frame.duration} ms`;\n }\n const placed = frame.spriteSourceSize;\n const source = frame.sourceSize ?? { w, h };\n if (\n placed !== undefined &&\n (placed.x < 0 ||\n placed.y < 0 ||\n placed.x + w > source.w ||\n placed.y + h > source.h)\n ) {\n return `is placed at ${placed.x},${placed.y} outside its ${source.w}x${source.h} source size`;\n }\n return undefined;\n};\n\ntype TagShape = Pick<SpriteTag, \"from\" | \"to\" | \"direction\">;\n\nconst isPingpong = (tag: TagShape) =>\n tag.direction === \"pingpong\" || tag.direction === \"pingpong_reverse\";\n\n/** Frames in one loop: a pingpong over 0..3 plays 0 1 2 3 2 1. */\nconst loopLength = (tag: TagShape): number => {\n const count = tag.to - tag.from + 1;\n return isPingpong(tag) && count > 1 ? 2 * count - 2 : count;\n};\n\n/** The frame index shown at `step` of a loop. */\nconst frameOfStep = (tag: TagShape, step: number): number => {\n const count = tag.to - tag.from + 1;\n const back = step - count + 1;\n switch (tag.direction) {\n case \"forward\": {\n return tag.from + step;\n }\n case \"reverse\": {\n return tag.to - step;\n }\n case \"pingpong\": {\n return step < count ? tag.from + step : tag.to - back;\n }\n case \"pingpong_reverse\": {\n return step < count ? tag.to - step : tag.from + back;\n }\n }\n};\n\n/**\n * The frame index (into `sheet.frames`) a tag shows `seconds` into its animation, looping,\n * from each frame's duration and the tag's direction. Allocates nothing: call it per sprite\n * per frame. Negative times count back from the end of a loop.\n */\nexport const frameAt = (\n sheet: SpriteSheetData,\n tag: SpriteTag,\n seconds: number\n): number => {\n if (tag.from === tag.to || !(tag.duration > 0)) {\n return tag.from;\n }\n let left = seconds % tag.duration;\n if (left < 0) {\n left += tag.duration;\n }\n const steps = loopLength(tag);\n for (let step = 0; step < steps; step += 1) {\n const index = frameOfStep(tag, step);\n left -= sheet.frames[index]?.duration ?? 0;\n if (left < 0) {\n return index;\n }\n }\n // Rounding can leave a hair of time past the last step.\n return frameOfStep(tag, steps - 1);\n};\n","import * as THREE from \"three\";\n\nimport { PixelCamera } from \"./pixel-camera\";\n\n/** Which camera a Stage builds. */\nexport type StageCameraKind = \"perspective\" | \"orthographic\" | \"pixel\";\n\n/** The camera type for a kind: what `Stage.camera` is. */\nexport type StageCamera<K extends StageCameraKind> = K extends \"pixel\"\n ? PixelCamera\n : K extends \"orthographic\"\n ? THREE.OrthographicCamera\n : THREE.PerspectiveCamera;\n\nexport interface StageOptions<K extends StageCameraKind = \"perspective\"> {\n /**\n * \"perspective\" (the default) for a 3D world seen from a point. \"orthographic\" for a flat\n * board, where sizes shouldn't shrink with distance; place it with `fitCamera`. \"pixel\" for\n * pixel art seen straight down (a `PixelCamera`); the stage keeps it told the drawing buffer's size.\n */\n readonly camera?: K;\n /**\n * Height of an orthographic camera's view, in world units, until `fitCamera` changes it.\n * Defaults to 10. For a pixel camera, the height it aims for (defaults to 20).\n */\n readonly viewHeight?: number;\n /** Pixel camera only: sprite pixels per world unit. Defaults to 16. */\n readonly pixelsPerUnit?: number;\n readonly background?: THREE.ColorRepresentation;\n}\n\n/** A perspective camera's field of view, in degrees. Set `stage.camera.fov` to change it. */\nconst FOV = 50;\n/** The near and far planes of a perspective or orthographic camera. */\nconst NEAR = 0.1;\nconst FAR = 400;\n/**\n * Caps devicePixelRatio for a perspective or orthographic stage; 2 keeps phones sharp without\n * rendering 9x the pixels. A pixel stage isn't capped: see `drawingBuffer`.\n */\nconst MAX_PIXEL_RATIO = 2;\n\n/**\n * A renderer, scene and camera that fill a container element and resize with it.\n * The camera is perspective unless `camera: \"orthographic\"` or `\"pixel\"` is passed; on resize, an\n * orthographic camera keeps its view height and widens or narrows to the new aspect ratio, and a\n * pixel camera picks its pixel scale again. The pixel ratio is read again on every resize, and\n * a pixel stage sizes its drawing buffer to the canvas's exact device pixels, where the others\n * cap the ratio at 2 (see `drawingBuffer`). The camera is public: change its fields (a\n * perspective camera's `fov`, `near` and `far`) and call `updateProjectionMatrix()`. So is the\n * scene: set `stage.scene.fog` for fog.\n * `dispose()` frees every GPU resource in the scene, except kept textures (see `keepTexture`),\n * and removes the canvas.\n */\nexport class Stage<K extends StageCameraKind = \"perspective\"> {\n readonly renderer: THREE.WebGLRenderer;\n readonly scene: THREE.Scene = new THREE.Scene();\n readonly camera: StageCamera<K>;\n private readonly container: HTMLElement;\n private readonly resizeObserver: ResizeObserver;\n\n constructor(container: HTMLElement, options: StageOptions<K> = {}) {\n this.container = container;\n const pixel = options.camera === \"pixel\";\n this.renderer = new THREE.WebGLRenderer({\n // A pixel camera's edges are already on pixels, so it needs no multisampling.\n antialias: !pixel,\n powerPreference: \"high-performance\",\n });\n const canvas = this.renderer.domElement;\n canvas.style.display = \"block\";\n canvas.style.width = \"100%\";\n canvas.style.height = \"100%\";\n if (pixel) {\n canvas.style.imageRendering = \"pixelated\";\n }\n container.append(canvas);\n\n if (pixel) {\n this.camera = new PixelCamera({\n ...(options.pixelsPerUnit === undefined\n ? {}\n : { pixelsPerUnit: options.pixelsPerUnit }),\n ...(options.viewHeight === undefined\n ? {}\n : { viewHeight: options.viewHeight }),\n }) as StageCamera<K>;\n } else if (options.camera === \"orthographic\") {\n const half = (options.viewHeight ?? 10) / 2;\n this.camera = new THREE.OrthographicCamera(\n -half,\n half,\n half,\n -half,\n NEAR,\n FAR\n ) as StageCamera<K>;\n } else {\n this.camera = new THREE.PerspectiveCamera(\n FOV,\n 1,\n NEAR,\n FAR\n ) as StageCamera<K>;\n }\n if (options.background !== undefined) {\n this.scene.background = new THREE.Color(options.background);\n }\n\n this.resizeObserver = new ResizeObserver((entries) =>\n this.resize(entries.at(-1))\n );\n try {\n // Also fires when only the pixel ratio changes (the window moved to another screen).\n this.resizeObserver.observe(container, {\n box: \"device-pixel-content-box\",\n });\n } catch {\n this.resizeObserver.observe(container);\n }\n this.resize(undefined);\n }\n\n render(): void {\n this.renderer.render(this.scene, this.camera);\n }\n\n dispose(): void {\n this.resizeObserver.disconnect();\n disposeObject(this.scene);\n this.renderer.dispose();\n this.renderer.domElement.remove();\n }\n\n private resize(entry: ResizeObserverEntry | undefined) {\n const width = Math.max(1, this.container.clientWidth);\n const height = Math.max(1, this.container.clientHeight);\n const camera = this.camera as\n | THREE.PerspectiveCamera\n | THREE.OrthographicCamera;\n const target = drawingBuffer(\n camera instanceof PixelCamera,\n width,\n height,\n window.devicePixelRatio,\n MAX_PIXEL_RATIO,\n entry?.devicePixelContentBoxSize?.[0]\n );\n this.renderer.setPixelRatio(target.pixelRatio);\n this.renderer.setSize(target.width, target.height, false);\n const aspect = width / height;\n if (camera instanceof PixelCamera) {\n const buffer = this.renderer.getDrawingBufferSize(BUFFER_SIZE);\n camera.setBufferSize(buffer.x, buffer.y);\n } else if (camera instanceof THREE.OrthographicCamera) {\n const centerX = (camera.left + camera.right) / 2;\n const halfWidth = ((camera.top - camera.bottom) / 2) * aspect;\n camera.left = centerX - halfWidth;\n camera.right = centerX + halfWidth;\n } else {\n camera.aspect = aspect;\n }\n camera.updateProjectionMatrix();\n }\n}\n\nconst BUFFER_SIZE = new THREE.Vector2();\n\n/**\n * The drawing buffer for a canvas `width` x `height` CSS pixels: the pixel ratio and size to\n * give `renderer.setSize`. A pixel stage never caps the ratio, since a smaller buffer is\n * stretched onto the screen by a fraction and sprite pixels would cover uneven numbers of\n * screen pixels; where the browser reports the canvas's device pixels (`device`), it uses\n * those, as width * ratio can round a pixel off from the browser's size at a fractional ratio.\n */\nexport const drawingBuffer = (\n pixel: boolean,\n width: number,\n height: number,\n devicePixelRatio: number,\n maxPixelRatio: number,\n device?: { readonly inlineSize: number; readonly blockSize: number }\n): { pixelRatio: number; width: number; height: number } => {\n if (!pixel) {\n return {\n pixelRatio: Math.min(devicePixelRatio, maxPixelRatio),\n width,\n height,\n };\n }\n if (device === undefined) {\n return { pixelRatio: devicePixelRatio, width, height };\n }\n return {\n pixelRatio: 1,\n width: Math.max(1, device.inlineSize),\n height: Math.max(1, device.blockSize),\n };\n};\n\nconst KEEP = \"arcadekitKeep\";\n\n/**\n * Marks a texture that something outside the scene owns, such as a sprite sheet the app\n * loaded: `disposeObject` and `Stage.dispose` leave it alone, and its owner disposes it.\n * Returns the texture.\n */\nexport const keepTexture = <T extends THREE.Texture>(texture: T): T => {\n texture.userData[KEEP] = true;\n return texture;\n};\n\n/** Whether `keepTexture` marked this texture. */\nexport const isKeptTexture = (texture: THREE.Texture): boolean =>\n texture.userData[KEEP] === true;\n\n/**\n * Frees the geometries, materials and textures of `root` and everything under it: the textures\n * a material holds (its maps, and a shader material's texture uniforms) and, for a scene, its\n * background and environment. Textures marked with `keepTexture` are skipped.\n * `Stage.dispose` calls it on the whole scene. A view that removes an object from the\n * scene calls it on that object, which `Stage.dispose` no longer reaches.\n */\nexport const disposeObject = (root: THREE.Object3D): void => {\n const textures = new Set<THREE.Texture>();\n if (root instanceof THREE.Scene) {\n addTexture(textures, root.background);\n addTexture(textures, root.environment);\n }\n root.traverse((object) => {\n if (\n object instanceof THREE.Mesh ||\n object instanceof THREE.Line ||\n object instanceof THREE.Points\n ) {\n object.geometry.dispose();\n const materials: THREE.Material[] = Array.isArray(object.material)\n ? object.material\n : [object.material];\n for (const material of materials) {\n for (const value of Object.values(material)) {\n addTexture(textures, value);\n }\n if (material instanceof THREE.ShaderMaterial) {\n for (const uniform of Object.values(material.uniforms)) {\n addTexture(textures, uniform.value);\n }\n }\n material.dispose();\n }\n }\n });\n for (const texture of textures) {\n texture.dispose();\n }\n};\n\nconst addTexture = (textures: Set<THREE.Texture>, value: unknown) => {\n if (value instanceof THREE.Texture && !isKeptTexture(value)) {\n textures.add(value);\n }\n};\n","import { Effect } from \"effect\";\nimport * as THREE from \"three\";\n\nimport { SpriteSheetError, decodeSpriteSheet } from \"./sprite-sheet\";\nimport type { SpriteSheetData } from \"./sprite-sheet\";\nimport { keepTexture } from \"./stage\";\n\n/**\n * A sprite sheet ready to draw: its frames and tags, and its image as a texture.\n * The app loads it and owns it: views draw from it but never free it (`disposeObject` and\n * `Stage.dispose` skip its texture). The app calls `dispose` when it no longer needs it.\n */\nexport interface SpriteSheet {\n readonly data: SpriteSheetData;\n /** Nearest filtering, no mipmaps, sRGB, `flipY` off: row 0 of the image is v = 0. */\n readonly texture: THREE.Texture;\n readonly dispose: () => void;\n}\n\n/**\n * Makes a sheet from parsed data and an image that's already in memory, such as a canvas the\n * art was painted on. `loadSpriteSheet` uses it after fetching both files.\n */\nexport const makeSpriteSheet = (\n data: SpriteSheetData,\n image: TexImageSource\n): SpriteSheet => {\n const texture = new THREE.Texture(image);\n texture.magFilter = THREE.NearestFilter;\n texture.minFilter = THREE.NearestFilter;\n texture.generateMipmaps = false;\n texture.colorSpace = THREE.SRGBColorSpace;\n texture.flipY = false;\n texture.premultiplyAlpha = false;\n texture.name = data.image;\n texture.needsUpdate = true;\n keepTexture(texture);\n return {\n data,\n texture,\n dispose: () => {\n texture.dispose();\n if (typeof ImageBitmap !== \"undefined\" && image instanceof ImageBitmap) {\n image.close();\n }\n },\n };\n};\n\n/**\n * Loads an Aseprite sheet: fetches the JSON at `jsonUrl`, then the image its `meta.image`\n * names, relative to the JSON. Fails with a `SpriteSheetError` naming the file when either\n * can't be fetched, the JSON is invalid, or the image's size isn't `meta.size`.\n */\nexport const loadSpriteSheet = (\n jsonUrl: string\n): Effect.Effect<SpriteSheet, SpriteSheetError> =>\n Effect.gen(function* () {\n const json = yield* fetchFile(\n jsonUrl,\n (response) => response.json() as Promise<unknown>\n );\n const data = yield* decodeSpriteSheet(jsonUrl, json);\n const imageUrl = new URL(\n data.image,\n new URL(jsonUrl, globalThis.location.href)\n ).href;\n const image = yield* fetchFile(imageUrl, async (response) =>\n createImageBitmap(await response.blob(), {\n premultiplyAlpha: \"none\",\n colorSpaceConversion: \"none\",\n })\n );\n const { width, height } = image;\n if (width !== data.width || height !== data.height) {\n image.close();\n return yield* new SpriteSheetError({\n file: imageUrl,\n message: `The sprite sheet image ${imageUrl} is ${width}x${height}, but ${jsonUrl} says ${data.width}x${data.height}`,\n });\n }\n return makeSpriteSheet(data, image);\n });\n\nconst fetchFile = <A>(\n file: string,\n read: (response: Response) => Promise<A>\n): Effect.Effect<A, SpriteSheetError> =>\n Effect.tryPromise({\n try: async (signal) => {\n const response = await fetch(file, { signal });\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}`);\n }\n return await read(response);\n },\n catch: (cause) =>\n new SpriteSheetError({\n file,\n message: `The sprite sheet file ${file} can't be loaded: ${cause instanceof Error ? cause.message : String(cause)}`,\n cause,\n }),\n });\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@arcadekit/render-three",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Three.js helpers: stage, mesh instances, camera rigs, pixel-art sprites",
5
+ "keywords": [
6
+ "arcadekit",
7
+ "effect",
8
+ "game",
9
+ "gamedev",
10
+ "pixel-art",
11
+ "sprites",
12
+ "three",
13
+ "threejs"
14
+ ],
15
+ "homepage": "https://github.com/lucasthevenet/arcadekit/tree/main/packages/render-three#readme",
16
+ "bugs": "https://github.com/lucasthevenet/arcadekit/issues",
17
+ "license": "MIT",
18
+ "author": "Lucas Thevenet",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/lucasthevenet/arcadekit.git",
22
+ "directory": "packages/render-three"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "!src/**/*.test.ts"
28
+ ],
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "exports": {
32
+ ".": {
33
+ "source": "./src/index.ts",
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ }
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "devDependencies": {
42
+ "@types/three": "^0.187.0",
43
+ "effect": "~4.1.2",
44
+ "three": "^0.187.0"
45
+ },
46
+ "peerDependencies": {
47
+ "@types/three": "^0.187.0",
48
+ "effect": "~4.1.2",
49
+ "three": "^0.187.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@types/three": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "engines": {
57
+ "node": ">=22"
58
+ },
59
+ "scripts": {
60
+ "build": "tsdown",
61
+ "typecheck": "tsc -p ."
62
+ }
6
63
  }
package/src/camera.ts ADDED
@@ -0,0 +1,104 @@
1
+ // Every helper here works on the ground plane (y = 0, Y up).
2
+ // A side-view genre would need its own.
3
+
4
+ import type * as THREE from "three";
5
+
6
+ /** Keeps the camera at a fixed offset from a target on the ground plane, looking at it. Top-down and isometric games. */
7
+ export const followTarget = (
8
+ camera: THREE.Camera,
9
+ target: { readonly x: number; readonly z: number },
10
+ offset: { readonly x: number; readonly y: number; readonly z: number }
11
+ ): void => {
12
+ camera.position.set(target.x + offset.x, offset.y, target.z + offset.z);
13
+ camera.lookAt(target.x, 0, target.z);
14
+ };
15
+
16
+ /**
17
+ * Circles the camera around a point on the ground plane at a fixed height, looking at it.
18
+ * `angle` is in radians; 0 puts the camera on the point's +z side. Menu backdrops and
19
+ * showcase views.
20
+ */
21
+ export const orbitAround = (
22
+ camera: THREE.Camera,
23
+ center: { readonly x: number; readonly z: number },
24
+ options: {
25
+ readonly radius: number;
26
+ readonly height: number;
27
+ readonly angle: number;
28
+ }
29
+ ): void => {
30
+ camera.position.set(
31
+ center.x + Math.sin(options.angle) * options.radius,
32
+ options.height,
33
+ center.z + Math.cos(options.angle) * options.radius
34
+ );
35
+ camera.lookAt(center.x, 0, center.z);
36
+ };
37
+
38
+ /** A rectangle on the ground plane (y = 0), in world units. */
39
+ export interface GroundBounds {
40
+ readonly minX: number;
41
+ readonly maxX: number;
42
+ readonly minZ: number;
43
+ readonly maxZ: number;
44
+ }
45
+
46
+ export interface FitCameraOptions {
47
+ /** World units of space left around the bounds on every side. Defaults to 0. */
48
+ readonly margin?: number;
49
+ /**
50
+ * How far the camera sits back (toward +z) from straight overhead, as a fraction of its
51
+ * height. 0 looks straight down. Defaults to 0.
52
+ */
53
+ readonly tilt?: number;
54
+ }
55
+
56
+ /**
57
+ * Places a camera above a rectangle on the ground plane, looking at its center, so that all of
58
+ * it shows at the camera's current aspect ratio. Board, puzzle and arena games. Call it every
59
+ * frame (it allocates nothing), or at least after each resize.
60
+ *
61
+ * A perspective camera keeps its field of view and moves back far enough. An orthographic
62
+ * camera (`Stage` with `camera: "orthographic"`) changes its frustum to fit instead.
63
+ * With a tilt, the fit is still worked out as if looking straight down. That leaves an
64
+ * orthographic camera a little extra room. It does not hold for a perspective camera: a small
65
+ * tilt (below the tangent of half its field of view) can push the near edge of the bounds off
66
+ * the screen, so give it more margin.
67
+ */
68
+ export const fitCamera = (
69
+ camera: THREE.PerspectiveCamera | THREE.OrthographicCamera,
70
+ bounds: GroundBounds,
71
+ options: FitCameraOptions = {}
72
+ ): void => {
73
+ const margin = options.margin ?? 0;
74
+ const tilt = options.tilt ?? 0;
75
+ const centerX = (bounds.minX + bounds.maxX) / 2;
76
+ const centerZ = (bounds.minZ + bounds.maxZ) / 2;
77
+ const halfWidth = (bounds.maxX - bounds.minX) / 2 + margin;
78
+ const halfDepth = (bounds.maxZ - bounds.minZ) / 2 + margin;
79
+ let height: number;
80
+ if (isPerspective(camera)) {
81
+ const halfTan = Math.tan((camera.fov * Math.PI) / 360);
82
+ height = Math.max(
83
+ halfDepth / halfTan,
84
+ halfWidth / (halfTan * camera.aspect)
85
+ );
86
+ } else {
87
+ const aspect = (camera.right - camera.left) / (camera.top - camera.bottom);
88
+ const halfHeight = Math.max(halfDepth, halfWidth / aspect);
89
+ camera.top = halfHeight;
90
+ camera.bottom = -halfHeight;
91
+ camera.left = -halfHeight * aspect;
92
+ camera.right = halfHeight * aspect;
93
+ camera.updateProjectionMatrix();
94
+ // Any height works for an orthographic camera; this one keeps the bounds well inside near/far.
95
+ height = 4 * Math.max(halfWidth, halfDepth, 1);
96
+ }
97
+ camera.position.set(centerX, height, centerZ + height * tilt);
98
+ camera.lookAt(centerX, 0, centerZ);
99
+ };
100
+
101
+ const isPerspective = (
102
+ camera: THREE.PerspectiveCamera | THREE.OrthographicCamera
103
+ ): camera is THREE.PerspectiveCamera =>
104
+ (camera as THREE.PerspectiveCamera).isPerspectiveCamera === true;
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ export {
2
+ type FitCameraOptions,
3
+ type GroundBounds,
4
+ fitCamera,
5
+ followTarget,
6
+ orbitAround,
7
+ } from "./camera";
8
+ export {
9
+ InstancedSprites,
10
+ type InstancedSpritesOptions,
11
+ } from "./instanced-sprites";
12
+ export { MeshInstances } from "./mesh-instances";
13
+ export {
14
+ PixelCamera,
15
+ type PixelCameraOptions,
16
+ fitPixelScale,
17
+ pixelFrustum,
18
+ pixelScale,
19
+ snapToPixel,
20
+ } from "./pixel-camera";
21
+ export {
22
+ type SpriteSheet,
23
+ loadSpriteSheet,
24
+ makeSpriteSheet,
25
+ } from "./sprite-loader";
26
+ export {
27
+ type SpriteFrame,
28
+ type SpriteSheetData,
29
+ SpriteSheetError,
30
+ type SpriteTag,
31
+ type SpriteTagDirection,
32
+ decodeSpriteSheet,
33
+ frameAt,
34
+ } from "./sprite-sheet";
35
+ export {
36
+ Stage,
37
+ type StageCamera,
38
+ type StageCameraKind,
39
+ type StageOptions,
40
+ disposeObject,
41
+ isKeptTexture,
42
+ keepTexture,
43
+ } from "./stage";