Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

Release
1ba42f1ca1d6…
Author-recorded commit
baecad10b1cd…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f

Archive hash verified: 90d22b206672eba4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/level/editorLevel.ts
// Levels authored in Blender: one GLB holds the visible objects, hidden
// collision objects, markers and any baked light (docs/level-editing.md).
// The game derives what tools used to write as collision.json, raymesh.bin
// and markers.json from it at load. Blender exports glTF Y-up in metres; the
// game is Director Z-up in inches.
import { Matrix4, Vector3 } from 'three';
import { type GlbNode, type GltfDoc, isTriangles, nodeLocalMatrix, type ParsedGlb, parseGlb, sceneRoots } from '../render/glb.js';
import type { CollisionFile, LevelData, Markers, W3DNode } from './data.js';

export const LEVEL_VERSION = 1;
/** The original's Havok world: gravity in in/s² and the unit scale (physics constants, kept). */
export const DEFAULT_GRAVITY = [0, 0, -386.21970060020317];
export const DEFAULT_HAVOK_SCALE = 0.025400051847100258;
const DEFAULT_FRICTION = 0.3;

/** The custom bundle's car hull: a 15 × 30 × 10 box, used when neither the level nor the pack has one. */
export const DEFAULT_CAR_HULL: Markers['car_hull'] = {
  name: 'car',
  node_matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
  positions: [-7.5, 7.5].flatMap((x) => [-15, 15].flatMap((y) => [0, 10].map((z) => [x, y, z]))),
};

const INCHES_PER_METRE = 1 / 0.0254;
/** glTF (Y-up, metres) to game space (Z-up, inches): (x, y, z) → (x, −z, y) / 0.0254. */
export const GLTF_TO_GAME = new Matrix4().set(
  INCHES_PER_METRE, 0, 0, 0,
  0, 0, -INCHES_PER_METRE, 0,
  0, INCHES_PER_METRE, 0, 0,
  0, 0, 0, 1,
);

export type Collide = 'none' | 'mesh' | 'convex';
export type Role = 'visual' | 'collision' | 'ray' | 'car_hull' | 'ignore';

export interface ObjectFlags {
  /** The name the game uses (bodies, ray models). */
  name: string;
  role: Role;
  render: boolean;
  collide: Collide;
  ray: boolean;
  friction: number;
  restitution: number;
  /** Sort key for collision bodies and ray models (document order breaks ties). */
  order: number;
  /** COLOR_0 is baked light (not a stray colour attribute). */
  baked: boolean;
}

export interface LevelSettings {
  version: number;
  gravity: number[];
  scale: number;
  title: string | null;
  /** Converted from the 2004 game's files: local use only. */
  original: boolean;
  lighting: 'vertex' | 'lightmap' | 'lights' | null;
}

const ROLE_DEFAULTS: Record<Role, Pick<ObjectFlags, 'render' | 'collide' | 'ray'>> = {
  visual: { render: true, collide: 'mesh', ray: true },
  collision: { render: false, collide: 'mesh', ray: false },
  ray: { render: false, collide: 'none', ray: true },
  car_hull: { render: false, collide: 'none', ray: false },
  ignore: { render: false, collide: 'none', ray: false },
};

/** Blender suffixes duplicate names with ".001"; the game ignores it. */
export function baseName(name: string): string {
  return name.replace(/\.\d{3,}$/, '');
}

const flag = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : typeof v === 'number' ? v !== 0 : undefined);
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined);

/** How the game treats a mesh object, from its ssrc_* custom properties. */
export function objectFlags(nodeName: string, extras: Record<string, unknown>, lighting: LevelSettings['lighting'] = null): ObjectFlags {
  const base = baseName(nodeName);
  const roleProp = str(extras.ssrc_role);
  const role: Role =
    roleProp && roleProp in ROLE_DEFAULTS ? (roleProp as Role)
    : /^COL_/i.test(base) ? 'collision'
    : /^RAY_/i.test(base) ? 'ray'
    : 'visual';
  const d = ROLE_DEFAULTS[role];
  const render = flag(extras.ssrc_render) ?? d.render;
  const collideProp = str(extras.ssrc_collide);
  const collide: Collide = collideProp === 'none' || collideProp === 'mesh' || collideProp === 'convex' ? collideProp : d.collide;
  return {
    name: str(extras.ssrc_name) ?? base.replace(/^(COL|RAY)_/i, ''),
    role,
    render: role === 'ignore' ? false : render,
    collide: role === 'ignore' ? 'none' : collide,
    ray: role === 'ignore' ? false : (flag(extras.ssrc_ray) ?? (role === 'visual' ? render : d.ray)),
    friction: num(extras.ssrc_friction) ?? DEFAULT_FRICTION,
    restitution: num(extras.ssrc_restitution) ?? DEFAULT_FRICTION,
    order: num(extras.ssrc_order) ?? Infinity,
    baked: flag(extras.ssrc_baked) ?? lighting === 'vertex',
  };
}

/** The level's settings: scene custom properties, or a node carrying ssrc_level (collection exporters drop scene ones). Null for a plain GLB. */
export function levelSettings(doc: GltfDoc): LevelSettings | null {
  const sources = [doc.scenes?.[doc.scene ?? 0]?.extras, ...(doc.nodes ?? []).map((n) => n.extras)];
  const extras = sources.find((e) => e && num(e.ssrc_level) !== undefined);
  if (!extras) return null;
  const gravity = Array.isArray(extras.ssrc_gravity) && extras.ssrc_gravity.length === 3 && extras.ssrc_gravity.every((v) => num(v) !== undefined)
    ? (extras.ssrc_gravity as number[])
    : DEFAULT_GRAVITY;
  const lighting = str(extras.ssrc_lighting);
  return {
    version: num(extras.ssrc_level)!,
    gravity,
    scale: num(extras.ssrc_havok_scale) ?? DEFAULT_HAVOK_SCALE,
    title: str(extras.ssrc_title) ?? null,
    original: flag(extras.ssrc_original) ?? false,
    lighting: lighting === 'vertex' || lighting === 'lightmap' || lighting === 'lights' ? lighting : null,
  };
}

/** Collision vertices and marker positions are rounded to 1e-3 in: exact for the converted levels, below f32 noise for metres. */
const round = (v: number) => Math.round(v * 1000) / 1000 || 0;

/** The axis swap alone (exact 0 and ±1 entries), so conjugating by it adds no rounding. */
const AXES = new Matrix4().set(1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1);
const AXES_INVERSE = AXES.clone().transpose();

/**
 * A node matrix in game space (the W3D convention), C · M · C⁻¹: the
 * orientation conjugated by the axis swap, the translation in inches (rounded).
 */
function gameNodeMatrix(gltfWorld: Matrix4): number[] {
  const m = AXES.clone().multiply(gltfWorld).multiply(AXES_INVERSE).elements.slice();
  for (const k of [12, 13, 14]) m[k] = round(m[k] * INCHES_PER_METRE);
  return m;
}

export interface DerivedLevel {
  level: LevelData;
  settings: LevelSettings | null;
  warnings: string[];
}

interface MeshObject {
  flags: ObjectFlags;
  index: number;
  /** World-space game positions per triangle corner (3 per triangle). */
  corners: number[];
  /** A converted level's exact collision data (ssrc_verts / ssrc_faces), in world space. */
  exact: { verts: number[]; faces: number[] } | null;
}

const numbers = (v: unknown): v is number[] => Array.isArray(v) && v.every((x) => num(x) !== undefined);

/**
 * Collision data kept verbatim by the converter: the original's Havok bodies
 * have duplicate, loose and degenerate vertices a Blender mesh cannot hold,
 * and Rapier's hulls depend on them. Local to the object (inches, Z-up); the
 * SSRC add-on drops them when the mesh is edited.
 */
function exactCollision(extras: Record<string, unknown> | undefined, toGame: number[]): MeshObject['exact'] {
  const verts = extras?.ssrc_verts;
  const faces = extras?.ssrc_faces;
  if (!numbers(verts) || !numbers(faces) || verts.length % 3 || faces.length % 3 || faces.some((i) => i < 0 || i * 3 >= verts.length)) return null;
  const identity = toGame.every((v, k) => v === (k % 5 === 0 ? 1 : 0));
  if (identity) return { verts: verts.slice(), faces: faces.slice() };
  const m = new Matrix4().fromArray(toGame);
  const v = new Vector3();
  const out: number[] = [];
  for (let k = 0; k < verts.length; k += 3) {
    v.set(verts[k], verts[k + 1], verts[k + 2]).applyMatrix4(m);
    out.push(round(v.x), round(v.y), round(v.z));
  }
  const flip = m.determinant() < 0;
  const f = flip ? faces.flatMap((_, k) => (k % 3 ? [] : [faces[k], faces[k + 2], faces[k + 1]])) : faces.slice();
  return { verts: out, faces: f };
}

/**
 * Build the level's physics and gameplay data from its GLB: collision bodies
 * (welded, rounded), the wheel/camera ray mesh, marker groups from empties and
 * the car hull. `carHull` is the fallback when the level has no car_hull object.
 */
export function deriveLevel(source: ArrayBuffer | ParsedGlb, options: { carHull?: Markers['car_hull'] } = {}): DerivedLevel {
  const glb = source instanceof ArrayBuffer ? parseGlb(source) : source;
  const { doc } = glb;
  const settings = levelSettings(doc);
  const warnings: string[] = [];
  if (!settings) warnings.push('No ssrc_level marker: every mesh is solid and raycast. Export with the SSRC Blender add-on.');
  else if (settings.version > LEVEL_VERSION) warnings.push(`Level format ${settings.version} is newer than this game understands (${LEVEL_VERSION}).`);

  const objects: MeshObject[] = [];
  const groups: W3DNode[] = [];
  let carHull = null as Markers['car_hull'] | null;

  const corners = (meshIndex: number, toGame: Matrix4): number[] => {
    const out: number[] = [];
    const flip = toGame.determinant() < 0;
    const v = new Vector3();
    for (const p of doc.meshes![meshIndex].primitives) {
      if (!isTriangles(p)) continue;
      const pos = glb.accessor(p.attributes.POSITION).array;
      const count = pos.length / 3;
      const idx = p.indices !== undefined ? glb.accessor(p.indices).array : null;
      const n = idx ? idx.length : count;
      for (let t = 0; t + 2 < n; t += 3) {
        const tri = flip ? [t, t + 2, t + 1] : [t, t + 1, t + 2];
        for (const k of tri) {
          const i = idx ? idx[k] : k;
          v.set(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]).applyMatrix4(toGame);
          out.push(v.x, v.y, v.z);
        }
      }
    }
    return out;
  };

  const visit = (i: number, parent: Matrix4) => {
    const n = doc.nodes![i];
    const world = parent.clone().multiply(nodeLocalMatrix(n));
    if (n.mesh !== undefined) {
      const flags = objectFlags(n.name ?? '', n.extras ?? {}, settings?.lighting ?? null);
      if (flags.role === 'car_hull') {
        // The converter keeps the hull's points in their original order (ssrc_hull_points,
        // inches): the convex hull Rapier builds, and so the handling, depends on it.
        const listed = n.extras?.ssrc_hull_points;
        const unique = new Map<string, number[]>();
        if (Array.isArray(listed) && listed.length % 3 === 0 && listed.length && listed.every((v) => num(v) !== undefined)) {
          for (let k = 0; k < listed.length; k += 3) unique.set(String(k), listed.slice(k, k + 3) as number[]);
        } else {
          const local = corners(n.mesh, GLTF_TO_GAME);
          for (let k = 0; k < local.length; k += 3) {
            const p = [round(local[k]), round(local[k + 1]), round(local[k + 2])];
            unique.set(p.join(','), p);
          }
        }
        const listedMatrix = n.extras?.ssrc_hull_matrix;
        carHull = {
          name: flags.name,
          node_matrix: numbers(listedMatrix) && listedMatrix.length === 16 ? listedMatrix.slice() : gameNodeMatrix(world),
          positions: [...unique.values()],
        };
        // The original also raycasts its parked car proxy.
        if (flags.ray) objects.push({ flags, index: objects.length, corners: corners(n.mesh, GLTF_TO_GAME.clone().multiply(world)), exact: null });
      } else if (flags.collide !== 'none' || flags.ray) {
        const exact = flags.collide !== 'none' ? exactCollision(n.extras, gameNodeMatrix(world)) : null;
        objects.push({ flags, index: objects.length, corners: corners(n.mesh, GLTF_TO_GAME.clone().multiply(world)), exact });
      }
    } else if (num(n.extras?.ssrc_level) === undefined) {
      groups.push({ kind: 'group', name: baseName(n.name ?? ''), parent: 'World', user: str(n.extras?.ssrc_user) ?? '', matrix: gameNodeMatrix(world) });
    }
    for (const c of n.children ?? []) visit(c, world);
  };
  for (const root of sceneRoots(doc)) visit(root, new Matrix4());

  objects.sort((a, b) => a.flags.order - b.flags.order || a.index - b.index);

  const bodies: CollisionFile['bodies'] = [];
  for (const o of objects) {
    if (o.flags.collide === 'none') continue;
    if (o.exact) {
      bodies.push({ name: o.flags.name, convex: o.flags.collide === 'convex', friction: o.flags.friction, restitution: o.flags.restitution, ...o.exact });
      continue;
    }
    const verts: number[] = [];
    const faces: number[] = [];
    const weld = new Map<string, number>();
    const tri: number[] = [];
    for (let k = 0; k < o.corners.length; k += 3) {
      const p = [round(o.corners[k]), round(o.corners[k + 1]), round(o.corners[k + 2])];
      const key = p.join(',');
      let id = weld.get(key);
      if (id === undefined) {
        id = verts.length / 3;
        weld.set(key, id);
        verts.push(...p);
      }
      tri.push(id);
      if (tri.length === 3) {
        // Welding can collapse a sliver; a trimesh has no use for it.
        if (tri[0] !== tri[1] && tri[1] !== tri[2] && tri[0] !== tri[2]) faces.push(...tri);
        tri.length = 0;
      }
    }
    if (!faces.length) {
      warnings.push(`${o.flags.name} has no usable triangles for collision.`);
      continue;
    }
    bodies.push({ name: o.flags.name, convex: o.flags.collide === 'convex', friction: o.flags.friction, restitution: o.flags.restitution, verts, faces });
  }
  if (!bodies.length) warnings.push('The level has no collision objects: the car will fall forever.');

  const rayObjects = objects.filter((o) => o.flags.ray && o.corners.length);
  const triangles = rayObjects.reduce((s, o) => s + o.corners.length / 9, 0);
  const positions = new Float32Array(triangles * 9);
  const indices = new Uint32Array(triangles * 3);
  const owner = new Uint16Array(triangles);
  let t = 0;
  rayObjects.forEach((o, model) => {
    positions.set(o.corners, t * 9);
    for (let k = 0; k < o.corners.length / 9; k++, t++) {
      indices.set([t * 3, t * 3 + 1, t * 3 + 2], t * 3);
      owner[t] = model;
    }
  });

  const seen = new Set<string>();
  for (const g of groups) {
    const key = g.name.toLowerCase();
    if (/^(checkpoint|prizes)_\d+$|^ssrc_start$/.test(key) && seen.has(key)) warnings.push(`More than one marker is named ${g.name}.`);
    seen.add(key);
  }

  const markers: Markers = {
    raymesh_models: rayObjects.map((o) => o.flags.name),
    car_hull: carHull ?? options.carHull ?? DEFAULT_CAR_HULL,
    level: groups,
    car: [],
  };
  const collision: CollisionFile = { gravity: settings?.gravity ?? DEFAULT_GRAVITY, scale: settings?.scale ?? DEFAULT_HAVOK_SCALE, bodies };
  return { level: { markers, collision, rayMesh: { positions, indices, owner } }, settings, warnings };
}

/** A visible object of an editor level, with its world matrix in game space. */
export interface LevelVisual {
  node: GlbNode;
  matrix: Matrix4;
  flags: ObjectFlags;
}

/** The objects an editor level draws (readGlb's tree, glTF space) and their game-space matrices. */
export function levelVisuals(roots: GlbNode[], settings: LevelSettings | null): LevelVisual[] {
  const out: LevelVisual[] = [];
  const visit = (node: GlbNode, parent: Matrix4) => {
    const world = parent.clone().multiply(node.matrix);
    if (node.mesh) {
      const flags = objectFlags(node.name, node.extras, settings?.lighting ?? null);
      if (flags.render) out.push({ node, matrix: world, flags });
    }
    for (const c of node.children) visit(c, world);
  };
  for (const root of roots) visit(root, GLTF_TO_GAME);
  return out;
}

/** Whether a GLB is a Blender-authored level (as opposed to the legacy level.glb with JSON side files). */
export function isEditorLevel(buffer: ArrayBuffer): boolean {
  try {
    return levelSettings(parseGlb(buffer).doc) !== null;
  } catch {
    return false;
  }
}