loading...马上就出来rua!

基于OpenGL的Windowkill游戏复刻


1.系统亮点和操作说明

1.1 总体介绍

本游戏是一个类吸血鬼幸存者Rougelike生存射击游戏,即结合了Rougelike(随机地图、永久死亡等)元素与生存、战斗玩法。游戏过程中,玩家需要击杀不断涌现的敌人,通过收集经验值和升级来增强自己的能力,并寻找合适的方法来对抗越来越强大的敌人。游戏的设计理念在于为玩家提供简单上手的游戏体验和快速通关的可能性,以此来满足现代玩家对于休闲和娱乐的需求。

而本游戏则以窗体限制视角大小,窗体会随时间不断缩小,玩家需要在通过射击扩大窗体的同时迎击不断生成的敌人,再通过敌人掉落的金币在商店中升级自己的属性。游戏总共包括三种敌人和一个boss,商店中有八种属性加成可选,比较完备。

1.2系统亮点

在传统的类吸血鬼幸存者这一游戏类型的基础上,将视角限制依托在窗体的大小变化上,并依靠子弹射击墙壁扩大窗口,要求玩家在攻击和扩大视野两个操作中做出权衡,从而增加游戏性与可玩性。

1.3实现方式

游戏的实现全部使用c++语言,OpenGL的库中只用了glut.h,此外还用了c++自带的std中的time.h(用来重置随机数)、math.h(用来进行数学运算)、vector(用来存储数据)、iostream(用来输出测试)、window.hstdlib.h、utility、stdio.h、string(用来实现汉字显示)、functional(用来传入函数优化代码结构)等库。

1.4 操作说明

在游戏中,按下wasd操作玩家(小白球)移动,按住鼠标发射子弹,方向沿玩家到鼠标的方向。(注:图中红色标记为后期加注,非游戏内内容)

img img

按下空格进入商店,通过鼠标点击选择购买使角色强化,再次按下空格退出商店。


2.设计的目的与意义

2.1 设计目的

本游戏旨在提供一个充满挑战与乐趣的Rougelike生存射击游戏体验,让玩家在游戏中体验到紧张刺激的生存战斗和策略选择。通过射击和生存机制的结合,游戏旨在培养玩家的反应能力、策略规划和决策能力。同时,游戏的设计理念是简单上手,快速通关,满足现代玩家对于休闲和娱乐的需求。

2.2 设计意义:

2.2.1 培养玩家的反应能力和决策能力:游戏中的战斗和生存机制要求玩家在紧张的环境中快速做出决策,培养了玩家的反应能力和决策能力。

2.2.2提供休闲和娱乐:游戏旨在为玩家提供一个轻松愉悦的游戏体验,让玩家在休闲时刻享受游戏的乐趣。

2.2.3激发玩家的创造力:游戏的设计理念和机制让玩家在游戏中不断尝试新的策略和方法,激发了玩家的创造力和想象力。


3.总体方案设计

3.1 功能模块的划分及主要实现功能的原理

3.1.1 游戏架构:

游戏由11个文件组成,包括总体1.逻辑与程序入口final、2.所有实体的基类entity、3.储存玩家资料的player、4.储存敌人资料的敌人基类enemy、5.敌人派生类enemyb、6.敌人派生类2enemyc、7.敌人派生类boss、8.子弹控制bullet、9.金币控制money、10.粒子系统particle和11.商店控制store

3.1.2 游戏引擎:

在游戏循环管理上,我进行了统一管理操作。对于所有可在屏幕上显示的实体,我均使它们继承自entity类,并在创建时加入链表统一管理。我用一个glutTimerFunc统筹了所有的游戏循环的实现与图形的绘制,方便了后续的代码编写和修改。

在图形渲染(使用OpenGL)上,由于实际游戏存在大于窗口展示画面,而glut的绘制是以窗口坐下为原点的,我将坐标简单分为实际坐标(物体在实际游戏中,即以屏幕左下角为原点的坐标系中所属坐标)和画面坐标(物体在以窗口左下角为原点的坐标系中所属的坐标)两种,在存储与计算时运用实际坐标,并在绘制过程中转换成画面坐标。绘制采用每帧重绘的方式,时间为1ms每帧。

在输入处理(键盘和鼠标)上,我则使用了glut自带的键鼠输入函数实现。

3.1.3 游戏玩法:

玩家移动和控制和射击机制由3.1中输入实现。

敌人有最基础的enemy类(追着角色跑)推广到enemyb(每过一段时间冲向角色)和enemyc(向角色发射子弹),最后推广至boss(发射激光,造成一定伤害后随机移动,以强化推墙机制)生成时,敌人会随机生成在画面外并追击玩家。敌人的最大生成数量会随时间增加而增加,生成间隔则会随时间减小,从而增加游戏难度,以适应属性不断增强的玩家。

商店由输入系统检测空格按键打开,同时改变游戏模式使游戏画面暂停,并在商店窗口中显示ui。

金币掉落数由随机数及玩家金币掉落率属性决定。

3.1.4 用户界面:

窗口大小与位置由全局循环实时改变,ui显示与entity显示方式一致。

3.2 系统流程图

img

3.3 系统运行的环境

​ c++、glut环境下


4.各个功能模块的主要实现程序

4.1 游戏引擎

4.1.1 游戏循环管理

我设置了一个glut定时器(glutTimerFunc)作为整个游戏循环的驱动,并在函数最后重新调用此方法,以实现系统循环,每次循环调用的时间为1ms。

void update(int v){
    //...
    glutTimerFunc(tickTime, update, 1);
}

此函数中包含了图像重绘,调整窗口大小,敌人生成与基本实体的内部循环等游戏核心逻辑部分。

对于基本实体的内部循环,我创建了一个基类entity,所有需要在屏幕上显示的实体都必须继承自这个entity类。在类的构造函数中,我将此类实例加入entities链表。这个链表中包含所有游戏实体,方便遍历执行实体的更新与重绘操作。

class entity{
public:
   pair pos;
   float hp;
   float moveSpeed;
   int ID;
   EntityType type;
   bool toDelete = false;
   float checkRadius;
   void virtual Draw();
   virtual void Draw(pair pos);
   virtual void Update();
   virtual void PhysicalCheck();
   virtual void Check(entity* other);
   virtual void OnCollispe(entity* other);
   entity(){
       ID = entities.size();
       entities.push_back(this);
       type = T_Untagged;
   }
};

我将在4.2**游戏玩法中详细介绍这个类。在循环这一过程中,我会遍历entities这个类并逐一调用其update函数以实现基本实体的内部循环。

for (int i = 0; i < entities.size(); i++)
{
   entities[i]->Update();
   if(entities[i]->toDelete)
   {
       entity *e = entities[i];
       entities.erase(entities.begin() + i);

       i--;
   }
}

4.1.2 图形渲染(使用OpenGL)

在本游戏中,图形渲染依赖于游戏循环与刚才说的entity类。在循环中,我每帧调用glut重绘函数,并在myDisplay中清屏后遍历所有实体进行绘制。

void myDisplay(){
    if(GameState == Play || GameState == Over){
        glClear(GL_COLOR_BUFFER_BIT);
        for (int i = 0; i < entities.size(); i++)
        {
            if(entities[i]->type != T_StoreUI){
                entities[i]->Draw();
            }
        }
        glutSwapBuffers();
    }
    else if (GameState == Store)
    {
        glutSetWindow(storeWinId);
        glClear(GL_COLOR_BUFFER_BIT);
        for (int i = 0; i < uis.size(); i++)
        {
            uis[i]->Draw();
        }
        glutSwapBuffers();
    }
}

而在entity类中,我利用Draw()函数作为中介,在函数中进行内部存储坐标(世界坐标)到屏幕坐标的转换,并传入虚函数Draw(pair<float, float> pos)中方便子类直接在屏幕坐标的基础上进行个性化绘制。其中if保证了图像在其他窗口中也能按照世界坐标进行绘制。

    void virtual Draw(){
        pair screenPos = world2WindowPos(pos);
        if(glutGetWindow() == bossWinId){
            screenPos = world2WindowPos(pos, {bossWindowPos[0], bossWindowPos[1]}, {bossWindowSize[0], bossWindowSize[1]});
            //cout << screenPos.first << screenPos.second;
        }
        Draw(screenPos);
    }
    virtual void Draw(pair pos) {}

关于坐标转换,世界坐标以屏幕左下角为原点,而画面坐标以窗口右下角为原。我维护了两个窗口变量,分别记录了窗口的左上角的位置(相对于屏幕左上角)和窗口的大小,故画面坐标下的x即为世界坐标下的x减去窗口位置的x值,而画面坐标下的y即为世界坐标下的y减去窗口下方到屏幕下方的距离,即屏幕y大小减窗口y坐标减窗口y大小。若要反向求解只要将这个过程逆过来即可。

考虑到本游戏为多窗口游戏,在函数中,我将窗口坐标与大小设为可以传入的参数,以方便物体坐标在主游戏窗口以外的窗口中的计算。

pair world2WindowPos(pair pos, pair winpos = {windowPos[0], windowPos[1]}, pair winsize = {windowSize[0], windowSize[1]}){
    pos.first -= winpos.first;
    pos.second = pos.second - (screenSize[1] - winpos.second - winsize.second);
    return pos;
}

pair window2WorldPos(pair pos, pair winpos = {windowPos[0], windowPos[1]}, pair winsize = {windowSize[0], windowSize[1]}){
    pos.first += winpos.first;
    pos.second = pos.second + (screenSize[1] - winpos.second - winsize.second);
    return pos;
}

4.1.3 物理检测

物理检测同样依赖于entity类。我在把所有实体的碰撞器模拟为一个圆的基础上实现碰撞。实体类中,我创建了Check虚方法定义该物体与其他物体的碰撞条件以方便子类个性化碰撞。而在基础的Check函数中,我将对比实体的物理检测半径与待检测实体的物理检测半径之和与它们之间的距离之差大小。如果检测半径之和小于距离则视为碰撞并调用中介方法OnCollispe

在距离检测上,我写了Distance方法计算(根号下x差的平方加y差的平方)。

float Distance(pair a, pair b){
    return sqrt(pow(a.first - b.first, 2) + pow(a.second - b.second, 2));
}

PhysicalCheck()方法中,我将遍历所有实体并通过Check函数检测碰撞。而OnCollispe函数则作为子类调用的接口,在检测到碰撞后将碰撞到的对象传递进去。

    virtual void PhysicalCheck(){
        for (auto &&en : entities)
        {
            if(en != this)
                Check(en);
        }
    }
    virtual void Check(entity* other){
        if(Distance(pos, other->pos) <= checkRadius + other->checkRadius){
            OnCollispe(other);
        }
    }
    virtual void OnCollispe(entity* other){

    }

在调用时,要求手动将PhysicalCheck写在Update中,只对需要开启物理碰撞检测的实体开启碰撞,以达到减少运算的目的。例如:

Player::Update(){
    PhysicalCheck();
    ...
}

4.1.4 输入处理(键盘和鼠标)

在输入上,我用了glut自带的输入方法。

    glutKeyboardFunc(onKeyPress_Main);
    glutKeyboardUpFunc(onKeyUp);
    glutMouseFunc(mouseFunc_Main);
    glutMotionFunc(OnMouseMove);
    glutPassiveMotionFunc(OnMouseMove);
    glutIgnoreKeyRepeat(1);

对于键盘,由于glutKeyboardFunc中按键按下尤其是长按时往往只能获取到单个按键(后按下的按键)的状态,故我采用了glutIgnoreKeyRepeat来取消glut对长按检测的支持,并加入了glutKeyboardUpFunc检测按键抬起。我创建了四个变量表示按键的按下与抬起状态,并在glutKeyboardFuncglutKeyboardUpFunc中赋值,以实现长按检测对多按键长按的支持。

#pragma region 键鼠按键状态
bool W_pressing;
bool A_pressing;
bool S_pressing;
bool D_pressing;

bool mouse_Pressing;
pair mousePos;
pair mousePos_Store;
#pragma endregion

void onKeyPress_Main(unsigned char key, int x, int y){
    //GameState = Play;
    if(key == 'a'){
        //MoveWindow(Left, 5);
        A_pressing = true;
    }
    if(key == 'd'){
        //MoveWindow(Right, 5);
        D_pressing = true;
    }
    if(key == 'w'){
        //MoveWindow(Up, 5);
        W_pressing = true;
    }
    if(key == 's'){
        //MoveWindow(Down, 5);
        S_pressing = true;
    }
    ...
}

onKeyUp中同理检测按键并将对应变量赋值成false)

而对于鼠标,我采用了类似的方法创建鼠标状态变量并在mouseFunc方法中对其进行赋值。额外的,我还创建了mousePos变量获取鼠标的位置以方便实现其他功能时调用。

img

4.2 游戏玩法:

4.2.1 entity类

作为所有需显示实体的基类,该类中包含了所有需要的接口方法与属性。(见4.1.1中图)

pos中保存实体的世界坐标;hp保存实体的生命值;moveSpeed保存了实体的运动速度;ID保存了实体在entity数组中的位置;type保存了实体的类别;toDelete作为中介控制了实体的销毁。checkRadiius中则保存了实体的碰撞半径。

所有实体都需要指定一种类别(如不指定,则在基类构造函数中赋值为T_Untagged,以方便查询实体类别。

enum EntityType
{
    T_Player,
    T_Bullet,
    T_Enemy,
    T_Money,
    T_StoreUI,
    T_Untagged
};

关于删除实体,见4.1.1中图,我在需要删除实体时将其toDelete标记为true,并在主循环中统一删除,防止循环迭代过程中因为链表中节点变化产生错误或者漏计算。

4.2.2 玩家移动和控制

在绘制上,利用虚函数Draw获取屏幕坐标并通过课程中学到的八点画圆发绘制。

void Draw(pair screenPos){
        glColor3f(1, 1, 1);
        if(isInvicinble)    glColor3f(1, 0.8, 1);

        glPointSize(3);
        //cout << screenPos.first << " " << screenPos.second << " ";
        circle(screenPos.first, screenPos.second, checkRadius);

        DrawUI();
    }

void cirpot(int x0, int y0, int x, int y){
    glBegin(GL_POINTS);
        glVertex2i(x0 + x, y0 + y);
        glVertex2i(x0 + y, y0 + x);
        glVertex2i(x0 + x, y0 - y);
        glVertex2i(x0 + y, y0 - x);
        glVertex2i(x0 - x, y0 + y);
        glVertex2i(x0 - y, y0 + x);
        glVertex2i(x0 - x, y0 - y);
        glVertex2i(x0 - y, y0 - x);
    glEnd();
}

玩家控制基于输入系统中创建的变量。这些变量决定玩家在x轴与y轴上的方向。注释内容为我原本想以加速度的方式给玩家一定的操作惯性,实际尝试后发现操作效果没有平动效果好,故予以删除。

    void Move(){
        //cout << pos.first << " " << pos.second << " ";
        if(A_pressing){
            //pos.first -= tickTime * moveSpeed;
            direction.first = -1; // + direction.first * motivation; //direction.first -= accelerate;
        }
        else if(D_pressing){
            //pos.first += tickTime * moveSpeed;
            direction.first = 1;// + direction.first * motivation;
           // direction.first += accelerate;
        }else{
            direction.first = 0;
        }
        if(W_pressing){
            //pos.second += tickTime * moveSpeed;
            direction.second = 1; // + direction.second * motivation; // direction.second -= accelerate;
        }
        else if(S_pressing){
            //pos.second -= tickTime * moveSpeed;
            direction.second = -1; // + direction.second * motivation; //direction.second += accelerate;
        }else
        {
            direction.second = 0;
        }
    }

在移动时,我只需改变玩家的坐标,便能改变其在绘制时的位置。我将玩家的x坐标加上单位化后x轴上的方向乘以速度并乘上每次循环的时间加以矫正。

pos.first += Normalize(direction).first * tickTime * moveSpeed;
pos.second += Normalize(direction).second * tickTime * moveSpeed;

在单位化上,我写了函数Normalize,返回一个与传入向量同方向且大小为1的向量。

pair Normalize(pair vec){
    if(vec.first == 0 && vec.second == 0)
        return vec;
    float first = vec.first / sqrt(pow(vec.first, 2) + pow(vec.second, 2));
    float second = vec.second / sqrt(pow(vec.first, 2) + pow(vec.second, 2));
    return {first, second};
}

改变完坐标后,还需检测玩家是否超出窗口范围,如果超出,则将玩家推回窗口内,并加上一定的offset,使玩家不会卡在屏幕边缘。

        if(pos.first < windowPos[0])
            pos.first = windowPos[0] + 5;
        else if (pos.first > windowPos[0] + windowSize[0])
        {
            pos.first = windowPos[0] + windowSize[0] - 5;
        }
        
        if(pos.second > screenSize[1] - windowPos[1]) 
            pos.second = screenSize[1] - windowPos[1] - 5;
        else if (pos.second < screenSize[1] - (windowPos[1] + windowSize[1]))
        {
            pos.second = screenSize[1] - (windowPos[1] + windowSize[1]) + 5;
        }

img

最后,在Update中调用Move函数即可实现玩家的移动控制。

4.2.3 射击机制

射击由player类中的Shoot函数与bullet类实现。首先,在player类中,我在Update函数中循环调用Shoot函数。当鼠标状态为按下并且可发射子弹时生成一个子弹实例,传入存在player中的属性参数,并将可发射子弹设为false,设置glut计时器调用静态函数enableShoot使在冷却时间过后可发射子弹重新回到true,为下一次发射做准备。

    void Shoot(){
        if(mouse_Pressing && shootable){
            //cout << "shoot";
            pair worldMousePos = window2WorldPos(mousePos);
            //cout <<"{player "<< mousePos.first << " " << mousePos.second << "}";
            Bullet *b = new Bullet(this, pos.first, pos.second, Normalize({worldMousePos.first - pos.first, worldMousePos.second - pos.second}), 6, pushPower, strength, enemyPush);
            //cout << b->pos.first << " " << b->pos.second << "   ";

            glutTimerFunc(shootCD * 1000, enableShoot, ID);
            shootable = false;
        }
    }

static void enableShoot(int a){
    player->shootable = true;
}

​ 而在bullet类中,还是使用虚函数Draw,绘制点状子弹。

    void Draw(pair screenPos){
        glColor3f(1, 1, 1);
        glPointSize(10);
        glBegin(GL_POINTS);
        glVertex2f(screenPos.first, screenPos.second);
        glEnd();
    }

类中属性分别存储子弹的前进方向,对窗口的推进力,对敌人的伤害,对敌人的击退力与子弹的所有者。属性均由构造函数传入赋值。此外,构造函数中还规定了子弹的类型。

class Bullet : public entity{
public:
    pair direction;
    float windowPushPower;
    float str;
    float enemyPushPower;
    entity *owner;
    
    Bullet(entity* owner, int x, int y, pair dir, float shootSpeed, float pushPower, float strength, float enemyPush){
        this->owner = owner;
        pos.first = x;
        pos.second = y;
        direction = dir;
        type = T_Bullet;
        moveSpeed = shootSpeed;
        windowPushPower = pushPower;
        checkRadius = 4;
        str = strength;
        enemyPushPower = enemyPush;
    }
}

在移动方面,通过在Update函数中循环调用Move函数实现移动。与玩家移动相似的,函数将坐标加上单位化后的方向与速度和时间偏差量的乘积以实现坐标变化。并检测是否撞到窗口,如果撞到,施加力并调用Hit函数。

    void virtual Move(){
        pos.first += Normalize(direction).first * tickTime * moveSpeed;
        pos.second += Normalize(direction).second * tickTime * moveSpeed;
        if(pos.first < windowPos[0]){
            MoveWindow(Left, windowPushPower);
            Hit();
        }else if (pos.first > windowPos[0] + windowSize[0])
        {
            MoveWindow(Right, windowPushPower);
            Hit();
        }
        if(screenSize[1] - pos.second < windowPos[1]){
            MoveWindow(Up, windowPushPower);
            Hit();
        }else if (screenSize[1] - pos.second > windowPos[1] + windowSize[1])
        {
            MoveWindow(Down, windowPushPower);
            Hit();
        }
    }

在Hit函数中,我们将创建粒子效果并将实体的toDelete标记为true,等待在主循环中被删除。

    void virtual Hit(){
        CreateParticle();
        toDelete = true;
    }

img

对敌人的碰撞则写在了enemy类中,将在4.2.4中介绍。

4.2.4 敌人

本游戏中一共有三种敌人和一个boss,将在本点中详细介绍。关于敌人的生成,idleTime为没有的敌人生成的时间,会在Update中累加,当有敌人生成后会归零并继续累加。当它大于下一个敌人的生成时间后即进行生成。下次生成时间会随着游戏时间的增加变短同时有3-4s的随机浮动,以增强游戏性。

生成过程中,敌人只会生成在窗口视野范围外。先规定敌人生成处对于窗口的方向,再相应的用随机数随机出敌人的坐标。接着决定敌人的种类。当不满足boss时间的条件时,有50%概率生成敌人a,30%概率生成敌人b,20%概率生成敌人c。根据随机数确定后将坐标传入相应的敌人构造函数则为一次生成成功。

    #pragma region 敌人生成
        if(idleTime > nextEnemyTime && enemyNum < maxEneNum || enemyNum == 0){
            //cout << "enemy";
            idleTime = 0;
            
            nextEnemyTime = rand() % (int)(_nextEnemyTime) + 1.5;
            enemyNum++;

            MoveDirection dir = static_cast(rand() % 4);
            float x, y;
            if(dir == Left){
                x = windowPos[0] - rand() % 100;
                y = rand() % (int)screenSize[1];
            }
            if(dir == Right){
                x = windowPos[0] + windowSize[0] + rand() % 100;
                y = rand() % (int)screenSize[1];
            }
            if(dir == Up){
                y = screenSize[1] - (windowPos[1] - rand() % 100);
                x = rand() % (int)screenSize[0];
            }
            if(dir == Down){
                y = screenSize[1] - (windowPos[1] + windowSize[1] + rand() % 100);
                x = rand() % (int)screenSize[0];
            }
            //bossTime = 50;
            if(bossTime < 50){
                int enemyrand = rand() % 100;
                if(enemyrand < 30){
                    new Enemy2(x, y);
                }else if(enemyrand < 80)
                {
                    new Enemy(x, y);
                }else
                {	
                    new Enemy3(x, y);
                }
            }

敌人中的enemy类,是第一种敌人,也是敌人的基类。绘制与之前的实体类似,绘制为正方形。在构造函数中设定类型和基础属性。

在移动上,普通情况下,敌人a移动方向为面向玩家,我将其速度限制为了其原速度和其与玩家距离*0.1中的最小值,以实现敌人在靠近玩家后会慢慢减速甚至停下。而specialMovementTime为敌人的击退、碰撞等特殊情况持续的帧数。当该值为正时,敌人会按照设定的特殊行动移动,并每帧减少该值。

    void virtual Move(){
        if(specialMovementTime > 0){
            pos.first += Normalize(direction).first * tickTime * moveSpeed;
            pos.second += Normalize(direction).second * tickTime * moveSpeed;
            specialMovementTime--;
        }
        else
        {
            direction = {player->pos.first - pos.first, player->pos.second - pos.second};
            moveSpeed = oriSpeed;
            pos.first += Normalize(direction).first * tickTime * min(moveSpeed, (Length({player->pos.first - pos.first, player->pos.second - pos.second}) - 30) * 0.1f);
            pos.second += Normalize(direction).second * tickTime * min(moveSpeed, (Length({player->pos.first - pos.first, player->pos.second - pos.second}) - 30) * 0.1f);
        } 
    }

在碰撞检测上,当检测到碰撞时,该函数会判断碰撞物的类别。如果同为敌人的实体进入碰撞范围,该实例会朝反方向进行一帧特殊行动,实现不与其他敌人重叠。当子弹进入碰撞范围,该实例会受到子弹的击退效果并减血,同时调用子弹的Hit函数销毁子弹。当碰撞为玩家时,给该实例一个大击退并使玩家扣血并获得1s的无敌效果,防止重复扣血。此时,玩家颜色显示为浅红色予以区分。

    void OnCollispe(entity* other){
        //cout << other->type;
        if(other->type == T_Enemy){
            if(specialMovementTime == 0){
                direction = {pos.first - other->pos.first, pos.second - other->pos.second};
                specialMovementTime = 1;
            }
        }else if (other->type == T_Bullet)
        {
            Bullet* b = static_cast(other);
            //cout << "hit";
            if(b->owner == player){
                if(specialMovementTime == 0){
                    direction = {pos.first - other->pos.first, pos.second - other->pos.second};
                    moveSpeed = b->enemyPushPower;
                    specialMovementTime = 1;
                }
                //glutTimerFunc(1, recoverSpeed, ID);
                hp -= b->str;
                b->Hit();
            }
        }else if(other->type == T_Player){
            if(specialMovementTime == 0){
                direction = {pos.first - other->pos.first, pos.second - other->pos.second};
                moveSpeed = 15;
                specialMovementTime = 5;
            }
            //cout << "player";
            if(!player->isInvicinble){
                player->health--;
                player->isInvicinble = true;
                glutTimerFunc(player->invicinbleTime * 1000, Player::disinvicinble, player->ID);
            }
            
        }
    }

Update中循环检测当hp<=0时敌人死亡,依照玩家的掉落率属性生成对应数量的金币,产生粒子效果并将自身标记删除。

    void Update(){
        if(hp <= 0){
            int moneyNum = player->moneyRate + rand() % 10 - 5;
            for (int i = 0; i < moneyNum; i++)
            {
                new Money(pos);
            }
            CreateParticles(rand() % 3 + 6, pos, 0.7, 0.7, 0.1);
            toDelete = true;
            enemyNum--;
        }

        PhysicalCheck();
        Move();
    }

img

敌人b为敌人a子类,重写了Draw函数和Update函数以实现颜色与粒子颜色上的区分。此外,还对Move函数进行了重写。敌人b有Attack和Search两种状态。当离玩家距离大于攻击距离时,敌人进入搜索状态并不断向玩家靠近。当距离小于搜索距离并且攻击冷却已结束时,敌人进入攻击状态,后退一小段距离进行缓冲,然后向玩家方向冲刺并释放粒子,冲刺过程中不改变方向。冲刺结束后回到搜索状态,等待冷却结束进行下一次攻击。

            if(curState == Search){
                direction = {player->pos.first - pos.first, player->pos.second - pos.second};
                moveSpeed = oriSpeed;
                pos.first += Normalize(direction).first * tickTime * min(moveSpeed, (Length({player->pos.first - pos.first, player->pos.second - pos.second}) - 30) * 0.1f);
                pos.second += Normalize(direction).second * tickTime * min(moveSpeed, (Length({player->pos.first - pos.first, player->pos.second - pos.second}) - 30) * 0.1f);
                if(attackCounter > 0)
                    attackCounter -= tickTime;
                if(Distance(player->pos, pos) < searchRange && attackCounter <= 0){
                    attackCounter = attackCD;
                    attackDiatance = toattackDiatance;
                    curState = Attack;
                }
            }else if (curState == Attack)
            {
                if(attackCounter > 0){
                    attackCounter -= tickTime;
                    direction = {pos.first - player->pos.first, player->pos.second - pos.second};
                    pos.first += Normalize(direction).first * tickTime * readySpeed;
                    pos.second += Normalize(direction).second * tickTime * readySpeed;
                    oriPos = pos;
                    //cout << attackCounter;
                    direction = {player->pos.first - pos.first, player->pos.second - pos.second};
                }   
                else
                {
                    moveSpeed = attackSpeed;
                    pos.first += Normalize(direction).first * tickTime * attackSpeed;
                    pos.second += Normalize(direction).second * tickTime * attackSpeed;
                    attackDiatance -= Distance(pos, oriPos);
                    if(rand() % 2 == 1)
                        new Particle(pos, {-direction.first + rand() % 20 - 10, -direction.second + rand() % 20 - 10}, 20, 0.7, 0.1, 0.7);
                    oriPos = pos;
                    if(attackDiatance <= 0){
                        curState = Search;
                        attackCD = attackCold;
                    }
                }
            }

img

敌人c与敌人b类似,也继承自enemy类。搜索状态下靠近玩家,攻击状态下停止移动并持续对玩家发射子弹,直到玩家离开攻击范围,离开后再次进入搜索状态。子弹继承自bullet类,玩家类中检测碰撞并判断子弹所有者,如为敌人则进行扣血。此外,敌人子弹对窗口没有推动作用。

        if(Distance(player->pos, pos) > outRange && curState == Attack)
            curState = Search;
        if(Distance(player->pos, pos) < searchRange && curState == Search)
            curState = Attack;
......
......
            direction = {player->pos.first - pos.first, player->pos.second - pos.second};
            if(curState == Search){
                moveSpeed = oriSpeed;
                pos.first += Normalize(direction).first * tickTime * min(moveSpeed, (Length({player->pos.first - pos.first, player->pos.second - pos.second}) - 30) * 0.1f);
                pos.second += Normalize(direction).second * tickTime * min(moveSpeed, (Length({player->pos.first - pos.first, player->pos.second - pos.second}) - 30) * 0.1f);
            }else if (curState == Attack)
            {
                if(attackCounter < 0){
                    EnemyBullet* b = new EnemyBullet(this, pos.first, pos.second, direction, 1, 0, 1, 10, 0.1, 0.7, 0.7);
                    attackCounter = attackCD;
                }else
                {
                    attackCounter -= tickTime;
                }
                
            }

img

4.2.5 boss

boss也继承自enemy类,当bossTime计数到达一定时间后便生成boss,并创建新窗口。boss不会移动,玩家需利用推墙到达boss所在处并攻击boss。伤害到达一定量后便会转移。boss发射激光,由两道警告光和一道真伤光组成,光柱由一系列enemyBullet构成,警告光伤害为0,颜色更暗。

            else if(attackReadyCounter <= 0){
                attackTimeCounter -= tickTime;
                if(attackTimeCounter <= 0){
                    if(bList.size() == 0){
                        for (int i = 0; i < 1000; i++)
                        {
                            EnemyBullet* b = new EnemyBullet(this, pos.first + Normalize(direction).first * i, pos.second + Normalize(direction).second * i, {}, 0, 0, 1, 0, 1, 0, 0, false);
                            bList.push_back(b);
                            attackTimeCounter = attackTimeCD;
                        }
                    }else{
                        for (auto &&b : bList)
                        {
                            b->toDelete = true;
                        }
                        bList = vector();
                        curState = Idle;
                    }
                }
                
            }

img

4.2.6 商店

商店由ChooseItemStoreManager组成,其中StoreManager采用了单例模式方便调用。ChooseItem为一项可以升级的属性,保存了显示字符、需要花费、位置坐标和方法接口等。此外,类中还重写了PhysicalCheck函数实现鼠标选中与点击响应。

void ChooseItem::PhysicalCheck(){
    {
        //cout << mousePos_Store.first << ' ' << mousePos_Store.second << endl;
        //cout << isMouseUp;

        if(leftDown.first < mousePos_Store.first && mousePos_Store.first < rightUp.first && leftDown.second < mousePos_Store.second && mousePos_Store.second < rightUp.second && player->money >= cost){
            isMouseUp = true;
        }else
        {
            isMouseUp = false;
        }
        
        if(isMouseUp && mouse_Pressing){
            isChosen = true;
        }

        if(isChosen && !mouse_Pressing){
            if(isMouseUp){
                //执行
                if(itemFunc != NULL){
                    itemFunc();
                }
                player->money -= cost;
                static_cast(entities[UID])->cost += costA;
                StoreManager::instance()->RefreshStore();
                StoreManager::instance()->ShowItems();
                //toDelete = true;
                
                isChosen = false;
            }
            else
            {
                isChosen = false;
            } 
        }
    }
}

StoreManager中初始化了所有ChooseItem,每次打开商店时刷新并复制一份随机选中的ChooseItem显示。商店显示与实体类似,但仅遍历uis链表。购买一项后使储存的ChooseItem花费增加、清空链表并再次生成item。

    void ShowItem(pair ld, pair ru){        
        leftDown = ld;
        rightUp = ru;
        isMouseUp = false;
        isChosen = false;
        //cout << entities.size();

        ChooseItem *copy = new ChooseItem(*this);
        copy->ID = entities.size();
        copy->UID = ID;
        uis.push_back(copy);
        entities.push_back(copy);
        //cout << entities.size();
    }

img

4.2.7 金币

金币被生成后会向上浮动一段距离,使画面更灵动。当金币与玩家的距离小于玩家的拾取范围时,金币会自动靠近吸附于玩家。

    void Move(){
        //cout << Distance(pos, player->pos);
        if(Distance(pos, player->pos) < player->pickRange){
            direction = {player->pos.first - pos.first, player->pos.second - pos.second};
            pos.first += Normalize(direction).first * tickTime * moveSpeed;
            pos.second += Normalize(direction).second * tickTime * moveSpeed;
        }
        else{
            if(distance > 1){
                pos.second += tickTime * moveSpeed * distance * 0.02;
                distance -= Distance(pos, oriPos);
            }
            else if(fallDistance < -1){
                pos.second -= tickTime * fallSpeed;
                fallDistance += Distance(pos, oriPos);
            }
            oriPos = pos;
        }
    }

img

4.2.8 粒子系统

粒子系统由Particle类组成。可直接调用CreateParticle传入个数,位置,颜色和大小创造粒子。其中,x、y上的方向均为-1010中的随机数。理论上来说取-11之间的任意随机实数即可实现所有方向上的随机,但考虑到我取随机数的方法没法得到小数,故改为使用-10~10中的随机数,在运算时单位化以实现任意方向上的随机。

void CreateParticles(int n, pair pos, float r, float g, float b, float size = 2){
    for (int i = 0; i < n; i++)
    {
        //(-10 - 10) (5, 5)
        //(5/sqrt(50))
        new Particle(pos, {rand() % 21 - 10, rand() % 21 - 10}, rand() % 15 + 60, r, g, b, size);
    }
}

粒子被创建后即会朝着固定方向移动一定距离,当满足距离条件后即将自己销毁。

    void Move(){
        //cout << direction.first << " " << direction.second;
        if(direction.first == 0 && direction.second == 0)
            toDelete = true;
        pos.first += Normalize(direction).first * tickTime * moveSpeed;
        pos.second += Normalize(direction).second * tickTime * moveSpeed;
        distance -= Distance(pos, oriPos);
        if(distance <= 0){
            toDelete = true;
        }
        oriPos = pos;
    }

此外,在绘制时,我使粒子大小随着走过的路程变大而减小,使粒子更有散开的氛围感。

    void Draw(pair screenPos){
        glColor3f(r, g, b);
        glPointSize(oriSize * distance * 0.05);
        glBegin(GL_POINTS);
        glVertex2f(screenPos.first, screenPos.second);
        glEnd();
    }

4.3 用户界面

4.3.1 窗口

在Update函数中,我实时修改窗口的属性,使其随我设定的窗口大小与位置变量改变而改变。逻辑中,我只需要改变变量的大小,就能使窗口每帧缩小。

#pragma region 窗口移动
enum MoveDirection
{
    Left,
    Right,
    Up,
    Down,
    Center
};

float last[4];
void MoveWindow(MoveDirection dir, float value){
    if(value + last[dir] > 3){
        last[dir] += value - 3;
        value = 3;
    }
    switch (dir)
    {
    case Left:
        windowPos[0] -= value;
        windowSize[0] += value;
        break;
    case Right:
        windowSize[0] += value;
        break;
    case Up:
        windowPos[1] -= value;
        windowSize[1] += value;
        break;
    case Down:
        windowSize[1] += value;
        break;
    default:
        break;
    }
}
#pragma endregion

其中,为了使窗口缓动,当窗口小于一定范围后,我会使窗口变化速度减缓直至趋近于0,当大于一定范围时,则会使窗口增大速度趋于0。

void MoveWindow2Center(float value){
    float valX = min(value, (windowSize[0] - 250) * 0.01f);
    if(windowSize[0] >= 600)
        valX = max(windowSize[0] * 0.001f, valX);
    float valY = min(value, (windowSize[1] - 250) * 0.005f);
    if(windowSize[1] >= 700)
    {
        valY = max(windowSize[1] * 0.0005f, valY);
    }
    MoveWindow(Left, -valX);
    MoveWindow(Right, -valX);
    MoveWindow(Up, -valY);
    MoveWindow(Down, -valY);
} 

5.课程设计总结与体会

通过本次课程设计,我对游戏开发有了更深入的理解和实践。在游戏架构设计上,我学会了如何合理划分功能模块,使得代码结构清晰,便于维护和扩展。在游戏引擎的实现上,我掌握了OpenGL的基本使用方法,了解了如何在游戏循环中统一管理操作,以及如何处理输入输出和图形渲染。在游戏玩法的开发过程中,我学会了如何设计敌人的行为模式和生成机制,以增加游戏的趣味性和挑战性。此外,我还学会了如何设计和实现用户界面,使得游戏更加友好和易于操作。

在本次课程设计中,我遇到了一些困难和挑战。例如,在游戏循环管理上,我最初使用的是glutIdleFunc,后来发现这种方式并不能很好地满足我的需求,于是改为使用glutTimerFunc。在处理输入输出时,我也遇到了一些问题,例如如何正确处理键盘和鼠标事件。通过查阅资料和不断尝试,我最终成功解决了这些问题。

通过本次课程设计,我不仅提高了自己的编程能力和解决问题的能力,还加深了对游戏开发的理解。我认识到,游戏开发不仅仅是编写代码,还需要进行需求分析、模块划分、界面设计等多方面的考虑。此外,我也体会到了团队合作的重要性,因为在开发过程中,我与其他同学进行了交流和合作,共同解决问题,这使得我受益匪浅。

总之,本次课程设计使我收获颇丰,不仅提高了我的技术能力,还培养了我的团队合作意识和解决问题的能力。我相信,在今后的学习和工作中,我会继续努力,将所学知识运用到实际项目中,不断提高自己的综合素质。

试玩地址


文章作者: mashimaro kumo
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 mashimaro kumo !
评论
  目录