Yufok1 Claude Fable 5 commited on
Commit ·
f7cebb4
1
Parent(s): e43bb65
Sprite atlas: all art ships as one 354KB fetch
Browse filesEach 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 +3 -0
- Dockerfile +1 -0
- package.json +1 -1
- src/client/game.tsx +51 -21
- src/client/module.d.ts +5 -0
- tools/build-sprite-atlas.mjs +27 -0
.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 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
|
|
|
| 482 |
}
|
| 483 |
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
const
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}`);
|