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