Sprite Mixer
Knowledge & experience
- ASP.NET Core 6 for creation of licensing API for key verification and update downloading
- ASP.NET Core 6 for creation of application launcher, that makes it possible to associate extension names
- Majority of the code is asynchronous to avoid stalling (image loading, combination calculations)
- Used the Uni Task plugin to avoid unnecessary allocations
- Coded a node system to link combinations together and automatically recalculates the combination count
- Inno setup for Windows installation
Sprite Mixer is a tool that allows you to quickly create combinations of images. Useful for game development & media creation. The tool has been developed in Unity. The choice for this was easy portability and prior experience with using Unity.
Code Samples
How the data is reloaded from save (sheet and node elements)
Sheet elements are the main graphical elements you can modify, resize adjust, change base image on.
Node elements are part of the node graph. Whereas the “output” node is connected to the sheet element.
The data from the output node will directly influence the output possibilities for a given sheet element.
For example, the eyes or mouth in the video.
private async void Load()
{
isLoadingProject = true;
// Configure elements
foreach (var data in savedData.elementData.Values)
SpawnSheetElement(data.Position, data.Id, data, false);
List<NodeElement> invalidNodes = new List<NodeElement>();
// Set nodes
foreach (var nodeData in savedData.nodeData.Values)
{
var spawnedNode = SpawnNode(nodeData.NodeType, data: nodeData);
spawnedNode.OnLoad();
// In case it is a output node, set owner (sheet element).
if (nodeData.NodeType != typeof(NodeElementOutput)) continue;
if (sheetElements.TryGetValue(nodeData.OwnerId, out var sheetElement))
{
NodeElementOutput outputNode = spawnedNode as NodeElementOutput;
if (outputNode == null)
continue;
outputNode.SetOwner(sheetElement);
outputNodes.Add(outputNode);
// Clear out the output node if the sheet element is destroyed.
sheetElement.OnDestroyAsObservable()
.Subscribe(_ => RemoveNode(outputNode))
.AddTo(outputNode);
sheetElement.OutputNode = outputNode;
}
else
{
Debug.LogError($"No owner found for node output element? id: {nodeData.OwnerId}");
invalidNodes.Add(spawnedNode);
}
}
foreach (var node in invalidNodes)
RemoveNode(node);
// Reconnect nodes
foreach (var nodeConnection in savedData.nodeConnectionData.Values)
foreach (var data in nodeConnection)
CreateConnection(data, addToDictionary:false, recalculateGraph:false);
Signals.Signals.LOADING_UPDATETASK.Dispatch(new LoadProgress
{
progress = 0,
isDone = false,
taskName = "Loading Project"
});
while (!IsLoading().isDone)
{
Signals.LOADING_UPDATETASK.Dispatch(new LoadProgress
{
progress = IsLoading().progress, // Iterates over each node to get active status.
isDone = false,
taskName = "Loading Project"
});
await UniTask.Delay(TimeSpan.FromSeconds(0.15f));
}
Signals.LOADING_UPDATETASK.Dispatch(new LoadProgress
{
progress = 1,
isDone = true,
taskName = "Loading Project"
});
isLoadingProject = false;
RecalculateGraph();
}
How images are indexed from a directory
All indexed directories are being watched using the FileSystemWatcher class. I’ve created DirectoryWatcher so that it automatically watches all files in the directory. And emits events in case of changes. Reloading results if necessary.
The progress parameter is used to display loading status on the “SelectRandomFile” node.
public static async UniTask<List<string>> ObtainImagePaths(string directory, IProgress<int> progress,
bool addWatcher = true)
{
if (instance.isLoadingPath.ContainsKey(directory))
await UniTask.Yield();
if (instance.cachedFolderContents.TryGetValue(directory, out var arr))
return arr;
instance.isLoadingPath.TryAdd(directory, true);
#if !UNITY_WEBGL
await UniTask.SwitchToThreadPool();
#endif
List<string> fetchResults = new List<string>();
var resultCount = 0;
int updateCount = 100;
foreach (var importType in ResourceImageLoader.GetSupportedImportTypes())
{
string searchPattern = $"*.{importType}";
foreach (string f in Directory.EnumerateFiles(directory, searchPattern, SearchOption.AllDirectories))
{
resultCount++;
updateCount--;
fetchResults.Add(f);
// Update the results per 100 objects.
if (updateCount > 0)
continue;
#if !UNITY_WEBGL
await UniTask.SwitchToMainThread();
#endif
progress?.Report(resultCount);
#if !UNITY_WEBGL
await UniTask.SwitchToThreadPool();
#endif
updateCount = 100;
}
}
var a = fetchResults.ToArray();
Array.Sort(a, new AlphanumComparatorFast());
fetchResults.Clear();
fetchResults.AddRange(a);
await UniTask.SwitchToMainThread();
if (addWatcher)
{
try
{
var watcher = new DirectoryWatcher(directory, "*.png", "*.jpg");
watcher.AddListener(instance);
if (!instance.watcher.ContainsKey(directory))
instance.watcher.TryAdd(directory, watcher);
}
catch (Exception e)
{
Debug.Log(e);
}
}
instance.cachedFolderContents.TryAdd(directory, fetchResults);
instance.isLoadingPath.TryRemove(directory, out var v);
return fetchResults;
}
Code of the "Random Element From Folder" node
The ILoad interface is used to fetch loading progress.
IPathWatchCallback interface is used to get a callback if the current path has had any changes
using System;
using System.Collections.Generic;
using System.IO;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json;
using TMPro;
using UnityEngine;
public class NodeElementRandomFromFolder : NodeElement, ILoad, IPathWatchCallback
{
[Header("References")]
[SerializeField] private TextMeshProUGUI textImageCount;
[SerializeField] private InspectorFieldPath fieldPathInspector;
[SerializeField] private GameObject loadingImage;
protected override void Loaded() => fieldPathInspector.OnFieldUpdated += OnUpdatedFilePath;
protected override void UnLoaded()
{
fieldPathInspector.OnFieldUpdated -= OnUpdatedFilePath;
ResourceFileIndexer.RemoveDirectoryListener(activePath, this);
}
private int resultCount;
private float progress;
private string activePath = "";
public string ActivePath => fieldPathInspector.GetFieldValue();
public override async UniTask<List<OutputPossibility>> GetOutputs(IProgress<LoadProgress> progress)
{
// If no path, return null.
if (!HasValidPathSelected())
return null;
var filePaths = await GetFilePaths();
if (filePaths == null)
return null;
List<OutputPossibility> possibilities = new List<OutputPossibility>(filePaths.Count);
// Generate basic outputs, no processing. Just the files.
foreach (var filepath in filePaths)
{
possibilities.Add(new OutputPossibility
{
baseImage = filepath,
originNodeId = data.Id,
effectNodeIds = "",
finalImageId = ""
});
}
return possibilities;
}
private bool HasValidPathSelected() => !string.IsNullOrEmpty(fieldPathInspector.GetFieldValue());
private async UniTask<List<string>> GetFilePaths()
{
return await ResourceFileIndexer.ObtainImagePaths(fieldPathInspector.GetFieldValue(), null);
}
public override UniTask<Texture2D> Process(Texture2D texture2D) => new(null);
private async void OnUpdatedFilePath(string path) => await IndexFiles(path);
private async UniTask IndexFiles(string path)
{
if (activePath != path && !string.IsNullOrEmpty(activePath))
ResourceFileIndexer.RemoveDirectoryListener(activePath, this);
activePath = path;
progress = 0;
// In case path isn't valid or empty
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path) || !Path.IsPathRooted(path))
{
textImageCount.SetText("0 Images found");
progress = 1;
return;
}
try
{
loadingImage.gameObject.SetActive(true);
}
catch (Exception e)
{
Debug.Log(e);
throw;
}
var p = new Progress<int>(_ =>
{
textImageCount.SetText($"{_.ToString()} Images found");
progress = Mathf.Clamp(_ * 0.01f, 0, 0.95f);
});
try
{
var task = ResourceFileIndexer.ObtainImagePaths(path, p);
var r = await task;
textImageCount.SetText($"{r.Count.ToString()} Images found");
}
catch (Exception e)
{
Debug.Log(e);
}
finally
{
await UniTask.SwitchToMainThread();
loadingImage.gameObject.SetActive(false);
progress = 1;
Signals.DATA_REQUESTRELOAD.Dispatch();
ResourceFileIndexer.AddDirectoryListener(path, this);
}
}
public override void OnSave()
{
data.CustomNodeData = JsonConvert.SerializeObject(new FilePath(fieldPathInspector.GetFieldValue()));
}
public override void OnLoad()
{
if (string.IsNullOrEmpty(data.CustomNodeData))
return;
if (data.CustomNodeData.TryParseJson<FilePath>(out var filePath))
{
fieldPathInspector.SetValue(filePath);
OnUpdatedFilePath(filePath);
}
else
{
OnUpdatedFilePath("");
}
}
public LoadProgress GetProgress()
{
return new LoadProgress
{
progress = progress,
isDone = progress >= 1,
taskName = "Node: Random images from folder"
};
}
public void OnPathFileChanged(string path, bool isFile, DirectoryWatcher.EventCause result)
{
OnUpdatedFilePath(path);
}
}