@osynicite/osynic-osudb
v0.1.4
Published
A WebAssembly (WASM) binding for the osynic_osudb crate, enabling osu! database manipulation in web environments.
Readme
📚 目录
✨ 特性
- 🌐 WASM 原生支持: 编译为 WebAssembly,在浏览器中以接近原生的速度运行
- 📱 多框架支持: 支持 Vue、React、Svelte 等流行前端框架
- 📦 完整格式支持: osu!.db、collection.db、scores.db 所有数据库文件
- 🎨 TypeScript 友好: 完整的类型定义,提供最佳的开发体验
- ⚡ 零配置: 通过 npm 安装即可使用,集成简单快速
- 🚀 高性能解析: 基于 nom8 解析器,高效可靠
- 📊 轻量级: WASM 包体积优化,快速加载和初始化
📦 安装
步骤一:安装依赖
使用 npm、yarn 或 pnpm 安装:
# npm
npm install @osynicite/osynic-osudb
# yarn
yarn add @osynicite/osynic-osudb
# pnpm
pnpm add @osynicite/osynic-osudb步骤二:Vite 配置
确保您的 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中配置模板路径以支持动态类名
🚀 快速开始
Node.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('---------------------------------');
}Vue 3 示例(使用 Tailwind CSS)
<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>📖 使用指南
解析 osu!.db(谱面数据库)
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)}`);
});解析 collection.db(收藏库)
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} 个谱面`);
});解析 scores.db(成绩数据库)
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,但请确保:
- ✅ Chrome 57+
- ✅ Firefox 52+
- ✅ Safari 11+
- ✅ Edge 79+
- ✅ Node.js 8+
文件大小限制
处理大文件时可能遇到内存问题。建议:
- 对于超过 100MB 的文件,考虑流式处理或分块读取
- 在生产环境中,建议在 Web Worker 中处理文件解析
常见问题
Q: 可以在 Node.js 中使用吗?
A: 是的,完全支持。只需确保已安装 @osynicite/osynic-osudb 包并正确配置构建工具。
Q: 支持写入操作吗?
A: 目前库主要支持读取操作。写入功能暂未绑定 WASM。
Q: 性能如何?
A: WASM 版本比纯 JavaScript 实现快 10-100 倍,取决于具体操作。对于大多数数据库文件,解析时间在 100ms 以内。
Q: 如何处理解析错误?
A: 使用 try-catch 捕获异常:
try {
const db = OsuDB.from_bytes(data);
} catch (error) {
console.error('Parse error:', error.message);
}🤝 贡献指南
欢迎提交 PR 或 Issue!如果您发现任何问题或有改进建议,请遵循以下规则:
如何贡献
- Fork 项目 - 在 GitHub 上 fork 该项目
- 创建分支 -
git checkout -b feature/your-feature - 提交更改 -
git commit -am 'Add your feature' - 推送到分支 -
git push origin feature/your-feature - 提交 Pull Request - 创建一个新的 Pull Request
开发指南
编译 WASM 库
# 从项目根目录
cd wasm
# 构建 WASM 库
wasm-pack build --release --target bundler --out-dir pkg --scope osynicite运行测试
# 安装依赖
npm install
# 运行测试
npm run test代码标准
- 遵循 Rust 官方编码规范
- 新增功能需附带测试用例
- 提交前运行
cargo fmt和cargo clippy - 更新相关文档和示例
❤️ 鸣谢
本项目基于 osu-db 库重构,进行了架构优化、性能提升和版本兼容性改进。
感谢 osu-db 的作者们!
osu-db 项目基于 Unlicense,项目证书放置在 licenses/ 目录。
📜 开源协议
本项目基于 MIT License 开源,请尊重原作者的著作权。
相关资源
- 📚 Rust 官方文档 - 完整的 Rust 库
- 🌐 在线文档 - TypeDoc 文档
- 📦 NPM 包 - npm 发布页面
- 💬 Discord 社区 - 加入我们的社区
