# Color Book — Architecture Guide
A toddler-targeted (ages 2–6) 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 **hybrid Sprites + Canvas** render strategy.
This document is the canonical reference for the Color Book game's structure. The Bus Game's [Darkmatter Architecture Guide](../Assets/Darkmatter_Architecture_Guide.md) is the parent contract; this doc only adds game-specific structure.
---
## 1. Game Flow
```
App launch
└─ Boot scene (RootLifetimeScope)
└─ MainMenu scene
├─ 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
│ ├─ "Capture" → save to Gallery with paper background
│ └─ "Next" → auto-save + load next drawing
└─ Press "Art Book" → ArtBook scene (gallery viewer)
├─ Grid of saved artworks
├─ View / share / delete
└─ Save to device camera roll
```
---
## 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 Content** — `Code/` 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/ ← (planned — paper rig prefabs)
│
├── 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/ ← misplaced empty folder — move to Contracts/Features/Paper/ when IPaperSurface lands
│ │ └── 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//` or new top-level `Core//` — 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//`, `Data/{Dynamic,Static}/Features//`, `Features//` — 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/Features/Paper/IPaperSurface.cs` | Paper surface contract (canvas roots) |
| `Core/Contracts/Services/Capture/ICaptureService.cs` | Capture service contract |
| `Core/Contracts/Services/Gallery/IGalleryService.cs` | Gallery service contract |
| `Core/Contracts/Features/Drawing/IDrawingTemplate.cs`, `IDrawingTemplateCatalog.cs` | Drawing template contracts |
| `Core/Contracts/Features/Coloring/IColorPalette.cs` | Palette contract |
| `Core/Contracts/Features/History/ICommand.cs`, `IUndoStack.cs` | Undo/redo contracts |
| `Core/Contracts/Features/Progression/IProgressionService.cs` | Progression contract (despite the name, it's a feature contract since it's game-specific) |
| `Core/Data/Static/Features/Drawing/` (DrawingTemplateSO) | Authored drawing data |
| `Core/Data/Static/Features/Coloring/` (ColorPaletteSO) | Authored palette data |
| `Core/Data/Dynamic/Features/Drawing/ColorRegionDTO.cs` | Runtime drawing struct (regions only — pieces use `ShapeSO`) |
| `Core/Data/Static/Features/Drawing/ShapeSO.cs` | Authored shape ScriptableObject (sprite + snap params) |
| `Core/Data/Dynamic/Features/Coloring/PaintCommandDTO.cs` | Runtime coloring struct |
| `Core/Data/Dynamic/Features/Gallery/SavedArtworkDTO.cs` | Runtime gallery struct |
| `Core/Data/Dynamic/Features/Signals/` (DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, ArtworkCapturedSignal, ArtworkSavedSignal) | Cross-feature signal structs |
| `Core/Enums/Services/Camera/CameraType.cs` | Add `ArtCamera` enum value to existing file |
| `Libs/CommandStack/` (+ `Libs.CommandStack.asmdef`) | Bounded undo/redo |
| `Services/Capture/` (+ `Services.Capture.asmdef`) | `RenderTextureCaptureService` drives the disabled `CaptureCamera` |
| `Services/Gallery/` (+ `Services.Gallery.asmdef`) | `FileGalleryService` — PNG + sidecar JSON IO |
| `Features/Paper/` (+ `Features.Paper.asmdef`) | Scene-bound `PaperSurface` MB + module |
| `Features/{MainMenu,DrawingCatalog,ShapeBuilder,Coloring,History,Capture,Progression,ColorBookFlow,ArtBook}/` (+ asmdefs each) | Game features |
| `App/LifetimeScopes/{MainMenu,ColorBook,ArtBook}LifetimeScope.cs` | Per-scene scopes |
| `App/Boot/AppBoot.cs` | Bootstrap entry point |
| `Assets/Darkmatter/Scenes/{MainMenu,ColorBook,ArtBook}.unity` | Scenes |
| `Content/Gameplay/Drawings///{Template.asset, Drawing.prefab, Regions/, PaperBackground.png}` | Authored drawings — `Drawing.prefab` holds `SlotMarker`s at slot poses with `ShapeSO` refs |
| `Content/Gameplay/Shapes/*.asset` | Reusable `ShapeSO`s (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..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 1–2 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..[.]` — the `Darkmatter.` prefix stays on namespaces. Examples already in code:
- `Darkmatter.Core.Contracts.Services.Camera` ([ICameraService.cs](Assets/Darkmatter/Code/Core/Contracts/Services/Camera/ICameraService.cs))
- `Darkmatter.Services.Camera` ([CameraService.cs](Assets/Darkmatter/Code/Services/Camera/Service/CameraService.cs))
- `Darkmatter.Services.Camera.Installers` ([CameraServiceModule.cs](Assets/Darkmatter/Code/Services/Camera/Installers/CameraServiceModule.cs))
- `Darkmatter.Libs.Installers` ([IServiceModule.cs](Assets/Darkmatter/Code/Libs/Installers/IServiceModule.cs))
**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 | Menu presenter, art book entry. |
| `ColorBook.unity` | `ColorBookLifetimeScope` | ⚠️ planned | `PaperRig`, DrawingCatalog, ShapeBuilder, Coloring, History, Capture, ColorBookFlow. |
| `ArtBook.unity` | `ArtBookLifetimeScope` | ⚠️ planned | Gallery presenter, viewer, share. |
Only `Boot.unity` exists today; the three scene scope classes haven't been written yet either (only `RootLifetimeScope` exists in [App/LifetimeScopes/](Assets/Darkmatter/Code/App/LifetimeScopes/)).
Scopes nest: `Root → (MainMenu | ColorBook | ArtBook)`. Services resolved from the root parent. Scene scopes only register their own features.
### Boot chain (planned)
No `AppBoot` class exists yet — today `RootLifetimeScope` only registers services and stops there. When `AppBoot` is added (as an `IAsyncStartable` registered via `builder.RegisterEntryPoint()`), it should run once, in order:
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) |
|---|---|---|
| Files in `IPaperRig` / `IArtInputBridge` | 2 contracts + ~80 lines of math | gone |
| 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`.
```csharp
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 Pieces { get; } // shapes that get spawned in the tray for this drawing
IReadOnlyList 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.
```csharp
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 `ShapeSO`s 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`.
```csharp
namespace Darkmatter.Core.Contracts.Features.Coloring;
public interface IColorPalette {
string Id { get; }
IReadOnlyList Colors { get; }
}
public readonly struct PaintCommandDTO {
public string RegionId { get; }
public Color FromColor { get; }
public Color ToColor { get; }
}
```
### Paper (canvas surface root)
> Contracts live in `Darkmatter.Core.Contracts.Features.Paper`. Files at `Core/Contracts/Features/Paper/`.
```csharp
namespace Darkmatter.Core.Contracts.Features.Paper;
public interface IPaperSurface {
RectTransform Root { get; } // PaperPanel — parent of slots/pieces/regions
RectTransform SlotsParent { get; } // parent for slot Images
RectTransform PiecesParent { get; } // parent for piece Images
RectTransform RegionsParent { get; } // parent for region Images
float DesignHalfSize { get; } // half of the reference square (e.g. 1024)
}
```
- Implemented by `PaperSurface : MonoBehaviour` in the ColorBook scene (sits on the `PaperPanel` GameObject).
- All paper-side features (`Coloring`, `ShapeBuilder`, `Capture`) parent their UI under one of these `RectTransform` slots and use canvas-local coords throughout.
- No `IPaperRig`. No `IArtInputBridge`. Input runs through Unity's `EventSystem` directly on the UI children.
### History
> Contracts in `Darkmatter.Core.Contracts.Features.History`.
```csharp
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();
}
```
### Gallery & Capture
> `IGalleryService` is a Service contract → `Darkmatter.Core.Contracts.Services.Gallery`. `SavedArtworkDTO` is a runtime data struct → `Darkmatter.Core.Data.Dynamic.Features.Gallery`. `ICaptureService` → `Darkmatter.Core.Contracts.Services.Capture`. `CaptureAsync` takes no args — implementation owns the `CaptureCamera` reference and renders the `PaperUI` layer to a one-shot RT.
```csharp
namespace Darkmatter.Core.Data.Dynamic.Features.Gallery;
public readonly struct SavedArtworkDTO {
public string Id { get; }
public string TemplateId { get; }
public DateTime CreatedUtc { get; }
public string ImagePath { get; } // persistentDataPath PNG
public string ThumbnailPath { get; }
}
namespace Darkmatter.Core.Contracts.Services.Gallery;
public interface IGalleryService {
UniTask SaveAsync(byte[] png, string templateId);
UniTask> ListAsync();
UniTask LoadFullAsync(string artworkId);
UniTask LoadThumbnailAsync(string artworkId);
UniTask DeleteAsync(string artworkId);
// Newest captured thumbnail for the given template, or null if none exist.
// Catalog cells fall back to IDrawingTemplate.DefaultThumbnail when this returns null.
UniTask GetLatestThumbnailAsync(string templateId);
}
namespace Darkmatter.Core.Contracts.Services.Capture;
public interface ICaptureService {
// Allocates a temp RT, renders the CaptureCamera once (PaperUI layer only),
// ReadPixels into a Texture2D, encodes PNG, releases the RT.
UniTask CaptureAsync();
}
```
`ICaptureService` owns a reference to `CaptureCamera` (a disabled `Camera` in the ColorBook scene). The capture camera's `cullingMask` is set to `PaperUI` so the HUD physically cannot appear in the PNG. The paper background is part of `PaperPanel`, so it's already in the right layer — no compositing pass.
### Signals
> Signal structs live in `Darkmatter.Core.Data.Dynamic.Features.Signals` (runtime data, cross-feature).
```csharp
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 ArtworkCapturedSignal {
public string ArtworkId { get; }
}
public readonly struct ArtworkSavedSignal {
public SavedArtworkDTO Artwork { get; }
}
```
---
## 9. Feature Responsibilities
### `DrawingCatalog`
- Loads the catalog manifest (list of available template IDs).
- Presents a scrollable grid of thumbnails (Canvas).
- **Each cell shows the latest captured thumbnail** for that template via `IGalleryService.GetLatestThumbnailAsync(templateId)`. If the user has no captures yet for that template, falls back to `IDrawingTemplate.DefaultThumbnail` (the authored sprite).
- Subscribes to `ArtworkSavedSignal` — re-fetches the cell's thumbnail for the affected `TemplateId` so the grid reflects user progress without a reopen.
- On select → fires `DrawingSelectedSignal(templateId)` and unloads the catalog UI.
### `Paper`
- Scene-scoped infrastructure. Lives in `ColorBook.unity` only.
- Owns `PaperSurface` (MonoBehaviour) on the `PaperPanel` GameObject. Implements `IPaperSurface`, exposes `Root`, `SlotsParent`, `PiecesParent`, `RegionsParent`, `DesignHalfSize`.
- Registered in `ColorBookLifetimeScope` via `PaperSurfaceModule`. Other features resolve `IPaperSurface` from DI when they need to parent their UI under one of the role-specific `RectTransform`s.
- No render-target ownership. No input bridge. No coordinate conversion. The paper *is* the canvas children — nothing more.
### `ShapeBuilder`
- Listens to `DrawingSelectedSignal`.
- Loads template, spawns UI `Image` per piece under either `IPaperSurface.PiecesParent` or the HUD tray (depending on the FSM start state — usually tray).
- 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: 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 `IPaperSurface.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 singleton `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 "Capture" button.
- Calls `ICaptureService.CaptureAsync()` → PNG bytes. Implementation owns the disabled `CaptureCamera`, sets its `targetTexture` to a temp RT, calls `Render()` once, reads pixels, releases.
- Hands bytes to `IGalleryService.SaveAsync(...)`.
- Fires `ArtworkCapturedSignal` then `ArtworkSavedSignal`.
- Shows a quick "saved!" toast with a thumbnail of the new entry.
### `Progression`
- Tracks completed template IDs and the in-progress draft.
- On "Next" button: silently runs Capture pipeline (auto-save), marks current as completed, calls `IDrawingTemplateCatalog.NextUnseen()`.
- Persists JSON via `IPersistenceService`.
### `ColorBookFlow`
- The only orchestrator inside ColorBook scope.
- Subscribes to flow-relevant signals and toggles UI panels (catalog → builder → coloring).
- Coordinates "Next" sequence: `IProgressionService.MarkCompleted` → `ICaptureService` autosave → `IDrawingTemplateLoader.Release(currentId)` → load next.
- Built as a small FSM (`Catalog → Building → Coloring → Done`).
### `ArtBook`
- Separate scene.
- `GalleryPresenter` calls `IGalleryService.ListAsync()` → grid of thumbnails.
- Tap → fullscreen view, share-sheet button, delete.
- Saved-to-device-camera-roll uses an optional platform plugin behind `IExternalShareService` (Core contract).
---
## 10. Addressables Strategy
Mirror the Bus Game pattern via `IAssetProviderService`.
### What ships through Addressables
| Asset | Why |
|---|---|
| `DrawingTemplate` ScriptableObject (per drawing) | Many; load on demand. |
| Shape piece sprites | Only needed when active. |
| Region sprites + polygon paths | 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. |
### 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)
Palettes (label: palette)
Audio_UI (label: sfx, ui)
Audio_Coloring (label: sfx, coloring)
```
### Lifecycle
- Catalog loads **thumbnail handles only** (cheap).
- On select → full template loads (pieces + regions + paper).
- On "Next" or scene exit → previous template `Release`d 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
Two distinct stores, each behind its own Core contract.
### `IPersistenceService` (JSON / PlayerPrefs)
Holds:
- Completed template IDs.
- Last opened drawing.
- Audio volume, simple settings.
Path: `Application.persistentDataPath/save.json`.
### `IGalleryService` (file IO)
Holds user artworks:
```
persistentDataPath/Gallery/
├── {guid}.png full-res render (~2048×2048)
├── {guid}.thumb.png 256×256 for grid
└── {guid}.json SavedArtworkDTO sidecar
```
- Writes are atomic (`.tmp` → rename).
- `ListAsync` enumerates sidecar JSONs sorted by `CreatedUtc desc`.
- Thumbnail generation happens once at save time on a worker thread.
---
## 12. Capture Pipeline
A dedicated `CaptureCamera` lives in the scene, disabled by default. It renders only the `PaperUI` layer into a temp `RenderTexture` when capture fires.
```
[Capture button or Next button]
│
▼
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
▼
IGalleryService.SaveAsync(bytes, templateId)
│
├─ Write .png atomically
├─ Generate + write thumbnail
├─ Write sidecar JSON
└─ return SavedArtworkDTO
▼
EventBus.Publish(new ArtworkSavedSignal(dto))
```
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.
---
## 13. Communication Rules
| Use case | Mechanism |
|---|---|
| Load template, return result | Direct DI call (`IDrawingTemplateLoader.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.
- **View** — `MonoBehaviour`, only setters (`SetColors(IReadOnlyList)`).
- **Presenter** — pure C#, subscribes to model events, calls view setters.
### Inspector bridge
For palette icons, undo buttons, region prefabs:
```csharp
[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, but for `SavedArtworkDTO` serialization, yes. |
| `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 |
|---|---|---|
| `DrawingCatalog` | — | `DrawingSelectedSignal` |
| `ShapeBuilder` | `DrawingSelectedSignal` | `ShapeAssembledSignal` |
| `Coloring` | `ShapeAssembledSignal` | `ColorAppliedSignal` |
| `History` | `DrawingSelectedSignal` (to clear) | — |
| `Capture` | — (button-driven) | `ArtworkCapturedSignal`, `ArtworkSavedSignal` |
| `Progression` | `ArtworkSavedSignal` | — |
| `ColorBookFlow` | `ShapeAssembledSignal`, `ArtworkSavedSignal` | — |
| `ArtBook (Gallery)` | `ArtworkSavedSignal` (if open) | — |
---
Maintained alongside the [Darkmatter Architecture Guide](../Assets/Darkmatter_Architecture_Guide.md). 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 `.` 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 |
|---|---|---|
| `Libs.CommandStack` | `Libs/CommandStack/` | `Core` |
| `Services.Capture` | `Services/Capture/` | `Core`, `Libs.Installers` |
| `Services.Gallery` | `Services/Gallery/` | `Core`, `Libs.Installers` |
| `Features.Paper` | `Features/Paper/` | `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`, `Libs.CommandStack` |
| `Features.History` | `Features/History/` | `Core`, `Libs.Installers`, `Libs.CommandStack` |
| `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` |
| `Features.ArtBook` | `Features/ArtBook/` | `Core`, `Libs.Installers` |
**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](Assets/Darkmatter/Code/App/LifetimeScopes/RootLifetimeScope.cs) already does today.
### `RootLifetimeScope` (Boot scene, persists forever) — actual code
```csharp
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
```csharp
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[]`:
- `PaperRigModule`
- `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](Assets/Darkmatter/Code/Services/Camera/Installers/CameraServiceModule.cs) and [InputServiceModule.cs](Assets/Darkmatter/Code/Services/Inputs/Installers/InputServiceModule.cs) — a `MonoBehaviour` implementing `IServiceModule.Register`.
```csharp
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(Lifetime.Scoped).AsSelf();
builder.Register(Lifetime.Scoped)
.As()
.AsSelf();
builder.Register(Lifetime.Scoped).AsSelf();
builder.RegisterEntryPoint();
}
}
```
Convention:
- One `IServiceModule` per feature, named `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](Assets/Darkmatter/Code/Libs/Installers/IServiceModule.cs).
- 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(_foo)` them. See the planned `PaperRigModule` in §32.5b for an example.
---
## 23. Command Pattern — `PaintRegionCommand`
```csharp
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:
```csharp
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`
```csharp
namespace Darkmatter.Lib.CommandStack;
public sealed class BoundedUndoStack : IUndoStack {
private readonly Deque _undo = new();
private readonly Stack _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` keeps the oldest entry cheap to evict when the cap fires.
---
## 25. View / Presenter Pair — Color Palette
### View (MonoBehaviour, setters only)
```csharp
namespace Darkmatter.Features.Coloring.UI;
public sealed class ColorPaletteView : MonoBehaviour, IColorPaletteView {
[SerializeField, RequireInterface(typeof(IColorButtonView))]
private MonoBehaviour[] _buttonsRaw;
private IColorButtonView[] _buttons;
public event Action OnColorButtonClicked;
private void Awake() {
_buttons = _buttonsRaw.Cast().ToArray();
for (var i = 0; i < _buttons.Length; i++) {
var idx = i;
_buttons[i].OnClicked += () => OnColorButtonClicked?.Invoke(idx);
}
}
public void SetColors(IReadOnlyList 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#)
```csharp
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.
```csharp
// In ShapePieceFsm.OnDragEnd (state: Dragging or Preview):
public void OnDragEnd() {
var pieceRT = _ui.RectTransform;
var slotRT = _targetSlot.RectTransform;
var d = Vector2.Distance(pieceRT.anchoredPosition, slotRT.anchoredPosition);
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 `IPaperSurface.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. 80–120), 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 `ParticleSystem`s under PaperPanel — they don't render in UI canvases without `UIParticleSystem` or similar packages.
---
## 28. SavedArtwork JSON Schema
```json
{
"id": "f3a8e2d4-...",
"templateId": "animals/elephant",
"createdUtc": "2026-05-26T16:42:11Z",
"imagePath": "Gallery/f3a8e2d4-....png",
"thumbnailPath": "Gallery/f3a8e2d4-....thumb.png",
"regions": [
{ "regionId": "body", "color": "#FFB347" },
{ "regionId": "ears", "color": "#FF6961" }
]
}
```
`regions[]` lets the gallery reopen an artwork for further edits in a future version (out of scope v1, but the schema reserves the field now to avoid migration later).
Paths are **relative** to `persistentDataPath`. Never store absolute paths — they change between OS updates on some platforms.
---
## 29. Boot & Error Handling
> **Status: not implemented.** No `AppBoot` class exists. Today, [RootLifetimeScope.cs](Assets/Darkmatter/Code/App/LifetimeScopes/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())
├─ 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. Open `Assets/Darkmatter/Scenes/Boot.unity` (currently the only scene wired).
3. Inspect the `RootLifetimeScope` GameObject — confirm its `serviceModules[]` list references the child installer MonoBehaviours (`AudioServiceModule`, `CameraServiceModule`, `InputServiceModule`, etc.).
4. Hit Play from `Boot.unity`. Other scenes (`MainMenu`, `ColorBook`, `ArtBook`) don't exist yet — they're listed in §6 / §4c as planned work.
5. 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.
6. When drawings are authored: duplicate the template folder under `Content/Gameplay/Drawings///`, edit `Template.asset` (pieces + regions), add to the appropriate Addressables group.
7. 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` |
| `IPaperSurface` | Core | `Core` |
| `ICommand`, `IUndoStack` | Core | `Core` |
| `BoundedUndoStack` | Libs | `Libs.CommandStack` |
| `AddressableAssetProviderService` | Services | `Services.Assets` |
| `FileGalleryService` | Services | `Services.Gallery` |
| `RenderTextureCaptureService` | Services | `Services.Capture` |
| `PaperSurface`, `PaperSurfaceModule` | Features | `Features.Paper` |
| `ColoringController`, `PaintRegionCommand` | Features | `Features.Coloring` |
| `ShapeBuilderController`, `ShapePieceView` | Features | `Features.ShapeBuilder` |
| `HistoryController` | Features | `Features.History` |
| `ColorBookFlowController` | Features | `Features.ColorBookFlow` |
| `GalleryPresenter`, `GalleryGridView` | Features | `Features.ArtBook` |
| `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.
---
## 32. Class Reference (Detailed)
> **Status: target spec, mostly unimplemented.** Of everything below, only the following Service classes exist on disk today: `AddressableAssetProviderService`, `AudioService` / `SfxPlayer`, `CameraService`, `SceneService`, `InputReaderSO`, `FirebaseAnalyticsSystem`. Everything else (Paper, Drawing, Coloring, History, Capture, Gallery, Progression, ColorBookFlow, ArtBook, AppBoot) 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//`, `Core/Contracts/Services//`, `Core/Data/Dynamic/Features//`, `Features///`, `Services///`.
---
### 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.
```csharp
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 Pieces { get; } // shapes spawned in tray (reusable across drawings)
IReadOnlyList 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 shows the latest user-captured thumbnail (via `IGalleryService.GetLatestThumbnailAsync`) when available, falling back to `DefaultThumbnail` when the user hasn't completed this template yet. The template itself stays immutable.
#### `IDrawingTemplateCatalog` *(Core/Contracts/Features/Drawing — planned)*
Authority on which drawings exist, completion state, and "next" selection.
```csharp
public interface IDrawingTemplateCatalog {
UniTask InitializeAsync(); // pulls manifest from Addressables
IReadOnlyList AllTemplateIds { get; }
UniTask GetThumbnailAsync(string id); // cheap; for grid
UniTask 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`.
```csharp
public interface IColorPalette {
string Id { get; }
IReadOnlyList Colors { get; } // 6–10 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)*
Persistent store of saved artwork PNGs.
```csharp
public interface IGalleryService {
UniTask SaveAsync(byte[] png, string templateId);
UniTask> ListAsync(); // sorted newest first
UniTask LoadFullAsync(string artworkId); // fullscreen view
UniTask LoadThumbnailAsync(string artworkId);
UniTask DeleteAsync(string artworkId);
// Newest captured thumbnail for the given template, or null if the user has
// no captures for it. Used by the catalog grid; null → caller falls back to
// IDrawingTemplate.DefaultThumbnail.
UniTask GetLatestThumbnailAsync(string templateId);
}
```
For v1 the latest-thumbnail lookup can list-and-filter (tens of templates max). Add an in-memory `Dictionary` cache later if perf becomes a concern.
#### `ICaptureService` *(Core/Contracts/Services/Capture — planned)*
Snapshots the paper RT to a PNG blob. No arguments — dimensions and content come from `IPaperRig.Surface`.
```csharp
public interface ICaptureService {
UniTask CaptureAsync();
}
```
#### `IPaperSurface` *(Core/Contracts/Features/Paper — planned)*
The paper is just RectTransform real estate. Features parent their UI children under one of the role-specific roots.
```csharp
public interface IPaperSurface {
RectTransform Root { get; } // PaperPanel itself
RectTransform SlotsParent { get; } // child of Root — for ShapeBuilder slot outlines
RectTransform PiecesParent { get; } // child of Root — for ShapeBuilder pieces (post-snap)
RectTransform RegionsParent { get; } // child of Root — for Coloring region Images
float DesignHalfSize { get; } // half the reference resolution side, in canvas units
}
```
No render-target ownership. No coordinate conversion. The contract just hands out RectTransforms so features don't have to `Find` them.
#### `IProgressionService` *(Core/Contracts/Features/Progression — planned)*
Tracks which templates the child has completed and what they last opened.
```csharp
public interface IProgressionService {
UniTask LoadAsync();
UniTask SaveAsync();
IReadOnlyCollection 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.
```csharp
public interface IAssetProviderService {
UniTask InitializeAsync();
UniTask LoadAsync(string address) where T : UnityEngine.Object;
UniTask> LoadByLabelAsync(string label) where T : UnityEngine.Object;
void Release(string address);
void ReleaseAll();
}
```
#### `IEventBus` *(Libs/Observer — ✅ exists; note the folder is `Observer`, not `EventBus`)*
```csharp
public interface IEventBus {
void Publish(T signal) where T : struct;
IDisposable Subscribe(Action 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` and ref-count handles by address.
- **State:** `Dictionary` keyed by address.
- **Notes:** `Release(address)` decrements; `ReleaseAll()` for scene teardown. Initialization must complete before any other service may load.
#### `FileGalleryService` *(Services/Gallery — planned)*
Implements `IGalleryService`.
```csharp
// fields:
// IPathProvider _paths (wraps Application.persistentDataPath for tests)
// IThumbnailGenerator _thumb (downscale + encode)
// IEventBus _bus
// pub: ArtworkSavedSignal, ArtworkDeletedSignal
```
- **Save flow:** write `{guid}.png.tmp` → fsync → rename; generate thumbnail on a worker; write sidecar JSON last (so partial saves are detectable by absence of JSON).
- **List flow:** enumerate `*.json` in `Gallery/`, deserialize, sort by `CreatedUtc desc`.
- **Delete flow:** delete png + thumb + json; missing files ignored (idempotent).
#### `RenderTextureCaptureService` *(Services/Capture — planned)*
Implements `ICaptureService`. Drives the scene's disabled `CaptureCamera` once per capture.
```csharp
// 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.
#### `JsonPersistenceService` *(Services/Persistence — planned; today `Libs/PlayerPrefs` covers small-key state)*
Implements `IPersistenceService` (small JSON blob; not the gallery).
```csharp
public interface IPersistenceService {
UniTask LoadAsync(string key) where T : class, new();
UniTask SaveAsync(string key, T value);
}
```
- **Path:** `Application.persistentDataPath/save.json`.
- **Format:** single JSON object keyed by `key` so multiple services can share one file.
- **Atomicity:** write to `save.json.tmp` → rename.
#### `SceneService` *(Services/Scenes — ✅ exists)*
Implements `ISceneService`. Wraps `SceneManager.LoadSceneAsync` with `UniTask` plus a fade-curtain.
```csharp
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.
```csharp
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` populated at preload.
#### `InputReaderSO` *(Services/Inputs/Readers — ✅ exists)*
ScriptableObject wrapping the new Input System; exposes events.
```csharp
public interface IInputReader {
event Action PointerDown; // world pos
event Action PointerDrag;
event Action PointerUp;
}
```
- **Why an SO:** assignable in inspector and survives scene loads, but still resolvable via DI (`builder.RegisterInstance(_inputReader).As()`).
---
### 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` of `Action` 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/](Assets/Darkmatter/Code/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.
```csharp
public sealed class Fsm where TState : struct, Enum {
public TState Current { get; }
public event Action 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.4 Feature — `DrawingCatalog` *(planned)*
#### `DrawingCatalogController` *(Systems)*
Headless logic. Owns the list of template IDs visible in the grid.
```csharp
// fields: IDrawingTemplateCatalog _catalog, IEventBus _bus
public sealed class DrawingCatalogController : IAsyncStartable {
public IReadOnlyList 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.
```csharp
// 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.
```csharp
public interface IDrawingCatalogView {
event Action OnItemClicked;
void SetItems(IReadOnlyList 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.
```csharp
// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory,
// IPaperSurface _paper, TrayPanel _tray, IEventBus _bus, ShapeBuilderConfig _cfg
public sealed class ShapeBuilderController : IDisposable {
public IReadOnlyList Active { get; }
public UniTask BuildAsync(string templateId); // load template, spawn pieces in tray
public void Reset(); // clear, unsubscribe
}
// sub: DrawingSelectedSignal
// pub: ShapeAssembledSignal
```
- **Internal:** counts `PieceSnappedSignal` against expected piece count.
- **Slot discovery:** after a drawing's per-drawing prefab is instantiated under `IPaperSurface.Root`, the controller queries `GetComponentsInChildren()` to discover all slots in the loaded drawing. Each slot's `_shape` field tells which `ShapeSO` it expects; matching pieces are spawned in the tray.
#### `ShapePieceUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler` *(UI)*
The UI Image that the toddler drags. One prefab; the assigned `ShapeSO` determines visual identity and snap params.
```csharp
public sealed class ShapePieceUI : MonoBehaviour,
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 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)`.
```csharp
// fields: ShapePieceUI _ui, SlotMarker _targetSlot, ShapeBuilderConfig _cfg,
// IAudioService _audio, IEventBus _bus
public sealed class ShapePieceFsm {
public void OnDragBegin();
public void OnDrag(Vector2 canvasLocalPos);
public void OnDragEnd();
public bool IsLocked { get; }
}
```
- **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` on `IPaperSurface.SlotsParent` showing where a piece should snap. Authored per drawing — designer places one in the scene at each slot location, with its `RectTransform` set to the target pose and `_shape` field assigned to the matching `ShapeSO`.
```csharp
public sealed class SlotMarker : MonoBehaviour {
[SerializeField] private ShapeSO _shape; // which shape fits here
[SerializeField] private Image _outline; // optional faint outline UI
public ShapeSO Shape => _shape;
public string SlotId => _shape != null ? _shape.Id : null;
public RectTransform RectTransform => (RectTransform)transform;
}
```
- **Pose lives on this MB's `RectTransform`** — `anchoredPosition`, `sizeDelta`, `localRotation` directly. No pose data on the SO.
- **Matching:** `ShapePieceFsm` compares `_piece.Shape == _slot.Shape` (Unity Object reference equality). No string lookups.
#### `TrayPanel : MonoBehaviour` *(UI)*
HUD-side panel (on `HUDCanvas`) where pieces start out. Has a `HorizontalLayoutGroup` + `ContentSizeFitter`. Provides spawn anchors via `RectTransform Slot(int index)` for the controller.
#### `ShapePieceFactory` *(Systems)*
Pool of `ShapePieceUI` GameObjects (one prefab) + their associated FSMs. Reused across template loads.
```csharp
public sealed class ShapePieceFactory {
// Instantiates the single piece prefab under `parent`, calls Assign(shape) on it,
// and wires up its FSM with the matching SlotMarker.
public ShapePieceUI Spawn(ShapeSO shape, SlotMarker targetSlot, RectTransform parent);
public void Despawn(ShapePieceUI piece);
}
```
- One prefab in `Content/Gameplay/Prefabs/ShapePiece.prefab` is instantiated repeatedly. Visual identity comes from the `ShapeSO` passed to `Assign`.
#### `ShapeBuilderInputBinder` *(Systems)*
With UI handlers on the piece itself, an explicit input binder isn't strictly needed — drag events route via the EventSystem. Keep this class only if you need to listen for "any tap outside any piece" (e.g. to dismiss a preview). Otherwise skip.
---
### 32.5b Feature — `Paper` *(planned)*
A tiny feature. Just exposes the paper RectTransforms via DI so consumers don't `Find` them.
#### `PaperSurface : MonoBehaviour, IPaperSurface` *(Surface)*
Scene-bound component placed on the `PaperPanel` GameObject in `ColorBook.unity`.
```csharp
// inspector fields:
// RectTransform _slotsParent
// RectTransform _piecesParent
// RectTransform _regionsParent
// float _designHalfSize = 1024f // half of 2048 reference resolution
public sealed class PaperSurface : MonoBehaviour, IPaperSurface {
public RectTransform Root => (RectTransform)transform;
public RectTransform SlotsParent => _slotsParent;
public RectTransform PiecesParent => _piecesParent;
public RectTransform RegionsParent => _regionsParent;
public float DesignHalfSize => _designHalfSize;
}
```
- No `Awake` / `OnDestroy` logic. The component is a pure pass-through to the RectTransforms.
- All four child rects share the same anchors and size as `Root` (anchored center, stretched to fill).
#### `PaperSurfaceModule : MonoBehaviour, IServiceModule` *(Installers)*
Scene-scoped installer. Dragged into `ColorBookLifetimeScope.sceneModules[]`.
```csharp
// inspector fields:
// PaperSurface _surface
public void Register(IContainerBuilder builder) {
builder.RegisterInstance(_surface);
}
```
Registers as `Instance` because `PaperSurface` is a MonoBehaviour already in the scene. Lifetime tied to the scene.
---
### 32.6 Feature — `Coloring` *(planned)*
#### `ColoringStateRepository` *(Repository)*
In-memory model. Owns "currently selected color" and the palette in use.
```csharp
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 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.
```csharp
// fields: IUndoStack _undo, ColoringStateRepository _state, ColorRegionFactory _factory,
// IPaperSurface _paper, 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`.
```csharp
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.SelectedIndexChanged` ↔ `IColorPaletteView`.
#### `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).
```csharp
// 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.
```csharp
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 "Capture" button. Stateless other than guarding against concurrent captures.
```csharp
// fields: ICaptureService _capture, IGalleryService _gallery, IEventBus _bus
public sealed class CaptureController {
public bool IsCapturing { get; }
public UniTask CaptureCurrentAsync(string templateId);
}
// pub: ArtworkCapturedSignal (mid-flow), ArtworkSavedSignal (post-save)
```
- **Flow:** `_capture.CaptureAsync()` → `_gallery.SaveAsync(bytes, templateId)` → publish signals.
- **Concurrency:** sets `IsCapturing = true` on entry; UI binds button enabled to `!IsCapturing` to prevent double-tap.
- **No camera or sprite args** — the implementation owns a reference to the disabled `CaptureCamera` and drives the one-shot render internally.
#### `CaptureButtonPresenter` *(UI)*
Wires button click → `CaptureController.CaptureCurrentAsync`. Disables button while in progress. Shows toast on `ArtworkSavedSignal`.
---
### 32.9 Feature — `Progression` *(planned)*
#### `ProgressionService` *(Systems)* — implements `IProgressionService`
The only place that knows what "completed" means.
- **Persistence:** delegates to `IPersistenceService` 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`.
```csharp
// fields:
// IEventBus _bus
// IDrawingTemplateCatalog _catalog
// ShapeBuilderController _builder
// IColoringController _coloring
// CaptureController _capture
// IProgressionService _progression
// ColorBookSceneRefs _refs (panel roots to enable/disable)
// Fsm _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.11 Feature — `ArtBook` *(planned)*
#### `GalleryPresenter` *(UI)* — `IAsyncStartable, IDisposable`
Lists artworks, opens fullscreen view, deletes, shares.
```csharp
// fields: IGalleryService _gallery, IGalleryView _view, IExternalShareService _share, IEventBus _bus
```
- **Start:** `_gallery.ListAsync()` → `_view.SetItems(...)`.
- **Subscribes** to `ArtworkSavedSignal` to live-refresh if the user pops back in.
#### `IGalleryView` *(UI)*
```csharp
public interface IGalleryView {
event Action OnArtworkTapped;
event Action OnDeleteRequested;
event Action OnShareRequested;
void SetItems(IReadOnlyList items);
void ShowFullscreen(Texture2D full);
void HideFullscreen();
}
```
#### `IExternalShareService` *(Core)*
Platform plugin shim (iOS Photos / Android MediaStore).
```csharp
public interface IExternalShareService {
UniTask SaveToCameraRollAsync(byte[] png);
UniTask ShareAsync(byte[] png, string subject);
}
```
---
### 32.12 App Layer
#### `AppBoot` *(App/Boot — planned; folder doesn't exist yet)* — `IAsyncStartable`
Single entry point. Steps in section 29.
```csharp
// fields: IAssetProviderService _assets, IPersistenceService _persist, IProgressionService _progress,
// IAudioService _audio, ISceneService _scenes, BootConfig _cfg
public sealed class AppBoot : IAsyncStartable {
public UniTask StartAsync(CancellationToken ct);
}
```
#### LifetimeScopes
- `RootLifetimeScope` — ✅ exists ([source](Assets/Darkmatter/Code/App/LifetimeScopes/RootLifetimeScope.cs)). 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 `PaperRigModule`, feature installers, and the flow controller installer.
- `ArtBookLifetimeScope` — planned.
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, optional)*
Aggregates HUD-side scene-bound Unity references that don't fit any single feature. Examples: `Camera captureCamera`, `RectTransform hudRoot`, `ColorPaletteView paletteView`, `HistoryButtonsView historyView`, `TrayPanel trayPanel`. Registered in `ColorBookLifetimeScope` via `builder.RegisterInstance(_sceneRefs)` so features don't `Find` things.
> Paper-side refs are subsumed by `IPaperSurface` (which exposes the four canvas RectTransform roots). `CaptureCamera` could either live here or be exposed via its own dedicated `ICaptureCameraSource` contract — for v1, putting it on `ColorBookSceneRefs` is fine.
#### `IServiceModule` *(Libs/Installers — ✅ exists)*
```csharp
public interface IServiceModule {
void Register(IContainerBuilder builder);
}
```
Implemented as `MonoBehaviour` per feature/service so scopes can drag them in the inspector ([CameraServiceModule.cs](Assets/Darkmatter/Code/Services/Camera/Installers/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, persist, progression, scenes |
| `RootLifetimeScope` | App | Root DI | configs |
| `ColorBookLifetimeScope` | App | Scene DI | scene refs, installers |
| `DrawingCatalogController` | Feature | Grid logic | catalog, bus |
| `DrawingCatalogPresenter` | Feature | UI bridge | view, controller, catalog |
| `ShapeBuilderController` | Feature | Piece spawn + snap tracking | catalog, factory, paper, tray, bus, cfg |
| `ShapeSO` | Core asset | Authored shape (sprite + snap params) | — |
| `ShapePieceUI` | Feature | Draggable UI piece prefab; holds `[SerializeField] ShapeSO _shape` | fsm |
| `ShapePieceFsm` | Feature | Per-piece state machine | ui, slot, cfg, audio, bus |
| `SlotMarker` | Feature | Slot outline UI Image at target pose | — |
| `TrayPanel` | Feature | HUD-side tray with LayoutGroup | — |
| `ColoringStateRepository` | Feature | Current color model | — |
| `ColoringController` | Feature | Region spawn + paint cmd | undo, state, factory, paper, bus |
| `ColorRegionView` | Feature | Region UI Image + IPointerClickHandler | controller |
| `PaintRegionCommand` | Feature | Undoable paint (sets Image.color) | view, bus |
| `PaperSurface` | Feature | IPaperSurface (Root + child rects) | — |
| `PaperSurfaceModule` | Feature | DI registration | surface |
| `HistoryController` | Feature | Undo/redo facade | undo stack, bus |
| `CaptureController` | Feature | Capture+save orchestration | capture svc, gallery, bus |
| `ColorBookFlowController` | Feature | Scene FSM | bus, catalog, builder, coloring, capture, progression |
| `GalleryPresenter` | Feature | Art book listing | gallery, share, view, bus |
| `BoundedUndoStack` | Lib | Capped undo store | — |
| `EventBus` | Lib | Pub/sub | — |
| `Fsm` | Lib | Generic FSM | — |
| `AddressableAssetProviderService` | Service | Addressables wrapper | — |
| `FileGalleryService` | Service | Gallery file IO | paths, thumb gen, bus |
| `RenderTextureCaptureService` | Service | PNG render from rig.Surface | paper rig |
| `JsonPersistenceService` | Service | Settings/progression IO | — |
| `SceneService` | Service | Async scene loads | — |
| `AudioService` | Service | SFX playback | assets |
| `ProgressionService` | Service | Completion tracking | persistence |
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.
> Today only these rows are real on disk: `RootLifetimeScope` (App), `AddressableAssetProviderService` (Service), `AudioService` (Service), `CameraService` (Service), `SceneService` (Service), `InputReaderSO` (Service), plus the Firebase analytics class, plus the `Libs.*` entries (`EventBus`, `StateMachine`, `IServiceModule`, PlayerPrefs lib, UI toggles). Everything else is the target.