Refactor osu beatmap
It now uses the builder pattern and has tests and some documentation.
This commit is contained in:
parent
3ab224414f
commit
73dea88f7d
81
Cargo.lock
generated
81
Cargo.lock
generated
|
@ -51,6 +51,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"clap",
|
"clap",
|
||||||
|
"derive_builder",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"konami-lz77",
|
"konami-lz77",
|
||||||
"log",
|
"log",
|
||||||
|
@ -92,7 +93,7 @@ dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"os_str_bytes",
|
"os_str_bytes",
|
||||||
"strsim",
|
"strsim 0.10.0",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
"textwrap",
|
"textwrap",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
|
@ -179,6 +180,66 @@ dependencies = [
|
||||||
"lazy_static",
|
"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]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.9"
|
version = "0.99.9"
|
||||||
|
@ -221,6 +282,12 @@ dependencies = [
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
|
@ -259,6 +326,12 @@ dependencies = [
|
||||||
"quick-error",
|
"quick-error",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
|
@ -587,6 +660,12 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
|
|
|
@ -8,6 +8,7 @@ edition = "2018"
|
||||||
anyhow = "1.0.31"
|
anyhow = "1.0.31"
|
||||||
byteorder = "1.3.4"
|
byteorder = "1.3.4"
|
||||||
clap = "3.0.0-beta.1"
|
clap = "3.0.0-beta.1"
|
||||||
|
derive_builder = "0.9"
|
||||||
derive_more = "0.99.9"
|
derive_more = "0.99.9"
|
||||||
konami-lz77 = { git = "https://github.com/sbruder/konami-lz77" }
|
konami-lz77 = { git = "https://github.com/sbruder/konami-lz77" }
|
||||||
log = "0.4.8"
|
log = "0.4.8"
|
||||||
|
|
|
@ -7,6 +7,7 @@ use log::{debug, info, trace, warn};
|
||||||
|
|
||||||
use crate::ddr::ssq;
|
use crate::ddr::ssq;
|
||||||
use crate::osu::beatmap;
|
use crate::osu::beatmap;
|
||||||
|
use crate::osu::types::*;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ConfigRange(f32, f32);
|
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 tempo_change in tempo_changes.to_vec() {
|
||||||
// For TempoChanges that are infinitely short but exactly cover that beat, use the start
|
// For TempoChanges that are infinitely short but exactly cover that beat, use the start
|
||||||
// time of that TempoChange
|
// time of that TempoChange
|
||||||
|
@ -175,7 +176,7 @@ impl From<ssq::TempoChange> for beatmap::TimingPoint {
|
||||||
tempo_change.beat_length
|
tempo_change.beat_length
|
||||||
},
|
},
|
||||||
meter: 4,
|
meter: 4,
|
||||||
sample_set: beatmap::SampleSet::BeatmapDefault,
|
sample_set: SampleSet::BeatmapDefault,
|
||||||
sample_index: 0,
|
sample_index: 0,
|
||||||
volume: 100,
|
volume: 100,
|
||||||
uninherited: true,
|
uninherited: true,
|
||||||
|
@ -206,26 +207,15 @@ impl ssq::Step {
|
||||||
|
|
||||||
for (column, active) in columns.iter().enumerate() {
|
for (column, active) in columns.iter().enumerate() {
|
||||||
if *active {
|
if *active {
|
||||||
hit_objects.push(beatmap::HitObject::HitCircle {
|
hit_objects.push(
|
||||||
x: beatmap::column_to_x(column as u8, num_columns),
|
beatmap::hit_object::ManiaHitCircleBuilder::default()
|
||||||
y: 192,
|
.column(column as u8)
|
||||||
time,
|
.columns(num_columns)
|
||||||
hit_sound: beatmap::HitSound {
|
.time(time)
|
||||||
normal: true,
|
.build()
|
||||||
whistle: false,
|
.unwrap()
|
||||||
finish: false,
|
.into(),
|
||||||
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(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -245,27 +235,16 @@ impl ssq::Step {
|
||||||
|
|
||||||
for (column, active) in columns.iter().enumerate() {
|
for (column, active) in columns.iter().enumerate() {
|
||||||
if *active {
|
if *active {
|
||||||
hit_objects.push(beatmap::HitObject::Hold {
|
hit_objects.push(
|
||||||
column: column as u8,
|
beatmap::hit_object::HoldBuilder::default()
|
||||||
columns: num_columns,
|
.column(column as u8)
|
||||||
time,
|
.columns(num_columns)
|
||||||
end_time,
|
.time(time)
|
||||||
hit_sound: beatmap::HitSound {
|
.end_time(end_time)
|
||||||
normal: true,
|
.build()
|
||||||
whistle: false,
|
.unwrap()
|
||||||
finish: false,
|
.into(),
|
||||||
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(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -287,26 +266,15 @@ impl ssq::Step {
|
||||||
let columns = shock_step_generator.next().unwrap_or_else(Vec::new);
|
let columns = shock_step_generator.next().unwrap_or_else(Vec::new);
|
||||||
|
|
||||||
for column in columns {
|
for column in columns {
|
||||||
hit_objects.push(beatmap::HitObject::HitCircle {
|
hit_objects.push(
|
||||||
x: beatmap::column_to_x(column as u8, num_columns),
|
beatmap::hit_object::ManiaHitCircleBuilder::default()
|
||||||
y: 192,
|
.column(column as u8)
|
||||||
time: get_time_from_beats(*beats, tempo_changes)?,
|
.columns(num_columns)
|
||||||
hit_sound: beatmap::HitSound {
|
.time(get_time_from_beats(*beats, tempo_changes)?)
|
||||||
normal: true,
|
.build()
|
||||||
whistle: false,
|
.unwrap()
|
||||||
finish: false,
|
.into(),
|
||||||
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(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -323,54 +291,60 @@ struct ConvertedChart {
|
||||||
|
|
||||||
impl ConvertedChart {
|
impl ConvertedChart {
|
||||||
fn to_beatmap(&self, config: &Config) -> beatmap::Beatmap {
|
fn to_beatmap(&self, config: &Config) -> beatmap::Beatmap {
|
||||||
beatmap::Beatmap {
|
beatmap::BeatmapBuilder::default()
|
||||||
version: 14,
|
.general(
|
||||||
general: beatmap::General {
|
beatmap::GeneralBuilder::default()
|
||||||
audio_filename: config.audio_filename.clone(),
|
.audio_filename(config.audio_filename.clone())
|
||||||
audio_lead_in: 0,
|
.sample_set(SampleSet::Soft)
|
||||||
preview_time: 0,
|
.mode(Mode::Mania)
|
||||||
countdown: beatmap::Countdown::No,
|
.build()
|
||||||
sample_set: beatmap::SampleSet::Soft,
|
.unwrap(),
|
||||||
mode: beatmap::Mode::Mania,
|
)
|
||||||
},
|
.metadata(
|
||||||
editor: beatmap::Editor {},
|
beatmap::MetadataBuilder::default()
|
||||||
metadata: beatmap::Metadata {
|
.title(
|
||||||
title: config
|
config
|
||||||
.metadata
|
.metadata
|
||||||
.title
|
.title
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.unwrap_or(&"unknown title".to_string())
|
.unwrap_or(&"unknown title".to_string())
|
||||||
.clone(),
|
.clone(),
|
||||||
artist: config
|
)
|
||||||
|
.artist(
|
||||||
|
config
|
||||||
.metadata
|
.metadata
|
||||||
.artist
|
.artist
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.unwrap_or(&"unknown artist".to_string())
|
.unwrap_or(&"unknown artist".to_string())
|
||||||
.clone(),
|
.clone(),
|
||||||
creator: format!("{}", config),
|
)
|
||||||
version: match &config.metadata.levels {
|
.creator(format!("{}", config))
|
||||||
|
.version(match &config.metadata.levels {
|
||||||
Some(levels) => {
|
Some(levels) => {
|
||||||
let level = self.level.to_value(levels);
|
let level = self.level.to_value(levels);
|
||||||
format!("{} (Lv. {})", self.level, level)
|
format!("{} (Lv. {})", self.level, level)
|
||||||
}
|
}
|
||||||
None => format!("{}", self.level),
|
None => format!("{}", self.level),
|
||||||
},
|
})
|
||||||
source: config.metadata.source.clone(),
|
.source(config.metadata.source.clone())
|
||||||
tags: vec![],
|
.build()
|
||||||
},
|
.unwrap(),
|
||||||
difficulty: beatmap::Difficulty {
|
)
|
||||||
hp_drain_rate: config.hp_drain.map_from(self.level.relative_difficulty()),
|
.difficulty(
|
||||||
circle_size: f32::from(self.level.players) * 4.0,
|
beatmap::DifficultyBuilder::default()
|
||||||
overall_difficulty: config.accuracy.map_from(self.level.relative_difficulty()),
|
.hp_drain_rate(config.hp_drain.map_from(self.level.relative_difficulty()))
|
||||||
approach_rate: 8.0,
|
.circle_size(f32::from(self.level.players) * 4.0)
|
||||||
slider_multiplier: 0.64,
|
.overall_difficulty(config.accuracy.map_from(self.level.relative_difficulty()))
|
||||||
slider_tick_rate: 1.0,
|
.approach_rate(8.0)
|
||||||
},
|
.slider_multiplier(0.64)
|
||||||
events: beatmap::Events(vec![]),
|
.slider_tick_rate(1.0)
|
||||||
timing_points: self.timing_points.clone(),
|
.build()
|
||||||
colours: beatmap::Colours(vec![]),
|
.unwrap(),
|
||||||
hit_objects: self.hit_objects.clone(),
|
)
|
||||||
}
|
.timing_points(self.timing_points.clone())
|
||||||
|
.hit_objects(self.hit_objects.clone())
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 beatmap;
|
||||||
pub mod osz;
|
pub mod osz;
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
|
|
@ -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 std::fmt;
|
||||||
|
|
||||||
|
use derive_builder::Builder;
|
||||||
use derive_more::{Deref, DerefMut};
|
use derive_more::{Deref, DerefMut};
|
||||||
use num_derive::ToPrimitive;
|
|
||||||
use num_traits::ToPrimitive;
|
use num_traits::ToPrimitive;
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
use crate::utils;
|
use crate::utils;
|
||||||
|
|
||||||
// Generic Type Aliases
|
#[derive(Builder, Clone)]
|
||||||
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)]
|
|
||||||
pub struct General {
|
pub struct General {
|
||||||
|
#[builder(setter(into))]
|
||||||
pub audio_filename: String,
|
pub audio_filename: String,
|
||||||
|
#[builder(default)]
|
||||||
pub audio_lead_in: Time,
|
pub audio_lead_in: Time,
|
||||||
pub preview_time: Time,
|
#[builder(default = "-1")]
|
||||||
|
pub preview_time: SignedTime,
|
||||||
|
#[builder(default)]
|
||||||
pub countdown: Countdown,
|
pub countdown: Countdown,
|
||||||
|
// SampleSet’s normal default (BeatmapDefault) does not make sense here
|
||||||
|
#[builder(default = "SampleSet::Normal")]
|
||||||
pub sample_set: SampleSet,
|
pub sample_set: SampleSet,
|
||||||
|
#[builder(default)]
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,8 +151,8 @@ impl fmt::Display for General {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Default)]
|
||||||
pub struct Editor {/* stub */}
|
pub struct Editor;
|
||||||
|
|
||||||
impl fmt::Display for Editor {
|
impl fmt::Display for Editor {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
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 struct Metadata {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub artist: String,
|
pub artist: String,
|
||||||
|
#[builder(default = "\"brd::osu\".to_string()")]
|
||||||
pub creator: String,
|
pub creator: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
|
#[builder(default)]
|
||||||
pub source: String,
|
pub source: String,
|
||||||
|
#[builder(default)]
|
||||||
pub tags: Vec<String>,
|
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 struct Difficulty {
|
||||||
pub hp_drain_rate: f32,
|
#[builder(setter(into))]
|
||||||
|
pub hp_drain_rate: RangeSetting,
|
||||||
/// Also is the number of keys in mania
|
/// Also is the number of keys in mania
|
||||||
pub circle_size: f32,
|
#[builder(setter(into))]
|
||||||
pub overall_difficulty: f32,
|
pub circle_size: RangeSetting,
|
||||||
pub approach_rate: f32,
|
#[builder(setter(into))]
|
||||||
|
pub overall_difficulty: RangeSetting,
|
||||||
|
#[builder(setter(into))]
|
||||||
|
pub approach_rate: RangeSetting,
|
||||||
pub slider_multiplier: f32,
|
pub slider_multiplier: f32,
|
||||||
pub slider_tick_rate: 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 {
|
impl fmt::Display for Difficulty {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
write!(
|
write!(
|
||||||
|
@ -158,7 +260,7 @@ impl fmt::Display for Difficulty {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deref)]
|
#[derive(Clone, Default, Deref, DerefMut)]
|
||||||
pub struct Events(pub Vec<Event>);
|
pub struct Events(pub Vec<Event>);
|
||||||
|
|
||||||
impl fmt::Display for Events {
|
impl fmt::Display for Events {
|
||||||
|
@ -174,7 +276,7 @@ impl fmt::Display for Events {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
Background {
|
Background {
|
||||||
filename: String,
|
filename: String,
|
||||||
|
@ -182,8 +284,8 @@ pub enum Event {
|
||||||
y_offset: OsuPixel,
|
y_offset: OsuPixel,
|
||||||
},
|
},
|
||||||
Video {
|
Video {
|
||||||
start_time: Time,
|
|
||||||
filename: String,
|
filename: String,
|
||||||
|
start_time: Time,
|
||||||
x_offset: OsuPixel,
|
x_offset: OsuPixel,
|
||||||
y_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>);
|
pub struct TimingPoints(pub Vec<TimingPoint>);
|
||||||
|
|
||||||
impl fmt::Display for TimingPoints {
|
impl fmt::Display for TimingPoints {
|
||||||
|
@ -235,7 +337,7 @@ impl fmt::Display for TimingPoints {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Builder, Clone, Default)]
|
||||||
pub struct TimingPointEffects {
|
pub struct TimingPointEffects {
|
||||||
pub kiai_time: bool,
|
pub kiai_time: bool,
|
||||||
pub omit_first_barline: bool,
|
pub omit_first_barline: bool,
|
||||||
|
@ -260,15 +362,21 @@ impl fmt::Display for TimingPointEffects {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Builder, Clone)]
|
||||||
pub struct TimingPoint {
|
pub struct TimingPoint {
|
||||||
pub time: Time,
|
pub time: Time,
|
||||||
pub beat_length: f32,
|
pub beat_length: f32,
|
||||||
|
#[builder(default = "4")]
|
||||||
pub meter: u8,
|
pub meter: u8,
|
||||||
|
#[builder(default = "SampleSet::BeatmapDefault")]
|
||||||
pub sample_set: SampleSet,
|
pub sample_set: SampleSet,
|
||||||
pub sample_index: SampleIndex,
|
#[builder(default = "0")]
|
||||||
|
pub sample_index: u32,
|
||||||
|
#[builder(default = "100")]
|
||||||
pub volume: u8,
|
pub volume: u8,
|
||||||
|
#[builder(default = "true")]
|
||||||
pub uninherited: bool,
|
pub uninherited: bool,
|
||||||
|
#[builder(default)]
|
||||||
pub effects: TimingPointEffects,
|
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>);
|
pub struct Colours(pub Vec<Colour>);
|
||||||
|
|
||||||
impl fmt::Display for Colours {
|
impl fmt::Display for Colours {
|
||||||
|
@ -321,7 +429,7 @@ impl fmt::Display for ColourScope {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Builder, Clone)]
|
||||||
pub struct Colour {
|
pub struct Colour {
|
||||||
pub scope: ColourScope,
|
pub scope: ColourScope,
|
||||||
pub colour: [u8; 3],
|
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 {
|
impl fmt::Display for HitSound {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
write!(
|
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 {
|
impl fmt::Display for HitSample {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
write!(
|
write!(
|
||||||
f,
|
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)]
|
#[derive(Clone, Default, Deref, DerefMut)]
|
||||||
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)]
|
|
||||||
pub struct HitObjects(pub Vec<HitObject>);
|
pub struct HitObjects(pub Vec<HitObject>);
|
||||||
|
|
||||||
impl fmt::Display for HitObjects {
|
impl fmt::Display for HitObjects {
|
||||||
|
@ -548,14 +495,19 @@ impl fmt::Display for HitObjects {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Builder)]
|
||||||
pub struct Beatmap {
|
pub struct Beatmap {
|
||||||
|
#[builder(default = "14")]
|
||||||
pub version: u8,
|
pub version: u8,
|
||||||
pub general: General,
|
pub general: General,
|
||||||
|
#[builder(default)]
|
||||||
pub editor: Editor,
|
pub editor: Editor,
|
||||||
pub metadata: Metadata,
|
pub metadata: Metadata,
|
||||||
pub difficulty: Difficulty,
|
pub difficulty: Difficulty,
|
||||||
|
#[builder(default)]
|
||||||
pub events: Events,
|
pub events: Events,
|
||||||
pub timing_points: TimingPoints,
|
pub timing_points: TimingPoints,
|
||||||
|
#[builder(default)]
|
||||||
pub colours: Colours,
|
pub colours: Colours,
|
||||||
pub hit_objects: HitObjects,
|
pub hit_objects: HitObjects,
|
||||||
}
|
}
|
||||||
|
@ -564,7 +516,7 @@ impl fmt::Display for Beatmap {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
write!(
|
write!(
|
||||||
f,
|
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.version,
|
||||||
self.general.clone(),
|
self.general.clone(),
|
||||||
self.editor.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
472
src/osu/beatmap/hit_object.rs
Normal file
472
src/osu/beatmap/hit_object.rs
Normal 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
177
src/osu/types.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
Reference in a new issue