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/app.ts
// Browser host: the original's screen flow (title → select → help → loading →
// race), its 1 s fades, and a fixed 40 fps Director frame clock.
import { PerspectiveCamera, WebGLRenderer } from 'three';
import { loadLevelData } from './assets.js';
import { SOUND_NAMES, SoundMixer } from './audio.js';
import { LocalMultiplayer, PLAYER_COLORS, splitViewports } from './localMultiplayer.js';
import { Input, type DirectorKey, type MenuIntent } from './input.js';
import type { LevelData } from './level/data.js';
import { CarSound } from './lingo/carSound.js';
import { DrivingManager, ORIGINAL_FRAME_MS } from './lingo/drivingManager.js';
import { createMode, type GameType, type ModeName } from './lingo/gameplay.js';
import { initPhysics } from './physics/havok.js';
import { WorldView } from './render/world.js';
import { Screens } from './ui/screens.js';

/** The original stage is 640x480; its camera fieldOfView is vertical. */
const STAGE_ASPECT = 4 / 3;
/** Director's default camera projection angle (the arrow's own camera). */
const DEFAULT_FOV = 30;

interface Fade {
  from: number;
  to: number;
  start: number;
  done: () => void;
}

interface Race {
  name: ModeName;
  dm: DrivingManager;
  mode: GameType;
  sound: CarSound | null;
  driving: boolean;
}

export interface Progress {
  load(): Promise<ModeName[]>;
  save(unlocked: ModeName[]): void;
}

export class App {
  private readonly renderer: WebGLRenderer;
  private readonly camera = new PerspectiveCamera(70, STAGE_ASPECT, 2, 60000);
  private readonly arrowCamera = new PerspectiveCamera(DEFAULT_FOV, STAGE_ASPECT, 1, 1000);
  private readonly input = new Input();
  private readonly mixer: SoundMixer | null;
  private readonly screens: Screens;
  private level: LevelData | null = null;
  private view: WorldView | null = null;
  private race: Race | null = null;
  private local: LocalMultiplayer | null = null;
  private localMenuHeld = false;
  private fade: Fade | null = null;
  private unlocked = new Set<ModeName>(['free_driving']);
  /** "the milliSeconds" */
  private ms = 0;
  private lastTime = 0;
  private accumulator = 0;
  onStatus: (text: string) => void = () => {};
  /**
   * Offer "Asset pack…" on the title screen (set when the host can download or
   * side-load a pack). The promise settles when the switch is over; the button
   * stays disabled until then.
   */
  packSwitch: (() => Promise<void>) | null = null;
  private idleWaiters: Array<() => void> = [];
  /**
   * Controller menu presses go here first (the pack panel, while it is open);
   * it returns true when it took the press, so the screens behind it do not.
   */
  menuOverride: ((intent: MenuIntent) => boolean) | null = null;
  /**
   * A second look at controller menu presses (the secret code's controller
   * entry, after any open panel): returning true takes the press, so the
   * screens behind it do not.
   */
  menuIntentFilter: ((intent: MenuIntent) => boolean) | null = null;
  private padHint = false;

  constructor(private readonly host: HTMLElement, private readonly progress: Progress | null) {
    this.renderer = new WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.autoClear = false;
    host.append(this.renderer.domElement);
    this.camera.up.set(0, 0, 1);
    this.mixer = typeof AudioContext !== 'undefined' ? new SoundMixer() : null;
    this.screens = new Screens(host, { click: () => this.buttonClick() }, this.input);
    new ResizeObserver(() => this.resize()).observe(host);
    this.resize();
    this.input.attach(window);
    const resumeAudio = () => this.mixer?.resume();
    window.addEventListener('pointerdown', resumeAudio);
    window.addEventListener('keydown', resumeAudio);
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) this.input.clear();
      this.lastTime = performance.now();
    });
  }

  /** Receive a discrete control action dispatched by the shell through NAP-KEYS. */
  triggerKeyAction(key: DirectorKey): void {
    this.input.pulseAction(key);
  }

  async start(): Promise<void> {
    this.level = (await loadLevelData()) ?? null;
    if (!this.level) throw new Error('Game data is not bundled in this build.');
    this.onStatus('Starting physics…');
    await initPhysics();
    this.onStatus('Building the track…');
    this.view = await WorldView.create();
    this.onStatus('Loading sounds…');
    await this.mixer?.load(SOUND_NAMES);
    for (const m of (await this.progress?.load()) ?? []) this.unlocked.add(m);
    this.onStatus('');
    this.titleStart();
    this.lastTime = performance.now();
    requestAnimationFrame((t) => this.frame(t));
  }

  /**
   * Rebuild the track, props and sounds from the asset pack now installed, then
   * return to the title screen. Only between races. If the pack
   * cannot be built this throws and keeps the previous scene, so the caller can
   * reinstall the old pack.
   */
  async reloadAssets(): Promise<void> {
    if (this.racing) throw new Error('Finish the race before switching asset packs.');
    this.fade = null;
    this.screens.setFade(0);
    this.screens.showLoading();
    try {
      this.onStatus('Building the track…');
      const level = await loadLevelData();
      if (!level) throw new Error('That asset pack has no track data.');
      const view = await WorldView.create();
      this.onStatus('Loading sounds…');
      try {
        await this.mixer?.load(SOUND_NAMES);
      } catch (error) {
        view.dispose();
        throw error;
      }
      this.view?.dispose();
      this.level = level;
      this.view = view;
    } finally {
      this.onStatus('');
    }
    this.titleStart();
  }

  /** Whether a race is running (asset packs can only be switched between races). */
  get racing(): boolean {
    return this.race !== null || this.local !== null;
  }

  /** Resolves once no race is running. */
  whenIdle(): Promise<void> {
    return this.racing ? new Promise((resolve) => this.idleWaiters.push(resolve)) : Promise.resolve();
  }

  /** Back to the title screen without changing assets (after a failed switch). */
  showTitle(): void {
    if (!this.racing) this.titleStart();
  }

  // --- screen flow ---------------------------------------------------------------

  private buttonClick(): void {
    this.mixer?.resume();
    this.mixer?.sound(1).play({ member: 'buttonclick' });
  }

  private fadeIn(done: () => void = () => {}): void {
    this.fade = { from: 1, to: 0, start: this.ms, done };
  }

  private fadeOut(done: () => void): void {
    this.fade = { from: 0, to: 1, start: this.ms, done };
  }

  private titleStart(): void {
    this.fadeIn();
    this.screens.showTitle(() => this.fadeOut(() => this.selectStart()), this.packSwitch);
  }

  private selectStart(): void {
    this.fadeIn();
    this.mixer?.sound(8).stop();
    if (this.mixer) this.mixer.sound(8).volume = 255;
    this.mixer?.sound(8).play({ member: 'SSRMusicSplashScreen', loopCount: 0 });
    this.screens.showSelect(this.unlocked, (mode) => this.fadeOut(() => this.helpStart(mode)), () => this.fadeOut(() => this.localStart()));
  }

  private helpStart(mode: ModeName): void {
    this.fadeIn();
    this.screens.showHelp(() => {
      this.mixer?.sound(8).fadeOut(500);
      this.fadeOut(() => this.gameStart(mode));
    });
  }

  /** gameTypeManager.start → realStart → realStart2. */
  private gameStart(name: ModeName): void {
    const level = this.level!;
    this.screens.showLoading();
    this.fadeIn(() => {
      const dm = new DrivingManager(level, this.input);
      const mode = createMode(name, {
        dm,
        input: this.input,
        markers: level.markers,
        mixer: this.mixer,
        ms: () => this.ms,
        unlock: (m) => this.unlock(m),
        exit: () => this.raceEnd(),
      });
      const sound = this.mixer ? new CarSound(dm.car, this.input, this.mixer) : null;
      dm.car.sound = sound;
      mode.realStart();
      this.view!.setMode(mode);
      this.race = { name, dm, mode, sound, driving: false };
      // The physics already runs behind the loading screen during this fade.
      this.fadeOut(() => {
        this.fade = null;
        this.screens.setFade(0);
        this.screens.showRace();
        dm.camera.fadeIn();
        mode.realStart2();
        this.race!.driving = true;
      });
    });
  }

  private localStart(): void {
    this.input.clear();
    this.local = new LocalMultiplayer(this.level!, this.mixer);
    this.view!.setMode(null);
    this.screens.showLocal(() => {
      if (this.local) this.local.paused = false;
      this.input.clear();
    }, () => this.localEnd());
    this.fadeIn();
  }

  private localEnd(): void {
    this.local?.destroy();
    this.local = null;
    this.view?.clearPlayers();
    this.screens.hideLocal();
    this.input.clear();
    this.localMenuHeld = false;
    this.selectStart();
    for (const resolve of this.idleWaiters.splice(0)) resolve();
  }

  private raceEnd(): void {
    const race = this.race;
    if (!race) return;
    this.race = null;
    race.sound?.destroy();
    race.dm.destroy();
    this.view?.setMode(null);
    this.screens.hideRace();
    this.input.clear();
    this.selectStart();
    for (const resolve of this.idleWaiters.splice(0)) resolve();
  }

  private unlock(mode: ModeName): void {
    if (this.unlocked.has(mode)) return;
    this.unlocked.add(mode);
    this.progress?.save([...this.unlocked]);
  }

  // --- frame loop ------------------------------------------------------------------

  private resize(): void {
    const w = Math.max(1, this.host.clientWidth);
    const h = Math.max(1, this.host.clientHeight);
    this.renderer.setSize(w, h, false);
    this.camera.aspect = this.arrowCamera.aspect = w / h;
    this.applyFov(this.camera, this.race?.dm.camera.fieldOfView ?? 70);
    this.applyFov(this.arrowCamera, DEFAULT_FOV);
  }

  /** Keep the original's 4:3 framing: vertical FOV in landscape, its horizontal FOV in portrait. */
  private applyFov(camera: PerspectiveCamera, fovV: number): void {
    const aspect = camera.aspect;
    if (aspect >= STAGE_ASPECT) {
      camera.fov = fovV;
    } else {
      const halfH = Math.atan(Math.tan((fovV * Math.PI) / 360) * STAGE_ASPECT);
      camera.fov = (2 * Math.atan(Math.tan(halfH) / aspect) * 180) / Math.PI;
    }
    camera.updateProjectionMatrix();
  }

  /** One Director frame: actorList stepFrame in the original's order. */
  private directorFrame(): void {
    this.ms += ORIGINAL_FRAME_MS;
    this.local?.step(ORIGINAL_FRAME_MS);
    const race = this.race;
    if (race) {
      race.dm.stepFrame(ORIGINAL_FRAME_MS);
      if (this.race === race) race.mode.stepFrame();
    }
    if (this.fade) {
      const f = this.fade;
      const t = Math.min(1, (this.ms - f.start) / 1000);
      this.screens.setFade(f.from + (f.to - f.from) * t);
      if (t >= 1) {
        this.fade = null;
        f.done();
      }
    }
  }

  /** Controllers: race keys for the Director frames below, or menu navigation. */
  private pollGamepad(now: number): void {
    const { intents, waiting, frame } = this.input.pollGamepad(this.race?.driving === true, now, this.local !== null);
    // Free Driving is also the entry point for plug-and-play split screen.
    // Leave keyboard/touch driving intact until a second supported pad appears.
    if (frame?.status === 'ready' && this.race?.driving && this.race.name === 'free_driving'
      && frame.players.filter((c) => c.mapped).length >= 2) {
      const race = this.race;
      const position = race.dm.car.rb.position, quaternion = race.dm.car.rb.quaternion;
      race.sound?.destroy();
      race.dm.destroy();
      this.race = null;
      this.screens.hideRace();
      this.localStart();
      this.local!.updateControllers(frame);
      // Keep P1 where they were driving instead of restarting at the level start.
      const first = this.local!.players[0].driver;
      const offset = position.clone().sub(first.car.rb.position);
      first.car.rb.position = position;
      first.car.rb.quaternion = quaternion;
      first.camera.worldPosition.add(offset);
      first.camera.update();
      console.log('[multiplayer] Free Driving switched to split screen', { players: this.local!.players.length });
      return;
    }
    if (this.local) {
      const wasPaused = this.local.paused;
      if (frame) this.local.updateControllers(frame);
      if (frame && wasPaused && this.local.paused) {
        for (const controller of frame.players) {
          for (const [action, intent] of [['up', 'up'], ['down', 'down'], ['left', 'left'], ['right', 'right'], ['accelerate', 'select']] as const) {
            if (controller.mapped && controller.actions[action]?.pressed) this.screens.navigateLocal(intent);
            if (!this.local) return;
          }
        }
      }
      const menuDown = this.input.keyPressed(53);
      if (menuDown && !this.localMenuHeld) this.local.paused = !this.local.paused;
      this.localMenuHeld = menuDown;
      if (this.local.players.length) this.mixer?.resume();
      return;
    }
    if (waiting !== this.padHint) {
      this.padHint = waiting;
      this.onStatus(waiting ? 'Click the game to use your controller' : '');
    }
    for (const intent of intents) {
      if (this.menuOverride?.(intent)) continue;
      if (this.menuIntentFilter?.(intent)) continue;
      // Like the fade overlay that blocks the pointer: no presses mid-fade.
      if (!this.race && !this.fade) this.screens.navigate(intent);
    }
  }

  private frame(now: number): void {
    this.pollGamepad(now);
    this.accumulator += Math.min(now - this.lastTime, 250);
    this.lastTime = now;
    while (this.accumulator >= ORIGINAL_FRAME_MS) {
      this.directorFrame();
      this.accumulator -= ORIGINAL_FRAME_MS;
    }
    const race = this.race;
    const view = this.view;
    this.renderer.setScissorTest(false);
    this.renderer.setViewport(0, 0, this.host.clientWidth, this.host.clientHeight);
    this.renderer.clear();
    if (this.local && view) {
      const local = this.local;
      const width = Math.max(1, this.host.clientWidth), height = Math.max(1, this.host.clientHeight);
      const rects = splitViewports(local.players.length, width, height);
      view.syncPlayers(local.players.map((p) => ({ car: p.driver.car, color: PLAYER_COLORS[p.slot], label: `P${p.slot + 1}` })), local.dm.rays);
      this.renderer.setScissorTest(true);
      local.players.forEach((player, i) => {
        const rect = rects[i], cam = player.driver.camera;
        this.renderer.setViewport(rect.x, height - rect.y - rect.height, rect.width, rect.height);
        this.renderer.setScissor(rect.x, height - rect.y - rect.height, rect.width, rect.height);
        this.camera.aspect = rect.width / rect.height;
        this.camera.position.copy(cam.worldPosition);
        this.camera.up.copy(cam.up).normalize();
        this.camera.lookAt(cam.target);
        this.applyFov(this.camera, cam.fieldOfView);
        this.renderer.clear();
        this.renderer.render(view.scene, this.camera);
      });
      this.renderer.setScissorTest(false);
      this.screens.renderLocal(local.players, rects, local.paused, local.status);
    } else if (race && view && race.driving) {
      view.sync(race.dm);
      this.camera.aspect = this.host.clientWidth / Math.max(1, this.host.clientHeight);
      const cam = race.dm.camera;
      this.camera.position.copy(cam.worldPosition);
      this.camera.up.copy(cam.up).normalize();
      this.camera.lookAt(cam.target);
      this.applyFov(this.camera, cam.fieldOfView);
      view.syncMode(race.mode, this.camera);
      this.renderer.render(view.scene, this.camera);
      this.arrowCamera.position.copy(this.camera.position);
      this.arrowCamera.quaternion.copy(this.camera.quaternion);
      this.renderer.clearDepth();
      this.renderer.render(view.arrowScene, this.arrowCamera);
      this.screens.renderHud(race.mode);
    }
    requestAnimationFrame((t) => this.frame(t));
  }
}