PURPOSE

Per-hull asset loader for the v4 ships pipeline. Loads an unlit diffuse PNG plus an optional _points.json bundle for each hull and exposes them to the renderer through a small synchronous cache. Assets are authored by the sprite workbench and exported to public/ships-v4/ via the dev export endpoint; the runtime loads them by hull class and renders them through the shared sprite batch.

OWNS

  • The module-level v4 asset cache (_assetCache: hull name ShipV4Assets).
  • The in-flight request map (_pending) that collapses concurrent preload calls for the same hull into one fetch.
  • The memoized manifest promise (_manifest) that lists every exported v4 ship.
  • The ShipV4Assets and PointsJson runtime types and the ShipV4ManifestEntry shape.
  • Path construction for diffuse and points URLs (including encodeURIComponent of the hull key).

READS FROM

  • public/ships-v4/<hull>.png — unlit diffuse sprite per hull (HTMLImageElement load).
  • public/ships-v4/<hull>_points.json — per-hull points-of-interest JSON, parsed as PointsJson.
  • public/ships-v4/manifest.json — prebuilt manifest of exported hulls ({ entries: ShipV4ManifestEntry[] }), tried first by loadShipsV4Manifest.
  • /__dev/ships-v4-manifest — dev-server middleware fallback used when the prebuilt manifest is absent.

PUSHES TO

  • The render path, which calls hasShipV4 to branch between legacy and v4 draw, then reads bundles through getShipV4.
  • UI image tags, which call getShipV4SpritePath(hull) to get a public URL for an <img> source.
  • Boot warm-up, which calls preloadAllShipV4() so the first render of any hull does not pop in.

DOES NOT

  • Mutate game state. preloadShipV4 is pure asset I/O.
  • Throw on missing assets. Missing files resolve to null; consumers must handle the null case directly.
  • Normalize, lower-case, or otherwise transform hull keys. Keys are the exact filename without extension, case-sensitive, with spaces preserved (e.g. "Big Bertha", not "big bertha"), because Vite static serving is case-sensitive in prod.
  • Provide a legacy /ships/ fallback. The legacy player loader was removed.
  • Decode or use the contents of the points map; the schema is reserved (Record<string, never>) and only the version and sprite dimensions are typed.
  • Render anything itself. Drawing belongs to the sprite batch and renderer modules.

Signals

None. The loader has no event bus, callback list, or subscriber API. Consumers poll synchronously via getShipV4 / hasShipV4 after awaiting (or fire-and-forgetting) preloadShipV4.

Entry points

  • preloadShipV4(hull) — start loading a hull’s diffuse and points in parallel; idempotent and concurrency-safe. Returns the cached bundle, the in-flight promise, or a fresh fetch promise. Resolves to null if the diffuse 404s.
  • preloadAllShipV4() — resolve the manifest then preload every listed hull. Used at boot. Resolves once every hull has either loaded or failed.
  • loadShipsV4Manifest() — fetch the manifest, prebuilt first then dev middleware, and memoize the result. Returns [] if neither source is reachable.
  • getShipV4(hull) — synchronous accessor for an already-loaded bundle. Returns null if not loaded or never existed.
  • hasShipV4(hull) — cheap synchronous presence check used by the render-path branch.
  • getShipV4SpritePath(hull) — public URL string for the hull’s diffuse sprite; used by UI <img> tags.
  • clearShipV4Cache() — clear asset cache, pending map, and memoized manifest. Tests and hot-reload only.

Pattern notes

  • Cache + in-flight map is the standard duplicate-request collapser: check _assetCache first, then _pending, otherwise start the fetch, register it in _pending, and remove it in a finally block once settled.
  • loadImage and loadJson both swallow errors and resolve to null rather than throwing, matching the contract that missing assets are not exceptional.
  • A bundle is committed to _assetCache only when the diffuse loaded. The points JSON is allowed to be null independently, so a hull with a diffuse but no points still caches as a valid ShipV4Assets.
  • The manifest loader chains two try blocks rather than using Promise.any, so the dev endpoint is only hit when the prebuilt fetch throws or returns a non-ok response.
  • All URL construction goes through encodeURIComponent on the hull key so spaces and punctuation in authored hull names survive as valid URLs.
  • Constants (ASSET_ROOT, DIFFUSE_EXT, POINTS_SUFFIX, MANIFEST_URL, DEV_MANIFEST_URL) are module-private; consumers go through the exported functions.