Tutorial 92: Building a Top-Down RPG

CNA — C++ XNA 4.0 reimplementation

Tilemap-based world

Represent the world as a 2D grid of tile indices. Each index maps to a source rectangle in a tileset texture.

struct TileMap {
    int width, height, tileSize;
    std::vector<int> tiles;   // tile index, -1 = empty
    Texture2D* tileset = nullptr;

    void Draw(SpriteBatch& sb, const Vector2& cameraOffset) {
        for (int y = 0; y < height; ++y) {
            for (int x = 0; x < width; ++x) {
                int idx = tiles[y * width + x];
                if (idx < 0) continue;
                int tilesPerRow = tileset->Width / tileSize;
                Rectangle src = {
                    (idx % tilesPerRow) * tileSize,
                    (idx / tilesPerRow) * tileSize,
                    tileSize, tileSize
                };
                Vector2 dest = Vector2(
                    x * tileSize - cameraOffset.X,
                    y * tileSize - cameraOffset.Y);
                sb.Draw(*tileset, dest, &src, Color::White);
            }
        }
    }
};

Player movement (8-directional)

void Player::Update(const GameTime& gt, const TileMap& map) {
    float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
    auto ks  = Keyboard::GetState();

    Vector2 dir;
    if (ks.IsKeyDown(Keys::W) || ks.IsKeyDown(Keys::Up))    dir.Y -= 1;
    if (ks.IsKeyDown(Keys::S) || ks.IsKeyDown(Keys::Down))  dir.Y += 1;
    if (ks.IsKeyDown(Keys::A) || ks.IsKeyDown(Keys::Left))  dir.X -= 1;
    if (ks.IsKeyDown(Keys::D) || ks.IsKeyDown(Keys::Right)) dir.X += 1;

    // Normalize diagonal movement
    float len = std::sqrt(dir.X*dir.X + dir.Y*dir.Y);
    if (len > 0.0f) dir = dir / len;

    Vector2 newPos = position_ + dir * speed_ * dt;

    // Tile collision (solid tiles block movement)
    if (!map.IsSolid(newPos + Vector2(4, 4)) &&
        !map.IsSolid(newPos + Vector2(20, 4)) &&
        !map.IsSolid(newPos + Vector2(4, 28)) &&
        !map.IsSolid(newPos + Vector2(20, 28))) {
        position_ = newPos;
    }

    if (dir.X != 0 || dir.Y != 0) facing_ = dir;
}

Z-ordering sprites by Y

In a top-down RPG, sprites closer to the bottom of the screen should appear in front of sprites higher up. Sort entities by their Y position before drawing.

void Level::DrawEntities(SpriteBatch& sb) {
    // Collect all drawable entities
    std::vector<Entity*> sorted;
    sorted.push_back(&player_);
    for (auto& npc : npcs_) sorted.push_back(&npc);

    // Sort by Y (bottom of sprite for correct overlap)
    std::sort(sorted.begin(), sorted.end(), [](Entity* a, Entity* b) {
        return (a->Position().Y + a->Height()) < (b->Position().Y + b->Height());
    });

    for (auto* e : sorted) e->Draw(sb);
}

Dialogue system

struct DialogueLine { std::string speaker, text; };
struct DialogueTree  { std::vector<DialogueLine> lines; };

class DialogueSystem {
public:
    void Start(const DialogueTree& tree) {
        tree_     = &tree;
        lineIdx_  = 0;
        active_   = true;
    }

    void Advance() {
        if (!active_) return;
        ++lineIdx_;
        if (lineIdx_ >= (int)tree_->lines.size()) active_ = false;
    }

    bool Active()  const { return active_; }
    const DialogueLine& Current() const { return tree_->lines[lineIdx_]; }

private:
    const DialogueTree* tree_ = nullptr;
    int  lineIdx_ = 0;
    bool active_  = false;
};

Inventory basics

struct Item { std::string name; int quantity; };

class Inventory {
public:
    void Add(const std::string& name, int qty = 1) {
        for (auto& item : items_) {
            if (item.name == name) { item.quantity += qty; return; }
        }
        items_.push_back({ name, qty });
    }

    bool Remove(const std::string& name, int qty = 1) {
        for (auto& item : items_) {
            if (item.name == name && item.quantity >= qty) {
                item.quantity -= qty;
                if (item.quantity == 0)
                    items_.erase(std::remove_if(items_.begin(), items_.end(),
                        [&](const Item& i){ return i.quantity == 0; }),
                        items_.end());
                return true;
            }
        }
        return false;
    }

    const std::vector<Item>& Items() const { return items_; }

private:
    std::vector<Item> items_;
};

Camera following player

void UpdateCamera(const Vector2& playerPos, int screenW, int screenH,
                  int worldW,  int worldH, Vector2& cameraPos) {
    // Center camera on player
    cameraPos.X = playerPos.X - screenW / 2.0f;
    cameraPos.Y = playerPos.Y - screenH / 2.0f;
    // Clamp to world bounds
    cameraPos.X = std::max(0.0f, std::min(cameraPos.X, (float)(worldW - screenW)));
    cameraPos.Y = std::max(0.0f, std::min(cameraPos.Y, (float)(worldH - screenH)));
}

Top-down player controller + tile collision

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include <vector>
#include <memory>
#include <cmath>
#include <algorithm>

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

class TopDownRPG final : public Game {
public:
    TopDownRPG() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

protected:
    void LoadContent() override {
        sb_    = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
        pixel_ = std::make_unique<Texture2D>(getGraphicsDeviceProperty(), 1, 1);
        Color w = Color::White;
        pixel_->SetData(&w, 1);

        // Simple 20x15 tile map (0 = grass, 1 = wall)
        mapW_ = 20; mapH_ = 15; tileSize_ = 48;
        tiles_.assign(mapW_ * mapH_, 0);
        // Surround with walls
        for (int x = 0; x < mapW_; ++x) { tiles_[x] = 1; tiles_[(mapH_-1)*mapW_+x] = 1; }
        for (int y = 0; y < mapH_; ++y) { tiles_[y*mapW_] = 1; tiles_[y*mapW_+mapW_-1] = 1; }

        playerPos_ = Vector2(3*tileSize_, 3*tileSize_);
    }

    bool IsSolid(Vector2 pt) {
        int tx = (int)(pt.X / tileSize_), ty = (int)(pt.Y / tileSize_);
        if (tx < 0 || ty < 0 || tx >= mapW_ || ty >= mapH_) return true;
        return tiles_[ty * mapW_ + tx] == 1;
    }

    void Update(const GameTime& gt) override {
        float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
        auto ks = Keyboard::GetState();
        Vector2 dir;
        if (ks.IsKeyDown(Keys::W)) dir.Y -= 1;
        if (ks.IsKeyDown(Keys::S)) dir.Y += 1;
        if (ks.IsKeyDown(Keys::A)) dir.X -= 1;
        if (ks.IsKeyDown(Keys::D)) dir.X += 1;
        float len = std::sqrt(dir.X*dir.X + dir.Y*dir.Y);
        if (len > 0) dir = dir / len;
        Vector2 np = playerPos_ + dir * 120.0f * dt;
        if (!IsSolid(np+Vector2(2,2)) && !IsSolid(np+Vector2(22,2)) &&
            !IsSolid(np+Vector2(2,26)) && !IsSolid(np+Vector2(22,26)))
            playerPos_ = np;

        // Camera
        camera_.X = std::max(0.0f, std::min(playerPos_.X - 400.0f,
                                             (float)(mapW_*tileSize_ - 800)));
        camera_.Y = std::max(0.0f, std::min(playerPos_.Y - 300.0f,
                                             (float)(mapH_*tileSize_ - 600)));
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(20, 60, 20));
        sb_->Begin();
        for (int y = 0; y < mapH_; ++y)
            for (int x = 0; x < mapW_; ++x) {
                Color c = tiles_[y*mapW_+x] == 1 ? Color(80,60,40) : Color(60,120,60);
                sb_->Draw(*pixel_,
                    Rectangle((int)(x*tileSize_-camera_.X),
                               (int)(y*tileSize_-camera_.Y),
                               tileSize_-1, tileSize_-1), c);
            }
        sb_->Draw(*pixel_,
            Rectangle((int)(playerPos_.X-camera_.X),
                       (int)(playerPos_.Y-camera_.Y), 24, 28),
            Color::Yellow);
        sb_->End();
        gd.Present();
    }

private:
    GraphicsDeviceManager        graphics_;
    std::unique_ptr<SpriteBatch> sb_;
    std::unique_ptr<Texture2D>   pixel_;
    std::vector<int> tiles_;
    int mapW_, mapH_, tileSize_;
    Vector2 playerPos_, camera_;
};

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