Fixes optimization and UI/UX updates

This commit is contained in:
Savya Bikram Shah
2026-07-07 13:05:48 +05:45
parent dfd76d187f
commit e167af11fd
20 changed files with 429 additions and 489 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
_initialized = true;
}
public async UniTask<Sprite> GetThumbnailAsync(string id)
public async UniTask<(Sprite sprite, bool owned)> GetThumbnailAsync(string id)
{
if (!_byId.TryGetValue(id, out var t))
throw new KeyNotFoundException($"Template '{id}' not in catalog. Did InitializeAsync run?");
@@ -62,9 +62,9 @@ namespace Darkmatter.Features.DrawingTemplates.Systems
if (savedTex != null)
{
var rect = new Rect(0, 0, savedTex.width, savedTex.height);
return Sprite.Create(savedTex, rect, new Vector2(0.5f, 0.5f));
return (Sprite.Create(savedTex, rect, new Vector2(0.5f, 0.5f)), true);
}
return t.DefaultThumbnail;
return (t.DefaultThumbnail, false);
}
public UniTask<IDrawingTemplate> LoadAsync(string id)

View File

@@ -80,6 +80,27 @@ namespace Darkmatter.Features.GameplayFlow.Systems
}
public async UniTask StartAsync(CancellationToken cancellation)
{
try
{
await StartCoreAsync(cancellation);
}
catch (OperationCanceledException)
{
// Scene torn down mid-init; nothing to recover.
}
catch (Exception e)
{
// Any throw here (missing template id, failed Addressables load, region init)
// would otherwise skip _loadingScreen.Hide() and strand the kid on a frozen
// loader forever. Log it and route back to the Colorbook, whose flow hides the
// loading screen once its catalog is populated.
Debug.LogError($"[GameplayFlow] Failed to start gameplay, returning to Colorbook: {e}");
await RecoverToColorbookAsync();
}
}
private async UniTask StartCoreAsync(CancellationToken cancellation)
{
_scopeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var ct = _scopeCts.Token;
@@ -127,6 +148,22 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_loadingScreen.Hide();
}
private async UniTask RecoverToColorbookAsync()
{
try
{
_shapeBuilder.Clear();
_coloring.Clear();
await _scenes.LoadSceneAsync(nameof(GameScene.Colorbook), progress: null, cancellationToken: default);
await _scenes.UnloadSceneAsync(nameof(GameScene.Gameplay), progress: null, cancellationToken: default);
}
catch (Exception e)
{
Debug.LogError($"[GameplayFlow] Recovery to Colorbook failed: {e}");
_loadingScreen.Hide();
}
}
public async UniTask BackAsync(CancellationToken ct)
{
await SaveCurrentAsync(CancellationToken.None);

View File

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

View File

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