关于 cc3k-villain 作业的思考
之前面向对象课程设计完成这个作业时,一直没有总结自己的心得体会。今天借着回答同学问题的机会总结一下。
开始之前
授课老师要求用 C++ 实现,不过从另一方面来说,个人当时也只会这一种语言。
当时为了做这个程序,想了很多也看了很多。比如,作业要求最终的呈现形式是一个 CLI 界面的程序,为此还尝试去学习 ncurses,但是最后也不了了之了。
主要参考的还是在网上翻到的一些同学的作业。首先,从这个作业的措辞中可以看出,其并非来自国内大众高校。于是,笔者尝试在代码托管平台 GitHub 上查找相关的内容,发现了不少相关的项目,其中一些项目包含了这份作业的原始 PDF 文档,印象中是滑铁卢大学的 OOP 的作业,且时限只有 3 周左右。而笔者做了将近一个学期,不得不说很是惭愧。
看到了原始的作业文档,才发现流传到国内高校教师手中的内容是多么残破与不完整。下面姑且使用 assignment 来称呼这个作业。从代码托管平台上的项目可以看到,这份作业不止使用了一年,每年描述 assignment 的文档也不尽相同。国外这份 assignment 的内容十分详尽,描述了题目产生的背景、程序的具体要求、输入输出示范、程序设计过程中应该注意思考的地方等等。
程序的背景大概是这样的,有一款游戏 Chamber Crawler 3000,简称 CC3K。这份作业里面,反派希望翻身做主角,因此叫做 CC3K villain。文档中还为程序中的每一个要素给出了具体而详细的定义,同时还提到了可以尝试使用设计模式(Design Patterns)进行编程,比如策略模式和装饰模式。
由于担心到可能的版权问题,笔者不在这里直接放出 assignment 的 PDF 文件,有需要的同学可以去 GitHub 上查阅,比如 这个仓库 中的 PDF 文件。
他山之石
在代码托管平台上可以看到很多他人的实现的 CC3K Villain 项目,可以作为我们开始之前的参考。
有备无患
在开始编写代码之前,我们需要对这个项目建立一个整体的感知。虽然这只是一个小程序,但是相信很多同学和笔者一样,最开始拿到这个题目是几乎没有任何头绪,不知道该从何处下手。另一方面,事先有了充足的准备,在之后的代码编写过程中,就不容易出现捉襟见肘、拆东墙补西墙的问题。
用成语来讲的话,就是要做到“胸有成竹”。
笔者完成这个作业时,因为最开始没有做好充足的设计,因此在设计过程中经常出现需要修改之前设计的地方。而这些改动,往往意味着整个程序的其他地方都需要修改。随着程序的开发,代码越来越多,修改起来也就愈发复杂。
如果要从专业的角度出发,我们可能需要软件工程(Software Engineering)的知识来对这个项目进行分析和设计。考虑到从专业角度分析的复杂性与程式化,笔者接下来不会严格按照软件工程指导的设计流程,而是从偏感性认知的角度来进行分析。
从表象推测内在逻辑
我们可以根据现有的要求,来想象一下要实现的程序的样子。(不过,课程讲师可能也提供了可供参考的可执行程序)
程序运行伊始,要求玩家选择一种身份。之后,玩家以这种身份,在“楼层”上游荡;楼层中可能有怪物或者物品,玩家可以与怪物或者物品进行交互——即,程序接受用户输入的命令,按照命令执行攻击、道具使用或者移动。换句话说,程序(中的数据/实体)会响应用户的输入而发生变化。
这个游戏实际是对现实世界的一种简化。现实社会中,物体之间的交互无时无刻不在进行,而这个程序里各个实体的状态,是在用户进行操作之后,才进行更新。具体来讲,是用户执行了一种操作(如“攻击”“使用道具”“移动”等)之后,游戏中的其他实体才进行更新(如“攻击玩家”“位置移动”)。
如此可见,游戏的主体是一个“用户操作”“游戏更新”“等待用户操作”的循环。
在计算机中,我们要模拟现实世界的运作,可以将时间分成若干时刻,每个时刻有一个状态,并定义状态之间的转换规则。这样,下一刻的状态就可以由这一刻的状态推演而得。在 CC3K 这个作业中,两个时刻之间需要等待用户的输入。用户不进行操作,时间就不会开始流转。(不过在现实世界中,其他实体不会因为主角的停滞而停止相互之间的交互。)
不过,我们还要考虑程序的输出。上述过程无非是计算机中的数据依照算法进行更新,但用户更希望看到一个直观的表示,在这个作业里也就是一个由字符搭建出来的“地图”,“地图”上展示出了玩家的位置以及楼层上的各种实体和元素。
当我们把输出加上之后,游戏就变成了一个“显示游戏状态”“等待用户操作”“用户操作”“游戏更新”的循环。
对实体进行建模
有了上述的知识,我们就需要对程序中涉及到的实体进行建模,具体来说,就是用什么样的数据字段(field)来描述他们。
从面向对象的程序设计来讲,就是为这些实体建立对应的类。类中的数据成员声明,就对应着描述该类属性的字段;类的成员方法,定义了这个对象与外界(其他对象)的交互方式。
因此,我们首先需要观察一下程序中设计到了哪些实体。
最先想到的可能是角色和物品。对于角色,我们需要记录它的生命值、位置等信息,对于物品,我们需要记录它的属性(使用后的效果)、位置等信息。
而谈论到位置,就不得不说到地形。地形将会决定物品的生成和角色的移动。在 CC3K 里,地形就体现为房间和连接房间之间的通道。
接下来,我们需要考虑该如何存储这些信息。
首先考虑地形输出。我们程序目标输出是文本界面的,也就是说,在这个游戏里,地形可以视作由一个个毗邻的方形格子组成,每个格子可以是地板、墙或者通道。因此,从直观的角度来说,我们可以用计算机中的“二维数组”,来存储这 25 × 79 个格子。
之后,我们需要建立角色和地图之间的关系。换句话说,我们需要将角色显示在地图上。
读者可能会想,可以直接用一种符号来代表某种角色,存储在地形数据中。但是问题是,游戏中某一类型可能有若干实体,因此不能用简单的符号来一概而论。也就是,我们需要将地形上表示实体的标记和具体的对象关联起来。
有的同学这个时候就采取了这样一种方式,即定义了一个 Cell
类,而地形则由 25 × 79 个 Cell
对象组成,并以某种形式,将实体存储在这些 Cell
对象中,比如,在 Cell
对象中保留一个指针,如果不为空,则表示指向了一个具体的对象。
这样的方式,如果要输出地图,则只需要遍历一遍所有的 Cell
对象,(一边遍历一边输出)即可将整个地图轻松的显示出来(可以直接根据地形找到对应位置上的对象,从而)。而且,在进行实体之间邻接关系的查找时也很方便,只需要检查相邻的 Cell
对象即可。
这种思路很直接,实现起来也不难,但是存在一个需要注意的问题,即角色的改变(比如移动、生成或消失),要应用在地图上;反之,在地图上的改变,也要应用在角色上;如果不小心忘记了更新地图上的信息,就会造成错误。
因此,笔者设计时采用了另外一种方式,虽然也是将地形与对象分开存储,但是不是使用“指针”,而是使用“坐标”将二者联系起来。我们发现,在地形上选择一个坐标基准点,就可以坐标表示角色所在的位置了,即对象、地形之间的相对关系通过坐标联系在一起。这种做法和上一种相比,并不能通过数据在计算机内存中存储的相对位置来判断邻接关系(也就是不能直接得到某一位置地形上是否存在对象,抑或是该对象的具体信息),而是要通过与所有存在对象逐个比较坐标关系来确定。同理,在进行移动时,也需要先得到角色的坐标,再去查阅地形信息,以判断是否能够移动。
此外,这种方式存储的数据要显示出来,则不能如上一种方法,遍历一次即可得到结果,而是每访问到一个位置,都要去在角色数组中查找是否存在位于该坐标上的角色,如有,则输出对应符号,否则,则输出对应位置的地形。可以看到,这样的程序将会多执行不少指令。为此,我们可以引入一个“画布”的概念:先将地形绘制在“画布”上,之后,遍历所有实体,再将实体绘制在输出好的地形图上。
这种方法虽然麻烦,但从长远角度来看有一些好处。一是这种形式不受限于二维网格,二是为游戏数据的存储提供了一种思路。我们知道,每次程序运行,对象在内存中的物理位置不一定相同。因此,我们在记录游戏状态时,必须使用一种与程序运行无关的方式来记录地形与角色之间的关系。很容易想到的就是坐标表示法。
对过程进行建模
接下来,我们就要为这些实体设计方法。
在 CC3K 里,不同角色之间的相互作用不同。即,某一角色受到不同角色的攻击,有不尽相同的应对方式。
根据面向对象的学习,我们首先可以想到可以设计不同的类型,并且为每个类型定义针对不同类型的方法。而从方便管理的角度,我们又需要将这些类型从一个基类派生出来。那么怎么从一个基类指针判定出其所属的类型呢?事实上,如果只是实现应对不同类型时运行不同策略,我们可以使用“访问者模式(Visitor Pattern)”。
参考链接:https://stackoverflow.com/questions/17678913/know-the-class-of-a-subclass-in-c
使用 C++ 的虚方法可以实现通过基类指针访问到具体类的对应方法。下面的例子是一个访问者模式的示例。
例子中
AnimalHitter
打动物的行为仅为示例,没有动物在演示过程中受到伤害。
AnimalHitter.hpp
#ifndef ANIMAL_HITTER_HPP
#define ANIMAL_HITTER_HPP
class Animal;
class Cat;
class Dog;
class AnimalHitter {
public:
virtual void hit(Cat *) = 0; // or: void hitCat(Animal *);
virtual void hit(Dog *) = 0; // or: void hitDog(Animal *);
virtual void be_biten(Animal * p) = 0;
virtual ~AnimalHitter() = default;
};
class Human : public AnimalHitter {
public:
void hit(Cat * p) override;
void hit(Dog * p) override;
void be_biten(Animal * p) override;
~Human() = default;
};
// 由于 Cat 和 Dog 还未具体定义,属于“不完整类型”,
// 因此这里不能直接在 Human 类里 inline 方式定义函数,
// 需要在类外定义函数
#endif // ANIMAL_HITTER_HPP
Animal.hpp
#ifndef ANIMAL_HPP
#define ANIMAL_HPP
#include "AnimalHitter.hpp"
#include <iostream>
class Animal {
public:
virtual void be_hit_by(AnimalHitter *) = 0;
virtual void bite(Human *) = 0;
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void be_hit_by(AnimalHitter * hitter) override {
hitter->hit(this); // match AnimalHitter::hit(Dog*)
// or call AnimalHitter::hitDog(Animal *)
std::cout << "Dog: Woof, woof!" << std::endl;
}
void bite(Human * human) override {
std::cout << "Dog: [bite human]" << std::endl;
}
~Dog() = default;
};
class Cat : public Animal {
public:
void be_hit_by(AnimalHitter * hitter) override {
hitter->hit(this); // match AnimalHitter::hit(Cat*)
// or call AnimalHitter::hitCat(Animal *)
std::cout << "Cat: Meow!" << std::endl;
}
void bite(Human * human) override {
std::cout << "Cat: [bite human]" << std::endl;
}
~Cat() = default;
};
#endif // ANIMAL_HPP
AnimalHitter.cpp
#include "AnimalHitter.hpp"
#include "Animal.hpp"
// 实际上只需要引用一个 Animal.hpp 头文件即可
void Human::hit(Cat * cat) {
std::cout << "Human: [hit cat] I hit a cat!" << std::endl;
}
void Human::hit(Dog * dog) {
std::cout << "Human: [hit dog] I hit a dog!" << std::endl;
}
void Human::be_biten(Animal * animal) {
animal->bite(this);
std::cout << "Human: Ouch!" << std::endl;
}
demo.cpp
#include "Animal.hpp"
#include "AnimalHitter.hpp"
int main() {
Animal* animals[2] = { new Cat(), new Dog() };
AnimalHitter* human = new Human();
for (auto animal : animals) {
std::cout << "---------\n";
std::cout << "The human is going to hit an animal.\n";
animal->be_hit_by(human);
std::cout << "An animal is going to bite the human.\n";
human->be_biten(animal);
std::cout << "---------" << std::endl;
}
for (auto p : animals) { delete p; }
delete human;
}
CMakeLists.txt
add_executable(demo
"Animal.hpp" "AnimalHitter.hpp" "AnimalHitter.cpp" "demo.cpp")
如上,即可如法编写游戏中角色之间的相互关系。除此之外,也可采取“通过在类型添加标签字段来辨别类型”的方式。
之后,是一个简单的游戏框架示范。
Player.hpp
#ifndef PLAYER_HPP
#define PLAYER_HPP
struct Point {
int row;
int col;
};
class Player {
private:
Point pos;
public:
Player() {}
auto set_pos(Point pos_) {
this->pos = pos_;
}
auto get_pos() { return this->pos; }
auto get_denote() {
return '@';
}
~Player() {}
};
#endif
Floor.hpp
#ifndef FLOOR_HPP
#define FLOOR_HPP
#include "Player.hpp"
#include <string>
#include <vector>
#include <iostream>
class Floor {
private:
static const int width = 10;
static const int height = 5;
std::vector<std::string> terrian;
// 目前只有一个主角
Player pc;
// 如果有多个其他角色,可使用容器存储,如
// std::vector<Character> characters;
public:
Floor();
~Floor() = default;
bool is_avaliable_pos(Point pos) {
return terrian[pos.row][pos.col] == '.';
}
void move_player(Point new_pos) {
pc.set_pos(new_pos);
}
auto get_player_pos() {
return pc.get_pos();
}
void print_to(std::ostream &os);
};
#endif // FLOOR_HPP
Floor.cpp
#include "Floor.hpp"
Floor::Floor() {
// 生成房间地形的函数
terrian = {
"|--------|",
"|........|",
"|........|",
"|........|",
"|--------|"};
// 设置玩家位置
pc.set_pos({3, 4});
}
void Floor::print_to(std::ostream & os) {
// 输出地图
char canvas[height][width]; // “画布”,起到缓冲区的作用
// 先绘制地形
for (int i = 0; i < height; ++i) {
for (int j = 0; j < width; ++j) {
canvas[i][j] = terrian[i][j];
}
}
os << "\033[H\033[J"; // 将终端的光标移动到左上方再输出,效果约等于清屏
// 将角色画在画布上
canvas[pc.get_pos().row][pc.get_pos().col] = pc.get_denote();
// 如果有多个角色,这里便是使用循环遍历角色列表
// 将画布上的内容输出
for (int i = 0; i < height; ++i) {
for (int j = 0; j < width; ++j) {
os << canvas[i][j];
}
os << "\n";
}
// 在进行后续操作前 flush 一下,保证输出得到及时显示
os << std::flush;
}
rogue-mini.cpp
#include <iostream>
#include <string>
#include "Floor.hpp"
int main() {
// 总控制流程
Floor floor;
std::string input;
while (true) {
// 显示当前状态
floor.print_to(std::cout);
// 等待用户输入
std::getline(std::cin, input);
if (input == "#") break;
// 处理用户输入
auto newPos = floor.get_player_pos();
if (input == "w") { newPos.row -= 1; }
if (input == "a") { newPos.col -= 1; }
if (input == "s") { newPos.row += 1; }
if (input == "d") { newPos.col += 1; }
if (floor.is_avaliable_pos(newPos)) {
floor.move_player(newPos);
}
// 游戏状态更新,如房间中实体的移动
}
}
可以将代码保存为对应的文件后,采用下边的 CMakeLists.txt 配置项目以运行。
add_executable(demo
"Animal.hpp" "AnimalHitter.hpp" "AnimalHitter.cpp" "demo.cpp")
add_executable(rogue-mini
"Player.hpp" "Floor.hpp" "Floor.cpp" "rogue-mini.cpp")