Tappy the clownfish
Menu Theme Made with GarageBand samples

Tappy Color

2016年の夏に友人Chris Greerと一緒に作ったタップサバイバルiOSゲーム。8年間引き出しの中にあった後、2024年にリマスター。

ゲーム

Tappy Colorはタップサバイバルゲームです。水位は常に下がり続け、低くなりすぎるとTappyは死にます。できるだけ長く生き延びることが目標です。

Blue raises water
Red warning
Red drops water
Starfish
Invincible mode

青い画面:できるだけ速くタップ。タップするたびに水位が上がります。

赤い画面:タップを止めて!赤いときにタップすると水が急に下がります。これが罠です。

ヒトデ:タップすると無敵になります。無敵中は赤が出ないので自由にタップできます。

スコアが上がるにつれて色の変化が速くなります。最初は反射神経のゲームが、衝動を抑えるテストになります。

「Bhris」という名前で著作権を取得しました。私たちの名前のかばん語です。Chrisがゲームメカニクスとコアシステムを担当。私がデザイン、サウンド、ピクセルアートを担当しました。

プロジェクトは8年間手つかずでした。そして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に命を吹き込みました。音楽と効果音を作曲。UXとチュートリアルフローをデザイン。Chrisが自然に泳ぎ回る動きのシステムを作りました。

共通の友人Dustinは何度もプレイし、彼の子供も大好きでした。彼はもっと完成させてほしいと言っていました。ついに実現できてうれしいです。

300のコンパイラエラー

Xcode 15でプロジェクトを開いたとき、300以上のコンパイラエラーが迎えてくれました。元のコードベースはSwift 2.xで書かれていました - ほとんどの構文がもう存在しないほど古いSwiftのバージョンです。

すべてのNSBundleがBundleに、すべてのNSURLがURLになりました。すべてのCGAffineTransformMake*関数がイニシャライザに置き換えられました。移行は単に退屈なだけでなく、考古学的でした。

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は実際に画面を泳ぎ回ります。ランダムな目的地を選び、3-6秒かけてそのポイントにスムーズにアニメーションし、新しい目的地を選びます。方向を変えるときは水平に反転します。

ChrisはCore Animationを使用してオリジナルの動作ロジックを書きました。このページの魚は同じアプローチをJavaScriptに移植しています。クリックするとApp Storeへ。

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を使って自分で作りました。GIFをフレームごとに解析し、メタデータからタイミングデータを抽出し、最適な再生のための最大公約数を見つけ、適切なUIImageアニメーションを返します。

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で動作します。

裏側

8年後も通用するコードパターン。

滑らかな難易度曲線

スコアが上がるにつれて水の排出が速くなりますが、難易度は線形には上がりません。代わりに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))))
}

スレッドセーフサウンドエフェクト

激しくタップすると、1秒間に数十の効果音がトリガーされます。リマスターされた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は難しいです:レイヤーの位置をアニメーションすると、positionプロパティはすぐに最終値にジャンプし、視覚的な表現だけがアニメーションします。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月。埃を払って全部直しました。

シングルゲームループ

4つの独立したタイマーが1つになりました。John Carmackのゲームエンジン哲学に触発され、1つのマスターループがすべてのサブシステムにディスパッチします:

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()を使用していました - 1秒に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

ダーティフラグラベル更新

ラベルは値が変わっていなくても毎フレーム更新されていました - 1秒に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を処理し、モデルはロジックを処理します。スコア、ラウンドライフ、カード状態、無敵カウンター - すべて抽出してテスト可能に。マジックナンバーはGameConstants.swiftに文書化。強制アンラップは安全なアンラップに置き換え。デッドコード削除。

新バージョンは間もなくApp Storeに登場します。

タイムライン

2016年8月 - ChrisとSwift 2でTappy Colorを構築。2人の大学生、1匹のピクセル化されたクマノミ、App Storeに出荷。

2017-2023 - アプリは手つかずのまま。Swiftは進化。コードベースは考古学的に。

2024年8月 - Xcode 15を開くと300以上のコンパイラエラー。すべてをSwift 5に移行。UIをリマスター。Tappy Color RemasteredをApp Storeに出荷。

2026年1月 - また開いた。GameModelを抽出。4つのタイマーを1つのCarmackスタイルゲームループに統合。難易度曲線を事前計算。ダーティフラグを追加。デッドコードを削除。言っていた問題をすべて修正。

10年、3つのバージョン、同じ魚。

音楽とサウンドについて
BGMはGarageBandでサンプルを使用して作曲しました。すべてのゲーム効果音はオンラインの8ビットサウンドジェネレーターで作成しました。コーナーのプレーヤーでゲームのトラックを切り替えられます。