Bab 8: Kode Aplikasi "Rod" & Paket "Shared"

Bab ini membahas kode Swift dari dua bagian repo CtSPoC: aplikasi Rod dan paket Shared. Aplikasi World (perender 3D di RealityKit) tidak dibahas di sini kecuali saat kode Rod/Shared berkomunikasi dengannya.

Rod adalah aplikasi iPhone terpisah. Pemain memegang HP seperti memegang joran pancing sungguhan. Tugas Rod ada empat: (1) membaca sensor gerak HP (kemiringan, putaran, sentakan ke depan), (2) mengubah gerakan mentah itu menjadi makna permainan ("ini lemparan/casting", "ini menggulung/reel"), (3) mengirim makna itu lewat Bluetooth ke aplikasi World yang jalan di Mac/iPad, dan (4) memberi umpan balik getar (haptic) ke tangan pemain saat ada kejadian di dalam game. Rod sama sekali tidak merender adegan 3D β€” ia cuma pengendali dengan satu layar SwiftUI yang sangat sederhana.

Paket Shared adalah kumpulan kode dan tipe data yang dipakai bersama oleh Rod dan World β€” termasuk protokol Bluetooth, model data gameplay, dan logika inti klasifikasi gerakan. Paket ini sengaja tidak mengimpor framework khusus platform seperti CoreBluetooth atau CoreMotion, supaya bisa dipakai oleh kedua aplikasi dan menjamin keduanya selalu sepakat soal bentuk data dan aturan protokol yang sama persis.

Bagian A β€” Aplikasi Rod

1. RodApp.swift (Rod/Rod/RodApp.swift)

Ini titik masuk aplikasi β€” kode pertama yang jalan saat Rod dibuka. Tugasnya cuma satu: merakit semua komponen (pembaca gerak, penerjemah gerak, pembantu Bluetooth/deteksi jarak, pengirim jaringan, haptic) lalu menyambungkannya jadi satu RodViewModel, kemudian menampilkan satu layar (ContentView).

Tipe yang didefinisikan: RodApp (struct yang mengikuti protokol App dari SwiftUI) β€” titik masuk @main seluruh aplikasi.

Fungsi-fungsi penting: init() berjalan sekali saat aplikasi dibuka. Ia membuat satu instance masing-masing dari MotionManager, RodMotionInterpreter, NearbyInteractionManager, TransportClient, dan FishingHaptics, lalu menyerahkan kelimanya ke RodViewModel baru yang disimpan sebagai @StateObject. Ini "manual dependency injection" β€” cuma bikin objek lalu diserahkan, tidak ada logika lain. var body: some Scene mengembalikan WindowGroup berisi ContentView(viewModel: viewModel) β€” inilah yang benar-benar ditampilkan SwiftUI.

Terhubung ke: Membangun MotionManager, RodMotionInterpreter, NearbyInteractionManager, TransportClient, FishingHaptics, RodViewModel, dan menampilkan ContentView.

Catatan menarik: Tidak ada β€” file ini sengaja dibuat sangat kecil, hanya berisi perakitan (wiring), tidak ada logika sendiri.

2. ContentView.swift (Rod/Rod/ContentView.swift)

Satu-satunya layar yang terlihat di aplikasi Rod. Menampilkan judul, status koneksi, tombol Start/End Fishing, tombol Start Casting, instruksi kalibrasi, teks error, dan baris "lupakan Mac yang sudah dipasangkan". Ada juga panel debug besar "Developer Details" di dalam kode, tapi saat ini dikomentari (di-comment out) sehingga tidak tampil ke pemain biasa.

Tipe yang didefinisikan: ContentView (SwiftUI View) β€” satu-satunya layar aplikasi.

Fungsi-fungsi penting:

var body: some View menyusun tampilan: judul + teks status koneksi, tombol fishing, tombol casting (kalau isFishing aktif), teks instruksi kalibrasi (sampai calibrationStep bernilai .complete), pesan error (kalau ada), baris perangkat terpasang (kalau ada), dan tombol toolbar untuk kalibrasi/kalibrasi ulang. Ada juga .onAppear { viewModel.start() } dan .onDisappear { viewModel.stop() } β€” artinya layar yang muncul di layar itulah yang benar-benar menyalakan sensor/Bluetooth, dan layar yang hilang itulah yang mematikannya.

private var pairedDeviceRow: some View menampilkan "Paired with <shortID>" dan tombol merah "Forget Paired Mac". Baris ini dibuat supaya pemasangan Bluetooth yang basi (misalnya setelah aplikasi World diinstal ulang di Mac lain) bisa dihapus dari UI Rod β€” sebelum baris ini ada, fungsi RodViewModel.forgetPairedDevice() memang sudah ada tapi tidak ada tombol yang memanggilnya, jadi kalau sudah nyangkut, satu-satunya cara lepas adalah hapus data aplikasi.

private var fishingButton: some View adalah satu tombol yang label dan aksinya berubah tergantung viewModel.isFishing: menampilkan "Start Fishing" (memanggil startFishing()) saat belum memancing, atau "End Fishing" (memanggil endFishing()) saat sedang memancing. Sengaja dibuat satu tombol yang bertukar, bukan dua tombol terpisah yang selalu tampil β€” komentar kode mengutip permintaan pemain langsung (kira-kira: "jangan Start dan End Fishing sekaligus, cukup Start saja, lalu saat sudah memancing baru muncul tombol End Fishing").

private var startCastingButton: some View adalah tombol "Start Casting" / "Casting Armed…". Labelnya berubah sesuai viewModel.isCastingArmed. Tombol ini nonaktif kecuali: fase hook (hookPhase) sedang .idle atau .result, casting belum "armed" (siap tembak), dan kalibrasi sudah selesai. Memanggil viewModel.startCasting().

private var developerDetails: some View (saat ini tidak dipakai karena pemanggilnya dikomentari) adalah panel diagnostik besar: baris status koneksi/nearby-interaction/motion/pose/fase/ fishing, langkah kalibrasi + jumlah sampel, nilai-nilai state rod (aksi, kecepatan reel, rentang kalibrasi, jarak, arah, pitch/roll/yaw, akselerasi/rotasi mentah, timestamp), dan menyisipkan MotionDebugView untuk melihat isi internal classifier cast/reel secara langsung.

Fungsi bantu lain: private func statusRow(_ title:, value:) (baris kecil, huruf awal value dikapitalkan, untuk baris status developer-details); private func valueRow(_ title:, value:) (sama tapi tidak dikapitalkan, untuk nilai numerik/mentah); private func format(_ value: Double) -> String dan versi Float (memformat angka jadi 3 angka desimal); private func format(_ value: Float?, suffix:) -> String (sama, tapi mengembalikan "--" kalau nil, dipakai untuk jarak opsional); private func formatDirection(_ state: RodState) -> String (memformat 3 komponen arah x, y, z dari Nearby Interaction jadi "x, y, z" dengan 2 desimal, atau "--" kalau ada yang kosong).

private var calibrationInstruction: String mengembalikan teks instruksi manusia sesuai calibrationStep saat ini ("Hold the rod naturally...", "Perform one natural forward cast...", "Reel naturally for a moment...", "Motion calibration complete."). private var calibrationButtonTitle: String mengembalikan label tombol toolbar sesuai langkah saat ini ("Capture Idle Pose" / "Capture Cast Gesture" / "Capture Reel Gesture" / "Recalibrate Motion").

Terhubung ke: Membaca hampir semua properti published dari RodViewModel; menampilkan field-field RodState; menyisipkan MotionDebugView (yang membaca viewModel.debugRecorder).

Catatan menarik: Tombol kalibrasi di toolbar hanya aktif kalau hasMotionSample bernilai true DAN hookPhase == .idle β€” komentar kode menyebut ini "ADR-064": dulu kalibrasi ulang cuma dibatasi oleh "belum mulai memancing," sekarang diperbolehkan kapan saja asal hook tidak sedang mid-cast/berjuang. Kondisi nonaktif tombol "Start Casting" sengaja mengizinkan "arming" saat hookPhase == .result juga (bukan cuma .idle) β€” disebut "ADR-061" β€” ini menggantikan fungsi tombol lama "Continue Fishing" yang dulu ada di sisi World; menekan "Start Casting" saat layar hasil (result) sedang tampil itulah yang memberi tahu World untuk menutup layar hasil tersebut. Panel DeveloperDetails (DisclosureGroup) sepenuhnya dikomentari di source β€” kode yang membangunnya (developerDetails) masih ada dan tetap bisa dikompilasi, cuma tidak dipanggil dari body.

3. FishingHaptics.swift (Rod/Rod/Haptics/FishingHaptics.swift)

Ini "otak" haptic level gameplay. Ia menerima event permainan level-tinggi dan state permainan yang terus-menerus (band tension saat berjuang, kecepatan reel), lalu memutuskan kapan dan bagaimana menyalakan mesin getar HP (lewat HapticManager), termasuk menahan (throttle) getaran berulang supaya tidak menyala terlalu sering.

Tipe yang didefinisikan: FishingHaptics (final class) β€” lapisan pengambil keputusan haptic. FishingHaptics.FightBand (enum bertingkat: .low, .medium, .high) β€” tingkat "ritme" ketegangan senar saat ini selama perjuangan menangkap ikan, mencerminkan band yang dihitung World di FishFightController (sesuai komentar kode, "ADR-039").

Fungsi-fungsi penting: init(haptics:, config:) menyimpan pemutar haptic level-rendah dan config tuning bersama (interval/intensitas/sharpness per band datang dari FishFightConfig, bukan hardcode di sini). private func parameters(for band: FightBand) -> (interval, intensity, sharpness) mencari tiga angka tuning untuk band tertentu dari config (mis. .low -> config.lowBandInterval, dst.). Dipanggil oleh fightPulse(now:).

func handle(_ event: FishingEvent) adalah saklar utama event. Untuk setiap kasus FishingEvent ia melakukan hal berbeda: .cast β€” bersihkan fight band, mainkan haptics.cast(); .hookSplash β€” bersihkan fight band, mainkan haptics.splash(); .reelTick β€” cuma mainkan haptics.reelTick() (pembatasan frekuensinya dilakukan di tempat lain, oleh reelTick(reelSpeed:now:) di bawah, bukan di sini); .fishInterested β€” set isFishInterested = true, reset timer pulsa minat, langsung mainkan satu haptics.fishInterested(); .fishBite β€” bersihkan isFishInterested, mainkan haptics.fishBite(); .fishEscaped β€” bersihkan minat + fight band, mainkan haptics.fishEscaped(); .lineBreak β€” bersihkan minat + fight band, mainkan haptics.lineBreak(); .catchFish β€” bersihkan minat + fight band, mainkan haptics.catchFish(); .reelTensionLow / .reelTensionMedium / .reelTensionHigh β€” bersihkan isFishInterested dan cuma set fightBand ke nilai yang sesuai (tidak langsung memainkan apa pun β€” getaran berulangnya adalah tugas fightPulse(now:)). Fungsi ini dipanggil setiap kali RodViewModel menerima pesan jaringan .fishingEvent dari World.

func reelTick(reelSpeed:, now:) dipanggil di setiap sampel gerak selagi pemain menggulung reel. Menghitung interval max(0.06, 0.25 - reelSpeed * 0.16) β€” makin cepat menggulung, makin pendek jaraknya antar getaran (batas bawah 0.06 detik) β€” dan cuma benar-benar menggetarkan kalau waktu yang cukup sudah lewat sejak lastReelTick. Inilah yang membuat gulungan reel terasa seperti "dengung" yang skalanya mengikuti kecepatan, bukan satu getar per sampel gerak (yang akan terasa terlalu cepat/konstan).

func interestPulse(now:) dipanggil di setiap sampel gerak. Tidak melakukan apa-apa kecuali isFishInterested bernilai true, dan bahkan saat true, cuma menyala sekali setiap interestPulseInterval (1.1 detik) β€” pulsa lambat dan berjarak yang memberi tahu pemain "ada sesuatu di dekat sini, tapi belum menggigit."

func fightPulse(now:) dipanggil di setiap sampel gerak. Tidak melakukan apa-apa kecuali ada fightBand yang sedang aktif. Mencari (interval, intensity, sharpness) band tersebut lewat parameters(for:) dan menyalakan haptics.fightTick(...) begitu interval tersebut lewat sejak getaran fight terakhir β€” inilah ritme "tarik-menarik" berulang selama perjuangan ikan (dari jarang -> teratur -> cepat seiring tension naik).

func stopInterest() membersihkan isFishInterested. Dipanggil setiap kali HookPhase keluar dari .hookInWater, supaya getaran "ikan tertarik" yang basi tidak terus berdenyut setelah ikan menggigit, kabur, atau lemparan baru dimulai. func stopFight() membersihkan fightBand. Dipanggil setiap kali HookPhase keluar dari fase perjuangan (.fishOnHook/.reeling), sebagai jaring pengaman tambahan di samping event-event penutup perjuangan di atas (yang juga membersihkannya), supaya getaran band tension yang basi tidak pernah bertahan melewati akhir perjuangan.

Terhubung ke: Memakai HapticManager (di bawah) untuk benar-benar menggetarkan; membaca FishFightConfig (Shared); memakai nilai FishingEvent (Shared); dikendalikan oleh RodViewModel (dipanggil dari RodViewModel.handle(_:) untuk event, dan dari RodViewModel.updateAction() setiap sampel gerak untuk metode pulsanya).

Catatan menarik: Komentar dokumentasi pada FightBand secara eksplisit mengatakan tipe ini dimaksudkan "senada" dengan FishFightController milik WorldViewModel meski keduanya tipe berbeda di aplikasi berbeda β€” keduanya harus dibangun dengan nilai FishFightConfig yang sama persis, kalau tidak, ritme haptic dan perhitungan tension sesungguhnya bisa diam-diam melenceng satu sama lain.

4. HapticManager.swift (Rod/Rod/Haptics/HapticManager.swift)

Ini pembungkus haptic level paling bawah β€” satu-satunya file di Rod yang benar-benar bicara langsung ke mesin CoreHaptics Apple. Setiap metode di sini adalah panggilan "mainkan pola ini"; file ini tidak tahu apa-apa soal state permainan, ikan, atau band β€” logika itu ada satu lapis di atas, di FishingHaptics.

Tipe yang didefinisikan: HapticManager (final class) β€” pembungkus tipis untuk CHHapticEngine.

Fungsi-fungsi penting: init() memeriksa CHHapticEngine.capabilitiesForHardware().supportsHaptics; kalau perangkat sama sekali tidak bisa getar, engine dibiarkan nil dan setiap panggilan berikutnya diam- diam tidak melakukan apa-apa. Kalau bisa, membuat CHHapticEngine, mengatur stoppedHandler kosong (supaya mesin berhenti tidak menyebabkan crash, cuma diabaikan), lalu menyalakannya. Kalau gagal nyala, engine tetap nil.

Metode pemutar pola: func cast() β€” satu ketuk transient, intensitas 1.0, sharpness 0.5, dipanggil saat lemparan terjadi. func splash() β€” satu ketuk transient, intensitas 1.0, sharpness 0.15 (terasa lebih tumpul/lembut dari cast), dipanggil saat hook menyentuh air. func reelTick() β€” satu ketuk transient, intensitas 1.0, sharpness 0.75 (lebih tajam/renyah), dipanggil berulang (lewat FishingHaptics.reelTick) selagi menggulung. func fishBite() β€” pola 4 ketuk pada waktu tertentu (0, 0.18, 0.28, 0.55 detik), intensitas/sharpness bervariasi β€” ritme "gigitan" khas yang harus langsung dikenali pemain. func catchFish() β€” pola 2 ketuk perayaan (0 detik kuat, 0.18 detik susulan). func lineBreak() β€” satu ketuk transient tajam dan keras (sharpness 1) menandakan senar putus. func fishEscaped() β€” pola 3 ketuk yang melemah menandakan ikan kabur. func fishInterested() β€” satu ketuk transient lembut (sharpness 0.2), dimaksudkan lebih tumpul dari fishBite(). Komentar kode ("ADR-060") mencatat bahwa ketumpulan sekarang murni datang dari sharpness karena intensitas selalu maksimal 1.0 di mana-mana. func fightTick(intensity:, sharpness:) β€” satu ketuk transient yang intensitas/sharpness-nya diberikan pemanggil (FishingHaptics.fightPulse, yang memutuskan nilai per band) β€” metode ini sendiri tidak tahu soal band.

private func playTransient(intensity:, sharpness:) β€” pembungkus satu pasang nilai jadi pola satu item, memanggil playPattern. private func playPattern(_ pulses: [(Float, Float, TimeInterval)]) β€” panggilan mesin sesungguhnya: untuk setiap tuple (intensity, sharpness, relativeTime) dibangun CHHapticEvent, dirakit jadi CHHapticPattern, dibuat pemutarnya, lalu dijalankan dari waktu 0. Menyalakan ulang mesin secara defensif (try engine.start()) sebelum memainkan setiap kali, karena CHHapticEngine bisa berhenti sendiri di background. Error yang dilempar diam-diam ditelan (return di dalam catch) β€” gagal memainkan haptic tidak dianggap layak diberitahukan atau dicatat log.

Terhubung ke: Hanya dipakai oleh FishingHaptics. Tidak ada file lain di Rod yang bicara langsung ke CoreHaptics.

Catatan menarik: Tidak ada logging/pemberitahuan error di mana pun β€” setiap jalur kegagalan (hardware tak didukung, mesin gagal nyala, gagal bangun pola) memang sengaja dibuat diam- diam. Ini pilihan wajar untuk haptic (getaran gagal seharusnya tidak pernah membuat crash atau mengganggu gameplay), tapi artinya mesin haptic yang rusak sama sekali tidak memberi sinyal diagnostik.

5. MotionManager.swift (Rod/Rod/Sensors/MotionManager.swift)

File ini membungkus API CoreMotion Apple, mengubah update sensor mentah menjadi struct sederhana MotionSample pada laju tetap 30 Hz, lalu memanggil balik (callback) closure setiap ada sampel baru. Ini tahap paling pertama dari seluruh alur gerak β€” secara harfiah membaca giroskop/akselerometer HP.

Tipe yang didefinisikan: MotionManager (final class, @MainActor) β€” pemilik CMMotionManager dan penyiar ulang update-nya. MotionSample (struct) β€” potret sederhana satu pembacaan gerak: timestamp, pitch, roll, yaw, accelerationZ, rotationRateMagnitude.

Fungsi-fungsi penting: var isAvailable: Bool β€” meneruskan manager.isDeviceMotionAvailable; dipakai RodViewModel saat mulai untuk mengatur motionAvailable. func start() β€” memastikan device motion tersedia, mengatur interval update ke 1/30 detik (30 Hz), lalu memanggil manager.startDeviceMotionUpdates(to: .main) { ... }. Di dalam callback: mengambil motion.attitude.pitch/roll/yaw, motion.userAcceleration.z (akselerasi vertikal/maju setelah gravitasi dihilangkan), dan menghitung rotationRateMagnitude sebagai magnitudo 3D (sqrt(xΒ²+yΒ²+zΒ²)) dari motion.rotationRate β€” yaitu seberapa cepat HP berputar secara keseluruhan, tanpa peduli sumbunya. Semua itu dibungkus jadi MotionSample dan memanggil onUpdate?(...). Dipanggil sekali saat RodViewModel.start() jalan (dipicu oleh ContentView.onAppear). func stop() β€” memanggil manager.stopDeviceMotionUpdates(). Dipanggil dari RodViewModel.stop() (dipicu oleh ContentView.onDisappear).

Terhubung ke: Dibangun di RodApp.init(); dipakai sepenuhnya oleh RodViewModel, yang menyimpan setiap MotionSample dan mengalirkannya ke RodMotionInterpreter (Shared) lewat updateAction().

Catatan menarik: 30 Hz (1.0/30.0) adalah angka ajaib hardcode β€” seluruh alur gerak gameplay (klasifikasi cast/reel, pembatasan haptic, streaming state BLE) dibangun dengan asumsi sampel datang kira-kira di laju ini. @MainActor pada seluruh class plus dispatch ke .main berarti setiap konsumen di bawahnya bisa dengan aman menganggap selalu berjalan di main thread.

6. NearbyInteractionManager.swift (Rod/Rod/Sensors/NearbyInteractionManager.swift)

File ini membungkus framework Nearby Interaction milik Apple (posisi relatif presisi berbasis UWB antara dua perangkat Apple) untuk mencari tahu jarak dan arah HP relatif terhadap Mac yang menjalankan World β€” kemungkinan dipakai supaya game bisa bereaksi terhadap seberapa jauh/ke arah mana pemain secara fisik mengarahkan HP relatif terhadap setup Mac/TV.

Tipe yang didefinisikan: NearbyInteractionManager (final class, subclass NSObject, mengikuti NISessionDelegate) β€” pemilik satu NISession dan penyiar ulang update-nya sebagai nilai SpatialState/ SpatialStatus yang lebih sederhana.

Fungsi-fungsi penting: override init() memanggil super.init(), lalu mengatur session.delegate = self supaya callback delegate di bawah bisa jalan.

func start() β€” membaca session.discoveryToken (token milik perangkat ini sendiri, selalu tersedia begitu sesi ada) dan mencoba mengarsipkannya jadi Data lewat NSKeyedArchiver. Kalau salah satu langkah gagal, melaporkan .failed lewat onStatusChange. Kalau berhasil, melaporkan .searching dan menyerahkan token yang sudah diarsipkan lewat onDiscoveryToken?(data) β€” token ini harus sampai ke perangkat lain (World) dengan suatu cara, dan itu terjadi lewat Bluetooth (lihat NetworkMessage.discoveryToken) karena Nearby Interaction sendiri tidak punya mekanisme penemuan/pemasangan sendiri. Dipanggil sekali dari RodViewModel.startSpatialSession() (yang sendiri hanya jalan setelah state koneksi BLE menjadi .ready).

func handleDiscoveryToken(_ data: Data) β€” mengambil token milik perangkat lain (World), yang diterima lewat Bluetooth, lalu membongkarnya kembali jadi NIDiscoveryToken. Kalau gagal, melaporkan .failed. Kalau berhasil, memanggil session.run(NINearbyPeerConfiguration(peerToken: token)) β€” inilah yang benar-benar memulai ranging UWB dua arah β€” dan melaporkan .ready. Dipanggil dari RodViewModel.handle(_:) saat pesan NetworkMessage berjenis .discoveryToken datang dari World.

Bagian extension (implementasi NISessionDelegate): func session(_:didUpdate nearbyObjects:) β€” callback yang terus berjalan setelah ranging aktif. Mengambil objek pertama yang terdeteksi (seharusnya cuma satu β€” World), membaca distance dan (kalau ada) vektor direction-nya, membangun SpatialState, melaporkan status .updating, lalu memanggil onSpatialUpdate?(state). func sessionWasSuspended(_:) β€” callback saat OS menjeda ranging sementara (mis. aplikasi masuk background); melaporkan .searching lagi. func sessionSuspensionEnded(_:) β€” callback saat jeda berakhir; memanggil start() lagi untuk mengulang pertukaran discovery token. func session(_: didInvalidateWith error:) β€” callback kegagalan sesi yang fatal; melaporkan .failed.

Terhubung ke: Memakai SpatialState/SpatialStatus (Shared). Dikendalikan oleh RodViewModel, yang juga mengalirkan discovery token milik World ke sini (lewat BLE) dan mengirim balik token miliknya sendiri lewat BLE.

Catatan menarik: NISession tidak punya cara bawaan untuk bertukar discovery token antar perangkat β€” itulah sebabnya token harus "menumpang" lewat kanal NetworkMessage Bluetooth yang sudah ada (kasus .discoveryToken) alih-alih Nearby Interaction menangani pemasangannya sendiri.

7. PairedCentralStore.swift (Rod/Rod/Transport/PairedCentralStore.swift)

File ini menyimpan (lintas peluncuran aplikasi) siapa central Bluetooth (Mac yang menjalankan World) yang sedang dipasangkan dengan Rod, memakai UserDefaults. Ini penting karena bisa saja ada beberapa Rod dan World dalam jangkauan Bluetooth yang sama di ruangan yang sama, dan tanpa penyimpanan ini, Rod akan menganggap Mac mana pun yang berlangganan karakteristik BLE-nya sebagai "yang" terhubung.

Tipe yang didefinisikan: PairedCentralStore (final class) β€” tempat penyimpanan satu nilai sederhana yang persisten.

Fungsi-fungsi penting: init() β€” saat dibuat, membaca string UUID tersimpan (kalau ada) dari UserDefaults dengan kunci "rod.pairedCentralIdentifier" dan mengubahnya kembali jadi UUID. func save(identifier: UUID) β€” mengatur self.identifier dan menulis string UUID ke UserDefaults. Dipanggil oleh TransportClient.peripheralManager(_:central:didSubscribeTo:) saat pertama kalinya ada central mana pun yang berlangganan (jadi pemasangan otomatis pada kontak pertama β€” tidak ada UI pemasangan khusus). func clear() β€” mengatur identifier = nil dan menghapus kunci dari UserDefaults. Dipanggil oleh TransportClient.forgetPairedDevice().

Terhubung ke: Dimiliki eksklusif oleh TransportClient, yang memeriksanya untuk memutuskan apakah langganan BLE yang masuk harus diterima sebagai "yang" terhubung.

Catatan menarik: CBCentral (CoreBluetooth) tidak menampilkan nama, cuma identifier UUID β€” itu sebabnya TransportClient.pairedDeviceShortID cuma menampilkan 8 karakter pertama UUID, bukan nama Mac yang mudah dibaca manusia.

8. TransportClient.swift (Rod/Rod/Transport/TransportClient.swift)

Ini lapisan jaringan Bluetooth Low Energy (BLE) di sisi Rod. Rod berperan sebagai peripheral BLE (pihak yang mengiklankan layanan dan dilanggan) β€” mencerminkan peran lamanya di bawah transport berbasis MultipeerConnectivity sebelumnya sebagai "klien, pihak yang menyiarkan dirinya sendiri." Rod mengirim RodState (~30Hz) dan event sekali-jalan ke World, serta menerima perintah (fishingEvent/hookPhase) balik dari World.

Tipe yang didefinisikan: TransportClient (final class, @MainActor, subclass NSObject, mengikuti TransportProtocol dan CBPeripheralManagerDelegate) β€” seluruh implementasi peripheral BLE. TransportClient.ClientError (enum bertingkat: .notConnected, .encodingFailed, .notReady) β€” error yang dilempar send(_:). TransportClient.QueuedSend (struct privat bertingkat) β€” satu pengiriman reliable yang menunggu: daftar potongan (chunk) data yang belum terkirim, plus CheckedContinuation untuk dilanjutkan setelah terkirim penuh (atau gagal).

Fungsi-fungsi penting: override init() β€” membangun CBPeripheralManager(delegate: self, queue: nil) (antrean nil berarti callback mendarat di antrean utama, cocok dengan isolasi @MainActor class ini). Mencetak debug saat inisialisasi.

func connect() async throws (syarat protokol) β€” mengatur wantsToAdvertise = true, melaporkan .searching, memanggil startAdvertisingIfPossible(). Dipanggil oleh RodViewModel.start().

func disconnect() (syarat protokol) β€” berhenti ingin beriklan, berhenti benar-benar beriklan, menggagalkan semua pengiriman reliable yang tertunda dengan .notConnected, menghapus central yang berlangganan, mereset reassembler masuk, melaporkan .disconnected. Dipanggil oleh RodViewModel.stop().

func forgetPairedDevice() β€” menghapus pemasangan tersimpan (PairedCentralStore.clear()), menggagalkan pengiriman tertunda, menghapus central yang berlangganan, mereset reassembler, melaporkan .disconnected. Sengaja tidak berhenti beriklan, supaya Mac lain bisa langsung dipasangkan tanpa perlu menjalankan ulang aplikasi. Dipanggil oleh RodViewModel.forgetPairedDevice() (sendiri dipanggil dari tombol "Forget Paired Mac" di ContentView).

func send(_ message: NetworkMessage) async throws (syarat protokol) β€” mensyaratkan isConnected, meng-encode pesan jadi JSON, lalu bercabang: pesan .state lewat sendUnreliable(_:); .discoveryToken/.fishingEvent/ .hookPhase lewat sendReliable(_:) (di-await). Dipanggil oleh RodViewModel.send(_:) untuk setiap pesan keluar.

private func setUpServiceIfNeeded() β€” membangun tiga CBMutableCharacteristic (state β€” notify saja, tanpa izin baca/tulis; reliableOut β€” indicate saja; command β€” write, izin bisa ditulis), merakitnya jadi satu CBMutableService, lalu memanggil peripheralManager.add(...). Dijaga supaya tidak jalan dua kali lewat isServiceAdded.

private func startAdvertisingIfPossible() β€” hanya benar-benar mulai beriklan begitu tiga syarat terpenuhi: wantsToAdvertise, isServiceAdded, dan radio Bluetooth sedang .poweredOn; juga dijaga supaya tidak beriklan dobel. Mengiklankan UUID layanan dan nama lokal "Rod".

private func chunkBudget() -> Int β€” mengembalikan jumlah byte maksimal yang bisa dipakai per paket BLE untuk central yang sedang berlangganan: central.maximumUpdateValueLength, dengan batas bawah BLEFraming.headerSize + 1 (selalu ada ruang untuk header 4-byte plus setidaknya 1 byte payload). Mengembalikan nilai cadangan 20 kalau belum ada central yang berlangganan.

private var sendTargets: [CBCentral]? β€” membungkus subscribedCentral (kalau ada) jadi array satu elemen, atau nil. Ini membatasi setiap kiriman notify/indicate hanya ke central yang benar-benar dipasangkan dengan Rod β€” mengirim nil ke API CoreBluetooth berarti "semua central yang berlangganan," yang justru bisa membocorkan state gameplay ke Mac yang tidak dipasangkan tapi kebetulan berlangganan (lihat catatan didSubscribeTo di bawah soal kenapa central yang tidak dipasangkan masih bisa berlangganan secara teknis di level protokol BLE).

private func sendUnreliable(_ data: Data) β€” Khusus untuk pesan .state. Memecah data lewat BLEFraming.chunks(for:maxChunkSize:); kalau tidak muat dalam satu potongan saja, seluruh pesan itu dibuang (tidak pernah dipecah lebih lanjut) β€” dicatat log di build DEBUG dengan jumlah byte vs anggaran. Pilihan desain ini (daripada diam-diam dipotong atau dipecah) dijelaskan dalam komentar panjang: sampel .state bersifat "kirim-lalu-lupakan", nilai yang terus diperbarui β€” kehilangan satu tidak masalah karena yang berikutnya cuma ~33ms lagi, tapi aliran multi-potongan yang tidak tersinkron di kanal yang tidak reliable dan tidak berurutan akan lebih buruk lagi. Ini secara eksplisit disebut sebagai perbaikan bug yang dulu diam-diam ("ADR-063"): sebelum RodState.CodingKeys memendekkan nama field di wire, castSolution yang terisi mendorong payload yang di-encode jadi 531-542 byte melawan anggaran satu-potongan sekitar 512 byte, artinya setiap percobaan cast sungguhan diam-diam dibuang selama seluruh jendela followThrough-nya tanpa error di mana pun.

private func sendReliable(_ data: Data) async throws β€” Untuk .discoveryToken/.fishingEvent/.hookPhase. Memecah data (pemecahan diperbolehkan di sini, karena indicate punya alur ack-per-potongan sendiri), membungkusnya plus CheckedContinuation jadi QueuedSend, menambahkannya ke reliableSendQueue, dan β€” kalau itu satu-satunya item di antrean β€” langsung memanggil drainReliableQueue(). Mengantre (bukan langsung kirim) mencegah dua pengiriman reliable yang hampir bersamaan saling merusak daftar potongan masing-masing.

private func drainReliableQueue() β€” Menjalankan bagian depan reliableSendQueue, mendorong potongan satu per satu lewat peripheralManager.updateValue(...). Kalau updateValue mengembalikan false (sinyal tekanan-balik BLE sendiri β€” buffer transmisi penuh), fungsi berhenti dan menunggu β€” peripheralManagerIsReady akan memanggilnya lagi nanti. Begitu semua potongan satu QueuedSend terkirim, dihapus dari antrean dan continuation-nya dilanjutkan (membuka blokir siapa pun yang sedang await di sendReliable), lalu lanjut ke kiriman berikutnya di antrean kalau ada.

private func failAllQueuedSends(with error:) β€” Mengosongkan reliableSendQueue dan melanjutkan setiap continuation di dalamnya dengan melempar error yang diberikan β€” dipanggil setiap kali koneksi putus (disconnect, forget, berhenti berlangganan) supaya tidak ada yang menggantung selamanya.

Extension CBPeripheralManagerDelegate: peripheralManagerDidUpdateState(_:) β€” saat .poweredOn, menyiapkan layanan GATT dan mulai beriklan kalau bisa. Saat .poweredOff/.unauthorized/.unsupported, melaporkan .failed. peripheralManager(_:didAdd service:error:) β€” kalau berhasil, mengatur isServiceAdded = true dan coba mulai beriklan; kalau gagal, melaporkan .failed (dan mencetak debug error). peripheralManagerDidStartAdvertising(_:error:) β€” melaporkan .failed kalau iklan sendiri gagal dimulai.

peripheralManager(_:central:didSubscribeTo characteristic:) β€” hanya peduli pada langganan ke karakteristik state (mengabaikan yang lain). Kalau sudah ada pemasangan (pairedCentralStore.identifier != nil) dan identifier central ini tidak cocok, langganan diam-diam diabaikan di level aplikasi Rod β€” komentar menjelaskan CoreBluetooth sendiri tidak punya API untuk benar-benar menolak langganan, jadi central yang tidak dipasangkan tetap secara teknis berlangganan di level protokol (tidak berbahaya), hanya saja subscribedCentral tidak pernah diatur ke central itu, sehingga isConnected/sendTargets keduanya tetap mengarah hanya ke perangkat yang benar-benar dipasangkan dan Mac yang tidak dipasangkan tidak mendapat apa-apa. Kalau tidak, mengatur subscribedCentral ke central ini, dan kalau belum ada pemasangan tersimpan, menyimpan yang ini lewat pairedCentralStore.save(identifier:) (ini satu-satunya tempat pemasangan benar-benar terjadi β€” otomatis, saat kontak pertama). Melaporkan .ready.

peripheralManager(_:central:didUnsubscribeFrom characteristic:) β€” hanya bereaksi kalau itu karakteristik state dan central yang berhenti berlangganan cocok dengan yang sedang berlangganan saat ini. Menggagalkan pengiriman tertunda, menghapus subscribedCentral, mereset reassembler, melaporkan .disconnected. Iklan sengaja dibiarkan tetap berjalan supaya peer yang sama bisa terhubung ulang otomatis nanti. peripheralManagerIsReady (toUpdateSubscribers:) β€” memanggil drainReliableQueue() lagi; ini sinyal CoreBluetooth bahwa ruang buffer sudah kosong.

peripheralManager(_:didReceiveWrite requests:) β€” untuk setiap permintaan tulis yang menyasar karakteristik command, memasukkan byte mentahnya ke inboundReassembler.feed(_:), dan untuk setiap pesan lengkap yang keluar dari sana, mencoba men-decode-nya sebagai NetworkMessage lewat JSON dan memanggil onMessage?(message). Kegagalan decode sekarang dicatat log di build DEBUG (komentar mencatat ini dulu try? yang diam-diam, artinya ketidakcocokan skema antara build Rod/World β€” misalnya cuma satu sisi yang dibangun ulang setelah field RodState berubah β€” diam-diam membuang setiap pesan tanpa sinyal apa pun, tampak seperti "terhubung tapi tidak pernah terjadi apa-apa"). Setiap permintaan diakui dengan .success apa pun hasil decode-nya.

Terhubung ke: Mengimplementasikan TransportProtocol Shared; memakai SessionConfiguration, BLEFraming, BLEMessageReassembler, NetworkMessage, ConnectionState (semua Shared); memakai PairedCentralStore (Rod). Dipakai oleh RodViewModel.

Catatan menarik: Komentar konformansi @preconcurrency CBPeripheralManagerDelegate menjelaskan kenapa ini bisa dikompilasi bersih di bawah strict concurrency Swift 6 meski protokol delegate CoreBluetooth sendiri tidak diberi anotasi @MainActor β€” karena queue: nil menjamin callback di antrean utama pada praktiknya, cocok dengan isolasi eksplisit @MainActor class ini. Bug pembuangan satu- potongan ADR-063 untuk .state dan perbaikan log kegagalan decode ("dulu try? yang diam- diam") sama-sama bug historis nyata yang didokumentasikan komentar file ini.

9. RodViewModel.swift (Rod/Rod/ViewModel/RodViewModel.swift)

Ini otak utama aplikasi Rod. Memiliki seluruh state UI yang di-publish, merangkai setiap manager (motion, spatial, transport, haptics, interpreter), memutuskan kapan mengirim state ke World, menangani pesan masuk dari World, dan mengendalikan alur kalibrasi gerak multi-langkah. Semua yang ada di ContentView membaca dari sini; semua yang berhubungan sensor/jaringan mengalir lewat sini.

Tipe yang didefinisikan: RodViewModel (final class, @MainActor, ObservableObject) β€” satu-satunya view model aplikasi.

Fungsi-fungsi penting: init(motionManager:motionInterpreter:spatialManager: transport:fishingHaptics:) β€” menyimpan lima dependency yang di-inject. Langsung mencoba memuat MotionProfile tersimpan sebelumnya dari UserDefaults (loadMotionProfile()); kalau ada, memasukkannya ke motionInterpreter.updateProfile(_:) dan mengatur calibrationStep jadi .complete atau .idle tergantung apakah sudah isComplete. Mengatur motionAvailable dari motion manager. Lalu merangkai lima closure: motionManager.onUpdate (setiap sampel gerak, memicu Task { @MainActor in ... } yang menyimpan sampel, mengatur hasMotionSample = true, memanggil collectCalibrationSample(), lalu updateAction(), lalu publishState()); spatialManager.onSpatialUpdate (menyimpan SpatialState baru dan menyiarkannya ulang); spatialManager.onStatusChange (memperbarui spatialStatus); spatialManager.onDiscoveryToken (mengirim token ke World lewat send(.discoveryToken(data))); transport.onStateChange (memperbarui connectionState; khususnya, saat menjadi .ready, membersihkan lastError dan memanggil startSpatialSession() β€” artinya Nearby Interaction baru mulai setelah Bluetooth benar-benar terhubung); transport.onMessage (mengarahkan setiap NetworkMessage masuk ke handle(_:)).

func start() β€” Dijaga oleh hasStarted (idempoten). Menyalakan debug recorder, menyalakan motion manager, dan memicu Task yang memanggil transport.connect(), menangkap error ke lastError/connectionState = .failed. Dipanggil oleh ContentView.onAppear. func stop() β€” mematikan motion manager dan transport, membersihkan hasStarted. Dipanggil oleh ContentView.onDisappear.

func startFishing() β€” mensyaratkan calibrationStep == .complete; mengatur isFishing = true. Dipanggil oleh tombol "Start Fishing". func endFishing() β€” mengatur isFishing = false dan isCastingArmed = false. Dipanggil oleh "End Fishing". func startCasting() β€” mensyaratkan isFishing, hookPhase adalah .idle atau .result, dan belum "armed"; mengatur isCastingArmed = true. Dipanggil oleh tombol "Start Casting". Komentar panjang menjelaskan pengecualian .result (ADR-061): ini membuat "Start Casting" berfungsi ganda sebagai sentuhan lama "Continue Fishing" di World, karena WorldViewModel menutup layar .result kembali ke .idle begitu melihat RodState.isCastingArmed == true. Ini tidak membuat sentakan fisik menembak lebih awal β€” tembakan sesungguhnya tetap memerlukan HookPhase.allowsCasting (hanya .idle), pengecualian ini cuma melonggarkan kapan tombolnya boleh ditekan.

func calibrateMotion() β€” mensyaratkan hasMotionSample dan hookPhase == .idle. Ini mesin-status 4-langkah yang dikendalikan calibrationStep: langkah .idle (menangkap pose diam): mensyaratkan setidaknya 3 sampel terkumpul; merata-ratakannya lewat averagePose(of:) dan memanggil motionInterpreter.setIdlePose(_:); juga secara eksplisit mengatur motionInterpreter.profile.isComplete = false (supaya kalibrasi ulang tidak pernah meninggalkan profil "complete" yang basi bisa dipakai di tengah urutan); membersihkan sampel; maju ke .cast. Langkah .cast: mensyaratkan setidaknya 3 sampel; merata-ratakan 10 sampel terakhir saja (bukan semuanya β€” ini memilih pose "posisi tarik-belakang penuh/cast yang ditahan" di ekor gerakan, bukan seluruh gestur termasuk transisi menuju ke sana) jadi castPose. Mencari sampel dengan puncak accelerationZ absolut terbesar dan memakai abs(nilai itu) * 0.2 (batas bawah 0.01) sebagai ambang castAcceleration yang diperlukan β€” artinya kalibrasi sengaja menaruh batas di hanya 20% dari puncak ayunan pemain sendiri, bukan puncak penuhnya, kemungkinan supaya sentakan biasa nanti selalu bisa melewatinya dengan mudah. Juga mencatat tanda puncak akselerasi itu (castAccelerationSign) karena maju vs mundur bisa tergantung orientasi HP. Mencatat castDuration sebagai rentang timestamp sampel itu sendiri (batas bawah 0.15 detik, batas atas 0.8 detik). Maju ke .reel. Langkah .reel: mensyaratkan setidaknya 3 sampel; merata-ratakan seluruh batch jadi reelPose. Menghitung rata-rata dan puncak magnitudo laju rotasi di semua sampel; mengatur reelRotationRate (ambang "baru mulai menggulung") ke 45% dari laju rata-rata (batas bawah 0.2), dan reelFullRotationRate (batas atas "kecepatan penuh") ke laju puncak (tapi selalu setidaknya 0.3 di atas nilai minimum). Mengatur profile.isComplete = true, maju ke .complete, dan menyimpan permanen profil lewat saveMotionProfile(_:). Langkah .complete (menekan ulang "Recalibrate Motion"): reset kembali ke .idle dan langsung menandai profile.isComplete = false (juga disimpan) β€” ini titik masuk ulang untuk seluruh urutan. Setiap cabang membersihkan calibrationSamples/calibrationSampleCount, dan seluruh fungsi diakhiri dengan memanggil updateAction() dan publishState() supaya efeknya langsung tercermin.

private func startSpatialSession() β€” memanggil spatialManager.start().

private func handle(_ message: NetworkMessage) β€” saklar pesan masuk: .discoveryToken(data) diteruskan ke spatialManager.handleDiscoveryToken(data). .fishingEvent(event) diteruskan ke fishingHaptics.handle(event). .hookPhase(phase) mengatur hookPhase = phase. Kalau fase baru bukan .idle/.result, membersihkan isCastingArmed (komentar menjelaskan .result sengaja dikecualikan β€” lihat ADR-061 di atas β€” membersihkannya di sini akan berlomba dengan pesan yang justru dimaksud membawa status "armed" melewati jendela .result). Kalau fase bukan .hookInWater, memanggil fishingHaptics.stopInterest(). Kalau bukan .fishOnHook/.reeling, memanggil fishingHaptics.stopFight(). Kalau khususnya .idle, membangun ulang dan menyiarkan ulang rodState segera (RodState baru tanpa cast solution) β€” kemungkinan supaya World langsung melihat state yang di-reset, bukan menunggu sampel gerak berikutnya. .state diabaikan (break); Rod tidak pernah berharap menerima jenis pesannya sendiri.

private func publishState() β€” membangun ulang rodState dari pose/orientasi/ aksi/kecepatan reel dsb. saat ini (mempertahankan castSolution/motionPhase/ castPower/castHoldDuration yang sudah ada kecuali updateAction() sudah menimpanya lebih dulu). Hanya benar-benar melanjutkan pengiriman kalau connectionState adalah .ready atau sudah .streaming (kalau tidak, cuma memperbarui state published lokal dan berhenti) β€” dan begitu benar-benar mengirim, mengubah connectionState jadi .streaming. Dijaga terhadap pengiriman yang tumpang tindih lewat isSendingState: kalau ada kiriman yang masih berjalan, panggilan ini cuma dilewati (bukan diantre) β€” komentar menjelaskan ini sengaja, karena publishState() jalan di setiap sampel gerak (sampai 30 kali/detik) dan cuma state terbaru yang layak dikirim; mengantre malah membuat kiriman tertinggal dari waktu sebenarnya dan terasa seperti input lag. Kalau tidak sedang mengirim, mengatur isSendingState = true, memicu Task untuk await send(.state(stateToSend)), lalu membersihkan flag-nya.

private func updateAction() β€” Inti dari mengubah gerak mentah jadi state gameplay. Menangkap motionAccelerationZ/motionRotationRate untuk tampilan debug, memanggil motionInterpreter.interpret(motionInput, isCastingArmed:), mencatat hasilnya ke debugRecorder (untuk tampilan debug classifier di layar). Memperbarui pose/ orientation dari hasil interpretasi. Kalau interpretasi membawa castSnapshot (artinya cast sungguhan baru saja terjadi), diselesaikan lewat castResolver.resolve(_:) dan ditempelkan distance yang dihitung lewat castMeterConfig.distance(for: resolved.power), dan langsung membersihkan isCastingArmed (satu tekanan "Start Casting" hanya pernah menghasilkan satu lemparan). Kalau tidak, tetap memakai castSolution apa pun yang sudah ada di rodState. Menghitung action akhir: dipaksa jadi .idle kalau tidak sedang isFishing; dipaksa jadi .idle kalau interpreter bilang .casting tapi hookPhase.allowsCasting bernilai false; dipaksa jadi .idle kalau interpreter bilang .reeling tapi hookPhase.allowsReeling bernilai false; kalau tidak, memakai aksi milik interpreter sendiri. Membangun ulang rodState sepenuhnya dengan semua di atas. Terakhir, menyalakan haptic: fishingHaptics.handle(.cast) saat aksi baru saja berubah jadi .casting; fishingHaptics.reelTick(reelSpeed:) setiap aksi .reeling; dan selalu memanggil interestPulse()/fightPulse() (keduanya membatasi diri sendiri secara internal, jadi aman dipanggil setiap sampel).

private var motionInput: RodMotionInput β€” membangun RodMotionInput dari sampel motion mentah saat ini; dipakai oleh updateAction() dan pengumpulan sampel kalibrasi. private func collectCalibrationSample() β€” tidak melakukan apa-apa begitu calibrationStep == .complete. Kalau tidak, menambahkan motionInput saat ini ke calibrationSamples, dipangkas ke maksimal 180 sampel (membuang yang paling lama), dan memperbarui calibrationSampleCount. private func averagePose(of samples: [RodMotionInput]) -> MotionPose β€” rata-rata sederhana per sumbu pitch/roll/yaw di seluruh sampel yang diberikan.

private func send(_ message: NetworkMessage) async β€” memanggil transport.send(_:); kalau berhasil membersihkan lastError, kalau gagal mengatur lastError ke deskripsi error. Tidak pernah melempar error sendiri (menelan error ke state UI published sebagai gantinya). private static func loadMotionProfile() -> MotionProfile? β€” membaca+men-decode MotionProfile tersimpan dari UserDefaults (kunci "rod.motionProfile"), atau nil. private static func saveMotionProfile(_ profile:) β€” meng-encode+menulisnya.

Terhubung ke: Pusat perangkaian β€” bergantung pada MotionManager, RodMotionInterpreter, NearbyInteractionManager, TransportClient, FishingHaptics (semua di-inject), plus CastResolver, CastMeterConfig, RodState, NetworkMessage, MotionProfile, HookPhase, CalibrationStep, RodAction, ConnectionState, SpatialStatus, MotionDebugRecorder, MotionDebugSample (semua Shared). Dipakai oleh ContentView dan RodApp.

Catatan menarik: Ini file paling padat mesin-status di Rod. Pilihan perata-rataan kalibrasi tidak jelas dengan sendirinya, layak diingat: idle/reel merata-ratakan semua sampel terkumpul, tapi cast hanya merata-ratakan 10 sampel terakhir (memilih tarik-belakang yang ditahan, bukan seluruh gestur). Ambang akselerasi cast ditetapkan di hanya 20% dari puncak kalibrasi pemain sendiri. "Buang, jangan antre" untuk isSendingState adalah pilihan desain anti-lag yang sengaja untuk stream ~30Hz. Komentar dokumentasi yang menandai reelCalibrationRange terkait laporan bug terbuka ("kecepatan reel terasa konstan") adalah sinyal masalah belum terselesaikan yang konkret, layak disimpan di buku ini.

10. MotionDebugView.swift (Rod/Rod/Views/MotionDebugView.swift)

Ini tampilan diagnostik SwiftUI kecil yang menampilkan isi internal langsung dari classifier cast/ reel (dari MotionDebugRecorder/MotionDebugSample) β€” fase, aksi, validitas pose, pose referensi mana yang dicocokkan interpreter, dan angka jarak/akselerasi mentah di balik keputusan itu. Dimaksudkan untuk pengembangan, disisipkan di dalam ContentView.developerDetails (yang sendiri saat ini dikomentari, jadi tampilan ini tidak ditampilkan ke pemain sekarang, cuma dikompilasi).

Tipe yang didefinisikan: MotionDebugView (SwiftUI View).

Fungsi-fungsi penting: var body: some View β€” kalau recorder.latestSample ada, menampilkan tumpukan baris LabeledContent: jumlah sampel; fase; aksi; pose valid/tidak valid; pose referensi yang dipilih plus jarak ke idle/cast/reel; akselerasi maju vs ambang yang diperlukan plus label lolos/gagal; dan label lolos/gagal cooldown. Kalau belum ada sampel, menampilkan "No motion samples recorded." private func format(_ value: Double) -> String β€” memformat ke 3 angka desimal.

Terhubung ke: Membaca MotionDebugRecorder/MotionDebugSample/ MotionDebugDecision (semua Shared). Disisipkan oleh ContentView.

Catatan menarik: Tidak ada di luar yang sudah dibahas β€” lapisan tampilan murni yang sederhana.

Bagian B β€” Paket Shared

Paket ini tidak punya UI dan tidak mengimpor apa pun yang khusus platform tertentu (sengaja menghindari CoreBluetooth, CoreMotion, dll. secara langsung) supaya Rod dan World bisa sama-sama bergantung padanya dan selalu sepakat soal bentuk data dan logika protokol yang sama.

Sub-folder Debug/

11. MotionDebugDecision.swift (Shared/Sources/Shared/Debug/MotionDebugDecision.swift)

Potret data murni dari alasan interpreter gerak mengklasifikasikan pose seperti itu di satu saat tertentu β€” angka-angka persis di balik keputusan lolos/gagal, untuk tampilan debug dan (mungkin di masa depan) logging/tuning.

Tipe yang didefinisikan: MotionDebugDecision (struct, Sendable, Equatable) β€” satu jejak penalaran lengkap dari satu keputusan classifier.

Fungsi-fungsi penting: init(pitchDelta:requiredPitchDelta: forwardAcceleration:requiredForwardAcceleration:cooldownRemaining:passedPitchCheck: passedForwardCheck:passedCooldownCheck:idleDistance:castDistance:reelDistance:selectedPose:) β€” inisialisasi memberwise biasa; beberapa parameter belakang (idleDistance, castDistance, reelDistance, selectedPose) punya nilai default (0, 0, 0, "unknown") supaya pemanggil lama yang lebih tua dari field-field itu tetap bisa dikompilasi.

Terhubung ke: Dibangun di dalam RodMotionInterpreter.interpret(_:isCastingArmed:); dibawa oleh RodMotionInterpretation.debug dan MotionDebugSample.decision; ditampilkan oleh MotionDebugView.

Catatan menarik: Penamaan di sini (pitchDelta/ requiredPitchDelta) agak basi/tidak cocok lagi dengan cara RodMotionInterpreter sebenarnya memakai struct ini sekarang β€” ia mengisi pitchDelta dengan distances.idle dan requiredPitchDelta dengan distances.cast, artinya dua field ini sebenarnya "jarak-ke-pose-idle" dan "jarak-ke-pose-cast," bukan benar-benar delta pitch β€” field idleDistance/castDistance/reelDistance/ selectedPose yang lebih baru adalah sumber kebenaran yang lebih jelas dan lebih terkini untuk informasi yang sama.

12. MotionDebugRecorder.swift (Shared/Sources/Shared/Debug/MotionDebugRecorder.swift)

Ring kecil yang bisa diobservasi dari keputusan-keputusan classifier terkini, memberi makan UI debug (MotionDebugView) dengan jumlah sampel dan sampel terbaru yang terus diperbarui.

Tipe yang didefinisikan: MotionDebugRecorder (final class, @Observable, public) β€” memakai framework Observation Swift yang lebih baru (bukan ObservableObject/@Published) supaya tampilan SwiftUI bisa membaca properti tertentu dan hanya digambar ulang saat properti spesifik itu berubah.

Fungsi-fungsi penting: init() β€” kosong, cuma state default di bawah. func start() β€” Membersihkan samples, mereset sampleCount ke 0, membersihkan latestSample, mengatur isRecording = true. Dipanggil oleh RodViewModel.start(). func stop() β€” mengatur isRecording = false. (Tidak terlihat dipanggil di mana pun dalam kumpulan file tugas ini β€” RodViewModel.stop() tidak memanggilnya, hanya start() yang terhubung.) func clear() β€” pembersihan sama seperti start() tapi tanpa menyentuh isRecording. func append(_ sample: MotionDebugSample) β€” tidak melakukan apa-apa kecuali isRecording. Menambahkan ke samples, mengatur latestSample, memperbarui sampleCount. Dipanggil oleh RodViewModel.updateAction() di setiap sampel gerak.

Terhubung ke: Dimiliki oleh RodViewModel (let debugRecorder); dibaca oleh MotionDebugView.

Catatan menarik: samples tumbuh tanpa batas selagi merekam (tidak ada batas, tidak seperti pemangkasan 180-sampel milik RodViewModel.calibrationSamples) β€” dalam sesi panjang, array ini bisa tumbuh sangat besar; layak ditandai sebagai potensi masalah pertumbuhan memori untuk build live-service, meski tidak berbahaya untuk sesi debug singkat.

13. MotionDebugSample.swift (Shared/Sources/Shared/Debug/MotionDebugSample.swift)

Membungkus satu input gerak mentah, hasil interpretasi interpreter, dan jejak keputusan debug, semuanya jadi satu unit yang bisa direkam.

Tipe yang didefinisikan: MotionDebugSample (struct, Sendable) β€” bungkusan tiga sub-struct.

Fungsi-fungsi penting: init(input:interpretation:decision:) β€” inisialisasi memberwise biasa.

Terhubung ke: Dibangun oleh RodViewModel.updateAction(); ditambahkan ke MotionDebugRecorder; dibaca oleh MotionDebugView (lewat recorder.latestSample).

Catatan menarik: Tidak ada.

Sub-folder Gameplay/

14. CastResolver.swift (Shared/Sources/Shared/Gameplay/CastResolver.swift)

Mengubah momen "lepas" gerak mentah (sebuah CastSnapshot) menjadi nilai gameplay akhir lemparan β€” arah, power, sudut lontar, dan jarak β€” memakai kurva meteran waktu berulang, bukan ramp isi-lalu-batasi yang sederhana. Ini versi resmi/otoritatif dari matematika power lemparan; meteran UI langsung di Rod mencerminkan rumus yang sama secara terpisah di dalam RodMotionInterpreter demi keperluan tampilan.

Tipe yang didefinisikan: CastVector3 (struct, Codable, Sendable, Equatable) β€” vektor 3D float minimal dengan .forward ((0,0,-1)), length, dan normalized(). CastSnapshot (struct, Codable, Sendable, Equatable) β€” menangkap orientasi, timestamp, dan backswingHoldDuration (detik ditahan di zona pengisian sebelum dilepas) pada momen lemparan dilepas. CastConfig (struct, Codable, Sendable, Equatable) β€” tuning: minimumLaunchAngle (0.15), maximumLaunchAngle (0.65), oscillationPeriod (3.5 detik β€” berapa lama satu osilasi power penuh 0->1->0 berlangsung selagi tarik-belakang ditahan). CastSolution (struct, Codable, Sendable, Equatable) β€” hasil akhir: direction (CastVector3), power (Float 0...1), launchAngle (Float), distance (Float, default 0 sampai diisi belakangan). CastMeterConfig (struct, Codable, Sendable, Equatable) β€” memetakan nilai power 0...1 jadi jarak akhir dalam meter, lewat tiga tingkat: shortMaximum (6), mediumMaximum (12), longMaximum (18). CastResolver (struct, Sendable, public) β€” resolver sesungguhnya, menyimpan satu CastConfig.

Fungsi-fungsi penting: CastVector3.length: Float (dihitung) β€” magnitudo Euclidean. CastVector3.normalized() -> CastVector3 β€” mengembalikan .forward kalau magnitudo 0 (menghindari pembagian dengan nol), kalau tidak vektor satuannya.

CastMeterConfig.distance(for power: Float) -> Float β€” Membatasi power ke 0...1. Dibagi jadi tiga ramp linear: di bawah 0.33 dari power ternormalisasi, ramp 0 -> shortMaximum; antara 0.33 dan 0.66, ramp shortMaximum -> mediumMaximum; di atas 0.66, ramp mediumMaximum -> longMaximum. Dipanggil oleh RodViewModel.updateAction() lewat castMeterConfig.distance(for: resolved.power).

CastResolver.resolve(_ snapshot: CastSnapshot) -> CastSolution β€” Mengambil vektor maju dari orientasi snapshot (lewat RodOrientation.act(_:), didefinisikan tepat di bawah), diratakan jadi murni horizontal (nolkan komponen y) lalu dinormalisasi; kalau proyeksi horizontal itu ternyata panjangnya nol (mis. rod menunjuk lurus ke atas/bawah), jatuh balik ke .forward. Menghitung normalizedPower dari kurva meteran waktu: phase = backswingHoldDuration * 2Ο€ / oscillationPeriod; power = (1 - cos(phase)) / 2 β€” ini gelombang halus 0->1->0->1... yang bukan ramp yang naik lalu berhenti di 1; menahan lebih lama cuma membuatnya terus berosilasi. launchAngle diinterpolasi linear antara minimumLaunchAngle dan maximumLaunchAngle memakai nilai power yang sama. Mengembalikan CastSolution (distance dibiarkan di default 0 β€” diisi belakangan oleh RodViewModel lewat .with(distance:)).

RodOrientation.act(_ vector: CastVector3) -> CastVector3 (extension pada RodOrientation, didefinisikan di sini meski RodOrientation sendiri ada di MotionProfile.swift) β€” memutar vektor dengan quaternion ini memakai rumus standar "quaternion kali vektor" (uv = q Γ— v, uuv = q Γ— uv, hasil = v + 2wΒ·uv + 2Β·uuv). Dipakai untuk mengubah "ke arah mana depan, mengingat orientasi rod saat ini" jadi arah ruang-dunia sesungguhnya.

Terhubung ke: RodOrientation (dari MotionProfile.swift) dipakai dan diperluas (extension) di sini. CastSnapshot dihasilkan oleh RodMotionInterpreter. CastResolver/CastMeterConfig keduanya dibangun dan dipakai oleh RodViewModel. CastSolution dibawa di dalam RodState sampai ke World.

Catatan menarik: Desain meteran waktu "ADR-057" (osilasi berkelanjutan, bukan isi-lalu-batasi) dan aturan "ADR-056" ("sentakan selalu memicu pelepasan; sentakan tidak pernah menyekalakan hasilnya") sama-sama disebut eksplisit di komentar sebagai keputusan gameplay yang sengaja dan tidak jelas dengan sendirinya β€” pembaca yang naif mungkin berharap menahan lebih lama berarti "power lebih besar," padahal sebenarnya berarti "fase gelombang apa pun yang kebetulan pemain lepaskan."

15. FishFightConfig.swift (Shared/Sources/Shared/Gameplay/FishFightConfig.swift)

Satu struct raksasa yang menyimpan setiap angka tuning untuk minigame perjuangan ikan (naik/turun tension senar, kecepatan tarik-menarik dan jarak kabur, mekanik arah lateral "juking", risiko/imbalan kecepatan reel yang diskalakan tension, dan ritme haptic band-tension Rod). Baik FishFightController milik World maupun FishingHaptics milik Rod dimaksudkan dibangun dari instance/nilai config yang sama, supaya kesulitan gameplay dan rasa haptic-nya tidak pernah melenceng satu sama lain.

Tipe yang didefinisikan: FishFightConfig (struct, Codable, Sendable, Equatable, public) β€” data murni, setiap field adalah var (sengaja dimaksudkan untuk diubah tangan demi tuning).

Fungsi-fungsi penting: init(...) β€” satu inisialisasi memberwise besar dengan nilai default tertanam untuk sekitar 25 field-nya (nilai-nilai didokumentasikan satu per satu di bawah); tidak ada logika lain.

Nilai tuning penting (dikelompokkan seperti di file, dengan default dan alasan di balik yang tidak jelas dengan sendirinya):

Tension senar: initialTension (0.5 β€” tension awal menyisakan ruang bergerak ke dua arah); baseFishPullRate (0.105 β€” laju drain dasar saat resistensi nol; dipotong 30% dari 0.15 sesuai permintaan langsung untuk "diperlembut"); baseReelTensionRate (0.6 β€” tension yang didapat per unit kecepatan reel; "tuas tunggal terbesar pada kesulitan keseluruhan"); pullRateResistanceMultiplier (3.0 β€” seberapa besar resistance ikan mengamplifikasi laju tarik di atas dasar; menaikkan ini dari 1.0 implisit lama secara khusus yang membuat tingkat kesulitan lebih tinggi benar-benar terasa lebih sulit, sesuai keluhan yang dikutip bahwa "fish_a sampai scott semua menghasilkan laju tension bersih yang mirip").

Tarik-menarik/kabur jarak: basePullSpeed (0.2625 β€” kecepatan dasar m/s ikan menarik kail menjauh; sejarah: 0.7 -> 0.35 -> 0.2625, dua putaran penyesuaian ulang "terasa terlalu kuat"); pullSpeedResistanceMultiplier (1.8); escapeDistanceAllowance (6.0 meter jatah senar ekstra sebelum kegagalan "spooled" total pada resistensi nol); escapeAllowanceResistancePenalty (3.5 β€” meter dikurangi per unit resistensi); minEscapeDistanceAllowance (2.0 β€” batas bawah keras supaya lemparan dadu resistensi maksimal tidak bisa mengecilkan jatah jadi terlalu tipis dan tidak adil).

Arah perjuangan (juking lateral): fightDirectionMinDuration/MaxDuration (5.0/10.0 detik β€” berapa lama sub-fase juking kiri/kanan berlangsung, mulai sejak ikan menggigit); fightDirectionSwitchMinInterval/MaxInterval (0.6/1.8 detik β€” seberapa sering arah juking berganti selagi aktif); fightDirectionRollThreshold (0.15 radian β€” roll rod minimum, relatif terhadap idle yang dikalibrasi, untuk dihitung sebagai kemiringan lawan yang disengaja); fightDirectionCounterRelief (0.25 relief tension/detik untuk melawan dengan benar); fightDirectionPenalty (0.10 penalti tension/detik kalau tidak melawan β€” dipotong setengah dari 0.20 sesuai permintaan langsung "kurangi state Fish Pulling").

Kecepatan tarik reel yang diskalakan tension: reelSpeedSafeTension (0.7 β€” cocok dengan tepi atas zona hijau LineTensionView milik World, meski tidak berbagi kode, cuma selaras secara angka); reelSpeedDangerTension (0.85 β€” cocok dengan tepi merah nyaris putus); reelPullSpeedSafe (2.0 m/s per input reel penuh di zona aman β€” sejarah: diluncurkan di 0.5 ["bahkan tidak bisa dimainkan"], dikoreksi ke 3.375, lalu secara eksplisit disesuaikan ulang turun ke 2.0); reelPullSpeedDanger (4.0 m/s di zona bahaya β€” selalu rasio 2x dari reelPullSpeedSafe, "imbalan bagi pemain yang berani bertahan di zona merah").

Band tension (ritme haptic Rod): tensionBandMediumThreshold (0.55), tensionBandHighThreshold (0.8); lowBandInterval/mediumBandInterval/ highBandInterval (0.5/0.28/0.11 detik β€” makin kecil terasa makin "kontinu"); lowBandIntensity/mediumBandIntensity/highBandIntensity (semua 1.0); lowBandSharpness/mediumBandSharpness/highBandSharpness (0.4/0.55/0.75).

Terhubung ke: Dipakai bersama oleh FishingHaptics (Rod, dibahas di atas) dan (sesuai komentar dokumentasinya) FishFightController milik World (di luar lingkup dokumen ini, tapi dependensinya nyata dan disengaja).

Catatan menarik: File ini pada dasarnya adalah catatan sejarah keseimbangan gameplay yang tertanam langsung di komentar dokumentasi β€” hampir setiap komentar field mengutip keluhan playtester sesungguhnya (dalam Bahasa Indonesia) yang menghasilkan nilai saat ini. Ini materi sumber primer yang sangat berguna untuk referensi "kenapa angka ini begini."

16. MotionProfile.swift (Shared/Sources/Shared/Gameplay/MotionProfile.swift)

Mendefinisikan matematika orientasi inti (tipe quaternion minimal) plus bentuk data kalibrasi gerak personal pemain β€” pose referensi idle/cast/reel mereka dan ambang akselerasi/rotasi yang diturunkan darinya.

Tipe yang didefinisikan: RodOrientation (struct, Codable, Sendable, Equatable, public) β€” quaternion ternormalisasi (x,y,z,w). MotionPose (struct, Codable, Sendable, Equatable, public) β€” tiga nilai pitch/roll/yaw mentah dari CoreMotion. RodMotionPose β€” public typealias untuk MotionPose, dipertahankan "untuk nama kompatibel-mundur dengan state Rod dan kode UI yang sudah ada." MotionProfile (struct, Codable, Sendable, Equatable, public) β€” hasil kalibrasi lengkap: penanda kelengkapan, tiga pose referensi, orientasi referensi kalibrasi, dan ambang cast/reel yang diturunkan.

Fungsi-fungsi penting: RodOrientation.init(x:y:z:w:) β€” Menormalisasi saat dibuat: menghitung panjang vektor; kalau 0 (input degenerate), jatuh balik ke .identity alih-alih membagi dengan nol; kalau tidak, membagi keempat komponen dengan panjangnya supaya quaternion tersimpan selalu unit-length. RodOrientation.static let identity β€” (0,0,0,1), quaternion "tanpa rotasi". RodOrientation.inverted() -> RodOrientation β€” Untuk quaternion unit, inversnya cuma konjugat (negasikan x/y/z, pertahankan w) β€” mengembalikan itu. static func * (lhs:rhs:) -> RodOrientation β€” perkalian Hamilton standar (perkalian quaternion) β€” menggabungkan dua rotasi jadi satu. static func from(pitch:roll:yaw:) -> RodOrientation β€” Membangun tiga quaternion satu-sumbu (pitch di sekitar x, roll di sekitar z, yaw di sekitar y β€” perhatikan penetapan sumbu: roll memakai z, bukan x atau y yang lebih umum) dari setengah-sudut, lalu menggabungkannya sebagai yaw * pitch * roll (urutan perkalian spesifik itu penting untuk hasil rotasi gabungannya).

Terhubung ke: RodOrientation diperluas di tempat lain (act(_:) milik CastResolver.swift). Seluruh struct MotionProfile adalah yang disimpan permanen RodViewModel ke UserDefaults dan yang dipakai membangun/memperbarui RodMotionInterpreter. MotionPose/RodMotionPose dipakai di seluruh UI Rod (ContentView.formatDirection, dll.) dan interpreter.

Catatan menarik: Sumbu roll yang jadi z (bukan x yang lebih konvensional) di from(pitch:roll:yaw:) adalah detail yang layak ditandai bagi siapa pun yang mengimplementasi ulang matematika ini di tempat lain β€” harus cocok persis, kalau tidak matematika orientasi bisa diam-diam melenceng antara Rod dan World.

17. RodMotionInterpreter.swift (Shared/Sources/Shared/Gameplay/RodMotionInterpreter.swift)

Classifier inti "apa yang sebenarnya sedang dilakukan tangan pemain." Mengambil input gerak mentah plus MotionProfile yang dikalibrasi dan mengubahnya jadi RodMotionInterpretation β€” pose, fase, aksi gameplay (idle/casting/reeling), kecepatan reel, dan (saat lemparan terjadi) sebuah CastSnapshot untuk diselesaikan CastResolver.

Tipe yang didefinisikan: RodMotionInput (struct, Sendable, Equatable, public) β€” nilai mentah satu momen: timestamp, pitch, roll, yaw, accelerationZ, rotationRateMagnitude. RodMotionPhase (enum, String, Codable, Sendable, Equatable, public) β€” fase gestur yang lebih terperinci: .invalid, .idle, .backswing, .charging, .forwardSwing, .release, .followThrough, .casting, .reeling. RodMotionInterpretation (struct, Sendable, Equatable, public) β€” hasil lengkap satu pemanggilan interpret(_:). RodMotionInterpreter (final class, public) β€” classifier ber-state yang menyimpan MotionProfile saat ini plus pelacakan gestur yang sedang berjalan. RodMotionInterpreter.Classification (enum privat bertingkat: .idle, .cast, .reel) β€” pose referensi mana yang paling dekat dengan gerakan saat ini.

Fungsi-fungsi penting: RodMotionInterpreter.init(profile:) β€” menyimpan profil. func updateProfile(_ profile:) β€” mengganti profil tersimpan dan mereset penuh seluruh state pelacakan gestur yang sedang berjalan (previousClassification = .idle, castEndsAt = 0, previousCastPhase = .idle, releasedCastPower = 0, castHoldStartTimestamp = nil), dan mengatur hasIdleReference = profile.isComplete. Dipanggil oleh RodViewModel.init saat memuat profil tersimpan. func setIdlePose(_ pose:) β€” Mengatur profile.idlePose dan langsung menurunkan profile.calibrationReference darinya lewat RodOrientation.from(pitch:roll:yaw:) (supaya pratinjau kalibrasi dan rendering runtime nanti memakai kerangka referensi yang sama-sama terkoreksi), mengatur hasIdleReference = true, dan mereset state gestur yang sama seperti updateProfile. Dipanggil oleh langkah .idle milik RodViewModel.calibrateMotion().

func interpret(_ input: RodMotionInput, isCastingArmed: Bool) -> RodMotionInterpretation β€” Fungsi klasifikasi utama, dipanggil setiap sampel gerak oleh RodViewModel.updateAction(). Urutan logikanya: (1) Membangun current (sebuah MotionPose) dan currentOrientation dari input mentah. (2) Kalau hasIdleReference bernilai false (belum pernah menangkap pose idle sama sekali), langsung kembali dengan phase: .invalid, action: .idle dan keputusan debug kosong. (3) Kalau profile.isComplete bernilai false (kalibrasi belum selesai), kembali dengan phase: .invalid, action: .idle tapi tetap mengembalikan pose/orientasi relatif yang nyata (supaya orientasi HP yang sedang berlangsung tetap bisa dipakai untuk pratinjau selama kalibrasi, cuma tidak diperlakukan sebagai input gameplay). (4) Menghitung distances β€” jarak Euclidean (dalam ruang sudut pitch/roll/yaw yang dibungkus) dari pose saat ini ke masing-masing idlePose/castPose/reelPose, dan memilih mana pun yang terdekat sebagai classification. (5) Membangun MotionDebugDecision yang meringkas semua itu (dipakai murni untuk tampilan debug).

(6) Logika reset arm cast: kalau !isCastingArmed, membersihkan castHoldStartTimestamp (tidak ada muatan yang bisa terbentuk kalau pemain belum menekan tombol). Kalau armed, mengatur castEndsAt = input.timestamp β€” komentar menjelaskan ini sengaja: tekanan "Start Casting" baru harus selalu langsung menimpa cooldown followThrough yang masih berjalan tersisa dari percobaan sebelumnya, kalau tidak, menekan ulang tak lama setelah lepas akan memutar ulang releasedCastPower beku yang lama, mungkin hampir maksimal, terbaca seperti "begitu saya tekan Start Casting, power sudah maksimal."

(7) Kalau input.timestamp < castEndsAt (masih di dalam jendela cooldown followThrough pasca-lepas), mengembalikan phase: .followThrough, action: .casting, memutar ulang releasedCastPower/releasedHoldDuration yang dibekukan dari lepasan sesungguhnya, bukan nilai langsung. (8) Kalau isCastingArmed (jalur pengisian utama, tidak bergantung pada klasifikasi pose mentah sesuai "ADR-061" β€” meteran berjalan begitu tombol ditekan, bukan hanya begitu HP secara fisik berayun masuk wilayah pose cast): memulai (atau melanjutkan) castHoldStartTimestamp β€” hanya memulai baru kalau sebelumnya nil (melindungi dari getaran klasifikasi yang menjalankan ulang timer). Menghitung holdDuration dan rumus castPower berosilasi yang sama seperti CastResolver.resolve(_:) ((1 - cos(phase))/2), memakai konstanta oscillationPeriod milik interpreter sendiri (3.5 detik, cocok dengan CastConfig.oscillationPeriod). Menentukan forwardMotion (accelerationZ melewati ambang terkalibrasi, dikoreksi tanda) dan backswing (accelerationZ melewati ambang di tanda yang berlawanan) lalu memilih phase: .backswing kalau menarik ke belakang keras, .release kalau mendorong ke depan keras, kalau tidak .charging (kalau castPower > 0.05) atau .forwardSwing.

Kalau forwardMotion bernilai true dan (ini masuk cast yang baru, atau fase sebelumnya bukan sudah .release β€” artinya ini benar-benar lepasan baru, bukan dorongan maju yang sama yang terus memicu ulang): mengatur castEndsAt untuk memulai cooldown followThrough (input.timestamp + profile.castDuration), membekukan releasedCastPower/releasedHoldDuration, dan mengembalikan phase: .release, action: .casting beserta CastSnapshot nyata (dibangun oleh fungsi bantu privat snapshot(...)) β€” inilah satu-satunya momen lemparan sungguhan terjadi dan dikirim ke RodViewModel/CastResolver. Kalau belum (belum benar- benar melepaskan), mengembalikan fase yang sedang berjalan dengan action: .idle (masih mengisi, belum jadi cast selesai) tapi dengan castPower/castHoldDuration langsung supaya meteran UI bisa menampilkan osilasinya.

(9) Kalau tidak armed, jatuh ke switch classification: .cast β€” pose kebetulan terbaca paling dekat dengan castPose, tapi karena tidak armed, ini diperlakukan persis seperti idle (tanpa timer muatan, tanpa meteran) β€” mereset previousClassification/ previousCastPhase ke idle dan mengembalikan phase: .idle, action: .idle. .reel β€” kalau rotationRateMagnitude belum mencapai profile.reelRotationRate, jatuh ke return generik terakhir (di bawah) dengan action: .idle. Kalau sudah, menghitung speed ternormalisasi antara reelRotationRate (0) dan reelFullRotationRate (1), dibatasi 0...1, dan mengembalikan phase: .reeling, action: .reeling, reelSpeed: speed. .idle β€” cuma mencatat klasifikasi dan lanjut jatuh. (10) Return fallback terakhir: phase adalah .idle kalau klasifikasi idle, kalau tidak .invalid (ini menutup kasus "terklasifikasi sebagai reel tapi laju rotasi terlalu rendah," yang benar terbaca sebagai .invalid, bukan .idle); action: .idle.

Fungsi bantu lain: private var emptyDebug: MotionDebugDecision β€” keputusan debug kosong/ false dipakai untuk dua kasus penjaga early-return (belum ada referensi idle / profil belum lengkap). private func distances(from:) -> (idle, cast, reel, minimum: Classification) β€” menghitung ketiga jarak dan memilih yang minimum, memilih .idle saat seri dengan .cast, dan .cast saat seri dengan .reel. private func distance(_ lhs:, _ rhs:) -> Double β€” jarak Euclidean lintas delta pitch/roll/yaw yang dibungkus (memanggil wrapped(_:) pada tiap selisih sumbu lebih dulu, supaya mis. pose dekat +Ο€ dan satu dekat -Ο€ terbaca dekat, bukan hampir 2Ο€ terpisah). private func relativePose(from current:) -> MotionPose β€” mengurangkan profile.idlePose dari pose saat ini (tiap sumbu dibungkus) β€” inilah yang benar-benar ditampilkan/dikirim sebagai "pose" (relatif terhadap sikap idle terkalibrasi pemain sendiri, bukan koordinat mentah perangkat). private func correctedOrientation(_ current:) -> RodOrientation β€” profile.calibrationReference.inverted() * current β€” mengarahkan ulang quaternion perangkat mentah ke kerangka referensi terkalibrasi pemain. private func wrapped(_ angle: Double) -> Double β€” membungkus sudut apa pun ke (-Ο€, Ο€] memakai aritmetika modulo, supaya selisih sudut dekat batas Β±Ο€ tidak terbaca sangat besar. private func result(...) -> RodMotionInterpretation β€” bantu kecil merakit semua parameter nilai kembali jadi satu RodMotionInterpretation, selalu mencap isPoseValid: profile.isComplete. private func snapshot(input:orientation: holdDuration:) -> CastSnapshot β€” membangun CastSnapshot yang diserahkan balik saat lepasan sungguhan terjadi.

Terhubung ke: Mengonsumsi MotionProfile, RodOrientation, MotionPose (dari MotionProfile.swift); menghasilkan CastSnapshot (dikonsumsi oleh CastResolver, keduanya di RodViewModel); menghasilkan MotionDebugDecision (Debug/); seluruh tipe keluarannya RodMotionInterpretation dibaca langsung oleh RodViewModel.updateAction(); memakai RodAction (di bawah) sebagai tipe field action-nya.

Catatan menarik: Ini logika paling rumit di seluruh kode Rod/Shared. Fakta tidak jelas dengan sendirinya yang layak diingat untuk buku ini: meteran waktu pengisian adalah osilasi berkelanjutan, bukan isi-lalu-batasi β€” menahan lebih lama tidak berarti "power lebih besar," tapi berarti "fase gelombang apa pun yang dilepaskan" (cocok dengan matematika identik milik CastResolver, disengaja, "ADR-057"). Begitu armed, meteran berjalan tanpa peduli klasifikasi pose mentah (ADR-061) β€” cuma dengan meng-arm saja sudah memulainya; setelah itu cuma sentakan akselerasi maju yang penting. Getaran satu-frame yang salah mengklasifikasikan pose kembali ke idle dulu diam-diam mereset seluruh timer muatan ke nol β€” ini sekarang secara eksplisit dijaga (castHoldStartTimestamp cuma dibersihkan oleh penjaga unarmed atau reset profil penuh, tidak pernah oleh derau klasifikasi biasa). Perhitungan kecepatan reel linear antara reelRotationRate terkalibrasi (0% kecepatan) dan reelFullRotationRate (100% kecepatan) β€” kalau kedua nilai terkalibrasi ini berakhir berdekatan, sedikit perbedaan getaran tangan bisa membuat reelSpeed berayun liar; inilah tepatnya kekhawatiran yang ditandai oleh paparan debug RodViewModel.reelCalibrationRange.

Sub-folder Models/Device/

18. ConnectionState.swift (Shared/Sources/Shared/Models/Device/ConnectionState.swift)

Kosakata bersama yang dipakai Rod dan World untuk menggambarkan tahapan siklus hidup koneksi Bluetooth.

Tipe yang didefinisikan: ConnectionState (enum, String, Codable, Sendable, public) β€” .idle, .searching, .connecting, .ready, .streaming, .disconnected, .failed.

Fungsi-fungsi penting: tidak ada di luar kasus raw-value enum.

Terhubung ke: Diatur oleh TransportClient.onStateChange; disimpan/ di-publish oleh RodViewModel.connectionState; ditampilkan di subjudul judul ContentView (teks hijau khusus saat .streaming).

Catatan menarik: .connecting didefinisikan tapi tidak terlihat pernah diatur secara eksplisit di mana pun dalam kode sisi Rod yang dibaca untuk dokumen ini β€” TransportClient hanya pernah melaporkan .searching, .ready, .disconnected, atau .failed dari sisi peripheral Rod; .connecting mungkin hanya dipakai di sisi central World, tidak dibahas di sini.

19. NetworkMessage.swift (Shared/Sources/Shared/Models/Device/NetworkMessage.swift)

Satu tipe amplop level-atas untuk setiap pesan yang dikirim lewat jalur Bluetooth antara Rod dan World, plus aturan jaminan pengiriman mana yang diperlukan tiap jenis pesan.

Tipe yang didefinisikan: NetworkMessage (enum, Codable, Sendable, public) β€” empat kasus: .state(RodState), .discoveryToken(Data), .fishingEvent(FishingEvent), .hookPhase(HookPhase).

Fungsi-fungsi penting: var requiresReliableDelivery: Bool β€” mengembalikan false hanya untuk .state; true untuk tiga lainnya. Ini satu-satunya sumber kebenaran yang dipakai TransportClient.send(_:) untuk memutuskan sendUnreliable vs sendReliable.

Terhubung ke: Membungkus RodState, FishingEvent, HookPhase (semua dibahas di bawah/atas). Dipakai oleh TransportClient (Rod) dan (sesuai komentar dokumentasinya) padanan host transport milik World.

Catatan menarik: Komentar dokumentasi memberikan alasan sesungguhnya di balik pembagian reliable/unreliable: telemetri frekuensi-tinggi berkelanjutan (.state) tidak berharga lagi begitu basi, jadi mengirim ulang yang hilang cuma menambah head-of-line-blocking latency untuk setiap sampel nyata yang mengantre di belakangnya β€” "penyebab klasik ... aliran kontrol terasa lambat di bawah kehilangan paket apa pun." Sinyal sekali-jalan diskret tidak boleh pernah diam-diam dibuang, karenanya reliable.

Sub-folder Models/Gameplay/

20. FishSpecies.swift (Shared/Sources/Shared/Models/Gameplay/FishSpecies.swift)

Data gameplay statis per spesies yang menggambarkan tiap jenis ikan yang bisa ditangkap β€” rentang ukurannya, kesulitan ("resistance"), nilai poin, dan tingkat progresi. Catatan: model ini cuma menggambarkan angka gameplay, bukan referensi aset/mesh 3D apa pun (komentar dokumentasi secara eksplisit mengatakan pemilihan aset belum jadi bagian model ini).

Tipe yang didefinisikan: FishSpeciesRange (struct, Codable, Sendable, Equatable, public) β€” rentang min/max Double inklusif yang (tidak seperti ClosedRange bawaan Swift) bisa di-encode/decode lewat Codable. FishTier (enum, String, Codable, Sendable, CaseIterable, public) β€” .small, .medium, .big, .boss. .boss secara eksplisit didokumentasikan sebagai berarti satu ikan spesifik tunggal ("Scott"), bukan sekadar "ikan reguler terbesar" β€” sebuah unlock terpisah, bukan bagian dari progresi tertimbang-acak yang sama yang diskalakan tiga tingkat lainnya. FishSpecies (struct, Codable, Sendable, Equatable, Identifiable, public) β€” definisi statis lengkap satu spesies.

Fungsi-fungsi penting: FishSpeciesRange.init(min:max:) β€” memberwise biasa. FishSpeciesRange.randomValue() -> Double β€” mengembalikan Double.random(in: min...max). Kemungkinan dipanggil World saat benar-benar melempar dadu berat/panjang spesifik satu ikan pada saat tangkapan (tidak terlihat dipanggil di mana pun dalam kode Rod/Shared yang dibaca di sini). FishSpecies.init(id:name:weightRange:lengthRange:resistance: value:tier:) β€” memberwise, tapi membatasi resistance ke 0...1 lewat min(1, max(0, resistance)) apa pun yang diberikan β€” supaya pemanggil tidak pernah bisa tidak sengaja membuat nilai resistance di luar rentang.

Terhubung ke: resistance langsung memberi makan matematika pullRateResistanceMultiplier/pullSpeedResistanceMultiplier milik FishFightConfig (sesuai komentar dokumentasi file itu sendiri, "resistance * sizeRatio β€” ADR-030"). Tidak dirujuk langsung oleh file lain mana pun yang dibaca dalam lingkup tugas ini β€” ini kemungkinan dikonsumsi oleh logika pemilihan-spesies/tangkapan milik World.

Catatan menarik: File ini bertanggal 18/07/26 di headernya, file dengan tanggal penulisan terbaru di antara yang ditinjau di sini β€” pemodelan gameplay spesies ikan ditambahkan setelah alur inti motion/transport sudah dibangun.

21. FishingEvent.swift (Shared/Sources/Shared/Models/Gameplay/FishingEvent.swift)

Momen-momen gameplay diskret spesifik yang diberitahukan World ke Rod supaya Rod bisa bereaksi dengan haptic β€” enum ini adalah seluruh kosakata "hal-hal yang bisa terjadi selama tangkapan" sejauh yang dipedulikan pengendali.

Tipe yang didefinisikan: FishingEvent (enum, String, Codable, Sendable, Equatable, public) β€” .cast, .hookSplash, .reelTick, .fishInterested, .fishBite, .fishEscaped, .catchFish, .lineBreak, .reelTensionLow, .reelTensionMedium, .reelTensionHigh.

Fungsi-fungsi penting: tidak ada di luar kasus mentahnya.

Terhubung ke: Dikirim World di dalam NetworkMessage.fishingEvent(_:); diterima TransportClient.onMessage -> RodViewModel.handle(_:) -> FishingHaptics.handle(_:), yang memetakan tiap kasus ke respons haptic spesifik (lihat file 3 di atas).

Catatan menarik: Tiga kasus reelTension* secara eksplisit didokumentasikan terkait dengan tension milik FishFightController yang melewati batas band terkonfigurasi (FishFightConfig.tensionBandMediumThreshold/ tensionBandHighThreshold) β€” World mengirim salah satunya setiap kali tension melewati batas, lalu Rod bergetar terus-menerus sesuai ritme band itu sampai event band berikutnya atau event apa pun yang mengakhiri perjuangan membersihkannya.

22. HookPhase.swift (Shared/Sources/Shared/Models/Gameplay/HookPhase.swift)

Fase alur permainan yang otoritatif untuk siklus lempar/tangkap saat ini, dimiliki World (logika permainan sesungguhnya ada di sana) tapi dicerminkan ke Rod supaya UI/interpreter lokal Rod bisa membatasi dirinya sendiri dengan benar (mis. jangan izinkan lemparan di tengah perjuangan).

Tipe yang didefinisikan: HookPhase (enum, String, Codable, Sendable, public) β€” .idle, .flying, .hookInWater, .fishOnHook, .reeling, .result.

Fungsi-fungsi penting (extension): var allowsCasting: Bool β€” benar hanya untuk .idle. var allowsReeling: Bool β€” benar untuk .hookInWater, .fishOnHook, atau .reeling.

Terhubung ke: Dikirim World di dalam NetworkMessage.hookPhase(_:); diterima dan disimpan sebagai RodViewModel.hookPhase; dikonsultasikan di seluruh RodViewModel (startCasting(), calibrateMotion(), logika pemaksaan-aksi updateAction()) dan ContentView (kondisi nonaktif tombol).

Catatan menarik: .result didokumentasikan sebagai "sebuah tangkapan baru saja selesai dan World sedang menampilkan layar hasil. Pemain harus menutupnya ... sebelum lemparan berikutnya diizinkan" β€” tapi sesuai ADR-061, tombol "Start Casting" milik Rod sendiri bisa jadi pemicu penutupan itu (lihat RodViewModel.startCasting() dan kasus .hookPhase di .handle(_:)) alih-alih memerlukan sentuhan "Continue Fishing" terpisah di layar World.

Sub-folder Models/Rod/

23. CalibrationStep.swift (Shared/Sources/Shared/Models/Rod/CalibrationStep.swift)

Urutan empat langkah yang dilalui pemain sekali sebelum lemparan pertama mereka: tangkap pose idle, tangkap gestur cast, tangkap gestur reel, selesai. Dimiliki/dijalankan oleh Rod (RodViewModel.calibrateMotion()) tapi dikirim ke World lewat RodState (didokumentasikan sebagai "ADR-064") secara khusus supaya World juga bisa menampilkan panduan kalibrasi di layarnya sendiri, alih-alih teks instruksi itu cuma ada di layar Rod (ditandai di komentar dokumentasi sebagai "celah terbuka sejak ADR-029" dalam maksud desain bahwa instruksi kalibrasi seharusnya tampil "seluruhnya di World").

Tipe yang didefinisikan: CalibrationStep (enum, String, Codable, Sendable, Equatable, public) β€” .idle, .cast, .reel, .complete.

Fungsi-fungsi penting: tidak ada di luar kasus mentahnya.

Terhubung ke: Dimajukan oleh RodViewModel.calibrateMotion(); dibawa di dalam RodState.calibrationStep; dibaca oleh ContentView (properti dihitung calibrationInstruction/calibrationButtonTitle) dan, sesuai komentar dokumentasinya, dimaksudkan juga mengendalikan UI sisi World.

Catatan menarik: Komentar dokumentasi sendiri menandai celah desain yang diakui β€” instruksi kalibrasi saat ini cuma ada di layar Rod meski maksud desain mengatakan World seharusnya menampilkannya; field ini dikirim secara khusus supaya nanti bisa menutup celah itu.

24. RodAction.swift (Shared/Sources/Shared/Models/Rod/RodAction.swift)

Ringkasan paling sederhana dari "apa yang sedang dilakukan rod pemain sekarang" sebagaimana disimpulkan dari gerak β€” kata kerja gameplay akhir yang kasar (berlawanan dengan RodMotionPhase, yang jauh lebih terperinci).

Tipe yang didefinisikan: RodAction (enum, String, Codable, Sendable, Equatable, public) β€” .idle, .casting, .reeling.

Fungsi-fungsi penting: tidak ada di luar kasus mentahnya.

Terhubung ke: Dihasilkan oleh RodMotionInterpreter.interpret(_:isCastingArmed:) (sebagai RodMotionInterpretation.action), lalu dibatasi lebih lanjut oleh RodViewModel.updateAction() terhadap hookPhase.allowsCasting/ allowsReeling sebelum disimpan sebagai RodViewModel.action dan dikirim di dalam RodState.action. Dipakai oleh ContentView.developerDetails untuk tampilan.

Catatan menarik: Tidak ada β€” sengaja dibuat minimal.

25. RodState.swift (Shared/Sources/Shared/Models/Rod/RodState.swift)

Potret tunggal dan lengkap dari segala hal yang diketahui Rod tentang pengendali di satu momen β€” inilah payload yang dikirim ke World kira-kira 30 kali per detik lewat kanal .state BLE yang tidak reliable, dan digambarkan di komentar dokumentasinya sendiri sebagai "satu-satunya sumber kebenaran yang dikirim ke World."

Tipe yang didefinisikan: RodState (struct, Codable, Sendable, Equatable, public) β€” potret lengkap. RodState.CodingKeys (enum bertingkat setengah-privat, String, CodingKey) β€” memetakan ulang setiap properti jadi nama wire pendek 1-3 karakter untuk encoding JSON.

Fungsi-fungsi penting: init(timestamp:spatial:pitch:roll:yaw:orientation: action:reelSpeed:castSolution:motionPhase:castPower:castHoldDuration:isFishing:isCastingArmed: calibrationStep:) β€” memberwise, tapi mengambil seluruh SpatialState (spatial:) sebagai parameter kemudahan dan membongkarnya jadi empat properti datar distance/directionX/Y/Z, alih-alih menyimpan SpatialState itu sendiri. timestamp default ke Date().timeIntervalSince1970; hampir semua yang lain default ke nilai idle/netral.

Terhubung ke: Dirakit terus-menerus oleh RodViewModel (publishState()/updateAction()/handle(_:)); dibungkus di dalam NetworkMessage.state(_:); dikirim lewat TransportClient.sendUnreliable(_:); di-decode dan dikonsumsi di sisi World (di luar lingkup sini). Memakai RodOrientation, RodAction, CastSolution, RodMotionPhase, CalibrationStep (semua Shared) dan meratakan SpatialState.

Catatan menarik: Ini perbaikan bug historis yang terdokumentasi nyata ("ADR-063"). Pemetaan ulang CodingKeys jadi kunci pendek ada murni untuk menjaga payload JSON yang di-encode tetap di bawah anggaran satu-potongan BLE ~512-byte: dengan kunci default (nama properti penuh), RodState yang membawa castSolution terisi secara konsisten terukur 531-542 byte β€” sedikit di atas anggaran β€” artinya aturan "buang kalau tidak muat dalam satu potongan" milik TransportClient.sendUnreliable(_:) diam-diam membuang data setiap percobaan cast sungguhan untuk seluruh jendela followThrough-nya, tanpa error di mana pun. Kunci pendek menurunkan payload yang sama jadi sekitar 420 byte, memulihkan margin nyata. Ini contoh konkret yang bagus untuk buku ini, tentang bagaimana pilihan yang tampak kosmetik (nama kunci JSON) sebenarnya menentukan kebenaran fungsional.

Sub-folder Models/Spatial/

26. SpatialState.swift (Shared/Sources/Shared/Models/Spatial/SpatialState.swift)

Potret kecil dan tidak-terikat-platform dari posisi relatif World, sebagaimana diukur oleh sesi Nearby Interaction milik Rod β€” dijaga terpisah dari tipe Apple milik NISession sendiri supaya bisa Codable/dibagikan tanpa mengimpor framework NearbyInteraction ke Shared.

Tipe yang didefinisikan: SpatialState (struct, Codable, Sendable, Equatable, public).

Fungsi-fungsi penting: init(distance:directionX:directionY:directionZ:) β€” memberwise, semuanya default nil (berarti "belum ada pembacaan").

Terhubung ke: Dibangun oleh callback delegate NISessionDelegate milik NearbyInteractionManager; disimpan sebagai RodViewModel.spatial; diratakan langsung ke empat field distance/direction* milik RodState lewat RodState.init(spatial:...).

Catatan menarik: Tidak ada di luar yang sudah dibahas.

27. SpatialStatus.swift (Shared/Sources/Shared/Models/Spatial/SpatialStatus.swift)

Status siklus hidup sesi Nearby Interaction (UWB), dicerminkan ke UI supaya pemain/pengembang bisa melihat apakah pelacakan spasial bekerja.

Tipe yang didefinisikan: SpatialStatus (enum, String, Codable, Sendable, public) β€” .unavailable, .searching, .ready, .updating, .failed.

Fungsi-fungsi penting: tidak ada di luar kasus mentahnya.

Terhubung ke: Dilaporkan oleh NearbyInteractionManager.onStatusChange; disimpan sebagai RodViewModel.spatialStatus; ditampilkan di ContentView.developerDetails (saat ini tidak tampil ke pemain karena tampilan itu dikomentari).

Catatan menarik: Tidak ada.

Sub-folder Protocols/

28. BLEFraming.swift (Shared/Sources/Shared/Protocols/BLEFraming.swift)

Implementasi murni, bebas CoreBluetooth, untuk memecah pesan yang sudah di-encode jadi potongan- potongan berukuran paket BLE (dengan header panjang supaya penerima bisa merakitnya kembali), plus reassembler pasangannya di sisi penerima. Karena tidak punya impor platform sama sekali, ini bisa dipakai identik oleh transport sisi-peripheral milik Rod maupun transport sisi-central milik World.

Tipe yang didefinisikan: BLEFraming (enum, public, dipakai murni sebagai namespace β€” tanpa kasus, hanya anggota statis). BLEMessageReassembler (final class, public) β€” akumulator potongan masuk per-karakteristik yang ber-state.

Fungsi-fungsi penting: BLEFraming.static let headerSize = 4 β€” header awalan-panjang selalu 4 byte. BLEFraming.static func chunks(for payload: Data, maxChunkSize: Int) -> [Data] β€” Mensyaratkan (lewat precondition) bahwa maxChunkSize > headerSize (crash keras kalau dilanggar β€” ini penjaga kesalahan programmer, bukan kondisi runtime yang diharapkan di produksi). Membangun header panjang 4-byte big-endian untuk total jumlah byte payload, menempelkannya di depan payload, lalu mengiris gabungan header+payload jadi potongan-potongan berturutan sebesar maxChunkSize (potongan terakhir boleh lebih pendek). Mengembalikan daftar potongan. Dipanggil oleh TransportClient.sendUnreliable(_:)/sendReliable(_:) di sisi Rod (dan, kemungkinan, lapisan transport padanan di sisi World).

BLEMessageReassembler.init() β€” kosong; mulai dengan buffer kosong. func feed(_ chunk: Data) -> [Data] (@discardableResult) β€” Menambahkan potongan baru ke buffer internal, lalu berulang: selagi buffer memiliki setidaknya headerSize byte, membaca panjang 4-byte big-endian dari depan, menghitung totalNeeded = headerSize + length; kalau buffer belum punya sebanyak itu, berhenti (menunggu data lebih lanjut di pemanggilan berikutnya). Begitu punya, mengekstrak persis length byte tepat setelah header sebagai satu pesan lengkap, menambahkannya ke daftar hasil, dan menghapus header+payload yang sudah dipakai dari depan buffer β€” lalu berulang lagi kalau- kalau beberapa pesan lengkap datang bersamaan dalam satu pengiriman. Mengembalikan setiap pesan lengkap yang diekstrak (biasanya 0 atau 1 per pemanggilan, tapi bisa lebih). func reset() β€” membersihkan seluruh buffer internal. Dipanggil oleh TransportClient saat disconnect/ unsubscribe/forget, supaya potongan parsial basi dari koneksi sebelumnya tidak bisa merusak pesan pertama dari koneksi berikutnya.

Terhubung ke: Dipakai oleh TransportClient (Rod) di sisi keluar (chunks(for:maxChunkSize:)) dan sisi masuk (inboundReassembler: BLEMessageReassembler, diberi makan di dalam peripheralManager(_:didReceiveWrite:)).

Catatan menarik: Ini pipa level-rendah yang membuat pengiriman reliable terpecah (indicate/write) memungkinkan sama sekali β€” jalur unreliable .state sengaja tidak pernah memakai lebih dari satu potongan (lihat TransportClient.sendUnreliable), jadi pada praktiknya reassembler ini di sisi Rod hanya benar-benar dipakai untuk penulisan karakteristik command yang masuk dari World (pesan .fishingEvent/.hookPhase), meski logika framing-nya sendiri generik.

29. Transport.swift (Shared/Sources/Shared/Protocols/Transport.swift)

Kontrak abstrak yang diikuti kedua lapisan transport, supaya RodViewModel (dan kemungkinan view model milik World) bisa bergantung pada "sebuah transport" tanpa peduli apakah itu berbasis BLE-peripheral (TransportClient milik Rod) atau BLE-central (padanan milik World).

Tipe yang didefinisikan: TransportProtocol (protocol, @MainActor, AnyObject, public).

Fungsi-fungsi penting (syarat protokol saja β€” tidak ada implementasi di sini): var isConnected: Bool { get }. func connect() async throws. func disconnect(). func send(_ message: NetworkMessage) async throws.

Terhubung ke: Diimplementasikan oleh TransportClient (Rod, Rod/Rod/Transport/TransportClient.swift) dan (sesuai komentar dokumentasinya sendiri) tipe TransportHost di sisi World. Dipakai secara konseptual sebagai tipe statis untuk referensi transport tersimpan milik RodViewModel (meski RodViewModel sebenarnya menyimpan tipe konkret TransportClient langsung, bukan protokol ini, di file yang dibaca).

Catatan menarik: Anotasi @MainActor pada seluruh protokol dijelaskan di komentar dokumentasinya sendiri sebagai sengaja: kedua implementasi sungguhan membungkus manager CoreBluetooth yang callback-nya di antrean utama, dan kedua titik pemanggilan sungguhan juga sendiri @MainActor β€” memberi anotasi di protokol ini menjadikan jaminan yang sudah benar itu eksplisit dan menghindari error kompilasi lintas-isolasi-actor di bawah pemeriksaan concurrency Swift 6 yang lebih ketat.

Sub-folder Spatial/

30. SessionConfiguration.swift (Shared/Sources/Shared/Spatial/SessionConfiguration.swift)

Satu-satunya tempat kode Bluetooth kedua aplikasi mencari UUID GATT yang persis, supaya Rod (peripheral) dan World (central) selalu sepakat layanan/karakteristik mana yang diiklankan/dipindai/ dibaca/ditulis. Disimpan sebagai String biasa (bukan CBUUID milik CoreBluetooth) secara khusus supaya file ini tidak memaksa Shared mengimpor CoreBluetooth.

Tipe yang didefinisikan: SessionConfiguration (enum, public, hanya-namespace β€” tanpa kasus).

Fungsi-fungsi penting: tidak ada β€” hanya konstanta statis.

Nilai-nilai penting: bleServiceUUID = "6E9B0001-2B5A-4F1E-9C3D-2B1A6E9B0001" β€” satu-satunya layanan GATT kustom yang dicari kedua sisi. bleStateCharacteristicUUID = "6E9B0002-..." β€” Rod -> World, notify, tidak reliable; membawa stream RodState ~30Hz; tidak pernah dipecah (didokumentasikan konsisten dengan perilaku TransportClient.sendUnreliable). bleReliableOutCharacteristicUUID = "6E9B0003-..." β€” Rod -> World, indicate, reliable; membawa .discoveryToken; boleh dipecah karena ack per-paket milik indicate sendiri membuat itu aman. bleCommandCharacteristicUUID = "6E9B0004-..." β€” World -> Rod, write-dengan-respons, reliable; membawa .fishingEvent/.hookPhase; juga boleh dipecah untuk alasan yang sama.

Terhubung ke: Dibaca oleh TransportClient (properti serviceUUID/stateUUID/reliableOutUUID/commandUUID, semuanya dibangun dari string-string ini lewat CBUUID(string:)).

Catatan menarik: Keempat UUID berurutan (...0001 sampai ...0004) β€” jelas dipilih tangan sebagai blok layanan privat kustom, bukan diturunkan dari layanan standar Bluetooth SIG mana pun. Komentar dokumentasi di sini adalah sumber otoritatif untuk karakteristik mana dipakai arah/reliabilitas mana, cocok persis dengan yang diimplementasikan TransportClient.

Alur Data: Dari Sensor HP ke Efek Gameplay

Bagian ini menelusuri langkah demi langkah bagaimana satu gerakan fisik benar-benar berubah jadi sesuatu yang direspons game, menyebut fungsi/tipe nyata yang terlibat.

1. Sensor dibaca (perangkat keras HP -> MotionManager). CMMotionManager (dibungkus MotionManager, Rod/Rod/Sensors/MotionManager.swift) dinyalakan oleh MotionManager.start(), mengambil sampel device motion pada laju tetap 30 Hz (interval 1.0/30.0). Setiap callback CoreMotion di bawahnya, ia membangun MotionSample (timestamp, pitch, roll, yaw, accelerationZ, dan rotationRateMagnitude yang dihitung β€” magnitudo 3D dari putaran) lalu memanggil onUpdate?(sample).

2. Persinggahan pertama: RodViewModel. Closure motionManager.onUpdate milik RodViewModel (disiapkan di init) jalan di @MainActor. Ia: menyimpan sampel ke self.motion, mengatur hasMotionSample = true, memanggil collectCalibrationSample() (hanya penting selama alur kalibrasi sekali-jalan), lalu memanggil updateAction(), lalu publishState().

3. Interpretasi/kalibrasi (RodMotionInterpreter, Shared). Di dalam updateAction(), RodViewModel membangun RodMotionInput dari sampel mentah dan memanggil motionInterpreter.interpret(motionInput, isCastingArmed: isCastingArmed) (Shared/Sources/Shared/Gameplay/RodMotionInterpreter.swift). Fungsi ini membandingkan pose saat ini dengan MotionProfile pemain sendiri yang dikalibrasi (ditangkap sebelumnya lewat urutan empat-langkah RodViewModel.calibrateMotion() β€” pose idle/cast/reel plus ambang akselerasi/rotasi turunan, disimpan permanen ke UserDefaults) dan mengklasifikasikan gerakan jadi salah satu dari .idle/.cast/.reel. Tergantung klasifikasi dan apakah cast sedang armed (isCastingArmed, hanya true setelah pemain menekan "Start Casting"), ia mengembalikan RodMotionInterpretation yang membawa: pose relatif, orientation yang dikoreksi (quaternion, RodOrientation), RodMotionPhase kasar (.idle/.backswing/.charging/ .../.release/.followThrough/.reeling), RodAction hasil (.idle/.casting/.reeling), reelSpeed (0...1, hanya saat menggulung), dan β€” krusial, hanya persis pada momen lepasan sentakan-maju sungguhan β€” sebuah CastSnapshot (orientasi + timestamp + berapa lama tarik-belakang ditahan, yaitu backswingHoldDuration).

4. Penyelesaian lemparan (CastResolver, Shared). Kembali di RodViewModel.updateAction(), kalau interpretasi mengikutkan castSnapshot, ini diserahkan ke castResolver.resolve(snapshot) (Shared/Sources/Shared/Gameplay/CastResolver.swift). Ini menghitung direction lemparan ruang-dunia sesungguhnya (memutar vektor maju dengan orientasi snapshot lewat RodOrientation.act(_:), diratakan horizontal), nilai power dari kurva meteran waktu yang berosilasi terus-menerus ((1 - cos(phase))/2 selama oscillationPeriod detik β€” power tidak cuma naik lalu berhenti di batas atas; melepaskan di instan berbeda dari tahanan yang sama memberi power berbeda), dan launchAngle yang diinterpolasi memakai power itu. RodViewModel kemudian menempelkan distance lewat castMeterConfig.distance(for: resolved.power) (CastMeterConfig, ramp bertingkat pendek/menengah/panjang) dan langsung membersihkan isCastingArmed β€” satu tekanan "Start Casting" hanya pernah menghasilkan satu lemparan.

5. Merakit potret keluar (RodState, Shared). RodViewModel (masih di dalam updateAction(), lalu lagi di publishState()) membangun ulang rodState: RodState yang di-publish (Shared/Sources/Shared/Models/Rod/RodState.swift) β€” struct "segala yang Rod tahu sekarang" tunggal: pose, orientasi, action, reelSpeed, castSolution (kalau ada), motionPhase, castPower/castHoldDuration (untuk meteran pengisian langsung), plus penanda sesi isFishing/isCastingArmed/ calibrationStep, plus SpatialState (jarak/arah dari Nearby Interaction) apa pun yang saat ini diketahui.

6. Transport Bluetooth (TransportClient, Rod; framing lewat BLEFraming, Shared). RodViewModel.publishState() membungkus RodState dalam NetworkMessage.state(rodState) (Shared/Sources/Shared/Models/Device/NetworkMessage.swift) dan memanggil transport.send(_:) (Rod/Rod/Transport/TransportClient.swift). Karena .state bukan salah satu kasus requiresReliableDelivery, TransportClient.send(_:) mengarahkannya lewat sendUnreliable(_:): byte JSON yang di-encode dipecah lewat BLEFraming.chunks(for:maxChunkSize:) (Shared/Sources/Shared/Protocols/BLEFraming.swift), tapi β€” sengaja β€” kalau RodState yang di-encode tidak muat dalam persis satu paket BLE (dianggarkan oleh maximumUpdateValueLength milik central yang berlangganan), seluruh sampel dibuang alih- alih dipecah lebih lanjut, karena sampel yang lebih segar selalu ~33ms lagi dan aliran multi-potongan yang tidak tersinkron dan tidak berurutan akan lebih buruk. (Ini persis mode kegagalan yang dicegah pemetaan ulang kunci wire pendek milik RodState.CodingKeys β€” tanpanya, castSolution yang terisi dulu mendorong payload setiap lemparan sungguhan sedikit lewat anggaran, diam-diam membuangnya setiap kali.) Potongan tunggal itu dikirim lewat peripheralManager.updateValue(_:for:onSubscribedCentrals:), dibatasi hanya ke CBCentral spesifik yang dipasangkan dengan Rod (PairedCentralStore), menyasar karakteristik GATT bleStateCharacteristicUUID (SessionConfiguration).

7. Framing/decoding saat tiba (sisi World, tidak dibahas dokumen ini). Kode central BLE milik World sendiri (tidak dibaca untuk tugas ini) menerima notify, merakit ulang/men-decode JSON kembali jadi RodState, lalu menjalankan gameplay-nya sendiri darinya β€” mis. membengkokkan tulang rod lewat pitch/orientation, menembakkan lemparan memakai castSolution, menggerakkan ikan yang terkait memakai reelSpeed. Pesan sekali-jalan diskret yang menuju arah sebaliknya (World -> Rod) β€” .discoveryToken, .fishingEvent, .hookPhase β€” selalu dikirim reliable (requiresReliableDelivery == true), ditulis ke karakteristik bleCommandCharacteristicUUID, dan di sisi Rod dirakit ulang oleh inboundReassembler: BLEMessageReassembler milik TransportClient (memberi makan byte permintaan-tulis mentah lewat feed(_:) sampai pesan lengkap muncul), lalu di-decode JSON kembali jadi nilai NetworkMessage dan diserahkan ke onMessage?(message).

8. Yang dilakukan Rod begitu pesan kembali dari World. RodViewModel.handle(_:) adalah saklar untuk apa pun yang dikirim balik World: .hookPhase(phase) memperbarui RodViewModel.hookPhase β€” inilah yang benar- benar membatasi apakah gerak boleh diinterpretasikan sebagai lemparan/gulungan sungguhan sama sekali (updateAction() memaksa action kembali ke .idle kalau klasifikasi interpreter saat ini tidak diizinkan oleh HookPhase.allowsCasting/ allowsReeling), dan juga memberi tahu FishingHaptics untuk stopInterest()/stopFight() sesuai kebutuhan saat fase berpindah. .fishingEvent(event) diserahkan langsung ke fishingHaptics.handle(event) (Rod/Rod/Haptics/FishingHaptics.swift), yang memetakan tiap kasus FishingEvent (.cast, .hookSplash, .fishInterested, .fishBite, .fishEscaped, .lineBreak, .catchFish, atau salah satu dari tiga sinyal band .reelTension*) ke panggilan berbeda pada HapticManager (Rod/Rod/Haptics/HapticManager.swift), satu-satunya file yang benar-benar bicara ke CoreHaptics dan menyalakan pola getar fisik di HP. .discoveryToken(data) diserahkan ke spatialManager.handleDiscoveryToken(data) (Rod/Rod/Sensors/NearbyInteractionManager.swift), yang memulai sesi ranging Nearby Interaction (UWB) terhadap Mac milik World, memberi makan SpatialState (jarak/arah) kembali ke RodState di setiap update berikutnya.

Jadi ujung ke ujung: giroskop/akselerometer HP (CMMotionManager) -> MotionManager.MotionSample -> RodViewModel.updateAction() -> RodMotionInterpreter.interpret(_:isCastingArmed:) (mengklasifikasikan terhadap MotionProfile pemain sendiri yang dikalibrasi) -> (saat lepasan sungguhan) CastResolver.resolve(_:) menghitung fisika lemparan akhir -> dirakit jadi RodState -> dibungkus dalam NetworkMessage.state(_:) -> dipecah/dikirim lewat Bluetooth oleh TransportClient memakai UUID dari SessionConfiguration dan bantu framing dari BLEFraming -> diterima dan ditindaklanjuti World (di luar lingkup) -> sinyal gameplay hasil World (FishingEvent/HookPhase) melintas balik lewat transport yang sama, kali ini reliable -> RodViewModel.handle(_:) mengarahkannya ke FishingHaptics/HapticManager, langkah terakhir yang benar- benar membuat HP bergetar di tangan pemain.