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.
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';
});
