Compare commits
30 Commits
28f431f26a
...
savya
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15b08df0c | ||
|
|
8c6962762c | ||
|
|
fd3c13e470 | ||
|
|
7d85a0be1d | ||
|
|
f08635225d | ||
|
|
36cf9cdad1 | ||
|
|
435988ca70 | ||
|
|
176d95ec6a | ||
|
|
f63c0c9d5e | ||
|
|
4eccde41b5 | ||
|
|
68bea947fe | ||
|
|
0b9d0ca8f2 | ||
|
|
521b5c3b7d | ||
|
|
ec4288e97a | ||
|
|
d406f41acc | ||
|
|
144bac177f | ||
|
|
a07f7618ab | ||
|
|
70b8dd2b95 | ||
|
|
220d651cfb | ||
|
|
e27d0e54cb | ||
|
|
dee4b004bd | ||
|
|
2462d972e3 | ||
|
|
e3f047de2b | ||
|
|
0bfcfa3038 | ||
|
|
b4e0cb0530 | ||
|
|
f81a17e1f5 | ||
|
|
64b383fe6e | ||
|
|
46b5bcc978 | ||
|
|
abc3df086d | ||
|
|
1641ed6b3c |
7
.idea/.idea.Colorbook/.idea/discord.xml
generated
Normal file
7
.idea/.idea.Colorbook/.idea/discord.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="PROJECT_FILES" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
BIN
Assets/.DS_Store
vendored
BIN
Assets/.DS_Store
vendored
Binary file not shown.
@@ -15,7 +15,7 @@ MonoBehaviour:
|
|||||||
m_DefaultGroup: 0e030d5498bfe4ffd8443c796618c539
|
m_DefaultGroup: 0e030d5498bfe4ffd8443c796618c539
|
||||||
m_currentHash:
|
m_currentHash:
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
Hash: 00000000000000000000000000000000
|
Hash: f172661451d53007cd560d2db7f013f5
|
||||||
m_OptimizeCatalogSize: 0
|
m_OptimizeCatalogSize: 0
|
||||||
m_BuildRemoteCatalog: 0
|
m_BuildRemoteCatalog: 0
|
||||||
m_CatalogRequestsTimeout: 0
|
m_CatalogRequestsTimeout: 0
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ MonoBehaviour:
|
|||||||
m_SerializedLabels:
|
m_SerializedLabels:
|
||||||
- drawing
|
- drawing
|
||||||
FlaggedDuringContentUpdateRestriction: 0
|
FlaggedDuringContentUpdateRestriction: 0
|
||||||
|
- m_GUID: 239bd4d142f84024996594b45f4925d5
|
||||||
|
m_Address: Boat
|
||||||
|
m_ReadOnly: 0
|
||||||
|
m_SerializedLabels:
|
||||||
|
- drawing
|
||||||
|
FlaggedDuringContentUpdateRestriction: 0
|
||||||
- m_GUID: 2d55441a1a6751c45bb9af623301426e
|
- m_GUID: 2d55441a1a6751c45bb9af623301426e
|
||||||
m_Address: Car
|
m_Address: Car
|
||||||
m_ReadOnly: 0
|
m_ReadOnly: 0
|
||||||
@@ -51,6 +57,12 @@ MonoBehaviour:
|
|||||||
m_SerializedLabels:
|
m_SerializedLabels:
|
||||||
- drawing
|
- drawing
|
||||||
FlaggedDuringContentUpdateRestriction: 0
|
FlaggedDuringContentUpdateRestriction: 0
|
||||||
|
- m_GUID: 478bf0db3a98f1941a5d25414ba9f87d
|
||||||
|
m_Address: Train
|
||||||
|
m_ReadOnly: 0
|
||||||
|
m_SerializedLabels:
|
||||||
|
- drawing
|
||||||
|
FlaggedDuringContentUpdateRestriction: 0
|
||||||
- m_GUID: 62ae112e11b695a40b889d773a36f8bd
|
- m_GUID: 62ae112e11b695a40b889d773a36f8bd
|
||||||
m_Address: Elephant
|
m_Address: Elephant
|
||||||
m_ReadOnly: 0
|
m_ReadOnly: 0
|
||||||
@@ -63,6 +75,18 @@ MonoBehaviour:
|
|||||||
m_SerializedLabels:
|
m_SerializedLabels:
|
||||||
- drawing
|
- drawing
|
||||||
FlaggedDuringContentUpdateRestriction: 0
|
FlaggedDuringContentUpdateRestriction: 0
|
||||||
|
- m_GUID: 8cdc379bbe2529a419e5ce0eedcbb09e
|
||||||
|
m_Address: Giraffe
|
||||||
|
m_ReadOnly: 0
|
||||||
|
m_SerializedLabels:
|
||||||
|
- drawing
|
||||||
|
FlaggedDuringContentUpdateRestriction: 0
|
||||||
|
- m_GUID: 971be7b01d5eef24fa58094805ea92b9
|
||||||
|
m_Address: Kite
|
||||||
|
m_ReadOnly: 0
|
||||||
|
m_SerializedLabels:
|
||||||
|
- drawing
|
||||||
|
FlaggedDuringContentUpdateRestriction: 0
|
||||||
- m_GUID: 977dc7dac5ee6b543b8ed47c2299919e
|
- m_GUID: 977dc7dac5ee6b543b8ed47c2299919e
|
||||||
m_Address: Airplane
|
m_Address: Airplane
|
||||||
m_ReadOnly: 0
|
m_ReadOnly: 0
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<androidPackage spec="com.appsflyer:af-android-sdk:6.17.6"></androidPackage>
|
<androidPackage spec="com.appsflyer:af-android-sdk:6.17.6"></androidPackage>
|
||||||
<androidPackage spec="com.appsflyer:unity-wrapper:6.17.91"></androidPackage>
|
<androidPackage spec="com.appsflyer:unity-wrapper:6.17.91"></androidPackage>
|
||||||
<androidPackage spec="com.android.installreferrer:installreferrer:2.1"></androidPackage>
|
<androidPackage spec="com.android.installreferrer:installreferrer:2.1"></androidPackage>
|
||||||
<androidPackage spec="com.appsflyer:purchase-connector:2.2.0"></androidPackage>
|
|
||||||
</androidPackages>
|
</androidPackages>
|
||||||
|
|
||||||
<iosPods>
|
<iosPods>
|
||||||
|
|||||||
BIN
Assets/Darkmatter/.DS_Store
vendored
BIN
Assets/Darkmatter/.DS_Store
vendored
Binary file not shown.
@@ -14,6 +14,11 @@ public interface IColoringController
|
|||||||
void PaintRegion(string regionId, Color color);
|
void PaintRegion(string regionId, Color color);
|
||||||
IReadOnlyDictionary<string, Color> GetCurrentColors();
|
IReadOnlyDictionary<string, Color> GetCurrentColors();
|
||||||
UniTask PlayCompletionAnimationAsync(CancellationToken ct);
|
UniTask PlayCompletionAnimationAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
// True while the completion celebration is on screen (the real drawing is hidden).
|
||||||
|
// Capture must skip these frames so it never grabs the animation instead of the art.
|
||||||
|
bool IsPlayingCompletionAnimation { get; }
|
||||||
|
|
||||||
bool HasNonAuthoredColors { get; }
|
bool HasNonAuthoredColors { get; }
|
||||||
void ResetAll();
|
void ResetAll();
|
||||||
void Clear();
|
void Clear();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: d63726aaa9398f341a1a923bc039c446
|
guid: 224a4c0e39279476aa91487a2572bbdd
|
||||||
folderAsset: yes
|
folderAsset: yes
|
||||||
DefaultImporter:
|
DefaultImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Darkmatter.Core.Contracts.Features.SaveGate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-scene gate in front of "save to gallery": shows a kid-friendly prompt and, on confirm,
|
||||||
|
/// a rewarded ad. Root-scoped so any feature in any scene (capture, art book, …) can reuse it.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRewardedSaveGate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the prompt and (on Watch) a rewarded ad. Returns true if the caller should save.
|
||||||
|
/// </summary>
|
||||||
|
UniTask<bool> RequestSaveAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Briefly shows the shared "Saved to gallery!" confirmation. For callers without their own
|
||||||
|
/// success popup (e.g. the art book). No-op if the overlay has no success panel wired.
|
||||||
|
/// </summary>
|
||||||
|
UniTask ShowSavedAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c8ca87e84abf64a0eb0c32de824318b6
|
||||||
@@ -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,41 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
using Darkmatter.Core.Enums.Features.Tutorial;
|
||||||
|
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.
|
||||||
|
/// <paramref name="placement"/> overrides where the instruction bubble sits; <paramref name="offset"/>
|
||||||
|
/// nudges it from that slot (px, +x right / +y up).
|
||||||
|
/// </summary>
|
||||||
|
void ShowTap(RectTransform target, string message, bool blockInput, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// <paramref name="placement"/> overrides where the instruction bubble sits; <paramref name="offset"/>
|
||||||
|
/// nudges it from that slot (px, +x right / +y up).
|
||||||
|
/// </summary>
|
||||||
|
void ShowDrag(RectTransform from, RectTransform to, string message, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
|
||||||
|
|
||||||
|
/// <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, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1fae7a22d89c64857af91268a55bd564
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
namespace Darkmatter.Core.Contracts.Services.Analytics
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical analytics event names. Use these constants everywhere — never raw strings — so the
|
||||||
|
/// Firebase and Facebook sinks stay identical and typo-free. GA4 limit: 500 distinct event names.
|
||||||
|
/// Names are snake_case and <= 40 chars (Facebook App Events limit). Lives in Core so any feature
|
||||||
|
/// or service can reference them alongside <see cref="IAnalyticsService"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class AnalyticsEvents
|
||||||
|
{
|
||||||
|
// Onboarding / activation funnel
|
||||||
|
public const string IntroStarted = "intro_started";
|
||||||
|
public const string IntroCompleted = "intro_completed";
|
||||||
|
public const string TutorialStarted = "tutorial_started";
|
||||||
|
public const string TutorialStepCompleted = "tutorial_step_completed";
|
||||||
|
public const string TutorialCompleted = "tutorial_completed";
|
||||||
|
public const string PlayClicked = "play_clicked";
|
||||||
|
public const string FirstDrawingStarted = "first_drawing_started";
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public const string ColorbookOpened = "colorbook_opened";
|
||||||
|
public const string ArtbookOpened = "artbook_opened";
|
||||||
|
public const string MainMenuReturned = "main_menu_returned";
|
||||||
|
|
||||||
|
// Core gameplay loop
|
||||||
|
public const string DrawingSelected = "drawing_selected";
|
||||||
|
public const string DrawingStarted = "drawing_started";
|
||||||
|
public const string DrawingCompleted = "drawing_completed";
|
||||||
|
public const string DrawingAbandoned = "drawing_abandoned";
|
||||||
|
public const string DrawingSaved = "drawing_saved";
|
||||||
|
public const string ShapeBuilderStarted = "shape_builder_started";
|
||||||
|
public const string ShapeAssembled = "shape_assembled";
|
||||||
|
public const string ColorApplied = "color_applied";
|
||||||
|
|
||||||
|
// Progression & content (GA4 recommended level_start / level_complete)
|
||||||
|
public const string LevelStart = "level_start";
|
||||||
|
public const string LevelComplete = "level_complete";
|
||||||
|
public const string AllContentCompleted = "all_content_completed";
|
||||||
|
|
||||||
|
// Gallery capture (pre-existing)
|
||||||
|
public const string GallerySaveStarted = "gallery_save_started";
|
||||||
|
public const string GallerySaveCompleted = "gallery_save_completed";
|
||||||
|
|
||||||
|
// Monetization (GA4 recommended ad_impression)
|
||||||
|
public const string AdImpression = "ad_impression";
|
||||||
|
public const string AdClicked = "ad_clicked";
|
||||||
|
|
||||||
|
// Friction
|
||||||
|
public const string ErrorShown = "error_shown";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 44ad2e947d64f40a38febad220fa92a1
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace Darkmatter.Core.Contracts.Services.Analytics
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical analytics parameter keys. Pass a consistent set on every gameplay event so reports can
|
||||||
|
/// answer "which drawing did people quit on", not just "how many quit". Each key used in reports must
|
||||||
|
/// be registered as a Custom Dimension in the Firebase console. GA4 limit: 25 params per event.
|
||||||
|
/// GA4-recommended events (level_start/level_complete/ad_impression) reuse GA4's reserved param names
|
||||||
|
/// (level_name, success, value, currency, ad_platform, ad_format, ad_unit_name) so they're recognised.
|
||||||
|
/// </summary>
|
||||||
|
public static class AnalyticsParams
|
||||||
|
{
|
||||||
|
// Content identity
|
||||||
|
public const string DrawingId = "drawing_id";
|
||||||
|
public const string LevelName = "level_name"; // GA4 reserved (level_start/level_complete)
|
||||||
|
|
||||||
|
// Tutorial
|
||||||
|
public const string StepId = "step_id";
|
||||||
|
public const string StepIndex = "step_index";
|
||||||
|
|
||||||
|
// Gameplay metrics
|
||||||
|
public const string DurationSeconds = "duration_seconds";
|
||||||
|
public const string ColorsUsed = "colors_used";
|
||||||
|
public const string Attempts = "attempts";
|
||||||
|
public const string ApplyIndex = "apply_index";
|
||||||
|
public const string CompletionCount = "completion_count";
|
||||||
|
public const string Success = "success"; // GA4 reserved (level_complete)
|
||||||
|
|
||||||
|
// Monetization (GA4 ad_impression reserved names)
|
||||||
|
public const string Value = "value";
|
||||||
|
public const string Currency = "currency";
|
||||||
|
public const string Precision = "precision";
|
||||||
|
public const string AdPlatform = "ad_platform";
|
||||||
|
public const string AdFormat = "ad_format";
|
||||||
|
public const string AdUnitName = "ad_unit_name";
|
||||||
|
|
||||||
|
// Friction
|
||||||
|
public const string ErrorType = "error_type";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3a7d4e7d6ad7e4ba39f557ddc970890c
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Darkmatter.Core.Data.Signals.Features.AppBoot;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the intro/logo video begins playing at app boot. Paired with
|
||||||
|
/// <see cref="IntroCompletedSignal"/> to bound the top of the activation funnel.
|
||||||
|
/// </summary>
|
||||||
|
public record struct IntroStartedSignal();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ebc4c1a0dab8d4b4fb95428c30f57f35
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Darkmatter.Core.Data.Signals.Features.Coloring
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raised the moment every region of the current drawing has been painted away from its authored
|
||||||
|
/// (uncoloured) default — i.e. the picture is fully coloured. Published right after the
|
||||||
|
/// <see cref="ColorAppliedSignal"/> that completes it. Used by the onboarding tutorial to hold the
|
||||||
|
/// "Tap Next" prompt until the child has coloured the whole picture.
|
||||||
|
/// </summary>
|
||||||
|
public record struct AllRegionsColoredSignal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 381ea33520a140d8b5639279c5390d2c
|
||||||
@@ -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 @@
|
|||||||
|
namespace Darkmatter.Core.Data.Signals.Features.GameplayFlow
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raised once the player has completed every drawing in the catalog (no content left). Published by
|
||||||
|
/// the gameplay flow, which owns the catalog + progression; analytics dedupes it to once-ever.
|
||||||
|
/// </summary>
|
||||||
|
public record struct AllContentCompletedSignal(int CompletedCount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3dc3c138deba5452ebb0d84ad3d4bf44
|
||||||
@@ -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
|
||||||
@@ -6,9 +6,13 @@ namespace Darkmatter.Core.Data.Static.Features.ShapeBuilder
|
|||||||
menuName = "Darkmatter/ShapeBuilder/Config")]
|
menuName = "Darkmatter/ShapeBuilder/Config")]
|
||||||
public sealed class ShapeBuilderConfig : ScriptableObject
|
public sealed class ShapeBuilderConfig : ScriptableObject
|
||||||
{
|
{
|
||||||
[Header("Radii (canvas units; reference resolution 2048x2048)")]
|
// Single catch radius for both preview and snap, expressed as a multiple of
|
||||||
[SerializeField] private float snapRadius = 100f;
|
// EACH slot's own half-diagonal — so a big slot gets a big catch and a small
|
||||||
[SerializeField] private float previewRadius = 200f;
|
// slot a small one, instead of one flat distance for all. 1 = exactly the
|
||||||
|
// slot's circumscribed circle; larger = more forgiving. Computed in canvas
|
||||||
|
// reference units, so it feels the same on every screen resolution/aspect.
|
||||||
|
[Header("Catch radius (multiple of slot half-diagonal)")]
|
||||||
|
[SerializeField, Range(0.25f, 2.5f)] private float catchRadiusScale = 1.1f;
|
||||||
|
|
||||||
[Header("Tween durations (seconds)")]
|
[Header("Tween durations (seconds)")]
|
||||||
[SerializeField] private float snapDuration = 0.25f;
|
[SerializeField] private float snapDuration = 0.25f;
|
||||||
@@ -16,16 +20,13 @@ namespace Darkmatter.Core.Data.Static.Features.ShapeBuilder
|
|||||||
|
|
||||||
[Header("Drag")]
|
[Header("Drag")]
|
||||||
[SerializeField, Range(1f, 2f)] private float dragScale = 1.15f;
|
[SerializeField, Range(1f, 2f)] private float dragScale = 1.15f;
|
||||||
|
[SerializeField, Range(0f, 1f)] private float dragAlpha = 0.7f;
|
||||||
|
|
||||||
[Header("Preview easing")]
|
public float CatchRadiusScale => catchRadiusScale;
|
||||||
[SerializeField] private AnimationCurve previewCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
|
||||||
|
|
||||||
public float SnapRadius => snapRadius;
|
|
||||||
public float PreviewRadius => previewRadius;
|
|
||||||
public float SnapDuration => snapDuration;
|
public float SnapDuration => snapDuration;
|
||||||
public float ReturnDuration => returnDuration;
|
public float ReturnDuration => returnDuration;
|
||||||
public float DragScale => dragScale;
|
public float DragScale => dragScale;
|
||||||
public AnimationCurve PreviewCurve => previewCurve;
|
public float DragAlpha => dragAlpha;
|
||||||
|
|
||||||
public Vector2 DragSizeDelta(ShapeSO shape) =>
|
public Vector2 DragSizeDelta(ShapeSO shape) =>
|
||||||
shape != null ? shape.DefaultSizeDelta : new Vector2(256, 256);
|
shape != null ? shape.DefaultSizeDelta : new Vector2(256, 256);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1cda3e3840cbd4eccbe7ad810dee920d
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Darkmatter.Core.Enums.Features.Tutorial;
|
||||||
|
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 finishColoringText = "Color the whole picture!";
|
||||||
|
[SerializeField] private string nextText = "Tap Next when you're done!";
|
||||||
|
[SerializeField] private string doneText = "Yay! You did it!";
|
||||||
|
|
||||||
|
[Header("Bubble placement + offset per step (Auto = smart; offset nudges from the slot in px, +x right / +y up)")]
|
||||||
|
[SerializeField] private BubblePlacement pickBubble = BubblePlacement.Auto;
|
||||||
|
[SerializeField] private Vector2 pickBubbleOffset;
|
||||||
|
[SerializeField] private BubblePlacement dragBubble = BubblePlacement.Auto;
|
||||||
|
[SerializeField] private Vector2 dragBubbleOffset;
|
||||||
|
[SerializeField] private BubblePlacement finishBubble = BubblePlacement.Auto;
|
||||||
|
[SerializeField] private Vector2 finishBubbleOffset;
|
||||||
|
[SerializeField] private BubblePlacement colorBubble = BubblePlacement.Auto;
|
||||||
|
[SerializeField] private Vector2 colorBubbleOffset;
|
||||||
|
[SerializeField] private BubblePlacement paintBubble = BubblePlacement.Auto;
|
||||||
|
[SerializeField] private Vector2 paintBubbleOffset;
|
||||||
|
[SerializeField] private BubblePlacement finishColoringBubble = BubblePlacement.Auto;
|
||||||
|
[SerializeField] private Vector2 finishColoringBubbleOffset;
|
||||||
|
[SerializeField] private BubblePlacement nextBubble = BubblePlacement.Auto;
|
||||||
|
[SerializeField] private Vector2 nextBubbleOffset;
|
||||||
|
[SerializeField] private BubblePlacement doneBubble = BubblePlacement.Auto;
|
||||||
|
[SerializeField] private Vector2 doneBubbleOffset;
|
||||||
|
|
||||||
|
[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 FinishColoringText => finishColoringText;
|
||||||
|
public string NextText => nextText;
|
||||||
|
public string DoneText => doneText;
|
||||||
|
|
||||||
|
public BubblePlacement PickBubble => pickBubble;
|
||||||
|
public BubblePlacement DragBubble => dragBubble;
|
||||||
|
public BubblePlacement FinishBubble => finishBubble;
|
||||||
|
public BubblePlacement ColorBubble => colorBubble;
|
||||||
|
public BubblePlacement PaintBubble => paintBubble;
|
||||||
|
public BubblePlacement FinishColoringBubble => finishColoringBubble;
|
||||||
|
public BubblePlacement NextBubble => nextBubble;
|
||||||
|
public BubblePlacement DoneBubble => doneBubble;
|
||||||
|
|
||||||
|
public Vector2 PickBubbleOffset => pickBubbleOffset;
|
||||||
|
public Vector2 DragBubbleOffset => dragBubbleOffset;
|
||||||
|
public Vector2 FinishBubbleOffset => finishBubbleOffset;
|
||||||
|
public Vector2 ColorBubbleOffset => colorBubbleOffset;
|
||||||
|
public Vector2 PaintBubbleOffset => paintBubbleOffset;
|
||||||
|
public Vector2 FinishColoringBubbleOffset => finishColoringBubbleOffset;
|
||||||
|
public Vector2 NextBubbleOffset => nextBubbleOffset;
|
||||||
|
public Vector2 DoneBubbleOffset => doneBubbleOffset;
|
||||||
|
|
||||||
|
public float StepTimeoutSeconds => stepTimeoutSeconds;
|
||||||
|
public float ActionTimeoutSeconds => actionTimeoutSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5c513430cc85f4357b3df1da019bf554
|
||||||
8
Assets/Darkmatter/Code/Core/Enums/Features/Tutorial.meta
Normal file
8
Assets/Darkmatter/Code/Core/Enums/Features/Tutorial.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4b28ea43ea1b942cab1264b52ec34a60
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Darkmatter.Core.Enums.Features.Tutorial
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Where the tutorial instruction bubble sits for a given step. <see cref="Auto"/> keeps the
|
||||||
|
/// smart default (sits opposite the spotlighted target so it never covers it; pinned to a screen
|
||||||
|
/// edge for the drag step; upper-centre for target-less hints). The other values override that
|
||||||
|
/// with a fixed vertical slot, horizontally centred.
|
||||||
|
/// </summary>
|
||||||
|
public enum BubblePlacement
|
||||||
|
{
|
||||||
|
Auto,
|
||||||
|
Top,
|
||||||
|
Center,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 61e68e58d45cf4314886e2a414e3856d
|
||||||
@@ -30,6 +30,9 @@ namespace Darkmatter.Features.AppBoot.Flow
|
|||||||
|
|
||||||
public async UniTask StartAsync(CancellationToken cancellation = default)
|
public async UniTask StartAsync(CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
|
#if UNITY_ANDROID || UNITY_IOS
|
||||||
|
Application.targetFrameRate = 60;
|
||||||
|
#endif
|
||||||
Screen.sleepTimeout = SleepTimeout.NeverSleep;
|
Screen.sleepTimeout = SleepTimeout.NeverSleep;
|
||||||
await _progression.LoadAsync();
|
await _progression.LoadAsync();
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ namespace Darkmatter.Features.AppBoot.Flow
|
|||||||
|
|
||||||
player.loopPointReached += OnDone;
|
player.loopPointReached += OnDone;
|
||||||
player.Play();
|
player.Play();
|
||||||
|
_eventBus.Publish(new IntroStartedSignal());
|
||||||
await _sceneService.LoadSceneAsync(nameof(GameScene.MainMenu), null, cancellation);
|
await _sceneService.LoadSceneAsync(nameof(GameScene.MainMenu), null, cancellation);
|
||||||
|
|
||||||
await tcs.Task.AttachExternalCancellation(cancellation);
|
await tcs.Task.AttachExternalCancellation(cancellation);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Cysharp.Threading.Tasks;
|
|||||||
using Darkmatter.Core;
|
using Darkmatter.Core;
|
||||||
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
|
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
|
||||||
using Darkmatter.Core.Contracts.Features.Progression;
|
using Darkmatter.Core.Contracts.Features.Progression;
|
||||||
|
using Darkmatter.Core.Contracts.Features.SaveGate;
|
||||||
using Darkmatter.Core.Contracts.Services.Gallery;
|
using Darkmatter.Core.Contracts.Services.Gallery;
|
||||||
using Darkmatter.Core.Data.Signals.Features.Drawing;
|
using Darkmatter.Core.Data.Signals.Features.Drawing;
|
||||||
using Darkmatter.Libs.Observer;
|
using Darkmatter.Libs.Observer;
|
||||||
@@ -22,6 +23,7 @@ namespace Darkmatter.Features.Artbook
|
|||||||
private readonly IProgressionSystem _progression;
|
private readonly IProgressionSystem _progression;
|
||||||
private readonly IDrawingTemplateCatalog _catalog;
|
private readonly IDrawingTemplateCatalog _catalog;
|
||||||
private readonly IGalleryService _gallery;
|
private readonly IGalleryService _gallery;
|
||||||
|
private readonly IRewardedSaveGate _saveGate;
|
||||||
|
|
||||||
private readonly List<ArtbookEntry> _entries = new();
|
private readonly List<ArtbookEntry> _entries = new();
|
||||||
private readonly List<Sprite> _ownedSprites = new();
|
private readonly List<Sprite> _ownedSprites = new();
|
||||||
@@ -36,13 +38,15 @@ namespace Darkmatter.Features.Artbook
|
|||||||
IEventBus eventBus,
|
IEventBus eventBus,
|
||||||
IProgressionSystem progression,
|
IProgressionSystem progression,
|
||||||
IDrawingTemplateCatalog catalog,
|
IDrawingTemplateCatalog catalog,
|
||||||
IGalleryService gallery)
|
IGalleryService gallery,
|
||||||
|
IRewardedSaveGate saveGate)
|
||||||
{
|
{
|
||||||
_view = view;
|
_view = view;
|
||||||
_eventBus = eventBus;
|
_eventBus = eventBus;
|
||||||
_progression = progression;
|
_progression = progression;
|
||||||
_catalog = catalog;
|
_catalog = catalog;
|
||||||
_gallery = gallery;
|
_gallery = gallery;
|
||||||
|
_saveGate = saveGate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
@@ -151,7 +155,11 @@ namespace Darkmatter.Features.Artbook
|
|||||||
{
|
{
|
||||||
if (!entry.HasValue || entry.Value.Thumbnail == null) return;
|
if (!entry.HasValue || entry.Value.Thumbnail == null) return;
|
||||||
var ct = _cts?.Token ?? CancellationToken.None;
|
var ct = _cts?.Token ?? CancellationToken.None;
|
||||||
|
// Same kid-friendly prompt + rewarded ad as the gameplay save button.
|
||||||
|
if (!await _saveGate.RequestSaveAsync(ct)) return;
|
||||||
await _gallery.SaveImageAsync(entry.Value.Thumbnail, entry.Value.Name, ct);
|
await _gallery.SaveImageAsync(entry.Value.Thumbnail, entry.Value.Name, ct);
|
||||||
|
// The art book has no success popup of its own, so use the shared toast.
|
||||||
|
await _saveGate.ShowSavedAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleLeftEditClicked() => OpenForEdit(GetLeftEntry());
|
private void HandleLeftEditClicked() => OpenForEdit(GetLeftEntry());
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace Darkmatter.Features.Capture
|
|||||||
builder.RegisterInstance(new CaptureConfig(captureScale));
|
builder.RegisterInstance(new CaptureConfig(captureScale));
|
||||||
builder.Register<ICaptureFeature, CaptureSystem>(Lifetime.Singleton);
|
builder.Register<ICaptureFeature, CaptureSystem>(Lifetime.Singleton);
|
||||||
|
|
||||||
|
// IRewardedSaveGate is resolved from the root scope (SaveGateModule in Boot).
|
||||||
if (captureButtonView != null)
|
if (captureButtonView != null)
|
||||||
builder.RegisterEntryPoint<CaptureButtonPresenter>().WithParameter(captureButtonView);
|
builder.RegisterEntryPoint<CaptureButtonPresenter>().WithParameter(captureButtonView);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Cysharp.Threading.Tasks;
|
using Cysharp.Threading.Tasks;
|
||||||
using Darkmatter.Core.Contracts.Features.Capture;
|
using Darkmatter.Core.Contracts.Features.Capture;
|
||||||
|
using Darkmatter.Core.Contracts.Features.Coloring;
|
||||||
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
|
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
|
||||||
using Darkmatter.Core.Contracts.Features.GameplayFlow;
|
using Darkmatter.Core.Contracts.Features.GameplayFlow;
|
||||||
using Darkmatter.Core.Contracts.Features.Progression;
|
using Darkmatter.Core.Contracts.Features.Progression;
|
||||||
@@ -21,6 +22,7 @@ namespace Darkmatter.Features.Capture
|
|||||||
private readonly CaptureConfig _config;
|
private readonly CaptureConfig _config;
|
||||||
private readonly IProgressionSystem _progression;
|
private readonly IProgressionSystem _progression;
|
||||||
private readonly IDrawingTemplateCatalog _catalog;
|
private readonly IDrawingTemplateCatalog _catalog;
|
||||||
|
private readonly IColoringController _coloring;
|
||||||
|
|
||||||
public CaptureSystem(
|
public CaptureSystem(
|
||||||
ICaptureService captureService,
|
ICaptureService captureService,
|
||||||
@@ -29,7 +31,8 @@ namespace Darkmatter.Features.Capture
|
|||||||
IEventBus bus,
|
IEventBus bus,
|
||||||
CaptureConfig config,
|
CaptureConfig config,
|
||||||
IProgressionSystem progression,
|
IProgressionSystem progression,
|
||||||
IDrawingTemplateCatalog catalog)
|
IDrawingTemplateCatalog catalog,
|
||||||
|
IColoringController coloring)
|
||||||
{
|
{
|
||||||
_captureService = captureService;
|
_captureService = captureService;
|
||||||
_galleryService = galleryService;
|
_galleryService = galleryService;
|
||||||
@@ -38,10 +41,16 @@ namespace Darkmatter.Features.Capture
|
|||||||
_config = config;
|
_config = config;
|
||||||
_progression = progression;
|
_progression = progression;
|
||||||
_catalog = catalog;
|
_catalog = catalog;
|
||||||
|
_coloring = coloring;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async UniTask<byte[]> CapturePngAsync(bool saveToGallery = false, CancellationToken ct = default)
|
public async UniTask<byte[]> CapturePngAsync(bool saveToGallery = false, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// The completion celebration hides the real drawing and shows an animation object
|
||||||
|
// under PaperRoot. Capturing then would grab the animation, not the art, so skip.
|
||||||
|
// A null result keeps the existing thumbnail and skips the gallery write (both no-op on null).
|
||||||
|
if (_coloring.IsPlayingCompletionAnimation) return null;
|
||||||
|
|
||||||
var png = await _captureService.CapturePngAsync(_refs.PaperRoot.gameObject, _config.CaptureScale, ct);
|
var png = await _captureService.CapturePngAsync(_refs.PaperRoot.gameObject, _config.CaptureScale, ct);
|
||||||
if (!saveToGallery || png == null || png.Length == 0) return png;
|
if (!saveToGallery || png == null || png.Length == 0) return png;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Threading;
|
||||||
using Cysharp.Threading.Tasks;
|
using Cysharp.Threading.Tasks;
|
||||||
using Darkmatter.Core.Contracts.Features.Capture;
|
using Darkmatter.Core.Contracts.Features.Capture;
|
||||||
|
using Darkmatter.Core.Contracts.Features.SaveGate;
|
||||||
using VContainer.Unity;
|
using VContainer.Unity;
|
||||||
|
|
||||||
namespace Darkmatter.Features.Capture.UI
|
namespace Darkmatter.Features.Capture.UI
|
||||||
@@ -9,15 +11,20 @@ namespace Darkmatter.Features.Capture.UI
|
|||||||
{
|
{
|
||||||
private readonly CaptureButtonView _view;
|
private readonly CaptureButtonView _view;
|
||||||
private readonly ICaptureFeature _capture;
|
private readonly ICaptureFeature _capture;
|
||||||
|
private readonly IRewardedSaveGate _saveGate;
|
||||||
|
|
||||||
public CaptureButtonPresenter(CaptureButtonView view, ICaptureFeature capture)
|
private CancellationTokenSource _cts;
|
||||||
|
|
||||||
|
public CaptureButtonPresenter(CaptureButtonView view, ICaptureFeature capture, IRewardedSaveGate saveGate)
|
||||||
{
|
{
|
||||||
_view = view;
|
_view = view;
|
||||||
_capture = capture;
|
_capture = capture;
|
||||||
|
_saveGate = saveGate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
_view.OnCaptureClicked += HandleCaptureClicked;
|
_view.OnCaptureClicked += HandleCaptureClicked;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +32,16 @@ namespace Darkmatter.Features.Capture.UI
|
|||||||
|
|
||||||
private async UniTaskVoid CaptureAsync()
|
private async UniTaskVoid CaptureAsync()
|
||||||
{
|
{
|
||||||
|
var ct = _cts?.Token ?? CancellationToken.None;
|
||||||
_view.SetInteractable(false);
|
_view.SetInteractable(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _capture.CapturePngAsync(saveToGallery: true);
|
// Kid-friendly prompt + rewarded ad (shared, cross-scene gate). The save then
|
||||||
|
// publishes the existing signals that drive the "Saved to gallery!" popup.
|
||||||
|
if (await _saveGate.RequestSaveAsync(ct))
|
||||||
|
await _capture.CapturePngAsync(saveToGallery: true, ct);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_view.SetInteractable(true);
|
_view.SetInteractable(true);
|
||||||
@@ -39,6 +51,9 @@ namespace Darkmatter.Features.Capture.UI
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_view.OnCaptureClicked -= HandleCaptureClicked;
|
_view.OnCaptureClicked -= HandleCaptureClicked;
|
||||||
|
_cts?.Cancel();
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
|||||||
private IDisposable _selectedSub;
|
private IDisposable _selectedSub;
|
||||||
private IDisposable _returnToMainMenuSubscription;
|
private IDisposable _returnToMainMenuSubscription;
|
||||||
private bool _navigatingToGameplay;
|
private bool _navigatingToGameplay;
|
||||||
|
private int _selectCount;
|
||||||
private CancellationTokenSource _scopeCts;
|
private CancellationTokenSource _scopeCts;
|
||||||
|
|
||||||
public ColorbookFlowController(
|
public ColorbookFlowController(
|
||||||
@@ -50,12 +51,21 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
|||||||
public async UniTask StartAsync(CancellationToken cancellation = new CancellationToken())
|
public async UniTask StartAsync(CancellationToken cancellation = new CancellationToken())
|
||||||
{
|
{
|
||||||
_scopeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
_scopeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||||
|
// Grab the token value up front. If the scope is torn down (scene unload) while the
|
||||||
|
// init below is in flight, the await resumes during disposal — when _scopeCts is already
|
||||||
|
// disposed and its .Token getter would throw ObjectDisposedException. The captured struct
|
||||||
|
// stays safe to read.
|
||||||
|
var ct = _scopeCts.Token;
|
||||||
_returnToMainMenuSubscription = _bus.Subscribe<ReturnToMainMenuSignal>(OnReturnToMainMenu);
|
_returnToMainMenuSubscription = _bus.Subscribe<ReturnToMainMenuSignal>(OnReturnToMainMenu);
|
||||||
_loadingScreen.SetProgress(1f);
|
_loadingScreen.SetProgress(1f);
|
||||||
await _drawingCatalog.InitializeAsync(cancellation);
|
await _drawingCatalog.InitializeAsync(ct);
|
||||||
|
// scope disposed mid-init; nothing left to do. Check the external token too — it's the
|
||||||
|
// one VContainer cancels on teardown, and the linked _scopeCts may lag the callback order.
|
||||||
|
if (cancellation.IsCancellationRequested || ct.IsCancellationRequested) return;
|
||||||
|
|
||||||
if (!_navigatingToGameplay) _loadingScreen.Hide();
|
if (!_navigatingToGameplay) _loadingScreen.Hide();
|
||||||
|
|
||||||
PrewarmInterstitialAdAsync(_scopeCts.Token).Forget();
|
PrewarmInterstitialAdAsync(ct).Forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async UniTaskVoid PrewarmInterstitialAdAsync(CancellationToken ct)
|
private async UniTaskVoid PrewarmInterstitialAdAsync(CancellationToken ct)
|
||||||
@@ -101,9 +111,14 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
|||||||
_loadingScreen.Show();
|
_loadingScreen.Show();
|
||||||
_loadingScreen.SetProgress(0f);
|
_loadingScreen.SetProgress(0f);
|
||||||
|
|
||||||
// Fire the interstitial but never await it: the ad overlays the transition while the level
|
// Frequency cap: show an interstitial on every 2nd level open (2nd, 4th, ...). On skip turns
|
||||||
// loads underneath, so a missed/dropped ad callback can't stall the flow at 0% anymore.
|
// just keep one prewarmed so the next show has an ad ready. Fire-and-forget either way — the
|
||||||
ShowInterstitialAdAsync(ct).Forget();
|
// ad overlays the transition while the level loads underneath, so a missed/dropped ad callback
|
||||||
|
// can't stall the flow at 0% anymore.
|
||||||
|
if (++_selectCount % 2 == 0)
|
||||||
|
ShowInterstitialAdAsync(ct).Forget();
|
||||||
|
else
|
||||||
|
PrewarmInterstitialAdAsync(ct).Forget();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public class ColoringController : IColoringController, IDisposable
|
|||||||
private GameObject _colorButtonPrefab;
|
private GameObject _colorButtonPrefab;
|
||||||
private GameObject _completionAnimationInstance;
|
private GameObject _completionAnimationInstance;
|
||||||
private CompletionAnimationView _completionAnimationView;
|
private CompletionAnimationView _completionAnimationView;
|
||||||
|
private bool _isPlayingCompletionAnimation;
|
||||||
private readonly List<ColorRegionView> _regions = new();
|
private readonly List<ColorRegionView> _regions = new();
|
||||||
private readonly List<ColorButton> _buttons = new();
|
private readonly List<ColorButton> _buttons = new();
|
||||||
private readonly Dictionary<string, Color> _authoredColors = new();
|
private readonly Dictionary<string, Color> _authoredColors = new();
|
||||||
@@ -78,6 +79,21 @@ public class ColoringController : IColoringController, IDisposable
|
|||||||
if (from != color)
|
if (from != color)
|
||||||
_history.Push(new ColorRegionCommand(region, from, color));
|
_history.Push(new ColorRegionCommand(region, from, color));
|
||||||
_bus.Publish(new ColorAppliedSignal(regionId, color));
|
_bus.Publish(new ColorAppliedSignal(regionId, color));
|
||||||
|
if (AllRegionsColored())
|
||||||
|
_bus.Publish(new AllRegionsColoredSignal());
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once every region has been painted away from its authored (uncoloured) default.
|
||||||
|
private bool AllRegionsColored()
|
||||||
|
{
|
||||||
|
if (_regions.Count == 0) return false;
|
||||||
|
foreach (var region in _regions)
|
||||||
|
{
|
||||||
|
if (region == null) continue;
|
||||||
|
if (_authoredColors.TryGetValue(region.RegionId, out var authored) && region.Color == authored)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyDictionary<string, Color> GetCurrentColors()
|
public IReadOnlyDictionary<string, Color> GetCurrentColors()
|
||||||
@@ -88,17 +104,21 @@ public class ColoringController : IColoringController, IDisposable
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsPlayingCompletionAnimation => _isPlayingCompletionAnimation;
|
||||||
|
|
||||||
public async UniTask PlayCompletionAnimationAsync(CancellationToken ct)
|
public async UniTask PlayCompletionAnimationAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_completionAnimationInstance == null || _completionAnimationView == null) return;
|
if (_completionAnimationInstance == null || _completionAnimationView == null) return;
|
||||||
if (_colorInstance != null) _colorInstance.SetActive(false);
|
if (_colorInstance != null) _colorInstance.SetActive(false);
|
||||||
_completionAnimationInstance.SetActive(true);
|
_completionAnimationInstance.SetActive(true);
|
||||||
|
_isPlayingCompletionAnimation = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _completionAnimationView.PlayAsync(ct);
|
await _completionAnimationView.PlayAsync(ct);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
_isPlayingCompletionAnimation = false;
|
||||||
if (_completionAnimationInstance != null)
|
if (_completionAnimationInstance != null)
|
||||||
_completionAnimationInstance.SetActive(false);
|
_completionAnimationInstance.SetActive(false);
|
||||||
}
|
}
|
||||||
@@ -133,6 +153,7 @@ public class ColoringController : IColoringController, IDisposable
|
|||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
_history.Drop();
|
_history.Drop();
|
||||||
|
_isPlayingCompletionAnimation = false;
|
||||||
|
|
||||||
foreach (var button in _buttons)
|
foreach (var button in _buttons)
|
||||||
if (button != null)
|
if (button != null)
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
using System;
|
using System;
|
||||||
using Darkmatter.Core.Contracts.Features.Coloring;
|
using Darkmatter.Core.Contracts.Features.Coloring;
|
||||||
|
using Darkmatter.Core.Data.Signals.Features.Coloring;
|
||||||
|
using Darkmatter.Libs.Observer;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Darkmatter.Features.Coloring.Systems;
|
namespace Darkmatter.Features.Coloring.Systems;
|
||||||
|
|
||||||
public class ColoringStateRepository
|
public class ColoringStateRepository
|
||||||
{
|
{
|
||||||
|
private readonly IEventBus _bus;
|
||||||
|
|
||||||
|
public ColoringStateRepository(IEventBus bus)
|
||||||
|
{
|
||||||
|
_bus = bus;
|
||||||
|
}
|
||||||
|
|
||||||
public IColorPalette SelectedPalette { get; private set; }
|
public IColorPalette SelectedPalette { get; private set; }
|
||||||
public int SelectedIndex { get; private set; }
|
public int SelectedIndex { get; private set; }
|
||||||
public Color SelectedColor =>
|
public Color SelectedColor =>
|
||||||
@@ -29,5 +38,9 @@ public class ColoringStateRepository
|
|||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
SelectedIndex = idx;
|
SelectedIndex = idx;
|
||||||
SelectedIndexChanged?.Invoke();
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ namespace Darkmatter.Features.Coloring.UI
|
|||||||
rt.localScale = startScale;
|
rt.localScale = startScale;
|
||||||
rt.localRotation = Quaternion.identity;
|
rt.localRotation = Quaternion.identity;
|
||||||
await Tween.Scale(rt, endScale, duration, ease).ToUniTask(cancellationToken: ct);
|
await Tween.Scale(rt, endScale, duration, ease).ToUniTask(cancellationToken: ct);
|
||||||
|
// The view can be destroyed mid-animation (flow advances, Back/Clear, scene teardown).
|
||||||
|
// PrimeTween auto-stops the tween on target death and ToUniTask resolves normally, so
|
||||||
|
// re-check the (Unity fake-null) transform before touching it again — otherwise the
|
||||||
|
// localRotation write below hits a freed native object and throws NullReferenceException.
|
||||||
|
if (rt == null) return;
|
||||||
|
|
||||||
await Tween.LocalRotation(rt, new Vector3(0f, 0f, wiggleAngle), wiggleDuration, Ease.InOutSine,
|
await Tween.LocalRotation(rt, new Vector3(0f, 0f, wiggleAngle), wiggleDuration, Ease.InOutSine,
|
||||||
cycles: wiggleCycles * 2, cycleMode: CycleMode.Yoyo)
|
cycles: wiggleCycles * 2, cycleMode: CycleMode.Yoyo)
|
||||||
.ToUniTask(cancellationToken: ct);
|
.ToUniTask(cancellationToken: ct);
|
||||||
|
if (rt == null) return;
|
||||||
rt.localRotation = Quaternion.identity;
|
rt.localRotation = Quaternion.identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ namespace Darkmatter.Features.DrawingCatalog
|
|||||||
|
|
||||||
// Unblock InitializeAsync: items are now on screen, so the loading screen can hide.
|
// Unblock InitializeAsync: items are now on screen, so the loading screen can hide.
|
||||||
_controller.NotifyPopulated();
|
_controller.NotifyPopulated();
|
||||||
|
|
||||||
|
// Cue the first-run tutorial that the catalog grid is on screen and tappable.
|
||||||
|
_eventBus.Publish(new DrawingCatalogReadySignal());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
|||||||
private IDrawingTemplate _template;
|
private IDrawingTemplate _template;
|
||||||
private string _templateId;
|
private string _templateId;
|
||||||
private DrawingPhase _phase = DrawingPhase.ShapeBuilding;
|
private DrawingPhase _phase = DrawingPhase.ShapeBuilding;
|
||||||
|
private bool _allContentReported;
|
||||||
|
|
||||||
private IDisposable _assembledSub;
|
private IDisposable _assembledSub;
|
||||||
private IDisposable _colorAppliedSub;
|
private IDisposable _colorAppliedSub;
|
||||||
@@ -136,6 +137,12 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
|||||||
|
|
||||||
public async UniTask NextAsync(CancellationToken ct)
|
public async UniTask NextAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Drop any debounced autosave so it can't fire during/after the completion
|
||||||
|
// animation and capture the animation (or an empty paper) into the thumbnail.
|
||||||
|
_autosaveCts?.Cancel();
|
||||||
|
_autosaveCts?.Dispose();
|
||||||
|
_autosaveCts = null;
|
||||||
|
|
||||||
await SaveCurrentAsync(ct);
|
await SaveCurrentAsync(ct);
|
||||||
_refs.Confetti.Play();
|
_refs.Confetti.Play();
|
||||||
_sfx.Play(SfxId.FireWorkLaunch);
|
_sfx.Play(SfxId.FireWorkLaunch);
|
||||||
@@ -145,6 +152,16 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
|||||||
var progressAfter = _progression.GetProgress(_templateId);
|
var progressAfter = _progression.GetProgress(_templateId);
|
||||||
_bus.Publish(new DrawingCompletedSignal(_templateId, progressAfter?.completionCount ?? 1));
|
_bus.Publish(new DrawingCompletedSignal(_templateId, progressAfter?.completionCount ?? 1));
|
||||||
|
|
||||||
|
// Player has cleared the whole catalog → surface the "ran out of content" milestone. Guarded
|
||||||
|
// per session here; analytics dedupes it to once-ever.
|
||||||
|
if (!_allContentReported &&
|
||||||
|
_catalog.AllTemplateIds.Count > 0 &&
|
||||||
|
_progression.CompletedTemplateIds.Count >= _catalog.AllTemplateIds.Count)
|
||||||
|
{
|
||||||
|
_allContentReported = true;
|
||||||
|
_bus.Publish(new AllContentCompletedSignal(_progression.CompletedTemplateIds.Count));
|
||||||
|
}
|
||||||
|
|
||||||
var nextId = _catalog.GetNextTemplate(_templateId);
|
var nextId = _catalog.GetNextTemplate(_templateId);
|
||||||
if (string.IsNullOrEmpty(nextId))
|
if (string.IsNullOrEmpty(nextId))
|
||||||
{
|
{
|
||||||
|
|||||||
8
Assets/Darkmatter/Code/Features/SaveGate.meta
Normal file
8
Assets/Darkmatter/Code/Features/SaveGate.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e6434fcc29f4e4949be4be8115fda2b9
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Features.SaveGate",
|
||||||
|
"rootNamespace": "Darkmatter.Features.SaveGate",
|
||||||
|
"references": [
|
||||||
|
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
||||||
|
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
|
||||||
|
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
||||||
|
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||||
|
"GUID:6055be8ebefd69e48b49212b09b47b2f"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"precompiledReferences": [],
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 48cf1e0dac50c4952b2f27e17e8a065a
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Assets/Darkmatter/Code/Features/SaveGate/Installers.meta
Normal file
8
Assets/Darkmatter/Code/Features/SaveGate/Installers.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8f38ec6a6d3544172b3fdf20e8d31800
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Darkmatter.Features.SaveGate.Systems;
|
||||||
|
using Darkmatter.Features.SaveGate.UI;
|
||||||
|
using Darkmatter.Libs.Installers;
|
||||||
|
using UnityEngine;
|
||||||
|
using VContainer;
|
||||||
|
using VContainer.Unity;
|
||||||
|
|
||||||
|
namespace Darkmatter.Features.SaveGate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Root module (add to Boot's RootLifetimeScope service modules) so the gate + overlay are
|
||||||
|
/// resolvable and persistent across every scene. If no overlay is assigned it spawns a
|
||||||
|
/// self-building one at runtime — no scene/prefab setup required.
|
||||||
|
/// </summary>
|
||||||
|
public class SaveGateModule : MonoBehaviour, IModule
|
||||||
|
{
|
||||||
|
[SerializeField] private SaveGalleryOverlayView overlayView;
|
||||||
|
[SerializeField, Tooltip("Save anyway when the rewarded ad has no fill or fails to show.")]
|
||||||
|
private bool saveWithoutAdOnFailure = true;
|
||||||
|
|
||||||
|
public void Register(IContainerBuilder builder)
|
||||||
|
{
|
||||||
|
|
||||||
|
// Explicit type so a null overlay still resolves; the gate then lets saving proceed
|
||||||
|
// without a prompt.
|
||||||
|
builder.RegisterEntryPoint<RewardedSaveGate>()
|
||||||
|
.WithParameter(typeof(SaveGalleryOverlayView), overlayView)
|
||||||
|
.WithParameter(new SaveGateConfig(saveWithoutAdOnFailure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 46ff2e3f5fac64fc9a40fe00a37bd9c8
|
||||||
61
Assets/Darkmatter/Code/Features/SaveGate/SETUP.md
Normal file
61
Assets/Darkmatter/Code/Features/SaveGate/SETUP.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# SaveGate — cross-scene rewarded-ad save gate (manual Unity wiring)
|
||||||
|
|
||||||
|
Code is complete and compiles. The gate does **not** show its prompt until the overlay is built in
|
||||||
|
**Boot** and assigned to `SaveGateModule`. Until then both callers fall back to saving directly
|
||||||
|
(no prompt), so nothing breaks pre-wiring.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A single root-scoped gate (`IRewardedSaveGate` → `RewardedSaveGate`) used by **every** save-to-gallery
|
||||||
|
button, in any scene:
|
||||||
|
|
||||||
|
1. Tap save → **kid-friendly prompt** ("Watch a short video to save your picture to the gallery!")
|
||||||
|
with **Watch** / **Cancel**.
|
||||||
|
2. **Cancel** → nothing saved.
|
||||||
|
3. **Watch** → **rewarded ad** (`AdFormat.Rewarded`). Reward earned → caller saves.
|
||||||
|
4. Ad closed early → not saved. No fill / load-or-show error → saved anyway iff
|
||||||
|
**Save Without Ad On Failure** (default ON).
|
||||||
|
|
||||||
|
Callers today:
|
||||||
|
- **Gameplay capture/save button** (`CaptureButtonPresenter`) — gate, then its existing
|
||||||
|
**GallerySaveView** "Saved to gallery!" popup (unchanged).
|
||||||
|
- **Art book** page save buttons (`ArtbookPresenter`) — gate, then the gate's **shared success toast**
|
||||||
|
(the art book has no popup of its own).
|
||||||
|
|
||||||
|
It lives in **Boot** (never unloaded), so the same overlay shows over MainMenu / Colorbook / Gameplay
|
||||||
|
— the loading-screen trick.
|
||||||
|
|
||||||
|
## The overlay prefab (ready-made)
|
||||||
|
|
||||||
|
A complete, wired overlay prefab already exists:
|
||||||
|
|
||||||
|
`Assets/Darkmatter/Content/Colorbook UI/Prefabs/UI/SaveGalleryOverlay.prefab`
|
||||||
|
|
||||||
|
It has its own screen-space Canvas (sort order 5100, 1080×1920 scaler — matches `TutorialOverlayCanvas`),
|
||||||
|
the `SaveGalleryOverlayView` on the root with all six fields wired, and:
|
||||||
|
- **PromptPanel** → dim backdrop + cream Card → Fredoka message, green **Watch video**, grey **Not now**.
|
||||||
|
- **SuccessPanel** (starts inactive) → dark toast → "Saved to gallery!".
|
||||||
|
|
||||||
|
Edit the copy/colors freely; it uses plain colored rects (no sprite art) so a designer can drop in
|
||||||
|
sprites later. To wire it: just drag the prefab into **Boot** (it's a self-contained Canvas, so it
|
||||||
|
works as-is) and assign it to `SaveGateModule` below. Boot is never unloaded, so it persists across
|
||||||
|
every scene. (Building your own overlay also works — put `SaveGalleryOverlayView` on an always-active
|
||||||
|
object and wire promptPanel / watchButton / cancelButton / promptLabel / successPanel / successLabel.)
|
||||||
|
|
||||||
|
## Register in DI (Boot RootLifetimeScope)
|
||||||
|
|
||||||
|
1. Add a **`SaveGateModule`** component in Boot (it's an `IModule`).
|
||||||
|
2. Add it to the **RootLifetimeScope**'s `serviceModules` list (same list that holds the Ad / Loading
|
||||||
|
modules) so it registers at the root.
|
||||||
|
3. On `SaveGateModule`: assign **Overlay View** → the `SaveGalleryOverlayView`; tick
|
||||||
|
**Save Without Ad On Failure** (recommended).
|
||||||
|
|
||||||
|
`IAdService` is already root-scoped (AdServiceModule in Boot); the gate resolves it automatically, and
|
||||||
|
`CaptureButtonPresenter` / `ArtbookPresenter` resolve `IRewardedSaveGate` from the root.
|
||||||
|
|
||||||
|
## Before release
|
||||||
|
|
||||||
|
- `AdUnitCatalogSO` holds AdMob **test** unit IDs (incl. test Rewarded). Swap in the real Rewarded ID.
|
||||||
|
- Child-directed / COPPA app (IDFA off, `TagForChildDirectedTreatment.True`, `MaxAdContentRating.G`
|
||||||
|
already set). Confirm rewarded ads are permitted under the relevant kids / family policies before
|
||||||
|
shipping rewarded-gated saving.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: f94ab64051f694090abf4906db896df0
|
guid: 9db44e977bbde446daa19aa928ee1230
|
||||||
TextScriptImporter:
|
TextScriptImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
userData:
|
userData:
|
||||||
8
Assets/Darkmatter/Code/Features/SaveGate/Systems.meta
Normal file
8
Assets/Darkmatter/Code/Features/SaveGate/Systems.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 010c08604fc7e44ceb9d069874618ce8
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
using Darkmatter.Core.Contracts.Features.SaveGate;
|
||||||
|
using Darkmatter.Core.Contracts.Services.Ads;
|
||||||
|
using Darkmatter.Core.Enums.Services.Ads;
|
||||||
|
using Darkmatter.Features.SaveGate.UI;
|
||||||
|
using VContainer.Unity;
|
||||||
|
|
||||||
|
namespace Darkmatter.Features.SaveGate.Systems
|
||||||
|
{
|
||||||
|
public class RewardedSaveGate : IRewardedSaveGate, IStartable, IDisposable
|
||||||
|
{
|
||||||
|
private readonly SaveGalleryOverlayView _overlay;
|
||||||
|
private readonly IAdService _ads;
|
||||||
|
private readonly SaveGateConfig _config;
|
||||||
|
|
||||||
|
private CancellationTokenSource _cts;
|
||||||
|
private bool _busy;
|
||||||
|
|
||||||
|
public RewardedSaveGate(SaveGalleryOverlayView overlay, IAdService ads, SaveGateConfig config)
|
||||||
|
{
|
||||||
|
_overlay = overlay;
|
||||||
|
_ads = ads;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
// Warm a rewarded ad so the gate is snappy on first save.
|
||||||
|
PrewarmRewardedAsync(_cts.Token).Forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async UniTask<bool> RequestSaveAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Overlay not built yet, or no ad service: never block saving.
|
||||||
|
if (_overlay == null || _ads == null) return true;
|
||||||
|
if (_busy) return false; // a prompt is already up; ignore the extra tap
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
_cts?.Token ?? CancellationToken.None, cancellationToken);
|
||||||
|
var ct = linked.Token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await ShowPromptAsync(ct)) return false; // child tapped Cancel
|
||||||
|
return await TryShowRewardedAsync(ct); // ad closed early / unavailable
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { return false; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async UniTask ShowSavedAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_overlay == null || !_overlay.HasSuccessPanel) return;
|
||||||
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
_cts?.Token ?? CancellationToken.None, cancellationToken);
|
||||||
|
_overlay.ShowSuccess();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await UniTask.Delay(TimeSpan.FromSeconds(_overlay.SuccessAutoHideSeconds),
|
||||||
|
cancellationToken: linked.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
_overlay.HideSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows the prompt and resolves true on Watch, false on Cancel (or cancellation).
|
||||||
|
private UniTask<bool> ShowPromptAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tcs = new UniTaskCompletionSource<bool>();
|
||||||
|
CancellationTokenRegistration reg = default;
|
||||||
|
var done = false;
|
||||||
|
|
||||||
|
void Finish(bool result, bool canceled)
|
||||||
|
{
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
_overlay.OnWatch -= OnWatch;
|
||||||
|
_overlay.OnCancel -= OnCancel;
|
||||||
|
reg.Dispose();
|
||||||
|
// On Watch, keep the prompt up with both buttons locked while the ad loads;
|
||||||
|
// TryShowRewardedAsync hides it once the ad is ready or fails. Cancel/cancellation hide now.
|
||||||
|
if (result && !canceled) _overlay.SetPromptButtonsInteractable(false);
|
||||||
|
else _overlay.HidePrompt();
|
||||||
|
if (canceled) tcs.TrySetCanceled(ct);
|
||||||
|
else tcs.TrySetResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnWatch() => Finish(true, false);
|
||||||
|
void OnCancel() => Finish(false, false);
|
||||||
|
|
||||||
|
_overlay.OnWatch += OnWatch;
|
||||||
|
_overlay.OnCancel += OnCancel;
|
||||||
|
_overlay.ShowPrompt();
|
||||||
|
reg = ct.Register(() => Finish(false, true));
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true only when the child earned the reward. Falls back to SaveWithoutAdOnFailure
|
||||||
|
// when the ad can't be shown at all; an ad closed early counts as not rewarded -> no save.
|
||||||
|
private async UniTask<bool> TryShowRewardedAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_ads.IsInitialized) await _ads.InitializeAsync(ct);
|
||||||
|
var ready = _ads.IsReady(AdFormat.Rewarded) || await _ads.LoadAsync(AdFormat.Rewarded, ct);
|
||||||
|
_overlay.HidePrompt(); // ad loaded or failed to load — now hide the prompt
|
||||||
|
if (!ready) return _config.SaveWithoutAdOnFailure;
|
||||||
|
|
||||||
|
var result = await _ads.ShowAsync(AdFormat.Rewarded, ct);
|
||||||
|
if (result.Rewarded) return true;
|
||||||
|
return result.Shown ? false : _config.SaveWithoutAdOnFailure;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _overlay.HidePrompt(); throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_overlay.HidePrompt();
|
||||||
|
UnityEngine.Debug.LogWarning($"[SaveGate] Rewarded ad gate failed: {ex.Message}");
|
||||||
|
return _config.SaveWithoutAdOnFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async UniTaskVoid PrewarmRewardedAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_ads == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_ads.IsInitialized) await _ads.InitializeAsync(ct);
|
||||||
|
if (!_ads.IsReady(AdFormat.Rewarded)) await _ads.LoadAsync(AdFormat.Rewarded, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
UnityEngine.Debug.LogWarning($"[SaveGate] Rewarded prewarm failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bcff7c06c2d77499db81865208f807e7
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Darkmatter.Features.SaveGate
|
||||||
|
{
|
||||||
|
[Serializable]
|
||||||
|
public struct SaveGateConfig
|
||||||
|
{
|
||||||
|
// When the rewarded ad can't be shown (no fill / load or show error), save anyway instead of
|
||||||
|
// blocking the child. A user who opens the ad and closes it early is never rewarded and does
|
||||||
|
// not trigger this fallback.
|
||||||
|
public bool SaveWithoutAdOnFailure { get; }
|
||||||
|
|
||||||
|
public SaveGateConfig(bool saveWithoutAdOnFailure)
|
||||||
|
{
|
||||||
|
SaveWithoutAdOnFailure = saveWithoutAdOnFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c7343ebeefe9e4db48c41c617347adae
|
||||||
8
Assets/Darkmatter/Code/Features/SaveGate/UI.meta
Normal file
8
Assets/Darkmatter/Code/Features/SaveGate/UI.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1356ea11d3edf4c1293bc8dec5ecdb3a
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
using TMPro;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
|
namespace Darkmatter.Features.SaveGate.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Root-scoped, cross-scene overlay for the save-to-gallery flow: a kid-friendly "watch a video"
|
||||||
|
/// prompt and a "Saved!" toast. Dumb view — it shows/hides panels and raises button events;
|
||||||
|
/// <c>RewardedSaveGate</c> drives the flow. Lives in Boot so it persists over every scene.
|
||||||
|
///
|
||||||
|
/// If the panels aren't wired in the inspector it builds the whole UI in code (see
|
||||||
|
/// <see cref="buildUiAtRuntime"/>), so the feature works with no manual prefab setup. A designer
|
||||||
|
/// can still wire custom panels and the code build is skipped.
|
||||||
|
/// </summary>
|
||||||
|
public class SaveGalleryOverlayView : MonoBehaviour
|
||||||
|
{
|
||||||
|
[Header("Watch-ad prompt")] [SerializeField]
|
||||||
|
private GameObject promptPanel;
|
||||||
|
|
||||||
|
[SerializeField] private Button watchButton;
|
||||||
|
[SerializeField] private Button cancelButton;
|
||||||
|
[SerializeField] private TMP_Text promptLabel;
|
||||||
|
|
||||||
|
[Header("Saved toast")] [SerializeField]
|
||||||
|
private GameObject successPanel;
|
||||||
|
|
||||||
|
[SerializeField] private TMP_Text successLabel;
|
||||||
|
[SerializeField, Min(0f)] private float successAutoHideSeconds = 1.5f;
|
||||||
|
|
||||||
|
public event Action OnWatch;
|
||||||
|
public event Action OnCancel;
|
||||||
|
|
||||||
|
public float SuccessAutoHideSeconds => successAutoHideSeconds;
|
||||||
|
public bool HasSuccessPanel => successPanel != null;
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
if (promptPanel != null) promptPanel.SetActive(false);
|
||||||
|
if (successPanel != null) successPanel.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Start()
|
||||||
|
{
|
||||||
|
if (watchButton != null) watchButton.onClick.AddListener(() => OnWatch?.Invoke());
|
||||||
|
if (cancelButton != null) cancelButton.onClick.AddListener(() => OnCancel?.Invoke());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowPrompt()
|
||||||
|
{
|
||||||
|
if (successPanel != null) successPanel.SetActive(false);
|
||||||
|
SetPromptButtonsInteractable(true);
|
||||||
|
if (promptPanel != null) promptPanel.SetActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the prompt visible but lock both buttons — used while the rewarded ad loads
|
||||||
|
// after the child taps Watch, so the UI doesn't disappear during the wait.
|
||||||
|
public void SetPromptButtonsInteractable(bool interactable)
|
||||||
|
{
|
||||||
|
if (watchButton != null) watchButton.interactable = interactable;
|
||||||
|
if (cancelButton != null) cancelButton.interactable = interactable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HidePrompt()
|
||||||
|
{
|
||||||
|
if (promptPanel != null) promptPanel.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowSuccess()
|
||||||
|
{
|
||||||
|
if (successPanel != null) successPanel.SetActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HideSuccess()
|
||||||
|
{
|
||||||
|
if (successPanel != null) successPanel.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
if (watchButton != null) watchButton.onClick.RemoveAllListeners();
|
||||||
|
if (cancelButton != null) cancelButton.onClick.RemoveAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e2688ce9b9d9848719e2ee33cf6eb155
|
||||||
@@ -107,31 +107,32 @@ namespace Darkmatter.Features.ShapeBuilder.Systems
|
|||||||
|
|
||||||
_bus.Publish(new ShapeBuilderStartedSignal(template.Id));
|
_bus.Publish(new ShapeBuilderStartedSignal(template.Id));
|
||||||
|
|
||||||
var colorByShapeId = BuildColorMap(template, slots);
|
// Distinct color per element — every piece and slot gets its own color,
|
||||||
|
// even when two pieces share the same shape. Hues are stepped by the
|
||||||
|
// golden ratio so successive colors land far apart on the wheel.
|
||||||
|
float hue = UnityEngine.Random.value;
|
||||||
foreach (var s in slots)
|
foreach (var s in slots)
|
||||||
if (s != null && s.Shape != null && colorByShapeId.TryGetValue(s.Shape.Id, out var c))
|
if (s != null)
|
||||||
s.SetColor(c);
|
s.SetColor(NextDistinctColor(ref hue));
|
||||||
|
|
||||||
CreateShapePieceInstances(template, preSnappedIds, count, slots, colorByShapeId);
|
CreateShapePieceInstances(template, preSnappedIds, count, slots, ref hue);
|
||||||
|
|
||||||
CheckIfShapeAssembled();
|
CheckIfShapeAssembled();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, Color> BuildColorMap(IDrawingTemplate template, SlotMarker[] slots)
|
// Conjugate of the golden ratio; adding it (mod 1) to a hue each step
|
||||||
|
// produces a maximally-spread, low-collision sequence of distinct colors.
|
||||||
|
private const float GoldenHueStep = 0.61803398875f;
|
||||||
|
|
||||||
|
private static Color NextDistinctColor(ref float hue)
|
||||||
{
|
{
|
||||||
var map = new Dictionary<string, Color>();
|
hue = Mathf.Repeat(hue + GoldenHueStep, 1f);
|
||||||
foreach (var p in template.Pieces)
|
return Color.HSVToRGB(hue, 0.7f, 0.95f);
|
||||||
if (p != null && !string.IsNullOrEmpty(p.Id) && !map.ContainsKey(p.Id))
|
|
||||||
map[p.Id] = Color.HSVToRGB(UnityEngine.Random.value, 0.7f, 0.95f);
|
|
||||||
foreach (var s in slots)
|
|
||||||
if (s != null && s.Shape != null && !string.IsNullOrEmpty(s.Shape.Id) && !map.ContainsKey(s.Shape.Id))
|
|
||||||
map[s.Shape.Id] = Color.HSVToRGB(UnityEngine.Random.value, 0.7f, 0.95f);
|
|
||||||
return map;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CreateShapePieceInstances(IDrawingTemplate template, IReadOnlyCollection<string> preSnappedIds,
|
private void CreateShapePieceInstances(IDrawingTemplate template, IReadOnlyCollection<string> preSnappedIds,
|
||||||
int count,
|
int count,
|
||||||
SlotMarker[] slots, Dictionary<string, Color> colorByShapeId)
|
SlotMarker[] slots, ref float hue)
|
||||||
{
|
{
|
||||||
var preSnapCounts = new Dictionary<string, int>();
|
var preSnapCounts = new Dictionary<string, int>();
|
||||||
if (preSnappedIds != null)
|
if (preSnappedIds != null)
|
||||||
@@ -152,8 +153,7 @@ namespace Darkmatter.Features.ShapeBuilder.Systems
|
|||||||
}
|
}
|
||||||
|
|
||||||
var piece = _factory.Create(_piecePrefab, shape, candidates, Vector2.zero);
|
var piece = _factory.Create(_piecePrefab, shape, candidates, Vector2.zero);
|
||||||
if (colorByShapeId != null && colorByShapeId.TryGetValue(shape.Id, out var c))
|
piece.SetColor(NextDistinctColor(ref hue));
|
||||||
piece.SetColor(c);
|
|
||||||
_pieces.Add(piece);
|
_pieces.Add(piece);
|
||||||
|
|
||||||
if (preSnapCounts.TryGetValue(shape.Id, out var remaining) && remaining > 0)
|
if (preSnapCounts.TryGetValue(shape.Id, out var remaining) && remaining > 0)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
private Vector2 _origAnchorMax;
|
private Vector2 _origAnchorMax;
|
||||||
private Vector2 _origPivot;
|
private Vector2 _origPivot;
|
||||||
private bool _origPreserveAspect;
|
private bool _origPreserveAspect;
|
||||||
|
private Vector3 _homeScale = Vector3.one;
|
||||||
|
|
||||||
// Per-drag state
|
// Per-drag state
|
||||||
private RectTransform _rt;
|
private RectTransform _rt;
|
||||||
@@ -45,6 +46,8 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
private Vector2 _trayPosInDragRoot;
|
private Vector2 _trayPosInDragRoot;
|
||||||
private Vector2 _dragSizeDelta;
|
private Vector2 _dragSizeDelta;
|
||||||
private Vector3 _dragLocalScale;
|
private Vector3 _dragLocalScale;
|
||||||
|
private float _dragOrigAlpha;
|
||||||
|
private Tween _dragScaleTween;
|
||||||
private Sequence _previewSeq;
|
private Sequence _previewSeq;
|
||||||
private Sequence _snapSettle;
|
private Sequence _snapSettle;
|
||||||
private bool _locked;
|
private bool _locked;
|
||||||
@@ -68,6 +71,14 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
if (image != null) image.color = color;
|
if (image != null) image.color = color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetAlpha(float a)
|
||||||
|
{
|
||||||
|
if (image == null) return;
|
||||||
|
var c = image.color;
|
||||||
|
c.a = a;
|
||||||
|
image.color = c;
|
||||||
|
}
|
||||||
|
|
||||||
public void Setup(
|
public void Setup(
|
||||||
ShapeSO shape,
|
ShapeSO shape,
|
||||||
SlotMarker[] candidateSlots,
|
SlotMarker[] candidateSlots,
|
||||||
@@ -86,7 +97,10 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
_bus = bus;
|
_bus = bus;
|
||||||
_undo = undo;
|
_undo = undo;
|
||||||
_trayPos = trayPos;
|
_trayPos = trayPos;
|
||||||
_traySize = shape.DefaultSizeDelta;
|
// Keep the piece at the prefab Image's authored size. Sourcing this from
|
||||||
|
// shape.DefaultSizeDelta let each ShapeSO override the image's default size;
|
||||||
|
// the prefab (with preserveAspect) is the single source of truth now.
|
||||||
|
_traySize = RectTransform.sizeDelta;
|
||||||
_dragRoot = dragRoot;
|
_dragRoot = dragRoot;
|
||||||
|
|
||||||
_homeParent = RectTransform.parent;
|
_homeParent = RectTransform.parent;
|
||||||
@@ -95,6 +109,7 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
_origAnchorMax = RectTransform.anchorMax;
|
_origAnchorMax = RectTransform.anchorMax;
|
||||||
_origPivot = RectTransform.pivot;
|
_origPivot = RectTransform.pivot;
|
||||||
_origPreserveAspect = image != null && image.preserveAspect;
|
_origPreserveAspect = image != null && image.preserveAspect;
|
||||||
|
_homeScale = RectTransform.localScale;
|
||||||
|
|
||||||
image.sprite = shape.Sprite;
|
image.sprite = shape.Sprite;
|
||||||
ApplyTrayPose();
|
ApplyTrayPose();
|
||||||
@@ -104,6 +119,10 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
{
|
{
|
||||||
if (_locked) return;
|
if (_locked) return;
|
||||||
|
|
||||||
|
// Kill any tweens still running on this piece (e.g. a return/preview from a
|
||||||
|
// re-grab mid-animation) so the new drag starts from a clean, known pose.
|
||||||
|
Tween.StopAll(onTarget: RectTransform);
|
||||||
|
|
||||||
if (_dragRoot != null && RectTransform.parent != _dragRoot)
|
if (_dragRoot != null && RectTransform.parent != _dragRoot)
|
||||||
{
|
{
|
||||||
RectTransform.SetParent(_dragRoot, worldPositionStays: true);
|
RectTransform.SetParent(_dragRoot, worldPositionStays: true);
|
||||||
@@ -114,11 +133,20 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
_eventCam = e.pressEventCamera;
|
_eventCam = e.pressEventCamera;
|
||||||
_trayPosInDragRoot = RectTransform.anchoredPosition;
|
_trayPosInDragRoot = RectTransform.anchoredPosition;
|
||||||
_dragSizeDelta = RectTransform.sizeDelta;
|
_dragSizeDelta = RectTransform.sizeDelta;
|
||||||
_dragLocalScale = RectTransform.localScale;
|
// Use the canonical resting scale, not the live value, so a re-grab mid-tween
|
||||||
|
// can't bake a drifted scale into the drag base.
|
||||||
|
_dragLocalScale = _homeScale;
|
||||||
|
RectTransform.localScale = _homeScale;
|
||||||
_grabOffset = RectTransform.anchoredPosition - ScreenToLocal(e.position);
|
_grabOffset = RectTransform.anchoredPosition - ScreenToLocal(e.position);
|
||||||
_inPreview = false;
|
_inPreview = false;
|
||||||
|
|
||||||
Tween.LocalScale(RectTransform, _dragLocalScale * _cfg.DragScale, _cfg.SnapDuration, Ease.OutQuad);
|
if (image != null)
|
||||||
|
{
|
||||||
|
_dragOrigAlpha = image.color.a;
|
||||||
|
SetAlpha(_cfg.DragAlpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
_dragScaleTween = Tween.Scale(RectTransform, _dragLocalScale * _cfg.DragScale, _cfg.SnapDuration, Ease.OutQuad);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnDrag(PointerEventData e)
|
public void OnDrag(PointerEventData e)
|
||||||
@@ -126,7 +154,7 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
if (_locked) return;
|
if (_locked) return;
|
||||||
|
|
||||||
var pointerLocal = ScreenToLocal(e.position) + _grabOffset;
|
var pointerLocal = ScreenToLocal(e.position) + _grabOffset;
|
||||||
var hovered = FindSlotUnder(e.position);
|
var hovered = FindSlotForCatch(e.position);
|
||||||
bool insidePreview = hovered != null;
|
bool insidePreview = hovered != null;
|
||||||
|
|
||||||
if (insidePreview && !_inPreview)
|
if (insidePreview && !_inPreview)
|
||||||
@@ -156,7 +184,17 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
{
|
{
|
||||||
if (_locked) return;
|
if (_locked) return;
|
||||||
|
|
||||||
var target = FindSlotUnder(e.position);
|
// Stop the begin-drag scale-up so it can't fight the return/snap pose.
|
||||||
|
// Leave _previewSeq alive — the snap path lets it settle.
|
||||||
|
if (_dragScaleTween.isAlive) _dragScaleTween.Stop();
|
||||||
|
|
||||||
|
SetAlpha(_dragOrigAlpha);
|
||||||
|
|
||||||
|
// If a slot is already previewing, releasing commits to it. Otherwise catch
|
||||||
|
// a quick drop with no prior preview using the same per-slot radius.
|
||||||
|
var target = _inPreview && _activeSlot != null
|
||||||
|
? _activeSlot
|
||||||
|
: FindSlotForCatch(e.position);
|
||||||
if (target != null)
|
if (target != null)
|
||||||
{
|
{
|
||||||
_activeSlot = target;
|
_activeSlot = target;
|
||||||
@@ -169,31 +207,61 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private SlotMarker FindSlotUnder(Vector2 screenPos)
|
// Nearest slot whose per-slot catch circle contains the pointer. Each slot's
|
||||||
|
// radius is derived from its own size (see SlotCatchRadius), so big slots catch
|
||||||
|
// from farther than small ones. All distances are in PaperRoot local space —
|
||||||
|
// ScreenPointToLocalPointInRectangle and InverseTransformVector both strip the
|
||||||
|
// CanvasScaler factor, so the catch feels identical on every screen resolution.
|
||||||
|
private SlotMarker FindSlotForCatch(Vector2 screenPos)
|
||||||
{
|
{
|
||||||
if (_candidateSlots == null) return null;
|
if (_candidateSlots == null) return null;
|
||||||
|
Vector2 pointerLocal = ScreenToLocal(screenPos);
|
||||||
|
SlotMarker best = null;
|
||||||
|
float bestSqr = float.MaxValue;
|
||||||
foreach (var s in _candidateSlots)
|
foreach (var s in _candidateSlots)
|
||||||
{
|
{
|
||||||
if (s == null) continue;
|
if (s == null) continue;
|
||||||
if (s.IsOccupied && s != _activeSlot) continue;
|
if (s.IsOccupied && s != _activeSlot) continue;
|
||||||
if (RectTransformUtility.RectangleContainsScreenPoint(s.RectTransform, screenPos, _eventCam))
|
var srt = s.RectTransform;
|
||||||
return s;
|
Vector2 slotLocal = _parentRect.InverseTransformPoint(srt.position);
|
||||||
|
float sqr = (slotLocal - pointerLocal).sqrMagnitude;
|
||||||
|
float radius = SlotCatchRadius(srt);
|
||||||
|
if (sqr <= radius * radius && sqr < bestSqr)
|
||||||
|
{
|
||||||
|
bestSqr = sqr;
|
||||||
|
best = s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch radius for one slot, in PaperRoot local units: half the slot's diagonal
|
||||||
|
// (its size mapped into parent space, so any slot rotation/mirror/scale is
|
||||||
|
// accounted for) times the config multiplier. Scales per slot — not one flat
|
||||||
|
// distance for all.
|
||||||
|
private float SlotCatchRadius(RectTransform slot)
|
||||||
|
{
|
||||||
|
Vector2 size = slot.rect.size;
|
||||||
|
Vector2 prX = _parentRect.InverseTransformVector(slot.TransformVector(new Vector3(size.x, 0f, 0f)));
|
||||||
|
Vector2 prY = _parentRect.InverseTransformVector(slot.TransformVector(new Vector3(0f, size.y, 0f)));
|
||||||
|
float halfDiagonal = 0.5f * new Vector2(prX.magnitude, prY.magnitude).magnitude;
|
||||||
|
return halfDiagonal * _cfg.CatchRadiusScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AnimatePreviewPose(bool toSlot)
|
private void AnimatePreviewPose(bool toSlot)
|
||||||
{
|
{
|
||||||
|
if (_dragScaleTween.isAlive) _dragScaleTween.Stop();
|
||||||
if (_previewSeq.isAlive) _previewSeq.Stop();
|
if (_previewSeq.isAlive) _previewSeq.Stop();
|
||||||
|
|
||||||
if (toSlot && _activeSlot != null)
|
if (toSlot && _activeSlot != null)
|
||||||
{
|
{
|
||||||
var slot = _activeSlot.RectTransform;
|
var slot = _activeSlot.RectTransform;
|
||||||
|
SlotPoseInDragSpace(out var slotRot, out var slotScale);
|
||||||
if (image != null) image.preserveAspect = false;
|
if (image != null) image.preserveAspect = false;
|
||||||
_previewSeq = Sequence.Create()
|
_previewSeq = Sequence.Create()
|
||||||
.Group(Tween.UIAnchoredPosition(RectTransform, SlotPosInDragSpace(), _cfg.SnapDuration, Ease.OutQuad))
|
.Group(Tween.UIAnchoredPosition(RectTransform, SlotPosInDragSpace(), _cfg.SnapDuration, Ease.OutQuad))
|
||||||
.Group(Tween.LocalScale(RectTransform, SlotScaleInDragSpace(), _cfg.SnapDuration, Ease.OutQuad))
|
.Group(Tween.LocalScale(RectTransform, slotScale, _cfg.SnapDuration, Ease.OutQuad))
|
||||||
.Group(Tween.LocalRotation(RectTransform, SlotRotInDragSpace(), _cfg.SnapDuration, Ease.OutQuad))
|
.Group(Tween.LocalRotation(RectTransform, slotRot, _cfg.SnapDuration, Ease.OutQuad))
|
||||||
.Group(Tween.UISizeDelta(RectTransform, slot.rect.size, _cfg.SnapDuration, Ease.OutQuad));
|
.Group(Tween.UISizeDelta(RectTransform, slot.rect.size, _cfg.SnapDuration, Ease.OutQuad));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -222,19 +290,25 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
_inPreview = false;
|
_inPreview = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Quaternion SlotRotInDragSpace()
|
// Rotation + signed scale that make the piece (a child of the drag root) match the pose
|
||||||
|
// it will have once parented into the slot. Derived from the slot's local X/Y axes
|
||||||
|
// expressed in drag-root space: their angle gives the rotation, their lengths the scale,
|
||||||
|
// and their handedness the mirror. A quaternion + lossyScale pair can't encode a negative
|
||||||
|
// (mirrored) scale, which is why flipped slots previewed at the wrong orientation while the
|
||||||
|
// snapped piece — which inherits the slot's real transform — looked correct.
|
||||||
|
private void SlotPoseInDragSpace(out Quaternion rot, out Vector3 scale)
|
||||||
{
|
{
|
||||||
return Quaternion.Inverse(_parentRect.rotation) * _activeSlot.RectTransform.rotation;
|
var slot = _activeSlot.RectTransform;
|
||||||
}
|
Vector3 r = _parentRect.InverseTransformVector(slot.TransformVector(Vector3.right));
|
||||||
|
Vector3 u = _parentRect.InverseTransformVector(slot.TransformVector(Vector3.up));
|
||||||
|
|
||||||
private Vector3 SlotScaleInDragSpace()
|
float angle = Mathf.Atan2(r.y, r.x) * Mathf.Rad2Deg;
|
||||||
{
|
float sx = new Vector2(r.x, r.y).magnitude;
|
||||||
Vector3 parentLossy = _parentRect.lossyScale;
|
float sy = new Vector2(u.x, u.y).magnitude;
|
||||||
Vector3 slotLossy = _activeSlot.RectTransform.lossyScale;
|
if (r.x * u.y - r.y * u.x < 0f) sy = -sy; // mirrored hierarchy -> flip one axis
|
||||||
return new Vector3(
|
|
||||||
slotLossy.x / Mathf.Max(0.0001f, parentLossy.x),
|
rot = Quaternion.Euler(0f, 0f, angle);
|
||||||
slotLossy.y / Mathf.Max(0.0001f, parentLossy.y),
|
scale = new Vector3(sx, sy, 1f);
|
||||||
slotLossy.z / Mathf.Max(0.0001f, parentLossy.z));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void SnapInternal()
|
internal void SnapInternal()
|
||||||
@@ -361,6 +435,7 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
|||||||
{
|
{
|
||||||
RectTransform.sizeDelta = _traySize;
|
RectTransform.sizeDelta = _traySize;
|
||||||
RectTransform.localRotation = Quaternion.identity;
|
RectTransform.localRotation = Quaternion.identity;
|
||||||
|
RectTransform.localScale = _homeScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Vector2 ScreenToLocal(Vector2 screenPos)
|
private Vector2 ScreenToLocal(Vector2 screenPos)
|
||||||
|
|||||||
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.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: cdbf3e4894eb54678926256b46820595
|
guid: c9fcf6edd0d00433bb41f63ff3b4b1b7
|
||||||
TextScriptImporter:
|
TextScriptImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
userData:
|
userData:
|
||||||
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,423 @@
|
|||||||
|
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.Core.Enums.Features.Tutorial; // BubblePlacement
|
||||||
|
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 _drawingCompleted;
|
||||||
|
private bool _hasColored;
|
||||||
|
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));
|
||||||
|
// The drawing being completed (Next pressed) is the run's true end — even if the child
|
||||||
|
// finds Next early during free-paint. Flag it so the catalog reload that follows isn't
|
||||||
|
// mistaken for a Back-navigation and doesn't restart the tutorial.
|
||||||
|
_navSubs.Add(_bus.Subscribe<DrawingCompletedSignal>(_ => _drawingCompleted = true));
|
||||||
|
// Painting at least one region means the child has grasped the core colour gesture. If
|
||||||
|
// they then go Back, we finish the tutorial instead of restarting it (OnCatalogReadyGlobal).
|
||||||
|
_navSubs.Add(_bus.Subscribe<ColorAppliedSignal>(_ => _hasColored = true));
|
||||||
|
|
||||||
|
StartRun(skipCatalogWait: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartRun(bool skipCatalogWait)
|
||||||
|
{
|
||||||
|
_runCts?.Cancel();
|
||||||
|
_runCts?.Dispose();
|
||||||
|
_runCts = new CancellationTokenSource();
|
||||||
|
_ct = _runCts.Token;
|
||||||
|
_gen++;
|
||||||
|
_suspended = false;
|
||||||
|
_drawingCompleted = false;
|
||||||
|
_hasColored = 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 7+ (the Next
|
||||||
|
// press), whose own completion loads the catalog.
|
||||||
|
private void OnCatalogReadyGlobal(DrawingCatalogReadySignal _)
|
||||||
|
{
|
||||||
|
if (_completed || _drawingCompleted || _stepIndex < 2 || _stepIndex > 6) return;
|
||||||
|
|
||||||
|
// Already coloured at least one region before backing out: the child has the core gesture,
|
||||||
|
// so finish the tutorial rather than trapping them in a full restart.
|
||||||
|
if (_hasColored)
|
||||||
|
{
|
||||||
|
_runCts?.Cancel();
|
||||||
|
Complete();
|
||||||
|
_overlay.HideInstant();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, _config.PickBubble, _config.PickBubbleOffset));
|
||||||
|
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, _config.DragBubble, _config.DragBubbleOffset);
|
||||||
|
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, _config.FinishBubble, _config.FinishBubbleOffset));
|
||||||
|
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, _config.ColorBubble, _config.ColorBubbleOffset));
|
||||||
|
if (!await WaitForActionAsync<ColorSelectedSignal>()) return;
|
||||||
|
EndStep("color", 4);
|
||||||
|
|
||||||
|
// Step 5 — paint the first region (teaches the tap by spotlighting one region).
|
||||||
|
_stepIndex = 5;
|
||||||
|
// Watch full-colour completion across both this taught tap and the free-paint step that
|
||||||
|
// follows. A one-region drawing is finished by this very tap, so its signal would fire
|
||||||
|
// before step 6 could subscribe — the flag lets us skip the free-paint step instead of
|
||||||
|
// stranding the child on a hint with nothing left to colour.
|
||||||
|
var fullyColored = false;
|
||||||
|
using var fullColorSub = _bus.Subscribe<AllRegionsColoredSignal>(_ => fullyColored = true);
|
||||||
|
ShowStep(() => ShowBlockingTap(FindLargestRegion(), _config.PaintText, _config.PaintBubble, _config.PaintBubbleOffset));
|
||||||
|
if (!await WaitForActionAsync<ColorAppliedSignal>()) return;
|
||||||
|
EndStep("paint", 5);
|
||||||
|
|
||||||
|
// Step 6 — colour the rest of the picture freely (non-blocking hint). Hold here until
|
||||||
|
// every region is filled, so the "Tap Next" prompt only appears once colouring is done.
|
||||||
|
// The child can still reach the live Next button during this open step; if they finish
|
||||||
|
// the drawing early that way, take it as done rather than waiting on a fill that can't come.
|
||||||
|
_stepIndex = 6;
|
||||||
|
if (!fullyColored)
|
||||||
|
{
|
||||||
|
ShowStep(() => _overlay.ShowTap(null, _config.FinishColoringText, blockInput: false, _config.FinishColoringBubble, _config.FinishColoringBubbleOffset));
|
||||||
|
await UniTask.WhenAny(
|
||||||
|
WaitForActionAsync<AllRegionsColoredSignal>(),
|
||||||
|
WaitForActionAsync<DrawingCompletedSignal>());
|
||||||
|
}
|
||||||
|
EndStep("finishColoring", 6);
|
||||||
|
|
||||||
|
// Step 7 — press Next (skipped if the drawing was already completed early).
|
||||||
|
if (!_drawingCompleted)
|
||||||
|
{
|
||||||
|
_stepIndex = 7;
|
||||||
|
await UniTask.NextFrame(_ct);
|
||||||
|
ShowStep(() => ShowBlockingTap(FindNextButton(), _config.NextText, _config.NextBubble, _config.NextBubbleOffset));
|
||||||
|
if (!await WaitForActionAsync<DrawingCompletedSignal>()) return;
|
||||||
|
EndStep("next", 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8 — celebrate.
|
||||||
|
_stepIndex = 8;
|
||||||
|
await _overlay.ShowToastAsync(_config.DoneText, _ct, _config.DoneBubble, _config.DoneBubbleOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowBlockingTap(RectTransform target, string message, BubblePlacement placement, Vector2 offset)
|
||||||
|
{
|
||||||
|
if (target == null) Debug.LogWarning($"[Tutorial] No target found for step: \"{message}\"");
|
||||||
|
_overlay.ShowTap(target, message, blockInput: target != null, placement, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
// Pieces spawn in data order under one SpawnRoot, so sibling 0 is the top of the tray.
|
||||||
|
// FindObjectsByType returns undefined order — pick the lowest sibling index, not [0].
|
||||||
|
var pieces = UnityEngine.Object.FindObjectsByType<ShapePiece>(FindObjectsSortMode.None);
|
||||||
|
ShapePiece piece = null;
|
||||||
|
var bestIndex = int.MaxValue;
|
||||||
|
foreach (var p in pieces)
|
||||||
|
{
|
||||||
|
if (p == null || p.IsLocked || !p.gameObject.activeInHierarchy) continue;
|
||||||
|
var index = p.transform.GetSiblingIndex();
|
||||||
|
if (index < bestIndex) { bestIndex = index; piece = p; }
|
||||||
|
}
|
||||||
|
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,495 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
using Darkmatter.Core.Contracts.Features.Tutorial;
|
||||||
|
using Darkmatter.Core.Enums.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;
|
||||||
|
[Tooltip("Margin (px) from the top/bottom edge for the Top/Bottom bubble placement presets.")]
|
||||||
|
[SerializeField] private float bubbleEdgeMargin = 150f;
|
||||||
|
[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 BubblePlacement _placement = BubblePlacement.Auto;
|
||||||
|
private Vector2 _bubbleOffset;
|
||||||
|
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, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default)
|
||||||
|
{
|
||||||
|
CacheRefs();
|
||||||
|
KillAnims();
|
||||||
|
SetText(message);
|
||||||
|
_placement = placement;
|
||||||
|
_bubbleOffset = offset;
|
||||||
|
_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, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default)
|
||||||
|
{
|
||||||
|
CacheRefs();
|
||||||
|
KillAnims();
|
||||||
|
SetText(message);
|
||||||
|
_placement = placement;
|
||||||
|
_bubbleOffset = offset;
|
||||||
|
|
||||||
|
// 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, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default)
|
||||||
|
{
|
||||||
|
CacheRefs();
|
||||||
|
KillAnims();
|
||||||
|
SetText(message);
|
||||||
|
_placement = placement;
|
||||||
|
_bubbleOffset = offset;
|
||||||
|
|
||||||
|
_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);
|
||||||
|
|
||||||
|
if (_placement == BubblePlacement.Auto)
|
||||||
|
PositionBubble(centerLocal.y, centerLocal.y + radiusLocal, centerLocal.y - radiusLocal);
|
||||||
|
else
|
||||||
|
PositionBubblePreset(_placement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
if (_placement == BubblePlacement.Auto)
|
||||||
|
PositionBubbleAtEdge(dragBubbleAtTop, dragBubbleEdgeMargin);
|
||||||
|
else
|
||||||
|
PositionBubblePreset(_placement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LayoutCentered()
|
||||||
|
{
|
||||||
|
if (bubbleRoot == null) return;
|
||||||
|
if (_placement != BubblePlacement.Auto) { PositionBubblePreset(_placement); return; }
|
||||||
|
float halfH = _area.rect.height * 0.5f;
|
||||||
|
SetBubbleAnchored(new Vector2(0f, halfH * 0.45f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies the per-step offset to a computed base position and clamps so the bubble always
|
||||||
|
// stays fully on-screen, then commits it. Every bubble-positioning path routes through here.
|
||||||
|
private void SetBubbleAnchored(Vector2 baseLocal)
|
||||||
|
{
|
||||||
|
if (bubbleRoot == null) return;
|
||||||
|
float halfW = _area.rect.width * 0.5f;
|
||||||
|
float halfH = _area.rect.height * 0.5f;
|
||||||
|
float bubbleHalfW = bubbleRoot.rect.width * 0.5f;
|
||||||
|
float bubbleHalfH = bubbleRoot.rect.height * 0.5f;
|
||||||
|
Vector2 p = baseLocal + _bubbleOffset;
|
||||||
|
p.x = Mathf.Clamp(p.x, -halfW + bubbleHalfW, halfW - bubbleHalfW);
|
||||||
|
p.y = Mathf.Clamp(p.y, -halfH + bubbleHalfH, halfH - bubbleHalfH);
|
||||||
|
bubbleRoot.anchoredPosition = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PositionBubble(float holeCenterY, float holeTopY, float holeBottomY)
|
||||||
|
{
|
||||||
|
if (bubbleRoot == null) return;
|
||||||
|
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||||
|
float y = holeCenterY <= 0f
|
||||||
|
? holeTopY + bubbleGap + bubbleHalf
|
||||||
|
: holeBottomY - bubbleGap - bubbleHalf;
|
||||||
|
SetBubbleAnchored(new Vector2(0f, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed-slot placement chosen per step (Top/Center/Bottom). Horizontally centred; clamped so
|
||||||
|
// the bubble always stays fully on-screen.
|
||||||
|
private void PositionBubblePreset(BubblePlacement placement)
|
||||||
|
{
|
||||||
|
if (bubbleRoot == null) return;
|
||||||
|
float halfH = _area.rect.height * 0.5f;
|
||||||
|
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||||
|
float y = placement switch
|
||||||
|
{
|
||||||
|
BubblePlacement.Top => halfH - bubbleHalf - bubbleEdgeMargin,
|
||||||
|
BubblePlacement.Bottom => -halfH + bubbleHalf + bubbleEdgeMargin,
|
||||||
|
_ => 0f, // Center
|
||||||
|
};
|
||||||
|
SetBubbleAnchored(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;
|
||||||
|
SetBubbleAnchored(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;
|
||||||
|
_placement = BubblePlacement.Auto;
|
||||||
|
_bubbleOffset = Vector2.zero;
|
||||||
|
_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
|
||||||
@@ -3,10 +3,12 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Cysharp.Threading.Tasks;
|
using Cysharp.Threading.Tasks;
|
||||||
using Darkmatter.Core.Contracts.Services.Ads;
|
using Darkmatter.Core.Contracts.Services.Ads;
|
||||||
|
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||||
using Darkmatter.Core.Data.Dynamic.Services.Ads;
|
using Darkmatter.Core.Data.Dynamic.Services.Ads;
|
||||||
using Darkmatter.Core.Data.Static.Services.Ads;
|
using Darkmatter.Core.Data.Static.Services.Ads;
|
||||||
using Darkmatter.Core.Enums.Services.Ads;
|
using Darkmatter.Core.Enums.Services.Ads;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using VContainer;
|
||||||
using AdFormat = Darkmatter.Core.Enums.Services.Ads.AdFormat;
|
using AdFormat = Darkmatter.Core.Enums.Services.Ads.AdFormat;
|
||||||
#if GOOGLE_MOBILE_ADS
|
#if GOOGLE_MOBILE_ADS
|
||||||
using GoogleMobileAds.Api;
|
using GoogleMobileAds.Api;
|
||||||
@@ -25,13 +27,22 @@ namespace Darkmatter.Services.Ads
|
|||||||
[SerializeField, Min(1)] private int reloadMaxAttempts = 6;
|
[SerializeField, Min(1)] private int reloadMaxAttempts = 6;
|
||||||
[Tooltip("Hard fallback (seconds) to recover a full-screen show if AdMob never raises its close callback. Android focus-return recovery usually fires far sooner; this cap covers iOS/edge cases. Must exceed max plausible ad length so a real ad is never cut short.")]
|
[Tooltip("Hard fallback (seconds) to recover a full-screen show if AdMob never raises its close callback. Android focus-return recovery usually fires far sooner; this cap covers iOS/edge cases. Must exceed max plausible ad length so a real ad is never cut short.")]
|
||||||
[SerializeField, Min(15f)] private float showWatchdogSeconds = 60f;
|
[SerializeField, Min(15f)] private float showWatchdogSeconds = 60f;
|
||||||
|
[Tooltip("Max interstitials shown per app session (run). Once reached, ShowAsync(Interstitial) no-ops until the app restarts. Counts only ads actually shown, not failed/skipped attempts. 0 disables interstitials.")]
|
||||||
|
[SerializeField, Min(0)] private int maxInterstitialsPerSession = 8;
|
||||||
|
|
||||||
public bool IsInitialized => _initialized;
|
public bool IsInitialized => _initialized;
|
||||||
public event Action<AdFormat, AdLoadState> LoadStateChanged;
|
public event Action<AdFormat, AdLoadState> LoadStateChanged;
|
||||||
|
|
||||||
|
private IAnalyticsService _analytics;
|
||||||
private bool _initialized;
|
private bool _initialized;
|
||||||
|
// Per-session interstitial counter. The service is the app-lifetime singleton (it retains
|
||||||
|
// loaded ads across scene swaps), so this survives Colorbook<->Gameplay transitions and only
|
||||||
|
// resets on app restart — i.e. a true per-session cap.
|
||||||
|
private int _interstitialsShownThisSession;
|
||||||
private bool _hasUserConsent = true;
|
private bool _hasUserConsent = true;
|
||||||
private bool _isChildDirected;
|
// Coloring book is a child-directed app, so default to true. SetConsent can still
|
||||||
|
// override if a consent flow later supplies a different value.
|
||||||
|
private bool _isChildDirected = true;
|
||||||
private CancellationTokenSource _lifetimeCts;
|
private CancellationTokenSource _lifetimeCts;
|
||||||
|
|
||||||
// App interruption state, fed by the Unity lifecycle messages below. A full-screen ad
|
// App interruption state, fed by the Unity lifecycle messages below. A full-screen ad
|
||||||
@@ -51,6 +62,12 @@ namespace Darkmatter.Services.Ads
|
|||||||
private BannerView _banner;
|
private BannerView _banner;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Method injection: AdMobAdService is a scene MonoBehaviour registered via RegisterComponent, so
|
||||||
|
// VContainer injects here rather than through a constructor. IAnalyticsService is the Root-scoped
|
||||||
|
// composite (Firebase + Facebook).
|
||||||
|
[Inject]
|
||||||
|
public void Construct(IAnalyticsService analytics) => _analytics = analytics;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
_lifetimeCts = new CancellationTokenSource();
|
_lifetimeCts = new CancellationTokenSource();
|
||||||
@@ -178,6 +195,11 @@ namespace Darkmatter.Services.Ads
|
|||||||
if (!_initialized) return AdShowResult.Failure("Not initialized.");
|
if (!_initialized) return AdShowResult.Failure("Not initialized.");
|
||||||
|
|
||||||
#if GOOGLE_MOBILE_ADS
|
#if GOOGLE_MOBILE_ADS
|
||||||
|
// Per-session interstitial cap. Check before load so a capped show wastes no fill. Skip
|
||||||
|
// is silent (Failure, Shown=false); the fire-and-forget caller just keeps playing.
|
||||||
|
if (format == AdFormat.Interstitial && _interstitialsShownThisSession >= maxInterstitialsPerSession)
|
||||||
|
return AdShowResult.Failure("Session interstitial cap reached.");
|
||||||
|
|
||||||
if (!IsReady(format))
|
if (!IsReady(format))
|
||||||
{
|
{
|
||||||
bool loaded = await LoadAsync(format, cancellationToken);
|
bool loaded = await LoadAsync(format, cancellationToken);
|
||||||
@@ -186,7 +208,12 @@ namespace Darkmatter.Services.Ads
|
|||||||
|
|
||||||
switch (format)
|
switch (format)
|
||||||
{
|
{
|
||||||
case AdFormat.Interstitial: return await ShowInterstitialAsync(cancellationToken);
|
case AdFormat.Interstitial:
|
||||||
|
{
|
||||||
|
var result = await ShowInterstitialAsync(cancellationToken);
|
||||||
|
if (result.Shown) _interstitialsShownThisSession++; // count real shows only
|
||||||
|
return result;
|
||||||
|
}
|
||||||
case AdFormat.Rewarded: return await ShowRewardedAsync(cancellationToken);
|
case AdFormat.Rewarded: return await ShowRewardedAsync(cancellationToken);
|
||||||
case AdFormat.RewardedInterstitial: return await ShowRewardedInterstitialAsync(cancellationToken);
|
case AdFormat.RewardedInterstitial: return await ShowRewardedInterstitialAsync(cancellationToken);
|
||||||
case AdFormat.AppOpen: return await ShowAppOpenAsync(cancellationToken);
|
case AdFormat.AppOpen: return await ShowAppOpenAsync(cancellationToken);
|
||||||
@@ -208,6 +235,8 @@ namespace Darkmatter.Services.Ads
|
|||||||
|
|
||||||
_banner?.Destroy();
|
_banner?.Destroy();
|
||||||
_banner = new BannerView(unitId, MapBannerSize(size), MapBannerPosition(position));
|
_banner = new BannerView(unitId, MapBannerSize(size), MapBannerPosition(position));
|
||||||
|
_banner.OnAdPaid += v => LogAdImpression(AdFormat.Banner, v);
|
||||||
|
_banner.OnAdClicked += () => LogAdClicked(AdFormat.Banner);
|
||||||
|
|
||||||
var tcs = new UniTaskCompletionSource<bool>();
|
var tcs = new UniTaskCompletionSource<bool>();
|
||||||
_banner.OnBannerAdLoaded += () => tcs.TrySetResult(true);
|
_banner.OnBannerAdLoaded += () => tcs.TrySetResult(true);
|
||||||
@@ -267,7 +296,9 @@ namespace Darkmatter.Services.Ads
|
|||||||
: TagForChildDirectedTreatment.Unspecified,
|
: TagForChildDirectedTreatment.Unspecified,
|
||||||
TagForUnderAgeOfConsent = _hasUserConsent
|
TagForUnderAgeOfConsent = _hasUserConsent
|
||||||
? TagForUnderAgeOfConsent.Unspecified
|
? TagForUnderAgeOfConsent.Unspecified
|
||||||
: TagForUnderAgeOfConsent.True
|
: TagForUnderAgeOfConsent.True,
|
||||||
|
// Child-directed app: cap served ads at G-rated content.
|
||||||
|
MaxAdContentRating = MaxAdContentRating.G
|
||||||
};
|
};
|
||||||
|
|
||||||
if (catalog.TestDeviceIds is { Count: > 0 })
|
if (catalog.TestDeviceIds is { Count: > 0 })
|
||||||
@@ -556,17 +587,59 @@ namespace Darkmatter.Services.Ads
|
|||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format) =>
|
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format)
|
||||||
|
{
|
||||||
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
||||||
|
ad.OnAdPaid += v => LogAdImpression(format, v);
|
||||||
|
ad.OnAdClicked += () => LogAdClicked(format);
|
||||||
|
}
|
||||||
|
|
||||||
private void WireFullScreenEvents(RewardedAd ad, AdFormat format) =>
|
private void WireFullScreenEvents(RewardedAd ad, AdFormat format)
|
||||||
|
{
|
||||||
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
||||||
|
ad.OnAdPaid += v => LogAdImpression(format, v);
|
||||||
|
ad.OnAdClicked += () => LogAdClicked(format);
|
||||||
|
}
|
||||||
|
|
||||||
private void WireFullScreenEvents(RewardedInterstitialAd ad, AdFormat format) =>
|
private void WireFullScreenEvents(RewardedInterstitialAd ad, AdFormat format)
|
||||||
|
{
|
||||||
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
||||||
|
ad.OnAdPaid += v => LogAdImpression(format, v);
|
||||||
|
ad.OnAdClicked += () => LogAdClicked(format);
|
||||||
|
}
|
||||||
|
|
||||||
private void WireFullScreenEvents(AppOpenAd ad, AdFormat format) =>
|
private void WireFullScreenEvents(AppOpenAd ad, AdFormat format)
|
||||||
|
{
|
||||||
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
||||||
|
ad.OnAdPaid += v => LogAdImpression(format, v);
|
||||||
|
ad.OnAdClicked += () => LogAdClicked(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ad revenue: GA4-recommended ad_impression carries value+currency from AdMob's paid callback —
|
||||||
|
// this is what surfaces ad revenue in Firebase/GA4. Ad callbacks fire on the Unity main thread
|
||||||
|
// (RaiseAdEventsOnUnityMainThread = true), so logging straight to analytics is safe.
|
||||||
|
private void LogAdImpression(AdFormat format, AdValue value)
|
||||||
|
{
|
||||||
|
_analytics?.LogEvent(AnalyticsEvents.AdImpression, new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[AnalyticsParams.AdPlatform] = "AdMob",
|
||||||
|
[AnalyticsParams.AdFormat] = format.ToString(),
|
||||||
|
[AnalyticsParams.AdUnitName] = catalog.GetUnitId(format, Application.platform) ?? string.Empty,
|
||||||
|
[AnalyticsParams.Value] = value.Value / 1_000_000.0, // AdValue.Value is micros
|
||||||
|
[AnalyticsParams.Currency] = value.CurrencyCode ?? string.Empty,
|
||||||
|
[AnalyticsParams.Precision] = value.Precision.ToString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogAdClicked(AdFormat format)
|
||||||
|
{
|
||||||
|
_analytics?.LogEvent(AnalyticsEvents.AdClicked, new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[AnalyticsParams.AdPlatform] = "AdMob",
|
||||||
|
[AnalyticsParams.AdFormat] = format.ToString(),
|
||||||
|
[AnalyticsParams.AdUnitName] = catalog.GetUnitId(format, Application.platform) ?? string.Empty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void ScheduleReload(AdFormat format)
|
private void ScheduleReload(AdFormat format)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,8 +10,17 @@ namespace Darkmatter.Services.Analytics
|
|||||||
{
|
{
|
||||||
public void Register(IContainerBuilder builder)
|
public void Register(IContainerBuilder builder)
|
||||||
{
|
{
|
||||||
builder.RegisterEntryPoint<FirebaseAnalyticsSystem>().As<IAnalyticsService>();
|
// Firebase + Facebook are sinks (registered AsSelf so the composite can inject them);
|
||||||
|
// CompositeAnalyticsService is the single IAnalyticsService the rest of the app consumes.
|
||||||
|
builder.RegisterEntryPoint<FirebaseAnalyticsSystem>().AsSelf();
|
||||||
|
builder.RegisterEntryPoint<FacebookAnalyticsSystem>().AsSelf();
|
||||||
|
builder.Register<CompositeAnalyticsService>(Lifetime.Singleton).As<IAnalyticsService>();
|
||||||
|
|
||||||
builder.RegisterEntryPoint<AnalyticsTracker>();
|
builder.RegisterEntryPoint<AnalyticsTracker>();
|
||||||
|
builder.RegisterEntryPoint<ErrorAnalyticsTracker>();
|
||||||
|
|
||||||
|
// Feeds the FCM token to AppsFlyer for uninstall measurement (Android-only).
|
||||||
|
builder.RegisterEntryPoint<AppsFlyerUninstallTracker>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,12 @@
|
|||||||
"name": "Services.Analytics",
|
"name": "Services.Analytics",
|
||||||
"rootNamespace": "Darkmatter.Services.Analytics",
|
"rootNamespace": "Darkmatter.Services.Analytics",
|
||||||
"references": [
|
"references": [
|
||||||
"GUID:bd7ea2d41bfe64d229c22616f66e20f7",
|
|
||||||
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
|
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
|
||||||
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||||
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
||||||
"GUID:f8c64bb88d959406689053ae3f31183d",
|
|
||||||
"GUID:a0b1547602fc44f6da0a5e755ab3a7ef",
|
|
||||||
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
||||||
"GUID:b4c9f7fbf1e144933a1797dc208ece5f"
|
"GUID:b4c9f7fbf1e144933a1797dc208ece5f",
|
||||||
|
"GUID:2a37df438292d4903b4e5159c5de3bf9"
|
||||||
],
|
],
|
||||||
"includePlatforms": [],
|
"includePlatforms": [],
|
||||||
"excludePlatforms": [],
|
"excludePlatforms": [],
|
||||||
|
|||||||
@@ -4,21 +4,43 @@ using Darkmatter.Core;
|
|||||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||||
using Darkmatter.Core.Data.Signals.Features.AppBoot;
|
using Darkmatter.Core.Data.Signals.Features.AppBoot;
|
||||||
using Darkmatter.Core.Data.Signals.Features.Capture;
|
using Darkmatter.Core.Data.Signals.Features.Capture;
|
||||||
|
using Darkmatter.Core.Data.Signals.Features.Coloring;
|
||||||
using Darkmatter.Core.Data.Signals.Features.Drawing;
|
using Darkmatter.Core.Data.Signals.Features.Drawing;
|
||||||
using Darkmatter.Core.Data.Signals.Features.GameplayFlow;
|
using Darkmatter.Core.Data.Signals.Features.GameplayFlow;
|
||||||
using Darkmatter.Core.Data.Signals.Features.MainMenu;
|
using Darkmatter.Core.Data.Signals.Features.MainMenu;
|
||||||
using Darkmatter.Core.Data.Signals.Features.ShapeBuilder;
|
using Darkmatter.Core.Data.Signals.Features.ShapeBuilder;
|
||||||
|
using Darkmatter.Core.Data.Signals.Features.Tutorial;
|
||||||
using Darkmatter.Libs.Observer;
|
using Darkmatter.Libs.Observer;
|
||||||
|
using UnityEngine;
|
||||||
using VContainer.Unity;
|
using VContainer.Unity;
|
||||||
|
|
||||||
namespace Darkmatter.Services.Analytics
|
namespace Darkmatter.Services.Analytics
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bridges gameplay signals to analytics events. Holds light per-drawing state (start time, distinct
|
||||||
|
/// colors, shape attempts) so completion/abandon events carry duration_seconds, colors_used and
|
||||||
|
/// attempts — the params that turn "they quit" into "they quit on drawing X after N seconds".
|
||||||
|
///
|
||||||
|
/// Logs once to <see cref="IAnalyticsService"/> (the composite), which fans out to Firebase + Facebook.
|
||||||
|
/// </summary>
|
||||||
public sealed class AnalyticsTracker : IStartable, IDisposable
|
public sealed class AnalyticsTracker : IStartable, IDisposable
|
||||||
{
|
{
|
||||||
|
private const int ColorApplySampleEvery = 25; // color_applied is high-frequency → sample
|
||||||
|
private const string FirstDrawingKey = "analytics_first_drawing_started";
|
||||||
|
private const string AllContentKey = "analytics_all_content_completed";
|
||||||
|
|
||||||
private readonly IEventBus _bus;
|
private readonly IEventBus _bus;
|
||||||
private readonly IAnalyticsService _analytics;
|
private readonly IAnalyticsService _analytics;
|
||||||
private readonly List<IDisposable> _subs = new();
|
private readonly List<IDisposable> _subs = new();
|
||||||
|
|
||||||
|
// Per-drawing aggregation state.
|
||||||
|
private string _activeDrawingId;
|
||||||
|
private float _drawingStartTime = -1f;
|
||||||
|
private readonly HashSet<int> _colors = new();
|
||||||
|
private int _colorApplyCount;
|
||||||
|
private int _shapeAttempts;
|
||||||
|
private bool _firstDrawingTracked;
|
||||||
|
|
||||||
public AnalyticsTracker(IEventBus bus, IAnalyticsService analytics)
|
public AnalyticsTracker(IEventBus bus, IAnalyticsService analytics)
|
||||||
{
|
{
|
||||||
_bus = bus;
|
_bus = bus;
|
||||||
@@ -27,31 +49,180 @@ namespace Darkmatter.Services.Analytics
|
|||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
_subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent("intro_completed")));
|
_firstDrawingTracked = PlayerPrefs.GetInt(FirstDrawingKey, 0) == 1;
|
||||||
_subs.Add(_bus.Subscribe<PlayBtnClickedSignal>(_ => _analytics.LogEvent("play_clicked")));
|
|
||||||
_subs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => _analytics.LogEvent("colorbook_opened")));
|
|
||||||
_subs.Add(_bus.Subscribe<OpenArtBookSignal>(_ => _analytics.LogEvent("artbook_opened")));
|
|
||||||
_subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => _analytics.LogEvent("main_menu_returned")));
|
|
||||||
|
|
||||||
_subs.Add(_bus.Subscribe<DrawingSelectedSignal>(s =>
|
// Onboarding / activation funnel
|
||||||
_analytics.LogEvent("drawing_selected", "template_id", s.TemplateId)));
|
_subs.Add(_bus.Subscribe<IntroStartedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.IntroStarted)));
|
||||||
|
_subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.IntroCompleted)));
|
||||||
|
_subs.Add(_bus.Subscribe<PlayBtnClickedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.PlayClicked)));
|
||||||
|
|
||||||
_subs.Add(_bus.Subscribe<ShapeBuilderStartedSignal>(s =>
|
// Navigation
|
||||||
_analytics.LogEvent("shape_builder_started", "template_id", s.TemplateId)));
|
_subs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ColorbookOpened)));
|
||||||
|
_subs.Add(_bus.Subscribe<OpenArtBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ArtbookOpened)));
|
||||||
|
_subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => OnReturnToMainMenu()));
|
||||||
|
|
||||||
_subs.Add(_bus.Subscribe<ShapeAssembledSignal>(s =>
|
// Core gameplay loop
|
||||||
_analytics.LogEvent("shape_assembled", "template_id", s.TemplateId)));
|
_subs.Add(_bus.Subscribe<DrawingSelectedSignal>(s => OnDrawingSelected(s.TemplateId)));
|
||||||
|
_subs.Add(_bus.Subscribe<ColorAppliedSignal>(OnColorApplied));
|
||||||
|
_subs.Add(_bus.Subscribe<ShapeBuilderStartedSignal>(s => OnShapeBuilderStarted(s.TemplateId)));
|
||||||
|
_subs.Add(_bus.Subscribe<PieceSnappedSignal>(_ => _shapeAttempts++));
|
||||||
|
_subs.Add(_bus.Subscribe<PieceUnsnappedSignal>(_ => _shapeAttempts++));
|
||||||
|
_subs.Add(_bus.Subscribe<ShapeAssembledSignal>(s => OnShapeAssembled(s.TemplateId)));
|
||||||
|
_subs.Add(_bus.Subscribe<DrawingCompletedSignal>(OnDrawingCompleted));
|
||||||
|
|
||||||
_subs.Add(_bus.Subscribe<DrawingCompletedSignal>(s => _analytics.LogEvent("drawing_completed",
|
// Progression & content
|
||||||
|
_subs.Add(_bus.Subscribe<AllContentCompletedSignal>(OnAllContentCompleted));
|
||||||
|
|
||||||
|
// Capture / save
|
||||||
|
_subs.Add(_bus.Subscribe<GallerySaveStartedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.GallerySaveStarted)));
|
||||||
|
_subs.Add(_bus.Subscribe<GallerySaveCompletedSignal>(OnGallerySaveCompleted));
|
||||||
|
|
||||||
|
// Tutorial
|
||||||
|
_subs.Add(_bus.Subscribe<TutorialStartedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.TutorialStarted)));
|
||||||
|
_subs.Add(_bus.Subscribe<TutorialStepCompletedSignal>(s => _analytics.LogEvent(AnalyticsEvents.TutorialStepCompleted,
|
||||||
new Dictionary<string, object>
|
new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["template_id"] = s.TemplateId,
|
[AnalyticsParams.StepId] = s.StepId,
|
||||||
["completion_count"] = s.CompletionCount,
|
[AnalyticsParams.StepIndex] = s.StepIndex,
|
||||||
})));
|
})));
|
||||||
|
_subs.Add(_bus.Subscribe<TutorialCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.TutorialCompleted)));
|
||||||
|
}
|
||||||
|
|
||||||
_subs.Add(_bus.Subscribe<GallerySaveStartedSignal>(_ => _analytics.LogEvent("gallery_save_started")));
|
private void OnDrawingSelected(string drawingId)
|
||||||
_subs.Add(_bus.Subscribe<GallerySaveCompletedSignal>(s =>
|
{
|
||||||
_analytics.LogEvent("gallery_save_completed", "success", s.Success ? "true" : "false")));
|
// A new selection while a drawing is still open (uncompleted) = the previous one was abandoned.
|
||||||
|
EndActiveDrawingIfAbandoned();
|
||||||
|
|
||||||
|
_activeDrawingId = drawingId;
|
||||||
|
_drawingStartTime = Time.realtimeSinceStartup;
|
||||||
|
_colors.Clear();
|
||||||
|
_colorApplyCount = 0;
|
||||||
|
_shapeAttempts = 0;
|
||||||
|
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.DrawingSelected, AnalyticsParams.DrawingId, drawingId);
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.DrawingStarted, AnalyticsParams.DrawingId, drawingId);
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.LevelStart, AnalyticsParams.LevelName, drawingId);
|
||||||
|
|
||||||
|
if (!_firstDrawingTracked)
|
||||||
|
{
|
||||||
|
_firstDrawingTracked = true;
|
||||||
|
PlayerPrefs.SetInt(FirstDrawingKey, 1);
|
||||||
|
PlayerPrefs.Save();
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.FirstDrawingStarted, AnalyticsParams.DrawingId, drawingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnColorApplied(ColorAppliedSignal s)
|
||||||
|
{
|
||||||
|
_colorApplyCount++;
|
||||||
|
Color32 c = s.Color;
|
||||||
|
_colors.Add((c.r << 24) | (c.g << 16) | (c.b << 8) | c.a);
|
||||||
|
|
||||||
|
// High-frequency event: emit only the first apply and every Nth thereafter.
|
||||||
|
if (_colorApplyCount == 1 || _colorApplyCount % ColorApplySampleEvery == 0)
|
||||||
|
{
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.ColorApplied, new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[AnalyticsParams.DrawingId] = _activeDrawingId ?? string.Empty,
|
||||||
|
[AnalyticsParams.ApplyIndex] = _colorApplyCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnShapeBuilderStarted(string drawingId)
|
||||||
|
{
|
||||||
|
_shapeAttempts = 0;
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.ShapeBuilderStarted, AnalyticsParams.DrawingId, drawingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnShapeAssembled(string drawingId)
|
||||||
|
{
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.ShapeAssembled, new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[AnalyticsParams.DrawingId] = drawingId,
|
||||||
|
[AnalyticsParams.Attempts] = _shapeAttempts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDrawingCompleted(DrawingCompletedSignal s)
|
||||||
|
{
|
||||||
|
var p = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[AnalyticsParams.DrawingId] = s.TemplateId,
|
||||||
|
[AnalyticsParams.CompletionCount] = s.CompletionCount,
|
||||||
|
[AnalyticsParams.ColorsUsed] = _colors.Count,
|
||||||
|
};
|
||||||
|
float dur = ElapsedDrawingSeconds();
|
||||||
|
if (dur >= 0f) p[AnalyticsParams.DurationSeconds] = dur;
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.DrawingCompleted, p);
|
||||||
|
|
||||||
|
// GA4 recommended games event (lights up built-in level funnels).
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.LevelComplete, new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[AnalyticsParams.LevelName] = s.TemplateId,
|
||||||
|
[AnalyticsParams.Success] = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
ClearActiveDrawing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAllContentCompleted(AllContentCompletedSignal s)
|
||||||
|
{
|
||||||
|
if (PlayerPrefs.GetInt(AllContentKey, 0) == 1) return; // once ever
|
||||||
|
PlayerPrefs.SetInt(AllContentKey, 1);
|
||||||
|
PlayerPrefs.Save();
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.AllContentCompleted,
|
||||||
|
AnalyticsParams.CompletionCount, s.CompletedCount.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGallerySaveCompleted(GallerySaveCompletedSignal s)
|
||||||
|
{
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.GallerySaveCompleted, AnalyticsParams.Success, s.Success ? "true" : "false");
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.DrawingSaved, new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[AnalyticsParams.DrawingId] = _activeDrawingId ?? string.Empty,
|
||||||
|
[AnalyticsParams.Success] = s.Success ? 1 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnReturnToMainMenu()
|
||||||
|
{
|
||||||
|
EndActiveDrawingIfAbandoned();
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.MainMenuReturned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emits drawing_abandoned for an open, uncompleted drawing. Completion clears _activeDrawingId, so
|
||||||
|
// reaching here with a non-null id means the drawing was left without finishing — the leak signal.
|
||||||
|
private void EndActiveDrawingIfAbandoned()
|
||||||
|
{
|
||||||
|
if (_activeDrawingId == null) return;
|
||||||
|
|
||||||
|
var p = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[AnalyticsParams.DrawingId] = _activeDrawingId,
|
||||||
|
[AnalyticsParams.ColorsUsed] = _colors.Count,
|
||||||
|
};
|
||||||
|
float dur = ElapsedDrawingSeconds();
|
||||||
|
if (dur >= 0f) p[AnalyticsParams.DurationSeconds] = dur;
|
||||||
|
_analytics.LogEvent(AnalyticsEvents.DrawingAbandoned, p);
|
||||||
|
|
||||||
|
ClearActiveDrawing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private float ElapsedDrawingSeconds()
|
||||||
|
{
|
||||||
|
if (_drawingStartTime < 0f) return -1f;
|
||||||
|
float d = Time.realtimeSinceStartup - _drawingStartTime;
|
||||||
|
return d >= 0f ? d : -1f; // guard against realtime baseline reset across an app relaunch
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearActiveDrawing()
|
||||||
|
{
|
||||||
|
_activeDrawingId = null;
|
||||||
|
_drawingStartTime = -1f;
|
||||||
|
_colors.Clear();
|
||||||
|
_colorApplyCount = 0;
|
||||||
|
_shapeAttempts = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
using UnityEngine;
|
||||||
|
using VContainer.Unity;
|
||||||
|
#if UNITY_ANDROID
|
||||||
|
using AppsFlyerSDK;
|
||||||
|
using Firebase;
|
||||||
|
using Firebase.Messaging;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace Darkmatter.Services.Analytics
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Forwards the Firebase Cloud Messaging registration token to AppsFlyer so it can measure uninstalls.
|
||||||
|
/// Android-only: AppsFlyer's server-uninstall measurement keys off the FCM token. iOS uninstall uses the
|
||||||
|
/// APNs device token via a different AppsFlyer API and is intentionally not wired here, so FCM is not
|
||||||
|
/// initialised on iOS.
|
||||||
|
///
|
||||||
|
/// AppsFlyer itself is started in the Boot scene (AppsFlyerObjectScript); this only feeds it the token.
|
||||||
|
/// </summary>
|
||||||
|
public class AppsFlyerUninstallTracker : IAsyncStartable, IDisposable
|
||||||
|
{
|
||||||
|
public async UniTask StartAsync(CancellationToken cancellation = default)
|
||||||
|
{
|
||||||
|
#if UNITY_ANDROID
|
||||||
|
DependencyStatus status;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
status = await FirebaseApp.CheckAndFixDependenciesAsync().AsUniTask();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[AppsFlyerUninstall] Firebase init failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status != DependencyStatus.Available)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[AppsFlyerUninstall] Firebase deps unavailable: {status}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribing triggers FCM registration; the current token is delivered here on every refresh.
|
||||||
|
FirebaseMessaging.TokenReceived += OnTokenReceived;
|
||||||
|
#else
|
||||||
|
await UniTask.CompletedTask;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if UNITY_ANDROID
|
||||||
|
private static void OnTokenReceived(object sender, TokenReceivedEventArgs e)
|
||||||
|
{
|
||||||
|
try { AppsFlyer.updateServerUninstallToken(e.Token); }
|
||||||
|
catch (Exception ex) { Debug.LogError($"[AppsFlyerUninstall] updateServerUninstallToken failed: {ex}"); }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
#if UNITY_ANDROID
|
||||||
|
FirebaseMessaging.TokenReceived -= OnTokenReceived;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7a08ecb787f14b11a4f8ce163f223a53
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user