Vasco Ferreira
Vasco Ferreira

Reputation: 2373

Clojurescript: functional way to make a bouncing ball

I'm learning Clojurescript while comparing it to Javascript and rewritting some scripts.

In Javascript I've created a canvas with a ball in it, that when it gets to the canvas' borders, it bounces back. I've made the same in Clojurescript, it works, but I need to create atoms outside the function, so I can keep track of the state. If I want to create more balls, I will need to replicate those atoms. At that point the code will be ugly. How should I change the code so I can create multiple balls and each with it's own state?

Here is the Javascript code:

// Circle object
function Circle(pos_x, pos_y, radius, vel_x, vel_y){

    // Starting variables
    this.radius = radius;
    this.pos_x = pos_x;
    this.pos_y = pos_y;
    this.vel_x = vel_x;
    this.vel_y = vel_y;

    // Draw circle on the canvas
    this.draw = function(){
        c.beginPath();
        c.arc(this.pos_x, this.pos_y, this.radius, 0, Math.PI * 2, false);
        c.strokeStyle = this.color;
        c.lineWidth = 5;
        c.fillStyle = this.color_fill;
        c.fill();
        c.stroke();
    };

    // Update the circle variables each time it is called
    this.update = function(){
        // Check if it goes out of the width
        if(this.pos_x + this.radius > canvas.width || this.pos_x - this.radius < 0){
            // Invert velocity = invert direction
            this.vel_x = -this.vel_x;
        }

        // Check if it goies out of the height
        if(this.pos_y + this.radius > canvas.height || this.pos_y - this.radius < 0){
            this.vel_y = -this.vel_y;
        }

        // Apply velocity
        this.pos_x += this.vel_x;
        this.pos_y += this.vel_y;

        // Draw circle
        this.draw();
    };
};

// Create a single circle
let one_circle = new Circle(300, 300, 20, 1, 1);

function animate(){
    requestAnimationFrame(animate);
    // Clear canvas
    c.clearRect(0, 0, canvas.width, canvas.height);

    // Update all the circles
    one_circle.update();
}

animate();

Here is the Clojurescript code:

(def ball-x (atom 300))
(def ball-y (atom 300))
(def ball-vel-x (atom 1))
(def ball-vel-y (atom 1))

(defn ball
  [pos-x pos-y radius]
   (.beginPath c)
   (.arc c pos-x pos-y radius 0 (* 2 Math/PI))
   (set! (.-lineWidth c) 5)
   (set! (.-fillStyle c) "red")
   (.fill c)
   (.stroke c))

(defn update-ball
  []
  (if (or (> (+ @ball-x radius) (.-width canvas)) (< (- @ball-x radius) 0))
    (reset! ball-vel-x (- @ball-vel-x)))
  (if (or (> (+ @ball-y radius) (.-height canvas)) (< (- @ball-y radius) 60))
    (reset! ball-vel-y (- @ball-vel-y)))
  (reset! ball-x (+ @ball-x @ball-vel-x))
  (reset! ball-y (+ @ball-y @ball-vel-y))
  (ball @ball-x @ball-y 20))

(defn animate
  []
  (.requestAnimationFrame js/window animate)
  (update-ball))

(animate)

Edit: I've tried a new approach to the problem, but this doesn't work. The ball is created, but it doesn't move.

(defrecord Ball [pos-x pos-y radius vel-x vel-y])

(defn create-ball
  [ball]
  (.beginPath c)
  (.arc c (:pos-x ball) (:pos-y ball) (:radius ball) 0 (* 2 Math/PI))
  (set! (.-lineWidth c) 5)
  (set! (.-fillStyle c) "red")
  (.fill c)
  (.stroke c))

(def balls (atom {}))
(reset! balls (Ball. 301 300 20 1 1))

(defn calculate-movement
  [ball]
  (let [pos-x (:pos-x ball)
        pos-y (:pos-y ball)
        radius (:radius ball)
        vel-x (:vel-x ball)
        vel-y (:vel-y ball)
        new-ball (atom {:pos-x pos-x :pos-y pos-y :radius radius :vel-x vel-x :vel-y vel-y})]

    ; Check if out of boundaries - width
    (if (or (> (+ pos-x radius) (.-width canvas)) (< (- pos-x radius) 0))
      (swap! new-ball assoc :vel-x (- vel-x)))

    ; Check if out of boundaries - height
    (if (or (> (+ pos-y radius) (.-height canvas)) (< (- pos-y radius) 60))
      (swap! new-ball assoc :vel-y (- vel-y)))

    ; Change `pos-x` and `pos-y`
    (swap! new-ball assoc :pos-x (+ pos-x (@new-ball :vel-x)))
    (swap! new-ball assoc :pos-x (+ pos-y (@new-ball :vel-y)))

    (create-ball @new-ball)
    (println @new-ball)
    @new-ball))


(defn animate
  []
  (.requestAnimationFrame js/window animate)
  (reset! balls (calculate-movement @balls)))

(animate)

Upvotes: 0

Views: 270

Answers (2)

Mike Fikes
Mike Fikes

Reputation: 3527

I'd maintain all of the balls as a collection in an atom. Each ball could be represented as a defrecord, but here we'll just keep them as maps. Let's define two balls:

(def balls (atom [{:pos-x  300
                   :pos-y  300
                   :radius 20
                   :vel-x  1
                   :vel-y  1}
                  {:pos-x  500
                   :pos-y  200
                   :radius 20
                   :vel-x  -1
                   :vel-y  1}]))

I'd define a function that can draw a single ball:

(defn draw-ball [ball]
  (let [{:keys [pos-x pos-y radius]} ball]
    (set! (.-fillStyle c) "black")
    (.beginPath c)
    (.arc c pos-x pos-y radius 0 (* 2 Math/PI))
    (.fill c)))

While we are at it, let's define a function to clear the canvas:

(defn clear-canvas []
  (.clearRect c 0 0 (.-width canvas) (.-height canvas)))

Now, let's define a function that can update a single ball:

(defn update-ball [ball]
  (let [{:keys [pos-x pos-y radius vel-x vel-y]} ball
        bounce (fn [pos vel upper-bound]
                 (if (< radius pos (- upper-bound radius))
                   vel
                   (- vel)))
        vel-x  (bounce pos-x vel-x (.-width canvas))
        vel-y  (bounce pos-y vel-y (.-height canvas))]
    {:pos-x  (+ pos-x vel-x)
     :pos-y  (+ pos-y vel-y)
     :radius radius
     :vel-x  vel-x
     :vel-y  vel-y}))

With the above, we can define our animate loop

(defn animate []
  (.requestAnimationFrame js/window animate)
  (let [updated-balls (swap! balls #(map update-ball %))]
    (clear-canvas)
    (run! draw-ball updated-balls)))

The key ideas are:

  • each entity (ball) is represented as a map
  • we have defined separate functions to draw and update the ball
  • everything is stored in a single atom

Some advantages:

  • the draw function is easy to test in isolation
  • the update function is easy to test at the REPL (arguably, it could be cleaned up further by passing in the canvas width and height, so that it is pure)
  • since all of the state is in a single atom, it is easy to reset! it with some new desired state (maybe to add new balls, or just for debugging purposes)

Upvotes: 1

Vasco Ferreira
Vasco Ferreira

Reputation: 2373

With the help of @Carcigenicate, this is a working script.

;;; Interact with canvas
(def canvas (.getElementById js/document "my-canvas"))
(def c (.getContext canvas "2d"))

;;; Set width and Height
(set! (.-width canvas) (.-innerWidth js/window))
(set! (.-height canvas) (.-innerHeight js/window))

(defrecord Ball [pos-x pos-y radius vel-x vel-y])

; Making the atom hold a list to hold multiple balls
(def balls-atom (atom []))

; You should prefer "->Ball" over the "Ball." constructor. The java interop form "Ball." has a few drawbacks
; And I'm adding to the balls vector. What you had before didn't make sense.
;  You put an empty map in the atom, then immedietly overwrote it with a single ball
(doseq [ball (range 10)]
  (swap! balls-atom conj (->Ball (+ 300 ball) (+ 100 ball) 20 (+ ball 1) (+ ball 1))))

; You called this create-ball, but it doesn't create anything. It draws a ball.
(defn draw-ball [ball]
  ; Deconstructing here for clarity
  (let [{:keys [pos-x pos-y radius]} ball]
    (.beginPath c)
    (.arc c pos-x pos-y radius 0 (* 2 Math/PI))
    (set! (.-lineWidth c) 5)
    (set! (.-fillStyle c) "red")
    (.fill c)
    (.stroke c)))

(defn draw-balls [balls]
  (doseq [ball balls]
    (draw-ball ball)))

(defn out-of-boundaries
  [ball]
  "Check if ball is out of boundaries.
If it is, returns a new ball with inversed velocities."
  (let [{:keys [pos-x pos-y vel-x vel-y radius]} ball]
  ;; This part was a huge mess. The calls to swap weren't even in the "if".
  ;; I'm using cond-> here. If the condition is true, it threads the ball. It works the same as ->, just conditionally.
  (cond-> ball
    (or (> (+ pos-x radius) (.-width canvas)) (< (- pos-x radius) 0))
    (update :vel-x -) ; This negates the x velocity
    (or (> (+ pos-y radius) (.-height canvas)) (< (- pos-y radius) 60))
    (update :vel-y -)))) ; This negates the y velocity

; You're mutating atoms here, but that's very poor practice.
; This function should be pure and return the new ball
; I also got rid of the draw call here, since this function has nothing to do with drawing
;  I moved the drawing to animate!.
(defn advance-ball [ball]
  (let [{:keys [pos-x pos-y vel-x vel-y radius]} ball
        ; You had this appearing after the bounds check.
        ; I'd think that you'd want to move, then check the bounds.
        moved-ball (-> ball
                       (update :pos-x + vel-x)
                       (update :pos-y + vel-y))]
    (out-of-boundaries moved-ball)))

; For convenience. Not really necessary, but it helps things thread nicer using ->.
(defn advance-balls [balls]
  (mapv advance-ball balls))

(defn animate
  []
  (swap! balls-atom
         (fn [balls]
           (doto (advance-balls balls)
               (draw-balls)))))

(animate)

Upvotes: 0

Related Questions