CMSC 15100 — Lecture 15

Universe

Up until now, our programs have been fairly static. We've been writing programs that solve particular mathematical problems. However, a common class of programs we're familiar with are interactive programs. These are the programs that we deal with every day when we use our computers and phones.

We'll be introducing how to create and think about interactive programming. First, in addition to the usual require statements, we will now add another:


(require "../include/cs151-universe.rkt")
    

This will allow us to create a world. What is a world? A world is just a structure that holds all the information we need about our world. For example, we can create a very basic world:


(define-struct BasicWorld
  ([width : Natural]
   [height : Natural]
   [size : Natural]))
    

For now, the only thing we'll keep about our world is its size (whatever that means).

Let's think about how we can interact with a program. For instance, what happens when we open a program or an app on our computer or phone?

Note that human-computer interaction works in two directions: we give the computer some input and it shows us stuff in response. So display is something we have to program. What is displayed will depend on the information that the program has on hand.

For now, our program/world only has two pieces of information: a width and a height. We can use this to create a very bare-bones representation of the world consisting of an empty scene of the specified width and height. But how do we do this?

We can think of what the program displays for us as an Image. If we think about the process and the flow of information, we have the following:

  1. We populate our world with some initial information
  2. Based on this information, an image is drawn

What this sounds like is a function that takes a world as input and produces an Image. That is what we will start off setting up.


(: draw (-> BasicWorld Image))
;; Draw a circle on an empty scene based on the width and height of the world
(define (draw world)
  (match world
    [(BasicWorld w h n) (overlay (circle n 'outline 'red)
                                 (empty-scene w h))]))
    

This gives us the ability to create a very minimal world. To put the pieces together, we call a function appropriately called big-bang:


(big-bang (BasicWorld 500 500 10) : BasicWorld
  [to-draw draw])
    

Here, we are calling big-bang on a BasicWorld with the width and height values set to 500 and the size of the circle set to 0. Note here that we also have to provide big-bang with the intended type of our world (in this case, BasicWorld). This is because worlds can take many forms.

Finally, note that we have a clause called to-draw which takes a function. In this case, we provide our function draw. This is important because we use this to tell big-bang that this is the function that will govern the display behaviour of the world. We also note that the function that we provide here must be of the type (-> World Image), where World is whatever world type we provided to big-bang.

If you are currently reading these notes for the first time, I recommend following along with DrRacket and modifying some of the code as we go. This will allow you to experiment, which I think will be more illuminating for this topic. This will also allow me to get away with not taking a ton of screenshots to include here.

At this point, there is not much else to do. So what's next? We can try to change the world by interacting with it. For a personal computer, there are two main ways of doing this: with a keyboard or with a mouse. What we need to do is tell Racket how we want the world to change based on our input. To do this, we need to define some functions.

The keyboard is a bit simpler, so we'll go with that first. A keystroke is represented as a String. Based on this, we define a function with the following type ascription.


(: keyboard (-> World String World))
    

What does this mean? This function takes a world and a String as input and produces a world. The intent is that we provide an existing world and a keystroke as a String and based on this keystroke, we define and construct a new world. Consider the following keyboard function:


(: keyboard (-> BasicWorld String BasicWorld))
(define (keyboard world key)
  (match world
    [(BasicWorld w h n)
     (match key
       ["up" (BasicWorld w h (add1 n))]
       [_ world])]))
    

Here, we have directed the keyboard to change the world in the following way:

Note that it's important that the final case is included. If it isn't, then no world will be produced, which causes an error.

Now, this on its own is not enough to change the world. As with draw, we need to tell Racket that we would like our keystrokes to start changing the world. To do so, we add the following clause to our big-bang call:


(big-bang (BasicWorld 500 500 10) : BasicWorld
  [on-key keyboard]
  [to-draw draw])
    

Just as the to-draw clause expects a function of type (-> BasicWorld Image), the on-key clause, which governs keyboard interactions expects a function of type (-> BasicWorld String BasicWorld).

Let's stop here and think again about the flow of information.

  1. We populate our world with some initial information.
  2. Based on this information, an image is drawn.
  3. We interact with the world by hitting a key.
  4. Based on the current world and the key, the world changes—more accurately, a new world is created to replace the old one.
  5. A new image is drawn based on the new world.

You may be interested in what "keys" are detected. We can modify our world to retain the last keystroke. In addition to making the circle larger, we now also display the String that we received as input, representing the keystroke. Our new world will be called KeyboardWorld.


(define-struct KeyboardWorld
  ([width : Natural]
   [height : Natural] 
   [size : Natural]
   [key : String]))

(: keyboard (-> KeyboardWorld String KeyboardWorld))
(define (keyboard world key)
  (match world
    [(KeyboardWorld w h n _)
     (match key
       ["up" (KeyboardWorld w h (add1 n) key)]
       [_ (KeyboardWorld w h n key)])]))

(: draw (-> KeyboardWorld Image))
(define (draw world)
  (match world
    [(KeyboardWorld w h n k)
     (overlay (circle n 'outline 'red)
              (text k 48 'black)        ; this displays the keystroke
              (empty-scene w h))]))
    

We can do something similar with mouse input. However, our type ascription will look slightly different.


(: mouse (-> World Integer Integer Mouse-Event World))
    

Unlike keystrokes, the kinds of actions a mouse can take are much more varied. These actions are called mouse events and are given as input with the type Mouse-Event (which are really just specific strings). So what can be a mouse event?

But also unlike keyboard events, mouse events also have an associated location. Not only does it matter what the mouse does, it also matters where it happened. These are given to the function as two Integers.

Other than those differences, the flow of information remains the same as with keyboard events:

  1. We populate our world with some initial information
  2. Based on this information, an image is drawn
  3. We interact with the world by doing something with the mouse
  4. Based on the current world and the interaction from the mouse, the world changes—more accurately, a new world is created to replace the old one
  5. A new image is drawn based on the new world

Again, we can modify our world to keep track of mouse actions and define MouseWorld. The following will draw a small circle where the mouse pointer is (or its last location if it leaves the window) as well as displaying the last mouse action and coordinates.


(define-struct MouseWorld
  ([width : Natural]
   [height : Natural] 
   [size : Natural]
   [key : String]
   [action : Mouse-Event]
   [last-posn : (Pairof Integer Integer)]))

(: keyboard (-> MouseWorld String MouseWorld))
(define (keyboard world key)
  (match world
    [(MouseWorld w h n _ m p)
     (match key
       ["up" (MouseWorld w h (add1 n) key m p)]
       [_ (MouseWorld w h n key m p)])]))

(: mouse (-> MouseWorld Integer Integer Mouse-Event MouseWorld))
(define (mouse world x y e)
  (match world
    [(MouseWorld w h n k _ _)
     (MouseWorld w h n k e (Pairof x y))]))

(: draw (-> MouseWorld Image))
(define (draw world)
  (match world
    [(MouseWorld w h n k m (Pairof x y))
     (place-image (circle 5 'outline 'black)           ; circle follows the mouse pointer
                  x y
                  (overlay (circle n 'outline 'red)
                           (above (text k 48 'black)   ; keystroke
                                  (text m 48 'orange)  ; last mouse action
                                  (text (string-append (number->string x)
                                                       ", "
                                                       (number->string y)) 
                                        48 'cyan))     ; coordinates
                           (empty-scene w h)))]))
    

Just as before, we still need to hook up our mouse function to big-bang:


(big-bang (MouseWorld 500 500 10 "" "leave" (Pairof 0 0)) : MouseWorld
  [on-key keyboard]
  [on-mouse mouse]
  [to-draw draw])
    

The clause of interest is on-mouse, which expects a function of type (-> MouseWorld Integer Integer Mouse-Event MouseWorld).

There is one more interaction that can affect our programs. However, this is not so much a direct interaction by the user, so much as it is a consequence of the physical world being bound to the temporal realm. Our worlds also have the ability to keep track of time. Specifically, this means the passage of time, rather than the time and date.

To understand how this affects the program, we return to examining the flow of information.

  1. We populate our world with some initial information
  2. Based on this information, an image is drawn
  3. One timestep occurs
  4. Based on the current world, the world changes—more accurately, a new world is created to replace the old one
  5. A new image is drawn based on the new world

Here, the world changes based on the passage of time rather than any direct interaction with the user. Again, how the world changes is defined by us in an appropriate function. Such a function will look much simpler than the ones we've seen already:


(: timestep (-> World World))
    

Note that the only input that our timed function takes is the current world. The idea is that for each timestep, this function is called. What is a timestep? This is specified when we provide the function to big-bang:


(big-bang (TimeWorld 500 500 10 "" "leave" (Pairof 0 0)) : TimeWorld
  [on-tick timestep 1]
  [on-key keyboard]
  [on-mouse mouse]
  [to-draw draw])
    

Here, we add the on-tick clause to big-bang, along with a function and a "tick rate". The function is a function of type (-> BasicWorld BasicWorld), which is called on each "tick". The rate of ticks is determined by the rate that is also passed in. This is a number for the number of seconds per tick. Here, we set this to 1, so a tick occurs every second. This number need not be an integer: we can have tick rates of, say, 1/10 or 1/100.

The following, TimeWorld, will add to our current program by increasing the size of the circle we had at the beginning by 1 per tick. It will also display the size, along with the other information we've already set up.


(define-struct TimeWorld
  ([width : Natural]
   [height : Natural] 
   [steps : Natural]
   [key : String]
   [action : Mouse-Event]
   [last-posn : (Pairof Integer Integer)]))

(: keyboard (-> TimeWorld String TimeWorld))
(define (keyboard world key)
  (match world
    [(TimeWorld w h n _ m p)
     (match key
       ["up" (TimeWorld w h (add1 n) key m p)]
       [_ (TimeWorld w h n key m p)])]))

(: mouse (-> TimeWorld Integer Integer Mouse-Event TimeWorld))
(define (mouse world x y e)
  (match world
    [(TimeWorld w h n k _ _)
     (TimeWorld w h n k e (Pairof x y))]))

(: timestep (-> TimeWorld TimeWorld))
(define (timestep world)
  (match world
    [(TimeWorld w h n k m p)
     (TimeWorld w h (add1 n) k m p)]))

(: draw (-> TimeWorld Image))
(define (draw world)
  (match world
    [(TimeWorld w h n k m (Pairof x y))
     (place-image (circle 5 'outline 'black)  ; circle follows the mouse pointer
                  x y                         ; lines here are getting to be > 80 characters, a sign that this should be broken up into more functions — see how annoying it begins to become to read? (note: the width of this area is only about 70-ish characters)
                  (overlay (circle n 'outline 'red)
                           (above (text (number->string n) 48 'lime)        ; size (modified by timestep)
                                  (text k 48 'black)                        ; last keystroke
                                  (text m 48 'orange)                       ; last mouse action 
                                  (text (string-append (number->string x)   ; last mouse location
                                                       ", "
                                                       (number->string y)) 
                                        48 'cyan))
                           (empty-scene w h)))]))