Featured image of post 【从零开始的C++游戏开发】植物明星大乱斗之Gameplay层实现

【从零开始的C++游戏开发】植物明星大乱斗之Gameplay层实现

制作一个简单版大乱斗玩法的双人平台射击游戏。这是这一系列教程第2个项目笔记的下半部分

目录

这一篇将详细展示Gameplay的实现思路,并在结尾处附上项目的完整源码。如需回顾整个项目的概况和整体框架的实现,请阅读本站文章《植物明星大乱斗之框架设计》。

开发流程 - Gameplay层

  • 主菜单界面和玩家选择界面的实现
    • 问题1:此项目封装了摄像机类,那么负责游戏渲染的主摄像机应该放在哪里呢?
    • 解决思路:将摄像机以参数的形式传递给渲染的函数
    • 问题2:如何让文本看起来有立体效果?
      • 解决思路:渲染两遍文本(比如一遍在原始位置渲染白色,另一遍在斜下方偏移一点的地方渲染灰色)
    • 问题3:如何让选择界面的背景上有滚动剪影的效果
      • 解决思路:同一张图片绘制两次
  • 游戏局内场景搭建和物理模拟实现
    • 问题1:如何模拟“重力”效果?
      • 解决思路:”重力“的体现为下坠+停止,封装平台类
    • 问题2:如何对物理碰撞数据进行可视化检查?
      • 解决思路:实现一个简单的”调试模式“
    • 问题3:玩家在某些下落情况下会出现突然向上瞬移的情况
      • 解决思路:确保上一帧玩家的脚底位置,决定是否要在这一帧修正玩家的脚底位置
    • 问题4:玩家出现连环跳
      • 解决思路:确保玩家在竖直方向速度为0时,才能继续执行跳跃逻辑
  • 子弹基类的实现
    • 游戏玩法的本质是通过抛射物似的子弹给对手造成伤害
      • 思路:创建子弹基类,不同的具体子弹继承自这个基类
    • 问题:子弹在碰撞后消失的逻辑该怎么写?
      • 解决思路:与玩家死亡播放动画的情况类似,使用回调函数解决
    • 子弹删除逻辑的优化
  • 豌豆子弹类的实现
  • 日光炸弹类、超级日光炸弹类的实现
    • 注意:爆炸动画时素材位置的偏移调整
  • 攻击技能的实现
    • 使用定时器来记录冷却时间
  • 无敌帧的实现
    • 思路:在播放动画时,间隔显示不同的图片:正常图片 和 纯白的剪影图片
  • 玩家状态栏的实现(生命值、能量值)
  • 粒子系统的实现
  • 胜负检定和结算动效

关键步骤和解决思路 - Gameplay层

主菜单界面和角色选择界面的搭建

摄像机的处理

主摄像机怎么放比较好呢?

因为我们在此项目中封装了摄像机类,那么就需要确保每一个场景在执行渲染时,都可以获取到游戏的摄像机对象,从而根据它的位置实时渲染游戏画面。有三个思路实现:

思路 1:

将摄像机定义在场景内部,让它作为场景类的成员变量存在。但这样的设计就很难在不同场景间共享摄像机的数据了。

思路 2:

把摄像机对象定义为全局变量,和此项目中图片等资源一样,我们可以通过 extern 关键字获取它。不过,全局变量终究是一种杂乱的设计,与我们想要尽量封装数据、减少全局变量的目标相悖。

思路 3:

参考帧更新中将时间的流逝 delta 以参数的方式传递给函数的思路 void on_update(int delta) ,我们选择把摄像机对象也作为参数在渲染时传递进来 void on_draw(const Camera& camera)

主菜单界面的实现

确定了摄像机的处理方法后,主菜单的完整代码是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
extern IMAGE img_menu_background;
extern SceneManager scene_manager;

class MenuScene : public Scene
{
public:
	MenuScene() = default;
	~MenuScene() = default;

	// 重写Scene基类中必要的虚函数
	void on_enter()
	{
		mciSendString(_T("play bgm_menu repeat from 0"), NULL, 0, NULL);
	}
	void on_update(int delta)
	{
	
	}
	void on_draw(const Camera& camera)
	{
		putimage(0, 0, &img_menu_background);
	}
	void on_input(const ExMessage& msg)
	{
		if (msg.message == WM_KEYUP)
		{
			mciSendString(_T("play ui_confirm from 0"), NULL, 0, NULL);
			scene_manager.switch_to(SceneManager::SceneType::Selector);
		}
	}
	void on_exit()
	{

	}

private:

};

角色选择界面的实现

角色种类的设计技巧

我们首先用枚举定义了玩家可选择的角色种类。此处为什么要加上一个名为“无效”的玩家呢?它可以作为确保我们在实现选择玩家的时候,我们的选择不会越界。

1
2
3
4
5
6
enum class PlayerType
{
    Peashooter = 0,
    Sunflower,
    Invalid
};

以玩家1的向左切换为例,我们首先将玩家角色类型的枚举变量转为 int 类型并减小1,然后加上同样转为 int 类型的 Invalid 枚举,来确保这个值始终大于等于0。随后将这个得到的值对 Invalid 枚举对应的 int 类型进行取模,确保最终的结果不会大于或等于 Invalid 的值。最后,将这一波操作得到的 int 值再转为 PlayerType 枚举类后赋值给玩家1的角色类型变量。这样我们就可以确保在按下向左切换键后,玩家类型发生了变化,同时这个值还在枚举类的第一个值和最后一个值之间,即 [Peashooter, Invalid)的前闭后开区间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
case WM_KEYUP:
	switch (msg.vkcode)
	{
	case 0x41: // 'A'
		is_btn_1P_left_down = false;
		player_type_1 = (PlayerType)(((int)PlayerType::Invalid + (int)player_type_1 - 1) % (int)PlayerType::Invalid);
		mciSendString(_T("play ui_switch from 0"), NULL, 0, NULL);
		break;
     // ......
    }
将摄像机封装进Animation类

因为所有的动画渲染时都需要先获取摄像机的位置,并且这部分逻辑在所有渲染动画时都要使用。那么根据 面向对象的封装特性,我们干脆把获取摄像机位置、并与自身坐标作差的这部分逻辑,直接放到Animation类中,于是动画类的 on_draw方法需要改成 void on_draw(const Camera& camera, int x, int y) const

背景剪影滚动效果的实现

一种比较简单的实现思路是将这张图片渲染两次。我们想象一条用来定位的竖直线条,这根线条从矩形的左侧滚向矩形的右侧。当达到矩形右侧时,线条又会闪现回到矩形左侧。我们在线的左侧绘制一次图片,然后在线的右侧又绘制一次图片,这样就实现了图片的连续滚动效果。

数据更新部分具体的代码是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void on_update(int delta) 
{
	animation_peashooter.on_update(delta);
	animation_sunflower.on_update(delta);

	// 背景滚动效果
	selector_background_scroll_offset_x += 5;
	if (selector_background_scroll_offset_x >= img_peashooter_selector_background_left.getwidth())
    {
        selector_background_scroll_offset_x = 0;
    }
}

在绘制部分,我们首先需要重载一个 putimage_alpha 函数来对图片进行裁剪绘制:

1
2
3
4
5
6
7
8
inline void putimage_alpha(int dst_x, int dst_y, int width, int height, IMAGE* img, int src_x, int src_y)
{
	int w = width > 0 ? width : img->getwidth();
	int h = height > 0 ? height : img->getheight();

	AlphaBlend(GetImageHDC(GetWorkingImage()), dst_x, dst_y, w, h,
		GetImageHDC(img), src_x, src_y, w, h, { AC_SRC_OVER, 0 , 255, AC_SRC_ALPHA });
}

然后在玩家1的背景图上滚动玩家2的剪影,在玩家2的背景图上滚动玩家1的剪影。所以最终的绘制部分代码是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
void on_draw(const Camera& camera) 
{
	IMAGE* img_P1_selector_background = nullptr;
	IMAGE* img_P2_selector_background = nullptr;
	
	// 互相为对方的滚动背景图赋值
	switch (player_type_2)
	{
	case PlayerType::Peashooter:
		img_P1_selector_background = &img_peashooter_selector_background_right;
		break;
	case PlayerType::Sunflower:
		img_P1_selector_background = &img_sunflower_selector_background_right;
		break;
	default:
		img_P1_selector_background = &img_peashooter_selector_background_right;
		break;
	}

	switch (player_type_1)
	{
	case PlayerType::Peashooter:
		img_P2_selector_background = &img_peashooter_selector_background_left;
		break;
	case PlayerType::Sunflower:
		img_P2_selector_background = &img_sunflower_selector_background_left;
		break;
	default:
		img_P2_selector_background = &img_peashooter_selector_background_left;
		break;
	}


	putimage(0, 0, &img_selector_background);

	// 绘制动态背景图
	putimage_alpha(getwidth() - selector_background_scroll_offset_x, 0, img_P2_selector_background);
	putimage_alpha(getwidth() - img_P2_selector_background->getwidth(), 0, img_P2_selector_background->getwidth() - selector_background_scroll_offset_x,
		0, img_P2_selector_background, selector_background_scroll_offset_x, 0);
	putimage_alpha(selector_background_scroll_offset_x - img_P1_selector_background->getwidth(), 0, img_P1_selector_background);
	putimage_alpha(selector_background_scroll_offset_x, 0, img_P1_selector_background->getwidth() - selector_background_scroll_offset_x,
		0, img_P1_selector_background, 0, 0);

	putimage_alpha(pos_img_VS.x, pos_img_VS.y, &img_VS);
    
    // ......
}

物理引擎的实现

平台类的设计

从功能入手考虑”平台“碰撞器的设计。在大多数2D平台类游戏的设计中,平台大多数被设计成 单向碰撞,也就是说,玩家从上方坠落时可以正常落到平台上;而当玩家从平台下方向上跳跃时,就可以穿过平台站立在平台上。所以我们只需要考虑玩家可以”站在哪里“即可,于是将平台碰撞器抽象为 一条线

此处需要注意的是,虽然我们在碰撞器的结构体中已经记录了平台的位置,但是还是需要单独去记录平台的渲染位置。这是因为平台的图片素材是有厚度的,并且这些图片的最顶部不一定就是碰撞线所处的位置。一般来说,碰撞的检测线位于平台图片内部稍偏上一点 的地方,这样在画面上才更符合玩家的直觉。同时,这种平台类的设计也和我们总体的设计思路 数据逻辑和渲染分离 相一致。当我们在碰撞检测时,我们只需要关注CollisionShape 的数据部分;当我们进行绘图时,我们也只需要关注图片和绘制位置的信息即可。这就进一步实践了 解耦合

为物理碰撞添加一个简单的调式模式,只需要在该模式下绘制出碰撞器的形状即可。最终,平台类的完整代码是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
extern bool is_debug;

class Platform
{
public: 
	struct CollisionShape // 描述碰撞器形状
	{
		float y; 
		float x_left;
		float x_right;
	};

public:
	CollisionShape shape;
	// 数据逻辑和渲染分离,实现解耦合
	IMAGE* img = nullptr;
	POINT render_position = { 0 };

public:
	Platform() = default;
	~Platform() = default;

	void on_draw(const Camera& camera) const
	{
		putimage_alpha(camera, render_position.x, render_position.y, img);

		// 根据是否在debug模式来决定是否要绘制碰撞线
		if (is_debug)
		{
			setlinecolor(RGB(255, 0, 0));
			line(camera, (int)shape.x_left, (int)shape.y, (int)shape.x_right, (int)shape.y);
		}
	}
		
private:

};

玩家基类的设计

把所有玩家都具备的数据和逻辑封装在玩家基类中。而豌豆射手、向日葵等具体的角色,则分别继承自Player基类,实现自己的逻辑。

如何实例化玩家对象?

因为有两个类别的玩家角色,游戏中具体实例化哪个玩家类,取决于玩家在游戏的选角阶段选择了哪一个角色,所以我们需要在玩家角色选择界面执行这部分的逻辑。同时,我们还需要在游戏局内的场景中使用它们。所以最简单的思路就是把它和之前那些生命周期同样跨越多个场景的对象一样,定义在全局环境中。所以在角色选择界面的相关代码是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// SelectorScene.h
class SelectorScene : public Scene
{
public:
    // ......
    void on_exit() 
    {
        // 根据玩家选择的角色,实例化对应的玩家对象,用于Game Scene中
        switch (player_type_1)
        {
        case PlayerType::Peashooter:
            player_1 = new PeashooterPlayer();
            break;
        case PlayerType::Sunflower:
            player_1 = new SunflowerPlayer();
            break;
        }

        switch (player_type_2)
        {
        case PlayerType::Peashooter:
            player_2 = new PeashooterPlayer();
            break;
        case PlayerType::Sunflower:
            player_2 = new SunflowerPlayer();
            break;
        }
    }
    // ......
}

另外,我们还需要记录玩家在世界坐标中的位置 Vector2 position; 另外需要添加玩家角色的动画相关的定义,这些动画具体使用哪个图集渲染,放到具体的子类中实现。读取玩家的按键操作并将按键消息映射到对应的逻辑这部分代码也应该放到玩家基类中。那么与玩家键位控制相关的玩家序号该怎么写呢?在设计上,我们把 玩家序号 作为玩家对象的一个成员,只需要在按键操作时根据序号执行不同逻辑即可。

平台单向碰撞检测和重力模拟

重力模拟

在自由落体的过程中,有两个关键的值:重力加速度和物体当前的速度。在整个重力模拟的过程中,场景中的物体始终都在受到重力加速度的牵引,并且有着向着竖直方向加速的趋势。所以在玩家类中还需要定义重力常量 const float gravity = 1.6e-3f; 注意,这个数值看似很大,但其实是在游戏开发中很常见的情况。受限于我们游戏世界的尺寸、画面比例乃至于玩家手感的优化,重力极有可能被调整为一个“四不像”的数值,作为开发者,我们只需要让玩家等物体在这个值的影响下展现出正确的效果即可。和物理相关的所有代码,我们都会写在 void move_and_collide(int delta) 这个函数中。于是,重力的模拟只需要用两行代码即可实现:

1
2
3
4
5
6
7
8
9
// Player.h
// ......
protected:
	void move_and_collide(int delta)
    {
		velocity.y += gravity * delta;
		position += velocity * (float)delta;
    }
// ......
平台单向碰撞检测

玩家和平台的碰撞只需要判断直线和矩形是否在水平方向发生重合即可。具体方法:取二者最右边界的值和二者最左边界的值作差,如果结果小于二者宽度之和,那么这两个图形在水平方向上有重合部分,也就是说,玩家角色和该平台在水平方向上发生了碰撞:

竖直方向上的碰撞更为简单:只需要确保平台碰撞检测线的 y 坐标,是否处于玩家矩形的上下边界之间即可。而当玩家和平台在水平和竖直方向上都有重合时,我们才能判断二者发生了碰撞。

接下来,我们需要考虑如何对玩家的坐标进行修正了。既然玩家已经碰到了平台,那么玩家无论已经落下多少距离,都应该停在平台之上。如果我们只是简单粗暴地将玩家的脚底位置设置到平台上,那么在某些情况下可能会发生“穿模”的bug:玩家向上跳起,但是只有身体的一部分穿过了平台,然后开始下落,这时玩家的速度向下,同时和平台发生了碰撞,满足了目前代码的碰撞条件,如果此时直接将玩家设置到平台之上的话,那么会出现刚刚开始下落的玩家一瞬间被移到了平台之上。

所以,我们需要确保只有玩家的整个身体都穿过了平台,随后开始下落时,才能判断玩家和平台发生了碰撞。也就是说,我们需要先获取上一帧玩家脚底的位置,只有它位于平台上方了,并且这一帧满足我们先前讨论的所有条件,才会执行修正玩家脚底坐标的逻辑。于是,完善后的代码是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void move_and_collide(int delta)
{
    velocity.y += gravity * delta;
    position += velocity * (float)delta;

    // 碰撞检测
    if (velocity.y > 0)
    {
        for (const Platform& platform : platform_list)
        {
            const Platform::CollisionShape& shape = platform.shape;
            bool is_collide_x = (max(position.x + size.x, shape.x_right) - min(position.x, shape.x_left) <= size.x + (shape.x_right - shape.x_left));
            bool is_collide_y = (shape.y >= position.y && shape.y <= position.y + size.y);

            if (is_collide_x && is_collide_y)
            {
                // 需要根据前一帧的脚底位置是否高于平台位置,来判断要不要将玩家的最终位置放在平台上
                float delta_pos_y = velocity.y * delta;
                float last_tick_foot_pos_y = position.y + size.y - delta_pos_y;
                if (last_tick_foot_pos_y <= shape.y) // 玩家在上一帧跳跃高度高于平台
                {
                    position.y = shape.y - size.y;
                    velocity.y = 0;

                    break;
                }
            }
        }
    }
}

角色技能设计

玩家技能的程序功能需求

此项目中有两种玩家角色:豌豆射手和向日葵。这两种角色都有普通攻击和特殊攻击两种攻击方式。

豌豆射手:可以向自己的面朝的方向发射豌豆子弹,子弹命中对方可以积攒能量。能量值蓄满后可以释放特殊攻击技能,快速喷射出一连串的豌豆子弹。

向日葵:普通攻击向着自己面朝的斜上方抛射日光炸弹。日光炸弹会受重力影响呈不太容易瞄准的曲线运动,但它在击中敌人后造成的伤害和奖励的能量更高。向日葵同样可以在能量集满后释放特殊攻击技能——在对手的头顶出召唤出巨大的日光炸弹,这个大日光炸弹有着更大的伤害范围和更高额的能量回馈。

从游戏设计的角度,无论何种类型的玩家角色,其核心都是使用抛射物给对手造成伤害的玩法。于是,我们就可以对场景中所有的抛射物进行大一统,它们的差异无非是在动画贴图和伤害范围等数值上等等。我们可以创建Bullet基类,豌豆子弹和日光炸弹、超大型日光炸弹则分别继承这个子弹基类,然后实现各自具体的更新和渲染逻辑。

子弹碰到敌人后消失的逻辑实现

类似于在动画类设计时玩家死亡消失的思路,子弹在碰到敌人后就应该被立即设置为无效的状态,从而防止其在后续的帧更新时与敌人发生多次碰撞。但是由于我们需要播放豌豆子弹的破裂动画和日光炸弹的爆炸动画,所以不能立即将子弹对象从场景中删除。也就是说,场景中的每一个子弹对象都有三个阶段:正常状态、无效状态、可以被删除的状态。

正常状态 中,我们播放子弹的动画,并在每一帧中检测它与玩家的碰撞。当它与目标玩家发生碰撞后,进入到 无效状态,此时,我们不再对子弹进行碰撞检测,同时播放子弹销毁的动画。在动画播放结束后,子弹进入到可以被删除的状态,从而在场景更新时被移除掉。所以子弹的成员变量可以这样设计:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Bullet.h
protected:
	Vector2 size;						// 子弹碰撞器尺寸
	Vector2 position;					// 子弹位置
	Vector2 velocity;					// 子弹速度
	int damage = 10;					// 子弹杀伤力

	// 子弹的三个状态:有效、无效、可以被删除
	bool valid = true;					// 子弹是否有效
	bool can_remove = false;			// 子弹是否可以被移除

	function<void()> callback;			// 子弹碰撞的回调函数

	PlayerID target_id = PlayerID::P1;	// 子弹碰撞的目标玩家的ID
子弹消失逻辑的优化

如果子弹只有在碰撞到玩家后才被销毁,那么没有发生碰撞的子弹就永远不会被删除掉,造成内存泄漏。所以我们还需要对已经飞到屏幕外不可见的子弹对象进行销毁。这部分的逻辑同样是所有继承自Bullet 基类的子类通用的内容,所以我们还需要定义 check_if_exceeds_screen 这一方法,在内部检测子弹的矩形边界是否已经位于屏幕矩形之外。

完整的子弹基类的代码是这样的:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
extern bool is_debug;

// Bullet基类
class Bullet
{
public:
	Bullet() = default;
	~Bullet() = default;

	virtual void on_collide()
	{
		if (callback)
			callback();
	}

	virtual bool check_collision(const Vector2& position, const Vector2& size) // 子弹中心坐标是否进入玩家碰撞体内部
	{
		return this->position.x + this->size.x / 2 >= position.x
			&& this->position.x + this->size.x / 2 <= position.x + size.x
			&& this->position.y + this->size.y / 2 >= position.y
			&& this->position.y + this->size.y / 2 <= position.y + size.y;
	}

	virtual void on_update(int delta)
	{

	}

	virtual void on_draw(const Camera& camera) const
	{
		if (is_debug)
		{
			setfillcolor(RGB(255, 255, 255));
			setlinecolor(RGB(255, 255, 255));
			rectangle((int)position.x, (int)position.y,
				(int)(position.x + size.x), (int)(position.y + size.y));
			solidcircle((int)(position.x + size.x / 2), (int)(position.y + size.y / 2), 5);
		}
	}

	void set_damage(int val)
	{
		damage = val;
	}

	int get_damage()
	{
		return damage;
	}

	void set_position(float x, float y)
	{
		position.x = x;
		position.y = y;
	}

	const Vector2& get_position() const
	{
		return position;
	}

	const Vector2& get_size() const
	{
		return size;
	}

	void set_velocity(float x, float y)
	{
		velocity.x = x;
		velocity.y = y;
	}

	void set_collide_target(PlayerID target)
	{
		target_id = target;
	}

	PlayerID get_collide_target() const
	{
		return target_id;
	}

	void set_callback(function<void()> callback)
	{
		this->callback = callback;
	}

	// 设置子弹是否可以继续碰撞
	void set_valid(bool flag)
	{
		valid = flag;
	}

	bool get_valid() const
	{
		return valid;
	}

	bool check_can_remove() const
	{
		return can_remove;
	}

protected:
	bool check_if_exceeds_screen()
	{
		return (position.x + size.x <= 0 || position.x >= getwidth()
			|| position.y + size.y <= 0 || position.y >= getheight());
	}

protected:
	Vector2 size;						// 子弹碰撞器尺寸
	Vector2 position;
	Vector2 velocity;
	int damage = 10;					// 子弹杀伤力

	// 子弹的三个状态:有效、无效、可以被删除
	bool valid = true;					// 子弹是否有效
	bool can_remove = false;			// 子弹是否可以被移除

	function<void()> callback;			// 子弹碰撞的回调函数

	PlayerID target_id = PlayerID::P1;	// 子弹碰撞的目标玩家的ID
};

注意:刚开始时,我们通常无法一次性地、 自顶而下 地这样设计一个游戏对象类。类的设计往往是 边写边改,然后通过后续使用时的查漏补缺来完善基类的内容。自顶而下地设计,在很多程度上是对编程经验的考验,随着不断地练习和经验的累积,会对这种思路越来越熟悉。

豌豆子弹类的设计

豌豆射手只会发射一种类型的子弹,普通攻击和特殊攻击无非是子弹发射频率的不同。我们用三种不同的音效资源在豌豆子弹发生碰撞时进行随机播放,这种设计在游戏开发中很常见。比如游戏中角色的脚步声或者是射击时的枪声,都可以使用不同的音效进行播放,这种随机的效果音会让游戏显得更加自然和灵动。所以,我们需要在豌豆子弹子类中重写 on_colide 方法来为豌豆子弹添加不同的破碎声音。需要注意的是,我们在 重写父类方法且还是需要执行父类逻辑时,需要显式地调用父类的方法。于是,完整的豌豆子弹子类是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// PeaBullet.h

extern IMAGE img_pea;
extern Atlas atlas_pea_break;

class PeaBullet : public Bullet
{
public:
	PeaBullet()
	{
		size.x = 64, size.y = 64;

		damage = 10;

		animation_break.set_atlas(&atlas_pea_break);
		animation_break.set_interval(100);
		animation_break.set_loop(false);
		animation_break.set_callback([&]() { can_remove = true; });
	}

	~PeaBullet() = default;

	void on_update(int delta)
	{
		position += velocity * (float)delta;

		if (!valid)
			animation_break.on_update(delta);

		if (check_if_exceeds_screen())
			can_remove = true;
	}

	void on_draw(const Camera& camera) const
	{
		if (valid)
			putimage_alpha(camera, (int)position.x, (int)position.y, &img_pea);
		else
			animation_break.on_draw(camera, (int)position.x, (int)position.y);

		Bullet::on_draw(camera);

	}

	void on_collide()
	{
		Bullet::on_collide();

		switch (rand() % 3)
		{
		case 0:
			mciSendString(_T("play pea_break_1 from 0"), NULL, 0, NULL);
			break;
		case 1:
			mciSendString(_T("play pea_break_2 from 0"), NULL, 0, NULL);
			break;
		case 3:
			mciSendString(_T("play pea_break_3 from 0"), NULL, 0, NULL);
			break;
		}
	}

private:
	Animation animation_break;	// 豌豆子弹破碎动画
};

Bullet 基类的基础上, PeaBullet 子类只是简单扩展了一部分自己独有的逻辑,就可以相对简明地实现完整的子弹功能了。这正是面向对象中 继承 的魅力所在。

日光炸弹类的实现

需要注意的是,爆炸动画的序列帧素材尺寸时略大于日光炸弹默认动画序列帧尺寸的,所以为了确保日光炸弹在爆炸时渲染的效果正确,我们就要保证这两个动画所在的矩形的中心对齐。而在EasyX中,我们渲染的坐标原点是矩形左上角的坐标,所以我们在爆炸动画渲染时加上一个小小的位置偏移,确保画面效果正确。

另外,在豌豆子弹的 on_update 方法中,我们根据其速度不断更新它的位置,也就是说,无论豌豆子弹是否发生了碰撞,都会一直向前飞翔。配合豌豆子弹破碎后的动画,我们就顺手实现了子弹碰撞后的飞溅效果。日光炸弹爆炸后的动画有所不同,它应该留在原地而不会继续受重力的影响而运动。

完整的日光炸弹类的代码是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// SunBullet.h

extern Atlas atlas_sun;
extern Atlas atlas_sun_explode;

extern Camera main_camera;

class SunBullet : public Bullet
{
public:
	SunBullet()
	{
		size.x = 96, size.y = 96;
		
		damage = 20;

		animation_idle.set_atlas(&atlas_sun);
		animation_idle.set_interval(50);

		animation_explode.set_atlas(&atlas_sun_explode);
		animation_explode.set_interval(50);
		animation_explode.set_loop(false);
		animation_explode.set_callback([&]() { can_remove = true; });

		// 处理爆炸和idle时图片的偏移问题
		IMAGE* frame_idle = animation_idle.get_frame();
		IMAGE* frame_explode = animation_explode.get_frame();
		explode_render_offset.x = (frame_idle->getwidth() - frame_explode->getwidth()) / 2.0f;
		explode_render_offset.y = (frame_idle->getheight() - frame_explode->getheight()) / 2.0f;
		
	}

	~SunBullet() = default;

	void on_update(int delta)
	{
		if (valid)
		{
			velocity.y += gravity * delta;
			position += velocity * (float)delta;
		}

		// 更新动画状态
		if (!valid)
			animation_explode.on_update(delta);
		else
			animation_idle.on_update(delta);

		if (check_if_exceeds_screen())
			can_remove = true;
	}

	void on_draw(const Camera& camera) const
	{
		if (valid)
			animation_idle.on_draw(camera, (int)position.x, (int)position.y);
		else
		{
			animation_explode.on_draw(camera, (int)(position.x + explode_render_offset.x),
				(int)(position.y + explode_render_offset.y));
		}

		Bullet::on_draw(camera);
	}

	void on_collide()
	{
		Bullet::on_collide();

		main_camera.shake(5, 250);	// 摄像机在爆炸时的抖动效果

		mciSendString(_T("play sun_explode from 0"), NULL, 0, NULL);
	}

private:
	const float gravity = 1e-3f;	// 日光炸弹的重力

private:
	Animation animation_idle;		// 日光炸弹的默认动画
	Animation animation_explode;	// 日光炸弹的爆炸动画
	Vector2 explode_render_offset;	// 爆炸时动画的渲染偏移(爆炸的图片略大于idle图片
									// 使其图片中心点重合,更符合玩家的直觉
};

超级日光炸弹类的实现

在我们的游戏中,向日葵的特殊技能是可以从屏幕外召唤超级大的日光炸弹。比起普通攻击召唤的小型炸弹,这个炸弹更大更强。它不会受到重力影响,而是缓慢下落,更多的是起到封走位的作用,方便抛射的小型日光炸弹更容易命中对手。

我们希望超级日光炸弹的碰撞检测范围更大一些,那么就不再将子弹的中心位置坐标作为碰撞检测点了,而是使用矩形边界作为碰撞检测的范围。于是我们就需要重写 check_collision 的方法了。

超级日光炸弹类是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
extern Atlas atlas_sun_ex;
extern Atlas atlas_sun_ex_explode;

extern Camera main_camera;

class SunBulletEx : public Bullet
{
public:
	SunBulletEx()
	{
		size.x = 288, size.y = 288;

		damage = 20;

		animation_idle.set_atlas(&atlas_sun_ex);
		animation_idle.set_interval(50);

		animation_explode.set_atlas(&atlas_sun_ex_explode);
		animation_explode.set_interval(50);
		animation_explode.set_loop(false);
		animation_explode.set_callback([&]() { can_remove = true; });

		// 处理爆炸和idle时图片的偏移问题
		IMAGE* frame_idle = animation_idle.get_frame();
		IMAGE* frame_explode = animation_explode.get_frame();
		explode_render_offset.x = (frame_idle->getwidth() - frame_explode->getwidth()) / 2.0f;
		explode_render_offset.y = (frame_idle->getheight() - frame_explode->getheight()) / 2.0f;
	}

	~SunBulletEx() = default;

	void on_update(int delta)
	{
		if (valid)
		{
			position += velocity * (float)delta;
		}

		if (!valid)
			animation_explode.on_update(delta);
		else
			animation_idle.on_update(delta);

		if (check_if_exceeds_screen())
			can_remove = true;
	}

	void on_draw(const Camera& camera) const
	{
		if (valid)
			animation_idle.on_draw(camera, (int)position.x, (int)position.y);
		else
		{
			animation_explode.on_draw(camera, (int)(position.x + explode_render_offset.x),
				(int)(position.y + explode_render_offset.y));
		}

		Bullet::on_draw(camera);
	}

	void on_collide()
	{
		Bullet::on_collide();

		main_camera.shake(20, 350);	
		mciSendString(_T("play sun_explode_ex from 0"), NULL, 0, NULL);
	}

	bool check_collision(const Vector2& position, const Vector2& size)	// 将碰撞范围扩展为一个矩形,而不是子弹的中心点
	{
		bool is_collide_x = (max(this->position.x + this->size.x, position.x + size.x)
			- min(this->position.x, position.x) <= this->size.x + size.x);
		bool is_collide_y = (max(this->position.y + this->size.y, position.y + size.y)
			- min(this->position.y, position.y) <= this->size.y + size.y);

		return is_collide_x && is_collide_y;
	}

private:
	Animation animation_idle;
	Animation animation_explode;
	Vector2 explode_render_offset;
};

从设计角度讲,超级日光炸弹完全可以通过继承普通日光炸弹来简化代码。但此项目还是采用这种相对扁平的类继承关系,确保整体的代码结构易于理解。

技能系统

Player 基类中添加 virtual void on_attackvirtual void on_attack_ex 两个虚函数,然后在具体的玩家子类中重写这两个虚函数的逻辑,就可以完成角色的普通攻击和特殊攻击了。

冷却时间的实现思路

角色的普通攻击一般是存在冷却时间的,在冷却时间内,无论如何按键都无法触发攻击效果,所以我们定义一个布尔变量 can_attack 用来标识角色当前是否可以释放普通攻击,使用一个定时器来记录角色普通攻击的冷却时间,用一个 int 变量来标识玩家普通攻击的冷却时间毫秒数。这样,我们就只需要在按键消息触发时,检查当前是否可以执行普通攻击,如果可以执行,则翻转这个布尔变量并重置定时器的时间,当定时器时间到达后,再恢复玩家可攻击的状态。这样,我们就实现了普通攻击的冷却效果。

无敌状态的实现

我们用两个布尔变量来标识角色当前是否处于“无敌状态” bool is_invulnaerable = false; 和当前帧是否需要显示玩家剪影 bool is_showing_sketch_frame = false; 我们希望当玩家处于无敌帧时,出现闪烁的动画效果,那么就需要不断切换显示当前动画序列帧和剪影动画序列帧:

我们通过添加两个定时器来控制玩家退出无敌状态以及闪烁时两种不同序列帧的切换功能:Timer timer_ivulnerable; Timer timer_invulnerable_blink; 于是在定时器的部分在 Player 类的构造函数里进行初始化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Player.h
Player()
{
	current_animation = is_facing_right;

	// 初始化普通攻击冷却相关成员变量
	timer_attack_cd.set_wait_time(attack_cd);
	timer_attack_cd.set_one_shot(true);
	timer_attack_cd.set_callback([&]()
		{
			can_attack = true;
		});

	// 初始化无敌时间定时器
	timer_invulnerable.set_wait_time(750);
	timer_invulnerable.set_one_shot(true);
	timer_invulnerable.set_callback([&]()
		{
			is_invulnerable = false;
			is_showing_sketch_frame = false; // 无敌结束时确保不再显示剪影
		});

	// 初始化无敌动画闪烁定时器
	timer_invulnerable_blink.set_wait_time(75);
	timer_invulnerable_blink.set_callback([&]()
		{
			is_showing_sketch_frame = !is_showing_sketch_frame;
		});
	
    // ......
}

on_update 中对定时器进行更新。在 Util.h 添加一个工具函数,实现将图片处理为纯白色的剪影效果。我们读取图片对象的像素色彩缓存区,将所有像素设置为白色即可,详细的解释请见本站文章《提瓦特幸存者》的“番外篇”部分。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Util.h
inline void sketch_image(IMAGE* src, IMAGE* dst)
{
	int w = src->getwidth();
	int h = src->getheight();

	Resize(dst, w, h);
	DWORD* src_buffer = GetImageBuffer(src);
	DWORD* dst_buffer = GetImageBuffer(dst);
	for (int y = 0; y < h; y++)
	{
		for (int x = 0; x < w; x++)
		{
			int idx = y * w + x;
			dst_buffer[idx] = BGR(RGB(255, 255, 255)) | (src_buffer[idx] & 0xFF000000);
		}
	}
}

那么,在 on_update 中就可以添加是否需要显示剪影图片的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Player.h
virtual void on_update(int delta)
{
    // ......
    
    // 是否需要显示剪影图片
	// 仅在无敌且需要显示剪影的帧,生成剪影图片
	if (is_showing_sketch_frame)
		sketch_image(current_animation->get_frame(), &img_sketch);
    
    // ......
}

并在 on_draw 中进行相应绘制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Player.h
virtual void on_draw(const Camera& camera)
{
    // ......
    if (hp > 0 && is_invulnerable && is_showing_sketch_frame)
		putimage_alpha(camera, (int)position.x, (int)position.y, &img_sketch);
	else
		current_animation->on_draw(camera, (int)position.x, (int)position.y);
    
    // ......
}

最后,在 Player 类中添加触发无敌状态的逻辑入口:

添加一个让玩家进入无敌状态的方法:

1
2
3
4
5
6
7
8
9
// Player.h
void make_invulnerable()
{
    is_invulnerable = true;
    timer_invulnerable.restart();

    is_showing_sketch_frame = true;    // 立即进入“显示剪影”的半帧
    timer_invulnerable_blink.restart(); // 开始闪烁
}

然后在 move_and collide 方法的子弹碰撞部分,在子弹发生碰撞后添加让玩家进入无敌状态的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Player.h
void move_and_collide(int delta)
{

    // ......
        
    // 玩家被子弹射中受到伤害
    if(!is_invulnerable)
	{
		for (Bullet* bullet : bullet_list)
		{
			if (!bullet->get_valid() || bullet->get_collide_target() != id)
				continue;

			if (bullet->check_collision(position, size))
			{
				make_invulnerable();		// 开启角色无敌状态

				bullet->on_collide();
				bullet->set_valid(false);
				hp -= bullet->get_damage();

			// ......
		}
	}
    // ......
}	

粒子系统的实现

粒子系统 是一种使用大量微小粒子的图元作为基本元素来描述不规则对象的技术。在很多游戏中,烟雾、火焰、雨雪等效果的实现都是依托在粒子系统上完成的。在分析和解剖粒子系统时,我们可以从两个角度入手:粒子对象 本身和 粒子发射器

粒子对象:通常由动画、物理和生命周期等众多属性来描述。

粒子发射器:决定粒子对象的生成方式。如粒子的发生频率、发射方向和初始速度等。

我们将尝试封装一个简单可用的粒子系统。粒子对象可以看作是一个特殊的动画对象,它与角色、子弹等动画的区别在于,粒子在发射后,世界坐标的位置就固定下来了,不会随着游戏的更新而移动。在播放完自身的动画后,粒子的寿命便终止了,就可以从场景中被移除掉了。

完整的粒子类代码是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class Particle
{
public:
	Particle() = default;

	Particle(const Vector2& position, Atlas* atlas, int lifespan)
		: position(position), atlas(atlas), lifespan(lifespan)
	{ }

	~Particle() = default;

	void on_update(int delta)
	{
		timer += delta;
		if (timer >= lifespan)
		{
			timer = 0;
			idx_frame++;
			if (idx_frame >= atlas->get_size())
			{
				idx_frame = atlas->get_size() - 1;
				valid = false;
			}
		}
	}

	void on_draw(const Camera& camera) const
	{
		putimage_alpha(camera, (int)position.x, (int)position.y, atlas->get_image(idx_frame));
	}

	void set_atlas(Atlas* new_atlas)
	{
		atlas = new_atlas;
	}

	void set_position(const Vector2& new_position)
	{
		position = new_position;
	}

	void set_lifespan(int ms)
	{
		lifespan = ms;
	}

	bool check_valid() const
	{
		return valid;
	}

private:
	int timer = 0;			// 粒子动画播放定时器
	int lifespan = 0;		// 单帧粒子动画持续时间
	int idx_frame = 0;		// 当前正在播放的动画帧
	Vector2 position;		// 粒子的世界坐标
	bool valid = true;		// 粒子对象是否有效
	Atlas* atlas = nullptr;	// 粒子动画所使用的图集
};

代码总体回顾及源码

Scene 基类

在这个项目中首次引入了场景设计的概念。游戏的不同阶段和不同界面对应到不同的场景中。在场景基类 Scene.h 中,on_enteron_exit 方法分别对应着场景的初始化逻辑和退出逻辑。on_inputon_updateon_draw 分别对应主循环的输入、更新和绘图三个阶段并传入了各自所需的参数。具体的场景子类只需要继承后重写自己的逻辑即可。而场景管理器的设计则用来控制游戏当前正在运行的场景。我们在跳转场景时使用了枚举来屏蔽具体的场景指针,而场景管理器同样有 on_inputon_updateon_draw 三个方法来调用当前场景实例的对应逻辑 。

main 函数

main.cpp 中,主要实现了两部分功能:资源加载游戏入口函数。首先加载游戏所需的像素字体,随后加载游戏中动画的图片资源并对需要进行左右朝向翻转的素材进行了处理,最后再载入游戏的背景音乐和音效。在进程的入口函数中,我们调用资源加载函数并实例化此场景,最后在游戏的主循环中调用场景管理器的各个阶段逻辑入口方法。

Atlas 图集类

在游戏的动画实现上,首先封装了Atlas 图集类。图集更像是容器的概念,用来批量加载和存储一套动画所需的图片素材。而 Animation 动画类则更像是一个只记录动画当前播放进度的轻量管理器。在渲染时具体绘制哪张图片则需要实时地去对应的图集类中获取。除此之外,Animation类 还记录着帧间隔、循环播放和播放结束逻辑等动画数据。

Camera 摄像机类

在动画渲染时,使用自行封装的 Camera 摄像机对象。摄像机在此项目的设计中只是充当了绘图时的相对定位锚点,当这个定位点的位置在一定范围内随机跳跃时,我们绘图的内容也在快速地抖动,这就实现了摄像机震动的效果。为了更简洁地编写使用摄像机进行绘图的代码,在 Util.h 中实现了使用摄像机进行透明贴图绘制的函数重载,以及翻转图片和生成纯白剪影效果的图像处理代码逻辑。

Timer 定时器类 和 Vector2 二维向量类

除此之外,游戏框架基础架构还有两个很关键的封装:定时器类和二维向量类。在 Timer.h 中封装了定时器的逻辑:定时器是一个在游戏中记录时间流逝的对象,当时间到达预设的时间的那一帧便会调用指定的回调函数逻辑。二维向量类则封装着和2D向量有关的数学运算:除去四则运算的重载外,还有获取向量长度以及标准化向量的方法。

场景子类:MenuScene、SelectorScene和GameScene

游戏启动后首先来到菜单场景。菜单场景十分简单,只需要播放音乐并处理跳转逻辑即可。而在随后的角色选择场景,我们使用了大量代码来实现硬编码的界面元素布局、在绘图时需要根据玩家选择的不同角色枚举值绘制不同的角色对象和动态的背景图、在输入时也是根据玩家1和玩家2的不同键位的键码值将角色的枚举值循环切换。而在角色选择界面退出时,我们根据当前两位玩家的枚举值来实例化不同的角色并设置头像和玩家ID。玩家ID也是使用枚举进行记录的,我们在 PlayerID.h 中进行了定义。

在游戏的局内场景中,我们需要处理的逻辑可以分为三部分:

  • 与游戏世界相关的内容,如背景图和平台

  • 与玩家角色相关的内容,如角色本身和飞行在场景中的子弹

  • 与游戏胜负相关的内容,如实时监测角色的位置和生命值,并在游戏结束时渲染结算条幅

调试模式的开启与关闭也是在游戏场景中进行控制的。

Player 玩家基类

在玩家角色的设计中,我们定义了Player基类来描述共有的逻辑。在画面表现上,玩家角色拥有闲置、奔跑、攻击、死亡等不同的动画效果,以及起跳、落地和奔跑时的粒子特效等内容。在数据逻辑上,玩家的普通攻击和特殊攻击分别由冷却时间定时器和能量值进行控制。奔跑、起跳和落地等逻辑也封装成对应的方法,方便触发时在其中修改角色速度和更新特效动画。除此之外,玩家角色在受伤后会有一小段无敌时间并产生闪烁的画面效果,我们同样使用定时器来进行控制。

与物理模拟相关的逻辑封装到了 move_and_collide 方法中:我们根据重力和当前的速度值更新了玩家的位置 ,并进行了平台和子弹的碰撞检测。

Peashooter 子类 和 Sunflower 子类

有了Player基类的基础,豌豆射手的子类实现就简单很多了,只需要配置其动画属性并设置攻击时角色在场景中生成子弹的逻辑。我们还用了随机音效来优化游戏效果。

向日葵子类的实现也是同理:在配置完成动画属性后,重写其普通攻击和特殊攻击的逻辑。只不过向日葵在特殊攻击时头顶会有额外的“日”字动画,所以我们重写了它的渲染方法。

Bullet 子弹基类

对于玩家角色所发射的子弹同样定义了Bullet基类进行抽象。子弹与玩家一样,需要在游戏场景中连贯运动,还需要模拟受重力影响的抛射,所以我们还是优先描述其速度,并在更新时记录其位置。 除此之外,子弹还要有伤害值、碰撞目标和碰撞回调等字段。

PeaBullet 子类 和 SunBullet 子类、SunBulletEx 子类

在豌豆子弹的实现中,我们重写了破碎的方法,播放了随机的音效,并在渲染时根据当前是否已经破碎选择了不同的渲染逻辑。小型热光炸弹也是相似的思路,只不过我们需要在它未碰到爆炸时模拟受重力影响的坠落运动,并在爆炸时调整爆炸动画的中心位置对齐。大型热光炸弹与小型热光炸弹除去尺寸、动画等数值字段不同外,更新时的区别是需要让它匀速竖直下落而不是在重力影响下加速运动。

Particle 粒子类

粒子对象在目前的设计中可以看作是一种特殊的动画对象,它在自身的动画播放结束后生命周期便结束了。所以在更新时的逻辑与动画对象十分相似。

Platform 平台类

平台对象Platform在数据层面本质是一条存在于世界中的水平直线,在渲染时我们使用平台图片素材进行绘制并根据当前是否启用了调试选择绘制额外的信息。

StatusBar 玩家状态类

玩家的状态栏组件有三部分构成:玩家头像、生命值状态条和能量值状态条。在游戏场景运行的过程中,我们实时地从对应的玩家对象中读取这些字段的数据并显示到界面组件上。

至此,这一项目的笔记全部完结。

完整源码

on Gitee

复盘和总结

这是我第一次学习一个完整的游戏项目是如何自顶而下设计的。一开始的框架部分感觉很难想到,也因为缺少像游戏层这样可以频繁而又直观快捷地进行测试而感到没有把握。但是一旦基础的框架搭建完成,后面的游戏层实现就变成了水到渠成的事情,有一种顺风顺水的畅快感。即使有了前面 提瓦特幸存者 项目的基础,这个项目还是充斥着大量的实现细节,稍不留神就会出现意想不到的bug,有一些实现思路如果让我自己来想的话也很难想到或者很容易踩坑,好在老师讲解清晰、代码简明易懂,让人很好理解。这个项目再次让我深深意识到自己目前在编程的认知阶段:懂了一点后发现,自己不懂的和不足的还有很多很多。所以需要持续保持锤炼和学习。

Licensed under CC BY-NC-SA 4.0
最后更新于 Oct 16, 2025 11:10 +0200
发表了13篇文章 · 总计9万0千字
本博客已运行
使用 Hugo 构建
主题 StackJimmy 设计