Merge remote-tracking branch 'origin/savya' into work_branch
This commit is contained in:
BIN
Assets/Darkmatter/.DS_Store
vendored
BIN
Assets/Darkmatter/.DS_Store
vendored
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 224a4c0e39279476aa91487a2572bbdd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace Darkmatter.Core.Contracts.Features.SaveGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Cross-scene gate in front of "save to gallery": shows a kid-friendly prompt and, on confirm,
|
||||
/// a rewarded ad. Root-scoped so any feature in any scene (capture, art book, …) can reuse it.
|
||||
/// </summary>
|
||||
public interface IRewardedSaveGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows the prompt and (on Watch) a rewarded ad. Returns true if the caller should save.
|
||||
/// </summary>
|
||||
UniTask<bool> RequestSaveAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Briefly shows the shared "Saved to gallery!" confirmation. For callers without their own
|
||||
/// success popup (e.g. the art book). No-op if the overlay has no success panel wired.
|
||||
/// </summary>
|
||||
UniTask ShowSavedAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c8ca87e84abf64a0eb0c32de824318b6
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Enums.Features.Tutorial;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Core.Contracts.Features.Tutorial
|
||||
@@ -18,19 +19,23 @@ namespace Darkmatter.Core.Contracts.Features.Tutorial
|
||||
/// Spotlight a single tap target. When <paramref name="target"/> is null the dim/hole are
|
||||
/// skipped and only a centered bubble is shown (a non-blocking hint).
|
||||
/// When <paramref name="blockInput"/> is true every touch outside the hole is swallowed.
|
||||
/// <paramref name="placement"/> overrides where the instruction bubble sits; <paramref name="offset"/>
|
||||
/// nudges it from that slot (px, +x right / +y up).
|
||||
/// </summary>
|
||||
void ShowTap(RectTransform target, string message, bool blockInput);
|
||||
void ShowTap(RectTransform target, string message, bool blockInput, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
|
||||
|
||||
/// <summary>
|
||||
/// Spotlight a drag gesture from <paramref name="from"/> to <paramref name="to"/>. Input is
|
||||
/// never blocked here, so the dragged piece can render above the dim.
|
||||
/// <paramref name="placement"/> overrides where the instruction bubble sits; <paramref name="offset"/>
|
||||
/// nudges it from that slot (px, +x right / +y up).
|
||||
/// </summary>
|
||||
void ShowDrag(RectTransform from, RectTransform to, string message);
|
||||
void ShowDrag(RectTransform from, RectTransform to, string message, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
|
||||
|
||||
/// <summary>Hide everything immediately (used across scene swaps).</summary>
|
||||
void HideInstant();
|
||||
|
||||
/// <summary>Show a centered celebratory bubble for a short beat, then hide.</summary>
|
||||
UniTask ShowToastAsync(string message, CancellationToken ct);
|
||||
UniTask ShowToastAsync(string message, CancellationToken ct, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace Darkmatter.Core.Contracts.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical analytics event names. Use these constants everywhere — never raw strings — so the
|
||||
/// Firebase and Facebook sinks stay identical and typo-free. GA4 limit: 500 distinct event names.
|
||||
/// Names are snake_case and <= 40 chars (Facebook App Events limit). Lives in Core so any feature
|
||||
/// or service can reference them alongside <see cref="IAnalyticsService"/>.
|
||||
/// </summary>
|
||||
public static class AnalyticsEvents
|
||||
{
|
||||
// Onboarding / activation funnel
|
||||
public const string IntroStarted = "intro_started";
|
||||
public const string IntroCompleted = "intro_completed";
|
||||
public const string TutorialStarted = "tutorial_started";
|
||||
public const string TutorialStepCompleted = "tutorial_step_completed";
|
||||
public const string TutorialCompleted = "tutorial_completed";
|
||||
public const string PlayClicked = "play_clicked";
|
||||
public const string FirstDrawingStarted = "first_drawing_started";
|
||||
|
||||
// Navigation
|
||||
public const string ColorbookOpened = "colorbook_opened";
|
||||
public const string ArtbookOpened = "artbook_opened";
|
||||
public const string MainMenuReturned = "main_menu_returned";
|
||||
|
||||
// Core gameplay loop
|
||||
public const string DrawingSelected = "drawing_selected";
|
||||
public const string DrawingStarted = "drawing_started";
|
||||
public const string DrawingCompleted = "drawing_completed";
|
||||
public const string DrawingAbandoned = "drawing_abandoned";
|
||||
public const string DrawingSaved = "drawing_saved";
|
||||
public const string ShapeBuilderStarted = "shape_builder_started";
|
||||
public const string ShapeAssembled = "shape_assembled";
|
||||
public const string ColorApplied = "color_applied";
|
||||
|
||||
// Progression & content (GA4 recommended level_start / level_complete)
|
||||
public const string LevelStart = "level_start";
|
||||
public const string LevelComplete = "level_complete";
|
||||
public const string AllContentCompleted = "all_content_completed";
|
||||
|
||||
// Gallery capture (pre-existing)
|
||||
public const string GallerySaveStarted = "gallery_save_started";
|
||||
public const string GallerySaveCompleted = "gallery_save_completed";
|
||||
|
||||
// Monetization (GA4 recommended ad_impression)
|
||||
public const string AdImpression = "ad_impression";
|
||||
public const string AdClicked = "ad_clicked";
|
||||
|
||||
// Friction
|
||||
public const string ErrorShown = "error_shown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44ad2e947d64f40a38febad220fa92a1
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Darkmatter.Core.Contracts.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical analytics parameter keys. Pass a consistent set on every gameplay event so reports can
|
||||
/// answer "which drawing did people quit on", not just "how many quit". Each key used in reports must
|
||||
/// be registered as a Custom Dimension in the Firebase console. GA4 limit: 25 params per event.
|
||||
/// GA4-recommended events (level_start/level_complete/ad_impression) reuse GA4's reserved param names
|
||||
/// (level_name, success, value, currency, ad_platform, ad_format, ad_unit_name) so they're recognised.
|
||||
/// </summary>
|
||||
public static class AnalyticsParams
|
||||
{
|
||||
// Content identity
|
||||
public const string DrawingId = "drawing_id";
|
||||
public const string LevelName = "level_name"; // GA4 reserved (level_start/level_complete)
|
||||
|
||||
// Tutorial
|
||||
public const string StepId = "step_id";
|
||||
public const string StepIndex = "step_index";
|
||||
|
||||
// Gameplay metrics
|
||||
public const string DurationSeconds = "duration_seconds";
|
||||
public const string ColorsUsed = "colors_used";
|
||||
public const string Attempts = "attempts";
|
||||
public const string ApplyIndex = "apply_index";
|
||||
public const string CompletionCount = "completion_count";
|
||||
public const string Success = "success"; // GA4 reserved (level_complete)
|
||||
|
||||
// Monetization (GA4 ad_impression reserved names)
|
||||
public const string Value = "value";
|
||||
public const string Currency = "currency";
|
||||
public const string Precision = "precision";
|
||||
public const string AdPlatform = "ad_platform";
|
||||
public const string AdFormat = "ad_format";
|
||||
public const string AdUnitName = "ad_unit_name";
|
||||
|
||||
// Friction
|
||||
public const string ErrorType = "error_type";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a7d4e7d6ad7e4ba39f557ddc970890c
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Darkmatter.Core.Data.Signals.Features.AppBoot;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the intro/logo video begins playing at app boot. Paired with
|
||||
/// <see cref="IntroCompletedSignal"/> to bound the top of the activation funnel.
|
||||
/// </summary>
|
||||
public record struct IntroStartedSignal();
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ebc4c1a0dab8d4b4fb95428c30f57f35
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Darkmatter.Core.Data.Signals.Features.Coloring
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised the moment every region of the current drawing has been painted away from its authored
|
||||
/// (uncoloured) default — i.e. the picture is fully coloured. Published right after the
|
||||
/// <see cref="ColorAppliedSignal"/> that completes it. Used by the onboarding tutorial to hold the
|
||||
/// "Tap Next" prompt until the child has coloured the whole picture.
|
||||
/// </summary>
|
||||
public record struct AllRegionsColoredSignal;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 381ea33520a140d8b5639279c5390d2c
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Darkmatter.Core.Data.Signals.Features.GameplayFlow
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised once the player has completed every drawing in the catalog (no content left). Published by
|
||||
/// the gameplay flow, which owns the catalog + progression; analytics dedupes it to once-ever.
|
||||
/// </summary>
|
||||
public record struct AllContentCompletedSignal(int CompletedCount);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3dc3c138deba5452ebb0d84ad3d4bf44
|
||||
@@ -6,9 +6,13 @@ namespace Darkmatter.Core.Data.Static.Features.ShapeBuilder
|
||||
menuName = "Darkmatter/ShapeBuilder/Config")]
|
||||
public sealed class ShapeBuilderConfig : ScriptableObject
|
||||
{
|
||||
[Header("Radii (canvas units; reference resolution 2048x2048)")]
|
||||
[SerializeField] private float snapRadius = 100f;
|
||||
[SerializeField] private float previewRadius = 200f;
|
||||
// Single catch radius for both preview and snap, expressed as a multiple of
|
||||
// EACH slot's own half-diagonal — so a big slot gets a big catch and a small
|
||||
// slot a small one, instead of one flat distance for all. 1 = exactly the
|
||||
// slot's circumscribed circle; larger = more forgiving. Computed in canvas
|
||||
// reference units, so it feels the same on every screen resolution/aspect.
|
||||
[Header("Catch radius (multiple of slot half-diagonal)")]
|
||||
[SerializeField, Range(0.25f, 2.5f)] private float catchRadiusScale = 1.1f;
|
||||
|
||||
[Header("Tween durations (seconds)")]
|
||||
[SerializeField] private float snapDuration = 0.25f;
|
||||
@@ -18,16 +22,11 @@ namespace Darkmatter.Core.Data.Static.Features.ShapeBuilder
|
||||
[SerializeField, Range(1f, 2f)] private float dragScale = 1.15f;
|
||||
[SerializeField, Range(0f, 1f)] private float dragAlpha = 0.7f;
|
||||
|
||||
[Header("Preview easing")]
|
||||
[SerializeField] private AnimationCurve previewCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
||||
|
||||
public float SnapRadius => snapRadius;
|
||||
public float PreviewRadius => previewRadius;
|
||||
public float CatchRadiusScale => catchRadiusScale;
|
||||
public float SnapDuration => snapDuration;
|
||||
public float ReturnDuration => returnDuration;
|
||||
public float DragScale => dragScale;
|
||||
public float DragAlpha => dragAlpha;
|
||||
public AnimationCurve PreviewCurve => previewCurve;
|
||||
|
||||
public Vector2 DragSizeDelta(ShapeSO shape) =>
|
||||
shape != null ? shape.DefaultSizeDelta : new Vector2(256, 256);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Darkmatter.Core.Enums.Features.Tutorial;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Core.Data.Static.Features.Tutorial
|
||||
@@ -17,9 +18,28 @@ namespace Darkmatter.Core.Data.Static.Features.Tutorial
|
||||
[SerializeField] private string finishText = "Now finish the puzzle!";
|
||||
[SerializeField] private string colorText = "Choose a color!";
|
||||
[SerializeField] private string paintText = "Tap the picture to color it!";
|
||||
[SerializeField] private string finishColoringText = "Color the whole picture!";
|
||||
[SerializeField] private string nextText = "Tap Next when you're done!";
|
||||
[SerializeField] private string doneText = "Yay! You did it!";
|
||||
|
||||
[Header("Bubble placement + offset per step (Auto = smart; offset nudges from the slot in px, +x right / +y up)")]
|
||||
[SerializeField] private BubblePlacement pickBubble = BubblePlacement.Auto;
|
||||
[SerializeField] private Vector2 pickBubbleOffset;
|
||||
[SerializeField] private BubblePlacement dragBubble = BubblePlacement.Auto;
|
||||
[SerializeField] private Vector2 dragBubbleOffset;
|
||||
[SerializeField] private BubblePlacement finishBubble = BubblePlacement.Auto;
|
||||
[SerializeField] private Vector2 finishBubbleOffset;
|
||||
[SerializeField] private BubblePlacement colorBubble = BubblePlacement.Auto;
|
||||
[SerializeField] private Vector2 colorBubbleOffset;
|
||||
[SerializeField] private BubblePlacement paintBubble = BubblePlacement.Auto;
|
||||
[SerializeField] private Vector2 paintBubbleOffset;
|
||||
[SerializeField] private BubblePlacement finishColoringBubble = BubblePlacement.Auto;
|
||||
[SerializeField] private Vector2 finishColoringBubbleOffset;
|
||||
[SerializeField] private BubblePlacement nextBubble = BubblePlacement.Auto;
|
||||
[SerializeField] private Vector2 nextBubbleOffset;
|
||||
[SerializeField] private BubblePlacement doneBubble = BubblePlacement.Auto;
|
||||
[SerializeField] private Vector2 doneBubbleOffset;
|
||||
|
||||
[Header("Watchdog")]
|
||||
[Tooltip("Timeout (s) while waiting for a SYSTEM precondition (scene/catalog/regions ready). If it " +
|
||||
"doesn't arrive the tutorial fails open so the player is never trapped.")]
|
||||
@@ -34,9 +54,28 @@ namespace Darkmatter.Core.Data.Static.Features.Tutorial
|
||||
public string FinishText => finishText;
|
||||
public string ColorText => colorText;
|
||||
public string PaintText => paintText;
|
||||
public string FinishColoringText => finishColoringText;
|
||||
public string NextText => nextText;
|
||||
public string DoneText => doneText;
|
||||
|
||||
public BubblePlacement PickBubble => pickBubble;
|
||||
public BubblePlacement DragBubble => dragBubble;
|
||||
public BubblePlacement FinishBubble => finishBubble;
|
||||
public BubblePlacement ColorBubble => colorBubble;
|
||||
public BubblePlacement PaintBubble => paintBubble;
|
||||
public BubblePlacement FinishColoringBubble => finishColoringBubble;
|
||||
public BubblePlacement NextBubble => nextBubble;
|
||||
public BubblePlacement DoneBubble => doneBubble;
|
||||
|
||||
public Vector2 PickBubbleOffset => pickBubbleOffset;
|
||||
public Vector2 DragBubbleOffset => dragBubbleOffset;
|
||||
public Vector2 FinishBubbleOffset => finishBubbleOffset;
|
||||
public Vector2 ColorBubbleOffset => colorBubbleOffset;
|
||||
public Vector2 PaintBubbleOffset => paintBubbleOffset;
|
||||
public Vector2 FinishColoringBubbleOffset => finishColoringBubbleOffset;
|
||||
public Vector2 NextBubbleOffset => nextBubbleOffset;
|
||||
public Vector2 DoneBubbleOffset => doneBubbleOffset;
|
||||
|
||||
public float StepTimeoutSeconds => stepTimeoutSeconds;
|
||||
public float ActionTimeoutSeconds => actionTimeoutSeconds;
|
||||
}
|
||||
|
||||
8
Assets/Darkmatter/Code/Core/Enums/Features/Tutorial.meta
Normal file
8
Assets/Darkmatter/Code/Core/Enums/Features/Tutorial.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b28ea43ea1b942cab1264b52ec34a60
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Darkmatter.Core.Enums.Features.Tutorial
|
||||
{
|
||||
/// <summary>
|
||||
/// Where the tutorial instruction bubble sits for a given step. <see cref="Auto"/> keeps the
|
||||
/// smart default (sits opposite the spotlighted target so it never covers it; pinned to a screen
|
||||
/// edge for the drag step; upper-centre for target-less hints). The other values override that
|
||||
/// with a fixed vertical slot, horizontally centred.
|
||||
/// </summary>
|
||||
public enum BubblePlacement
|
||||
{
|
||||
Auto,
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 61e68e58d45cf4314886e2a414e3856d
|
||||
@@ -44,6 +44,7 @@ namespace Darkmatter.Features.AppBoot.Flow
|
||||
|
||||
player.loopPointReached += OnDone;
|
||||
player.Play();
|
||||
_eventBus.Publish(new IntroStartedSignal());
|
||||
await _sceneService.LoadSceneAsync(nameof(GameScene.MainMenu), null, cancellation);
|
||||
|
||||
await tcs.Task.AttachExternalCancellation(cancellation);
|
||||
|
||||
@@ -5,6 +5,7 @@ using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core;
|
||||
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
|
||||
using Darkmatter.Core.Contracts.Features.Progression;
|
||||
using Darkmatter.Core.Contracts.Features.SaveGate;
|
||||
using Darkmatter.Core.Contracts.Services.Gallery;
|
||||
using Darkmatter.Core.Data.Signals.Features.Drawing;
|
||||
using Darkmatter.Libs.Observer;
|
||||
@@ -22,6 +23,7 @@ namespace Darkmatter.Features.Artbook
|
||||
private readonly IProgressionSystem _progression;
|
||||
private readonly IDrawingTemplateCatalog _catalog;
|
||||
private readonly IGalleryService _gallery;
|
||||
private readonly IRewardedSaveGate _saveGate;
|
||||
|
||||
private readonly List<ArtbookEntry> _entries = new();
|
||||
private readonly List<Sprite> _ownedSprites = new();
|
||||
@@ -36,13 +38,15 @@ namespace Darkmatter.Features.Artbook
|
||||
IEventBus eventBus,
|
||||
IProgressionSystem progression,
|
||||
IDrawingTemplateCatalog catalog,
|
||||
IGalleryService gallery)
|
||||
IGalleryService gallery,
|
||||
IRewardedSaveGate saveGate)
|
||||
{
|
||||
_view = view;
|
||||
_eventBus = eventBus;
|
||||
_progression = progression;
|
||||
_catalog = catalog;
|
||||
_gallery = gallery;
|
||||
_saveGate = saveGate;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -151,7 +155,11 @@ namespace Darkmatter.Features.Artbook
|
||||
{
|
||||
if (!entry.HasValue || entry.Value.Thumbnail == null) return;
|
||||
var ct = _cts?.Token ?? CancellationToken.None;
|
||||
// Same kid-friendly prompt + rewarded ad as the gameplay save button.
|
||||
if (!await _saveGate.RequestSaveAsync(ct)) return;
|
||||
await _gallery.SaveImageAsync(entry.Value.Thumbnail, entry.Value.Name, ct);
|
||||
// The art book has no success popup of its own, so use the shared toast.
|
||||
await _saveGate.ShowSavedAsync(ct);
|
||||
}
|
||||
|
||||
private void HandleLeftEditClicked() => OpenForEdit(GetLeftEntry());
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace Darkmatter.Features.Capture
|
||||
builder.RegisterInstance(new CaptureConfig(captureScale));
|
||||
builder.Register<ICaptureFeature, CaptureSystem>(Lifetime.Singleton);
|
||||
|
||||
// IRewardedSaveGate is resolved from the root scope (SaveGateModule in Boot).
|
||||
if (captureButtonView != null)
|
||||
builder.RegisterEntryPoint<CaptureButtonPresenter>().WithParameter(captureButtonView);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Features.Capture;
|
||||
using Darkmatter.Core.Contracts.Features.SaveGate;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Features.Capture.UI
|
||||
@@ -9,15 +11,20 @@ namespace Darkmatter.Features.Capture.UI
|
||||
{
|
||||
private readonly CaptureButtonView _view;
|
||||
private readonly ICaptureFeature _capture;
|
||||
private readonly IRewardedSaveGate _saveGate;
|
||||
|
||||
public CaptureButtonPresenter(CaptureButtonView view, ICaptureFeature capture)
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
public CaptureButtonPresenter(CaptureButtonView view, ICaptureFeature capture, IRewardedSaveGate saveGate)
|
||||
{
|
||||
_view = view;
|
||||
_capture = capture;
|
||||
_saveGate = saveGate;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_view.OnCaptureClicked += HandleCaptureClicked;
|
||||
}
|
||||
|
||||
@@ -25,11 +32,16 @@ namespace Darkmatter.Features.Capture.UI
|
||||
|
||||
private async UniTaskVoid CaptureAsync()
|
||||
{
|
||||
var ct = _cts?.Token ?? CancellationToken.None;
|
||||
_view.SetInteractable(false);
|
||||
try
|
||||
{
|
||||
await _capture.CapturePngAsync(saveToGallery: true);
|
||||
// Kid-friendly prompt + rewarded ad (shared, cross-scene gate). The save then
|
||||
// publishes the existing signals that drive the "Saved to gallery!" popup.
|
||||
if (await _saveGate.RequestSaveAsync(ct))
|
||||
await _capture.CapturePngAsync(saveToGallery: true, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
_view.SetInteractable(true);
|
||||
@@ -39,6 +51,9 @@ namespace Darkmatter.Features.Capture.UI
|
||||
public void Dispose()
|
||||
{
|
||||
_view.OnCaptureClicked -= HandleCaptureClicked;
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
private IDisposable _selectedSub;
|
||||
private IDisposable _returnToMainMenuSubscription;
|
||||
private bool _navigatingToGameplay;
|
||||
private int _selectCount;
|
||||
private CancellationTokenSource _scopeCts;
|
||||
|
||||
public ColorbookFlowController(
|
||||
@@ -110,9 +111,14 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
_loadingScreen.Show();
|
||||
_loadingScreen.SetProgress(0f);
|
||||
|
||||
// Fire the interstitial but never await it: the ad overlays the transition while the level
|
||||
// loads underneath, so a missed/dropped ad callback can't stall the flow at 0% anymore.
|
||||
ShowInterstitialAdAsync(ct).Forget();
|
||||
// Frequency cap: show an interstitial on every 2nd level open (2nd, 4th, ...). On skip turns
|
||||
// just keep one prewarmed so the next show has an ad ready. Fire-and-forget either way — the
|
||||
// ad overlays the transition while the level loads underneath, so a missed/dropped ad callback
|
||||
// can't stall the flow at 0% anymore.
|
||||
if (++_selectCount % 2 == 0)
|
||||
ShowInterstitialAdAsync(ct).Forget();
|
||||
else
|
||||
PrewarmInterstitialAdAsync(ct).Forget();
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -79,6 +79,21 @@ public class ColoringController : IColoringController, IDisposable
|
||||
if (from != color)
|
||||
_history.Push(new ColorRegionCommand(region, from, color));
|
||||
_bus.Publish(new ColorAppliedSignal(regionId, color));
|
||||
if (AllRegionsColored())
|
||||
_bus.Publish(new AllRegionsColoredSignal());
|
||||
}
|
||||
|
||||
// True once every region has been painted away from its authored (uncoloured) default.
|
||||
private bool AllRegionsColored()
|
||||
{
|
||||
if (_regions.Count == 0) return false;
|
||||
foreach (var region in _regions)
|
||||
{
|
||||
if (region == null) continue;
|
||||
if (_authoredColors.TryGetValue(region.RegionId, out var authored) && region.Color == authored)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, Color> GetCurrentColors()
|
||||
|
||||
@@ -25,10 +25,16 @@ namespace Darkmatter.Features.Coloring.UI
|
||||
rt.localScale = startScale;
|
||||
rt.localRotation = Quaternion.identity;
|
||||
await Tween.Scale(rt, endScale, duration, ease).ToUniTask(cancellationToken: ct);
|
||||
// The view can be destroyed mid-animation (flow advances, Back/Clear, scene teardown).
|
||||
// PrimeTween auto-stops the tween on target death and ToUniTask resolves normally, so
|
||||
// re-check the (Unity fake-null) transform before touching it again — otherwise the
|
||||
// localRotation write below hits a freed native object and throws NullReferenceException.
|
||||
if (rt == null) return;
|
||||
|
||||
await Tween.LocalRotation(rt, new Vector3(0f, 0f, wiggleAngle), wiggleDuration, Ease.InOutSine,
|
||||
cycles: wiggleCycles * 2, cycleMode: CycleMode.Yoyo)
|
||||
.ToUniTask(cancellationToken: ct);
|
||||
if (rt == null) return;
|
||||
rt.localRotation = Quaternion.identity;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
private IDrawingTemplate _template;
|
||||
private string _templateId;
|
||||
private DrawingPhase _phase = DrawingPhase.ShapeBuilding;
|
||||
private bool _allContentReported;
|
||||
|
||||
private IDisposable _assembledSub;
|
||||
private IDisposable _colorAppliedSub;
|
||||
@@ -151,6 +152,16 @@ namespace Darkmatter.Features.GameplayFlow.Systems
|
||||
var progressAfter = _progression.GetProgress(_templateId);
|
||||
_bus.Publish(new DrawingCompletedSignal(_templateId, progressAfter?.completionCount ?? 1));
|
||||
|
||||
// Player has cleared the whole catalog → surface the "ran out of content" milestone. Guarded
|
||||
// per session here; analytics dedupes it to once-ever.
|
||||
if (!_allContentReported &&
|
||||
_catalog.AllTemplateIds.Count > 0 &&
|
||||
_progression.CompletedTemplateIds.Count >= _catalog.AllTemplateIds.Count)
|
||||
{
|
||||
_allContentReported = true;
|
||||
_bus.Publish(new AllContentCompletedSignal(_progression.CompletedTemplateIds.Count));
|
||||
}
|
||||
|
||||
var nextId = _catalog.GetNextTemplate(_templateId);
|
||||
if (string.IsNullOrEmpty(nextId))
|
||||
{
|
||||
|
||||
8
Assets/Darkmatter/Code/Features/SaveGate.meta
Normal file
8
Assets/Darkmatter/Code/Features/SaveGate.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e6434fcc29f4e4949be4be8115fda2b9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Features.SaveGate",
|
||||
"rootNamespace": "Darkmatter.Features.SaveGate",
|
||||
"references": [
|
||||
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
||||
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
|
||||
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
||||
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||
"GUID:6055be8ebefd69e48b49212b09b47b2f"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 48cf1e0dac50c4952b2f27e17e8a065a
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Darkmatter/Code/Features/SaveGate/Installers.meta
Normal file
8
Assets/Darkmatter/Code/Features/SaveGate/Installers.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f38ec6a6d3544172b3fdf20e8d31800
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
using Darkmatter.Features.SaveGate.Systems;
|
||||
using Darkmatter.Features.SaveGate.UI;
|
||||
using Darkmatter.Libs.Installers;
|
||||
using UnityEngine;
|
||||
using VContainer;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Features.SaveGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Root module (add to Boot's RootLifetimeScope service modules) so the gate + overlay are
|
||||
/// resolvable and persistent across every scene. If no overlay is assigned it spawns a
|
||||
/// self-building one at runtime — no scene/prefab setup required.
|
||||
/// </summary>
|
||||
public class SaveGateModule : MonoBehaviour, IModule
|
||||
{
|
||||
[SerializeField] private SaveGalleryOverlayView overlayView;
|
||||
[SerializeField, Tooltip("Save anyway when the rewarded ad has no fill or fails to show.")]
|
||||
private bool saveWithoutAdOnFailure = true;
|
||||
|
||||
public void Register(IContainerBuilder builder)
|
||||
{
|
||||
|
||||
// Explicit type so a null overlay still resolves; the gate then lets saving proceed
|
||||
// without a prompt.
|
||||
builder.RegisterEntryPoint<RewardedSaveGate>()
|
||||
.WithParameter(typeof(SaveGalleryOverlayView), overlayView)
|
||||
.WithParameter(new SaveGateConfig(saveWithoutAdOnFailure));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46ff2e3f5fac64fc9a40fe00a37bd9c8
|
||||
61
Assets/Darkmatter/Code/Features/SaveGate/SETUP.md
Normal file
61
Assets/Darkmatter/Code/Features/SaveGate/SETUP.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# SaveGate — cross-scene rewarded-ad save gate (manual Unity wiring)
|
||||
|
||||
Code is complete and compiles. The gate does **not** show its prompt until the overlay is built in
|
||||
**Boot** and assigned to `SaveGateModule`. Until then both callers fall back to saving directly
|
||||
(no prompt), so nothing breaks pre-wiring.
|
||||
|
||||
## What it does
|
||||
|
||||
A single root-scoped gate (`IRewardedSaveGate` → `RewardedSaveGate`) used by **every** save-to-gallery
|
||||
button, in any scene:
|
||||
|
||||
1. Tap save → **kid-friendly prompt** ("Watch a short video to save your picture to the gallery!")
|
||||
with **Watch** / **Cancel**.
|
||||
2. **Cancel** → nothing saved.
|
||||
3. **Watch** → **rewarded ad** (`AdFormat.Rewarded`). Reward earned → caller saves.
|
||||
4. Ad closed early → not saved. No fill / load-or-show error → saved anyway iff
|
||||
**Save Without Ad On Failure** (default ON).
|
||||
|
||||
Callers today:
|
||||
- **Gameplay capture/save button** (`CaptureButtonPresenter`) — gate, then its existing
|
||||
**GallerySaveView** "Saved to gallery!" popup (unchanged).
|
||||
- **Art book** page save buttons (`ArtbookPresenter`) — gate, then the gate's **shared success toast**
|
||||
(the art book has no popup of its own).
|
||||
|
||||
It lives in **Boot** (never unloaded), so the same overlay shows over MainMenu / Colorbook / Gameplay
|
||||
— the loading-screen trick.
|
||||
|
||||
## The overlay prefab (ready-made)
|
||||
|
||||
A complete, wired overlay prefab already exists:
|
||||
|
||||
`Assets/Darkmatter/Content/Colorbook UI/Prefabs/UI/SaveGalleryOverlay.prefab`
|
||||
|
||||
It has its own screen-space Canvas (sort order 5100, 1080×1920 scaler — matches `TutorialOverlayCanvas`),
|
||||
the `SaveGalleryOverlayView` on the root with all six fields wired, and:
|
||||
- **PromptPanel** → dim backdrop + cream Card → Fredoka message, green **Watch video**, grey **Not now**.
|
||||
- **SuccessPanel** (starts inactive) → dark toast → "Saved to gallery!".
|
||||
|
||||
Edit the copy/colors freely; it uses plain colored rects (no sprite art) so a designer can drop in
|
||||
sprites later. To wire it: just drag the prefab into **Boot** (it's a self-contained Canvas, so it
|
||||
works as-is) and assign it to `SaveGateModule` below. Boot is never unloaded, so it persists across
|
||||
every scene. (Building your own overlay also works — put `SaveGalleryOverlayView` on an always-active
|
||||
object and wire promptPanel / watchButton / cancelButton / promptLabel / successPanel / successLabel.)
|
||||
|
||||
## Register in DI (Boot RootLifetimeScope)
|
||||
|
||||
1. Add a **`SaveGateModule`** component in Boot (it's an `IModule`).
|
||||
2. Add it to the **RootLifetimeScope**'s `serviceModules` list (same list that holds the Ad / Loading
|
||||
modules) so it registers at the root.
|
||||
3. On `SaveGateModule`: assign **Overlay View** → the `SaveGalleryOverlayView`; tick
|
||||
**Save Without Ad On Failure** (recommended).
|
||||
|
||||
`IAdService` is already root-scoped (AdServiceModule in Boot); the gate resolves it automatically, and
|
||||
`CaptureButtonPresenter` / `ArtbookPresenter` resolve `IRewardedSaveGate` from the root.
|
||||
|
||||
## Before release
|
||||
|
||||
- `AdUnitCatalogSO` holds AdMob **test** unit IDs (incl. test Rewarded). Swap in the real Rewarded ID.
|
||||
- Child-directed / COPPA app (IDFA off, `TagForChildDirectedTreatment.True`, `MaxAdContentRating.G`
|
||||
already set). Confirm rewarded ads are permitted under the relevant kids / family policies before
|
||||
shipping rewarded-gated saving.
|
||||
7
Assets/Darkmatter/Code/Features/SaveGate/SETUP.md.meta
Normal file
7
Assets/Darkmatter/Code/Features/SaveGate/SETUP.md.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9db44e977bbde446daa19aa928ee1230
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Darkmatter/Code/Features/SaveGate/Systems.meta
Normal file
8
Assets/Darkmatter/Code/Features/SaveGate/Systems.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 010c08604fc7e44ceb9d069874618ce8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Features.SaveGate;
|
||||
using Darkmatter.Core.Contracts.Services.Ads;
|
||||
using Darkmatter.Core.Enums.Services.Ads;
|
||||
using Darkmatter.Features.SaveGate.UI;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Features.SaveGate.Systems
|
||||
{
|
||||
public class RewardedSaveGate : IRewardedSaveGate, IStartable, IDisposable
|
||||
{
|
||||
private readonly SaveGalleryOverlayView _overlay;
|
||||
private readonly IAdService _ads;
|
||||
private readonly SaveGateConfig _config;
|
||||
|
||||
private CancellationTokenSource _cts;
|
||||
private bool _busy;
|
||||
|
||||
public RewardedSaveGate(SaveGalleryOverlayView overlay, IAdService ads, SaveGateConfig config)
|
||||
{
|
||||
_overlay = overlay;
|
||||
_ads = ads;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
// Warm a rewarded ad so the gate is snappy on first save.
|
||||
PrewarmRewardedAsync(_cts.Token).Forget();
|
||||
}
|
||||
|
||||
public async UniTask<bool> RequestSaveAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Overlay not built yet, or no ad service: never block saving.
|
||||
if (_overlay == null || _ads == null) return true;
|
||||
if (_busy) return false; // a prompt is already up; ignore the extra tap
|
||||
|
||||
_busy = true;
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
_cts?.Token ?? CancellationToken.None, cancellationToken);
|
||||
var ct = linked.Token;
|
||||
try
|
||||
{
|
||||
if (!await ShowPromptAsync(ct)) return false; // child tapped Cancel
|
||||
return await TryShowRewardedAsync(ct); // ad closed early / unavailable
|
||||
}
|
||||
catch (OperationCanceledException) { return false; }
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask ShowSavedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_overlay == null || !_overlay.HasSuccessPanel) return;
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
_cts?.Token ?? CancellationToken.None, cancellationToken);
|
||||
_overlay.ShowSuccess();
|
||||
try
|
||||
{
|
||||
await UniTask.Delay(TimeSpan.FromSeconds(_overlay.SuccessAutoHideSeconds),
|
||||
cancellationToken: linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
_overlay.HideSuccess();
|
||||
}
|
||||
|
||||
// Shows the prompt and resolves true on Watch, false on Cancel (or cancellation).
|
||||
private UniTask<bool> ShowPromptAsync(CancellationToken ct)
|
||||
{
|
||||
var tcs = new UniTaskCompletionSource<bool>();
|
||||
CancellationTokenRegistration reg = default;
|
||||
var done = false;
|
||||
|
||||
void Finish(bool result, bool canceled)
|
||||
{
|
||||
if (done) return;
|
||||
done = true;
|
||||
_overlay.OnWatch -= OnWatch;
|
||||
_overlay.OnCancel -= OnCancel;
|
||||
reg.Dispose();
|
||||
_overlay.HidePrompt();
|
||||
if (canceled) tcs.TrySetCanceled(ct);
|
||||
else tcs.TrySetResult(result);
|
||||
}
|
||||
|
||||
void OnWatch() => Finish(true, false);
|
||||
void OnCancel() => Finish(false, false);
|
||||
|
||||
_overlay.OnWatch += OnWatch;
|
||||
_overlay.OnCancel += OnCancel;
|
||||
_overlay.ShowPrompt();
|
||||
reg = ct.Register(() => Finish(false, true));
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
// Returns true only when the child earned the reward. Falls back to SaveWithoutAdOnFailure
|
||||
// when the ad can't be shown at all; an ad closed early counts as not rewarded -> no save.
|
||||
private async UniTask<bool> TryShowRewardedAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_ads.IsInitialized) await _ads.InitializeAsync(ct);
|
||||
if (!_ads.IsReady(AdFormat.Rewarded) && !await _ads.LoadAsync(AdFormat.Rewarded, ct))
|
||||
return _config.SaveWithoutAdOnFailure;
|
||||
|
||||
var result = await _ads.ShowAsync(AdFormat.Rewarded, ct);
|
||||
if (result.Rewarded) return true;
|
||||
return result.Shown ? false : _config.SaveWithoutAdOnFailure;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"[SaveGate] Rewarded ad gate failed: {ex.Message}");
|
||||
return _config.SaveWithoutAdOnFailure;
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTaskVoid PrewarmRewardedAsync(CancellationToken ct)
|
||||
{
|
||||
if (_ads == null) return;
|
||||
try
|
||||
{
|
||||
if (!_ads.IsInitialized) await _ads.InitializeAsync(ct);
|
||||
if (!_ads.IsReady(AdFormat.Rewarded)) await _ads.LoadAsync(AdFormat.Rewarded, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"[SaveGate] Rewarded prewarm failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bcff7c06c2d77499db81865208f807e7
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace Darkmatter.Features.SaveGate
|
||||
{
|
||||
[Serializable]
|
||||
public struct SaveGateConfig
|
||||
{
|
||||
// When the rewarded ad can't be shown (no fill / load or show error), save anyway instead of
|
||||
// blocking the child. A user who opens the ad and closes it early is never rewarded and does
|
||||
// not trigger this fallback.
|
||||
public bool SaveWithoutAdOnFailure { get; }
|
||||
|
||||
public SaveGateConfig(bool saveWithoutAdOnFailure)
|
||||
{
|
||||
SaveWithoutAdOnFailure = saveWithoutAdOnFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c7343ebeefe9e4db48c41c617347adae
|
||||
8
Assets/Darkmatter/Code/Features/SaveGate/UI.meta
Normal file
8
Assets/Darkmatter/Code/Features/SaveGate/UI.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1356ea11d3edf4c1293bc8dec5ecdb3a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Darkmatter.Features.SaveGate.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Root-scoped, cross-scene overlay for the save-to-gallery flow: a kid-friendly "watch a video"
|
||||
/// prompt and a "Saved!" toast. Dumb view — it shows/hides panels and raises button events;
|
||||
/// <c>RewardedSaveGate</c> drives the flow. Lives in Boot so it persists over every scene.
|
||||
///
|
||||
/// If the panels aren't wired in the inspector it builds the whole UI in code (see
|
||||
/// <see cref="buildUiAtRuntime"/>), so the feature works with no manual prefab setup. A designer
|
||||
/// can still wire custom panels and the code build is skipped.
|
||||
/// </summary>
|
||||
public class SaveGalleryOverlayView : MonoBehaviour
|
||||
{
|
||||
[Header("Watch-ad prompt")] [SerializeField]
|
||||
private GameObject promptPanel;
|
||||
|
||||
[SerializeField] private Button watchButton;
|
||||
[SerializeField] private Button cancelButton;
|
||||
[SerializeField] private TMP_Text promptLabel;
|
||||
|
||||
[Header("Saved toast")] [SerializeField]
|
||||
private GameObject successPanel;
|
||||
|
||||
[SerializeField] private TMP_Text successLabel;
|
||||
[SerializeField, Min(0f)] private float successAutoHideSeconds = 1.5f;
|
||||
|
||||
public event Action OnWatch;
|
||||
public event Action OnCancel;
|
||||
|
||||
public float SuccessAutoHideSeconds => successAutoHideSeconds;
|
||||
public bool HasSuccessPanel => successPanel != null;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (promptPanel != null) promptPanel.SetActive(false);
|
||||
if (successPanel != null) successPanel.SetActive(false);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (watchButton != null) watchButton.onClick.AddListener(() => OnWatch?.Invoke());
|
||||
if (cancelButton != null) cancelButton.onClick.AddListener(() => OnCancel?.Invoke());
|
||||
}
|
||||
|
||||
public void ShowPrompt()
|
||||
{
|
||||
if (successPanel != null) successPanel.SetActive(false);
|
||||
if (promptPanel != null) promptPanel.SetActive(true);
|
||||
}
|
||||
|
||||
public void HidePrompt()
|
||||
{
|
||||
if (promptPanel != null) promptPanel.SetActive(false);
|
||||
}
|
||||
|
||||
public void ShowSuccess()
|
||||
{
|
||||
if (successPanel != null) successPanel.SetActive(true);
|
||||
}
|
||||
|
||||
public void HideSuccess()
|
||||
{
|
||||
if (successPanel != null) successPanel.SetActive(false);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (watchButton != null) watchButton.onClick.RemoveAllListeners();
|
||||
if (cancelButton != null) cancelButton.onClick.RemoveAllListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2688ce9b9d9848719e2ee33cf6eb155
|
||||
@@ -97,7 +97,10 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
||||
_bus = bus;
|
||||
_undo = undo;
|
||||
_trayPos = trayPos;
|
||||
_traySize = shape.DefaultSizeDelta;
|
||||
// Keep the piece at the prefab Image's authored size. Sourcing this from
|
||||
// shape.DefaultSizeDelta let each ShapeSO override the image's default size;
|
||||
// the prefab (with preserveAspect) is the single source of truth now.
|
||||
_traySize = RectTransform.sizeDelta;
|
||||
_dragRoot = dragRoot;
|
||||
|
||||
_homeParent = RectTransform.parent;
|
||||
@@ -151,7 +154,7 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
||||
if (_locked) return;
|
||||
|
||||
var pointerLocal = ScreenToLocal(e.position) + _grabOffset;
|
||||
var hovered = FindSlotUnder(e.position);
|
||||
var hovered = FindSlotForCatch(e.position);
|
||||
bool insidePreview = hovered != null;
|
||||
|
||||
if (insidePreview && !_inPreview)
|
||||
@@ -187,7 +190,11 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
||||
|
||||
SetAlpha(_dragOrigAlpha);
|
||||
|
||||
var target = FindSlotUnder(e.position);
|
||||
// If a slot is already previewing, releasing commits to it. Otherwise catch
|
||||
// a quick drop with no prior preview using the same per-slot radius.
|
||||
var target = _inPreview && _activeSlot != null
|
||||
? _activeSlot
|
||||
: FindSlotForCatch(e.position);
|
||||
if (target != null)
|
||||
{
|
||||
_activeSlot = target;
|
||||
@@ -200,17 +207,45 @@ namespace Darkmatter.Features.ShapeBuilder.UI
|
||||
}
|
||||
}
|
||||
|
||||
private SlotMarker FindSlotUnder(Vector2 screenPos)
|
||||
// Nearest slot whose per-slot catch circle contains the pointer. Each slot's
|
||||
// radius is derived from its own size (see SlotCatchRadius), so big slots catch
|
||||
// from farther than small ones. All distances are in PaperRoot local space —
|
||||
// ScreenPointToLocalPointInRectangle and InverseTransformVector both strip the
|
||||
// CanvasScaler factor, so the catch feels identical on every screen resolution.
|
||||
private SlotMarker FindSlotForCatch(Vector2 screenPos)
|
||||
{
|
||||
if (_candidateSlots == null) return null;
|
||||
Vector2 pointerLocal = ScreenToLocal(screenPos);
|
||||
SlotMarker best = null;
|
||||
float bestSqr = float.MaxValue;
|
||||
foreach (var s in _candidateSlots)
|
||||
{
|
||||
if (s == null) continue;
|
||||
if (s.IsOccupied && s != _activeSlot) continue;
|
||||
if (RectTransformUtility.RectangleContainsScreenPoint(s.RectTransform, screenPos, _eventCam))
|
||||
return s;
|
||||
var srt = s.RectTransform;
|
||||
Vector2 slotLocal = _parentRect.InverseTransformPoint(srt.position);
|
||||
float sqr = (slotLocal - pointerLocal).sqrMagnitude;
|
||||
float radius = SlotCatchRadius(srt);
|
||||
if (sqr <= radius * radius && sqr < bestSqr)
|
||||
{
|
||||
bestSqr = sqr;
|
||||
best = s;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return best;
|
||||
}
|
||||
|
||||
// Catch radius for one slot, in PaperRoot local units: half the slot's diagonal
|
||||
// (its size mapped into parent space, so any slot rotation/mirror/scale is
|
||||
// accounted for) times the config multiplier. Scales per slot — not one flat
|
||||
// distance for all.
|
||||
private float SlotCatchRadius(RectTransform slot)
|
||||
{
|
||||
Vector2 size = slot.rect.size;
|
||||
Vector2 prX = _parentRect.InverseTransformVector(slot.TransformVector(new Vector3(size.x, 0f, 0f)));
|
||||
Vector2 prY = _parentRect.InverseTransformVector(slot.TransformVector(new Vector3(0f, size.y, 0f)));
|
||||
float halfDiagonal = 0.5f * new Vector2(prX.magnitude, prY.magnitude).magnitude;
|
||||
return halfDiagonal * _cfg.CatchRadiusScale;
|
||||
}
|
||||
|
||||
private void AnimatePreviewPose(bool toSlot)
|
||||
|
||||
@@ -11,6 +11,7 @@ using Darkmatter.Core.Data.Signals.Features.GameplayFlow; // DrawingCompletedSig
|
||||
using Darkmatter.Core.Data.Signals.Features.ShapeBuilder; // ShapeBuilderStarted/PieceSnapped/ShapeAssembled
|
||||
using Darkmatter.Core.Data.Signals.Features.Tutorial;
|
||||
using Darkmatter.Core.Data.Static.Features.Tutorial;
|
||||
using Darkmatter.Core.Enums.Features.Tutorial; // BubblePlacement
|
||||
using Darkmatter.Features.Coloring.UI; // ColorButton, ColorRegionView
|
||||
using Darkmatter.Features.DrawingCatalog; // DrawingCatalogButton
|
||||
using Darkmatter.Features.GameplayFlow.UI; // NextButtonView
|
||||
@@ -44,6 +45,8 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
private CancellationTokenSource _runCts;
|
||||
private CancellationToken _ct;
|
||||
private bool _completed;
|
||||
private bool _drawingCompleted;
|
||||
private bool _hasColored;
|
||||
private bool _suspended;
|
||||
private Action _reshow;
|
||||
private int _stepIndex;
|
||||
@@ -74,6 +77,13 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
_navSubs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => Suspend()));
|
||||
_navSubs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => Resume()));
|
||||
_navSubs.Add(_bus.Subscribe<DrawingCatalogReadySignal>(OnCatalogReadyGlobal));
|
||||
// The drawing being completed (Next pressed) is the run's true end — even if the child
|
||||
// finds Next early during free-paint. Flag it so the catalog reload that follows isn't
|
||||
// mistaken for a Back-navigation and doesn't restart the tutorial.
|
||||
_navSubs.Add(_bus.Subscribe<DrawingCompletedSignal>(_ => _drawingCompleted = true));
|
||||
// Painting at least one region means the child has grasped the core colour gesture. If
|
||||
// they then go Back, we finish the tutorial instead of restarting it (OnCatalogReadyGlobal).
|
||||
_navSubs.Add(_bus.Subscribe<ColorAppliedSignal>(_ => _hasColored = true));
|
||||
|
||||
StartRun(skipCatalogWait: false);
|
||||
}
|
||||
@@ -86,6 +96,8 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
_ct = _runCts.Token;
|
||||
_gen++;
|
||||
_suspended = false;
|
||||
_drawingCompleted = false;
|
||||
_hasColored = false;
|
||||
_reshow = null;
|
||||
_stepIndex = 0;
|
||||
_overlay.HideInstant();
|
||||
@@ -108,12 +120,23 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
}
|
||||
|
||||
// The catalog re-appeared while we were mid-gameplay -> the player went Back. Restart from
|
||||
// step 1 (the catalog is already on screen, so skip the wait). Excludes step 6+, whose own
|
||||
// completion loads the catalog.
|
||||
// step 1 (the catalog is already on screen, so skip the wait). Excludes step 7+ (the Next
|
||||
// press), whose own completion loads the catalog.
|
||||
private void OnCatalogReadyGlobal(DrawingCatalogReadySignal _)
|
||||
{
|
||||
if (!_completed && _stepIndex >= 2 && _stepIndex <= 5)
|
||||
StartRun(skipCatalogWait: true);
|
||||
if (_completed || _drawingCompleted || _stepIndex < 2 || _stepIndex > 6) return;
|
||||
|
||||
// Already coloured at least one region before backing out: the child has the core gesture,
|
||||
// so finish the tutorial rather than trapping them in a full restart.
|
||||
if (_hasColored)
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
Complete();
|
||||
_overlay.HideInstant();
|
||||
return;
|
||||
}
|
||||
|
||||
StartRun(skipCatalogWait: true);
|
||||
}
|
||||
|
||||
private void ShowStep(Action show)
|
||||
@@ -154,7 +177,7 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
if (!await WaitForSignalAsync<DrawingCatalogReadySignal>()) return;
|
||||
}
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindFirstCatalogCell(), _config.PickText));
|
||||
ShowStep(() => ShowBlockingTap(FindFirstCatalogCell(), _config.PickText, _config.PickBubble, _config.PickBubbleOffset));
|
||||
if (!await WaitForActionAsync<DrawingSelectedSignal>()) return;
|
||||
EndStep("pick", 1);
|
||||
|
||||
@@ -165,7 +188,7 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
ShowStep(() =>
|
||||
{
|
||||
var (pieceRect, slotRect) = FindFirstPieceAndSlot();
|
||||
if (pieceRect != null) _overlay.ShowDrag(pieceRect, slotRect, _config.DragText);
|
||||
if (pieceRect != null) _overlay.ShowDrag(pieceRect, slotRect, _config.DragText, _config.DragBubble, _config.DragBubbleOffset);
|
||||
else Debug.LogWarning("[Tutorial] No draggable piece found for the drag step.");
|
||||
});
|
||||
if (!await WaitForActionAsync<PieceSnappedSignal>()) return; // any snap teaches the gesture
|
||||
@@ -173,7 +196,7 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
|
||||
// Step 3 — finish the rest of the puzzle freely (non-blocking hint).
|
||||
_stepIndex = 3;
|
||||
ShowStep(() => _overlay.ShowTap(null, _config.FinishText, blockInput: false));
|
||||
ShowStep(() => _overlay.ShowTap(null, _config.FinishText, blockInput: false, _config.FinishBubble, _config.FinishBubbleOffset));
|
||||
if (!await WaitForActionAsync<ShapeAssembledSignal>()) return;
|
||||
EndStep("finish", 3);
|
||||
|
||||
@@ -181,32 +204,55 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
_stepIndex = 4;
|
||||
if (!await WaitForSignalAsync<RegionsInitializedSignal>()) return;
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindFirstColorButton(), _config.ColorText));
|
||||
ShowStep(() => ShowBlockingTap(FindFirstColorButton(), _config.ColorText, _config.ColorBubble, _config.ColorBubbleOffset));
|
||||
if (!await WaitForActionAsync<ColorSelectedSignal>()) return;
|
||||
EndStep("color", 4);
|
||||
|
||||
// Step 5 — paint a region.
|
||||
// Step 5 — paint the first region (teaches the tap by spotlighting one region).
|
||||
_stepIndex = 5;
|
||||
ShowStep(() => ShowBlockingTap(FindLargestRegion(), _config.PaintText));
|
||||
// Watch full-colour completion across both this taught tap and the free-paint step that
|
||||
// follows. A one-region drawing is finished by this very tap, so its signal would fire
|
||||
// before step 6 could subscribe — the flag lets us skip the free-paint step instead of
|
||||
// stranding the child on a hint with nothing left to colour.
|
||||
var fullyColored = false;
|
||||
using var fullColorSub = _bus.Subscribe<AllRegionsColoredSignal>(_ => fullyColored = true);
|
||||
ShowStep(() => ShowBlockingTap(FindLargestRegion(), _config.PaintText, _config.PaintBubble, _config.PaintBubbleOffset));
|
||||
if (!await WaitForActionAsync<ColorAppliedSignal>()) return;
|
||||
EndStep("paint", 5);
|
||||
|
||||
// Step 6 — press Next.
|
||||
// Step 6 — colour the rest of the picture freely (non-blocking hint). Hold here until
|
||||
// every region is filled, so the "Tap Next" prompt only appears once colouring is done.
|
||||
// The child can still reach the live Next button during this open step; if they finish
|
||||
// the drawing early that way, take it as done rather than waiting on a fill that can't come.
|
||||
_stepIndex = 6;
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindNextButton(), _config.NextText));
|
||||
if (!await WaitForActionAsync<DrawingCompletedSignal>()) return;
|
||||
EndStep("next", 6);
|
||||
if (!fullyColored)
|
||||
{
|
||||
ShowStep(() => _overlay.ShowTap(null, _config.FinishColoringText, blockInput: false, _config.FinishColoringBubble, _config.FinishColoringBubbleOffset));
|
||||
await UniTask.WhenAny(
|
||||
WaitForActionAsync<AllRegionsColoredSignal>(),
|
||||
WaitForActionAsync<DrawingCompletedSignal>());
|
||||
}
|
||||
EndStep("finishColoring", 6);
|
||||
|
||||
// Step 7 — celebrate.
|
||||
_stepIndex = 7;
|
||||
await _overlay.ShowToastAsync(_config.DoneText, _ct);
|
||||
// Step 7 — press Next (skipped if the drawing was already completed early).
|
||||
if (!_drawingCompleted)
|
||||
{
|
||||
_stepIndex = 7;
|
||||
await UniTask.NextFrame(_ct);
|
||||
ShowStep(() => ShowBlockingTap(FindNextButton(), _config.NextText, _config.NextBubble, _config.NextBubbleOffset));
|
||||
if (!await WaitForActionAsync<DrawingCompletedSignal>()) return;
|
||||
EndStep("next", 7);
|
||||
}
|
||||
|
||||
// Step 8 — celebrate.
|
||||
_stepIndex = 8;
|
||||
await _overlay.ShowToastAsync(_config.DoneText, _ct, _config.DoneBubble, _config.DoneBubbleOffset);
|
||||
}
|
||||
|
||||
private void ShowBlockingTap(RectTransform target, string message)
|
||||
private void ShowBlockingTap(RectTransform target, string message, BubblePlacement placement, Vector2 offset)
|
||||
{
|
||||
if (target == null) Debug.LogWarning($"[Tutorial] No target found for step: \"{message}\"");
|
||||
_overlay.ShowTap(target, message, blockInput: target != null);
|
||||
_overlay.ShowTap(target, message, blockInput: target != null, placement, offset);
|
||||
}
|
||||
|
||||
private void EndStep(string id, int index)
|
||||
@@ -283,13 +329,16 @@ namespace Darkmatter.Features.Tutorial.Systems
|
||||
|
||||
private static (RectTransform piece, RectTransform slot) FindFirstPieceAndSlot()
|
||||
{
|
||||
// Pieces spawn in data order under one SpawnRoot, so sibling 0 is the top of the tray.
|
||||
// FindObjectsByType returns undefined order — pick the lowest sibling index, not [0].
|
||||
var pieces = UnityEngine.Object.FindObjectsByType<ShapePiece>(FindObjectsSortMode.None);
|
||||
ShapePiece piece = null;
|
||||
var bestIndex = int.MaxValue;
|
||||
foreach (var p in pieces)
|
||||
{
|
||||
if (p == null || p.IsLocked || !p.gameObject.activeInHierarchy) continue;
|
||||
piece = p;
|
||||
break;
|
||||
var index = p.transform.GetSiblingIndex();
|
||||
if (index < bestIndex) { bestIndex = index; piece = p; }
|
||||
}
|
||||
if (piece == null) return (null, null);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Features.Tutorial;
|
||||
using Darkmatter.Core.Enums.Features.Tutorial;
|
||||
using PrimeTween;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
@@ -45,6 +46,8 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
[SerializeField] private float fadeDuration = 0.25f;
|
||||
[SerializeField] private float toastSeconds = 1.6f;
|
||||
[SerializeField] private float bubbleGap = 60f;
|
||||
[Tooltip("Margin (px) from the top/bottom edge for the Top/Bottom bubble placement presets.")]
|
||||
[SerializeField] private float bubbleEdgeMargin = 150f;
|
||||
[SerializeField] private float haloPulseScale = 1.18f;
|
||||
[SerializeField] private float pulseDuration = 0.6f;
|
||||
[SerializeField] private float dragHandDuration = 1.1f;
|
||||
@@ -60,6 +63,8 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
private RectTransform _area;
|
||||
private Camera _overlayCam;
|
||||
private Mode _mode = Mode.Hidden;
|
||||
private BubblePlacement _placement = BubblePlacement.Auto;
|
||||
private Vector2 _bubbleOffset;
|
||||
private RectTransform _target; // tap target
|
||||
private RectTransform _dragFrom; // the piece (drag start)
|
||||
private RectTransform _dragTo; // the slot (drag destination)
|
||||
@@ -91,11 +96,13 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
|
||||
// ── ITutorialOverlay ─────────────────────────────────────────────────
|
||||
|
||||
public void ShowTap(RectTransform target, string message, bool blockInput)
|
||||
public void ShowTap(RectTransform target, string message, bool blockInput, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default)
|
||||
{
|
||||
CacheRefs();
|
||||
KillAnims();
|
||||
SetText(message);
|
||||
_placement = placement;
|
||||
_bubbleOffset = offset;
|
||||
_dragFrom = null;
|
||||
_dragTo = null;
|
||||
|
||||
@@ -122,11 +129,13 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
FadeInQuick();
|
||||
}
|
||||
|
||||
public void ShowDrag(RectTransform from, RectTransform to, string message)
|
||||
public void ShowDrag(RectTransform from, RectTransform to, string message, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default)
|
||||
{
|
||||
CacheRefs();
|
||||
KillAnims();
|
||||
SetText(message);
|
||||
_placement = placement;
|
||||
_bubbleOffset = offset;
|
||||
|
||||
// No dim and no blocking — the piece lives under the overlay and must stay visible and
|
||||
// draggable. Halo + hand travel together along the piece -> slot path, recomputed live.
|
||||
@@ -153,11 +162,13 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
FadeInQuick();
|
||||
}
|
||||
|
||||
public async UniTask ShowToastAsync(string message, CancellationToken ct)
|
||||
public async UniTask ShowToastAsync(string message, CancellationToken ct, BubblePlacement placement = BubblePlacement.Auto, Vector2 offset = default)
|
||||
{
|
||||
CacheRefs();
|
||||
KillAnims();
|
||||
SetText(message);
|
||||
_placement = placement;
|
||||
_bubbleOffset = offset;
|
||||
|
||||
_mode = Mode.Centered;
|
||||
_target = null;
|
||||
@@ -238,8 +249,10 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
if (hand != null)
|
||||
hand.anchoredPosition = centerLocal + new Vector2(0f, -radiusLocal * 0.5f);
|
||||
|
||||
PositionBubble(centerLocal.y, centerLocal.y + radiusLocal, centerLocal.y - radiusLocal,
|
||||
_area.rect.height * 0.5f);
|
||||
if (_placement == BubblePlacement.Auto)
|
||||
PositionBubble(centerLocal.y, centerLocal.y + radiusLocal, centerLocal.y - radiusLocal);
|
||||
else
|
||||
PositionBubblePreset(_placement);
|
||||
}
|
||||
|
||||
// Drag: halo + hand glide piece -> slot, recomputed live so it tracks the real positions.
|
||||
@@ -283,27 +296,59 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
|
||||
// The drag path spans the play area, so pin the bubble to a screen edge instead of hugging
|
||||
// the piece — keeps it off the shapes.
|
||||
PositionBubbleAtEdge(dragBubbleAtTop, dragBubbleEdgeMargin);
|
||||
if (_placement == BubblePlacement.Auto)
|
||||
PositionBubbleAtEdge(dragBubbleAtTop, dragBubbleEdgeMargin);
|
||||
else
|
||||
PositionBubblePreset(_placement);
|
||||
}
|
||||
|
||||
private void LayoutCentered()
|
||||
{
|
||||
if (bubbleRoot == null) return;
|
||||
if (_placement != BubblePlacement.Auto) { PositionBubblePreset(_placement); return; }
|
||||
float halfH = _area.rect.height * 0.5f;
|
||||
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||
bubbleRoot.anchoredPosition =
|
||||
new Vector2(0f, Mathf.Clamp(halfH * 0.45f, -halfH + bubbleHalf, halfH - bubbleHalf));
|
||||
SetBubbleAnchored(new Vector2(0f, halfH * 0.45f));
|
||||
}
|
||||
|
||||
private void PositionBubble(float holeCenterY, float holeTopY, float holeBottomY, float halfH)
|
||||
// Applies the per-step offset to a computed base position and clamps so the bubble always
|
||||
// stays fully on-screen, then commits it. Every bubble-positioning path routes through here.
|
||||
private void SetBubbleAnchored(Vector2 baseLocal)
|
||||
{
|
||||
if (bubbleRoot == null) return;
|
||||
float halfW = _area.rect.width * 0.5f;
|
||||
float halfH = _area.rect.height * 0.5f;
|
||||
float bubbleHalfW = bubbleRoot.rect.width * 0.5f;
|
||||
float bubbleHalfH = bubbleRoot.rect.height * 0.5f;
|
||||
Vector2 p = baseLocal + _bubbleOffset;
|
||||
p.x = Mathf.Clamp(p.x, -halfW + bubbleHalfW, halfW - bubbleHalfW);
|
||||
p.y = Mathf.Clamp(p.y, -halfH + bubbleHalfH, halfH - bubbleHalfH);
|
||||
bubbleRoot.anchoredPosition = p;
|
||||
}
|
||||
|
||||
private void PositionBubble(float holeCenterY, float holeTopY, float holeBottomY)
|
||||
{
|
||||
if (bubbleRoot == null) return;
|
||||
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||
float y = holeCenterY <= 0f
|
||||
? holeTopY + bubbleGap + bubbleHalf
|
||||
: holeBottomY - bubbleGap - bubbleHalf;
|
||||
y = Mathf.Clamp(y, -halfH + bubbleHalf, halfH - bubbleHalf);
|
||||
bubbleRoot.anchoredPosition = new Vector2(0f, y);
|
||||
SetBubbleAnchored(new Vector2(0f, y));
|
||||
}
|
||||
|
||||
// Fixed-slot placement chosen per step (Top/Center/Bottom). Horizontally centred; clamped so
|
||||
// the bubble always stays fully on-screen.
|
||||
private void PositionBubblePreset(BubblePlacement placement)
|
||||
{
|
||||
if (bubbleRoot == null) return;
|
||||
float halfH = _area.rect.height * 0.5f;
|
||||
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||
float y = placement switch
|
||||
{
|
||||
BubblePlacement.Top => halfH - bubbleHalf - bubbleEdgeMargin,
|
||||
BubblePlacement.Bottom => -halfH + bubbleHalf + bubbleEdgeMargin,
|
||||
_ => 0f, // Center
|
||||
};
|
||||
SetBubbleAnchored(new Vector2(0f, y));
|
||||
}
|
||||
|
||||
// Pins the bubble to the top or bottom edge (used for the drag step, whose target spans the
|
||||
@@ -314,8 +359,7 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
float halfH = _area.rect.height * 0.5f;
|
||||
float bubbleHalf = bubbleRoot.rect.height * 0.5f;
|
||||
float y = top ? halfH - bubbleHalf - margin : -halfH + bubbleHalf + margin;
|
||||
y = Mathf.Clamp(y, -halfH + bubbleHalf, halfH - bubbleHalf);
|
||||
bubbleRoot.anchoredPosition = new Vector2(0f, y);
|
||||
SetBubbleAnchored(new Vector2(0f, y));
|
||||
}
|
||||
|
||||
// ── Coordinate conversion (camera-agnostic) ──────────────────────────
|
||||
@@ -405,6 +449,8 @@ namespace Darkmatter.Features.Tutorial.UI
|
||||
private void ApplyHiddenState()
|
||||
{
|
||||
_mode = Mode.Hidden;
|
||||
_placement = BubblePlacement.Auto;
|
||||
_bubbleOffset = Vector2.zero;
|
||||
_target = null;
|
||||
_dragFrom = null;
|
||||
_dragTo = null;
|
||||
|
||||
@@ -3,10 +3,12 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Services.Ads;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using Darkmatter.Core.Data.Dynamic.Services.Ads;
|
||||
using Darkmatter.Core.Data.Static.Services.Ads;
|
||||
using Darkmatter.Core.Enums.Services.Ads;
|
||||
using UnityEngine;
|
||||
using VContainer;
|
||||
using AdFormat = Darkmatter.Core.Enums.Services.Ads.AdFormat;
|
||||
#if GOOGLE_MOBILE_ADS
|
||||
using GoogleMobileAds.Api;
|
||||
@@ -29,9 +31,12 @@ namespace Darkmatter.Services.Ads
|
||||
public bool IsInitialized => _initialized;
|
||||
public event Action<AdFormat, AdLoadState> LoadStateChanged;
|
||||
|
||||
private IAnalyticsService _analytics;
|
||||
private bool _initialized;
|
||||
private bool _hasUserConsent = true;
|
||||
private bool _isChildDirected;
|
||||
// Coloring book is a child-directed app, so default to true. SetConsent can still
|
||||
// override if a consent flow later supplies a different value.
|
||||
private bool _isChildDirected = true;
|
||||
private CancellationTokenSource _lifetimeCts;
|
||||
|
||||
// App interruption state, fed by the Unity lifecycle messages below. A full-screen ad
|
||||
@@ -51,6 +56,12 @@ namespace Darkmatter.Services.Ads
|
||||
private BannerView _banner;
|
||||
#endif
|
||||
|
||||
// Method injection: AdMobAdService is a scene MonoBehaviour registered via RegisterComponent, so
|
||||
// VContainer injects here rather than through a constructor. IAnalyticsService is the Root-scoped
|
||||
// composite (Firebase + Facebook).
|
||||
[Inject]
|
||||
public void Construct(IAnalyticsService analytics) => _analytics = analytics;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_lifetimeCts = new CancellationTokenSource();
|
||||
@@ -208,6 +219,8 @@ namespace Darkmatter.Services.Ads
|
||||
|
||||
_banner?.Destroy();
|
||||
_banner = new BannerView(unitId, MapBannerSize(size), MapBannerPosition(position));
|
||||
_banner.OnAdPaid += v => LogAdImpression(AdFormat.Banner, v);
|
||||
_banner.OnAdClicked += () => LogAdClicked(AdFormat.Banner);
|
||||
|
||||
var tcs = new UniTaskCompletionSource<bool>();
|
||||
_banner.OnBannerAdLoaded += () => tcs.TrySetResult(true);
|
||||
@@ -267,7 +280,9 @@ namespace Darkmatter.Services.Ads
|
||||
: TagForChildDirectedTreatment.Unspecified,
|
||||
TagForUnderAgeOfConsent = _hasUserConsent
|
||||
? TagForUnderAgeOfConsent.Unspecified
|
||||
: TagForUnderAgeOfConsent.True
|
||||
: TagForUnderAgeOfConsent.True,
|
||||
// Child-directed app: cap served ads at G-rated content.
|
||||
MaxAdContentRating = MaxAdContentRating.G
|
||||
};
|
||||
|
||||
if (catalog.TestDeviceIds is { Count: > 0 })
|
||||
@@ -556,17 +571,59 @@ namespace Darkmatter.Services.Ads
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format) =>
|
||||
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format)
|
||||
{
|
||||
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
||||
ad.OnAdPaid += v => LogAdImpression(format, v);
|
||||
ad.OnAdClicked += () => LogAdClicked(format);
|
||||
}
|
||||
|
||||
private void WireFullScreenEvents(RewardedAd ad, AdFormat format) =>
|
||||
private void WireFullScreenEvents(RewardedAd ad, AdFormat format)
|
||||
{
|
||||
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
||||
ad.OnAdPaid += v => LogAdImpression(format, v);
|
||||
ad.OnAdClicked += () => LogAdClicked(format);
|
||||
}
|
||||
|
||||
private void WireFullScreenEvents(RewardedInterstitialAd ad, AdFormat format) =>
|
||||
private void WireFullScreenEvents(RewardedInterstitialAd ad, AdFormat format)
|
||||
{
|
||||
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
||||
ad.OnAdPaid += v => LogAdImpression(format, v);
|
||||
ad.OnAdClicked += () => LogAdClicked(format);
|
||||
}
|
||||
|
||||
private void WireFullScreenEvents(AppOpenAd ad, AdFormat format) =>
|
||||
private void WireFullScreenEvents(AppOpenAd ad, AdFormat format)
|
||||
{
|
||||
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
|
||||
ad.OnAdPaid += v => LogAdImpression(format, v);
|
||||
ad.OnAdClicked += () => LogAdClicked(format);
|
||||
}
|
||||
|
||||
// Ad revenue: GA4-recommended ad_impression carries value+currency from AdMob's paid callback —
|
||||
// this is what surfaces ad revenue in Firebase/GA4. Ad callbacks fire on the Unity main thread
|
||||
// (RaiseAdEventsOnUnityMainThread = true), so logging straight to analytics is safe.
|
||||
private void LogAdImpression(AdFormat format, AdValue value)
|
||||
{
|
||||
_analytics?.LogEvent(AnalyticsEvents.AdImpression, new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.AdPlatform] = "AdMob",
|
||||
[AnalyticsParams.AdFormat] = format.ToString(),
|
||||
[AnalyticsParams.AdUnitName] = catalog.GetUnitId(format, Application.platform) ?? string.Empty,
|
||||
[AnalyticsParams.Value] = value.Value / 1_000_000.0, // AdValue.Value is micros
|
||||
[AnalyticsParams.Currency] = value.CurrencyCode ?? string.Empty,
|
||||
[AnalyticsParams.Precision] = value.Precision.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
private void LogAdClicked(AdFormat format)
|
||||
{
|
||||
_analytics?.LogEvent(AnalyticsEvents.AdClicked, new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.AdPlatform] = "AdMob",
|
||||
[AnalyticsParams.AdFormat] = format.ToString(),
|
||||
[AnalyticsParams.AdUnitName] = catalog.GetUnitId(format, Application.platform) ?? string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
private void ScheduleReload(AdFormat format)
|
||||
{
|
||||
|
||||
@@ -10,8 +10,17 @@ namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
public void Register(IContainerBuilder builder)
|
||||
{
|
||||
builder.RegisterEntryPoint<FirebaseAnalyticsSystem>().As<IAnalyticsService>();
|
||||
// Firebase + Facebook are sinks (registered AsSelf so the composite can inject them);
|
||||
// CompositeAnalyticsService is the single IAnalyticsService the rest of the app consumes.
|
||||
builder.RegisterEntryPoint<FirebaseAnalyticsSystem>().AsSelf();
|
||||
builder.RegisterEntryPoint<FacebookAnalyticsSystem>().AsSelf();
|
||||
builder.Register<CompositeAnalyticsService>(Lifetime.Singleton).As<IAnalyticsService>();
|
||||
|
||||
builder.RegisterEntryPoint<AnalyticsTracker>();
|
||||
builder.RegisterEntryPoint<ErrorAnalyticsTracker>();
|
||||
|
||||
// Feeds the FCM token to AppsFlyer for uninstall measurement (Android-only).
|
||||
builder.RegisterEntryPoint<AppsFlyerUninstallTracker>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,12 @@
|
||||
"name": "Services.Analytics",
|
||||
"rootNamespace": "Darkmatter.Services.Analytics",
|
||||
"references": [
|
||||
"GUID:bd7ea2d41bfe64d229c22616f66e20f7",
|
||||
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
|
||||
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
||||
"GUID:f8c64bb88d959406689053ae3f31183d",
|
||||
"GUID:a0b1547602fc44f6da0a5e755ab3a7ef",
|
||||
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
||||
"GUID:b4c9f7fbf1e144933a1797dc208ece5f"
|
||||
"GUID:b4c9f7fbf1e144933a1797dc208ece5f",
|
||||
"GUID:2a37df438292d4903b4e5159c5de3bf9"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
@@ -20,4 +18,4 @@
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,43 @@ using Darkmatter.Core;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using Darkmatter.Core.Data.Signals.Features.AppBoot;
|
||||
using Darkmatter.Core.Data.Signals.Features.Capture;
|
||||
using Darkmatter.Core.Data.Signals.Features.Coloring;
|
||||
using Darkmatter.Core.Data.Signals.Features.Drawing;
|
||||
using Darkmatter.Core.Data.Signals.Features.GameplayFlow;
|
||||
using Darkmatter.Core.Data.Signals.Features.MainMenu;
|
||||
using Darkmatter.Core.Data.Signals.Features.ShapeBuilder;
|
||||
using Darkmatter.Core.Data.Signals.Features.Tutorial;
|
||||
using Darkmatter.Libs.Observer;
|
||||
using UnityEngine;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// Bridges gameplay signals to analytics events. Holds light per-drawing state (start time, distinct
|
||||
/// colors, shape attempts) so completion/abandon events carry duration_seconds, colors_used and
|
||||
/// attempts — the params that turn "they quit" into "they quit on drawing X after N seconds".
|
||||
///
|
||||
/// Logs once to <see cref="IAnalyticsService"/> (the composite), which fans out to Firebase + Facebook.
|
||||
/// </summary>
|
||||
public sealed class AnalyticsTracker : IStartable, IDisposable
|
||||
{
|
||||
private const int ColorApplySampleEvery = 25; // color_applied is high-frequency → sample
|
||||
private const string FirstDrawingKey = "analytics_first_drawing_started";
|
||||
private const string AllContentKey = "analytics_all_content_completed";
|
||||
|
||||
private readonly IEventBus _bus;
|
||||
private readonly IAnalyticsService _analytics;
|
||||
private readonly List<IDisposable> _subs = new();
|
||||
|
||||
// Per-drawing aggregation state.
|
||||
private string _activeDrawingId;
|
||||
private float _drawingStartTime = -1f;
|
||||
private readonly HashSet<int> _colors = new();
|
||||
private int _colorApplyCount;
|
||||
private int _shapeAttempts;
|
||||
private bool _firstDrawingTracked;
|
||||
|
||||
public AnalyticsTracker(IEventBus bus, IAnalyticsService analytics)
|
||||
{
|
||||
_bus = bus;
|
||||
@@ -28,40 +49,180 @@ namespace Darkmatter.Services.Analytics
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent("intro_completed")));
|
||||
_subs.Add(_bus.Subscribe<PlayBtnClickedSignal>(_ => _analytics.LogEvent("play_clicked")));
|
||||
_subs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => _analytics.LogEvent("colorbook_opened")));
|
||||
_subs.Add(_bus.Subscribe<OpenArtBookSignal>(_ => _analytics.LogEvent("artbook_opened")));
|
||||
_subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => _analytics.LogEvent("main_menu_returned")));
|
||||
_firstDrawingTracked = PlayerPrefs.GetInt(FirstDrawingKey, 0) == 1;
|
||||
|
||||
_subs.Add(_bus.Subscribe<DrawingSelectedSignal>(s =>
|
||||
_analytics.LogEvent("drawing_selected", "template_id", s.TemplateId)));
|
||||
// Onboarding / activation funnel
|
||||
_subs.Add(_bus.Subscribe<IntroStartedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.IntroStarted)));
|
||||
_subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.IntroCompleted)));
|
||||
_subs.Add(_bus.Subscribe<PlayBtnClickedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.PlayClicked)));
|
||||
|
||||
_subs.Add(_bus.Subscribe<ShapeBuilderStartedSignal>(s =>
|
||||
_analytics.LogEvent("shape_builder_started", "template_id", s.TemplateId)));
|
||||
// Navigation
|
||||
_subs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ColorbookOpened)));
|
||||
_subs.Add(_bus.Subscribe<OpenArtBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ArtbookOpened)));
|
||||
_subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => OnReturnToMainMenu()));
|
||||
|
||||
_subs.Add(_bus.Subscribe<ShapeAssembledSignal>(s =>
|
||||
_analytics.LogEvent("shape_assembled", "template_id", s.TemplateId)));
|
||||
// Core gameplay loop
|
||||
_subs.Add(_bus.Subscribe<DrawingSelectedSignal>(s => OnDrawingSelected(s.TemplateId)));
|
||||
_subs.Add(_bus.Subscribe<ColorAppliedSignal>(OnColorApplied));
|
||||
_subs.Add(_bus.Subscribe<ShapeBuilderStartedSignal>(s => OnShapeBuilderStarted(s.TemplateId)));
|
||||
_subs.Add(_bus.Subscribe<PieceSnappedSignal>(_ => _shapeAttempts++));
|
||||
_subs.Add(_bus.Subscribe<PieceUnsnappedSignal>(_ => _shapeAttempts++));
|
||||
_subs.Add(_bus.Subscribe<ShapeAssembledSignal>(s => OnShapeAssembled(s.TemplateId)));
|
||||
_subs.Add(_bus.Subscribe<DrawingCompletedSignal>(OnDrawingCompleted));
|
||||
|
||||
_subs.Add(_bus.Subscribe<DrawingCompletedSignal>(s => _analytics.LogEvent("drawing_completed",
|
||||
// Progression & content
|
||||
_subs.Add(_bus.Subscribe<AllContentCompletedSignal>(OnAllContentCompleted));
|
||||
|
||||
// Capture / save
|
||||
_subs.Add(_bus.Subscribe<GallerySaveStartedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.GallerySaveStarted)));
|
||||
_subs.Add(_bus.Subscribe<GallerySaveCompletedSignal>(OnGallerySaveCompleted));
|
||||
|
||||
// Tutorial
|
||||
_subs.Add(_bus.Subscribe<TutorialStartedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.TutorialStarted)));
|
||||
_subs.Add(_bus.Subscribe<TutorialStepCompletedSignal>(s => _analytics.LogEvent(AnalyticsEvents.TutorialStepCompleted,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["template_id"] = s.TemplateId,
|
||||
["completion_count"] = s.CompletionCount,
|
||||
[AnalyticsParams.StepId] = s.StepId,
|
||||
[AnalyticsParams.StepIndex] = s.StepIndex,
|
||||
})));
|
||||
_subs.Add(_bus.Subscribe<TutorialCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.TutorialCompleted)));
|
||||
}
|
||||
|
||||
_subs.Add(_bus.Subscribe<GallerySaveStartedSignal>(_ => _analytics.LogEvent("gallery_save_started")));
|
||||
_subs.Add(_bus.Subscribe<GallerySaveCompletedSignal>(s =>
|
||||
_analytics.LogEvent("gallery_save_completed", "success", s.Success ? "true" : "false")));
|
||||
private void OnDrawingSelected(string drawingId)
|
||||
{
|
||||
// A new selection while a drawing is still open (uncompleted) = the previous one was abandoned.
|
||||
EndActiveDrawingIfAbandoned();
|
||||
|
||||
_subs.Add(_bus.Subscribe<TutorialStartedSignal>(_ => _analytics.LogEvent("tutorial_started")));
|
||||
_subs.Add(_bus.Subscribe<TutorialStepCompletedSignal>(s => _analytics.LogEvent("tutorial_step_completed",
|
||||
new Dictionary<string, object>
|
||||
_activeDrawingId = drawingId;
|
||||
_drawingStartTime = Time.realtimeSinceStartup;
|
||||
_colors.Clear();
|
||||
_colorApplyCount = 0;
|
||||
_shapeAttempts = 0;
|
||||
|
||||
_analytics.LogEvent(AnalyticsEvents.DrawingSelected, AnalyticsParams.DrawingId, drawingId);
|
||||
_analytics.LogEvent(AnalyticsEvents.DrawingStarted, AnalyticsParams.DrawingId, drawingId);
|
||||
_analytics.LogEvent(AnalyticsEvents.LevelStart, AnalyticsParams.LevelName, drawingId);
|
||||
|
||||
if (!_firstDrawingTracked)
|
||||
{
|
||||
_firstDrawingTracked = true;
|
||||
PlayerPrefs.SetInt(FirstDrawingKey, 1);
|
||||
PlayerPrefs.Save();
|
||||
_analytics.LogEvent(AnalyticsEvents.FirstDrawingStarted, AnalyticsParams.DrawingId, drawingId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnColorApplied(ColorAppliedSignal s)
|
||||
{
|
||||
_colorApplyCount++;
|
||||
Color32 c = s.Color;
|
||||
_colors.Add((c.r << 24) | (c.g << 16) | (c.b << 8) | c.a);
|
||||
|
||||
// High-frequency event: emit only the first apply and every Nth thereafter.
|
||||
if (_colorApplyCount == 1 || _colorApplyCount % ColorApplySampleEvery == 0)
|
||||
{
|
||||
_analytics.LogEvent(AnalyticsEvents.ColorApplied, new Dictionary<string, object>
|
||||
{
|
||||
["step_id"] = s.StepId,
|
||||
["step_index"] = s.StepIndex,
|
||||
})));
|
||||
_subs.Add(_bus.Subscribe<TutorialCompletedSignal>(_ => _analytics.LogEvent("tutorial_completed")));
|
||||
[AnalyticsParams.DrawingId] = _activeDrawingId ?? string.Empty,
|
||||
[AnalyticsParams.ApplyIndex] = _colorApplyCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void OnShapeBuilderStarted(string drawingId)
|
||||
{
|
||||
_shapeAttempts = 0;
|
||||
_analytics.LogEvent(AnalyticsEvents.ShapeBuilderStarted, AnalyticsParams.DrawingId, drawingId);
|
||||
}
|
||||
|
||||
private void OnShapeAssembled(string drawingId)
|
||||
{
|
||||
_analytics.LogEvent(AnalyticsEvents.ShapeAssembled, new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.DrawingId] = drawingId,
|
||||
[AnalyticsParams.Attempts] = _shapeAttempts,
|
||||
});
|
||||
}
|
||||
|
||||
private void OnDrawingCompleted(DrawingCompletedSignal s)
|
||||
{
|
||||
var p = new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.DrawingId] = s.TemplateId,
|
||||
[AnalyticsParams.CompletionCount] = s.CompletionCount,
|
||||
[AnalyticsParams.ColorsUsed] = _colors.Count,
|
||||
};
|
||||
float dur = ElapsedDrawingSeconds();
|
||||
if (dur >= 0f) p[AnalyticsParams.DurationSeconds] = dur;
|
||||
_analytics.LogEvent(AnalyticsEvents.DrawingCompleted, p);
|
||||
|
||||
// GA4 recommended games event (lights up built-in level funnels).
|
||||
_analytics.LogEvent(AnalyticsEvents.LevelComplete, new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.LevelName] = s.TemplateId,
|
||||
[AnalyticsParams.Success] = 1,
|
||||
});
|
||||
|
||||
ClearActiveDrawing();
|
||||
}
|
||||
|
||||
private void OnAllContentCompleted(AllContentCompletedSignal s)
|
||||
{
|
||||
if (PlayerPrefs.GetInt(AllContentKey, 0) == 1) return; // once ever
|
||||
PlayerPrefs.SetInt(AllContentKey, 1);
|
||||
PlayerPrefs.Save();
|
||||
_analytics.LogEvent(AnalyticsEvents.AllContentCompleted,
|
||||
AnalyticsParams.CompletionCount, s.CompletedCount.ToString());
|
||||
}
|
||||
|
||||
private void OnGallerySaveCompleted(GallerySaveCompletedSignal s)
|
||||
{
|
||||
_analytics.LogEvent(AnalyticsEvents.GallerySaveCompleted, AnalyticsParams.Success, s.Success ? "true" : "false");
|
||||
_analytics.LogEvent(AnalyticsEvents.DrawingSaved, new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.DrawingId] = _activeDrawingId ?? string.Empty,
|
||||
[AnalyticsParams.Success] = s.Success ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
private void OnReturnToMainMenu()
|
||||
{
|
||||
EndActiveDrawingIfAbandoned();
|
||||
_analytics.LogEvent(AnalyticsEvents.MainMenuReturned);
|
||||
}
|
||||
|
||||
// Emits drawing_abandoned for an open, uncompleted drawing. Completion clears _activeDrawingId, so
|
||||
// reaching here with a non-null id means the drawing was left without finishing — the leak signal.
|
||||
private void EndActiveDrawingIfAbandoned()
|
||||
{
|
||||
if (_activeDrawingId == null) return;
|
||||
|
||||
var p = new Dictionary<string, object>
|
||||
{
|
||||
[AnalyticsParams.DrawingId] = _activeDrawingId,
|
||||
[AnalyticsParams.ColorsUsed] = _colors.Count,
|
||||
};
|
||||
float dur = ElapsedDrawingSeconds();
|
||||
if (dur >= 0f) p[AnalyticsParams.DurationSeconds] = dur;
|
||||
_analytics.LogEvent(AnalyticsEvents.DrawingAbandoned, p);
|
||||
|
||||
ClearActiveDrawing();
|
||||
}
|
||||
|
||||
private float ElapsedDrawingSeconds()
|
||||
{
|
||||
if (_drawingStartTime < 0f) return -1f;
|
||||
float d = Time.realtimeSinceStartup - _drawingStartTime;
|
||||
return d >= 0f ? d : -1f; // guard against realtime baseline reset across an app relaunch
|
||||
}
|
||||
|
||||
private void ClearActiveDrawing()
|
||||
{
|
||||
_activeDrawingId = null;
|
||||
_drawingStartTime = -1f;
|
||||
_colors.Clear();
|
||||
_colorApplyCount = 0;
|
||||
_shapeAttempts = 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using VContainer.Unity;
|
||||
#if UNITY_ANDROID
|
||||
using AppsFlyerSDK;
|
||||
using Firebase;
|
||||
using Firebase.Messaging;
|
||||
#endif
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// Forwards the Firebase Cloud Messaging registration token to AppsFlyer so it can measure uninstalls.
|
||||
/// Android-only: AppsFlyer's server-uninstall measurement keys off the FCM token. iOS uninstall uses the
|
||||
/// APNs device token via a different AppsFlyer API and is intentionally not wired here, so FCM is not
|
||||
/// initialised on iOS.
|
||||
///
|
||||
/// AppsFlyer itself is started in the Boot scene (AppsFlyerObjectScript); this only feeds it the token.
|
||||
/// </summary>
|
||||
public class AppsFlyerUninstallTracker : IAsyncStartable, IDisposable
|
||||
{
|
||||
public async UniTask StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
#if UNITY_ANDROID
|
||||
DependencyStatus status;
|
||||
try
|
||||
{
|
||||
status = await FirebaseApp.CheckAndFixDependenciesAsync().AsUniTask();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[AppsFlyerUninstall] Firebase init failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status != DependencyStatus.Available)
|
||||
{
|
||||
Debug.LogError($"[AppsFlyerUninstall] Firebase deps unavailable: {status}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribing triggers FCM registration; the current token is delivered here on every refresh.
|
||||
FirebaseMessaging.TokenReceived += OnTokenReceived;
|
||||
#else
|
||||
await UniTask.CompletedTask;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_ANDROID
|
||||
private static void OnTokenReceived(object sender, TokenReceivedEventArgs e)
|
||||
{
|
||||
try { AppsFlyer.updateServerUninstallToken(e.Token); }
|
||||
catch (Exception ex) { Debug.LogError($"[AppsFlyerUninstall] updateServerUninstallToken failed: {ex}"); }
|
||||
}
|
||||
#endif
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
#if UNITY_ANDROID
|
||||
FirebaseMessaging.TokenReceived -= OnTokenReceived;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a08ecb787f14b11a4f8ce163f223a53
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// The single <see cref="IAnalyticsService"/> the app consumes. Fans every call out to all sinks
|
||||
/// (Firebase + Facebook) so call sites log once and both backends receive it. A throwing sink is
|
||||
/// isolated so it can't break the others. To add a sink: add a constructor arg and include it below.
|
||||
/// </summary>
|
||||
public sealed class CompositeAnalyticsService : IAnalyticsService
|
||||
{
|
||||
private readonly IAnalyticsService[] _sinks;
|
||||
|
||||
public CompositeAnalyticsService(FirebaseAnalyticsSystem firebase, FacebookAnalyticsSystem facebook)
|
||||
{
|
||||
_sinks = new IAnalyticsService[] { firebase, facebook };
|
||||
}
|
||||
|
||||
public void LogEvent(string name) =>
|
||||
ForEach(s => s.LogEvent(name));
|
||||
|
||||
public void LogEvent(string name, string paramName, string paramValue) =>
|
||||
ForEach(s => s.LogEvent(name, paramName, paramValue));
|
||||
|
||||
public void LogEvent(string name, IReadOnlyDictionary<string, object> parameters) =>
|
||||
ForEach(s => s.LogEvent(name, parameters));
|
||||
|
||||
public void SetUserProperty(string name, string value) =>
|
||||
ForEach(s => s.SetUserProperty(name, value));
|
||||
|
||||
private void ForEach(Action<IAnalyticsService> action)
|
||||
{
|
||||
foreach (var sink in _sinks)
|
||||
{
|
||||
try { action(sink); }
|
||||
catch (Exception e) { Debug.LogError($"[Analytics] Sink {sink.GetType().Name} failed: {e}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fbc94bef99b574c83a85b6886ff7fd03
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using UnityEngine;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// Forwards Unity error/exception logs to analytics as a sampled <c>error_shown</c> event so
|
||||
/// crash-adjacent friction surfaces in funnels. Capped per session and deduped by error_type to avoid
|
||||
/// event floods / GA4 quota burn. No message text or PII is sent — only the leading token of the
|
||||
/// condition (e.g. "NullReferenceException"). Distinct from Crashlytics (which logs full crashes).
|
||||
/// </summary>
|
||||
public sealed class ErrorAnalyticsTracker : IStartable, IDisposable
|
||||
{
|
||||
private const int MaxPerSession = 25;
|
||||
|
||||
private readonly IAnalyticsService _analytics;
|
||||
private readonly HashSet<string> _seen = new();
|
||||
private int _count;
|
||||
|
||||
public ErrorAnalyticsTracker(IAnalyticsService analytics) => _analytics = analytics;
|
||||
|
||||
public void Start() => Application.logMessageReceived += OnLog;
|
||||
public void Dispose() => Application.logMessageReceived -= OnLog;
|
||||
|
||||
private void OnLog(string condition, string stackTrace, LogType type)
|
||||
{
|
||||
if (type != LogType.Error && type != LogType.Exception) return;
|
||||
if (_count >= MaxPerSession) return;
|
||||
|
||||
string errorType = ExtractType(condition);
|
||||
if (!_seen.Add(errorType)) return; // one per distinct type per session
|
||||
|
||||
_count++;
|
||||
_analytics.LogEvent(AnalyticsEvents.ErrorShown, AnalyticsParams.ErrorType, errorType);
|
||||
}
|
||||
|
||||
// Leading token only — keeps cardinality low and avoids leaking message contents / PII.
|
||||
private static string ExtractType(string condition)
|
||||
{
|
||||
if (string.IsNullOrEmpty(condition)) return "unknown";
|
||||
int colon = condition.IndexOf(':');
|
||||
string head = (colon > 0 ? condition.Substring(0, colon) : condition).Trim();
|
||||
int space = head.IndexOf(' ');
|
||||
if (space > 0) head = head.Substring(0, space);
|
||||
return head.Length > 40 ? head.Substring(0, 40) : head;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 05e0b4bc8382a40c0a78b122ac37a9fa
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Darkmatter.Core.Contracts.Services.Analytics;
|
||||
using Facebook.Unity;
|
||||
using UnityEngine;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
/// <summary>
|
||||
/// Facebook App Events sink. Mirrors <see cref="FirebaseAnalyticsSystem"/>'s readiness queue: events
|
||||
/// logged before FB.Init completes are buffered and flushed on init.
|
||||
///
|
||||
/// Compliance: child-directed app — advertiser-ID collection is OFF (FacebookSettings), so App Events
|
||||
/// flow without IDFA. Do NOT enable advertiser tracking / ATT here. See memory child-directed-coppa.
|
||||
/// </summary>
|
||||
public class FacebookAnalyticsSystem : IAnalyticsService, IStartable
|
||||
{
|
||||
private bool _ready;
|
||||
private readonly Queue<Action> _pending = new();
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (FB.IsInitialized) OnInitialized();
|
||||
else FB.Init(OnInitialized);
|
||||
}
|
||||
|
||||
private void OnInitialized()
|
||||
{
|
||||
FB.ActivateApp();
|
||||
_ready = true;
|
||||
while (_pending.Count > 0)
|
||||
{
|
||||
try { _pending.Dequeue().Invoke(); }
|
||||
catch (Exception e) { Debug.LogError($"[FacebookAnalytics] Queued event failed: {e}"); }
|
||||
}
|
||||
}
|
||||
|
||||
public void LogEvent(string name) =>
|
||||
Run(() => FB.LogAppEvent(MapName(name)));
|
||||
|
||||
public void LogEvent(string name, string paramName, string paramValue) =>
|
||||
Run(() => FB.LogAppEvent(MapName(name), null,
|
||||
new Dictionary<string, object> { [paramName] = paramValue ?? string.Empty }));
|
||||
|
||||
public void LogEvent(string name, IReadOnlyDictionary<string, object> parameters)
|
||||
{
|
||||
if (parameters == null || parameters.Count == 0)
|
||||
{
|
||||
LogEvent(name);
|
||||
return;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, object>(parameters.Count);
|
||||
float? valueToSum = null;
|
||||
foreach (var kv in parameters)
|
||||
{
|
||||
// GA4 ad_impression carries revenue in "value"; surface it to FB as the summable value.
|
||||
if (kv.Key == AnalyticsParams.Value && TryToFloat(kv.Value, out var v)) valueToSum = v;
|
||||
dict[kv.Key] = ToFbValue(kv.Value);
|
||||
}
|
||||
Run(() => FB.LogAppEvent(MapName(name), valueToSum, dict));
|
||||
}
|
||||
|
||||
// Facebook App Events has no user-property concept.
|
||||
public void SetUserProperty(string name, string value) { }
|
||||
|
||||
private void Run(Action action)
|
||||
{
|
||||
if (_ready)
|
||||
{
|
||||
try { action(); }
|
||||
catch (Exception e) { Debug.LogError($"[FacebookAnalytics] Event failed: {e}"); }
|
||||
}
|
||||
else _pending.Enqueue(action);
|
||||
}
|
||||
|
||||
// Map our snake_case names to Facebook standard events where one exists; else log as custom.
|
||||
// (This SDK's AppEventName has no Ad* constants, so ad_impression / ad_clicked stay custom.)
|
||||
private static string MapName(string name) => name switch
|
||||
{
|
||||
AnalyticsEvents.TutorialCompleted => AppEventName.CompletedTutorial,
|
||||
AnalyticsEvents.LevelComplete => AppEventName.AchievedLevel,
|
||||
_ => name
|
||||
};
|
||||
|
||||
private static object ToFbValue(object value) => value switch
|
||||
{
|
||||
null => string.Empty,
|
||||
bool b => b ? 1 : 0,
|
||||
string s => s,
|
||||
int or long or float or double => value,
|
||||
_ => value.ToString()
|
||||
};
|
||||
|
||||
private static bool TryToFloat(object value, out float result)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case float f: result = f; return true;
|
||||
case double d: result = (float)d; return true;
|
||||
case int i: result = i; return true;
|
||||
case long l: result = l; return true;
|
||||
default: result = 0f; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 196c5d190005d45f885236d8765813d1
|
||||
BIN
Assets/Darkmatter/Content/Colorbook UI/Prefabs/.DS_Store
vendored
Normal file
BIN
Assets/Darkmatter/Content/Colorbook UI/Prefabs/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -330,15 +330,15 @@ PrefabInstance:
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalScale.x
|
||||
value: 0.24355799
|
||||
value: 0.4092992
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalScale.y
|
||||
value: 0.24355799
|
||||
value: 0.4092992
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalScale.z
|
||||
value: 0.24355799
|
||||
value: 0.4092992
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalPosition.x
|
||||
@@ -370,11 +370,11 @@ PrefabInstance:
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_AnchoredPosition.x
|
||||
value: -381.463
|
||||
value: -404.24997
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_AnchoredPosition.y
|
||||
value: 20.077026
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.x
|
||||
@@ -562,15 +562,15 @@ PrefabInstance:
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalScale.x
|
||||
value: 0.24355799
|
||||
value: 0.4092992
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalScale.y
|
||||
value: 0.24355799
|
||||
value: 0.4092992
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalScale.z
|
||||
value: 0.24355799
|
||||
value: 0.4092992
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalPosition.x
|
||||
@@ -602,11 +602,11 @@ PrefabInstance:
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_AnchoredPosition.x
|
||||
value: -204.58456
|
||||
value: -107
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_AnchoredPosition.y
|
||||
value: 20.077026
|
||||
value: -0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3648889831887995107, guid: ed3abc5b1c6bc43938850705ab3e4d4b, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.x
|
||||
|
||||
@@ -12,6 +12,7 @@ GameObject:
|
||||
- component: {fileID: 501846013816502432}
|
||||
- component: {fileID: 8665832532240036901}
|
||||
- component: {fileID: 8513459380562180911}
|
||||
- component: {fileID: 7638529631096518376}
|
||||
m_Layer: 5
|
||||
m_Name: Recatngle
|
||||
m_TagString: Untagged
|
||||
@@ -90,3 +91,18 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier: Features.ShapeBuilder::Darkmatter.Features.ShapeBuilder.UI.SlotMarker
|
||||
shape: {fileID: 11400000, guid: 4a67406eb6fe043628d2b6a4e0c970ba, type: 2}
|
||||
outline: {fileID: 0}
|
||||
--- !u!114 &7638529631096518376
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 4279528263583881541}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: e19747de3f5aca642ab2be37e372fb86, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Outline
|
||||
m_EffectColor: {r: 0, g: 0, b: 0, a: 0.5}
|
||||
m_EffectDistance: {x: 2.5, y: -2.5}
|
||||
m_UseGraphicAlpha: 1
|
||||
|
||||
BIN
Assets/Darkmatter/Content/Colorbook UI/Prefabs/UI/.DS_Store
vendored
Normal file
BIN
Assets/Darkmatter/Content/Colorbook UI/Prefabs/UI/.DS_Store
vendored
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f9a026758de8463bb1416c9a09c38c11
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -75,8 +75,8 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 0cd44c1031e13a943bb63640046fad76, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_UiScaleMode: 1
|
||||
m_ReferencePixelsPerUnit: 100
|
||||
m_ScaleFactor: 1
|
||||
@@ -98,8 +98,8 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: dc42784cf147c0c48a680349fa168899, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreReversedGraphics: 1
|
||||
m_BlockingObjects: 0
|
||||
m_BlockingMask:
|
||||
@@ -127,8 +127,8 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 97ce2c486cc8541d1ab1d83fda7f8eda, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
overlayView: {fileID: 100106}
|
||||
config: {fileID: 11400000, guid: 71357eb1222bb4151ab4e5697a1decd3, type: 2}
|
||||
--- !u!1 &100110
|
||||
@@ -182,8 +182,8 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 4ecf127118699405ebcd0e0712d7373d, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
canvas: {fileID: 100102}
|
||||
rootGroup: {fileID: 100105}
|
||||
cutout: {fileID: 100204}
|
||||
@@ -198,6 +198,9 @@ MonoBehaviour:
|
||||
haloPulseScale: 1.18
|
||||
pulseDuration: 0.6
|
||||
dragHandDuration: 1.1
|
||||
dragHandTipOffset: {x: 0, y: 70}
|
||||
dragBubbleAtTop: 1
|
||||
dragBubbleEdgeMargin: 150
|
||||
--- !u!225 &6008915593776814208
|
||||
CanvasGroup:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -266,8 +269,8 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 0, g: 0, b: 0, a: 0.72}
|
||||
m_RaycastTarget: 1
|
||||
@@ -296,8 +299,8 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 29c836b3d2ed343e6a810f6e7548f487, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
cutoutShader: {fileID: 4800000, guid: 57d1ed1c62afa45db97a7c8e9ace795c, type: 3}
|
||||
dimImage: {fileID: 100203}
|
||||
--- !u!1 &100600
|
||||
@@ -352,11 +355,11 @@ MonoBehaviour:
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 100600}
|
||||
m_Enabled: 1
|
||||
m_Enabled: 0
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 0.9}
|
||||
m_RaycastTarget: 0
|
||||
@@ -410,7 +413,7 @@ RectTransform:
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 130, y: 130}
|
||||
m_SizeDelta: {x: 200, y: 200}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &100702
|
||||
CanvasRenderer:
|
||||
@@ -430,8 +433,8 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 0
|
||||
@@ -465,7 +468,7 @@ GameObject:
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
m_IsActive: 0
|
||||
--- !u!224 &100801
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -524,7 +527,7 @@ RectTransform:
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 1, y: 1}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 91}
|
||||
m_SizeDelta: {x: -235, y: 91}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &100902
|
||||
CanvasRenderer:
|
||||
@@ -544,8 +547,8 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 0.96}
|
||||
m_RaycastTarget: 0
|
||||
@@ -574,7 +577,7 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 3245ec927659c4140ac4f8d17403cc18, type: 3}
|
||||
m_Name:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.ContentSizeFitter
|
||||
m_HorizontalFit: 2
|
||||
m_VerticalFit: 0
|
||||
@@ -588,7 +591,7 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 59f8146938fff824cb5fd77236b75775, type: 3}
|
||||
m_Name:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.VerticalLayoutGroup
|
||||
m_Padding:
|
||||
m_Left: 100
|
||||
@@ -639,8 +642,8 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 463.445, y: -95.50001}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 207.45999, y: -95.50001}
|
||||
m_SizeDelta: {x: 214.92, y: 72.61}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &101002
|
||||
CanvasRenderer:
|
||||
@@ -660,7 +663,7 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
|
||||
m_Name:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
@@ -752,7 +755,7 @@ MonoBehaviour:
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 3e37e2b5f1962004c8f10779a2fcf23a, type: 3}
|
||||
m_Name:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: uLayout::Poke.UI.LayoutText
|
||||
m_log: 0
|
||||
m_ignoreLayout: 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
BIN
Assets/Darkmatter/Content/Sprites/World/.DS_Store
vendored
Normal file
BIN
Assets/Darkmatter/Content/Sprites/World/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
Assets/Darkmatter/Content/Sprites/World/Ask Coins Sprites/.DS_Store
vendored
Normal file
BIN
Assets/Darkmatter/Content/Sprites/World/Ask Coins Sprites/.DS_Store
vendored
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 22 KiB |
@@ -49,7 +49,7 @@ TextureImporter:
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 270, y: 191, z: 255, w: 110}
|
||||
spriteBorder: {x: 741, y: 322, z: 925, w: 337}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
|
||||
@@ -12,8 +12,14 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: e6352f0162df4adf99a5945e25c6bf40, type: 3}
|
||||
m_Name: AdUnitCatalog
|
||||
m_EditorClassIdentifier: Core::Darkmatter.Core.Data.Static.Services.Ads.AdUnitCatalogSO
|
||||
androidAppId: ca-app-pub-3940256099942544~3347511713
|
||||
iosAppId: ca-app-pub-3940256099942544~1458002511
|
||||
androidAppId: ca-app-pub-3810264200292720~3342731243
|
||||
iosAppId: ca-app-pub-3810264200292720~5597889728
|
||||
useTestUnits: 0
|
||||
testDeviceIds: []
|
||||
entries: []
|
||||
entries:
|
||||
- Format: 1
|
||||
AndroidUnitId: ca-app-pub-3810264200292720/8753612666
|
||||
IosUnitId: ca-app-pub-3810264200292720/8064711900
|
||||
- Format: 2
|
||||
AndroidUnitId: ca-app-pub-3810264200292720/9189954299
|
||||
IosUnitId: ca-app-pub-3810264200292720/3366781979
|
||||
|
||||
@@ -12,32 +12,8 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 52d6fdba64cc3491880636e34ed593d0, type: 3}
|
||||
m_Name: ShapeBuilderConfig
|
||||
m_EditorClassIdentifier: Core::Darkmatter.Core.Data.Static.Features.ShapeBuilder.ShapeBuilderConfig
|
||||
snapRadius: 200
|
||||
previewRadius: 300
|
||||
catchRadiusScale: 1.5
|
||||
snapDuration: 0.25
|
||||
returnDuration: 0.25
|
||||
dragScale: 1.15
|
||||
previewCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 1
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
dragAlpha: 0.7
|
||||
|
||||
@@ -17,6 +17,24 @@ MonoBehaviour:
|
||||
finishText: Now finish the puzzle!
|
||||
colorText: Choose a color!
|
||||
paintText: Tap the picture to color it!
|
||||
finishColoringText: Color the whole picture!
|
||||
nextText: Tap Next when you're done!
|
||||
doneText: Yay! You did it!
|
||||
pickBubble: 0
|
||||
pickBubbleOffset: {x: 0, y: 0}
|
||||
dragBubble: 1
|
||||
dragBubbleOffset: {x: 200, y: 0}
|
||||
finishBubble: 1
|
||||
finishBubbleOffset: {x: 200, y: 0}
|
||||
colorBubble: 0
|
||||
colorBubbleOffset: {x: 0, y: 0}
|
||||
paintBubble: 0
|
||||
paintBubbleOffset: {x: 0, y: 0}
|
||||
finishColoringBubble: 1
|
||||
finishColoringBubbleOffset: {x: 200, y: 0}
|
||||
nextBubble: 0
|
||||
nextBubbleOffset: {x: 0, y: 0}
|
||||
doneBubble: 0
|
||||
doneBubbleOffset: {x: 0, y: 0}
|
||||
stepTimeoutSeconds: 45
|
||||
actionTimeoutSeconds: 0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2067,7 +2067,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 1, y: 1}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: -0.0006763566}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0, y: 1}
|
||||
--- !u!114 &1155451093
|
||||
@@ -2098,11 +2098,11 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.VerticalLayoutGroup
|
||||
m_Padding:
|
||||
m_Left: 0
|
||||
m_Right: 0
|
||||
m_Right: 41
|
||||
m_Top: 0
|
||||
m_Bottom: 0
|
||||
m_ChildAlignment: 1
|
||||
m_Spacing: 30
|
||||
m_Spacing: 120
|
||||
m_ChildForceExpandWidth: 0
|
||||
m_ChildForceExpandHeight: 0
|
||||
m_ChildControlWidth: 0
|
||||
@@ -2339,8 +2339,8 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 1, y: 1}
|
||||
m_AnchoredPosition: {x: 0, y: -82.37799}
|
||||
m_SizeDelta: {x: -191.90698, y: -574.56104}
|
||||
m_AnchoredPosition: {x: 0, y: -115.30393}
|
||||
m_SizeDelta: {x: -191.907, y: -626.817}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &1281354569
|
||||
MonoBehaviour:
|
||||
|
||||
Reference in New Issue
Block a user