DEV Community

Cover image for Horizon World Tutorial - Maze Runner - Part 3 - HUD timer and background sound
LNATION for LNATION

Posted on

Horizon World Tutorial - Maze Runner - Part 3 - HUD timer and background sound

In our last tutorial, we setup the basic game mechanics for our Maze runner game. In this part, we will enhance the game by adding a custom UI timer to our HUD and some background sound to the world to improve the overall player experience.

Let's start by adding a custom UI timer to the players screen, to implement this we can extend the already existing localPlayerHUD script. Open this script in your editor to refamiliarise yourself with its structure. Notice that we currently import Events, but to properly display the timer during specific game states, we also need to import GameState. Update the import statement accordingly.

import { GameState, Events } from 'GameUtils';
Enter fullscreen mode Exit fullscreen mode

Next we need to add some properties to store the timer state and ongoing duration. Below the existing properties, add the following code:

    private timer = new Binding<string>('0'); // Timer value in seconds
    private timerDisplay = new Binding<string>('flex'); // property to manage visibility of HUD
    private timerID: number = 0;
Enter fullscreen mode Exit fullscreen mode

We have defined three properties, the timer which is a Binding<string> to hold the timer value, the timerDisplay which is a Binding<string> to manage the visibility of the HUD, and the timerID which is a number to keep track of the timer's interval ID. Note that we are using Binding to ensure that any changes to these properties will automatically update the UI.

With these properties in place, we next need to update our initializeUI to render the timer in the HUD. Locate the initializeUI function and replace it with the following:

    initializeUI() {
        return ui.View({
            children: [
                ui.View({
                    style: {
                        position: 'absolute',
                        display: this.timerDisplay,
                        bottom: 20,
                        left: 20,
                        backgroundColor: 'rgba(0, 0, 0, 0.5)',
                        borderRadius: 10,
                        padding: 10,
                    },
                    children: [
                        ui.Text({
                            text: this.timer,
                            style: {
                                fontSize: 24,
                                color: 'white',
                                textAlign: 'center',
                            }
                        })
                    ]
                }),
                ui.View({
                    style: {
                        position: 'absolute',
                        right: 20,
                        bottom: 20,
                        height: '50%',
                        width: 20,
                        backgroundColor: 'gray',
                        borderRadius: 10,
                    },
                    children: [
                        ui.View({
                            style: {
                                position: 'absolute',
                                bottom: 0,
                                width: '100%',
                                height: this.sprintProgress,
                                backgroundColor: 'blue',
                                borderRadius: 10,
                            },
                        }),
                    ]
                }),
            ],
            style: {
                position: 'relative',
                width: '100%',
                height: '100%',
            },
        });
    }
Enter fullscreen mode Exit fullscreen mode

What we have done here is add a new UI.View component that will be displayed at the bottom left of the screen. This view contains a UI.Text component that binds to our timer property, allowing it to dynamically update as the timer changes. We use the timerDisplay property to control the visibility of the timer HUD. The view is styled with a semi-transparent background and rounded corners to ensure it is visually appealing and does not obstruct gameplay.

Now before we can test the timer, we need to do a little extra work to ensure the gameStateChanged event can reach our HUD, the problem is you cannot simply connect to a connectLocalBroadcastEvent as this listener gets destroyed when you change the ownership of the HUD. For this to work we need to send a sendNetworkEvent for each player HUD and listen to them via connectNetworkEvent. So next you need to open PlayerController and inside the handleGameStateChanged function you need to extend with the following code at the end.

    this.players.forEach((p: hz.Player) => {
        this.sendNetworkEvent(
            p,
            Events.gameStateChanged,
            { fromState, toState }
        );
    });
Enter fullscreen mode Exit fullscreen mode

This code will ensure that all players receive the gameStateChanged event to their local HUD script when the game state changes. Next we need to setup a listener for our gameStateChanged Event, within this event we will show the timer HUD when we enter Starting state. The timer will being counting when we enter Playing state and will stop when we enter Ending state. We will connect to the event inside of our start function. After the existing setHUDSprint event listener add the following code:

    this.connectNetworkEvent(
        this.entity.owner.get(),
        Events.gameStateChanged,
        (data: {
            fromState: GameState,
            toState: GameState,
        }) => this.handleGameStateChanged(data.fromState, data.toState),
    );
Enter fullscreen mode Exit fullscreen mode

Then to implement the handleGameStateChanged function after the start function add:

    handleGameStateChanged(fromState: GameState, toState: GameState) {
        if (toState === GameState.Starting) {
            this.showTimer();
        } else if (toState === GameState.Playing) {
            this.startTimer();
        } else if (toState === GameState.Ending) {
            this.stopTimer();
        }
    }
Enter fullscreen mode Exit fullscreen mode

This function checks the new game state and calls the appropriate method to manage the timer's visibility and functionality. When the game state changes to Starting, it calls showTimer to make the timer visible. When it changes to Playing, it starts the timer with startTimer. Finally, when the game state changes to Ending, it stops the timer with stopTimer.

We need to next implement the showTimer, startTimer, and stopTimer functions in our HUD script. We will start with showTimer which will make the timer HUD visible. It will need to set the timerDisplay property to flex and reset the timer property to 0.

    private showTimer() {
        this.timerDisplay.set('flex');
        this.timer.set('0');
    }
Enter fullscreen mode Exit fullscreen mode

Next we will add the startTimer function which will begin the timer's countdown. We will use async setInterval to ensure we do not block the main thread, we will use the timerID property to keep track of the interval.

    private startTimer() {
        let t = 0;
        this.timerID = this.async.setInterval(() => {
            t++;
            this.timer.set(t.toString());
        }, 1000);
    }
Enter fullscreen mode Exit fullscreen mode

Finally, we will implement the stopTimer function which will stop the timer's countdown. This function will clear the interval using the timerID property and set the timerDisplay property to none.

    private stopTimer() {
        this.async.clearInterval(this.timerID);
        this.timerDisplay.set('none');
    }
Enter fullscreen mode Exit fullscreen mode

With this in place when you now enter preview mode, you should see the timer HUD appear when the game state changes to Starting (when you press the button), start counting up when it changes to Playing (when you teleport into the game area), and stop counting and hide when it changes to Ending (when you reach the finish line).

Timer

Now that we have the timer working, let's add some sound to our game. Today we will be adding background music to enhance the gaming experience. We will use two sound tracks: one for the lobby and one for the game area.

First we will need to prepare our GameController, open this file in you code editor and then inside of the propsDefinition add definitions for our two sound assets:

    static propsDefinition = {
        lobbySound: {type: hz.PropTypes.Entity},
        playingSound: {type: hz.PropTypes.Entity},
    };
Enter fullscreen mode Exit fullscreen mode

Now that we have the properties defined, return to the desktop editor, navigate to the Sounds tab under Build, and change the dropdown to Music. Then you can choose two tracks to add into your world, for my example I will use Arcade Theme for the lobby and Flexing for the game area. Adjust the volume to your liking and disable the Play on Start option. Once you are happy, click on the GameController object and link your sound assets to the lobbySound and playingSound properties.

Sounds

Link Entity

With the sound assets linked, return to your code editor and open GameController.ts. Next we will add some more private properties to store the casted AudioGizmo instances:

    private lobbySound: hz.AudioGizmo | undefined;
    private playingSound: hz.AudioGizmo | undefined;
Enter fullscreen mode Exit fullscreen mode

Then we need to instantiate these properties, we will do this early inside of the start function, we can also begin playing the lobbySound at this point. As the beginning of the start function add the following:

        this.lobbySound = this.props.lobbySound?.as(hz.AudioGizmo);
        this.lobbySound?.play();
        this.playingSound = this.props.playingSound?.as(hz.AudioGizmo);
Enter fullscreen mode Exit fullscreen mode

This code casts the linked sound entities to AudioGizmo instances and starts playing the lobby sound. Next, we will need to implement the logic to switch between the lobby and playing sounds based on the game state. Inside setGameState case GameState.Starting before the call to handleNewMatchStarting add:

                this.lobbySound?.stop();
                this.playingSound?.play();
Enter fullscreen mode Exit fullscreen mode

Then inside the same setGameState function, add the case for GameState.Finished:

                this.playingSound?.stop();
                this.lobbySound?.play();
Enter fullscreen mode Exit fullscreen mode

Now return to your desktop editor and run the preview mode, on start you should hear the lobby music, when you start playing the game the music should switch to the playing music, and when you finish the game it should switch back to the lobby music.

This concludes this part of the tutorial. In the next installment, we’ll tackle the actual generation of the maze.

Top comments (0)