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 ); }