Files
Colorbook/Readme.md
2026-05-28 19:45:15 +05:45

2411 lines
129 KiB
Markdown
Raw Permalink 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 **Canvas-only UI** render strategy.
This document is the canonical reference for the Color Book game's structure. The Bus Game's [Darkmatter Architecture Guide](../Assets/Darkmatter_Architecture_Guide.md) is the parent contract; this doc only adds game-specific structure.
---
## 1. Game Flow
Four scenes. Each gets its own scope. Root services persist across all of them.
```
┌─────────────────┐
│ Boot.unity │ RootLifetimeScope — services + cross-scene singletons
│ │ AppBoot: Addressables.Init → Progression.Load → Catalog.Init
└────────┬────────┘
│ Scenes.LoadAsync("MainMenu")
┌─────────────────┐
│ MainMenu.unity │ Spine mascot looping idle. Single "Play" button.
│ │ Tap → SetLastOpened(null) → load Colorbook
└────────┬────────┘
│ Scenes.LoadAsync("Colorbook")
┌─────────────────┐ ◀─────────────────────────┐
│ Colorbook.unity │ Catalog grid of drawings. │ (back from Gameplay returns here;
│ │ Each cell shows: cached │ catalog cells refresh with cached
│ │ user thumbnail if any, │ thumbnails written during gameplay)
│ │ else DefaultThumbnail. │
│ │ Tap cell → │
│ │ Progression.SetLastOpened(id) → load Gameplay
└────────┬────────┘ │
│ Scenes.LoadAsync("Gameplay") │
▼ │
┌─────────────────┐ │
│ Gameplay.unity │ Active drawing experience │
│ │ │
│ Reads Progression.LastOpenedTemplateId │
│ Reads Progression.GetProgress(id) → null, │
│ Building, or Coloring │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ GameplayState.Building │ │
│ │ • Pieces in tray, drag → snap │ │
│ │ • Pre-snapped pieces auto-locked │ │
│ │ if resuming │ │
│ │ • Back tap → save partial state ─┼────┤
│ │ + load Colorbook │ │
│ └────────────────┬─────────────────────┘ │
│ │ ShapeAssembledSignal │
│ │ (save phase + thumb) │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ GameplayState.Coloring │ │
│ │ • Tap color → tap region → paint │ │
│ │ • Undo / Redo any time │ │
│ │ • Autosave (debounced 500 ms) │ │
│ │ • Save tap → capture + native ─┼────┐
│ │ Photos save + cache thumb │ │
│ │ • Next tap → save + mark complete │ │
│ │ + advance to next drawing ──────┼─┐ │
│ │ • Back tap → save + load Colorbook─┼────┤
│ └──────────────────────────────────────┘ │ │
│ │ │
└────────────────────────────────────────────┼──┘
┌──────────────────────────┘
│ AdvanceToNextDrawing:
│ Catalog.NextUnseen(currentId) → reload Gameplay
└─ stays in Gameplay.unity, no scene transition
```
The user views captured drawings inside the phone's native **Photos** app — there is no in-app gallery viewer. `ICaptureService` produces PNG bytes; `IGalleryService` is a thin shim over a native plugin that writes those bytes into the device's photo library. The Save System (§13) decides *when* to capture and save.
---
## 2. Philosophy
Identical to Bus Game:
- **Vertical slices** — code grouped by Feature, not by type.
- **Strict layering** — dependencies flow downward only.
- **Composition over inheritance** — wired by DI.
- **Code vs 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/ ← stale folder — paper rig dropped; safe to delete
├── Data/
│ ├── Inputs/ (Input System .inputactions)
│ └── Settings/
│ ├── Persistance/Resources/ (ProtectedPlayerPrefs settings)
│ ├── Rendering/ (URP renderer + asset)
│ └── Scenes/URP2DSceneTemplate.unity
└── Code/
├── App/
│ └── LifetimeScopes/
│ └── RootLifetimeScope.cs ← scope loads serialized IServiceModule list
│ Darkmatter.App.asmdef
├── Core/ (asmdef name: `Core`, namespace root `Darkmatter.Core.*`)
│ ├── Compatibility/
│ │ └── IsExternalInit.cs (C#9 init shim for older runtimes)
│ ├── Contracts/
│ │ ├── Paper/ ← stale empty folder — delete (Paper is no longer a feature)
│ │ └── Services/
│ │ ├── Assets/IAssetProviderService.cs
│ │ ├── Audio/IAudioService.cs, ISfxPlayer.cs
│ │ ├── Camera/ICameraService.cs
│ │ ├── Capture/ ← (planned — ICaptureService)
│ │ ├── Inputs/IInputReader.cs
│ │ └── Scenes/ISceneService.cs
│ ├── Data/
│ │ ├── Dynamic/Services/Audio/ (AudioHandle, AudioRequest)
│ │ └── Static/Services/Audio/ (SfxCatalogSO)
│ └── Enums/
│ └── Services/
│ ├── Audio/ (AudioChannel, AudioPlayMode, SfxId)
│ ├── Camera/CameraType.cs (MainCamera, UICamera — ArtCamera not added yet)
│ └── Scenes/GameScene.cs
├── Features/ ← (planned — empty folder today)
├── Libs/
│ ├── FSM/ (IState, State, StateMachine + Docs)
│ │ Libs.FSM.asmdef
│ ├── Installers/ (IServiceModule — Register(IContainerBuilder))
│ │ Libs.Installers.asmdef
│ ├── Observer/ (IEventBus, EventBus — note: not named "EventBus")
│ │ Libs.Observer.asmdef
│ ├── PlayerPrefs/ (ProtectedPlayerPrefs — used in place of a Persistence service)
│ │ ├── Editor/ Libs.PlayerPrefs.Editor.asmdef
│ │ └── Runtime/ Libs.PlayerPrefs.asmdef
│ └── UI/ (ToggleButton, ToggleButtonGroup)
│ Libs.UI.asmdef
└── Services/
├── Analytics/
│ ├── Installers/AnalyticsServiceModule.cs
│ └── Systems/FirebaseAnalyticsSystem.cs
│ Services.Analytics.asmdef
├── Assets/
│ ├── AddressableAssetProviderService.cs
│ └── AddressableLoadHandleTracker.cs
│ Services.Assets.asmdef
├── Audio/
│ ├── AudioService.cs
│ └── SfxPlayer.cs
│ Services.Audio.asmdef
├── Camera/
│ ├── Installers/CameraServiceModule.cs
│ └── Service/CameraService.cs
│ Services.Camera.asmdef
├── Inputs/
│ ├── Generated/GameInputs.cs (Input System codegen)
│ ├── Installers/InputServiceModule.cs
│ └── Readers/InputReaderSO.cs
│ Services.Inputs.asmdef
└── Scenes/
└── SceneService.cs
Services.Scenes.asmdef
```
### 4b. Conventions visible in current code
- **Asmdef per Service / Lib / App / Core.** No Feature asmdefs yet (folder is empty).
- **Core sub-tree shape:** `Core/{Compatibility, Contracts, Data, Enums}` — deeply nested by Service rather than by topic. Game-specific Core types (Drawing, Coloring, Paper, Gallery, History, Progression, Signals) will be added under either `Core/Contracts/<Area>/` or new top-level `Core/<Area>/` — pick one convention before adding the first one.
- **Service sub-shape varies** — some are flat (`Services/Audio/*.cs`), some split (`Services/Camera/Installers/`, `Services/Camera/Service/`). The "official" per-feature shape (§4d) hasn't been validated against real code yet.
- **Installer pattern:** `MonoBehaviour, IServiceModule` with `Register(IContainerBuilder builder)`. **Not** `IInstaller.Install(...)` as older sections of this doc imply. New installers must follow `IServiceModule`.
- **No `AppBoot`** entry point exists yet. `RootLifetimeScope` only iterates a serialized `MonoBehaviour[] serviceModules` and calls `Register` on each `IServiceModule`. Boot sequence in §29 is aspirational.
- **No Persistence service.** `Libs/PlayerPrefs` (the `ProtectedPlayerPrefs` library) is in place for small-key state. JSON persistence for gallery sidecars will need a separate service when Gallery lands.
- **Camera service is a registry**, not a fitter. `ICameraService.Register/Get` only — no aspect-fit logic.
### 4c. Planned additions (not on disk yet)
All new game code follows the same nesting pattern as existing Services — `Contracts/Features/<Name>/`, `Data/{Dynamic,Static}/Features/<Name>/`, `Features/<Name>/` — and asmdefs drop the `Darkmatter.` prefix to match `Core`, `Services.Audio`, `Libs.Observer`.
Rough landing order for ColorBook scene to be playable:
| Path | Role |
|---|---|
| `Core/Contracts/Services/Capture/ICaptureService.cs` | Capture service contract — returns PNG bytes |
| `Core/Contracts/Services/Gallery/IGalleryService.cs` | Native-gallery save shim |
| `Core/Contracts/Features/Drawing/IDrawingTemplate.cs`, `IDrawingTemplateCatalog.cs` | Drawing template contracts |
| `Core/Contracts/Features/Coloring/IColorPalette.cs` | Palette contract |
| `Core/Contracts/Features/Progression/IProgressionService.cs` | Progression contract |
| `Core/Data/Static/Features/Drawing/DrawingTemplateSO.cs` | Authored drawing data |
| `Core/Data/Static/Features/Drawing/ShapeSO.cs` | Authored shape (sprite + snap params, reusable) |
| `Core/Data/Static/Features/Coloring/ColorPaletteSO.cs` | Authored palette data |
| `Core/Data/Dynamic/Features/Drawing/ColorRegionDTO.cs` | Runtime region struct |
| `Core/Data/Dynamic/Features/Coloring/PaintCommandDTO.cs` | Runtime coloring struct |
| `Core/Data/Dynamic/Features/Signals/*.cs` (DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, PaperSavedSignal) | Cross-feature signal structs |
| `Core/Enums/Services/Camera/CameraType.cs` | Add `CaptureCamera` enum value to existing file |
| `Services/Capture/` (+ `Services.Capture.asmdef`) | `RenderTextureCaptureService` drives the disabled `CaptureCamera` |
| `Services/Gallery/` (+ `Services.Gallery.asmdef`) | `NativeGallerySaveService` — wraps the native gallery plugin |
| `Features/MainMenu/` (+ `Features.MainMenu.asmdef`) | Spine mascot + Play button |
| `Features/{DrawingCatalog,ShapeBuilder,Coloring,Capture,Progression,ColorBookFlow}/` (+ asmdefs each) | Game features |
| `App/LifetimeScopes/{MainMenu,ColorBook}LifetimeScope.cs` | Per-scene scopes |
| `App/Boot/AppBoot.cs` | Bootstrap entry point |
| `Assets/Darkmatter/Scenes/{MainMenu,ColorBook}.unity` | Scenes (no ArtBook — captures go to phone Photos) |
| `Content/Gameplay/Drawings/<theme>/<id>/{Template.asset, Drawing.prefab, Regions/, PaperBackground.png}` | Authored drawings — `Drawing.prefab` holds `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.<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
Four scenes, each with its own scope. Root scope persists across all scene changes.
| Scene | Scope | Status | Contents |
|---|---|---|---|
| `Boot.unity` | `RootLifetimeScope` | ✅ exists | All Services + Libs + cross-scene singletons (`IProgressionSystem`, `IDrawingTemplateCatalog`). Persists forever. |
| `MainMenu.unity` | `MainMenuLifetimeScope` | ⚠️ planned | Spine mascot, single "Play" button. |
| `Colorbook.unity` | `ColorbookLifetimeScope` | ⚠️ planned | `ColorbookFlow` + `DrawingCatalog`. Catalog grid where the player picks a drawing. |
| `Gameplay.unity` | `GameplayLifetimeScope` | ⚠️ scene exists, scope empty | `ShapeBuilder` + `Coloring` + `History` + `Capture` (the feature wrapper) + `GameplayFlow`. The active drawing experience. |
Only `Boot.unity` and an empty `Gameplay.unity` exist today; the four scene scope classes need full implementation (only `RootLifetimeScope` is functional).
Scopes nest: `Root → (MainMenu | Colorbook | Gameplay)`. Services and cross-scene features (Progression, DrawingTemplate) resolve from the root parent. Scene scopes only register their own features.
**No in-app gallery** — captured drawings go to the phone's native Photos app via `IGalleryService`. The catalog grid in `Colorbook.unity` shows the user's progress by reading cached thumbnails from `IProgressionSystem`.
### Boot chain (planned)
No `AppBoot` class exists yet — today `RootLifetimeScope` only registers services and stops. When `AppBoot` is added (as an `IAsyncStartable` registered via `RootLifetimeScope`), it should run once, in order:
1. `IAssetProviderService.InitializeAsync()` — Addressables init.
2. `IProgressionSystem.LoadAsync()` — hydrate per-template state from PlayerPrefs.
3. `IDrawingTemplateCatalog.InitializeAsync()` — batch-load all `DrawingTemplateSO`s by Addressables label.
4. Optional: preload UI sounds, palette assets.
5. `ISceneService.LoadAsync(MainMenu)`.
Failures show a child-friendly retry screen; never crash.
---
## 7. Rendering Strategy
**Full Canvas UI.** No `SpriteRenderer`, no `Physics2D`, no offscreen `RenderTexture` for the live view. The paper, slots, pieces, and color regions are all `Image` components on a Screen-Space-Camera canvas. Standard Unity UI eventing (`IPointerDownHandler`, `IDragHandler`) handles all input.
```
┌──────────────────────────────────────────────────────────┐
│ PaperCanvas (Screen Space - Camera, UICamera) │
│ layer: PaperUI │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ PaperPanel (RectTransform, 2048×2048 ref units) │ │
│ │ ├─ BackgroundImage │ │
│ │ ├─ SlotsPanel (slot Image outlines) │ │
│ │ ├─ PiecesPanel (draggable piece Images) │ │
│ │ └─ RegionsPanel (colorable region Images) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ HUDCanvas (Screen Space - Overlay, OR separate camera) │
│ layer: HUDUI │
│ ├─ Palette panel │
│ ├─ Undo / Redo buttons │
│ ├─ Capture / Next buttons │
│ └─ Tray panel (during build phase) │
└──────────────────────────────────────────────────────────┘
CaptureCamera (disabled by default, one-shot Render() on capture)
orthographic, projection cloned from UICamera
cullingMask = PaperUI only
targetTexture = temp RT allocated per capture (2048×2048)
```
### Cameras
| Camera | Render mode | Culling Mask | Render Target | Purpose |
|---|---|---|---|---|
| `UICamera` | Screen-Space - Camera (orthographic) | `PaperUI`, `HUDUI` | Screen | Normal display each frame. |
| `CaptureCamera` | Orthographic, disabled | `PaperUI` only | temp `RenderTexture` | One-shot `Render()` invoked by `ICaptureService.CaptureAsync()`. |
`CaptureCamera` shares `UICamera`'s position, orthographic size, and clip planes so the captured frame matches what the player sees — minus the HUD because of the culling mask.
### Layers
| Layer | Used by |
|---|---|
| `PaperUI` | `PaperCanvas` and all of its children (background, slots, pieces, regions, completion FX). Visible in capture. |
| `HUDUI` | `HUDCanvas` and tray panel (palette, undo/redo, capture button, drawing catalog grid, etc.). Excluded from capture. |
| `EventSystem` | Unity's input layer — managed automatically. |
### Why full UI
| Need | Choice | Why |
|---|---|---|
| Tap-to-paint region | `Image` + `Image.alphaHitTestMinimumThreshold` + `IPointerClickHandler` | Tight alpha-based hit shape per region. No mesh / collider authoring. Tap events route through `EventSystem` natively. |
| Drag/drop shape pieces | `Image` + `IBeginDragHandler` / `IDragHandler` / `IEndDragHandler` | Standard Unity UI drag. Pointer events come in canvas-local coords already. No screen→world math anywhere. |
| Visual transition during drag → snap | `DOAnchorPos`, `DOSizeDelta`, `DOLocalRotate` | All pose is in `RectTransform` units. The "transition" is a tween over canvas-local values — no swap of render context. |
| Capture to PNG | Dedicated `CaptureCamera` with `cullingMask = PaperUI` | One `Render()` call into a temp RT. HUD physically can't appear. |
| Multi-resolution support | `CanvasScaler` on `PaperCanvas` (Scale With Screen Size) | Reference resolution `2048 × 2048`, Match = 1 (height). All `anchoredPosition` units are constant across devices. |
| HUD layout independent of paper | `HUDCanvas` (separate Canvas) | HUD scales/anchors per its own rules without affecting the paper layout. |
| Drawing catalog grid, palette, etc. | Standard UI (`GridLayoutGroup`, `ScrollRect`, `Button`) | Anchors handle aspect ratios. Async thumbnail loader. |
### Multi-resolution rule
The paper content is **canvas-unit-stable.** Author every drawing against a fixed 2048 × 2048 reference resolution. Slot positions, piece sizes, region rects, hit shapes — all expressed in `anchoredPosition` / `sizeDelta` units. `CanvasScaler` on `PaperCanvas` does the screen mapping.
`PaperPanel` is anchored center, fixed 2048×2048 (or whatever you pick for the reference). On a wider screen, `CanvasScaler` pillarboxes the panel; on a narrower screen, it letterboxes. The panel's contents never resize relative to each other.
If you want a backdrop (wood/cloth behind the paper area), it's a sibling `Image` of `PaperPanel` (still on `PaperUI` layer) sized to fill the canvas. The backdrop *is* captured into the PNG by default — set its layer to `HUDUI` if you want it excluded.
### Tradeoff vs the old RT-paper-rig design
| Concern | RT-paper-rig (old) | Canvas-only (current) |
|---|---|---|
| Paper contracts | 2 contracts + ~80 lines of math | gone (paper is just RectTransforms in scene) |
| Input pipeline | `IInputReader` → bridge → `Physics2D.OverlapPoint` | native `EventSystem` (`IPointerDownHandler` etc.) |
| Coloring hit shape | `PolygonCollider2D` from `Sprite.Editor` physics shape | `Image.alphaHitTestMinimumThreshold = 0.5f` on the region sprite |
| Per-frame render passes | 2 (ArtCamera into RT + UICamera draws RawImage) | 1 (UICamera draws everything) |
| Capture | read persistent RT | one-shot `CaptureCamera.Render()` |
| Coordinate gotchas | mismatches between screen / RT / world | none — everything is canvas-local |
If you ever need world-space effects (particle sparkles that physically explode outside the paper, free-draw brush stroke, pinch zoom on the artwork), revisit the RT approach. For the v1 tap-to-fill + drag-to-snap design, Canvas-only is correct.
---
## 8. Core Contracts
All Core types are pure data or interfaces.
### Drawing
> Contracts live in `Darkmatter.Core.Contracts.Features.Drawing`; DTOs in `Darkmatter.Core.Data.Dynamic.Features.Drawing`.
```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<ShapeSO> Pieces { get; } // shapes that get spawned in the tray for this drawing
IReadOnlyList<ColorRegionDTO> Regions { get; }
}
public readonly struct ColorRegionDTO {
public string RegionId { get; }
public Sprite Sprite { get; } // assigned to Image.sprite
public Vector2 AnchoredPosition { get; } // canvas units, relative to PaperRoot (region's authored parent)
public Vector2 SizeDelta { get; } // canvas units
public Color InitialColor { get; } // usually white
// Hit shape comes from the sprite alpha — set Image.alphaHitTestMinimumThreshold = 0.5.
// No polygon path needed; sprite import settings ("Read/Write Enabled") provide it.
}
```
### Shape authoring (`ShapeSO` + one prefab)
Shapes are authored as ScriptableObject assets via the Project Create menu (`Assets > Create > Darkmatter > Drawing > Shape`). One asset per shape — reusable across many drawings.
```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<Color> Colors { get; }
}
public readonly struct PaintCommandDTO {
public string RegionId { get; }
public Color FromColor { get; }
public Color ToColor { get; }
}
```
### Scene composition (no Paper feature)
The paper area is just a `PaperPanel` GameObject in the `Gameplay.unity` scene. There is **no `IPaperSurface` contract** and no Paper feature. The single cross-feature anchor — `PaperRoot` — is exposed via a small `GameplaySceneRefs : MonoBehaviour, IGameplaySceneRefs`:
```csharp
public sealed class GameplaySceneRefs : MonoBehaviour, IGameplaySceneRefs {
[SerializeField] private RectTransform paperRoot;
public RectTransform PaperRoot => paperRoot;
}
```
Scene refs hold only **what must be shared across features**. Everything else — the shape tray, the color palette, the drawing-instance prefab (which carries its own `SlotMarker`s + `ColorRegionView`s as children) — is owned by its feature's **holder view**. This keeps `GameplaySceneRefs` from becoming a god object as features are added.
The drawing prefab is instantiated under `PaperRoot`; slot markers and color regions are pre-authored as children of that prefab, so neither `SlotsParent` nor `RegionsParent` needs its own ref. The `CaptureCamera` will live on a separate scene MB (planned, owned by the Capture feature).
Registered once via `GameplayLifetimeScope`:
```csharp
builder.RegisterComponent<IGameplaySceneRefs>(_sceneRefs);
```
Features inject `IGameplaySceneRefs` (or their own holder views) directly.
### 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();
}
```
### Capture & Gallery
Two separate, independent services. **Capture** produces PNG bytes. **Gallery** writes those bytes into the device's native photo library. Neither service knows about the other; the orchestration lives in the Capture feature.
```csharp
namespace Darkmatter.Core.Contracts.Services.Capture;
public interface ICaptureService {
// Renders the disabled CaptureCamera into a temp RT, ReadPixels into a Texture2D,
// encodes PNG, releases the RT. Returns the encoded bytes.
UniTask<byte[]> CaptureAsync();
}
namespace Darkmatter.Core.Contracts.Services.Gallery;
public interface IGalleryService {
// Saves the given PNG bytes into the device's native photo library
// under the given album. Native plugin handles platform permissions.
UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book");
}
```
- `ICaptureService` owns the `CaptureCamera` reference (a disabled `Camera` in the ColorBook scene). The camera's `cullingMask` is set to `PaperUI` so the HUD physically cannot appear in the PNG. Paper background is just an `Image` on `PaperUI` — no compositing pass.
- `IGalleryService` is a **thin shim over a native gallery plugin** (NativeGallery, NativeShare, or a custom plugin). It does **not** save thumbnails, does **not** maintain a file list, does **not** delete entries. Everything in the device library is owned by the OS.
- There is no `SavedArtworkDTO`, no sidecar JSON, no in-app gallery file system. The user views their drawings in the phone's Photos app.
### Signals
> Signal structs live in `Darkmatter.Core.Data.Dynamic.Features.Signals` (runtime data, cross-feature).
```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 PaperCapturedSignal {
public string TemplateId { get; } // captured, before native-gallery save
}
public readonly struct PaperSavedSignal {
public string TemplateId { get; } // PNG written to phone library
}
```
---
## 9. Feature Responsibilities
### `MainMenu`
- Lives in `MainMenu.unity`. Single primary action: **Play** (→ `ColorBook` scene). There is no in-app gallery viewer — captured drawings live in the phone's Photos app.
- Hosts a **Spine character mascot** (via `SkeletonGraphic` for Canvas). Authored animations include idle loop, wave, react-to-button, victory dance.
- `MenuMascotPresenter` (pure C#) drives the mascot from code: subscribes to Play-button hover / click events and the model's idle timer, calls `IMenuMascotView.Play(animName, loop)`.
- View is setter-only. Spine-Unity's `SkeletonGraphic.AnimationState.SetAnimation(track, name, loop)` is encapsulated behind `IMenuMascotView`.
- Mascot's skeleton + atlas ship via Addressables (see §10).
### `DrawingCatalog`
- Loads the catalog manifest (list of available template IDs).
- Presents a scrollable grid of thumbnails (Canvas), one per template.
- Each cell shows `IDrawingTemplate.DefaultThumbnail` (the authored fallback sprite). The user's captured drawings live in the phone's Photos app, not in the catalog grid.
- On select → fires `DrawingSelectedSignal(templateId)` and unloads the catalog UI.
### `ShapeBuilder`
- Listens to `DrawingSelectedSignal` (raised by the Colorbook scene before scene change; resume reads `LastOpenedTemplateId` in Gameplay scope startup).
- Loads the per-drawing prefab via `IDrawingTemplateCatalog`, instantiates it under `GameplaySceneRefs.PaperRoot`. The prefab carries the `SlotMarker`s at their authored poses.
- **`ShapeHolderView`** owns the tray `RectTransform` + slide-in/out animation (PrimeTween — `Tween.UIAnchoredPosition` + `Tween.Alpha` on `CanvasGroup`). Default hide direction is right (off-screen +X), tunable in inspector.
- **`ShapeHolderPresenter`** subscribes to `ShapeBuilderStartedSignal``view.Show()` and `ShapeAssembledSignal``view.Hide()`. No controller poking the view directly.
- **`IShapePieceFactory`** (`ShapePieceFactory` impl) owns the per-piece dependencies (`ShapeBuilderConfig`, `ISfxPlayer`, `IEventBus`, `IUndoStack`) and the spawn parent (read from `ShapeHolderView.SpawnRoot`). Factory creates one `ShapePiece` MB and runs its `Setup`. Controller calls `factory.Create(prefab, shape, slot, trayPos, preSnapped)` per piece.
- `ShapeBuilderController` only orchestrates: loads piece prefab via `IAssetProviderService`, computes tray positions from `holder.SpawnWidth`, publishes `ShapeBuilderStartedSignal` before spawn, and listens for `PieceSnappedSignal` to count down to `ShapeAssembledSignal`. If `progress.Phase == ShapeBuilding`, pieces in `progress.SnappedPieces` are passed to the factory with `preSnapped: true`.
- `ShapePiece` is a single MB handling all three behaviors inline: drag (Unity UI `IBeginDrag / IDrag / IEndDrag`), reactive preview lerp when within `cfg.PreviewRadius`, snap (PrimeTween — `Tween.UIAnchoredPosition` / `UISizeDelta` / `LocalRotation`) on release inside `cfg.SnapRadius`, otherwise tween back to tray.
- Publishes `PieceSnappedSignal(pieceId)` on lock. Controller counts against expected; fires `ShapeAssembledSignal(templateId)` when all locked.
### `Coloring`
- Listens to `ShapeAssembledSignal`.
- Regions are pre-authored as children of the drawing prefab (under `PaperRoot`). Each is a `ColorRegionView` (UI `Image` + `IPointerClickHandler`). On `Awake`, the view sets `Image.alphaHitTestMinimumThreshold` from a serialized field (default `0.01f`; tune up to `0.5f` for tighter hits) so taps on transparent pixels pass through to the next region below.
- **`ColorPaletteHolderView`** owns the palette container `RectTransform` + slide-in/out animation (same pattern as `ShapeHolderView``Tween.UIAnchoredPosition` + `Tween.Alpha` on `CanvasGroup`, hides off-screen right).
- **`ColorPaletteHolderPresenter`** subscribes to `ShapeAssembledSignal``view.Show()`. Hide is invoked from scene tear-down or future phase-exit hook.
- **`ColorButton` + `IColorButtonFactory`** (planned): each color is its own self-contained MB (swatch `Image` + `IPointerClickHandler`). `ColorButtonFactory` instantiates the button prefab under `ColorPaletteHolderView.SpawnRoot` and calls `button.Setup(color, state, sfx)`. On click, the button writes directly to `ColoringStateRepository.SelectColor(myColor)` — no view/presenter intermediary. `ColoringController` iterates `IColorPalette.Colors` once on init and calls `factory.Create(color)` per entry. Mirrors `ShapePieceFactory` for symmetry.
- Each region's `OnPointerClick``ColoringController.PaintRegion(view)`.
- Controller builds `PaintRegionCommand(regionId, oldColor, newColor)` and pushes to `IUndoStack`. Command sets `Image.color` on Execute/Undo.
- Publishes `ColorAppliedSignal` for SFX / sparkle effects.
- **Resume:** if `progress.RegionColors` is non-empty, spawned regions are initialized with those saved colors instead of `region.InitialColor`.
- **Autosave hook:** after each `PaintRegion`, debounces 500 ms then calls `GameplayFlowController.AutosaveAsync` so the colors hit disk without thumbnail re-render. See §13.
### `History`
- Owns the scoped `IUndoStack` for the current Gameplay session.
- Cleared on Gameplay scope startup (new drawing = fresh history).
- Capped at 20 entries.
- UI: two big arrow buttons; disabled state when `CanUndo` / `CanRedo` is false.
### `Capture`
- Wraps `ICaptureService.CaptureAsync()` (one-shot `CaptureCamera.Render()` into a temp RT, ReadPixels, EncodeToPNG). Returns raw PNG bytes.
- The **Capture feature does NOT decide what to do with the bytes**`GameplayFlowController` calls it, then routes to gallery + thumbnail cache depending on the trigger. See §13.
### `Progression`
- Single source of truth for per-template user state. Implements `IProgressionSystem`, persists `DrawingProgress` records via `Libs.PlayerPrefs` (single JSON blob under `PlayerPrefsKeys.Progression`).
- Internally stores thumbnails per template as PNG files in `Application.persistentDataPath/thumbs/{safeId}.png` (large blobs don't belong in PlayerPrefs).
- `ProgressionRepository` does the IO; `ProgressionSystem` keeps an in-memory cache and exposes a clean API.
- Exposes: `GetProgress(id)`, `SaveProgressAsync(progress)`, `SaveProgressAsync(progress, png)`, `ClearProgressAsync(id)`, `IsCompleted(id)`, `CompletedTemplateIds`, `LastOpenedTemplateId / SetLastOpened`, `GetCachedThumbnailAsync(id)`.
- See §13 for the full save matrix.
### `ColorbookFlow` (in `Colorbook.unity`)
- The orchestrator for the catalog scene.
- On scope start: calls `_drawingCatalog.InitializeAsync()` to populate the visible-IDs list.
- Subscribes to `DrawingSelectedSignal`: `_progression.SetLastOpened(id)` + `_scenes.LoadAsync(Gameplay)`.
### `GameplayFlow` (in `Gameplay.unity`)
- The orchestrator for the active drawing scene.
- On scope start: reads `_progression.LastOpenedTemplateId`, fetches its `DrawingProgress`, enters either Building (no progress / Phase==ShapeBuilding) or Coloring (Phase==Coloring) state.
- Handles all save points (§13): Back button, ShapeAssembled transition, Save button, Next button, app lifecycle pause/quit.
- Uses `Libs.FSM` (StateMachine) for `Building``Coloring` runtime states.
---
## 10. Addressables Strategy
Mirror the Bus Game pattern via `IAssetProviderService`.
### What ships through Addressables
| Asset | Why |
|---|---|
| `DrawingTemplate` ScriptableObject (per drawing) | Many; load on demand. |
| `ShapeSO` assets | Reused across drawings; load once per drawing batch. |
| Region sprites | Heavy; loaded per drawing. |
| Paper backgrounds | Per template, sometimes shared. |
| Color palette SOs | Swap per theme. |
| Audio clips (tap, snap, complete, sparkle) | Shared SFX bank. |
| Spine mascot (`SkeletonDataAsset` + atlas) | Heavy textures; load with `MainMenu` scene, release on scene exit. |
### What does NOT use Addressables
- HUD prefabs (palette button, undo icon) — always loaded with scene.
- Core UI canvases.
- Boot scene assets.
- User-saved gallery PNGs — those live in `Application.persistentDataPath`.
### Group layout
```
Drawings_Animals (label: drawing, animals)
Drawings_Vehicles (label: drawing, vehicles)
Drawings_Shapes (label: drawing, shapes)
Shapes_Library (label: shape) — reusable ShapeSO assets
Palettes (label: palette)
Audio_UI (label: sfx, ui)
Audio_Coloring (label: sfx, coloring)
Spine_MainMenu (label: spine, menu) — mascot skeleton + atlas
```
### Lifecycle
- Catalog loads **thumbnail handles only** (cheap).
- On select → full template loads (pieces + regions + paper).
- On "Next" or scene exit → previous template `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
Only one in-app persistent store — small settings + progression. Captured drawings go to the device's native photo library (managed by the OS, not by the app).
### Settings + progression via `Libs.PlayerPrefs`
`ProtectedPlayerPrefs` (in `Libs/PlayerPrefs/`) is a lightweight encrypted-string wrapper around Unity's `PlayerPrefs`. Used for:
- Completed template IDs (JSON-encoded list).
- Last opened drawing.
- Audio volume, simple toggles.
A higher-level `IProgressionService` reads/writes these keys; consumers never touch `PlayerPrefs` directly.
### Native photo library (gallery)
Captured PNGs go to the phone's Photos app via `IGalleryService.SaveToDeviceAsync(bytes, albumName)`. The app does **not**:
- Write `.png` files to `persistentDataPath`.
- Generate or store thumbnails locally.
- Maintain any sidecar JSON / index.
- Provide list / load / delete operations.
The user opens the phone's Photos app to view, share, or delete their drawings. iOS / Android handle permissions and album organization.
---
## 12. Capture Pipeline
A dedicated `CaptureCamera` lives in the ColorBook scene, disabled by default. It renders only the `PaperUI` layer into a temp `RenderTexture` when capture fires. The PNG bytes are then handed to the native gallery plugin — no local file IO.
```
[Save button or Next button]
CaptureController.SaveAsync(templateId)
ICaptureService.CaptureAsync()
├─ rt = RenderTexture.GetTemporary(2048, 2048, 0, ARGB32)
├─ _captureCam.targetTexture = rt
├─ _captureCam.Render() (one-shot; cullingMask = PaperUI only)
├─ _captureCam.targetTexture = null
├─ prev = RenderTexture.active
├─ RenderTexture.active = rt
├─ tex = new Texture2D(2048, 2048, RGBA32, false)
├─ tex.ReadPixels(full rect, 0, 0); tex.Apply()
├─ RenderTexture.active = prev
├─ RenderTexture.ReleaseTemporary(rt)
├─ bytes = tex.EncodeToPNG() (on worker via UniTask.RunOnThreadPool)
├─ Object.Destroy(tex)
└─ return bytes
EventBus.Publish(new PaperCapturedSignal(templateId))
IGalleryService.SaveToDeviceAsync(bytes, "Color Book")
├─ native plugin handles platform permissions
├─ writes PNG into the device's Photos album
└─ (no return — fire and forget; throws on failure)
EventBus.Publish(new PaperSavedSignal(templateId))
```
Notes:
- HUD never appears in capture because `CaptureCamera.cullingMask` excludes `HUDUI`. Layer mask, not coincidence — even if you accidentally parent a HUD element under `PaperPanel`, putting it on the wrong layer keeps it out.
- Paper background is just an `Image` on `PaperUI`. Already in the right layer; no special compositing.
- Saved PNGs are 2048×2048 on every device. `CaptureCamera` has fixed `orthographicSize` and aspect, independent of screen size.
- `CaptureAsync` is safe to call repeatedly. The CaptureCamera's transform / projection are set once at scene start and never modified.
- The temp RT is allocated via `RenderTexture.GetTemporary` so successive captures don't leak GPU memory.
- `IGalleryService` and `ICaptureService` are independent — `IGalleryService` knows nothing about the camera; `ICaptureService` knows nothing about the native plugin. The chain is the `CaptureController`'s sole responsibility.
---
## 12b. Save System
Everything the user does that affects their drawing state must end up persisted. `GameplayFlowController` is the **single owner** of all save calls — feature controllers expose getters; the flow controller assembles the `DrawingProgress` record and hands it to `IProgressionSystem`.
### What is saved
| Field on `DrawingProgress` | Meaning |
|---|---|
| `templateId` | Which drawing this record is about |
| `phase` | `ShapeBuilding` or `Coloring` — where to resume |
| `snappedPieces` | Pieces locked into slots (relevant in ShapeBuilding) |
| `regionColors` | Per-region color (relevant in Coloring) |
| `hasThumbnail` | Whether a thumbnail PNG exists on disk for catalog display |
| `hasBeenCompleted` | Flipped true on first Next; never flips back |
| `completionCount` | Number of times Next was pressed (optional stats) |
| `updatedUtcIso` / `firstCompletedUtcIso` | Timestamps (ISO 8601 strings for JsonUtility) |
### Save matrix
| Trigger | Phase saved | Snapped pieces | Region colors | Thumbnail? | Native gallery? |
|---|---|---|---|---|---|
| **ShapeAssembledSignal** (Building → Coloring transition) | `Coloring` | all | empty | **yes** (bare-assembled paper) | no |
| **Each paint** (debounced 500 ms) | `Coloring` | all | current | no | no |
| **Save button** | `Coloring` | all | current | **yes** | **yes** |
| **Next button** | `Coloring` | all | current | **yes** | **yes** |
| **Back button (during Building)** | `ShapeBuilding` | current | empty | no | no |
| **Back button (during Coloring)** | `Coloring` | all | current | **yes** | no |
| **OnApplicationPause(true) / OnApplicationQuit** | current phase | current | current | no | no |
Two design principles drive the matrix:
- **Thumbnail capture is expensive** (render + ReadPixels + PNG encode). Skip it on partial-assembly saves and per-paint autosaves. Only generate a thumbnail when the user takes an explicit save-style action.
- **Defensive saves never block UX.** App pause/quit saves whatever is in memory without capturing — fast path, no awaitable IO holding up shutdown.
### `Next` adds two extras
- Flips `hasBeenCompleted = true` (preserves first `firstCompletedUtcIso`); increments `completionCount`.
- Plays completion animation, then calls `AdvanceToNextDrawing``Catalog.NextUnseen(currentId)` → reload Gameplay scope for the new drawing.
### Storage layout
| What | Where |
|---|---|
| `DrawingProgress` records + `lastOpened` | One JSON blob in `ProtectedPlayerPrefs[PlayerPrefsKeys.Progression]` (see `ProgressionRootDto`) |
| Thumbnail PNGs | `Application.persistentDataPath/thumbs/{safeId}.png` (one file per template that has a thumbnail) |
`safeId` replaces `/` and `\` with `_` so `animals/elephant` becomes `animals_elephant.png`.
### Resume / load decision
On Gameplay scope startup:
```csharp
var id = _progression.LastOpenedTemplateId;
var progress = _progression.GetProgress(id); // null if untouched
if (progress == null || progress.Value.phase == DrawingPhase.ShapeBuilding) {
fsm.Go(Building); // spawn pieces in tray, pre-snap any in progress.snappedPieces
} else {
fsm.Go(Coloring); // skip ShapeBuilder; auto-snap pieces; spawn regions with progress.regionColors
}
```
### Catalog cells reflect saves automatically
The Colorbook scene reloads on Back. Its presenter calls `_progression.GetCachedThumbnailAsync(id)` per cell → returns the most recent save's PNG. Drawings the user touched display their progress; untouched drawings fall back to `IDrawingTemplate.DefaultThumbnail`. No live-update plumbing needed — re-entry is the refresh.
### Files touching the save system
| Path | Role |
|---|---|
| `Core/Contracts/Features/Progression/IProgressionSystem.cs` | Contract |
| `Core/Data/Dynamic/Features/Progression/DrawingProgress.cs` | The struct |
| `Core/Data/Dynamic/Features/Progression/ProgressionRootDto.cs` | JSON root (records + lastOpened) |
| `Core/Data/Dynamic/Features/Progression/RegionColorEntry.cs` | Flattened color entry (JsonUtility-friendly) |
| `Core/Enums/Features/Progression/DrawingPhase.cs` | Phase enum |
| `Features/Progression/Systems/ProgressionSystem.cs` | In-memory cache + write serialization (SemaphoreSlim) |
| `Features/Progression/Systems/ProgressionRepository.cs` | PlayerPrefs JSON + thumbnail file IO |
| `Features/Progression/Installers/ProgressionFeatureModule.cs` | Registers `IProgressionSystem` as Singleton in Root scope |
### Single rule
> Only `GameplayFlowController` calls `_progression.SaveProgressAsync(...)`. Feature controllers expose getters; they never touch the tracker themselves. This means there is exactly one place to audit when save behavior changes.
---
## 13. Communication Rules
| Use case | Mechanism |
|---|---|
| Load template, return result | Direct DI call (`IDrawingTemplateCatalog.LoadAsync`). |
| Capture → save chain | Direct DI calls, sequenced. |
| Notify HUD that a region was painted | `IEventBus` signal. |
| Notify Progression that a drawing was completed | `IEventBus` signal. |
| Tell ColorBookFlow that pieces are assembled | `IEventBus` signal. |
| Tell Coloring which color is currently selected | Direct DI on `ColoringStateRepository`. |
**Never** use signals for request/response. If you need a return value or guaranteed single handler, define a Core interface.
---
## 14. UI (MVP — Passive View)
Identical to Bus Game.
- **Model** — controller / repository, fires C# events.
- **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; mostly type-only checks. |
| `Services/Gallery` | EditMode w/ temp directory | mocks `Application.persistentDataPath`. |
| `Services/Capture` | PlayMode | requires a Camera in the test scene. |
| `Features/*/Systems` | EditMode w/ DI test container | inject fakes for `IUndoStack`, signals captured by a fake `IEventBus`. |
| Full flow | PlayMode smoke test | one drawing → assemble → color → capture → assert gallery has 1 file. |
---
## 17. "Where do I put this?" Checklist
1. **Is it a cross-assembly interface / enum / DTO?**`Core/`
2. **Is it a generic, sellable utility?**`Libs/`
3. **Is it infrastructure (input, audio, file IO, addressables, capture)?**`Services/`
4. **Is it gameplay logic specific to coloring books?**`Features/`
5. **Is it composition / scene wiring?**`App/`
When in doubt, ask: *would deleting this feature break Core?* If yes, the dependency is wrong.
---
## 18. Open Questions / Future Work
- **Pencil/brush mode** — currently the design is tap-to-fill regions. A free-draw brush mode would need a `BrushStrokeCommand` and a dynamic texture per region; out of scope for v1.
- **Multi-child profiles** — single-profile for v1; multi-profile would slot in behind `IProgressionService` and `IGalleryService` keyed by `profileId`.
- **Cloud sync** — gallery sync would happen behind `IGalleryService` (decorator pattern); local-first stays the source of truth.
- **Sticker / decoration layer** — additive sprite layer above coloring, also `ICommand`-driven so it integrates with undo/redo cleanly.
---
## 19. Quick Reference — Feature ↔ Signal Map
| Feature | Subscribes to | Publishes |
|---|---|---|
| `MainMenu` | — | — (Play tap loads a scene directly) |
| `DrawingCatalog` | — | `DrawingSelectedSignal` |
| `ShapeBuilder` | `DrawingSelectedSignal` | `ShapeAssembledSignal` |
| `Coloring` | `ShapeAssembledSignal` | `ColorAppliedSignal` |
| `History` | `DrawingSelectedSignal` (to clear) | — |
| `Capture` | — (button-driven) | `PaperCapturedSignal`, `PaperSavedSignal` |
| `Progression` | `PaperSavedSignal` | — |
| `ColorBookFlow` | `ShapeAssembledSignal`, `PaperSavedSignal` | — |
---
Maintained alongside the [Darkmatter Architecture Guide](../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 |
|---|---|---|
| `Services.Capture` | `Services/Capture/` | `Core`, `Libs.Installers` |
| `Services.Gallery` | `Services/Gallery/` | `Core`, `Libs.Installers` |
| `Features.MainMenu` | `Features/MainMenu/` | `Core`, `Libs.Installers` |
| `Features.DrawingCatalog` | `Features/DrawingCatalog/` | `Core`, `Libs.Installers` |
| `Features.ShapeBuilder` | `Features/ShapeBuilder/` | `Core`, `Libs.Installers` |
| `Features.Coloring` | `Features/Coloring/` | `Core`, `Libs.Installers` |
| `Features.Capture` | `Features/Capture/` | `Core`, `Libs.Installers` |
| `Features.Progression` | `Features/Progression/` | `Core`, `Libs.Installers`, `Libs.PlayerPrefs` |
| `Features.ColorBookFlow` | `Features/ColorBookFlow/` | `Core`, `Libs.Installers`, `Libs.FSM` |
> `Libs.CommandStack`, `Features.ArtBook`, and `Features.Paper` were previously planned but cut. Undo lives inside `Features.History` (already on disk). Art-book is removed because captures save to phone Photos. Paper is just RectTransforms in the scene — no contract needed. `ICommand` / `IUndoStack` live in `Core`, so `Features.Coloring` reaches the undo stack without referencing `Features.History`.
**Hard rules:**
- No Service asmdef references any Feature asmdef.
- No Feature asmdef references another Feature asmdef.
- All Services and Features depend on `Libs.Installers` so they can implement `IServiceModule`.
- The compiler enforces this — if a `using` won't resolve, the dependency is wrong.
---
## 21. LifetimeScope Concrete Sample
All scopes use the same pattern: a serialized `MonoBehaviour[]` list of `IServiceModule` installers. Each installer is a MonoBehaviour on a child GameObject of the scope. Scope iterates and calls `Register`. **No hardcoded registrations in the scope itself.** This is exactly what [RootLifetimeScope.cs](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[]`:
- `DrawingCatalogModule`
- `ShapeBuilderModule`
- `ColoringModule`
- `HistoryModule`
- `CaptureModule`
- `ProgressionModule`
- `ColorBookFlowModule`
Each registers its own classes via `IServiceModule.Register(IContainerBuilder)`.
> If a scope needs a non-installer reference (e.g. a `GameplaySceneRefs` MB holding `PaperRoot`), expose it as a separate `[SerializeField]` and `builder.RegisterComponent(...)` it inside the scope's `Configure`. Don't put scene refs inside an installer — keep installers stateless across scenes.
---
## 22. Installer Pattern — Concrete Coloring Sample
Mirrors the existing [CameraServiceModule.cs](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<IColorButtonFactory, ColorButtonFactory>(Lifetime.Scoped);
builder.RegisterEntryPoint<ColoringInputBinder>();
}
}
```
Convention:
- One `IServiceModule` per feature, named `<Feature>Module` (matches `CameraServiceModule`, `InputServiceModule`, `AnalyticsServiceModule` already in the project).
- `MonoBehaviour` lives on a GameObject under the scope's hierarchy; dragged into the scope's `serviceModules[]` / `sceneModules[]` inspector list.
- Method name is `Register`, not `Install`. There is **no `IInstaller`** in this project — uses `IServiceModule` from [Libs.Installers](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.RegisterComponent<IFoo>(_foo)` them. `GameplaySceneRefs` (§32.13) is registered this way directly from the scope's serialized field. Per-feature holder views (e.g. `ShapeHolderView`, `ColorPaletteHolderView`) are registered the same way from each feature's `IServiceModule`.
---
## 23. Command Pattern — `PaintRegionCommand`
```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. Self-contained Button + Factory — Color Palette
The palette deliberately **skips the View/Presenter pair**. Each color is its own self-contained `ColorButton` MB, spawned by a factory that mirrors `ShapePieceFactory`. The button writes directly to `ColoringStateRepository` on click — no intermediary indexed event, no presenter wiring `_state.SelectedIndexChanged → _view.SetSelected`.
Why: palette behavior is per-button (each one is a discrete tap target with its own selected/hover anim), and adding per-button variants later (locked colors, magic "rainbow" button, premium colors) is just a new MB type that the factory can branch to. A View+Presenter pair would force every variant through a single `SetColors(IReadOnlyList<Color>)` setter.
### Button (self-contained MB)
```csharp
namespace Darkmatter.Features.Coloring.UI;
public sealed class ColorButton : MonoBehaviour, IPointerClickHandler {
[SerializeField] private Image swatch;
[SerializeField] private GameObject selectedRing; // optional highlight
private Color _color;
private ColoringStateRepository _state;
private ISfxPlayer _sfx;
public Color Color => _color;
public void Setup(Color color, ColoringStateRepository state, ISfxPlayer sfx) {
_color = color;
_state = state;
_sfx = sfx;
swatch.color = color;
_state.SelectedColorChanged += OnSelectedChanged;
OnSelectedChanged(_state.CurrentColor);
}
public void OnPointerClick(PointerEventData _) {
_sfx.Play(SfxId.UiTap);
_state.SelectColor(_color);
}
private void OnSelectedChanged(Color current) {
if (selectedRing != null) selectedRing.SetActive(current == _color);
}
private void OnDestroy() {
if (_state != null) _state.SelectedColorChanged -= OnSelectedChanged;
}
}
```
### Factory (mirrors ShapePieceFactory)
```csharp
namespace Darkmatter.Features.Coloring.Systems;
public interface IColorButtonFactory {
ColorButton Create(Color color);
}
public sealed class ColorButtonFactory : IColorButtonFactory {
private readonly ColorPaletteHolderView _holder;
private readonly GameObject _buttonPrefab;
private readonly ColoringStateRepository _state;
private readonly ISfxPlayer _sfx;
public ColorButtonFactory(
ColorPaletteHolderView holder,
GameObject buttonPrefab,
ColoringStateRepository state,
ISfxPlayer sfx) {
_holder = holder;
_buttonPrefab = buttonPrefab;
_state = state;
_sfx = sfx;
}
public ColorButton Create(Color color) {
var go = Object.Instantiate(_buttonPrefab, _holder.SpawnRoot);
var btn = go.GetComponent<ColorButton>();
btn.Setup(color, _state, _sfx);
return btn;
}
}
```
### Spawn loop (inside `ColoringController.InitializeAsync`)
```csharp
foreach (var color in _palette.Colors)
_buttonFactory.Create(color);
```
That's the whole feature wiring. No `IColorPaletteView`, no `OnColorButtonClicked` indexed event, no `SelectedIndex ↔ SetSelected` round-trip.
### When to use View/Presenter instead
Other features (history buttons, drawing catalog) still use View/Presenter — that pair fits when a single canvas of fixed sub-widgets needs to react to one model. Use button+factory when each item is **spawned dynamically from data** and has independent click behavior.
---
## 26. ShapeBuilder — Snap Algorithm
All math is in canvas-local space — `anchoredPosition`, `sizeDelta`, `localRotation`. No world coords. Behavior lives inline in `ShapePiece : MonoBehaviour` — no FSM, no factory, no state classes. Three behaviors expressed across three Unity drag handlers.
### `OnDrag` — reactive preview lerp
```csharp
public void OnDrag(PointerEventData e)
{
if (_locked) return;
var pointerLocal = ScreenToLocal(e.position) + _grabOffset;
var slotPos = _slot.RectTransform.anchoredPosition;
float dist = Vector2.Distance(pointerLocal, slotPos);
if (dist <= _cfg.PreviewRadius)
{
if (!_inPreview) { _inPreview = true; _sfx.Play(SfxId.ShapeHover); }
ApplyPreviewLerp(pointerLocal, dist);
}
else
{
_inPreview = false;
RectTransform.anchoredPosition = pointerLocal;
RectTransform.sizeDelta = _traySize;
RectTransform.localRotation = Quaternion.identity;
}
}
private void ApplyPreviewLerp(Vector2 pointerLocal, float dist)
{
float t = Mathf.Clamp01(1f - dist / _cfg.PreviewRadius);
if (_cfg.PreviewCurve != null) t = _cfg.PreviewCurve.Evaluate(t);
var slot = _slot.RectTransform;
RectTransform.anchoredPosition = Vector2.Lerp(pointerLocal, slot.anchoredPosition, t);
RectTransform.sizeDelta = Vector2.Lerp(_traySize, slot.sizeDelta, t);
RectTransform.localRotation = Quaternion.Slerp(Quaternion.identity, slot.localRotation, t);
}
```
### `OnEndDrag` — snap or return
```csharp
public void OnEndDrag(PointerEventData e)
{
if (_locked) return;
float dist = Vector2.Distance(
RectTransform.anchoredPosition,
_slot.RectTransform.anchoredPosition);
if (dist <= _cfg.SnapRadius) Snap();
else ReturnToTray();
}
private void Snap()
{
Lock(); // reparent + raycast off + _locked = true
var slot = _slot.RectTransform;
Tween.UIAnchoredPosition(RectTransform, slot.anchoredPosition, _cfg.SnapDuration, Ease.OutBack);
Tween.UISizeDelta (RectTransform, slot.sizeDelta, _cfg.SnapDuration, Ease.OutBack);
Tween.LocalRotation (RectTransform, slot.localRotation, _cfg.SnapDuration, Ease.OutBack);
_sfx.Play(SfxId.ShapeSnap);
_bus.Publish(new PieceSnappedSignal(_shape.Id));
}
```
### Four things worth noting
1. **Reparent on lock**`Lock()` calls `RectTransform.SetParent(_slot.RectTransform.parent, false)`. The piece moves from the HUD-side tray to the per-drawing slot parent so it travels with the paper and gets included in the captured PNG.
2. **Three parallel PrimeTween calls** — position, size, rotation. Tweens start together so the piece visually snaps as one motion. Zero allocations per tween.
3. **`SnapRadius` is in canvas units** (from `ShapeBuilderConfig`, e.g. 80120), not world units. Same `CanvasScaler` reference resolution across devices = same hit feel.
4. **Preview hover sound fires once per drag**, on the boundary cross into the preview radius. `_inPreview` flag resets on `OnBeginDrag`.
### Pre-snapped resume
If the user closes the app mid-assembly (or after completing the drawing), the saved `DrawingProgress.snappedPieces` lists which pieces were locked. On resume, the controller passes `preSnapped: true` to `Setup` for those, and `ShapePiece.SnapInstantly()` puts them straight into their slots — no tween, no input. The user can keep snapping the remaining pieces.
```csharp
private void SnapInstantly()
{
Lock();
var slot = _slot.RectTransform;
RectTransform.anchoredPosition = slot.anchoredPosition;
RectTransform.sizeDelta = slot.sizeDelta;
RectTransform.localRotation = slot.localRotation;
}
```
### Spawn loop in `ShapeBuilderController.BuildAsync`
The controller no longer parents pieces directly nor knows about `Setup` dependencies. It delegates to `IShapePieceFactory`, which owns the parent (`ShapeHolderView.SpawnRoot`) and the per-piece deps (cfg/sfx/bus/undo). Tray width is read from the holder, not from scene refs.
```csharp
_bus.Publish(new ShapeBuilderStartedSignal(template.Id)); // ShapeHolderPresenter → view.Show()
int count = template.Pieces.Count;
float trayW = _holder.SpawnWidth;
float pitch = trayW / (count + 1);
for (int i = 0; i < count; i++)
{
var shape = template.Pieces[i];
var slot = FindSlotForShape(slots, shape);
var trayPos = new Vector2(pitch * (i + 1) - trayW * 0.5f, 0f);
var preSnapped = preSnappedIds != null && preSnappedIds.Contains(shape.Id);
_factory.Create(_piecePrefab, shape, slot, trayPos, preSnapped);
}
```
Controller listens for `PieceSnappedSignal`, counts against expected piece count, fires `ShapeAssembledSignal` when complete → `GameplayFlowController` captures bare-assembled thumbnail, transitions to Coloring (see §13).
---
## 27. Rendering Order & Sorting
Canvas-only — order is sibling index inside `PaperPanel` (front-most is last in hierarchy). No URP 2D sorting layers.
`PaperPanel` children (bottom → top):
```
PaperPanel
├─ BackgroundImage (paper texture)
├─ RegionsPanel (colorable region Images)
├─ SlotsPanel (slot outline Images — under pieces so snapped pieces hide them)
├─ PiecesPanel (draggable / snapped piece Images)
└─ EffectsPanel (sparkle / particle UI for completion FX, optional)
```
`HUDCanvas` is a separate Canvas at a higher sorting order (or Screen Space - Overlay). It never sorts against `PaperCanvas` because they're different canvases.
`CaptureCamera` renders only the `PaperUI` layer. The HUD physically cannot leak into the saved PNG because of the culling mask, regardless of sibling order.
> If you ever need particles outside the canvas (e.g. confetti falling across the full screen on completion), use a separate Canvas above the HUD with its own sub-tree of UI particles. Don't add `ParticleSystem`s under PaperPanel — they don't render in UI canvases without `UIParticleSystem` or similar packages.
---
## 28. Native Gallery Integration
`IGalleryService.SaveToDeviceAsync(byte[] png, string albumName)` is the only operation. Implementations wrap a native plugin — recommended packages:
| Platform | Library |
|---|---|
| Cross-platform | [Native Gallery for Android & iOS](https://github.com/yasirkula/UnityNativeGallery) |
| iOS-only fallback | `PHPhotoLibrary` direct bindings |
| Android-only fallback | `MediaStore` direct bindings via `AndroidJavaClass` |
Permission handling:
- **iOS** — `NSPhotoLibraryAddUsageDescription` in `Info.plist`. iOS prompts on first save.
- **Android 13+** — no permission required for writes that target a public collection via the plugin.
- **Android 1112** — `WRITE_EXTERNAL_STORAGE` declared but not requested at runtime; plugin uses scoped storage.
- **Android ≤ 10** — `WRITE_EXTERNAL_STORAGE` runtime permission requested by the plugin.
`NativeGallerySaveService` (the planned concrete) catches plugin permission denials and either silently no-ops (toddler app) or surfaces a child-friendly retry prompt via the HUD.
No app-side data is persisted about saved drawings. Once `SaveToDeviceAsync` returns, the PNG is the OS's responsibility.
---
## 29. Boot & Error Handling
> **Status: not implemented.** No `AppBoot` class exists. Today, [RootLifetimeScope.cs](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. Verify required Unity packages are installed (check `Packages/manifest.json`): VContainer, UniTask, Addressables, Input System, URP, **Spine-Unity runtime** (`com.esotericsoftware.spine.spine-unity`) for the main-menu mascot, DOTween (for snap/return tweens).
3. Open `Assets/Darkmatter/Scenes/Boot.unity` (currently the only scene wired).
4. Inspect the `RootLifetimeScope` GameObject — confirm its `serviceModules[]` list references the child installer MonoBehaviours (`AudioServiceModule`, `CameraServiceModule`, `InputServiceModule`, etc.).
5. Hit Play from `Boot.unity`. Other scenes (`MainMenu`, `ColorBook`) don't exist yet — they're listed in §6 / §4c as planned work.
6. When new scene scopes land, the same rule applies: never start a scene mid-flow, always enter from `Boot.unity` so the root scope exists.
7. When drawings are authored: duplicate the template folder under `Content/Gameplay/Drawings/<theme>/<id>/`, edit `Template.asset` (pieces + regions), add to the appropriate Addressables group.
8. Run `Tests > EditMode` and `Tests > PlayMode` before pushing (test infra not set up yet — see §16).
---
## 31. Quick Reference — Class ↔ Layer ↔ Asmdef
| Class | Layer | Asmdef |
|---|---|---|
| `IDrawingTemplate`, `IDrawingTemplateCatalog`, `IDrawingCatalogController` | Core | `Core` |
| `ColorRegionDTO`, `PaintCommandDTO`, `ColorPaletteSO` | Core | `Core` |
| `ShapeSO`, `ShapeBuilderConfig` (ScriptableObjects) | Core | `Core` |
| `DrawingProgress`, `DrawingPhase`, `ProgressionRootDto`, `RegionColorEntry` | Core | `Core` |
| `ICommand`, `IUndoStack`, `IProgressionSystem` | Core | `Core` |
| `UndoStack`, `HistoryButtonsView`, `HistoryButtonsPresenter` | Features | `Features.History` |
| `AddressableAssetProviderService` | Services | `Services.Assets` |
| `NativeGallerySaveService` | Services | `Services.Gallery` |
| `CaptureService` | Services | `Services.Capture` |
| `ColoringController`, `ColoringStateRepository`, `ColorRegionView`, `ColorPaletteHolderView`, `ColorPaletteHolderPresenter`, `PaintRegionCommand` | Features | `Features.Coloring` |
| `ShapePiece`, `SlotMarker`, `ShapeBuilderController`, `IShapePieceFactory`, `ShapePieceFactory`, `ShapeHolderView`, `ShapeHolderPresenter` | Features | `Features.ShapeBuilder` |
| `AddressableDrawingTemplateCatalog` | Features | `Features.DrawingTemplate` |
| `DrawingCatalogController`, `DrawingCatalogPresenter`, `DrawingCatalogView`, `CatalogItemVM` | Features | `Features.DrawingCatalog` |
| `ColorbookFlowController` | Features | `Features.Colorbook` |
| `GameplayFlowController` | Features | `Features.GameplayFlow` |
| `ProgressionSystem`, `ProgressionRepository` | Features | `Features.Progression` |
| `MenuMascotView`, `MenuMascotPresenter` | Features | `Features.MainMenu` |
| `RootLifetimeScope`, `MainMenuLifetimeScope`, `ColorbookLifetimeScope`, `GameplayLifetimeScope`, `GameplaySceneRefs`, `AppBoot` | App | `Darkmatter.App` |
If a class's natural home doesn't match its asmdef, the architecture is bent — fix the placement, don't add a reference.
### 31b. Scripts inventory by domain
Comprehensive index — every script (existing or planned) grouped by its module. Use this as the single-page mental map. Status column: ✅ exists on disk, ⚠️ planned.
#### Core
| Module (path) | Scripts | Status |
|---|---|---|
| `Core/Compatibility/` | `IsExternalInit` | ✅ |
| `Core/Contracts/Services/Assets/` | `IAssetProviderService` | ✅ |
| `Core/Contracts/Services/Audio/` | `IAudioService`, `ISfxPlayer` | ✅ |
| `Core/Contracts/Services/Camera/` | `ICameraService` | ✅ |
| `Core/Contracts/Services/Inputs/` | `IInputReader` | ✅ |
| `Core/Contracts/Services/Scenes/` | `ISceneService` | ✅ |
| `Core/Contracts/Services/Capture/` | `ICaptureService` | ⚠️ |
| `Core/Contracts/Services/Gallery/` | `IGalleryService` | ⚠️ |
| `Core/Contracts/Features/DrawingCatalog/` | `IDrawingCatalogController`, `IDrawingTemplate`, `IDrawingTemplateCatalog` | ✅ |
| `Core/Contracts/Features/Coloring/` | `IColorPalette` | ✅ |
| `Core/Contracts/Features/History/` | `ICommand`, `IUndoStack` | ✅ |
| `Core/Contracts/Features/Progression/` | `IProgressionSystem` | ✅ |
| `Core/Data/Dynamic/Services/Audio/` | `AudioHandle`, `AudioRequest` | ✅ |
| `Core/Data/Static/Services/Audio/` | `SfxCatalogSO` | ✅ |
| `Core/Data/Static/Features/DrawingTemplate/` | `DrawingTemplateSO` | ✅ |
| `Core/Data/Static/Features/ShapeBuilder/` | `ShapeSO`, `ShapeBuilderConfig` | ✅ |
| `Core/Data/Static/Features/Coloring/` | `ColorPaletteSO` | ✅ |
| `Core/Data/Dynamic/Features/Coloring/` | `ColorRegionDTO`, `PaintCommandDTO` | ✅ |
| `Core/Data/Dynamic/Features/Progression/` | `DrawingProgress`, `ProgressionRootDto`, `RegionColorEntry` | ✅ |
| `Core/Data/Signals/Features/DrawingCatalog/` | `DrawingSelectedSignal` | ✅ |
| `Core/Data/Signals/Features/ShapeBuilder/` | `ShapeAssembledSignal`, `PieceSnappedSignal` | ✅ |
| `Core/Data/Signals/Features/Coloring/` | `ColorAppliedSignal` | ⚠️ |
| `Core/Data/Signals/Features/Capture/` | `PaperCapturedSignal`, `PaperSavedSignal` | ⚠️ |
| `Core/Enums/Features/Progression/` | `DrawingPhase` (`ShapeBuilding`, `Coloring`) | ✅ |
| `Core/Enums/Services/Audio/` | `SfxId` (`None`, `ShapeHover`, `ShapeSnap`, `ShapeNiceTry`, `ShapeReturn`) | ✅ |
| `Core/Enums/Services/Camera/` | `CameraType` (add `CaptureCamera` value) | ✅ |
| `Core/Enums/Services/Scenes/` | `GameScene` | ✅ |
#### Libs
| Module (path) | Scripts | Status |
|---|---|---|
| `Libs/FSM/` | `IState`, `State<T>`, `StateMachine` (abstract) | ✅ |
| `Libs/Installers/` | `IModule` | ✅ |
| `Libs/Observer/` | `IEventBus`, `EventBus` | ✅ |
| `Libs/PlayerPrefs/Runtime/` | `ProtectedPlayerPrefs`, `ProtectedPlayerPrefsSettings`, `PlayerPrefsKeys`, `PlayerPrefsKeyRegistry`, `LocalWriteTracker`, `PendingWriteResync` | ✅ |
| `Libs/PlayerPrefs/Editor/` | `PlayerPrefsEditorWindow`, `ProtectedPlayerPrefsGettingStartedWindow`, `ProtectedPlayerPrefsSettingsUtility`, `ProtectedPlayerPrefsSetupBootstrap` | ✅ |
| `Libs/UI/` | `ToggleButton`, `ToggleButtonGroup` | ✅ |
#### Services
| Module (path) | Scripts | Status |
|---|---|---|
| `Services/Analytics/Installers/` | `AnalyticsModule` | ✅ |
| `Services/Analytics/Systems/` | `FirebaseAnalyticsSystem` | ✅ |
| `Services/Assets/` | `AddressableAssetProviderService`, `AddressableLoadHandleTracker` | ✅ |
| `Services/Audio/` | `AudioService`, `SfxPlayer` | ✅ |
| `Services/Camera/Service/` | `CameraService` | ✅ |
| `Services/Camera/Installers/` | `CameraModule` | ✅ |
| `Services/Inputs/Generated/` | `GameInputs` (Input System codegen) | ✅ |
| `Services/Inputs/` | (Inputs feature partial — Reader + Installer location TBD) | ⚠️ |
| `Services/Scenes/` | `SceneService` | ✅ |
| `Services/Capture/Systems/` | `CaptureService` | ✅ (stub) |
| `Services/Capture/Installers/` | `CaptureModule` | ✅ |
| `Services/Gallery/Core/` | `GalleryService` | ✅ (stub — needs native plugin wiring) |
| `Services/Gallery/Installers/` | `GalleryModule` | ✅ |
#### Features
| Module (path) | Scripts | Status |
|---|---|---|
| `Features/History/Stack/` | `UndoStack` | ✅ |
| `Features/History/Installers/` | `HistoryFeatureModule` | ✅ |
| `Features/History/UI/` | `HistoryButtonsView`, `HistoryButtonsPresenter` | ✅ |
| `Features/MainMenu/Installers/` | `MainMenuFeatureModule` | ⚠️ |
| `Features/MainMenu/Systems/` | `MainMenuModel`, `MenuMascotPresenter` | ⚠️ |
| `Features/MainMenu/UI/` | `MenuMascotView`, `IMenuMascotView` | ⚠️ |
| `Features/DrawingCatalog/Systems/` | `DrawingCatalogController` | ✅ |
| `Features/DrawingCatalog/UI/` | `DrawingCatalogPresenter`, `DrawingCatalogView`, `DrawingCatalogButton`, `CatalogItemVM` | ✅ |
| `Features/DrawingCatalog/Installers/` | `DrawingCatalogFeatureModule` | ✅ |
| `Features/DrawingTemplate/Systems/` | `AddressableDrawingTemplateCatalog` | ✅ |
| `Features/DrawingTemplate/Installers/` | `DrawingTemplateFeatureModule` | ✅ |
| `Features/ShapeBuilder/UI/` | `ShapePiece`, `SlotMarker`, `ShapeHolderView`, `ShapeHolderPresenter` | ✅ |
| `Features/ShapeBuilder/Systems/` | `ShapeBuilderController`, `IShapePieceFactory`, `ShapePieceFactory` | ✅ |
| `Features/ShapeBuilder/Installers/` | `ShapeBuilderFeatureModule` | ✅ |
| `Features/Coloring/Systems/` | `ColoringController`, `ColoringStateRepository` | ⚠️ planned |
| `Features/Coloring/UI/` | `ColorRegionView` ✅, `ColorPaletteHolderView` ✅, `ColorPaletteHolderPresenter` ✅, `ColorButton` ⚠️ planned | |
| `Features/Coloring/Systems/` | `IColorButtonFactory`, `ColorButtonFactory` | ⚠️ planned |
| `Features/Coloring/Commands/` | `PaintRegionCommand` | ⚠️ |
| `Features/Coloring/Installers/` | `ColoringFeatureModule` | ⚠️ |
| `Features/Capture/Systems/` | `CaptureController` (light wrapper around `ICaptureService`) | ⚠️ |
| `Features/Capture/UI/` | `CaptureButtonPresenter`, `SaveToastView` | ⚠️ |
| `Features/Capture/Installers/` | `CaptureFeatureModule` | ⚠️ |
| `Features/Progression/Systems/` | `ProgressionSystem`, `ProgressionRepository` | ✅ (stubs) |
| `Features/Progression/Installers/` | `ProgressionFeatureModule` | ✅ |
| `Features/ColorbookFlow/System/` | `ColorbookFlowController` | ✅ (needs constructor injection) |
| `Features/ColorbookFlow/Installers/` | `ColorbookFlowFeatureModule` | ✅ |
| `Features/GameplayFlow/Systems/` | `GameplayFlowController` (single owner of all saves — §13) | ⚠️ |
| `Features/GameplayFlow/Installers/` | `GameplayFlowFeatureModule` | ⚠️ |
#### App
| Module (path) | Scripts | Status |
|---|---|---|
| `App/LifetimeScopes/` | `BaseLifetimeScope` (abstract), `RootLifetimeScope` | ✅ |
| `App/LifetimeScopes/` | `GameLifetimeScope` (placeholder, empty), `GameplayLifetimescope` (typo — needs rename to `GameplayLifetimeScope`) | ⚠️ |
| `App/LifetimeScopes/` | `MainMenuLifetimeScope`, `ColorbookLifetimeScope`, `GameplayLifetimeScope` (final) | ⚠️ planned |
| `App/Boot/` | `AppBoot` | ⚠️ planned |
| `Features/GameplayFlow/SceneRefs/` | `GameplaySceneRefs` (PaperRoot only — per-feature containers moved to holder views) | ✅ |
---
## 32. Class Reference (Detailed)
> **Status: target spec, mostly unimplemented.** Existing on disk: `AddressableAssetProviderService`, `AudioService` / `SfxPlayer`, `CameraService`, `SceneService`, `InputReaderSO`, `FirebaseAnalyticsSystem`, plus the History feature (`UndoStack`, `HistoryServiceModule`, `ICommand`, `IUndoStack`). Everything else (MainMenu, DrawingCatalog, ShapeBuilder, Coloring, Capture, Gallery, Progression, ColorBookFlow, AppBoot, scene scopes) is the target shape for when those classes are written. Treat this section as a contract for new code, not documentation of current state.
Canonical breakdown of every concrete class and interface. For each: **purpose**, **public surface** (signatures), **injected dependencies**, and **collaborators** (signals or interfaces it talks to).
> Convention used below
> - `// fields:` = constructor-injected dependencies
> - `// pub:` = events / signals fired
> - `// sub:` = events / signals consumed
> - All async returns are `UniTask` unless noted.
> - Folder labels follow the actual nesting pattern: `Core/Contracts/Features/<Name>/`, `Core/Contracts/Services/<Name>/`, `Core/Data/Dynamic/Features/<Name>/`, `Features/<Name>/<Sub>/`, `Services/<Name>/<Sub>/`.
---
### 32.1 Core Contracts
Pure interfaces and DTOs. Zero logic.
#### `IDrawingTemplate` *(Core/Contracts/Features/Drawing — planned)*
Immutable view of a single drawing's authored data.
```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<ShapeSO> Pieces { get; } // shapes spawned in tray (reusable across drawings)
IReadOnlyList<ColorRegionDTO> Regions { get; } // for Coloring
}
```
Implemented by `DrawingTemplateSO` (ScriptableObject) loaded via Addressables. The per-drawing slot positions live in the drawing's authored scene/prefab as `SlotMarker` MonoBehaviours, not in the template SO.
> The catalog grid always uses `DefaultThumbnail`. The user's captured drawings live in the phone's Photos app, not the catalog cell.
#### `IDrawingTemplateCatalog` *(Core/Contracts/Features/Drawing — planned)*
Authority on which drawings exist, completion state, and "next" selection.
```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)*
Thin shim over a native gallery plugin. Saves PNG bytes into the phone's Photos app. **Does not** track files, thumbnails, or sidecar metadata — the OS owns the file once it's saved.
```csharp
public interface IGalleryService {
UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book");
}
```
- No list / load / delete operations. The user uses the phone's Photos app for those.
- Implementation (`NativeGallerySaveService`) wraps a third-party native plugin and handles platform permission prompts.
#### `ICaptureService` *(Core/Contracts/Services/Capture — planned)*
Snapshots the paper area to a PNG blob. No arguments — implementation owns the disabled `CaptureCamera` reference.
```csharp
public interface ICaptureService {
UniTask<byte[]> CaptureAsync();
}
```
- Independent of `IGalleryService`. Returns raw PNG bytes; what happens next is the caller's call (save, share, discard).
#### Removed contracts
- `IPaperRig`, `IArtInputBridge`, `IPaperSurface` — paper is just RectTransforms in the scene now, exposed via `GameplaySceneRefs.PaperRoot`. No contract.
- `SavedArtworkDTO`, `IGalleryService.ListAsync/LoadFullAsync/LoadThumbnailAsync/DeleteAsync/GetLatestThumbnailAsync` — no app-side gallery store.
- `ArtworkCapturedSignal`, `ArtworkSavedSignal` — replaced by `PaperCapturedSignal` / `PaperSavedSignal` (templateId only, no DTO).
#### `IProgressionService` *(Core/Contracts/Features/Progression — planned)*
Tracks which templates the child has completed and what they last opened.
```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.
#### `NativeGallerySaveService` *(Services/Gallery — planned)*
Implements `IGalleryService` as a thin wrapper around a native gallery plugin.
```csharp
// fields:
// INativeGalleryPlugin _plugin (third-party native bridge)
// IEventBus _bus (optional — for failure surfacing)
public sealed class NativeGallerySaveService : IGalleryService {
public async UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book") {
var permission = await _plugin.RequestWritePermissionAsync();
if (permission != Permission.Granted) {
// toddler app — silently skip; or publish a failure signal for HUD retry prompt
return;
}
await _plugin.SaveImageToAlbumAsync(png, albumName, fileName: $"colorbook_{DateTime.UtcNow:yyyyMMdd_HHmmss}.png");
}
}
```
- **No file IO**, no thumbnails, no sidecars. The native plugin owns everything past the call.
- **Permission flow** runs once per session; the plugin caches the grant.
- **Failure handling**: a toddler app shouldn't crash or block on denial — silently skip and let the user keep playing.
#### `RenderTextureCaptureService` *(Services/Capture — planned)*
Implements `ICaptureService`. Drives the scene's disabled `CaptureCamera` once per capture.
```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.
#### Persistence (no dedicated service)
There is no `IPersistenceService` / JSON file writer. `Libs/PlayerPrefs` (`ProtectedPlayerPrefs`) is the only persistent storage in the app. `IProgressionService` consumes it directly.
- **Backing store:** `PlayerPrefs` via the encrypted wrapper.
- **Keys:** namespaced strings registered in `PlayerPrefsKeyRegistry`.
- **Format per value:** JSON-encoded primitives (`ScriptableObject.CreateInstance` not needed at this layer).
- **Atomicity:** `PlayerPrefs` is already atomic per key on iOS/Android.
#### `SceneService` *(Services/Scenes — ✅ exists)*
Implements `ISceneService`. Wraps `SceneManager.LoadSceneAsync` with `UniTask` plus a fade-curtain.
```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.3b Feature — `MainMenu` *(planned)*
Lives in `MainMenu.unity`. Hosts the **Play** / **Art Book** entry buttons plus the **Spine character mascot**.
#### `IMenuMascotView` *(UI contract)*
Setter-only view interface. Hides Spine-Unity's API behind a tiny surface.
```csharp
public interface IMenuMascotView {
event Action<string> AnimationComplete; // fires when a non-looping anim ends
void Play(string animName, bool loop);
void SetSkin(string skinName); // optional — character variants
}
```
#### `MenuMascotView : MonoBehaviour, IMenuMascotView` *(UI)*
Concrete view. Wraps a `SkeletonGraphic` component (Spine-Unity's Canvas-compatible renderer).
```csharp
public sealed class MenuMascotView : MonoBehaviour, IMenuMascotView {
[SerializeField] private SkeletonGraphic _skeleton; // Spine UI component
public event Action<string> AnimationComplete;
public void Play(string animName, bool loop) {
var track = _skeleton.AnimationState.SetAnimation(0, animName, loop);
track.Complete += _ => AnimationComplete?.Invoke(animName);
}
public void SetSkin(string skinName) {
_skeleton.Skeleton.SetSkin(skinName);
_skeleton.Skeleton.SetSlotsToSetupPose();
_skeleton.AnimationState.Apply(_skeleton.Skeleton);
}
}
```
- `SkeletonGraphic` lives on a child of `MainMenuCanvas`. It's a `Graphic`, so it interacts with `CanvasRenderer` just like an `Image`.
- The Spine asset (`SkeletonDataAsset`) is loaded via Addressables, assigned at scene setup, and released on scene exit.
#### `MenuMascotPresenter` *(UI)* — `IStartable, IDisposable`
Pure C#. Subscribes to button events + idle timer, drives the view.
```csharp
// fields: IMenuMascotView _view, MainMenuModel _model, IInputReader _input
public sealed class MenuMascotPresenter : IStartable, IDisposable {
public void Start() {
_view.Play("idle", loop: true);
_model.PlayButtonHovered += () => _view.Play("hover_play", loop: false);
_model.PlayButtonPressed += () => _view.Play("wave", loop: false);
_view.AnimationComplete += OnAnimationComplete;
}
private void OnAnimationComplete(string anim) {
if (anim != "idle") _view.Play("idle", loop: true); // always return to idle
}
}
```
- Mascot reactions are pure presenter logic — the view never decides what to play.
- If you want randomized idle variants, add an idle timer in the model + a list of clip names.
#### `MainMenuModel` *(Repository)*
Holds menu state — current selected skin, fires hover/click events from button presenters.
#### `MainMenuModule : MonoBehaviour, IServiceModule` *(Installers)*
Registers the view (`RegisterInstance<IMenuMascotView>(_view)`), the presenter as a startable entry point, and the model.
> **Package dependency:** [Spine-Unity runtime](http://esotericsoftware.com/spine-unity) (`com.esotericsoftware.spine.spine-unity`). Add to `Packages/manifest.json`. The `SkeletonGraphic` component lives in `Spine.Unity` namespace.
---
### 32.4 Feature — `DrawingCatalog` *(planned)*
#### `DrawingCatalogController` *(Systems)*
Headless logic. Owns the list of template IDs visible in the grid.
```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 controller, MBs exist)*
The simplified post-FSM design. **No state machine per piece, no factory class.** Three roles: piece MB, slot MB, controller. Plus a tunables SO.
#### `ShapePiece : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler` *(UI — ✅ exists)*
The single MonoBehaviour that handles drag, reactive preview lerp, snap (PrimeTween), and return-to-tray. Spawned by the controller; `Setup` binds dependencies and starting pose.
```csharp
public sealed class ShapePiece : MonoBehaviour,
IBeginDragHandler, IDragHandler, IEndDragHandler
{
[SerializeField] private Image image;
private ShapeSO _shape; // bound by Setup
private SlotMarker _slot;
private ShapeBuilderConfig _cfg;
private ISfxPlayer _sfx;
private IEventBus _bus;
private Vector2 _trayPos;
private Vector2 _traySize;
private bool _locked;
public ShapeSO Shape => _shape;
public string PieceId => _shape != null ? _shape.Id : null;
public bool IsLocked => _locked;
public RectTransform RectTransform { get; }
public void Setup(
ShapeSO shape, SlotMarker slot, ShapeBuilderConfig cfg,
ISfxPlayer sfx, IEventBus bus,
Vector2 trayPos, bool preSnapped);
}
```
- Drag handlers run inline in this MB — no separate FSM class.
- `Snap()` is a PrimeTween triple (`Tween.UIAnchoredPosition` / `UISizeDelta` / `LocalRotation`); `SnapInstantly()` is the resume path that puts a pre-snapped piece directly into its slot pose without animation.
- `ReturnToTray()` builds a PrimeTween `Sequence` of three parallel tweens.
- See §26 for the snap algorithm walkthrough.
#### `SlotMarker : MonoBehaviour` *(UI — ✅ exists)*
Authored per drawing — designer places one in the per-drawing prefab at each slot location with the `RectTransform` set to the target pose and `_shape` field assigned to the matching `ShapeSO`. The `RectTransform` itself **is** the target pose.
```csharp
public sealed class SlotMarker : MonoBehaviour
{
[SerializeField] private ShapeSO shape;
[SerializeField] private Image outline; // optional faint outline UI
public ShapeSO Shape => shape;
public string SlotId => shape != null ? shape.Id : null;
public RectTransform RectTransform => (RectTransform)transform;
public void SetOutlineVisible(bool visible);
}
```
**Matching is by `ShapeSO` reference equality** — the controller pairs each `ShapePiece.Shape` with the `SlotMarker.Shape` of the same SO asset.
#### `ShapeBuilderConfig : ScriptableObject` *(Static data — ✅ exists)*
```csharp
[CreateAssetMenu(menuName = "Darkmatter/ShapeBuilder/Config")]
public sealed class ShapeBuilderConfig : ScriptableObject
{
public float SnapRadius; // 80120 canvas units
public float SnapGraceMultiplier; // (currently unused — grace zone removed)
public float PreviewRadius; // ~2× SnapRadius
public float SnapDuration; // 0.25s
public float ReturnDuration; // 0.25s
public AnimationCurve PreviewCurve; // easing for the reactive lerp
public Vector2 DragSizeDelta(ShapeSO shape);
}
```
#### `ShapeBuilderController` *(Systems — ✅ exists)*
Orchestrates per-template build: loads piece prefab, instantiates drawing layout under `PaperRoot`, computes tray positions from `ShapeHolderView.SpawnWidth`, publishes `ShapeBuilderStartedSignal`, delegates per-piece spawn to `IShapePieceFactory`. Subscribes to `PieceSnappedSignal` and fires `ShapeAssembledSignal` when count matches expected.
```csharp
// fields: IEventBus _bus, IAssetProviderService _assetProviderService,
// IShapePieceFactory _factory, ShapeHolderView _holder, IGameplaySceneRefs _refs
public sealed class ShapeBuilderController : IShapeBuilderController, IDisposable
{
private GameObject _piecePrefab;
public async UniTask InitializeAsync(CancellationToken ct); // load prefab + subscribe
public IReadOnlyCollection<string> GetSnappedPieceIds(); // for save records
public async UniTask BuildAsync(
IDrawingTemplate template,
IReadOnlyCollection<string> preSnappedIds,
CancellationToken ct = default);
public void Clear(); // destroys current drawing instance + resets counters
}
// sub: PieceSnappedSignal
// pub: ShapeBuilderStartedSignal (before spawn), ShapeAssembledSignal (when all locked)
```
- **Slot discovery:** after the per-drawing prefab is instantiated, `GetComponentsInChildren<SlotMarker>(includeInactive: true)` finds all slots. Each slot's `_shape` tells which `ShapeSO` it expects.
- **Pre-snap on resume:** if `preSnappedIds.Contains(shape.Id)`, the factory is called with `preSnapped: true``ShapePiece.SnapInstantly()` lands it in the slot at scope start.
- **Tray width source:** read from `_holder.SpawnWidth` — the controller never touches `_refs` for HUD geometry.
#### `IShapePieceFactory` / `ShapePieceFactory` *(Systems — ✅ exists)*
Encapsulates piece instantiation. Owns the parent (from `ShapeHolderView.SpawnRoot`) and the per-piece deps so the controller stays focused on flow.
```csharp
public interface IShapePieceFactory {
ShapePiece Create(GameObject prefab, ShapeSO shape, SlotMarker slot, Vector2 trayPos, bool preSnapped);
}
// fields: ShapeHolderView _holder, ShapeBuilderConfig _cfg, ISfxPlayer _sfx, IEventBus _bus, IUndoStack _undo
public sealed class ShapePieceFactory : IShapePieceFactory {
public ShapePiece Create(GameObject prefab, ShapeSO shape, SlotMarker slot, Vector2 trayPos, bool preSnapped) {
var go = Object.Instantiate(prefab, _holder.SpawnRoot);
var piece = go.GetComponent<ShapePiece>();
piece.Setup(shape, slot, _cfg, _sfx, _bus, _undo, trayPos, preSnapped);
return piece;
}
}
```
#### `ShapeHolderView` / `ShapeHolderPresenter` *(UI — ✅ exists)*
View owns the tray container `RectTransform` (`SpawnRoot`) and a PrimeTween `Sequence` driving slide + alpha on a `CanvasGroup`. Default hide direction is `+X = 1500` (slide right off-screen), tunable per inspector. Public: `SpawnRoot`, `SpawnWidth`, `Show()`, `Hide()`, `HideInstant()`. Presenter is an `IStartable, IDisposable`:
- On `Start`: `view.HideInstant()`, subscribe `ShapeBuilderStartedSignal``Show`, subscribe `ShapeAssembledSignal``Hide`.
- On `Dispose`: dispose subscriptions.
#### Removed / not needed
- **`TrayLayout`** — was a stateless tray-position helper. Tray positions are now computed inline from `holder.SpawnWidth` (~3 lines).
- **`ShapePieceFsm`** — was a per-piece state machine. Replaced by inline drag handlers + a single `_locked` bool on `ShapePiece`.
- **Five state classes** (`InTray`, `Dragging`, `Preview`, `Snapped`, `Returning`) — gone. Their behavior maps to: `_locked = false` (idle/dragging/preview all share the same handlers), `_inPreview` flag (preview boundary detection), `Snap()` method, `ReturnToTray()` method.
- **`ShapeBuilderInputBinder`** — never needed; UI handlers on the piece are sufficient.
---
### 32.6 Feature — `Coloring` *(planned)*
#### `ColoringStateRepository` *(Repository)*
In-memory model. Owns "currently selected color" and the palette in use.
```csharp
public sealed class ColoringStateRepository {
public IColorPalette Palette { get; private set; }
public Color CurrentColor { get; private set; }
public event Action<Color> SelectedColorChanged;
public void SetPalette(IColorPalette palette); // resets CurrentColor to Colors[0]
public void SelectColor(Color color); // called directly by ColorButton on click
}
```
- **Why a repository:** `ColorButton` (palette taps) and `ColoringController` (paint command builder) both need to read/write the current color; an event-emitting POCO is simpler than wiring two signals.
- **Color-keyed, not index-keyed:** with `ColorButton + Factory` (§25), each button owns its `Color` directly. Index-based addressing is unnecessary — the button passes its color straight to `SelectColor(color)`.
#### `ColoringController` *(Systems — planned)* — implements `IColoringController`
Wires pre-authored `ColorRegionView` children, applies saved colors on resume, builds and pushes `PaintRegionCommand` instances on click.
```csharp
// fields: IUndoStack _undo, ColoringStateRepository _state, IGameplaySceneRefs _refs, IEventBus _bus
public interface IColoringController {
// Initialize regions on the paper after the drawing prefab is instantiated.
// Pass non-null savedColors to restore colors from a DrawingProgress record;
// null = use ColorRegionDTO.InitialColor.
UniTask InitializeRegionsAsync(
IDrawingTemplate template,
IReadOnlyDictionary<string, Color> savedColors = null);
void PaintRegion(ColorRegionView view); // builds command, pushes to undo stack
// Snapshot current paint state for save records (see §13).
IReadOnlyDictionary<string, Color> GetCurrentColors();
void Clear(); // detach handlers
}
// sub: ShapeAssembledSignal (via flow controller, not direct)
// pub: ColorAppliedSignal (via PaintRegionCommand)
```
Regions are pre-authored as children of the drawing prefab (parented under `PaperRoot`) — the controller does not spawn them. Each `ColorRegionView` sets its own `Image.alphaHitTestMinimumThreshold` in `Awake`; the controller subscribes to each view's click event and routes to `PaintRegion`. No `Physics2D`.
**Autosave integration:** after each successful `PaintRegion`, the controller calls a debounced `GameplayFlowController.ScheduleAutosave()` so the flow can write the new color state to `IProgressionSystem` 500 ms later (no thumbnail, cheap). The flow controller cancels and resets the timer on each paint — only the last paint in a burst triggers the write.
#### `ColorRegionView : MonoBehaviour, IPointerClickHandler` *(UI — ✅ exists)*
UI Image with alpha-based hit detection. Tap routes through Unity's EventSystem directly to `OnPointerClick`.
```csharp
[RequireComponent(typeof(Image))]
public sealed class ColorRegionView : MonoBehaviour, IPointerClickHandler {
[field: SerializeField] public string RegionId { get; private set; }
[SerializeField, Range(0f, 1f)] private float alphaHitThreshold = 0.01f;
public Color Color => _image.color;
private void Awake() {
_image = GetComponent<Image>();
_image.alphaHitTestMinimumThreshold = alphaHitThreshold; // pass-through transparent pixels
}
public void Initialize(string id, Color color);
public void SetColor(Color color);
public void OnPointerClick(PointerEventData e); // routes to ColoringController.PaintRegion(this)
}
```
- **Required sprite import:** **Read/Write Enabled = on**, **Mesh Type = Full Rect**, keep alpha channel (RGBA32 or Automatic-with-alpha). Without these, Unity throws `UnityException: Texture is not readable` on first hover/click.
- **Threshold tuning:** default `0.01f` is very permissive (any non-fully-transparent pixel counts). Raise toward `0.5f` for tighter hits when regions overlap visually.
- **Sibling order matters** for stacked regions — top sibling gets first crack at the click; with alpha hit-test, transparent areas defer correctly to siblings below.
No `ColoringInputBinder` class needed. Unity's EventSystem fires `OnPointerClick` on the topmost UI element under the pointer whose `Image.alphaHitTestMinimumThreshold` is met.
#### `PaintRegionCommand` *(Commands)*
Source in section 23. Holds `view`, `fromColor`, `toColor`, `bus`. Symmetrical execute/undo.
#### `ColorButton` + `IColorButtonFactory` / `ColorButtonFactory` *(UI + Systems — planned)*
Replaces the View/Presenter pair for the palette. Each color is its own self-contained MB with click handler; factory instantiates the prefab under `ColorPaletteHolderView.SpawnRoot` and runs `Setup(color, state, sfx)`. Button writes `_state.SelectColor(_color)` on click. See §25 for the full pattern and rationale. The stale `ColorPaletteView.cs` / `ColorPalettePresenter.cs` stubs should be deleted when this lands.
#### `ColorPaletteHolderView` / `ColorPaletteHolderPresenter` *(UI — ✅ exists)*
View owns the palette container `RectTransform` (`SpawnRoot`) + slide/fade show-hide animation (same `Tween.UIAnchoredPosition` + `Tween.Alpha` pattern as `ShapeHolderView`, default hide off-screen right). Presenter subscribes `ShapeAssembledSignal``view.Show()`. Hide is invoked externally (scope tear-down or future phase-exit signal).
#### Removed / not needed
- **`ColorRegionFactory`** — regions are pre-authored as children of the drawing prefab. The controller wires existing views; it doesn't spawn anything.
- **`ColorPaletteView` + `ColorPalettePresenter`** — replaced by self-contained `ColorButton` + `ColorButtonFactory`. The stub files at `Features/Coloring/UI/ColorPaletteView.cs` and `ColorPalettePresenter.cs` should be deleted; see §25 for rationale.
---
### 32.7 Feature — `History` *(planned)*
#### `HistoryController` *(Systems)* — `IStartable, IDisposable`
Owns the per-session `IUndoStack` (registered scoped, so a new ColorBook scene = new stack).
```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 "Save" button. Owns the capture-then-save chain. Stateless other than guarding against concurrent captures.
```csharp
// fields: ICaptureService _capture, IGalleryService _gallery, IEventBus _bus
public sealed class CaptureController {
public bool IsBusy { get; }
public UniTask SaveAsync(string templateId);
}
// pub: PaperCapturedSignal (mid-flow), PaperSavedSignal (after native save)
```
- **Flow:** `_capture.CaptureAsync()` → publish `PaperCapturedSignal``_gallery.SaveToDeviceAsync(bytes)` → publish `PaperSavedSignal`.
- **Concurrency:** sets `IsBusy = true` on entry; UI binds button enabled to `!IsBusy` to prevent double-tap.
- **No camera args** — `ICaptureService` owns the `CaptureCamera` reference.
- **No file-IO awareness** — `IGalleryService` handles the native plugin handoff.
#### `CaptureButtonPresenter` *(UI)*
Wires button click → `CaptureController.SaveAsync(currentTemplateId)`. Disables button while `IsBusy`. Shows a "Saved to Photos" toast on `PaperSavedSignal`.
---
### 32.9 Feature — `Progression` *(planned)*
#### `ProgressionService` *(Systems)* — implements `IProgressionService`
The only place that knows what "completed" means.
- **Persistence:** delegates to `ProtectedPlayerPrefs` (`Libs.PlayerPrefs`) under key `"progression"`.
- **Load order:** `AppBoot` calls `LoadAsync()` early.
- **Save trigger:** after `MarkCompleted`, debounced 500 ms to coalesce a burst of "Next" presses.
#### `ProgressionRepository` *(Repository)*
Pure in-memory holder used by the service. Separated so tests can inspect state without going through file IO.
---
### 32.10 Feature — `ColorBookFlow` *(planned)*
#### `ColorBookFlowController` *(Systems)* — `IStartable, IDisposable`
**The only orchestrator inside the ColorBook scene.** Drives the panel FSM: `Catalog → Building → Coloring → Done`.
```csharp
// fields:
// IEventBus _bus
// IDrawingTemplateCatalog _catalog
// ShapeBuilderController _builder
// IColoringController _coloring
// CaptureController _capture
// IProgressionService _progression
// IGameplaySceneRefs _refs (PaperRoot only — panel show/hide owned by holder views)
// Fsm<ColorBookState> _fsm
```
- **State table:**
| State | On enter | Triggers exit |
|------------|-----------------------------------------------|--------------------------------|
| `Catalog` | Show catalog panel | `DrawingSelectedSignal` |
| `Building` | `_builder.BuildAsync(id)` | `ShapeAssembledSignal` |
| `Coloring` | `_coloring.SpawnRegionsAsync(template)` | "Next" or "Capture" pressed |
| `Done` | Run autosave capture, mark completed, `Go(Catalog)` for next | always advances |
- **"Next" sequence:** `_capture.CaptureCurrentAsync``_progression.MarkCompleted``_catalog.Release(current)``_catalog.LoadAsync(_catalog.NextUnseen(current))` → re-enter `Building`.
---
### 32.12 App Layer
#### `AppBoot` *(App/Boot — planned; folder doesn't exist yet)* — `IAsyncStartable`
Single entry point. Steps in section 29.
```csharp
// fields: IAssetProviderService _assets, IProgressionService _progress,
// IAudioService _audio, ISceneService _scenes, BootConfig _cfg
public sealed class AppBoot : IAsyncStartable {
public UniTask StartAsync(CancellationToken ct);
}
```
#### LifetimeScopes
- `RootLifetimeScope` — ✅ exists ([source](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).
- `GameplayLifetimeScope` — planned. Same pattern; installer list includes feature installers + the flow controller installer. Also has a `[SerializeField] GameplaySceneRefs _sceneRefs;` and registers it via `builder.RegisterComponent<IGameplaySceneRefs>(_sceneRefs)`.
All scope classes are thin: a serialized installer-MonoBehaviour list (+ optional scene refs as separate fields) and a `Configure(IContainerBuilder)` that iterates and calls `Register`.
---
### 32.13 Cross-cutting types
#### `GameplaySceneRefs : MonoBehaviour, IGameplaySceneRefs` *(Features/GameplayFlow/SceneRefs — ✅ exists)*
Single source of the one cross-feature scene anchor (`PaperRoot`). Per-feature UI containers (tray, palette) live on their own holder views — see §32.X. Registered in `GameplayLifetimeScope` via `builder.RegisterComponent<IGameplaySceneRefs>(_sceneRefs)`.
```csharp
public sealed class GameplaySceneRefs : MonoBehaviour, IGameplaySceneRefs {
[SerializeField] private RectTransform paperRoot;
public RectTransform PaperRoot => paperRoot;
}
```
Replaces the dropped `IPaperSurface` contract. The drawing prefab (with `SlotMarker` + `ColorRegionView` children) is instantiated under `PaperRoot`; the `CaptureCamera` will live on its own scene MB owned by the Capture feature.
#### `ShapeHolderView : MonoBehaviour` + `ShapeHolderPresenter` *(Features/ShapeBuilder/UI — ✅ exists)*
Owns the tray container `RectTransform` (`SpawnRoot`) and the slide/fade show/hide animation (PrimeTween — `Tween.UIAnchoredPosition` + `Tween.Alpha` on `CanvasGroup`). Default hide offset is `+1500` on X (slide right off-screen), tunable per inspector. Exposes `SpawnRoot`, `SpawnWidth`, `Show()`, `Hide()`, `HideInstant()`. Presenter is an `IStartable` that subscribes:
- `ShapeBuilderStartedSignal``view.Show()`
- `ShapeAssembledSignal``view.Hide()`
Registered in `ShapeBuilderFeatureModule` via `builder.RegisterComponent(holderView)` + `builder.RegisterEntryPoint<ShapeHolderPresenter>().WithParameter(holderView)`.
#### `ColorPaletteHolderView : MonoBehaviour` + `ColorPaletteHolderPresenter` *(Features/Coloring/UI — ✅ exists)*
Same pattern as `ShapeHolderView` — owns the palette container `RectTransform` (`SpawnRoot`) and slide/fade animation. Presenter subscribes to `ShapeAssembledSignal``view.Show()`. Hide is invoked externally (scope tear-down or future phase-exit signal). Registered analogously in `ColoringFeatureModule`.
#### `IShapePieceFactory` / `ShapePieceFactory` *(Features/ShapeBuilder/Systems — ✅ exists)*
Encapsulates piece instantiation. Owns the per-piece dependencies and the spawn parent so the controller stays focused on flow.
```csharp
public interface IShapePieceFactory {
ShapePiece Create(GameObject prefab, ShapeSO shape, SlotMarker slot, Vector2 trayPos, bool preSnapped);
}
// fields: ShapeHolderView _holder, ShapeBuilderConfig _cfg, ISfxPlayer _sfx, IEventBus _bus, IUndoStack _undo
public sealed class ShapePieceFactory : IShapePieceFactory { /* Instantiate under _holder.SpawnRoot, run piece.Setup(...) */ }
```
#### `IServiceModule` *(Libs/Installers — ✅ exists)*
```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, progression, audio, scenes |
| `RootLifetimeScope` | App | Root DI | service modules |
| `MainMenuLifetimeScope` | App | Menu scene DI | feature modules |
| `ColorbookLifetimeScope` | App | Catalog scene DI | feature modules |
| `GameplayLifetimeScope` | App | Active drawing scene DI | scene refs, feature modules |
| `GameplaySceneRefs` | App | Scene-bound RectTransform + CaptureCamera holder | — |
| `MenuMascotView` | Feature.MainMenu | Spine mascot UI (`SkeletonGraphic` wrapper) | — |
| `MenuMascotPresenter` | Feature.MainMenu | Drives mascot animations from model events | view, model |
| `DrawingCatalogController` | Feature.DrawingCatalog | Visible-ID list + selection signal | catalog, progression, bus |
| `DrawingCatalogPresenter` | Feature.DrawingCatalog | UI bridge | view, controller, catalog, progression |
| `DrawingCatalogView` | Feature.DrawingCatalog | UI; renders cells | — |
| `CatalogItemVM` | Feature.DrawingCatalog | View-model per cell | — |
| `AddressableDrawingTemplateCatalog` | Feature.DrawingTemplate | Loads `DrawingTemplateSO`s, exposes `NextUnseen` | assets, progression |
| `ShapeSO` | Core asset | Authored shape (id + sprite + DefaultSizeDelta) | — |
| `ShapeBuilderConfig` | Core asset | Tunables (radii, durations, curve) | — |
| `ShapePiece` | Feature.ShapeBuilder | Draggable piece MB (drag + preview lerp + snap + return) | shape, slot, cfg, sfx, bus |
| `SlotMarker` | Feature.ShapeBuilder | Slot anchor MB; `RectTransform` == target pose | — |
| `ShapeBuilderController` | Feature.ShapeBuilder | Orchestrates per-template build, delegates spawn to factory, tracks snap count | bus, assets, factory, holder, refs |
| `IShapePieceFactory` / `ShapePieceFactory` | Feature.ShapeBuilder | Instantiates a `ShapePiece` under `ShapeHolderView.SpawnRoot` + runs `Setup` | holder, cfg, sfx, bus, undo |
| `ShapeHolderView` / `ShapeHolderPresenter` | Feature.ShapeBuilder | Tray container + slide/fade show/hide; presenter reacts to `ShapeBuilderStartedSignal` / `ShapeAssembledSignal` | bus |
| `ColoringStateRepository` | Feature.Coloring | Current color model | — |
| `ColoringController` | Feature.Coloring | Wires pre-authored regions + paint cmd + autosave hook | undo, state, refs, flow, bus |
| `ColorPaletteHolderView` / `ColorPaletteHolderPresenter` | Feature.Coloring | Palette container + slide/fade show/hide; presenter reacts to `ShapeAssembledSignal` | bus |
| `ColorButton` | Feature.Coloring | Self-contained palette swatch MB; click writes to repository (no view/presenter pair) | state, sfx |
| `IColorButtonFactory` / `ColorButtonFactory` | Feature.Coloring | Instantiates a `ColorButton` under `ColorPaletteHolderView.SpawnRoot` + runs `Setup` | holder, prefab, state, sfx |
| `ColorRegionView` | Feature.Coloring | Region UI Image + `IPointerClickHandler` | controller |
| `PaintRegionCommand` | Feature.Coloring | Undoable paint (sets `Image.color`) | view, bus |
| `HistoryController` | Feature.History | Undo/redo facade | undo stack, bus |
| `UndoStack` | Feature.History | Bounded undo store | — |
| `CaptureController` | Feature.Capture | (light wrapper) calls `ICaptureService.CaptureAsync` | capture svc |
| `ColorbookFlowController` | Feature.ColorbookFlow | Catalog scene orchestrator (init + selection→scene-load) | catalog, progression, scenes, bus |
| `GameplayFlowController` | Feature.GameplayFlow | Active drawing FSM + **single owner of all saves** (see §13) | catalog, builder, coloring, capture, gallery, progression, scenes, bus |
| `ProgressionSystem` | Feature.Progression | Per-template state + completed view | repository |
| `ProgressionRepository` | Feature.Progression | PlayerPrefs JSON + thumbnail file IO | — |
| `EventBus` | Lib.Observer | Pub/sub | — |
| `StateMachine` (abstract) + `State<T>` | Lib.FSM | Generic FSM base (Enter/Tick/Exit, ChangeState) | — |
| `IModule` | Lib.Installers | DI installer interface | — |
| `ProtectedPlayerPrefs` | Lib.PlayerPrefs | Encrypted PlayerPrefs wrapper | — |
| `AddressableAssetProviderService` | Service.Assets | Addressables wrapper | — |
| `CaptureService` | Service.Capture | One-shot PNG render via `CaptureCamera` | refs |
| `NativeGallerySaveService` | Service.Gallery | Native gallery save (thin plugin shim) | — |
| `SceneService` | Service.Scenes | Async scene loads | — |
| `AudioService`, `SfxPlayer` | Service.Audio | SFX playback | assets |
| `CameraService` | Service.Camera | Camera registry | — |
| `InputReaderSO` | Service.Inputs | New Input System reader | — |
| `FirebaseAnalyticsSystem` | Service.Analytics | Analytics events | — |
If you add a class not in this table, add it here in the same PR. This table is the cheap mental-model index — keep it honest. See §31b for the full path-by-path inventory.
> **What's real on disk today (2026-05):** All Service classes (`AddressableAssetProviderService`, `AudioService`/`SfxPlayer`, `CameraService`, `SceneService`, `InputReaderSO`, `FirebaseAnalyticsSystem`, stub `CaptureService`, stub `GalleryService`), all Lib classes, `ShapePiece` + `SlotMarker` + `ShapeBuilderConfig`, `UndoStack` + `HistoryServiceModule`, `ProgressionSystem` + `ProgressionRepository` (stubs), `AddressableDrawingTemplateCatalog` + module, `DrawingCatalogController` + presenter + view, `ColorbookFlowController` (partial — needs constructor injection wired). Empty / planned: `MainMenu` feature, `GameplayFlow` feature, `Coloring` feature, `MainMenu`/`Colorbook`/`Gameplay` scene scopes, all scenes except `Boot.unity`.