Ult Meter
In this tutorial, we'll demonstrate the use of Preact in OneJS and showcase interoperability between C# and JavaScript. We'll render a circular progress bar and drive the value change from C# to JS.
Table of Contents
Tip: Every bullet point is an actionable step. Everything else are explanations. Also for the most part, we'll use the terms TypeScript (TS) and JavaScript (JS) interchangeably.
Setup
Continuing from where we left off in the Getting Started guide, let's first,
- Make sure we still have a ScriptEngine prefab in the scene.
- Make sure all the watcher tasks (
esbuild
,tailwind
, andtsc
) are still running in VSCode.
Since we'll be using a little bit of Tailwind for this tutorial,
- Add the
tailwind.uss
file to theStyle Sheets
list on the ScriptEngine component.
You can find tailwind.uss
right under your Unity project's Assets folder. This file is being continuously generated by the tailwind
watcher task.
Draw Circle
Let's use Preact and UI Toolkit's Vector API to make a circular progress bar.
- In Unity, enter Playmode.
- In VSCode, open up
index.tsx
and replace with the following,
import { h, render } from "preact"
import { Mathf, Vector2 } from "UnityEngine"
import { parseColor } from "onejs/utils"
import { useEffect, useRef } from "preact/hooks"
import { Angle, ArcDirection, MeshGenerationContext } from "UnityEngine/UIElements"
const RadialProgress = () => {
const ref = useRef<Element>()
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
}, [])
function onGenerateVisualContent(mgc: MeshGenerationContext) {
var painter2D = mgc.painter2D
const { width, height } = mgc.visualElement.contentRect
let radius = Mathf.Min(width, height) / 2
let dx = width / 2 - radius
let dy = height / 2 - radius
painter2D.strokeColor = parseColor("#305fbc")
painter2D.lineWidth = radius * 0.2
painter2D.BeginPath()
painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.8, new Angle(0), new Angle(360), ArcDirection.Clockwise)
painter2D.Stroke()
painter2D.ClosePath()
}
return <div ref={ref} class="w-full h-full"></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).
Here's what the above code renders:
ProgressManager (C#)
So far, we have done everything in JS. In real world scenarios, we should write our core game logic in C#, and have our UI code (in JS) listen for state changes. So, let's have our JS 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 updates a progress value (0 to 1) every few seconds and fires an event whenever the value changes. Now, let's make the ProgressManager component accessible from JavaScript.
- In the Hierarchy panel, create a new GameObject and name it ProgressManager.
- Add the component
ProgressManager
to it. - With the ProgressManager GameObject selected, right-click on the Inspector tab and choose "Properties" to open a popup inspector for this GameObject.
- Select the ScriptEngine GameObject and locate the
Global Objects
list. - Drag the
ProgressManager
component (not the GameObject) from the popup window to theGlobal Objects
list, and name itpman
.
You can now access the ProgressManager
instance in JavaScript using the global variable pman
.
RadialProgress (JS)
Let's use pman
to drive our RadialProgress.
- Go back to index.tsx in VSCode
- Add a
progress
prop to theRadialProgress
component
...
import { useEffect, useRef } from "preact/hooks"
import { Angle, ArcDirection, MeshGenerationContext } from "UnityEngine/UIElements"
const RadialProgress = () => {
const RadialProgress = ({ progress }: { progress: number }) => {
const ref = useRef<Element>()
useEffect(() => {
...
- Modify
onGenerateVisualContent
'spainter2D.Arc()
to include theprogress
function onGenerateVisualContent(mgc: MeshGenerationContext) {
...
painter2D.lineWidth = radius * 0.2
painter2D.BeginPath()
painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.8, new Angle(0), new Angle(360), ArcDirection.Clockwise)
painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.8, new Angle(0), new Angle(progress * 360), ArcDirection.Clockwise)
painter2D.Stroke()
painter2D.ClosePath()
}
- Add another
useEffect()
block after the first one:
...
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
}, [])
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.ve.MarkDirtyRepaint()
}, [progress])
function onGenerateVisualContent(mgc: MeshGenerationContext) {
var painter2D = mgc.painter2D
...
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.
- Modify the previous
render()
line like so:
import { useEffect, useRef, useEventfulState } from "preact/hooks"
...
render(<RadialProgress />, document.body)
declare const pman: any
â €
const App = () => {
const [progress, _] = useEventfulState(pman, "Progress")
â €
return <RadialProgress progress={progress} />
}
â €
render(<App />, document.body)
useEventfulState(obj, "Progress")
assumes the C# obj
has a property named "Progress
" and an event named "OnProgressChanged
". Our ProgressManager
satisfies both conditions.
- Enter Playmode, and here's our result:
Let's add a number display too.
- Modify
RadialProgress
as follows:
const RadialProgress = ({ progress }: { progress: number }) => {
const ref = useRef<Element>()
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.style.fontSize = 0
}, [])
...
function onGenerateVisualContent(mgc: MeshGenerationContext) {
...
let dx = width / 2 - radius
let dy = height / 2 - radius
setTimeout(() => {
ref.current.style.fontSize = Mathf.Min(width, height) * 0.3
})
painter2D.strokeColor = parseColor("#305fbc")
painter2D.lineWidth = radius * 0.2
...
}
return <div ref={ref} class="w-full h-full"></div>
return <div ref={ref} class="w-full h-full justify-center items-center text-white">{Math.round(progress * 100)}</div>
}
Click for Full Code So Far
import { h, render } from "preact"
import { Mathf, Vector2 } from "UnityEngine"
import { parseColor } from "onejs/utils"
import { useEffect, useRef, useEventfulState } from "preact/hooks"
import { Angle, ArcDirection, MeshGenerationContext } from "UnityEngine/UIElements"
const RadialProgress = ({ progress }: { progress: number }) => {
const ref = useRef<Element>()
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.style.fontSize = 0
}, [])
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.ve.MarkDirtyRepaint()
}, [progress])
function onGenerateVisualContent(mgc: MeshGenerationContext) {
var painter2D = mgc.painter2D
const { width, height } = mgc.visualElement.contentRect
let radius = Mathf.Min(width, height) / 2
let dx = width / 2 - radius
let dy = height / 2 - radius
setTimeout(() => {
ref.current.style.fontSize = Mathf.Min(width, height) * 0.3
})
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="w-full h-full justify-center items-center text-white">{Math.round(progress * 100)}</div>
}
declare const pman: any
const App = () => {
const [progress, _] = useEventfulState(pman, "Progress")
return <RadialProgress progress={progress} />
}
render(<App />, document.body)
Tweening
All that's left is to smooth out the transitions with tweening. For that, we can use tween.js.
- In Terminal, run
npm install @tweenjs/tween.js
We need to smoothly animate both the display text and the radial arc. In OneJS/UI Toolkit, every TextNode is its own VisualElement. To make the text easier to modify, we'll use a label
element. We also 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.
- Modify
index.tsx
like so:
...
const RadialProgress = ({ progress }: { progress: number }) => {
const ref = useRef<Element>()
const labelRef = useRef<Element>()
const prev = useRef(progress)
useEffect(() => {
...
}, [])
useEffect(() => {
...
}, [progress])
function onGenerateVisualContent(mgc: MeshGenerationContext) {
...
}
return <div ref={ref} class="w-full h-full justify-center items-center text-white">{Math.round(progress * 100)}</div>
return <div ref={ref} class="w-full h-full justify-center items-center text-white"><label ref={labelRef} /></div>
}
- Update the
onGenerateVisualContent()
function to replaceprogress
withprev.current
:
function onGenerateVisualContent(mgc: MeshGenerationContext) {
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.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(prev.current * 360), ArcDirection.Clockwise)
painter2D.Stroke()
painter2D.ClosePath()
}
Next, we use tween.js 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:
import { Easing, Tween, update } from "@tweenjs/tween.js"
...
const RadialProgress = ({ progress }: { progress: number }) => {
...
useEffect(() => {
...
}, [])
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])
...
}
Finally, 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,
render(<App />, document.body)
â €
function animate(time) {
requestAnimationFrame(animate)
update(time)
}
requestAnimationFrame(animate)
And voila! Here's the final result in Playmode:
Click for Full Code
import { h, render } from "preact"
import { Mathf, Vector2 } from "UnityEngine"
import { parseColor } from "onejs/utils"
import { useEffect, useRef, useEventfulState } from "preact/hooks"
import { Easing, Tween, update } from "@tweenjs/tween.js"
import { Angle, ArcDirection, MeshGenerationContext, TextElement } from "UnityEngine/UIElements"
const RadialProgress = ({ progress }: { progress: number }) => {
const ref = useRef<Element>()
const labelRef = useRef<Element>()
const prev = useRef(progress)
useEffect(() => {
ref.current.ve.generateVisualContent = onGenerateVisualContent
ref.current.style.fontSize = 0
}, [])
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 { width, height } = mgc.visualElement.contentRect
let radius = Mathf.Min(width, height) / 2
let dx = width / 2 - radius
let dy = height / 2 - radius
setTimeout(() => {
ref.current.style.fontSize = Mathf.Min(width, height) * 0.3
})
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="w-full h-full justify-center items-center text-white"><label ref={labelRef} /></div>
}
declare const pman: any
const App = () => {
const [progress, _] = useEventfulState(pman, "Progress")
return <RadialProgress progress={progress} />
}
render(<App />, document.body)
function animate(time) {
requestAnimationFrame(animate)
update(time)
}
requestAnimationFrame(animate)
Next Up
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.
The next tutorial, Rainbow Bars, will cover achieving zero-allocation interop and optimal performance using OneJS and PuerTS.