🚀 高性能 · 🏗️ 结构优良 · 🌐 原生 WASM
功能完整的 osu! 数据库解析库,支持解析 osu!.db, collection.db 与 scores.db 。
使用 npm、yarn 或 pnpm 安装:
# npm
npm install @osynicite/osynic-osudb
# yarn
yarn add @osynicite/osynic-osudb
# pnpm
pnpm add @osynicite/osynic-osudb
确保您的 Vite 项目配置支持 WebAssembly:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'
export default defineConfig({
plugins: [
wasm(),
topLevelAwait(),
vue(),
],
optimizeDeps: {
exclude: ['@osynicite/osynic-osudb'],
},
})
📝 如果使用 Tailwind CSS,请在
tailwind.config.js中配置模板路径以支持动态类名
import { OsuDB } from '@osynicite/osynic-osudb';
import fs from 'fs';
const data = fs.readFileSync('osu!.db');
const osudb = OsuDB.from_bytes(new Uint8Array(data));
// 遍历前3个谱面
for (let i = 0; i < Math.min(3, osudb.beatmaps.length); i++) {
const beatmap = osudb.beatmaps[i];
console.log(`Song ${i + 1}: ${beatmap.artist_unicode || beatmap.artist} - ${beatmap.title_unicode || beatmap.title}`);
console.log(`Creator: ${beatmap.creator}`);
console.log(`Difficulty: ${beatmap.difficulty_name}`);
console.log(`AR: ${beatmap.approach_rate.toFixed(2)}`);
console.log(`CS: ${beatmap.circle_size.toFixed(2)}`);
console.log(`OD: ${beatmap.overall_difficulty.toFixed(2)}`);
console.log('---------------------------------');
}
<template>
<div class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6 text-white">
<div class="max-w-6xl mx-auto space-y-6">
<!-- Header -->
<div class="text-center space-y-2 mb-8">
<h1 class="text-4xl font-bold">🎵 osu! 谱面浏览器</h1>
<p class="text-gray-400">使用 WASM 解析您的数据库文件</p>
</div>
<!-- Upload Section -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 space-y-3">
<label class="block">
<span class="text-sm font-medium mb-2 inline-block">选择 osu!.db 文件</span>
<input
type="file"
@change="handleFileUpload"
accept=".db"
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
/>
</label>
<button
@click="loadDatabase"
:disabled="!fileData || loading"
class="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg font-semibold transition duration-200"
>
{{ loading ? '⏳ 解析中...' : '📂 加载数据库' }}
</button>
</div>
<!-- Error Message -->
<div v-if="error" class="bg-red-900/30 border border-red-500/50 rounded-lg p-4 text-red-200">
❌ {{ error }}
</div>
<!-- Stats Summary -->
<div v-if="beatmaps.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4 text-center">
<div class="text-3xl font-bold text-blue-400">{{ beatmaps.length }}</div>
<div class="text-gray-400 text-sm">谱面总数</div>
</div>
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4 text-center">
<div class="text-3xl font-bold text-green-400">{{ calcTotalLength() }}</div>
<div class="text-gray-400 text-sm">总时长</div>
</div>
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4 text-center">
<div class="text-3xl font-bold text-purple-400">{{ calcAvgDiff() }}</div>
<div class="text-gray-400 text-sm">平均难度</div>
</div>
</div>
<!-- Beatmaps Grid -->
<div v-if="beatmaps.length > 0" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div
v-for="(beatmap, index) in beatmaps.slice(0, 20)"
:key="index"
class="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-lg p-4 hover:border-blue-500/50 transition group cursor-pointer"
>
<!-- Title -->
<h3 class="text-lg font-bold text-blue-300 group-hover:text-blue-200 transition truncate">
{{ beatmap.artist_unicode || beatmap.artist }} - {{ beatmap.title_unicode || beatmap.title }}
</h3>
<p class="text-sm text-gray-400 truncate">[{{ beatmap.difficulty_name }}]</p>
<!-- Creator & File -->
<div class="mt-3 space-y-1 text-sm text-gray-300">
<p>👤 {{ beatmap.creator || 'Unknown' }}</p>
<p class="truncate">📁 {{ beatmap.file_name }}</p>
<p>🔐 {{ beatmap.hash?.substring(0, 12) }}...</p>
</div>
<!-- Objects -->
<div class="mt-3 flex gap-2 text-xs font-medium">
<span class="bg-red-500/20 text-red-300 px-2 py-1 rounded">🔴 {{ beatmap.hitcircle_count }}</span>
<span class="bg-orange-500/20 text-orange-300 px-2 py-1 rounded">↔️ {{ beatmap.slider_count }}</span>
<span class="bg-yellow-500/20 text-yellow-300 px-2 py-1 rounded">⚙️ {{ beatmap.spinner_count }}</span>
</div>
<!-- Difficulty Stats -->
<div class="mt-3 grid grid-cols-4 gap-1 text-xs">
<div class="bg-gray-700/50 rounded p-2 text-center">
<div class="font-bold text-cyan-300">{{ beatmap.approach_rate.toFixed(1) }}</div>
<div class="text-gray-400">AR</div>
</div>
<div class="bg-gray-700/50 rounded p-2 text-center">
<div class="font-bold text-cyan-300">{{ beatmap.circle_size.toFixed(1) }}</div>
<div class="text-gray-400">CS</div>
</div>
<div class="bg-gray-700/50 rounded p-2 text-center">
<div class="font-bold text-cyan-300">{{ beatmap.hp_drain.toFixed(1) }}</div>
<div class="text-gray-400">HP</div>
</div>
<div class="bg-gray-700/50 rounded p-2 text-center">
<div class="font-bold text-cyan-300">{{ beatmap.overall_difficulty.toFixed(1) }}</div>
<div class="text-gray-400">OD</div>
</div>
</div>
<!-- Footer -->
<p class="mt-3 text-xs text-gray-500">⏱️ {{ formatTime(beatmap.last_modified) }}</p>
</div>
</div>
<!-- Loading State -->
<div v-else-if="loading" class="text-center py-12">
<div class="inline-block">
<div class="animate-spin text-3xl mb-2">⌛</div>
<p class="text-gray-400">正在解析数据库...</p>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12 text-gray-400">
<div class="text-5xl mb-4">📂</div>
<p>选择并加载您的 osu!.db 文件开始</p>
</div>
<!-- Footer -->
<div v-if="beatmaps.length > 0" class="text-center text-sm text-gray-500 pt-4 border-t border-gray-700">
显示前 20 个谱面
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { OsuDB } from '@osynicite/osynic-osudb';
const fileData = ref<Uint8Array | null>(null);
const beatmaps = ref<any[]>([]);
const loading = ref(false);
const error = ref('');
const handleFileUpload = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
fileData.value = new Uint8Array(e.target?.result as ArrayBuffer);
error.value = '';
};
reader.readAsArrayBuffer(file);
}
};
const loadDatabase = () => {
if (!fileData.value) return;
loading.value = true;
error.value = '';
try {
const osudb = OsuDB.from_bytes(fileData.value);
beatmaps.value = osudb.beatmaps || [];
} catch (err: any) {
error.value = `解析失败: ${err.message}`;
beatmaps.value = [];
} finally {
loading.value = false;
}
};
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString('zh-CN');
};
const calcTotalLength = () => {
const seconds = beatmaps.value.reduce((sum, b) => sum + parseInt(b.total_length || 0), 0);
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
};
const calcAvgDiff = () => {
if (!beatmaps.value.length) return '0';
const avg = beatmaps.value.reduce((sum, b) => sum + parseFloat(b.overall_difficulty || 0), 0) / beatmaps.value.length;
return avg.toFixed(2);
};
</script>
import { OsuDB } from '@osynicite/osynic-osudb';
import fs from 'fs';
const data = fs.readFileSync('osu!.db');
const db = OsuDB.from_bytes(new Uint8Array(data));
console.log(`总谱面数: ${db.beatmaps.length}`);
db.beatmaps.forEach(beatmap => {
console.log(`${beatmap.artist} - ${beatmap.title} [${beatmap.difficulty_name}]`);
console.log(` ★${beatmap.overall_difficulty.toFixed(2)}`);
});
import { CollectionDB } from '@osynicite/osynic-osudb';
import fs from 'fs';
const data = fs.readFileSync('collection.db');
const db = CollectionDB.from_bytes(new Uint8Array(data));
db.collections.forEach(collection => {
console.log(`📁 ${collection.name}: ${collection.beatmap_hashes.length} 个谱面`);
});
import { ScoresDB } from '@osynicite/osynic-osudb';
import fs from 'fs';
const data = fs.readFileSync('scores.db');
const db = ScoresDB.from_bytes(new Uint8Array(data));
for (const [hash, scores] of Object.entries(db.scores)) {
console.log(`谱面 ${hash} 有 ${scores.length} 个成绩`);
scores.forEach((score, idx) => {
console.log(` ${idx + 1}. ${score.player_name}: ${score.score} 分`);
});
}
现代浏览器均支持 WebAssembly,但请确保:
处理大文件时可能遇到内存问题。建议:
A: 是的,完全支持。只需确保已安装 @osynicite/osynic-osudb 包并正确配置构建工具。
A: 目前库主要支持读取操作。写入功能暂未绑定 WASM。
A: WASM 版本比纯 JavaScript 实现快 10-100 倍,取决于具体操作。对于大多数数据库文件,解析时间在 100ms 以内。
A: 使用 try-catch 捕获异常:
try {
const db = OsuDB.from_bytes(data);
} catch (error) {
console.error('Parse error:', error.message);
}
欢迎提交 PR 或 Issue!如果您发现任何问题或有改进建议,请遵循以下规则:
git checkout -b feature/your-featuregit commit -am 'Add your feature'git push origin feature/your-feature# 从项目根目录
cd wasm
# 构建 WASM 库
wasm-pack build --release --target bundler --out-dir pkg --scope osynicite
# 安装依赖
npm install
# 运行测试
npm run test
cargo fmt 和 cargo clippy本项目基于 osu-db 库重构,进行了架构优化、性能提升和版本兼容性改进。
感谢 osu-db 的作者们!
osu-db 项目基于 Unlicense,项目证书放置在 licenses/ 目录。
本项目基于 MIT License 开源,请尊重原作者的著作权。