Shape and coloring

This commit is contained in:
Savya Bikram Shah
2026-05-29 13:19:15 +05:45
parent 96e69c3d1a
commit 774a7159d3
118 changed files with 29144 additions and 18646 deletions

View File

@@ -47,7 +47,7 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
_selectedSub = _bus.Subscribe<DrawingSelectedSignal>(OnDrawingSelected);
_loadingScreen.Hide();
}
private void OnReturnToMainMenu(ReturnToMainMenuSignal signal)
{
LoadMainMenuSceneAsync().Forget(UnityEngine.Debug.LogException);
@@ -70,8 +70,13 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
private async UniTaskVoid HandleSelectionAsync(string templateId)
{
_loadingScreen.Show();
var progress = new Progress<float>(p => _loadingScreen.SetProgress(p * 0.5f));
var mappedProgress = new Progress<float>(p => _loadingScreen.SetProgress(0.5f + p * 0.25f));
await _progression.SetLastOpenedAsync(templateId);
await _scenes.LoadSceneAsync(GameScene.Gameplay, progress: null, cancellationToken: default);
await _scenes.LoadSceneAsync(nameof(GameScene.Gameplay), progress: progress, cancellationToken: default);
await _scenes.UnloadSceneAsync(nameof(GameScene.Colorbook), progress: mappedProgress,
cancellationToken: default);
}
public void Dispose()
@@ -79,4 +84,4 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
_selectedSub?.Dispose();
_returnToMainMenuSubscription?.Dispose();
}
}
}

View File

@@ -19,7 +19,7 @@ namespace Darkmatter.Features.Coloring.Systems;
public class ColoringController : IColoringController, IDisposable
{
private const string ColorButtonPrefabKey = "colorButton";
private const string ColorButtonPrefabKey = "ColorButton";
private readonly ColoringStateRepository _repository;
private readonly IColorButtonFactory _buttonFactory;
@@ -67,7 +67,8 @@ public class ColoringController : IColoringController, IDisposable
if (region == null) return;
var from = region.Color;
_history.Push(new ColorRegionCommand(region, from, color));
if (from != color)
_history.Push(new ColorRegionCommand(region, from, color));
_bus.Publish(new ColorAppliedSignal(regionId, color));
}
@@ -84,7 +85,8 @@ public class ColoringController : IColoringController, IDisposable
_history.Drop();
foreach (var button in _buttons)
if (button != null) UnityEngine.Object.Destroy(button.gameObject);
if (button != null)
UnityEngine.Object.Destroy(button.gameObject);
_buttons.Clear();
_regions.Clear();
@@ -143,4 +145,4 @@ public class ColoringController : IColoringController, IDisposable
foreach (var color in _repository.SelectedPalette.Colors)
_buttons.Add(_buttonFactory.Create(_colorButtonPrefab, color));
}
}
}

View File

@@ -9,8 +9,9 @@ namespace Darkmatter.Features.Coloring.UI
[RequireComponent(typeof(Button))]
public class ColorButton : MonoBehaviour
{
[SerializeField] private Image swatch;
[SerializeField] private GameObject selectedUI;
[SerializeField] private Image[] swatches;
[SerializeField] private GameObject activeUI;
[SerializeField] private GameObject inactiveUI;
private Button _button;
private Color _color;
private ColoringStateRepository _repository;
@@ -26,7 +27,10 @@ namespace Darkmatter.Features.Coloring.UI
_color = color;
_repository = repository;
_sfx = sfx;
swatch.color = color;
foreach (var swatch in swatches)
{
swatch.color = color;
}
_button.onClick.AddListener(OnClick);
_repository.SelectedIndexChanged += UpdateSelectedUI;
@@ -41,8 +45,10 @@ namespace Darkmatter.Features.Coloring.UI
private void UpdateSelectedUI()
{
if (selectedUI != null)
selectedUI.SetActive(_repository.SelectedColor == _color);
if (activeUI != null)
activeUI.SetActive(_repository.SelectedColor == _color);
if (inactiveUI != null)
inactiveUI.SetActive(_repository.SelectedColor != _color);
}
private void OnDestroy()
@@ -51,4 +57,4 @@ namespace Darkmatter.Features.Coloring.UI
if (_repository != null) _repository.SelectedIndexChanged -= UpdateSelectedUI;
}
}
}
}

View File

@@ -15,18 +15,24 @@ namespace Darkmatter.Features.Coloring.UI
private CanvasGroup _canvasGroup;
private Vector2 _shownAnchoredPos;
private Sequence _activeSequence;
private bool _refsReady;
public RectTransform SpawnRoot => spawnRoot;
private void Awake()
private void Awake() => EnsureRefs();
private void EnsureRefs()
{
_canvasGroup = GetComponent<CanvasGroup>();
if (_refsReady) return;
if (_canvasGroup == null) _canvasGroup = GetComponent<CanvasGroup>();
if (animatedRoot == null) animatedRoot = (RectTransform)transform;
_shownAnchoredPos = animatedRoot.anchoredPosition;
_refsReady = true;
}
public Sequence Show()
{
EnsureRefs();
KillActive();
gameObject.SetActive(true);
_canvasGroup.interactable = true;
@@ -40,6 +46,7 @@ namespace Darkmatter.Features.Coloring.UI
public Sequence Hide()
{
EnsureRefs();
KillActive();
_canvasGroup.interactable = false;
_canvasGroup.blocksRaycasts = false;
@@ -54,6 +61,7 @@ namespace Darkmatter.Features.Coloring.UI
public void HideInstant()
{
EnsureRefs();
KillActive();
animatedRoot.anchoredPosition = _shownAnchoredPos + hiddenOffset;
_canvasGroup.alpha = 0f;

View File

@@ -14,6 +14,7 @@ namespace Darkmatter.Features.Coloring.UI
private Image _image;
private event Action OnRegionClicked;
private void Awake()
{
_image = GetComponent<Image>();

View File

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

View File

@@ -0,0 +1,917 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Darkmatter.Core.Data.Static.Features.Coloring;
using Darkmatter.Core.Data.Static.Features.DrawingTemplate;
using Darkmatter.Core.Data.Static.Features.ShapeBuilder;
using Darkmatter.Features.Coloring.UI;
using Darkmatter.Features.ShapeBuilder.UI;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
namespace Darkmatter.Features.DrawingTemplates.Editor
{
public sealed class DrawingTemplateEditorWindow : EditorWindow
{
private const string AddressableLabel = "drawing";
private const string DrawingsGroupName = "Drawing";
private const string PalettesGroupName = "Palettes";
private const string DefaultTemplateFolder = "Assets/Darkmatter/Data/Drawings/Templates";
private const string DefaultShapeFolder = "Assets/Darkmatter/Data/Drawings/Shapes";
private const string DefaultPaletteFolder = "Assets/Darkmatter/Data/Drawings/Palettes";
private enum Tab { Templates, Shapes, Palettes }
private Tab _tab;
private Vector2 _leftScroll;
private Vector2 _rightScroll;
private string _search = string.Empty;
private readonly List<DrawingTemplateSO> _templates = new();
private readonly List<ShapeSO> _shapes = new();
private readonly List<ColorPaletteSO> _palettes = new();
private DrawingTemplateSO _selectedTemplate;
private ShapeSO _selectedShape;
private ColorPaletteSO _selectedPalette;
private readonly List<string> _validation = new();
private string _lastScanReport;
private DefaultAsset _batchShapeFolder;
private bool _autoAssignRegionIds = true;
private bool _mergeRegions = false;
[MenuItem("Tools/Darkmatter/Drawing Template Editor", priority = 10)]
public static void Open()
{
var win = GetWindow<DrawingTemplateEditorWindow>("Drawing Editor");
win.minSize = new Vector2(900, 540);
win.Refresh();
}
public static void OpenAndSelect(DrawingTemplateSO template)
{
var win = GetWindow<DrawingTemplateEditorWindow>("Drawing Editor");
win.minSize = new Vector2(900, 540);
win.Refresh();
win._tab = Tab.Templates;
win.Select(template);
}
private void OnEnable() => Refresh();
private void Refresh()
{
_templates.Clear();
_shapes.Clear();
_palettes.Clear();
_templates.AddRange(FindAllOfType<DrawingTemplateSO>());
_shapes.AddRange(FindAllOfType<ShapeSO>());
_palettes.AddRange(FindAllOfType<ColorPaletteSO>());
}
private static IEnumerable<T> FindAllOfType<T>() where T : ScriptableObject
{
var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name);
foreach (var g in guids)
{
var path = AssetDatabase.GUIDToAssetPath(g);
var a = AssetDatabase.LoadAssetAtPath<T>(path);
if (a != null) yield return a;
}
}
private void OnGUI()
{
DrawToolbar();
EditorGUILayout.Space(2);
switch (_tab)
{
case Tab.Templates: DrawTemplatesTab(); break;
case Tab.Shapes: DrawShapesTab(); break;
case Tab.Palettes: DrawPalettesTab(); break;
}
}
// ─── Toolbar ──────────────────────────────────────────────────────────────
private void DrawToolbar()
{
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
{
if (TabButton("Templates", _tab == Tab.Templates)) _tab = Tab.Templates;
if (TabButton("Shapes", _tab == Tab.Shapes)) _tab = Tab.Shapes;
if (TabButton("Palettes", _tab == Tab.Palettes)) _tab = Tab.Palettes;
GUILayout.FlexibleSpace();
_search = GUILayout.TextField(_search, EditorStyles.toolbarSearchField, GUILayout.Width(220));
if (GUILayout.Button("Refresh", EditorStyles.toolbarButton, GUILayout.Width(60)))
Refresh();
}
}
private static bool TabButton(string label, bool active)
{
var bg = GUI.backgroundColor;
if (active) GUI.backgroundColor = new Color(0.6f, 0.8f, 1f);
var click = GUILayout.Button(label, EditorStyles.toolbarButton, GUILayout.Width(90));
GUI.backgroundColor = bg;
return click;
}
// ─── Templates Tab ────────────────────────────────────────────────────────
private void DrawTemplatesTab()
{
using (new EditorGUILayout.HorizontalScope())
{
DrawTemplateList();
DrawSeparator();
DrawTemplateDetail();
}
}
private void DrawTemplateList()
{
using (new EditorGUILayout.VerticalScope(GUILayout.Width(260)))
{
if (GUILayout.Button("+ New Drawing Template", GUILayout.Height(26)))
CreateNewTemplate();
EditorGUILayout.LabelField($"Templates ({_templates.Count})", EditorStyles.boldLabel);
using (var scroll = new EditorGUILayout.ScrollViewScope(_leftScroll))
{
_leftScroll = scroll.scrollPosition;
foreach (var t in _templates)
{
if (t == null) continue;
if (!string.IsNullOrEmpty(_search) &&
(t.name == null || t.name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) < 0) &&
(t.Id == null || t.Id.IndexOf(_search, StringComparison.OrdinalIgnoreCase) < 0))
continue;
DrawTemplateRow(t);
}
}
}
}
private void DrawTemplateRow(DrawingTemplateSO t)
{
var active = _selectedTemplate == t;
var bg = GUI.backgroundColor;
if (active) GUI.backgroundColor = new Color(0.55f, 0.8f, 1f);
using (new EditorGUILayout.HorizontalScope("box"))
{
if (t.DefaultThumbnail != null)
GUILayout.Label(AssetPreview.GetAssetPreview(t.DefaultThumbnail), GUILayout.Width(36), GUILayout.Height(36));
else
GUILayout.Label(GUIContent.none, GUILayout.Width(36), GUILayout.Height(36));
using (new EditorGUILayout.VerticalScope())
{
GUILayout.Label(string.IsNullOrEmpty(t.DisplayName) ? t.name : t.DisplayName, EditorStyles.boldLabel);
GUILayout.Label("id: " + (string.IsNullOrEmpty(t.Id) ? "<unset>" : t.Id), EditorStyles.miniLabel);
}
if (GUILayout.Button("Select", GUILayout.Width(54)))
Select(t);
}
GUI.backgroundColor = bg;
}
private void Select(DrawingTemplateSO t)
{
_selectedTemplate = t;
_lastScanReport = null;
Validate();
Repaint();
}
private void DrawTemplateDetail()
{
using (var scroll = new EditorGUILayout.ScrollViewScope(_rightScroll))
{
_rightScroll = scroll.scrollPosition;
if (_selectedTemplate == null)
{
EditorGUILayout.HelpBox("Select a template from the left list, or click '+ New Drawing Template'.", MessageType.Info);
return;
}
var t = _selectedTemplate;
var so = new SerializedObject(t);
so.Update();
EditorGUILayout.LabelField(AssetDatabase.GetAssetPath(t), EditorStyles.miniLabel);
EditorGUILayout.Space(4);
// Basic fields
EditorGUILayout.LabelField("Basics", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(so.FindProperty("id"), new GUIContent("Id (Addressable Key)"));
EditorGUILayout.PropertyField(so.FindProperty("displayName"));
EditorGUILayout.PropertyField(so.FindProperty("defaultThumbnail"));
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("Prefabs", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(so.FindProperty("drawingPrefab"), new GUIContent("Drawing Prefab (SlotMarkers)"));
EditorGUILayout.PropertyField(so.FindProperty("coloringPrefab"), new GUIContent("Coloring Prefab (ColorRegionViews)"));
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("Palette", EditorStyles.boldLabel);
DrawPaletteIdField(so.FindProperty("colorPaletteId"));
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("Pieces", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Scan Drawing Prefab", GUILayout.Height(22)))
{
ScanDrawingPrefab(t);
so.Update();
}
if (GUILayout.Button("Clear", GUILayout.Width(60)))
{
Undo.RecordObject(t, "Clear Pieces");
var emptyPieces = new List<ShapeSO>();
t.EditorSet(t.Id, t.DisplayName, t.DefaultThumbnail, t.DrawingPrefab, t.ColoringPrefab,
emptyPieces, t.AuthoredRegions.ToList(), t.ColorPaletteId);
EditorUtility.SetDirty(t);
so.Update();
}
}
EditorGUILayout.PropertyField(so.FindProperty("pieces"), includeChildren: true);
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("Regions", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
_autoAssignRegionIds = GUILayout.Toggle(_autoAssignRegionIds,
new GUIContent("Auto-assign blank Ids from GameObject name (writes back to prefab)"),
GUILayout.ExpandWidth(false));
}
using (new EditorGUILayout.HorizontalScope())
{
_mergeRegions = GUILayout.Toggle(_mergeRegions,
new GUIContent("Merge mode (keep existing entries' colors)"),
GUILayout.ExpandWidth(false));
}
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Scan Coloring Prefab", GUILayout.Height(22)))
{
ScanColoringPrefab(t);
so.Update();
}
if (GUILayout.Button("Clear", GUILayout.Width(60)))
{
Undo.RecordObject(t, "Clear Regions");
t.EditorSet(t.Id, t.DisplayName, t.DefaultThumbnail, t.DrawingPrefab, t.ColoringPrefab,
t.Pieces.ToList(), new List<RegionAuthoring>(), t.ColorPaletteId);
EditorUtility.SetDirty(t);
so.Update();
}
}
EditorGUILayout.PropertyField(so.FindProperty("regions"), includeChildren: true);
so.ApplyModifiedProperties();
EditorGUILayout.Space(10);
DrawValidationBox();
EditorGUILayout.Space(4);
if (!string.IsNullOrEmpty(_lastScanReport))
EditorGUILayout.HelpBox(_lastScanReport, MessageType.Info);
EditorGUILayout.Space(8);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Validate", GUILayout.Height(28)))
Validate();
if (GUILayout.Button("Mark Addressable ('drawing')", GUILayout.Height(28)))
TryMakeAddressable(t);
if (GUILayout.Button("Ping Asset", GUILayout.Width(120), GUILayout.Height(28)))
EditorGUIUtility.PingObject(t);
}
}
}
private void DrawPaletteIdField(SerializedProperty prop)
{
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.PropertyField(prop, new GUIContent("Palette Id"));
var ids = _palettes.Where(p => p != null).Select(p => p.Id).Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList();
if (ids.Count == 0)
{
GUILayout.Label("(no palettes)", EditorStyles.miniLabel, GUILayout.Width(90));
return;
}
var current = ids.IndexOf(prop.stringValue);
var newIndex = EditorGUILayout.Popup(current, ids.ToArray(), GUILayout.Width(150));
if (newIndex >= 0 && newIndex != current)
prop.stringValue = ids[newIndex];
}
}
// ─── Scans ────────────────────────────────────────────────────────────────
private void ScanDrawingPrefab(DrawingTemplateSO t)
{
if (t.DrawingPrefab == null)
{
_lastScanReport = "No Drawing Prefab assigned.";
return;
}
var path = AssetDatabase.GetAssetPath(t.DrawingPrefab);
var root = PrefabUtility.LoadPrefabContents(path);
try
{
var markers = root.GetComponentsInChildren<SlotMarker>(includeInactive: true);
var pieces = new List<ShapeSO>();
var missing = 0;
foreach (var m in markers)
{
if (m.Shape == null) { missing++; continue; }
if (!pieces.Contains(m.Shape)) pieces.Add(m.Shape);
}
Undo.RecordObject(t, "Scan Drawing Prefab");
t.EditorSet(t.Id, t.DisplayName, t.DefaultThumbnail, t.DrawingPrefab, t.ColoringPrefab,
pieces, t.AuthoredRegions.ToList(), t.ColorPaletteId);
EditorUtility.SetDirty(t);
_lastScanReport = $"Found {markers.Length} SlotMarker(s), {pieces.Count} unique ShapeSO. {missing} marker(s) had no ShapeSO assigned.";
Validate();
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
}
private void ScanColoringPrefab(DrawingTemplateSO t)
{
if (t.ColoringPrefab == null)
{
_lastScanReport = "No Coloring Prefab assigned.";
return;
}
var path = AssetDatabase.GetAssetPath(t.ColoringPrefab);
var root = PrefabUtility.LoadPrefabContents(path);
var existingColors = _mergeRegions
? t.AuthoredRegions.ToDictionary(r => r.RegionId, r => r.InitialColor)
: new Dictionary<string, Color>();
try
{
var views = root.GetComponentsInChildren<ColorRegionView>(includeInactive: true);
var regions = new List<RegionAuthoring>();
var seen = new HashSet<string>();
var assigned = new List<string>();
var duplicates = new List<string>();
var blanks = new List<string>();
var prefabDirty = false;
foreach (var v in views)
{
var id = v.RegionId;
var goPath = GetHierarchyPath(v.transform, root.transform);
if (string.IsNullOrEmpty(id))
{
if (_autoAssignRegionIds)
{
var baseId = SanitizeId(v.gameObject.name);
id = MakeUniqueId(baseId, seen);
if (TrySetRegionId(v, id))
{
prefabDirty = true;
assigned.Add($"{goPath} → '{id}'");
}
else
{
blanks.Add($"{goPath} (could not set Id; backing field not found)");
continue;
}
}
else
{
blanks.Add(goPath);
continue;
}
}
if (!seen.Add(id))
{
duplicates.Add($"{goPath} (id='{id}')");
continue;
}
var img = v.GetComponent<UnityEngine.UI.Image>();
var col = existingColors.TryGetValue(id, out var keepCol)
? keepCol
: (img != null ? img.color : Color.white);
regions.Add(new RegionAuthoring { RegionId = id, InitialColor = col });
}
if (prefabDirty)
PrefabUtility.SaveAsPrefabAsset(root, path);
Undo.RecordObject(t, "Scan Coloring Prefab");
t.EditorSet(t.Id, t.DisplayName, t.DefaultThumbnail, t.DrawingPrefab, t.ColoringPrefab,
t.Pieces.ToList(), regions, t.ColorPaletteId);
EditorUtility.SetDirty(t);
var report = new System.Text.StringBuilder();
report.AppendLine($"Found {views.Length} ColorRegionView(s). Stored {regions.Count}. Merge mode: {_mergeRegions}.");
if (assigned.Count > 0)
{
report.AppendLine($"Auto-assigned {assigned.Count} blank Id(s) and wrote back to prefab:");
foreach (var a in assigned) report.AppendLine(" • " + a);
}
if (blanks.Count > 0)
{
report.AppendLine($"Skipped {blanks.Count} blank Id(s) (enable auto-assign to fix):");
foreach (var b in blanks) report.AppendLine(" • " + b);
}
if (duplicates.Count > 0)
{
report.AppendLine($"Skipped {duplicates.Count} duplicate Id(s):");
foreach (var d in duplicates) report.AppendLine(" • " + d);
}
_lastScanReport = report.ToString().TrimEnd();
Validate();
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
}
private static bool TrySetRegionId(ColorRegionView v, string id)
{
var vso = new SerializedObject(v);
var prop = FindAutoProp(vso, "RegionId");
if (prop == null) return false;
prop.stringValue = id;
vso.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(v);
return true;
}
// Robust lookup for [field: SerializeField] backing fields across Unity versions.
// Tries '<Name>k__BackingField', 'Name', 'name', 'm_Name', then iterates visible properties matching by name.
private static SerializedProperty FindAutoProp(SerializedObject so, string propertyName)
{
var prop = so.FindProperty("<" + propertyName + ">k__BackingField");
if (prop != null) return prop;
prop = so.FindProperty(propertyName);
if (prop != null) return prop;
var camel = char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1);
prop = so.FindProperty(camel);
if (prop != null) return prop;
prop = so.FindProperty("m_" + propertyName);
if (prop != null) return prop;
var iter = so.GetIterator();
if (!iter.NextVisible(true)) return null;
do
{
if (iter.name.IndexOf(propertyName, StringComparison.OrdinalIgnoreCase) >= 0)
return iter.Copy();
} while (iter.NextVisible(false));
return null;
}
private static string MakeUniqueId(string baseId, HashSet<string> taken)
{
if (!taken.Contains(baseId)) return baseId;
for (int i = 2; i < 10000; i++)
{
var candidate = baseId + "_" + i;
if (!taken.Contains(candidate)) return candidate;
}
return baseId + "_" + System.Guid.NewGuid().ToString("N").Substring(0, 6);
}
private static string GetHierarchyPath(Transform node, Transform root)
{
if (node == root) return node.name;
var parts = new List<string> { node.name };
var cur = node.parent;
while (cur != null && cur != root)
{
parts.Add(cur.name);
cur = cur.parent;
}
parts.Reverse();
return string.Join("/", parts);
}
// ─── Validation ───────────────────────────────────────────────────────────
private void Validate()
{
_validation.Clear();
var t = _selectedTemplate;
if (t == null) return;
if (string.IsNullOrWhiteSpace(t.Id)) _validation.Add("Id is empty.");
if (string.IsNullOrWhiteSpace(t.DisplayName)) _validation.Add("DisplayName is empty.");
if (t.DefaultThumbnail == null) _validation.Add("DefaultThumbnail is missing.");
if (t.DrawingPrefab == null) _validation.Add("DrawingPrefab is missing.");
if (t.ColoringPrefab == null) _validation.Add("ColoringPrefab is missing.");
if (string.IsNullOrWhiteSpace(t.ColorPaletteId)) _validation.Add("ColorPaletteId is empty.");
else if (!_palettes.Any(p => p != null && p.Id == t.ColorPaletteId))
_validation.Add($"No ColorPaletteSO with Id '{t.ColorPaletteId}' found in project.");
if (t.Pieces == null || t.Pieces.Count == 0)
_validation.Add("No Pieces assigned (use 'Scan Drawing Prefab').");
else
{
var dup = t.Pieces.GroupBy(p => p != null ? p.Id : null).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
foreach (var d in dup) _validation.Add($"Duplicate piece Id '{d}'.");
if (t.Pieces.Any(p => p == null)) _validation.Add("A Piece slot is null.");
if (t.Pieces.Any(p => p != null && string.IsNullOrEmpty(p.Id))) _validation.Add("A Piece has empty Id.");
}
var ridDup = t.AuthoredRegions.GroupBy(r => r.RegionId).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
foreach (var d in ridDup) _validation.Add($"Duplicate region Id '{d}'.");
// Cross-check: piece count vs slot count in DrawingPrefab
if (t.DrawingPrefab != null && t.Pieces != null)
{
var path = AssetDatabase.GetAssetPath(t.DrawingPrefab);
var root = PrefabUtility.LoadPrefabContents(path);
try
{
var markerCount = root.GetComponentsInChildren<SlotMarker>(true).Length;
if (markerCount != t.Pieces.Count)
_validation.Add($"Pieces count ({t.Pieces.Count}) does not match SlotMarker count in prefab ({markerCount}).");
}
finally { PrefabUtility.UnloadPrefabContents(root); }
}
}
private void DrawValidationBox()
{
if (_validation.Count == 0)
{
EditorGUILayout.HelpBox("Looks good.", MessageType.Info);
return;
}
EditorGUILayout.HelpBox(string.Join("\n", _validation.Select(v => "• " + v)), MessageType.Warning);
}
// ─── Addressables ─────────────────────────────────────────────────────────
private void TryMakeAddressable(UnityEngine.Object asset)
{
try
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null)
{
EditorUtility.DisplayDialog("Addressables", "Addressables settings not found. Open Window > Asset Management > Addressables > Groups and click 'Create Addressables Settings' first.", "OK");
return;
}
var group = EnsureGroup(settings, DrawingsGroupName);
var path = AssetDatabase.GetAssetPath(asset);
var guid = AssetDatabase.AssetPathToGUID(path);
var entry = settings.CreateOrMoveEntry(guid, group);
var t = asset as DrawingTemplateSO;
if (t != null && !string.IsNullOrEmpty(t.Id))
entry.address = t.Id;
if (!settings.GetLabels().Contains(AddressableLabel))
settings.AddLabel(AddressableLabel);
entry.SetLabel(AddressableLabel, true, true, false);
settings.SetDirty(AddressableAssetSettings.ModificationEvent.EntryModified, entry, true);
_lastScanReport = $"Marked '{asset.name}' addressable in group '{group.Name}'. Address='{entry.address}', label='{AddressableLabel}'.";
}
catch (Exception e)
{
EditorUtility.DisplayDialog("Addressables", "Failed: " + e.Message, "OK");
}
}
private static AddressableAssetGroup EnsureGroup(AddressableAssetSettings settings, string groupName)
{
var group = settings.FindGroup(groupName);
if (group != null) return group;
System.Type[] schemaTypes;
if (settings.DefaultGroup != null && settings.DefaultGroup.Schemas.Count > 0)
schemaTypes = settings.DefaultGroup.Schemas.Select(s => s.GetType()).ToArray();
else
schemaTypes = new[] { typeof(UnityEditor.AddressableAssets.Settings.GroupSchemas.BundledAssetGroupSchema) };
return settings.CreateGroup(groupName, false, false, true, null, schemaTypes);
}
// ─── New Template ─────────────────────────────────────────────────────────
private void CreateNewTemplate()
{
if (!AssetDatabase.IsValidFolder(DefaultTemplateFolder))
EnsureFolder(DefaultTemplateFolder);
var path = EditorUtility.SaveFilePanelInProject(
"New Drawing Template", "NewDrawing", "asset",
"Choose where to save the drawing template asset.",
DefaultTemplateFolder);
if (string.IsNullOrEmpty(path)) return;
var t = CreateInstance<DrawingTemplateSO>();
var name = Path.GetFileNameWithoutExtension(path);
t.EditorSet(name, name, null, null, null, new List<ShapeSO>(), new List<RegionAuthoring>(), "defaultPalette");
AssetDatabase.CreateAsset(t, path);
AssetDatabase.SaveAssets();
Refresh();
Select(t);
}
// ─── Shapes Tab ───────────────────────────────────────────────────────────
private void DrawShapesTab()
{
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUILayout.VerticalScope(GUILayout.Width(280)))
{
if (GUILayout.Button("+ New Shape from Sprite", GUILayout.Height(24)))
CreateShapePicker();
EditorGUILayout.LabelField("Batch import from folder:", EditorStyles.miniBoldLabel);
_batchShapeFolder = (DefaultAsset)EditorGUILayout.ObjectField(_batchShapeFolder, typeof(DefaultAsset), false);
using (new EditorGUI.DisabledScope(_batchShapeFolder == null))
{
if (GUILayout.Button("Batch Create From Folder"))
BatchCreateShapesFromFolder(_batchShapeFolder);
}
EditorGUILayout.Space(4);
EditorGUILayout.LabelField($"Shapes ({_shapes.Count})", EditorStyles.boldLabel);
using (var s = new EditorGUILayout.ScrollViewScope(_leftScroll))
{
_leftScroll = s.scrollPosition;
foreach (var sh in _shapes)
{
if (sh == null) continue;
if (!string.IsNullOrEmpty(_search) &&
sh.name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) < 0 &&
(sh.Id == null || sh.Id.IndexOf(_search, StringComparison.OrdinalIgnoreCase) < 0)) continue;
DrawShapeRow(sh);
}
}
}
DrawSeparator();
DrawShapeDetail();
}
}
private void DrawShapeRow(ShapeSO sh)
{
using (new EditorGUILayout.HorizontalScope("box"))
{
var preview = sh.Sprite != null ? AssetPreview.GetAssetPreview(sh.Sprite) : null;
GUILayout.Label(preview, GUILayout.Width(36), GUILayout.Height(36));
using (new EditorGUILayout.VerticalScope())
{
GUILayout.Label(sh.name, EditorStyles.boldLabel);
GUILayout.Label("id: " + (string.IsNullOrEmpty(sh.Id) ? "<unset>" : sh.Id), EditorStyles.miniLabel);
}
if (GUILayout.Button("Edit", GUILayout.Width(48)))
{
_selectedShape = sh;
Repaint();
}
}
}
private void DrawShapeDetail()
{
using (var s = new EditorGUILayout.ScrollViewScope(_rightScroll))
{
_rightScroll = s.scrollPosition;
if (_selectedShape == null)
{
EditorGUILayout.HelpBox("Select a shape on the left, or create a new one.", MessageType.Info);
return;
}
var so = new SerializedObject(_selectedShape);
so.Update();
EditorGUILayout.LabelField(AssetDatabase.GetAssetPath(_selectedShape), EditorStyles.miniLabel);
EditorGUILayout.PropertyField(FindAutoProp(so, "Id"), new GUIContent("Id"));
EditorGUILayout.PropertyField(FindAutoProp(so, "Sprite"), new GUIContent("Sprite"));
EditorGUILayout.PropertyField(FindAutoProp(so, "DefaultSizeDelta"), new GUIContent("Default Size Delta"));
so.ApplyModifiedProperties();
EditorGUILayout.Space(8);
if (_selectedShape.Sprite != null)
{
var preview = AssetPreview.GetAssetPreview(_selectedShape.Sprite);
if (preview != null)
GUILayout.Label(preview, GUILayout.Width(160), GUILayout.Height(160));
}
EditorGUILayout.Space(8);
if (GUILayout.Button("Ping Asset", GUILayout.Width(120)))
EditorGUIUtility.PingObject(_selectedShape);
}
}
private void CreateShapePicker()
{
var path = EditorUtility.OpenFilePanelWithFilters(
"Pick sprite", Application.dataPath,
new[] { "Sprites", "png,jpg,jpeg,psd,asset" });
if (string.IsNullOrEmpty(path)) return;
if (!path.StartsWith(Application.dataPath))
{
EditorUtility.DisplayDialog("Pick sprite", "Sprite must be inside the project's Assets folder.", "OK");
return;
}
var relative = "Assets" + path.Substring(Application.dataPath.Length);
var sprite = AssetDatabase.LoadAssetAtPath<Sprite>(relative);
if (sprite == null)
{
EditorUtility.DisplayDialog("Pick sprite", "Selected file is not a Sprite asset.", "OK");
return;
}
CreateShape(sprite);
}
private void BatchCreateShapesFromFolder(DefaultAsset folder)
{
var folderPath = AssetDatabase.GetAssetPath(folder);
if (!AssetDatabase.IsValidFolder(folderPath))
{
EditorUtility.DisplayDialog("Batch import", "Selection is not a folder.", "OK");
return;
}
var guids = AssetDatabase.FindAssets("t:Sprite", new[] { folderPath });
int created = 0;
foreach (var g in guids)
{
var p = AssetDatabase.GUIDToAssetPath(g);
var spr = AssetDatabase.LoadAssetAtPath<Sprite>(p);
if (spr == null) continue;
CreateShape(spr, batch: true);
created++;
}
AssetDatabase.SaveAssets();
Refresh();
_lastScanReport = $"Batch created {created} ShapeSO from '{folderPath}'.";
}
private ShapeSO CreateShape(Sprite sprite, bool batch = false)
{
EnsureFolder(DefaultShapeFolder);
var idGuess = SanitizeId(sprite.name);
var path = $"{DefaultShapeFolder}/{idGuess}.asset";
path = AssetDatabase.GenerateUniqueAssetPath(path);
var shape = CreateInstance<ShapeSO>();
var so = new SerializedObject(shape);
FindAutoProp(so, "Id").stringValue = idGuess;
FindAutoProp(so, "Sprite").objectReferenceValue = sprite;
FindAutoProp(so, "DefaultSizeDelta").vector2Value = new Vector2(256, 256);
so.ApplyModifiedProperties();
AssetDatabase.CreateAsset(shape, path);
if (!batch)
{
AssetDatabase.SaveAssets();
Refresh();
_selectedShape = shape;
EditorGUIUtility.PingObject(shape);
}
return shape;
}
// ─── Palettes Tab ─────────────────────────────────────────────────────────
private void DrawPalettesTab()
{
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUILayout.VerticalScope(GUILayout.Width(260)))
{
if (GUILayout.Button("+ New Color Palette", GUILayout.Height(24)))
CreatePalette();
EditorGUILayout.LabelField($"Palettes ({_palettes.Count})", EditorStyles.boldLabel);
using (var s = new EditorGUILayout.ScrollViewScope(_leftScroll))
{
_leftScroll = s.scrollPosition;
foreach (var p in _palettes)
{
if (p == null) continue;
using (new EditorGUILayout.HorizontalScope("box"))
{
using (new EditorGUILayout.VerticalScope())
{
GUILayout.Label(p.name, EditorStyles.boldLabel);
GUILayout.Label("id: " + (string.IsNullOrEmpty(p.Id) ? "<unset>" : p.Id), EditorStyles.miniLabel);
DrawPaletteSwatches(p);
}
if (GUILayout.Button("Edit", GUILayout.Width(48)))
{
_selectedPalette = p;
Repaint();
}
}
}
}
}
DrawSeparator();
using (var s = new EditorGUILayout.ScrollViewScope(_rightScroll))
{
_rightScroll = s.scrollPosition;
if (_selectedPalette == null)
{
EditorGUILayout.HelpBox("Select a palette on the left, or create a new one.", MessageType.Info);
return;
}
var so = new SerializedObject(_selectedPalette);
so.Update();
EditorGUILayout.LabelField(AssetDatabase.GetAssetPath(_selectedPalette), EditorStyles.miniLabel);
EditorGUILayout.PropertyField(FindAutoProp(so, "Id"), new GUIContent("Id"));
EditorGUILayout.PropertyField(FindAutoProp(so, "Colors"), new GUIContent("Colors"), includeChildren: true);
so.ApplyModifiedProperties();
EditorGUILayout.Space(6);
if (GUILayout.Button("Mark Addressable (key=Id)", GUILayout.Height(24)))
TryMakeAddressablePalette(_selectedPalette);
}
}
}
private void TryMakeAddressablePalette(ColorPaletteSO p)
{
try
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null)
{
EditorUtility.DisplayDialog("Addressables", "Addressables settings not found. Open Window > Asset Management > Addressables > Groups and click 'Create Addressables Settings' first.", "OK");
return;
}
var group = EnsureGroup(settings, PalettesGroupName);
var path = AssetDatabase.GetAssetPath(p);
var guid = AssetDatabase.AssetPathToGUID(path);
var entry = settings.CreateOrMoveEntry(guid, group);
if (!string.IsNullOrEmpty(p.Id)) entry.address = p.Id;
settings.SetDirty(AddressableAssetSettings.ModificationEvent.EntryModified, entry, true);
_lastScanReport = $"Marked palette '{p.name}' addressable in group '{group.Name}'. Address='{entry.address}'.";
}
catch (Exception e)
{
EditorUtility.DisplayDialog("Addressables", "Failed: " + e.Message, "OK");
}
}
private static void DrawPaletteSwatches(ColorPaletteSO p)
{
if (p.Colors == null) return;
const int swatch = 14;
using (new EditorGUILayout.HorizontalScope())
{
foreach (var c in p.Colors)
{
var r = GUILayoutUtility.GetRect(swatch, swatch, GUILayout.Width(swatch), GUILayout.Height(swatch));
EditorGUI.DrawRect(r, c);
}
}
}
private void CreatePalette()
{
EnsureFolder(DefaultPaletteFolder);
var path = EditorUtility.SaveFilePanelInProject(
"New Color Palette", "NewPalette", "asset",
"Choose where to save the palette asset.",
DefaultPaletteFolder);
if (string.IsNullOrEmpty(path)) return;
var p = CreateInstance<ColorPaletteSO>();
var so = new SerializedObject(p);
FindAutoProp(so, "Id").stringValue = Path.GetFileNameWithoutExtension(path);
so.ApplyModifiedProperties();
AssetDatabase.CreateAsset(p, path);
AssetDatabase.SaveAssets();
Refresh();
_selectedPalette = p;
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private static void DrawSeparator()
{
var r = GUILayoutUtility.GetRect(2, 0, GUILayout.Width(2), GUILayout.ExpandHeight(true));
EditorGUI.DrawRect(r, new Color(0, 0, 0, 0.25f));
}
private static void EnsureFolder(string assetPath)
{
if (AssetDatabase.IsValidFolder(assetPath)) return;
var parts = assetPath.Split('/');
var current = parts[0];
for (int i = 1; i < parts.Length; i++)
{
var next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
AssetDatabase.CreateFolder(current, parts[i]);
current = next;
}
}
private static string SanitizeId(string raw)
{
if (string.IsNullOrEmpty(raw)) return "shape";
var chars = raw.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_').ToArray();
var s = new string(chars).Trim('_');
return string.IsNullOrEmpty(s) ? "shape" : s;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64a89f3acbab9486288ef3ff452a39c7

View File

@@ -0,0 +1,26 @@
using Darkmatter.Core.Data.Static.Features.DrawingTemplate;
using UnityEditor;
using UnityEngine;
namespace Darkmatter.Features.DrawingTemplates.Editor
{
[CustomEditor(typeof(DrawingTemplateSO))]
public sealed class DrawingTemplateInspector : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
var t = (DrawingTemplateSO)target;
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Open in Drawing Editor", GUILayout.Height(28)))
DrawingTemplateEditorWindow.OpenAndSelect(t);
if (GUILayout.Button("Ping", GUILayout.Width(60), GUILayout.Height(28)))
EditorGUIUtility.PingObject(t);
}
EditorGUILayout.Space(4);
DrawDefaultInspector();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7ac1d35307f6a427082cca127e61ab71

View File

@@ -0,0 +1,23 @@
{
"name": "Features.DrawingTemplates.Editor",
"rootNamespace": "Darkmatter.Features.DrawingTemplates.Editor",
"references": [
"GUID:6a005d98aed1c4439bc4689802fa2e3b",
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
"GUID:4cede189a43c349069c614e305683720",
"GUID:2ca8c3a66565544118d3d52d3922933b",
"GUID:9e24947de15b9834991c9d8411ea37cf",
"GUID:69448af7b92c7f342b298e06a37122aa"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 32c91add8e49f4e1fb2121cac770862f
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -5,8 +5,11 @@ using Cysharp.Threading.Tasks;
using Darkmatter.Core.Contracts.Features.Coloring;
using Darkmatter.Core.Contracts.Features.DrawingCatalog;
using Darkmatter.Core.Contracts.Features.GameplayFlow;
using Darkmatter.Core.Contracts.Features.Loading;
using Darkmatter.Core.Contracts.Features.Progression;
using Darkmatter.Core.Contracts.Features.ShapeBuilder;
using Darkmatter.Core.Contracts.Services.Capture;
using Darkmatter.Core.Contracts.Services.Gallery;
using Darkmatter.Core.Contracts.Services.Scenes;
using Darkmatter.Core.Data.Dynamic.Features.Progression;
using Darkmatter.Core.Data.Signals.Features.Coloring;
@@ -28,6 +31,9 @@ namespace Darkmatter.Features.GameplayFlow.Systems
private readonly IShapeBuilderController _shapeBuilder;
private readonly IColoringController _coloring;
private readonly ISceneService _scenes;
private readonly ICaptureService _captureService;
private readonly IGameplaySceneRefs _refs;
private readonly ILoadingScreen _loadingScreen;
private readonly IEventBus _bus;
private IDrawingTemplate _template;
@@ -45,6 +51,9 @@ namespace Darkmatter.Features.GameplayFlow.Systems
IShapeBuilderController shapeBuilder,
IColoringController coloring,
ISceneService scenes,
ICaptureService captureService,
IGameplaySceneRefs refs,
ILoadingScreen loadingScreen,
IEventBus bus)
{
_progression = progression;
@@ -52,6 +61,9 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_shapeBuilder = shapeBuilder;
_coloring = coloring;
_scenes = scenes;
_captureService = captureService;
_loadingScreen = loadingScreen;
_refs = refs;
_bus = bus;
}
@@ -62,7 +74,8 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_templateId = _progression.LastOpenedTemplateId;
if (string.IsNullOrEmpty(_templateId))
throw new Exception("[GameplayFlow] No LastOpenedTemplateId set. ColorbookFlow must call _progression.SetLastOpenedAsync(id) before loading Gameplay.");
throw new Exception(
"[GameplayFlow] No LastOpenedTemplateId set. ColorbookFlow must call _progression.SetLastOpenedAsync(id) before loading Gameplay.");
_template = await _catalog.LoadAsync(_templateId);
ct.ThrowIfCancellationRequested();
@@ -73,41 +86,41 @@ namespace Darkmatter.Features.GameplayFlow.Systems
await _shapeBuilder.InitializeAsync(ct);
ct.ThrowIfCancellationRequested();
_assembledSub = _bus.Subscribe<ShapeAssembledSignal>(OnShapeAssembled);
_assembledSub = _bus.Subscribe<ShapeAssembledSignal>(OnShapeAssembled);
_colorAppliedSub = _bus.Subscribe<ColorAppliedSignal>(OnColorApplied);
_loadingScreen.SetProgress(1f);
if (_phase == DrawingPhase.Coloring)
{
// Resume direct into coloring: pre-snap every piece. ShapeBuilder
// emits ShapeAssembledSignal once counter hits expected; that path
// also triggers InitializeColoring with savedColors.
var allIds = CollectAllPieceIds(_template);
await _shapeBuilder.BuildAsync(_template, allIds, ct);
var savedColors = ToColorDict(progress?.regionColors);
await _coloring.InitializeRegionsAsync(_template, savedColors, ct);
}
else
{
await _shapeBuilder.BuildAsync(_template, progress?.snappedPieces, ct);
}
_loadingScreen.Hide();
}
public async UniTask BackAsync()
public async UniTask BackAsync(CancellationToken ct)
{
await SaveCurrentAsync(captureThumbnail: _phase == DrawingPhase.Coloring);
await SaveCurrentAsync(captureThumbnail: true, CancellationToken.None);
_shapeBuilder.Clear();
_coloring.Clear();
await _scenes.LoadSceneAsync(GameScene.Colorbook, progress: null, cancellationToken: default);
await _scenes.LoadSceneAsync(GameScene.Colorbook, progress: null, cancellationToken: ct);
}
public async UniTask SaveAsync()
public async UniTask SaveAsync(CancellationToken ct)
{
// Explicit user-pressed save — capture thumbnail + (planned) native gallery export.
await SaveCurrentAsync(captureThumbnail: true);
await SaveCurrentAsync(captureThumbnail: true, ct);
// TODO: route through ICaptureService + IGalleryService once those exist.
}
public async UniTask NextAsync()
public async UniTask NextAsync(CancellationToken ct)
{
await SaveCurrentAsync(captureThumbnail: true);
await SaveCurrentAsync(captureThumbnail: true, ct);
_progression.MarkCompleted(_templateId);
var nextId = _catalog.GetNextTemplate(_templateId);
@@ -126,26 +139,25 @@ namespace Darkmatter.Features.GameplayFlow.Systems
public void OnApplicationPaused()
{
// Fire-and-forget — pause window is small; PlayerPrefs write is sync under the hood.
SaveCurrentAsync(captureThumbnail: false).Forget();
SaveCurrentAsync(captureThumbnail: false, CancellationToken.None).Forget();
}
private void OnShapeAssembled(ShapeAssembledSignal signal)
{
if (signal.TemplateId != _templateId) return;
EnterColoringAsync().Forget();
EnterColoringAsync(CancellationToken.None).Forget();
}
private async UniTask EnterColoringAsync()
private async UniTask EnterColoringAsync(CancellationToken ct)
{
_phase = DrawingPhase.Coloring;
var progress = _progression.GetProgress(_templateId);
var savedColors = ToColorDict(progress?.regionColors);
await _coloring.InitializeRegionsAsync(_template, savedColors, _scopeCts.Token);
_shapeBuilder.DespawnDrawing();
// Bare-assembled snapshot — catalog should show the puzzle outline even if
// the child never paints (per readme §12b save matrix).
await SaveCurrentAsync(captureThumbnail: true);
await SaveCurrentAsync(captureThumbnail: true, ct);
}
private void OnColorApplied(ColorAppliedSignal _)
@@ -162,12 +174,15 @@ namespace Darkmatter.Features.GameplayFlow.Systems
try
{
await UniTask.Delay(AutosaveDebounceMs, cancellationToken: ct);
await SaveCurrentAsync(captureThumbnail: false);
await SaveCurrentAsync(captureThumbnail: false, ct);
}
catch (OperationCanceledException)
{
/* superseded by next paint or scope end */
}
catch (OperationCanceledException) { /* superseded by next paint or scope end */ }
}
private async UniTask SaveCurrentAsync(bool captureThumbnail)
private async UniTask SaveCurrentAsync(bool captureThumbnail, CancellationToken ct)
{
if (string.IsNullOrEmpty(_templateId)) return;
@@ -179,34 +194,26 @@ namespace Darkmatter.Features.GameplayFlow.Systems
var existing = _progression.GetProgress(_templateId);
var progress = new DrawingProgress
{
templateId = _templateId,
phase = _phase,
snappedPieces = snappedIds,
regionColors = regionEntries,
hasThumbnail = captureThumbnail || (existing?.hasThumbnail ?? false),
hasBeenCompleted = existing?.hasBeenCompleted ?? false,
completionCount = existing?.completionCount ?? 0,
UpdatedUtc = DateTime.UtcNow,
templateId = _templateId,
phase = _phase,
snappedPieces = snappedIds,
regionColors = regionEntries,
hasThumbnail = captureThumbnail || (existing?.hasThumbnail ?? false),
hasBeenCompleted = existing?.hasBeenCompleted ?? false,
completionCount = existing?.completionCount ?? 0,
UpdatedUtc = DateTime.UtcNow,
FirstCompletedUtc = existing?.FirstCompletedUtc,
};
byte[] thumbnailPng = null;
if (captureThumbnail)
{
// TODO: replace with ICaptureService.CaptureAsync() once available.
// For now we record hasThumbnail=true but write no PNG.
thumbnailPng = await _captureService.CapturePngAsync(_refs.PaperRoot.gameObject, 10, ct);
}
await _progression.SaveProgressAsync(progress, thumbnailPng);
}
private static IReadOnlyCollection<string> CollectAllPieceIds(IDrawingTemplate template)
{
var ids = new List<string>(template.Pieces.Count);
foreach (var p in template.Pieces) ids.Add(p.Id);
return ids;
}
private static IReadOnlyDictionary<string, Color> ToColorDict(List<RegionColorEntry> entries)
{
var dict = new Dictionary<string, Color>();
@@ -227,4 +234,4 @@ namespace Darkmatter.Features.GameplayFlow.Systems
_scopeCts?.Dispose();
}
}
}
}

View File

@@ -43,7 +43,7 @@ namespace Darkmatter.Features.History
{
_view.SetUndoInteractable(_undoStack.CanUndo);
_view.SetRedoInteractable(_undoStack.CanRedo);
_view.SetClearInteractable(_undoStack.CanUndo || _undoStack.CanRedo);
_view.SetClearInteractable(_undoStack.CanUndo);
}
public void Dispose()

View File

@@ -1,4 +1,3 @@
using System;
using UnityEngine;
using UnityEngine.UI;
@@ -16,20 +15,31 @@ namespace Darkmatter.Features.History
void Start()
{
undoButton.onClick.AddListener(() => OnUndoClicked?.Invoke());
redoButton.onClick.AddListener(() => OnRedoClicked?.Invoke());
clearButton.onClick.AddListener(() => OnClearClicked?.Invoke());
if (undoButton != null) undoButton.onClick.AddListener(() => OnUndoClicked?.Invoke());
if (redoButton != null) redoButton.onClick.AddListener(() => OnRedoClicked?.Invoke());
if (clearButton != null) clearButton.onClick.AddListener(() => OnClearClicked?.Invoke());
}
public void SetUndoInteractable(bool value) => undoButton.interactable = value;
public void SetRedoInteractable(bool value) => redoButton.interactable = value;
public void SetClearInteractable(bool value) => clearButton.interactable = value;
public void SetUndoInteractable(bool value)
{
if (undoButton != null) undoButton.interactable = value;
}
public void SetRedoInteractable(bool value)
{
if (redoButton != null) redoButton.interactable = value;
}
public void SetClearInteractable(bool value)
{
if (clearButton != null) clearButton.interactable = value;
}
private void OnDestroy()
{
undoButton.onClick.RemoveListener(() => OnUndoClicked?.Invoke());
redoButton.onClick.RemoveListener(() => OnRedoClicked?.Invoke());
clearButton.onClick.RemoveListener(() => OnClearClicked?.Invoke());
if (undoButton != null) undoButton.onClick.RemoveAllListeners();
if (redoButton != null) redoButton.onClick.RemoveAllListeners();
if (clearButton != null) clearButton.onClick.RemoveAllListeners();
}
}
}

View File

@@ -11,20 +11,20 @@ namespace Darkmatter.Features.ShapeBuilder.Commands
private readonly Vector2 _prevSize;
private readonly Quaternion _prevRot;
private readonly Transform _prevParent;
private readonly int _prevSiblingIndex;
public SnapPieceCommand(ShapePiece piece)
{
_piece = piece;
var rt = piece.RectTransform;
_prevPos = rt.anchoredPosition;
_prevSize = rt.sizeDelta;
_prevRot = rt.localRotation;
_prevParent = rt.parent;
_prevParent = piece.HomeParent;
_prevSiblingIndex = piece.HomeSiblingIndex;
_prevPos = piece.TrayPosition;
_prevSize = piece.TraySize;
_prevRot = Quaternion.identity;
}
public void Execute() => _piece.SnapInternal();
public void Undo() => _piece.UnsnapInternal(_prevParent, _prevPos, _prevSize, _prevRot);
public void Undo() => _piece.UnsnapInternal(_prevParent, _prevSiblingIndex, _prevPos, _prevSize, _prevRot);
}
}

View File

@@ -20,7 +20,7 @@ namespace Darkmatter.Features.ShapeBuilder.Systems
{
public class ShapeBuilderController : IShapeBuilderController, IDisposable
{
private const string PiecePrefabKey = "ShapeBuilder/Piece";
private const string PiecePrefabKey = "ShapePiece";
private string _currentTemplateId;
private int _expected;
@@ -173,6 +173,16 @@ namespace Darkmatter.Features.ShapeBuilder.Systems
return _snappedPieceIds;
}
public void DespawnDrawing()
{
foreach (var piece in _pieces)
if (piece != null) Object.Destroy(piece.gameObject);
_pieces.Clear();
if (_drawingInstance != null) Object.Destroy(_drawingInstance);
_drawingInstance = null;
}
public void Clear()
{
_undo.Drop();

View File

@@ -1,3 +1,4 @@
using Darkmatter.Core.Contracts.Features.GameplayFlow;
using Darkmatter.Core.Contracts.Features.History;
using Darkmatter.Core.Contracts.Services.Audio;
using Darkmatter.Core.Data.Static.Features.ShapeBuilder;
@@ -14,19 +15,22 @@ namespace Darkmatter.Features.ShapeBuilder.Systems
private readonly ISfxPlayer _sfx;
private readonly IEventBus _bus;
private readonly IUndoStack _undo;
private readonly IGameplaySceneRefs _refs;
public ShapePieceFactory(
ShapeHolderView holder,
ShapeBuilderConfig cfg,
ISfxPlayer sfx,
IEventBus bus,
IUndoStack undo)
IUndoStack undo,
IGameplaySceneRefs refs)
{
_holder = holder;
_cfg = cfg;
_sfx = sfx;
_bus = bus;
_undo = undo;
_refs = refs;
}
public ShapePiece Create(GameObject prefab, ShapeSO shape, SlotMarker slot, Vector2 trayPos)
@@ -35,7 +39,7 @@ namespace Darkmatter.Features.ShapeBuilder.Systems
go.name = $"Piece_{shape.Id}";
var piece = go.GetComponent<ShapePiece>();
piece.Setup(shape, slot, _cfg, _sfx, _bus, _undo, trayPos);
piece.Setup(shape, slot, _cfg, _sfx, _bus, _undo, trayPos, _refs.PaperRoot);
return piece;
}
}

View File

@@ -27,12 +27,19 @@ namespace Darkmatter.Features.ShapeBuilder.UI
private IUndoStack _undo;
private Vector2 _trayPos;
private Vector2 _traySize;
private RectTransform _dragRoot;
private Transform _homeParent;
private int _homeSiblingIndex;
// Per-drag state
private RectTransform _rt;
private RectTransform _parentRect;
private Camera _eventCam;
private Vector2 _grabOffset;
private Vector2 _trayPosInDragRoot;
private Vector2 _dragSizeDelta;
private Vector3 _dragLocalScale;
private Sequence _previewSeq;
private bool _locked;
private bool _inPreview;
@@ -40,6 +47,10 @@ namespace Darkmatter.Features.ShapeBuilder.UI
public string PieceId => _shape != null ? _shape.Id : null;
public bool IsLocked => _locked;
public RectTransform RectTransform => _rt != null ? _rt : (_rt = (RectTransform)transform);
public Transform HomeParent => _homeParent;
public int HomeSiblingIndex => _homeSiblingIndex;
public Vector2 TrayPosition => _trayPos;
public Vector2 TraySize => _traySize;
public void Setup(
ShapeSO shape,
@@ -48,7 +59,8 @@ namespace Darkmatter.Features.ShapeBuilder.UI
ISfxPlayer sfx,
IEventBus bus,
IUndoStack undo,
Vector2 trayPos)
Vector2 trayPos,
RectTransform dragRoot)
{
_shape = shape;
_slot = slot;
@@ -58,6 +70,10 @@ namespace Darkmatter.Features.ShapeBuilder.UI
_undo = undo;
_trayPos = trayPos;
_traySize = shape.DefaultSizeDelta;
_dragRoot = dragRoot;
_homeParent = RectTransform.parent;
_homeSiblingIndex = RectTransform.GetSiblingIndex();
image.sprite = shape.Sprite;
ApplyTrayPose();
@@ -66,8 +82,18 @@ namespace Darkmatter.Features.ShapeBuilder.UI
public void OnBeginDrag(PointerEventData e)
{
if (_locked) return;
if (_dragRoot != null && RectTransform.parent != _dragRoot)
{
RectTransform.SetParent(_dragRoot, worldPositionStays: true);
RectTransform.SetAsLastSibling();
}
_parentRect = (RectTransform)RectTransform.parent;
_eventCam = e.pressEventCamera;
_trayPosInDragRoot = RectTransform.anchoredPosition;
_dragSizeDelta = RectTransform.sizeDelta;
_dragLocalScale = RectTransform.localScale;
_grabOffset = RectTransform.anchoredPosition - ScreenToLocal(e.position);
_inPreview = false;
}
@@ -77,75 +103,122 @@ namespace Darkmatter.Features.ShapeBuilder.UI
if (_locked) return;
var pointerLocal = ScreenToLocal(e.position) + _grabOffset;
var slotPos = _slot.RectTransform.anchoredPosition;
float dist = Vector2.Distance(pointerLocal, slotPos);
bool insidePreview = IsOverSlot(e.position);
if (dist <= _cfg.PreviewRadius)
if (insidePreview && !_inPreview)
{
if (!_inPreview)
{
_inPreview = true;
_sfx.Play(SfxId.ShapeHover);
}
ApplyPreviewLerp(pointerLocal, dist);
_inPreview = true;
_sfx.Play(SfxId.ShapeHover);
AnimatePreviewPose(toSlot: true);
}
else
else if (!insidePreview && _inPreview)
{
_inPreview = false;
RectTransform.anchoredPosition = pointerLocal;
RectTransform.sizeDelta = _traySize;
RectTransform.localRotation = Quaternion.identity;
AnimatePreviewPose(toSlot: false);
}
if (!_inPreview)
RectTransform.anchoredPosition = pointerLocal;
}
public void OnEndDrag(PointerEventData e)
{
if (_locked) return;
float dist = Vector2.Distance(
RectTransform.anchoredPosition,
_slot.RectTransform.anchoredPosition);
if (dist <= _cfg.SnapRadius)
_undo.Push(new SnapPieceCommand(this));
if (IsOverSlot(e.position))
_undo.Push(new SnapPieceCommand(this));
else
ReturnToTray();
}
private void ApplyPreviewLerp(Vector2 pointerLocal, float dist)
private bool IsOverSlot(Vector2 screenPos)
{
float t = Mathf.Clamp01(1f - dist / _cfg.PreviewRadius);
if (_cfg.PreviewCurve != null) t = _cfg.PreviewCurve.Evaluate(t);
return RectTransformUtility.RectangleContainsScreenPoint(
_slot.RectTransform, screenPos, _eventCam);
}
var slot = _slot.RectTransform;
RectTransform.anchoredPosition = Vector2.Lerp(pointerLocal, slot.anchoredPosition, t);
RectTransform.sizeDelta = Vector2.Lerp(_traySize, slot.sizeDelta, t);
RectTransform.localRotation = Quaternion.Slerp(Quaternion.identity, slot.localRotation, t);
private void AnimatePreviewPose(bool toSlot)
{
if (_previewSeq.isAlive) _previewSeq.Stop();
if (toSlot)
{
var slot = _slot.RectTransform;
_previewSeq = Sequence.Create()
.Group(Tween.UIAnchoredPosition(RectTransform, SlotPosInDragSpace(), _cfg.SnapDuration, Ease.OutQuad))
.Group(Tween.LocalScale(RectTransform, SlotScaleInDragSpace(), _cfg.SnapDuration, Ease.OutQuad))
.Group(Tween.LocalRotation(RectTransform, SlotRotInDragSpace(), _cfg.SnapDuration, Ease.OutQuad))
.Group(Tween.UISizeDelta(RectTransform, slot.sizeDelta, _cfg.SnapDuration, Ease.OutQuad));
}
else
{
_previewSeq = Sequence.Create()
.Group(Tween.LocalScale(RectTransform, _dragLocalScale, _cfg.SnapDuration, Ease.OutQuad))
.Group(Tween.LocalRotation(RectTransform, Quaternion.identity, _cfg.SnapDuration, Ease.OutQuad))
.Group(Tween.UISizeDelta(RectTransform, _dragSizeDelta, _cfg.SnapDuration, Ease.OutQuad));
}
}
private Vector2 SlotPosInDragSpace()
{
Vector3 worldPos = _slot.RectTransform.position;
Vector3 local = _parentRect.InverseTransformPoint(worldPos);
Vector2 parentSize = _parentRect.rect.size;
Vector2 anchorRef = (RectTransform.anchorMin - _parentRect.pivot) * parentSize;
return new Vector2(local.x - anchorRef.x, local.y - anchorRef.y);
}
private void StopPreviewTweens()
{
if (_previewSeq.isAlive) _previewSeq.Stop();
_previewSeq = default;
_inPreview = false;
}
private Quaternion SlotRotInDragSpace()
{
return Quaternion.Inverse(_parentRect.rotation) * _slot.RectTransform.rotation;
}
private Vector3 SlotScaleInDragSpace()
{
Vector3 parentLossy = _parentRect.lossyScale;
Vector3 slotLossy = _slot.RectTransform.lossyScale;
return new Vector3(
slotLossy.x / Mathf.Max(0.0001f, parentLossy.x),
slotLossy.y / Mathf.Max(0.0001f, parentLossy.y),
slotLossy.z / Mathf.Max(0.0001f, parentLossy.z));
}
internal void SnapInternal()
{
StopPreviewTweens();
Lock();
var slot = _slot.RectTransform;
Tween.UIAnchoredPosition(RectTransform, slot.anchoredPosition, _cfg.SnapDuration, Ease.OutBack);
Tween.UISizeDelta (RectTransform, slot.sizeDelta, _cfg.SnapDuration, Ease.OutBack);
Tween.LocalRotation (RectTransform, slot.localRotation, _cfg.SnapDuration, Ease.OutBack);
Tween.Position(RectTransform, slot.position, _cfg.SnapDuration, Ease.OutBack);
Tween.Rotation(RectTransform, slot.rotation, _cfg.SnapDuration, Ease.OutBack);
Tween.LocalScale(RectTransform, slot.localScale, _cfg.SnapDuration, Ease.OutBack);
Tween.UISizeDelta(RectTransform, slot.sizeDelta, _cfg.SnapDuration, Ease.OutBack);
_sfx.Play(SfxId.ShapeSnap);
_bus.Publish(new PieceSnappedSignal(_shape.Id));
}
internal void UnsnapInternal(Transform parent, Vector2 pos, Vector2 size, Quaternion rot)
internal void UnsnapInternal(Transform parent, int siblingIndex, Vector2 pos, Vector2 size, Quaternion rot)
{
_locked = false;
image.raycastTarget = true;
RectTransform.SetParent(parent, worldPositionStays: false);
Tween.StopAll(onTarget: RectTransform);
Tween.UIAnchoredPosition(RectTransform, pos, _cfg.ReturnDuration, Ease.OutQuad);
Tween.UISizeDelta (RectTransform, size, _cfg.ReturnDuration, Ease.OutQuad);
Tween.LocalRotation (RectTransform, rot, _cfg.ReturnDuration, Ease.OutQuad);
RectTransform.SetParent(parent, worldPositionStays: false);
if (siblingIndex >= 0) RectTransform.SetSiblingIndex(siblingIndex);
RectTransform.anchoredPosition = pos;
RectTransform.sizeDelta = size;
RectTransform.localRotation = rot;
RectTransform.localScale = Vector3.one;
_sfx.Play(SfxId.ShapeReturn);
_bus.Publish(new PieceUnsnappedSignal(_shape.Id));
@@ -155,25 +228,37 @@ namespace Darkmatter.Features.ShapeBuilder.UI
{
Lock();
var slot = _slot.RectTransform;
RectTransform.anchoredPosition = slot.anchoredPosition;
RectTransform.sizeDelta = slot.sizeDelta;
RectTransform.localRotation = slot.localRotation;
RectTransform.position = slot.position;
RectTransform.rotation = slot.rotation;
RectTransform.localScale = slot.localScale;
RectTransform.sizeDelta = slot.sizeDelta;
}
private void ReturnToTray()
{
StopPreviewTweens();
_sfx.Play(SfxId.ShapeReturn);
Sequence.Create()
.Group(Tween.UIAnchoredPosition(RectTransform, _trayPos, _cfg.ReturnDuration, Ease.OutQuad))
.Group(Tween.UISizeDelta (RectTransform, _traySize, _cfg.ReturnDuration, Ease.OutQuad))
.Group(Tween.LocalRotation (RectTransform, Quaternion.identity, _cfg.ReturnDuration, Ease.OutQuad));
.Group(Tween.UIAnchoredPosition(RectTransform, _trayPosInDragRoot, _cfg.ReturnDuration, Ease.OutQuad))
.Group(Tween.LocalScale(RectTransform, _dragLocalScale, _cfg.ReturnDuration, Ease.OutQuad))
.Group(Tween.LocalRotation(RectTransform, Quaternion.identity, _cfg.ReturnDuration, Ease.OutQuad))
.Group(Tween.UISizeDelta(RectTransform, _dragSizeDelta, _cfg.ReturnDuration, Ease.OutQuad))
.ChainCallback(RestoreToHomeParent);
}
private void RestoreToHomeParent()
{
if (this == null || RectTransform == null || _homeParent == null) return;
RectTransform.SetParent(_homeParent, worldPositionStays: false);
RectTransform.SetSiblingIndex(_homeSiblingIndex);
ApplyTrayPose();
}
private void Lock()
{
_locked = true;
image.raycastTarget = false;
RectTransform.SetParent(_slot.RectTransform.parent, worldPositionStays: false);
RectTransform.SetParent(_slot.RectTransform.parent, worldPositionStays: true);
}
private void ApplyTrayPose()