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/lingo/gameplay.ts
// Ports of the race-time scripts: gameTypeManager and its five modes,
// checkpoint_gametype, chasetimer, pointscounter / pointsgoalcounter,
// stuntdetector, textmessagemanager, 3d_textsprite, pickupManager and arrow.
// See docs/original-game-spec.md §5–6. Presentation (HUD, stars, prizes,
// arrow model) reads the state exposed here each frame.
import { Vector3 } from 'three';
import type { SoundMixer } from '../audio.js';
import type { Input } from '../input.js';
import { findNode, nodePosition, type Markers } from '../level/data.js';
import type { DrivingManager, HavokSubscriber } from './drivingManager.js';

export type ModeName = 'free_driving' | 'checkpoint_chase' | 'stunt_rally' | 'scavenger_hunt' | 'supersonic_challenge';
export const MODE_ORDER: ModeName[] = ['free_driving', 'checkpoint_chase', 'stunt_rally', 'scavenger_hunt', 'supersonic_challenge'];

/** Director integer(): round to nearest. */
const integer = (x: number) => Math.round(x);

/** Everything a race needs from its host. */
export interface RaceEnv {
  dm: DrivingManager;
  input: Input;
  markers: Markers;
  mixer: SoundMixer | null;
  /** "the milliSeconds": game time in ms. */
  ms(): number;
  unlock(mode: ModeName): void;
  /** gameTypeManager.destroy → back to the level select. */
  exit(): void;
}

// --- 3d_textsprite + textmessagemanager ---------------------------------------

export interface TextLine {
  text: string;
  /** Font size in 640x480 stage pixels. */
  size: number;
  color?: string;
}

/** A fading text overlay centred at a 640x480 stage position. */
export class TextSprite {
  blend = 0;
  private target = 100;
  private lastMs: number;
  dead = false;

  constructor(
    readonly lines: TextLine[],
    readonly x: number,
    readonly y: number,
    private readonly env: RaceEnv,
  ) {
    this.lastMs = env.ms();
  }

  stepFrame(): void {
    if (this.dead) return;
    const now = this.env.ms();
    const tDif = Math.max(1, now - this.lastMs);
    this.lastMs = now;
    const b = this.blend + (this.target - this.blend) * Math.pow(0.003, 15 / tDif);
    this.blend = Math.min(100, Math.max(0, b));
    if (this.target === 0 && this.blend < 0.5) this.dead = true;
  }

  fadeOutAndDestroy(): void {
    this.target = 0;
  }

  destroy(): void {
    this.dead = true;
  }
}

export class TextMessageManager {
  current: TextSprite | null = null;
  private startedAt = 0;
  private stopped = false;

  constructor(private readonly env: RaceEnv, private readonly sprites: TextSprite[]) {}

  /** RETURN-separated lines at 24 / 18 / 12 pt, bold italic white, at (320, 400). */
  setText(text: string): void {
    if (this.stopped) return;
    const sizes = [24, 18, 12];
    const lines = text.split('\n').map((t, i) => ({ text: t, size: sizes[i] ?? 12 }));
    this.current?.destroy();
    this.current = new TextSprite(lines, 320, 400, this.env);
    this.sprites.push(this.current);
    this.startedAt = this.env.ms();
  }

  stop(): void {
    this.stopped = true;
  }

  stepFrame(): void {
    if (this.current && this.env.ms() > this.startedAt + 5000) {
      this.current.fadeOutAndDestroy();
      this.current = null;
    }
  }

  destroy(): void {
    this.current?.destroy();
  }
}

// --- chasetimer ------------------------------------------------------------------

export class ChaseTimer {
  private pStartTime = 0;
  private pTotalTime = 0;
  private pStopped = false;
  private pStopTime = 0;
  private pOutOfTime = false;
  private pLastOink = 10;
  text = '00:00';
  readonly label: TextSprite;

  constructor(private readonly env: RaceEnv, private readonly mode: GameType, sprites: TextSprite[]) {
    this.label = new TextSprite([{ text: 'Time remaining', size: 12 }], 72, 20, env);
    sprites.push(this.label);
    this.setTime(20);
    this.pLastOink = 10;
  }

  private simTime(): number {
    return this.env.dm.havokManager.getSimTime();
  }

  setTime(t: number): void {
    this.pStartTime = integer(this.simTime() / 3);
    this.pTotalTime = t;
  }

  addTime(t: number): void {
    this.pTotalTime += t;
    this.pLastOink = Math.min(10, this.pTotalTime);
  }

  getTime(): number {
    if (this.pStopped) return this.pStopTime;
    const t = Math.max(0, integer(-this.simTime() / 3 + this.pStartTime + this.pTotalTime));
    if (t < this.pLastOink) {
      this.pLastOink = t;
      this.env.mixer?.sound(4).play({ member: 'alarm' });
    }
    return t;
  }

  stop(): void {
    this.pStopTime = this.getTime();
    this.pStopped = true;
  }

  stepFrame(): void {
    const t = this.getTime();
    if (t === 0 && !this.pOutOfTime) {
      this.pOutOfTime = true;
      this.mode.outOfTime();
    }
    const mm = Math.floor(t / 60);
    const ss = t - mm * 60;
    this.text = `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
  }
}

// --- points counters -------------------------------------------------------------

export class PointsCounter {
  points = 0;
  goal = 500;
  reachedGoal = false;
  readonly label: TextSprite;

  /** goal = false: "pointscounter" (5 white digits); true: "pointsgoalcounter". */
  constructor(env: RaceEnv, readonly withGoal: boolean, private readonly mode: GameType, sprites: TextSprite[]) {
    this.label = withGoal
      ? new TextSprite([{ text: 'Score / Goal', size: 12 }], 534, 20, env)
      : new TextSprite([{ text: 'Points', size: 12 }], 582, 20, env);
    sprites.push(this.label);
  }

  setGoal(goal: number): void {
    this.goal = goal;
  }

  addPoints(p: number): void {
    this.points = Math.min(99999, this.points + p);
    if (this.withGoal && this.points >= this.goal && !this.reachedGoal) {
      this.reachedGoal = true;
      this.mode.reachedGoal();
    }
  }

  get text(): string {
    if (!this.withGoal) return String(this.points).padStart(5, '0');
    return `${String(this.points).padStart(5, ' ')}/${String(this.goal).padEnd(5, ' ')}`;
  }
}

// --- stuntdetector ---------------------------------------------------------------

export class StuntDetector implements HavokSubscriber {
  private pJumpInProgress = 0;
  private pJumpStartTime = 0;
  private pAngularDistance = 0;
  pMaxCrash = 0;

  constructor(private readonly env: RaceEnv, private readonly mode: GameType) {
    env.dm.registerForUpdate(this);
    const havok = env.dm.havokManager.havok;
    havok.registerInterest(env.dm.car.rb, 1, 0.1, (info) => {
      this.pMaxCrash = Math.max(this.pMaxCrash, info.normalRelativeVelocity);
    });
  }

  havokUpdate(): void {
    const dm = this.env.dm;
    const rb = dm.car.rb;
    const havok = dm.havokManager.havok;
    let air = 1;
    for (const w of dm.car.wheels) if (w.pOnGround) air = 0;
    if (this.pJumpInProgress) this.pAngularDistance += rb.angularVelocity.length() * havok.timeStep;
    if (!air && this.pJumpInProgress) {
      let p = 2;
      const duration = integer(havok.simTime - this.pJumpStartTime);
      p *= Math.max(1, duration / 20);
      p *= Math.max(1, this.pAngularDistance);
      p *= 10;
      if (p > 20) this.mode.stuntEvent(integer(p));
    }
    if (air && !this.pJumpInProgress) {
      this.pJumpStartTime = havok.simTime;
      this.pAngularDistance = 0;
      this.pMaxCrash = 0;
    }
    this.pJumpInProgress = air;
  }
}

export function getStuntText(points: number): string {
  const t = Math.floor(points / 10);
  const table: Array<[number, string]> = [
    [80, 'Inconceivable!'], [75, 'Phenomenal!'], [70, 'Huge stunt!'], [65, 'Fantastic stunt!'],
    [60, 'Mind-blowing!'], [55, 'Extraordinary!'], [50, 'Astonishing!'], [45, 'Unbelievable!'],
    [40, 'Wicked stunt!'], [35, 'Incredible!'], [30, 'Awesome!'], [25, 'Wow!'], [20, 'Amazing!'],
    [15, 'Great stunt!'], [10, 'Nice one!'], [5, 'Nice stunt!'],
  ];
  for (const [limit, text] of table) if (t > limit) return text;
  return 'Not bad!';
}

// --- pickupManager -----------------------------------------------------------------

/** Respawn after falling out of the world; un-flip after 8 sim seconds upside down. */
export class PickupManager {
  private pUpSideDown = 0;
  private pUpSideDownStartTime = 0;

  constructor(private readonly env: { dm: Pick<DrivingManager, 'car' | 'rays' | 'havokManager'> }) {}

  stepFrame(): void {
    const dm = this.env.dm;
    const rb = dm.car.rb;
    const havok = dm.havokManager.havok;
    let upsideNow = 0;
    if (rb.position.z < -10) {
      const p = rb.position;
      p.z = 800;
      const hit = dm.rays.modelsUnderRay(p, new Vector3(0, 0, -1));
      rb.position = hit ? hit.isectPosition.clone().add(new Vector3(0, 0, 30)) : new Vector3(p.x, p.y, 100);
    }
    if (rb.linearVelocity.length() < 10) {
      const zAxis = new Vector3().setFromMatrixColumn(rb.transform, 2);
      if ((zAxis.angleTo(new Vector3(0, 0, -1)) * 180) / Math.PI < 20) {
        if (this.pUpSideDown === 0) this.pUpSideDownStartTime = havok.simTime;
        else if (havok.simTime - this.pUpSideDownStartTime > 8) {
          rb.position = rb.position.add(new Vector3(0, 0, 20));
          rb.quaternion = rb.quaternion.identity();
        }
        this.pUpSideDown = 1;
        upsideNow = 1;
      }
    }
    if (!upsideNow) this.pUpSideDown = 0;
  }
}

// --- gameTypeManager + modes -----------------------------------------------------------

export interface Checkpoint {
  position: Vector3;
  inWorld: boolean;
  /** Accumulated spin about Z, degrees (3 per chunk). */
  spin: number;
}

export interface Prize {
  name: string;
  position: Vector3;
}

export abstract class GameType implements HavokSubscriber {
  timer!: ChaseTimer;
  counter!: PointsCounter;
  text!: TextMessageManager;
  stunts!: StuntDetector;
  pickups!: PickupManager;
  pStopping = false;
  /** Overlay text sprites (labels and messages), drawn by the HUD. */
  readonly sprites: TextSprite[] = [];
  readonly timeouts: Array<{ at: number; fn: () => void }> = [];
  arrow = { visible: false, target: new Vector3() };
  checkpoints: Checkpoint[] = [];
  prizes: Prize[] = [];
  destroyed = false;

  constructor(protected readonly env: RaceEnv) {}

  protected abstract withGoalCounter(): boolean;

  /** gameTypeManager.realStart: the drivingManager has just started. */
  realStart(): void {
    this.env.dm.registerForUpdate(this);
    this.env.dm.car.onStopDriving = () => this.timer.stop();
    this.timer = new ChaseTimer(this.env, this, this.sprites);
    this.counter = new PointsCounter(this.env, this.withGoalCounter(), this, this.sprites);
    this.text = new TextMessageManager(this.env, this.sprites);
    this.stunts = new StuntDetector(this.env, this);
    this.pickups = new PickupManager(this.env);
  }

  /** realStart2: the driving view appears. */
  realStart2(): void {}

  havokUpdate(): void {
    if (this.env.input.keyPressed(53)) {
      this.stopDriving();
      this.destroy();
    }
  }

  stuntEvent(points: number): void {
    this.text.setText(`${getStuntText(points)}\n${points} Points!`);
    this.counter.addPoints(points);
  }

  reachedGoal(): void {}
  outOfTime(): void {}

  stopDriving(): void {
    this.pStopping = true;
    this.timer.stop();
    this.env.dm.stopDriving();
  }

  protected sting(member: string): void {
    this.env.mixer?.sound(4).play({ member });
  }

  protected after(ms: number, fn: () => void): void {
    this.timeouts.push({ at: this.env.ms() + ms, fn });
  }

  /** Per Director frame, after drivingManager (actorList order). */
  stepFrame(): void {
    this.pickups.stepFrame();
    for (const s of this.sprites) s.stepFrame();
    this.timer.stepFrame();
    this.text.stepFrame();
    for (let i = this.sprites.length - 1; i >= 0; i--) if (this.sprites[i].dead) this.sprites.splice(i, 1);
    const now = this.env.ms();
    for (const t of [...this.timeouts]) {
      if (now >= t.at) {
        this.timeouts.splice(this.timeouts.indexOf(t), 1);
        t.fn();
      }
    }
  }

  destroy(): void {
    if (this.destroyed) return;
    this.destroyed = true;
    this.text.destroy();
    this.env.exit();
  }
}

/** checkpoint_gametype: ordered stars 1..N, a 300-unit pass radius, and the arrow. */
abstract class CheckpointGameType extends GameType {
  protected pCheckPointIndex = 0; // 0-based here (Lingo: 1-based)
  protected pLaps = 0;
  protected pHidden = false;

  override realStart(): void {
    super.realStart();
    this.checkpoints = [];
    for (let i = 1; ; i++) {
      const g = findNode(this.env.markers.level, `checkpoint_${i}`);
      if (!g) break;
      this.checkpoints.push({ position: nodePosition(g).sub(new Vector3(0, 0, 50)), inWorld: true, spin: 0 });
    }
    this.pCheckPointIndex = 0;
    this.arrow = { visible: true, target: this.checkpoints[0].position.clone() };
  }

  override havokUpdate(): void {
    super.havokUpdate();
    if (this.pStopping || this.pHidden) return;
    const cp = this.checkpoints[this.pCheckPointIndex];
    if (this.env.dm.car.rb.position.distanceTo(cp.position) < 300) {
      this.sting('stingcheckpoint');
      this.passedCheckPoint();
      this.showThisCheckpoint(this.pCheckPointIndex);
    }
    const current = this.checkpoints[this.pCheckPointIndex];
    if (current) current.spin += 3;
  }

  protected abstract passedCheckPoint(): void;

  protected hideCheckpoints(): void {
    for (const c of this.checkpoints) c.inWorld = false;
    this.pHidden = true;
  }

  protected showThisCheckpoint(i: number): void {
    this.checkpoints.forEach((c, k) => (c.inWorld = k === i));
  }

  protected pointArrowAtCurrent(): void {
    this.arrow.target.copy(this.checkpoints[this.pCheckPointIndex].position);
  }
}

export class FreeDriving extends CheckpointGameType {
  private pReachedGoal = false;
  protected withGoalCounter(): boolean {
    return false;
  }

  override realStart(): void {
    super.realStart();
    this.timer.setTime(150);
  }

  protected passedCheckPoint(): void {
    this.text.setText('Checkpoint!\n+2 Seconds');
    this.timer.addTime(2);
    this.pCheckPointIndex++;
    if (this.pCheckPointIndex >= this.checkpoints.length) {
      this.pCheckPointIndex = 0;
      this.pLaps++;
      if (!this.pReachedGoal && this.pLaps > 1) {
        this.sting('stingwin');
        this.env.unlock('checkpoint_chase');
        this.text.setText('You did it!\nCheckpoint Chase unlocked!\nFree Driving Mode - Press ESC to exit');
        this.pReachedGoal = true;
        this.arrow.visible = false;
      }
    }
    this.pointArrowAtCurrent();
  }

  override outOfTime(): void {
    if (this.pReachedGoal) return;
    this.text.setText('Out of Time!\nFinish 2 laps in under 2:30 to unlock Checkpoint Chase\nFree Driving Mode - Press ESC to exit');
    this.hideCheckpoints();
    this.arrow.visible = false;
  }
}

export class CheckpointChase extends CheckpointGameType {
  protected withGoalCounter(): boolean {
    return false;
  }

  override realStart(): void {
    super.realStart();
    this.timer.setTime(10);
  }

  protected passedCheckPoint(): void {
    this.text.setText('Checkpoint!\n+10 Seconds');
    this.timer.addTime(10);
    this.pCheckPointIndex++;
    if (this.pCheckPointIndex >= this.checkpoints.length) {
      this.pCheckPointIndex = 0;
      this.pLaps++;
      if (this.pLaps > 1) {
        this.sting('stingwin');
        this.env.unlock('stunt_rally');
        this.text.setText('You did it!\nStunt Rally unlocked!\n');
        this.text.stop();
        this.stopDriving();
        this.after(6000, () => this.destroy());
        this.arrow.visible = false;
      }
    }
    this.pointArrowAtCurrent();
  }

  override outOfTime(): void {
    this.sting('stingloseloop');
    this.text.setText('Out of Time!\nHit all Checkpoints to unlock Stunt Rally');
    this.text.stop();
    this.stopDriving();
    this.after(6000, () => this.destroy());
    this.arrow.visible = false;
  }
}

export class StuntRally extends GameType {
  private pReachedGoal = false;
  private pOutOfTime = false;
  protected withGoalCounter(): boolean {
    return true;
  }

  override realStart(): void {
    super.realStart();
    this.timer.setTime(120);
    this.counter.setGoal(2500);
  }

  override reachedGoal(): void {
    if (this.pOutOfTime) return;
    this.pReachedGoal = true;
    this.sting('stingwin');
    this.env.unlock('scavenger_hunt');
    this.text.setText('You did it!\nScavenger Hunt Unlocked!');
    this.text.stop();
    this.after(6000, () => this.destroy());
    this.env.dm.car.stopDriving();
  }

  override outOfTime(): void {
    if (this.pReachedGoal) return;
    this.pOutOfTime = true;
    this.env.dm.car.stopDriving();
    this.sting('stingloseloop');
    this.text.setText('Out of Time!\nGet 2500 points to unlock Scavenger Hunt');
    this.text.stop();
    this.after(6000, () => this.destroy());
  }
}

function spawnPrizes(markers: Markers): Prize[] {
  const prizes: Prize[] = [];
  for (let i = 1; i <= 139; i++) {
    const g = findNode(markers.level, `prizes_${i}`);
    if (g) prizes.push({ name: `prize${i}`, position: nodePosition(g) });
  }
  return prizes;
}

/** scavenger_hunt / supersonic_challenge prize pickup: radius 100, +100 points. */
function collectPrizes(mode: GameType, env: RaceEnv, sting: (m: string) => void): void {
  const car = env.dm.car.rb.position;
  for (let i = mode.prizes.length - 1; i >= 0; i--) {
    if (car.distanceTo(mode.prizes[i].position) < 100) {
      mode.prizes.splice(i, 1);
      sting('stinghiddentreasure');
      mode.counter.addPoints(100);
      mode.text.setText('+100 Points');
    }
  }
}

export class ScavengerHunt extends GameType {
  private pReachedGoal = false;
  private pOutOfTime = false;
  protected withGoalCounter(): boolean {
    return true;
  }

  override realStart(): void {
    super.realStart();
    this.prizes = spawnPrizes(this.env.markers);
    this.counter.setGoal(3000);
    this.timer.setTime(5 * 60);
  }

  override stuntEvent(): void {
    // Stunts score nothing in the scavenger hunt.
  }

  override havokUpdate(): void {
    super.havokUpdate();
    if (this.pStopping) return;
    collectPrizes(this, this.env, (m) => this.sting(m));
  }

  override reachedGoal(): void {
    if (this.pOutOfTime) return;
    this.pReachedGoal = true;
    this.sting('stingwin');
    this.env.unlock('supersonic_challenge');
    this.text.setText('You did it!\nSupersonic Challenge Unlocked!');
    this.text.stop();
    this.env.dm.car.stopDriving();
    this.after(6000, () => this.destroy());
  }

  override outOfTime(): void {
    if (this.pReachedGoal) return;
    this.pOutOfTime = true;
    this.sting('stingloseloop');
    this.text.setText('Out of Time!\nGet 3000 points to unlock Supersonic Challenge\nPress ESC to exit');
    this.text.stop();
    this.env.dm.car.stopDriving();
    this.after(6000, () => this.destroy());
  }
}

export class SupersonicChallenge extends CheckpointGameType {
  private pReachedGoal = false;
  private pOutOfTime = false;
  protected withGoalCounter(): boolean {
    return true;
  }

  override realStart(): void {
    super.realStart();
    this.prizes = spawnPrizes(this.env.markers);
    this.timer.setTime(5 * 60);
    this.counter.setGoal(15000);
  }

  override havokUpdate(): void {
    super.havokUpdate();
    if (this.pStopping) return;
    collectPrizes(this, this.env, (m) => this.sting(m));
  }

  override stuntEvent(points: number): void {
    this.text.setText(`${getStuntText(points)}\n+${points} Points!`);
    this.counter.addPoints(points);
  }

  protected passedCheckPoint(): void {
    this.pCheckPointIndex++;
    if (this.pCheckPointIndex >= this.checkpoints.length) {
      this.pCheckPointIndex = 0;
      this.pLaps++;
      this.arrow.visible = false;
      this.hideCheckpoints();
      if (this.pLaps > 0 && this.pReachedGoal) {
        this.win();
        return;
      }
    }
    this.pointArrowAtCurrent();
    this.timer.addTime(2);
    this.counter.addPoints(500);
    this.text.setText('Checkpoint!\n+2 Seconds! +500 Points!');
  }

  override showThisCheckpoint(i: number): void {
    if (this.pHidden) return;
    super.showThisCheckpoint(i);
  }

  override reachedGoal(): void {
    if (this.pOutOfTime) return;
    this.pReachedGoal = true;
    if (this.pLaps > 0) this.win();
  }

  private win(): void {
    this.sting('stingwin');
    this.text.setText("\nCongratulations! You've won the Supersonic Challenge!\nPress ESC to return to the main menu.");
    this.text.stop();
    this.env.dm.car.stopDriving();
    this.after(6000, () => this.destroy());
  }

  override outOfTime(): void {
    if (this.pReachedGoal && this.pLaps > 1) return;
    this.pOutOfTime = true;
    this.sting('stingloseloop');
    this.text.setText('Out of Time!\nGet 15000 Points and 1 Lap to win the game.\nPress ESC to exit');
    this.text.stop();
    this.env.dm.car.stopDriving();
    this.after(6000, () => this.destroy());
  }
}

export function createMode(name: ModeName, env: RaceEnv): GameType {
  switch (name) {
    case 'free_driving':
      return new FreeDriving(env);
    case 'checkpoint_chase':
      return new CheckpointChase(env);
    case 'stunt_rally':
      return new StuntRally(env);
    case 'scavenger_hunt':
      return new ScavengerHunt(env);
    case 'supersonic_challenge':
      return new SupersonicChallenge(env);
  }
}