我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

用cocos2d-x开发双人坦克大战:从设计到碰撞检测的完整实践

用cocos2d-x开发双人坦克大战:从设计到碰撞检测的完整实践 简介在2D游戏开发中引擎选择与核心机制的理解是入门关键。cocos2d-x作为经典的跨平台2D引擎其场景管理、精灵动画、事件监听和碰撞检测等模块为开发者提供了完整的游戏开发链路。通过一个双人坦克大战的练手项目可以系统掌握格子系统、AABB碰撞检测、分轴移动回退、多键输入映射等核心技术。本文从概念到原理从技术价值到应用场景剖析了如何利用该引擎实现经典街机玩法并针对子弹穿透、坦克卡墙、手感调优等实战问题给出解决方案既适合新手快速上手也为游戏开发中的碰撞检测与对象管理提供了工程实践参考最终自然聚焦到双人坦克大战demo的完整实现与复盘。 说来有点惭愧这个双人坦克大战demo是我在啃cocos2d-x时顺手做的一个验证项目。当时目的很简单一是想确认双人同屏对战坦克这个玩法到底有没有意思二是想通过一个完整的小项目把cocos2d-x的场景管理、精灵动画、事件监听、碰撞检测这些核心机制彻底摸一遍。做完之后我发现这个demo的代码量不大但把所有游戏开发的基础问题都碰了一遍对新手熟悉引擎特别有帮助。如果你也在学cocos2d-x或者想用经典玩法练手这篇文章应该能帮你少走不少弯路。我会把整个项目的设计思路、踩坑记录和代码取舍都摊开来讲包括那些文档里不会写、只有真正跑起来才会遇到的细节。1. 立项先别急着写代码这个demo的目标边界和玩法取舍1.1 为什么偏偏选坦克大战来熟悉cocos2d-x市面上练手游戏很多贪吃蛇、飞机大战、打砖块都算经典。但我最终选了坦克大战是因为它几乎能覆盖2D游戏开发的所有基础系统场景管理需要一个游戏场景可能还要加上菜单场景和结算场景。地图系统坦克大战的地图是格子化的涉及地图数据结构、墙体碰撞、砖块摧毁。对象管理坦克、子弹、墙体爆炸这些都是独立对象各自有生命周期。碰撞检测坦克与墙、坦克与坦克、子弹与墙、子弹与坦克这是4层碰撞关系。输入系统双人同屏意味着要做键盘分区还要考虑多键同时按下的情况。游戏状态切换开始、暂停、胜负判定、重新开始。你会发现贪吃蛇基本只涉及对象移动和自身碰撞飞机大战主要是弹幕和敌机生成但坦克大战把这些全占了。用一个demo把这么多系统都过一遍比做三个小游戏效率高得多。而且坦克大战的双人模式天然有竞技性做完之后拉同事来对战一局反馈非常直接。1.2 demo范围怎么定什么该做、什么该砍我见过太多人做demo时喜欢给自己加戏什么道具系统、4种敌人AI、多关卡、Boss战统统塞进来结果写了一个月还没跑通热情全耗在调试里。做demo的目标应该非常明确验证核心玩法熟悉引擎。所以我给自己定了几个硬性边界。必须做的部分两个玩家键盘分别控制能在同一张地图里移动和射击。地图上有墙体子弹打砖墙会摧毁砖墙打钢墙没反应。玩家有血量或被打中就判定胜负的简单规则。有开局等待和胜负结算。明确砍掉的部分敌人AI。demo阶段只做双人对抗不做单机敌人。道具系统。什么五星升级、定时炸弹、铲子护盾统统不做。出生保护。真实FC版有出生无敌保护demo初期直接放弃。地形多样性。只做砖墙和钢墙草地、冰面、水域全部不做。这个取舍很重要。你是在验证玩法不是在复刻FC游戏。把范围控制在双人互射一周之内就能跑起来而且后续每个扩展方向都是独立的模块想加随时能加。很多人在demo阶段失败就是范围失控。2. 工程搭建和坦克对象设计环境准备与核心类结构2.1 版本选型和编译时最容易忽略的细节cocos2d-x目前常见的是3.17.2这个版本它的C API稳定社区资料多遇到问题能搜到答案。4.0版本改了很多接口但demo阶段没有必要追新能跑通、能学习才是第一位的。环境准备有几个坑印象很深。第一个坑是资源路径。cocos2d-x默认会把Resources目录作为资源根目录但你从网上下载的老教程会用CCFileUtils::getInstance()-addSearchPath()去添加自定义路径这个API在新版本里仍然可用但如果你在Windows下用的是中文用户名或者项目放在带中文的路径里资源加载容易出现诡异的问题。我的做法是项目路径保持全英文图片资源全部放在Resources目录下省心。第二个坑是编译速度。用VS打开cocos2d-x工程第一次全量编译快则几分钟、慢则十几分钟这很正常。但如果你改了引擎底层代码比如去改CCDirector那每次编译都会很痛苦。demo阶段千万不要改引擎源码所有业务逻辑都写在Classes目录下自己的类里。第三个坑是创建工程。官方推荐用cocos new命令创建工程或者直接找一个别人的cocos2d-x根目录复制测试工程过来改。我当时图省事直接复制了官方cpp-test的工程结构改了个名字结果发现里面带了一堆用不到的依赖。如果你用CMake构建记得把不需要的模块关掉比如ccUseLua、ccUseJS这些宏不然编译时间会变长。2.2 坦克类怎么设计从Sprite到状态机坦克大战里的坦克本质就是一个能移动、能转向、能发射子弹的对象。在cocos2d-x里最直接的做法是让它继承Sprite这样渲染、位置管理都由引擎帮你搞定了。我定义坦克类大概是这样enum class TankDirection { UP, DOWN, LEFT, RIGHT }; class Tank : public cocos2d::Sprite { public: static Tank* createWithId(int playerId); void setDirection(TankDirection dir); void move(float delta); void stop(); bool fire(); // 碰撞后回调 void onHit(); private: void updateMove(float delta); void updateFrame(); TankDirection _direction TankDirection::UP; cocos2d::Vec2 _velocity cocos2d::Vec2::ZERO; float _moveSpeed 120.0f; // 像素/秒 float _fireCooldown 0.35f; // 射击冷却时间 float _cooldownLeft 0.0f; int _hp 1; int _playerId 1; };继承Sprite的好处是你直接可以用Tank::createWithId(1)创建出一个带默认贴图的坦克对象然后加到场景里就开始干活了。这里有几个设计细节值得展开讲一讲。第一个是移动方向。坦克大战里坦克是格子式转向的也就是按一下方向键立刻转90度不能像射击游戏那样平滑旋转。我用一个枚举方向加上一个方向对应的速度向量来管理void Tank::setDirection(TankDirection dir) { _direction dir; switch (dir) { case TankDirection::UP: _velocity cocos2d::Vec2(0, _moveSpeed); break; case TankDirection::DOWN: _velocity cocos2d::Vec2(0, -_moveSpeed); break; case TankDirection::LEFT: _velocity cocos2d::Vec2(-_moveSpeed, 0); break; case TankDirection::RIGHT: _velocity cocos2d::Vec2(_moveSpeed, 0); break; } this-setRotation(0); // 先清掉旋转通过替换贴图来表现方向 }表现方向的方式有两种一种是让坦克图片就朝上画然后用setRotation(90/180/270)去旋转另一种是准备四张方向贴图切方向时换贴图。我实际推荐换贴图因为旋转Sprite虽然方便但如果你后续要做坦克履带动画旋转就不好处理了。而且FC坦克本身就是像素风格四个方向各画一张很简单。第二个是帧更新。坦克的移动写在update里用delta乘以速度来保证匀速void Tank::updateMove(float delta) { auto pos this-getPosition(); pos.x _velocity.x * delta; pos.y _velocity.y * delta; this-setPosition(pos); if (_cooldownLeft 0) { _cooldownLeft - delta; } }这个写法是基础但非常关键的。如果你不用delta而直接一帧加固定偏移那在帧率不稳定的时候游戏速度会忽快忽慢。虽然demo里计算机性能通常很稳但养成这个习惯很重要。第三个是坦克的出生点。玩家1的坦克出生在左下角玩家2出生在右下角方向一个朝右一个朝左。这个要在场景初始化时设置好并且开局要做个简单倒计时防止一方还没准备好就被打死。2.3 碰撞检测里最重要的边界问题坦克对象设计好之后第一个真正卡住我的问题是碰撞检测。cocos2d-x自带了物理引擎但对于坦克大战这种格子化、规则简单的游戏用物理引擎其实是杀鸡用牛刀而且物理引擎的碰撞回调处理起来没有手写AABB来得直接。我的做法是所有障碍物墙、坦克都维护一个碰撞矩形每帧检测玩家坦克和障碍物的矩形是否相交。cocos2d-x的Sprite自带getBoundingBox()返回的是节点在世界坐标轴对齐的包围盒正好就是AABB。所以碰撞检测的逻辑非常直白bool GameScene::isCollidingWithWall(Tank* tank) { auto tankRect tank-getBoundingBox(); for (auto wall : _walls) { if (tankRect.intersectsRect(wall-getBoundingBox())) { return true; } } return false; }但这里有一个非常经典的坑坦克在移动时如果直接一帧把位置从A点挪到B点而B点和墙重叠了那你检测到碰撞后怎么处理直接回退和把位置设回去会导致坦克在贴墙时抖个不停。我最后采用的方案是按轴分步移动。意思是不直接把(vx vy) * delta同时作用在坦克上而是先只移动X轴检测碰撞如果碰了就把X轴位置回退再移动Y轴检测碰撞碰了再回退Y轴。这样坦克既能贴着墙走又不会卡进墙里。void Tank::moveWithCollision(float delta) { // 先移动X轴 auto pos this-getPosition(); pos.x _velocity.x * delta; this-setPosition(pos); if (_gameScene-isCollidingWithWall(this)) { // 回退X轴 pos.x - _velocity.x * delta; this-setPosition(pos); } // 再移动Y轴 pos.y _velocity.y * delta; this-setPosition(pos); if (_gameScene-isCollidingWithWall(this)) { pos.y - _velocity.y * delta; this-setPosition(pos); } }这个方法的本质是分轴检测回退比用物理引擎的碰撞体简单而且效果特别好坦克可以在墙边滑动。在双人对战里这种贴墙滑动的手感非常重要因为玩家经常需要贴着墙边缘探出身去射击。如果直接用整体移动直接卡死那个手感就很僵硬了。3. 地图、子弹与碰撞核心玩法落地3.1 地图数据结构从二维数组到可视化的墙体坦克大战的地图天然适合用二维数组表示。我用一个std::vectorstd::vectorint来存地图数据每个格子代表一个32x32像素的块0表示空地1表示砖墙2表示钢墙。demo的地图只有两个玩家对战不需要很复杂我用了官方原版的对称地图结构左右镜像中间留一条通道。地图的初始化过程是这样的void GameScene::initMap() { _mapData { {1,1,0,0,0,0,0,1,1,0,0,0,0,0,1,1}, {1,0,0,2,2,0,0,0,0,0,0,2,2,0,0,1}, // 这里省略中间行实际16x16的二维数组 ... }; for (int row 0; row _mapData.size(); row) { for (int col 0; col _mapData[row].size(); col) { int type _mapData[row][col]; if (type 0) continue; auto wall Wall::create(type); wall-setPosition(col * TILE_SIZE TILE_SIZE / 2, row * TILE_SIZE TILE_SIZE / 2); _walls.push_back(wall); this-addChild(wall); } } }这里有个细节地图的坐标原点和cocos2d-x坐标原点都在左下角所以第0行应该在地图最下面。很多新手会习惯性地认为row0是在最上面结果地图做出来是上下颠倒的。我调试的时候打印过坐标发现这个问题浪费了10分钟。对于墙体我也没有用复杂的物理引擎就是一个普通的Sprite加上一个碰撞矩形。砖墙被子弹打中后要从地图数据里标记为0并且从_walls里移除同时播放一个简单的销毁动画。3.2 子弹系统生命周期和对象复用子弹是坦克大战里创建最频繁的对象如果每次发射都new一个Sprite、用完再delete虽然demo阶段无所谓但会养成坏习惯。我用了对象池的思路来管理子弹先创建一批备用子弹放到池子里发射时从池子里取一个激活碰到物体后回收。不过第一次做demo时我没有直接写对象池而是用了一个最朴素的方案子弹作为一个独立的Bullet对象发射时创建碰到物体或者飞出地图就移除。跑起来之后我发现一个明显的问题连续快速射击时场景里会同时存在七八颗子弹每颗子弹都在做碰撞检测虽然性能没崩但偶尔会发现子弹和子弹互相之间的碰撞没处理好导致误删。如果只做demo最简单的子弹逻辑是这样class Bullet : public cocos2d::Sprite { public: void initBullet(TankDirection dir, cocos2d::Vec2 pos, int ownerId); void updateBullet(float delta); private: cocos2d::Vec2 _velocity; int _ownerId 0; // 用来区分是哪个玩家发射的 float _lifeTime 2.0f; };子弹的移动和坦克一样用delta乘以速度向量。但要注意子弹的速度如果太快比如超过每帧几百像素就会出现穿透问题子弹一帧跨过了墙体或坦克的碰撞矩形根本检测不到相交。这个问题的解法有两种一是限制子弹速度把速度控制在单帧移动距离小于碰撞体最小尺寸的范围二是做细分步进把一帧分成几小步来移动和检测。demo里我选了第一种把子弹速度设为240像素/秒在60帧率下单帧移动4像素不会穿墙。3.3 碰撞检测的层级顺序坦克与坦克、子弹与墙、子弹与坦克碰撞检测写起来不难难的是顺序。如果你的代码顺序不对会出现子弹同时撞了墙和坦克到底优先处理哪个的bug。我最终的碰撞检测顺序是先更新所有子弹的位置检测子弹和墙体碰撞。如果命中砖墙砖墙销毁子弹销毁如果命中钢墙子弹销毁墙不变。再检测子弹和坦克的碰撞。注意要排除子弹所有者自己的坦克不然你刚射出去的子弹就把自己打死了。我用_ownerId来区分。最后检测坦克和坦克的碰撞。两个坦克如果相遇互相不能穿过处理方式和坦克碰墙一样分轴回退。void GameScene::checkBulletHitTank(Bullet* bullet) { if (bullet-getOwnerId() 1) { auto tank2 _player2Tank; if (bullet-getBoundingBox().intersectsRect(tank2-getBoundingBox())) { bullet-removeFromParent(); tank2-onHit(); this-onGameOver(1); } } // 同理处理ownerId2的情况 }这个顺序很关键。如果先检测子弹和坦克再检测子弹和墙可能会出现子弹同时贴近坦克和墙时你优先判定了坦克被击中但画面里子弹其实已经和墙重叠了视觉上很怪。4. 双人控制输入映射和操作手感调优4.1 键盘监听方案用EventKeyboard做多键同时按下FC版坦克大战的经典双人操作是玩家1用WASD移动、F键射击有些版本用J键玩家2用方向键移动、Shift键或小键盘0射击。我在demo里继承了这个方案因为WASD和方向键分开不容易手指打架。cocos2d-x的键盘事件用EventListenerKeyboard来监听。但有一个很容易踩的坑onKeyPressed和onKeyReleased会在每个键盘事件里被调用如果你直接在里面设置一个boolean标志位然后在update里根据标志位决定移动会出现按键状态丢失的问题。因为cocos2d-x的键盘事件在Windows平台上对某些按键组合支持不完整比如同时按W和D时D的释放事件可能会异常。我的做法是维护两个std::setcocos2d::EventKeyboard::KeyCode一个给玩家1一个给玩家2。每次按键按下时往set里加释放时移除。update里查set来决定移动方向void GameScene::onKeyPressed(cocos2d::EventKeyboard::KeyCode keyCode, cocos2d::Event* event) { if (keyCode EventKeyboard::KeyCode::KEY_W) _player1Keys.insert(keyCode); if (keyCode EventKeyboard::KeyCode::KEY_S) _player1Keys.insert(keyCode); // ... 其他按键 } void GameScene::updatePlayer1(float delta) { if (_player1Keys.find(EventKeyboard::KeyCode::KEY_W) ! _player1Keys.end()) { _player1Tank-setDirection(TankDirection::UP); _player1Tank-moveWithCollision(delta); } else if (_player1Keys.find(EventKeyboard::KeyCode::KEY_S) ! _player1Keys.end()) { _player1Tank-setDirection(TankDirection::DOWN); _player1Tank-moveWithCollision(delta); } // A和D类似 }用set的好处是不会漏掉按键释放事件。而且我还发现在Windows上如果使用第三方输入法某些快捷键会干扰键盘事件导致按W没反应。遇到这种情况只需要把输入法切成英文模式就行但我当时排查了很久才确认是这个原因。4.2 操作手感移动速度、转向延迟和射击冷却双人游戏最重要的是手感手感又来自一组数值。我调了很长时间最终感觉比较合适的一组参数是参数值说明坦克移动速度120像素/秒比原版略快一点双人对战节奏更紧凑子弹速度240像素/秒太快容易穿透太慢没打击感射击冷却0.35秒限制了连射频率避免变成弹幕游戏转向方式瞬时转向保持FC原版的机械手感坦克碰撞尺寸28x28贴图是32x32稍微缩小一点避免卡墙这里有一个手感调优的教训一开始我把射击冷却设成0.1秒结果双方都在无脑连射子弹满屏飞对战变成了看谁先运气好打中一点策略性都没有。而把冷却提高到0.35秒后玩家会在开枪前考虑要不要瞄准、怎么走位游戏深度一下子出来了。这就是demo验证玩法价值的体现。还有一个细节是射击时子弹的初始位置。如果从坦克中心发射子弹一出来就会和坦克自身的碰撞矩形重叠导致子弹立刻被判定为打中自己。我把子弹初始位置设置在坦克前方一个格子Vec2 Bullet::calcSpawnPosition(Tank* owner) { auto pos owner-getPosition(); auto dir owner-getDirection(); float offset 24.0f; // 比半个坦克大一点 switch (dir) { case TankDirection::UP: pos.y offset; break; case TankDirection::DOWN: pos.y - offset; break; case TankDirection::LEFT: pos.x - offset; break; case TankDirection::RIGHT: pos.x offset; break; } return pos; }这个偏移量不能太大否则子弹看起来像从空气里射出来的也不能太小不然会误伤自己。我试了16、24、28三个值24最自然。5. 调试过程复盘demo开发中踩过的坑5.1 子弹穿透和重叠问题的排查前面提到子弹速度太快会穿透但我最开始并没有直接意识到。我的引擎帧率在调试时打开了vsync某些机器上会跑到120帧有些只有30帧。测试时发现同样速度的子弹在低帧率机器上偶尔会穿过砖墙。用log打印子弹每帧的位置发现一帧位移了十几像素而砖墙厚度只有32像素按理说不会穿透但问题出在砖墙的碰撞矩形是按getBoundingBox()计算的如果砖墙的锚点、位置和实际贴图对不上矩形可能偏移了。用drawNode画出所有碰撞盒之后真相大白我给墙体创建碰撞矩形时用的是wall-getBoundingBox()但某个墙体的锚点被我改成了(0,0)而其他墙体锚点是(0.5,0.5)导致碰撞盒偏移了半个格子。这个bug很隐蔽因为只有部分墙体会出问题。排查方法在调试模式下开启cocos2d::Director::getInstance()-setDisplayStats(true)然后在场景里叠加一个DrawNode把每个对象的碰撞矩形画出来void GameScene::drawDebug() { _debugNode-clear(); for (auto wall : _walls) { auto rect wall-getBoundingBox(); _debugNode-drawRect(Vec2(rect.getMinX(), rect.getMinY()), Vec2(rect.getMaxX(), rect.getMaxY()), Color4F(0, 1, 0, 1)); } auto tankRect _player1Tank-getBoundingBox(); _debugNode-drawRect(Vec2(tankRect.getMinX(), tankRect.getMinY()), Vec2(tankRect.getMaxX(), tankRect.getMaxY()), Color4F(1, 0, 0, 1)); }这段代码放在update里每帧刷新一次。调试完记得关掉不然正式玩的时候满屏绿框红框。5.2 坦克卡在墙壁里的边界处理分轴移动回退方案解决了一大半卡墙问题但还有一个bug当两个坦克同时挤向同一堵墙时靠墙近的那个坦克可能在回退时被另一个坦克排挤导致贴图位置在墙里一两像素从视觉上看出陷进去了。我的解决办法是给碰撞矩形加一个收缩边距inset。在生成坦克碰撞盒时不用整个getBoundingBox()而是手动收缩4像素cocos2d::Rect Tank::getCollisionRect() const { auto rect this-getBoundingBox(); rect.origin.x 4; rect.origin.y 4; rect.size.width - 8; rect.size.height - 8; return rect; }这算是游戏开发里一个比较通用的技巧视觉表现和碰撞体不完全一致。碰撞体略小于贴图可以显著改善边缘剐蹭的手感玩家不会觉得明明还有空隙却走不动。5.3 帧率显示和性能观察cocos2d-x自带帧率显示用Director::getInstance()-setDisplayStats(true)开启屏幕左上角会显示FPS和每帧耗时。我开发过程中一直开着方便确认有没有哪段代码导致帧率骤降。有一段时间坦克在地图上跑起来后帧率从60掉到40。排查发现问题出在每帧update里都调用了getBoundingBox()而这个函数会先从父节点递归计算世界坐标而且我在地图里放了大量墙体对象每帧都要遍历所有墙体做两次碰撞检测。对于demo来说是完全可以接受的但为了跑顺我把墙体分成两个集合一个是可被子弹摧毁的砖墙一个是打不动的钢墙。子弹碰撞只需要遍历砖墙坦克碰撞需要遍历所有墙。这样减少了不必要的遍历。其实这个方法换成空间哈希或网格索引会更高效但对demo来说减少对象数量和遍历范围是性价比最高的优化。性能优化这种事永远是在真的出现瓶颈时才做demo阶段不要过度设计。6. 我到底验证了什么以及这个demo还能怎么延伸6.1 验证结论关于玩法和引擎的几点判断跑起来并和几个朋友对战过之后我对这个项目有了明确的结论。首先双人坦克大战这种玩法放在今天依然有很强的本地多人乐趣。两个人的操作空间很窄同屏、地图有限但正因为地图小冲突发生频率高每一局的胜负都充满戏剧性。尤其是在一炮没打中、绕到墙后反杀那一刻朋友瞬间爆发出这也行的惊呼这就是玩法的价值所在。其次cocos2d-x这个引擎的核心机制确实很适合做2D像素风游戏。场景-节点-精灵-动线-事件这套体系是我见过最直观的游戏对象组织方式。相比Unity那种强组件结构cocos2d-x更像替你封装好一切的类库写起来有种原始控制一切的爽快感。但它的经典之处也伴随着年老比如C内存管理、autorelease机制、坐标变换这些都需要额外理解成本。如果你玩过一遍cocos2d-x再去看Unity或者Godot会容易很多。最后demo验证了一个客观事实一套玩法能不能立住和引擎无关。核心是手感参数和控制逻辑。用cocos2d-x和用别的引擎做出来体验上的差异远小于数值调优带来的差异。6.2 后续可以做的几个方向如果这个demo继续做下去我会优先加这几个东西第一个是道具系统。FC版里的铁锹、满屏炸、无敌这些道具每一样都是打破僵局的强机制。道具生成位置可以放在地图中央用随机刷新让双方去抢会大幅增强对抗性。第二个是出生点保护。双人开局时加个2秒无敌护盾避免一方开局就朝对面出生点开火不给人活路。我实测了几局发现这个demo因为出生点相对固定老玩家会记角度开局隔墙打体验很不好必须加保护。第三个是单机AI模式。既然已经实现了完整的地图、坦克、子弹系统加一个简单的AI敌人并不难。AI逻辑可以先用状态机巡逻、瞄准、开火、逃跑四种状态切换足够做出看起来有点聪明的对手。这样即使没有人一起玩也可以自己测试。第四是音效和音乐。目前整个demo是静音的打击感少了一大半。坦克移动的履带声、开炮声、墙体爆炸声这三个音效是最基本的。如果不想用版权音乐可以自己用软件合成8-bit风格音效几KB大小的音频文件就能明显提升游戏质感。写在最后做这个双人坦克大战demo前前后后花了我大约一个周末的时间。收获最大的不是代码量而是我终于把cocos2d-x的整个开发链路跑通了资源加载、场景搭建、精灵管理、键盘事件、自定义碰撞、对象生命周期、调试统计、性能观察全部亲手过了一遍。如果你也想用类似的方式熟悉一个游戏引擎我的建议非常简单选一个你小时候最爱的经典游戏砍掉一半功能用最朴素的方式把它实现出来。不要纠结引擎版本不要纠结代码架构先让它跑起来然后慢慢打磨手感。你会发现当你能熟练地调整一个射击冷却时间来改变整个游戏节奏时你对游戏开发的理解已经上了一个台阶。本文还有配套的精品资源点击获取
返回列表