Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

Release
372375d18e3f…
Author-recorded commit
6fbf09f88a0a…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f

Archive hash verified: 76e4eb30c67a6f45…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import './napplet-settings.js';
import { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import {
  canDownloadPack,
  canLoadPackLink,
  canSideLoadPack,
  currentPack,
  currentPackBuffer,
  fetchPackLink,
  installPack,
  loadBuildPack,
  PACK_EMBEDDED,
  type PackMeta,
  pickPackFile,
} from './game/assets.js';
import { App, type Progress } from './game/app.js';
import { MODE_ORDER, type ModeName } from './game/lingo/gameplay.js';
import { registerDrivingKeyActions } from './napplet-keys.js';
import './styles.css';

// App-owned colours: the game keeps its own palette in every host theme.
export const FOLLOW_HOST_THEME = false;

const PROGRESS_KEY = 'ssrc.unlocked.v1';

function requireElement<T extends HTMLElement>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing required element: ${selector}`);
  return element;
}

/** Unlocks persist through the host's storage domain; without it they last for the session (as in the original). */
const progress: Progress | null = runtimeHasDomain(STORAGE_DOMAIN)
  ? {
      async load() {
        try {
          const raw = await storage.getItem(PROGRESS_KEY);
          const list = raw ? (JSON.parse(raw) as unknown) : [];
          return Array.isArray(list) ? MODE_ORDER.filter((m) => list.includes(m)) : [];
        } catch {
          return [];
        }
      },
      save(unlocked: ModeName[]) {
        void storage.setItem(PROGRESS_KEY, JSON.stringify(unlocked)).catch(() => {});
      },
    }
  : null;

const stage = requireElement<HTMLDivElement>('#stage');
const status = requireElement<HTMLOutputElement>('#status');
const badge = requireElement<HTMLSpanElement>('#assetBadge');
const panel = requireElement<HTMLElement>('#packPanel');
const panelTitle = requireElement<HTMLHeadingElement>('#packTitle');
const panelMessage = requireElement<HTMLParagraphElement>('#packMessage');
const linkForm = requireElement<HTMLDivElement>('#packLinkForm');
const linkInput = requireElement<HTMLInputElement>('#packLink');
const linkButton = requireElement<HTMLButtonElement>('#packLinkLoad');
const retryButton = requireElement<HTMLButtonElement>('#packRetry');
const fileButton = requireElement<HTMLButtonElement>('#packFile');
const cancelButton = requireElement<HTMLButtonElement>('#packCancel');

const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error));

// The pack panel: a full-frame card that gets an asset pack from the player,
// at startup (when the build's own pack is unavailable) or from "Asset pack…"
// on the title screen. One set of controls; `panelSession` says what the open
// panel does with them.
interface PanelSession {
  title: string;
  message: string;
  error: boolean;
  /** Called with the chosen pack's bytes. Throw to show the error in the panel. */
  take(buffer: ArrayBuffer): Promise<void> | void;
  retry?: () => void;
  cancel?: () => void;
}
let panelSession: PanelSession | null = null;

function showPanel(session: PanelSession): void {
  panelSession = session;
  panel.hidden = false;
  status.hidden = true;
  panelTitle.textContent = session.title;
  panelMessage.dataset.state = session.error ? 'error' : '';
  panelMessage.textContent = session.message;
  linkForm.hidden = !canLoadPackLink();
  fileButton.hidden = !canSideLoadPack();
  // Next to the link field the file picker is the secondary choice.
  fileButton.textContent = linkForm.hidden ? 'Load asset pack…' : 'Choose file…';
  fileButton.classList.toggle('primary', linkForm.hidden);
  retryButton.hidden = !session.retry;
  cancelButton.hidden = !session.cancel;
  setPanelBusy(false);
  (!linkForm.hidden ? linkInput : !fileButton.hidden ? fileButton : retryButton).focus();
}

function hidePanel(): void {
  panelSession = null;
  if (panel.contains(document.activeElement)) (document.activeElement as HTMLElement).blur();
  panel.hidden = true;
}

function setPanelBusy(busy: boolean): void {
  for (const control of [linkInput, linkButton, retryButton, fileButton, cancelButton]) control.disabled = busy;
}

/** Get a pack's bytes from `load` and hand them to the open session; errors stay in the panel. */
async function panelLoad(load: () => Promise<ArrayBuffer | null>, busyText: string): Promise<void> {
  const session = panelSession;
  if (!session) return;
  setPanelBusy(true);
  panelMessage.dataset.state = '';
  panelMessage.textContent = busyText;
  try {
    const buffer = await load();
    if (buffer) await session.take(buffer);
    else if (panelSession === session) showPanel(session);
  } catch (error) {
    if (panelSession === session) showPanel({ ...session, message: errorText(error), error: true });
  }
}

// Not a <form>: the sandbox has no allow-forms, so a submit would be blocked.
const loadLink = () => {
  const text = linkInput.value;
  void panelLoad(() => fetchPackLink(text), 'Downloading the asset pack…');
};
linkButton.addEventListener('click', loadLink);
linkInput.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') loadLink();
});
fileButton.addEventListener('click', () => void panelLoad(pickPackFile, 'Reading the asset pack…'));
retryButton.addEventListener('click', () => panelSession?.retry?.());
cancelButton.addEventListener('click', () => panelSession?.cancel?.());
panel.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && panelSession?.cancel && !cancelButton.disabled) {
    e.stopPropagation();
    panelSession.cancel();
  }
});

/**
 * Get an asset pack: the build's own (embedded, or downloaded from Blossom),
 * else whatever the player links or side-loads. Resolves once one is installed.
 */
function acquirePack(): Promise<PackMeta> {
  return new Promise((resolve) => {
    const offer = (message: string) => {
      const link = canLoadPackLink();
      const sideLoad = canSideLoadPack();
      const alternatives = [link && 'paste a Blossom link or hash of an asset pack', sideLoad && 'load an asset pack file (.ssrcpack)'].filter(Boolean);
      showPanel({
        title: 'Game data',
        message:
          !canDownloadPack() && !link && !sideLoad
            ? 'This host cannot download or open the game data. Open SuperSonic RC in a napplet host with resource or file access.'
            : message + (alternatives.length ? ` You can ${alternatives.join(' or ')} instead.` : ''),
        error: true,
        take: (buffer) => {
          const meta = installPack(buffer);
          hidePanel();
          resolve(meta);
        },
        retry: canDownloadPack() ? () => void tryBuild() : undefined,
      });
    };
    const tryBuild = async () => {
      panel.hidden = PACK_EMBEDDED;
      panelTitle.textContent = 'Game data';
      panelMessage.dataset.state = '';
      panelMessage.textContent = 'Downloading the game data…';
      linkForm.hidden = retryButton.hidden = fileButton.hidden = cancelButton.hidden = true;
      try {
        resolve(await loadBuildPack());
      } catch (error) {
        offer(errorText(error));
      }
    };
    void tryBuild();
  });
}

const app = new App(stage, progress);
const closeKeyActions = registerDrivingKeyActions((key) => app.triggerKeyAction(key));
window.addEventListener('pagehide', closeKeyActions, { once: true });
app.onStatus = (text) => {
  status.textContent = text;
  status.hidden = text === '';
  status.dataset.state = '';
};

/** A short status line that clears itself (pack switch results). */
let noticeTimer: ReturnType<typeof setTimeout> | undefined;
function notice(text: string, error = false): void {
  clearTimeout(noticeTimer);
  app.onStatus(text);
  status.dataset.state = error ? 'error' : '';
  noticeTimer = setTimeout(() => app.onStatus(''), error ? 8000 : 4000);
}

// Switching packs: "Asset pack…" on the title screen opens the pack panel to
// load another .ssrcpack (a pasted Blossom link or hash through the host's
// resource domain, or a file through its file picker) in place of the current
// one. A file that is not a pack stays in the panel as an error; a pack that
// fails to build is dropped and the previous one reinstalled.
if (canLoadPackLink() || canSideLoadPack()) {
  app.packSwitch = () =>
    new Promise<void>((done) => {
      const current = currentPack();
      showPanel({
        title: 'Asset pack',
        message: `${current ? `Playing with “${current.title}”. ` : ''}Load another asset pack: ${
          canLoadPackLink() ? `paste its Blossom link or hash${canSideLoadPack() ? ', or choose a .ssrcpack file' : ''}` : 'choose a .ssrcpack file'
        }.`,
        error: false,
        take: async (buffer) => {
          const previous = currentPackBuffer();
          const meta = installPack(buffer);
          hidePanel();
          done();
          linkInput.value = '';
          try {
            await app.reloadAssets();
          } catch (error) {
            if (previous) installPack(previous);
            app.showTitle();
            notice(`Could not switch asset packs: ${errorText(error)}`, true);
            return;
          }
          badge.hidden = !meta.original;
          notice(`Loaded asset pack: ${meta.title}`);
        },
        cancel: () => {
          hidePanel();
          done();
        },
      });
    });
}
acquirePack()
  .then((pack) => {
    panel.hidden = true;
    badge.hidden = !pack.original;
    return app.start();
  })
  .catch((error: unknown) => {
    status.hidden = false;
    status.textContent = errorText(error);
    status.dataset.state = 'error';
  });