Tutorial 93: Building a First-Person 3D Game

CNA — C++ XNA 4.0 reimplementation

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(const GameTime& gt) {
        float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());

        // Mouse look
        auto ms = Mouse::GetState();
        int cx = 400, cy = 300;   // screen center
        float dx = (float)(ms.X - cx) * 0.002f;
        float dy = (float)(ms.Y - 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;
    }

    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);
    }
};

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() };
    for (auto& box : boxes) {
        float near, far;
        if (box.Intersects(Ray(origin, dir), near)) {
            if (near < best.distance) {
                best = { true, origin + dir * near, near };
            }
        }
    }
    return best;
}

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(const GameTime& gt, const Vector3& playerPos) {
        float dt  = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
        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
minimap_ = std::make_unique<RenderTarget2D>(
    getGraphicsDeviceProperty(), 128, 128,
    false, SurfaceFormat::Color, DepthFormat::None);

void DrawMinimap(GraphicsDevice& gd, SpriteBatch& sb) {
    // Render to minimap RT
    gd.SetRenderTarget(*minimap_);
    gd.Clear(Color(20, 20, 20, 180));
    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);

    // Draw minimap RT onto screen HUD (top-right corner)
    sb.Begin();
    sb.Draw(*minimap_, Vector2(800-138, 10), nullptr,
            Color::White, 0, Vector2::Zero, 1.0f, SpriteEffects::None, 0);
    sb.End();
}

FPS camera class + raycasting hit test

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Graphics/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/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 <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_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

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

        // Floor quad
        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>(
            getGraphicsDeviceProperty(),
            VertexPositionColor::VertexDeclaration, 4, BufferUsage::None);
        floorVB_->SetData(floor, 4);

        camPos_  = Vector3(0, 1.7f, 0);
        enemies_.push_back({ Vector3(3, 0.5f, -5) });
        Mouse::SetVisible(false);
    }

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

        Vector3 fwd(std::cos(pitch_)*std::sin(yaw_),
                   -std::sin(pitch_),
                    std::cos(pitch_)*std::cos(yaw_));
        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();

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

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(30, 30, 50));

        Vector3 fwd(std::cos(pitch_)*std::sin(yaw_),
                   -std::sin(pitch_),
                    std::cos(pitch_)*std::cos(yaw_));
        Matrix view = Matrix::CreateLookAt(camPos_, camPos_+fwd, Vector3::Up);
        Matrix proj = Matrix::CreatePerspectiveFieldOfView(
            MathHelper::ToRadians(75.0f), 800.0f/600.0f, 0.1f, 100.0f);

        effect_->setView(view);
        effect_->setProjection(proj);
        effect_->setVertexColorEnabled(true);
        effect_->setWorld(Matrix::Identity);

        gd.setVertexBuffer(*floorVB_);
        for (auto& p : effect_->getCurrentTechnique().Passes) {
            p.Apply();
            gd.DrawPrimitives(PrimitiveType::TriangleStrip, 0, 2);
        }

        gd.Present();
    }

private:
    struct SimpleEnemy {
        Vector3 pos;
        void Update(const GameTime& gt, const Vector3& pp) {
            float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
            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; }
        }
    };

    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_;
    std::vector<SimpleEnemy>            enemies_;
    Vector3 camPos_;
    float   yaw_ = 0, pitch_ = 0;
};

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