diff --git a/Assets/Darkmatter/Code/Core/Data/Dynamic/Features/Progression/DrawingProgress.cs b/Assets/Darkmatter/Code/Core/Data/Dynamic/Features/Progression/DrawingProgress.cs index a3c682b..c59dab6 100644 --- a/Assets/Darkmatter/Code/Core/Data/Dynamic/Features/Progression/DrawingProgress.cs +++ b/Assets/Darkmatter/Code/Core/Data/Dynamic/Features/Progression/DrawingProgress.cs @@ -10,8 +10,8 @@ namespace Darkmatter.Core.Data.Dynamic.Features.Progression { public string templateId; public DrawingPhase phase; - public List SnappedPieces; - public List RegionColors; + public List snappedPieces; + public List regionColors; public bool hasThumbnail; public bool hasBeenCompleted; @@ -26,8 +26,8 @@ namespace Darkmatter.Core.Data.Dynamic.Features.Progression { this.templateId = templateId; this.phase = phase; - SnappedPieces = snappedPieces; - RegionColors = regionColors; + this.snappedPieces = snappedPieces; + this.regionColors = regionColors; this.hasThumbnail = hasThumbnail; this.hasBeenCompleted = hasBeenCompleted; this.completionCount = completionCount; diff --git a/Assets/Darkmatter/Code/Core/Data/Dynamic/Features/Progression/RegionColorEntry.cs b/Assets/Darkmatter/Code/Core/Data/Dynamic/Features/Progression/RegionColorEntry.cs index ddc78d1..24933e7 100644 --- a/Assets/Darkmatter/Code/Core/Data/Dynamic/Features/Progression/RegionColorEntry.cs +++ b/Assets/Darkmatter/Code/Core/Data/Dynamic/Features/Progression/RegionColorEntry.cs @@ -1,4 +1,5 @@ using System; +using UnityEngine; namespace Darkmatter.Core.Data.Dynamic.Features.Progression { @@ -6,6 +7,6 @@ namespace Darkmatter.Core.Data.Dynamic.Features.Progression public struct RegionColorEntry { public string regionId; - public float r, g, b, a; + public Color color; } } \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceSnappedSignal.cs b/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceSnappedSignal.cs new file mode 100644 index 0000000..10c4017 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceSnappedSignal.cs @@ -0,0 +1,4 @@ +namespace Darkmatter.Core.Data.Signals.Features.ShapeBuilder +{ + public record struct PieceSnappedSignal(string PieceId); +} diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceSnappedSignal.cs.meta b/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceSnappedSignal.cs.meta new file mode 100644 index 0000000..e8b6059 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceSnappedSignal.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4a88ca1eb07044daa9fd7a15c3dcc19 \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceUnsnappedSignal.cs b/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceUnsnappedSignal.cs new file mode 100644 index 0000000..eae223f --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceUnsnappedSignal.cs @@ -0,0 +1,4 @@ +namespace Darkmatter.Core.Data.Signals.Features.ShapeBuilder +{ + public record struct PieceUnsnappedSignal(string PieceId); +} diff --git a/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceUnsnappedSignal.cs.meta b/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceUnsnappedSignal.cs.meta new file mode 100644 index 0000000..504295b --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Data/Signals/Features/ShapeBuilder/PieceUnsnappedSignal.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d16116e16ea0a40b9afdc4765ba8881c \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeBuilderConfig.cs b/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeBuilderConfig.cs new file mode 100644 index 0000000..37948e8 --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeBuilderConfig.cs @@ -0,0 +1,30 @@ +using UnityEngine; + +namespace Darkmatter.Core.Data.Static.Features.ShapeBuilder +{ + [CreateAssetMenu(fileName = "ShapeBuilderConfig", + menuName = "Darkmatter/ShapeBuilder/Config")] + public sealed class ShapeBuilderConfig : ScriptableObject + { + [Header("Radii (canvas units; reference resolution 2048x2048)")] + [SerializeField] private float snapRadius = 100f; + [SerializeField] private float snapGraceMultiplier = 1.5f; + [SerializeField] private float previewRadius = 200f; + + [Header("Tween durations (seconds)")] + [SerializeField] private float snapDuration = 0.25f; + [SerializeField] private float returnDuration = 0.25f; + + [Header("Preview easing")] + [SerializeField] private AnimationCurve previewCurve = AnimationCurve.EaseInOut(0, 0, 1, 1); + + public float SnapRadius => snapRadius; + public float PreviewRadius => previewRadius; + public float SnapDuration => snapDuration; + public float ReturnDuration => returnDuration; + public AnimationCurve PreviewCurve => previewCurve; + + public Vector2 DragSizeDelta(ShapeSO shape) => + shape != null ? shape.DefaultSizeDelta : new Vector2(256, 256); + } +} diff --git a/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeBuilderConfig.cs.meta b/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeBuilderConfig.cs.meta new file mode 100644 index 0000000..29c5edd --- /dev/null +++ b/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeBuilderConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 52d6fdba64cc3491880636e34ed593d0 \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeSO.cs b/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeSO.cs index 219e650..b720285 100644 --- a/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeSO.cs +++ b/Assets/Darkmatter/Code/Core/Data/Static/Features/ShapeBuilder/ShapeSO.cs @@ -7,5 +7,6 @@ namespace Darkmatter.Core.Data.Static.Features.ShapeBuilder { [field: SerializeField] public string Id { get; private set; } [field: SerializeField] public Sprite Sprite { get; private set; } + [field: SerializeField] public Vector2 DefaultSizeDelta { get; private set; } = new(256, 256); } } \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Core/Enums/Services/Audio/SfxId.cs b/Assets/Darkmatter/Code/Core/Enums/Services/Audio/SfxId.cs index b2a6e9e..9863533 100644 --- a/Assets/Darkmatter/Code/Core/Enums/Services/Audio/SfxId.cs +++ b/Assets/Darkmatter/Code/Core/Enums/Services/Audio/SfxId.cs @@ -3,14 +3,8 @@ namespace Darkmatter.Core.Enums.Services.Audio public enum SfxId { None = 0, - - WiperUp = 100, - WiperDown = 101, - - BlinkerTick = 200, - - GearShift = 300, - - ReverseBeep = 400, + ShapeHover = 100, + ShapeSnap = 101, + ShapeReturn = 102, } } diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder.meta new file mode 100644 index 0000000..57845fc --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c12a68c64c45647dab002508c06ec8cd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/Commands.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder/Commands.meta new file mode 100644 index 0000000..c7c3a61 --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/Commands.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 32ae4b246e57f4379baa7b98cffeb8c2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/Commands/SnapPieceCommand.cs b/Assets/Darkmatter/Code/Features/ShapeBuilder/Commands/SnapPieceCommand.cs new file mode 100644 index 0000000..44b038a --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/Commands/SnapPieceCommand.cs @@ -0,0 +1,35 @@ +using Darkmatter.Core.Contracts.Features.History; +using Darkmatter.Features.ShapeBuilder.UI; +using UnityEngine; + +namespace Darkmatter.Features.ShapeBuilder.Commands +{ + /// + /// Undoable snap. Captures the piece's pre-snap pose + parent at ctor time; + /// Execute drives the normal snap tween; Undo restores the pre-snap state + /// and re-enables drag. + /// + internal sealed class SnapPieceCommand : ICommand + { + private readonly ShapePiece _piece; + private readonly Vector2 _prevPos; + private readonly Vector2 _prevSize; + private readonly Quaternion _prevRot; + private readonly Transform _prevParent; + + public SnapPieceCommand(ShapePiece piece) + { + _piece = piece; + + var rt = piece.RectTransform; + _prevPos = rt.anchoredPosition; + _prevSize = rt.sizeDelta; + _prevRot = rt.localRotation; + _prevParent = rt.parent; + } + + public void Execute() => _piece.SnapInternal(); + + public void Undo() => _piece.UnsnapInternal(_prevParent, _prevPos, _prevSize, _prevRot); + } +} diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/Commands/SnapPieceCommand.cs.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder/Commands/SnapPieceCommand.cs.meta new file mode 100644 index 0000000..61fae22 --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/Commands/SnapPieceCommand.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7565e0dc60eca451c9877bd23dddb901 \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/Features.ShapeBuilder.asmdef b/Assets/Darkmatter/Code/Features/ShapeBuilder/Features.ShapeBuilder.asmdef new file mode 100644 index 0000000..56a7974 --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/Features.ShapeBuilder.asmdef @@ -0,0 +1,22 @@ +{ + "name": "Features.ShapeBuilder", + "rootNamespace": "Darkmatter.Features.ShapeBuilder", + "references": [ + "GUID:6a0a834eb41764f12ba55c3fb04a40cb", + "GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1", + "GUID:c176ee863a5e74e88a6517f9f102cf92", + "GUID:b4c9f7fbf1e144933a1797dc208ece5f", + "GUID:b0214a6008ed146ff8f122a6a9c2f6cc", + "GUID:f51ebe6a0ceec4240a699833d6309b23", + "GUID:80ecb87cae9c44d19824e70ea7229748" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/Features.ShapeBuilder.asmdef.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder/Features.ShapeBuilder.asmdef.meta new file mode 100644 index 0000000..17032cc --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/Features.ShapeBuilder.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2ca8c3a66565544118d3d52d3922933b +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/Installers.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder/Installers.meta new file mode 100644 index 0000000..353399c --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/Installers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f8f128ba2fede4ab2971c98f4f83819a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/Installers/ShapeBuilderFeatureModule.cs b/Assets/Darkmatter/Code/Features/ShapeBuilder/Installers/ShapeBuilderFeatureModule.cs new file mode 100644 index 0000000..017748e --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/Installers/ShapeBuilderFeatureModule.cs @@ -0,0 +1,21 @@ +using Darkmatter.Core.Data.Static.Features.ShapeBuilder; +using Darkmatter.Libs.Installers; +using UnityEngine; +using VContainer; + +namespace Darkmatter.Features.ShapeBuilder.Installers +{ + public class ShapeBuilderFeatureModule : MonoBehaviour, IModule + { + [SerializeField] private ShapeBuilderConfig config; + + public void Register(IContainerBuilder builder) + { + if (config != null) + builder.RegisterInstance(config); + + // ShapePiece instances are MonoBehaviours instantiated by the + // ShapeBuilderController; nothing to register here for them. + } + } +} diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/Installers/ShapeBuilderFeatureModule.cs.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder/Installers/ShapeBuilderFeatureModule.cs.meta new file mode 100644 index 0000000..684c1ff --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/Installers/ShapeBuilderFeatureModule.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d6323fd2530234772b15e4b2d169df47 \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/Systems.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder/Systems.meta new file mode 100644 index 0000000..9c34fd0 --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/Systems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 679f7a317a1b7445aaa719055bca4a6d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/UI.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI.meta new file mode 100644 index 0000000..263d27b --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cdbdbbcc180884694930e5ce1dbbb7d4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/ShapePiece.cs b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/ShapePiece.cs new file mode 100644 index 0000000..4e11b19 --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/ShapePiece.cs @@ -0,0 +1,203 @@ +using Darkmatter.Core.Contracts.Features.History; +using Darkmatter.Core.Contracts.Services.Audio; +using Darkmatter.Core.Data.Signals.Features.ShapeBuilder; +using Darkmatter.Core.Data.Static.Features.ShapeBuilder; +using Darkmatter.Core.Enums.Services.Audio; +using Darkmatter.Features.ShapeBuilder.Commands; +using Darkmatter.Libs.Observer; +using PrimeTween; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace Darkmatter.Features.ShapeBuilder.UI +{ + /// + /// Single-MB shape piece. Handles drag, reactive preview lerp, snap, + /// and return-to-tray. Snap is wrapped in a + /// pushed onto the shared so it participates in + /// the same undo/redo flow as coloring. + /// + [RequireComponent(typeof(RectTransform))] + public sealed class ShapePiece : MonoBehaviour, + IBeginDragHandler, IDragHandler, IEndDragHandler + { + [SerializeField] private Image image; + + // Bound by Setup + private ShapeSO _shape; + private SlotMarker _slot; + private ShapeBuilderConfig _cfg; + private ISfxPlayer _sfx; + private IEventBus _bus; + private IUndoStack _undo; + private Vector2 _trayPos; + private Vector2 _traySize; + + // Per-drag state + private RectTransform _rt; + private RectTransform _parentRect; + private Camera _eventCam; + private Vector2 _grabOffset; + private bool _locked; + private bool _inPreview; + + public ShapeSO Shape => _shape; + public string PieceId => _shape != null ? _shape.Id : null; + public bool IsLocked => _locked; + public RectTransform RectTransform => _rt != null ? _rt : (_rt = (RectTransform)transform); + + public void Setup( + ShapeSO shape, + SlotMarker slot, + ShapeBuilderConfig cfg, + ISfxPlayer sfx, + IEventBus bus, + IUndoStack undo, + Vector2 trayPos, + bool preSnapped) + { + _shape = shape; + _slot = slot; + _cfg = cfg; + _sfx = sfx; + _bus = bus; + _undo = undo; + _trayPos = trayPos; + _traySize = shape.DefaultSizeDelta; + + image.sprite = shape.Sprite; + ApplyTrayPose(); + + if (preSnapped) SnapInstantly(); + } + + public void OnBeginDrag(PointerEventData e) + { + if (_locked) return; + _parentRect = (RectTransform)RectTransform.parent; + _eventCam = e.pressEventCamera; + _grabOffset = RectTransform.anchoredPosition - ScreenToLocal(e.position); + _inPreview = false; + } + + public void OnDrag(PointerEventData e) + { + if (_locked) return; + + var pointerLocal = ScreenToLocal(e.position) + _grabOffset; + var slotPos = _slot.RectTransform.anchoredPosition; + float dist = Vector2.Distance(pointerLocal, slotPos); + + if (dist <= _cfg.PreviewRadius) + { + if (!_inPreview) + { + _inPreview = true; + _sfx.Play(SfxId.ShapeHover); + } + ApplyPreviewLerp(pointerLocal, dist); + } + else + { + _inPreview = false; + RectTransform.anchoredPosition = pointerLocal; + RectTransform.sizeDelta = _traySize; + RectTransform.localRotation = Quaternion.identity; + } + } + + public void OnEndDrag(PointerEventData e) + { + if (_locked) return; + + float dist = Vector2.Distance( + RectTransform.anchoredPosition, + _slot.RectTransform.anchoredPosition); + + if (dist <= _cfg.SnapRadius) + _undo.Push(new SnapPieceCommand(this)); // Push calls Execute → SnapInternal + else + ReturnToTray(); + } + + private void ApplyPreviewLerp(Vector2 pointerLocal, float dist) + { + float t = Mathf.Clamp01(1f - dist / _cfg.PreviewRadius); + if (_cfg.PreviewCurve != null) t = _cfg.PreviewCurve.Evaluate(t); + + var slot = _slot.RectTransform; + RectTransform.anchoredPosition = Vector2.Lerp(pointerLocal, slot.anchoredPosition, t); + RectTransform.sizeDelta = Vector2.Lerp(_traySize, slot.sizeDelta, t); + RectTransform.localRotation = Quaternion.Slerp(Quaternion.identity, slot.localRotation, t); + } + + // ---- Command-driven snap / un-snap ---- + internal void SnapInternal() + { + Lock(); + var slot = _slot.RectTransform; + + Tween.UIAnchoredPosition(RectTransform, slot.anchoredPosition, _cfg.SnapDuration, Ease.OutBack); + Tween.UISizeDelta (RectTransform, slot.sizeDelta, _cfg.SnapDuration, Ease.OutBack); + Tween.LocalRotation (RectTransform, slot.localRotation, _cfg.SnapDuration, Ease.OutBack); + + _sfx.Play(SfxId.ShapeSnap); + _bus.Publish(new PieceSnappedSignal(_shape.Id)); + } + + internal void UnsnapInternal(Transform parent, Vector2 pos, Vector2 size, Quaternion rot) + { + _locked = false; + image.raycastTarget = true; + + RectTransform.SetParent(parent, worldPositionStays: false); + + Tween.UIAnchoredPosition(RectTransform, pos, _cfg.ReturnDuration, Ease.OutQuad); + Tween.UISizeDelta (RectTransform, size, _cfg.ReturnDuration, Ease.OutQuad); + Tween.LocalRotation (RectTransform, rot, _cfg.ReturnDuration, Ease.OutQuad); + + _sfx.Play(SfxId.ShapeReturn); + _bus.Publish(new PieceUnsnappedSignal(_shape.Id)); + } + + private void SnapInstantly() + { + Lock(); + var slot = _slot.RectTransform; + RectTransform.anchoredPosition = slot.anchoredPosition; + RectTransform.sizeDelta = slot.sizeDelta; + RectTransform.localRotation = slot.localRotation; + } + + private void ReturnToTray() + { + _sfx.Play(SfxId.ShapeReturn); + Sequence.Create() + .Group(Tween.UIAnchoredPosition(RectTransform, _trayPos, _cfg.ReturnDuration, Ease.OutQuad)) + .Group(Tween.UISizeDelta (RectTransform, _traySize, _cfg.ReturnDuration, Ease.OutQuad)) + .Group(Tween.LocalRotation (RectTransform, Quaternion.identity, _cfg.ReturnDuration, Ease.OutQuad)); + } + + private void Lock() + { + _locked = true; + image.raycastTarget = false; + RectTransform.SetParent(_slot.RectTransform.parent, worldPositionStays: false); + } + + private void ApplyTrayPose() + { + RectTransform.anchoredPosition = _trayPos; + RectTransform.sizeDelta = _traySize; + RectTransform.localRotation = Quaternion.identity; + } + + private Vector2 ScreenToLocal(Vector2 screenPos) + { + RectTransformUtility.ScreenPointToLocalPointInRectangle( + _parentRect, screenPos, _eventCam, out var local); + return local; + } + } +} diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/ShapePiece.cs.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/ShapePiece.cs.meta new file mode 100644 index 0000000..c0df7eb --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/ShapePiece.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 189fa0409d676438abe96e8707e29ad0 \ No newline at end of file diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/SlotMarker.cs b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/SlotMarker.cs new file mode 100644 index 0000000..6aecf01 --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/SlotMarker.cs @@ -0,0 +1,21 @@ +using Darkmatter.Core.Data.Static.Features.ShapeBuilder; +using UnityEngine; +using UnityEngine.UI; + +namespace Darkmatter.Features.ShapeBuilder.UI +{ + public sealed class SlotMarker : MonoBehaviour + { + [SerializeField] private ShapeSO shape; + [SerializeField] private Image outline; + + public ShapeSO Shape => shape; + public string SlotId => shape != null ? shape.Id : null; + public RectTransform RectTransform => (RectTransform)transform; + + public void SetOutlineVisible(bool visible) + { + if (outline != null) outline.enabled = visible; + } + } +} diff --git a/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/SlotMarker.cs.meta b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/SlotMarker.cs.meta new file mode 100644 index 0000000..82bcb78 --- /dev/null +++ b/Assets/Darkmatter/Code/Features/ShapeBuilder/UI/SlotMarker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5d79b18d536324085b58d842648372a8 \ No newline at end of file diff --git a/Assets/Darkmatter/Scenes/GamePlay.unity b/Assets/Darkmatter/Scenes/GamePlay.unity index a8a149d..f7e02fb 100644 --- a/Assets/Darkmatter/Scenes/GamePlay.unity +++ b/Assets/Darkmatter/Scenes/GamePlay.unity @@ -324,8 +324,54 @@ Transform: m_ConstrainProportionsScale: 0 m_Children: - {fileID: 1551649428} + - {fileID: 1991184379} m_Father: {fileID: 1224714932} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1991184378 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1991184379} + - component: {fileID: 1991184380} + m_Layer: 0 + m_Name: ShapeBuilderFeatureModule + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1991184379 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1991184378} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1965442263} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1991184380 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1991184378} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d6323fd2530234772b15e4b2d169df47, type: 3} + m_Name: + m_EditorClassIdentifier: Features.ShapeBuilder::Darkmatter.Features.ShapeBuilder.Installers.ShapeBuilderFeatureModule + config: {fileID: 0} --- !u!1 &2069155637 GameObject: m_ObjectHideFlags: 0 diff --git a/Readme.docx b/Readme.docx index a2cd684..9ef6d64 100644 Binary files a/Readme.docx and b/Readme.docx differ diff --git a/Readme.md b/Readme.md index 9786bbe..8fe340d 100644 --- a/Readme.md +++ b/Readme.md @@ -8,23 +8,70 @@ This document is the canonical reference for the Color Book game's structure. Th ## 1. Game Flow +Four scenes. Each gets its own scope. Root services persist across all of them. + ``` -App launch - └─ Boot scene (RootLifetimeScope) - └─ MainMenu scene (Spine mascot, Play button) - └─ Press "Play" → ColorBook scene - ├─ Drawing catalog (grid of templates) - ├─ Select drawing - ├─ Shape Builder panel (drag pieces → snap to slots) - ├─ ↓ on assembly complete - ├─ Color panel (tap color → tap region) - ├─ Undo / Redo any time - ├─ "Save" → screenshot via CaptureCamera → native gallery plugin - │ saves PNG to phone's Photos album. Toast confirmation. - └─ "Next" → auto-save + load next drawing +┌─────────────────┐ +│ Boot.unity │ RootLifetimeScope — services + cross-scene singletons +│ │ AppBoot: Addressables.Init → Progression.Load → Catalog.Init +└────────┬────────┘ + │ Scenes.LoadAsync("MainMenu") + ▼ +┌─────────────────┐ +│ MainMenu.unity │ Spine mascot looping idle. Single "Play" button. +│ │ Tap → SetLastOpened(null) → load Colorbook +└────────┬────────┘ + │ Scenes.LoadAsync("Colorbook") + ▼ +┌─────────────────┐ ◀─────────────────────────┐ +│ Colorbook.unity │ Catalog grid of drawings. │ (back from Gameplay returns here; +│ │ Each cell shows: cached │ catalog cells refresh with cached +│ │ user thumbnail if any, │ thumbnails written during gameplay) +│ │ else DefaultThumbnail. │ +│ │ Tap cell → │ +│ │ Progression.SetLastOpened(id) → load Gameplay +└────────┬────────┘ │ + │ Scenes.LoadAsync("Gameplay") │ + ▼ │ +┌─────────────────┐ │ +│ Gameplay.unity │ Active drawing experience │ +│ │ │ +│ Reads Progression.LastOpenedTemplateId │ +│ Reads Progression.GetProgress(id) → null, │ +│ Building, or Coloring │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ GameplayState.Building │ │ +│ │ • Pieces in tray, drag → snap │ │ +│ │ • Pre-snapped pieces auto-locked │ │ +│ │ if resuming │ │ +│ │ • Back tap → save partial state ─┼────┤ +│ │ + load Colorbook │ │ +│ └────────────────┬─────────────────────┘ │ +│ │ ShapeAssembledSignal │ +│ │ (save phase + thumb) │ +│ ▼ │ +│ ┌──────────────────────────────────────┐ │ +│ │ GameplayState.Coloring │ │ +│ │ • Tap color → tap region → paint │ │ +│ │ • Undo / Redo any time │ │ +│ │ • Autosave (debounced 500 ms) │ │ +│ │ • Save tap → capture + native ─┼────┐ +│ │ Photos save + cache thumb │ │ +│ │ • Next tap → save + mark complete │ │ +│ │ + advance to next drawing ──────┼─┐ │ +│ │ • Back tap → save + load Colorbook─┼────┤ +│ └──────────────────────────────────────┘ │ │ +│ │ │ +└────────────────────────────────────────────┼──┘ + │ + ┌──────────────────────────┘ + │ AdvanceToNextDrawing: + │ Catalog.NextUnseen(currentId) → reload Gameplay + └─ stays in Gameplay.unity, no scene transition ``` -The user views their captured drawings inside the phone's native **Photos** app — there is no in-app gallery viewer. Capture and gallery-save are two independent services: `ICaptureService` produces PNG bytes; `IGalleryService` is a thin shim over a native plugin that writes those bytes into the device's photo library. +The user views captured drawings inside the phone's native **Photos** app — there is no in-app gallery viewer. `ICaptureService` produces PNG bytes; `IGalleryService` is a thin shim over a native plugin that writes those bytes into the device's photo library. The Save System (§13) decides *when* to capture and save. --- @@ -255,24 +302,30 @@ New asmdefs follow the same convention: `Services.Capture`, `Services.Gallery`, ## 6. Scenes & Lifetime Scopes +Four scenes, each with its own scope. Root scope persists across all scene changes. + | Scene | Scope | Status | Contents | |---|---|---|---| -| `Boot.unity` | `RootLifetimeScope` | ✅ exists | All Services + Libs. Persists forever. | -| `MainMenu.unity` | `MainMenuLifetimeScope` | ⚠️ planned | Spine mascot, Play button. | -| `ColorBook.unity` | `ColorBookLifetimeScope` | ⚠️ planned | DrawingCatalog, ShapeBuilder, Coloring, History, Capture, ColorBookFlow. | +| `Boot.unity` | `RootLifetimeScope` | ✅ exists | All Services + Libs + cross-scene singletons (`IProgressionSystem`, `IDrawingTemplateCatalog`). Persists forever. | +| `MainMenu.unity` | `MainMenuLifetimeScope` | ⚠️ planned | Spine mascot, single "Play" button. | +| `Colorbook.unity` | `ColorbookLifetimeScope` | ⚠️ planned | `ColorbookFlow` + `DrawingCatalog`. Catalog grid where the player picks a drawing. | +| `Gameplay.unity` | `GameplayLifetimeScope` | ⚠️ scene exists, scope empty | `ShapeBuilder` + `Coloring` + `History` + `Capture` (the feature wrapper) + `GameplayFlow`. The active drawing experience. | -Only `Boot.unity` exists today; the two scene scope classes haven't been written yet either (only `RootLifetimeScope` exists in [App/LifetimeScopes/](Assets/Darkmatter/Code/App/LifetimeScopes/)). +Only `Boot.unity` and an empty `Gameplay.unity` exist today; the four scene scope classes need full implementation (only `RootLifetimeScope` is functional). -Scopes nest: `Root → (MainMenu | ColorBook)`. Services resolved from the root parent. Scene scopes only register their own features. There is **no in-app gallery** — captured drawings go to the phone's native Photos app via the gallery service. +Scopes nest: `Root → (MainMenu | Colorbook | Gameplay)`. Services and cross-scene features (Progression, DrawingTemplate) resolve from the root parent. Scene scopes only register their own features. + +**No in-app gallery** — captured drawings go to the phone's native Photos app via `IGalleryService`. The catalog grid in `Colorbook.unity` shows the user's progress by reading cached thumbnails from `IProgressionSystem`. ### Boot chain (planned) -No `AppBoot` class exists yet — today `RootLifetimeScope` only registers services and stops there. When `AppBoot` is added (as an `IAsyncStartable` registered via `builder.RegisterEntryPoint()`), it should run once, in order: +No `AppBoot` class exists yet — today `RootLifetimeScope` only registers services and stops. When `AppBoot` is added (as an `IAsyncStartable` registered via `RootLifetimeScope`), it should run once, in order: -1. Initialize `IAssetProviderService` (Addressables init). -2. Preload essential bundles (palettes, UI sounds). -3. Load `IProgressionService` from disk. -4. Load `MainMenu` scene. +1. `IAssetProviderService.InitializeAsync()` — Addressables init. +2. `IProgressionSystem.LoadAsync()` — hydrate per-template state from PlayerPrefs. +3. `IDrawingTemplateCatalog.InitializeAsync()` — batch-load all `DrawingTemplateSO`s by Addressables label. +4. Optional: preload UI sounds, palette assets. +5. `ISceneService.LoadAsync(MainMenu)`. Failures show a child-friendly retry screen; never crash. @@ -563,54 +616,55 @@ public readonly struct PaperSavedSignal { ### `ShapeBuilder` -- Listens to `DrawingSelectedSignal`. -- Loads template, instantiates the single piece prefab once per `ShapeSO` in the template, parents under the HUD tray panel (`ColorBookSceneRefs.TrayPanel`). Each instance is `Assign(shape)`ed to its `ShapeSO`. -- `SlotMarker`s in the drawing's per-drawing prefab (under `ColorBookSceneRefs.SlotsParent`) provide target poses + matching `ShapeSO` refs. -- Each piece has `IBeginDragHandler` / `IDragHandler` / `IEndDragHandler` plus a per-piece `ShapePieceFsm`. Drag updates `RectTransform.anchoredPosition` directly from `PointerEventData`, converted to canvas-local via `RectTransformUtility.ScreenPointToLocalPointInRectangle`. -- On entering preview radius of the matching slot: reactive `Lerp` of `anchoredPosition` / `sizeDelta` / `localRotation` toward `SlotMarker`'s `RectTransform`. Drives off pointer distance, not time. -- On `OnEndDrag` inside snap radius: piece reparents to `ColorBookSceneRefs.PiecesParent`, DOTween ease-out to exact slot pose, disable input. Otherwise DOTween back to tray slot. -- Fires `ShapeAssembledSignal` when all pieces locked. +- Listens to `DrawingSelectedSignal` (raised by the Colorbook scene before scene change; resume reads `LastOpenedTemplateId` in Gameplay scope startup). +- Loads the per-drawing prefab via `IDrawingTemplateCatalog`, instantiates it under `GameplaySceneRefs.PaperRoot`. The prefab carries the `SlotMarker`s at their authored poses. +- Spawns one **`ShapePiece` MonoBehaviour** per `ShapeSO` in the template via `Instantiate(piecePrefab, tray)` and calls `piece.Setup(shape, slot, cfg, sfx, bus, trayPos, preSnapped)`. If `progress.Phase == ShapeBuilding`, pieces in `progress.SnappedPieces` are pre-snapped (start locked). +- `ShapePiece` is a single MB handling all three behaviors inline: drag (Unity UI `IBeginDrag / IDrag / IEndDrag`), reactive preview lerp when within `cfg.PreviewRadius`, snap (PrimeTween — `Tween.UIAnchoredPosition` / `UISizeDelta` / `LocalRotation`) on release inside `cfg.SnapRadius`, otherwise tween back to tray. No FSM, no factory — just the MB. +- Publishes `PieceSnappedSignal(pieceId)` on lock. Controller counts against expected; fires `ShapeAssembledSignal(templateId)` when all locked. ### `Coloring` - Listens to `ShapeAssembledSignal`. -- Spawns one UI `Image` per `ColorRegionDTO` under `ColorBookSceneRefs.RegionsParent`. Each region's `Image.alphaHitTestMinimumThreshold = 0.5f` so taps on transparent pixels pass through to the next region. +- Spawns one UI `Image` per `ColorRegionDTO` under `GameplaySceneRefs.RegionsParent`. Each region's `Image.alphaHitTestMinimumThreshold = 0.5f` so taps on transparent pixels pass through to the next region below. - Each region has `IPointerClickHandler`. On click → `ColoringController.PaintRegion(view)`. - Listens to palette selection (current color held in `ColoringStateRepository`). -- Controller builds `PaintRegionCommand(regionId, oldColor, newColor)` and pushes to `IUndoStack`. -- Command sets `Image.color` on undo/redo. -- Fires `ColorAppliedSignal` for SFX / sparkle effects. +- Controller builds `PaintRegionCommand(regionId, oldColor, newColor)` and pushes to `IUndoStack`. Command sets `Image.color` on Execute/Undo. +- Publishes `ColorAppliedSignal` for SFX / sparkle effects. +- **Resume:** if `progress.RegionColors` is non-empty, spawned regions are initialized with those saved colors instead of `region.InitialColor`. +- **Autosave hook:** after each `PaintRegion`, debounces 500 ms then calls `GameplayFlowController.AutosaveAsync` so the colors hit disk without thumbnail re-render. See §13. ### `History` -- Owns the scoped `IUndoStack` for the current ColorBook session. -- Cleared on `DrawingSelectedSignal` (new drawing = fresh history). -- Capped at 20 entries (memory + cognitive simplicity). -- UI: two big arrow buttons; disabled state when `CanUndo / CanRedo` is false. +- Owns the scoped `IUndoStack` for the current Gameplay session. +- Cleared on Gameplay scope startup (new drawing = fresh history). +- Capped at 20 entries. +- UI: two big arrow buttons; disabled state when `CanUndo` / `CanRedo` is false. ### `Capture` -- Bound to the "Save" button (and triggered silently by "Next"). -- `CaptureController.SaveAsync(templateId)`: - 1. `_capture.CaptureAsync()` → PNG bytes (one-shot `CaptureCamera.Render()` into a temp RT) - 2. Publish `PaperCapturedSignal(templateId)` - 3. `_gallery.SaveToDeviceAsync(bytes, "Color Book")` → native plugin writes into phone's Photos - 4. Publish `PaperSavedSignal(templateId)` -- HUD shows a brief "Saved to Photos" toast on `PaperSavedSignal`. -- `CaptureController` is the only place that orchestrates capture-then-save. Other features never call `IGalleryService` directly. +- Wraps `ICaptureService.CaptureAsync()` (one-shot `CaptureCamera.Render()` into a temp RT, ReadPixels, EncodeToPNG). Returns raw PNG bytes. +- The **Capture feature does NOT decide what to do with the bytes** — `GameplayFlowController` calls it, then routes to gallery + thumbnail cache depending on the trigger. See §13. ### `Progression` -- Tracks completed template IDs and the in-progress draft. -- On "Next" button: silently runs `CaptureController.SaveAsync`, marks current as completed, calls `IDrawingTemplateCatalog.NextUnseen()`. -- Persists JSON via `Libs.PlayerPrefs` (`ProtectedPlayerPrefs`). +- Single source of truth for per-template user state. Implements `IProgressionSystem`, persists `DrawingProgress` records via `Libs.PlayerPrefs` (single JSON blob under `PlayerPrefsKeys.Progression`). +- Internally stores thumbnails per template as PNG files in `Application.persistentDataPath/thumbs/{safeId}.png` (large blobs don't belong in PlayerPrefs). +- `ProgressionRepository` does the IO; `ProgressionSystem` keeps an in-memory cache and exposes a clean API. +- Exposes: `GetProgress(id)`, `SaveProgressAsync(progress)`, `SaveProgressAsync(progress, png)`, `ClearProgressAsync(id)`, `IsCompleted(id)`, `CompletedTemplateIds`, `LastOpenedTemplateId / SetLastOpened`, `GetCachedThumbnailAsync(id)`. +- See §13 for the full save matrix. -### `ColorBookFlow` +### `ColorbookFlow` (in `Colorbook.unity`) -- The only orchestrator inside ColorBook scope. -- Subscribes to flow-relevant signals and toggles UI panels (catalog → builder → coloring). -- Coordinates "Next" sequence: `CaptureController.SaveAsync` → `IProgressionService.MarkCompleted` → `IDrawingTemplateCatalog.Release(currentId)` → load next. -- Built as a small FSM (`Catalog → Building → Coloring → Done`). +- The orchestrator for the catalog scene. +- On scope start: calls `_drawingCatalog.InitializeAsync()` to populate the visible-IDs list. +- Subscribes to `DrawingSelectedSignal`: `_progression.SetLastOpened(id)` + `_scenes.LoadAsync(Gameplay)`. + +### `GameplayFlow` (in `Gameplay.unity`) + +- The orchestrator for the active drawing scene. +- On scope start: reads `_progression.LastOpenedTemplateId`, fetches its `DrawingProgress`, enters either Building (no progress / Phase==ShapeBuilding) or Coloring (Phase==Coloring) state. +- Handles all save points (§13): Back button, ShapeAssembled transition, Save button, Next button, app lifecycle pause/quit. +- Uses `Libs.FSM` (StateMachine) for `Building` ↔ `Coloring` runtime states. --- @@ -740,6 +794,92 @@ Notes: --- +## 12b. Save System + +Everything the user does that affects their drawing state must end up persisted. `GameplayFlowController` is the **single owner** of all save calls — feature controllers expose getters; the flow controller assembles the `DrawingProgress` record and hands it to `IProgressionSystem`. + +### What is saved + +| Field on `DrawingProgress` | Meaning | +|---|---| +| `templateId` | Which drawing this record is about | +| `phase` | `ShapeBuilding` or `Coloring` — where to resume | +| `snappedPieces` | Pieces locked into slots (relevant in ShapeBuilding) | +| `regionColors` | Per-region color (relevant in Coloring) | +| `hasThumbnail` | Whether a thumbnail PNG exists on disk for catalog display | +| `hasBeenCompleted` | Flipped true on first Next; never flips back | +| `completionCount` | Number of times Next was pressed (optional stats) | +| `updatedUtcIso` / `firstCompletedUtcIso` | Timestamps (ISO 8601 strings for JsonUtility) | + +### Save matrix + +| Trigger | Phase saved | Snapped pieces | Region colors | Thumbnail? | Native gallery? | +|---|---|---|---|---|---| +| **ShapeAssembledSignal** (Building → Coloring transition) | `Coloring` | all | empty | **yes** (bare-assembled paper) | no | +| **Each paint** (debounced 500 ms) | `Coloring` | all | current | no | no | +| **Save button** | `Coloring` | all | current | **yes** | **yes** | +| **Next button** | `Coloring` | all | current | **yes** | **yes** | +| **Back button (during Building)** | `ShapeBuilding` | current | empty | no | no | +| **Back button (during Coloring)** | `Coloring` | all | current | **yes** | no | +| **OnApplicationPause(true) / OnApplicationQuit** | current phase | current | current | no | no | + +Two design principles drive the matrix: + +- **Thumbnail capture is expensive** (render + ReadPixels + PNG encode). Skip it on partial-assembly saves and per-paint autosaves. Only generate a thumbnail when the user takes an explicit save-style action. +- **Defensive saves never block UX.** App pause/quit saves whatever is in memory without capturing — fast path, no awaitable IO holding up shutdown. + +### `Next` adds two extras + +- Flips `hasBeenCompleted = true` (preserves first `firstCompletedUtcIso`); increments `completionCount`. +- Plays completion animation, then calls `AdvanceToNextDrawing` → `Catalog.NextUnseen(currentId)` → reload Gameplay scope for the new drawing. + +### Storage layout + +| What | Where | +|---|---| +| `DrawingProgress` records + `lastOpened` | One JSON blob in `ProtectedPlayerPrefs[PlayerPrefsKeys.Progression]` (see `ProgressionRootDto`) | +| Thumbnail PNGs | `Application.persistentDataPath/thumbs/{safeId}.png` (one file per template that has a thumbnail) | + +`safeId` replaces `/` and `\` with `_` so `animals/elephant` becomes `animals_elephant.png`. + +### Resume / load decision + +On Gameplay scope startup: + +```csharp +var id = _progression.LastOpenedTemplateId; +var progress = _progression.GetProgress(id); // null if untouched + +if (progress == null || progress.Value.phase == DrawingPhase.ShapeBuilding) { + fsm.Go(Building); // spawn pieces in tray, pre-snap any in progress.snappedPieces +} else { + fsm.Go(Coloring); // skip ShapeBuilder; auto-snap pieces; spawn regions with progress.regionColors +} +``` + +### Catalog cells reflect saves automatically + +The Colorbook scene reloads on Back. Its presenter calls `_progression.GetCachedThumbnailAsync(id)` per cell → returns the most recent save's PNG. Drawings the user touched display their progress; untouched drawings fall back to `IDrawingTemplate.DefaultThumbnail`. No live-update plumbing needed — re-entry is the refresh. + +### Files touching the save system + +| Path | Role | +|---|---| +| `Core/Contracts/Features/Progression/IProgressionSystem.cs` | Contract | +| `Core/Data/Dynamic/Features/Progression/DrawingProgress.cs` | The struct | +| `Core/Data/Dynamic/Features/Progression/ProgressionRootDto.cs` | JSON root (records + lastOpened) | +| `Core/Data/Dynamic/Features/Progression/RegionColorEntry.cs` | Flattened color entry (JsonUtility-friendly) | +| `Core/Enums/Features/Progression/DrawingPhase.cs` | Phase enum | +| `Features/Progression/Systems/ProgressionSystem.cs` | In-memory cache + write serialization (SemaphoreSlim) | +| `Features/Progression/Systems/ProgressionRepository.cs` | PlayerPrefs JSON + thumbnail file IO | +| `Features/Progression/Installers/ProgressionFeatureModule.cs` | Registers `IProgressionSystem` as Singleton in Root scope | + +### Single rule + +> Only `GameplayFlowController` calls `_progression.SaveProgressAsync(...)`. Feature controllers expose getters; they never touch the tracker themselves. This means there is exactly one place to audit when save behavior changes. + +--- + ## 13. Communication Rules | Use case | Mechanism | @@ -1145,44 +1285,109 @@ Same shape repeats for every feature's UI. ## 26. ShapeBuilder — Snap Algorithm -All math is in canvas-local space — `anchoredPosition`, `sizeDelta`, `localRotation`. No world coords. +All math is in canvas-local space — `anchoredPosition`, `sizeDelta`, `localRotation`. No world coords. Behavior lives inline in `ShapePiece : MonoBehaviour` — no FSM, no factory, no state classes. Three behaviors expressed across three Unity drag handlers. + +### `OnDrag` — reactive preview lerp ```csharp -// In ShapePieceFsm.OnDragEnd (state: Dragging or Preview): -public void OnDragEnd() { - var pieceRT = _ui.RectTransform; - var slotRT = _targetSlot.RectTransform; - var d = Vector2.Distance(pieceRT.anchoredPosition, slotRT.anchoredPosition); +public void OnDrag(PointerEventData e) +{ + if (_locked) return; - if (d <= _cfg.SnapRadius) { - SnapToSlot(); - } else if (d <= _cfg.SnapRadius * 1.5f) { - // Toddler grace zone — snap anyway, play happy sound - SnapToSlot(); - _audio.PlayOneShot(SfxId.NiceTry); - } else { - ReturnToTrayAnimated(); + var pointerLocal = ScreenToLocal(e.position) + _grabOffset; + var slotPos = _slot.RectTransform.anchoredPosition; + float dist = Vector2.Distance(pointerLocal, slotPos); + + if (dist <= _cfg.PreviewRadius) + { + if (!_inPreview) { _inPreview = true; _sfx.Play(SfxId.ShapeHover); } + ApplyPreviewLerp(pointerLocal, dist); + } + else + { + _inPreview = false; + RectTransform.anchoredPosition = pointerLocal; + RectTransform.sizeDelta = _traySize; + RectTransform.localRotation = Quaternion.identity; } } -private void SnapToSlot() { - _ui.RectTransform.SetParent(_paper.PiecesParent, worldPositionStays: false); - var slot = _targetSlot.RectTransform; - _ui.RectTransform.DOAnchorPos(slot.anchoredPosition, 0.25f).SetEase(Ease.OutBack); - _ui.RectTransform.DOSizeDelta(slot.sizeDelta, 0.25f).SetEase(Ease.OutBack); - _ui.RectTransform.DOLocalRotateQuaternion(slot.localRotation, 0.25f).SetEase(Ease.OutBack); - _audio.PlayOneShot(SfxId.Snap); - _bus.Publish(new PieceSnappedSignal(_ui.PieceId)); +private void ApplyPreviewLerp(Vector2 pointerLocal, float dist) +{ + float t = Mathf.Clamp01(1f - dist / _cfg.PreviewRadius); + if (_cfg.PreviewCurve != null) t = _cfg.PreviewCurve.Evaluate(t); + var slot = _slot.RectTransform; + RectTransform.anchoredPosition = Vector2.Lerp(pointerLocal, slot.anchoredPosition, t); + RectTransform.sizeDelta = Vector2.Lerp(_traySize, slot.sizeDelta, t); + RectTransform.localRotation = Quaternion.Slerp(Quaternion.identity, slot.localRotation, t); } ``` -Three things to note: +### `OnEndDrag` — snap or return -1. **Reparent** the piece from `TrayPanel` (HUD canvas) to `ColorBookSceneRefs.PiecesParent` (PaperCanvas) so it'll be included in capture. `worldPositionStays: false` because we want the new `anchoredPosition` to be relative to the new parent, not the world. -2. **Three simultaneous tweens** — position, size, rotation. Use `DOAnchorPos`, `DOSizeDelta`, `DOLocalRotateQuaternion`. They start together so the piece visually snaps as one motion. +```csharp +public void OnEndDrag(PointerEventData e) +{ + if (_locked) return; + float dist = Vector2.Distance( + RectTransform.anchoredPosition, + _slot.RectTransform.anchoredPosition); + if (dist <= _cfg.SnapRadius) Snap(); + else ReturnToTray(); +} + +private void Snap() +{ + Lock(); // reparent + raycast off + _locked = true + var slot = _slot.RectTransform; + Tween.UIAnchoredPosition(RectTransform, slot.anchoredPosition, _cfg.SnapDuration, Ease.OutBack); + Tween.UISizeDelta (RectTransform, slot.sizeDelta, _cfg.SnapDuration, Ease.OutBack); + Tween.LocalRotation (RectTransform, slot.localRotation, _cfg.SnapDuration, Ease.OutBack); + _sfx.Play(SfxId.ShapeSnap); + _bus.Publish(new PieceSnappedSignal(_shape.Id)); +} +``` + +### Four things worth noting + +1. **Reparent on lock** — `Lock()` calls `RectTransform.SetParent(_slot.RectTransform.parent, false)`. The piece moves from the HUD-side tray to the per-drawing slot parent so it travels with the paper and gets included in the captured PNG. +2. **Three parallel PrimeTween calls** — position, size, rotation. Tweens start together so the piece visually snaps as one motion. Zero allocations per tween. 3. **`SnapRadius` is in canvas units** (from `ShapeBuilderConfig`, e.g. 80–120), not world units. Same `CanvasScaler` reference resolution across devices = same hit feel. +4. **Preview hover sound fires once per drag**, on the boundary cross into the preview radius. `_inPreview` flag resets on `OnBeginDrag`. -Controller listens for `PieceSnappedSignal`, counts against expected piece count, fires `ShapeAssembledSignal` when complete. +### Pre-snapped resume + +If the user closes the app mid-assembly (or after completing the drawing), the saved `DrawingProgress.snappedPieces` lists which pieces were locked. On resume, the controller passes `preSnapped: true` to `Setup` for those, and `ShapePiece.SnapInstantly()` puts them straight into their slots — no tween, no input. The user can keep snapping the remaining pieces. + +```csharp +private void SnapInstantly() +{ + Lock(); + var slot = _slot.RectTransform; + RectTransform.anchoredPosition = slot.anchoredPosition; + RectTransform.sizeDelta = slot.sizeDelta; + RectTransform.localRotation = slot.localRotation; +} +``` + +### Spawn loop in `ShapeBuilderController.BuildAsync` + +```csharp +var preSnappedIds = progress?.snappedPieces; +foreach (var (shape, idx) in template.Pieces.Select((s, i) => (s, i))) +{ + var go = Instantiate(_piecePrefab, _refs.TrayPanel); + var piece = go.GetComponent(); + var slot = FindSlotForShape(slots, shape); + var trayPos = _trayLayout.GetSlotPosition(idx, template.Pieces.Count); + var preSnapped = preSnappedIds != null && preSnappedIds.Contains(shape.Id); + + piece.Setup(shape, slot, _cfg, _sfx, _bus, trayPos, preSnapped); + _alive.Add(piece); +} +``` + +Controller listens for `PieceSnappedSignal`, counts against expected piece count, fires `ShapeAssembledSignal` when complete → `GameplayFlowController` captures bare-assembled thumbnail, transitions to Coloring (see §13). --- @@ -1273,19 +1478,24 @@ Toddler-mode error UI: | Class | Layer | Asmdef | |---|---|---| -| `IDrawingTemplate`, `ColorRegionDTO` | Core | `Core` | -| `ShapeSO` (ScriptableObject) | Core | `Core` | -| `ICommand`, `IUndoStack` | Core | `Core` | -| `UndoStack` | Features | `Features.History` | +| `IDrawingTemplate`, `IDrawingTemplateCatalog`, `IDrawingCatalogController` | Core | `Core` | +| `ColorRegionDTO`, `PaintCommandDTO`, `ColorPaletteSO` | Core | `Core` | +| `ShapeSO`, `ShapeBuilderConfig` (ScriptableObjects) | Core | `Core` | +| `DrawingProgress`, `DrawingPhase`, `ProgressionRootDto`, `RegionColorEntry` | Core | `Core` | +| `ICommand`, `IUndoStack`, `IProgressionSystem` | Core | `Core` | +| `UndoStack`, `HistoryButtonsView`, `HistoryButtonsPresenter` | Features | `Features.History` | | `AddressableAssetProviderService` | Services | `Services.Assets` | | `NativeGallerySaveService` | Services | `Services.Gallery` | -| `RenderTextureCaptureService` | Services | `Services.Capture` | -| `ColoringController`, `PaintRegionCommand` | Features | `Features.Coloring` | -| `ShapeBuilderController`, `ShapePieceUI` | Features | `Features.ShapeBuilder` | -| `HistoryController` | Features | `Features.History` | -| `ColorBookFlowController` | Features | `Features.ColorBookFlow` | +| `CaptureService` | Services | `Services.Capture` | +| `ColoringController`, `ColoringStateRepository`, `ColorRegionView`, `PaintRegionCommand` | Features | `Features.Coloring` | +| `ShapePiece`, `SlotMarker`, `ShapeBuilderController`, `TrayLayout` | Features | `Features.ShapeBuilder` | +| `AddressableDrawingTemplateCatalog` | Features | `Features.DrawingTemplate` | +| `DrawingCatalogController`, `DrawingCatalogPresenter`, `DrawingCatalogView`, `CatalogItemVM` | Features | `Features.DrawingCatalog` | +| `ColorbookFlowController` | Features | `Features.Colorbook` | +| `GameplayFlowController` | Features | `Features.GameplayFlow` | +| `ProgressionSystem`, `ProgressionRepository` | Features | `Features.Progression` | | `MenuMascotView`, `MenuMascotPresenter` | Features | `Features.MainMenu` | -| `ColorBookSceneRefs`, `ColorBookLifetimeScope`, `AppBoot` | App | `Darkmatter.App` | +| `RootLifetimeScope`, `MainMenuLifetimeScope`, `ColorbookLifetimeScope`, `GameplayLifetimeScope`, `GameplaySceneRefs`, `AppBoot` | App | `Darkmatter.App` | If a class's natural home doesn't match its asmdef, the architecture is bent — fix the placement, don't add a reference. @@ -1305,18 +1515,23 @@ Comprehensive index — every script (existing or planned) grouped by its module | `Core/Contracts/Services/Scenes/` | `ISceneService` | ✅ | | `Core/Contracts/Services/Capture/` | `ICaptureService` | ⚠️ | | `Core/Contracts/Services/Gallery/` | `IGalleryService` | ⚠️ | -| `Core/Contracts/Features/Drawing/` | `IDrawingTemplate`, `IDrawingTemplateCatalog` | ⚠️ | -| `Core/Contracts/Features/Coloring/` | `IColorPalette` | ⚠️ | +| `Core/Contracts/Features/DrawingCatalog/` | `IDrawingCatalogController`, `IDrawingTemplate`, `IDrawingTemplateCatalog` | ✅ | +| `Core/Contracts/Features/Coloring/` | `IColorPalette` | ✅ | | `Core/Contracts/Features/History/` | `ICommand`, `IUndoStack` | ✅ | -| `Core/Contracts/Features/Progression/` | `IProgressionService` | ⚠️ | +| `Core/Contracts/Features/Progression/` | `IProgressionSystem` | ✅ | | `Core/Data/Dynamic/Services/Audio/` | `AudioHandle`, `AudioRequest` | ✅ | | `Core/Data/Static/Services/Audio/` | `SfxCatalogSO` | ✅ | -| `Core/Data/Static/Features/Drawing/` | `DrawingTemplateSO`, `ShapeSO` | ⚠️ | -| `Core/Data/Static/Features/Coloring/` | `ColorPaletteSO` | ⚠️ | -| `Core/Data/Dynamic/Features/Drawing/` | `ColorRegionDTO` | ⚠️ | -| `Core/Data/Dynamic/Features/Coloring/` | `PaintCommandDTO` | ⚠️ | -| `Core/Data/Dynamic/Features/Signals/` | `DrawingSelectedSignal`, `ShapeAssembledSignal`, `ColorAppliedSignal`, `PieceSnappedSignal`, `PaperCapturedSignal`, `PaperSavedSignal` | ⚠️ | -| `Core/Enums/Services/Audio/` | `AudioChannel`, `AudioPlayMode`, `SfxId` | ✅ | +| `Core/Data/Static/Features/DrawingTemplate/` | `DrawingTemplateSO` | ✅ | +| `Core/Data/Static/Features/ShapeBuilder/` | `ShapeSO`, `ShapeBuilderConfig` | ✅ | +| `Core/Data/Static/Features/Coloring/` | `ColorPaletteSO` | ✅ | +| `Core/Data/Dynamic/Features/Coloring/` | `ColorRegionDTO`, `PaintCommandDTO` | ✅ | +| `Core/Data/Dynamic/Features/Progression/` | `DrawingProgress`, `ProgressionRootDto`, `RegionColorEntry` | ✅ | +| `Core/Data/Signals/Features/DrawingCatalog/` | `DrawingSelectedSignal` | ✅ | +| `Core/Data/Signals/Features/ShapeBuilder/` | `ShapeAssembledSignal`, `PieceSnappedSignal` | ✅ | +| `Core/Data/Signals/Features/Coloring/` | `ColorAppliedSignal` | ⚠️ | +| `Core/Data/Signals/Features/Capture/` | `PaperCapturedSignal`, `PaperSavedSignal` | ⚠️ | +| `Core/Enums/Features/Progression/` | `DrawingPhase` (`ShapeBuilding`, `Coloring`) | ✅ | +| `Core/Enums/Services/Audio/` | `SfxId` (`None`, `ShapeHover`, `ShapeSnap`, `ShapeNiceTry`, `ShapeReturn`) | ✅ | | `Core/Enums/Services/Camera/` | `CameraType` (add `CaptureCamera` value) | ✅ | | `Core/Enums/Services/Scenes/` | `GameScene` | ✅ | @@ -1324,8 +1539,8 @@ Comprehensive index — every script (existing or planned) grouped by its module | Module (path) | Scripts | Status | |---|---|---| -| `Libs/FSM/` | `IState`, `State`, `StateMachine` | ✅ | -| `Libs/Installers/` | `IServiceModule` | ✅ | +| `Libs/FSM/` | `IState`, `State`, `StateMachine` (abstract) | ✅ | +| `Libs/Installers/` | `IModule` | ✅ | | `Libs/Observer/` | `IEventBus`, `EventBus` | ✅ | | `Libs/PlayerPrefs/Runtime/` | `ProtectedPlayerPrefs`, `ProtectedPlayerPrefsSettings`, `PlayerPrefsKeys`, `PlayerPrefsKeyRegistry`, `LocalWriteTracker`, `PendingWriteResync` | ✅ | | `Libs/PlayerPrefs/Editor/` | `PlayerPrefsEditorWindow`, `ProtectedPlayerPrefsGettingStartedWindow`, `ProtectedPlayerPrefsSettingsUtility`, `ProtectedPlayerPrefsSetupBootstrap` | ✅ | @@ -1335,55 +1550,61 @@ Comprehensive index — every script (existing or planned) grouped by its module | Module (path) | Scripts | Status | |---|---|---| -| `Services/Analytics/Installers/` | `AnalyticsServiceModule` | ✅ | +| `Services/Analytics/Installers/` | `AnalyticsModule` | ✅ | | `Services/Analytics/Systems/` | `FirebaseAnalyticsSystem` | ✅ | | `Services/Assets/` | `AddressableAssetProviderService`, `AddressableLoadHandleTracker` | ✅ | | `Services/Audio/` | `AudioService`, `SfxPlayer` | ✅ | | `Services/Camera/Service/` | `CameraService` | ✅ | -| `Services/Camera/Installers/` | `CameraServiceModule` | ✅ | +| `Services/Camera/Installers/` | `CameraModule` | ✅ | | `Services/Inputs/Generated/` | `GameInputs` (Input System codegen) | ✅ | -| `Services/Inputs/Readers/` | `InputReaderSO` | ✅ | -| `Services/Inputs/Installers/` | `InputServiceModule` | ✅ | +| `Services/Inputs/` | (Inputs feature partial — Reader + Installer location TBD) | ⚠️ | | `Services/Scenes/` | `SceneService` | ✅ | -| `Services/Capture/` | `RenderTextureCaptureService`, `CaptureServiceModule` | ⚠️ | -| `Services/Gallery/` | `NativeGallerySaveService`, `GalleryServiceModule` | ⚠️ | +| `Services/Capture/Systems/` | `CaptureService` | ✅ (stub) | +| `Services/Capture/Installers/` | `CaptureModule` | ✅ | +| `Services/Gallery/Core/` | `GalleryService` | ✅ (stub — needs native plugin wiring) | +| `Services/Gallery/Installers/` | `GalleryModule` | ✅ | #### Features | Module (path) | Scripts | Status | |---|---|---| | `Features/History/Stack/` | `UndoStack` | ✅ | -| `Features/History/Installers/` | `HistoryServiceModule` | ✅ | -| `Features/History/UI/` | `HistoryButtonsView`, `HistoryPresenter`, `HistoryController` | ⚠️ | -| `Features/MainMenu/Installers/` | `MainMenuModule` | ⚠️ | +| `Features/History/Installers/` | `HistoryFeatureModule` | ✅ | +| `Features/History/UI/` | `HistoryButtonsView`, `HistoryButtonsPresenter` | ✅ | +| `Features/MainMenu/Installers/` | `MainMenuFeatureModule` | ⚠️ | | `Features/MainMenu/Systems/` | `MainMenuModel`, `MenuMascotPresenter` | ⚠️ | | `Features/MainMenu/UI/` | `MenuMascotView`, `IMenuMascotView` | ⚠️ | -| `Features/DrawingCatalog/Systems/` | `DrawingCatalogController` | ⚠️ | -| `Features/DrawingCatalog/UI/` | `DrawingCatalogPresenter`, `DrawingCatalogView`, `IDrawingCatalogView`, `CatalogItemVM` | ⚠️ | -| `Features/DrawingCatalog/Installers/` | `DrawingCatalogModule` | ⚠️ | -| `Features/ShapeBuilder/Systems/` | `ShapeBuilderController`, `ShapePieceFsm`, `ShapePieceFactory`, `TrayLayout` | ⚠️ | -| `Features/ShapeBuilder/UI/` | `ShapePieceUI`, `SlotMarker`, `TrayPanel` | ⚠️ | -| `Features/ShapeBuilder/Installers/` | `ShapeBuilderModule` | ⚠️ | +| `Features/DrawingCatalog/Systems/` | `DrawingCatalogController` | ✅ | +| `Features/DrawingCatalog/UI/` | `DrawingCatalogPresenter`, `DrawingCatalogView`, `DrawingCatalogButton`, `CatalogItemVM` | ✅ | +| `Features/DrawingCatalog/Installers/` | `DrawingCatalogFeatureModule` | ✅ | +| `Features/DrawingTemplate/Systems/` | `AddressableDrawingTemplateCatalog` | ✅ | +| `Features/DrawingTemplate/Installers/` | `DrawingTemplateFeatureModule` | ✅ | +| `Features/ShapeBuilder/UI/` | `ShapePiece`, `SlotMarker` | ✅ | +| `Features/ShapeBuilder/Systems/` | `ShapeBuilderController`, `TrayLayout` | ⚠️ | +| `Features/ShapeBuilder/Installers/` | `ShapeBuilderFeatureModule` | ✅ | | `Features/Coloring/Systems/` | `ColoringController`, `ColoringStateRepository`, `ColorRegionFactory` | ⚠️ | | `Features/Coloring/UI/` | `ColorRegionView`, `ColorPaletteView`, `ColorPalettePresenter` | ⚠️ | | `Features/Coloring/Commands/` | `PaintRegionCommand` | ⚠️ | -| `Features/Coloring/Installers/` | `ColoringModule` | ⚠️ | -| `Features/Capture/Systems/` | `CaptureController` | ⚠️ | +| `Features/Coloring/Installers/` | `ColoringFeatureModule` | ⚠️ | +| `Features/Capture/Systems/` | `CaptureController` (light wrapper around `ICaptureService`) | ⚠️ | | `Features/Capture/UI/` | `CaptureButtonPresenter`, `SaveToastView` | ⚠️ | | `Features/Capture/Installers/` | `CaptureFeatureModule` | ⚠️ | -| `Features/Progression/Systems/` | `ProgressionService`, `ProgressionRepository` | ⚠️ | -| `Features/Progression/Installers/` | `ProgressionModule` | ⚠️ | -| `Features/ColorBookFlow/Systems/` | `ColorBookFlowController` | ⚠️ | -| `Features/ColorBookFlow/Installers/` | `ColorBookFlowModule` | ⚠️ | +| `Features/Progression/Systems/` | `ProgressionSystem`, `ProgressionRepository` | ✅ (stubs) | +| `Features/Progression/Installers/` | `ProgressionFeatureModule` | ✅ | +| `Features/ColorbookFlow/System/` | `ColorbookFlowController` | ✅ (needs constructor injection) | +| `Features/ColorbookFlow/Installers/` | `ColorbookFlowFeatureModule` | ✅ | +| `Features/GameplayFlow/Systems/` | `GameplayFlowController` (single owner of all saves — §13) | ⚠️ | +| `Features/GameplayFlow/Installers/` | `GameplayFlowFeatureModule` | ⚠️ | #### App | Module (path) | Scripts | Status | |---|---|---| -| `App/LifetimeScopes/` | `RootLifetimeScope` | ✅ | -| `App/LifetimeScopes/` | `MainMenuLifetimeScope`, `ColorBookLifetimeScope` | ⚠️ | -| `App/Boot/` | `AppBoot` | ⚠️ | -| `App/SceneRefs/` | `ColorBookSceneRefs` | ⚠️ | +| `App/LifetimeScopes/` | `BaseLifetimeScope` (abstract), `RootLifetimeScope` | ✅ | +| `App/LifetimeScopes/` | `GameLifetimeScope` (placeholder, empty), `GameplayLifetimescope` (typo — needs rename to `GameplayLifetimeScope`) | ⚠️ | +| `App/LifetimeScopes/` | `MainMenuLifetimeScope`, `ColorbookLifetimeScope`, `GameplayLifetimeScope` (final) | ⚠️ planned | +| `App/Boot/` | `AppBoot` | ⚠️ planned | +| `App/SceneRefs/` | `GameplaySceneRefs` (PaperRoot, SlotsParent, PiecesParent, RegionsParent, TrayPanel, CaptureCamera) | ⚠️ planned | --- @@ -1728,109 +1949,124 @@ public interface IDrawingCatalogView { --- -### 32.5 Feature — `ShapeBuilder` *(planned)* +### 32.5 Feature — `ShapeBuilder` *(planned controller, MBs exist)* -#### `ShapeBuilderController` *(Systems)* -Spawns shape pieces for the selected template, tracks snap progress, fires `ShapeAssembledSignal` when complete. +The simplified post-FSM design. **No state machine per piece, no factory class.** Three roles: piece MB, slot MB, controller. Plus a tunables SO. + +#### `ShapePiece : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler` *(UI — ✅ exists)* +The single MonoBehaviour that handles drag, reactive preview lerp, snap (PrimeTween), and return-to-tray. Spawned by the controller; `Setup` binds dependencies and starting pose. ```csharp -// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory, -// ColorBookSceneRefs _refs, TrayPanel _tray, IEventBus _bus, ShapeBuilderConfig _cfg -public sealed class ShapeBuilderController : IDisposable { - public IReadOnlyList Active { get; } - public UniTask BuildAsync(string templateId); // load template, spawn pieces in tray - public void Reset(); // clear, unsubscribe -} -// sub: DrawingSelectedSignal -// pub: ShapeAssembledSignal -``` -- **Internal:** counts `PieceSnappedSignal` against expected piece count. -- **Slot discovery:** after a drawing's per-drawing prefab is instantiated under `ColorBookSceneRefs.PaperRoot`, the controller queries `GetComponentsInChildren()` to discover all slots in the loaded drawing. Each slot's `_shape` field tells which `ShapeSO` it expects; matching pieces are spawned in the tray. - -#### `ShapePieceUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler` *(UI)* -The UI Image that the toddler drags. One prefab; the assigned `ShapeSO` determines visual identity and snap params. - -```csharp -public sealed class ShapePieceUI : MonoBehaviour, +public sealed class ShapePiece : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { - [SerializeField] private ShapeSO _shape; // set by controller at spawn (or in inspector for testing) - [SerializeField] private Image _image; + [SerializeField] private Image image; + private ShapeSO _shape; // bound by Setup + private SlotMarker _slot; + private ShapeBuilderConfig _cfg; + private ISfxPlayer _sfx; + private IEventBus _bus; + private Vector2 _trayPos; + private Vector2 _traySize; + private bool _locked; + public ShapeSO Shape => _shape; public string PieceId => _shape != null ? _shape.Id : null; - public ShapeSO Shape => _shape; - public RectTransform RectTransform => (RectTransform)transform; - public bool IsLocked { get; private set; } - public event Action Snapped; + public bool IsLocked => _locked; + public RectTransform RectTransform { get; } - // Controller calls this at spawn time - public void Assign(ShapeSO shape) { - _shape = shape; - ApplyShape(); + public void Setup( + ShapeSO shape, SlotMarker slot, ShapeBuilderConfig cfg, + ISfxPlayer sfx, IEventBus bus, + Vector2 trayPos, bool preSnapped); +} +``` +- Drag handlers run inline in this MB — no separate FSM class. +- `Snap()` is a PrimeTween triple (`Tween.UIAnchoredPosition` / `UISizeDelta` / `LocalRotation`); `SnapInstantly()` is the resume path that puts a pre-snapped piece directly into its slot pose without animation. +- `ReturnToTray()` builds a PrimeTween `Sequence` of three parallel tweens. +- See §26 for the snap algorithm walkthrough. + +#### `SlotMarker : MonoBehaviour` *(UI — ✅ exists)* +Authored per drawing — designer places one in the per-drawing prefab at each slot location with the `RectTransform` set to the target pose and `_shape` field assigned to the matching `ShapeSO`. The `RectTransform` itself **is** the target pose. +```csharp +public sealed class SlotMarker : MonoBehaviour +{ + [SerializeField] private ShapeSO shape; + [SerializeField] private Image outline; // optional faint outline UI + + public ShapeSO Shape => shape; + public string SlotId => shape != null ? shape.Id : null; + public RectTransform RectTransform => (RectTransform)transform; + + public void SetOutlineVisible(bool visible); +} +``` +**Matching is by `ShapeSO` reference equality** — the controller pairs each `ShapePiece.Shape` with the `SlotMarker.Shape` of the same SO asset. + +#### `ShapeBuilderConfig : ScriptableObject` *(Static data — ✅ exists)* +```csharp +[CreateAssetMenu(menuName = "Darkmatter/ShapeBuilder/Config")] +public sealed class ShapeBuilderConfig : ScriptableObject +{ + public float SnapRadius; // 80–120 canvas units + public float SnapGraceMultiplier; // (currently unused — grace zone removed) + public float PreviewRadius; // ~2× SnapRadius + public float SnapDuration; // 0.25s + public float ReturnDuration; // 0.25s + public AnimationCurve PreviewCurve; // easing for the reactive lerp + + public Vector2 DragSizeDelta(ShapeSO shape); +} +``` + +#### `ShapeBuilderController` *(Systems — planned)* +Spawns pieces for the selected template, tracks snap progress, fires `ShapeAssembledSignal` when complete. **Replaces the old `ShapePieceFactory` — spawning is now a 5-line inline loop, not a separate class.** +```csharp +// fields: IDrawingTemplateCatalog _catalog, IAssetProviderService _assets, +// GameplaySceneRefs _refs, TrayLayout _trayLayout, +// ShapeBuilderConfig _cfg, ISfxPlayer _sfx, IEventBus _bus +public sealed class ShapeBuilderController : IAsyncStartable, IDisposable +{ + private readonly List _alive = new(); + private GameObject _piecePrefab; + + public async UniTask StartAsync(CancellationToken ct) { + _piecePrefab = await _assets.LoadAssetAsync( + "shapebuilder/piece", null, ct); + _pieceSnappedSub = _bus.Subscribe(OnPieceSnapped); } - private void OnValidate() => ApplyShape(); // editor inspector edits - private void Awake() => ApplyShape(); // runtime safety + public IReadOnlyList Alive => _alive; + public IReadOnlyCollection GetSnappedPieceIds(); // for save records - private void ApplyShape() { - if (_shape == null || _image == null) return; - _image.sprite = _shape.Sprite; - RectTransform.sizeDelta = _shape.DefaultSizeDelta; - } + public async UniTask BuildAsync( + IDrawingTemplate template, + IReadOnlyCollection preSnappedIds = null); + + public void Reset(); // despawns all pieces; called by GameplayFlowController on Teardown } +// sub: PieceSnappedSignal +// pub: ShapeAssembledSignal ``` -- Handlers forward to `ShapePieceFsm` (`OnDragBegin / OnDrag(localPos) / OnDragEnd`). -- `OnDrag` converts `PointerEventData.position` to canvas-local via `RectTransformUtility.ScreenPointToLocalPointInRectangle` against the piece's parent rect. -- No collider, no Physics2D anywhere. -- **Identity follows the SO** — change `_shape` in inspector and the visual + ID update on the next `OnValidate`. At runtime, `Assign(...)` is the only mutation path. +- **Slot discovery:** after the per-drawing prefab is instantiated, `GetComponentsInChildren(includeInactive: true)` finds all slots. Each slot's `_shape` tells which `ShapeSO` it expects. +- **Pre-snap on resume:** if `preSnappedIds.Contains(shape.Id)`, the spawned `ShapePiece` is initialized with `preSnapped: true` → `SnapInstantly()` lands it in the slot at scope start. +- **Snapped count:** subscribes to `PieceSnappedSignal`, counts against expected, fires `ShapeAssembledSignal` when count == `template.Pieces.Count`. -#### `ShapePieceFsm` *(Systems)* -Per-piece state machine using `Libs.FSM`. States: `InTray → Dragging → Preview → (Snapped | Returning)`. +#### `TrayLayout` *(Systems — planned)* +Tiny stateless helper. Given (`index`, `total`), returns the tray slot's `anchoredPosition`. Either uses a `HorizontalLayoutGroup`'s computed positions or a hand-rolled even spacing across the tray's width. ```csharp -// fields: ShapePieceUI _ui, SlotMarker _targetSlot, ShapeBuilderConfig _cfg, -// IAudioService _audio, IEventBus _bus -public sealed class ShapePieceFsm { - public void OnDragBegin(); - public void OnDrag(Vector2 canvasLocalPos); - public void OnDragEnd(); - public bool IsLocked { get; } +public sealed class TrayLayout +{ + [SerializeField] private RectTransform trayRect; + public Vector2 GetSlotPosition(int index, int total); } ``` -- **Preview-state update**: reactive lerp of `anchoredPosition / sizeDelta / localRotation` toward `_targetSlot`'s pose, driven by `1 - dist/PreviewRadius`. No DOTween while previewing — it's per-frame. -- **Snapped enter**: DOTween ease-out to exact slot pose (~0.2s), disable drag, fire `PieceSnappedSignal`. -- **Returning enter**: DOTween back to tray slot (`anchoredPosition` from `TrayLayout`). -#### `SlotMarker : MonoBehaviour` *(UI)* -The outline `Image` under `ColorBookSceneRefs.SlotsParent` showing where a piece should snap. Authored per drawing — designer places one in the per-drawing prefab at each slot location, with its `RectTransform` set to the target pose and `_shape` field assigned to the matching `ShapeSO`. -```csharp -public sealed class SlotMarker : MonoBehaviour { - [SerializeField] private ShapeSO _shape; // which shape fits here - [SerializeField] private Image _outline; // optional faint outline UI +#### Removed / not needed - public ShapeSO Shape => _shape; - public string SlotId => _shape != null ? _shape.Id : null; - public RectTransform RectTransform => (RectTransform)transform; -} -``` -- **Pose lives on this MB's `RectTransform`** — `anchoredPosition`, `sizeDelta`, `localRotation` directly. No pose data on the SO. -- **Matching:** `ShapePieceFsm` compares `_piece.Shape == _slot.Shape` (Unity Object reference equality). No string lookups. - -#### `TrayPanel : MonoBehaviour` *(UI)* -HUD-side panel (on `HUDCanvas`) where pieces start out. Has a `HorizontalLayoutGroup` + `ContentSizeFitter`. Provides spawn anchors via `RectTransform Slot(int index)` for the controller. - -#### `ShapePieceFactory` *(Systems)* -Pool of `ShapePieceUI` GameObjects (one prefab) + their associated FSMs. Reused across template loads. -```csharp -public sealed class ShapePieceFactory { - // Instantiates the single piece prefab under `parent`, calls Assign(shape) on it, - // and wires up its FSM with the matching SlotMarker. - public ShapePieceUI Spawn(ShapeSO shape, SlotMarker targetSlot, RectTransform parent); - public void Despawn(ShapePieceUI piece); -} -``` -- One prefab in `Content/Gameplay/Prefabs/ShapePiece.prefab` is instantiated repeatedly. Visual identity comes from the `ShapeSO` passed to `Assign`. - -#### `ShapeBuilderInputBinder` *(Systems)* -With UI handlers on the piece itself, an explicit input binder isn't strictly needed — drag events route via the EventSystem. Keep this class only if you need to listen for "any tap outside any piece" (e.g. to dismiss a preview). Otherwise skip. +- **`ShapePieceFsm`** — was a per-piece state machine. Replaced by inline drag handlers + a single `_locked` bool on `ShapePiece`. +- **`ShapePieceFactory`** — was a wrapper around `Instantiate` + FSM wiring. Replaced by a 5-line inline loop in `ShapeBuilderController.BuildAsync`. +- **Five state classes** (`InTray`, `Dragging`, `Preview`, `Snapped`, `Returning`) — gone. Their behavior maps to: `_locked = false` (idle/dragging/preview all share the same handlers), `_inPreview` flag (preview boundary detection), `Snap()` method, `ReturnToTray()` method. +- **`ShapeBuilderInputBinder`** — never needed; UI handlers on the piece are sufficient. --- @@ -1855,16 +2091,27 @@ public sealed class ColoringStateRepository { Builds and pushes `PaintRegionCommand` instances; spawns `ColorRegionView` per region. ```csharp // fields: IUndoStack _undo, ColoringStateRepository _state, ColorRegionFactory _factory, -// ColorBookSceneRefs _refs, IEventBus _bus +// GameplaySceneRefs _refs, IEventBus _bus public interface IColoringController { - UniTask SpawnRegionsAsync(IDrawingTemplate template); - void PaintRegion(ColorRegionView view); // builds command, pushes to undo stack - void Clear(); + // Spawn regions on the paper. Pass non-null savedColors to restore colors + // from a DrawingProgress record; null = use ColorRegionDTO.InitialColor. + UniTask SpawnRegionsAsync( + IDrawingTemplate template, + IReadOnlyDictionary savedColors = null); + + void PaintRegion(ColorRegionView view); // builds command, pushes to undo stack + + // Snapshot current paint state for save records (see §13). + IReadOnlyDictionary GetCurrentColors(); + + void Clear(); // despawn all regions } // sub: ShapeAssembledSignal (via flow controller, not direct) // pub: ColorAppliedSignal (via PaintRegionCommand) ``` -Spawns each region as a UI `Image` under `_paper.RegionsParent`. No `Physics2D`. +Spawns each region as a UI `Image` under `_refs.RegionsParent`. No `Physics2D`. + +**Autosave integration:** after each successful `PaintRegion`, the controller calls a debounced `GameplayFlowController.ScheduleAutosave()` so the flow can write the new color state to `IProgressionSystem` 500 ms later (no thumbnail, cheap). The flow controller cancels and resets the timer on each paint — only the last paint in a burst triggers the write. #### `ColorRegionView : MonoBehaviour, IPointerClickHandler` *(UI)* UI Image with alpha-based hit detection. Tap routes through Unity's EventSystem directly to `OnPointerClick`. @@ -2045,41 +2292,47 @@ Implemented as `MonoBehaviour` per feature/service so scopes can drag them in th |---|---|---|---| | `AppBoot` | App | Startup sequencer | assets, progression, audio, scenes | | `RootLifetimeScope` | App | Root DI | service modules | -| `ColorBookLifetimeScope` | App | Scene DI | scene refs, feature modules | | `MainMenuLifetimeScope` | App | Menu scene DI | feature modules | -| `ColorBookSceneRefs` | App | Scene-bound RectTransform / Camera holder | — | -| `MenuMascotView` | Feature | Spine mascot UI (SkeletonGraphic wrapper) | — | -| `MenuMascotPresenter` | Feature | Drives mascot animations from model events | view, model | -| `DrawingCatalogController` | Feature | Grid logic | catalog, bus | -| `DrawingCatalogPresenter` | Feature | UI bridge | view, controller, catalog | -| `ShapeSO` | Core asset | Authored shape (sprite + snap params, reusable) | — | -| `ShapeBuilderController` | Feature | Piece spawn + snap tracking | catalog, factory, refs, tray, bus, cfg | -| `ShapePieceUI` | Feature | Draggable UI piece prefab; holds `[SerializeField] ShapeSO _shape` | fsm | -| `ShapePieceFsm` | Feature | Per-piece state machine (Tray/Drag/Preview/Snapped/Returning) | ui, slot, cfg, audio, bus | -| `SlotMarker` | Feature | Slot outline UI Image at target pose; holds `_shape` | — | -| `TrayPanel` | Feature | HUD-side tray with LayoutGroup | — | -| `ColoringStateRepository` | Feature | Current color model | — | -| `ColoringController` | Feature | Region spawn + paint cmd | undo, state, factory, refs, bus | -| `ColorRegionView` | Feature | Region UI Image + IPointerClickHandler | controller | -| `PaintRegionCommand` | Feature | Undoable paint (sets Image.color) | view, bus | -| `HistoryController` | Feature | Undo/redo facade | undo stack, bus | -| `UndoStack` | Feature | Bounded undo store | — | -| `CaptureController` | Feature | Capture-then-save orchestration | capture svc, gallery svc, bus | -| `ColorBookFlowController` | Feature | Scene FSM (Catalog → Building → Coloring → Done) | bus, catalog, builder, coloring, capture, progression | -| `ProgressionService` | Feature | Completion tracking | PlayerPrefs lib | -| `EventBus` | Lib | Pub/sub | — | -| `StateMachine` | Lib | Generic FSM | — | -| `IServiceModule` | Lib | DI installer interface | — | -| `ProtectedPlayerPrefs` | Lib | Encrypted PlayerPrefs wrapper | — | -| `AddressableAssetProviderService` | Service | Addressables wrapper | — | -| `RenderTextureCaptureService` | Service | One-shot PNG render via CaptureCamera | scene refs | -| `NativeGallerySaveService` | Service | Native gallery save (thin plugin shim) | — | -| `SceneService` | Service | Async scene loads | — | -| `AudioService`, `SfxPlayer` | Service | SFX playback | assets | -| `CameraService` | Service | Camera registry (MainCamera, UICamera, CaptureCamera) | — | -| `InputReaderSO` | Service | New Input System reader | — | -| `FirebaseAnalyticsSystem` | Service | Analytics events | — | +| `ColorbookLifetimeScope` | App | Catalog scene DI | feature modules | +| `GameplayLifetimeScope` | App | Active drawing scene DI | scene refs, feature modules | +| `GameplaySceneRefs` | App | Scene-bound RectTransform + CaptureCamera holder | — | +| `MenuMascotView` | Feature.MainMenu | Spine mascot UI (`SkeletonGraphic` wrapper) | — | +| `MenuMascotPresenter` | Feature.MainMenu | Drives mascot animations from model events | view, model | +| `DrawingCatalogController` | Feature.DrawingCatalog | Visible-ID list + selection signal | catalog, progression, bus | +| `DrawingCatalogPresenter` | Feature.DrawingCatalog | UI bridge | view, controller, catalog, progression | +| `DrawingCatalogView` | Feature.DrawingCatalog | UI; renders cells | — | +| `CatalogItemVM` | Feature.DrawingCatalog | View-model per cell | — | +| `AddressableDrawingTemplateCatalog` | Feature.DrawingTemplate | Loads `DrawingTemplateSO`s, exposes `NextUnseen` | assets, progression | +| `ShapeSO` | Core asset | Authored shape (id + sprite + DefaultSizeDelta) | — | +| `ShapeBuilderConfig` | Core asset | Tunables (radii, durations, curve) | — | +| `ShapePiece` | Feature.ShapeBuilder | Draggable piece MB (drag + preview lerp + snap + return) | shape, slot, cfg, sfx, bus | +| `SlotMarker` | Feature.ShapeBuilder | Slot anchor MB; `RectTransform` == target pose | — | +| `ShapeBuilderController` | Feature.ShapeBuilder | Spawns pieces, tracks snap count | catalog, assets, refs, tray, cfg, sfx, bus | +| `TrayLayout` | Feature.ShapeBuilder | Computes piece tray positions | — | +| `ColoringStateRepository` | Feature.Coloring | Current color model | — | +| `ColoringController` | Feature.Coloring | Region spawn + paint cmd + autosave hook | undo, state, factory, refs, flow, bus | +| `ColorRegionView` | Feature.Coloring | Region UI Image + `IPointerClickHandler` | controller | +| `PaintRegionCommand` | Feature.Coloring | Undoable paint (sets `Image.color`) | view, bus | +| `HistoryController` | Feature.History | Undo/redo facade | undo stack, bus | +| `UndoStack` | Feature.History | Bounded undo store | — | +| `CaptureController` | Feature.Capture | (light wrapper) calls `ICaptureService.CaptureAsync` | capture svc | +| `ColorbookFlowController` | Feature.ColorbookFlow | Catalog scene orchestrator (init + selection→scene-load) | catalog, progression, scenes, bus | +| `GameplayFlowController` | Feature.GameplayFlow | Active drawing FSM + **single owner of all saves** (see §13) | catalog, builder, coloring, capture, gallery, progression, scenes, bus | +| `ProgressionSystem` | Feature.Progression | Per-template state + completed view | repository | +| `ProgressionRepository` | Feature.Progression | PlayerPrefs JSON + thumbnail file IO | — | +| `EventBus` | Lib.Observer | Pub/sub | — | +| `StateMachine` (abstract) + `State` | Lib.FSM | Generic FSM base (Enter/Tick/Exit, ChangeState) | — | +| `IModule` | Lib.Installers | DI installer interface | — | +| `ProtectedPlayerPrefs` | Lib.PlayerPrefs | Encrypted PlayerPrefs wrapper | — | +| `AddressableAssetProviderService` | Service.Assets | Addressables wrapper | — | +| `CaptureService` | Service.Capture | One-shot PNG render via `CaptureCamera` | refs | +| `NativeGallerySaveService` | Service.Gallery | Native gallery save (thin plugin shim) | — | +| `SceneService` | Service.Scenes | Async scene loads | — | +| `AudioService`, `SfxPlayer` | Service.Audio | SFX playback | assets | +| `CameraService` | Service.Camera | Camera registry | — | +| `InputReaderSO` | Service.Inputs | New Input System reader | — | +| `FirebaseAnalyticsSystem` | Service.Analytics | Analytics events | — | If you add a class not in this table, add it here in the same PR. This table is the cheap mental-model index — keep it honest. See §31b for the full path-by-path inventory. -> Today only these rows are real on disk: `RootLifetimeScope` (App), `AddressableAssetProviderService`, `AudioService`/`SfxPlayer`, `CameraService`, `SceneService`, `InputReaderSO`, `FirebaseAnalyticsSystem` (Services), `UndoStack` + `HistoryServiceModule` (Features.History), plus `Libs.*` entries (`EventBus`, `StateMachine`, `IServiceModule`, PlayerPrefs lib, UI toggles). Everything else is the target. +> **What's real on disk today (2026-05):** All Service classes (`AddressableAssetProviderService`, `AudioService`/`SfxPlayer`, `CameraService`, `SceneService`, `InputReaderSO`, `FirebaseAnalyticsSystem`, stub `CaptureService`, stub `GalleryService`), all Lib classes, `ShapePiece` + `SlotMarker` + `ShapeBuilderConfig`, `UndoStack` + `HistoryServiceModule`, `ProgressionSystem` + `ProgressionRepository` (stubs), `AddressableDrawingTemplateCatalog` + module, `DrawingCatalogController` + presenter + view, `ColorbookFlowController` (partial — needs constructor injection wired). Empty / planned: `MainMenu` feature, `GameplayFlow` feature, `Coloring` feature, `MainMenu`/`Colorbook`/`Gameplay` scene scopes, all scenes except `Boot.unity`.