Yufok1 Claude Fable 5 commited on
Commit
f7cebb4
·
1 Parent(s): e43bb65

Sprite atlas: all art ships as one 354KB fetch

Browse files

Each rendered plant was fetching 12 files (base + 11 masks) at 0.4-1.5s
per request on the free tier - a cold garden load fired ~250 requests.
Build now packs all 542 images into a data-URI atlas emitted as a
hashed immutable asset; the client resolves art from it with in-memory
memoization of images, masks, and in-flight loads. Falls back to
network paths if the atlas is missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

.gitignore CHANGED
@@ -38,4 +38,7 @@ starter_profiles.json
38
  # Local dev game state (production state lives in the mounted HF Storage Bucket)
39
  /data/
40
 
 
 
 
41
 
 
38
  # Local dev game state (production state lives in the mounted HF Storage Bucket)
39
  /data/
40
 
41
+ # Generated at build time by tools/build-sprite-atlas.mjs
42
+ src/client/spriteAtlas.json
43
+
44
 
Dockerfile CHANGED
@@ -5,6 +5,7 @@ WORKDIR /app
5
  COPY package.json package-lock.json ./
6
  RUN npm ci
7
  COPY vite.config.ts ./
 
8
  COPY src ./src
9
  COPY public ./public
10
  RUN npm run build
 
5
  COPY package.json package-lock.json ./
6
  RUN npm ci
7
  COPY vite.config.ts ./
8
+ COPY tools ./tools
9
  COPY src ./src
10
  COPY public ./public
11
  RUN npm run build
package.json CHANGED
@@ -5,7 +5,7 @@
5
  "license": "BSD-3-Clause",
6
  "type": "module",
7
  "scripts": {
8
- "build": "vite build",
9
  "dev": "tsx watch src/server/index.ts",
10
  "dev:client": "vite build --watch",
11
  "start": "tsx src/server/index.ts",
 
5
  "license": "BSD-3-Clause",
6
  "type": "module",
7
  "scripts": {
8
+ "build": "node tools/build-sprite-atlas.mjs && vite build",
9
  "dev": "tsx watch src/server/index.ts",
10
  "dev:client": "vite build --watch",
11
  "start": "tsx src/server/index.ts",
src/client/game.tsx CHANGED
@@ -3,6 +3,7 @@ import './index.css';
3
  import { MatrixRainFxPanel, MatrixRainFxProvider } from './MatrixRainFx';
4
 
5
  import { bootTelegram, tgHeaders } from './telegram';
 
6
  import { StrictMode, useEffect, useMemo, useRef, useState } from 'react';
7
  import type { CSSProperties } from 'react';
8
  import { createRoot } from 'react-dom/client';
@@ -472,29 +473,58 @@ function paletteKey(colors: Rgb[]): string {
472
  return colors.map((color) => color.join('-')).join('_');
473
  }
474
 
475
- function loadSpriteImage(src: string): Promise<HTMLImageElement> {
476
- return new Promise((resolve, reject) => {
477
- const image = new Image();
478
- image.onload = () => resolve(image);
479
- image.onerror = () => reject(new Error(`Could not load ${src}`));
480
- image.src = src;
481
- });
 
482
  }
483
 
484
- async function loadMaskData(src: string, width: number, height: number): Promise<Uint8ClampedArray> {
485
- const image = await loadSpriteImage(src);
486
- const canvas = document.createElement('canvas');
487
- canvas.width = width;
488
- canvas.height = height;
489
- const context = canvas.getContext('2d', { willReadFrequently: true });
490
- if (!context) throw new Error('Mask canvas unavailable');
491
- context.drawImage(image, 0, 0, width, height);
492
- const pixels = context.getImageData(0, 0, width, height).data;
493
- const mask = new Uint8ClampedArray(width * height);
494
- for (let offset = 0, index = 0; offset < pixels.length; offset += 4, index += 1) {
495
- mask[index] = pixels[offset] ?? 0;
496
- }
497
- return mask;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  }
499
 
500
  function strongestLayer(layers: Uint8ClampedArray[], pixel: number): [number, number] {
 
3
  import { MatrixRainFxPanel, MatrixRainFxProvider } from './MatrixRainFx';
4
 
5
  import { bootTelegram, tgHeaders } from './telegram';
6
+ import atlasUrl from './spriteAtlas.json?url';
7
  import { StrictMode, useEffect, useMemo, useRef, useState } from 'react';
8
  import type { CSSProperties } from 'react';
9
  import { createRoot } from 'react-dom/client';
 
473
  return colors.map((color) => color.join('-')).join('_');
474
  }
475
 
476
+ // All art ships as one atlas of data URIs (built by tools/build-sprite-atlas.mjs)
477
+ // so a garden render costs one cached fetch instead of ~12 requests per plant.
478
+ let spriteAtlasPromise: Promise<Record<string, string>> | null = null;
479
+ function loadSpriteAtlas(): Promise<Record<string, string>> {
480
+ spriteAtlasPromise ??= fetch(atlasUrl)
481
+ .then((response) => (response.ok ? (response.json() as Promise<Record<string, string>>) : {}))
482
+ .catch(() => ({}));
483
+ return spriteAtlasPromise;
484
  }
485
 
486
+ const spriteImageCache = new Map<string, Promise<HTMLImageElement>>();
487
+ function loadSpriteImage(src: string): Promise<HTMLImageElement> {
488
+ const cached = spriteImageCache.get(src);
489
+ if (cached) return cached;
490
+ const promise = (async () => {
491
+ const atlas = await loadSpriteAtlas();
492
+ const resolved = atlas[src] ?? src;
493
+ return await new Promise<HTMLImageElement>((resolve, reject) => {
494
+ const image = new Image();
495
+ image.onload = () => resolve(image);
496
+ image.onerror = () => reject(new Error(`Could not load ${src}`));
497
+ image.src = resolved;
498
+ });
499
+ })();
500
+ spriteImageCache.set(src, promise);
501
+ promise.catch(() => spriteImageCache.delete(src));
502
+ return promise;
503
+ }
504
+
505
+ const maskDataCache = new Map<string, Promise<Uint8ClampedArray>>();
506
+ function loadMaskData(src: string, width: number, height: number): Promise<Uint8ClampedArray> {
507
+ const key = `${src}|${width}x${height}`;
508
+ const cached = maskDataCache.get(key);
509
+ if (cached) return cached;
510
+ const promise = (async () => {
511
+ const image = await loadSpriteImage(src);
512
+ const canvas = document.createElement('canvas');
513
+ canvas.width = width;
514
+ canvas.height = height;
515
+ const context = canvas.getContext('2d', { willReadFrequently: true });
516
+ if (!context) throw new Error('Mask canvas unavailable');
517
+ context.drawImage(image, 0, 0, width, height);
518
+ const pixels = context.getImageData(0, 0, width, height).data;
519
+ const mask = new Uint8ClampedArray(width * height);
520
+ for (let offset = 0, index = 0; offset < pixels.length; offset += 4, index += 1) {
521
+ mask[index] = pixels[offset] ?? 0;
522
+ }
523
+ return mask;
524
+ })();
525
+ maskDataCache.set(key, promise);
526
+ promise.catch(() => maskDataCache.delete(key));
527
+ return promise;
528
  }
529
 
530
  function strongestLayer(layers: Uint8ClampedArray[], pixel: number): [number, number] {
src/client/module.d.ts CHANGED
@@ -1,3 +1,8 @@
 
 
 
 
 
1
  declare module '*.png' {
2
  const content: string;
3
  export default content;
 
1
+ declare module '*.json?url' {
2
+ const url: string;
3
+ export default url;
4
+ }
5
+
6
  declare module '*.png' {
7
  const content: string;
8
  export default content;
tools/build-sprite-atlas.mjs ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Packs all sprite art and masks into one JSON atlas of data URIs, so the
2
+ // client fetches a single cached file instead of ~540 tiny PNGs (12 requests
3
+ // per rendered plant against a slow free-tier host).
4
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ const roots = [
8
+ ['public/assets_px', '/assets_px'],
9
+ ['public/assets_masks', '/assets_masks'],
10
+ ];
11
+ const atlas = {};
12
+
13
+ function walk(dir, urlBase) {
14
+ for (const name of readdirSync(dir)) {
15
+ const path = join(dir, name);
16
+ if (statSync(path).isDirectory()) {
17
+ walk(path, `${urlBase}/${name}`);
18
+ } else if (name.endsWith('.png')) {
19
+ atlas[`${urlBase}/${name}`] = `data:image/png;base64,${readFileSync(path).toString('base64')}`;
20
+ }
21
+ }
22
+ }
23
+
24
+ for (const [dir, base] of roots) walk(dir, base);
25
+ const out = 'src/client/spriteAtlas.json';
26
+ writeFileSync(out, JSON.stringify(atlas));
27
+ console.log(`sprite atlas: ${Object.keys(atlas).length} images, ${(statSync(out).size / 1024).toFixed(0)} KB -> ${out}`);