Tappy Color
2016年夏天与朋友Chris Greer一起制作的点击生存iOS游戏。在抽屉里放了八年后于2024年重制。
游戏
Tappy Color是一款点击生存游戏。水位不断下降,如果降得太低,Tappy就会死。你的目标是尽可能长时间地让他活着。
蓝屏:尽可能快地点击。每次点击都会提高水位。
红屏:停止点击!红色时点击会使水突然下降。这是陷阱。
海星:点击获得无敌。无敌时红色不会出现 - 所以可以自由点击。
随着分数增加,颜色变化越来越快。起初是反应游戏,最终变成冲动控制测试。
我们以"Bhris"的名义注册了版权 - 我们名字的混成词。Chris构建了游戏机制和核心系统。
项目搁置了八年。然后在2024年7月,我决定把它翻出来。
谁做了什么
Tappy Color是真正的协作。那个夏天Chris和我并肩工作,来回推送提交。
- 核心游戏概念(点击生存机制)
- 推动创建玩家会在乎的角色
- Tappy的游泳运动系统
- 使用tanh()的平滑难度曲线
- 游戏状态机和分数追踪
- 颜色转换和时机机制
- 手工GIF解码器用于动画
- Tappy像素艺术和动画(2016)
- 配乐创作(2016)
- 8位音效(2016)
- 教程和帮助界面(2016)
- Swift 2到Swift 5迁移(2024)
- 现代AudioManager单例(2024)
- Game Center集成(2024)
- iOS 16.4+现代化(2024)
关于"游戏设计"的说明:在游戏开发中,它意味着想出使游戏有趣的核心创意。
第一个版本只是一个点击变绿、失败变红的屏幕。Chris一直说:"不,我们需要做一些人们会在乎的东西,比如一个角色。"
但他是对的。我投入像素艺术,一帧一帧地给Tappy注入生命。
我们共同的朋友Dustin反复玩,他的孩子也很喜欢。
300个编译器错误
在Xcode 15中打开项目时,迎接我的是300多个编译器错误。原始代码库是用Swift 2.x编写的。
每个NSBundle变成了Bundle。每个NSURL变成了URL。迁移不仅仅是乏味的 - 它是考古学的。
// Swift 2.x (2016) - What we wrote
let imageData = NSData(contentsOfURL: NSBundle.mainBundle()
.URLForResource("tappy", withExtension: "gif")!)
self.transform = CGAffineTransformMakeScale(-1.0, 1.0)
// Swift 5.0 (2024) - What it needed to become
let imageData = NSData(contentsOf: Bundle.main
.url(forResource: "tappy", withExtension: "gif")!)
self.transform = CGAffineTransform(scaleX: -1.0, y: 1.0)
Tappy的游泳动画
我们仍然引以为豪的事情之一:Tappy实际上在屏幕上游来游去。
Chris使用Core Animation编写了原始运动逻辑。这个页面上的鱼使用相同的方法,移植到JavaScript。
func startFishMove() {
let fromPoint = self.layer.position
self.destination = acceptablePoint()
let movement = CABasicAnimation(keyPath: "position")
movement.fromValue = NSValue(cgPoint: fromPoint)
movement.toValue = NSValue(cgPoint: self.destination)
movement.duration = 3.0 + Double(arc4random_uniform(3))
movement.delegate = self
self.layer.add(movement, forKey: "fishmove")
self.state = .ChilaxedMove
self.layer.position = self.destination
}
手工GIF解码器
2016年没有很好的Swift动画GIF库,所以Chris用ImageIO和Core Graphics自己写了一个。
它在Swift 2中工作,经过重制期间的少量语法更新,今天仍然有效。
2024重制版
迁移Swift语法后,我着手进行实际改进。
现代音频系统
原始代码中音频播放器实例散布各处。我将所有内容整合到一个具有适当AVAudioSession处理的单例AudioManager中。
class AudioManager {
static let shared = AudioManager()
private var backgroundMusicPlayer: AVAudioPlayer?
private var soundEffectPlayers: [AVAudioPlayer] = []
func playSound(named filename: String) {
guard !isMuted else { return }
guard let url = Bundle.main.url(forResource: filename,
withExtension: "wav") else { return }
let player = try? AVAudioPlayer(contentsOf: url)
player?.play()
// Auto-cleanup after playback
soundEffectPlayers.append(player!)
DispatchQueue.main.asyncAfter(deadline: .now() + player!.duration) {
self.cleanUpFinishedPlayers()
}
}
}
Game Center集成
原版游戏没有排行榜。重制版具有完整的Game Center支持和全球排名。
iOS 16.4+目标
不再支持旧设备。重制版在具有所有最新API的现代iOS上运行。
内部机制
八年后仍然有效的一些代码模式。
平滑难度曲线
随着分数增加,水排得更快,但难度不是线性上升的。Chris使用双曲正切函数创建了平滑的S曲线。
// Difficulty ramps using tanh() for a smooth S-curve
// Starts gentle, gets steep, then plateaus at ceiling
if counter <= 140 {
// Early game: 150 → 370 drain rate
self.currentRate = Int(150.0 + (220.0 * tanh(0.03 * (Float(counter) / 20.0))))
} else {
// Late game: approaches ceiling asymptotically
self.currentRate = Int(195.0 + (ceilingRate * tanh(rateAttack * (Double(counter - 140) / 20.0))))
}
线程安全音效
当你疯狂点击时,每秒可能触发数十个音效。重制版AudioManager保持播放器池并用锁保护并发访问。
private let soundEffectPlayersLock = NSLock()
private var soundEffectPlayers: [AVAudioPlayer] = []
func playSound(named filename: String) {
guard !isMuted, let url = Bundle.main.url(forResource: filename,
withExtension: "wav"),
let player = try? AVAudioPlayer(contentsOf: url) else { return }
// Thread-safe: lock before modifying shared array
soundEffectPlayersLock.lock()
soundEffectPlayers.append(player)
soundEffectPlayersLock.unlock()
player.play()
}
private func cleanUpFinishedPlayers() {
soundEffectPlayersLock.lock()
soundEffectPlayers.removeAll { !$0.isPlaying }
soundEffectPlayersLock.unlock()
}
表示层动画
Core Animation很棘手。Chris通过从表示层读取来获取Tappy在动画期间的真实屏幕位置来解决这个问题。
func getActualPosition() -> CGPoint {
// During animation, layer.position is already the END value
// The presentation layer gives us where it ACTUALLY is on screen
if self.layer.animation(forKey: "fishmove") != nil {
if let presentationLayer = self.layer.presentation() {
return presentationLayer.position
}
}
return self.layer.position
}
带重试逻辑的Game Center
网络请求会失败。重制版Game Center集成使用最大重试次数的指数退避。
enum GameCenterError: Error {
case notAuthenticated, leaderboardNotFound
case networkError, retryLimitExceeded
}
private func loadLeaderboardWithRetry(
retryCount: Int = 0,
completion: @escaping (Result<GKLeaderboard, GameCenterError>) -> Void
) {
GKLeaderboard.loadLeaderboards { leaderboards, error in
if let leaderboard = leaderboards?.first {
completion(.success(leaderboard))
} else if error != nil, retryCount < self.maxRetries {
// Exponential backoff: wait longer each retry
DispatchQueue.main.asyncAfter(deadline: .now() + self.retryDelay) {
self.loadLeaderboardWithRetry(
retryCount: retryCount + 1,
completion: completion
)
}
} else {
completion(.failure(.retryLimitExceeded))
}
}
}
我又打开了它
2026年1月。擦去灰尘,修复一切。
单一游戏循环
四个独立的计时器变成了一个。受John Carmack的游戏引擎哲学启发 - 一个主循环分派到所有子系统。
@objc func timerAction() {
game.tickCounter += 1
// Difficulty from pre-computed lookup table
game.currentRate = difficultyLUT[min(game.tickCounter, 1199)]
// Flag-driven subsystems
if isInvincibleBlinkActive { updateInvincibleBlink() }
if isStarMovementActive { updateStarMovement() }
// Dirty-flag updates
updateLabels()
fishView.update()
}
预计算难度曲线
难度曲线每帧都使用tanh() - 每秒20次昂贵的浮点运算。现在是单一数组查找。
// Setup: compute once at game start
private var difficultyLUT: [Int] = []
func precomputeDifficultyTable() {
difficultyLUT.reserveCapacity(1200)
for tick in 0..<1200 {
let normalized = tanh(Double(tick) / 1000.0)
difficultyLUT.append(Int(normalized * 20) + baseRate)
}
}
// Game loop: O(1) lookup instead of tanh()
game.currentRate = difficultyLUT[game.tickCounter]
CGRect分配避免
星星移动每帧都创建一个新的CGRect。现在它原地修改现有帧。
// Before: new CGRect every tick
starButton.frame = CGRect(x: newX, y: newY, width: w, height: h)
// After: modify origin directly
var starFrame = starButton.frame
starFrame.origin.y -= cachedStarMoveAmount
starFrame.origin.x = CGFloat(game.starActiveX) + xphase
starButton.frame = starFrame
脏标记标签更新
标签每帧都在更新,即使值没有变化 - 每秒40次字符串分配浪费了。
private var lastDisplayedScore: Int = -1
func updateLabels() {
if game.score != lastDisplayedScore {
scoreLabel.text = "\(game.score)"
lastDisplayedScore = game.score
}
}
GameModel提取
所有可变游戏状态现在都在专用的GameModel类中。ViewController处理UI;模型处理逻辑。
新版本即将登陆App Store。
时间线
2016年8月 - Chris和我用Swift 2构建了Tappy Color。两个大学生,一条像素化的小丑鱼,发布到App Store。
2017-2023 - 应用闲置未动。Swift不断发展。代码库变得考古学化。
2024年8月 - 打开Xcode 15看到300多个编译器错误。将一切迁移到Swift 5。重制UI。将Tappy Color Remastered发布到App Store。
2026年1月 - 再次打开。提取GameModel。将4个计时器合并为1个Carmack风格游戏循环。预计算难度曲线。添加脏标记。删除死代码。
十年,三个版本,同一条鱼。