zooming for easy coloring

This commit is contained in:
Savya Bikram Shah
2026-06-26 16:18:39 +05:45
parent db3354c88b
commit 7446e49c8f
15 changed files with 536 additions and 852 deletions

View File

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

View File

@@ -51,7 +51,12 @@ namespace Darkmatter.Features.Capture
// A null result keeps the existing thumbnail and skips the gallery write (both no-op on null).
if (_coloring.IsPlayingCompletionAnimation) return null;
var png = await _captureService.CapturePngAsync(_refs.PaperRoot.gameObject, _config.CaptureScale, ct);
byte[] png;
using (_coloring.UseNonZoomedView())
{
png = await _captureService.CapturePngAsync(_refs.PaperRoot.gameObject, _config.CaptureScale, ct);
}
if (!saveToGallery || png == null || png.Length == 0) return png;
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, mipChain: false);

View File

@@ -8,7 +8,8 @@
"GUID:b4c9f7fbf1e144933a1797dc208ece5f",
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
"GUID:f51ebe6a0ceec4240a699833d6309b23",
"GUID:80ecb87cae9c44d19824e70ea7229748"
"GUID:80ecb87cae9c44d19824e70ea7229748",
"GUID:75469ad4d38634e559750d17036d5f7c"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -19,4 +20,4 @@
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
}

View File

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

View File

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

View File

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

View File

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