Bab 7: Kode Aplikasi "World" (Renderer 3D)

Repo aset yang kita kerjakan (fish + environment + rod) bukan aplikasi itu sendiri β€” dia cuma menghasilkan file .usdz. Aplikasi yang benar-benar merender semua aset itu jadi dunia 3D game mancing bernama "World", letaknya di repo terpisah ~/repos/c4/CtSPoC, folder World/World/. World jalan di iPad/Mac, pakai framework RealityKit dari Apple untuk render danau, dermaga, dan ikan. Ada juga aplikasi pendamping bernama "Rod" (jalan di iPhone, jadi alat kontrol gerak) yang terhubung ke World lewat Bluetooth Low Energy (BLE) β€” tapi Rod tidak dibahas di bab ini.

Sebelum masuk ke detail, ada beberapa istilah yang dipakai terus-menerus di bab ini:

  • Entity: istilah RealityKit untuk "benda apa pun di scene 3D" β€” bisa ikan, joran, air, lampu, atau titik jangkar yang tidak kelihatan.
  • .usdz: format file 3D yang dipakai untuk semua aset (dermaga, ikan, joran, pohon, dll), dihasilkan dari repo Blender terpisah (repo kita).
  • @MainActor: anotasi Swift yang artinya "kode ini wajib jalan di thread utama/UI." Dipakai di banyak tempat karena RealityKit, SwiftUI, dan callback Bluetooth semua butuh aman terhadap thread UI.
  • @Published: penanda properti yang membuat tampilan SwiftUI otomatis redraw setiap kali nilainya berubah.
  • ADR ("Architecture Decision Record"): catatan keputusan desain bernomor yang sering disebut di komentar kode (mis. "ADR-039"). Ini bukan file di aplikasi ini, tapi log keputusan terpisah yang dipegang tim β€” nomornya cuma referensi historis kenapa sebuah angka/pendekatan dipilih.

1. WorldApp.swift

Ini titik masuk paling awal aplikasi β€” potongan kode kecil yang memberitahu sistem operasi "kalau aplikasi ini dibuka, tampilkan jendela ini isinya tampilan ini." Setiap aplikasi Swift/SwiftUI butuh persis satu file seperti ini.

Tipe yang didefinisikan: WorldApp (struct, mengikuti protokol App) β€” aplikasinya sendiri. Ditandai @main, sinyal ke compiler "mulai eksekusi dari sini."

Fungsi-fungsi penting: var body: some Scene β€” mengembalikan sebuah WindowGroup yang isinya ContentView(). Cuma ini yang dilakukan file ini: buka satu jendela yang menampilkan ContentView. Dipanggil otomatis oleh OS saat aplikasi dibuka, tidak ada kode lain yang memanggilnya langsung.

Terhubung ke: Membuat instance ContentView (file berikutnya). Tidak ada file lain yang bergantung ke file ini β€” dia adalah akar, bukan layanan yang dipanggil kode lain.

Catatan menarik: Tidak ada yang aneh, ini boilerplate SwiftUI standar, cuma 17 baris.

2. ContentView.swift

Ini satu-satunya tampilan SwiftUI paling atas untuk seluruh aplikasi. Dia menyusun tampilan 3D RealityKit ditambah semua overlay 2D (menu, HUD, cutscene, kartu hasil, banner naik level) yang bisa muncul di atasnya, dan menentukan overlay mana yang tampil berdasarkan state game saat ini.

Tipe yang didefinisikan: ContentView (struct, View SwiftUI) β€” satu-satunya tampilan paling atas.

Fungsi-fungsi penting:

  • init() β€” membangun sebuah WorldViewModel (dengan memasukkan TransportHost yang baru dibuat, objek koneksi Bluetooth) lalu menghubungkannya ke dua property wrapper SwiftUI: @StateObject untuk view model itu sendiri, dan @ObservedObject untuk viewModel.fishFight (pengontrol perkelahian ikan) supaya tampilan juga redraw ketika state perkelahian berubah, tidak cuma saat state view-model utama berubah.
  • var body: some View β€” sebuah ZStack (menumpuk semua layer) berisi: content utama (scene 3D + HUD), lalu secara kondisional: OnboardingView (tutorial pertama kali main), MainMenuView, overlay kalibrasi ulang, CatchCutsceneView, ResultView, dan LevelUpBannerView. Masing-masing hanya muncul kalau kondisi viewModel.screen/viewModel.hookPhase/dll terpenuhi, jadi cuma overlay yang relevan yang tampil bersamaan.
  • private var recalibrationOverlay: some View β€” latar hitam redup plus kartu CalibrationGuidanceView di tengah. Ditampilkan di tengah sesi (bukan cuma sebelum main pertama kali) kapan pun hook sedang idle dan joran belum dikalibrasi β€” ditambahkan sesuai "ADR-064" supaya pemain bisa mengulang walkthrough kalibrasi kapan saja saat idle, tidak cuma dari Main Menu.
  • private var content: some View β€” layar gameplay sebenarnya: sebuah RealityView (jembatan SwiftUI-nya RealityKit) yang saat make mengatur mode kamera ke .virtual (3D sintetis penuh, tidak ada kamera asli β€” mengonfirmasi ini BUKAN aplikasi AR), memanggil viewModel.makeSceneEntity() untuk membangun seluruh scene 3D secara asinkron lalu menambahkannya, kemudian memanggil viewModel.attachFrameLoop(to:) untuk memulai tick simulasi per-frame. Di bawah tampilan 3D ada penghitung FPS, label status koneksi, sebuah GroupBox debug (dikomentari β€” "Debug purpose only. Can ignore"), LineTensionView (meteran tegangan senar saat perkelahian), sebuah GroupBox ringkasan jumlah tangkapan/level/hasil terakhir, CastMeterView (bar kekuatan lemparan), dan label error kalau lastError terisi. Seluruh "Group" elemen HUD ini memudar saat cutscene tangkapan berlangsung supaya tidak bersaing secara visual dengan momen "CATCH!". .onAppear { viewModel.start() } / .onDisappear { viewModel.stop() } menyalakan/mematikan koneksi Bluetooth dan audio.
  • private var fpsIndicator: some View β€” badge teks FPS kecil berwarna (hijau/kuning/merah sesuai ambang batas).
  • private func row(_ title:_ value:) -> some View β€” helper debug, satu baris label+nilai dalam Grid (cuma dipakai blok debug yang dikomentari).
  • private func format(_ value: Double) -> String / format(_ value: Float) -> String / format(_ value: Float?, suffix:) -> String β€” formatter angka jadi string sederhana (3 desimal), dipakai blok debug.
  • private func formatDirection(_ state: RodState) -> String β€” memformat vektor arah mentah joran jadi "x, y, z", atau "--" kalau ada komponen yang kosong. Debug saja.
  • private func formatXZ(_ point: SIMD3<Float>?) -> String β€” memformat X/Z dari titik 3D jadi "(x, z)". Debug saja, dipakai diagnostik posisi jatuh lemparan.
  • private func formatDegrees(_ degrees: Float?) -> String β€” memformat sudut derajat dengan akhiran Β°. Debug saja.

Terhubung ke: Membuat WorldViewModel dan TransportHost. Menampilkan OnboardingView, MainMenuView, CalibrationGuidanceView, CatchCutsceneView, ResultView, LevelUpBannerView, LineTensionView, CastMeterView β€” jadi ini pusat penyambung semua file View lain berdasarkan state WorldViewModel. Mengimpor RealityKit dan paket Shared (untuk RodState).

Catatan menarik: Blok GroupBox debug besar (Controller State, Cast Direction Debug) seluruhnya dikomentari dengan komentar eksplisit "Debug purpose only. Can ignore" β€” dibiarkan di file untuk dinyalakan lagi nanti, bukan dihapus. Komentar di atas recalibrationOverlay mengutip "ADR-064" sebagai alasan kenapa kalibrasi ulang sekarang boleh di tengah sesi, tidak cuma pra-game.

3. Audio/FishingSounds.swift

Ini "router" suara yang paham logika game. Dia menerjemahkan event mancing tingkat tinggi dan perubahan fase jadi panggilan pemutaran suara nyata ke SoundManager. Dia sengaja tidak pernah menyentuh transport Bluetooth β€” WorldViewModel adalah satu-satunya tempat yang memutar suara SEKALIGUS mengirim event yang sama ke Rod, dari satu titik panggilan yang sama, "supaya keduanya tidak bisa jadi tidak sinkron."

Tipe yang didefinisikan:

  • FishingSounds (final class, @MainActor) β€” router suara itu sendiri.
  • FishingSounds.Config (struct) β€” satu setting: ambiencePlaysInMenu: Bool = true (suara latar danau tetap main walau di menu utama, tidak cuma saat gameplay, sesuai keputusan langsung pengguna "ADR-040").

Fungsi-fungsi penting:

  • init(sounds: SoundManager, config: Config = Config()) β€” menyimpan referensi saja, tidak ada setup lain.
  • func handle(_ event: FishingEvent) β€” switch utama untuk semua event, mirip persis handler haptic di aplikasi Rod. Untuk tiap kasus FishingEvent: .hookSplash memutar suara cebur; .fishInterested memulai loop suara "tertarik" yang halus; .fishBite menghentikan loop itu dan memutar suara gigitan; .fishEscaped/.lineBreak keduanya memakai ulang suara cebur (belum ada aset khusus) dan menghentikan loop tertarik/reel; .catchFish menghentikan loop reel lalu memutar suara cebur-tangkap dan suara "ikan tertangkap" sekaligus; event pita tegangan (.reelTensionLow/Medium/High) tidak melakukan apa-apa di sini (haptic saja, di sisi Rod) β€” .cast/.reelTick juga tidak melakukan apa-apa karena World memang tidak pernah mengeluarkan dua event itu (keduanya khusus lokal di Rod).
  • func castLaunched() β€” memutar suara lempar. Dipanggil langsung oleh WorldViewModel.launchHook(solution:) karena World tidak pernah mengeluarkan event .cast yang resmi.
  • func uiClick() β€” memutar suara klik UI (dipakai misalnya saat menutup banner naik level).
  • func updateReelLoop(active: Bool) β€” menyalakan/mematikan loop suara "reel". Dipanggil setiap tick (bukan cuma saat event berubah) dengan status apakah pemain sedang aktif reel di fase yang mengizinkannya β€” dengan begini walaupun sedang menarik "kosong" (belum ada ikan, .hookInWater) tetap dapat suara reel, sesuatu yang akan terlewat kalau desainnya cuma berbasis event.
  • func updateIdleLoop(active: Bool) β€” menyalakan/mematikan loop ambient "menunggu" selagi kail belum ada ikan (.idle/.hookInWater), berlapis di bawah suara latar danau. Juga digerakkan per frame dengan alasan yang sama.
  • func updateReelFightLoop(active: Bool) β€” menyalakan/mematikan loop khusus "reel fight" selagi ikan benar-benar terkait (.fishOnHook/.reeling). Berbeda dari yang lain, menghentikan loop ini melakukan fade volume turun perlahan selama reelFightFadeDuration (1.5 detik), bukan langsung dipotong β€” "matikan perlahan music reel fight."
  • func handlePhaseChange(_ phase: HookPhase) β€” jaring pengaman yang dipanggil setiap kali HookPhase berubah (dari WorldViewModel.setHookPhase) yang memaksa berhenti loop mana pun yang secara logika seharusnya tidak lagi main untuk fase baru β€” dijelaskan sebagai "belt-and-suspenders," menjamin tidak ada loop nyasar yang bertahan setelah perubahan fase apa pun penyebabnya (lempar ulang, tangkap, kabur, disconnect fail-safe, dll). Ini pakai stop instan (tanpa fade), karena ini jalur reset mendadak, bukan kasus akhir perkelahian normal (itu ditangani fade-nya updateReelFightLoop).
  • func handleScreenChange(isPlaying: Bool) β€” menyalakan/mematikan loop suara latar danau saat layar utama aplikasi berganti (menu vs main). Juga menghentikan paksa loop idle/reel-fight saat keluar dari gameplay.
  • func stopAll() β€” menghentikan semua loop di SoundManager dan mereset flag internal kelas ini sendiri. Dipanggil dari WorldViewModel.stop().

Terhubung ke: Membungkus instance SoundManager (dibuat oleh WorldViewModel lalu diberikan ke sini). Memakai enum FishingEvent dan HookPhase dari Shared. Dipanggil banyak sekali dari WorldViewModel β€” setiap emit(_:), setHookPhase(_:), dan panggilan update loop per-tick di tick(deltaTime:) lewat sini.

Catatan menarik: Ada komentar eksplisit bahwa .fishEscaped/.lineBreak memakai ulang suara cebur karena "belum ada aset khusus." Seluruh class ini sengaja dibuat mencerminkan FishingHaptics di sisi Rod satu-satu, supaya orang yang membaca kedua file berdampingan bisa lihat event yang sama ditangani dengan cara yang sama di dua modalitas berbeda (suara vs getar haptic).

4. Audio/SoundManager.swift

Ini pemutar file suara level rendah. Dia tidak tahu apa-apa soal gameplay mancing β€” cuma tahu cara preload, putar, loop, fade, dan atur volume daftar tetap aset suara pakai framework AVFoundation dari Apple. Rekannya di sisi Rod adalah HapticManager.

Tipe yang didefinisikan:

  • SoundManager (final class, @MainActor).
  • SoundManager.Category (enum): .ui, .effects, .ambience β€” pengelompokan kasar cuma untuk kontrol volume massal.
  • SoundManager.Sound (enum, String, CaseIterable) β€” satu kasus per file suara nyata yang dibundel bersama aplikasi (mis. .uiClick, .castThrow, .hookSplash, .fishInterestedLoop, .fishBite, .reelLoop, .catchSplash, .ambienceLake, .idle, .reelFight, .fishCatched). Nilai string mentah tiap kasus persis nama file (tanpa ekstensi) yang dibundel di World/World/Assets/SFX/.

Fungsi-fungsi penting:

  • Sound.category: Category β€” memetakan tiap kasus suara ke kategorinya (.uiClick β†’ .ui, .ambienceLake β†’ .ambience, sisanya β†’ .effects).
  • Sound.fileExtension: String β€” "mp3" untuk .ambienceLake, "wav" untuk sisanya.
  • Sound.defaultVolume: Float β€” volume dasar hasil tuning manual per suara (mis. ambience 0.35, loop fight/idle lebih pelan 0.3, kebanyakan one-shot 0.85).
  • init() β€” di iOS, mengatur AVAudioSession bersama ke kategori .ambient dengan .mixWithOthers (supaya audio game tidak membisukan aplikasi/musik lain) lalu mengaktifkannya; kemudian memanggil preloadPlayers().
  • private func configureAudioSession() (khusus iOS, dibungkus #if os(iOS)) β€” mengatur sesi audio seperti dijelaskan di atas; error diabaikan dengan try?.
  • private func preloadPlayers() β€” untuk tiap kasus Sound, mencari file yang dibundel, membuat AVAudioPlayer, mengatur volumenya ke nilai default, memanggil prepareToPlay() (buffer awal supaya tidak ada jeda saat pertama diputar), lalu menyimpannya di dictionary players. Diam-diam melewati suara mana pun yang filenya tidak ditemukan atau gagal dimuat.
  • private func effectiveVolume(for sound: Sound) -> Float β€” volume default suara dikalikan pengali kategori yang sedang berlaku (categoryVolumeMultipliers), untuk kontrol volume saat runtime.
  • func play(_ sound: Sound) β€” memutar one-shot dari awal (currentTime = 0), bahkan memicu ulang suara yang sedang main, karena tidak ada gameplay yang benar-benar butuh dua salinan suara yang sama main bersamaan.
  • func startLoop(_ sound: Sound) β€” memulai loop tanpa henti (numberOfLoops = -1); tidak melakukan apa-apa kalau sudah main (idempoten).
  • func stopLoop(_ sound: Sound, fadeDuration: TimeInterval = 0) β€” dengan fadeDuration == 0, berhenti langsung dan rewind. Dengan durasi fade positif, menurunkan volume ke 0 selama durasi itu pakai AVAudioPlayer.setVolume(_:fadeDuration:), lalu setelah await Task.sleep, baru benar-benar menghentikan player, rewind, dan mengembalikan volumenya ke normal (supaya panggilan startLoop BERIKUTNYA tidak diam-diam jadi bisu karena volume ketinggalan di 0).
  • func stopAllLoops() β€” menghentikan loop semua suara non-kategori-UI (suara UI adalah one-shot, tidak pernah loop, jadi dikecualikan).
  • func setVolume(_ volume: Float, for category: Category) β€” mengatur pengali sekategori dan langsung menerapkannya ke setiap player yang sudah dimuat di kategori itu.

Terhubung ke: Dibungkus oleh FishingSounds (satu-satunya pemakai). Cuma bergantung ke AVFoundation β€” tidak bergantung ke Shared, RealityKit, atau tipe gameplay apa pun. Ini pemisahan lapisan yang disengaja: SoundManager "bodoh," FishingSounds yang menambahkan makna.

Catatan menarik: Komentar pada jalur fade di stopLoop menjelaskan secara spesifik kenapa volume dikembalikan setelah berhenti β€” sebuah kelas bug halus (playback berikutnya diam-diam rusak) yang jelas sudah dipikirkan penulisnya lebih dulu.

5. Gameplay/FishCatalog.swift

Ini daftar statis semua "spesies" ikan yang bisa ditangkap (saat ini masih nama placeholder Fish A/B/C dan Scott) beserta statistik dasarnya. Ini tabel data yang jadi acuan simulasi perkelahian.

Tipe yang didefinisikan: Tidak ada tipe baru miliknya sendiri β€” ini enum FishCatalog yang cuma dipakai sebagai namespace (tanpa kasus, cuma anggota statis), dan dia memproduksi/memakai tipe FishSpecies/FishSpeciesRange/FishTier dari Shared (didefinisikan di Shared/Sources/Shared/Models/Gameplay/FishSpecies.swift, bukan di aplikasi ini tapi penting untuk dipahami: FishSpecies menyimpan id, nama, rentang berat/panjang, nilai resistance 0–1, value koin, dan FishTier β€” .small/.medium/.big/.boss).

Fungsi-fungsi penting:

  • static let all: [FishSpecies] β€” empat spesies yang dikodekan langsung: fish_a (tier small, 0.8–3.5 kg, 20–55 cm, resistance 0.35, value 10), fish_b (medium, 3.5–9.0 kg, 55–90 cm, resistance 0.50, value 25), fish_c (big, 9.0–20.0 kg, 90–140 cm, resistance 0.65, value 60), dan scott (boss, 20–40 kg, 140–200 cm, resistance 0.70, value 500 β€” tangkapan legendaris nama besar game ini).
  • static func randomSpecies(unlockedTiers: Set<FishTier>) -> FishSpecies β€” menyaring all hingga cuma spesies yang tier-nya ada di unlockedTiers (dikendalikan oleh ProgressionStore), lalu memilih satu secara acak; kalau hasil saringan kosong, jatuh balik ke all[0] (fish_a, selalu terbuka), menjamin setiap gigitan selalu berujung ke SESUATU.

Terhubung ke: Dipakai FishFightController.updateSearching(deltaTime:), yang memanggil randomSpecies(unlockedTiers:) tepat saat gigitan terjadi. Bergantung ke FishSpecies/FishSpeciesRange/FishTier dari Shared.

Catatan menarik: Komentar dokumentasi menyebut eksplisit bahwa angka-angka ini masih kasar, bukan hasil balancing final β€” ada kutipan langsung dari pihak yang meminta: "parameter fish nya nanti di ubah lagi seiring berjalannya game." Nilai resistance untuk fish_c dan scott juga punya komentar inline yang mencatat sudah diturunkan dua kali (riwayat semacam 0.75β†’0.60β†’0.65) merespons masukan langsung pemain bahwa level 3–4 terlalu susah ("mudahin dikit dong ikan level 3 dan 4 nya").

6. Gameplay/FishCatchResult.swift

Ini tipe nilai kecil dan sederhana yang membawa semua yang dibutuhkan HUD/layar hasil untuk menampilkan hasil satu gigitan (tertangkap, kabur, atau senar putus). Ini murni data tampilan β€” tidak ada logika.

Tipe yang didefinisikan:

  • FishCatchResult (struct, Equatable) β€” hasil satu gigitan yang sudah selesai.
  • FishCatchResult.Outcome (enum, String): .caught ("Caught"), .escaped ("Escaped"), .lineBreak ("Line Broke").

Fungsi-fungsi penting: Tidak ada β€” ini struct data biasa tanpa metode, cuma properti tersimpan (speciesName, weight, length, outcome, stars 1–5 atau 0, coins, isNewRecord).

Catatan tambahan: Ketujuh field-nya semua let (tidak bisa diubah setelah dibuat). stars/coins/isNewRecord didokumentasikan selalu nol/false kecuali outcome == .caught.

Terhubung ke: Diproduksi oleh FishFightController.resolve(outcome:) dan disimpan sebagai lastCatchResult. Dipakai oleh ResultView (kartu hasil) dan strip ringkasan "hasil terakhir" kecil di ContentView.

Catatan menarik: Tidak ada yang rumit β€” contoh buku teks struct "view model" yang immutable.

7. Gameplay/FishFightController.swift

Ini jantung minigame mancing yang sebenarnya β€” semua tentang mencari ikan, ikan mulai tertarik, menggigit, dan tarik-menarik (tug-of-war) berikutnya berupa pengelolaan tegangan senar plus sub-mekanik "juking" kiri/kanan, semuanya ada di sini. File ini sama sekali tidak tahu apa-apa soal posisi 3D, entity RealityKit, atau transport Bluetooth; WorldViewModel adalah jembatan yang memberinya angka dunia nyata (jarak kail, kemiringan/roll joran, delta waktu) dan membaca balik state publikasinya untuk menggerakkan tampilan di layar.

Tipe yang didefinisikan:

  • FishFightController (final class, ObservableObject, @MainActor).
  • FishFightController.FightFailure (enum): .escaped, .lineBreak β€” dua cara perkelahian berakhir buruk.
  • FishFightController.TensionBand (enum): .low, .medium, .high β€” pita tegangan mana yang menentukan ritme haptic yang seharusnya dimainkan Rod, diturunkan dari tegangan.
  • FishFightController.SearchEvent (enum): .interested, .bite β€” apa yang baru saja dilakukan updateSearching tick ini, kalau ada.
  • FishFightController.FightDirection (enum): .left, .right β€” arah tarikan ikan yang sedang terkait saat fase juking-nya; pemain harus memutar joran ke arah SEBALIKNYA untuk melawan (konvensi world/screen-space yang terdokumentasi, bukan relatif ke joran).

Fungsi-fungsi penting:

  • init(recordStore:, progressionStore:, config:) β€” menerima tiga dependency yang bisa disuntik (dibuat default kalau tidak diberikan): FishRecordStore (penyimpanan berat terbaik per spesies), ProgressionStore (penyimpanan tier yang terbuka), dan FishFightConfig (semua angka yang bisa di-tuning β€” struct dari paket Shared dengan default seperti initialTension = 0.5, baseFishPullRate = 0.105, dll, semuanya didokumentasikan lengkap dengan riwayat tuning-nya). Membaca currentLevel awal dari progressionStore.level.
  • var progressTowardNextLevel: (caught: Int, needed: Int)? β€” pass-through ke progressionStore.progressTowardNextLevel, supaya HUD tidak perlu punya referensi sendiri ke store itu.
  • func dismissLevelUpAnnouncement() β€” mengosongkan levelUpAnnouncement jadi nil; dipanggil tombol tutup di HUD.
  • func resetProgression() β€” mereset progressionStore kembali ke Level 1 dan menutup banner naik level yang sedang tampil. Aksi debug/utilitas, bukan bagian alur normal.
  • func resetForNewCast(castDistance: Float = 0) β€” dipanggil setiap kali lemparan baru jatuh di air. Mengosongkan hasFishOnHook, isFishInterested, spesies/tegangan saat ini, memulai ulang timer ketertarikan acak (0.8–2.0 detik), dan mencatat castDistance baik untuk gating internal (currentCastDistance) maupun tampilan HUD (lastCastDistance).
  • func updateSearching(deltaTime: Float) -> SearchEvent? (@discardableResult) β€” mesin state per-tick "apakah ikan sudah tertarik/menggigit," cuma dipanggil selagi .hookInWater. Kalau belum tertarik, menghitung mundur interestTimer; begitu habis, mengubah isFishInterested = true, memulai biteTimer baru, mengembalikan .interested. Kalau sudah tertarik, menghitung mundur biteTimer; begitu habis, memilih spesies acak (dibatasi oleh progressionStore.unlockedTiers), mengundi berat/panjangnya lewat biasedRoll (dibatasi oleh sizeRollWindow, yang tergantung castZone tempat lemparan ini jatuh β€” lihat di bawah), mengatur hasFishOnHook = true, mengatur tegangan ke config.initialTension, dan memulai sub-fase juking arah-perkelahian (acak .left/.right, durasi acak 5–10 detik, interval ganti acak 0.6–1.8 detik) β€” mengembalikan .bite.
  • func updateFight(deltaTime:, isReeling:, reelSpeed:, hookDistance:, rodRoll:) -> FightFailure? (@discardableResult) β€” matematika tarik-menarik per-tick, cuma dipanggil selagi ada ikan terkait. Tick pertama menyimpan hookDistance sebagai fightStartDistance; kalau kail pernah menjauh lebih dari effectiveEscapeDistanceAllowance dari titik awal itu, ikan langsung dinyatakan kabur (tidak peduli tegangan β€” ini "escape jarak"). Kalau tidak, dihitung reelPull (kecepatan reel Γ— angka konfigurasi, atau 0 kalau tidak sedang reel), memanggil updateFightDirection untuk delta melawan-juking, lalu memperbarui lineTension dari gabungan angka itu Γ— deltaTime, dibatasi ke rentang [0,1]. Tegangan menyentuh 0 β†’ .escaped (senar kendur); menyentuh 1 β†’ .lineBreak (putus karena terlalu kencang ditarik). Mengembalikan kegagalan, atau nil kalau perkelahian berlanjut.
  • private func updateFightDirection(deltaTime:, rodRoll:) -> Float β€” memajukan sub-fase juking: menghitung mundur fightDirectionRemainingDuration (timer keseluruhan sub-fase; kalau habis, fightDirection kembali ke nil dan fungsi ini mengembalikan 0), dan terpisah menghitung mundur fightDirectionSwitchTimer (membalik .left↔.right pada interval acak baru saat habis β€” "pengacak cepat"). Mengevaluasi apakah pemain SAAT INI melawan dengan benar dengan memeriksa rodRoll terhadap config.fightDirectionRollThreshold di tanda yang benar untuk arah saat ini, dan mengembalikan entah pelepasan tegangan (-config.fightDirectionCounterRelief, hadiah untuk melawan yang benar) atau penalti (+config.fightDirectionPenalty, untuk mengabaikan atau melawan arah yang salah).
  • var tensionBand: TensionBand? β€” dihitung dari lineTension dibandingkan config.tensionBandHighThreshold/.tensionBandMediumThreshold; nil kalau tidak ada ikan terkait. Dipakai memberitahu Rod ritme haptic mana yang harus dipulsakan.
  • var fishPullSpeed: Float β€” meter/detik seberapa cepat ikan yang terkait menyeret kail menjauh saat pemain tidak sedang reel, diskalakan oleh resistance. Dibaca WorldViewModel untuk menggerakkan entity kail 3D.
  • var reelPullSpeed: Float β€” kecepatan reel risiko/hadiah yang diskalakan tegangan: pada atau di bawah config.reelSpeedSafeTension nilainya rata config.reelPullSpeedSafe (2.0 m/s); pada atau di atas config.reelSpeedDangerTension nilainya config.reelPullSpeedDanger (4.0 m/s, lebih cepat β€” menghadiahi pemain yang berani menahan tegangan mendekati batas putus), diinterpolasi linear di antara kedua ambang itu.
  • func resolveCatch() β€” dipanggil ketika kail sampai ke joran selagi ada ikan terkait; menambah caughtCount dan memanggil resolve(outcome: .caught).
  • private func resolve(outcome: FishCatchResult.Outcome) β€” logika finalisasi bersama untuk ketiga hasil. Untuk .caught, menghitung stars (1–5, lewat Self.stars(forWeight:in:)), coins (lewat Self.coins(baseValue:stars:)), memeriksa recordStore.recordCatch(...) untuk rekor baru, dan melapor tangkapan ke progressionStore.recordCatch(tier:) β€” kalau level yang dikembalikan lebih tinggi dari currentLevel, mengatur levelUpAnnouncement jadi pesan perayaan. Membangun dan menyimpan lastCatchResult, lalu mereset semua variabel instance perkelahian kembali ke default idle-nya (mirip banyak hal yang dilakukan resetForNewCast).
  • private static func stars(forWeight:in:) -> Int β€” 1 sampai 5 bintang, linear terhadap seberapa dekat berat tangkapan dengan maksimum rentang spesiesnya.
  • private static func coins(baseValue:stars:) -> Int β€” payout skala sedikit naik seiring bintang (pengali 0.6 + 0.1 * stars pada nilai dasar spesies).
  • private static func announcement(forLevel:) -> String? β€” teks yang dikodekan langsung untuk level 2 ("Medium fish have entered the lake!") dan level 3 ("Scott has appeared. Catch him!"); nil untuk level lainnya (didokumentasikan sebagai praktis tidak mungkin terjadi, karena recordCatch cuma pernah mengembalikan 1/2/3).
  • private enum CastZone (.green/.yellow/.red) dan private var castZone: CastZone β€” sepertiga mana dari maxExpectedCastDistance tempat lemparan saat ini jatuh, cocok satu-satu dengan sepertiga hijau/kuning/merah bar kekuatan milik CastMeterView.
  • private var sizeRollWindow: (floor: Float, ceiling: Float) β€” untuk tiap castZone, rasio lantai/langit-langit yang tegas (mis. hijau β†’ 0.0–0.375) dipilih supaya hasil stars(forWeight:) selalu jatuh di tier yang dijanjikan (lemparan hijau cuma bisa mengundi ikan 1–2 bintang, merah cuma 4–5 bintang). Ini pilihan desain yang disengaja supaya lemparan pendek tidak bisa lagi kadang-kadang mengundi ikan besar karena keberuntungan.
  • private static func biasedRoll(in:floorRatio:ceilingRatio:) -> Double β€” mengundi nilai acak dibatasi dalam rentang [range.min + span*floorRatio, range.min + span*ceilingRatio], bukan rentang alami penuh spesiesnya.
  • private var effectiveFishPullRate: Float / private var effectiveEscapeDistanceAllowance: Float β€” versi konstanta konfigurasi dasar yang disesuaikan dengan resistance (resistance lebih besar β†’ tegangan menguras lebih cepat, jarak-escape lebih ketat, dengan batas bawah supaya tidak jadi tidak adil terlalu ketat).
  • private var resistance: Float β€” resistance efektif untuk tangkapan ini spesifik: species.resistance * sizeRatio β€” ikan yang terundi mendekati ukuran minimum spesiesnya berkelahi mendekati kesulitan dasar; yang mendekati maksimum berkelahi dengan resistance penuh yang tercantum di spesies.
  • private var sizeRatio: Float β€” 0…1, seberapa besar (dari hasil kali beratΓ—panjang) tangkapan ini terundi relatif terhadap rentang min/max spesiesnya sendiri.

Catatan tambahan: minimumFightDistance: Float = 0.4 adalah jarak minimum kail-ke-joran yang dijamin ada tepat saat gigitan terjadi (lihat WorldViewModel.ensureFightDistance()), supaya pemain yang sudah sedang menarik saat gigitan terjadi tetap dapat perkelahian sungguhan, bukan langsung tertangkap instan.

Terhubung ke: Memiliki sebuah FishRecordStore dan ProgressionStore. Membaca FishCatalog.randomSpecies(unlockedTiers:). Sepenuhnya digerakkan oleh loop tick WorldViewModel (updateSearching, updateFight, resolveCatch, resetForNewCast) β€” dia tidak pernah menyentuh RealityKit, transport, atau entity sendiri. Properti publikasinya memberi makan LineTensionView, strip ringkasan di ContentView, dan ResultView (lewat lastCatchResult).

Catatan menarik: Komentarnya sangat kaya dengan kutipan langsung dari sesi masukan tuning (dalam Bahasa Indonesia, diterjemahkan inline) β€” mis. "fish pull speed rasanya terlalu kencang," "coba base nya 2 m/s instead of 0.5 - 1." Seluruh desain perkelahian (satu bar tegangan berkelanjutan plus sub-mekanik juking kiri/kanan di atasnya) secara eksplisit dicocokkan dengan dokumen desain terpisah bernama GAMEPLAY_ARCHITECTURE_V1.md.

8. Gameplay/FishRecordStore.swift

Menyimpan (bertahan lintas peluncuran ulang aplikasi) ikan terberat yang pernah tertangkap per spesies, supaya game bisa menandai "New Record" saat tertangkap.

Tipe yang didefinisikan: FishRecordStore (final class) β€” tidak ada enum/struct sendiri.

Fungsi-fungsi penting:

  • init() β€” memuat rekor tersimpan dari UserDefaults lewat Self.load().
  • func recordCatch(speciesID:, weight:) -> Bool (@discardableResult) β€” membandingkan berat baru dengan rekor tersimpan untuk spesies ID itu; kalau tidak lebih berat, mengembalikan false tanpa melakukan apa pun. Kalau lebih, memperbarui dictionary, menyimpannya, dan mengembalikan true (rekor baru).
  • private static func load() -> [String: Double] β€” membaca dan men-decode JSON dictionary dari UserDefaults.standard.data(forKey:); mengembalikan dictionary kosong kalau tidak ada yang tersimpan atau decode gagal.
  • private func save() β€” meng-encode bestWeightBySpeciesID jadi JSON dan menulisnya balik ke UserDefaults.

Catatan tambahan: private var bestWeightBySpeciesID: [String: Double] β€” seluruh tabel rekor tersimpan, dikunci dengan string ID spesies (mis. "fish_a", "scott").

Terhubung ke: Dimiliki oleh FishFightController; cuma dipanggil dari resolve(outcome:). Memakai UserDefaults/JSONEncoder/JSONDecoder dari Foundation β€” tidak ada dependensi aplikasi lain.

Catatan menarik: Kode sederhana dan defensif β€” setiap decode pakai try?, jadi file simpanan yang rusak atau hilang gagal dengan anggun jadi "belum ada rekor" alih-alih crash.

9. Gameplay/ProgressionStore.swift

Melacak berapa banyak ikan tiap tier yang sudah ditangkap pemain, dan menurunkan "level" pemain saat ini (yang menentukan tier ikan mana yang bisa ditangkap sama sekali) dari hitungan-hitungan itu. Bertahan lintas peluncuran seperti FishRecordStore.

Tipe yang didefinisikan:

  • ProgressionStore (final class).
  • ProgressionStore.Counts (private struct, Codable) β€” empat penghitung Int (small, medium, big, boss), semuanya default 0.

Fungsi-fungsi penting:

  • init() β€” memuat counts dari UserDefaults lewat Self.load().
  • var level: Int β€” dihitung dari counts terhadap ambang tetap (small: 3, medium: 5, big: 10, boss: 1): mulai di 1; mencapai 3 tangkapan small β†’ level 2; 5 tangkapan medium β†’ level 3; 10 tangkapan big β†’ level 4. (Komentar dokumentasinya eksplisit bahwa ini menggantikan desain lama yang basi, tiga-level 10/10 yang pernah dijelaskan di versi komentar sebelumnya β€” ambang yang benar-benar dipakai adalah yang ada di kode.)
  • var unlockedTiers: Set<FishTier> β€” memetakan level ke tier mana yang bisa ditangkap: level 1 β†’ [.small]; level 2 β†’ +.medium; level 3 β†’ +.big; level 4+ β†’ keempatnya termasuk .boss (Scott).
  • var progressTowardNextLevel: (caught: Int, needed: Int)? β€” untuk tampilan HUD "X/Y menuju tier berikutnya"; mengembalikan nil begitu level 4 tercapai dan satu-satunya Scott juga sudah tertangkap (tidak ada lagi yang perlu dikejar).
  • func recordCatch(tier: FishTier) -> Int (@discardableResult) β€” menambah penghitung yang sesuai untuk tier yang diberikan, menyimpannya, dan mengembalikan level SETELAH pencatatan (supaya pemanggil bisa membandingkan dengan level sebelumnya untuk mendeteksi transisi naik level).
  • func reset() β€” mengosongkan semua penghitung kembali ke nol (mengembalikan pemain ke level 1) dan menyimpannya. Cara pemain mengulang kurva unlock tanpa install ulang.
  • private static func load() -> Counts / private func save() β€” pola UserDefaults + JSON yang sama seperti FishRecordStore.

Catatan tambahan: private var counts: Counts β€” empat penghitung tangkapan per-tier yang tersimpan; ini seluruh state progresi.

Terhubung ke: Dimiliki oleh FishFightController. Dipakai oleh FishCatalog.randomSpecies(unlockedTiers:) (tidak langsung, lewat FishFightController membaca progressionStore.unlockedTiers) dan oleh FishFightController.progressTowardNextLevel/currentLevel, yang dibaca HUD.

Catatan menarik: Komentar dokumentasinya secara eksplisit menandai bahwa dia diperbaiki saat menulis salinan onboarding terpisah (ADR-064) karena komentar sebelumnya menjelaskan angka yang sudah basi dan tidak lagi benar β€” contoh bagus dokumentasi yang melenceng dari kode lalu ketahuan dan diperbaiki. Teks halaman ketiga OnboardingView ("Catch 3 small fish... Catch 5 medium... Catch 10 big... Catch Scott") mencerminkan persis ambang-ambang ini.

10. Reality/EnvironmentSceneBuilder.swift

Membangun seluruh lingkungan danau 3D yang statis β€” air, terrain, dermaga, langit, matahari, pencahayaan ambient langit, dan sekitar 300 instance dekorasi tersebar (pohon, rumput, alang-alang, batu, pakis, semak, properti dermaga) β€” setiap kali aplikasi dibuka, dengan memuat file .usdz yang sudah jadi (dibuat di repo Blender terpisah) dan menempatkannya di koordinat dunia tepat yang diambil manual.

Tipe yang didefinisikan:

  • EnvironmentSceneBuilder (enum, @MainActor) β€” namespace, tidak punya instance.
  • EnvironmentSceneBuilder.ScatterInstance (private struct) β€” satu dekorasi tersebar: assetName, position (SIMD3<Float>), scale (Float, seragam).
  • EnvironmentSceneBuilder.DockProp (private struct) β€” satu properti yang ditempatkan di dek dermaga: assetName, position, yawDegrees.
  • (Level file, tergantung platform) typealias PlatformColor = NSColor (macOS/AppKit) atau = UIColor (iOS/UIKit) β€” supaya kode yang sama bisa dikompilasi di kedua platform karena tipe warna RealityKit cuma alias untuk kelas warna asli platform tersebut.

Fungsi-fungsi penting:

  • (Level file) private func srgbColor(_:_:_:_:) -> PlatformColor β€” membangun warna sRGB dengan tipe platform konkret yang benar dari komponen float merah/hijau/biru/alpha.
  • static func build() async -> Entity β€” titik masuk utama, dipanggil sekali dari WorldViewModel.makeSceneEntity(). Memuat dan menambah lima aset "inti" (water_surface, terrain_underwater, terrain_horizon_ring, dock_main, sky_dome) pada transform identitas (semuanya dibuat bersama dalam satu kerangka koordinat yang sama, diverifikasi terhadap scene preview milik repo aset sendiri). Menerapkan shader air custom (WaterMaterial.apply) ke entity air dan tint warna hijau (tintTerrainGreen) ke tekstur terrain horizon ring. Menambahkan cahaya matahari terarah (makeSun()) dan, kalau berhasil dimuat, cahaya berbasis gambar (refleksi langit, makeImageBasedLight()). Lalu, untuk tiap nama aset di scatterInstances yang dikelompokkan bersama, memuat satu salinan template dan meng-clone(recursive:)-nya sekali per instance, menerapkan posisi/skala instance itu tepat (dan, untuk beberapa tipe aset "vertikal," rotasi yaw per-instance deterministik untuk variasi visual β€” lihat di bawah). Terakhir menempatkan tiga properti dermaga (prop_bucket_01, prop_tacklebox_01, prop_rope_01) di posisi/rotasi yang dibuat. Mengembalikan Entity akar yang sudah tersusun.
  • private static func load(_ name: String) async -> Entity? β€” wrapper tipis di sekitar Entity(named:), menelan kegagalan pemuatan jadi nil (jadi satu aset yang hilang/rusak tidak merusak seluruh build).
  • private static func tintTerrainGreen(_ entity: Entity) β€” mengalikan tekstur foto tanah bawaan terrain_horizon_ring dengan tint condong hijau ((0.65, 1.0, 0.6)) lewat PhysicallyBasedMaterial.BaseColor.tint, karena tekstur foto dunia-nyata yang dipakai sebenarnya terbaca cokelat/tan, bukan "hijau rimbun" seperti yang diminta. Komentar dokumentasi menjelaskan dua putaran tuning ulang berbasis screenshot langsung: percobaan pertama (tint lebih berat) membuat tanah terlihat hijau rata tidak alami karena mengalikan setiap piksel dengan warna yang sama meruntuhkan variasi hue alami, bukan cuma kecerahan; nilai final adalah kompromi yang lebih moderat.
  • private static func findModelEntity(in:) -> ModelEntity? β€” pencarian pohon rekursif kecil yang mengembalikan ModelEntity pertama yang benar-benar membawa mesh, ditemukan di mana pun di anak-anak sebuah entity (sebuah .usdz yang dimuat sering dibungkus satu atau lebih entity kontainer kosong dulu).
  • private static func makeImageBasedLight() async -> Entity? β€” memuat JPEG equirectangular yang dibundel (sky_ibl.jpg β€” salinan berdiri sendiri dari tekstur yang sama yang ditampilkan sky_dome.usdz sendiri), membangun EnvironmentResource RealityKit darinya, dan mengembalikan entity yang membawa ImageBasedLightComponent. Ini yang memberi setiap permukaan di scene (terutama air) cahaya ambient/refleksi yang cocok dengan langit yang terlihat, bukan cuma satu matahari terarah datar tanpa refleksi sama sekali β€” dijelaskan sebagai menutup TODO yang sebelumnya belum diimplementasikan dari CLAUDE.md milik repo aset sendiri.
  • private static func makeSun() -> Entity β€” membangun DirectionalLightComponent tunggal (warna hangat, intensitas 7000, dengan sub-komponen .Shadow supaya benar-benar melempar bayangan β€” komponen opt-in terpisah, mudah terlewat) pada konvensi pencahayaan yang terdokumentasi (~15Β° elevasi, 135Β° azimuth, warna hangat), dihitung jadi vektor arah dunia sebenarnya lalu diarahkan lewat Entity.look(at:from:).

Catatan tambahan:

  • private static let sunElevationDegrees / sunAzimuthDegrees / sunColor / sunDistance β€” konstanta konvensi pencahayaan.
  • private static let coreAssetNames: [String] β€” lima aset lingkungan "inti" yang dimuat pada transform identitas.
  • private static let scatterInstances: [ScatterInstance] β€” array yang dikodekan langsung sangat panjang (ratusan entri) yang memberi posisi/skala tepat tiap instance dekorasi (rumput, alang-alang, batu, pohon, pakis, semak), diambil dari scene preview repo Blender sendiri, plus beberapa tambahan sisi-aplikasi-saja yang dilapiskan kemudian (kepadatan pohon tambahan, foliase pantai tambahan dekat sudut pandang kamera sebenarnya, sejumlah kecil instance pakis/semak baru).
  • private static let yawRandomizedAssetNames: Set<String> β€” tipe aset mana yang mendapat rotasi yaw acak per-instance (tree_01, foliage_fern_01, foliage_shrub_01 β€” apa pun yang vertikal/asimetris di mana rotasi terlihat jelas secara visual; rumput/alang-alang/batu dilewati karena percuma).
  • private static let deckHeight: Float = 0.80 dan private static let dockProps: [DockProp] β€” tiga dekorasi dermaga yang ditempatkan manual.

Terhubung ke: Dipanggil sekali oleh WorldViewModel.makeSceneEntity(). Memanggil WaterMaterial.apply(to:). Memuat file .usdz yang dibundel bersama aplikasi (awalnya diproduksi dari repo Blender new-blender-project β€” repo yang dijelaskan CLAUDE.md sesi ini). Memakai RealityKit, ImageIO, simd.

Catatan menarik: File ini punya komentar bergaya "laporan insiden" terbanyak di seluruh aplikasi. Yang menonjol:

  • Sebuah fungsi hash yaw per-instance deterministik (deterministicYaw(for:), gaya hash sin klasik ala GLSL) dipakai alih-alih menyimpan seed acak, jadi menjalankan ulang aplikasi selalu menghasilkan rotasi "terlihat acak" yang sama.
  • Bug-dan-perbaikan yang terdokumentasi: versi sebelumnya MENGGANTI orientasi tiap klon yang dirotasi sepenuhnya dengan kuaternion yaw, yang diam-diam menghapus koreksi impor bawaan βˆ’90Β° sekitar X yang dimiliki ketiga tipe aset itu (perbaikan standar USD Z-up β†’ RealityKit Y-up) β€” membuat setiap pohon yang dirotasi rebah miring jadi seperti "coretan puing cabang horizontal." Perbaikannya adalah mengalikan yaw dari kiri di atas orientasi yang sudah ada, bukan menggantinya.
  • tree_02.usdz punya cacat yang diketahui dan belum diperbaiki (bentuk humanoid menyatu ke mesh batangnya, dilacak sebagai "CP41" di repo aset) β€” setiap slot scatter yang awalnya dibuat untuk tree_02 (bahkan tree_03, si palem) dialihkan ke tree_01, khusus untuk menghindari mesh yang rusak sekaligus kenaikan frekuensi pohon palem yang tidak diinginkan yang tanpa sengaja dimasukkan perbaikan cepat sebelumnya.
  • foliage_shrub_01 sangat mahal (~156.000 segitiga per instance, dikonfirmasi kepadatan Poly Haven asli, bukan bug) dan sengaja dibatasi hanya 5 instance yang ditempatkan di titik paling menonjol secara visual (mengapit dermaga) alih-alih disebar luas.

11. Reality/RodRigController.swift

Menggerakkan pose bengkok joran yang dihitung kode secara langsung dan pose kendur (sag) senar pancing setiap frame. Baik rod_main.usdz maupun rod_line.usdz dikirim dari repo aset sebagai rangka skeleton rest-pose saja tanpa animasi baked sama sekali (sesuai tabel model animasi di CLAUDE.md) β€” file ini adalah separuh "kode aplikasi harus memutar tulang secara langsung" dari kontrak itu.

Tipe yang didefinisikan:

  • RodRigController (struct, @MainActor) β€” menyimpan referensi ke entity joran/senar/reel yang dimuat dan state smoothing saat ini; dikembalikan oleh factory statis load()-nya sendiri.
  • RodRigController.BoneWeight (private struct) β€” satu sendi/joint: path string terautentikasi (mis. "Root/Handle/Rod_01"), seberapa besar porsi bengkok/kendur total yang dia bawa (weight, 0=kaku sampai 1=amplitudo penuh), dan translasi lokal rest-pose-nya (panjang tulang).

Fungsi-fungsi penting:

  • static func load() async -> RodRigController β€” memuat rod_main dan rod_line sebagai entity, menemukan ModelEntity yang benar-benar ber-skin di dalamnya (lewat findSkinnedModel, karena data skeleton/joint ada di entity pembawa mesh, bukan kontainer generik yang dikembalikan Entity(named:)), menempelkan senar ke joint Tip milik joran lewat "pin" RealityKit (digeser oleh tipTailOffset supaya jatuh di EKOR tulang Tip, bukan kepalanya β€” kesalahan "kalau ini terbalik diam-diam memendekkan senar 0.25m" yang terdokumentasi di CLAUDE.md, sudah diperbaiki di sini), mengatur pin marker tipEntity terpisah dengan offset nol (dipakai di tempat lain untuk meluncurkan lemparan dari ujung visual joran yang sebenarnya), dan membangun reel placeholder β€” bola pipih yang di-pin dekat pegangan, mengganti sementara aset reel/spool yang belum dimodelkan (cocok, menurut komentarnya sendiri, dengan pola placeholder primitif yang sudah ditetapkan proyek untuk aset yang hilang).
  • mutating func applyBend(pitch:, roll:) β€” fungsi bengkok joran, dipanggil tiap tick dari WorldViewModel.updateRodPose(). Menghaluskan pitch/roll mentah yang masuk dengan filter low-pass satu-kutub (bendSmoothing = 0.25) supaya jitter sensor antar-frame tidak terbaca sebagai gonta-ganti tekuk/kaku yang aneh. Untuk tiap tulang di rodBones (Rootβ†’Handleβ†’Rod_01β†’Rod_02β†’Rod_03β†’Tip, bobot 0β†’0β†’0.15β†’0.35β†’0.65β†’1.0), menghitung sudut bengkok per-joint (pitch/roll yang sudah dihaluskan Γ— konstanta skala tetap Γ— bobot tulang itu sendiri), membatasinya ke batas atas per-joint yang tegas (maxBendAnglePerJoint = 0.35 rad, batas defensif melawan lonjakan sensor), membangun kuaternion kecil darinya, dan menulis balik Transform lokal joint itu (rotasi + translasi rest-nya sendiri) ke array jointTransforms milik model. Karena ini rotasi LOKAL yang berkomposisi menyusuri rantai parent, defleksi visual sebenarnya di ujung menjumlahkan semua bobot tulang bersama β€” fakta yang dikerjakan komentar dokumentasi secara eksplisit lewat aritmatika (0.15+0.35+0.65+1.0 = 2.15), karena nilai skala sebelumnya menghasilkan "cambukan liar berlebihan" karena tidak memperhitungkan komposisi ini.
  • private func clampedBendAngle(_:) -> Float β€” helper clamp min/max bersama yang dipakai applyBend.
  • func applyLineTension(_ tension: Float) β€” fungsi kendur senar, dipanggil tiap tick dari WorldViewModel.tick(). Mengonversi tension (0=risiko putus, 1=kencang) jadi slack = 1 - tension, lalu untuk tiap tulang di lineBones (Rootβ†’Anchorβ†’Line_01…Line_04, bobot 0.2β†’0.45β†’0.7β†’1.0) menghitung sudut kendur per-joint di sekitar sumbu yang DIBALIK-LAWAN (orientasi dunia ujung joran saat ini dibalik, supaya kendur selalu mengarah ke "bawah" sejati apa pun kemiringan/roll joran saat ini β€” memperbaiki bug yang sebelumnya diketahui di mana senar terlihat bercabang menjauh dari bidang bengkok joran sendiri saat pitch+roll digabung). Tiap joint kendurnya dibatasi maxLineSagAnglePerJoint dan ditulis ke jointTransforms milik model senar sendiri.
  • func applyReelSpin(reelSpeed:, isReeling:, deltaTime:) β€” memutar entity reel placeholder selagi aktif reel, sebanding dengan kecepatan reel, murni sebagai isyarat visual (tidak ada geometri reel nyata yang dimodelkan untuk membuat ini akurat secara fisik).
  • private static func findSkinnedModel(in:) -> ModelEntity? β€” pencarian rekursif untuk ModelEntity pertama di sebuah hierarki yang jointNames-nya tidak kosong (artinya benar-benar membawa skeleton).

Catatan tambahan:

  • let root: Entity β€” entity induk yang memegang sub-hierarki joran dan senar.
  • let tipEntity: Entity? β€” marker world-space di posisi tepat ujung joran, dipakai WorldViewModel.launchHook untuk tahu di mana menempatkan lemparan.
  • private let rodModel: ModelEntity? / lineModel: ModelEntity? / reelEntity: Entity? β€” mesh ber-skin/placeholder sebenarnya yang ditemukan saat load().
  • private var smoothedBendPitch / smoothedBendRoll: Double β€” pitch/roll yang sudah difilter low-pass, cuma dipakai untuk visualisasi bengkok sekunder (dipisah dari orientasi rig utama tak-dihaluskan yang diterapkan langsung WorldViewModel).
  • Beberapa konstanta tuning: bendPitchScale = 0.085, bendRollScale = 0.035, maxBendAnglePerJoint = 0.35, maxLineSag = 0.15, maxLineSagAnglePerJoint = 0.35, bendSmoothing = 0.25, tipTailOffset = [0, 0.25, 0] β€” semuanya disertai komentar dokumentasi substansial yang melacak riwayat tuning-nya dan aritmatika di baliknya.

Terhubung ke: Dimuat sekali oleh WorldViewModel.makeSceneEntity(), disimpan sebagai rodRig. Dipanggil tiap frame dari WorldViewModel.updateRodPose() (applyBend) dan WorldViewModel.tick() (applyLineTension, applyReelSpin). Bergantung ke RealityKit, AppKit (impor khusus macOS, patut dicatat β€” file ini ditulis tanpa cabang #if canImport(UIKit) seperti yang dipunyai EnvironmentSceneBuilder), dan simd.

Catatan menarik: Ini salah satu file yang paling banyak "menunjukkan matematika" di seluruh aplikasi β€” hampir setiap komentar konstanta menyertakan aritmatika sebenarnya (mis. pitch * bendPitchScale * 2.15) yang menjustifikasi kenapa angka spesifik itu dipilih, dan beberapa komentar menjelaskan bug konkret sebelum/sesudah (offset tip-tail, cabang sumbu kendur senar, rig rotasi berlebihan) yang ketahuan lewat screenshot langsung lalu diperbaiki. Reel secara eksplisit disebut sebagai placeholder menunggu aset nyata dari repo Blender terpisah.

12. Reality/WaterMaterial.swift

Membangun dan menerapkan material shader air animasi real-time ke entity water_surface yang dimuat, menggantikan material placeholder statis bawaan file .usdz-nya. Ini implementasi sisi-aplikasi dari TODO "shader air belum dibangun di repo aset, itu tugas RealityKit yang eksplisit" yang disebut di CLAUDE.md milik repo aset.

Tipe yang didefinisikan:

  • WaterMaterial (enum, @MainActor) β€” cuma namespace.
  • WaterMaterial.MaterialError (private enum): .noMetalDevice, .noDefaultLibrary.

Fungsi-fungsi penting:

  • static func apply(to entity: Entity) β€” menemukan ModelEntity pertama di dalam hierarki entity yang diberikan, mencoba membangun material custom lewat makeMaterial(), dan kalau berhasil, mengganti setiap slot material di model itu dengan salinan material itu. Gagal secara DIAM-DIAM (membiarkan material placeholder bawaan tetap ada) kalau library shader Metal tidak bisa dimuat β€” pilihan sengaja supaya gagal dengan anggun, bukan crash.
  • private static func makeMaterial() throws -> CustomMaterial β€” mengambil device Metal default dan library shader default-nya, mencari dua fungsi shader bernama darinya (waterSurfaceShader, sebuah SurfaceShader, dan waterGeometryModifier, sebuah GeometryModifier β€” keduanya dikompilasi dari file WaterShader.metal terpisah yang tidak ada di daftar file ini), lalu membangun CustomMaterial darinya dengan pencahayaan .lit. Kemudian memuat dua tekstur dan mengikatnya ke material: gambar langit equirectangular sky_ibl.jpg yang sama yang dipakai untuk image-based lighting diikat ke slot tekstur baseColor material (bukan sebagai warna dasar dalam arti biasa, tapi sebagai data piksel mentah yang di-sample sendiri oleh shader berdasarkan sudut equirectangular vektor refleksi, memalsukan refleksi langit nyata di air); dan water_normal.jpg (peta normal riak air yang bisa diubin, dikenal luas, dibundel dari aset contoh milik three.js sendiri yang berlisensi MIT) dimuat dengan semantik tekstur .normal (jadi RealityKit memperlakukannya sebagai peta normal linear, bukan warna sRGB) dan diikat ke slot peta normal material.
  • private static func findModelEntity(in:) -> ModelEntity? β€” pola pencarian pohon rekursif yang sama seperti di EnvironmentSceneBuilder.

Terhubung ke: Dipanggil sekali dari EnvironmentSceneBuilder.build() tepat setelah memuat water_surface. Bergantung ke RealityKit dan Metal secara langsung (bukan cuma lewat abstraksi RealityKit sendiri), karena CustomMaterial butuh MTLDevice/MTLLibrary nyata untuk mencari fungsi shader bernama yang dikompilasi dari file .metal.

Catatan menarik: Jejak komentarnya mendokumentasikan riwayat pendekatan yang ditolak yang menarik: refleksi air pertama kali dicoba sebagai pola "pita garis pantai" prosedural murni, yang menghasilkan sorotan radial sembarangan tanpa hubungan nyata dengan langit (secara eksplisit disebut bukan yang dimaksud pengguna dengan minta air "merefleksikan langit" β€” "not reflecting the sky, random reflect highlight"); lalu dua putaran percobaan riak prosedural (gelombang sinus analitik, lalu pola refleksi palsu) juga "tetap terlihat sintetis," yang membuat tim akhirnya cuma pakai tekstur peta normal air nyata yang gratis dan dikenal luas, di-sample tiga kali (lapisan sample ketiga yang bergulir ditambahkan di putaran belakangan) dengan cara yang sama seperti shader air referensi three.js sendiri melakukannya. File ini cuma MENYIAPKAN material; logika sampling/animasi sebenarnya ada di WaterShader.metal, yang di luar cakupan dokumentasi ini.

13. Transport/PairedDeviceStore.swift

Mengingat, lintas peluncuran ulang aplikasi, PERSIS perangkat Rod mana (lewat identifier peripheral Bluetooth) yang dipasangkan dengan World, supaya di ruangan dengan banyak pasangan Rod/World di dekatnya, World tidak asal terhubung ke Rod mana pun yang kebetulan mengiklankan diri lebih dulu.

Tipe yang didefinisikan: PairedDeviceStore (final class) β€” tidak ada enum/struct sendiri.

Fungsi-fungsi penting:

  • init() β€” membaca string UUID tersimpan dan nama perangkat dari UserDefaults, mem-parsing UUID kalau ada.
  • func save(identifier:, name:) β€” menyimpan identifier dan nama (opsional), menyimpannya ke UserDefaults; menghapus key nama kalau name adalah nil.
  • func clear() β€” menghapus kedua nilai di memori dan entry UserDefaults-nya.

Catatan tambahan: private(set) var identifier: UUID? dan private(set) var name: String? β€” identitas Bluetooth Rod yang sedang dipasangkan saat ini, atau nil kalau belum pernah dipasangkan.

Terhubung ke: Dimiliki eksklusif oleh TransportHost, yang membaca identifier/name dan memanggil save/clear pada titik yang tepat dalam siklus hidup koneksi Bluetooth. Cuma memakai UserDefaults dari Foundation.

Catatan menarik: Pemasangan tercatat otomatis pertama kali sebuah koneksi benar-benar berhasil PENUH (berlangganan kedua characteristic notify dan punya characteristic command) β€” tidak ada UI pemasangan khusus; satu-satunya cara melepas pasangan adalah forgetPairedDevice() di TransportHost, ditampilkan ke pemain sebagai "Forget Device" di MainMenuView.

14. Transport/TransportHost.swift

Ini implementasi World untuk koneksi Bluetooth Low Energy ke Rod. World bertindak sebagai BLE central (pihak yang memindai dan menghubungkan ke perangkat lain), mencerminkan apa yang dulunya dibangun pakai framework MultipeerConnectivity lama Apple. Dia memindai layanan yang diiklankan Rod, terhubung, berlangganan data keluar Rod, dan menulis perintah balik.

Tipe yang didefinisikan:

  • TransportHost (final class, NSObject, @MainActor, mengikuti protokol TransportProtocol dari Shared).
  • TransportHost.HostError (enum): .noConnectedPeripheral, .encodingFailed, .missingCharacteristic.
  • TransportHost.QueuedSend (private struct) β€” satu pesan keluar yang sedang berjalan: potongan-potongan (chunks) yang belum terkirim dan CheckedContinuation untuk dilanjutkan begitu terkirim penuh.

Fungsi-fungsi penting:

  • override init() β€” membuat CBCentralManager dengan queue: nil (antrean utama β€” cocok dengan isolasi @MainActor class ini).
  • func connect() async throws β€” mengatur wantsToScan = true, melaporkan .searching, dan memanggil startScanningIfPossible().
  • func disconnect() β€” menghentikan pemindaian, membatalkan koneksi peripheral yang aktif, membongkar state lokal, melaporkan .disconnected.
  • func forgetPairedDevice() β€” membatalkan koneksi saat ini, membongkar state, menghapus pemasangan tersimpan (PairedDeviceStore.clear()), dan langsung mulai memindai lagi (tidak menyentuh wantsToScan, jadi sesi yang sudah aktif langsung mencari Rod BARU).
  • func send(_ message: NetworkMessage) async throws β€” meng-encode pesan jadi JSON dan mengirimkannya lewat sendCommand(_:to:characteristic:); melempar error kalau tidak ada peripheral/characteristic terhubung atau encoding gagal.
  • private func startScanningIfPossible() β€” kalau sudah ada perangkat yang dipasangkan, mencoba dulu centralManager.retrievePeripherals(withIdentifiers:) (membiarkan iOS mengembalikan peripheral yang masih ter-bond/baru terlihat tanpa scan baru); kalau tidak, mulai scanForPeripherals(withServices:) biasa.
  • private func connect(to:) β€” menghentikan pemindaian, melaporkan .connecting, dan memanggil centralManager.connect(peripheral).
  • private func tearDownConnection() β€” menggagalkan semua pengiriman yang mengantre, menghapus referensi characteristic/peripheral, mereset flag langganan, dan mereset kedua reassembler pesan.
  • private func chunkBudget(for:) -> Int β€” ukuran maksimum chunk penulisan BLE untuk peripheral saat ini, dengan batas bawah di BLEFraming.headerSize + 1.
  • private func sendCommand(_:to:characteristic:) async throws β€” memecah data jadi chunk ukuran BLE (lewat BLEFraming.chunks), menambahkan QueuedSend ke antrean, dan β€” kalau itu satu-satunya item β€” mulai mengosongkan antrean. Memakai withCheckedThrowingContinuation supaya panggilan async cuma kembali begitu setiap chunk benar-benar tertulis.
  • private func drainCommandQueue() β€” mengirim persis satu chunk dari pesan paling depan antrean sekaligus (penulisan selesai satu per satu, ditandai oleh delegate didWriteValueFor), berbeda dari jalur notify peripheral yang bisa mengosongkan banyak sekaligus.
  • private func failAllQueuedSends(with:) β€” melanjutkan setiap continuation yang mengantre dengan error yang diberikan (dipakai saat disconnect/pembongkaran).
  • Konformasi CBCentralManagerDelegate: centralManagerDidUpdateState (mulai memindai saat .poweredOn, melaporkan .failed saat .poweredOff/.unauthorized/.unsupported), didDiscover (mengabaikan apa pun yang bukan rodPeripheral == nil atau, setelah dipasangkan, bukan identifier yang dipasangkan β€” kalau tidak, terhubung), didConnect (menemukan layanan mancing), didFailToConnect / didDisconnectPeripheral (membongkar state dan β€” patut dicatat β€” selalu memulai ulang pemindaian sesudahnya supaya rekan yang kembali ke jangkauan terhubung ulang otomatis).
  • Konformasi CBPeripheralDelegate: didDiscoverServices (menemukan tiga characteristic yang dibutuhkan di layanan yang cocok), didDiscoverCharacteristicsFor (berlangganan dua characteristic notify, menyimpan referensi characteristic command), didUpdateNotificationStateFor (begitu kedua langganan notify dan characteristic command semuanya siap, menyimpan pemasangan pada keberhasilan pertama kalinya dan melaporkan .ready), didUpdateValueFor (memasukkan data BLE masuk ke BLEMessageReassembler yang tepat berdasarkan characteristic asalnya, mendecode tiap pesan yang tersusun ulang sebagai NetworkMessage, dan memanggil onMessage?; MENCATAT β€” bukan diam-diam menelan β€” kegagalan decode apa pun, khususnya karena ketidakcocokan skema antara build Rod/World sebelumnya menjatuhkan setiap pesan tanpa sinyal terlihat sama sekali), didWriteValueFor (memajukan antrean kirim saat sukses, atau menggagalkan pengiriman paling depan antrean dan lanjut kalau ada error).

Catatan tambahan: private var centralManager: CBCentralManager!, rodPeripheral: CBPeripheral?, commandCharacteristic: CBCharacteristic? β€” objek Bluetooth yang aktif. private let pairedDeviceStore = PairedDeviceStore(). private let stateReassembler / reliableInReassembler: BLEMessageReassembler β€” menyusun ulang pesan multi-chunk yang datang di dua characteristic notify. private var commandSendQueue: [QueuedSend] β€” antrean pesan keluar. var isConnected: Bool β€” apakah commandCharacteristic ada. var pairedDeviceName: String? / var hasPairedDevice: Bool β€” pass-through ke pairedDeviceStore. var onMessage: ((NetworkMessage) -> Void)? / var onStateChange: ((ConnectionState) -> Void)? β€” closure callback yang dipasang WorldViewModel.

Terhubung ke: Dibuat oleh ContentView.init() dan diberikan ke WorldViewModel. Bergantung ke CoreBluetooth, dan SessionConfiguration dari Shared (untuk UUID layanan/characteristic tetap), BLEFraming (helper chunking), TransportProtocol, NetworkMessage, ConnectionState.

Catatan menarik: Komentar class-nya secara eksplisit membingkai ini sebagai mencerminkan "peran 'host' MultipeerConnectivity sebelumnya," artinya ini migrasi dari teknologi transport lama ke CoreBluetooth. Perubahan pencatatan kegagalan decode (eksplisit #if DEBUG print alih-alih try? yang diam) disebut sebagai memperbaiki kelas bug nyata yang sebelumnya tidak terlihat, di mana ketidakcocokan versi antara model Swift kedua aplikasi membuat koneksi terlihat sehat padahal secara harfiah tidak ada yang berfungsi.

15. ViewModels/WorldViewModel.swift

Ini orkestrator pusat seluruh aplikasi. Dia memiliki mesin state layar paling atas (onboarding/menu/main), menggerakkan tick simulasi per-frame (pose joran, kamera, fisika kail, lempar, reel, cutscene tangkapan), dan menjadi satu-satunya tempat di mana minigame mancing (FishFightController), scene 3D (entity RealityKit), sistem suara (FishingSounds), dan transport jaringan (TransportHost) semua disambungkan bersama. Ini file terbesar di aplikasi (1.374 baris), jauh melampaui yang lain.

Tipe yang didefinisikan:

  • WorldViewModel (final class, ObservableObject, @MainActor).
  • WorldViewModel.Screen (enum): .onboarding, .mainMenu, .playing β€” layar aplikasi paling atas.
  • WorldViewModel.CatchCutscene (private struct): origin: SIMD3<Float>, elapsed: Float = 0 β€” melacak cutscene tangkapan yang sedang berlangsung.

Fungsi-fungsi penting (dikelompokkan berdasarkan tujuan supaya mudah dibaca; semua tercakup):

Siklus hidup / setup

  • init(transport:) β€” menyimpan transport, mengatur screen awal berdasarkan apakah onboarding sudah pernah dilihat (Self.hasSeenOnboarding), dan menyambungkan closure transport.onStateChange/onMessage. onStateChange: memperbarui connectionState; saat .disconnected/.failed memanggil returnToMenu() (jaring pengaman, karena Rod adalah satu-satunya otoritas mulai/selesai sesi lewat RodState.isFishing, dan koneksi yang putus berarti tidak ada lagi update state yang datang untuk mengakhiri sesi secara normal); saat .ready, menghapus lastError dan mengirim ulang hookPhase saat ini (kalau-kalau disconnect sebelumnya membuat Rod tidak pernah dapat update fase dan akan terjebak percaya sesuatu yang basi). onMessage: meneruskan ke handle(_:).
  • func start() β€” dijaga supaya cuma jalan sekali (hasStarted); memberitahu sounds state layar saat ini, lalu secara asinkron memanggil transport.connect().
  • func stop() β€” memutus transport, mereset hasStarted, menghentikan semua suara.
  • func retryConnection() β€” aksi tombol "Retry Connection" yang dilihat pemain: memutus lalu menghubungkan ulang transport, tanpa menyentuh state game lain β€” dijelaskan sebagai memulihkan link Bluetooth yang dilaporkan kadang macet tak-terpulihkan sendiri.
  • var pairedDeviceName: String? / var hasPairedDevice: Bool β€” pass-through ke transport.
  • func forgetPairedDevice() β€” pass-through ke transport.forgetPairedDevice().
  • func resetProgression() β€” pass-through ke fishFight.resetProgression().
  • func completeOnboarding() β€” menyimpan bahwa onboarding sudah dilihat, kembali ke .mainMenu.
  • func showOnboarding() β€” cuma dari .mainMenu, berpindah ke .onboarding (tombol "How to Play").
  • func attachFrameLoop(to:) β€” berlangganan sekali ke event SceneEvents.Update milik scene RealityKit, memanggil advance(deltaTime:) setiap frame yang benar-benar dirender β€” menggantikan loop timer lama berbasis Task.sleep dengan clock terpisah yang melenceng saat beban tinggi.
  • private func advance(deltaTime:) β€” memperbarui penghitung FPS berjalan, lalu (cuma kalau hasStarted) memanggil tick(deltaTime:).
  • private func updateFPS(deltaTime:) β€” mengakumulasi jumlah frame/waktu selama jendela bergulir setengah detik dan menghitung ulang fps darinya, bukannya satu 1/deltaTime yang berisik per frame.

Pembangunan scene

  • func makeSceneEntity() async -> Entity β€” membangun seluruh scene sekali: menambahkan hasil EnvironmentSceneBuilder.build(), membangun dan menambahkan kamera pemain, memuat rig joran (RodRigController.load()), membuat entity kail bola kuning placeholder (dimatikan sampai lemparan pertama), memuat aset showcase "ikan tertangkap" beranimasi (mekong_catfish_caught, selalu spesies ini apa pun tier yang sebenarnya terundi β€” didokumentasikan sebagai keputusan konten yang ditunda sampai lebih banyak spesies diperiksa), dan membangun entity garis cutscene silinder-diregangkan sederhana (solusi sementara karena rod_line.usdz yang di-rig tidak sampai ke posisi kail cutscene).
  • private func makePlayerCamera() -> Entity β€” membangun PerspectiveCamera (FOV vertikal 46.4Β°) dan memposisikannya lewat resetCameraToPlayerPose. Didokumentasikan sebagai perlu karena RealityView di mode "virtual" non-AR tidak punya sudut pandang orang-pertama default sama sekali β€” tanpa kamera eksplisit, dia otomatis membingkai seluruh kotak batas scene dari sudut sembarang, itulah yang menyebabkan bug awal "berdiri di dalam air melihat ke atas ke dermaga."
  • private func resetCameraToPlayerPose(_:) β€” mengarahkan kamera dari playerCameraPosition yang tetap, dimiringkan ke bawah sebesar cameraPitchDownDegrees, menuju vektor depan yang dihitung.
  • private func updateCameraFollow() β€” tiap tick, memutuskan di mana kamera seharusnya berada: selama cutscene tangkapan, dolly halus (eased) di antara dua offset sambil melihat ke titik asal cutscene ditambah ketinggiannya saat ini; selagi kail .flying/.fishOnHook/.reeling, mengikuti di belakang dan di atas kail sambil melihat lurus ke sana (untuk framing dramatis "tonton perkelahian dari dekat"); kalau tidak, langsung kembali ke pose pemain tetap.

Transisi layar/fase

  • func continueFishing() β€” menutup layar hasil yang sedang tampil dan mengembalikan hookPhase ke .idle; cuma valid dari .result.
  • private func returnToMenu() β€” reset lengkap saat sesi berakhir (lewat disconnect atau "End Fishing" milik Rod): mematikan entity kail/ikan/senar, mengosongkan cutscene, mereset pengontrol perkelahian untuk lemparan baru, mengatur hookPhase = .idle, berpindah screen = .mainMenu, memberitahu suara bahwa layar berubah. Secara eksplisit TIDAK memutus transport (supaya memulai ulang tidak perlu menemukan ulang perangkat).
  • private func setHookPhase(_:) β€” satu-satunya tempat hookPhase pernah diubah: dijaga dari pengaturan-ulang tanpa-perubahan, memberitahu sounds.handlePhaseChange, dan secara asinkron mengirim fase baru ke Rod.

Tick utama per-frame

  • private func tick(deltaTime:) β€” jantung simulasi, dipanggil sekali tiap frame yang dirender. Memperbarui pose visual joran dan kamera pengikut, menerapkan tegangan senar/putaran reel ke rig joran, lalu switch berdasarkan hookPhase:
    • .idle: kalau Rod melaporkan .casting dan ada pendingCastSolution yang menunggu, memanggil launchHook(solution:).
    • .flying: memanggil simulateFlyingHook(deltaTime:) (busur balistik).
    • .hookInWater: memanggil fishFight.updateSearching(deltaTime:), bereaksi terhadap .interested (mengeluarkan .fishInterested) atau .bite (berpindah ke .fishOnHook, memanggil ensureFightDistance(), mengeluarkan .fishBite); kalau pemain sedang reel, memanggil reelHook, kalau tidak updateBobberFloat (animasi mengambang idle).
    • .result: kalau cutscene sedang berlangsung, memajukannya lewat updateCatchCutscene.
    • .fishOnHook/.reeling: kalau reel, memanggil reelHook; kalau tidak, driftHookAway lalu menegaskan ulang .fishOnHook. Selalu menerapkan updateFightDirectionDrift, lalu memanggil fishFight.updateFight(...) dan, kalau gagal, resolveFightFailure(_:).
    Akhirnya, setiap tick apa pun fasenya: memperbarui tiga loop suara berkelanjutan (updateReelLoop, updateIdleLoop, updateReelFightLoop) dengan kondisi "apakah loop ini seharusnya main sekarang" yang hidup, dan memanggil syncTensionBand().
  • private func hookDistanceFromRod() -> Float β€” jarak 3D garis-lurus antara akar joran dan kail, dipakai untuk pemeriksaan jarak-escape.
  • private func syncTensionBand() β€” kapan pun fishFight.tensionBand berubah nilai, mengeluarkan FishingEvent yang cocok (.reelTensionLow/Medium/High) ke Rod supaya bisa memulsakan haptic-nya di ritme yang benar tanpa World perlu terus-menerus menstream tegangan mentah.
  • private func resolveFightFailure(_:) β€” saat .escaped atau .lineBreak, mematikan entity kail, mengatur hookPhase balik ke .idle (lemparan baru dibutuhkan β€” kedua jenis kegagalan sekarang diperlakukan sama, sesuai permintaan langsung pemain bahwa escape karena tegangan kendur juga harus langsung reset alih-alih tetap "di air"), dan mengeluarkan event yang cocok.

Helper gerakan kail/pelampung

  • private func updateBobberFloat(deltaTime:) β€” menerapkan bob gelombang sinus idle yang halus ke kail yang mengambang, plus (selagi ikan tertarik) dip "gigitan kecil" yang lebih tajam selalu ke bawah dilapiskan di atasnya β€” foreshadowing visual sebelum gigitan sesungguhnya.
  • private func ensureFightDistance() β€” dipanggil tepat saat ikan menggigit: kalau kail saat ini lebih dekat ke joran dari fishFight.minimumFightDistance, mendorongnya keluar menyusuri arah-menjauh supaya tick reelHook berikutnya tidak langsung menyelesaikan tangkapan sebelum meteran tegangan sempat merender satu frame pun.
  • private func driftHookAway(deltaTime:) β€” menggerakkan kail menjauh dari joran dengan kecepatan fishFight.fishPullSpeed selagi pemain tidak reel; dalam jarak hookLiftDistance dari target angkat, dia hanyut penuh 3D (jadi ikan yang kabur/diabaikan secara visual menarik kail turun kembali ke air saat mundur), kalau tidak dia tetap terpaku di ketinggian air dan cuma bergerak horizontal.
  • private func updateFightDirectionDrift(deltaTime:) β€” dorongan samping murni visual pada kail menuju arah mana pun yang sedang ditunjuk fishFight.fightDirection, dibatasi ke offset lateral maksimum dari posisi X joran sendiri β€” evaluasi sukses/gagal melawan yang sebenarnya terjadi di dalam FishFightController dari roll joran, bukan dari visual ini.

Pose joran

  • private func updateRodPose() β€” mengatur posisi akar rig joran ke dockAnchorPosition yang tetap dan orientasinya langsung dari RodState.orientation (kuaternion yang sudah dikoreksi yang di-stream dari Rod β€” secara eksplisit tidak boleh direkonstruksi dari sudut Euler mentah atau dihaluskan ulang di sini), lalu memanggil rodRig?.applyBend(pitch:roll:) untuk visualisasi bengkok sekunder.
  • private func lineTension(for:) -> Float β€” nilai tegangan tetap berbasis fase yang dipakai cuma untuk rig kendur senar VISUAL (bukan angka tegangan gameplay sebenarnya): 0 selagi idle/result, 0.9 selagi flying, 0.3 selagi di air menunggu, 0.8 selagi berkelahi.

Melempar (casting)

  • private func launchHook(solution:) β€” seluruh urutan peluncuran lemparan. Menjaga bahwa scene sudah benar-benar dibangun (mencatat pesan debug dan berhenti kalau belum). Menempatkan kail di ujung joran. Menurunkan arah bidik horizontal sebenarnya dari GEOMETRI RIG YANG HIDUP (vektor dari akar joran ke posisi dunia ujungnya saat ini) alih-alih mempercayai x/z mentah CastSolution.direction sebagai heading dunia langsung β€” didokumentasikan sebagai perlu karena field itu didefinisikan relatif ke konvensi panah placeholder yang dikodekan langsung yang sudah tidak lagi cocok dengan sumbu rig mesh joran sebenarnya. Menerapkan rotasi koreksi empiris (aimCorrectionAngle = .pi, yaitu 180Β°) yang ditemukan dengan menambahkan pembacaan sudut debug dan mendapati bidikan mentah mendarat hampir persis terbalik dari arah depan. Membatasi arah yang sudah dikoreksi ke kerucut depan selebar 135Β° (clampedToForwardCone) apa pun hasil matematika mentahnya, sesuai keluhan langsung pemain bahwa arah jatuh pelampung terasa tidak dapat diprediksi. Mencatat empat nilai debug (sudut bidik mentah/terkoreksi, koordinat menghadap/mendarat) untuk panel debug di layar (yang sekarang sebagian besar dikomentari). Menghitung kecepatan luncur dari castSpeed dan power ternormalisasi solusi (dengan batas bawah dan skala yang secara eksplisit di-tuning supaya lemparan zona-hijau/lemah tetap melewati jejak dermaga sendiri, bukan mendarat di dalamnya β€” "nembus pier kebawah"). Mengatur hookVelocity, memutar suara lempar, dan berpindah ke .flying.
  • private func rotatedAroundY(_:by:) -> SIMD3<Float> β€” helper rotasi 2D (bidang horizontal) dipakai untuk menerapkan koreksi bidik.
  • private func clampedToForwardCone(_:) -> SIMD3<Float> β€” mengonversi arah horizontal jadi sudut bertanda dari lurus-depan, membatasinya ke Β±castConeHalfAngle, dan membangun ulang vektornya β€” lebih murah daripada slerp kuaternion untuk satu sumbu referensi tetap.
  • private func signedAngleDegrees(_:) -> Float β€” konvensi sudut yang sama dengan cone clamp, dalam derajat, tak-dibatasi, cuma untuk tampilan debug.
  • private func simulateFlyingHook(deltaTime:) β€” menerapkan gravitasi ke hookVelocity, mengintegrasikan posisi kail; begitu melintasi waterHeight, langsung menempel ke permukaan, menolkan kecepatan, menghitung jarak lemparan horizontal sebenarnya yang ditempuh, mencatat koordinat debug mendarat-vs-menghadap, memanggil fishFight.resetForNewCast(castDistance:), mereset fase pelampung, berpindah ke .hookInWater, dan mengeluarkan .hookSplash.

Menarik (reeling)

  • private func reelHook(deltaTime:) β€” logika gerakan reel-in. Kalau kail dalam jarak hookLiftDistance dari hookLiftTargetPosition, penarikan selesai SEKETIKA (langsung menempel ke target angkat alih-alih terus naik pelan yang terlihat): kalau ada ikan terkait, memanggil fishFight.resolveCatch() dan beginCatchCutscene(from:) lalu mengeluarkan .catchFish; kalau tidak, cuma mematikan kail (penarikan kosong). Mengatur hookPhase ke .result atau .idle sesuai. Di luar zona itu, menghitung kecepatan menutup bersih β€” fishFight.reelPullSpeed (kurva risiko/hadiah yang diskalakan tegangan) kalau ada ikan terkait, atau konstanta rata reelSpeed kalau tidak β€” dikurangi fishFight.fishPullSpeed, lalu menggerakkan kail horizontal menuju target angkat sebesar itu (yang bisa NEGATIF, artinya ikan yang kuat masih bisa merebut senar bahkan saat pemain aktif tapi lemah menarik). Cuma menaikkan hookPhase ke .reeling kalau memang ada ikan terkait β€” menarik senar kosong selagi .hookInWater harus tetap .hookInWater.

Cutscene tangkapan

  • private func beginCatchCutscene(from:) β€” memulai seluruh urutan "CATCH!". Kalau entity ikan showcase berhasil dimuat, menyalakan dan memposisikannya, mengatur orientasi "menggantung vertikal" tetapnya (catchCutsceneFishOrientation), menskalakannya supaya cocok dengan panjang tangkapan yang sesungguhnya terundi (dengan batas bawah supaya ikan kecil tidak menyusut jadi nol), menskalakan bola kail supaya cocok juga, memarkir kail di mulut ikan (offset tetap menyusuri sumbu kepala lokalnya, diskalakan), memperbarui garis cutscene supaya menghubungkan kail-ke-ujung-joran, dan memutar klip animasi baked "Caught" milik ikan pada kecepatan yang dihitung (lihat catchCutsceneFishAnimationSpeed di bawah) supaya puncak dramatis yang terautentikasi jatuh di momen yang tepat. Selalu mencatat state CatchCutscene dan mengubah isShowingCatchCutscene = true.
  • private func catchCutsceneHeight(elapsed:) -> Float β€” kurva ketinggian naik/beku/turun bersama (eased sinus naik selama fase naik, ditahan datar selama fase beku, eased cosinus turun lagi selama fase turun), dipakai untuk menggerakkan entity mana pun yang sedang memainkan cutscene dan menjaga titik fokus kamera tetap sinkron.
  • private var catchCutsceneFishOrientation: simd_quatf β€” orientasi "menggantung vertikal, kepala di atas" yang tetap dibangun langsung dari vektor basis target (bukan fungsi fishOrientation(facing:) yang lebih umum di bawah, karena konstruksi fungsi itu berdegenerasi kalau arah menghadap lurus ke atas).
  • private func fishOrientation(facing:) -> simd_quatf β€” fungsi tujuan-umum (didefinisikan tapi, menurut komentar di sekitarnya, efektif digantikan oleh orientasi vertikal tetap di atas untuk cutscene saat ini) yang memutar sumbu kepala lokal ikan supaya menghadap arah dunia sembarang lewat rotasi jalur-terpendek, lalu mengoreksi roll hasilnya supaya sirip punggung ikan mengarah sedekat mungkin ke atas-dunia secara geometris.
  • private func updateCutsceneLine(from:) β€” meregangkan/mengarahkan entity garis silinder sederhana tiap frame supaya membentang dari posisi kail saat ini ke posisi ujung joran yang hidup sebenarnya, mematikan dirinya sendiri dengan anggun kalau belum ada referensi joran atau kedua titik itu terlalu dekat sampai degenerat.
  • private func catchCutsceneTimeDilation(elapsed:) -> Float β€” menghitung pengali slow-motion waktu-nyata-ke-waktu-cerita: kecepatan normal (1) selama fase naik; di dalam fase beku dan, terpisah, di dalam fase turun, melandai turun ke catchCutsceneSlowMotionFactor (0.3Γ—) selama catchCutsceneSlowMotionEaseFraction pertama/terakhir dari jendela fase itu sendiri dan menahan dip-nya di tengah β€” efek "bullet time" yang disengaja pada tangkapan.
  • private func updateCatchCutscene(deltaTime:) β€” memajukan cutscene.elapsed sebesar deltaTime * dilation (bukan deltaTime mentah), menyasar ulang kecepatan playback animasi ikan supaya cocok dengan dilation yang sama tiap frame, menghitung ketinggian/posisi saat ini, menggerakkan entity mana pun yang sedang memainkan cutscene (mengutamakan ikan sungguhan, jatuh balik ke bola kail placeholder kalau aset ikan gagal dimuat) dan menjaga kail/garis tetap terparkir benar relatif terhadapnya. Begitu elapsed mencapai durasi total, semuanya dibongkar (entity dimatikan, state cutscene dikosongkan, isShowingCatchCutscene berbalik jadi false).

Penghubung jaringan

  • private func handle(_ message: NetworkMessage) β€” handler pesan masuk. Cuma bereaksi terhadap pesan .state(RodState) (mengabaikan .discoveryToken/.fishingEvent/.hookPhase, yang cuma-keluar dari sudut pandang World atau tidak relevan di sini). Mencatat rodState yang baru; kalau membawa castSolution segar selagi action == .casting, menyimpannya sebagai pendingCastSolution untuk dikonsumsi tick berikutnya; kalau isCastingArmed, memanggil continueFishing() (otomatis menutup hasil yang sedang tampil tepat saat pemain memasang lemparan baru, menggantikan tombol "Continue Fishing" eksplisit lama); mengatur connectionState = .streaming; dan bereaksi terhadap tepi perubahan isFishing β€” tepi naik berpindah ke .playing dan memberitahu suara bahwa layar berubah, tepi turun memanggil returnToMenu(). Ini mekanisme konkret di balik "Rod memiliki batas mulai/selesai sesi."
  • private func emit(_ event: FishingEvent) β€” corong tunggal untuk setiap event gameplay yang dikeluarkan World: memutar suara yang cocok lewat sounds.handle(event) SEKALIGUS secara asinkron mengirimkannya ke Rod, dari panggilan yang sama, khusus supaya suara dan transmisi jaringan tidak pernah bisa jadi tidak sinkron.
  • private func send(_ message: NetworkMessage) async β€” menunggu transport.send(message), menghapus lastError saat sukses atau mengaturnya jadi deskripsi error saat gagal.

Terhubung ke: Dibuat sekali oleh ContentView.init(). Bergantung ke hampir semua yang lain di aplikasi: EnvironmentSceneBuilder, RodRigController, WaterMaterial (tidak langsung, lewat EnvironmentSceneBuilder), FishFightController, FishingSounds, TransportHost, dan tipe RodState/HookPhase/ConnectionState/FishingEvent/NetworkMessage/CastSolution dari paket Shared. Setiap file View di aplikasi membaca properti publikasinya atau memanggil metodenya.

Catatan menarik: File ini pada dasarnya sebuah log berjalan berisi bug nyata yang ditemukan lewat screenshot langsung dan diperbaiki di tempat, masing-masing dengan paragraf penalarannya sendiri β€” penemuan "sudut koreksi bidik" (bidik mentah terukur di βˆ’174Β°, praktis terbalik, bukan 90Β°-melenceng yang diasumsikan awalnya), perbaikan batas bawah kekuatan-lempar untuk clipping dermaga, pemisahan target-angkat-vs-posisi-jangkar (supaya kail berhenti naik MENEMBUS mesh padat dermaga), rumus kecepatan animasi cutscene yang DITURUNKAN (bukan dipilih manual), dan dip slow-motion "bullet time" dua-fase, semuanya dikerjakan matematisnya langsung di komentar. Beberapa nomor ADR (038, 039, 043, 045, 046, 047, 048, 056, 061, 064, 065, 066, 067, 069, 070, 071, 072) dirujuk berulang di seluruh file, jadi semacam changelog de-facto riwayat iterasi fitur mancing.

16. Views/CalibrationGuidanceView.swift

Menampilkan instruksi langkah-demi-langkah langsung ke pemain untuk mengkalibrasi ponsel-sebagai-joran mereka, bereaksi langsung terhadap langkah apa pun yang sedang dilaporkan Rod saat ini. Dipakai ulang baik untuk kalibrasi pertama kali (dari Main Menu) maupun kalibrasi ulang di tengah sesi kapan pun.

Tipe yang didefinisikan: CalibrationGuidanceView (SwiftUI View).

Fungsi-fungsi penting:

  • private var title: String β€” switch pada step (CalibrationStep, enum dari Shared: .idle/.cast/.reel/.complete) menghasilkan "Step 1 of 3 β€” Idle Pose," "Step 2 of 3 β€” Cast Gesture," "Step 3 of 3 β€” Reel Gesture," atau "Calibration Complete."
  • private var instruction: String β€” teks instruksi bahasa sederhana yang cocok untuk tiap langkah.
  • private var rodButtonLabel: String? β€” label tombol persis yang ditampilkan Rod sendiri untuk langkah saat ini (mis. "Capture Idle Pose"), secara eksplisit dijaga sinkron lewat referensi komentar ke calibrationButtonTitle milik Rod sendiri supaya teks kedua aplikasi tidak bisa diam-diam melenceng; nil begitu .complete (tidak ada tombol lagi untuk ditekan).
  • var body: some View β€” sebuah ikon (ikon scope, atau centang hijau begitu selesai), judul, teks instruksi, dan β€” kalau ada label tombol β€” baris "Press '<label>' on your Rod" yang disorot.

Catatan tambahan: let step: CalibrationStep β€” satu-satunya input, menggerakkan setiap properti terhitung lainnya.

Terhubung ke: Dibuat instance-nya baik oleh MainMenuView (kalibrasi pra-sesi) maupun recalibrationOverlay milik ContentView (kalibrasi ulang tengah-sesi). Bergantung ke CalibrationStep dari Shared.

Catatan menarik: Komentar dokumentasinya secara eksplisit membingkai file ini sebagai menutup celah desain yang sebelumnya ditandai ("Calibration instructions appear entirely in World") yang sudah terbuka sejak dua keputusan arsitektur sebelumnya.

17. Views/CastMeterView.swift

Meteran kekuatan lemparan di layar β€” bar hijau/kuning/merah yang menunjukkan seberapa keras lemparan saat ini terisi, cuma terlihat selama fase gerakan melempar sesungguhnya.

Tipe yang didefinisikan: CastMeterView (SwiftUI View).

Fungsi-fungsi penting:

  • private var isVisible: Bool β€” benar cuma selagi phase (enum RodMotionPhase dari Shared: .invalid/.idle/.backswing/.charging/.forwardSwing/.release/.followThrough/.casting/.reeling) adalah salah satu dari lima fase terkait-lempar.
  • var body: some View β€” kalau terlihat: baris judul ("Cast Meter" + timer "Holding X.Xs" langsung dari holdDuration), bar kekuatan tiga warna (hijau/kuning/merah) dengan marker vertikal tipis diposisikan di power saat ini (0–1) lewat GeometryReader, baris keterangan "Near"/"Far", dan β€” cuma selama .release/.followThrough dan kalau ada castSolution β€” angka power/jarak yang benar-benar sudah terselesaikan; kalau tidak, keterangan "No active cast".

Catatan tambahan: let castSolution: CastSolution?, power: Float, phase: RodMotionPhase, holdDuration: TimeInterval β€” semuanya diberikan dari luar dari WorldViewModel.rodState, tidak ada state lokal.

Terhubung ke: Dibuat instance-nya oleh ContentView, diberi makan langsung dari field viewModel.rodState. Bergantung ke CastSolution/RodMotionPhase dari Shared.

Catatan menarik: Sepertiga bar kekuatan hijau/kuning/merah persis sama dengan pembagian yang dipakai FishFightController.CastZone untuk membatasi ukuran ikan yang terundi β€” komentar dokumentasi di FishFightController merujuk silang view ini dengan namanya untuk menjelaskan kenapa keduanya harus tetap sejajar secara angka walaupun tidak berbagi kode.

18. Views/CatchCutsceneView.swift

Overlay sinematik layar-penuh yang ditampilkan selama jeda singkat antara tangkapan yang terselesaikan dan kartu hasil muncul β€” bar letterbox, cahaya radial, dan judul "CATCH!" beranimasi pegas, sementara animasi lompatan ikan 3D sesungguhnya berjalan di belakangnya di scene RealityKit.

Tipe yang didefinisikan: CatchCutsceneView (SwiftUI View).

Fungsi-fungsi penting:

  • var body: some View β€” melapiskan vignette gradien radial yang meredupkan, bar letterbox hitam atas/bawah (Rectangle yang tingginya beranimasi masuk lewat barHeight), dan lingkaran cahaya di tengah plus teks "CATCH!" (membesar/memudar masuk lewat titleScale/titleOpacity/glowOpacity). Ditandai .allowsHitTesting(false) β€” ini murni dekoratif dan tidak pernah menangkap sentuhan.
  • Closure .onAppear β€” memicu dua animasi SwiftUI terpisah: perluasan ease-out bar letterbox selama 0.25 detik, dan pembesaran/pemudaran-masuk pegas (response 0.45, damping 0.45 β€” "pop" melebih-lebihkan) untuk judul dan cahaya.

Catatan tambahan: Empat nilai penggerak animasi @State (titleScale, titleOpacity, glowOpacity, barHeight) β€” murni presentasi, dianimasikan sekali saat muncul, tidak dibaca file lain mana pun.

Terhubung ke: Ditampilkan oleh ContentView kapan pun viewModel.isShowingCatchCutscene bernilai true. Tidak ada dependensi selain SwiftUI.

Catatan menarik: Komentar dokumentasi secara eksplisit menjelaskan KENAPA begitu banyak polesan presentasi ditambahkan di sini: label "CATCH!" statis saja, bahkan digabung dengan potongan kamera 3D dan freeze slow-motion yang sudah diimplementasikan di WorldViewModel, tetap terasa datar β€” ini dijelaskan sebagai "tuas presentasi-saja yang tersedia sebelum aset ikan/kamera sungguhan datang," yaitu solusi sementara yang disengaja mengingat keterbatasan aset.

19. Views/LevelUpBannerView.swift

Banner toast yang bisa ditutup, muncul di atas layar tepat saat sebuah tangkapan melewati ambang tier progresi (mis. membuka ikan medium, atau Scott muncul).

Tipe yang didefinisikan: LevelUpBannerView (SwiftUI View).

Fungsi-fungsi penting: var body: some View β€” ikon bintang, teks pesan, dan tombol tutup (Γ—), dalam kartu gelap membulat yang dipasang di atas, dibungkus dengan .transition(.move(edge: .top).combined(with: .opacity)) untuk animasi masuk/keluarnya.

Catatan tambahan: let message: String, let onDismiss: () -> Void β€” tidak ada state internal; sepenuhnya dikendalikan oleh parent.

Terhubung ke: Ditampilkan oleh ContentView kapan pun fishFight.levelUpAnnouncement tidak nil; closure onDismiss-nya memanggil viewModel.sounds.uiClick() lalu fishFight.dismissLevelUpAnnouncement().

Catatan menarik: Sengaja tidak punya timer auto-dismiss β€” komentar dokumentasi menjelaskan ini disengaja supaya momen "Scott has appeared" tidak bisa pernah terlewat karena melirik pada detik yang salah; juga secara eksplisit dirancang bisa menumpuk secara visual di atas ResultView karena tangkapan yang memicu naik level tetap merupakan tangkapan normal juga.

20. Views/LineTensionView.swift

Meteran tegangan saat perkelahian β€” elemen HUD inti "jangan biarkan kendur, jangan tarik terlalu kencang" yang ditampilkan cuma selagi ikan sedang aktif diperjuangkan.

Tipe yang didefinisikan: LineTensionView (SwiftUI View).

Fungsi-fungsi penting:

  • private var color: Color β€” merah kalau tension di luar rentang "bahaya" (<0.15 atau >0.85), kuning kalau di luar rentang "aman" yang lebih ketat (<0.3 atau >0.7) tapi masih di dalam bahaya, kalau tidak hijau.
  • private var warningLabel: String? β€” "LOSING FISH" di bawah ambang bahaya-rendah, "SNAPPING" di atas ambang bahaya-tinggi, kalau tidak nil.
  • var body: some View β€” cuma dirender selagi isFighting. Menampilkan header dengan label peringatan opsional, bar kapsul berbasis GeometryReader (pita "zona aman" hijau yang diarsir digambar di bawah isian tegangan berwarna sesungguhnya), baris keterangan "Slack"/"Snap", dan β€” kalau fightDirection tidak nil β€” baris yang menunjukkan arah mana ikan sedang menarik (panah) dan arah mana pemain harus memiringkan joran untuk melawan (panah sebaliknya).

Catatan tambahan: let isFighting: Bool, tension: Float, fightDirection: FishFightController.FightDirection? β€” semuanya diberikan dari luar, tidak ada state lokal. Empat konstanta ambang privat (dangerLow = 0.15, dangerHigh = 0.85, safeLow = 0.3, safeHigh = 0.7) mendefinisikan zona warna.

Terhubung ke: Dibuat instance-nya oleh ContentView, diberi makan dari fishFight.hasFishOnHook/lineTension/fightDirection. Merujuk langsung FishFightController.FightDirection (satu-satunya file View yang mengimpor tipe dari lapisan Gameplay, bukan cuma Shared).

Catatan menarik: Komentar dokumentasi menjelaskan prinsip desain inti secara langsung: "Both ends of the bar are a failure... The safe zone in the middle is shaded so the player can see it, not just infer it from color" β€” pilihan desain aksesibilitas/kejelasan yang eksplisit. Konstanta safeLow/safeHigh/dangerLow/dangerHigh-nya dirujuk secara angka (bukan berbagi kode) dari komentar tuning FishFightConfig sendiri, yang secara eksplisit bilang ambangnya sendiri "dipilih supaya cocok dengan ambang LineTensionView sendiri... walau keduanya tidak berbagi kode, cuma sejajar secara angka."

21. Views/MainMenuView.swift

Menu utama / layar judul World β€” titik masuk sebelum Connect β†’ Calibrate β†’ Ready. Sengaja tidak punya tombol mulai/selesai sesi sendiri; dia cuma pernah MEREFLEKSIKAN status, karena tombol fisik "Start Fishing"/"End Fishing" milik Rod sendiri adalah satu-satunya kontrol batas sesi.

Tipe yang didefinisikan: MainMenuView (SwiftUI View).

Fungsi-fungsi penting:

  • private var isRodReady: Bool β€” benar kalau connectionState adalah .ready atau .streaming.
  • private var statusText: String β€” switch atas setiap kasus ConnectionState menghasilkan baris status yang mudah dibaca manusia ("Searching for Rod...", "Press 'Start Fishing' on Rod to play", "Rod disconnected", "Connection failed", dll).
  • var body: some View β€” latar hitam, judul game ("Catch the Scott" / "A Two-Device Fishing Experience"), lalu entah CalibrationGuidanceView (kalau Rod terhubung tapi belum dikalibrasi) atau baris status (centang kalau siap, kalau tidak spinner) dengan statusText; tombol "Retry Connection" (cuma ditampilkan selagi belum siap); dan blok bawah dengan "How to Play" (membuka ulang onboarding), baris perangkat yang dipasangkan, dan "Reset Progress" (bergaya destruktif).
  • private var pairedDeviceRow: some View (@ViewBuilder) β€” cuma ditampilkan kalau hasPairedDevice: menampilkan Rod mana yang dipasangkan dengan perangkat ini dan tombol "Forget Device".

Catatan tambahan: Semua input adalah nilai let yang diberikan dari luar (connectionState, pairedDeviceName, hasPairedDevice, calibrationStep) plus lima closure (onForgetDevice, onResetProgression, onRetryConnection, onShowOnboarding) β€” sepenuhnya tanpa-state/dikendalikan, cocok dengan pola setiap file View lain di aplikasi ini.

Terhubung ke: Dibuat instance-nya oleh ContentView, disambungkan langsung ke properti/metode WorldViewModel yang cocok. Bergantung ke ConnectionState/CalibrationStep dari Shared, dan langsung menyematkan CalibrationGuidanceView.

Catatan menarik: Komentarnya eksplisit dan tegas soal aturan desain "tidak ada tombol kontrol sesi di sini," mengutip langsung dokumen arsitektur dan menjelaskan persis dua kontrol apa yang MEMANG dimiliki layar ini (pemasangan perangkat, reset progresi) dan kenapa keduanya tidak berhubungan dengan aturan batas-sesi.

22. Views/OnboardingView.swift

Tutorial pertama kali main β€” walkthrough tiga halaman sederhana yang bisa digeser, menjelaskan apa itu game ini, tujuannya, dan cara kerja sistem level/tier. Juga bisa diakses lagi nanti kapan saja lewat "How to Play" di Main Menu.

Tipe yang didefinisikan:

  • OnboardingView (SwiftUI View).
  • OnboardingView.Page (private struct): systemImage, title, body β€” isi satu halaman tutorial.

Fungsi-fungsi penting:

  • var body: some View β€” latar hitam; ikon, judul, dan teks isi halaman saat ini; titik-titik indikator halaman; tombol Back/Next (atau "Play" di halaman terakhir). Dilapiskan (bukan ditumpuk di ZStack yang sama, khusus supaya tidak mewarisi alignment yang juga akan salah memposisikan konten utama yang ditengahkan β€” dijelaskan di komentar) dengan tombol "Skip Intro" di kanan atas.
  • private var isLastPage: Bool β€” pageIndex == pages.count - 1.
  • private var pageDots: some View β€” baris lingkaran kecil, satu per halaman, terisi untuk halaman saat ini.

Catatan tambahan: @State private var pageIndex = 0 β€” satu-satunya state yang bisa berubah; let pages: [Page] β€” tiga halaman tutorial yang dikodekan langsung (intro game, "Your Goal" termasuk mencari Scott, dan "Levels & Fish Tiers" yang menuliskan ambang tangkapan 3/5/10/1 persis yang cocok dengan angka sebenarnya di ProgressionStore).

Terhubung ke: Ditampilkan oleh ContentView kapan pun viewModel.screen == .onboarding; closure onComplete-nya adalah viewModel.completeOnboarding.

Catatan menarik: Sengaja TIDAK menyertakan instruksi kalibrasi β€” komentar dokumentasi menjelaskan ini sengaja diserahkan ke CalibrationGuidanceView yang terpisah dan bisa dipakai ulang, jadi teks panduan yang persis sama juga mencakup kalibrasi ulang nanti, bukan cuma satu kali pertama ini.

23. Views/ResultView.swift

Kartu hasil pasca-tangkapan β€” nama spesies, berat/panjang, rating bintang, payout koin, dan (kalau berlaku) lencana "NEW RECORD". Memblokir scene sampai pemain menekan "Start Casting" di Rod lagi.

Tipe yang didefinisikan: ResultView (SwiftUI View).

Fungsi-fungsi penting:

  • var body: some View β€” latar redup plus card di tengah.
  • private var card: some View β€” judul "Catch!", nama spesies, baris statistik berat/panjang, baris lima bintang, label payout koin, lencana "NEW RECORD" opsional, dan baris instruksi ("Press 'Start Casting' on Rod to fish again").
  • private var starsRow: some View β€” lima ikon bintang, terisi sampai result.stars.
  • private func stat(_:_:) -> some View β€” kolom label+nilai kecil (dipakai baik untuk berat maupun panjang).

Catatan tambahan: let result: FishCatchResult β€” satu-satunya input; tidak ada state lokal.

Terhubung ke: Ditampilkan oleh ContentView kapan pun hookPhase == .result dan cutscene sudah selesai dan fishFight.lastCatchResult ada. Bergantung ke Gameplay/FishCatchResult.swift.

Catatan menarik: Komentar dokumentasi secara eksplisit menjelaskan KENAPA sengaja tidak ada tombol tutup dan tombol "Return to Menu" di sini β€” kedua aksi itu sekarang murni tanggung jawab Rod (memasang lemparan baru otomatis menutup ini lewat WorldViewModel.handle(_:) yang bereaksi terhadap RodState.isCastingArmed; mengakhiri sesi cuma lewat tombol "End Fishing" milik Rod), dan tombol dari sisi World yang melakukan salah satunya cuma akan diam-diam ditimpa balik oleh sinkronisasi state berikutnya dari Rod.

Alur Data Satu Putaran Permainan

Bagian ini melacak satu sesi main lengkap lewat kode sungguhan, dari aplikasi dibuka sampai melihat hasil ikan yang tertangkap, menyebutkan fungsi/tipe nyata di tiap langkah.

1. Aplikasi dibuka. WorldApp (titik masuk @main) membuka satu WindowGroup berisi ContentView(). ContentView.init() membangun TransportHost dan memberikannya ke WorldViewModel baru. Di WorldViewModel.init(transport:), screen diatur ke .onboarding atau .mainMenu tergantung apakah UserDefaults sudah punya world.hasSeenOnboarding (Self.hasSeenOnboarding), dan closure transport.onStateChange/onMessage disambungkan untuk memperbarui connectionState dan meneruskan pesan Bluetooth masuk ke handle(_:). ContentView.body's content membangun RealityView RealityKit; pada closure make-nya, viewModel.makeSceneEntity() (async) membangun seluruh scene 3D sekali lewat EnvironmentSceneBuilder.build() (danau, dermaga, langit, matahari, pohon/batu/foliase tersebar, shader air dari WaterMaterial.apply), kamera pemain (makePlayerCamera), rig joran (RodRigController.load()), bola kail placeholder, dan entity ikan showcase "tertangkap." viewModel.attachFrameLoop(to:) berlangganan ke SceneEvents.Update RealityKit, jadi tiap frame yang dirender memanggil WorldViewModel.advance(deltaTime:) β†’ tick(deltaTime:) dari sini seterusnya. .onAppear milik ContentView.content memanggil viewModel.start(), yang memanggil transport.connect() secara asinkron β€” TransportHost mulai memindai Rod lewat Bluetooth.

2. Onboarding (cuma main pertama kali). Kalau screen == .onboarding, ContentView menampilkan OnboardingView, walkthrough tiga halaman (intro game, teaser tujuan/Scott, ambang unlock tier). Menekan "Play" di halaman terakhir memanggil viewModel.completeOnboarding(), yang menyimpan world.hasSeenOnboarding = true dan mengatur screen = .mainMenu.

3. Menghubungkan dan mengkalibrasi. MainMenuView ditampilkan selagi screen == .mainMenu, merefleksikan connectionState (lewat callback CBCentralManagerDelegate/CBPeripheralDelegate milik TransportHost yang menggerakkan onStateChange) β€” menampilkan "Searching for Rod...", lalu "Connecting...", lalu (begitu layanan/characteristic BLE sudah sepenuhnya berlangganan di TransportHost.peripheral(_:didUpdateNotificationStateFor:...)) .ready. Kalau joran belum dikalibrasi (RodState.calibrationStep != .complete, di-stream terus-menerus dari Rod di dalam tiap snapshot RodState), MainMenuView menampilkan CalibrationGuidanceView yang hidup dan reaktif-langkah alih-alih baris status normal, melacak progres kalibrasi Rod sendiri (pose idle β†’ gerakan lempar β†’ gerakan reel β†’ selesai) secara real-time.

4. Memulai sesi. Rod adalah satu-satunya otoritas kapan sesi dimulai (keputusan desain terdokumentasi, "ADR-038"): pemain menekan "Start Fishing" di aplikasi Rod, yang mengatur RodState.isFishing = true dan menstreamnya lewat BLE. TransportHost mendecodenya sebagai NetworkMessage .state(RodState) dan memanggil onMessage, yang diteruskan ke WorldViewModel.handle(_:). Di sana, state.isFishing yang naik dari false ke true (diperiksa terhadap rodState.isFishing sebelumnya) mengatur screen = .playing dan memanggil sounds.handleScreenChange(isPlaying: true). ContentView sekarang menampilkan HUD lengkap (LineTensionView, GroupBox jumlah-tangkapan/level, CastMeterView) alih-alih menu.

5. Melempar. Saat pemain benar-benar mengayunkan ponselnya, penerjemah gerakan Rod sendiri menghitung RodMotionPhase (backswing β†’ charging β†’ forwardSwing β†’ release β†’ followThrough) dan, saat rilis, sebuah CastSolution (arah/kekuatan/sudut luncur), di-stream di dalam RodState. CastMeterView merender bar kekuatan pengisian yang hidup dari rodState.castPower/castHoldDuration. Ketika WorldViewModel.handle(_:) melihat state.action == .casting dengan castSolution yang segar, dia menyimpannya sebagai pendingCastSolution. Panggilan tick(deltaTime:) berikutnya, selagi hookPhase == .idle, melihat aksi dan fase yang menunggu itu lalu memanggil launchHook(solution:): ini menurunkan arah bidik sebenarnya dari posisi ujung rig joran yang hidup (bukan CastSolution.direction mentah, yang didokumentasikan sebagai memakai konvensi mesh-placeholder yang sudah basi), menerapkan koreksi 180Β° yang ditemukan secara empiris, membatasinya ke kerucut depan selebar 135Β°, menghitung kecepatan luncur dari kekuatan lemparan, mengatur hookVelocity, memutar suara lempar (sounds.castLaunched()), dan berpindah hookPhase ke .flying. Tiap tick berikutnya selagi .flying, simulateFlyingHook(deltaTime:) mengintegrasikan gravitasi dan posisi sampai kail melintasi bidang air, di titik itu langsung menempel ke permukaan, menghitung jarak lemparan sebenarnya yang ditempuh, memanggil fishFight.resetForNewCast(castDistance:) (yang juga menentukan castZone yang membatasi ukuran ikan apa yang bisa terundi nanti), berpindah ke .hookInWater, dan mengeluarkan .hookSplash (memutar suara cebur dan mengirim event ke Rod).

6. Menunggu gigitan. Selagi hookPhase == .hookInWater, tiap tick memanggil fishFight.updateSearching(deltaTime:). Sebuah timer acak pertama-tama mengubah isFishInterested = true (WorldViewModel bereaksi dengan mengeluarkan .fishInterested, yang memulai loop suara "tertarik" halus di World dan, di Rod, denyut haptic periodik pelan) β€” bob visual kail juga dapat dip "gigitan kecil" yang lebih tajam ke bawah lewat updateBobberFloat. Timer acak kedua lalu menyelesaikan gigitan sesungguhnya: FishCatalog.randomSpecies(unlockedTiers:) (dibatasi ProgressionStore.unlockedTiers, yaitu level pemain saat ini) memilih spesies, FishFightController mengundi berat/panjangnya (dibatasi castZone tempat lemparan jatuh, sesuai sizeRollWindow), mengatur hasFishOnHook = true dan memulai sub-fase juking kiri/kanan, lalu mengembalikan .bite. WorldViewModel bereaksi dengan berpindah hookPhase ke .fishOnHook, memanggil ensureFightDistance() (supaya pemain yang sudah sedang reel tidak langsung dapat tangkapan instan), dan mengeluarkan .fishBite.

7. Perkelahian. Selagi hookPhase adalah .fishOnHook/.reeling, tiap tick: kalau pemain sedang reel (sesuai rodState.action == .reeling), reelHook(deltaTime:) menggerakkan kail menuju joran dengan kecepatan yang diturunkan dari fishFight.reelPullSpeed (kurva risiko/hadiah yang diskalakan tegangan β€” lebih cepat mendekati zona bahaya-putus) dikurangi fishFight.fishPullSpeed; kalau tidak reel, driftHookAway(deltaTime:) membiarkan ikan secara visual menyeret kail lebih jauh. updateFightDirectionDrift melapiskan dorongan samping visual yang cocok dengan fightDirection juking saat ini. fishFight.updateFight(deltaTime:isReeling:reelSpeed:hookDistance:rodRoll:) adalah resolver gameplay sesungguhnya: dia memeriksa kondisi escape-jarak dulu, lalu memperbarui lineTension dari tarikan reel, laju tarikan ikan, dan apakah pemain melawan arah juking dengan benar lewat roll joran (dibaca dari RodState.roll). Kalau tegangan menyentuh 0, ikan kabur; di 1, senar putus β€” kedua kasus itu WorldViewModel.resolveFightFailure(_:) mematikan kail, mereset hookPhase ke .idle, dan mengeluarkan event yang cocok (.fishEscaped/.lineBreak), lalu pemain harus melempar dari awal lagi. Sepanjang perkelahian, LineTensionView menampilkan bar tegangan yang hidup dengan zona aman diarsir, dan syncTensionBand() mengeluarkan .reelTensionLow/Medium/High ke Rod kapan pun fishFight.tensionBand melewati ambang, menggerakkan ritme haptic Rod sendiri.

8. Mendaratkan tangkapan. Kalau pemain berhasil menarik kail sampai ke hookLiftTargetPosition (dalam jarak hookLiftDistance) selagi ikan masih terkait, reelHook memanggil fishFight.resolveCatch() (menambah caughtCount, menyelesaikan bintang/koin/rekor-baru lewat FishRecordStore, dan mungkin naik level lewat ProgressionStore.recordCatch(tier:) β€” kalau ya, FishFightController.levelUpAnnouncement diatur, nanti ditampilkan LevelUpBannerView), lalu beginCatchCutscene(from:), dan mengeluarkan .catchFish. hookPhase diatur ke .result.

9. Cutscene tangkapan. Selagi isShowingCatchCutscene bernilai true, ContentView menampilkan CatchCutsceneView (bar letterbox, cahaya, judul "CATCH!" beranimasi pegas) di atas scene RealityKit, dan menyembunyikan blok statistik HUD rutin. Di belakangnya, WorldViewModel.updateCatchCutscene(deltaTime:) menggerakkan entity ikan showcase (mekong_catfish_caught, diskalakan sesuai panjang tangkapan yang sungguhan terundi) lewat lompatan naik β†’ beku β†’ turun, menerapkan dip "bullet time" slow-motion selama fase beku/turun (catchCutsceneTimeDilation) yang juga menyasar ulang kecepatan playback klip animasi baked ikan supaya puncak dramatis yang terautentikasi jatuh tepat di titik tengah fase beku. Kamera (updateCameraFollow) dolly perlahan mendekati ikan/kail sepanjang waktu itu. Begitu durasi total cutscene habis, semuanya dimatikan dan isShowingCatchCutscene berbalik jadi false.

10. Hasil / showcase. Dengan cutscene selesai dan hookPhase == .result, ContentView menampilkan ResultView, membaca fishFight.lastCatchResult (spesies, berat, panjang, rating bintang, koin, flag rekor-baru). Tidak ada tombol tutup di kartu ini secara sengaja β€” pemain harus menekan "Start Casting" di Rod lagi, yang mengatur RodState.isCastingArmed = true; WorldViewModel.handle(_:) melihat ini dan memanggil continueFishing(), yang (karena hookPhase == .result) memanggil setHookPhase(.idle), menutup hasil dan mengembalikan alur ke langkah 5 (melempar) untuk ikan berikutnya.

11. Mengakhiri sesi. Kapan pun, pemain bisa menekan "End Fishing" di Rod (mengatur RodState.isFishing = false) atau link Bluetooth bisa putus. Kedua cara itu membuat WorldViewModel memanggil returnToMenu(): setiap entity dimatikan, state perkelahian/cutscene direset penuh, hookPhase kembali ke .idle, dan screen kembali ke .mainMenu β€” mendarat kembali di langkah 3, siap terhubung ulang/kalibrasi ulang/mulai lagi tanpa memuat ulang scene 3D dari awal.