diff --git a/AI/CHANGELOG.md b/AI/CHANGELOG.md
index 6571de5..9dcfd56 100644
--- a/AI/CHANGELOG.md
+++ b/AI/CHANGELOG.md
@@ -64,6 +64,13 @@
- **Impact:** Tutorial v0.6.0. Lesson step copy stays on `DefaultTutorialLesson.asset`; visual chrome moves to the guide style asset.
- **Refs:** `TutorialGuideStyle.cs`, `TutorialLayoutBuilder.cs`, `TutorialController.cs`, `TutorialBootstrapRefs.cs`, `TutorialEditorTools.cs`, `Tutorial.md`
+### Added — Score record flow on the results screen
+- **Scope:** component:ResultScreen · session
+- **What:** A third button, RECORD, sits between CONTINUE and QUIT. It opens an arcade name entry (Left/Right walk the glyph grid, Place inserts, DONE or Enter saves, Back deletes then cancels), writes the run to `score_records.json` in `Application.persistentDataPath`, and then shows the generated id (`tf-001`) so the player can photograph it. Continue and Quit on that confirmation are the player's final answer and take the same route the results buttons would have taken.
+- **Why:** Players had no way to leave their name on a run, and the results web app needs a stable per-run id to look a run up.
+- **Impact:** Available on any panel the player can navigate, which covers 1P, Player 1 in VS Bot, both players in human 2P, and battle mode. The bot's spectator panel passes `allowNavigation: false` and keeps the two-button layout. Record is never reported to `TwoPlayerGameCoordinator` as a Continue, and after a save the results buttons are re-armed so a 2P selection mismatch can still be resolved. Campus text comes from `Resources/CampusBuildConfig`, so a cabinet swaps campus by editing one asset. JSON fields are `id`, `name`, `score`, `date`, `campus`, `lastPlaced`.
+- **Refs:** `ScoreRecord.cs`, `ScoreRecordStore.cs`, `Campus.cs`, `CampusBuildConfig.cs`, `RecordNameEntryController.cs`, `RecordConfirmController.cs`, `RecordFlowLayoutBuilder.cs`, `ResultScreenController.cs`, `ResultScreenLayoutBuilder.cs`
+
### Changed — Tutorial drops the synthwave sky and restores the note hit bar
- **Scope:** component:Tutorial
- **What:** New `TutorialPresentationGuards` runs on every tutorial play: it forces `SynthwaveSky` off and re-creates the missing `NoteHitSpot` hit bar on the note path's hit waypoint. The bar reuses GameScene's own `Assets/Materials/NoteHitSpot.mat` (now carried on `TutorialBootstrapRefs` so play mode never touches AssetDatabase) with GameScene's exact scale `2.5 × 0.04 × 0.08` and layer, so it is the same thin bar the real level uses rather than a tutorial-only style.
diff --git a/Assets/Resources/CampusBuildConfig.asset b/Assets/Resources/CampusBuildConfig.asset
new file mode 100644
index 0000000..f05f0ca
--- /dev/null
+++ b/Assets/Resources/CampusBuildConfig.asset
@@ -0,0 +1,16 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 43c0ab061fa14d129717dae1adfc6af5, type: 3}
+ m_Name: CampusBuildConfig
+ m_EditorClassIdentifier:
+ _campus: 0
+ _displayName: Indianapolis
diff --git a/Assets/Resources/CampusBuildConfig.asset.meta b/Assets/Resources/CampusBuildConfig.asset.meta
new file mode 100644
index 0000000..5c6f5af
--- /dev/null
+++ b/Assets/Resources/CampusBuildConfig.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 39109f0886f04bc7b75bc58a37965707
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Session/Campus.cs b/Assets/Session/Campus.cs
new file mode 100644
index 0000000..fdc5339
--- /dev/null
+++ b/Assets/Session/Campus.cs
@@ -0,0 +1,10 @@
+/**
+ * Physical campus a cabinet build belongs to. The value is set once per build
+ * on ; scenes and gameplay code never hardcode it.
+ *
+ */
+public enum Campus
+{
+ Indianapolis = 0,
+ WestLafayette = 1
+}
diff --git a/Assets/Session/Campus.cs.meta b/Assets/Session/Campus.cs.meta
new file mode 100644
index 0000000..a65bc50
--- /dev/null
+++ b/Assets/Session/Campus.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4fad8d5cc01e421a847e47aab49a54a0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Session/CampusBuildConfig.cs b/Assets/Session/CampusBuildConfig.cs
new file mode 100644
index 0000000..9d24e53
--- /dev/null
+++ b/Assets/Session/CampusBuildConfig.cs
@@ -0,0 +1,73 @@
+using UnityEngine;
+
+/**
+ * Build-specific campus identity for score records.
+ * Swap this asset (or its value) between the Indianapolis
+ * and West Lafayette cabinets. Gameplay code only reads .
+ *
+ */
+[CreateAssetMenu(menuName = "Treeformance/Campus Build Config", fileName = "CampusBuildConfig")]
+public class CampusBuildConfig : ScriptableObject
+{
+ /** Resources path this config is loaded from at runtime. */
+ public const string ResourcesPath = "CampusBuildConfig";
+
+ private const string IndianapolisDisplayName = "Indianapolis";
+ private const string WestLafayetteDisplayName = "West Lafayette";
+
+ [Tooltip("Which campus this build represents.")]
+ [SerializeField] private Campus _campus = Campus.Indianapolis;
+
+ [Tooltip("Exact string written into the campus field of score_records.json.")]
+ [SerializeField] private string _displayName = IndianapolisDisplayName;
+
+ /** Enum value for this build. */
+ public Campus Campus => _campus;
+
+ /** Value written to the JSON campus field. */
+ public string DisplayName =>
+ string.IsNullOrWhiteSpace(_displayName)
+ ? DefaultDisplayName(_campus)
+ : _displayName.Trim();
+
+ /**
+ * Loads the config from Resources. If the asset is missing, returns a runtime
+ * instance for the default campus so a record still saves with a valid campus
+ * string instead of an empty one.
+ *
+ */
+ public static CampusBuildConfig LoadOrDefault()
+ {
+ var config = Resources.Load(ResourcesPath);
+ if (config != null)
+ return config;
+
+ Debug.LogWarning(
+ $"[CampusBuildConfig] No Resources/{ResourcesPath} asset found, defaulting to " +
+ $"{DefaultDisplayName(Campus.Indianapolis)}. Create one via " +
+ "Create > Treeformance > Campus Build Config and put it in a Resources folder.");
+
+ var fallback = CreateInstance();
+ fallback._campus = Campus.Indianapolis;
+ fallback._displayName = DefaultDisplayName(Campus.Indianapolis);
+ return fallback;
+ }
+
+ /** Canonical display name for each campus. */
+ public static string DefaultDisplayName(Campus campus) =>
+ campus switch
+ {
+ Campus.WestLafayette => WestLafayetteDisplayName,
+ _ => IndianapolisDisplayName
+ };
+
+#if UNITY_EDITOR
+ // Clearing the field in the Inspector refills it with the campus default,
+ // so the JSON campus value can never end up blank.
+ private void OnValidate()
+ {
+ if (string.IsNullOrWhiteSpace(_displayName))
+ _displayName = DefaultDisplayName(_campus);
+ }
+#endif
+}
diff --git a/Assets/Session/CampusBuildConfig.cs.meta b/Assets/Session/CampusBuildConfig.cs.meta
new file mode 100644
index 0000000..75acbdc
--- /dev/null
+++ b/Assets/Session/CampusBuildConfig.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 43c0ab061fa14d129717dae1adfc6af5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Session/ScoreRecord.cs b/Assets/Session/ScoreRecord.cs
new file mode 100644
index 0000000..e919ae1
--- /dev/null
+++ b/Assets/Session/ScoreRecord.cs
@@ -0,0 +1,38 @@
+using System;
+
+/**
+ * One saved run written to score_records.json. Field names and types are the
+ * contract the results web app reads, so do not rename them:
+ *
+ * {
+ * "id": "tf-002",
+ * "name": "HARPE",
+ * "score": 1515,
+ * "date": "2026-07-24",
+ * "campus": "West Lafayette",
+ * "lastPlaced": [4, 6, 18]
+ * }
+ *
+ *
+ */
+[Serializable]
+public class ScoreRecord
+{
+ public string id;
+ public string name;
+ public long score;
+ public string date;
+ public string campus;
+ public int[] lastPlaced;
+}
+
+/**
+ * JsonUtility cannot deserialize a top-level JSON array, so reads and writes go
+ * through this wrapper. The file on disk is still a raw [...] array.
+ *
+ */
+[Serializable]
+public class ScoreRecordFile
+{
+ public ScoreRecord[] records = Array.Empty();
+}
diff --git a/Assets/Session/ScoreRecord.cs.meta b/Assets/Session/ScoreRecord.cs.meta
new file mode 100644
index 0000000..96ae13a
--- /dev/null
+++ b/Assets/Session/ScoreRecord.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e9fe36cd296e4db09f700fcb40eb053a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Session/ScoreRecordStore.cs b/Assets/Session/ScoreRecordStore.cs
new file mode 100644
index 0000000..63ce48c
--- /dev/null
+++ b/Assets/Session/ScoreRecordStore.cs
@@ -0,0 +1,217 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using UnityEngine;
+
+/**
+ * Loads and appends entries in a JSON file under
+ * . The file on disk is a raw JSON
+ * array so the results web app can consume it without unwrapping.
+ *
+ * Every method is failure-tolerant: a missing, empty, or corrupt file reads as
+ * zero records rather than throwing, because a bad save must never block a
+ * player from leaving the results screen.
+ *
+ */
+public static class ScoreRecordStore
+{
+ /** File name inside . */
+ public const string FileName = "score_records.json";
+
+ /** How many of the player's final placements are stored per record. */
+ public const int LastPlacedCount = 3;
+
+ /** Character limit for a saved name, matching the arcade name entry. */
+ public const int MaxNameLength = 5;
+
+ private const string IdPrefix = "tf-";
+ private const int IdDigits = 3;
+ private const string DateFormat = "yyyy-MM-dd";
+ private const string JsonIndent = " ";
+
+ /** Full path to the on-disk records file. */
+ public static string FilePath =>
+ Path.Combine(Application.persistentDataPath, FileName);
+
+ /**
+ * Appends one record and returns it, including the generated id, so the
+ * caller can show that id to the player.
+ *
+ * Raw entered name; normalized before saving.
+ * Final score for the run.
+ * Campus string from .
+ * Values the player placed, in placement order.
+ */
+ public static ScoreRecord Append(
+ string playerName,
+ long score,
+ string campusDisplayName,
+ IReadOnlyList placements)
+ {
+ var file = Load();
+ var list = new List(file.records ?? Array.Empty());
+
+ var record = new ScoreRecord
+ {
+ id = NextId(list),
+ name = NormalizeName(playerName),
+ score = score,
+ date = DateTime.Now.ToString(DateFormat, CultureInfo.InvariantCulture),
+ campus = string.IsNullOrWhiteSpace(campusDisplayName)
+ ? CampusBuildConfig.DefaultDisplayName(Campus.Indianapolis)
+ : campusDisplayName.Trim(),
+ lastPlaced = TakeLastPlaced(placements)
+ };
+
+ list.Add(record);
+ file.records = list.ToArray();
+ Save(file);
+ return record;
+ }
+
+ /** Reads existing records, or an empty set if the file is missing or unreadable. */
+ public static ScoreRecordFile Load()
+ {
+ string path = FilePath;
+ if (!File.Exists(path))
+ return new ScoreRecordFile();
+
+ try
+ {
+ string raw = File.ReadAllText(path, Encoding.UTF8);
+ if (string.IsNullOrWhiteSpace(raw))
+ return new ScoreRecordFile();
+
+ // JsonUtility needs a named field, so a top-level array is wrapped first.
+ string trimmed = raw.TrimStart();
+ string wrapped = trimmed.StartsWith("[", StringComparison.Ordinal)
+ ? "{\"records\":" + trimmed + "}"
+ : trimmed;
+
+ var file = JsonUtility.FromJson(wrapped);
+ return file?.records == null ? new ScoreRecordFile() : file;
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[ScoreRecordStore] Failed to load {path}: {ex.Message}");
+ return new ScoreRecordFile();
+ }
+ }
+
+ private static void Save(ScoreRecordFile file)
+ {
+ string path = FilePath;
+ try
+ {
+ string dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ var records = file.records ?? Array.Empty();
+ File.WriteAllText(path, ToArrayJson(records), Encoding.UTF8);
+ Debug.Log($"[ScoreRecordStore] Saved {records.Length} record(s) to {path}");
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[ScoreRecordStore] Failed to save {path}: {ex.Message}");
+ }
+ }
+
+ /**
+ * Builds the next tf-001 style id. Derived from the highest existing
+ * suffix rather than the record count, so hand-editing or deleting a row in
+ * the file cannot hand out an id that is already printed on someone's phone.
+ *
+ */
+ public static string NextId(IReadOnlyList existing)
+ {
+ int highest = 0;
+ if (existing != null)
+ {
+ for (int i = 0; i < existing.Count; i++)
+ {
+ if (TryParseIdNumber(existing[i]?.id, out int number) && number > highest)
+ highest = number;
+ }
+ }
+
+ return FormatId(highest + 1);
+ }
+
+ /** Formats a sequence number as tf-001. */
+ public static string FormatId(int number) =>
+ IdPrefix + number.ToString("D" + IdDigits, CultureInfo.InvariantCulture);
+
+ private static bool TryParseIdNumber(string id, out int number)
+ {
+ number = 0;
+ if (string.IsNullOrEmpty(id) || !id.StartsWith(IdPrefix, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return int.TryParse(
+ id.Substring(IdPrefix.Length),
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out number);
+ }
+
+ /** Uppercases and clamps a name to . */
+ public static string NormalizeName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return string.Empty;
+
+ string cleaned = name.Trim().ToUpperInvariant();
+ return cleaned.Length <= MaxNameLength ? cleaned : cleaned.Substring(0, MaxNameLength);
+ }
+
+ /** Returns the last placements, or fewer if the run was short. */
+ public static int[] TakeLastPlaced(IReadOnlyList placements)
+ {
+ if (placements == null || placements.Count == 0)
+ return Array.Empty();
+
+ int take = Math.Min(LastPlacedCount, placements.Count);
+ var result = new int[take];
+ int start = placements.Count - take;
+ for (int i = 0; i < take; i++)
+ result[i] = placements[start + i];
+ return result;
+ }
+
+ /** Serializes records as a pretty-printed raw JSON array. */
+ public static string ToArrayJson(ScoreRecord[] records)
+ {
+ if (records == null || records.Length == 0)
+ return "[]";
+
+ var sb = new StringBuilder();
+ sb.Append("[\n");
+ for (int i = 0; i < records.Length; i++)
+ {
+ // JsonUtility only pretty-prints a single object, so each object is
+ // re-indented one level to sit inside the array.
+ string item = JsonUtility.ToJson(records[i], prettyPrint: true);
+ using (var reader = new StringReader(item))
+ {
+ string line;
+ bool first = true;
+ while ((line = reader.ReadLine()) != null)
+ {
+ if (!first) sb.Append('\n');
+ first = false;
+ sb.Append(JsonIndent).Append(line);
+ }
+ }
+
+ if (i < records.Length - 1)
+ sb.Append(',');
+ sb.Append('\n');
+ }
+
+ sb.Append(']');
+ return sb.ToString();
+ }
+}
diff --git a/Assets/Session/ScoreRecordStore.cs.meta b/Assets/Session/ScoreRecordStore.cs.meta
new file mode 100644
index 0000000..ad13152
--- /dev/null
+++ b/Assets/Session/ScoreRecordStore.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: eb2ce6cdb1cc4d58bb3ae855ad1006b8
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/UI/Style1/RecordConfirmController.cs b/Assets/UI/Style1/RecordConfirmController.cs
new file mode 100644
index 0000000..3d712c2
--- /dev/null
+++ b/Assets/UI/Style1/RecordConfirmController.cs
@@ -0,0 +1,165 @@
+using System;
+using TMPro;
+using UnityEngine;
+
+/**
+ * Post-save confirmation. Shows the id the run was filed under so the player can
+ * photograph it and look their run up later, then takes a final Continue or Quit.
+ *
+ * Built by and driven by
+ * , one instance per results panel.
+ *
+ */
+public class RecordConfirmController : MonoBehaviour
+{
+ /** Pixels the selection indicator extends past the focused button. */
+ private const float IndicatorPadding = 4f;
+
+ private CanvasGroup _cg;
+ private TMP_Text _idLabel;
+ private Style1Button _continueBtn;
+ private Style1Button _quitBtn;
+ private RectTransform _continueBtnRect;
+ private RectTransform _quitBtnRect;
+ private RectTransform _indicator;
+ private IMenuInputProvider _input;
+
+ private bool _inputEnabled;
+ private bool _onContinue = true;
+ private Action _onChoice;
+
+ /** Called by the layout builder once the overlay exists. */
+ public void SetRefs(
+ CanvasGroup cg,
+ TMP_Text idLabel,
+ Style1Button continueBtn,
+ Style1Button quitBtn,
+ RectTransform continueBtnRect,
+ RectTransform quitBtnRect,
+ RectTransform indicator)
+ {
+ _cg = cg;
+ _idLabel = idLabel;
+ _continueBtn = continueBtn;
+ _quitBtn = quitBtn;
+ _continueBtnRect = continueBtnRect;
+ _quitBtnRect = quitBtnRect;
+ _indicator = indicator;
+ }
+
+ /** Shows the saved id and waits for Continue or Quit. */
+ public void Show(string recordId, IMenuInputProvider input, Action onChoice)
+ {
+ _input = input;
+ _onChoice = onChoice;
+ _onContinue = true;
+
+ if (_idLabel != null)
+ _idLabel.text = recordId ?? string.Empty;
+
+ gameObject.SetActive(true);
+ if (_cg != null)
+ {
+ _cg.alpha = 1f;
+ _cg.interactable = true;
+ _cg.blocksRaycasts = true;
+ }
+
+ _continueBtn?.SetClickHandler(() => Choose(true));
+ _quitBtn?.SetClickHandler(() => Choose(false));
+
+ SubscribeInput();
+ _inputEnabled = true;
+ _input?.Enable();
+ UpdateIndicator();
+ RefreshButtonHover();
+ }
+
+ /** Hides the overlay and stops listening for input. */
+ public void Hide()
+ {
+ _inputEnabled = false;
+ UnsubscribeInput();
+ if (_cg != null)
+ {
+ _cg.interactable = false;
+ _cg.blocksRaycasts = false;
+ _cg.alpha = 0f;
+ }
+
+ gameObject.SetActive(false);
+ }
+
+ private void SubscribeInput()
+ {
+ if (_input == null) return;
+ _input.OnLeft += OnLeft;
+ _input.OnRight += OnRight;
+ _input.OnConfirm += OnConfirm;
+ }
+
+ private void UnsubscribeInput()
+ {
+ if (_input == null) return;
+ _input.OnLeft -= OnLeft;
+ _input.OnRight -= OnRight;
+ _input.OnConfirm -= OnConfirm;
+ }
+
+ private void OnLeft()
+ {
+ if (!_inputEnabled) return;
+ _onContinue = true;
+ UpdateIndicator();
+ RefreshButtonHover();
+ }
+
+ private void OnRight()
+ {
+ if (!_inputEnabled) return;
+ _onContinue = false;
+ UpdateIndicator();
+ RefreshButtonHover();
+ }
+
+ private void OnConfirm()
+ {
+ if (!_inputEnabled) return;
+ (_onContinue ? _continueBtn : _quitBtn)?.SimulateVisualPress();
+ Choose(_onContinue);
+ }
+
+ private void Choose(bool isContinue)
+ {
+ if (!_inputEnabled) return;
+ _inputEnabled = false;
+ UnsubscribeInput();
+ Hide();
+ _onChoice?.Invoke(isContinue);
+ }
+
+ private void RefreshButtonHover()
+ {
+ _continueBtn?.SimulateHover(_onContinue);
+ _quitBtn?.SimulateHover(!_onContinue);
+ }
+
+ private void UpdateIndicator()
+ {
+ if (_indicator == null) return;
+ RectTransform target = _onContinue ? _continueBtnRect : _quitBtnRect;
+ if (target == null) return;
+
+ _indicator.gameObject.SetActive(true);
+ _indicator.SetAsLastSibling();
+ _indicator.anchorMin = target.anchorMin;
+ _indicator.anchorMax = target.anchorMax;
+ _indicator.offsetMin = target.offsetMin + new Vector2(-IndicatorPadding, -IndicatorPadding);
+ _indicator.offsetMax = target.offsetMax + new Vector2(IndicatorPadding, IndicatorPadding);
+ }
+
+ private void OnDestroy()
+ {
+ UnsubscribeInput();
+ }
+}
diff --git a/Assets/UI/Style1/RecordConfirmController.cs.meta b/Assets/UI/Style1/RecordConfirmController.cs.meta
new file mode 100644
index 0000000..0b0b78e
--- /dev/null
+++ b/Assets/UI/Style1/RecordConfirmController.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b1ece8fafe354c72930f9be96b19aeee
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/UI/Style1/RecordFlowLayoutBuilder.cs b/Assets/UI/Style1/RecordFlowLayoutBuilder.cs
new file mode 100644
index 0000000..39301b0
--- /dev/null
+++ b/Assets/UI/Style1/RecordFlowLayoutBuilder.cs
@@ -0,0 +1,308 @@
+using System.Collections.Generic;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+
+/**
+ * Builds the two score-record overlays (name entry and id confirmation) at
+ * runtime, in Style1 tokens, as siblings under a results panel. Called once per
+ * panel by , so each half of a split screen
+ * gets its own overlays.
+ *
+ */
+public static class RecordFlowLayoutBuilder
+{
+ /** Unity's built-in UI layer, matching the rest of the Style1 builders. */
+ private const int UiLayer = 5;
+
+ /** Opacity of the black sheet that hides the results panel behind an overlay. */
+ private const float BackdropAlpha = 0.88f;
+
+ /** Opacity of the accent fill behind the focused button. */
+ private const float IndicatorAlpha = 0.35f;
+
+ /** Inset of each glyph cell inside its slice of the grid. */
+ private const float KeyCellInsetX = 3f;
+ private const float KeyCellInsetY = 2f;
+
+ /** Vertical breathing room between glyph rows, as a fraction of one row. */
+ private const float KeyRowPadding = 0.04f;
+
+ /** Horizontal inset between name slots. */
+ private const float NameSlotInset = 4f;
+
+ /** Selection indicator overshoot, matching the results button row. */
+ private const float IndicatorPadding = 4f;
+
+ private const string NameEntryTitle = "ENTER NAME";
+ private const string NameEntryHint = "LEFT / RIGHT MOVE PLACE INSERTS DONE SAVES";
+ private const string ConfirmTitle = "RECORD SAVED";
+ private const string ConfirmHint = "TAKE A PICTURE OF YOUR CODE";
+ private const string ContinueLabel = "CONTINUE";
+ private const string QuitLabel = "QUIT";
+
+ /**
+ * Creates both overlays under , inactive and fully
+ * transparent, ready for their controllers to show them.
+ *
+ */
+ public static void Build(
+ Transform parent,
+ out RecordNameEntryController nameEntry,
+ out RecordConfirmController confirm)
+ {
+ nameEntry = BuildNameEntry(parent);
+ confirm = BuildConfirm(parent);
+ }
+
+ private static RecordNameEntryController BuildNameEntry(Transform parent)
+ {
+ var root = MakeOverlayRoot("RecordNameEntry", parent, out CanvasGroup cg);
+ var panel = MakePanel(root.transform, new Vector2(0.12f, 0.12f), new Vector2(0.88f, 0.88f));
+
+ MakeSpan(MakeText("Title", panel.transform, NameEntryTitle,
+ Style1Typography.Role.Heading, Style1Palette.Accent),
+ new Vector2(0.05f, 0.86f), new Vector2(0.95f, 0.98f));
+
+ var scoreLabel = MakeText("ScoreValue", panel.transform, "0",
+ Style1Typography.Role.Score, Style1Palette.Score);
+ MakeSpan(scoreLabel, new Vector2(0.05f, 0.78f), new Vector2(0.95f, 0.87f));
+
+ MakeSpan(MakeText("Hint", panel.transform, NameEntryHint,
+ Style1Typography.Role.Label, Style1Palette.Label),
+ new Vector2(0.05f, 0.72f), new Vector2(0.95f, 0.78f));
+
+ var slotsRow = MakeGroup("NameSlots", panel.transform,
+ new Vector2(0.2f, 0.60f), new Vector2(0.8f, 0.72f));
+
+ var slotLabels = new TMP_Text[RecordNameEntryController.MaxNameLength];
+ for (int i = 0; i < slotLabels.Length; i++)
+ {
+ var slot = MakeText($"Slot{i}", slotsRow.transform,
+ RecordNameEntryController.EmptySlotGlyph,
+ Style1Typography.Role.Score, Style1Palette.Label);
+ var slotRect = slot.GetComponent();
+ slotRect.anchorMin = new Vector2(i / (float)slotLabels.Length, 0f);
+ slotRect.anchorMax = new Vector2((i + 1) / (float)slotLabels.Length, 1f);
+ slotRect.offsetMin = new Vector2(NameSlotInset, 0f);
+ slotRect.offsetMax = new Vector2(-NameSlotInset, 0f);
+ slotLabels[i] = slot.GetComponent();
+ }
+
+ var keyboard = MakeGroup("Keyboard", panel.transform,
+ new Vector2(0.04f, 0.06f), new Vector2(0.96f, 0.58f));
+
+ var keyRefs = new List<(string id, Image bg, TMP_Text label)>();
+ var glyphRows = RecordNameEntryController.GlyphRows;
+
+ // One extra row holds DEL and DONE.
+ int rowCount = glyphRows.Count + 1;
+ for (int row = 0; row < glyphRows.Count; row++)
+ {
+ float yMax = 1f - (row / (float)rowCount);
+ float yMin = 1f - ((row + 1) / (float)rowCount);
+ BuildGlyphRow(keyboard.transform, glyphRows[row], yMin, yMax, keyRefs);
+ }
+
+ float specialRowMax = 1f - (glyphRows.Count / (float)rowCount);
+ keyRefs.Add(MakeKey(keyboard.transform, RecordNameEntryController.DeleteKeyId, "DEL",
+ 0.05f, 0.45f, 0f, specialRowMax));
+ keyRefs.Add(MakeKey(keyboard.transform, RecordNameEntryController.DoneKeyId, "DONE",
+ 0.55f, 0.95f, 0f, specialRowMax));
+
+ var controller = root.AddComponent();
+ controller.SetRefs(cg, scoreLabel.GetComponent(), slotLabels, keyRefs);
+ root.SetActive(false);
+ return controller;
+ }
+
+ private static void BuildGlyphRow(
+ Transform parent, string glyphs, float yMin, float yMax,
+ List<(string id, Image bg, TMP_Text label)> keyRefs)
+ {
+ for (int i = 0; i < glyphs.Length; i++)
+ {
+ string id = glyphs[i].ToString();
+ keyRefs.Add(MakeKey(parent, id, id,
+ i / (float)glyphs.Length, (i + 1) / (float)glyphs.Length, yMin, yMax));
+ }
+ }
+
+ private static (string id, Image bg, TMP_Text label) MakeKey(
+ Transform parent, string id, string labelText,
+ float xMin, float xMax, float yMin, float yMax)
+ {
+ var go = new GameObject($"Key_{id}", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
+ go.layer = UiLayer;
+ go.transform.SetParent(parent, false);
+ var rect = go.GetComponent();
+ rect.anchorMin = new Vector2(xMin, yMin + KeyRowPadding);
+ rect.anchorMax = new Vector2(xMax, yMax - KeyRowPadding);
+ rect.offsetMin = new Vector2(KeyCellInsetX, KeyCellInsetY);
+ rect.offsetMax = new Vector2(-KeyCellInsetX, -KeyCellInsetY);
+
+ var img = go.GetComponent();
+ img.sprite = Style1Sprites.BlackRounded;
+ img.type = Image.Type.Sliced;
+ img.color = Style1Palette.PanelFill;
+ img.raycastTarget = false;
+
+ var label = MakeText("Label", go.transform, labelText,
+ Style1Typography.Role.Label, Style1Palette.Score);
+ StretchFull(label.GetComponent());
+
+ return (id, img, label.GetComponent());
+ }
+
+ private static RecordConfirmController BuildConfirm(Transform parent)
+ {
+ var root = MakeOverlayRoot("RecordConfirm", parent, out CanvasGroup cg);
+ var panel = MakePanel(root.transform, new Vector2(0.18f, 0.22f), new Vector2(0.82f, 0.78f));
+
+ MakeSpan(MakeText("Title", panel.transform, ConfirmTitle,
+ Style1Typography.Role.Heading, Style1Palette.Accent),
+ new Vector2(0.05f, 0.78f), new Vector2(0.95f, 0.95f));
+
+ var idLabel = MakeText("IdLabel", panel.transform, string.Empty,
+ Style1Typography.Role.Score, Style1Palette.Score);
+ MakeSpan(idLabel, new Vector2(0.05f, 0.42f), new Vector2(0.95f, 0.76f));
+
+ MakeSpan(MakeText("Hint", panel.transform, ConfirmHint,
+ Style1Typography.Role.Label, Style1Palette.Label),
+ new Vector2(0.05f, 0.28f), new Vector2(0.95f, 0.42f));
+
+ var btnRow = MakeGroup("ButtonRow", panel.transform,
+ new Vector2(0.08f, 0.06f), new Vector2(0.92f, 0.24f));
+
+ var continueBtn = MakeButton(btnRow.transform, "ContinueButton", ContinueLabel, 0f, 0.47f);
+ var quitBtn = MakeButton(btnRow.transform, "QuitButton", QuitLabel, 0.53f, 1f);
+
+ var indicator = new GameObject("SelectionIndicator",
+ typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
+ indicator.layer = UiLayer;
+ indicator.transform.SetParent(btnRow.transform, false);
+ var indRect = indicator.GetComponent();
+ indRect.anchorMin = new Vector2(0f, 0f);
+ indRect.anchorMax = new Vector2(0.47f, 1f);
+ indRect.offsetMin = new Vector2(-IndicatorPadding, -IndicatorPadding);
+ indRect.offsetMax = new Vector2(IndicatorPadding, IndicatorPadding);
+ var indImg = indicator.GetComponent();
+ indImg.sprite = Style1Sprites.BlackRounded;
+ indImg.type = Image.Type.Sliced;
+ indImg.color = new Color(Style1Palette.Accent.r, Style1Palette.Accent.g,
+ Style1Palette.Accent.b, IndicatorAlpha);
+ indImg.raycastTarget = false;
+ indicator.transform.SetAsLastSibling();
+
+ var controller = root.AddComponent();
+ controller.SetRefs(
+ cg,
+ idLabel.GetComponent(),
+ continueBtn.GetComponent(),
+ quitBtn.GetComponent(),
+ continueBtn.GetComponent(),
+ quitBtn.GetComponent(),
+ indRect);
+ root.SetActive(false);
+ return controller;
+ }
+
+ #region Primitives
+
+ private static GameObject MakeOverlayRoot(string name, Transform parent, out CanvasGroup cg)
+ {
+ var root = new GameObject(name, typeof(RectTransform), typeof(CanvasGroup));
+ root.layer = UiLayer;
+ root.transform.SetParent(parent, false);
+ StretchFull(root.GetComponent());
+
+ cg = root.GetComponent();
+ cg.alpha = 0f;
+ cg.interactable = false;
+ cg.blocksRaycasts = false;
+
+ var backdrop = MakeImage("Backdrop", root.transform, new Color(0f, 0f, 0f, BackdropAlpha));
+ StretchFull(backdrop.GetComponent());
+ return root;
+ }
+
+ private static GameObject MakePanel(Transform parent, Vector2 anchorMin, Vector2 anchorMax)
+ {
+ var panel = MakeImage("Panel", parent, Style1Palette.PanelFill);
+ MakeSpan(panel, anchorMin, anchorMax);
+ return panel;
+ }
+
+ private static GameObject MakeGroup(string name, Transform parent, Vector2 anchorMin, Vector2 anchorMax)
+ {
+ var go = new GameObject(name, typeof(RectTransform));
+ go.layer = UiLayer;
+ go.transform.SetParent(parent, false);
+ MakeSpan(go, anchorMin, anchorMax);
+ return go;
+ }
+
+ private static GameObject MakeButton(Transform parent, string name, string label, float xMin, float xMax)
+ {
+ var go = new GameObject(name, typeof(RectTransform));
+ go.layer = UiLayer;
+ go.transform.SetParent(parent, false);
+ var rect = go.GetComponent();
+ rect.anchorMin = new Vector2(xMin, 0f);
+ rect.anchorMax = new Vector2(xMax, 1f);
+ rect.offsetMin = Vector2.zero;
+ rect.offsetMax = Vector2.zero;
+
+ var style = go.AddComponent();
+ style.SetLabel(label);
+ style.ApplyTheme();
+ return go;
+ }
+
+ private static GameObject MakeImage(string name, Transform parent, Color color)
+ {
+ var go = new GameObject(name, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
+ go.layer = UiLayer;
+ go.transform.SetParent(parent, false);
+ var img = go.GetComponent();
+ img.sprite = Style1Sprites.BlackRounded;
+ img.type = Image.Type.Sliced;
+ img.color = color;
+ img.raycastTarget = false;
+ return go;
+ }
+
+ private static GameObject MakeText(
+ string name, Transform parent, string text,
+ Style1Typography.Role role, Color color)
+ {
+ var go = new GameObject(name, typeof(RectTransform), typeof(CanvasRenderer), typeof(TextMeshProUGUI));
+ go.layer = UiLayer;
+ go.transform.SetParent(parent, false);
+ var tmp = go.GetComponent();
+ tmp.text = text;
+ tmp.alignment = TextAlignmentOptions.Center;
+ tmp.raycastTarget = false;
+ Style1Typography.Apply(tmp, role, color);
+ return go;
+ }
+
+ private static void MakeSpan(GameObject go, Vector2 anchorMin, Vector2 anchorMax)
+ {
+ var rect = go.GetComponent();
+ rect.anchorMin = anchorMin;
+ rect.anchorMax = anchorMax;
+ rect.offsetMin = Vector2.zero;
+ rect.offsetMax = Vector2.zero;
+ }
+
+ private static void StretchFull(RectTransform rect)
+ {
+ rect.anchorMin = Vector2.zero;
+ rect.anchorMax = Vector2.one;
+ rect.offsetMin = Vector2.zero;
+ rect.offsetMax = Vector2.zero;
+ }
+
+ #endregion
+}
diff --git a/Assets/UI/Style1/RecordFlowLayoutBuilder.cs.meta b/Assets/UI/Style1/RecordFlowLayoutBuilder.cs.meta
new file mode 100644
index 0000000..c37374e
--- /dev/null
+++ b/Assets/UI/Style1/RecordFlowLayoutBuilder.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: afa7fbc79c244ad596241e24515ea0a1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/UI/Style1/RecordNameEntryController.cs b/Assets/UI/Style1/RecordNameEntryController.cs
new file mode 100644
index 0000000..7b9e6c9
--- /dev/null
+++ b/Assets/UI/Style1/RecordNameEntryController.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+using TMPro;
+using UnityEngine;
+using UnityEngine.InputSystem;
+using UnityEngine.UI;
+
+/**
+ * Arcade name entry for a score record. The glyph grid is walked with Left and
+ * Right only, because the cabinet has no up or down navigation on the menu map:
+ * Confirm inserts the focused glyph, DONE (or the physical Enter key) submits,
+ * and Back deletes, then cancels once the name is empty.
+ *
+ * Built by and driven by
+ * , one instance per results panel.
+ *
+ */
+public class RecordNameEntryController : MonoBehaviour
+{
+ /** Character limit, shared with the store that writes the name. */
+ public const int MaxNameLength = ScoreRecordStore.MaxNameLength;
+
+ /** Shortest name accepted by DONE. */
+ private const int MinNameLength = 1;
+
+ /** Placeholder drawn in an unfilled name slot. */
+ public const string EmptySlotGlyph = "_";
+
+ private static readonly string[] KeyRows =
+ {
+ "QWERTYUIOP",
+ "ASDFGHJKL",
+ "ZXCVBNM",
+ "0123456789"
+ };
+
+ private const string KeyDel = "DEL";
+ private const string KeyDone = "DONE";
+
+ #region Refs
+
+ private CanvasGroup _cg;
+ private TMP_Text _scoreLabel;
+ private TMP_Text[] _slotLabels;
+ private readonly List _keys = new List();
+ private IMenuInputProvider _input;
+
+ #endregion
+
+ #region State
+
+ private readonly StringBuilder _name = new StringBuilder(MaxNameLength);
+ private int _focusIndex;
+ private bool _inputEnabled;
+ private long _pendingScore;
+ private IReadOnlyList _pendingPlacements;
+ private string _campusDisplayName;
+ private Action _onSaved;
+ private Action _onCancel;
+
+ #endregion
+
+ private struct KeyCell
+ {
+ public string Id;
+ public Image Background;
+ public TMP_Text Label;
+ }
+
+ #region Wiring
+
+ /** Called by the layout builder once the grid exists. */
+ public void SetRefs(
+ CanvasGroup cg,
+ TMP_Text scoreLabel,
+ TMP_Text[] slotLabels,
+ List<(string id, Image bg, TMP_Text label)> keys)
+ {
+ _cg = cg;
+ _scoreLabel = scoreLabel;
+ _slotLabels = slotLabels;
+ _keys.Clear();
+ foreach (var key in keys)
+ _keys.Add(new KeyCell { Id = key.id, Background = key.bg, Label = key.label });
+ }
+
+ #endregion
+
+ #region Public API
+
+ /** Shows the keyboard and starts listening for menu input. */
+ public void Show(
+ IMenuInputProvider input,
+ long score,
+ IReadOnlyList placements,
+ string campusDisplayName,
+ Action onSaved,
+ Action onCancel = null)
+ {
+ _input = input;
+ _pendingScore = score;
+ _pendingPlacements = placements;
+ _campusDisplayName = campusDisplayName;
+ _onSaved = onSaved;
+ _onCancel = onCancel;
+
+ _name.Clear();
+ _focusIndex = 0;
+ if (_scoreLabel != null)
+ _scoreLabel.text = score.ToString("N0", CultureInfo.InvariantCulture);
+ RefreshSlots();
+ RefreshKeyFocus();
+
+ gameObject.SetActive(true);
+ if (_cg != null)
+ {
+ _cg.alpha = 1f;
+ _cg.interactable = true;
+ _cg.blocksRaycasts = true;
+ }
+
+ SubscribeInput();
+ _inputEnabled = true;
+ _input?.Enable();
+ }
+
+ /** Hides the overlay and stops listening for input. */
+ public void Hide()
+ {
+ _inputEnabled = false;
+ UnsubscribeInput();
+ if (_cg != null)
+ {
+ _cg.interactable = false;
+ _cg.blocksRaycasts = false;
+ _cg.alpha = 0f;
+ }
+
+ gameObject.SetActive(false);
+ }
+
+ #endregion
+
+ #region Input
+
+ private void SubscribeInput()
+ {
+ if (_input == null) return;
+ _input.OnLeft += OnLeft;
+ _input.OnRight += OnRight;
+ _input.OnConfirm += OnConfirm;
+ _input.OnBack += OnBack;
+ }
+
+ private void UnsubscribeInput()
+ {
+ if (_input == null) return;
+ _input.OnLeft -= OnLeft;
+ _input.OnRight -= OnRight;
+ _input.OnConfirm -= OnConfirm;
+ _input.OnBack -= OnBack;
+ }
+
+ // Enter is not on the cabinet menu map, so it is polled directly as a
+ // keyboard shortcut for testing and for the keyboard build.
+ private void Update()
+ {
+ if (!_inputEnabled) return;
+
+ var keyboard = Keyboard.current;
+ if (keyboard != null && keyboard.enterKey.wasPressedThisFrame)
+ TrySubmit();
+ }
+
+ private void OnLeft()
+ {
+ if (!_inputEnabled || _keys.Count == 0) return;
+ _focusIndex = (_focusIndex - 1 + _keys.Count) % _keys.Count;
+ RefreshKeyFocus();
+ }
+
+ private void OnRight()
+ {
+ if (!_inputEnabled || _keys.Count == 0) return;
+ _focusIndex = (_focusIndex + 1) % _keys.Count;
+ RefreshKeyFocus();
+ }
+
+ private void OnConfirm()
+ {
+ if (!_inputEnabled || _keys.Count == 0) return;
+ ActivateFocusedKey();
+ }
+
+ private void OnBack()
+ {
+ if (!_inputEnabled) return;
+ if (_name.Length > 0)
+ {
+ _name.Length--;
+ RefreshSlots();
+ return;
+ }
+
+ _onCancel?.Invoke();
+ }
+
+ private void ActivateFocusedKey()
+ {
+ string id = _keys[_focusIndex].Id;
+ if (id == KeyDel)
+ {
+ if (_name.Length > 0)
+ {
+ _name.Length--;
+ RefreshSlots();
+ }
+
+ return;
+ }
+
+ if (id == KeyDone)
+ {
+ TrySubmit();
+ return;
+ }
+
+ if (_name.Length >= MaxNameLength)
+ return;
+
+ _name.Append(id);
+ RefreshSlots();
+ }
+
+ private void TrySubmit()
+ {
+ if (_name.Length < MinNameLength)
+ return;
+
+ _inputEnabled = false;
+ UnsubscribeInput();
+
+ var record = ScoreRecordStore.Append(
+ _name.ToString(),
+ _pendingScore,
+ _campusDisplayName,
+ _pendingPlacements);
+
+ Hide();
+ _onSaved?.Invoke(record);
+ }
+
+ #endregion
+
+ #region Visual
+
+ private void RefreshSlots()
+ {
+ if (_slotLabels == null) return;
+ for (int i = 0; i < _slotLabels.Length; i++)
+ {
+ if (_slotLabels[i] == null) continue;
+ bool filled = i < _name.Length;
+ _slotLabels[i].text = filled ? _name[i].ToString() : EmptySlotGlyph;
+ _slotLabels[i].color = filled ? Style1Palette.Score : Style1Palette.Label;
+ }
+ }
+
+ private void RefreshKeyFocus()
+ {
+ for (int i = 0; i < _keys.Count; i++)
+ {
+ var key = _keys[i];
+ bool focused = i == _focusIndex;
+ if (key.Background != null)
+ key.Background.color = focused ? Style1Palette.Accent : Style1Palette.PanelFill;
+
+ if (key.Label != null)
+ key.Label.color = focused ? Style1Palette.Background : Style1Palette.Score;
+ }
+ }
+
+ #endregion
+
+ private void OnDestroy()
+ {
+ UnsubscribeInput();
+ }
+
+ /** Glyph rows the layout builder lays out, in display order. */
+ public static IReadOnlyList GlyphRows => KeyRows;
+
+ /** Key id the layout builder uses for the delete cell. */
+ public static string DeleteKeyId => KeyDel;
+
+ /** Key id the layout builder uses for the submit cell. */
+ public static string DoneKeyId => KeyDone;
+}
diff --git a/Assets/UI/Style1/RecordNameEntryController.cs.meta b/Assets/UI/Style1/RecordNameEntryController.cs.meta
new file mode 100644
index 0000000..f9b1a38
--- /dev/null
+++ b/Assets/UI/Style1/RecordNameEntryController.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ca3e7af6c1d24646b94eb9736926dd44
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/UI/Style1/ResultScreenController.cs b/Assets/UI/Style1/ResultScreenController.cs
index a6e1cae..84782e3 100644
--- a/Assets/UI/Style1/ResultScreenController.cs
+++ b/Assets/UI/Style1/ResultScreenController.cs
@@ -11,11 +11,31 @@
* 1. Camera pulls back (zooms out) to reveal the finished tree.
* 2. "TREE COMPLETE!" banner flies in.
* 3. Stats panel rises in and counts up.
- * 4. Continue / Quit buttons appear; player navigates with the menu input.
+ * 4. Continue / Record / Quit buttons appear; player navigates with the menu input.
+ *
+ * Record only appears on a panel the player can actually navigate, so a
+ * spectator panel (the bot's half of a VS Bot split screen) never offers it.
*
*/
public class ResultScreenController : MonoBehaviour
{
+ /** Buttons in the bottom row, left to right. */
+ private enum ResultChoice
+ {
+ Continue = 0,
+ Record = 1,
+ Quit = 2
+ }
+
+ /** Pixels the selection indicator extends past the focused button. */
+ private const float IndicatorPadding = 4f;
+
+ /** Pixel gap between buttons when all three are shown. */
+ private const float ButtonGap = 6f;
+
+ /** Pixel gap between buttons when Record is hidden and both are wider. */
+ private const float WideButtonGap = 8f;
+
#region Inspector-linked refs (set by ResultScreenLayoutBuilder)
private TMP_Text _heroLabel;
@@ -30,11 +50,18 @@ public class ResultScreenController : MonoBehaviour
private TMP_Text _missValue;
private TMP_Text _comboValue;
private Style1Button _continueBtn;
+ private Style1Button _recordBtn;
private Style1Button _quitBtn;
private RectTransform _indicator;
private RectTransform _continueBtnRect;
+ private RectTransform _recordBtnRect;
private RectTransform _quitBtnRect;
private TreeResultQrDisplay _treeQrDisplay;
+ private TreeRunTracker _runTracker;
+
+ // Record overlays, built lazily under this panel so split-screen halves stay independent
+ private RecordNameEntryController _nameEntry;
+ private RecordConfirmController _recordConfirm;
// Hit distribution bar fills — animated vertically proportional to hit counts
private RectTransform[] _hitsBarFills;
@@ -46,9 +73,14 @@ public class ResultScreenController : MonoBehaviour
#region State
- private CanvasGroup _cg;
- private bool _confirmingContinue = true;
- private bool _inputEnabled = false;
+ private CanvasGroup _cg;
+ private ResultChoice _selection = ResultChoice.Continue;
+ private bool _inputEnabled = false;
+ private bool _recordAvailable;
+
+ // Captured when the panel opens, because saving happens after the run has ended
+ private long _pendingScore;
+ private int[] _pendingLastPlaced = Array.Empty();
private IMenuInputProvider _input;
@@ -70,7 +102,7 @@ public class ResultScreenController : MonoBehaviour
public event Action OnSelectionChanged;
/** Whether the player's current selection is Continue (vs Quit). */
- public bool IsOnContinue => _confirmingContinue;
+ public bool IsOnContinue => _selection == ResultChoice.Continue;
/**
* Forces a scene transition using the current button selection. Called by
@@ -82,7 +114,7 @@ public void ExecuteTransition()
if (!_inputEnabled) return;
_inputEnabled = false;
UnsubscribeInput();
- SetIntentAndLoad(_confirmingContinue);
+ SetIntentAndLoad(_selection != ResultChoice.Quit);
}
/** Disables navigation input without triggering a transition. */
@@ -112,8 +144,9 @@ public void SetRefs(
TMP_Text hero, TMP_Text sub, GameObject statsPanel, TMP_Text grade,
TMP_Text score, TMP_Text accuracy, TMP_Text perfect, TMP_Text good,
TMP_Text ok, TMP_Text miss, TMP_Text combo,
- Style1Button continueBtn, Style1Button quitBtn,
- RectTransform indicator, RectTransform continueBtnRect, RectTransform quitBtnRect,
+ Style1Button continueBtn, Style1Button recordBtn, Style1Button quitBtn,
+ RectTransform indicator,
+ RectTransform continueBtnRect, RectTransform recordBtnRect, RectTransform quitBtnRect,
TreeResultQrDisplay treeQrDisplay = null)
{
_heroLabel = hero;
@@ -128,20 +161,24 @@ public void SetRefs(
_missValue = miss;
_comboValue = combo;
_continueBtn = continueBtn;
+ _recordBtn = recordBtn;
_quitBtn = quitBtn;
_indicator = indicator;
_continueBtnRect = continueBtnRect;
+ _recordBtnRect = recordBtnRect;
_quitBtnRect = quitBtnRect;
_treeQrDisplay = treeQrDisplay;
}
/**
- * Binds the per-player used to build the share QR.
+ * Binds the per-player used to build the share QR
+ * and to read the placements stored with a score record.
* Call before so the texture is ready when the panel rises.
*
*/
public void SetTreeRunTracker(TreeRunTracker tracker)
{
+ _runTracker = tracker;
if (_treeQrDisplay == null)
_treeQrDisplay = GetComponent();
_treeQrDisplay?.SetTracker(tracker);
@@ -189,14 +226,17 @@ private void GatherRefs()
if (_missValue == null) _missValue = transform.Find("StatsPanel/Fill/HitsRow/MissCol/Value")?.GetComponent();
if (_comboValue == null) _comboValue = transform.Find("StatsPanel/Fill/ComboRow/Value")?.GetComponent();
- var contGo = transform.Find("ButtonRow/ContinueButton");
- var quitGo = transform.Find("ButtonRow/QuitButton");
- var indGo = transform.Find("ButtonRow/SelectionIndicator");
+ var contGo = transform.Find("ButtonRow/ContinueButton");
+ var recordGo = transform.Find("ButtonRow/RecordButton");
+ var quitGo = transform.Find("ButtonRow/QuitButton");
+ var indGo = transform.Find("ButtonRow/SelectionIndicator");
if (_continueBtn == null) _continueBtn = contGo?.GetComponent();
+ if (_recordBtn == null) _recordBtn = recordGo?.GetComponent();
if (_quitBtn == null) _quitBtn = quitGo?.GetComponent();
if (_indicator == null) _indicator = indGo?.GetComponent();
if (_continueBtnRect == null) _continueBtnRect = contGo?.GetComponent();
+ if (_recordBtnRect == null) _recordBtnRect = recordGo?.GetComponent();
if (_quitBtnRect == null) _quitBtnRect = quitGo?.GetComponent();
_hitsBarFills = new RectTransform[]
@@ -234,6 +274,14 @@ public async UniTask ShowAsync(RhythmScoreTracker score, IMenuInputProvider inpu
_input = input;
gameObject.SetActive(true);
+ // Snapshot what a record would store now; the run is over and the
+ // tracker is about to be finished by the QR step below.
+ _pendingScore = score != null ? score.TotalScore : 0L;
+ _pendingLastPlaced = ScoreRecordStore.TakeLastPlaced(_runTracker?.SuccessfulPlayerPlacements);
+
+ _recordAvailable = allowNavigation;
+ ApplyRecordButtonLayout();
+
var cg = GetComponent();
cg.alpha = 0f;
cg.interactable = false;
@@ -260,6 +308,7 @@ public async UniTask ShowAsync(RhythmScoreTracker score, IMenuInputProvider inpu
if (_heroLabel != null) _heroLabel.transform.localPosition = new Vector3(0f, 700f, 0f);
if (_statsPanel != null) _statsPanel.transform.localPosition = new Vector3(0f, -1500f, 0f);
if (_continueBtn != null) _continueBtn.transform.localScale = Vector3.zero;
+ if (_recordBtn != null) _recordBtn.transform.localScale = Vector3.zero;
if (_quitBtn != null) _quitBtn.transform.localScale = Vector3.zero;
if (_indicator != null) _indicator.gameObject.SetActive(false);
var subCgInit = _subLabel?.GetComponent();
@@ -350,6 +399,11 @@ await Tween.LocalPosition(_statsPanel.transform,
_continueBtn.transform.localScale = Vector3.zero;
_ = Tween.Scale(_continueBtn.transform, Vector3.one, 0.3f, Ease.OutBack);
}
+ if (_recordAvailable && _recordBtn != null)
+ {
+ _recordBtn.transform.localScale = Vector3.zero;
+ _ = Tween.Scale(_recordBtn.transform, Vector3.one, 0.3f, Ease.OutBack);
+ }
if (_quitBtn != null)
{
_quitBtn.transform.localScale = Vector3.zero;
@@ -360,7 +414,7 @@ await Tween.LocalPosition(_statsPanel.transform,
if (!allowNavigation)
{
- // Stats-only display (e.g. P2 panel in 2P mode) — spring-show buttons, keep indicator hidden.
+ // Stats-only display (e.g. the bot's panel in VS Bot) — spring-show buttons, keep indicator hidden.
if (_continueBtn != null) _ = Tween.Scale(_continueBtn.transform, Vector3.one, 0.3f, Ease.OutBack);
if (_quitBtn != null) _ = Tween.Scale(_quitBtn.transform, Vector3.one, 0.3f, Ease.OutBack);
if (_indicator != null) _indicator.gameObject.SetActive(false);
@@ -369,13 +423,155 @@ await Tween.LocalPosition(_statsPanel.transform,
// Wire button clicks
_continueBtn?.SetClickHandler(OnContinue);
+ _recordBtn?.SetClickHandler(OnRecord);
_quitBtn?.SetClickHandler(OnQuit);
SubscribeInput();
- _inputEnabled = true;
- _confirmingContinue = true;
- cg.interactable = true;
+ _inputEnabled = true;
+ _selection = ResultChoice.Continue;
+ cg.interactable = true;
+ UpdateIndicator();
+ }
+
+ #endregion
+
+ #region Record flow
+
+ /**
+ * Positions the button row for two or three buttons and hides Record when
+ * this panel cannot be navigated.
+ *
+ */
+ private void ApplyRecordButtonLayout()
+ {
+ if (_recordBtn == null) return;
+
+ _recordBtn.gameObject.SetActive(_recordAvailable);
+ if (_recordAvailable)
+ {
+ SetButtonSpan(_continueBtnRect, 0f, 0.30f, 0f, -ButtonGap);
+ SetButtonSpan(_recordBtnRect, 0.35f, 0.65f, ButtonGap, -ButtonGap);
+ SetButtonSpan(_quitBtnRect, 0.70f, 1f, ButtonGap, 0f);
+ }
+ else
+ {
+ SetButtonSpan(_continueBtnRect, 0f, 0.47f, 0f, -WideButtonGap);
+ SetButtonSpan(_quitBtnRect, 0.53f, 1f, WideButtonGap, 0f);
+ }
+ }
+
+ private static void SetButtonSpan(RectTransform rect,
+ float anchorXMin, float anchorXMax, float offsetXMin, float offsetXMax)
+ {
+ if (rect == null) return;
+ rect.anchorMin = new Vector2(anchorXMin, 0f);
+ rect.anchorMax = new Vector2(anchorXMax, 1f);
+ rect.offsetMin = new Vector2(offsetXMin, 0f);
+ rect.offsetMax = new Vector2(offsetXMax, 0f);
+ }
+
+ /**
+ * Builds the name-entry and confirmation overlays under this panel, so each
+ * half of a split screen owns its own overlays and its own input provider.
+ *
+ */
+ private void EnsureRecordOverlays()
+ {
+ if (_nameEntry != null && _recordConfirm != null) return;
+ RecordFlowLayoutBuilder.Build(transform, out _nameEntry, out _recordConfirm);
+ }
+
+ private void OnRecord()
+ {
+ if (!_inputEnabled || !_recordAvailable) return;
+
+ EnsureRecordOverlays();
+ if (_nameEntry == null)
+ {
+ Debug.LogError("[ResultScreenController] Record name entry overlay missing; staying on results.");
+ return;
+ }
+
+ _inputEnabled = false;
+ UnsubscribeInput();
+
+ // Mute the results panel so only the name entry reacts to input.
+ if (_cg != null)
+ {
+ _cg.interactable = false;
+ _cg.blocksRaycasts = false;
+ }
+
+ _nameEntry.Show(
+ _input,
+ _pendingScore,
+ _pendingLastPlaced,
+ CampusBuildConfig.LoadOrDefault().DisplayName,
+ onSaved: OnRecordSaved,
+ onCancel: OnRecordCancelled);
+ }
+
+ private void OnRecordSaved(ScoreRecord record)
+ {
+ EnsureRecordOverlays();
+ if (_recordConfirm == null)
+ {
+ Debug.LogError("[ResultScreenController] Record confirm overlay missing; continuing to Menu.");
+ CompletePostRecordChoice(isContinue: true);
+ return;
+ }
+
+ _recordConfirm.Show(record.id, _input, CompletePostRecordChoice);
+ }
+
+ /**
+ * Applies a Continue or Quit answer, whether it came from the results buttons
+ * or from the confirmation screen after a save.
+ *
+ * In a coordinated mode the results buttons are restored first, because the
+ * other player may have picked the opposite option and the coordinator will
+ * ask for another confirm. Leaving no navigable UI behind would strand the
+ * player on the results screen.
+ *
+ */
+ private void CompletePostRecordChoice(bool isContinue)
+ {
+ if (UseCoordinatedTransition)
+ {
+ RestoreResultsNavigation(isContinue ? ResultChoice.Continue : ResultChoice.Quit);
+ OnConfirmRequested?.Invoke(isContinue);
+ return;
+ }
+
+ SetIntentAndLoad(isContinue);
+ }
+
+ /** Back on an empty name returns to the results buttons. */
+ private void OnRecordCancelled()
+ {
+ _nameEntry?.Hide();
+ RestoreResultsNavigation(ResultChoice.Continue);
+ }
+
+ /** Re-shows and re-arms the results button row with a given selection. */
+ private void RestoreResultsNavigation(ResultChoice selection)
+ {
+ if (_cg != null)
+ {
+ _cg.alpha = 1f;
+ _cg.interactable = true;
+ _cg.blocksRaycasts = true;
+ }
+
+ _selection = selection;
+ if (!_inputEnabled)
+ {
+ SubscribeInput();
+ _inputEnabled = true;
+ }
+
UpdateIndicator();
+ RefreshButtonHover();
}
#endregion
@@ -403,47 +599,87 @@ private void UnsubscribeInput()
private void OnLeft()
{
if (!_inputEnabled) return;
- _confirmingContinue = true;
+ _selection = PreviousChoice(_selection);
OnSelectionChanged?.Invoke();
UpdateIndicator();
- _continueBtn?.SimulateHover(true);
- _quitBtn?.SimulateHover(false);
+ RefreshButtonHover();
}
private void OnRight()
{
if (!_inputEnabled) return;
- _confirmingContinue = false;
+ _selection = NextChoice(_selection);
OnSelectionChanged?.Invoke();
UpdateIndicator();
- _quitBtn?.SimulateHover(true);
- _continueBtn?.SimulateHover(false);
+ RefreshButtonHover();
}
+ /** Steps right through the row, skipping Record when it is hidden. */
+ private ResultChoice NextChoice(ResultChoice current) =>
+ current switch
+ {
+ ResultChoice.Continue => _recordAvailable ? ResultChoice.Record : ResultChoice.Quit,
+ ResultChoice.Record => ResultChoice.Quit,
+ _ => ResultChoice.Continue
+ };
+
+ /** Steps left through the row, skipping Record when it is hidden. */
+ private ResultChoice PreviousChoice(ResultChoice current) =>
+ current switch
+ {
+ ResultChoice.Quit => _recordAvailable ? ResultChoice.Record : ResultChoice.Continue,
+ ResultChoice.Record => ResultChoice.Continue,
+ _ => ResultChoice.Quit
+ };
+
private void OnConfirmInput()
{
if (!_inputEnabled) return;
- (_confirmingContinue ? _continueBtn : _quitBtn)?.SimulateVisualPress();
+ SelectedButton()?.SimulateVisualPress();
+
+ // Record opens an overlay on this panel instead of leaving the scene, so
+ // it must never be reported to the coordinator as a Continue.
+ if (_selection == ResultChoice.Record)
+ {
+ OnRecord();
+ return;
+ }
+
+ bool isContinue = _selection == ResultChoice.Continue;
if (UseCoordinatedTransition)
- OnConfirmRequested?.Invoke(_confirmingContinue);
- else if (_confirmingContinue) OnContinue();
+ OnConfirmRequested?.Invoke(isContinue);
+ else if (isContinue) OnContinue();
else OnQuit();
}
- private void OnContinue()
+ private Style1Button SelectedButton() =>
+ _selection switch
+ {
+ ResultChoice.Record => _recordBtn,
+ ResultChoice.Quit => _quitBtn,
+ _ => _continueBtn
+ };
+
+ private void RefreshButtonHover()
{
- if (!_inputEnabled) return;
- _inputEnabled = false;
- UnsubscribeInput();
- SetIntentAndLoad(isContinue: true);
+ _continueBtn?.SimulateHover(_selection == ResultChoice.Continue);
+ if (_recordAvailable)
+ _recordBtn?.SimulateHover(_selection == ResultChoice.Record);
+ _quitBtn?.SimulateHover(_selection == ResultChoice.Quit);
}
- private void OnQuit()
+ private void OnContinue() => ConfirmChoice(ResultChoice.Continue);
+
+ private void OnQuit() => ConfirmChoice(ResultChoice.Quit);
+
+ /** Direct button click path, used by the mouse and by 1P confirm. */
+ private void ConfirmChoice(ResultChoice choice)
{
if (!_inputEnabled) return;
+ _selection = choice;
_inputEnabled = false;
UnsubscribeInput();
- SetIntentAndLoad(isContinue: false);
+ CompletePostRecordChoice(choice == ResultChoice.Continue);
}
private void SetIntentAndLoad(bool isContinue)
@@ -466,7 +702,12 @@ private async UniTaskVoid LoadSceneWithFadeAsync(string sceneName)
private void UpdateIndicator()
{
if (_indicator == null) return;
- RectTransform target = _confirmingContinue ? _continueBtnRect : _quitBtnRect;
+ RectTransform target = _selection switch
+ {
+ ResultChoice.Record => _recordBtnRect,
+ ResultChoice.Quit => _quitBtnRect,
+ _ => _continueBtnRect
+ };
if (target == null) return;
// Ensure the indicator always renders on top of the buttons regardless of sibling order.
@@ -476,8 +717,8 @@ private void UpdateIndicator()
// Copy anchors exactly from the target button (same parent = same coordinate space)
_indicator.anchorMin = target.anchorMin;
_indicator.anchorMax = target.anchorMax;
- _indicator.offsetMin = target.offsetMin + new Vector2(-4f, -4f);
- _indicator.offsetMax = target.offsetMax + new Vector2(4f, 4f);
+ _indicator.offsetMin = target.offsetMin + new Vector2(-IndicatorPadding, -IndicatorPadding);
+ _indicator.offsetMax = target.offsetMax + new Vector2(IndicatorPadding, IndicatorPadding);
Tween.StopAll(_indicator);
_indicator.localScale = Vector3.one * 0.92f;
diff --git a/Assets/UI/Style1/ResultScreenLayoutBuilder.cs b/Assets/UI/Style1/ResultScreenLayoutBuilder.cs
index 8d9d4bd..4f9a7f9 100644
--- a/Assets/UI/Style1/ResultScreenLayoutBuilder.cs
+++ b/Assets/UI/Style1/ResultScreenLayoutBuilder.cs
@@ -145,6 +145,20 @@ public static void Build(Transform canvasRoot, out ResultScreenController contro
contStyle.SetLabel("CONTINUE");
contStyle.ApplyTheme();
+ // Record button (middle). ResultScreenController re-spans the row and hides
+ // this button on panels the player cannot navigate.
+ var recordBtn = new GameObject("RecordButton", typeof(RectTransform));
+ recordBtn.layer = 5;
+ recordBtn.transform.SetParent(btnRow.transform, false);
+ var rbr = recordBtn.GetComponent();
+ rbr.anchorMin = new Vector2(0.35f, 0f);
+ rbr.anchorMax = new Vector2(0.65f, 1f);
+ rbr.offsetMin = new Vector2(6f, 0f);
+ rbr.offsetMax = new Vector2(-6f, 0f);
+ var recordStyle = recordBtn.AddComponent();
+ recordStyle.SetLabel("RECORD");
+ recordStyle.ApplyTheme();
+
// Quit button (right half)
var quitBtn = new GameObject("QuitButton", typeof(RectTransform));
quitBtn.layer = 5;
@@ -196,9 +210,10 @@ public static void Build(Transform canvasRoot, out ResultScreenController contro
statsFill?.Find("HitsRow/MissCol/Value")?.GetComponent(),
statsFill?.Find("ComboRow/Value")?.GetComponent(),
continueBtn.GetComponent(),
+ recordBtn.GetComponent(),
quitBtn.GetComponent(),
indicator.GetComponent(),
- cbr, qbr,
+ cbr, rbr, qbr,
qrDisplay
);
}