This commit is contained in:
Brandon Johnson
2026-01-31 14:36:28 -05:00
parent d5e5eeea6c
commit 1bf9ecd4a2
4 changed files with 188 additions and 5 deletions

View File

@@ -83,6 +83,12 @@
</div>
<input id="pack-file" class="hidden" type="file" accept=".zip" />
<input id="pack-folder" class="hidden" type="file" webkitdirectory />
<div class="pack-controls">
<button id="replay-save" class="ghost compact" type="button">Save Replay</button>
<button id="replay-load" class="ghost compact" type="button">Load Replay</button>
<div id="replay-status" class="pack-status">Replay: none</div>
</div>
<input id="replay-file" class="hidden" type="file" accept=".json" />
<label id="control-mode-field" class="field hidden">
<span>Control Mode</span>

View File

@@ -17,6 +17,7 @@ import { intersectsMovingSpheres, tfPhysballToAnimGroupSpace } from './collision
import { MatrixStack, sqrt, toS16 } from './math.js';
import { GameplayCamera } from './camera.js';
import { dequantizeStick, quantizeStick, type QuantizedStick } from './determinism.js';
import { createReplayData, type ReplayData } from './replay.js';
import {
checkBallEnteredGoal,
createBallState,
@@ -338,6 +339,9 @@ export class Game {
public inputRecord: QuantizedStick[] | null;
public fixedTickMode: boolean;
public fixedTicksPerUpdate: number;
public autoRecordInputs: boolean;
public inputStartTick: number;
public replayInputStartTick: number | null;
constructor({
hud,
@@ -433,6 +437,9 @@ export class Game {
this.inputRecord = null;
this.fixedTickMode = false;
this.fixedTicksPerUpdate = 1;
this.autoRecordInputs = true;
this.inputStartTick = 0;
this.replayInputStartTick = null;
}
setGameSource(source: GameSource) {
@@ -451,6 +458,19 @@ export class Game {
this.fixedTicksPerUpdate = Math.max(1, ticksPerUpdate | 0);
}
setReplayMode(enabled: boolean, useFixedTicks = true) {
this.autoRecordInputs = !enabled;
this.setFixedTickMode(enabled && useFixedTicks, 1);
if (enabled) {
this.inputRecord = null;
this.replayInputStartTick = 0;
} else {
this.inputFeed = null;
this.inputFeedIndex = 0;
this.replayInputStartTick = null;
}
}
startInputRecording() {
this.inputRecord = [];
}
@@ -461,6 +481,21 @@ export class Game {
return record;
}
exportReplay(note?: string, hashes?: number[]): ReplayData | null {
if (!this.stage || !this.inputRecord) {
return null;
}
return createReplayData(
this.gameSource,
this.stage.stageId,
this.inputRecord.length,
this.inputStartTick,
this.inputRecord.slice(),
hashes,
note,
);
}
init() {
this.input = new Input();
this.world = new World();
@@ -1228,6 +1263,7 @@ export class Game {
this.stageRuntime = new StageRuntime(stage);
this.simTick = 0;
this.inputFeedIndex = 0;
this.inputStartTick = 0;
this.animGroupTransforms = this.stageRuntime.animGroups.map((group) => group.transform);
this.interpolatedAnimGroupTransforms = null;
this.bananaGroups = new Array(this.stage.animGroupCount);
@@ -1267,6 +1303,9 @@ export class Game {
this.hurryUpAnnouncerPlayed = false;
this.timeOverAnnouncerPlayed = false;
this.pendingAdvance = false;
if (this.autoRecordInputs) {
this.startInputRecording();
}
this.resetBallForStage({ withIntro: true });
@@ -1753,20 +1792,32 @@ export class Game {
private readDeterministicStick(inputEnabled: boolean) {
let frame = null;
if (this.inputFeed && this.inputFeedIndex < this.inputFeed.length) {
frame = this.inputFeed[this.inputFeedIndex];
const canConsumeReplay = this.replayInputStartTick === null || this.simTick >= this.replayInputStartTick;
if (this.inputFeed && canConsumeReplay) {
frame = this.inputFeed[this.inputFeedIndex] ?? { x: 0, y: 0 };
this.inputFeedIndex += 1;
}
if (!inputEnabled) {
return { x: 0, y: 0 };
}
if (this.inputRecord && this.autoRecordInputs) {
if (this.inputRecord.length === 0) {
this.inputStartTick = this.simTick;
}
if (!frame) {
const raw = this.input?.getStick?.() ?? { x: 0, y: 0 };
frame = quantizeStick(raw);
}
this.inputRecord.push({ x: frame.x, y: frame.y });
}
if (!frame) {
const raw = this.input?.getStick?.() ?? { x: 0, y: 0 };
frame = quantizeStick(raw);
if (this.inputRecord) {
this.inputRecord.push({ x: frame.x, y: frame.y });
}
}
return dequantizeStick(frame);
}

View File

@@ -33,6 +33,7 @@ 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 type { ReplayData } from './replay.js';
import {
fetchPackSlice,
getActivePack,
@@ -213,6 +214,10 @@ const packLoadFolderButton = document.getElementById('pack-load-folder') as HTML
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 replaySaveButton = document.getElementById('replay-save') as HTMLButtonElement | null;
const replayLoadButton = document.getElementById('replay-load') as HTMLButtonElement | null;
const replayFileInput = document.getElementById('replay-file') as HTMLInputElement | null;
const replayStatus = document.getElementById('replay-status') as HTMLElement | 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;
@@ -868,6 +873,7 @@ async function startStage(
hudStatus.textContent = '';
}
game.setReplayMode(false);
game.setGameSource(activeGameSource);
game.stageBasePath = getStageBasePath(activeGameSource);
currentSmb2LikeMode =
@@ -876,6 +882,55 @@ async function startStage(
await game.start(difficulty);
}
function setReplayStatus(text: string) {
if (replayStatus) {
replayStatus.textContent = text;
}
}
async function startReplay(replay: ReplayData) {
setOverlayVisible(false);
resumeButton.disabled = true;
if (hudStatus) {
hudStatus.textContent = '';
}
game.setReplayMode(true, true);
game.setGameSource(replay.gameSource);
game.stageBasePath = getStageBasePath(replay.gameSource);
currentSmb2LikeMode = null;
game.course = null;
void audio.resume();
await game.loadStage(replay.stageId);
if (game.ball && game.stage) {
const startTick = Math.max(0, replay.inputStartTick ?? 0);
game.introTotalFrames = startTick;
game.introTimerFrames = startTick;
game.cameraController?.initForStage(game.ball, game.ball.startRotY, game.stageRuntime);
}
game.replayInputStartTick = Math.max(0, replay.inputStartTick ?? 0);
game.setInputFeed(replay.inputs);
game.paused = false;
while (game.simTick < game.replayInputStartTick) {
game.update(game.fixedStep);
}
game.setFixedTickMode(false, 1);
setReplayStatus(`Replay loaded (stage ${replay.stageId})`);
}
function downloadReplay(replay: ReplayData) {
const label = String(replay.stageId).padStart(3, '0');
const filename = `replay_${replay.gameSource}_st${label}.json`;
const blob = new Blob([JSON.stringify(replay, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function bindVolumeControl(
input: HTMLInputElement | null,
output: HTMLOutputElement | null,
@@ -1403,6 +1458,43 @@ packFolderInput?.addEventListener('change', async () => {
}
});
replaySaveButton?.addEventListener('click', () => {
if (!game || !game.stage) {
setReplayStatus('Replay: no stage active');
return;
}
const replay = game.exportReplay();
if (!replay) {
setReplayStatus('Replay: no inputs recorded');
return;
}
downloadReplay(replay);
setReplayStatus(`Replay saved (stage ${replay.stageId})`);
});
replayLoadButton?.addEventListener('click', () => {
replayFileInput?.click();
});
replayFileInput?.addEventListener('change', async () => {
const file = replayFileInput.files?.[0];
replayFileInput.value = '';
if (!file) {
return;
}
try {
const text = await file.text();
const replay = JSON.parse(text) as ReplayData;
if (!replay || replay.version !== 1 || !Array.isArray(replay.inputs)) {
throw new Error('Invalid replay');
}
await startReplay(replay);
} catch (error) {
console.error(error);
setReplayStatus('Replay: failed to load');
}
});
smb2ModeSelect?.addEventListener('change', () => {
updateSmb2ModeFields();
});

34
src/replay.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { GameSource } from './constants.js';
import type { QuantizedStick } from './determinism.js';
export type ReplayData = {
version: 1;
gameSource: GameSource;
stageId: number;
ticks: number;
inputStartTick: number;
inputs: QuantizedStick[];
hashes?: number[];
note?: string;
};
export function createReplayData(
gameSource: GameSource,
stageId: number,
ticks: number,
inputStartTick: number,
inputs: QuantizedStick[],
hashes?: number[],
note?: string,
): ReplayData {
return {
version: 1,
gameSource,
stageId,
ticks,
inputStartTick,
inputs,
hashes,
note,
};
}