Compare commits
48 Commits
e3f047de2b
...
work_branc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa2b8f1e8e | ||
|
|
f6d672995e | ||
|
|
ecc2dd3dff | ||
|
|
c2d3ff4dd9 | ||
|
|
dfd76d187f | ||
|
|
7b90efca98 | ||
|
|
e0a7fa9735 | ||
|
|
3df3d923fb | ||
|
|
40ba0df92d | ||
|
|
b28e1f637d | ||
|
|
21e5206626 | ||
|
|
3095469c4f | ||
|
|
fc291cf116 | ||
|
|
7446e49c8f | ||
|
|
f3c80be3ca | ||
|
|
db3354c88b | ||
|
|
c64020628b | ||
|
|
789edf1fba | ||
|
|
b9c9889145 | ||
|
|
608656bb83 | ||
|
|
ab706059c1 | ||
|
|
93fafae63b | ||
|
|
a04a826ae0 | ||
|
|
a4fff942bf | ||
|
|
cf2f37be0c | ||
|
|
729ddf9277 | ||
|
|
a15b08df0c | ||
|
|
8c6962762c | ||
|
|
fd3c13e470 | ||
|
|
7d85a0be1d | ||
|
|
f08635225d | ||
|
|
36cf9cdad1 | ||
|
|
435988ca70 | ||
|
|
176d95ec6a | ||
|
|
f63c0c9d5e | ||
|
|
4eccde41b5 | ||
|
|
68bea947fe | ||
|
|
0b9d0ca8f2 | ||
|
|
521b5c3b7d | ||
|
|
ec4288e97a | ||
|
|
d406f41acc | ||
|
|
144bac177f | ||
|
|
a07f7618ab | ||
|
|
70b8dd2b95 | ||
|
|
220d651cfb | ||
|
|
e27d0e54cb | ||
|
|
dee4b004bd | ||
|
|
2462d972e3 |
BIN
Assets/.DS_Store
vendored
BIN
Assets/.DS_Store
vendored
Binary file not shown.
BIN
Assets/AddressableAssetsData/.DS_Store
vendored
Normal file
BIN
Assets/AddressableAssetsData/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -15,7 +15,8 @@ MonoBehaviour:
|
||||
m_DefaultGroup: 0e030d5498bfe4ffd8443c796618c539
|
||||
m_currentHash:
|
||||
serializedVersion: 2
|
||||
Hash: 00000000000000000000000000000000
|
||||
Hash: 9c85511c3b292d6ea9a478a45ca9832c
|
||||
m_ExtractTypeTreeData: 0
|
||||
m_OptimizeCatalogSize: 0
|
||||
m_BuildRemoteCatalog: 0
|
||||
m_CatalogRequestsTimeout: 0
|
||||
|
||||
@@ -21,12 +21,24 @@ MonoBehaviour:
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 14027365d88f82745bd4019ac24a4a48
|
||||
m_Address: Rooster
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 14477b5d35d0be9439ecb935f1c4e64e
|
||||
m_Address: Donut
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 18367bdefdff1504fb125f7abf8d1283
|
||||
m_Address: Motorcycle
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 2043f692673a79543afed5cb879f0e04
|
||||
m_Address: Five
|
||||
m_ReadOnly: 0
|
||||
@@ -39,18 +51,48 @@ MonoBehaviour:
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 2d13a177a6749834288f5e88a973fee7
|
||||
m_Address: Yak
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 2d55441a1a6751c45bb9af623301426e
|
||||
m_Address: Car
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 2ee6ed56b9746934386767eca8c7a128
|
||||
m_Address: Pankha
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 364f644d962504fa7ac2f382800014ea
|
||||
m_Address: apple
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 37724d82877cdd742b46da6b173015e0
|
||||
m_Address: Tipper
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 3dc99c70dccf80940bb59c8744ad6893
|
||||
m_Address: Butterfly
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 3f2894c3e52b9034fb38c2d61987e19c
|
||||
m_Address: Temple
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 4474b93704045a740ba9b8114e8a5238
|
||||
m_Address: Fish
|
||||
m_ReadOnly: 0
|
||||
@@ -63,24 +105,90 @@ MonoBehaviour:
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 4986d20a529345f48a5c05dc039848b5
|
||||
m_Address: Snow Leopard
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 55bd536848430a14290186903c3ccc79
|
||||
m_Address: Ketli
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 5c0e5d19eecbfae43a61b7c6145357a9
|
||||
m_Address: Dashain Ping
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 5d02c6b7028e54e479bf1b645b4fcf44
|
||||
m_Address: Madal
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 5f3b8273533c8514cbc5f46cf86f90e8
|
||||
m_Address: Diyo
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 62ae112e11b695a40b889d773a36f8bd
|
||||
m_Address: Elephant
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 67fc4e3245778f34083cfec4de5572ab
|
||||
m_Address: Laligurans
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 6b911e576e57c68468991e31fe634b37
|
||||
m_Address: Eagle
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 6e36ba5d4763c694289c8ca75ee81449
|
||||
m_Address: Bus
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 88e19e9e182745c48825ef3852cf8f4c
|
||||
m_Address: Umbrella
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 8cdc379bbe2529a419e5ce0eedcbb09e
|
||||
m_Address: Giraffe
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 8efb0b102f9afc04da1b0fbfbcf1e764
|
||||
m_Address: Doll
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 9274ef6670e95f04283827da968c8e8b
|
||||
m_Address: Peacock
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 949b2f7f7192a4c40b11a6b2a88a4d96
|
||||
m_Address: Chura
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 971be7b01d5eef24fa58094805ea92b9
|
||||
m_Address: Kite
|
||||
m_ReadOnly: 0
|
||||
@@ -93,12 +201,66 @@ MonoBehaviour:
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: a0b17d65fcd1db642acd02e569d2ab18
|
||||
m_Address: Tempo
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: a2ef043d1d7051c47a68e2ebce7bd3dd
|
||||
m_Address: Dog
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: a3267b093c298c146b47cbc2dec66201
|
||||
m_Address: Crocodile
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: a3e107832ef0cd044b55484d4d084b2e
|
||||
m_Address: Rhino
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: a80d3a0f58b23a04fa7e256dcbde84ea
|
||||
m_Address: Mirga
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: b25eb4fe03b3fa44cba09f957cb66f0f
|
||||
m_Address: Ping
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: b900a31d7a581b44880c1c1c0fd2120b
|
||||
m_Address: Khukuri
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: b91952879cc013c4a937230bcfc50a13
|
||||
m_Address: Monkey
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: bfafeadd98627734cabbb911a519be0e
|
||||
m_Address: Mandala
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: d9cb166d2b3929646a141aa69bafabf3
|
||||
m_Address: danfe
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: dc24a6d2802de5d4298d1da3d1415e97
|
||||
m_Address: Envelop
|
||||
m_ReadOnly: 0
|
||||
@@ -111,12 +273,60 @@ MonoBehaviour:
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: e01d5e1bee4eb7d4b897466911d9ba5d
|
||||
m_Address: Mala
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: e4fa512c35aded6499b818ff5844fa65
|
||||
m_Address: Gagri
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: ee932fc542864974595b8f1114a85f06
|
||||
m_Address: Bag
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: ef139978c562d1e49a73714f90943327
|
||||
m_Address: Parrot
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: f3200c6715fea4a41bc71c0314b519cd
|
||||
m_Address: Frog
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: f64d814175be99b409d6b1c94e4b424c
|
||||
m_Address: Tractor
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: f87bb07e8452a5b4c9ad8dc140bc8e73
|
||||
m_Address: Cat
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: f8a1809740bda3247a55aee5355ccebf
|
||||
m_Address: Tiger
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: fb4d005ac1c2506438718430fb29cfb2
|
||||
m_Address: Rabbit
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels:
|
||||
- drawing
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 0
|
||||
m_Settings: {fileID: 11400000, guid: 4a94ef317c3674edd8270e4ed15031f6, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
BIN
Assets/AppsFlyer/.DS_Store
vendored
Normal file
BIN
Assets/AppsFlyer/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/Confetti FX/.DS_Store
vendored
Normal file
BIN
Assets/Confetti FX/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/Darkmatter/.DS_Store
vendored
BIN
Assets/Darkmatter/.DS_Store
vendored
Binary file not shown.
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
@@ -20,6 +21,7 @@ public interface IColoringController
|
||||
bool IsPlayingCompletionAnimation { get; }
|
||||
|
||||
bool HasNonAuthoredColors { get; }
|
||||
IDisposable UseNonZoomedView();
|
||||
void ResetAll();
|
||||
void Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ namespace Darkmatter.Core.Contracts.Features.DrawingCatalog
|
||||
{
|
||||
string Id { get; }
|
||||
string DisplayName { get; }
|
||||
int Difficulty { get; }
|
||||
Sprite DefaultThumbnail { get; }
|
||||
GameObject DrawingPrefab { get; }
|
||||
GameObject ColoringPrefab { get; }
|
||||
GameObject CompletionAnimationPrefab { get; }
|
||||
AudioClip CompletionSound { get; }
|
||||
IReadOnlyList<ShapeSO> Pieces { get; }
|
||||
IReadOnlyList<ColorRegionDTO> Regions { get; }
|
||||
string ColorPaletteId { get; }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 224a4c0e39279476aa91487a2572bbdd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Enums.Features.Tutorial;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Core.Contracts.Features.Tutorial
|
||||
@@ -18,19 +19,23 @@ namespace Darkmatter.Core.Contracts.Features.Tutorial
|
||||
/// 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);
|
||||
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);
|
||||
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);
|
||||
UniTask ShowToastAsync(string message, CancellationToken ct, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_begin";
|
||||
public const string TutorialStepCompleted = "tutorial_step_completed";
|
||||
public const string TutorialCompleted = "tutorial_complete";
|
||||
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. level_start and level_end are GA4-recommended games events.
|
||||
public const string LevelStart = "level_start";
|
||||
public const string LevelComplete = "level_end";
|
||||
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_end/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_end)
|
||||
|
||||
// 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_end)
|
||||
|
||||
// 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
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
@@ -7,6 +8,6 @@ namespace Darkmatter.Core.Contracts.Services.Capture
|
||||
public interface ICaptureService
|
||||
{
|
||||
UniTask<byte[]> CapturePngAsync(GameObject captureObject, float scale,
|
||||
CancellationToken cancellationToken = default);
|
||||
Func<IDisposable> captureViewScopeFactory = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,8 @@
|
||||
namespace Darkmatter.Core.Data.Signals.Features.Capture
|
||||
{
|
||||
public record struct GallerySaveCompletedSignal(bool Success);
|
||||
/// <summary>
|
||||
/// TemplateId identifies which drawing was saved; publishers outside gameplay (art book) must set it
|
||||
/// because there is no active drawing for analytics to fall back on there.
|
||||
/// </summary>
|
||||
public record struct GallerySaveCompletedSignal(bool Success, string TemplateId = null);
|
||||
}
|
||||
|
||||
@@ -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,10 @@
|
||||
namespace Darkmatter.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// The drawing-catalog (colorbook) screen became visible: scene entry (play / back / next) or
|
||||
/// returning from the art book. Distinct from <see cref="OpenColorBookSignal"/>, which is the
|
||||
/// command to show the view and only fires on one navigation path. Analytics keys colorbook_opened
|
||||
/// and abandoned-drawing detection off this.
|
||||
/// </summary>
|
||||
public record struct ColorbookShownSignal;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa0fb32346ff54da3a9bbfec9f763cf2
|
||||
@@ -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
|
||||
@@ -12,20 +12,29 @@ namespace Darkmatter.Core.Data.Static.Features.DrawingTemplate
|
||||
{
|
||||
[SerializeField] private string id;
|
||||
[SerializeField] private string displayName;
|
||||
[SerializeField, Min(0), Tooltip("0 = auto (pieces + regions). Any positive value overrides the computed difficulty.")]
|
||||
private int difficultyOverride;
|
||||
[SerializeField] private Sprite defaultThumbnail;
|
||||
[SerializeField] private GameObject drawingPrefab;
|
||||
[SerializeField] private GameObject coloringPrefab;
|
||||
[SerializeField] private GameObject completionAnimationPrefab;
|
||||
[SerializeField] private AudioClip completionSound;
|
||||
[SerializeField] private List<ShapeSO> pieces = new();
|
||||
[SerializeField] private List<RegionAuthoring> regions = new();
|
||||
[SerializeField] private string colorPaletteId = "defaultPalette";
|
||||
|
||||
public string Id => id;
|
||||
public string DisplayName => displayName;
|
||||
// Workload-based: every shape to place and region to color adds one point.
|
||||
// A positive difficultyOverride replaces the computed value.
|
||||
public int AutoDifficulty => pieces.Count + regions.Count;
|
||||
public int Difficulty => difficultyOverride > 0 ? difficultyOverride : AutoDifficulty;
|
||||
public bool HasDifficultyOverride => difficultyOverride > 0;
|
||||
public Sprite DefaultThumbnail => defaultThumbnail;
|
||||
public GameObject DrawingPrefab => drawingPrefab;
|
||||
public GameObject ColoringPrefab => coloringPrefab;
|
||||
public GameObject CompletionAnimationPrefab => completionAnimationPrefab;
|
||||
public AudioClip CompletionSound => completionSound;
|
||||
public IReadOnlyList<ShapeSO> Pieces => pieces;
|
||||
public IReadOnlyList<ColorRegionDTO> Regions => Array.Empty<ColorRegionDTO>();
|
||||
public string ColorPaletteId => colorPaletteId;
|
||||
@@ -34,7 +43,7 @@ namespace Darkmatter.Core.Data.Static.Features.DrawingTemplate
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public void EditorSet(string newId, string newDisplayName, Sprite thumbnail,
|
||||
GameObject drawing, GameObject coloring, GameObject completionAnimation,
|
||||
GameObject drawing, GameObject coloring, GameObject completionAnimation, AudioClip completionSoundClip,
|
||||
List<ShapeSO> newPieces,
|
||||
List<RegionAuthoring> newRegions, string paletteId)
|
||||
{
|
||||
@@ -44,6 +53,7 @@ namespace Darkmatter.Core.Data.Static.Features.DrawingTemplate
|
||||
drawingPrefab = drawing;
|
||||
coloringPrefab = coloring;
|
||||
completionAnimationPrefab = completionAnimation;
|
||||
completionSound = completionSoundClip;
|
||||
pieces = newPieces ?? new List<ShapeSO>();
|
||||
regions = newRegions ?? new List<RegionAuthoring>();
|
||||
colorPaletteId = paletteId;
|
||||
|
||||
@@ -6,9 +6,13 @@ namespace Darkmatter.Core.Data.Static.Features.ShapeBuilder
|
||||
menuName = "Darkmatter/ShapeBuilder/Config")]
|
||||
public sealed class ShapeBuilderConfig : ScriptableObject
|
||||
{
|
||||
[Header("Radii (canvas units; reference resolution 2048x2048)")]
|
||||
[SerializeField] private float snapRadius = 100f;
|
||||
[SerializeField] private float previewRadius = 200f;
|
||||
// Single catch radius for both preview and snap, expressed as a multiple of
|
||||
// EACH slot's own half-diagonal — so a big slot gets a big catch and a small
|
||||
// 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)")]
|
||||
[SerializeField] private float snapDuration = 0.25f;
|
||||
@@ -18,16 +22,11 @@ namespace Darkmatter.Core.Data.Static.Features.ShapeBuilder
|
||||
[SerializeField, Range(1f, 2f)] private float dragScale = 1.15f;
|
||||
[SerializeField, Range(0f, 1f)] private float dragAlpha = 0.7f;
|
||||
|
||||
[Header("Preview easing")]
|
||||
[SerializeField] private AnimationCurve previewCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
||||
|
||||
public float SnapRadius => snapRadius;
|
||||
public float PreviewRadius => previewRadius;
|
||||
public float CatchRadiusScale => catchRadiusScale;
|
||||
public float SnapDuration => snapDuration;
|
||||
public float ReturnDuration => returnDuration;
|
||||
public float DragScale => dragScale;
|
||||
public float DragAlpha => dragAlpha;
|
||||
public AnimationCurve PreviewCurve => previewCurve;
|
||||
|
||||
public Vector2 DragSizeDelta(ShapeSO shape) =>
|
||||
shape != null ? shape.DefaultSizeDelta : new Vector2(256, 256);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Darkmatter.Core.Enums.Features.Tutorial;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Core.Data.Static.Features.Tutorial
|
||||
@@ -17,9 +18,28 @@ namespace Darkmatter.Core.Data.Static.Features.Tutorial
|
||||
[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.")]
|
||||
@@ -34,9 +54,28 @@ namespace Darkmatter.Core.Data.Static.Features.Tutorial
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
8
Assets/Darkmatter/Code/Editor.meta
Normal file
8
Assets/Darkmatter/Code/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a01b1f301d474e39808b73160a98677
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,78 @@
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace Darkmatter.EditorTools
|
||||
{
|
||||
public static class AddSelectedPrefabToPaperRoot
|
||||
{
|
||||
[MenuItem("Tools/Colorbook/Add Selected Prefab To PaperRoot")]
|
||||
private static void AddSelected()
|
||||
{
|
||||
var prefab = Selection.activeObject as GameObject;
|
||||
if (prefab == null || PrefabUtility.GetPrefabAssetType(prefab) == PrefabAssetType.NotAPrefab)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"Select a prefab",
|
||||
"Select a drawing, coloring, or animation prefab in the Project window first.",
|
||||
"OK");
|
||||
return;
|
||||
}
|
||||
|
||||
var paperRoot = FindPaperRoot();
|
||||
if (paperRoot == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"PaperRoot not found",
|
||||
"Open the Gameplay scene and make sure it contains an object named PaperRoot.",
|
||||
"OK");
|
||||
return;
|
||||
}
|
||||
|
||||
var instance = PrefabUtility.InstantiatePrefab(prefab, paperRoot.scene) as GameObject;
|
||||
if (instance == null) return;
|
||||
|
||||
Undo.RegisterCreatedObjectUndo(instance, $"Add {prefab.name} to PaperRoot");
|
||||
Undo.SetTransformParent(instance.transform, paperRoot.transform, "Parent prefab to PaperRoot");
|
||||
|
||||
if (instance.transform is RectTransform rectTransform)
|
||||
{
|
||||
rectTransform.anchorMin = Vector2.zero;
|
||||
rectTransform.anchorMax = Vector2.one;
|
||||
rectTransform.anchoredPosition = Vector2.zero;
|
||||
rectTransform.sizeDelta = Vector2.zero;
|
||||
rectTransform.localScale = Vector3.one;
|
||||
}
|
||||
else
|
||||
{
|
||||
instance.transform.localPosition = Vector3.zero;
|
||||
instance.transform.localRotation = Quaternion.identity;
|
||||
instance.transform.localScale = Vector3.one;
|
||||
}
|
||||
|
||||
Selection.activeGameObject = instance;
|
||||
EditorSceneManager.MarkSceneDirty(paperRoot.scene);
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Colorbook/Add Selected Prefab To PaperRoot", true)]
|
||||
private static bool ValidateAddSelected()
|
||||
{
|
||||
return !EditorApplication.isPlayingOrWillChangePlaymode;
|
||||
}
|
||||
|
||||
private static GameObject FindPaperRoot()
|
||||
{
|
||||
var activeScene = SceneManager.GetActiveScene();
|
||||
foreach (var root in activeScene.GetRootGameObjects())
|
||||
{
|
||||
var transforms = root.GetComponentsInChildren<Transform>(true);
|
||||
foreach (var candidate in transforms)
|
||||
if (candidate.name == "PaperRoot")
|
||||
return candidate.gameObject;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 675d955c682824cd9904fb2e96517df1
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Features.Progression;
|
||||
@@ -14,6 +15,10 @@ namespace Darkmatter.Features.AppBoot.Flow
|
||||
{
|
||||
public class AppBootFlow : IAsyncStartable
|
||||
{
|
||||
private const int IntroPrepareTimeoutMs = 3000;
|
||||
private const float IntroMinimumFallbackSeconds = 6f;
|
||||
private const float IntroFallbackPaddingSeconds = 2f;
|
||||
|
||||
private readonly AppBootSceneRefs _sceneRefs;
|
||||
private readonly ISceneService _sceneService;
|
||||
private readonly IEventBus _eventBus;
|
||||
@@ -30,32 +35,96 @@ namespace Darkmatter.Features.AppBoot.Flow
|
||||
|
||||
public async UniTask StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
// Entry points run in serviceModules registration order and this module is near the front.
|
||||
// Everything below LoadAsync completes synchronously, so without a real yield the
|
||||
// IntroStartedSignal publish would happen before later-registered subscribers
|
||||
// (e.g. AnalyticsTracker) have run Start() — the event bus has no replay, so they'd miss it.
|
||||
await UniTask.Yield();
|
||||
|
||||
#if UNITY_ANDROID || UNITY_IOS
|
||||
Application.targetFrameRate = 60;
|
||||
#endif
|
||||
Screen.sleepTimeout = SleepTimeout.NeverSleep;
|
||||
await _progression.LoadAsync();
|
||||
|
||||
var tcs = new UniTaskCompletionSource();
|
||||
var player = _sceneRefs.IntroVideoPlayer;
|
||||
|
||||
void OnDone(VideoPlayer vp)
|
||||
{
|
||||
vp.loopPointReached -= OnDone;
|
||||
tcs.TrySetResult();
|
||||
}
|
||||
|
||||
player.loopPointReached += OnDone;
|
||||
player.Play();
|
||||
var introTask = PlayIntroAsync(player, cancellation);
|
||||
await _sceneService.LoadSceneAsync(nameof(GameScene.MainMenu), null, cancellation);
|
||||
|
||||
await tcs.Task.AttachExternalCancellation(cancellation);
|
||||
await introTask.AttachExternalCancellation(cancellation);
|
||||
|
||||
CleanupIntro(player);
|
||||
if (_sceneRefs.IntroCanvas != null)
|
||||
UnityEngine.Object.Destroy(_sceneRefs.IntroCanvas.gameObject);
|
||||
_eventBus.Publish(new IntroCompletedSignal());
|
||||
}
|
||||
|
||||
private async UniTask PlayIntroAsync(VideoPlayer player, CancellationToken cancellation)
|
||||
{
|
||||
if (player == null)
|
||||
return;
|
||||
|
||||
var prepared = new UniTaskCompletionSource();
|
||||
var completed = new UniTaskCompletionSource();
|
||||
|
||||
void OnPrepared(VideoPlayer vp) => prepared.TrySetResult();
|
||||
|
||||
void OnDone(VideoPlayer vp) => completed.TrySetResult();
|
||||
|
||||
void OnError(VideoPlayer vp, string message)
|
||||
{
|
||||
Debug.LogWarning($"[AppBootFlow] Intro video error: {message}");
|
||||
completed.TrySetResult();
|
||||
prepared.TrySetResult();
|
||||
}
|
||||
|
||||
player.prepareCompleted += OnPrepared;
|
||||
player.loopPointReached += OnDone;
|
||||
player.errorReceived += OnError;
|
||||
|
||||
try
|
||||
{
|
||||
if (!player.isPrepared)
|
||||
{
|
||||
player.Prepare();
|
||||
await UniTask.WhenAny(
|
||||
prepared.Task,
|
||||
UniTask.Delay(IntroPrepareTimeoutMs, cancellationToken: cancellation));
|
||||
}
|
||||
|
||||
player.Play();
|
||||
_eventBus.Publish(new IntroStartedSignal());
|
||||
|
||||
await UniTask.WhenAny(
|
||||
completed.Task,
|
||||
UniTask.Delay(GetIntroFallbackTimeout(player), cancellationToken: cancellation));
|
||||
}
|
||||
finally
|
||||
{
|
||||
player.prepareCompleted -= OnPrepared;
|
||||
player.loopPointReached -= OnDone;
|
||||
player.errorReceived -= OnError;
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan GetIntroFallbackTimeout(VideoPlayer player)
|
||||
{
|
||||
var length = player != null && player.length > 0d
|
||||
? (float)player.length
|
||||
: IntroMinimumFallbackSeconds;
|
||||
|
||||
return TimeSpan.FromSeconds(Mathf.Max(IntroMinimumFallbackSeconds, length + IntroFallbackPaddingSeconds));
|
||||
}
|
||||
|
||||
private static void CleanupIntro(VideoPlayer player)
|
||||
{
|
||||
if (player == null)
|
||||
return;
|
||||
|
||||
player.Stop();
|
||||
var rt = player.targetTexture;
|
||||
if (rt != null) rt.Release();
|
||||
|
||||
if (_sceneRefs.IntroCanvas != null)
|
||||
Object.Destroy(_sceneRefs.IntroCanvas.gameObject);
|
||||
Object.Destroy(player.gameObject);
|
||||
_eventBus.Publish(new IntroCompletedSignal());
|
||||
UnityEngine.Object.Destroy(player.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Features.Artbook
|
||||
{
|
||||
public record struct ArtbookEntry(string Id, string Name, Texture2D Thumbnail, DateTime UpdatedUtc);
|
||||
public record struct ArtbookEntry(string Id, string Name, DateTime UpdatedUtc);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core;
|
||||
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
|
||||
using Darkmatter.Core.Contracts.Features.Progression;
|
||||
using Darkmatter.Core.Contracts.Features.SaveGate;
|
||||
using Darkmatter.Core.Contracts.Services.Gallery;
|
||||
using Darkmatter.Core.Data.Signals.Features.Capture;
|
||||
using Darkmatter.Core.Data.Signals.Features.Drawing;
|
||||
using Darkmatter.Libs.Observer;
|
||||
using UnityEngine;
|
||||
@@ -22,27 +24,31 @@ namespace Darkmatter.Features.Artbook
|
||||
private readonly IProgressionSystem _progression;
|
||||
private readonly IDrawingTemplateCatalog _catalog;
|
||||
private readonly IGalleryService _gallery;
|
||||
private readonly IRewardedSaveGate _saveGate;
|
||||
|
||||
private readonly List<ArtbookEntry> _entries = new();
|
||||
private readonly List<Sprite> _ownedSprites = new();
|
||||
private readonly List<Texture2D> _ownedTextures = new();
|
||||
private readonly List<Sprite> _visibleSprites = new();
|
||||
private readonly List<Texture2D> _visibleTextures = new();
|
||||
private Action _onClose;
|
||||
private CancellationTokenSource _cts;
|
||||
private IDisposable _openSubscription;
|
||||
private int _currentSpread;
|
||||
private int _renderVersion;
|
||||
|
||||
public ArtbookPresenter(
|
||||
ArtbookView view,
|
||||
IEventBus eventBus,
|
||||
IProgressionSystem progression,
|
||||
IDrawingTemplateCatalog catalog,
|
||||
IGalleryService gallery)
|
||||
IGalleryService gallery,
|
||||
IRewardedSaveGate saveGate)
|
||||
{
|
||||
_view = view;
|
||||
_eventBus = eventBus;
|
||||
_progression = progression;
|
||||
_catalog = catalog;
|
||||
_gallery = gallery;
|
||||
_saveGate = saveGate;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -71,33 +77,40 @@ namespace Darkmatter.Features.Artbook
|
||||
|
||||
private async UniTaskVoid LoadAndShowAsync(CancellationToken ct)
|
||||
{
|
||||
ClearOwnedAssets();
|
||||
_entries.Clear();
|
||||
_currentSpread = 0;
|
||||
|
||||
await _catalog.FetchAsync();
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
foreach (var id in _catalog.AllTemplateIds)
|
||||
try
|
||||
{
|
||||
ClearVisibleAssets();
|
||||
_entries.Clear();
|
||||
_currentSpread = 0;
|
||||
|
||||
await _catalog.FetchAsync();
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
var progress = _progression.GetProgress(id);
|
||||
if (progress is not { hasThumbnail: true }) continue;
|
||||
foreach (var id in _catalog.AllTemplateIds)
|
||||
{
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
var texture = await _progression.GetCachedThumbnailAsync(id);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
if (texture == null) continue;
|
||||
var progress = _progression.GetProgress(id);
|
||||
if (progress is not { hasThumbnail: true }) continue;
|
||||
|
||||
var template = await _catalog.LoadAsync(id);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
var template = await _catalog.LoadAsync(id);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
if (template == null) continue;
|
||||
|
||||
_ownedTextures.Add(texture);
|
||||
_entries.Add(new ArtbookEntry(id, template.DisplayName, texture, progress.Value.UpdatedUtc));
|
||||
_entries.Add(new ArtbookEntry(id, template.DisplayName, progress.Value.UpdatedUtc));
|
||||
}
|
||||
|
||||
_entries.Sort((a, b) => b.UpdatedUtc.CompareTo(a.UpdatedUtc));
|
||||
RenderSpread();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Opening/closing the art book can cancel the load at any await point.
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Artbook] Failed to load entries: {e}");
|
||||
}
|
||||
|
||||
_entries.Sort((a, b) => b.UpdatedUtc.CompareTo(a.UpdatedUtc));
|
||||
RenderSpread();
|
||||
}
|
||||
|
||||
private void HandlePrevSpreadClicked()
|
||||
@@ -118,7 +131,7 @@ namespace Darkmatter.Features.Artbook
|
||||
|
||||
private void RenderSpread()
|
||||
{
|
||||
_view.SetSpread(GetLeftEntry(), SpriteFor(GetLeftEntry()), GetRightEntry(), SpriteFor(GetRightEntry()));
|
||||
RenderSpreadAsync(++_renderVersion, _cts?.Token ?? CancellationToken.None).Forget();
|
||||
_view.SetNavigation(_currentSpread > 0, _currentSpread < TotalSpreads - 1);
|
||||
}
|
||||
|
||||
@@ -134,13 +147,51 @@ namespace Darkmatter.Features.Artbook
|
||||
return idx < _entries.Count ? _entries[idx] : null;
|
||||
}
|
||||
|
||||
private Sprite SpriteFor(ArtbookEntry? entry)
|
||||
private async UniTaskVoid RenderSpreadAsync(int version, CancellationToken ct)
|
||||
{
|
||||
if (!entry.HasValue) return null;
|
||||
var tex = entry.Value.Thumbnail;
|
||||
var left = GetLeftEntry();
|
||||
var right = GetRightEntry();
|
||||
ClearVisibleAssets();
|
||||
_view.SetSpread(left, null, right, null);
|
||||
|
||||
try
|
||||
{
|
||||
var leftSprite = await SpriteForAsync(left, version, ct);
|
||||
var rightSprite = await SpriteForAsync(right, version, ct);
|
||||
if (ct.IsCancellationRequested || version != _renderVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_view.SetSpread(left, leftSprite, right, rightSprite);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (version == _renderVersion) ClearVisibleAssets();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (version == _renderVersion) ClearVisibleAssets();
|
||||
Debug.LogError($"[Artbook] Failed to render spread: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<Sprite> SpriteForAsync(ArtbookEntry? entry, int version, CancellationToken ct)
|
||||
{
|
||||
if (!entry.HasValue || ct.IsCancellationRequested || version != _renderVersion) return null;
|
||||
|
||||
var tex = await _progression.GetCachedThumbnailAsync(entry.Value.Id);
|
||||
if (ct.IsCancellationRequested || version != _renderVersion)
|
||||
{
|
||||
if (tex != null) UnityEngine.Object.Destroy(tex);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tex == null) return null;
|
||||
|
||||
var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f);
|
||||
_ownedSprites.Add(sprite);
|
||||
_visibleTextures.Add(tex);
|
||||
_visibleSprites.Add(sprite);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
@@ -149,9 +200,45 @@ namespace Darkmatter.Features.Artbook
|
||||
|
||||
private async UniTaskVoid SaveToGalleryAsync(ArtbookEntry? entry)
|
||||
{
|
||||
if (!entry.HasValue || entry.Value.Thumbnail == null) return;
|
||||
var ct = _cts?.Token ?? CancellationToken.None;
|
||||
await _gallery.SaveImageAsync(entry.Value.Thumbnail, entry.Value.Name, ct);
|
||||
Texture2D texture = null;
|
||||
try
|
||||
{
|
||||
if (!entry.HasValue) return;
|
||||
var ct = _cts?.Token ?? CancellationToken.None;
|
||||
// Same kid-friendly prompt + rewarded ad as the gameplay save button.
|
||||
if (!await _saveGate.RequestSaveAsync(ct)) return;
|
||||
texture = await _progression.GetCachedThumbnailAsync(entry.Value.Id);
|
||||
if (texture == null) return;
|
||||
|
||||
// Mirror CaptureSystem's pairing: Started before the write, Completed(success) in
|
||||
// finally — art-book saves land in the same gallery_save_* analytics funnel as
|
||||
// gameplay saves.
|
||||
_eventBus.Publish(new GallerySaveStartedSignal());
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
await _gallery.SaveImageAsync(texture, entry.Value.Name, ct);
|
||||
success = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_eventBus.Publish(new GallerySaveCompletedSignal(success, entry.Value.Id));
|
||||
}
|
||||
// The art book has no success popup of its own, so use the shared toast.
|
||||
await _saveGate.ShowSavedAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The art book was closed or replaced while saving.
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Artbook] Failed to save page: {e}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (texture != null) UnityEngine.Object.Destroy(texture);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLeftEditClicked() => OpenForEdit(GetLeftEntry());
|
||||
@@ -161,27 +248,43 @@ namespace Darkmatter.Features.Artbook
|
||||
{
|
||||
if (!entry.HasValue) return;
|
||||
_eventBus.Publish(new DrawingSelectedSignal(entry.Value.Id));
|
||||
StopActiveWork();
|
||||
_view.Hide();
|
||||
}
|
||||
|
||||
private void HandleBackButtonClicked()
|
||||
{
|
||||
_onClose?.Invoke();
|
||||
StopActiveWork();
|
||||
_view.Hide();
|
||||
}
|
||||
|
||||
private void HandleColorbookButtonClicked()
|
||||
{
|
||||
_eventBus.Publish(new OpenColorBookSignal());
|
||||
StopActiveWork();
|
||||
_view.Hide();
|
||||
}
|
||||
|
||||
private void StopActiveWork()
|
||||
{
|
||||
_renderVersion++;
|
||||
_cts?.Cancel();
|
||||
_view.SetSpread(null, null, null, null);
|
||||
ClearVisibleAssets();
|
||||
}
|
||||
|
||||
private void ClearOwnedAssets()
|
||||
{
|
||||
foreach (var s in _ownedSprites) UnityEngine.Object.Destroy(s);
|
||||
foreach (var t in _ownedTextures) UnityEngine.Object.Destroy(t);
|
||||
_ownedSprites.Clear();
|
||||
_ownedTextures.Clear();
|
||||
ClearVisibleAssets();
|
||||
}
|
||||
|
||||
private void ClearVisibleAssets()
|
||||
{
|
||||
foreach (var s in _visibleSprites) UnityEngine.Object.Destroy(s);
|
||||
foreach (var t in _visibleTextures) UnityEngine.Object.Destroy(t);
|
||||
_visibleSprites.Clear();
|
||||
_visibleTextures.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -200,4 +303,4 @@ namespace Darkmatter.Features.Artbook
|
||||
ClearOwnedAssets();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace Darkmatter.Features.Capture
|
||||
builder.RegisterInstance(new CaptureConfig(captureScale));
|
||||
builder.Register<ICaptureFeature, CaptureSystem>(Lifetime.Singleton);
|
||||
|
||||
// IRewardedSaveGate is resolved from the root scope (SaveGateModule in Boot).
|
||||
if (captureButtonView != null)
|
||||
builder.RegisterEntryPoint<CaptureButtonPresenter>().WithParameter(captureButtonView);
|
||||
|
||||
|
||||
@@ -51,17 +51,23 @@ namespace Darkmatter.Features.Capture
|
||||
// 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,
|
||||
_coloring.UseNonZoomedView,
|
||||
ct);
|
||||
if (!saveToGallery || png == null || png.Length == 0) return png;
|
||||
|
||||
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, mipChain: false);
|
||||
var success = false;
|
||||
// Started must pair 1:1 with the Completed publish in finally — a failed LoadImage or a
|
||||
// throw in BuildFileNameAsync would otherwise emit Completed(false) with no Started.
|
||||
_bus.Publish(new GallerySaveStartedSignal());
|
||||
try
|
||||
{
|
||||
if (tex.LoadImage(png))
|
||||
{
|
||||
var fileName = await BuildFileNameAsync();
|
||||
_bus.Publish(new GallerySaveStartedSignal());
|
||||
await _galleryService.SaveImageAsync(tex, fileName, ct);
|
||||
success = true;
|
||||
}
|
||||
@@ -69,7 +75,7 @@ namespace Darkmatter.Features.Capture
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.Destroy(tex);
|
||||
_bus.Publish(new GallerySaveCompletedSignal(success));
|
||||
_bus.Publish(new GallerySaveCompletedSignal(success, _progression.LastOpenedTemplateId));
|
||||
}
|
||||
return png;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Features.Capture;
|
||||
using Darkmatter.Core.Contracts.Features.SaveGate;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Features.Capture.UI
|
||||
@@ -9,15 +11,20 @@ namespace Darkmatter.Features.Capture.UI
|
||||
{
|
||||
private readonly CaptureButtonView _view;
|
||||
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;
|
||||
_capture = capture;
|
||||
_saveGate = saveGate;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_view.OnCaptureClicked += HandleCaptureClicked;
|
||||
}
|
||||
|
||||
@@ -25,11 +32,16 @@ namespace Darkmatter.Features.Capture.UI
|
||||
|
||||
private async UniTaskVoid CaptureAsync()
|
||||
{
|
||||
var ct = _cts?.Token ?? CancellationToken.None;
|
||||
_view.SetInteractable(false);
|
||||
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
|
||||
{
|
||||
_view.SetInteractable(true);
|
||||
@@ -39,6 +51,9 @@ namespace Darkmatter.Features.Capture.UI
|
||||
public void Dispose()
|
||||
{
|
||||
_view.OnCaptureClicked -= HandleCaptureClicked;
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
private IDisposable _selectedSub;
|
||||
private IDisposable _returnToMainMenuSubscription;
|
||||
private bool _navigatingToGameplay;
|
||||
// App-session counter, NOT instance state. ColorbookFlowController is scene-scoped: a fresh one
|
||||
// is built every time the Colorbook scene loads, and selecting a drawing unloads the scene — so
|
||||
// each visit makes exactly ONE selection. An instance counter would reset to 0 each visit, land
|
||||
// on 1 (odd) every time, and the "% 2 == 0" show branch would NEVER fire. static persists across
|
||||
// visits for the whole app run (resets on restart), matching the per-session interstitial cap.
|
||||
private static int _selectCount;
|
||||
private CancellationTokenSource _scopeCts;
|
||||
|
||||
public ColorbookFlowController(
|
||||
@@ -62,17 +68,29 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
// 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();
|
||||
// Catalog genuinely on screen — not a transit hop toward gameplay (edit/next relays land
|
||||
// in this scene with _navigatingToGameplay already latched and never reveal the catalog).
|
||||
_bus.Publish(new ColorbookShownSignal());
|
||||
}
|
||||
|
||||
PrewarmInterstitialAdAsync(ct).Forget();
|
||||
PrewarmInterstitialAdAsync().Forget();
|
||||
}
|
||||
|
||||
private async UniTaskVoid PrewarmInterstitialAdAsync(CancellationToken ct)
|
||||
// Ad ops MUST NOT use the Colorbook scope token. This scene unloads inside HandleSelectionAsync —
|
||||
// the same moment the interstitial should show — which disposes ColorBookLifetimeScope, calls our
|
||||
// Dispose(), and cancels _scopeCts. A scope-bound load/show then aborts mid-flight (load: never
|
||||
// ready; show: await throws OCE, handlers unsubscribed). AdMobAdService is the Boot/Root-scoped,
|
||||
// app-lifetime singleton and self-manages teardown (_lifetimeCts + OnDestroy), so drive ad ops on
|
||||
// CancellationToken.None — they intentionally outlive this scene.
|
||||
private async UniTaskVoid PrewarmInterstitialAdAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_ads.IsInitialized) await _ads.InitializeAsync(ct);
|
||||
if (!_ads.IsReady(AdFormat.Interstitial)) await _ads.LoadAsync(AdFormat.Interstitial, ct);
|
||||
if (!_ads.IsInitialized) await _ads.InitializeAsync(CancellationToken.None);
|
||||
if (!_ads.IsReady(AdFormat.Interstitial)) await _ads.LoadAsync(AdFormat.Interstitial, CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
@@ -105,14 +123,18 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
|
||||
private async UniTaskVoid HandleSelectionAsync(string templateId)
|
||||
{
|
||||
var ct = _scopeCts?.Token ?? CancellationToken.None;
|
||||
|
||||
_loadingScreen.Show();
|
||||
_loadingScreen.SetProgress(0f);
|
||||
|
||||
// Fire the interstitial but never await it: the ad overlays the transition while the level
|
||||
// loads underneath, so a missed/dropped ad callback can't stall the flow at 0% anymore.
|
||||
ShowInterstitialAdAsync(ct).Forget();
|
||||
// Frequency cap: show an interstitial on every 2nd level open (2nd, 4th, ...). On skip turns
|
||||
// just keep one prewarmed so the next show has an ad ready. Fire-and-forget either way — the
|
||||
// ad overlays the transition while the level loads underneath, so a missed/dropped ad callback
|
||||
// can't stall the flow at 0% anymore. Ad ops run on CancellationToken.None, NOT the scene
|
||||
// scope token: this scene unloads further down, which would otherwise cancel the show.
|
||||
if (++_selectCount % 2 == 0)
|
||||
ShowInterstitialAdAsync().Forget();
|
||||
else
|
||||
PrewarmInterstitialAdAsync().Forget();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -137,7 +159,8 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
// Fire-and-forget interstitial. Shows only if one is already prewarmed; otherwise it kicks a
|
||||
// load for next time and returns immediately. Never blocks the level load — by design the
|
||||
// scene swap below does not depend on the ad's close callback, so the ad can never stall it.
|
||||
private async UniTaskVoid ShowInterstitialAdAsync(CancellationToken ct)
|
||||
// CancellationToken.None: the show must survive this scene's unload (see PrewarmInterstitialAdAsync).
|
||||
private async UniTaskVoid ShowInterstitialAdAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -145,11 +168,11 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
|
||||
if (!_ads.IsReady(AdFormat.Interstitial))
|
||||
{
|
||||
_ads.LoadAsync(AdFormat.Interstitial, ct).Forget();
|
||||
_ads.LoadAsync(AdFormat.Interstitial, CancellationToken.None).Forget();
|
||||
return;
|
||||
}
|
||||
|
||||
await _ads.ShowAsync(AdFormat.Interstitial, ct);
|
||||
await _ads.ShowAsync(AdFormat.Interstitial, CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"GUID:b4c9f7fbf1e144933a1797dc208ece5f",
|
||||
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
||||
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||
"GUID:80ecb87cae9c44d19824e70ea7229748"
|
||||
"GUID:80ecb87cae9c44d19824e70ea7229748",
|
||||
"GUID:75469ad4d38634e559750d17036d5f7c"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
@@ -19,4 +20,4 @@
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace Darkmatter.Features.Coloring
|
||||
public class ColoringFeatureModule : MonoBehaviour, IModule
|
||||
{
|
||||
[SerializeField] private ColorPaletteHolderView paletteHolderView;
|
||||
[SerializeField] private ColoringPinchZoomController pinchZoomController;
|
||||
|
||||
public void Register(IContainerBuilder builder)
|
||||
{
|
||||
@@ -20,6 +21,7 @@ namespace Darkmatter.Features.Coloring
|
||||
builder.RegisterEntryPoint<ColorPaletteHolderPresenter>().WithParameter(paletteHolderView);
|
||||
}
|
||||
|
||||
builder.RegisterComponent(pinchZoomController);
|
||||
builder.Register<IColorButtonFactory, ColorButtonFactory>(Lifetime.Singleton);
|
||||
builder.Register<ColoringStateRepository>(Lifetime.Singleton);
|
||||
builder.Register<IColoringController, ColoringController>(Lifetime.Singleton);
|
||||
|
||||
@@ -7,8 +7,11 @@ using Darkmatter.Core.Contracts.Features.DrawingCatalog;
|
||||
using Darkmatter.Core.Contracts.Features.GameplayFlow;
|
||||
using Darkmatter.Core.Contracts.Features.History;
|
||||
using Darkmatter.Core.Contracts.Services.Assets;
|
||||
using Darkmatter.Core.Contracts.Services.Audio;
|
||||
using Darkmatter.Core.Data.Dynamic.Services.Audio;
|
||||
using Darkmatter.Core.Data.Signals.Features.Coloring;
|
||||
using Darkmatter.Core.Data.Static.Features.Coloring;
|
||||
using Darkmatter.Core.Enums.Services.Audio;
|
||||
using Darkmatter.Features.Coloring.Commands;
|
||||
using Darkmatter.Features.Coloring.UI;
|
||||
using Darkmatter.Libs.Observer;
|
||||
@@ -28,11 +31,15 @@ public class ColoringController : IColoringController, IDisposable
|
||||
private readonly IUndoStack _history;
|
||||
private readonly IGameplaySceneRefs _refs;
|
||||
private readonly ColorPaletteHolderView _paletteHolder;
|
||||
private readonly IAudioService _audioService;
|
||||
|
||||
private GameObject _colorInstance;
|
||||
private GameObject _colorButtonPrefab;
|
||||
private GameObject _completionAnimationInstance;
|
||||
private CompletionAnimationView _completionAnimationView;
|
||||
private AudioClip _completionSound;
|
||||
private AudioHandle _completionSoundHandle = AudioHandle.Invalid;
|
||||
private readonly ColoringPinchZoomController _pinchZoom;
|
||||
private bool _isPlayingCompletionAnimation;
|
||||
private readonly List<ColorRegionView> _regions = new();
|
||||
private readonly List<ColorButton> _buttons = new();
|
||||
@@ -41,26 +48,30 @@ public class ColoringController : IColoringController, IDisposable
|
||||
public ColoringController(
|
||||
ColoringStateRepository repository,
|
||||
IColorButtonFactory buttonFactory,
|
||||
ColoringPinchZoomController pinchZoom,
|
||||
IEventBus bus,
|
||||
IAssetProviderService assetProviderService,
|
||||
IUndoStack history,
|
||||
IGameplaySceneRefs refs,
|
||||
ColorPaletteHolderView paletteHolder)
|
||||
ColorPaletteHolderView paletteHolder,
|
||||
IAudioService audioService)
|
||||
{
|
||||
_repository = repository;
|
||||
_buttonFactory = buttonFactory;
|
||||
_bus = bus;
|
||||
_assetProviderService = assetProviderService;
|
||||
_history = history;
|
||||
_pinchZoom = pinchZoom;
|
||||
_refs = refs;
|
||||
_paletteHolder = paletteHolder;
|
||||
_audioService = audioService;
|
||||
}
|
||||
|
||||
public async UniTask InitializeRegionsAsync(IDrawingTemplate template,
|
||||
IReadOnlyDictionary<string, Color> savedColors, CancellationToken ct)
|
||||
{
|
||||
Clear();
|
||||
_paletteHolder.Show();
|
||||
_ = _paletteHolder.Show();
|
||||
await TryLoadColorButtonPrefabAsync(ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await TryLoadColorPaletteAsync(template, ct);
|
||||
@@ -79,6 +90,21 @@ public class ColoringController : IColoringController, IDisposable
|
||||
if (from != color)
|
||||
_history.Push(new ColorRegionCommand(region, from, 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()
|
||||
@@ -95,8 +121,11 @@ public class ColoringController : IColoringController, IDisposable
|
||||
{
|
||||
if (_completionAnimationInstance == null || _completionAnimationView == null) return;
|
||||
if (_colorInstance != null) _colorInstance.SetActive(false);
|
||||
if (_pinchZoom != null)
|
||||
_pinchZoom.ResetZoom();
|
||||
_completionAnimationInstance.SetActive(true);
|
||||
_isPlayingCompletionAnimation = true;
|
||||
PlayCompletionSound();
|
||||
try
|
||||
{
|
||||
await _completionAnimationView.PlayAsync(ct);
|
||||
@@ -109,6 +138,23 @@ public class ColoringController : IColoringController, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Starts the template's completion sound together with the completion animation.
|
||||
// A OneShot is left to finish naturally on success; StopCompletionSound() (called from
|
||||
// Clear) cuts it off if the controller is torn down mid-play.
|
||||
private void PlayCompletionSound()
|
||||
{
|
||||
if (_audioService == null || _completionSound == null) return;
|
||||
_completionSoundHandle = _audioService.Play(
|
||||
new AudioRequest(_completionSound, AudioChannel.Sfx, AudioPlayMode.OneShot));
|
||||
}
|
||||
|
||||
private void StopCompletionSound()
|
||||
{
|
||||
if (_audioService != null && _completionSoundHandle.IsValid)
|
||||
_audioService.Stop(_completionSoundHandle);
|
||||
_completionSoundHandle = AudioHandle.Invalid;
|
||||
}
|
||||
|
||||
public bool HasNonAuthoredColors
|
||||
{
|
||||
get
|
||||
@@ -123,6 +169,14 @@ public class ColoringController : IColoringController, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposable UseNonZoomedView()
|
||||
{
|
||||
if (_pinchZoom == null)
|
||||
return null;
|
||||
|
||||
return _pinchZoom.UseNonZoomedView();
|
||||
}
|
||||
|
||||
public void ResetAll()
|
||||
{
|
||||
if (_regions.Count == 0) return;
|
||||
@@ -139,6 +193,7 @@ public class ColoringController : IColoringController, IDisposable
|
||||
{
|
||||
_history.Drop();
|
||||
_isPlayingCompletionAnimation = false;
|
||||
StopCompletionSound();
|
||||
|
||||
foreach (var button in _buttons)
|
||||
if (button != null)
|
||||
@@ -154,12 +209,16 @@ public class ColoringController : IColoringController, IDisposable
|
||||
_completionAnimationInstance = null;
|
||||
_completionAnimationView = null;
|
||||
}
|
||||
_completionSound = null;
|
||||
|
||||
if (_colorInstance != null)
|
||||
{
|
||||
UnityEngine.Object.Destroy(_colorInstance);
|
||||
_colorInstance = null;
|
||||
}
|
||||
|
||||
if (_pinchZoom != null)
|
||||
_pinchZoom.SetTarget(null);
|
||||
}
|
||||
|
||||
public void Dispose() => Clear();
|
||||
@@ -167,12 +226,14 @@ public class ColoringController : IColoringController, IDisposable
|
||||
private void InitializeColorRegions(IDrawingTemplate template, IReadOnlyDictionary<string, Color> savedColors)
|
||||
{
|
||||
_colorInstance = UnityEngine.Object.Instantiate(template.ColoringPrefab, _refs.PaperRoot);
|
||||
InitializePinchZoom(_refs.PaperRoot);
|
||||
if (template.CompletionAnimationPrefab != null)
|
||||
{
|
||||
_completionAnimationInstance = UnityEngine.Object.Instantiate(template.CompletionAnimationPrefab, _refs.PaperRoot);
|
||||
_completionAnimationView = _completionAnimationInstance.GetComponentInChildren<CompletionAnimationView>(includeInactive: true);
|
||||
_completionAnimationInstance.SetActive(false);
|
||||
}
|
||||
_completionSound = template.CompletionSound;
|
||||
var views = _colorInstance.GetComponentsInChildren<ColorRegionView>(includeInactive: true);
|
||||
|
||||
foreach (var region in views)
|
||||
@@ -192,6 +253,17 @@ public class ColoringController : IColoringController, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializePinchZoom(RectTransform target)
|
||||
{
|
||||
if (target == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pinchZoom != null)
|
||||
_pinchZoom.SetTarget(target);
|
||||
}
|
||||
|
||||
private async UniTask TryLoadColorButtonPrefabAsync(CancellationToken ct)
|
||||
{
|
||||
if (_colorButtonPrefab != null) return;
|
||||
@@ -216,4 +288,4 @@ public class ColoringController : IColoringController, IDisposable
|
||||
foreach (var color in _repository.SelectedPalette.Colors)
|
||||
_buttons.Add(_buttonFactory.Create(_colorButtonPrefab, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
namespace Darkmatter.Features.Coloring.Systems
|
||||
{
|
||||
|
||||
public sealed class ColoringPinchZoomController : MonoBehaviour
|
||||
{
|
||||
private const float MinimumPinchDistance = 8f;
|
||||
|
||||
[SerializeField] private float minZoom = 1f;
|
||||
[SerializeField] private float maxZoom = 3f;
|
||||
[SerializeField] private float scrollZoomSensitivity = 0.0015f;
|
||||
|
||||
private RectTransform _target;
|
||||
private RectTransform _parent;
|
||||
private Canvas _canvas;
|
||||
private Camera _eventCamera;
|
||||
|
||||
private bool _isPinching;
|
||||
private float _currentZoom = 1f;
|
||||
private Vector3 _baseScale = Vector3.one;
|
||||
private Vector2 _baseAnchoredPosition;
|
||||
|
||||
public void SetTarget(RectTransform target)
|
||||
{
|
||||
ResetZoom();
|
||||
_target = target;
|
||||
_parent = target != null ? target.parent as RectTransform : null;
|
||||
_canvas = target != null ? target.GetComponentInParent<Canvas>() : null;
|
||||
_eventCamera = ResolveEventCamera(_canvas);
|
||||
_baseScale = target != null ? target.localScale : Vector3.one;
|
||||
_baseAnchoredPosition = target != null ? target.anchoredPosition : Vector2.zero;
|
||||
_currentZoom = 1f;
|
||||
enabled = _target != null && _parent != null;
|
||||
}
|
||||
|
||||
public void ResetZoom()
|
||||
{
|
||||
if (_target != null)
|
||||
{
|
||||
_target.localScale = _baseScale;
|
||||
_target.anchoredPosition = _baseAnchoredPosition;
|
||||
}
|
||||
|
||||
_currentZoom = 1f;
|
||||
_isPinching = false;
|
||||
}
|
||||
|
||||
public IDisposable UseNonZoomedView()
|
||||
{
|
||||
if (_target == null)
|
||||
return null;
|
||||
|
||||
var state = new ViewState(_target.localScale, _target.anchoredPosition, _currentZoom);
|
||||
ResetZoom();
|
||||
return new NonZoomedViewScope(this, state);
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_target == null || _parent == null)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryApplyPinchZoom())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isPinching = false;
|
||||
TryApplyScrollZoom();
|
||||
}
|
||||
|
||||
private bool TryApplyPinchZoom()
|
||||
{
|
||||
if (!TryGetTouchSamples(out TouchSample firstTouch, out TouchSample secondTouch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float distance = Vector2.Distance(firstTouch.Position, secondTouch.Position);
|
||||
if (distance < MinimumPinchDistance)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Vector2 midpoint = (firstTouch.Position + secondTouch.Position) * 0.5f;
|
||||
if (!TryGetParentLocalPoint(midpoint, out Vector2 midpointLocal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_isPinching)
|
||||
{
|
||||
_isPinching = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
float previousDistance = Vector2.Distance(firstTouch.PreviousPosition, secondTouch.PreviousPosition);
|
||||
if (previousDistance < MinimumPinchDistance)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float targetZoom = Mathf.Clamp(_currentZoom * (distance / previousDistance), minZoom, maxZoom);
|
||||
ZoomTowards(midpointLocal, targetZoom);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void TryApplyScrollZoom()
|
||||
{
|
||||
Mouse mouse = Mouse.current;
|
||||
if (mouse == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float scroll = mouse.scroll.ReadValue().y;
|
||||
if (Mathf.Approximately(scroll, 0f))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 screenPosition = mouse.position.ReadValue();
|
||||
if (!IsInputPointAllowed(screenPosition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetParentLocalPoint(screenPosition, out Vector2 localPoint))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float targetZoom = Mathf.Clamp(_currentZoom * (1f + scroll * scrollZoomSensitivity), minZoom, maxZoom);
|
||||
ZoomTowards(localPoint, targetZoom);
|
||||
}
|
||||
|
||||
private void ZoomTowards(Vector2 parentLocalPoint, float targetZoom)
|
||||
{
|
||||
if (Mathf.Approximately(targetZoom, _currentZoom))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 contentPoint = (parentLocalPoint - _target.anchoredPosition) / _currentZoom;
|
||||
_currentZoom = targetZoom;
|
||||
_target.localScale = _baseScale * _currentZoom;
|
||||
_target.anchoredPosition = Mathf.Approximately(_currentZoom, minZoom)
|
||||
? _baseAnchoredPosition
|
||||
: parentLocalPoint - contentPoint * _currentZoom;
|
||||
}
|
||||
|
||||
private bool TryGetParentLocalPoint(Vector2 screenPoint, out Vector2 localPoint)
|
||||
{
|
||||
return RectTransformUtility.ScreenPointToLocalPointInRectangle(_parent, screenPoint, _eventCamera,
|
||||
out localPoint);
|
||||
}
|
||||
|
||||
private bool TryGetTouchSamples(out TouchSample firstTouch, out TouchSample secondTouch)
|
||||
{
|
||||
firstTouch = default;
|
||||
secondTouch = default;
|
||||
|
||||
Touchscreen touchscreen = Touchscreen.current;
|
||||
if (touchscreen == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
foreach (var touchControl in touchscreen.touches)
|
||||
{
|
||||
if (!touchControl.press.isPressed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Vector2 position = touchControl.position.ReadValue();
|
||||
if (!IsInputPointAllowed(position))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sample = new TouchSample(position, touchControl.delta.ReadValue());
|
||||
if (count == 0)
|
||||
{
|
||||
firstTouch = sample;
|
||||
}
|
||||
else
|
||||
{
|
||||
secondTouch = sample;
|
||||
return true;
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsInputPointAllowed(Vector2 screenPoint)
|
||||
{
|
||||
return _target != null &&
|
||||
RectTransformUtility.RectangleContainsScreenPoint(_target, screenPoint, _eventCamera);
|
||||
}
|
||||
|
||||
private static Camera ResolveEventCamera(Canvas canvas)
|
||||
{
|
||||
if (canvas == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return canvas.worldCamera;
|
||||
}
|
||||
|
||||
private void RestoreView(ViewState state)
|
||||
{
|
||||
if (_target == null)
|
||||
return;
|
||||
|
||||
_target.localScale = state.Scale;
|
||||
_target.anchoredPosition = state.AnchoredPosition;
|
||||
_currentZoom = state.CurrentZoom;
|
||||
_isPinching = false;
|
||||
}
|
||||
|
||||
private readonly struct ViewState
|
||||
{
|
||||
public readonly Vector3 Scale;
|
||||
public readonly Vector2 AnchoredPosition;
|
||||
public readonly float CurrentZoom;
|
||||
|
||||
public ViewState(Vector3 scale, Vector2 anchoredPosition, float currentZoom)
|
||||
{
|
||||
Scale = scale;
|
||||
AnchoredPosition = anchoredPosition;
|
||||
CurrentZoom = currentZoom;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NonZoomedViewScope : IDisposable
|
||||
{
|
||||
private ColoringPinchZoomController _controller;
|
||||
private readonly ViewState _state;
|
||||
|
||||
public NonZoomedViewScope(ColoringPinchZoomController controller, ViewState state)
|
||||
{
|
||||
_controller = controller;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_controller == null)
|
||||
return;
|
||||
|
||||
if (_controller != null)
|
||||
_controller.RestoreView(_state);
|
||||
|
||||
_controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct TouchSample
|
||||
{
|
||||
public readonly Vector2 Position;
|
||||
public readonly Vector2 PreviousPosition;
|
||||
public readonly Vector2 Delta;
|
||||
|
||||
public TouchSample(Vector2 position, Vector2 delta)
|
||||
{
|
||||
Position = position;
|
||||
PreviousPosition = position - delta;
|
||||
Delta = delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ddf9e0ad8b4b4ee59470410f7cf40689
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -25,10 +25,16 @@ namespace Darkmatter.Features.Coloring.UI
|
||||
rt.localScale = startScale;
|
||||
rt.localRotation = Quaternion.identity;
|
||||
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,
|
||||
cycles: wiggleCycles * 2, cycleMode: CycleMode.Yoyo)
|
||||
.ToUniTask(cancellationToken: ct);
|
||||
if (rt == null) return;
|
||||
rt.localRotation = Quaternion.identity;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ public sealed class DrawingCatalogController : IDrawingCatalogController
|
||||
private void Refresh()
|
||||
{
|
||||
_visible.Clear();
|
||||
// Catalog already orders ids by difficulty (easiest first), then id.
|
||||
_visible.AddRange(_catalog.AllTemplateIds);
|
||||
_visible.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
ListChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
{
|
||||
Id = id;
|
||||
thumbnail.sprite = thumbnailSprite;
|
||||
button.onClick.RemoveAllListeners();
|
||||
button.onClick.AddListener(onClick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
|
||||
private int _currentPage = 0;
|
||||
private int _totalPages = 1;
|
||||
private const int RowCount = 2;
|
||||
private const int ItemPerPage = 8; //2 rows x 4 columns
|
||||
|
||||
private readonly DrawingCatalogView _view;
|
||||
@@ -48,8 +49,14 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
}
|
||||
|
||||
private void HandleOpenColorBookSignal(OpenColorBookSignal signal)
|
||||
{
|
||||
ShowCatalog();
|
||||
}
|
||||
|
||||
private void ShowCatalog()
|
||||
{
|
||||
_view.Show();
|
||||
_eventBus.Publish(new ColorbookShownSignal());
|
||||
}
|
||||
|
||||
private void OnRightPageBtnClicked()
|
||||
@@ -84,7 +91,7 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
|
||||
private void OnArtBookBtnClicked()
|
||||
{
|
||||
_eventBus.Publish(new OpenArtBookSignal(()=> _view.Show()));
|
||||
_eventBus.Publish(new OpenArtBookSignal(ShowCatalog));
|
||||
_view.Hide();
|
||||
}
|
||||
|
||||
@@ -102,7 +109,7 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
|
||||
private async UniTask RefreshAsync(CancellationToken ct)
|
||||
{
|
||||
var ids = _controller.VisibleIds;
|
||||
var ids = ToFixedRowGridOrder(_controller.VisibleIds);
|
||||
var vms = new List<CatalogItemVM>(ids.Count);
|
||||
|
||||
_totalPages = (int)Math.Ceiling(ids.Count / (float)ItemPerPage);
|
||||
@@ -127,6 +134,19 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
_eventBus.Publish(new DrawingCatalogReadySignal());
|
||||
}
|
||||
|
||||
private static List<string> ToFixedRowGridOrder(IReadOnlyList<string> ids)
|
||||
{
|
||||
var ordered = new List<string>(ids.Count);
|
||||
|
||||
for (var row = 0; row < RowCount; row++)
|
||||
{
|
||||
for (var index = row; index < ids.Count; index += RowCount)
|
||||
ordered.Add(ids[index]);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_view.OnItemClicked -= OnItemClicked;
|
||||
@@ -142,4 +162,4 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,6 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
while (_buttons.Count < items.Count)
|
||||
{
|
||||
var button = Instantiate(buttonPrefab, content);
|
||||
var item = items[_buttons.Count];
|
||||
button.Initialize(item.Id, item.Thumbnail,
|
||||
() => { OnItemClicked?.Invoke(item.Id); });
|
||||
_buttons.Add(button);
|
||||
}
|
||||
|
||||
@@ -75,6 +72,13 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
Destroy(last.gameObject);
|
||||
}
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
_buttons[i].Initialize(item.Id, item.Thumbnail,
|
||||
() => { OnItemClicked?.Invoke(item.Id); });
|
||||
}
|
||||
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(content);
|
||||
}
|
||||
|
||||
@@ -178,4 +182,4 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
_suppressScrollEvents = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,9 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
|
||||
_templates.Clear();
|
||||
_shapes.Clear();
|
||||
_palettes.Clear();
|
||||
_templates.AddRange(FindAllOfType<DrawingTemplateSO>());
|
||||
_templates.AddRange(FindAllOfType<DrawingTemplateSO>()
|
||||
.OrderBy(t => t.Difficulty)
|
||||
.ThenBy(t => t.Id, StringComparer.OrdinalIgnoreCase));
|
||||
_shapes.AddRange(FindAllOfType<ShapeSO>());
|
||||
_palettes.AddRange(FindAllOfType<ColorPaletteSO>());
|
||||
}
|
||||
@@ -169,7 +171,7 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
|
||||
using (new EditorGUILayout.VerticalScope())
|
||||
{
|
||||
GUILayout.Label(string.IsNullOrEmpty(t.DisplayName) ? t.name : t.DisplayName, EditorStyles.boldLabel);
|
||||
GUILayout.Label("id: " + (string.IsNullOrEmpty(t.Id) ? "<unset>" : t.Id), EditorStyles.miniLabel);
|
||||
GUILayout.Label($"id: {(string.IsNullOrEmpty(t.Id) ? "<unset>" : t.Id)} • diff: {t.Difficulty}{(t.HasDifficultyOverride ? "*" : $" ({t.Pieces.Count}p+{t.AuthoredRegions.Count}r)")}", EditorStyles.miniLabel);
|
||||
}
|
||||
if (GUILayout.Button("Select", GUILayout.Width(54)))
|
||||
Select(t);
|
||||
@@ -206,8 +208,20 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
|
||||
EditorGUILayout.LabelField("Basics", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(so.FindProperty("id"), new GUIContent("Id (Addressable Key)"));
|
||||
EditorGUILayout.PropertyField(so.FindProperty("displayName"));
|
||||
EditorGUILayout.PropertyField(so.FindProperty("difficultyOverride"),
|
||||
new GUIContent("Difficulty Override", "0 = auto (pieces + regions). Any positive value overrides."));
|
||||
EditorGUILayout.LabelField(
|
||||
new GUIContent("Difficulty (effective)", "Sorts the catalog, lowest first."),
|
||||
new GUIContent(t.HasDifficultyOverride
|
||||
? $"{t.Difficulty} (override; auto would be {t.AutoDifficulty})"
|
||||
: $"{t.Difficulty} (auto: {t.Pieces.Count} pieces + {t.AuthoredRegions.Count} regions)"));
|
||||
EditorGUILayout.PropertyField(so.FindProperty("defaultThumbnail"));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("Sound", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(so.FindProperty("completionSound"),
|
||||
new GUIContent("Completion Sound (MP3)", "Audio clip (e.g. an imported .mp3) played in sync with the completion animation."));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("Prefabs", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(so.FindProperty("drawingPrefab"), new GUIContent("Drawing Prefab (SlotMarkers)"));
|
||||
@@ -232,7 +246,7 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
|
||||
Undo.RecordObject(t, "Clear Pieces");
|
||||
var emptyPieces = new List<ShapeSO>();
|
||||
t.EditorSet(t.Id, t.DisplayName, t.DefaultThumbnail, t.DrawingPrefab, t.ColoringPrefab,
|
||||
t.CompletionAnimationPrefab, emptyPieces, t.AuthoredRegions.ToList(), t.ColorPaletteId);
|
||||
t.CompletionAnimationPrefab, t.CompletionSound, emptyPieces, t.AuthoredRegions.ToList(), t.ColorPaletteId);
|
||||
EditorUtility.SetDirty(t);
|
||||
so.Update();
|
||||
}
|
||||
@@ -264,7 +278,7 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
|
||||
{
|
||||
Undo.RecordObject(t, "Clear Regions");
|
||||
t.EditorSet(t.Id, t.DisplayName, t.DefaultThumbnail, t.DrawingPrefab, t.ColoringPrefab,
|
||||
t.CompletionAnimationPrefab, t.Pieces.ToList(), new List<RegionAuthoring>(), t.ColorPaletteId);
|
||||
t.CompletionAnimationPrefab, t.CompletionSound, t.Pieces.ToList(), new List<RegionAuthoring>(), t.ColorPaletteId);
|
||||
EditorUtility.SetDirty(t);
|
||||
so.Update();
|
||||
}
|
||||
@@ -332,7 +346,7 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
|
||||
}
|
||||
Undo.RecordObject(t, "Scan Drawing Prefab");
|
||||
t.EditorSet(t.Id, t.DisplayName, t.DefaultThumbnail, t.DrawingPrefab, t.ColoringPrefab,
|
||||
t.CompletionAnimationPrefab, pieces, t.AuthoredRegions.ToList(), t.ColorPaletteId);
|
||||
t.CompletionAnimationPrefab, t.CompletionSound, pieces, t.AuthoredRegions.ToList(), t.ColorPaletteId);
|
||||
EditorUtility.SetDirty(t);
|
||||
_lastScanReport = $"Found {markers.Length} SlotMarker(s), {pieces.Count} unique ShapeSO. {missing} marker(s) had no ShapeSO assigned.";
|
||||
Validate();
|
||||
@@ -412,7 +426,7 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
|
||||
|
||||
Undo.RecordObject(t, "Scan Coloring Prefab");
|
||||
t.EditorSet(t.Id, t.DisplayName, t.DefaultThumbnail, t.DrawingPrefab, t.ColoringPrefab,
|
||||
t.CompletionAnimationPrefab, t.Pieces.ToList(), regions, t.ColorPaletteId);
|
||||
t.CompletionAnimationPrefab, t.CompletionSound, t.Pieces.ToList(), regions, t.ColorPaletteId);
|
||||
EditorUtility.SetDirty(t);
|
||||
|
||||
var report = new System.Text.StringBuilder();
|
||||
@@ -612,7 +626,7 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
var t = CreateInstance<DrawingTemplateSO>();
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
t.EditorSet(name, name, null, null, null, null, new List<ShapeSO>(), new List<RegionAuthoring>(), "defaultPalette");
|
||||
t.EditorSet(name, name, null, null, null, null, null, new List<ShapeSO>(), new List<RegionAuthoring>(), "defaultPalette");
|
||||
AssetDatabase.CreateAsset(t, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
Refresh();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
@@ -48,7 +49,7 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
|
||||
_byId[t.Id] = t;
|
||||
}
|
||||
|
||||
_allIds.Sort();
|
||||
SortIds();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
@@ -84,12 +85,22 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
|
||||
if (!_allIds.Contains(id))
|
||||
{
|
||||
_allIds.Add(id);
|
||||
_allIds.Sort();
|
||||
SortIds();
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
// Easiest drawings first; ties broken by id so the order is stable.
|
||||
private void SortIds()
|
||||
{
|
||||
_allIds.Sort((a, b) =>
|
||||
{
|
||||
var byDifficulty = _byId[a].Difficulty.CompareTo(_byId[b].Difficulty);
|
||||
return byDifficulty != 0 ? byDifficulty : string.Compare(a, b, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
public void ReleaseAll()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core;
|
||||
using Darkmatter.Core.Contracts.Features.Capture;
|
||||
using Darkmatter.Core.Contracts.Features.Coloring;
|
||||
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
|
||||
@@ -28,6 +29,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
public sealed class GameplayFlowController : IAsyncStartable, IGameplayFlowController, IDisposable
|
||||
{
|
||||
private const int AutosaveDebounceMs = 500;
|
||||
private const float ThumbnailMinIntervalSec = 30f;
|
||||
|
||||
private readonly IProgressionSystem _progression;
|
||||
private readonly IDrawingTemplateCatalog _catalog;
|
||||
@@ -43,10 +45,13 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
private IDrawingTemplate _template;
|
||||
private string _templateId;
|
||||
private DrawingPhase _phase = DrawingPhase.ShapeBuilding;
|
||||
private bool _allContentReported;
|
||||
private float _lastThumbnailTime = float.NegativeInfinity;
|
||||
|
||||
private IDisposable _assembledSub;
|
||||
private IDisposable _colorAppliedSub;
|
||||
private IDisposable _drawingSelectedSub;
|
||||
private IDisposable _openColorbookSub;
|
||||
private CancellationTokenSource _autosaveCts;
|
||||
private CancellationTokenSource _scopeCts;
|
||||
|
||||
@@ -103,6 +108,10 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
_assembledSub = _bus.Subscribe<ShapeAssembledSignal>(OnShapeAssembled);
|
||||
_colorAppliedSub = _bus.Subscribe<ColorAppliedSignal>(OnColorApplied);
|
||||
_drawingSelectedSub = _bus.Subscribe<DrawingSelectedSignal>(OnDrawingSelected);
|
||||
// The art book's "colorbook" button publishes OpenColorBookSignal. In the Colorbook
|
||||
// scene DrawingCatalogPresenter shows the catalog; during gameplay that presenter is
|
||||
// dead, so treat the signal as "leave gameplay for the colorbook" (same as back).
|
||||
_openColorbookSub = _bus.Subscribe<OpenColorBookSignal>(OnOpenColorBook);
|
||||
|
||||
_loadingScreen.SetProgress(1f);
|
||||
if (_phase == DrawingPhase.Coloring)
|
||||
@@ -130,8 +139,10 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
|
||||
public async UniTask SaveAsync(CancellationToken ct)
|
||||
{
|
||||
await SaveCurrentAsync(ct);
|
||||
await _capture.CapturePngAsync(saveToGallery: true, ct);
|
||||
// One capture serves both the gallery write and the progress thumbnail — this used to
|
||||
// run the full capture pipeline twice back to back.
|
||||
var png = await _capture.CapturePngAsync(saveToGallery: true, ct);
|
||||
await SaveCurrentAsync(ct, captureThumbnail: false, precapturedThumbnail: png);
|
||||
}
|
||||
|
||||
public async UniTask NextAsync(CancellationToken ct)
|
||||
@@ -151,6 +162,16 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
var progressAfter = _progression.GetProgress(_templateId);
|
||||
_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);
|
||||
if (string.IsNullOrEmpty(nextId))
|
||||
{
|
||||
@@ -198,6 +219,11 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
DebouncedAutosaveAsync(_autosaveCts.Token).Forget();
|
||||
}
|
||||
|
||||
private void OnOpenColorBook(OpenColorBookSignal _)
|
||||
{
|
||||
BackAsync(CancellationToken.None).Forget();
|
||||
}
|
||||
|
||||
private void OnDrawingSelected(DrawingSelectedSignal signal)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signal.TemplateId)) return;
|
||||
@@ -221,7 +247,13 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
try
|
||||
{
|
||||
await UniTask.Delay(AutosaveDebounceMs, cancellationToken: ct);
|
||||
await SaveCurrentAsync(ct);
|
||||
// Autosaves fire after every paint pause; capturing a thumbnail each time renders the
|
||||
// scene to an RT and PNG-encodes it, which OOM-kills long sessions on low-RAM devices.
|
||||
// Colors always persist; the thumbnail refreshes at most every ThumbnailMinIntervalSec
|
||||
// here and unconditionally on the exit paths (back / next / select-away).
|
||||
bool refreshThumbnail =
|
||||
Time.realtimeSinceStartup - _lastThumbnailTime >= ThumbnailMinIntervalSec;
|
||||
await SaveCurrentAsync(ct, captureThumbnail: refreshThumbnail);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -229,7 +261,8 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask SaveCurrentAsync(CancellationToken ct)
|
||||
private async UniTask SaveCurrentAsync(CancellationToken ct, bool captureThumbnail = true,
|
||||
byte[] precapturedThumbnail = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_templateId)) return;
|
||||
|
||||
@@ -252,7 +285,10 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
FirstCompletedUtc = existing?.FirstCompletedUtc,
|
||||
};
|
||||
|
||||
var thumbnailPng = await _capture.CapturePngAsync(saveToGallery: false, ct);
|
||||
byte[] thumbnailPng = precapturedThumbnail;
|
||||
if (thumbnailPng == null && captureThumbnail)
|
||||
thumbnailPng = await _capture.CapturePngAsync(saveToGallery: false, ct);
|
||||
if (thumbnailPng != null) _lastThumbnailTime = Time.realtimeSinceStartup;
|
||||
|
||||
await _progression.SaveProgressAsync(progress, thumbnailPng);
|
||||
}
|
||||
@@ -273,6 +309,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
_assembledSub?.Dispose();
|
||||
_colorAppliedSub?.Dispose();
|
||||
_drawingSelectedSub?.Dispose();
|
||||
_openColorbookSub?.Dispose();
|
||||
_autosaveCts?.Cancel();
|
||||
_autosaveCts?.Dispose();
|
||||
_scopeCts?.Cancel();
|
||||
|
||||
8
Assets/Darkmatter/Code/Features/ImageCombiner.meta
Normal file
8
Assets/Darkmatter/Code/Features/ImageCombiner.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 000c0b9528ead0b43af7cdc19fb38f0e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 932d925aaafb08242b1fd65d3860ef40
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Features.ImageCombiner.Editor",
|
||||
"rootNamespace": "Darkmatter.Features.ImageCombiner.Editor",
|
||||
"references": [
|
||||
"GUID:bcae41f3415403d4da1f7aafb6d9a728"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cba93615156299e4482e6b41e7db0199
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,633 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Darkmatter.Features.ImageCombiner.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom inspector for <see cref="UIImageCombiner"/>. Draws the configuration (save folder,
|
||||
/// the two "after combining" checkboxes, resolution options) and performs the actual flatten:
|
||||
/// it composites every child Image into one texture that matches the children's combined
|
||||
/// bounds / position / aspect ratio, writes it as a PNG and (optionally) assigns it back.
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(UIImageCombiner))]
|
||||
public sealed class UIImageCombinerEditor : UnityEditor.Editor
|
||||
{
|
||||
private SerializedProperty _saveFolder;
|
||||
private SerializedProperty _fileName;
|
||||
private SerializedProperty _destroyChildren;
|
||||
private SerializedProperty _disableChildren;
|
||||
private SerializedProperty _recurse;
|
||||
private SerializedProperty _includeInactive;
|
||||
private SerializedProperty _pixelsPerUnit;
|
||||
private SerializedProperty _maxDimension;
|
||||
private SerializedProperty _trimTransparent;
|
||||
private SerializedProperty _alphaThreshold;
|
||||
private SerializedProperty _assignToSelf;
|
||||
|
||||
/// <summary>Relative aspect-ratio difference above which a stretched child is reported.</summary>
|
||||
private const float AspectMismatchTolerance = 0.01f;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_saveFolder = serializedObject.FindProperty("_saveFolder");
|
||||
_fileName = serializedObject.FindProperty("_fileName");
|
||||
_destroyChildren = serializedObject.FindProperty("_destroyChildrenAfterCombine");
|
||||
_disableChildren = serializedObject.FindProperty("_disableChildrenAfterCombine");
|
||||
_recurse = serializedObject.FindProperty("_recurseIntoChildren");
|
||||
_includeInactive = serializedObject.FindProperty("_includeInactiveChildren");
|
||||
_pixelsPerUnit = serializedObject.FindProperty("_pixelsPerUnit");
|
||||
_maxDimension = serializedObject.FindProperty("_maxDimension");
|
||||
_trimTransparent = serializedObject.FindProperty("_trimTransparent");
|
||||
_alphaThreshold = serializedObject.FindProperty("_alphaThreshold");
|
||||
_assignToSelf = serializedObject.FindProperty("_assignCombinedToSelf");
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
var combiner = (UIImageCombiner)target;
|
||||
|
||||
EditorGUILayout.LabelField("Output location", EditorStyles.boldLabel);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.PropertyField(_saveFolder, new GUIContent("Save Folder"));
|
||||
if (GUILayout.Button("Browse", GUILayout.Width(64)))
|
||||
BrowseForFolder(_saveFolder);
|
||||
}
|
||||
EditorGUILayout.PropertyField(_fileName, new GUIContent("File Name (opt.)"));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("After combining", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_destroyChildren, new GUIContent("Destroy Children"));
|
||||
using (new EditorGUI.DisabledScope(_destroyChildren.boolValue))
|
||||
EditorGUILayout.PropertyField(_disableChildren, new GUIContent("Disable Children"));
|
||||
if (_destroyChildren.boolValue && _disableChildren.boolValue)
|
||||
EditorGUILayout.HelpBox("Both ticked: children will be destroyed (destroy wins).", MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("What to combine", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_recurse, new GUIContent("Recurse Into Children"));
|
||||
EditorGUILayout.PropertyField(_includeInactive, new GUIContent("Include Inactive"));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("Resolution", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_pixelsPerUnit, new GUIContent("Pixels Per Unit (0 = auto)"));
|
||||
EditorGUILayout.PropertyField(_maxDimension, new GUIContent("Max Dimension"));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("Trimming", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_trimTransparent, new GUIContent("Trim Transparent"));
|
||||
using (new EditorGUI.DisabledScope(!_trimTransparent.boolValue))
|
||||
EditorGUILayout.PropertyField(_alphaThreshold, new GUIContent("Alpha Threshold"));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("Assignment", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_assignToSelf, new GUIContent("Assign Combined To Self"));
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
using (new EditorGUI.DisabledScope(Application.isPlaying))
|
||||
{
|
||||
if (GUILayout.Button("Combine Images", GUILayout.Height(32)))
|
||||
Combine(combiner);
|
||||
}
|
||||
if (Application.isPlaying)
|
||||
EditorGUILayout.HelpBox("Exit play mode to combine images.", MessageType.Warning);
|
||||
}
|
||||
|
||||
private static void BrowseForFolder(SerializedProperty folderProp)
|
||||
{
|
||||
string start = folderProp.stringValue;
|
||||
if (string.IsNullOrEmpty(start)) start = "Assets";
|
||||
string abs = EditorUtility.OpenFolderPanel("Choose save folder", start, "");
|
||||
if (string.IsNullOrEmpty(abs)) return;
|
||||
|
||||
// Convert to a project-relative "Assets/..." path when the chosen folder is inside the project.
|
||||
string dataPath = Application.dataPath.Replace('\\', '/');
|
||||
abs = abs.Replace('\\', '/');
|
||||
if (abs == dataPath)
|
||||
folderProp.stringValue = "Assets";
|
||||
else if (abs.StartsWith(dataPath + "/"))
|
||||
folderProp.stringValue = "Assets" + abs.Substring(dataPath.Length);
|
||||
else
|
||||
folderProp.stringValue = abs; // outside the project – keep the absolute path
|
||||
folderProp.serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ combine
|
||||
|
||||
private static void Combine(UIImageCombiner combiner)
|
||||
{
|
||||
var self = (RectTransform)combiner.transform;
|
||||
|
||||
var images = CollectImages(combiner, self);
|
||||
if (images.Count == 0)
|
||||
{
|
||||
EditorUtility.DisplayDialog("UI Image Combiner",
|
||||
"No child Image components found to combine.", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Combined world bounds from every child's rect corners.
|
||||
if (!TryComputeBounds(images, out Vector2 boundsMin, out Vector2 boundsMax))
|
||||
{
|
||||
EditorUtility.DisplayDialog("UI Image Combiner",
|
||||
"The combined bounds are empty (zero size).", "OK");
|
||||
return;
|
||||
}
|
||||
Vector2 boundsSize = boundsMax - boundsMin;
|
||||
|
||||
// 2) Output resolution.
|
||||
float ppu = combiner.PixelsPerUnit > 0f
|
||||
? combiner.PixelsPerUnit
|
||||
: AutoPixelsPerUnit(images);
|
||||
int width = Mathf.Max(1, Mathf.RoundToInt(boundsSize.x * ppu));
|
||||
int height = Mathf.Max(1, Mathf.RoundToInt(boundsSize.y * ppu));
|
||||
int maxDim = combiner.MaxDimension;
|
||||
int longest = Mathf.Max(width, height);
|
||||
if (longest > maxDim)
|
||||
{
|
||||
float s = maxDim / (float)longest;
|
||||
width = Mathf.Max(1, Mathf.RoundToInt(width * s));
|
||||
height = Mathf.Max(1, Mathf.RoundToInt(height * s));
|
||||
}
|
||||
|
||||
bool rotationWarned = false;
|
||||
var pixelCache = new Dictionary<Texture, (Color32[] px, int w, int h)>();
|
||||
var output = new Color[width * height]; // straight alpha, (0,0) = bottom-left
|
||||
float worldPerPxX = boundsSize.x / width;
|
||||
float worldPerPxY = boundsSize.y / height;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < images.Count; i++)
|
||||
{
|
||||
Image img = images[i];
|
||||
EditorUtility.DisplayProgressBar("UI Image Combiner",
|
||||
$"Compositing {img.name} ({i + 1}/{images.Count})", (i + 1f) / images.Count);
|
||||
|
||||
if (!TryGetAxisAlignedRect(img, out Vector2 imgMin, out Vector2 imgMax, out bool rotated))
|
||||
continue;
|
||||
if (rotated && !rotationWarned)
|
||||
{
|
||||
Debug.LogWarning("[UIImageCombiner] One or more images are rotated/skewed; " +
|
||||
"they are composited using their axis-aligned bounds and may " +
|
||||
"look distorted.", img);
|
||||
rotationWarned = true;
|
||||
}
|
||||
|
||||
Color tint = img.color;
|
||||
Sprite sprite = img.sprite;
|
||||
|
||||
// Rect the sprite is actually drawn into (accounts for Preserve Aspect).
|
||||
Vector2 drawMin = imgMin;
|
||||
Vector2 drawSize = imgMax - imgMin;
|
||||
if (sprite != null && drawSize.x > 0f && drawSize.y > 0f)
|
||||
{
|
||||
if (img.preserveAspect)
|
||||
{
|
||||
FitPreserveAspect(sprite, ref drawMin, ref drawSize);
|
||||
}
|
||||
else if (sprite.rect.height > 0f)
|
||||
{
|
||||
// Preserve Aspect is off, so the whole sprite is stretched to fill the
|
||||
// RectTransform. If their aspect ratios differ, the content (and any
|
||||
// transparent padding) gets squashed/stretched — warn and name the child.
|
||||
float spriteAspect = sprite.rect.width / sprite.rect.height;
|
||||
float rectAspect = drawSize.x / drawSize.y;
|
||||
if (spriteAspect > 0f &&
|
||||
Mathf.Abs(rectAspect - spriteAspect) > spriteAspect * AspectMismatchTolerance)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[UIImageCombiner] \"{img.name}\" will be stretched: sprite is " +
|
||||
$"{sprite.rect.width:0}x{sprite.rect.height:0} (aspect {spriteAspect:0.###}) " +
|
||||
$"but its RectTransform aspect is {rectAspect:0.###} and Preserve Aspect is " +
|
||||
"off. Enable \"Preserve Aspect\" on the Image, or match the RectTransform to " +
|
||||
"the sprite's aspect ratio, to avoid distortion.", img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A trimmed / tight-mesh sprite reports its FULL frame as sprite.rect but only
|
||||
// draws the opaque texels described by GetOuterUV. A UI Image maps that outer UV
|
||||
// onto the padding-inset sub-rect of its RectTransform and leaves the transparent
|
||||
// border empty — it does NOT stretch the artwork to fill the frame. Shrink the draw
|
||||
// rect to that same sub-rect so each child keeps its real size/position and only its
|
||||
// visible pixels are composited (the transparent background is dropped).
|
||||
if (sprite != null && drawSize.x > 0f && drawSize.y > 0f)
|
||||
InsetBySpritePadding(sprite, ref drawMin, ref drawSize);
|
||||
|
||||
if (drawSize.x <= 0f || drawSize.y <= 0f)
|
||||
continue;
|
||||
|
||||
(Color32[] px, int tw, int th) src = default;
|
||||
Rect texRect = default;
|
||||
if (sprite != null)
|
||||
{
|
||||
src = GetPixels(sprite.texture, pixelCache);
|
||||
// Use the sprite's OUTER UV rect (the full quad, transparent padding
|
||||
// included) — the same region a UI Image maps onto its RectTransform when
|
||||
// "Use Sprite Mesh" is off. sprite.textureRect is the tight, transparent-
|
||||
// trimmed rect (Sprite Mesh Type = Tight), which would map only the drawn
|
||||
// pixels across the whole rect and stretch the artwork to fill it.
|
||||
Vector4 uv = UnityEngine.Sprites.DataUtility.GetOuterUV(sprite);
|
||||
texRect = new Rect(uv.x * src.tw, uv.y * src.th,
|
||||
(uv.z - uv.x) * src.tw, (uv.w - uv.y) * src.th);
|
||||
}
|
||||
|
||||
// Pixel range this image can touch.
|
||||
int px0 = Mathf.Clamp(Mathf.FloorToInt((drawMin.x - boundsMin.x) / worldPerPxX), 0, width);
|
||||
int px1 = Mathf.Clamp(Mathf.CeilToInt((drawMin.x + drawSize.x - boundsMin.x) / worldPerPxX), 0, width);
|
||||
int py0 = Mathf.Clamp(Mathf.FloorToInt((drawMin.y - boundsMin.y) / worldPerPxY), 0, height);
|
||||
int py1 = Mathf.Clamp(Mathf.CeilToInt((drawMin.y + drawSize.y - boundsMin.y) / worldPerPxY), 0, height);
|
||||
|
||||
for (int y = py0; y < py1; y++)
|
||||
{
|
||||
float worldY = boundsMin.y + (y + 0.5f) * worldPerPxY;
|
||||
float v = (worldY - drawMin.y) / drawSize.y;
|
||||
if (v < 0f || v > 1f) continue;
|
||||
int row = y * width;
|
||||
|
||||
for (int x = px0; x < px1; x++)
|
||||
{
|
||||
float worldX = boundsMin.x + (x + 0.5f) * worldPerPxX;
|
||||
float u = (worldX - drawMin.x) / drawSize.x;
|
||||
if (u < 0f || u > 1f) continue;
|
||||
|
||||
Color srcColor;
|
||||
if (sprite != null)
|
||||
{
|
||||
float texPx = texRect.x + u * texRect.width;
|
||||
float texPy = texRect.y + v * texRect.height;
|
||||
srcColor = SampleBilinear(src.px, src.tw, src.th, texPx, texPy) * tint;
|
||||
}
|
||||
else
|
||||
{
|
||||
srcColor = tint; // Image with no sprite = solid colour quad.
|
||||
}
|
||||
|
||||
if (srcColor.a <= 0f) continue;
|
||||
output[row + x] = Over(srcColor, output[row + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
}
|
||||
|
||||
// 2b) Alpha-trim: crop away transparent margins and shrink the bounds to the real content
|
||||
// so the output PNG (and the resized parent) tightly wrap the visible pixels.
|
||||
if (combiner.TrimTransparent)
|
||||
{
|
||||
if (TryTrim(output, width, height, combiner.AlphaThreshold,
|
||||
out Color[] trimmed, out int tw, out int th,
|
||||
out int offsetX, out int offsetY))
|
||||
{
|
||||
boundsMin = new Vector2(boundsMin.x + offsetX * worldPerPxX,
|
||||
boundsMin.y + offsetY * worldPerPxY);
|
||||
boundsMax = new Vector2(boundsMin.x + tw * worldPerPxX,
|
||||
boundsMin.y + th * worldPerPxY);
|
||||
boundsSize = boundsMax - boundsMin;
|
||||
output = trimmed;
|
||||
width = tw;
|
||||
height = th;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[UIImageCombiner] The combined image is fully transparent; " +
|
||||
"nothing to trim.", combiner);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Build the PNG.
|
||||
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
|
||||
texture.SetPixels(output);
|
||||
texture.Apply();
|
||||
byte[] png = texture.EncodeToPNG();
|
||||
Object.DestroyImmediate(texture);
|
||||
|
||||
// 4) Save it.
|
||||
string relFolder = combiner.SaveFolder.Replace('\\', '/').TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(relFolder)) relFolder = "Assets";
|
||||
string fileName = SanitizeFileName(combiner.FileName);
|
||||
if (!fileName.EndsWith(".png", System.StringComparison.OrdinalIgnoreCase))
|
||||
fileName += ".png";
|
||||
|
||||
string projectRoot = Directory.GetParent(Application.dataPath).FullName;
|
||||
string absFolder = Path.IsPathRooted(relFolder)
|
||||
? relFolder
|
||||
: Path.Combine(projectRoot, relFolder);
|
||||
Directory.CreateDirectory(absFolder);
|
||||
string absFile = Path.Combine(absFolder, fileName);
|
||||
File.WriteAllBytes(absFile, png);
|
||||
|
||||
// 5) If it is inside the project, import as a Sprite and assign it back.
|
||||
string assetPath = ToAssetPath(absFile, projectRoot);
|
||||
Sprite combinedSprite = null;
|
||||
if (assetPath != null)
|
||||
{
|
||||
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
|
||||
if (AssetImporter.GetAtPath(assetPath) is TextureImporter importer)
|
||||
{
|
||||
importer.textureType = TextureImporterType.Sprite;
|
||||
importer.spriteImportMode = SpriteImportMode.Single;
|
||||
importer.alphaIsTransparency = true;
|
||||
importer.mipmapEnabled = false;
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
combinedSprite = AssetDatabase.LoadAssetAtPath<Sprite>(assetPath);
|
||||
}
|
||||
|
||||
// 6) Assign to self + resize to the combined bounds.
|
||||
Undo.RegisterFullObjectHierarchyUndo(self.gameObject, "Combine Images");
|
||||
if (combiner.AssignCombinedToSelf && combinedSprite != null)
|
||||
{
|
||||
var image = self.GetComponent<Image>();
|
||||
if (image == null) image = Undo.AddComponent<Image>(self.gameObject);
|
||||
image.sprite = combinedSprite;
|
||||
image.type = Image.Type.Simple;
|
||||
image.preserveAspect = false;
|
||||
image.color = Color.white;
|
||||
image.enabled = true;
|
||||
|
||||
Vector3 lossy = self.lossyScale;
|
||||
self.anchorMin = self.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
self.pivot = new Vector2(0.5f, 0.5f);
|
||||
self.sizeDelta = new Vector2(
|
||||
boundsSize.x / (Mathf.Approximately(lossy.x, 0f) ? 1f : lossy.x),
|
||||
boundsSize.y / (Mathf.Approximately(lossy.y, 0f) ? 1f : lossy.y));
|
||||
Vector2 center = (boundsMin + boundsMax) * 0.5f;
|
||||
self.position = new Vector3(center.x, center.y, self.position.z);
|
||||
EditorUtility.SetDirty(image);
|
||||
}
|
||||
|
||||
// 7) Destroy or disable the combined children.
|
||||
var childObjects = images.Select(im => im.gameObject).Distinct().ToList();
|
||||
if (combiner.DestroyChildrenAfterCombine)
|
||||
{
|
||||
foreach (var go in childObjects)
|
||||
if (go != null && go != self.gameObject)
|
||||
Undo.DestroyObjectImmediate(go);
|
||||
}
|
||||
else if (combiner.DisableChildrenAfterCombine)
|
||||
{
|
||||
foreach (var go in childObjects)
|
||||
if (go != null && go != self.gameObject)
|
||||
go.SetActive(false);
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(combiner);
|
||||
if (!Application.isPlaying)
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(combiner.gameObject.scene);
|
||||
|
||||
if (combinedSprite != null)
|
||||
{
|
||||
EditorGUIUtility.PingObject(combinedSprite);
|
||||
Selection.activeObject = combinedSprite;
|
||||
}
|
||||
Debug.Log($"[UIImageCombiner] Combined {images.Count} image(s) into {width}x{height} " +
|
||||
$"PNG at \"{(assetPath ?? absFile)}\".", combiner);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
private static List<Image> CollectImages(UIImageCombiner combiner, RectTransform self)
|
||||
{
|
||||
var result = new List<Image>();
|
||||
if (combiner.RecurseIntoChildren)
|
||||
{
|
||||
// GetComponentsInChildren returns parent-first, depth-first (== UI draw order).
|
||||
foreach (var img in self.GetComponentsInChildren<Image>(combiner.IncludeInactiveChildren))
|
||||
{
|
||||
if (img.gameObject == self.gameObject) continue;
|
||||
if (!img.enabled) continue;
|
||||
result.Add(img);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int c = 0; c < self.childCount; c++)
|
||||
{
|
||||
var child = self.GetChild(c);
|
||||
if (!combiner.IncludeInactiveChildren && !child.gameObject.activeInHierarchy) continue;
|
||||
var img = child.GetComponent<Image>();
|
||||
if (img != null && img.enabled) result.Add(img);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryComputeBounds(List<Image> images, out Vector2 min, out Vector2 max)
|
||||
{
|
||||
min = new Vector2(float.MaxValue, float.MaxValue);
|
||||
max = new Vector2(float.MinValue, float.MinValue);
|
||||
var corners = new Vector3[4];
|
||||
bool any = false;
|
||||
foreach (var img in images)
|
||||
{
|
||||
((RectTransform)img.transform).GetWorldCorners(corners);
|
||||
for (int k = 0; k < 4; k++)
|
||||
{
|
||||
min.x = Mathf.Min(min.x, corners[k].x);
|
||||
min.y = Mathf.Min(min.y, corners[k].y);
|
||||
max.x = Mathf.Max(max.x, corners[k].x);
|
||||
max.y = Mathf.Max(max.y, corners[k].y);
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
return any && max.x > min.x && max.y > min.y;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the tight bounding box of pixels whose alpha exceeds <paramref name="threshold"/> and
|
||||
/// returns a cropped copy plus the bottom-left offset (in pixels) of that box within the source.
|
||||
/// </summary>
|
||||
private static bool TryTrim(Color[] src, int width, int height, float threshold,
|
||||
out Color[] cropped, out int newW, out int newH, out int offsetX, out int offsetY)
|
||||
{
|
||||
int minX = width, minY = height, maxX = -1, maxY = -1;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
int row = y * width;
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
if (src[row + x].a > threshold)
|
||||
{
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxX < minX || maxY < minY)
|
||||
{
|
||||
cropped = null;
|
||||
newW = newH = offsetX = offsetY = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
newW = maxX - minX + 1;
|
||||
newH = maxY - minY + 1;
|
||||
offsetX = minX;
|
||||
offsetY = minY;
|
||||
cropped = new Color[newW * newH];
|
||||
for (int y = 0; y < newH; y++)
|
||||
{
|
||||
int srcRow = (minY + y) * width + minX;
|
||||
int dstRow = y * newW;
|
||||
for (int x = 0; x < newW; x++)
|
||||
cropped[dstRow + x] = src[srcRow + x];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Axis-aligned world rect of an image, plus whether it looked rotated/skewed.</summary>
|
||||
private static bool TryGetAxisAlignedRect(Image img, out Vector2 min, out Vector2 max, out bool rotated)
|
||||
{
|
||||
var corners = new Vector3[4]; // 0=BL 1=TL 2=TR 3=BR
|
||||
((RectTransform)img.transform).GetWorldCorners(corners);
|
||||
min = new Vector2(
|
||||
Mathf.Min(Mathf.Min(corners[0].x, corners[1].x), Mathf.Min(corners[2].x, corners[3].x)),
|
||||
Mathf.Min(Mathf.Min(corners[0].y, corners[1].y), Mathf.Min(corners[2].y, corners[3].y)));
|
||||
max = new Vector2(
|
||||
Mathf.Max(Mathf.Max(corners[0].x, corners[1].x), Mathf.Max(corners[2].x, corners[3].x)),
|
||||
Mathf.Max(Mathf.Max(corners[0].y, corners[1].y), Mathf.Max(corners[2].y, corners[3].y)));
|
||||
|
||||
// Rotated if bottom edge is not horizontal or left edge is not vertical.
|
||||
const float eps = 1e-3f;
|
||||
rotated = Mathf.Abs(corners[0].y - corners[3].y) > eps ||
|
||||
Mathf.Abs(corners[0].x - corners[1].x) > eps;
|
||||
return (max.x - min.x) > 0f && (max.y - min.y) > 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shrinks a draw rect from the sprite's full frame (<see cref="Sprite.rect"/>) to the tight
|
||||
/// sub-rect its opaque texels actually occupy. Trimmed / tight-mesh sprites report the full
|
||||
/// frame as their rect but only render the region returned by GetOuterUV; a UI Image maps that
|
||||
/// region onto the padding-inset part of the RectTransform and leaves the border transparent.
|
||||
/// Applying the same inset here keeps the artwork at its true size/position (no stretching) and
|
||||
/// means only the visible pixels — not the transparent background — get composited.
|
||||
/// </summary>
|
||||
private static void InsetBySpritePadding(Sprite sprite, ref Vector2 min, ref Vector2 size)
|
||||
{
|
||||
float rw = sprite.rect.width, rh = sprite.rect.height;
|
||||
if (rw <= 0f || rh <= 0f) return;
|
||||
// GetPadding returns the empty border (left, bottom, right, top) in pixels of sprite.rect.
|
||||
Vector4 pad = UnityEngine.Sprites.DataUtility.GetPadding(sprite);
|
||||
if (pad == Vector4.zero) return;
|
||||
min = new Vector2(min.x + (pad.x / rw) * size.x, min.y + (pad.y / rh) * size.y);
|
||||
size = new Vector2(size.x * (1f - (pad.x + pad.z) / rw),
|
||||
size.y * (1f - (pad.y + pad.w) / rh));
|
||||
}
|
||||
|
||||
private static void FitPreserveAspect(Sprite sprite, ref Vector2 min, ref Vector2 size)
|
||||
{
|
||||
float spriteAspect = sprite.rect.width / sprite.rect.height;
|
||||
float rectAspect = size.x / size.y;
|
||||
Vector2 drawn;
|
||||
if (rectAspect > spriteAspect) // rect wider than sprite -> fit height
|
||||
drawn = new Vector2(size.y * spriteAspect, size.y);
|
||||
else
|
||||
drawn = new Vector2(size.x, size.x / spriteAspect);
|
||||
min += (size - drawn) * 0.5f;
|
||||
size = drawn;
|
||||
}
|
||||
|
||||
private static float AutoPixelsPerUnit(List<Image> images)
|
||||
{
|
||||
float ppu = 0f;
|
||||
var corners = new Vector3[4];
|
||||
foreach (var img in images)
|
||||
{
|
||||
if (img.sprite == null) continue;
|
||||
((RectTransform)img.transform).GetWorldCorners(corners);
|
||||
float worldW = Vector2.Distance(corners[0], corners[3]);
|
||||
float worldH = Vector2.Distance(corners[0], corners[1]);
|
||||
if (worldW > 0f) ppu = Mathf.Max(ppu, img.sprite.rect.width / worldW);
|
||||
if (worldH > 0f) ppu = Mathf.Max(ppu, img.sprite.rect.height / worldH);
|
||||
}
|
||||
return ppu > 0f ? ppu : 100f; // fallback for all-solid-colour cases
|
||||
}
|
||||
|
||||
/// <summary>Reads a texture's pixels via a Blit (works even when the texture is not marked readable).</summary>
|
||||
private static (Color32[] px, int w, int h) GetPixels(
|
||||
Texture tex, Dictionary<Texture, (Color32[], int, int)> cache)
|
||||
{
|
||||
if (tex == null) return (new Color32[] { new Color32(255, 255, 255, 255) }, 1, 1);
|
||||
if (cache.TryGetValue(tex, out var cached)) return cached;
|
||||
|
||||
int w = tex.width, h = tex.height;
|
||||
RenderTexture rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32);
|
||||
RenderTexture prev = RenderTexture.active;
|
||||
Graphics.Blit(tex, rt);
|
||||
RenderTexture.active = rt;
|
||||
var tmp = new Texture2D(w, h, TextureFormat.RGBA32, false);
|
||||
tmp.ReadPixels(new Rect(0, 0, w, h), 0, 0);
|
||||
tmp.Apply();
|
||||
RenderTexture.active = prev;
|
||||
RenderTexture.ReleaseTemporary(rt);
|
||||
Color32[] px = tmp.GetPixels32();
|
||||
Object.DestroyImmediate(tmp);
|
||||
|
||||
var entry = (px, w, h);
|
||||
cache[tex] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static Color SampleBilinear(Color32[] px, int w, int h, float x, float y)
|
||||
{
|
||||
x = Mathf.Clamp(x - 0.5f, 0f, w - 1f);
|
||||
y = Mathf.Clamp(y - 0.5f, 0f, h - 1f);
|
||||
int x0 = (int)x, y0 = (int)y;
|
||||
int x1 = Mathf.Min(x0 + 1, w - 1);
|
||||
int y1 = Mathf.Min(y0 + 1, h - 1);
|
||||
float fx = x - x0, fy = y - y0;
|
||||
|
||||
Color c00 = px[y0 * w + x0];
|
||||
Color c10 = px[y0 * w + x1];
|
||||
Color c01 = px[y1 * w + x0];
|
||||
Color c11 = px[y1 * w + x1];
|
||||
return Color.Lerp(Color.Lerp(c00, c10, fx), Color.Lerp(c01, c11, fx), fy);
|
||||
}
|
||||
|
||||
/// <summary>Straight-alpha "source over destination".</summary>
|
||||
private static Color Over(Color src, Color dst)
|
||||
{
|
||||
float outA = src.a + dst.a * (1f - src.a);
|
||||
if (outA <= 0f) return new Color(0f, 0f, 0f, 0f);
|
||||
float inv = 1f / outA;
|
||||
return new Color(
|
||||
(src.r * src.a + dst.r * dst.a * (1f - src.a)) * inv,
|
||||
(src.g * src.a + dst.g * dst.a * (1f - src.a)) * inv,
|
||||
(src.b * src.a + dst.b * dst.a * (1f - src.a)) * inv,
|
||||
outA);
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) name = "CombinedImage";
|
||||
foreach (char c in Path.GetInvalidFileNameChars())
|
||||
name = name.Replace(c, '_');
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string ToAssetPath(string absFile, string projectRoot)
|
||||
{
|
||||
string full = Path.GetFullPath(absFile).Replace('\\', '/');
|
||||
string root = Path.GetFullPath(projectRoot).Replace('\\', '/');
|
||||
if (!full.StartsWith(root + "/")) return null;
|
||||
string rel = full.Substring(root.Length + 1);
|
||||
return rel.StartsWith("Assets/") || rel == "Assets" ? rel : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ff6d3964378b694ba9c7ba67b3af897
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Features.ImageCombiner",
|
||||
"rootNamespace": "Darkmatter.Features.ImageCombiner",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bcae41f3415403d4da1f7aafb6d9a728
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,85 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Darkmatter.Features.ImageCombiner
|
||||
{
|
||||
/// <summary>
|
||||
/// Attach to a parent UI object that holds several child <see cref="Image"/> objects
|
||||
/// (each with a transparent background). The custom inspector's "Combine Images" button
|
||||
/// flattens every child Image into a single PNG that has the exact combined bounds,
|
||||
/// position and aspect ratio of the children, saves it to the folder you specify, and
|
||||
/// assigns it back to this object as one Image.
|
||||
///
|
||||
/// All of the actual combining/saving lives in the editor-only inspector
|
||||
/// (Editor/UIImageCombinerEditor.cs); this component only stores the configuration so it
|
||||
/// still compiles into player builds.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
[AddComponentMenu("Darkmatter/UI/UI Image Combiner")]
|
||||
public sealed class UIImageCombiner : MonoBehaviour
|
||||
{
|
||||
[Header("Output location")]
|
||||
[Tooltip("Folder the combined PNG is written to. Use a project-relative path that starts " +
|
||||
"with \"Assets/\" (e.g. Assets/Darkmatter/CombinedImages) so it can be imported as a " +
|
||||
"Sprite and assigned back automatically. An absolute path outside the project just " +
|
||||
"writes the file to disk.")]
|
||||
[SerializeField] private string _saveFolder = "Assets/Darkmatter/CombinedImages";
|
||||
|
||||
[Tooltip("File name for the combined PNG (without extension). Leave empty to use this " +
|
||||
"GameObject's name.")]
|
||||
[SerializeField] private string _fileName = "";
|
||||
|
||||
[Header("After combining")]
|
||||
[Tooltip("Destroy the child GameObjects whose images were combined. Takes priority over " +
|
||||
"\"Disable Children\" when both are ticked.")]
|
||||
[SerializeField] private bool _destroyChildrenAfterCombine = false;
|
||||
|
||||
[Tooltip("Disable (SetActive false) the child GameObjects whose images were combined.")]
|
||||
[SerializeField] private bool _disableChildrenAfterCombine = false;
|
||||
|
||||
[Header("What to combine")]
|
||||
[Tooltip("Also combine Images found on nested (grand-)children, not just direct children.")]
|
||||
[SerializeField] private bool _recurseIntoChildren = true;
|
||||
|
||||
[Tooltip("Include children that are currently inactive in the hierarchy.")]
|
||||
[SerializeField] private bool _includeInactiveChildren = false;
|
||||
|
||||
[Header("Resolution")]
|
||||
[Tooltip("Pixels per world unit for the output texture. 0 = auto: matched to the highest " +
|
||||
"resolution source sprite so no detail is lost.")]
|
||||
[SerializeField] private float _pixelsPerUnit = 0f;
|
||||
|
||||
[Tooltip("Hard cap for the longest side of the output texture (keeps huge layouts from " +
|
||||
"producing enormous PNGs). Aspect ratio is preserved.")]
|
||||
[SerializeField] private int _maxDimension = 4096;
|
||||
|
||||
[Header("Trimming")]
|
||||
[Tooltip("Crop the final combined image to the tight bounding box of non-transparent pixels, " +
|
||||
"so empty/transparent margins are NOT included. The assigned Image is repositioned " +
|
||||
"and resized to match the trimmed content, keeping each child at its real size.")]
|
||||
[SerializeField] private bool _trimTransparent = true;
|
||||
|
||||
[Tooltip("Pixels with alpha at or below this value are treated as empty when trimming (0..1). " +
|
||||
"Leave at 0 to keep soft/anti-aliased edges; raise slightly to also trim faint fringes.")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] private float _alphaThreshold = 0f;
|
||||
|
||||
[Header("Assignment")]
|
||||
[Tooltip("Add/reuse an Image on THIS object, assign the combined sprite to it, and resize " +
|
||||
"this RectTransform to the combined bounds so it lands at the same place and size.")]
|
||||
[SerializeField] private bool _assignCombinedToSelf = true;
|
||||
|
||||
public string SaveFolder => _saveFolder;
|
||||
public string FileName => string.IsNullOrWhiteSpace(_fileName) ? name : _fileName.Trim();
|
||||
public bool DestroyChildrenAfterCombine => _destroyChildrenAfterCombine;
|
||||
public bool DisableChildrenAfterCombine => _disableChildrenAfterCombine;
|
||||
public bool RecurseIntoChildren => _recurseIntoChildren;
|
||||
public bool IncludeInactiveChildren => _includeInactiveChildren;
|
||||
public float PixelsPerUnit => _pixelsPerUnit;
|
||||
public int MaxDimension => Mathf.Max(1, _maxDimension);
|
||||
public bool TrimTransparent => _trimTransparent;
|
||||
public float AlphaThreshold => _alphaThreshold;
|
||||
public bool AssignCombinedToSelf => _assignCombinedToSelf;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85c9da43c395b794fb2180a6da9f6511
|
||||
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.
|
||||
7
Assets/Darkmatter/Code/Features/SaveGate/SETUP.md.meta
Normal file
7
Assets/Darkmatter/Code/Features/SaveGate/SETUP.md.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9db44e977bbde446daa19aa928ee1230
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
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));
|
||||
|
||||
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)
|
||||
if (s != null && s.Shape != null && colorByShapeId.TryGetValue(s.Shape.Id, out var c))
|
||||
s.SetColor(c);
|
||||
if (s != null)
|
||||
s.SetColor(NextDistinctColor(ref hue));
|
||||
|
||||
CreateShapePieceInstances(template, preSnappedIds, count, slots, colorByShapeId);
|
||||
CreateShapePieceInstances(template, preSnappedIds, count, slots, ref hue);
|
||||
|
||||
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>();
|
||||
foreach (var p in template.Pieces)
|
||||
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;
|
||||
hue = Mathf.Repeat(hue + GoldenHueStep, 1f);
|
||||
return Color.HSVToRGB(hue, 0.7f, 0.95f);
|
||||
}
|
||||
|
||||
private void CreateShapePieceInstances(IDrawingTemplate template, IReadOnlyCollection<string> preSnappedIds,
|
||||
int count,
|
||||
SlotMarker[] slots, Dictionary<string, Color> colorByShapeId)
|
||||
SlotMarker[] slots, ref float hue)
|
||||
{
|
||||
var preSnapCounts = new Dictionary<string, int>();
|
||||
if (preSnappedIds != null)
|
||||
@@ -152,8 +153,7 @@ namespace Darkmatter.Features.ShapeBuilder.Systems
|
||||
}
|
||||
|
||||
var piece = _factory.Create(_piecePrefab, shape, candidates, Vector2.zero);
|
||||
if (colorByShapeId != null && colorByShapeId.TryGetValue(shape.Id, out var c))
|
||||
piece.SetColor(c);
|
||||
piece.SetColor(NextDistinctColor(ref hue));
|
||||
_pieces.Add(piece);
|
||||
|
||||
if (preSnapCounts.TryGetValue(shape.Id, out var remaining) && remaining > 0)
|
||||
|
||||
@@ -97,7 +97,10 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
||||
_bus = bus;
|
||||
_undo = undo;
|
||||
_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;
|
||||
|
||||
_homeParent = RectTransform.parent;
|
||||
@@ -151,7 +154,7 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
||||
if (_locked) return;
|
||||
|
||||
var pointerLocal = ScreenToLocal(e.position) + _grabOffset;
|
||||
var hovered = FindSlotUnder(e.position);
|
||||
var hovered = FindSlotForCatch(e.position);
|
||||
bool insidePreview = hovered != null;
|
||||
|
||||
if (insidePreview && !_inPreview)
|
||||
@@ -187,7 +190,11 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
||||
|
||||
SetAlpha(_dragOrigAlpha);
|
||||
|
||||
var target = FindSlotUnder(e.position);
|
||||
// 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)
|
||||
{
|
||||
_activeSlot = target;
|
||||
@@ -200,17 +207,45 @@ 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;
|
||||
Vector2 pointerLocal = ScreenToLocal(screenPos);
|
||||
SlotMarker best = null;
|
||||
float bestSqr = float.MaxValue;
|
||||
foreach (var s in _candidateSlots)
|
||||
{
|
||||
if (s == null) continue;
|
||||
if (s.IsOccupied && s != _activeSlot) continue;
|
||||
if (RectTransformUtility.RectangleContainsScreenPoint(s.RectTransform, screenPos, _eventCam))
|
||||
return s;
|
||||
var srt = s.RectTransform;
|
||||
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)
|
||||
|
||||
@@ -11,6 +11,7 @@ using Darkmatter.Core.Data.Signals.Features.GameplayFlow; // DrawingCompletedSig
|
||||
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
|
||||
@@ -44,6 +45,8 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
private CancellationTokenSource _runCts;
|
||||
private CancellationToken _ct;
|
||||
private bool _completed;
|
||||
private bool _drawingCompleted;
|
||||
private bool _hasColored;
|
||||
private bool _suspended;
|
||||
private Action _reshow;
|
||||
private int _stepIndex;
|
||||
@@ -74,6 +77,13 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
_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);
|
||||
}
|
||||
@@ -86,6 +96,8 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
_ct = _runCts.Token;
|
||||
_gen++;
|
||||
_suspended = false;
|
||||
_drawingCompleted = false;
|
||||
_hasColored = false;
|
||||
_reshow = null;
|
||||
_stepIndex = 0;
|
||||
_overlay.HideInstant();
|
||||
@@ -108,12 +120,23 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
}
|
||||
|
||||
// The catalog re-appeared while we were mid-gameplay -> the player went Back. Restart from
|
||||
// step 1 (the catalog is already on screen, so skip the wait). Excludes step 6+, whose own
|
||||
// completion loads the catalog.
|
||||
// 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 && _stepIndex >= 2 && _stepIndex <= 5)
|
||||
StartRun(skipCatalogWait: true);
|
||||
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)
|
||||
@@ -154,7 +177,7 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
if (!await WaitForSignalAsync<DrawingCatalogReadySignal>()) return;
|
||||
}
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindFirstCatalogCell(), _config.PickText));
|
||||
ShowStep(() => ShowBlockingTap(FindFirstCatalogCell(), _config.PickText, _config.PickBubble, _config.PickBubbleOffset));
|
||||
if (!await WaitForActionAsync<DrawingSelectedSignal>()) return;
|
||||
EndStep("pick", 1);
|
||||
|
||||
@@ -165,7 +188,7 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
ShowStep(() =>
|
||||
{
|
||||
var (pieceRect, slotRect) = FindFirstPieceAndSlot();
|
||||
if (pieceRect != null) _overlay.ShowDrag(pieceRect, slotRect, _config.DragText);
|
||||
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
|
||||
@@ -173,7 +196,7 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
|
||||
// Step 3 — finish the rest of the puzzle freely (non-blocking hint).
|
||||
_stepIndex = 3;
|
||||
ShowStep(() => _overlay.ShowTap(null, _config.FinishText, blockInput: false));
|
||||
ShowStep(() => _overlay.ShowTap(null, _config.FinishText, blockInput: false, _config.FinishBubble, _config.FinishBubbleOffset));
|
||||
if (!await WaitForActionAsync<ShapeAssembledSignal>()) return;
|
||||
EndStep("finish", 3);
|
||||
|
||||
@@ -181,32 +204,55 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
_stepIndex = 4;
|
||||
if (!await WaitForSignalAsync<RegionsInitializedSignal>()) return;
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindFirstColorButton(), _config.ColorText));
|
||||
ShowStep(() => ShowBlockingTap(FindFirstColorButton(), _config.ColorText, _config.ColorBubble, _config.ColorBubbleOffset));
|
||||
if (!await WaitForActionAsync<ColorSelectedSignal>()) return;
|
||||
EndStep("color", 4);
|
||||
|
||||
// Step 5 — paint a region.
|
||||
// Step 5 — paint the first region (teaches the tap by spotlighting one region).
|
||||
_stepIndex = 5;
|
||||
ShowStep(() => ShowBlockingTap(FindLargestRegion(), _config.PaintText));
|
||||
// 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 — press Next.
|
||||
// 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;
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindNextButton(), _config.NextText));
|
||||
if (!await WaitForActionAsync<DrawingCompletedSignal>()) return;
|
||||
EndStep("next", 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 — celebrate.
|
||||
_stepIndex = 7;
|
||||
await _overlay.ShowToastAsync(_config.DoneText, _ct);
|
||||
// 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)
|
||||
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);
|
||||
_overlay.ShowTap(target, message, blockInput: target != null, placement, offset);
|
||||
}
|
||||
|
||||
private void EndStep(string id, int index)
|
||||
@@ -283,13 +329,16 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
|
||||
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;
|
||||
piece = p;
|
||||
break;
|
||||
var index = p.transform.GetSiblingIndex();
|
||||
if (index < bestIndex) { bestIndex = index; piece = p; }
|
||||
}
|
||||
if (piece == null) return (null, null);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -45,6 +46,8 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
[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;
|
||||
@@ -60,6 +63,8 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
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)
|
||||
@@ -91,11 +96,13 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
|
||||
// ── ITutorialOverlay ─────────────────────────────────────────────────
|
||||
|
||||
public void ShowTap(RectTransform target, string message, bool blockInput)
|
||||
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;
|
||||
|
||||
@@ -122,11 +129,13 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
FadeInQuick();
|
||||
}
|
||||
|
||||
public void ShowDrag(RectTransform from, RectTransform to, string message)
|
||||
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.
|
||||
@@ -153,11 +162,13 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
FadeInQuick();
|
||||
}
|
||||
|
||||
public async UniTask ShowToastAsync(string message, CancellationToken ct)
|
||||
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;
|
||||
@@ -238,8 +249,10 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
if (hand != null)
|
||||
hand.anchoredPosition = centerLocal + new Vector2(0f, -radiusLocal * 0.5f);
|
||||
|
||||
PositionBubble(centerLocal.y, centerLocal.y + radiusLocal, centerLocal.y - radiusLocal,
|
||||
_area.rect.height * 0.5f);
|
||||
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.
|
||||
@@ -283,27 +296,59 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
|
||||
// The drag path spans the play area, so pin the bubble to a screen edge instead of hugging
|
||||
// the piece — keeps it off the shapes.
|
||||
PositionBubbleAtEdge(dragBubbleAtTop, dragBubbleEdgeMargin);
|
||||
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;
|
||||
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||
bubbleRoot.anchoredPosition =
|
||||
new Vector2(0f, Mathf.Clamp(halfH * 0.45f, -halfH + bubbleHalf, halfH - bubbleHalf));
|
||||
SetBubbleAnchored(new Vector2(0f, halfH * 0.45f));
|
||||
}
|
||||
|
||||
private void PositionBubble(float holeCenterY, float holeTopY, float holeBottomY, float halfH)
|
||||
// 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;
|
||||
y = Mathf.Clamp(y, -halfH + bubbleHalf, halfH - bubbleHalf);
|
||||
bubbleRoot.anchoredPosition = new Vector2(0f, y);
|
||||
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
|
||||
@@ -314,8 +359,7 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
float halfH = _area.rect.height * 0.5f;
|
||||
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||
float y = top ? halfH - bubbleHalf - margin : -halfH + bubbleHalf + margin;
|
||||
y = Mathf.Clamp(y, -halfH + bubbleHalf, halfH - bubbleHalf);
|
||||
bubbleRoot.anchoredPosition = new Vector2(0f, y);
|
||||
SetBubbleAnchored(new Vector2(0f, y));
|
||||
}
|
||||
|
||||
// ── Coordinate conversion (camera-agnostic) ──────────────────────────
|
||||
@@ -405,6 +449,8 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
private void ApplyHiddenState()
|
||||
{
|
||||
_mode = Mode.Hidden;
|
||||
_placement = BubblePlacement.Auto;
|
||||
_bubbleOffset = Vector2.zero;
|
||||
_target = null;
|
||||
_dragFrom = null;
|
||||
_dragTo = null;
|
||||
|
||||
@@ -3,10 +3,12 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Services.Ads;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using Darkmatter.Core.Data.Dynamic.Services.Ads;
|
||||
using Darkmatter.Core.Data.Static.Services.Ads;
|
||||
using Darkmatter.Core.Enums.Services.Ads;
|
||||
using UnityEngine;
|
||||
using VContainer;
|
||||
using AdFormat = Darkmatter.Core.Enums.Services.Ads.AdFormat;
|
||||
#if GOOGLE_MOBILE_ADS
|
||||
using GoogleMobileAds.Api;
|
||||
@@ -17,21 +19,48 @@ namespace Darkmatter.Services.Ads
|
||||
public class AdMobAdService : MonoBehaviour, IAdService
|
||||
{
|
||||
[SerializeField] private AdUnitCatalogSO catalog;
|
||||
[Tooltip("Auto-reload after dismiss/failure for non-banner formats.")]
|
||||
[SerializeField] private bool autoReload = true;
|
||||
[Tooltip("Seconds between auto-reload retries on failure.")]
|
||||
[SerializeField, Min(1f)] private float reloadDelaySeconds = 5f;
|
||||
[Tooltip("Max reload attempts before giving up.")]
|
||||
[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.")]
|
||||
[SerializeField, Min(15f)] private float showWatchdogSeconds = 60f;
|
||||
|
||||
[Tooltip("Auto-reload after dismiss/failure for non-banner formats.")] [SerializeField]
|
||||
private bool autoReload = true;
|
||||
|
||||
[Tooltip("Seconds between auto-reload retries on failure.")] [SerializeField, Min(1f)]
|
||||
private float reloadDelaySeconds = 5f;
|
||||
|
||||
[Tooltip("Max reload attempts before giving up.")] [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.")]
|
||||
[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;
|
||||
|
||||
[Tooltip(
|
||||
"Enable only after verifying Firebase/AdMob is not already auto-logging ad_impression for the same ads. Leaving this off avoids duplicate ad_impression counts in GA4/Firebase.")]
|
||||
[SerializeField]
|
||||
private bool logManualAdImpressionEvents;
|
||||
|
||||
public bool IsInitialized => _initialized;
|
||||
public event Action<AdFormat, AdLoadState> LoadStateChanged;
|
||||
|
||||
private IAnalyticsService _analytics;
|
||||
|
||||
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 _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;
|
||||
|
||||
// App interruption state, fed by the Unity lifecycle messages below. A full-screen ad
|
||||
@@ -51,6 +80,12 @@ namespace Darkmatter.Services.Ads
|
||||
private BannerView _banner;
|
||||
#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()
|
||||
{
|
||||
_lifetimeCts = new CancellationTokenSource();
|
||||
@@ -148,6 +183,7 @@ namespace Darkmatter.Services.Ads
|
||||
SetState(format, AdLoadState.Failed);
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
#else
|
||||
await UniTask.CompletedTask;
|
||||
@@ -178,19 +214,31 @@ namespace Darkmatter.Services.Ads
|
||||
if (!_initialized) return AdShowResult.Failure("Not initialized.");
|
||||
|
||||
#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("[AdMobAdService] Session interstitial cap reached.");
|
||||
|
||||
if (!IsReady(format))
|
||||
{
|
||||
bool loaded = await LoadAsync(format, cancellationToken);
|
||||
if (!loaded) return AdShowResult.Failure($"{format} failed to load.");
|
||||
}
|
||||
|
||||
Debug.Log($"[AdMobAdService] Showing {format} ad.");
|
||||
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.RewardedInterstitial: return await ShowRewardedInterstitialAsync(cancellationToken);
|
||||
case AdFormat.AppOpen: return await ShowAppOpenAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return AdShowResult.Failure($"{format} not supported by ShowAsync.");
|
||||
#else
|
||||
await UniTask.CompletedTask;
|
||||
@@ -198,7 +246,8 @@ namespace Darkmatter.Services.Ads
|
||||
#endif
|
||||
}
|
||||
|
||||
public async UniTask<bool> ShowBannerAsync(BannerSize size, BannerPosition position, CancellationToken cancellationToken)
|
||||
public async UniTask<bool> ShowBannerAsync(BannerSize size, BannerPosition position,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_initialized) return false;
|
||||
|
||||
@@ -208,6 +257,8 @@ namespace Darkmatter.Services.Ads
|
||||
|
||||
_banner?.Destroy();
|
||||
_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>();
|
||||
_banner.OnBannerAdLoaded += () => tcs.TrySetResult(true);
|
||||
@@ -267,7 +318,9 @@ namespace Darkmatter.Services.Ads
|
||||
: TagForChildDirectedTreatment.Unspecified,
|
||||
TagForUnderAgeOfConsent = _hasUserConsent
|
||||
? 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 })
|
||||
@@ -278,7 +331,8 @@ namespace Darkmatter.Services.Ads
|
||||
MobileAds.SetRequestConfiguration(config);
|
||||
}
|
||||
|
||||
private async UniTask<bool> LoadInterstitialAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
|
||||
private async UniTask<bool> LoadInterstitialAsync(string unitId, AdRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<bool>();
|
||||
InterstitialAd.Load(unitId, request, (ad, error) =>
|
||||
@@ -289,6 +343,7 @@ namespace Darkmatter.Services.Ads
|
||||
tcs.TrySetResult(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_interstitial?.Destroy();
|
||||
_interstitial = ad;
|
||||
WireFullScreenEvents(ad, AdFormat.Interstitial);
|
||||
@@ -303,7 +358,8 @@ namespace Darkmatter.Services.Ads
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<bool> LoadRewardedAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
|
||||
private async UniTask<bool> LoadRewardedAsync(string unitId, AdRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<bool>();
|
||||
RewardedAd.Load(unitId, request, (ad, error) =>
|
||||
@@ -314,6 +370,7 @@ namespace Darkmatter.Services.Ads
|
||||
tcs.TrySetResult(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_rewarded?.Destroy();
|
||||
_rewarded = ad;
|
||||
WireFullScreenEvents(ad, AdFormat.Rewarded);
|
||||
@@ -328,7 +385,8 @@ namespace Darkmatter.Services.Ads
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<bool> LoadRewardedInterstitialAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
|
||||
private async UniTask<bool> LoadRewardedInterstitialAsync(string unitId, AdRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<bool>();
|
||||
RewardedInterstitialAd.Load(unitId, request, (ad, error) =>
|
||||
@@ -339,6 +397,7 @@ namespace Darkmatter.Services.Ads
|
||||
tcs.TrySetResult(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_rewardedInterstitial?.Destroy();
|
||||
_rewardedInterstitial = ad;
|
||||
WireFullScreenEvents(ad, AdFormat.RewardedInterstitial);
|
||||
@@ -353,7 +412,8 @@ namespace Darkmatter.Services.Ads
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<bool> LoadAppOpenAsync(string unitId, AdRequest request, CancellationToken cancellationToken)
|
||||
private async UniTask<bool> LoadAppOpenAsync(string unitId, AdRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<bool>();
|
||||
AppOpenAd.Load(unitId, request, (ad, error) =>
|
||||
@@ -364,6 +424,7 @@ namespace Darkmatter.Services.Ads
|
||||
tcs.TrySetResult(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_appOpen?.Destroy();
|
||||
_appOpen = ad;
|
||||
WireFullScreenEvents(ad, AdFormat.AppOpen);
|
||||
@@ -482,8 +543,16 @@ namespace Darkmatter.Services.Ads
|
||||
var tcs = new UniTaskCompletionSource<AdShowResult>();
|
||||
bool resolved = false;
|
||||
|
||||
Action onClosed = () => { resolved = true; tcs.TrySetResult(buildResult()); };
|
||||
Action<AdError> onFailed = err => { resolved = true; tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage())); };
|
||||
Action onClosed = () =>
|
||||
{
|
||||
resolved = true;
|
||||
tcs.TrySetResult(buildResult());
|
||||
};
|
||||
Action<AdError> onFailed = err =>
|
||||
{
|
||||
resolved = true;
|
||||
tcs.TrySetResult(AdShowResult.Failure(err?.GetMessage()));
|
||||
};
|
||||
|
||||
subscribe(onClosed, onFailed);
|
||||
show();
|
||||
@@ -539,34 +608,82 @@ namespace Darkmatter.Services.Ads
|
||||
{
|
||||
// App returned to foreground after the ad held it => ad was dismissed but
|
||||
// the close callback was dropped. Brief grace for the real event, then force.
|
||||
await UniTask.Delay(ForegroundGraceMs, DelayType.Realtime, PlayerLoopTiming.Update, cancellationToken);
|
||||
await UniTask.Delay(ForegroundGraceMs, DelayType.Realtime, PlayerLoopTiming.Update,
|
||||
cancellationToken);
|
||||
if (!isResolved() && tcs.TrySetResult(buildResult()))
|
||||
Debug.LogWarning("[AdMobAdService] Close callback missed; recovered via foreground watchdog.");
|
||||
Debug.LogWarning(
|
||||
"[AdMobAdService] Close callback missed; recovered via foreground watchdog.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (elapsed >= showWatchdogSeconds)
|
||||
{
|
||||
if (!isResolved() && tcs.TrySetResult(buildResult()))
|
||||
Debug.LogWarning($"[AdMobAdService] Close callback missed; recovered via {showWatchdogSeconds:0}s watchdog cap.");
|
||||
Debug.LogWarning(
|
||||
$"[AdMobAdService] Close callback missed; recovered via {showWatchdogSeconds:0}s watchdog cap.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
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.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.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.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.OnAdPaid += v => LogAdImpression(format, v);
|
||||
ad.OnAdClicked += () => LogAdClicked(format);
|
||||
}
|
||||
|
||||
// AdMob/Firebase can auto-log ad_impression for linked apps. Manual logging is opt-in so one
|
||||
// shown ad does not become two GA4 ad_impression events.
|
||||
private void LogAdImpression(AdFormat format, AdValue value)
|
||||
{
|
||||
if (!logManualAdImpressionEvents) return;
|
||||
|
||||
_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)
|
||||
{
|
||||
@@ -585,9 +702,12 @@ namespace Darkmatter.Services.Ads
|
||||
if (IsReady(format)) return;
|
||||
if (await LoadAsync(format, cancellationToken)) return;
|
||||
}
|
||||
|
||||
Debug.LogWarning($"[AdMobAdService] {format} reload gave up after {reloadMaxAttempts} attempts.");
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static AdSize MapBannerSize(BannerSize size) => size switch
|
||||
@@ -598,7 +718,8 @@ namespace Darkmatter.Services.Ads
|
||||
BannerSize.FullBanner => AdSize.IABBanner,
|
||||
BannerSize.Leaderboard => AdSize.Leaderboard,
|
||||
BannerSize.SmartBanner => AdSize.SmartBanner,
|
||||
BannerSize.AnchoredAdaptive => AdSize.GetCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(AdSize.FullWidth),
|
||||
BannerSize.AnchoredAdaptive => AdSize.GetCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(
|
||||
AdSize.FullWidth),
|
||||
_ => AdSize.Banner
|
||||
};
|
||||
|
||||
|
||||
@@ -10,8 +10,17 @@ namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
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<ErrorAnalyticsTracker>();
|
||||
|
||||
// Feeds the FCM token to AppsFlyer for uninstall measurement (Android-only).
|
||||
builder.RegisterEntryPoint<AppsFlyerUninstallTracker>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,12 @@
|
||||
"name": "Services.Analytics",
|
||||
"rootNamespace": "Darkmatter.Services.Analytics",
|
||||
"references": [
|
||||
"GUID:bd7ea2d41bfe64d229c22616f66e20f7",
|
||||
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
|
||||
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
||||
"GUID:f8c64bb88d959406689053ae3f31183d",
|
||||
"GUID:a0b1547602fc44f6da0a5e755ab3a7ef",
|
||||
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
||||
"GUID:b4c9f7fbf1e144933a1797dc208ece5f"
|
||||
"GUID:b4c9f7fbf1e144933a1797dc208ece5f",
|
||||
"GUID:2a37df438292d4903b4e5159c5de3bf9"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
@@ -20,4 +18,4 @@
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,43 @@ using Darkmatter.Core;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using Darkmatter.Core.Data.Signals.Features.AppBoot;
|
||||
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.GameplayFlow;
|
||||
using Darkmatter.Core.Data.Signals.Features.MainMenu;
|
||||
using Darkmatter.Core.Data.Signals.Features.ShapeBuilder;
|
||||
using Darkmatter.Core.Data.Signals.Features.Tutorial;
|
||||
using Darkmatter.Libs.Observer;
|
||||
using UnityEngine;
|
||||
using VContainer.Unity;
|
||||
|
||||
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
|
||||
{
|
||||
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 IAnalyticsService _analytics;
|
||||
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)
|
||||
{
|
||||
_bus = bus;
|
||||
@@ -28,40 +49,202 @@ namespace Darkmatter.Services.Analytics
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent("intro_completed")));
|
||||
_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")));
|
||||
_firstDrawingTracked = PlayerPrefs.GetInt(FirstDrawingKey, 0) == 1;
|
||||
|
||||
_subs.Add(_bus.Subscribe<DrawingSelectedSignal>(s =>
|
||||
_analytics.LogEvent("drawing_selected", "template_id", s.TemplateId)));
|
||||
// Onboarding / activation funnel
|
||||
_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 =>
|
||||
_analytics.LogEvent("shape_builder_started", "template_id", s.TemplateId)));
|
||||
// Navigation. ColorbookShownSignal (not OpenColorBookSignal, which is the show-view command
|
||||
// and misses scene-entry paths) fires on every way the catalog becomes visible.
|
||||
_subs.Add(_bus.Subscribe<ColorbookShownSignal>(_ => OnColorbookShown()));
|
||||
_subs.Add(_bus.Subscribe<OpenArtBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ArtbookOpened)));
|
||||
_subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => OnReturnToMainMenu()));
|
||||
|
||||
_subs.Add(_bus.Subscribe<ShapeAssembledSignal>(s =>
|
||||
_analytics.LogEvent("shape_assembled", "template_id", s.TemplateId)));
|
||||
// Core gameplay loop
|
||||
_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>
|
||||
{
|
||||
["template_id"] = s.TemplateId,
|
||||
["completion_count"] = s.CompletionCount,
|
||||
[AnalyticsParams.StepId] = s.StepId,
|
||||
[AnalyticsParams.StepIndex] = s.StepIndex,
|
||||
})));
|
||||
_subs.Add(_bus.Subscribe<TutorialCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.TutorialCompleted)));
|
||||
}
|
||||
|
||||
_subs.Add(_bus.Subscribe<GallerySaveStartedSignal>(_ => _analytics.LogEvent("gallery_save_started")));
|
||||
_subs.Add(_bus.Subscribe<GallerySaveCompletedSignal>(s =>
|
||||
_analytics.LogEvent("gallery_save_completed", "success", s.Success ? "true" : "false")));
|
||||
private void OnDrawingSelected(string drawingId)
|
||||
{
|
||||
// GameplayFlowController relays the selection a second time after the scene swap
|
||||
// (artbook edit path) — same id while already active is that relay, not a new selection.
|
||||
if (drawingId == _activeDrawingId) return;
|
||||
|
||||
_subs.Add(_bus.Subscribe<TutorialStartedSignal>(_ => _analytics.LogEvent("tutorial_started")));
|
||||
_subs.Add(_bus.Subscribe<TutorialStepCompletedSignal>(s => _analytics.LogEvent("tutorial_step_completed",
|
||||
new Dictionary<string, object>
|
||||
// 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>
|
||||
{
|
||||
["step_id"] = s.StepId,
|
||||
["step_index"] = s.StepIndex,
|
||||
})));
|
||||
_subs.Add(_bus.Subscribe<TutorialCompletedSignal>(_ => _analytics.LogEvent("tutorial_completed")));
|
||||
[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);
|
||||
|
||||
// Companion to GA4's level_start.
|
||||
_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, new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.CompletionCount] = s.CompletedCount,
|
||||
});
|
||||
}
|
||||
|
||||
private void OnGallerySaveCompleted(GallerySaveCompletedSignal s)
|
||||
{
|
||||
// Prefer the id carried by the signal: art-book saves happen with no active drawing.
|
||||
string drawingId = string.IsNullOrEmpty(s.TemplateId) ? _activeDrawingId ?? string.Empty : s.TemplateId;
|
||||
_analytics.LogEvent(AnalyticsEvents.GallerySaveCompleted, new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.DrawingId] = drawingId,
|
||||
[AnalyticsParams.Success] = s.Success ? 1 : 0,
|
||||
});
|
||||
_analytics.LogEvent(AnalyticsEvents.DrawingSaved, new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.DrawingId] = drawingId,
|
||||
[AnalyticsParams.Success] = s.Success ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
private void OnReturnToMainMenu()
|
||||
{
|
||||
EndActiveDrawingIfAbandoned();
|
||||
_analytics.LogEvent(AnalyticsEvents.MainMenuReturned);
|
||||
}
|
||||
|
||||
private void OnColorbookShown()
|
||||
{
|
||||
// Reaching the catalog with a drawing still open means the player backed out of gameplay
|
||||
// mid-drawing — the abandon moment. (Completion clears the active drawing before the
|
||||
// catalog reloads, so the finish path never lands here with one open.)
|
||||
EndActiveDrawingIfAbandoned();
|
||||
_analytics.LogEvent(AnalyticsEvents.ColorbookOpened);
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -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:
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// The single <see cref="IAnalyticsService"/> the app consumes. Fans every call out to all sinks
|
||||
/// (Firebase + Facebook) so call sites log once and both backends receive it. A throwing sink is
|
||||
/// isolated so it can't break the others. To add a sink: add a constructor arg and include it below.
|
||||
/// </summary>
|
||||
public sealed class CompositeAnalyticsService : IAnalyticsService
|
||||
{
|
||||
private readonly IAnalyticsService[] _sinks;
|
||||
|
||||
public CompositeAnalyticsService(FirebaseAnalyticsSystem firebase, FacebookAnalyticsSystem facebook)
|
||||
{
|
||||
_sinks = new IAnalyticsService[] { firebase, facebook };
|
||||
}
|
||||
|
||||
public void LogEvent(string name) =>
|
||||
ForEach(s => s.LogEvent(name));
|
||||
|
||||
public void LogEvent(string name, string paramName, string paramValue) =>
|
||||
ForEach(s => s.LogEvent(name, paramName, paramValue));
|
||||
|
||||
public void LogEvent(string name, IReadOnlyDictionary<string, object> parameters) =>
|
||||
ForEach(s => s.LogEvent(name, parameters));
|
||||
|
||||
public void SetUserProperty(string name, string value) =>
|
||||
ForEach(s => s.SetUserProperty(name, value));
|
||||
|
||||
private void ForEach(Action<IAnalyticsService> action)
|
||||
{
|
||||
foreach (var sink in _sinks)
|
||||
{
|
||||
try { action(sink); }
|
||||
catch (Exception e) { Debug.LogError($"[Analytics] Sink {sink.GetType().Name} failed: {e}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fbc94bef99b574c83a85b6886ff7fd03
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using UnityEngine;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// Forwards Unity error/exception logs to analytics as a sampled <c>error_shown</c> event so
|
||||
/// crash-adjacent friction surfaces in funnels. Capped per session and deduped by error_type to avoid
|
||||
/// event floods / GA4 quota burn. No message text or PII is sent — only the leading token of the
|
||||
/// condition (e.g. "NullReferenceException"). Distinct from Crashlytics (which logs full crashes).
|
||||
/// </summary>
|
||||
public sealed class ErrorAnalyticsTracker : IStartable, IDisposable
|
||||
{
|
||||
private const int MaxPerSession = 25;
|
||||
|
||||
private readonly IAnalyticsService _analytics;
|
||||
private readonly HashSet<string> _seen = new();
|
||||
private int _count;
|
||||
|
||||
public ErrorAnalyticsTracker(IAnalyticsService analytics) => _analytics = analytics;
|
||||
|
||||
public void Start() => Application.logMessageReceived += OnLog;
|
||||
public void Dispose() => Application.logMessageReceived -= OnLog;
|
||||
|
||||
private void OnLog(string condition, string stackTrace, LogType type)
|
||||
{
|
||||
if (type != LogType.Error && type != LogType.Exception) return;
|
||||
if (_count >= MaxPerSession) return;
|
||||
|
||||
string errorType = ExtractType(condition);
|
||||
if (!_seen.Add(errorType)) return; // one per distinct type per session
|
||||
|
||||
_count++;
|
||||
_analytics.LogEvent(AnalyticsEvents.ErrorShown, AnalyticsParams.ErrorType, errorType);
|
||||
}
|
||||
|
||||
// Leading token only — keeps cardinality low and avoids leaking message contents / PII.
|
||||
private static string ExtractType(string condition)
|
||||
{
|
||||
if (string.IsNullOrEmpty(condition)) return "unknown";
|
||||
int colon = condition.IndexOf(':');
|
||||
string head = (colon > 0 ? condition.Substring(0, colon) : condition).Trim();
|
||||
int space = head.IndexOf(' ');
|
||||
if (space > 0) head = head.Substring(0, space);
|
||||
return head.Length > 40 ? head.Substring(0, 40) : head;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 05e0b4bc8382a40c0a78b122ac37a9fa
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using Facebook.Unity;
|
||||
using UnityEngine;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// Facebook App Events sink. Mirrors <see cref="FirebaseAnalyticsSystem"/>'s readiness queue: events
|
||||
/// logged before FB.Init completes are buffered and flushed on init.
|
||||
///
|
||||
/// Compliance: child-directed app — advertiser-ID collection is OFF (FacebookSettings), so App Events
|
||||
/// flow without IDFA. Do NOT enable advertiser tracking / ATT here. See memory child-directed-coppa.
|
||||
/// </summary>
|
||||
public class FacebookAnalyticsSystem : IAnalyticsService, IStartable
|
||||
{
|
||||
private bool _ready;
|
||||
private readonly Queue<Action> _pending = new();
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (FB.IsInitialized) OnInitialized();
|
||||
else FB.Init(OnInitialized);
|
||||
}
|
||||
|
||||
private void OnInitialized()
|
||||
{
|
||||
FB.ActivateApp();
|
||||
_ready = true;
|
||||
while (_pending.Count > 0)
|
||||
{
|
||||
try { _pending.Dequeue().Invoke(); }
|
||||
catch (Exception e) { Debug.LogError($"[FacebookAnalytics] Queued event failed: {e}"); }
|
||||
}
|
||||
}
|
||||
|
||||
public void LogEvent(string name) =>
|
||||
Run(() => FB.LogAppEvent(MapName(name)));
|
||||
|
||||
public void LogEvent(string name, string paramName, string paramValue) =>
|
||||
Run(() => FB.LogAppEvent(MapName(name), null,
|
||||
new Dictionary<string, object> { [paramName] = paramValue ?? string.Empty }));
|
||||
|
||||
public void LogEvent(string name, IReadOnlyDictionary<string, object> parameters)
|
||||
{
|
||||
if (parameters == null || parameters.Count == 0)
|
||||
{
|
||||
LogEvent(name);
|
||||
return;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, object>(parameters.Count);
|
||||
float? valueToSum = null;
|
||||
foreach (var kv in parameters)
|
||||
{
|
||||
// GA4 ad_impression carries revenue in "value"; surface it to FB as the summable value.
|
||||
if (kv.Key == AnalyticsParams.Value && TryToFloat(kv.Value, out var v)) valueToSum = v;
|
||||
dict[kv.Key] = ToFbValue(kv.Value);
|
||||
}
|
||||
Run(() => FB.LogAppEvent(MapName(name), valueToSum, dict));
|
||||
}
|
||||
|
||||
// Facebook App Events has no user-property concept.
|
||||
public void SetUserProperty(string name, string value) { }
|
||||
|
||||
private void Run(Action action)
|
||||
{
|
||||
if (_ready)
|
||||
{
|
||||
try { action(); }
|
||||
catch (Exception e) { Debug.LogError($"[FacebookAnalytics] Event failed: {e}"); }
|
||||
}
|
||||
else _pending.Enqueue(action);
|
||||
}
|
||||
|
||||
// Map our snake_case names to Facebook standard events where one exists; else log as custom.
|
||||
// (This SDK's AppEventName has no Ad* constants, so ad_impression / ad_clicked stay custom.)
|
||||
private static string MapName(string name) => name switch
|
||||
{
|
||||
AnalyticsEvents.TutorialCompleted => AppEventName.CompletedTutorial,
|
||||
AnalyticsEvents.LevelComplete => AppEventName.AchievedLevel,
|
||||
_ => name
|
||||
};
|
||||
|
||||
private static object ToFbValue(object value) => value switch
|
||||
{
|
||||
null => string.Empty,
|
||||
bool b => b ? 1 : 0,
|
||||
string s => s,
|
||||
int or long or float or double => value,
|
||||
_ => value.ToString()
|
||||
};
|
||||
|
||||
private static bool TryToFloat(object value, out float result)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case float f: result = f; return true;
|
||||
case double d: result = (float)d; return true;
|
||||
case int i: result = i; return true;
|
||||
case long l: result = l; return true;
|
||||
default: result = 0f; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 196c5d190005d45f885236d8765813d1
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
@@ -5,6 +6,7 @@ using Darkmatter.Core.Contracts.Services.Camera;
|
||||
using Darkmatter.Core.Contracts.Services.Capture;
|
||||
using UnityEngine;
|
||||
using CameraType = Darkmatter.Core.Enums.Services.Camera.CameraType;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Darkmatter.Services.Capture
|
||||
{
|
||||
@@ -15,7 +17,7 @@ namespace Darkmatter.Services.Capture
|
||||
public CaptureService(ICameraService cameraService) => _cameraService = cameraService;
|
||||
|
||||
public async UniTask<byte[]> CapturePngAsync(GameObject captureObject, float scale,
|
||||
CancellationToken cancellationToken = default)
|
||||
Func<IDisposable> captureViewScopeFactory = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (captureObject == null) return null;
|
||||
var paperRT = captureObject.transform as RectTransform;
|
||||
@@ -26,10 +28,6 @@ namespace Darkmatter.Services.Capture
|
||||
int sw = Screen.width;
|
||||
int sh = Screen.height;
|
||||
|
||||
Rect crop = ComputeCropRect(captureObject, sw, sh);
|
||||
int cropW = Mathf.Max(1, (int)crop.width);
|
||||
int cropH = Mathf.Max(1, (int)crop.height);
|
||||
|
||||
var prevFlags = cam.clearFlags;
|
||||
var prevBg = cam.backgroundColor;
|
||||
var prevTarget = cam.targetTexture;
|
||||
@@ -41,15 +39,22 @@ namespace Darkmatter.Services.Capture
|
||||
|
||||
List<Canvas> disabledCanvases = null;
|
||||
List<UnityEngine.UI.Graphic> disabledGraphics = null;
|
||||
IDisposable captureViewScope = null;
|
||||
|
||||
try
|
||||
{
|
||||
await UniTask.WaitForEndOfFrame(cancellationToken);
|
||||
|
||||
captureViewScope = captureViewScopeFactory?.Invoke();
|
||||
|
||||
disabledCanvases = DisableOtherRootCanvases(paperCanvas);
|
||||
disabledGraphics = HideNonPaperGraphics(paperRT);
|
||||
HideBackdropGraphics(paperRT, disabledGraphics);
|
||||
|
||||
Rect crop = ComputeCropRect(captureObject, sw, sh);
|
||||
int cropW = Mathf.Max(1, (int)crop.width);
|
||||
int cropH = Mathf.Max(1, (int)crop.height);
|
||||
|
||||
cam.clearFlags = CameraClearFlags.SolidColor;
|
||||
cam.backgroundColor = new Color(0f, 0f, 0f, 0f);
|
||||
cam.targetTexture = rt;
|
||||
@@ -63,42 +68,36 @@ namespace Darkmatter.Services.Capture
|
||||
|
||||
var prevActive = RenderTexture.active;
|
||||
RenderTexture.active = rt;
|
||||
var fullScreen = new Texture2D(sw, sh, TextureFormat.RGBA32, mipChain: false);
|
||||
fullScreen.ReadPixels(new Rect(0, 0, sw, sh), 0, 0);
|
||||
fullScreen.Apply();
|
||||
// Read only the crop region straight off the RT. A full-screen ReadPixels followed by
|
||||
// GetPixels/SetPixels cropping allocated a managed Color[] at 16 bytes/pixel (tens of MB)
|
||||
// on every capture — autosave runs this after each paint, OOM-killing low-RAM devices.
|
||||
var cropped = new Texture2D(cropW, cropH, TextureFormat.RGBA32, mipChain: false);
|
||||
cropped.ReadPixels(new Rect(crop.x, crop.y, cropW, cropH), 0, 0);
|
||||
cropped.Apply();
|
||||
RenderTexture.active = prevActive;
|
||||
captureViewScope?.Dispose();
|
||||
captureViewScope = null;
|
||||
|
||||
try
|
||||
{
|
||||
var cropped = new Texture2D(cropW, cropH, TextureFormat.RGBA32, mipChain: false);
|
||||
int targetW = Mathf.Max(1, Mathf.RoundToInt(cropW * scale));
|
||||
int targetH = Mathf.Max(1, Mathf.RoundToInt(cropH * scale));
|
||||
Texture2D output = cropped;
|
||||
if (output.width != targetW || output.height != targetH)
|
||||
output = Resize(cropped, targetW, targetH);
|
||||
|
||||
try
|
||||
{
|
||||
cropped.SetPixels(fullScreen.GetPixels((int)crop.x, (int)crop.y, cropW, cropH));
|
||||
cropped.Apply();
|
||||
|
||||
int targetW = Mathf.Max(1, Mathf.RoundToInt(cropW * scale));
|
||||
int targetH = Mathf.Max(1, Mathf.RoundToInt(cropH * scale));
|
||||
Texture2D output = cropped;
|
||||
if (output.width != targetW || output.height != targetH)
|
||||
output = Resize(cropped, targetW, targetH);
|
||||
|
||||
try
|
||||
{
|
||||
return output.EncodeToPNG();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (output != cropped) Object.Destroy(output);
|
||||
}
|
||||
return output.EncodeToPNG();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.Destroy(cropped);
|
||||
if (output != cropped) Object.Destroy(output);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.Destroy(fullScreen);
|
||||
Object.Destroy(cropped);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -118,6 +117,7 @@ namespace Darkmatter.Services.Capture
|
||||
foreach (var c in disabledCanvases)
|
||||
if (c != null)
|
||||
c.enabled = true;
|
||||
captureViewScope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,4 +217,4 @@ namespace Darkmatter.Services.Capture
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user