Compare commits

..

6 Commits

Author SHA1 Message Date
5189257935 Merge pull request 'savya' (#14) from savya into main
Reviewed-on: #14
2026-07-07 09:55:44 +02:00
Savya Bikram Shah
e167af11fd Fixes optimization and UI/UX updates 2026-07-07 13:05:48 +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
66 changed files with 4801 additions and 3341 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

@@ -6,9 +6,12 @@ namespace Darkmatter.Core.Contracts.Features.DrawingCatalog
{
public interface IDrawingTemplateCatalog
{
UniTask FetchAsync();
UniTask FetchAsync();
IReadOnlyList<string> AllTemplateIds { get; }
UniTask<Sprite> GetThumbnailAsync(string id);
// owned = true when the sprite (and its texture) were created for this call from a
// saved thumbnail on disk — the caller must Destroy() both when done. owned = false
// means the template's default thumbnail asset, which must NOT be destroyed.
UniTask<(Sprite sprite, bool owned)> GetThumbnailAsync(string id);
UniTask<IDrawingTemplate> LoadAsync(string id);
void ReleaseAll();
string GetNextTemplate(string currentId);

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

@@ -6,7 +6,8 @@
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
"GUID:b4c9f7fbf1e144933a1797dc208ece5f",
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
"GUID:f51ebe6a0ceec4240a699833d6309b23"
"GUID:f51ebe6a0ceec4240a699833d6309b23",
"GUID:75469ad4d38634e559750d17036d5f7c"
],
"includePlatforms": [],
"excludePlatforms": [],

View File

@@ -0,0 +1,48 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Rendering;
using VContainer.Unity;
namespace Darkmatter.Features.AppBoot.Flow
{
// A coloring page is static most of the time; re-rendering it at a locked 60 fps only
// turns battery into heat. Keep the player loop at 60 Hz (input still sampled every
// frame, so the first touch never lands on a skipped frame) but render every other
// frame while nothing has been touched for a few seconds.
public sealed class AdaptiveFrameRate : ITickable
{
private const float FullRateHoldSeconds = 3f;
private const int IdleRenderInterval = 2; // 60 Hz loop -> 30 fps rendering when idle
private float _lastInteractionTime;
public AdaptiveFrameRate()
{
// Treat boot as an interaction so the intro video starts at full rate.
_lastInteractionTime = Time.unscaledTime;
}
public void Tick()
{
if (IsPointerPressed())
_lastInteractionTime = Time.unscaledTime;
var desired = Time.unscaledTime - _lastInteractionTime < FullRateHoldSeconds
? 1
: IdleRenderInterval;
if (OnDemandRendering.renderFrameInterval != desired)
OnDemandRendering.renderFrameInterval = desired;
}
private static bool IsPointerPressed()
{
var touchscreen = Touchscreen.current;
if (touchscreen != null && touchscreen.primaryTouch.press.isPressed)
return true;
var mouse = Mouse.current;
return mouse != null && mouse.leftButton.isPressed;
}
}
}

View File

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

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

@@ -16,6 +16,7 @@ namespace Darkmatter.Features.AppBoot.Installers
if (sceneRefs != null)
builder.RegisterComponent(sceneRefs);
builder.RegisterEntryPoint<AppBootFlow>();
builder.RegisterEntryPoint<AdaptiveFrameRate>();
}
}
}

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

@@ -1,5 +1,6 @@
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
namespace Darkmatter.Features.Coloring.Systems
@@ -8,6 +9,7 @@ namespace Darkmatter.Features.Coloring.Systems
public sealed class ColoringPinchZoomController : MonoBehaviour
{
private const float MinimumPinchDistance = 8f;
private const float PanZoomEpsilon = 0.0001f;
[SerializeField] private float minZoom = 1f;
[SerializeField] private float maxZoom = 3f;
@@ -22,6 +24,7 @@ namespace Darkmatter.Features.Coloring.Systems
private float _currentZoom = 1f;
private Vector3 _baseScale = Vector3.one;
private Vector2 _baseAnchoredPosition;
private PanDragRelay _panRelay;
public void SetTarget(RectTransform target)
{
@@ -34,6 +37,26 @@ namespace Darkmatter.Features.Coloring.Systems
_baseAnchoredPosition = target != null ? target.anchoredPosition : Vector2.zero;
_currentZoom = 1f;
enabled = _target != null && _parent != null;
_panRelay = null;
if (_target != null)
{
_panRelay = _target.gameObject.GetComponent<PanDragRelay>();
if (_panRelay == null)
_panRelay = _target.gameObject.AddComponent<PanDragRelay>();
_panRelay.Controller = this;
}
UpdatePanRelayState();
}
// While not zoomed the relay is disabled so the event system never resolves it as a
// drag target — sloppy kid taps keep registering as clicks (paints) exactly as before.
// Zoomed in, drags become pans and are suppressed as clicks by uGUI.
private void UpdatePanRelayState()
{
if (_panRelay != null)
_panRelay.enabled = _currentZoom > 1f + PanZoomEpsilon;
}
public void ResetZoom()
@@ -46,6 +69,7 @@ namespace Darkmatter.Features.Coloring.Systems
_currentZoom = 1f;
_isPinching = false;
UpdatePanRelayState();
}
public IDisposable UseNonZoomedView()
@@ -78,6 +102,7 @@ namespace Darkmatter.Features.Coloring.Systems
_isPinching = false;
TryApplyScrollZoom();
TryApplyMousePan();
}
private bool TryApplyPinchZoom()
@@ -111,8 +136,16 @@ namespace Darkmatter.Features.Coloring.Systems
return true;
}
// Anchor the content point that was under the previous midpoint to the current
// midpoint: distance change zooms, midpoint movement pans — one gesture does both.
Vector2 previousMidpoint = (firstTouch.PreviousPosition + secondTouch.PreviousPosition) * 0.5f;
if (!TryGetParentLocalPoint(previousMidpoint, out Vector2 previousMidpointLocal))
{
return true;
}
float targetZoom = Mathf.Clamp(_currentZoom * (distance / previousDistance), minZoom, maxZoom);
ZoomTowards(midpointLocal, targetZoom);
ApplyZoomPan(previousMidpointLocal, midpointLocal, targetZoom);
return true;
}
@@ -152,12 +185,123 @@ namespace Darkmatter.Features.Coloring.Systems
return;
}
Vector2 contentPoint = (parentLocalPoint - _target.anchoredPosition) / _currentZoom;
ApplyZoomPan(parentLocalPoint, parentLocalPoint, targetZoom);
}
private void ApplyZoomPan(Vector2 previousLocalPoint, Vector2 currentLocalPoint, float targetZoom)
{
Vector2 contentPoint = (previousLocalPoint - _target.anchoredPosition) / _currentZoom;
_currentZoom = targetZoom;
_target.localScale = _baseScale * _currentZoom;
_target.anchoredPosition = Mathf.Approximately(_currentZoom, minZoom)
? _baseAnchoredPosition
: parentLocalPoint - contentPoint * _currentZoom;
_target.anchoredPosition = ClampPan(currentLocalPoint - contentPoint * _currentZoom);
UpdatePanRelayState();
}
// Editor/desktop convenience: drag with the right or middle mouse button to pan while zoomed.
private void TryApplyMousePan()
{
Mouse mouse = Mouse.current;
if (mouse == null || (!mouse.rightButton.isPressed && !mouse.middleButton.isPressed))
{
return;
}
if (_currentZoom <= 1f + PanZoomEpsilon)
{
return;
}
Vector2 delta = mouse.delta.ReadValue();
if (delta == Vector2.zero)
{
return;
}
Vector2 screenPosition = mouse.position.ReadValue();
if (!IsInputPointAllowed(screenPosition))
{
return;
}
if (!TryGetParentLocalPoint(screenPosition, out Vector2 currentLocal) ||
!TryGetParentLocalPoint(screenPosition - delta, out Vector2 previousLocal))
{
return;
}
_target.anchoredPosition = ClampPan(_target.anchoredPosition + (currentLocal - previousLocal));
}
// Single-finger (or left-mouse) drag pan, fed by PanDragRelay on the paper root.
// Going through uGUI's drag pipeline matters: once the drag threshold is crossed the
// event system cancels the pending click, so a pan can never paint the region the
// finger ends on — a plain tap still paints.
private void OnPanDrag(PointerEventData eventData, Transform source)
{
if (_target == null || source != _target.transform)
{
return;
}
if (_isPinching || HasMultiTouch())
{
return; // two-finger gesture owns the view
}
if (_currentZoom <= 1f + PanZoomEpsilon)
{
return;
}
if (!TryGetParentLocalPoint(eventData.position, out Vector2 currentLocal) ||
!TryGetParentLocalPoint(eventData.position - eventData.delta, out Vector2 previousLocal))
{
return;
}
_target.anchoredPosition = ClampPan(_target.anchoredPosition + (currentLocal - previousLocal));
}
private static bool HasMultiTouch()
{
Touchscreen touchscreen = Touchscreen.current;
if (touchscreen == null)
{
return false;
}
int count = 0;
foreach (var touchControl in touchscreen.touches)
{
if (touchControl.press.isPressed && ++count > 1)
{
return true;
}
}
return false;
}
// The scaled paper must keep covering its zoom-1 footprint: panning can never expose
// background inside the area the drawing occupies when not zoomed.
private Vector2 ClampPan(Vector2 anchoredPosition)
{
if (_currentZoom <= 1f + PanZoomEpsilon)
{
return _baseAnchoredPosition;
}
Rect rect = _target.rect;
float growth = 1f - _currentZoom; // negative while zoomed in
float minX = _baseAnchoredPosition.x + rect.xMax * _baseScale.x * growth;
float maxX = _baseAnchoredPosition.x + rect.xMin * _baseScale.x * growth;
float minY = _baseAnchoredPosition.y + rect.yMax * _baseScale.y * growth;
float maxY = _baseAnchoredPosition.y + rect.yMin * _baseScale.y * growth;
return new Vector2(
Mathf.Clamp(anchoredPosition.x, minX, maxX),
Mathf.Clamp(anchoredPosition.y, minY, maxY));
}
private bool TryGetParentLocalPoint(Vector2 screenPoint, out Vector2 localPoint)
@@ -233,6 +377,7 @@ namespace Darkmatter.Features.Coloring.Systems
_target.anchoredPosition = state.AnchoredPosition;
_currentZoom = state.CurrentZoom;
_isPinching = false;
UpdatePanRelayState();
}
private readonly struct ViewState
@@ -285,5 +430,25 @@ namespace Darkmatter.Features.Coloring.Systems
Delta = delta;
}
}
// Runtime-attached to the paper root (SetTarget); regions are its children, so drags
// that start on a region bubble up here while taps stay clicks. Implementing the uGUI
// drag interfaces is what makes the event system suppress the region click on drag.
private sealed class PanDragRelay : MonoBehaviour, IBeginDragHandler, IDragHandler
{
internal ColoringPinchZoomController Controller;
public void OnBeginDrag(PointerEventData eventData)
{
}
public void OnDrag(PointerEventData eventData)
{
if (Controller != null)
{
Controller.OnPanDrag(eventData, transform);
}
}
}
}
}

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;
@@ -24,6 +25,12 @@ namespace Darkmatter.Features.DrawingCatalog
private readonly CancellationTokenSource _cts = new();
private IDisposable _openSubscription;
// Sprites/textures decoded from saved thumbnails are created fresh on every refresh
// and are owned by this presenter — without destroying the previous batch each
// Colorbook visit leaks several MB per painted drawing until the OS kills the app.
private readonly List<Sprite> _ownedSprites = new();
private readonly List<Texture2D> _ownedTextures = new();
public DrawingCatalogPresenter(DrawingCatalogView view, IDrawingCatalogController controller,
IDrawingTemplateCatalog catalog, IEventBus eventBus)
{
@@ -48,8 +55,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 +97,7 @@ namespace Darkmatter.Features.DrawingCatalog
private void OnArtBookBtnClicked()
{
_eventBus.Publish(new OpenArtBookSignal(()=> _view.Show()));
_eventBus.Publish(new OpenArtBookSignal(ShowCatalog));
_view.Hide();
}
@@ -102,29 +115,86 @@ namespace Darkmatter.Features.DrawingCatalog
private async UniTask RefreshAsync(CancellationToken ct)
{
var ids = _controller.VisibleIds;
var vms = new List<CatalogItemVM>(ids.Count);
var newSprites = new List<Sprite>();
var newTextures = new List<Texture2D>();
var applied = false;
_totalPages = (int)Math.Ceiling(ids.Count / (float)ItemPerPage);
_currentPage = 0;
foreach (var id in ids)
try
{
var ids = ToFixedRowGridOrder(_controller.VisibleIds);
var vms = new List<CatalogItemVM>(ids.Count);
_totalPages = (int)Math.Ceiling(ids.Count / (float)ItemPerPage);
_currentPage = 0;
foreach (var id in ids)
{
if (ct.IsCancellationRequested) return;
var (thumb, owned) = await _catalog.GetThumbnailAsync(id);
if (owned)
{
newSprites.Add(thumb);
newTextures.Add(thumb.texture);
}
vms.Add(new CatalogItemVM(id, thumb));
}
if (ct.IsCancellationRequested) return;
var thumb = await _catalog.GetThumbnailAsync(id);
vms.Add(new CatalogItemVM(id, thumb));
_view.ResetScrollPosition();
_view.SetItems(vms);
_view.SetPagination(_currentPage, _totalPages);
// Buttons now reference the new batch; the previous one can go.
DestroyOwnedThumbnails();
_ownedSprites.AddRange(newSprites);
_ownedTextures.AddRange(newTextures);
applied = true;
// 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());
}
catch (Exception e)
{
Debug.LogError($"[DrawingCatalog] Failed to refresh catalog: {e}");
}
finally
{
// Aborted refresh (cancelled or threw): don't strand the freshly decoded batch.
if (!applied)
{
foreach (var s in newSprites) UnityEngine.Object.Destroy(s);
foreach (var t in newTextures) UnityEngine.Object.Destroy(t);
}
// Idempotent — always unblock the flow; a stuck loading screen is worse than
// an empty catalog.
_controller.NotifyPopulated();
}
}
private void DestroyOwnedThumbnails()
{
foreach (var s in _ownedSprites) UnityEngine.Object.Destroy(s);
foreach (var t in _ownedTextures) UnityEngine.Object.Destroy(t);
_ownedSprites.Clear();
_ownedTextures.Clear();
}
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]);
}
if (ct.IsCancellationRequested) return;
_view.ResetScrollPosition();
_view.SetItems(vms);
_view.SetPagination(_currentPage, _totalPages);
// 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());
return ordered;
}
public void Dispose()
@@ -140,6 +210,7 @@ namespace Darkmatter.Features.DrawingCatalog
_openSubscription?.Dispose();
_cts.Cancel();
_cts.Dispose();
DestroyOwnedThumbnails();
}
}
}
}

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,11 +49,11 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
_byId[t.Id] = t;
}
_allIds.Sort();
SortIds();
_initialized = true;
}
public async UniTask<Sprite> GetThumbnailAsync(string id)
public async UniTask<(Sprite sprite, bool owned)> GetThumbnailAsync(string id)
{
if (!_byId.TryGetValue(id, out var t))
throw new KeyNotFoundException($"Template '{id}' not in catalog. Did InitializeAsync run?");
@@ -61,9 +62,9 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
if (savedTex != null)
{
var rect = new Rect(0, 0, savedTex.width, savedTex.height);
return Sprite.Create(savedTex, rect, new Vector2(0.5f, 0.5f));
return (Sprite.Create(savedTex, rect, new Vector2(0.5f, 0.5f)), true);
}
return t.DefaultThumbnail;
return (t.DefaultThumbnail, false);
}
public UniTask<IDrawingTemplate> LoadAsync(string id)
@@ -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;
@@ -76,6 +80,27 @@ namespace Darkmatter.Features.GameplayFlow.Systems
}
public async UniTask StartAsync(CancellationToken cancellation)
{
try
{
await StartCoreAsync(cancellation);
}
catch (OperationCanceledException)
{
// Scene torn down mid-init; nothing to recover.
}
catch (Exception e)
{
// Any throw here (missing template id, failed Addressables load, region init)
// would otherwise skip _loadingScreen.Hide() and strand the kid on a frozen
// loader forever. Log it and route back to the Colorbook, whose flow hides the
// loading screen once its catalog is populated.
Debug.LogError($"[GameplayFlow] Failed to start gameplay, returning to Colorbook: {e}");
await RecoverToColorbookAsync();
}
}
private async UniTask StartCoreAsync(CancellationToken cancellation)
{
_scopeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var ct = _scopeCts.Token;
@@ -104,6 +129,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)
@@ -119,6 +148,22 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_loadingScreen.Hide();
}
private async UniTask RecoverToColorbookAsync()
{
try
{
_shapeBuilder.Clear();
_coloring.Clear();
await _scenes.LoadSceneAsync(nameof(GameScene.Colorbook), progress: null, cancellationToken: default);
await _scenes.UnloadSceneAsync(nameof(GameScene.Gameplay), progress: null, cancellationToken: default);
}
catch (Exception e)
{
Debug.LogError($"[GameplayFlow] Recovery to Colorbook failed: {e}");
_loadingScreen.Hide();
}
}
public async UniTask BackAsync(CancellationToken ct)
{
await SaveCurrentAsync(CancellationToken.None);
@@ -131,8 +176,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 +256,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 +284,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 +298,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 +322,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 +346,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_assembledSub?.Dispose();
_colorAppliedSub?.Dispose();
_drawingSelectedSub?.Dispose();
_openColorbookSub?.Dispose();
_autosaveCts?.Cancel();
_autosaveCts?.Dispose();
_scopeCts?.Cancel();

View File

@@ -13,6 +13,7 @@ namespace Darkmatter.Features.Mainmenu
private readonly IEventBus _eventBus;
private readonly MainMenuView _view;
private readonly ISfxPlayer _sfxPlayer;
private IDisposable _introSubscription;
public MainMenuPresenter(MainMenuView view, IEventBus eventBus, ISfxPlayer sfxPlayer)
@@ -26,7 +27,7 @@ namespace Darkmatter.Features.Mainmenu
{
_view.OnPlayBtnPressedEvent += OnPlayBtnPressed;
_view.OnPlayBtnClickedEvent += OnPlayBtnClicked;
_eventBus.Subscribe<IntroCompletedSignal>(OnIntroComplete);
_introSubscription = _eventBus.Subscribe<IntroCompletedSignal>(OnIntroComplete);
}
private void OnIntroComplete(IntroCompletedSignal signal)
@@ -48,6 +49,7 @@ namespace Darkmatter.Features.Mainmenu
{
_view.OnPlayBtnPressedEvent -= OnPlayBtnPressed;
_view.OnPlayBtnClickedEvent -= OnPlayBtnClicked;
_introSubscription?.Dispose();
}
}
}

View File

@@ -86,16 +86,27 @@ public sealed class ProgressionRepository
{
if (string.IsNullOrEmpty(templateId) || png == null || png.Length == 0) return;
Directory.CreateDirectory(ThumbnailDirectory());
var path = ThumbnailPath(templateId);
var tmp = path + ".tmp";
await UniTask.RunOnThreadPool(() =>
// Never let thumbnail IO (disk full, permission churn) throw into the save path:
// saves run before every Back/Next navigation, and an unhandled IOException there
// aborts the scene transition and traps the user in gameplay. Losing a thumbnail
// is fine — the colors themselves persist via PlayerPrefs.
try
{
File.WriteAllBytes(tmp, png);
if (File.Exists(path)) File.Delete(path);
File.Move(tmp, path);
});
Directory.CreateDirectory(ThumbnailDirectory());
var path = ThumbnailPath(templateId);
var tmp = path + ".tmp";
await UniTask.RunOnThreadPool(() =>
{
File.WriteAllBytes(tmp, png);
if (File.Exists(path)) File.Delete(path);
File.Move(tmp, path);
});
}
catch (Exception e)
{
Debug.LogWarning($"[Progression] Failed writing thumbnail '{templateId}': {e.Message}");
}
}
public async UniTask<Texture2D> LoadThumbnailAsync(string templateId)

View File

@@ -15,7 +15,23 @@ namespace Darkmatter.Libs.Observer
if (_map.TryGetValue(typeof(T), out var handlerObj))
{
var action = (Action<T>)handlerObj;
action?.Invoke(evt);
if (action == null) return;
// Invoke handlers one by one: a multicast invoke would let one throwing
// subscriber silently starve every later subscriber and rethrow into the
// publisher (paint / scene-transition code), turning a local bug into a
// stuck flow.
foreach (var handler in action.GetInvocationList())
{
try
{
((Action<T>)handler).Invoke(evt);
}
catch (Exception e)
{
UnityEngine.Debug.LogException(e);
}
}
}
}

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

@@ -350,7 +350,9 @@ namespace Darkmatter.Services.Audio
if (!source.gameObject.activeInHierarchy) source.gameObject.SetActive(true);
source.Play();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.Log($"[AudioService] Play clip='{(request.Clip != null ? request.Clip.name : "null")}' ch={request.Channel} vol={source.volume:F2} mixer={(source.outputAudioMixerGroup != null ? source.outputAudioMixerGroup.name : "none")} listener={(AudioListener.volume)} muted={AudioListener.pause} isPlaying={source.isPlaying}");
#endif
if (IsChannelPaused(request.Channel))
{
source.Pause();

View File

@@ -68,54 +68,58 @@ 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();
RenderTexture.active = prevActive;
captureViewScope?.Dispose();
captureViewScope = null;
// 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);
try
{
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;
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);
RenderTexture.active = prevActive;
Object.Destroy(cropped);
}
}
finally
{
paperCanvas.renderMode = prevMode;
paperCanvas.worldCamera = prevWorldCam;
paperCanvas.planeDistance = prevPlaneDist;
cam.targetTexture = prevTarget;
cam.clearFlags = prevFlags;
cam.backgroundColor = prevBg;
// The awaits above give scene teardown (autosave racing an unload) a chance to
// destroy the canvas or camera; touching them then would throw from finally and
// skip the rest of the restore.
if (paperCanvas != null)
{
paperCanvas.renderMode = prevMode;
paperCanvas.worldCamera = prevWorldCam;
paperCanvas.planeDistance = prevPlaneDist;
}
if (cam != null)
{
cam.targetTexture = prevTarget;
cam.clearFlags = prevFlags;
cam.backgroundColor = prevBg;
}
RenderTexture.ReleaseTemporary(rt);
if (disabledGraphics != null)
foreach (var g in disabledGraphics)

View File

@@ -118,8 +118,17 @@ namespace Darkmatter.Services.Gallery
GL.PopMatrix();
var output = new Texture2D(bgW, bgH, TextureFormat.RGBA32, mipChain: false);
output.ReadPixels(new Rect(0, 0, bgW, bgH), 0, 0);
output.Apply();
try
{
output.ReadPixels(new Rect(0, 0, bgW, bgH), 0, 0);
output.Apply();
}
catch
{
UnityEngine.Object.Destroy(output);
throw;
}
return output;
}
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

File diff suppressed because one or more lines are too long

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}

View File

@@ -23,7 +23,7 @@ MonoBehaviour:
m_RequireOpaqueTexture: 0
m_OpaqueDownsampling: 1
m_SupportsTerrainHoles: 1
m_SupportsHDR: 1
m_SupportsHDR: 0
m_HDRColorBufferPrecision: 0
m_MSAA: 1
m_RenderScale: 1
@@ -42,7 +42,7 @@ MonoBehaviour:
m_SupportProbeVolumeScenarioBlending: 0
m_ProbeVolumeSHBands: 1
m_MainLightRenderingMode: 1
m_MainLightShadowsSupported: 1
m_MainLightShadowsSupported: 0
m_MainLightShadowmapResolution: 2048
m_AdditionalLightsRenderingMode: 1
m_AdditionalLightsPerObjectLimit: 4

View File

@@ -977,8 +977,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 118.53375, y: -50.553192}
m_SizeDelta: {x: -305.9775, y: -178.6848}
m_AnchoredPosition: {x: 149.13885, y: -50.553192}
m_SizeDelta: {x: -336.5826, y: -178.6848}
m_Pivot: {x: 0, y: 1}
--- !u!114 &1157581244
MonoBehaviour:
@@ -1743,7 +1743,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.GridLayoutGroup
m_Padding:
m_Left: 20
m_Left: 0
m_Right: 0
m_Top: 0
m_Bottom: 0

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.

View File

@@ -1 +1 @@
<?xml version="1.0" encoding="utf-8"?><resources><string name="com.crashlytics.android.build_id" translatable="false">8fc48ed6-41de-4dcf-a980-9aa3316fad11</string></resources>
<?xml version="1.0" encoding="utf-8"?><resources><string name="com.crashlytics.android.build_id" translatable="false">b468c378-10c7-4fe5-a4e9-6ae4bf5078cd</string></resources>

View File

@@ -13,4 +13,4 @@ MonoBehaviour:
m_Name: SmartlookSettings
m_EditorClassIdentifier:
ProjectKey: 3bcfb098c5c4f6f1f48d8bbef4c0c94d7c2f9bc9
FPS: 30
FPS: 2

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

@@ -11,7 +11,7 @@ PlayerSettings:
defaultScreenOrientation: 4
targetDevice: 2
useOnDemandResources: 0
accelerometerFrequency: 60
accelerometerFrequency: 0
companyName: Darkmatter Game Production
productName: Kidsage Colorbook
defaultCursor: {fileID: 0}
@@ -148,7 +148,7 @@ PlayerSettings:
loadStoreDebugModeEnabled: 0
visionOSBundleVersion: 1.0
tvOSBundleVersion: 1.0
bundleVersion: 1.9
bundleVersion: 2.1
preloadedAssets:
- {fileID: 11400000, guid: cc969dbecb228fa49b16da9273753a8f, type: 2}
- {fileID: -944628639613478452, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3}
@@ -180,7 +180,7 @@ PlayerSettings:
iPhone: 1
tvOS: 0
overrideDefaultApplicationIdentifier: 1
AndroidBundleVersionCode: 9
AndroidBundleVersionCode: 11
AndroidMinSdkVersion: 26
AndroidTargetSdkVersion: 0
AndroidPreferredInstallLocation: 1