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