--- title: "unifir 102 - A developer's guide" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{unifir 102 - A developer's guide} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` This document provides an overview of the inner workings of unifir, focusing on how scripts, props, and actions work together to produce a Unity scene. The tone and focus here are going to be extremely technical and fiddly; for a friendlier introduction to [unifir 101 - A user's guide](unifir-user-guide.html). At a high level, unifir operates around the idea of a _script_ object, a container that stores all the parameters and instructions you want to run through Unity in a single place. Those parameters and instructions are specified in the form of _props_, which let users add certain parameterized pre-written commands to their script to execute in sequence. The actual execution is then handled by the `action()` function, which both prepares the script for execution and then executes it. This basic pattern is modeled after the fantastic [recipes](https://recipes.tidymodels.org/) package. With that framework established, let's walk through what exactly scripts and props are and how they interact with `action()`. But first, we should talk about `waiver()`. ## Waivers As we'll discuss in a moment, unifir does a lot of input checking to make sure that scripts are going to execute successfully in an attempt to give users more useful feedback than Unity's command line interface often offers. That involves making sure that scripts and props have the correct values provided to all of their parameters, and also validating that the user is going to have a working version of Unity to run these commands in at all. Of course, a short list of places that _don't_ have a working version of Unity includes "CRAN check machines" and "GitHub actions". And so as a result, even the simplest function calls will fail on CRAN -- for instance, running the following would throw an error: ```{r eval = FALSE} library(unifir) make_script("example") ``` ```{r echo = FALSE} library(unifir) ``` In order to work around this, I've borrowed an idea (and the entire function) from ggplot2: `waiver()`. The `waiver()` function is a way to indicate to unifir that yes, you _know_ this value normally needs to be provided, and you _know_ that it's missing, and that's _fine_. All `waiver()` does is return an object of class `waiver`: ```{r} waiver() ``` On its own, this object doesn't do anything. However, in a few key places, unifir will understand `waiver()` as meaning "don't validate this argument", which is essential for some odd props and CRAN checks to succeed. I'll be using it throughout this vignette, starting with: ## Scripts The "script" is the core object in unifir. To create a script, we use `make_script`: ```{r} script <- make_script( project = file.path(tempdir(), "unifir"), unity = waiver() # Don't error if we can't find Unity ) script ``` This creates an [R6](https://github.com/r-lib/R6) object of the class `unifir_script`. If you haven't worked with R6 before, I recommend checking out [the section of Advanced R on the subject](https://adv-r.hadley.nz/r6.html). At the end of the day, however, a script is effectively just a glorified list. We can check out its contents using `names()`: ```{r} names(script) ``` Some of these contents -- `.__encols_env__`, `clone`, `initialize` -- will be familiar to anyone used to working with R6. Others, such as the file names for the scene and script unifir is operating on, are set in `make_script()` (and documented in `?make_script`) and default to `NULL` if not set: ```{r} all( is.null(script$initialize_project), is.null(script$scene_name), is.null(script$script_name) ) ``` Others, like `using`, `beats`, and `props`, are a little bit more involved. We'll use these objects in order to track the props we add to our script; to talk about that, it's time we talk about props. ## Props At a low level, a unifir prop is just another R6 object, created using the function `unifir_prop()`: ```{r} prop_file <- tempfile() file.create(prop_file) prop <- unifir_prop( prop_file = prop_file, method_name = "ExampleName", method_type = "ExampleMethod", build = function(script, prop, debug) {}, using = "ExampleDependencies", parameters = list() ) prop ``` Just as before, we can see our prop's contents using `names()`: ```{r} names(prop) ``` These fields are all documented in `?unifir_prop`. Prop objects are the actual method unifir uses to translate R inputs into C# methods. A typical prop will take some input parameters, provided either by the prop constructor function (more on that in a moment) or set at the script level, interpolate them into a pre-written C# method, and then add that method to a pile of C# code that will be run in sequence to produce a scene. Of course, the details of this process depend on what exactly the C# code is expected to _do_. Most of those details are sorted out by prop constructor functions. Rather than forcing users to use `unifir_prop()` directly, unifir provides a number of wrappers around this function to add specific props to a script. For instance, if we look at the code that powers our `new_scene()` function: ```{r} new_scene ``` This prop takes two parameters -- `setup` and `mode` -- and the top of the script makes sure they exist and are passed correctly. We then get to the internal `unifir_prop()` call. This call passes `system.file("NewScene.cs", package = "unifir")` to the `prop_file` argument; if we print that file out we can see that it's a relatively simple C# method: ```{r} readLines(system.file("NewScene.cs", package = "unifir")) ``` This method calls `EditorSceneManager.NewScene`, part of Unity's scripting API, and uses it to create a new scene. Because this code relies on the "UnityEngine.SceneManagement", "UnityEditor", and "UnityEditor.SceneManagement" namespaces, we've included those namespaces in our prop's `using` argument. Our R code is only going to edit three parts of this function, marked by `%` signs -- the `method_name`, `setup`, and `mode` arguments will all be replaced by their equivalent values in R. Moving further along the `unifir_prop` call, we can then see that `method_name` is set to `NULL` by default. `method_name` is a unique identifier for _this method of this prop_, so should not be hard-coded or provided as a default in your prop constructors. If you leave it as `NULL`, unifir will attempt to generate a name made of 4 random English words to fill in the space. The next argument, `method_type`, is internally set to `NewScene` -- users cannot control this value. `method_type` is meant to be a certain "key" associated with your specific type of prop, which other props might search for if they depend on or conflict with your code; as such, it generally shouldn't be configurable by your users. We then see that our function parameters are passed as a list to the `parameters` argument. These will be essential for our next argument, the `build()` function, which constructs our C# method. The `build()` function of every prop must take three (and only three) arguments: `script`, the unifir script a prop is stored in, `prop`, the prop being built, and `debug`, which is discussed below. `build()` methods with more arguments than this will cause errors. As a result, all other parameters you need to construct your C# method _must_ be stored either in the prop or script object, and it is generally easiest to store them in `parameters` (which is not checked by the R6 class). The actual build function here is relatively simple, using `glue` to replace the snippets between `%` symbols with their R equivalents. If we don't change any of the default arguments to this function, that means our output C# method will look something like this: ```{r} script <- new_scene(script) script$props[[1]]$build(script, script$props[[1]]) ``` The last part of our prop constructor is the `add_prop` function, which registers the prop as part of our script. Its code is incredibly simple, and mostly deals with creating the `script$beats` table: ```{r} add_prop ``` That table is relatively simple, storing four variables: `idx`, the order that methods will be executed in, `name`, the `method_name` of each method, `type`, the `method_type` of each method, and `exec`, a boolean representing whether or not that method should be called in the final C# script: ```{r} script$beats ``` ## Action With our props and scripts written, it's showtime! We can use the `action` function to transform our R6 objects into an actual C# script, and then execute that script in Unity. The `action()` does quite a few things. Namely, it: + Checks if the Unity project needs to be created, and creates it if so. + Fills in any missing directory or file names that were left as `NULL`. + Calls the `build()` method of each prop, in order of `script$beats$idx`, and stores the constructed C# methods back in the script. + Creates a "caller" function that will call every method where `script$beats$exec` is `TRUE` in sequential order. + If `write = TRUE`, writes a final C# script to file. + If `exec = TRUE`, executes the final C# script in Unity. Most of this process is internal and doesn't matter for any prop constructors you write. So long as your prop is idempotent and can be constructed using its own `build` argument, `action()` shouldn't create any issues. However, if it does cause trouble, `action()` returns a constructed script object with props replaced by their equivalent C# code. That makes it easy to see how unifir interpreted your build argument; for instance, we can run our example script through `action()` as so: ```{r} script <- make_script( project = file.path(tempdir(), "unifir"), unity = waiver(), # Don't error if we can't find Unity initialize_project = FALSE, # Don't create the project -- so this runs on CRAN script_name = "example_script" ) script <- new_scene(script) exec_script <- action( script, exec = FALSE, write = TRUE ) ``` If we're concerned about how unifir translated our prop code, we can find the rendered C# inside `props`: ```{r} exec_script$props ``` To see the entire produced C# script, we need to read the actual script file itself: ```{r} readLines( file.path(tempdir(), "unifir", "Assets", "Editor", "example_script.cs") ) ``` Notice how this code matches our prop, with two additions: first, all the namespaces we provided to `using` are now imported at the top of the script, and second a function `MainFunc` has been created to call our prop. When executing the C# script, unifir will execute this `MainFunc` method, which will in turn call each prop once in the order it's listed in `script$beats`. ## Debug One last thing to know about unifir is that it is also built with a "debug" mode, in which functions will make no changes to your file system. unifir code checks if it's running in debug mode using the following code: ```{r eval=FALSE} function() { debug <- FALSE if (Sys.getenv("unifir_debugmode") != "" || !is.null(options("unifir_debugmode")$unifir_debugmode)) { debug <- TRUE } debug } ``` So if `unifir_debugmode` is set to any value either as an environment variable or as an option, unifir will avoid writing anything to file or making any changes to a user's computer. When `action()` is called, it will provide the current state of `debug` to your prop's `build` function. For the majority of props, this can be safely ignored; if all your prop does is add C# code to the final script, `action()` will respect debug on your behalf. However, if your prop makes changes to the file system before the script is actually executed -- for instance, by moving prefabs to the project directory or editing configuration files from R -- make sure to wrap those sections of your prop in `if (!debug)`! ## Cloning While I've avoided getting too deep into the underlying mechanics of R6 here, there's one stumbling block that I want to flag for anyone interested in developing with unifir. The vast majority of objects in R have what's referred to as "copy-on-modify" semantics. Say for instance you have some object `x`: ```{r} x <- 2 x ``` If you assign `x` to a new object, `y`, we'd expect `y` and `x` to both have the same value: ```{r} y <- x y == x ``` As an optimization on R's part, not only are these objects both the same value, but they actually both point to the same piece of data on your machine. R only actually creates a new variable, pointing to its own unique data, when you actually modify the object. As a result, when you change the value of `x`, you _don't_ in turn change the value of `y`: R makes a copy when you modify the original object, so they now point to different data: ```{r} x <- 1 y == x ``` The same is **not true** for R6 objects, like unifir scripts and props. If you assign a prop to a new object, both of the objects will point to the same data, and changing one object will change them both. This is true whether you change the new object: ```{r} other_prop <- prop other_prop$method_name <- "NewName" prop$method_name ``` Or the original one: ```{r} prop$method_name <- "AnotherName" other_prop$method_name ``` Instead, with R6 objects, we need to make an explicit copy. We can do this using the `clone()` function inside our prop object, like so: ```{r} disconnected_prop <- prop$clone() ``` This creates an actual disconnected object, with the same values as the original it was cloned from. Now we can make changes to our prop (or script) without impacting any of the other copies: ```{r} disconnected_prop$method_name <- "OnlyIGetThisName" prop$method_name ``` Make sure that, if you're trying to have multiple different versions of a prop or a script, you use `clone()` in your own code!