From ee3be9ebedb551816730d903f918d0ba7a81d74e Mon Sep 17 00:00:00 2001 From: Kenneth Enow Date: Mon, 3 Aug 2026 00:00:24 -0400 Subject: [PATCH] Add the score record flow to the results screen 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 later. A third button, RECORD, sits between CONTINUE and QUIT. Choosing it opens an arcade name entry, saves the run to score_records.json, then shows the id the run was filed under so the player can photograph it. New session files: Campus.cs and CampusBuildConfig.cs hold the cabinet's campus identity. The config loads from Resources and falls back to a runtime instance with a valid campus string, so a missing asset cannot write an empty campus field. A cabinet switches campus by editing one asset. ScoreRecord.cs is the JSON contract the web app reads: id, name, score, date, campus, lastPlaced. ScoreRecordFile exists only because JsonUtility cannot deserialize a top-level array; the file on disk is still a raw array. ScoreRecordStore.cs loads, appends, and saves. Ids come from the highest existing suffix rather than the record count, so deleting or hand-editing a row cannot hand out an id already printed on someone's phone. Dates and ids format with invariant culture. A missing, empty, or corrupt file reads as zero records, because a bad save must never block a player from leaving the results screen. New UI files: RecordNameEntryController.cs walks the glyph grid with Left and Right only, since the cabinet menu map has no up or down. Confirm inserts, DONE or the physical Enter key saves, and Back deletes then cancels once the name is empty. RecordConfirmController.cs shows the saved id and takes a final Continue or Quit. RecordFlowLayoutBuilder.cs builds both overlays from Style1 tokens, per results panel, so each half of a split screen owns its own overlays and its own input provider. Results screen changes: ResultScreenLayoutBuilder adds the RECORD button to the row. ResultScreenController replaces the two-way Continue/Quit flag with a three-way selection that skips Record when it is hidden, and re-spans the button row for two or three buttons. Record is gated on allowNavigation rather than a list of game modes, so it covers 1P, Player 1 in VS Bot, both players in human 2P, and battle mode, while the bot's spectator panel keeps the original two buttons. Record opens an overlay instead of leaving the scene, so it is never reported to TwoPlayerGameCoordinator as a Continue. After a save, the results buttons are re-armed before the confirm is reported, because the other player may have chosen the opposite option and the coordinator will ask for another confirm. Co-authored-by: Cursor --- AI/CHANGELOG.md | 7 + Assets/Resources/CampusBuildConfig.asset | 16 + Assets/Resources/CampusBuildConfig.asset.meta | 8 + Assets/Session/Campus.cs | 10 + Assets/Session/Campus.cs.meta | 11 + Assets/Session/CampusBuildConfig.cs | 73 ++++ Assets/Session/CampusBuildConfig.cs.meta | 11 + Assets/Session/ScoreRecord.cs | 38 +++ Assets/Session/ScoreRecord.cs.meta | 11 + Assets/Session/ScoreRecordStore.cs | 217 ++++++++++++ Assets/Session/ScoreRecordStore.cs.meta | 11 + Assets/UI/Style1/RecordConfirmController.cs | 165 ++++++++++ .../UI/Style1/RecordConfirmController.cs.meta | 11 + Assets/UI/Style1/RecordFlowLayoutBuilder.cs | 308 +++++++++++++++++ .../UI/Style1/RecordFlowLayoutBuilder.cs.meta | 11 + Assets/UI/Style1/RecordNameEntryController.cs | 300 +++++++++++++++++ .../Style1/RecordNameEntryController.cs.meta | 11 + Assets/UI/Style1/ResultScreenController.cs | 311 ++++++++++++++++-- Assets/UI/Style1/ResultScreenLayoutBuilder.cs | 17 +- 19 files changed, 1511 insertions(+), 36 deletions(-) create mode 100644 Assets/Resources/CampusBuildConfig.asset create mode 100644 Assets/Resources/CampusBuildConfig.asset.meta create mode 100644 Assets/Session/Campus.cs create mode 100644 Assets/Session/Campus.cs.meta create mode 100644 Assets/Session/CampusBuildConfig.cs create mode 100644 Assets/Session/CampusBuildConfig.cs.meta create mode 100644 Assets/Session/ScoreRecord.cs create mode 100644 Assets/Session/ScoreRecord.cs.meta create mode 100644 Assets/Session/ScoreRecordStore.cs create mode 100644 Assets/Session/ScoreRecordStore.cs.meta create mode 100644 Assets/UI/Style1/RecordConfirmController.cs create mode 100644 Assets/UI/Style1/RecordConfirmController.cs.meta create mode 100644 Assets/UI/Style1/RecordFlowLayoutBuilder.cs create mode 100644 Assets/UI/Style1/RecordFlowLayoutBuilder.cs.meta create mode 100644 Assets/UI/Style1/RecordNameEntryController.cs create mode 100644 Assets/UI/Style1/RecordNameEntryController.cs.meta diff --git a/AI/CHANGELOG.md b/AI/CHANGELOG.md index 046661e..b7e8bce 100644 --- a/AI/CHANGELOG.md +++ b/AI/CHANGELOG.md @@ -57,6 +57,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 ); }