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.
// DOM presentation of the original's 640x480 screens and race HUD.
// Original bitmaps are used when bundled; otherwise an HTML stand-in renders
// the same layout (the path for builds without the original art).
import { assetUrl } from '../assets.js';
import type { DirectorKey, Input, MenuIntent } from '../input.js';
import { PLAYER_COLORS, type Viewport } from '../localMultiplayer.js';
import { MODE_ORDER, type GameType, type ModeName } from '../lingo/gameplay.js';
const STAGE_W = 640;
const STAGE_H = 480;
const MODE_INFO: Record<ModeName, { label: string; art: string; rule: string }> = {
free_driving: { label: 'Free Driving', art: 'Free', rule: 'Race through all checkpoints. Complete two laps in under 2:30 to unlock Checkpoint Chase.' },
checkpoint_chase: { label: 'Checkpoint Chase', art: 'Checkpoint', rule: 'Complete two laps. Make it to each checkpoint in under 10 seconds to unlock Stunt Rally.' },
stunt_rally: { label: 'Stunt Rally', art: 'Stunt', rule: 'Pull off stunts to gain points. Reach 2500 points in 2:00 to unlock Scavenger Hunt.' },
scavenger_hunt: { label: 'Scavenger Hunt', art: 'Scavenger', rule: 'Find as many hidden prizes as you can to add points. Gain 3000 to unlock Supersonic Challenge.' },
supersonic_challenge: { label: 'Supersonic Challenge', art: 'Supersonic', rule: 'Race through checkpoints, pull stunts, and find prizes. Get 15,000 points to win the Challenge!' },
};
/** Button rects (stage px) from the original score: sprite loc (320,240) minus reg point. */
const BUTTON_RECTS: Record<ModeName, [number, number, number, number]> = {
free_driving: [30, 67, 321, 123],
checkpoint_chase: [30, 150, 321, 207],
stunt_rally: [30, 234, 321, 291],
scavenger_hunt: [30, 318, 321, 375],
supersonic_challenge: [30, 402, 321, 459],
};
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
const e = document.createElement(tag);
if (className) e.className = className;
if (text !== undefined) e.textContent = text;
return e;
}
/** Focus from a controller: show the focus ring, as keyboard focus does. */
export function padFocus(target: HTMLElement): void {
for (const old of document.querySelectorAll('.pad-focus')) old.classList.remove('pad-focus');
target.focus({ focusVisible: true } as FocusOptions);
target.classList.add('pad-focus');
target.addEventListener('blur', () => target.classList.remove('pad-focus'), { once: true });
}
/**
* Move controller focus through `targets` (in DOM order, wrapping): up/left go
* back, down/right forward. Returns the focused target.
*/
export function stepFocus(targets: HTMLElement[], intent: MenuIntent): HTMLElement | null {
if (!targets.length) return null;
const at = targets.indexOf(document.activeElement as HTMLElement);
const step = intent === 'up' || intent === 'left' ? -1 : 1;
const next = at < 0 ? (step > 0 ? 0 : targets.length - 1) : (at + step + targets.length) % targets.length;
padFocus(targets[next]);
return targets[next];
}
/** Press a button from a controller as a pointer would (click sound, down art), then click it. */
export function padPress(button: HTMLElement): void {
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
button.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
button.click();
}
/** Enabled, displayed controls under `root`, in DOM order. */
export function focusTargets(root: HTMLElement, selector = 'button'): HTMLElement[] {
return [...root.querySelectorAll<HTMLElement>(selector)].filter(
(e) => !(e as HTMLButtonElement).disabled && e.getAttribute('aria-disabled') !== 'true' && e.getClientRects().length > 0,
);
}
export interface ScreenHandlers {
/** standardButton mouseDown: the "buttonclick" sound. */
click(): void;
}
export class Screens {
readonly root: HTMLDivElement;
private readonly stage: HTMLDivElement;
private readonly fade: HTMLDivElement;
private readonly hud: HTMLDivElement;
private readonly touch: HTMLDivElement;
private hudParts: { timer: HTMLDivElement; counter: HTMLDivElement; texts: HTMLDivElement } | null = null;
private textNodes = new Map<object, HTMLDivElement>();
private scale = 1;
private localLayer: HTMLDivElement | null = null;
private localSignature = '';
private localStatus: HTMLDivElement | null = null;
private pausePanel: HTMLDivElement | null = null;
constructor(host: HTMLElement, private readonly handlers: ScreenHandlers, private readonly input: Input) {
this.root = el('div', 'screens');
this.stage = el('div', 'stage640');
this.hud = el('div', 'hud-layer');
this.touch = el('div', 'touch-layer');
this.fade = el('div', 'fade');
this.root.append(this.hud, this.touch, this.stage, this.fade);
host.append(this.root);
new ResizeObserver(() => this.layout()).observe(host);
this.layout();
this.buildTouch();
}
private layout(): void {
const w = this.root.clientWidth || 1;
const h = this.root.clientHeight || 1;
this.scale = Math.min(w / STAGE_W, h / STAGE_H);
this.stage.style.transform = `translate(-50%, -50%) scale(${this.scale})`;
this.root.style.setProperty('--hud-scale', String(Math.min(2, Math.max(0.6, this.scale))));
this.root.style.setProperty('--stage-scale', String(this.scale));
}
/**
* A controller press on the title, select or help screen: move focus between
* its buttons (locked modes skipped) or press the focused one. The original
* has no back action, so B does nothing here.
*/
navigate(intent: MenuIntent): void {
if (this.stage.hidden || intent === 'back') return;
const targets = focusTargets(this.stage);
if (intent === 'select' || intent === 'start') {
const focused = targets.find((t) => t === document.activeElement) ?? targets[0];
if (focused) padPress(focused);
} else stepFocus(targets, intent);
}
/** Fade overlay opacity 0..1 (fadein / fadeout objects). */
setFade(opacity: number): void {
this.fade.style.opacity = String(opacity);
this.fade.style.pointerEvents = opacity > 0.01 ? 'auto' : 'none';
}
private clear(): void {
this.stage.replaceChildren();
this.stage.hidden = false;
}
private art(name: string, ext = 'png'): string | undefined {
return assetUrl(`ui/${name}.${ext}`);
}
/**
* titleManager: full-screen standardButton; any click continues. `packSwitch`
* adds an "Asset pack…" button under "Click to Start" (in the corner over the
* original art, which paints that text along its bottom edge).
*/
showTitle(next: () => void, packSwitch: (() => Promise<void>) | null = null): void {
this.clear();
const url = this.art('Title-v8', 'jpg');
const button = el('button', 'screen-button title');
button.setAttribute('aria-label', 'Supersonic RC. Click to start');
if (url) button.style.backgroundImage = `url(${url})`;
else button.append(el('span', 'fallback-title', 'Supersonic RC'), el('span', 'fallback-hint', 'Click to Start'));
button.addEventListener('pointerdown', () => this.handlers.click());
button.addEventListener('click', () => next());
this.stage.append(button);
if (packSwitch) {
const pack = el('button', `title-pack-button${url ? ' over-art' : ''}`, 'Asset pack…');
pack.type = 'button';
pack.title = 'Load another asset pack (.ssrcpack) or level (.glb) from a Blossom link, hash or file';
pack.addEventListener('pointerdown', () => this.handlers.click());
pack.addEventListener('click', () => {
pack.disabled = true;
void packSwitch().finally(() => {
pack.disabled = false;
// Back from the pack panel (cancelled): keep keyboard focus here.
if (pack.isConnected && document.activeElement === document.body) pack.focus();
});
});
this.stage.append(pack);
}
button.focus();
}
showHelp(next: () => void): void {
this.clear();
const url = this.art('Help-v5', 'jpg');
const button = el('button', 'screen-button help');
button.setAttribute('aria-label', 'Controls. Click to start');
if (url) button.style.backgroundImage = `url(${url})`;
else {
const list = el('div', 'fallback-help');
list.append(el('h2', '', 'Controls'));
for (const [k, v] of [['↑', 'Accelerate'], ['↓', 'Brake / Reverse'], ['← →', 'Steer'], ['Space', 'Hard brake'], ['Esc', 'Quit to menu'], ['M', 'Sound on/off']]) {
const row = el('p');
row.append(el('kbd', '', k), document.createTextNode(` ${v}`));
list.append(row);
}
const pad = el('p', 'fallback-pad');
pad.append(document.createTextNode('Controller: '));
for (const [k, v] of [['A', 'accelerate'], ['X', 'brake / reverse'], ['Stick', 'steer'], ['B', 'hard brake'], ['≡', 'quit']]) {
pad.append(el('kbd', '', k), document.createTextNode(` ${v} `));
}
list.append(pad);
list.append(el('p', 'fallback-hint', 'The arrow at the bottom of your screen points to the next checkpoint. Click to Start'));
button.append(list);
}
button.addEventListener('pointerdown', () => this.handlers.click());
button.addEventListener('click', () => next());
this.stage.append(button);
button.focus();
}
showSelect(unlocked: Set<ModeName>, pick: (mode: ModeName) => void, local?: () => void): void {
this.clear();
const bg = this.art('Background', 'jpg');
const page = el('div', 'select-page');
if (bg) page.style.backgroundImage = `url(${bg})`;
else page.append(el('h2', 'fallback-heading', 'Choose your challenge'));
for (const mode of MODE_ORDER) {
const info = MODE_INFO[mode];
const enabled = unlocked.has(mode);
const [l, t, r, b] = BUTTON_RECTS[mode];
const button = el('button', 'level-button');
button.style.left = `${l}px`;
button.style.top = `${t}px`;
button.style.width = `${r - l}px`;
button.style.height = `${b - t}px`;
button.setAttribute('aria-label', `${info.label}${enabled ? '' : ' (locked)'}`);
button.setAttribute('aria-disabled', String(!enabled));
const normal = this.art(info.art);
if (normal) {
const states = { normal, over: this.art(`${info.art}Over`) ?? normal, down: this.art(`${info.art}Down`) ?? normal, gray: this.art(`${info.art}Gray`) ?? normal };
const set = (s: keyof typeof states) => (button.style.backgroundImage = `url(${states[s]})`);
set(enabled ? 'normal' : 'gray');
if (enabled) {
button.addEventListener('pointerenter', () => set('over'));
button.addEventListener('pointerleave', () => set('normal'));
button.addEventListener('pointerdown', () => set('down'));
button.addEventListener('pointerup', () => set('over'));
button.addEventListener('focus', () => set('over'));
button.addEventListener('blur', () => set('normal'));
}
} else {
button.classList.add('fallback');
button.textContent = info.label;
if (!bg) {
const rule = el('p', 'fallback-rule', info.rule);
rule.style.top = `${t}px`;
page.append(rule);
}
}
// standardButton plays the click sound on mouseDown even when disabled.
button.addEventListener('pointerdown', () => this.handlers.click());
button.addEventListener('click', () => {
if (enabled) pick(mode);
});
page.append(button);
}
if (local) {
const multiplayer = el('button', 'local-select-button', 'Local Multiplayer · 1–4');
multiplayer.addEventListener('click', () => { this.handlers.click(); local(); });
page.append(multiplayer);
}
this.stage.append(page);
(page.querySelector('button[aria-disabled="false"]') as HTMLButtonElement | null)?.focus();
}
showLoading(): void {
this.clear();
const page = el('div', 'loading-page');
page.append(el('div', 'loading-text', 'Initializing'));
this.stage.append(page);
}
/** Race view: the stage hides; the HUD and touch controls take over. */
showRace(): void {
this.stage.hidden = true;
this.hud.replaceChildren();
const timer = el('div', 'hud-timer');
const counter = el('div', 'hud-counter');
const texts = el('div', 'hud-texts');
this.hud.append(timer, counter, texts);
this.hudParts = { timer, counter, texts };
this.textNodes.clear();
this.touch.classList.add('active');
}
showLocal(resume: () => void, quit: () => void): void {
this.stage.hidden = true;
this.hud.replaceChildren();
this.hudParts = null;
this.touch.classList.remove('active');
this.localLayer = el('div', 'local-layer');
this.localStatus = el('div', 'local-status');
this.localStatus.setAttribute('role', 'status');
this.pausePanel = el('div', 'local-pause');
this.pausePanel.hidden = true;
const again = el('button', '', 'Resume');
again.addEventListener('click', resume);
const exit = el('button', '', 'Return to menu');
exit.addEventListener('click', quit);
this.pausePanel.append(el('strong', '', 'Paused'), again, exit);
const menu = el('button', 'local-menu', '≡');
menu.setAttribute('aria-label', 'Menu');
menu.addEventListener('click', () => this.input.pulseAction(53));
this.localSignature = '';
this.root.append(this.localLayer, this.localStatus, this.pausePanel, menu);
}
renderLocal(players: Array<{ slot: number }>, rects: Viewport[], paused: boolean, status: string): void {
if (!this.localLayer || !this.localStatus || !this.pausePanel) return;
const signature = JSON.stringify([players.map((p) => p.slot), rects]);
if (signature !== this.localSignature) {
this.localSignature = signature;
this.localLayer.replaceChildren();
players.forEach((player, i) => {
const panel = el('div', 'local-player');
const rect = rects[i];
Object.assign(panel.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px` });
panel.style.setProperty('--player-color', PLAYER_COLORS[player.slot]);
panel.append(el('span', 'local-player-label', `P${player.slot + 1}`));
this.localLayer!.append(panel);
});
}
this.localStatus.textContent = status;
this.localStatus.hidden = !status;
const wasHidden = this.pausePanel.hidden;
this.pausePanel.hidden = !paused;
if (paused && wasHidden) (this.pausePanel.querySelector('button') as HTMLButtonElement).focus();
}
navigateLocal(intent: MenuIntent): void {
if (!this.pausePanel || this.pausePanel.hidden) return;
const targets = focusTargets(this.pausePanel);
if (intent === 'select' || intent === 'start') padPress(targets.find((t) => t === document.activeElement) ?? targets[0]);
else if (intent !== 'back') stepFocus(targets, intent);
}
hideLocal(): void {
for (const node of this.root.querySelectorAll('.local-layer, .local-status, .local-pause, .local-menu')) node.remove();
this.localLayer = this.localStatus = this.pausePanel = null;
}
hideRace(): void {
this.hud.replaceChildren();
this.hudParts = null;
this.touch.classList.remove('active');
}
/** Per frame: timer, counter and fading text sprites. */
renderHud(mode: GameType): void {
const parts = this.hudParts;
if (!parts) return;
parts.timer.textContent = mode.timer.text;
const c = mode.counter;
if (c.withGoal) {
const [score, goal] = c.text.split('/');
parts.counter.replaceChildren(
el('span', c.reachedGoal ? 'goal' : 'score', score),
el('span', 'slash', '/'),
el('span', 'goal', goal),
);
} else {
parts.counter.textContent = c.text;
}
const alive = new Set<object>();
for (const sprite of mode.sprites) {
alive.add(sprite);
let node = this.textNodes.get(sprite);
if (!node) {
node = el('div', 'hud-text');
for (const line of sprite.lines) {
const p = el('div', 'line', line.text || ' ');
p.style.fontSize = `calc(${line.size}px * var(--hud-scale))`;
if (line.text === 'Score / Goal') p.innerHTML = '<span class="score">Score</span> / <span class="goal">Goal</span>';
node.append(p);
}
// Anchor to the nearest stage edge so the HUD survives any aspect ratio.
if (sprite.x < 200) node.style.left = `calc(${sprite.x}px * var(--hud-scale))`;
else if (sprite.x > 440) node.style.right = `calc(${STAGE_W - sprite.x}px * var(--hud-scale))`;
else node.style.left = '50%';
if (sprite.y > 240) node.style.bottom = `calc(${STAGE_H - sprite.y}px * var(--hud-scale))`;
else node.style.top = `calc(${sprite.y}px * var(--hud-scale))`;
node.dataset.anchor = sprite.x < 200 ? 'left' : sprite.x > 440 ? 'right' : 'center';
parts.texts.append(node);
this.textNodes.set(sprite, node);
}
node.style.opacity = String(sprite.blend / 100);
}
for (const [sprite, node] of this.textNodes) {
if (!alive.has(sprite)) {
node.remove();
this.textNodes.delete(sprite);
}
}
}
private buildTouch(): void {
const pad = (cls: string, label: string, key: DirectorKey) => {
const b = el('button', `touch-pad ${cls}`, label);
b.setAttribute('aria-label', cls);
const down = (e: PointerEvent) => {
e.preventDefault();
b.setPointerCapture(e.pointerId);
this.input.setVirtual(key, true);
b.classList.add('held');
};
const up = () => {
this.input.setVirtual(key, false);
b.classList.remove('held');
};
b.addEventListener('pointerdown', down);
b.addEventListener('pointerup', up);
b.addEventListener('pointercancel', up);
b.addEventListener('lostpointercapture', up);
return b;
};
const left = el('div', 'touch-group left');
left.append(pad('steer-left', '◀', 123), pad('steer-right', '▶', 124));
const right = el('div', 'touch-group right');
right.append(pad('brake', '▼', 125), pad('handbrake', '⏸', ' '), pad('throttle', '▲', 126));
const menu = pad('menu', 'Esc', 53);
this.touch.append(left, right, menu);
}
}
