A Virtual World for Sensate Creatures
aurellem ☉
1 The World
There's no point in having senses if there's nothing to experience. In this section I make some tools with which to build virtual worlds for my characters to inhabit. If you look at the tutorials at the jme3 website, you will see a pattern in how virtual worlds are normally built. I call this "the Java way" of making worlds.
- The Java way:
- Create a class that extends
SimpleApplication
orApplication
- Implement setup functions that create all the scene objects using
the inherited
assetManager
and call them by overriding thesimpleInitApp
method. - Create
ActionListeners
and add them to theinputManager
inherited fromApplication
to handle key-bindings. - Override
simpleUpdate
to implement game logic. - Running/Testing an Application involves creating a new JVM, running the App, and then closing everything down.
- Create a class that extends
- A more Clojureish way:
- Use a map from keys->functions to specify key-bindings.
- Use functions to create objects separately from any particular application.
- Use a REPL – this means that there's only ever one JVM, and Applications come and go.
Since most development work using jMonkeyEngine is done in Java, jme3 supports "the Java way" quite well out of the box. To work "the clojure way", it necessary to wrap the JME3 elements that deal with the Application life-cycle with a REPL driven interface.
The most important modifications are:
- Separation of Object life-cycles with the Application life-cycle.
- Functional interface to the underlying
Application
andSimpleApplication
classes.
1.1 Header
(ns cortex.world "World Creation, abstraction over jme3's input system, and REPL driven exception handling" {:author "Robert McIntyre"} (:import com.aurellem.capture.IsoTimer) (:import com.jme3.math.Vector3f) (:import com.jme3.scene.Node) (:import com.jme3.system.AppSettings) (:import com.jme3.system.JmeSystem) (:import com.jme3.input.KeyInput) (:import com.jme3.input.controls.KeyTrigger) (:import com.jme3.input.controls.MouseButtonTrigger) (:import com.jme3.input.InputManager) (:import com.jme3.bullet.BulletAppState) (:import com.jme3.shadow.BasicShadowRenderer) (:import com.jme3.app.SimpleApplication) (:import com.jme3.input.controls.ActionListener) (:import com.jme3.renderer.queue.RenderQueue$ShadowMode) (:import org.lwjgl.input.Mouse) (:import com.aurellem.capture.AurellemSystemDelegate))
1.2 General Settings
(in-ns 'cortex.world) (def ^:dynamic *app-settings* "These settings control how the game is displayed on the screen for debugging purposes. Use binding forms to change this if desired. Full-screen mode does not work on some computers." (doto (AppSettings. true) (.setFullscreen false) (.setTitle "Aurellem.") ;; The "Send" AudioRenderer supports simulated hearing. (.setAudioRenderer "Send"))) (defn asset-manager "returns a new, configured assetManager" [] (JmeSystem/newAssetManager (.getResource (.getContextClassLoader (Thread/currentThread)) "com/jme3/asset/Desktop.cfg")))
Normally, people just use the AssetManager
inherited from
Application
whenever they extend that class. However,
AssetManagers
are useful on their own to create objects/ materials,
independent from any particular application. (asset-manager)
makes
object creation less tightly bound to a particular Application
Instance.
1.3 Exception Protection
(in-ns 'cortex.world) (defmacro no-exceptions "Sweet relief like I never knew." [& forms] `(try ~@forms (catch Exception e# (.printStackTrace e#)))) (defn thread-exception-removal "Exceptions thrown in the graphics rendering thread generally cause the entire REPL to crash! It is good to suppress them while trying things out to shorten the debug loop." [] (.setUncaughtExceptionHandler (Thread/currentThread) (proxy [Thread$UncaughtExceptionHandler] [] (uncaughtException [thread thrown] (println "uncaught-exception thrown in " thread) (println (.getMessage thrown))))))
Exceptions thrown in the LWJGL render thread, if not caught, will destroy the entire JVM process including the REPL and slow development to a crawl. It is better to try to continue on in the face of exceptions and keep the REPL alive as long as possible. Normally it is possible to just exit the faulty Application, fix the bug, reevaluate the appropriate forms, and be on your way, without restarting the JVM.
1.4 Input
(in-ns 'cortex.world) (defn static-integer? "does the field represent a static integer constant?" [#^java.lang.reflect.Field field] (and (java.lang.reflect.Modifier/isStatic (.getModifiers field)) (integer? (.get field nil)))) (defn integer-constants [class] (filter static-integer? (.getFields class))) (defn constant-map "Takes a class and creates a map of the static constant integer fields with their names. This helps with C wrappers where they have just defined a bunch of integer constants instead of enums" [class] (let [integer-fields (integer-constants class)] (into (sorted-map) (zipmap (map #(.get % nil) integer-fields) (map #(.getName %) integer-fields))))) (alter-var-root #'constant-map memoize) (defn all-keys "Uses reflection to generate a map of string names to jme3 trigger objects, which govern input from the keyboard and mouse" [] (let [inputs (constant-map KeyInput)] (assoc (zipmap (map (fn [field] (.toLowerCase (.replaceAll field "_" "-"))) (vals inputs)) (map (fn [val] (KeyTrigger. val)) (keys inputs))) ;;explicitly add mouse controls "mouse-left" (MouseButtonTrigger. 0) "mouse-middle" (MouseButtonTrigger. 2) "mouse-right" (MouseButtonTrigger. 1)))) (defn initialize-inputs "Establish key-bindings for a particular virtual world." [game input-manager key-map] (doall (map (fn [[name trigger]] (.addMapping ^InputManager input-manager name (into-array (class trigger) [trigger]))) key-map)) (doall (map (fn [name] (.addListener ^InputManager input-manager game (into-array String [name]))) (keys key-map))))
These functions are for controlling the world through the keyboard and mouse.
constant-map
gets the numerical values for all the keys defined in
the KeyInput
class.
(take 5 (vals (cortex.world/constant-map KeyInput)))
("KEY_ESCAPE" "KEY_1" "KEY_2" "KEY_3" "KEY_4")
(all-keys)
converts the constant names like KEY_J
to the more
clojure-like key-j
, and returns a map from these keys to
jMonkeyEngine KeyTrigger
objects, which jMonkeyEngine3 uses as it's
abstraction over the physical keys. all-keys
also adds the three
mouse button controls to the map.
(clojure.pprint/pprint
(take 6 (cortex.world/all-keys)))
(["key-n" #<KeyTrigger com.jme3.input.controls.KeyTrigger@2ad82934>] ["key-apps" #<KeyTrigger com.jme3.input.controls.KeyTrigger@3c900d00>] ["key-pgup" #<KeyTrigger com.jme3.input.controls.KeyTrigger@7d051157>] ["key-f8" #<KeyTrigger com.jme3.input.controls.KeyTrigger@717f0d2d>] ["key-o" #<KeyTrigger com.jme3.input.controls.KeyTrigger@4a555fcc>] ["key-at" #<KeyTrigger com.jme3.input.controls.KeyTrigger@47d31aaa>])
1.5 World Creation
(in-ns 'cortex.world) (defn no-op "Takes any number of arguments and does nothing." [& _]) (defn traverse "apply f to every non-node, deeply" [f node] (if (isa? (class node) Node) (dorun (map (partial traverse f) (.getChildren node))) (f node))) (defn world "the =world= function takes care of the details of initializing a SimpleApplication. ***** Arguments: - root-node : a com.jme3.scene.Node object which contains all of the objects that should be in the simulation. - key-map : a map from strings describing keys to functions that should be executed whenever that key is pressed. the functions should take a SimpleApplication object and a boolean value. The SimpleApplication is the current simulation that is running, and the boolean is true if the key is being pressed, and false if it is being released. As an example, {\"key-j\" (fn [game value] (if value (println \"key j pressed\")))} is a valid key-map which will cause the simulation to print a message whenever the 'j' key on the keyboard is pressed. - setup-fn : a function that takes a SimpleApplication object. It is called once when initializing the simulation. Use it to create things like lights, change the gravity, initialize debug nodes, etc. - update-fn : this function takes a SimpleApplication object and a float and is called every frame of the simulation. The float tells how many seconds is has been since the last frame was rendered, according to whatever clock jme is currently using. The default is to use IsoTimer which will result in this value always being the same. " [root-node key-map setup-fn update-fn] (let [physics-manager (BulletAppState.)] (JmeSystem/setSystemDelegate (AurellemSystemDelegate.)) (doto (proxy [SimpleApplication ActionListener] [] (simpleInitApp [] (no-exceptions ;; allow AI entities as much time as they need to think. (.setTimer this (IsoTimer. 60)) (.setFrustumFar (.getCamera this) 300) ;; Create default key-map. (initialize-inputs this (.getInputManager this) (all-keys)) ;; Don't take control of the mouse (org.lwjgl.input.Mouse/setGrabbed false) ;; add all objects to the world (.attachChild (.getRootNode this) root-node) ;; enable physics ;; add a physics manager (.attach (.getStateManager this) physics-manager) (.setGravity (.getPhysicsSpace physics-manager) (Vector3f. 0 -9.81 0)) ;; go through every object and add it to the physics ;; manager if relevant. ;;(traverse (fn [geom] ;; (dorun ;; (for [n (range (.getNumControls geom))] ;; (do ;; (cortex.util/println-repl ;; "adding " (.getControl geom n)) ;; (.add (.getPhysicsSpace physics-manager) ;; (.getControl geom n)))))) ;; (.getRootNode this)) ;; call the supplied setup-fn ;; simpler ! (.addAll (.getPhysicsSpace physics-manager) root-node) (if setup-fn (setup-fn this)))) (simpleUpdate [tpf] (no-exceptions (update-fn this tpf))) (onAction [binding value tpf] ;; whenever a key is pressed, call the function returned ;; from key-map. (no-exceptions (if-let [react (key-map binding)] (react this value))))) ;; don't show a menu to change options. (.setShowSettings false) ;; continue running simulation even if the window has lost ;; focus. (.setPauseOnLostFocus false) (.setSettings *app-settings*))))
(world)
is the most important function here. It presents a more
functional interface to the Application life-cycle, and all its
arguments except root-node
are plain immutable clojure data
structures. This makes it easier to extend functionally by composing
multiple functions together, and to add more keyboard-driven actions
by combining clojure maps.