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