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",
|
||||
"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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
// SampleSet’s 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
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