24 August 2021

Managing GUI Screens with Quil

It's all just data.

I'm building a tic-tac-toe app. It needs to be able to run in the terminal and in a desktop GUI. So, I'm getting to know the quil graphics library for Clojure (based on Processing, for Java). I tinkered with a few examples and watched a clean coders video to get a feel for how functional graphics programming with quil works. Uncle Bob summarized the quil processing loop like this:

(defn quil [setup draw update]
  (loop [state (setup)]
    (draw state)
    (recur (update state))))

So, the general idea is that you provide quil with 3 functions:

  1. setup (invoked once, sets up initial 'state')
  2. draw (called to render each frame, receives the 'state' initialized in setup)
  3. update (called after draw, gives you a chance to return new, updated 'state')

I had already diagrammed the four different screens I wanted in the game:

  1. Choose the grid size (3x3 or 4x4)
  2. Configure Player 1 as human or AI (easy, medium, hard)
  3. Configure Player 2 (see step 2)
  4. Game Play

I had several questions at this point:

  1. How would I receive input from the user? (mouse clicks)
  2. How would I transition from screen to screen at the appropriate times?
  3. How would I integrate the UI with the game rules already implemented?

As a way to get familiar with the quil api and to answer question 1 (above) I built a small prototype that rendered a grid and allowed the user to place alternating X and O marks in cells indicated by mouse clicks. Here's what it looked like in action:

Tic-Tac-Toe UI Prototype

That prototype helped me get a handle on what it would be like to work with a state data structure. Now, on to the question of transitioning from one screen/stage of the game to the next. Here's what I think I'll do, starting with the main function:

(defn -main [& args]
  (q/defsketch
    tic-tac-toe
    :title "Tic-Tac-Toe"
    :size [screen-width screen-width]
    :setup #'setup-root
    :update #'update-root
    :draw #'draw-root
    :features [:keep-on-top]
    :middleware [m/fun-mode]))

Ok, so, there has to be a few 'root' functions. The setup-root function will establish the state and, by so doing, declare which screen we are on:

(defn setup-root []
  {:current-screen :choose-grid})

Here's a peak at update-root:

(def updates
  {:choose-grid #'choose-grid/update
   :player1     #'player1/update
   :player2     #'player2/update
   :in-play     #'in-play/update
   :game-over   #'game-over/update})

(defn update-root [state]
  ((updates (:current-screen state)) state))

So, update-root is in charge of forwarding to more specific 'update' function depending on which screen (stage) of the game is currently being played. I'll bet you can guess what draw-root will look like...

The update functions will probably set a flag on the state when it's time to transition and the root function will refer to a mapping of transitions to know which screen to go to next. (I'd rather not let any of the screens know which one is next, in case I want to rearrange the order at some point.) Stay tuned for how that shakes out...

Ok, so that [mostly] takes care of question 2, and for now I'm content to defer dealing with question 3 (I don't think it will be too difficult--one of the update functions will initialize the grid and attach it to the state data structure when enough info is present).

But another issue surfaced as I implemented the choose-grid/update function. It was full of calculations. I felt it was important to anchor all on-screen elements based on ratios calculated from the width of the actual screen at runtime, which I wanted to be somewhat flexible. This meant lots of division and offsets and [x, y] coordinates to draw shapes and lines. It was a mess.

So, I decided to let the setup-root function assemble and nest a collection of what I'm going to call screen-anchors inside the state data structure. Here's a sneak peak:

(def screen-width 500)

(defn setup-root []
  {:current-screen :choose-grid
   :screen-anchors {:choose-grid (choose-grid/calculate-anchors screen-width)
                    :player1     nil    ; TODO
                    :player2     nil    ; TODO
                    :in-play     nil    ; TODO
                    :game-over   nil}}) ; TODO

And here's what choose-grid/calculate-anchors returns:

(defn calculate-anchors [screen-width]
  (let [center (/ screen-width 2)
        width  screen-width
        height screen-width]
    {:text-size            (/ height 24)
     :welcome-text         {:x (/ width 4) :y (/ height 10)}
     :what-size-text       {:x (/ width 3) :y (/ height 3)}
     :welcome-divider-line {:p1 [0,,,,, center] :p2 [width center]}
     :grid-divider-line    {:p1 [center center] :p2 [center height]}
     :grid3x3              {:width (/ width 2) :p1 [0 center]}
     :grid4x4              {:width (/ width 2) :p1 [center center]}}))

Just a data structure with lots of [x,y] stuff all based on the actual screen width of the running program. Now, drawing the "choose-grid" screen looks quite straight-forward:

(defn draw [state]
  (let [{:keys [text-size welcome-text what-size-text
                welcome-divider-line grid-divider-line
                grid3x3 grid4x4]}
        (get-in state [:screen-anchors :choose-grid])]

    (q/stroke-weight 10)
    (q/stroke 50)
    (q/fill 0)
    (q/text-size text-size)

    (q/text "Welcome to Tic-Tac-Toe!" (:x welcome-text) (:y welcome-text))
    (q/line (:p1 welcome-divider-line) (:p2 welcome-divider-line))

    (q/text "What size grid?" (:x what-size-text) (:y what-size-text))
    (q/line (:p1 grid-divider-line) (:p2 grid-divider-line))

    (q/stroke-weight 2)
    (q/fill 255)
    (render-grid 3 (:width grid3x3) (:p1 grid3x3))
    (render-grid 4 (:width grid4x4) (:p1 grid4x4))))

And here's what the rendered screen looks like:

The "choose grid" screen

It's a work in progress, to be sure, (and there's lots left to do) but for day 1 of GUI work I feel pretty good. Today was filled with what felt like big decisions. Having made most of them, the next few days should go pretty smoothly...