Step 16.5: assets: sprites and belt rocks
CI / check (push) Has been cancelled
Details
The art script slices belt-asteroids.png into its six separate rocks (connected regions of visible pixels, each cut out on its own) and exports them at 64 and 128 px; an optional belt-haze.png strip is supported. The source images are only read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -1520,3 +1520,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
## Step 16.4
|
||||
|
||||
- **No save migration for new locations**: CR-016 §9 asks for one that adds the three places as undiscovered, but the save (since CR-014) stores only discovered places, so there is nothing to add. Bumping the save version for no data change would only make older builds refuse newer saves for no reason; the test shows an older save opens correctly instead.
|
||||
|
||||
## Step 16.5
|
||||
|
||||
- **The manifest declares how many rocks the sheet holds** (`pieces: 6`), and the script checks it: the loader needs to know the file names without reading the folder, and a changed sheet then fails loudly instead of silently loading the wrong set.
|
||||
- **Slicing uses alpha ≥ 16 and drops everything else around a rock**: the user's rocks have a soft glow that reaches toward their neighbours; below 16/255 it is invisible on the map, and keeping it would put slivers of other rocks' glow in each sprite.
|
||||
- **Rocks are exported at 64 and 128 px** (the CR's sizes), named `belt-rocks-<n>@1x/@2x`, in reading order of the sheet.
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
|
@ -8,13 +8,22 @@
|
|||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { sliceSheet } from "./slice.mjs";
|
||||
|
||||
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const manifest = JSON.parse(readFileSync(join(webRoot, "src/map/art/manifest.json"), "utf8"));
|
||||
|
||||
/** Long-side sizes in px for the 1x and 2x variants of each sprite kind. */
|
||||
export const SPRITE_SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256] };
|
||||
export const SPRITE_SIZES = {
|
||||
body: [256, 512],
|
||||
badge: [256, 512],
|
||||
ship: [128, 256],
|
||||
// Each rock sliced from a sheet (CR-016 §5).
|
||||
rocks: [64, 128],
|
||||
};
|
||||
export const BACKGROUND_MAX = 2048;
|
||||
/** A seamless strip (the belt's haze, CR-016 §5) is kept within this size. */
|
||||
export const STRIP_MAX = { width: 4096, height: 256 };
|
||||
export const DEFAULT_OUT_DIR = join(webRoot, "public/assets/map-gen");
|
||||
|
||||
/** Every file the script writes for the manifest, relative to the output folder. */
|
||||
|
|
@ -23,7 +32,14 @@ export function expectedOutputs() {
|
|||
for (const [id, asset] of Object.entries(manifest.assets)) {
|
||||
if (asset.file === null) continue;
|
||||
if (asset.kind === "background") outputs.push({ id, file: `${id}.webp`, kind: asset.kind });
|
||||
else {
|
||||
else if (asset.kind === "strip") outputs.push({ id, file: `${id}.png`, kind: asset.kind });
|
||||
else if (asset.kind === "rocks") {
|
||||
const [one, two] = SPRITE_SIZES.rocks;
|
||||
for (let i = 1; i <= asset.pieces; i++) {
|
||||
outputs.push({ id, file: `${id}-${i}@1x.png`, kind: asset.kind, longSide: one });
|
||||
outputs.push({ id, file: `${id}-${i}@2x.png`, kind: asset.kind, longSide: two });
|
||||
}
|
||||
} else {
|
||||
const [one, two] = SPRITE_SIZES[asset.kind];
|
||||
outputs.push({ id, file: `${id}@1x.png`, kind: asset.kind, longSide: one });
|
||||
outputs.push({ id, file: `${id}@2x.png`, kind: asset.kind, longSide: two });
|
||||
|
|
@ -54,18 +70,42 @@ async function generate(sharp, outDir) {
|
|||
.toFile(join(outDir, `${id}.webp`));
|
||||
continue;
|
||||
}
|
||||
if (asset.kind === "strip") {
|
||||
await sharp(source)
|
||||
.resize({ ...STRIP_MAX, fit: "inside", withoutEnlargement: true })
|
||||
.png(png)
|
||||
.toFile(join(outDir, `${id}.png`));
|
||||
continue;
|
||||
}
|
||||
if (asset.kind === "rocks") {
|
||||
// A sheet of separate rocks: one sprite per rock (CR-016 §5).
|
||||
const pieces = await sliceSheet(sharp, source);
|
||||
if (pieces.length !== asset.pieces) {
|
||||
throw new Error(
|
||||
`map-art: ${asset.file} has ${pieces.length} separate sprites; the manifest says ${asset.pieces}`,
|
||||
);
|
||||
}
|
||||
for (const [i, piece] of pieces.entries()) {
|
||||
await writeSizes(sharp, piece, SPRITE_SIZES.rocks, join(outDir, `${id}-${i + 1}`));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Trim transparent padding once, then export each size from the trimmed image.
|
||||
const trimmed = await sharp(source).trim().png().toBuffer();
|
||||
const [one, two] = SPRITE_SIZES[asset.kind];
|
||||
for (const [suffix, size] of [
|
||||
["1x", one],
|
||||
["2x", two],
|
||||
]) {
|
||||
await sharp(trimmed)
|
||||
.resize({ width: size, height: size, fit: "inside" })
|
||||
.png(png)
|
||||
.toFile(join(outDir, `${id}@${suffix}.png`));
|
||||
}
|
||||
await writeSizes(sharp, trimmed, SPRITE_SIZES[asset.kind], join(outDir, id));
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes `<base>@1x.png` and `<base>@2x.png` with the given long sides. */
|
||||
async function writeSizes(sharp, image, [one, two], base) {
|
||||
for (const [suffix, size] of [
|
||||
["1x", one],
|
||||
["2x", two],
|
||||
]) {
|
||||
await sharp(image)
|
||||
.resize({ width: size, height: size, fit: "inside" })
|
||||
.png(png)
|
||||
.toFile(`${base}@${suffix}.png`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
// Splits a sheet of separate sprites (e.g. the belt's rocks, CR-016) into one
|
||||
// image per sprite, by connected regions of visible pixels. Read-only on the source.
|
||||
|
||||
/** Pixels at least this opaque belong to a sprite; fainter glow is dropped. */
|
||||
export const MIN_ALPHA = 16;
|
||||
/** Regions smaller than this share of the sheet are stray specks, not sprites. */
|
||||
export const MIN_AREA_SHARE = 0.002;
|
||||
|
||||
/**
|
||||
* Labels the connected regions (4-neighbour) of pixels with alpha ≥ MIN_ALPHA.
|
||||
* Returns the label per pixel (0 = none) and each kept region's bounding box,
|
||||
* in reading order (by row of their top edge, then left to right).
|
||||
*/
|
||||
export function findRegions(alpha, width, height) {
|
||||
const labels = new Int32Array(width * height);
|
||||
const minArea = Math.max(1, Math.floor(width * height * MIN_AREA_SHARE));
|
||||
const regions = [];
|
||||
let next = 1;
|
||||
for (let start = 0; start < width * height; start++) {
|
||||
if (labels[start] !== 0 || alpha[start] < MIN_ALPHA) continue;
|
||||
const label = next++;
|
||||
let area = 0;
|
||||
let [left, top, right, bottom] = [width, height, -1, -1];
|
||||
const stack = [start];
|
||||
labels[start] = label;
|
||||
while (stack.length > 0) {
|
||||
const p = stack.pop();
|
||||
area++;
|
||||
const x = p % width;
|
||||
const y = (p - x) / width;
|
||||
if (x < left) left = x;
|
||||
if (x > right) right = x;
|
||||
if (y < top) top = y;
|
||||
if (y > bottom) bottom = y;
|
||||
for (const q of [p - 1, p + 1, p - width, p + width]) {
|
||||
if (q < 0 || q >= width * height) continue;
|
||||
if ((q === p - 1 || q === p + 1) && Math.floor(q / width) !== y) continue;
|
||||
if (labels[q] === 0 && alpha[q] >= MIN_ALPHA) {
|
||||
labels[q] = label;
|
||||
stack.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (area >= minArea) {
|
||||
regions.push({ label, left, top, width: right - left + 1, height: bottom - top + 1, area });
|
||||
}
|
||||
}
|
||||
// Reading order: rows of sprites (tops within half the smallest sprite's height), then x.
|
||||
const rowTolerance = Math.max(1, Math.min(...regions.map((r) => r.height)) / 2);
|
||||
regions.sort((a, b) =>
|
||||
Math.abs(a.top - b.top) <= rowTolerance ? a.left - b.left : a.top - b.top,
|
||||
);
|
||||
return { labels, regions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts each sprite out of the sheet as its own trimmed PNG buffer (RGBA): the
|
||||
* region's bounding box, with every pixel that isn't part of that region made
|
||||
* fully transparent (so a neighbour's glow never comes along).
|
||||
*/
|
||||
export async function sliceSheet(sharp, source) {
|
||||
const { data, info } = await sharp(source)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const { width, height } = info;
|
||||
const alpha = new Uint8Array(width * height);
|
||||
for (let i = 0; i < width * height; i++) alpha[i] = data[i * 4 + 3];
|
||||
const { labels, regions } = findRegions(alpha, width, height);
|
||||
const pieces = [];
|
||||
for (const region of regions) {
|
||||
const out = Buffer.alloc(region.width * region.height * 4);
|
||||
for (let y = 0; y < region.height; y++) {
|
||||
for (let x = 0; x < region.width; x++) {
|
||||
const src = (region.top + y) * width + region.left + x;
|
||||
const dst = (y * region.width + x) * 4;
|
||||
if (labels[src] !== region.label) continue;
|
||||
data.copy(out, dst, src * 4, src * 4 + 4);
|
||||
}
|
||||
}
|
||||
pieces.push(
|
||||
await sharp(out, { raw: { width: region.width, height: region.height, channels: 4 } })
|
||||
.png()
|
||||
.toBuffer(),
|
||||
);
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { artManifest } from "./manifest";
|
||||
import { artManifest, pieceIds } from "./manifest";
|
||||
|
||||
/** Where `scripts/map-art.mjs` output is served from (`public/assets/map-gen`). */
|
||||
export const ART_BASE_URL = "/assets/map-gen";
|
||||
|
|
@ -10,10 +10,18 @@ export function artBundle(devicePixelRatio: number): Record<string, string> {
|
|||
const bundle: Record<string, string> = {};
|
||||
for (const [id, asset] of Object.entries(artManifest.assets)) {
|
||||
if (asset.file === null) continue;
|
||||
if (asset.kind === "rocks") {
|
||||
for (const piece of pieceIds(id, asset)) {
|
||||
bundle[piece] = `${ART_BASE_URL}/${piece}@${density}.png`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
bundle[id] =
|
||||
asset.kind === "background"
|
||||
? `${ART_BASE_URL}/${id}.webp`
|
||||
: `${ART_BASE_URL}/${id}@${density}.png`;
|
||||
: asset.kind === "strip"
|
||||
? `${ART_BASE_URL}/${id}.png`
|
||||
: `${ART_BASE_URL}/${id}@${density}.png`;
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,15 @@
|
|||
"ship-clipper": {
|
||||
"kind": "ship",
|
||||
"file": "ship-clipper.png"
|
||||
},
|
||||
"belt-rocks": {
|
||||
"kind": "rocks",
|
||||
"file": "belt-asteroids.png",
|
||||
"pieces": 6
|
||||
},
|
||||
"belt-haze": {
|
||||
"kind": "strip",
|
||||
"file": null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,27 @@ import manifestJson from "./manifest.json";
|
|||
* user's map assets folder. A `null` file means the asset is expected but
|
||||
* missing, so the map draws its placeholder (decision 0.5.41).
|
||||
*/
|
||||
export type ArtKind = "background" | "body" | "badge" | "ship";
|
||||
export type ArtKind = "background" | "body" | "badge" | "ship" | "rocks" | "strip";
|
||||
|
||||
export interface ArtAsset {
|
||||
kind: ArtKind;
|
||||
file: string | null;
|
||||
/**
|
||||
* `rocks` only (CR-016): how many separate sprites the sheet holds. The
|
||||
* script slices it into `<id>-1` … `<id>-<pieces>` and checks the count.
|
||||
*/
|
||||
pieces?: number;
|
||||
}
|
||||
|
||||
export interface ArtManifest {
|
||||
/** Source folder, relative to `apps/web`. Never written to. */
|
||||
sourceDir: string;
|
||||
assets: Record<string, { kind: ArtKind; file: string | null }>;
|
||||
assets: Record<string, ArtAsset>;
|
||||
}
|
||||
|
||||
/** The texture ids a sliced sheet loads as (`belt-rocks-1` …). */
|
||||
export function pieceIds(id: string, asset: ArtAsset): string[] {
|
||||
return Array.from({ length: asset.pieces ?? 0 }, (_, i) => `${id}-${i + 1}`);
|
||||
}
|
||||
|
||||
export const artManifest = manifestJson as ArtManifest;
|
||||
|
|
|
|||
|
|
@ -6,14 +6,21 @@ import { dirname, join } from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { artManifest } from "../src/map/art/manifest";
|
||||
import { artManifest, pieceIds } from "../src/map/art/manifest";
|
||||
// @ts-expect-error: a plain .mjs build script, tested directly.
|
||||
import { MIN_ALPHA, sliceSheet } from "../scripts/slice.mjs";
|
||||
|
||||
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const script = join(webRoot, "scripts/map-art.mjs");
|
||||
sharp.cache(false);
|
||||
/** Reads through a buffer so sharp never holds the temp files open (Windows). */
|
||||
const image = (path: string) => sharp(readFileSync(path));
|
||||
const SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256] } as const;
|
||||
const SIZES = {
|
||||
body: [256, 512],
|
||||
badge: [256, 512],
|
||||
ship: [128, 256],
|
||||
rocks: [64, 128],
|
||||
} as const;
|
||||
|
||||
const run = (outDir: string) =>
|
||||
execFileSync(process.execPath, [script, outDir], { cwd: webRoot, stdio: "pipe" });
|
||||
|
|
@ -42,13 +49,23 @@ describe("map art optimization script (CR-003)", () => {
|
|||
expect(Math.max(meta.width!, meta.height!)).toBeLessThanOrEqual(2048);
|
||||
continue;
|
||||
}
|
||||
if (asset.kind === "strip") {
|
||||
const meta = await image(join(first, `${id}.png`)).metadata();
|
||||
expect(meta.width!).toBeLessThanOrEqual(4096);
|
||||
expect(meta.height!).toBeLessThanOrEqual(256);
|
||||
continue;
|
||||
}
|
||||
// A sliced sheet writes one sprite per piece (CR-016).
|
||||
const bases = asset.kind === "rocks" ? pieceIds(id, asset) : [id];
|
||||
const [one, two] = SIZES[asset.kind];
|
||||
for (const [suffix, longSide] of [
|
||||
["1x", one],
|
||||
["2x", two],
|
||||
] as const) {
|
||||
const meta = await image(join(first, `${id}@${suffix}.png`)).metadata();
|
||||
expect(Math.max(meta.width!, meta.height!), `${id}@${suffix}`).toBe(longSide);
|
||||
for (const base of bases) {
|
||||
for (const [suffix, longSide] of [
|
||||
["1x", one],
|
||||
["2x", two],
|
||||
] as const) {
|
||||
const meta = await image(join(first, `${base}@${suffix}.png`)).metadata();
|
||||
expect(Math.max(meta.width!, meta.height!), `${base}@${suffix}`).toBe(longSide);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -101,3 +118,70 @@ describe("map art optimization script (CR-003)", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** A transparent sheet with an opaque square at each `[x, y, size]`, plus faint glow and specks. */
|
||||
async function sheet(squares: [number, number, number][]): Promise<Buffer> {
|
||||
const width = 200;
|
||||
const height = 120;
|
||||
const data = Buffer.alloc(width * height * 4);
|
||||
const paint = (x: number, y: number, a: number) => {
|
||||
const i = (y * width + x) * 4;
|
||||
data[i] = 180;
|
||||
data[i + 1] = 160;
|
||||
data[i + 2] = 140;
|
||||
data[i + 3] = Math.max(data[i + 3]!, a);
|
||||
};
|
||||
for (const [left, top, size] of squares) {
|
||||
// A faint halo bridging toward the neighbours, below the slicing threshold.
|
||||
for (let y = top - 6; y < top + size + 6; y++) {
|
||||
for (let x = left - 6; x < left + size + 6; x++) {
|
||||
if (x >= 0 && y >= 0 && x < width && y < height) paint(x, y, MIN_ALPHA - 6);
|
||||
}
|
||||
}
|
||||
for (let y = top; y < top + size; y++)
|
||||
for (let x = left; x < left + size; x++) paint(x, y, 255);
|
||||
}
|
||||
// A stray speck: far too small to be a sprite.
|
||||
paint(195, 115, 255);
|
||||
return sharp(data, { raw: { width, height, channels: 4 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
describe("slicing a sprite sheet (Step 16.5)", () => {
|
||||
it("cuts three separate rocks into exactly three trimmed sprites", async () => {
|
||||
const pieces: Buffer[] = await sliceSheet(
|
||||
sharp,
|
||||
await sheet([
|
||||
[10, 10, 30],
|
||||
[80, 20, 20],
|
||||
[140, 60, 40],
|
||||
]),
|
||||
);
|
||||
expect(pieces).toHaveLength(3);
|
||||
const sizes = await Promise.all(pieces.map((piece) => sharp(piece).metadata()));
|
||||
// Reading order (top row left to right), each trimmed to its own square.
|
||||
expect(sizes.map((m) => [m.width, m.height])).toEqual([
|
||||
[30, 30],
|
||||
[20, 20],
|
||||
[40, 40],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a single rock as one sprite", async () => {
|
||||
expect(await sliceSheet(sharp, await sheet([[60, 30, 50]]))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops a neighbour's glow from each sprite", async () => {
|
||||
const [piece] = await sliceSheet(
|
||||
sharp,
|
||||
await sheet([
|
||||
[10, 10, 30],
|
||||
[44, 10, 30],
|
||||
]),
|
||||
);
|
||||
const { data } = await sharp(piece).raw().toBuffer({ resolveWithObject: true });
|
||||
// Every pixel is either the rock (opaque) or cleared, never the faint halo.
|
||||
for (let i = 3; i < data.length; i += 4) expect([0, 255]).toContain(data[i]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -397,7 +397,7 @@ Spec: `memory-bank/change-requests/CR-016-venus-io-europa-belt.md` (received as
|
|||
- [x] **Step 16.2 — Content: goods.** New goods: Atmospheric Data Cores (High-Value tier 2, Venus → Earth), Refined Sulfur (Heavy, Io → Earth, Mars, Venus), Volcanic Isotopes (High-Value tier 3, Io → Earth), Subsurface Ocean Samples (High-Value tier 3, Priority, Europa → Earth); Cloud-Grown Botanicals came in 16.1. Water Ice and Liquid Oxygen are also made at Europa, Industrial Chemicals at Venus; Water Ice goes to Io and Venus, Liquid Oxygen to Io, Habitat Prefabs to Io and Europa, and Packaged Food, Consumer Goods, Mail & Parcels, Medical Supplies, Electronics and Machine Parts to all three new places. Balance medians: first purchase 92.3, discovery 48.9, loan 160.5, four ships 147 vs 156. Tests (`packages/content/test/goods.test.ts`): the five new goods with their classes, tiers and routes (Sulfur has no tier of its own); every location makes a Standard good and wants at least one; each new place is a destination for at least five goods; the new producers on existing goods.
|
||||
- [x] **Step 16.3 — Content tuning: reach.** No tuning needed: with Phase 15's tanks every cell of CR-015 §4 and of CR-016 §3 already holds (Clipper ≈ 252M km reaches Venus at ≈ 200 but not Mars from Venus at ≈ 332; Freighter ≈ 460 reaches Mars from Venus and Io/Europa from Ceres at ≈ 393 but not Ceres from Venus at ≈ 520, nor Io from Mars at ≈ 592 × 1.15 short; Bulk Hauler ≈ 1,251 covers Venus–Jupiter at ≈ 885). Tests (`reach-table.test.ts`, CR-016 §3): one test per new cell (the Jupiter-system loop for Courier, Lighter, Clipper and Freighter; Earth↔Venus and Moon→Venus for Clipper and Freighter, not Courier or Lighter; Venus→Mars only from the Freighter up; Ceres↔Io/Europa for the Freighter; nothing from the inner system to the Jupiter system short of the Bulk Hauler, which flies every leg between all eight); the Bulk Hauler's full-tank reach ≥ 900M km; a Courier at the platform flies to Io and Europa and back without stranding. The no-strand test already covers every leg between all eight places.
|
||||
- [x] **Step 16.4 — Engine: save migration and boards.** No save migration and no save-version change: the save lists only discovered places, so a place that is in content but not in that list is undiscovered, and a save from before CR-016 opens with the three new places undiscovered and nothing else changed. Boards needed no change either: first contacts and fit slots already draw from undiscovered places in reach. Tests (`packages/engine/test/new-places.test.ts`): a pre-CR-016 save imported through the engine shows eight places with Venus, Io and Europa undiscovered, and its save version, discoveries and company are unchanged; 200 Earth boards for the deal's Clipper and veteran include first contacts to Venus; a Clipper delivering a first contact to Venus discovers it (the logbook says so) and Venus then has its own board, every contract on it a good made at Venus.
|
||||
- [ ] Step 16.5 — Assets: sprites and belt rocks
|
||||
- [x] **Step 16.5 — Assets: sprites and belt rocks.** (The Venus, Io and Europa sprites came in 16.1.) Two new art kinds: `rocks` (a sheet of separate sprites; the manifest's `belt-rocks` entry names `belt-asteroids.png` and says it holds `pieces: 6`) and `strip` (`belt-haze`, file `null` until the user provides one). `scripts/slice.mjs`: `findRegions` labels connected regions of pixels with alpha ≥ 16 (fainter glow and specks under 0.2 % of the sheet are dropped) in reading order; `sliceSheet` cuts each region out with every other pixel cleared, so a neighbour's glow never comes along. `map-art.mjs` writes `belt-rocks-1` … `belt-rocks-6` at 64 and 128 px (long side) and refuses a sheet whose piece count doesn't match the manifest; a haze strip would be kept within 4096 × 256. The loader registers each rock piece and the strip. The source image is only read. Tests: `apps/web/test/map-art.test.ts` (a fixture sheet with three separate squares and faint bridging glow slices into exactly three sprites, trimmed, in reading order; one square gives one; no faint glow survives in a piece; the real sheet's six pieces are written at the right sizes, deterministically and matching the committed output; the transparency check covers the belt image).
|
||||
- [ ] Step 16.6 — Map: belt haze
|
||||
- [ ] Step 16.7 — Map: belt rocks
|
||||
- [ ] Step 16.8 — Map: new bodies and Jupiter-system layout
|
||||
|
|
|
|||