Compare commits

...

56 Commits

Author SHA1 Message Date
Mausham
fa2b8f1e8e some fixes 2026-07-10 13:14:55 +05:45
Mausham
f6d672995e added sound in color completion 2026-07-08 18:36:36 +05:45
Mausham
ecc2dd3dff reduced coloring objects 2026-07-07 15:52:58 +05:45
Mausham
c2d3ff4dd9 Merge remote-tracking branch 'origin/savya' into work_branch
# Conflicts:
#	Assets/Darkmatter/Content/Fonts/static/Fredoka-SemiBold SDF.asset
2026-07-06 18:14:44 +05:45
Savya Bikram Shah
dfd76d187f video and difficulty based sorting fixes 2026-07-06 14:37:59 +05:45
Savya Bikram Shah
7b90efca98 Version changed 2026-07-06 14:24:13 +05:45
Savya Bikram Shah
e0a7fa9735 Events edits 2026-07-06 14:17:50 +05:45
Savya Bikram Shah
3df3d923fb Difficulty based sorting and analytics fixes 2026-07-06 13:20:27 +05:45
Mausham
40ba0df92d fixed pagination 2026-06-26 18:38:40 +05:45
Savya Bikram Shah
b28e1f637d Crash fixes 2026-06-26 18:18:49 +05:45
Savya Bikram Shah
21e5206626 Merge branch 'work_branch' of https://git.darkmatterproduction.org/savya/Colorbook into savya 2026-06-26 17:54:41 +05:45
Mausham
3095469c4f added diyo animation 2026-06-26 17:54:17 +05:45
Savya Bikram Shah
fc291cf116 Merge branch 'work_branch' of https://git.darkmatterproduction.org/savya/Colorbook into savya
# Conflicts:
#	Assets/Darkmatter/Content/Fonts/static/Fredoka-SemiBold SDF.asset
2026-06-26 16:19:08 +05:45
Savya Bikram Shah
7446e49c8f zooming for easy coloring 2026-06-26 16:18:39 +05:45
Mausham
f3c80be3ca fix 2026-06-26 15:37:52 +05:45
Mausham
db3354c88b added animations 2026-06-26 13:43:42 +05:45
Mausham
c64020628b added 40 drawing and coloring 2026-06-25 18:15:44 +05:45
Mausham
789edf1fba Merge remote-tracking branch 'origin/savya' into work_branch 2026-06-25 11:24:03 +05:45
Mausham
b9c9889145 fix 2026-06-25 11:23:34 +05:45
Savya Bikram Shah
608656bb83 Done 2026-06-25 11:23:06 +05:45
Savya Bikram Shah
ab706059c1 Minor work around 2026-06-25 11:19:01 +05:45
Mausham
93fafae63b added some drawing images yet remainded to create prefab
\
2026-06-23 18:41:23 +05:45
Mausham
a04a826ae0 added eagle coloring missing part 2026-06-23 17:39:38 +05:45
Mausham
a4fff942bf added 41 coloring prefabs 2026-06-23 16:51:51 +05:45
Savya Bikram Shah
cf2f37be0c flag of nepal 2026-06-22 16:05:28 +05:45
Savya Bikram Shah
729ddf9277 fixed 2026-06-07 18:41:02 +05:45
Savya Bikram Shah
a15b08df0c Fix 2026-06-07 18:28:00 +05:45
Savya Bikram Shah
8c6962762c Minor ux fix 2026-06-07 17:35:10 +05:45
Savya Bikram Shah
fd3c13e470 Merge remote-tracking branch 'origin/work_branch' into savya 2026-06-07 17:23:07 +05:45
Savya Bikram Shah
7d85a0be1d rewareded ux fix 2026-06-07 17:22:43 +05:45
Mausham
f08635225d fixes 2026-06-07 17:21:48 +05:45
Savya Bikram Shah
36cf9cdad1 Merge remote-tracking branch 'origin/work_branch' into savya
# Conflicts:
#	ProjectSettings/ProjectSettings.asset
2026-06-07 16:59:51 +05:45
Mausham
435988ca70 added more diagram and animations 2026-06-07 16:58:17 +05:45
Savya Bikram Shah
176d95ec6a Heat fix by fps cap 2026-06-07 16:58:17 +05:45
Savya Bikram Shah
f63c0c9d5e shapes more visible 2026-06-07 16:22:03 +05:45
Mausham
4eccde41b5 Merge remote-tracking branch 'origin/savya' into work_branch 2026-06-07 16:09:47 +05:45
Mausham
68bea947fe tttt 2026-06-07 16:07:41 +05:45
Savya Bikram Shah
0b9d0ca8f2 fixes 2026-06-07 16:07:30 +05:45
Savya Bikram Shah
521b5c3b7d Intersterial Limit added 2026-06-07 15:57:31 +05:45
Mausham
ec4288e97a Merge remote-tracking branch 'origin/savya' into work_branch 2026-06-07 15:53:45 +05:45
Savya Bikram Shah
d406f41acc Bus and crash fixes UX improvements rewarded ads added 2026-06-07 15:53:16 +05:45
Savya Bikram Shah
144bac177f Ad frequency reduced 2026-06-07 14:17:07 +05:45
Savya Bikram Shah
a07f7618ab Meta and appflyer deeper intregration 2026-06-07 14:12:09 +05:45
Savya Bikram Shah
70b8dd2b95 Child directed made 2026-06-05 18:33:56 +05:45
Savya Bikram Shah
220d651cfb Tutorial fix 2026-06-05 18:33:39 +05:45
Savya Bikram Shah
e27d0e54cb UX updates 2026-06-05 18:00:22 +05:45
Savya Bikram Shah
dee4b004bd Ad units fixed 2026-06-04 13:37:21 +05:45
Savya Bikram Shah
2462d972e3 Ad ids fixed 2026-06-04 13:00:45 +05:45
Mausham
e3f047de2b Merge remote-tracking branch 'origin/savya' into work_branch 2026-06-03 10:54:02 +05:45
Savya Bikram Shah
0bfcfa3038 Tutorial done 2026-06-03 10:53:45 +05:45
Mausham
b4e0cb0530 Merge remote-tracking branch 'origin/savya' into work_branch
# Conflicts:
#	Assets/Darkmatter/Content/Fonts/static/Fredoka-Bold SDF Blue Outline.asset
2026-06-03 10:24:03 +05:45
Savya Bikram Shah
f81a17e1f5 fix 2026-06-02 13:06:40 +05:45
Savya Bikram Shah
64b383fe6e fixes 2026-06-02 13:03:27 +05:45
Savya Bikram Shah
46b5bcc978 fixes 2026-06-02 12:48:55 +05:45
Savya Bikram Shah
28f431f26a test ads removed 2026-06-01 18:16:34 +05:45
Savya Bikram Shah
d3cc23a1ce NEver sleeps 2026-06-01 18:04:28 +05:45
2374 changed files with 358588 additions and 9095 deletions

BIN
.DS_Store vendored

Binary file not shown.

7
.idea/.idea.Colorbook/.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

BIN
Assets/.DS_Store vendored

Binary file not shown.

BIN
Assets/AddressableAssetsData/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -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

View File

@@ -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

Binary file not shown.

View File

@@ -5,7 +5,6 @@
<androidPackage spec="com.appsflyer:af-android-sdk:6.17.6"></androidPackage>
<androidPackage spec="com.appsflyer:unity-wrapper:6.17.91"></androidPackage>
<androidPackage spec="com.android.installreferrer:installreferrer:2.1"></androidPackage>
<androidPackage spec="com.appsflyer:purchase-connector:2.2.0"></androidPackage>
</androidPackages>
<iosPods>

BIN
Assets/Confetti FX/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
@@ -14,7 +15,13 @@ public interface IColoringController
void PaintRegion(string regionId, Color color);
IReadOnlyDictionary<string, Color> GetCurrentColors();
UniTask PlayCompletionAnimationAsync(CancellationToken ct);
// True while the completion celebration is on screen (the real drawing is hidden).
// Capture must skip these frames so it never grabs the animation instead of the art.
bool IsPlayingCompletionAnimation { get; }
bool HasNonAuthoredColors { get; }
IDisposable UseNonZoomedView();
void ResetAll();
void Clear();
}
}

View File

@@ -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; }

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 224a4c0e39279476aa91487a2572bbdd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c8ca87e84abf64a0eb0c32de824318b6

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fac6898f446df4a8e994fa47eed10068
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,14 @@
namespace Darkmatter.Core.Contracts.Features.Tutorial
{
/// <summary>
/// Decides whether the one-time tutorial should run, and records that it has been completed.
/// </summary>
public interface ITutorialGate
{
/// <summary>True only for a genuine first-time player who has not finished the tutorial.</summary>
bool ShouldRun { get; }
/// <summary>Persist that the tutorial is done so it never runs again.</summary>
void MarkCompleted();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e7c2fc9f6a9354a068fdc7d9ed5aa4ad

View File

@@ -0,0 +1,41 @@
using System.Threading;
using Cysharp.Threading.Tasks;
using Darkmatter.Core.Enums.Features.Tutorial;
using UnityEngine;
namespace Darkmatter.Core.Contracts.Features.Tutorial
{
/// <summary>
/// Full-screen guidance overlay: a dim frame with a cut-out "hole" over a target element,
/// an animated hand + halo, and an instruction bubble. Lives on a persistent root-scoped
/// Canvas so it survives scene swaps (mirrors the loading screen).
/// </summary>
public interface ITutorialOverlay
{
/// <summary>True once the view is initialised and safe to drive.</summary>
bool IsReady { get; }
/// <summary>
/// Spotlight a single tap target. When <paramref name="target"/> is null the dim/hole are
/// skipped and only a centered bubble is shown (a non-blocking hint).
/// When <paramref name="blockInput"/> is true every touch outside the hole is swallowed.
/// <paramref name="placement"/> overrides where the instruction bubble sits; <paramref name="offset"/>
/// nudges it from that slot (px, +x right / +y up).
/// </summary>
void ShowTap(RectTransform target, string message, bool blockInput, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
/// <summary>
/// Spotlight a drag gesture from <paramref name="from"/> to <paramref name="to"/>. Input is
/// never blocked here, so the dragged piece can render above the dim.
/// <paramref name="placement"/> overrides where the instruction bubble sits; <paramref name="offset"/>
/// nudges it from that slot (px, +x right / +y up).
/// </summary>
void ShowDrag(RectTransform from, RectTransform to, string message, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
/// <summary>Hide everything immediately (used across scene swaps).</summary>
void HideInstant();
/// <summary>Show a centered celebratory bubble for a short beat, then hide.</summary>
UniTask ShowToastAsync(string message, CancellationToken ct, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1fae7a22d89c64857af91268a55bd564

View File

@@ -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 &lt;= 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";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 44ad2e947d64f40a38febad220fa92a1

View File

@@ -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";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3a7d4e7d6ad7e4ba39f557ddc970890c

View File

@@ -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);
}
}
}

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ebc4c1a0dab8d4b4fb95428c30f57f35

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 381ea33520a140d8b5639279c5390d2c

View File

@@ -0,0 +1,6 @@
using UnityEngine;
namespace Darkmatter.Core.Data.Signals.Features.Coloring
{
public record struct ColorSelectedSignal(int Index, Color Color);
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: df1823d5d5df7469781b42466c308ecc

View File

@@ -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;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fa0fb32346ff54da3a9bbfec9f763cf2

View File

@@ -0,0 +1,6 @@
namespace Darkmatter.Core
{
// Published by the catalog presenter once the drawing grid is populated and on screen.
// Namespace matches the sibling OpenColorBookSignal so existing catalog code needs no new using.
public record struct DrawingCatalogReadySignal;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e71fd9bc94a644ced8b0a141f058291b

View File

@@ -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);
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3dc3c138deba5452ebb0d84ad3d4bf44

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3aee861659cfe43c4b48d513479c2d7d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,4 @@
namespace Darkmatter.Core.Data.Signals.Features.Tutorial
{
public record struct TutorialCompletedSignal;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3b50d1afc8134a0f8ae92b6c0590bdb

View File

@@ -0,0 +1,4 @@
namespace Darkmatter.Core.Data.Signals.Features.Tutorial
{
public record struct TutorialStartedSignal;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3aeb975bb1d6a45aca5c86ddf72bee22

View File

@@ -0,0 +1,4 @@
namespace Darkmatter.Core.Data.Signals.Features.Tutorial
{
public record struct TutorialStepCompletedSignal(string StepId, int StepIndex);
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 11b2e1b23836f4664be3a84389c1c364

View File

@@ -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;

View File

@@ -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;
@@ -16,16 +20,13 @@ namespace Darkmatter.Core.Data.Static.Features.ShapeBuilder
[Header("Drag")]
[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 AnimationCurve PreviewCurve => previewCurve;
public float DragAlpha => dragAlpha;
public Vector2 DragSizeDelta(ShapeSO shape) =>
shape != null ? shape.DefaultSizeDelta : new Vector2(256, 256);

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1cda3e3840cbd4eccbe7ad810dee920d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,82 @@
using Darkmatter.Core.Enums.Features.Tutorial;
using UnityEngine;
namespace Darkmatter.Core.Data.Static.Features.Tutorial
{
/// <summary>
/// Designer-tunable copy and timing for the one-time onboarding tutorial. The step *sequence*
/// is fixed in TutorialDirector; only the wording, the spotlight padding and the per-step
/// watchdog timeout live here.
/// </summary>
[CreateAssetMenu(fileName = "TutorialStepsConfig",
menuName = "Darkmatter/Tutorial/Steps Config")]
public sealed class TutorialStepsConfig : ScriptableObject
{
[Header("Step copy (kept short for young readers)")]
[SerializeField] private string pickText = "Pick a picture to start!";
[SerializeField] private string dragText = "Drag the piece to its spot!";
[SerializeField] private string finishText = "Now finish the puzzle!";
[SerializeField] private string colorText = "Choose a color!";
[SerializeField] private string paintText = "Tap the picture to color it!";
[SerializeField] private string finishColoringText = "Color the whole picture!";
[SerializeField] private string nextText = "Tap Next when you're done!";
[SerializeField] private string doneText = "Yay! You did it!";
[Header("Bubble placement + offset per step (Auto = smart; offset nudges from the slot in px, +x right / +y up)")]
[SerializeField] private BubblePlacement pickBubble = BubblePlacement.Auto;
[SerializeField] private Vector2 pickBubbleOffset;
[SerializeField] private BubblePlacement dragBubble = BubblePlacement.Auto;
[SerializeField] private Vector2 dragBubbleOffset;
[SerializeField] private BubblePlacement finishBubble = BubblePlacement.Auto;
[SerializeField] private Vector2 finishBubbleOffset;
[SerializeField] private BubblePlacement colorBubble = BubblePlacement.Auto;
[SerializeField] private Vector2 colorBubbleOffset;
[SerializeField] private BubblePlacement paintBubble = BubblePlacement.Auto;
[SerializeField] private Vector2 paintBubbleOffset;
[SerializeField] private BubblePlacement finishColoringBubble = BubblePlacement.Auto;
[SerializeField] private Vector2 finishColoringBubbleOffset;
[SerializeField] private BubblePlacement nextBubble = BubblePlacement.Auto;
[SerializeField] private Vector2 nextBubbleOffset;
[SerializeField] private BubblePlacement doneBubble = BubblePlacement.Auto;
[SerializeField] private Vector2 doneBubbleOffset;
[Header("Watchdog")]
[Tooltip("Timeout (s) while waiting for a SYSTEM precondition (scene/catalog/regions ready). If it " +
"doesn't arrive the tutorial fails open so the player is never trapped.")]
[SerializeField] private float stepTimeoutSeconds = 45f;
[Tooltip("Timeout (s) while waiting for the CHILD's own action (drag/tap/paint). 0 = no timeout: " +
"the hint stays until they do it. Set a large value if you want a safety net instead.")]
[SerializeField] private float actionTimeoutSeconds;
public string PickText => pickText;
public string DragText => dragText;
public string FinishText => finishText;
public string ColorText => colorText;
public string PaintText => paintText;
public string FinishColoringText => finishColoringText;
public string NextText => nextText;
public string DoneText => doneText;
public BubblePlacement PickBubble => pickBubble;
public BubblePlacement DragBubble => dragBubble;
public BubblePlacement FinishBubble => finishBubble;
public BubblePlacement ColorBubble => colorBubble;
public BubblePlacement PaintBubble => paintBubble;
public BubblePlacement FinishColoringBubble => finishColoringBubble;
public BubblePlacement NextBubble => nextBubble;
public BubblePlacement DoneBubble => doneBubble;
public Vector2 PickBubbleOffset => pickBubbleOffset;
public Vector2 DragBubbleOffset => dragBubbleOffset;
public Vector2 FinishBubbleOffset => finishBubbleOffset;
public Vector2 ColorBubbleOffset => colorBubbleOffset;
public Vector2 PaintBubbleOffset => paintBubbleOffset;
public Vector2 FinishColoringBubbleOffset => finishColoringBubbleOffset;
public Vector2 NextBubbleOffset => nextBubbleOffset;
public Vector2 DoneBubbleOffset => doneBubbleOffset;
public float StepTimeoutSeconds => stepTimeoutSeconds;
public float ActionTimeoutSeconds => actionTimeoutSeconds;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5c513430cc85f4357b3df1da019bf554

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4b28ea43ea1b942cab1264b52ec34a60
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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,
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 61e68e58d45cf4314886e2a414e3856d

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2a01b1f301d474e39808b73160a98677
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 675d955c682824cd9904fb2e96517df1

View File

@@ -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,31 +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);
}
}
}
}

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
using System.Threading;
using Cysharp.Threading.Tasks;
using Darkmatter.Core.Contracts.Features.Capture;
using Darkmatter.Core.Contracts.Features.Coloring;
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
using Darkmatter.Core.Contracts.Features.GameplayFlow;
using Darkmatter.Core.Contracts.Features.Progression;
@@ -21,6 +22,7 @@ namespace Darkmatter.Features.Capture
private readonly CaptureConfig _config;
private readonly IProgressionSystem _progression;
private readonly IDrawingTemplateCatalog _catalog;
private readonly IColoringController _coloring;
public CaptureSystem(
ICaptureService captureService,
@@ -29,7 +31,8 @@ namespace Darkmatter.Features.Capture
IEventBus bus,
CaptureConfig config,
IProgressionSystem progression,
IDrawingTemplateCatalog catalog)
IDrawingTemplateCatalog catalog,
IColoringController coloring)
{
_captureService = captureService;
_galleryService = galleryService;
@@ -38,21 +41,33 @@ namespace Darkmatter.Features.Capture
_config = config;
_progression = progression;
_catalog = catalog;
_coloring = coloring;
}
public async UniTask<byte[]> CapturePngAsync(bool saveToGallery = false, CancellationToken ct = default)
{
var png = await _captureService.CapturePngAsync(_refs.PaperRoot.gameObject, _config.CaptureScale, ct);
// The completion celebration hides the real drawing and shows an animation object
// under PaperRoot. Capturing then would grab the animation, not the art, so skip.
// A null result keeps the existing thumbnail and skips the gallery write (both no-op on null).
if (_coloring.IsPlayingCompletionAnimation) return null;
var png = await _captureService.CapturePngAsync(
_refs.PaperRoot.gameObject,
_config.CaptureScale,
_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;
}
@@ -60,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;
}

View File

@@ -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;
}
}
}

View File

@@ -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(
@@ -50,20 +56,41 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
public async UniTask StartAsync(CancellationToken cancellation = new CancellationToken())
{
_scopeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
// Grab the token value up front. If the scope is torn down (scene unload) while the
// init below is in flight, the await resumes during disposal — when _scopeCts is already
// disposed and its .Token getter would throw ObjectDisposedException. The captured struct
// stays safe to read.
var ct = _scopeCts.Token;
_returnToMainMenuSubscription = _bus.Subscribe<ReturnToMainMenuSignal>(OnReturnToMainMenu);
_loadingScreen.SetProgress(1f);
await _drawingCatalog.InitializeAsync(cancellation);
if (!_navigatingToGameplay) _loadingScreen.Hide();
await _drawingCatalog.InitializeAsync(ct);
// scope disposed mid-init; nothing left to do. Check the external token too — it's the
// one VContainer cancels on teardown, and the linked _scopeCts may lag the callback order.
if (cancellation.IsCancellationRequested || ct.IsCancellationRequested) return;
PrewarmInterstitialAdAsync(_scopeCts.Token).Forget();
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().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)
@@ -96,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
{
@@ -128,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
{
@@ -136,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)

View File

@@ -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
}
}

View File

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

View File

@@ -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,16 @@ 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();
private readonly Dictionary<string, Color> _authoredColors = new();
@@ -40,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);
@@ -78,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()
@@ -88,22 +115,46 @@ public class ColoringController : IColoringController, IDisposable
return snapshot;
}
public bool IsPlayingCompletionAnimation => _isPlayingCompletionAnimation;
public async UniTask PlayCompletionAnimationAsync(CancellationToken ct)
{
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);
}
finally
{
_isPlayingCompletionAnimation = false;
if (_completionAnimationInstance != null)
_completionAnimationInstance.SetActive(false);
}
}
// 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
@@ -118,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;
@@ -133,6 +192,8 @@ public class ColoringController : IColoringController, IDisposable
public void Clear()
{
_history.Drop();
_isPlayingCompletionAnimation = false;
StopCompletionSound();
foreach (var button in _buttons)
if (button != null)
@@ -148,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();
@@ -161,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)
@@ -186,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;
@@ -210,4 +288,4 @@ public class ColoringController : IColoringController, IDisposable
foreach (var color in _repository.SelectedPalette.Colors)
_buttons.Add(_buttonFactory.Create(_colorButtonPrefab, color));
}
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ddf9e0ad8b4b4ee59470410f7cf40689
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,11 +1,20 @@
using System;
using Darkmatter.Core.Contracts.Features.Coloring;
using Darkmatter.Core.Data.Signals.Features.Coloring;
using Darkmatter.Libs.Observer;
using UnityEngine;
namespace Darkmatter.Features.Coloring.Systems;
public class ColoringStateRepository
{
private readonly IEventBus _bus;
public ColoringStateRepository(IEventBus bus)
{
_bus = bus;
}
public IColorPalette SelectedPalette { get; private set; }
public int SelectedIndex { get; private set; }
public Color SelectedColor =>
@@ -29,5 +38,9 @@ public class ColoringStateRepository
if (idx < 0) return;
SelectedIndex = idx;
SelectedIndexChanged?.Invoke();
// Surface the user's colour pick on the bus so the tutorial (a root-scoped service that
// can't reach this scene-scoped repository directly) can detect it. Only fires on an actual
// SelectColor — not on the default selection made in SetPalette.
_bus.Publish(new ColorSelectedSignal(idx, color));
}
}

View File

@@ -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;
}

View File

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

View File

@@ -13,8 +13,8 @@ namespace Darkmatter.Features.DrawingCatalog
{
Id = id;
thumbnail.sprite = thumbnailSprite;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(onClick);
}
}
}

View File

@@ -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);
@@ -122,6 +129,22 @@ namespace Darkmatter.Features.DrawingCatalog
// Unblock InitializeAsync: items are now on screen, so the loading screen can hide.
_controller.NotifyPopulated();
// Cue the first-run tutorial that the catalog grid is on screen and tappable.
_eventBus.Publish(new DrawingCatalogReadySignal());
}
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()
@@ -139,4 +162,4 @@ namespace Darkmatter.Features.DrawingCatalog
_cts.Dispose();
}
}
}
}

View File

@@ -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;
}
}
}
}

View File

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

View File

@@ -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;

View File

@@ -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,12 +139,20 @@ 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)
{
// Drop any debounced autosave so it can't fire during/after the completion
// animation and capture the animation (or an empty paper) into the thumbnail.
_autosaveCts?.Cancel();
_autosaveCts?.Dispose();
_autosaveCts = null;
await SaveCurrentAsync(ct);
_refs.Confetti.Play();
_sfx.Play(SfxId.FireWorkLaunch);
@@ -145,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))
{
@@ -192,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;
@@ -215,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)
{
@@ -223,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;
@@ -246,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);
}
@@ -267,6 +309,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_assembledSub?.Dispose();
_colorAppliedSub?.Dispose();
_drawingSelectedSub?.Dispose();
_openColorbookSub?.Dispose();
_autosaveCts?.Cancel();
_autosaveCts?.Dispose();
_scopeCts?.Cancel();

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 000c0b9528ead0b43af7cdc19fb38f0e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 932d925aaafb08242b1fd65d3860ef40
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cba93615156299e4482e6b41e7db0199
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ff6d3964378b694ba9c7ba67b3af897

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: bcae41f3415403d4da1f7aafb6d9a728
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 85c9da43c395b794fb2180a6da9f6511

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e6434fcc29f4e4949be4be8115fda2b9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 48cf1e0dac50c4952b2f27e17e8a065a
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8f38ec6a6d3544172b3fdf20e8d31800
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 46ff2e3f5fac64fc9a40fe00a37bd9c8

View 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.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9db44e977bbde446daa19aa928ee1230
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 010c08604fc7e44ceb9d069874618ce8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bcff7c06c2d77499db81865208f807e7

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c7343ebeefe9e4db48c41c617347adae

Some files were not shown because too many files have changed in this diff Show More