Files
Colorbook/Readme.md
Savya Bikram Shah d3d16439b6 Readme asset
2026-05-27 13:13:47 +05:45

1935 lines
89 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 **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/<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/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/ShapePieceDTO.cs`, `ColorRegionDTO.cs` | Runtime drawing structs |
| `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/<theme>/<id>/{Template.asset, Pieces/, Regions/, PaperBackground.png}` | Authored drawings (under existing `Content/Gameplay/` 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:
- `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<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) |
|---|---|---|
| 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 Thumbnail { get; }
Sprite PaperBackground { get; }
IReadOnlyList<ShapePieceDTO> Pieces { get; }
IReadOnlyList<ColorRegionDTO> Regions { get; }
}
public readonly struct ShapePieceDTO {
public string PieceId { get; }
public Sprite Sprite { get; } // assigned to Image.sprite
public Vector2 SlotAnchoredPosition { get; } // canvas units, relative to SlotsParent
public Vector2 SlotSizeDelta { get; } // canvas units — target size when snapped
public float SlotRotationZ { get; } // degrees, local rotation when snapped
public float SnapRadius { get; } // canvas units; ~80120 for toddlers
public float PreviewRadius { get; } // canvas units; ~2× snap radius
}
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.
}
```
### 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<Color> 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<SavedArtworkDTO> SaveAsync(byte[] png, string templateId);
UniTask<IReadOnlyList<SavedArtworkDTO>> ListAsync();
UniTask<Texture2D> LoadFullAsync(string artworkId);
UniTask DeleteAsync(string artworkId);
}
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<byte[]> 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 + thumbnail addresses).
- Presents a scrollable grid of thumbnails (Canvas).
- 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<Color>)`).
- **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 `<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 |
|---|---|---|
| `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<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](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<IFoo>(_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<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)
```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<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#)
```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. 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 `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<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. 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/<theme>/<id>/`, 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`, `ShapePieceDTO`, `ColorRegionDTO` | 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/<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.
```csharp
public interface IDrawingTemplate {
string Id { get; } // e.g. "animals/elephant"
string DisplayName { get; } // user-facing
Sprite Thumbnail { get; } // 256×256 for catalog grid
Sprite PaperBackground { get; } // composited under artwork
IReadOnlyList<ShapePieceDTO> Pieces { get; } // for ShapeBuilder
IReadOnlyList<ColorRegionDTO> Regions { get; } // for Coloring
}
```
Implemented by `DrawingTemplateSO` (ScriptableObject) loaded via Addressables.
#### `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<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`.
```csharp
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)*
Persistent store of saved artwork PNGs.
```csharp
public interface IGalleryService {
UniTask<SavedArtworkDTO> SaveAsync(byte[] png, string templateId);
UniTask<IReadOnlyList<SavedArtworkDTO>> ListAsync(); // sorted newest first
UniTask<Texture2D> LoadFullAsync(string artworkId); // for fullscreen view
UniTask<Texture2D> LoadThumbnailAsync(string artworkId);
UniTask DeleteAsync(string artworkId);
}
```
#### `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<byte[]> 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<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.
```csharp
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`)*
```csharp
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.
#### `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<T> LoadAsync<T>(string key) where T : class, new();
UniTask SaveAsync<T>(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<string, AudioClip>` populated at preload.
#### `InputReaderSO` *(Services/Inputs/Readers — ✅ exists)*
ScriptableObject wrapping the new Input System; exposes events.
```csharp
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/](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<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.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<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.
```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<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.
```csharp
// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory,
// IPaperSurface _paper, 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.
#### `ShapePieceUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler` *(UI)*
The UI Image that the toddler drags. Lives under `TrayPanel` while idle, reparents under `IPaperSurface.PiecesParent` when snapped.
```csharp
public sealed class ShapePieceUI : MonoBehaviour,
IBeginDragHandler, IDragHandler, IEndDragHandler
{
public string PieceId { get; }
public bool IsLocked { get; }
public RectTransform RectTransform { get; }
public event Action<string> Snapped;
public void Initialize(ShapePieceDTO dto, ShapePieceFsm fsm);
}
```
- 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.
#### `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. Its `RectTransform` directly *is* the target pose — `ShapePieceFsm` reads `anchoredPosition`, `sizeDelta`, `localRotation` from it.
```csharp
public sealed class SlotMarker : MonoBehaviour {
public string SlotId;
public RectTransform RectTransform => transform as RectTransform;
}
```
#### `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 + their associated FSMs. Reused across template loads.
```csharp
public sealed class ShapePieceFactory {
public ShapePieceUI Spawn(ShapePieceDTO dto, RectTransform parent);
public void Despawn(ShapePieceUI piece);
}
```
#### `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<IPaperSurface>(_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<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.
```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<SavedArtworkDTO> 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<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.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<string> OnArtworkTapped;
event Action<string> OnDeleteRequested;
event Action<string> OnShareRequested;
void SetItems(IReadOnlyList<GalleryItemVM> 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 |
| `ShapePieceUI` | Feature | Draggable UI piece (Image + drag handlers) | 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<TState>` | 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.