Tappy the clownfish
Menu Theme Made with GarageBand samples

Tappy Color

2016年夏天與朋友Chris Greer一起製作的點擊生存iOS遊戲。在抽屜裡放了八年後於2024年重製。

遊戲

Tappy Color是一款點擊生存遊戲。水位不斷下降,如果降得太低,Tappy就會死。你的目標是盡可能長時間地讓他活著。

Blue raises water
Red warning
Red drops water
Starfish
Invincible mode

藍屏:盡可能快地點擊。每次點擊都會提高水位。

紅屏:停止點擊!紅色時點擊會使水突然下降。這是陷阱。

海星:點擊獲得無敵。無敵時紅色不會出現 - 所以可以自由點擊。

隨著分數增加,顏色變化越來越快。起初是反應遊戲,最終變成衝動控制測試。

我們以「Bhris」的名義註冊了版權 - 我們名字的混成詞。Chris構建了遊戲機制和核心系統。

專案擱置了八年。然後在2024年7月,我決定把它翻出來。

誰做了什麼

Tappy Color是真正的協作。那個夏天Chris和我並肩工作,來回推送提交。

Chris Greer(2016)
遊戲設計與工程
  • 核心遊戲概念(點擊生存機制)
  • 推動創建玩家會在乎的角色
  • Tappy的游泳運動系統
  • 使用tanh()的平滑難度曲線
  • 遊戲狀態機和分數追蹤
  • 顏色轉換和時機機制
  • 手工GIF解碼器用於動畫
Blake Crosley
藝術、音效和2024重製版
  • 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。遷移不僅僅是乏味的 - 它是考古學的。

Migration Example
// 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。

FishImageView.swift — Chris Greer
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中。

AudioManager.swift — Blake Crosley (2024)
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曲線。

ViewController.swift — Chris Greer
// 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保持播放器池並用鎖保護並發存取。

AudioManager.swift — Blake Crosley (2024)
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在動畫期間的真實螢幕位置來解決這個問題。

FishImageView.swift — Chris Greer
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整合使用最大重試次數的指數退避。

GameCenterService.swift — Blake Crosley (2024)
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的遊戲引擎哲學啟發 - 一個主迴圈分派到所有子系統。

ViewController.swift — Single Game Loop
@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次昂貴的浮點運算。現在是單一陣列查詢。

ViewController.swift — Difficulty LUT
// 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。現在它原地修改現有幀。

ViewController.swift — Star Movement
// 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次字串分配浪費了。

ViewController.swift — Dirty Flags
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風格遊戲迴圈。預計算難度曲線。新增髒標記。刪除死碼。

十年,三個版本,同一條魚。

關於音樂和音效
背景音樂使用GarageBand中的採樣創作。所有遊戲音效均使用線上8位元聲音產生器製作。