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.
// 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);
}
}
