life game的全名叫Conway's Game of Life,是一個英國數學家70年代在一個雜誌上發表的遊戲。著名的C++大佬、C++ Weekly視頻專欄的作者Jason Turner有一次在他的「表弟教我學rust」的直播中用這個遊戲完成了rust的入門。
所以這個遊戲其實非常適合新手學習一門語言、或者嘗試熟悉某個gui庫以及遊戲庫。很多初學編程的人都把這個當做一個練習。
可以認為,life game就是有一個稍微複雜一點的hello world。
那麼,life game到底是什麼?根據wiki上的描述:
生命遊戲是一個零玩家遊戲(就是不需要玩家)。它包括一個二維矩形世界,這個世界中的每個方格居住著一個活著的或死了的細胞。一個細胞在下一個時刻生死取決於相鄰八個方格中活著的或死了的細胞的數量。如果相鄰方格活著的細胞數量過多,這個細胞會因為資源匱乏而在下一個時刻死去;相反,如果周圍活細胞過少,這個細胞會因太孤單而死去。
具體的規則如下:
每個細胞有兩種狀態 - 存活或死亡,每個細胞與以自身為中心的周圍八個細胞產生互動(黑色為存活,白色為死亡)當前細胞為存活狀態時,如果周圍的存活細胞低於2個(不包含2個),該細胞變成死亡狀態。(模擬生命數量稀少)當前細胞為存活狀態時,如果周圍有2個或3個存活細胞,該細胞保持原樣。當前細胞為存活狀態時,如果周圍有超過3個存活細胞,該細胞變成死亡狀態。(模擬生命數量過多)當前細胞為死亡狀態時,如果周圍有3個存活細胞,該細胞變成存活狀態。(模擬繁殖)這個規則非常之簡單,它的有趣之處在於從看似無序的規則中包含一些穩定的狀態,充分體現了數學之美。廢話少說,馬上動手來實現一個看看。
最近在重新折騰rust,其實幾年前用它做過一些leetcode的題目,但是到現在已經忘了,所以相當於重新學,所以剛好可以來做這個life game的練習。但是我還是想用C++來先寫一遍,然後再「翻譯」成rust。
用Qt來做一個life gameC++的圖形庫非常之多,選什麼可以看心情。我這次選的是Qt,用Qt來做這種動畫非常簡單,表示每個細胞的矩形可以用QGraphicsRectItem對象,只要定時更新這些矩形的填充顏色即可。
用一個簡單的類來定義細胞所存在的二維矩形世界:
template<int WIDTH, int HEIGHT>
class World {
World(World const&) = delete;
World& operator=(World const&) = delete;
public:
World() { // 構造函數,隨機初始化每個細胞的狀態
// ...
}
// 更新全部細胞的下一個狀態
void Next() {
// ...
}
// 查詢某個格子的細胞是否活著
bool Alive(int x, int y) const {
return cells_[y][x];
}
private:
// 計算八個相鄰方向活著的細胞數量
int LiveNeighbours(int x, int y) const {
// ...
}
private:
// 每個細胞的狀態,只用兩種狀態,用bool類型
std::array<std::array<bool, WIDTH>, HEIGHT> cells_ = {};
};數據成員只有一個二維數組,每一幀調用一次Next()函數更新所有細胞的狀態,然後再調用Alive(x, y)查詢每個位置的狀態,用不同的顏色繪製出來即可。每個矩形的位置只要由數組下標就可以得到,其實在這個程序中,每個細胞的位置不需要改變。
上面每個函數的實現我都省略了,因為實現都很簡單,完整源碼我會上傳到github上。
用Qt creator創建一個工程,添加一個名字叫LifeCells的類,從QGraphicsView繼承:
class LifeCells : public QGraphicsView
{
Q_OBJECT
// 每個小矩形(其實是正方形)的寬度與高度
static const int CELL_SIZE = 20;
// 矩形的行數
static const int CELL_ROWS = 30;
// 矩形的列數
static const int CELL_COLS = 40;
public:
LifeCells(QWidget *parent = nullptr);
~LifeCells() override;
private slots:
void timeout();
private:
QGraphicsScene scene_;
QTimer* timer_; // 定時器,用來調用world的Next函數
// 表示每個細胞狀態的矩形
QGraphicsRectItem* world_[CELL_ROWS][CELL_COLS];
// World對象,保存所有細胞的數據
World<CELL_COLS, CELL_ROWS> data_;
private:
Ui::LifeCells *ui;
};在LifeCells的構造函數中初始化表示World的data_成員以及創建所有的矩形:
LifeCells::LifeCells(QWidget *parent) :
QGraphicsView(parent),
ui(new Ui::LifeCells)
{
ui->setupUi(this);
this->setScene(&scene_);
// 按照矩形的數量計算窗口的大小
this->resize(CELL_SIZE * CELL_COLS, CELL_SIZE * CELL_ROWS);
// 創建每個矩形對象
for(int row = 0; row < CELL_ROWS; ++row) {
for(int col = 0; col < CELL_COLS; ++col) {
auto p = new QGraphicsRectItem(
CELL_SIZE * col + 1,
CELL_SIZE * row + 1,
CELL_SIZE - 2,
CELL_SIZE - 2);
world_[row][col] = p;
scene_.addItem(p);
p->setBrush(data_.Alive(col, row) ? Qt::black : Qt::white);
}
}
// 創建定時器對象,每隔一定時間更新一次數據
timer_ = new QTimer(this);
connect(timer_, SIGNAL(timeout()), this, SLOT(timeout()));
timer_->start(200);
}更新每個矩形的顏色,就是循環獲取每個細胞的當前狀態,然後設置成不同的填充色:
void LifeCells::timeout()
{
data_.Next();
for(int row = 0; row < CELL_ROWS; ++row) {
for(int col = 0; col < CELL_COLS; ++col) {
world_[row][col]->setBrush(data_.Alive(col, row) ? Qt::black : Qt::white);
}
}
}至此,整個邏輯就完成了,當然new出來的矩形對象別忘了在析構函數中釋放,析構函數我這裡就省略了。
運行就可以看到漂亮的動畫。
因為C++非常熟悉,所以一下子就寫好了。現在可以開始用rust來寫一遍了。
用rust來做一個life gamerust中沒有成熟的ui庫,我這次選用了piston_window,它是一個簡單的遊戲框架的一部分。安裝rust非常簡單,一個命令然後出現提示的時候再按一個回車就裝好了,包括cargo。後面的其它步驟我儘量寫詳細一些,這樣即使從未用過rust的人也可以操作。
用cargo來創建一個程序,命令:
cargo new --bin rust_life然後rust_life目錄如下:
rust_life
├── Cargo.toml
└── src
└── main.rs後面涉及到命令包括:cargo build或者cargo run都是在rust_life中運行。
編輯Cargo.toml,在[dependencies]下面加上需要依賴的庫,如下:
[dependencies]
piston_window = "*"
rand = "*"之後運行cargo的時候會自動去下載所依賴的庫到本地,而且每個平臺的操作方法都一樣,這個對新手比C++要友好得多。
因為這個就是一個複雜一點的hello world而已,所以代碼量很少,所有代碼直接實現在一個文件中即可,即src/main.rs。
先看看piston_window怎麼用。因為我只要知道怎麼繪製矩形即可,它的教程上剛好就是用最簡單的代碼繪製了一個矩形,我只要照著這個改一下就行了。來看一下官方的這個例子:
extern crate piston_window;
use piston_window::*;
fn main() {
// 創建一個640*480大小的窗口
let mut window: PistonWindow =
WindowSettings::new("Hello Piston!", [640, 480])
.exit_on_esc(true).build().unwrap();
// 處理窗口的所有事件
while let Some(event) = window.next() {
window.draw_2d(&event, |context, graphics, _device| {
clear([1.0; 4], graphics); // 用背景色填充窗口
rectangle([1.0, 0.0, 0.0, 1.0], // 繪製一個紅色矩形
[0.0, 0.0, 100.0, 100.0],
context.transform,
graphics);
});
}
}我需要做的就是繪製N個矩形,每隔一段時間更新一下矩形的顏色信息。把之前的C++代碼改過來即可,所有代碼都寫在main.rs這一個文件中。定義數據結構及常量:
use rand::distributions::{Distribution, Uniform};
use piston_window::*;
// 代表細胞的矩形的長度和寬度
const CELL_SIZE: i32 = 20;
// 二維矩形的行數
const CELL_ROWS: i32 = 30;
// 二維矩形的列數
const CELL_COLS: i32 = 40;
// 活著的細胞的顏色,黑色
const ALIVE_COLOR: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
// 死亡細胞的顏色,我用了淺灰色,因為沒有畫邊框,白色的太不明顯
const DEAD_COLOR: [f32; 4] = [0.9, 0.9, 0.9, 1.0];
// 每個0.04秒更新一次所有細胞的狀態
const FRAME_LENGTH: f64 = 0.04;
// 表示二維矩形的World結構
struct World {
// 二維bool數組
cells : [[bool; CELL_COLS as usize]; CELL_ROWS as usize],
// 距離上一次更新狀態的時間,如果超過上面設定的0.04,則觸發一次狀態更新
time_elapsed: f64,
}rust的數組大小及下標都需要是usize類型,如果不是就要用as來顯式強轉,看起來有點囉嗦。
給World添加幾個函數:
impl World {
fn new() -> Self {
// 類似構造函數的功能
}
fn next(&mut self) {
// 更新每個細胞的狀態
}
fn draw_rects(&self, context: Context, g: &mut G2d) {
// 循環繪製全部矩形
}
fn update(&mut self, delta_time: f64) {
// 更新time_elapsed,如果超過設定的0.04,則調用update
}
}然後在main函數中這樣調用:
fn main() {
let mut window: PistonWindow =
WindowSettings::new("Game of life", [(CELL_SIZE * CELL_COLS) as u32, (CELL_SIZE * CELL_ROWS) as u32])
.exit_on_esc(true).build().unwrap();
let mut world = World::new(); // 創建world
while let Some(event) = window.next() {
window.draw_2d(&event, |context, graphics, _device| {
clear([1.0; 4], graphics);
world.draw_rects(context, graphics); // 繪製world中的細胞
});
// 距離上一次update的時間,需要用UpdateEvent來獲取
if let Some(ref args) = event.update_args() {
world.update(args.dt); // dt就是距離上一次update的時間
}
}
}前面提到,World有四個函數,先看new,用來創建一個新的World:
fn new() -> Self {
// 隨機數庫的用法,照著文檔搬過來的
let mut rng = rand::thread_rng();
let die = Uniform::from(0..2);
// 創建一個二維數組
let mut cells: [[bool; CELL_COLS as usize]; CELL_ROWS as usize] =
[[false; CELL_COLS as usize]; CELL_ROWS as usize];
// 給數組賦值
for row in 0..CELL_ROWS {
for col in 0..CELL_COLS {
let v = die.sample(&mut rng); // 生成隨機數
cells[row as usize][col as usize] = if v == 0 { false } else { true };
}
}
// 返回一個World
World {
cells,
time_elapsed: 0.0,
}update函數很簡單,就是根據一個時間增量判斷是否需要更新所有狀態:
fn update(&mut self, delta_time: f64) {
self.time_elapsed += delta_time;
if self.time_elapsed >= FRAME_LENGTH { // 時間間隔足夠了
self.time_elapsed = 0.0; // 置零
self.next(); // 更新狀態
}
}用於更新狀態的next可以按照之前的C++版本直接翻譯過來:
fn next(&mut self) {
// rust中的閉包,相當於C++的lambda函數
// 定義一個局部函數,用來計算每個細胞後者的鄰居數量
let alive_neighbours = |x, y| {
// 8個方向遍歷一下
let deltax: [i32; 8] = [0, 1, 1, 1, 0, -1, -1, -1];
let deltay: [i32; 8] = [-1, -1, 0, 1, 1, 1, 0, -1];
let mut ret = 0;
for i in 0..8usize {
let x2 = x + deltax[i];
let y2 = y + deltay[i];
if x2 >= CELL_COLS || x2 < 0 || y2 >= CELL_ROWS || y2 < 0 {
continue;
}
ret += if self.cells[y2 as usize][x2 as usize] { 1 } else { 0 };
}
ret
};
// 創建一個新的二維數組保存新的狀態
let mut tmp: [[bool; CELL_COLS as usize]; CELL_ROWS as usize] =
[[false; CELL_COLS as usize]; CELL_ROWS as usize];
for row in 0..CELL_ROWS {
for col in 0..CELL_COLS {
let nb = alive_neighbours(col, row);
if nb > 3 || nb < 2 {
tmp[row as usize][col as usize] = false;
} else if nb == 2 {
tmp[row as usize][col as usize] = self.cells[row as usize][col as usize];
} else { // nb = 3
tmp[row as usize][col as usize] = true;
}
}
}
// 用新的狀態替換掉老的狀態
self.cells = tmp;
}還有一個draw_rects函數,就是繪製所有矩形,在main函數中調用的,很簡單:
fn draw_rects(&self, context: Context, g: &mut G2d) {
for row in 0..CELL_ROWS {
for col in 0..CELL_COLS {
// 確定顏色
let color: [f32; 4] = if self.cells[row as usize][col as usize] { ALIVE_COLOR } else { DEAD_COLOR };
// 用rectange函數來繪製
rectangle(color,
[(col * CELL_SIZE) as f64 + 1.0,
(row * CELL_SIZE) as f64 + 1.0,
CELL_SIZE as f64 - 2.0,
CELL_SIZE as f64 - 2.0],
context.transform,
g);
}
}
}運行cargo run,經過一番編譯以後,rust版本的life game就跑起來了,效果和之前的C++版本很像。所有代碼加起來也就百行左右(去掉空行和注釋就不足百行了),所以確實是一個hello world級別的東西,適合作為入門之用。
反覆運行,會發現一些有趣的圖形,這就是life game的魅力所在。
本文源碼我依然會放在github/franktea/treehouse與文章同名的目錄中。
註:Jason Turner學習用rust寫生命遊戲的直播視頻標題叫「Jonathan Teaches Jason Rust!」,youtube上有。