diff --git a/AI/CHANGELOG.md b/AI/CHANGELOG.md index b77fdda..735918e 100644 --- a/AI/CHANGELOG.md +++ b/AI/CHANGELOG.md @@ -29,6 +29,14 @@ ## [Unreleased] +### Added — End-of-level tree QR share on Result Screen + +- **Scope:** component:ResultScreen · integration +- **What:** Results stats card now includes a `TreeShareSection` (square QR + "Scan to view your binary tree"). `TreeRunTracker` records starting BST insertion order and successful placements; `TreeResultQrDisplay` encodes a seemytree viewer URL via vendored ZXing.Net. Viewer host / query names live in `TreeShareConfig` ScriptableObject (not hardcoded in tracker logic). +- **Why:** Players can scan the existing performance screen to view their constructed tree online without a separate completion popup; share endpoint is tunable per environment. +- **Impact:** `ResultScreenLayoutBuilder` / `ResultScreenController` extended; `TreeRhythmController` + `TwoPlayerGameCoordinator` wired. Requires `Assets/Plugins/ZXing/zxing.dll` and `Assets/ScriptableObjects/Configs/TreeShareConfig.asset`. +- **Refs:** `TreeRunTracker.cs`, `TreeShareConfig.cs`, `TreeResultQrDisplay.cs`, `TreeQrCodeGenerator.cs`, `ResultScreenLayoutBuilder.cs`, `ResultScreenController.cs` + ### Changed — Synthwave UI revamp: monochrome-accented dark style across all menus - **Scope:** framework · token · component:TitleScreen · component:SongSelect · component:ScoreHUD · component:MainMenu · animation diff --git a/Assets/Integration/TreeQrCodeGenerator.cs b/Assets/Integration/TreeQrCodeGenerator.cs new file mode 100644 index 0000000..b97a38a --- /dev/null +++ b/Assets/Integration/TreeQrCodeGenerator.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using ZXing; +using ZXing.Common; +using ZXing.QrCode; +using ZXing.QrCode.Internal; + +/** + * Runtime QR texture generation via ZXing.Net (Assets/Plugins/ZXing). + * Produces a square, point-filtered Texture2D suitable for a UI RawImage. + * + */ +public static class TreeQrCodeGenerator +{ + private const int DefaultResolution = 512; + private const int QuietZoneModules = 2; // white margin around the QR + + /** + * Encodes as a QR code texture. + * Returns null and logs on failure so callers can show a fallback. + * + * Full URL (or any string) to encode. + * Square pixel size; at least 512 recommended. + */ + public static Texture2D Generate(string contents, int resolution = DefaultResolution) + { + if (string.IsNullOrEmpty(contents)) + { + Debug.LogError("[TreeQrCodeGenerator] Cannot encode an empty string."); + return null; + } + + if (resolution < 64) + resolution = DefaultResolution; + + try + { + var hints = new Dictionary + { + { EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M }, + { EncodeHintType.MARGIN, QuietZoneModules }, + { EncodeHintType.CHARACTER_SET, "UTF-8" }, + }; + + var writer = new QRCodeWriter(); + BitMatrix matrix = writer.encode(contents, BarcodeFormat.QR_CODE, resolution, resolution, hints); + if (matrix == null) + { + Debug.LogError("[TreeQrCodeGenerator] QRCodeWriter returned a null matrix."); + return null; + } + + return BitMatrixToTexture(matrix); + } + catch (Exception ex) + { + Debug.LogError($"[TreeQrCodeGenerator] QR generation failed: {ex.Message}"); + return null; + } + } + + /** + * Converts a ZXing BitMatrix into a black-on-white Texture2D with + * point filtering and no mipmaps so modules stay crisp when scaled. + * + */ + private static Texture2D BitMatrixToTexture(BitMatrix matrix) + { + int width = matrix.Width; + int height = matrix.Height; + + var texture = new Texture2D(width, height, TextureFormat.RGB24, mipChain: false) + { + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp, + name = "TreeResultQr", + }; + + // ZXing BitMatrix is row-major from the top; Unity textures are bottom-up. + var pixels = new Color32[width * height]; + for (int y = 0; y < height; y++) + { + int unityY = height - 1 - y; + for (int x = 0; x < width; x++) + { + bool dark = matrix[x, y]; + pixels[unityY * width + x] = dark + ? new Color32(0, 0, 0, 255) + : new Color32(255, 255, 255, 255); + } + } + + texture.SetPixels32(pixels); + texture.Apply(updateMipmaps: false, makeNoLongerReadable: true); + return texture; + } +} diff --git a/Assets/Integration/TreeQrCodeGenerator.cs.meta b/Assets/Integration/TreeQrCodeGenerator.cs.meta new file mode 100644 index 0000000..d0693db --- /dev/null +++ b/Assets/Integration/TreeQrCodeGenerator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 23971f79c6a6f400f9c27f88262196c5 \ No newline at end of file diff --git a/Assets/Integration/TreeResultQrDisplay.cs b/Assets/Integration/TreeResultQrDisplay.cs new file mode 100644 index 0000000..8902ee6 --- /dev/null +++ b/Assets/Integration/TreeResultQrDisplay.cs @@ -0,0 +1,204 @@ +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +/** + * Prepares and shows the tree-share QR code on the existing result screen. + * + * Does not open or close the performance overlay — that stays the job of + * . Call + * once per completed run, before or as the results UI becomes visible. + * + */ +public class TreeResultQrDisplay : MonoBehaviour +{ + private const string FallbackMessage = "Unable to generate tree QR code"; + private const int QrTextureSize = 512; + + [Header("References")] + [Tooltip("Tracks start-tree insertion order and successful player placements.")] + [SerializeField] private TreeRunTracker _tracker; + + [Tooltip("Square RawImage that receives the generated QR texture.")] + [SerializeField] private RawImage _qrRawImage; + + [Tooltip("Short instruction under / beside the QR (e.g. scan prompt).")] + [SerializeField] private TMP_Text _instructionLabel; + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + [Tooltip("Optional URL label shown only in development builds.")] + [SerializeField] private TMP_Text _debugUrlLabel; +#endif + + private Texture2D _runtimeTexture; + private bool _preparedForCurrentRun; + private string _lastUrl; + + #region Wiring + + /** Assigns runtime UI refs created by . */ + public void SetUiRefs(RawImage qrRawImage, TMP_Text instructionLabel) + { + _qrRawImage = qrRawImage; + _instructionLabel = instructionLabel; + } + + /** Binds the per-player run tracker used to build the viewer URL. */ + public void SetTracker(TreeRunTracker tracker) + { + _tracker = tracker; + _preparedForCurrentRun = false; + } + + #endregion + + #region Public API + + /** + * Finishes tracking, builds the viewer URL, generates a QR texture, and + * assigns it to the results-screen RawImage. Safe to call once per run; + * subsequent calls for the same run are no-ops until . + * + */ + public void PrepareResultQrCode() + { + if (_preparedForCurrentRun) + return; + + if (_tracker == null) + { + Debug.LogError("[TreeResultQrDisplay] Missing TreeRunTracker reference."); + ShowFallback(); + return; + } + + if (_qrRawImage == null) + { + Debug.LogError("[TreeResultQrDisplay] Missing QR RawImage reference."); + // Still finish tracking so placement data stops growing. + _tracker.FinishLevel(); + return; + } + + _tracker.FinishLevel(); + _lastUrl = _tracker.BuildViewerUrl(); + if (string.IsNullOrEmpty(_lastUrl)) + { + Debug.LogError("[TreeResultQrDisplay] Viewer URL was empty (check TreeShareConfig)."); + ShowFallback(); + _preparedForCurrentRun = true; + return; + } + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.Log($"[TreeResultQrDisplay] Tree viewer URL: {_lastUrl}"); +#endif + + DestroyRuntimeTexture(); + + Texture2D qr = null; + try + { + qr = TreeQrCodeGenerator.Generate(_lastUrl, QrTextureSize); + } + catch (System.Exception ex) + { + Debug.LogError($"[TreeResultQrDisplay] QR generation threw: {ex.Message}"); + } + + if (qr == null) + { + ShowFallback(); + _preparedForCurrentRun = true; + return; + } + + _runtimeTexture = qr; + _qrRawImage.texture = qr; + _qrRawImage.color = Color.white; + _qrRawImage.enabled = true; + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + if (_debugUrlLabel != null) + { + _debugUrlLabel.text = _lastUrl; + _debugUrlLabel.gameObject.SetActive(true); + } +#endif + + _preparedForCurrentRun = true; + } + + /** + * Clears the displayed QR and destroys the runtime texture. + * Call when the results screen closes or before the next run. + * + */ + public void ClearQr() + { + DestroyRuntimeTexture(); + + if (_qrRawImage != null) + { + _qrRawImage.texture = null; + _qrRawImage.enabled = false; + } + + _preparedForCurrentRun = false; + _lastUrl = null; + } + + /** URL produced by the last successful . */ + public string LastViewerUrl => _lastUrl; + + #endregion + + #region Lifecycle + + private void OnDisable() + { + // Hide QR with the results overlay; texture is destroyed so the next + // PrepareResultQrCode generates a fresh one for the next run. + ClearQr(); + } + + private void OnDestroy() + { + DestroyRuntimeTexture(); + } + + #endregion + + #region Helpers + + private void ShowFallback() + { + if (_qrRawImage != null) + { + _qrRawImage.texture = null; + _qrRawImage.enabled = false; + } + + if (_instructionLabel != null) + { + _instructionLabel.text = FallbackMessage; + _instructionLabel.gameObject.SetActive(true); + } + + Debug.LogWarning("[TreeResultQrDisplay] Showing QR fallback message."); + } + + private void DestroyRuntimeTexture() + { + if (_runtimeTexture == null) + return; + + if (_qrRawImage != null && _qrRawImage.texture == _runtimeTexture) + _qrRawImage.texture = null; + + Destroy(_runtimeTexture); + _runtimeTexture = null; + } + + #endregion +} diff --git a/Assets/Integration/TreeResultQrDisplay.cs.meta b/Assets/Integration/TreeResultQrDisplay.cs.meta new file mode 100644 index 0000000..f86d93a --- /dev/null +++ b/Assets/Integration/TreeResultQrDisplay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe6df20e034594594a32df240ff68f16 \ No newline at end of file diff --git a/Assets/Integration/TreeRhythmController.cs b/Assets/Integration/TreeRhythmController.cs index d888be8..ca70104 100644 --- a/Assets/Integration/TreeRhythmController.cs +++ b/Assets/Integration/TreeRhythmController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Cysharp.Threading.Tasks; using TMPro; using UnityEngine; @@ -38,6 +39,13 @@ public class TreeRhythmController : MonoBehaviour, IRhythmGameAdapter "Assign IntValueGeneratorConfig or SmartIntValueGeneratorConfig.")] [SerializeField] private IntGeneratorConfig _generatorConfig; + [Header("Tree Share (QR)")] + [Tooltip("Tracks starting insertion order and successful placements for the results QR. Auto-added on this GameObject if left empty.")] + [SerializeField] private TreeRunTracker _runTracker; + + [Tooltip("Viewer base URL / query names for the share QR. Required for BuildViewerUrl.")] + [SerializeField] private TreeShareConfig _treeShareConfig; + [Header("Layer")] [Tooltip("Unity layer for all nodes and edges created by this controller. Set to P1Tree or P2Tree in 2P mode.")] [SerializeField] private string _treeLayerName = "Default"; @@ -116,6 +124,9 @@ public class TreeRhythmController : MonoBehaviour, IRhythmGameAdapter /** Fired on every beat during playback. Carries the integer beat index. */ public event System.Action OnBeat; + /** Per-player run tracker used to build the end-of-level tree QR URL. */ + public TreeRunTracker RunTracker => _runTracker; + #endregion #region Private Systems @@ -151,6 +162,17 @@ public class TreeRhythmController : MonoBehaviour, IRhythmGameAdapter private void Awake() { + // Ensure a tracker lives on this controller so scene wiring is optional. + if (_runTracker == null) + _runTracker = GetComponent(); + if (_runTracker == null) + _runTracker = gameObject.AddComponent(); + + if (_treeShareConfig != null) + _runTracker.SetShareConfig(_treeShareConfig); + else if (_runTracker.ShareConfig == null) + Debug.LogWarning("[TreeRhythmController] TreeShareConfig is not assigned — QR viewer URL will fail until it is set."); + _reactionPolicy = new TreeRhythmReactionPolicy(); if (_useP2GameMap) @@ -310,13 +332,25 @@ private void SubscribeNavigatorEvents() _navigator.OnMovedRight += OnMoved; _navigator.OnMovedLeft += _ => _lastActionSucceeded = true; _navigator.OnMovedRight += _ => _lastActionSucceeded = true; - _navigator.OnNodeInserted += _ => _lastActionSucceeded = true; + _navigator.OnNodeInserted += OnPlayerNodeInserted; _navigator.OnInvalidActionAttempted += OnInvalidAction; _navigator.OnReachedEmptySlot += context => OnReachedEmptySlotAsync(context).Forget(); - _navigator.OnNodeInserted += node => OnNodeInsertedAsync(node).Forget(); _navigator.OnNavigationComplete += () => OnNavigationCompleteAsync().Forget(); } + /** + * Fired only after a successful player Place (not sample-tree InsertDirect). + * Records the value for QR sharing and kicks off the insert animation pipeline. + * + */ + private void OnPlayerNodeInserted(TreeNode node) + { + _lastActionSucceeded = true; + if (node != null) + _runTracker?.RecordSuccessfulPlacement(node.Value); + OnNodeInsertedAsync(node).Forget(); + } + private void SubscribeRhythmInput() { IRhythmInputProvider input = _rhythmInput; @@ -431,6 +465,14 @@ private void PulseTreeNodes(TreeNode node) private void PopulateSampleTree() { + // Capture the exact insertion sequence used to build the starting BST + // (not an inorder/preorder traversal). InsertDirect does not fire OnNodeInserted, + // so sample values are never recorded as player placements. + IEnumerable startingOrder = _sampleTreeProvider != null + ? _sampleTreeProvider.Values + : System.Array.Empty(); + _runTracker?.BeginLevel(startingOrder); + if (_sampleTreeProvider != null && _sampleTreeProvider.Values.Count > 0) { foreach (int value in _sampleTreeProvider.Values) diff --git a/Assets/Integration/TreeRunTracker.cs b/Assets/Integration/TreeRunTracker.cs new file mode 100644 index 0000000..49e04f5 --- /dev/null +++ b/Assets/Integration/TreeRunTracker.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/** + * Tracks one level run of BST construction for QR sharing. + * + * Holds the starting tree's original insertion order plus every value the + * player successfully places, in placement order. Used by + * to build a seemytree viewer URL. + * + * Attach beside on each GameController. + * Does not use DontDestroyOnLoad — lives for the GameScene level only. + * + */ +public class TreeRunTracker : MonoBehaviour +{ + [Header("Share Config")] + [Tooltip("Viewer base URL and query param names. Assign TreeShareConfig asset.")] + [SerializeField] private TreeShareConfig _shareConfig; + + private readonly List _startingInsertionOrder = new List(16); + private readonly List _successfulPlayerPlacements = new List(64); + + /// True after and before . + private bool _isLevelRunning; + + /// + /// Guards against the same placement event being recorded twice in one + /// frame (e.g. duplicate OnNodeInserted subscriptions). Distinct placements + /// of the same numeric value across different frames are still recorded. + /// + private int _lastRecordedFrame = -1; + private int _lastRecordedValue; + + #region Read-only views + + /** Original insertion sequence used to build the starting BST. */ + public IReadOnlyList StartingInsertionOrder => _startingInsertionOrder; + + /** Values the player successfully placed, in placement order. */ + public IReadOnlyList SuccessfulPlayerPlacements => _successfulPlayerPlacements; + + /** Whether this tracker is currently accepting placement records. */ + public bool IsLevelRunning => _isLevelRunning; + + /** Active share config (may be null until assigned). */ + public TreeShareConfig ShareConfig => _shareConfig; + + #endregion + + #region Public API + + /** Assigns the share config (used when the tracker is auto-added at runtime). */ + public void SetShareConfig(TreeShareConfig config) + { + _shareConfig = config; + } + + /** + * Starts a new level run. Clears prior data and copies the starting + * tree insertion order exactly as provided (not sorted / traversed). + * + * + * Insertion sequence used to construct the starting BST. + * Null is treated as an empty start tree. + * + */ + public void BeginLevel(IEnumerable startingValues) + { + ResetTracking(); + + if (startingValues != null) + { + foreach (int value in startingValues) + _startingInsertionOrder.Add(value); + } + + _isLevelRunning = true; + } + + /** + * Records one successful player placement. Call only after the existing + * tree system confirms insertion (e.g. TreeNavigator.OnNodeInserted). + * Ignored when the level is not running or the same event is replayed. + * + */ + public void RecordSuccessfulPlacement(int value) + { + if (!_isLevelRunning) + return; + + // One Place action cannot insert twice in the same frame; treat that as a duplicate event. + int frame = Time.frameCount; + if (frame == _lastRecordedFrame && value == _lastRecordedValue) + return; + + _lastRecordedFrame = frame; + _lastRecordedValue = value; + _successfulPlayerPlacements.Add(value); + } + + /** + * Stops accepting further placements. Safe to call more than once. + * + */ + public void FinishLevel() + { + _isLevelRunning = false; + } + + /** + * Clears all tracked data and marks the level as not running. + * + */ + public void ResetTracking() + { + _startingInsertionOrder.Clear(); + _successfulPlayerPlacements.Clear(); + _isLevelRunning = false; + _lastRecordedFrame = -1; + _lastRecordedValue = 0; + } + + /** + * Builds the seemytree viewer URL for the current run. + * Empty placement lists are valid and encode as placed=. + * Base URL and query names come from . + * + */ + public string BuildViewerUrl() + { + if (_shareConfig == null) + { + Debug.LogError("[TreeRunTracker] Missing TreeShareConfig — cannot build viewer URL."); + return string.Empty; + } + + string startJoined = string.Join(",", _startingInsertionOrder); + string placedJoined = string.Join(",", _successfulPlayerPlacements); + + string startEncoded = Uri.EscapeDataString(startJoined); + string placedEncoded = Uri.EscapeDataString(placedJoined); + + string baseUrl = _shareConfig.ViewerBaseUrl; + string startKey = _shareConfig.StartParamName; + string placedKey = _shareConfig.PlacedParamName; + + return $"{baseUrl}?{startKey}={startEncoded}&{placedKey}={placedEncoded}"; + } + + #endregion +} diff --git a/Assets/Integration/TreeRunTracker.cs.meta b/Assets/Integration/TreeRunTracker.cs.meta new file mode 100644 index 0000000..6471d9f --- /dev/null +++ b/Assets/Integration/TreeRunTracker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f44aa09bc7c8e4960aecfe4cf77e8e3a \ No newline at end of file diff --git a/Assets/Integration/TreeShareConfig.cs b/Assets/Integration/TreeShareConfig.cs new file mode 100644 index 0000000..3c31a9b --- /dev/null +++ b/Assets/Integration/TreeShareConfig.cs @@ -0,0 +1,35 @@ +using UnityEngine; + +/** + * Deployment settings for the end-of-level tree share / QR viewer link. + * Keep environment-specific URLs here so gameplay code does not hardcode hosts. + * + */ +[CreateAssetMenu(menuName = "Treeformance/TreeShareConfig", fileName = "TreeShareConfig")] +public class TreeShareConfig : ScriptableObject +{ + [Header("Viewer")] + [Tooltip("Base URL for the tree viewer (no query string). Example: https://seemytree.netlify.app/view")] + [SerializeField] private string _viewerBaseUrl = "https://seemytree.netlify.app/view"; + + [Header("Query Parameter Names")] + [Tooltip("Query key for the starting BST insertion order.")] + [SerializeField] private string _startParamName = "start"; + + [Tooltip("Query key for successful player placements in order.")] + [SerializeField] private string _placedParamName = "placed"; + + /** Host + path used before ?start=…&placed=…. */ + public string ViewerBaseUrl => + string.IsNullOrWhiteSpace(_viewerBaseUrl) + ? "https://seemytree.netlify.app/view" + : _viewerBaseUrl.TrimEnd('/'); + + /** Query parameter name for the starting insertion sequence. */ + public string StartParamName => + string.IsNullOrWhiteSpace(_startParamName) ? "start" : _startParamName; + + /** Query parameter name for successful placements. */ + public string PlacedParamName => + string.IsNullOrWhiteSpace(_placedParamName) ? "placed" : _placedParamName; +} diff --git a/Assets/Integration/TreeShareConfig.cs.meta b/Assets/Integration/TreeShareConfig.cs.meta new file mode 100644 index 0000000..46c4145 --- /dev/null +++ b/Assets/Integration/TreeShareConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 12b30bbd212e049eb9d2fa93b78b1a36 \ No newline at end of file diff --git a/Assets/Integration/TwoPlayerGameCoordinator.cs b/Assets/Integration/TwoPlayerGameCoordinator.cs index 5f5effd..032de47 100644 --- a/Assets/Integration/TwoPlayerGameCoordinator.cs +++ b/Assets/Integration/TwoPlayerGameCoordinator.cs @@ -66,6 +66,7 @@ public class TwoPlayerGameCoordinator : MonoBehaviour #region Private State private bool _twoPlayer; + private bool _resultsShown; private InputSystem_Actions _resultInputActions; private SynthwaveBackgroundController _p2BackgroundCtrl; private Action _p2BeatHandler; @@ -153,6 +154,11 @@ private void OnP1SongScheduled(double dspStart) private void OnSongFinished(RhythmScoreTracker score) { + // Song-complete can fire more than once if Tick keeps running; show results only once. + if (_resultsShown) + return; + _resultsShown = true; + ShowResultsAsync(score).Forget(); } @@ -206,6 +212,17 @@ private async UniTaskVoid ShowResultsAsync(RhythmScoreTracker p1Score) _p1Controller?.DisablePlayerInput(); if (_twoPlayer) _p2Controller?.DisablePlayerInput(); + // Freeze placement recording immediately so late inputs cannot append after song end. + _p1Controller?.RunTracker?.FinishLevel(); + if (_twoPlayer) + _p2Controller?.RunTracker?.FinishLevel(); + + // Bind each player's run tracker before ShowAsync so QR textures are ready + // as soon as the stats panel animates in (no empty-QR flash). + _resultScreen.SetTreeRunTracker(_p1Controller != null ? _p1Controller.RunTracker : null); + if (_twoPlayer && _p2ResultScreen != null) + _p2ResultScreen.SetTreeRunTracker(_p2Controller != null ? _p2Controller.RunTracker : null); + _resultInputActions ??= new InputSystem_Actions(); _resultInputActions.Enable(); var p1Input = new UnityMenuInputProvider(_resultInputActions.Menu1); diff --git a/Assets/Plugins/ZXing.meta b/Assets/Plugins/ZXing.meta new file mode 100644 index 0000000..8b14136 --- /dev/null +++ b/Assets/Plugins/ZXing.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b2c3d4e5f6789012345678abcdef0123 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/ZXing/README.txt b/Assets/Plugins/ZXing/README.txt new file mode 100644 index 0000000..9d6d78a --- /dev/null +++ b/Assets/Plugins/ZXing/README.txt @@ -0,0 +1,6 @@ +ZXing.Net 0.16.9 (netstandard2.0) +https://github.com/micjahn/ZXing.Net +Apache License 2.0 + +Vendored as Assets/Plugins/ZXing/zxing.dll for runtime QR encoding +used by TreeQrCodeGenerator / TreeResultQrDisplay. diff --git a/Assets/Plugins/ZXing/README.txt.meta b/Assets/Plugins/ZXing/README.txt.meta new file mode 100644 index 0000000..5a1f7c8 --- /dev/null +++ b/Assets/Plugins/ZXing/README.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c0432345b11e94aeeb100c67dd021b95 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/ZXing/zxing.dll b/Assets/Plugins/ZXing/zxing.dll new file mode 100644 index 0000000..f5d1352 Binary files /dev/null and b/Assets/Plugins/ZXing/zxing.dll differ diff --git a/Assets/Plugins/ZXing/zxing.dll.meta b/Assets/Plugins/ZXing/zxing.dll.meta new file mode 100644 index 0000000..3a4225a --- /dev/null +++ b/Assets/Plugins/ZXing/zxing.dll.meta @@ -0,0 +1,27 @@ +fileFormatVersion: 2 +guid: 8f3c1a2b4d5e6f708192a3b4c5d6e7f8 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scenes/GameScene.unity b/Assets/Scenes/GameScene.unity index 3c1b330..1488ab9 100644 --- a/Assets/Scenes/GameScene.unity +++ b/Assets/Scenes/GameScene.unity @@ -4069,7 +4069,9 @@ MonoBehaviour: _edgeContainer: {fileID: 141288317} _errorCountLabel: {fileID: 1625478451} _scoreHud: {fileID: 1606661210} - _sampleTreeProvider: {fileID: 11400000, guid: 5e91a1578115841178d8c06c0f639514, type: 2} + _sampleTreeProvider: {fileID: 11400000, guid: 5e742b1b425de4606ad22578dcb5452d, type: 2} + _runTracker: {fileID: 0} + _treeShareConfig: {fileID: 11400000, guid: 9c4e2a1b8d7f6543210abcdef1234567, type: 2} _generatorConfig: {fileID: 11400000, guid: 3f9e78716f93749c5852fd5d64d06511, type: 2} _treeLayerName: Player1 _previewNodePrefab: {fileID: 1000000001, guid: a2b3c4d5e6f748a9b0c1d2e3f4a5b6c7, type: 3} @@ -6642,7 +6644,9 @@ MonoBehaviour: _edgeContainer: {fileID: 996378942} _errorCountLabel: {fileID: 0} _scoreHud: {fileID: 435648780} - _sampleTreeProvider: {fileID: 11400000, guid: 5e91a1578115841178d8c06c0f639514, type: 2} + _sampleTreeProvider: {fileID: 11400000, guid: 5e742b1b425de4606ad22578dcb5452d, type: 2} + _runTracker: {fileID: 0} + _treeShareConfig: {fileID: 11400000, guid: 9c4e2a1b8d7f6543210abcdef1234567, type: 2} _generatorConfig: {fileID: 11400000, guid: 3f9e78716f93749c5852fd5d64d06511, type: 2} _treeLayerName: Player2 _previewNodePrefab: {fileID: 1000000001, guid: a2b3c4d5e6f748a9b0c1d2e3f4a5b6c7, type: 3} diff --git a/Assets/ScriptableObjects/Configs/TreeShareConfig.asset b/Assets/ScriptableObjects/Configs/TreeShareConfig.asset new file mode 100644 index 0000000..968359e --- /dev/null +++ b/Assets/ScriptableObjects/Configs/TreeShareConfig.asset @@ -0,0 +1,17 @@ +%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: 12b30bbd212e049eb9d2fa93b78b1a36, type: 3} + m_Name: TreeShareConfig + m_EditorClassIdentifier: + _viewerBaseUrl: https://seemytree.netlify.app/view + _startParamName: start + _placedParamName: placed diff --git a/Assets/ScriptableObjects/Configs/TreeShareConfig.asset.meta b/Assets/ScriptableObjects/Configs/TreeShareConfig.asset.meta new file mode 100644 index 0000000..a3bd5dc --- /dev/null +++ b/Assets/ScriptableObjects/Configs/TreeShareConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9c4e2a1b8d7f6543210abcdef1234567 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/UI/Style1/ResultScreenController.cs b/Assets/UI/Style1/ResultScreenController.cs index 3079c47..630ae58 100644 --- a/Assets/UI/Style1/ResultScreenController.cs +++ b/Assets/UI/Style1/ResultScreenController.cs @@ -34,6 +34,7 @@ public class ResultScreenController : MonoBehaviour private RectTransform _indicator; private RectTransform _continueBtnRect; private RectTransform _quitBtnRect; + private TreeResultQrDisplay _treeQrDisplay; // Hit distribution bar fills — animated vertically proportional to hit counts private RectTransform[] _hitsBarFills; @@ -109,7 +110,8 @@ public void SetRefs( 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) + RectTransform indicator, RectTransform continueBtnRect, RectTransform quitBtnRect, + TreeResultQrDisplay treeQrDisplay = null) { _heroLabel = hero; _subLabel = sub; @@ -127,6 +129,19 @@ public void SetRefs( _indicator = indicator; _continueBtnRect = continueBtnRect; _quitBtnRect = quitBtnRect; + _treeQrDisplay = treeQrDisplay; + } + + /** + * Binds the per-player used to build the share QR. + * Call before so the texture is ready when the panel rises. + * + */ + public void SetTreeRunTracker(TreeRunTracker tracker) + { + if (_treeQrDisplay == null) + _treeQrDisplay = GetComponent(); + _treeQrDisplay?.SetTracker(tracker); } public void SetInputProvider(IMenuInputProvider input) @@ -176,6 +191,9 @@ private void GatherRefs() transform.Find("StatsPanel/Fill/HitsRow/OkCol/BarContainer/BarFill")?.GetComponent(), transform.Find("StatsPanel/Fill/HitsRow/MissCol/BarContainer/BarFill")?.GetComponent(), }; + + if (_treeQrDisplay == null) + _treeQrDisplay = GetComponent(); } private void OnDestroy() @@ -201,6 +219,19 @@ public async UniTask ShowAsync(RhythmScoreTracker score, IMenuInputProvider inpu // Pre-initialize all animated elements before the backdrop becomes opaque so that // placeholder text ("S" grade, "100%" accuracy) and default positions never flash. PopulateStats(score); + + // Build the tree-share QR before any fade/slide so the panel never shows an empty QR slot. + if (_treeQrDisplay == null) + _treeQrDisplay = GetComponent(); + try + { + _treeQrDisplay?.PrepareResultQrCode(); + } + catch (Exception ex) + { + Debug.LogError($"[ResultScreenController] QR prepare failed (stats still shown): {ex.Message}"); + } + if (_gradeLabel != null) _gradeLabel.transform.localScale = Vector3.zero; if (_heroLabel != null) _heroLabel.transform.localPosition = new Vector3(0f, 700f, 0f); if (_statsPanel != null) _statsPanel.transform.localPosition = new Vector3(0f, -1500f, 0f); diff --git a/Assets/UI/Style1/ResultScreenLayoutBuilder.cs b/Assets/UI/Style1/ResultScreenLayoutBuilder.cs index 527d964..9fe8787 100644 --- a/Assets/UI/Style1/ResultScreenLayoutBuilder.cs +++ b/Assets/UI/Style1/ResultScreenLayoutBuilder.cs @@ -8,13 +8,12 @@ * ResultScreen CanvasGroup, fullscreen * Backdrop dark semi-transparent fill * Banner "TREE COMPLETE!" hero text + star burst - * StatsPanel Style1 card — grade, score, accuracy, hit counts + * StatsPanel Style1 card — grade, score, accuracy, hits + tree QR share * Fill * GradeLabel - * ScoreRow "SCORE" / value - * AccuracyRow "ACCURACY" / value - * HitsRow perfect · good · ok · miss columns - * ComboRow "BEST COMBO" / value + * ScoreRow / AccuracyRow / HitsRow / ComboRow + * TreeShareSection + * Title / QrCodeRawImage * ButtonRow * ContinueButton → SongSelect * QuitButton → TitleScreen @@ -74,49 +73,54 @@ public static void Build(Transform canvasRoot, out ResultScreenController contro subRect.offsetMin = Vector2.zero; subRect.offsetMax = Vector2.zero; - // Stats panel — card spanning middle 40% of screen + // Stats panel — card spanning middle band of screen var statsCard = CardShell("StatsPanel", root.transform); var statsCardRect = statsCard.GetComponent(); - statsCardRect.anchorMin = new Vector2(0.12f, 0.24f); - statsCardRect.anchorMax = new Vector2(0.88f, 0.73f); + statsCardRect.anchorMin = new Vector2(0.08f, 0.24f); + statsCardRect.anchorMax = new Vector2(0.92f, 0.73f); statsCardRect.offsetMin = Vector2.zero; statsCardRect.offsetMax = Vector2.zero; Transform statsFill = statsCard.transform.Find("Fill"); - // Grade label — large letter in left 30% of stats fill + // Grade label — large letter in left ~18% of stats fill var gradeLabel = MakeText("GradeLabel", statsFill, "S", Style1Typography.Role.Score, Style1Palette.PerfectPopup, TextAlignmentOptions.Center); gradeLabel.GetComponent().fontSize = 80f; var gradeRect = gradeLabel.GetComponent(); gradeRect.anchorMin = new Vector2(0f, 0f); - gradeRect.anchorMax = new Vector2(0.28f, 1f); + gradeRect.anchorMax = new Vector2(0.18f, 1f); gradeRect.offsetMin = new Vector2(8f, 8f); - gradeRect.offsetMax = new Vector2(-8f, -8f); + gradeRect.offsetMax = new Vector2(-4f, -8f); - // Vertical divider + // Vertical divider between grade and performance stats var divider = MakeImage("GradeDivider", statsFill, Style1Palette.Border); var divRect = divider.GetComponent(); - divRect.anchorMin = new Vector2(0.29f, 0.1f); - divRect.anchorMax = new Vector2(0.30f, 0.9f); + divRect.anchorMin = new Vector2(0.185f, 0.1f); + divRect.anchorMax = new Vector2(0.195f, 0.9f); divRect.offsetMin = Vector2.zero; divRect.offsetMax = Vector2.zero; - // Right column: 5 rows - // Row heights from top: score, accuracy, divider, hit counts, combo - BuildStatRow("ScoreRow", statsFill, "SCORE", "0", 0.78f, 0.98f, out _, out _); - BuildStatRow("AccuracyRow", statsFill, "ACCURACY", "100%", 0.56f, 0.76f, out _, out _); + // Middle column: performance rows (score / accuracy / hits / combo) + BuildStatRow("ScoreRow", statsFill, "SCORE", "0", 0.78f, 0.98f, + 0.21f, 0.58f, out _, out _); + BuildStatRow("AccuracyRow", statsFill, "ACCURACY", "100%", 0.56f, 0.76f, + 0.21f, 0.58f, out _, out _); var midDivider = MakeImage("MidDivider", statsFill, new Color(Style1Palette.Label.r, Style1Palette.Label.g, Style1Palette.Label.b, 0.18f)); var midDivRect = midDivider.GetComponent(); - midDivRect.anchorMin = new Vector2(0.32f, 0.52f); - midDivRect.anchorMax = new Vector2(0.98f, 0.53f); + midDivRect.anchorMin = new Vector2(0.21f, 0.52f); + midDivRect.anchorMax = new Vector2(0.58f, 0.53f); midDivRect.offsetMin = Vector2.zero; midDivRect.offsetMax = Vector2.zero; - BuildHitsRow("HitsRow", statsFill, 0.27f, 0.52f); - BuildStatRow("ComboRow", statsFill, "BEST COMBO", "0", 0.04f, 0.26f, out _, out _); + BuildHitsRow("HitsRow", statsFill, 0.27f, 0.52f, 0.21f, 0.58f); + BuildStatRow("ComboRow", statsFill, "BEST COMBO", "0", 0.04f, 0.26f, + 0.21f, 0.58f, out _, out _); + + // Right column: tree-share QR (square) + BuildTreeShareSection(statsFill, out RawImage qrRawImage); // Button row — bottom 20% of screen var btnRow = new GameObject("ButtonRow", typeof(RectTransform)); @@ -173,8 +177,10 @@ public static void Build(Transform canvasRoot, out ResultScreenController contro indImg.pixelsPerUnitMultiplier = 1f; indicator.transform.SetAsLastSibling(); - // Attach controller + // Attach controllers controller = root.AddComponent(); + var qrDisplay = root.AddComponent(); + qrDisplay.SetUiRefs(qrRawImage, instructionLabel: null); // Expose child refs via controller controller.SetRefs( @@ -192,21 +198,68 @@ public static void Build(Transform canvasRoot, out ResultScreenController contro continueBtn.GetComponent(), quitBtn.GetComponent(), indicator.GetComponent(), - cbr, qbr + cbr, qbr, + qrDisplay ); } + /** + * Right-hand share column: title + square QR RawImage. + * AspectRatioFitter keeps the QR square across aspect ratios. + * + */ + private static void BuildTreeShareSection(Transform statsFill, out RawImage qrRawImage) + { + var section = new GameObject("TreeShareSection", typeof(RectTransform)); + section.layer = 5; + section.transform.SetParent(statsFill, false); + var sectionRect = section.GetComponent(); + sectionRect.anchorMin = new Vector2(0.60f, 0.04f); + sectionRect.anchorMax = new Vector2(0.98f, 0.96f); + sectionRect.offsetMin = Vector2.zero; + sectionRect.offsetMax = Vector2.zero; + + var title = MakeText("TreeShareTitle", section.transform, "YOUR TREE", + Style1Typography.Role.Label, Style1Palette.Label, TextAlignmentOptions.Center); + var titleRect = title.GetComponent(); + titleRect.anchorMin = new Vector2(0f, 0.88f); + titleRect.anchorMax = new Vector2(1f, 1f); + titleRect.offsetMin = Vector2.zero; + titleRect.offsetMax = Vector2.zero; + + // Square QR host — FitInParent + 1:1 aspect keeps modules square + var qrHost = new GameObject("QrCodeRawImage", + typeof(RectTransform), typeof(CanvasRenderer), typeof(RawImage), typeof(AspectRatioFitter)); + qrHost.layer = 5; + qrHost.transform.SetParent(section.transform, false); + var qrRect = qrHost.GetComponent(); + qrRect.anchorMin = new Vector2(0.06f, 0.06f); + qrRect.anchorMax = new Vector2(0.94f, 0.86f); + qrRect.offsetMin = Vector2.zero; + qrRect.offsetMax = Vector2.zero; + + var aspect = qrHost.GetComponent(); + aspect.aspectMode = AspectRatioFitter.AspectMode.FitInParent; + aspect.aspectRatio = 1f; + + qrRawImage = qrHost.GetComponent(); + qrRawImage.color = Color.white; + qrRawImage.raycastTarget = false; + qrRawImage.enabled = false; // filled by TreeResultQrDisplay before reveal + } + private static void BuildStatRow(string name, Transform parent, string labelText, string valueText, float anchorYMin, float anchorYMax, + float anchorXMin, float anchorXMax, out TMP_Text labelOut, out TMP_Text valueOut) { var row = new GameObject(name, typeof(RectTransform)); row.layer = 5; row.transform.SetParent(parent, false); var rowRect = row.GetComponent(); - rowRect.anchorMin = new Vector2(0.32f, anchorYMin); - rowRect.anchorMax = new Vector2(0.98f, anchorYMax); + rowRect.anchorMin = new Vector2(anchorXMin, anchorYMin); + rowRect.anchorMax = new Vector2(anchorXMax, anchorYMax); rowRect.offsetMin = Vector2.zero; rowRect.offsetMax = Vector2.zero; @@ -230,14 +283,15 @@ private static void BuildStatRow(string name, Transform parent, valueOut = val.GetComponent(); } - private static void BuildHitsRow(string name, Transform parent, float anchorYMin, float anchorYMax) + private static void BuildHitsRow(string name, Transform parent, + float anchorYMin, float anchorYMax, float anchorXMin, float anchorXMax) { var row = new GameObject(name, typeof(RectTransform)); row.layer = 5; row.transform.SetParent(parent, false); var rowRect = row.GetComponent(); - rowRect.anchorMin = new Vector2(0.32f, anchorYMin); - rowRect.anchorMax = new Vector2(0.98f, anchorYMax); + rowRect.anchorMin = new Vector2(anchorXMin, anchorYMin); + rowRect.anchorMax = new Vector2(anchorXMax, anchorYMax); rowRect.offsetMin = Vector2.zero; rowRect.offsetMax = Vector2.zero; @@ -263,7 +317,6 @@ private static void BuildHitsRow(string name, Transform parent, float anchorYMin cr.offsetMin = new Vector2(2f, 0f); cr.offsetMax = new Vector2(-2f, 0f); - // Label: top 28% var colLabel = MakeText("ColLabel", col.transform, defs[i].label, Style1Typography.Role.Label, defs[i].color, TextAlignmentOptions.Center); var clr = colLabel.GetComponent(); @@ -272,7 +325,6 @@ private static void BuildHitsRow(string name, Transform parent, float anchorYMin clr.offsetMin = Vector2.zero; clr.offsetMax = Vector2.zero; - // Bar container: middle section var barContainer = new GameObject("BarContainer", typeof(RectTransform)); barContainer.layer = 5; barContainer.transform.SetParent(col.transform, false); @@ -282,21 +334,18 @@ private static void BuildHitsRow(string name, Transform parent, float anchorYMin bcr.offsetMin = Vector2.zero; bcr.offsetMax = Vector2.zero; - // Bar background var barBg = MakeImage("BarBg", barContainer.transform, Style1Palette.Background); var bbrct = barBg.GetComponent(); bbrct.anchorMin = Vector2.zero; bbrct.anchorMax = Vector2.one; bbrct.offsetMin = bbrct.offsetMax = Vector2.zero; - // Bar fill: starts at zero height, anchored at bottom — animated upward var barFill = MakeImage("BarFill", barContainer.transform, defs[i].color); var bfrct = barFill.GetComponent(); bfrct.anchorMin = new Vector2(0f, 0f); - bfrct.anchorMax = new Vector2(1f, 0f); // zero height initially + bfrct.anchorMax = new Vector2(1f, 0f); bfrct.offsetMin = bfrct.offsetMax = Vector2.zero; - // Value: bottom 28% var colVal = MakeText("Value", col.transform, "0", Style1Typography.Role.Body, Style1Palette.Score, TextAlignmentOptions.Center); var cvr = colVal.GetComponent();