C语言课程设计:红蓝军对战游戏完整框架解析

C语言课程设计:红蓝军对战游戏完整框架解析 简介这是一份面向C语言初学者的课程设计小游戏源码以红蓝军对战为主题完整覆盖士兵、炮兵、装甲车、坦克等单位的移动、攻击、人数统计与状态恢复等核心玩法。资源压缩包共34个文件包含20个C源文件、13个头文件及1个说明文档源码按函数模块拆分除主流程外还涉及移动、AI、战斗、地图绘制、文件读取等模块整体仅24KB便于快速下载和研读。目前已有117人学习下载。项目中通过结构体保存单位位置、生命值、攻击力等属性借助数组与指针管理战场与单位状态并实现了回合制对战、胜负判断和用户输入交互等逻辑。对于正在完成C语言课程设计或希望结合游戏场景练习数据结构、模块化编程的读者这套代码能提供完整的项目框架与可直接运行的参考方案阅读源码也有助于理解小型C语言项目如何组织头文件、划分模块以及处理地图和单位数据。1. 红蓝军对战一个C语言课程设计里的完整游戏框架拿到这份 red-blue-confrontati.zip 的时候我其实有点意外一个课程设计小游戏居然拆出了 20 多个 .c/.h 文件。仔细过了一遍结构后发现它并不是那种几百行贴在一起的控制台 demo而是真正把「单位属性、地图网格、回合制战斗、鼠标交互、文件存档」都串起来的完整小游戏。对做 C 语言课程设计的同学来说这个项目的价值在于它同时覆盖了结构体设计、二维数组地图、数组管理单位、EasyX 图形界面、文件读写和简单 AI 决策每个部分都能单独拿出来讲原理。下面我就是以这个项目为线索按「数据结构 → 战斗 AI → 交互界面 → 验收排错」的顺序把里面值得复用的写法拆开讲。无论你是自己写一遍还是拿它做课设答辩都建议把模块边界先看清楚。2. 先看懂项目骨架struct.h、mapping.c 与文件读写模块课程设计最容易翻车的地方是数据结构和业务逻辑糊在一起。这个项目中struct.h 负责所有类型定义mapping.c 负责地图初始化readfile.c 负责把外部配置读进内存分工很明确。理解这个骨架后面的 AI 和战斗就有地方落。2.1 单位、地图和全局状态怎么建模游戏里有士兵、炮兵、装甲车、坦克四类单位每一类在 struct.h 里共用一套结构体通过 type 字段区分。我一般会这样定义一个单位typedef struct unit { int id; // 单位唯一编号 int side; // 0 红方, 1 蓝方 int type; // 0士兵, 1炮兵, 2装甲车, 3坦克 int x, y; // 网格坐标 int hp, max_hp; // 当前生命值/最大生命值 int atk, def; // 攻击力、防御力 int range; // 攻击范围格 int move; // 每回合可移动格数 int moved; // 本回合是否已移动 int attacked; // 本回合是否已攻击 int alive; } Unit;地图用二维数组全局状态用一个结构体包起来。单位数量不多时用数组管理即可没必要上链表遍历排序都方便。#define MAP_W 20 #define MAP_H 15 typedef struct map_cell { int terrain; // 0空地, 1障碍物, 2建筑 int occupy; // 驻扎单位的 id-1 表示无 } MapCell; MapCell map[MAP_H][MAP_W]; Unit units[100];这里的关键是occupy字段它代替了在地图上直接存单位指针避免指针悬空。每次单位移动后旧格子的 occupy 要置 -1新格子写入单位 id。这个「地图只存索引不存指针」的做法在 C 语言课设里非常实用因为 EasyX 不需要开线程根本不用担心指针并发问题。2.2 从 readfile.c 看到数据外部化地图和单位配置怎么从文件加载项目里的 x.txt 通常保存地图布局比如用数字表示地形用特定字符表示初始单位。把地图数据放在外部文件里好处是调整关卡不用重新编译。readfile.c 这个文件做的就是这个事。我一般会写成这样int load_map(const char* path, MapCell map[MAP_H][MAP_W]) { FILE* fp fopen(path, r); if (fp NULL) return -1; for (int i 0; i MAP_H; i) { for (int j 0; j MAP_W; j) { int val; if (fscanf(fp, %d, val) ! 1) { fclose(fp); return -2; } map[i][j].terrain val; map[i][j].occupy -1; } } fclose(fp); return 0; }注意这里用了fscanf(%d)而不是getchar因为地图文件里数字之间可能有多余空格或换行。返回 -1 表示文件打不开-2 表示格式错误好处是调用方可以根据返回值弹窗提示而不是直接崩溃。加载单位配置可以用类似的fscanf读取side,type,x,y四元组然后逐个调用create_unit()。关于文件读写这个项目里还有 file1.c/file2.c通常是存盘和读档。课程设计答辩时老师常问「扩展名存什么」建议直接存二进制结构体数组比如fwrite(units, sizeof(Unit), unit_count, fp)恢复时fread一次读回简单且不易错。但要注意结构体有对齐缺口换编译器可能不兼容如果追求跨平台就改成逐字段fprintf/fscanf。我的习惯是课设存文本因为答辩时可以直接用记事本打开展示。2.3 模块拆分renew.c 和 game.c 怎么把每帧更新串起来游戏的执行流程在 main.c 里一般是「初始化 → 主循环 → 回收」主循环里做的事通常会放到 renew.crenew 就是 refresh 的简写负责每帧刷新状态。game.c 则保存整个游戏对象例如当前处于菜单、战斗还是结束状态。下面是一个典型的主循环骨架while (running) { handle_input(); // 读取键盘/鼠标消息 if (state STATE_BATTLE) { update_game(); // renew.c 中的刷新函数 render_game(); // pictures.c 中的绘制函数 } Sleep(50); // 50ms 一帧约 20 FPS }这段循环里Sleep(50)非常关键。没有它EasyX 的getmessage会空转CPU 占用直接跑满加了它动画节奏也容易控制。update_game()内部按顺序务必是「玩家操作 → AI 行动 → 状态恢复 → 胜负判断」。顺序反了会出现 AI 抢先攻击、死亡单位仍能反击之类的怪问题。打架逻辑放 fighting.cAI 决策放 ai.crenew.c 只做调度这样每个文件职责单一。为了更直观下表列出项目文件常见职责我按源码包内的名字对照文件职责关键点struct.h类型定义Unit/MapCell/GameStatemapping.c地图初始化与障碍判断二维数组遍历readfile.c加载地图/单位配置fscanf 按格式读取moveing.c移动逻辑检测 occupy 和地形ai.c电脑方决策距离计算/目标选取fighting.c战斗结算伤害公式/胜负判定pictures.c界面绘制EasyX putimagemouse.c鼠标交互坐标转格子main.c程序入口主循环这个表不是让你背而是做模块设计时脑子里要有一张同样的图。很多同学写课设写着写着就把 AI 和移动放在同一个函数里最后改一步错一步就是缺了这张图。3. 回合制战斗与 AI从 moveing.c 到 ai.c 的决策链路这一章是游戏的核心。红蓝军对战本质是回合制战棋双方单位轮流行动每个回合内可以移动、攻击、待机。moveing.c 处理移动ai.c 处理电脑阵营的自动决策fighting.c 负责每一次攻击的数值结算。三个文件串起来就是「谁动、动到哪、打谁、打多少血」的完整链路。3.1 移动与碰撞moveing.c 里怎么处理边界和阻挡单位只能在格子间移动所以第一步是把像素坐标换算成网格坐标这一步往往在 mouse.c 里做然后调用移动函数。一个可用的移动函数如下int try_move(Unit* u, int nx, int ny) { if (nx 0 || nx MAP_W || ny 0 || ny MAP_H) return 0; if (map[ny][nx].terrain OBSTACLE) return 0; if (map[ny][nx].occupy ! -1) return 0; int cost abs(nx - u-x) abs(ny - u-y); // 曼哈顿距离 if (cost 0 || cost u-move) return 0; // 更新地图占用关系 map[u-y][u-x].occupy -1; u-x nx; u-y ny; map[ny][nx].occupy u-id; u-moved 1; return 1; }这里用abs(nx-x)abs(ny-y)作为移动消耗适合战棋类网格如果允许斜向移动消耗改成max(abs(dx),abs(dy))。注意u-move在回合开始时由 renew.c 重置而不是在移动函数里重置这样才能做到「一个回合动一次」。moved标记的作用是重复点击同一单位时已经移动过的格子要变灰否则玩家会连续移动好几格。一个容易踩的坑是玩家点击目的地时如果路径中间有障碍物上述代码并没有做「寻路」它只判断终点可站。课程设计里这是可以接受的因为地图是离散网格手动操作通常不会穿越障碍。如果 AI 频繁穿墙你才需要补一个 BFS 最短路径这个我放在 3.2 里一起讲。3.2 AI 索敌ai.c 里用简单贪心实现自动攻击电脑阵营的 AI 不需要很聪明但至少要做两件事找到最近敌人走过去然后打。常见做法是遍历蓝方所有存活单位再遍历红方单位用曼哈顿距离挑出最近目标。int find_nearest_enemy(Unit* me, Unit* enemies, int enemy_count) { int target_id -1; int min_dist 9999; for (int i 0; i enemy_count; i) { if (enemies[i].alive 0 || enemies[i].side me-side) continue; int dist abs(enemies[i].x - me-x) abs(enemies[i].y - me-y); if (dist min_dist) { min_dist dist; target_id i; } } return target_id; }这个函数返回目标在数组里的下标。得到目标后AI 要先尝试攻击if (dist me-range) fire(me, enemies[target_id])否则往目标方向逼近一格。逼近的方向用符号函数sign(dx)和sign(dy)决定移动后要重新检查占用关系。这里的参数me-range很关键炮兵范围大、移动慢坦克近战强、装甲值高AI 的行为应该按 type 区分否则所有单位都只会互砍。我的做法是给 AI 加一个「攻击偏好」炮兵优先打远处高血量单位坦克优先打贴近自己血少的单位。如果需要寻路在移动前用 BFS 标记所有可到达格子然后按敌方方向移动int next_step_towards(Unit* u, int tx, int ty) { static int dist[MAP_H][MAP_W]; // 以 u-x,u-y 为起点对地图做 BFSdist 记录到每格的最短距离 // 然后在 (tx,ty) 到起点路径上取第一步 // 课程设计够用没必要上 A*BFS 已经能通过答辩 }这里我特意没写完整 BFS因为那是另一个话题。课程设计答辩时能说清楚「用 BFS 而不是 DFS」就足够体现算法意识了。3.3 战斗结算fighting.c 里的伤害公式和胜负判定终于到数值环节。传统的伤害公式是伤害 攻击力 - 防御力但这样会出现伤害为 0 的情况。实际项目里常见做法是乘一个随机浮动再给一个最低伤害例如int calc_damage(Unit* atk, Unit* def) { int base atk-atk * 10 - def-def * 5; if (base 10) base 10; int rnd rand() % 20 - 10; // -10% ~ 10% return base * (100 rnd) / 100; }注意这里把攻击力和防御力都放大 10 倍避免atk-def小数丢失。rand()需要在 main 里先srand(time(NULL))否则每次运行伤害完全相同。攻击后调用def-hp - damage如果hp 0就置alive0并把对应格子occupy置 -1。同时还要给被攻击方反击的机会但反击的前提是对方在自身攻击范围内且本回合未攻击过。这个「反击」逻辑要放在fighting.c而不是ai.c因为它是战斗规则的一部分。状态恢复功能对应摘要里说的「补给和治疗」renew.c 里每回合会给指定单位回血void renew_units() { for (int i 0; i unit_count; i) { if (units[i].alive units[i].hp units[i].max_hp) { units[i].hp 5; if (units[i].hp units[i].max_hp) units[i].hp units[i].max_hp; } units[i].moved 0; units[i].attacked 0; } }这里的血量恢复数量5建议做成可调参数放配置文件中方便答辩时展示数值平衡。胜负判定写在整个回合结束后一方存活单位数为 0或一方人数低于某个阈值比如少于 3就宣布失败。注意人数统计要每次遍历判断alive不能用开局人数减去死亡数因为可能有多方单位同时死亡。4. EasyX 图形与鼠标交互pictures.c、mouse.c 与界面状态机C 语言课设里图形界面最容易让老师眼前一亮。这个项目有 pictures.h、mouse.h、selectmo.c说明它用了 EasyX 图形库。EasyX 本身只是 VC6 / VS 下的一个 Windows 图形库优点是 API 简单能让学生把精力留在游戏逻辑上。4.1 用 EasyX 贴图pictures.c 里的图片加载与显示先用loadimage把资源图片读进 IMAGE 对象再在绘制阶段调用putimage贴到指定位置。图片放在相对路径下注意工作目录问题。初始化窗口的代码通常是initgraph(MAP_W * CELL_SIZE, MAP_H * CELL_SIZE); loadimage(img_soldier, soldier.png); loadimage(img_tank, tank.png);这里推荐把所有图片对象定义成全局IMAGE变量。putimage贴图时如果是 32x32 的格子地图起点是(0,0)则某个单位应该画在(x * 32, y * 32)。为了不掩盖地形先绘制地图格子再绘制单位最后绘制选择框。pictures.c里一般会有一个draw_unit(Unit* u)函数内部按照u-side决定用红色还是蓝色画笔再按u-type决定贴哪张图。颜色可以直接用 EasyX 的RED/BLUE避免去搞调色板。4.2 鼠标消息处理mouse.c 怎么把点击坐标映射到游戏格子EasyX 里鼠标消息用MOUSEMSG结构体拿比如while (MouseHit()) { MOUSEMSG m GetMouseMsg(); if (m.uMsg WM_LBUTTONDOWN) { int gx m.x / CELL_SIZE; int gy m.y / CELL_SIZE; select_unit(gx, gy); } }m.x是像素坐标除以格子边长得到网格坐标。这里有个细节如果窗口在initgraph里设置过左上角偏移比如要留边栏显示信息计算gx时要把偏移减掉写成(m.x - LEFT_PANEL) / CELL_SIZE。select_unit内部要维护一个「当前选中单位」的全局变量第二次点击时如果目标是敌方单位且距离在攻击范围内则触发攻击而不是移动。这个「选中—目标」两段式交互几乎所有战棋游戏都用代码量不大但交互体验提升明显。注意MouseHit()要放在主循环里且在update_game之前调用否则一帧内输入会被绘制覆盖。鼠标消息处理完后要把当前点击位置保存到全局变量供绘制函数高亮显示选中格子和攻击范围。4.3 界面切换selectmo.c 与 explain.c 构成的状态机项目里有 selectmo.c选择模式、explain.c游戏说明、stop.c暂停这几个文件本质就是游戏状态机。我一般用一个枚举加一个全局状态变量typedef enum { STATE_MENU, STATE_EXPLAIN, STATE_SELECT, STATE_BATTLE, STATE_PAUSE, STATE_END } GameState; GameState state STATE_MENU;主循环里根据 state 调用不同的更新和绘制函数。selectmo.c 里的「选择模式」通常指玩家选择红方或蓝方或者选择简单/困难 AI。这些选择实际上是把一些全局变量比如玩家阵营、AI 难度系数赋值而已。例如简单模式下 AI 每回合只移动 1 格困难模式下移动 3 格。这个数可以直接读取difficulty全局变量在 AI 的me-move上做乘法。状态切换的常见错误是直接在case里写实现导致每个分支很长。更稳妥的写法是每个状态对应两个函数比如menu_update()、menu_render()主函数只做分发switch (state) { case STATE_MENU: menu_update(); menu_render(); break; case STATE_BATTLE: game_update(); game_render(); break; // ... }stop.c的暂停就是在暂停状态下停止调用game_update()只保留菜单绘制这样实现起来非常干净也不用担心暂停时单位还在动。5. 课程设计验收把工程搬到 VS 并跑通的几个细节最后这部分是提分技巧不是总结。课程设计最终要当面演示和答辩跑不起来就是零分。这里分享几个我用这个项目时最常遇到的问题和解决办法。5.1 编译环境与头文件顺序这个项目要在 Visual Studio 里建一个 Windows 空项目先添加所有 .c 和 .h 文件然后在项目属性里选择字符集为「使用多字节字符集」否则 EasyX 的loadimage传中文路径会提示编译错误。头文件包含顺序也有讲究struct.h必须最先被包含因为别的头文件里都要用到Unit类型。pictures.h要等mouse.h之后包含因为某些绘制函数需要鼠标坐标的全局变量。如果出现error C2371之类的重定义大概率是某个头文件忘了加#ifndef守卫。我建议每个头文件都写成这样#ifndef STRUCT_H #define STRUCT_H typedef struct { /* ... */ } Unit; #endif这样就算重复包含也不会报错。5.2 常见运行时报错图片加载失败、坐标越界EasyX 程序双击运行后黑屏闪退十有八九是initgraph失败或者图片文件不在当前目录。VS 中当前目录默认是工程目录不是 exe 所在目录。最简单的验证方法是把图片放在设置工作目录的宏// 在 main 函数最开头添加 char buffer[MAX_PATH]; GetCurrentDirectory(MAX_PATH, buffer); // 检查图片文件是否存在不存在则用绝对路径调试 FILE* f fopen(soldier.png, r); if (f NULL) MessageBox(NULL, 请把图片放到源码目录, 提示, MB_OK);如果你在调试阶段不想弹窗可以临时把资源路径写成绝对路径。坐标越界也是常见崩溃源一定在try_move和find_nearest_enemy里先判断越界和alive再访问map数组。C 语言不检查数组越界你写错一个下标画面上可能只是一个看不见的错位但多走几步就崩溃了。调试时建议把MAP_W和MAP_H临时改成 3x3用固定输入测试一遍移动和攻击逻辑再改回正式尺寸。5.3 一个加分的调试技巧把战斗日志写入文件答辩时老师会问「你怎么验证 AI 决策正确」。我习惯在fighting.c里写一个调试函数把每次攻击信息追加写入battle.logvoid log_battle(const char* action, const Unit* a, const Unit* b, int dmg) { FILE* fp fopen(battle.log, a); if (fp NULL) return; fprintf(fp, %s: %d-%d, dmg%d, hp%d\n, action, a-id, b-id, dmg, b-hp); fclose(fp); }日志文件可以完整复盘一场战斗比 printf 输出到控制台更稳定也不影响游戏画面。配合 5.2 的MessageBox检查这个项目的调试体验基本能覆盖课设会遇到的所有坑。如果你的设计说明书写了「支持恢复功能」也可以把 renew_units 的回血量写进日志演示时直接打开 log 文件让老师看比口头解释更有说服力。最后提醒一句把这些调试代码用#ifdef DEBUG包起来发布/演示版本把宏注释掉就能得到一份干净的可执行文件。日志文件会自然积累不需要额外处理。本文还有配套的精品资源点击获取