SOURCE / PINNED RELEASE
Made of little things.
Supersonic RC Revive
- Release
- 372375d18e3f…
- Author-recorded commit
- 6fbf09f88a0a…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f
Archive hash verified: 76e4eb30c67a6f45…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
#!/usr/bin/env node
// Build the custom (original-art-free) asset bundle into src/game/assets-custom/.
// pnpm assets:build
// Writes the same files tools/extract/build_level.py makes from the 2004 game,
// in the same formats (see that script and src/game/level/data.ts), so the
// game code runs on either bundle. Everything here is authored from scratch:
// procedural meshes, textures and baked vertex lighting. Sounds are not files;
// src/game/synth.ts renders them at load time.
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { bakeLevel } from './bake.ts';
import { type GlbMaterialIn, type GlbNodeIn, type GlbPrimitiveIn, writeGlb } from './files.ts';
import { buildLevel, LEVEL_MATERIALS } from './level.ts';
import type { Piece, Vec3 } from './mesh.ts';
import { ARROW_MATERIALS, buildArrow, buildCar, buildPrize, buildStar, CAR_MATERIALS, PRIZE_MATERIALS, type PropMaterial, STAR_MATERIALS } from './props.ts';
import { buildTextures, carShadow } from './textures.ts';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const out = join(root, 'src/game/assets-custom');
/** The original's Havok world: gravity in in/s² and the unit scale (physics constants, kept). */
const GRAVITY = [0, 0, -386.21970060020317];
const HAVOK_SCALE = 0.025400051847100258;
const FRICTION = 0.3;
const RESTITUTION = 0.3;
const srgbToLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
function linear(hex: string): [number, number, number] {
const v = parseInt(hex.slice(1), 16);
return [srgbToLinear(((v >> 16) & 255) / 255), srgbToLinear(((v >> 8) & 255) / 255), srgbToLinear((v & 255) / 255)];
}
const round = (v: number) => Math.round(v * 1000) / 1000;
function primitives(p: Piece, materialIndex: (name: string) => number, opts: { uvs?: (material: string) => boolean; colors?: Map<string, Uint8Array> } = {}): GlbPrimitiveIn[] {
return [...p.prims.values()].map((pr) => ({
material: materialIndex(pr.material),
positions: new Float32Array(pr.positions.map(round)),
normals: new Float32Array(pr.normals),
uvs: opts.uvs?.(pr.material) ? new Float32Array(pr.uvs) : undefined,
colors: opts.colors?.get(`${p.name}/${pr.material}`),
indices: new Uint32Array(pr.indices),
}));
}
/** A props GLB plus its materials.json section (the W3D shader colours the game reads). */
function propGlb(nodes: (index: (name: string) => number) => GlbNodeIn[], table: Record<string, PropMaterial>) {
const names = Object.keys(table);
const index = (name: string) => {
const i = names.indexOf(name);
if (i < 0) throw new Error(`unknown material ${name}`);
return i;
};
const materials: GlbMaterialIn[] = names.map((name) => ({ name, color: [...linear(table[name].diffuse), 1] }));
const section = Object.fromEntries(
names.map((name) => {
const m = table[name];
return [name, { ambient: linear(m.ambient ?? m.diffuse), diffuse: linear(m.diffuse), emissive: linear(m.emissive ?? '#000000'), opacity: 1 }];
}),
);
return { glb: writeGlb(nodes(index), materials), section };
}
function main(): void {
const started = Date.now();
rmSync(out, { recursive: true, force: true });
mkdirSync(out, { recursive: true });
const write = (name: string, data: Buffer | string) => writeFileSync(join(out, name), data);
// --- level -------------------------------------------------------------------------
const { pieces, markers } = buildLevel();
const textures = buildTextures();
const textureIndex = new Map(textures.map((t, i) => [t.name, i]));
const levelMaterialNames = [...new Set(pieces.flatMap((p) => [...p.prims.keys()]))];
const levelMaterials: GlbMaterialIn[] = levelMaterialNames.map((name) => {
const m = LEVEL_MATERIALS[name];
if (!m) throw new Error(`unknown level material ${name}`);
return { name, color: [...linear(m.color), 1], texture: m.texture ? textureIndex.get(m.texture) : undefined };
});
const textured = (name: string) => !!LEVEL_MATERIALS[name]?.texture;
const baked = bakeLevel(pieces);
const levelNodes: GlbNodeIn[] = pieces.map((p) => ({
name: p.name,
mesh: { name: p.name, primitives: primitives(p, (n) => levelMaterialNames.indexOf(n), { uvs: textured, colors: baked }), extras: { baked: true } },
}));
write('level.glb', writeGlb(levelNodes, levelMaterials, textures.map((t) => t.png)));
// Havok static bodies: convex pieces as hulls, the rest as trimeshes.
const bodies = pieces
.filter((p) => p.collide !== 'none')
.map((p) => ({ name: p.name, convex: p.collide === 'convex', friction: FRICTION, restitution: RESTITUTION, verts: p.coarse.verts.map(round), faces: p.coarse.faces }));
write('collision.json', JSON.stringify({ gravity: GRAVITY, scale: HAVOK_SCALE, bodies }));
// Ray mesh: the coarse faces of every ray-visible piece.
const rayPieces = pieces.filter((p) => p.ray);
const verts: number[] = [];
const tris: number[] = [];
const owner: number[] = [];
rayPieces.forEach((p, i) => {
const base = verts.length / 3;
verts.push(...p.coarse.verts);
for (let f = 0; f < p.coarse.faces.length; f += 3) {
tris.push(base + p.coarse.faces[f], base + p.coarse.faces[f + 1], base + p.coarse.faces[f + 2]);
owner.push(i);
}
});
const ray = Buffer.alloc(8 + verts.length * 4 + tris.length * 4 + owner.length * 2);
ray.writeUInt32LE(verts.length / 3, 0);
ray.writeUInt32LE(tris.length / 3, 4);
let o = 8;
for (const v of verts) o = ray.writeFloatLE(v, o);
for (const t of tris) o = ray.writeUInt32LE(t, o);
for (const w of owner) o = ray.writeUInt16LE(w, o);
write('raymesh.bin', ray);
// Markers in the W3D node format: row-vector matrices, translation in 12..14.
const node = (name: string, [x, y, z]: Vec3, heading = 0) => {
const a = (heading * Math.PI) / 180;
const c = Math.cos(a);
const s = Math.sin(a);
return { kind: 'group', name, parent: 'World', user: '', matrix: [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, x, y, z, 1] };
};
const hull: Vec3[] = [];
for (const x of [-7.5, 7.5]) for (const y of [-15, 15]) for (const z of [0, 10]) hull.push([x, y, z]);
write(
'markers.json',
JSON.stringify({
raymesh_models: rayPieces.map((p) => p.name),
car_hull: { name: 'car', node_matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], positions: hull },
level: markers.map((m) => node(m.name, m.position, m.heading)),
car: [],
}),
);
// --- car and props -------------------------------------------------------------------
const car = buildCar();
const carGlb = propGlb(
(index) => [
{
name: 'Car',
mesh: { name: 'Car', primitives: primitives(car.body, index) },
children: car.wheelPositions.map((t, i) => ({ name: `Wheel${i + 1}`, translation: t, mesh: { name: `Wheel${i + 1}`, primitives: primitives(car.wheel, index) } })),
},
],
CAR_MATERIALS,
);
write('car.glb', carGlb.glb);
const star = propGlb((index) => [{ name: 'Star', mesh: { name: 'Star', primitives: primitives(buildStar(), index) } }], STAR_MATERIALS);
write('star.glb', star.glb);
const prize = propGlb((index) => [{ name: 'prizesbox', mesh: { name: 'prizesbox', primitives: primitives(buildPrize(), index) } }], PRIZE_MATERIALS);
write('prize.glb', prize.glb);
const arrow = propGlb((index) => [{ name: 'arrow', mesh: { name: 'arrow', primitives: primitives(buildArrow(), index) } }], ARROW_MATERIALS);
write('arrow2.glb', arrow.glb);
mkdirSync(join(out, 'ui'));
write('ui/carshadow.png', carShadow());
write('materials.json', JSON.stringify({ holypolycrap: carGlb.section, star: star.section, prize: prize.section, arrow2: arrow.section }, null, 1) + '\n');
write(
'README.md',
'# Custom asset bundle (generated)\n\n' +
'Built by `pnpm assets:build` (tools/assets/build.ts). Do not edit by hand.\n' +
'Everything here is original procedural work for this project, under the repository licence.\n' +
'Sounds are synthesised at load time by src/game/synth.ts.\n',
);
const files = readdirSync(out, { recursive: true }).length;
console.log(`${files} files -> ${out} (${pieces.length} level pieces, ${bodies.length} bodies, ${tris.length / 3} ray tris) in ${Date.now() - started} ms`);
}
main();
