This project was made in 2019 ~ 2020. It’s made in C# and uses the Unity engine. This was a group assignment with a group consisting of 5 people. This assignment came from an external client.
Source Control
We used GitHub for our source control solution. We tried to use git to the best we could for this project.
This meant that we should use Pull Requests
instead of merging our branching into master
or dev
. This resulted in a lot less merge conflicts.
When a new Pull Request
was made, other members would review the code for quality, comments and the code style. If the reviewer has nothing to note the pull request is approved and the Pull Request
can be merged.
I got better at reviewing code over the course of this project, thanks to all the reviewing I had to do when a new Pull Request came through. The code quality of entire project team also quickly improved because the we used these code reviews. Thanks to the reviews, we could all chip in and say how we would tackle an issue in a different approach, which might be better.
We also made sure to add comments whenever we felt something wasn’t clear when you read the code itself. Which made me appreciate comments a lot more. Before this project I wasn’t aware of all the special stuff you could do with comments, such as reference parameters, methods and other stuff.
Drawing
The thing I’m most proud of during this project was the drawing system.
I was in charge on the drawing, and received little help with it. When I first started, I thought this would take a few weeks at least, since we’d have to find out how to write to textures in run-time. Thankfully I found a asset we could use in our project, called Free Draw.
I took a look in the code to see how it worked and if it could be optimized, and found out that the brushes were methods assigned to a delegate;
public delegate void Brush_Function(Vector2 world_position);
// This is the function called when a left click happens
// Pass in your own custom one to change the brush type
// Set the default function in the Awake method
public Brush_Function current_brush;
...
// Default brush type. Has width and colour.
// Pass in a point in WORLD coordinates
// Changes the surrounding pixels of the world_point to the static pen_colour
public void PenBrush(Vector2 world_point)
{
Vector2 pixel_pos = WorldToPixelCoordinates(world_point);
cur_colors = drawable_texture.GetPixels32();
if (previous_drag_position == Vector2.zero)
{
// If this is the first time we've ever dragged on this image, simply colour the pixels at our mouse position
MarkPixelsToColour(pixel_pos, Pen_Width, Pen_Colour);
}
else
{
// Colour in a line from where we were on the last update call
ColourBetween(previous_drag_position, pixel_pos, Pen_Width, Pen_Colour);
}
ApplyMarkedPixelChanges();
//Debug.Log("Dimensions: " + pixelWidth + "," + pixelHeight + ". Units to pixels: " + unitsToPixels + ". Pixel pos: " + pixel_pos);
previous_drag_position = pixel_pos;
}
...
// Helper method used by UI to set what brush the user wants
// Create a new one for any new brushes you implement
public void SetPenBrush()
{
// PenBrush is the NAME of the method we want to set as our current brush
current_brush = PenBrush;
}
I didn’t like the way this was done, because it would bloat the Drawable
script if you wanted a lot of different pens shapes, different colours, and other stuff. Which was something I wanted.
I rewrote the code and turned the brushes from delegates into ScriptableObjects
.
public abstract class AbstractBrush : ScriptableObject
{
[SerializeField] protected Color color = Color.black;
[SerializeField] protected Vector2Int radius = new Vector2Int(5, 5);
[SerializeField] private bool selected;
[ReadOnly] [SerializeField] private bool pixelChangedLastFrame;
public Color Color => color;
public bool Selected
{
get => selected;
set => selected = value;
}
public Vector2Int PixelPosition { get; private set; }
public int RadiusMultiplier { private get; set; } = 1;
public bool PixelChangedLastFrame => pixelChangedLastFrame;
protected Color32[] CurrentColors;
private Vector2Int _previousPixelPosition;
private void OnEnable()
{
selected = false;
}
private void OnDisable()
{
selected = false;
}
/// <summary>
/// Draws on the <paramref name="drawable"/> with this brush on the <paramref name="position"/>.
/// </summary>
/// <param name="position">World Position to draw on.</param>
/// <param name="drawable">Drawable to draw on.</param>
public virtual void Draw(Vector2 position, Drawable drawable)
{
PixelPosition = WorldToPixelCoordinates(position, drawable);
CurrentColors = GetFromTexture(drawable);
// Check if PreviousDrag is null.
if (drawable.PreviousDragPosition == null)
{
MarkPixelsToColor(PixelPosition, drawable);
}
else
{
// PreviousDrag isn't null, so we can safely get its value.
ColorInLine(drawable.PreviousDragPosition.Value, PixelPosition, drawable);
}
ApplyMarkedPixelChanges(drawable);
drawable.PreviousDragPosition = PixelPosition;
}
/// <summary>
/// Method which gets called by <see cref="Drawable.Update()"/> for when a brush needs to be updated with Time.
/// </summary>
public virtual void Update()
{
pixelChangedLastFrame = PixelPosition != _previousPixelPosition;
_previousPixelPosition = PixelPosition;
}
private Vector2Int Radius() => radius * RadiusMultiplier;
/// <summary>
/// Retrieve Color32 array from <see cref="Drawable"/>.
/// </summary>
/// <param name="drawable">Drawable to retrieve the colors from.</param>
/// <returns>
/// All colors present in the <see cref="Drawable"/>.
/// </returns>
protected Color32[] GetFromTexture(Drawable drawable)
{
return drawable.Texture.GetPixels32();
}
/// <summary>
/// Change <see cref="worldPosition"/> to a usable position for a texture.
/// </summary>
/// <param name="worldPosition">Position of the click in world space.</param>
/// <param name="drawable">The <see cref="Drawable"/> to draw on.</param>
/// <returns>
/// Coordinates for a pixel that is converted from <see cref="worldPosition"/>.
/// </returns>
protected virtual Vector2Int WorldToPixelCoordinates(Vector2 worldPosition, Drawable drawable)
{
// Change coordinates to local coordinates of this image.
var localPosition = drawable.transform.InverseTransformPoint(worldPosition);
return PositionToPixelCoordinates(localPosition, drawable);
}
/// <summary>
/// Change <see cref="position"/> to a usable position for a texture.
/// </summary>
/// <param name="position">Position of the click.</param>
/// <param name="drawable">The <see cref="Drawable"/> to draw on.</param>
/// <returns>
/// Coordinates for a pixel that is converted from <see cref="position"/>.
/// </returns>
protected virtual Vector2Int PositionToPixelCoordinates(Vector2 position, Drawable drawable)
{
// Change these to coordinates of pixels.
var pixelWidth = drawable.Sprite.rect.width;
var pixelHeight = drawable.Sprite.rect.height;
var unitsToPixels = pixelWidth / drawable.Sprite.bounds.size.x * drawable.transform.localScale.x;
// Need to center our coordinates.
var centeredX = (int) Math.Round(position.x * unitsToPixels + pixelWidth / 2);
var centeredY = (int) Math.Round(position.y * unitsToPixels + pixelHeight / 2);
// Round current mouse position to nearest pixel.
return new Vector2Int(centeredX, centeredY);
}
// Set the color of pixels in a straight line from start_point all the way to end_point,
// to ensure everything in between is colored.
/// <summary>
/// Set the color of pixels in a straight line from <paramref name="startPoint"/> up to <paramref name="endPoint"/>
/// this is to ensure everything in between gets colored.
/// </summary>
/// <param name="startPoint">Starting position for the line.</param>
/// <param name="endPoint">End position for the line.</param>
/// <param name="drawable">Where the line should be drawn on.</param>
protected virtual void ColorInLine(Vector2 startPoint, Vector2 endPoint, Drawable drawable)
{
// Get the distance from start to finish.
var distance = Vector2.Distance(startPoint, endPoint);
// Calculate how many times we should interpolate between startPoint and endPoint.
// based on the amount of time that has passed since the last update.
var lerpSteps = 1 / distance;
for (var lerp = 0f; lerp <= 1; lerp += lerpSteps)
{
var currentPosition = Vector2.Lerp(startPoint, endPoint, lerp);
MarkPixelsToColor(currentPosition, drawable);
}
}
/// <summary>
/// Mark pixels that should be colored.
/// </summary>
/// <param name="centerPixel">The position to start from.</param>
/// <param name="drawable">Drawable on which the pixels should be marked.</param>
protected virtual void MarkPixelsToColor(Vector2 centerPixel, Drawable drawable)
{
// Figure out how many pixels we need to color in each direction (x and y).
var centerX = (int) centerPixel.x;
var centerY = (int) centerPixel.y;
// Start from the left of the sprite and loop through it until we hit the end.
for (var x = centerX - Radius().x; x <= centerX + Radius().x; x++)
{
// Check if the X wraps around the image, so we don't draw pixels on the other side of the image.
if (IsInsideDrawable(x, (int) drawable.Sprite.rect.width))
{
continue;
}
// Start from the bottom of the sprite and loop through it until we hit the top.
for (var y = centerY - Radius().y; y <= centerY + Radius().y; y++)
{
// Check if the Y wraps around the image, so we don't draw pixels on the other side of the image.
if (IsInsideDrawable(y, (int) drawable.Sprite.rect.height))
{
continue;
}
// Check the distance from the center to the current x and y.
var distanceFromCenter = Math.Sqrt(Math.Pow(x - centerX, 2) + Math.Pow(y - centerY, 2));
// If the distance is bigger than Radius it should be skipped, so we get a circle.
if (distanceFromCenter > ((Radius().x + Radius().y) / 2))
{
continue;
}
MarkPixelToChange(x, y, drawable);
}
}
}
/// <summary>
/// Mark pixels to be changed.
/// </summary>
/// <param name="x">X position of the pixel.</param>
/// <param name="y">Y position of the pixel.</param>
/// <param name="drawable">Drawable on which the pixels should be changed.</param>
protected virtual void MarkPixelToChange(int x, int y, Drawable drawable)
{
// Need to transform x and y coordinates to flat coordinates of array.
var arrayPos = y * (int) drawable.Sprite.rect.width + x;
// Check if this is a valid position.
if (arrayPos >= CurrentColors.Length || arrayPos < 0)
{
return;
}
CurrentColors[arrayPos] = color;
}
/// <summary>
/// <para>
/// Directly colors Pixels.
/// Colours both the center pixel and a number of pixels around the center based on <see cref="Radius"/>.
/// </para>
/// <para>
/// This methods is slower than using <see cref="MarkPixelsToColor"/>.
/// </para>
/// </summary>
/// <param name="centerPixel">The position to start from.</param>
/// <param name="drawable">Drawable on which the pixels should be marked.</param>
protected virtual void ColorPixels(Vector2 centerPixel, Drawable drawable)
{
// Figure out how many pixels we need to color in each direction (x and y)
var centerX = (int) centerPixel.x;
var centerY = (int) centerPixel.y;
for (var x = centerX - Radius().x; x <= centerX + Radius().x; x++)
{
for (var y = centerY - Radius().y; y <= centerY + Radius().y; y++)
{
drawable.Texture.SetPixel(x, y, color);
}
}
drawable.Texture.Apply();
}
protected virtual void ApplyMarkedPixelChanges(Drawable drawable)
{
drawable.Texture.SetPixels32(CurrentColors);
drawable.Texture.Apply();
}
protected bool IsInsideDrawable(int pixelPosition, int axisToCheckFor)
{
return pixelPosition >= (int) axisToCheckFor || pixelPosition < 0;
}
}
I chose to make this an abstract so new brushes could easily overwrite specific methods and properties without having to write a lot of duplicate code.
For instance, if I wanted to create a brush that draws a rainbow, all I would have to do is write a few lines of code for it to work.
[CreateAssetMenu(fileName = "Rainbow Brush", menuName = "Brushes/Rainbow Pen Brush")]
public class RainbowPenBrush : AbstractBrush
{
[SerializeField] private float speed = 1f;
/// <summary>
/// Updates color over time to match a rainbow effect.
/// </summary>
public override void Update()
{
base.Update();
// Mathf.PingPong keeps looping between (Time.time * speed) and 1.
color = new HSBColor(Mathf.PingPong(Time.time * speed, 1), 1, 1).ToColor();
}
}
This would give me the following result when I use the brush;
This change made the rest of the Drawable
class a shorter and, in my opinion, easier to understand.
Over time, we had to add more stuff to Drawable
such as outlines, merging two textures together and brush size multipliers.
public class Drawable : MonoBehaviour
{
[SerializeField] protected AbstractBrush currentBrush;
[SerializeField] protected bool resetCanvasOnPlay = true;
[SerializeField] protected Color defaultColor = new Color(255, 255, 255, 1);
[SerializeField] protected LayerMask drawingLayers;
[SerializeField] protected Texture2D textureToReplace;
[SerializeField] protected SpriteRenderer spriteRenderer;
[SerializeField] protected SpriteRenderer outlineSprite;
public Sprite Sprite => spriteRenderer.sprite;
public Texture2D Texture { get; private set; }
private string FileName => Item.Id.ToString();
public Item Item { get; set; }
public Vector2? PreviousDragPosition { get; set; }
public bool MergeOutline { get; set; }
protected bool NoDrawingOnCurrentDrag;
protected bool MouseWasPreviouslyHeldDown;
private Color[] _cleanColorsArray;
private int _brushRadiusMultiplier = 1;
private const string FolderName = "Pictures";
private const string FileExtension = "bin";
private void Awake()
{
// Initialize clean pixels to use.
_cleanColorsArray = new Color[(int) Sprite.rect.width * (int) Sprite.rect.height];
for (var x = 0; x < _cleanColorsArray.Length; x++)
{
_cleanColorsArray[x] = defaultColor;
}
// Should we reset our canvas image when we hit play in the editor?
if (resetCanvasOnPlay)
{
ResetCanvas();
}
currentBrush.Selected = true;
}
/// <summary>
/// Call <see cref="AbstractBrush.Update()"/> if it needs to update despite it being an ScriptableObject.
/// </summary>
protected virtual void Update()
{
currentBrush.Update();
}
/// <summary>
/// Switch brushes.
/// Also resets RadiusMultiplier and applies <see cref="_brushRadiusMultiplier"/>
/// to the new <see cref="currentBrush"/>.
/// </summary>
/// <param name="brush">Brush that is now going to be the current brush.</param>
public void SetBrush(AbstractBrush brush)
{
currentBrush.Selected = false;
currentBrush.RadiusMultiplier = 1;
currentBrush = brush;
currentBrush.Selected = true;
currentBrush.RadiusMultiplier = _brushRadiusMultiplier;
}
/// <summary>
/// Add a multiplier to the radius size of the currently selected <see cref="AbstractBrush"/>.
/// </summary>
/// <param name="multiplier">By how much the radius should be multiplied.</param>
public void SetBrushRadiusMultiplier(int multiplier)
{
_brushRadiusMultiplier = multiplier;
currentBrush.RadiusMultiplier = multiplier;
}
/// <summary>
/// Change every pixel to the reset color.
/// </summary>
[Button("Reset")]
public void ResetCanvas()
{
if (Texture == null)
{
return;
}
Texture.SetPixels(_cleanColorsArray);
Texture.Apply();
}
/// <summary>
/// Instantiate a texture based on <see cref="textureToReplace"/>.
/// </summary>
public void AssignCleanTexture()
{
Texture = Instantiate(textureToReplace);
// The pixels per unit as set by the drawing canvas.
const float pixelsPerUnit = 100f;
spriteRenderer.sprite = Sprite.Create(
Texture,
new Rect(0, 0, Texture.width, Texture.height),
// Set pivot to the center of the image.
new Vector2(0.5f, 0.5f),
pixelsPerUnit);
}
[Button("Save")]
public void Save()
{
if (MergeOutline)
{
Texture = MergeTextures(Texture, outlineSprite.sprite.texture);
}
var textureData = Texture.GetRawTextureData();
Item.TextureData = textureData;
BinaryDataLoader.Get.Save(textureData, FolderName, FileName, FileExtension);
}
[Button("Load")]
public void Load()
{
Texture.LoadRawTextureData(BinaryDataLoader.Get.Load(FolderName, FileName, FileExtension));
Texture.Apply();
}
/// <summary>
/// Merge the drawing and outline image into a single Texture.
/// </summary>
/// <param name="drawing">The drawing that should be merged.</param>
/// <param name="outline">The outline that should be merged.</param>
/// <returns>
/// The <paramref name="drawing"/> which is merged with the <paramref name="outline"/>.
/// </returns>
private Texture2D MergeTextures(Texture2D drawing, Texture2D outline)
{
var startX = 0;
var startY = drawing.height - outline.height;
for (var x = startX; x < drawing.width; x++)
{
for (var y = startY; y < drawing.height; y++)
{
var drawingColor = drawing.GetPixel(x, y);
var outlineColor = outline.GetPixel(x, y);
var finalColor = Color.Lerp(drawingColor, outlineColor, outlineColor.a / 1f);
drawing.SetPixel(x, y, finalColor);
}
}
drawing.Apply();
return drawing;
}
}