custom pack support first pass

This commit is contained in:
Brandon Johnson
2026-01-28 00:23:11 -05:00
parent f8255bb73e
commit 4efdb5e171
9 changed files with 802 additions and 75 deletions

View File

@@ -34,7 +34,17 @@
<span>— Renderer</span>
</div>
<div>
camthesaxman <span>— Decompilation</span>
camthesaxman <span> SMB1 Decompilation</span>
</div>
<div class="credits-multiline">
<div class="credits-title">SMB2 Decompilation</div>
<div class="credits-sublist">
<div>ComplexPlane</div>
<div>CraftedCart</div>
<div>EELI</div>
<div>Eucalyptus</div>
<div>The BombSquad</div>
</div>
</div>
<div>
Amusement Vision <span>— Original game</span>
@@ -56,6 +66,16 @@
<option value="mb2ws">Super Monkey Ball 2 (MB2WS)</option>
</select>
</label>
<div class="pack-controls">
<button id="pack-load" class="ghost compact" type="button">Load Pack</button>
<div id="pack-picker" class="pack-picker hidden">
<button id="pack-load-zip" class="ghost compact" type="button">Zip File</button>
<button id="pack-load-folder" class="ghost compact" type="button">Folder</button>
</div>
<div id="pack-status" class="pack-status">No pack loaded</div>
</div>
<input id="pack-file" class="hidden" type="file" accept=".zip" />
<input id="pack-folder" class="hidden" type="file" webkitdirectory />
<label id="control-mode-field" class="field hidden">
<span>Control Mode</span>
@@ -263,18 +283,34 @@
syncGyroUi();
const requestGyroPermission = async () => {
const requests = [];
const orientationPermission = window.DeviceOrientationEvent?.requestPermission;
if (typeof orientationPermission === 'function') {
requests.push(orientationPermission.call(window.DeviceOrientationEvent));
}
const motionPermission = window.DeviceMotionEvent?.requestPermission;
if (typeof motionPermission === 'function') {
requests.push(motionPermission.call(window.DeviceMotionEvent));
}
if (requests.length === 0) {
return 'granted';
}
try {
const results = await Promise.all(requests);
return results.every((result) => result === 'granted') ? 'granted' : 'denied';
} catch {
return 'denied';
}
};
select.addEventListener('change', async () => {
let next = select.value;
// iOS: gyro requires a user-gesture permission prompt.
if (next === 'gyro' && typeof window.DeviceOrientationEvent?.requestPermission === 'function') {
try {
const result = await window.DeviceOrientationEvent.requestPermission();
if (result !== 'granted') {
next = hasTouch ? 'touch' : 'gyro';
select.value = next;
}
} catch {
if (next === 'gyro') {
const result = await requestGyroPermission();
if (result !== 'granted') {
next = hasTouch ? 'touch' : 'gyro';
select.value = next;
}

View File

@@ -1,4 +1,5 @@
import { DEFAULT_STAGE_TIME } from './constants.js';
import { DEFAULT_STAGE_TIME, GAME_SOURCES } from './constants.js';
import { getPackCourseData, getPackStageTimeOverride, hasPackForGameSource } from './pack.js';
type ChallengeEntry = {
id: number;
@@ -234,7 +235,7 @@ export const MB2WS_STORY_ORDER = [
[308, 309, 310, 311, 312, 313, 314, 315, 316, 317],
] as const;
export type Mb2wsChallengeDifficulty = keyof typeof MB2WS_CHALLENGE_ORDER;
export type Mb2wsChallengeDifficulty = keyof typeof MB2WS_CHALLENGE_ORDER | string;
export type Mb2wsCourseConfig =
| {
@@ -248,7 +249,65 @@ export type Mb2wsCourseConfig =
stageIndex: number;
};
const STORY_FLAT_ORDER = MB2WS_STORY_ORDER.flat();
function computeChallengeBonusFlags(list: number[]) {
return list.map((_id, index) => {
const stageNumber = index + 1;
if (stageNumber === 5) {
return true;
}
if (stageNumber % 10 === 0) {
return stageNumber !== list.length;
}
return false;
});
}
function normalizeBonusFlags(list: number[], flags: boolean[] | null | undefined) {
if (!flags || flags.length === 0) {
return computeChallengeBonusFlags(list);
}
const normalized = flags.slice(0, list.length);
while (normalized.length < list.length) {
normalized.push(false);
}
return normalized;
}
function normalizeTimers(list: number[], timers: (number | null)[] | null | undefined) {
if (!timers || timers.length === 0) {
return list.map(() => null);
}
const normalized = timers.slice(0, list.length);
while (normalized.length < list.length) {
normalized.push(null);
}
return normalized;
}
function getPackCourses() {
if (!hasPackForGameSource(GAME_SOURCES.MB2WS)) {
return null;
}
return getPackCourseData();
}
function flattenStoryOrder(order: number[][]) {
return order.reduce<number[]>((acc, list) => acc.concat(list), []);
}
function getStoryIndex(order: number[][], worldIndex: number, stageIndex: number) {
if (order.length === 0) {
return 0;
}
const safeWorld = clampIndex(worldIndex, order.length);
const worldStages = order[safeWorld] ?? [];
const safeStage = clampIndex(stageIndex, worldStages.length);
let index = safeStage;
for (let i = 0; i < safeWorld; i += 1) {
index += order[i]?.length ?? 0;
}
return index;
}
function clampIndex(value: number, max: number) {
if (max <= 0) {
@@ -277,19 +336,25 @@ export class Mb2wsCourse {
constructor(config: Mb2wsCourseConfig) {
this.mode = config.mode;
this.difficultyLabel = null;
const packCourses = getPackCourses();
if (config.mode === 'challenge') {
const list = MB2WS_CHALLENGE_ORDER[config.difficulty] ?? [];
const times = MB2WS_CHALLENGE_TIMERS[config.difficulty] ?? [];
const packOrder = packCourses?.challenge?.order?.[config.difficulty];
const list = packOrder ?? MB2WS_CHALLENGE_ORDER[config.difficulty] ?? [];
const packTimers = packCourses?.challenge?.timers?.[config.difficulty];
const times = packTimers ?? MB2WS_CHALLENGE_TIMERS[config.difficulty] ?? [];
this.stageList = list.slice();
this.timeList = times.slice();
this.bonusFlags = (MB2WS_CHALLENGE_BONUS[config.difficulty] ?? []).slice();
this.timeList = normalizeTimers(this.stageList, times);
const packBonus = packCourses?.challenge?.bonus?.[config.difficulty];
const defaultBonus = MB2WS_CHALLENGE_BONUS[config.difficulty as keyof typeof MB2WS_CHALLENGE_ORDER];
this.bonusFlags = normalizeBonusFlags(this.stageList, packBonus ?? defaultBonus);
this.currentIndex = clampIndex(config.stageIndex, this.stageList.length);
this.difficultyLabel = titleCaseDifficulty(config.difficulty);
} else {
this.stageList = STORY_FLAT_ORDER.slice();
const storyOrder = packCourses?.story ?? MB2WS_STORY_ORDER;
this.stageList = flattenStoryOrder(storyOrder);
this.timeList = [];
this.bonusFlags = [];
const storyIndex = config.worldIndex * 10 + config.stageIndex;
const storyIndex = getStoryIndex(storyOrder, config.worldIndex, config.stageIndex);
this.currentIndex = clampIndex(storyIndex, this.stageList.length);
}
@@ -297,6 +362,12 @@ export class Mb2wsCourse {
}
getTimeLimitFrames() {
if (hasPackForGameSource(GAME_SOURCES.MB2WS)) {
const override = getPackStageTimeOverride(this.currentStageId);
if (override !== null) {
return override;
}
}
if (this.mode === 'challenge') {
const override = this.timeList[this.currentIndex];
if (override !== null && override !== undefined) {

View File

@@ -1,4 +1,5 @@
import { DEFAULT_STAGE_TIME } from './constants.js';
import { DEFAULT_STAGE_TIME, GAME_SOURCES } from './constants.js';
import { getPackCourseData, getPackStageTimeOverride, hasPackForGameSource } from './pack.js';
export const SMB2_CHALLENGE_ORDER = {
'beginner': [201, 202, 203, 204, 205, 206, 207, 208, 209, 210],
@@ -34,7 +35,7 @@ export const SMB2_STORY_ORDER = [
[341, 342, 343, 344, 345, 346, 347, 348, 349, 350],
] as const;
export type Smb2ChallengeDifficulty = keyof typeof SMB2_CHALLENGE_ORDER;
export type Smb2ChallengeDifficulty = keyof typeof SMB2_CHALLENGE_ORDER | string;
export const SMB2_CHALLENGE_BONUS = Object.fromEntries(
Object.entries(SMB2_CHALLENGE_ORDER).map(([key, list]) => [
@@ -64,7 +65,54 @@ export type Smb2CourseConfig =
stageIndex: number;
};
const STORY_FLAT_ORDER = SMB2_STORY_ORDER.flat();
function computeChallengeBonusFlags(list: number[]) {
return list.map((_id, index) => {
const stageNumber = index + 1;
if (stageNumber === 5) {
return true;
}
if (stageNumber % 10 === 0) {
return stageNumber !== list.length;
}
return false;
});
}
function normalizeBonusFlags(list: number[], flags: boolean[] | null | undefined) {
if (!flags || flags.length === 0) {
return computeChallengeBonusFlags(list);
}
const normalized = flags.slice(0, list.length);
while (normalized.length < list.length) {
normalized.push(false);
}
return normalized;
}
function getPackCourses() {
if (!hasPackForGameSource(GAME_SOURCES.SMB2)) {
return null;
}
return getPackCourseData();
}
function flattenStoryOrder(order: number[][]) {
return order.reduce<number[]>((acc, list) => acc.concat(list), []);
}
function getStoryIndex(order: number[][], worldIndex: number, stageIndex: number) {
if (order.length === 0) {
return 0;
}
const safeWorld = clampIndex(worldIndex, order.length);
const worldStages = order[safeWorld] ?? [];
const safeStage = clampIndex(stageIndex, worldStages.length);
let index = safeStage;
for (let i = 0; i < safeWorld; i += 1) {
index += order[i]?.length ?? 0;
}
return index;
}
function clampIndex(value: number, max: number) {
if (max <= 0) {
@@ -92,16 +140,21 @@ export class Smb2Course {
constructor(config: Smb2CourseConfig) {
this.mode = config.mode;
this.difficultyLabel = null;
const packCourses = getPackCourses();
if (config.mode === 'challenge') {
const list = SMB2_CHALLENGE_ORDER[config.difficulty] ?? [];
const packOrder = packCourses?.challenge?.order?.[config.difficulty];
const list = packOrder ?? SMB2_CHALLENGE_ORDER[config.difficulty] ?? [];
this.stageList = list.slice();
this.bonusFlags = (SMB2_CHALLENGE_BONUS[config.difficulty] ?? []).slice();
const packBonus = packCourses?.challenge?.bonus?.[config.difficulty];
const defaultBonus = SMB2_CHALLENGE_BONUS[config.difficulty as keyof typeof SMB2_CHALLENGE_ORDER];
this.bonusFlags = normalizeBonusFlags(this.stageList, packBonus ?? defaultBonus);
this.currentIndex = clampIndex(config.stageIndex, this.stageList.length);
this.difficultyLabel = titleCaseDifficulty(config.difficulty);
} else {
this.stageList = STORY_FLAT_ORDER.slice();
const storyOrder = packCourses?.story ?? SMB2_STORY_ORDER;
this.stageList = flattenStoryOrder(storyOrder);
this.bonusFlags = [];
const storyIndex = config.worldIndex * 10 + config.stageIndex;
const storyIndex = getStoryIndex(storyOrder, config.worldIndex, config.stageIndex);
this.currentIndex = clampIndex(storyIndex, this.stageList.length);
}
@@ -109,6 +162,12 @@ export class Smb2Course {
}
getTimeLimitFrames() {
if (hasPackForGameSource(GAME_SOURCES.SMB2)) {
const override = getPackStageTimeOverride(this.currentStageId);
if (override !== null) {
return override;
}
}
return DEFAULT_STAGE_TIME;
}

View File

@@ -68,6 +68,7 @@ const SPEED_MPH_SCALE = 134.21985;
const SPEED_BAR_MAX_MPH = 70;
const fallOutStack = new MatrixStack();
const fallOutLocal = { x: 0, y: 0, z: 0 };
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
@@ -323,6 +324,13 @@ export class Game {
public interpolatedAnimGroupTransforms: Float32Array[] | null;
public effectDebugLastLogTime: number;
public loadingStage: boolean;
public simPerf: {
enabled: boolean;
logEvery: number;
tickCount: number;
tickMs: number;
lastTickMs: number;
};
constructor({
hud,
@@ -405,6 +413,13 @@ export class Game {
this.interpolatedAnimGroupTransforms = null;
this.effectDebugLastLogTime = 0;
this.loadingStage = false;
this.simPerf = {
enabled: true,
logEvery: 120,
tickCount: 0,
tickMs: 0,
lastTickMs: 0,
};
}
setGameSource(source: GameSource) {
@@ -1726,6 +1741,8 @@ export class Game {
}
this.stageRuntime.goalHoldOpen = this.goalTimerFrames > 0;
while (!this.pendingAdvance && this.accumulator >= this.fixedStep) {
const tickStart = this.simPerf.enabled ? nowMs() : 0;
try {
const ringoutActive = this.ringoutTimerFrames > 0;
const timeoverActive = this.timeoverTimerFrames > 0;
const inputEnabled = this.introTimerFrames <= 0
@@ -1887,6 +1904,25 @@ export class Game {
this.captureCameraPose(cameraPoses.curr);
}
this.accumulator -= this.fixedStep;
} finally {
if (this.simPerf.enabled) {
const tickMs = nowMs() - tickStart;
this.simPerf.lastTickMs = tickMs;
this.simPerf.tickMs += tickMs;
this.simPerf.tickCount += 1;
}
}
}
if (this.simPerf.enabled && this.simPerf.tickCount >= this.simPerf.logEvery) {
const avgMs = this.simPerf.tickMs / Math.max(1, this.simPerf.tickCount);
console.log(
"[perf] sim-tick avg=%sms last=%sms over=%d",
avgMs.toFixed(3),
this.simPerf.lastTickMs.toFixed(3),
this.simPerf.tickCount,
);
this.simPerf.tickCount = 0;
this.simPerf.tickMs = 0;
}
}

View File

@@ -33,6 +33,18 @@ import { StageId, STAGE_INFO_MAP } from './noclip/SuperMonkeyBall/StageInfo.js';
import type { StageData } from './noclip/SuperMonkeyBall/World.js';
import { convertSmb2StageDef, getMb2wsStageInfo, getSmb2StageInfo } from './smb2_render.js';
import { HudRenderer } from './hud.js';
import {
fetchPackSlice,
getActivePack,
getPackCourseData,
getPackStageBasePath,
hasPackForGameSource,
loadPackFromFileList,
loadPackFromUrl,
loadPackFromZipFile,
setActivePack,
} from './pack.js';
import type { LoadedPack } from './pack.js';
function clamp(value: number, min: number, max: number) {
if (value < min) {
@@ -55,7 +67,16 @@ const DEFAULT_PAD_GATE = [
[59, -59],
];
const STAGE_BASE_PATH = STAGE_BASE_PATHS[GAME_SOURCES.SMB1];
function getStageBasePath(gameSource: GameSource): string {
const selection = gameSourceSelect?.value as GameSourceSelection | undefined;
const usePack = selection === 'pack' && hasPackForGameSource(gameSource);
if (usePack) {
return getPackStageBasePath(gameSource)
?? STAGE_BASE_PATHS[gameSource]
?? STAGE_BASE_PATHS[GAME_SOURCES.SMB1];
}
return STAGE_BASE_PATHS[gameSource] ?? STAGE_BASE_PATHS[GAME_SOURCES.SMB1];
}
const NAOMI_STAGE_IDS = new Set([
10, 19, 20, 30, 49, 50, 60, 70, 80, 92, 96, 97, 98, 99, 100, 114, 115, 116, 117, 118, 119, 120,
]);
@@ -64,6 +85,47 @@ function isNaomiStage(stageId: number): boolean {
return NAOMI_STAGE_IDS.has(stageId);
}
type GameSourceSelection = GameSource | 'pack';
let packOption: HTMLOptionElement | null = null;
function resolveSelectedGameSource() {
const selection = (gameSourceSelect?.value as GameSourceSelection) ?? GAME_SOURCES.SMB1;
if (selection === 'pack') {
const pack = getActivePack();
if (pack) {
return { selection, gameSource: pack.manifest.gameSource };
}
return { selection, gameSource: GAME_SOURCES.SMB1 };
}
return { selection, gameSource: selection as GameSource };
}
function updatePackUi() {
const pack = getActivePack();
if (packStatus) {
packStatus.textContent = pack
? `Loaded: ${pack.manifest.name} (${pack.manifest.gameSource.toUpperCase()})`
: 'No pack loaded';
}
if (!gameSourceSelect) {
return;
}
if (pack) {
if (!packOption) {
packOption = document.createElement('option');
packOption.value = 'pack';
}
packOption.textContent = `Pack: ${pack.manifest.name}`;
if (!gameSourceSelect.querySelector('option[value="pack"]')) {
gameSourceSelect.appendChild(packOption);
}
gameSourceSelect.value = 'pack';
} else if (packOption && gameSourceSelect.contains(packOption)) {
gameSourceSelect.removeChild(packOption);
packOption = null;
}
}
const canvas = document.getElementById('game') as HTMLCanvasElement;
const hudCanvas = document.getElementById('hud-canvas') as HTMLCanvasElement;
const overlay = document.getElementById('overlay') as HTMLElement;
@@ -102,6 +164,13 @@ const resumeButton = document.getElementById('resume') as HTMLButtonElement;
const difficultySelect = document.getElementById('difficulty') as HTMLSelectElement;
const smb1StageSelect = document.getElementById('smb1-stage') as HTMLSelectElement;
const gameSourceSelect = document.getElementById('game-source') as HTMLSelectElement;
const packLoadButton = document.getElementById('pack-load') as HTMLButtonElement | null;
const packPicker = document.getElementById('pack-picker') as HTMLElement | null;
const packLoadZipButton = document.getElementById('pack-load-zip') as HTMLButtonElement | null;
const packLoadFolderButton = document.getElementById('pack-load-folder') as HTMLButtonElement | null;
const packStatus = document.getElementById('pack-status') as HTMLElement | null;
const packFileInput = document.getElementById('pack-file') as HTMLInputElement | null;
const packFolderInput = document.getElementById('pack-folder') as HTMLInputElement | null;
const smb1Fields = document.getElementById('smb1-fields') as HTMLElement;
const smb2Fields = document.getElementById('smb2-fields') as HTMLElement;
const smb2ModeSelect = document.getElementById('smb2-mode') as HTMLSelectElement;
@@ -193,12 +262,37 @@ function resizeCanvasToDisplaySize(canvasElem: HTMLCanvasElement) {
}
async function fetchSlice(path: string): Promise<ArrayBufferSlice> {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to load ${path}: ${response.status} ${response.statusText}`);
return fetchPackSlice(path);
}
async function initPackFromQuery() {
const packParam = new URLSearchParams(window.location.search).get('pack');
if (!packParam) {
return;
}
const buffer = await response.arrayBuffer();
return new ArrayBufferSlice(buffer);
try {
const pack = await loadPackFromUrl(packParam);
setActivePack(pack);
updatePackUi();
updateSmb2ChallengeStages();
updateSmb2StoryOptions();
updateSmb1Stages();
updateGameSourceFields();
} catch (error) {
console.error(error);
if (hudStatus) {
hudStatus.textContent = 'Failed to load pack.';
}
}
}
async function applyLoadedPack(pack: LoadedPack) {
setActivePack(pack);
updatePackUi();
updateSmb2ChallengeStages();
updateSmb2StoryOptions();
updateSmb1Stages();
updateGameSourceFields();
}
async function loadRenderStage(stageId: number): Promise<StageData> {
@@ -208,21 +302,22 @@ async function loadRenderStage(stageId: number): Promise<StageData> {
throw new Error(`Missing StageInfo for stage ${stageId}`);
}
const stagedefPath = `${STAGE_BASE_PATH}/st${stageIdStr}/STAGE${stageIdStr}.lz`;
const stageGmaPath = `${STAGE_BASE_PATH}/st${stageIdStr}/st${stageIdStr}.gma`;
const stageTplPath = `${STAGE_BASE_PATH}/st${stageIdStr}/st${stageIdStr}.tpl`;
const stageBasePath = getStageBasePath(GAME_SOURCES.SMB1);
const stagedefPath = `${stageBasePath}/st${stageIdStr}/STAGE${stageIdStr}.lz`;
const stageGmaPath = `${stageBasePath}/st${stageIdStr}/st${stageIdStr}.gma`;
const stageTplPath = `${stageBasePath}/st${stageIdStr}/st${stageIdStr}.tpl`;
const commonGmaPath = `${STAGE_BASE_PATH}/init/common.gma`;
const commonTplPath = `${STAGE_BASE_PATH}/init/common.tpl`;
const commonNlPath = `${STAGE_BASE_PATH}/init/common_p.lz`;
const commonNlTplPath = `${STAGE_BASE_PATH}/init/common.lz`;
const commonGmaPath = `${stageBasePath}/init/common.gma`;
const commonTplPath = `${stageBasePath}/init/common.tpl`;
const commonNlPath = `${stageBasePath}/init/common_p.lz`;
const commonNlTplPath = `${stageBasePath}/init/common.lz`;
const bgName = stageInfo.bgInfo.fileName;
const bgGmaPath = `${STAGE_BASE_PATH}/bg/${bgName}.gma`;
const bgTplPath = `${STAGE_BASE_PATH}/bg/${bgName}.tpl`;
const bgGmaPath = `${stageBasePath}/bg/${bgName}.gma`;
const bgTplPath = `${stageBasePath}/bg/${bgName}.tpl`;
const isNaomi = isNaomiStage(stageId);
const stageNlObjPath = isNaomi ? `${STAGE_BASE_PATH}/st${stageIdStr}/st${stageIdStr}_p.lz` : null;
const stageNlTplPath = isNaomi ? `${STAGE_BASE_PATH}/st${stageIdStr}/st${stageIdStr}.lz` : null;
const stageNlObjPath = isNaomi ? `${stageBasePath}/st${stageIdStr}/st${stageIdStr}_p.lz` : null;
const stageNlTplPath = isNaomi ? `${stageBasePath}/st${stageIdStr}/st${stageIdStr}.lz` : null;
const [
stagedefBuf,
@@ -294,7 +389,7 @@ async function loadRenderStageSmb2(stageId: number, stage: any, gameSource: Game
gameSource === GAME_SOURCES.MB2WS ? getMb2wsStageInfo(stageId) : getSmb2StageInfo(stageId);
const stagedef = convertSmb2StageDef(stage);
const stageBasePath = STAGE_BASE_PATHS[gameSource] ?? STAGE_BASE_PATHS[GAME_SOURCES.SMB2];
const stageBasePath = getStageBasePath(gameSource) ?? STAGE_BASE_PATHS[GAME_SOURCES.SMB2];
const stageGmaPath = `${stageBasePath}/st${stageIdStr}/st${stageIdStr}.gma`;
const stageTplPath = `${stageBasePath}/st${stageIdStr}/st${stageIdStr}.tpl`;
@@ -566,11 +661,33 @@ function setSelectOptions(select: HTMLSelectElement, values: { value: string; la
}
}
function getPackChallengeOrder(gameSource: GameSource) {
if (!hasPackForGameSource(gameSource)) {
return null;
}
return getPackCourseData()?.challenge?.order ?? null;
}
function getPackStoryOrder(gameSource: GameSource) {
if (!hasPackForGameSource(gameSource)) {
return null;
}
return getPackCourseData()?.story ?? null;
}
function getSmb2LikeChallengeOrder(gameSource: GameSource) {
const packOrder = getPackChallengeOrder(gameSource);
if (packOrder) {
return packOrder;
}
return gameSource === GAME_SOURCES.MB2WS ? MB2WS_CHALLENGE_ORDER : SMB2_CHALLENGE_ORDER;
}
function getSmb2LikeStoryOrder(gameSource: GameSource) {
const packOrder = getPackStoryOrder(gameSource);
if (packOrder) {
return packOrder;
}
return gameSource === GAME_SOURCES.MB2WS ? MB2WS_STORY_ORDER : SMB2_STORY_ORDER;
}
@@ -578,9 +695,19 @@ function updateSmb2ChallengeStages() {
if (!smb2ChallengeSelect || !smb2ChallengeStageSelect) {
return;
}
const gameSource = (gameSourceSelect?.value as GameSource) ?? GAME_SOURCES.SMB1;
const { gameSource } = resolveSelectedGameSource();
const order = getSmb2LikeChallengeOrder(gameSource);
if (hasPackForGameSource(gameSource) && order) {
const keys = Object.keys(order);
if (keys.length > 0) {
const current = smb2ChallengeSelect.value;
const options = keys.map((key) => ({ value: key, label: key }));
setSelectOptions(smb2ChallengeSelect, options);
smb2ChallengeSelect.value = keys.includes(current) ? current : keys[0];
}
}
const difficulty = smb2ChallengeSelect.value as Smb2ChallengeDifficulty | Mb2wsChallengeDifficulty;
const stages = getSmb2LikeChallengeOrder(gameSource)[difficulty] ?? [];
const stages = order[difficulty] ?? [];
const options = stages.map((_, index) => ({
value: String(index + 1),
label: `Stage ${index + 1}`,
@@ -604,18 +731,28 @@ function updateSmb2StoryOptions() {
if (!smb2StoryWorldSelect || !smb2StoryStageSelect) {
return;
}
const gameSource = (gameSourceSelect?.value as GameSource) ?? GAME_SOURCES.SMB1;
const { gameSource } = resolveSelectedGameSource();
const storyOrder = getSmb2LikeStoryOrder(gameSource);
if (storyOrder.length === 0) {
setSelectOptions(smb2StoryWorldSelect, []);
setSelectOptions(smb2StoryStageSelect, []);
return;
}
const worldOptions = storyOrder.map((_, index) => ({
value: String(index + 1),
label: `World ${index + 1}`,
}));
const stageOptions = storyOrder[0].map((_, index) => ({
setSelectOptions(smb2StoryWorldSelect, worldOptions);
const currentWorld = Math.max(0, Math.min(storyOrder.length - 1, Number(smb2StoryWorldSelect.value ?? 1) - 1));
smb2StoryWorldSelect.value = String(currentWorld + 1);
const stageList = storyOrder[currentWorld] ?? [];
const stageOptions = stageList.map((_, index) => ({
value: String(index + 1),
label: `Stage ${index + 1}`,
}));
setSelectOptions(smb2StoryWorldSelect, worldOptions);
setSelectOptions(smb2StoryStageSelect, stageOptions);
const currentStage = Math.max(0, Math.min(stageList.length - 1, Number(smb2StoryStageSelect.value ?? 1) - 1));
smb2StoryStageSelect.value = String(currentStage + 1);
}
function updateSmb2ModeFields() {
@@ -628,7 +765,7 @@ function updateSmb2ModeFields() {
}
function updateGameSourceFields() {
const gameSource = (gameSourceSelect?.value as GameSource) ?? GAME_SOURCES.SMB1;
const { gameSource } = resolveSelectedGameSource();
const isSmb2Like = gameSource !== GAME_SOURCES.SMB1;
smb1Fields?.classList.toggle('hidden', isSmb2Like);
smb2Fields?.classList.toggle('hidden', !isSmb2Like);
@@ -677,6 +814,7 @@ async function startStage(
}
game.setGameSource(activeGameSource);
game.stageBasePath = getStageBasePath(activeGameSource);
currentSmb2LikeMode =
activeGameSource !== GAME_SOURCES.SMB1 && hasSmb2LikeMode(difficulty) ? difficulty.mode : null;
void audio.resume();
@@ -1094,10 +1232,13 @@ function updateGyroHelper() {
setOverlayVisible(true);
startButton.disabled = false;
updateSmb2ChallengeStages();
updateSmb2StoryOptions();
updateSmb1Stages();
updateGameSourceFields();
updatePackUi();
void initPackFromQuery().finally(() => {
updateSmb2ChallengeStages();
updateSmb2StoryOptions();
updateSmb1Stages();
updateGameSourceFields();
});
bindVolumeControl(musicVolumeInput, musicVolumeValue, (value) => {
audio.setMusicVolume(value);
@@ -1148,6 +1289,64 @@ updateFalloffCurve(game.input?.inputFalloff ?? 1);
syncTouchPreviewVisibility();
updateFullscreenButtonVisibility();
function setPackPickerOpen(open: boolean) {
if (!packPicker) {
return;
}
packPicker.classList.toggle('hidden', !open);
}
packLoadButton?.addEventListener('click', () => {
if (!packPicker) {
return;
}
setPackPickerOpen(packPicker.classList.contains('hidden'));
});
packLoadZipButton?.addEventListener('click', () => {
setPackPickerOpen(false);
packFileInput?.click();
});
packLoadFolderButton?.addEventListener('click', () => {
setPackPickerOpen(false);
packFolderInput?.click();
});
packFileInput?.addEventListener('change', async () => {
const file = packFileInput.files?.[0];
packFileInput.value = '';
if (!file) {
return;
}
try {
const pack = await loadPackFromZipFile(file);
await applyLoadedPack(pack);
} catch (error) {
console.error(error);
if (hudStatus) {
hudStatus.textContent = 'Failed to load pack.';
}
}
});
packFolderInput?.addEventListener('change', async () => {
const files = packFolderInput.files;
packFolderInput.value = '';
if (!files || files.length === 0) {
return;
}
try {
const pack = await loadPackFromFileList(files);
await applyLoadedPack(pack);
} catch (error) {
console.error(error);
if (hudStatus) {
hudStatus.textContent = 'Failed to load pack.';
}
}
});
smb2ModeSelect?.addEventListener('change', () => {
updateSmb2ModeFields();
});
@@ -1156,6 +1355,10 @@ smb2ChallengeSelect?.addEventListener('change', () => {
updateSmb2ChallengeStages();
});
smb2StoryWorldSelect?.addEventListener('change', () => {
updateSmb2StoryOptions();
});
difficultySelect?.addEventListener('change', () => {
updateSmb1Stages();
});
@@ -1225,7 +1428,8 @@ if (interpolationToggle) {
}
startButton.addEventListener('click', () => {
activeGameSource = (gameSourceSelect?.value as GameSource) || GAME_SOURCES.SMB1;
const resolved = resolveSelectedGameSource();
activeGameSource = resolved.gameSource;
const difficulty = activeGameSource === GAME_SOURCES.SMB2
? buildSmb2CourseConfig()
: activeGameSource === GAME_SOURCES.MB2WS

254
src/pack.ts Normal file
View File

@@ -0,0 +1,254 @@
import { unzipSync } from 'fflate';
import ArrayBufferSlice from './noclip/ArrayBufferSlice.js';
import { STAGE_BASE_PATHS, type GameSource } from './constants.js';
export type PackKeyframe = {
t: number;
v: number;
ease?: number;
in?: number;
out?: number;
};
export type PackFogAnim = {
start?: PackKeyframe[] | null;
end?: PackKeyframe[] | null;
r?: PackKeyframe[] | null;
g?: PackKeyframe[] | null;
b?: PackKeyframe[] | null;
};
export type PackFog = {
type: number;
start: number;
end: number;
color: [number, number, number];
anim?: PackFogAnim;
};
export type PackBgInfo = {
fileName: string;
clearColor?: [number, number, number, number];
ambientColor?: [number, number, number];
infLightColor?: [number, number, number];
infLightRotX?: number;
infLightRotY?: number;
};
export type PackStageEnv = {
bgInfo?: PackBgInfo;
fog?: PackFog;
};
export type PackCourseData = {
challenge?: {
order?: Record<string, number[]>;
bonus?: Record<string, boolean[]>;
timers?: Record<string, (number | null)[]>;
};
story?: number[][];
};
export type PackManifest = {
id: string;
name: string;
gameSource: GameSource;
version: number;
basePath?: string;
content?: {
stages?: number[];
stageNames?: Record<string, string>;
stageTimeOverrides?: Record<string, number | null>;
};
courses?: PackCourseData;
stageEnv?: Record<string, PackStageEnv>;
};
export type PackProvider = {
fetch: (path: string) => Promise<ArrayBuffer>;
};
export type LoadedPack = {
manifest: PackManifest;
provider: PackProvider;
basePath: string;
};
let activePack: LoadedPack | null = null;
function normalizePackPath(path: string): string {
return path.replace(/^\.\//, '').replace(/^\//, '');
}
function joinBasePath(basePath: string, path: string): string {
const normalized = normalizePackPath(path);
const normalizedBase = basePath.replace(/\/+$/, '');
if (!basePath) {
return normalized;
}
if (normalizedBase && (normalized === normalizedBase || normalized.startsWith(`${normalizedBase}/`))) {
return normalized;
}
if (/^https?:\/\//.test(normalized)) {
return normalized;
}
return `${normalizedBase}/${normalized}`;
}
export function setActivePack(pack: LoadedPack | null) {
activePack = pack;
}
export function getActivePack() {
return activePack;
}
export function getPackStageEnv(stageId: number): PackStageEnv | null {
if (!activePack?.manifest.stageEnv) {
return null;
}
return activePack.manifest.stageEnv[String(stageId)] ?? null;
}
export function getPackCourseData(): PackCourseData | null {
return activePack?.manifest.courses ?? null;
}
export function getPackStageName(stageId: number): string | null {
const name = activePack?.manifest.content?.stageNames?.[String(stageId)];
return name ?? null;
}
export function getPackStageTimeOverride(stageId: number): number | null {
const override = activePack?.manifest.content?.stageTimeOverrides?.[String(stageId)];
if (override === undefined) {
return null;
}
return override === null ? 0 : override;
}
export function getPackStageBasePath(gameSource: GameSource): string | null {
if (!activePack) {
return null;
}
if (activePack.manifest.gameSource !== gameSource) {
return null;
}
return activePack.basePath;
}
export function hasPackForGameSource(gameSource: GameSource): boolean {
return activePack?.manifest.gameSource === gameSource;
}
export async function fetchPackSlice(path: string): Promise<ArrayBufferSlice> {
const pack = activePack;
const normalized = normalizePackPath(path);
const defaultBasePaths = Object.values(STAGE_BASE_PATHS).map((base) => normalizePackPath(base));
const isDefaultPath = defaultBasePaths.some((base) => normalized === base || normalized.startsWith(`${base}/`));
if (pack && isDefaultPath) {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to load ${path}: ${response.status} ${response.statusText}`);
}
return new ArrayBufferSlice(await response.arrayBuffer());
}
if (!pack) {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to load ${path}: ${response.status} ${response.statusText}`);
}
return new ArrayBufferSlice(await response.arrayBuffer());
}
const resolved = joinBasePath(pack.basePath, normalized);
return new ArrayBufferSlice(await pack.provider.fetch(resolved));
}
export async function fetchPackBuffer(path: string): Promise<ArrayBuffer> {
const slice = await fetchPackSlice(path);
return slice.arrayBuffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength);
}
export async function loadPackFromUrl(url: string): Promise<LoadedPack> {
if (url.endsWith('.zip')) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load pack: ${response.status} ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
return loadPackFromZipBuffer(buffer, '');
}
const basePath = url.endsWith('/') || url.endsWith('pack.json')
? url.replace(/\/pack\.json$/, '')
: url;
const response = await fetch(`${basePath.replace(/\/+$/, '')}/pack.json`);
if (!response.ok) {
throw new Error(`Failed to load pack.json: ${response.status} ${response.statusText}`);
}
const manifest = await response.json();
const provider: PackProvider = {
fetch: async (path: string) => {
const res = await fetch(path);
if (!res.ok) {
throw new Error(`Failed to load ${path}: ${res.status} ${res.statusText}`);
}
return res.arrayBuffer();
},
};
return { manifest, provider, basePath: manifest.basePath ?? basePath };
}
export async function loadPackFromZipFile(file: File): Promise<LoadedPack> {
const buffer = await file.arrayBuffer();
return loadPackFromZipBuffer(buffer, '');
}
export async function loadPackFromFileList(fileList: FileList): Promise<LoadedPack> {
const files = Array.from(fileList);
const map = new Map<string, File>();
for (const file of files) {
const rawPath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const normalized = normalizePackPath(rawPath.replace(/^[^/]+\//, ''));
map.set(normalized, file);
}
const manifestFile = map.get('pack.json');
if (!manifestFile) {
throw new Error('pack.json not found in selected folder');
}
const manifest = JSON.parse(await manifestFile.text()) as PackManifest;
const provider: PackProvider = {
fetch: async (path: string) => {
const normalized = normalizePackPath(path);
const file = map.get(normalized);
if (!file) {
throw new Error(`Missing pack entry: ${normalized}`);
}
return file.arrayBuffer();
},
};
return { manifest, provider, basePath: '' };
}
function loadPackFromZipBuffer(buffer: ArrayBuffer, basePath: string): LoadedPack {
const entries = unzipSync(new Uint8Array(buffer));
const map = new Map<string, Uint8Array>();
for (const [name, data] of Object.entries(entries)) {
map.set(normalizePackPath(name), data as Uint8Array);
}
const manifestBytes = map.get('pack.json');
if (!manifestBytes) {
throw new Error('pack.json not found in zip');
}
const manifest = JSON.parse(new TextDecoder('utf-8').decode(manifestBytes)) as PackManifest;
const provider: PackProvider = {
fetch: async (path: string) => {
const normalized = normalizePackPath(path);
const entry = map.get(normalized);
if (!entry) {
throw new Error(`Missing pack entry: ${normalized}`);
}
return entry.buffer.slice(entry.byteOffset, entry.byteOffset + entry.byteLength);
},
};
return { manifest, provider, basePath };
}

View File

@@ -1,4 +1,5 @@
import { vec2, vec3 } from 'gl-matrix';
import { GAME_SOURCES, type GameSource } from './constants.js';
import {
AnimType,
BananaType,
@@ -10,6 +11,8 @@ import {
type StageModelInstance,
} from './noclip/SuperMonkeyBall/Stagedef.js';
import { BgInfos, type StageInfo } from './noclip/SuperMonkeyBall/StageInfo.js';
import { colorNewFromRGBA } from './noclip/Color.js';
import { getPackStageEnv, hasPackForGameSource } from './pack.js';
const SMB2_STAGE_THEME_IDS = [
0, // 0
@@ -539,6 +542,36 @@ const SMB2_BG_INFO_BY_NAME = {
'bg_ending': BgInfos.Ending,
} as const;
function applyPackBgInfo(stageId: number, gameSource: GameSource, baseInfo: BgInfos[keyof typeof BgInfos], fileName: string) {
if (!hasPackForGameSource(gameSource)) {
return { ...baseInfo, fileName };
}
const packEnv = getPackStageEnv(stageId);
const packBg = packEnv?.bgInfo;
if (!packBg) {
return { ...baseInfo, fileName };
}
const packFileName = packBg.fileName || fileName;
const mapped =
(packFileName ? SMB2_BG_INFO_BY_NAME[packFileName as keyof typeof SMB2_BG_INFO_BY_NAME] : null) ??
baseInfo;
return {
...mapped,
fileName: packFileName,
clearColor: packBg.clearColor
? colorNewFromRGBA(packBg.clearColor[0], packBg.clearColor[1], packBg.clearColor[2], packBg.clearColor[3])
: mapped.clearColor,
ambientColor: packBg.ambientColor
? colorNewFromRGBA(packBg.ambientColor[0], packBg.ambientColor[1], packBg.ambientColor[2], 1)
: mapped.ambientColor,
infLightColor: packBg.infLightColor
? colorNewFromRGBA(packBg.infLightColor[0], packBg.infLightColor[1], packBg.infLightColor[2], 1)
: mapped.infLightColor,
infLightRotX: packBg.infLightRotX ?? mapped.infLightRotX,
infLightRotY: packBg.infLightRotY ?? mapped.infLightRotY,
};
}
function toVec3(input: { x: number; y: number; z: number }) {
return vec3.fromValues(input.x, input.y, input.z);
}
@@ -644,30 +677,24 @@ function convertStageModelInstances(list: any[]): StageModelInstance[] {
export function getSmb2StageInfo(stageId: number): StageInfo {
const themeId = SMB2_STAGE_THEME_IDS[stageId] ?? 0;
const fileName = SMB2_THEME_BG_NAMES[themeId] ?? '';
const bgInfo =
const baseInfo =
(fileName ? SMB2_BG_INFO_BY_NAME[fileName as keyof typeof SMB2_BG_INFO_BY_NAME] : null) ??
BgInfos.Jungle;
return {
id: stageId as any,
bgInfo: {
...bgInfo,
fileName,
},
bgInfo: applyPackBgInfo(stageId, GAME_SOURCES.SMB2, baseInfo, fileName),
};
}
export function getMb2wsStageInfo(stageId: number): StageInfo {
const themeId = MB2WS_STAGE_THEME_IDS[stageId] ?? 0;
const fileName = SMB2_THEME_BG_NAMES[themeId] ?? '';
const bgInfo =
const baseInfo =
(fileName ? SMB2_BG_INFO_BY_NAME[fileName as keyof typeof SMB2_BG_INFO_BY_NAME] : null) ??
BgInfos.Jungle;
return {
id: stageId as any,
bgInfo: {
...bgInfo,
fileName,
},
bgInfo: applyPackBgInfo(stageId, GAME_SOURCES.MB2WS, baseInfo, fileName),
};
}

View File

@@ -1,4 +1,5 @@
import { lzssDecompress } from './lzs.js';
import { fetchPackBuffer } from './pack.js';
import ArrayBufferSlice from './noclip/ArrayBufferSlice.js';
import { CommonNlModelID } from './noclip/SuperMonkeyBall/NlModelInfo.js';
import { parseObj as parseNlObj } from './noclip/SuperMonkeyBall/NaomiLib.js';
@@ -3078,11 +3079,13 @@ export async function loadStageModelBounds(stageId, basePath = STAGE_BASE_PATHS.
const id = formatStageId(stageId);
const gmaPath = `${basePath}/st${id}/st${id}.gma`;
const tplPath = `${basePath}/st${id}/st${id}.tpl`;
const [gmaResponse, tplResponse] = await Promise.all([fetch(gmaPath), fetch(tplPath)]);
if (!gmaResponse.ok || !tplResponse.ok) {
let gmaBuffer;
let tplBuffer;
try {
[gmaBuffer, tplBuffer] = await Promise.all([fetchPackBuffer(gmaPath), fetchPackBuffer(tplPath)]);
} catch {
return null;
}
const [gmaBuffer, tplBuffer] = await Promise.all([gmaResponse.arrayBuffer(), tplResponse.arrayBuffer()]);
const tpl = parseAVTpl(tplBuffer, `st${id}`);
const gma = parseGma(gmaBuffer, tpl);
const boundSphere = computeGmaBoundSphere(gma, modelNames) ?? computeGmaBoundSphere(gma);
@@ -3095,11 +3098,13 @@ export async function loadStageModelBounds(stageId, basePath = STAGE_BASE_PATHS.
export async function loadGoalTapeAnchorY(basePath = STAGE_BASE_PATHS.smb1, gameSource = 'smb1') {
const commonNlPath = `${basePath}/init/common_p.lz`;
const commonNlTplPath = `${basePath}/init/common.lz`;
const [nlResponse, tplResponse] = await Promise.all([fetch(commonNlPath), fetch(commonNlTplPath)]);
if (!nlResponse.ok || !tplResponse.ok) {
let nlBuffer;
let tplBuffer;
try {
[nlBuffer, tplBuffer] = await Promise.all([fetchPackBuffer(commonNlPath), fetchPackBuffer(commonNlTplPath)]);
} catch {
return null;
}
const [nlBuffer, tplBuffer] = await Promise.all([nlResponse.arrayBuffer(), tplResponse.arrayBuffer()]);
const tplSlice = decompressLZ(new ArrayBufferSlice(tplBuffer));
const nlSlice = decompressLZ(new ArrayBufferSlice(nlBuffer));
if (!tplSlice.byteLength || !nlSlice.byteLength) {
@@ -3120,11 +3125,7 @@ export async function loadGoalTapeAnchorY(basePath = STAGE_BASE_PATHS.smb1, game
export async function loadStageDef(stageId, basePath = STAGE_BASE_PATHS.smb1, gameSource = 'smb1') {
const id = formatStageId(stageId);
const path = `${basePath}/st${id}/STAGE${id}.lz`;
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to load stage file: ${path}`);
}
const buffer = await response.arrayBuffer();
const buffer = await fetchPackBuffer(path);
const decompressed = lzssDecompress(buffer);
const view = new Uint8Array(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength);
const stage = parseStageDef(view, gameSource);

View File

@@ -426,6 +426,28 @@ select option {
gap: 12px;
}
.pack-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.pack-controls .ghost {
flex: 0 0 auto;
}
.pack-picker {
display: flex;
gap: 8px;
}
.pack-status {
font-size: 12px;
color: var(--muted);
}
button {
flex: 1;
padding: 12px 16px;
@@ -547,6 +569,23 @@ button:disabled {
margin-left: 4px;
}
.credits-panel .credits-multiline {
display: grid;
gap: 4px;
}
.credits-panel .credits-title {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.2px;
}
.credits-panel .credits-sublist {
display: grid;
gap: 2px;
margin-left: 12px;
}
.mobile-menu-button {
position: fixed;
top: 12px;