ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言课程设计实战:控制台版球球大作战开发全解析

C语言课程设计实战:控制台版球球大作战开发全解析 C语言课程设计最愁人的往往不是题目本身而是题目无聊。我们班交上去的选题十个里有八个是学生成绩管理系统、图书管理系统剩下两个是计算器。我做的是“伪版球球大作战”——控制台里一张100×28的地图上玩家用表示AI用o和O表示食物用.表示。你控制小球去吃食物、吞比你小的球同时躲开比你大的球越吃越大越大越强直到把全场对手清光或者自己被吞。这个项目用到的全是C语言课内知识点结构体、数组、随机数、字符界面输出核心算法就是碰撞检测和简单的AI寻路。写这篇文章是给同样在为课程设计发愁的同学一个参考代码在文末可以直接复制去跑也能当答辩演示用。我自己的体验是这类“游戏型课程设计”在答辩时非常占便宜因为效果直观老师一眼就能看出程序在干什么不需要你从头解释“这是一个管理系统能增删改查”。而且它展示的不只是语法还有数据结构设计、算法思路和工程拆分能力这些都是课程设计真正想考察的东西。1. 项目概述与整体设计1.1 为什么课程设计要选这个题目选题目之前我先定了个标准不能太简单不能显得像抄作业最好还能让答辩现场有点互动感。C语言课设的常见套路是各种管理系统但这类项目有个问题——你很难证明代码是你自己想的因为网上模板太多老师都看腻了。反而是游戏类项目尤其这种带实时交互的代码风格、算法设计都带着明显的个人痕迹老师一眼能看出你下了功夫。“球球大作战”这个玩法本身就适合课程设计。核心规则一句话就能说明白大球吃小球。但真做起来涉及的东西一点都不少——地图管理、多对象模拟、移动逻辑、碰撞检测、AI决策、界面刷新每一个模块都能单独拿出来讲。我把它定位成“伪版”就是用自己的方式模仿核心玩法而不是复刻商业游戏这样既有辨识度又不至于给自己挖一个填不完的坑。答辩的时候我准备了一句话总结这个项目用结构体数组管理三类游戏对象用距离判定实现吞噬逻辑用简单的威胁感知策略驱动AI运动整个程序跑在一个固定帧率的实时循环里。这句话把项目骨架描述得清清楚楚也是这篇文章要拆解的核心。1.2 玩法规则与最终效果最终做出来的游戏就是这个样子一张100列×28行的字符地图地图上分布着三种东西——食物、AI小球、玩家小球。食物用.表示吃了一颗长一厘米size加1同时加1分AI球分成两档o是普通AIO是已经长得比较大、很危险的AI玩家用表示。核心规则是吞噬判定两个球如果发生接触距离小于半径之和较大的球吞掉较小的球并把对方一半的尺寸加到自己身上。比如玩家size是8AI size是6两者相遇玩家变成11AI消失玩家加50分。反过来如果玩家只有5撞上一个size为8的AI游戏直接结束。这里有个关键设计玩家移动步长固定为1个字符AI也是一样谁都不会“瞬移”所以碰撞是有预判空间的玩家可以通过走位躲大球、追小球。地图边界做了“穿越”处理从左边出去从右边回来从上面出去从下面回来这是借鉴了球球大作战里的环形世界设计其实实现起来就是几行取模运算但带来的玩法变化很大——逃跑可以绕圈追击可以包抄。一局的体验大概是这样的开局玩家size只有5地图上一堆AI最小的AI是3最大的能到8所以开局必须先找食物发育避开大AI。中间阶段玩家size到10以上就可以主动追击小AI了。后期目标就是把AI清光或者稳住局面刷分。1.3 技术选型控制台方案 vs 图形库做这类游戏第一反应往往是上EasyX图形库画圆、贴图、监听鼠标效果确实好。但我最后选了纯控制台方案理由很实际EasyX是Windows专属图形库很多同学电脑上没装或者装的时候各种报错再加上EasyX项目在答辩时容易被追问“这里面的绘图函数是哪来的”你得额外解释第三方库的原理。纯控制台方案的优势是零依赖只要编译器支持标准C和Windows API的conio.h、windows.h就能跑。而且它的渲染思路其实是很多2D游戏的原型——维护一个逻辑坐标下的“游戏世界”再把世界状态绘制到屏幕上。这个思想放到任何游戏引擎里都成立只是我们用的是字符而不是像素。当然控制台方案的代价也很明显画面简陋没有真正的圆形只有字符不能接受鼠标输入只能用键盘控制刷新如果写得不好会闪屏。这些问题我在第四节会详细讲怎么解决。总体来说对课程设计这个场景控制台方案是在“效果”和“可解释性”之间最平衡的选择。2. 核心模块设计与实现思路2.1 地图与实体管理游戏世界是一个100×28的字符矩阵。为什么是100×28因为Windows控制台默认窗口大概能显示80×25但我在设计时希望地图比屏幕稍大一点让玩家有“探索”的感觉。实际跑的时候把控制台窗口拉大或者把字体调小100列打印出来效果正好。28行也是同理太矮了地图显得局促太高了刷屏时上下滚动会加剧闪烁。地图上的对象分三类玩家、AI、食物。每类对象都用一个结构体管理结构体只有四个字段x坐标、y坐标、size尺寸、alive存活标记。curriculum design里最常用的一个知识点就在这里——用结构体数组把游戏对象聚集起来然后通过循环统一处理。typedef struct { int x, y; int size; int alive; } Ball;玩家只有一个所以定义成单独变量Ball player;。AI有8个定义成数组Ball ai[MAX_AI];。食物有60个定义成数组Ball food[MAX_FOOD];。这里为什么不直接用链表两点考虑一是数组在循环遍历时更直观访问ai[i]直接索引不需要指针跳转二是课程设计阶段数据量很小总共不到70个对象链表节省的那点内存完全没有意义反而增加代码复杂度和出bug概率。2.2 控制台渲染方案控制台渲染的原理说穿了就是两件事把光标定位到指定位置然后打印字符。Windows提供了SetConsoleCursorPosition这个API我封装成一个gotoxy(x, y)函数把光标移动到(x, y)位置。配合printf输出就能在屏幕任意位置画东西。这里要特别说一个新手容易踩的坑每帧刷新时绝对不要用system(cls)清屏。cls会清空整个屏幕然后所有内容重新打印肉眼看到的就是剧烈闪烁。正确做法是维护一个屏幕缓冲区——定义一个二维字符数组buffer[MAP_H][MAP_W 1]先把这一帧的所有对象画到buffer里然后用gotoxy(0, 0)把光标定位到左上角一次性把buffer里的所有行打印出来。这样打印的是整帧静态画面视觉上干净很多。还有一个隐藏细节光标默认是闪烁的方块正好挡在玩家位置看起来很难受。所以初始化时调用hideCursor()把光标隐藏掉。这行代码看起来不起眼但对体验的提升非常明显。2.3 移动与碰撞检测玩家的移动逻辑简单直接检测键盘输入按W上移、S下移、A左移、D右移每次移动一格。地图边界做穿越处理坐标越界后取模回到另一边。这里要注意的是我每帧只读取一次按键而不是让按键持续生效。如果按住W不放因为键盘有自动重复系统会自动连续触发效果上很像“持续移动”实际代码里只是每次循环检测一次。碰撞检测是整个游戏的核心算法也是答辩时最值得讲的点。两个球是否接触最朴素的方法是计算欧几里得距离距离 sqrt((x1-x2)^2 (y1-y2)^2) 碰撞条件距离 size1 size2但sqrt开方运算在每次碰撞检测里调用几十次虽然现代CPU扛得住可我们完全没必要用。我的做法是两边同时平方变成int dist2(int x1, int y1, int x2, int y2) { int dx x1 - x2; int dy y1 - y2; return dx * dx dy * dy; } // 碰撞判断 if (dist2(ax, ay, bx, by) (asize bsize) * (asize bsize)) { // 发生碰撞 }避免开方而且判断结果完全一样。这个细节很小但答辩老师很吃这一套因为这说明你真的理解计算开销不是只会调sqrt。碰撞后的大小判断就是比较size大的吞小的。这里有个细节如果两个球完全相等怎么办我让它们擦肩而过不触发吞噬。如果用判断会出现两个同样大小的球同时被判定为“吃掉对方”逻辑乱套。用严格大于等于就安全了。2.4 AI怎么“有点傻又有点聪明”AI设计是游戏是否好玩的关键。如果AI只是随机乱走玩家会觉得索然无味如果AI太聪明玩家又会被虐到摔键盘。我的方案是让AI有两种状态状态一附近有威胁比自己大的球立刻朝反方向逃跑状态二没有威胁就朝最近的食物走。先看威胁检测AI遍历所有其他AI和玩家如果发现某个球的size比自己大并且距离小于一个阈值比如20格以内就启动逃跑模式。逃跑方向很简单——当前AI的位置减去威胁的位置得到的方向向量就是远离威胁的方向。比如AI在(50, 50)威胁在(40, 48)向量是(10, 2)AI就往右下方移动。没有威胁时AI执行觅食逻辑遍历所有食物找到离自己最近的那颗然后让自身坐标向食物坐标逼近——如果食物在右边就向右移一格在下方就向下移一格。这个逻辑模拟的是“贪心觅食”每帧都重新规划目标所以AI看起来是有目的地在探索地图。这样设计的AI既有基本的生存本能又不会过于聪明。开局阶段AI会四散找食物一旦玩家长大靠近它们它们会警觉地躲开但如果玩家足够大追击小人时会发现对方像“泥鳅”一样专门往反方向跑这就有了游戏性。3. 关键代码解析与实操演示3.1 数据结构与全局变量完整的代码我放在第六节。这里先说明几个核心定义。全局变量主要是四个地图尺寸宏、玩家对象、AI数组、食物数组。地图尺寸用宏定义因为后面多次用到比如边界判断、循环遍历如果以后想把地图调大调小只需要改宏定义全局自动生效。#define MAP_W 100 #define MAP_H 28 #define MAX_FOOD 60 #define MAX_AI 8这里有个经验把游戏对象、地图尺寸这些东西全部定义在全局区是课程设计阶段最省心的做法。虽然工程上不推荐全局变量但你能在答辩时理直气壮地说“这是为了在小规模程序里简化函数之间的数据传递”老师也不会怼你。等你做真正的项目再考虑把全局变量收进结构体或者用参数传递。3.2 初始化流程游戏开始前的初始化分三块玩家初始位置在屏幕正中初始size为5AI位置随机分布在整张地图初始size在3到8之间随机保证有大有小食物位置随机一颗接一颗铺满地图。随机数种子必须加而且要加在初始化函数里用当前时间做种子srand((unsigned)time(NULL));如果不加这行每次运行游戏生成的随机布局都一样一开局哪儿有食物、AI去哪吃玩家背都背下来了游戏就不存在随机性了。食物生成我单独封装了一个函数void addFood(int index) { food[index].x rand() % MAP_W; food[index].y rand() % MAP_H; food[index].size 1; food[index].alive 1; }这样在初始化时调用60次在食物被吃掉后重新调用让食物在随机位置刷新代码结构很干净。3.3 主循环的运行机制主循环是整个游戏的心脏结构就是经典的“输入-更新-渲染”三件套while (!gameOver) { handleInput(); // 处理玩家键盘输入 for (i 0; i MAX_AI; i) { if (ai[i].alive) aiMove(ai[i]); // 更新每个AI } checkCollision(); // 碰撞检测 吞噬处理 drawMap(); // 渲染当前画面 Sleep(50); // 控制帧率 }Sleep(50)控制每帧间隔50毫秒大约每秒20帧。对于字符界面游戏来说这个帧率足够流畅。不写Sleep的话循环会以CPU的最高速度狂转AI移动快得看不清而且CPU占用率直接飙到100%所以我每次演示前都会特意提一句这个Sleep是控制节奏的关键。循环顺序是有讲究的先处理输入再更新AI再检查碰撞最后渲染。如果顺序反了比如先渲染后更新玩家会看到自己按下按键之后要慢一帧才有反应也就是俗称的“输入延迟”。虽然50毫秒的延迟感知不明显但逻辑上应该保持“先逻辑后渲染”。3.4 碰撞处理的顺序问题碰撞检测不是一句“两两判断”就完了顺序处理不好会出现很诡异的bug。我的代码里分了三步第一步玩家和食物的碰撞。玩家碰到食物就吃掉size加1记1分食物被标记为死亡并重生成。第二步AI和食物的碰撞逻辑一样。第三步玩家和AI、AI和AI的碰撞。先检查玩家和每个AI再检查AI之间的两两碰撞。这里容易出问题的是AI互吃。假设有三个AIA的大小是8B是5C是4。一帧之内A和B碰上了A吞掉Bsize变成10紧接着A又和C碰上了A又吞掉Csize变成12。这种情况在游戏逻辑上没问题因为本来就该一帧只处理一次碰撞。但如果你在同一个循环里用了原地修改的数据结构可能会出现A在同一帧被两个球“同时吞掉”的奇葩状态。我的方案是用alive标记配合严格的大小比较每次吞噬后下一轮循环重新检查保证吞并是链式但有序的。这个细节在答辩时讲出来让人觉得你考虑得很全面。4. 踩坑记录与避坑指南4.1 屏幕闪烁问题这个问题我调试了很久。最初版本我也是用system(cls)清屏运行时整个屏幕疯狂闪动看得眼睛疼。后来改成buffer整帧刷新问题才解决。具体做法就是前面说的不一行一行打印而是先把所有内容整理进一个二维字符数组然后从(0,0)起始一次性按行输出。还有个附加技巧打印完地图后故意留两行空白再在下面输出状态信息这样状态信息不会和地图内容挤在一起。4.2 数组越界导致“幽灵球”第一次做碰撞检测的时候我用了很多ai[i].alive判断但早期版本里AI被吃掉之后只是标记alive0没有把坐标重置。结果渲染时虽然不显示它了但碰撞检测循环还是会遍历它导致“地图上明明没有这个球玩家走过去却被弹开”的幽灵球现象。修复办法就是在所有对AI的遍历里加上alive判断渲染跳过碰撞也跳过这是必然的。这个坑我建议在答辩时主动提出来说明你知道循环遍历时要考虑对象存活状态能加分。4.3 边界穿越与卡位问题实现边界穿越后出现了一个新bugAI在地图边缘逃跑时坐标从0变成MAP_W-1位置瞬间从左边跑到右边但如果追踪它的玩家也跟着穿越边界视觉上两个球像是在“瞬移”。调试半天发现这是逻辑正确的只是显示效果有点跳。处理方法是在渲染层不做任何坐标修正让玩家慢慢习惯“穿越后可能出现在对面”这个设定毕竟球球大作战原版也是环形世界玩家多玩几局就懂了。4.4 编译环境与中文乱码代码用到的_getch()和_kbhit()是Windows Visual Studio系列的头文件函数。如果你用Dev-C或者老版本MinGW编译器可能要改成getch()和kbhit()不带下划线。另外中文提示信息在控制台上默认按GBK编码显示如果源码文件是UTF-8编码可能打印出乱码。我自己的处理是代码里的中文注释不影响运行但printf输出的中文如果乱码要么把源码文件另存为GBK编码要么用英文提示。答辩演示时建议提前在目标机器上完整跑一遍别在现场调编码问题。5. 答辩演示与加分扩展5.1 向老师讲解的四个要点答辩时间一般只有五到十分钟不要意图把所有代码都讲一遍讲清楚四个点就足够清晰了第一数据结构。用结构体表示球说明三个对象集合分别是结构体数组alive字段用来标记存活状态。第二碰撞检测。距离平方代替开方严格大于避免同尺寸互吞吞噬后size增量规则。第三AI策略。威胁感知二态机优先逃跑其次觅食。第四实时循环。输入→更新→碰撞→渲染的循环框架Sleep控制帧率。把四个点串成一句话“这是一个由结构体数组组织对象、以距离碰撞为交互核心、让AI具备威胁感知能力、运行在标准游戏循环里的实时对战模拟程序。”这句话说完老师基本知道你确实是自己写出来的。5.2 想拿高分还能加什么如果课程设计要求更高或者你想冲优秀至少有三个扩展方向第一添加障碍物。在地图里随机生成一些不可穿越的墙块AI需要绕开碰撞检测会复杂一些。第二保存最高分。把历史最高分用文件读写存到本地txt文件每次游戏结束更新这正好用上文件操作知识点。第三鼠标操作。如果敢引入EasyX可以把整个游戏移植成图形版画面效果翻好几倍但工作量也会翻倍。5.3 从课程设计到真实项目的差距这个项目做完之后我再回头看真实游戏开发的思路发现其实很多原理是相通的。游戏引擎里的所谓的“物理系统”“AI行为树”“渲染管线”本质上和我们这个控制台游戏里做的事是一样的——管理对象、判定碰撞、做决策、画画面。课程设计的限定环境逼着你用最笨的方案解决这些问题反而把原理看得更透。等以后学Unity或者别的引擎你会发现很多概念你已经通过C语言课设提前熟悉过了。6. 完整代码可直接运行下面是完整的C语言代码保存为.c文件在Windows环境下用Visual Studio或Dev-C编译即可运行。编译前把控制台窗口拉大一点字体调到合适大小保证100列能完整显示。控制方式WASD移动。/* 伪版球球大作战 - C语言课程设计 * 运行环境Windows Visual Studio / Dev-C * 控制方式WASD移动大球可吞噬小球 */ #include stdio.h #include stdlib.h #include time.h #include ctype.h #include conio.h #include windows.h #define MAP_W 100 #define MAP_H 28 #define MAX_FOOD 60 #define MAX_AI 8 int score 0; typedef struct { int x, y; int size; int alive; } Ball; Ball player; Ball ai[MAX_AI]; Ball food[MAX_FOOD]; void gotoxy(int x, int y); void hideCursor(); void initGame(); void drawMap(); void handleInput(); void aiMove(Ball* b); void checkCollision(); int dist2(int x1, int y1, int x2, int y2); void addFood(int index); int countAliveAI(); void gotoxy(int x, int y) { COORD c; c.X x; c.Y y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), c); } void hideCursor() { CONSOLE_CURSOR_INFO info; info.dwSize 1; info.bVisible 0; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), info); } int dist2(int x1, int y1, int x2, int y2) { int dx x1 - x2; int dy y1 - y2; return dx * dx dy * dy; } void addFood(int index) { food[index].x rand() % MAP_W; food[index].y rand() % MAP_H; food[index].size 1; food[index].alive 1; } void initGame() { int i; player.x MAP_W / 2; player.y MAP_H / 2; player.size 5; player.alive 1; score 0; srand((unsigned)time(NULL)); for (i 0; i MAX_AI; i) { ai[i].x rand() % MAP_W; ai[i].y rand() % MAP_H; ai[i].size 3 rand() % 6; ai[i].alive 1; } for (i 0; i MAX_FOOD; i) { addFood(i); } } int countAliveAI() { int i, cnt 0; for (i 0; i MAX_AI; i) { if (ai[i].alive) cnt; } return cnt; } void drawMap() { int i, x, y; static char buffer[MAP_H][MAP_W 1]; for (y 0; y MAP_H; y) { for (x 0; x MAP_W; x) { buffer[y][x] ; } buffer[y][MAP_W] \0; } for (i 0; i MAX_FOOD; i) { if (food[i].alive) { buffer[food[i].y][food[i].x] .; } } for (i 0; i MAX_AI; i) { if (ai[i].alive) { buffer[ai[i].y][ai[i].x] ai[i].size 8 ? O : o; } } if (player.alive) { buffer[player.y][player.x] ; } gotoxy(0, 0); printf(Score: %d PlayerSize: %d AI Left: %d\n, score, player.size, countAliveAI()); for (y 0; y MAP_H; y) { printf(%s\n, buffer[y]); } } void handleInput() { char key; if (_kbhit()) { key _getch(); key tolower(key); switch (key) { case w: player.y--; break; case s: player.y; break; case a: player.x--; break; case d: player.x; break; } if (player.x 0) player.x MAP_W - 1; if (player.x MAP_W) player.x 0; if (player.y 0) player.y MAP_H - 1; if (player.y MAP_H) player.y 0; } } void aiMove(Ball* b) { int i; int nearestFood -1; int minFoodDist 999999; int threatFound 0; int tx 0, ty 0; if (player.alive player.size b-size) { int d dist2(b-x, b-y, player.x, player.y); if (d 200) { threatFound 1; tx b-x - player.x; ty b-y - player.y; } } if (!threatFound) { for (i 0; i MAX_AI; i) { if (!ai[i].alive || ai[i] b) continue; if (ai[i].size b-size) continue; int d dist2(b-x, b-y, ai[i].x, ai[i].y); if (d 200) { threatFound 1; tx b-x - ai[i].x; ty b-y - ai[i].y; break; } } } if (threatFound) { if (tx 0) tx (rand() % 2) ? 1 : -1; if (ty 0) ty (rand() % 2) ? 1 : -1; b-x (tx 0 ? 1 : -1); b-y (ty 0 ? 1 : -1); } else { for (i 0; i MAX_FOOD; i) { if (!food[i].alive) continue; int d dist2(b-x, b-y, food[i].x, food[i].y); if (d minFoodDist) { minFoodDist d; nearestFood i; } } if (nearestFood 0) { if (food[nearestFood].x b-x) b-x; else if (food[nearestFood].x b-x) b-x--; if (food[nearestFood].y b-y) b-y; else if (food[nearestFood].y b-y) b-y--; } } if (b-x 0) b-x MAP_W - 1; if (b-x MAP_W) b-x 0; if (b-y 0) b-y MAP_H - 1; if (b-y MAP_H) b-y 0; } void checkCollision() { int i, j; for (i 0; i MAX_FOOD; i) { if (!food[i].alive) continue; if (dist2(player.x, player.y, food[i].x, food[i].y) (player.size food[i].size) * (player.size food[i].size)) { food[i].alive 0; addFood(i); player.size; score 1; } } for (i 0; i MAX_AI; i) { if (!ai[i].alive) continue; for (j 0; j MAX_FOOD; j) { if (!food[j].alive) continue; if (dist2(ai[i].x, ai[i].y, food[j].x, food[j].y) (ai[i].size food[j].size) * (ai[i].size food[j].size)) { food[j].alive 0; addFood(j); ai[i].size; } } } for (i 0; i MAX_AI; i) { if (!ai[i].alive || !player.alive) continue; int d dist2(player.x, player.y, ai[i].x, ai[i].y); int r player.size ai[i].size; if (d r * r) { if (player.size ai[i].size) { player.size ai[i].size / 2; score 50; ai[i].alive 0; } else if (player.size ai[i].size) { ai[i].size player.size / 2; player.alive 0; } } } for (i 0; i MAX_AI; i) { if (!ai[i].alive) continue; for (j 0; j MAX_AI; j) { if (i j || !ai[j].alive) continue; int d dist2(ai[i].x, ai[i].y, ai[j].x, ai[j].y); int r ai[i].size ai[j].size; if (d r * r) { if (ai[i].size ai[j].size) { ai[i].size ai[j].size / 2; ai[j].alive 0; } else if (ai[i].size ai[j].size) { ai[j].size ai[i].size / 2; ai[i].alive 0; } } } } } int main() { int i; int gameOver 0; hideCursor(); initGame(); while (!gameOver) { handleInput(); for (i 0; i MAX_AI; i) { if (ai[i].alive) aiMove(ai[i]); } checkCollision(); drawMap(); if (!player.alive) gameOver 1; if (countAliveAI() 0) gameOver 1; Sleep(50); } gotoxy(0, MAP_H 2); if (player.alive) { printf( You Win! All AI are swallowed! \n); } else { printf( Game Over: You are swallowed! \n); } printf(Final Score: %d\n, score); return 0; }最后再说两个小经验。编译运行前把控制台窗口最大化再把字体改成点阵字体或者小号字体100列才不会折行如果你用的是老版本Dev-C_getch()若提示未声明改成getch()就行。这个项目后续想扩展建议先加一个“玩家吃球时显示增长动画”哪怕只是闪烁一下字符观感都会上一个台阶。我做完这个课设之后最大的体会是别怕项目小而简单把小而简单的项目做到逻辑闭环把每一步原理吃透比堆一个自己都讲不清楚的大项目有用得多。
返回列表