-
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 is
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, transparently converting events and state to Clojure 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
(let [the-object (new UnityEngine.GameObject "some object")]
(hook+
the-object
:update
:log-name
;; `obj` will be the `the-object`, `role-key` will be `:log-name`
(fn [obj role-key]
(log (.name go)))))
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:
(let [the-object (new UnityEngine.GameObject "some object")]
(hook+
the-object
:on-collision-enter
:log-collision
;; `obj` will be the `the-object`, `role-key` will be `:log-collision`,
;; `collision` will be the Collision instance provided by Unity
(fn [obj role-key collision]
(log "just bumped into" (.. collision gameObject name)))))
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.
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
user=> (hook+ (object-named "Main Camera") :update #'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.
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.