summary history branches tags files
tempest/tempest/core.cljs
;;
;; This file is part of tempest-cljs
;; Copyright (c) 2012, Trevor Bentley
;; All rights reserved.
;; See LICENSE file for details.
;;

(ns ^{:doc "
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.

"}
  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")

;; ---

(defn next-game-state
  "**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.
"
  [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)))

(defn game-logic-paused
  "Called by next-game-state when game is paused.  Just listens for keypress
   to unpause game."
  [game-state]
  (->> game-state
       dequeue-keypresses-while-paused
       schedule-next-frame))

(defn game-logic-player-shooping-down-level
  "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."
  [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
       ))

(defn game-logic-playable
  "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."
  [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-enemies-remain
                 check-if-player-captured
                 update-player-if-shot
                 update-entity-is-flipping
                 update-entity-flippyness
                 animate-player-capture
                 update-frame-count
                 maybe-render-fps-display)]
    (->> gs3 schedule-next-frame)))


(defn game-logic-non-playable
  "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."
  [game-state]
  (->> game-state
       dequeue-keypresses-while-paused
       clear-frame
       draw-board
       render-frame
       update-frame-count
       maybe-render-fps-display
       schedule-next-frame))

;; ---

(def ^{:doc
       "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."}
  *key-event-queue* (atom '()))


(defn build-game-state
  "Returns an empty game-state map."
  []
  {: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
   })

(defn check-if-enemies-remain
  "If no enemies are left on the level, and no enemies remain to be launched
   mark level as zooming out."
  [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)))

(defn change-level
  "Changes current level of game."
  [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))))))

(defn maybe-change-level
  "Reloads or moves to the next level if player is dead, or if all enemies are
   dead."
  [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)))
             

(defn build-projectile
  "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."
  [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?
   })

(def ^{:doc "Enumeration of directions a flipper can be flipping."}
  DirectionEnum {"NONE" 0 "CW" 1 "CCW" 2})

(def ^{:doc "Enumeration of types of enemies."}
  EnemyEnum {"NONE" 0 "FLIPPER" 1 "TANKER" 2
             "SPIKER" 3 "FUSEBALL" 4 "PULSAR" 5})

(defn direction-string-from-value
  "Given a value from DirectionEnum, return the corresponding string."
  [val]
  (first (first (filter #(= 1 (peek %)) (into [] maptest)))))

(defn build-enemy
  "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."
  [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")
   })

(defn build-tanker
  "Returns a new tanker enemy.  Tankers move slowly and do not shoot or flip."
  [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
    ))

(defn build-spiker
  "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."
  [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)
    ))

(defn build-flipper
  "A more specific form of build-enemy for initializing a 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
    ))

(defn projectiles-after-shooting
  "Returns a new list of active projectiles after randomly adding shots from
   enemies."
  [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)))))

(defn maybe-enemies-shoot
  "Randomly adds new projectiles coming from enemies based on the enemies'
   shoot-probability field.  See projectiles-after-shooting."
  [game-state]
  (let [enemies (:enemy-list game-state)
        projectiles (:projectile-list game-state)]
  (assoc game-state
    :projectile-list (projectiles-after-shooting enemies projectiles))))

(defn maybe-make-enemy
  "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."
  [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)))


(defn flip-angle-stride
  "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.
"
  [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))))

(defn mark-flipper-for-flipping
  "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."
  [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)))

(defn update-entity-stop-flipping
  "Updates an entity and marks it as not currently flipping."
  [flipper]  
  (assoc flipper
    :stride (:old-stride flipper)
    :flip-dir (DirectionEnum "NONE")
    :flip-cur-angle 0
    :segment (:flip-to-segment flipper)))

(defn random-direction
  "Returns a random direction from DirectionEnum (not 'NONE')"
  []
  (condp = (rand-int 2)
        0 (DirectionEnum "CW")
        (DirectionEnum "CCW")))

(defn segment-for-flip-direction
  "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."
  [flipper flip-dir]
  (condp = flip-dir
        (DirectionEnum "CW") (segment-entity-cw flipper)
        (segment-entity-ccw flipper)))

(defn swap-flipper-permanent-dir
  "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."
  [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)))

(defn engage-flipping
  "Mark flipper as flipping in given direction, unless no segment is in
   that direction."
  [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)))

(defn maybe-engage-flipping
  "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."
  [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)))

(defn mark-player-captured
  "Marks player as being captured."
  [player]
  (assoc player
    :captured? true
    :stride -4))

(defn mark-enemy-capturing
  "Marks enemy as having captured the player."
  [enemy]
  (assoc enemy
    :capturing true
    :can-flip false
    :step (- (:step enemy) 10) ;; looks better if enemy leads player
    :stride -4))

(defn enemy-is-on-player?
  "Returns true if given enemy and player are on top of each other."
  [player enemy]
  (and (= (:segment player) (:segment enemy))
       (= (:step player) (:step enemy))
       (= (DirectionEnum "NONE") (:flip-dir enemy))))

(defn player-and-enemies-if-captured
  "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."
  [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))])))

(defn check-if-player-captured
  "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."
  [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)))


(defn update-entity-is-flipping
  "Decide if an enemy should start flipping for every enemy on the level."
  [game-state]
  (let [{enemy-list :enemy-list} game-state]
    (assoc game-state :enemy-list (map maybe-engage-flipping enemy-list))))

(defn update-entity-flippyness
  "Update the position of any actively flipping enemy for every enemy on the
   level."
  [game-state]
  (let [{enemy-list :enemy-list} game-state]
    (assoc game-state :enemy-list (map update-flip-angle enemy-list))))

(defn update-flip-angle
  "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."
  [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)))


(defn build-player
  "Returns a dictionary describing a player on the given level and segment."
  [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
   })

(defn entity-next-step
  "Returns the next step position of given entity, taking into account
   minimum and maximum positions of the level."
  [entity]
  (let [stride (:stride entity)
        maxstep (:steps (:level entity))
        newstep (+ stride (:step entity))]
    (cond
     (> newstep maxstep) maxstep
     (< newstep 0) 0
     :else newstep)))

(defn update-entity-position!
  "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."
  [entity]
  (assoc entity :step (entity-next-step entity)))

(defn update-entity-list-positions
  "Call update-entity-position! on all entities in list."
  [entity-list]
  (map update-entity-position! entity-list))

(defn update-entity-direction!
  "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."
  [entity]
  (let [{:keys [step max-step stride]} entity
        newstride (if (>= step max-step) (- stride) stride)]
  (assoc entity :stride newstride)))

(defn update-entity-list-directions
  "Apply update-entity-direction! to all enemies in the given list that have
   a maximum step."
  [entity-list]
  (let [{spikers true others false}
        (group-by #(contains? % :max-step) entity-list)]
    (concat others (map update-entity-direction! spikers))))

(defn entity-between-steps
  "Returns true of entity is on seg-idx, and between steps step0 and step1,
   inclusive."
  [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))))

(defn projectiles-after-collision
  "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."
  [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))

(defn entities-after-collisions
  "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."
  [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))


(defn new-flippers-from-tanker
  "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."
  [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")))))

(defn enemy-list-after-deaths
  "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."
  [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)))))

(defn handle-dead-enemies
  "Return game state after handling dead enemies, by removing them and possibly
   replacing them with children."
  [game-state]
  (let [enemy-list (:enemy-list game-state)]
    (assoc game-state :enemy-list (enemy-list-after-deaths enemy-list))))

(defn enemy-list-after-exiting-spikers
  "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."
  [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))))))

(defn handle-exiting-spikers
  "Apply enemy-list-after-exiting-spikers to the enemy list and update game
   state.  This removes any spikers that are ready to disappear."
  [game-state]
  (let [enemy-list (:enemy-list game-state)]
    (assoc game-state
      :enemy-list (enemy-list-after-exiting-spikers enemy-list))))

(defn spikes-after-spike-laying
  "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]"
  [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)))))
     
(defn handle-spike-laying
  "Updates the length of spikes on the level.  See spikes-after-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))))

(defn kill-tanker-at-top
  "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."
  [tanker]
  (let [step (:step tanker)
        maxstep (:steps (:level tanker))]
    (if (= step maxstep)
      (assoc tanker :hits-remaining 0)
      tanker)))

(defn maybe-split-tankers
  "Marks tankers at the top of the level as ready to split into flippers."
  [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))))

(defn mark-player-if-spiked
  "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."
  [game-state]
  (let [{:keys [spikes player]} game-state step (:step player)
        ;; TODO: (nth) caused an error here once, spikes was length 0.
        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)))

(defn animate-player-shooping
  "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)."
  [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
             ))))

(defn animate-player-capture
  "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."
  [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)))))

(defn clear-level-entities
  "Clears enemies, projectiles, and spikes from level."
  [game-state]
  (assoc game-state
    :enemy-list '()
    :projectile-list '()
    :spikes []))

(defn update-zoom
  "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."
  [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)))))


(defn clear-player-segment
  "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."
  [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))

(defn highlight-player-segment
  "Returns game-state unchanged, and as a side effect draws the player's
   current segment with a yellow border."
  [game-state]
  (do
    (set! (. (:bgcontext game-state) -lineWidth) 1)
    (draw/draw-player-segment game-state {:r 150 :g 150 :b 15})
    game-state))


(defn draw-board
  "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."
  [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)))
        

(defn collisions-with-projectile
  "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."
  [enemy-list bullet]
  (group-by (partial entity-between-steps
                   (:segment bullet)
                   (:step bullet)
                   (entity-next-step bullet))
            enemy-list))


(defn decrement-enemy-hits
  "Decrement hits-remaining count on given enemy."
  [enemy]
  (assoc enemy :hits-remaining (dec (:hits-remaining enemy))))


(defn projectile-off-level?
  "Returns true if a projectile has reached either boundary of the level."
  [projectile]
  (cond
   (zero? (:step projectile)) true
   (>= (:step projectile) (:steps (:level projectile))) true
   :else false))

(defn add-enemy-projectile
  "Add a new projectile to the global list of live projectiles, originating
   from the given enemy, on the segment he is currently on."
  [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))))

(defn add-player-projectile
  "Add a new projectile to the global list of live projectiles, originating
   from the given player, on the segment he is currently on."
  [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))))

(defn segment-entity-cw
  "Returns the segment to the left of the player.  Loops around the level
   on connected levels, and stops at 0 on unconnected levels."
  [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)))


(defn segment-entity-ccw
  "Returns the segment to the right of the player.  Loops around the level
   on connected levels, and stops at max on unconnected levels."
  [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)))


(defn queue-keypress
  "Atomically queue keypress in global queue for later handling.  This should
   be called as the browser's key-handling callback."
  [event]
  (let [key (.-keyCode event)]
    (swap! *key-event-queue* #(concat % [key]))
    (.preventDefault event)
    (.stopPropagation event)))

(defn dequeue-keypresses-while-paused
  "See dequeue-keypresses for details.  This unqueues all keypresses, but only
   responds to unpause."
  [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*))))))

(defn handle-keypress-unpause
  "See handle-keypress.  This version only accepts unpause."
  [game-state key]
  (let [paused? (:paused? game-state)]
    (condp = key
      key-codes/ESC (assoc game-state :paused? (not paused?))
      game-state)))


(defn handle-keypress
  "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
  "
  [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)))

(defn dequeue-keypresses
  "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.
"
  [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*)
        )))))


(defn animationFrameMethod
  "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.
"
  []
  (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)))


(defn clear-frame
  "Returns game state unmodified, clears the HTML5 canvas as a side-effect."
  [game-state]
  (do
    (draw/clear-context (:context game-state) (:dims game-state))
    game-state))

(defn render-frame
  "Draws the current game-state on the HTML5 canvas.  Returns the game state
   unmodified (drawing is a side-effect)."
  [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))

(defn remove-collided-entities
  "Detects and removes projectiles that have collided with enemies, and enemies
   whose hit counts have dropped to zero.  Returns updated game-state."
  [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))))

(defn bullets-will-collide?
  "Returns true if two bullets will collide within the next frame, and one is
   from the player and the other is from an enemy."
  [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)))))
           
(defn projectile-list-without-collisions
  "Given a list of projectiles, returns the list minus any bullet-on-bullet
   collisions that occur within it."
  [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)))))))

(defn remove-collided-bullets
  "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."
  [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)))

(defn decrement-spike-length
  "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."
  [spike-len hit-count]
  (let [new-len (- spike-len (* 10 hit-count))]
    (if (<= new-len 5) 0 new-len)))

(defn filter-spike-bullet-collisions
  "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."
  [projectile-list spike-len]
  (let [{hit true missed false}
        (group-by #(<= (:step %) spike-len) projectile-list)]
    [missed (decrement-spike-length spike-len (count hit))]))
    
(defn remove-spiked-bullets
  "Returns the game state with any bullets that hit a spike removed, and any
   spikes that were hit shrunk in length."
  [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)))))))
         
    

(defn bullets-will-kill-player?
  "Returns true given bullet will hit the given player."
  [player bullet]
  (let [next-step (entity-next-step bullet)
        player-step (:step player)]
    (and (= player-step next-step)
         (:from-enemy? bullet))))

(defn update-player-if-shot
  "Updates the player to indicate whether he was shot by an enemy."
  [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
        :player-zooming? false
        :zoom-in? false)
      game-state)))

(defn update-projectile-locations
  "Returns game-state with all projectiles updated to have new positions
   based on their speeds and current position."
  [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))))

(defn update-enemy-locations
  "Returns game-state with all of the enemies updated to have new positions
   based on their speeds and current position."
  [game-state]
  (let [{enemy-list :enemy-list} game-state]
    (assoc game-state :enemy-list (update-entity-list-positions enemy-list))))

(defn update-enemy-directions
  "Return game state with any enemies who were ready to turn around marked to
   travel in the opposite direction."
  [game-state]
  (let [{enemy-list :enemy-list} game-state]
    (assoc game-state :enemy-list (update-entity-list-directions enemy-list))))

(defn schedule-next-frame
  "Tells the player's browser to schedule the next frame to be drawn, using
   whatever the best mechanism the browser has to do so."
  [game-state]
  ((:anim-fn game-state) #(next-game-state game-state)))

(defn update-frame-count
  "Increments the game-state's frame counter, which is a count of frames since
   the last FPS measurement."
  [game-state]
  (let [{frame-count :frame-count}
        game-state]
    (assoc game-state :frame-count (inc frame-count))))

(defn render-fps-display
  "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."
  [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))))

(defn maybe-render-fps-display
  "Calls render-fps-display if the frame-count is above a certain threshhold."
  [game-state]
  (if (= (:frame-count game-state) 20)
    (render-fps-display game-state)
    game-state))