C/C++ Tutorial, Goal-Oriented Style (3a) RPG homework in a nutshell (?)

The “RPG” homework in Chapter 3

第三章 (http://www.yhorng.com/blog/?p=226) 最後的作業是寫一個文字角色扮演遊戲,像這種規模較大的程式,我們要如何用 divide and conquer 來釐清問題、並思考程式架構呢?

我們認真地將這個 project 升級,當成一個真正的遊戲來寫吧!

首先,我們要從最上層的 UI、也就是玩家操作的方法和會看到的畫面開始思考起。因為是真的遊戲,所以:

  1. 我們希望玩家輸入某個按鍵時,能像用遊戲手把一樣,不要有回饋字出現在畫面上。
  2. 我們希望玩家輸入某個按鍵時,能像用遊戲手把一樣,不要像 std::cin 將程式卡住 (blocking)。
  3. 使用者輸入指令時,遊戲會有反應,但就算沒有輸入時,RPG 的世界依然會運轉 (除非你要提供 pause 功能)。

Non-Blocking / No-Prompt user input

一開始就遇到問題了,C++ 能用鍵盤模擬遊戲手把嗎? 遇到沒頭緒的事不要怕用 Google,如果不翻文件不爬網路,沒人能將這些瑣事全記在腦袋裡的。

non-blocking 的問題和 no-prompt 的問題各可以用下面的 keyword 找出答案。

  1. Google Keyword: ‘C++ nonblocking get char’
  2. Google Keyword: ‘C++ get char without echo’

簡單的說,標準 C++ 是做不到的,這是 platform-dependency 的範疇。因為我們使用的是 Linux-Like (包括 OSX) 的環境,所以下面是將 二個 linux solution 合併的範例。

  • non-blocking solution: fcntl( … O_NONBLOCK )
  • no-prompt solution: tcsetattr( … )
#include <iostream> 
#include <cstdio>
#include <termios.h>
#include <unistd.h>
#include <fcntl.h>

int main() 
{
    struct termios initial_settings, new_settings;
    
    fcntl(0, F_SETFL, fcntl(0, F_GETFL) | O_NONBLOCK);
    
    tcgetattr(0,&initial_settings);
    
    new_settings = initial_settings;
    new_settings.c_lflag &= ~ICANON;
    new_settings.c_lflag &= ~ECHO;
    new_settings.c_lflag &= ~ISIG;
    new_settings.c_cc[VMIN] = 0;
    new_settings.c_cc[VTIME] = 0;
        
    tcsetattr(0, TCSANOW, &new_settings);

    while ( true )
    {
        char input;
        
        // read single character from terminal
        int read_size = read(0, &input, 1);
        
        if ( read_size > 0 )
        {
            std::cout << "your input is " << input << std::endl;
            
            // type q to leave the loop
            if ( input == 'q')
            {
                break;
            }
        }
    }
    
    tcsetattr(0, TCSANOW, &initial_settings);

    return 0; 
}

Update Frequency

接下來,絕大部分的動作或是 RPG 遊戲,都有每隔一段時間就要自動更新一次畫面 (當然也包括遊戲邏輯) 的概念。差別是動作或即時戰略遊戲的更新速度是 100 ms ~ 200 ms (30 Hz ~ 60 Hz),我們這種古早的文字冒險遊戲 500 ms ~ 1 sec 更新一次就好。

要做到每隔一秒自動呼叫某個 update function,就需要有個能提供以 millisecond 為單位的時間的函數,這 C 有,C++ 也有。Google Keyword: “C++ get time in millisecond”,下面是 C++ 的寫法 (如果用 C 的 solution,code 會短很多,不過標題是 C++ 我們就寫 C++ )。


#include <iostream>
#include <chrono>

int main()
{
    std::chrono::high_resolution_clock::time_point pre_timestamp =
        std::chrono::high_resolution_clock::now();
        
    while( true )
    {
        std::chrono::high_resolution_clock::time_point now =
            std::chrono::high_resolution_clock::now();
            
        std::chrono::duration<double, std::milli> diff = now - pre_timestamp;

        if ( diff.count() > 1000 )
        {
            pre_timestamp = now;
            std::cout << "one second passed." << std::endl;
        }
    }
    
    return 0;
}

World, Room, Monster and Player

接下來,我們要思考的是怪物、玩家、房間、世界地圖這四者 (四個物件?) 的關係和架構問題。

  1. 世界是由很多房間組成,房間和房間有通道可以互通連結。
  2. 任何一個玩家和怪物,都一定存於唯一一個房間之下。

架構一

房間 (room) 資料儲存在世界 (world) 裡,怪物 (monster) 和玩家 (player) 的資料存在其所在的房間物件中。如下圖。

class room
{
    vector<player> players_;
    vector<monster> monsters_;
}

class world 
{
    vector<room> rooms_;
}

架構二

玩家和怪物資料存在世界物件下,將位置資訊用做標放在玩家和怪物的物件內,由世界物件自己轉換座標連結到房間物件 (if exist)。

class moveable
{
    int x_, y_;
}

class player : moveable
{
}

class monster : moveable
{
}

class room
{
}
class world
{
    vector<room> rooms_;
    vector<monster> monsters_;
    vector<player> players_;
}

架構三

同時使用上面二個架構,在世界物件內有玩家和怪物連結 (weak_ptr),在房間物件中有玩家和怪物的實體 (shared_ptr),利用儲存空間換取效率。

我們在作業中的 pseudo-code 使用的是架構二。架構二的實作最簡單,但是擴充性不佳,舉例來說,如果我們要從 2D 地圖變成 3D,或是說房間的大小變成不固定的,又或是讓出口連結變得可以跳躍,整個程式就得要重寫。而架構三是擴充性最好的,但是複雜度高,而且對小型的專案是種 over-enginnering。這種儲存空間 vs 效率,或是簡單易寫 vs 擴充性,都是設計時要考慮的。

我們使用架構二,然後套用第三章繼承練習,就可以完成下面的 moveable / player / monster 的實作。

Moveable

可以在不同房間移動,擁有座標 (x, y) 和生命力 (hp),可以戰鬥的物件。

class Moveable
{
public:
    const static int IDLE = 0;
    const static int NORTH = 1;
    const static int EAST = 2;
    const static int SOUTH = 3;
    const static int WEST = 4;
    
public:   
    Moveable( int hp, int x, int y )
    {
        hp_ = hp;
        x_ = x;
        y_ = y;
    }
    
    // always write a destructor, even it is empty
    ~Moveable() {}
    
    virtual int next_move() 
    {
        return Moveable::IDLE;
    }

    void move( int dir )
    {
        if ( dir == Moveable::NORTH )
            y_--;
        else if ( dir == Moveable::EAST )
            x_++;
        else if ( dir == Moveable::SOUTH )
            y_++;
        else if ( dir == Moveable::WEST )
            x_--;
    }
    
    void hp( int hp ) { hp_ = hp; }
    int  x()        { return x_; }
    int  y()        { return y_; }
    int  hp()       { return hp_; }

public:
    bool is_fighting()
    {
        if ( attacking_.expired() )
        {
            return false;
        }
        
        return true;
    }
    
protected:
    int x_, y_, hp_;    
    std::weak_ptr<Moveable> attacking_;
};

Monster, 複寫了 next_move 的 Moveable,移動的 AI 先寫成亂數,每 30 次 next_move 中有 4 次會提供東西南北其中一個方向。注意 constructor 的語法,這種語法可以呼叫上一層物件的 constructor。

class Monster : public Moveable
{
public:
    // new syntax, direct call parent constructor 
    Monster( int hp, int x, int y ) : Moveable( hp, x, y ) {} 
    ~Monster() {}
    
public:
    int next_move() override
    {
        int rand_num = rand() % 30; // 4/30 chance it might move
        if ( rand_num == Moveable::NORTH ||
             rand_num == Moveable::EAST ||
             rand_num == Moveable::SOUTH ||
             rand_num == Moveable::WEST )
        {
            return rand_num;
        }
        
        return Moveable::IDLE;
    }
};

Player, 可以接收與暫存外界傳進來的方向指令 (set_move),等待 next_move 呼叫時丟出去並清除暫存。

這邊有個寫遊戲要考慮的地方,當使用者在二次的更新之間,輸入了超過一個的指令該怎麼處裡? 有人是把所有的指令全部存下來,在接下來二個更新各處裡一個,有人是像我們這個範例只留最後一個。這和遊戲類型和硬體能力有關。

class Player : public Moveable
{    
public:
    Player( int hp, int x, int y ) : Moveable( hp, x, y )
    {
        next_move_ = Moveable::IDLE;
    }
    
    ~Player() {}
    
public:
    int next_move() override
    {
        int ret = next_move_;
        next_move_ = Moveable::IDLE;
        return ret;
    }
    
    void set_move( int move )
    {
        next_move_ = move;
    }

private:
    int next_move_;
};

Create an Infinite-Empty World and Put a Player in it

World, 裡面有 monster 和 player 與 rooms,我們讓 World 用 set_move 接收外界輸入的 “文字” 指令,轉化成方向傳給 player。在 tick 裡是處裡每次更新的邏輯。還有 desc( x, y ) 取得房間的描述字串 (std::string)。

class World
{
// singleton trick
public:
    static World& get_instance()
    {
        static World instance;
        return instance;
    }

public:
    World(const World& arg) = delete; // copy constructor
    World& operator=(const World& arg) = delete; // assignment operator
    
private:
    World()
    {
        player_ = std::make_shared<Player>( 100, 2, 2 );
        tick_count_ = 0;
        player_x_ = 0;
        player_y_ = 0;
    }

public:
    void set_move( char move )
    {
        if ( move == 'n' || move == 'N' )
        {
            player_.get()->set_move( Moveable::NORTH );
        }
        else if ( move == 'e' || move == 'E' )
        {
            player_.get()->set_move( Moveable::EAST );
        }
        else if ( move == 's' || move == 'S' )
        {
            player_.get()->set_move( Moveable::SOUTH );
        }
        else if ( move == 'w' || move == 'W' )
        {
            player_.get()->set_move( Moveable::WEST );
        }
    }        
    
    void tick()
    {
        int player_move;
        
        player_move = player_.get()->next_move();
        
        // check if the move is valid, if so, move him/her
        if ( player_move == Moveable::NORTH ||
            player_move == Moveable::EAST ||
            player_move == Moveable::SOUTH ||
            player_move == Moveable::WEST )
        {
            player_.get()->move( player_move );
        }
        
        // we shows the map information at first tick, or when player actually moved
        if ( tick_count_ == 0 || 
            ( player_x_ != player_.get()->x() || player_y_ != player_.get()->y() ))
        {
            // show map desc
            std::cout << desc( player_.get()->x(), player_.get()->y() ) << std::endl; player_x_ = player_.get()->x();
            player_y_ = player_.get()->y();
        }
        
        
        tick_count_ ++;
    }

private:
    std::string desc( int x, int y )
    {
        std::stringstream desc_str;
        desc_str << "(" << x << "," << y << ")";
        return desc_str.str();
    }
    
private:
    long long tick_count_;
    int player_x_, player_y_;
    std::shared_ptr<Player> player_;    
};

main function,每半秒 (500 ms) 呼叫 World::tick(),有輸入時,用 World::set_move 傳進去。

int main()
{       
    // get start time in ms
    std::chrono::high_resolution_clock::time_point pre_timestamp =
        std::chrono::high_resolution_clock::now();
        
    // remove the input prompt and set user input to non-blocking
    struct termios initial_settings, new_settings;
    
    fcntl(0, F_SETFL, fcntl(0, F_GETFL) | O_NONBLOCK);
    
    tcgetattr(0,&initial_settings);
    
    new_settings = initial_settings;
    new_settings.c_lflag &= ~ICANON;
    new_settings.c_lflag &= ~ECHO;
    new_settings.c_lflag &= ~ISIG;
    new_settings.c_cc[VMIN] = 0;
    new_settings.c_cc[VTIME] = 0;
        
    tcsetattr(0, TCSANOW, &new_settings);
    
    // main loop
    while( true )
    {
        char input;
        int read_size = read(0, &input, 1);
        
        // ctrl+c is 3
        if ( input == 'q' || input == 'Q' || input == (int)3 )
        {
            break;
        }
        
        std::chrono::high_resolution_clock::time_point now =
            std::chrono::high_resolution_clock::now();
            
        std::chrono::duration<double, std::milli> diff = now - pre_timestamp;

        // we want the world start moving whenever user has input, or tick expired
        if ( read_size == 1 || diff.count() > 500 )
        {
            // feed user input if any
            if ( read_size == 1 )
            {
                World::get_instance().set_move( input );
            }
            
            pre_timestamp = now;            
            World::get_instance().tick();            
        }
    }
    
    // restore the terminal setting
    tcsetattr(0, TCSANOW, &initial_settings);
    
    return 0;
}

到這裡,我們完成了可以在一個無限大的平面上走來走去的遊戲,裡面沒有怪物,所以沒有戰鬥。房間的邏輯也還沒完成。

Create a World with Rooms

因為程式越來越大了,下面就只寫出必要的修改而非完整的 code。

利用作業提供的 pseudo-code,我們直接用 array 來建立地圖資料,而不另做一個 room 的物件了。

class World
{
    World()
    {
        // convenient c++11 syntax
        map_ = 
        {
            1, 1, 1, 1, 1,
            1, 0, 0, 0, 1,
            1, 0, 0, 1, 1,
            1, 1, 0, 1, 1,
            1, 1, 1, 1, 1            
        };
        
        max_map_x_ = 5;
        max_map_y_ = 5;    
    }
    
    int max_map_x_, max_map_y_;
    std::vector map_;
};

再來,我們要在 World 做一個幫忙判斷: “從 (x, y) 座標往某個方向走一步,會有路可以走嗎?” 的函式。這也只是個簡單的加減運算,沒甚麼特別的。

class World
{
    bool check_map( int x, int y, int dir )
    {
        if ( dir == Moveable::NORTH )
            y--;
        else if ( dir == Moveable::EAST )
            x++;
        else if ( dir == Moveable::SOUTH )
            y++;
        else if ( dir == Moveable::WEST )
            x--;
        
        if ( x < 0 || y < 0 || x >= max_map_x_ || y >= max_map_y_ )
            return false;
        
        if ( map_.at( x + max_map_x_ * y ) != 0 )
            return false;
        
        return true;
    }
};

最後,我們要 tick() 裡面原本沒有任何判斷就移動玩家的那行,加個判斷式。

原本是

class World
{
    void tick()    
    {
        // ...
        player_.get()->move( player_move );
        // ...
    }
};

改成

class World
{
    void tick()    
    {
        // ...
        if ( check_map( player_.get()->x(), player_.get()->y(), player_move ))
        {
            player_.get()->move( player_move );
        }
        else
        {
            std::cout << "Ouch! No exit in that direction!" << std::endl;
            std::cout << desc( player_.get()->x(), player_.get()->y() ) << std::endl;
        } 
        // ... 
    }
};

當然描述房間的 desc() 也要改一下,讓它能顯示出口,不過這個太瑣碎了就不寫了。

Harmless Monsters who Wandering in the World

接下來我們建立幾個怪物,讓它們在世界遊蕩,但是先不要攻擊玩家,做個和平的世界先。:)

建立怪物


class World
{
    World()
    {       
        // create map
        // create player
        
        // create monster
        mobs_.push_back( std::make_shared<Monster>( 20, 1, 1 ) );
        mobs_.push_back( std::make_shared<Monster>( 20, 2, 2 ) );
    }
};

怪物的移動和顯示有三種情況

  1. 怪物和玩家在同一個房間,顯示房間資訊時要一並顯示怪物資訊出來。
  2. 怪物進入玩家房間
  3. 怪物離開玩家房間

怪物和玩家在同一個房間,顯示房間資訊時要一並顯示怪物資訊出來。

class World
{
    std::string desc( int x, int y )
    {
        // show room's information        
        // show monster's information
        for ( auto it = mobs_.begin(); it != mobs_.end(); it ++ )
        {
            if ( (*it).get()->x() == x && (*it).get()->y() == y )
            {
                desc_str << "One monster is in this room and looking at you." << std::endl;
            }
        }
        
        return desc_str.str();        
    }
};

怪物進入和離開玩家房間

class World
{
    void tick()
    {
        int player_move = player_.get()->next_move();
        int player_x = player_.get()->x();
        int player_y = player_.get()->y();
        
        // let move monster
        for ( auto it = mobs_.begin(); it != mobs_.end(); it ++ )
        {
            int mob_move = (*it).get()->next_move();
            int mob_x = (*it).get()->x();
            int mob_y = (*it).get()->y();
            
            if ( mob_move == Moveable::IDLE )
            {
                // no move
                continue;
            }
            
            if ( ! check_map( mob_x, mob_y, mob_move ))
            {
                // no exit in that direction
                continue;
            }
            
            // check if mobs leave out player's room
            if ( mob_x == player_x && mob_y == player_y )
            {
                std::cout << "Monster leaves to [dir:" << mob_move << "]" << std::endl; } // move! (*it).get()->move( mob_move );
            
            // check if mob move in player's room
            mob_x = (*it).get()->x();
            mob_y = (*it).get()->y();
            
            if ( mob_x == player_x && mob_y == player_y )
            {
                std::cout << "Monster comes from [dir:" << mob_move << "]" << std::endl;
            }
        }
        
        // handle player's action
    }
};

Monster: When Peace is not Good Enough

我們現在有地圖,有玩家,有遊蕩的怪物,最後剩下來的就只有戰鬥系統了。

我們先幫 Moveable 加上下面三個函式

  1. 設定目前攻擊的對象 (set attackee_)
  2. 確認自己是否還是活著 (hp > 0)
  3. 確認目前是否是戰鬥中 (is attackee_ set and alive)

class Moveable
{
public:
    void attack_to( std::shared_ptr attackee )
    {
        attackee_ = attackee;        
    }
    
    std::weak_ptr get_attackee()
    {
        return attackee_;
    }
    
    bool is_fighting()
    {
        if ( attackee_.expired() )
            return false;
        
        if ( ! attackee_.lock().get()->is_alive() )
            return false;
        
        return true;
    }
    
    bool is_alive()
    {
        if ( hp_ > 0 )
            return true;        
        return false;
    }
};

再來是再 World::tick 的最後加上戰鬥邏輯

  1. 確認所有的 monster 是否和玩家在同一房間,如果是的話開始攻擊玩家
  2. 玩家被攻擊時,如果沒有在戰鬥中,就打回去
  3. 計算玩家對怪物的傷害
  4. 計算怪物對玩家的傷害
class Moveable
{
    void tick()
    {
        // when mob is in the same room with player, the combat begins
        for ( auto it = mobs_.begin(); it != mobs_.end(); it ++ )
        {
            int mob_x = (*it).get()->x();
            int mob_y = (*it).get()->y();
            
            if ( ! (*it).get()->is_alive() )
            {
                continue;
            }

            if ( ! (*it).get()->is_fighting() && mob_x == player_x && mob_y == player_y )
            {
                //  fight!
                std::cout << "Monster starts attacking you!" << std::endl; (*it).get()->attack_to( player_ );
                
                // player fight back!
                if ( ! player_.get()->is_fighting() )
                {
                    std::cout << "You starts attacking monster!" << std::endl; player_.get()->attack_to( *it );
                }
            }
        }
        
        // calculate the damage that player attacks to monster
        if ( player_.get()->is_fighting() )
        {
            std::shared_ptr mob = player_.get()->get_attackee().lock();
            int mob_hp = mob.get()->hp() - 10;
            
            std::cout << "You attack monster for 10 damage, monster's hp is " << mob_hp << " now." << std::endl; mob.get()->hp( mob_hp );
            
            if ( mob_hp <= 0 ) { player_.get()->get_attackee().reset();
                std::cout << "Monster is dead" << std::endl;
            }
        }
        
        // monsters' turn to attack player
        for ( auto it = mobs_.begin(); it != mobs_.end(); it ++ )
        {
            // TODO: complete this part!
        }
    }    

};

大概就這樣囉。