Custom GLBs drop into 14 named slots without code changes; an empty manifest
produces today's game by construction (the fallback builders are the original
code moved into a closure, and an empty registry returns the caller's own
object by identity).
- src/assets/{slots,manifest,idb,registry,blobBody}.ts — schema + validation,
GLTF cache with per-instance material cloning, fit nodes, IndexedDB override
layer, paintability report.
- Slot hooks in createBlob, parts, cannon, greybox, puddles, ghost. Mesh-only:
no collider, physics or logic line is touched. Animated sub-parts (plate cap,
belt chevrons, fan blades, cannon pivot) stay procedural so a custom model
cannot stop them moving.
- public/assets/ — the build had NO asset copy step at all, so every asset URL
would have 404'd in production. public/ is vite's default publicDir, so this
needs no vite.config change.
- Tests: 63 headless checks + a farm-GLB audit that fires PaintSkin's own
raycast against the fitted body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
124 lines
4.0 KiB
TypeScript
124 lines
4.0 KiB
TypeScript
/**
|
|
* Lane I — local override store (`blobbo-workshop`).
|
|
*
|
|
* Why IndexedDB and not a server: John must be able to try a custom asset on
|
|
* the LIVE site with zero deploys. The editor writes a manifest + the raw .glb
|
|
* bytes here; the game reads them at boot and they win over the shipped pack.
|
|
*
|
|
* Two stores:
|
|
* `manifest` — single record under key 'current', a Manifest object.
|
|
* `blobs` — key -> ArrayBuffer of a .glb, addressed as `idb:<key>` urls.
|
|
*
|
|
* Every function resolves rather than rejects when IndexedDB is unavailable
|
|
* (private mode, old browser, node): no override is a normal state, not an error.
|
|
*/
|
|
import { validateManifest, type Manifest } from './manifest'
|
|
|
|
export const DB_NAME = 'blobbo-workshop'
|
|
export const DB_VERSION = 1
|
|
export const STORE_MANIFEST = 'manifest'
|
|
export const STORE_BLOBS = 'blobs'
|
|
const MANIFEST_KEY = 'current'
|
|
|
|
export const IDB_URL_PREFIX = 'idb:'
|
|
|
|
export function isIdbUrl(url: string): boolean {
|
|
return url.startsWith(IDB_URL_PREFIX)
|
|
}
|
|
|
|
export function idbKeyFromUrl(url: string): string {
|
|
return url.slice(IDB_URL_PREFIX.length)
|
|
}
|
|
|
|
function openDb(): Promise<IDBDatabase | null> {
|
|
return new Promise((resolve) => {
|
|
if (typeof indexedDB === 'undefined') return resolve(null)
|
|
let req: IDBOpenDBRequest
|
|
try {
|
|
req = indexedDB.open(DB_NAME, DB_VERSION)
|
|
} catch {
|
|
return resolve(null)
|
|
}
|
|
req.onupgradeneeded = () => {
|
|
const db = req.result
|
|
if (!db.objectStoreNames.contains(STORE_MANIFEST)) db.createObjectStore(STORE_MANIFEST)
|
|
if (!db.objectStoreNames.contains(STORE_BLOBS)) db.createObjectStore(STORE_BLOBS)
|
|
}
|
|
req.onsuccess = () => resolve(req.result)
|
|
req.onerror = () => resolve(null)
|
|
req.onblocked = () => resolve(null)
|
|
})
|
|
}
|
|
|
|
function request<T>(store: IDBObjectStore, run: (s: IDBObjectStore) => IDBRequest): Promise<T | null> {
|
|
return new Promise((resolve) => {
|
|
let req: IDBRequest
|
|
try {
|
|
req = run(store)
|
|
} catch {
|
|
return resolve(null)
|
|
}
|
|
req.onsuccess = () => resolve(req.result as T)
|
|
req.onerror = () => resolve(null)
|
|
})
|
|
}
|
|
|
|
async function withStore<T>(
|
|
name: string,
|
|
mode: IDBTransactionMode,
|
|
run: (s: IDBObjectStore) => IDBRequest,
|
|
): Promise<T | null> {
|
|
const db = await openDb()
|
|
if (!db) return null
|
|
try {
|
|
const tx = db.transaction(name, mode)
|
|
const out = await request<T>(tx.objectStore(name), run)
|
|
return out
|
|
} catch {
|
|
return null
|
|
} finally {
|
|
db.close()
|
|
}
|
|
}
|
|
|
|
/** The editor's saved pack, or `{}` when there is none. Never throws. */
|
|
export async function loadOverrideManifest(): Promise<Manifest> {
|
|
const raw = await withStore<unknown>(STORE_MANIFEST, 'readonly', (s) => s.get(MANIFEST_KEY))
|
|
if (raw === null || raw === undefined) return {}
|
|
const { manifest, problems } = validateManifest(raw)
|
|
if (problems.length) console.warn('[assets] local override manifest problems:', problems)
|
|
return manifest
|
|
}
|
|
|
|
export async function saveOverrideManifest(manifest: Manifest): Promise<boolean> {
|
|
const out = await withStore<IDBValidKey>(STORE_MANIFEST, 'readwrite', (s) =>
|
|
s.put(manifest, MANIFEST_KEY),
|
|
)
|
|
return out !== null
|
|
}
|
|
|
|
export async function clearOverrides(): Promise<void> {
|
|
await withStore(STORE_MANIFEST, 'readwrite', (s) => s.clear())
|
|
await withStore(STORE_BLOBS, 'readwrite', (s) => s.clear())
|
|
}
|
|
|
|
export async function putBlob(key: string, data: ArrayBuffer): Promise<boolean> {
|
|
const out = await withStore<IDBValidKey>(STORE_BLOBS, 'readwrite', (s) => s.put(data, key))
|
|
return out !== null
|
|
}
|
|
|
|
export async function getBlob(key: string): Promise<ArrayBuffer | null> {
|
|
return withStore<ArrayBuffer>(STORE_BLOBS, 'readonly', (s) => s.get(key))
|
|
}
|
|
|
|
export async function listBlobKeys(): Promise<string[]> {
|
|
const keys = await withStore<IDBValidKey[]>(STORE_BLOBS, 'readonly', (s) => s.getAllKeys())
|
|
return (keys ?? []).map(String)
|
|
}
|
|
|
|
/** True when a local pack is active — the game shows a "custom assets" pill. */
|
|
export async function hasOverrides(): Promise<boolean> {
|
|
const m = await loadOverrideManifest()
|
|
return Object.keys(m).length > 0
|
|
}
|