-
Notifications
You must be signed in to change notification settings - Fork 108
Using Arcadia
Arcadia is different from both Unity and Clojure in important ways. Knowledge of both is important, but so is understanding how Arcadia itself works.
Unity will notify GameObjects when particular events occur, such as collisions with other objects or interaction from the user. Most of Unity's events are listed here.
In C# Unity development, you specify how to respond to events by implementing Component classes implementing event methods, and attaching instances of these classes to GameObjects.
Arcadia provides a simple and consistent associative (key-based) interface into Unity's event system. Users can process the scene graph as data and transparently converting events and state to Clojure's persistent maps and back. Rather than defining Component classes, Arcadia users associate callback functions (referred to as hooks) with GameObjects and Unity events.
This comprises a system of related functions:
function | description |
---|---|
(hook obj, event-kw, role-key) |
Retrieves a message callback (hook) from GameObject obj on a key |
(hook+ obj, event-kw, role-key, IFn) |
Sets a message callback on a key |
(hook- obj, event-kw, role-key) |
Removes a message callback on a key |
(state obj, role-key) |
Retrieves a piece of state from obj on a key |
(state+ obj, role-key, state-map) |
Sets a piece of state on a key |
(state- obj, role-key) |
Removes a piece of state on a key |
(update-state obj, role-key, f & args) |
Updates the state on a key by applying function f to it |
(role obj, role-key) |
Retrieves a map containing all hooks and state on a key |
(role+ obj, role-key, role-map) |
Sets state and multiple hooks on a key |
(role- obj, role-key) |
Removes state and all hooks on a key |
(roles obj) |
Retrieves mapping from all keys on obj to the role-map for a given obj |
(roles+ obj, roles-map) |
Sets multiple roles at once (may remove hooks or state) |
Hooks are functions (anything that implements clojure.lang.IFn
). When their corresponding Unity event fires, they are passed two or more arguments:
- The GameObject they are attached to
- The
role-key
value that was used when the hook was attached - Additional arguments depending on the Unity event
For example, Unity's Update event provides no additional arguments, so an :update
hook would take two arguments
(use 'arcadia.core)
(defn log-name [obj role-key]
(log (.name obj)))
(let [the-object (new UnityEngine.GameObject "some object")]
(hook+
the-object
:update
:log-name
;; in log-name `obj` will be the `the-object`, `role-key` will be `:log-name`
#'log-name))
Unity's OnCollisionEnter event provides a single argument of type Collision
that describes the collision that occurred, an :on-collision-enter
hook would take three arguments:
(defn log-collision [obj role-key collision]
(log "just bumped into" (.. collision gameObject name)))
(let [the-object (new UnityEngine.GameObject "some object")]
(hook+
the-object
:on-collision-enter
:log-collision
;; in log-collision `obj` will be the `the-object`, `role-key` will be
;; `:log-collision`, `collision` will be the Collision instance provided
;; by Unity
#'log-collision))
The hook system is complemented by defmutable, a type-definition mechanism for high-performance mutable state:
form | description |
---|---|
(defmutable OrbitState [^float radius]) |
Defines mutable type with unboxed primitive field radius
|
(snapshot orbit-state) |
Retrieves persistent representation of defmutable -type instance orbit-state
|
(mutable orbit-state-map) |
Constructs defmutable -type instance from persistent representation orbit-state-map
|
roles
will snapshot any defmutable
-type instances in a GameObject's state, and role+
will convert any defmutable
snapshot data to a corresponding defmutable
instance. Users can thereby process the scene graph as immutable data without sacrificing imperative performance.
The hook system is fully optional and can coexist with other approaches. If you want to roll your own treatment of the scene graph Arcadia will not get in your way.
(use 'arcadia.core 'arcadia.linear)
(import '[UnityEngine GameObject Time Mathf Transform])
(defn orbit [^GameObject obj, k] ; Takes the GameObject and the key this function was attached with
(let [{:keys [:radius]} (state obj k)] ; Looks up the piece of state corresponding to the key `k`
(with-cmpt obj [tr Transform]
(set! (. tr position)
(v3 (* radius (Mathf/Cos Time/realtimeSinceStartup))
0
(* radius (Mathf/Sin Time/realtimeSinceStartup)))))))
(let [gobj (create-primitive :cube "Orbiter")]
(state+ gobj :orbit {:radius 5}) ; set up state
(hook+ gobj :fixed-update :orbit #'orbit) ; set up message callback (hook)
)
The role system can abbreviate this and treat it as data:
(let [gobj (create-primitive :cube "Orbiter")]
(role+ gobj :orbit
{:state {:radius 5}
:fixed-update #'orbit}))
role+
is intended to emulate assoc
with the GameObject in the place of a map, and will therefore replace any hooks and state already on the GameObject at the specified key.
(defn orbit-collide [obj1 obj2 k]
(UnityEngine.Debug/Log "a collision!"))
;; We'll start by setting up a role.
(role+ (object-named "Orbiter") :orbit
{:state {:radius 5},
:fixed-update #'orbit
:on-collision-enter #'orbit-collide})
(role (object-named "Orbiter") :orbit)
;; =>
;; {:state {:radius 5},
;; :fixed-update #'orbit
;; :on-collision-enter #'orbit-collide}
(defn other-orbit-collide [obj1 obj2 k]
(UnityEngine.Debug/Log "another collision!"))
;; If we call `role+` again with the same key but a different map of
;; hooks and state, it will:
;; - reset hooks and state to the specified values
;; - remove any existing hooks or state not specified
(role+ (object-named "Orbiter") :orbit
{:state {:radius 8},
:on-collision-enter #'other-orbit-collide})
(role (object-named "Orbiter") :orbit)
;; =>
;; {:state {:radius 8},
;; :on-collision-enter #'other-orbit-collide}
;; Note that the :fixed-update hook has been removed.
Attached state and roles can then be retrieved as persistent maps using role
:
(role (object-named "Orbiter") :orbit)
;; =>
;; {:state {:radius 5},
;; :fixed-update #'user/orbit}
Multiple callbacks and pieces of state may be attached in this manner.
;; ...
(defn bobble [^GameObject obj, k] ; Takes the GameObject and the key this function was attached with
(let [{:keys [:amplitude]} (state obj k)] ; Looks up the piece of state corresponding to the key `k`
(with-cmpt obj [tr Transform]
(set! (. tr position)
(v3 (.. tr position x)
(* amplitude (Mathf/Cos Time/realtimeSinceStartup))
(.. tr position y))))))
(let [gobj (create-primitive :cube "Orbiter")]
(state+ gobj :orbit {:radius 5}) ; set up state
(hook+ gobj :fixed-update :orbit #'orbit) ; set up the hook
(state+ gobj :bobble {:amplitude 5}) ; set up another piece of state
(hook+ gobj :fixed-update #'bobble) ; set up another hook
)
All keys can be accessed and managed at once using roles
and roles+
.
roles
bundles up all the keys, hooks, and state into a map with the hook or state keys as its keys, and maps suitable for use in role+
as its values.
(roles (object-named "Orbiter"))
;; =>
;; {:orbit {:state {:radius 5},
;; :fixed-update #'orbit},
;; :bobble {:state {:amplitude 5},
;; :fixed-update #'bobble}}
roles+
attaches a bundle of keys, hooks, and state, such as might be returned by roles
, to the scene graph.
(roles+ (object-named "Orbiter")
{:orbit {:state {:radius 5},
:fixed-update #'orbit},
:bobble {:state {:amplitude 5},
:fixed-update #'bobble}})
While anonymous fn
functions and other IFn
s can be used as hooks, Vars are greatly preferred because they can be serialized. Without serialization, hooks cannot be:
- Cloned using
instantiate
- Displayed in the inspector
- Saved into a scene
- Saved into a prefab
Due to these limitations, Arcadia will by default issue a warning to the console if you attach a hook to an object that is not a var. This behavior can be controlled in your configuration file as documented in the Configuration page of the wiki.
Any data that would be closed over by an anonymous function instance can be stored in the state corresponding to a callback Var. For example, consider the following function that takes a GameObject and a numeric rate of spin, and starts the GameObject spinning at that rate.
(defn start-spinning [obj, rate]
(let [f (fn [obj, _]
(with-cmpt obj [tr Transform]
(set! (.. tr rotation)
(qq* (.. tr rotation)
(aa rate 0 1 0)))))] ; uses `rate` to set degrees spun per frame
(role+ obj ::gyrate
{:update f})))
Here we attach an anonymous fn
to obj
as the :update
hook. Anonymous fn
's cannot serialize, therefore this GameObject cannot serialize, greatly limiting its role in our scene as described above.
We can solve this problem by boosting the :update
hook into a top-level named function, and storing rate
in the state:
(defn spin [obj, k]
(let [{:keys [rate]} (state obj k)] ; retrieve rate of spinning from state
(with-cmpt obj [tr Transform]
(set! (.. tr rotation)
(qq* (.. tr rotation)
(aa rate 0 1 0))))))
(defn start-spinning [obj, rate]
(role+ obj ::gyrate
{:update #'spin ; attach the Var #'spin as an update hook
:state {:rate rate}}) ; store rate of spinning in state
)
This arrangement will serialize correctly.
Clojure developers often look for a "main" function or an "entry point" that they can use to kick off their code. Unity does not have such a thing. All code at runtime is triggered through Messages, meaning all code at runtime is triggered by Components attached to GameObjects. If your game logic would benefit from an entry point, you will have to add an empty GameObject to the scene with a :start
hook associated with the function you want to run at startup.
It's good to know is that any code loaded by a hook attached to a game object will be run during deserialization. This means that if you refer to a namespace that runs Resources/Load
when it's loaded, e.g. by using def
: (def x (Resources/Load "x"))
, then you will get this error: UnityException: Load is not allowed to be called during serialization, call it from Awake or Start instead. Called from MonoBehaviour 'AwakeHook' on game object 'keyboard'.
The way to solve this is to only call Resources/Load
in :start
, :update
, :awake
or similar hooks. One way to do this is to create an init-function which gets called in a :start
-hook, and that in turn runs the required Resources/Load
s. E.g.
(def x nil)
(defn init
[]
(def x (Resources/Load "x")))
This might not look that pretty, but it works. You could also do something like having a resources map, and update it:
(def resources (atom {}))
(defn add-res!
[res]
(swap! resources assoc res (Resources/Load res)))
(init
[]
(add-res! "x"))
A bit prettier, but more code.
For hooks that serialize and persist, writing code to a file is crucial. At the same time, the UI for adding hooks is not finished yet, so the REPL is still needed. A good workflow is the following:
- Start a Clojure file in
Assets
following Clojure folder naming conventions (e.g.Assets/game/core.clj
) - Give the file a namespace form as usual with any
require
s you need
(ns game.core
(:use arcadia.core))
- Define a function in that file that you want to use as a hook
(defn rotate [obj role-key]
(.. obj transform (Rotate 0 1 0)))
- Save the file
- In the REPL, require the namespace and use
arcadia.core
user=> (require 'game.core)
user=> (use 'arcadia.core)
- In the REPL, attach the var to a GameObject with the appropriate event keyword and a role keyword
user=> (hook+ (object-named "Main Camera") :update :rotation #'game.core/rotate)
- Save the scene
Most Clojure programmers are familiar with the JVM-based version of the language, but Arcadia does not use that. Instead, it is built on the official port to the Common Language Runtime that David Miller maintains. We maintain our own fork of the compiler so that we can introduce Unity specific fixes.
As an Arcadia programmer you should be aware of the differences between ClojureCLR and ClojureJVM, and the ClojureCLR wiki is a good place to start, in particular the pages on interop and types. Our wiki page on "gotchas" is a good resource on implementation-specific bugs/surprises and their workarounds.
Arcadia does not go out of its way to "wrap" the Unity API in Clojure functions. Instead, a lot of Arcadia programming bottoms out in interoperating directly with Unity. For a function to point the camera at a point in space could look like
(defn point-camera [p]
(.. Camera/main transform (LookAt p)))
This uses Clojure's dot special form to access the static main
field of the Camera
class, which is a Camera
instance and has a transform
property of type Transform
that has a LookAt
method that takes a Vector3
. It is the equivalent of Camera.main.transform.LookAt(p)
in C#.
Unity is a highly mutable, stateful system. The above function will mutate the main camera's rotation to look at the new point. Furthermore, the reference to Camera/main
could be changed by some other bit of code. Unity's API is single-threaded by design, so memory corruption is avoided. Your own Clojure code can still be as functional and multi-threaded as you like, but keep in mind that using the Unity API has side effects and is impure.
For convenience, there are parts of the API that we have wrapped in Clojure functions and macros. These are usually very commonly used methods whose terseness would improve the experience at the REPL. The scope of what does and does not get wrapped is an ongoing design exercise of the framework, but in general we plan to be conservative about how much of our own ideas we impose on top of the Unity API.
While the Unity scene graph API is ostensibly single threaded, the Mono VM it runs on is not. This means you can write multithreaded Clojure code without issue provided that you do not call Unity scene graph methods off the main thread. If you do, they will throw an exception. REPL code is evaluated on the main thread, and the hook/state system can only be used from the main thread.
Note that not all types in the UnityEngine namespace need to be used from the main thread. Some value types such as Vector3
are usable anywhere, for example.
The Assets
folder is the root of your namespaces. So a file at Assets/game/logic/hard_ai.clj
should correspond to the namespace game.logic.hard-ai
.
In addition to that you can specify additional namespace roots in your configuration.edn
file using the :source-paths
key. It expects a vector of strings that describe paths relative to Assets
to act as additional namespace roots. These paths take precedence over Assets
for the purposes of namespace resolution.
Arcadia supports sub-projects that contain a Leiningen-formatted project.clj
file. Folders under Assets
that contain such a file will have their src
subdirectory added as namespace root, if such a subdirectory exists. Additionally, if the project.clj
file declares any :source-paths
those will be added as namespace roots as well.
Unity will restart the Mono VM at specific times
- Whenever the editor compiles a file (e.g. when a
.cs
file underAssets
is updated or a new.dll
file is added) - Whenever the editor enters play mode
When this happens, your REPL connection will be interrupted and you will have to reconnect. Any state you had set up in the REPL will be lost and you will have to re-require
any namespaces you were working with. This is a deep part of Unity's architecture and not something we can meaningfully affect, unfortunately.
To make your game playable outside of Unity you have to export it. This is a three step process:
- Declare the Clojure namespaces you intend to include in your exported game in the
:export-namespaces
key in yourconfiguration.edn
file. The expected syntax is a vector of symbols where each symbol corresponds to a namespace. Each namespace will be compiled along with every namespace itrequire
s oruse
s. Make sure not to include namespaces that refer to any types underUnityEditor
as they will not be usable in export, e.g.arcadia.debug
. - Click Arcadia → Prepare for Export. This will compile your Clojure code to
.dll
files that Unity can export. Arcadia is in an indeterminate state after you've done this, so proceed immediately to step 3. - Click File → Build Settings... and build your Unity game as normal. The compiled Arcadia files will be automatically cleaned up.