# Conflicts: # Assets/Darkmatter/Scenes/Colorbook.unity
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 Canvas-only UI render strategy.
This document is the canonical reference for the Color Book game's structure. The Bus Game's Darkmatter Architecture Guide is the parent contract; this doc only adds game-specific structure.
1. Game Flow
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
ICommandso 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
IEventBussignals or Core contracts. - Core may not contain logic. Interfaces, enums, DTOs, signal records only.
- Core may reference
UniTaskfor 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 eitherCore/Contracts/<Area>/or new top-levelCore/<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, IServiceModulewithRegister(IContainerBuilder builder). NotIInstaller.Install(...)as older sections of this doc imply. New installers must followIServiceModule. - No
AppBootentry point exists yet.RootLifetimeScopeonly iterates a serializedMonoBehaviour[] serviceModulesand callsRegisteron eachIServiceModule. Boot sequence in §29 is aspirational. - No Persistence service.
Libs/PlayerPrefs(theProtectedPlayerPrefslibrary) 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/Getonly — no aspect-fit logic.
4c. Planned additions (not on disk yet)
All new game code follows the same nesting pattern as existing Services — Contracts/Features/<Name>/, Data/{Dynamic,Static}/Features/<Name>/, Features/<Name>/ — and asmdefs drop the Darkmatter. prefix to match Core, Services.Audio, Libs.Observer.
Rough landing order for ColorBook scene to be playable:
| Path | Role |
|---|---|
Core/Contracts/Services/Capture/ICaptureService.cs |
Capture service contract — returns PNG bytes |
Core/Contracts/Services/Gallery/IGalleryService.cs |
Native-gallery save shim |
Core/Contracts/Features/Drawing/IDrawingTemplate.cs, IDrawingTemplateCatalog.cs |
Drawing template contracts |
Core/Contracts/Features/Coloring/IColorPalette.cs |
Palette contract |
Core/Contracts/Features/Progression/IProgressionService.cs |
Progression contract |
Core/Data/Static/Features/Drawing/DrawingTemplateSO.cs |
Authored drawing data |
Core/Data/Static/Features/Drawing/ShapeSO.cs |
Authored shape (sprite + snap params, reusable) |
Core/Data/Static/Features/Coloring/ColorPaletteSO.cs |
Authored palette data |
Core/Data/Dynamic/Features/Drawing/ColorRegionDTO.cs |
Runtime region struct |
Core/Data/Dynamic/Features/Coloring/PaintCommandDTO.cs |
Runtime coloring struct |
Core/Data/Dynamic/Features/Signals/*.cs (DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, PaperSavedSignal) |
Cross-feature signal structs |
Core/Enums/Services/Camera/CameraType.cs |
Add CaptureCamera enum value to existing file |
Services/Capture/ (+ Services.Capture.asmdef) |
RenderTextureCaptureService drives the disabled CaptureCamera |
Services/Gallery/ (+ Services.Gallery.asmdef) |
NativeGallerySaveService — wraps the native gallery plugin |
Features/MainMenu/ (+ Features.MainMenu.asmdef) |
Spine mascot + Play button |
Features/{DrawingCatalog,ShapeBuilder,Coloring,Capture,Progression,ColorBookFlow}/ (+ asmdefs each) |
Game features |
App/LifetimeScopes/{MainMenu,ColorBook}LifetimeScope.cs |
Per-scene scopes |
App/Boot/AppBoot.cs |
Bootstrap entry point |
Assets/Darkmatter/Scenes/{MainMenu,ColorBook}.unity |
Scenes (no ArtBook — captures go to phone Photos) |
Content/Gameplay/Drawings/<theme>/<id>/{Template.asset, Drawing.prefab, Regions/, PaperBackground.png} |
Authored drawings — Drawing.prefab holds SlotMarkers at slot poses with ShapeSO refs |
Content/Gameplay/Shapes/*.asset |
Reusable ShapeSOs (one per shape; shared across drawings) |
Content/Gameplay/Prefabs/ShapePiece.prefab |
The single piece prefab (ShapePieceUI MB on root) |
Content/Gameplay/Palettes/*.asset |
Color palettes |
Content/Audio/{UI,Coloring}/ |
SFX banks |
4d. Per-feature folder layout (matches existing Services pattern)
Look at Services/Camera/ (Installers/ + Service/) and Services/Analytics/ (Installers/ + Systems/) — that's the convention. Features adopt the same shape, adding UI/ or Views/ only when there's something to put in them.
Features/[Name]/
├── Installers/ IServiceModule — VContainer registration
├── Systems/ (or Service/) Controllers, repositories, factories (pure C#)
├── UI/ (only if the feature has Canvas UI)
│ ├── *Presenter.cs Pure C#, listens to model, drives view
│ └── *View.cs MonoBehaviour, setters only
├── Views/ (only if the feature has world-space MonoBehaviours)
└── Features.<Name>.asmdef
Rules of thumb pulled from current Services:
- Use
Service/(singular) when the feature has exactly one main implementation class (likeServices/Camera/Service/CameraService.cs). - Use
Systems/(plural) when there are multiple pure-C# coordinators (likeServices/Analytics/Systems/). - Skip nesting entirely when the feature has only 1–2 files at root (like
Services/Audio/AudioService.cs+SfxPlayer.csflat). Docs/is per-folder in current code — drop aDocs/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)Darkmatter.Services.Camera(CameraService.cs)Darkmatter.Services.Camera.Installers(CameraServiceModule.cs)Darkmatter.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:
IAssetProviderService.InitializeAsync()— Addressables init.IProgressionSystem.LoadAsync()— hydrate per-template state from PlayerPrefs.IDrawingTemplateCatalog.InitializeAsync()— batch-load allDrawingTemplateSOs by Addressables label.- Optional: preload UI sounds, palette assets.
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 inDarkmatter.Core.Data.Dynamic.Features.Drawing.
namespace Darkmatter.Core.Contracts.Features.Drawing;
public interface IDrawingTemplate {
string Id { get; }
string DisplayName { get; }
Sprite DefaultThumbnail { get; } // authored fallback (used when user has no captures for this template)
Sprite PaperBackground { get; }
IReadOnlyList<ShapeSO> Pieces { get; } // shapes that get spawned in the tray for this drawing
IReadOnlyList<ColorRegionDTO> Regions { get; }
}
public readonly struct ColorRegionDTO {
public string RegionId { get; }
public Sprite Sprite { get; } // assigned to Image.sprite
public Vector2 AnchoredPosition { get; } // canvas units, relative to 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.
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:
- One piece prefab. A
ShapePiecePrefabinContent/Gameplay/Prefabs/carriesImage+ShapePieceUI. TheShapePieceUIMonoBehaviour has a[SerializeField] ShapeSO _shapefield — empty on the raw prefab, filled in by the controller at spawn time (or pre-assigned in inspector for testing scenes). SlotMarkerlives in the drawing's per-drawing scene/prefab at the slot's authored pose. Its[SerializeField] ShapeSO _shapefield tells which shape fits this slot. The slot'sRectTransform(anchoredPosition,sizeDelta,localRotation) is the target snap pose.- Matching is by
ShapeSOreference equality. Piece P matches slot S iffP._shape == S._shape. No string-id lookups at runtime. - Identity follows the asset. Whenever
_shapechanges (inspector edit or runtime assign),ShapePieceUIre-applies_shape.Spriteto itsImage, setsRectTransform.sizeDelta = _shape.DefaultSizeDelta, and exposesPieceId => _shape.Id. Done viaOnValidate(editor) +Awake(runtime) + an explicitAssign(ShapeSO)method (controller-driven).
Optional future editor tool: a wizard window for bulk-creating
ShapeSOs from a folder of sprites — setsIdfrom 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 inDarkmatter.Core.Data.Dynamic.Features.Coloring.
namespace Darkmatter.Core.Contracts.Features.Coloring;
public interface IColorPalette {
string Id { get; }
IReadOnlyList<Color> Colors { get; }
}
public readonly struct PaintCommandDTO {
public string RegionId { get; }
public Color FromColor { get; }
public Color ToColor { get; }
}
Scene composition (no Paper feature)
The paper area is just a PaperPanel GameObject in the 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:
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 SlotMarkers + ColorRegionViews 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:
builder.RegisterComponent<IGameplaySceneRefs>(_sceneRefs);
Features inject IGameplaySceneRefs (or their own holder views) directly.
History
Contracts in
Darkmatter.Core.Contracts.Features.History.
namespace Darkmatter.Core.Contracts.Features.History;
public interface ICommand {
void Execute();
void Undo();
}
public interface IUndoStack {
bool CanUndo { get; }
bool CanRedo { get; }
void Push(ICommand cmd); // executes + appends
void Undo();
void Redo();
void Clear();
}
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.
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");
}
ICaptureServiceowns theCaptureCamerareference (a disabledCamerain the ColorBook scene). The camera'scullingMaskis set toPaperUIso the HUD physically cannot appear in the PNG. Paper background is just anImageonPaperUI— no compositing pass.IGalleryServiceis a thin shim over a native gallery plugin (NativeGallery, NativeShare, or a custom plugin). It does not save thumbnails, does not maintain a file list, does not delete entries. Everything in the device library is owned by the OS.- There is no
SavedArtworkDTO, no sidecar JSON, no in-app gallery file system. The user views their drawings in the phone's Photos app.
Signals
Signal structs live in
Darkmatter.Core.Data.Dynamic.Features.Signals(runtime data, cross-feature).
namespace Darkmatter.Core.Data.Dynamic.Features.Signals;
public readonly struct DrawingSelectedSignal {
public string TemplateId { get; }
}
public readonly struct ShapeAssembledSignal {
public string TemplateId { get; }
}
public readonly struct ColorAppliedSignal {
public string RegionId { get; }
public Color Color { get; }
}
public readonly struct PaperCapturedSignal {
public string TemplateId { get; } // captured, before native-gallery save
}
public readonly struct PaperSavedSignal {
public string TemplateId { get; } // PNG written to phone library
}
9. Feature Responsibilities
MainMenu
- Lives in
MainMenu.unity. Single primary action: Play (→ColorBookscene). There is no in-app gallery viewer — captured drawings live in the phone's Photos app. - Hosts a Spine character mascot (via
SkeletonGraphicfor 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, callsIMenuMascotView.Play(animName, loop).- View is setter-only. Spine-Unity's
SkeletonGraphic.AnimationState.SetAnimation(track, name, loop)is encapsulated behindIMenuMascotView. - 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 readsLastOpenedTemplateIdin Gameplay scope startup). - Loads the per-drawing prefab via
IDrawingTemplateCatalog, instantiates it underGameplaySceneRefs.PaperRoot. The prefab carries theSlotMarkers at their authored poses. ShapeHolderViewowns the trayRectTransform+ slide-in/out animation (PrimeTween —Tween.UIAnchoredPosition+Tween.AlphaonCanvasGroup). Default hide direction is right (off-screen +X), tunable in inspector.ShapeHolderPresentersubscribes toShapeBuilderStartedSignal→view.Show()andShapeAssembledSignal→view.Hide(). No controller poking the view directly.IShapePieceFactory(ShapePieceFactoryimpl) owns the per-piece dependencies (ShapeBuilderConfig,ISfxPlayer,IEventBus,IUndoStack) and the spawn parent (read fromShapeHolderView.SpawnRoot). Factory creates oneShapePieceMB and runs itsSetup. Controller callsfactory.Create(prefab, shape, slot, trayPos, preSnapped)per piece.ShapeBuilderControlleronly orchestrates: loads piece prefab viaIAssetProviderService, computes tray positions fromholder.SpawnWidth, publishesShapeBuilderStartedSignalbefore spawn, and listens forPieceSnappedSignalto count down toShapeAssembledSignal. Ifprogress.Phase == ShapeBuilding, pieces inprogress.SnappedPiecesare passed to the factory withpreSnapped: true.ShapePieceis a single MB handling all three behaviors inline: drag (Unity UIIBeginDrag / IDrag / IEndDrag), reactive preview lerp when withincfg.PreviewRadius, snap (PrimeTween —Tween.UIAnchoredPosition/UISizeDelta/LocalRotation) on release insidecfg.SnapRadius, otherwise tween back to tray.- Publishes
PieceSnappedSignal(pieceId)on lock. Controller counts against expected; firesShapeAssembledSignal(templateId)when all locked.
Coloring
- Listens to
ShapeAssembledSignal. - Regions are pre-authored as children of the drawing prefab (under
PaperRoot). Each is aColorRegionView(UIImage+IPointerClickHandler). OnAwake, the view setsImage.alphaHitTestMinimumThresholdfrom a serialized field (default0.01f; tune up to0.5ffor tighter hits) so taps on transparent pixels pass through to the next region below. ColorPaletteHolderViewowns the palette containerRectTransform+ slide-in/out animation (same pattern asShapeHolderView—Tween.UIAnchoredPosition+Tween.AlphaonCanvasGroup, hides off-screen right).ColorPaletteHolderPresentersubscribes toShapeAssembledSignal→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 (swatchImage+IPointerClickHandler).ColorButtonFactoryinstantiates the button prefab underColorPaletteHolderView.SpawnRootand callsbutton.Setup(color, state, sfx). On click, the button writes directly toColoringStateRepository.SelectColor(myColor)— no view/presenter intermediary.ColoringControlleriteratesIColorPalette.Colorsonce on init and callsfactory.Create(color)per entry. MirrorsShapePieceFactoryfor symmetry.- Each region's
OnPointerClick→ColoringController.PaintRegion(view). - Controller builds
PaintRegionCommand(regionId, oldColor, newColor)and pushes toIUndoStack. Command setsImage.coloron Execute/Undo. - Publishes
ColorAppliedSignalfor SFX / sparkle effects. - Resume: if
progress.RegionColorsis non-empty, spawned regions are initialized with those saved colors instead ofregion.InitialColor. - Autosave hook: after each
PaintRegion, debounces 500 ms then callsGameplayFlowController.AutosaveAsyncso the colors hit disk without thumbnail re-render. See §13.
History
- Owns the scoped
IUndoStackfor 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/CanRedois false.
Capture
- Wraps
ICaptureService.CaptureAsync()(one-shotCaptureCamera.Render()into a temp RT, ReadPixels, EncodeToPNG). Returns raw PNG bytes. - The Capture feature does NOT decide what to do with the bytes —
GameplayFlowControllercalls 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, persistsDrawingProgressrecords viaLibs.PlayerPrefs(single JSON blob underPlayerPrefsKeys.Progression). - Internally stores thumbnails per template as PNG files in
Application.persistentDataPath/thumbs/{safeId}.png(large blobs don't belong in PlayerPrefs). ProgressionRepositorydoes the IO;ProgressionSystemkeeps 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 itsDrawingProgress, 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) forBuilding↔Coloringruntime 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
Released before next loads. - This bound matters on toddler tablets with limited RAM.
Remote groups (future)
Drawing packs ship as remote bundles. New theme packs (Christmas, Dinosaurs) update without an app store release.
11. Persistence
Only one in-app persistent store — small settings + progression. Captured drawings go to the device's native photo library (managed by the OS, not by the app).
Settings + progression via Libs.PlayerPrefs
ProtectedPlayerPrefs (in Libs/PlayerPrefs/) is a lightweight encrypted-string wrapper around Unity's PlayerPrefs. Used for:
- Completed template IDs (JSON-encoded list).
- Last opened drawing.
- Audio volume, simple toggles.
A higher-level IProgressionService reads/writes these keys; consumers never touch PlayerPrefs directly.
Native photo library (gallery)
Captured PNGs go to the phone's Photos app via IGalleryService.SaveToDeviceAsync(bytes, albumName). The app does not:
- Write
.pngfiles topersistentDataPath. - 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.cullingMaskexcludesHUDUI. Layer mask, not coincidence — even if you accidentally parent a HUD element underPaperPanel, putting it on the wrong layer keeps it out. - Paper background is just an
ImageonPaperUI. Already in the right layer; no special compositing. - Saved PNGs are 2048×2048 on every device.
CaptureCamerahas fixedorthographicSizeand aspect, independent of screen size. CaptureAsyncis 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.GetTemporaryso successive captures don't leak GPU memory. IGalleryServiceandICaptureServiceare independent —IGalleryServiceknows nothing about the camera;ICaptureServiceknows nothing about the native plugin. The chain is theCaptureController'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 firstfirstCompletedUtcIso); incrementscompletionCount. - 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:
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
GameplayFlowControllercalls_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:
[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
- Is it a cross-assembly interface / enum / DTO? →
Core/ - Is it a generic, sellable utility? →
Libs/ - Is it infrastructure (input, audio, file IO, addressables, capture)? →
Services/ - Is it gameplay logic specific to coloring books? →
Features/ - 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
BrushStrokeCommandand a dynamic texture per region; out of scope for v1. - Multi-child profiles — single-profile for v1; multi-profile would slot in behind
IProgressionServiceandIGalleryServicekeyed byprofileId. - Cloud sync — gallery sync would happen behind
IGalleryService(decorator pattern); local-first stays the source of truth. - Sticker / decoration layer — additive sprite layer above coloring, also
ICommand-driven so it integrates with undo/redo cleanly.
19. Quick Reference — Feature ↔ Signal Map
| Feature | Subscribes to | Publishes |
|---|---|---|
MainMenu |
— | — (Play tap loads a scene directly) |
DrawingCatalog |
— | DrawingSelectedSignal |
ShapeBuilder |
DrawingSelectedSignal |
ShapeAssembledSignal |
Coloring |
ShapeAssembledSignal |
ColorAppliedSignal |
History |
DrawingSelectedSignal (to clear) |
— |
Capture |
— (button-driven) | PaperCapturedSignal, PaperSavedSignal |
Progression |
PaperSavedSignal |
— |
ColorBookFlow |
ShapeAssembledSignal, PaperSavedSignal |
— |
Maintained alongside the Darkmatter Architecture Guide. Do not break the dependency arrows.
20. Assembly Definition Map
Every Lib / Service / Feature is its own .asmdef. The Darkmatter. prefix is only on the App asmdef; everything else uses bare <Layer>.<Module> names. References follow the layer rules.
On disk today
| Asmdef | Path | References |
|---|---|---|
Darkmatter.App |
App/ |
All Services, Libs, Core (Features when they land) |
Core |
Core/ |
(none — UniTask allowed in async signatures) |
Libs.FSM |
Libs/FSM/ |
Core |
Libs.Installers |
Libs/Installers/ |
(VContainer only) |
Libs.Observer |
Libs/Observer/ |
Core |
Libs.PlayerPrefs |
Libs/PlayerPrefs/Runtime/ |
(standalone) |
Libs.PlayerPrefs.Editor |
Libs/PlayerPrefs/Editor/ |
Libs.PlayerPrefs |
Libs.UI |
Libs/UI/ |
Core |
Services.Analytics |
Services/Analytics/ |
Core, Libs.Installers |
Services.Assets |
Services/Assets/ |
Core, Libs.Installers |
Services.Audio |
Services/Audio/ |
Core, Libs.Installers |
Services.Camera |
Services/Camera/ |
Core, Libs.Installers |
Services.Inputs |
Services/Inputs/ |
Core, Libs.Installers |
Services.Scenes |
Services/Scenes/ |
Core, Libs.Installers |
Planned (not on disk yet)
| Asmdef | Path | References |
|---|---|---|
Services.Capture |
Services/Capture/ |
Core, Libs.Installers |
Services.Gallery |
Services/Gallery/ |
Core, Libs.Installers |
Features.MainMenu |
Features/MainMenu/ |
Core, Libs.Installers |
Features.DrawingCatalog |
Features/DrawingCatalog/ |
Core, Libs.Installers |
Features.ShapeBuilder |
Features/ShapeBuilder/ |
Core, Libs.Installers |
Features.Coloring |
Features/Coloring/ |
Core, Libs.Installers |
Features.Capture |
Features/Capture/ |
Core, Libs.Installers |
Features.Progression |
Features/Progression/ |
Core, Libs.Installers, Libs.PlayerPrefs |
Features.ColorBookFlow |
Features/ColorBookFlow/ |
Core, Libs.Installers, Libs.FSM |
Libs.CommandStack,Features.ArtBook, andFeatures.Paperwere previously planned but cut. Undo lives insideFeatures.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/IUndoStacklive inCore, soFeatures.Coloringreaches the undo stack without referencingFeatures.History.
Hard rules:
- No Service asmdef references any Feature asmdef.
- No Feature asmdef references another Feature asmdef.
- All Services and Features depend on
Libs.Installersso they can implementIServiceModule. - The compiler enforces this — if a
usingwon't resolve, the dependency is wrong.
21. LifetimeScope Concrete Sample
All scopes use the same pattern: a serialized MonoBehaviour[] list of IServiceModule installers. Each installer is a MonoBehaviour on a child GameObject of the scope. Scope iterates and calls Register. No hardcoded registrations in the scope itself. This is exactly what RootLifetimeScope.cs already does today.
RootLifetimeScope (Boot scene, persists forever) — actual code
using Darkmatter.Libs.Installers;
using UnityEngine;
using VContainer;
using VContainer.Unity;
public class RootLifetimeScope : LifetimeScope {
[SerializeField] private MonoBehaviour[] serviceModules;
protected override void Configure(IContainerBuilder builder) {
foreach (var module in serviceModules) {
if (module is IServiceModule serviceModule)
serviceModule.Register(builder);
}
}
}
The inspector lists the installer MonoBehaviours in serviceModules[]. Drag the children of the Boot scope GameObject (e.g. AudioServiceModule, CameraServiceModule, InputServiceModule, AssetProviderServiceModule, AnalyticsServiceModule, SceneServiceModule) into that slot. Each is a MonoBehaviour, IServiceModule.
ColorBookLifetimeScope (per-scene, child of Root) — same pattern
namespace Darkmatter.App.LifetimeScopes;
public sealed class ColorBookLifetimeScope : LifetimeScope {
[SerializeField] private MonoBehaviour[] sceneModules;
protected override void Configure(IContainerBuilder builder) {
foreach (var module in sceneModules) {
if (module is IServiceModule serviceModule)
serviceModule.Register(builder);
}
}
}
Drag the scene's installer MonoBehaviours into sceneModules[]:
DrawingCatalogModuleShapeBuilderModuleColoringModuleHistoryModuleCaptureModuleProgressionModuleColorBookFlowModule
Each registers its own classes via IServiceModule.Register(IContainerBuilder).
If a scope needs a non-installer reference (e.g. a
GameplaySceneRefsMB holdingPaperRoot), expose it as a separate[SerializeField]andbuilder.RegisterComponent(...)it inside the scope'sConfigure. Don't put scene refs inside an installer — keep installers stateless across scenes.
22. Installer Pattern — Concrete Coloring Sample
Mirrors the existing CameraServiceModule.cs and InputServiceModule.cs — a MonoBehaviour implementing IServiceModule.Register.
namespace Darkmatter.Features.Coloring.Installers;
public sealed class ColoringModule : MonoBehaviour, IServiceModule {
[SerializeField] private ColoringConfig _config;
public void Register(IContainerBuilder builder) {
builder.RegisterInstance(_config);
builder.Register<ColoringStateRepository>(Lifetime.Scoped).AsSelf();
builder.Register<ColoringController>(Lifetime.Scoped)
.As<IColoringController>()
.AsSelf();
builder.Register<IColorButtonFactory, ColorButtonFactory>(Lifetime.Scoped);
builder.RegisterEntryPoint<ColoringInputBinder>();
}
}
Convention:
- One
IServiceModuleper feature, named<Feature>Module(matchesCameraServiceModule,InputServiceModule,AnalyticsServiceModulealready in the project). MonoBehaviourlives on a GameObject under the scope's hierarchy; dragged into the scope'sserviceModules[]/sceneModules[]inspector list.- Method name is
Register, notInstall. There is noIInstallerin this project — usesIServiceModulefrom Libs.Installers. - Registers only its own types. Never touches another feature's types.
- If the installer needs to wire scene-bound MonoBehaviours into DI, expose them as
[SerializeField]fields on the installer itself andbuilder.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'sIServiceModule.
23. Command Pattern — PaintRegionCommand
namespace Darkmatter.Features.Coloring.Commands;
internal sealed class PaintRegionCommand : ICommand {
private readonly ColorRegionView _view;
private readonly Color _fromColor;
private readonly Color _toColor;
private readonly IEventBus _bus;
public PaintRegionCommand(ColorRegionView view, Color from, Color to, IEventBus bus) {
_view = view;
_fromColor = from;
_toColor = to;
_bus = bus;
}
public void Execute() {
_view.SetColor(_toColor);
_bus.Publish(new ColorAppliedSignal(_view.RegionId, _toColor));
}
public void Undo() {
_view.SetColor(_fromColor);
_bus.Publish(new ColorAppliedSignal(_view.RegionId, _fromColor));
}
}
Usage in controller:
public void PaintRegion(ColorRegionView view) {
var current = _state.CurrentColor;
if (view.Color == current) return; // no-op
var cmd = new PaintRegionCommand(view, view.Color, current, _bus);
_undoStack.Push(cmd); // Push executes + records
}
Same pattern applies to SnapPieceCommand if shape-builder steps should be undoable (optional for v1).
24. CommandStack — Libs/CommandStack
namespace Darkmatter.Lib.CommandStack;
public sealed class BoundedUndoStack : IUndoStack {
private readonly Deque<ICommand> _undo = new();
private readonly Stack<ICommand> _redo = new();
private readonly int _capacity;
public BoundedUndoStack(int capacity = 20) => _capacity = capacity;
public bool CanUndo => _undo.Count > 0;
public bool CanRedo => _redo.Count > 0;
public void Push(ICommand cmd) {
cmd.Execute();
_undo.AddLast(cmd);
if (_undo.Count > _capacity) _undo.RemoveFirst();
_redo.Clear();
}
public void Undo() {
if (!CanUndo) return;
var cmd = _undo.Last;
_undo.RemoveLast();
cmd.Undo();
_redo.Push(cmd);
}
public void Redo() {
if (!CanRedo) return;
var cmd = _redo.Pop();
cmd.Execute();
_undo.AddLast(cmd);
}
public void Clear() {
_undo.Clear();
_redo.Clear();
}
}
Deque<T> keeps the oldest entry cheap to evict when the cap fires.
25. 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)
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)
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)
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
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
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
- Reparent on lock —
Lock()callsRectTransform.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. - Three parallel PrimeTween calls — position, size, rotation. Tweens start together so the piece visually snaps as one motion. Zero allocations per tween.
SnapRadiusis in canvas units (fromShapeBuilderConfig, e.g. 80–120), not world units. SameCanvasScalerreference resolution across devices = same hit feel.- Preview hover sound fires once per drag, on the boundary cross into the preview radius.
_inPreviewflag resets onOnBeginDrag.
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.
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.
_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
ParticleSystems under PaperPanel — they don't render in UI canvases withoutUIParticleSystemor 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 |
| iOS-only fallback | PHPhotoLibrary direct bindings |
| Android-only fallback | MediaStore direct bindings via AndroidJavaClass |
Permission handling:
- iOS —
NSPhotoLibraryAddUsageDescriptioninInfo.plist. iOS prompts on first save. - Android 13+ — no permission required for writes that target a public collection via the plugin.
- Android 11–12 —
WRITE_EXTERNAL_STORAGEdeclared but not requested at runtime; plugin uses scoped storage. - Android ≤ 10 —
WRITE_EXTERNAL_STORAGEruntime 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
AppBootclass exists. Today, RootLifetimeScope.cs only iterates installer MonoBehaviours and registers them — nothing runs after that. The block below is the target sequence whenAppBootis added as anIAsyncStartableentry point underApp/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)
- Open
Colorbook.slnat the repo root. - 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). - Open
Assets/Darkmatter/Scenes/Boot.unity(currently the only scene wired). - Inspect the
RootLifetimeScopeGameObject — confirm itsserviceModules[]list references the child installer MonoBehaviours (AudioServiceModule,CameraServiceModule,InputServiceModule, etc.). - Hit Play from
Boot.unity. Other scenes (MainMenu,ColorBook) don't exist yet — they're listed in §6 / §4c as planned work. - When new scene scopes land, the same rule applies: never start a scene mid-flow, always enter from
Boot.unityso the root scope exists. - When drawings are authored: duplicate the template folder under
Content/Gameplay/Drawings/<theme>/<id>/, editTemplate.asset(pieces + regions), add to the appropriate Addressables group. - Run
Tests > EditModeandTests > PlayModebefore 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
UniTaskunless noted.- Folder labels follow the actual nesting pattern:
Core/Contracts/Features/<Name>/,Core/Contracts/Services/<Name>/,Core/Data/Dynamic/Features/<Name>/,Features/<Name>/<Sub>/,Services/<Name>/<Sub>/.
32.1 Core Contracts
Pure interfaces and DTOs. Zero logic.
IDrawingTemplate (Core/Contracts/Features/Drawing — planned)
Immutable view of a single drawing's authored data.
public interface IDrawingTemplate {
string Id { get; } // e.g. "animals/elephant"
string DisplayName { get; } // user-facing
Sprite DefaultThumbnail { get; } // 256×256 authored fallback for the catalog grid
Sprite PaperBackground { get; } // image under all paper content
IReadOnlyList<ShapeSO> Pieces { get; } // shapes spawned in tray (reusable across drawings)
IReadOnlyList<ColorRegionDTO> Regions { get; } // for Coloring
}
Implemented by DrawingTemplateSO (ScriptableObject) loaded via Addressables. The per-drawing slot positions live in the drawing's authored scene/prefab as SlotMarker MonoBehaviours, not in the template SO.
The catalog grid always uses
DefaultThumbnail. The user's captured drawings live in the phone's Photos app, not the catalog cell.
IDrawingTemplateCatalog (Core/Contracts/Features/Drawing — planned)
Authority on which drawings exist, completion state, and "next" selection.
public interface IDrawingTemplateCatalog {
UniTask InitializeAsync(); // pulls manifest from Addressables
IReadOnlyList<string> AllTemplateIds { get; }
UniTask<Sprite> GetThumbnailAsync(string id); // cheap; for grid
UniTask<IDrawingTemplate> LoadAsync(string id); // expensive; full template
void Release(string id); // Addressables ref count down
string NextUnseen(string currentId); // progression hint
}
IColorPalette (Core/Contracts/Features/Coloring — planned)
Set of colors offered to the child. Authored as ColorPaletteSO.
public interface IColorPalette {
string Id { get; }
IReadOnlyList<Color> Colors { get; } // 6–10 entries typical
}
ICommand & IUndoStack (Core/Contracts/Features/History — planned)
Already shown in section 8. Each undoable user action is one ICommand; the stack is bounded.
IGalleryService (Core/Contracts/Services/Gallery — planned)
Thin shim over a native gallery plugin. Saves PNG bytes into the phone's Photos app. Does not track files, thumbnails, or sidecar metadata — the OS owns the file once it's saved.
public interface IGalleryService {
UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book");
}
- No list / load / delete operations. The user uses the phone's Photos app for those.
- Implementation (
NativeGallerySaveService) wraps a third-party native plugin and handles platform permission prompts.
ICaptureService (Core/Contracts/Services/Capture — planned)
Snapshots the paper area to a PNG blob. No arguments — implementation owns the disabled CaptureCamera reference.
public interface ICaptureService {
UniTask<byte[]> CaptureAsync();
}
- Independent of
IGalleryService. Returns raw PNG bytes; what happens next is the caller's call (save, share, discard).
Removed contracts
IPaperRig,IArtInputBridge,IPaperSurface— paper is just RectTransforms in the scene now, exposed viaGameplaySceneRefs.PaperRoot. No contract.SavedArtworkDTO,IGalleryService.ListAsync/LoadFullAsync/LoadThumbnailAsync/DeleteAsync/GetLatestThumbnailAsync— no app-side gallery store.ArtworkCapturedSignal,ArtworkSavedSignal— replaced byPaperCapturedSignal/PaperSavedSignal(templateId only, no DTO).
IProgressionService (Core/Contracts/Features/Progression — planned)
Tracks which templates the child has completed and what they last opened.
public interface IProgressionService {
UniTask LoadAsync();
UniTask SaveAsync();
IReadOnlyCollection<string> CompletedTemplateIds { get; }
string LastOpenedTemplateId { get; }
void MarkCompleted(string templateId);
void SetLastOpened(string templateId);
}
IAssetProviderService (Core/Contracts/Services/Assets — ✅ exists)
Addressables wrapper. Hides handle bookkeeping from features.
public interface IAssetProviderService {
UniTask InitializeAsync();
UniTask<T> LoadAsync<T>(string address) where T : UnityEngine.Object;
UniTask<IReadOnlyList<T>> LoadByLabelAsync<T>(string label) where T : UnityEngine.Object;
void Release(string address);
void ReleaseAll();
}
IEventBus (Libs/Observer — ✅ exists; note the folder is Observer, not EventBus)
public interface IEventBus {
void Publish<T>(T signal) where T : struct;
IDisposable Subscribe<T>(Action<T> handler) where T : struct;
}
Signals are structs to avoid GC. Disposable subscription so presenters can unsubscribe in Dispose().
32.2 Services Layer
Concrete infrastructure. One implementation each. All singletons in RootLifetimeScope, registered via per-service MonoBehaviour, IServiceModule installers.
AddressableAssetProviderService (Services/Assets — ✅ exists)
Implements IAssetProviderService.
- Responsibility: Wrap
Addressables.LoadAssetAsync<T>and ref-count handles by address. - State:
Dictionary<string, AsyncOperationHandle>keyed by address. - Notes:
Release(address)decrements;ReleaseAll()for scene teardown. Initialization must complete before any other service may load.
NativeGallerySaveService (Services/Gallery — planned)
Implements IGalleryService as a thin wrapper around a native gallery plugin.
// fields:
// INativeGalleryPlugin _plugin (third-party native bridge)
// IEventBus _bus (optional — for failure surfacing)
public sealed class NativeGallerySaveService : IGalleryService {
public async UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book") {
var permission = await _plugin.RequestWritePermissionAsync();
if (permission != Permission.Granted) {
// toddler app — silently skip; or publish a failure signal for HUD retry prompt
return;
}
await _plugin.SaveImageToAlbumAsync(png, albumName, fileName: $"colorbook_{DateTime.UtcNow:yyyyMMdd_HHmmss}.png");
}
}
- No file IO, no thumbnails, no sidecars. The native plugin owns everything past the call.
- Permission flow runs once per session; the plugin caches the grant.
- Failure handling: a toddler app shouldn't crash or block on denial — silently skip and let the user keep playing.
RenderTextureCaptureService (Services/Capture — planned)
Implements ICaptureService. Drives the scene's disabled CaptureCamera once per capture.
// fields:
// Camera _captureCam (scene-bound, registered via CaptureServiceModule)
// int _surfaceSize = 2048
// IPathProvider _paths (only if you want to expose paths — usually not needed here)
- Steps:
RenderTexture.GetTemporary(size, size, 0, ARGB32)→ set_captureCam.targetTexture = rt→_captureCam.Render()→ReadPixelsinto aTexture2D→ null out the target texture →RenderTexture.ReleaseTemporary(rt)→EncodeToPNG→ return bytes. - Threading: PNG encode happens on
UniTask.RunOnThreadPoolto avoid hitching the main thread on tablets. - Camera setup:
_captureCamhascullingMask = PaperUI,clearFlags = SolidColor(white or paper color),orthographicSizeandaspectcloned fromUICameraonce 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:
PlayerPrefsvia the encrypted wrapper. - Keys: namespaced strings registered in
PlayerPrefsKeyRegistry. - Format per value: JSON-encoded primitives (
ScriptableObject.CreateInstancenot needed at this layer). - Atomicity:
PlayerPrefsis already atomic per key on iOS/Android.
SceneService (Services/Scenes — ✅ exists)
Implements ISceneService. Wraps SceneManager.LoadSceneAsync with UniTask plus a fade-curtain.
public interface ISceneService {
UniTask LoadAsync(string sceneName, LoadSceneMode mode = LoadSceneMode.Single);
UniTask UnloadAsync(string sceneName);
}
AudioService (Services/Audio — ✅ exists; see also SfxPlayer)
Implements IAudioService. Plays SFX clips loaded by address, mixes via Unity AudioMixer groups.
public interface IAudioService {
UniTask PreloadAsync(string label); // e.g. "sfx,ui"
void PlayOneShot(string clipId, float volume = 1f);
void SetCategoryVolume(AudioCategory cat, float v01);
}
Holds an internal Dictionary<string, AudioClip> populated at preload.
InputReaderSO (Services/Inputs/Readers — ✅ exists)
ScriptableObject wrapping the new Input System; exposes events.
public interface IInputReader {
event Action<Vector2> PointerDown; // world pos
event Action<Vector2> PointerDrag;
event Action<Vector2> PointerUp;
}
- Why an SO: assignable in inspector and survives scene loads, but still resolvable via DI (
builder.RegisterInstance(_inputReader).As<IInputReader>()).
32.3 Libs
Generic, project-agnostic utilities.
BoundedUndoStack (Libs/CommandStack — planned)
Implements IUndoStack. Source already in section 24.
- Capacity: default 20.
- Invariant:
_redocleared on any newPush. - Edge cases:
Undo/Redoon 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
IDisposablethat removes the handler onDispose. - Publish snapshots the invocation list before iterating (so handlers may safely unsubscribe during dispatch).
StateMachine / IState / State (Libs/FSM — ✅ exists)
Generic state machine. Current shape on disk uses IState / State / StateMachine (see Libs/FSM/). ColorBookFlowController (planned) will use this. The generic sketch below is the target shape if you decide to make it strongly-typed via an enum — verify against actual API before consuming.
public sealed class Fsm<TState> where TState : struct, Enum {
public TState Current { get; }
public event Action<TState, TState> Transitioned;
public void Bind(TState state, IFsmState handler);
public void Go(TState next); // calls Exit on old, Enter on new
}
public interface IFsmState { void Enter(); void Exit(); }
32.3b Feature — MainMenu (planned)
Lives in MainMenu.unity. Hosts the Play / Art Book entry buttons plus the Spine character mascot.
IMenuMascotView (UI contract)
Setter-only view interface. Hides Spine-Unity's API behind a tiny surface.
public interface IMenuMascotView {
event Action<string> AnimationComplete; // fires when a non-looping anim ends
void Play(string animName, bool loop);
void SetSkin(string skinName); // optional — character variants
}
MenuMascotView : MonoBehaviour, IMenuMascotView (UI)
Concrete view. Wraps a SkeletonGraphic component (Spine-Unity's Canvas-compatible renderer).
public sealed class MenuMascotView : MonoBehaviour, IMenuMascotView {
[SerializeField] private SkeletonGraphic _skeleton; // Spine UI component
public event Action<string> AnimationComplete;
public void Play(string animName, bool loop) {
var track = _skeleton.AnimationState.SetAnimation(0, animName, loop);
track.Complete += _ => AnimationComplete?.Invoke(animName);
}
public void SetSkin(string skinName) {
_skeleton.Skeleton.SetSkin(skinName);
_skeleton.Skeleton.SetSlotsToSetupPose();
_skeleton.AnimationState.Apply(_skeleton.Skeleton);
}
}
SkeletonGraphiclives on a child ofMainMenuCanvas. It's aGraphic, so it interacts withCanvasRendererjust like anImage.- The Spine asset (
SkeletonDataAsset) is loaded via Addressables, assigned at scene setup, and released on scene exit.
MenuMascotPresenter (UI) — IStartable, IDisposable
Pure C#. Subscribes to button events + idle timer, drives the view.
// fields: IMenuMascotView _view, MainMenuModel _model, IInputReader _input
public sealed class MenuMascotPresenter : IStartable, IDisposable {
public void Start() {
_view.Play("idle", loop: true);
_model.PlayButtonHovered += () => _view.Play("hover_play", loop: false);
_model.PlayButtonPressed += () => _view.Play("wave", loop: false);
_view.AnimationComplete += OnAnimationComplete;
}
private void OnAnimationComplete(string anim) {
if (anim != "idle") _view.Play("idle", loop: true); // always return to idle
}
}
- Mascot reactions are pure presenter logic — the view never decides what to play.
- If you want randomized idle variants, add an idle timer in the model + a list of clip names.
MainMenuModel (Repository)
Holds menu state — current selected skin, fires hover/click events from button presenters.
MainMenuModule : MonoBehaviour, IServiceModule (Installers)
Registers the view (RegisterInstance<IMenuMascotView>(_view)), the presenter as a startable entry point, and the model.
Package dependency: Spine-Unity runtime (
com.esotericsoftware.spine.spine-unity). Add toPackages/manifest.json. TheSkeletonGraphiccomponent lives inSpine.Unitynamespace.
32.4 Feature — DrawingCatalog (planned)
DrawingCatalogController (Systems)
Headless logic. Owns the list of template IDs visible in the grid.
// fields: IDrawingTemplateCatalog _catalog, IEventBus _bus
public sealed class DrawingCatalogController : IAsyncStartable {
public IReadOnlyList<string> VisibleIds { get; }
public event Action ListChanged;
public UniTask StartAsync(CancellationToken ct); // pulls catalog, refreshes list
public void OnTemplateSelected(string id); // bus.Publish(new DrawingSelectedSignal(id))
}
// pub: DrawingSelectedSignal
DrawingCatalogPresenter (UI)
Bridges controller ↔ view.
// fields: IDrawingCatalogView _view, DrawingCatalogController _ctrl, IDrawingTemplateCatalog _catalog
public sealed class DrawingCatalogPresenter : IStartable, IDisposable {
public void Start(); // wires view.OnItemClicked → _ctrl.OnTemplateSelected
public void Dispose();
}
- Thumbnail load:
_catalog.GetThumbnailAsync(id)per visible cell, with placeholder while loading.
DrawingCatalogView : MonoBehaviour (UI)
Implements IDrawingCatalogView. Pure setters + click event.
public interface IDrawingCatalogView {
event Action<string> OnItemClicked;
void SetItems(IReadOnlyList<CatalogItemVM> items); // vm = id + Sprite thumbnail
}
32.5 Feature — ShapeBuilder (planned 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.
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 PrimeTweenSequenceof 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.
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)
[CreateAssetMenu(menuName = "Darkmatter/ShapeBuilder/Config")]
public sealed class ShapeBuilderConfig : ScriptableObject
{
public float SnapRadius; // 80–120 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.
// 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_shapetells whichShapeSOit expects. - Pre-snap on resume: if
preSnappedIds.Contains(shape.Id), the factory is called withpreSnapped: true→ShapePiece.SnapInstantly()lands it in the slot at scope start. - Tray width source: read from
_holder.SpawnWidth— the controller never touches_refsfor 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.
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(), subscribeShapeBuilderStartedSignal→Show, subscribeShapeAssembledSignal→Hide. - On
Dispose: dispose subscriptions.
Removed / not needed
TrayLayout— was a stateless tray-position helper. Tray positions are now computed inline fromholder.SpawnWidth(~3 lines).ShapePieceFsm— was a per-piece state machine. Replaced by inline drag handlers + a single_lockedbool onShapePiece.- Five state classes (
InTray,Dragging,Preview,Snapped,Returning) — gone. Their behavior maps to:_locked = false(idle/dragging/preview all share the same handlers),_inPreviewflag (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.
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) andColoringController(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 itsColordirectly. Index-based addressing is unnecessary — the button passes its color straight toSelectColor(color).
ColoringController (Systems — planned) — implements IColoringController
Wires pre-authored ColorRegionView children, applies saved colors on resume, builds and pushes PaintRegionCommand instances on click.
// 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.
[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 readableon first hover/click. - Threshold tuning: default
0.01fis very permissive (any non-fully-transparent pixel counts). Raise toward0.5ffor 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-containedColorButton+ColorButtonFactory. The stub files atFeatures/Coloring/UI/ColorPaletteView.csandColorPalettePresenter.csshould 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).
// fields: IUndoStack _stack, IEventBus _bus
public sealed class HistoryController : IStartable, IDisposable {
public bool CanUndo => _stack.CanUndo;
public bool CanRedo => _stack.CanRedo;
public event Action StateChanged;
public void Undo(); // _stack.Undo() + StateChanged
public void Redo();
// sub: DrawingSelectedSignal → _stack.Clear()
}
HistoryButtonsView : MonoBehaviour (UI)
Two big arrow buttons. Setters only.
public interface IHistoryButtonsView {
event Action UndoClicked;
event Action RedoClicked;
void SetUndoEnabled(bool enabled);
void SetRedoEnabled(bool enabled);
}
HistoryPresenter (UI)
Wires controller StateChanged ↔ view enable/disable; view click events → controller.
32.8 Feature — Capture (planned)
CaptureController (Systems)
The orchestrator behind the "Save" button. Owns the capture-then-save chain. Stateless other than guarding against concurrent captures.
// fields: ICaptureService _capture, IGalleryService _gallery, IEventBus _bus
public sealed class CaptureController {
public bool IsBusy { get; }
public UniTask SaveAsync(string templateId);
}
// pub: PaperCapturedSignal (mid-flow), PaperSavedSignal (after native save)
- Flow:
_capture.CaptureAsync()→ publishPaperCapturedSignal→_gallery.SaveToDeviceAsync(bytes)→ publishPaperSavedSignal. - Concurrency: sets
IsBusy = trueon entry; UI binds button enabled to!IsBusyto prevent double-tap. - No camera args —
ICaptureServiceowns theCaptureCamerareference. - No file-IO awareness —
IGalleryServicehandles 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:
AppBootcallsLoadAsync()early. - Save trigger: after
MarkCompleted, debounced 500 ms to coalesce a burst of "Next" presses.
ProgressionRepository (Repository)
Pure in-memory holder used by the service. Separated so tests can inspect state without going through file IO.
32.10 Feature — ColorBookFlow (planned)
ColorBookFlowController (Systems) — IStartable, IDisposable
The only orchestrator inside the ColorBook scene. Drives the panel FSM: Catalog → Building → Coloring → Done.
// fields:
// IEventBus _bus
// IDrawingTemplateCatalog _catalog
// ShapeBuilderController _builder
// IColoringController _coloring
// CaptureController _capture
// IProgressionService _progression
// IGameplaySceneRefs _refs (PaperRoot only — panel show/hide owned by holder views)
// Fsm<ColorBookState> _fsm
-
State table:
State On enter Triggers exit CatalogShow catalog panel DrawingSelectedSignalBuilding_builder.BuildAsync(id)ShapeAssembledSignalColoring_coloring.SpawnRegionsAsync(template)"Next" or "Capture" pressed DoneRun autosave capture, mark completed, Go(Catalog)for nextalways advances -
"Next" sequence:
_capture.CaptureCurrentAsync→_progression.MarkCompleted→_catalog.Release(current)→_catalog.LoadAsync(_catalog.NextUnseen(current))→ re-enterBuilding.
32.12 App Layer
AppBoot (App/Boot — planned; folder doesn't exist yet) — IAsyncStartable
Single entry point. Steps in section 29.
// fields: IAssetProviderService _assets, IProgressionService _progress,
// IAudioService _audio, ISceneService _scenes, BootConfig _cfg
public sealed class AppBoot : IAsyncStartable {
public UniTask StartAsync(CancellationToken ct);
}
LifetimeScopes
RootLifetimeScope— ✅ exists (source). Iterates a serializedMonoBehaviour[] serviceModulesand callsRegisteron eachIServiceModule. 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 viabuilder.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).
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.
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)
public interface IServiceModule {
void Register(IContainerBuilder builder);
}
Implemented as MonoBehaviour per feature/service so scopes can drag them in the inspector (CameraServiceModule.cs shows the pattern). The method is Register, not Install — there is no IInstaller in this project.
32.14 Class summary table
| Class | Layer | Role | Key dependencies |
|---|---|---|---|
AppBoot |
App | Startup sequencer | assets, progression, audio, scenes |
RootLifetimeScope |
App | Root DI | service modules |
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 DrawingTemplateSOs, 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, stubCaptureService, stubGalleryService), all Lib classes,ShapePiece+SlotMarker+ShapeBuilderConfig,UndoStack+HistoryServiceModule,ProgressionSystem+ProgressionRepository(stubs),AddressableDrawingTemplateCatalog+ module,DrawingCatalogController+ presenter + view,ColorbookFlowController(partial — needs constructor injection wired). Empty / planned:MainMenufeature,GameplayFlowfeature,Coloringfeature,MainMenu/Colorbook/Gameplayscene scopes, all scenes exceptBoot.unity.