Tutorial done
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fac6898f446df4a8e994fa47eed10068
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Darkmatter.Core.Contracts.Features.Tutorial
|
||||
{
|
||||
/// <summary>
|
||||
/// Decides whether the one-time tutorial should run, and records that it has been completed.
|
||||
/// </summary>
|
||||
public interface ITutorialGate
|
||||
{
|
||||
/// <summary>True only for a genuine first-time player who has not finished the tutorial.</summary>
|
||||
bool ShouldRun { get; }
|
||||
|
||||
/// <summary>Persist that the tutorial is done so it never runs again.</summary>
|
||||
void MarkCompleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e7c2fc9f6a9354a068fdc7d9ed5aa4ad
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Core.Contracts.Features.Tutorial
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public interface ITutorialOverlay
|
||||
{
|
||||
/// <summary>True once the view is initialised and safe to drive.</summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Spotlight a single tap target. When <paramref name="target"/> is null the dim/hole are
|
||||
/// skipped and only a centered bubble is shown (a non-blocking hint).
|
||||
/// When <paramref name="blockInput"/> is true every touch outside the hole is swallowed.
|
||||
/// </summary>
|
||||
void ShowTap(RectTransform target, string message, bool blockInput);
|
||||
|
||||
/// <summary>
|
||||
/// Spotlight a drag gesture from <paramref name="from"/> to <paramref name="to"/>. Input is
|
||||
/// never blocked here, so the dragged piece can render above the dim.
|
||||
/// </summary>
|
||||
void ShowDrag(RectTransform from, RectTransform to, string message);
|
||||
|
||||
/// <summary>Hide everything immediately (used across scene swaps).</summary>
|
||||
void HideInstant();
|
||||
|
||||
/// <summary>Show a centered celebratory bubble for a short beat, then hide.</summary>
|
||||
UniTask ShowToastAsync(string message, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1fae7a22d89c64857af91268a55bd564
|
||||
@@ -0,0 +1,6 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Core.Data.Signals.Features.Coloring
|
||||
{
|
||||
public record struct ColorSelectedSignal(int Index, Color Color);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df1823d5d5df7469781b42466c308ecc
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e71fd9bc94a644ced8b0a141f058291b
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3aee861659cfe43c4b48d513479c2d7d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Darkmatter.Core.Data.Signals.Features.Tutorial
|
||||
{
|
||||
public record struct TutorialCompletedSignal;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3b50d1afc8134a0f8ae92b6c0590bdb
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Darkmatter.Core.Data.Signals.Features.Tutorial
|
||||
{
|
||||
public record struct TutorialStartedSignal;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3aeb975bb1d6a45aca5c86ddf72bee22
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Darkmatter.Core.Data.Signals.Features.Tutorial
|
||||
{
|
||||
public record struct TutorialStepCompletedSignal(string StepId, int StepIndex);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 11b2e1b23836f4664be3a84389c1c364
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1cda3e3840cbd4eccbe7ad810dee920d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,43 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Core.Data.Static.Features.Tutorial
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c513430cc85f4357b3df1da019bf554
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
8
Assets/Darkmatter/Code/Features/Tutorial.meta
Normal file
8
Assets/Darkmatter/Code/Features/Tutorial.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5913ea683733a4597b3cf6b3903811a0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Darkmatter/Code/Features/Tutorial/Editor.meta
Normal file
8
Assets/Darkmatter/Code/Features/Tutorial/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d69b43c419c8d4f13b930f1dc30359ff
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// QA helpers for the forced-once tutorial (editor only — stripped from player builds).
|
||||
/// </summary>
|
||||
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
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a54326b3914a43ffa206b427980908e
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a6beea6626ba14397b6be37b16032fb5
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Darkmatter/Code/Features/Tutorial/Installers.meta
Normal file
8
Assets/Darkmatter/Code/Features/Tutorial/Installers.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ed3d9a0f27b0a486ab8dfe44d39ce2f9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class TutorialFeatureModule : MonoBehaviour, IModule
|
||||
{
|
||||
[SerializeField] private TutorialOverlayView overlayView;
|
||||
[SerializeField] private TutorialStepsConfig config;
|
||||
|
||||
public void Register(IContainerBuilder builder)
|
||||
{
|
||||
if (overlayView != null)
|
||||
builder.RegisterComponent<ITutorialOverlay>(overlayView);
|
||||
if (config != null)
|
||||
builder.RegisterInstance(config);
|
||||
|
||||
builder.Register<ITutorialGate, TutorialGateService>(Lifetime.Singleton);
|
||||
builder.RegisterEntryPoint<TutorialDirector>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97ce2c486cc8541d1ab1d83fda7f8eda
|
||||
69
Assets/Darkmatter/Code/Features/Tutorial/SETUP.md
Normal file
69
Assets/Darkmatter/Code/Features/Tutorial/SETUP.md
Normal file
@@ -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.
|
||||
7
Assets/Darkmatter/Code/Features/Tutorial/SETUP.md.meta
Normal file
7
Assets/Darkmatter/Code/Features/Tutorial/SETUP.md.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9fcf6edd0d00433bb41f63ff3b4b1b7
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Darkmatter/Code/Features/Tutorial/Systems.meta
Normal file
8
Assets/Darkmatter/Code/Features/Tutorial/Systems.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bc34be257ded14eacbf3059d20b0c758
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the one-time, forced guided tutorial. A single root-scoped singleton that arms on
|
||||
/// <see cref="IntroCompletedSignal"/> 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.
|
||||
/// </summary>
|
||||
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<IDisposable> _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<IntroCompletedSignal>(OnIntroCompleted);
|
||||
}
|
||||
|
||||
private void OnIntroCompleted(IntroCompletedSignal _)
|
||||
{
|
||||
_introSub?.Dispose();
|
||||
_introSub = null;
|
||||
|
||||
if (!_gate.ShouldRun) return;
|
||||
|
||||
// Run-lifetime navigation handling (persists across restarts).
|
||||
_navSubs.Add(_bus.Subscribe<OpenArtBookSignal>(_ => Suspend()));
|
||||
_navSubs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => Suspend()));
|
||||
_navSubs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => Resume()));
|
||||
_navSubs.Add(_bus.Subscribe<DrawingCatalogReadySignal>(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<DrawingCatalogReadySignal>()) return;
|
||||
}
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindFirstCatalogCell(), _config.PickText));
|
||||
if (!await WaitForActionAsync<DrawingSelectedSignal>()) return;
|
||||
EndStep("pick", 1);
|
||||
|
||||
// Step 2 — drag the first piece (Gameplay scene).
|
||||
_stepIndex = 2;
|
||||
if (!await WaitForSignalAsync<ShapeBuilderStartedSignal>()) 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<PieceSnappedSignal>()) 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<ShapeAssembledSignal>()) return;
|
||||
EndStep("finish", 3);
|
||||
|
||||
// Step 4 — pick a colour.
|
||||
_stepIndex = 4;
|
||||
if (!await WaitForSignalAsync<RegionsInitializedSignal>()) return;
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindFirstColorButton(), _config.ColorText));
|
||||
if (!await WaitForActionAsync<ColorSelectedSignal>()) return;
|
||||
EndStep("color", 4);
|
||||
|
||||
// Step 5 — paint a region.
|
||||
_stepIndex = 5;
|
||||
ShowStep(() => ShowBlockingTap(FindLargestRegion(), _config.PaintText));
|
||||
if (!await WaitForActionAsync<ColorAppliedSignal>()) return;
|
||||
EndStep("paint", 5);
|
||||
|
||||
// Step 6 — press Next.
|
||||
_stepIndex = 6;
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindNextButton(), _config.NextText));
|
||||
if (!await WaitForActionAsync<DrawingCompletedSignal>()) 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<bool> WaitForSignalAsync<T>(Func<T, bool> 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<bool> WaitForActionAsync<T>(Func<T, bool> predicate = null) where T : struct =>
|
||||
WaitCoreAsync(_config.ActionTimeoutSeconds, predicate);
|
||||
|
||||
private async UniTask<bool> WaitCoreAsync<T>(float timeoutSeconds, Func<T, bool> predicate) where T : struct
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_ct);
|
||||
var tcs = new UniTaskCompletionSource<bool>();
|
||||
|
||||
var sub = _bus.Subscribe<T>(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<bool> 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<DrawingCatalogButton>(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<ShapePiece>(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<SlotMarker>(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<ColorButton>(FindObjectsSortMode.None);
|
||||
return LowestSiblingActive(buttons);
|
||||
}
|
||||
|
||||
private static RectTransform FindLargestRegion()
|
||||
{
|
||||
var regions = UnityEngine.Object.FindObjectsByType<ColorRegionView>(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<NextButtonView>();
|
||||
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>(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<ScrollRect>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d96038ffc4ea74bca8143d84655e8b4a
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ee1f88d72ddf4d99bf5669a36cc52bc
|
||||
8
Assets/Darkmatter/Code/Features/Tutorial/UI.meta
Normal file
8
Assets/Darkmatter/Code/Features/Tutorial/UI.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98ae3bd636ac84b648d602de444dbf89
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,72 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Darkmatter.Features.Tutorial.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single full-screen dim Image with a soft circular hole driven by the TutorialCutout shader.
|
||||
/// Also an <see cref="ICanvasRaycastFilter"/>: when the dim is blocking, taps inside the hole
|
||||
/// fall through to the real button underneath, taps outside are swallowed.
|
||||
/// </summary>
|
||||
[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<Image>();
|
||||
if (cutoutShader != null && dimImage != null)
|
||||
{
|
||||
_material = new Material(cutoutShader); // instance — never mutate the asset
|
||||
dimImage.material = _material;
|
||||
}
|
||||
SetVisible(false);
|
||||
}
|
||||
|
||||
/// <summary>Show/hide the dim. Hidden = the Graphic is disabled, so it neither draws nor blocks.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 29c836b3d2ed343e6a810f6e7548f487
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Persistent guidance overlay. A single full-screen dim with a soft circular hole (see
|
||||
/// <see cref="TutorialCutoutDim"/>) 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.
|
||||
/// </summary>
|
||||
[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<Canvas>();
|
||||
if (rootGroup == null) rootGroup = GetComponent<CanvasGroup>();
|
||||
_overlayCam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay
|
||||
? canvas.worldCamera
|
||||
: null;
|
||||
_ready = true;
|
||||
}
|
||||
|
||||
// ── ITutorialOverlay ─────────────────────────────────────────────────
|
||||
|
||||
public void ShowTap(RectTransform target, string message, bool blockInput)
|
||||
{
|
||||
CacheRefs();
|
||||
KillAnims();
|
||||
SetText(message);
|
||||
_dragFrom = null;
|
||||
_dragTo = null;
|
||||
|
||||
if (target != null)
|
||||
{
|
||||
_mode = Mode.Tap;
|
||||
_target = target;
|
||||
if (cutout != null) cutout.SetVisible(true);
|
||||
SetPointerVisible(true);
|
||||
rootGroup.blocksRaycasts = blockInput;
|
||||
StartHaloPulse();
|
||||
StartHandTap();
|
||||
}
|
||||
else
|
||||
{
|
||||
_mode = Mode.Centered;
|
||||
_target = null;
|
||||
if (cutout != null) cutout.SetVisible(false);
|
||||
SetPointerVisible(false);
|
||||
rootGroup.blocksRaycasts = false;
|
||||
}
|
||||
|
||||
LayoutNow();
|
||||
FadeInQuick();
|
||||
}
|
||||
|
||||
public void ShowDrag(RectTransform from, RectTransform to, string message)
|
||||
{
|
||||
CacheRefs();
|
||||
KillAnims();
|
||||
SetText(message);
|
||||
|
||||
// No dim and no blocking — the piece lives under the overlay and must stay visible and
|
||||
// draggable. Halo + hand travel together along the piece -> slot path, recomputed live.
|
||||
if (cutout != null) cutout.SetVisible(false);
|
||||
rootGroup.blocksRaycasts = false;
|
||||
_target = null;
|
||||
_dragFrom = from;
|
||||
_dragTo = to;
|
||||
_dragT = 0f;
|
||||
|
||||
if (from != null)
|
||||
{
|
||||
_mode = Mode.Drag;
|
||||
SetPointerVisible(true);
|
||||
StartHaloPulse();
|
||||
}
|
||||
else
|
||||
{
|
||||
_mode = Mode.Centered;
|
||||
SetPointerVisible(false);
|
||||
}
|
||||
|
||||
LayoutNow();
|
||||
FadeInQuick();
|
||||
}
|
||||
|
||||
public async UniTask ShowToastAsync(string message, CancellationToken ct)
|
||||
{
|
||||
CacheRefs();
|
||||
KillAnims();
|
||||
SetText(message);
|
||||
|
||||
_mode = Mode.Centered;
|
||||
_target = null;
|
||||
_dragFrom = null;
|
||||
_dragTo = null;
|
||||
if (cutout != null) cutout.SetVisible(false);
|
||||
SetPointerVisible(false);
|
||||
rootGroup.blocksRaycasts = false;
|
||||
LayoutNow();
|
||||
|
||||
await FadeAsync(0f, 1f, fadeDuration, ct);
|
||||
try
|
||||
{
|
||||
await UniTask.Delay(TimeSpan.FromSeconds(toastSeconds), DelayType.UnscaledDeltaTime, cancellationToken: ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
HideInstant();
|
||||
throw;
|
||||
}
|
||||
await FadeAsync(1f, 0f, fadeDuration, CancellationToken.None);
|
||||
HideInstant();
|
||||
}
|
||||
|
||||
public void HideInstant()
|
||||
{
|
||||
CacheRefs();
|
||||
KillAnims();
|
||||
ApplyHiddenState();
|
||||
}
|
||||
|
||||
// ── Per-frame layout ─────────────────────────────────────────────────
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_mode == Mode.Hidden) return;
|
||||
LayoutNow();
|
||||
}
|
||||
|
||||
private void LayoutNow()
|
||||
{
|
||||
switch (_mode)
|
||||
{
|
||||
case Mode.Tap:
|
||||
if (_target == null) { HideInstant(); return; } // destroyed (scene swap) -> self-hide
|
||||
PlaceSpotlight(_target);
|
||||
break;
|
||||
case Mode.Drag:
|
||||
if (_dragFrom == null) { HideInstant(); return; }
|
||||
LayoutDrag();
|
||||
break;
|
||||
default:
|
||||
LayoutCentered();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Tap: cutout + halo + resting hand + bubble around a static target.
|
||||
private void PlaceSpotlight(RectTransform target)
|
||||
{
|
||||
if (!TryToScreenBounds(target, out var min, out var max)) return;
|
||||
|
||||
var centerScreen = (min + max) * 0.5f;
|
||||
var radiusScreen = (max - min).magnitude * 0.5f + holePadding;
|
||||
|
||||
if (cutout != null) cutout.SetHole(centerScreen, radiusScreen, Screen.width, Screen.height);
|
||||
|
||||
if (!ScreenToLocal(centerScreen, out var centerLocal)) return;
|
||||
|
||||
float scale = Screen.height > 0 ? _area.rect.height / Screen.height : 1f;
|
||||
float radiusLocal = radiusScreen * scale;
|
||||
|
||||
if (halo != null)
|
||||
{
|
||||
halo.anchoredPosition = centerLocal;
|
||||
halo.sizeDelta = Vector2.one * (radiusLocal * 2f);
|
||||
}
|
||||
if (hand != null)
|
||||
hand.anchoredPosition = centerLocal + new Vector2(0f, -radiusLocal * 0.5f);
|
||||
|
||||
PositionBubble(centerLocal.y, centerLocal.y + radiusLocal, centerLocal.y - radiusLocal,
|
||||
_area.rect.height * 0.5f);
|
||||
}
|
||||
|
||||
// Drag: halo + hand glide piece -> slot, recomputed live so it tracks the real positions.
|
||||
private void LayoutDrag()
|
||||
{
|
||||
if (!TryToLocalCenter(_dragFrom, out var a)) return;
|
||||
Vector2 b = _dragTo != null && TryToLocalCenter(_dragTo, out var bc) ? bc : a;
|
||||
|
||||
const float pressT = 0.25f;
|
||||
const float holdT = 0.5f;
|
||||
float move = Mathf.Max(0.3f, dragHandDuration);
|
||||
float period = pressT + move + holdT;
|
||||
_dragT += Time.unscaledDeltaTime;
|
||||
if (_dragT >= period) _dragT -= period;
|
||||
|
||||
float frac, pressScale;
|
||||
if (_dragT < pressT) { frac = 0f; pressScale = Mathf.Lerp(1f, 0.8f, _dragT / pressT); }
|
||||
else if (_dragT < pressT + move) { frac = Mathf.SmoothStep(0f, 1f, (_dragT - pressT) / move); pressScale = 0.8f; }
|
||||
else { frac = 1f; pressScale = Mathf.Lerp(0.8f, 1f, (_dragT - pressT - move) / holdT); }
|
||||
|
||||
Vector2 pos = Vector2.Lerp(a, b, frac);
|
||||
|
||||
float scale = Screen.height > 0 ? _area.rect.height / Screen.height : 1f;
|
||||
float radiusScreen = 120f;
|
||||
if (TryToScreenBounds(_dragFrom, out var min, out var max))
|
||||
radiusScreen = (max - min).magnitude * 0.5f + holePadding;
|
||||
float radiusLocal = radiusScreen * scale;
|
||||
|
||||
if (halo != null)
|
||||
{
|
||||
halo.anchoredPosition = pos;
|
||||
halo.sizeDelta = Vector2.one * (radiusLocal * 2f);
|
||||
}
|
||||
if (hand != null)
|
||||
{
|
||||
// Put the visible fingertip on the target point (halo centre) so it points accurately,
|
||||
// with the hand body hanging below it.
|
||||
hand.anchoredPosition = pos - dragHandTipOffset;
|
||||
hand.localScale = Vector3.one * pressScale;
|
||||
}
|
||||
|
||||
// The drag path spans the play area, so pin the bubble to a screen edge instead of hugging
|
||||
// the piece — keeps it off the shapes.
|
||||
PositionBubbleAtEdge(dragBubbleAtTop, dragBubbleEdgeMargin);
|
||||
}
|
||||
|
||||
private void LayoutCentered()
|
||||
{
|
||||
if (bubbleRoot == null) return;
|
||||
float halfH = _area.rect.height * 0.5f;
|
||||
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||
bubbleRoot.anchoredPosition =
|
||||
new Vector2(0f, Mathf.Clamp(halfH * 0.45f, -halfH + bubbleHalf, halfH - bubbleHalf));
|
||||
}
|
||||
|
||||
private void PositionBubble(float holeCenterY, float holeTopY, float holeBottomY, float halfH)
|
||||
{
|
||||
if (bubbleRoot == null) return;
|
||||
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||
float y = holeCenterY <= 0f
|
||||
? holeTopY + bubbleGap + bubbleHalf
|
||||
: holeBottomY - bubbleGap - bubbleHalf;
|
||||
y = Mathf.Clamp(y, -halfH + bubbleHalf, halfH - bubbleHalf);
|
||||
bubbleRoot.anchoredPosition = new Vector2(0f, y);
|
||||
}
|
||||
|
||||
// Pins the bubble to the top or bottom edge (used for the drag step, whose target spans the
|
||||
// play area). Centred horizontally so it clears the shapes.
|
||||
private void PositionBubbleAtEdge(bool top, float margin)
|
||||
{
|
||||
if (bubbleRoot == null) return;
|
||||
float halfH = _area.rect.height * 0.5f;
|
||||
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||
float y = top ? halfH - bubbleHalf - margin : -halfH + bubbleHalf + margin;
|
||||
y = Mathf.Clamp(y, -halfH + bubbleHalf, halfH - bubbleHalf);
|
||||
bubbleRoot.anchoredPosition = new Vector2(0f, y);
|
||||
}
|
||||
|
||||
// ── Coordinate conversion (camera-agnostic) ──────────────────────────
|
||||
|
||||
private bool TryToScreenBounds(RectTransform target, out Vector2 min, out Vector2 max)
|
||||
{
|
||||
min = new Vector2(float.MaxValue, float.MaxValue);
|
||||
max = new Vector2(float.MinValue, float.MinValue);
|
||||
if (target == null) return false;
|
||||
|
||||
var cam = ResolveCamera(target);
|
||||
target.GetWorldCorners(_corners);
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var sp = RectTransformUtility.WorldToScreenPoint(cam, _corners[i]);
|
||||
min = Vector2.Min(min, sp);
|
||||
max = Vector2.Max(max, sp);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryToLocalCenter(RectTransform target, out Vector2 local)
|
||||
{
|
||||
local = default;
|
||||
if (!TryToScreenBounds(target, out var min, out var max)) return false;
|
||||
return ScreenToLocal((min + max) * 0.5f, out local);
|
||||
}
|
||||
|
||||
private bool ScreenToLocal(Vector2 screen, out Vector2 local) =>
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(_area, screen, _overlayCam, out local);
|
||||
|
||||
private static Camera ResolveCamera(RectTransform target)
|
||||
{
|
||||
var c = target.GetComponentInParent<Canvas>();
|
||||
if (c == null) return null;
|
||||
c = c.rootCanvas;
|
||||
return c.renderMode == RenderMode.ScreenSpaceOverlay ? null : c.worldCamera;
|
||||
}
|
||||
|
||||
// ── Animation ────────────────────────────────────────────────────────
|
||||
|
||||
private void StartHaloPulse()
|
||||
{
|
||||
if (halo == null) return;
|
||||
halo.localScale = Vector3.one;
|
||||
_haloSeq = Sequence.Create(useUnscaledTime: true, cycles: -1)
|
||||
.Chain(Tween.Scale(halo, Vector3.one * haloPulseScale, pulseDuration, Ease.InOutSine))
|
||||
.Chain(Tween.Scale(halo, Vector3.one, pulseDuration, Ease.InOutSine));
|
||||
}
|
||||
|
||||
private void StartHandTap()
|
||||
{
|
||||
if (hand == null) return;
|
||||
hand.localScale = Vector3.one;
|
||||
_handSeq = Sequence.Create(useUnscaledTime: true, cycles: -1)
|
||||
.Chain(Tween.Scale(hand, Vector3.one * 0.82f, 0.35f, Ease.OutQuad))
|
||||
.Chain(Tween.Scale(hand, Vector3.one, 0.35f, Ease.OutQuad))
|
||||
.ChainDelay(0.35f);
|
||||
}
|
||||
|
||||
private void FadeInQuick()
|
||||
{
|
||||
if (rootGroup == null) return;
|
||||
Tween.StopAll(onTarget: rootGroup);
|
||||
rootGroup.alpha = 0f;
|
||||
Sequence.Create(useUnscaledTime: true).Chain(Tween.Alpha(rootGroup, 1f, fadeDuration, Ease.OutQuad));
|
||||
}
|
||||
|
||||
private async UniTask FadeAsync(float from, float to, float duration, CancellationToken ct)
|
||||
{
|
||||
if (rootGroup == null) return;
|
||||
Tween.StopAll(onTarget: rootGroup);
|
||||
rootGroup.alpha = from;
|
||||
float t = 0f;
|
||||
while (t < duration)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
t += Time.unscaledDeltaTime;
|
||||
rootGroup.alpha = Mathf.Lerp(from, to, duration > 0f ? t / duration : 1f);
|
||||
await UniTask.Yield(PlayerLoopTiming.Update);
|
||||
}
|
||||
rootGroup.alpha = to;
|
||||
}
|
||||
|
||||
// ── State helpers ────────────────────────────────────────────────────
|
||||
|
||||
private void ApplyHiddenState()
|
||||
{
|
||||
_mode = Mode.Hidden;
|
||||
_target = null;
|
||||
_dragFrom = null;
|
||||
_dragTo = null;
|
||||
if (cutout != null) cutout.SetVisible(false);
|
||||
if (rootGroup != null)
|
||||
{
|
||||
rootGroup.alpha = 0f;
|
||||
rootGroup.blocksRaycasts = false;
|
||||
rootGroup.interactable = false;
|
||||
}
|
||||
SetPointerVisible(false);
|
||||
}
|
||||
|
||||
private void SetText(string message)
|
||||
{
|
||||
if (bubbleText != null) bubbleText.text = message;
|
||||
if (bubbleRoot != null) bubbleRoot.gameObject.SetActive(!string.IsNullOrEmpty(message));
|
||||
}
|
||||
|
||||
private void SetPointerVisible(bool visible)
|
||||
{
|
||||
if (halo != null) halo.gameObject.SetActive(visible);
|
||||
if (hand != null) hand.gameObject.SetActive(visible);
|
||||
}
|
||||
|
||||
private void KillAnims()
|
||||
{
|
||||
if (_haloSeq.isAlive) _haloSeq.Stop();
|
||||
if (_handSeq.isAlive) _handSeq.Stop();
|
||||
_haloSeq = default;
|
||||
_handSeq = default;
|
||||
if (halo != null) { Tween.StopAll(onTarget: halo); halo.localScale = Vector3.one; }
|
||||
if (hand != null) { Tween.StopAll(onTarget: hand); hand.localScale = Vector3.one; }
|
||||
if (rootGroup != null) Tween.StopAll(onTarget: rootGroup);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
KillAnims();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ecf127118699405ebcd0e0712d7373d
|
||||
@@ -8,6 +8,7 @@ using Darkmatter.Core.Data.Signals.Features.Drawing;
|
||||
using Darkmatter.Core.Data.Signals.Features.GameplayFlow;
|
||||
using Darkmatter.Core.Data.Signals.Features.MainMenu;
|
||||
using Darkmatter.Core.Data.Signals.Features.ShapeBuilder;
|
||||
using Darkmatter.Core.Data.Signals.Features.Tutorial;
|
||||
using Darkmatter.Libs.Observer;
|
||||
using VContainer.Unity;
|
||||
|
||||
@@ -52,6 +53,15 @@ namespace Darkmatter.Services.Analytics
|
||||
_subs.Add(_bus.Subscribe<GallerySaveStartedSignal>(_ => _analytics.LogEvent("gallery_save_started")));
|
||||
_subs.Add(_bus.Subscribe<GallerySaveCompletedSignal>(s =>
|
||||
_analytics.LogEvent("gallery_save_completed", "success", s.Success ? "true" : "false")));
|
||||
|
||||
_subs.Add(_bus.Subscribe<TutorialStartedSignal>(_ => _analytics.LogEvent("tutorial_started")));
|
||||
_subs.Add(_bus.Subscribe<TutorialStepCompletedSignal>(s => _analytics.LogEvent("tutorial_step_completed",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["step_id"] = s.StepId,
|
||||
["step_index"] = s.StepIndex,
|
||||
})));
|
||||
_subs.Add(_bus.Subscribe<TutorialCompletedSignal>(_ => _analytics.LogEvent("tutorial_completed")));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
Reference in New Issue
Block a user