Tappy Color
2016年の夏に友人Chris Greerと一緒に作ったタップサバイバルiOSゲーム。8年間引き出しの中にあった後、2024年にリマスター。
ゲーム
Tappy Colorはタップサバイバルゲームです。水位は常に下がり続け、低くなりすぎるとTappyは死にます。できるだけ長く生き延びることが目標です。
青い画面:できるだけ速くタップ。タップするたびに水位が上がります。
赤い画面:タップを止めて!赤いときにタップすると水が急に下がります。これが罠です。
ヒトデ:タップすると無敵になります。無敵中は赤が出ないので自由にタップできます。
スコアが上がるにつれて色の変化が速くなります。最初は反射神経のゲームが、衝動を抑えるテストになります。
「Bhris」という名前で著作権を取得しました。私たちの名前のかばん語です。Chrisがゲームメカニクスとコアシステムを担当。私がデザイン、サウンド、ピクセルアートを担当しました。
プロジェクトは8年間手つかずでした。そして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に命を吹き込みました。音楽と効果音を作曲。UXとチュートリアルフローをデザイン。Chrisが自然に泳ぎ回る動きのシステムを作りました。
共通の友人Dustinは何度もプレイし、彼の子供も大好きでした。彼はもっと完成させてほしいと言っていました。ついに実現できてうれしいです。
300のコンパイラエラー
Xcode 15でプロジェクトを開いたとき、300以上のコンパイラエラーが迎えてくれました。元のコードベースはSwift 2.xで書かれていました - ほとんどの構文がもう存在しないほど古いSwiftのバージョンです。
すべてのNSBundleがBundleに、すべてのNSURLがURLになりました。すべてのCGAffineTransformMake*関数がイニシャライザに置き換えられました。移行は単に退屈なだけでなく、考古学的でした。
// 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へ。
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にすべてを統合しました。
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字曲線を作成しました:最初は緩やか、中盤で急、上限で平坦。難しくても公平に感じます。
// 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はプレーヤーのプールを保持し、ロックで同時アクセスを保護します - オーディオの不具合もクラッシュもなし。
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の真の画面上の位置を取得する方法で解決しました。
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月。埃を払って全部直しました。
シングルゲームループ
4つの独立したタイマーが1つになりました。John Carmackのゲームエンジン哲学に触発され、1つのマスターループがすべてのサブシステムにディスパッチします:
@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回の高価な浮動小数点演算。今は単一の配列検索です:
// 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
ダーティフラグラベル更新
ラベルは値が変わっていなくても毎フレーム更新されていました - 1秒に40回の文字列割り当てが無駄になっていました:
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つのバージョン、同じ魚。