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位声音生成器制作。