ClojureScript & Canvas - A Simple Breakout Implementation
Title pretty much says it all, a simple breakout clone using ClojureScript and canvas. Click here for the demo.
(def paddle-height 15) (def paddle-width 150) (def brick-height 15) (def brick-width 100) (def ball-radius 10) (defn init-round [[_ width height]] {:ball [(/ width 2) (/ height 2) 4 8] :bricks (for [x (range 0 width brick-width) y (range 0 (/ height 3) brick-height)] [[x y] [(+ x brick-width) y] [(+ x brick-width) (+ y brick-height)] [x (+ y brick-height)]]) :paddle [(- (/ width 2) (/ paddle-width 2)) (- height paddle-height)]})
Game state is kept in a map containing the location of the ball, location of each corner for each brick and the location of the paddle.
(defn surface [] (let [surface (dom/getElement "surface")] [(.getContext surface "2d") (. surface -width) (. surface -height)])) (defn fill-rect [[surface] [x y width height] [r g b]] (set! (. surface -fillStyle) (str "rgb(" r "," g "," b ")")) (.fillRect surface x y width height)) (defn stroke-rect [[surface] [x y width height] line-width [r g b]] (set! (. surface -strokeStyle) (str "rgb(" r "," g "," b ")")) (set! (. surface -lineWidth) line-width) (.strokeRect surface x y width height)) (defn fill-circle [[surface] coords [r g b]] (let [[x y d] coords] (set! (. surface -fillStyle) (str "rgb(" r "," g "," b ")")) (. surface (beginPath)) (.arc surface x y d 0 (* 2 Math/PI) true) (. surface (closePath)) (. surface (fill))))
I've started with Google Closure's CanvasGraphics, but unless I missed something obvious it was dog slow, takes around 3 seconds to paint a bunch of boxes, so I ended up wrapping Canvas methods.
(defn update-canvas [state surface] (let [{:keys [ball paddle bricks]} state [paddle-x paddle-y] paddle [ball-x ball-y] ball [_ width height] surface] (fill-rect surface [0 0 width height] [255 255 255]) (stroke-rect surface [0 0 width height] 2 [0 0 0]) (doseq [[[x y]] bricks] (fill-rect surface [x y brick-width brick-height] [0 0 0]) (stroke-rect surface [x y brick-width brick-height] 2 [169 169 169])) (fill-rect surface [paddle-x paddle-y paddle-width paddle-height] [0 0 0]) (fill-circle surface [ball-x ball-y ball-radius] [0 0 0])))
Once graphics routines are defined, we are ready to paint the current state of the game on to the canvas.
(let [clamp (fn [x min max] (cond (> x max) max (< x min) min :default x)) srq #(* % %)] (defn rectangle-circle-collision [rect [c r]] (let [[circle-x circle-y] c xs (map first rect) ys (map second rect) min-x (apply min xs) max-x (apply max xs) min-y (apply min ys) max-y (apply max ys) closest-x (clamp circle-x min-x max-x) closest-y (clamp circle-y min-y max-y) dist-x (- circle-x closest-x) dist-y (- circle-y closest-y)] (< (+ (srq dist-x) (srq dist-y)) (srq r)))))
(defn ball-collision? [state pts] (let [[x y] (:ball state)] (rectangle-circle-collision pts [[x y] ball-radius])))
Next we need a way to detect collision, to check for a collision between a circle and a rectangle. We begin by finding a point on the rectangle that is closest to the circle and calculate the distance between the circle's center and this closest point, then we check if the distance is less than the circle's radius which means an intersection occurs. (Even though the name of the function is rectangle-circle-collision if you pass it two points representing a line instead of four representing rectangle it would also act as line-circle-collision.)
(defn handle-brick-collision [state] (let [{:keys [ball bricks]} state [ball-x ball-y dx dy] ball] (if (some true? (map #(ball-collision? state %) bricks)) (assoc state :ball [ball-x ball-y dx (- dy)] :bricks (filter #(not (ball-collision? state %)) bricks)) state)))
Checking for brick collision is done by mapping ball-collision? to each brick to see if there is collision, when there is collision we reverse the direction of the ball and remove the brick/s that the ball has collided, otherwise state is returned untouched.
(defn handle-paddle-collision [state] (let [{:keys [ball paddle]} state [ball-x ball-y dx dy] ball [paddle-x paddle-y] paddle] (if (ball-collision? state [[paddle-x paddle-y] [(+ paddle-x paddle-width) paddle-y]]) (assoc state :ball [ball-x ball-y dx (- dy)]) state)))
Same goes for paddle collision the only difference being that we check for a line collision (surface of the paddle) instead of a rectangle collision.
(defn tick-ball [state [_ width height]] (let [[x y dx dy] (:ball state) dx (if (or (ball-collision? state [[0 0] [0 height]]) (ball-collision? state [[width 0] [width height]])) (- dx) dx) dy (if (ball-collision? state [[0 0] [width 0]]) (- dy) dy)] (assoc state :ball [(+ x dx) (+ y dy) dx dy])))
Finally we need a way to move the ball, everytime tick-ball is called it will check for a collision with the sides and the top of the canvas bouncing the ball if it collides then move the ball.
(defn game [timer state surface] (let [[_ width height] surface] (swap! state (fn [curr] (update-canvas curr surface) (-> curr (tick-ball surface) (handle-brick-collision) (handle-paddle-collision)))) (when (or (ball-collision? @state [[0 height] [width height]]) (empty? (:bricks @state))) (. timer (stop)) (update-canvas (init-round surface) surface))))
A single round of breakout is simply taking the initial state and running the above transformations until the ball hits the bottom wall.
(defn mouse-move [state [_ width height] event] (let [x (.-clientX event) [_ y] (:paddle @state)] (when (and (>= (- x (/ paddle-width 2)) 0) (<= (+ x (/ paddle-width 2)) width)) (swap! state assoc :paddle [(- x (/ paddle-width 2)) y])))) (defn click [timer state surface event] (let [[_ width height] surface] (swap! state merge (init-round surface)) (when (not (.-enabled timer)) (. timer (start))))) (defn ^:export init [] (let [surface (surface) timer (goog.Timer. (/ 1000 60)) state (atom {})] (update-canvas (init-round surface) surface) (events/listen timer goog.Timer/TICK #(game timer state surface)) (events/listen js/window event-type/CLICK #(click timer state surface %)) (events/listen js/window event-type/MOUSEMOVE #(mouse-move state surface %))))
The only thing thats left to do is to give user the ability to control the game for that we rely on two events click and mousemove, mouse-move simply sets the paddles x coordinate to where the mouse is on the canvas, click event resets the game state and starts the timer if it is not already running.
Appendix in the raw file contains instructions on how to extract the source.