diff --git a/.DS_Store b/.DS_Store
index d0f58a9..74c5385 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/.idea/.idea.Colorbook/.idea/discord.xml b/.idea/.idea.Colorbook/.idea/discord.xml
new file mode 100644
index 0000000..d8e9561
--- /dev/null
+++ b/.idea/.idea.Colorbook/.idea/discord.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Assets/_Recovery.meta b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial.meta
similarity index 77%
rename from Assets/_Recovery.meta
rename to Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial.meta
index e234baf..25c4bed 100644
--- a/Assets/_Recovery.meta
+++ b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: d63726aaa9398f341a1a923bc039c446
+guid: fac6898f446df4a8e994fa47eed10068
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialGate.cs b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialGate.cs
new file mode 100644
index 0000000..413180c
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialGate.cs
@@ -0,0 +1,14 @@
+namespace Darkmatter.Core.Contracts.Features.Tutorial
+{
+ ///
+ /// Decides whether the one-time tutorial should run, and records that it has been completed.
+ ///
+ public interface ITutorialGate
+ {
+ /// True only for a genuine first-time player who has not finished the tutorial.
+ bool ShouldRun { get; }
+
+ /// Persist that the tutorial is done so it never runs again.
+ void MarkCompleted();
+ }
+}
diff --git a/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialGate.cs.meta b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialGate.cs.meta
new file mode 100644
index 0000000..5aabae7
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialGate.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e7c2fc9f6a9354a068fdc7d9ed5aa4ad
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialOverlay.cs b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialOverlay.cs
new file mode 100644
index 0000000..9f49511
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialOverlay.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using Cysharp.Threading.Tasks;
+using UnityEngine;
+
+namespace Darkmatter.Core.Contracts.Features.Tutorial
+{
+ ///
+ /// Full-screen guidance overlay: a dim frame with a cut-out "hole" over a target element,
+ /// an animated hand + halo, and an instruction bubble. Lives on a persistent root-scoped
+ /// Canvas so it survives scene swaps (mirrors the loading screen).
+ ///
+ public interface ITutorialOverlay
+ {
+ /// True once the view is initialised and safe to drive.
+ bool IsReady { get; }
+
+ ///
+ /// Spotlight a single tap target. When is null the dim/hole are
+ /// skipped and only a centered bubble is shown (a non-blocking hint).
+ /// When is true every touch outside the hole is swallowed.
+ ///
+ void ShowTap(RectTransform target, string message, bool blockInput);
+
+ ///
+ /// Spotlight a drag gesture from to . Input is
+ /// never blocked here, so the dragged piece can render above the dim.
+ ///
+ void ShowDrag(RectTransform from, RectTransform to, string message);
+
+ /// Hide everything immediately (used across scene swaps).
+ void HideInstant();
+
+ /// Show a centered celebratory bubble for a short beat, then hide.
+ UniTask ShowToastAsync(string message, CancellationToken ct);
+ }
+}
diff --git a/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialOverlay.cs.meta b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialOverlay.cs.meta
new file mode 100644
index 0000000..9fb2133
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Contracts/Features/Tutorial/ITutorialOverlay.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1fae7a22d89c64857af91268a55bd564
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/Coloring/ColorSelectedSignal.cs b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Coloring/ColorSelectedSignal.cs
new file mode 100644
index 0000000..4790810
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Coloring/ColorSelectedSignal.cs
@@ -0,0 +1,6 @@
+using UnityEngine;
+
+namespace Darkmatter.Core.Data.Signals.Features.Coloring
+{
+ public record struct ColorSelectedSignal(int Index, Color Color);
+}
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/Coloring/ColorSelectedSignal.cs.meta b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Coloring/ColorSelectedSignal.cs.meta
new file mode 100644
index 0000000..7874b34
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Coloring/ColorSelectedSignal.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: df1823d5d5df7469781b42466c308ecc
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/DrawingCatalog/DrawingCatalogReadySignal.cs b/Assets/Darkmatter/Code/Core/Data/Signals/Features/DrawingCatalog/DrawingCatalogReadySignal.cs
new file mode 100644
index 0000000..a926a38
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/DrawingCatalog/DrawingCatalogReadySignal.cs
@@ -0,0 +1,6 @@
+namespace Darkmatter.Core
+{
+ // Published by the catalog presenter once the drawing grid is populated and on screen.
+ // Namespace matches the sibling OpenColorBookSignal so existing catalog code needs no new using.
+ public record struct DrawingCatalogReadySignal;
+}
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/DrawingCatalog/DrawingCatalogReadySignal.cs.meta b/Assets/Darkmatter/Code/Core/Data/Signals/Features/DrawingCatalog/DrawingCatalogReadySignal.cs.meta
new file mode 100644
index 0000000..c39fe69
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/DrawingCatalog/DrawingCatalogReadySignal.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e71fd9bc94a644ced8b0a141f058291b
\ No newline at end of file
diff --git a/Assets/_Recovery/0 (2).unity.meta b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial.meta
similarity index 67%
rename from Assets/_Recovery/0 (2).unity.meta
rename to Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial.meta
index c286e78..77c18d2 100644
--- a/Assets/_Recovery/0 (2).unity.meta
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial.meta
@@ -1,5 +1,6 @@
fileFormatVersion: 2
-guid: 3509e33d6780510448b1f27a6ad6b84f
+guid: 3aee861659cfe43c4b48d513479c2d7d
+folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialCompletedSignal.cs b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialCompletedSignal.cs
new file mode 100644
index 0000000..d533c99
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialCompletedSignal.cs
@@ -0,0 +1,4 @@
+namespace Darkmatter.Core.Data.Signals.Features.Tutorial
+{
+ public record struct TutorialCompletedSignal;
+}
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialCompletedSignal.cs.meta b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialCompletedSignal.cs.meta
new file mode 100644
index 0000000..98962eb
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialCompletedSignal.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e3b50d1afc8134a0f8ae92b6c0590bdb
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStartedSignal.cs b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStartedSignal.cs
new file mode 100644
index 0000000..dcd05ed
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStartedSignal.cs
@@ -0,0 +1,4 @@
+namespace Darkmatter.Core.Data.Signals.Features.Tutorial
+{
+ public record struct TutorialStartedSignal;
+}
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStartedSignal.cs.meta b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStartedSignal.cs.meta
new file mode 100644
index 0000000..8157c8f
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStartedSignal.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3aeb975bb1d6a45aca5c86ddf72bee22
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStepCompletedSignal.cs b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStepCompletedSignal.cs
new file mode 100644
index 0000000..76f38f0
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStepCompletedSignal.cs
@@ -0,0 +1,4 @@
+namespace Darkmatter.Core.Data.Signals.Features.Tutorial
+{
+ public record struct TutorialStepCompletedSignal(string StepId, int StepIndex);
+}
diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStepCompletedSignal.cs.meta b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStepCompletedSignal.cs.meta
new file mode 100644
index 0000000..6f432b8
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/Tutorial/TutorialStepCompletedSignal.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 11b2e1b23836f4664be3a84389c1c364
\ No newline at end of file
diff --git a/Assets/_Recovery/0 (1).unity.meta b/Assets/Darkmatter/Code/Core/Data/Static/Features/Tutorial.meta
similarity index 67%
rename from Assets/_Recovery/0 (1).unity.meta
rename to Assets/Darkmatter/Code/Core/Data/Static/Features/Tutorial.meta
index df6669f..4f270c4 100644
--- a/Assets/_Recovery/0 (1).unity.meta
+++ b/Assets/Darkmatter/Code/Core/Data/Static/Features/Tutorial.meta
@@ -1,5 +1,6 @@
fileFormatVersion: 2
-guid: 147272ca7efe70f41b90c3b9abf1597e
+guid: 1cda3e3840cbd4eccbe7ad810dee920d
+folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
diff --git a/Assets/Darkmatter/Code/Core/Data/Static/Features/Tutorial/TutorialStepsConfig.cs b/Assets/Darkmatter/Code/Core/Data/Static/Features/Tutorial/TutorialStepsConfig.cs
new file mode 100644
index 0000000..c7259aa
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Static/Features/Tutorial/TutorialStepsConfig.cs
@@ -0,0 +1,43 @@
+using UnityEngine;
+
+namespace Darkmatter.Core.Data.Static.Features.Tutorial
+{
+ ///
+ /// Designer-tunable copy and timing for the one-time onboarding tutorial. The step *sequence*
+ /// is fixed in TutorialDirector; only the wording, the spotlight padding and the per-step
+ /// watchdog timeout live here.
+ ///
+ [CreateAssetMenu(fileName = "TutorialStepsConfig",
+ menuName = "Darkmatter/Tutorial/Steps Config")]
+ public sealed class TutorialStepsConfig : ScriptableObject
+ {
+ [Header("Step copy (kept short for young readers)")]
+ [SerializeField] private string pickText = "Pick a picture to start!";
+ [SerializeField] private string dragText = "Drag the piece to its spot!";
+ [SerializeField] private string finishText = "Now finish the puzzle!";
+ [SerializeField] private string colorText = "Choose a color!";
+ [SerializeField] private string paintText = "Tap the picture to color it!";
+ [SerializeField] private string nextText = "Tap Next when you're done!";
+ [SerializeField] private string doneText = "Yay! You did it!";
+
+ [Header("Watchdog")]
+ [Tooltip("Timeout (s) while waiting for a SYSTEM precondition (scene/catalog/regions ready). If it " +
+ "doesn't arrive the tutorial fails open so the player is never trapped.")]
+ [SerializeField] private float stepTimeoutSeconds = 45f;
+
+ [Tooltip("Timeout (s) while waiting for the CHILD's own action (drag/tap/paint). 0 = no timeout: " +
+ "the hint stays until they do it. Set a large value if you want a safety net instead.")]
+ [SerializeField] private float actionTimeoutSeconds;
+
+ public string PickText => pickText;
+ public string DragText => dragText;
+ public string FinishText => finishText;
+ public string ColorText => colorText;
+ public string PaintText => paintText;
+ public string NextText => nextText;
+ public string DoneText => doneText;
+
+ public float StepTimeoutSeconds => stepTimeoutSeconds;
+ public float ActionTimeoutSeconds => actionTimeoutSeconds;
+ }
+}
diff --git a/Assets/Darkmatter/Code/Core/Data/Static/Features/Tutorial/TutorialStepsConfig.cs.meta b/Assets/Darkmatter/Code/Core/Data/Static/Features/Tutorial/TutorialStepsConfig.cs.meta
new file mode 100644
index 0000000..0c1aaa7
--- /dev/null
+++ b/Assets/Darkmatter/Code/Core/Data/Static/Features/Tutorial/TutorialStepsConfig.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5c513430cc85f4357b3df1da019bf554
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Features/Coloring/Systems/ColoringStateRepository.cs b/Assets/Darkmatter/Code/Features/Coloring/Systems/ColoringStateRepository.cs
index e42d3ce..6ce1909 100644
--- a/Assets/Darkmatter/Code/Features/Coloring/Systems/ColoringStateRepository.cs
+++ b/Assets/Darkmatter/Code/Features/Coloring/Systems/ColoringStateRepository.cs
@@ -1,11 +1,20 @@
using System;
using Darkmatter.Core.Contracts.Features.Coloring;
+using Darkmatter.Core.Data.Signals.Features.Coloring;
+using Darkmatter.Libs.Observer;
using UnityEngine;
namespace Darkmatter.Features.Coloring.Systems;
public class ColoringStateRepository
{
+ private readonly IEventBus _bus;
+
+ public ColoringStateRepository(IEventBus bus)
+ {
+ _bus = bus;
+ }
+
public IColorPalette SelectedPalette { get; private set; }
public int SelectedIndex { get; private set; }
public Color SelectedColor =>
@@ -29,5 +38,9 @@ public class ColoringStateRepository
if (idx < 0) return;
SelectedIndex = idx;
SelectedIndexChanged?.Invoke();
+ // Surface the user's colour pick on the bus so the tutorial (a root-scoped service that
+ // can't reach this scene-scoped repository directly) can detect it. Only fires on an actual
+ // SelectColor — not on the default selection made in SetPalette.
+ _bus.Publish(new ColorSelectedSignal(idx, color));
}
}
diff --git a/Assets/Darkmatter/Code/Features/DrawingCatalog/UI/DrawingCatalogPresenter.cs b/Assets/Darkmatter/Code/Features/DrawingCatalog/UI/DrawingCatalogPresenter.cs
index 34982ba..56f3559 100644
--- a/Assets/Darkmatter/Code/Features/DrawingCatalog/UI/DrawingCatalogPresenter.cs
+++ b/Assets/Darkmatter/Code/Features/DrawingCatalog/UI/DrawingCatalogPresenter.cs
@@ -122,6 +122,9 @@ namespace Darkmatter.Features.DrawingCatalog
// Unblock InitializeAsync: items are now on screen, so the loading screen can hide.
_controller.NotifyPopulated();
+
+ // Cue the first-run tutorial that the catalog grid is on screen and tappable.
+ _eventBus.Publish(new DrawingCatalogReadySignal());
}
public void Dispose()
diff --git a/Assets/_Recovery/0.unity.meta b/Assets/Darkmatter/Code/Features/Tutorial.meta
similarity index 67%
rename from Assets/_Recovery/0.unity.meta
rename to Assets/Darkmatter/Code/Features/Tutorial.meta
index 06fabe8..ad9dda3 100644
--- a/Assets/_Recovery/0.unity.meta
+++ b/Assets/Darkmatter/Code/Features/Tutorial.meta
@@ -1,5 +1,6 @@
fileFormatVersion: 2
-guid: 8f948c844c48b42479fbc2aea5142bc1
+guid: 5913ea683733a4597b3cf6b3903811a0
+folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Editor.meta b/Assets/Darkmatter/Code/Features/Tutorial/Editor.meta
new file mode 100644
index 0000000..986a9ff
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d69b43c419c8d4f13b930f1dc30359ff
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Editor/TutorialDebugMenu.cs b/Assets/Darkmatter/Code/Features/Tutorial/Editor/TutorialDebugMenu.cs
new file mode 100644
index 0000000..3f6c23c
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Editor/TutorialDebugMenu.cs
@@ -0,0 +1,31 @@
+#if UNITY_EDITOR
+using Darkmatter.Features.Tutorial.Systems;
+using Darkmatter.Libs.PlayerPrefs;
+using UnityEditor;
+using UnityEngine;
+
+namespace Darkmatter.Features.Tutorial.Editor
+{
+ ///
+ /// QA helpers for the forced-once tutorial (editor only — stripped from player builds).
+ ///
+ public static class TutorialDebugMenu
+ {
+ [MenuItem("Tools/Darkmatter/Tutorial/Reset (run again next launch)")]
+ public static void Reset()
+ {
+ ProtectedPlayerPrefs.SetBool(TutorialGateService.CompletedKey, false);
+ ProtectedPlayerPrefs.Save();
+ Debug.Log("[Tutorial] Reset. Note: it also requires zero completed drawings to run.");
+ }
+
+ [MenuItem("Tools/Darkmatter/Tutorial/Mark Completed (skip)")]
+ public static void MarkCompleted()
+ {
+ ProtectedPlayerPrefs.SetBool(TutorialGateService.CompletedKey, true);
+ ProtectedPlayerPrefs.Save();
+ Debug.Log("[Tutorial] Marked completed — it will not run.");
+ }
+ }
+}
+#endif
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Editor/TutorialDebugMenu.cs.meta b/Assets/Darkmatter/Code/Features/Tutorial/Editor/TutorialDebugMenu.cs.meta
new file mode 100644
index 0000000..bbf8e1c
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Editor/TutorialDebugMenu.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9a54326b3914a43ffa206b427980908e
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Features.Tutorial.asmdef b/Assets/Darkmatter/Code/Features/Tutorial/Features.Tutorial.asmdef
new file mode 100644
index 0000000..535a8c9
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Features.Tutorial.asmdef
@@ -0,0 +1,27 @@
+{
+ "name": "Features.Tutorial",
+ "rootNamespace": "Darkmatter.Features.Tutorial",
+ "references": [
+ "GUID:6a0a834eb41764f12ba55c3fb04a40cb",
+ "GUID:b4c9f7fbf1e144933a1797dc208ece5f",
+ "GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
+ "GUID:564d11c0820a9455c8821cd85e9d0fd1",
+ "GUID:2ca8c3a66565544118d3d52d3922933b",
+ "GUID:4cede189a43c349069c614e305683720",
+ "GUID:eb9b7ee4936ff42bebd83ca110182103",
+ "GUID:995166e584dda4ff98501f62b07aa9cb",
+ "GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
+ "GUID:f51ebe6a0ceec4240a699833d6309b23",
+ "GUID:80ecb87cae9c44d19824e70ea7229748",
+ "GUID:6055be8ebefd69e48b49212b09b47b2f"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Features.Tutorial.asmdef.meta b/Assets/Darkmatter/Code/Features/Tutorial/Features.Tutorial.asmdef.meta
new file mode 100644
index 0000000..ac92cef
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Features.Tutorial.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: a6beea6626ba14397b6be37b16032fb5
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Installers.meta b/Assets/Darkmatter/Code/Features/Tutorial/Installers.meta
new file mode 100644
index 0000000..88003b1
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Installers.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ed3d9a0f27b0a486ab8dfe44d39ce2f9
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Installers/TutorialFeatureModule.cs b/Assets/Darkmatter/Code/Features/Tutorial/Installers/TutorialFeatureModule.cs
new file mode 100644
index 0000000..9f2e56c
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Installers/TutorialFeatureModule.cs
@@ -0,0 +1,34 @@
+using Darkmatter.Core.Contracts.Features.Tutorial;
+using Darkmatter.Core.Data.Static.Features.Tutorial;
+using Darkmatter.Features.Tutorial.Systems;
+using Darkmatter.Features.Tutorial.UI;
+using Darkmatter.Libs.Installers;
+using UnityEngine;
+using VContainer;
+using VContainer.Unity;
+
+namespace Darkmatter.Features.Tutorial.Installers
+{
+ ///
+ /// Registers the tutorial in the Boot scene's RootLifetimeScope so the director, overlay and
+ /// gate are single instances that survive every Colorbook -> Gameplay scene swap (same pattern
+ /// as the loading screen). Drop this component on a GameObject in the Boot scene and add it to
+ /// RootLifetimeScope.serviceModules.
+ ///
+ public class TutorialFeatureModule : MonoBehaviour, IModule
+ {
+ [SerializeField] private TutorialOverlayView overlayView;
+ [SerializeField] private TutorialStepsConfig config;
+
+ public void Register(IContainerBuilder builder)
+ {
+ if (overlayView != null)
+ builder.RegisterComponent(overlayView);
+ if (config != null)
+ builder.RegisterInstance(config);
+
+ builder.Register(Lifetime.Singleton);
+ builder.RegisterEntryPoint();
+ }
+ }
+}
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Installers/TutorialFeatureModule.cs.meta b/Assets/Darkmatter/Code/Features/Tutorial/Installers/TutorialFeatureModule.cs.meta
new file mode 100644
index 0000000..4a52db1
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Installers/TutorialFeatureModule.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 97ce2c486cc8541d1ab1d83fda7f8eda
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/SETUP.md b/Assets/Darkmatter/Code/Features/Tutorial/SETUP.md
new file mode 100644
index 0000000..e45aae7
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/SETUP.md
@@ -0,0 +1,69 @@
+# Tutorial — Unity Editor setup
+
+All C# is in place. These steps are the GUI wiring the code can't do. The tutorial is **forced
+once**: a first-time player (no completed drawings, flag unset) is guided through pick → drag →
+finish → pick colour → paint → Next, with a watchdog that fails open if anything stalls.
+
+## 1. Create the config asset
+- Project window → right-click → **Create ▸ Darkmatter ▸ Tutorial ▸ Steps Config**.
+- Save as `Assets/Darkmatter/Data/Settings/Tutorial/TutorialStepsConfig.asset`.
+- Edit the step copy / watchdog timeout if desired.
+
+## 2. Overlay prefab — single-image circular cutout
+`Assets/Darkmatter/Content/Colorbook UI/Prefabs/UI/TutorialOverlayCanvas.prefab`. Target hierarchy:
+
+```
+TutorialOverlayCanvas Canvas (Overlay, Sort Order 5000) + CanvasScaler + GraphicRaycaster
+ + CanvasGroup + TutorialFeatureModule (overlayView + config assigned)
+ Area full-stretch RectTransform (pivot 0.5) + TutorialOverlayView
+ Dim ONE full-stretch black Image, raycast ON, + TutorialCutoutDim component
+ Halo ring sprite, raycast OFF
+ Hand hand sprite, raycast OFF
+ BubbleRoot
+ BubbleBg bubble sprite, raycast OFF
+ Text TMP_Text (Fredoka SemiBold), raycast OFF
+```
+
+The dim is a **single full-screen Image**; the `TutorialCutoutDim` component drives the
+`Darkmatter/TutorialCutout` shader to punch a soft **circular hole** over the target, and acts as a
+raycast filter so taps inside the hole reach the real button while everything else is blocked.
+
+`TutorialCutoutDim` fields: **Cutout Shader** = `Darkmatter/TutorialCutout`, **Dim Image** = the
+Dim's own Image. (It builds a material instance at runtime — no material asset needed.)
+
+`TutorialOverlayView` fields: Canvas, RootGroup (root CanvasGroup), **Cutout** (the Dim's
+TutorialCutoutDim), Halo, Hand, BubbleRoot, BubbleText.
+
+Sprites (optional polish) under `Assets/Darkmatter/Content/Colorbook UI/Sprites/Tutorial/`:
+ring for Halo, finger for Hand (turn **Preserve Aspect** on), bubble for BubbleBg.
+
+## 3. Put it in the Boot scene
+Open `Assets/Darkmatter/Scenes/Boot.unity`:
+1. Drag the **TutorialOverlayCanvas** prefab into the scene (persistent — Boot is never unloaded,
+ like the loading screen).
+2. Make sure its `TutorialFeatureModule` **Config** field points at `TutorialStepsConfig.asset`.
+3. Select the **RootLifetimeScope** GameObject and add the prefab's root (it carries the
+ `TutorialFeatureModule`) to the **Service Modules** array — same as every other root module.
+
+That's the whole wiring: assign config + drop in Boot + add to serviceModules.
+
+## 4. (Optional) register the prefs key for documentation
+The flag is stored via `ProtectedPlayerPrefs` under the key `Tutorial.Completed` (hashed, no
+registry validation — it already works). To list it in the editor for clarity, add it via
+**Tools ▸ Darkmatter ▸ PlayerPrefs Editor** (type Bool).
+
+## 5. Test
+- **Tools ▸ Darkmatter ▸ Tutorial ▸ Reset**, and clear progression (so there are no completed
+ drawings), then Play from **Boot**.
+- After the intro you should be guided through the whole loop. Relaunch → it must NOT reappear.
+- See the verification checklist in `/Users/darkmatter/.claude/plans/help-me-design-a-splendid-hearth.md`.
+
+## How it hooks in (no gameplay prefab changes needed)
+- The director (root singleton) arms on `IntroCompletedSignal`, then awaits existing gameplay
+ signals one per step. Spawned targets (catalog cell, piece, colour button, region, Next) are
+ found at runtime with `FindObjectsByType`, so no scene wiring of targets is required.
+- Two tiny signals were added for things Find can't observe: `DrawingCatalogReadySignal`
+ (catalog presenter) and `ColorSelectedSignal` (coloring state repository).
+- Input gating is the overlay CanvasGroup's `blocksRaycasts`: ON for blocking tap steps (dim
+ swallows touches, the empty hole passes through to the real button), OFF for the drag step so the
+ piece stays visible and draggable.
diff --git a/Assets/_Recovery/1.unity.meta b/Assets/Darkmatter/Code/Features/Tutorial/SETUP.md.meta
similarity index 62%
rename from Assets/_Recovery/1.unity.meta
rename to Assets/Darkmatter/Code/Features/Tutorial/SETUP.md.meta
index 8e68a60..8f6ae74 100644
--- a/Assets/_Recovery/1.unity.meta
+++ b/Assets/Darkmatter/Code/Features/Tutorial/SETUP.md.meta
@@ -1,6 +1,6 @@
fileFormatVersion: 2
-guid: 95b36b0e05811724fa9ff82f0e1b72ed
-DefaultImporter:
+guid: c9fcf6edd0d00433bb41f63ff3b4b1b7
+TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Systems.meta b/Assets/Darkmatter/Code/Features/Tutorial/Systems.meta
new file mode 100644
index 0000000..59ca1f7
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Systems.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: bc34be257ded14eacbf3059d20b0c758
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialDirector.cs b/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialDirector.cs
new file mode 100644
index 0000000..b1f5a89
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialDirector.cs
@@ -0,0 +1,374 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Cysharp.Threading.Tasks;
+using Darkmatter.Core; // DrawingCatalogReadySignal, OpenArtBook/ColorBook, ReturnToMainMenu
+using Darkmatter.Core.Contracts.Features.Tutorial;
+using Darkmatter.Core.Data.Signals.Features.AppBoot; // IntroCompletedSignal
+using Darkmatter.Core.Data.Signals.Features.Coloring; // ColorApplied/ColorSelected/RegionsInitialized
+using Darkmatter.Core.Data.Signals.Features.Drawing; // DrawingSelectedSignal
+using Darkmatter.Core.Data.Signals.Features.GameplayFlow; // DrawingCompletedSignal
+using Darkmatter.Core.Data.Signals.Features.ShapeBuilder; // ShapeBuilderStarted/PieceSnapped/ShapeAssembled
+using Darkmatter.Core.Data.Signals.Features.Tutorial;
+using Darkmatter.Core.Data.Static.Features.Tutorial;
+using Darkmatter.Features.Coloring.UI; // ColorButton, ColorRegionView
+using Darkmatter.Features.DrawingCatalog; // DrawingCatalogButton
+using Darkmatter.Features.GameplayFlow.UI; // NextButtonView
+using Darkmatter.Features.ShapeBuilder.UI; // ShapePiece, SlotMarker
+using Darkmatter.Libs.Observer;
+using UnityEngine;
+using UnityEngine.UI;
+using VContainer.Unity;
+
+namespace Darkmatter.Features.Tutorial.Systems
+{
+ ///
+ /// Drives the one-time, forced guided tutorial. A single root-scoped singleton that arms on
+ /// and walks a fixed, linear sequence by awaiting one gameplay
+ /// signal per step. Watchdog timeouts make every wait fail open so a child is never trapped.
+ ///
+ /// Navigation is handled so the overlay never gets stranded: opening the ArtBook or leaving to
+ /// the menu hides it (and re-shows the current step on return), and going Back to the catalog
+ /// mid-gameplay restarts the tutorial from step 1. A generation counter keeps a restarted run
+ /// from racing the cancelled one. Targets are found at runtime via FindObjectsByType.
+ ///
+ public sealed class TutorialDirector : IStartable, IDisposable
+ {
+ private readonly IEventBus _bus;
+ private readonly ITutorialOverlay _overlay;
+ private readonly ITutorialGate _gate;
+ private readonly TutorialStepsConfig _config;
+
+ private IDisposable _introSub;
+ private readonly List _navSubs = new();
+ private CancellationTokenSource _runCts;
+ private CancellationToken _ct;
+ private bool _completed;
+ private bool _suspended;
+ private Action _reshow;
+ private int _stepIndex;
+ private int _gen;
+
+ public TutorialDirector(IEventBus bus, ITutorialOverlay overlay, ITutorialGate gate, TutorialStepsConfig config)
+ {
+ _bus = bus;
+ _overlay = overlay;
+ _gate = gate;
+ _config = config;
+ }
+
+ public void Start()
+ {
+ _introSub = _bus.Subscribe(OnIntroCompleted);
+ }
+
+ private void OnIntroCompleted(IntroCompletedSignal _)
+ {
+ _introSub?.Dispose();
+ _introSub = null;
+
+ if (!_gate.ShouldRun) return;
+
+ // Run-lifetime navigation handling (persists across restarts).
+ _navSubs.Add(_bus.Subscribe(_ => Suspend()));
+ _navSubs.Add(_bus.Subscribe(_ => Suspend()));
+ _navSubs.Add(_bus.Subscribe(_ => Resume()));
+ _navSubs.Add(_bus.Subscribe(OnCatalogReadyGlobal));
+
+ StartRun(skipCatalogWait: false);
+ }
+
+ private void StartRun(bool skipCatalogWait)
+ {
+ _runCts?.Cancel();
+ _runCts?.Dispose();
+ _runCts = new CancellationTokenSource();
+ _ct = _runCts.Token;
+ _gen++;
+ _suspended = false;
+ _reshow = null;
+ _stepIndex = 0;
+ _overlay.HideInstant();
+ RunAsync(_gen, skipCatalogWait).Forget();
+ }
+
+ // Opening the ArtBook / leaving to menu: hide the overlay but keep awaiting the current step.
+ private void Suspend()
+ {
+ _suspended = true;
+ _overlay.HideInstant();
+ }
+
+ // Returning to the colour book (ArtBook closed): re-show the current step.
+ private void Resume()
+ {
+ if (!_suspended) return;
+ _suspended = false;
+ _reshow?.Invoke();
+ }
+
+ // The catalog re-appeared while we were mid-gameplay -> the player went Back. Restart from
+ // step 1 (the catalog is already on screen, so skip the wait). Excludes step 6+, whose own
+ // completion loads the catalog.
+ private void OnCatalogReadyGlobal(DrawingCatalogReadySignal _)
+ {
+ if (!_completed && _stepIndex >= 2 && _stepIndex <= 5)
+ StartRun(skipCatalogWait: true);
+ }
+
+ private void ShowStep(Action show)
+ {
+ _reshow = show;
+ if (!_suspended) show();
+ }
+
+ private async UniTaskVoid RunAsync(int gen, bool skipCatalogWait)
+ {
+ if (gen == _gen) _bus.Publish(new TutorialStartedSignal());
+ try
+ {
+ await StepsAsync(skipCatalogWait);
+ if (gen == _gen) Complete();
+ }
+ catch (OperationCanceledException)
+ {
+ // Cancelled by a restart or app shutdown — the newer run (if any) owns the overlay.
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"[Tutorial] Aborted with error, failing open: {e}");
+ if (gen == _gen) Complete();
+ }
+ finally
+ {
+ if (gen == _gen) _overlay.HideInstant();
+ }
+ }
+
+ private async UniTask StepsAsync(bool skipCatalogWait)
+ {
+ // Step 1 — pick a drawing (Colorbook scene).
+ _stepIndex = 1;
+ if (!skipCatalogWait)
+ {
+ if (!await WaitForSignalAsync()) return;
+ }
+ await UniTask.NextFrame(_ct);
+ ShowStep(() => ShowBlockingTap(FindFirstCatalogCell(), _config.PickText));
+ if (!await WaitForActionAsync()) return;
+ EndStep("pick", 1);
+
+ // Step 2 — drag the first piece (Gameplay scene).
+ _stepIndex = 2;
+ if (!await WaitForSignalAsync()) return;
+ await UniTask.NextFrame(_ct);
+ ShowStep(() =>
+ {
+ var (pieceRect, slotRect) = FindFirstPieceAndSlot();
+ if (pieceRect != null) _overlay.ShowDrag(pieceRect, slotRect, _config.DragText);
+ else Debug.LogWarning("[Tutorial] No draggable piece found for the drag step.");
+ });
+ if (!await WaitForActionAsync()) return; // any snap teaches the gesture
+ EndStep("drag", 2);
+
+ // Step 3 — finish the rest of the puzzle freely (non-blocking hint).
+ _stepIndex = 3;
+ ShowStep(() => _overlay.ShowTap(null, _config.FinishText, blockInput: false));
+ if (!await WaitForActionAsync()) return;
+ EndStep("finish", 3);
+
+ // Step 4 — pick a colour.
+ _stepIndex = 4;
+ if (!await WaitForSignalAsync()) return;
+ await UniTask.NextFrame(_ct);
+ ShowStep(() => ShowBlockingTap(FindFirstColorButton(), _config.ColorText));
+ if (!await WaitForActionAsync()) return;
+ EndStep("color", 4);
+
+ // Step 5 — paint a region.
+ _stepIndex = 5;
+ ShowStep(() => ShowBlockingTap(FindLargestRegion(), _config.PaintText));
+ if (!await WaitForActionAsync()) return;
+ EndStep("paint", 5);
+
+ // Step 6 — press Next.
+ _stepIndex = 6;
+ await UniTask.NextFrame(_ct);
+ ShowStep(() => ShowBlockingTap(FindNextButton(), _config.NextText));
+ if (!await WaitForActionAsync()) return;
+ EndStep("next", 6);
+
+ // Step 7 — celebrate.
+ _stepIndex = 7;
+ await _overlay.ShowToastAsync(_config.DoneText, _ct);
+ }
+
+ private void ShowBlockingTap(RectTransform target, string message)
+ {
+ if (target == null) Debug.LogWarning($"[Tutorial] No target found for step: \"{message}\"");
+ _overlay.ShowTap(target, message, blockInput: target != null);
+ }
+
+ private void EndStep(string id, int index)
+ {
+ _reshow = null;
+ _bus.Publish(new TutorialStepCompletedSignal(id, index));
+ _overlay.HideInstant();
+ }
+
+ private void Complete()
+ {
+ if (_completed) return;
+ _completed = true;
+ _gate.MarkCompleted();
+ _bus.Publish(new TutorialCompletedSignal());
+ DisposeNav();
+ }
+
+ // Preconditions (a system/scene must become ready): time out so a stalled load fails open.
+ private UniTask WaitForSignalAsync(Func predicate = null) where T : struct =>
+ WaitCoreAsync(_config.StepTimeoutSeconds, predicate);
+
+ // The child's own action (drag/tap/paint): defaults to no timeout (ActionTimeoutSeconds = 0) so
+ // the hint stays put until they do it; a positive value re-enables a safety net.
+ private UniTask WaitForActionAsync(Func predicate = null) where T : struct =>
+ WaitCoreAsync(_config.ActionTimeoutSeconds, predicate);
+
+ private async UniTask WaitCoreAsync(float timeoutSeconds, Func predicate) where T : struct
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_ct);
+ var tcs = new UniTaskCompletionSource();
+
+ var sub = _bus.Subscribe(evt =>
+ {
+ if (predicate == null || predicate(evt)) tcs.TrySetResult(true);
+ });
+ if (timeoutSeconds > 0f) TimeoutAsync(timeoutSeconds, timeoutCts.Token, tcs).Forget();
+
+ try
+ {
+ using (_ct.Register(() => tcs.TrySetCanceled(_ct)))
+ return await tcs.Task;
+ }
+ finally
+ {
+ sub.Dispose();
+ timeoutCts.Cancel();
+ }
+ }
+
+ private async UniTaskVoid TimeoutAsync(float seconds, CancellationToken ct, UniTaskCompletionSource tcs)
+ {
+ try
+ {
+ await UniTask.Delay(TimeSpan.FromSeconds(seconds), DelayType.UnscaledDeltaTime, cancellationToken: ct);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ tcs.TrySetResult(false);
+ }
+
+ // ── Runtime target discovery ─────────────────────────────────────────
+
+ private static RectTransform FindFirstCatalogCell()
+ {
+ var buttons = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None);
+ var cell = LowestSiblingActive(buttons);
+ ScrollToStart(cell); // catalog may be on another page — bring the first item into view
+ return cell;
+ }
+
+ private static (RectTransform piece, RectTransform slot) FindFirstPieceAndSlot()
+ {
+ var pieces = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None);
+ ShapePiece piece = null;
+ foreach (var p in pieces)
+ {
+ if (p == null || p.IsLocked || !p.gameObject.activeInHierarchy) continue;
+ piece = p;
+ break;
+ }
+ if (piece == null) return (null, null);
+
+ RectTransform slotRect = null;
+ var slots = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None);
+ foreach (var s in slots)
+ {
+ if (s == null || s.IsOccupied) continue;
+ if (s.SlotId == piece.PieceId) { slotRect = s.RectTransform; break; }
+ }
+ return (piece.RectTransform, slotRect);
+ }
+
+ private static RectTransform FindFirstColorButton()
+ {
+ var buttons = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None);
+ return LowestSiblingActive(buttons);
+ }
+
+ private static RectTransform FindLargestRegion()
+ {
+ var regions = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None);
+ ColorRegionView best = null;
+ float bestArea = -1f;
+ foreach (var r in regions)
+ {
+ if (r == null || !r.gameObject.activeInHierarchy) continue;
+ var size = ((RectTransform)r.transform).rect.size;
+ var area = Mathf.Abs(size.x * size.y);
+ if (area > bestArea) { bestArea = area; best = r; }
+ }
+ return best != null ? (RectTransform)best.transform : null;
+ }
+
+ private static RectTransform FindNextButton()
+ {
+ var view = UnityEngine.Object.FindFirstObjectByType();
+ return view != null ? (RectTransform)view.transform : null;
+ }
+
+ // The first item = lowest sibling index. Cells are instantiated in data order under a layout
+ // group, so sibling 0 is the leftmost/first; visibility is handled by scrolling it into view.
+ private static RectTransform LowestSiblingActive(T[] components) where T : Component
+ {
+ T best = null;
+ var bestIndex = int.MaxValue;
+ foreach (var c in components)
+ {
+ if (c == null || !c.gameObject.activeInHierarchy) continue;
+ var index = c.transform.GetSiblingIndex();
+ if (index < bestIndex) { bestIndex = index; best = c; }
+ }
+
+ return best != null ? (RectTransform)best.transform : null;
+ }
+
+ // Scrolls the target's ScrollRect to the start so a paged-off first item becomes visible.
+ private static void ScrollToStart(RectTransform cell)
+ {
+ if (cell == null) return;
+ var scroll = cell.GetComponentInParent();
+ if (scroll == null) return;
+ scroll.StopMovement();
+ if (scroll.horizontal) scroll.horizontalNormalizedPosition = 0f;
+ if (scroll.vertical) scroll.verticalNormalizedPosition = 1f;
+ }
+
+ private void DisposeNav()
+ {
+ foreach (var s in _navSubs) s?.Dispose();
+ _navSubs.Clear();
+ }
+
+ public void Dispose()
+ {
+ _introSub?.Dispose();
+ DisposeNav();
+ _runCts?.Cancel();
+ _runCts?.Dispose();
+ }
+ }
+}
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialDirector.cs.meta b/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialDirector.cs.meta
new file mode 100644
index 0000000..120329f
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialDirector.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d96038ffc4ea74bca8143d84655e8b4a
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialGateService.cs b/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialGateService.cs
new file mode 100644
index 0000000..f27df87
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialGateService.cs
@@ -0,0 +1,33 @@
+using Darkmatter.Core.Contracts.Features.Progression;
+using Darkmatter.Core.Contracts.Features.Tutorial;
+using Darkmatter.Libs.PlayerPrefs;
+
+namespace Darkmatter.Features.Tutorial.Systems
+{
+ public sealed class TutorialGateService : ITutorialGate
+ {
+ // Stored through ProtectedPlayerPrefs (keys are hashed, not validated against the registry,
+ // so a local constant is safe). Optionally register it in Tools > Darkmatter > PlayerPrefs
+ // Editor for documentation.
+ public const string CompletedKey = "Tutorial.Completed";
+
+ private readonly IProgressionSystem _progression;
+
+ public TutorialGateService(IProgressionSystem progression)
+ {
+ _progression = progression;
+ }
+
+ // Belt-and-suspenders: a player who cleared prefs but already has finished drawings is not a
+ // first-timer, so don't re-tutorialize them.
+ public bool ShouldRun =>
+ !ProtectedPlayerPrefs.GetBool(CompletedKey, false)
+ && _progression.CompletedTemplateIds.Count == 0;
+
+ public void MarkCompleted()
+ {
+ ProtectedPlayerPrefs.SetBool(CompletedKey, true);
+ ProtectedPlayerPrefs.Save();
+ }
+ }
+}
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialGateService.cs.meta b/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialGateService.cs.meta
new file mode 100644
index 0000000..6bd0419
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/Systems/TutorialGateService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3ee1f88d72ddf4d99bf5669a36cc52bc
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/UI.meta b/Assets/Darkmatter/Code/Features/Tutorial/UI.meta
new file mode 100644
index 0000000..6d7c571
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/UI.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 98ae3bd636ac84b648d602de444dbf89
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/UI/TutorialCutoutDim.cs b/Assets/Darkmatter/Code/Features/Tutorial/UI/TutorialCutoutDim.cs
new file mode 100644
index 0000000..3be1e27
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/UI/TutorialCutoutDim.cs
@@ -0,0 +1,72 @@
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace Darkmatter.Features.Tutorial.UI
+{
+ ///
+ /// A single full-screen dim Image with a soft circular hole driven by the TutorialCutout shader.
+ /// Also an : when the dim is blocking, taps inside the hole
+ /// fall through to the real button underneath, taps outside are swallowed.
+ ///
+ [RequireComponent(typeof(Image))]
+ public sealed class TutorialCutoutDim : MonoBehaviour, ICanvasRaycastFilter
+ {
+ [SerializeField] private Shader cutoutShader;
+ [SerializeField] private Image dimImage;
+
+ private Material _material;
+ private Vector2 _centerScreen;
+ private float _radiusScreen;
+ private bool _holeActive;
+
+ private static readonly int CenterId = Shader.PropertyToID("_Center");
+ private static readonly int RadiusId = Shader.PropertyToID("_Radius");
+ private static readonly int AspectId = Shader.PropertyToID("_Aspect");
+ private static readonly int SoftnessId = Shader.PropertyToID("_Softness");
+
+ private void Awake()
+ {
+ if (dimImage == null) dimImage = GetComponent();
+ if (cutoutShader != null && dimImage != null)
+ {
+ _material = new Material(cutoutShader); // instance — never mutate the asset
+ dimImage.material = _material;
+ }
+ SetVisible(false);
+ }
+
+ /// Show/hide the dim. Hidden = the Graphic is disabled, so it neither draws nor blocks.
+ public void SetVisible(bool visible)
+ {
+ if (dimImage != null) dimImage.enabled = visible;
+ if (!visible) ClearHole();
+ }
+
+ public void SetHole(Vector2 centerScreen, float radiusScreen, float screenWidth, float screenHeight)
+ {
+ _centerScreen = centerScreen;
+ _radiusScreen = radiusScreen;
+ _holeActive = radiusScreen > 0f && screenHeight > 0f;
+ if (_material == null || screenHeight <= 0f) return;
+
+ _material.SetVector(CenterId, new Vector4(centerScreen.x / screenWidth, centerScreen.y / screenHeight, 0f, 0f));
+ _material.SetFloat(RadiusId, radiusScreen / screenHeight);
+ _material.SetFloat(AspectId, screenWidth / screenHeight);
+ _material.SetFloat(SoftnessId, 0.004f);
+ }
+
+ public void ClearHole()
+ {
+ _holeActive = false;
+ if (_material != null) _material.SetFloat(RadiusId, 0f);
+ }
+
+ // Consulted only when the graphic actually participates in raycasting (parent CanvasGroup
+ // blocksRaycasts == true). Outside the hole -> block; inside -> pass to whatever's beneath.
+ public bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
+ {
+ if (!_holeActive) return true;
+ return Vector2.Distance(screenPoint, _centerScreen) > _radiusScreen;
+ }
+ }
+}
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/UI/TutorialCutoutDim.cs.meta b/Assets/Darkmatter/Code/Features/Tutorial/UI/TutorialCutoutDim.cs.meta
new file mode 100644
index 0000000..04c7886
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/UI/TutorialCutoutDim.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 29c836b3d2ed343e6a810f6e7548f487
\ No newline at end of file
diff --git a/Assets/Darkmatter/Code/Features/Tutorial/UI/TutorialOverlayView.cs b/Assets/Darkmatter/Code/Features/Tutorial/UI/TutorialOverlayView.cs
new file mode 100644
index 0000000..34f53de
--- /dev/null
+++ b/Assets/Darkmatter/Code/Features/Tutorial/UI/TutorialOverlayView.cs
@@ -0,0 +1,449 @@
+using System;
+using System.Threading;
+using Cysharp.Threading.Tasks;
+using Darkmatter.Core.Contracts.Features.Tutorial;
+using PrimeTween;
+using TMPro;
+using UnityEngine;
+
+namespace Darkmatter.Features.Tutorial.UI
+{
+ ///
+ /// Persistent guidance overlay. A single full-screen dim with a soft circular hole (see
+ /// ) spotlights the target; an animated hand + halo point at it
+ /// and an instruction bubble sits above/below. Lives on a high-sortingOrder Canvas in the Boot
+ /// scene so it renders over — and outlives — every additive gameplay scene.
+ ///
+ /// Input gating is the whole-overlay CanvasGroup.blocksRaycasts master switch: ON for blocking
+ /// tap steps; OFF for drag steps, hints and toasts. All targets are tracked live in LateUpdate
+ /// (catalog cells scroll, pieces are dragged); if a target is destroyed (scene swap) the overlay
+ /// hides itself. All animation uses unscaled time.
+ ///
+ [RequireComponent(typeof(CanvasGroup))]
+ public sealed class TutorialOverlayView : MonoBehaviour, ITutorialOverlay
+ {
+ private enum Mode { Hidden, Tap, Drag, Centered }
+
+ [Header("Canvas")]
+ [SerializeField] private Canvas canvas;
+ [SerializeField] private CanvasGroup rootGroup;
+
+ [Header("Dim (single image + circular cutout)")]
+ [SerializeField] private TutorialCutoutDim cutout;
+
+ [Header("Pointer")]
+ [SerializeField] private RectTransform halo;
+ [SerializeField] private RectTransform hand;
+
+ [Header("Bubble")]
+ [SerializeField] private RectTransform bubbleRoot;
+ [SerializeField] private TMP_Text bubbleText;
+
+ [Header("Tuning")]
+ [Tooltip("Extra screen pixels added to the spotlight circle radius around the target.")]
+ [SerializeField] private float holePadding = 44f;
+ [SerializeField] private float fadeDuration = 0.25f;
+ [SerializeField] private float toastSeconds = 1.6f;
+ [SerializeField] private float bubbleGap = 60f;
+ [SerializeField] private float haloPulseScale = 1.18f;
+ [SerializeField] private float pulseDuration = 0.6f;
+ [SerializeField] private float dragHandDuration = 1.1f;
+
+ [Header("Drag step")]
+ [Tooltip("Offset (px) from the hand's pivot to its visible fingertip. The tip is placed on the target so it points accurately; a positive Y drops the hand body below the spot.")]
+ [SerializeField] private Vector2 dragHandTipOffset = new(0f, 70f);
+ [Tooltip("Pin the drag bubble to the top (true) or bottom (false) edge so it never covers the play area.")]
+ [SerializeField] private bool dragBubbleAtTop = true;
+ [Tooltip("Margin (px) from that edge for the drag bubble.")]
+ [SerializeField] private float dragBubbleEdgeMargin = 150f;
+
+ private RectTransform _area;
+ private Camera _overlayCam;
+ private Mode _mode = Mode.Hidden;
+ private RectTransform _target; // tap target
+ private RectTransform _dragFrom; // the piece (drag start)
+ private RectTransform _dragTo; // the slot (drag destination)
+ private float _dragT;
+ private Sequence _haloSeq;
+ private Sequence _handSeq;
+ private readonly Vector3[] _corners = new Vector3[4];
+ private bool _ready;
+
+ public bool IsReady => _ready && this != null;
+
+ private void Awake()
+ {
+ CacheRefs();
+ ApplyHiddenState();
+ }
+
+ private void CacheRefs()
+ {
+ if (_ready) return;
+ _area = (RectTransform)transform;
+ if (canvas == null) canvas = GetComponentInParent