vsrg 0.6.0

Data structures for vertical scrolling rhythm games
Documentation
# vsrg


![vsrg logo](assets/vsrg.png)

<img src="https://img.shields.io/badge/min%20rust-1.91-green.svg" alt="Minimum Rust Version">
<a href="https://docs.rs/vsrg"><img src="https://docs.rs/vsrg/badge.svg" alt="docs.rs"></a>
<a href="https://crates.io/crates/vsrg"><img src="https://img.shields.io/crates/v/vsrg.svg?label=klyff" alt="crates.io"></a>

Rust crate providing data structures for vertical scrolling rhythm games.

## Stability


This crate is currently in its very early stage. We plan to battle-test it by using
it to build our own commercial rhythm game, so expect occasional breaking changes
until the API stabilizes.

We expect to release `v1.0` around the same time as the public release of our rhythm
game.

## What is this crate for?


This crate is intended to help you build rhythm-game gameplay systems and editors without
starting from low-level timing, scrolling, and note-storage primitives yourself. See the
feature list below.

`vsrg` does not try to be a full game engine. You can use only the parts you need,
such as the math and rhythm modules, or you can use the note-storage system to build
larger chart and editor.

We are unsure about Bevy support because we do not use Bevy ourselves. Issues and pull
requests related to Bevy integration are welcome.

## Key features


### Timing and events


- Beat-time, clock-time, tempo, and scroll-position newtypes.
- Conversion between beat time and clock time with variable BPM over time.
- Scroll-position calculation with variable scroll speed over time.
- Tempo and scroll-speed changes with eased transitions.
- Linear, quadratic, cubic, and other easing utilities.

### Note storage


- Short-note and long-note storage using a struct-of-arrays layout.
- Fast queries for notes inside timing and scroll-position windows.
- Generated game-specific storage types through `generate_notes_storage!`.
- Editing methods for adding, removing, replacing, and batching note changes.
- Helpers for note relationships such as chords, endpoints, parent-child links,
  and chains.

## Installation


Add the crate with Cargo:

```sh
cargo add vsrg
```

Or add it manually to `Cargo.toml`:

```toml
[dependencies]
vsrg = "0.1"
```

## Optional features


```toml
[dependencies]
vsrg = { version = "0.1", features = ["serde", "glam", "lut_integration"] }
```

Available features:

- `glam`: implements easing support for supported `glam` vector types.
- `serde`: enables serialization and deserialization for supported types.
- `lut_integration`: enables integration of advanced easing curves using bundled
  lookup tables. This increases binary/static data size.

## Quick start


See the [crate documentation](https://docs.rs/crate/vsrg) for a fuller example.
We plan to add more example code to this repository later.

A typical setup looks like this:

1. Define your game's note types, such as tap notes and hold notes.
2. Implement `ShortNoteData` or `LongNoteData` for those note types.
3. Use `generate_notes_storage!` to generate strongly typed storage.
4. Create tempo and scroll-speed tracks.
5. Load notes into storage.
6. Query that storage during gameplay and mutate it from your editor.

```rust
use vsrg::{generate_notes_storage, ClockTime, Time};
use vsrg::notes::{
    GroupId, LongNoteData, LongNoteTypeStorage, ShortNoteData, ShortNoteTypeStorage,
};
use soa_rs::{SoaClone, Soars};

#[derive(Debug, Clone)]

struct TapNote {
    time: ClockTime,
    lane: u16,
}

#[derive(Debug, Clone)]

struct HoldNote {
    time: ClockTime,
    duration: ClockTime,
    lane: u16,
}

#[derive(Clone, Soars, SoaClone, Default)]

struct NoteState {
    completed: bool,
    highlighted: bool,
}

#[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)
    }

    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 {
        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 }
    }
}

generate_notes_storage! {
    enum MyNoteType,
    enum MyNote,
    enum MyNoteSnapshot,
    enum MyNoteRef,
    enum MyNoteMut,
    struct MyNotesStorage {
        note_type Tap(TapNote) => taps: ShortNoteTypeStorage<TapNote>,
        note_type Hold(HoldNote) => holds: LongNoteTypeStorage<HoldNote>,
    }
}
```

See the crate documentation for a fuller example that includes note groups, tempo
tracks, scroll-speed tracks, rendering queries, judgement queries, and editor updates.

## Concepts


### VSRG primer: how scrolling works


In a typical VSRG, each note has a `Time`, which has two jobs:
- Tells when the player should hit the note (needed for judgement)
- And determines where the note appears on the scrolling field (needed for rendering notes).

The latter requires converting from the note's time to scroll position. With a constant scroll
speed, the formula is simple:
```
note_position(note, current_music_time) = (note.time - current_music_time) * scroll_speed
```

Which can be split into two steps:
```
scroll_position(time) = time * scroll_speed
note_position(note, current_music_time) = scroll_position(note.time) - scroll_position(current_music_time)
```

If scroll speed can change over time, then the `scroll_speed` itself becomes a function of time. Calculating position
from speed is a classic physics problem, the position is calculated by integrating scrolling speed over time:

```
scroll_position(time) = ∫ from t=0 to t=time of scroll_speed(t)*dt
```

The important thing is that once you can calculate `scroll_position` from `time`, then the same formula for
`note_position` over time stays the same. Combining them together:
```
scroll_position(time) = ∫t=0 -> t=time scroll_speed(t)*dt
note_position(note, current_music_time) = scroll_position(note.time) - scroll_position(current_music_time)
```

Because a note's time does not change during gameplay, you can easily cache `scroll_position(note.time)` at level load
(and/or after editing the note). Only `scroll_position(current_music_time)` must be recomputed every frame,
which can then be reused to calculate the position of every notes at once.

This crates already handles the `scroll_position` calculation, and caches `scroll_position(note.time)` per note for you.
Rendering code can simply query visible notes in a range of `scroll_position`, then subtract `scroll_position(current_time)`
to get each note's offset, like so:

```rust
fn render_notes<G: NoteGroup>(
    notes: &MyNotesStorage,
    groups: &NoteGroups<G>,
    scroll_tracks: &ScrollSpeedTracks,
    current_time: ClockTime,
) {
    // Notes in the range `scroll_position(current_time)` to `scroll_position(current_time) + render_distance`
    // is rendered.
    let render_distance = ScrollPosition::new(10.0).expect("render distance is not NaN");

    // See note groups explanation below
    for group in notes.taps().scroll_sorted_groups() {
        let group_properties = groups.get_group_or_main(group.group_id());
        let scroll_track = scroll_tracks.get_track_or_main(group_properties.scroll_track_id());
        let current_position = scroll_track.calculate_scroll_position(current_time);
        let visible_range = Interval::new(current_position, current_position + render_distance);

        for i in query_intersecting(group.scroll_position(), visible_range) {
            let value = group.value_data().get(i).expect("component arrays are aligned");
            let state = group.runtime_data().get(i).expect("component arrays are aligned");
            let y = group.scroll_position()[i] - current_position;

            draw_tap_note(*value.lane, y, state);
        }
    }

    for group in notes.holds().scroll_sorted_groups() {
        let group_properties = groups.get_group_or_main(group.group_id());
        let scroll_track = scroll_tracks.get_track_or_main(group_properties.scroll_track_id());
        let current_position = scroll_track.calculate_scroll_position(current_time);
        let visible_range = Interval::new(current_position, current_position + render_distance);

        for i in group.query_scroll_intersecting(visible_range) {
            let value = group.value_data().get(i).expect("component arrays are aligned");
            let state = group.runtime_data().get(i).expect("component arrays are aligned");

            // Hold notes a long notes which is has a time interval instead of a single time point,
            // thus its scroll position is also an interval.
            let y_start = group.scroll_position()[i].start() - current_position;
            let y_end = group.scroll_position()[i].end() - current_position;

            draw_hold_note(*value.lane, y_start, y_end, state);
        }
    }
}
```

With some creative thinking, the same approach can be used to create other rhythm games format such as 
cytus, osu or others, simply by applying `scroll_position` not to the note's position but perhaps note
size for example.

### Note groups


The scrolling examples above mostly assume that every note in the chart shares the same tempo and
scroll-speed changes, but modern rhythm games sometimes allow multiple notes with different scroll
peeds to appear at the same time, or attach other shared properties to subsets of notes.

This crate represents them as note groups. A note group is a collection of notes that share the same
group properties. At minimum, those properties select the tempo track used to convert beat
time into clock time, and the scroll-speed track used to convert clock time into scroll position.

Your game can add more group properties on top of that, such as note color, note size, lane layout,
visibility rules, or any other data that should be shared by many notes instead of duplicated into
every note.

### Tempo, beat-time and clock-time


Clock time is the ground truth during gameplay. Judgement should compare input timestamps against
note timestamps in clock time, and scroll speed is also parametrized over clock time.

However, many rhythm game formats define note timing in beats instead of seconds. Beat time is useful
because it can be represented precisely in musical terms and usually gives a better chart-editing
workflow. This crate supports both units through the `Time` enum. A note can be authored in beat time or
clock time, and a game format may choose to allow both or restrict itself to only one.

When beat time is used, the note group's tempo track converts it into clock time before judgement,
indexing, and scroll position calculation.

### Note value and note state


This crates make the distinction between a note's value and a note's state:

- A note's value data is the minimum data needed to define the note itself, i.e the part you would
normally write into a serialized chart format. Since time, duration, group id, and cached positions are
stored separately by the note storage, value data usually only needs game-specific fields such as lane,
color, damage type, or sound effect id.

- A note's runtime state is data that changes while the chart is being played. Judgement and rendering
systems can use it for fields such as whether a note has already been hit, whether it is currently being
held, animation state, or temporary editor selection state.

Changing runtime state is cheap because it does not affect where the note is stored or how it is
indexed. Changing note value data is a structural edit because depending on what changed, storage may need
to reindex notes, recalculate cached timing or scroll-position data, or update note relationships.

### Struct-of-arrays storage


Notes are stored in a struct-of-arrays layout instead of one large array of note
objects. This makes common hot-loop operations faster because gameplay and rendering
systems can access only the fields they need, and it has better memory access patterns.

### Floating-point invariants


Types such as `BeatTime`, `ClockTime`, `Tempo`, and `ScrollPosition` are newtypes
around `f64`. They enforce invariants so they can implement ordering (which allows sorting).
In particular, NaN values are rejected when constructing these types, and if a math operation
would produce NaN, `vsrg` uses infinity instead, with the expectation that downstream code can safely
ignore invalid or unreachable results.

## Performance guidance


- Avoid storing expensive-to-clone values in note value data or runtime data.
- Prefer compact data in note components for acceptablecheap cloning. If large shared data is needed, consider
  storing it behind `Rc<[T]>` (or `Arc<[T]>` if needed).
- Prefer direct per-note-type queries in gameplay and rendering hot loops to avoid cloning, over querying
  for all note types at once:
```rust
// Prefer this: query and borrow only the data needed by the hot loop.
for group in notes.taps().scroll_sorted_groups() {
    for i in group.query_scroll_intersecting(visible_range) {
        let value = &group.value_data()[i];
        draw_tap_note(*value.lane, group.scroll_position()[i]);
    }
}

// Over this: query all note types at once, then branch on each returned note enum.
// This is fine in editor code that runs infrequently.
for note in notes.query_scroll_intersecting(visible_range) {
    match note {
        MyNoteRef::Tap(note) => {
            draw_tap_note(*note.value_data.lane, note.scroll_position);
        }
        MyNoteRef::Hold(note) => {
            draw_hold_note(
                *note.value_data.lane,
                note.scroll_position.start(),
                note.scroll_position.end(),
            );
        }
    }
}
```

## Contributing


Contributions are welcome. Before opening a pull request, please run:

```sh
cargo fmt
cargo test --all-features
```

When changing behavior, please add or update tests where appropriate.

## License


All source code is licensed under the MIT License.