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. Add it before converting the hit time 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 uses the recorded accuracy when choosing the despawn time. An early hit makes the note disappear before it reaches the line, while a late hit keeps it 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

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

  • In normal watch mode, each note reaches the judgment line, plays the Perfect sound, and produces a particle.
  • 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.