University of Wales Trinity Saint David
ACCA6021 - Graphics and Games Programming

Lab 23 - Exit Door, Polish, and Complete Game

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:

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

  1. Open src/tests/maze/TestMaze.h. Add LevelComplete to the GameState enum between Playing and Won.
  2. Change the LoadLevel declaration to accept a level index:
    void LoadLevel(int level, unsigned int seed);
  3. Add a static inline 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; }
  4. Declare bool RayHitsDoor(float maxDistance) const; as a private method.
  5. Add std::unique_ptr<Texture> m_DoorTexture; after m_FloorTexture.
  6. 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;
src/tests/maze/TestMaze.h
#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

  1. In TestMaze.cpp, update the constant block. Replace the single s_LevelTime constant with an array indexed by level, and add InteractDistance:
    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 int level and unsigned int seed. Store both (m_CurrentLevel, m_Seed), call m_Map.Load(level, seed), set m_TimeRemaining = LevelTime[level], and reset m_CoinsCollected, m_HasKey, and m_DoorTargeted to their default values before setting m_State = GameState::Playing.
  3. In the constructor, add the door texture after the floor texture:
    m_DoorTexture = std::make_unique<Texture>("res/Textures/maze/door.png");
  4. Replace the old LoadLevel(1337u) call at the end of the constructor with:
    LoadLevel(0, DefaultSeed(0));
src/tests/maze/TestMaze.cpp
// 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)

  1. Add RayHitsDoor to TestMaze.cpp using the skeleton below. The ray origin is the camera position and the direction is m_Camera->getFront() - the camera front vector IS the crosshair direction, so no unprojection is needed.
  2. 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);
  3. Initialise tMin = 0 (reject hits behind the camera) and tMax = maxDistance (clamp to interaction reach).
  4. In the per-axis loop:
    1. If the direction component is near zero the ray is parallel to the slab. Return false if the origin is outside the slab on that axis.
    2. Otherwise compute t1 and t2 (entry and exit along this axis), swap them if t1 > t2, then narrow the interval:
      tMin = std::max(tMin, t1);
      tMax = std::min(tMax, t2);
      if (tMin > tMax) return false;
  5. Return true after the loop - all three slabs overlapped.
src/tests/maze/TestMaze.cpp
// 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

  1. 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.
  2. In Update(), extend the switch with a LevelComplete case that advances to the next level, and update the Lost case to retry the same seed (LoadLevel(m_CurrentLevel, m_Seed)).
  3. In Render(), add the locked door inside the tile loop's else branch, after the floor slab:
    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());
    }
    Then add the key rendering (cyan elongated cube) after the coin loop.
  4. 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 when m_DoorTargeted is true, and the LevelComplete case in the state switch.
  5. Build and run the complete game:
    1. Collect all gold coins (they disappear on contact).
    2. Walk over the cyan key object.
    3. Aim the crosshair at the door and press E. A yellow prompt guides you.
    4. Walk through the open doorway to complete Level 1.
    5. Press Enter to start Level 2 (a larger 15x15 maze with more coins).
    6. Complete Level 2 to see the "YOU ESCAPED!" screen.
    7. Let the timer expire to see "OUT OF TIME..." - Enter retries with the same maze.
    8. Use the Regenerate button to build a different random maze.
src/tests/maze/TestMaze.cpp
// 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.