mirror of
https://github.com/sndrec/WebMonkeyBall.git
synced 2026-02-03 02:03:33 +00:00
replays
This commit is contained in:
@@ -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>
|
||||
|
||||
61
src/game.ts
61
src/game.ts
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
92
src/main.ts
92
src/main.ts
@@ -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
34
src/replay.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user