Merge remote-tracking branch 'origin/savya' into work_branch
# Conflicts: # Assets/Darkmatter/Content/Fonts/static/Fredoka-SemiBold SDF.asset
This commit is contained in:
BIN
Assets/.DS_Store
vendored
BIN
Assets/.DS_Store
vendored
Binary file not shown.
BIN
Assets/AddressableAssetsData/.DS_Store
vendored
Normal file
BIN
Assets/AddressableAssetsData/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/AppsFlyer/.DS_Store
vendored
Normal file
BIN
Assets/AppsFlyer/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/Confetti FX/.DS_Store
vendored
Normal file
BIN
Assets/Confetti FX/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/Darkmatter/.DS_Store
vendored
BIN
Assets/Darkmatter/.DS_Store
vendored
Binary file not shown.
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
namespace Darkmatter.Core.Data.Signals.Features.Capture
|
||||
{
|
||||
public record struct GallerySaveCompletedSignal(bool Success);
|
||||
/// <summary>
|
||||
/// TemplateId identifies which drawing was saved; publishers outside gameplay (art book) must set it
|
||||
/// because there is no active drawing for analytics to fall back on there.
|
||||
/// </summary>
|
||||
public record struct GallerySaveCompletedSignal(bool Success, string TemplateId = null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Darkmatter.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// The drawing-catalog (colorbook) screen became visible: scene entry (play / back / next) or
|
||||
/// returning from the art book. Distinct from <see cref="OpenColorBookSignal"/>, which is the
|
||||
/// command to show the view and only fires on one navigation path. Analytics keys colorbook_opened
|
||||
/// and abandoned-drawing detection off this.
|
||||
/// </summary>
|
||||
public record struct ColorbookShownSignal;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa0fb32346ff54da3a9bbfec9f763cf2
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ public sealed class DrawingCatalogController : IDrawingCatalogController
|
||||
private void Refresh()
|
||||
{
|
||||
_visible.Clear();
|
||||
// Catalog already orders ids by difficulty (easiest first), then id.
|
||||
_visible.AddRange(_catalog.AllTemplateIds);
|
||||
_visible.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
ListChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
{
|
||||
Id = id;
|
||||
thumbnail.sprite = thumbnailSprite;
|
||||
button.onClick.RemoveAllListeners();
|
||||
button.onClick.AddListener(onClick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
|
||||
private int _currentPage = 0;
|
||||
private int _totalPages = 1;
|
||||
private const int RowCount = 2;
|
||||
private const int ItemPerPage = 8; //2 rows x 4 columns
|
||||
|
||||
private readonly DrawingCatalogView _view;
|
||||
@@ -48,8 +49,14 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
}
|
||||
|
||||
private void HandleOpenColorBookSignal(OpenColorBookSignal signal)
|
||||
{
|
||||
ShowCatalog();
|
||||
}
|
||||
|
||||
private void ShowCatalog()
|
||||
{
|
||||
_view.Show();
|
||||
_eventBus.Publish(new ColorbookShownSignal());
|
||||
}
|
||||
|
||||
private void OnRightPageBtnClicked()
|
||||
@@ -84,7 +91,7 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
|
||||
private void OnArtBookBtnClicked()
|
||||
{
|
||||
_eventBus.Publish(new OpenArtBookSignal(()=> _view.Show()));
|
||||
_eventBus.Publish(new OpenArtBookSignal(ShowCatalog));
|
||||
_view.Hide();
|
||||
}
|
||||
|
||||
@@ -102,7 +109,7 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
|
||||
private async UniTask RefreshAsync(CancellationToken ct)
|
||||
{
|
||||
var ids = _controller.VisibleIds;
|
||||
var ids = ToFixedRowGridOrder(_controller.VisibleIds);
|
||||
var vms = new List<CatalogItemVM>(ids.Count);
|
||||
|
||||
_totalPages = (int)Math.Ceiling(ids.Count / (float)ItemPerPage);
|
||||
@@ -127,6 +134,19 @@ namespace Darkmatter.Features.DrawingCatalog
|
||||
_eventBus.Publish(new DrawingCatalogReadySignal());
|
||||
}
|
||||
|
||||
private static List<string> ToFixedRowGridOrder(IReadOnlyList<string> ids)
|
||||
{
|
||||
var ordered = new List<string>(ids.Count);
|
||||
|
||||
for (var row = 0; row < RowCount; row++)
|
||||
{
|
||||
for (var index = row; index < ids.Count; index += RowCount)
|
||||
ordered.Add(ids[index]);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_view.OnItemClicked -= OnItemClicked;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
@@ -48,7 +49,7 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
|
||||
_byId[t.Id] = t;
|
||||
}
|
||||
|
||||
_allIds.Sort();
|
||||
SortIds();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
@@ -84,12 +85,22 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
|
||||
if (!_allIds.Contains(id))
|
||||
{
|
||||
_allIds.Add(id);
|
||||
_allIds.Sort();
|
||||
SortIds();
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
// Easiest drawings first; ties broken by id so the order is stable.
|
||||
private void SortIds()
|
||||
{
|
||||
_allIds.Sort((a, b) =>
|
||||
{
|
||||
var byDifficulty = _byId[a].Difficulty.CompareTo(_byId[b].Difficulty);
|
||||
return byDifficulty != 0 ? byDifficulty : string.Compare(a, b, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
public void ReleaseAll()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core;
|
||||
using Darkmatter.Core.Contracts.Features.Capture;
|
||||
using Darkmatter.Core.Contracts.Features.Coloring;
|
||||
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
|
||||
@@ -28,6 +29,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
public sealed class GameplayFlowController : IAsyncStartable, IGameplayFlowController, IDisposable
|
||||
{
|
||||
private const int AutosaveDebounceMs = 500;
|
||||
private const float ThumbnailMinIntervalSec = 30f;
|
||||
|
||||
private readonly IProgressionSystem _progression;
|
||||
private readonly IDrawingTemplateCatalog _catalog;
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
Assets/Darkmatter/Content/.DS_Store
vendored
BIN
Assets/Darkmatter/Content/.DS_Store
vendored
Binary file not shown.
BIN
Assets/Darkmatter/Content/Colorbook UI/.DS_Store
vendored
BIN
Assets/Darkmatter/Content/Colorbook UI/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
@@ -21,7 +21,7 @@ TextureImporter:
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 1
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
|
||||
@@ -21,7 +21,7 @@ TextureImporter:
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 1
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
|
||||
@@ -21,7 +21,7 @@ TextureImporter:
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 1
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
|
||||
@@ -21,7 +21,7 @@ TextureImporter:
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 1
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
|
||||
@@ -21,7 +21,7 @@ TextureImporter:
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 1
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
|
||||
@@ -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
@@ -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
BIN
Assets/FacebookSDK/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/Firebase/.DS_Store
vendored
Normal file
BIN
Assets/Firebase/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/Packages/.DS_Store
vendored
Normal file
BIN
Assets/Packages/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/Plugins/.DS_Store
vendored
Normal file
BIN
Assets/Plugins/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/Spine/.DS_Store
vendored
Normal file
BIN
Assets/Spine/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/TextMesh Pro/.DS_Store
vendored
Normal file
BIN
Assets/TextMesh Pro/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -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
8
Assets/_Recovery.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f8a91ab8cce424696bd7b4cbbac110fe
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
4042
Assets/_Recovery/0.unity
Normal file
4042
Assets/_Recovery/0.unity
Normal file
File diff suppressed because it is too large
Load Diff
7
Assets/_Recovery/0.unity.meta
Normal file
7
Assets/_Recovery/0.unity.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90078c122328b4902a1e67255b30525a
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user