Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ without creating an unreviewed second source of truth.
- [Auto Tempo Coach](development/auto-tempo-coach.md)
- [Audio and latency sound check](development/audio-latency-sound-check.md)
- [Ghost Replay](development/ghost-replay.md)
- [Accessible reactive stage](development/reactive-stage.md)
- [Demo-song vertical slice](development/demo-song-vertical-slice.md)
- [Keyboard hit matching](development/keyboard-hit-matching.md)
- [Pad visuals](development/pad-visuals.md)
Expand Down
21 changes: 21 additions & 0 deletions docs/development/reactive-stage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Accessible reactive stage

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.

## Signals

- Combo selects a bounded energy band: calm, building, live, or peak.
- The latest hit grade provides a short accent.
- The latest drum pad selects the accent color already used by that lane.
- Misses and unmatched inputs enter a visible recovery state.

The stage uses text and geometric patterns as well as color. A miss therefore remains distinguishable in high-contrast and color-impaired viewing conditions.

## Motion and intensity safety

- Full visual intensity is capped at `0.78`.
- `Reduce motion` replaces moving patterns with a steady composition and caps intensity at `0.22`.
- Pulses last at least `0.18` seconds and the stage never alternates the full screen on/off.
- `High contrast` increases outlines and preserves the textual stage state.

All visuals are procedural UI Toolkit drawing. No commercial assets, per-frame material creation, telemetry, or additional gameplay systems are introduced.
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
private Label keyGuideSnareHiHatLabel;
private Label keyGuideFloorKickLabel;
private Label impactCueLabel;
private Label reactiveStageStatusLabel;
private Button menuButton;
private Button pauseButton;
private Button resumeButton;
Expand Down Expand Up @@ -120,6 +121,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
private readonly List<DrumArticulation> chartArticulationChoices = new List<DrumArticulation>();
private GameplayHighwaySurface surface;
private GameplayKitSurface kitSurface;
private GameplayReactiveStageSurface reactiveStageSurface;
private bool showInstructionalKit;
private HitMatchingPrototype subscribedMatching;
private readonly GameplayScoreTracker scoreTracker = new GameplayScoreTracker();
Expand All @@ -140,6 +142,9 @@ public sealed class GameplayHighwayController : MonoBehaviour
private bool metronomeScheduled;
private bool metronomeSeekedWhilePaused;
private bool resultRecorded;
private HitGrade? latestStageGrade;
private bool latestStageWrongInput;
private float stagePulseDeadline;
private GameplayAutoTempoRecommendation autoTempoRecommendation;
private bool isChangingTempo;
private readonly GameplayPracticeLoop practiceLoop = new GameplayPracticeLoop();
Expand All @@ -154,6 +159,9 @@ public sealed class GameplayHighwayController : MonoBehaviour
public GameplayPresentationTheme Theme { get; private set; }
public GameplayHighwaySurface Surface => surface;
public GameplayKitSurface KitSurface => kitSurface;
public GameplayReactiveStageSurface ReactiveStageSurface => reactiveStageSurface;
public GameplayReactiveStageState ReactiveStageState => reactiveStageSurface?.State ??
GameplayReactiveStageCalculator.Calculate(0, null, null, 0, false, false, false);
public bool IsInstructionalKitVisible => showInstructionalKit;
public Texture2D ActiveBackground => BackgroundFor(Theme);
public string EnvironmentTitle => GameplayEnvironmentProfile.For(Theme).Title;
Expand Down Expand Up @@ -273,6 +281,7 @@ private void SetTheme(GameplayPresentationTheme theme)
}

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

VisualElement highwayHost = root.Q<VisualElement>("highway-host");
VisualElement reactiveStageHost = root.Q<VisualElement>("reactive-stage-host");
VisualElement kitVisualHost = root.Q<VisualElement>("kit-visual-host");
VisualElement targetsHost = root.Q<VisualElement>("targets-host");
VisualElement kickHost = root.Q<VisualElement>("kick-target-host");
if (highwayHost == null || kitVisualHost == null || targetsHost == null || kickHost == null)
if (highwayHost == null || reactiveStageHost == null || kitVisualHost == null ||
targetsHost == null || kickHost == null)
{
Debug.LogError("Gameplay highway UXML is missing a required host element.", this);
enabled = false;
return;
}

highwayHost.Clear();
reactiveStageHost.Clear();
reactiveStageSurface = new GameplayReactiveStageSurface();
reactiveStageSurface.AddToClassList("reactive-stage-surface");
reactiveStageHost.Add(reactiveStageSurface);
surface = new GameplayHighwaySurface { name = "gameplay-highway-surface" };
surface.AddToClassList("highway-surface");
highwayHost.Add(surface);
Expand Down Expand Up @@ -474,6 +490,19 @@ private void RefreshPresentation()
? Mathf.Clamp01((deadline - Time.unscaledTime) / PulseDurationSeconds)
: 0;
surface.SetFrame(upcoming, position, HighwayLookAheadSeconds, latestPulsePad, pulse, ghostReplay.Ghost);
PlayerPreferencesSnapshot preferences = PlayerPreferencesRuntime.Current.Snapshot;
float stagePulse = Mathf.Clamp01((stagePulseDeadline - Time.unscaledTime) /
GameplayReactiveStageCalculator.MinimumPulseDurationSeconds);
GameplayReactiveStageState stageState = GameplayReactiveStageCalculator.Calculate(
scoreTracker.Snapshot.Combo,
latestStageGrade,
latestPulsePad,
stagePulse,
latestStageWrongInput,
preferences.ReducedMotion,
preferences.HighContrast);
reactiveStageSurface?.SetState(stageState);
if (reactiveStageStatusLabel != null) reactiveStageStatusLabel.text = stageState.Label;
if (ghostStatusLabel != null)
ghostStatusLabel.text = ghostReplay.HasGhost
? $"GHOST ATTIVO · {ghostReplay.Ghost.Count} COLPI"
Expand Down Expand Up @@ -508,6 +537,9 @@ private void HandleInputProcessed(DrumInputEvent input, HitResult result)
else CaptureGhostHit(input, result);
latestPulsePad = input.Pad;
pulseDeadlines[input.Pad] = Time.unscaledTime + PulseDurationSeconds;
stagePulseDeadline = Time.unscaledTime + GameplayReactiveStageCalculator.MinimumPulseDurationSeconds;
latestStageGrade = result?.Grade;
latestStageWrongInput = result == null;
lastCalibrationSource = input.Source == DrumInputSource.Midi
? DrumInputSource.Midi
: DrumInputSource.Keyboard;
Expand Down Expand Up @@ -550,6 +582,9 @@ public bool CaptureGhostHit(DrumInputEvent input, HitResult result) =>
private void HandleHitResolved(HitResult result)
{
if (result == null) return;
latestStageGrade = result.Grade;
latestStageWrongInput = false;
stagePulseDeadline = Time.unscaledTime + GameplayReactiveStageCalculator.MinimumPulseDurationSeconds;
performanceAnalyzer.Record(result.Note.Pad, result.Grade);
errorMapAnalyzer?.Record(result);
scoreTracker.Apply(result);
Expand Down Expand Up @@ -644,6 +679,9 @@ public void RestartRun()
metronomeScheduled = false;
pulseDeadlines.Clear();
latestPulsePad = null;
latestStageGrade = null;
latestStageWrongInput = false;
stagePulseDeadline = 0;
matching.RestartSession();
songClock.RestartPlayback();
RunState = GameplayRunState.Countdown;
Expand Down
Loading
Loading