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