Skip to content

Commit 41622c9

Browse files
feat(gameplay): add Practice Lab section loops (#30)
1 parent 16f5a8b commit 41622c9

17 files changed

Lines changed: 690 additions & 12 deletions

‎docs/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ without creating an unreviewed second source of truth.
4444
- [Branching and release channels](development/branching-and-releases.md)
4545
- [DSP song clock](development/dsp-song-clock.md)
4646
- [Chart timeline](development/chart-timeline.md)
47+
- [Practice Lab](development/practice-lab.md)
4748
- [Chart Creator foundation](development/chart-creator.md)
4849
- [Demo-song vertical slice](development/demo-song-vertical-slice.md)
4950
- [Keyboard hit matching](development/keyboard-hit-matching.md)

‎docs/development/practice-lab.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Practice Lab
2+
3+
Practice Lab adds repeatable song sections and manual A–B loops to the existing
4+
gameplay scene. It deliberately reuses the production DSP clock, chart timeline,
5+
hit matcher, score tracker and audio sources.
6+
7+
## Player flow
8+
9+
Pause a song with `Esc` or `P`, then use the Practice Lab panel:
10+
11+
- choose the previous or next four-bar section and select **Loop section**;
12+
- select **Set A**, resume and pause later, then select **Set B** for a custom
13+
range;
14+
- select **Whole song** to leave practice mode and restart normally.
15+
16+
Every repetition includes a two-beat preparation window before point A when the
17+
range does not start at the beginning. Only notes inside `[A, B)` are sent to the
18+
matcher. Score, combo and timing analysis restart for each pass, while recorded
19+
practice time remains cumulative.
20+
21+
## Timing contract
22+
23+
`DspSongClock.Seek` re-anchors absolute song position to the current DSP time.
24+
`DspSongClockPrototype.SeekPlayback` moves the audio playhead to the equivalent
25+
clip position, including playback-speed conversion. The highway continues to
26+
derive marker position from absolute chart time, so repeated loops do not
27+
accumulate frame-based drift.
28+
29+
Automatic sections are derived from verified session timing in groups of four
30+
bars. The final section may contain fewer bars. A selected section is clamped to
31+
the real audio duration before it can become active.
32+
33+
## Boundaries
34+
35+
This foundation does not persist loop selections and does not add a second
36+
timeline, scoring engine or audio transport. Named musical sections can be added
37+
later as optional chart metadata without changing the loop transport contract.

‎src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,27 @@ public void Resume()
7575
IsPaused = false;
7676
}
7777

78+
public void Seek(double positionSeconds)
79+
{
80+
EnsureScheduled();
81+
EnsureFinite(positionSeconds, nameof(positionSeconds));
82+
if (positionSeconds < 0 || positionSeconds >= DurationSeconds)
83+
{
84+
throw new ArgumentOutOfRangeException(
85+
nameof(positionSeconds),
86+
"Song position must be within the scheduled duration.");
87+
}
88+
89+
if (IsPaused)
90+
{
91+
StartDspTime = pausedAtDspTime - positionSeconds;
92+
}
93+
else
94+
{
95+
StartDspTime = timeSource.Now - positionSeconds;
96+
}
97+
}
98+
7899
private static void EnsureFinite(double value, string parameterName)
79100
{
80101
if (double.IsNaN(value) || double.IsInfinity(value))

‎src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public sealed class DspSongClockPrototype : MonoBehaviour
2525
private string externalAudioPath;
2626
private double audioPlaybackSpeed = 1.0;
2727
private UnityWebRequest audioRequest;
28+
private bool seekedWhilePaused;
2829

2930
public DspSongClock Clock { get; private set; }
3031
public double StartDspTime => Clock != null && Clock.IsScheduled ? Clock.StartDspTime : double.NaN;
@@ -142,7 +143,15 @@ public void ResumePlayback()
142143
{
143144
if (Clock == null || !Clock.IsPaused) return;
144145
Clock.Resume();
145-
audioSource.UnPause();
146+
if (seekedWhilePaused)
147+
{
148+
audioSource.Play();
149+
seekedWhilePaused = false;
150+
}
151+
else
152+
{
153+
audioSource.UnPause();
154+
}
146155
}
147156

148157
public void RestartPlayback()
@@ -154,6 +163,35 @@ public void RestartPlayback()
154163
completedLogged = false;
155164
}
156165

166+
public void SeekPlayback(double positionSeconds)
167+
{
168+
if (generatedClip == null || Clock == null || !Clock.IsScheduled)
169+
throw new InvalidOperationException("Song playback must be scheduled before seeking.");
170+
if (double.IsNaN(positionSeconds) || double.IsInfinity(positionSeconds) ||
171+
positionSeconds < 0 || positionSeconds >= Clock.DurationSeconds)
172+
throw new ArgumentOutOfRangeException(nameof(positionSeconds));
173+
174+
bool wasPaused = Clock.IsPaused;
175+
double clipPosition = Math.Min(
176+
generatedClip.length - (1.0 / Math.Max(1, generatedClip.frequency)),
177+
positionSeconds * audioPlaybackSpeed);
178+
179+
audioSource.Stop();
180+
audioSource.time = (float)Math.Max(0, clipPosition);
181+
Clock.Seek(positionSeconds);
182+
if (wasPaused)
183+
{
184+
seekedWhilePaused = true;
185+
}
186+
else
187+
{
188+
audioSource.Play();
189+
seekedWhilePaused = false;
190+
}
191+
startedLogged = positionSeconds >= 0;
192+
completedLogged = false;
193+
}
194+
157195
public void PreviewFromSourceTime(double sourceTimeSeconds, double speed = 1.0)
158196
{
159197
if (generatedClip == null || audioSource == null)
@@ -186,6 +224,7 @@ private void SchedulePlayback()
186224
Clock = new DspSongClock(timeSource);
187225
Clock.Schedule(startDspTime, generatedClip.length / audioPlaybackSpeed);
188226
audioSource.PlayScheduled(startDspTime);
227+
seekedWhilePaused = false;
189228
}
190229

191230
private void Update()

‎src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,22 @@ public IReadOnlyList<TimelineNote> GetElapsed(double songPositionSeconds)
8585
return result.AsReadOnly();
8686
}
8787

88+
public IReadOnlyList<TimelineNote> GetRange(double startSeconds, double endSeconds)
89+
{
90+
ValidateFinite(startSeconds, nameof(startSeconds));
91+
ValidateFinite(endSeconds, nameof(endSeconds));
92+
if (startSeconds < 0 || endSeconds <= startSeconds)
93+
throw new ArgumentOutOfRangeException(nameof(endSeconds), "Range end must be after a non-negative start.");
94+
95+
var result = new List<TimelineNote>();
96+
foreach (TimelineNote note in notes)
97+
{
98+
if (note.EffectiveTimeSeconds >= startSeconds && note.EffectiveTimeSeconds < endSeconds)
99+
result.Add(note);
100+
}
101+
return result.AsReadOnly();
102+
}
103+
88104
private static void ValidateFinite(double value, string parameterName)
89105
{
90106
if (!IsFinite(value))

‎src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,23 @@ private void Start()
6565
public IReadOnlyList<ChartNote> CreateMatchingNotes()
6666
{
6767
if (Timeline == null) throw new InvalidOperationException("The chart timeline has not started.");
68-
var result = new ChartNote[Timeline.Notes.Count];
68+
return CreateMatchingNotes(Timeline.Notes);
69+
}
70+
71+
public IReadOnlyList<ChartNote> CreateMatchingNotes(double startSeconds, double endSeconds)
72+
{
73+
if (Timeline == null) throw new InvalidOperationException("The chart timeline has not started.");
74+
return CreateMatchingNotes(Timeline.GetRange(startSeconds, endSeconds));
75+
}
76+
77+
private static IReadOnlyList<ChartNote> CreateMatchingNotes(IReadOnlyList<TimelineNote> source)
78+
{
79+
var result = new ChartNote[source.Count];
6980
for (int index = 0; index < result.Length; index++)
7081
{
71-
ChartNote note = Timeline.Notes[index].Note;
82+
ChartNote note = source[index].Note;
7283
result[index] = new ChartNote(
73-
Timeline.Notes[index].EffectiveTimeSeconds,
84+
source[index].EffectiveTimeSeconds,
7485
note.Pad,
7586
note.Velocity,
7687
note.Articulation);

0 commit comments

Comments
 (0)