Tutorial 101
This is a step-by-step tutorial introducing many aspects of OneJS. Familiarity with Typescript, Preact, and Unity is recommended.
Tip: Every bullet point is an actionable step. Everything else are explanations.
If you haven't already, please:
- Install VSCode (https://code.visualstudio.com/docs/setup/setup-overview)
- Make sure the Typescript compiler (tsc) is also installed
Table of Contents
- Setup
- A Ball on a Plane
- Make it Bounce
- Add Some UI
- Time for Some Vector Action
- Here Comes the Interop
- Smooth it out
Setup
Unity
- Start with an empty project and an empty scene (you can leave
Main Camera
andDirectional Light
alone). - Import OneJS from Package Manager (or directly clone the private repo if you have access).
- Drag the ScriptEngine prefab onto the Hierarchy panel.
- Go to Project Settings -> Player, and make sure "Run In Background" is turned on under Resolution and Presentation
- [Optional] In Project Settings -> Player -> Other Settings, Change
Color Space
toLinear
if it's not already. - [Optional] Turn on VSync in the Game panel to save some CPU cycles.
- [Optional] Change the Main Camera's
Clear Flags
toSolid Color
and change its background color to a more desaturated one i.e. #E0CFB9 (so that it won't get in the way of our UI later). - Finally, press Play to Enter Playmode (ScriptEngine will setup a bunch of stuff for the first time)
VSCode
- Start VSCode and open folder
{ProjectDir}/OneJS
. (NOTE:{ProjectDir}
is not yourAssets
folder; it is one level above theAssets
folder) - Start tsc watchmode by pressing
Ctrl + Shift + B
orCmd + Shift + B
and choosetsc: watch - tsconfig.json
- Create a new file
index.tsx
(This is the only .ts file we'll be working with for the rest of this tutorial)
A Ball on a Plane
Let's first spawn a single Sphere using Typescript.
- Make sure Unity is still in Playmode
- In
index.tsx
, enter the following and save the file:
import { GameObject, PrimitiveType } from "UnityEngine"
GameObject.CreatePrimitive(PrimitiveType.Sphere)
This creates a white Sphere in the scene.
After you save the file, OneJS will reload the script automatically (and take care of cleaning up any old GameObject created by the previous run).
And as you can see, the code is very similar to what you'd normally write in C#. OneJS provides a lot of TS type definitions for Unity. Alternatively, you can also just use plain JS without any Typing info:
importNamespace("UnityEngine").GameObject.CreatePrimitive(0)
Now let's tweak the Camera a little and add a plane.
- Change your code to the following:
import { Camera, CameraClearFlags, GameObject, MeshRenderer, PrimitiveType, Vector3 } from "UnityEngine"
import { parseColor } from "onejs/utils/color-parser"
Camera.main.transform.position = new Vector3(8, 4, -8)
Camera.main.transform.LookAt(new Vector3(0, 1, 0))
const plane = GameObject.CreatePrimitive(PrimitiveType.Plane)
plane.GetComponent(MeshRenderer).material.color = parseColor("DarkGreen")
const sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere)
sphere.GetComponent(MeshRenderer).material.color = parseColor("FireBrick")
sphere.transform.position = new Vector3(0, 5, 0)
Make it Bounce
Let's tweak some physics stuff, add a Rigidbody to the sphere, and add a bouncy PhysicMaterial to both sphere and plane.
- Append the following code:
Physics.gravity = new Vector3(0, -20, 0) // -9.8 is too "floaty", -20 makes things slightly more realistic
let rb = sphere.AddComponent(Rigidbody)
let pm = new PhysicMaterial()
pm.bounciness = 0.8
sphere.GetComponent(Collider).material = pm
plane.GetComponent(Collider).material = pm
Tip: Use the Quick Fix code action to easily import typings
Add Some UI
Let's add a button that resets the ball's position and changes its color when clicked.
- Append the following code:
const App = () => {
function onClick() {
sphere.GetComponent(MeshRenderer).material.color = Random.ColorHSV(0, 1, 1, 1, 0.5, 1)
sphere.transform.position = new Vector3(0, 5, 0)
rb.velocity = new Vector3(0, 0, 0)
}
return <button onClick={onClick} text="Reset Ball" />
}
render(<App />, document.body)
Again, use the "Add all missing imports" code action to quickly take care of the imports.
As you can see, you can just seamlessly include a Preact component and render it.
Let's style the button using the Emotion api:
...
return <button class={emo`
position: absolute;
bottom: 0;
left: 0;
margin: 10px;
border-width: 0;
border-radius: 5px;
color: white;
background-color: rgb(42, 192, 197);
transition: translate 0.2s, background-color 0.2s;
&:hover {
background-color: rgb(70, 162, 219);
translate: 0 -5px;
}
`} onClick={onClick} text="Reset Ball" />
...
Click for Full Code
import { Camera, CameraClearFlags, Collider, GameObject, MeshRenderer, PhysicMaterial, Physics, PrimitiveType, Random, Rigidbody, Vector3 } from "UnityEngine"
import { parseColor } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { emo } from "onejs/styled"
Camera.main.transform.position = new Vector3(8, 4, -8)
Camera.main.transform.LookAt(new Vector3(0, 1, 0))
const plane = GameObject.CreatePrimitive(PrimitiveType.Plane)
plane.GetComponent(MeshRenderer).material.color = parseColor("DarkGreen")
const sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere)
sphere.GetComponent(MeshRenderer).material.color = parseColor("FireBrick")
sphere.transform.position = new Vector3(0, 5, 0)
Physics.gravity = new Vector3(0, -20, 0)
let rb = sphere.AddComponent(Rigidbody)
let pm = new PhysicMaterial()
pm.bounciness = 0.8
sphere.GetComponent(Collider).material = pm
plane.GetComponent(Collider).material = pm
const App = () => {
function onClick() {
sphere.GetComponent(MeshRenderer).material.color = Random.ColorHSV(0, 1, 1, 1, 0.5, 1)
sphere.transform.position = new Vector3(0, 5, 0)
rb.velocity = new Vector3(0, 0, 0)
}
return <button class={emo`
position: absolute;
bottom: 0;
left: 0;
margin: 10px;
border-width: 0;
border-radius: 5px;
color: white;
background-color: rgb(42, 192, 197);
transition: translate 0.2s, background-color 0.2s;
&:hover {
background-color: rgb(70, 162, 219);
translate: 0 -5px;
}
`} onClick={onClick} text="Reset Ball" />
}
render(<App />, document.body)
Time for Some Vector Action
Let's do a circular progress bar from scratch. UI Toolkit's Vector API makes this extremely easy.
- Delete everything in index.tsx
- Copy & paste the following
import { emo } from "onejs/styled"
import { parseColor } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { useRef, useEffect } from "preact/hooks"
import { Mathf, Vector2 } from "UnityEngine"
import { MeshGenerationContext, Angle, ArcDirection } from "UnityEngine/UIElements"
const RadialProgress = () => {
const ref = useRef<Dom>();
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
}, [])
function onGenerateVisualContent(mgc: MeshGenerationContext) {
var painter2D = mgc.painter2D
const resolvedStyle = ref.current.ve.resolvedStyle
let radius = Mathf.Min(resolvedStyle.width, resolvedStyle.height) / 2
let dx = resolvedStyle.width / 2 - radius
let dy = resolvedStyle.height / 2 - radius
painter2D.strokeColor = parseColor("#305fbc")
painter2D.lineWidth = radius * 0.2
painter2D.BeginPath()
painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(360), ArcDirection.Clockwise)
painter2D.Stroke()
painter2D.ClosePath()
}
return <div ref={ref} class={emo`width:100%;height:100%;`}></div>
}
render(<RadialProgress />, document.body)
A bit to unpack here. useRef()
gets a reference to the underlying DOM, while useEffect()
here is used to run some startup code. These are called Hooks in Preact; you can get a primer on those over at preactjs.com.
ref.current.ve
deserves some special attention here. The Dom object in OneJS is just a thin wrapper over the underlying VisualElement (which is the UI Toolkit's equivalent of HTMLElement). Dom.ve
returns the VisualElement. In other words, ref.current
returns the Dom, and ref.current.ve
returns the VisualElement.
We set the VisualElement's generateVisualContent
property to the callback onGenerateVisualContent()
where we use painter2D to draw an arc (a full circle in this case). onGenerateVisualContent()
will be called everytime the VisualElement needs to repaint (during size changes, for example).
The Drawing logic is similar to the Canvas API (see CanvasRenderingContext2D) but limited in scope. Here we are basically drawing an 360 degrees arc in the dead center of the current VisualElement. We use ref.current.ve.resolvedStyle
to get the actual rendered styling/dimension of the VisualElement.
Here Comes the Interop
So far, we have done everything in Typescript. In real world scenarios, we should have our core game logic in C#, and have our UI code listen for state changes. In other words, we should have our TS code subscribe to C# events.
- Exit Unity Playmode
- Create a new C# script in Unity, name it
ProgressManager
- Open the new script and replace the content with the following:
using System;
using System.Collections;
using UnityEngine;
using Random = UnityEngine.Random;
public class ProgressManager : MonoBehaviour {
public float Progress => _progress;
public event Action<float> OnProgressChanged;
float _progress = 1f;
void Start() {
StartCoroutine(ChangeProgressCo());
}
IEnumerator ChangeProgressCo() {
var waitTime = Random.Range(1f, 5f); // Wait for a random time
yield return new WaitForSeconds(waitTime);
ChangeProgress();
StartCoroutine(ChangeProgressCo()); // Repeat
}
void ChangeProgress() {
var p = Random.Range(0, 1f);
while (Mathf.Abs(p - _progress) < 0.2f) { // Roll a number that's fairly different from the current value
p = Random.Range(0, 1f);
}
_progress = p;
OnProgressChanged?.Invoke(_progress);
}
}
This basically updates a progress value (0 to 1f) every few seconds. An event is fired everytime the progress value changes.
Now let's set it up so that a ProgressManager object is accessible from Typescript.
- In the Hierarchy panel, create a new GameObject, call it
ProgressManager
- Add the component
ProgressManager
to it. - While having the
ProgressManager
GameObject selected, right-click on the Inspector tab, and pick "Properties". (This opens up a standalone popup window for theProgressManager
GameObject) - Now, select the ScriptEngine GameObject and locate the
Objects
list under INTEROP. - Drag the
ProgressManager
component from the popup window onto theObjects
list. - Name the mapping to "pman"
Now your ProgressManager
MonoBehaviour object will be accessible from Typescript via require("pman")
. This is just one of the many ways you can do interop between C# and Typescript. Refer to the Jint ReadMe for more info.
Let's use pman
to drive our RadialProgress.
- Go back to index.tsx in VSCode
- Add a
progress
prop to theRadialProgress
component
const RadialProgress = ({ progress }: { progress: number }) => {
...
- Modify
onGenerateVisualContent
'spainter2D.Arc()
to include theprogress
function onGenerateVisualContent(mgc: MeshGenerationContext) {
...
painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(progress * 360), ArcDirection.Clockwise)
...
}
- Add another
useEffect()
block after the first one:
...
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.ve.MarkDirtyRepaint()
}, [progress])
...
We use this new useEffect()
block to track changes to progress
and trigger repaint everytime it changes. Next, we add an App
comp to hold the RadialProgress
comp and use the useEventfulState()
hook (this is provided by OneJS, not Preact) to grab the C# state conveniently.
- Replace the previous
render()
line with the following:
const App = () => {
const pman = require("pman")
const [progress, _] = useEventfulState(pman, "Progress")
return <RadialProgress progress={progress} />
}
render(<App />, document.body)
NOTE:
useEventfulState(obj, "Progress")
assumes the C#obj
has a property named "Progress
" and an event named "OnProgressChanged
". OurProgressManager
satisfies both conditions.
Here's our result:
Let's add a number display too.
- Modify
RadialProgress
as follows:
const RadialProgress = ({ progress }: { progress: number }) => {
const ref = useRef<Dom>()
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
const resolvedStyle = ref.current.ve.resolvedStyle
const minSize = Mathf.Min(resolvedStyle.width, resolvedStyle.height)
ref.current.style.fontSize = minSize * 0.3
}, [])
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.ve.MarkDirtyRepaint()
}, [progress])
function onGenerateVisualContent(mgc: MeshGenerationContext) {
...
}
return <div ref={ref} class={emo`width:100%;height:100%;display:flex;justify-content:center;align-items:center;color:#305fbc;`}>{Math.round(progress * 100)}</div>
}
Click for Full Code
import { emo } from "onejs/styled"
import { useEventfulState } from "onejs"
import { parseColor } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { useRef, useEffect } from "preact/hooks"
import { Mathf, Vector2 } from "UnityEngine"
import { MeshGenerationContext, Angle, ArcDirection } from "UnityEngine/UIElements"
const RadialProgress = ({ progress }: { progress: number }) => {
const ref = useRef<Dom>()
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
const resolvedStyle = ref.current.ve.resolvedStyle
const minSize = Mathf.Min(resolvedStyle.width, resolvedStyle.height)
ref.current.style.fontSize = minSize * 0.3
}, [])
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.ve.MarkDirtyRepaint()
}, [progress])
function onGenerateVisualContent(mgc: MeshGenerationContext) {
var painter2D = mgc.painter2D
const resolvedStyle = ref.current.ve.resolvedStyle
let radius = Mathf.Min(resolvedStyle.width, resolvedStyle.height) / 2
let dx = resolvedStyle.width / 2 - radius
let dy = resolvedStyle.height / 2 - radius
painter2D.strokeColor = parseColor("#305fbc")
painter2D.lineWidth = radius * 0.2
painter2D.BeginPath()
painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(progress * 360), ArcDirection.Clockwise)
painter2D.Stroke()
painter2D.ClosePath()
}
return <div ref={ref} class={emo`width:100%;height:100%;display:flex;justify-content:center;align-items:center;color:#305fbc;`}>{Math.round(progress * 100)}</div>
}
const App = () => {
const pman = require("pman")
const [progress, _] = useEventfulState(pman, "Progress")
return <RadialProgress progress={progress} />
}
render(<App />, document.body)
Smooth it out
For this last section, let's make the RadialProgress
have smooth transitions when value changes. For that, we can use the tween.js library that comes with OneJS.
So we need to smoothly animate both the display text and the arc. In OneJS, every TextNode is its own VisualElement. To make the text easier to modify, we'll use a label
element.
- Add a new ref to
RadialProgress
:
const labelRef = useRef<Dom>()
- Modify the return line of
RadialProgress
like this:
return <div ref={ref} class={emo`width:100%;height:100%;display:flex;justify-content:center;align-items:center;color:#305fbc;`}><label ref={labelRef} /></div>
We need an intermediate value that'll converge to the progress
value. And since we are working with Stateless Function Components, we need a way for the intermediate value to persist between re-renders. We can use useRef()
for this.
- Add another
useRef()
after thelabelRef
we just added:
const prev = useRef(progress) // using this to persist data between renders
- Update the
onGenerateVisualContent
callback function to replaceprogress
withprev.current
:
...
painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(prev.current * 360), ArcDirection.Clockwise)
...
Finally, we can use the tween.js api to smoothly tween the prev
ref to the new progress value, updating the display text via labelRef
along the way.
- Change the 2nd
useEffect()
block like this:
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.ve.MarkDirtyRepaint()
new Tween(prev).to({ current: progress }, 300)
.easing(Easing.Quadratic.InOut).onUpdate(() => {
ref.current.ve.MarkDirtyRepaint();
(labelRef.current.ve as TextElement).text = Math.round(prev.current * 100) + ""
}).start()
}, [progress])
tween.js requires the use of requestAnimationFrame()
(it's a standard web api which OneJS also provides).
- Append the following to the end of the file:
function animate(time) {
requestAnimationFrame(animate)
update(time)
}
requestAnimationFrame(animate)
And voila! Here's the final result:
Click for Full Code
import { Dom } from "OneJS/Dom"
import { emo } from "onejs/styled"
import { h, render } from "preact"
import { useEventfulState } from "onejs"
import { Mathf, Vector2 } from "UnityEngine"
import { Easing, Tween, update } from "tweenjs"
import { useRef, useEffect } from "preact/hooks"
import { parseColor } from "onejs/utils/color-parser"
import { MeshGenerationContext, Angle, ArcDirection, TextElement } from "UnityEngine/UIElements"
const RadialProgress = ({ progress }: { progress: number }) => {
const ref = useRef<Dom>()
const labelRef = useRef<Dom>()
const prev = useRef(progress) // using this to persist data between renders
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
const resolvedStyle = ref.current.ve.resolvedStyle
const minSize = Mathf.Min(resolvedStyle.width, resolvedStyle.height)
ref.current.style.fontSize = minSize * 0.3
}, [])
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.ve.MarkDirtyRepaint()
new Tween(prev).to({ current: progress }, 300)
.easing(Easing.Quadratic.InOut).onUpdate(() => {
ref.current.ve.MarkDirtyRepaint();
(labelRef.current.ve as TextElement).text = Math.round(prev.current * 100) + ""
}).start()
}, [progress])
function onGenerateVisualContent(mgc: MeshGenerationContext) {
var painter2D = mgc.painter2D
const resolvedStyle = ref.current.ve.resolvedStyle
let radius = Mathf.Min(resolvedStyle.width, resolvedStyle.height) / 2
let dx = resolvedStyle.width / 2 - radius
let dy = resolvedStyle.height / 2 - radius
painter2D.strokeColor = parseColor("#305fbc")
painter2D.lineWidth = radius * 0.2
painter2D.BeginPath()
painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(prev.current * 360), ArcDirection.Clockwise)
painter2D.Stroke()
painter2D.ClosePath()
}
return <div ref={ref} class={emo`width:100%;height:100%;display:flex;justify-content:center;align-items:center;color:#305fbc;`}><label ref={labelRef} /></div>
}
const App = () => {
const pman = require("pman")
const [progress, _] = useEventfulState(pman, "Progress")
return <RadialProgress progress={progress} />
}
render(<App />, document.body)
function animate(time) {
requestAnimationFrame(animate)
update(time)
}
requestAnimationFrame(animate)
We've reached the end of this tutorial. Kudos to you for following all the way to the end! Everything we showed you so far are really just the tip of the iceberg. But hopefully this tutorial served its purpose of getting you up to speed with the breadth of tech stacks in OneJS.
Unity, C#, Typescript, and (P)React are incredibly deep and flexible tools. If you ever get stuck on something or come across a bug, make sure to stop by our Discord to let us know!