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風格遊戲迴圈。預計算難度曲線。新增髒標記。刪除死碼。
十年,三個版本,同一條魚。