Overview
This is the final maze lab. You will complete the game by adding a key
pickup, an exit door that can only be opened when all coins have been
collected and the key has been found, and a second larger level. The door
interaction uses a ray-AABB intersection test (the slab
method) to check whether the player's crosshair is pointing at the door -
a technique you first saw in the Ray Casting lab (Lab 17). The state
machine gains a fourth state (LevelComplete) and the HUD
is extended to show level progress, key status, and a seed-based maze
regeneration button. By the end of this lab TestMaze is a complete
two-level first-person game.
Learning Objectives
By the end of this lab you will be able to:
- Extend the state machine to four states (Playing, LevelComplete, Won, Lost) and route the Lost state to retry the same seed.
- Implement the slab-method ray-AABB intersection to detect whether the crosshair is aimed at the exit door.
- Add a key pickup and gate the door-unlock interaction on possessing the key and collecting all coins.
- Render the locked door as a textured cube and the key as a cyan elongated spinning object.
- Complete the HUD: level counter, key status, seed display, Regenerate button, and context-sensitive door prompts.
Concept
The exit door needs an interaction that only fires when the player is
looking directly at it. The simplest approach is to treat the camera's
front vector as a ray and test it against the door's
AABB. The slab method does this
efficiently by treating the box as the intersection of three pairs of
parallel planes (one pair per axis). For each axis the ray enters one
plane at parametric distance t1 and exits the other at
t2. The ray hits the box only if all three intervals
[t1, t2] overlap. The algorithm maintains a running
[tMin, tMax] interval; each axis narrows it. If
tMin ever exceeds tMax the intervals have
diverged and there is no intersection.
Because the crosshair sits at the exact centre of the screen and the
camera's front vector getFront() points to that centre,
no screen-to-world unprojection is needed. Setting tMax
to InteractDistance at the start means the ray is clamped
to a short reach, so the player must stand close to the door. Rays
parallel to a slab axis (direction component near zero) are handled
separately: if the origin is outside the slab the ray can never cross
it, so the function returns false immediately.
The multi-level structure is driven by a level index
parameter added to LoadLevel. A static
DefaultSeed method maps each level index to a fixed seed,
giving reproducible mazes on every run. Storing the seed that was used
to build the current maze in m_Seed lets the Lost state
retry the exact same layout. The Regenerate button replaces
m_Seed with a new std::random_device value,
which builds a fresh random maze without changing the level.
Tasks
Task 1 - Complete the TestMaze header
-
Open
src/tests/maze/TestMaze.h. AddLevelCompleteto theGameStateenum betweenPlayingandWon. -
Change the
LoadLeveldeclaration to accept a level index:void LoadLevel(int level, unsigned int seed); -
Add a
staticinline helper that maps a level index to a deterministic seed, so every run produces the same mazes by default:static unsigned int DefaultSeed(int level) { return 1337u + (unsigned int)level * 7919u; } -
Declare
bool RayHitsDoor(float maxDistance) const;as a private method. -
Add
std::unique_ptr<Texture> m_DoorTexture;afterm_FloorTexture. -
Add the five new member variables at the bottom of the private section:
int m_CurrentLevel = 0; unsigned int m_Seed = 0; bool m_HasKey = false; bool m_DoorTargeted = false; bool m_EPressedLastFrame = false;
#pragma once
#include "../Tests.h"
#include "../../Renderer.h"
#include "../../Shader.h"
#include "../../Texture.h"
#include "../../VertexArray.h"
#include "../../VertexBuffer.h"
#include "../../IndexBuffer.h"
#include "../../utils/Camera.h"
#include "MazeMap.h"
#include "Player.h"
#include <memory>
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>
namespace test {
class TestMaze : public Tests {
public:
TestMaze(GLFWwindow* window);
void Update(float deltaTime) override;
void Render() override;
void RenderGUI() override;
private:
// TODO: Add LevelComplete to the GameState enum (after Won, before Lost):
enum class GameState { Playing, /* LevelComplete, */ Won, Lost };
void BuildCubeMesh();
// TODO: Change the LoadLevel signature to take a level index:
// void LoadLevel(int level, unsigned int seed);
void LoadLevel(unsigned int seed); // Lab 22 signature - update this
void UpdatePlaying(float deltaTime);
bool AllCoinsCollected() const;
// TODO: Declare a static helper that returns a deterministic seed per level:
// static unsigned int DefaultSeed(int level)
// { return 1337u + (unsigned int)level * 7919u; }
// TODO: Declare RayHitsDoor(float maxDistance) const
void DrawCube(const glm::mat4& model, const glm::vec3& colour, const Texture* texture);
GLFWwindow* m_window;
Renderer m_Renderer;
std::unique_ptr<Camera> m_Camera;
std::unique_ptr<Player> m_Player;
MazeMap m_Map;
std::unique_ptr<Shader> m_Shader;
std::unique_ptr<Texture> m_WallTexture;
std::unique_ptr<Texture> m_FloorTexture;
// TODO: Add m_DoorTexture
std::unique_ptr<VertexArray> m_CubeVAO;
std::unique_ptr<VertexBuffer> m_CubeVBO;
std::unique_ptr<IndexBuffer> m_CubeIBO;
// Game state (from Lab 22)
GameState m_State = GameState::Playing;
float m_TimeRemaining = 0.0f;
int m_CoinsCollected = 0;
bool m_AdvancePressedLastFrame = false;
// TODO: Add new members for Lab 23:
// int m_CurrentLevel = 0;
// unsigned int m_Seed = 0;
// bool m_HasKey = false;
// bool m_DoorTargeted = false;
// bool m_EPressedLastFrame = false;
};
}
Task 2 - Update LoadLevel and the constructor
-
In
TestMaze.cpp, update the constant block. Replace the singles_LevelTimeconstant with an array indexed by level, and addInteractDistance:static const float WallHeight = 2.5f; static const float InteractDistance = 2.0f; static const float PickupRadius = 0.6f; static const float LevelTime[MazeMap::LevelCount] = { 90.0f, 120.0f }; -
Update
LoadLevelto acceptint levelandunsigned int seed. Store both (m_CurrentLevel,m_Seed), callm_Map.Load(level, seed), setm_TimeRemaining = LevelTime[level], and resetm_CoinsCollected,m_HasKey, andm_DoorTargetedto their default values before settingm_State = GameState::Playing. -
In the constructor, add the door texture after the floor texture:
m_DoorTexture = std::make_unique<Texture>("res/Textures/maze/door.png"); -
Replace the old
LoadLevel(1337u)call at the end of the constructor with:LoadLevel(0, DefaultSeed(0));
// Changes to TestMaze.cpp for Lab 23
// 1. Update the constant block (replace the single s_LevelTime with an array):
//
// static const float WallHeight = 2.5f;
// static const float InteractDistance = 2.0f;
// static const float PickupRadius = 0.6f;
// static const float LevelTime[MazeMap::LevelCount] = { 90.0f, 120.0f };
// 2. Update LoadLevel to accept a level index and update the new members:
void TestMaze::LoadLevel(int level, unsigned int seed)
{
// TODO: Store level and seed: m_CurrentLevel = level; m_Seed = seed;
// TODO: Call m_Map.Load(level, seed)
// TODO: Set m_TimeRemaining = LevelTime[level]
// TODO: Reset m_CoinsCollected = 0, m_HasKey = false, m_DoorTargeted = false
// TODO: Set m_State = GameState::Playing
// TODO: Call m_Player->Respawn(m_Map.PlayerStart(), m_Map.StartYaw())
}
// 3. Update the constructor to load the door texture and call the new LoadLevel:
TestMaze::TestMaze(GLFWwindow* window)
: m_window(window)
{
m_Camera = std::make_unique<Camera>(
window,
glm::vec3(0.0f, 1.6f, 0.0f),
glm::vec3(0.0f, 1.0f, 0.0f),
0.0f, 0.0f, 60.0f
);
m_Player = std::make_unique<Player>(m_Camera.get(), &m_Map);
m_Shader = std::make_unique<Shader>("res/Shaders/Maze.shader");
m_WallTexture = std::make_unique<Texture>("res/Textures/maze/wall.png");
m_FloorTexture = std::make_unique<Texture>("res/Textures/maze/floor.png");
// TODO: Load m_DoorTexture from "res/Textures/maze/door.png"
BuildCubeMesh();
glEnable(GL_DEPTH_TEST);
// TODO: Call LoadLevel(0, DefaultSeed(0))
// (replaces the old LoadLevel(1337u) from Lab 22)
}
Task 3 - Implement RayHitsDoor (slab method)
-
Add
RayHitsDoortoTestMaze.cppusing the skeleton below. The ray origin is the camera position and the direction ism_Camera->getFront()- the camera front vector IS the crosshair direction, so no unprojection is needed. -
Build the door AABB. The door tile sits at floor level, but we want a
full-height box, so add half the wall height to the Y component of the
centre, then extend by half the wall height in each Y direction:
glm::vec3 doorCenter = m_Map.DoorPosition() + glm::vec3(0.0f, WallHeight * 0.5f, 0.0f); glm::vec3 boxMin = doorCenter - glm::vec3(half, WallHeight * 0.5f, half); glm::vec3 boxMax = doorCenter + glm::vec3(half, WallHeight * 0.5f, half); -
Initialise
tMin = 0(reject hits behind the camera) andtMax = maxDistance(clamp to interaction reach). -
In the per-axis loop:
-
If the direction component is near zero the ray is parallel to the
slab. Return
falseif the origin is outside the slab on that axis. -
Otherwise compute
t1andt2(entry and exit along this axis), swap them ift1 > t2, then narrow the interval:tMin = std::max(tMin, t1); tMax = std::min(tMax, t2); if (tMin > tMax) return false;
-
If the direction component is near zero the ray is parallel to the
slab. Return
-
Return
trueafter the loop - all three slabs overlapped.
// Add RayHitsDoor to TestMaze.cpp, inside namespace test { ... }
/**
* Slab method ray-AABB intersection.
*
* Treats the door's AABB as the intersection of three infinite "slabs"
* (a slab is the region between two parallel planes on one axis).
* For each axis the ray travels between the two planes at parametric
* distances t1 and t2. The ray hits the box only if ALL THREE intervals
* [t1, t2] overlap. The intersection of those intervals is [tMin, tMax].
* If tMin > tMax the intervals have no common region and there is no hit.
*
* Because the crosshair is at the exact centre of the screen and the
* camera's front vector points to that centre, no screen-space
* unprojection is needed: the camera front IS the ray direction.
*/
bool TestMaze::RayHitsDoor(float maxDistance) const
{
glm::vec3 origin = m_Camera->getPosition();
glm::vec3 direction = m_Camera->getFront();
// Build the door AABB: tile-width in XZ, WallHeight tall, centred at mid-height
float half = m_Map.TileSize() * 0.5f;
glm::vec3 doorCenter = m_Map.DoorPosition()
+ glm::vec3(0.0f, WallHeight * 0.5f, 0.0f);
// TODO: Compute boxMin = doorCenter - glm::vec3(half, WallHeight * 0.5f, half)
// TODO: Compute boxMax = doorCenter + glm::vec3(half, WallHeight * 0.5f, half)
// tMin starts at 0 (ignore hits behind the camera).
// tMax starts at maxDistance (clamps the interaction reach).
float tMin = 0.0f;
float tMax = maxDistance;
for (int axis = 0; axis < 3; axis++)
{
if (std::abs(direction[axis]) < 1e-8f)
{
// Ray is parallel to this slab - check if origin is inside it.
// If the origin is outside the slab there can be no intersection.
// TODO: if (origin[axis] < boxMin[axis] || origin[axis] > boxMax[axis])
// return false;
}
else
{
// Compute entry (t1) and exit (t2) distances for this slab.
float invD = 1.0f / direction[axis];
// TODO: float t1 = (boxMin[axis] - origin[axis]) * invD;
// TODO: float t2 = (boxMax[axis] - origin[axis]) * invD;
// TODO: if (t1 > t2) std::swap(t1, t2); // ensure t1 <= t2
// Narrow the running interval: tMin grows, tMax shrinks.
// TODO: tMin = std::max(tMin, t1);
// TODO: tMax = std::min(tMax, t2);
// TODO: if (tMin > tMax) return false; // intervals no longer overlap
}
}
return true;
}
Task 4 - Key, door interaction, and full HUD
-
In
UpdatePlaying(), add the key pickup after the coin loop (same XZ distance pattern), then add door targeting, E-key edge detection, and the walk-through check using the TODO blocks in the skeleton. -
In
Update(), extend the switch with aLevelCompletecase that advances to the next level, and update theLostcase to retry the same seed (LoadLevel(m_CurrentLevel, m_Seed)). -
In
Render(), add the locked door inside the tile loop'selsebranch, after the floor slab:
Then add the key rendering (cyan elongated cube) after the coin loop.if (tileChar == 'D' && !m_Map.IsDoorOpen()) { glm::mat4 doorModel = glm::translate(glm::mat4(1.0f), center + glm::vec3(0.0f, WallHeight * 0.5f, 0.0f)); doorModel = glm::scale(doorModel, glm::vec3(tile, WallHeight, tile)); DrawCube(doorModel, glm::vec3(0.85f, 0.6f, 0.4f), m_DoorTexture.get()); } -
Replace
RenderGUI()with the full version in the skeleton. Key additions are: Level display at the top, Key status line, Seed display with a Regenerate button (LoadLevel(m_CurrentLevel, std::random_device{}())), the updated controls hint, context-sensitive door prompts whenm_DoorTargetedis true, and theLevelCompletecase in the state switch. -
Build and run the complete game:
- Collect all gold coins (they disappear on contact).
- Walk over the cyan key object.
- Aim the crosshair at the door and press E. A yellow prompt guides you.
- Walk through the open doorway to complete Level 1.
- Press Enter to start Level 2 (a larger 15x15 maze with more coins).
- Complete Level 2 to see the "YOU ESCAPED!" screen.
- Let the timer expire to see "OUT OF TIME..." - Enter retries with the same maze.
- Use the Regenerate button to build a different random maze.
// Changes to UpdatePlaying(), Render(), Update(), and RenderGUI() for Lab 23.
// Add the sections marked TODO to the existing Lab 22 implementations.
// =========================================================================
// UpdatePlaying() - add AFTER the coin pickup loop, before the win check
// =========================================================================
// ---- Key pickup ----
// TODO: Get m_Map.Key() by non-const reference
// TODO: if (!key.collected && distanceXZ(playerPos, key.position) < PickupRadius)
// { key.collected = true; m_HasKey = true; }
// ---- Door targeting ----
// Fire a ray from the camera each frame. The door is targeted when
// it is still locked AND the crosshair ray hits its AABB.
// TODO: m_DoorTargeted = !m_Map.IsDoorOpen() && RayHitsDoor(InteractDistance);
// ---- E-key: unlock the door ----
// Edge-detected: act only on the frame the key is pressed, not while held.
// The door needs the key AND every coin collected to open.
// TODO: bool ePressed = glfwGetKey(m_window, GLFW_KEY_E) == GLFW_PRESS;
// TODO: if (ePressed && !m_EPressedLastFrame
// && m_DoorTargeted && m_HasKey && AllCoinsCollected())
// { m_Map.OpenDoor(); m_DoorTargeted = false; }
// TODO: m_EPressedLastFrame = ePressed;
// ---- Walk through the open door to finish the level ----
// TODO: if (m_Map.IsDoorOpen())
// {
// int col, row;
// m_Map.WorldToTile(playerPos, col, row);
// if (col == m_Map.DoorCol() && row == m_Map.DoorRow())
// m_State = (m_CurrentLevel + 1 < MazeMap::LevelCount)
// ? GameState::LevelComplete
// : GameState::Won;
// }
// =========================================================================
// Update() - extend the switch with LevelComplete and update Won/Lost
// =========================================================================
switch (m_State)
{
case GameState::Playing:
UpdatePlaying(deltaTime);
break;
// TODO: case GameState::LevelComplete:
// if (advancePressed)
// LoadLevel(m_CurrentLevel + 1, DefaultSeed(m_CurrentLevel + 1));
// break;
case GameState::Won:
// TODO: if (advancePressed) LoadLevel(0, DefaultSeed(0));
break;
case GameState::Lost:
// TODO: if (advancePressed) LoadLevel(m_CurrentLevel, m_Seed);
// (retry the SAME maze - m_Seed records the seed it was built with)
break;
}
// =========================================================================
// Render() - add door and key rendering after the coin loop
// =========================================================================
// ---- Door (locked only - open door is just a gap) ----
// TODO: In the tile loop, where tileChar == 'D' and !m_Map.IsDoorOpen():
// glm::mat4 doorModel = glm::translate(glm::mat4(1.0f),
// center + glm::vec3(0.0f, WallHeight * 0.5f, 0.0f));
// doorModel = glm::scale(doorModel, glm::vec3(tile, WallHeight, tile));
// DrawCube(doorModel, glm::vec3(0.85f, 0.6f, 0.4f), m_DoorTexture.get());
//
// HINT: This belongs inside the 'else' branch of tileChar == '#'.
// After drawing the floor slab, add an if block for 'D'.
// ---- Key (cyan elongated cube) ----
// TODO: const Collectible& key = m_Map.Key();
// TODO: if (!key.collected)
// {
// glm::vec3 bob(0.0f, 0.1f * std::sin(time * 3.0f), 0.0f);
// glm::mat4 model = glm::translate(glm::mat4(1.0f), key.position + bob);
// model = glm::rotate(model, time * 2.0f, glm::vec3(0.0f, 1.0f, 0.0f));
// model = glm::scale(model, glm::vec3(0.15f, 0.15f, 0.5f)); // elongated
// DrawCube(model, glm::vec3(0.3f, 0.9f, 1.0f), nullptr); // cyan
// }
// =========================================================================
// RenderGUI() - replace with full version
// =========================================================================
void TestMaze::RenderGUI()
{
// ---- Panel widgets ----
// TODO: ImGui::Text("Level: %d / %d", m_CurrentLevel + 1, MazeMap::LevelCount);
ImGui::Text("Time: %.0f s", m_TimeRemaining);
ImGui::Text("Coins: %d / %d", m_CoinsCollected, (int)m_Map.Coins().size());
// TODO: ImGui::Text("Key: %s", m_HasKey ? "collected" : "not found");
ImGui::Separator();
// TODO: ImGui::Text("Seed: %u", m_Seed);
// TODO: if (ImGui::Button("Regenerate maze"))
// LoadLevel(m_CurrentLevel, std::random_device{}());
ImGui::Separator();
// TODO: Update controls hint to include: "E: open door"
ImGui::TextWrapped("WASD: move | Mouse: look | Enter: continue | ESC: pause + cursor");
m_Camera->cameraGUI();
// ---- Foreground overlays ----
ImDrawList* drawList = ImGui::GetForegroundDrawList();
ImGuiIO& io = ImGui::GetIO();
ImVec2 center(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f);
auto drawCenteredText = [&](float fontSize, float yOffset, ImU32 colour, const char* text)
{
ImFont* font = ImGui::GetFont();
ImVec2 size = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text);
drawList->AddText(font, fontSize,
ImVec2(center.x - size.x * 0.5f, center.y + yOffset - size.y * 0.5f),
colour, text);
};
if (m_State == GameState::Playing && !m_Camera->isDetached())
{
// Crosshair
const float arm = 10.0f;
ImU32 white = IM_COL32(255, 255, 255, 200);
drawList->AddLine(ImVec2(center.x - arm, center.y), ImVec2(center.x + arm, center.y), white, 2.0f);
drawList->AddLine(ImVec2(center.x, center.y - arm), ImVec2(center.x, center.y + arm), white, 2.0f);
// TODO: Door interaction prompt (when m_DoorTargeted is true):
// if (!m_HasKey) -> "The door is locked - find the key"
// else if (!AllCoinsCollected) -> "The door won't budge - collect every coin first"
// else -> "Press E to unlock the door"
// Draw with drawCenteredText(24.0f, 50.0f, IM_COL32(255, 230, 120, 255), prompt)
}
switch (m_State)
{
// TODO: case GameState::LevelComplete:
// drawCenteredText(48.0f, -30.0f, IM_COL32(120, 255, 120, 255), "LEVEL COMPLETE!");
// drawCenteredText(24.0f, 30.0f, IM_COL32(255, 255, 255, 255), "Press ENTER for the next maze");
// break;
case GameState::Won:
drawCenteredText(48.0f, -30.0f, IM_COL32(120, 255, 120, 255), "YOU ESCAPED!");
drawCenteredText(24.0f, 30.0f, IM_COL32(255, 255, 255, 255), "Press ENTER to play again");
break;
case GameState::Lost:
drawCenteredText(48.0f, -30.0f, IM_COL32(255, 100, 100, 255), "OUT OF TIME...");
drawCenteredText(24.0f, 30.0f, IM_COL32(255, 255, 255, 255), "Press ENTER to retry this maze");
break;
case GameState::Playing:
break;
}
}
Solution
Try the tasks above before revealing the solution.