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"

// the rules of one level, kept in an asset rather than in the scene: each
// level gets its own .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;
	// coins that win the level
	i32 coinsToWin = 5;
	// the scene that follows a win. Left empty, a win returns to the menu
	Handle<SceneAsset> nextLevel;
};
Source/LevelDefinition.cpp
#include "LevelDefinition.h"

// registering the type is all a data asset needs: the Create menu entry, the
// Inspector and saving come from its 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.
  3. In the Inspector, set timeLimit to 30 and coinsToWin to 5, and select Save. Leave nextLevel empty for now.

The HUD

The HUD and the pause menu are one UI document, Hud.slxml.

  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 and 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, and starts hidden with display: none. LevelController shows it on pause and 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>
  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.

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 and the clock, pauses
// the game, and moves on when the level is won or the time runs out.
//
// It sits on the same object as the UIPanelRenderer that draws the HUD, below
// it: a component is enabled after the ones above it, so 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 carrying 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"

#include "Shalltear/Core/UI/Context.h"
#include "Shalltear/Core/UI/UIPanelRenderer.h"

#include <cmath>
#include <format>

SLT_REGISTER_TYPE(LevelController)

LevelController::LevelController() : Behavior(BehaviorTick::Update) {
	_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, found by the id they have in Hud.slxml. The
	// handler holds a Handle rather than `this`, so a click that arrives after
	// this component is gone 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();
	// a game paused when it left the level must not arrive paused elsewhere
	Time::SetTimeScale(1.0f);
	// the handlers are this project's code, which a reload replaces, so they
	// go with the component that set them
	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 or 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 zero, so
	// the clock stops with everything else
	_timeLeft = std::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: on to the next level, or back to the menu after the last one
		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 and everything that reads GetDeltaTime().
	// OnUpdate still runs, which is how the pause key can unpause
	Time::SetTimeScale(paused ? 0.0f : 1.0f);
	// the document hides the pause menu with `display: none`, so it stays out
	// of the way while you edit; this shows it and 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,
	// which makes it 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.
  3. Select Add Component, open UI and select UIPanelRenderer. Drag Hud.slxml onto its document field and Game.slpanel onto 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.

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.

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