savya #3

Merged
savya merged 2 commits from savya into main 2026-05-28 08:29:25 +02:00
39 changed files with 1092 additions and 266 deletions

View File

@@ -10,8 +10,8 @@ namespace Darkmatter.Core.Data.Dynamic.Features.Progression
{
public string templateId;
public DrawingPhase phase;
public List<string> SnappedPieces;
public List<RegionColorEntry> RegionColors;
public List<string> snappedPieces;
public List<RegionColorEntry> 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;

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 77eed34ff6b754cfc9c661d43deb905f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,6 @@
using UnityEngine;
namespace Darkmatter.Core.Data.Signals.Features.Coloring
{
public record struct ColorAppliedSignal(string RegionId, Color Color);
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a7dea5f84d9ba4103a449599d32ed73b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,4 @@
namespace Darkmatter.Core.Data.Signals.Features.ShapeBuilder
{
public record struct PieceSnappedSignal(string PieceId);
}

View File

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

View File

@@ -0,0 +1,4 @@
namespace Darkmatter.Core.Data.Signals.Features.ShapeBuilder
{
public record struct PieceUnsnappedSignal(string PieceId);
}

View File

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

View File

@@ -0,0 +1,6 @@
using UnityEngine;
namespace Darkmatter.Core.Data.Signals.Features.ShapeBuilder
{
public record struct ShapeAssembledSignal(string TemplateId);
}

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 52d6fdba64cc3491880636e34ed593d0

View File

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

View File

@@ -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,
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c12a68c64c45647dab002508c06ec8cd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 32ae4b246e57f4379baa7b98cffeb8c2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,35 @@
using Darkmatter.Core.Contracts.Features.History;
using Darkmatter.Features.ShapeBuilder.UI;
using UnityEngine;
namespace Darkmatter.Features.ShapeBuilder.Commands
{
/// <summary>
/// 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.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7565e0dc60eca451c9877bd23dddb901

View File

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

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2ca8c3a66565544118d3d52d3922933b
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f8f128ba2fede4ab2971c98f4f83819a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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.
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 679f7a317a1b7445aaa719055bca4a6d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cdbdbbcc180884694930e5ce1dbbb7d4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
{
/// <summary>
/// Single-MB shape piece. Handles drag, reactive preview lerp, snap,
/// and return-to-tray. Snap is wrapped in a <see cref="SnapPieceCommand"/>
/// pushed onto the shared <see cref="IUndoStack"/> so it participates in
/// the same undo/redo flow as coloring.
/// </summary>
[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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 189fa0409d676438abe96e8707e29ad0

View File

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5d79b18d536324085b58d842648372a8

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 15b7f9e434b1446aabb77bffc9f22780
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: ec78f9cae5b834662a0005dcbadd12c6
VideoClipImporter:
externalObjects: {}
serializedVersion: 3
frameRange: 0
startFrame: -1
endFrame: -1
colorSpace: 0
deinterlace: 0
encodeAlpha: 0
flipVertical: 0
flipHorizontal: 0
importAudio: 1
targetSettings: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -12,4 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f8ac5d9908c8049e1a3c8b8ac108b5f5, type: 3}
m_Name: PlayerPrefsKeyRegistry
m_EditorClassIdentifier: Libs.PlayerPrefs::Darkmatter.Libs.PlayerPrefs.PlayerPrefsKeyRegistry
_entries: []
_entries:
- Key: Progression
Type: 0
Description: Stores User's Progression Data

View File

@@ -941,6 +941,74 @@ MonoBehaviour:
autoInjectGameObjects: []
serviceModules:
- {fileID: 1239449676}
--- !u!1 &2122267603
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 2122267605}
- component: {fileID: 2122267604}
m_Layer: 0
m_Name: IntroVideoPlayer
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!328 &2122267604
VideoPlayer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2122267603}
m_Enabled: 1
m_VideoClip: {fileID: 32900000, guid: ec78f9cae5b834662a0005dcbadd12c6, type: 3}
m_TargetCameraAlpha: 1
m_TargetCamera3DLayout: 0
m_TargetCamera: {fileID: 0}
m_TargetTexture: {fileID: 0}
m_TimeReference: 0
m_TargetMaterialRenderer: {fileID: 0}
m_TargetMaterialProperty: <noninit>
m_RenderMode: 2
m_AspectRatio: 2
m_DataSource: 0
m_TimeUpdateMode: 2
m_PlaybackSpeed: 1
m_AudioOutputMode: 2
m_TargetAudioSources:
- {fileID: 0}
m_DirectAudioVolumes:
- 1
m_Url:
m_EnabledAudioTracks: 01
m_DirectAudioMutes: 00
m_ControlledAudioTrackCount: 1
m_PlayOnAwake: 0
m_SkipOnDrop: 1
m_Looping: 0
m_WaitForFirstFrame: 0
m_FrameReadyEventEnabled: 0
m_VideoShaders: []
--- !u!4 &2122267605
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2122267603}
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: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
@@ -950,3 +1018,4 @@ SceneRoots:
- {fileID: 82022335}
- {fileID: 1798580248}
- {fileID: 329578012}
- {fileID: 2122267605}

View File

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

Binary file not shown.

755
Readme.md
View File

@@ -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<AppBoot>()`), 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. 80120), 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<ShapePiece>();
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<T>`, `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<ShapePieceUI> 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<SlotMarker>()` 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<ShapeSO> 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; // 80120 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<ShapePiece> _alive = new();
private GameObject _piecePrefab;
public async UniTask StartAsync(CancellationToken ct) {
_piecePrefab = await _assets.LoadAssetAsync<GameObject>(
"shapebuilder/piece", null, ct);
_pieceSnappedSub = _bus.Subscribe<PieceSnappedSignal>(OnPieceSnapped);
}
private void OnValidate() => ApplyShape(); // editor inspector edits
private void Awake() => ApplyShape(); // runtime safety
public IReadOnlyList<ShapePiece> Alive => _alive;
public IReadOnlyCollection<string> 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<string> 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<SlotMarker>(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<string, Color> savedColors = null);
void PaintRegion(ColorRegionView view); // builds command, pushes to undo stack
// Snapshot current paint state for save records (see §13).
IReadOnlyDictionary<string, Color> 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<T>` | 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`.