Back to Supersonic RC Revive
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.

tools/assets/build.ts
#!/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();