冰球游戏

冰球游戏

游戏灵感和游戏规则

冰球游戏是我和朋友去电玩城,玩了几盘冰球后,我突发奇想要在电脑上模拟的一款游戏。

图:在电玩城打冰球

我先玩了几局游戏,掌握游戏的玩法和规则。

这个网站的冰球游戏给了我最初的灵感:

https://www.silvergames.com/en/air-hockey

而Youtube博主 javidx9Code 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

游戏分析


图:游戏元素分析

这个冰球游戏的几个元素:

  1. 场地:分为己方半场和对方半场,球场有两个球门,球进了对方球门就得分

  2. 球拍:双方都用球拍来击球

  3. 冰球:全场一个冰球,双方可以用球拍击打冰球,从而攻破对方球门,每局结束冰球都会重置

我就创建了4个类

  1. Field类,场地类,绘制场地

  2. Paddle类,球拍类,绘制球拍,控制球拍运动

  3. Puck类,冰球类,绘制球、控制球体运动

  4. 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类的绘制功能外,还可以自行分析局势,自行移动以击打冰球

GIF:引入游戏AI后的表现

我采用的策略很简单,就是把AI的状态分成防御和进攻
AI根据冰球当前的位置来决定是防御还是进攻

  1. 如果冰球在对方半场: 防御
  2. 如果冰球在己方半场:进攻
    防御和进攻的具体策略可以看下面的代码

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%以下。

posted @ 2024-07-04 20:41  lucky_doog  阅读(3)  评论(0编辑  收藏  举报