☰
FunCode桌球课程设计:C++状态机、碰撞反弹与精灵编程实战
2026/9/30 8:34:12 网站建设 项目流程

简介:这是一份基于FunCode引擎的C++游戏设计课程设计PDF文档,面向高校C++初学者和游戏编程入门者,完整讲解从零实现桌球小游戏的开发思路。文档从FunCode中创建C++工程、导入snooker模板讲起,逐步覆盖球与球杆初始化、鼠标坐标同步、运动方向虚线绘制、球洞循环换位、桌边碰撞反射、进球判定以及新一轮随机发球等核心逻辑;同时针对小球初始位置仅y轴随机、球杆跟随鼠标移动、球速过大直穿球洞或过小难以进洞等实验要点给出可操作方案。还涉及精灵对象管理、状态机思想和基础向量计算,能帮助读者理解事件驱动编程与简单物理规则在游戏中的落地方式。包体为单个PDF文件,大小约1.2MB,适合作为课程设计参考、期末复习或入门游戏编程的配套材料。目前已有114人学习下载,资源内容精炼紧凑,可直接对照FunCode环境阅读。

1. FunCode 桌球课程设计:撞球进洞背后藏着三个 C++ 硬考点

很多人下载这份《04 FunCode游戏设计+C++课程设计_桌球+.pdf》,第一反应是:桌球游戏不是鼠标点一下就完事?真照着实验指导敲完才发现,这个小小的 FunCode 项目把游戏状态机、向量单位化、数组循环移位、速度衰减和碰撞反弹全串了起来,而且有一个反直觉的设计——球进洞不是“碰到就算”,必须是速度低于 30 且球心落入同色洞精灵内部,速度太快会直接掠过洞口。它适合正在做课程设计、想补 C++ 事件驱动与精灵编程基础的学生,也适合想快速上手 FunCode 模板工程的开发者。

2. 先把规则和状态机理清:桌球游戏的三张表

2.1 两个核心状态位决定“这一杆能不能打”

实验指导全文反复出现两个 int:m_iGameState 和 m_iPlayState。很多同学一开始把它们当成同一个状态来管理,结果 OnMouseClick 里的 if 判断永远不通过。这两个变量一个管“当前对局是否在进行”,一个管“这一杆的球是不是已经打出去了”。m_iGameState 初始值为 1,构造函数里从 0 改成 1;m_iPlayState 初始化为 0。点击事件有效的前提是 m_iGameState == 2 且 m_iPlayState == 0,也就是说游戏必须先进入进行中状态,并且上一杆的球已经完全停止,这一杆才允许挥出。

这里最容易被带偏的是:m_iGameState 在进洞判断里又会被设回 1,那不是“返回主菜单”,而是“重新开局”。配合 m_iPlayState = 0,下一局左侧生成新球。如果你把这两个状态放在一个变量里管理,就会出现点一下没反应、球停不下来、进洞后同一帧又被重复打出去的连锁问题。

状态变量取值含义切换位置
m_iGameState1等待开局/重新开局进洞后设为 1
m_iGameState2游戏进行中,可响应点击进入游戏时
m_iPlayState0球静止,可击打构造函数、停球后
m_iPlayState1球运动中,等待碰撞/进洞OnMouseClick 击球时

m_iPlayState 被置 0 的地方在实验六的进洞判断里:当速度模长 fSpeedValue < 0.50f,说明球已经几乎停住,这时把 m_iPlayState 置 0、m_iGameState 置 1,开始下一局。所以整个游戏的核心循环就是“等待静止 → 点击击球 → 减速运动 → 进洞或停球 → 重新开局”。

2.2 四类精灵资源:球、球杆、球洞、边沿

导入 snooker 模板后,地图里已经有球、球洞、边沿和球杆的素材,但 C++ 侧需要自己 new CSprite。资源命名遵循 CSystem::MakeSpriteName 规则:Ball0 到 Ball7 对应 8 个不同颜色的球;BallHole0 到 BallHole7 对应同色球洞;VerSide0/1 和 HorSide0/1 是台球桌边沿;BallCue 是球杆。构造函数里一次性把 8 个球、8 个球洞、4 个边沿全部 new 出来,后续只靠移动位置控制显隐,而不是反复创建销毁。

为什么要一次性建 8 个球?因为每一局的 m_iBallNumber 是 0-7 里随机的一个数字。如果开局是 3 号球,就把 3 号球移到屏幕内,上一局的球移到屏幕外。这样切局只改两个精灵的位置,逻辑清晰,也避免地图精灵名字和资源名不一致导致的闪烁。球洞精灵同理,只是球洞的编号还会随 HoleChange 循环滚动。

精灵数组元素个数命名规则初始化处
m_spBall[8]8Ball0 - Ball7构造函数
m_spBallHole[8]8BallHole0 - BallHole7构造函数
m_spVerSide[2]2VerSide0 - VerSide1构造函数
m_spHorSide[2]2HorSide0 - HorSide1构造函数
m_spBallCue1BallCue构造函数

这里要记住一个映射关系:m_iBallNumber 的取值范围 0-7 本身也是颜色图片编号,所以当前球的颜色由编号决定,当前球对应的球洞就是 m_spBallHole[m_iBallNumber]。进洞判定里 IsPointInSprite 用的正是这个同名索引,颜色错位往往就是这里出了问题。

2.3 进洞阈值 30 与阻尼 1.6:速度边界的数学关系

实验六的进洞判定给出了两个硬阈值:速度向量模长 fSpeedValue < 30.f 才允许进洞;小于 0.50f 就强制结束这一杆。这个 30 不是 FunCode 的魔法数,而是默认地图尺寸和阻尼参数下打磨出来的手感值。如果你自己改了窗口大小或精灵缩放,原来能进洞的力度可能变成穿洞,原因就是阈值对应的物理距离变了。

再配合实验四的 SetSpriteLinearVelocity(fVectorX * 12.f, fVectorY * 12.f) 和 SetSpriteDamping(1.6f),击球瞬间的速度大约是从鼠标到球的向量放大 12 倍,随后每帧按 1.6 衰减。也就是说,球的整个滑行距离同时取决于鼠标离球多远、阻尼多大、阈值 30 多高。调哪个都能改变手感,但只调一个容易出现“球永远滑不到洞口”或“停不下来”两个极端。

一个比较扎实的调法:先把阻尼固定在 1.6,用鼠标离球的像素距离做变量;如果频繁穿洞,就把 30 上调到 36,或者把放大倍数从 12 降到 10;如果球停在洞口外,就减小阻尼到 1.4 或把停球阈值 0.5 降到 0.3。每次只动一个参数,记录一次手感,比同时调三个变量再回头找老版本靠谱得多。

3. 照实验二做球杆和虚线:OnMouseMove、单位向量与 12 段虚线

3.1 球杆跟手:隐藏系统鼠标 + SetSpritePosition 同步

实验二第一件事是让球杆跟随鼠标。FunCode 的鼠标移动事件会在 Main.cpp 的 OnMouseMove 回调里触发,但这个回调默认只做系统处理,你要在 Main.cpp 里加一行 g_GameMain.OnMouseMove(fMouseX, fMouseY),把坐标转发给你自己的函数。漏掉这一行,球杆就永远停在原地。

自定义的 OnMouseMove 实现非常短:

void CGameMain::OnMouseMove( const float fMouseX, const float fMouseY ) { // 把鼠标坐标直接同步给球杆精灵 m_spBallCue->SetSpritePosition( fMouseX, fMouseY ); }

逻辑说明:SetSpritePosition 接收的是地图坐标系里的坐标,和精灵本身的坐标是同一套,所以直接赋值即可。fMouseX / fMouseY 的单位不是屏幕像素,是地图单位,不要自己做像素换算,否则在非默认窗口下会偏位。

参数说明:如果只更新一轴,可以用 SetSpritePositionX / SetSpritePositionY;OnMouseMove 在鼠标移动时高频触发,里面不要做 new 对象、加载资源这类耗时操作,只做坐标同步。隐藏系统鼠标用 CSystem::ShowCursor(0),我建议放在 GameInit 的 if(!iInited) 初始化块里,只执行一次。另外退出前记得 ShowCursor(1) 恢复,否则调试时鼠标图标消失,选菜单都费劲。

3.2 画虚线前先算单位向量:从两点坐标到 12 段线段

方向虚线本质是一条沿“球 → 球杆”方向的线段。为了做出“虚线”效果,实验指导用了 12 段循环,每两段只在偶数段画线,形成 6 段首尾相连的虚线。fPosStep = fVectorSize / 10.f,也就是把球到球杆的向量分成 10 份,但循环 12 次,所以虚线会比球杆距离略长一点。很多人不留意这个细节,看起来虚线刚好指向球杆位置,其实已经超出一点,反而更像运动轨迹。

核心代码可以整理成这样:

void CGameMain::DrawDottedLine() { // 获取当前球和球杆的坐标 float fBallPosX = m_spBall[m_iBallNumber]->GetSpritePositionX(); float fBallPosY = m_spBall[m_iBallNumber]->GetSpritePositionY(); float fCuePosX = m_spBallCue->GetSpritePositionX(); float fCuePosY = m_spBallCue->GetSpritePositionY(); // 两点相减得到方向向量 float fVectorX = fBallPosX - fCuePosX; float fVectorY = fBallPosY - fCuePosY; // 防止球杆和球重合时除以 0 if( fVectorX > 0.001f || fVectorX < -0.001f || fVectorY > 0.001f || fVectorY < -0.001f ) { // 计算向量长度并单位化 float fVectorSize = sqrt( fVectorX * fVectorX + fVectorY * fVectorY ); float fDirX = fVectorX / fVectorSize; float fDirY = fVectorY / fVectorSize; float fLastPosX = fBallPosX, fLastPosY = fBallPosY; float fPosStep = fVectorSize / 10.f; for( int iLoop = 0; iLoop < 12; iLoop++ ) { float fNewPosX = fLastPosX + fDirX * fPosStep; float fNewPosY = fLastPosY + fDirY * fPosStep; // 偶数段画线,奇数段不画,形成虚线 if( iLoop % 2 == 0 ) { CSystem::DrawLine( fLastPosX, fLastPosY, fNewPosX, fNewPosY, 2.f, 0, 0, 255, 0, 255 ); } // 新坐标变成下一次循环的起点 fLastPosX = fNewPosX; fLastPosY = fNewPosY; } } }

逻辑说明:先判断两点不重合,再用 sqrt 求向量长度,接着用分量除以长度得到单位向量 fDirX / fDirY。循环 12 次,每次沿单位向量方向推进 fPosStep 的距离;iLoop 为偶数时画线,为奇数时跳过,视觉上就是一段段分离的虚线。

参数说明:DrawLine 的后几个参数是线宽、RGB 和透明度,这里画的是蓝色实线段,线宽 2。如果你想改成红色,把 255 从 B 通道挪到 R 通道即可。注意画虚线的前提是 m_iPlayState == 0,所以球一旦动起来,虚线就会消失,这符合“静止时可瞄准”的操作逻辑。

3.3 一个头文件和一个弧度陷阱

指导里明确写了:#include <math.h>。实践上 Visual Studio 工程经常因为预编译头或 iostream 间接包含了 math.h,于是不写也能编译。但换到 Dev-C++ 或 Linux g++,就会报 sqrt 找不到标识符。我建议不管编译是否通过,都在使用 sqrt 的 cpp 顶部显式 include,这是最小成本的习惯。

另一个隐含点是 SetSpriteRotation(m_fOldRotation + 10.f) 里的 10。FunCode 精灵旋转参数和引擎内置精灵系统保持一致,按指导原样用就行。但如果你在项目里要做连续旋转,注意不要把这个 10 累加到 m_fOldRotation 本身,否则每次击球都从旧角度再加 10,球杆会越转越偏,最后变成反方向。

4. 球洞循环与击球手感:数组移位算法、速度放大 12 倍与阻尼 1.6

4.1 球洞 8 个位置循环滚动的“首尾追赶”写法

实验三的 HoleChange 函数,作用是让 8 个球洞的位置按频率循环滚动。PDF 里m_fHoleRollTime > 1.f的判断后面多了一次m_fHoleRollTime -= 1.f,按原文抄下来,球洞会每隔一帧跳两个位置,视觉上就像洞在闪现。我这里整理成稳定版本:先累加时间,超过 1 秒才执行移位,并把计时器减回 1 秒以内。

void CGameMain::HoleChange( const float fDeltaTime ) { m_fHoleRollTime += fDeltaTime; if( m_fHoleRollTime < 1.f ) return; m_fHoleRollTime -= 1.f; // 保存最后一个球洞的编号和 Y 坐标 int iLastData = m_iHoleNumber[7]; float fLastPosY = m_spBallHole[iLastData]->GetSpritePositionY(); // 从后往前遍历,每个位置的值被前一个位置覆盖 for( int iLoop = 7; iLoop > 0; iLoop-- ) { m_iHoleNumber[iLoop] = m_iHoleNumber[iLoop - 1]; int tmpNum = m_iHoleNumber[iLoop - 1]; float fTempPosY = m_spBallHole[tmpNum]->GetSpritePositionY(); m_spBallHole[tmpNum]->SetSpritePositionY( fLastPosY ); fLastPosY = fTempPosY; } // 最后一个球洞编号放到第一个位置 m_iHoleNumber[0] = iLastData; m_spBallHole[iLastData]->SetSpritePositionY( fLastPosY ); }

逻辑说明:m_iHoleNumber 数组存的是每个位置当前显示的球洞编号。循环里把后一个位置的编号覆盖为前一个位置的编号,同时把前一个位置的精灵 Y 坐标同步到后一个位置,相当于所有球洞整体向下移动一格。最下面的球洞编号先保存到 iLastData,循环结束后放到最上面,形成循环滚动。

参数说明:fDeltaTime 是帧间隔时间,m_fHoleRollTime 累加到 1 秒才滚动一次,所以无论游戏跑 30 帧还是 60 帧,换位速度一致。数组移位必须从后往前,如果从前往后,第一个位置的值会提前被覆盖,整个顺序就乱套了。

4.2 OnMouseClick 的响应边界:只有静止状态才能打

实验四的击球逻辑在 OnMouseClick 里实现,Main.cpp 的鼠标点击回调同样需要转发。完整代码:

void CGameMain::OnMouseClick( const int iMouseType, const float fMouseX, const float fMouseY ) { // 只响应游戏进行中且球静止时的点击 if( 2 != m_iGameState || 0 != m_iPlayState ) return; // 标记球已进入运动状态 m_iPlayState = 1; m_fRotateTime = 0.2f; m_spBallCue->SetSpriteRotation( m_fOldRotation + 10.f ); // 用鼠标位置到球位置的向量作为速度方向 float fPosX = m_spBall[m_iBallNumber]->GetSpritePositionX(); float fPosY = m_spBall[m_iBallNumber]->GetSpritePositionY(); float fVectorX = fPosX - fMouseX; float fVectorY = fPosY - fMouseY; // 向量放大 12 倍,得到初始速度 fVectorX *= 12.f; fVectorY *= 12.f; m_spBall[m_iBallNumber]->SetSpriteLinearVelocity( fVectorX, fVectorY ); m_spBall[m_iBallNumber]->SetSpriteDamping( 1.6f ); }

逻辑说明:点击时先把 m_iPlayState 置为 1,这样同帧内如果再次触发 OnMouseClick,第二个点击会被 if 挡住,不会出现“一杆没打完又补一杆”的情况。鼠标指向球的方向向量被放大 12 倍作为初速度,SetSpriteDamping(1.6f) 让球在前进过程中持续减速。

参数说明:SetSpriteLinearVelocity 接收的是 X/Y 方向的速度值,单位与地图坐标一致;SetSpriteDamping 的 1.6 是速度衰减系数,数值越大球停得越快。球杆旋转 0.2 秒后要复位,这段逻辑放在 GameRun 末尾:m_fRotateTime 逐帧减 fDeltaTime,减到 0 时用 SetSpriteRotation(m_fOldRotation) 复位。

4.3 速度和阻尼的配合:12 倍放大对 1.6 衰减

击球力度不是越大越好。如果鼠标放在离球很远的位置,向量模长本身就大,乘以 12 后初速度可能飙到五十甚至更高。虽然阻尼会持续减速,但球很可能在速度还没降到 30.f 以下时就已经穿过洞口区域,这就是课程设计里说的“速度太大直接运动过球洞”。

反过来,鼠标几乎贴着球点击,初速度很小,球没走多远就降到 0.5.f 以下,会被判定为“停球”,直接进入下一局,也谈不上进洞。所以可操作窗口是:球到达洞口时的瞬时速度必须落在 0.5.f 到 30.f 之间。这个窗口看起来宽,实际上因为阻尼存在,球的减速曲线让高初速度更容易穿洞,低初速度更容易提前停球,真正舒服的力度范围并不大。我一般固定阻尼 1.6,只调向量放大倍率;如果发现穿洞多,就把放大倍率降到 10 或 11,让球的最高速度整体下移,进洞窗口变大。

5. 碰撞反弹与进洞判定:避坑复盘五个常见翻车点

看起来是“球进洞”一个功能,实际拆开是碰撞配置、方向取反、速度阈值、数组同步、事件转发五个环节。下面按现象 → 原因 → 解决的方式复盘,都是照着指导抄代码时最容易踩的坑。

5.1 碰撞没反应:Send/Receive 配反或没挂回调

现象:球碰到桌边沿直接穿过去,完全没有反弹。

原因:边沿精灵的 SetSpriteCollisionReceive(1) 和球的 SetSpriteCollisionSend(1) 至少有一个没设。另一个可能的原因是 Main.cpp 的 OnSpriteColSprite 里没有调用 g_GameMain.OnSpriteColSprite(szSrcName, szTarName),自定义碰撞函数写了也不会被触发。

解决:在 GameInit 里按实验五初始化,给球设发送、给边沿设接收:

for( int i = 0; i < 8; i++ ) { m_spBall[i]->SetSpriteCollisionSend( 1 ); } for( int j = 0; j < 2; j++ ) { m_spVerSide[j]->SetSpriteCollisionReceive( 1 ); m_spHorSide[j]->SetSpriteCollisionReceive( 1 ); }

同时检查 Main.cpp 三个回调转发:OnMouseMove、OnMouseClick、OnSpriteColSprite。这三个转发是 FunCode 模板里最容易漏的地方,漏一个就是“某功能完全没反应”。

5.2 反弹方向反了:VerSide 和 HorSide 的取反容易写反

现象:球撞到上边沿后没有向下弹,反而继续向屏幕外冲。

原因:在 strstr(szSrcName, "VerSide") 的分支里对 Y 速度取反,方向逻辑搞反了。FunCode 模板里的 VerSide / HorSide 不是按中文“水平/垂直”字面理解的。

解决:严格按实验五来:碰撞到 VerSide 时对 X 速度取反,碰撞到 HorSide 时对 Y 速度取反:

if( strstr( szSrcName, "VerSide" ) || strstr( szTarName, "VerSide" ) ) { float fVelX = m_spBall[m_iBallNumber]->GetSpriteLinearVelocityX(); m_spBall[m_iBallNumber]->SetSpriteLinearVelocityX( fVelX * -1.f ); } else if( strstr( szSrcName, "HorSide" ) || strstr( szTarName, "HorSide" ) ) { float fVelY = m_spBall[m_iBallNumber]->GetSpriteLinearVelocityY(); m_spBall[m_iBallNumber]->SetSpriteLinearVelocityY( fVelY * -1.f ); }

如果发现方向还是反的,把两个分支里的取反内容互换是最快的验证方式。名字和方向的对应关系不要凭感觉记,以实际反弹结果为准。

5.3 进洞判定不如预期:速度阈值 30 和 IsPointInSprite 的配合

现象:球从洞口中央穿过,看起来碰到了,但没判进洞。

原因:fSpeedValue >= 30.f,速度阈值不满足;另一种情况是球进洞时速度已经很低,但球心没有落在洞口精灵的判定区域里,IsPointInSprite 返回 false。这个函数是精灵矩形包围盒的点包含判断,素材透明边也会被算进去,但如果洞口素材比球小,就可能漏判。

解决:先用打印或断点看击球后的速度模长,确认是不是一直高于 30。如果只高一点点,把阈值上调到 32 或 33 即可;如果经常在洞口处还有 40 以上,说明击球力度偏大,更适合把放大倍数从 12 降到 10。球心不在洞内时,可以给洞口精灵加一个隐藏的辅助判定精灵,半径比原素材大 10% 到 20%,不要在视觉精灵上直接放大。

5.4 球洞颜色和位置错位:数组移了,精灵 Y 坐标没同步

现象:新一局球洞排列的颜色顺序乱了,玩家明明把球打进洞口,但视觉上洞的颜色和球对不上。

原因:只移动了 m_iHoleNumber 数组,没有同步更新对应球洞精灵的 Y 坐标;或者循环里 fLastPosY 更新时取错了精灵,用了 m_spBallHole[iLoop] 而不是临时编号对应的精灵。

解决:按 4.1 整理后的代码来写,核心是同时维护两个东西:编号数组和精灵位置。循环里 tmpNum = m_iHoleNumber[iLoop - 1],一定要用这个编号去取精灵的 Y 坐标,再用上一次保存的 fLastPosY 给它赋值。数组和精灵位置是一一对应的映射关系,漏一个就是颜色错位。

5.5 点击没反应但断点没进:OnMouseClick 压根没被调用

现象:按鼠标球杆不挥,断点打在自定义 OnMouseClick 函数里也不命中。

原因:Main.cpp 的鼠标点击回调里没有转发。FunCode 模板默认只把鼠标事件交给系统处理,自定义函数要手动挂到全局回调上。同理 OnMouseMove、OnSpriteColSprite 都有这个问题。

解决:在 Main.cpp 里找到三个回调,分别加一行转发:

// 在 Main.cpp 对应函数中 g_GameMain.OnMouseMove( fMouseX, fMouseY ); g_GameMain.OnMouseClick( iMouseType, fMouseX, fMouseY ); g_GameMain.OnSpriteColSprite( szSrcName, szTarName );

每加一个就测试一个,不要三个一起加,否则某个转发写错位置时还得回头排。这个检查清单我现在每次搭 FunCode 工程都会先过一遍。

6. 验证与调试:跑通后只补三个断点和一个检查清单

项目跑通后,别急着改手感。我一般会做三件事验证整个链路。

6.1 三个断点验证状态轮转

第一处断点打在 GameRun 里if( 0 == m_iPlayState ) DrawDottedLine();,观察点击前是否进入画线分支。第二处打在else进洞判断开头,确认球运动时每一帧都在处理速度检测。第三处打在 OnSpriteColSprite 入口,确认碰撞回调真的被触发。三处断点完整走一遍,等于把实验二到实验六的核心链路全部验证了一遍。

参数位置作用
速度放大 12.fOnMouseClick鼠标到球的向量乘倍数得到初始速度
阻尼 1.6OnMouseClick速度衰减系数,决定滑行距离
进洞阈值 30.fGameRun else 分支速度模长低于此值才允许进洞判定
停球阈值 0.50fGameRun else 分支速度低于此值结束本杆并重新开局
虚线步长 fVectorSize / 10DrawDottedLine控制虚线延伸距离与虚线密度
换位间隔 1.0fHoleChange球洞位置每秒循环一次

把这张参数表贴在代码注释里,调参时不会两眼一抹黑。最后再检查一次 Main.cpp 里三个转发函数是否都在,以及边沿精灵的 Send/Receive 配置是否正确。

从那以后我拿到 FunCode 模板,第一件事就是先查 Main.cpp 的转发和精灵的碰撞配置,再碰业务逻辑。这两个检查项排掉,项目里一半的“莫名其妙”就消失了。希望这份课程设计的坑你能少踩几个,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询