The first question to answer is how to represent our canvas. Here, we’ll go with a matrix of strings which we will tint by placing space characters (" ") on a background colored with ANSI escape codes.
const PIXEL = " "
const COORD_ORIGIN = (1, 1) # origin or the coordinate system (row, col)
# "\x1b[48:5:XXXm" sets background color to XXX color code (256-color mode)
const BG_COLORS = Dict(
:gray => "\x1b[48:5:8m",
:white => "\x1b[48:5:15m",
:red => "\x1b[48:5:160m",
:yellow => "\x1b[48:5:11m",
:blue => "\x1b[48:5:12m",
:darkblue => "\x1b[48:5:20m",
:green => "\x1b[48:5:35m",
:black => "\x1b[48:5:0m",
:brown => "\x1b[48:5:88m",
)
# "\x1b[0m" resets background color to default value
# default color: "\x1b[48:5:13m" - pink
function getBgColor(color::Symbol, colors::Dict{Symbol, Str}=BG_COLORS)::Str
return get(colors, color, "\x1b[48:5:13m") * PIXEL * "\x1b[0m"
end
canvas = fill(getBgColor(:gray), 30, 60)
Note. Using
constwith mutable containers like vectors or dictionaries allows to change their contents later on, e.g., withpush!. So theconstused here is more like a convention, a signal that we do not plan to change the containers in the future. If we really wanted an immutable container then we should consider a(n) (immutable) tuple. Anyway, some programming languages suggest thatconstnames should be declared using all uppercase characters to make them stand out. Here, I follow this convention.
Next, we want a way to properly display canvas (printCanvas) and to clear it (clearCanvas!). This last method will allow us to erase an incorrect drawing and try again and again if we need to.
function printCanvas(cvs::Matrix{Str}=canvas)::Nothing
nRows, _ = size(cvs)
for r in 1:nRows
println(cvs[r, :] |> join)
end
return nothing
end
function clearCanvas!(cvs::Matrix{Str}=canvas)::Nothing
cvs .= getBgColor(:gray)
return nothing
end
Now, to draw a picture we will need a few shapes, most likely: a rectangle, a triangle and an oval/circle. A shape will be represented as a vector of positions in our matrix (canvas). The positions need to be dyed with a specific color to visualize an object. Let’s start with a rectangle as this should be the easiest shape to obtain.
const Pos = Tuple{Int, Int} # position, (row, col) in canvas
function getRectangle(width::Int, height::Int)::Vec{Pos}
@assert width >= 2 "width must be >= 2"
@assert height >= 2 "height must be >= 2"
rectangle::Vec{Pos} = Vec{Pos}(undef, width * height)
rowStart::Int, colStart::Int = COORD_ORIGIN
i::Int = 1
for row in rowStart:height, col in colStart:width
rectangle[i] = (row, col)
i += 1
end
return rectangle
end
Each rectangle is represented as a vector of positions (Vec{Pos}). It will start at the origin of our coordinate system (COORD_ORIGIN - top left corner of our matrix). It will spread through as many rows and columns as there are needed. Their numbers will be calculated based on the height (startX:height) and width (startY:width) of the canvas. Such a rectangle (the one that starts in the coordinate system origin point) is a good start, but to draw a picture we need to be able to place a shape in any location on the canvas.
# moves a shape by (nRows, nCols)
function nudge(shape::Vec{Pos}, by::Pos)::Vec{Pos}
return map(pt -> pt .+ by, shape)
end
# shifts a shape so that its anchor point starts where we want
function shift(shape::Vec{Pos}, anchor::Pos)::Vec{Pos}
shift::Pos = anchor .- COORD_ORIGIN
return nudge(shape, shift)
end
function getRectangle(width::Int, height::Int, topLeftCorner::Pos)::Vec{Pos}
return shift(getRectangle(width, height), topLeftCorner)
end
So far so good. Time to find a way to add the points that build our shape to the canvas (notice, that we only add the points that are inside of our canvas).
function isWithinCanvas(point::Pos, cvs::Matrix{Str}=canvas)::Bool
nRows, nCols = size(cvs)
row, col = point
return (0 < row <= nRows) && (0 < col <= nCols)
end
function addPoints!(shape::Vec{Pos}, color::Symbol,
cvs::Matrix{Str}=canvas)::Nothing
for pt in shape
if isWithinCanvas(pt, cvs)
cvs[pt...] = getBgColor(color)
end
end
return nothing
end
Once, we got it we can move to another shape, i.e. a triangle.
function getTriangle(height::Int)::Vec{Pos}
@assert height > 1 "height must be > 1"
rowStart::Int, colStart::Int = COORD_ORIGIN
lCol::Int = colStart # 1
rCol::Int = colStart # 2
triangle::Vec{Pos} = []
for row in rowStart:height
for col in lCol:rCol
push!(triangle, (row, col))
end
lCol -= 1
rCol += 1
end
return triangle
end
function getTriangle(height::Int, apex::Pos)::Vec{Pos}
return shift(getTriangle(height), apex)
end
Our triangle’s top starts with a pixel (lCol and rCol are initialized with the same value) in the origin of our coordinate system (COORD_ORIGIN). Then for each row (for row in rowStart:height) we dye each pixel between the left (lCol) and right (rCol) columns (inclusive-inclusive). The basis of the triangle is increased by one pixel on each side (lCol -= 1 and rCol += 1) with each row we move down. Of note, we could have shortened the above snippet, e.g. by using a C-like chained assignment (lCol = rCol = colStart instead of lines #1 and #2). However, the longer version might be clearer and easier to follow in a head.
There’s one more shape left, a circle.
function getCircle(radius::Int)::Vec{Pos}
@assert 1 < radius < 6 "radius must be in range [2-5]"
cols::Vec{Vec{Int}} = [collect((-1-r):(2+r)) for r in 0:(radius-1)]
cols = [cols..., reverse(cols)...]
triangle::Vec{Pos} = []
rowStart::Int, _ = COORD_ORIGIN
for row in rowStart:(radius*2)
for col in cols[row]
push!(triangle, (row, col))
end
end
return triangle
end
function getCircle(radius::Int, topCenter::Pos)::Vec{Pos}
return shift(getCircle(radius), topCenter)
end
Here, we use a pattern similar to the one from the triangle. A circle is started in the top row (rowStart) with three columns (collect((-1-r):(2+r))). With every row down we increase the spread by 1 column in each direction (r changes by 1). Once, we are in half of our circle we decrease the number of colored columns. We achieve that by combining the previous cols with their reversed version (... is a splat operator that, unpacks a vector by copying its elements).
Finally, we proceed to create our pixel-art graphics, e.g. by iteratively adding one element at a time with something like:
clearCanvas!()
addPoints!(getSomeShape, :someColor)
printCanvas()
and
clearCanvas!()
addPoints!(getSomeShape, :someColor)
addPoints!(getAnotherShape, :someOtherColor)
printCanvas()
Until we reach a satisfactory result with a code snippet similar to the one below:
clearCanvas!()
addPoints!(getRectangle(60, 15, (16, 1)), :green) # meadow
addPoints!(getRectangle(60, 15), :blue) # sky
addPoints!(getRectangle(15, 8, (15, 21)), :white) # house walls
addPoints!(getRectangle(6, 6, (17, 28)), :brown) # doors
addPoints!(getRectangle(4, 2, (16, 22)), :darkblue) # window
addPoints!(getRectangle(4, 6, (8, 31)), :black) # chimney
addPoints!(getTriangle(8, (7, 28)), :red) # roof
addPoints!(getCircle(4, (2, 55)), :yellow) # sun
addPoints!(getCircle(3, (4, 7)), :white) # cloud, part 1
addPoints!(getCircle(4, (4, 14)), :white) # cloud, part 2
addPoints!(getCircle(2, (2, 18)), :white) # cloud, part 3
printCanvas()