mirror of
https://github.com/sndrec/WebMonkeyBall.git
synced 2026-02-03 10:13:33 +00:00
custom pack support first pass
This commit is contained in:
54
index.html
54
index.html
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
36
src/game.ts
36
src/game.ts
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
262
src/main.ts
262
src/main.ts
@@ -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
254
src/pack.ts
Normal 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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
23
src/stage.ts
23
src/stage.ts
@@ -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);
|
||||
|
||||
39
style.css
39
style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user