Skip to main content

Crate vsrg

Crate vsrg 

Source
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 with BeatTime, seconds with ClockTime, and scrolling coordinates with ScrollPosition. rhythm::TempoTrack convert between beats and seconds rhythm::ScrollSpeedTrack convert 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, including math::Fraction and easing curves used by rhythm. Easing provides monotonic curves; math::AdvancedEasing adds curves that may be non-monotonic, with a more limited set of supported operations.

§Helper modules

§Optional features

  • glam: implement math::Easable for supported [glam] vector types.
  • serde: enable serialization and deserialization of the easing enums.
  • lut_integration: enable integration of math::AdvancedEasing curves 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::Fraction in your game instead.

  • During hot loop, prefer querying the individual storage of each note type directly, similar to render_notes in 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.