尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++与EasyX仿超级马里奥游戏开发:从碰撞检测到游戏循环

C++与EasyX仿超级马里奥游戏开发:从碰撞检测到游戏循环 简介这是一份基于 C 与 EasyX 图形库开发的仿超级马里奥游戏项目完整还原了经典横版闯关的核心玩法包含 1-1、1-2、1-3 三个可玩关卡。项目代码结构清晰涵盖游戏场景、角色控制、怪物碰撞、道具与关卡逻辑等模块并配有项目说明与超详细注释非常适合作为计算机相关专业的毕业设计、课程设计或期末大作业参考也便于初学者快速上手或二次开发。资源包共 239 个文件约 10.61MB其中 163 个 PNG 图片负责角色、地形与UI素材25 个 MP3 和 1 个 WAV 提供背景音乐与音效21 个 cpp 和 21 个 h 构成完整源码另有 sln、vcxproj 解决方案文件可直接打开编译。整体目录组织有序下载后即可运行体验已有 302 人学习使用。通过阅读源码和注释读者可以掌握 EasyX 绘图、游戏循环、碰撞检测、状态切换等实用开发技巧在此基础上扩展新关卡或新功能也较为方便。1. 从“能跑”到“能玩”仿超级马里奥到底在仿什么当你拿到一个名为“基于EasyX 图形库和C开发的仿超级马里奥游戏源码项目说明超详细注释sln解决方案.zip”的压缩包第一反应可能是打开sln编译运行看到窗口里马里奥能跳能跑就关掉。但真正有经验的C开发者会先翻两样东西注释里的数据结构设计以及碰撞检测的判定顺序。仿超级马里奥不是把几张像素图贴到窗口里而是把“人物动画、重力、平台碰撞、敌人AI、关卡切换”这一整套侧视平台跳跃游戏的最小可运行骨架用C和EasyX在Windows上实现。EasyX只是提供绘图和输入接口真正的游戏逻辑全部要自己写这正是这个项目适合你读源码而不是直接玩游戏的原因。这类项目的价值在于它用最小代码量让你看清游戏循环、DeltaTime、碰撞响应、状态机这些概念如何落地。适合刚学完C语法、想接触游戏开发但没有游戏引擎基础的人也适合要给学生带课或做课程设计的工程师。下面我直接拆解这套代码里最容易被忽略、也最容易出 bug 的五个技术断面照着做你也能写出属于自己的复刻版。2. EasyX下的游戏循环与绘图缓冲先让窗口动起来2.1 用EasyX初始化窗口的最小可用代码EasyX是一个面向Windows的C图形库本质是对GDI的薄封装头文件是graphics.h。它的编程模型是创建窗口循环绘图响应消息。没有内置的组件系统也不管游戏循环所有帧率控制都要自己做。最基础的初始化代码如下。#include graphics.h #include conio.h int main() { initgraph(800, 600); // 创建 800x600 绘图窗口 setbkcolor(RGB(30, 30, 60)); // 设置背景色 cleardevice(); // 清屏为背景色 // 主循环游戏是否结束 while (!_kbhit()) { // 1. 处理输入读键盘状态 // 2. 更新逻辑移动、碰撞 // 3. 绘制先画场景再画精灵 Sleep(16); // 粗略按 60 FPS 控制节奏 } closegraph(); // 关闭图形窗口 return 0; }initgraph的两个参数是窗口宽高单位像素。很多复刻版会做成800x600因为马里奥关卡横向滚动纵向只需要看到地面和头顶几格砖块。Sleep(16)是一般做法但严格说是“假帧率控制”因为逻辑更新和绘制都睡在同一个线程里如果某次绘制超过16ms整体节奏就变慢。进阶做法是用GetTickCount64()测量真实耗时后面第5章会讲。2.2 双缓冲为什么你的窗口总在闪烁EasyX默认每次绘图都直接写显存当你先清屏再画图时用户会看到闪白或闪黑。解决办法是使用双缓冲先把所有内容画到一个内存位图里再一次性贴到屏幕。EasyX里典型写法是BeginBatchDraw(); // 开始批量绘制自动创建后台缓冲区 while (!_kbhit()) { cleardevice(); // 清空后台缓冲区 // 画地面、砖块、敌人、马里奥 drawPlayer(); // 示例函数 FlushBatchDraw(); // 将后台缓冲区一次提交到屏幕 } EndBatchDraw();参数上不需要额外配置BeginBatchDraw和EndBatchDraw成对出现。注意cleardevice()在批量模式下清的是后台缓存不是屏幕。很多新手漏掉FlushBatchDraw导致窗口一片黑。判断是否真的启用了双缓冲可以看窗口缩放或拖动时是否卡顿而不是看静态画面。2.3 帧率与移动速度的换算用DeltaTime统一所有速度如果直接把角色坐标写成x 5在CPU占用低的机器上跑可能每秒300帧角色飞一样快在别人机器上可能只有30帧角色慢吞吞。正宗做法是引入帧间隔时间dt所有速度都从“每帧多少像素”变成“每秒多少像素”。LONGLONG prevTime GetTickCount64(); while (!_kbhit()) { LONGLONG now GetTickCount64(); float dt (now - prevTime) / 1000.0f; // 单位秒 prevTime now; if (dt 0.05f) dt 0.05f; // 防止切窗口后瞬移 player.velY GRAVITY * dt; // 重力加速度 * 秒 player.x player.velX * dt; // 水平速度 * 秒 player.y player.velY * dt; // 垂直速度 * 秒 Sleep(1); // 让出CPU避免占满 }这里的重力GRAVITY单位是“像素/秒²”而不是“像素/帧²”。这是两个流派的分水岭。老派写法用每帧固定步长代码简单但分辨率、机器性能一变就出问题新派写法引入dt后所有跳跃高度、移动速度都变成时间相关调参时只改常量比如跳跃初速JUMP_VELOCITY -800.0f像素/秒负值向上。参数需要你根据自己窗口高度试窗口600像素高重力1500时跳跃初速-700到-900之间手感比较接近原版马里奥。3. 马里奥核心机制重力、碰撞与状态机3.1 把角色模型成“条形盒子”AABB碰撞仿马里奥这种2D平台游戏碰撞体通常是凸多边形中最简单的轴对齐包围盒AABB。角色、砖块、管道都用RECT或自定义结构表示碰撞判定就是两个矩形是否相交。但直接相交就反弹会遇到一个经典问题从侧面碰砖也会被当成踩头。正确做法是“移动后分轴处理”。struct Collider { float x, y, w, h; // 左上角坐标 宽高 }; bool intersect(const Collider a, const Collider b) { return a.x b.x b.w a.x a.w b.x a.y b.y b.h a.y a.h b.y; }分轴处理说明先把角色的水平速度加到位置再检测水平碰撞碰撞后把角色的x方向速度置0并贴紧对方边界然后在垂直方向做同样的事。好处是能准确判断“落地”和“撞头”。很多源码为了省事只做一次全量碰撞结果就是马里奥站在砖缝里会被卡住或者下落时被斜着踢出去。你在读源码时重点看它update()函数里是先移动x还是先移动y以及碰撞后是如何调整坐标的。3.2 重力、跳跃与手感调参数字背后的物理意义超级马里奥的手感来自三组参数重力加速度、跳跃初速度、空中加速度。我们把三者写成常量方便在Visual Studio的“解决方案资源管理器”里直接改。const float GRAVITY 1500.0f; // 下落加速度像素/秒^2 const float JUMP_VELOCITY -750.0f; // 跳跃瞬间垂直初速度负数向上 const float MOVE_SPEED 260.0f; // 水平移动速度像素/秒 const float AIR_CONTROL 0.6f; // 空中转向加速度倍率越小越难操作跳跃高度可以用公式估算v²/(2g)即750*750/(2*1500)187.5像素。如果关卡里砖块高度是32像素一格这个跳跃能越过5格接近原版。原版红白机上的马里奥跳跃高度约4格因此想更还原就调低初速或调高重力。还有两个容易忽略的细节一个是跳跃“滞空缓冲”也就是玩家按下跳跃键后稍微给几帧时间如果这几帧内角色落地了也能起跳另一个是“松开跳跃键提前截断上升速度”。这两个手感优化是区分“能玩”和“好玩”的关键。// 在按键检测中如果跳跃键按下让allowJumpBuffer 0.1f每次update衰减 if (isKeyDown(VK_UP) (player.onGround || allowJumpBuffer 0)) { player.velY JUMP_VELOCITY; player.onGround false; } // 上升途中松开跳跃键将向上速度减半提前终止跳高 if (!isKeyDown(VK_UP) player.velY 0) player.velY * 0.7f;3.3 关卡怎么存用二维数组做砖块地图马里奥每关的地形是固定的格状布局。仿制版最流行、也最好调试的方式是用一维或二维数组每个数字代表一种砖块ID。比如0是空气1是地面砖2是问号砖3是普通砖。int map[LEVEL_HEIGHT][LEVEL_WIDTH] { {0,0,0,0,0,0,0,0,0,0}, {0,0,0,2,0,0,0,0,0,0}, {0,0,0,0,0,0,1,1,0,0}, {1,1,1,1,1,1,1,1,1,1}, }; // 每格像素大小如 TILE_SIZE 32渲染时只需要两个for循环根据数组值调用fillrectangle或者从图集中截取贴图。碰撞检测时也只需要把马里奥的碰撞盒映射到地图坐标检查它覆盖了哪几个格子是不是墙壁。这个“映射到格子坐标”的计算是高频出 bug 点注意要用floor而不是int截断因为角色坐标可能是负的。int leftTile (int)(player.x / TILE_SIZE); int rightTile (int)((player.x player.w) / TILE_SIZE); int topTile (int)(player.y / TILE_SIZE); int bottomTile (int)((player.y player.h) / TILE_SIZE);当角色速度很快一帧移动超过一个格子时上述碰撞会直接穿透。简易做法是在运动后把速度限制在一个格子边长以内正规做法是扫掠碰撞检测但EasyX的仿制项目里基本不用因为帧率较高、速度也受限。你读源码时看到player.x velX * dt后面立即做碰撞就要考虑人眼可见的“钻墙”是否有速度上限保护。3.4 敌人与金币同一套碰撞逻辑复用敌人蘑菇怪和金币的本质也是碰撞体差别只在碰撞后的反应函数。蘑菇怪只需要向左走到墙边掉头遇马里奥时判断碰撞方向如果马里奥的底部在敌人顶部之上视为踩踏敌人消失马里奥弹跳否则马里奥受伤甚至死亡。实现时不需要复杂AI一个patrolDir变量即可。struct Enemy { Collider col; float patrolDir; // 1 向右-1 向左 int type; }; // 每帧逻辑 enemy.col.x enemy.patrolDir * ENEMY_SPEED * dt; // 检测撞墙查看当前方向下一个格子是否为实心 if (isWallAt(enemy.col.x (enemy.patrolDir 0 ? enemy.col.w : -1), enemy.col.y enemy.col.h)) { enemy.patrolDir * -1; }这里要说明一下参数ENEMY_SPEED常见取60像素/秒大约是马里奥基础速度的四分之一。太慢显得呆太快玩家反应不过来。碰撞判断“踩到敌人”的阈值一般设为角色碰撞盒底部与敌人碰撞盒顶部之间的重叠量比如if (player.velY 0 player.y player.h - enemy.y 12)这个12像素就是宽容区间太大容易误伤太小难操作。4. 工程组织与sln解决方案从单文件到可交的作业4.1 Visual Studio中创建EasyX项目的完整步骤拿到.zip解压后里面应该有一个.sln文件用Visual Studio打开按F5编译运行。但如果你是打算从零自己建项目步骤是新建空项目CWindows控制台应用点击“项目-属性”把字符集设为多字节然后在源码中#include graphics.h并链接libeasyx.a新版EasyX是头文件lib。关键点项目属性里的“附加包含目录”和“附加库目录”要指向EasyX安装后的路径。如果你看到fatal error C1083: 无法打开包括文件: “graphics.h”就是这个问题。sln解决方案文件本身是Visual Studio的工程容器一个sln里可以有多个vcxproj项目。教学型源码通常只有单项目但存在sln的意义是保留“可编译环境”的完整配置。自建项目时建议也顺手写一个README.md或者在源码头部注释里写清楚“开发环境Windows 10/11 Visual Studio 2022 EasyX 2023”避免别人下载后说编译不过。4.2 多文件拆分的固定套路header、source、assets不要把所有代码塞进main.cpp。常见做法是拆成Game.h/Game.cpp主逻辑、Player.h/Player.cpp角色类、Level.h/Level.cpp地图、Object.h/Object.cpp敌人和道具。以下是一个最小头文件结构。// Player.h #pragma once #include Collider.h class Player { public: Player(float startX, float startY); void update(float dt, const Level level); void draw() const; bool onGround false; private: float x, y, w, h; float velX, velY; };对应C源文件里update负责处理重力、按键、碰撞和状态切换。这里要注意C类的“覆盖”问题如果你在派生类写了同名函数但没有加override关键字编译器不报错但可能调错这个游戏demo里没有继承体系但你要扩展敌人种类时建议用虚函数和override。4.3 超详细注释怎么写注释的是意图不是语法很多开源项目的注释只是把代码翻译成中文比如// 增加速度这种注释无用。值得学习的是“为什么”注释和“边界情况”注释。例如// 使用ceil而不是int转换防止角色y坐标刚好等于边界时把上一行误判为脚下 int bottomTile (int)ceilf((player.y player.h) / TILE_SIZE) - 1;写项目说明时建议包含四个部分运行方法、操作按键、关卡数据格式、核心逻辑梳理。你用Markdown写README.md甚至比sln里的代码更重要别人评审作业时首先看这个。按键映射表可以这样写方向键移动Z跳跃ESC退出R重开。4.4 防止“TODO注释地狱”的注释规范建议如果你的源码会发给别人看注释里禁止出现“这里应该修改但还没改”等不明确句子。规范是每个函数上方注释它输入输出每个复杂分支注释“这个分支什么时候会进”。比如// 只有按下跳跃键且缓冲区未过期时才允许起跳 // 缓冲区是给“玩家提前0.1秒按下跳跃键但还没落地”的手感补偿 if (jumpBufferTime 0)这说明你既懂代码也懂玩法。批量编译时注释不会影响性能但如果开了预处理宏比如#define DEBUG_COLLISION里面的printf会导致EasyX窗口和命令行混在一起需要先说清楚。5. 进阶验证动画状态机与摄像机跟随5.1 用枚举管理角色的动画状态把“站立、跑动、跳跃、下落”做成枚举比一堆布尔值清晰得多。enum class PlayerState { IDLE, RUN, JUMP, FALL }; PlayerState state;状态切换的条件要按优先级排列先判定是否在地面再判定速度方向。例如velY 0的状态是JUMPvelY 0且不在空中的是FALL水平速度不为0且在地面是RUN否则IDLE。每个状态对应不同的贴图起始坐标和帧率用frameTimer累计时间来控制换帧。这里的帧率参数跑步动画建议每秒8到12帧跳跃动画只需要2到3帧甚至固定一张。5.2 摄像机让世界大于窗口典型马里奥关卡比窗口宽好几倍。实现摄像机跟随的一个常见做法是不移动全部砖块而是维护一个全局viewX绘制时所有物体的x坐标减去viewX。公式为screenX worldX - viewX。摄像机跟随不应直接等于玩家坐标否则在跳跃时画面上下抖动有的是加一个“平滑阻尼”。float targetViewX player.x player.w / 2 - screenWidth / 2; viewX viewX (targetViewX - viewX) * 0.2f; // 阻尼系数0.2 if (viewX 0) viewX 0; // 关卡左边界0.2这个参数意味着摄像机大概每5帧接近目标一半距离手感比较柔和。边界限制很关键否则游戏一开始画面会把左边的空白露出来。垂直方向是否跟随取决于关卡是否高出屏幕仿马里奥1-1关卡通常不跟随垂直方向这样可以降低实现难度。5.3 验证碰撞系统是否健壮的三个测试用例代码写完后不要只玩一遍。要做三个定向测试确保碰撞逻辑对得起注释。第一边缘测试站在最右侧单元格边缘向墙角移动角色不应该被吸进去。检测方式是在角色x方向加小步长0.01像素观察输出坐标是否有突变。第二高速下落测试把重力临时设为100000角色从高点下落看是否能穿透地面。如果是穿透了说明你的碰撞顺序先水平后垂直不够必须用扫掠或者把最大速度限制在TILE_SIZE / dt以内。第三踩敌容差测试不断从不同高度踩同一个敌人记录允许失败的像素数这个值应稳定在预期范围内。我在阅读源码时会先在update函数里添加临时调试输出#ifdef DEBUG_COLLISION printf(x%.2f y%.2f vx%.2f vy%.2f onGround%d\n, player.x, player.y, player.velX, player.velY, player.onGround); #endif控制台与图形窗口同时存在时可以观察但发布版要屏蔽。最后关于sln解决方案我建议你编译一遍后把.vcxproj文件里的配置都检查一下尤其是“调试-工作目录”和“平台工具集”这在新电脑上最容易崩。如果注释和项目说明都写得到位这个复刻项目就能作为一个完整的C入门游戏开发教学案例被别人直接复用。本文还有配套的精品资源点击获取
返回列表