Merge pull request 'savya' (#1) from savya into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-05-27 09:29:20 +02:00
29 changed files with 400 additions and 690 deletions

View File

@@ -1,6 +1,4 @@
using UnityEngine;
namespace Darkmatter.Core
namespace Darkmatter.Core.Contracts.Features.History
{
public interface ICommand
{

View File

@@ -1,12 +0,0 @@
using System;
using UnityEngine;
namespace Darkmatter.Core.Contracts.Services.Inputs
{
public interface IInputReader
{
Vector2 TouchPosition { get; }
event Action OnTouchStart;
event Action OnTouchEnd;
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 882d89c37a9f543c09ebe4b15395da7d

View File

@@ -1,6 +1,6 @@
{
"name": "Features.PaperRig",
"rootNamespace": "Darkmatter.Features.PaperRig",
"name": "Features.History",
"rootNamespace": "Darkmatter.Features.History",
"references": [
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: c54b3e6267ecb41b68cc06079d4b39f2
guid: 3aa6224adf551496497bf0c866e704b5
AssemblyDefinitionImporter:
externalObjects: {}
userData:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 588d1c926497b491c96d2f405876b176
guid: 9cc86805d75dc4f3781d17d7c65ac1c8
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@@ -0,0 +1,15 @@
using Darkmatter.Core.Contracts.Features.History;
using Darkmatter.Libs.Installers;
using UnityEngine;
using VContainer;
namespace Darkmatter.Features.History
{
public class HistoryServiceModule : MonoBehaviour,IServiceModule
{
public void Register(IContainerBuilder builder)
{
builder.Register<IUndoStack, UndoStack>(Lifetime.Singleton);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 45c42e41a28d34b01a364a3c2631ba73

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: f8c5a5b38d3aa43adae4dd7df1b8184c
guid: 352fce035805a47c4aec94d870adea6d
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@@ -0,0 +1,47 @@
using System.Collections.Generic;
using Darkmatter.Core.Contracts.Features.History;
namespace Darkmatter.Features.History
{
public sealed class UndoStack : IUndoStack
{
private const int Capacity = 20;
private readonly LinkedList<ICommand> _undo = new();
private readonly Stack<ICommand> _redo = new();
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!.Value;
_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();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1a9ed946cf48e4bd582992ca4ca662a3

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: f555e5ed9c45e44cd804a598d08efc6c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: f26c384b6ed9f482b8b3f325ba5e06f5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,15 +0,0 @@
using System;
using Darkmatter.Core.Contracts.Features.Paper;
using UnityEngine;
namespace Darkmatter.Features.PaperRig
{
[Serializable]
public class PaperRig : IPaperRig
{
[SerializeField] private Camera artCamera;
[SerializeField] private RectTransform displayRect;
public Camera ArtCamera => artCamera;
public RectTransform DisplayRect => displayRect;
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: fd7db17694325451c8441d824b221199

View File

@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 0156c96d959344bfbf0c1212ea68d473
timeCreated: 1779859615

View File

@@ -1,45 +0,0 @@
using Darkmatter.Core.Contracts.Features.Paper;
using Darkmatter.Core.Contracts.Services.Camera;
using UnityEngine;
using CameraType = Darkmatter.Core.Enums.Services.Camera.CameraType;
namespace Darkmatter.Features.PaperRig.Darkmatter.Code.Features.PaperRig.Input
{
public class ArtInputBridge : IArtInputBridge
{
private readonly IPaperRig _paperRig;
private ICameraService _cameraService;
public ArtInputBridge(IPaperRig paperRig, ICameraService cameraService)
{
_paperRig = paperRig;
_cameraService = cameraService;
}
public bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos)
{
var rectT = _paperRig.DisplayRect;
var uiCamera = _cameraService.GetCamera(CameraType.UICamera);
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
rectT, screenPos, uiCamera, out var local))
{
artWorldPos = default;
return false;
}
var rect = rectT.rect;
var uv = new Vector2(
(local.x - rect.xMin) / rect.width,
(local.y - rect.yMin) / rect.height);
if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1)
{
artWorldPos = default;
return false;
}
artWorldPos = _paperRig.ArtCamera.ViewportToWorldPoint(uv);
return true;
}
}
}

View File

@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 0337c48e57934443887873de8e382d4e
timeCreated: 1779859625

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: ecba85ae080a44ef9b1a1d55f3f86659
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,20 +0,0 @@
using Darkmatter.Core.Contracts.Features.Paper;
using Darkmatter.Features.PaperRig.Darkmatter.Code.Features.PaperRig.Input;
using Darkmatter.Libs.Installers;
using UnityEngine;
using VContainer;
using VContainer.Unity;
namespace Darkmatter.Features.PaperRig
{
public class PaperRigServiceModule : MonoBehaviour, IServiceModule
{
[SerializeField] private PaperRig paperRig;
public void Register(IContainerBuilder builder)
{
builder.RegisterComponent<IPaperRig>(paperRig);
builder.Register<IArtInputBridge, ArtInputBridge>(Lifetime.Singleton);
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ef7eaa67fc66d48c88c8ec12d27f9b14

View File

@@ -1,19 +0,0 @@
using Darkmatter.Core.Contracts.Services.Inputs;
using Darkmatter.Libs.Installers;
using Darkmatter.Services.Inputs.Readers;
using UnityEngine;
using VContainer;
using VContainer.Unity;
namespace Darkmatter.Services.Inputs
{
public class InputServiceModule : MonoBehaviour, IServiceModule
{
[SerializeField] private InputReaderSO inputReaderSO;
public void Register(IContainerBuilder builder)
{
builder.RegisterComponent<IInputReader>(inputReaderSO);
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1b23ca8ea5ee647ddba0712811953811

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 2e8e28c4942b5410d962cbff40ac302f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,47 +0,0 @@
using System;
using Darkmatter.Core.Contracts.Services.Inputs;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Darkmatter.Services.Inputs.Readers
{
[CreateAssetMenu(menuName = "Darkmatter/Inputs/New Input Reader")]
public class InputReaderSO : ScriptableObject, IInputReader, GameInputs.IPlayerActions
{
public Vector2 TouchPosition { get; private set; }
public event Action OnTouchStart;
public event Action OnTouchEnd;
private GameInputs _gameInputActions;
private void OnEnable()
{
_gameInputActions = new GameInputs();
_gameInputActions.Player.SetCallbacks(this);
_gameInputActions.Enable();
}
public void OnTouchPosition(InputAction.CallbackContext context)
{
TouchPosition = context.ReadValue<Vector2>();
}
public void OnTouched(InputAction.CallbackContext context)
{
if(context.started)
OnTouchStart?.Invoke();
else if(context.canceled)
OnTouchEnd?.Invoke();
}
private void OnDisable()
{
if (_gameInputActions != null)
{
_gameInputActions.Disable();
_gameInputActions.Dispose();
_gameInputActions = null;
}
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c7a7f36a3426c43b7a1ceeaa853bdc3e

View File

@@ -247,51 +247,6 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &147402014
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 147402015}
- component: {fileID: 147402016}
m_Layer: 0
m_Name: InputServiceModules
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &147402015
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 147402014}
serializedVersion: 2
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 16.30939, y: 8.32207, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 274737044}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &147402016
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 147402014}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1b23ca8ea5ee647ddba0712811953811, type: 3}
m_Name:
m_EditorClassIdentifier: Services.Inputs::Darkmatter.Services.Inputs.InputServiceModule
inputReaderSO: {fileID: 11400000, guid: f9b7ed848ae3b4036923bbbb2f77fe40, type: 2}
--- !u!1 &274737043
GameObject:
m_ObjectHideFlags: 0
@@ -322,7 +277,6 @@ Transform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 292698384}
- {fileID: 147402015}
m_Father: {fileID: 329578012}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &292698383
@@ -417,12 +371,11 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Darkmatter.App::GameLifetimeScope
parentReference:
TypeName: RootLifetimeScope
TypeName:
autoRun: 1
autoInjectGameObjects: []
serviceModules:
- {fileID: 292698385}
- {fileID: 147402016}
--- !u!1 &519420028
GameObject:
m_ObjectHideFlags: 0

View File

@@ -282,134 +282,6 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &942391588
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 942391592}
- component: {fileID: 942391591}
- component: {fileID: 942391589}
m_Layer: 0
m_Name: ArtCamera
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &942391589
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 942391588}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
m_RenderShadows: 1
m_RequiresDepthTextureOption: 2
m_RequiresOpaqueTextureOption: 2
m_CameraType: 0
m_Cameras: []
m_RendererIndex: -1
m_VolumeLayerMask:
serializedVersion: 2
m_Bits: 1
m_VolumeTrigger: {fileID: 0}
m_VolumeFrameworkUpdateModeOption: 2
m_RenderPostProcessing: 0
m_Antialiasing: 0
m_AntialiasingQuality: 2
m_StopNaN: 0
m_Dithering: 0
m_ClearDepth: 1
m_AllowXRRendering: 1
m_AllowHDROutput: 1
m_UseScreenCoordOverride: 0
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_TaaSettings:
m_Quality: 3
m_FrameInfluence: 0.1
m_JitterScale: 1
m_MipBias: 0
m_VarianceClampScale: 0.9
m_ContrastAdaptiveSharpening: 0
m_Version: 2
--- !u!20 &942391591
Camera:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 942391588}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 1
m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0}
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
m_Iso: 200
m_ShutterSpeed: 0.005
m_Aperture: 16
m_FocusDistance: 10
m_FocalLength: 50
m_BladeCount: 5
m_Curvature: {x: 2, y: 11}
m_BarrelClipping: 0.25
m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 1000
field of view: 60
orthographic: 1
orthographic size: 5
m_Depth: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 23
m_RenderingPath: -1
m_TargetTexture: {fileID: 8400000, guid: 00ba58b6f31d046c1849805f2cb9a57d, type: 2}
m_TargetDisplay: 0
m_TargetEye: 3
m_HDR: 1
m_AllowMSAA: 1
m_AllowDynamicResolution: 0
m_ForceIntoRT: 0
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!4 &942391592
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 942391588}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -10}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1224714931
GameObject:
m_ObjectHideFlags: 0
@@ -461,6 +333,51 @@ MonoBehaviour:
autoInjectGameObjects: []
serviceModules:
- {fileID: 1594774441}
- {fileID: 1551649429}
--- !u!1 &1551649427
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1551649428}
- component: {fileID: 1551649429}
m_Layer: 0
m_Name: HistoryServiceModule
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1551649428
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1551649427}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1965442263}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1551649429
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1551649427}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 45c42e41a28d34b01a364a3c2631ba73, type: 3}
m_Name:
m_EditorClassIdentifier: Features.History::Darkmatter.Features.History.HistoryServiceModule
--- !u!1 &1594774439
GameObject:
m_ObjectHideFlags: 0
@@ -506,8 +423,8 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Features.PaperRig::Darkmatter.Features.PaperRig.PaperRigServiceModule
paperRig:
artCamera: {fileID: 942391591}
displayRect: {fileID: 2081960987}
artCamera: {fileID: 0}
displayRect: {fileID: 0}
--- !u!1 &1965442262
GameObject:
m_ObjectHideFlags: 0
@@ -538,6 +455,7 @@ Transform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1594774440}
- {fileID: 1551649428}
m_Father: {fileID: 1224714932}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &2069155637
@@ -588,12 +506,12 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 0cd44c1031e13a943bb63640046fad76, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.CanvasScaler
m_UiScaleMode: 0
m_UiScaleMode: 1
m_ReferencePixelsPerUnit: 100
m_ScaleFactor: 1
m_ReferenceResolution: {x: 800, y: 600}
m_ReferenceResolution: {x: 1920, y: 1080}
m_ScreenMatchMode: 0
m_MatchWidthOrHeight: 0
m_MatchWidthOrHeight: 0.5
m_PhysicalUnit: 3
m_FallbackScreenDPI: 96
m_DefaultSpriteDPI: 96
@@ -608,7 +526,7 @@ Canvas:
m_GameObject: {fileID: 2069155637}
m_Enabled: 1
serializedVersion: 3
m_RenderMode: 0
m_RenderMode: 1
m_Camera: {fileID: 0}
m_PlaneDistance: 100
m_PixelPerfect: 0
@@ -616,7 +534,7 @@ Canvas:
m_OverrideSorting: 0
m_OverridePixelPerfect: 0
m_SortingBucketNormalizedSize: 0
m_VertexColorAlwaysGammaSpace: 0
m_VertexColorAlwaysGammaSpace: 1
m_UseReflectionProbes: 0
m_AdditionalShaderChannelsFlag: 0
m_UpdateRectTransformForStandalone: 0
@@ -634,8 +552,7 @@ RectTransform:
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0, y: 0, z: 0}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2081960987}
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -643,84 +560,11 @@ RectTransform:
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0, y: 0}
--- !u!1 &2081960986
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 2081960987}
- component: {fileID: 2081960989}
- component: {fileID: 2081960988}
m_Layer: 5
m_Name: ArtCanvas
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &2081960987
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2081960986}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 2069155641}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2081960988
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2081960986}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1344c3c82d62a2a41a3576d8abb8e3ea, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.RawImage
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Texture: {fileID: 8400000, guid: 00ba58b6f31d046c1849805f2cb9a57d, type: 2}
m_UVRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
--- !u!222 &2081960989
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2081960986}
m_CullTransparentMesh: 1
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 619394802}
- {fileID: 942391592}
- {fileID: 2069155641}
- {fileID: 590523275}
- {fileID: 1224714932}

491
Readme.md
View File

@@ -98,7 +98,7 @@ Assets/Darkmatter/
│ ├── Compatibility/
│ │ └── IsExternalInit.cs (C#9 init shim for older runtimes)
│ ├── Contracts/
│ │ ├── Paper/ ← misplaced empty folder — should be Contracts/Features/Paper/ (delete or move)
│ │ ├── Paper/ ← misplaced empty folder — move to Contracts/Features/Paper/ when IPaperSurface lands
│ │ └── Services/
│ │ ├── Assets/IAssetProviderService.cs
│ │ ├── Audio/IAudioService.cs, ISfxPlayer.cs
@@ -175,7 +175,7 @@ Rough landing order for ColorBook scene to be playable:
| Path | Role |
|---|---|
| `Core/Contracts/Features/Paper/IPaperRig.cs`, `IArtInputBridge.cs` | Paper rig contracts |
| `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 |
@@ -190,9 +190,9 @@ Rough landing order for ColorBook scene to be playable:
| `Core/Data/Dynamic/Features/Signals/` (DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, ArtworkCapturedSignal, ArtworkSavedSignal) | Cross-feature signal structs |
| `Core/Enums/Services/Camera/CameraType.cs` | Add `ArtCamera` enum value to existing file |
| `Libs/CommandStack/` (+ `Libs.CommandStack.asmdef`) | Bounded undo/redo |
| `Services/Capture/` (+ `Services.Capture.asmdef`) | `RenderTextureCaptureService` reads `IPaperRig.Surface` |
| `Services/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 RT rig |
| `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 |
@@ -283,59 +283,87 @@ Failures show a child-friendly retry screen; never crash.
## 7. Rendering Strategy
**RT-as-paper.** ArtCamera renders the drawing world to an offscreen `RenderTexture`. A Canvas `RawImage` displays that RT. HUD lives on the same Canvas, above the RawImage. The RT *is* the paper — same fixed coordinate system on every device.
**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.
```
┌──────────────────────────────────────────────────────┐
UICanvas (Screen-Space - Camera, UICamera) │
┌────────────────────────────────────┐
│ RawImage (AspectRatioFitter 1:1) │ [HUD]
│ │ └─ texture = PaperRig.Surface │ palette
│ │ undo etc
│ │ ArtCamera renders → here │
└────────────────────────────────────┘
└──────────────────────────────────────────────────────
│ rendered offscreen
ArtCamera (orthographicSize fixed, aspect = 1f)
culling mask: Artwork, PaperBackground, Effects
target texture: PaperRig.Surface (2048×2048 ARGB32)
┌──────────────────────────────────────────────────────────
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 | Type | Culling Mask | Render Target | Purpose |
| Camera | Render mode | Culling Mask | Render Target | Purpose |
|---|---|---|---|---|
| `ArtCamera` | Orthographic, **fixed ortho size**, aspect = 1 | `Artwork`, `PaperBackground`, `Effects` | `PaperRig.Surface` (offscreen RT) | Renders the drawing world. Never sees the screen. |
| `UICamera` | Camera (Screen-Space Camera) | `UI` | Screen | Displays the paper RawImage + HUD. |
| `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 |
|---|---|
| `Artwork` | Drawing region sprites, shape pieces, paper bg, all in ArtCamera world |
| `Effects` | Particle bursts, sparkles — also in ArtCamera world (so they're captured into the PNG) |
| `UI` | All Canvas elements (RawImage paper + HUD) |
| `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 RT-as-paper
### Why full UI
| Need | Choice | Why |
|---|---|---|
| Per-region tap-to-fill | Sprites + `PolygonCollider2D` in ArtCamera world; tapped via `IArtInputBridge` | Coordinate system is fixed (RT space). One `Physics2D.OverlapPoint` call after screen→art-world conversion. |
| Drag/drop shape pieces | Sprites + Physics2D in art world | Same fixed bounds on every device — no per-aspect tray layout. |
| Capture to PNG | `RT → Texture2D → PNG` | The RT *is* the saved image. No camera state override, no compositing pass, no determinism worries. |
| Multi-resolution support | `AspectRatioFitter (1:1, FitInParent)` on the RawImage | The "fit camera" problem reduces to a single Canvas property. Letterbox/pillarbox = whatever the Canvas around the RawImage looks like. |
| Color palette, buttons | Canvas above the RawImage | Anchors handle aspect ratios. Buttons + ScrollRect free. |
| Drawing catalog grid | Canvas | `GridLayoutGroup` + ScrollRect, async thumbnail loader. |
| 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 artwork world is **screen-size-independent by construction.** Author every drawing in a fixed 2048×2048 design rect (or 20×20 world units at PPU=100). Pieces, regions, snap radii, slot positions — all expressed in this space and never scaled at runtime. Different screen sizes only change how the *RawImage* is laid out on the Canvas; the contents of the RT stay identical.
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.
If you need a backdrop (wood/cloth behind the paper), it's a sibling Canvas Image *outside* the RawImage, sized to fill the screen. The RT itself has a transparent or paper-colored background.
`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.
---
@@ -361,17 +389,22 @@ public interface IDrawingTemplate {
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 Sprite Sprite { get; } // assigned to Image.sprite
public Vector2 SlotAnchoredPosition { get; } // canvas units, relative to SlotsParent
public Vector2 SlotSizeDelta { get; } // canvas units — target size when snapped
public float SlotRotationZ { get; } // degrees, local rotation when snapped
public float SnapRadius { get; } // canvas units; ~80120 for toddlers
public float PreviewRadius { get; } // canvas units; ~2× snap radius
}
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
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.
}
```
@@ -394,31 +427,25 @@ public readonly struct PaintCommandDTO {
}
```
### Paper (RT rig + input bridge)
### Paper (canvas surface root)
> Contracts live in `Darkmatter.Core.Contracts.Features.Paper`. Files at `Core/Contracts/Features/Paper/`.
```csharp
namespace Darkmatter.Core.Contracts.Features.Paper;
public interface IPaperRig {
Camera ArtCamera { get; } // offscreen, targetTexture = Surface
RenderTexture Surface { get; } // 2048×2048 ARGB32; the paper itself
Transform PaperRoot { get; } // parent of regions/pieces/paper bg
Vector2 DesignSize { get; } // world units, e.g. (20, 20)
Rect DesignRect { get; } // centered on origin, DesignSize wide
}
public interface IArtInputBridge {
// Converts a screen-space pointer (Input System) to art-world coords
// inside the RT. Returns false if the pointer is outside the RawImage.
bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos);
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)
}
```
- `IPaperRig` is implemented by `PaperRig : MonoBehaviour` in the ColorBook scene.
- `IArtInputBridge` does the screen → RawImage local → UV → `ArtCamera.ViewportToWorldPoint` chain.
- All consumers (Coloring, ShapeBuilder, Capture, particle effects) read these from DI; they never touch `Screen.width/height` directly.
- Implemented by `PaperSurface : MonoBehaviour` in the ColorBook scene (sits on the `PaperPanel` GameObject).
- All paper-side features (`Coloring`, `ShapeBuilder`, `Capture`) parent their UI under one of these `RectTransform` slots and use canvas-local coords throughout.
- No `IPaperRig`. No `IArtInputBridge`. Input runs through Unity's `EventSystem` directly on the UI children.
### History
@@ -444,7 +471,7 @@ public interface IUndoStack {
### Gallery & Capture
> `IGalleryService` is a Service contract → `Darkmatter.Core.Contracts.Services.Gallery`. `SavedArtworkDTO` is a runtime data struct → `Darkmatter.Core.Data.Dynamic.Features.Gallery`. `ICaptureService` → `Darkmatter.Core.Contracts.Services.Capture`.
> `IGalleryService` is a Service contract → `Darkmatter.Core.Contracts.Services.Gallery`. `SavedArtworkDTO` is a runtime data struct → `Darkmatter.Core.Data.Dynamic.Features.Gallery`. `ICaptureService` → `Darkmatter.Core.Contracts.Services.Capture`. `CaptureAsync` takes no args — implementation owns the `CaptureCamera` reference and renders the `PaperUI` layer to a one-shot RT.
```csharp
namespace Darkmatter.Core.Data.Dynamic.Features.Gallery;
@@ -469,13 +496,13 @@ public interface IGalleryService {
namespace Darkmatter.Core.Contracts.Services.Capture;
public interface ICaptureService {
// No camera or paperBg args — capture reads directly from IPaperRig.Surface.
// Dimensions inherited from the RT; no resize, no compositing.
// Allocates a temp RT, renders the CaptureCamera once (PaperUI layer only),
// ReadPixels into a Texture2D, encodes PNG, releases the RT.
UniTask<byte[]> CaptureAsync();
}
```
`ICaptureService` resolves `IPaperRig` via DI and reads `Surface` directly. The paper background is already baked into the RT because it sits in `PaperRoot` under the ArtCamera. No special compositing pass is ever needed.
`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
@@ -519,25 +546,27 @@ public readonly struct ArtworkSavedSignal {
### `Paper`
- Scene-scoped infrastructure. Lives in `ColorBook.unity` only.
- Owns `PaperRig` (MonoBehaviour) — exposes `ArtCamera`, the `RenderTexture Surface`, `PaperRoot` transform, and the design rect.
- Owns `ArtInputBridge` — converts pointer screen positions to art-world coords inside the RT.
- Registered in `ColorBookLifetimeScope` via `PaperRigModule`. All other features in the scene resolve `IPaperRig` / `IArtInputBridge` from DI.
- Lifetime is scene-scoped: created on scene load, destroyed on scene unload. RT is allocated in `Awake`, released in `OnDestroy`.
- Owns `PaperSurface` (MonoBehaviour) on the `PaperPanel` GameObject. Implements `IPaperSurface`, exposes `Root`, `SlotsParent`, `PiecesParent`, `RegionsParent`, `DesignHalfSize`.
- Registered in `ColorBookLifetimeScope` via `PaperSurfaceModule`. Other features resolve `IPaperSurface` from DI when they need to parent their UI under one of the role-specific `RectTransform`s.
- No render-target ownership. No input bridge. No coordinate conversion. The paper *is* the canvas children — nothing more.
### `ShapeBuilder`
- Listens to `DrawingSelectedSignal`.
- Loads template via `IDrawingTemplateLoader`, parents shape pieces under `IPaperRig.PaperRoot` at off-slot positions inside the design rect.
- Per piece: drag with `ShapePieceView` (sprite + collider). Pointer events go through `IArtInputBridge.TryScreenToArtWorld`. On drop, check distance to `SlotPosition` against `SnapRadius`; if within, snap and lock.
- Loads template, spawns UI `Image` per piece under either `IPaperSurface.PiecesParent` or the HUD tray (depending on the FSM start state — usually tray).
- Each piece has `IBeginDragHandler` / `IDragHandler` / `IEndDragHandler` plus a per-piece `ShapePieceFsm`. Drag updates `RectTransform.anchoredPosition` directly from `PointerEventData` (converted to canvas-local via `RectTransformUtility.ScreenPointToLocalPointInRectangle`).
- On entering preview radius of the matching slot: reactive `Lerp` of `anchoredPosition` / `sizeDelta` / `localRotation` toward `SlotMarker`'s `RectTransform`. Drives off pointer distance, not time.
- On `OnEndDrag` inside snap radius: DOTween ease-out to exact slot pose, disable input. Otherwise DOTween back to tray slot.
- Fires `ShapeAssembledSignal` when all pieces locked.
### `Coloring`
- Listens to `ShapeAssembledSignal`.
- Spawns one `ColorRegionView` per `ColorRegionDTO` under `IPaperRig.PaperRoot` (sprite + polygon collider on `Artwork` layer).
- Spawns one UI `Image` per `ColorRegionDTO` under `IPaperSurface.RegionsParent`. Each region's `Image.alphaHitTestMinimumThreshold = 0.5f` so 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`).
- On pointer down: `IArtInputBridge.TryScreenToArtWorld(screenPos, out var artPos)``Physics2D.OverlapPoint(artPos, artworkMask)` → if hit, build `PaintRegionCommand(regionId, oldColor, newColor)`, push to `IUndoStack`.
- Command sets `SpriteRenderer.color` on undo/redo.
- Controller builds `PaintRegionCommand(regionId, oldColor, newColor)` and pushes to `IUndoStack`.
- Command sets `Image.color` on undo/redo.
- Fires `ColorAppliedSignal` for SFX / sparkle effects.
### `History`
@@ -550,7 +579,7 @@ public readonly struct ArtworkSavedSignal {
### `Capture`
- Bound to the "Capture" button.
- Calls `ICaptureService.CaptureAsync()` → PNG bytes. Capture reads `IPaperRig.Surface` directly; no camera or paper-bg args needed.
- Calls `ICaptureService.CaptureAsync()` → PNG bytes. Implementation owns the disabled `CaptureCamera`, sets its `targetTexture` to a temp RT, calls `Render()` once, reads pixels, releases.
- Hands bytes to `IGalleryService.SaveAsync(...)`.
- Fires `ArtworkCapturedSignal` then `ArtworkSavedSignal`.
- Shows a quick "saved!" toast with a thumbnail of the new entry.
@@ -656,7 +685,7 @@ persistentDataPath/Gallery/
## 12. Capture Pipeline
With the RT-paper-rig, capture has no setup phase. The RT is already the final image at all times.
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]
@@ -664,13 +693,17 @@ With the RT-paper-rig, capture has no setup phase. The RT is already the final i
ICaptureService.CaptureAsync()
├─ rt = _paperRig.Surface (already populated each frame)
├─ 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(rt.width, rt.height, RGBA32, false)
├─ tex = new Texture2D(2048, 2048, RGBA32, false)
├─ tex.ReadPixels(full rect, 0, 0); tex.Apply()
├─ RenderTexture.active = prev
├─ bytes = tex.EncodeToPNG() (on worker via UniTask.RunOnThreadPool)
├─ RenderTexture.ReleaseTemporary(rt)
├─ bytes = tex.EncodeToPNG() (on worker via UniTask.RunOnThreadPool)
├─ Object.Destroy(tex)
└─ return bytes
@@ -686,10 +719,11 @@ EventBus.Publish(new ArtworkSavedSignal(dto))
Notes:
- HUD never appears in capture because the HUD is on `UICamera` / Canvas — it is physically in a different render path. The RT only ever sees `ArtCamera`'s output.
- Paper background is a sprite parented under `IPaperRig.PaperRoot` and is rendered into the RT every frame — already baked in.
- Saved PNGs are byte-comparable across devices because the RT dimensions and ArtCamera matrix never depend on screen size.
- `CaptureAsync` is safe to call repeatedly — no camera state is ever mutated.
- HUD never appears in capture because `CaptureCamera.cullingMask` excludes `HUDUI`. Layer mask, not coincidence — even if you accidentally parent a HUD element under `PaperPanel`, putting it on the wrong layer keeps it out.
- Paper background is just an `Image` on `PaperUI`. Already in the right layer; no special compositing.
- Saved PNGs are 2048×2048 on every device. `CaptureCamera` has fixed `orthographicSize` and aspect, independent of screen size.
- `CaptureAsync` is safe to call repeatedly. The CaptureCamera's transform / projection are set once at scene start and never modified.
- The temp RT is allocated via `RenderTexture.GetTemporary` so successive captures don't leak GPU memory.
---
@@ -733,7 +767,7 @@ 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.
- **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.
@@ -1101,48 +1135,67 @@ 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);
All math is in canvas-local space — `anchoredPosition`, `sizeDelta`, `localRotation`. No world coords.
if (d <= _piece.SnapRadius) {
```csharp
// 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 <= _piece.SnapRadius * 1.5f) {
} else if (d <= _cfg.SnapRadius * 1.5f) {
// Toddler grace zone — snap anyway, play happy sound
SnapToSlot();
_audio.PlayOneShot(_clips.NiceTry);
_audio.PlayOneShot(SfxId.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));
_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:
1. **Reparent** the piece from `TrayPanel` (HUD canvas) to `IPaperSurface.PiecesParent` (PaperCanvas) so it'll be included in capture. `worldPositionStays: false` because we want the new `anchoredPosition` to be relative to the new parent, not the world.
2. **Three simultaneous tweens** — position, size, rotation. Use `DOAnchorPos`, `DOSizeDelta`, `DOLocalRotateQuaternion`. They start together so the piece visually snaps as one motion.
3. **`SnapRadius` is in canvas units** (from `ShapeBuilderConfig`, e.g. 80120), not world units. Same `CanvasScaler` reference resolution across devices = same hit feel.
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.
Canvas-only — order is sibling index inside `PaperPanel` (front-most is last in hierarchy). No URP 2D sorting layers.
| 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) |
`PaperPanel` children (bottom → top):
Canvas HUD lives on `UICamera` (Overlay), never sorts against `ArtCamera`. Capture renders only `ArtCamera`'s layers → HUD physically cannot leak into saved PNG.
```
PaperPanel
├─ BackgroundImage (paper texture)
├─ RegionsPanel (colorable region Images)
├─ SlotsPanel (slot outline Images — under pieces so snapped pieces hide them)
├─ PiecesPanel (draggable / snapped piece Images)
└─ EffectsPanel (sparkle / particle UI for completion FX, optional)
```
`HUDCanvas` is a separate Canvas at a higher sorting order (or Screen Space - Overlay). It never sorts against `PaperCanvas` because they're different canvases.
`CaptureCamera` renders only the `PaperUI` layer. The HUD physically cannot leak into the saved PNG because of the culling mask, regardless of sibling order.
> If you ever need particles outside the canvas (e.g. confetti falling across the full screen on completion), use a separate Canvas above the HUD with its own sub-tree of UI particles. Don't add `ParticleSystem`s under PaperPanel — they don't render in UI canvases without `UIParticleSystem` or similar packages.
---
@@ -1209,13 +1262,13 @@ Toddler-mode error UI:
| Class | Layer | Asmdef |
|---|---|---|
| `IDrawingTemplate`, `ShapePieceDTO`, `ColorRegionDTO` | Core | `Core` |
| `IPaperRig`, `IArtInputBridge` | 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` |
| `PaperRig`, `ArtInputBridge`, `PaperRigModule` | Features | `Features.Paper` |
| `PaperSurface`, `PaperSurfaceModule` | Features | `Features.Paper` |
| `ColoringController`, `PaintRegionCommand` | Features | `Features.Coloring` |
| `ShapeBuilderController`, `ShapePieceView` | Features | `Features.ShapeBuilder` |
| `HistoryController` | Features | `Features.History` |
@@ -1305,26 +1358,18 @@ public interface ICaptureService {
}
```
#### `IPaperRig` *(Core/Contracts/Features/Paper — planned)*
Shared art rig. The single source of truth for everything that lives in the drawing world.
#### `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.
```csharp
public interface IPaperRig {
Camera ArtCamera { get; } // offscreen, targetTexture = Surface
RenderTexture Surface { get; } // 2048×2048 ARGB32 — the paper itself
Transform PaperRoot { get; } // parent of regions/pieces/paper bg
Vector2 DesignSize { get; } // world units, e.g. (20, 20)
Rect DesignRect { get; } // centered on origin
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
}
```
#### `IArtInputBridge` *(Core/Contracts/Features/Paper — planned)*
Converts screen-space pointer coords to art-world coords inside the RT.
```csharp
public interface IArtInputBridge {
bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos);
}
```
Returns `false` when the pointer is outside the displayed RawImage rect (toddler tapped the HUD or backdrop). Every art-world raycast goes through this.
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.
@@ -1386,9 +1431,16 @@ Implements `IGalleryService`.
- **Delete flow:** delete png + thumb + json; missing files ignored (idempotent).
#### `RenderTextureCaptureService` *(Services/Capture — planned)*
Implements `ICaptureService`.
- **Steps:** allocate `RenderTexture(width, height, 0, ARGB32)` → bind to `artCamera.targetTexture``artCamera.Render()``ReadPixels` into `Texture2D` → composite `paperBackground` underneath (single shader blit) → `EncodeToPNG` → release RT + textures.
- **Threading:** PNG encode happens on a `UniTask.RunOnThreadPool` to avoid hitching the main thread on tablets.
Implements `ICaptureService`. Drives the scene's disabled `CaptureCamera` once per capture.
```csharp
// fields:
// Camera _captureCam (scene-bound, registered via CaptureServiceModule)
// int _surfaceSize = 2048
// IPathProvider _paths (only if you want to expose paths — usually not needed here)
```
- **Steps:** `RenderTexture.GetTemporary(size, size, 0, ARGB32)` → set `_captureCam.targetTexture = rt``_captureCam.Render()``ReadPixels` into a `Texture2D` → null out the target texture → `RenderTexture.ReleaseTemporary(rt)``EncodeToPNG` → return bytes.
- **Threading:** PNG encode happens on `UniTask.RunOnThreadPool` to avoid hitching the main thread on tablets.
- **Camera setup:** `_captureCam` has `cullingMask = PaperUI`, `clearFlags = SolidColor` (white or paper color), `orthographicSize` and `aspect` cloned from `UICamera` once at scene start. Stays disabled — `Render()` is the only call site.
- **Sizing:** default 2048², overridable. Capped at device max texture size.
#### `JsonPersistenceService` *(Services/Persistence — planned; today `Libs/PlayerPrefs` covers small-key state)*
@@ -1508,9 +1560,10 @@ public interface IDrawingCatalogView {
#### `ShapeBuilderController` *(Systems)*
Spawns shape pieces for the selected template, tracks snap progress, fires `ShapeAssembledSignal` when complete.
```csharp
// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory, IEventBus _bus, ShapeBuilderConfig _cfg
// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory,
// IPaperSurface _paper, TrayPanel _tray, IEventBus _bus, ShapeBuilderConfig _cfg
public sealed class ShapeBuilderController : IDisposable {
public IReadOnlyList<ShapePieceView> Active { get; }
public IReadOnlyList<ShapePieceUI> Active { get; }
public UniTask BuildAsync(string templateId); // load template, spawn pieces in tray
public void Reset(); // clear, unsubscribe
}
@@ -1519,97 +1572,101 @@ public sealed class ShapeBuilderController : IDisposable {
```
- **Internal:** counts `PieceSnappedSignal` against expected piece count.
#### `ShapePieceView : MonoBehaviour` *(Views)*
World-space draggable sprite with collider. Source for snap-or-return logic shown in section 26.
#### `ShapePieceUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler` *(UI)*
The UI Image that the toddler drags. Lives under `TrayPanel` while idle, reparents under `IPaperSurface.PiecesParent` when snapped.
```csharp
public sealed class ShapePieceView : MonoBehaviour {
public sealed class ShapePieceUI : MonoBehaviour,
IBeginDragHandler, IDragHandler, IEndDragHandler
{
public string PieceId { get; }
public bool IsLocked { get; }
public event Action<string> Snapped; // raised when piece locks into slot
public void Initialize(ShapePieceDTO dto, IInputReader input, IAudioService audio);
public RectTransform RectTransform { get; }
public event Action<string> Snapped;
public void Initialize(ShapePieceDTO dto, ShapePieceFsm fsm);
}
```
- **No public mutators** for position once locked — controller treats `IsLocked` as the source of truth.
- Handlers forward to `ShapePieceFsm` (`OnDragBegin / OnDrag(localPos) / OnDragEnd`).
- `OnDrag` converts `PointerEventData.position` to canvas-local via `RectTransformUtility.ScreenPointToLocalPointInRectangle` against the piece's parent rect.
- No collider, no Physics2D anywhere.
#### `ShapePieceFsm` *(Systems)*
Per-piece state machine using `Libs.FSM`. States: `InTray → Dragging → Preview → (Snapped | Returning)`.
```csharp
// 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 / localRotation` toward `_targetSlot`'s pose, driven by `1 - 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 (`anchoredPosition` from `TrayLayout`).
#### `SlotMarker : MonoBehaviour` *(UI)*
The outline `Image` on `IPaperSurface.SlotsParent` showing where a piece should snap. Its `RectTransform` directly *is* the target pose — `ShapePieceFsm` reads `anchoredPosition`, `sizeDelta`, `localRotation` from it.
```csharp
public sealed class SlotMarker : MonoBehaviour {
public string SlotId;
public RectTransform RectTransform => transform as RectTransform;
}
```
#### `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)*
Instantiates `ShapePieceView` prefabs from a pool. Avoids re-instantiating across "Next" cycles on the same template family.
Pool of `ShapePieceUI` GameObjects + their associated FSMs. Reused across template loads.
```csharp
public sealed class ShapePieceFactory {
public ShapePieceView Spawn(ShapePieceDTO dto, Transform parent);
public void Despawn(ShapePieceView view);
public ShapePieceUI Spawn(ShapePieceDTO dto, RectTransform parent);
public void Despawn(ShapePieceUI piece);
}
```
#### `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)*
The shared art rig — RT, offscreen camera, screen↔world bridge. Every other feature in the ColorBook scene resolves `IPaperRig` and `IArtInputBridge` from DI and never touches `Screen.*` or `Camera.*` directly.
A tiny feature. Just exposes the paper RectTransforms via DI so consumers don't `Find` them.
#### `PaperRig : MonoBehaviour, IPaperRig` *(Rig)*
Scene-bound component placed on a GameObject in `ColorBook.unity`. Owns the RT lifecycle.
#### `PaperSurface : MonoBehaviour, IPaperSurface` *(Surface)*
Scene-bound component placed on the `PaperPanel` GameObject in `ColorBook.unity`.
```csharp
// inspector fields:
// Camera _artCamera (Orthographic, aspect=1, fixed ortho size)
// Transform _paperRoot (parent of regions/pieces)
// Vector2 _designSize = (20, 20) (world units; matches 2048×2048 at PPU=100)
// int _surfaceSize = 2048 (RT side length, square)
// RectTransform _slotsParent
// RectTransform _piecesParent
// RectTransform _regionsParent
// float _designHalfSize = 1024f // half of 2048 reference resolution
public sealed class PaperRig : MonoBehaviour, IPaperRig {
public Camera ArtCamera => _artCamera;
public RenderTexture Surface => _surface;
public Transform PaperRoot => _paperRoot;
public Vector2 DesignSize => _designSize;
public Rect DesignRect => new(-_designSize / 2f, _designSize);
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;
}
```
- **Awake:** allocate `_surface = new RenderTexture(_surfaceSize, _surfaceSize, 0, ARGB32) { name = "PaperSurface" };` then `_surface.Create()` and `_artCamera.targetTexture = _surface; _artCamera.aspect = 1f; _artCamera.orthographicSize = _designSize.y / 2f;`.
- **OnDestroy:** `_surface.Release(); Object.Destroy(_surface);`.
- **No update logic** — the camera renders every frame automatically because `targetTexture` is set.
- **Important:** `_artCamera`'s `orthographicSize` and `aspect` are set once and never touched again. The RT contents are deterministic.
- No `Awake` / `OnDestroy` logic. 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).
#### `ArtInputBridge : MonoBehaviour, IArtInputBridge` *(Input)*
Lives on the same UI Canvas as the paper `RawImage`.
#### `PaperSurfaceModule : MonoBehaviour, IServiceModule` *(Installers)*
Scene-scoped installer. Dragged into `ColorBookLifetimeScope.sceneModules[]`.
```csharp
// inspector fields:
// RawImage _paperImage (the on-screen paper)
// RectTransform _paperRect (== _paperImage.rectTransform)
// Camera _uiCamera (Canvas event camera)
// IPaperRig _rig (injected via VContainer + IInjectable, or resolved in Start)
public bool TryScreenToArtWorld(Vector2 screenPos, out Vector2 artWorldPos) {
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
_paperRect, screenPos, _uiCamera, out var local)) {
artWorldPos = default; return false;
}
var rect = _paperRect.rect;
var uv = new Vector2(
(local.x - rect.xMin) / rect.width,
(local.y - rect.yMin) / rect.height);
if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1) {
artWorldPos = default; return false;
}
artWorldPos = _rig.ArtCamera.ViewportToWorldPoint(uv);
return true;
}
```
- Returns `false` when the toddler tapped outside the RawImage (HUD button area, backdrop, off-screen).
- Used by every feature that does world-space picking — `Coloring`, `ShapeBuilder`, and any future feature like stickers.
#### `PaperRigModule : MonoBehaviour, IServiceModule` *(Installers)*
Scene-scoped installer. Dragged onto `ColorBookLifetimeScope._installers[]`.
```csharp
// inspector fields:
// PaperRig _rig
// ArtInputBridge _bridge
// PaperSurface _surface
public void Register(IContainerBuilder builder) {
builder.RegisterInstance<IPaperRig>(_rig);
builder.RegisterInstance<IArtInputBridge>(_bridge);
builder.RegisterInstance<IPaperSurface>(_surface);
}
```
- Registers as `Instance` because both are MonoBehaviours already in the scene.
- Lifetime is implicitly tied to the scene (Unity destroys them on unload).
Registers as `Instance` because `PaperSurface` is a MonoBehaviour already in the scene. Lifetime tied to the scene.
---
@@ -1632,7 +1689,8 @@ public sealed class ColoringStateRepository {
#### `ColoringController` *(Systems)* — implements `IColoringController`
Builds and pushes `PaintRegionCommand` instances; spawns `ColorRegionView` per region.
```csharp
// fields: IUndoStack _undo, ColoringStateRepository _state, ColorRegionFactory _factory, IEventBus _bus
// 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
@@ -1641,28 +1699,23 @@ public interface IColoringController {
// 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` *(Views)*
Sprite + `PolygonCollider2D`, on `Artwork` layer. Tapped via `Physics2D.OverlapPoint` from `ColoringInputBinder`.
#### `ColorRegionView : MonoBehaviour, IPointerClickHandler` *(UI)*
UI Image with alpha-based hit detection. Tap routes through Unity's EventSystem directly to `OnPointerClick`.
```csharp
public sealed class ColorRegionView : MonoBehaviour {
public sealed class ColorRegionView : MonoBehaviour, IPointerClickHandler {
public string RegionId { get; }
public Color Color { get; } // current paint
public void Initialize(ColorRegionDTO dto);
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.5f` on 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.
#### `ColoringInputBinder` *(Systems)* — `IStartable, IDisposable`
Subscribes to `IInputReader.PointerDown`. On each tap:
1. `_bridge.TryScreenToArtWorld(screenPos, out var artPos)` — bail if outside the paper.
2. `Physics2D.OverlapPoint(artPos, _artworkMask)` against the `Artwork` layer.
3. If hit, `ColoringController.PaintRegion(hit.GetComponent<ColorRegionView>())`.
```csharp
// fields: IInputReader _input, IArtInputBridge _bridge, IColoringController _coloring, LayerMask _artworkMask
```
Note: `_bridge` is the same instance the entire scene uses — no per-feature coordinate math.
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.
@@ -1721,7 +1774,7 @@ public sealed class CaptureController {
```
- **Flow:** `_capture.CaptureAsync()``_gallery.SaveAsync(bytes, templateId)` → publish signals.
- **Concurrency:** sets `IsCapturing = true` on entry; UI binds button enabled to `!IsCapturing` to prevent double-tap.
- **No camera or sprite args** — capture reads `IPaperRig.Surface` directly inside the service.
- **No camera or sprite args** — the implementation owns a reference to the disabled `CaptureCamera` and drives the one-shot render internally.
#### `CaptureButtonPresenter` *(UI)*
Wires button click → `CaptureController.CaptureCurrentAsync`. Disables button while in progress. Shows toast on `ArtworkSavedSignal`.
@@ -1826,10 +1879,10 @@ All scope classes are thin: a serialized installer-MonoBehaviour list (+ optiona
### 32.13 Cross-cutting types
#### `ColorBookSceneRefs : MonoBehaviour` *(App — planned)*
Aggregates scene-bound Unity references that features need: `Camera artCamera`, `Transform catalogRoot`, `Transform builderRoot`, `Transform coloringRoot`, `RectTransform hudRoot`, `ColorPaletteView paletteView`, `HistoryButtonsView historyView`. Registered in `ColorBookLifetimeScope` via `builder.RegisterInstance(_sceneRefs)` so features don't `Find` things.
#### `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.
> Most of these refs are subsumed by `IPaperRig` now (which owns `ArtCamera` and `PaperRoot`). `ColorBookSceneRefs` reduces to the HUD-side refs (palette view, history buttons, panel roots).
> Paper-side refs are subsumed by `IPaperSurface` (which exposes the four canvas RectTransform roots). `CaptureCamera` could either live here or be exposed via its own dedicated `ICaptureCameraSource` contract — for v1, putting it on `ColorBookSceneRefs` is fine.
#### `IServiceModule` *(Libs/Installers — ✅ exists)*
```csharp
@@ -1850,15 +1903,17 @@ Implemented as `MonoBehaviour` per feature/service so scopes can drag them in th
| `ColorBookLifetimeScope` | App | Scene DI | scene refs, installers |
| `DrawingCatalogController` | Feature | Grid logic | catalog, bus |
| `DrawingCatalogPresenter` | Feature | UI bridge | view, controller, catalog |
| `ShapeBuilderController` | Feature | Piece spawn + snap tracking | catalog, factory, bus, cfg |
| `ShapePieceView` | Feature | Draggable piece MB | input, audio |
| `ShapeBuilderController` | Feature | Piece spawn + snap tracking | catalog, factory, paper, tray, bus, cfg |
| `ShapePieceUI` | Feature | Draggable UI piece (Image + drag handlers) | 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, bus |
| `ColorRegionView` | Feature | Region sprite MB | — |
| `PaintRegionCommand` | Feature | Undoable paint | view, bus |
| `PaperRig` | Feature | RT + ArtCamera owner | — |
| `ArtInputBridge` | Feature | Screen→art-world picking | rig, raw image, ui cam |
| `PaperRigModule` | Feature | DI registration | rig, bridge |
| `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 |