Refactor osu beatmap

It now uses the builder pattern and has tests and some documentation.
master
Simon Bruder 2020-07-25 13:19:44 +02:00
parent 3ab224414f
commit 73dea88f7d
No known key found for this signature in database
GPG Key ID: 6F03E0000CC5B62F
7 changed files with 1272 additions and 348 deletions

81
Cargo.lock generated
View File

@ -51,6 +51,7 @@ dependencies = [
"anyhow",
"byteorder",
"clap",
"derive_builder",
"derive_more",
"konami-lz77",
"log",
@ -92,7 +93,7 @@ dependencies = [
"indexmap",
"lazy_static",
"os_str_bytes",
"strsim",
"strsim 0.10.0",
"termcolor",
"textwrap",
"unicode-width",
@ -179,6 +180,66 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "darling"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.9.3",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "derive_builder"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2658621297f2cf68762a6f7dc0bb7e1ff2cfd6583daef8ee0fed6f7ec468ec0"
dependencies = [
"darling",
"derive_builder_core",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_builder_core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2791ea3e372c8495c0bc2033991d76b512cd799d07491fbd6890124db9458bef"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_more"
version = "0.99.9"
@ -221,6 +282,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "getrandom"
version = "0.1.14"
@ -259,6 +326,12 @@ dependencies = [
"quick-error",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "1.4.0"
@ -587,6 +660,12 @@ dependencies = [
"syn",
]
[[package]]
name = "strsim"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c"
[[package]]
name = "strsim"
version = "0.10.0"

View File

@ -8,6 +8,7 @@ edition = "2018"
anyhow = "1.0.31"
byteorder = "1.3.4"
clap = "3.0.0-beta.1"
derive_builder = "0.9"
derive_more = "0.99.9"
konami-lz77 = { git = "https://github.com/sbruder/konami-lz77" }
log = "0.4.8"

View File

@ -7,6 +7,7 @@ use log::{debug, info, trace, warn};
use crate::ddr::ssq;
use crate::osu::beatmap;
use crate::osu::types::*;
#[derive(Clone, Debug)]
pub struct ConfigRange(f32, f32);
@ -144,7 +145,7 @@ impl ShockStepGenerator {
}
}
fn get_time_from_beats(beats: f32, tempo_changes: &ssq::TempoChanges) -> Option<beatmap::Time> {
fn get_time_from_beats(beats: f32, tempo_changes: &ssq::TempoChanges) -> Option<Time> {
for tempo_change in tempo_changes.to_vec() {
// For TempoChanges that are infinitely short but exactly cover that beat, use the start
// time of that TempoChange
@ -175,7 +176,7 @@ impl From<ssq::TempoChange> for beatmap::TimingPoint {
tempo_change.beat_length
},
meter: 4,
sample_set: beatmap::SampleSet::BeatmapDefault,
sample_set: SampleSet::BeatmapDefault,
sample_index: 0,
volume: 100,
uninherited: true,
@ -206,26 +207,15 @@ impl ssq::Step {
for (column, active) in columns.iter().enumerate() {
if *active {
hit_objects.push(beatmap::HitObject::HitCircle {
x: beatmap::column_to_x(column as u8, num_columns),
y: 192,
time,
hit_sound: beatmap::HitSound {
normal: true,
whistle: false,
finish: false,
clap: false,
},
new_combo: false,
skip_combo_colours: 0,
hit_sample: beatmap::HitSample {
normal_set: 0,
addition_set: 0,
index: 0,
volume: 0,
filename: "".to_string(),
},
})
hit_objects.push(
beatmap::hit_object::ManiaHitCircleBuilder::default()
.column(column as u8)
.columns(num_columns)
.time(time)
.build()
.unwrap()
.into(),
)
}
}
}
@ -245,27 +235,16 @@ impl ssq::Step {
for (column, active) in columns.iter().enumerate() {
if *active {
hit_objects.push(beatmap::HitObject::Hold {
column: column as u8,
columns: num_columns,
time,
end_time,
hit_sound: beatmap::HitSound {
normal: true,
whistle: false,
finish: false,
clap: false,
},
new_combo: false,
skip_combo_colours: 0,
hit_sample: beatmap::HitSample {
normal_set: 0,
addition_set: 0,
index: 0,
volume: 0,
filename: "".to_string(),
},
})
hit_objects.push(
beatmap::hit_object::HoldBuilder::default()
.column(column as u8)
.columns(num_columns)
.time(time)
.end_time(end_time)
.build()
.unwrap()
.into(),
)
}
}
}
@ -287,26 +266,15 @@ impl ssq::Step {
let columns = shock_step_generator.next().unwrap_or_else(Vec::new);
for column in columns {
hit_objects.push(beatmap::HitObject::HitCircle {
x: beatmap::column_to_x(column as u8, num_columns),
y: 192,
time: get_time_from_beats(*beats, tempo_changes)?,
hit_sound: beatmap::HitSound {
normal: true,
whistle: false,
finish: false,
clap: false,
},
new_combo: false,
skip_combo_colours: 0,
hit_sample: beatmap::HitSample {
normal_set: 0,
addition_set: 0,
index: 0,
volume: 0,
filename: "".to_string(),
},
})
hit_objects.push(
beatmap::hit_object::ManiaHitCircleBuilder::default()
.column(column as u8)
.columns(num_columns)
.time(get_time_from_beats(*beats, tempo_changes)?)
.build()
.unwrap()
.into(),
)
}
}
}
@ -323,54 +291,60 @@ struct ConvertedChart {
impl ConvertedChart {
fn to_beatmap(&self, config: &Config) -> beatmap::Beatmap {
beatmap::Beatmap {
version: 14,
general: beatmap::General {
audio_filename: config.audio_filename.clone(),
audio_lead_in: 0,
preview_time: 0,
countdown: beatmap::Countdown::No,
sample_set: beatmap::SampleSet::Soft,
mode: beatmap::Mode::Mania,
},
editor: beatmap::Editor {},
metadata: beatmap::Metadata {
title: config
.metadata
.title
.as_ref()
.unwrap_or(&"unknown title".to_string())
.clone(),
artist: config
.metadata
.artist
.as_ref()
.unwrap_or(&"unknown artist".to_string())
.clone(),
creator: format!("{}", config),
version: match &config.metadata.levels {
Some(levels) => {
let level = self.level.to_value(levels);
format!("{} (Lv. {})", self.level, level)
}
None => format!("{}", self.level),
},
source: config.metadata.source.clone(),
tags: vec![],
},
difficulty: beatmap::Difficulty {
hp_drain_rate: config.hp_drain.map_from(self.level.relative_difficulty()),
circle_size: f32::from(self.level.players) * 4.0,
overall_difficulty: config.accuracy.map_from(self.level.relative_difficulty()),
approach_rate: 8.0,
slider_multiplier: 0.64,
slider_tick_rate: 1.0,
},
events: beatmap::Events(vec![]),
timing_points: self.timing_points.clone(),
colours: beatmap::Colours(vec![]),
hit_objects: self.hit_objects.clone(),
}
beatmap::BeatmapBuilder::default()
.general(
beatmap::GeneralBuilder::default()
.audio_filename(config.audio_filename.clone())
.sample_set(SampleSet::Soft)
.mode(Mode::Mania)
.build()
.unwrap(),
)
.metadata(
beatmap::MetadataBuilder::default()
.title(
config
.metadata
.title
.as_ref()
.unwrap_or(&"unknown title".to_string())
.clone(),
)
.artist(
config
.metadata
.artist
.as_ref()
.unwrap_or(&"unknown artist".to_string())
.clone(),
)
.creator(format!("{}", config))
.version(match &config.metadata.levels {
Some(levels) => {
let level = self.level.to_value(levels);
format!("{} (Lv. {})", self.level, level)
}
None => format!("{}", self.level),
})
.source(config.metadata.source.clone())
.build()
.unwrap(),
)
.difficulty(
beatmap::DifficultyBuilder::default()
.hp_drain_rate(config.hp_drain.map_from(self.level.relative_difficulty()))
.circle_size(f32::from(self.level.players) * 4.0)
.overall_difficulty(config.accuracy.map_from(self.level.relative_difficulty()))
.approach_rate(8.0)
.slider_multiplier(0.64)
.slider_tick_rate(1.0)
.build()
.unwrap(),
)
.timing_points(self.timing_points.clone())
.hit_objects(self.hit_objects.clone())
.build()
.unwrap()
}
}

View File

@ -1,7 +1,4 @@
/*
* For documentation on the file format see
* https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)
*/
pub mod beatmap;
pub mod osz;
pub mod types;

View File

@ -1,61 +1,130 @@
//! The description format of an osu! beatmap.
//!
//! The beatmap file format is described in the [osu! knowledge base].
//!
//! Example (building a minimal beatmap):
//!
//! ```
//! # use brd::osu::beatmap;
//! let awesome_beatmap = beatmap::BeatmapBuilder::default()
//! .general(
//! beatmap::GeneralBuilder::default()
//! .audio_filename("audio.mp3")
//! .build()
//! .unwrap(),
//! )
//! .metadata(
//! beatmap::MetadataBuilder::default()
//! .title("My awesome song")
//! .artist("Awesome artist")
//! .creator("Me")
//! .version("Hard")
//! .source("Awesome songs vol.3")
//! .build()
//! .unwrap(),
//! )
//! .difficulty(
//! beatmap::DifficultyBuilder::default()
//! .hp_drain_rate(4.0)
//! .circle_size(4.0)
//! .overall_difficulty(3.0)
//! .approach_rate(8.0)
//! .slider_multiplier(0.64)
//! .slider_tick_rate(1.0)
//! .build()
//! .unwrap(),
//! )
//! .timing_points(beatmap::TimingPoints(vec![
//! beatmap::TimingPointBuilder::default()
//! .time(0)
//! .beat_length(1000.0 / 3.0)
//! .build()
//! .unwrap(),
//! ]))
//! .hit_objects(beatmap::HitObjects(vec![
//! beatmap::hit_object::HitCircleBuilder::default()
//! .x(256)
//! .y(192)
//! .time(8000)
//! .build()
//! .unwrap()
//! .into(),
//! ]))
//! .build()
//! .unwrap();
//!
//! assert_eq!(
//! format!("{}", awesome_beatmap),
//! r#"osu file format v14
//!
//! [General]
//! AudioFilename: audio.mp3
//! AudioLeadIn: 0
//! PreviewTime: -1
//! Countdown: 1
//! SampleSet: Normal
//! Mode: 0
//!
//! [Editor]
//!
//! [Metadata]
//! Title:My awesome song
//! Artist:Awesome artist
//! Creator:Me
//! Version:Hard
//! Source:Awesome songs vol.3
//! Tags:
//!
//! [Difficulty]
//! HPDrainRate:4
//! CircleSize:4
//! OverallDifficulty:3
//! ApproachRate:8
//! SliderMultiplier:0.64
//! SliderTickRate:1
//!
//! [Events]
//!
//!
//! [TimingPoints]
//! 0,333.33334,4,0,0,100,1,0
//!
//! [Colours]
//!
//!
//! [HitObjects]
//! 256,192,8000,1,0,0:0:0:0:
//! "#
//! );
//! ```
//!
//! [osu! knowledge base]: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)
pub mod hit_object;
pub use hit_object::HitObject;
use std::fmt;
use derive_builder::Builder;
use derive_more::{Deref, DerefMut};
use num_derive::ToPrimitive;
use num_traits::ToPrimitive;
use super::types::*;
use crate::utils;
// Generic Type Aliases
pub type OsuPixel = i16;
pub type DecimalOsuPixel = f32;
pub type SampleIndex = u16;
pub type Time = u32;
fn assemble_hit_object_type(hit_object_type: u8, new_combo: bool, skip_combo_colours: u8) -> u8 {
let hit_object_type = 1u8 << hit_object_type;
let new_combo = if new_combo { 0b0000_0010_u8 } else { 0u8 };
let skip_combo_colours = (skip_combo_colours & 0b_0000_0111u8) << 1;
hit_object_type + new_combo + skip_combo_colours
}
pub fn column_to_x(column: u8, columns: u8) -> OsuPixel {
(512 * OsuPixel::from(column) + 256) / OsuPixel::from(columns)
}
#[derive(ToPrimitive, Clone)]
pub enum Countdown {
No = 0,
Normal = 1,
Half = 2,
Double = 3,
}
#[derive(ToPrimitive, Clone)]
pub enum Mode {
Normal = 0,
Taiko = 1,
Catch = 2,
Mania = 3,
}
#[derive(ToPrimitive, Debug, Clone)]
pub enum SampleSet {
BeatmapDefault = 0,
Normal = 1,
Soft = 2,
Drum = 3,
}
#[derive(Clone)]
#[derive(Builder, Clone)]
pub struct General {
#[builder(setter(into))]
pub audio_filename: String,
#[builder(default)]
pub audio_lead_in: Time,
pub preview_time: Time,
#[builder(default = "-1")]
pub preview_time: SignedTime,
#[builder(default)]
pub countdown: Countdown,
// SampleSets normal default (BeatmapDefault) does not make sense here
#[builder(default = "SampleSet::Normal")]
pub sample_set: SampleSet,
#[builder(default)]
pub mode: Mode,
}
@ -82,8 +151,8 @@ impl fmt::Display for General {
}
}
#[derive(Clone)]
pub struct Editor {/* stub */}
#[derive(Clone, Default)]
pub struct Editor;
impl fmt::Display for Editor {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
@ -91,13 +160,17 @@ impl fmt::Display for Editor {
}
}
#[derive(Clone)]
#[derive(Builder, Clone)]
#[builder(setter(into))]
pub struct Metadata {
pub title: String,
pub artist: String,
#[builder(default = "\"brd::osu\".to_string()")]
pub creator: String,
pub version: String,
#[builder(default)]
pub source: String,
#[builder(default)]
pub tags: Vec<String>,
}
@ -124,17 +197,46 @@ impl fmt::Display for Metadata {
}
}
#[derive(Clone)]
#[derive(Builder, Clone, Debug)]
#[builder(build_fn(validate = "Self::validate"))]
pub struct Difficulty {
pub hp_drain_rate: f32,
#[builder(setter(into))]
pub hp_drain_rate: RangeSetting,
/// Also is the number of keys in mania
pub circle_size: f32,
pub overall_difficulty: f32,
pub approach_rate: f32,
#[builder(setter(into))]
pub circle_size: RangeSetting,
#[builder(setter(into))]
pub overall_difficulty: RangeSetting,
#[builder(setter(into))]
pub approach_rate: RangeSetting,
pub slider_multiplier: f32,
pub slider_tick_rate: f32,
}
impl DifficultyBuilder {
fn validate_option(maybe_value: &Option<RangeSetting>, name: &str) -> Result<(), String> {
if let Some(value) = maybe_value {
if !value.validate() {
return Err(format!(
"{} has to be between {} and {}",
name,
RangeSetting::MIN,
RangeSetting::MAX
));
}
}
Ok(())
}
fn validate(&self) -> Result<(), String> {
Self::validate_option(&self.hp_drain_rate, "hp_drain_rate")?;
Self::validate_option(&self.circle_size, "circle_size")?;
Self::validate_option(&self.overall_difficulty, "overall_difficulty")?;
Self::validate_option(&self.approach_rate, "approach_rate")?;
Ok(())
}
}
impl fmt::Display for Difficulty {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
@ -158,7 +260,7 @@ impl fmt::Display for Difficulty {
}
}
#[derive(Clone, Deref)]
#[derive(Clone, Default, Deref, DerefMut)]
pub struct Events(pub Vec<Event>);
impl fmt::Display for Events {
@ -174,7 +276,7 @@ impl fmt::Display for Events {
}
}
#[derive(Clone)]
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
Background {
filename: String,
@ -182,8 +284,8 @@ pub enum Event {
y_offset: OsuPixel,
},
Video {
start_time: Time,
filename: String,
start_time: Time,
x_offset: OsuPixel,
y_offset: OsuPixel,
},
@ -219,7 +321,7 @@ impl fmt::Display for Event {
}
}
#[derive(Clone, Deref, DerefMut)]
#[derive(Clone, Default, Deref, DerefMut)]
pub struct TimingPoints(pub Vec<TimingPoint>);
impl fmt::Display for TimingPoints {
@ -235,7 +337,7 @@ impl fmt::Display for TimingPoints {
}
}
#[derive(Clone)]
#[derive(Builder, Clone, Default)]
pub struct TimingPointEffects {
pub kiai_time: bool,
pub omit_first_barline: bool,
@ -260,15 +362,21 @@ impl fmt::Display for TimingPointEffects {
}
}
#[derive(Clone)]
#[derive(Builder, Clone)]
pub struct TimingPoint {
pub time: Time,
pub beat_length: f32,
#[builder(default = "4")]
pub meter: u8,
#[builder(default = "SampleSet::BeatmapDefault")]
pub sample_set: SampleSet,
pub sample_index: SampleIndex,
#[builder(default = "0")]
pub sample_index: u32,
#[builder(default = "100")]
pub volume: u8,
#[builder(default = "true")]
pub uninherited: bool,
#[builder(default)]
pub effects: TimingPointEffects,
}
@ -289,7 +397,7 @@ impl fmt::Display for TimingPoint {
}
}
#[derive(Clone, Deref)]
#[derive(Clone, Default, Deref, DerefMut)]
pub struct Colours(pub Vec<Colour>);
impl fmt::Display for Colours {
@ -321,7 +429,7 @@ impl fmt::Display for ColourScope {
}
}
#[derive(Clone)]
#[derive(Builder, Clone)]
pub struct Colour {
pub scope: ColourScope,
pub colour: [u8; 3],
@ -338,14 +446,6 @@ impl fmt::Display for Colour {
}
}
#[derive(Clone)]
pub struct HitSound {
pub normal: bool,
pub whistle: bool,
pub finish: bool,
pub clap: bool,
}
impl fmt::Display for HitSound {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
@ -365,174 +465,21 @@ impl fmt::Display for HitSound {
}
}
#[derive(Clone)]
pub struct HitSample {
pub normal_set: SampleIndex,
pub addition_set: SampleIndex,
pub index: SampleIndex,
pub volume: u8,
pub filename: String,
}
impl fmt::Display for HitSample {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{}:{}:{}:{}:{}",
self.normal_set, self.addition_set, self.index, self.volume, self.filename
ToPrimitive::to_u8(&self.normal_set).unwrap(),
ToPrimitive::to_u8(&self.addition_set).unwrap(),
self.index,
self.volume,
self.filename
)
}
}
#[derive(Clone)]
pub enum HitObject {
HitCircle {
x: OsuPixel,
y: OsuPixel,
time: Time,
hit_sound: HitSound,
new_combo: bool,
skip_combo_colours: u8,
hit_sample: HitSample,
},
Slider {
x: OsuPixel,
y: OsuPixel,
time: Time,
hit_sound: HitSound,
new_combo: bool,
skip_combo_colours: u8,
curve_type: char,
curve_points: Vec<(DecimalOsuPixel, DecimalOsuPixel)>,
slides: u8,
length: DecimalOsuPixel,
edge_sounds: Vec<SampleIndex>,
edge_sets: Vec<(SampleSet, SampleSet)>,
hit_sample: HitSample,
},
Spinner {
time: Time,
hit_sound: HitSound,
new_combo: bool,
skip_combo_colours: u8,
end_time: Time,
hit_sample: HitSample,
},
Hold {
column: u8,
columns: u8,
time: Time,
hit_sound: HitSound,
new_combo: bool,
skip_combo_colours: u8,
end_time: Time,
hit_sample: HitSample,
},
}
impl fmt::Display for HitObject {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
HitObject::HitCircle {
x,
y,
time,
hit_sound,
new_combo,
skip_combo_colours,
hit_sample,
} => write!(
f,
"{},{},{},{},{},{}",
x,
y,
time,
assemble_hit_object_type(0, *new_combo, *skip_combo_colours),
hit_sound,
hit_sample
),
HitObject::Slider {
x,
y,
time,
hit_sound,
new_combo,
skip_combo_colours,
curve_type,
curve_points,
slides,
length,
edge_sounds,
edge_sets,
hit_sample,
} => write!(
f,
"{},{},{},{},{},{}|{},{},{},{},{},{}",
x,
y,
time,
assemble_hit_object_type(1, *new_combo, *skip_combo_colours),
hit_sound,
curve_type,
curve_points
.iter()
.map(|point| format!("{}:{}", point.0, point.1))
.collect::<Vec<_>>()
.join("|"),
slides,
length,
utils::join_display_values(edge_sounds.clone(), "|"),
edge_sets
.iter()
.map(|set| format!(
"{}:{}",
ToPrimitive::to_u16(&set.0).unwrap(),
ToPrimitive::to_u16(&set.1).unwrap()
))
.collect::<Vec<_>>()
.join("|"),
hit_sample
),
HitObject::Spinner {
time,
hit_sound,
new_combo,
skip_combo_colours,
end_time,
hit_sample,
} => write!(
f,
"256,192,{},{},{},{},{}",
time,
assemble_hit_object_type(3, *new_combo, *skip_combo_colours),
hit_sound,
end_time,
hit_sample
),
HitObject::Hold {
column,
columns,
time,
hit_sound,
new_combo,
skip_combo_colours,
end_time,
hit_sample,
} => write!(
f,
"{},192,{},{},{},{}:{}",
column_to_x(*column, *columns),
time,
assemble_hit_object_type(7, *new_combo, *skip_combo_colours),
hit_sound,
end_time,
hit_sample
),
}
}
}
#[derive(Clone, Deref, DerefMut)]
#[derive(Clone, Default, Deref, DerefMut)]
pub struct HitObjects(pub Vec<HitObject>);
impl fmt::Display for HitObjects {
@ -548,14 +495,19 @@ impl fmt::Display for HitObjects {
}
}
#[derive(Builder)]
pub struct Beatmap {
#[builder(default = "14")]
pub version: u8,
pub general: General,
#[builder(default)]
pub editor: Editor,
pub metadata: Metadata,
pub difficulty: Difficulty,
#[builder(default)]
pub events: Events,
pub timing_points: TimingPoints,
#[builder(default)]
pub colours: Colours,
pub hit_objects: HitObjects,
}
@ -564,7 +516,7 @@ impl fmt::Display for Beatmap {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"osu file format v{}\n\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
"osu file format v{}\n\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
self.version,
self.general.clone(),
self.editor.clone(),
@ -577,3 +529,275 @@ impl fmt::Display for Beatmap {
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn general() {
let general = GeneralBuilder::default()
.audio_filename("foo.mp3")
.audio_lead_in(23)
.preview_time(5000)
.countdown(Countdown::Double)
.sample_set(SampleSet::Drum)
.mode(Mode::Mania)
.build()
.unwrap();
assert_eq!(
format!("{}", general),
"[General]\n\
AudioFilename: foo.mp3\n\
AudioLeadIn: 23\n\
PreviewTime: 5000\n\
Countdown: 3\n\
SampleSet: Drum\n\
Mode: 3\n",
)
}
#[test]
fn editor() {
assert_eq!(format!("{}", Editor), "[Editor]\n");
}
#[test]
fn metadata() {
let metadata = MetadataBuilder::default()
.title("Song Title")
.artist("Song Artist")
.creator("mycoolusername42")
.version("Super Hard")
.source("Best Hits Vol. 23")
.tags(vec![
"some".to_string(),
"descriptive".to_string(),
"tags".to_string(),
])
.build()
.unwrap();
assert_eq!(
format!("{}", metadata),
"[Metadata]\n\
Title:Song Title\n\
Artist:Song Artist\n\
Creator:mycoolusername42\n\
Version:Super Hard\n\
Source:Best Hits Vol. 23\n\
Tags:some descriptive tags\n"
);
}
#[test]
fn dificulty_builder_error() {
assert_eq!(
DifficultyBuilder::default()
.hp_drain_rate(25.0)
.circle_size(5.0)
.overall_difficulty(5.0)
.approach_rate(5.0)
.build()
.unwrap_err(),
"hp_drain_rate has to be between 0 and 10"
);
}
#[test]
fn difficulty() {
let difficulty = DifficultyBuilder::default()
.hp_drain_rate(4.0)
.circle_size(5.0)
.overall_difficulty(6.0)
.approach_rate(7.0)
.slider_multiplier(0.64)
.slider_tick_rate(1.0)
.build()
.unwrap();
assert_eq!(
format!("{}", difficulty),
"[Difficulty]\n\
HPDrainRate:4\n\
CircleSize:5\n\
OverallDifficulty:6\n\
ApproachRate:7\n\
SliderMultiplier:0.64\n\
SliderTickRate:1\n"
)
}
#[test]
fn events() {
let mut events = Events(Vec::new());
events.push(Event::Background {
filename: "foo.jpg".to_string(),
x_offset: 42.into(),
y_offset: 23.into(),
});
events.push(Event::Video {
filename: "foo.mp4".to_string(),
start_time: 500,
x_offset: 42.into(),
y_offset: 23.into(),
});
events.push(Event::Break {
start_time: 23000,
end_time: 42000,
});
assert_eq!(
format!("{}", events),
"[Events]\n\
0,0,foo.jpg,42,23\n\
Video,500,foo.mp4,42,23\n\
Break,23000,42000\n"
)
}
#[test]
fn timing_points() {
let mut timing_points = TimingPoints(Vec::new());
timing_points.push(
TimingPointBuilder::default()
.time(0)
.beat_length(1000.0 / 3.0)
.build()
.unwrap(),
);
timing_points.push(
TimingPointBuilder::default()
.time(5000)
.beat_length(500.0)
.meter(8)
.sample_set(SampleSet::Drum)
.sample_index(1)
.volume(50)
.uninherited(false)
.effects(
TimingPointEffectsBuilder::default()
.kiai_time(true)
.omit_first_barline(true)
.build()
.unwrap(),
)
.build()
.unwrap(),
);
assert_eq!(
format!("{}", timing_points),
"[TimingPoints]\n\
0,333.33334,4,0,0,100,1,0\n\
5000,500,8,3,1,50,0,9\n"
);
}
#[test]
fn colours() {
let mut colours = Colours::default();
colours.push(
ColourBuilder::default()
.scope(ColourScope::Combo(42))
.colour([0, 127, 255])
.build()
.unwrap(),
);
colours.push(
ColourBuilder::default()
.scope(ColourScope::SliderTrackOverride)
.colour([127, 255, 0])
.build()
.unwrap(),
);
colours.push(
ColourBuilder::default()
.scope(ColourScope::SliderBorder)
.colour([255, 0, 127])
.build()
.unwrap(),
);
assert_eq!(
format!("{}", colours),
"[Colours]\n\
Combo42 : 0,127,255\n\
SliderTrackOverride : 127,255,0\n\
SliderBorder : 255,0,127\n"
)
}
#[test]
fn hit_sound() {
assert_eq!(format!("{}", HitSound::default()), "0");
assert_eq!(
format!(
"{}",
HitSoundBuilder::default().normal(true).build().unwrap()
),
"1"
);
assert_eq!(
format!(
"{}",
HitSoundBuilder::default().whistle(true).build().unwrap()
),
"2"
);
assert_eq!(
format!(
"{}",
HitSoundBuilder::default().finish(true).build().unwrap()
),
"4"
);
assert_eq!(
format!("{}", HitSoundBuilder::default().clap(true).build().unwrap()),
"8"
);
}
#[test]
fn hit_sample() {
assert_eq!(format!("{}", HitSample::default()), "0:0:0:0:");
assert_eq!(
format!(
"{}",
HitSampleBuilder::default()
.normal_set(SampleSet::Drum)
.addition_set(SampleSet::Normal)
.index(23)
.volume(42)
.filename("foo.mp3")
.build()
.unwrap()
),
"3:1:23:42:foo.mp3"
);
}
#[test]
fn hit_objects() {
let mut hit_objects: HitObjects = Default::default();
hit_objects.push(
hit_object::HitCircleBuilder::default()
.x(200)
.y(400)
.time(5732)
.build()
.unwrap()
.into(),
);
hit_objects.push(
hit_object::HitCircleBuilder::default()
.x(400)
.y(500)
.time(7631)
.build()
.unwrap()
.into(),
);
assert_eq!(
format!("{}", hit_objects),
"[HitObjects]\n\
200,400,5732,1,0,0:0:0:0:\n\
400,500,7631,1,0,0:0:0:0:\n"
);
}
}

View File

@ -0,0 +1,472 @@
use std::fmt;
use derive_builder::Builder;
use num_traits::ToPrimitive;
use super::super::types::*;
use crate::utils;
/// Represents every hit object type
///
/// The recommended way to construct hit objects is to use the `*Builder` structs of [`HitCircle`],
/// [`Slider`], [`Spinner`] and [`Hold`]. See their respective
/// documentation for examples on how to do that.
/// For constructing osu!mania hit circles, the convenience struct [`ManiaHitCircle`] and its
/// builder is provided.
///
/// [`HitCircle`]: struct.HitCircle.html
/// [`Slider`]: struct.Slider.html
/// [`Spinner`]: struct.Spinner.html
/// [`Hold`]: struct.Hold.html
/// [`ManiaHitCircle`]: struct.ManiaHitCircle.html
#[derive(Clone, Debug, PartialEq)]
pub enum HitObject {
HitCircle(HitCircle),
Slider(Slider),
Spinner(Spinner),
Hold(Hold),
}
// TODO: deduplicate new_combo and skip_combo_colours
impl HitObject {
/// Variant independent getter for `new_combo`
fn new_combo(&self) -> bool {
match self {
Self::HitCircle(HitCircle { new_combo, .. })
| Self::Slider(Slider { new_combo, .. })
| Self::Spinner(Spinner { new_combo, .. })
| Self::Hold(Hold { new_combo, .. }) => *new_combo,
}
}
/// Variant independent getter for `skip_combo_colours`
fn skip_combo_colours(&self) -> u8 {
match self {
Self::HitCircle(HitCircle {
skip_combo_colours, ..
})
| Self::Slider(Slider {
skip_combo_colours, ..
})
| Self::Spinner(Spinner {
skip_combo_colours, ..
})
| Self::Hold(Hold {
skip_combo_colours, ..
}) => *skip_combo_colours,
}
}
/// Returns the hit object type as `u8` (byte)
///
/// See the [osu! knowledge base] for more information.
///
/// [osu! knowledge base]: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#type
fn type_byte(&self) -> u8 {
let type_bit = match self {
Self::HitCircle { .. } => 0,
Self::Slider { .. } => 1,
Self::Spinner { .. } => 3,
Self::Hold { .. } => 7,
};
let hit_object_type = 1u8 << type_bit;
let new_combo = if self.new_combo() {
0b0000_0010_u8
} else {
0u8
};
let skip_combo_colours = (self.skip_combo_colours() & 0b_0000_0111u8) << 3;
hit_object_type + new_combo + skip_combo_colours
}
}
impl fmt::Display for HitObject {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
HitObject::HitCircle(HitCircle {
x,
y,
time,
hit_sound,
hit_sample,
..
}) => write!(
f,
"{},{},{},{},{},{}",
x,
y,
time,
self.type_byte(),
hit_sound,
hit_sample
),
HitObject::Slider(Slider {
x,
y,
time,
curve_type,
curve_points,
slides,
length,
edge_sounds,
edge_sets,
hit_sound,
hit_sample,
..
}) => write!(
f,
"{},{},{},{},{},{:?}|{},{},{},{},{},{}",
x,
y,
time,
self.type_byte(),
hit_sound,
curve_type,
curve_points
.iter()
.map(|point| format!("{}:{}", point.0, point.1))
.collect::<Vec<_>>()
.join("|"),
slides,
length,
utils::join_display_values(edge_sounds.clone(), "|"),
edge_sets
.iter()
.map(|set| format!(
"{}:{}",
ToPrimitive::to_u16(&set.0).unwrap(),
ToPrimitive::to_u16(&set.1).unwrap()
))
.collect::<Vec<_>>()
.join("|"),
hit_sample
),
HitObject::Spinner(Spinner {
time,
end_time,
hit_sound,
hit_sample,
..
}) => write!(
f,
"256,192,{},{},{},{},{}",
time,
self.type_byte(),
hit_sound,
end_time,
hit_sample
),
HitObject::Hold(Hold {
column,
columns,
time,
end_time,
hit_sound,
hit_sample,
..
}) => write!(
f,
"{},192,{},{},{},{}:{}",
OsuPixel::from_mania_column(*column, *columns),
time,
self.type_byte(),
hit_sound,
end_time,
hit_sample
),
}
}
}
/// Represents a hit circle
///
/// Minimal example:
///
/// ```
/// # use brd::osu::beatmap::hit_object::*;
/// let hit_circle: HitObject = HitCircleBuilder::default()
/// .x(200)
/// .y(400)
/// .time(5000)
/// .build()
/// .unwrap()
/// .into();
/// assert_eq!(format!("{}", hit_circle), "200,400,5000,1,0,0:0:0:0:");
/// ```
#[derive(Builder, Clone, Debug, PartialEq)]
pub struct HitCircle {
#[builder(setter(into))]
x: OsuPixel,
#[builder(setter(into))]
y: OsuPixel,
time: Time,
#[builder(default)]
hit_sound: HitSound,
#[builder(default)]
new_combo: bool,
#[builder(default)]
skip_combo_colours: u8,
#[builder(default)]
hit_sample: HitSample,
}
impl Into<HitObject> for HitCircle {
fn into(self) -> HitObject {
HitObject::HitCircle(self)
}
}
/// Represents a slider
///
/// Minimal example:
///
/// ```
/// # use brd::osu::{beatmap::hit_object::*, types::*};
/// let slider: HitObject = SliderBuilder::default()
/// .x(200)
/// .y(400)
/// .time(5000)
/// .curve_type(CurveType::B)
/// .curve_points(vec![(20.1, 30.2), (40.3, 50.4)])
/// .length(250.8)
/// .build()
/// .unwrap()
/// .into();
/// assert_eq!(
/// format!("{}", slider),
/// "200,400,5000,2,0,B|20.1:30.2|40.3:50.4,1,250.8,,,0:0:0:0:"
/// );
/// ```
#[derive(Builder, Clone, Debug, PartialEq)]
pub struct Slider {
#[builder(setter(into))]
x: OsuPixel,
#[builder(setter(into))]
y: OsuPixel,
time: Time,
curve_type: CurveType,
curve_points: Vec<(DecimalOsuPixel, DecimalOsuPixel)>,
#[builder(default = "1")]
slides: u8,
length: DecimalOsuPixel,
#[builder(default)]
edge_sounds: Vec<HitSound>,
#[builder(default)]
edge_sets: Vec<(SampleSet, SampleSet)>,
#[builder(default)]
hit_sound: HitSound,
#[builder(default)]
new_combo: bool,
#[builder(default)]
skip_combo_colours: u8,
#[builder(default)]
hit_sample: HitSample,
}
impl Into<HitObject> for Slider {
fn into(self) -> HitObject {
HitObject::Slider(self)
}
}
/// Represents a spinner
///
/// Minimal example:
///
/// ```
/// # use brd::osu::{beatmap::hit_object::*};
/// let spinner: HitObject = SpinnerBuilder::default()
/// .time(5000)
/// .end_time(10000)
/// .build()
/// .unwrap()
/// .into();
/// assert_eq!(format!("{}", spinner), "256,192,5000,8,0,10000,0:0:0:0:");
/// ```
#[derive(Builder, Clone, Debug, PartialEq)]
pub struct Spinner {
time: Time,
end_time: Time,
#[builder(default)]
hit_sound: HitSound,
#[builder(default)]
new_combo: bool,
#[builder(default)]
skip_combo_colours: u8,
#[builder(default)]
hit_sample: HitSample,
}
impl Into<HitObject> for Spinner {
fn into(self) -> HitObject {
HitObject::Spinner(self)
}
}
/// Represents a osu!mania hold
///
/// Minimal example:
///
/// ```
/// # use brd::osu::{beatmap::hit_object::*};
/// let hold: HitObject = HoldBuilder::default()
/// .column(2) // columns start at 0 → column 2 is the third column
/// .columns(4)
/// .time(5000)
/// .end_time(10000)
/// .build()
/// .unwrap()
/// .into();
/// assert_eq!(format!("{}", hold), "320,192,5000,128,0,10000:0:0:0:0:");
/// ```
#[derive(Builder, Clone, Debug, PartialEq)]
pub struct Hold {
column: u8,
columns: u8,
time: Time,
end_time: Time,
#[builder(default)]
hit_sound: HitSound,
#[builder(default)]
new_combo: bool,
#[builder(default)]
skip_combo_colours: u8,
#[builder(default)]
hit_sample: HitSample,
}
impl Into<HitObject> for Hold {
fn into(self) -> HitObject {
HitObject::Hold(self)
}
}
/// Helper sturct to build an osu!mania hit circle
///
/// This struct abstracts the creation of osu!mania hit circles, are normal [`HitCircle`]s that use
/// the `x` value to determine the column to display in. `192` is used as the `y` value.
///
/// [`HitCircle`]: struct.HitCircle.html
///
/// Minimal example:
///
/// ```
/// # use brd::osu::{beatmap::hit_object::*};
/// let helper: HitObject = ManiaHitCircleBuilder::default()
/// .column(1)
/// .columns(4)
/// .time(7500)
/// .build()
/// .unwrap()
/// .into();
/// let manual: HitObject = HitCircleBuilder::default()
/// .x(192)
/// .y(192)
/// .time(7500)
/// .build()
/// .unwrap()
/// .into();
/// assert_eq!(helper, manual);
/// ```
#[derive(Builder, Clone, Debug, PartialEq)]
pub struct ManiaHitCircle {
column: u8,
columns: u8,
time: Time,
#[builder(default)]
hit_sound: HitSound,
#[builder(default)]
new_combo: bool,
#[builder(default)]
skip_combo_colours: u8,
#[builder(default)]
hit_sample: HitSample,
}
impl Into<HitObject> for ManiaHitCircle {
fn into(self) -> HitObject {
HitCircle {
x: OsuPixel::from_mania_column(self.column, self.columns),
y: 192.into(),
time: self.time,
hit_sound: self.hit_sound,
new_combo: self.new_combo,
skip_combo_colours: self.skip_combo_colours,
hit_sample: self.hit_sample,
}
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hit_circle() {
let object: HitObject = HitCircleBuilder::default()
.x(200)
.y(400)
.time(5732)
.new_combo(true)
.skip_combo_colours(5)
.build()
.unwrap()
.into();
assert_eq!(format!("{}", object), "200,400,5732,43,0,0:0:0:0:");
}
#[test]
fn slider() {
let object: HitObject = SliderBuilder::default()
.x(200)
.y(400)
.slides(4)
.time(5732)
.curve_type(CurveType::B)
.curve_points(vec![(20.1, 30.2), (40.3, 50.4)])
.length(250.8)
.edge_sounds(vec![HitSound::default()])
.edge_sets(vec![(SampleSet::Normal, SampleSet::Drum)])
.new_combo(true)
.skip_combo_colours(5)
.build()
.unwrap()
.into();
assert_eq!(
format!("{}", object),
"200,400,5732,44,0,B|20.1:30.2|40.3:50.4,4,250.8,0,1:3,0:0:0:0:"
);
}
#[test]
fn spinner() {
let object: HitObject = SpinnerBuilder::default()
.time(5000)
.end_time(10000)
.new_combo(true)
.skip_combo_colours(5)
.build()
.unwrap()
.into();
assert_eq!(format!("{}", object), "256,192,5000,50,0,10000,0:0:0:0:")
}
#[test]
fn hold() {
let object: HitObject = HoldBuilder::default()
.column(2)
.columns(4)
.time(6000)
.end_time(9000)
.new_combo(true)
.skip_combo_colours(5)
.build()
.unwrap()
.into();
assert_eq!(format!("{}", object), "320,192,6000,170,0,9000:0:0:0:0:");
}
}

177
src/osu/types.rs Normal file
View File

@ -0,0 +1,177 @@
use derive_builder::Builder;
use derive_more::{Deref, Display, From};
use num_derive::ToPrimitive;
/// The representation of one screen pixel when osu! is running in 640x480 resolution.
///
/// osupixels are one of the main coordinate systems used in osu!, and apply to hit circle
/// placement and storyboard screen coordinates (these pixels are scaled over a 4:3 ratio to fit
/// your screen).
///
/// ([osu! knowledge base: Glossary: osupixel](https://osu.ppy.sh/help/wiki/Glossary#osupixel))
#[derive(Clone, Debug, Deref, Display, From, PartialEq)]
pub struct OsuPixel(i16);
impl OsuPixel {
/// Converts osu!mania column to x position
pub fn from_mania_column(column: u8, columns: u8) -> Self {
Self((512 * i16::from(column) + 256) / i16::from(columns))
}
}
/// Special case of [`OsuPixel`] for sliders as they require additional precision.
///
/// [`OsuPixel`]: type.OsuPixel.html
pub type DecimalOsuPixel = f32;
/// Stores time in milliseconds
pub type Time = u32;
/// Special case of [`Time`] for [`General::preview_time`] which has a magic default value of `-1`.
///
/// [`Time`]: type.Time.html
/// [`General::preview_time`]: struct.General.html#structfield.preview_time
pub type SignedTime = i32;
#[derive(ToPrimitive, Clone, Debug, PartialEq)]
pub enum Countdown {
No = 0,
Normal = 1,
Half = 2,
Double = 3,
}
impl Default for Countdown {
fn default() -> Self {
Self::Normal
}
}
#[derive(ToPrimitive, Clone, Debug, PartialEq)]
pub enum Mode {
Normal = 0,
Taiko = 1,
Catch = 2,
Mania = 3,
}
impl Default for Mode {
fn default() -> Self {
Self::Normal
}
}
#[derive(ToPrimitive, Debug, Clone, PartialEq)]
pub enum SampleSet {
BeatmapDefault = 0,
Normal = 1,
Soft = 2,
Drum = 3,
}
impl Default for SampleSet {
fn default() -> Self {
Self::BeatmapDefault
}
}
#[derive(Clone, Debug, Deref, Display, PartialEq)]
pub struct RangeSetting(f32);
impl From<f32> for RangeSetting {
fn from(value: f32) -> Self {
Self(value)
}
}
impl RangeSetting {
pub const MIN: f32 = 0.0;
pub const MAX: f32 = 10.0;
pub fn validate(&self) -> bool {
self.0 >= Self::MIN && self.0 <= Self::MAX
}
}
/// The sounds played when the object is hit
///
/// By default, no sound is set to `true`, which [uses the normal hitsound](
/// https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds)
#[derive(Builder, Clone, Debug, Default, PartialEq)]
#[builder(default)]
pub struct HitSound {
#[builder(default)]
pub normal: bool,
#[builder(default)]
pub whistle: bool,
#[builder(default)]
pub finish: bool,
#[builder(default)]
pub clap: bool,
}
#[derive(Builder, Clone, Debug, Default, PartialEq)]
#[builder(default)]
pub struct HitSample {
pub normal_set: SampleSet,
pub addition_set: SampleSet,
pub index: u32,
pub volume: u8,
#[builder(setter(into))]
pub filename: String,
}
#[derive(Clone, Debug, PartialEq)]
pub enum CurveType {
/// Bézier
B,
/// Centripetal catmull-rom
C,
/// Linear
L,
/// Perfect circle
P,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults() {
assert_eq!(Countdown::default(), Countdown::Normal);
assert_eq!(Mode::default(), Mode::Normal);
assert_eq!(SampleSet::default(), SampleSet::BeatmapDefault);
assert_eq!(
HitSound::default(),
HitSound {
normal: false,
whistle: false,
finish: false,
clap: false,
}
)
}
#[test]
fn range_setting_from_f32() {
assert_eq!(RangeSetting::from(5.0), RangeSetting(5.0));
}
#[test]
fn range_setting_validate() {
assert_eq!(RangeSetting(-0.1).validate(), false);
assert_eq!(RangeSetting(0.0).validate(), true);
assert_eq!(RangeSetting(5.0).validate(), true);
assert_eq!(RangeSetting(10.0).validate(), true);
assert_eq!(RangeSetting(10.1).validate(), false);
}
#[test]
fn osu_pixel_from_mania_column() {
assert_eq!(OsuPixel::from_mania_column(0, 4), OsuPixel(64));
assert_eq!(OsuPixel::from_mania_column(3, 4), OsuPixel(448));
assert_eq!(OsuPixel::from_mania_column(0, 8), OsuPixel(32));
assert_eq!(OsuPixel::from_mania_column(5, 8), OsuPixel(352));
assert_eq!(OsuPixel::from_mania_column(7, 8), OsuPixel(480));
}
}