A Fancy Textbox Part 3: Visuals

This is part 3 of building a fancy textbox TIC-80 and Janet lang. This builds on the previous 2 posts, so be sure to read those first. In this post we'll dive into scrolling text character by character, and finally draw everything to the screen.

Scrolling by Character

Last time I ended with an ominous comment about scrolling through characters at different speeds. Right now our textbox conceptually iterates through words, since that's how we parsed it out from the plaintext. The first step will be to introduce 2 new fields.

Let's update our create-textbox function!

(defn create-textbox [script &named progress-fn]
  # .. rest of code omitted ..

  @{
    # whats actually displayed on screen
    :text @[]
    :text-cursor 0
    :text-tick 0

    # .. rest of code omitted ..
    })

Then we'll create a new function that will increment the text-tick based on the current speed, increase the text-cursor, then possibly reset the tick.

(defn textbox-scroll-text [self dt]
  (+= (self :text-tick) (self :speed))

  # when text-tick is less then 1, floor
  # will make this do basically nothing
  (->> (self :text-cursor)
       (+ (math/floor (self :text-tick)))
       (min (length-of (self :text)))
       (put self :text-cursor))

  (when (>= (self :text-tick) 1)
    (put self :text-tick 0)))

The logic for our textbox-update function will now become this. If the text-cursor is equal to the length of text AND we can consume the next script, do so. Otherwise scroll the text.

(defn textbox-update [self dt]
  (if (and (= (length-of (self :text)) (self :text-cursor))
	   (can-consume-next-script-command self))
    (textbox-consume-next-script-command self)
    (textbox-scroll-text self dt))) # new function called here!

FINALLY Displaying Something

Ok after 3 weeks and a ton of code, we're finally ready to start displaying something. In addition to creating an actuall textbox-draw function, let's introduce the concepts of..

(defn textbox-draw [self]
  (:draw-frame self))

(defn create-textbox [script &named progress-fn draw-frame pos box padding]
  (default frame-fn
    (fn [{:pos pos :box box}]
      # background
      (tic80/rect (pos :x) (pos :y)
		  (box :width) (box :height)
		  0)
      # border
      (tic80/rectb (pos :x) (pos :y)
		   (box :width) (box :height)
		   12)))
  (default pos {:x 0 :y 106})
  (default box {:width 240 :height 30})
  (default padding {:x 3 :y 3})

  @{:pos pos
    :box box
    :padding padding

    :draw-frame frame-fn
    :draw textbox-draw
   })

All of this will get you...

A black box!

Now you may be thinking "Great! lets just display our text truncated by the text-cursor", but sadly we cannot do that. Remember we need to drop words that would flow outside the bounds of the textbox down to the next line! We also need to format each word according to what the styles were when it was added. This is why we made our text field a list of objects. We also need to truncate words according to our text-cursor... uugghhh you can tell this is going to be a mess.

The approach I ended up taking was to go word by word, char by char. This means each character has its own tic80/print call. This... works. However I would not be surprised if there is a cleaner solution out there. Below is the giant loop in its entirety, annotated the best I could.

(defn textbox-draw [self]
  (:draw-frame self)

  (let [{:pos pos :text text :text-cursor text-cursor} self]
    # offsets will keep track of the final x & y to draw to
    (var offset-x (pos :x))
    (var offset-y (pos :y))

    # this will count down as we approach the text-cursor cutoff
    (var remaining-cursor text-cursor)

    # outer loop, word by word
    (loop [text-word :in text
	   :let [{:word word :color color :wiggle wiggle :width width} text-word

		 # handle case where the text-cursor is mid-word
		 sliced-word (if (> remaining-cursor (length word))
			       word
			       (string/slice word 0 remaining-cursor))]

	   # before printing, update our offsets.
	   # specifically, when the width of word + offset is out of bounds
	   # drop the text down to the next line.
	   :before (when (> (+ width offset-x)
			    (- (get-in self [:box :width]) 5))
		     (+= offset-y 8)
		     (set offset-x (pos :x)))

	   # after printing the word, decrease our remaining cursor
	   # by its length
	   :after (-= remaining-cursor (length word))

	   # inner loop, char in word
	   [char-count char ] :pairs sliced-word
	   :let [char-as-str (string/from-bytes char)

		 # the wiggle effect uses some fancy sin math
		 # ... sorry about the magic numbers
		 wiggle-offset (-> (* char-count 20)
				   (+ (/ (tic80/time) 100))
				   (math/sin)
				   (math/floor))]

	   # after printing update the x offset
	   :after (+= offset-x (width-of char-as-str))]

      # actual print to screen!
      (tic80/print char-as-str
		   (+ offset-x
		      (get-in self [:padding :x]))
		   (+ offset-y
		      (get-in self [:padding :y])
		      (if wiggle wiggle-offset 0))
		   color))))

One more thing, Voices

Oh hey we forgot about that voice effect. The voice sound effect should play when each character is displayed, but not if we're at the end of the list. We can easily add this to the textbox-scroll-text function.

(defn- textbox-scroll-text [self dt]
  # .. omitted ..

  (when (>= (self :text-tick) 1)
    (put self :text-tick 0)

    # play sound effect when one is set,
    # and we're not at the end of the text.
    (when (and (not (nil? (self :voice)))
	       (< (self :text-cursor)
		  (length-of (self :text))))
      (tic80/sfx (self :voice)))))

All done

That's it! This is everything I've done so far with the textbox! I've already begun using it in a couple game jams, and its worked really well if I do say so myself. Eventually I want to add support for conversation branches... and actually make use of that name field to display different character profiles.

Writing this sort of technical tutorial has been really fun, and it actually made me improve the code as I went. So expect more stuff like this in the future, probably related to Janet and TIC-80.

Here's the code in its entirety, I hope someone finds it useful!

(import tic80)

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

(defn- length-of [word-list]
  (if (empty? word-list)
    0 (+ ;(map |(length ($ :word))  word-list))))

(defn parse-script [input]
  (peg/match
   ~{:bool (+ (* (constant true) "true")
	      (* (constant false) "false"))
     :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)
	      (* (constant :wiggle) "wiggle " :bool))
	   ")" (any " "))
	,struct)
     :word
       (replace
	(* (constant :word)
	   (<- (* (some (+ :w (set "',.?!"))) (any " "))))
	,struct)
     :character-name
       (replace
	(* (constant :name)
	   (replace (* (<- (some (+ :w " "))))
		    ,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))

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

(defn- textbox-scroll-text [self dt]
  (+= (self :text-tick) (self :speed))

  (->> (self :text-cursor)
       (+ (self :text-tick))
       (min (length-of (self :text)))
       (math/floor )
       (put self :text-cursor))

  (when (>= (self :text-tick) 1)
    (put self :text-tick 0)

    (when (and (not (nil? (self :voice)))
	       (< (self :text-cursor)
		  (length-of (self :text))))
      (tic80/sfx (self :voice)))))

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

(defn textbox-update [self dt]
  (if (and (= (length-of (self :text)) (self :text-cursor))
	   (can-consume-next-script-command self))
    (textbox-consume-next-script-command self)
    (textbox-scroll-text self dt)))

(defn textbox-draw [self]
  (:draw-frame self)

  (let [{:pos pos :text text :text-cursor text-cursor} self]
    # offsets will keep track of the final x & y to draw to
    (var offset-x (pos :x))
    (var offset-y (pos :y))

    # this will count down as we approach the text-cursor cutoff
    (var remaining-cursor text-cursor)

    # outer loop, word by word
    (loop [text-word :in text
	   :let [{:word word :color color :wiggle wiggle :width width} text-word

		 # handle case where the text-cursor is mid-word
		 sliced-word (if (> remaining-cursor (length word))
			       word
			       (string/slice word 0 remaining-cursor))]

	   # before printing, update our offsets.
	   # specifically, when the width of word + offset is out of bounds
	   # drop the text down to the next line.
	   :before (when (> (+ width offset-x)
			    (- (get-in self [:box :width]) 5))
		     (+= offset-y 8)
		     (set offset-x (pos :x)))

	   # after printing the word, decrease our remianing cursor
	   # by its length
	   :after (-= remaining-cursor (length word))

	   # inner loop, char in word
	   [char-count char ] :pairs sliced-word
	   :let [char-as-str (string/from-bytes char)

		 # the wiggle effect uses some fancy sin math
		 # ... sorry about the magic numbers
		 wiggle-offset (-> (* char-count 20)
				   (+ (/ (tic80/time) 100))
				   (math/sin)
				   (math/floor))]

	   # after printing update the x offset
	   :after (+= offset-x (width-of char-as-str))]

      # actual print to screen!
      (tic80/print char-as-str
		   (+ offset-x
		      (get-in self [:padding :x]))
		   (+ offset-y
		      (get-in self [:padding :y])
		      (if wiggle wiggle-offset 0))
		   color))))

(defn create-textbox [script &named progress-fn draw-frame pos box padding]
  (default progress-fn (fn [self] (tic80/btnp 0)))
  (default draw-frame
    (fn [{:pos pos :box box}]
      # background
      (tic80/rect (pos :x) (pos :y)
		  (box :width) (box :height)
		  0)
      # border
      (tic80/rectb (pos :x) (pos :y)
		   (box :width) (box :height)
		   12)))
  (default pos {:x 0 :y 106})
  (default box {:width 240 :height 30})
  (default padding {:x 3 :y 3})

  @{:pos pos
    :box box
    :padding padding

    :script (parse-script script)
    :script-i 0

    :text @[]
    :text-cursor 0
    :text-tick 0

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

    # methods
    :progress? progress-fn
    :draw-frame draw-frame
    :update textbox-update
    :draw textbox-draw})