Tutorial 93: Building a First-Person 3D Game

CNA — C++ XNA 4.0 reimplementation

ℹ

What you’ll learn

  • Combining mouse-look and WASD into an FPS controller.
  • Raycasting for hit detection against world geometry.
  • A simple enemy state machine, and a minimap drawn to a render target.

Before you start — Tutorial 34: 3D Camera Setup and Control (the camera this builds on), Tutorial 44: Bounding Volumes and Spatial Queries (ray/volume intersection) and Tutorial 23: Render Targets for Off-Screen Rendering (the minimap). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderer (SDL_RENDERER) throws on 3D calls by default, and STUB reports no 3D capability at all. The minimap also needs a renderer with render-target support, which every 3D renderer has.

FPS camera (mouse look + WASD)

class FPSCamera {
public:
    Vector3 Position;
    float   Yaw   = 0.0f;   // horizontal rotation (radians)
    float   Pitch = 0.0f;   // vertical rotation (radians, clamped)

    void Update(GameTime& gt) {
        float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());

        // Mouse look
        auto ms = Mouse::GetState();
        int cx = 400, cy = 300;   // screen center
        float dx = (float)(ms.getXProperty() - cx) * 0.002f;
        float dy = (float)(ms.getYProperty() - cy) * 0.002f;
        Yaw   += dx;
        Pitch  = std::clamp(Pitch + dy, -MathHelper::PiOver2 + 0.01f,
                                         MathHelper::PiOver2 - 0.01f);
        Mouse::SetPosition(cx, cy);

        // WASD movement
        Vector3 forward = Forward();
        Vector3 right   = Vector3::Cross(forward, Vector3::Up);
        right.Normalize();
        auto ks = Keyboard::GetState();
        float speed = 5.0f * dt;
        if (ks.IsKeyDown(Keys::W)) Position += forward * speed;
        if (ks.IsKeyDown(Keys::S)) Position -= forward * speed;
        if (ks.IsKeyDown(Keys::A)) Position -= right   * speed;
        if (ks.IsKeyDown(Keys::D)) Position += right   * speed;
    }

    // Same convention as Tutorial 34: yaw 0 looks down -Z (XNA's "forward"),
    // positive yaw turns right, and a positive pitch (mouse moved down) looks down.
    Vector3 Forward() const {
        return Vector3(
            std::cos(Pitch) * std::sin(Yaw),
           -std::sin(Pitch),
           -std::cos(Pitch) * std::cos(Yaw));
    }

    Matrix ViewMatrix() const {
        return Matrix::CreateLookAt(Position, Position + Forward(), Vector3::Up);
    }
};

A purity note: the compound operators Vector3::operator+=, -= and *= used above and below are CNAEXT conveniences, deprecated under CNA_STRICT_XNA_API; the XNA-pure spelling is Position = Position + forward * speed;. The mouse-look here re-centres the cursor every frame, as in Tutorial 34; the CNAEXT Mouse::setIsRelativeMouseModeEXTProperty(true) is the alternative.

Raycasting for hit detection

struct HitResult { bool hit; Vector3 point; float distance; };

HitResult Raycast(const Vector3& origin, const Vector3& dir,
                  const std::vector<BoundingBox>& boxes) {
    HitResult best { false, {}, std::numeric_limits<float>::max() };
    const Ray ray(origin, dir);                 // dir must be normalised
    for (const BoundingBox& box : boxes) {
        // BoundingBox::Intersects(Ray) returns the distance along the ray,
        // or an empty std::optional when the ray misses.
        std::optional<float> t = box.Intersects(ray);
        if (t && *t < best.distance) {
            best = { true, origin + dir * *t, *t };
        }
    }
    return best;
}

Do not name the distances near and far: on Windows those are macros from the platform headers and will break the build.

Enemy AI (simple state machine)

enum class EnemyState { Idle, Chase, Attack, Dead };

class Enemy {
public:
    Vector3     Position;
    EnemyState  State    = EnemyState::Idle;
    float       Health   = 100.0f;
    float       AlertRadius = 10.0f;

    void Update(GameTime& gt, const Vector3& playerPos) {
        float dt  = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
        float dist = Vector3::Distance(Position, playerPos);

        switch (State) {
        case EnemyState::Idle:
            if (dist < AlertRadius) State = EnemyState::Chase;
            break;
        case EnemyState::Chase: {
            Vector3 dir = playerPos - Position;
            dir.Normalize();
            Position += dir * 3.0f * dt;
            if (dist < 1.5f) State = EnemyState::Attack;
            if (dist > AlertRadius * 2.0f) State = EnemyState::Idle;
            break;
        }
        case EnemyState::Attack:
            attackTimer_ -= dt;
            if (attackTimer_ <= 0.0f) {
                // Deal damage to player here
                attackTimer_ = 1.0f;
            }
            if (dist > 2.0f) State = EnemyState::Chase;
            break;
        case EnemyState::Dead:
            break;
        }
    }

    void TakeDamage(float dmg) {
        Health -= dmg;
        if (Health <= 0.0f) State = EnemyState::Dead;
    }

private:
    float attackTimer_ = 1.0f;
};

Minimap on RenderTarget2D

// Create minimap render target (128 x 128 is a power of two and well inside the
// default Reach profile's 2048 texture-size limit; Color is a Reach-legal format)
minimap_ = std::make_unique<RenderTarget2D>(
    getGraphicsDeviceProperty(), 128, 128,
    false, SurfaceFormat::Color, DepthFormat::None);

// Call this at the START of Draw(), before the main scene: switching render targets
// is not guaranteed to preserve the back buffer's contents.
void DrawMinimap(GraphicsDevice& gd, SpriteBatch& sb) {
    // Render to minimap RT (SetRenderTarget takes a RenderTarget2D*)
    gd.SetRenderTarget(minimap_.get());
    gd.Clear(Color(20, 20, 20, 255));
    sb.Begin();
    // Draw rooms, corridors, enemies as colored dots
    for (auto& e : enemies_) {
        if (e.State == EnemyState::Dead) continue;
        Vector2 mp = WorldToMinimap(e.Position);
        sb.Draw(*pixel_, Rectangle((int)mp.X-2, (int)mp.Y-2, 4, 4), Color::Red);
    }
    Vector2 pp = WorldToMinimap(camera_.Position);
    sb.Draw(*pixel_, Rectangle((int)pp.X-3, (int)pp.Y-3, 6, 6), Color::Yellow);
    sb.End();
    gd.SetRenderTarget(nullptr);
}

// Later in Draw(), after the 3D scene: put the minimap on the HUD (top-right corner)
sb.Begin();
sb.Draw(*minimap_, Vector2(800-138, 10), Color::White);   // a RenderTarget2D is a Texture2D
sb.End();
// SpriteBatch does not restore the blend, depth, sampler or rasterizer state it changes,
// so set them back before the next frame's 3D pass (see Tutorial 31).

FPS camera class + raycasting hit test

The complete program below puts the pieces together: mouse-look and WASD, red cube enemies that chase you, a left-click that casts a ray from the eye and removes the nearest enemy it hits, and the minimap drawn to a RenderTarget2D and shown in the corner. It stays inside the default Reach profile: no 32-bit indices, no float render targets, no multiple render targets.

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionColor.hpp"
#include "Microsoft/Xna/Framework/Graphics/RenderTarget2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/BlendState.hpp"
#include "Microsoft/Xna/Framework/Graphics/DepthStencilState.hpp"
#include "Microsoft/Xna/Framework/Graphics/RasterizerState.hpp"
#include "Microsoft/Xna/Framework/Graphics/SamplerState.hpp"
#include "Microsoft/Xna/Framework/Input/ButtonState.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Mouse.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/BoundingBox.hpp"
#include "Microsoft/Xna/Framework/Ray.hpp"
#include <vector>
#include <memory>
#include <optional>
#include <cmath>
#include <algorithm>
#include <limits>

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;

class FPSGame final : public Game {
public:
    FPSGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

protected:
    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();
        effect_  = std::make_unique<BasicEffect>(gd);
        effect_->VertexColorEnabled = true;
        sb_      = std::make_unique<SpriteBatch>(gd);
        pixel_   = std::make_unique<Texture2D>(gd, 1, 1);
        Color w  = Color::White;
        pixel_->SetData(&w, 1);
        minimap_ = std::make_unique<RenderTarget2D>(
            gd, 128, 128, false, SurfaceFormat::Color, DepthFormat::None);

        // Floor: a strip of two triangles, clockwise as seen from above
        VertexPositionColor floor[] = {
            { Vector3(-10,0,-10), Color(80,80,80) },
            { Vector3( 10,0,-10), Color(80,80,80) },
            { Vector3(-10,0, 10), Color(80,80,80) },
            { Vector3( 10,0, 10), Color(80,80,80) },
        };
        floorVB_ = std::make_unique<VertexBuffer>(
            gd, VertexPositionColor::getVertexDeclarationStatic(), 4, BufferUsage::WriteOnly);
        floorVB_->SetData(floor, 4);

        BuildEnemyCube();

        camPos_ = Vector3(0, 1.7f, 0);
        enemies_ = { { Vector3(3, 0.5f, -5) }, { Vector3(-4, 0.5f, -7) }, { Vector3(0, 0.5f, -9) } };
        setIsMouseVisibleProperty(false);
    }

    void Update(GameTime& gt) override {
        float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
        auto ms  = Mouse::GetState();
        float dx = (ms.getXProperty() - 400) * 0.002f;
        float dy = (ms.getYProperty() - 300) * 0.002f;
        yaw_   += dx;
        pitch_  = std::clamp(pitch_ + dy, -1.5f, 1.5f);
        Mouse::SetPosition(400, 300);

        Vector3 fwd = Forward();
        Vector3 right = Vector3::Cross(fwd, Vector3::Up);
        right.Normalize();
        auto ks = Keyboard::GetState();
        float sp = 5.0f * dt;
        if (ks.IsKeyDown(Keys::W)) camPos_ += fwd   * sp;
        if (ks.IsKeyDown(Keys::S)) camPos_ -= fwd   * sp;
        if (ks.IsKeyDown(Keys::A)) camPos_ -= right  * sp;
        if (ks.IsKeyDown(Keys::D)) camPos_ += right  * sp;
        if (ks.IsKeyDown(Keys::Escape)) Exit();

        // Fire on the frame the left button goes down: ray from the eye along the view direction
        bool down = ms.getLeftButtonProperty() == ButtonState::Pressed;
        if (down && !wasDown_) Fire(fwd);
        wasDown_ = down;

        for (auto& e : enemies_) e.Update(gt, camPos_);
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();

        // Off-screen pass first: switching render targets is not guaranteed to keep the
        // back buffer's contents, so do it before the main scene is drawn.
        DrawMinimap(gd);

        gd.Clear(Color(30, 30, 50));

        // SpriteBatch (used by the minimap and the HUD) leaves its own blend, depth,
        // sampler and rasterizer state behind: restore the 3D state every frame.
        gd.setBlendStateProperty(BlendState::Opaque);
        gd.setDepthStencilStateProperty(DepthStencilState::Default);
        gd.setRasterizerStateProperty(RasterizerState::CullCounterClockwise);
        gd.getSamplerStatesProperty()[0] = SamplerState::LinearClamp;

        Matrix view = Matrix::CreateLookAt(camPos_, camPos_ + Forward(), Vector3::Up);
        Matrix proj = Matrix::CreatePerspectiveFieldOfView(
            MathHelper::ToRadians(75.0f), 800.0f/600.0f, 0.1f, 100.0f);

        effect_->setViewProperty(view);
        effect_->setProjectionProperty(proj);

        // Floor
        effect_->setWorldProperty(Matrix::getIdentityProperty());
        gd.SetVertexBuffer(floorVB_.get());
        for (auto& p : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
            p.Apply();
            gd.DrawPrimitives(PrimitiveType::TriangleStrip, 0, 2);
        }

        // Enemies: one shared red cube, moved with a world matrix
        gd.SetVertexBuffer(cubeVB_.get());
        for (auto& e : enemies_) {
            if (!e.alive) continue;
            effect_->setWorldProperty(Matrix::CreateTranslation(e.pos));
            for (auto& p : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
                p.Apply();
                gd.DrawPrimitives(PrimitiveType::TriangleList, 0, 12);
            }
        }

        // HUD: minimap in the top-right corner and a crosshair
        sb_->Begin();
        sb_->Draw(*minimap_, Vector2(800 - 138, 10), Color::White);
        sb_->Draw(*pixel_, Rectangle(400 - 2, 300 - 2, 4, 4), Color::White);
        sb_->End();
        // No gd.Present(): Game presents in EndDraw, after Draw() returns.
    }

private:
    struct SimpleEnemy {
        Vector3 pos;
        bool    alive = true;
        BoundingBox Bounds() const {
            return BoundingBox(pos - Vector3(0.5f, 0.5f, 0.5f), pos + Vector3(0.5f, 0.5f, 0.5f));
        }
        void Update(GameTime& gt, const Vector3& pp) {
            if (!alive) return;
            float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
            Vector3 d = pp - pos; d.Y = 0;
            float len = std::sqrt(d.X*d.X+d.Z*d.Z);
            if (len > 0) { d = d/len; pos += d*1.5f*dt; }
        }
    };

    // Same convention as Tutorial 34: yaw 0 looks down -Z, positive yaw turns right.
    Vector3 Forward() const {
        return Vector3(std::cos(pitch_)*std::sin(yaw_),
                      -std::sin(pitch_),
                      -std::cos(pitch_)*std::cos(yaw_));
    }

    void Fire(const Vector3& dir) {
        // BoundingBox::Intersects(Ray) returns the distance along the ray, or an empty optional.
        const Ray ray(camPos_, dir);
        SimpleEnemy* nearest = nullptr;
        float nearestT = std::numeric_limits<float>::max();
        for (auto& e : enemies_) {
            if (!e.alive) continue;
            std::optional<float> t = e.Bounds().Intersects(ray);
            if (t && *t < nearestT) { nearestT = *t; nearest = &e; }
        }
        if (nearest) nearest->alive = false;       // a one-shot kill
    }

    Vector2 WorldToMinimap(const Vector3& p) const {
        // The world spans -10..10 on X and Z; the minimap is 128 x 128 pixels
        return Vector2((p.X + 10.0f) / 20.0f * 128.0f, (p.Z + 10.0f) / 20.0f * 128.0f);
    }

    void DrawMinimap(GraphicsDevice& gd) {
        gd.SetRenderTarget(minimap_.get());
        gd.Clear(Color(20, 20, 20, 255));
        sb_->Begin();
        for (auto& e : enemies_) {
            if (!e.alive) continue;
            Vector2 mp = WorldToMinimap(e.pos);
            sb_->Draw(*pixel_, Rectangle((int)mp.X - 2, (int)mp.Y - 2, 4, 4), Color::Red);
        }
        Vector2 pp = WorldToMinimap(camPos_);
        sb_->Draw(*pixel_, Rectangle((int)pp.X - 3, (int)pp.Y - 3, 6, 6), Color::Yellow);
        sb_->End();
        gd.SetRenderTarget(nullptr);
    }

    void BuildEnemyCube() {
        const float h = 0.5f;
        const Vector3 c[8] = {
            {-h,-h,-h},{+h,-h,-h},{+h,+h,-h},{-h,+h,-h},
            {-h,-h,+h},{+h,-h,+h},{+h,+h,+h},{-h,+h,+h}
        };
        // 12 triangles, each wound clockwise as seen from outside (front-facing under
        // the default CullCounterClockwise state)
        const int faces[36] = {
            0,1,2, 0,2,3,  4,6,5, 4,7,6,  0,3,7, 0,7,4,
            1,5,6, 1,6,2,  3,2,6, 3,6,7,  0,4,5, 0,5,1
        };
        std::vector<VertexPositionColor> verts;
        for (int i : faces) verts.push_back({ c[i], Color::Red });
        cubeVB_ = std::make_unique<VertexBuffer>(
            getGraphicsDeviceProperty(), VertexPositionColor::getVertexDeclarationStatic(),
            static_cast<int>(verts.size()), BufferUsage::WriteOnly);
        cubeVB_->SetData(verts.data(), static_cast<int>(verts.size()));
    }

    GraphicsDeviceManager               graphics_;
    std::unique_ptr<BasicEffect>        effect_;
    std::unique_ptr<SpriteBatch>        sb_;
    std::unique_ptr<Texture2D>          pixel_;
    std::unique_ptr<RenderTarget2D>     minimap_;
    std::unique_ptr<VertexBuffer>       floorVB_, cubeVB_;
    std::vector<SimpleEnemy>            enemies_;
    Vector3 camPos_;
    float   yaw_ = 0, pitch_ = 0;
    bool    wasDown_ = false;
};

int main() { FPSGame game; game.Run(); }