你的位置:首页 > 信息动态 > 新闻中心
信息动态
联系我们

关于 cc3k-villain 作业的思考

2021-11-29 4:41:22

关于 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")