简介:一份基于FunCode平台的C++桌球游戏课程设计文档,面向C++初学者、游戏开发入门者及计算机相关专业需完成课设的学生。文档以实验任务为主线,详细讲解了从项目创建、模板导入到桌球与球杆初始化、球洞周期变化、碰撞反弹、同色进洞判断的完整开发流程,并给出了关键头文件与源文件中成员变量声明与函数实现,包括精灵随机Y坐标设置、球杆跟随鼠标移动的鼠标移动事件处理、基于向量长度计算的虚线绘制方法,以及球状态管理和八球洞循环移动逻辑。通过这份PDF,读者可以快速掌握FunCode游戏框架下事件驱动编程、精灵动画、碰撞检测等核心技能,也可直接作为课程设计报告的编写参考。包体为1个PDF文件,大小仅1.2MB,内容紧凑清晰。目前已有113人学习使用,适合需要完整课设方案和代码思路解析的读者。
1. 用 FunCode 写桌球课程设计,真正的难点是状态切换
这道课程设计表面上是做一个桌球游戏,实际上是在 FunCode 引擎里完成一个带物理模拟的小型游戏逻辑闭环。左侧一颗小球,右侧八个颜色不同的球洞,球杆跟随鼠标移动,点击后球被击出,球洞以固定频率循环换位,球碰到台边按反射角弹回,最后以合适速度穿过同色球洞才算进洞,然后重新生成新球。真正动手做的时候你会发现,画精灵不是问题,问题在于每一帧都要回答“当前处于什么状态”:球能不能打、球是否还在运动、是否该判定进洞、进洞后如何重置。这个项目的价值正在于此,它逼着你把坐标、向量和碰撞回调组织成一条严谨的状态链路,适合刚学完 C++ 基础又想接触游戏事件驱动编程的人。
2. FunCode 工程搭建与精灵初始化的正确姿势
2.1 创建工程与导入 snooker 模板
FunCode 的工程创建比想象中严格:菜单栏选择“项目”里的“创建 C++ 工程”,工程名必须是字母开头,只能包含字母和数字,中间不能有空格,否则编译阶段会报一些可读性很差的错误。创建完成后,再从“项目”菜单打开“导入地图模块”,在弹出的对话框里选中 snooker 模板导入。模板里已经包含了桌球台、球、球洞和球杆的精灵资源,这一步省去了手动找图的麻烦,后面代码里直接用字符串 "Ball"、"BallHole"、"BallCue" 就能构造出对应精灵。
模板导入后,你自己只需要负责游戏逻辑,不需要关心地图文件的加载过程。不过要注意 FunCode 的精灵坐标系和普通窗口坐标系不同,它的单位不是屏幕像素,而是地图世界坐标,比如 X 方向 -30 到 50 之间已经是屏幕内比较明显的横向位移了,后面初始化球位置时如果按像素思维写数值,会出现“球跑出屏幕外”的问题。
2.2 成员变量:桌球项目到底需要哪些全局对象
进入 LessonX.h 之前先列一份清单,看看这个游戏需要保存哪些跨函数共享的数据。以下是我在课程设计里整理出来的成员变量,它们基本覆盖了所有后续操作:
| 变量名 | 类型 | 用途 |
|---|---|---|
| m_iPlayState | int | 0 表示可以点击击球,1 表示球正在运动中 |
| m_iBallNumber | int | 当前本局桌球对应的颜色编号,0 到 7 |
| m_fOldRotation | float | 球杆初始朝向,用于击球动画后的复位 |
| m_spBall[8] | CSprite* | 8 个桌球的精灵,只显示本局那一个 |
| m_spBallCue | CSprite* | 球杆精灵 |
| m_iHoleNumber[8] | int | 8 个球洞当前对应哪个颜色的球 |
| m_spBallHole[8] | CSprite* | 8 个球洞精灵 |
| m_fHoleRollTime | float | 球洞循环换位的累计时间 |
| m_fRotateTime | float | 球杆旋转击打动画的剩余时间 |
| m_spVerSide[2] | CSprite* | 上下两条台边精灵 |
| m_spHorSide[2] | CSprite* | 左右两条台边精灵 |
这些变量建议全部声明为 private,统一放在 CGameMain 类顶部。球和球洞虽然各占 8 个指针,但同一时刻只有一个球是激活的,其他球都放在屏幕外;球洞则是 8 个全部可见,所以必须有 m_iHoleNumber 记录每个球洞此刻匹配的颜色编号,否则你没法判断“同色进洞”。
2.3 构造函数与 GameInit:先建精灵,再摆位置
构造函数里先做基础初始化,再 new 出精灵指针。常见做法是只在这里创建精灵,真正设置位置和可见性放到 GameInit 里,这样每一局开始时只要重新调用 GameInit 就能重置游戏,不用反复 new 和 delete 对象。
// LessonX.cpp 构造函数片段 m_iPlayState = 0; m_iBallNumber = 0; m_fOldRotation = 0.f; m_fHoleRollTime = 0.f; m_fRotateTime = 0.f; m_spBallCue = new CSprite("BallCue"); for (int iLoop = 0; iLoop < 8; iLoop++) { char* tmpName = CSystem::MakeSpriteName("Ball", iLoop); m_spBall[iLoop] = new CSprite(tmpName); }这段代码里CSystem::MakeSpriteName("Ball", iLoop)是 FunCode 的资源命名规则,它会把字符串拼成 "Ball0" 到 "Ball7" 这样的精灵名字。如果你不想用这个辅助函数,也可以直接写new CSprite("Ball0"),但用 MakeSpriteName 的好处是当编号变量变化时,不用手动拼接字符串。
接下来在 GameInit 里处理球的随机位置。这里有一个容易被忽略的细节:旧球要先移到 Y=50 的屏幕外位置,再切换 m_iBallNumber 指向新球,否则下一局开始时上一局的球还留在桌面上。
// GameInit 中初始化本局球的位置 int iNewBallNumber = CSystem::RandomRange(0, 7); if (iNewBallNumber != m_iBallNumber) { m_spBall[m_iBallNumber]->SetSpritePositionY(50.f); // 旧球移出屏幕 m_iBallNumber = iNewBallNumber; } int iPosY = CSystem::RandomRange(-25, 25); m_spBall[m_iBallNumber]->SetSpritePositionX(-30.f); m_spBall[m_iBallNumber]->SetSpritePositionY((float)iPosY);这里 X 固定为 -30,Y 在 -25 到 25 之间随机。FunCode 的 RandomRange 返回的是整数,所以 iPosY 可以接到 int 变量里,最后强转 float 赋值给精灵。注意 X 固定并不是说球永远只在左侧纵向移动,而是出生点固定,击球后速度由鼠标方向决定,球可以跑到桌面右侧去撞击球洞。
2.4 鼠标移动同步球杆与光标隐藏
球杆跟随鼠标是靠 OnMouseMove 回调实现的。FunCode 的鼠标移动回调传入的是世界坐标,所以可以直接把参数传给 SetSpritePosition。
void CGameMain::OnMouseMove(const float fMouseX, const float fMouseY) { m_spBallCue->SetSpritePosition(fMouseX, fMouseY); }这个函数需要与 Main.cpp 里的系统回调绑定。我在 Main.cpp 的 OnMouseMove 函数里加了一行g_GameMain.OnMouseMove(fMouseX, fMouseY);,并且在 GameInit 里调用CSystem::ShowCursor(0)隐藏鼠标图标,否则场景里会出现两个“球杆”:一个是你控制的精灵,一个是系统光标。
这里特别要注意:OnMouseMove 不需要你手动判断鼠标是否在窗口内,FunCode 只在游戏窗口内触发这个回调。球杆跟随鼠标时也不要限制它的 X 范围,因为球杆本身就是用来瞄准的,允许它跑到屏幕边缘外反而更容易调整击球方向。
3. 方向指示虚线与球洞循环:把数学变成可见的反馈
3.1 为什么要用向量计算画虚线
虚线是给玩家看的击球方向提示。实现思路并不复杂:从球杆位置出发,朝球的方向延伸一段线段,但全画成实线会挡住球的运动路径,所以按固定间隔隔一段画一段,形成虚线。计算方向时需要用到向量减法:球的坐标减去球杆的坐标,得到一个由球杆指向球的向量,再把这个向量单位化,得到长度为 1 的方向向量。
这里的关键是必须处理“球杆和球重合”的情况。如果鼠标恰好移到球的中心,向量长度为 0,单位化时会出现除零错误,我习惯用 0.001f 作为阈值过滤这种情况,也就是向量每个分量绝对值都小于 0.001 时就跳过画线。
3.2 DrawDottedLine 的循环与参数解析
画虚线的方法我放在 DrawDottedLine 函数里,每一帧如果当前是可击打状态就调用。
#include <math.h> 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; // 排除球杆与球重合的情况 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; float fLastPosY = fBallPosY; float fNewPosX = 0.f; float fNewPosY = 0.f; float fPosStep = fVectorSize / 10.f; for (int iLoop = 0; iLoop < 12; iLoop++) { fNewPosX = fLastPosX + fDirX * fPosStep; 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; } } }fPosStep = fVectorSize / 10.f表示把球杆到球的整个距离分成 10 份,每一步走一份,画 12 段后实际上会越过球的位置一点点,这样虚线的末端会落在球的后方不远处,视觉效果更自然。DrawLine 的第二个参数 2.f 是线宽,后面依次是 RGB 颜色值和透明度,我这里传的是蓝色不透明线。
3.3 球洞循环算法:数组后移与Y坐标交换
球洞循环的目的是让右侧 8 个球洞每秒钟整体向下移动一格,最下面的球洞移动到最上面。这个效果本质上是数组元素旋转,但难点在于精灵位置也要跟着对应的颜色编号一起移动。
假设初始时位置从上到下依次是球洞 0 到球洞 7,一轮循环后变成球洞 7 在最上面,然后 0、1、2…… 依次往下。这对应数组的右移一位操作。需要注意的是,移动的不仅是数字,还有对应精灵的 Y 坐标。
我直接用代码实现这个旋转逻辑:
void CGameMain::HoleChange(const float fDeltaTime) { int iLoop = 0; int iLastData = 0; float fTempPosY = 0.f; float fLastPosY = 0.f; m_fHoleRollTime += fDeltaTime; if (m_fHoleRollTime > 1.f) { m_fHoleRollTime -= 1.f; // 控制循环频率为每秒一次 } // 先保存最后一个球洞的编号和 Y 坐标 iLastData = m_iHoleNumber[7]; fLastPosY = m_spBallHole[iLastData]->GetSpritePositionY(); // 从后往前遍历,前一个的值覆盖当前值 for (iLoop = 7; iLoop > 0; iLoop--) { m_iHoleNumber[iLoop] = m_iHoleNumber[iLoop - 1]; int tmpNum = m_iHoleNumber[iLoop - 1]; fTempPosY = m_spBallHole[tmpNum]->GetSpritePositionY(); m_spBallHole[tmpNum]->SetSpritePositionY(fLastPosY); fLastPosY = fTempPosY; } // 最后把最后一个球洞的数据放到第一位 m_iHoleNumber[0] = iLastData; m_spBallHole[iLastData]->SetSpritePositionY(fLastPosY); }这段代码的关键在于 fLastPosY 和 fTempPosY 的接力。每一轮循环里,当前编号的球洞要先拿到下一个位置的 Y 坐标,同时自己原来的 Y 坐标再留作下一轮使用。这样做避免了临时数组,也不需要额外保存 8 个 Y 坐标值。
下面是移动过程的可视化表格,假设初始顺序是 [0, 1, 2, 3, 4, 5, 6, 7],一轮结束后变为 [7, 0, 1, 2, 3, 4, 5, 6]:
| 位置 | 初始球洞编号 | 一轮循环后编号 |
|---|---|---|
| 第1位(最上) | 0 | 7 |
| 第2位 | 1 | 0 |
| 第3位 | 2 | 1 |
| 第4位 | 3 | 2 |
| 第5位 | 4 | 3 |
| 第6位 | 5 | 4 |
| 第7位 | 6 | 5 |
| 第8位(最下) | 7 | 6 |
循环调用的位置在 GameRun 函数里:先判断 m_iPlayState 是否为 0,是则画虚线,然后调用 HoleChange(fDeltaTime)。这样球洞在玩家还没击球时就已经开始滚动,增加了瞄准时的动态难度。
4. 点击击球与台边反弹:物理效果如何用速度向量实现
4.1 OnMouseClick:条件判断与速度赋值
击球操作要响应鼠标左键单击。检测到点击后,先确认当前状态是否允许击球:m_iGameState 必须等于 2,游戏处于运行中;m_iPlayState 必须等于 0,球还没有被击出。两个条件不满足就直接 return,避免玩家在球运动中再次触发击球。
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; fVectorX *= 12.f; fVectorY *= 12.f; m_spBall[m_iBallNumber]->SetSpriteLinearVelocity(fVectorX, fVectorY); m_spBall[m_iBallNumber]->SetSpriteDamping(1.6f); }速度向量的方向是从鼠标指向球,乘以 12.f 是为了把方向向量放大到实际运动速度。这个 12.f 是经验值,直接决定击球力量的极限。如果玩家把鼠标放在离球很远的角落,fVectorX 和 fVectorY 的原始值可能只有几十个单位,放大后速度会很大,球会一杆穿过所有球洞。
SetSpriteDamping(1.6f) 是 FunCode 提供的速度衰减系数,值越大球停下来越快。初次测试时可以把 1.6f 调低到 1.0f 观察球的滑行距离,这个参数直接影响进洞判定的难度。
4.2 球杆击打动画与复位
球杆旋转不能一直停在偏转 10 度的位置,需要在一小段时间后回到初始角度。这里用 m_fRotateTime 作为倒计时,在 GameRun 中按帧更新。
// GameRun 每帧执行 if (m_fRotateTime > 0.f) { m_fRotateTime -= fDeltaTime; if (m_fRotateTime <= 0.f) { m_spBallCue->SetSpriteRotation(m_fOldRotation); } }fDeltaTime 是两帧之间的时间差,乘以频率之后,0.2f 大约会在 200 毫秒内完成复位。实际运行时我发现这个时间不能太短,太短了球杆看起来像是“抖了一下”,没有击打的重量感;也不建议超过 0.3f,否则玩家会以为球杆卡住了。
4.3 碰撞检测与反射方向
台边碰撞需要让精灵进入碰撞监听模式。球的精灵要设置为可发送碰撞,台边精灵设置为接收碰撞,这样 FunCode 才会在二者重叠时回调。
// 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 的,你需要在自己的 CGameMain 里实现同名函数,然后在 Main.cpp 里转发。判断逻辑是:先确认碰撞双方里有当前激活的球,再根据台边精灵的名字判断碰的是哪条边。
void CGameMain::OnSpriteColSprite(const char szSrcName[], const char szTarName[]) { if (strcmp(szSrcName, m_spBall[m_iBallNumber]->GetName()) == 0 || strcmp(szTarName, m_spBall[m_iBallNumber]->GetName()) == 0) { 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); } } }这里的命名一开始容易混淆。m_spVerSide 在 ini 定义里是上下边沿精灵,因为它们在视觉上通常是竖直走向的窄条;碰撞时碰到“VerSide”意味着球的水平速度 X 方向要取反。反过来碰到“HorSide”就是 Y 方向取反。把速度取反就是理想反射角模型,速度大小不变,只改变运动方向。
| 台边精灵名 | 实际位置 | 速度分量操作 |
|---|---|---|
| VerSide | 上下边沿 | X 速度取反 |
| HorSide | 左右边沿 | Y 速度取反 |
这个实现不会额外损失动能,所以球理想情况下会永远弹下去,直到阻尼把它降到停止或进洞。
5. 进洞判定与速度阈值:让手感“不难不贱”
5.1 速度大小:为什么不是速度为零才算停止
进洞判定不是简单的“球碰到球洞就算”。游戏规则里特意提出:速度太大球会直接冲过球洞,速度太小又滚不进去。所以判定逻辑的第一步是拿到当前球的实时速度大小。
float fVelX = m_spBall[m_iBallNumber]->GetSpriteLinearVelocityX(); float fVelY = m_spBall[m_iBallNumber]->GetSpriteLinearVelocityY(); float fSpeedValue = sqrt(fVelX * fVelX + fVelY * fVelY);用欧几里得范数求速度大小,就是 sqrt(x平方 + y平方),这个值和速度方向无关。我在实际调试时发现,球的线性速度会受阻尼影响持续减小,所以越接近球洞时往往速度越低,而我们想要的就是一个“刚好能滚进球洞”的中等速度区间。
5.2 用 IsPointInSprite 判断球是否进入球洞
速度合格还不够,还要确认球当前坐标是否真的落在同色球洞精灵的范围内。FunCode 提供了 IsPointInSprite 方法,传入一个世界坐标点,返回这个点在不在精灵内部。
// GameRun 中,m_iPlayState == 1 的 else 分支 if (fSpeedValue < 30.f) { float fPosX = m_spBall[m_iBallNumber]->GetSpritePositionX(); float fPosY = m_spBall[m_iBallNumber]->GetSpritePositionY(); if (m_spBallHole[m_iBallNumber]->IsPointInSprite(fPosX, fPosY)) { // 同色球洞且速度合适,判定进洞 m_spBall[m_iBallNumber]->SetSpritePositionY(50.f); // 隐藏球 m_iPlayState = 1; // 保持禁止击球状态,稍后重置 m_iGameState = 1; // 触发新一局初始化 } }注意这里的m_spBallHole[m_iBallNumber]是当前球颜色对应的球洞精灵。球洞会循环换位,但编号和精灵始终是绑定的,所以用球编号取球洞精灵一定是对应颜色的那个。速度阈值 30.f 是 FunCode 地图坐标系的经验值,课程设计里直接沿用即可。
5.3 兜底逻辑:速度过小也要恢复可击打
实际测试中很容易碰到一种情况:球的速度降到接近零,但没有停在球洞里,而是停在球洞正上方或边缘。如果你不处理这种状态,m_iPlayState 会永远停在 1,玩家再点击也没反应。因此进洞判断后面还要加一个速度下限检查。
if (fSpeedValue < 0.50f) { m_iPlayState = 0; // 球已经停下,恢复可击打 m_iGameState = 1; // 重新初始化新局 return; }这个 0.50f 可以理解为“球几乎静止”的判定阈值。只要球速低于它,说明这次击球的力量已经完全耗尽,不管有没有进洞,都应该让游戏回到可操作状态。如果已经进洞,上面那段代码会把球 Y 移到 50.f 屏幕外,然后 m_iGameState 变成 1 会在下一帧重新执行 GameInit,生成新球并恢复 m_iPlayState = 0;如果没有进洞,这个兜底也能让球停在原地,重新开始下一杆。
这里有一个很容易踩的坑:如果你把 m_iGameState = 1 放在进洞代码里,但同时又把 m_iPlayState = 0 放到兜底分支,GameRun 的调用顺序必须保证 GameInit 执行时用的是最新状态。我的做法是在进洞分支里只设置状态,不在那里直接改球的位置,让 GameInit 统一处理新球的随机位置,这样代码逻辑更清晰。
| 速度范围 | 球的运动表现 | 游戏行为 |
|---|---|---|
| > 30.f | 快速滚过球洞 | 不进洞,继续运动 |
| 0.50f ~ 30.f | 缓慢进入同色球洞 | 判进洞,重新开局 |
| < 0.50f | 球基本静止 | 停在原地或洞内,重置状态 |
6. 状态复位的兜底技巧:从进洞后防卡死说起
6.1 状态位的三种组合
m_iGameState 和 m_iPlayState 这两个变量组合起来,构成了游戏的最小状态机。我在调通所有实验后总结出一张组合表,遇到球消失、无法点击、球不重置这类问题,先对着表查状态位:
| m_iGameState | m_iPlayState | 含义 |
|---|---|---|
| 1 | 0 | 可开局,新球待生成 |
| 2 | 0 | 游戏中,球静止,等待击球 |
| 2 | 1 | 游戏中,球正在运动或停在桌面上 |
这上面少了一个“游戏中但球丢了”的组合。如果出现 m_iGameState 一直是 2、m_iPlayState 一直是 1,而且球不可见,大概率是进洞分支把球 Y 移到屏幕外后没有触发 GameInit。我会在 GameRun 里加一条保险:当 m_iPlayState 为 1 且球速度小于 0.50f 时,强制把状态重置为可击打。
6.2 用输出日志定位“球消失”的边界情况
FunCode 的调试手段不多,我自己最常用的是CSystem::OutputDebugString,把关键值打印到输出窗口。
char szDbg[128]; sprintf(szDbg, "Speed=%.2f PosX=%.2f PosY=%.2f State=%d/%d\n", fSpeedValue, fPosX, fPosY, m_iGameState, m_iPlayState); CSystem::OutputDebugString(szDbg);打印速度、坐标和两个状态位,可以让某一帧的判定结果一目了然。比如你发现某次速度是 12.5,坐标落在球洞内,但 IsPointInSprite 返回了 false,那就要检查 m_spBallHole 数组是否和球编号对齐了;如果速度是 0.8,但球已经跑出屏幕,那可能是阻尼设得太大,导致球还没到球洞就被判成静止。
6.3 手感调参的最优顺序
最后说一个调节手感的具体顺序。不要一上来就动 30.f 进洞阈值,那个值只是判断“能否进洞”的上限,调大了会让球还没到洞口就被“吸进去”。优先调的是 SetSpriteDamping 的阻尼系数,1.6f 偏大,球停得太快;改成 1.2f 之后球能多滑出四分之一左右的距离,进洞率会明显提升。其次是速度放大倍数 12.f,觉得击球无力就增大到 15.f,但别超过 18.f,否则高速下球的反弹会变得很难控制。最后再回到 0.50f 的下限,这个值一般不用改,除非你想让球能在极慢速下多磨蹭一会,那时把它改成 0.3f 即可。
把这三个参数按顺序调完,桌球课程的体验就会从“能跑”变成“能玩”。
本文还有配套的精品资源,点击获取