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.