Expand description
§vsrg: Data structures for vertical scrolling rhythm games
This crate provides the building blocks that lets you quickly create vertical scrolling rhythm gameplay (hence vsrg) and its editor. Namely:
rhythm: represent beats withBeatTime, seconds withClockTime, and scrolling coordinates withScrollPosition.rhythm::TempoTrackconvert between beats and secondsrhythm::ScrollSpeedTrackconvert clock time to scroll position. Both support eased changes.notes: store short and long notes in a struct-of-arrays layout, indexed by both the note’s clock time and scroll position, which means querying visible notes for rendering and querying eligible notes for judgement is fast.generate_notes_storage!is a macro that generates storage for a game’s note types with ergonomic querying and editing methods.notes::relations: maintain relationships during edits, such as chords, matching endpoints, parent-child links, and chains of notes.math: general-purpose math utilities, includingmath::Fractionand easing curves used byrhythm.Easingprovides monotonic curves;math::AdvancedEasingadds curves that may be non-monotonic, with a more limited set of supported operations.
§Helper modules
collections: general-purpose data structures, such ascollections::IntervalTree. Used internally bynotes.
§Optional features
glam: implementmath::Easablefor supported [glam] vector types.serde: enable serialization and deserialization of the easing enums.lut_integration: enable integration ofmath::AdvancedEasingcurves using bundled lookup tables, at the cost of bundling the tables in static data (about 450kb).
§Getting started
You do not have to use all functionalities of this crate, the math and rhythm modules are
designed to be usable in isolation. For more details on them, check the respective modules’
documentations.
The rest of this section will guide you through how to quickly scaffold your rhythm game with this crate. This is a birds’ eye view that will gloss over many details. A full example is available on the git repository if you need more details.
§Examples
use vsrg::{generate_notes_storage, ClockTime, Easing, Tempo, Time};
use vsrg::notes::{
query_intersecting, GroupId, Interval, LongNoteData, LongNoteTypeStorage,
NoteGroup, NoteGroups, NotesStorage, ShortNoteData, ShortNoteTypeStorage,
};
use vsrg::rhythm::{
ScrollSpeedChangeEvent, ScrollSpeedTrack, ScrollSpeedTracks,
ScrollPosition, TempoChangeEvent, TempoTrack, TempoTracks, TrackId,
};
use soa_rs::{SoaClone, Soars};
// 1. Define tap and hold notes for a 4K rhythm game.
#[derive(Debug, Clone)]
struct TapNote {
// Clock time in this example; other games can store BeatTime or Time instead.
time: ClockTime,
lane: u16, // 0 to 3
}
#[derive(Debug, Clone)]
struct HoldNote {
time: ClockTime,
duration: ClockTime,
lane: u16,
}
// Runtime state supports judgement and rendering, and starts at Default::default().
// Both note types share these components in this example, but they can be different types.
#[derive(Clone, Soars, SoaClone, Default)]
struct NoteState {
completed: bool,
highlighted: bool,
}
// Stored note values other than timing and group membership.
// Both note types share these components in this example, but they can be different types.
#[derive(Clone, Soars, SoaClone)]
struct NoteValue {
lane: u16,
}
impl ShortNoteData for TapNote {
type ValueData = NoteValue;
type RuntimeData = NoteState;
fn time(&self) -> Time { Time::Clock(self.time) }
// Main group only in this example, but you can store the concrete group id in the note.
fn group_id(&self) -> GroupId { GroupId::Main }
fn value_data(&self) -> NoteValue { NoteValue { lane: self.lane } }
fn reconstruct<'a>(time: Time, data: <NoteValue as Soars>::Ref<'a>, group: GroupId) -> Self {
// This example stores only clock-based notes in the main group.
assert_eq!(group, GroupId::Main);
let Time::Clock(time) = time else { panic!("expected clock time") };
Self { time, lane: *data.lane }
}
}
impl LongNoteData for HoldNote {
type ValueData = NoteValue;
type RuntimeData = NoteState;
fn start_time(&self) -> Time { Time::Clock(self.time) }
fn duration(&self) -> Time { Time::Clock(self.duration) }
fn group_id(&self) -> GroupId { GroupId::Main }
fn value_data(&self) -> NoteValue { NoteValue { lane: self.lane } }
fn reconstruct<'a>(
time: Time, duration: Time, data: <NoteValue as Soars>::Ref<'a>, group: GroupId,
) -> Self {
assert_eq!(group, GroupId::Main);
let (Time::Clock(time), Time::Clock(duration)) = (time, duration) else {
panic!("expected clock time and duration")
};
Self { time, duration, lane: *data.lane }
}
}
// 2. Generate storage and enums for all note types.
generate_notes_storage! {
enum MyNoteType,
enum MyNote,
enum MyNoteSnapshot,
enum MyNoteRef,
enum MyNoteMut,
struct My4kNotesStorage {
note_type Tap(TapNote) => taps: ShortNoteTypeStorage<TapNote>,
note_type Hold(HoldNote) => holds: LongNoteTypeStorage<HoldNote>,
}
}
// 3. Define the note groups and their event tracks.
// Collections always have a main track. Named tracks can be added for other groups.
struct My4kEventTracks {
tempo_tracks: TempoTracks,
scroll_tracks: ScrollSpeedTracks,
}
struct MyNoteGroup {
tempo_track: TrackId,
scroll_track: TrackId,
// The above two are the bare minimum, if your games requires other properties per note
// group, add them here.
}
impl NoteGroup for MyNoteGroup {
fn main_group() -> Self {
Self {
tempo_track: TrackId::Main,
scroll_track: TrackId::Main,
}
}
fn scroll_track_id(&self) -> TrackId { self.scroll_track }
fn tempo_track_id(&self) -> TrackId { self.tempo_track }
}
// Keep groups and tracks available for later chart edits and rendering.
struct Chart {
notes: My4kNotesStorage,
groups: NoteGroups<MyNoteGroup>,
tracks: My4kEventTracks,
}
// 4. Load the chart, for example after deserializing your chart format.
fn load_chart(tap_notes: Vec<TapNote>, hold_notes: Vec<HoldNote>) -> Chart {
let tempo = TempoTrack::with_events(vec![TempoChangeEvent {
time: Time::Clock(ClockTime::ZERO),
tempo: Tempo::from_bpm(120.0).unwrap(),
ease: Easing::InConst,
}]).unwrap();
let mut tempo_tracks = TempoTracks::with_tracks(tempo, std::iter::empty());
let scroll = ScrollSpeedTrack::with_events(
TrackId::Main,
vec![ScrollSpeedChangeEvent::new(
Time::Clock(ClockTime::ZERO), 1.0, Easing::InConst,
).unwrap()],
&tempo_tracks,
);
let mut scroll_tracks = ScrollSpeedTracks::with_tracks(scroll, std::iter::empty());
let mut groups = NoteGroups::new();
let values = tap_notes.into_iter().map(MyNote::Tap)
.chain(hold_notes.into_iter().map(MyNote::Hold)).collect();
let notes = My4kNotesStorage::with_notes(
values, &mut groups, &mut tempo_tracks, &mut scroll_tracks,
);
Chart { notes, groups, tracks: My4kEventTracks { tempo_tracks, scroll_tracks } }
}
// 5. During gameplay loop:
// Query a judgement window in clock time.
// The game can use these candidates to choose which note a key press should hit.
fn judge_notes(notes: &My4kNotesStorage, current_time: ClockTime) {
let window = ClockTime::from_milis(100.0).unwrap();
let notes_in_range = notes.notes_ref_overlapping_range(Interval::new(
current_time - window, current_time + window,
));
for _note in notes_in_range {
todo!("Perform judgement logic here");
}
}
// Query the visible scroll range for each note type.
fn render_notes(chart: &Chart, current_time: ClockTime) {
// How long your visible range is
let render_distance = ScrollPosition::new(10.0).expect("Not NaN");
for group in chart.notes.taps().scroll_sorted_groups() {
let properties = chart
.groups
.get_group_or_main(*group.group_id());
let scroll_track = chart
.tracks
.scroll_tracks
.get_track_or_main(properties.scroll_track_id());
let current_pos = scroll_track.calculate_scroll_position(current_time);
let far_pos = current_pos + render_distance;
let visible = Interval::new(current_pos, far_pos);
// Quickly query for notes in range
for i in query_intersecting(group.scroll_position(), visible) {
// Retrieve any data that you need
let _note_value = group.value_data().get(i).unwrap();
let _note_state = group.runtime_data().get(i).unwrap();
let _note_scroll_pos = group.scroll_position()[i];
todo!("Draw the note");
}
}
for group in chart.notes.holds().scroll_sorted_groups() {
let properties = chart
.groups
.get_group_or_main(group.group_id());
let scroll_track = chart
.tracks
.scroll_tracks
.get_track_or_main(properties.scroll_track_id());
let current_pos = scroll_track.calculate_scroll_position(current_time);
let far_pos = current_pos + render_distance;
let visible = Interval::new(current_pos, far_pos);
// Quickly query for notes in range
for i in group.query_scroll_intersecting(visible) {
// Retrieve any data that you need
let _note_value = group.value_data().get(i).unwrap();
let _note_state = group.runtime_data().get(i).unwrap();
let _note_scroll_pos = group.scroll_position()[i];
todo!("Draw the note");
}
}
}
// 6. Modify the chart at runtime in your editor.
// Adding, removing, and replacing notes is supported, as well as operating in batches.
// Each note is identified by a u64 id.
fn editor(chart: &mut Chart, tap_id: u64) {
chart.notes.replace_note(
&mut chart.groups,
&mut chart.tracks.tempo_tracks,
&mut chart.tracks.scroll_tracks,
tap_id,
MyNote::Tap(TapNote { time: ClockTime::from_seconds(4.0).unwrap(), lane: 3 }),
);
}§Other tips
-
vsrg uses float for beat time, which may be imprecise. You may want to store beat time as
math::Fractionin your game instead. -
During hot loop, prefer querying the individual storage of each note type directly, similar to
render_notesin the above example. It both avoids cloning, allocating and has better memory access pattern. -
Avoid storing Vec or similar data structure in the note’s value data and runtime data since cloning them is expensive. Prefer Arc<[T]>, or even Rc<[T]> if your game logic is single threaded.
-
BeatTime, ClockTime, Tempo, ScrollPosition and other types are newtypes of f64, and have strict invariants (must be non NaN, infinities are allowed) to allow for Ord implementation. Any time a math operation produces NaN, it instead returns f64::INFINITY with the expectation that downstream code will safely ignore them. This should not be a problem in most rhythm games, but is something you may want to keep in mind.
Re-exports§
pub use math::Easing;pub use rhythm::BeatTime;pub use rhythm::ClockTime;pub use rhythm::ScrollPosition;pub use rhythm::Tempo;pub use rhythm::Time;pub use generativity;pub use soa_rs;
Modules§
- collections
- General-purpose collection and data structure utilities.
- math
- General-purpose mathematical utility types.
- notes
- Generic storage of rhythm game notes.
- rhythm
- Beat time, clock time, note scroll position and conversion between them through event tracks.
Macros§
- generate_
notes_ storage - Generates note enum and storage of all note types.