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/localMultiplayer.ts
import { Quaternion, Vector3 } from 'three';
import type { ControllerFrame } from '../../docs/examples/gamepad.js';
import { Input } from './input.js';
import type { LevelData } from './level/data.js';
import { PickupManager } from './lingo/gameplay.js';
import { CarSound } from './lingo/carSound.js';
import { CAR_START_POSITION } from './lingo/car.js';
import { carStart, DrivingManager } from './lingo/drivingManager.js';
import type { SoundMixer } from './audio.js';

export const PLAYER_COLORS = ['#ffcf33', '#4cbcff', '#ff6680', '#7ee894'];
export interface Viewport { x: number; y: number; width: number; height: number }
/** Coordinates are CSS pixels from the top left. */
export function splitViewports(count: number, width: number, height: number): Viewport[] {
  if (!count) return [];
  // Classic horizontal split: P1 above P2, then a 2×2 grid for 3–4.
  const cols = count <= 2 ? 1 : 2;
  const rows = Math.ceil(count / cols);
  return Array.from({ length: count }, (_, i) => {
    const col = i % cols, row = Math.floor(i / cols);
    const x = Math.floor(col * width / cols), y = Math.floor(row * height / rows);
    return { x, y, width: Math.floor((col + 1) * width / cols) - x, height: Math.floor((row + 1) * height / rows) - y };
  });
}
export interface LocalPlayer {
  slot: number;
  controllerIndex: number;
  input: Input;
  driver: DrivingManager['players'][number];
  sound: CarSound | null;
  recovery: PickupManager;
}

/** One physics world, one poll per browser frame, up to four independent cars. */
export class LocalMultiplayer {
  readonly dm: DrivingManager;
  readonly players: LocalPlayer[] = [];
  paused = false;
  status = 'Connect a controller. If it is not detected, press any button.';

  constructor(private readonly level: LevelData, private readonly mixer: SoundMixer | null) {
    this.dm = new DrivingManager(level, new Input(), {}, false);
    // Music belongs to the session, rather than to each joining car.
    mixer?.sound(8).stop();
    mixer?.sound(8).play({ member: 'musicloop1', loopCount: 0 });
    if (mixer) mixer.sound(8).volume = 75;
  }

  updateControllers(frame: ControllerFrame): void {
    const present = new Set(frame.players.filter((c) => c.mapped).map((c) => c.index));
    // An unavailable API is not evidence that all controllers were unplugged.
    if (frame.status !== 'blocked' && frame.status !== 'unavailable') {
      for (const player of [...this.players]) {
        if (!present.has(player.controllerIndex) || frame.disconnected.includes(player.controllerIndex)) this.remove(player);
      }
      for (const controller of frame.players) {
        if (!controller.mapped || this.players.some((p) => p.controllerIndex === controller.index) || this.players.length >= 4) continue;
        const slot = [0, 1, 2, 3].find((s) => !this.players.some((p) => p.slot === s))!;
        const input = new Input();
        const driver = this.dm.addPlayer(input, this.spawn(slot), `car-p${slot + 1}`);
        driver.camera.worldPosition.copy(driver.car.rb.position).add(new Vector3(0, -70, 30));
        driver.camera.update();
        driver.camera.fadeIn();
        const sound = this.mixer ? new CarSound(driver.car, input, this.mixer, 10 + slot * 3, false) : null;
        driver.car.sound = sound;
        const recovery = new PickupManager({ dm: { car: driver.car, rays: this.dm.rays, havokManager: this.dm.havokManager } });
        this.players.push({ slot, controllerIndex: controller.index, input, driver, sound, recovery });
        this.players.sort((a, b) => a.slot - b.slot);
        console.log('[multiplayer] player added', { player: slot + 1, controllerIndex: controller.index, players: this.players.length });
      }
    }
    for (const player of this.players) {
      const controller = frame.players.find((c) => c.index === player.controllerIndex);
      player.input.clear();
      if (controller && frame.status === 'ready' && !this.paused) player.input.applyController(controller);
    }
    if (frame.status === 'inactive') this.paused = true;
    const unsupported = frame.players.filter((c) => !c.mapped);
    this.status = frame.status === 'blocked' || frame.status === 'unavailable'
      ? 'Controller access unavailable. Return to the menu for single-player controls.'
      : unsupported.length
        ? `Controller ${unsupported.map((c) => c.index + 1).join(', ')} detected without a supported mapping; unable to add ${unsupported.length === 1 ? 'its car' : 'their cars'}.`
        : this.players.length
          ? ''
          : 'No controllers detected. Click the game, then press a controller button.';
    if (frame.players.some((c) => c.mapped && c.actions.menu?.pressed)) this.paused = !this.paused;
    if (this.paused) for (const player of this.players) player.input.clear();
  }

  private spawn(slot: number) {
    const base = carStart(this.level) ?? { position: CAR_START_POSITION.clone(), quaternion: new Quaternion() };
    // Search around the level's start for ground and a gap from existing cars.
    for (let attempt = 0; attempt < 32; attempt++) {
      const offset = new Vector3(((slot + attempt) % 4 - 1.5) * 100, -Math.floor(attempt / 4) * 100, 0);
      offset.applyQuaternion(base.quaternion ?? new Quaternion());
      const candidate = base.position.clone().add(offset);
      const hit = this.dm.rays.modelsUnderRay(candidate.clone().add(new Vector3(0, 0, 300)), new Vector3(0, 0, -1));
      if (!hit || hit.isectNormal.z < 0.65 || Math.abs(hit.isectPosition.z - base.position.z) > 250) continue;
      candidate.z = hit.isectPosition.z + 40;
      if (this.players.every((p) => p.driver.car.rb.position.distanceTo(candidate) > 85)) return { position: candidate, quaternion: base.quaternion };
    }
    // Keep a unique fallback above the start for unusually small custom levels.
    return { position: base.position.clone().add(new Vector3(0, 0, 100 + slot * 100)), quaternion: base.quaternion };
  }

  step(elapsedMs: number): void {
    if (!this.paused && this.players.length) {
      this.dm.stepFrame(elapsedMs);
      for (const player of this.players) player.recovery.stepFrame();
    }
  }
  private remove(player: LocalPlayer): void {
    player.input.clear();
    player.sound?.destroy();
    this.dm.removePlayer(player.driver);
    this.players.splice(this.players.indexOf(player), 1);
    console.log('[multiplayer] player removed', { player: player.slot + 1, controllerIndex: player.controllerIndex, players: this.players.length });
  }
  destroy(): void {
    for (const player of [...this.players]) this.remove(player);
    this.mixer?.sound(8).stop();
    this.dm.destroy();
  }
}