环境准备
Visual Studio 2019 Community(或任意VC10以上)
这里给出的Visual Studio链接是2022的,可以在页面最下端找到较早的下载项选项。详细的安装过程请移步教程,大家注意一下把Visual Studio以及各种组件的安装路径更改为非系统盘,以免占用过多系统空间。同时请大家一定要选择下载Community版本,功能足够大多数人使用,并且免费。
EasyX
直接点击最右端的下载EasyX即可,请在安装完Visual Studio之后下载运行,选择对应版本的Visual Studio即可(Visual Studio安装成功的话一般会自动检测到对应版本)
当然考虑到可能大部分用户只是想运行一下代码,这里直接放上度娘连接。把exe和准备好的mp3文件放在同一目录下即可播放bgm,这里我准备了一首稻香。想要其他BGM的小伙伴,可以把喜欢的音乐的mp3文件改为song.mp3,放在与heart.exe同一目录即可。
heart(2)相比heart(1)增加了光晕
heart(2).exe
提取码:an3j
改进了一下代码,解决了频闪问题,旧的exe已被替换
heart(1).exe
提取码:6eo0
song.mp3
提取码:5l0w
cpp代码
提取码:0l6s
exe文件只有56KB,大家放心的下载,歌曲的话可以自行更换(3M左右的歌曲可能得下个十几分钟吧),只要保证与heart.exe在同一目录且mp3文件名为song.mp3即可。
代码编写说明
这个程序是对b站up 码农高天 的视频进行的改写,因为Python代码不方便移植(得让用户安装Python解释器),用PyInstaller打包的exe体积又很大,所以产生了用C++重写一遍的念头(流畅性和效果比不上,也没有添加光晕效果。因为EasyX我也是现学的,清屏并且重绘操作延迟还是太大,能看见不连贯)。如果有更好的实现方式,欢迎大家在评论区补充。下面直接对用C++编写的代码进行讲解(Pyhon代码在原视频评论区有笔记,有Python环境的话复制粘贴可以直接运行)。
头文件引入
// 这句代码是实用math.h里面定义好的常量,如 M_PI
# define _USE_MATH_DEFINES
// 下面两个应该是easyx的库
# include<graphics.h>
# include<conio.h>
// 使用常见的数学运算,以及类似pi(M_PI)这种常量
# include<math.h>
// 这里是调试的时候控制台输出用的,可以省略不加
# include<iostream>
// 关于list有必要做一下说明,因为Python代码是用set()实现的,但是
// C++下用set存储像素点绘图产生了解释不来的BUG,所以我换成了list
// 有相同作用的数据结构大家可以自行替换
# include<list>
// 粒子的扩散通过随机数实现
# include<random>
// 应该是Windows提供的API,这里用作播放mp3文件,请务必使用Winmm.lib的库
# include<windows.h>
# include <mmsystem.h>
# pragma comment(lib,"Winmm.lib")
using namespace std;
全局变量的定义
// 定义窗口的大小
const int Canvas_heiht = 640;
const int Canvas_width = 480;
// 找到窗口的中心点,注意很多窗口的坐标原点在窗口左上角
// 水平向右为x轴正方向,竖直向下为y轴正方向
const int center_x = Canvas_width / 2;
const int center_y = Canvas_heiht / 2;
// 定义心形轮廓的缩放
const int image_sacle = 12;
// up的Python代码是用随机数产生点实现的,这里我直接用一个完整的弧度(0,2pi)进行计算
// 因为math.h中的sin(t)、cos(t)接受的是弧度值,所以定义一下一弧度的大小,方便后续累加计算
const float one_angle = M_PI / 180;
// 随机数产生相关
// 设定随机种子
default_random_engine random(time(NULL));
// 下面这行代码,因为心形函数绘制我换成了确定值的计算而不是通过随机数产生弧度,所以注释掉了
// uniform_real_distribution<float> dis(0, 2 * M_PI);
// 在心形内部产生扩散的像素点
uniform_real_distribution<float> scatter(0, 1);
// 根据up原话,需要在心形更内部的地方产生扩散的像素点
// choice用于选择心形轮廓哪个像素点进行扩散
uniform_int_distribution<int> choice(0, 1);
// 因为我们编写的代码心是跳动的,expand用于计算像素点变化相关的函数
uniform_int_distribution<int> expand(-1, 1);
uniform_int_distribution<int> expand_size(0, 2);
心形函数
// 这里我感觉用引用方式传递数组更好,但是昨天编写时间紧张,没找到解决办法
void heartfunction(double t, int*p) {
// t:接收的弧度制
// *p:一个指针,用于指向一个长度为2的整型数组(因为Python可以有多个返回值,C++不允许,所以在函数外定义一个整形数组保存产生像素点的x、y坐标)
// x和y用于产生心形的轮廓,注意t是弧度制不是角度值
double x = 16 * pow(sin(t), 3);;
double y = -(13 * cos(t) - 5 * cos(2 * t) - 2 * cos(3 * t) - cos(4 * t));
// 对心形的轮廓进行放大
x *= image_sacle;
y *= image_sacle;
// 将心形移至窗口正中间(居中显示)
x += center_x;
y += center_y;
// 将产生的x、y坐标用数组进行接收,以便有序保存(因为C++不支持返回多个值,除非用数组、结构体这种实现方式)
p[0] = x;
p[1] = y;
}
显示效果(从视频中截取的Python的实现效果,以下不再说明)
这里仅仅展示效果,因为现在我要显示图像得回退代码,已经懒得搞了。之后按照原视频Python的写法,在C++里面也写了一个Heart类。由于代码改了好多次,这是一个最终呈现的结果,一步一步的过程可以移步开头提到的视频
class Heart {
public:
// 保存心形轮廓点,因为C++不能像Python那样方便的用list()、set()添加多个数据。
// list<int>长度为2,保存x坐标和y坐标。
list<list<int>> points;
// 保存第一次扩散的点(不那么靠近心的中心位置)
list<list<int>> scatterPoints;
// 保存第二次扩散的点(比较靠近心的中心位置)
list<list<int>> insidePoints;
// 因为要让静止的图像动起来,就是要让像素点移动嘛,用frame保存每一帧所有像素点(新的轮廓还有扩散的点)的位置
list<list<list<int>>> frame; // 保存帧用来循环播放
// 设置生成的总帧数(本以为越多越平滑,但是实践发现要平滑跳动需要一个更好的数学公式)
int gen_frame;
// set<>
Heart() {
// 设置线条颜色(实际上比较接近红色,但不是纯红)
setlinecolor(RGB(255, 99, 71));
// 设置背景为纯黑
setbkcolor(BLACK);
// 因为粒子是用非常小的矩形(几个像素大小)实现的,因此设置矩形内部的填充颜色
setfillcolor(RGB(255, 99, 71));
// 生成200帧图片,原视频为20
gen_frame = 200;
// 原写法是this->build(2000),下面的void build(int num),但是粒子的生成我没有用随机数实现,而是用了从0到2pi的定值计算(上传的cpp代码没改)
this->build();
}
// 生成每一帧的粒子位置
void build() {
// 保存heartfunction函数产生的像素点的x、y位置
int posArray[2];
// 保存scatter_inside函数产生的扩散的像素点的x、y位置,该变量被多个函数复用
int scatterArray[2];
// 用来保存一对儿像素点,该变量也被多个过程复用,注意使用.clear()方法清空不需要的数据。
list<int> tmpArray;
// 初始角度值
double angle = 0;
while (angle < 2 * M_PI) {
// 在while循环里计算心形轮廓的像素位置,使用的函数将在之后进行简要的介绍(大部分都是数学公式的编程实现)
heartfunction(angle, posArray);
// 使得生成的心形轮廓更加平滑
shrink(posArray[0], posArray[1], posArray);
// 保证points每次接受的list长度为2(一个像素点的x、y坐标)
tmpArray.clear();
// 使用tmpArray暂存生成的x、y坐标
tmpArray.push_back(posArray[0]);
tmpArray.push_back(posArray[1]);
// 保存心形轮廓的x、y坐标
points.push_back(tmpArray);
// 累加角度值,用于之后心形轮廓像素点位置的计算
angle += one_angle;
// 该for循环用于计算信心轮廓的扩散后的位置,与points过程较为类似,只是存储用的变量变成了scatterPoints,计算用的函数变成了scatter_inside()
for (int i = 0; i < 3; i++) {
tmpArray.clear();
scatter_inside(posArray[0], posArray[1], scatterArray);
tmpArray.push_back(scatterArray[0]);
tmpArray.push_back(scatterArray[1]);
scatterPoints.push_back(tmpArray);
}
}
// 这部分代码我的理解是循环4000次,循环的过程中随机抽点再进行扩散,迭代器的知识一篇博文已经不太够了,大家有兴趣可以自行查阅C++ STL相关知识
list<list<int>>::iterator i = points.begin();
list<int>::iterator j;
for (int m = 0; m < 4000; m++) {
// 以1/2的概率让轮廓上的点进行第二次扩散(使得轮廓显得更厚实),使用的函数仍然是scatter_inside()
// 第二次扩散后的点保存在insidePoints里面
if (choice(random) == 0) {
j = (*i).begin();
tmpArray.clear();
int tmpx = *j;
j++;
int tmpy = *j;
scatter_inside(tmpx, tmpy, scatterArray);
tmpArray.push_back(scatterArray[0]);
tmpArray.push_back(scatterArray[1]);
insidePoints.push_back(tmpArray);
}
i++;
if (i == points.end())
i = points.begin();
}
// 通过calc()函数生成每一帧像素点应在的位置,以后通过frame可以循环播放。
for (int i = 0; i < gen_frame; i++) {
calc(i, &frame);
}
}
// 计算像素点的运动,几乎是纯数学,大家可以尝试自行更换公式
void cal_position(int x, int y, float ratio, int*p) {
float force = 1.0 / pow((pow(x - center_x, 2) + pow(y - center_y, 2)), 0.52);
float dx = ratio * force * (x - center_x) + expand(random);
float dy = ratio * force * (y - center_y) + expand(random);
p[0] = (int)(x - dx);
p[1] = (int)(y - dy);
}
// 通过调用cal_position()函数计算已经生成的像素点(轮廓以及经过2次扩散后产生的像素点)
void calc(int frame, list<list<list<int>>> *p) {
// float ratio = 10 * sin(frame * 2 / gen_frame * M_PI);
float ratio = 40 * (sin(gen_frame / 1 * frame)) / (M_PI);
list<list<int>> all_points;
list<int> current_points;
int size;
int tmppoint[2];
// 轮廓线上的点运动
for (list<list<int>>::iterator i = points.begin(); i != points.end(); i++) {
list<int>::iterator j = (*i).begin();
tmppoint[0] = *j;
j++;
tmppoint[1] = *j;
cal_position(tmppoint[0], tmppoint[1], ratio, tmppoint);
size = expand_size(random);
current_points.push_back(tmppoint[0]);
current_points.push_back(tmppoint[1]);
current_points.push_back(size);
all_points.push_back(current_points);
current_points.clear();
}
// 内部点的运动
for (list<list<int>>::iterator i = scatterPoints.begin(); i != scatterPoints.end(); i++) {
list<int>::iterator j = (*i).begin();
tmppoint[0] = *j;
j++;
tmppoint[1] = *j;
cal_position(tmppoint[0], tmppoint[1], ratio, tmppoint);
size = expand_size(random);
current_points.push_back(tmppoint[0]);
current_points.push_back(tmppoint[1]);
current_points.push_back(size);
all_points.push_back(current_points);
current_points.clear();
}
for (list<list<int>>::iterator i = insidePoints.begin(); i != insidePoints.end(); i++) {
list<int>::iterator j = (*i).begin();
tmppoint[0] = *j;
j++;
tmppoint[1] = *j;
cal_position(tmppoint[0], tmppoint[1], ratio, tmppoint);
size = expand_size(random);
current_points.push_back(tmppoint[0]);
current_points.push_back(tmppoint[1]);
current_points.push_back(size);
all_points.push_back(current_points);
current_points.clear();
}
(*p).push_back(all_points);
}
// 使得轮廓位置像素点更加圆润平滑
void shrink(int x, int y, int*p, float ratio = 0.5) {
float force = 1.0 / pow((pow(x - center_x, 2) + pow(y - center_y, 2)), 0.6);
float dx = ratio * force * (x - center_x) + expand(random);
float dy = ratio * force * (y - center_y) + expand(random);
p[0] = (int)(x - dx);
p[1] = (int)(y - dy);
}
// 将产生的像素点输出到屏幕上
void render() {
// 这里感觉通过把frame转成数组可以进一步加快时间,因为少了解引用的操作。以后要是有兴趣再做更改吧(不过感觉以后就没有兴趣了)
for (list<list<list<int>>>::iterator i = frame.begin(); ; ) {
// clearrectangle(30, 150, Canvas_width - 30, Canvas_heiht - 80);
// 没有找到EasyX清屏的方法,只能手动清屏。以为通过solidrectangle的方式能加快一些清屏速度,单数好像并没有什么用,还是能看到明显的清屏痕迹
setfillcolor(BLACK);
solidrectangle(30, 150, Canvas_width - 30, Canvas_heiht - 80);
// 设置文本
settextcolor(RGB(238,99,99));
// 这里_T("")可以注意一下,Visual Studio对文本的编码方式有可能导致一些字符串报错
settextstyle(20, 0, _T("仿宋"));
outtextxy(20, 20, L"功成名就不是目的,让自己快乐快乐这才叫做意义");
// 通过for循环填充像素点(以矩形方式填充一个或多个像素点)
for (list<list<int>>::iterator j = (*i).begin(); j != (*i).end(); j++) {
list<int>::iterator k = (*j).begin();
int tmpx = *k;
k++;
int tmpy = *k;
k++;
int size = *k;
setfillcolor(RGB(255, 99, 71));
fillrectangle(tmpx, tmpy, tmpx + size, tmpy + size);
}
// 通过Sleep(100)延迟100ms,避免窗口刷新过快(有点鬼畜)
Sleep(100);
i++;
// 循环播放动画,播放到最后一帧的时候跳转到第一帧继续播放
if (i == frame.end())
i = frame.begin();
}
}
};
scatter_inside()函数
// 用于控制粒子向轮廓内部扩散
void scatter_inside(int x, int y, int* p, float beta = 0.25) {
float ratiox = - beta * log10(scatter(random));
float ratioy = -beta * log10(scatter(random));
float dx = ratiox * (x - center_x);
float dy = ratioy * (y - center_y);
p[0] = (int)(x - dx);
p[1] = (int)(y - dy);
}
主函数
int main() {
// 设置窗口大小
initgraph(Canvas_width, Canvas_heiht);
// 实例化Heart对象
Heart heart;
// 看样子是通过类似cmd命令行的方式打开文件并播放音乐
// ./表示当前目录(即与exe同目录),./song.mp3表示同目录下的song.mp3文件
// 需要注意一下用L解决字符串报错的问题
mciSendString(L"open ./song.mp3 alias mymusic", NULL, 0, NULL);
mciSendString(L"play mymusic repeat", NULL, 0, NULL);
// 渲染画面
heart.render();
// 绘制一帧图像的时候,如果没有getchar(),会导致窗口一闪而过
// 由于我们是循环绘制,不存在结束(即不关闭窗口则一直运行heart.render()),所以不需要getchar()
// getchar();
// 程序结束时关闭画布和音乐文件
closegraph();
mciSendString(L"stop mymusic", NULL, 0, NULL);
mciSendString(L"close mymusic", NULL, 0, NULL);
return 0;
}
频闪问题解决啦!
其实就是多了三个函数:BeginBatchDraw()、FlushBatchDraw()和EndBatchDraw()。这三个函数都是EasyX里面自带的。除了EndBatchDraw()写在Heart类的析构函数中以外,前两个函数写在render()里:
~Heart() {
EndBatchDraw();
}
void render() {
for (list<list<list<int>>>::iterator i = frame.begin(); ; ) {
// clearrectangle(30, 150, Canvas_width - 30, Canvas_heiht - 80);
setfillcolor(BLACK);
solidrectangle(30, 150, Canvas_width - 30, Canvas_heiht - 80);
BeginBatchDraw();
settextcolor(RGB(238,99,99));
settextstyle(20, 0, _T("仿宋"));
outtextxy(20, 20, L"功成名就不是目的,让自己快乐快乐这才叫做意义");
for (list<list<int>>::iterator j = (*i).begin(); j != (*i).end(); j++) {
list<int>::iterator k = (*j).begin();
int tmpx = *k;
k++;
int tmpy = *k;
k++;
int size = *k;
setfillcolor(RGB(255, 99, 71));
fillrectangle(tmpx, tmpy, tmpx + size, tmpy + size);
}
FlushBatchDraw();
Sleep(100);
i++;
if (i == frame.end())
i = frame.begin();
}
}
};
这样能起作用可能是因为,遇见FlushBatchDraw()语句才会将绘制的画面输出,循环过一次之后,清屏的操作虽然执行了,但是不再输出。