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에 생명을 불어넣었습니다.
우리의 공통 친구 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년에는 애니메이션 GIF를 위한 훌륭한 Swift 라이브러리가 없어서 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에서 실행됩니다.
내부 구조
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))))
}
스레드 세이프 사운드 이펙트
격렬하게 탭하면 초당 수십 개의 효과음이 트리거될 수 있습니다. 리마스터된 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월. 먼지를 털어내고 모든 것을 고쳤습니다.
싱글 게임 루프
4개의 독립적인 타이머가 하나가 되었습니다. 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 스타일 게임 루프로 통합. 난이도 곡선 사전 계산. 더티 플래그 추가. 데드 코드 제거.
10년, 3개의 버전, 같은 물고기.