2025-01-04 – An analog clock
Iteration 1: analog clock with a second hand
For iteration 1, we will begin with a simple analog clock featuring only a second hand. This clock can be represented in two ways:
- Time representation
- The number of seconds that have elapsed
since the most recent full 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:
(fn get-seconds [] (math.floor (/ (time) 1000)))
Then, writing something like this will draw a hand that moves with each passing second:
(let [seconds (get-seconds) geoclock (-> (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 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-04analogclock1.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
Coming soon.