Skip to content

10 Replay ​

In this chapter, we will use the recorded judgment and accuracy to reproduce each note's replay result.

Judgment and Accuracy ​

Continue editing guide/watch/note.py. StandardImport, is_skip, and scaled_time are already imported. Merge the new names into the import groups so the affected lines read:

python
from sonolus.script.bucket import Judgment
from sonolus.script.runtime import is_replay, is_skip, scaled_time

from guide.lib.buckets import Buckets

Then add judgment and accuracy directly below the existing beat field. They use StandardImport to read the recorded replay values:

python
class WatchNote(WatchArchetype):
    beat: StandardImport.BEAT
    judgment: StandardImport.JUDGMENT
    accuracy: StandardImport.ACCURACY

Sonolus supplies these imports from the result recorded by the scored play archetype with the same name. This is why WatchNote and the play-mode Note archetype share a name, as set up in chapter 05.

Despawn Time ​

Accuracy is the recorded offset from target_time, in seconds on the level timeline. First add it to target_time to get the recorded hit time, then convert that sum to the scaled lifetime timeline. This order matters when a level contains time-scale changes.

The is_replay function identifies replay mode. Replace despawn_time with:

python
class WatchNote(WatchArchetype):
    # ...

    def despawn_time(self) -> float:
        if is_replay():
            return time_to_scaled_time(self.target_time + self.accuracy)
        return self.visual_time.end

Normal watch mode still despawns the note at its scaled target time. Replay mode instead despawns it at the recorded hit time. A negative accuracy represents an early hit and makes the note disappear before it reaches the line. A positive accuracy represents a late hit and keeps the note moving past the line. The later judgment checks prevent a replay miss from producing hit feedback.

Hit Time and Sound Effects ​

In preprocess, replace the unconditional Perfect schedule from chapter 08 with this block:

python
class WatchNote(WatchArchetype):
    # ...

    def preprocess(self):
        # ...

        if is_replay():
            hit_time = self.target_time + self.accuracy
        else:
            hit_time = self.target_time
            self.judgment = Judgment.PERFECT
            self.accuracy = 0

        match self.judgment:
            case Judgment.PERFECT:
                Effects.perfect.schedule(hit_time, 0.02)
            case Judgment.GREAT:
                Effects.great.schedule(hit_time, 0.02)
            case Judgment.GOOD:
                Effects.good.schedule(hit_time, 0.02)

Normal watch mode treats every note as a Perfect at its target time. Replay mode uses the recorded hit time and the sound for its recorded judgment. A Miss matches no case, so it schedules no hit sound.

Bucket Result ​

Append these assignments to preprocess after the match block. As in Play chapter 15, bucket values use milliseconds, so convert the accuracy from seconds:

python
class WatchNote(WatchArchetype):
    # ...

    def preprocess(self):
        # ...

        self.result.bucket @= Buckets.note
        self.result.bucket_value = self.accuracy * 1000

Particle Effect ​

In terminate, add this replay-miss check after the existing is_skip() check and before creating particle_layout:

python
class WatchNote(WatchArchetype):
    # ...

    def terminate(self):
        # ...

        if is_replay() and self.judgment == Judgment.MISS:
            return

Final File ​

After these edits, guide/watch/note.py should match this complete file:

python
from sonolus.script.archetype import StandardImport, WatchArchetype, entity_data
from sonolus.script.bucket import Judgment
from sonolus.script.interval import Interval
from sonolus.script.quad import Rect
from sonolus.script.runtime import is_replay, is_skip, scaled_time
from sonolus.script.timing import beat_to_bpm, beat_to_time, time_to_scaled_time
from sonolus.script.vec import Vec2

from guide.lib import archetype_names
from guide.lib.buckets import Buckets
from guide.lib.effect import Effects
from guide.lib.layout import Config
from guide.lib.particle import Particles
from guide.lib.skin import Skin


class WatchNote(WatchArchetype):
    name = archetype_names.NOTE
    is_scored = True

    beat: StandardImport.BEAT
    judgment: StandardImport.JUDGMENT
    accuracy: StandardImport.ACCURACY

    target_time: float = entity_data()
    visual_time: Interval = entity_data()

    def preprocess(self):
        self.target_time = beat_to_time(self.beat)
        self.visual_time.end = time_to_scaled_time(self.target_time)
        self.visual_time.start = self.visual_time.end - 120 / beat_to_bpm(self.beat)

        self.result.target_time = self.target_time

        if is_replay():
            hit_time = self.target_time + self.accuracy
        else:
            hit_time = self.target_time
            self.judgment = Judgment.PERFECT
            self.accuracy = 0

        match self.judgment:
            case Judgment.PERFECT:
                Effects.perfect.schedule(hit_time, 0.02)
            case Judgment.GREAT:
                Effects.great.schedule(hit_time, 0.02)
            case Judgment.GOOD:
                Effects.good.schedule(hit_time, 0.02)

        self.result.bucket @= Buckets.note
        self.result.bucket_value = self.accuracy * 1000

    def spawn_time(self) -> float:
        return self.visual_time.start

    def despawn_time(self) -> float:
        if is_replay():
            return time_to_scaled_time(self.target_time + self.accuracy)
        return self.visual_time.end

    def update_parallel(self):
        y = self.visual_time.unlerp(scaled_time())

        layout = Rect.from_center(
            Vec2(0, y),
            Vec2(2 * Config.note_radius, -2 * Config.note_radius),
        )

        Skin.note.draw(layout, z=(1, -self.target_time))

    def terminate(self):
        if is_skip():
            return

        if is_replay() and self.judgment == Judgment.MISS:
            return

        particle_layout = Rect.from_center(
            Vec2(0, 1),
            Vec2(4 * Config.note_radius, -4 * Config.note_radius),
        )

        Particles.note.spawn(particle_layout, duration=0.3)

Checkpoint ​

First, open the development level in normal watch mode. Verify that each note reaches the judgment line, plays the Perfect sound, and produces a particle.

Then play the development level to the end, hitting at least one Note and missing at least one Note. Open the saved replay in watch mode and verify:

  • In replay, hit timing and sounds follow the recorded accuracy and judgment.
  • Replay misses produce no hit sound or particle.
  • Seeking forward or backward across notes produces no particle.

Once these checks pass, continue to the Preview section.