Tappy Color
Gra typu tap-to-survive na iOS stworzona z moim przyjacielem Chrisem Greerem latem 2016 roku. Zremasterowana w 2024 roku po ośmiu latach w szufladzie.
Gra
Tappy Color to gra typu tap-to-survive. Poziom wody stale opada, a jeśli spadnie zbyt nisko, Tappy umiera. Twoim celem jest utrzymanie go przy życiu jak najdłużej.
Niebieski ekran: Stukaj tak szybko, jak możesz. Każde stuknięcie podnosi poziom wody.
Czerwony ekran: Przestań stukać! Stukanie podczas czerwonego gwałtownie obniża poziom wody. To pułapka.
Rozgwiazda: Stuknij ją, aby uzyskać nietykalność. Podczas nietykalności czerwony się nie pojawia - więc stukaj swobodnie.
Zmiany kolorów przyspieszają wraz ze wzrostem wyniku. To, co zaczyna się jako gra na refleks, staje się testem kontroli impulsów.
Zarejestrowaliśmy prawa autorskie pod nazwą „Bhris" - zbitką naszych imion. Chris zbudował mechanikę gry i podstawowe systemy. Ja zajmowałem się projektem, dźwiękami i pixel artem. Wydaliśmy ją, uzyskaliśmy kilka pobrań od przyjaciół i rodziny, a potem życie potoczyło się dalej.
Projekt leżał nietkniętym przez osiem lat. Potem w lipcu 2024 roku postanowiłem go odkurzyć.
Kto co zbudował
Tappy Color to prawdziwa współpraca. Chris i ja pracowaliśmy ramię w ramię, wymieniając się commitami przez całe tamto lato.
- Główna koncepcja gry (mechanika tap-to-survive)
- Dążył do stworzenia postaci, na której graczom będzie zależeć
- System ruchu pływania Tappy'ego
- Krzywa trudności wykorzystująca tanh() dla płynnego wzrostu
- Maszyna stanów gry i śledzenie wyniku
- Przejścia kolorów i mechanika czasowa
- Własnoręcznie napisany dekoder GIF dla animacji
- Pixel art i animacje Tappy'ego (2016)
- Kompozycja ścieżki dźwiękowej (2016)
- 8-bitowe efekty dźwiękowe (2016)
- Ekrany samouczka i pomocy (2016)
- Migracja ze Swift 2 do Swift 5 (2024)
- Nowoczesny singleton AudioManager (2024)
- Integracja z Game Center (2024)
- Modernizacja do iOS 16.4+ (2024)
Krótka uwaga o „projektowaniu gier": w branży gier oznacza to wymyślanie głównej idei tego, co sprawia, że gra jest fajna. „Twórca gier" oznacza rozwijanie tego pomysłu i doprowadzenie wszystkiego do działania. Nie ma to nic wspólnego z projektowaniem wizualnym.
Pierwsza wersja to był po prostu ekran, który robił się zielony po dotknięciu i czerwony przy porażce. Chris nalegał: „Nie, musimy stworzyć coś, na czym ludziom będzie zależeć, jak postać. Nikogo nie obchodzi ten ekran." Wtedy myślałem, że nie wydajemy wystarczająco szybko.
Ale miał rację. Włożyłem serce w pixel art, ożywiając Tappy'ego klatka po klatce. Skomponowałem muzykę i efekty dźwiękowe. Zaprojektowałem UX i przepływ samouczka. Chris zbudował system ruchu, który sprawia, że Tappy pływa tak naturalnie. Razem to właśnie uczyniło grę fajną.
Nasz wspólny przyjaciel Dustin grał w nią wielokrotnie, a jego dziecko też ją uwielbiało. Chciał, żebyśmy włożyli w nią więcej pracy i naprawdę ją ukończyli. Cieszę się, że w końcu to zrobiliśmy.
300 błędów kompilatora
Kiedy otworzyłem projekt w Xcode 15, przywitało mnie ponad 300 błędów kompilatora. Oryginalna baza kodu została napisana w Swift 2.x - wersji Swift tak starej, że większość jej składni już nie istnieje.
Każdy NSBundle stał się Bundle. Każdy NSURL stał się URL. Każda funkcja CGAffineTransformMake* została zastąpiona inicjalizatorami. Migracja nie była tylko żmudna - była archeologiczna.
// 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)
Animacja pływania Tappy'ego
Jedna z rzeczy, z których wciąż jesteśmy dumni: Tappy naprawdę pływa po ekranie. Wybiera losowy cel, płynnie animuje się do tego punktu przez 3-6 sekund, a następnie wybiera nowy cel. Nawet obraca się poziomo przy zmianie kierunku.
Chris napisał oryginalną logikę ruchu używając Core Animation. Rybka na tej stronie wykorzystuje to samo podejście, przeniesione do JavaScript. Kliknij ją, aby odwiedzić 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
}
Własnoręcznie wykonany dekoder GIF
W 2016 roku nie było świetnej biblioteki Swift do animowanych GIF-ów, więc Chris napisał własną używając ImageIO i Core Graphics. Parsuje GIF klatka po klatce, wyodrębnia dane czasowe z metadanych, znajduje największy wspólny dzielnik dla optymalnego odtwarzania i zwraca prawidłową animację UIImage.
Działało w Swift 2 i po drobnych aktualizacjach składni podczas remastera, działa do dziś.
Remaster 2024
Po migracji składni Swift, zająłem się prawdziwymi ulepszeniami.
Nowoczesny system audio
Oryginalny kod miał instancje odtwarzacza audio porozrzucane wszędzie. Skonsolidowałem wszystko w singleton AudioManager z prawidłową obsługą AVAudioSession.
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()
}
}
}
Integracja z Game Center
Oryginalna gra nie miała tablic wyników. Remaster ma pełne wsparcie Game Center z globalnymi rankingami.
Docelowy iOS 16.4+
Koniec ze wspieraniem starych urządzeń. Remaster działa na nowoczesnym iOS ze wszystkimi najnowszymi API.
Pod maską
Niektóre wzorce kodu, które wciąż się sprawdzają osiem lat później.
Płynna krzywa trudności
Woda spływa szybciej wraz ze wzrostem wyniku, ale trudność nie rośnie po prostu liniowo - to byłoby karzące. Zamiast tego Chris użył funkcji tangensa hiperbolicznego, aby stworzyć płynną krzywą S: łagodną na początku, stromą w środku, a następnie wyrównującą się przy suficie. Wydaje się sprawiedliwa nawet gdy jest trudna.
// 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))))
}
Bezpieczne wątkowo efekty dźwiękowe
Gdy tapasz wściekle, dziesiątki efektów dźwiękowych mogą się uruchomić w ciągu sekundy. Zremasterowany AudioManager utrzymuje pulę odtwarzaczy i chroni równoczesny dostęp za pomocą blokady - bez glitchy audio, bez crashy.
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()
}
Animacja warstwy prezentacji
Core Animation jest podchwytliwe: gdy animujesz pozycję warstwy, właściwość position natychmiast przeskakuje do końcowej wartości - tylko reprezentacja wizualna się animuje. Chris rozwiązał to odczytując z warstwy prezentacji, aby uzyskać prawdziwą pozycję Tappy'ego na ekranie podczas animacji.
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 z logiką ponawiania
Żądania sieciowe zawodzą. Zremasterowana integracja z Game Center używa wykładniczego wycofywania z maksymalną liczbą prób - wyniki ostatecznie się synchronizują nawet przy niestabilnym połączeniu.
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))
}
}
}
Otworzyłem to ponownie
Styczeń 2026. Odkurzyłem to i naprawiłem wszystko.
Pojedyncza pętla gry
Cztery niezależne timery stały się jednym. Zainspirowane filozofią silnika gier Johna Carmacka - jedna główna pętla rozsyła do wszystkich podsystemów:
@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()
}
Wstępnie obliczona krzywa trudności
Krzywa trudności używała tanh() przy każdym ticku - kosztowne obliczenia zmiennoprzecinkowe 20 razy na sekundę. Teraz to pojedyncze wyszukiwanie w tablicy:
// 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]
Unikanie alokacji CGRect
Ruch gwiazdy tworzył nowy CGRect w każdej klatce. Teraz modyfikuje istniejącą ramkę w miejscu:
// 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
Aktualizacje etykiet z flagą dirty
Etykiety aktualizowały się przy każdym ticku, nawet gdy wartości się nie zmieniły - 40 alokacji stringów na sekundę na darmo:
private var lastDisplayedScore: Int = -1
func updateLabels() {
if game.score != lastDisplayedScore {
scoreLabel.text = "\(game.score)"
lastDisplayedScore = game.score
}
}
Ekstrakcja GameModel
Cały zmienny stan gry znajduje się teraz w dedykowanej klasie GameModel. ViewController obsługuje UI; model obsługuje logikę. Wynik, życie rundy, stan karty, liczniki nietykalności - wszystko wyekstrahowane i testowalne. Magiczne liczby udokumentowane w GameConstants.swift. Force unwrap zastąpione bezpiecznym rozpakowywaniem. Martwy kod usunięty.
Nowa wersja wkrótce w App Store.
Oś czasu
Sierpień 2016 - Chris i ja tworzymy Tappy Color w Swift 2. Dwóch studentów, jedna pikselowa ryba-błazen, wysłana do App Store.
2017-2023 - Aplikacja stoi nietknięta. Swift ewoluuje. Baza kodu staje się archeologiczna.
Sierpień 2024 - Otwieram Xcode 15 z ponad 300 błędami kompilatora. Migruję wszystko do Swift 5. Odświeżam UI. Wysyłam Tappy Color Remastered do App Store.
Styczeń 2026 - Otwieram ponownie. Ekstraktuję GameModel. Konsoliduję cztery timery w jedną pętlę gry w stylu Carmacka. Prekomputuję krzywą trudności. Dodaję flagi dirty. Usuwam martwy kod. Naprawiam każdy problem, który obiecałem naprawić.
Dziesięć lat, trzy wersje, ta sama ryba.