From 8a018d14c5bb1dfb59a29e9a5c69f6b5335feaf1 Mon Sep 17 00:00:00 2001 From: Savya Bikram Shah Date: Tue, 26 May 2026 17:23:58 +0545 Subject: [PATCH] added readme --- .DS_Store | Bin 8196 -> 10244 bytes Readme.md | 1049 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1049 insertions(+) create mode 100644 Readme.md diff --git a/.DS_Store b/.DS_Store index 06179f8cec29604e75fb7fb05abaa48939fc5242..c6198cd4da9baaf4f930306bd8ce7731deeeb88a 100644 GIT binary patch delta 290 zcmZp1XbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~50$SAcjU^hRb)Mg%m_00Z~49N`n z3^_nt#E=9e^BJ-kbQuzXykv$9hD?SMhEyP{grO8DlFFdRkjs$bnUkNKl#`zXG81SM zBM|5O2LlF%$vcE?Ik_2}!RDn-ZV*vmV&vQWN?4Lfni(jT#E^xmo52aFyKeIjQGTY4 z6+!HR%s@kdK!F=bxPsigvG6/ +│ │ ├── Template.asset (DrawingTemplateSO) +│ │ ├── Pieces/*.png +│ │ ├── Regions/*.png +│ │ └── PaperBackground.png +│ └── Vehicles/... +├── Palettes/*.asset (ColorPaletteSO) +├── Audio/ +│ ├── UI/ (tap, swipe, button) +│ └── Coloring/ (fill, complete, sparkle) +└── UI/ (HUD prefabs, fonts, icons) +``` + +--- + +## 5. Namespaces + +`Darkmatter.[Layer].[Module]` + +- `Darkmatter.Features.Coloring` +- `Darkmatter.Features.ShapeBuilder` +- `Darkmatter.Services.Gallery` +- `Darkmatter.Services.Capture` +- `Darkmatter.Core.Drawing` +- `Darkmatter.Lib.CommandStack` + +Each maps 1:1 to a `.asmdef`. + +--- + +## 6. Scenes & Lifetime Scopes + +| Scene | Scope | Contents | +|---|---|---| +| `Boot.unity` | `RootLifetimeScope` | All Services + `IEventBus`. Persists forever. | +| `MainMenu.unity` | `MainMenuLifetimeScope` | Menu presenter, art book entry. | +| `ColorBook.unity` | `ColorBookLifetimeScope` | DrawingCatalog, ShapeBuilder, Coloring, History, Capture, ColorBookFlow. | +| `ArtBook.unity` | `ArtBookLifetimeScope` | Gallery presenter, viewer, share. | + +Scopes nest: `Root → (MainMenu | ColorBook | ArtBook)`. Services resolved from the root parent. Scene scopes only register their own features. + +### Boot chain + +`AppBoot` runs once, in order: + +1. Initialize `IAssetProviderService` (Addressables init). +2. Preload essential bundles (palettes, UI sounds). +3. Load `IProgressionService` from disk. +4. Load `MainMenu` scene. + +Failures show a child-friendly retry screen; never crash. + +--- + +## 7. Rendering Strategy + +Hybrid: **Sprites for artwork, Canvas for HUD**. + +### Cameras + +| Camera | Type | Culling Mask | Purpose | +|---|---|---|---| +| `ArtCamera` | Orthographic | `Artwork`, `PaperBackground` | Renders the drawing only. Source for capture. | +| `UICamera` | Overlay (or screen-space) | `UI` | HUD canvas, palette, buttons. | + +### Layers + +| Layer | Used by | +|---|---| +| `Artwork` | Drawing region sprites, shape pieces, paper bg | +| `UI` | All Canvas elements | +| `Effects` | Particle bursts, sparkles on completion | + +### Why hybrid + +| Need | Choice | Why | +|---|---|---| +| Per-region tap-to-fill | Sprites + `PolygonCollider2D` | Clean `Physics2D.OverlapPoint`; deterministic; no shader work for the toddler region count (5–20). | +| Drag/drop shape pieces | Sprites + Physics2D | Natural world bounds, easy snap distance checks. | +| Capture to PNG with paper bg | Sprites under dedicated Camera | `RenderTexture` from `ArtCamera` excludes HUD automatically. | +| Color palette, buttons | Canvas | Anchors handle aspect ratios. Buttons + ScrollRect free. | +| Drawing catalog grid | Canvas | `GridLayoutGroup` + ScrollRect, async thumbnail loader. | + +--- + +## 8. Core Contracts + +All Core types are pure data or interfaces. + +### Drawing + +```csharp +namespace Darkmatter.Core.Drawing; + +public interface IDrawingTemplate { + string Id { get; } + string DisplayName { get; } + Sprite Thumbnail { get; } + Sprite PaperBackground { get; } + IReadOnlyList Pieces { get; } + IReadOnlyList Regions { get; } +} + +public readonly struct ShapePieceDTO { + public string PieceId { get; } + public Sprite Sprite { get; } + public Vector2 SlotPosition { get; } + public float SlotRotation { get; } + public float SnapRadius { get; } // generous for toddlers +} + +public readonly struct ColorRegionDTO { + public string RegionId { get; } + public Sprite Sprite { get; } // sprite renderer source + public Vector2[] ColliderPath { get; } // polygon collider points + public Color InitialColor { get; } // usually white +} +``` + +### Coloring + +```csharp +namespace Darkmatter.Core.Coloring; + +public interface IColorPalette { + string Id { get; } + IReadOnlyList Colors { get; } +} + +public readonly struct PaintCommandDTO { + public string RegionId { get; } + public Color FromColor { get; } + public Color ToColor { get; } +} +``` + +### History + +```csharp +namespace Darkmatter.Core.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 + +```csharp +namespace Darkmatter.Core.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; } +} + +public interface IGalleryService { + UniTask SaveAsync(byte[] png, string templateId); + UniTask> ListAsync(); + UniTask LoadFullAsync(string artworkId); + UniTask DeleteAsync(string artworkId); +} + +namespace Darkmatter.Core.Capture; + +public interface ICaptureService { + UniTask CaptureAsync(Camera artCamera, Sprite paperBackground, int width = 2048, int height = 2048); +} +``` + +### Signals + +```csharp +namespace Darkmatter.Core.Signals; + +public readonly struct DrawingSelectedSignal { + public string TemplateId { get; } +} + +public readonly struct ShapeAssembledSignal { + public string TemplateId { get; } +} + +public readonly struct ColorAppliedSignal { + public string RegionId { get; } + public Color Color { get; } +} + +public readonly struct ArtworkCapturedSignal { + public string ArtworkId { get; } +} + +public readonly struct ArtworkSavedSignal { + public SavedArtworkDTO Artwork { get; } +} +``` + +--- + +## 9. Feature Responsibilities + +### `DrawingCatalog` + +- Loads the catalog manifest (list of available template IDs + thumbnail addresses). +- Presents a scrollable grid of thumbnails (Canvas). +- On select → fires `DrawingSelectedSignal(templateId)` and unloads the catalog UI. + +### `ShapeBuilder` + +- Listens to `DrawingSelectedSignal`. +- Loads template via `IDrawingTemplateLoader`, instantiates shape pieces at random off-slot positions. +- Per piece: drag with `ShapePieceView` (sprite + collider). On drop, check distance to `SlotPosition` against `SnapRadius`; if within, snap and lock. +- Fires `ShapeAssembledSignal` when all pieces locked. + +### `Coloring` + +- Listens to `ShapeAssembledSignal`. +- Spawns one `ColorRegionView` per `ColorRegionDTO` (sprite + polygon collider on Artwork layer). +- Listens to palette selection (current color held in `ColoringStateRepository`). +- On region tap: builds `PaintRegionCommand(regionId, oldColor, newColor)`, pushes to `IUndoStack`. +- Command sets `SpriteRenderer.color` on undo/redo. +- Fires `ColorAppliedSignal` for SFX / sparkle effects. + +### `History` + +- Owns the singleton `IUndoStack` for the current ColorBook session. +- Cleared on `DrawingSelectedSignal` (new drawing = fresh history). +- Capped at ~20 entries (memory + cognitive simplicity). +- UI: two big arrow buttons; disabled state when `CanUndo / CanRedo` is false. + +### `Capture` + +- Bound to the "Capture" button. +- Calls `ICaptureService.CaptureAsync(artCamera, template.PaperBackground)` → PNG bytes. +- Hands bytes to `IGalleryService.SaveAsync(...)`. +- Fires `ArtworkCapturedSignal` then `ArtworkSavedSignal`. +- Shows a quick "saved!" toast with a thumbnail of the new entry. + +### `Progression` + +- Tracks completed template IDs and the in-progress draft. +- On "Next" button: silently runs Capture pipeline (auto-save), marks current as completed, calls `IDrawingTemplateCatalog.NextUnseen()`. +- Persists JSON via `IPersistenceService`. + +### `ColorBookFlow` + +- The only orchestrator inside ColorBook scope. +- Subscribes to flow-relevant signals and toggles UI panels (catalog → builder → coloring). +- Coordinates "Next" sequence: `IProgressionService.MarkCompleted` → `ICaptureService` autosave → `IDrawingTemplateLoader.Release(currentId)` → load next. +- Built as a small FSM (`Catalog → Building → Coloring → Done`). + +### `ArtBook` + +- Separate scene. +- `GalleryPresenter` calls `IGalleryService.ListAsync()` → grid of thumbnails. +- Tap → fullscreen view, share-sheet button, delete. +- Saved-to-device-camera-roll uses an optional platform plugin behind `IExternalShareService` (Core contract). + +--- + +## 10. Addressables Strategy + +Mirror the Bus Game pattern via `IAssetProviderService`. + +### What ships through Addressables + +| Asset | Why | +|---|---| +| `DrawingTemplate` ScriptableObject (per drawing) | Many; load on demand. | +| Shape piece sprites | Only needed when active. | +| Region sprites + polygon paths | Heavy; loaded per drawing. | +| Paper backgrounds | Per template, sometimes shared. | +| Color palette SOs | Swap per theme. | +| Audio clips (tap, snap, complete, sparkle) | Shared SFX bank. | + +### What does NOT use Addressables + +- HUD prefabs (palette button, undo icon) — always loaded with scene. +- Core UI canvases. +- Boot scene assets. +- User-saved gallery PNGs — those live in `Application.persistentDataPath`. + +### Group layout + +``` +Drawings_Animals (label: drawing, animals) +Drawings_Vehicles (label: drawing, vehicles) +Drawings_Shapes (label: drawing, shapes) +Palettes (label: palette) +Audio_UI (label: sfx, ui) +Audio_Coloring (label: sfx, coloring) +``` + +### Lifecycle + +- Catalog loads **thumbnail handles only** (cheap). +- On select → full template loads (pieces + regions + paper). +- On "Next" or scene exit → previous template `Release`d before next loads. +- This bound matters on toddler tablets with limited RAM. + +### Remote groups (future) + +Drawing packs ship as remote bundles. New theme packs (Christmas, Dinosaurs) update without an app store release. + +--- + +## 11. Persistence + +Two distinct stores, each behind its own Core contract. + +### `IPersistenceService` (JSON / PlayerPrefs) + +Holds: + +- Completed template IDs. +- Last opened drawing. +- Audio volume, simple settings. + +Path: `Application.persistentDataPath/save.json`. + +### `IGalleryService` (file IO) + +Holds user artworks: + +``` +persistentDataPath/Gallery/ +├── {guid}.png full-res render (~2048×2048) +├── {guid}.thumb.png 256×256 for grid +└── {guid}.json SavedArtworkDTO sidecar +``` + +- Writes are atomic (`.tmp` → rename). +- `ListAsync` enumerates sidecar JSONs sorted by `CreatedUtc desc`. +- Thumbnail generation happens once at save time on a worker thread. + +--- + +## 12. Capture Pipeline + +``` +[Capture button or Next button] + │ + ▼ +ICaptureService.CaptureAsync(artCamera, paperBg) + │ + ├─ Allocate RenderTexture (2048×2048, ARGB32) + ├─ artCamera.targetTexture = rt + ├─ Force render (artCamera.Render()) + ├─ ReadPixels into Texture2D + ├─ Composite paperBg underneath (single shader pass or CPU blend) + ├─ Encode PNG (Texture2D.EncodeToPNG) + ├─ Release RT + temp texture + └─ return byte[] + ▼ +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 `ArtCamera` only renders the `Artwork` layer. +- Paper background can either be already present in the scene (cheap) or composited at capture time (lets the same drawing be saved with different papers). + +--- + +## 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)`). +- **Presenter** — pure C#, subscribes to model events, calls view setters. + +### Inspector bridge + +For palette icons, undo buttons, region prefabs: + +```csharp +[SerializeField, RequireInterface(typeof(IColorButtonView))] +private MonoBehaviour[] _colorButtons; +``` + +--- + +## 15. Toddler UX Constraints + +These shape several design decisions and are **non-negotiable**: + +- **No fail states.** Drawings cannot be "wrong". +- **No timers.** Nothing decays or runs out. +- **No tiny hitboxes.** Drag tolerance ≥ 40 px; snap radius ≥ 60 px for shape pieces. +- **Auto-snap on near-miss.** If a piece is dropped within `1.5 × SnapRadius`, snap anyway and play a happy sound. +- **No text-heavy UI.** Icons everywhere. Single-word labels max. +- **Loud, immediate feedback.** Every tap plays a sound; every fill bursts a small particle effect. +- **Undo cap = 20.** Toddlers will mash undo. Bound the memory. +- **Long-press = quick menu off.** Avoid surprise modals. + +--- + +## 16. Testing + +| Layer | Test type | Location | +|---|---|---| +| `Libs/CommandStack` | EditMode unit tests | `Libs/CommandStack/Tests/` | +| `Core` DTOs | EditMode | rarely needed, but for `SavedArtworkDTO` serialization, yes. | +| `Services/Gallery` | EditMode w/ temp directory | mocks `Application.persistentDataPath`. | +| `Services/Capture` | PlayMode | requires a Camera in the test scene. | +| `Features/*/Systems` | EditMode w/ DI test container | inject fakes for `IUndoStack`, signals captured by a fake `IEventBus`. | +| Full flow | PlayMode smoke test | one drawing → assemble → color → capture → assert gallery has 1 file. | + +--- + +## 17. "Where do I put this?" Checklist + +1. **Is it a cross-assembly interface / enum / DTO?** → `Core/` +2. **Is it a generic, sellable utility?** → `Libs/` +3. **Is it infrastructure (input, audio, file IO, addressables, capture)?** → `Services/` +4. **Is it gameplay logic specific to coloring books?** → `Features/` +5. **Is it composition / scene wiring?** → `App/` + +When in doubt, ask: *would deleting this feature break Core?* If yes, the dependency is wrong. + +--- + +## 18. Open Questions / Future Work + +- **Pencil/brush mode** — currently the design is tap-to-fill regions. A free-draw brush mode would need a `BrushStrokeCommand` and a dynamic texture per region; out of scope for v1. +- **Multi-child profiles** — single-profile for v1; multi-profile would slot in behind `IProgressionService` and `IGalleryService` keyed by `profileId`. +- **Cloud sync** — gallery sync would happen behind `IGalleryService` (decorator pattern); local-first stays the source of truth. +- **Sticker / decoration layer** — additive sprite layer above coloring, also `ICommand`-driven so it integrates with undo/redo cleanly. + +--- + +## 19. Quick Reference — Feature ↔ Signal Map + +| Feature | Subscribes to | Publishes | +|---|---|---| +| `DrawingCatalog` | — | `DrawingSelectedSignal` | +| `ShapeBuilder` | `DrawingSelectedSignal` | `ShapeAssembledSignal` | +| `Coloring` | `ShapeAssembledSignal` | `ColorAppliedSignal` | +| `History` | `DrawingSelectedSignal` (to clear) | — | +| `Capture` | — (button-driven) | `ArtworkCapturedSignal`, `ArtworkSavedSignal` | +| `Progression` | `ArtworkSavedSignal` | — | +| `ColorBookFlow` | `ShapeAssembledSignal`, `ArtworkSavedSignal` | — | +| `ArtBook (Gallery)` | `ArtworkSavedSignal` (if open) | — | + +--- + +Maintained alongside the [Darkmatter Architecture Guide](../Assets/Darkmatter_Architecture_Guide.md). Do not break the dependency arrows. + +--- + +## 20. Assembly Definition Map + +Every folder under `Code/` is its own `.asmdef`. References follow the layer rules exactly. + +| Asmdef | Path | References | +|---|---|---| +| `Darkmatter.App` | `App/` | All Features, all Services, Core, Libs | +| `Darkmatter.Core` | `Core/` | (none — `UniTask` allowed in async signatures) | +| `Darkmatter.Lib.CommandStack` | `Libs/CommandStack/` | `Darkmatter.Core` | +| `Darkmatter.Lib.EventBus` | `Libs/EventBus/` | `Darkmatter.Core` | +| `Darkmatter.Lib.FSM` | `Libs/FSM/` | `Darkmatter.Core` | +| `Darkmatter.Services.Audio` | `Services/Audio/` | `Darkmatter.Core` | +| `Darkmatter.Services.Inputs` | `Services/Inputs/` | `Darkmatter.Core` | +| `Darkmatter.Services.Assets` | `Services/Assets/` | `Darkmatter.Core` | +| `Darkmatter.Services.Scenes` | `Services/Scenes/` | `Darkmatter.Core` | +| `Darkmatter.Services.Persistence` | `Services/Persistence/` | `Darkmatter.Core` | +| `Darkmatter.Services.Gallery` | `Services/Gallery/` | `Darkmatter.Core` | +| `Darkmatter.Services.Capture` | `Services/Capture/` | `Darkmatter.Core` | +| `Darkmatter.Features.MainMenu` | `Features/MainMenu/` | `Darkmatter.Core`, Libs | +| `Darkmatter.Features.DrawingCatalog` | `Features/DrawingCatalog/` | `Darkmatter.Core`, Libs | +| `Darkmatter.Features.ShapeBuilder` | `Features/ShapeBuilder/` | `Darkmatter.Core`, Libs | +| `Darkmatter.Features.Coloring` | `Features/Coloring/` | `Darkmatter.Core`, `Lib.CommandStack` | +| `Darkmatter.Features.History` | `Features/History/` | `Darkmatter.Core`, `Lib.CommandStack` | +| `Darkmatter.Features.Capture` | `Features/Capture/` | `Darkmatter.Core` | +| `Darkmatter.Features.Progression` | `Features/Progression/` | `Darkmatter.Core` | +| `Darkmatter.Features.ColorBookFlow` | `Features/ColorBookFlow/` | `Darkmatter.Core`, `Lib.FSM` | +| `Darkmatter.Features.ArtBook` | `Features/ArtBook/` | `Darkmatter.Core` | + +**Hard rule:** No Service asmdef references any Feature asmdef. No Feature asmdef references another Feature asmdef. Compiler enforces the architecture. + +--- + +## 21. LifetimeScope Concrete Sample + +### `RootLifetimeScope` (Boot scene, persists forever) + +```csharp +namespace Darkmatter.App.LifetimeScopes; + +public sealed class RootLifetimeScope : LifetimeScope { + [SerializeField] private AudioServiceConfig _audioConfig; + [SerializeField] private InputReaderSO _inputReader; + + protected override void Configure(IContainerBuilder builder) { + // EventBus + builder.Register(Lifetime.Singleton); + + // Services + builder.RegisterInstance(_inputReader).As(); + builder.Register(Lifetime.Singleton) + .WithParameter(_audioConfig); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + + // App entry + builder.RegisterEntryPoint(); + } +} +``` + +### `ColorBookLifetimeScope` (per-scene, child of Root) + +```csharp +namespace Darkmatter.App.LifetimeScopes; + +public sealed class ColorBookLifetimeScope : LifetimeScope { + [SerializeField] private ColorBookSceneRefs _sceneRefs; // ArtCamera, panel roots, prefabs + [SerializeField] private IInstaller[] _installers; // assigned in inspector + + protected override void Configure(IContainerBuilder builder) { + builder.RegisterInstance(_sceneRefs); + + // Each feature ships an IInstaller + foreach (var installer in _installers) installer.Install(builder); + + // Scene-scoped orchestrator + builder.RegisterEntryPoint(); + } +} +``` + +Drag these installers in the inspector: +- `DrawingCatalogServiceModule` +- `ShapeBuilderServiceModule` +- `ColoringServiceModule` +- `HistoryServiceModule` +- `CaptureFeatureModule` +- `ProgressionServiceModule` + +--- + +## 22. Installer Pattern — Concrete Coloring Sample + +```csharp +namespace Darkmatter.Features.Coloring.Installers; + +[CreateAssetMenu(menuName = "Darkmatter/Installers/Coloring")] +public sealed class ColoringServiceModule : ScriptableObject, IInstaller { + [SerializeField] private ColoringConfig _config; + + public void Install(IContainerBuilder builder) { + builder.RegisterInstance(_config); + builder.Register(Lifetime.Scoped).AsSelf(); + builder.Register(Lifetime.Scoped) + .As() + .AsSelf(); + builder.Register(Lifetime.Scoped).AsSelf(); + builder.RegisterEntryPoint(); + } +} +``` + +Convention: +- One `IInstaller` per feature. +- `ScriptableObject` so it can be referenced by scene scope inspector. +- Registers only its own types. Never touches another feature's types. + +--- + +## 23. Command Pattern — `PaintRegionCommand` + +```csharp +namespace Darkmatter.Features.Coloring.Commands; + +internal sealed class PaintRegionCommand : ICommand { + private readonly ColorRegionView _view; + private readonly Color _fromColor; + private readonly Color _toColor; + private readonly IEventBus _bus; + + public PaintRegionCommand(ColorRegionView view, Color from, Color to, IEventBus bus) { + _view = view; + _fromColor = from; + _toColor = to; + _bus = bus; + } + + public void Execute() { + _view.SetColor(_toColor); + _bus.Publish(new ColorAppliedSignal(_view.RegionId, _toColor)); + } + + public void Undo() { + _view.SetColor(_fromColor); + _bus.Publish(new ColorAppliedSignal(_view.RegionId, _fromColor)); + } +} +``` + +Usage in controller: + +```csharp +public void PaintRegion(ColorRegionView view) { + var current = _state.CurrentColor; + if (view.Color == current) return; // no-op + var cmd = new PaintRegionCommand(view, view.Color, current, _bus); + _undoStack.Push(cmd); // Push executes + records +} +``` + +Same pattern applies to `SnapPieceCommand` if shape-builder steps should be undoable (optional for v1). + +--- + +## 24. CommandStack — `Libs/CommandStack` + +```csharp +namespace Darkmatter.Lib.CommandStack; + +public sealed class BoundedUndoStack : IUndoStack { + private readonly Deque _undo = new(); + private readonly Stack _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` keeps the oldest entry cheap to evict when the cap fires. + +--- + +## 25. View / Presenter Pair — Color Palette + +### View (MonoBehaviour, setters only) + +```csharp +namespace Darkmatter.Features.Coloring.UI; + +public sealed class ColorPaletteView : MonoBehaviour, IColorPaletteView { + [SerializeField, RequireInterface(typeof(IColorButtonView))] + private MonoBehaviour[] _buttonsRaw; + + private IColorButtonView[] _buttons; + + public event Action OnColorButtonClicked; + + private void Awake() { + _buttons = _buttonsRaw.Cast().ToArray(); + for (var i = 0; i < _buttons.Length; i++) { + var idx = i; + _buttons[i].OnClicked += () => OnColorButtonClicked?.Invoke(idx); + } + } + + public void SetColors(IReadOnlyList colors) { + for (var i = 0; i < _buttons.Length; i++) + _buttons[i].SetVisible(i < colors.Count); + for (var i = 0; i < colors.Count; i++) + _buttons[i].SetColor(colors[i]); + } + + public void SetSelected(int index) { + for (var i = 0; i < _buttons.Length; i++) + _buttons[i].SetSelected(i == index); + } +} +``` + +### Presenter (pure C#) + +```csharp +namespace Darkmatter.Features.Coloring.UI; + +public sealed class ColorPalettePresenter : IStartable, IDisposable { + private readonly IColorPaletteView _view; + private readonly ColoringStateRepository _state; + + public ColorPalettePresenter(IColorPaletteView view, ColoringStateRepository state) { + _view = view; + _state = state; + } + + public void Start() { + _view.SetColors(_state.Palette.Colors); + _view.SetSelected(_state.SelectedIndex); + _view.OnColorButtonClicked += OnClicked; + _state.SelectedIndexChanged += OnIndexChanged; + } + + private void OnClicked(int index) => _state.SelectColor(index); + private void OnIndexChanged(int index) => _view.SetSelected(index); + + public void Dispose() { + _view.OnColorButtonClicked -= OnClicked; + _state.SelectedIndexChanged -= OnIndexChanged; + } +} +``` + +Same shape repeats for every feature's UI. + +--- + +## 26. ShapeBuilder — Snap Algorithm + +```csharp +// In ShapePieceView.OnPointerUp: +public void OnDragEnd(Vector2 worldPos) { + var slot = transform.position; // assigned target slot + var d = Vector2.Distance(worldPos, slot); + + if (d <= _piece.SnapRadius) { + SnapToSlot(); + } else if (d <= _piece.SnapRadius * 1.5f) { + // Toddler grace zone — snap anyway, play happy sound + SnapToSlot(); + _audio.PlayOneShot(_clips.NiceTry); + } else { + ReturnToTrayAnimated(); + } +} + +private void SnapToSlot() { + _locked = true; + transform.DOMove(_piece.SlotPosition, 0.25f).SetEase(Ease.OutBack); + _audio.PlayOneShot(_clips.Snap); + _bus.Publish(new PieceSnappedSignal(_piece.PieceId)); +} +``` + +Controller listens for `PieceSnappedSignal`, counts against expected piece count, fires `ShapeAssembledSignal` when complete. + +--- + +## 27. Rendering Order & Sorting + +URP 2D with a single `ArtCamera` ortho cam. + +| Sorting Layer | Order | Contents | +|---|---|---| +| `PaperBackground` | 0 | Paper bg sprite (under everything) | +| `ArtworkRegions` | 100 | `ColorRegionView` sprites (the colorable shapes) | +| `ArtworkPieces` | 200 | `ShapePieceView` sprites (during build) | +| `Effects` | 300 | Particle bursts, sparkles | +| `UIWorld` | 400 | World-space prompts (rare; mostly Canvas) | + +Canvas HUD lives on `UICamera` (Overlay), never sorts against `ArtCamera`. Capture renders only `ArtCamera`'s layers → HUD physically cannot leak into saved PNG. + +--- + +## 28. SavedArtwork JSON Schema + +```json +{ + "id": "f3a8e2d4-...", + "templateId": "animals/elephant", + "createdUtc": "2026-05-26T16:42:11Z", + "imagePath": "Gallery/f3a8e2d4-....png", + "thumbnailPath": "Gallery/f3a8e2d4-....thumb.png", + "regions": [ + { "regionId": "body", "color": "#FFB347" }, + { "regionId": "ears", "color": "#FF6961" } + ] +} +``` + +`regions[]` lets the gallery reopen an artwork for further edits in a future version (out of scope v1, but the schema reserves the field now to avoid migration later). + +Paths are **relative** to `persistentDataPath`. Never store absolute paths — they change between OS updates on some platforms. + +--- + +## 29. Boot & Error Handling + +``` +AppBoot.StartAsync() + ├─ try Addressables.InitializeAsync() + │ fail → show "Tap to retry" splash + ├─ try preload palette + UI sounds (Addressables labels) + │ fail → log + continue (non-fatal) + ├─ try _persistence.LoadAsync() + │ fail → start with empty progression (don't crash) + ├─ _scenes.LoadAsync("MainMenu") + └─ done +``` + +Toddler-mode error UI: +- One large smiling icon. +- One big "tap" button. +- No text, no error codes. +- A small upper-right gear opens a parent-only diagnostic screen (long-press 3 s to unlock). + +--- + +## 30. Setup Checklist (new dev, day one) + +1. Open `Bus Game.sln` (color book lives in same repo / Unity project per plan). +2. Verify Addressables groups exist: `Drawings_*`, `Palettes`, `Audio_*`. +3. Open `Boot.unity` → confirm `RootLifetimeScope` references the right configs. +4. Open `ColorBook.unity` → confirm `ColorBookLifetimeScope._installers[]` is fully populated. +5. Hit Play from `Boot.unity` (entry scene). Never start mid-flow — DI parent scope must exist. +6. To author a new drawing: duplicate `Animals/elephant/`, edit `Template.asset` (pieces + regions), add to the appropriate Addressables group. +7. Run `Tests > EditMode` and `Tests > PlayMode` before pushing. + +--- + +## 31. Quick Reference — Class ↔ Layer ↔ Asmdef + +| Class | Layer | Asmdef | +|---|---|---| +| `IDrawingTemplate`, `ShapePieceDTO`, `ColorRegionDTO` | Core | `Darkmatter.Core` | +| `ICommand`, `IUndoStack` | Core | `Darkmatter.Core` | +| `BoundedUndoStack` | Libs | `Darkmatter.Lib.CommandStack` | +| `AddressableAssetProviderService` | Services | `Darkmatter.Services.Assets` | +| `FileGalleryService` | Services | `Darkmatter.Services.Gallery` | +| `RenderTextureCaptureService` | Services | `Darkmatter.Services.Capture` | +| `ColoringController`, `PaintRegionCommand` | Features | `Darkmatter.Features.Coloring` | +| `ShapeBuilderController`, `ShapePieceView` | Features | `Darkmatter.Features.ShapeBuilder` | +| `HistoryController` | Features | `Darkmatter.Features.History` | +| `ColorBookFlowController` | Features | `Darkmatter.Features.ColorBookFlow` | +| `GalleryPresenter`, `GalleryGridView` | Features | `Darkmatter.Features.ArtBook` | +| `ColorBookLifetimeScope`, `AppBoot` | App | `Darkmatter.App` | + +If a class's natural home doesn't match its asmdef, the architecture is bent — fix the placement, don't add a reference.