tempest-cljs

0.1.4-SNAPSHOT


Clone of Tempest vector-graphic arcade game. Written in ClojureScript, and uses HTML5 2D canvas for display.

dependencies

org.clojure/clojure
1.3.0
noir
1.2.1

dev dependencies

lein-marginalia
0.7.0-SNAPSHOT



(this space intentionally left almost blank)
 

Macros for Tempest, in separate namespace because of ClojureScript limitation.

(ns tempest.macros)

Log time taken to run given expressions to javascript console.

(defmacro fntime
  [& body]
  `(let [starttime# (goog.now)]
     (do
       ~@body
       (.log js/console
             (str "Fn time: " (pr-str (- (goog.now) starttime#)) " ms")))))

Macro that returns a function that generates an enemy of type 'type' randomly. The returned function should be given the game-state, and it will generate a random number, and create a new enemy of the given type if the random number is under the enemy type's probability and more of the given type of enemy are permitted on the level.

Calls 'build-TYPE' function to make an enemy, with 'TYPE' replaced by whatever random-enemy-fn was called with.

Example usage: (let [ffn (random-enemy-fn flipper)] (ffn game-state))

(defmacro random-enemy-fn
  [type]
  `(fn [game-state#]
    (let [level# (:level game-state#)
          enemy-list# (:enemy-list game-state#)
          r# (if (empty? enemy-list#) (/ (rand) 2) (rand))
          {{more?# (keyword ~(name type))} :remaining
           {prob# (keyword ~(name type))} :probability
           segments# :segments} level#]
      (if (and (<= r# prob#) (pos? more?#))
        (assoc game-state#
          :enemy-list (cons (~(symbol (str "build-" (name type)))
                             level#
                             (rand-int (count segments#))) enemy-list#)
          :level (assoc-in level#
                           [:remaining (keyword ~(name type))] (dec more?#)))
        game-state#))))
 

Publicly exported functions to embed Tempest game in HTML.

(ns 
  tempest
  (:require [tempest.levels :as levels]
            [tempest.draw :as draw]
            [tempest.core :as c]
            [goog.dom :as dom]
            [goog.events :as events]
            [goog.events.KeyHandler :as key-handler]))

Design notes:

  • Nearly everything is defined with polar coordinates (length and angle)
  • "Entities" are players, enemies, projectiles
  • "Entities" are defined by a path, a series of polar coordinates, that are in relation to the previous point in the path.
  • Polar coordinates are converted to cartesian coordinates shortly before drawing.
  • Levels have a number of "steps" defined, which is how many occupiable points the level has (instead of allowing continuous motion).
  • An entity's location in the level is dictated by its current segment, and which step of the level it's on.
  • An entity has a "stride", which is how many steps it moves per update. The sign of the stride is direction, with positive strides moving out towards the player.

Obscure design oddities:

  • draw-path can optionally follow, but not draw, the first line of an entity's path. There is a crazy reason for this. The 'center' of an entity when drawn ends up being the first point drawn. The first vertex is the one that gets centered on its location on the board. If the needs to be centered around a point that is not drawn (or just not its first point), the first, undrawn line given to draw-path can be a line from where the entity's center should be to its first drawn vertex. An example is the player's ship, whose first vertex is it's "rear thruster", but who's origin when drawing must be up in the front center of the ship.

TODO:

  • MOAR ENEMIES
  • Jump? Is that possible with this design? I think so, easily, by scaling just the first, undrawn line of player. It ends up being normal to the segment's top line.
  • Power ups. Bonus points if they're crazy swirly particle things.
  • Browser + keyboard input stuff
    • Any way to change repeat rate? Probably not
    • Any way to use the mouse?
    • I'm not above making a custom rotary controller.
    • Two keys at the same time? Gotta swirl-n-shoot.
  • Rate-limit bullets
  • Frame timing, and disassociate movement speed from framerate.

List of enemies, one per segment.

(defn enemy-on-each-segment
  [level]
  (map #(c/build-flipper level % :step 0)
       (range (count (:segments level)))))

Begins a game of tempest. 'level' specified as a string representation of an integer.

(defn ^:export canvasDraw
  [level-str]
  (let [document (dom/getDocument)
        level-idx (- (js/parseInt level-str) 1)
        canvas (dom/getElement "canv-fg")
        context (.getContext canvas "2d")
        bgcanvas (dom/getElement "canv-bg")
        bgcontext (.getContext bgcanvas "2d")
        handler (goog.events.KeyHandler. document true)
        dims {:width (.-width canvas) :height (.-height canvas)}]
    (events/listen handler "key" (fn [e] (c/queue-keypress e)))
    (let [empty-game-state (c/build-game-state)
          game-state (c/change-level
                      (assoc empty-game-state
                        :context context
                        :bgcontext bgcontext
                        :dims dims
                        :anim-fn (c/animationFrameMethod)
                        :enemy-list )
                      level-idx)]
      (c/next-game-state game-state))))
 

Functions related to the game of tempest, and game state.

Functions in this module create the game state, and modify it based on player actions or time. This includes management of entities such as the player's ship, enemies, and projectiles.

Enemy types:

  • Flipper -- Moves quickly up level, flips randomly to adjacent segments, and shoots. When a flipper reaches the outer edge, he flips endlessly back and forth along the perimeter. If he touches the player, he carries the player down the level and the player is dead.
  • Tanker -- Moves slowly, shoots, and never leaves his segment. If a tanker is shot or reaches the outer edge, it is destroyed and two flippers flip out of it in opposite directions.
  • Spiker -- Moves quickly, shoots, and lays a spike on the level where it travels. Spikers cannot change segments. The spiker turns around when it reaches a random point on the level and goes back down, and disappears if it reaches the inner edge. The spike it lays remains, and can be shot. If the player kills all the enemies, he must fly down the level and avoid hitting any spikes, or he will be killed.
(ns 
  tempest.core
  (:require [tempest.levels :as levels]
            [tempest.util :as util]
            [tempest.draw :as draw]
            [tempest.path :as path]
            [goog.dom :as dom]
            [goog.events :as events]
            [goog.events.KeyCodes :as key-codes]
            [clojure.browser.repl :as repl])
  (:require-macros [tempest.macros :as macros]))
(repl/connect "http://localhost:9000/repl")

Main logic loop

Given the current game-state, threads it through a series of functions that calculate the next game-state. This is the most fundamental call in the game; it applies all of the logic.

The last call, schedule-next-frame, schedules this function to be called again by the browser sometime in the future with the update game-state after passing through all the other functions. This implements the game loop.

This function actualy dispatches to one of multiple other functions, starting with the 'game-logic-' prefix, that actually do the function threading. This is because the game's main loop logic changes based on a few possible states:

  • Normal, active gameplay is handled by game-logic-playable. This is the longest path, and has to handle all of the gameplay logic, collision detection, etc.
  • Animation of levels zooming in and out, when first loading or after the player dies, are handled by game-logic-non-playable. Most of the game logic is disabled during this stage, as it is primarily displaying a non-interactive animation.
  • 'Shooping' is what I call the end-of-level gameplay, after all enemies are defeated, when the player's ship travels down the level and must destroy or avoid any spikes remaining. All of the game logic regarding enemies is disabled in this path, but moving and shooting still works.
  • The 'Paused' state is an extremely reduced state that only listens for the unpause key.
(defn next-game-state
  [game-state]
  (cond
   (:paused? game-state) (game-logic-paused game-state)
   (:player-zooming? game-state)
   (game-logic-player-shooping-down-level game-state)
   (and (:is-zooming? game-state))
   (game-logic-non-playable game-state)
   :else (game-logic-playable game-state)))

Called by next-game-state when game is paused. Just listens for keypress to unpause game.

(defn game-logic-paused
  [game-state]
  (->> game-state
       dequeue-keypresses-while-paused
       schedule-next-frame))

That's right, I named it that. This is the game logic path that handles the player 'shooping' down the level, traveling into it, after all enemies have been defeated. The player can still move and shoot, and can kill or be killed by spikes remaining on the level.

(defn game-logic-player-shooping-down-level
  [game-state]
  (->> game-state
       clear-player-segment
       dequeue-keypresses
       highlight-player-segment
       clear-frame
       draw-board
       render-frame       
       remove-spiked-bullets
       update-projectile-locations
       animate-player-shooping
       mark-player-if-spiked
       maybe-change-level
       update-frame-count
       maybe-render-fps-display                 
       schedule-next-frame))

Called by next-game-state when game and player are active. This logic path handles all the good stuff: drawing the player, drawing the board, enemies, bullets, spikes, movement, player capture, player death, etc.

(defn game-logic-playable
  [game-state]
  (let [gs1 (->> game-state
                 clear-player-segment
                 dequeue-keypresses
                 highlight-player-segment
                 maybe-change-level
                 clear-frame
                 draw-board
                 render-frame)
        gs2 (->> gs1
                 remove-spiked-bullets
                 remove-collided-entities
                 remove-collided-bullets
                 update-projectile-locations
                 update-enemy-locations
                 update-enemy-directions
                 maybe-split-tankers
                 handle-dead-enemies
                 handle-exiting-spikers
                 maybe-enemies-shoot)
        gs3 (->> gs2
                 handle-spike-laying
                 maybe-make-enemy
                 check-if-player-captured
                 update-player-if-shot
                 check-if-enemies-remain
                 update-entity-is-flipping
                 update-entity-flippyness
                 animate-player-capture
                 update-frame-count
                 maybe-render-fps-display)]
    (->> gs3 schedule-next-frame)))

Called by next-game-state for non-playable animations. This is used when the level is quickly zoomed in or out between stages or after the player dies. Most of the game logic is disabled during this animation.

(defn game-logic-non-playable
  [game-state]
  (->> game-state
       dequeue-keypresses-while-paused
       clear-frame
       draw-board
       render-frame
       update-frame-count
       maybe-render-fps-display
       schedule-next-frame))

Global queue for storing player's keypresses. The browser sticks keypresses in this queue via callback, and keys are later pulled out and applied to the game state during the game logic loop.

(def 
  *key-event-queue* (atom '()))

Returns an empty game-state map.

(defn build-game-state
  []
  {:enemy-list '()
   :projectile-list '()
   :player '()
   :spikes []
   :context nil
   :bgcontext nil
   :anim-fn identity
   :dims {:width 0 :height 0}
   :level-idx 0
   :level nil
   :frame-count 0
   :frame-time 0
   :paused? false
   :is-zooming? true
   :zoom-in? true
   :zoom 0.0
   :level-done? false
   :player-zooming? false
   })

If no enemies are left on the level, and no enemies remain to be launched mark level as zooming out.

(defn check-if-enemies-remain
  [game-state]
  (let [level (:level game-state)
        player (:player game-state)
        on-board (count (:enemy-list game-state))
        unlaunched (apply + (vals (:remaining level)))
        remaining (+ on-board unlaunched)]
    (if (zero? remaining)
      (assoc game-state
        :player (assoc player :stride -2)
        :player-zooming? true)
      game-state)))

Changes current level of game.

(defn change-level
  [game-state level-idx]
  (let [level (get levels/*levels* level-idx)]
    (assoc game-state
      :level-idx level-idx
      :level level
      :player (build-player level 0)
      :zoom 0.0
      :zoom-in? true
      :is-zooming? true
      :level-done? false
      :player-zooming? false
      :projectile-list '()
      :enemy-list '()
      :spikes (vec (take (count (:segments level)) (repeat 0))))))

Reloads or moves to the next level if player is dead, or if all enemies are dead.

(defn maybe-change-level
  [game-state]
  (let [player (:player game-state)
        level (:level game-state)]
    (cond
     (and (:is-dead? player) (:level-done? game-state))
     (change-level game-state (:level-idx game-state))
     (and (not (:is-dead? player)) (:level-done? game-state))
     (change-level game-state (inc (:level-idx game-state)))
     :else game-state)))

Returns a dictionary describing a projectile (bullet) on the given level, in the given segment, with a given stride (steps per update to move, with negative meaning in and positive meaning out), and given step to start on.

(defn build-projectile
  [level seg-idx stride & {:keys [step from-enemy?]
                           :or {step 0 from-enemy? false}}]
  {:step step
   :stride stride
   :segment seg-idx
   :damage-segment seg-idx
   :level level
   :path-fn path/projectile-path-on-level
   :from-enemy? from-enemy?
   })

Enumeration of directions a flipper can be flipping.

(def 
  DirectionEnum {"NONE" 0 "CW" 1 "CCW" 2})

Enumeration of types of enemies.

(def 
  EnemyEnum {"NONE" 0 "FLIPPER" 1 "TANKER" 2
             "SPIKER" 3 "FUSEBALL" 4 "PULSAR" 5})

Given a value from DirectionEnum, return the corresponding string.

(defn direction-string-from-value
  [val]
  (first (first (filter #(= 1 (peek %)) (into [] maptest)))))

Returns a dictionary describing an enemy on the given level and segment, and starting on the given step. Step defaults to 0 (innermost step of level) if not specified. TODO: Only makes flippers.

(defn build-enemy
  [level seg-idx & {:keys [step] :or {step 0}}]
  {:step step
   :stride 1
   :segment seg-idx
   :damage-segment seg-idx
   :level level
   :hits-remaining 1
   :path-fn #([])
   :flip-dir (DirectionEnum "NONE")
   :flip-point [0 0]
   :flip-stride 1
   :flip-max-angle 0
   :flip-cur-angle 0
   :flip-probability 0
   :can-flip false
   :shoot-probability 0
   :type (EnemyEnum "NONE")
   })

Returns a new tanker enemy. Tankers move slowly and do not shoot or flip.

(defn build-tanker
  [level seg-idx & {:keys [step] :or {step 0}}]
  (assoc (build-enemy level seg-idx :step step)
    :type (EnemyEnum "TANKER")
    :path-fn path/tanker-path-on-level
    :can-flip false
    :stride 0.2
    :shoot-probability 0.0))

Returns a new spiker enemy. Spiker cannot change segments, travels quickly, and turns around on a random step, where 20 <= step <= max_step - 20. Spikers can shoot, and they lay spikes behind them as they move.

(defn build-spiker
  [level seg-idx & {:keys [step] :or {step 0}}]
  (assoc (build-enemy level seg-idx :step step)
    :type (EnemyEnum "SPIKER")
    :path-fn path/spiker-path-on-level
    :can-flip false
    :stride 1
    :shoot-probability 0.001
    :max-step (+ (rand-int (- (:steps level) 40)) 20)))

A more specific form of build-enemy for initializing a flipper.

(defn build-flipper
  [level seg-idx & {:keys [step] :or {step 0}}]
  (assoc (build-enemy level seg-idx :step step)
    :type (EnemyEnum "FLIPPER")
    :path-fn path/flipper-path-on-level
    :flip-dir (DirectionEnum "NONE")
    :flip-point [0 0]
    :flip-stride 1
    :flip-step-count 20
    :flip-max-angle 0
    :flip-cur-angle 0
    :flip-permanent-dir nil
    :flip-probability 0.015
    :can-flip true
    :shoot-probability 0.004))

Returns a new list of active projectiles after randomly adding shots from enemies.

(defn projectiles-after-shooting
  [enemy-list projectile-list]
  (loop [[enemy & enemies] enemy-list
         projectiles projectile-list]
    (if (nil? enemy) projectiles
        (if (and (<= (rand) (:shoot-probability enemy))
                 (not= (:step enemy) (:steps (:level enemy)))
                 (pos? (:stride enemy)))
          (recur enemies (add-enemy-projectile projectiles enemy))
          (recur enemies projectiles)))))

Randomly adds new projectiles coming from enemies based on the enemies' shoot-probability field. See projectiles-after-shooting.

(defn maybe-enemies-shoot
  [game-state]
  (let [enemies (:enemy-list game-state)
        projectiles (:projectile-list game-state)]
  (assoc game-state
    :projectile-list (projectiles-after-shooting enemies projectiles))))

Randomly create new enemies if the level needs more. Each level has a total count and probability of arrival for each type of enemy. When a new enemy is added by this function, the total count for that type is decremented. If zero enemies are on the board, probability of placing one is increased two-fold to avoid long gaps with nothing to do.

(defn maybe-make-enemy
  [game-state]
  (let [flipper-fn (macros/random-enemy-fn flipper)
        tanker-fn (macros/random-enemy-fn tanker)
        spiker-fn (macros/random-enemy-fn spiker)]
    (->> game-state
         flipper-fn
         tanker-fn
         spiker-fn)))

Returns the angle stride of a flipper, which is how many radians to increment his current flip angle by to be completely flipped onto his destination segment (angle of max-angle) on 'steps' number of increments, going clockwise if cw? is true, or counter-clockwise otherwise.

Implementation details:

There are three known possibilities for determining the stride such that the flipper appears to flip 'inside' the level:

  • max-angle/steps -- If flipper is going clockwise and max-angle is less than zero, or if flipper is going counter-clockwise and max-angle is greater than zero.
  • (max-angle - 2PI)/steps -- If flipper is going clockwise and max-angle is greater than zero.
  • (max-angle + 2PI)/steps -- If flipper is going counter-clockwise and max-angle is less than zero.
(defn flip-angle-stride
  [max-angle steps cw?]
  (let [dir0 (/ max-angle steps)
        dir1 (/ (- max-angle 6.2831853) steps)
        dir2 (/ (+ max-angle 6.2831853) steps)]
  (cond
   (<= max-angle 0) (if cw? dir0 dir2)
   :else (if cw? dir1 dir0))))

Updates a flipper's map to indicate that it is currently flipping in the given direction, to the given segment index. cw? should be true if flipping clockwise, false for counter-clockwise.

(defn mark-flipper-for-flipping
  [flipper direction seg-idx cw?]
  (let [point (path/flip-point-between-segments
               (:level flipper)
               (:segment flipper)
               seg-idx
               (:step flipper)
               cw?)
        max-angle (path/flip-angle-between-segments
                   (:level flipper)
                   (:segment flipper)
                   seg-idx
                   cw?)
        step-count (:flip-step-count flipper)
        stride (flip-angle-stride max-angle step-count cw?)
        permanent (if (= (:steps (:level flipper))
                         (:step flipper)) direction nil)]
    (assoc flipper
      :stride 0
      :old-stride (:stride flipper)
      :flip-dir direction
      :flip-cur-angle 0
      :flip-to-segment seg-idx
      :flip-point point
      :flip-max-angle max-angle
      :flip-stride stride
      :flip-steps-remaining step-count
      :flip-permanent-dir permanent)))

Updates an entity and marks it as not currently flipping.

(defn update-entity-stop-flipping
  [flipper]  
  (assoc flipper
    :stride (:old-stride flipper)
    :flip-dir (DirectionEnum "NONE")
    :flip-cur-angle 0
    :segment (:flip-to-segment flipper)))

Returns a random direction from DirectionEnum (not 'NONE')

(defn random-direction
  []
  (condp = (rand-int 2)
        0 (DirectionEnum "CW")
        (DirectionEnum "CCW")))

Returns the segment that the given flipper would flip into if it flipped in direction flip-dir. If the flipper can't flip that way, it will return the flipper's current segment.

(defn segment-for-flip-direction
  [flipper flip-dir]
  (condp = flip-dir
        (DirectionEnum "CW") (segment-entity-cw flipper)
        (segment-entity-ccw flipper)))

Given a flipper with its 'permanent direction' set, this swaps the permanent direction to be opposite. A flipper's permanent direction is the direction it flips constantly along the outermost edge of the level until it hits a boundary.

(defn swap-flipper-permanent-dir
  [flipper]
  (let [cur-dir (:flip-permanent-dir flipper)
        new-dir (if (= (DirectionEnum "CW") cur-dir)
                  (DirectionEnum "CCW")
                  (DirectionEnum "CW"))]
    (assoc flipper :flip-permanent-dir new-dir)))

Mark flipper as flipping in given direction, unless no segment is in that direction.

(defn engage-flipping
  [flipper flip-dir]
  (let [flip-seg-idx (segment-for-flip-direction flipper flip-dir)
        cw? (= flip-dir (DirectionEnum "CW"))]
    (if (not= flip-seg-idx (:segment flipper))
      (mark-flipper-for-flipping flipper flip-dir
                                 flip-seg-idx cw?)
      flipper)))

Given a flipper, returns the flipper possibly modified to be in a state of flipping to another segment. This will always be true if the flipper is on the outermost edge of the level, and will randomly be true if it has not reached the edge.

(defn maybe-engage-flipping
  [flipper]
  (let [should-flip (and
                     (true? (:can-flip flipper))
                     (= (:flip-dir flipper) (DirectionEnum "NONE"))
                     (or (<= (rand) (:flip-probability flipper))
                         (= (:step flipper) (:steps (:level flipper)))))
        permanent-dir (:flip-permanent-dir flipper)
        flip-dir (or permanent-dir (random-direction))
        flip-seg-idx (segment-for-flip-direction flipper flip-dir)
        cw? (= flip-dir (DirectionEnum "CW"))]
    (cond
     (false? should-flip) flipper
     (not= flip-seg-idx (:segment flipper)) (mark-flipper-for-flipping
                                             flipper flip-dir
                                             flip-seg-idx cw?)
     (not (nil? permanent-dir)) (swap-flipper-permanent-dir flipper)
     :else flipper)))

Marks player as being captured.

(defn mark-player-captured
  [player]
  (assoc player
    :captured? true
    :stride -4))

Marks enemy as having captured the player.

(defn mark-enemy-capturing
  [enemy]
  (assoc enemy
    :capturing true
    :can-flip false
    :step (- (:step enemy) 10) ;; looks better if enemy leads player
    :stride -4))

Returns true if given enemy and player are on top of each other.

(defn enemy-is-on-player?
  [player enemy]
  (and (= (:segment player) (:segment enemy))
       (= (:step player) (:step enemy))
       (= (DirectionEnum "NONE") (:flip-dir enemy))))

Given player and current list of enemies, returns an updated player and updated enemy list if an enemy is capturing the player in vector [player enemy-list]. Returns nil if no capture occurred.

(defn player-and-enemies-if-captured
  [player enemy-list]
  (let [{colliders true missers false}
        (group-by (partial enemy-is-on-player? player) enemy-list)]
    (when-let [[enemy & rest] colliders]
      [(mark-player-captured player)
       (cons (mark-enemy-capturing enemy) (concat missers rest))])))

If player is not already captured, checks all enemies to see if they are now capturing the player. See player-and-enemies-if-captured. If capture is in progress, returns game-state with player and enemy-list updated.

(defn check-if-player-captured
  [game-state]
  (if (:captured? (:player game-state))
    game-state
    (if-let [[player enemy-list] (player-and-enemies-if-captured
                                  (:player game-state)
                                  (:enemy-list game-state))]
      (assoc game-state :enemy-list enemy-list :player player)
      game-state)))

Decide if an enemy should start flipping for every enemy on the level.

(defn update-entity-is-flipping
  [game-state]
  (let [{enemy-list :enemy-list} game-state]
    (assoc game-state :enemy-list (map maybe-engage-flipping enemy-list))))

Update the position of any actively flipping enemy for every enemy on the level.

(defn update-entity-flippyness
  [game-state]
  (let [{enemy-list :enemy-list} game-state]
    (assoc game-state :enemy-list (map update-flip-angle enemy-list))))

Given a flipper in the state of flipping, updates its current angle. If the update would cause it to 'land' on its new segment, the flipper is updated and returned as a no-longer-flipping. If the given enemy is not flipping, returns it unchanged.

(defn update-flip-angle
  [flipper]
  (let [new-angle (+ (:flip-stride flipper) (:flip-cur-angle flipper))
        remaining (dec (:flip-steps-remaining flipper))
        new-seg (if (<= remaining (/ (:flip-step-count flipper) 2))
                  (:flip-to-segment flipper)
                  (:segment flipper))]
    (if (not= (:flip-dir flipper) (DirectionEnum "NONE"))
      (if (< remaining 0)
        (update-entity-stop-flipping flipper)
        (assoc flipper
          :damage-segment new-seg
          :flip-cur-angle new-angle
          :flip-steps-remaining remaining))
      flipper)))

Returns a dictionary describing a player on the given level and segment.

(defn build-player
  [level seg-idx]
  {:segment seg-idx
   :level level
   :captured? false
   :step (:steps level)
   :bullet-stride -5
   :stride 0
   :path path/*player-path*
   :is-dead? false
   })

Returns the next step position of given entity, taking into account minimum and maximum positions of the level.

(defn entity-next-step
  [entity]
  (let [stride (:stride entity)
        maxstep (:steps (:level entity))
        newstep (+ stride (:step entity))]
    (cond
     (> newstep maxstep) maxstep
     (< newstep 0) 0
     :else newstep)))

Return entity updated with a new position based on its current location and stride. Won't go lower than 0, or higher than the maximum steps of the level.

(defn update-entity-position!
  [entity]
  (assoc entity :step (entity-next-step entity)))

Call update-entity-position! on all entities in list.

(defn update-entity-list-positions
  [entity-list]
  (map update-entity-position! entity-list))

Updates an enemy to travel in the opposite direction if he has reached his maximum allowable step. This is used for Spikers, which travel back down the level after laying spikes.

(defn update-entity-direction!
  [entity]
  (let [{:keys [step max-step stride]} entity
        newstride (if (>= step max-step) (- stride) stride)]
  (assoc entity :stride newstride)))

Apply update-entity-direction! to all enemies in the given list that have a maximum step.

(defn update-entity-list-directions
  [entity-list]
  (let [{spikers true others false}
        (group-by #(contains? % :max-step) entity-list)]
    (concat others (map update-entity-direction! spikers))))

Returns true of entity is on seg-idx, and between steps step0 and step1, inclusive.

(defn entity-between-steps
  [seg-idx step0 step1 entity]
  (let [min (min step0 step1)
        max (max step0 step1)]
    (and
     (= (:damage-segment entity) seg-idx)
     (>= (:step entity) min)
     (<= (:step entity) max))))

Given an entity and a list of projectiles, returns the entity and updated list of projectiles after collisions. The entity's hits-remaining counter is decremented on a collision, and the projectile is removed. Small amount of fudge factor (1 step += actual projectile location) to avoid narrow misses in the collision algorithm.

(defn projectiles-after-collision
  [entity projectile-list]
  ((fn [entity projectiles-in projectiles-out was-hit?]
     (if (empty? projectiles-in)
       {:entity entity :projectiles projectiles-out :was-hit? was-hit?}
       (let [bullet (first projectiles-in)
             collision? (entity-between-steps
                         (:segment bullet)
                         (inc (:step bullet))
                         (dec (entity-next-step bullet))
                         entity)]
         (if (and (not (:from-enemy? bullet)) collision?)
           (recur (decrement-enemy-hits entity)
                  nil
                  (concat projectiles-out (rest projectiles-in))
                  true)
           (recur entity
                  (rest projectiles-in)
                  (cons bullet projectiles-out)
                  was-hit?)))))
   entity projectile-list '() false))

Given a list of entities and a list of projectiles, returns the lists with entity hit counts updated, entities removed if they have no hits remaining, and collided projectiles removed.

See projectiles-after-collision, which is called for each entity in entity-list.

(defn entities-after-collisions
  [entity-list projectile-list]
  ((fn [entities-in entities-out projectiles-in]
     (if (empty? entities-in)
       {:entities entities-out :projectiles projectiles-in}
       (let [{entity :entity projectiles :projectiles was-hit? :was-hit?}
             (projectiles-after-collision (first entities-in)
                                          projectiles-in)]
           (recur (rest entities-in)
                  (cons entity entities-out)
                  projectiles))))
   entity-list '() projectile-list))

Spawns two new flippers from one tanker. These flippers are automatically set to be flipping to the segments surround the tanker, unless one of the directions is blocked, in which case that flipper just stays on the tanker's segment.

(defn new-flippers-from-tanker
  [enemy]
  (let [{:keys [segment level step]} enemy]
    (list
     (engage-flipping
      (build-flipper level segment :step step)
      (DirectionEnum "CW"))
     (engage-flipping
      (build-flipper level segment :step step)
      (DirectionEnum "CCW")))))

Returns the enemy list updated for deaths. This means removing enemies that died, and possibly adding new enemies for those that spawn children on death.

(defn enemy-list-after-deaths
  [enemy-list]
  (let [{live-enemies false dead-enemies true}
        (group-by #(zero? (:hits-remaining %)) enemy-list)]
    (loop [[enemy & enemies] dead-enemies
           enemies-out '()]
      (cond
       (nil? enemy) (concat live-enemies enemies-out)
       (= (:type enemy) (EnemyEnum "TANKER"))
       (recur enemies (concat (new-flippers-from-tanker enemy) enemies-out))
       :else (recur enemies enemies-out)))))

Return game state after handling dead enemies, by removing them and possibly replacing them with children.

(defn handle-dead-enemies
  [game-state]
  (let [enemy-list (:enemy-list game-state)]
    (assoc game-state :enemy-list (enemy-list-after-deaths enemy-list))))

Returns an updated copy of the given list of enemies with spikers removed if they have returned to the innermost edge of the level. Spikers travel out towards the player a random distance, then turn around and go back in. They disappear when they are all the way in.

(defn enemy-list-after-exiting-spikers
  [enemy-list]
  (let [{spikers true others false}
        (group-by #(= (:type %) (EnemyEnum "SPIKER")) enemy-list)]
    (loop [[enemy & enemies] spikers
           enemies-out '()]
      (cond
       (nil? enemy) (concat others enemies-out)
       (and (neg? (:stride enemy)) (zero? (:step enemy)))
       (recur enemies enemies-out)
       :else
       (recur enemies (cons enemy enemies-out))))))

Apply enemy-list-after-exiting-spikers to the enemy list and update game state. This removes any spikers that are ready to disappear.

(defn handle-exiting-spikers
  [game-state]
  (let [enemy-list (:enemy-list game-state)]
    (assoc game-state
      :enemy-list (enemy-list-after-exiting-spikers enemy-list))))

Given a list of spikers and the current length of spikes on each segment, this updates the spike lengths to be longer if a spiker has traveled past the edge of an existing spike. Returns [enemy-list spikes]

(defn spikes-after-spike-laying
  [enemy-list spikes]
  (loop [[enemy & enemies] enemy-list
         spikes-out spikes]
    (let [{:keys [step segment]} enemy
          spike-step (nth spikes-out segment)]
    (cond
     (nil? enemy) spikes-out
     (>= step spike-step) (recur enemies (assoc spikes-out segment step))
     :else (recur enemies spikes-out)))))

Updates the length of spikes on the level. See spikes-after-spike-laying.

(defn handle-spike-laying
  [game-state]
  (let [enemy-list (:enemy-list game-state)
        spikes (:spikes game-state)
        spiker-list (filter #(= (:type %) (EnemyEnum "SPIKER")) enemy-list)]
    (assoc game-state :spikes (spikes-after-spike-laying spiker-list spikes))))

If the given tanker is at the top of a level, mark it as dead. Tankers die when they reach the player, and split into two flippers.

(defn kill-tanker-at-top
  [tanker]
  (let [step (:step tanker)
        maxstep (:steps (:level tanker))]
    (if (= step maxstep)
      (assoc tanker :hits-remaining 0)
      tanker)))

Marks tankers at the top of the level as ready to split into flippers.

(defn maybe-split-tankers
  [game-state]
  (let [enemy-list (:enemy-list game-state)
        {tankers true others false}
        (group-by #(= (:type %) (EnemyEnum "TANKER")) enemy-list)]
    (assoc game-state
      :enemy-list (concat (map kill-tanker-at-top tankers) others))))

Marks the player as dead and sets up the animation flags to trigger a level reload if the player has impacted a spike while traveling down the level.

(defn mark-player-if-spiked
  [game-state]
  (let [{:keys [spikes player]} game-state step (:step player)
        segment (:segment player) spike-len (nth spikes segment)]
    (cond
     (zero? spike-len) game-state
     (<= step spike-len)
     (assoc game-state
       :player (assoc player :is-dead? true)
       :is-zooming? true
       :zoom-in? false
       :player-zooming? false)
     :else game-state)))

Updates the player's position as he travels ('shoops') down the level after defeating all enemies. Player moves relatively slowly. Camera zooms in (actually, level zooms out) a little slower than the player moves. After player reaches bottom, camera zooms faster. Level marked as finished when zoom level is very high (10x normal).

(defn animate-player-shooping
  [game-state]
  (let [player (:player game-state)
        level (:level game-state)
        zoom (:zoom game-state)]
    (cond
     (:level-done? game-state) game-state
     (>= zoom 10) (assoc game-state :level-done? true)
     (zero? (:step player)) (assoc game-state :zoom (+ zoom .2))
     :else (assoc game-state
             :player (update-entity-position! player)
             :zoom (+ 1 (/ (- (:steps level) (:step player)) 150))
             :is-zooming? true
             :zoom-in? false))))

Updates player's position on board while player is in the process of being captured by an enemy, and marks player as dead when he reaches the inner boundary of the level. When player dies, level zoom-out is initiated.

(defn animate-player-capture
  [game-state]
  (let [player (:player game-state)
        captured? (:captured? player)
        isdead? (zero? (:step player))]
    (cond
     (false? captured?) game-state
     (true? isdead?) (assoc (clear-level-entities game-state)
                       :player (assoc player :is-dead? true)
                       :is-zooming? true
                       :zoom-in? false)
     :else (assoc game-state :player (update-entity-position! player)))))

Clears enemies, projectiles, and spikes from level.

(defn clear-level-entities
  [game-state]
  (assoc game-state
    :enemy-list '()
    :projectile-list '()
    :spikes []))

Updates current zoom value of the level, based on direction of :zoom-in? in the game-state. This is used to animate the board zooming in or zooming out at the start or end of a round. If this was a zoom out, and it's finished, mark the level as done so it can restart.

(defn update-zoom
  [game-state]
  (let [zoom (:zoom game-state)
        zoom-in? (:zoom-in? game-state)
        zoom-step 0.04
        newzoom (if zoom-in? (+ zoom zoom-step) (- zoom zoom-step))
        target (if zoom-in? 1.0 0.0)
        cmp (if zoom-in? >= <=)]
    (if (cmp zoom target) (assoc game-state
                            :is-zooming? false
                            :level-done? (not zoom-in?))
        (if (cmp newzoom target)
          (assoc game-state :zoom target)
          (assoc game-state :zoom newzoom)))))

Returns game-state unchanged, and as a side affect clears the player's current segment back to blue. To avoid weird color mixing, it is cleared to black first (2px wide), then redrawn as blue (1.5px wide). This looks right, but is different from how the board is drawn when done all at once.

(defn clear-player-segment
  [game-state]
  (do
    (set! (. (:bgcontext game-state) -lineWidth) 2)
    (draw/draw-player-segment game-state {:r 0 :g 0 :b 0})
    (set! (. (:bgcontext game-state) -lineWidth) 1.5)
    (draw/draw-player-segment game-state {:r 10 :g 10 :b 100})
    game-state))

Returns game-state unchanged, and as a side effect draws the player's current segment with a yellow border.

(defn highlight-player-segment
  [game-state]
  (do
    (set! (. (:bgcontext game-state) -lineWidth) 1)
    (draw/draw-player-segment game-state {:r 150 :g 150 :b 15})
    game-state))

Draws the level when level is zooming in or out, and updates the zoom level. This doesn't redraw the board normally, since the board is drawn on a different HTML5 canvas than the players for efficiency.

(defn draw-board
  [game-state]
  (let [is-zooming? (:is-zooming? game-state)
        zoom (:zoom game-state)
        {width :width height :height} (:dims game-state)]
    (if is-zooming?
      (do
        (draw/clear-context (:bgcontext game-state) (:dims game-state))
        (draw/draw-board (assoc game-state
                           :dims {:width (/ width zoom)
                                  :height (/ height zoom)}))
        (if (:player-zooming? game-state)
          game-state
          (update-zoom game-state)))
        game-state)))

Returns map with keys true and false. Values under true key have or will collide with bullet in the next bullet update. Values under the false key will not.

(defn collisions-with-projectile
  [enemy-list bullet]
  (group-by (partial entity-between-steps
                   (:segment bullet)
                   (:step bullet)
                   (entity-next-step bullet))
            enemy-list))

Decrement hits-remaining count on given enemy.

(defn decrement-enemy-hits
  [enemy]
  (assoc enemy :hits-remaining (dec (:hits-remaining enemy))))

Returns true if a projectile has reached either boundary of the level.

(defn projectile-off-level?
  [projectile]
  (cond
   (zero? (:step projectile)) true
   (>= (:step projectile) (:steps (:level projectile))) true
   :else false))

Add a new projectile to the global list of live projectiles, originating from the given enemy, on the segment he is currently on.

(defn add-enemy-projectile
  [projectile-list enemy]
  (let [level (:level enemy)
        seg-idx (:segment enemy)
        stride (+ (:stride enemy) 2)
        step (:step enemy)]
    (conj projectile-list
          (build-projectile level seg-idx stride :step step :from-enemy? true))))

Add a new projectile to the global list of live projectiles, originating from the given player, on the segment he is currently on.

(defn add-player-projectile
  [projectile-list player]
  (let [level (:level player)
        seg-idx (:segment player)
        stride (:bullet-stride player)
        step (:step player)]
    (conj projectile-list
          (build-projectile level seg-idx stride :step step))))

Returns the segment to the left of the player. Loops around the level on connected levels, and stops at 0 on unconnected levels.

(defn segment-entity-cw
  [player]
  (let [level (:level player)
        seg-max (dec (count (:segments level)))
        cur-seg (:segment player)
        loops? (:loops? level)
        new-seg (dec cur-seg)]
    (if (< new-seg 0)
      (if loops? seg-max 0)
      new-seg)))

Returns the segment to the right of the player. Loops around the level on connected levels, and stops at max on unconnected levels.

(defn segment-entity-ccw
  [player]
  (let [level (:level player)
        seg-max (dec (count (:segments level)))
        cur-seg (:segment player)
        loops? (:loops? level)
        new-seg (inc cur-seg)]
    (if (> new-seg seg-max)
      (if loops? 0 seg-max)
      new-seg)))

Atomically queue keypress in global queue for later handling. This should be called as the browser's key-handling callback.

(defn queue-keypress
  [event]
  (let [key (.-keyCode event)]
    (swap! *key-event-queue* #(concat % [key]))
    (.preventDefault event)
    (.stopPropagation event)))

See dequeue-keypresses for details. This unqueues all keypresses, but only responds to unpause.

(defn dequeue-keypresses-while-paused
  [game-state]
  (loop [state game-state
         queue @*key-event-queue*]
    (if (empty? queue)
      state
      (let [key (first queue)
            valid? (compare-and-set! *key-event-queue* queue (rest queue))]
        (if valid?
          (recur (handle-keypress-unpause state key) @*key-event-queue*)
          (recur state @*key-event-queue*))))))

See handle-keypress. This version only accepts unpause.

(defn handle-keypress-unpause
  [game-state key]
  (let [paused? (:paused? game-state)]
    (condp = key
      key-codes/ESC (assoc game-state :paused? (not paused?))
      game-state)))

Returns new game state updated to reflect the results of a player's keypress.

## Key map

   * Right -- Move counter-clockwise
   * Left -- Move clockwise
   * Space -- Shoot
   * Escape -- Pause
(defn handle-keypress
  [game-state key]
  (let [player (:player game-state)
        projectile-list (:projectile-list game-state)
        paused? (:paused? game-state)]
    (condp = key
      key-codes/RIGHT (assoc game-state
                        :player
                        (assoc player :segment (segment-entity-ccw player)))
      key-codes/LEFT  (assoc game-state
                        :player
                        (assoc player :segment (segment-entity-cw player)))
      key-codes/SPACE (assoc game-state
                        :projectile-list
                        (add-player-projectile projectile-list player))
      key-codes/ESC (assoc game-state :paused? (not paused?))
      game-state)))

Atomically dequeue keypresses from global queue and pass to handle-keypress, until global queue is empty. Returns game state updated after applying all keypresses.

Has a side effect of clearing global key-event-queue.

Implementation details:

Use compare-and-set! instead of swap! to test against the value we entered the loop with, instead of the current value. compare-and-set! returns true only if the update was a success (i.e. the queue hasn't changed since entering the loop), in which case we handle the key. If the queue has changed, we do nothing. The loop always gets called again with the current deref of the global state.

(defn dequeue-keypresses
  [game-state]
  (loop [state game-state
         queue @*key-event-queue*]
    (if (empty? queue)
      state
      (let [key (first queue)
            valid? (compare-and-set! *key-event-queue* queue (rest queue))]
        (cond
         (not valid?) (recur state @*key-event-queue*)
         (not (:captured? (:player game-state))) (recur (handle-keypress
                                                         state
                                                         key)
                                                        @*key-event-queue*)
         :else (recur (handle-keypress-unpause state key) @*key-event-queue*))))))

Returns a callable javascript function to schedule a frame to be drawn. Tries to use requestAnimationFrame, or the browser-specific version of it that is available. Falls back on setTimeout if requestAnimationFrame is not available on player's browser.

requestAnimationFrame tries to figure out a consistent framerate based on how long frame takes to render.

The setTimeout fail-over is hard-coded to attempt 30fps.

(defn animationFrameMethod
  []
  (let [window (dom/getWindow)
        names ["requestAnimationFrame"
               "webkitRequestAnimationFrame"
               "mozRequestAnimationFrame"
               "oRequestAnimationFrame"
               "msRequestAnimationFrame"]
        options (map (fn [name] #(aget window name)) names)]
    ((fn [[current & remaining]]
       (cond
        (nil? current) #((.-setTimeout window) % (/ 1000 30))
        (fn? (current)) (current)
        :else (recur remaining)))
     options)))

Returns game state unmodified, clears the HTML5 canvas as a side-effect.

(defn clear-frame
  [game-state]
  (do
    (draw/clear-context (:context game-state) (:dims game-state))
    game-state))

Draws the current game-state on the HTML5 canvas. Returns the game state unmodified (drawing is a side-effect).

(defn render-frame
  [game-state]
  (let [{context :context
         dims :dims
         level :level
         enemy-list :enemy-list
         projectile-list :projectile-list
         player :player}
        game-state
        {enemy-shots true player-shots false}
        (group-by :from-enemy? projectile-list)
        zoom (:zoom game-state)
        zoom-dims {:width (/ (:width dims) zoom)
                   :height (/ (:height dims) zoom)}]
    (draw/draw-all-spikes (assoc game-state :dims zoom-dims))
    (if (not (:is-dead? player))
      (draw/draw-player context zoom-dims level player (:zoom game-state)))
    (draw/draw-entities context zoom-dims level
                        enemy-list
                        {:r 150 :g 10 :b 10}
                        zoom)
    (draw/draw-entities context zoom-dims level
                        player-shots
                        {:r 255 :g 255 :b 255}
                        zoom)
    (draw/draw-entities context zoom-dims level
                        enemy-shots
                        {:r 150 :g 15 :b 150}
                        zoom)
    game-state))

Detects and removes projectiles that have collided with enemies, and enemies whose hit counts have dropped to zero. Returns updated game-state.

(defn remove-collided-entities
  [game-state]
  (let [{enemy-list :enemy-list
         projectile-list :projectile-list}
        game-state]
    (let [{plist :projectiles elist :entities}
          (entities-after-collisions enemy-list projectile-list)]
      (assoc game-state
        :projectile-list plist
        :enemy-list elist))))

Returns true if two bullets will collide within the next frame, and one is from the player and the other is from an enemy.

(defn bullets-will-collide?
  [bullet1 bullet2]
  (let [max-stride (max (:stride bullet1) (:stride bullet2))
        min-stride (min (:stride bullet1) (:stride bullet2))
        step1 (:step bullet1)
        step2 (:step bullet2)
        next-step1 (entity-next-step bullet1)
        next-step2 (entity-next-step bullet2)]
    (and (or (and (>= step1 step2) (<= next-step1 next-step2))
             (and (>= step2 step1) (<= next-step2 next-step1)))
         (neg? min-stride)
         (pos? max-stride)
         (if (:from-enemy? bullet1)
           (not (:from-enemy? bullet2))
           (:from-enemy? bullet2)))))

Given a list of projectiles, returns the list minus any bullet-on-bullet collisions that occur within it.

(defn projectile-list-without-collisions
  [projectiles]
  (loop [[bullet & others] projectiles
         survivors '()]
    (if (nil? bullet) survivors
        (let [{not-hit false hit true}
              (group-by #(bullets-will-collide? bullet %) others)]
          (if-not (empty? hit)
            (recur (concat not-hit (rest hit)) survivors)
            (recur others (cons bullet survivors)))))))

Remove bullets that have hit each other. Only player-vs-enemy collisions count. Breaks list of projectiles into one list per segment, and then runs projectile-list-without-collisions on each of those lists to get back a final list of only bullets that aren't involved in collisions.

(defn remove-collided-bullets
  [game-state]
  (let [projectile-list (:projectile-list game-state)
        segment-lists (vals (group-by :segment projectile-list))
        non-collided (mapcat projectile-list-without-collisions segment-lists)]
    (assoc game-state :projectile-list non-collided)))

Returns a new spike length based on the given spike length and the number of times the spike was hit. Spike is arbitrarily shrunk by 10 steps per hit. If it falls below a short threshhold (5), it is set to zero.

(defn decrement-spike-length
  [spike-len hit-count]
  (let [new-len (- spike-len (* 10 hit-count))]
    (if (<= new-len 5) 0 new-len)))

Given a list of projectiles on a segment (it is mandatory that they all be on the same segment), and the spike length on that segment, returns [projectile-list spike-len], where any projectiles that hit the spike have been removed from projectile-list, and spike-len has been updated to be shorter if it was hit.

(defn filter-spike-bullet-collisions
  [projectile-list spike-len]
  (let [{hit true missed false}
        (group-by #(<= (:step %) spike-len) projectile-list)]
    [missed (decrement-spike-length spike-len (count hit))]))

Returns the game state with any bullets that hit a spike removed, and any spikes that were hit shrunk in length.

(defn remove-spiked-bullets
  [game-state]
  (let [projectile-list (:projectile-list game-state)
        {player-list false enemy-list true}
        (group-by :from-enemy? projectile-list)
        segmented-projectiles (group-by :segment player-list)]
    (loop [[seg-bullets & remaining] segmented-projectiles
           spikes (:spikes game-state)
           projectiles-out '()]
      (if (nil? seg-bullets)
        (assoc game-state
          :projectile-list (concat projectiles-out enemy-list)
          :spikes spikes)
        (let [[key bullets] seg-bullets
              spike-len (nth spikes key)
              [bullets new-len] (filter-spike-bullet-collisions bullets
                                                                spike-len)]
          (recur remaining
                 (assoc spikes key new-len)
                 (concat projectiles-out bullets)))))))

Returns true given bullet will hit the given player.

(defn bullets-will-kill-player?
  [player bullet]
  (let [next-step (entity-next-step bullet)
        player-step (:step player)]
    (and (= player-step next-step)
         (:from-enemy? bullet))))

Updates the player to indicate whether he was shot by an enemy.

(defn update-player-if-shot
  [game-state]
  (let [projectile-list (:projectile-list game-state)
        player (:player game-state)
        on-segment (filter #(= (:segment player) (:segment %)) projectile-list)
        {hit true miss false} (group-by
                               #(bullets-will-kill-player? player %)
                               on-segment)]
    (if-not (empty? hit)
      (assoc (clear-level-entities game-state)
        :player (assoc player :is-dead? true)
        :is-zooming? true
        :zoom-in? false)
      game-state)))

Returns game-state with all projectiles updated to have new positions based on their speeds and current position.

(defn update-projectile-locations
  [game-state]
  (let [{projectile-list :projectile-list} game-state
        rm-fn (partial remove projectile-off-level?)]
    (assoc game-state
      :projectile-list (-> projectile-list
                           update-entity-list-positions
                           rm-fn))))

Returns game-state with all of the enemies updated to have new positions based on their speeds and current position.

(defn update-enemy-locations
  [game-state]
  (let [{enemy-list :enemy-list} game-state]
    (assoc game-state :enemy-list (update-entity-list-positions enemy-list))))

Return game state with any enemies who were ready to turn around marked to travel in the opposite direction.

(defn update-enemy-directions
  [game-state]
  (let [{enemy-list :enemy-list} game-state]
    (assoc game-state :enemy-list (update-entity-list-directions enemy-list))))

Tells the player's browser to schedule the next frame to be drawn, using whatever the best mechanism the browser has to do so.

(defn schedule-next-frame
  [game-state]
  ((:anim-fn game-state) #(next-game-state game-state)))

Increments the game-state's frame counter, which is a count of frames since the last FPS measurement.

(defn update-frame-count
  [game-state]
  (let [{frame-count :frame-count}
        game-state]
    (assoc game-state :frame-count (inc frame-count))))

Print a string representation of the most recent FPS measurement in an HTML element named 'fps'. This resets the frame-count and frame-time currently stored in the game state.

(defn render-fps-display
  [game-state]
  (let [{frame-count :frame-count
         frame-time :frame-time}
        game-state
        fps (/ (* 1000 frame-count) (- (goog.now) frame-time))
        str-fps (pr-str (util/round fps))]
    (dom/setTextContent (dom/getElement "fps") (str "FPS: " str-fps))
    (assoc game-state
      :frame-count 0
      :frame-time (goog.now))))

Calls render-fps-display if the frame-count is above a certain threshhold.

(defn maybe-render-fps-display
  [game-state]
  (if (= (:frame-count game-state) 20)
    (render-fps-display game-state)
    game-state))
 

Functions related to drawing on an HTML5 canvas.

The functions in this module are responsible for drawing paths on an HTML5 canvas. This includes both primitive draw functions, and higher level functions to draw complete game entities using the primitives.

(ns 
  tempest.draw
  (:require [tempest.levels :as levels]
            [tempest.util :as util]
            [tempest.path :as path]
            [goog.dom :as dom]))

Draws a rectangle (4 cartesian coordinates in a vector) on the 2D context of an HTML5 canvas.

(defn draw-rectangle
  [context [p0 & points]]
  (.moveTo context (first p0) (peek p0))
  (doseq [p points]
    (.lineTo context (first p) (peek p)))
  (.lineTo context (first p0) (peek p0))
  (.stroke context))

Draws a line on the given 2D context of an HTML5 canves element, between the two given cartesian coordinates.

(defn draw-line
  [context point0 point1]
  (.moveTo context (first point0) (peek point0))
  (.lineTo context (first point1) (peek point1))
  (.stroke context))
(defn max-flipper-angle
  []
  ;; get (x0,y0) and (x1,y1) of corners of current segment
  ;; gamma = atan2(y1-y0,x1-x0)
  ;; theta = PI-gamma)
(defn draw-path-rotated
  [context origin vecs skipfirst? point angle]
  ;; determine x/y translate and origin offsets by difference between
  ;; cartesian midpoint of segment and cartesian corner of segment.
  ;;
  ;; angle starts at 0, and ends at some angle difference between 
  (do
    (.save context)
    (.translate context
                (- (first origin) (first point))
                (- (peek origin) (peek point)))
    (.rotate context angle)
    ((fn [origin vecs skip?]
       (if (empty? vecs)
         nil
         (let [line (first vecs)
               point (path/rebase-origin (path/polar-to-cartesian-coords line)
                                         origin)]
           (.lineTo context (first point) (peek point))
           (recur point (next vecs) false))))
     [(first point) (peek point)] vecs skipfirst?)
     ;;[0 0] vecs skipfirst?)
    (.stroke context)
    (.restore context)))

Draws a 'path', a vector of multiple polar coordinates, on an HTML5 2D drawing canvas.

context -- The '2D Context' of an HTML5 canvas element origin -- The point (cartesian coordinate) to start drawing from vecs -- Vector of polar coordinates to draw skipfirst? -- Whether the first line described by vecs should be drawn. If no, the first line can be used to offset the path, in effect changing the 'midpoint' of the entity being drawn. If yes, the 'midpoint' of the object is the first vertex from which the first line is drawn.

(defn draw-path
  [context origin vecs skipfirst?]
  (do
    (.moveTo context (first origin) (peek origin))    
    ((fn [origin vecs skip?]
       (if (empty? vecs)
         nil
         (let [line (first vecs)
               point (path/rebase-origin (path/polar-to-cartesian-coords line)
                                         origin)]
           (if-not skip?
             (.lineTo context (first point) (peek point))
             (.moveTo context (first point) (peek point)))
           (recur point (next vecs) false))))
     origin vecs skipfirst?)
    (.stroke context)))

Draws a player, defined by the given path 'player', on the 2D context of an HTML5 canvas, with :height and :width specified in dims, and on the given level.

(defn draw-player
  [context dims level player zoom]
  (doseq []
    (.save context)
    (.beginPath context)
    (if (zero? zoom)
      (.scale context 0.00001 0.0001)
      (.scale context zoom zoom))
    (set! (. context -strokeStyle) (str "rgb(255,255,0)"))
    (draw-path context
               (path/polar-to-cartesian-centered
                (path/polar-entity-coord player)
                dims)
               (path/round-path (path/player-path-on-level player))
               true)
    (.closePath context)
    (.restore context)))

Draws all the entities, defined by paths in 'entity-list', on the 2D context of an HTML5 canvas, with :height and :width specified in dims, and on the given level.

(defn draw-entities
  [context dims level entity-list color zoom]
  (let [{r :r g :g b :b} color
        color-str (str "rgb(" r "," g "," b ")")]
    (.save context)
    (if (zero? zoom)
      (.scale context 0.00001 0.0001)
      (.scale context zoom zoom))
    (doseq [entity entity-list]
      (.beginPath context)
      (set! (. context -strokeStyle) color-str)
      (draw-path-rotated context
                         (path/polar-to-cartesian-centered
                          (path/polar-entity-coord entity)
                          dims)
                         (path/round-path ((:path-fn entity) entity))
                         true
                         (:flip-point entity)
                         (:flip-cur-angle entity))
      (.closePath context))
    (.restore context)))
(defn draw-spike
  [{:keys [dims context level]} seg-idx length]
  (.beginPath context)
  (set! (. context -strokeStyle) (str "rgb(10, 150, 10)"))
  (draw-line context
              (path/polar-to-cartesian-centered
               (path/segment-midpoint level seg-idx false) dims)
              (path/polar-to-cartesian-centered
               (path/polar-segment-midpoint level seg-idx length) dims))
  (.closePath context))
(defn draw-all-spikes
  [game-state]
  (let [context (:context game-state) zoom (:zoom game-state)
        spikes (:spikes game-state) spike-count (count spikes)]
    (.save context)
    (if (zero? zoom)
      (.scale context 0.00001 0.0001)
      (.scale context zoom zoom))
    (doseq [idx (range spike-count)]
      (let [length (nth spikes idx)] 
        (if (pos? length)
          (draw-spike game-state idx length))))
    (.restore context)))

(for [idx (range spike-count) spike (nth spikes idx) :when (pos? spike)] #(draw-spike game-state idx spike))

Draws just the segment of the board that the player is on, with the given color.

(defn draw-player-segment
  [{:keys [dims level zoom player] context :bgcontext {pseg :segment} :player}
   {:keys [r g b]}]
  (.beginPath context)
  (set! (. context -strokeStyle) (str "rgb(" r "," g "," b ")"))
  (draw-rectangle
   context
   (path/round-path (path/rectangle-to-canvas-coords
                     dims
                     (path/rectangle-for-segment level pseg))))
  (.closePath context))

Draws a level on a 2D context of an HTML5 canvas with :height and :width specified in dims.

(defn draw-board
  [{:keys [dims level zoom player] context :bgcontext}]
  (doseq []
    (.save context)
    ;; To fix bug in Firefox.  scale to 0.0 breaks it.
    (if (zero? zoom)
      (.scale context 0.00001 0.0001)
      (.scale context zoom zoom))
    (set! (. context -lineWidth) 1)
    (set! (. context -strokeStyle) (str "rgb(10,10,100)"))
    (.beginPath context)
    (doseq [idx (range (count (:segments level)))]
      (draw-rectangle
       context
       (path/round-path (path/rectangle-to-canvas-coords
                         dims
                         (path/rectangle-for-segment level idx)))))
    (.restore context)
    (.closePath context)))

Clears an HTML5 context

(defn clear-context
  [context dims]
  (let [{width :width height :height} dims]
    (.clearRect context 0 0 width height)))
 

Functions related to generating paths representing levels.

(ns 
  tempest.levels
  (:require [tempest.util :as util]))

Level Terminology:

length and depth both refer to how far from origin the inner line is drawn, in pixels.

length-fn is a function to determine how long between inner and outer line. Takes one argument 'r' to the inner line. Returns 'r' to the outer line. Default is 'inner r' multiplied by 4.

width is how wide, in pixels, the outer line segment is.

Level design

Levels are defined by a vector of polar coordinates [r theta], which are used to build a vector of 'segments' that form a level.

Levels can be manually specified by building a vector of lines manually.

Some types of levels can be built automatically by calling helper functions in this module with various parameters.

Levels are drawn radially, from the center point of the canvas.

Levels are stored in the *levels* vector as a list of maps.

Enemies travel up segments in steps. A level has the same number of steps per segment, but the size of the steps can vary depending on the dimensions of the segment. Instead of keeping track of its coordinates, an enemy keeps track of which segment it is on, and how many steps up the segment.

Default length, in pixels, from origin to inner line.

(def 
  *default-line-length* 20)

Default length function, returns argument*4

(def 
  *default-length-fn* #(* 4 %))
(def *default-steps-per-segment* 200)
(defn build-unlinked-segment-list [max-x]
  (vec ((fn [x segments]
    (if (= x 0)
      segments
      (recur (dec x) (cons [(dec x) x] segments)))) max-x [])))
(defn build-segment-list [max-x linked?]
  (let [segments (build-unlinked-segment-list max-x)]
    (if (true? linked?)
      (conj segments [(last (last segments)) (first (first segments))])
      segments)))

"Flat" level functions

Functions for generating "flat" levels: levels where the edge appears as a straight line. Something like this garbage:

      ___________________________
     /  /  / |  |  |  |  |   \   \
    /  |  |  |  |  |  |   |   \   \
   /  /  |  |   |  |   |   |   |   \
  /  /  /   |  |   |   |   |    |   \
 /  /  /   |   |   |   |    |    |   \
----------------------------------------

Flat levels always start with a line dropped straight down, and build out symmetrically from there. The width of segments at the "outer" edge (closer to the player) is uniform.

Return theta for segment n. Width is width of segment at the outer edge, closest to the player, and depth is the distance to origin.

(defn theta-flat 
  [n width depth]
  (js/Math.round (util/rad-to-deg (js/Math.atan (/ (* (+ n 1) width) depth)))))

Return r for given theta (see theta-flat).

(defn r-flat 
  [theta depth]
  (js/Math.round (/ depth (js/Math.cos (util/deg-to-rad theta)))))

Returns [r theta] for nth straight line segment. angle-center is the angle that theta should be in reference to (probably 270 degrees, a line straight down), and angle-multiplier should be -1 to built left or 1 to build right.

(defn r-theta-pair-flat 
  [n width depth angle-center angle-multiplier]
  (let [th (theta-flat n width depth)]
    [(r-flat th depth) (+ angle-center (* th angle-multiplier))]))

Return a list of line segments representing a flat level with segment-count segments ON EACH SIDE OF CENTER (2*segment-count total), width at the player edge of segment-width, and distance from origin to inner-edge as segment-depth

(defn flat-level 
  [segment-count segment-width segment-depth]
  (concat (reverse (map #(r-theta-pair-flat % segment-width segment-depth 270 -1) (range segment-count)))
          [[80 270]]
          (map #(r-theta-pair-flat % segment-width segment-depth 270 1) (range segment-count))))

"Oblong" level functions

Functions for generating oblong triangles using Law of Cosines. Use to generate arbitrary levels from a list of angles, gamma(0)..gamma(N), where gamma is the angle between the previous line segment 'towards the player' and the line segment that makes the 'width' of the segment.

              ____
             /    / \
            /    /   \ 
           /    /     /
          /    /     /
  gamma1-/->  /     /
        /____/     /
              \  </--- gamma0
               \ /

Named 'oblong' after the triangles formed when the lines are extended to origin. As opposed to the flat-levels, which are constructed of right triangles.

This is a terrible non-obvious way to construct a level, but does let you construct complex, symmetric structures (both open and closed) with just a list of angles.

Calculate the next radius of an oblong triangle. Depends on the gamma specified for this segment, the width of the segment, and the previous radius r0. For the first segment, r0 should be straight down (270 degrees).

(defn r-oblong 
  [gamma width r0]
  (js/Math.sqrt (+ (js/Math.pow width 2)
                   (js/Math.pow r0 2)
                   (* -2 width r0 (js/Math.cos (util/deg-to-rad gamma))))))

Calculate the next theta, the angle (in degrees) in relation to origin, for an oblong triangle. This depends on the width of the segment, the previous radius r0, the current radius r1 (see r-oblong), the previous theta theta0. Provide a function, either + or -, to determine the direction. - builds segments clockwise, + builds counterclockwise.

(defn theta-oblong 
  [width r1 r0 theta0 sumfn]
  (sumfn theta0
     (util/rad-to-deg (js/Math.acos
                  (/  (+ (js/Math.pow r1 2)
                         (js/Math.pow r0 2)
                         (* -1 (js/Math.pow width 2)))
                      (* 2 r1 r0))))))

Return a vector [r theta] representing a line segment. See theta-oblong and r-oblong for parameters.

(defn r-theta-pair-oblong 
  [gamma width r0 theta0 sumfn]
  (let [r1 (r-oblong gamma width r0)]
    (vec (map js/Math.round [r1 (theta-oblong width r1 r0 theta0 sumfn)]))))

Builds vector of line segments in relation to a line dropped straight down, with the angles given in gammas. Only builds in one direction.

(defn oblong-half-level 
  [gammas width height sumfn]
  ((fn [gammas r0 theta0 segments]
     (if (= (count gammas) 0)
       segments
       (let [pair (r-theta-pair-oblong (first gammas) width r0 theta0 sumfn)]
         (recur (rest gammas) (first pair) (last pair) (cons pair segments)))))
   gammas height 270 []))

Builds a full level from the angles given in gammas. Level is symmetric, and can be open or closed. Always starts with a straight down line.

(defn oblong-level 
  [gammas width height]
  (concat
   (oblong-half-level gammas width height -)
   [[height 270]]
   (reverse (oblong-half-level gammas width height +))))

BELOW HERE ARE LEVEL DEFINITIONS

Flat level with 8 segments

(def *level1_lines* (vec (flat-level 4 15 80)))

Custom level, a circle

(def *level2_lines*
  [[*default-line-length* 0]
   [*default-line-length* 18]
   [*default-line-length* 36]
   [*default-line-length* 54]
   [*default-line-length* 72]
   [*default-line-length* 90]
   [*default-line-length* 108]
   [*default-line-length* 126]
   [*default-line-length* 144]
   [*default-line-length* 162]
   [*default-line-length* 180]
   [*default-line-length* 198]
   [*default-line-length* 216]
   [*default-line-length* 234]
   [*default-line-length* 252]
   [*default-line-length* 270]
   [*default-line-length* 288]
   [*default-line-length* 306]   
   [*default-line-length* 324]
   [*default-line-length* 342]
   ])

Flat level with 14 segments

(def *level3_lines* (vec (flat-level 7 15 80)))

Oblong level, an open "W"

(def *level4_lines* (vec (oblong-level [135 105 90 33] 15 60)))

Oblong level, open "eagle wings"

(def *level5_lines* (vec (oblong-level [135 100 90 90 90 85 80 75] 15 60)))

Oblong level, a closed, spikey flower

(def *level6_lines* (vec (oblong-level [135 45 90 135 45 90 135 45 90 135 45 90
                                        135 45 90 135 45 90 135 45 90 135 45] 15 80)))
(def *level6_lines* (vec (oblong-level [135 45 90 135 45 90 135 45 90 135 45 90
                                        135 45 90 135 45 90 135 45 90 135 45] 11 57)))
(def *level7_lines* (vec (oblong-level [135 45 135 45] 15 3)))

Oblong level

(def *level8_lines* (vec (oblong-level [135 105 90 33] 8 10)))

Oblong level, V shape

(def *level9_lines* (vec (oblong-level [50 50 60 60 70] 15 60)))

Custom level, a 'V' in horizontal and vertical steps

(def *level10_lines*
  [[38 203]
   [43 217]
   [34 229]
   [44 239]
   [39 255]
   [50 262]
   [50 278]
   [39 284]
   [44 300]
   [34 310]
   [43 322]
   [38 336]
   ])

Make a map that defines a level. A level contains a vector of lines, a vector of segments constructed of pairs of lines, and a length function. This function takes a vector of lines, and a boolean specifying whether the level is a closed loop, or open.

(defn make-level-entry 
  [lines loops? enemy-count enemy-probability &
   {:keys [length-fn steps] :or {length-fn *default-length-fn*
                                 steps *default-steps-per-segment*}}]
  {:lines lines
   :loops? loops?
   :segments (build-segment-list (- (count lines) 1) loops?)
   :length-fn length-fn
   :steps steps
   :remaining enemy-count
   :probability enemy-probability})
(def *levels*
  [ (make-level-entry *level1_lines* false
                      {:flipper 6 :tanker 0 :spiker 2}
                      {:flipper 0.01 :tanker 0  :spiker 0.01})
    (make-level-entry *level2_lines* true
                      {:flipper 20 :tanker 0 :spiker 3}
                      {:flipper 0.01 :tanker 0 :spiker 0.005}
                      :length-fn #(* 9 %))
    (make-level-entry *level3_lines* false
                      {:flipper 20 :tanker 5 :spiker 6}
                      {:flipper 0.01 :tanker 0.005 :spiker 0.005})
    (make-level-entry *level4_lines* false
                      {:flipper 20 :tanker 10 :spiker 6}
                      {:flipper 0.01 :tanker 0.005 :spiker .005})
    (make-level-entry *level5_lines* false
                      {:flipper 20 :tanker 10 :spiker 6}
                      {:flipper 0.01 :tanker 0.005 :spiker .005})
    (make-level-entry *level6_lines* true
                      {:flipper 20 :tanker 10 :spiker 6}
                      {:flipper 0.01 :tanker 0.005 :spiker .005})
    (make-level-entry *level7_lines* false
                      {:flipper 20 :tanker 10 :spiker 6}
                      {:flipper 0.01 :tanker 0.005 :spiker .005})
    (make-level-entry *level8_lines* false
                      {:flipper 20 :tanker 10 :spiker 6}
                      {:flipper 0.01 :tanker 0.005 :spiker .005}
                      :length-fn #(* 10 %)
                      :steps 400)
    (make-level-entry *level9_lines* false
                      {:flipper 20 :tanker 10 :spiker 6}
                      {:flipper 0.01 :tanker 0.005 :spiker .005})
    (make-level-entry *level10_lines* false
                      {:flipper 20 :tanker 10 :spiker 6}
                      {:flipper 0.01 :tanker 0.005 :spiker .005})
    ])
 

Functions related to path and coordinate creation and manipulation.

Functions in this module work with polar coordinates, cartesian coordinate, and 'paths' consisting of a sequence of coordinates.

(ns 
  tempest.path
  (:require [tempest.levels :as levels]
            [tempest.util :as util]
            [goog.dom :as dom]
            [goog.math :as math]))

Given two polar coordinates, returns one polar coordinate with the first elements (radii) summed, and the second elements (angles) subtracted. i.e. [r1+r0, th1-th0]. This is used to move point0 to be relative to point1.

(defn add-sub
  [point0 point1]
  [(+ (first point1) (first point0))
   (- (peek point1) (peek point0))])

Returns a pair of cartesian coordinates [[x0 y0] [x1 y1]], representing the points on the edges of the given segment of the given level at the given step.

That is, this returns the two points at the edge of a segment between which an entity would be drawn.

(defn cartesian-edge-coordinates
  [level seg-idx step]
  (let [edges (polar-lines-for-segment level seg-idx false)
        edge-steps (step-lengths-for-segment-lines level seg-idx)
        offset0 (* (first edge-steps) step)
        offset1 (* (peek edge-steps) step)
        point0 (polar-extend offset0 (first edges))
        point1 (polar-extend offset1 (peek edges))]
    [(polar-to-cartesian-coords point0)
     (polar-to-cartesian-coords point1)]))

Returns the cartesian coordinates of the point on the edge in between the two given segments at the given step. This is the point about which a flipping enemy should rotate.

(defn cartesian-point-between-segments
  [level seg-idx0 seg-idx1 step]
  (let [line (edge-line-between-segments level seg-idx0 seg-idx1)
        line-steps (step-length-for-level-line level line)        
        offset (* line-steps step)
        point0 (polar-extend offset line)]
    (polar-to-cartesian-coords point0)))

Returns the polar coordinates (i.e. description of a line) of the line between the given segments.

(defn edge-line-between-segments
  [level seg-idx0 seg-idx1]
  (let [segs0 (get (:segments level) seg-idx0)
        segs1 (get (:segments level) seg-idx1)
        allsegs (flatten [segs0 segs1])]
    (first (for [[id freq] (frequencies allsegs) :when (> freq 1)]
             (get (:lines level) id)))))

Returns the angle, in radians, between the two given segments on the given level.

(defn flip-angle-between-segments
  [level seg-idx-cur seg-idx-new cw?]
  (let [angle-cur (segment-angle level seg-idx-cur)
        angle-new (segment-angle level seg-idx-new)]
      (mod (- 0 (- (+ angle-new 3.14159265) angle-cur)) 6.2831853)))

Returns the point about which an enemy on seg-idx-cur flipping to seg-idx-new from step step should be rotated. cw? should be true if enemy would be flipping clockwise, false otherwise.

(defn flip-point-between-segments
  [level seg-idx-cur seg-idx-new step cw?]
  (let [[x0 y0] (cartesian-point-between-segments level
                                                  seg-idx-cur
                                                  seg-idx-new
                                                  step)
        [x1 y1] (polar-to-cartesian-coords
                 (polar-segment-midpoint level seg-idx-cur step))
        edge-points (cartesian-edge-coordinates level seg-idx-new step)]
    [(- x1 x0) (- y0 y1)]))

Return cartesian coordinate 'point' in relation to 'origin'.

(defn rebase-origin
  [point origin]
  (add-sub point origin))

Converts a polar coordinate (r,theta) into a cartesian coordinate (x,y) centered on in a rectangle with given width and height.

(defn polar-to-cartesian-centered
  [point {width :width height :height}]
  (rebase-origin (polar-to-cartesian-coords point) [(/ width 2) (/ height 2)]))

Converts polar coordinates to cartesian coordinates. If optional length-fn is specified, it is applied to the radius first.

(defn polar-to-cartesian-coords
  ([[r angle]] [(math/angleDx angle r) (math/angleDy angle r)])
  ([[r angle] length-fn]
     (let [newr (length-fn r)]
       [(math/angleDx angle newr) (math/angleDy angle newr)])))

Rounds all numbers in a path (vector of 2-tuples) to nearest integer.

(defn round-path-math
  [path]
  (map (fn [coords]
         [(js/Math.round (first coords))
          (js/Math.round (peek coords))])
       path))

Rounds all numbers in a path (vector of 2-tuples) to nearest integer. ONLY WORKS WITH POSITIVE NUMBERS. Faster than round-path-math.

(defn round-path-hack
  [path]
  (map (fn [[x y]]
         [(js* "~~" (+ 0.5 x))
          (js* "~~" (+ 0.5 y))])
       path))

Use round-path-hack for now, since it's theoretically faster

(def round-path round-path-hack)

Center a cartesian coordinate centered around (0,0) to be centered around the middle of a rectangle with the given width and height. It inverts y, assuming that the input y is 'up', and in the output y is 'down', as is the case with an HTML5 canvas.

(defn point-to-canvas-coords
  [{width :width height :height} p]
  (let [xmid (/ width 2)
        ymid (/ height 2)]
    [(+ (first p) xmid) (- ymid (peek p))]))

Given a rectangle (vector of 4 cartesian coordinates) centered around (0,0), this function shifts them to be centered around the center of an HTML5 canvas with the :width and :height set in dims.

(defn rectangle-to-canvas-coords
  [dims rect]
  (map #(point-to-canvas-coords dims %) rect))

Returns vector [[x0 y0] [x1 y1] [x2 y2] [x3 y3]] describing segment's rectangle in cartesian coordinates.

(defn rectangle-for-segment
  [level seg-idx]
  (let [[seg0 seg1] (get (:segments level) seg-idx)
        line0 (get (:lines level) seg0)
        line1 (get (:lines level) seg1)]
    [(polar-to-cartesian-coords line0)
     (polar-to-cartesian-coords line0 (:length-fn level))
     (polar-to-cartesian-coords line1 (:length-fn level))
     (polar-to-cartesian-coords line1)]))

Returns current polar coordinates to the entity.

(defn polar-segment-midpoint
  [level seg-idx step]
  (let [steplen (step-length-segment-midpoint level seg-idx)
        offset (* steplen step)
        midpoint (segment-midpoint level seg-idx)]
    (polar-extend offset midpoint)))

Returns current polar coordinates to the entity.

(defn polar-entity-coord
  [entity]
  (polar-segment-midpoint (:level entity)
                          (:segment entity)
                          (:step entity)))

Finds the 'step length' of a line through the middle of a level's segment. This is how many pixels an entity should move per update to travel one step.

(defn step-length-segment-midpoint
  [level seg-idx]
  (/
   (-
    (first (segment-midpoint level seg-idx true))
    (first (segment-midpoint level seg-idx false)))
   (:steps level)))

Finds the 'step length' of a line along the edge of a level's segment.

(defn step-length-segment-edge
  [level line]
  (/
   (-
    ((:length-fn level) (first line))
    (first line))
   (:steps level)))

Finds the 'step length' of an arbitrary line on the given level.

(defn step-length-line
  [level point0 point1]
  (js/Math.abs
   (/
    (-
     (first point0)
     (first point1))
    (:steps level))))
(defn step-length-for-level-line
  [level line]
  (let [longline (scale-polar-coord (:length-fn level) line)]
    (step-length-line level line longline)))

Returns a vector [len0 len1] with the 'step length' for the two edge lines that mark the boundaries of the given segment.

(defn step-lengths-for-segment-lines
  [level seg-idx]
  (let [coords (concat (polar-lines-for-segment level seg-idx false)
                       (polar-lines-for-segment level seg-idx true))
        line0 (take-nth 2 coords)
        line1 (take-nth 2 (rest coords))]
    [(apply #(step-length-line level %1 %2) line0)
     (apply #(step-length-line level %1 %2) line1)]))

Returns distance between to points specified by polar coordinates.

(defn polar-distance
  [[r0 theta0] [r1 theta1]]
  (js/Math.sqrt
   (+
    (js/Math.pow r0 2)
    (js/Math.pow r1 2)
    (* -2 r0 r1 (js/Math.cos (util/deg-to-rad (- theta1 theta0)))))))

Returns the radius to the midpoint of a line drawn between two polar coordinates.

(defn polar-midpoint-r
  [[r0 theta0] [r1 theta1]]
  (js/Math.round
   (/
    (js/Math.sqrt 
     (+
      (js/Math.pow r0 2)
      (js/Math.pow r1 2)
      (* 2 r0 r1 (js/Math.cos (util/deg-to-rad (- theta1 theta0))))))
    2)))

Returns the angle to the midpoint of a line drawn between two polar coordinates.

(defn polar-midpoint-theta
  [[r0 theta0] [r1 theta1]]
  (js/Math.round
   (mod
    (+ (util/rad-to-deg
        (js/Math.atan2
         (+
          (* r0 (js/Math.sin (util/deg-to-rad theta0)))
          (* r1 (js/Math.sin (util/deg-to-rad theta1))))
         (+
          (* r0 (js/Math.cos (util/deg-to-rad theta0)))
          (* r1 (js/Math.cos (util/deg-to-rad theta1))))))
       360) 360)))

Returns polar coordinate representing the midpoint between the two points specified. This can be used to draw a line down the middle of a level segment -- the line that entities should follow.

(defn polar-midpoint
  [point0 point1]
  [(polar-midpoint-r point0 point1)
   (polar-midpoint-theta point0 point1)])

Given a level and a segment index, returns the midpoint of the segment. scaled? determines whether it gives you the inner (false) or outer (true) point.

(defn segment-midpoint
  [level seg-idx scaled?]
  (apply polar-midpoint
         (polar-lines-for-segment level seg-idx scaled?)))

Return a polar coordinate with the first element (radius) scaled using the function scalefn

(defn scale-polar-coord
  [scalefn coord]
  [(scalefn (first coord)) (peek coord)])

Add 'length' to radius of polar coordinate.

(defn polar-extend
  [length coord]
  [(+ length (first coord))
   (peek coord)])

Returns vector [line0 line1], where lineN is a polar coordinate describing the line from origin (canvas midpoint) that would draw the edges of a level segment.

'scaled?' sets whether you want the unscaled, inner point, or the outer point scaled with the level's scale function.

To actually draw a level's line, you would move to the unscaled point without drawing, and then draw to the scaled point.

(defn polar-lines-for-segment
  [level seg-idx scaled?]
  (let [[seg0 seg1] (get (:segments level) seg-idx)
        line0 (get (:lines level) seg0)
        line1 (get (:lines level) seg1)]
    (if (true? scaled?)
      [(scale-polar-coord (:length-fn level) line0)
       (scale-polar-coord (:length-fn level) line1)]
      [line0 line1])))

Path, in polar coordinates, describing the player's ship.

Path that defines player.

(def 
  *player-path*
  [[24 90]
   [26 196]
   [16 333]
   [10 135]
   [18 11]
   [18 349]
   [10 225]
   [16 27]
   [26 164]])
(defn bounding-box-from-radius
  [origin radius]
  (let [d (* radius 2)]
    {:x (- (first origin) radius)
     :y (- (peek origin) radius)
     :width d
     :height d}))

Returns the path of polar coordinates to draw the player correctly at its current location. It corrects for size and angle.

(defn player-path-on-level
  [player]
  ;;(rotate-path (enemy-angle player) (:path player)))
  (rotate-path (enemy-angle player)
               (player-path-with-width
                 (* 0.75 (entity-desired-width player))
                 (= (:step player) (:steps (:level player))))))

Returns the radius of the bounding circle around the given flipper's path.

(defn flipper-path-bounding-circle-radius
  [path]
  (max (map first path)))

Returns the path of polar coordinates to draw a flipper correctly at its current location. It corrects for size and angle.

(defn flipper-path-on-level
  [flipper]
  (let [coord (polar-entity-coord flipper)]
    (rotate-path
     (enemy-angle flipper)
     (flipper-path-with-width (* 0.8 (entity-desired-width flipper))))))

Returns the path of polar coordinates to draw a flipper correctly at its current location. It corrects for size and angle.

(defn tanker-path-on-level
  [tanker]
  (let [coord (polar-entity-coord tanker)]
    (rotate-path
     (enemy-angle tanker)
     (tanker-path-with-width (entity-desired-width tanker)))))

Returns the path of polar coordinates to draw a flipper correctly at its current location. It corrects for size and angle.

(defn spiker-path-on-level
  [entity]
  (let [coord (polar-entity-coord entity)]
    (rotate-path
     (enemy-angle entity)
     (spiker-path-with-width (entity-desired-width entity)))))

Returns the path of polar coordinates to draw a projectile correctly at its current location. It corrects for size and angle.

(defn projectile-path-on-level
  [projectile]
  (let [coord (polar-entity-coord projectile)]
    (rotate-path
     (enemy-angle projectile)
     (projectile-path-with-width (* 0.3 (entity-desired-width projectile))))))

Returns a path to draw a 'tanker' enemy with given width. Tanker is a diamond with an angular 'cat-eye' inside.

(defn tanker-path-with-width
  [width]
  (let [r (* .55 (/ width (* 2 (js/Math.cos (util/deg-to-rad 45)))))
        midheight (* .55 (* r (js/Math.sin (util/deg-to-rad 45))))
        r2 (* .55 (/ (/ width 2) (* 2 (js/Math.cos (util/deg-to-rad 65)))))]
    [[midheight 270]
     [r 45]
     [r 135]
     [r 225]
     [r 315]
     [r2 65]
     [r2 115]
     [r2 245]
     [r2 295]]))

Returns a path to draw a 'flipper' enemy with given width.

(defn flipper-path-with-width
  [width]
  (let [r (/ width (js/Math.cos (util/deg-to-rad 16)))]
    [[0 0]
     [(/ r 2) 16]
     [(/ r 4) 214]
     [(/ r 4) 326]
     [r 164]
     [(/ r 4) 326]
     [(/ r 4) 214]
     [(/ r 2) 16]]))

Returns a path to draw a 'spiker' enemy with given width.

(defn spiker-path-with-width
  [width]
  (let [r (util/round (/ width (js/Math.cos (util/deg-to-rad 16))))
        r_14 (/ r 14) r_11 (/ r 11) r_8 (/ r 8) r_6 (/ r 6)
        r_5 (/ r 5) r_4 (/ r 4)]
    [[0 0]
     [r_14 0]
     [r_14 60]
     [r_11 120]
     [r_11 180]
     [r_8  240]
     [r_8  300]
     [r_6  0]
     [r_6  60]
     [r_5  120]
     [r_5  180]
     [r_4  240]
     [r_4  300]
     [r_5  350]
     [r_5  40]]))

Returns a path to draw a projectile with the given width.

(defn player-path-with-width
  [width offset?]
  (let [r (/ (/ width 2) (js/Math.cos (util/deg-to-rad 16)))
        offset (if offset? r 0)]
  [[offset 90]
   [r 196]
   [(* 0.62 r) 333]
   [(* 0.38 r) 135]
   [(* 0.69 r) 11]
   [(* 0.69 r) 349]
   [(* 0.38 r) 225]
   [(* 0.62 r) 27]
   [r 164]]))

Returns a path to draw a projectile with the given width.

(defn projectile-path-with-width
  [width]
  (let [r (/ width (* 2 (js/Math.cos (util/deg-to-rad 45))))
        midheight (* r (js/Math.sin (util/deg-to-rad 45)))]
    [[midheight 270]
     [r 45]
     [r 135]
     [r 225]
     [r 315]]))

Add angle to all polar coordinates in path.

(defn rotate-path
  [angle path]
  (map (fn [coords]
         [(first coords)
          (mod (+ angle (peek coords)) 360)])
       path))

Multiply all lengths of polar coordinates in path by scale.

(defn scale-path
  [scale path]
  (map (fn [coords]
         [(* scale (first coords))
          (peek coords)])
       path))

Add 'length' to all polar coordinates in path

(defn path-extend
  [length path]
  (map #(polar-extend length %) path))

Returns the angle (in radians) of the given segment. The angle of a segment is the angle of any line projected onto it.

(defn segment-angle
  [level seg-idx]
  (let [[point0 point1] (polar-lines-for-segment level seg-idx false)]
    (apply js/Math.atan2
           (vec (reverse (map - (polar-to-cartesian-coords point0)
                              (polar-to-cartesian-coords point1)))))))

Returns the angle (in degrees) from origin that the enemy needs to be rotated to appear in the correct orientation at its current spot on the level. In reality, it returns the angle of the line that traverses the segment across the midpoint of the enemy. TODO: This should be renamed to 'entity-angle', it works with anything on the board.

(defn enemy-angle
  [enemy]
  (util/rad-to-deg (segment-angle (:level enemy) (:segment enemy))))

Returns how wide the given enemy should be drawn to span the full width of its current location. In reality, that means returning the length of the line that spans the level segment, cutting through the enemy's midpoint. TODO: rename this entity-desired-width.

(defn entity-desired-width
  [enemy]
  (let [edges (polar-lines-for-segment (:level enemy)
                                       (:segment enemy)
                                       false)
        edge-steps (step-lengths-for-segment-lines (:level enemy)
                                                   (:segment enemy))
        offset0 (* (first edge-steps) (:step enemy))
        offset1 (* (peek edge-steps) (:step enemy))
        point0 (polar-extend offset0 (first edges))
        point1 (polar-extend offset1 (peek edges))]
    (polar-distance point0 point1)))
 

Small utility and math helper functions.

(ns 
  tempest.util)

Convert radians to degrees

(defn rad-to-deg
  [rad]
  (/ (* rad 180) 3.14159265358979))

Convert degrees to radians

(defn deg-to-rad
  [deg]
  (/ (* deg 3.14159265358979) 180))

Perform quick rounding of given number. ONLY WORKS WITH POSITIVE NUMBERS.

(defn round
  [num]
  (js* "~~" (+ 0.5 num)))