UX updates

This commit is contained in:
Savya Bikram Shah
2026-06-05 18:00:22 +05:45
parent dee4b004bd
commit e27d0e54cb
30 changed files with 3177 additions and 154 deletions

View File

@@ -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()

View File

@@ -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)

View File

@@ -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,7 @@ namespace Darkmatter.Features.Tutorial.Systems
private CancellationTokenSource _runCts;
private CancellationToken _ct;
private bool _completed;
private bool _drawingCompleted;
private bool _suspended;
private Action _reshow;
private int _stepIndex;
@@ -74,6 +76,10 @@ 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));
StartRun(skipCatalogWait: false);
}
@@ -86,6 +92,7 @@ namespace Darkmatter.Features.Tutorial.Systems
_ct = _runCts.Token;
_gen++;
_suspended = false;
_drawingCompleted = false;
_reshow = null;
_stepIndex = 0;
_overlay.HideInstant();
@@ -108,11 +115,11 @@ 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)
if (!_completed && !_drawingCompleted && _stepIndex >= 2 && _stepIndex <= 6)
StartRun(skipCatalogWait: true);
}
@@ -154,7 +161,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 +172,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 +180,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 +188,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)

View File

@@ -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;