冰球游戏
冰球游戏
游戏灵感和游戏规则
冰球游戏是我和朋友去电玩城,玩了几盘冰球后,我突发奇想要在电脑上模拟的一款游戏。
图:在电玩城打冰球
我先玩了几局游戏,掌握游戏的玩法和规则。
这个网站的冰球游戏给了我最初的灵感:
https://www.silvergames.com/en/air-hockey
而Youtube博主 javidx9 的 Code it Yourself 系列游戏教程视频,给了我具体实现的启发
https://www.youtube.com/watch?v=8OK8_tHeCIA&list=PLrOv9FMX8xJE8NgepZR1etrsU63fDDGxO
b站也有搬运他的视频:
https://www.bilibili.com/video/BV1ET421171Z?p=12
他的开源引擎 olcPixelGameEngine ,给了我实现的代码基础
https://github.com/OneLoneCoder/olcPixelGameEngine
我也将把自己写的游戏开源,欢迎大家游玩和评论
https://github.com/wangqqiyue/Games/tree/master/ice_hockey
游戏分析
这个冰球游戏的几个元素:
-
场地:分为己方半场和对方半场,球场有两个球门,球进了对方球门就得分
-
球拍:双方都用球拍来击球
-
冰球:全场一个冰球,双方可以用球拍击打冰球,从而攻破对方球门,每局结束冰球都会重置
我就创建了4个类
-
Field类,场地类,绘制场地
-
Paddle类,球拍类,绘制球拍,控制球拍运动
-
Puck类,冰球类,绘制球、控制球体运动
-
IceHockey类,冰球游戏类,作为游戏整体的控制类,用于初始化游戏对象、控制游戏帧率、管理游戏比分、绘制背景动画、播放音乐等
碰撞模拟
这个游戏主要涉及的物理技术就是运动的球体间相互碰撞的模拟
所谓碰撞:指的是物体A以一定的速度和质量,在一定角度与具有一定速度和质量的物体B发生碰撞,从而产生二者运动状态的同时改变。
这个公式是从书上找到,参考了《游戏开发物理学》
碰撞响应代码
转成实际代码就是:
void IceHockey::CollisionResponse(Paddle& paddle) {
olc::vf2d vPaddle = paddle.v;
olc::vf2d vRelative = puck.velocity - vPaddle;
olc::vf2d vDis = puck.position - paddle.pos;
olc::vf2d vNormal = vDis.norm();//碰撞垂向量
float vRn = vRelative.dot(vNormal);
float dis = vDis.mag();
float sumR = paddle.outerR + puck.radius;
//距离小于两者半径和,且相对移动速度为正
if (dis < sumR && vRn<0.0f) {
float j = -2.0f * vRn / vNormal.dot(vNormal);
j /= (1.0f / paddle.mass + 1.0f / puck.mass);
puck.velocity += j * vNormal * 1.0f / puck.mass;
paddle.v -= j * vNormal * 1.0f / paddle.mass;
PlaySound(NULL, 0, 0);//先停止其他声音
PlaySound(bound_sound_file, NULL, SND_FILENAME | SND_ASYNC);
//调整位置
paddle.pos -= (sumR-dis)*vDis.norm();
}
}
引入游戏AI
另外为了增强游戏可玩性,我还增加了一个AiPaddle类,即Ai球拍,它继承自Paddle类
它除了Paddle类的绘制功能外,还可以自行分析局势,自行移动以击打冰球
我采用的策略很简单,就是把AI的状态分成防御和进攻
AI根据冰球当前的位置来决定是防御还是进攻
- 如果冰球在对方半场: 防御
- 如果冰球在己方半场:进攻
防御和进攻的具体策略可以看下面的代码
void IceHockey::AiResponseStrong(AiPaddle& paddle) {
paddle.speedEasy = SPEED_MAX / 10;
paddle.speedNormal = SPEED_MAX / 5;
paddle.speedHard = SPEED_MAX/2;
olc::vf2d nMove = { 0.0f,0.0f };
olc::vf2d pCenter= { ScreenWidth() / 2.0f,ScreenHeight() / 2.0f };
float disX = paddle.pos.x - puck.position.x;
//默认方向是朝向球的方向
nMove = (puck.position - paddle.pos).norm();
srand(time(NULL));
//如果球在对方半场
if ((paddle.side == LEFT && puck.position.x -puck.radius > ScreenWidth()/2.0f) || (paddle.side==RIGHT && puck.position.x+puck.radius < ScreenWidth() / 2.0f)) {
//防守策略
olc::vf2d pIntercept = (puck.position + paddle.posGoal ) /2.0f;
if ((pIntercept - paddle.pos).mag() < 2*paddle.outerR) {
return;
}
nMove = (pIntercept-paddle.pos).norm();
nMove *= {0.9f + 0.2f * rand() / RAND_MAX, 0.9f + 0.2f * rand() / RAND_MAX};
paddle.v = paddle.speedEasy * nMove;
return;
}
//球在己方半场
//进攻策略,当球未越过球拍时采用
if ((paddle.side == LEFT && disX <= 0) || (paddle.side == RIGHT && disX >= 0)) {
//如果球拍到球的和敌方球门的夹角较小,则大力进攻
if ((puck.position - paddle.pos).norm().dot((paddle.posEnemyGoal - paddle.pos).norm()) > 0.7f) {
if ((puck.position - paddle.pos).mag() <= 1.2f*(paddle.outerR + puck.radius)) {
nMove = (paddle.posEnemyGoal - paddle.pos).norm();
nMove *= 2.0f;
}
else {
nMove = (puck.position - paddle.pos).norm();
}
}
//绕道球后面
else {
nMove = (puck.position - paddle.posEnemyGoal).norm() + (puck.position - paddle.pos).norm();
}
nMove *= {0.9f + 0.2f * rand() / RAND_MAX, 0.9f + 0.2f * rand() / RAND_MAX};
paddle.v = paddle.speedNormal * nMove;
//cout << "I'm attacking." << endl;
return;
}
//防守策略,当球已越过球拍时采用
olc::vf2d pIntercept = paddle.posGoal;//拦截点位置
//cout << "I'm defensing." << endl;
//如果球拍击球会导致球进入自己球门,则迂回绕开
if (puck.velocity.mag()>paddle.speedEasy) {
//球拍去拦截球
nMove = (pIntercept - paddle.pos).norm();
}
else {
nMove = (puck.position - paddle.pos).norm();
}
nMove *= {0.6f+0.8f*rand()/RAND_MAX, 0.6f+0.8f* rand() / RAND_MAX};
paddle.v = paddle.speedEasy * nMove;
return;
}
其他技术
为了让游戏更有趣,我还增加了背景音乐,背景图
背景音乐
背景音乐的播放用到了PlaySound
函数,需要引入头文件<windows.h>
和<mmsystem.h>
还要引入winmm库, `#pragma comment(lib,"winmm.lib")
PlaySound的用法, 可以参考微软文档
https://learn.microsoft.com/zh-cn/windows/win32/multimedia/the-playsound-function
背景图
我在使用 PixelGameEngine 时,发现 Sprite 绘制会让帧率大幅降低
于是查询 PixelGameEngine 的教程,发现还可以用 Decal 绘制图片
Decal 绘制用的是GPU资源,绘制起来很快
// Sprites live in RAM and are accessed and manipulated by the CPU.
//DrawSprite(0, 0, bgSprite.get());
//A decal is a sprite that lives on the GPU
// The GPU will draw the decal on top of whatever was drawn by the CPU first.
//SetDrawTarget(bgSprite.get());
SetDecalMode(olc::DecalMode::MULTIPLICATIVE);
DrawDecal({ 10,10 }, bgDecal.get());
GPU绘制Decal,会默认绘制在最上层
而我希望背景是在下层的,不希望遮住场地上的冰球和球拍等物体,但是目前还找不到解决办法
临时解决方案:在绘制Decal之前,SetDecalMode
把绘制模式设为 MULTIPLICATIVE
MULTIPLICATIVE模式指的是两个像素点绘制到一个位置时,最终颜色的处理方法,是把两种像素颜色和ALPHA值叠加
中点椭圆算法
在绘制球场时,我希望尽可能还原真实球场的比例和外表
我查询了真实球场,发现人家有一段弧形的围栏
图:真实球场比例
而我一开始绘制的,只有直角边,且PixelGameEngine自带的Draw函数里,也没有椭圆/圆弧的绘制函数
所以我只能自己查找资料实现弧线边框的绘制
画椭圆函数DrawEllipse
//该函数使用当前画线样式绘制无填充的椭圆
void PixelGameEngine::DrawEllipse(int32_t left, int32_t top, int32_t right, int32_t bottom, Pixel p) {
/*left 椭圆外切矩形的左上角 x 坐标。
top椭圆外切矩形的左上角 y 坐标。
right椭圆外切矩形的右下角 x 坐标。
bottom椭圆外切矩形的右下角 y 坐标。*/
int a = (right - left) / 2;
int b = (bottom-top) / 2;
if (a <= 0 || b <= 0) {
//非法值
return;
}
int centerX = (left + right) / 2;
int centerY = (top + bottom) / 2;
int x, y;
float d1, d2;
x = 0;
y = b;
Draw(centerX+x, centerY+y, p);
Draw(centerX - x, centerY + y, p);
Draw(centerX + x, centerY - y, p);
Draw(centerX - x, centerY - y, p);
d1 = b * b - a * a * b + a * a / 4;//b^2-a^2b+a^2/4
/*Region 1*/
while (a * a * (y - 0.5f) > b * b * (x + 1)) {//a^2(y-1/2) > b^2(x+1)
if (d1 < 0) {
d1 += b * b * (2 * x + 3);//b^2(2x+3)
}
else {
d1 += b * b * (2 * x + 3) + a * a * (-2 * y + 2);//b^2(2x+3)+a^2(-2y+2)
y--;
}
x++;
Draw(centerX + x, centerY + y, p);
Draw(centerX - x, centerY + y, p);
Draw(centerX + x, centerY - y, p);
Draw(centerX - x, centerY - y, p);
}
//d2 = b^2(x+1/2)^2 + a^2(y-1)^2 -a^2b^2
d2 = b * b * (x + 0.5f) * (x + 0.5f) + a * a * (y - 1) * (y - 1) - a * a * b * b;
/*Region 2*/
while (y > 0) {
if (d2 < 0) {
d2 += b * b * (2 * x + 2) + a * a * (-2 * y + 3);//b^2(2x+2) + a^2(-2y+3)
x++;
}
else {
d2 += a * a * (-2 * y + 3);//a^2(-2y+3)
}
y--;
Draw(centerX + x, centerY + y, p);
Draw(centerX - x, centerY + y, p);
Draw(centerX + x, centerY - y, p);
Draw(centerX - x, centerY - y, p);
}
}
画弧线函数DrawArc
//start提供x坐标,end 提供y坐标,d代表绘制的弧线在start-end连线的什么方向
void Field::DrawArc(olc::vf2d start , olc::vf2d end, olc::Pixel c, Direction d) {
olc::vf2d center;
olc::vi2d rotate;
int x, y;
int a, b;
float d1, d2;
a = abs(start.x - end.x);
b = abs(start.y - end.y);
center.x = start.x;
center.y = end.y;
x = 0;
y = b;
switch (d) {
case NW:
rotate.x = -1;
rotate.y = -1;
break;
case NE:
rotate.x = 1;
rotate.y = -1;
break;
case SW:
rotate.x = -1;
rotate.y = 1;
break;
case SE:
rotate.x = 1;
rotate.y = 1;
break;
}
p->Draw(center.x + rotate.x*x, center.y + rotate.y*y, c);
d1 = b * b - a * a * b + a * a / 4;//b^2-a^2b+a^2/4
/*Region 1*/
while (a * a * (y - 0.5f) > b * b * (x + 1)) {//a^2(y-1/2) > b^2(x+1)
if (d1 < 0) {
d1 += b * b * (2 * x + 3);//b^2(2x+3)
}
else {
d1 += b * b * (2 * x + 3) + a * a * (-2 * y + 2);//b^2(2x+3)+a^2(-2y+2)
y--;
}
x++;
p->Draw(center.x + rotate.x * x, center.y + rotate.y * y, c);
}
//d2 = b^2(x+1/2)^2 + a^2(y-1)^2 -a^2b^2
d2 = b * b * (x + 0.5f) * (x + 0.5f) + a * a * (y - 1) * (y - 1) - a * a * b * b;
/*Region 2*/
while (y > 0) {
if (d2 < 0) {
d2 += b * b * (2 * x + 2) + a * a * (-2 * y + 3);//b^2(2x+2) + a^2(-2y+3)
x++;
}
else {
d2 += a * a * (-2 * y + 3);//a^2(-2y+3)
}
y--;
p->Draw(center.x + rotate.x * x, center.y + rotate.y * y, c);
}
}
绘制后
帧率控制
为了降低CPU占用率,我可以设置帧率上限
设置了帧率上限后,CPU占用率从30%多降到了10%以下。