Savya Bikram Shah 3e5ff544bf fixes
2026-05-29 20:31:37 +05:45
2026-05-27 15:05:26 +05:45
2026-05-29 20:31:37 +05:45
2026-05-29 20:31:37 +05:45
2026-05-29 20:31:37 +05:45
2026-05-28 13:20:36 +05:45
2026-05-26 16:47:16 +05:45
2026-05-28 19:45:15 +05:45
2026-05-28 19:45:15 +05:45

Color Book — Architecture Guide

A toddler-targeted (ages 26) coloring book game built on the same Strict Modular Monolith pattern as the Bus Game. Powered by VContainer for DI, UniTask for async, Addressables for shipped content, and a Canvas-only UI render strategy.

This document is the canonical reference for the Color Book game's structure. The Bus Game's Darkmatter Architecture Guide is the parent contract; this doc only adds game-specific structure.


1. Game Flow

Four scenes. Each gets its own scope. Root services persist across all of them.

┌─────────────────┐
│  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 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.


2. Philosophy

Identical to Bus Game:

  • Vertical slices — code grouped by Feature, not by type.
  • Strict layering — dependencies flow downward only.
  • Composition over inheritance — wired by DI.
  • Code vs ContentCode/ for logic, Contents/ for assets.

Game-specific additions:

  • Toddler-first UX — large hitboxes, forgiving snap radii, no fail states, no timers.
  • Stateless replay — every action (paint, snap) is an ICommand so undo/redo and auto-save are trivial.
  • Capture-as-truth — the gallery is a folder of PNGs on disk, not a serialized scene graph. What the child sees is what gets saved.

3. Dependency Graph

App   ──────────┐
                ▼
Features ──► Core ◄── Services
                ▲
                └── Libs

Forbidden actions

  • Services cannot reference Features.
  • Features cannot reference Service implementations — only Core contracts via DI.
  • Features cannot reference other Features — use IEventBus signals or Core contracts.
  • Core may not contain logic. Interfaces, enums, DTOs, signal records only.
  • Core may reference UniTask for async contract signatures. Nothing else.

4. Folder Structure

This section reflects the actual project on disk today. Empty folders that have been reserved for upcoming work are marked (planned); everything else has at least one file in it. Aspirational additions for the rest of the game are listed in §4c at the bottom.

4a. Actual layout on disk

Assets/Darkmatter/
├── Scenes/
│   └── Boot.unity                                          ← only scene wired so far
│
├── Content/                                                ← singular ("Content", not "Contents")
│   └── Gameplay/
│       └── PaperRig/                                       ← stale folder — paper rig dropped; safe to delete
│
├── Data/
│   ├── Inputs/                                             (Input System .inputactions)
│   └── Settings/
│       ├── Persistance/Resources/                          (ProtectedPlayerPrefs settings)
│       ├── Rendering/                                      (URP renderer + asset)
│       └── Scenes/URP2DSceneTemplate.unity
│
└── Code/
    ├── App/
    │   └── LifetimeScopes/
    │       └── RootLifetimeScope.cs                        ← scope loads serialized IServiceModule list
    │   Darkmatter.App.asmdef
    │
    ├── Core/                                               (asmdef name: `Core`, namespace root `Darkmatter.Core.*`)
    │   ├── Compatibility/
    │   │   └── IsExternalInit.cs                           (C#9 init shim for older runtimes)
    │   ├── Contracts/
    │   │   ├── Paper/                                      ← stale empty folder — delete (Paper is no longer a feature)
    │   │   └── Services/
    │   │       ├── Assets/IAssetProviderService.cs
    │   │       ├── Audio/IAudioService.cs, ISfxPlayer.cs
    │   │       ├── Camera/ICameraService.cs
    │   │       ├── Capture/                                ← (planned — ICaptureService)
    │   │       ├── Inputs/IInputReader.cs
    │   │       └── Scenes/ISceneService.cs
    │   ├── Data/
    │   │   ├── Dynamic/Services/Audio/                     (AudioHandle, AudioRequest)
    │   │   └── Static/Services/Audio/                      (SfxCatalogSO)
    │   └── Enums/
    │       └── Services/
    │           ├── Audio/                                  (AudioChannel, AudioPlayMode, SfxId)
    │           ├── Camera/CameraType.cs                    (MainCamera, UICamera — ArtCamera not added yet)
    │           └── Scenes/GameScene.cs
    │
    ├── Features/                                           ← (planned — empty folder today)
    │
    ├── Libs/
    │   ├── FSM/                                            (IState, State, StateMachine + Docs)
    │   │   Libs.FSM.asmdef
    │   ├── Installers/                                     (IServiceModule — Register(IContainerBuilder))
    │   │   Libs.Installers.asmdef
    │   ├── Observer/                                       (IEventBus, EventBus — note: not named "EventBus")
    │   │   Libs.Observer.asmdef
    │   ├── PlayerPrefs/                                    (ProtectedPlayerPrefs — used in place of a Persistence service)
    │   │   ├── Editor/   Libs.PlayerPrefs.Editor.asmdef
    │   │   └── Runtime/  Libs.PlayerPrefs.asmdef
    │   └── UI/                                             (ToggleButton, ToggleButtonGroup)
    │       Libs.UI.asmdef
    │
    └── Services/
        ├── Analytics/
        │   ├── Installers/AnalyticsServiceModule.cs
        │   └── Systems/FirebaseAnalyticsSystem.cs
        │   Services.Analytics.asmdef
        ├── Assets/
        │   ├── AddressableAssetProviderService.cs
        │   └── AddressableLoadHandleTracker.cs
        │   Services.Assets.asmdef
        ├── Audio/
        │   ├── AudioService.cs
        │   └── SfxPlayer.cs
        │   Services.Audio.asmdef
        ├── Camera/
        │   ├── Installers/CameraServiceModule.cs
        │   └── Service/CameraService.cs
        │   Services.Camera.asmdef
        ├── Inputs/
        │   ├── Generated/GameInputs.cs                     (Input System codegen)
        │   ├── Installers/InputServiceModule.cs
        │   └── Readers/InputReaderSO.cs
        │   Services.Inputs.asmdef
        └── Scenes/
            └── SceneService.cs
            Services.Scenes.asmdef

4b. Conventions visible in current code

  • Asmdef per Service / Lib / App / Core. No Feature asmdefs yet (folder is empty).
  • Core sub-tree shape: Core/{Compatibility, Contracts, Data, Enums} — deeply nested by Service rather than by topic. Game-specific Core types (Drawing, Coloring, Paper, Gallery, History, Progression, Signals) will be added under either Core/Contracts/<Area>/ or new top-level Core/<Area>/ — pick one convention before adding the first one.
  • Service sub-shape varies — some are flat (Services/Audio/*.cs), some split (Services/Camera/Installers/, Services/Camera/Service/). The "official" per-feature shape (§4d) hasn't been validated against real code yet.
  • Installer pattern: MonoBehaviour, IServiceModule with Register(IContainerBuilder builder). Not IInstaller.Install(...) as older sections of this doc imply. New installers must follow IServiceModule.
  • No AppBoot entry point exists yet. RootLifetimeScope only iterates a serialized MonoBehaviour[] serviceModules and calls Register on each IServiceModule. Boot sequence in §29 is aspirational.
  • No Persistence service. Libs/PlayerPrefs (the ProtectedPlayerPrefs library) is in place for small-key state. JSON persistence for gallery sidecars will need a separate service when Gallery lands.
  • Camera service is a registry, not a fitter. ICameraService.Register/Get only — no aspect-fit logic.

4c. Planned additions (not on disk yet)

All new game code follows the same nesting pattern as existing Services — Contracts/Features/<Name>/, Data/{Dynamic,Static}/Features/<Name>/, Features/<Name>/ — and asmdefs drop the Darkmatter. prefix to match Core, Services.Audio, Libs.Observer.

Rough landing order for ColorBook scene to be playable:

Path Role
Core/Contracts/Services/Capture/ICaptureService.cs Capture service contract — returns PNG bytes
Core/Contracts/Services/Gallery/IGalleryService.cs Native-gallery save shim
Core/Contracts/Features/Drawing/IDrawingTemplate.cs, IDrawingTemplateCatalog.cs Drawing template contracts
Core/Contracts/Features/Coloring/IColorPalette.cs Palette contract
Core/Contracts/Features/Progression/IProgressionService.cs Progression contract
Core/Data/Static/Features/Drawing/DrawingTemplateSO.cs Authored drawing data
Core/Data/Static/Features/Drawing/ShapeSO.cs Authored shape (sprite + snap params, reusable)
Core/Data/Static/Features/Coloring/ColorPaletteSO.cs Authored palette data
Core/Data/Dynamic/Features/Drawing/ColorRegionDTO.cs Runtime region struct
Core/Data/Dynamic/Features/Coloring/PaintCommandDTO.cs Runtime coloring struct
Core/Data/Dynamic/Features/Signals/*.cs (DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, PaperSavedSignal) Cross-feature signal structs
Core/Enums/Services/Camera/CameraType.cs Add CaptureCamera enum value to existing file
Services/Capture/ (+ Services.Capture.asmdef) RenderTextureCaptureService drives the disabled CaptureCamera
Services/Gallery/ (+ Services.Gallery.asmdef) NativeGallerySaveService — wraps the native gallery plugin
Features/MainMenu/ (+ Features.MainMenu.asmdef) Spine mascot + Play button
Features/{DrawingCatalog,ShapeBuilder,Coloring,Capture,Progression,ColorBookFlow}/ (+ asmdefs each) Game features
App/LifetimeScopes/{MainMenu,ColorBook}LifetimeScope.cs Per-scene scopes
App/Boot/AppBoot.cs Bootstrap entry point
Assets/Darkmatter/Scenes/{MainMenu,ColorBook}.unity Scenes (no ArtBook — captures go to phone Photos)
Content/Gameplay/Drawings/<theme>/<id>/{Template.asset, Drawing.prefab, Regions/, PaperBackground.png} Authored drawings — Drawing.prefab holds SlotMarkers at slot poses with ShapeSO refs
Content/Gameplay/Shapes/*.asset Reusable ShapeSOs (one per shape; shared across drawings)
Content/Gameplay/Prefabs/ShapePiece.prefab The single piece prefab (ShapePieceUI MB on root)
Content/Gameplay/Palettes/*.asset Color palettes
Content/Audio/{UI,Coloring}/ SFX banks

4d. Per-feature folder layout (matches existing Services pattern)

Look at Services/Camera/ (Installers/ + Service/) and Services/Analytics/ (Installers/ + Systems/) — that's the convention. Features adopt the same shape, adding UI/ or Views/ only when there's something to put in them.

Features/[Name]/
├── Installers/                IServiceModule — VContainer registration
├── Systems/   (or Service/)   Controllers, repositories, factories (pure C#)
├── UI/                        (only if the feature has Canvas UI)
│   ├── *Presenter.cs          Pure C#, listens to model, drives view
│   └── *View.cs               MonoBehaviour, setters only
├── Views/                     (only if the feature has world-space MonoBehaviours)
└── Features.<Name>.asmdef

Rules of thumb pulled from current Services:

  • Use Service/ (singular) when the feature has exactly one main implementation class (like Services/Camera/Service/CameraService.cs).
  • Use Systems/ (plural) when there are multiple pure-C# coordinators (like Services/Analytics/Systems/).
  • Skip nesting entirely when the feature has only 12 files at root (like Services/Audio/AudioService.cs + SfxPlayer.cs flat).
  • Docs/ is per-folder in current code — drop a Docs/ inside any sub-folder that needs notes, don't make a global feature-level Docs.

5. Namespaces & Asmdef naming

C# namespace pattern is Darkmatter.<Layer>.<Module>[.<SubArea>] — the Darkmatter. prefix stays on namespaces. Examples already in code:

Asmdef names drop the Darkmatter. prefix. Existing pattern:

Namespace Asmdef
Darkmatter.Core.* Core
Darkmatter.App Darkmatter.App (one exception — keep as-is, don't churn)
Darkmatter.Libs.Observer Libs.Observer
Darkmatter.Libs.FSM Libs.FSM
Darkmatter.Libs.Installers Libs.Installers
Darkmatter.Libs.PlayerPrefs Libs.PlayerPrefs (+ Libs.PlayerPrefs.Editor)
Darkmatter.Libs.UI Libs.UI
Darkmatter.Services.Audio Services.Audio
Darkmatter.Services.Assets Services.Assets
Darkmatter.Services.Camera Services.Camera
Darkmatter.Services.Inputs Services.Inputs
Darkmatter.Services.Scenes Services.Scenes
Darkmatter.Services.Analytics Services.Analytics

New asmdefs follow the same convention: Services.Capture, Services.Gallery, Libs.CommandStack, Features.Paper, Features.Coloring, etc.


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 + 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 and an empty Gameplay.unity exist today; the four scene scope classes need full implementation (only RootLifetimeScope is functional).

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. When AppBoot is added (as an IAsyncStartable registered via RootLifetimeScope), it should run once, in order:

  1. IAssetProviderService.InitializeAsync() — Addressables init.
  2. IProgressionSystem.LoadAsync() — hydrate per-template state from PlayerPrefs.
  3. IDrawingTemplateCatalog.InitializeAsync() — batch-load all DrawingTemplateSOs by Addressables label.
  4. Optional: preload UI sounds, palette assets.
  5. ISceneService.LoadAsync(MainMenu).

Failures show a child-friendly retry screen; never crash.


7. Rendering Strategy

Full Canvas UI. No SpriteRenderer, no Physics2D, no offscreen RenderTexture for the live view. The paper, slots, pieces, and color regions are all Image components on a Screen-Space-Camera canvas. Standard Unity UI eventing (IPointerDownHandler, IDragHandler) handles all input.

┌──────────────────────────────────────────────────────────┐
│ PaperCanvas (Screen Space - Camera, UICamera)           │
│   layer: PaperUI                                         │
│                                                          │
│   ┌──────────────────────────────────────────────────┐   │
│   │ PaperPanel (RectTransform, 2048×2048 ref units)  │   │
│   │   ├─ BackgroundImage                             │   │
│   │   ├─ SlotsPanel       (slot Image outlines)      │   │
│   │   ├─ PiecesPanel      (draggable piece Images)   │   │
│   │   └─ RegionsPanel     (colorable region Images)  │   │
│   └──────────────────────────────────────────────────┘   │
│                                                          │
└──────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│ HUDCanvas (Screen Space - Overlay, OR separate camera)  │
│   layer: HUDUI                                           │
│   ├─ Palette panel                                       │
│   ├─ Undo / Redo buttons                                 │
│   ├─ Capture / Next buttons                              │
│   └─ Tray panel (during build phase)                     │
└──────────────────────────────────────────────────────────┘

CaptureCamera (disabled by default, one-shot Render() on capture)
   orthographic, projection cloned from UICamera
   cullingMask = PaperUI only
   targetTexture = temp RT allocated per capture (2048×2048)

Cameras

Camera Render mode Culling Mask Render Target Purpose
UICamera Screen-Space - Camera (orthographic) PaperUI, HUDUI Screen Normal display each frame.
CaptureCamera Orthographic, disabled PaperUI only temp RenderTexture One-shot Render() invoked by ICaptureService.CaptureAsync().

CaptureCamera shares UICamera's position, orthographic size, and clip planes so the captured frame matches what the player sees — minus the HUD because of the culling mask.

Layers

Layer Used by
PaperUI PaperCanvas and all of its children (background, slots, pieces, regions, completion FX). Visible in capture.
HUDUI HUDCanvas and tray panel (palette, undo/redo, capture button, drawing catalog grid, etc.). Excluded from capture.
EventSystem Unity's input layer — managed automatically.

Why full UI

Need Choice Why
Tap-to-paint region Image + Image.alphaHitTestMinimumThreshold + IPointerClickHandler Tight alpha-based hit shape per region. No mesh / collider authoring. Tap events route through EventSystem natively.
Drag/drop shape pieces Image + IBeginDragHandler / IDragHandler / IEndDragHandler Standard Unity UI drag. Pointer events come in canvas-local coords already. No screen→world math anywhere.
Visual transition during drag → snap DOAnchorPos, DOSizeDelta, DOLocalRotate All pose is in RectTransform units. The "transition" is a tween over canvas-local values — no swap of render context.
Capture to PNG Dedicated CaptureCamera with cullingMask = PaperUI One Render() call into a temp RT. HUD physically can't appear.
Multi-resolution support CanvasScaler on PaperCanvas (Scale With Screen Size) Reference resolution 2048 × 2048, Match = 1 (height). All anchoredPosition units are constant across devices.
HUD layout independent of paper HUDCanvas (separate Canvas) HUD scales/anchors per its own rules without affecting the paper layout.
Drawing catalog grid, palette, etc. Standard UI (GridLayoutGroup, ScrollRect, Button) Anchors handle aspect ratios. Async thumbnail loader.

Multi-resolution rule

The paper content is canvas-unit-stable. Author every drawing against a fixed 2048 × 2048 reference resolution. Slot positions, piece sizes, region rects, hit shapes — all expressed in anchoredPosition / sizeDelta units. CanvasScaler on PaperCanvas does the screen mapping.

PaperPanel is anchored center, fixed 2048×2048 (or whatever you pick for the reference). On a wider screen, CanvasScaler pillarboxes the panel; on a narrower screen, it letterboxes. The panel's contents never resize relative to each other.

If you want a backdrop (wood/cloth behind the paper area), it's a sibling Image of PaperPanel (still on PaperUI layer) sized to fill the canvas. The backdrop is captured into the PNG by default — set its layer to HUDUI if you want it excluded.

Tradeoff vs the old RT-paper-rig design

Concern RT-paper-rig (old) Canvas-only (current)
Paper contracts 2 contracts + ~80 lines of math gone (paper is just RectTransforms in scene)
Input pipeline IInputReader → bridge → Physics2D.OverlapPoint native EventSystem (IPointerDownHandler etc.)
Coloring hit shape PolygonCollider2D from Sprite.Editor physics shape Image.alphaHitTestMinimumThreshold = 0.5f on the region sprite
Per-frame render passes 2 (ArtCamera into RT + UICamera draws RawImage) 1 (UICamera draws everything)
Capture read persistent RT one-shot CaptureCamera.Render()
Coordinate gotchas mismatches between screen / RT / world none — everything is canvas-local

If you ever need world-space effects (particle sparkles that physically explode outside the paper, free-draw brush stroke, pinch zoom on the artwork), revisit the RT approach. For the v1 tap-to-fill + drag-to-snap design, Canvas-only is correct.


8. Core Contracts

All Core types are pure data or interfaces.

Drawing

Contracts live in Darkmatter.Core.Contracts.Features.Drawing; DTOs in Darkmatter.Core.Data.Dynamic.Features.Drawing.

namespace Darkmatter.Core.Contracts.Features.Drawing;

public interface IDrawingTemplate {
    string Id { get; }
    string DisplayName { get; }
    Sprite DefaultThumbnail { get; }         // authored fallback (used when user has no captures for this template)
    Sprite PaperBackground { get; }
    IReadOnlyList<ShapeSO> Pieces { get; }   // shapes that get spawned in the tray for this drawing
    IReadOnlyList<ColorRegionDTO> Regions { get; }
}

public readonly struct ColorRegionDTO {
    public string RegionId { get; }
    public Sprite Sprite { get; }               // assigned to Image.sprite
    public Vector2 AnchoredPosition { get; }    // canvas units, relative to PaperRoot (region's authored parent)
    public Vector2 SizeDelta { get; }           // canvas units
    public Color InitialColor { get; }          // usually white
    // Hit shape comes from the sprite alpha — set Image.alphaHitTestMinimumThreshold = 0.5.
    // No polygon path needed; sprite import settings ("Read/Write Enabled") provide it.
}

Shape authoring (ShapeSO + one prefab)

Shapes are authored as ScriptableObject assets via the Project Create menu (Assets > Create > Darkmatter > Drawing > Shape). One asset per shape — reusable across many drawings.

namespace Darkmatter.Core.Data.Static.Features.Drawing;

[CreateAssetMenu(menuName = "Darkmatter/Drawing/Shape", fileName = "Shape_")]
public sealed class ShapeSO : ScriptableObject
{
    [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);
    [field: SerializeField] public float SnapRadius { get; private set; } = 100f;
    [field: SerializeField] public float PreviewRadius { get; private set; } = 200f;
}

How the runtime uses it:

  1. One piece prefab. A ShapePiecePrefab in Content/Gameplay/Prefabs/ carries Image + ShapePieceUI. The ShapePieceUI MonoBehaviour has a [SerializeField] ShapeSO _shape field — empty on the raw prefab, filled in by the controller at spawn time (or pre-assigned in inspector for testing scenes).
  2. SlotMarker lives in the drawing's per-drawing scene/prefab at the slot's authored pose. Its [SerializeField] ShapeSO _shape field tells which shape fits this slot. The slot's RectTransform (anchoredPosition, sizeDelta, localRotation) is the target snap pose.
  3. Matching is by ShapeSO reference equality. Piece P matches slot S iff P._shape == S._shape. No string-id lookups at runtime.
  4. Identity follows the asset. Whenever _shape changes (inspector edit or runtime assign), ShapePieceUI re-applies _shape.Sprite to its Image, sets RectTransform.sizeDelta = _shape.DefaultSizeDelta, and exposes PieceId => _shape.Id. Done via OnValidate (editor) + Awake (runtime) + an explicit Assign(ShapeSO) method (controller-driven).

Optional future editor tool: a wizard window for bulk-creating ShapeSOs from a folder of sprites — sets Id from filename, assigns sprite, applies sensible default radii. For v1, the plain Create-Asset-Menu is enough.

Coloring

Contracts in Darkmatter.Core.Contracts.Features.Coloring; DTOs in Darkmatter.Core.Data.Dynamic.Features.Coloring.

namespace Darkmatter.Core.Contracts.Features.Coloring;

public interface IColorPalette {
    string Id { get; }
    IReadOnlyList<Color> Colors { get; }
}

public readonly struct PaintCommandDTO {
    public string RegionId { get; }
    public Color FromColor { get; }
    public Color ToColor { get; }
}

Scene composition (no Paper feature)

The paper area is just a PaperPanel GameObject in the Gameplay.unity scene. There is no IPaperSurface contract and no Paper feature. The single cross-feature anchor — PaperRoot — is exposed via a small GameplaySceneRefs : MonoBehaviour, IGameplaySceneRefs:

public sealed class GameplaySceneRefs : MonoBehaviour, IGameplaySceneRefs {
    [SerializeField] private RectTransform paperRoot;
    public RectTransform PaperRoot => paperRoot;
}

Scene refs hold only what must be shared across features. Everything else — the shape tray, the color palette, the drawing-instance prefab (which carries its own SlotMarkers + ColorRegionViews as children) — is owned by its feature's holder view. This keeps GameplaySceneRefs from becoming a god object as features are added.

The drawing prefab is instantiated under PaperRoot; slot markers and color regions are pre-authored as children of that prefab, so neither SlotsParent nor RegionsParent needs its own ref. The CaptureCamera will live on a separate scene MB (planned, owned by the Capture feature).

Registered once via GameplayLifetimeScope:

builder.RegisterComponent<IGameplaySceneRefs>(_sceneRefs);

Features inject IGameplaySceneRefs (or their own holder views) directly.

History

Contracts in Darkmatter.Core.Contracts.Features.History.

namespace Darkmatter.Core.Contracts.Features.History;

public interface ICommand {
    void Execute();
    void Undo();
}

public interface IUndoStack {
    bool CanUndo { get; }
    bool CanRedo { get; }
    void Push(ICommand cmd);   // executes + appends
    void Undo();
    void Redo();
    void Clear();
}

Two separate, independent services. Capture produces PNG bytes. Gallery writes those bytes into the device's native photo library. Neither service knows about the other; the orchestration lives in the Capture feature.

namespace Darkmatter.Core.Contracts.Services.Capture;

public interface ICaptureService {
    // Renders the disabled CaptureCamera into a temp RT, ReadPixels into a Texture2D,
    // encodes PNG, releases the RT. Returns the encoded bytes.
    UniTask<byte[]> CaptureAsync();
}

namespace Darkmatter.Core.Contracts.Services.Gallery;

public interface IGalleryService {
    // Saves the given PNG bytes into the device's native photo library
    // under the given album. Native plugin handles platform permissions.
    UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book");
}
  • ICaptureService owns the CaptureCamera reference (a disabled Camera in the ColorBook scene). The camera's cullingMask is set to PaperUI so the HUD physically cannot appear in the PNG. Paper background is just an Image on PaperUI — no compositing pass.
  • IGalleryService is a thin shim over a native gallery plugin (NativeGallery, NativeShare, or a custom plugin). It does not save thumbnails, does not maintain a file list, does not delete entries. Everything in the device library is owned by the OS.
  • There is no SavedArtworkDTO, no sidecar JSON, no in-app gallery file system. The user views their drawings in the phone's Photos app.

Signals

Signal structs live in Darkmatter.Core.Data.Dynamic.Features.Signals (runtime data, cross-feature).

namespace Darkmatter.Core.Data.Dynamic.Features.Signals;

public readonly struct DrawingSelectedSignal {
    public string TemplateId { get; }
}

public readonly struct ShapeAssembledSignal {
    public string TemplateId { get; }
}

public readonly struct ColorAppliedSignal {
    public string RegionId { get; }
    public Color Color { get; }
}

public readonly struct PaperCapturedSignal {
    public string TemplateId { get; }      // captured, before native-gallery save
}

public readonly struct PaperSavedSignal {
    public string TemplateId { get; }      // PNG written to phone library
}

9. Feature Responsibilities

MainMenu

  • Lives in MainMenu.unity. Single primary action: Play (→ ColorBook scene). There is no in-app gallery viewer — captured drawings live in the phone's Photos app.
  • Hosts a Spine character mascot (via SkeletonGraphic for Canvas). Authored animations include idle loop, wave, react-to-button, victory dance.
  • MenuMascotPresenter (pure C#) drives the mascot from code: subscribes to Play-button hover / click events and the model's idle timer, calls IMenuMascotView.Play(animName, loop).
  • View is setter-only. Spine-Unity's SkeletonGraphic.AnimationState.SetAnimation(track, name, loop) is encapsulated behind IMenuMascotView.
  • Mascot's skeleton + atlas ship via Addressables (see §10).

DrawingCatalog

  • Loads the catalog manifest (list of available template IDs).
  • Presents a scrollable grid of thumbnails (Canvas), one per template.
  • Each cell shows IDrawingTemplate.DefaultThumbnail (the authored fallback sprite). The user's captured drawings live in the phone's Photos app, not in the catalog grid.
  • On select → fires DrawingSelectedSignal(templateId) and unloads the catalog UI.

ShapeBuilder

  • 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 SlotMarkers at their authored poses.
  • ShapeHolderView owns the tray RectTransform + slide-in/out animation (PrimeTween — Tween.UIAnchoredPosition + Tween.Alpha on CanvasGroup). Default hide direction is right (off-screen +X), tunable in inspector.
  • ShapeHolderPresenter subscribes to ShapeBuilderStartedSignalview.Show() and ShapeAssembledSignalview.Hide(). No controller poking the view directly.
  • IShapePieceFactory (ShapePieceFactory impl) owns the per-piece dependencies (ShapeBuilderConfig, ISfxPlayer, IEventBus, IUndoStack) and the spawn parent (read from ShapeHolderView.SpawnRoot). Factory creates one ShapePiece MB and runs its Setup. Controller calls factory.Create(prefab, shape, slot, trayPos, preSnapped) per piece.
  • ShapeBuilderController only orchestrates: loads piece prefab via IAssetProviderService, computes tray positions from holder.SpawnWidth, publishes ShapeBuilderStartedSignal before spawn, and listens for PieceSnappedSignal to count down to ShapeAssembledSignal. If progress.Phase == ShapeBuilding, pieces in progress.SnappedPieces are passed to the factory with preSnapped: true.
  • 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.
  • Publishes PieceSnappedSignal(pieceId) on lock. Controller counts against expected; fires ShapeAssembledSignal(templateId) when all locked.

Coloring

  • Listens to ShapeAssembledSignal.
  • Regions are pre-authored as children of the drawing prefab (under PaperRoot). Each is a ColorRegionView (UI Image + IPointerClickHandler). On Awake, the view sets Image.alphaHitTestMinimumThreshold from a serialized field (default 0.01f; tune up to 0.5f for tighter hits) so taps on transparent pixels pass through to the next region below.
  • ColorPaletteHolderView owns the palette container RectTransform + slide-in/out animation (same pattern as ShapeHolderViewTween.UIAnchoredPosition + Tween.Alpha on CanvasGroup, hides off-screen right).
  • ColorPaletteHolderPresenter subscribes to ShapeAssembledSignalview.Show(). Hide is invoked from scene tear-down or future phase-exit hook.
  • ColorButton + IColorButtonFactory (planned): each color is its own self-contained MB (swatch Image + IPointerClickHandler). ColorButtonFactory instantiates the button prefab under ColorPaletteHolderView.SpawnRoot and calls button.Setup(color, state, sfx). On click, the button writes directly to ColoringStateRepository.SelectColor(myColor) — no view/presenter intermediary. ColoringController iterates IColorPalette.Colors once on init and calls factory.Create(color) per entry. Mirrors ShapePieceFactory for symmetry.
  • Each region's OnPointerClickColoringController.PaintRegion(view).
  • 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 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

  • 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 bytesGameplayFlowController calls it, then routes to gallery + thumbnail cache depending on the trigger. See §13.

Progression

  • 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 (in Colorbook.unity)

  • 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 BuildingColoring runtime states.

10. Addressables Strategy

Mirror the Bus Game pattern via IAssetProviderService.

What ships through Addressables

Asset Why
DrawingTemplate ScriptableObject (per drawing) Many; load on demand.
ShapeSO assets Reused across drawings; load once per drawing batch.
Region sprites Heavy; loaded per drawing.
Paper backgrounds Per template, sometimes shared.
Color palette SOs Swap per theme.
Audio clips (tap, snap, complete, sparkle) Shared SFX bank.
Spine mascot (SkeletonDataAsset + atlas) Heavy textures; load with MainMenu scene, release on scene exit.

What does NOT use Addressables

  • HUD prefabs (palette button, undo icon) — always loaded with scene.
  • Core UI canvases.
  • Boot scene assets.
  • User-saved gallery PNGs — those live in Application.persistentDataPath.

Group layout

Drawings_Animals       (label: drawing, animals)
Drawings_Vehicles      (label: drawing, vehicles)
Drawings_Shapes        (label: drawing, shapes)
Shapes_Library         (label: shape)         — reusable ShapeSO assets
Palettes               (label: palette)
Audio_UI               (label: sfx, ui)
Audio_Coloring         (label: sfx, coloring)
Spine_MainMenu         (label: spine, menu)   — mascot skeleton + atlas

Lifecycle

  • Catalog loads thumbnail handles only (cheap).
  • On select → full template loads (pieces + regions + paper).
  • On "Next" or scene exit → previous template Released before next loads.
  • This bound matters on toddler tablets with limited RAM.

Remote groups (future)

Drawing packs ship as remote bundles. New theme packs (Christmas, Dinosaurs) update without an app store release.


11. Persistence

Only one in-app persistent store — small settings + progression. Captured drawings go to the device's native photo library (managed by the OS, not by the app).

Settings + progression via Libs.PlayerPrefs

ProtectedPlayerPrefs (in Libs/PlayerPrefs/) is a lightweight encrypted-string wrapper around Unity's PlayerPrefs. Used for:

  • Completed template IDs (JSON-encoded list).
  • Last opened drawing.
  • Audio volume, simple toggles.

A higher-level IProgressionService reads/writes these keys; consumers never touch PlayerPrefs directly.

Captured PNGs go to the phone's Photos app via IGalleryService.SaveToDeviceAsync(bytes, albumName). The app does not:

  • Write .png files to persistentDataPath.
  • Generate or store thumbnails locally.
  • Maintain any sidecar JSON / index.
  • Provide list / load / delete operations.

The user opens the phone's Photos app to view, share, or delete their drawings. iOS / Android handle permissions and album organization.


12. Capture Pipeline

A dedicated CaptureCamera lives in the ColorBook scene, disabled by default. It renders only the PaperUI layer into a temp RenderTexture when capture fires. The PNG bytes are then handed to the native gallery plugin — no local file IO.

[Save button or Next button]
        │
        ▼
CaptureController.SaveAsync(templateId)
        │
        ▼
ICaptureService.CaptureAsync()
        │
        ├─ rt = RenderTexture.GetTemporary(2048, 2048, 0, ARGB32)
        ├─ _captureCam.targetTexture = rt
        ├─ _captureCam.Render()                  (one-shot; cullingMask = PaperUI only)
        ├─ _captureCam.targetTexture = null
        ├─ prev = RenderTexture.active
        ├─ RenderTexture.active = rt
        ├─ tex = new Texture2D(2048, 2048, RGBA32, false)
        ├─ tex.ReadPixels(full rect, 0, 0); tex.Apply()
        ├─ RenderTexture.active = prev
        ├─ RenderTexture.ReleaseTemporary(rt)
        ├─ bytes = tex.EncodeToPNG()             (on worker via UniTask.RunOnThreadPool)
        ├─ Object.Destroy(tex)
        └─ return bytes
        ▼
EventBus.Publish(new PaperCapturedSignal(templateId))
        │
        ▼
IGalleryService.SaveToDeviceAsync(bytes, "Color Book")
        │
        ├─ native plugin handles platform permissions
        ├─ writes PNG into the device's Photos album
        └─ (no return — fire and forget; throws on failure)
        ▼
EventBus.Publish(new PaperSavedSignal(templateId))

Notes:

  • HUD never appears in capture because CaptureCamera.cullingMask excludes HUDUI. Layer mask, not coincidence — even if you accidentally parent a HUD element under PaperPanel, putting it on the wrong layer keeps it out.
  • Paper background is just an Image on PaperUI. Already in the right layer; no special compositing.
  • Saved PNGs are 2048×2048 on every device. CaptureCamera has fixed orthographicSize and aspect, independent of screen size.
  • CaptureAsync is safe to call repeatedly. The CaptureCamera's transform / projection are set once at scene start and never modified.
  • The temp RT is allocated via RenderTexture.GetTemporary so successive captures don't leak GPU memory.
  • IGalleryService and ICaptureService are independent — IGalleryService knows nothing about the camera; ICaptureService knows nothing about the native plugin. The chain is the CaptureController's sole responsibility.

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 AdvanceToNextDrawingCatalog.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:

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
Load template, return result Direct DI call (IDrawingTemplateCatalog.LoadAsync).
Capture → save chain Direct DI calls, sequenced.
Notify HUD that a region was painted IEventBus signal.
Notify Progression that a drawing was completed IEventBus signal.
Tell ColorBookFlow that pieces are assembled IEventBus signal.
Tell Coloring which color is currently selected Direct DI on ColoringStateRepository.

Never use signals for request/response. If you need a return value or guaranteed single handler, define a Core interface.


14. UI (MVP — Passive View)

Identical to Bus Game.

  • Model — controller / repository, fires C# events.
  • ViewMonoBehaviour, only setters (SetColors(IReadOnlyList<Color>)).
  • Presenter — pure C#, subscribes to model events, calls view setters.

Inspector bridge

For palette icons, undo buttons, region prefabs:

[SerializeField, RequireInterface(typeof(IColorButtonView))]
private MonoBehaviour[] _colorButtons;

15. Toddler UX Constraints

These shape several design decisions and are non-negotiable:

  • No fail states. Drawings cannot be "wrong".
  • No timers. Nothing decays or runs out.
  • No tiny hitboxes. Drag tolerance ≥ 40 canvas units; snap radius ≥ 80 canvas units for shape pieces. (Canvas reference resolution is 2048×2048 — these are anchored-position deltas, not screen px.)
  • Auto-snap on near-miss. If a piece is dropped within 1.5 × SnapRadius, snap anyway and play a happy sound.
  • No text-heavy UI. Icons everywhere. Single-word labels max.
  • Loud, immediate feedback. Every tap plays a sound; every fill bursts a small particle effect.
  • Undo cap = 20. Toddlers will mash undo. Bound the memory.
  • Long-press = quick menu off. Avoid surprise modals.

16. Testing

Layer Test type Location
Libs/CommandStack EditMode unit tests Libs/CommandStack/Tests/
Core DTOs EditMode rarely needed; mostly type-only checks.
Services/Gallery EditMode w/ temp directory mocks Application.persistentDataPath.
Services/Capture PlayMode requires a Camera in the test scene.
Features/*/Systems EditMode w/ DI test container inject fakes for IUndoStack, signals captured by a fake IEventBus.
Full flow PlayMode smoke test one drawing → assemble → color → capture → assert gallery has 1 file.

17. "Where do I put this?" Checklist

  1. Is it a cross-assembly interface / enum / DTO?Core/
  2. Is it a generic, sellable utility?Libs/
  3. Is it infrastructure (input, audio, file IO, addressables, capture)?Services/
  4. Is it gameplay logic specific to coloring books?Features/
  5. Is it composition / scene wiring?App/

When in doubt, ask: would deleting this feature break Core? If yes, the dependency is wrong.


18. Open Questions / Future Work

  • Pencil/brush mode — currently the design is tap-to-fill regions. A free-draw brush mode would need a BrushStrokeCommand and a dynamic texture per region; out of scope for v1.
  • Multi-child profiles — single-profile for v1; multi-profile would slot in behind IProgressionService and IGalleryService keyed by profileId.
  • Cloud sync — gallery sync would happen behind IGalleryService (decorator pattern); local-first stays the source of truth.
  • Sticker / decoration layer — additive sprite layer above coloring, also ICommand-driven so it integrates with undo/redo cleanly.

19. Quick Reference — Feature ↔ Signal Map

Feature Subscribes to Publishes
MainMenu — (Play tap loads a scene directly)
DrawingCatalog DrawingSelectedSignal
ShapeBuilder DrawingSelectedSignal ShapeAssembledSignal
Coloring ShapeAssembledSignal ColorAppliedSignal
History DrawingSelectedSignal (to clear)
Capture — (button-driven) PaperCapturedSignal, PaperSavedSignal
Progression PaperSavedSignal
ColorBookFlow ShapeAssembledSignal, PaperSavedSignal

Maintained alongside the Darkmatter Architecture Guide. Do not break the dependency arrows.


20. Assembly Definition Map

Every Lib / Service / Feature is its own .asmdef. The Darkmatter. prefix is only on the App asmdef; everything else uses bare <Layer>.<Module> names. References follow the layer rules.

On disk today

Asmdef Path References
Darkmatter.App App/ All Services, Libs, Core (Features when they land)
Core Core/ (none — UniTask allowed in async signatures)
Libs.FSM Libs/FSM/ Core
Libs.Installers Libs/Installers/ (VContainer only)
Libs.Observer Libs/Observer/ Core
Libs.PlayerPrefs Libs/PlayerPrefs/Runtime/ (standalone)
Libs.PlayerPrefs.Editor Libs/PlayerPrefs/Editor/ Libs.PlayerPrefs
Libs.UI Libs/UI/ Core
Services.Analytics Services/Analytics/ Core, Libs.Installers
Services.Assets Services/Assets/ Core, Libs.Installers
Services.Audio Services/Audio/ Core, Libs.Installers
Services.Camera Services/Camera/ Core, Libs.Installers
Services.Inputs Services/Inputs/ Core, Libs.Installers
Services.Scenes Services/Scenes/ Core, Libs.Installers

Planned (not on disk yet)

Asmdef Path References
Services.Capture Services/Capture/ Core, Libs.Installers
Services.Gallery Services/Gallery/ Core, Libs.Installers
Features.MainMenu Features/MainMenu/ Core, Libs.Installers
Features.DrawingCatalog Features/DrawingCatalog/ Core, Libs.Installers
Features.ShapeBuilder Features/ShapeBuilder/ Core, Libs.Installers
Features.Coloring Features/Coloring/ Core, Libs.Installers
Features.Capture Features/Capture/ Core, Libs.Installers
Features.Progression Features/Progression/ Core, Libs.Installers, Libs.PlayerPrefs
Features.ColorBookFlow Features/ColorBookFlow/ Core, Libs.Installers, Libs.FSM

Libs.CommandStack, Features.ArtBook, and Features.Paper were previously planned but cut. Undo lives inside Features.History (already on disk). Art-book is removed because captures save to phone Photos. Paper is just RectTransforms in the scene — no contract needed. ICommand / IUndoStack live in Core, so Features.Coloring reaches the undo stack without referencing Features.History.

Hard rules:

  • No Service asmdef references any Feature asmdef.
  • No Feature asmdef references another Feature asmdef.
  • All Services and Features depend on Libs.Installers so they can implement IServiceModule.
  • The compiler enforces this — if a using won't resolve, the dependency is wrong.

21. LifetimeScope Concrete Sample

All scopes use the same pattern: a serialized MonoBehaviour[] list of IServiceModule installers. Each installer is a MonoBehaviour on a child GameObject of the scope. Scope iterates and calls Register. No hardcoded registrations in the scope itself. This is exactly what RootLifetimeScope.cs already does today.

RootLifetimeScope (Boot scene, persists forever) — actual code

using Darkmatter.Libs.Installers;
using UnityEngine;
using VContainer;
using VContainer.Unity;

public class RootLifetimeScope : LifetimeScope {
    [SerializeField] private MonoBehaviour[] serviceModules;

    protected override void Configure(IContainerBuilder builder) {
        foreach (var module in serviceModules) {
            if (module is IServiceModule serviceModule)
                serviceModule.Register(builder);
        }
    }
}

The inspector lists the installer MonoBehaviours in serviceModules[]. Drag the children of the Boot scope GameObject (e.g. AudioServiceModule, CameraServiceModule, InputServiceModule, AssetProviderServiceModule, AnalyticsServiceModule, SceneServiceModule) into that slot. Each is a MonoBehaviour, IServiceModule.

ColorBookLifetimeScope (per-scene, child of Root) — same pattern

namespace Darkmatter.App.LifetimeScopes;

public sealed class ColorBookLifetimeScope : LifetimeScope {
    [SerializeField] private MonoBehaviour[] sceneModules;

    protected override void Configure(IContainerBuilder builder) {
        foreach (var module in sceneModules) {
            if (module is IServiceModule serviceModule)
                serviceModule.Register(builder);
        }
    }
}

Drag the scene's installer MonoBehaviours into sceneModules[]:

  • DrawingCatalogModule
  • ShapeBuilderModule
  • ColoringModule
  • HistoryModule
  • CaptureModule
  • ProgressionModule
  • ColorBookFlowModule

Each registers its own classes via IServiceModule.Register(IContainerBuilder).

If a scope needs a non-installer reference (e.g. a GameplaySceneRefs MB holding PaperRoot), expose it as a separate [SerializeField] and builder.RegisterComponent(...) it inside the scope's Configure. Don't put scene refs inside an installer — keep installers stateless across scenes.


22. Installer Pattern — Concrete Coloring Sample

Mirrors the existing CameraServiceModule.cs and InputServiceModule.cs — a MonoBehaviour implementing IServiceModule.Register.

namespace Darkmatter.Features.Coloring.Installers;

public sealed class ColoringModule : MonoBehaviour, IServiceModule {
    [SerializeField] private ColoringConfig _config;

    public void Register(IContainerBuilder builder) {
        builder.RegisterInstance(_config);
        builder.Register<ColoringStateRepository>(Lifetime.Scoped).AsSelf();
        builder.Register<ColoringController>(Lifetime.Scoped)
               .As<IColoringController>()
               .AsSelf();
        builder.Register<IColorButtonFactory, ColorButtonFactory>(Lifetime.Scoped);
        builder.RegisterEntryPoint<ColoringInputBinder>();
    }
}

Convention:

  • One IServiceModule per feature, named <Feature>Module (matches CameraServiceModule, InputServiceModule, AnalyticsServiceModule already in the project).
  • MonoBehaviour lives on a GameObject under the scope's hierarchy; dragged into the scope's serviceModules[] / sceneModules[] inspector list.
  • Method name is Register, not Install. There is no IInstaller in this project — uses IServiceModule from Libs.Installers.
  • Registers only its own types. Never touches another feature's types.
  • If the installer needs to wire scene-bound MonoBehaviours into DI, expose them as [SerializeField] fields on the installer itself and builder.RegisterComponent<IFoo>(_foo) them. GameplaySceneRefs (§32.13) is registered this way directly from the scope's serialized field. Per-feature holder views (e.g. ShapeHolderView, ColorPaletteHolderView) are registered the same way from each feature's IServiceModule.

23. Command Pattern — PaintRegionCommand

namespace Darkmatter.Features.Coloring.Commands;

internal sealed class PaintRegionCommand : ICommand {
    private readonly ColorRegionView _view;
    private readonly Color _fromColor;
    private readonly Color _toColor;
    private readonly IEventBus _bus;

    public PaintRegionCommand(ColorRegionView view, Color from, Color to, IEventBus bus) {
        _view = view;
        _fromColor = from;
        _toColor = to;
        _bus = bus;
    }

    public void Execute() {
        _view.SetColor(_toColor);
        _bus.Publish(new ColorAppliedSignal(_view.RegionId, _toColor));
    }

    public void Undo() {
        _view.SetColor(_fromColor);
        _bus.Publish(new ColorAppliedSignal(_view.RegionId, _fromColor));
    }
}

Usage in controller:

public void PaintRegion(ColorRegionView view) {
    var current = _state.CurrentColor;
    if (view.Color == current) return;            // no-op
    var cmd = new PaintRegionCommand(view, view.Color, current, _bus);
    _undoStack.Push(cmd);                          // Push executes + records
}

Same pattern applies to SnapPieceCommand if shape-builder steps should be undoable (optional for v1).


24. CommandStack — Libs/CommandStack

namespace Darkmatter.Lib.CommandStack;

public sealed class BoundedUndoStack : IUndoStack {
    private readonly Deque<ICommand> _undo = new();
    private readonly Stack<ICommand> _redo = new();
    private readonly int _capacity;

    public BoundedUndoStack(int capacity = 20) => _capacity = capacity;

    public bool CanUndo => _undo.Count > 0;
    public bool CanRedo => _redo.Count > 0;

    public void Push(ICommand cmd) {
        cmd.Execute();
        _undo.AddLast(cmd);
        if (_undo.Count > _capacity) _undo.RemoveFirst();
        _redo.Clear();
    }

    public void Undo() {
        if (!CanUndo) return;
        var cmd = _undo.Last;
        _undo.RemoveLast();
        cmd.Undo();
        _redo.Push(cmd);
    }

    public void Redo() {
        if (!CanRedo) return;
        var cmd = _redo.Pop();
        cmd.Execute();
        _undo.AddLast(cmd);
    }

    public void Clear() {
        _undo.Clear();
        _redo.Clear();
    }
}

Deque<T> keeps the oldest entry cheap to evict when the cap fires.


25. Self-contained Button + Factory — Color Palette

The palette deliberately skips the View/Presenter pair. Each color is its own self-contained ColorButton MB, spawned by a factory that mirrors ShapePieceFactory. The button writes directly to ColoringStateRepository on click — no intermediary indexed event, no presenter wiring _state.SelectedIndexChanged → _view.SetSelected.

Why: palette behavior is per-button (each one is a discrete tap target with its own selected/hover anim), and adding per-button variants later (locked colors, magic "rainbow" button, premium colors) is just a new MB type that the factory can branch to. A View+Presenter pair would force every variant through a single SetColors(IReadOnlyList<Color>) setter.

Button (self-contained MB)

namespace Darkmatter.Features.Coloring.UI;

public sealed class ColorButton : MonoBehaviour, IPointerClickHandler {
    [SerializeField] private Image swatch;
    [SerializeField] private GameObject selectedRing;   // optional highlight

    private Color _color;
    private ColoringStateRepository _state;
    private ISfxPlayer _sfx;

    public Color Color => _color;

    public void Setup(Color color, ColoringStateRepository state, ISfxPlayer sfx) {
        _color = color;
        _state = state;
        _sfx = sfx;
        swatch.color = color;
        _state.SelectedColorChanged += OnSelectedChanged;
        OnSelectedChanged(_state.CurrentColor);
    }

    public void OnPointerClick(PointerEventData _) {
        _sfx.Play(SfxId.UiTap);
        _state.SelectColor(_color);
    }

    private void OnSelectedChanged(Color current) {
        if (selectedRing != null) selectedRing.SetActive(current == _color);
    }

    private void OnDestroy() {
        if (_state != null) _state.SelectedColorChanged -= OnSelectedChanged;
    }
}

Factory (mirrors ShapePieceFactory)

namespace Darkmatter.Features.Coloring.Systems;

public interface IColorButtonFactory {
    ColorButton Create(Color color);
}

public sealed class ColorButtonFactory : IColorButtonFactory {
    private readonly ColorPaletteHolderView _holder;
    private readonly GameObject _buttonPrefab;
    private readonly ColoringStateRepository _state;
    private readonly ISfxPlayer _sfx;

    public ColorButtonFactory(
        ColorPaletteHolderView holder,
        GameObject buttonPrefab,
        ColoringStateRepository state,
        ISfxPlayer sfx) {
        _holder = holder;
        _buttonPrefab = buttonPrefab;
        _state = state;
        _sfx = sfx;
    }

    public ColorButton Create(Color color) {
        var go = Object.Instantiate(_buttonPrefab, _holder.SpawnRoot);
        var btn = go.GetComponent<ColorButton>();
        btn.Setup(color, _state, _sfx);
        return btn;
    }
}

Spawn loop (inside ColoringController.InitializeAsync)

foreach (var color in _palette.Colors)
    _buttonFactory.Create(color);

That's the whole feature wiring. No IColorPaletteView, no OnColorButtonClicked indexed event, no SelectedIndex ↔ SetSelected round-trip.

When to use View/Presenter instead

Other features (history buttons, drawing catalog) still use View/Presenter — that pair fits when a single canvas of fixed sub-widgets needs to react to one model. Use button+factory when each item is spawned dynamically from data and has independent click behavior.


26. ShapeBuilder — Snap Algorithm

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

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

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

OnEndDrag — snap or return

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 lockLock() 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.

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.

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

The controller no longer parents pieces directly nor knows about Setup dependencies. It delegates to IShapePieceFactory, which owns the parent (ShapeHolderView.SpawnRoot) and the per-piece deps (cfg/sfx/bus/undo). Tray width is read from the holder, not from scene refs.

_bus.Publish(new ShapeBuilderStartedSignal(template.Id));  // ShapeHolderPresenter → view.Show()

int count = template.Pieces.Count;
float trayW = _holder.SpawnWidth;
float pitch = trayW / (count + 1);

for (int i = 0; i < count; i++)
{
    var shape = template.Pieces[i];
    var slot  = FindSlotForShape(slots, shape);
    var trayPos = new Vector2(pitch * (i + 1) - trayW * 0.5f, 0f);
    var preSnapped = preSnappedIds != null && preSnappedIds.Contains(shape.Id);

    _factory.Create(_piecePrefab, shape, slot, trayPos, preSnapped);
}

Controller listens for PieceSnappedSignal, counts against expected piece count, fires ShapeAssembledSignal when complete → GameplayFlowController captures bare-assembled thumbnail, transitions to Coloring (see §13).


27. Rendering Order & Sorting

Canvas-only — order is sibling index inside PaperPanel (front-most is last in hierarchy). No URP 2D sorting layers.

PaperPanel children (bottom → top):

PaperPanel
├─ BackgroundImage          (paper texture)
├─ RegionsPanel             (colorable region Images)
├─ SlotsPanel               (slot outline Images — under pieces so snapped pieces hide them)
├─ PiecesPanel              (draggable / snapped piece Images)
└─ EffectsPanel             (sparkle / particle UI for completion FX, optional)

HUDCanvas is a separate Canvas at a higher sorting order (or Screen Space - Overlay). It never sorts against PaperCanvas because they're different canvases.

CaptureCamera renders only the PaperUI layer. The HUD physically cannot leak into the saved PNG because of the culling mask, regardless of sibling order.

If you ever need particles outside the canvas (e.g. confetti falling across the full screen on completion), use a separate Canvas above the HUD with its own sub-tree of UI particles. Don't add ParticleSystems under PaperPanel — they don't render in UI canvases without UIParticleSystem or similar packages.


IGalleryService.SaveToDeviceAsync(byte[] png, string albumName) is the only operation. Implementations wrap a native plugin — recommended packages:

Platform Library
Cross-platform Native Gallery for Android & iOS
iOS-only fallback PHPhotoLibrary direct bindings
Android-only fallback MediaStore direct bindings via AndroidJavaClass

Permission handling:

  • iOSNSPhotoLibraryAddUsageDescription in Info.plist. iOS prompts on first save.
  • Android 13+ — no permission required for writes that target a public collection via the plugin.
  • Android 1112WRITE_EXTERNAL_STORAGE declared but not requested at runtime; plugin uses scoped storage.
  • Android ≤ 10WRITE_EXTERNAL_STORAGE runtime permission requested by the plugin.

NativeGallerySaveService (the planned concrete) catches plugin permission denials and either silently no-ops (toddler app) or surfaces a child-friendly retry prompt via the HUD.

No app-side data is persisted about saved drawings. Once SaveToDeviceAsync returns, the PNG is the OS's responsibility.


29. Boot & Error Handling

Status: not implemented. No AppBoot class exists. Today, RootLifetimeScope.cs only iterates installer MonoBehaviours and registers them — nothing runs after that. The block below is the target sequence when AppBoot is added as an IAsyncStartable entry point under App/Boot/.

AppBoot.StartAsync()           (planned — Features/Boot/AppBoot.cs, registered via builder.RegisterEntryPoint<AppBoot>())
  ├─ try Addressables.InitializeAsync()
  │     fail → show "Tap to retry" splash
  ├─ try preload palette + UI sounds (Addressables labels)
  │     fail → log + continue (non-fatal)
  ├─ try _persistence.LoadAsync()
  │     fail → start with empty progression (don't crash)
  ├─ _scenes.LoadAsync("MainMenu")
  └─ done

Toddler-mode error UI:

  • One large smiling icon.
  • One big "tap" button.
  • No text, no error codes.
  • A small upper-right gear opens a parent-only diagnostic screen (long-press 3 s to unlock).

30. Setup Checklist (new dev, day one)

  1. Open Colorbook.sln at the repo root.
  2. Verify required Unity packages are installed (check Packages/manifest.json): VContainer, UniTask, Addressables, Input System, URP, Spine-Unity runtime (com.esotericsoftware.spine.spine-unity) for the main-menu mascot, DOTween (for snap/return tweens).
  3. Open Assets/Darkmatter/Scenes/Boot.unity (currently the only scene wired).
  4. Inspect the RootLifetimeScope GameObject — confirm its serviceModules[] list references the child installer MonoBehaviours (AudioServiceModule, CameraServiceModule, InputServiceModule, etc.).
  5. Hit Play from Boot.unity. Other scenes (MainMenu, ColorBook) don't exist yet — they're listed in §6 / §4c as planned work.
  6. When new scene scopes land, the same rule applies: never start a scene mid-flow, always enter from Boot.unity so the root scope exists.
  7. When drawings are authored: duplicate the template folder under Content/Gameplay/Drawings/<theme>/<id>/, edit Template.asset (pieces + regions), add to the appropriate Addressables group.
  8. Run Tests > EditMode and Tests > PlayMode before pushing (test infra not set up yet — see §16).

31. Quick Reference — Class ↔ Layer ↔ Asmdef

Class Layer Asmdef
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
CaptureService Services Services.Capture
ColoringController, ColoringStateRepository, ColorRegionView, ColorPaletteHolderView, ColorPaletteHolderPresenter, PaintRegionCommand Features Features.Coloring
ShapePiece, SlotMarker, ShapeBuilderController, IShapePieceFactory, ShapePieceFactory, ShapeHolderView, ShapeHolderPresenter 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
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.

31b. Scripts inventory by domain

Comprehensive index — every script (existing or planned) grouped by its module. Use this as the single-page mental map. Status column: exists on disk, ⚠️ planned.

Core

Module (path) Scripts Status
Core/Compatibility/ IsExternalInit
Core/Contracts/Services/Assets/ IAssetProviderService
Core/Contracts/Services/Audio/ IAudioService, ISfxPlayer
Core/Contracts/Services/Camera/ ICameraService
Core/Contracts/Services/Inputs/ IInputReader
Core/Contracts/Services/Scenes/ ISceneService
Core/Contracts/Services/Capture/ ICaptureService ⚠️
Core/Contracts/Services/Gallery/ IGalleryService ⚠️
Core/Contracts/Features/DrawingCatalog/ IDrawingCatalogController, IDrawingTemplate, IDrawingTemplateCatalog
Core/Contracts/Features/Coloring/ IColorPalette
Core/Contracts/Features/History/ ICommand, IUndoStack
Core/Contracts/Features/Progression/ IProgressionSystem
Core/Data/Dynamic/Services/Audio/ AudioHandle, AudioRequest
Core/Data/Static/Services/Audio/ SfxCatalogSO
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

Libs

Module (path) Scripts Status
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
Libs/UI/ ToggleButton, ToggleButtonGroup

Services

Module (path) Scripts Status
Services/Analytics/Installers/ AnalyticsModule
Services/Analytics/Systems/ FirebaseAnalyticsSystem
Services/Assets/ AddressableAssetProviderService, AddressableLoadHandleTracker
Services/Audio/ AudioService, SfxPlayer
Services/Camera/Service/ CameraService
Services/Camera/Installers/ CameraModule
Services/Inputs/Generated/ GameInputs (Input System codegen)
Services/Inputs/ (Inputs feature partial — Reader + Installer location TBD) ⚠️
Services/Scenes/ SceneService
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/ 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, DrawingCatalogButton, CatalogItemVM
Features/DrawingCatalog/Installers/ DrawingCatalogFeatureModule
Features/DrawingTemplate/Systems/ AddressableDrawingTemplateCatalog
Features/DrawingTemplate/Installers/ DrawingTemplateFeatureModule
Features/ShapeBuilder/UI/ ShapePiece, SlotMarker, ShapeHolderView, ShapeHolderPresenter
Features/ShapeBuilder/Systems/ ShapeBuilderController, IShapePieceFactory, ShapePieceFactory
Features/ShapeBuilder/Installers/ ShapeBuilderFeatureModule
Features/Coloring/Systems/ ColoringController, ColoringStateRepository ⚠️ planned
Features/Coloring/UI/ ColorRegionView , ColorPaletteHolderView , ColorPaletteHolderPresenter , ColorButton ⚠️ planned
Features/Coloring/Systems/ IColorButtonFactory, ColorButtonFactory ⚠️ planned
Features/Coloring/Commands/ PaintRegionCommand ⚠️
Features/Coloring/Installers/ ColoringFeatureModule ⚠️
Features/Capture/Systems/ CaptureController (light wrapper around ICaptureService) ⚠️
Features/Capture/UI/ CaptureButtonPresenter, SaveToastView ⚠️
Features/Capture/Installers/ CaptureFeatureModule ⚠️
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/ 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
Features/GameplayFlow/SceneRefs/ GameplaySceneRefs (PaperRoot only — per-feature containers moved to holder views)

32. Class Reference (Detailed)

Status: target spec, mostly unimplemented. Existing on disk: AddressableAssetProviderService, AudioService / SfxPlayer, CameraService, SceneService, InputReaderSO, FirebaseAnalyticsSystem, plus the History feature (UndoStack, HistoryServiceModule, ICommand, IUndoStack). Everything else (MainMenu, DrawingCatalog, ShapeBuilder, Coloring, Capture, Gallery, Progression, ColorBookFlow, AppBoot, scene scopes) is the target shape for when those classes are written. Treat this section as a contract for new code, not documentation of current state.

Canonical breakdown of every concrete class and interface. For each: purpose, public surface (signatures), injected dependencies, and collaborators (signals or interfaces it talks to).

Convention used below

  • // fields: = constructor-injected dependencies
  • // pub: = events / signals fired
  • // sub: = events / signals consumed
  • All async returns are UniTask unless noted.
  • Folder labels follow the actual nesting pattern: Core/Contracts/Features/<Name>/, Core/Contracts/Services/<Name>/, Core/Data/Dynamic/Features/<Name>/, Features/<Name>/<Sub>/, Services/<Name>/<Sub>/.

32.1 Core Contracts

Pure interfaces and DTOs. Zero logic.

IDrawingTemplate (Core/Contracts/Features/Drawing — planned)

Immutable view of a single drawing's authored data.

public interface IDrawingTemplate {
    string Id { get; }                              // e.g. "animals/elephant"
    string DisplayName { get; }                     // user-facing
    Sprite DefaultThumbnail { get; }                // 256×256 authored fallback for the catalog grid
    Sprite PaperBackground { get; }                 // image under all paper content
    IReadOnlyList<ShapeSO> Pieces { get; }          // shapes spawned in tray (reusable across drawings)
    IReadOnlyList<ColorRegionDTO> Regions { get; }  // for Coloring
}

Implemented by DrawingTemplateSO (ScriptableObject) loaded via Addressables. The per-drawing slot positions live in the drawing's authored scene/prefab as SlotMarker MonoBehaviours, not in the template SO.

The catalog grid always uses DefaultThumbnail. The user's captured drawings live in the phone's Photos app, not the catalog cell.

IDrawingTemplateCatalog (Core/Contracts/Features/Drawing — planned)

Authority on which drawings exist, completion state, and "next" selection.

public interface IDrawingTemplateCatalog {
    UniTask InitializeAsync();                       // pulls manifest from Addressables
    IReadOnlyList<string> AllTemplateIds { get; }
    UniTask<Sprite> GetThumbnailAsync(string id);    // cheap; for grid
    UniTask<IDrawingTemplate> LoadAsync(string id);  // expensive; full template
    void Release(string id);                         // Addressables ref count down
    string NextUnseen(string currentId);             // progression hint
}

IColorPalette (Core/Contracts/Features/Coloring — planned)

Set of colors offered to the child. Authored as ColorPaletteSO.

public interface IColorPalette {
    string Id { get; }
    IReadOnlyList<Color> Colors { get; }             // 610 entries typical
}

ICommand & IUndoStack (Core/Contracts/Features/History — planned)

Already shown in section 8. Each undoable user action is one ICommand; the stack is bounded.

IGalleryService (Core/Contracts/Services/Gallery — planned)

Thin shim over a native gallery plugin. Saves PNG bytes into the phone's Photos app. Does not track files, thumbnails, or sidecar metadata — the OS owns the file once it's saved.

public interface IGalleryService {
    UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book");
}
  • No list / load / delete operations. The user uses the phone's Photos app for those.
  • Implementation (NativeGallerySaveService) wraps a third-party native plugin and handles platform permission prompts.

ICaptureService (Core/Contracts/Services/Capture — planned)

Snapshots the paper area to a PNG blob. No arguments — implementation owns the disabled CaptureCamera reference.

public interface ICaptureService {
    UniTask<byte[]> CaptureAsync();
}
  • Independent of IGalleryService. Returns raw PNG bytes; what happens next is the caller's call (save, share, discard).

Removed contracts

  • IPaperRig, IArtInputBridge, IPaperSurface — paper is just RectTransforms in the scene now, exposed via GameplaySceneRefs.PaperRoot. No contract.
  • SavedArtworkDTO, IGalleryService.ListAsync/LoadFullAsync/LoadThumbnailAsync/DeleteAsync/GetLatestThumbnailAsync — no app-side gallery store.
  • ArtworkCapturedSignal, ArtworkSavedSignal — replaced by PaperCapturedSignal / PaperSavedSignal (templateId only, no DTO).

IProgressionService (Core/Contracts/Features/Progression — planned)

Tracks which templates the child has completed and what they last opened.

public interface IProgressionService {
    UniTask LoadAsync();
    UniTask SaveAsync();
    IReadOnlyCollection<string> CompletedTemplateIds { get; }
    string LastOpenedTemplateId { get; }
    void MarkCompleted(string templateId);
    void SetLastOpened(string templateId);
}

IAssetProviderService (Core/Contracts/Services/Assets — exists)

Addressables wrapper. Hides handle bookkeeping from features.

public interface IAssetProviderService {
    UniTask InitializeAsync();
    UniTask<T> LoadAsync<T>(string address) where T : UnityEngine.Object;
    UniTask<IReadOnlyList<T>> LoadByLabelAsync<T>(string label) where T : UnityEngine.Object;
    void Release(string address);
    void ReleaseAll();
}

IEventBus (Libs/Observer — exists; note the folder is Observer, not EventBus)

public interface IEventBus {
    void Publish<T>(T signal) where T : struct;
    IDisposable Subscribe<T>(Action<T> handler) where T : struct;
}

Signals are structs to avoid GC. Disposable subscription so presenters can unsubscribe in Dispose().


32.2 Services Layer

Concrete infrastructure. One implementation each. All singletons in RootLifetimeScope, registered via per-service MonoBehaviour, IServiceModule installers.

AddressableAssetProviderService (Services/Assets — exists)

Implements IAssetProviderService.

  • Responsibility: Wrap Addressables.LoadAssetAsync<T> and ref-count handles by address.
  • State: Dictionary<string, AsyncOperationHandle> keyed by address.
  • Notes: Release(address) decrements; ReleaseAll() for scene teardown. Initialization must complete before any other service may load.

NativeGallerySaveService (Services/Gallery — planned)

Implements IGalleryService as a thin wrapper around a native gallery plugin.

// fields:
//   INativeGalleryPlugin _plugin     (third-party native bridge)
//   IEventBus _bus                   (optional — for failure surfacing)

public sealed class NativeGallerySaveService : IGalleryService {
    public async UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book") {
        var permission = await _plugin.RequestWritePermissionAsync();
        if (permission != Permission.Granted) {
            // toddler app — silently skip; or publish a failure signal for HUD retry prompt
            return;
        }
        await _plugin.SaveImageToAlbumAsync(png, albumName, fileName: $"colorbook_{DateTime.UtcNow:yyyyMMdd_HHmmss}.png");
    }
}
  • No file IO, no thumbnails, no sidecars. The native plugin owns everything past the call.
  • Permission flow runs once per session; the plugin caches the grant.
  • Failure handling: a toddler app shouldn't crash or block on denial — silently skip and let the user keep playing.

RenderTextureCaptureService (Services/Capture — planned)

Implements ICaptureService. Drives the scene's disabled CaptureCamera once per capture.

// fields:
//   Camera _captureCam            (scene-bound, registered via CaptureServiceModule)
//   int    _surfaceSize = 2048
//   IPathProvider _paths          (only if you want to expose paths — usually not needed here)
  • Steps: RenderTexture.GetTemporary(size, size, 0, ARGB32) → set _captureCam.targetTexture = rt_captureCam.Render()ReadPixels into a Texture2D → null out the target texture → RenderTexture.ReleaseTemporary(rt)EncodeToPNG → return bytes.
  • Threading: PNG encode happens on UniTask.RunOnThreadPool to avoid hitching the main thread on tablets.
  • Camera setup: _captureCam has cullingMask = PaperUI, clearFlags = SolidColor (white or paper color), orthographicSize and aspect cloned from UICamera once at scene start. Stays disabled — Render() is the only call site.
  • Sizing: default 2048², overridable. Capped at device max texture size.

Persistence (no dedicated service)

There is no IPersistenceService / JSON file writer. Libs/PlayerPrefs (ProtectedPlayerPrefs) is the only persistent storage in the app. IProgressionService consumes it directly.

  • Backing store: PlayerPrefs via the encrypted wrapper.
  • Keys: namespaced strings registered in PlayerPrefsKeyRegistry.
  • Format per value: JSON-encoded primitives (ScriptableObject.CreateInstance not needed at this layer).
  • Atomicity: PlayerPrefs is already atomic per key on iOS/Android.

SceneService (Services/Scenes — exists)

Implements ISceneService. Wraps SceneManager.LoadSceneAsync with UniTask plus a fade-curtain.

public interface ISceneService {
    UniTask LoadAsync(string sceneName, LoadSceneMode mode = LoadSceneMode.Single);
    UniTask UnloadAsync(string sceneName);
}

AudioService (Services/Audio — exists; see also SfxPlayer)

Implements IAudioService. Plays SFX clips loaded by address, mixes via Unity AudioMixer groups.

public interface IAudioService {
    UniTask PreloadAsync(string label);              // e.g. "sfx,ui"
    void PlayOneShot(string clipId, float volume = 1f);
    void SetCategoryVolume(AudioCategory cat, float v01);
}

Holds an internal Dictionary<string, AudioClip> populated at preload.

InputReaderSO (Services/Inputs/Readers — exists)

ScriptableObject wrapping the new Input System; exposes events.

public interface IInputReader {
    event Action<Vector2> PointerDown;     // world pos
    event Action<Vector2> PointerDrag;
    event Action<Vector2> PointerUp;
}
  • Why an SO: assignable in inspector and survives scene loads, but still resolvable via DI (builder.RegisterInstance(_inputReader).As<IInputReader>()).

32.3 Libs

Generic, project-agnostic utilities.

BoundedUndoStack (Libs/CommandStack — planned)

Implements IUndoStack. Source already in section 24.

  • Capacity: default 20.
  • Invariant: _redo cleared on any new Push.
  • Edge cases: Undo/Redo on empty stack is a no-op (never throws).

EventBus (Libs/Observer — exists)

Implements IEventBus with a Dictionary<Type, Delegate> of Action<T> per signal type.

  • Subscribe returns an IDisposable that removes the handler on Dispose.
  • Publish snapshots the invocation list before iterating (so handlers may safely unsubscribe during dispatch).

StateMachine / IState / State (Libs/FSM — exists)

Generic state machine. Current shape on disk uses IState / State / StateMachine (see Libs/FSM/). ColorBookFlowController (planned) will use this. The generic sketch below is the target shape if you decide to make it strongly-typed via an enum — verify against actual API before consuming.

public sealed class Fsm<TState> where TState : struct, Enum {
    public TState Current { get; }
    public event Action<TState, TState> Transitioned;
    public void Bind(TState state, IFsmState handler);
    public void Go(TState next);                     // calls Exit on old, Enter on new
}

public interface IFsmState { void Enter(); void Exit(); }

32.3b Feature — MainMenu (planned)

Lives in MainMenu.unity. Hosts the Play / Art Book entry buttons plus the Spine character mascot.

IMenuMascotView (UI contract)

Setter-only view interface. Hides Spine-Unity's API behind a tiny surface.

public interface IMenuMascotView {
    event Action<string> AnimationComplete;       // fires when a non-looping anim ends
    void Play(string animName, bool loop);
    void SetSkin(string skinName);                // optional — character variants
}

MenuMascotView : MonoBehaviour, IMenuMascotView (UI)

Concrete view. Wraps a SkeletonGraphic component (Spine-Unity's Canvas-compatible renderer).

public sealed class MenuMascotView : MonoBehaviour, IMenuMascotView {
    [SerializeField] private SkeletonGraphic _skeleton;     // Spine UI component

    public event Action<string> AnimationComplete;

    public void Play(string animName, bool loop) {
        var track = _skeleton.AnimationState.SetAnimation(0, animName, loop);
        track.Complete += _ => AnimationComplete?.Invoke(animName);
    }

    public void SetSkin(string skinName) {
        _skeleton.Skeleton.SetSkin(skinName);
        _skeleton.Skeleton.SetSlotsToSetupPose();
        _skeleton.AnimationState.Apply(_skeleton.Skeleton);
    }
}
  • SkeletonGraphic lives on a child of MainMenuCanvas. It's a Graphic, so it interacts with CanvasRenderer just like an Image.
  • The Spine asset (SkeletonDataAsset) is loaded via Addressables, assigned at scene setup, and released on scene exit.

MenuMascotPresenter (UI)IStartable, IDisposable

Pure C#. Subscribes to button events + idle timer, drives the view.

// fields: IMenuMascotView _view, MainMenuModel _model, IInputReader _input
public sealed class MenuMascotPresenter : IStartable, IDisposable {
    public void Start() {
        _view.Play("idle", loop: true);
        _model.PlayButtonHovered  += () => _view.Play("hover_play", loop: false);
        _model.PlayButtonPressed  += () => _view.Play("wave",       loop: false);
        _view.AnimationComplete   += OnAnimationComplete;
    }

    private void OnAnimationComplete(string anim) {
        if (anim != "idle") _view.Play("idle", loop: true);   // always return to idle
    }
}
  • Mascot reactions are pure presenter logic — the view never decides what to play.
  • If you want randomized idle variants, add an idle timer in the model + a list of clip names.

MainMenuModel (Repository)

Holds menu state — current selected skin, fires hover/click events from button presenters.

MainMenuModule : MonoBehaviour, IServiceModule (Installers)

Registers the view (RegisterInstance<IMenuMascotView>(_view)), the presenter as a startable entry point, and the model.

Package dependency: Spine-Unity runtime (com.esotericsoftware.spine.spine-unity). Add to Packages/manifest.json. The SkeletonGraphic component lives in Spine.Unity namespace.


32.4 Feature — DrawingCatalog (planned)

DrawingCatalogController (Systems)

Headless logic. Owns the list of template IDs visible in the grid.

// fields: IDrawingTemplateCatalog _catalog, IEventBus _bus
public sealed class DrawingCatalogController : IAsyncStartable {
    public IReadOnlyList<string> VisibleIds { get; }
    public event Action ListChanged;
    public UniTask StartAsync(CancellationToken ct);     // pulls catalog, refreshes list
    public void OnTemplateSelected(string id);           // bus.Publish(new DrawingSelectedSignal(id))
}
// pub: DrawingSelectedSignal

DrawingCatalogPresenter (UI)

Bridges controller ↔ view.

// fields: IDrawingCatalogView _view, DrawingCatalogController _ctrl, IDrawingTemplateCatalog _catalog
public sealed class DrawingCatalogPresenter : IStartable, IDisposable {
    public void Start();         // wires view.OnItemClicked → _ctrl.OnTemplateSelected
    public void Dispose();
}
  • Thumbnail load: _catalog.GetThumbnailAsync(id) per visible cell, with placeholder while loading.

DrawingCatalogView : MonoBehaviour (UI)

Implements IDrawingCatalogView. Pure setters + click event.

public interface IDrawingCatalogView {
    event Action<string> OnItemClicked;
    void SetItems(IReadOnlyList<CatalogItemVM> items);   // vm = id + Sprite thumbnail
}

32.5 Feature — ShapeBuilder (planned controller, MBs exist)

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.

public sealed class ShapePiece : MonoBehaviour,
    IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [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 bool IsLocked => _locked;
    public RectTransform RectTransform { get; }

    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.

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)

[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 — exists)

Orchestrates per-template build: loads piece prefab, instantiates drawing layout under PaperRoot, computes tray positions from ShapeHolderView.SpawnWidth, publishes ShapeBuilderStartedSignal, delegates per-piece spawn to IShapePieceFactory. Subscribes to PieceSnappedSignal and fires ShapeAssembledSignal when count matches expected.

// fields: IEventBus _bus, IAssetProviderService _assetProviderService,
//         IShapePieceFactory _factory, ShapeHolderView _holder, IGameplaySceneRefs _refs
public sealed class ShapeBuilderController : IShapeBuilderController, IDisposable
{
    private GameObject _piecePrefab;

    public async UniTask InitializeAsync(CancellationToken ct);   // load prefab + subscribe
    public IReadOnlyCollection<string> GetSnappedPieceIds();      // for save records

    public async UniTask BuildAsync(
        IDrawingTemplate template,
        IReadOnlyCollection<string> preSnappedIds,
        CancellationToken ct = default);

    public void Clear();   // destroys current drawing instance + resets counters
}
// sub: PieceSnappedSignal
// pub: ShapeBuilderStartedSignal (before spawn), ShapeAssembledSignal (when all locked)
  • 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 factory is called with preSnapped: trueShapePiece.SnapInstantly() lands it in the slot at scope start.
  • Tray width source: read from _holder.SpawnWidth — the controller never touches _refs for HUD geometry.

IShapePieceFactory / ShapePieceFactory (Systems — exists)

Encapsulates piece instantiation. Owns the parent (from ShapeHolderView.SpawnRoot) and the per-piece deps so the controller stays focused on flow.

public interface IShapePieceFactory {
    ShapePiece Create(GameObject prefab, ShapeSO shape, SlotMarker slot, Vector2 trayPos, bool preSnapped);
}

// fields: ShapeHolderView _holder, ShapeBuilderConfig _cfg, ISfxPlayer _sfx, IEventBus _bus, IUndoStack _undo
public sealed class ShapePieceFactory : IShapePieceFactory {
    public ShapePiece Create(GameObject prefab, ShapeSO shape, SlotMarker slot, Vector2 trayPos, bool preSnapped) {
        var go = Object.Instantiate(prefab, _holder.SpawnRoot);
        var piece = go.GetComponent<ShapePiece>();
        piece.Setup(shape, slot, _cfg, _sfx, _bus, _undo, trayPos, preSnapped);
        return piece;
    }
}

ShapeHolderView / ShapeHolderPresenter (UI — exists)

View owns the tray container RectTransform (SpawnRoot) and a PrimeTween Sequence driving slide + alpha on a CanvasGroup. Default hide direction is +X = 1500 (slide right off-screen), tunable per inspector. Public: SpawnRoot, SpawnWidth, Show(), Hide(), HideInstant(). Presenter is an IStartable, IDisposable:

  • On Start: view.HideInstant(), subscribe ShapeBuilderStartedSignalShow, subscribe ShapeAssembledSignalHide.
  • On Dispose: dispose subscriptions.

Removed / not needed

  • TrayLayout — was a stateless tray-position helper. Tray positions are now computed inline from holder.SpawnWidth (~3 lines).
  • ShapePieceFsm — was a per-piece state machine. Replaced by inline drag handlers + a single _locked bool on ShapePiece.
  • 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.

32.6 Feature — Coloring (planned)

ColoringStateRepository (Repository)

In-memory model. Owns "currently selected color" and the palette in use.

public sealed class ColoringStateRepository {
    public IColorPalette Palette { get; private set; }
    public Color CurrentColor { get; private set; }
    public event Action<Color> SelectedColorChanged;
    public void SetPalette(IColorPalette palette);   // resets CurrentColor to Colors[0]
    public void SelectColor(Color color);            // called directly by ColorButton on click
}
  • Why a repository: ColorButton (palette taps) and ColoringController (paint command builder) both need to read/write the current color; an event-emitting POCO is simpler than wiring two signals.
  • Color-keyed, not index-keyed: with ColorButton + Factory (§25), each button owns its Color directly. Index-based addressing is unnecessary — the button passes its color straight to SelectColor(color).

ColoringController (Systems — planned) — implements IColoringController

Wires pre-authored ColorRegionView children, applies saved colors on resume, builds and pushes PaintRegionCommand instances on click.

// fields: IUndoStack _undo, ColoringStateRepository _state, IGameplaySceneRefs _refs, IEventBus _bus
public interface IColoringController {
    // Initialize regions on the paper after the drawing prefab is instantiated.
    // Pass non-null savedColors to restore colors from a DrawingProgress record;
    // null = use ColorRegionDTO.InitialColor.
    UniTask InitializeRegionsAsync(
        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();                                       // detach handlers
}
// sub: ShapeAssembledSignal (via flow controller, not direct)
// pub: ColorAppliedSignal (via PaintRegionCommand)

Regions are pre-authored as children of the drawing prefab (parented under PaperRoot) — the controller does not spawn them. Each ColorRegionView sets its own Image.alphaHitTestMinimumThreshold in Awake; the controller subscribes to each view's click event and routes to PaintRegion. 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 — exists)

UI Image with alpha-based hit detection. Tap routes through Unity's EventSystem directly to OnPointerClick.

[RequireComponent(typeof(Image))]
public sealed class ColorRegionView : MonoBehaviour, IPointerClickHandler {
    [field: SerializeField] public string RegionId { get; private set; }
    [SerializeField, Range(0f, 1f)] private float alphaHitThreshold = 0.01f;
    public Color Color => _image.color;

    private void Awake() {
        _image = GetComponent<Image>();
        _image.alphaHitTestMinimumThreshold = alphaHitThreshold;   // pass-through transparent pixels
    }

    public void Initialize(string id, Color color);
    public void SetColor(Color color);
    public void OnPointerClick(PointerEventData e);   // routes to ColoringController.PaintRegion(this)
}
  • Required sprite import: Read/Write Enabled = on, Mesh Type = Full Rect, keep alpha channel (RGBA32 or Automatic-with-alpha). Without these, Unity throws UnityException: Texture is not readable on first hover/click.
  • Threshold tuning: default 0.01f is very permissive (any non-fully-transparent pixel counts). Raise toward 0.5f for tighter hits when regions overlap visually.
  • Sibling order matters for stacked regions — top sibling gets first crack at the click; with alpha hit-test, transparent areas defer correctly to siblings below.

No ColoringInputBinder class needed. Unity's EventSystem fires OnPointerClick on the topmost UI element under the pointer whose Image.alphaHitTestMinimumThreshold is met.

PaintRegionCommand (Commands)

Source in section 23. Holds view, fromColor, toColor, bus. Symmetrical execute/undo.

ColorButton + IColorButtonFactory / ColorButtonFactory (UI + Systems — planned)

Replaces the View/Presenter pair for the palette. Each color is its own self-contained MB with click handler; factory instantiates the prefab under ColorPaletteHolderView.SpawnRoot and runs Setup(color, state, sfx). Button writes _state.SelectColor(_color) on click. See §25 for the full pattern and rationale. The stale ColorPaletteView.cs / ColorPalettePresenter.cs stubs should be deleted when this lands.

ColorPaletteHolderView / ColorPaletteHolderPresenter (UI — exists)

View owns the palette container RectTransform (SpawnRoot) + slide/fade show-hide animation (same Tween.UIAnchoredPosition + Tween.Alpha pattern as ShapeHolderView, default hide off-screen right). Presenter subscribes ShapeAssembledSignalview.Show(). Hide is invoked externally (scope tear-down or future phase-exit signal).

Removed / not needed

  • ColorRegionFactory — regions are pre-authored as children of the drawing prefab. The controller wires existing views; it doesn't spawn anything.
  • ColorPaletteView + ColorPalettePresenter — replaced by self-contained ColorButton + ColorButtonFactory. The stub files at Features/Coloring/UI/ColorPaletteView.cs and ColorPalettePresenter.cs should be deleted; see §25 for rationale.

32.7 Feature — History (planned)

HistoryController (Systems)IStartable, IDisposable

Owns the per-session IUndoStack (registered scoped, so a new ColorBook scene = new stack).

// fields: IUndoStack _stack, IEventBus _bus
public sealed class HistoryController : IStartable, IDisposable {
    public bool CanUndo => _stack.CanUndo;
    public bool CanRedo => _stack.CanRedo;
    public event Action StateChanged;
    public void Undo();                              // _stack.Undo() + StateChanged
    public void Redo();
    // sub: DrawingSelectedSignal → _stack.Clear()
}

HistoryButtonsView : MonoBehaviour (UI)

Two big arrow buttons. Setters only.

public interface IHistoryButtonsView {
    event Action UndoClicked;
    event Action RedoClicked;
    void SetUndoEnabled(bool enabled);
    void SetRedoEnabled(bool enabled);
}

HistoryPresenter (UI)

Wires controller StateChanged ↔ view enable/disable; view click events → controller.


32.8 Feature — Capture (planned)

CaptureController (Systems)

The orchestrator behind the "Save" button. Owns the capture-then-save chain. Stateless other than guarding against concurrent captures.

// fields: ICaptureService _capture, IGalleryService _gallery, IEventBus _bus
public sealed class CaptureController {
    public bool IsBusy { get; }
    public UniTask SaveAsync(string templateId);
}
// pub: PaperCapturedSignal (mid-flow), PaperSavedSignal (after native save)
  • Flow: _capture.CaptureAsync() → publish PaperCapturedSignal_gallery.SaveToDeviceAsync(bytes) → publish PaperSavedSignal.
  • Concurrency: sets IsBusy = true on entry; UI binds button enabled to !IsBusy to prevent double-tap.
  • No camera argsICaptureService owns the CaptureCamera reference.
  • No file-IO awarenessIGalleryService handles the native plugin handoff.

CaptureButtonPresenter (UI)

Wires button click → CaptureController.SaveAsync(currentTemplateId). Disables button while IsBusy. Shows a "Saved to Photos" toast on PaperSavedSignal.


32.9 Feature — Progression (planned)

ProgressionService (Systems) — implements IProgressionService

The only place that knows what "completed" means.

  • Persistence: delegates to ProtectedPlayerPrefs (Libs.PlayerPrefs) under key "progression".
  • Load order: AppBoot calls LoadAsync() early.
  • Save trigger: after MarkCompleted, debounced 500 ms to coalesce a burst of "Next" presses.

ProgressionRepository (Repository)

Pure in-memory holder used by the service. Separated so tests can inspect state without going through file IO.


32.10 Feature — ColorBookFlow (planned)

ColorBookFlowController (Systems)IStartable, IDisposable

The only orchestrator inside the ColorBook scene. Drives the panel FSM: Catalog → Building → Coloring → Done.

// fields:
//   IEventBus _bus
//   IDrawingTemplateCatalog _catalog
//   ShapeBuilderController _builder
//   IColoringController _coloring
//   CaptureController _capture
//   IProgressionService _progression
//   IGameplaySceneRefs _refs        (PaperRoot only — panel show/hide owned by holder views)
//   Fsm<ColorBookState> _fsm
  • State table:

    State On enter Triggers exit
    Catalog Show catalog panel DrawingSelectedSignal
    Building _builder.BuildAsync(id) ShapeAssembledSignal
    Coloring _coloring.SpawnRegionsAsync(template) "Next" or "Capture" pressed
    Done Run autosave capture, mark completed, Go(Catalog) for next always advances
  • "Next" sequence: _capture.CaptureCurrentAsync_progression.MarkCompleted_catalog.Release(current)_catalog.LoadAsync(_catalog.NextUnseen(current)) → re-enter Building.


32.12 App Layer

AppBoot (App/Boot — planned; folder doesn't exist yet)IAsyncStartable

Single entry point. Steps in section 29.

// fields: IAssetProviderService _assets, IProgressionService _progress,
//         IAudioService _audio, ISceneService _scenes, BootConfig _cfg
public sealed class AppBoot : IAsyncStartable {
    public UniTask StartAsync(CancellationToken ct);
}

LifetimeScopes

  • RootLifetimeScope exists (source). Iterates a serialized MonoBehaviour[] serviceModules and calls Register on each IServiceModule. Persists for app lifetime.
  • MainMenuLifetimeScope — planned. Same pattern as Root (serialized installer list, no hardcoded registrations).
  • GameplayLifetimeScope — planned. Same pattern; installer list includes feature installers + the flow controller installer. Also has a [SerializeField] GameplaySceneRefs _sceneRefs; and registers it via builder.RegisterComponent<IGameplaySceneRefs>(_sceneRefs).

All scope classes are thin: a serialized installer-MonoBehaviour list (+ optional scene refs as separate fields) and a Configure(IContainerBuilder) that iterates and calls Register.


32.13 Cross-cutting types

GameplaySceneRefs : MonoBehaviour, IGameplaySceneRefs (Features/GameplayFlow/SceneRefs — exists)

Single source of the one cross-feature scene anchor (PaperRoot). Per-feature UI containers (tray, palette) live on their own holder views — see §32.X. Registered in GameplayLifetimeScope via builder.RegisterComponent<IGameplaySceneRefs>(_sceneRefs).

public sealed class GameplaySceneRefs : MonoBehaviour, IGameplaySceneRefs {
    [SerializeField] private RectTransform paperRoot;
    public RectTransform PaperRoot => paperRoot;
}

Replaces the dropped IPaperSurface contract. The drawing prefab (with SlotMarker + ColorRegionView children) is instantiated under PaperRoot; the CaptureCamera will live on its own scene MB owned by the Capture feature.

ShapeHolderView : MonoBehaviour + ShapeHolderPresenter (Features/ShapeBuilder/UI — exists)

Owns the tray container RectTransform (SpawnRoot) and the slide/fade show/hide animation (PrimeTween — Tween.UIAnchoredPosition + Tween.Alpha on CanvasGroup). Default hide offset is +1500 on X (slide right off-screen), tunable per inspector. Exposes SpawnRoot, SpawnWidth, Show(), Hide(), HideInstant(). Presenter is an IStartable that subscribes:

  • ShapeBuilderStartedSignalview.Show()
  • ShapeAssembledSignalview.Hide()

Registered in ShapeBuilderFeatureModule via builder.RegisterComponent(holderView) + builder.RegisterEntryPoint<ShapeHolderPresenter>().WithParameter(holderView).

ColorPaletteHolderView : MonoBehaviour + ColorPaletteHolderPresenter (Features/Coloring/UI — exists)

Same pattern as ShapeHolderView — owns the palette container RectTransform (SpawnRoot) and slide/fade animation. Presenter subscribes to ShapeAssembledSignalview.Show(). Hide is invoked externally (scope tear-down or future phase-exit signal). Registered analogously in ColoringFeatureModule.

IShapePieceFactory / ShapePieceFactory (Features/ShapeBuilder/Systems — exists)

Encapsulates piece instantiation. Owns the per-piece dependencies and the spawn parent so the controller stays focused on flow.

public interface IShapePieceFactory {
    ShapePiece Create(GameObject prefab, ShapeSO shape, SlotMarker slot, Vector2 trayPos, bool preSnapped);
}

// fields: ShapeHolderView _holder, ShapeBuilderConfig _cfg, ISfxPlayer _sfx, IEventBus _bus, IUndoStack _undo
public sealed class ShapePieceFactory : IShapePieceFactory { /* Instantiate under _holder.SpawnRoot, run piece.Setup(...) */ }

IServiceModule (Libs/Installers — exists)

public interface IServiceModule {
    void Register(IContainerBuilder builder);
}

Implemented as MonoBehaviour per feature/service so scopes can drag them in the inspector (CameraServiceModule.cs shows the pattern). The method is Register, not Install — there is no IInstaller in this project.


32.14 Class summary table

Class Layer Role Key dependencies
AppBoot App Startup sequencer assets, progression, audio, scenes
RootLifetimeScope App Root DI service modules
MainMenuLifetimeScope App Menu scene DI feature modules
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 DrawingTemplateSOs, 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 Orchestrates per-template build, delegates spawn to factory, tracks snap count bus, assets, factory, holder, refs
IShapePieceFactory / ShapePieceFactory Feature.ShapeBuilder Instantiates a ShapePiece under ShapeHolderView.SpawnRoot + runs Setup holder, cfg, sfx, bus, undo
ShapeHolderView / ShapeHolderPresenter Feature.ShapeBuilder Tray container + slide/fade show/hide; presenter reacts to ShapeBuilderStartedSignal / ShapeAssembledSignal bus
ColoringStateRepository Feature.Coloring Current color model
ColoringController Feature.Coloring Wires pre-authored regions + paint cmd + autosave hook undo, state, refs, flow, bus
ColorPaletteHolderView / ColorPaletteHolderPresenter Feature.Coloring Palette container + slide/fade show/hide; presenter reacts to ShapeAssembledSignal bus
ColorButton Feature.Coloring Self-contained palette swatch MB; click writes to repository (no view/presenter pair) state, sfx
IColorButtonFactory / ColorButtonFactory Feature.Coloring Instantiates a ColorButton under ColorPaletteHolderView.SpawnRoot + runs Setup holder, prefab, state, sfx
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.

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.

Description
No description provided
Readme 731 MiB
Languages
C# 87%
ShaderLab 7.2%
HLSL 3.3%
GLSL 2.5%