2025-01-04 – An analog clock
This project aims at building an analog clock in the TIC-80 fantasy console through iterative development and literate programming. Each iteration will incrementally add new features and refine existing ones, starting with a simple timer-like clock in iteration 1 and evolving from there.
In this project, functions are intentionally kept small and focused, and are designed to operate on tables (or maps).
Iteration 1: analog clock with a second hand
We will begin with a simple analog clock featuring only a second hand. In the code, this clock can be represented in two ways:
- Time representation
- The number of seconds that have elapsed
since the most recent minute. It is an integer in the range
[0,60[
. We will refer to this representation as a clock in the code. - Geometric representation
- The angle in degrees between the
second hand and the 12 o'clock position. It is an integer in the
range
[0,360[
. We will refer to this representation as a geometric clock in the code, or geoclock (a kind of geometrical object).
We will start writing the program by converting an arbitrary number of seconds into a clock:
(fn seconds->clock [seconds] (% seconds 60))
Converting a clock to a geoclock is equally straightforward:
(fn clock->geoclock [clock] (* (/ clock 60) 360))
Next, we need to actually draw an analog clock in the TIC-80. To begin simply, we draw a static hand at the 12 o'clock position. This is done by rendering a 50-pixel line extending upward from the center of the screen:
(fn _G.TIC [] (cls 8) (let [center-x (/ 240 2) center-y (/ 136 2)] (line center-x center-y center-x (- center-y 50) 12)))
This results in a basic representation of the second hand:
The above implementation is functional but lacks abstraction. To address this, we introduce helper functions to organize and generalize the drawing logic:
(fn half [x] (/ x 2)) (fn point [x y] {: x : y }) (fn pline [p1 p2 color] (line p1.x p1.y p2.x p2.y color)) (fn draw-hand [center size color] (pline center (point center.x (- center.y size)) color)) (local center (point (half 240) (half 136))) (fn _G.TIC [] (cls 8) (let [white 12] (draw-hand center 50 white)))
half
– A utility function to compute half of a given number.point
– A helper function to create a point withx
andy
coordinates.pline
– A utility function that draws a line between two points.draw-hand
– A function that usespline
to draw a hand (a line) extending upward from a given center, with a size and color.
So far, there is no relationship between a clock and its hand. To
establish this relationship, we need to allow the draw-hand
function
to accept an angle. To implement this, we require a little bit of
geometry.
The mathematical formula for rotating a point (x, y) around the origin by an angle theta is as follows:
\[ x' = x \cos \theta - y \sin \theta \]
\[ y' = y \cos \theta + x \sin \theta \]
You can test things out on this page. Note that the rotation is counterclockwise because Y values go up in a classic plane. On the TIC-80, values go down, so the rotation will be clockwise (which is a good thing for a clock 🙂).
Using the formula above, we can create a function, rotate-orig
, that
rotates a given point (x, y) around the origin by a specified angle in
degrees, called deg
, and returns the resulting point:
(fn rotate-orig [x y angle] {:x (- (* x (math.cos angle)) (* y (math.sin angle))) :y (+ (* y (math.cos angle)) (* x (math.sin angle)))})
Reworking things a little bit, using our point
function and allowing
an angle in degrees, we get:
(fn rotate-orig [p deg] (let [rad (math.rad deg) cos (math.cos rad) sin (math.sin rad)] (point (- (* p.x cos) (* p.y sin)) (+ (* p.y cos) (* p.x sin)))))
However, we need a function that rotates a point around any other point, not only the origin of the plane. Here's the formula to do it:
\[ x' = (x - cx) \cos \theta - (y - cy) \sin \theta + cx \]
\[ y' = (y - cy) \cos \theta + (x - cx) \sin \theta + cy \]
Actually, if you look closely, we can keep our original function and write another, more general one:
(fn rotate [p1 p2 deg] (let [p1o (point (- p1.x p2.x) (- p1.y p2.y)) p2o (rotate-orig p1o deg)] (point (+ p2o.x p2.x) (+ p2o.y p2.y))))
Indeed, the process of rotating a point around any other point can be broken down into three steps:
- Translate the system – Move the point
p1
such that the center of rotationp2
becomes the origin, to obtainp1o
. This is achieved by subtracting the coordinates ofp2
fromp1
. - Rotate around the origin – Use the existing
rotate-orig
function to perform the rotation of the translated pointp1o
by the given angle deg, to obtainp2o
. - Translate back – Move
p2o
back to the original coordinate system by adding the coordinates ofp2
. This reverts the earlier translation, placing the rotated point in its correct position.
Although I could write rotate
directly, for now I like having
rotate-orig
as a helper function because it makes things clearer in
my head 🙂
Next, we update the draw-hand
function to use this rotation
logic. By incorporating an angle parameter, the function can now draw
a clock hand at any specified angle:
(fn draw-hand [center size deg color] (let [noon (point center.x (- center.y size)) extremity (rotate noon deg)] (pline center extremity color)))
- A "noon" point is created with the previous logic.
- The
rotate
function is used to rotate the initial "noon" position by the specified angledeg
, resulting in the new endpoint of the hand,extremity
. - Finally, the
pline
function is used to draw the line from the center of the clock to the hand's extremity.
The result:
(fn half [x] (/ x 2)) (fn point [x y] {: x : y }) (fn pline [p1 p2 color] (line p1.x p1.y p2.x p2.y color)) (fn rotate-orig [p deg] (let [rad (math.rad deg) cos (math.cos rad) sin (math.sin rad)] (point (+ (* p.x cos) (* p.y (- sin))) (+ (* p.y cos) (* p.x sin))))) (fn rotate [p1 p2 deg] (let [p1o (point (- p1.x p2.x) (- p1.y p2.y)) p2o (rotate-orig p1o deg)] (point (+ p2o.x p2.x) (+ p2o.y p2.y)))) (fn draw-hand [center size deg color] (let [noon (point center.x (- center.y size)) extremity (rotate noon center deg)] (pline center extremity color))) (local center (point (half 240) (half 136))) (fn _G.TIC [] (cls 8) (let [hand-size 50 angle 45 white 12] (draw-hand center hand-size angle white)))
Using our functions defined at the beginning, we can now easily draw a
moving second hand. We first need to get seconds from TIC-80's time
function, that returns milliseconds. Since we want a "discrete" second
hand and not a smooth second hand, we'll use math.floor
:
(fn get-seconds [] (math.floor (/ (time) 1000)))
Then, writing something like this will draw a hand that moves with each passing second:
(let [geoclock (-> (get-seconds) (seconds->clock seconds) (clock->geoclock))] (draw-hand center (- clock-size 10) geoclock white-color))
The ->
threading macro takes its first value and splices it into the
second form as the first argument. So in this case, the clock is
passed to clock->geoclock
and we obtain a geoclock. Yay, Lisp!
By changing draw-hand
to take the angle as the first argument:
(fn draw-hand [deg center size color] (let [noon (point center.x (- center.y size)) extremity (rotate noon center deg)] (pline center extremity color)))
…we can even get a nice little "functional" pipeline that draws our single-hand clock:
(fn _G.TIC [] (cls 8) (circb center.x center.y clock-size white-color) (-> (get-seconds) (seconds->clock) (clock->geoclock) (draw-hand center (- clock-size 10) white-color)))
Our TIC
function is now a clock-rendering function that depends on
time only!
The final code for iteration 1 is available in the 2025-01-04_analog_clock_1.fnl file. It could still be improved in quite a few ways, but since we're seeing an abstraction emerge, we might as well focus our efforts on improving it directly in a next iteration.
Iteration 2: analog clock displaying UTC time
In its initial iteration, our clock functioned more like a timer than a true analog clock. It had only one hand and relied solely on the number of seconds that had elapsed since the program started. In this iteration, we will create a true analog clock that utilizes UNIX time.
So we won't be using the get-seconds
function anymore:
(fn get-seconds [] (math.floor (/ (time) 1000)))
Instead, we will rely on the utime
function, which is based on
TIC-80's tstamp
function:
(fn utime [] (math.floor (tstamp)))
Our earlier choice to represent a clock as the number of seconds elapsed since "midnight" turns out to be quite fitting, as UNIX time is defined as the number of seconds elapsed since 1970-01-01 at midnight UTC. Leap seconds might pose an issue (I'm not entirely sure at the moment), but for the sake of simplicity, we will ignore them.
However, our analog clock can now display 12 hours, or 43,200 seconds,
with its 3 hands. As a result, seconds->clock
is updated to:
(fn seconds->clock [seconds] (% seconds 43200))
In other words, our previous time representation (or clock) from
iteration 1 is now an integer in the range [0,43200[
.
Of course, our geometric representation (or geoclock) will also change. We still require an angle in degrees between a hand and the 12 o'clock position, but now this applies to 3 hands: the hour, minute, and second hands.
Following this logic, clock->geoclock
becomes:
(fn clock->geoclock [clock] (let [h (/ clock 3600) m (% (/ clock 60) 60) s (% clock 60)] {:hdeg (* (/ h 12) 360) :mdeg (* (/ m 60) 360) :sdeg (* (/ s 60) 360)}))
Finally, we can create a draw-clock
function to render the clock
with its 3 hands based on a geoclock:
(fn draw-clock [geoclock center size] (let [white 12 light-grey 13] (circb center.x center.y size white) (draw-hand geoclock.hdeg center (- size 20) white) (draw-hand geoclock.mdeg center (- size 10) white) (draw-hand geoclock.sdeg center (- size 10) light-grey)))
Note that the first argument to draw-clock
is a geoclock, allowing
us to write a streamlined "pipeline" in the TIC-80's main function:
(fn _G.TIC [] (cls 8) (-> (utime) (seconds->clock) (clock->geoclock) (draw-clock center clock-size)))
And the final result:
The final code for iteration 2 is available in the 2025-01-04_analog_clock_2.fnl file.
Iteration 3
Coming soon!