The “RPG” homework in Chapter 3
第三章 (http://www.yhorng.com/blog/?p=226) 最後的作業是寫一個文字角色扮演遊戲,像這種規模較大的程式,我們要如何用 divide and conquer 來釐清問題、並思考程式架構呢?
我們認真地將這個 project 升級,當成一個真正的遊戲來寫吧!
首先,我們要從最上層的 UI、也就是玩家操作的方法和會看到的畫面開始思考起。因為是真的遊戲,所以:
- 我們希望玩家輸入某個按鍵時,能像用遊戲手把一樣,不要有回饋字出現在畫面上。
- 我們希望玩家輸入某個按鍵時,能像用遊戲手把一樣,不要像 std::cin 將程式卡住 (blocking)。
- 使用者輸入指令時,遊戲會有反應,但就算沒有輸入時,RPG 的世界依然會運轉 (除非你要提供 pause 功能)。
Non-Blocking / No-Prompt user input
一開始就遇到問題了,C++ 能用鍵盤模擬遊戲手把嗎? 遇到沒頭緒的事不要怕用 Google,如果不翻文件不爬網路,沒人能將這些瑣事全記在腦袋裡的。
non-blocking 的問題和 no-prompt 的問題各可以用下面的 keyword 找出答案。
- Google Keyword: ‘C++ nonblocking get char’
- 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
接下來,我們要思考的是怪物、玩家、房間、世界地圖這四者 (四個物件?) 的關係和架構問題。
- 世界是由很多房間組成,房間和房間有通道可以互通連結。
- 任何一個玩家和怪物,都一定存於唯一一個房間之下。
架構一
房間 (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 ) );
}
};
怪物的移動和顯示有三種情況
- 怪物和玩家在同一個房間,顯示房間資訊時要一並顯示怪物資訊出來。
- 怪物進入玩家房間
- 怪物離開玩家房間
怪物和玩家在同一個房間,顯示房間資訊時要一並顯示怪物資訊出來。
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 加上下面三個函式
- 設定目前攻擊的對象 (set attackee_)
- 確認自己是否還是活著 (hp > 0)
- 確認目前是否是戰鬥中 (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 的最後加上戰鬥邏輯
- 確認所有的 monster 是否和玩家在同一房間,如果是的話開始攻擊玩家
- 玩家被攻擊時,如果沒有在戰鬥中,就打回去
- 計算玩家對怪物的傷害
- 計算怪物對玩家的傷害
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!
}
}
};
大概就這樣囉。