Merge remote-tracking branch 'origin/savya' into work_branch

# Conflicts:
#	Assets/Darkmatter/Content/Fonts/static/Fredoka-SemiBold SDF.asset
This commit is contained in:
Mausham
2026-07-06 18:14:44 +05:45
49 changed files with 4485 additions and 2144 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
Assets/.DS_Store vendored

Binary file not shown.

BIN
Assets/AddressableAssetsData/.DS_Store vendored Normal file

Binary file not shown.

BIN
Assets/AppsFlyer/.DS_Store vendored Normal file

Binary file not shown.

BIN
Assets/Confetti FX/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -9,6 +9,7 @@ namespace Darkmatter.Core.Contracts.Features.DrawingCatalog
{
string Id { get; }
string DisplayName { get; }
int Difficulty { get; }
Sprite DefaultThumbnail { get; }
GameObject DrawingPrefab { get; }
GameObject ColoringPrefab { get; }

View File

@@ -11,9 +11,9 @@ namespace Darkmatter.Core.Contracts.Services.Analytics
// Onboarding / activation funnel
public const string IntroStarted = "intro_started";
public const string IntroCompleted = "intro_completed";
public const string TutorialStarted = "tutorial_started";
public const string TutorialStarted = "tutorial_begin";
public const string TutorialStepCompleted = "tutorial_step_completed";
public const string TutorialCompleted = "tutorial_completed";
public const string TutorialCompleted = "tutorial_complete";
public const string PlayClicked = "play_clicked";
public const string FirstDrawingStarted = "first_drawing_started";
@@ -32,9 +32,9 @@ namespace Darkmatter.Core.Contracts.Services.Analytics
public const string ShapeAssembled = "shape_assembled";
public const string ColorApplied = "color_applied";
// Progression & content (GA4 recommended level_start / level_complete)
// Progression & content. level_start and level_end are GA4-recommended games events.
public const string LevelStart = "level_start";
public const string LevelComplete = "level_complete";
public const string LevelComplete = "level_end";
public const string AllContentCompleted = "all_content_completed";
// Gallery capture (pre-existing)

View File

@@ -4,14 +4,14 @@ namespace Darkmatter.Core.Contracts.Services.Analytics
/// Canonical analytics parameter keys. Pass a consistent set on every gameplay event so reports can
/// answer "which drawing did people quit on", not just "how many quit". Each key used in reports must
/// be registered as a Custom Dimension in the Firebase console. GA4 limit: 25 params per event.
/// GA4-recommended events (level_start/level_complete/ad_impression) reuse GA4's reserved param names
/// 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_complete)
public const string LevelName = "level_name"; // GA4 reserved (level_start/level_end)
// Tutorial
public const string StepId = "step_id";
@@ -23,7 +23,7 @@ namespace Darkmatter.Core.Contracts.Services.Analytics
public const string Attempts = "attempts";
public const string ApplyIndex = "apply_index";
public const string CompletionCount = "completion_count";
public const string Success = "success"; // GA4 reserved (level_complete)
public const string Success = "success"; // GA4 reserved (level_end)
// Monetization (GA4 ad_impression reserved names)
public const string Value = "value";

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
{
/// <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

@@ -12,6 +12,8 @@ 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;
@@ -22,6 +24,11 @@ namespace Darkmatter.Core.Data.Static.Features.DrawingTemplate
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;

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,36 +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();
_eventBus.Publish(new IntroStartedSignal());
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

@@ -7,6 +7,7 @@ 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;
@@ -209,7 +210,20 @@ namespace Darkmatter.Features.Artbook
texture = await _progression.GetCachedThumbnailAsync(entry.Value.Id);
if (texture == null) return;
await _gallery.SaveImageAsync(texture, entry.Value.Name, ct);
// 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);
}

View File

@@ -60,12 +60,14 @@ namespace Darkmatter.Features.Capture
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;
}
@@ -73,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

@@ -68,7 +68,13 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
// one VContainer cancels on teardown, and the linked _scopeCts may lag the callback order.
if (cancellation.IsCancellationRequested || ct.IsCancellationRequested) return;
if (!_navigatingToGameplay) _loadingScreen.Hide();
if (!_navigatingToGameplay)
{
_loadingScreen.Hide();
// Catalog genuinely on screen — not a transit hop toward gameplay (edit/next relays land
// in this scene with _navigatingToGameplay already latched and never reveal the catalog).
_bus.Publish(new ColorbookShownSignal());
}
PrewarmInterstitialAdAsync().Forget();
}

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);
@@ -127,6 +134,19 @@ namespace Darkmatter.Features.DrawingCatalog
_eventBus.Publish(new DrawingCatalogReadySignal());
}
private static List<string> ToFixedRowGridOrder(IReadOnlyList<string> ids)
{
var ordered = new List<string>(ids.Count);
for (var row = 0; row < RowCount; row++)
{
for (var index = row; index < ids.Count; index += RowCount)
ordered.Add(ids[index]);
}
return ordered;
}
public void Dispose()
{
_view.OnItemClicked -= OnItemClicked;
@@ -142,4 +162,4 @@ namespace Darkmatter.Features.DrawingCatalog
_cts.Dispose();
}
}
}
}

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,6 +208,13 @@ 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);

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;
@@ -44,10 +46,12 @@ namespace Darkmatter.Features.GameplayFlow.Systems
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;
@@ -104,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)
@@ -131,8 +139,10 @@ namespace Darkmatter.Features.GameplayFlow.Systems
public async UniTask SaveAsync(CancellationToken ct)
{
await SaveCurrentAsync(ct);
await _capture.CapturePngAsync(saveToGallery: true, ct);
// One capture serves both the gallery write and the progress thumbnail — this used to
// run the full capture pipeline twice back to back.
var png = await _capture.CapturePngAsync(saveToGallery: true, ct);
await SaveCurrentAsync(ct, captureThumbnail: false, precapturedThumbnail: png);
}
public async UniTask NextAsync(CancellationToken ct)
@@ -209,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;
@@ -232,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)
{
@@ -240,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;
@@ -263,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);
}
@@ -284,6 +309,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_assembledSub?.Dispose();
_colorAppliedSub?.Dispose();
_drawingSelectedSub?.Dispose();
_openColorbookSub?.Dispose();
_autosaveCts?.Cancel();
_autosaveCts?.Dispose();
_scopeCts?.Cancel();

View File

@@ -39,6 +39,11 @@ namespace Darkmatter.Services.Ads
[SerializeField, Min(0)]
private int maxInterstitialsPerSession = 8;
[Tooltip(
"Enable only after verifying Firebase/AdMob is not already auto-logging ad_impression for the same ads. Leaving this off avoids duplicate ad_impression counts in GA4/Firebase.")]
[SerializeField]
private bool logManualAdImpressionEvents;
public bool IsInitialized => _initialized;
public event Action<AdFormat, AdLoadState> LoadStateChanged;
@@ -653,11 +658,12 @@ namespace Darkmatter.Services.Ads
ad.OnAdClicked += () => LogAdClicked(format);
}
// Ad revenue: GA4-recommended ad_impression carries value+currency from AdMob's paid callback —
// this is what surfaces ad revenue in Firebase/GA4. Ad callbacks fire on the Unity main thread
// (RaiseAdEventsOnUnityMainThread = true), so logging straight to analytics is safe.
// AdMob/Firebase can auto-log ad_impression for linked apps. Manual logging is opt-in so one
// shown ad does not become two GA4 ad_impression events.
private void LogAdImpression(AdFormat format, AdValue value)
{
if (!logManualAdImpressionEvents) return;
_analytics?.LogEvent(AnalyticsEvents.AdImpression, new Dictionary<string, object>
{
[AnalyticsParams.AdPlatform] = "AdMob",
@@ -736,4 +742,4 @@ namespace Darkmatter.Services.Ads
LoadStateChanged?.Invoke(format, state);
}
}
}
}

View File

@@ -56,8 +56,9 @@ namespace Darkmatter.Services.Analytics
_subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.IntroCompleted)));
_subs.Add(_bus.Subscribe<PlayBtnClickedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.PlayClicked)));
// Navigation
_subs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ColorbookOpened)));
// Navigation. ColorbookShownSignal (not OpenColorBookSignal, which is the show-view command
// and misses scene-entry paths) fires on every way the catalog becomes visible.
_subs.Add(_bus.Subscribe<ColorbookShownSignal>(_ => OnColorbookShown()));
_subs.Add(_bus.Subscribe<OpenArtBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ArtbookOpened)));
_subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => OnReturnToMainMenu()));
@@ -90,6 +91,10 @@ namespace Darkmatter.Services.Analytics
private void OnDrawingSelected(string drawingId)
{
// GameplayFlowController relays the selection a second time after the scene swap
// (artbook edit path) — same id while already active is that relay, not a new selection.
if (drawingId == _activeDrawingId) return;
// A new selection while a drawing is still open (uncompleted) = the previous one was abandoned.
EndActiveDrawingIfAbandoned();
@@ -156,7 +161,7 @@ namespace Darkmatter.Services.Analytics
if (dur >= 0f) p[AnalyticsParams.DurationSeconds] = dur;
_analytics.LogEvent(AnalyticsEvents.DrawingCompleted, p);
// GA4 recommended games event (lights up built-in level funnels).
// Companion to GA4's level_start.
_analytics.LogEvent(AnalyticsEvents.LevelComplete, new Dictionary<string, object>
{
[AnalyticsParams.LevelName] = s.TemplateId,
@@ -171,16 +176,24 @@ namespace Darkmatter.Services.Analytics
if (PlayerPrefs.GetInt(AllContentKey, 0) == 1) return; // once ever
PlayerPrefs.SetInt(AllContentKey, 1);
PlayerPrefs.Save();
_analytics.LogEvent(AnalyticsEvents.AllContentCompleted,
AnalyticsParams.CompletionCount, s.CompletedCount.ToString());
_analytics.LogEvent(AnalyticsEvents.AllContentCompleted, new Dictionary<string, object>
{
[AnalyticsParams.CompletionCount] = s.CompletedCount,
});
}
private void OnGallerySaveCompleted(GallerySaveCompletedSignal s)
{
_analytics.LogEvent(AnalyticsEvents.GallerySaveCompleted, AnalyticsParams.Success, s.Success ? "true" : "false");
// Prefer the id carried by the signal: art-book saves happen with no active drawing.
string drawingId = string.IsNullOrEmpty(s.TemplateId) ? _activeDrawingId ?? string.Empty : s.TemplateId;
_analytics.LogEvent(AnalyticsEvents.GallerySaveCompleted, new Dictionary<string, object>
{
[AnalyticsParams.DrawingId] = drawingId,
[AnalyticsParams.Success] = s.Success ? 1 : 0,
});
_analytics.LogEvent(AnalyticsEvents.DrawingSaved, new Dictionary<string, object>
{
[AnalyticsParams.DrawingId] = _activeDrawingId ?? string.Empty,
[AnalyticsParams.DrawingId] = drawingId,
[AnalyticsParams.Success] = s.Success ? 1 : 0,
});
}
@@ -191,6 +204,15 @@ namespace Darkmatter.Services.Analytics
_analytics.LogEvent(AnalyticsEvents.MainMenuReturned);
}
private void OnColorbookShown()
{
// Reaching the catalog with a drawing still open means the player backed out of gameplay
// mid-drawing — the abandon moment. (Completion clears the active drawing before the
// catalog reloads, so the finish path never lands here with one open.)
EndActiveDrawingIfAbandoned();
_analytics.LogEvent(AnalyticsEvents.ColorbookOpened);
}
// Emits drawing_abandoned for an open, uncompleted drawing. Completion clears _activeDrawingId, so
// reaching here with a non-null id means the drawing was left without finishing — the leak signal.
private void EndActiveDrawingIfAbandoned()

View File

@@ -68,44 +68,36 @@ namespace Darkmatter.Services.Capture
var prevActive = RenderTexture.active;
RenderTexture.active = rt;
var fullScreen = new Texture2D(sw, sh, TextureFormat.RGBA32, mipChain: false);
fullScreen.ReadPixels(new Rect(0, 0, sw, sh), 0, 0);
fullScreen.Apply();
// Read only the crop region straight off the RT. A full-screen ReadPixels followed by
// GetPixels/SetPixels cropping allocated a managed Color[] at 16 bytes/pixel (tens of MB)
// on every capture — autosave runs this after each paint, OOM-killing low-RAM devices.
var cropped = new Texture2D(cropW, cropH, TextureFormat.RGBA32, mipChain: false);
cropped.ReadPixels(new Rect(crop.x, crop.y, cropW, cropH), 0, 0);
cropped.Apply();
RenderTexture.active = prevActive;
captureViewScope?.Dispose();
captureViewScope = null;
try
{
var cropped = new Texture2D(cropW, cropH, TextureFormat.RGBA32, mipChain: false);
int targetW = Mathf.Max(1, Mathf.RoundToInt(cropW * scale));
int targetH = Mathf.Max(1, Mathf.RoundToInt(cropH * scale));
Texture2D output = cropped;
if (output.width != targetW || output.height != targetH)
output = Resize(cropped, targetW, targetH);
try
{
cropped.SetPixels(fullScreen.GetPixels((int)crop.x, (int)crop.y, cropW, cropH));
cropped.Apply();
int targetW = Mathf.Max(1, Mathf.RoundToInt(cropW * scale));
int targetH = Mathf.Max(1, Mathf.RoundToInt(cropH * scale));
Texture2D output = cropped;
if (output.width != targetW || output.height != targetH)
output = Resize(cropped, targetW, targetH);
try
{
return output.EncodeToPNG();
}
finally
{
if (output != cropped) Object.Destroy(output);
}
return output.EncodeToPNG();
}
finally
{
Object.Destroy(cropped);
if (output != cropped) Object.Destroy(output);
}
}
finally
{
Object.Destroy(fullScreen);
Object.Destroy(cropped);
}
}
finally

Binary file not shown.

Binary file not shown.

View File

@@ -21,7 +21,7 @@ TextureImporter:
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 1
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0

View File

@@ -21,7 +21,7 @@ TextureImporter:
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 1
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0

View File

@@ -21,7 +21,7 @@ TextureImporter:
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 1
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0

View File

@@ -21,7 +21,7 @@ TextureImporter:
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 1
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0

View File

@@ -21,7 +21,7 @@ TextureImporter:
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 1
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0

View File

@@ -21,7 +21,7 @@ TextureImporter:
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 1
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0

File diff suppressed because one or more lines are too long

View File

@@ -14,6 +14,7 @@ MonoBehaviour:
m_EditorClassIdentifier: Core::Darkmatter.Core.Data.Static.Features.DrawingTemplate.DrawingTemplateSO
id: Bag
displayName: Bag
difficultyOverride: 0
defaultThumbnail: {fileID: 8766740682603709380, guid: ecad1a66623031f4a91dbcee6f10a295, type: 3}
drawingPrefab: {fileID: 8396209140419971077, guid: ab15e2395fec7be429926e140d9f4a61, type: 3}
coloringPrefab: {fileID: 222817150442217502, guid: b8b22ebdfe861214c8edef5e46743323, type: 3}

BIN
Assets/FacebookSDK/.DS_Store vendored Normal file

Binary file not shown.

BIN
Assets/Firebase/.DS_Store vendored Normal file

Binary file not shown.

BIN
Assets/Packages/.DS_Store vendored Normal file

Binary file not shown.

BIN
Assets/Plugins/.DS_Store vendored Normal file

Binary file not shown.

BIN
Assets/Spine/.DS_Store vendored Normal file

Binary file not shown.

BIN
Assets/TextMesh Pro/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -8,3 +8,13 @@ ScriptedImporter:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: 625f186215c104763be7675aa2d941aa, type: 3}
useAsTemplate: 0
exposeTemplateAsShader: 0
indexedData: {instanceID: 0}
template:
name:
category:
description:
icon: {instanceID: 0}
thumbnail: {instanceID: 0}
order: 0

8
Assets/_Recovery.meta Normal file
View File

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

4042
Assets/_Recovery/0.unity Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 90078c122328b4902a1e67255b30525a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -148,7 +148,7 @@ PlayerSettings:
loadStoreDebugModeEnabled: 0
visionOSBundleVersion: 1.0
tvOSBundleVersion: 1.0
bundleVersion: 1.9
bundleVersion: 2.0
preloadedAssets:
- {fileID: 11400000, guid: cc969dbecb228fa49b16da9273753a8f, type: 2}
- {fileID: -944628639613478452, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3}