Rendering UI

Adding Custom UI Elements with React

oncyber uses the React (opens in a new tab) library as the primary approach to creating a custom in-world UI for your experience.

Here's how you can render a simple React component in a Behavior:

import { ScriptBehavior, UI } from '@oo/scripting'
 
const style = {
    position: "fixed",
    backgroundColor: "white",
    top: "20px",
    left: "50%",
    transform: "translateX(-50%)",
}
 
function UiElement() {
    return <div style={style}>
        Hello, World
    </div>
}
 
export default class ExampleBehavior extends ScriptBehavior {
 
    private renderer = UI.createRenderer();
 
    onReady = () => {
        this.renderer.render(<UiElement/>);
    }
 
    onDispose = () => {
        this.renderer.unmount();
    }
}

This method can be used in other types of scripts as well -- not just Behaviors.

Although a renderer can only display a single React component, you can create additional UI elements with React by creating multiple renderers.

Creating a Reactive UI

In the prior example, our UI element is static. Although this is fine for UI elements intended as a permanent fixture (ie. a graphic HUD frame/container for other UI elements), anything variable such as a score or player health will need to be made dynamic.

Creating a dynamic (or "Reactive") UI in oncyber can be approached in several ways -- next, we'll take a look at a few examples.

Manual Rerendering

One approach is to manually specify a re-render for the UI each time we change a variable:

import { ScriptBehavior, Param, UI } from '@oo/scripting'
 
export default class ExampleBehavior extends ScriptBehavior {
 
    private renderer = UI.createRenderer();
    private count = 0;
 
    onReady = () => {
        this.render();
    }
 
    render = () => {
        this.renderer.render(<this.ui/>);
    };
 
    action = () => {
        this.count++;
        this.render();
    }
 
    ui = () => {
        return <div
            style={{
                position: "fixed",
                backgroundColor: "white",
                top: "20px",
                left: "50%",
                transform: "translateX(-50%)",
            }}
        >
            <div> Count: {this.count} </div>
            <button
                onClick={this.action}
            >
                Button
            </button>
        </div>
    }
 
    onDispose = () => {
        this.renderer.unmount();
    }
}

Automatic Re-Rendering

If you prefer to lean more into React's style, you can use a Store.

This allows us to automate UI re-rendering by creating a Store that keeps track of some values we want displayed in our UI.

In the example that follows, we'll create a Store that keeps track of our count, add a way to increment it, and have our component update when that value changes:

import { ScriptBehavior, UI, Store, useStore } from '@oo/scripting'
 
const store = new Store({
    count: 0,
});
 
function Button() {
    const { count } = useStore(store);
 
    function increaseCount() {
        store.update({ count: count+1 });
    }
 
    return <div
        style={{
            position: "fixed",
            backgroundColor: "white",
            top: "20px",
            left: "50%",
            transform: "translateX(-50%)",
        }}
    >
        <div> Count: {count} </div>
        <button
            onClick={increaseCount}
        >
            Button
        </button>
    </div>
}
 
export default class ExampleBehavior extends ScriptBehavior {
 
    private renderer = UI.createRenderer();
 
    get count() {
        return store.state.count;
    }
 
    onReady = () => {
        this.renderer.render(<Button/>)
    }
 
    onEnd = () => {
        console.log("current count:", this.count)
    }
 
    onDispose = () => {
        this.renderer.unmount()
    }
}

For further modularity, you can also isolate and export the Store for use in your other scripts.

Let's say you have two scripts, A and B.

In A, you create your Store:

A
import { Store } from '@oo/scripting'
 
export const store = new Store({
    score: 0,
    level: 0,
});
 

Next, in Script B, you import the Store from Script A:

B
import { store } from "./A"
 
// read the score from the store
const score = store.state.score;
 
// change the state of the store
store.update({ score: score+1 });
 
// subscribe to updates
const unsubscribe = store.subscribe(() => {
    console.log("store has been updated");
})
 
// ...
unsubscribe();

This allows you to take a more modular approach with your Store, making it independently accessible instead of tying it to another script's code.

Pointer Events

It's important to disable pointer events for on-screen UI elements that are not interactive. UI elements with pointer events will prevent users from locking the mouse when clicking on a them.

Disabling pointer events for UI elements you've created is simple -- when defining style, just add a line that sets pointerEvents to none:

function Score() {
    const { score } = useStore(store);
    
    return <div
        style={{
            pointerEvents: "none",
            position: "fixed",
            backgroundColor: "white",
            fontSize: "20px"
            top: "20px",
            left: "50%",
            transform: "translateX(-50%)",
        }}
    >
        Score: {score}
    </div>
}