1869 lines
79 KiB
Markdown
1869 lines
79 KiB
Markdown
# Color Book — Architecture Guide
|
||
|
||
A toddler-targeted (ages 2–6) coloring book game built on the same **Strict Modular Monolith** pattern as the Bus Game. Powered by **VContainer** for DI, **UniTask** for async, **Addressables** for shipped content, and a **hybrid Sprites + Canvas** render strategy.
|
||
|
||
This document is the canonical reference for the Color Book game's structure. The Bus Game's [Darkmatter Architecture Guide](../Assets/Darkmatter_Architecture_Guide.md) is the parent contract; this doc only adds game-specific structure.
|
||
|
||
---
|
||
|
||
## 1. Game Flow
|
||
|
||
```
|
||
App launch
|
||
└─ Boot scene (RootLifetimeScope)
|
||
└─ MainMenu scene
|
||
├─ Press "Play" → ColorBook scene
|
||
│ ├─ Drawing catalog (grid of templates)
|
||
│ ├─ Select drawing
|
||
│ ├─ Shape Builder panel (drag pieces → snap to slots)
|
||
│ ├─ ↓ on assembly complete
|
||
│ ├─ Color panel (tap color → tap region)
|
||
│ ├─ Undo / Redo any time
|
||
│ ├─ "Capture" → save to Gallery with paper background
|
||
│ └─ "Next" → auto-save + load next drawing
|
||
└─ Press "Art Book" → ArtBook scene (gallery viewer)
|
||
├─ Grid of saved artworks
|
||
├─ View / share / delete
|
||
└─ Save to device camera roll
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Philosophy
|
||
|
||
Identical to Bus Game:
|
||
|
||
- **Vertical slices** — code grouped by Feature, not by type.
|
||
- **Strict layering** — dependencies flow downward only.
|
||
- **Composition over inheritance** — wired by DI.
|
||
- **Code vs Content** — `Code/` for logic, `Contents/` for assets.
|
||
|
||
Game-specific additions:
|
||
|
||
- **Toddler-first UX** — large hitboxes, forgiving snap radii, no fail states, no timers.
|
||
- **Stateless replay** — every action (paint, snap) is an `ICommand` so undo/redo and auto-save are trivial.
|
||
- **Capture-as-truth** — the gallery is a folder of PNGs on disk, not a serialized scene graph. What the child sees is what gets saved.
|
||
|
||
---
|
||
|
||
## 3. Dependency Graph
|
||
|
||
```
|
||
App ──────────┐
|
||
▼
|
||
Features ──► Core ◄── Services
|
||
▲
|
||
└── Libs
|
||
```
|
||
|
||
### Forbidden actions
|
||
|
||
- Services cannot reference Features.
|
||
- Features cannot reference Service implementations — only Core contracts via DI.
|
||
- Features cannot reference other Features — use `IEventBus` signals or Core contracts.
|
||
- Core may not contain logic. Interfaces, enums, DTOs, signal records only.
|
||
- Core may reference `UniTask` for async contract signatures. Nothing else.
|
||
|
||
---
|
||
|
||
## 4. Folder Structure
|
||
|
||
This section reflects the **actual project on disk today**. Empty folders that have been reserved for upcoming work are marked `(planned)`; everything else has at least one file in it. Aspirational additions for the rest of the game are listed in §4c at the bottom.
|
||
|
||
### 4a. Actual layout on disk
|
||
|
||
```
|
||
Assets/Darkmatter/
|
||
├── Scenes/
|
||
│ └── Boot.unity ← only scene wired so far
|
||
│
|
||
├── Content/ ← singular ("Content", not "Contents")
|
||
│ └── Gameplay/
|
||
│ └── PaperRig/ ← (planned — paper rig prefabs)
|
||
│
|
||
├── Data/
|
||
│ ├── Inputs/ (Input System .inputactions)
|
||
│ └── Settings/
|
||
│ ├── Persistance/Resources/ (ProtectedPlayerPrefs settings)
|
||
│ ├── Rendering/ (URP renderer + asset)
|
||
│ └── Scenes/URP2DSceneTemplate.unity
|
||
│
|
||
└── Code/
|
||
├── App/
|
||
│ └── LifetimeScopes/
|
||
│ └── RootLifetimeScope.cs ← scope loads serialized IServiceModule list
|
||
│ Darkmatter.App.asmdef
|
||
│
|
||
├── Core/ (asmdef name: `Core`, namespace root `Darkmatter.Core.*`)
|
||
│ ├── Compatibility/
|
||
│ │ └── IsExternalInit.cs (C#9 init shim for older runtimes)
|
||
│ ├── Contracts/
|
||
│ │ ├── Paper/ ← misplaced empty folder — should be Contracts/Features/Paper/ (delete or move)
|
||
│ │ └── Services/
|
||
│ │ ├── Assets/IAssetProviderService.cs
|
||
│ │ ├── Audio/IAudioService.cs, ISfxPlayer.cs
|
||
│ │ ├── Camera/ICameraService.cs
|
||
│ │ ├── Capture/ ← (planned — ICaptureService)
|
||
│ │ ├── Inputs/IInputReader.cs
|
||
│ │ └── Scenes/ISceneService.cs
|
||
│ ├── Data/
|
||
│ │ ├── Dynamic/Services/Audio/ (AudioHandle, AudioRequest)
|
||
│ │ └── Static/Services/Audio/ (SfxCatalogSO)
|
||
│ └── Enums/
|
||
│ └── Services/
|
||
│ ├── Audio/ (AudioChannel, AudioPlayMode, SfxId)
|
||
│ ├── Camera/CameraType.cs (MainCamera, UICamera — ArtCamera not added yet)
|
||
│ └── Scenes/GameScene.cs
|
||
│
|
||
├── Features/ ← (planned — empty folder today)
|
||
│
|
||
├── Libs/
|
||
│ ├── FSM/ (IState, State, StateMachine + Docs)
|
||
│ │ Libs.FSM.asmdef
|
||
│ ├── Installers/ (IServiceModule — Register(IContainerBuilder))
|
||
│ │ Libs.Installers.asmdef
|
||
│ ├── Observer/ (IEventBus, EventBus — note: not named "EventBus")
|
||
│ │ Libs.Observer.asmdef
|
||
│ ├── PlayerPrefs/ (ProtectedPlayerPrefs — used in place of a Persistence service)
|
||
│ │ ├── Editor/ Libs.PlayerPrefs.Editor.asmdef
|
||
│ │ └── Runtime/ Libs.PlayerPrefs.asmdef
|
||
│ └── UI/ (ToggleButton, ToggleButtonGroup)
|
||
│ Libs.UI.asmdef
|
||
│
|
||
└── Services/
|
||
├── Analytics/
|
||
│ ├── Installers/AnalyticsServiceModule.cs
|
||
│ └── Systems/FirebaseAnalyticsSystem.cs
|
||
│ Services.Analytics.asmdef
|
||
├── Assets/
|
||
│ ├── AddressableAssetProviderService.cs
|
||
│ └── AddressableLoadHandleTracker.cs
|
||
│ Services.Assets.asmdef
|
||
├── Audio/
|
||
│ ├── AudioService.cs
|
||
│ └── SfxPlayer.cs
|
||
│ Services.Audio.asmdef
|
||
├── Camera/
|
||
│ ├── Installers/CameraServiceModule.cs
|
||
│ └── Service/CameraService.cs
|
||
│ Services.Camera.asmdef
|
||
├── Inputs/
|
||
│ ├── Generated/GameInputs.cs (Input System codegen)
|
||
│ ├── Installers/InputServiceModule.cs
|
||
│ └── Readers/InputReaderSO.cs
|
||
│ Services.Inputs.asmdef
|
||
└── Scenes/
|
||
└── SceneService.cs
|
||
Services.Scenes.asmdef
|
||
```
|
||
|
||
### 4b. Conventions visible in current code
|
||
|
||
- **Asmdef per Service / Lib / App / Core.** No Feature asmdefs yet (folder is empty).
|
||
- **Core sub-tree shape:** `Core/{Compatibility, Contracts, Data, Enums}` — deeply nested by Service rather than by topic. Game-specific Core types (Drawing, Coloring, Paper, Gallery, History, Progression, Signals) will be added under either `Core/Contracts/<Area>/` or new top-level `Core/<Area>/` — pick one convention before adding the first one.
|
||
- **Service sub-shape varies** — some are flat (`Services/Audio/*.cs`), some split (`Services/Camera/Installers/`, `Services/Camera/Service/`). The "official" per-feature shape (§4d) hasn't been validated against real code yet.
|
||
- **Installer pattern:** `MonoBehaviour, IServiceModule` with `Register(IContainerBuilder builder)`. **Not** `IInstaller.Install(...)` as older sections of this doc imply. New installers must follow `IServiceModule`.
|
||
- **No `AppBoot`** entry point exists yet. `RootLifetimeScope` only iterates a serialized `MonoBehaviour[] serviceModules` and calls `Register` on each `IServiceModule`. Boot sequence in §29 is aspirational.
|
||
- **No Persistence service.** `Libs/PlayerPrefs` (the `ProtectedPlayerPrefs` library) is in place for small-key state. JSON persistence for gallery sidecars will need a separate service when Gallery lands.
|
||
- **Camera service is a registry**, not a fitter. `ICameraService.Register/Get` only — no aspect-fit logic.
|
||
|
||
### 4c. Planned additions (not on disk yet)
|
||
|
||
All new game code follows the same nesting pattern as existing Services — `Contracts/Features/<Name>/`, `Data/{Dynamic,Static}/Features/<Name>/`, `Features/<Name>/` — and asmdefs drop the `Darkmatter.` prefix to match `Core`, `Services.Audio`, `Libs.Observer`.
|
||
|
||
Rough landing order for ColorBook scene to be playable:
|
||
|
||
| Path | Role |
|
||
|---|---|
|
||
| `Core/Contracts/Features/Paper/IPaperRig.cs`, `IArtInputBridge.cs` | Paper rig contracts |
|
||
| `Core/Contracts/Services/Capture/ICaptureService.cs` | Capture service contract |
|
||
| `Core/Contracts/Services/Gallery/IGalleryService.cs` | Gallery service contract |
|
||
| `Core/Contracts/Features/Drawing/IDrawingTemplate.cs`, `IDrawingTemplateCatalog.cs` | Drawing template contracts |
|
||
| `Core/Contracts/Features/Coloring/IColorPalette.cs` | Palette contract |
|
||
| `Core/Contracts/Features/History/ICommand.cs`, `IUndoStack.cs` | Undo/redo contracts |
|
||
| `Core/Contracts/Features/Progression/IProgressionService.cs` | Progression contract (despite the name, it's a feature contract since it's game-specific) |
|
||
| `Core/Data/Static/Features/Drawing/` (DrawingTemplateSO) | Authored drawing data |
|
||
| `Core/Data/Static/Features/Coloring/` (ColorPaletteSO) | Authored palette data |
|
||
| `Core/Data/Dynamic/Features/Drawing/ShapePieceDTO.cs`, `ColorRegionDTO.cs` | Runtime drawing structs |
|
||
| `Core/Data/Dynamic/Features/Coloring/PaintCommandDTO.cs` | Runtime coloring struct |
|
||
| `Core/Data/Dynamic/Features/Gallery/SavedArtworkDTO.cs` | Runtime gallery struct |
|
||
| `Core/Data/Dynamic/Features/Signals/` (DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, ArtworkCapturedSignal, ArtworkSavedSignal) | Cross-feature signal structs |
|
||
| `Core/Enums/Services/Camera/CameraType.cs` | Add `ArtCamera` enum value to existing file |
|
||
| `Libs/CommandStack/` (+ `Libs.CommandStack.asmdef`) | Bounded undo/redo |
|
||
| `Services/Capture/` (+ `Services.Capture.asmdef`) | `RenderTextureCaptureService` reads `IPaperRig.Surface` |
|
||
| `Services/Gallery/` (+ `Services.Gallery.asmdef`) | `FileGalleryService` — PNG + sidecar JSON IO |
|
||
| `Features/Paper/` (+ `Features.Paper.asmdef`) | Scene-bound RT rig |
|
||
| `Features/{MainMenu,DrawingCatalog,ShapeBuilder,Coloring,History,Capture,Progression,ColorBookFlow,ArtBook}/` (+ asmdefs each) | Game features |
|
||
| `App/LifetimeScopes/{MainMenu,ColorBook,ArtBook}LifetimeScope.cs` | Per-scene scopes |
|
||
| `App/Boot/AppBoot.cs` | Bootstrap entry point |
|
||
| `Assets/Darkmatter/Scenes/{MainMenu,ColorBook,ArtBook}.unity` | Scenes |
|
||
| `Content/Gameplay/Drawings/<theme>/<id>/{Template.asset, Pieces/, Regions/, PaperBackground.png}` | Authored drawings (under existing `Content/Gameplay/` root) |
|
||
| `Content/Gameplay/Palettes/*.asset` | Color palettes |
|
||
| `Content/Audio/{UI,Coloring}/` | SFX banks |
|
||
|
||
### 4d. Per-feature folder layout (matches existing Services pattern)
|
||
|
||
Look at `Services/Camera/` (`Installers/` + `Service/`) and `Services/Analytics/` (`Installers/` + `Systems/`) — that's the convention. Features adopt the same shape, adding `UI/` or `Views/` only when there's something to put in them.
|
||
|
||
```
|
||
Features/[Name]/
|
||
├── Installers/ IServiceModule — VContainer registration
|
||
├── Systems/ (or Service/) Controllers, repositories, factories (pure C#)
|
||
├── UI/ (only if the feature has Canvas UI)
|
||
│ ├── *Presenter.cs Pure C#, listens to model, drives view
|
||
│ └── *View.cs MonoBehaviour, setters only
|
||
├── Views/ (only if the feature has world-space MonoBehaviours)
|
||
└── Features.<Name>.asmdef
|
||
```
|
||
|
||
Rules of thumb pulled from current Services:
|
||
- **Use `Service/` (singular)** when the feature has exactly one main implementation class (like `Services/Camera/Service/CameraService.cs`).
|
||
- **Use `Systems/` (plural)** when there are multiple pure-C# coordinators (like `Services/Analytics/Systems/`).
|
||
- **Skip nesting entirely** when the feature has only 1–2 files at root (like `Services/Audio/AudioService.cs` + `SfxPlayer.cs` flat).
|
||
- **`Docs/` is per-folder** in current code — drop a `Docs/` inside any sub-folder that needs notes, don't make a global feature-level Docs.
|
||
|
||
---
|
||
|
||
## 5. Namespaces & Asmdef naming
|
||
|
||
C# namespace pattern is `Darkmatter.<Layer>.<Module>[.<SubArea>]` — the `Darkmatter.` prefix stays on namespaces. Examples already in code:
|
||
|
||
- `Darkmatter.Core.Contracts.Services.Camera` ([ICameraService.cs](Assets/Darkmatter/Code/Core/Contracts/Services/Camera/ICameraService.cs))
|
||
- `Darkmatter.Services.Camera` ([CameraService.cs](Assets/Darkmatter/Code/Services/Camera/Service/CameraService.cs))
|
||
- `Darkmatter.Services.Camera.Installers` ([CameraServiceModule.cs](Assets/Darkmatter/Code/Services/Camera/Installers/CameraServiceModule.cs))
|
||
- `Darkmatter.Libs.Installers` ([IServiceModule.cs](Assets/Darkmatter/Code/Libs/Installers/IServiceModule.cs))
|
||
|
||
**Asmdef names drop the `Darkmatter.` prefix.** Existing pattern:
|
||
|
||
| Namespace | Asmdef |
|
||
|---|---|
|
||
| `Darkmatter.Core.*` | `Core` |
|
||
| `Darkmatter.App` | `Darkmatter.App` (one exception — keep as-is, don't churn) |
|
||
| `Darkmatter.Libs.Observer` | `Libs.Observer` |
|
||
| `Darkmatter.Libs.FSM` | `Libs.FSM` |
|
||
| `Darkmatter.Libs.Installers` | `Libs.Installers` |
|
||
| `Darkmatter.Libs.PlayerPrefs` | `Libs.PlayerPrefs` (+ `Libs.PlayerPrefs.Editor`) |
|
||
| `Darkmatter.Libs.UI` | `Libs.UI` |
|
||
| `Darkmatter.Services.Audio` | `Services.Audio` |
|
||
| `Darkmatter.Services.Assets` | `Services.Assets` |
|
||
| `Darkmatter.Services.Camera` | `Services.Camera` |
|
||
| `Darkmatter.Services.Inputs` | `Services.Inputs` |
|
||
| `Darkmatter.Services.Scenes` | `Services.Scenes` |
|
||
| `Darkmatter.Services.Analytics` | `Services.Analytics` |
|
||
|
||
New asmdefs follow the same convention: `Services.Capture`, `Services.Gallery`, `Libs.CommandStack`, `Features.Paper`, `Features.Coloring`, etc.
|
||
|
||
---
|
||
|
||
## 6. Scenes & Lifetime Scopes
|
||
|
||
| Scene | Scope | Status | Contents |
|
||
|---|---|---|---|
|
||
| `Boot.unity` | `RootLifetimeScope` | ✅ exists | All Services + Libs. Persists forever. |
|
||
| `MainMenu.unity` | `MainMenuLifetimeScope` | ⚠️ planned | Menu presenter, art book entry. |
|
||
| `ColorBook.unity` | `ColorBookLifetimeScope` | ⚠️ planned | `PaperRig`, DrawingCatalog, ShapeBuilder, Coloring, History, Capture, ColorBookFlow. |
|
||
| `ArtBook.unity` | `ArtBookLifetimeScope` | ⚠️ planned | Gallery presenter, viewer, share. |
|
||
|
||
Only `Boot.unity` exists today; the three scene scope classes haven't been written yet either (only `RootLifetimeScope` exists in [App/LifetimeScopes/](Assets/Darkmatter/Code/App/LifetimeScopes/)).
|
||
|
||
Scopes nest: `Root → (MainMenu | ColorBook | ArtBook)`. Services resolved from the root parent. Scene scopes only register their own features.
|
||
|
||
### Boot chain (planned)
|
||
|
||
No `AppBoot` class exists yet — today `RootLifetimeScope` only registers services and stops there. When `AppBoot` is added (as an `IAsyncStartable` registered via `builder.RegisterEntryPoint<AppBoot>()`), it should run once, in order:
|
||
|
||
1. Initialize `IAssetProviderService` (Addressables init).
|
||
2. Preload essential bundles (palettes, UI sounds).
|
||
3. Load `IProgressionService` from disk.
|
||
4. Load `MainMenu` scene.
|
||
|
||
Failures show a child-friendly retry screen; never crash.
|
||
|
||
---
|
||
|
||
## 7. Rendering Strategy
|
||
|
||
**RT-as-paper.** ArtCamera renders the drawing world to an offscreen `RenderTexture`. A Canvas `RawImage` displays that RT. HUD lives on the same Canvas, above the RawImage. The RT *is* the paper — same fixed coordinate system on every device.
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ UICanvas (Screen-Space - Camera, UICamera) │
|
||
│ │
|
||
│ ┌────────────────────────────────────┐ │
|
||
│ │ RawImage (AspectRatioFitter 1:1) │ [HUD] │
|
||
│ │ └─ texture = PaperRig.Surface │ palette │
|
||
│ │ │ undo etc │
|
||
│ │ ArtCamera renders → here │ │
|
||
│ └────────────────────────────────────┘ │
|
||
│ │
|
||
└──────────────────────────────────────────────────────┘
|
||
▲
|
||
│ rendered offscreen
|
||
│
|
||
ArtCamera (orthographicSize fixed, aspect = 1f)
|
||
culling mask: Artwork, PaperBackground, Effects
|
||
target texture: PaperRig.Surface (2048×2048 ARGB32)
|
||
```
|
||
|
||
### Cameras
|
||
|
||
| Camera | Type | Culling Mask | Render Target | Purpose |
|
||
|---|---|---|---|---|
|
||
| `ArtCamera` | Orthographic, **fixed ortho size**, aspect = 1 | `Artwork`, `PaperBackground`, `Effects` | `PaperRig.Surface` (offscreen RT) | Renders the drawing world. Never sees the screen. |
|
||
| `UICamera` | Camera (Screen-Space – Camera) | `UI` | Screen | Displays the paper RawImage + HUD. |
|
||
|
||
### Layers
|
||
|
||
| Layer | Used by |
|
||
|---|---|
|
||
| `Artwork` | Drawing region sprites, shape pieces, paper bg, all in ArtCamera world |
|
||
| `Effects` | Particle bursts, sparkles — also in ArtCamera world (so they're captured into the PNG) |
|
||
| `UI` | All Canvas elements (RawImage paper + HUD) |
|
||
|
||
### Why RT-as-paper
|
||
|
||
| Need | Choice | Why |
|
||
|---|---|---|
|
||
| Per-region tap-to-fill | Sprites + `PolygonCollider2D` in ArtCamera world; tapped via `IArtInputBridge` | Coordinate system is fixed (RT space). One `Physics2D.OverlapPoint` call after screen→art-world conversion. |
|
||
| Drag/drop shape pieces | Sprites + Physics2D in art world | Same fixed bounds on every device — no per-aspect tray layout. |
|
||
| Capture to PNG | `RT → Texture2D → PNG` | The RT *is* the saved image. No camera state override, no compositing pass, no determinism worries. |
|
||
| Multi-resolution support | `AspectRatioFitter (1:1, FitInParent)` on the RawImage | The "fit camera" problem reduces to a single Canvas property. Letterbox/pillarbox = whatever the Canvas around the RawImage looks like. |
|
||
| Color palette, buttons | Canvas above the RawImage | Anchors handle aspect ratios. Buttons + ScrollRect free. |
|
||
| Drawing catalog grid | Canvas | `GridLayoutGroup` + ScrollRect, async thumbnail loader. |
|
||
|
||
### Multi-resolution rule
|
||
|
||
The artwork world is **screen-size-independent by construction.** Author every drawing in a fixed 2048×2048 design rect (or 20×20 world units at PPU=100). Pieces, regions, snap radii, slot positions — all expressed in this space and never scaled at runtime. Different screen sizes only change how the *RawImage* is laid out on the Canvas; the contents of the RT stay identical.
|
||
|
||
If you need a backdrop (wood/cloth behind the paper), it's a sibling Canvas Image *outside* the RawImage, sized to fill the screen. The RT itself has a transparent or paper-colored background.
|
||
|
||
---
|
||
|
||
## 8. Core Contracts
|
||
|
||
All Core types are pure data or interfaces.
|
||
|
||
### Drawing
|
||
|
||
> Contracts live in `Darkmatter.Core.Contracts.Features.Drawing`; DTOs in `Darkmatter.Core.Data.Dynamic.Features.Drawing`.
|
||
|
||
```csharp
|
||
namespace Darkmatter.Core.Contracts.Features.Drawing;
|
||
|
||
public interface IDrawingTemplate {
|
||
string Id { get; }
|
||
string DisplayName { get; }
|
||
Sprite Thumbnail { get; }
|
||
Sprite PaperBackground { get; }
|
||
IReadOnlyList<ShapePieceDTO> Pieces { get; }
|
||
IReadOnlyList<ColorRegionDTO> Regions { get; }
|
||
}
|
||
|
||
public readonly struct ShapePieceDTO {
|
||
public string PieceId { get; }
|
||
public Sprite Sprite { get; }
|
||
public Vector2 SlotPosition { get; }
|
||
public float SlotRotation { get; }
|
||
public float SnapRadius { get; } // generous for toddlers
|
||
}
|
||
|
||
public readonly struct ColorRegionDTO {
|
||
public string RegionId { get; }
|
||
public Sprite Sprite { get; } // sprite renderer source
|
||
public Vector2[] ColliderPath { get; } // polygon collider points
|
||
public Color InitialColor { get; } // usually white
|
||
}
|
||
```
|
||
|
||
### Coloring
|
||
|
||
> Contracts in `Darkmatter.Core.Contracts.Features.Coloring`; DTOs in `Darkmatter.Core.Data.Dynamic.Features.Coloring`.
|
||
|
||
```csharp
|
||
namespace Darkmatter.Core.Contracts.Features.Coloring;
|
||
|
||
public interface IColorPalette {
|
||
string Id { get; }
|
||
IReadOnlyList<Color> Colors { get; }
|
||
}
|
||
|
||
public readonly struct PaintCommandDTO {
|
||
public string RegionId { get; }
|
||
public Color FromColor { get; }
|
||
public Color ToColor { get; }
|
||
}
|
||
```
|
||
|
||
### Paper (RT rig + input bridge)
|
||
|
||
> Contracts live in `Darkmatter.Core.Contracts.Features.Paper`. Files at `Core/Contracts/Features/Paper/`.
|
||
|
||
```csharp
|
||
namespace Darkmatter.Core.Contracts.Features.Paper;
|
||
|
||
public interface IPaperRig {
|
||
Camera ArtCamera { get; } // offscreen, targetTexture = Surface
|
||
RenderTexture Surface { get; } // 2048×2048 ARGB32; the paper itself
|
||
Transform PaperRoot { get; } // parent of regions/pieces/paper bg
|
||
Vector2 DesignSize { get; } // world units, e.g. (20, 20)
|
||
Rect DesignRect { get; } // centered on origin, DesignSize wide
|
||
}
|
||
|
||
public interface IArtInputBridge {
|
||
// Converts a screen-space pointer (Input System) to art-world coords
|
||
// inside the RT. Returns false if the pointer is outside the RawImage.
|
||
bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos);
|
||
}
|
||
```
|
||
|
||
- `IPaperRig` is implemented by `PaperRig : MonoBehaviour` in the ColorBook scene.
|
||
- `IArtInputBridge` does the screen → RawImage local → UV → `ArtCamera.ViewportToWorldPoint` chain.
|
||
- All consumers (Coloring, ShapeBuilder, Capture, particle effects) read these from DI; they never touch `Screen.width/height` 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();
|
||
}
|
||
```
|
||
|
||
### Gallery & Capture
|
||
|
||
> `IGalleryService` is a Service contract → `Darkmatter.Core.Contracts.Services.Gallery`. `SavedArtworkDTO` is a runtime data struct → `Darkmatter.Core.Data.Dynamic.Features.Gallery`. `ICaptureService` → `Darkmatter.Core.Contracts.Services.Capture`.
|
||
|
||
```csharp
|
||
namespace Darkmatter.Core.Data.Dynamic.Features.Gallery;
|
||
|
||
public readonly struct SavedArtworkDTO {
|
||
public string Id { get; }
|
||
public string TemplateId { get; }
|
||
public DateTime CreatedUtc { get; }
|
||
public string ImagePath { get; } // persistentDataPath PNG
|
||
public string ThumbnailPath { get; }
|
||
}
|
||
|
||
namespace Darkmatter.Core.Contracts.Services.Gallery;
|
||
|
||
public interface IGalleryService {
|
||
UniTask<SavedArtworkDTO> SaveAsync(byte[] png, string templateId);
|
||
UniTask<IReadOnlyList<SavedArtworkDTO>> ListAsync();
|
||
UniTask<Texture2D> LoadFullAsync(string artworkId);
|
||
UniTask DeleteAsync(string artworkId);
|
||
}
|
||
|
||
namespace Darkmatter.Core.Contracts.Services.Capture;
|
||
|
||
public interface ICaptureService {
|
||
// No camera or paperBg args — capture reads directly from IPaperRig.Surface.
|
||
// Dimensions inherited from the RT; no resize, no compositing.
|
||
UniTask<byte[]> CaptureAsync();
|
||
}
|
||
```
|
||
|
||
`ICaptureService` resolves `IPaperRig` via DI and reads `Surface` directly. The paper background is already baked into the RT because it sits in `PaperRoot` under the ArtCamera. No special compositing pass is ever needed.
|
||
|
||
### Signals
|
||
|
||
> Signal structs live in `Darkmatter.Core.Data.Dynamic.Features.Signals` (runtime data, cross-feature).
|
||
|
||
```csharp
|
||
namespace Darkmatter.Core.Data.Dynamic.Features.Signals;
|
||
|
||
public readonly struct DrawingSelectedSignal {
|
||
public string TemplateId { get; }
|
||
}
|
||
|
||
public readonly struct ShapeAssembledSignal {
|
||
public string TemplateId { get; }
|
||
}
|
||
|
||
public readonly struct ColorAppliedSignal {
|
||
public string RegionId { get; }
|
||
public Color Color { get; }
|
||
}
|
||
|
||
public readonly struct ArtworkCapturedSignal {
|
||
public string ArtworkId { get; }
|
||
}
|
||
|
||
public readonly struct ArtworkSavedSignal {
|
||
public SavedArtworkDTO Artwork { get; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Feature Responsibilities
|
||
|
||
### `DrawingCatalog`
|
||
|
||
- Loads the catalog manifest (list of available template IDs + thumbnail addresses).
|
||
- Presents a scrollable grid of thumbnails (Canvas).
|
||
- On select → fires `DrawingSelectedSignal(templateId)` and unloads the catalog UI.
|
||
|
||
### `Paper`
|
||
|
||
- Scene-scoped infrastructure. Lives in `ColorBook.unity` only.
|
||
- Owns `PaperRig` (MonoBehaviour) — exposes `ArtCamera`, the `RenderTexture Surface`, `PaperRoot` transform, and the design rect.
|
||
- Owns `ArtInputBridge` — converts pointer screen positions to art-world coords inside the RT.
|
||
- Registered in `ColorBookLifetimeScope` via `PaperRigModule`. All other features in the scene resolve `IPaperRig` / `IArtInputBridge` from DI.
|
||
- Lifetime is scene-scoped: created on scene load, destroyed on scene unload. RT is allocated in `Awake`, released in `OnDestroy`.
|
||
|
||
### `ShapeBuilder`
|
||
|
||
- Listens to `DrawingSelectedSignal`.
|
||
- Loads template via `IDrawingTemplateLoader`, parents shape pieces under `IPaperRig.PaperRoot` at off-slot positions inside the design rect.
|
||
- Per piece: drag with `ShapePieceView` (sprite + collider). Pointer events go through `IArtInputBridge.TryScreenToArtWorld`. On drop, check distance to `SlotPosition` against `SnapRadius`; if within, snap and lock.
|
||
- Fires `ShapeAssembledSignal` when all pieces locked.
|
||
|
||
### `Coloring`
|
||
|
||
- Listens to `ShapeAssembledSignal`.
|
||
- Spawns one `ColorRegionView` per `ColorRegionDTO` under `IPaperRig.PaperRoot` (sprite + polygon collider on `Artwork` layer).
|
||
- Listens to palette selection (current color held in `ColoringStateRepository`).
|
||
- On pointer down: `IArtInputBridge.TryScreenToArtWorld(screenPos, out var artPos)` → `Physics2D.OverlapPoint(artPos, artworkMask)` → if hit, build `PaintRegionCommand(regionId, oldColor, newColor)`, push to `IUndoStack`.
|
||
- Command sets `SpriteRenderer.color` on undo/redo.
|
||
- Fires `ColorAppliedSignal` for SFX / sparkle effects.
|
||
|
||
### `History`
|
||
|
||
- Owns the singleton `IUndoStack` for the current ColorBook session.
|
||
- Cleared on `DrawingSelectedSignal` (new drawing = fresh history).
|
||
- Capped at ~20 entries (memory + cognitive simplicity).
|
||
- UI: two big arrow buttons; disabled state when `CanUndo / CanRedo` is false.
|
||
|
||
### `Capture`
|
||
|
||
- Bound to the "Capture" button.
|
||
- Calls `ICaptureService.CaptureAsync()` → PNG bytes. Capture reads `IPaperRig.Surface` directly; no camera or paper-bg args needed.
|
||
- Hands bytes to `IGalleryService.SaveAsync(...)`.
|
||
- Fires `ArtworkCapturedSignal` then `ArtworkSavedSignal`.
|
||
- Shows a quick "saved!" toast with a thumbnail of the new entry.
|
||
|
||
### `Progression`
|
||
|
||
- Tracks completed template IDs and the in-progress draft.
|
||
- On "Next" button: silently runs Capture pipeline (auto-save), marks current as completed, calls `IDrawingTemplateCatalog.NextUnseen()`.
|
||
- Persists JSON via `IPersistenceService`.
|
||
|
||
### `ColorBookFlow`
|
||
|
||
- The only orchestrator inside ColorBook scope.
|
||
- Subscribes to flow-relevant signals and toggles UI panels (catalog → builder → coloring).
|
||
- Coordinates "Next" sequence: `IProgressionService.MarkCompleted` → `ICaptureService` autosave → `IDrawingTemplateLoader.Release(currentId)` → load next.
|
||
- Built as a small FSM (`Catalog → Building → Coloring → Done`).
|
||
|
||
### `ArtBook`
|
||
|
||
- Separate scene.
|
||
- `GalleryPresenter` calls `IGalleryService.ListAsync()` → grid of thumbnails.
|
||
- Tap → fullscreen view, share-sheet button, delete.
|
||
- Saved-to-device-camera-roll uses an optional platform plugin behind `IExternalShareService` (Core contract).
|
||
|
||
---
|
||
|
||
## 10. Addressables Strategy
|
||
|
||
Mirror the Bus Game pattern via `IAssetProviderService`.
|
||
|
||
### What ships through Addressables
|
||
|
||
| Asset | Why |
|
||
|---|---|
|
||
| `DrawingTemplate` ScriptableObject (per drawing) | Many; load on demand. |
|
||
| Shape piece sprites | Only needed when active. |
|
||
| Region sprites + polygon paths | Heavy; loaded per drawing. |
|
||
| Paper backgrounds | Per template, sometimes shared. |
|
||
| Color palette SOs | Swap per theme. |
|
||
| Audio clips (tap, snap, complete, sparkle) | Shared SFX bank. |
|
||
|
||
### What does NOT use Addressables
|
||
|
||
- HUD prefabs (palette button, undo icon) — always loaded with scene.
|
||
- Core UI canvases.
|
||
- Boot scene assets.
|
||
- User-saved gallery PNGs — those live in `Application.persistentDataPath`.
|
||
|
||
### Group layout
|
||
|
||
```
|
||
Drawings_Animals (label: drawing, animals)
|
||
Drawings_Vehicles (label: drawing, vehicles)
|
||
Drawings_Shapes (label: drawing, shapes)
|
||
Palettes (label: palette)
|
||
Audio_UI (label: sfx, ui)
|
||
Audio_Coloring (label: sfx, coloring)
|
||
```
|
||
|
||
### Lifecycle
|
||
|
||
- Catalog loads **thumbnail handles only** (cheap).
|
||
- On select → full template loads (pieces + regions + paper).
|
||
- On "Next" or scene exit → previous template `Release`d before next loads.
|
||
- This bound matters on toddler tablets with limited RAM.
|
||
|
||
### Remote groups (future)
|
||
|
||
Drawing packs ship as remote bundles. New theme packs (Christmas, Dinosaurs) update without an app store release.
|
||
|
||
---
|
||
|
||
## 11. Persistence
|
||
|
||
Two distinct stores, each behind its own Core contract.
|
||
|
||
### `IPersistenceService` (JSON / PlayerPrefs)
|
||
|
||
Holds:
|
||
|
||
- Completed template IDs.
|
||
- Last opened drawing.
|
||
- Audio volume, simple settings.
|
||
|
||
Path: `Application.persistentDataPath/save.json`.
|
||
|
||
### `IGalleryService` (file IO)
|
||
|
||
Holds user artworks:
|
||
|
||
```
|
||
persistentDataPath/Gallery/
|
||
├── {guid}.png full-res render (~2048×2048)
|
||
├── {guid}.thumb.png 256×256 for grid
|
||
└── {guid}.json SavedArtworkDTO sidecar
|
||
```
|
||
|
||
- Writes are atomic (`.tmp` → rename).
|
||
- `ListAsync` enumerates sidecar JSONs sorted by `CreatedUtc desc`.
|
||
- Thumbnail generation happens once at save time on a worker thread.
|
||
|
||
---
|
||
|
||
## 12. Capture Pipeline
|
||
|
||
With the RT-paper-rig, capture has no setup phase. The RT is already the final image at all times.
|
||
|
||
```
|
||
[Capture button or Next button]
|
||
│
|
||
▼
|
||
ICaptureService.CaptureAsync()
|
||
│
|
||
├─ rt = _paperRig.Surface (already populated each frame)
|
||
├─ prev = RenderTexture.active
|
||
├─ RenderTexture.active = rt
|
||
├─ tex = new Texture2D(rt.width, rt.height, RGBA32, false)
|
||
├─ tex.ReadPixels(full rect, 0, 0); tex.Apply()
|
||
├─ RenderTexture.active = prev
|
||
├─ bytes = tex.EncodeToPNG() (on worker via UniTask.RunOnThreadPool)
|
||
├─ Object.Destroy(tex)
|
||
└─ return bytes
|
||
▼
|
||
IGalleryService.SaveAsync(bytes, templateId)
|
||
│
|
||
├─ Write .png atomically
|
||
├─ Generate + write thumbnail
|
||
├─ Write sidecar JSON
|
||
└─ return SavedArtworkDTO
|
||
▼
|
||
EventBus.Publish(new ArtworkSavedSignal(dto))
|
||
```
|
||
|
||
Notes:
|
||
|
||
- HUD never appears in capture because the HUD is on `UICamera` / Canvas — it is physically in a different render path. The RT only ever sees `ArtCamera`'s output.
|
||
- Paper background is a sprite parented under `IPaperRig.PaperRoot` and is rendered into the RT every frame — already baked in.
|
||
- Saved PNGs are byte-comparable across devices because the RT dimensions and ArtCamera matrix never depend on screen size.
|
||
- `CaptureAsync` is safe to call repeatedly — no camera state is ever mutated.
|
||
|
||
---
|
||
|
||
## 13. Communication Rules
|
||
|
||
| Use case | Mechanism |
|
||
|---|---|
|
||
| Load template, return result | Direct DI call (`IDrawingTemplateLoader.LoadAsync`). |
|
||
| Capture → save chain | Direct DI calls, sequenced. |
|
||
| Notify HUD that a region was painted | `IEventBus` signal. |
|
||
| Notify Progression that a drawing was completed | `IEventBus` signal. |
|
||
| Tell ColorBookFlow that pieces are assembled | `IEventBus` signal. |
|
||
| Tell Coloring which color is currently selected | Direct DI on `ColoringStateRepository`. |
|
||
|
||
**Never** use signals for request/response. If you need a return value or guaranteed single handler, define a Core interface.
|
||
|
||
---
|
||
|
||
## 14. UI (MVP — Passive View)
|
||
|
||
Identical to Bus Game.
|
||
|
||
- **Model** — controller / repository, fires C# events.
|
||
- **View** — `MonoBehaviour`, only setters (`SetColors(IReadOnlyList<Color>)`).
|
||
- **Presenter** — pure C#, subscribes to model events, calls view setters.
|
||
|
||
### Inspector bridge
|
||
|
||
For palette icons, undo buttons, region prefabs:
|
||
|
||
```csharp
|
||
[SerializeField, RequireInterface(typeof(IColorButtonView))]
|
||
private MonoBehaviour[] _colorButtons;
|
||
```
|
||
|
||
---
|
||
|
||
## 15. Toddler UX Constraints
|
||
|
||
These shape several design decisions and are **non-negotiable**:
|
||
|
||
- **No fail states.** Drawings cannot be "wrong".
|
||
- **No timers.** Nothing decays or runs out.
|
||
- **No tiny hitboxes.** Drag tolerance ≥ 40 px; snap radius ≥ 60 px for shape pieces.
|
||
- **Auto-snap on near-miss.** If a piece is dropped within `1.5 × SnapRadius`, snap anyway and play a happy sound.
|
||
- **No text-heavy UI.** Icons everywhere. Single-word labels max.
|
||
- **Loud, immediate feedback.** Every tap plays a sound; every fill bursts a small particle effect.
|
||
- **Undo cap = 20.** Toddlers will mash undo. Bound the memory.
|
||
- **Long-press = quick menu off.** Avoid surprise modals.
|
||
|
||
---
|
||
|
||
## 16. Testing
|
||
|
||
| Layer | Test type | Location |
|
||
|---|---|---|
|
||
| `Libs/CommandStack` | EditMode unit tests | `Libs/CommandStack/Tests/` |
|
||
| `Core` DTOs | EditMode | rarely needed, but for `SavedArtworkDTO` serialization, yes. |
|
||
| `Services/Gallery` | EditMode w/ temp directory | mocks `Application.persistentDataPath`. |
|
||
| `Services/Capture` | PlayMode | requires a Camera in the test scene. |
|
||
| `Features/*/Systems` | EditMode w/ DI test container | inject fakes for `IUndoStack`, signals captured by a fake `IEventBus`. |
|
||
| Full flow | PlayMode smoke test | one drawing → assemble → color → capture → assert gallery has 1 file. |
|
||
|
||
---
|
||
|
||
## 17. "Where do I put this?" Checklist
|
||
|
||
1. **Is it a cross-assembly interface / enum / DTO?** → `Core/`
|
||
2. **Is it a generic, sellable utility?** → `Libs/`
|
||
3. **Is it infrastructure (input, audio, file IO, addressables, capture)?** → `Services/`
|
||
4. **Is it gameplay logic specific to coloring books?** → `Features/`
|
||
5. **Is it composition / scene wiring?** → `App/`
|
||
|
||
When in doubt, ask: *would deleting this feature break Core?* If yes, the dependency is wrong.
|
||
|
||
---
|
||
|
||
## 18. Open Questions / Future Work
|
||
|
||
- **Pencil/brush mode** — currently the design is tap-to-fill regions. A free-draw brush mode would need a `BrushStrokeCommand` and a dynamic texture per region; out of scope for v1.
|
||
- **Multi-child profiles** — single-profile for v1; multi-profile would slot in behind `IProgressionService` and `IGalleryService` keyed by `profileId`.
|
||
- **Cloud sync** — gallery sync would happen behind `IGalleryService` (decorator pattern); local-first stays the source of truth.
|
||
- **Sticker / decoration layer** — additive sprite layer above coloring, also `ICommand`-driven so it integrates with undo/redo cleanly.
|
||
|
||
---
|
||
|
||
## 19. Quick Reference — Feature ↔ Signal Map
|
||
|
||
| Feature | Subscribes to | Publishes |
|
||
|---|---|---|
|
||
| `DrawingCatalog` | — | `DrawingSelectedSignal` |
|
||
| `ShapeBuilder` | `DrawingSelectedSignal` | `ShapeAssembledSignal` |
|
||
| `Coloring` | `ShapeAssembledSignal` | `ColorAppliedSignal` |
|
||
| `History` | `DrawingSelectedSignal` (to clear) | — |
|
||
| `Capture` | — (button-driven) | `ArtworkCapturedSignal`, `ArtworkSavedSignal` |
|
||
| `Progression` | `ArtworkSavedSignal` | — |
|
||
| `ColorBookFlow` | `ShapeAssembledSignal`, `ArtworkSavedSignal` | — |
|
||
| `ArtBook (Gallery)` | `ArtworkSavedSignal` (if open) | — |
|
||
|
||
---
|
||
|
||
Maintained alongside the [Darkmatter Architecture Guide](../Assets/Darkmatter_Architecture_Guide.md). Do not break the dependency arrows.
|
||
|
||
---
|
||
|
||
## 20. Assembly Definition Map
|
||
|
||
Every Lib / Service / Feature is its own `.asmdef`. The `Darkmatter.` prefix is **only** on the App asmdef; everything else uses bare `<Layer>.<Module>` names. References follow the layer rules.
|
||
|
||
### On disk today
|
||
|
||
| Asmdef | Path | References |
|
||
|---|---|---|
|
||
| `Darkmatter.App` | `App/` | All Services, Libs, Core (Features when they land) |
|
||
| `Core` | `Core/` | (none — `UniTask` allowed in async signatures) |
|
||
| `Libs.FSM` | `Libs/FSM/` | `Core` |
|
||
| `Libs.Installers` | `Libs/Installers/` | (VContainer only) |
|
||
| `Libs.Observer` | `Libs/Observer/` | `Core` |
|
||
| `Libs.PlayerPrefs` | `Libs/PlayerPrefs/Runtime/` | (standalone) |
|
||
| `Libs.PlayerPrefs.Editor` | `Libs/PlayerPrefs/Editor/` | `Libs.PlayerPrefs` |
|
||
| `Libs.UI` | `Libs/UI/` | `Core` |
|
||
| `Services.Analytics` | `Services/Analytics/` | `Core`, `Libs.Installers` |
|
||
| `Services.Assets` | `Services/Assets/` | `Core`, `Libs.Installers` |
|
||
| `Services.Audio` | `Services/Audio/` | `Core`, `Libs.Installers` |
|
||
| `Services.Camera` | `Services/Camera/` | `Core`, `Libs.Installers` |
|
||
| `Services.Inputs` | `Services/Inputs/` | `Core`, `Libs.Installers` |
|
||
| `Services.Scenes` | `Services/Scenes/` | `Core`, `Libs.Installers` |
|
||
|
||
### Planned (not on disk yet)
|
||
|
||
| Asmdef | Path | References |
|
||
|---|---|---|
|
||
| `Libs.CommandStack` | `Libs/CommandStack/` | `Core` |
|
||
| `Services.Capture` | `Services/Capture/` | `Core`, `Libs.Installers` |
|
||
| `Services.Gallery` | `Services/Gallery/` | `Core`, `Libs.Installers` |
|
||
| `Features.Paper` | `Features/Paper/` | `Core`, `Libs.Installers` |
|
||
| `Features.MainMenu` | `Features/MainMenu/` | `Core`, `Libs.Installers` |
|
||
| `Features.DrawingCatalog` | `Features/DrawingCatalog/` | `Core`, `Libs.Installers` |
|
||
| `Features.ShapeBuilder` | `Features/ShapeBuilder/` | `Core`, `Libs.Installers` |
|
||
| `Features.Coloring` | `Features/Coloring/` | `Core`, `Libs.Installers`, `Libs.CommandStack` |
|
||
| `Features.History` | `Features/History/` | `Core`, `Libs.Installers`, `Libs.CommandStack` |
|
||
| `Features.Capture` | `Features/Capture/` | `Core`, `Libs.Installers` |
|
||
| `Features.Progression` | `Features/Progression/` | `Core`, `Libs.Installers`, `Libs.PlayerPrefs` |
|
||
| `Features.ColorBookFlow` | `Features/ColorBookFlow/` | `Core`, `Libs.Installers`, `Libs.FSM` |
|
||
| `Features.ArtBook` | `Features/ArtBook/` | `Core`, `Libs.Installers` |
|
||
|
||
**Hard rules:**
|
||
- No Service asmdef references any Feature asmdef.
|
||
- No Feature asmdef references another Feature asmdef.
|
||
- All Services and Features depend on `Libs.Installers` so they can implement `IServiceModule`.
|
||
- The compiler enforces this — if a `using` won't resolve, the dependency is wrong.
|
||
|
||
---
|
||
|
||
## 21. LifetimeScope Concrete Sample
|
||
|
||
All scopes use the same pattern: a serialized `MonoBehaviour[]` list of `IServiceModule` installers. Each installer is a MonoBehaviour on a child GameObject of the scope. Scope iterates and calls `Register`. **No hardcoded registrations in the scope itself.** This is exactly what [RootLifetimeScope.cs](Assets/Darkmatter/Code/App/LifetimeScopes/RootLifetimeScope.cs) already does today.
|
||
|
||
### `RootLifetimeScope` (Boot scene, persists forever) — actual code
|
||
|
||
```csharp
|
||
using Darkmatter.Libs.Installers;
|
||
using UnityEngine;
|
||
using VContainer;
|
||
using VContainer.Unity;
|
||
|
||
public class RootLifetimeScope : LifetimeScope {
|
||
[SerializeField] private MonoBehaviour[] serviceModules;
|
||
|
||
protected override void Configure(IContainerBuilder builder) {
|
||
foreach (var module in serviceModules) {
|
||
if (module is IServiceModule serviceModule)
|
||
serviceModule.Register(builder);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
The inspector lists the installer MonoBehaviours in `serviceModules[]`. Drag the children of the Boot scope GameObject (e.g. `AudioServiceModule`, `CameraServiceModule`, `InputServiceModule`, `AssetProviderServiceModule`, `AnalyticsServiceModule`, `SceneServiceModule`) into that slot. Each is a `MonoBehaviour, IServiceModule`.
|
||
|
||
### `ColorBookLifetimeScope` (per-scene, child of Root) — same pattern
|
||
|
||
```csharp
|
||
namespace Darkmatter.App.LifetimeScopes;
|
||
|
||
public sealed class ColorBookLifetimeScope : LifetimeScope {
|
||
[SerializeField] private MonoBehaviour[] sceneModules;
|
||
|
||
protected override void Configure(IContainerBuilder builder) {
|
||
foreach (var module in sceneModules) {
|
||
if (module is IServiceModule serviceModule)
|
||
serviceModule.Register(builder);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Drag the scene's installer MonoBehaviours into `sceneModules[]`:
|
||
- `PaperRigModule`
|
||
- `DrawingCatalogModule`
|
||
- `ShapeBuilderModule`
|
||
- `ColoringModule`
|
||
- `HistoryModule`
|
||
- `CaptureModule`
|
||
- `ProgressionModule`
|
||
- `ColorBookFlowModule`
|
||
|
||
Each registers its own classes via `IServiceModule.Register(IContainerBuilder)`.
|
||
|
||
> If a scope needs a non-installer reference (e.g. a `ColorBookSceneRefs` MB holding camera + roots), expose it as a separate `[SerializeField]` and `builder.RegisterInstance(...)` it inside the scope's `Configure`. Don't put scene refs inside an installer — keep installers stateless across scenes.
|
||
|
||
---
|
||
|
||
## 22. Installer Pattern — Concrete Coloring Sample
|
||
|
||
Mirrors the existing [CameraServiceModule.cs](Assets/Darkmatter/Code/Services/Camera/Installers/CameraServiceModule.cs) and [InputServiceModule.cs](Assets/Darkmatter/Code/Services/Inputs/Installers/InputServiceModule.cs) — a `MonoBehaviour` implementing `IServiceModule.Register`.
|
||
|
||
```csharp
|
||
namespace Darkmatter.Features.Coloring.Installers;
|
||
|
||
public sealed class ColoringModule : MonoBehaviour, IServiceModule {
|
||
[SerializeField] private ColoringConfig _config;
|
||
|
||
public void Register(IContainerBuilder builder) {
|
||
builder.RegisterInstance(_config);
|
||
builder.Register<ColoringStateRepository>(Lifetime.Scoped).AsSelf();
|
||
builder.Register<ColoringController>(Lifetime.Scoped)
|
||
.As<IColoringController>()
|
||
.AsSelf();
|
||
builder.Register<ColorPalettePresenter>(Lifetime.Scoped).AsSelf();
|
||
builder.RegisterEntryPoint<ColoringInputBinder>();
|
||
}
|
||
}
|
||
```
|
||
|
||
Convention:
|
||
- One `IServiceModule` per feature, named `<Feature>Module` (matches `CameraServiceModule`, `InputServiceModule`, `AnalyticsServiceModule` already in the project).
|
||
- `MonoBehaviour` lives on a GameObject under the scope's hierarchy; dragged into the scope's `serviceModules[]` / `sceneModules[]` inspector list.
|
||
- Method name is `Register`, not `Install`. There is **no `IInstaller`** in this project — uses `IServiceModule` from [Libs.Installers](Assets/Darkmatter/Code/Libs/Installers/IServiceModule.cs).
|
||
- Registers only its own types. Never touches another feature's types.
|
||
- If the installer needs to wire scene-bound MonoBehaviours into DI, expose them as `[SerializeField]` fields on the installer itself and `builder.RegisterInstance<IFoo>(_foo)` them. See the planned `PaperRigModule` in §32.5b for an example.
|
||
|
||
---
|
||
|
||
## 23. Command Pattern — `PaintRegionCommand`
|
||
|
||
```csharp
|
||
namespace Darkmatter.Features.Coloring.Commands;
|
||
|
||
internal sealed class PaintRegionCommand : ICommand {
|
||
private readonly ColorRegionView _view;
|
||
private readonly Color _fromColor;
|
||
private readonly Color _toColor;
|
||
private readonly IEventBus _bus;
|
||
|
||
public PaintRegionCommand(ColorRegionView view, Color from, Color to, IEventBus bus) {
|
||
_view = view;
|
||
_fromColor = from;
|
||
_toColor = to;
|
||
_bus = bus;
|
||
}
|
||
|
||
public void Execute() {
|
||
_view.SetColor(_toColor);
|
||
_bus.Publish(new ColorAppliedSignal(_view.RegionId, _toColor));
|
||
}
|
||
|
||
public void Undo() {
|
||
_view.SetColor(_fromColor);
|
||
_bus.Publish(new ColorAppliedSignal(_view.RegionId, _fromColor));
|
||
}
|
||
}
|
||
```
|
||
|
||
Usage in controller:
|
||
|
||
```csharp
|
||
public void PaintRegion(ColorRegionView view) {
|
||
var current = _state.CurrentColor;
|
||
if (view.Color == current) return; // no-op
|
||
var cmd = new PaintRegionCommand(view, view.Color, current, _bus);
|
||
_undoStack.Push(cmd); // Push executes + records
|
||
}
|
||
```
|
||
|
||
Same pattern applies to `SnapPieceCommand` if shape-builder steps should be undoable (optional for v1).
|
||
|
||
---
|
||
|
||
## 24. CommandStack — `Libs/CommandStack`
|
||
|
||
```csharp
|
||
namespace Darkmatter.Lib.CommandStack;
|
||
|
||
public sealed class BoundedUndoStack : IUndoStack {
|
||
private readonly Deque<ICommand> _undo = new();
|
||
private readonly Stack<ICommand> _redo = new();
|
||
private readonly int _capacity;
|
||
|
||
public BoundedUndoStack(int capacity = 20) => _capacity = capacity;
|
||
|
||
public bool CanUndo => _undo.Count > 0;
|
||
public bool CanRedo => _redo.Count > 0;
|
||
|
||
public void Push(ICommand cmd) {
|
||
cmd.Execute();
|
||
_undo.AddLast(cmd);
|
||
if (_undo.Count > _capacity) _undo.RemoveFirst();
|
||
_redo.Clear();
|
||
}
|
||
|
||
public void Undo() {
|
||
if (!CanUndo) return;
|
||
var cmd = _undo.Last;
|
||
_undo.RemoveLast();
|
||
cmd.Undo();
|
||
_redo.Push(cmd);
|
||
}
|
||
|
||
public void Redo() {
|
||
if (!CanRedo) return;
|
||
var cmd = _redo.Pop();
|
||
cmd.Execute();
|
||
_undo.AddLast(cmd);
|
||
}
|
||
|
||
public void Clear() {
|
||
_undo.Clear();
|
||
_redo.Clear();
|
||
}
|
||
}
|
||
```
|
||
|
||
`Deque<T>` keeps the oldest entry cheap to evict when the cap fires.
|
||
|
||
---
|
||
|
||
## 25. View / Presenter Pair — Color Palette
|
||
|
||
### View (MonoBehaviour, setters only)
|
||
|
||
```csharp
|
||
namespace Darkmatter.Features.Coloring.UI;
|
||
|
||
public sealed class ColorPaletteView : MonoBehaviour, IColorPaletteView {
|
||
[SerializeField, RequireInterface(typeof(IColorButtonView))]
|
||
private MonoBehaviour[] _buttonsRaw;
|
||
|
||
private IColorButtonView[] _buttons;
|
||
|
||
public event Action<int> OnColorButtonClicked;
|
||
|
||
private void Awake() {
|
||
_buttons = _buttonsRaw.Cast<IColorButtonView>().ToArray();
|
||
for (var i = 0; i < _buttons.Length; i++) {
|
||
var idx = i;
|
||
_buttons[i].OnClicked += () => OnColorButtonClicked?.Invoke(idx);
|
||
}
|
||
}
|
||
|
||
public void SetColors(IReadOnlyList<Color> colors) {
|
||
for (var i = 0; i < _buttons.Length; i++)
|
||
_buttons[i].SetVisible(i < colors.Count);
|
||
for (var i = 0; i < colors.Count; i++)
|
||
_buttons[i].SetColor(colors[i]);
|
||
}
|
||
|
||
public void SetSelected(int index) {
|
||
for (var i = 0; i < _buttons.Length; i++)
|
||
_buttons[i].SetSelected(i == index);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Presenter (pure C#)
|
||
|
||
```csharp
|
||
namespace Darkmatter.Features.Coloring.UI;
|
||
|
||
public sealed class ColorPalettePresenter : IStartable, IDisposable {
|
||
private readonly IColorPaletteView _view;
|
||
private readonly ColoringStateRepository _state;
|
||
|
||
public ColorPalettePresenter(IColorPaletteView view, ColoringStateRepository state) {
|
||
_view = view;
|
||
_state = state;
|
||
}
|
||
|
||
public void Start() {
|
||
_view.SetColors(_state.Palette.Colors);
|
||
_view.SetSelected(_state.SelectedIndex);
|
||
_view.OnColorButtonClicked += OnClicked;
|
||
_state.SelectedIndexChanged += OnIndexChanged;
|
||
}
|
||
|
||
private void OnClicked(int index) => _state.SelectColor(index);
|
||
private void OnIndexChanged(int index) => _view.SetSelected(index);
|
||
|
||
public void Dispose() {
|
||
_view.OnColorButtonClicked -= OnClicked;
|
||
_state.SelectedIndexChanged -= OnIndexChanged;
|
||
}
|
||
}
|
||
```
|
||
|
||
Same shape repeats for every feature's UI.
|
||
|
||
---
|
||
|
||
## 26. ShapeBuilder — Snap Algorithm
|
||
|
||
```csharp
|
||
// In ShapePieceView.OnPointerUp:
|
||
public void OnDragEnd(Vector2 worldPos) {
|
||
var slot = transform.position; // assigned target slot
|
||
var d = Vector2.Distance(worldPos, slot);
|
||
|
||
if (d <= _piece.SnapRadius) {
|
||
SnapToSlot();
|
||
} else if (d <= _piece.SnapRadius * 1.5f) {
|
||
// Toddler grace zone — snap anyway, play happy sound
|
||
SnapToSlot();
|
||
_audio.PlayOneShot(_clips.NiceTry);
|
||
} else {
|
||
ReturnToTrayAnimated();
|
||
}
|
||
}
|
||
|
||
private void SnapToSlot() {
|
||
_locked = true;
|
||
transform.DOMove(_piece.SlotPosition, 0.25f).SetEase(Ease.OutBack);
|
||
_audio.PlayOneShot(_clips.Snap);
|
||
_bus.Publish(new PieceSnappedSignal(_piece.PieceId));
|
||
}
|
||
```
|
||
|
||
Controller listens for `PieceSnappedSignal`, counts against expected piece count, fires `ShapeAssembledSignal` when complete.
|
||
|
||
---
|
||
|
||
## 27. Rendering Order & Sorting
|
||
|
||
URP 2D with a single `ArtCamera` ortho cam.
|
||
|
||
| Sorting Layer | Order | Contents |
|
||
|---|---|---|
|
||
| `PaperBackground` | 0 | Paper bg sprite (under everything) |
|
||
| `ArtworkRegions` | 100 | `ColorRegionView` sprites (the colorable shapes) |
|
||
| `ArtworkPieces` | 200 | `ShapePieceView` sprites (during build) |
|
||
| `Effects` | 300 | Particle bursts, sparkles |
|
||
| `UIWorld` | 400 | World-space prompts (rare; mostly Canvas) |
|
||
|
||
Canvas HUD lives on `UICamera` (Overlay), never sorts against `ArtCamera`. Capture renders only `ArtCamera`'s layers → HUD physically cannot leak into saved PNG.
|
||
|
||
---
|
||
|
||
## 28. SavedArtwork JSON Schema
|
||
|
||
```json
|
||
{
|
||
"id": "f3a8e2d4-...",
|
||
"templateId": "animals/elephant",
|
||
"createdUtc": "2026-05-26T16:42:11Z",
|
||
"imagePath": "Gallery/f3a8e2d4-....png",
|
||
"thumbnailPath": "Gallery/f3a8e2d4-....thumb.png",
|
||
"regions": [
|
||
{ "regionId": "body", "color": "#FFB347" },
|
||
{ "regionId": "ears", "color": "#FF6961" }
|
||
]
|
||
}
|
||
```
|
||
|
||
`regions[]` lets the gallery reopen an artwork for further edits in a future version (out of scope v1, but the schema reserves the field now to avoid migration later).
|
||
|
||
Paths are **relative** to `persistentDataPath`. Never store absolute paths — they change between OS updates on some platforms.
|
||
|
||
---
|
||
|
||
## 29. Boot & Error Handling
|
||
|
||
```
|
||
AppBoot.StartAsync()
|
||
├─ try Addressables.InitializeAsync()
|
||
│ fail → show "Tap to retry" splash
|
||
├─ try preload palette + UI sounds (Addressables labels)
|
||
│ fail → log + continue (non-fatal)
|
||
├─ try _persistence.LoadAsync()
|
||
│ fail → start with empty progression (don't crash)
|
||
├─ _scenes.LoadAsync("MainMenu")
|
||
└─ done
|
||
```
|
||
|
||
Toddler-mode error UI:
|
||
- One large smiling icon.
|
||
- One big "tap" button.
|
||
- No text, no error codes.
|
||
- A small upper-right gear opens a parent-only diagnostic screen (long-press 3 s to unlock).
|
||
|
||
---
|
||
|
||
## 30. Setup Checklist (new dev, day one)
|
||
|
||
1. Open `Colorbook.sln` at the repo root.
|
||
2. Open `Assets/Darkmatter/Scenes/Boot.unity` (currently the only scene wired).
|
||
3. Inspect the `RootLifetimeScope` GameObject — confirm its `serviceModules[]` list references the child installer MonoBehaviours (`AudioServiceModule`, `CameraServiceModule`, `InputServiceModule`, etc.).
|
||
4. Hit Play from `Boot.unity`. Other scenes (`MainMenu`, `ColorBook`, `ArtBook`) don't exist yet — they're listed in §6 / §4c as planned work.
|
||
5. When new scene scopes land, the same rule applies: never start a scene mid-flow, always enter from `Boot.unity` so the root scope exists.
|
||
6. When drawings are authored: duplicate the template folder under `Content/Gameplay/Drawings/<theme>/<id>/`, edit `Template.asset` (pieces + regions), add to the appropriate Addressables group.
|
||
7. Run `Tests > EditMode` and `Tests > PlayMode` before pushing (test infra not set up yet — see §16).
|
||
|
||
---
|
||
|
||
## 31. Quick Reference — Class ↔ Layer ↔ Asmdef
|
||
|
||
| Class | Layer | Asmdef |
|
||
|---|---|---|
|
||
| `IDrawingTemplate`, `ShapePieceDTO`, `ColorRegionDTO` | Core | `Core` |
|
||
| `IPaperRig`, `IArtInputBridge` | Core | `Core` |
|
||
| `ICommand`, `IUndoStack` | Core | `Core` |
|
||
| `BoundedUndoStack` | Libs | `Libs.CommandStack` |
|
||
| `AddressableAssetProviderService` | Services | `Services.Assets` |
|
||
| `FileGalleryService` | Services | `Services.Gallery` |
|
||
| `RenderTextureCaptureService` | Services | `Services.Capture` |
|
||
| `PaperRig`, `ArtInputBridge`, `PaperRigModule` | Features | `Features.Paper` |
|
||
| `ColoringController`, `PaintRegionCommand` | Features | `Features.Coloring` |
|
||
| `ShapeBuilderController`, `ShapePieceView` | Features | `Features.ShapeBuilder` |
|
||
| `HistoryController` | Features | `Features.History` |
|
||
| `ColorBookFlowController` | Features | `Features.ColorBookFlow` |
|
||
| `GalleryPresenter`, `GalleryGridView` | Features | `Features.ArtBook` |
|
||
| `ColorBookLifetimeScope`, `AppBoot` | App | `Darkmatter.App` |
|
||
|
||
If a class's natural home doesn't match its asmdef, the architecture is bent — fix the placement, don't add a reference.
|
||
|
||
---
|
||
|
||
## 32. Class Reference (Detailed)
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
### 32.1 Core Contracts
|
||
|
||
Pure interfaces and DTOs. Zero logic.
|
||
|
||
#### `IDrawingTemplate` *(Core/Drawing)*
|
||
Immutable view of a single drawing's authored data.
|
||
```csharp
|
||
public interface IDrawingTemplate {
|
||
string Id { get; } // e.g. "animals/elephant"
|
||
string DisplayName { get; } // user-facing
|
||
Sprite Thumbnail { get; } // 256×256 for catalog grid
|
||
Sprite PaperBackground { get; } // composited under artwork
|
||
IReadOnlyList<ShapePieceDTO> Pieces { get; } // for ShapeBuilder
|
||
IReadOnlyList<ColorRegionDTO> Regions { get; } // for Coloring
|
||
}
|
||
```
|
||
Implemented by `DrawingTemplateSO` (ScriptableObject) loaded via Addressables.
|
||
|
||
#### `IDrawingTemplateCatalog` *(Core/Drawing)*
|
||
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/Coloring)*
|
||
Set of colors offered to the child. Authored as `ColorPaletteSO`.
|
||
```csharp
|
||
public interface IColorPalette {
|
||
string Id { get; }
|
||
IReadOnlyList<Color> Colors { get; } // 6–10 entries typical
|
||
}
|
||
```
|
||
|
||
#### `ICommand` & `IUndoStack` *(Core/History)*
|
||
Already shown in section 8. Each undoable user action is one `ICommand`; the stack is bounded.
|
||
|
||
#### `IGalleryService` *(Core/Gallery)*
|
||
Persistent store of saved artwork PNGs.
|
||
```csharp
|
||
public interface IGalleryService {
|
||
UniTask<SavedArtworkDTO> SaveAsync(byte[] png, string templateId);
|
||
UniTask<IReadOnlyList<SavedArtworkDTO>> ListAsync(); // sorted newest first
|
||
UniTask<Texture2D> LoadFullAsync(string artworkId); // for fullscreen view
|
||
UniTask<Texture2D> LoadThumbnailAsync(string artworkId);
|
||
UniTask DeleteAsync(string artworkId);
|
||
}
|
||
```
|
||
|
||
#### `ICaptureService` *(Core/Capture)*
|
||
Snapshots the paper RT to a PNG blob. No arguments — dimensions and content come from `IPaperRig.Surface`.
|
||
```csharp
|
||
public interface ICaptureService {
|
||
UniTask<byte[]> CaptureAsync();
|
||
}
|
||
```
|
||
|
||
#### `IPaperRig` *(Core/Paper)*
|
||
Shared art rig. The single source of truth for everything that lives in the drawing world.
|
||
```csharp
|
||
public interface IPaperRig {
|
||
Camera ArtCamera { get; } // offscreen, targetTexture = Surface
|
||
RenderTexture Surface { get; } // 2048×2048 ARGB32 — the paper itself
|
||
Transform PaperRoot { get; } // parent of regions/pieces/paper bg
|
||
Vector2 DesignSize { get; } // world units, e.g. (20, 20)
|
||
Rect DesignRect { get; } // centered on origin
|
||
}
|
||
```
|
||
|
||
#### `IArtInputBridge` *(Core/Paper)*
|
||
Converts screen-space pointer coords to art-world coords inside the RT.
|
||
```csharp
|
||
public interface IArtInputBridge {
|
||
bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos);
|
||
}
|
||
```
|
||
Returns `false` when the pointer is outside the displayed RawImage rect (toddler tapped the HUD or backdrop). Every art-world raycast goes through this.
|
||
|
||
#### `IProgressionService` *(Core/Progression)*
|
||
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/Assets)*
|
||
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/EventBus, also referenced from Core)*
|
||
```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`.
|
||
|
||
#### `AddressableAssetProviderService` *(Services/Assets)*
|
||
Implements `IAssetProviderService`.
|
||
- **Responsibility:** Wrap `Addressables.LoadAssetAsync<T>` and ref-count handles by address.
|
||
- **State:** `Dictionary<string, AsyncOperationHandle>` keyed by address.
|
||
- **Notes:** `Release(address)` decrements; `ReleaseAll()` for scene teardown. Initialization must complete before any other service may load.
|
||
|
||
#### `FileGalleryService` *(Services/Gallery)*
|
||
Implements `IGalleryService`.
|
||
```csharp
|
||
// fields:
|
||
// IPathProvider _paths (wraps Application.persistentDataPath for tests)
|
||
// IThumbnailGenerator _thumb (downscale + encode)
|
||
// IEventBus _bus
|
||
// pub: ArtworkSavedSignal, ArtworkDeletedSignal
|
||
```
|
||
- **Save flow:** write `{guid}.png.tmp` → fsync → rename; generate thumbnail on a worker; write sidecar JSON last (so partial saves are detectable by absence of JSON).
|
||
- **List flow:** enumerate `*.json` in `Gallery/`, deserialize, sort by `CreatedUtc desc`.
|
||
- **Delete flow:** delete png + thumb + json; missing files ignored (idempotent).
|
||
|
||
#### `RenderTextureCaptureService` *(Services/Capture)*
|
||
Implements `ICaptureService`.
|
||
- **Steps:** allocate `RenderTexture(width, height, 0, ARGB32)` → bind to `artCamera.targetTexture` → `artCamera.Render()` → `ReadPixels` into `Texture2D` → composite `paperBackground` underneath (single shader blit) → `EncodeToPNG` → release RT + textures.
|
||
- **Threading:** PNG encode happens on a `UniTask.RunOnThreadPool` to avoid hitching the main thread on tablets.
|
||
- **Sizing:** default 2048², overridable. Capped at device max texture size.
|
||
|
||
#### `JsonPersistenceService` *(Services/Persistence)*
|
||
Implements `IPersistenceService` (small JSON blob; not the gallery).
|
||
```csharp
|
||
public interface IPersistenceService {
|
||
UniTask<T> LoadAsync<T>(string key) where T : class, new();
|
||
UniTask SaveAsync<T>(string key, T value);
|
||
}
|
||
```
|
||
- **Path:** `Application.persistentDataPath/save.json`.
|
||
- **Format:** single JSON object keyed by `key` so multiple services can share one file.
|
||
- **Atomicity:** write to `save.json.tmp` → rename.
|
||
|
||
#### `SceneService` *(Services/Scenes)*
|
||
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)*
|
||
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)*
|
||
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)*
|
||
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/EventBus)*
|
||
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).
|
||
|
||
#### `Fsm<TState>` *(Libs/FSM)*
|
||
Generic state machine used by `ColorBookFlowController`.
|
||
```csharp
|
||
public sealed class Fsm<TState> where TState : struct, Enum {
|
||
public TState Current { get; }
|
||
public event Action<TState, TState> Transitioned;
|
||
public void Bind(TState state, IFsmState handler);
|
||
public void Go(TState next); // calls Exit on old, Enter on new
|
||
}
|
||
|
||
public interface IFsmState { void Enter(); void Exit(); }
|
||
```
|
||
|
||
---
|
||
|
||
### 32.4 Feature — `DrawingCatalog`
|
||
|
||
#### `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`
|
||
|
||
#### `ShapeBuilderController` *(Systems)*
|
||
Spawns shape pieces for the selected template, tracks snap progress, fires `ShapeAssembledSignal` when complete.
|
||
```csharp
|
||
// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory, IEventBus _bus, ShapeBuilderConfig _cfg
|
||
public sealed class ShapeBuilderController : IDisposable {
|
||
public IReadOnlyList<ShapePieceView> Active { get; }
|
||
public UniTask BuildAsync(string templateId); // load template, spawn pieces in tray
|
||
public void Reset(); // clear, unsubscribe
|
||
}
|
||
// sub: DrawingSelectedSignal
|
||
// pub: ShapeAssembledSignal
|
||
```
|
||
- **Internal:** counts `PieceSnappedSignal` against expected piece count.
|
||
|
||
#### `ShapePieceView : MonoBehaviour` *(Views)*
|
||
World-space draggable sprite with collider. Source for snap-or-return logic shown in section 26.
|
||
```csharp
|
||
public sealed class ShapePieceView : MonoBehaviour {
|
||
public string PieceId { get; }
|
||
public bool IsLocked { get; }
|
||
public event Action<string> Snapped; // raised when piece locks into slot
|
||
public void Initialize(ShapePieceDTO dto, IInputReader input, IAudioService audio);
|
||
}
|
||
```
|
||
- **No public mutators** for position once locked — controller treats `IsLocked` as the source of truth.
|
||
|
||
#### `ShapePieceFactory` *(Systems)*
|
||
Instantiates `ShapePieceView` prefabs from a pool. Avoids re-instantiating across "Next" cycles on the same template family.
|
||
```csharp
|
||
public sealed class ShapePieceFactory {
|
||
public ShapePieceView Spawn(ShapePieceDTO dto, Transform parent);
|
||
public void Despawn(ShapePieceView view);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 32.5b Feature — `Paper`
|
||
|
||
The shared art rig — RT, offscreen camera, screen↔world bridge. Every other feature in the ColorBook scene resolves `IPaperRig` and `IArtInputBridge` from DI and never touches `Screen.*` or `Camera.*` directly.
|
||
|
||
#### `PaperRig : MonoBehaviour, IPaperRig` *(Rig)*
|
||
Scene-bound component placed on a GameObject in `ColorBook.unity`. Owns the RT lifecycle.
|
||
```csharp
|
||
// inspector fields:
|
||
// Camera _artCamera (Orthographic, aspect=1, fixed ortho size)
|
||
// Transform _paperRoot (parent of regions/pieces)
|
||
// Vector2 _designSize = (20, 20) (world units; matches 2048×2048 at PPU=100)
|
||
// int _surfaceSize = 2048 (RT side length, square)
|
||
|
||
public sealed class PaperRig : MonoBehaviour, IPaperRig {
|
||
public Camera ArtCamera => _artCamera;
|
||
public RenderTexture Surface => _surface;
|
||
public Transform PaperRoot => _paperRoot;
|
||
public Vector2 DesignSize => _designSize;
|
||
public Rect DesignRect => new(-_designSize / 2f, _designSize);
|
||
}
|
||
```
|
||
- **Awake:** allocate `_surface = new RenderTexture(_surfaceSize, _surfaceSize, 0, ARGB32) { name = "PaperSurface" };` then `_surface.Create()` and `_artCamera.targetTexture = _surface; _artCamera.aspect = 1f; _artCamera.orthographicSize = _designSize.y / 2f;`.
|
||
- **OnDestroy:** `_surface.Release(); Object.Destroy(_surface);`.
|
||
- **No update logic** — the camera renders every frame automatically because `targetTexture` is set.
|
||
- **Important:** `_artCamera`'s `orthographicSize` and `aspect` are set once and never touched again. The RT contents are deterministic.
|
||
|
||
#### `ArtInputBridge : MonoBehaviour, IArtInputBridge` *(Input)*
|
||
Lives on the same UI Canvas as the paper `RawImage`.
|
||
```csharp
|
||
// inspector fields:
|
||
// RawImage _paperImage (the on-screen paper)
|
||
// RectTransform _paperRect (== _paperImage.rectTransform)
|
||
// Camera _uiCamera (Canvas event camera)
|
||
// IPaperRig _rig (injected via VContainer + IInjectable, or resolved in Start)
|
||
|
||
public bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos) {
|
||
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||
_paperRect, screenPos, _uiCamera, out var local)) {
|
||
artWorldPos = default; return false;
|
||
}
|
||
var rect = _paperRect.rect;
|
||
var uv = new Vector2(
|
||
(local.x - rect.xMin) / rect.width,
|
||
(local.y - rect.yMin) / rect.height);
|
||
if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1) {
|
||
artWorldPos = default; return false;
|
||
}
|
||
artWorldPos = _rig.ArtCamera.ViewportToWorldPoint(uv);
|
||
return true;
|
||
}
|
||
```
|
||
- Returns `false` when the toddler tapped outside the RawImage (HUD button area, backdrop, off-screen).
|
||
- Used by every feature that does world-space picking — `Coloring`, `ShapeBuilder`, and any future feature like stickers.
|
||
|
||
#### `PaperRigModule : MonoBehaviour, IServiceModule` *(Installers)*
|
||
Scene-scoped installer. Dragged onto `ColorBookLifetimeScope._installers[]`.
|
||
```csharp
|
||
// inspector fields:
|
||
// PaperRig _rig
|
||
// ArtInputBridge _bridge
|
||
|
||
public void Register(IContainerBuilder builder) {
|
||
builder.RegisterInstance<IPaperRig>(_rig);
|
||
builder.RegisterInstance<IArtInputBridge>(_bridge);
|
||
}
|
||
```
|
||
- Registers as `Instance` because both are MonoBehaviours already in the scene.
|
||
- Lifetime is implicitly tied to the scene (Unity destroys them on unload).
|
||
|
||
---
|
||
|
||
### 32.6 Feature — `Coloring`
|
||
|
||
#### `ColoringStateRepository` *(Repository)*
|
||
In-memory model. Owns "currently selected color" and the palette in use.
|
||
```csharp
|
||
public sealed class ColoringStateRepository {
|
||
public IColorPalette Palette { get; private set; }
|
||
public int SelectedIndex { get; private set; }
|
||
public Color CurrentColor => Palette.Colors[SelectedIndex];
|
||
public event Action<int> SelectedIndexChanged;
|
||
public void SetPalette(IColorPalette palette); // resets SelectedIndex to 0
|
||
public void SelectColor(int index);
|
||
}
|
||
```
|
||
- **Why a repository:** presenter and controller both need to read/write current color; an event-emitting POCO is simpler than wiring two signals.
|
||
|
||
#### `ColoringController` *(Systems)* — implements `IColoringController`
|
||
Builds and pushes `PaintRegionCommand` instances; spawns `ColorRegionView` per region.
|
||
```csharp
|
||
// fields: IUndoStack _undo, ColoringStateRepository _state, ColorRegionFactory _factory, IEventBus _bus
|
||
public interface IColoringController {
|
||
UniTask SpawnRegionsAsync(IDrawingTemplate template);
|
||
void PaintRegion(ColorRegionView view); // builds command, pushes to undo stack
|
||
void Clear();
|
||
}
|
||
// sub: ShapeAssembledSignal (via flow controller, not direct)
|
||
// pub: ColorAppliedSignal (via PaintRegionCommand)
|
||
```
|
||
|
||
#### `ColorRegionView : MonoBehaviour` *(Views)*
|
||
Sprite + `PolygonCollider2D`, on `Artwork` layer. Tapped via `Physics2D.OverlapPoint` from `ColoringInputBinder`.
|
||
```csharp
|
||
public sealed class ColorRegionView : MonoBehaviour {
|
||
public string RegionId { get; }
|
||
public Color Color { get; } // current paint
|
||
public void Initialize(ColorRegionDTO dto);
|
||
public void SetColor(Color c); // setter only; no logic
|
||
}
|
||
```
|
||
|
||
#### `ColoringInputBinder` *(Systems)* — `IStartable, IDisposable`
|
||
Subscribes to `IInputReader.PointerDown`. On each tap:
|
||
1. `_bridge.TryScreenToArtWorld(screenPos, out var artPos)` — bail if outside the paper.
|
||
2. `Physics2D.OverlapPoint(artPos, _artworkMask)` against the `Artwork` layer.
|
||
3. If hit, `ColoringController.PaintRegion(hit.GetComponent<ColorRegionView>())`.
|
||
|
||
```csharp
|
||
// fields: IInputReader _input, IArtInputBridge _bridge, IColoringController _coloring, LayerMask _artworkMask
|
||
```
|
||
Note: `_bridge` is the same instance the entire scene uses — no per-feature coordinate math.
|
||
|
||
#### `PaintRegionCommand` *(Commands)*
|
||
Source in section 23. Holds `view`, `fromColor`, `toColor`, `bus`. Symmetrical execute/undo.
|
||
|
||
#### `ColorPaletteView`, `ColorPalettePresenter` *(UI)*
|
||
Sources in section 25. Presenter binds `ColoringStateRepository.SelectedIndexChanged` ↔ `IColorPaletteView`.
|
||
|
||
#### `ColorRegionFactory` *(Systems)*
|
||
Mirror of `ShapePieceFactory` for regions. Pool-friendly.
|
||
|
||
---
|
||
|
||
### 32.7 Feature — `History`
|
||
|
||
#### `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`
|
||
|
||
#### `CaptureController` *(Systems)*
|
||
The orchestrator behind the "Capture" button. Stateless other than guarding against concurrent captures.
|
||
```csharp
|
||
// fields: ICaptureService _capture, IGalleryService _gallery, IEventBus _bus
|
||
public sealed class CaptureController {
|
||
public bool IsCapturing { get; }
|
||
public UniTask<SavedArtworkDTO> CaptureCurrentAsync(string templateId);
|
||
}
|
||
// pub: ArtworkCapturedSignal (mid-flow), ArtworkSavedSignal (post-save)
|
||
```
|
||
- **Flow:** `_capture.CaptureAsync()` → `_gallery.SaveAsync(bytes, templateId)` → publish signals.
|
||
- **Concurrency:** sets `IsCapturing = true` on entry; UI binds button enabled to `!IsCapturing` to prevent double-tap.
|
||
- **No camera or sprite args** — capture reads `IPaperRig.Surface` directly inside the service.
|
||
|
||
#### `CaptureButtonPresenter` *(UI)*
|
||
Wires button click → `CaptureController.CaptureCurrentAsync`. Disables button while in progress. Shows toast on `ArtworkSavedSignal`.
|
||
|
||
---
|
||
|
||
### 32.9 Feature — `Progression`
|
||
|
||
#### `ProgressionService` *(Systems)* — implements `IProgressionService`
|
||
The only place that knows what "completed" means.
|
||
- **Persistence:** delegates to `IPersistenceService` under key `"progression"`.
|
||
- **Load order:** `AppBoot` calls `LoadAsync()` early.
|
||
- **Save trigger:** after `MarkCompleted`, debounced 500 ms to coalesce a burst of "Next" presses.
|
||
|
||
#### `ProgressionRepository` *(Repository)*
|
||
Pure in-memory holder used by the service. Separated so tests can inspect state without going through file IO.
|
||
|
||
---
|
||
|
||
### 32.10 Feature — `ColorBookFlow`
|
||
|
||
#### `ColorBookFlowController` *(Systems)* — `IStartable, IDisposable`
|
||
**The only orchestrator inside the ColorBook scene.** Drives the panel FSM: `Catalog → Building → Coloring → Done`.
|
||
```csharp
|
||
// fields:
|
||
// IEventBus _bus
|
||
// IDrawingTemplateCatalog _catalog
|
||
// ShapeBuilderController _builder
|
||
// IColoringController _coloring
|
||
// CaptureController _capture
|
||
// IProgressionService _progression
|
||
// ColorBookSceneRefs _refs (panel roots to enable/disable)
|
||
// Fsm<ColorBookState> _fsm
|
||
```
|
||
- **State table:**
|
||
|
||
| State | On enter | Triggers exit |
|
||
|------------|-----------------------------------------------|--------------------------------|
|
||
| `Catalog` | Show catalog panel | `DrawingSelectedSignal` |
|
||
| `Building` | `_builder.BuildAsync(id)` | `ShapeAssembledSignal` |
|
||
| `Coloring` | `_coloring.SpawnRegionsAsync(template)` | "Next" or "Capture" pressed |
|
||
| `Done` | Run autosave capture, mark completed, `Go(Catalog)` for next | always advances |
|
||
|
||
- **"Next" sequence:** `_capture.CaptureCurrentAsync` → `_progression.MarkCompleted` → `_catalog.Release(current)` → `_catalog.LoadAsync(_catalog.NextUnseen(current))` → re-enter `Building`.
|
||
|
||
---
|
||
|
||
### 32.11 Feature — `ArtBook`
|
||
|
||
#### `GalleryPresenter` *(UI)* — `IAsyncStartable, IDisposable`
|
||
Lists artworks, opens fullscreen view, deletes, shares.
|
||
```csharp
|
||
// fields: IGalleryService _gallery, IGalleryView _view, IExternalShareService _share, IEventBus _bus
|
||
```
|
||
- **Start:** `_gallery.ListAsync()` → `_view.SetItems(...)`.
|
||
- **Subscribes** to `ArtworkSavedSignal` to live-refresh if the user pops back in.
|
||
|
||
#### `IGalleryView` *(UI)*
|
||
```csharp
|
||
public interface IGalleryView {
|
||
event Action<string> OnArtworkTapped;
|
||
event Action<string> OnDeleteRequested;
|
||
event Action<string> OnShareRequested;
|
||
void SetItems(IReadOnlyList<GalleryItemVM> items);
|
||
void ShowFullscreen(Texture2D full);
|
||
void HideFullscreen();
|
||
}
|
||
```
|
||
|
||
#### `IExternalShareService` *(Core)*
|
||
Platform plugin shim (iOS Photos / Android MediaStore).
|
||
```csharp
|
||
public interface IExternalShareService {
|
||
UniTask SaveToCameraRollAsync(byte[] png);
|
||
UniTask ShareAsync(byte[] png, string subject);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 32.12 App Layer
|
||
|
||
#### `AppBoot` *(App/Boot)* — `IAsyncStartable`
|
||
Single entry point. Steps in section 29.
|
||
```csharp
|
||
// fields: IAssetProviderService _assets, IPersistenceService _persist, IProgressionService _progress,
|
||
// IAudioService _audio, ISceneService _scenes, BootConfig _cfg
|
||
public sealed class AppBoot : IAsyncStartable {
|
||
public UniTask StartAsync(CancellationToken ct);
|
||
}
|
||
```
|
||
|
||
#### LifetimeScopes
|
||
- `RootLifetimeScope` — section 21. Registers all services + `IEventBus`. Persists for app lifetime.
|
||
- `MainMenuLifetimeScope` — registers `MainMenuPresenter` and view.
|
||
- `ColorBookLifetimeScope` — section 21. Registers feature installers + `ColorBookFlowController` as entry point.
|
||
- `ArtBookLifetimeScope` — registers `GalleryPresenter` + view + `IExternalShareService`.
|
||
|
||
All scope classes are thin: serialized fields for scene refs, `Configure(IContainerBuilder)` only.
|
||
|
||
---
|
||
|
||
### 32.13 Cross-cutting types
|
||
|
||
#### `ColorBookSceneRefs : MonoBehaviour` *(App)*
|
||
Aggregates all scene-bound Unity references that features need: `Camera artCamera`, `Transform catalogRoot`, `Transform builderRoot`, `Transform coloringRoot`, `RectTransform hudRoot`, `ColorPaletteView paletteView`, `HistoryButtonsView historyView`. Registered as a singleton in `ColorBookLifetimeScope` so features don't `Find` things.
|
||
|
||
#### `IInstaller` *(App)*
|
||
```csharp
|
||
public interface IInstaller { void Install(IContainerBuilder builder); }
|
||
```
|
||
Implemented as `ScriptableObject` per feature so scopes can drag them in the inspector (section 22).
|
||
|
||
---
|
||
|
||
### 32.14 Class summary table
|
||
|
||
| Class | Layer | Role | Key dependencies |
|
||
|---|---|---|---|
|
||
| `AppBoot` | App | Startup sequencer | assets, persist, progression, scenes |
|
||
| `RootLifetimeScope` | App | Root DI | configs |
|
||
| `ColorBookLifetimeScope` | App | Scene DI | scene refs, installers |
|
||
| `DrawingCatalogController` | Feature | Grid logic | catalog, bus |
|
||
| `DrawingCatalogPresenter` | Feature | UI bridge | view, controller, catalog |
|
||
| `ShapeBuilderController` | Feature | Piece spawn + snap tracking | catalog, factory, bus, cfg |
|
||
| `ShapePieceView` | Feature | Draggable piece MB | input, audio |
|
||
| `ColoringStateRepository` | Feature | Current color model | — |
|
||
| `ColoringController` | Feature | Region spawn + paint cmd | undo, state, factory, bus |
|
||
| `ColorRegionView` | Feature | Region sprite MB | — |
|
||
| `PaintRegionCommand` | Feature | Undoable paint | view, bus |
|
||
| `PaperRig` | Feature | RT + ArtCamera owner | — |
|
||
| `ArtInputBridge` | Feature | Screen→art-world picking | rig, raw image, ui cam |
|
||
| `PaperRigModule` | Feature | DI registration | rig, bridge |
|
||
| `HistoryController` | Feature | Undo/redo facade | undo stack, bus |
|
||
| `CaptureController` | Feature | Capture+save orchestration | capture svc, gallery, bus |
|
||
| `ColorBookFlowController` | Feature | Scene FSM | bus, catalog, builder, coloring, capture, progression |
|
||
| `GalleryPresenter` | Feature | Art book listing | gallery, share, view, bus |
|
||
| `BoundedUndoStack` | Lib | Capped undo store | — |
|
||
| `EventBus` | Lib | Pub/sub | — |
|
||
| `Fsm<TState>` | Lib | Generic FSM | — |
|
||
| `AddressableAssetProviderService` | Service | Addressables wrapper | — |
|
||
| `FileGalleryService` | Service | Gallery file IO | paths, thumb gen, bus |
|
||
| `RenderTextureCaptureService` | Service | PNG render from rig.Surface | paper rig |
|
||
| `JsonPersistenceService` | Service | Settings/progression IO | — |
|
||
| `SceneService` | Service | Async scene loads | — |
|
||
| `AudioService` | Service | SFX playback | assets |
|
||
| `ProgressionService` | Service | Completion tracking | persistence |
|
||
|
||
If you add a class not in this table, add it here in the same PR. This table is the cheap mental-model index — keep it honest.
|