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.
// 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 { type GameType } from '../lingo/gameplay.js';
const STAGE_W = 640;
const STAGE_H = 480;
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));
}
/**
* Title navigation includes the online controls outside the scaled bitmap.
* A presses the focused button; B does nothing on the title.
*/
navigate(intent: MenuIntent): void {
if (this.stage.hidden || intent === 'back') return;
const targets = [...focusTargets(this.stage), ...focusTargets(this.root.parentElement!, '.online-entry button')];
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();
}
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 = '';
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);
}
}
