首页 / 资讯中心 / 文章详情

Flappy Bird Xcode工程:碰撞检测、管道复用与计分闭环

Flappy Bird Xcode工程:碰撞检测、管道复用与计分闭环 ★ FEATURED ARTICLE
简介面向iOS游戏开发初学者与Xcode实践者的Flappy Bird复刻源码围绕2D休闲游戏的核心机制展开可用于学习Swift或Objective-C编程、SpriteKit场景组织、物理模拟与触摸交互。包内共58个文件以m与h源码、png图像素材、json和plist配置、sks场景及mp3/wav音效为主分别承载逻辑实现、纹理与界面资源、场景数据和声音反馈另含工程配置与说明文档压缩包约507KB体量较小便于导入Xcode研究。已有179人浏览学习。代码覆盖小鸟重力与点击上升、管道随机生成与移动、SKPhysicsBody碰撞检测、穿管计分和游戏结束回调也涉及游戏循环、touchesBegan事件监听、Interface Builder界面搭建与Auto Layout适配。读者可据此梳理从场景搭建、资源加载到得分与失败判定的完整流程并观察工程目录组织、参数调节和调试测试方法帮助理解游戏状态管理与碰撞响应适合课程设计、自学练手或作为2D游戏开发与Xcode工程实践的参考。1. 一份 Xcode 工程点开之前flappy bird 这类小游戏的完整闭环有个刚入行的朋友拿着一个 flappy bird 的 Xcode 工程来问我说能编译但是鸟一碰管道就穿过去分数也不加。我打开看了十分钟问题出在contactTestBitMask上一个常量写成了默认值——这类小工程看着简单但从场景搭建、物理体配置、管道复用、碰撞回调到计分状态机一条链路都不少任何一环漏了都会表现成「能跑但不对」。这个工程适合两类人一是想第一次完整走通 iOS 游戏开发闭环的新手二是想拿一个干净骨架做玩法试验的熟手。它不依赖第三方引擎纯 SpriteKit 加系统物理代码量小到可以一个下午读完但麻雀虽小该有的骨架全在。2. 工程骨架SpriteKit 场景树与资源目录怎么组织2.1 打开工程先看目录哪些是代码哪些是资源第一次拿到源码包别急着按运行先把目录过一遍。这类工程常见的组织方式是把所有东西平铺在工程根下看着乱但胜在找得到。我一般会先整理成下面这个心智模型再动手改FlappyBird/ ├── FlappyBird.xcodeproj # 工程文件签名和 Target 都在里面 ├── FlappyBird/ │ ├── AppDelegate.swift # 生命周期入口通常没什么逻辑 │ ├── GameViewController.swift # 把 SKView 挂到窗口上 │ ├── GameScene.swift # 玩法主场景核心逻辑八成在这 │ ├── Bird.swift # 小鸟节点物理体和动画封装 │ ├── PipePair.swift # 一对管道的节点容器 │ ├── PhysicsCategory.swift # 碰撞掩码常量务必集中管理 │ └── Assets.xcassets/ # 图片资源目录 │ ├── bird_0.imageset │ ├── pipe_up.imageset │ └── ground.imageset └── FlappyBirdUITests/ # 自动生成的测试 Target可以直接不看判断结构是否健康看两个信号就够了一是碰撞掩码有没有单独抽成文件二是管道有没有做成独立节点类。如果GameScene.swift里同时出现了0x1 1和addChild(pipe)和score 1说明所有逻辑糊在一起后面改难度会很痛苦——这在二手源码里非常常见属于典型的「能跑但改不动」。资源目录这块要注意Assets.xcassets里的 imageset 是有Contents.json的如果拷贝工程时漏了Contents.jsonXcode 会显示黄色警告运行时UIImage(named:)直接返回 nil节点尺寸变成 0表现出来就是「程序没崩但什么都看不见」。2.2 视图控制器到场景SKView 的三行关键配置SpriteKit 工程的入口比 UIKit 工程薄很多GameViewController一般只做一件事把SKView顶上去然后 present 一个场景。下面这段是这类工程里最典型的写法也是我改过最多遍的地方import UIKit import SpriteKit final class GameViewController: UIViewController { override func loadView() { // 直接把 SKView 当根视图省掉一个中间层 view SKView(frame: UIScreen.main.bounds) } override func viewDidLoad() { super.viewDidLoad() guard let skView view as? SKView else { return } // 兄弟节点顺序不参与排序SpriteKit 用 zPosition 自己决定层级 // 打开后 draw call 更少但要求所有节点手动设 zPosition skView.ignoresSiblingOrder true // 调试 HUD上线前记得关 skView.showsFPS true skView.showsNodeCount true let scene GameScene(size: skView.bounds.size) scene.scaleMode .aspectFill // 关键见 2.3 skView.presentScene(scene) } override var prefersStatusBarHidden: Bool { true } }逐项说清楚ignoresSiblingOrder true是性能开关。默认情况下 SpriteKit 会按节点加入顺序排序渲染打开这个开关后会改成纯按zPosition排序同一zPosition的节点可以被合批。代价是你必须给背景、管道、小鸟、地面分别设置不一样的zPosition否则会出现管道画在鸟身上这种灵异现象。我一般约定背景 0、管道 10、小鸟 20、地面 15、UI 100。scene.scaleMode .aspectFill决定了场景尺寸和设备屏幕的换算关系。写.aspectFill意味着场景内容按比例铺满超出部分被裁掉写.resizeFill意味着场景尺寸直接等于屏幕尺寸节点坐标得自己按比例算。这两种在手机上看起来都正常但在不同长宽比的设备上差异巨大选错就会踩 2.3 节那个坑。GameScene(size:)传的是skView.bounds.size也就是当前设备的点尺寸。如果源码里写死了CGSize(width: 320, height: 480)那在全面屏上要么留黑边要么内容被拉伸——这是老工程最常见的遗留问题。2.3 场景坐标系与缩放不同屏幕为什么位置全乱SpriteKit 的坐标系原点在左下角y 轴向上这跟 UIKit 左上角原点、y 轴向下是反的。新手第一次调位置把小鸟的 y 设成 100 结果贴在地上就是这个原因。真正容易翻车的是锚点。SKSpriteNode默认anchorPoint是(0.5, 0.5)也就是 position 指向节点中心但SKScene本身没有 anchorPoint 的概念它的原点永远在左下角。下面这段是场景初始化的典型写法final class GameScene: SKScene { private let playableHeightRatio: CGFloat 0.85 override func didMove(to view: SKView) { // 背景铺满整屏锚点设在左下角方便按尺寸算 let bg SKSpriteNode(imageNamed: bg_day) bg.anchorPoint .zero bg.position .zero bg.size size bg.zPosition 0 addChild(bg) // 地面只占底部 15%anchorPoint 设左下角位置直接贴底 let ground SKSpriteNode(imageNamed: ground) ground.anchorPoint .zero ground.position .zero ground.size CGSize(width: size.width, height: size.height * 0.15) ground.zPosition 15 addChild(ground) // 小鸟的水平位置按场景宽度比例来不写死像素 bird.position CGPoint(x: size.width * 0.25, y: size.height * 0.55) } }这里的核心思路是所有位置和尺寸都用size的比例表达不写绝对像素。因为size会随设备和scaleMode变化写死 320 就意味着换一台设备全乱。playableHeightRatio 0.85这个常量是我加的一个小抽象它表示地面上方可以玩的区域占比。后续做管道高度随机时随机范围要落在这段区域里而不是整屏——否则管道缺口会被地面挡住玩家看到的是「莫名其妙就死了」。提示如果你发现所有节点都挤在左下角先看scaleMode是不是.resizeFill再看有没有把size传给GameScene。这两个地方不对坐标系会整体缩放比你想象中更常见。2.4 纹理图集与资源命名图片资源这块小工程一般直接用imageNamed:从Assets.xcassets里取。这么写在节点数量少的时候没问题一旦管道开始频繁创建销毁内存和 draw call 都会涨。常见的优化做法是建一个Sprite Atlas.spriteatlas目录把bird_0、bird_1、bird_2这些帧动画图片放进去Xcode 打包时会合成一张大图运行时一次加载、多次复用。代码层面不需要改还是SKTexture(imageNamed:)但底层会命中图集缓存。我一般还会统一命名规范原因是帧动画要用数组索引// 命名约定bird_0 / bird_1 / bird_2方便按序拼数组 private lazy var birdFrames: [SKTexture] { (0...2).compactMap { SKTexture(imageNamed: bird_\($0)) } }()compactMap而不是map是为了在某一帧资源缺失时静默跳过而不是带着一个空纹理往下跑。这个细节看起来小但换资源的时候能省不少排查时间——空纹理不会崩只会在屏幕上显示成一块白色方块。3. 核心链路重力、管道复用与碰撞判定三件事3.1 小鸟一个圆形物理体加三种状态小鸟的行为其实只有三件事受重力下坠、点击给一个向上的冲量、撞到东西就停。用 SpriteKit 的物理引擎实现核心是把physicsBody的四个参数配准final class Bird: SKSpriteNode { static func make(at position: CGPoint) - Bird { let bird Bird(texture: SKTexture(imageNamed: bird_0)) bird.position position bird.zPosition 20 // 用圆形而不是矩形避免翅膀尖碰到管道边缘时误判 let body SKPhysicsBody(circleOfRadius: bird.size.width * 0.38) body.isDynamic true body.allowsRotation false // 关掉旋转鸟不会翻跟头 body.mass 0.02 // 质量小一点冲量手感更跟手 body.linearDamping 0 // 无空气阻力纯重力 body.restitution 0 // 弹性为 0撞了不弹 // 三个掩码我是谁、我撞谁、谁撞我能收到回调 body.categoryBitMask PhysicsCategory.bird body.collisionBitMask PhysicsCategory.none // 不产生物理反弹 body.contactTestBitMask PhysicsCategory.pipe | PhysicsCategory.ground bird.physicsBody body return bird } func flap() { guard let body physicsBody else { return } body.velocity .zero // 先清零避免连点叠加 body.applyImpulse(CGVector(dx: 0, dy: 0.28)) } }几个参数背后的取舍circleOfRadius用size.width * 0.38而不是 0.5是因为鸟的贴图带翅膀四角是透明的用外接圆会偏大落地判定会提前。0.38 是我试过比较顺手的值太小会穿模太大会「隔空死」。collisionBitMask .none意味着小鸟和管道之间没有物理反弹撞上不会弹开全靠contactTestBitMask触发回调再手动处理死亡。这是横版跑酷里非常标准的做法——如果开启 collision鸟撞管道会被弹到什么位置全看物理引擎心情手感不可控。applyImpulse的值0.28跟mass 0.02是配套的。冲量除以质量得到速度变化0.28 / 0.02 14 pt/s的瞬时上冲。如果你改了 mass 但没改冲量会立刻发现鸟要么飞不起来要么一飞冲天这是新手改参数时第一个踩的坑。velocity .zero那行是手感优化的关键。不做清零的话连点两下速度会线性叠加鸟会突然窜出屏幕。3.2 管道生成复用池比 addChild/removeFromParent 稳管道是小游戏里唯一的高频创建对象。一个跑得久一点的局几十上百对管道被创建和销毁如果每次都addChildremoveFromParent会有两个问题一是节点重建和纹理查找的开销二是removeFromParent之后如果还有动作在跑容易出一些难查的野指针问题。我一般用对象池代码稍长但是后面改起来稳final class PipePool { private var available: [PipePair] [] private unowned let scene: GameScene init(scene: GameScene, preload: Int 6) { self.scene scene for _ in 0..preload { let pair PipePair() pair.isHidden true scene.addChild(pair) available.append(pair) } } /// 从池里取一对池空则新建永远不会返回 nil func obtain() - PipePair { if let pair available.popLast() { pair.isHidden false return pair } let pair PipePair() scene.addChild(pair) return pair } /// 回收隐藏 归位不真正从场景树摘掉 func recycle(_ pair: PipePair) { pair.removeAllActions() pair.isHidden true pair.position .zero available.append(pair) } }preload: 6的取值逻辑是这样的屏幕上同屏最多能看到 3 对管道加上过渡区一共 4 对预加载 6 对留了余量正常游戏过程中池子不会空也就不会触发「新建」这条慢路径。recycle里removeAllActions()必须放在最前面。管道靠SKAction做移动回收时如果动作还在跑下一帧它会继续移动这个已经归位的节点表现出来就是新管道突然出现在屏幕中间。3.3 碰撞与计分contact 代理和分数闸门物理掩码一共三组用OptionSet定义比裸数字可读性好太多struct PhysicsCategory { static let none: UInt32 0 static let bird: UInt32 1 0 static let pipe: UInt32 1 1 static let ground: UInt32 1 2 static let score: UInt32 1 3 // 管道缺口里的隐形传感器 }碰撞回调本身很短但位运算的判等方式值得单独讲。常见写法有两种一种是contact.bodyA.categoryBitMask ...逐个判断另一种是先把两个掩码或起来再比对组合后者更不容易漏extension GameScene: SKPhysicsContactDelegate { func didBegin(_ contact: SKPhysicsContact) { let mask contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask if mask (PhysicsCategory.bird | PhysicsCategory.ground) || mask (PhysicsCategory.bird | PhysicsCategory.pipe) { handleGameOver() return } if mask (PhysicsCategory.bird | PhysicsCategory.score) { handleScore(sensor: contact.bodyB.node) } } }注意这里为什么用「或起来再等值比较」而不是if a.contains(b)。因为categoryBitMask是位掩码组合值是唯一的等值比较能一次性排除「鸟碰鸟」这种不该发生的组合。前提是每一类节点的categoryBitMask只能设一个位不能设成多个位的并集——如果源码里某处写了bird | pipe这里就得换写法。计分用了一个隐形传感器节点贴在管道缺口中间isDynamic false、categoryBitMask .score、collisionBitMask .none。它不参与物理只负责在鸟穿过时触发一次回调。这里有个必加的闸门同一根管道只能加一次分。因为didBegin在某些高速穿透的情况下可能被调用多次尤其是鸟速度很快、物理步长固定的时候不做保护会看到分数一下跳两分private var scoredPipes SetObjectIdentifier() private func handleScore(sensor: SKNode?) { guard let node sensor?.parent else { return } let key ObjectIdentifier(node) guard !scoredPipes.contains(key) else { return } scoredPipes.insert(key) score 1 scoreLabel.text \(score) }ObjectIdentifier是拿对象身份而不是值做 key节点复用时不会因为「位置一样」误判成同一根管道。回收管道时记得把 key 从scoredPipes里删掉否则集合会一直涨虽然量级小到无所谓但逻辑上不干净。3.4 状态机与重开ready / playing / gameOver游戏只有三个状态但如果没有显式状态机代码会变成一堆布尔值互相打架enum GameState { case ready // 站在起点等待第一次点击 case playing // 进行中物理生效 case gameOver // 死亡停止一切输入 } private var state: GameState .ready { didSet { onStateChanged(from: oldValue, to: state) } } override func touchesBegan(_ touches: SetUITouch, with event: UIEvent?) { switch state { case .ready: state .playing physicsWorld.gravity CGVector(dx: 0, dy: -9.8) bird.flap() case .playing: bird.flap() case .gameOver: restart() } }onStateChanged里集中做三件事切换重力开关、控制地面和管道的移动动作、显示或隐藏重开按钮。把副作用收在一处比散落在十几个if isGameOver里好维护得多——我见过一份源码用了 7 个不同的布尔值来表达状态改一个地方牵动全身。ready状态时physicsWorld.gravity设为零鸟悬停在空中做上下浮动动画这样视觉上不会有「一开始就往下掉」的突兀感。进入playing才开启重力这是这类游戏的通用处理。4. 跑通实录从打开工程到真机满帧4.1 签名与 Bundle Identifier红叉最多的两个地方双击.xcodeproj打开先看左侧导航栏顶部有没有红色标记。有红的基本上都是签名问题跟代码无关。# 命令行确认一下当前工程的签名配置 xcodebuild -project FlappyBird.xcodeproj \ -scheme FlappyBird \ -showBuildSettings | grep -E PRODUCT_BUNDLE_IDENTIFIER|DEVELOPMENT_TEAM这条命令会打出两个关键值。DEVELOPMENT_TEAM为空说明没选开发者账号在 Xcode 的Signing Capabilities里勾上Automatically manage signing即可。PRODUCT_BUNDLE_IDENTIFIER如果跟别人的账号域名撞了也会报错随手改成com.你的名字.flappybird就行。如果是模拟器运行签名这块可以直接跳过随便选个模拟器点运行应该就能起来。所以第一次跑永远先跑模拟器把签名问题隔离掉再看逻辑对不对。4.2 目标设备与部署版本对齐跑真机之前对三个值!-- project.pbxproj 里能找到的字段对应 Xcode 界面上的三个下拉框 -- IPHONEOS_DEPLOYMENT_TARGET 13.0; !-- 最低系统版本 -- TARGETED_DEVICE_FAMILY 1,2; !-- 1iPhone, 2iPad只做手机就改成 1 -- SUPPORTED_PLATFORMS iphoneos iphonesimulator;IPHONEOS_DEPLOYMENT_TARGET设得太高老设备连装都装不上设得太低又会缺新 API。这个工程用的都是 SpriteKit 的基础接口设在 13.0 就能覆盖绝大部分活跃设备。TARGETED_DEVICE_FAMILY如果是1,2意味着 iPad 也要适配。这类工程一般只在 iPhone 横屏下调过坐标iPad 上大概率布局错位。如果只是自己看改成1省事。4.3 资源加载失败怎么定位资源问题的表现很有迷惑性不崩、不报错就是画面不对。定位方法是在didMove(to:)里加一段断言式检查#if DEBUG override func didMove(to view: SKView) { super.didMove(to: view) let required [bg_day, ground, pipe_up, pipe_down, bird_0] for name in required { let exists SKTexture(imageNamed: name).size() ! .zero assert(exists, 纹理缺失: \(name)检查 Assets.xcassets 里的 imageset 是否完整) } } #endifSKTexture(imageNamed:)在图片缺失时不会返回 nil而是返回一个 1x1 的空白纹理所以不能判空只能判size()。加上#if DEBUG之后线上包不会带这段零成本。跑一次如果命中 assert就去Assets.xcassets里看对应的.imageset目录是不是少了Contents.json——从压缩包解压的时候某些系统会把这个文件当成隐藏文件跳过。这是我在源码包里遇到过最离谱但也最常见的资源问题之一。4.4 用调试 HUD 看帧率和 draw call跑起来之后屏幕上那几行白字别急着关skView.showsFPS true // 帧率目标 60 skView.showsNodeCount true // 当前场景树节点数 skView.showsDrawCount true // 每帧 draw call越少越好 skView.showsPhysics true // 画出物理体轮廓排查碰撞神器showsPhysics是最值钱的一个。物理体看不见碰撞对不对全靠猜打开之后鸟的圆形、管道的矩形、地面的边缘都会描边显示一眼就能看出「为什么没撞上」——八成是物理体比贴图小了一圈或者位置偏了。正常运行时draw call应该稳定在个位数。如果一直在二三十往上多半是图片没进图集每张纹理各占一个批次。nodeCount稳定在两三百是正常的如果每过一根管道就往上跳几十不回落说明管道回收没生效节点在泄漏。5. 踩坑排查五类让新工程直接卡住的问题5.1 编译期与签名类的两种现象一打开工程直接十几个红叉提示No such module Xxx。原因通常不是代码问题而是工程里引用的依赖没下载下来。这类小工程一般不带 CocoaPods 或 SPM 依赖如果看到Podfile说明打包时只给了源码没给Pods目录如果是 SPM 依赖.swiftpm缓存目录也可能被过滤掉了。解决先看根目录有没有Podfile有就执行pod install然后用.xcworkspace打开而不是.xcodeproj。如果报的是Could not resolve package dependencies在 Xcode 的File Packages Reset Package Caches走一遍再Resolve Package Versions。真的没有外部依赖还报红叉就Product Clean Build Folder十有八九是 DerivedData 缓存坏了。现象二模拟器能跑插上真机就提示Unable to install。原因是工程里残留了别人的签名配置DEVELOPMENT_TEAM指向一个当前账号没有权限的 Team ID。解决在Signing Capabilities里把 Team 改成自己的勾上自动管理。如果还不行看Build Settings里的PROVISIONING_PROFILE_SPECIFIER有没有被写死成一个固定的 profile 名有就清空让 Xcode 自己生成。5.2 运行时的渲染、物理与性能问题现象三程序跑起来了但是屏幕全黑或者所有东西挤在左下角一个小区域里。原因是场景尺寸和scaleMode不匹配。常见组合是GameScene(size: CGSize(width: 320, height: 480))配.aspectFill在高分屏上会被放大后再裁切视觉上就是一团糊在角落。解决改成GameScene(size: skView.bounds.size)或者保持固定尺寸但配合.aspectFit并且接受黑边。改完之后检查所有写死的像素值尤其是bird.position.y 200这种统统换成size.height * 比例。现象四鸟直接从管道中间穿过去碰撞回调不触发。原因有三个可能按概率排一是physicsBody没设只是加了个SKSpriteNode二是contactTestBitMask没设或者设成了0物理引擎不会为这个节点产生接触事件三是两边都设了collisionBitMask但contactTestBitMask是 0物理上有反弹但没有回调。解决先打开skView.showsPhysics true如果管道和鸟身上没有描边就是第一个原因有描边但还是穿就打印两边的掩码看交集print(bird:, String(bird.physicsBody!.contactTestBitMask, radix: 2)) print(pipe:, String(pipe.physicsBody!.categoryBitMask, radix: 2))contactTestBitMask和对方的categoryBitMask按位与非零才会触发didBegin。这两个值写反是新手最常犯的错——把「我想检测什么」写成了「我是什么」。现象五分数一次穿管道加了两分或者干脆不加。加两分的原因是同一对管道触发了多次didBegin不加分的原因是传感器节点的isDynamic设成了true或者categoryBitMask没设对。解决加分用SetObjectIdentifier做闸门见 3.3不加分的情况直接在didBegin里print(contact.bodyA.categoryBitMask, contact.bodyB.categoryBitMask)多半是传感器的 category 用了pipe而不是score导致mask bird|score这个等式永远不成立。现象六真机上明显掉帧模拟器上却很流畅。原因是节点数量随时间增长不回落或者纹理没进图集导致 draw call 飙升。解决盯着showsNodeCount玩两分钟如果数字单调递增说明管道回收逻辑没生效检查recycle()是不是真的被调到了。再盯showsDrawCount如果稳定在两位数以上把图片资源挪进.spriteatlas同时确认ignoresSiblingOrder是开着的并且每个节点都设了zPosition。6. 二次开发把固定难度改成可配置的关卡表原工程里管道缺口宽度、移动速度、重力值大概率是直接写死的常量改起来要翻好几处。想让它变成能做玩法试验的骨架第一步是把这些参数抽成数据。我会先定义一个配置结构用 JSON 存好处是不用重编译就能调手感{ gapStart: 160, gapMin: 110, speedStart: 140, speedMax: 210, accelPerScore: 2.5, gravity: -9.8, flapImpulse: 0.28 }struct LevelConfig: Codable { let gapStart: CGFloat // 初始缺口高度pt let gapMin: CGFloat // 缺口收缩下限防止窄到不可能通过 let speedStart: CGFloat // 管道初始移动速度pt/s let speedMax: CGFloat // 速度上限防止后期快到无法反应 let accelPerScore: CGFloat // 每得一分速度增加多少 let gravity: CGFloat let flapImpulse: CGFloat } // 按当前分数算实时参数所有随机都收敛在这个函数里 func tuned(at score: Int, base: LevelConfig) - (gap: CGFloat, speed: CGFloat) { let speed min(base.speedStart CGFloat(score) * base.accelPerScore, base.speedMax) let gap max(base.gapStart - CGFloat(score) * 3, base.gapMin) return (gap, speed) }几个参数的作用和调法gapStart和gapMin决定难度曲线的陡缓想做成新手友好就把gapMin拉高到 130 以上speedMax是难度天花板成年玩家的反应极限大致在 220 pt/s 附近超过这个值再练也没用只会觉得是玄学accelPerScore我用 2.5 是因为它能让第 20 分左右开始明显吃力又不至于前 10 分就劝退。改完之后怎么验证曲线合不合理我一般会连着跑五次把每局死亡时的分数记下来看分布。如果五次全在 8 到 12 分之间曲线太陡如果五次都在 30 分以上说明难度没起来accelPerScore该往上调。除了难度还有两个动手成本低、体感提升明显的方向改造点改哪个文件工作量效果鸟的皮肤切换Bird.swift 资源目录半天视觉差异明显适合做收集玩法音效与震动反馈GameScene 状态回调里加两小时撞击和计分的手感立刻不同排行榜本地存档新增 ScoreStore.swift一天有了重开的动力夜间模式换调色场景初始化时切资源前缀三小时一个变量控制整屏风格最后一个提醒是关于参数改动的验证顺序。我踩过的坑是一次性把重力、冲量、缺口、速度全调了结果游戏彻底不能玩根本不知道是哪一个改坏的。从那以后我每次动参数都强制走一遍「一次只改一个值跑三局记下死亡分数」的流程把这个习惯固化下来之后调手感才真正变成一件有据可依的事而不是靠感觉瞎试。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站