Savya Bikram Shah aac0dc1513 Updates
2026-05-27 15:09:05 +05:45
2026-05-27 15:09:05 +05:45
2026-05-27 10:36:39 +05:45
2026-05-27 10:36:39 +05:45
2026-05-26 16:47:16 +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

App launch
  └─ Boot scene (RootLifetimeScope)
      └─ MainMenu scene (Spine mascot, Play button)
          └─ Press "Play"   → ColorBook scene
                ├─ Drawing catalog (grid of templates)
                ├─ Select drawing
                ├─ Shape Builder panel  (drag pieces → snap to slots)
                ├─ ↓ on assembly complete
                ├─ Color panel          (tap color → tap region)
                ├─ Undo / Redo any time
                ├─ "Save"  → screenshot via CaptureCamera → native gallery plugin
                │            saves PNG to phone's Photos album. Toast confirmation.
                └─ "Next"  → auto-save + load next drawing

The user views their captured drawings inside the phone's native Photos app — there is no in-app gallery viewer. Capture and gallery-save are two independent services: ICaptureService produces PNG bytes; IGalleryService is a thin shim over a native plugin that writes those bytes into the device's photo library.


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

Scene Scope Status Contents
Boot.unity RootLifetimeScope exists All Services + Libs. Persists forever.
MainMenu.unity MainMenuLifetimeScope ⚠️ planned Spine mascot, Play button.
ColorBook.unity ColorBookLifetimeScope ⚠️ planned DrawingCatalog, ShapeBuilder, Coloring, History, Capture, ColorBookFlow.

Only Boot.unity exists today; the two scene scope classes haven't been written yet either (only RootLifetimeScope exists in App/LifetimeScopes/).

Scopes nest: Root → (MainMenu | ColorBook). Services resolved from the root parent. Scene scopes only register their own features. There is no in-app gallery — captured drawings go to the phone's native Photos app via the gallery service.

Boot chain (planned)

No AppBoot class exists yet — today RootLifetimeScope only registers services and stops there. When AppBoot is added (as an IAsyncStartable registered via builder.RegisterEntryPoint<AppBoot>()), it should run once, in order:

  1. Initialize IAssetProviderService (Addressables init).
  2. Preload essential bundles (palettes, UI sounds).
  3. Load IProgressionService from disk.
  4. Load MainMenu scene.

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 RegionsParent
    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 ColorBook.unity scene with child RectTransforms for slots, pieces, and regions. There is no IPaperSurface contract and no Paper feature. Features that need to parent UI under one of these roots read them from a small ColorBookSceneRefs : MonoBehaviour that the scene scope registers as a singleton:

public sealed class ColorBookSceneRefs : MonoBehaviour {
    [SerializeField] public RectTransform PaperRoot;
    [SerializeField] public RectTransform SlotsParent;
    [SerializeField] public RectTransform PiecesParent;
    [SerializeField] public RectTransform RegionsParent;
    [SerializeField] public Camera CaptureCamera;       // disabled, used by ICaptureService
    [SerializeField] public RectTransform HudRoot;
    [SerializeField] public RectTransform TrayPanel;
}

Registered once via ColorBookLifetimeScope:

builder.RegisterInstance(_sceneRefs);

Features inject ColorBookSceneRefs directly and read the rects they need. This keeps the scene refs visible in the inspector while avoiding a one-feature wrapper that adds nothing.

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.
  • Loads template, instantiates the single piece prefab once per ShapeSO in the template, parents under the HUD tray panel (ColorBookSceneRefs.TrayPanel). Each instance is Assign(shape)ed to its ShapeSO.
  • SlotMarkers in the drawing's per-drawing prefab (under ColorBookSceneRefs.SlotsParent) provide target poses + matching ShapeSO refs.
  • Each piece has IBeginDragHandler / IDragHandler / IEndDragHandler plus a per-piece ShapePieceFsm. Drag updates RectTransform.anchoredPosition directly from PointerEventData, converted to canvas-local via RectTransformUtility.ScreenPointToLocalPointInRectangle.
  • On entering preview radius of the matching slot: reactive Lerp of anchoredPosition / sizeDelta / localRotation toward SlotMarker's RectTransform. Drives off pointer distance, not time.
  • On OnEndDrag inside snap radius: piece reparents to ColorBookSceneRefs.PiecesParent, DOTween ease-out to exact slot pose, disable input. Otherwise DOTween back to tray slot.
  • Fires ShapeAssembledSignal when all pieces locked.

Coloring

  • Listens to ShapeAssembledSignal.
  • Spawns one UI Image per ColorRegionDTO under ColorBookSceneRefs.RegionsParent. Each region's Image.alphaHitTestMinimumThreshold = 0.5f so taps on transparent pixels pass through to the next region.
  • Each region has IPointerClickHandler. On click → ColoringController.PaintRegion(view).
  • Listens to palette selection (current color held in ColoringStateRepository).
  • Controller builds PaintRegionCommand(regionId, oldColor, newColor) and pushes to IUndoStack.
  • Command sets Image.color on undo/redo.
  • Fires ColorAppliedSignal for SFX / sparkle effects.

History

  • Owns the scoped IUndoStack for the current ColorBook session.
  • Cleared on DrawingSelectedSignal (new drawing = fresh history).
  • Capped at 20 entries (memory + cognitive simplicity).
  • UI: two big arrow buttons; disabled state when CanUndo / CanRedo is false.

Capture

  • Bound to the "Save" button (and triggered silently by "Next").
  • CaptureController.SaveAsync(templateId):
    1. _capture.CaptureAsync() → PNG bytes (one-shot CaptureCamera.Render() into a temp RT)
    2. Publish PaperCapturedSignal(templateId)
    3. _gallery.SaveToDeviceAsync(bytes, "Color Book") → native plugin writes into phone's Photos
    4. Publish PaperSavedSignal(templateId)
  • HUD shows a brief "Saved to Photos" toast on PaperSavedSignal.
  • CaptureController is the only place that orchestrates capture-then-save. Other features never call IGalleryService directly.

Progression

  • Tracks completed template IDs and the in-progress draft.
  • On "Next" button: silently runs CaptureController.SaveAsync, marks current as completed, calls IDrawingTemplateCatalog.NextUnseen().
  • Persists JSON via Libs.PlayerPrefs (ProtectedPlayerPrefs).

ColorBookFlow

  • The only orchestrator inside ColorBook scope.
  • Subscribes to flow-relevant signals and toggles UI panels (catalog → builder → coloring).
  • Coordinates "Next" sequence: CaptureController.SaveAsyncIProgressionService.MarkCompletedIDrawingTemplateCatalog.Release(currentId) → load next.
  • Built as a small FSM (Catalog → Building → Coloring → Done).

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.

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 ColorBookSceneRefs MB holding camera + roots), expose it as a separate [SerializeField] and builder.RegisterInstance(...) 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<ColorPalettePresenter>(Lifetime.Scoped).AsSelf();
        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.RegisterInstance<IFoo>(_foo) them. ColorBookSceneRefs (§32.13) is registered this way directly from the scope's serialized field.

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. View / Presenter Pair — Color Palette

View (MonoBehaviour, setters only)

namespace Darkmatter.Features.Coloring.UI;

public sealed class ColorPaletteView : MonoBehaviour, IColorPaletteView {
    [SerializeField, RequireInterface(typeof(IColorButtonView))]
    private MonoBehaviour[] _buttonsRaw;

    private IColorButtonView[] _buttons;

    public event Action<int> OnColorButtonClicked;

    private void Awake() {
        _buttons = _buttonsRaw.Cast<IColorButtonView>().ToArray();
        for (var i = 0; i < _buttons.Length; i++) {
            var idx = i;
            _buttons[i].OnClicked += () => OnColorButtonClicked?.Invoke(idx);
        }
    }

    public void SetColors(IReadOnlyList<Color> colors) {
        for (var i = 0; i < _buttons.Length; i++)
            _buttons[i].SetVisible(i < colors.Count);
        for (var i = 0; i < colors.Count; i++)
            _buttons[i].SetColor(colors[i]);
    }

    public void SetSelected(int index) {
        for (var i = 0; i < _buttons.Length; i++)
            _buttons[i].SetSelected(i == index);
    }
}

Presenter (pure C#)

namespace Darkmatter.Features.Coloring.UI;

public sealed class ColorPalettePresenter : IStartable, IDisposable {
    private readonly IColorPaletteView _view;
    private readonly ColoringStateRepository _state;

    public ColorPalettePresenter(IColorPaletteView view, ColoringStateRepository state) {
        _view = view;
        _state = state;
    }

    public void Start() {
        _view.SetColors(_state.Palette.Colors);
        _view.SetSelected(_state.SelectedIndex);
        _view.OnColorButtonClicked += OnClicked;
        _state.SelectedIndexChanged += OnIndexChanged;
    }

    private void OnClicked(int index) => _state.SelectColor(index);
    private void OnIndexChanged(int index) => _view.SetSelected(index);

    public void Dispose() {
        _view.OnColorButtonClicked -= OnClicked;
        _state.SelectedIndexChanged -= OnIndexChanged;
    }
}

Same shape repeats for every feature's UI.


26. ShapeBuilder — Snap Algorithm

All math is in canvas-local space — anchoredPosition, sizeDelta, localRotation. No world coords.

// In ShapePieceFsm.OnDragEnd (state: Dragging or Preview):
public void OnDragEnd() {
    var pieceRT = _ui.RectTransform;
    var slotRT  = _targetSlot.RectTransform;
    var d = Vector2.Distance(pieceRT.anchoredPosition, slotRT.anchoredPosition);

    if (d <= _cfg.SnapRadius) {
        SnapToSlot();
    } else if (d <= _cfg.SnapRadius * 1.5f) {
        // Toddler grace zone — snap anyway, play happy sound
        SnapToSlot();
        _audio.PlayOneShot(SfxId.NiceTry);
    } else {
        ReturnToTrayAnimated();
    }
}

private void SnapToSlot() {
    _ui.RectTransform.SetParent(_paper.PiecesParent, worldPositionStays: false);
    var slot = _targetSlot.RectTransform;
    _ui.RectTransform.DOAnchorPos(slot.anchoredPosition, 0.25f).SetEase(Ease.OutBack);
    _ui.RectTransform.DOSizeDelta(slot.sizeDelta, 0.25f).SetEase(Ease.OutBack);
    _ui.RectTransform.DOLocalRotateQuaternion(slot.localRotation, 0.25f).SetEase(Ease.OutBack);
    _audio.PlayOneShot(SfxId.Snap);
    _bus.Publish(new PieceSnappedSignal(_ui.PieceId));
}

Three things to note:

  1. Reparent the piece from TrayPanel (HUD canvas) to ColorBookSceneRefs.PiecesParent (PaperCanvas) so it'll be included in capture. worldPositionStays: false because we want the new anchoredPosition to be relative to the new parent, not the world.
  2. Three simultaneous tweens — position, size, rotation. Use DOAnchorPos, DOSizeDelta, DOLocalRotateQuaternion. They start together so the piece visually snaps as one motion.
  3. SnapRadius is in canvas units (from ShapeBuilderConfig, e.g. 80120), not world units. Same CanvasScaler reference resolution across devices = same hit feel.

Controller listens for PieceSnappedSignal, counts against expected piece count, fires ShapeAssembledSignal when complete.


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, ColorRegionDTO Core Core
ShapeSO (ScriptableObject) Core Core
ICommand, IUndoStack Core Core
UndoStack Features Features.History
AddressableAssetProviderService Services Services.Assets
NativeGallerySaveService Services Services.Gallery
RenderTextureCaptureService Services Services.Capture
ColoringController, PaintRegionCommand Features Features.Coloring
ShapeBuilderController, ShapePieceUI Features Features.ShapeBuilder
HistoryController Features Features.History
ColorBookFlowController Features Features.ColorBookFlow
MenuMascotView, MenuMascotPresenter Features Features.MainMenu
ColorBookSceneRefs, ColorBookLifetimeScope, 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/Drawing/ IDrawingTemplate, IDrawingTemplateCatalog ⚠️
Core/Contracts/Features/Coloring/ IColorPalette ⚠️
Core/Contracts/Features/History/ ICommand, IUndoStack
Core/Contracts/Features/Progression/ IProgressionService ⚠️
Core/Data/Dynamic/Services/Audio/ AudioHandle, AudioRequest
Core/Data/Static/Services/Audio/ SfxCatalogSO
Core/Data/Static/Features/Drawing/ DrawingTemplateSO, ShapeSO ⚠️
Core/Data/Static/Features/Coloring/ ColorPaletteSO ⚠️
Core/Data/Dynamic/Features/Drawing/ ColorRegionDTO ⚠️
Core/Data/Dynamic/Features/Coloring/ PaintCommandDTO ⚠️
Core/Data/Dynamic/Features/Signals/ DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, PieceSnappedSignal, PaperCapturedSignal, PaperSavedSignal ⚠️
Core/Enums/Services/Audio/ AudioChannel, AudioPlayMode, SfxId
Core/Enums/Services/Camera/ CameraType (add CaptureCamera value)
Core/Enums/Services/Scenes/ GameScene

Libs

Module (path) Scripts Status
Libs/FSM/ IState, State, StateMachine
Libs/Installers/ IServiceModule
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/ AnalyticsServiceModule
Services/Analytics/Systems/ FirebaseAnalyticsSystem
Services/Assets/ AddressableAssetProviderService, AddressableLoadHandleTracker
Services/Audio/ AudioService, SfxPlayer
Services/Camera/Service/ CameraService
Services/Camera/Installers/ CameraServiceModule
Services/Inputs/Generated/ GameInputs (Input System codegen)
Services/Inputs/Readers/ InputReaderSO
Services/Inputs/Installers/ InputServiceModule
Services/Scenes/ SceneService
Services/Capture/ RenderTextureCaptureService, CaptureServiceModule ⚠️
Services/Gallery/ NativeGallerySaveService, GalleryServiceModule ⚠️

Features

Module (path) Scripts Status
Features/History/Stack/ UndoStack
Features/History/Installers/ HistoryServiceModule
Features/History/UI/ HistoryButtonsView, HistoryPresenter, HistoryController ⚠️
Features/MainMenu/Installers/ MainMenuModule ⚠️
Features/MainMenu/Systems/ MainMenuModel, MenuMascotPresenter ⚠️
Features/MainMenu/UI/ MenuMascotView, IMenuMascotView ⚠️
Features/DrawingCatalog/Systems/ DrawingCatalogController ⚠️
Features/DrawingCatalog/UI/ DrawingCatalogPresenter, DrawingCatalogView, IDrawingCatalogView, CatalogItemVM ⚠️
Features/DrawingCatalog/Installers/ DrawingCatalogModule ⚠️
Features/ShapeBuilder/Systems/ ShapeBuilderController, ShapePieceFsm, ShapePieceFactory, TrayLayout ⚠️
Features/ShapeBuilder/UI/ ShapePieceUI, SlotMarker, TrayPanel ⚠️
Features/ShapeBuilder/Installers/ ShapeBuilderModule ⚠️
Features/Coloring/Systems/ ColoringController, ColoringStateRepository, ColorRegionFactory ⚠️
Features/Coloring/UI/ ColorRegionView, ColorPaletteView, ColorPalettePresenter ⚠️
Features/Coloring/Commands/ PaintRegionCommand ⚠️
Features/Coloring/Installers/ ColoringModule ⚠️
Features/Capture/Systems/ CaptureController ⚠️
Features/Capture/UI/ CaptureButtonPresenter, SaveToastView ⚠️
Features/Capture/Installers/ CaptureFeatureModule ⚠️
Features/Progression/Systems/ ProgressionService, ProgressionRepository ⚠️
Features/Progression/Installers/ ProgressionModule ⚠️
Features/ColorBookFlow/Systems/ ColorBookFlowController ⚠️
Features/ColorBookFlow/Installers/ ColorBookFlowModule ⚠️

App

Module (path) Scripts Status
App/LifetimeScopes/ RootLifetimeScope
App/LifetimeScopes/ MainMenuLifetimeScope, ColorBookLifetimeScope ⚠️
App/Boot/ AppBoot ⚠️
App/SceneRefs/ ColorBookSceneRefs ⚠️

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 ColorBookSceneRefs. 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)

ShapeBuilderController (Systems)

Spawns shape pieces for the selected template, tracks snap progress, fires ShapeAssembledSignal when complete.

// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory,
//         ColorBookSceneRefs _refs, TrayPanel _tray, IEventBus _bus, ShapeBuilderConfig _cfg
public sealed class ShapeBuilderController : IDisposable {
    public IReadOnlyList<ShapePieceUI> Active { get; }
    public UniTask BuildAsync(string templateId);    // load template, spawn pieces in tray
    public void Reset();                             // clear, unsubscribe
}
// sub: DrawingSelectedSignal
// pub: ShapeAssembledSignal
  • Internal: counts PieceSnappedSignal against expected piece count.
  • Slot discovery: after a drawing's per-drawing prefab is instantiated under ColorBookSceneRefs.PaperRoot, the controller queries GetComponentsInChildren<SlotMarker>() to discover all slots in the loaded drawing. Each slot's _shape field tells which ShapeSO it expects; matching pieces are spawned in the tray.

ShapePieceUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler (UI)

The UI Image that the toddler drags. One prefab; the assigned ShapeSO determines visual identity and snap params.

public sealed class ShapePieceUI : MonoBehaviour,
    IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField] private ShapeSO _shape;     // set by controller at spawn (or in inspector for testing)
    [SerializeField] private Image _image;

    public string PieceId => _shape != null ? _shape.Id : null;
    public ShapeSO Shape => _shape;
    public RectTransform RectTransform => (RectTransform)transform;
    public bool IsLocked { get; private set; }
    public event Action<ShapeSO> Snapped;

    // Controller calls this at spawn time
    public void Assign(ShapeSO shape) {
        _shape = shape;
        ApplyShape();
    }

    private void OnValidate() => ApplyShape();   // editor inspector edits
    private void Awake()      => ApplyShape();   // runtime safety

    private void ApplyShape() {
        if (_shape == null || _image == null) return;
        _image.sprite = _shape.Sprite;
        RectTransform.sizeDelta = _shape.DefaultSizeDelta;
    }
}
  • Handlers forward to ShapePieceFsm (OnDragBegin / OnDrag(localPos) / OnDragEnd).
  • OnDrag converts PointerEventData.position to canvas-local via RectTransformUtility.ScreenPointToLocalPointInRectangle against the piece's parent rect.
  • No collider, no Physics2D anywhere.
  • Identity follows the SO — change _shape in inspector and the visual + ID update on the next OnValidate. At runtime, Assign(...) is the only mutation path.

ShapePieceFsm (Systems)

Per-piece state machine using Libs.FSM. States: InTray → Dragging → Preview → (Snapped | Returning).

// fields: ShapePieceUI _ui, SlotMarker _targetSlot, ShapeBuilderConfig _cfg,
//         IAudioService _audio, IEventBus _bus
public sealed class ShapePieceFsm {
    public void OnDragBegin();
    public void OnDrag(Vector2 canvasLocalPos);
    public void OnDragEnd();
    public bool IsLocked { get; }
}
  • Preview-state update: reactive lerp of anchoredPosition / sizeDelta / localRotation toward _targetSlot's pose, driven by 1 - dist/PreviewRadius. No DOTween while previewing — it's per-frame.
  • Snapped enter: DOTween ease-out to exact slot pose (~0.2s), disable drag, fire PieceSnappedSignal.
  • Returning enter: DOTween back to tray slot (anchoredPosition from TrayLayout).

SlotMarker : MonoBehaviour (UI)

The outline Image under ColorBookSceneRefs.SlotsParent showing where a piece should snap. Authored per drawing — designer places one in the per-drawing prefab at each slot location, with its RectTransform set to the target pose and _shape field assigned to the matching ShapeSO.

public sealed class SlotMarker : MonoBehaviour {
    [SerializeField] private ShapeSO _shape;     // which shape fits here
    [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;
}
  • Pose lives on this MB's RectTransformanchoredPosition, sizeDelta, localRotation directly. No pose data on the SO.
  • Matching: ShapePieceFsm compares _piece.Shape == _slot.Shape (Unity Object reference equality). No string lookups.

TrayPanel : MonoBehaviour (UI)

HUD-side panel (on HUDCanvas) where pieces start out. Has a HorizontalLayoutGroup + ContentSizeFitter. Provides spawn anchors via RectTransform Slot(int index) for the controller.

ShapePieceFactory (Systems)

Pool of ShapePieceUI GameObjects (one prefab) + their associated FSMs. Reused across template loads.

public sealed class ShapePieceFactory {
    // Instantiates the single piece prefab under `parent`, calls Assign(shape) on it,
    // and wires up its FSM with the matching SlotMarker.
    public ShapePieceUI Spawn(ShapeSO shape, SlotMarker targetSlot, RectTransform parent);
    public void Despawn(ShapePieceUI piece);
}
  • One prefab in Content/Gameplay/Prefabs/ShapePiece.prefab is instantiated repeatedly. Visual identity comes from the ShapeSO passed to Assign.

ShapeBuilderInputBinder (Systems)

With UI handlers on the piece itself, an explicit input binder isn't strictly needed — drag events route via the EventSystem. Keep this class only if you need to listen for "any tap outside any piece" (e.g. to dismiss a preview). Otherwise skip.


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 int SelectedIndex { get; private set; }
    public Color CurrentColor => Palette.Colors[SelectedIndex];
    public event Action<int> SelectedIndexChanged;
    public void SetPalette(IColorPalette palette);   // resets SelectedIndex to 0
    public void SelectColor(int index);
}
  • Why a repository: presenter and controller both need to read/write current color; an event-emitting POCO is simpler than wiring two signals.

ColoringController (Systems) — implements IColoringController

Builds and pushes PaintRegionCommand instances; spawns ColorRegionView per region.

// fields: IUndoStack _undo, ColoringStateRepository _state, ColorRegionFactory _factory,
//         ColorBookSceneRefs _refs, IEventBus _bus
public interface IColoringController {
    UniTask SpawnRegionsAsync(IDrawingTemplate template);
    void PaintRegion(ColorRegionView view);          // builds command, pushes to undo stack
    void Clear();
}
// sub: ShapeAssembledSignal (via flow controller, not direct)
// pub: ColorAppliedSignal (via PaintRegionCommand)

Spawns each region as a UI Image under _paper.RegionsParent. No Physics2D.

ColorRegionView : MonoBehaviour, IPointerClickHandler (UI)

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

public sealed class ColorRegionView : MonoBehaviour, IPointerClickHandler {
    public string RegionId { get; }
    public Color Color => _image.color;
    public void Initialize(ColorRegionDTO dto, IColoringController controller);
    public void SetColor(Color c);                   // setter only; no logic
    public void OnPointerClick(PointerEventData e) => _controller.PaintRegion(this);
}
  • Required sprite setup: sprite import inspector → Read/Write Enabled = on, Generate Physics Shape = off (not needed). Image.alphaHitTestMinimumThreshold = 0.5f on Initialize so taps on transparent pixels pass through to the next region below.
  • Sibling order matters for stacked regions — top sibling gets first crack at the click; with alpha hit-test, transparent areas defer correctly.

No ColoringInputBinder class needed. Unity's EventSystem fires OnPointerClick on the topmost UI element under the pointer that consumes it — exactly what we want.

PaintRegionCommand (Commands)

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

ColorPaletteView, ColorPalettePresenter (UI)

Sources in section 25. Presenter binds ColoringStateRepository.SelectedIndexChangedIColorPaletteView.

ColorRegionFactory (Systems)

Mirror of ShapePieceFactory for regions. Pool-friendly.


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
//   ColorBookSceneRefs _refs        (panel roots to enable/disable)
//   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).
  • ColorBookLifetimeScope — planned. Same pattern; installer list includes feature installers + the flow controller installer. Also has a [SerializeField] ColorBookSceneRefs _sceneRefs; and registers it via builder.RegisterInstance(_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

ColorBookSceneRefs : MonoBehaviour (App — planned)

The single source of scene-bound Unity references for the ColorBook scene. Registered in ColorBookLifetimeScope via builder.RegisterInstance(_sceneRefs) so features don't Find things.

public sealed class ColorBookSceneRefs : MonoBehaviour {
    public RectTransform PaperRoot;
    public RectTransform SlotsParent;
    public RectTransform PiecesParent;
    public RectTransform RegionsParent;
    public RectTransform HudRoot;
    public RectTransform TrayPanel;
    public Camera CaptureCamera;          // disabled — used by ICaptureService
    public ColorPaletteView PaletteView;  // optional inline ref
    public HistoryButtonsView HistoryButtons;
}

Replaces the dropped IPaperSurface contract — features that need a paper-area RectTransform read it off this MB.

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
ColorBookLifetimeScope App Scene DI scene refs, feature modules
MainMenuLifetimeScope App Menu scene DI feature modules
ColorBookSceneRefs App Scene-bound RectTransform / Camera holder
MenuMascotView Feature Spine mascot UI (SkeletonGraphic wrapper)
MenuMascotPresenter Feature Drives mascot animations from model events view, model
DrawingCatalogController Feature Grid logic catalog, bus
DrawingCatalogPresenter Feature UI bridge view, controller, catalog
ShapeSO Core asset Authored shape (sprite + snap params, reusable)
ShapeBuilderController Feature Piece spawn + snap tracking catalog, factory, refs, tray, bus, cfg
ShapePieceUI Feature Draggable UI piece prefab; holds [SerializeField] ShapeSO _shape fsm
ShapePieceFsm Feature Per-piece state machine (Tray/Drag/Preview/Snapped/Returning) ui, slot, cfg, audio, bus
SlotMarker Feature Slot outline UI Image at target pose; holds _shape
TrayPanel Feature HUD-side tray with LayoutGroup
ColoringStateRepository Feature Current color model
ColoringController Feature Region spawn + paint cmd undo, state, factory, refs, bus
ColorRegionView Feature Region UI Image + IPointerClickHandler controller
PaintRegionCommand Feature Undoable paint (sets Image.color) view, bus
HistoryController Feature Undo/redo facade undo stack, bus
UndoStack Feature Bounded undo store
CaptureController Feature Capture-then-save orchestration capture svc, gallery svc, bus
ColorBookFlowController Feature Scene FSM (Catalog → Building → Coloring → Done) bus, catalog, builder, coloring, capture, progression
ProgressionService Feature Completion tracking PlayerPrefs lib
EventBus Lib Pub/sub
StateMachine Lib Generic FSM
IServiceModule Lib DI installer interface
ProtectedPlayerPrefs Lib Encrypted PlayerPrefs wrapper
AddressableAssetProviderService Service Addressables wrapper
RenderTextureCaptureService Service One-shot PNG render via CaptureCamera scene refs
NativeGallerySaveService Service Native gallery save (thin plugin shim)
SceneService Service Async scene loads
AudioService, SfxPlayer Service SFX playback assets
CameraService Service Camera registry (MainCamera, UICamera, CaptureCamera)
InputReaderSO Service New Input System reader
FirebaseAnalyticsSystem Service Analytics events

If you add a class not in this table, add it here in the same PR. This table is the cheap mental-model index — keep it honest. See §31b for the full path-by-path inventory.

Today only these rows are real on disk: RootLifetimeScope (App), AddressableAssetProviderService, AudioService/SfxPlayer, CameraService, SceneService, InputReaderSO, FirebaseAnalyticsSystem (Services), UndoStack + HistoryServiceModule (Features.History), plus Libs.* entries (EventBus, StateMachine, IServiceModule, PlayerPrefs lib, UI toggles). Everything else is the target.

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