Skip to content

Commit b4948cf

Browse files
committed
feat(gameplay): add accessible reactive stage
1 parent 5ba1e76 commit b4948cf

9 files changed

Lines changed: 410 additions & 1 deletion

File tree

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ without creating an unreviewed second source of truth.
5050
- [Auto Tempo Coach](development/auto-tempo-coach.md)
5151
- [Audio and latency sound check](development/audio-latency-sound-check.md)
5252
- [Ghost Replay](development/ghost-replay.md)
53+
- [Accessible reactive stage](development/reactive-stage.md)
5354
- [Demo-song vertical slice](development/demo-song-vertical-slice.md)
5455
- [Keyboard hit matching](development/keyboard-hit-matching.md)
5556
- [Pad visuals](development/pad-visuals.md)

docs/development/reactive-stage.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Accessible reactive stage
2+
3+
The gameplay stage reacts to real runtime signals already produced by the hit matcher and score tracker. It does not own timing, scoring, input, or song state.
4+
5+
## Signals
6+
7+
- Combo selects a bounded energy band: calm, building, live, or peak.
8+
- The latest hit grade provides a short accent.
9+
- The latest drum pad selects the accent color already used by that lane.
10+
- Misses and unmatched inputs enter a visible recovery state.
11+
12+
The stage uses text and geometric patterns as well as color. A miss therefore remains distinguishable in high-contrast and color-impaired viewing conditions.
13+
14+
## Motion and intensity safety
15+
16+
- Full visual intensity is capped at `0.78`.
17+
- `Reduce motion` replaces moving patterns with a steady composition and caps intensity at `0.22`.
18+
- Pulses last at least `0.18` seconds and the stage never alternates the full screen on/off.
19+
- `High contrast` increases outlines and preserves the textual stage state.
20+
21+
All visuals are procedural UI Toolkit drawing. No commercial assets, per-frame material creation, telemetry, or additional gameplay systems are introduced.

src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
6161
private Label keyGuideSnareHiHatLabel;
6262
private Label keyGuideFloorKickLabel;
6363
private Label impactCueLabel;
64+
private Label reactiveStageStatusLabel;
6465
private Button menuButton;
6566
private Button pauseButton;
6667
private Button resumeButton;
@@ -120,6 +121,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
120121
private readonly List<DrumArticulation> chartArticulationChoices = new List<DrumArticulation>();
121122
private GameplayHighwaySurface surface;
122123
private GameplayKitSurface kitSurface;
124+
private GameplayReactiveStageSurface reactiveStageSurface;
123125
private bool showInstructionalKit;
124126
private HitMatchingPrototype subscribedMatching;
125127
private readonly GameplayScoreTracker scoreTracker = new GameplayScoreTracker();
@@ -140,6 +142,9 @@ public sealed class GameplayHighwayController : MonoBehaviour
140142
private bool metronomeScheduled;
141143
private bool metronomeSeekedWhilePaused;
142144
private bool resultRecorded;
145+
private HitGrade? latestStageGrade;
146+
private bool latestStageWrongInput;
147+
private float stagePulseDeadline;
143148
private GameplayAutoTempoRecommendation autoTempoRecommendation;
144149
private bool isChangingTempo;
145150
private readonly GameplayPracticeLoop practiceLoop = new GameplayPracticeLoop();
@@ -154,6 +159,9 @@ public sealed class GameplayHighwayController : MonoBehaviour
154159
public GameplayPresentationTheme Theme { get; private set; }
155160
public GameplayHighwaySurface Surface => surface;
156161
public GameplayKitSurface KitSurface => kitSurface;
162+
public GameplayReactiveStageSurface ReactiveStageSurface => reactiveStageSurface;
163+
public GameplayReactiveStageState ReactiveStageState => reactiveStageSurface?.State ??
164+
GameplayReactiveStageCalculator.Calculate(0, null, null, 0, false, false, false);
157165
public bool IsInstructionalKitVisible => showInstructionalKit;
158166
public Texture2D ActiveBackground => BackgroundFor(Theme);
159167
public string EnvironmentTitle => GameplayEnvironmentProfile.For(Theme).Title;
@@ -273,6 +281,7 @@ private void SetTheme(GameplayPresentationTheme theme)
273281
}
274282

275283
surface?.SetTheme(theme);
284+
reactiveStageSurface?.SetTheme(theme);
276285
if (impactCueLabel != null)
277286
impactCueLabel.style.top = Length.Percent(environment.StrikeRatio * 100f);
278287
if (kitSurface != null)
@@ -306,6 +315,7 @@ private void BindView()
306315
positionLabel = root.Q<Label>("song-position");
307316
judgmentLabel = root.Q<Label>("judgment-label");
308317
kitGuidanceLabel = root.Q<Label>("kit-guidance-label");
318+
reactiveStageStatusLabel = root.Q<Label>("reactive-stage-status");
309319
environmentTitleLabel = root.Q<Label>("environment-title");
310320
environmentSubtitleLabel = root.Q<Label>("environment-subtitle");
311321
currentInputLabel = root.Q<Label>("current-input");
@@ -381,17 +391,23 @@ private void BindView()
381391
if (keyGuideFloorKickLabel != null) keyGuideFloorKickLabel.text = $"{preferences.FloorTomKey}/{preferences.KickKey} TIMPANO / GRANCASSA";
382392

383393
VisualElement highwayHost = root.Q<VisualElement>("highway-host");
394+
VisualElement reactiveStageHost = root.Q<VisualElement>("reactive-stage-host");
384395
VisualElement kitVisualHost = root.Q<VisualElement>("kit-visual-host");
385396
VisualElement targetsHost = root.Q<VisualElement>("targets-host");
386397
VisualElement kickHost = root.Q<VisualElement>("kick-target-host");
387-
if (highwayHost == null || kitVisualHost == null || targetsHost == null || kickHost == null)
398+
if (highwayHost == null || reactiveStageHost == null || kitVisualHost == null ||
399+
targetsHost == null || kickHost == null)
388400
{
389401
Debug.LogError("Gameplay highway UXML is missing a required host element.", this);
390402
enabled = false;
391403
return;
392404
}
393405

394406
highwayHost.Clear();
407+
reactiveStageHost.Clear();
408+
reactiveStageSurface = new GameplayReactiveStageSurface();
409+
reactiveStageSurface.AddToClassList("reactive-stage-surface");
410+
reactiveStageHost.Add(reactiveStageSurface);
395411
surface = new GameplayHighwaySurface { name = "gameplay-highway-surface" };
396412
surface.AddToClassList("highway-surface");
397413
highwayHost.Add(surface);
@@ -474,6 +490,19 @@ private void RefreshPresentation()
474490
? Mathf.Clamp01((deadline - Time.unscaledTime) / PulseDurationSeconds)
475491
: 0;
476492
surface.SetFrame(upcoming, position, HighwayLookAheadSeconds, latestPulsePad, pulse, ghostReplay.Ghost);
493+
PlayerPreferencesSnapshot preferences = PlayerPreferencesRuntime.Current.Snapshot;
494+
float stagePulse = Mathf.Clamp01((stagePulseDeadline - Time.unscaledTime) /
495+
GameplayReactiveStageCalculator.MinimumPulseDurationSeconds);
496+
GameplayReactiveStageState stageState = GameplayReactiveStageCalculator.Calculate(
497+
scoreTracker.Snapshot.Combo,
498+
latestStageGrade,
499+
latestPulsePad,
500+
stagePulse,
501+
latestStageWrongInput,
502+
preferences.ReducedMotion,
503+
preferences.HighContrast);
504+
reactiveStageSurface?.SetState(stageState);
505+
if (reactiveStageStatusLabel != null) reactiveStageStatusLabel.text = stageState.Label;
477506
if (ghostStatusLabel != null)
478507
ghostStatusLabel.text = ghostReplay.HasGhost
479508
? $"GHOST ATTIVO · {ghostReplay.Ghost.Count} COLPI"
@@ -508,6 +537,9 @@ private void HandleInputProcessed(DrumInputEvent input, HitResult result)
508537
else CaptureGhostHit(input, result);
509538
latestPulsePad = input.Pad;
510539
pulseDeadlines[input.Pad] = Time.unscaledTime + PulseDurationSeconds;
540+
stagePulseDeadline = Time.unscaledTime + GameplayReactiveStageCalculator.MinimumPulseDurationSeconds;
541+
latestStageGrade = result?.Grade;
542+
latestStageWrongInput = result == null;
511543
lastCalibrationSource = input.Source == DrumInputSource.Midi
512544
? DrumInputSource.Midi
513545
: DrumInputSource.Keyboard;
@@ -550,6 +582,9 @@ public bool CaptureGhostHit(DrumInputEvent input, HitResult result) =>
550582
private void HandleHitResolved(HitResult result)
551583
{
552584
if (result == null) return;
585+
latestStageGrade = result.Grade;
586+
latestStageWrongInput = false;
587+
stagePulseDeadline = Time.unscaledTime + GameplayReactiveStageCalculator.MinimumPulseDurationSeconds;
553588
performanceAnalyzer.Record(result.Note.Pad, result.Grade);
554589
errorMapAnalyzer?.Record(result);
555590
scoreTracker.Apply(result);
@@ -644,6 +679,9 @@ public void RestartRun()
644679
metronomeScheduled = false;
645680
pulseDeadlines.Clear();
646681
latestPulsePad = null;
682+
latestStageGrade = null;
683+
latestStageWrongInput = false;
684+
stagePulseDeadline = 0;
647685
matching.RestartSession();
648686
songClock.RestartPlayback();
649687
RunState = GameplayRunState.Countdown;

0 commit comments

Comments
 (0)