@Singtaa: Please consider leaving a review on the Asset Store if you enjoy using OneJS. It really helps us a lot! And, I promise it'll take less time than debugging your last script.😊 Oh also, OneJS V2 is now out of preview!
VERSION
Doc Menu

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, and tsc) are still running in VSCode.

Since we'll be using a little bit of Tailwind for this tutorial,

  • Add the tailwind.uss file to the Style 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,
TS
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:
CS
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 the Global Objects list, and name it pman.

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 the RadialProgress component
TSX
...
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's painter2D.Arc() to include the progress
TSX
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:
TSX
...
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:
TSX
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:
TSX
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
TSX
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:
TSX
...
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 replace progress with prev.current:
TSX
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:
TSX
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,
TSX
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
TSX
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.