- New §9 MainMenu responsibility: Spine character with idle + reaction animations, driven by MenuMascotPresenter - New §32.3b MainMenu feature reference: IMenuMascotView (Play/SetSkin/ AnimationComplete), MenuMascotView wrapping SkeletonGraphic for Canvas, MenuMascotPresenter listening to model events - §10 Addressables: add Spine asset group + ShapeSO library + Shapes line - §30 Setup checklist: list required Unity packages including Spine-Unity - §31, §32.14: add MenuMascotView / MenuMascotPresenter rows Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
99 KiB
Color Book — Architecture Guide
A toddler-targeted (ages 2–6) coloring book game built on the same Strict Modular Monolith pattern as the Bus Game. Powered by VContainer for DI, UniTask for async, Addressables for shipped content, and a hybrid Sprites + Canvas render strategy.
This document is the canonical reference for the Color Book game's structure. The Bus Game's Darkmatter Architecture Guide is the parent contract; this doc only adds game-specific structure.
1. Game Flow
App launch
└─ Boot scene (RootLifetimeScope)
└─ MainMenu scene
├─ Press "Play" → ColorBook scene
│ ├─ Drawing catalog (grid of templates)
│ ├─ Select drawing
│ ├─ Shape Builder panel (drag pieces → snap to slots)
│ ├─ ↓ on assembly complete
│ ├─ Color panel (tap color → tap region)
│ ├─ Undo / Redo any time
│ ├─ "Capture" → save to Gallery with paper background
│ └─ "Next" → auto-save + load next drawing
└─ Press "Art Book" → ArtBook scene (gallery viewer)
├─ Grid of saved artworks
├─ View / share / delete
└─ Save to device camera roll
2. Philosophy
Identical to Bus Game:
- Vertical slices — code grouped by Feature, not by type.
- Strict layering — dependencies flow downward only.
- Composition over inheritance — wired by DI.
- Code vs Content —
Code/for logic,Contents/for assets.
Game-specific additions:
- Toddler-first UX — large hitboxes, forgiving snap radii, no fail states, no timers.
- Stateless replay — every action (paint, snap) is an
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/ ← (planned — paper rig prefabs)
│
├── Data/
│ ├── Inputs/ (Input System .inputactions)
│ └── Settings/
│ ├── Persistance/Resources/ (ProtectedPlayerPrefs settings)
│ ├── Rendering/ (URP renderer + asset)
│ └── Scenes/URP2DSceneTemplate.unity
│
└── Code/
├── App/
│ └── LifetimeScopes/
│ └── RootLifetimeScope.cs ← scope loads serialized IServiceModule list
│ Darkmatter.App.asmdef
│
├── Core/ (asmdef name: `Core`, namespace root `Darkmatter.Core.*`)
│ ├── Compatibility/
│ │ └── IsExternalInit.cs (C#9 init shim for older runtimes)
│ ├── Contracts/
│ │ ├── Paper/ ← misplaced empty folder — move to Contracts/Features/Paper/ when IPaperSurface lands
│ │ └── Services/
│ │ ├── Assets/IAssetProviderService.cs
│ │ ├── Audio/IAudioService.cs, ISfxPlayer.cs
│ │ ├── Camera/ICameraService.cs
│ │ ├── Capture/ ← (planned — ICaptureService)
│ │ ├── Inputs/IInputReader.cs
│ │ └── Scenes/ISceneService.cs
│ ├── Data/
│ │ ├── Dynamic/Services/Audio/ (AudioHandle, AudioRequest)
│ │ └── Static/Services/Audio/ (SfxCatalogSO)
│ └── Enums/
│ └── Services/
│ ├── Audio/ (AudioChannel, AudioPlayMode, SfxId)
│ ├── Camera/CameraType.cs (MainCamera, UICamera — ArtCamera not added yet)
│ └── Scenes/GameScene.cs
│
├── Features/ ← (planned — empty folder today)
│
├── Libs/
│ ├── FSM/ (IState, State, StateMachine + Docs)
│ │ Libs.FSM.asmdef
│ ├── Installers/ (IServiceModule — Register(IContainerBuilder))
│ │ Libs.Installers.asmdef
│ ├── Observer/ (IEventBus, EventBus — note: not named "EventBus")
│ │ Libs.Observer.asmdef
│ ├── PlayerPrefs/ (ProtectedPlayerPrefs — used in place of a Persistence service)
│ │ ├── Editor/ Libs.PlayerPrefs.Editor.asmdef
│ │ └── Runtime/ Libs.PlayerPrefs.asmdef
│ └── UI/ (ToggleButton, ToggleButtonGroup)
│ Libs.UI.asmdef
│
└── Services/
├── Analytics/
│ ├── Installers/AnalyticsServiceModule.cs
│ └── Systems/FirebaseAnalyticsSystem.cs
│ Services.Analytics.asmdef
├── Assets/
│ ├── AddressableAssetProviderService.cs
│ └── AddressableLoadHandleTracker.cs
│ Services.Assets.asmdef
├── Audio/
│ ├── AudioService.cs
│ └── SfxPlayer.cs
│ Services.Audio.asmdef
├── Camera/
│ ├── Installers/CameraServiceModule.cs
│ └── Service/CameraService.cs
│ Services.Camera.asmdef
├── Inputs/
│ ├── Generated/GameInputs.cs (Input System codegen)
│ ├── Installers/InputServiceModule.cs
│ └── Readers/InputReaderSO.cs
│ Services.Inputs.asmdef
└── Scenes/
└── SceneService.cs
Services.Scenes.asmdef
4b. Conventions visible in current code
- Asmdef per Service / Lib / App / Core. No Feature asmdefs yet (folder is empty).
- Core sub-tree shape:
Core/{Compatibility, Contracts, Data, Enums}— deeply nested by Service rather than by topic. Game-specific Core types (Drawing, Coloring, Paper, Gallery, History, Progression, Signals) will be added under 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/Features/Paper/IPaperSurface.cs |
Paper surface contract (canvas roots) |
Core/Contracts/Services/Capture/ICaptureService.cs |
Capture service contract |
Core/Contracts/Services/Gallery/IGalleryService.cs |
Gallery service contract |
Core/Contracts/Features/Drawing/IDrawingTemplate.cs, IDrawingTemplateCatalog.cs |
Drawing template contracts |
Core/Contracts/Features/Coloring/IColorPalette.cs |
Palette contract |
Core/Contracts/Features/History/ICommand.cs, IUndoStack.cs |
Undo/redo contracts |
Core/Contracts/Features/Progression/IProgressionService.cs |
Progression contract (despite the name, it's a feature contract since it's game-specific) |
Core/Data/Static/Features/Drawing/ (DrawingTemplateSO) |
Authored drawing data |
Core/Data/Static/Features/Coloring/ (ColorPaletteSO) |
Authored palette data |
Core/Data/Dynamic/Features/Drawing/ColorRegionDTO.cs |
Runtime drawing struct (regions only — pieces use ShapeSO) |
Core/Data/Static/Features/Drawing/ShapeSO.cs |
Authored shape ScriptableObject (sprite + snap params) |
Core/Data/Dynamic/Features/Coloring/PaintCommandDTO.cs |
Runtime coloring struct |
Core/Data/Dynamic/Features/Gallery/SavedArtworkDTO.cs |
Runtime gallery struct |
Core/Data/Dynamic/Features/Signals/ (DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, ArtworkCapturedSignal, ArtworkSavedSignal) |
Cross-feature signal structs |
Core/Enums/Services/Camera/CameraType.cs |
Add ArtCamera enum value to existing file |
Libs/CommandStack/ (+ Libs.CommandStack.asmdef) |
Bounded undo/redo |
Services/Capture/ (+ Services.Capture.asmdef) |
RenderTextureCaptureService drives the disabled CaptureCamera |
Services/Gallery/ (+ Services.Gallery.asmdef) |
FileGalleryService — PNG + sidecar JSON IO |
Features/Paper/ (+ Features.Paper.asmdef) |
Scene-bound PaperSurface MB + module |
Features/{MainMenu,DrawingCatalog,ShapeBuilder,Coloring,History,Capture,Progression,ColorBookFlow,ArtBook}/ (+ asmdefs each) |
Game features |
App/LifetimeScopes/{MainMenu,ColorBook,ArtBook}LifetimeScope.cs |
Per-scene scopes |
App/Boot/AppBoot.cs |
Bootstrap entry point |
Assets/Darkmatter/Scenes/{MainMenu,ColorBook,ArtBook}.unity |
Scenes |
Content/Gameplay/Drawings/<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
| Scene | Scope | Status | Contents |
|---|---|---|---|
Boot.unity |
RootLifetimeScope |
✅ exists | All Services + Libs. Persists forever. |
MainMenu.unity |
MainMenuLifetimeScope |
⚠️ planned | Menu presenter, art book entry. |
ColorBook.unity |
ColorBookLifetimeScope |
⚠️ planned | PaperRig, DrawingCatalog, ShapeBuilder, Coloring, History, Capture, ColorBookFlow. |
ArtBook.unity |
ArtBookLifetimeScope |
⚠️ planned | Gallery presenter, viewer, share. |
Only Boot.unity exists today; the three scene scope classes haven't been written yet either (only RootLifetimeScope exists in App/LifetimeScopes/).
Scopes nest: Root → (MainMenu | ColorBook | ArtBook). Services resolved from the root parent. Scene scopes only register their own features.
Boot chain (planned)
No AppBoot class exists yet — today RootLifetimeScope only registers services and stops there. When AppBoot is added (as an IAsyncStartable registered via builder.RegisterEntryPoint<AppBoot>()), it should run once, in order:
- Initialize
IAssetProviderService(Addressables init). - Preload essential bundles (palettes, UI sounds).
- Load
IProgressionServicefrom disk. - Load
MainMenuscene.
Failures show a child-friendly retry screen; never crash.
7. Rendering Strategy
Full Canvas UI. No SpriteRenderer, no Physics2D, no offscreen RenderTexture for the live view. The paper, slots, pieces, and color regions are all Image components on a Screen-Space-Camera canvas. Standard Unity UI eventing (IPointerDownHandler, IDragHandler) handles all input.
┌──────────────────────────────────────────────────────────┐
│ PaperCanvas (Screen Space - Camera, UICamera) │
│ layer: PaperUI │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ PaperPanel (RectTransform, 2048×2048 ref units) │ │
│ │ ├─ BackgroundImage │ │
│ │ ├─ SlotsPanel (slot Image outlines) │ │
│ │ ├─ PiecesPanel (draggable piece Images) │ │
│ │ └─ RegionsPanel (colorable region Images) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ HUDCanvas (Screen Space - Overlay, OR separate camera) │
│ layer: HUDUI │
│ ├─ Palette panel │
│ ├─ Undo / Redo buttons │
│ ├─ Capture / Next buttons │
│ └─ Tray panel (during build phase) │
└──────────────────────────────────────────────────────────┘
CaptureCamera (disabled by default, one-shot Render() on capture)
orthographic, projection cloned from UICamera
cullingMask = PaperUI only
targetTexture = temp RT allocated per capture (2048×2048)
Cameras
| Camera | Render mode | Culling Mask | Render Target | Purpose |
|---|---|---|---|---|
UICamera |
Screen-Space - Camera (orthographic) | PaperUI, HUDUI |
Screen | Normal display each frame. |
CaptureCamera |
Orthographic, disabled | PaperUI only |
temp RenderTexture |
One-shot Render() invoked by ICaptureService.CaptureAsync(). |
CaptureCamera shares UICamera's position, orthographic size, and clip planes so the captured frame matches what the player sees — minus the HUD because of the culling mask.
Layers
| Layer | Used by |
|---|---|
PaperUI |
PaperCanvas and all of its children (background, slots, pieces, regions, completion FX). Visible in capture. |
HUDUI |
HUDCanvas and tray panel (palette, undo/redo, capture button, drawing catalog grid, etc.). Excluded from capture. |
EventSystem |
Unity's input layer — managed automatically. |
Why full UI
| Need | Choice | Why |
|---|---|---|
| Tap-to-paint region | Image + Image.alphaHitTestMinimumThreshold + IPointerClickHandler |
Tight alpha-based hit shape per region. No mesh / collider authoring. Tap events route through EventSystem natively. |
| Drag/drop shape pieces | Image + IBeginDragHandler / IDragHandler / IEndDragHandler |
Standard Unity UI drag. Pointer events come in canvas-local coords already. No screen→world math anywhere. |
| Visual transition during drag → snap | DOAnchorPos, DOSizeDelta, DOLocalRotate |
All pose is in RectTransform units. The "transition" is a tween over canvas-local values — no swap of render context. |
| Capture to PNG | Dedicated CaptureCamera with cullingMask = PaperUI |
One Render() call into a temp RT. HUD physically can't appear. |
| Multi-resolution support | CanvasScaler on PaperCanvas (Scale With Screen Size) |
Reference resolution 2048 × 2048, Match = 1 (height). All anchoredPosition units are constant across devices. |
| HUD layout independent of paper | HUDCanvas (separate Canvas) |
HUD scales/anchors per its own rules without affecting the paper layout. |
| Drawing catalog grid, palette, etc. | Standard UI (GridLayoutGroup, ScrollRect, Button) |
Anchors handle aspect ratios. Async thumbnail loader. |
Multi-resolution rule
The paper content is canvas-unit-stable. Author every drawing against a fixed 2048 × 2048 reference resolution. Slot positions, piece sizes, region rects, hit shapes — all expressed in anchoredPosition / sizeDelta units. CanvasScaler on PaperCanvas does the screen mapping.
PaperPanel is anchored center, fixed 2048×2048 (or whatever you pick for the reference). On a wider screen, CanvasScaler pillarboxes the panel; on a narrower screen, it letterboxes. The panel's contents never resize relative to each other.
If you want a backdrop (wood/cloth behind the paper area), it's a sibling Image of PaperPanel (still on PaperUI layer) sized to fill the canvas. The backdrop is captured into the PNG by default — set its layer to HUDUI if you want it excluded.
Tradeoff vs the old RT-paper-rig design
| Concern | RT-paper-rig (old) | Canvas-only (current) |
|---|---|---|
Files in IPaperRig / IArtInputBridge |
2 contracts + ~80 lines of math | gone |
| Input pipeline | IInputReader → bridge → Physics2D.OverlapPoint |
native EventSystem (IPointerDownHandler etc.) |
| Coloring hit shape | PolygonCollider2D from Sprite.Editor physics shape |
Image.alphaHitTestMinimumThreshold = 0.5f on the region sprite |
| Per-frame render passes | 2 (ArtCamera into RT + UICamera draws RawImage) | 1 (UICamera draws everything) |
| Capture | read persistent RT | one-shot CaptureCamera.Render() |
| Coordinate gotchas | mismatches between screen / RT / world | none — everything is canvas-local |
If you ever need world-space effects (particle sparkles that physically explode outside the paper, free-draw brush stroke, pinch zoom on the artwork), revisit the RT approach. For the v1 tap-to-fill + drag-to-snap design, Canvas-only is correct.
8. Core Contracts
All Core types are pure data or interfaces.
Drawing
Contracts live in
Darkmatter.Core.Contracts.Features.Drawing; DTOs 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 RegionsParent
public Vector2 SizeDelta { get; } // canvas units
public Color InitialColor { get; } // usually white
// Hit shape comes from the sprite alpha — set Image.alphaHitTestMinimumThreshold = 0.5.
// No polygon path needed; sprite import settings ("Read/Write Enabled") provide it.
}
Shape authoring (ShapeSO + one prefab)
Shapes are authored as ScriptableObject assets via the Project Create menu (Assets > Create > Darkmatter > Drawing > Shape). One asset per shape — reusable across many drawings.
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; }
}
Paper (canvas surface root)
Contracts live in
Darkmatter.Core.Contracts.Features.Paper. Files atCore/Contracts/Features/Paper/.
namespace Darkmatter.Core.Contracts.Features.Paper;
public interface IPaperSurface {
RectTransform Root { get; } // PaperPanel — parent of slots/pieces/regions
RectTransform SlotsParent { get; } // parent for slot Images
RectTransform PiecesParent { get; } // parent for piece Images
RectTransform RegionsParent { get; } // parent for region Images
float DesignHalfSize { get; } // half of the reference square (e.g. 1024)
}
- Implemented by
PaperSurface : MonoBehaviourin the ColorBook scene (sits on thePaperPanelGameObject). - All paper-side features (
Coloring,ShapeBuilder,Capture) parent their UI under one of theseRectTransformslots and use canvas-local coords throughout. - No
IPaperRig. NoIArtInputBridge. Input runs through Unity'sEventSystemdirectly on the UI children.
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();
}
Gallery & Capture
IGalleryServiceis a Service contract →Darkmatter.Core.Contracts.Services.Gallery.SavedArtworkDTOis a runtime data struct →Darkmatter.Core.Data.Dynamic.Features.Gallery.ICaptureService→Darkmatter.Core.Contracts.Services.Capture.CaptureAsynctakes no args — implementation owns theCaptureCamerareference and renders thePaperUIlayer to a one-shot RT.
namespace Darkmatter.Core.Data.Dynamic.Features.Gallery;
public readonly struct SavedArtworkDTO {
public string Id { get; }
public string TemplateId { get; }
public DateTime CreatedUtc { get; }
public string ImagePath { get; } // persistentDataPath PNG
public string ThumbnailPath { get; }
}
namespace Darkmatter.Core.Contracts.Services.Gallery;
public interface IGalleryService {
UniTask<SavedArtworkDTO> SaveAsync(byte[] png, string templateId);
UniTask<IReadOnlyList<SavedArtworkDTO>> ListAsync();
UniTask<Texture2D> LoadFullAsync(string artworkId);
UniTask<Texture2D> LoadThumbnailAsync(string artworkId);
UniTask DeleteAsync(string artworkId);
// Newest captured thumbnail for the given template, or null if none exist.
// Catalog cells fall back to IDrawingTemplate.DefaultThumbnail when this returns null.
UniTask<Texture2D> GetLatestThumbnailAsync(string templateId);
}
namespace Darkmatter.Core.Contracts.Services.Capture;
public interface ICaptureService {
// Allocates a temp RT, renders the CaptureCamera once (PaperUI layer only),
// ReadPixels into a Texture2D, encodes PNG, releases the RT.
UniTask<byte[]> CaptureAsync();
}
ICaptureService owns a reference to CaptureCamera (a disabled Camera in the ColorBook scene). The capture camera's cullingMask is set to PaperUI so the HUD physically cannot appear in the PNG. The paper background is part of PaperPanel, so it's already in the right layer — no compositing pass.
Signals
Signal structs live in
Darkmatter.Core.Data.Dynamic.Features.Signals(runtime data, cross-feature).
namespace Darkmatter.Core.Data.Dynamic.Features.Signals;
public readonly struct DrawingSelectedSignal {
public string TemplateId { get; }
}
public readonly struct ShapeAssembledSignal {
public string TemplateId { get; }
}
public readonly struct ColorAppliedSignal {
public string RegionId { get; }
public Color Color { get; }
}
public readonly struct ArtworkCapturedSignal {
public string ArtworkId { get; }
}
public readonly struct ArtworkSavedSignal {
public SavedArtworkDTO Artwork { get; }
}
9. Feature Responsibilities
MainMenu
- Lives in
MainMenu.unity. Two main entry buttons: Play (→ColorBookscene) and Art Book (→ArtBookscene). - Hosts a Spine character mascot (via
SkeletonGraphicfor Canvas). The mascot has multiple authored animations — idle loop, wave, react-to-button, victory dance. MenuMascotPresenter(pure C#) drives the mascot from code: subscribes to 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).
- Each cell shows the latest captured thumbnail for that template via
IGalleryService.GetLatestThumbnailAsync(templateId). If the user has no captures yet for that template, falls back toIDrawingTemplate.DefaultThumbnail(the authored sprite). - Subscribes to
ArtworkSavedSignal— re-fetches the cell's thumbnail for the affectedTemplateIdso the grid reflects user progress without a reopen. - On select → fires
DrawingSelectedSignal(templateId)and unloads the catalog UI.
Paper
- Scene-scoped infrastructure. Lives in
ColorBook.unityonly. - Owns
PaperSurface(MonoBehaviour) on thePaperPanelGameObject. ImplementsIPaperSurface, exposesRoot,SlotsParent,PiecesParent,RegionsParent,DesignHalfSize. - Registered in
ColorBookLifetimeScopeviaPaperSurfaceModule. Other features resolveIPaperSurfacefrom DI when they need to parent their UI under one of the role-specificRectTransforms. - No render-target ownership. No input bridge. No coordinate conversion. The paper is the canvas children — nothing more.
ShapeBuilder
- Listens to
DrawingSelectedSignal. - Loads template, spawns UI
Imageper piece under eitherIPaperSurface.PiecesParentor the HUD tray (depending on the FSM start state — usually tray). - Each piece has
IBeginDragHandler/IDragHandler/IEndDragHandlerplus a per-pieceShapePieceFsm. Drag updatesRectTransform.anchoredPositiondirectly fromPointerEventData(converted to canvas-local viaRectTransformUtility.ScreenPointToLocalPointInRectangle). - On entering preview radius of the matching slot: reactive
LerpofanchoredPosition/sizeDelta/localRotationtowardSlotMarker'sRectTransform. Drives off pointer distance, not time. - On
OnEndDraginside snap radius: DOTween ease-out to exact slot pose, disable input. Otherwise DOTween back to tray slot. - Fires
ShapeAssembledSignalwhen all pieces locked.
Coloring
- Listens to
ShapeAssembledSignal. - Spawns one UI
ImageperColorRegionDTOunderIPaperSurface.RegionsParent. Each region'sImage.alphaHitTestMinimumThreshold = 0.5fso taps on transparent pixels pass through to the next region. - Each region has
IPointerClickHandler. On click →ColoringController.PaintRegion(view). - Listens to palette selection (current color held in
ColoringStateRepository). - Controller builds
PaintRegionCommand(regionId, oldColor, newColor)and pushes toIUndoStack. - Command sets
Image.coloron undo/redo. - Fires
ColorAppliedSignalfor SFX / sparkle effects.
History
- Owns the singleton
IUndoStackfor the current ColorBook session. - Cleared on
DrawingSelectedSignal(new drawing = fresh history). - Capped at ~20 entries (memory + cognitive simplicity).
- UI: two big arrow buttons; disabled state when
CanUndo / CanRedois false.
Capture
- Bound to the "Capture" button.
- Calls
ICaptureService.CaptureAsync()→ PNG bytes. Implementation owns the disabledCaptureCamera, sets itstargetTextureto a temp RT, callsRender()once, reads pixels, releases. - Hands bytes to
IGalleryService.SaveAsync(...). - Fires
ArtworkCapturedSignalthenArtworkSavedSignal. - Shows a quick "saved!" toast with a thumbnail of the new entry.
Progression
- Tracks completed template IDs and the in-progress draft.
- On "Next" button: silently runs Capture pipeline (auto-save), marks current as completed, calls
IDrawingTemplateCatalog.NextUnseen(). - Persists JSON via
IPersistenceService.
ColorBookFlow
- The only orchestrator inside ColorBook scope.
- Subscribes to flow-relevant signals and toggles UI panels (catalog → builder → coloring).
- Coordinates "Next" sequence:
IProgressionService.MarkCompleted→ICaptureServiceautosave →IDrawingTemplateLoader.Release(currentId)→ load next. - Built as a small FSM (
Catalog → Building → Coloring → Done).
ArtBook
- Separate scene.
GalleryPresentercallsIGalleryService.ListAsync()→ grid of thumbnails.- Tap → fullscreen view, share-sheet button, delete.
- Saved-to-device-camera-roll uses an optional platform plugin behind
IExternalShareService(Core contract).
10. Addressables Strategy
Mirror the Bus Game pattern via IAssetProviderService.
What ships through Addressables
| Asset | Why |
|---|---|
DrawingTemplate ScriptableObject (per drawing) |
Many; load on demand. |
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
Two distinct stores, each behind its own Core contract.
IPersistenceService (JSON / PlayerPrefs)
Holds:
- Completed template IDs.
- Last opened drawing.
- Audio volume, simple settings.
Path: Application.persistentDataPath/save.json.
IGalleryService (file IO)
Holds user artworks:
persistentDataPath/Gallery/
├── {guid}.png full-res render (~2048×2048)
├── {guid}.thumb.png 256×256 for grid
└── {guid}.json SavedArtworkDTO sidecar
- Writes are atomic (
.tmp→ rename). ListAsyncenumerates sidecar JSONs sorted byCreatedUtc desc.- Thumbnail generation happens once at save time on a worker thread.
12. Capture Pipeline
A dedicated CaptureCamera lives in the scene, disabled by default. It renders only the PaperUI layer into a temp RenderTexture when capture fires.
[Capture button or Next button]
│
▼
ICaptureService.CaptureAsync()
│
├─ rt = RenderTexture.GetTemporary(2048, 2048, 0, ARGB32)
├─ _captureCam.targetTexture = rt
├─ _captureCam.Render() (one-shot; cullingMask = PaperUI only)
├─ _captureCam.targetTexture = null
├─ prev = RenderTexture.active
├─ RenderTexture.active = rt
├─ tex = new Texture2D(2048, 2048, RGBA32, false)
├─ tex.ReadPixels(full rect, 0, 0); tex.Apply()
├─ RenderTexture.active = prev
├─ RenderTexture.ReleaseTemporary(rt)
├─ bytes = tex.EncodeToPNG() (on worker via UniTask.RunOnThreadPool)
├─ Object.Destroy(tex)
└─ return bytes
▼
IGalleryService.SaveAsync(bytes, templateId)
│
├─ Write .png atomically
├─ Generate + write thumbnail
├─ Write sidecar JSON
└─ return SavedArtworkDTO
▼
EventBus.Publish(new ArtworkSavedSignal(dto))
Notes:
- HUD never appears in capture because
CaptureCamera.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.
13. Communication Rules
| Use case | Mechanism |
|---|---|
| Load template, return result | Direct DI call (IDrawingTemplateLoader.LoadAsync). |
| Capture → save chain | Direct DI calls, sequenced. |
| Notify HUD that a region was painted | IEventBus signal. |
| Notify Progression that a drawing was completed | IEventBus signal. |
| Tell ColorBookFlow that pieces are assembled | IEventBus signal. |
| Tell Coloring which color is currently selected | Direct DI on ColoringStateRepository. |
Never use signals for request/response. If you need a return value or guaranteed single handler, define a Core interface.
14. UI (MVP — Passive View)
Identical to Bus Game.
- Model — controller / repository, fires C# events.
- View —
MonoBehaviour, only setters (SetColors(IReadOnlyList<Color>)). - Presenter — pure C#, subscribes to model events, calls view setters.
Inspector bridge
For palette icons, undo buttons, region prefabs:
[SerializeField, RequireInterface(typeof(IColorButtonView))]
private MonoBehaviour[] _colorButtons;
15. Toddler UX Constraints
These shape several design decisions and are non-negotiable:
- No fail states. Drawings cannot be "wrong".
- No timers. Nothing decays or runs out.
- No tiny hitboxes. Drag tolerance ≥ 40 canvas units; snap radius ≥ 80 canvas units for shape pieces. (Canvas reference resolution is 2048×2048 — these are anchored-position deltas, not screen px.)
- Auto-snap on near-miss. If a piece is dropped within
1.5 × SnapRadius, snap anyway and play a happy sound. - No text-heavy UI. Icons everywhere. Single-word labels max.
- Loud, immediate feedback. Every tap plays a sound; every fill bursts a small particle effect.
- Undo cap = 20. Toddlers will mash undo. Bound the memory.
- Long-press = quick menu off. Avoid surprise modals.
16. Testing
| Layer | Test type | Location |
|---|---|---|
Libs/CommandStack |
EditMode unit tests | Libs/CommandStack/Tests/ |
Core DTOs |
EditMode | rarely needed, but for SavedArtworkDTO serialization, yes. |
Services/Gallery |
EditMode w/ temp directory | mocks Application.persistentDataPath. |
Services/Capture |
PlayMode | requires a Camera in the test scene. |
Features/*/Systems |
EditMode w/ DI test container | inject fakes for IUndoStack, signals captured by a fake IEventBus. |
| Full flow | PlayMode smoke test | one drawing → assemble → color → capture → assert gallery has 1 file. |
17. "Where do I put this?" Checklist
- 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 |
|---|---|---|
DrawingCatalog |
— | DrawingSelectedSignal |
ShapeBuilder |
DrawingSelectedSignal |
ShapeAssembledSignal |
Coloring |
ShapeAssembledSignal |
ColorAppliedSignal |
History |
DrawingSelectedSignal (to clear) |
— |
Capture |
— (button-driven) | ArtworkCapturedSignal, ArtworkSavedSignal |
Progression |
ArtworkSavedSignal |
— |
ColorBookFlow |
ShapeAssembledSignal, ArtworkSavedSignal |
— |
ArtBook (Gallery) |
ArtworkSavedSignal (if open) |
— |
Maintained alongside the Darkmatter Architecture Guide. Do not break the dependency arrows.
20. Assembly Definition Map
Every Lib / Service / Feature is its own .asmdef. The Darkmatter. prefix is only on the App asmdef; everything else uses bare <Layer>.<Module> names. References follow the layer rules.
On disk today
| Asmdef | Path | References |
|---|---|---|
Darkmatter.App |
App/ |
All Services, Libs, Core (Features when they land) |
Core |
Core/ |
(none — UniTask allowed in async signatures) |
Libs.FSM |
Libs/FSM/ |
Core |
Libs.Installers |
Libs/Installers/ |
(VContainer only) |
Libs.Observer |
Libs/Observer/ |
Core |
Libs.PlayerPrefs |
Libs/PlayerPrefs/Runtime/ |
(standalone) |
Libs.PlayerPrefs.Editor |
Libs/PlayerPrefs/Editor/ |
Libs.PlayerPrefs |
Libs.UI |
Libs/UI/ |
Core |
Services.Analytics |
Services/Analytics/ |
Core, Libs.Installers |
Services.Assets |
Services/Assets/ |
Core, Libs.Installers |
Services.Audio |
Services/Audio/ |
Core, Libs.Installers |
Services.Camera |
Services/Camera/ |
Core, Libs.Installers |
Services.Inputs |
Services/Inputs/ |
Core, Libs.Installers |
Services.Scenes |
Services/Scenes/ |
Core, Libs.Installers |
Planned (not on disk yet)
| Asmdef | Path | References |
|---|---|---|
Libs.CommandStack |
Libs/CommandStack/ |
Core |
Services.Capture |
Services/Capture/ |
Core, Libs.Installers |
Services.Gallery |
Services/Gallery/ |
Core, Libs.Installers |
Features.Paper |
Features/Paper/ |
Core, Libs.Installers |
Features.MainMenu |
Features/MainMenu/ |
Core, Libs.Installers |
Features.DrawingCatalog |
Features/DrawingCatalog/ |
Core, Libs.Installers |
Features.ShapeBuilder |
Features/ShapeBuilder/ |
Core, Libs.Installers |
Features.Coloring |
Features/Coloring/ |
Core, Libs.Installers, Libs.CommandStack |
Features.History |
Features/History/ |
Core, Libs.Installers, Libs.CommandStack |
Features.Capture |
Features/Capture/ |
Core, Libs.Installers |
Features.Progression |
Features/Progression/ |
Core, Libs.Installers, Libs.PlayerPrefs |
Features.ColorBookFlow |
Features/ColorBookFlow/ |
Core, Libs.Installers, Libs.FSM |
Features.ArtBook |
Features/ArtBook/ |
Core, Libs.Installers |
Hard rules:
- No Service asmdef references any Feature asmdef.
- No Feature asmdef references another Feature asmdef.
- All Services and Features depend on
Libs.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[]:
PaperRigModuleDrawingCatalogModuleShapeBuilderModuleColoringModuleHistoryModuleCaptureModuleProgressionModuleColorBookFlowModule
Each registers its own classes via IServiceModule.Register(IContainerBuilder).
If a scope needs a non-installer reference (e.g. a
ColorBookSceneRefsMB holding camera + roots), expose it as a separate[SerializeField]andbuilder.RegisterInstance(...)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<ColorPalettePresenter>(Lifetime.Scoped).AsSelf();
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.RegisterInstance<IFoo>(_foo)them. See the plannedPaperRigModulein §32.5b for an example.
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. View / Presenter Pair — Color Palette
View (MonoBehaviour, setters only)
namespace Darkmatter.Features.Coloring.UI;
public sealed class ColorPaletteView : MonoBehaviour, IColorPaletteView {
[SerializeField, RequireInterface(typeof(IColorButtonView))]
private MonoBehaviour[] _buttonsRaw;
private IColorButtonView[] _buttons;
public event Action<int> OnColorButtonClicked;
private void Awake() {
_buttons = _buttonsRaw.Cast<IColorButtonView>().ToArray();
for (var i = 0; i < _buttons.Length; i++) {
var idx = i;
_buttons[i].OnClicked += () => OnColorButtonClicked?.Invoke(idx);
}
}
public void SetColors(IReadOnlyList<Color> colors) {
for (var i = 0; i < _buttons.Length; i++)
_buttons[i].SetVisible(i < colors.Count);
for (var i = 0; i < colors.Count; i++)
_buttons[i].SetColor(colors[i]);
}
public void SetSelected(int index) {
for (var i = 0; i < _buttons.Length; i++)
_buttons[i].SetSelected(i == index);
}
}
Presenter (pure C#)
namespace Darkmatter.Features.Coloring.UI;
public sealed class ColorPalettePresenter : IStartable, IDisposable {
private readonly IColorPaletteView _view;
private readonly ColoringStateRepository _state;
public ColorPalettePresenter(IColorPaletteView view, ColoringStateRepository state) {
_view = view;
_state = state;
}
public void Start() {
_view.SetColors(_state.Palette.Colors);
_view.SetSelected(_state.SelectedIndex);
_view.OnColorButtonClicked += OnClicked;
_state.SelectedIndexChanged += OnIndexChanged;
}
private void OnClicked(int index) => _state.SelectColor(index);
private void OnIndexChanged(int index) => _view.SetSelected(index);
public void Dispose() {
_view.OnColorButtonClicked -= OnClicked;
_state.SelectedIndexChanged -= OnIndexChanged;
}
}
Same shape repeats for every feature's UI.
26. ShapeBuilder — Snap Algorithm
All math is in canvas-local space — anchoredPosition, sizeDelta, localRotation. No world coords.
// In ShapePieceFsm.OnDragEnd (state: Dragging or Preview):
public void OnDragEnd() {
var pieceRT = _ui.RectTransform;
var slotRT = _targetSlot.RectTransform;
var d = Vector2.Distance(pieceRT.anchoredPosition, slotRT.anchoredPosition);
if (d <= _cfg.SnapRadius) {
SnapToSlot();
} else if (d <= _cfg.SnapRadius * 1.5f) {
// Toddler grace zone — snap anyway, play happy sound
SnapToSlot();
_audio.PlayOneShot(SfxId.NiceTry);
} else {
ReturnToTrayAnimated();
}
}
private void SnapToSlot() {
_ui.RectTransform.SetParent(_paper.PiecesParent, worldPositionStays: false);
var slot = _targetSlot.RectTransform;
_ui.RectTransform.DOAnchorPos(slot.anchoredPosition, 0.25f).SetEase(Ease.OutBack);
_ui.RectTransform.DOSizeDelta(slot.sizeDelta, 0.25f).SetEase(Ease.OutBack);
_ui.RectTransform.DOLocalRotateQuaternion(slot.localRotation, 0.25f).SetEase(Ease.OutBack);
_audio.PlayOneShot(SfxId.Snap);
_bus.Publish(new PieceSnappedSignal(_ui.PieceId));
}
Three things to note:
- Reparent the piece from
TrayPanel(HUD canvas) toIPaperSurface.PiecesParent(PaperCanvas) so it'll be included in capture.worldPositionStays: falsebecause we want the newanchoredPositionto be relative to the new parent, not the world. - Three simultaneous tweens — position, size, rotation. Use
DOAnchorPos,DOSizeDelta,DOLocalRotateQuaternion. They start together so the piece visually snaps as one motion. SnapRadiusis in canvas units (fromShapeBuilderConfig, e.g. 80–120), not world units. SameCanvasScalerreference resolution across devices = same hit feel.
Controller listens for PieceSnappedSignal, counts against expected piece count, fires ShapeAssembledSignal when complete.
27. Rendering Order & Sorting
Canvas-only — order is sibling index inside PaperPanel (front-most is last in hierarchy). No URP 2D sorting layers.
PaperPanel children (bottom → top):
PaperPanel
├─ BackgroundImage (paper texture)
├─ RegionsPanel (colorable region Images)
├─ SlotsPanel (slot outline Images — under pieces so snapped pieces hide them)
├─ PiecesPanel (draggable / snapped piece Images)
└─ EffectsPanel (sparkle / particle UI for completion FX, optional)
HUDCanvas is a separate Canvas at a higher sorting order (or Screen Space - Overlay). It never sorts against PaperCanvas because they're different canvases.
CaptureCamera renders only the PaperUI layer. The HUD physically cannot leak into the saved PNG because of the culling mask, regardless of sibling order.
If you ever need particles outside the canvas (e.g. confetti falling across the full screen on completion), use a separate Canvas above the HUD with its own sub-tree of UI particles. Don't add
ParticleSystems under PaperPanel — they don't render in UI canvases withoutUIParticleSystemor similar packages.
28. SavedArtwork JSON Schema
{
"id": "f3a8e2d4-...",
"templateId": "animals/elephant",
"createdUtc": "2026-05-26T16:42:11Z",
"imagePath": "Gallery/f3a8e2d4-....png",
"thumbnailPath": "Gallery/f3a8e2d4-....thumb.png",
"regions": [
{ "regionId": "body", "color": "#FFB347" },
{ "regionId": "ears", "color": "#FF6961" }
]
}
regions[] lets the gallery reopen an artwork for further edits in a future version (out of scope v1, but the schema reserves the field now to avoid migration later).
Paths are relative to persistentDataPath. Never store absolute paths — they change between OS updates on some platforms.
29. Boot & Error Handling
Status: not implemented. No
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,ArtBook) 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, ColorRegionDTO |
Core | Core |
ShapeSO (ScriptableObject) |
Core | Core |
IPaperSurface |
Core | Core |
ICommand, IUndoStack |
Core | Core |
BoundedUndoStack |
Libs | Libs.CommandStack |
AddressableAssetProviderService |
Services | Services.Assets |
FileGalleryService |
Services | Services.Gallery |
RenderTextureCaptureService |
Services | Services.Capture |
PaperSurface, PaperSurfaceModule |
Features | Features.Paper |
ColoringController, PaintRegionCommand |
Features | Features.Coloring |
ShapeBuilderController, ShapePieceView |
Features | Features.ShapeBuilder |
HistoryController |
Features | Features.History |
ColorBookFlowController |
Features | Features.ColorBookFlow |
GalleryPresenter, GalleryGridView |
Features | Features.ArtBook |
MenuMascotView, MenuMascotPresenter |
Features | Features.MainMenu |
ColorBookLifetimeScope, AppBoot |
App | Darkmatter.App |
If a class's natural home doesn't match its asmdef, the architecture is bent — fix the placement, don't add a reference.
32. Class Reference (Detailed)
Status: target spec, mostly unimplemented. Of everything below, only the following Service classes exist on disk today:
AddressableAssetProviderService,AudioService/SfxPlayer,CameraService,SceneService,InputReaderSO,FirebaseAnalyticsSystem. Everything else (Paper, Drawing, Coloring, History, Capture, Gallery, Progression, ColorBookFlow, ArtBook, AppBoot) is the target shape for when those classes are written. Treat this section as a contract for new code, not documentation of current state.
Canonical breakdown of every concrete class and interface. For each: purpose, public surface (signatures), injected dependencies, and collaborators (signals or interfaces it talks to).
Convention used below
// fields:= constructor-injected dependencies// pub:= events / signals fired// sub:= events / signals consumed- All async returns are
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 shows the latest user-captured thumbnail (via
IGalleryService.GetLatestThumbnailAsync) when available, falling back toDefaultThumbnailwhen the user hasn't completed this template yet. The template itself stays immutable.
IDrawingTemplateCatalog (Core/Contracts/Features/Drawing — planned)
Authority on which drawings exist, completion state, and "next" selection.
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)
Persistent store of saved artwork PNGs.
public interface IGalleryService {
UniTask<SavedArtworkDTO> SaveAsync(byte[] png, string templateId);
UniTask<IReadOnlyList<SavedArtworkDTO>> ListAsync(); // sorted newest first
UniTask<Texture2D> LoadFullAsync(string artworkId); // fullscreen view
UniTask<Texture2D> LoadThumbnailAsync(string artworkId);
UniTask DeleteAsync(string artworkId);
// Newest captured thumbnail for the given template, or null if the user has
// no captures for it. Used by the catalog grid; null → caller falls back to
// IDrawingTemplate.DefaultThumbnail.
UniTask<Texture2D> GetLatestThumbnailAsync(string templateId);
}
For v1 the latest-thumbnail lookup can list-and-filter (tens of templates max). Add an in-memory Dictionary<templateId, latestArtworkId> cache later if perf becomes a concern.
ICaptureService (Core/Contracts/Services/Capture — planned)
Snapshots the paper RT to a PNG blob. No arguments — dimensions and content come from IPaperRig.Surface.
public interface ICaptureService {
UniTask<byte[]> CaptureAsync();
}
IPaperSurface (Core/Contracts/Features/Paper — planned)
The paper is just RectTransform real estate. Features parent their UI children under one of the role-specific roots.
public interface IPaperSurface {
RectTransform Root { get; } // PaperPanel itself
RectTransform SlotsParent { get; } // child of Root — for ShapeBuilder slot outlines
RectTransform PiecesParent { get; } // child of Root — for ShapeBuilder pieces (post-snap)
RectTransform RegionsParent { get; } // child of Root — for Coloring region Images
float DesignHalfSize { get; } // half the reference resolution side, in canvas units
}
No render-target ownership. No coordinate conversion. The contract just hands out RectTransforms so features don't have to Find them.
IProgressionService (Core/Contracts/Features/Progression — planned)
Tracks which templates the child has completed and what they last opened.
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.
FileGalleryService (Services/Gallery — planned)
Implements IGalleryService.
// fields:
// IPathProvider _paths (wraps Application.persistentDataPath for tests)
// IThumbnailGenerator _thumb (downscale + encode)
// IEventBus _bus
// pub: ArtworkSavedSignal, ArtworkDeletedSignal
- Save flow: write
{guid}.png.tmp→ fsync → rename; generate thumbnail on a worker; write sidecar JSON last (so partial saves are detectable by absence of JSON). - List flow: enumerate
*.jsoninGallery/, deserialize, sort byCreatedUtc desc. - Delete flow: delete png + thumb + json; missing files ignored (idempotent).
RenderTextureCaptureService (Services/Capture — planned)
Implements ICaptureService. Drives the scene's disabled CaptureCamera once per capture.
// 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.
JsonPersistenceService (Services/Persistence — planned; today Libs/PlayerPrefs covers small-key state)
Implements IPersistenceService (small JSON blob; not the gallery).
public interface IPersistenceService {
UniTask<T> LoadAsync<T>(string key) where T : class, new();
UniTask SaveAsync<T>(string key, T value);
}
- Path:
Application.persistentDataPath/save.json. - Format: single JSON object keyed by
keyso multiple services can share one file. - Atomicity: write to
save.json.tmp→ rename.
SceneService (Services/Scenes — ✅ exists)
Implements ISceneService. Wraps SceneManager.LoadSceneAsync with UniTask plus a fade-curtain.
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.ArtBookButtonHovered += () => _view.Play("hover_artbook", 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)
ShapeBuilderController (Systems)
Spawns shape pieces for the selected template, tracks snap progress, fires ShapeAssembledSignal when complete.
// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory,
// IPaperSurface _paper, TrayPanel _tray, IEventBus _bus, ShapeBuilderConfig _cfg
public sealed class ShapeBuilderController : IDisposable {
public IReadOnlyList<ShapePieceUI> Active { get; }
public UniTask BuildAsync(string templateId); // load template, spawn pieces in tray
public void Reset(); // clear, unsubscribe
}
// sub: DrawingSelectedSignal
// pub: ShapeAssembledSignal
- Internal: counts
PieceSnappedSignalagainst expected piece count. - Slot discovery: after a drawing's per-drawing prefab is instantiated under
IPaperSurface.Root, the controller queriesGetComponentsInChildren<SlotMarker>()to discover all slots in the loaded drawing. Each slot's_shapefield tells whichShapeSOit expects; matching pieces are spawned in the tray.
ShapePieceUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler (UI)
The UI Image that the toddler drags. One prefab; the assigned ShapeSO determines visual identity and snap params.
public sealed class ShapePieceUI : MonoBehaviour,
IBeginDragHandler, IDragHandler, IEndDragHandler
{
[SerializeField] private ShapeSO _shape; // set by controller at spawn (or in inspector for testing)
[SerializeField] private Image _image;
public string PieceId => _shape != null ? _shape.Id : null;
public ShapeSO Shape => _shape;
public RectTransform RectTransform => (RectTransform)transform;
public bool IsLocked { get; private set; }
public event Action<ShapeSO> Snapped;
// Controller calls this at spawn time
public void Assign(ShapeSO shape) {
_shape = shape;
ApplyShape();
}
private void OnValidate() => ApplyShape(); // editor inspector edits
private void Awake() => ApplyShape(); // runtime safety
private void ApplyShape() {
if (_shape == null || _image == null) return;
_image.sprite = _shape.Sprite;
RectTransform.sizeDelta = _shape.DefaultSizeDelta;
}
}
- Handlers forward to
ShapePieceFsm(OnDragBegin / OnDrag(localPos) / OnDragEnd). OnDragconvertsPointerEventData.positionto canvas-local viaRectTransformUtility.ScreenPointToLocalPointInRectangleagainst the piece's parent rect.- No collider, no Physics2D anywhere.
- Identity follows the SO — change
_shapein inspector and the visual + ID update on the nextOnValidate. At runtime,Assign(...)is the only mutation path.
ShapePieceFsm (Systems)
Per-piece state machine using Libs.FSM. States: InTray → Dragging → Preview → (Snapped | Returning).
// fields: ShapePieceUI _ui, SlotMarker _targetSlot, ShapeBuilderConfig _cfg,
// IAudioService _audio, IEventBus _bus
public sealed class ShapePieceFsm {
public void OnDragBegin();
public void OnDrag(Vector2 canvasLocalPos);
public void OnDragEnd();
public bool IsLocked { get; }
}
- Preview-state update: reactive lerp of
anchoredPosition / sizeDelta / localRotationtoward_targetSlot's pose, driven by1 - dist/PreviewRadius. No DOTween while previewing — it's per-frame. - Snapped enter: DOTween ease-out to exact slot pose (~0.2s), disable drag, fire
PieceSnappedSignal. - Returning enter: DOTween back to tray slot (
anchoredPositionfromTrayLayout).
SlotMarker : MonoBehaviour (UI)
The outline Image on IPaperSurface.SlotsParent showing where a piece should snap. Authored per drawing — designer places one in the scene at each slot location, with its RectTransform set to the target pose and _shape field assigned to the matching ShapeSO.
public sealed class SlotMarker : MonoBehaviour {
[SerializeField] private ShapeSO _shape; // which shape fits here
[SerializeField] private Image _outline; // optional faint outline UI
public ShapeSO Shape => _shape;
public string SlotId => _shape != null ? _shape.Id : null;
public RectTransform RectTransform => (RectTransform)transform;
}
- Pose lives on this MB's
RectTransform—anchoredPosition,sizeDelta,localRotationdirectly. No pose data on the SO. - Matching:
ShapePieceFsmcompares_piece.Shape == _slot.Shape(Unity Object reference equality). No string lookups.
TrayPanel : MonoBehaviour (UI)
HUD-side panel (on HUDCanvas) where pieces start out. Has a HorizontalLayoutGroup + ContentSizeFitter. Provides spawn anchors via RectTransform Slot(int index) for the controller.
ShapePieceFactory (Systems)
Pool of ShapePieceUI GameObjects (one prefab) + their associated FSMs. Reused across template loads.
public sealed class ShapePieceFactory {
// Instantiates the single piece prefab under `parent`, calls Assign(shape) on it,
// and wires up its FSM with the matching SlotMarker.
public ShapePieceUI Spawn(ShapeSO shape, SlotMarker targetSlot, RectTransform parent);
public void Despawn(ShapePieceUI piece);
}
- One prefab in
Content/Gameplay/Prefabs/ShapePiece.prefabis instantiated repeatedly. Visual identity comes from theShapeSOpassed toAssign.
ShapeBuilderInputBinder (Systems)
With UI handlers on the piece itself, an explicit input binder isn't strictly needed — drag events route via the EventSystem. Keep this class only if you need to listen for "any tap outside any piece" (e.g. to dismiss a preview). Otherwise skip.
32.5b Feature — Paper (planned)
A tiny feature. Just exposes the paper RectTransforms via DI so consumers don't Find them.
PaperSurface : MonoBehaviour, IPaperSurface (Surface)
Scene-bound component placed on the PaperPanel GameObject in ColorBook.unity.
// inspector fields:
// RectTransform _slotsParent
// RectTransform _piecesParent
// RectTransform _regionsParent
// float _designHalfSize = 1024f // half of 2048 reference resolution
public sealed class PaperSurface : MonoBehaviour, IPaperSurface {
public RectTransform Root => (RectTransform)transform;
public RectTransform SlotsParent => _slotsParent;
public RectTransform PiecesParent => _piecesParent;
public RectTransform RegionsParent => _regionsParent;
public float DesignHalfSize => _designHalfSize;
}
- No
Awake/OnDestroylogic. The component is a pure pass-through to the RectTransforms. - All four child rects share the same anchors and size as
Root(anchored center, stretched to fill).
PaperSurfaceModule : MonoBehaviour, IServiceModule (Installers)
Scene-scoped installer. Dragged into ColorBookLifetimeScope.sceneModules[].
// inspector fields:
// PaperSurface _surface
public void Register(IContainerBuilder builder) {
builder.RegisterInstance<IPaperSurface>(_surface);
}
Registers as Instance because PaperSurface is a MonoBehaviour already in the scene. Lifetime tied to the scene.
32.6 Feature — Coloring (planned)
ColoringStateRepository (Repository)
In-memory model. Owns "currently selected color" and the palette in use.
public sealed class ColoringStateRepository {
public IColorPalette Palette { get; private set; }
public int SelectedIndex { get; private set; }
public Color CurrentColor => Palette.Colors[SelectedIndex];
public event Action<int> SelectedIndexChanged;
public void SetPalette(IColorPalette palette); // resets SelectedIndex to 0
public void SelectColor(int index);
}
- Why a repository: presenter and controller both need to read/write current color; an event-emitting POCO is simpler than wiring two signals.
ColoringController (Systems) — implements IColoringController
Builds and pushes PaintRegionCommand instances; spawns ColorRegionView per region.
// fields: IUndoStack _undo, ColoringStateRepository _state, ColorRegionFactory _factory,
// IPaperSurface _paper, IEventBus _bus
public interface IColoringController {
UniTask SpawnRegionsAsync(IDrawingTemplate template);
void PaintRegion(ColorRegionView view); // builds command, pushes to undo stack
void Clear();
}
// sub: ShapeAssembledSignal (via flow controller, not direct)
// pub: ColorAppliedSignal (via PaintRegionCommand)
Spawns each region as a UI Image under _paper.RegionsParent. No Physics2D.
ColorRegionView : MonoBehaviour, IPointerClickHandler (UI)
UI Image with alpha-based hit detection. Tap routes through Unity's EventSystem directly to OnPointerClick.
public sealed class ColorRegionView : MonoBehaviour, IPointerClickHandler {
public string RegionId { get; }
public Color Color => _image.color;
public void Initialize(ColorRegionDTO dto, IColoringController controller);
public void SetColor(Color c); // setter only; no logic
public void OnPointerClick(PointerEventData e) => _controller.PaintRegion(this);
}
- Required sprite setup: sprite import inspector → Read/Write Enabled = on, Generate Physics Shape = off (not needed).
Image.alphaHitTestMinimumThreshold = 0.5fon Initialize so taps on transparent pixels pass through to the next region below. - Sibling order matters for stacked regions — top sibling gets first crack at the click; with alpha hit-test, transparent areas defer correctly.
No ColoringInputBinder class needed. Unity's EventSystem fires OnPointerClick on the topmost UI element under the pointer that consumes it — exactly what we want.
PaintRegionCommand (Commands)
Source in section 23. Holds view, fromColor, toColor, bus. Symmetrical execute/undo.
ColorPaletteView, ColorPalettePresenter (UI)
Sources in section 25. Presenter binds ColoringStateRepository.SelectedIndexChanged ↔ IColorPaletteView.
ColorRegionFactory (Systems)
Mirror of ShapePieceFactory for regions. Pool-friendly.
32.7 Feature — History (planned)
HistoryController (Systems) — IStartable, IDisposable
Owns the per-session IUndoStack (registered scoped, so a new ColorBook scene = new stack).
// 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 "Capture" button. Stateless other than guarding against concurrent captures.
// fields: ICaptureService _capture, IGalleryService _gallery, IEventBus _bus
public sealed class CaptureController {
public bool IsCapturing { get; }
public UniTask<SavedArtworkDTO> CaptureCurrentAsync(string templateId);
}
// pub: ArtworkCapturedSignal (mid-flow), ArtworkSavedSignal (post-save)
- Flow:
_capture.CaptureAsync()→_gallery.SaveAsync(bytes, templateId)→ publish signals. - Concurrency: sets
IsCapturing = trueon entry; UI binds button enabled to!IsCapturingto prevent double-tap. - No camera or sprite args — the implementation owns a reference to the disabled
CaptureCameraand drives the one-shot render internally.
CaptureButtonPresenter (UI)
Wires button click → CaptureController.CaptureCurrentAsync. Disables button while in progress. Shows toast on ArtworkSavedSignal.
32.9 Feature — Progression (planned)
ProgressionService (Systems) — implements IProgressionService
The only place that knows what "completed" means.
- Persistence: delegates to
IPersistenceServiceunder 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
// ColorBookSceneRefs _refs (panel roots to enable/disable)
// 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.11 Feature — ArtBook (planned)
GalleryPresenter (UI) — IAsyncStartable, IDisposable
Lists artworks, opens fullscreen view, deletes, shares.
// fields: IGalleryService _gallery, IGalleryView _view, IExternalShareService _share, IEventBus _bus
- Start:
_gallery.ListAsync()→_view.SetItems(...). - Subscribes to
ArtworkSavedSignalto live-refresh if the user pops back in.
IGalleryView (UI)
public interface IGalleryView {
event Action<string> OnArtworkTapped;
event Action<string> OnDeleteRequested;
event Action<string> OnShareRequested;
void SetItems(IReadOnlyList<GalleryItemVM> items);
void ShowFullscreen(Texture2D full);
void HideFullscreen();
}
IExternalShareService (Core)
Platform plugin shim (iOS Photos / Android MediaStore).
public interface IExternalShareService {
UniTask SaveToCameraRollAsync(byte[] png);
UniTask ShareAsync(byte[] png, string subject);
}
32.12 App Layer
AppBoot (App/Boot — planned; folder doesn't exist yet) — IAsyncStartable
Single entry point. Steps in section 29.
// fields: IAssetProviderService _assets, IPersistenceService _persist, IProgressionService _progress,
// IAudioService _audio, ISceneService _scenes, BootConfig _cfg
public sealed class AppBoot : IAsyncStartable {
public UniTask StartAsync(CancellationToken ct);
}
LifetimeScopes
RootLifetimeScope— ✅ exists (source). Iterates a serializedMonoBehaviour[] serviceModulesand callsRegisteron eachIServiceModule. Persists for app lifetime.MainMenuLifetimeScope— planned. Same pattern as Root (serialized installer list, no hardcoded registrations).ColorBookLifetimeScope— planned. Same pattern; installer list includesPaperRigModule, feature installers, and the flow controller installer.ArtBookLifetimeScope— planned.
All scope classes are thin: a serialized installer-MonoBehaviour list (+ optional scene refs as separate fields) and a Configure(IContainerBuilder) that iterates and calls Register.
32.13 Cross-cutting types
ColorBookSceneRefs : MonoBehaviour (App — planned, optional)
Aggregates HUD-side scene-bound Unity references that don't fit any single feature. Examples: Camera captureCamera, RectTransform hudRoot, ColorPaletteView paletteView, HistoryButtonsView historyView, TrayPanel trayPanel. Registered in ColorBookLifetimeScope via builder.RegisterInstance(_sceneRefs) so features don't Find things.
Paper-side refs are subsumed by
IPaperSurface(which exposes the four canvas RectTransform roots).CaptureCameracould either live here or be exposed via its own dedicatedICaptureCameraSourcecontract — for v1, putting it onColorBookSceneRefsis fine.
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, persist, progression, scenes |
RootLifetimeScope |
App | Root DI | configs |
ColorBookLifetimeScope |
App | Scene DI | scene refs, installers |
DrawingCatalogController |
Feature | Grid logic | catalog, bus |
DrawingCatalogPresenter |
Feature | UI bridge | view, controller, catalog |
ShapeBuilderController |
Feature | Piece spawn + snap tracking | catalog, factory, paper, tray, bus, cfg |
ShapeSO |
Core asset | Authored shape (sprite + snap params) | — |
ShapePieceUI |
Feature | Draggable UI piece prefab; holds [SerializeField] ShapeSO _shape |
fsm |
ShapePieceFsm |
Feature | Per-piece state machine | ui, slot, cfg, audio, bus |
SlotMarker |
Feature | Slot outline UI Image at target pose | — |
TrayPanel |
Feature | HUD-side tray with LayoutGroup | — |
ColoringStateRepository |
Feature | Current color model | — |
ColoringController |
Feature | Region spawn + paint cmd | undo, state, factory, paper, bus |
ColorRegionView |
Feature | Region UI Image + IPointerClickHandler | controller |
PaintRegionCommand |
Feature | Undoable paint (sets Image.color) | view, bus |
PaperSurface |
Feature | IPaperSurface (Root + child rects) | — |
PaperSurfaceModule |
Feature | DI registration | surface |
HistoryController |
Feature | Undo/redo facade | undo stack, bus |
CaptureController |
Feature | Capture+save orchestration | capture svc, gallery, bus |
ColorBookFlowController |
Feature | Scene FSM | bus, catalog, builder, coloring, capture, progression |
GalleryPresenter |
Feature | Art book listing | gallery, share, view, bus |
MenuMascotView |
Feature | Spine mascot UI (SkeletonGraphic wrapper) | — |
MenuMascotPresenter |
Feature | Drives mascot animations from model events | view, model |
BoundedUndoStack |
Lib | Capped undo store | — |
EventBus |
Lib | Pub/sub | — |
Fsm<TState> |
Lib | Generic FSM | — |
AddressableAssetProviderService |
Service | Addressables wrapper | — |
FileGalleryService |
Service | Gallery file IO | paths, thumb gen, bus |
RenderTextureCaptureService |
Service | PNG render from rig.Surface | paper rig |
JsonPersistenceService |
Service | Settings/progression IO | — |
SceneService |
Service | Async scene loads | — |
AudioService |
Service | SFX playback | assets |
ProgressionService |
Service | Completion tracking | persistence |
If you add a class not in this table, add it here in the same PR. This table is the cheap mental-model index — keep it honest.
Today only these rows are real on disk:
RootLifetimeScope(App),AddressableAssetProviderService(Service),AudioService(Service),CameraService(Service),SceneService(Service),InputReaderSO(Service), plus the Firebase analytics class, plus theLibs.*entries (EventBus,StateMachine,IServiceModule, PlayerPrefs lib, UI toggles). Everything else is the target.