Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

Release
bb8b570f3600…
Author-recorded commit
073e2d2c0e63…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/SuperSonicRCRevive

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

src/game/lingo/gameplay.ts
// Free Driving feedback, stunt scoring and automatic car recovery.
import { Vector3 } from 'three';
import type { SoundMixer } from '../audio.js';
import type { Input } from '../input.js';
import { type Markers } from '../level/data.js';
import type { DrivingManager, HavokSubscriber } from './drivingManager.js';

export type ModeName = 'free_driving';

/** 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;
  /** gameTypeManager.destroy → back to the title screen. */
  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;
  private readonly spawnPosition: Vector3;
  private readonly spawnQuaternion;

  constructor(private readonly env: { dm: Pick<DrivingManager, 'car' | 'rays' | 'havokManager'> }) {
    this.spawnPosition = env.dm.car.rb.position;
    this.spawnQuaternion = env.dm.car.rb.quaternion;
  }

  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)) : this.spawnPosition.clone();
      rb.quaternion = this.spawnQuaternion.clone();
      rb.linearVelocity = new Vector3();
      rb.angularVelocity = new Vector3();
      this.pUpSideDown = 0;
    }
    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();
    // Free Driving has no countdown.
    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();
  }
}

/** Unrestricted driving: stunt feedback and recovery, without challenges or unlocks. */
export class FreeDriving extends GameType {
  protected withGoalCounter(): boolean { return false; }

  override realStart(): void {
    super.realStart();
    this.timer.stop();
    this.timer.label.destroy();
  }
}

export function createMode(_name: ModeName, env: RaceEnv): GameType {
  return new FreeDriving(env);
}