Timer, goal, and pause

The level's rules as an asset

Each level has a time limit, a number of coins that wins it and a level that follows it. Those belong to the level rather than to any object in it, so they go in an asset of their own: one .level file per level.

  1. Create a component named LevelDefinition, as before, and replace its two files. It is not a component once you have: it derives from DataAsset, the base class for assets made of fields.
Source/LevelDefinition.h
#pragma once

#include "Shalltear.h"

// rules of one level, stored in an asset (not in the scene). Each level
// gets a .level file (made from the Asset Browser's Create menu)
class LevelDefinition : public DataAsset {
public:
	SLT_REFLECT_CLASS(LevelDefinition)
	SLT_REFLECT_PARENT(DataAsset)
	SLT_CREATE_ASSET_MENU("Level Definition", ".level")
	SLT_REFLECT_FIELD(timeLimit)
	SLT_REFLECT_FIELD(coinsToWin)
	SLT_REFLECT_FIELD(nextLevel)
	SLT_END_REFLECT()

	// seconds the player has to finish
	f32 timeLimit = 30.0f;
	i32 coinsToWin = 5;
	// the scene following a win. a win should return to menu if empty
	Handle<SceneAsset> nextLevel;
};
Source/LevelDefinition.cpp
#include "LevelDefinition.h"

// registering the type is all a data asset needs. Create menu entry/Inspector/
// saving come from reflected fields
SLT_REGISTER_TYPE(LevelDefinition)
  1. Save both files. SLT_CREATE_ASSET_MENU adds Level Definition to the Asset Browser's Create menu.

  2. In the Asset Browser, select + and choose Create ▸ Level Definition. Rename it Level1.level.

    The Asset Browser's Create menu, with Level Definition among the asset types
  3. In the Inspector, timeLimit is set to 30 and coinsToWin is set to 5. Leave nextLevel empty for now.

    Level1.level in the Inspector: timeLimit 30, coinsToWin 5 and an empty nextLevel

The HUD

The HUD and the pause menu are one UI document, Hud.slxml. A UI document is a file you can write in a code editor, or build in the editor's UI Builder: in the Asset Browser, select + and choose Create ▸ UI Document, then double-click the new file. This page gives you the text, so the ids the code looks for match exactly.

  1. In your code editor, create the file Assets/UI/Hud.slxml in the project folder with this text, and save it. The editor finds the new file and adds it to the Asset Browser. To change it later, double-click it to open the UI Builder.
Assets/UI/Hud.slxml
<?xml version="1.0" encoding="UTF-8"?>
<ui version="1">
	<panel id="screen" style="position: absolute; left: 0; top: 0; width: 100%; height: 100%">
		<!-- LevelController writes the coins + the time left into these two, by their ids -->
		<panel id="hud" style="position: absolute; left: 24px; top: 24px; flex-direction: column">
			<panel id="coins">Coins: 0 / 0</panel>
			<panel id="timer">Time: 0</panel>
		</panel>
		<!-- covers the screen, starts hidden with display: none. LevelController shows it on pause + wires its two buttons -->
		<panel id="pause-menu" style="display: none; position: absolute; left: 0; top: 0; width: 100%; height: 100%; flex-direction: column; align-items: center; justify-content: center; background-color: #00000099">
			<panel id="paused" style="margin-bottom: 24px">Paused</panel>
			<button id="resume" style="width: 240px; margin-bottom: 12px" text="Resume"/>
			<button id="menu" style="width: 240px" text="Main Menu"/>
		</panel>
	</panel>
</ui>
Hud.slxml in the Asset Browser's UI folder
  1. In the Asset Browser, select + and choose Create ▸ Panel Settings. Rename it Game.slpanel, set fontSizePx to 28 and select Save. Panel settings apply to a whole panel; this one makes its text large enough to read.

    Game.slpanel in the Inspector, with fontSizePx set to 28 and the Save button

The LevelController component

  1. Create a component named LevelController and replace its two files:
Source/LevelController.h
#pragma once

#include "Shalltear.h"

#include "LevelDefinition.h"

// FD
namespace Shalltear {
class UIPanelRenderer;
}

// runs one level: counts down its time, shows the coins + the clock, pauses
// the game, moves on when the level is won (or the time runs out)
//
// Sits on the same object as the UIPanelRenderer that draws the HUD (below)
// A component is enabled after the ones above -> the HUD is built by
// the time OnEnable looks for its buttons
class LevelController : public Behavior {
public:
	LevelController();

	SLT_REFLECT_COMPONENT(LevelController, Behavior)
	SLT_REFLECT_FIELD(level)
	SLT_REFLECT_FIELD(player)
	SLT_REFLECT_FIELD(menuScene)
	SLT_END_REFLECT()

	// this level's rules
	Handle<LevelDefinition> level;
	// the object with the PlayerController
	Handle<SceneObject> player;
	// where a lost level (or the pause menu's Main Menu button) goes
	Handle<SceneAsset> menuScene;

protected:
	void OnEnable() override;
	void OnDisable() override;
	void OnUpdate() override;

private:
	UIPanelRenderer *GetHud() const;
	void SetPaused(bool paused);
	void GoTo(Handle<SceneAsset> scene);

	InputActionMap _controls;
	ActionId _pause;
	f32 _timeLeft = 0.0f;
	bool _paused = false;
	bool _finished = false;
};
Source/LevelController.cpp
#include "LevelController.h"

#include "PlayerController.h"

SLT_REGISTER_TYPE(LevelController)

LevelController::LevelController() {
	_pause = _controls.RegisterAction("Pause"_hash, ActionType::Button);
	_controls.Bind(_pause, {.code = u16(Key::Escape)});
	_controls.Bind(_pause, {.source = InputBinding::Source::GamepadButton, .code = u16(GamepadButton::Start)});
}

void LevelController::OnEnable() {
	LevelDefinition *rules = level.Get();
	_timeLeft = rules ? rules->timeLimit : 0.0f;
	_controls.Enable();

	// the pause menu's two buttons (by the id they have in Hud.slxml). The
	// handler holds a Handle, not `this` -> a click arriving after
	// this component is dead does nothing
	if (UIPanelRenderer *hud = GetHud()) {
		UI::Context *ui = hud->GetContext();
		Handle<LevelController> self(this);
		ui->Events(hud->FindElement("resume")).onClick = [self] {
			if (LevelController *controller = self.Get()) {
				controller->SetPaused(false);
			}
		};
		ui->Events(hud->FindElement("menu")).onClick = [self] {
			if (LevelController *controller = self.Get()) {
				controller->GoTo(controller->menuScene);
			}
		};
	}
	SetPaused(false);
}

void LevelController::OnDisable() {
	_controls.Disable();
	Time::SetTimeScale(1.0f);
	if (UIPanelRenderer *hud = GetHud()) {
		UI::Context *ui = hud->GetContext();
		ui->Events(hud->FindElement("resume")).onClick = nullptr;
		ui->Events(hud->FindElement("menu")).onClick = nullptr;
	}
}

// the HUD on this object (or null when it is missing/not built)
UIPanelRenderer *LevelController::GetHud() const {
	UIPanelRenderer *hud = GetOwner()->GetComponent<UIPanelRenderer>().Get();
	return hud && hud->GetContext() ? hud : nullptr;
}

void LevelController::OnUpdate() {
	_controls.Evaluate();
	if (_controls.WasPerformed(_pause) && !_finished) {
		SetPaused(!_paused);
	}

	LevelDefinition *rules = level.Get();
	SceneObject *playerObject = player.Get();
	PlayerController *hero = playerObject ? playerObject->GetComponent<PlayerController>().Get() : nullptr;
	if (!rules || !hero || _finished) {
		return;
	}

	// GetDeltaTime() is scaled time. While the game is paused it is 0
	_timeLeft = Math::Max(0.0f, _timeLeft - Time::GetDeltaTime());

	if (UIPanelRenderer *hud = GetHud()) {
		UI::Context *ui = hud->GetContext();
		ui->SetTextString(hud->FindElement("coins"), std::format("Coins: {} / {}", hero->GetCoins(), rules->coinsToWin));
		ui->SetTextString(hud->FindElement("timer"), std::format("Time: {}", static_cast<i32>(std::ceil(_timeLeft))));
	}

	if (hero->GetCoins() >= rules->coinsToWin) {
		// won: to the next level (or back to the menu if last)
		GoTo(rules->nextLevel.Get() ? rules->nextLevel : menuScene);
	} else if (_timeLeft <= 0.0f) {
		GoTo(menuScene);
	}
}

void LevelController::SetPaused(bool paused) {
	_paused = paused;
	// time scale 0 stops physics/everything that reads GetDeltaTime().
	// OnUpdate still runs (pause key can unpause)
	Time::SetTimeScale(paused ? 0.0f : 1.0f);
	// sound runs on own clock (paused separately). The
	// player's AudioSource plays every sound in this game (pausing it
	// holds its one-shots too). Game with many sources routes them to a mixer
	// group whose clock is Gameplay instead -> the 0 time scale holds them
	SceneObject *playerObject = player.Get();
	if (AudioSource *audio = playerObject ? playerObject->GetComponent<AudioSource>().Get() : nullptr) {
		if (paused) {
			audio->Pause();
		} else {
			audio->UnPause();
		}
	}
	// the document hides the pause menu with `display: none` -> it stays out
	// of the way while you edit. This shows/hides it again
	if (UIPanelRenderer *hud = GetHud()) {
		UI::Context *ui = hud->GetContext();
		UI::ElementHandle pauseMenu = hud->FindElement("pause-menu");
		UI::Style style = ui->GetStyle(pauseMenu);
		style.display = paused ? UI::Display::Flex : UI::Display::None;
		ui->SetStyle(pauseMenu, style);
	}
}

void LevelController::GoTo(Handle<SceneAsset> scene) {
	SceneAsset *target = scene.Get();
	if (!target || _finished) {
		return;
	}
	_finished = true;
	// every loaded scene is replaced by this one at the end of the frame
	// (safe to ask from anywhere -> a button click included)
	SceneSystem::ReplaceScenes(target->assetId);
}
  1. Save both files.

  2. In the Hierarchy, select + and choose Create Empty. Rename the object Level.

    The new Level object, selected in the Hierarchy
  3. Select Add Component, open UI and select UIPanelRenderer. Drag Hud.slxml from Assets/UI onto its document field and Game.slpanel onto panelSettings.

    The UIPanelRenderer, with Hud.slxml as its document and Game.slpanel as its panelSettings
  4. Add LevelController, so that it is below the UIPanelRenderer. Drag Level1.level onto its level field, the Player row from the Hierarchy onto player, and Main.scene from Assets/Scenes onto menuScene.

    The LevelController below the UIPanelRenderer, with Level1.level, Player and Main.scene in its fields

Press Ctrl+P. The HUD shows the coins and the time. Press Escape to pause, and Resume to carry on. Collecting all five coins, or running out of time, loads Main.scene, which still holds only a camera and a light; the next page makes it the menu.

The HUD during play, counting one coin of five and the seconds left
The pause menu, with Resume and Main Menu over the dimmed game

Save the scene. Next, add a menu and a second level.