EasyX游戏帧动画效果处理

通过对一组动画帧IMAGE对象色彩缓冲区的操作,实现了动画帧翻转、无敌帧闪烁、冻结三种常见的游戏效果,本文介绍这三种效果的相关概念和实现思路。程序中所使用的帧动画素材为提瓦特幸存者进阶版中分享的素材,需要测试的小伙伴可以在文末找到相关素材的下载链接。

原始动画和三种效果

色彩缓冲区

我们首先来看一下EasyX中IMAGE类的声明:

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
// Image class
class IMAGE
{
public:
int getwidth() const; // Get the width of the image
int getheight() const; // Get the height of the image

private:
int width, height; // Width and height of the image
HBITMAP m_hBmp;
HDC m_hMemDC;
float m_data[6];
COLORREF m_LineColor; // Current line color
COLORREF m_FillColor; // Current fill color
COLORREF m_TextColor; // Current text color
COLORREF m_BkColor; // Current background color
DWORD* m_pBuffer; // Memory buffer of the image

LINESTYLE m_LineStyle; // Current line style
FILLSTYLE m_FillStyle; // Current fill style

virtual void SetDefault(); // Set the graphics environment as default

public:
IMAGE(int _width = 0, int _height = 0);
IMAGE(const IMAGE &img);
IMAGE& operator = (const IMAGE &img);
virtual ~IMAGE();
virtual void Resize(int _width, int _height); // Resize image
};

如果我们将IMAGE用来表示图片对象,那么其中最关键的部分就是m_pBuffer,这个缓冲区中存储了图片的像素色彩数据,可以使用GetImageBuffer函数获取到缓冲区地址。

在缓冲区中,每个像素点的色彩数据占用 4 个字节,这些数据按照图片从左到右、从上向下的顺序依次排列,也就是说,虽然EasyX的绘图函数大多不会考虑透明通道的信息,但是在使用loadimage加载到程序中的图片数据还是保留了透明通道的数据。

除此之外,还需要特别注意的是,缓冲区中的每个像素点数据色彩是B、G、R顺序排布的,所以如果我们想要为它重新赋值,在使用RGB()宏构造COLORREF后,还需要使用BGR()宏交换对应位的数据才能表达正确的颜色;同理,如果想要直接获取缓冲区像素对应的颜色分量,GetBValue()宏获取到的实际是红色分量的值,而GetRValue()宏获取到的才是蓝色分量的值。

动画帧翻转

在很多2D游戏的制作中,玩家角色是分为向左和向右两套动画,而大多数来自互联网的素材包中大多只会提供一个方向的动画帧序列,翻转的操作一般是在现代化的游戏制作工具中动态处理的。虽然我们可以使用PS将图片处理为水平镜像,但是这有点过于冗余了,我们希望可以在游戏运行时动态地生成翻转后的图片,可是EasyX并没有提供将图片翻转的函数,所以我们需要自己实现这部分内容。

首先是动画序列帧图片的加载:

1
2
3
4
5
6
7
8
9
IMAGE img_player_left[6];

// 加载玩家向左动画的图片
for (int i = 0; i < 6; i++)
{
static TCHAR img_path[256];
_stprintf_s(img_path, _T("img/paimon_left_%d.png"), i);
loadimage(&img_player_left[i], img_path);
}

随后,我们可以对每一张向左的动画图片,逐行翻转水平坐标的像素,从而实现左右镜像的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
IMAGE img_player_right[6];

// 生成玩家向右动画的图片
for (int i = 0; i < 6; i++)
{
// 调整向右动画图片尺寸大小
int width = img_player_left[i].getwidth();
int height = img_player_left[i].getheight();
Resize(&img_player_right[i], width, height);
// 遍历图片的色彩缓冲区,逐行水平翻转拷贝像素数据
DWORD* color_buffer_left_img = GetImageBuffer(&img_player_left[i]);
DWORD* color_buffer_right_img = GetImageBuffer(&img_player_right[i]);
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int idx_left_img = y * width + x; // 源像素索引
int idx_right_img = y * width + (width - x - 1); // 目标像素索引
color_buffer_right_img[idx_right_img] = color_buffer_left_img[idx_left_img];
}
}
}

无敌帧闪烁

动画闪白的效果很常见,一般来说,当玩家受伤后,会进入到一小段时间的无敌状态,来避免每帧进行伤害检定时造成多余的伤害,而动画序列帧的闪白可以说是一种廉价且经典的效果。在很多游戏的素材包中,制作者会提供角色动画序列帧对应的白色剪影序列帧;但是随着角色动画越来越多,每种动画都需要对应一套额外的白色剪影动画,这就显得很麻烦了。

我们同样可以在运行时通过对像素缓冲的处理生成一套全新的白色剪影序列帧,只需要对透明度不为0的像素的RGB值设置为白色即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
IMAGE img_player_left_sketch[6];

// 生成玩家向左动画的剪影
for (int i = 0; i < 6; i++)
{
// 调整剪影动画图片尺寸大小
int width = img_player_left[i].getwidth();
int height = img_player_left[i].getheight();
Resize(&img_player_left_sketch[i], width, height);
// 遍历图片的色彩缓冲区,将透明度不为 0 的像素处理为白色
DWORD* color_buffer_raw_img = GetImageBuffer(&img_player_left[i]);
DWORD* color_buffer_sketch_img = GetImageBuffer(&img_player_left_sketch[i]);
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int idx = y * width + x;
if ((color_buffer_raw_img[idx] & 0xFF000000) >> 24)
color_buffer_sketch_img[idx] = BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) << 24);
}
}
}

而在渲染时,只需要每隔一段时间交替绘制正常的序列帧图片和白色剪影图片,即可实现闪烁的效果。

冻结效果

冻结的效果在游戏中也很常见,主要由两部分构成:暂停动画帧、混叠结晶图。我们需要准备好一张与角色动画尺寸相同或稍大的冰冻结晶图素材:

结晶贴图

暂停动画帧很简单,只需要当角色进入到冻结状态时停止动画帧计时器的更新,让图片序列帧的索引停止在当前数值即可;混叠结晶图指的是将结晶贴图的RGB分量与当前动画帧的RGB分量进行混合,相当于将结晶贴图处理为了半透明状态贴到了玩家贴图之上;当然,为了让结晶只出现在玩家身上,所以只需要处理玩家动画帧中透明通道不为0的像素。

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
// 拷贝当前帧用于后续处理
IMAGE img_current_frame(img_player_left[counter]);
int width = img_current_frame.getwidth();
int height = img_current_frame.getheight();

// 遍历当前帧的色彩缓冲区,将不透明区域进行混叠
DWORD* color_buffer_ice_img = GetImageBuffer(&img_ice);
DWORD* color_buffer_frame_img = GetImageBuffer(&img_current_frame);
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int idx = y * width + x;
static const float RATIO = 0.25f; // 混叠比率
DWORD color_ice_img = color_buffer_ice_img[idx];
DWORD color_frame_img = color_buffer_frame_img[idx];
if ((color_frame_img & 0xFF000000) >> 24)
{
// 注意,由于色彩缓冲中的颜色为 BGR 顺序,所以需要交换 R 和 B 位置
BYTE r = (BYTE)(GetBValue(color_frame_img) * RATIO + GetBValue(color_ice_img) * (1 - RATIO));
BYTE g = (BYTE)(GetGValue(color_frame_img) * RATIO + GetGValue(color_ice_img) * (1 - RATIO));
BYTE b = (BYTE)(GetRValue(color_frame_img) * RATIO + GetRValue(color_ice_img) * (1 - RATIO));
color_buffer_frame_img[idx] = BGR(RGB(r, g, b)) | (((DWORD)(BYTE)(255)) << 24);
}
}
}

这样就可以实现最基础的冻结效果,如果想要让效果更逼真一点,让结晶之上存在流动的高光效果,那么就可以用一个循环的条带,不断地从头到脚扫描冻结后的贴图,条带范围内的像素被处理为白色,也就是这样的效果:

高亮扫描线

但是这样规则的扫描线有点太过于生硬了,结合现实情况考虑,冰堆的高光一般是因为某些冰面的反射方向恰好处于观察者视角处导致的,也就是结晶贴图中较亮的部分,所以流光效果本质是为了强化冰堆中较亮的那部分内容;所以,我们可以对混叠后的贴图首先进行亮度提取,只有亮度大于阈值的部分,才会显示为白色的条带,这样整体效果就会自然很多:

优化后的高亮效果

冻结效果动画相关的完整代码:

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
void RenderFrozenPlayer()
{
static const POINT position = { 1075, 345 };

static int counter = 0; // 动画帧索引
static int anim_timer = 0; // 动画计时器
static int frozen_timer = 0; // 冰冻状态计时器
static const int THICKNESS = 5; // 扫描线宽度
static int highlight_pos_y = 0; // 扫描线竖直坐标
static bool is_frozen = false; // 当前是否正在冰冻

// 如果没有处于冻结状态则更新动画计时器
if ((!is_frozen) && (++anim_timer % 3 == 0))
counter = (counter + 1) % 6;
// 更新冻结状态计时器并重置扫描线位置
if (++frozen_timer % 100 == 0)
{
is_frozen = !is_frozen;
highlight_pos_y = -THICKNESS;
}

// 绘制玩家脚底阴影
putimage_alpha(position.x + (80 - 32) / 2, position.y + 80, &img_shadow);
// 根据当前是否处于冻结状态渲染不同的动画帧
if (is_frozen)
{
// 拷贝当前帧用于后续处理
IMAGE img_current_frame(img_player_left[counter]);
int width = img_current_frame.getwidth();
int height = img_current_frame.getheight();
// 更新高亮扫描线竖直坐标
highlight_pos_y = (highlight_pos_y + 2) % height;
// 遍历当前帧的色彩缓冲区,将不透明区域进行混叠
DWORD* color_buffer_ice_img = GetImageBuffer(&img_ice);
DWORD* color_buffer_frame_img = GetImageBuffer(&img_current_frame);
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int idx = y * width + x;
static const float RATIO = 0.25f; // 混叠比率
static const float THRESHOLD = 0.84f; // 高亮阈值
DWORD color_ice_img = color_buffer_ice_img[idx];
DWORD color_frame_img = color_buffer_frame_img[idx];
if ((color_frame_img & 0xFF000000) >> 24)
{
// 注意,由于 COLORREF 中的颜色为 BGR 顺序,所以需要交换 R 和 B 位置
BYTE r = (BYTE)(GetBValue(color_frame_img) * RATIO + GetBValue(color_ice_img) * (1 - RATIO));
BYTE g = (BYTE)(GetGValue(color_frame_img) * RATIO + GetGValue(color_ice_img) * (1 - RATIO));
BYTE b = (BYTE)(GetRValue(color_frame_img) * RATIO + GetRValue(color_ice_img) * (1 - RATIO));
// 如果高亮扫描线处的像素亮度大于阈值,则直接将该像素设置为纯白色
if ((y >= highlight_pos_y && y <= highlight_pos_y + THICKNESS)
&& ((r / 255.0f) * 0.2126f + (g / 255.0f) * 0.7152f + (b / 255.0f) * 0.0722f >= THRESHOLD))
{
color_buffer_frame_img[idx] = BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) << 24);
continue;
}
color_buffer_frame_img[idx] = BGR(RGB(r, g, b)) | (((DWORD)(BYTE)(255)) << 24);
}
}
}
putimage_alpha(position.x, position.y, &img_current_frame);
}
else
putimage_alpha(position.x, position.y, &img_player_left[counter]);
}

素材下载

序列帧素材链接:https://pan.baidu.com/s/1CQ86MR3AnTUbSqHbkCgBRg?pwd=7777

作者

Voidmatrix

发布于

2023-12-31

更新于

2024-01-02

许可协议

评论