Tappy the clownfish
Menu Theme Made with GarageBand samples

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.

Blue raises water
Red warning
Red drops water
Starfish
Invincible mode

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.

Chris Greer (2016)
Game Design i programowanie
  • 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
Blake Crosley
Grafika, dźwięk i remaster 2024
  • 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.

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)

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.

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
}

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.

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()
        }
    }
}

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.

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))))
}

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.

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()
}

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.

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 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.

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))
        }
    }
}

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:

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()
}

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:

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]

Unikanie alokacji CGRect

Ruch gwiazdy tworzył nowy CGRect w każdej klatce. Teraz modyfikuje istniejącą ramkę w miejscu:

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

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:

ViewController.swift — Dirty Flags
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.

O muzyce i dźwiękach
Muzyka w tle została skomponowana przy użyciu sampli w GarageBand. Wszystkie efekty dźwiękowe gry zostały stworzone przy użyciu generatorów dźwięków 8-bitowych online. Użyj odtwarzacza w rogu, aby przełączać się między utworami z gry.