// osu!mania Replay Renderer - Advanced // Handles .osr parsing, replay playback, skin rendering, PP calculation class OsuManiaReplayRenderer { constructor(canvasId) { this.canvas = document.getElementById(canvasId); this.ctx = this.canvas.getContext('2d'); this.replay = null; this.beatmap = null; this.currentTime = 0; this.playbackRate = 1; this.skins = new Map(); this.activeSkin = null; this.hitWindows = { perfect: 16, great: 64, good: 98, ok: 128, miss: 188 }; this.keyStates = [false, false, false, false]; this.comboCounter = 0; this.score = 0; this.pp = 0; this.judgments = { perfect: 0, great: 0, good: 0, ok: 0, miss: 0 }; this.notes = []; this.holdNotes = new Map(); this.renderQueue = []; this.animations = []; this.fps = 60; this.frameTime = 1000 / this.fps; this.lastFrameTime = 0; } // Parse .osr replay file (binary format) parseReplay(arrayBuffer) { const view = new DataView(arrayBuffer); let offset = 0; const readByte = () => view.getUint8(offset++); const readShort = () => { const val = view.getUint16(offset, true); offset += 2; return val; }; const readInt = () => { const val = view.getInt32(offset, true); offset += 4; return val; }; const readLong = () => { const val = Number(view.getBigInt64(offset, true)); offset += 8; return val; }; const readDouble = () => { const val = view.getFloat64(offset, true); offset += 8; return val; }; const readString = () => { const type = readByte(); if (type === 0x00) return ''; const length = this.readULEB128(view, offset); offset += this.getULEB128Size(length); const str = new TextDecoder().decode( new Uint8Array(arrayBuffer, offset, length) ); offset += length; return str; }; const readULEB128 = (view, offset) => { let result = 0; let shift = 0; let byte; do { byte = view.getUint8(offset++); result |= (byte & 0x7f) << shift; shift += 7; } while (byte & 0x80); return result; }; this.getULEB128Size = (value) => { if (value < 128) return 1; if (value < 16384) return 2; if (value < 2097152) return 3; return 4; }; this.replay = { gameMode: readByte(), gameVersion: readInt(), beatmapHash: readString(), playerName: readString(), replayHash: readString(), count300: readShort(), count100: readShort(), count50: readShort(), countGeki: readShort(), countKatu: readShort(), countMiss: readShort(), totalScore: readInt(), maxCombo: readShort(), perfectCombo: readByte() === 1, mods: readInt(), lifeBarGraph: readString(), timestamp: readLong(), replayLength: readInt(), replayData: new Uint8Array( arrayBuffer, offset, arrayBuffer.byteLength - offset - 4 ), seed: readInt(), }; this.parseReplayFrames(); return this.replay; } parseReplayFrames() { const frames = []; const data = this.replay.replayData; let offset = 0; while (offset < data.length) { const chunk = new TextDecoder().decode(data.slice(offset, offset + 100)); const line = chunk.split(',')[0]; const parts = line.split('|'); if (parts.length >= 4) { frames.push({ timeDiff: parseInt(parts[0]), x: parseFloat(parts[1]), y: parseFloat(parts[2]), keys: parseInt(parts[3]), time: frames.length > 0 ? frames[frames.length - 1].time + parseInt(parts[0]) : parseInt(parts[0]), }); } offset += line.length + 1; } this.replay.frames = frames; } // Load beatmap data (requires .osu file parsing) async loadBeatmap(osuFileContent) { const lines = osuFileContent.split('\n'); let section = null; this.beatmap = { title: '', artist: '', creator: '', version: '', cs: 4, od: 5, ar: 5, hp: 5, bpm: 120, objects: [], }; for (let line of lines) { line = line.trim(); if (line.startsWith('[')) section = line.slice(1, -1); else if (section === 'Metadata') { if (line.startsWith('Title:')) this.beatmap.title = line.split(':')[1]; if (line.startsWith('Artist:')) this.beatmap.artist = line.split(':')[1]; if (line.startsWith('Creator:')) this.beatmap.creator = line.split(':')[1]; if (line.startsWith('Version:')) this.beatmap.version = line.split(':')[1]; } else if (section === 'Difficulty') { if (line.startsWith('CircleSize:')) this.beatmap.cs = parseFloat(line.split(':')[1]); if (line.startsWith('OverallDifficulty:')) this.beatmap.od = parseFloat(line.split(':')[1]); if (line.startsWith('ApproachRate:')) this.beatmap.ar = parseFloat(line.split(':')[1]); if (line.startsWith('HPDrainRate:')) this.beatmap.hp = parseFloat(line.split(':')[1]); } else if (section === 'HitObjects') { const obj = this.parseHitObject(line); if (obj) this.beatmap.objects.push(obj); } } this.initializeNotes(); } parseHitObject(line) { if (!line) return null; const parts = line.split(','); if (parts.length < 5) return null; const x = parseInt(parts[0]); const y = parseInt(parts[1]); const time = parseInt(parts[2]); const type = parseInt(parts[3]); const hitSound = parseInt(parts[4]); return { x, y, time, type, hitSound, lane: Math.floor(x / (512 / 7)) }; } initializeNotes() { this.notes = this.beatmap.objects.map((obj, idx) => ({ ...obj, id: idx, hit: false, judgment: null, hitTime: null, })); } // PP Calculation (Simplified osu!mania algorithm) calculatePP() { const accuracy = this.calculateAccuracy(); const strainPP = this.calculateStrainPP(); const accPP = this.calculateAccuracyPP(accuracy); this.pp = Math.round((strainPP + accPP) * this.getModMultiplier()); return this.pp; } calculateAccuracy() { const total = Object.values(this.judgments).reduce((a, b) => a + b, 0); if (total === 0) return 0; const score = this.judgments.perfect * 305 + this.judgments.great * 300 + this.judgments.good * 200 + this.judgments.ok * 100; return (score / (total * 305)) * 100; } calculateStrainPP() { // Simplified strain calculation const objectCount = this.notes.length; const missCount = this.judgments.miss; const strain = Math.max(0, (objectCount - missCount * 3) / objectCount); return strain * Math.sqrt(objectCount) * 0.8; } calculateAccuracyPP(accuracy) { if (accuracy === 0) return 0; const accPower = Math.pow(accuracy / 100, 16); return accPower * 80; } getModMultiplier() { let mult = 1; if (this.replay.mods & 0x10) mult *= 1.12; // Easy if (this.replay.mods & 0x20) mult *= 0.9; // NoFail if (this.replay.mods & 0x100) mult *= 1.06; // Hidden if (this.replay.mods & 0x200) mult *= 1.12; // HardRock if (this.replay.mods & 0x4000) mult *= 1.0; // Flashlight return mult; } // Skin system loadSkin(name, skinData) { this.skins.set(name, { name, noteColors: skinData.noteColors || [ '#FF0000', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#FF00FF', '#FFFFFF', ], holdBodyColor: skinData.holdBodyColor || '#333333', holdHeadColor: skinData.holdHeadColor || '#666666', backgroundColor: skinData.backgroundColor || '#000000', judegmentLineColor: skinData.judegmentLineColor || '#FFFFFF', customTextures: skinData.customTextures || {}, }); if (!this.activeSkin) this.activeSkin = name; } setSkin(name) { if (this.skins.has(name)) { this.activeSkin = name; } } // Core rendering pipeline render(timestamp) { if (!this.lastFrameTime) this.lastFrameTime = timestamp; const deltaTime = timestamp - this.lastFrameTime; if (deltaTime >= this.frameTime) { this.update(deltaTime); this.draw(); this.lastFrameTime = timestamp; } requestAnimationFrame((t) => this.render(t)); } update(deltaTime) { this.currentTime += deltaTime * this.playbackRate; this.updateKeyStates(); this.processNoteHits(); this.updateAnimations(deltaTime); } updateKeyStates() { // Update from replay frames if (this.replay && this.replay.frames) { const frameIdx = this.replay.frames.findIndex( (f) => f.time >= this.currentTime ); if (frameIdx !== -1) { const keys = this.replay.frames[frameIdx].keys; this.keyStates = [ (keys & 1) !== 0, (keys & 2) !== 0, (keys & 4) !== 0, (keys & 8) !== 0, ]; } } } processNoteHits() { for (let note of this.notes) { if (note.hit) continue; const timeDiff = this.currentTime - note.time; if (Math.abs(timeDiff) > this.hitWindows.miss) continue; let judgment = null; if (Math.abs(timeDiff) <= this.hitWindows.perfect) judgment = 'perfect'; else if (Math.abs(timeDiff) <= this.hitWindows.great) judgment = 'great'; else if (Math.abs(timeDiff) <= this.hitWindows.good) judgment = 'good'; else if (Math.abs(timeDiff) <= this.hitWindows.ok) judgment = 'ok'; else judgment = 'miss'; if (Math.abs(timeDiff) <= this.hitWindows.miss) { note.hit = true; note.judgment = judgment; note.hitTime = this.currentTime; this.registerJudgment(judgment); } } } registerJudgment(judgment) { if (judgment === 'miss') { this.comboCounter = 0; this.judgments.miss++; } else { this.comboCounter++; this.judgments[judgment]++; const scoreValues = { perfect: 305, great: 300, good: 200, ok: 100 }; this.score += scoreValues[judgment]; } this.calculatePP(); } updateAnimations(deltaTime) { this.animations = this.animations.filter((anim) => { anim.elapsed += deltaTime; return anim.elapsed < anim.duration; }); } draw() { const skin = this.skins.get(this.activeSkin); if (!skin) return; // Clear canvas this.ctx.fillStyle = skin.backgroundColor; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); // Draw lanes this.drawLanes(skin); // Draw notes this.drawNotes(skin); // Draw judgement line this.drawJudgementLine(skin); // Draw HUD this.drawHUD(skin); } drawLanes(skin) { const laneWidth = this.canvas.width / 7; this.ctx.strokeStyle = '#333333'; this.ctx.lineWidth = 2; for (let i = 0; i <= 7; i++) { const x = i * laneWidth; this.ctx.beginPath(); this.ctx.moveTo(x, 0); this.ctx.lineTo(x, this.canvas.height); this.ctx.stroke(); } } drawNotes(skin) { const laneWidth = this.canvas.width / 7; const noteHeight = 60; const scrollSpeed = this.canvas.height / 3000; // 3 seconds to reach bottom for (let note of this.notes) { const lane = note.lane; const noteX = lane * laneWidth; const noteY = this.canvas.height - 100 - (this.currentTime - note.time) * scrollSpeed; // Skip if off-screen if (noteY < -noteHeight || noteY > this.canvas.height + noteHeight) continue; const color = skin.noteColors[lane % skin.noteColors.length]; // Draw note this.ctx.fillStyle = note.hit ? '#888888' : color; this.ctx.fillRect( noteX + 5, noteY, laneWidth - 10, noteHeight ); // Draw border this.ctx.strokeStyle = '#FFFFFF'; this.ctx.lineWidth = 2; this.ctx.strokeRect( noteX + 5, noteY, laneWidth - 10, noteHeight ); // Draw judgment text if hit if (note.hit && note.judgment) { this.ctx.fillStyle = '#FFFFFF'; this.ctx.font = 'bold 20px Arial'; this.ctx.textAlign = 'center'; this.ctx.fillText( note.judgment.toUpperCase(), noteX + laneWidth / 2, noteY + noteHeight / 2 ); } } } drawJudgementLine(skin) { const lineY = this.canvas.height - 100; this.ctx.strokeStyle = skin.judegmentLineColor; this.ctx.lineWidth = 4; this.ctx.beginPath(); this.ctx.moveTo(0, lineY); this.ctx.lineTo(this.canvas.width, lineY); this.ctx.stroke(); } drawHUD(skin) { this.ctx.fillStyle = '#FFFFFF'; this.ctx.font = '16px Arial'; this.ctx.textAlign = 'left'; const accuracy = this.calculateAccuracy(); const timeStr = this.formatTime(this.currentTime); const totalTime = this.beatmap ? this.beatmap.objects[this.beatmap.objects.length - 1]?.time : 0; const totalTimeStr = this.formatTime(totalTime); this.ctx.fillText(`Score: ${this.score}`, 20, 30); this.ctx.fillText(`PP: ${this.pp}`, 20, 55); this.ctx.fillText(`Combo: ${this.comboCounter}`, 20, 80); this.ctx.fillText(`Accuracy: ${accuracy.toFixed(2)}%`, 20, 105); this.ctx.fillText( `${timeStr} / ${totalTimeStr}`, 20, 130 ); // Judgment counters let yPos = this.canvas.height - 120; this.ctx.textAlign = 'right'; this.ctx.fillText( `Perfect: ${this.judgments.perfect}`, this.canvas.width - 20, yPos ); this.ctx.fillText( `Great: ${this.judgments.great}`, this.canvas.width - 20, yPos + 25 ); this.ctx.fillText( `Good: ${this.judgments.good}`, this.canvas.width - 20, yPos + 50 ); this.ctx.fillText( `OK: ${this.judgments.ok}`, this.canvas.width - 20, yPos + 75 ); this.ctx.fillText( `Miss: ${this.judgments.miss}`, this.canvas.width - 20, yPos + 100 ); } formatTime(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const secs = seconds % 60; return `${minutes}:${secs.toString().padStart(2, '0')}`; } // Control methods play() { this.playbackRate = 1; } pause() { this.playbackRate = 0; } seek(time) { this.currentTime = time; this.notes = this.notes.map((n) => ({ ...n, hit: false, judgment: null, hitTime: null, })); this.judgments = { perfect: 0, great: 0, good: 0, ok: 0, miss: 0 }; this.score = 0; this.comboCounter = 0; } } // HTML/UI Setup const html = `
Import .osr replays and view them with custom skins