A Fancy Textbox Part 2: Processing

This is part 2 of building a fancy textbox using TIC-80 and Janet lang. Last time we made a DSL for our script, and wrote the code to parse that DSL into an usable data structure. We are now finally at the point where we can create the actual Textbox which uses this script!

Processing Script Commands

Our textbox will be represented as a single Table. To start we will need to store a couple things

(defn create-textbox [script]
  @{:script (parse-script script)
    :script-i 0

    # whats actually displayed on screen
    :text @[]

    # parameters that are controlled by script commands
    :name nil
    :voice nil
    :color 12 # white
    :speed 1
    :wiggle false

    # flag for when the textbox is all done
    :complete? false})

With those in place, lets write a function which looks at the current script command, updates one of the textbox's parameters, then increments the script index.. Lets start with the 5 formatting commands along with the complete command, since they're the simplest to implement.

(defn consume-next-script-command [self]
  (match (get (self :script) (self :script-i))
    {:name n}   (put self :name n)
    {:voice v}  (put self :voice v)
    {:speed s}  (put self :speed s)
    {:color c}  (put self :color c)
    {:wiggle w} (put self :wiggle w)
    :complete   (put self :complete true))
  (++ (self :script-i)))

Now you may look at this function and see multiple ways to condense it, but as it grows in complexity this pattern will make more sense.. at least to me. There are 2 remaining script commands, word and wait. Let's implement those!

the Word

Processing a word script command means adding that word to the text field on the textbox. However we also need to include all the formatting information for this word at the time it's being processed. Consider for example the text below

~ Character Name
| (color 1) pizza (color 2) hotdog

both "pizza" and "hotdog" will be displayed at the same time, but they will be different colors! For the same reason we'll need to keep track of whether the wiggle effect is enabled or not. Although not strictly necessary, let's keep track of the width of the word also (since text in tic80 is not monospaced).

So the text array will be a list of objects we append to.

(defn- width-of [text]
  "print returns the width of the text, so lets do that offscreen"
  (tic80/print text -100 -100))

(defn consume-next-script-command [self]
  (match (get (self :script) (self :script-i))
    # .. rest of code omitted ..
    {:word word} (array/push (self :text)
                              {:word word
                               :color (self :color)
                               :wiggle (self :wiggle)
                               :width (width-of word)}))
  (++ (self :script-i)))

Note that we could have gone a different direction and calculated the colors (and other formatting) of each word each frame. I'm making the trade-off of more memory for reduced CPU time... not that our text is long enough to really matter. But it's fun to think about this sort of thing so leave me alone!

Wait a Minute

The wait command is added in between every panel of text, and signifies that we're waiting for something like user input before moving on to the next panel. We don't want to limit the usage of this textbox to a single button to progress text, or even really limit it to user input. So let's add another arg to our create-textbox function which takes a lambda function. This function will return true if the game is ready for the textbox to progress to the next panel.

(defn create-textbox [script &named progress-fn]
  (default progress-fn (fn [self] (tic80/btnp 0)))
  @{
    # .. rest of code omitted ..
    # methods on this table
    :progress? progress-fn})

However we also need a place to call this new lambda. Let's introduce the update method for the textbox. We'll expect it gets called every frame, and if we're on the wait command, lets wait.

(defn can-consume-next-script-command [self]
  (match (get (self :script) (self :script-i))
    :wait (:progress? self)
    _ true))

# its common for update functions to take a dt arg, though we dont use it here yet.
(defn textbox-update [self dt]
  (when (can-consume-next-script-command self)
    (consume-next-script-command self)))

(defn create-textbox [script &named progress-fn]
  (default progress-fn (fn [self] (tic80/btnp 0)))
  @{
    # .. rest of code omitted ..
    # methods on this table
    :progress? progress-fn
    :update textbox-update})

Finally the consume function will simply clear the text array.

(defn consume-next-script-command [self]
  (match (get (self :script) (self :script-i))
    # .. rest of code omitted ..
    :wait (array/clear (self :text)))
  (++ (self :script-i)))

Putting everything we've written together in 1 codeblock we get this...

# From part 1
(defn parse-script [input]
  (peg/match
   ~{:pos-int (number (some (range "09")))
     :pos-float (number (some (+ (range "09") ".")))
     :command
       (replace
        (* "("
           (+ (* (constant :voice) "voice " :pos-int)
              (* (constant :speed) "speed " :pos-float)
              (* (constant :color) "color " :pos-int))
           ")" (any " "))
        ,struct)
     :word
       (replace
        (* (constant :word)
           (<- (* (some (+ (range "az" "AZ" "09") (set "',.?!"))) (any " "))))
        ,struct)
     :character-name
       (replace
        (* (constant :name)
           (replace (* (<- (some (+ (range "az" "AZ" "09") " "))))
                    ,string/trim))
        ,struct)
     :source-line
       (* "~ " :character-name (any :command))
     :dialogue-line
       (* "| "
          (some (choice :command :word))
          (constant :wait))
     :main
       (* :source-line
          (some :dialogue-line)
          (constant :complete))}
   input))

# Part 2 begins
(defn- width-of [text]
  "print returns the width of the text, so lets do that offscreen"
  (tic80/print text -100 -100))

(defn consume-next-script-command [self]
  (match (get (self :script) (self :script-i))
    {:name n}    (put self :name n)
    {:voice v}   (put self :voice v)
    {:speed s}   (put self :speed s)
    {:color c}   (put self :color c)
    {:wiggle w}  (put self :wiggle w)
    :complete    (put self :complete true)
    :wait        (array/clear (self :text))
    {:word word} (array/push (self :text)
                             {:word word
                              :color (self :color)
                              :wiggle (self :wiggle)
                              :width (width-of word)}))
  (++ (self :script-i)))

(defn can-consume-next-script-command [self]
  (match (get (self :script) (self :script-i))
    :wait (:progress? self)
    _ true))

(defn textbox-update [self dt]
  (when (can-consume-next-script-command self)
    (consume-next-script-command self)))

(defn create-textbox [script &named progress-fn]
  (default progress-fn (fn [self] (tic80/btnp 0)))
  @{
    # internal script commands
    :script (parse-script script)
    :script-i 0

    # whats actually displayed on screen
    :text @[]

    # parameters that are controlled by script commands
    :name nil
    :voice nil
    :color 12
    :speed 1
    :complete? false

    # methods on this table
    :progress? progress-fn
    :update textbox-update})

We have almost everything we need to be able to actually draw something to screen. Though spoilers, adding per character scrolling at different speeds is going to be a pain in the butt..

However, we'll save that for next time!