Compare commits

...

10 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
Savya Bikram Shah
b28e1f637d Crash fixes 2026-06-26 18:18:49 +05:45
Savya Bikram Shah
21e5206626 Merge branch 'work_branch' of https://git.darkmatterproduction.org/savya/Colorbook into savya 2026-06-26 17:54:41 +05:45
Savya Bikram Shah
fc291cf116 Merge branch 'work_branch' of https://git.darkmatterproduction.org/savya/Colorbook into savya
# Conflicts:
#	Assets/Darkmatter/Content/Fonts/static/Fredoka-SemiBold SDF.asset
2026-06-26 16:19:08 +05:45
Savya Bikram Shah
7446e49c8f zooming for easy coloring 2026-06-26 16:18:39 +05:45
77 changed files with 5450 additions and 2579 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

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
@@ -20,6 +21,7 @@ public interface IColoringController
bool IsPlayingCompletionAnimation { get; } bool IsPlayingCompletionAnimation { get; }
bool HasNonAuthoredColors { get; } bool HasNonAuthoredColors { get; }
IDisposable UseNonZoomedView();
void ResetAll(); void ResetAll();
void Clear(); void Clear();
} }

View File

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

View File

@@ -8,7 +8,10 @@ namespace Darkmatter.Core.Contracts.Features.DrawingCatalog
{ {
UniTask FetchAsync(); UniTask FetchAsync();
IReadOnlyList<string> AllTemplateIds { get; } 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); UniTask<IDrawingTemplate> LoadAsync(string id);
void ReleaseAll(); void ReleaseAll();
string GetNextTemplate(string currentId); string GetNextTemplate(string currentId);

View File

@@ -11,9 +11,9 @@ namespace Darkmatter.Core.Contracts.Services.Analytics
// Onboarding / activation funnel // Onboarding / activation funnel
public const string IntroStarted = "intro_started"; public const string IntroStarted = "intro_started";
public const string IntroCompleted = "intro_completed"; 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 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 PlayClicked = "play_clicked";
public const string FirstDrawingStarted = "first_drawing_started"; 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 ShapeAssembled = "shape_assembled";
public const string ColorApplied = "color_applied"; 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 LevelStart = "level_start";
public const string LevelComplete = "level_complete"; public const string LevelComplete = "level_end";
public const string AllContentCompleted = "all_content_completed"; public const string AllContentCompleted = "all_content_completed";
// Gallery capture (pre-existing) // 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 /// 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 /// 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. /// 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. /// (level_name, success, value, currency, ad_platform, ad_format, ad_unit_name) so they're recognised.
/// </summary> /// </summary>
public static class AnalyticsParams public static class AnalyticsParams
{ {
// Content identity // Content identity
public const string DrawingId = "drawing_id"; 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 // Tutorial
public const string StepId = "step_id"; public const string StepId = "step_id";
@@ -23,7 +23,7 @@ namespace Darkmatter.Core.Contracts.Services.Analytics
public const string Attempts = "attempts"; public const string Attempts = "attempts";
public const string ApplyIndex = "apply_index"; public const string ApplyIndex = "apply_index";
public const string CompletionCount = "completion_count"; 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) // Monetization (GA4 ad_impression reserved names)
public const string Value = "value"; public const string Value = "value";

View File

@@ -1,3 +1,4 @@
using System;
using System.Threading; using System.Threading;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
using UnityEngine; using UnityEngine;
@@ -7,6 +8,6 @@ namespace Darkmatter.Core.Contracts.Services.Capture
public interface ICaptureService public interface ICaptureService
{ {
UniTask<byte[]> CapturePngAsync(GameObject captureObject, float scale, UniTask<byte[]> CapturePngAsync(GameObject captureObject, float scale,
CancellationToken cancellationToken = default); Func<IDisposable> captureViewScopeFactory = null, CancellationToken cancellationToken = default);
} }
} }

View File

@@ -1,4 +1,8 @@
namespace Darkmatter.Core.Data.Signals.Features.Capture 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 id;
[SerializeField] private string displayName; [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 Sprite defaultThumbnail;
[SerializeField] private GameObject drawingPrefab; [SerializeField] private GameObject drawingPrefab;
[SerializeField] private GameObject coloringPrefab; [SerializeField] private GameObject coloringPrefab;
@@ -22,6 +24,11 @@ namespace Darkmatter.Core.Data.Static.Features.DrawingTemplate
public string Id => id; public string Id => id;
public string DisplayName => displayName; 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 Sprite DefaultThumbnail => defaultThumbnail;
public GameObject DrawingPrefab => drawingPrefab; public GameObject DrawingPrefab => drawingPrefab;
public GameObject ColoringPrefab => coloringPrefab; public GameObject ColoringPrefab => coloringPrefab;

View File

@@ -6,7 +6,8 @@
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1", "GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
"GUID:b4c9f7fbf1e144933a1797dc208ece5f", "GUID:b4c9f7fbf1e144933a1797dc208ece5f",
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc", "GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
"GUID:f51ebe6a0ceec4240a699833d6309b23" "GUID:f51ebe6a0ceec4240a699833d6309b23",
"GUID:75469ad4d38634e559750d17036d5f7c"
], ],
"includePlatforms": [], "includePlatforms": [],
"excludePlatforms": [], "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 System.Threading;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
using Darkmatter.Core.Contracts.Features.Progression; using Darkmatter.Core.Contracts.Features.Progression;
@@ -14,6 +15,10 @@ namespace Darkmatter.Features.AppBoot.Flow
{ {
public class AppBootFlow : IAsyncStartable 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 AppBootSceneRefs _sceneRefs;
private readonly ISceneService _sceneService; private readonly ISceneService _sceneService;
private readonly IEventBus _eventBus; private readonly IEventBus _eventBus;
@@ -30,36 +35,96 @@ namespace Darkmatter.Features.AppBoot.Flow
public async UniTask StartAsync(CancellationToken cancellation = default) 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 #if UNITY_ANDROID || UNITY_IOS
Application.targetFrameRate = 60; Application.targetFrameRate = 60;
#endif #endif
Screen.sleepTimeout = SleepTimeout.NeverSleep; Screen.sleepTimeout = SleepTimeout.NeverSleep;
await _progression.LoadAsync(); await _progression.LoadAsync();
var tcs = new UniTaskCompletionSource();
var player = _sceneRefs.IntroVideoPlayer; var player = _sceneRefs.IntroVideoPlayer;
var introTask = PlayIntroAsync(player, cancellation);
void OnDone(VideoPlayer vp)
{
vp.loopPointReached -= OnDone;
tcs.TrySetResult();
}
player.loopPointReached += OnDone;
player.Play();
_eventBus.Publish(new IntroStartedSignal());
await _sceneService.LoadSceneAsync(nameof(GameScene.MainMenu), null, 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(); player.Stop();
var rt = player.targetTexture; var rt = player.targetTexture;
if (rt != null) rt.Release(); if (rt != null) rt.Release();
UnityEngine.Object.Destroy(player.gameObject);
if (_sceneRefs.IntroCanvas != null)
Object.Destroy(_sceneRefs.IntroCanvas.gameObject);
Object.Destroy(player.gameObject);
_eventBus.Publish(new IntroCompletedSignal());
} }
} }
} }

View File

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

View File

@@ -1,7 +1,6 @@
using System; using System;
using UnityEngine;
namespace Darkmatter.Features.Artbook namespace Darkmatter.Features.Artbook
{ {
public record struct ArtbookEntry(string Id, string Name, Texture2D Thumbnail, DateTime UpdatedUtc); public record struct ArtbookEntry(string Id, string Name, DateTime UpdatedUtc);
} }

View File

@@ -7,6 +7,7 @@ using Darkmatter.Core.Contracts.Features.DrawingCatalog;
using Darkmatter.Core.Contracts.Features.Progression; using Darkmatter.Core.Contracts.Features.Progression;
using Darkmatter.Core.Contracts.Features.SaveGate; using Darkmatter.Core.Contracts.Features.SaveGate;
using Darkmatter.Core.Contracts.Services.Gallery; using Darkmatter.Core.Contracts.Services.Gallery;
using Darkmatter.Core.Data.Signals.Features.Capture;
using Darkmatter.Core.Data.Signals.Features.Drawing; using Darkmatter.Core.Data.Signals.Features.Drawing;
using Darkmatter.Libs.Observer; using Darkmatter.Libs.Observer;
using UnityEngine; using UnityEngine;
@@ -26,12 +27,13 @@ namespace Darkmatter.Features.Artbook
private readonly IRewardedSaveGate _saveGate; private readonly IRewardedSaveGate _saveGate;
private readonly List<ArtbookEntry> _entries = new(); private readonly List<ArtbookEntry> _entries = new();
private readonly List<Sprite> _ownedSprites = new(); private readonly List<Sprite> _visibleSprites = new();
private readonly List<Texture2D> _ownedTextures = new(); private readonly List<Texture2D> _visibleTextures = new();
private Action _onClose; private Action _onClose;
private CancellationTokenSource _cts; private CancellationTokenSource _cts;
private IDisposable _openSubscription; private IDisposable _openSubscription;
private int _currentSpread; private int _currentSpread;
private int _renderVersion;
public ArtbookPresenter( public ArtbookPresenter(
ArtbookView view, ArtbookView view,
@@ -75,33 +77,40 @@ namespace Darkmatter.Features.Artbook
private async UniTaskVoid LoadAndShowAsync(CancellationToken ct) private async UniTaskVoid LoadAndShowAsync(CancellationToken ct)
{ {
ClearOwnedAssets(); try
_entries.Clear();
_currentSpread = 0;
await _catalog.FetchAsync();
if (ct.IsCancellationRequested) return;
foreach (var id in _catalog.AllTemplateIds)
{ {
ClearVisibleAssets();
_entries.Clear();
_currentSpread = 0;
await _catalog.FetchAsync();
if (ct.IsCancellationRequested) return; if (ct.IsCancellationRequested) return;
var progress = _progression.GetProgress(id); foreach (var id in _catalog.AllTemplateIds)
if (progress is not { hasThumbnail: true }) continue; {
if (ct.IsCancellationRequested) return;
var texture = await _progression.GetCachedThumbnailAsync(id); var progress = _progression.GetProgress(id);
if (ct.IsCancellationRequested) return; if (progress is not { hasThumbnail: true }) continue;
if (texture == null) continue;
var template = await _catalog.LoadAsync(id); var template = await _catalog.LoadAsync(id);
if (ct.IsCancellationRequested) return; if (ct.IsCancellationRequested) return;
if (template == null) continue;
_ownedTextures.Add(texture); _entries.Add(new ArtbookEntry(id, template.DisplayName, progress.Value.UpdatedUtc));
_entries.Add(new ArtbookEntry(id, template.DisplayName, texture, progress.Value.UpdatedUtc)); }
_entries.Sort((a, b) => b.UpdatedUtc.CompareTo(a.UpdatedUtc));
RenderSpread();
}
catch (OperationCanceledException)
{
// Opening/closing the art book can cancel the load at any await point.
}
catch (Exception e)
{
Debug.LogError($"[Artbook] Failed to load entries: {e}");
} }
_entries.Sort((a, b) => b.UpdatedUtc.CompareTo(a.UpdatedUtc));
RenderSpread();
} }
private void HandlePrevSpreadClicked() private void HandlePrevSpreadClicked()
@@ -122,7 +131,7 @@ namespace Darkmatter.Features.Artbook
private void RenderSpread() private void RenderSpread()
{ {
_view.SetSpread(GetLeftEntry(), SpriteFor(GetLeftEntry()), GetRightEntry(), SpriteFor(GetRightEntry())); RenderSpreadAsync(++_renderVersion, _cts?.Token ?? CancellationToken.None).Forget();
_view.SetNavigation(_currentSpread > 0, _currentSpread < TotalSpreads - 1); _view.SetNavigation(_currentSpread > 0, _currentSpread < TotalSpreads - 1);
} }
@@ -138,13 +147,51 @@ namespace Darkmatter.Features.Artbook
return idx < _entries.Count ? _entries[idx] : null; return idx < _entries.Count ? _entries[idx] : null;
} }
private Sprite SpriteFor(ArtbookEntry? entry) private async UniTaskVoid RenderSpreadAsync(int version, CancellationToken ct)
{ {
if (!entry.HasValue) return null; var left = GetLeftEntry();
var tex = entry.Value.Thumbnail; var right = GetRightEntry();
ClearVisibleAssets();
_view.SetSpread(left, null, right, null);
try
{
var leftSprite = await SpriteForAsync(left, version, ct);
var rightSprite = await SpriteForAsync(right, version, ct);
if (ct.IsCancellationRequested || version != _renderVersion)
{
return;
}
_view.SetSpread(left, leftSprite, right, rightSprite);
}
catch (OperationCanceledException)
{
if (version == _renderVersion) ClearVisibleAssets();
}
catch (Exception e)
{
if (version == _renderVersion) ClearVisibleAssets();
Debug.LogError($"[Artbook] Failed to render spread: {e}");
}
}
private async UniTask<Sprite> SpriteForAsync(ArtbookEntry? entry, int version, CancellationToken ct)
{
if (!entry.HasValue || ct.IsCancellationRequested || version != _renderVersion) return null;
var tex = await _progression.GetCachedThumbnailAsync(entry.Value.Id);
if (ct.IsCancellationRequested || version != _renderVersion)
{
if (tex != null) UnityEngine.Object.Destroy(tex);
return null;
}
if (tex == null) return null; if (tex == null) return null;
var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f); var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f);
_ownedSprites.Add(sprite); _visibleTextures.Add(tex);
_visibleSprites.Add(sprite);
return sprite; return sprite;
} }
@@ -153,13 +200,45 @@ namespace Darkmatter.Features.Artbook
private async UniTaskVoid SaveToGalleryAsync(ArtbookEntry? entry) private async UniTaskVoid SaveToGalleryAsync(ArtbookEntry? entry)
{ {
if (!entry.HasValue || entry.Value.Thumbnail == null) return; Texture2D texture = null;
var ct = _cts?.Token ?? CancellationToken.None; try
// Same kid-friendly prompt + rewarded ad as the gameplay save button. {
if (!await _saveGate.RequestSaveAsync(ct)) return; if (!entry.HasValue) return;
await _gallery.SaveImageAsync(entry.Value.Thumbnail, entry.Value.Name, ct); var ct = _cts?.Token ?? CancellationToken.None;
// The art book has no success popup of its own, so use the shared toast. // Same kid-friendly prompt + rewarded ad as the gameplay save button.
await _saveGate.ShowSavedAsync(ct); if (!await _saveGate.RequestSaveAsync(ct)) return;
texture = await _progression.GetCachedThumbnailAsync(entry.Value.Id);
if (texture == null) return;
// Mirror CaptureSystem's pairing: Started before the write, Completed(success) in
// finally — art-book saves land in the same gallery_save_* analytics funnel as
// gameplay saves.
_eventBus.Publish(new GallerySaveStartedSignal());
var success = false;
try
{
await _gallery.SaveImageAsync(texture, entry.Value.Name, ct);
success = true;
}
finally
{
_eventBus.Publish(new GallerySaveCompletedSignal(success, entry.Value.Id));
}
// The art book has no success popup of its own, so use the shared toast.
await _saveGate.ShowSavedAsync(ct);
}
catch (OperationCanceledException)
{
// The art book was closed or replaced while saving.
}
catch (Exception e)
{
Debug.LogError($"[Artbook] Failed to save page: {e}");
}
finally
{
if (texture != null) UnityEngine.Object.Destroy(texture);
}
} }
private void HandleLeftEditClicked() => OpenForEdit(GetLeftEntry()); private void HandleLeftEditClicked() => OpenForEdit(GetLeftEntry());
@@ -169,27 +248,43 @@ namespace Darkmatter.Features.Artbook
{ {
if (!entry.HasValue) return; if (!entry.HasValue) return;
_eventBus.Publish(new DrawingSelectedSignal(entry.Value.Id)); _eventBus.Publish(new DrawingSelectedSignal(entry.Value.Id));
StopActiveWork();
_view.Hide(); _view.Hide();
} }
private void HandleBackButtonClicked() private void HandleBackButtonClicked()
{ {
_onClose?.Invoke(); _onClose?.Invoke();
StopActiveWork();
_view.Hide(); _view.Hide();
} }
private void HandleColorbookButtonClicked() private void HandleColorbookButtonClicked()
{ {
_eventBus.Publish(new OpenColorBookSignal()); _eventBus.Publish(new OpenColorBookSignal());
StopActiveWork();
_view.Hide(); _view.Hide();
} }
private void StopActiveWork()
{
_renderVersion++;
_cts?.Cancel();
_view.SetSpread(null, null, null, null);
ClearVisibleAssets();
}
private void ClearOwnedAssets() private void ClearOwnedAssets()
{ {
foreach (var s in _ownedSprites) UnityEngine.Object.Destroy(s); ClearVisibleAssets();
foreach (var t in _ownedTextures) UnityEngine.Object.Destroy(t); }
_ownedSprites.Clear();
_ownedTextures.Clear(); private void ClearVisibleAssets()
{
foreach (var s in _visibleSprites) UnityEngine.Object.Destroy(s);
foreach (var t in _visibleTextures) UnityEngine.Object.Destroy(t);
_visibleSprites.Clear();
_visibleTextures.Clear();
} }
public void Dispose() public void Dispose()

View File

@@ -51,17 +51,23 @@ namespace Darkmatter.Features.Capture
// A null result keeps the existing thumbnail and skips the gallery write (both no-op on null). // A null result keeps the existing thumbnail and skips the gallery write (both no-op on null).
if (_coloring.IsPlayingCompletionAnimation) return null; if (_coloring.IsPlayingCompletionAnimation) return null;
var png = await _captureService.CapturePngAsync(_refs.PaperRoot.gameObject, _config.CaptureScale, ct); var png = await _captureService.CapturePngAsync(
_refs.PaperRoot.gameObject,
_config.CaptureScale,
_coloring.UseNonZoomedView,
ct);
if (!saveToGallery || png == null || png.Length == 0) return png; if (!saveToGallery || png == null || png.Length == 0) return png;
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, mipChain: false); var tex = new Texture2D(2, 2, TextureFormat.RGBA32, mipChain: false);
var success = 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 try
{ {
if (tex.LoadImage(png)) if (tex.LoadImage(png))
{ {
var fileName = await BuildFileNameAsync(); var fileName = await BuildFileNameAsync();
_bus.Publish(new GallerySaveStartedSignal());
await _galleryService.SaveImageAsync(tex, fileName, ct); await _galleryService.SaveImageAsync(tex, fileName, ct);
success = true; success = true;
} }
@@ -69,7 +75,7 @@ namespace Darkmatter.Features.Capture
finally finally
{ {
UnityEngine.Object.Destroy(tex); UnityEngine.Object.Destroy(tex);
_bus.Publish(new GallerySaveCompletedSignal(success)); _bus.Publish(new GallerySaveCompletedSignal(success, _progression.LastOpenedTemplateId));
} }
return png; 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. // one VContainer cancels on teardown, and the linked _scopeCts may lag the callback order.
if (cancellation.IsCancellationRequested || ct.IsCancellationRequested) return; 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(); PrewarmInterstitialAdAsync().Forget();
} }

View File

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

View File

@@ -11,6 +11,7 @@ namespace Darkmatter.Features.Coloring
public class ColoringFeatureModule : MonoBehaviour, IModule public class ColoringFeatureModule : MonoBehaviour, IModule
{ {
[SerializeField] private ColorPaletteHolderView paletteHolderView; [SerializeField] private ColorPaletteHolderView paletteHolderView;
[SerializeField] private ColoringPinchZoomController pinchZoomController;
public void Register(IContainerBuilder builder) public void Register(IContainerBuilder builder)
{ {
@@ -20,6 +21,7 @@ namespace Darkmatter.Features.Coloring
builder.RegisterEntryPoint<ColorPaletteHolderPresenter>().WithParameter(paletteHolderView); builder.RegisterEntryPoint<ColorPaletteHolderPresenter>().WithParameter(paletteHolderView);
} }
builder.RegisterComponent(pinchZoomController);
builder.Register<IColorButtonFactory, ColorButtonFactory>(Lifetime.Singleton); builder.Register<IColorButtonFactory, ColorButtonFactory>(Lifetime.Singleton);
builder.Register<ColoringStateRepository>(Lifetime.Singleton); builder.Register<ColoringStateRepository>(Lifetime.Singleton);
builder.Register<IColoringController, ColoringController>(Lifetime.Singleton); builder.Register<IColoringController, ColoringController>(Lifetime.Singleton);

View File

@@ -33,6 +33,7 @@ public class ColoringController : IColoringController, IDisposable
private GameObject _colorButtonPrefab; private GameObject _colorButtonPrefab;
private GameObject _completionAnimationInstance; private GameObject _completionAnimationInstance;
private CompletionAnimationView _completionAnimationView; private CompletionAnimationView _completionAnimationView;
private readonly ColoringPinchZoomController _pinchZoom;
private bool _isPlayingCompletionAnimation; private bool _isPlayingCompletionAnimation;
private readonly List<ColorRegionView> _regions = new(); private readonly List<ColorRegionView> _regions = new();
private readonly List<ColorButton> _buttons = new(); private readonly List<ColorButton> _buttons = new();
@@ -41,6 +42,7 @@ public class ColoringController : IColoringController, IDisposable
public ColoringController( public ColoringController(
ColoringStateRepository repository, ColoringStateRepository repository,
IColorButtonFactory buttonFactory, IColorButtonFactory buttonFactory,
ColoringPinchZoomController pinchZoom,
IEventBus bus, IEventBus bus,
IAssetProviderService assetProviderService, IAssetProviderService assetProviderService,
IUndoStack history, IUndoStack history,
@@ -52,6 +54,7 @@ public class ColoringController : IColoringController, IDisposable
_bus = bus; _bus = bus;
_assetProviderService = assetProviderService; _assetProviderService = assetProviderService;
_history = history; _history = history;
_pinchZoom = pinchZoom;
_refs = refs; _refs = refs;
_paletteHolder = paletteHolder; _paletteHolder = paletteHolder;
} }
@@ -60,7 +63,7 @@ public class ColoringController : IColoringController, IDisposable
IReadOnlyDictionary<string, Color> savedColors, CancellationToken ct) IReadOnlyDictionary<string, Color> savedColors, CancellationToken ct)
{ {
Clear(); Clear();
_paletteHolder.Show(); _ = _paletteHolder.Show();
await TryLoadColorButtonPrefabAsync(ct); await TryLoadColorButtonPrefabAsync(ct);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
await TryLoadColorPaletteAsync(template, ct); await TryLoadColorPaletteAsync(template, ct);
@@ -110,6 +113,8 @@ public class ColoringController : IColoringController, IDisposable
{ {
if (_completionAnimationInstance == null || _completionAnimationView == null) return; if (_completionAnimationInstance == null || _completionAnimationView == null) return;
if (_colorInstance != null) _colorInstance.SetActive(false); if (_colorInstance != null) _colorInstance.SetActive(false);
if (_pinchZoom != null)
_pinchZoom.ResetZoom();
_completionAnimationInstance.SetActive(true); _completionAnimationInstance.SetActive(true);
_isPlayingCompletionAnimation = true; _isPlayingCompletionAnimation = true;
try try
@@ -138,6 +143,14 @@ public class ColoringController : IColoringController, IDisposable
} }
} }
public IDisposable UseNonZoomedView()
{
if (_pinchZoom == null)
return null;
return _pinchZoom.UseNonZoomedView();
}
public void ResetAll() public void ResetAll()
{ {
if (_regions.Count == 0) return; if (_regions.Count == 0) return;
@@ -175,6 +188,9 @@ public class ColoringController : IColoringController, IDisposable
UnityEngine.Object.Destroy(_colorInstance); UnityEngine.Object.Destroy(_colorInstance);
_colorInstance = null; _colorInstance = null;
} }
if (_pinchZoom != null)
_pinchZoom.SetTarget(null);
} }
public void Dispose() => Clear(); public void Dispose() => Clear();
@@ -182,6 +198,7 @@ public class ColoringController : IColoringController, IDisposable
private void InitializeColorRegions(IDrawingTemplate template, IReadOnlyDictionary<string, Color> savedColors) private void InitializeColorRegions(IDrawingTemplate template, IReadOnlyDictionary<string, Color> savedColors)
{ {
_colorInstance = UnityEngine.Object.Instantiate(template.ColoringPrefab, _refs.PaperRoot); _colorInstance = UnityEngine.Object.Instantiate(template.ColoringPrefab, _refs.PaperRoot);
InitializePinchZoom(_refs.PaperRoot);
if (template.CompletionAnimationPrefab != null) if (template.CompletionAnimationPrefab != null)
{ {
_completionAnimationInstance = UnityEngine.Object.Instantiate(template.CompletionAnimationPrefab, _refs.PaperRoot); _completionAnimationInstance = UnityEngine.Object.Instantiate(template.CompletionAnimationPrefab, _refs.PaperRoot);
@@ -207,6 +224,17 @@ public class ColoringController : IColoringController, IDisposable
} }
} }
private void InitializePinchZoom(RectTransform target)
{
if (target == null)
{
return;
}
if (_pinchZoom != null)
_pinchZoom.SetTarget(target);
}
private async UniTask TryLoadColorButtonPrefabAsync(CancellationToken ct) private async UniTask TryLoadColorButtonPrefabAsync(CancellationToken ct)
{ {
if (_colorButtonPrefab != null) return; if (_colorButtonPrefab != null) return;

View File

@@ -0,0 +1,454 @@
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
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;
[SerializeField] private float scrollZoomSensitivity = 0.0015f;
private RectTransform _target;
private RectTransform _parent;
private Canvas _canvas;
private Camera _eventCamera;
private bool _isPinching;
private float _currentZoom = 1f;
private Vector3 _baseScale = Vector3.one;
private Vector2 _baseAnchoredPosition;
private PanDragRelay _panRelay;
public void SetTarget(RectTransform target)
{
ResetZoom();
_target = target;
_parent = target != null ? target.parent as RectTransform : null;
_canvas = target != null ? target.GetComponentInParent<Canvas>() : null;
_eventCamera = ResolveEventCamera(_canvas);
_baseScale = target != null ? target.localScale : Vector3.one;
_baseAnchoredPosition = target != null ? target.anchoredPosition : Vector2.zero;
_currentZoom = 1f;
enabled = _target != null && _parent != null;
_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()
{
if (_target != null)
{
_target.localScale = _baseScale;
_target.anchoredPosition = _baseAnchoredPosition;
}
_currentZoom = 1f;
_isPinching = false;
UpdatePanRelayState();
}
public IDisposable UseNonZoomedView()
{
if (_target == null)
return null;
var state = new ViewState(_target.localScale, _target.anchoredPosition, _currentZoom);
ResetZoom();
return new NonZoomedViewScope(this, state);
}
private void Awake()
{
enabled = false;
}
private void Update()
{
if (_target == null || _parent == null)
{
enabled = false;
return;
}
if (TryApplyPinchZoom())
{
return;
}
_isPinching = false;
TryApplyScrollZoom();
TryApplyMousePan();
}
private bool TryApplyPinchZoom()
{
if (!TryGetTouchSamples(out TouchSample firstTouch, out TouchSample secondTouch))
{
return false;
}
float distance = Vector2.Distance(firstTouch.Position, secondTouch.Position);
if (distance < MinimumPinchDistance)
{
return true;
}
Vector2 midpoint = (firstTouch.Position + secondTouch.Position) * 0.5f;
if (!TryGetParentLocalPoint(midpoint, out Vector2 midpointLocal))
{
return true;
}
if (!_isPinching)
{
_isPinching = true;
return true;
}
float previousDistance = Vector2.Distance(firstTouch.PreviousPosition, secondTouch.PreviousPosition);
if (previousDistance < MinimumPinchDistance)
{
return true;
}
// 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);
ApplyZoomPan(previousMidpointLocal, midpointLocal, targetZoom);
return true;
}
private void TryApplyScrollZoom()
{
Mouse mouse = Mouse.current;
if (mouse == null)
{
return;
}
float scroll = mouse.scroll.ReadValue().y;
if (Mathf.Approximately(scroll, 0f))
{
return;
}
Vector2 screenPosition = mouse.position.ReadValue();
if (!IsInputPointAllowed(screenPosition))
{
return;
}
if (!TryGetParentLocalPoint(screenPosition, out Vector2 localPoint))
{
return;
}
float targetZoom = Mathf.Clamp(_currentZoom * (1f + scroll * scrollZoomSensitivity), minZoom, maxZoom);
ZoomTowards(localPoint, targetZoom);
}
private void ZoomTowards(Vector2 parentLocalPoint, float targetZoom)
{
if (Mathf.Approximately(targetZoom, _currentZoom))
{
return;
}
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 = 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)
{
return RectTransformUtility.ScreenPointToLocalPointInRectangle(_parent, screenPoint, _eventCamera,
out localPoint);
}
private bool TryGetTouchSamples(out TouchSample firstTouch, out TouchSample secondTouch)
{
firstTouch = default;
secondTouch = default;
Touchscreen touchscreen = Touchscreen.current;
if (touchscreen == null)
{
return false;
}
int count = 0;
foreach (var touchControl in touchscreen.touches)
{
if (!touchControl.press.isPressed)
{
continue;
}
Vector2 position = touchControl.position.ReadValue();
if (!IsInputPointAllowed(position))
{
continue;
}
var sample = new TouchSample(position, touchControl.delta.ReadValue());
if (count == 0)
{
firstTouch = sample;
}
else
{
secondTouch = sample;
return true;
}
count++;
}
return false;
}
private bool IsInputPointAllowed(Vector2 screenPoint)
{
return _target != null &&
RectTransformUtility.RectangleContainsScreenPoint(_target, screenPoint, _eventCamera);
}
private static Camera ResolveEventCamera(Canvas canvas)
{
if (canvas == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
return null;
}
return canvas.worldCamera;
}
private void RestoreView(ViewState state)
{
if (_target == null)
return;
_target.localScale = state.Scale;
_target.anchoredPosition = state.AnchoredPosition;
_currentZoom = state.CurrentZoom;
_isPinching = false;
UpdatePanRelayState();
}
private readonly struct ViewState
{
public readonly Vector3 Scale;
public readonly Vector2 AnchoredPosition;
public readonly float CurrentZoom;
public ViewState(Vector3 scale, Vector2 anchoredPosition, float currentZoom)
{
Scale = scale;
AnchoredPosition = anchoredPosition;
CurrentZoom = currentZoom;
}
}
private sealed class NonZoomedViewScope : IDisposable
{
private ColoringPinchZoomController _controller;
private readonly ViewState _state;
public NonZoomedViewScope(ColoringPinchZoomController controller, ViewState state)
{
_controller = controller;
_state = state;
}
public void Dispose()
{
if (_controller == null)
return;
if (_controller != null)
_controller.RestoreView(_state);
_controller = null;
}
}
private readonly struct TouchSample
{
public readonly Vector2 Position;
public readonly Vector2 PreviousPosition;
public readonly Vector2 Delta;
public TouchSample(Vector2 position, Vector2 delta)
{
Position = position;
PreviousPosition = position - delta;
Delta = delta;
}
}
// 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

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

View File

@@ -57,8 +57,8 @@ public sealed class DrawingCatalogController : IDrawingCatalogController
private void Refresh() private void Refresh()
{ {
_visible.Clear(); _visible.Clear();
// Catalog already orders ids by difficulty (easiest first), then id.
_visible.AddRange(_catalog.AllTemplateIds); _visible.AddRange(_catalog.AllTemplateIds);
_visible.Sort(StringComparer.OrdinalIgnoreCase);
ListChanged?.Invoke(); ListChanged?.Invoke();
} }
} }

View File

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

View File

@@ -15,6 +15,7 @@ namespace Darkmatter.Features.DrawingCatalog
private int _currentPage = 0; private int _currentPage = 0;
private int _totalPages = 1; private int _totalPages = 1;
private const int RowCount = 2;
private const int ItemPerPage = 8; //2 rows x 4 columns private const int ItemPerPage = 8; //2 rows x 4 columns
private readonly DrawingCatalogView _view; private readonly DrawingCatalogView _view;
@@ -24,6 +25,12 @@ namespace Darkmatter.Features.DrawingCatalog
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private IDisposable _openSubscription; 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, public DrawingCatalogPresenter(DrawingCatalogView view, IDrawingCatalogController controller,
IDrawingTemplateCatalog catalog, IEventBus eventBus) IDrawingTemplateCatalog catalog, IEventBus eventBus)
{ {
@@ -48,8 +55,14 @@ namespace Darkmatter.Features.DrawingCatalog
} }
private void HandleOpenColorBookSignal(OpenColorBookSignal signal) private void HandleOpenColorBookSignal(OpenColorBookSignal signal)
{
ShowCatalog();
}
private void ShowCatalog()
{ {
_view.Show(); _view.Show();
_eventBus.Publish(new ColorbookShownSignal());
} }
private void OnRightPageBtnClicked() private void OnRightPageBtnClicked()
@@ -84,7 +97,7 @@ namespace Darkmatter.Features.DrawingCatalog
private void OnArtBookBtnClicked() private void OnArtBookBtnClicked()
{ {
_eventBus.Publish(new OpenArtBookSignal(()=> _view.Show())); _eventBus.Publish(new OpenArtBookSignal(ShowCatalog));
_view.Hide(); _view.Hide();
} }
@@ -102,29 +115,86 @@ namespace Darkmatter.Features.DrawingCatalog
private async UniTask RefreshAsync(CancellationToken ct) private async UniTask RefreshAsync(CancellationToken ct)
{ {
var ids = _controller.VisibleIds; var newSprites = new List<Sprite>();
var vms = new List<CatalogItemVM>(ids.Count); var newTextures = new List<Texture2D>();
var applied = false;
_totalPages = (int)Math.Ceiling(ids.Count / (float)ItemPerPage); try
_currentPage = 0;
foreach (var id in ids)
{ {
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; if (ct.IsCancellationRequested) return;
var thumb = await _catalog.GetThumbnailAsync(id); _view.ResetScrollPosition();
vms.Add(new CatalogItemVM(id, thumb)); _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; return ordered;
_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());
} }
public void Dispose() public void Dispose()
@@ -140,6 +210,7 @@ namespace Darkmatter.Features.DrawingCatalog
_openSubscription?.Dispose(); _openSubscription?.Dispose();
_cts.Cancel(); _cts.Cancel();
_cts.Dispose(); _cts.Dispose();
DestroyOwnedThumbnails();
} }
} }
} }

View File

@@ -62,9 +62,6 @@ namespace Darkmatter.Features.DrawingCatalog
while (_buttons.Count < items.Count) while (_buttons.Count < items.Count)
{ {
var button = Instantiate(buttonPrefab, content); var button = Instantiate(buttonPrefab, content);
var item = items[_buttons.Count];
button.Initialize(item.Id, item.Thumbnail,
() => { OnItemClicked?.Invoke(item.Id); });
_buttons.Add(button); _buttons.Add(button);
} }
@@ -75,6 +72,13 @@ namespace Darkmatter.Features.DrawingCatalog
Destroy(last.gameObject); 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); LayoutRebuilder.ForceRebuildLayoutImmediate(content);
} }

View File

@@ -68,7 +68,9 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
_templates.Clear(); _templates.Clear();
_shapes.Clear(); _shapes.Clear();
_palettes.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>()); _shapes.AddRange(FindAllOfType<ShapeSO>());
_palettes.AddRange(FindAllOfType<ColorPaletteSO>()); _palettes.AddRange(FindAllOfType<ColorPaletteSO>());
} }
@@ -169,7 +171,7 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
using (new EditorGUILayout.VerticalScope()) using (new EditorGUILayout.VerticalScope())
{ {
GUILayout.Label(string.IsNullOrEmpty(t.DisplayName) ? t.name : t.DisplayName, EditorStyles.boldLabel); 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))) if (GUILayout.Button("Select", GUILayout.Width(54)))
Select(t); Select(t);
@@ -206,6 +208,13 @@ namespace Darkmatter.Features.DrawingTemplates.Editor
EditorGUILayout.LabelField("Basics", EditorStyles.boldLabel); EditorGUILayout.LabelField("Basics", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(so.FindProperty("id"), new GUIContent("Id (Addressable Key)")); EditorGUILayout.PropertyField(so.FindProperty("id"), new GUIContent("Id (Addressable Key)"));
EditorGUILayout.PropertyField(so.FindProperty("displayName")); 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.PropertyField(so.FindProperty("defaultThumbnail"));
EditorGUILayout.Space(6); EditorGUILayout.Space(6);

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
@@ -48,11 +49,11 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
_byId[t.Id] = t; _byId[t.Id] = t;
} }
_allIds.Sort(); SortIds();
_initialized = true; _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)) if (!_byId.TryGetValue(id, out var t))
throw new KeyNotFoundException($"Template '{id}' not in catalog. Did InitializeAsync run?"); throw new KeyNotFoundException($"Template '{id}' not in catalog. Did InitializeAsync run?");
@@ -61,9 +62,9 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
if (savedTex != null) if (savedTex != null)
{ {
var rect = new Rect(0, 0, savedTex.width, savedTex.height); 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) public UniTask<IDrawingTemplate> LoadAsync(string id)
@@ -84,12 +85,22 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
if (!_allIds.Contains(id)) if (!_allIds.Contains(id))
{ {
_allIds.Add(id); _allIds.Add(id);
_allIds.Sort(); SortIds();
} }
return t; 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() public void ReleaseAll()
{ {
if (!_initialized) return; if (!_initialized) return;

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
using Darkmatter.Core;
using Darkmatter.Core.Contracts.Features.Capture; using Darkmatter.Core.Contracts.Features.Capture;
using Darkmatter.Core.Contracts.Features.Coloring; using Darkmatter.Core.Contracts.Features.Coloring;
using Darkmatter.Core.Contracts.Features.DrawingCatalog; using Darkmatter.Core.Contracts.Features.DrawingCatalog;
@@ -28,6 +29,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
public sealed class GameplayFlowController : IAsyncStartable, IGameplayFlowController, IDisposable public sealed class GameplayFlowController : IAsyncStartable, IGameplayFlowController, IDisposable
{ {
private const int AutosaveDebounceMs = 500; private const int AutosaveDebounceMs = 500;
private const float ThumbnailMinIntervalSec = 30f;
private readonly IProgressionSystem _progression; private readonly IProgressionSystem _progression;
private readonly IDrawingTemplateCatalog _catalog; private readonly IDrawingTemplateCatalog _catalog;
@@ -44,10 +46,12 @@ namespace Darkmatter.Features.GameplayFlow.Systems
private string _templateId; private string _templateId;
private DrawingPhase _phase = DrawingPhase.ShapeBuilding; private DrawingPhase _phase = DrawingPhase.ShapeBuilding;
private bool _allContentReported; private bool _allContentReported;
private float _lastThumbnailTime = float.NegativeInfinity;
private IDisposable _assembledSub; private IDisposable _assembledSub;
private IDisposable _colorAppliedSub; private IDisposable _colorAppliedSub;
private IDisposable _drawingSelectedSub; private IDisposable _drawingSelectedSub;
private IDisposable _openColorbookSub;
private CancellationTokenSource _autosaveCts; private CancellationTokenSource _autosaveCts;
private CancellationTokenSource _scopeCts; private CancellationTokenSource _scopeCts;
@@ -76,6 +80,27 @@ namespace Darkmatter.Features.GameplayFlow.Systems
} }
public async UniTask StartAsync(CancellationToken cancellation) 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); _scopeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var ct = _scopeCts.Token; var ct = _scopeCts.Token;
@@ -104,6 +129,10 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_assembledSub = _bus.Subscribe<ShapeAssembledSignal>(OnShapeAssembled); _assembledSub = _bus.Subscribe<ShapeAssembledSignal>(OnShapeAssembled);
_colorAppliedSub = _bus.Subscribe<ColorAppliedSignal>(OnColorApplied); _colorAppliedSub = _bus.Subscribe<ColorAppliedSignal>(OnColorApplied);
_drawingSelectedSub = _bus.Subscribe<DrawingSelectedSignal>(OnDrawingSelected); _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); _loadingScreen.SetProgress(1f);
if (_phase == DrawingPhase.Coloring) if (_phase == DrawingPhase.Coloring)
@@ -119,6 +148,22 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_loadingScreen.Hide(); _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) public async UniTask BackAsync(CancellationToken ct)
{ {
await SaveCurrentAsync(CancellationToken.None); await SaveCurrentAsync(CancellationToken.None);
@@ -131,8 +176,10 @@ namespace Darkmatter.Features.GameplayFlow.Systems
public async UniTask SaveAsync(CancellationToken ct) public async UniTask SaveAsync(CancellationToken ct)
{ {
await SaveCurrentAsync(ct); // One capture serves both the gallery write and the progress thumbnail — this used to
await _capture.CapturePngAsync(saveToGallery: true, ct); // 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) public async UniTask NextAsync(CancellationToken ct)
@@ -209,6 +256,11 @@ namespace Darkmatter.Features.GameplayFlow.Systems
DebouncedAutosaveAsync(_autosaveCts.Token).Forget(); DebouncedAutosaveAsync(_autosaveCts.Token).Forget();
} }
private void OnOpenColorBook(OpenColorBookSignal _)
{
BackAsync(CancellationToken.None).Forget();
}
private void OnDrawingSelected(DrawingSelectedSignal signal) private void OnDrawingSelected(DrawingSelectedSignal signal)
{ {
if (string.IsNullOrEmpty(signal.TemplateId)) return; if (string.IsNullOrEmpty(signal.TemplateId)) return;
@@ -232,7 +284,13 @@ namespace Darkmatter.Features.GameplayFlow.Systems
try try
{ {
await UniTask.Delay(AutosaveDebounceMs, cancellationToken: ct); 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) 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; if (string.IsNullOrEmpty(_templateId)) return;
@@ -263,7 +322,10 @@ namespace Darkmatter.Features.GameplayFlow.Systems
FirstCompletedUtc = existing?.FirstCompletedUtc, 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); await _progression.SaveProgressAsync(progress, thumbnailPng);
} }
@@ -284,6 +346,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_assembledSub?.Dispose(); _assembledSub?.Dispose();
_colorAppliedSub?.Dispose(); _colorAppliedSub?.Dispose();
_drawingSelectedSub?.Dispose(); _drawingSelectedSub?.Dispose();
_openColorbookSub?.Dispose();
_autosaveCts?.Cancel(); _autosaveCts?.Cancel();
_autosaveCts?.Dispose(); _autosaveCts?.Dispose();
_scopeCts?.Cancel(); _scopeCts?.Cancel();

View File

@@ -13,6 +13,7 @@ namespace Darkmatter.Features.Mainmenu
private readonly IEventBus _eventBus; private readonly IEventBus _eventBus;
private readonly MainMenuView _view; private readonly MainMenuView _view;
private readonly ISfxPlayer _sfxPlayer; private readonly ISfxPlayer _sfxPlayer;
private IDisposable _introSubscription;
public MainMenuPresenter(MainMenuView view, IEventBus eventBus, ISfxPlayer sfxPlayer) public MainMenuPresenter(MainMenuView view, IEventBus eventBus, ISfxPlayer sfxPlayer)
@@ -26,7 +27,7 @@ namespace Darkmatter.Features.Mainmenu
{ {
_view.OnPlayBtnPressedEvent += OnPlayBtnPressed; _view.OnPlayBtnPressedEvent += OnPlayBtnPressed;
_view.OnPlayBtnClickedEvent += OnPlayBtnClicked; _view.OnPlayBtnClickedEvent += OnPlayBtnClicked;
_eventBus.Subscribe<IntroCompletedSignal>(OnIntroComplete); _introSubscription = _eventBus.Subscribe<IntroCompletedSignal>(OnIntroComplete);
} }
private void OnIntroComplete(IntroCompletedSignal signal) private void OnIntroComplete(IntroCompletedSignal signal)
@@ -48,6 +49,7 @@ namespace Darkmatter.Features.Mainmenu
{ {
_view.OnPlayBtnPressedEvent -= OnPlayBtnPressed; _view.OnPlayBtnPressedEvent -= OnPlayBtnPressed;
_view.OnPlayBtnClickedEvent -= OnPlayBtnClicked; _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; if (string.IsNullOrEmpty(templateId) || png == null || png.Length == 0) return;
Directory.CreateDirectory(ThumbnailDirectory()); // Never let thumbnail IO (disk full, permission churn) throw into the save path:
var path = ThumbnailPath(templateId); // saves run before every Back/Next navigation, and an unhandled IOException there
var tmp = path + ".tmp"; // aborts the scene transition and traps the user in gameplay. Losing a thumbnail
// is fine — the colors themselves persist via PlayerPrefs.
await UniTask.RunOnThreadPool(() => try
{ {
File.WriteAllBytes(tmp, png); Directory.CreateDirectory(ThumbnailDirectory());
if (File.Exists(path)) File.Delete(path); var path = ThumbnailPath(templateId);
File.Move(tmp, path); 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) 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)) if (_map.TryGetValue(typeof(T), out var handlerObj))
{ {
var action = (Action<T>)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)] [SerializeField, Min(0)]
private int maxInterstitialsPerSession = 8; 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 bool IsInitialized => _initialized;
public event Action<AdFormat, AdLoadState> LoadStateChanged; public event Action<AdFormat, AdLoadState> LoadStateChanged;
@@ -653,11 +658,12 @@ namespace Darkmatter.Services.Ads
ad.OnAdClicked += () => LogAdClicked(format); ad.OnAdClicked += () => LogAdClicked(format);
} }
// Ad revenue: GA4-recommended ad_impression carries value+currency from AdMob's paid callback — // AdMob/Firebase can auto-log ad_impression for linked apps. Manual logging is opt-in so one
// this is what surfaces ad revenue in Firebase/GA4. Ad callbacks fire on the Unity main thread // shown ad does not become two GA4 ad_impression events.
// (RaiseAdEventsOnUnityMainThread = true), so logging straight to analytics is safe.
private void LogAdImpression(AdFormat format, AdValue value) private void LogAdImpression(AdFormat format, AdValue value)
{ {
if (!logManualAdImpressionEvents) return;
_analytics?.LogEvent(AnalyticsEvents.AdImpression, new Dictionary<string, object> _analytics?.LogEvent(AnalyticsEvents.AdImpression, new Dictionary<string, object>
{ {
[AnalyticsParams.AdPlatform] = "AdMob", [AnalyticsParams.AdPlatform] = "AdMob",

View File

@@ -56,8 +56,9 @@ namespace Darkmatter.Services.Analytics
_subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.IntroCompleted))); _subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.IntroCompleted)));
_subs.Add(_bus.Subscribe<PlayBtnClickedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.PlayClicked))); _subs.Add(_bus.Subscribe<PlayBtnClickedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.PlayClicked)));
// Navigation // Navigation. ColorbookShownSignal (not OpenColorBookSignal, which is the show-view command
_subs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ColorbookOpened))); // 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<OpenArtBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ArtbookOpened)));
_subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => OnReturnToMainMenu())); _subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => OnReturnToMainMenu()));
@@ -90,6 +91,10 @@ namespace Darkmatter.Services.Analytics
private void OnDrawingSelected(string drawingId) 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. // A new selection while a drawing is still open (uncompleted) = the previous one was abandoned.
EndActiveDrawingIfAbandoned(); EndActiveDrawingIfAbandoned();
@@ -156,7 +161,7 @@ namespace Darkmatter.Services.Analytics
if (dur >= 0f) p[AnalyticsParams.DurationSeconds] = dur; if (dur >= 0f) p[AnalyticsParams.DurationSeconds] = dur;
_analytics.LogEvent(AnalyticsEvents.DrawingCompleted, p); _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> _analytics.LogEvent(AnalyticsEvents.LevelComplete, new Dictionary<string, object>
{ {
[AnalyticsParams.LevelName] = s.TemplateId, [AnalyticsParams.LevelName] = s.TemplateId,
@@ -171,16 +176,24 @@ namespace Darkmatter.Services.Analytics
if (PlayerPrefs.GetInt(AllContentKey, 0) == 1) return; // once ever if (PlayerPrefs.GetInt(AllContentKey, 0) == 1) return; // once ever
PlayerPrefs.SetInt(AllContentKey, 1); PlayerPrefs.SetInt(AllContentKey, 1);
PlayerPrefs.Save(); PlayerPrefs.Save();
_analytics.LogEvent(AnalyticsEvents.AllContentCompleted, _analytics.LogEvent(AnalyticsEvents.AllContentCompleted, new Dictionary<string, object>
AnalyticsParams.CompletionCount, s.CompletedCount.ToString()); {
[AnalyticsParams.CompletionCount] = s.CompletedCount,
});
} }
private void OnGallerySaveCompleted(GallerySaveCompletedSignal s) 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> _analytics.LogEvent(AnalyticsEvents.DrawingSaved, new Dictionary<string, object>
{ {
[AnalyticsParams.DrawingId] = _activeDrawingId ?? string.Empty, [AnalyticsParams.DrawingId] = drawingId,
[AnalyticsParams.Success] = s.Success ? 1 : 0, [AnalyticsParams.Success] = s.Success ? 1 : 0,
}); });
} }
@@ -191,6 +204,15 @@ namespace Darkmatter.Services.Analytics
_analytics.LogEvent(AnalyticsEvents.MainMenuReturned); _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 // 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. // reaching here with a non-null id means the drawing was left without finishing — the leak signal.
private void EndActiveDrawingIfAbandoned() private void EndActiveDrawingIfAbandoned()

View File

@@ -350,7 +350,9 @@ namespace Darkmatter.Services.Audio
if (!source.gameObject.activeInHierarchy) source.gameObject.SetActive(true); if (!source.gameObject.activeInHierarchy) source.gameObject.SetActive(true);
source.Play(); 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}"); 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)) if (IsChannelPaused(request.Channel))
{ {
source.Pause(); source.Pause();

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
@@ -5,6 +6,7 @@ using Darkmatter.Core.Contracts.Services.Camera;
using Darkmatter.Core.Contracts.Services.Capture; using Darkmatter.Core.Contracts.Services.Capture;
using UnityEngine; using UnityEngine;
using CameraType = Darkmatter.Core.Enums.Services.Camera.CameraType; using CameraType = Darkmatter.Core.Enums.Services.Camera.CameraType;
using Object = UnityEngine.Object;
namespace Darkmatter.Services.Capture namespace Darkmatter.Services.Capture
{ {
@@ -15,7 +17,7 @@ namespace Darkmatter.Services.Capture
public CaptureService(ICameraService cameraService) => _cameraService = cameraService; public CaptureService(ICameraService cameraService) => _cameraService = cameraService;
public async UniTask<byte[]> CapturePngAsync(GameObject captureObject, float scale, public async UniTask<byte[]> CapturePngAsync(GameObject captureObject, float scale,
CancellationToken cancellationToken = default) Func<IDisposable> captureViewScopeFactory = null, CancellationToken cancellationToken = default)
{ {
if (captureObject == null) return null; if (captureObject == null) return null;
var paperRT = captureObject.transform as RectTransform; var paperRT = captureObject.transform as RectTransform;
@@ -26,10 +28,6 @@ namespace Darkmatter.Services.Capture
int sw = Screen.width; int sw = Screen.width;
int sh = Screen.height; int sh = Screen.height;
Rect crop = ComputeCropRect(captureObject, sw, sh);
int cropW = Mathf.Max(1, (int)crop.width);
int cropH = Mathf.Max(1, (int)crop.height);
var prevFlags = cam.clearFlags; var prevFlags = cam.clearFlags;
var prevBg = cam.backgroundColor; var prevBg = cam.backgroundColor;
var prevTarget = cam.targetTexture; var prevTarget = cam.targetTexture;
@@ -41,15 +39,22 @@ namespace Darkmatter.Services.Capture
List<Canvas> disabledCanvases = null; List<Canvas> disabledCanvases = null;
List<UnityEngine.UI.Graphic> disabledGraphics = null; List<UnityEngine.UI.Graphic> disabledGraphics = null;
IDisposable captureViewScope = null;
try try
{ {
await UniTask.WaitForEndOfFrame(cancellationToken); await UniTask.WaitForEndOfFrame(cancellationToken);
captureViewScope = captureViewScopeFactory?.Invoke();
disabledCanvases = DisableOtherRootCanvases(paperCanvas); disabledCanvases = DisableOtherRootCanvases(paperCanvas);
disabledGraphics = HideNonPaperGraphics(paperRT); disabledGraphics = HideNonPaperGraphics(paperRT);
HideBackdropGraphics(paperRT, disabledGraphics); HideBackdropGraphics(paperRT, disabledGraphics);
Rect crop = ComputeCropRect(captureObject, sw, sh);
int cropW = Mathf.Max(1, (int)crop.width);
int cropH = Mathf.Max(1, (int)crop.height);
cam.clearFlags = CameraClearFlags.SolidColor; cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0f, 0f, 0f, 0f); cam.backgroundColor = new Color(0f, 0f, 0f, 0f);
cam.targetTexture = rt; cam.targetTexture = rt;
@@ -63,52 +68,58 @@ namespace Darkmatter.Services.Capture
var prevActive = RenderTexture.active; var prevActive = RenderTexture.active;
RenderTexture.active = rt; RenderTexture.active = rt;
var fullScreen = new Texture2D(sw, sh, TextureFormat.RGBA32, mipChain: false); // Read only the crop region straight off the RT. A full-screen ReadPixels followed by
fullScreen.ReadPixels(new Rect(0, 0, sw, sh), 0, 0); // GetPixels/SetPixels cropping allocated a managed Color[] at 16 bytes/pixel (tens of MB)
fullScreen.Apply(); // on every capture — autosave runs this after each paint, OOM-killing low-RAM devices.
RenderTexture.active = prevActive; var cropped = new Texture2D(cropW, cropH, TextureFormat.RGBA32, mipChain: false);
try 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 try
{ {
cropped.SetPixels(fullScreen.GetPixels((int)crop.x, (int)crop.y, cropW, cropH)); return output.EncodeToPNG();
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);
}
} }
finally finally
{ {
Object.Destroy(cropped); if (output != cropped) Object.Destroy(output);
} }
} }
finally finally
{ {
Object.Destroy(fullScreen); RenderTexture.active = prevActive;
Object.Destroy(cropped);
} }
} }
finally finally
{ {
paperCanvas.renderMode = prevMode; // The awaits above give scene teardown (autosave racing an unload) a chance to
paperCanvas.worldCamera = prevWorldCam; // destroy the canvas or camera; touching them then would throw from finally and
paperCanvas.planeDistance = prevPlaneDist; // skip the rest of the restore.
cam.targetTexture = prevTarget; if (paperCanvas != null)
cam.clearFlags = prevFlags; {
cam.backgroundColor = prevBg; paperCanvas.renderMode = prevMode;
paperCanvas.worldCamera = prevWorldCam;
paperCanvas.planeDistance = prevPlaneDist;
}
if (cam != null)
{
cam.targetTexture = prevTarget;
cam.clearFlags = prevFlags;
cam.backgroundColor = prevBg;
}
RenderTexture.ReleaseTemporary(rt); RenderTexture.ReleaseTemporary(rt);
if (disabledGraphics != null) if (disabledGraphics != null)
foreach (var g in disabledGraphics) foreach (var g in disabledGraphics)
@@ -118,6 +129,7 @@ namespace Darkmatter.Services.Capture
foreach (var c in disabledCanvases) foreach (var c in disabledCanvases)
if (c != null) if (c != null)
c.enabled = true; c.enabled = true;
captureViewScope?.Dispose();
} }
} }

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ TextureImporter:
heightScale: 0.25 heightScale: 0.25
normalMapFilter: 0 normalMapFilter: 0
flipGreenChannel: 0 flipGreenChannel: 0
isReadable: 1 isReadable: 0
streamingMipmaps: 0 streamingMipmaps: 0
streamingMipmapsPriority: 0 streamingMipmapsPriority: 0
vTOnly: 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 m_EditorClassIdentifier: Core::Darkmatter.Core.Data.Static.Features.DrawingTemplate.DrawingTemplateSO
id: Bag id: Bag
displayName: Bag displayName: Bag
difficultyOverride: 0
defaultThumbnail: {fileID: 8766740682603709380, guid: ecad1a66623031f4a91dbcee6f10a295, type: 3} defaultThumbnail: {fileID: 8766740682603709380, guid: ecad1a66623031f4a91dbcee6f10a295, type: 3}
drawingPrefab: {fileID: 8396209140419971077, guid: ab15e2395fec7be429926e140d9f4a61, type: 3} drawingPrefab: {fileID: 8396209140419971077, guid: ab15e2395fec7be429926e140d9f4a61, type: 3}
coloringPrefab: {fileID: 222817150442217502, guid: b8b22ebdfe861214c8edef5e46743323, type: 3} coloringPrefab: {fileID: 222817150442217502, guid: b8b22ebdfe861214c8edef5e46743323, type: 3}

View File

@@ -23,7 +23,7 @@ MonoBehaviour:
m_RequireOpaqueTexture: 0 m_RequireOpaqueTexture: 0
m_OpaqueDownsampling: 1 m_OpaqueDownsampling: 1
m_SupportsTerrainHoles: 1 m_SupportsTerrainHoles: 1
m_SupportsHDR: 1 m_SupportsHDR: 0
m_HDRColorBufferPrecision: 0 m_HDRColorBufferPrecision: 0
m_MSAA: 1 m_MSAA: 1
m_RenderScale: 1 m_RenderScale: 1
@@ -42,7 +42,7 @@ MonoBehaviour:
m_SupportProbeVolumeScenarioBlending: 0 m_SupportProbeVolumeScenarioBlending: 0
m_ProbeVolumeSHBands: 1 m_ProbeVolumeSHBands: 1
m_MainLightRenderingMode: 1 m_MainLightRenderingMode: 1
m_MainLightShadowsSupported: 1 m_MainLightShadowsSupported: 0
m_MainLightShadowmapResolution: 2048 m_MainLightShadowmapResolution: 2048
m_AdditionalLightsRenderingMode: 1 m_AdditionalLightsRenderingMode: 1
m_AdditionalLightsPerObjectLimit: 4 m_AdditionalLightsPerObjectLimit: 4
@@ -74,7 +74,6 @@ MonoBehaviour:
m_MixedLightingSupported: 1 m_MixedLightingSupported: 1
m_SupportsLightCookies: 1 m_SupportsLightCookies: 1
m_SupportsLightLayers: 0 m_SupportsLightLayers: 0
m_DebugLevel: 0
m_StoreActionsOptimization: 0 m_StoreActionsOptimization: 0
m_UseAdaptivePerformance: 1 m_UseAdaptivePerformance: 1
m_ColorGradingMode: 0 m_ColorGradingMode: 0
@@ -136,6 +135,7 @@ MonoBehaviour:
m_PrefilterReflectionProbeBlending: 1 m_PrefilterReflectionProbeBlending: 1
m_PrefilterReflectionProbeBoxProjection: 1 m_PrefilterReflectionProbeBoxProjection: 1
m_PrefilterReflectionProbeAtlas: 1 m_PrefilterReflectionProbeAtlas: 1
m_PrefilterPointSamplingUpsampling: 1
m_ShaderVariantLogLevel: 0 m_ShaderVariantLogLevel: 0
m_ShadowCascades: 0 m_ShadowCascades: 0
m_Textures: m_Textures:

View File

@@ -68,7 +68,20 @@ MonoBehaviour:
- rid: 570818657416118287 - rid: 570818657416118287
- rid: 570818657416118288 - rid: 570818657416118288
m_RuntimeSettings: m_RuntimeSettings:
m_List: [] m_List:
- rid: 7752762179098771456
- rid: 7752762179098771457
- rid: 7752762179098771459
- rid: 7752762179098771462
- rid: 7752762179098771464
- rid: 7752762179098771466
- rid: 7752762179098771468
- rid: 7752762179098771472
- rid: 7752762179098771476
- rid: 3114554777721110529
- rid: 3114554777721110530
- rid: 570818657416118282
- rid: 570818657416118283
m_AssetVersion: 10 m_AssetVersion: 10
m_ObsoleteDefaultVolumeProfile: {fileID: 0} m_ObsoleteDefaultVolumeProfile: {fileID: 0}
m_RenderingLayerNames: m_RenderingLayerNames:

View File

@@ -274,8 +274,8 @@ RectTransform:
- {fileID: 259035378} - {fileID: 259035378}
- {fileID: 1310839949} - {fileID: 1310839949}
- {fileID: 376589367} - {fileID: 376589367}
- {fileID: 1143672390}
- {fileID: 1340226039} - {fileID: 1340226039}
- {fileID: 1427434780}
m_Father: {fileID: 1950265412} m_Father: {fileID: 1950265412}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 0}
@@ -2013,16 +2013,16 @@ RectTransform:
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1143672389} m_GameObject: {fileID: 1143672389}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1} m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: [] m_Children: []
m_Father: {fileID: 153461769} m_Father: {fileID: 1427434780}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -139.443, y: 163.666} m_AnchoredPosition: {x: -83.744095, y: -9.064926}
m_SizeDelta: {x: 2439.04, y: 1377.741} m_SizeDelta: {x: 2439.04, y: 1377.741}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1143672392 --- !u!222 &1143672392
@@ -2636,6 +2636,96 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1351490753} m_GameObject: {fileID: 1351490753}
m_CullTransparentMesh: 1 m_CullTransparentMesh: 1
--- !u!1 &1427434779
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1427434780}
- component: {fileID: 1427434782}
- component: {fileID: 1427434781}
- component: {fileID: 1427434783}
m_Layer: 5
m_Name: Mask
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1427434780
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1427434779}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1143672390}
m_Father: {fileID: 153461769}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: -55.698975, y: 172.73102}
m_SizeDelta: {x: -462.3999, y: -487.479}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1427434781
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1427434779}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &1427434782
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1427434779}
m_CullTransparentMesh: 1
--- !u!114 &1427434783
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1427434779}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 31a19414c41e5ae4aae2af33fee712f6, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Mask
m_ShowMaskGraphic: 0
--- !u!1 &1518670450 --- !u!1 &1518670450
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -2790,6 +2880,53 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Features.History::Darkmatter.Features.History.HistoryServiceModule m_EditorClassIdentifier: Features.History::Darkmatter.Features.History.HistoryServiceModule
historyButtonsView: {fileID: 1310839950} historyButtonsView: {fileID: 1310839950}
--- !u!1 &1794655486
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1794655488}
- component: {fileID: 1794655487}
m_Layer: 0
m_Name: Coloring Pinch Zoom Controller
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1794655487
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1794655486}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: ddf9e0ad8b4b4ee59470410f7cf40689, type: 3}
m_Name:
m_EditorClassIdentifier: Features.Coloring::Darkmatter.Features.Coloring.Systems.ColoringPinchZoomController
minZoom: 1
maxZoom: 3
scrollZoomSensitivity: 0.0015
--- !u!4 &1794655488
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1794655486}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 393.77545, y: 435.62915, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1867428028 --- !u!1 &1867428028
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -2835,6 +2972,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Features.Coloring::Darkmatter.Features.Coloring.ColoringFeatureModule m_EditorClassIdentifier: Features.Coloring::Darkmatter.Features.Coloring.ColoringFeatureModule
paletteHolderView: {fileID: 1518670454} paletteHolderView: {fileID: 1518670454}
pinchZoomController: {fileID: 1794655487}
--- !u!1 &1950265411 --- !u!1 &1950265411
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -3897,3 +4035,4 @@ SceneRoots:
- {fileID: 2069155641} - {fileID: 2069155641}
- {fileID: 396806866} - {fileID: 396806866}
- {fileID: 1224714932} - {fileID: 1224714932}
- {fileID: 1794655488}

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">7f32d9eb-b712-4616-ad93-07615d514802</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

@@ -1 +1 @@
<?xml version="1.0" encoding="utf-8"?><resources><string name="com.google.firebase.crashlytics.unity_version" translatable="false">6000.4.5f1</string></resources> <?xml version="1.0" encoding="utf-8"?><resources><string name="com.google.firebase.crashlytics.unity_version" translatable="false">6000.5.1f1</string></resources>

View File

@@ -18,12 +18,15 @@ MonoBehaviour:
m_PlatformId: b9b35072a6f44c2e863f17467ea3dc13 m_PlatformId: b9b35072a6f44c2e863f17467ea3dc13
m_PlatformBuildProfile: m_PlatformBuildProfile:
rid: 570818657416118487 rid: 570818657416118487
m_ActivePlatformGuid:
m_AdditionalPlatformBuildSettings: []
m_OverrideGlobalSceneList: 0 m_OverrideGlobalSceneList: 0
m_Scenes: [] m_Scenes: []
m_HasScriptingDefines: 0 m_HasScriptingDefines: 0
m_ScriptingDefines: [] m_ScriptingDefines: []
m_PlayerSettingsYaml: m_PlayerSettingsYaml:
m_Settings: [] m_Settings: []
requiredComponents: []
references: references:
version: 2 version: 2
RefIds: RefIds:
@@ -33,6 +36,7 @@ MonoBehaviour:
m_Development: 0 m_Development: 0
m_ConnectProfiler: 0 m_ConnectProfiler: 0
m_BuildWithDeepProfilingSupport: 0 m_BuildWithDeepProfilingSupport: 0
m_BuildWithCodeCoverage: 0
m_AllowDebugging: 0 m_AllowDebugging: 0
m_WaitForManagedDebugger: 0 m_WaitForManagedDebugger: 0
m_ManagedDebuggerFixedPort: 0 m_ManagedDebuggerFixedPort: 0

View File

@@ -13,4 +13,4 @@ MonoBehaviour:
m_Name: SmartlookSettings m_Name: SmartlookSettings
m_EditorClassIdentifier: m_EditorClassIdentifier:
ProjectKey: 3bcfb098c5c4f6f1f48d8bbef4c0c94d7c2f9bc9 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: assetBundleName:
assetBundleVariant: assetBundleVariant:
script: {fileID: 11500000, guid: 625f186215c104763be7675aa2d941aa, type: 3} 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

@@ -36,6 +36,15 @@ GraphicsSettings:
- {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0} - {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0}
m_PreloadedShaders: [] m_PreloadedShaders: []
m_PreloadShadersBatchTimeLimit: -1 m_PreloadShadersBatchTimeLimit: -1
m_GraphicsStateCollection: {fileID: 0}
m_CollectionStartupAction: 0
m_TraceSavePath: TracedCollection
m_TraceSendToEditor: 1
m_AdditionalWarmupCollections: []
m_WarmupAsync: 1
m_EnableCacheMissTracing: 0
m_WarmupProgressivelyLimit: -1
m_CacheMissCollectionPath: CacheMissCollection
m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
m_CustomRenderPipeline: {fileID: 0} m_CustomRenderPipeline: {fileID: 0}
m_TransparencySortMode: 0 m_TransparencySortMode: 0
@@ -61,6 +70,8 @@ GraphicsSettings:
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 93b439a37f63240aca3dd4e01d978a9f, type: 2} UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 93b439a37f63240aca3dd4e01d978a9f, type: 2}
m_ShaderBuildSettings: m_ShaderBuildSettings:
keywordDeclarationOverrides: [] keywordDeclarationOverrides: []
numInternalDefines: 0
defines: []
m_LightsUseLinearIntensity: 1 m_LightsUseLinearIntensity: 1
m_LightsUseColorTemperature: 1 m_LightsUseColorTemperature: 1
m_LogWhenShaderIsCompiled: 0 m_LogWhenShaderIsCompiled: 0

View File

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