Bab 7: Kode Aplikasi "World" (Renderer 3D)
Repo aset yang kita kerjakan (fish + environment + rod) bukan aplikasi itu sendiri β dia cuma menghasilkan file .usdz. Aplikasi yang benar-benar merender semua aset itu jadi dunia 3D game mancing bernama "World", letaknya di repo terpisah ~/repos/c4/CtSPoC, folder World/World/. World jalan di iPad/Mac, pakai framework RealityKit dari Apple untuk render danau, dermaga, dan ikan. Ada juga aplikasi pendamping bernama "Rod" (jalan di iPhone, jadi alat kontrol gerak) yang terhubung ke World lewat Bluetooth Low Energy (BLE) β tapi Rod tidak dibahas di bab ini.
Sebelum masuk ke detail, ada beberapa istilah yang dipakai terus-menerus di bab ini:
- Entity: istilah RealityKit untuk "benda apa pun di scene 3D" β bisa ikan, joran, air, lampu, atau titik jangkar yang tidak kelihatan.
.usdz: format file 3D yang dipakai untuk semua aset (dermaga, ikan, joran, pohon, dll), dihasilkan dari repo Blender terpisah (repo kita).@MainActor: anotasi Swift yang artinya "kode ini wajib jalan di thread utama/UI." Dipakai di banyak tempat karena RealityKit, SwiftUI, dan callback Bluetooth semua butuh aman terhadap thread UI.@Published: penanda properti yang membuat tampilan SwiftUI otomatis redraw setiap kali nilainya berubah.- ADR ("Architecture Decision Record"): catatan keputusan desain bernomor yang sering disebut di komentar kode (mis. "ADR-039"). Ini bukan file di aplikasi ini, tapi log keputusan terpisah yang dipegang tim β nomornya cuma referensi historis kenapa sebuah angka/pendekatan dipilih.
1. WorldApp.swift
Ini titik masuk paling awal aplikasi β potongan kode kecil yang memberitahu sistem operasi "kalau aplikasi ini dibuka, tampilkan jendela ini isinya tampilan ini." Setiap aplikasi Swift/SwiftUI butuh persis satu file seperti ini.
Tipe yang didefinisikan: WorldApp (struct, mengikuti protokol App) β aplikasinya sendiri. Ditandai @main, sinyal ke compiler "mulai eksekusi dari sini."
Fungsi-fungsi penting: var body: some Scene β mengembalikan sebuah WindowGroup yang isinya ContentView(). Cuma ini yang dilakukan file ini: buka satu jendela yang menampilkan ContentView. Dipanggil otomatis oleh OS saat aplikasi dibuka, tidak ada kode lain yang memanggilnya langsung.
Terhubung ke: Membuat instance ContentView (file berikutnya). Tidak ada file lain yang bergantung ke file ini β dia adalah akar, bukan layanan yang dipanggil kode lain.
Catatan menarik: Tidak ada yang aneh, ini boilerplate SwiftUI standar, cuma 17 baris.
2. ContentView.swift
Ini satu-satunya tampilan SwiftUI paling atas untuk seluruh aplikasi. Dia menyusun tampilan 3D RealityKit ditambah semua overlay 2D (menu, HUD, cutscene, kartu hasil, banner naik level) yang bisa muncul di atasnya, dan menentukan overlay mana yang tampil berdasarkan state game saat ini.
Tipe yang didefinisikan: ContentView (struct, View SwiftUI) β satu-satunya tampilan paling atas.
Fungsi-fungsi penting:
init()β membangun sebuahWorldViewModel(dengan memasukkanTransportHostyang baru dibuat, objek koneksi Bluetooth) lalu menghubungkannya ke dua property wrapper SwiftUI:@StateObjectuntuk view model itu sendiri, dan@ObservedObjectuntukviewModel.fishFight(pengontrol perkelahian ikan) supaya tampilan juga redraw ketika state perkelahian berubah, tidak cuma saat state view-model utama berubah.var body: some Viewβ sebuahZStack(menumpuk semua layer) berisi:contentutama (scene 3D + HUD), lalu secara kondisional:OnboardingView(tutorial pertama kali main),MainMenuView, overlay kalibrasi ulang,CatchCutsceneView,ResultView, danLevelUpBannerView. Masing-masing hanya muncul kalau kondisiviewModel.screen/viewModel.hookPhase/dll terpenuhi, jadi cuma overlay yang relevan yang tampil bersamaan.private var recalibrationOverlay: some Viewβ latar hitam redup plus kartuCalibrationGuidanceViewdi tengah. Ditampilkan di tengah sesi (bukan cuma sebelum main pertama kali) kapan pun hook sedang idle dan joran belum dikalibrasi β ditambahkan sesuai "ADR-064" supaya pemain bisa mengulang walkthrough kalibrasi kapan saja saat idle, tidak cuma dari Main Menu.private var content: some Viewβ layar gameplay sebenarnya: sebuahRealityView(jembatan SwiftUI-nya RealityKit) yang saatmakemengatur mode kamera ke.virtual(3D sintetis penuh, tidak ada kamera asli β mengonfirmasi ini BUKAN aplikasi AR), memanggilviewModel.makeSceneEntity()untuk membangun seluruh scene 3D secara asinkron lalu menambahkannya, kemudian memanggilviewModel.attachFrameLoop(to:)untuk memulai tick simulasi per-frame. Di bawah tampilan 3D ada penghitung FPS, label status koneksi, sebuahGroupBoxdebug (dikomentari β "Debug purpose only. Can ignore"),LineTensionView(meteran tegangan senar saat perkelahian), sebuahGroupBoxringkasan jumlah tangkapan/level/hasil terakhir,CastMeterView(bar kekuatan lemparan), dan label error kalaulastErrorterisi. Seluruh "Group" elemen HUD ini memudar saat cutscene tangkapan berlangsung supaya tidak bersaing secara visual dengan momen "CATCH!"..onAppear { viewModel.start() }/.onDisappear { viewModel.stop() }menyalakan/mematikan koneksi Bluetooth dan audio.private var fpsIndicator: some Viewβ badge teks FPS kecil berwarna (hijau/kuning/merah sesuai ambang batas).private func row(_ title:_ value:) -> some Viewβ helper debug, satu baris label+nilai dalamGrid(cuma dipakai blok debug yang dikomentari).private func format(_ value: Double) -> String/format(_ value: Float) -> String/format(_ value: Float?, suffix:) -> Stringβ formatter angka jadi string sederhana (3 desimal), dipakai blok debug.private func formatDirection(_ state: RodState) -> Stringβ memformat vektor arah mentah joran jadi"x, y, z", atau"--"kalau ada komponen yang kosong. Debug saja.private func formatXZ(_ point: SIMD3<Float>?) -> Stringβ memformat X/Z dari titik 3D jadi"(x, z)". Debug saja, dipakai diagnostik posisi jatuh lemparan.private func formatDegrees(_ degrees: Float?) -> Stringβ memformat sudut derajat dengan akhiranΒ°. Debug saja.
Terhubung ke: Membuat WorldViewModel dan TransportHost. Menampilkan OnboardingView, MainMenuView, CalibrationGuidanceView, CatchCutsceneView, ResultView, LevelUpBannerView, LineTensionView, CastMeterView β jadi ini pusat penyambung semua file View lain berdasarkan state WorldViewModel. Mengimpor RealityKit dan paket Shared (untuk RodState).
Catatan menarik: Blok GroupBox debug besar (Controller State, Cast Direction Debug) seluruhnya dikomentari dengan komentar eksplisit "Debug purpose only. Can ignore" β dibiarkan di file untuk dinyalakan lagi nanti, bukan dihapus. Komentar di atas recalibrationOverlay mengutip "ADR-064" sebagai alasan kenapa kalibrasi ulang sekarang boleh di tengah sesi, tidak cuma pra-game.
3. Audio/FishingSounds.swift
Ini "router" suara yang paham logika game. Dia menerjemahkan event mancing tingkat tinggi dan perubahan fase jadi panggilan pemutaran suara nyata ke SoundManager. Dia sengaja tidak pernah menyentuh transport Bluetooth β WorldViewModel adalah satu-satunya tempat yang memutar suara SEKALIGUS mengirim event yang sama ke Rod, dari satu titik panggilan yang sama, "supaya keduanya tidak bisa jadi tidak sinkron."
Tipe yang didefinisikan:
FishingSounds(final class,@MainActor) β router suara itu sendiri.FishingSounds.Config(struct) β satu setting:ambiencePlaysInMenu: Bool = true(suara latar danau tetap main walau di menu utama, tidak cuma saat gameplay, sesuai keputusan langsung pengguna "ADR-040").
Fungsi-fungsi penting:
init(sounds: SoundManager, config: Config = Config())β menyimpan referensi saja, tidak ada setup lain.func handle(_ event: FishingEvent)β switch utama untuk semua event, mirip persis handler haptic di aplikasi Rod. Untuk tiap kasusFishingEvent:.hookSplashmemutar suara cebur;.fishInterestedmemulai loop suara "tertarik" yang halus;.fishBitemenghentikan loop itu dan memutar suara gigitan;.fishEscaped/.lineBreakkeduanya memakai ulang suara cebur (belum ada aset khusus) dan menghentikan loop tertarik/reel;.catchFishmenghentikan loop reel lalu memutar suara cebur-tangkap dan suara "ikan tertangkap" sekaligus; event pita tegangan (.reelTensionLow/Medium/High) tidak melakukan apa-apa di sini (haptic saja, di sisi Rod) β.cast/.reelTickjuga tidak melakukan apa-apa karena World memang tidak pernah mengeluarkan dua event itu (keduanya khusus lokal di Rod).func castLaunched()β memutar suara lempar. Dipanggil langsung olehWorldViewModel.launchHook(solution:)karena World tidak pernah mengeluarkan event.castyang resmi.func uiClick()β memutar suara klik UI (dipakai misalnya saat menutup banner naik level).func updateReelLoop(active: Bool)β menyalakan/mematikan loop suara "reel". Dipanggil setiap tick (bukan cuma saat event berubah) dengan status apakah pemain sedang aktif reel di fase yang mengizinkannya β dengan begini walaupun sedang menarik "kosong" (belum ada ikan,.hookInWater) tetap dapat suara reel, sesuatu yang akan terlewat kalau desainnya cuma berbasis event.func updateIdleLoop(active: Bool)β menyalakan/mematikan loop ambient "menunggu" selagi kail belum ada ikan (.idle/.hookInWater), berlapis di bawah suara latar danau. Juga digerakkan per frame dengan alasan yang sama.func updateReelFightLoop(active: Bool)β menyalakan/mematikan loop khusus "reel fight" selagi ikan benar-benar terkait (.fishOnHook/.reeling). Berbeda dari yang lain, menghentikan loop ini melakukan fade volume turun perlahan selamareelFightFadeDuration(1.5 detik), bukan langsung dipotong β "matikan perlahan music reel fight."func handlePhaseChange(_ phase: HookPhase)β jaring pengaman yang dipanggil setiap kaliHookPhaseberubah (dariWorldViewModel.setHookPhase) yang memaksa berhenti loop mana pun yang secara logika seharusnya tidak lagi main untuk fase baru β dijelaskan sebagai "belt-and-suspenders," menjamin tidak ada loop nyasar yang bertahan setelah perubahan fase apa pun penyebabnya (lempar ulang, tangkap, kabur, disconnect fail-safe, dll). Ini pakai stop instan (tanpa fade), karena ini jalur reset mendadak, bukan kasus akhir perkelahian normal (itu ditangani fade-nyaupdateReelFightLoop).func handleScreenChange(isPlaying: Bool)β menyalakan/mematikan loop suara latar danau saat layar utama aplikasi berganti (menu vs main). Juga menghentikan paksa loop idle/reel-fight saat keluar dari gameplay.func stopAll()β menghentikan semua loop diSoundManagerdan mereset flag internal kelas ini sendiri. Dipanggil dariWorldViewModel.stop().
Terhubung ke: Membungkus instance SoundManager (dibuat oleh WorldViewModel lalu diberikan ke sini). Memakai enum FishingEvent dan HookPhase dari Shared. Dipanggil banyak sekali dari WorldViewModel β setiap emit(_:), setHookPhase(_:), dan panggilan update loop per-tick di tick(deltaTime:) lewat sini.
Catatan menarik: Ada komentar eksplisit bahwa .fishEscaped/.lineBreak memakai ulang suara cebur karena "belum ada aset khusus." Seluruh class ini sengaja dibuat mencerminkan FishingHaptics di sisi Rod satu-satu, supaya orang yang membaca kedua file berdampingan bisa lihat event yang sama ditangani dengan cara yang sama di dua modalitas berbeda (suara vs getar haptic).
4. Audio/SoundManager.swift
Ini pemutar file suara level rendah. Dia tidak tahu apa-apa soal gameplay mancing β cuma tahu cara preload, putar, loop, fade, dan atur volume daftar tetap aset suara pakai framework AVFoundation dari Apple. Rekannya di sisi Rod adalah HapticManager.
Tipe yang didefinisikan:
SoundManager(final class,@MainActor).SoundManager.Category(enum):.ui,.effects,.ambienceβ pengelompokan kasar cuma untuk kontrol volume massal.SoundManager.Sound(enum,String,CaseIterable) β satu kasus per file suara nyata yang dibundel bersama aplikasi (mis..uiClick,.castThrow,.hookSplash,.fishInterestedLoop,.fishBite,.reelLoop,.catchSplash,.ambienceLake,.idle,.reelFight,.fishCatched). Nilai string mentah tiap kasus persis nama file (tanpa ekstensi) yang dibundel diWorld/World/Assets/SFX/.
Fungsi-fungsi penting:
Sound.category: Categoryβ memetakan tiap kasus suara ke kategorinya (.uiClickβ.ui,.ambienceLakeβ.ambience, sisanya β.effects).Sound.fileExtension: Stringβ"mp3"untuk.ambienceLake,"wav"untuk sisanya.Sound.defaultVolume: Floatβ volume dasar hasil tuning manual per suara (mis. ambience 0.35, loop fight/idle lebih pelan 0.3, kebanyakan one-shot 0.85).init()β di iOS, mengaturAVAudioSessionbersama ke kategori.ambientdengan.mixWithOthers(supaya audio game tidak membisukan aplikasi/musik lain) lalu mengaktifkannya; kemudian memanggilpreloadPlayers().private func configureAudioSession()(khusus iOS, dibungkus#if os(iOS)) β mengatur sesi audio seperti dijelaskan di atas; error diabaikan dengantry?.private func preloadPlayers()β untuk tiap kasusSound, mencari file yang dibundel, membuatAVAudioPlayer, mengatur volumenya ke nilai default, memanggilprepareToPlay()(buffer awal supaya tidak ada jeda saat pertama diputar), lalu menyimpannya di dictionaryplayers. Diam-diam melewati suara mana pun yang filenya tidak ditemukan atau gagal dimuat.private func effectiveVolume(for sound: Sound) -> Floatβ volume default suara dikalikan pengali kategori yang sedang berlaku (categoryVolumeMultipliers), untuk kontrol volume saat runtime.func play(_ sound: Sound)β memutar one-shot dari awal (currentTime = 0), bahkan memicu ulang suara yang sedang main, karena tidak ada gameplay yang benar-benar butuh dua salinan suara yang sama main bersamaan.func startLoop(_ sound: Sound)β memulai loop tanpa henti (numberOfLoops = -1); tidak melakukan apa-apa kalau sudah main (idempoten).func stopLoop(_ sound: Sound, fadeDuration: TimeInterval = 0)β denganfadeDuration == 0, berhenti langsung dan rewind. Dengan durasi fade positif, menurunkan volume ke 0 selama durasi itu pakaiAVAudioPlayer.setVolume(_:fadeDuration:), lalu setelahawait Task.sleep, baru benar-benar menghentikan player, rewind, dan mengembalikan volumenya ke normal (supaya panggilanstartLoopBERIKUTNYA tidak diam-diam jadi bisu karena volume ketinggalan di 0).func stopAllLoops()β menghentikan loop semua suara non-kategori-UI (suara UI adalah one-shot, tidak pernah loop, jadi dikecualikan).func setVolume(_ volume: Float, for category: Category)β mengatur pengali sekategori dan langsung menerapkannya ke setiap player yang sudah dimuat di kategori itu.
Terhubung ke: Dibungkus oleh FishingSounds (satu-satunya pemakai). Cuma bergantung ke AVFoundation β tidak bergantung ke Shared, RealityKit, atau tipe gameplay apa pun. Ini pemisahan lapisan yang disengaja: SoundManager "bodoh," FishingSounds yang menambahkan makna.
Catatan menarik: Komentar pada jalur fade di stopLoop menjelaskan secara spesifik kenapa volume dikembalikan setelah berhenti β sebuah kelas bug halus (playback berikutnya diam-diam rusak) yang jelas sudah dipikirkan penulisnya lebih dulu.
5. Gameplay/FishCatalog.swift
Ini daftar statis semua "spesies" ikan yang bisa ditangkap (saat ini masih nama placeholder Fish A/B/C dan Scott) beserta statistik dasarnya. Ini tabel data yang jadi acuan simulasi perkelahian.
Tipe yang didefinisikan: Tidak ada tipe baru miliknya sendiri β ini enum FishCatalog yang cuma dipakai sebagai namespace (tanpa kasus, cuma anggota statis), dan dia memproduksi/memakai tipe FishSpecies/FishSpeciesRange/FishTier dari Shared (didefinisikan di Shared/Sources/Shared/Models/Gameplay/FishSpecies.swift, bukan di aplikasi ini tapi penting untuk dipahami: FishSpecies menyimpan id, nama, rentang berat/panjang, nilai resistance 0β1, value koin, dan FishTier β .small/.medium/.big/.boss).
Fungsi-fungsi penting:
static let all: [FishSpecies]β empat spesies yang dikodekan langsung:fish_a(tier small, 0.8β3.5 kg, 20β55 cm, resistance 0.35, value 10),fish_b(medium, 3.5β9.0 kg, 55β90 cm, resistance 0.50, value 25),fish_c(big, 9.0β20.0 kg, 90β140 cm, resistance 0.65, value 60), danscott(boss, 20β40 kg, 140β200 cm, resistance 0.70, value 500 β tangkapan legendaris nama besar game ini).static func randomSpecies(unlockedTiers: Set<FishTier>) -> FishSpeciesβ menyaringallhingga cuma spesies yang tier-nya ada diunlockedTiers(dikendalikan olehProgressionStore), lalu memilih satu secara acak; kalau hasil saringan kosong, jatuh balik keall[0](fish_a, selalu terbuka), menjamin setiap gigitan selalu berujung ke SESUATU.
Terhubung ke: Dipakai FishFightController.updateSearching(deltaTime:), yang memanggil randomSpecies(unlockedTiers:) tepat saat gigitan terjadi. Bergantung ke FishSpecies/FishSpeciesRange/FishTier dari Shared.
Catatan menarik: Komentar dokumentasi menyebut eksplisit bahwa angka-angka ini masih kasar, bukan hasil balancing final β ada kutipan langsung dari pihak yang meminta: "parameter fish nya nanti di ubah lagi seiring berjalannya game." Nilai resistance untuk fish_c dan scott juga punya komentar inline yang mencatat sudah diturunkan dua kali (riwayat semacam 0.75β0.60β0.65) merespons masukan langsung pemain bahwa level 3β4 terlalu susah ("mudahin dikit dong ikan level 3 dan 4 nya").
6. Gameplay/FishCatchResult.swift
Ini tipe nilai kecil dan sederhana yang membawa semua yang dibutuhkan HUD/layar hasil untuk menampilkan hasil satu gigitan (tertangkap, kabur, atau senar putus). Ini murni data tampilan β tidak ada logika.
Tipe yang didefinisikan:
FishCatchResult(struct,Equatable) β hasil satu gigitan yang sudah selesai.FishCatchResult.Outcome(enum,String):.caught("Caught"),.escaped("Escaped"),.lineBreak("Line Broke").
Fungsi-fungsi penting: Tidak ada β ini struct data biasa tanpa metode, cuma properti tersimpan (speciesName, weight, length, outcome, stars 1β5 atau 0, coins, isNewRecord).
Catatan tambahan: Ketujuh field-nya semua let (tidak bisa diubah setelah dibuat). stars/coins/isNewRecord didokumentasikan selalu nol/false kecuali outcome == .caught.
Terhubung ke: Diproduksi oleh FishFightController.resolve(outcome:) dan disimpan sebagai lastCatchResult. Dipakai oleh ResultView (kartu hasil) dan strip ringkasan "hasil terakhir" kecil di ContentView.
Catatan menarik: Tidak ada yang rumit β contoh buku teks struct "view model" yang immutable.
7. Gameplay/FishFightController.swift
Ini jantung minigame mancing yang sebenarnya β semua tentang mencari ikan, ikan mulai tertarik, menggigit, dan tarik-menarik (tug-of-war) berikutnya berupa pengelolaan tegangan senar plus sub-mekanik "juking" kiri/kanan, semuanya ada di sini. File ini sama sekali tidak tahu apa-apa soal posisi 3D, entity RealityKit, atau transport Bluetooth; WorldViewModel adalah jembatan yang memberinya angka dunia nyata (jarak kail, kemiringan/roll joran, delta waktu) dan membaca balik state publikasinya untuk menggerakkan tampilan di layar.
Tipe yang didefinisikan:
FishFightController(final class,ObservableObject,@MainActor).FishFightController.FightFailure(enum):.escaped,.lineBreakβ dua cara perkelahian berakhir buruk.FishFightController.TensionBand(enum):.low,.medium,.highβ pita tegangan mana yang menentukan ritme haptic yang seharusnya dimainkan Rod, diturunkan dari tegangan.FishFightController.SearchEvent(enum):.interested,.biteβ apa yang baru saja dilakukanupdateSearchingtick ini, kalau ada.FishFightController.FightDirection(enum):.left,.rightβ arah tarikan ikan yang sedang terkait saat fase juking-nya; pemain harus memutar joran ke arah SEBALIKNYA untuk melawan (konvensi world/screen-space yang terdokumentasi, bukan relatif ke joran).
Fungsi-fungsi penting:
init(recordStore:, progressionStore:, config:)β menerima tiga dependency yang bisa disuntik (dibuat default kalau tidak diberikan):FishRecordStore(penyimpanan berat terbaik per spesies),ProgressionStore(penyimpanan tier yang terbuka), danFishFightConfig(semua angka yang bisa di-tuning β struct dari paketShareddengan default sepertiinitialTension = 0.5,baseFishPullRate = 0.105, dll, semuanya didokumentasikan lengkap dengan riwayat tuning-nya). MembacacurrentLevelawal dariprogressionStore.level.var progressTowardNextLevel: (caught: Int, needed: Int)?β pass-through keprogressionStore.progressTowardNextLevel, supaya HUD tidak perlu punya referensi sendiri ke store itu.func dismissLevelUpAnnouncement()β mengosongkanlevelUpAnnouncementjadinil; dipanggil tombol tutup di HUD.func resetProgression()β meresetprogressionStorekembali ke Level 1 dan menutup banner naik level yang sedang tampil. Aksi debug/utilitas, bukan bagian alur normal.func resetForNewCast(castDistance: Float = 0)β dipanggil setiap kali lemparan baru jatuh di air. MengosongkanhasFishOnHook,isFishInterested, spesies/tegangan saat ini, memulai ulang timer ketertarikan acak (0.8β2.0 detik), dan mencatatcastDistancebaik untuk gating internal (currentCastDistance) maupun tampilan HUD (lastCastDistance).func updateSearching(deltaTime: Float) -> SearchEvent?(@discardableResult) β mesin state per-tick "apakah ikan sudah tertarik/menggigit," cuma dipanggil selagi.hookInWater. Kalau belum tertarik, menghitung mundurinterestTimer; begitu habis, mengubahisFishInterested = true, memulaibiteTimerbaru, mengembalikan.interested. Kalau sudah tertarik, menghitung mundurbiteTimer; begitu habis, memilih spesies acak (dibatasi olehprogressionStore.unlockedTiers), mengundi berat/panjangnya lewatbiasedRoll(dibatasi olehsizeRollWindow, yang tergantungcastZonetempat lemparan ini jatuh β lihat di bawah), mengaturhasFishOnHook = true, mengatur tegangan keconfig.initialTension, dan memulai sub-fase juking arah-perkelahian (acak.left/.right, durasi acak 5β10 detik, interval ganti acak 0.6β1.8 detik) β mengembalikan.bite.func updateFight(deltaTime:, isReeling:, reelSpeed:, hookDistance:, rodRoll:) -> FightFailure?(@discardableResult) β matematika tarik-menarik per-tick, cuma dipanggil selagi ada ikan terkait. Tick pertama menyimpanhookDistancesebagaifightStartDistance; kalau kail pernah menjauh lebih darieffectiveEscapeDistanceAllowancedari titik awal itu, ikan langsung dinyatakan kabur (tidak peduli tegangan β ini "escape jarak"). Kalau tidak, dihitungreelPull(kecepatan reel Γ angka konfigurasi, atau 0 kalau tidak sedang reel), memanggilupdateFightDirectionuntuk delta melawan-juking, lalu memperbaruilineTensiondari gabungan angka itu ΓdeltaTime, dibatasi ke rentang[0,1]. Tegangan menyentuh0β.escaped(senar kendur); menyentuh1β.lineBreak(putus karena terlalu kencang ditarik). Mengembalikan kegagalan, ataunilkalau perkelahian berlanjut.private func updateFightDirection(deltaTime:, rodRoll:) -> Floatβ memajukan sub-fase juking: menghitung mundurfightDirectionRemainingDuration(timer keseluruhan sub-fase; kalau habis,fightDirectionkembali kenildan fungsi ini mengembalikan0), dan terpisah menghitung mundurfightDirectionSwitchTimer(membalik.leftβ.rightpada interval acak baru saat habis β "pengacak cepat"). Mengevaluasi apakah pemain SAAT INI melawan dengan benar dengan memeriksarodRollterhadapconfig.fightDirectionRollThresholddi tanda yang benar untuk arah saat ini, dan mengembalikan entah pelepasan tegangan (-config.fightDirectionCounterRelief, hadiah untuk melawan yang benar) atau penalti (+config.fightDirectionPenalty, untuk mengabaikan atau melawan arah yang salah).var tensionBand: TensionBand?β dihitung darilineTensiondibandingkanconfig.tensionBandHighThreshold/.tensionBandMediumThreshold;nilkalau tidak ada ikan terkait. Dipakai memberitahu Rod ritme haptic mana yang harus dipulsakan.var fishPullSpeed: Floatβ meter/detik seberapa cepat ikan yang terkait menyeret kail menjauh saat pemain tidak sedang reel, diskalakan olehresistance. DibacaWorldViewModeluntuk menggerakkan entity kail 3D.var reelPullSpeed: Floatβ kecepatan reel risiko/hadiah yang diskalakan tegangan: pada atau di bawahconfig.reelSpeedSafeTensionnilainya rataconfig.reelPullSpeedSafe(2.0 m/s); pada atau di atasconfig.reelSpeedDangerTensionnilainyaconfig.reelPullSpeedDanger(4.0 m/s, lebih cepat β menghadiahi pemain yang berani menahan tegangan mendekati batas putus), diinterpolasi linear di antara kedua ambang itu.func resolveCatch()β dipanggil ketika kail sampai ke joran selagi ada ikan terkait; menambahcaughtCountdan memanggilresolve(outcome: .caught).private func resolve(outcome: FishCatchResult.Outcome)β logika finalisasi bersama untuk ketiga hasil. Untuk.caught, menghitungstars(1β5, lewatSelf.stars(forWeight:in:)),coins(lewatSelf.coins(baseValue:stars:)), memeriksarecordStore.recordCatch(...)untuk rekor baru, dan melapor tangkapan keprogressionStore.recordCatch(tier:)β kalau level yang dikembalikan lebih tinggi daricurrentLevel, mengaturlevelUpAnnouncementjadi pesan perayaan. Membangun dan menyimpanlastCatchResult, lalu mereset semua variabel instance perkelahian kembali ke default idle-nya (mirip banyak hal yang dilakukanresetForNewCast).private static func stars(forWeight:in:) -> Intβ 1 sampai 5 bintang, linear terhadap seberapa dekat berat tangkapan dengan maksimum rentang spesiesnya.private static func coins(baseValue:stars:) -> Intβ payout skala sedikit naik seiring bintang (pengali0.6 + 0.1 * starspada nilai dasar spesies).private static func announcement(forLevel:) -> String?β teks yang dikodekan langsung untuk level 2 ("Medium fish have entered the lake!") dan level 3 ("Scott has appeared. Catch him!");niluntuk level lainnya (didokumentasikan sebagai praktis tidak mungkin terjadi, karenarecordCatchcuma pernah mengembalikan 1/2/3).private enum CastZone(.green/.yellow/.red) danprivate var castZone: CastZoneβ sepertiga mana darimaxExpectedCastDistancetempat lemparan saat ini jatuh, cocok satu-satu dengan sepertiga hijau/kuning/merah bar kekuatan milikCastMeterView.private var sizeRollWindow: (floor: Float, ceiling: Float)β untuk tiapcastZone, rasio lantai/langit-langit yang tegas (mis. hijau β 0.0β0.375) dipilih supaya hasilstars(forWeight:)selalu jatuh di tier yang dijanjikan (lemparan hijau cuma bisa mengundi ikan 1β2 bintang, merah cuma 4β5 bintang). Ini pilihan desain yang disengaja supaya lemparan pendek tidak bisa lagi kadang-kadang mengundi ikan besar karena keberuntungan.private static func biasedRoll(in:floorRatio:ceilingRatio:) -> Doubleβ mengundi nilai acak dibatasi dalam rentang[range.min + span*floorRatio, range.min + span*ceilingRatio], bukan rentang alami penuh spesiesnya.private var effectiveFishPullRate: Float/private var effectiveEscapeDistanceAllowance: Floatβ versi konstanta konfigurasi dasar yang disesuaikan dengan resistance (resistance lebih besar β tegangan menguras lebih cepat, jarak-escape lebih ketat, dengan batas bawah supaya tidak jadi tidak adil terlalu ketat).private var resistance: Floatβ resistance efektif untuk tangkapan ini spesifik:species.resistance * sizeRatioβ ikan yang terundi mendekati ukuran minimum spesiesnya berkelahi mendekati kesulitan dasar; yang mendekati maksimum berkelahi dengan resistance penuh yang tercantum di spesies.private var sizeRatio: Floatβ 0β¦1, seberapa besar (dari hasil kali beratΓpanjang) tangkapan ini terundi relatif terhadap rentang min/max spesiesnya sendiri.
Catatan tambahan: minimumFightDistance: Float = 0.4 adalah jarak minimum kail-ke-joran yang dijamin ada tepat saat gigitan terjadi (lihat WorldViewModel.ensureFightDistance()), supaya pemain yang sudah sedang menarik saat gigitan terjadi tetap dapat perkelahian sungguhan, bukan langsung tertangkap instan.
Terhubung ke: Memiliki sebuah FishRecordStore dan ProgressionStore. Membaca FishCatalog.randomSpecies(unlockedTiers:). Sepenuhnya digerakkan oleh loop tick WorldViewModel (updateSearching, updateFight, resolveCatch, resetForNewCast) β dia tidak pernah menyentuh RealityKit, transport, atau entity sendiri. Properti publikasinya memberi makan LineTensionView, strip ringkasan di ContentView, dan ResultView (lewat lastCatchResult).
Catatan menarik: Komentarnya sangat kaya dengan kutipan langsung dari sesi masukan tuning (dalam Bahasa Indonesia, diterjemahkan inline) β mis. "fish pull speed rasanya terlalu kencang," "coba base nya 2 m/s instead of 0.5 - 1." Seluruh desain perkelahian (satu bar tegangan berkelanjutan plus sub-mekanik juking kiri/kanan di atasnya) secara eksplisit dicocokkan dengan dokumen desain terpisah bernama GAMEPLAY_ARCHITECTURE_V1.md.
8. Gameplay/FishRecordStore.swift
Menyimpan (bertahan lintas peluncuran ulang aplikasi) ikan terberat yang pernah tertangkap per spesies, supaya game bisa menandai "New Record" saat tertangkap.
Tipe yang didefinisikan: FishRecordStore (final class) β tidak ada enum/struct sendiri.
Fungsi-fungsi penting:
init()β memuat rekor tersimpan dariUserDefaultslewatSelf.load().func recordCatch(speciesID:, weight:) -> Bool(@discardableResult) β membandingkan berat baru dengan rekor tersimpan untuk spesies ID itu; kalau tidak lebih berat, mengembalikanfalsetanpa melakukan apa pun. Kalau lebih, memperbarui dictionary, menyimpannya, dan mengembalikantrue(rekor baru).private static func load() -> [String: Double]β membaca dan men-decode JSON dictionary dariUserDefaults.standard.data(forKey:); mengembalikan dictionary kosong kalau tidak ada yang tersimpan atau decode gagal.private func save()β meng-encodebestWeightBySpeciesIDjadi JSON dan menulisnya balik keUserDefaults.
Catatan tambahan: private var bestWeightBySpeciesID: [String: Double] β seluruh tabel rekor tersimpan, dikunci dengan string ID spesies (mis. "fish_a", "scott").
Terhubung ke: Dimiliki oleh FishFightController; cuma dipanggil dari resolve(outcome:). Memakai UserDefaults/JSONEncoder/JSONDecoder dari Foundation β tidak ada dependensi aplikasi lain.
Catatan menarik: Kode sederhana dan defensif β setiap decode pakai try?, jadi file simpanan yang rusak atau hilang gagal dengan anggun jadi "belum ada rekor" alih-alih crash.
9. Gameplay/ProgressionStore.swift
Melacak berapa banyak ikan tiap tier yang sudah ditangkap pemain, dan menurunkan "level" pemain saat ini (yang menentukan tier ikan mana yang bisa ditangkap sama sekali) dari hitungan-hitungan itu. Bertahan lintas peluncuran seperti FishRecordStore.
Tipe yang didefinisikan:
ProgressionStore(final class).ProgressionStore.Counts(private struct,Codable) β empat penghitungInt(small,medium,big,boss), semuanya default 0.
Fungsi-fungsi penting:
init()β memuatcountsdariUserDefaultslewatSelf.load().var level: Intβ dihitung dari counts terhadap ambang tetap (small: 3,medium: 5,big: 10,boss: 1): mulai di 1; mencapai 3 tangkapan small β level 2; 5 tangkapan medium β level 3; 10 tangkapan big β level 4. (Komentar dokumentasinya eksplisit bahwa ini menggantikan desain lama yang basi, tiga-level 10/10 yang pernah dijelaskan di versi komentar sebelumnya β ambang yang benar-benar dipakai adalah yang ada di kode.)var unlockedTiers: Set<FishTier>β memetakanlevelke tier mana yang bisa ditangkap: level 1 β[.small]; level 2 β+.medium; level 3 β+.big; level 4+ β keempatnya termasuk.boss(Scott).var progressTowardNextLevel: (caught: Int, needed: Int)?β untuk tampilan HUD "X/Y menuju tier berikutnya"; mengembalikannilbegitu level 4 tercapai dan satu-satunya Scott juga sudah tertangkap (tidak ada lagi yang perlu dikejar).func recordCatch(tier: FishTier) -> Int(@discardableResult) β menambah penghitung yang sesuai untuk tier yang diberikan, menyimpannya, dan mengembalikan level SETELAH pencatatan (supaya pemanggil bisa membandingkan dengan level sebelumnya untuk mendeteksi transisi naik level).func reset()β mengosongkan semua penghitung kembali ke nol (mengembalikan pemain ke level 1) dan menyimpannya. Cara pemain mengulang kurva unlock tanpa install ulang.private static func load() -> Counts/private func save()β polaUserDefaults+ JSON yang sama sepertiFishRecordStore.
Catatan tambahan: private var counts: Counts β empat penghitung tangkapan per-tier yang tersimpan; ini seluruh state progresi.
Terhubung ke: Dimiliki oleh FishFightController. Dipakai oleh FishCatalog.randomSpecies(unlockedTiers:) (tidak langsung, lewat FishFightController membaca progressionStore.unlockedTiers) dan oleh FishFightController.progressTowardNextLevel/currentLevel, yang dibaca HUD.
Catatan menarik: Komentar dokumentasinya secara eksplisit menandai bahwa dia diperbaiki saat menulis salinan onboarding terpisah (ADR-064) karena komentar sebelumnya menjelaskan angka yang sudah basi dan tidak lagi benar β contoh bagus dokumentasi yang melenceng dari kode lalu ketahuan dan diperbaiki. Teks halaman ketiga OnboardingView ("Catch 3 small fish... Catch 5 medium... Catch 10 big... Catch Scott") mencerminkan persis ambang-ambang ini.
10. Reality/EnvironmentSceneBuilder.swift
Membangun seluruh lingkungan danau 3D yang statis β air, terrain, dermaga, langit, matahari, pencahayaan ambient langit, dan sekitar 300 instance dekorasi tersebar (pohon, rumput, alang-alang, batu, pakis, semak, properti dermaga) β setiap kali aplikasi dibuka, dengan memuat file .usdz yang sudah jadi (dibuat di repo Blender terpisah) dan menempatkannya di koordinat dunia tepat yang diambil manual.
Tipe yang didefinisikan:
EnvironmentSceneBuilder(enum,@MainActor) β namespace, tidak punya instance.EnvironmentSceneBuilder.ScatterInstance(private struct) β satu dekorasi tersebar:assetName,position(SIMD3<Float>),scale(Float, seragam).EnvironmentSceneBuilder.DockProp(private struct) β satu properti yang ditempatkan di dek dermaga:assetName,position,yawDegrees.- (Level file, tergantung platform)
typealias PlatformColor = NSColor(macOS/AppKit) atau= UIColor(iOS/UIKit) β supaya kode yang sama bisa dikompilasi di kedua platform karena tipe warna RealityKit cuma alias untuk kelas warna asli platform tersebut.
Fungsi-fungsi penting:
- (Level file)
private func srgbColor(_:_:_:_:) -> PlatformColorβ membangun warna sRGB dengan tipe platform konkret yang benar dari komponen float merah/hijau/biru/alpha. static func build() async -> Entityβ titik masuk utama, dipanggil sekali dariWorldViewModel.makeSceneEntity(). Memuat dan menambah lima aset "inti" (water_surface,terrain_underwater,terrain_horizon_ring,dock_main,sky_dome) pada transform identitas (semuanya dibuat bersama dalam satu kerangka koordinat yang sama, diverifikasi terhadap scene preview milik repo aset sendiri). Menerapkan shader air custom (WaterMaterial.apply) ke entity air dan tint warna hijau (tintTerrainGreen) ke tekstur terrain horizon ring. Menambahkan cahaya matahari terarah (makeSun()) dan, kalau berhasil dimuat, cahaya berbasis gambar (refleksi langit,makeImageBasedLight()). Lalu, untuk tiap nama aset discatterInstancesyang dikelompokkan bersama, memuat satu salinan template dan meng-clone(recursive:)-nya sekali per instance, menerapkan posisi/skala instance itu tepat (dan, untuk beberapa tipe aset "vertikal," rotasi yaw per-instance deterministik untuk variasi visual β lihat di bawah). Terakhir menempatkan tiga properti dermaga (prop_bucket_01,prop_tacklebox_01,prop_rope_01) di posisi/rotasi yang dibuat. MengembalikanEntityakar yang sudah tersusun.private static func load(_ name: String) async -> Entity?β wrapper tipis di sekitarEntity(named:), menelan kegagalan pemuatan jadinil(jadi satu aset yang hilang/rusak tidak merusak seluruh build).private static func tintTerrainGreen(_ entity: Entity)β mengalikan tekstur foto tanah bawaanterrain_horizon_ringdengan tint condong hijau ((0.65, 1.0, 0.6)) lewatPhysicallyBasedMaterial.BaseColor.tint, karena tekstur foto dunia-nyata yang dipakai sebenarnya terbaca cokelat/tan, bukan "hijau rimbun" seperti yang diminta. Komentar dokumentasi menjelaskan dua putaran tuning ulang berbasis screenshot langsung: percobaan pertama (tint lebih berat) membuat tanah terlihat hijau rata tidak alami karena mengalikan setiap piksel dengan warna yang sama meruntuhkan variasi hue alami, bukan cuma kecerahan; nilai final adalah kompromi yang lebih moderat.private static func findModelEntity(in:) -> ModelEntity?β pencarian pohon rekursif kecil yang mengembalikanModelEntitypertama yang benar-benar membawa mesh, ditemukan di mana pun di anak-anak sebuah entity (sebuah.usdzyang dimuat sering dibungkus satu atau lebih entity kontainer kosong dulu).private static func makeImageBasedLight() async -> Entity?β memuat JPEG equirectangular yang dibundel (sky_ibl.jpgβ salinan berdiri sendiri dari tekstur yang sama yang ditampilkansky_dome.usdzsendiri), membangunEnvironmentResourceRealityKit darinya, dan mengembalikan entity yang membawaImageBasedLightComponent. Ini yang memberi setiap permukaan di scene (terutama air) cahaya ambient/refleksi yang cocok dengan langit yang terlihat, bukan cuma satu matahari terarah datar tanpa refleksi sama sekali β dijelaskan sebagai menutup TODO yang sebelumnya belum diimplementasikan dariCLAUDE.mdmilik repo aset sendiri.private static func makeSun() -> Entityβ membangunDirectionalLightComponenttunggal (warna hangat, intensitas 7000, dengan sub-komponen.Shadowsupaya benar-benar melempar bayangan β komponen opt-in terpisah, mudah terlewat) pada konvensi pencahayaan yang terdokumentasi (~15Β° elevasi, 135Β° azimuth, warna hangat), dihitung jadi vektor arah dunia sebenarnya lalu diarahkan lewatEntity.look(at:from:).
Catatan tambahan:
private static let sunElevationDegrees/sunAzimuthDegrees/sunColor/sunDistanceβ konstanta konvensi pencahayaan.private static let coreAssetNames: [String]β lima aset lingkungan "inti" yang dimuat pada transform identitas.private static let scatterInstances: [ScatterInstance]β array yang dikodekan langsung sangat panjang (ratusan entri) yang memberi posisi/skala tepat tiap instance dekorasi (rumput, alang-alang, batu, pohon, pakis, semak), diambil dari scene preview repo Blender sendiri, plus beberapa tambahan sisi-aplikasi-saja yang dilapiskan kemudian (kepadatan pohon tambahan, foliase pantai tambahan dekat sudut pandang kamera sebenarnya, sejumlah kecil instance pakis/semak baru).private static let yawRandomizedAssetNames: Set<String>β tipe aset mana yang mendapat rotasi yaw acak per-instance (tree_01,foliage_fern_01,foliage_shrub_01β apa pun yang vertikal/asimetris di mana rotasi terlihat jelas secara visual; rumput/alang-alang/batu dilewati karena percuma).private static let deckHeight: Float = 0.80danprivate static let dockProps: [DockProp]β tiga dekorasi dermaga yang ditempatkan manual.
Terhubung ke: Dipanggil sekali oleh WorldViewModel.makeSceneEntity(). Memanggil WaterMaterial.apply(to:). Memuat file .usdz yang dibundel bersama aplikasi (awalnya diproduksi dari repo Blender new-blender-project β repo yang dijelaskan CLAUDE.md sesi ini). Memakai RealityKit, ImageIO, simd.
Catatan menarik: File ini punya komentar bergaya "laporan insiden" terbanyak di seluruh aplikasi. Yang menonjol:
- Sebuah fungsi hash yaw per-instance deterministik (
deterministicYaw(for:), gaya hashsinklasik ala GLSL) dipakai alih-alih menyimpan seed acak, jadi menjalankan ulang aplikasi selalu menghasilkan rotasi "terlihat acak" yang sama. - Bug-dan-perbaikan yang terdokumentasi: versi sebelumnya MENGGANTI orientasi tiap klon yang dirotasi sepenuhnya dengan kuaternion yaw, yang diam-diam menghapus koreksi impor bawaan β90Β° sekitar X yang dimiliki ketiga tipe aset itu (perbaikan standar USD Z-up β RealityKit Y-up) β membuat setiap pohon yang dirotasi rebah miring jadi seperti "coretan puing cabang horizontal." Perbaikannya adalah mengalikan yaw dari kiri di atas orientasi yang sudah ada, bukan menggantinya.
tree_02.usdzpunya cacat yang diketahui dan belum diperbaiki (bentuk humanoid menyatu ke mesh batangnya, dilacak sebagai "CP41" di repo aset) β setiap slot scatter yang awalnya dibuat untuktree_02(bahkantree_03, si palem) dialihkan ketree_01, khusus untuk menghindari mesh yang rusak sekaligus kenaikan frekuensi pohon palem yang tidak diinginkan yang tanpa sengaja dimasukkan perbaikan cepat sebelumnya.foliage_shrub_01sangat mahal (~156.000 segitiga per instance, dikonfirmasi kepadatan Poly Haven asli, bukan bug) dan sengaja dibatasi hanya 5 instance yang ditempatkan di titik paling menonjol secara visual (mengapit dermaga) alih-alih disebar luas.
11. Reality/RodRigController.swift
Menggerakkan pose bengkok joran yang dihitung kode secara langsung dan pose kendur (sag) senar pancing setiap frame. Baik rod_main.usdz maupun rod_line.usdz dikirim dari repo aset sebagai rangka skeleton rest-pose saja tanpa animasi baked sama sekali (sesuai tabel model animasi di CLAUDE.md) β file ini adalah separuh "kode aplikasi harus memutar tulang secara langsung" dari kontrak itu.
Tipe yang didefinisikan:
RodRigController(struct,@MainActor) β menyimpan referensi ke entity joran/senar/reel yang dimuat dan state smoothing saat ini; dikembalikan oleh factory statisload()-nya sendiri.RodRigController.BoneWeight(private struct) β satu sendi/joint: path string terautentikasi (mis."Root/Handle/Rod_01"), seberapa besar porsi bengkok/kendur total yang dia bawa (weight, 0=kaku sampai 1=amplitudo penuh), dan translasi lokal rest-pose-nya (panjang tulang).
Fungsi-fungsi penting:
static func load() async -> RodRigControllerβ memuatrod_maindanrod_linesebagai entity, menemukanModelEntityyang benar-benar ber-skin di dalamnya (lewatfindSkinnedModel, karena data skeleton/joint ada di entity pembawa mesh, bukan kontainer generik yang dikembalikanEntity(named:)), menempelkan senar ke joint Tip milik joran lewat "pin" RealityKit (digeser olehtipTailOffsetsupaya jatuh di EKOR tulang Tip, bukan kepalanya β kesalahan "kalau ini terbalik diam-diam memendekkan senar 0.25m" yang terdokumentasi di CLAUDE.md, sudah diperbaiki di sini), mengatur pin markertipEntityterpisah dengan offset nol (dipakai di tempat lain untuk meluncurkan lemparan dari ujung visual joran yang sebenarnya), dan membangun reel placeholder β bola pipih yang di-pin dekat pegangan, mengganti sementara aset reel/spool yang belum dimodelkan (cocok, menurut komentarnya sendiri, dengan pola placeholder primitif yang sudah ditetapkan proyek untuk aset yang hilang).mutating func applyBend(pitch:, roll:)β fungsi bengkok joran, dipanggil tiap tick dariWorldViewModel.updateRodPose(). Menghaluskan pitch/roll mentah yang masuk dengan filter low-pass satu-kutub (bendSmoothing = 0.25) supaya jitter sensor antar-frame tidak terbaca sebagai gonta-ganti tekuk/kaku yang aneh. Untuk tiap tulang dirodBones(RootβHandleβRod_01βRod_02βRod_03βTip, bobot 0β0β0.15β0.35β0.65β1.0), menghitung sudut bengkok per-joint (pitch/roll yang sudah dihaluskan Γ konstanta skala tetap Γ bobot tulang itu sendiri), membatasinya ke batas atas per-joint yang tegas (maxBendAnglePerJoint = 0.35rad, batas defensif melawan lonjakan sensor), membangun kuaternion kecil darinya, dan menulis balikTransformlokal joint itu (rotasi + translasi rest-nya sendiri) ke arrayjointTransformsmilik model. Karena ini rotasi LOKAL yang berkomposisi menyusuri rantai parent, defleksi visual sebenarnya di ujung menjumlahkan semua bobot tulang bersama β fakta yang dikerjakan komentar dokumentasi secara eksplisit lewat aritmatika (0.15+0.35+0.65+1.0 = 2.15), karena nilai skala sebelumnya menghasilkan "cambukan liar berlebihan" karena tidak memperhitungkan komposisi ini.private func clampedBendAngle(_:) -> Floatβ helper clamp min/max bersama yang dipakaiapplyBend.func applyLineTension(_ tension: Float)β fungsi kendur senar, dipanggil tiap tick dariWorldViewModel.tick(). Mengonversitension(0=risiko putus, 1=kencang) jadislack = 1 - tension, lalu untuk tiap tulang dilineBones(RootβAnchorβLine_01β¦Line_04, bobot 0.2β0.45β0.7β1.0) menghitung sudut kendur per-joint di sekitar sumbu yang DIBALIK-LAWAN (orientasi dunia ujung joran saat ini dibalik, supaya kendur selalu mengarah ke "bawah" sejati apa pun kemiringan/roll joran saat ini β memperbaiki bug yang sebelumnya diketahui di mana senar terlihat bercabang menjauh dari bidang bengkok joran sendiri saat pitch+roll digabung). Tiap joint kendurnya dibatasimaxLineSagAnglePerJointdan ditulis kejointTransformsmilik model senar sendiri.func applyReelSpin(reelSpeed:, isReeling:, deltaTime:)β memutar entity reel placeholder selagi aktif reel, sebanding dengan kecepatan reel, murni sebagai isyarat visual (tidak ada geometri reel nyata yang dimodelkan untuk membuat ini akurat secara fisik).private static func findSkinnedModel(in:) -> ModelEntity?β pencarian rekursif untukModelEntitypertama di sebuah hierarki yangjointNames-nya tidak kosong (artinya benar-benar membawa skeleton).
Catatan tambahan:
let root: Entityβ entity induk yang memegang sub-hierarki joran dan senar.let tipEntity: Entity?β marker world-space di posisi tepat ujung joran, dipakaiWorldViewModel.launchHookuntuk tahu di mana menempatkan lemparan.private let rodModel: ModelEntity?/lineModel: ModelEntity?/reelEntity: Entity?β mesh ber-skin/placeholder sebenarnya yang ditemukan saatload().private var smoothedBendPitch/smoothedBendRoll: Doubleβ pitch/roll yang sudah difilter low-pass, cuma dipakai untuk visualisasi bengkok sekunder (dipisah dari orientasi rig utama tak-dihaluskan yang diterapkan langsungWorldViewModel).- Beberapa konstanta tuning:
bendPitchScale = 0.085,bendRollScale = 0.035,maxBendAnglePerJoint = 0.35,maxLineSag = 0.15,maxLineSagAnglePerJoint = 0.35,bendSmoothing = 0.25,tipTailOffset = [0, 0.25, 0]β semuanya disertai komentar dokumentasi substansial yang melacak riwayat tuning-nya dan aritmatika di baliknya.
Terhubung ke: Dimuat sekali oleh WorldViewModel.makeSceneEntity(), disimpan sebagai rodRig. Dipanggil tiap frame dari WorldViewModel.updateRodPose() (applyBend) dan WorldViewModel.tick() (applyLineTension, applyReelSpin). Bergantung ke RealityKit, AppKit (impor khusus macOS, patut dicatat β file ini ditulis tanpa cabang #if canImport(UIKit) seperti yang dipunyai EnvironmentSceneBuilder), dan simd.
Catatan menarik: Ini salah satu file yang paling banyak "menunjukkan matematika" di seluruh aplikasi β hampir setiap komentar konstanta menyertakan aritmatika sebenarnya (mis. pitch * bendPitchScale * 2.15) yang menjustifikasi kenapa angka spesifik itu dipilih, dan beberapa komentar menjelaskan bug konkret sebelum/sesudah (offset tip-tail, cabang sumbu kendur senar, rig rotasi berlebihan) yang ketahuan lewat screenshot langsung lalu diperbaiki. Reel secara eksplisit disebut sebagai placeholder menunggu aset nyata dari repo Blender terpisah.
12. Reality/WaterMaterial.swift
Membangun dan menerapkan material shader air animasi real-time ke entity water_surface yang dimuat, menggantikan material placeholder statis bawaan file .usdz-nya. Ini implementasi sisi-aplikasi dari TODO "shader air belum dibangun di repo aset, itu tugas RealityKit yang eksplisit" yang disebut di CLAUDE.md milik repo aset.
Tipe yang didefinisikan:
WaterMaterial(enum,@MainActor) β cuma namespace.WaterMaterial.MaterialError(private enum):.noMetalDevice,.noDefaultLibrary.
Fungsi-fungsi penting:
static func apply(to entity: Entity)β menemukanModelEntitypertama di dalam hierarki entity yang diberikan, mencoba membangun material custom lewatmakeMaterial(), dan kalau berhasil, mengganti setiap slot material di model itu dengan salinan material itu. Gagal secara DIAM-DIAM (membiarkan material placeholder bawaan tetap ada) kalau library shader Metal tidak bisa dimuat β pilihan sengaja supaya gagal dengan anggun, bukan crash.private static func makeMaterial() throws -> CustomMaterialβ mengambil device Metal default dan library shader default-nya, mencari dua fungsi shader bernama darinya (waterSurfaceShader, sebuahSurfaceShader, danwaterGeometryModifier, sebuahGeometryModifierβ keduanya dikompilasi dari fileWaterShader.metalterpisah yang tidak ada di daftar file ini), lalu membangunCustomMaterialdarinya dengan pencahayaan.lit. Kemudian memuat dua tekstur dan mengikatnya ke material: gambar langit equirectangularsky_ibl.jpgyang sama yang dipakai untuk image-based lighting diikat ke slot teksturbaseColormaterial (bukan sebagai warna dasar dalam arti biasa, tapi sebagai data piksel mentah yang di-sample sendiri oleh shader berdasarkan sudut equirectangular vektor refleksi, memalsukan refleksi langit nyata di air); danwater_normal.jpg(peta normal riak air yang bisa diubin, dikenal luas, dibundel dari aset contoh milik three.js sendiri yang berlisensi MIT) dimuat dengan semantik tekstur.normal(jadi RealityKit memperlakukannya sebagai peta normal linear, bukan warna sRGB) dan diikat ke slot peta normal material.private static func findModelEntity(in:) -> ModelEntity?β pola pencarian pohon rekursif yang sama seperti diEnvironmentSceneBuilder.
Terhubung ke: Dipanggil sekali dari EnvironmentSceneBuilder.build() tepat setelah memuat water_surface. Bergantung ke RealityKit dan Metal secara langsung (bukan cuma lewat abstraksi RealityKit sendiri), karena CustomMaterial butuh MTLDevice/MTLLibrary nyata untuk mencari fungsi shader bernama yang dikompilasi dari file .metal.
Catatan menarik: Jejak komentarnya mendokumentasikan riwayat pendekatan yang ditolak yang menarik: refleksi air pertama kali dicoba sebagai pola "pita garis pantai" prosedural murni, yang menghasilkan sorotan radial sembarangan tanpa hubungan nyata dengan langit (secara eksplisit disebut bukan yang dimaksud pengguna dengan minta air "merefleksikan langit" β "not reflecting the sky, random reflect highlight"); lalu dua putaran percobaan riak prosedural (gelombang sinus analitik, lalu pola refleksi palsu) juga "tetap terlihat sintetis," yang membuat tim akhirnya cuma pakai tekstur peta normal air nyata yang gratis dan dikenal luas, di-sample tiga kali (lapisan sample ketiga yang bergulir ditambahkan di putaran belakangan) dengan cara yang sama seperti shader air referensi three.js sendiri melakukannya. File ini cuma MENYIAPKAN material; logika sampling/animasi sebenarnya ada di WaterShader.metal, yang di luar cakupan dokumentasi ini.
13. Transport/PairedDeviceStore.swift
Mengingat, lintas peluncuran ulang aplikasi, PERSIS perangkat Rod mana (lewat identifier peripheral Bluetooth) yang dipasangkan dengan World, supaya di ruangan dengan banyak pasangan Rod/World di dekatnya, World tidak asal terhubung ke Rod mana pun yang kebetulan mengiklankan diri lebih dulu.
Tipe yang didefinisikan: PairedDeviceStore (final class) β tidak ada enum/struct sendiri.
Fungsi-fungsi penting:
init()β membaca string UUID tersimpan dan nama perangkat dariUserDefaults, mem-parsing UUID kalau ada.func save(identifier:, name:)β menyimpan identifier dan nama (opsional), menyimpannya keUserDefaults; menghapus key nama kalaunameadalahnil.func clear()β menghapus kedua nilai di memori dan entryUserDefaults-nya.
Catatan tambahan: private(set) var identifier: UUID? dan private(set) var name: String? β identitas Bluetooth Rod yang sedang dipasangkan saat ini, atau nil kalau belum pernah dipasangkan.
Terhubung ke: Dimiliki eksklusif oleh TransportHost, yang membaca identifier/name dan memanggil save/clear pada titik yang tepat dalam siklus hidup koneksi Bluetooth. Cuma memakai UserDefaults dari Foundation.
Catatan menarik: Pemasangan tercatat otomatis pertama kali sebuah koneksi benar-benar berhasil PENUH (berlangganan kedua characteristic notify dan punya characteristic command) β tidak ada UI pemasangan khusus; satu-satunya cara melepas pasangan adalah forgetPairedDevice() di TransportHost, ditampilkan ke pemain sebagai "Forget Device" di MainMenuView.
14. Transport/TransportHost.swift
Ini implementasi World untuk koneksi Bluetooth Low Energy ke Rod. World bertindak sebagai BLE central (pihak yang memindai dan menghubungkan ke perangkat lain), mencerminkan apa yang dulunya dibangun pakai framework MultipeerConnectivity lama Apple. Dia memindai layanan yang diiklankan Rod, terhubung, berlangganan data keluar Rod, dan menulis perintah balik.
Tipe yang didefinisikan:
TransportHost(final class,NSObject,@MainActor, mengikuti protokolTransportProtocoldariShared).TransportHost.HostError(enum):.noConnectedPeripheral,.encodingFailed,.missingCharacteristic.TransportHost.QueuedSend(private struct) β satu pesan keluar yang sedang berjalan: potongan-potongan (chunks) yang belum terkirim danCheckedContinuationuntuk dilanjutkan begitu terkirim penuh.
Fungsi-fungsi penting:
override init()β membuatCBCentralManagerdenganqueue: nil(antrean utama β cocok dengan isolasi@MainActorclass ini).func connect() async throwsβ mengaturwantsToScan = true, melaporkan.searching, dan memanggilstartScanningIfPossible().func disconnect()β menghentikan pemindaian, membatalkan koneksi peripheral yang aktif, membongkar state lokal, melaporkan.disconnected.func forgetPairedDevice()β membatalkan koneksi saat ini, membongkar state, menghapus pemasangan tersimpan (PairedDeviceStore.clear()), dan langsung mulai memindai lagi (tidak menyentuhwantsToScan, jadi sesi yang sudah aktif langsung mencari Rod BARU).func send(_ message: NetworkMessage) async throwsβ meng-encode pesan jadi JSON dan mengirimkannya lewatsendCommand(_:to:characteristic:); melempar error kalau tidak ada peripheral/characteristic terhubung atau encoding gagal.private func startScanningIfPossible()β kalau sudah ada perangkat yang dipasangkan, mencoba dulucentralManager.retrievePeripherals(withIdentifiers:)(membiarkan iOS mengembalikan peripheral yang masih ter-bond/baru terlihat tanpa scan baru); kalau tidak, mulaiscanForPeripherals(withServices:)biasa.private func connect(to:)β menghentikan pemindaian, melaporkan.connecting, dan memanggilcentralManager.connect(peripheral).private func tearDownConnection()β menggagalkan semua pengiriman yang mengantre, menghapus referensi characteristic/peripheral, mereset flag langganan, dan mereset kedua reassembler pesan.private func chunkBudget(for:) -> Intβ ukuran maksimum chunk penulisan BLE untuk peripheral saat ini, dengan batas bawah diBLEFraming.headerSize + 1.private func sendCommand(_:to:characteristic:) async throwsβ memecah data jadi chunk ukuran BLE (lewatBLEFraming.chunks), menambahkanQueuedSendke antrean, dan β kalau itu satu-satunya item β mulai mengosongkan antrean. MemakaiwithCheckedThrowingContinuationsupaya panggilan async cuma kembali begitu setiap chunk benar-benar tertulis.private func drainCommandQueue()β mengirim persis satu chunk dari pesan paling depan antrean sekaligus (penulisan selesai satu per satu, ditandai oleh delegatedidWriteValueFor), berbeda dari jalur notify peripheral yang bisa mengosongkan banyak sekaligus.private func failAllQueuedSends(with:)β melanjutkan setiap continuation yang mengantre dengan error yang diberikan (dipakai saat disconnect/pembongkaran).- Konformasi
CBCentralManagerDelegate:centralManagerDidUpdateState(mulai memindai saat.poweredOn, melaporkan.failedsaat.poweredOff/.unauthorized/.unsupported),didDiscover(mengabaikan apa pun yang bukanrodPeripheral == nilatau, setelah dipasangkan, bukan identifier yang dipasangkan β kalau tidak, terhubung),didConnect(menemukan layanan mancing),didFailToConnect/didDisconnectPeripheral(membongkar state dan β patut dicatat β selalu memulai ulang pemindaian sesudahnya supaya rekan yang kembali ke jangkauan terhubung ulang otomatis). - Konformasi
CBPeripheralDelegate:didDiscoverServices(menemukan tiga characteristic yang dibutuhkan di layanan yang cocok),didDiscoverCharacteristicsFor(berlangganan dua characteristic notify, menyimpan referensi characteristic command),didUpdateNotificationStateFor(begitu kedua langganan notify dan characteristic command semuanya siap, menyimpan pemasangan pada keberhasilan pertama kalinya dan melaporkan.ready),didUpdateValueFor(memasukkan data BLE masuk keBLEMessageReassembleryang tepat berdasarkan characteristic asalnya, mendecode tiap pesan yang tersusun ulang sebagaiNetworkMessage, dan memanggilonMessage?; MENCATAT β bukan diam-diam menelan β kegagalan decode apa pun, khususnya karena ketidakcocokan skema antara build Rod/World sebelumnya menjatuhkan setiap pesan tanpa sinyal terlihat sama sekali),didWriteValueFor(memajukan antrean kirim saat sukses, atau menggagalkan pengiriman paling depan antrean dan lanjut kalau ada error).
Catatan tambahan: private var centralManager: CBCentralManager!, rodPeripheral: CBPeripheral?, commandCharacteristic: CBCharacteristic? β objek Bluetooth yang aktif. private let pairedDeviceStore = PairedDeviceStore(). private let stateReassembler / reliableInReassembler: BLEMessageReassembler β menyusun ulang pesan multi-chunk yang datang di dua characteristic notify. private var commandSendQueue: [QueuedSend] β antrean pesan keluar. var isConnected: Bool β apakah commandCharacteristic ada. var pairedDeviceName: String? / var hasPairedDevice: Bool β pass-through ke pairedDeviceStore. var onMessage: ((NetworkMessage) -> Void)? / var onStateChange: ((ConnectionState) -> Void)? β closure callback yang dipasang WorldViewModel.
Terhubung ke: Dibuat oleh ContentView.init() dan diberikan ke WorldViewModel. Bergantung ke CoreBluetooth, dan SessionConfiguration dari Shared (untuk UUID layanan/characteristic tetap), BLEFraming (helper chunking), TransportProtocol, NetworkMessage, ConnectionState.
Catatan menarik: Komentar class-nya secara eksplisit membingkai ini sebagai mencerminkan "peran 'host' MultipeerConnectivity sebelumnya," artinya ini migrasi dari teknologi transport lama ke CoreBluetooth. Perubahan pencatatan kegagalan decode (eksplisit #if DEBUG print alih-alih try? yang diam) disebut sebagai memperbaiki kelas bug nyata yang sebelumnya tidak terlihat, di mana ketidakcocokan versi antara model Swift kedua aplikasi membuat koneksi terlihat sehat padahal secara harfiah tidak ada yang berfungsi.
15. ViewModels/WorldViewModel.swift
Ini orkestrator pusat seluruh aplikasi. Dia memiliki mesin state layar paling atas (onboarding/menu/main), menggerakkan tick simulasi per-frame (pose joran, kamera, fisika kail, lempar, reel, cutscene tangkapan), dan menjadi satu-satunya tempat di mana minigame mancing (FishFightController), scene 3D (entity RealityKit), sistem suara (FishingSounds), dan transport jaringan (TransportHost) semua disambungkan bersama. Ini file terbesar di aplikasi (1.374 baris), jauh melampaui yang lain.
Tipe yang didefinisikan:
WorldViewModel(final class,ObservableObject,@MainActor).WorldViewModel.Screen(enum):.onboarding,.mainMenu,.playingβ layar aplikasi paling atas.WorldViewModel.CatchCutscene(private struct):origin: SIMD3<Float>,elapsed: Float = 0β melacak cutscene tangkapan yang sedang berlangsung.
Fungsi-fungsi penting (dikelompokkan berdasarkan tujuan supaya mudah dibaca; semua tercakup):
Siklus hidup / setup
init(transport:)β menyimpan transport, mengaturscreenawal berdasarkan apakah onboarding sudah pernah dilihat (Self.hasSeenOnboarding), dan menyambungkan closuretransport.onStateChange/onMessage.onStateChange: memperbaruiconnectionState; saat.disconnected/.failedmemanggilreturnToMenu()(jaring pengaman, karena Rod adalah satu-satunya otoritas mulai/selesai sesi lewatRodState.isFishing, dan koneksi yang putus berarti tidak ada lagi update state yang datang untuk mengakhiri sesi secara normal); saat.ready, menghapuslastErrordan mengirim ulanghookPhasesaat ini (kalau-kalau disconnect sebelumnya membuat Rod tidak pernah dapat update fase dan akan terjebak percaya sesuatu yang basi).onMessage: meneruskan kehandle(_:).func start()β dijaga supaya cuma jalan sekali (hasStarted); memberitahusoundsstate layar saat ini, lalu secara asinkron memanggiltransport.connect().func stop()β memutus transport, meresethasStarted, menghentikan semua suara.func retryConnection()β aksi tombol "Retry Connection" yang dilihat pemain: memutus lalu menghubungkan ulang transport, tanpa menyentuh state game lain β dijelaskan sebagai memulihkan link Bluetooth yang dilaporkan kadang macet tak-terpulihkan sendiri.var pairedDeviceName: String?/var hasPairedDevice: Boolβ pass-through ke transport.func forgetPairedDevice()β pass-through ketransport.forgetPairedDevice().func resetProgression()β pass-through kefishFight.resetProgression().func completeOnboarding()β menyimpan bahwa onboarding sudah dilihat, kembali ke.mainMenu.func showOnboarding()β cuma dari.mainMenu, berpindah ke.onboarding(tombol "How to Play").func attachFrameLoop(to:)β berlangganan sekali ke eventSceneEvents.Updatemilik scene RealityKit, memanggiladvance(deltaTime:)setiap frame yang benar-benar dirender β menggantikan loop timer lama berbasisTask.sleepdengan clock terpisah yang melenceng saat beban tinggi.private func advance(deltaTime:)β memperbarui penghitung FPS berjalan, lalu (cuma kalauhasStarted) memanggiltick(deltaTime:).private func updateFPS(deltaTime:)β mengakumulasi jumlah frame/waktu selama jendela bergulir setengah detik dan menghitung ulangfpsdarinya, bukannya satu1/deltaTimeyang berisik per frame.
Pembangunan scene
func makeSceneEntity() async -> Entityβ membangun seluruh scene sekali: menambahkan hasilEnvironmentSceneBuilder.build(), membangun dan menambahkan kamera pemain, memuat rig joran (RodRigController.load()), membuat entity kail bola kuning placeholder (dimatikan sampai lemparan pertama), memuat aset showcase "ikan tertangkap" beranimasi (mekong_catfish_caught, selalu spesies ini apa pun tier yang sebenarnya terundi β didokumentasikan sebagai keputusan konten yang ditunda sampai lebih banyak spesies diperiksa), dan membangun entity garis cutscene silinder-diregangkan sederhana (solusi sementara karenarod_line.usdzyang di-rig tidak sampai ke posisi kail cutscene).private func makePlayerCamera() -> Entityβ membangunPerspectiveCamera(FOV vertikal 46.4Β°) dan memposisikannya lewatresetCameraToPlayerPose. Didokumentasikan sebagai perlu karenaRealityViewdi mode "virtual" non-AR tidak punya sudut pandang orang-pertama default sama sekali β tanpa kamera eksplisit, dia otomatis membingkai seluruh kotak batas scene dari sudut sembarang, itulah yang menyebabkan bug awal "berdiri di dalam air melihat ke atas ke dermaga."private func resetCameraToPlayerPose(_:)β mengarahkan kamera dariplayerCameraPositionyang tetap, dimiringkan ke bawah sebesarcameraPitchDownDegrees, menuju vektor depan yang dihitung.private func updateCameraFollow()β tiap tick, memutuskan di mana kamera seharusnya berada: selama cutscene tangkapan, dolly halus (eased) di antara dua offset sambil melihat ke titik asal cutscene ditambah ketinggiannya saat ini; selagi kail.flying/.fishOnHook/.reeling, mengikuti di belakang dan di atas kail sambil melihat lurus ke sana (untuk framing dramatis "tonton perkelahian dari dekat"); kalau tidak, langsung kembali ke pose pemain tetap.
Transisi layar/fase
func continueFishing()β menutup layar hasil yang sedang tampil dan mengembalikanhookPhaseke.idle; cuma valid dari.result.private func returnToMenu()β reset lengkap saat sesi berakhir (lewat disconnect atau "End Fishing" milik Rod): mematikan entity kail/ikan/senar, mengosongkan cutscene, mereset pengontrol perkelahian untuk lemparan baru, mengaturhookPhase = .idle, berpindahscreen = .mainMenu, memberitahu suara bahwa layar berubah. Secara eksplisit TIDAK memutus transport (supaya memulai ulang tidak perlu menemukan ulang perangkat).private func setHookPhase(_:)β satu-satunya tempathookPhasepernah diubah: dijaga dari pengaturan-ulang tanpa-perubahan, memberitahusounds.handlePhaseChange, dan secara asinkron mengirim fase baru ke Rod.
Tick utama per-frame
private func tick(deltaTime:)β jantung simulasi, dipanggil sekali tiap frame yang dirender. Memperbarui pose visual joran dan kamera pengikut, menerapkan tegangan senar/putaran reel ke rig joran, lalu switch berdasarkanhookPhase:.idle: kalau Rod melaporkan.castingdan adapendingCastSolutionyang menunggu, memanggillaunchHook(solution:)..flying: memanggilsimulateFlyingHook(deltaTime:)(busur balistik)..hookInWater: memanggilfishFight.updateSearching(deltaTime:), bereaksi terhadap.interested(mengeluarkan.fishInterested) atau.bite(berpindah ke.fishOnHook, memanggilensureFightDistance(), mengeluarkan.fishBite); kalau pemain sedang reel, memanggilreelHook, kalau tidakupdateBobberFloat(animasi mengambang idle)..result: kalau cutscene sedang berlangsung, memajukannya lewatupdateCatchCutscene..fishOnHook/.reeling: kalau reel, memanggilreelHook; kalau tidak,driftHookAwaylalu menegaskan ulang.fishOnHook. Selalu menerapkanupdateFightDirectionDrift, lalu memanggilfishFight.updateFight(...)dan, kalau gagal,resolveFightFailure(_:).
updateReelLoop,updateIdleLoop,updateReelFightLoop) dengan kondisi "apakah loop ini seharusnya main sekarang" yang hidup, dan memanggilsyncTensionBand().private func hookDistanceFromRod() -> Floatβ jarak 3D garis-lurus antara akar joran dan kail, dipakai untuk pemeriksaan jarak-escape.private func syncTensionBand()β kapan punfishFight.tensionBandberubah nilai, mengeluarkanFishingEventyang cocok (.reelTensionLow/Medium/High) ke Rod supaya bisa memulsakan haptic-nya di ritme yang benar tanpa World perlu terus-menerus menstream tegangan mentah.private func resolveFightFailure(_:)β saat.escapedatau.lineBreak, mematikan entity kail, mengaturhookPhasebalik ke.idle(lemparan baru dibutuhkan β kedua jenis kegagalan sekarang diperlakukan sama, sesuai permintaan langsung pemain bahwa escape karena tegangan kendur juga harus langsung reset alih-alih tetap "di air"), dan mengeluarkan event yang cocok.
Helper gerakan kail/pelampung
private func updateBobberFloat(deltaTime:)β menerapkan bob gelombang sinus idle yang halus ke kail yang mengambang, plus (selagi ikan tertarik) dip "gigitan kecil" yang lebih tajam selalu ke bawah dilapiskan di atasnya β foreshadowing visual sebelum gigitan sesungguhnya.private func ensureFightDistance()β dipanggil tepat saat ikan menggigit: kalau kail saat ini lebih dekat ke joran darifishFight.minimumFightDistance, mendorongnya keluar menyusuri arah-menjauh supaya tickreelHookberikutnya tidak langsung menyelesaikan tangkapan sebelum meteran tegangan sempat merender satu frame pun.private func driftHookAway(deltaTime:)β menggerakkan kail menjauh dari joran dengan kecepatanfishFight.fishPullSpeedselagi pemain tidak reel; dalam jarakhookLiftDistancedari target angkat, dia hanyut penuh 3D (jadi ikan yang kabur/diabaikan secara visual menarik kail turun kembali ke air saat mundur), kalau tidak dia tetap terpaku di ketinggian air dan cuma bergerak horizontal.private func updateFightDirectionDrift(deltaTime:)β dorongan samping murni visual pada kail menuju arah mana pun yang sedang ditunjukfishFight.fightDirection, dibatasi ke offset lateral maksimum dari posisi X joran sendiri β evaluasi sukses/gagal melawan yang sebenarnya terjadi di dalamFishFightControllerdari roll joran, bukan dari visual ini.
Pose joran
private func updateRodPose()β mengatur posisi akar rig joran kedockAnchorPositionyang tetap dan orientasinya langsung dariRodState.orientation(kuaternion yang sudah dikoreksi yang di-stream dari Rod β secara eksplisit tidak boleh direkonstruksi dari sudut Euler mentah atau dihaluskan ulang di sini), lalu memanggilrodRig?.applyBend(pitch:roll:)untuk visualisasi bengkok sekunder.private func lineTension(for:) -> Floatβ nilai tegangan tetap berbasis fase yang dipakai cuma untuk rig kendur senar VISUAL (bukan angka tegangan gameplay sebenarnya): 0 selagi idle/result, 0.9 selagi flying, 0.3 selagi di air menunggu, 0.8 selagi berkelahi.
Melempar (casting)
private func launchHook(solution:)β seluruh urutan peluncuran lemparan. Menjaga bahwa scene sudah benar-benar dibangun (mencatat pesan debug dan berhenti kalau belum). Menempatkan kail di ujung joran. Menurunkan arah bidik horizontal sebenarnya dari GEOMETRI RIG YANG HIDUP (vektor dari akar joran ke posisi dunia ujungnya saat ini) alih-alih mempercayai x/z mentahCastSolution.directionsebagai heading dunia langsung β didokumentasikan sebagai perlu karena field itu didefinisikan relatif ke konvensi panah placeholder yang dikodekan langsung yang sudah tidak lagi cocok dengan sumbu rig mesh joran sebenarnya. Menerapkan rotasi koreksi empiris (aimCorrectionAngle = .pi, yaitu 180Β°) yang ditemukan dengan menambahkan pembacaan sudut debug dan mendapati bidikan mentah mendarat hampir persis terbalik dari arah depan. Membatasi arah yang sudah dikoreksi ke kerucut depan selebar 135Β° (clampedToForwardCone) apa pun hasil matematika mentahnya, sesuai keluhan langsung pemain bahwa arah jatuh pelampung terasa tidak dapat diprediksi. Mencatat empat nilai debug (sudut bidik mentah/terkoreksi, koordinat menghadap/mendarat) untuk panel debug di layar (yang sekarang sebagian besar dikomentari). Menghitung kecepatan luncur daricastSpeeddanpowerternormalisasi solusi (dengan batas bawah dan skala yang secara eksplisit di-tuning supaya lemparan zona-hijau/lemah tetap melewati jejak dermaga sendiri, bukan mendarat di dalamnya β "nembus pier kebawah"). MengaturhookVelocity, memutar suara lempar, dan berpindah ke.flying.private func rotatedAroundY(_:by:) -> SIMD3<Float>β helper rotasi 2D (bidang horizontal) dipakai untuk menerapkan koreksi bidik.private func clampedToForwardCone(_:) -> SIMD3<Float>β mengonversi arah horizontal jadi sudut bertanda dari lurus-depan, membatasinya keΒ±castConeHalfAngle, dan membangun ulang vektornya β lebih murah daripada slerp kuaternion untuk satu sumbu referensi tetap.private func signedAngleDegrees(_:) -> Floatβ konvensi sudut yang sama dengan cone clamp, dalam derajat, tak-dibatasi, cuma untuk tampilan debug.private func simulateFlyingHook(deltaTime:)β menerapkan gravitasi kehookVelocity, mengintegrasikan posisi kail; begitu melintasiwaterHeight, langsung menempel ke permukaan, menolkan kecepatan, menghitung jarak lemparan horizontal sebenarnya yang ditempuh, mencatat koordinat debug mendarat-vs-menghadap, memanggilfishFight.resetForNewCast(castDistance:), mereset fase pelampung, berpindah ke.hookInWater, dan mengeluarkan.hookSplash.
Menarik (reeling)
private func reelHook(deltaTime:)β logika gerakan reel-in. Kalau kail dalam jarakhookLiftDistancedarihookLiftTargetPosition, penarikan selesai SEKETIKA (langsung menempel ke target angkat alih-alih terus naik pelan yang terlihat): kalau ada ikan terkait, memanggilfishFight.resolveCatch()danbeginCatchCutscene(from:)lalu mengeluarkan.catchFish; kalau tidak, cuma mematikan kail (penarikan kosong). MengaturhookPhaseke.resultatau.idlesesuai. Di luar zona itu, menghitung kecepatan menutup bersih βfishFight.reelPullSpeed(kurva risiko/hadiah yang diskalakan tegangan) kalau ada ikan terkait, atau konstanta ratareelSpeedkalau tidak β dikurangifishFight.fishPullSpeed, lalu menggerakkan kail horizontal menuju target angkat sebesar itu (yang bisa NEGATIF, artinya ikan yang kuat masih bisa merebut senar bahkan saat pemain aktif tapi lemah menarik). Cuma menaikkanhookPhaseke.reelingkalau memang ada ikan terkait β menarik senar kosong selagi.hookInWaterharus tetap.hookInWater.
Cutscene tangkapan
private func beginCatchCutscene(from:)β memulai seluruh urutan "CATCH!". Kalau entity ikan showcase berhasil dimuat, menyalakan dan memposisikannya, mengatur orientasi "menggantung vertikal" tetapnya (catchCutsceneFishOrientation), menskalakannya supaya cocok dengan panjang tangkapan yang sesungguhnya terundi (dengan batas bawah supaya ikan kecil tidak menyusut jadi nol), menskalakan bola kail supaya cocok juga, memarkir kail di mulut ikan (offset tetap menyusuri sumbu kepala lokalnya, diskalakan), memperbarui garis cutscene supaya menghubungkan kail-ke-ujung-joran, dan memutar klip animasi baked "Caught" milik ikan pada kecepatan yang dihitung (lihatcatchCutsceneFishAnimationSpeeddi bawah) supaya puncak dramatis yang terautentikasi jatuh di momen yang tepat. Selalu mencatat stateCatchCutscenedan mengubahisShowingCatchCutscene = true.private func catchCutsceneHeight(elapsed:) -> Floatβ kurva ketinggian naik/beku/turun bersama (eased sinus naik selama fase naik, ditahan datar selama fase beku, eased cosinus turun lagi selama fase turun), dipakai untuk menggerakkan entity mana pun yang sedang memainkan cutscene dan menjaga titik fokus kamera tetap sinkron.private var catchCutsceneFishOrientation: simd_quatfβ orientasi "menggantung vertikal, kepala di atas" yang tetap dibangun langsung dari vektor basis target (bukan fungsifishOrientation(facing:)yang lebih umum di bawah, karena konstruksi fungsi itu berdegenerasi kalau arah menghadap lurus ke atas).private func fishOrientation(facing:) -> simd_quatfβ fungsi tujuan-umum (didefinisikan tapi, menurut komentar di sekitarnya, efektif digantikan oleh orientasi vertikal tetap di atas untuk cutscene saat ini) yang memutar sumbu kepala lokal ikan supaya menghadap arah dunia sembarang lewat rotasi jalur-terpendek, lalu mengoreksi roll hasilnya supaya sirip punggung ikan mengarah sedekat mungkin ke atas-dunia secara geometris.private func updateCutsceneLine(from:)β meregangkan/mengarahkan entity garis silinder sederhana tiap frame supaya membentang dari posisi kail saat ini ke posisi ujung joran yang hidup sebenarnya, mematikan dirinya sendiri dengan anggun kalau belum ada referensi joran atau kedua titik itu terlalu dekat sampai degenerat.private func catchCutsceneTimeDilation(elapsed:) -> Floatβ menghitung pengali slow-motion waktu-nyata-ke-waktu-cerita: kecepatan normal (1) selama fase naik; di dalam fase beku dan, terpisah, di dalam fase turun, melandai turun kecatchCutsceneSlowMotionFactor(0.3Γ) selamacatchCutsceneSlowMotionEaseFractionpertama/terakhir dari jendela fase itu sendiri dan menahan dip-nya di tengah β efek "bullet time" yang disengaja pada tangkapan.private func updateCatchCutscene(deltaTime:)β memajukancutscene.elapsedsebesardeltaTime * dilation(bukandeltaTimementah), menyasar ulang kecepatan playback animasi ikan supaya cocok dengan dilation yang sama tiap frame, menghitung ketinggian/posisi saat ini, menggerakkan entity mana pun yang sedang memainkan cutscene (mengutamakan ikan sungguhan, jatuh balik ke bola kail placeholder kalau aset ikan gagal dimuat) dan menjaga kail/garis tetap terparkir benar relatif terhadapnya. Begituelapsedmencapai durasi total, semuanya dibongkar (entity dimatikan, state cutscene dikosongkan,isShowingCatchCutsceneberbalik jadifalse).
Penghubung jaringan
private func handle(_ message: NetworkMessage)β handler pesan masuk. Cuma bereaksi terhadap pesan.state(RodState)(mengabaikan.discoveryToken/.fishingEvent/.hookPhase, yang cuma-keluar dari sudut pandang World atau tidak relevan di sini). MencatatrodStateyang baru; kalau membawacastSolutionsegar selagiaction == .casting, menyimpannya sebagaipendingCastSolutionuntuk dikonsumsi tick berikutnya; kalauisCastingArmed, memanggilcontinueFishing()(otomatis menutup hasil yang sedang tampil tepat saat pemain memasang lemparan baru, menggantikan tombol "Continue Fishing" eksplisit lama); mengaturconnectionState = .streaming; dan bereaksi terhadap tepi perubahanisFishingβ tepi naik berpindah ke.playingdan memberitahu suara bahwa layar berubah, tepi turun memanggilreturnToMenu(). Ini mekanisme konkret di balik "Rod memiliki batas mulai/selesai sesi."private func emit(_ event: FishingEvent)β corong tunggal untuk setiap event gameplay yang dikeluarkan World: memutar suara yang cocok lewatsounds.handle(event)SEKALIGUS secara asinkron mengirimkannya ke Rod, dari panggilan yang sama, khusus supaya suara dan transmisi jaringan tidak pernah bisa jadi tidak sinkron.private func send(_ message: NetworkMessage) asyncβ menunggutransport.send(message), menghapuslastErrorsaat sukses atau mengaturnya jadi deskripsi error saat gagal.
Terhubung ke: Dibuat sekali oleh ContentView.init(). Bergantung ke hampir semua yang lain di aplikasi: EnvironmentSceneBuilder, RodRigController, WaterMaterial (tidak langsung, lewat EnvironmentSceneBuilder), FishFightController, FishingSounds, TransportHost, dan tipe RodState/HookPhase/ConnectionState/FishingEvent/NetworkMessage/CastSolution dari paket Shared. Setiap file View di aplikasi membaca properti publikasinya atau memanggil metodenya.
Catatan menarik: File ini pada dasarnya sebuah log berjalan berisi bug nyata yang ditemukan lewat screenshot langsung dan diperbaiki di tempat, masing-masing dengan paragraf penalarannya sendiri β penemuan "sudut koreksi bidik" (bidik mentah terukur di β174Β°, praktis terbalik, bukan 90Β°-melenceng yang diasumsikan awalnya), perbaikan batas bawah kekuatan-lempar untuk clipping dermaga, pemisahan target-angkat-vs-posisi-jangkar (supaya kail berhenti naik MENEMBUS mesh padat dermaga), rumus kecepatan animasi cutscene yang DITURUNKAN (bukan dipilih manual), dan dip slow-motion "bullet time" dua-fase, semuanya dikerjakan matematisnya langsung di komentar. Beberapa nomor ADR (038, 039, 043, 045, 046, 047, 048, 056, 061, 064, 065, 066, 067, 069, 070, 071, 072) dirujuk berulang di seluruh file, jadi semacam changelog de-facto riwayat iterasi fitur mancing.
16. Views/CalibrationGuidanceView.swift
Menampilkan instruksi langkah-demi-langkah langsung ke pemain untuk mengkalibrasi ponsel-sebagai-joran mereka, bereaksi langsung terhadap langkah apa pun yang sedang dilaporkan Rod saat ini. Dipakai ulang baik untuk kalibrasi pertama kali (dari Main Menu) maupun kalibrasi ulang di tengah sesi kapan pun.
Tipe yang didefinisikan: CalibrationGuidanceView (SwiftUI View).
Fungsi-fungsi penting:
private var title: Stringβ switch padastep(CalibrationStep, enum dariShared:.idle/.cast/.reel/.complete) menghasilkan "Step 1 of 3 β Idle Pose," "Step 2 of 3 β Cast Gesture," "Step 3 of 3 β Reel Gesture," atau "Calibration Complete."private var instruction: Stringβ teks instruksi bahasa sederhana yang cocok untuk tiap langkah.private var rodButtonLabel: String?β label tombol persis yang ditampilkan Rod sendiri untuk langkah saat ini (mis. "Capture Idle Pose"), secara eksplisit dijaga sinkron lewat referensi komentar kecalibrationButtonTitlemilik Rod sendiri supaya teks kedua aplikasi tidak bisa diam-diam melenceng;nilbegitu.complete(tidak ada tombol lagi untuk ditekan).var body: some Viewβ sebuah ikon (ikon scope, atau centang hijau begitu selesai), judul, teks instruksi, dan β kalau ada label tombol β baris "Press '<label>' on your Rod" yang disorot.
Catatan tambahan: let step: CalibrationStep β satu-satunya input, menggerakkan setiap properti terhitung lainnya.
Terhubung ke: Dibuat instance-nya baik oleh MainMenuView (kalibrasi pra-sesi) maupun recalibrationOverlay milik ContentView (kalibrasi ulang tengah-sesi). Bergantung ke CalibrationStep dari Shared.
Catatan menarik: Komentar dokumentasinya secara eksplisit membingkai file ini sebagai menutup celah desain yang sebelumnya ditandai ("Calibration instructions appear entirely in World") yang sudah terbuka sejak dua keputusan arsitektur sebelumnya.
17. Views/CastMeterView.swift
Meteran kekuatan lemparan di layar β bar hijau/kuning/merah yang menunjukkan seberapa keras lemparan saat ini terisi, cuma terlihat selama fase gerakan melempar sesungguhnya.
Tipe yang didefinisikan: CastMeterView (SwiftUI View).
Fungsi-fungsi penting:
private var isVisible: Boolβ benar cuma selagiphase(enumRodMotionPhasedariShared:.invalid/.idle/.backswing/.charging/.forwardSwing/.release/.followThrough/.casting/.reeling) adalah salah satu dari lima fase terkait-lempar.var body: some Viewβ kalau terlihat: baris judul ("Cast Meter" + timer "Holding X.Xs" langsung dariholdDuration), bar kekuatan tiga warna (hijau/kuning/merah) dengan marker vertikal tipis diposisikan dipowersaat ini (0β1) lewatGeometryReader, baris keterangan "Near"/"Far", dan β cuma selama.release/.followThroughdan kalau adacastSolutionβ angka power/jarak yang benar-benar sudah terselesaikan; kalau tidak, keterangan "No active cast".
Catatan tambahan: let castSolution: CastSolution?, power: Float, phase: RodMotionPhase, holdDuration: TimeInterval β semuanya diberikan dari luar dari WorldViewModel.rodState, tidak ada state lokal.
Terhubung ke: Dibuat instance-nya oleh ContentView, diberi makan langsung dari field viewModel.rodState. Bergantung ke CastSolution/RodMotionPhase dari Shared.
Catatan menarik: Sepertiga bar kekuatan hijau/kuning/merah persis sama dengan pembagian yang dipakai FishFightController.CastZone untuk membatasi ukuran ikan yang terundi β komentar dokumentasi di FishFightController merujuk silang view ini dengan namanya untuk menjelaskan kenapa keduanya harus tetap sejajar secara angka walaupun tidak berbagi kode.
18. Views/CatchCutsceneView.swift
Overlay sinematik layar-penuh yang ditampilkan selama jeda singkat antara tangkapan yang terselesaikan dan kartu hasil muncul β bar letterbox, cahaya radial, dan judul "CATCH!" beranimasi pegas, sementara animasi lompatan ikan 3D sesungguhnya berjalan di belakangnya di scene RealityKit.
Tipe yang didefinisikan: CatchCutsceneView (SwiftUI View).
Fungsi-fungsi penting:
var body: some Viewβ melapiskan vignette gradien radial yang meredupkan, bar letterbox hitam atas/bawah (Rectangleyang tingginya beranimasi masuk lewatbarHeight), dan lingkaran cahaya di tengah plus teks "CATCH!" (membesar/memudar masuk lewattitleScale/titleOpacity/glowOpacity). Ditandai.allowsHitTesting(false)β ini murni dekoratif dan tidak pernah menangkap sentuhan.- Closure
.onAppearβ memicu dua animasi SwiftUI terpisah: perluasan ease-out bar letterbox selama 0.25 detik, dan pembesaran/pemudaran-masuk pegas (response 0.45, damping 0.45 β "pop" melebih-lebihkan) untuk judul dan cahaya.
Catatan tambahan: Empat nilai penggerak animasi @State (titleScale, titleOpacity, glowOpacity, barHeight) β murni presentasi, dianimasikan sekali saat muncul, tidak dibaca file lain mana pun.
Terhubung ke: Ditampilkan oleh ContentView kapan pun viewModel.isShowingCatchCutscene bernilai true. Tidak ada dependensi selain SwiftUI.
Catatan menarik: Komentar dokumentasi secara eksplisit menjelaskan KENAPA begitu banyak polesan presentasi ditambahkan di sini: label "CATCH!" statis saja, bahkan digabung dengan potongan kamera 3D dan freeze slow-motion yang sudah diimplementasikan di WorldViewModel, tetap terasa datar β ini dijelaskan sebagai "tuas presentasi-saja yang tersedia sebelum aset ikan/kamera sungguhan datang," yaitu solusi sementara yang disengaja mengingat keterbatasan aset.
19. Views/LevelUpBannerView.swift
Banner toast yang bisa ditutup, muncul di atas layar tepat saat sebuah tangkapan melewati ambang tier progresi (mis. membuka ikan medium, atau Scott muncul).
Tipe yang didefinisikan: LevelUpBannerView (SwiftUI View).
Fungsi-fungsi penting: var body: some View β ikon bintang, teks pesan, dan tombol tutup (Γ), dalam kartu gelap membulat yang dipasang di atas, dibungkus dengan .transition(.move(edge: .top).combined(with: .opacity)) untuk animasi masuk/keluarnya.
Catatan tambahan: let message: String, let onDismiss: () -> Void β tidak ada state internal; sepenuhnya dikendalikan oleh parent.
Terhubung ke: Ditampilkan oleh ContentView kapan pun fishFight.levelUpAnnouncement tidak nil; closure onDismiss-nya memanggil viewModel.sounds.uiClick() lalu fishFight.dismissLevelUpAnnouncement().
Catatan menarik: Sengaja tidak punya timer auto-dismiss β komentar dokumentasi menjelaskan ini disengaja supaya momen "Scott has appeared" tidak bisa pernah terlewat karena melirik pada detik yang salah; juga secara eksplisit dirancang bisa menumpuk secara visual di atas ResultView karena tangkapan yang memicu naik level tetap merupakan tangkapan normal juga.
20. Views/LineTensionView.swift
Meteran tegangan saat perkelahian β elemen HUD inti "jangan biarkan kendur, jangan tarik terlalu kencang" yang ditampilkan cuma selagi ikan sedang aktif diperjuangkan.
Tipe yang didefinisikan: LineTensionView (SwiftUI View).
Fungsi-fungsi penting:
private var color: Colorβ merah kalautensiondi luar rentang "bahaya" (<0.15atau>0.85), kuning kalau di luar rentang "aman" yang lebih ketat (<0.3atau>0.7) tapi masih di dalam bahaya, kalau tidak hijau.private var warningLabel: String?β "LOSING FISH" di bawah ambang bahaya-rendah, "SNAPPING" di atas ambang bahaya-tinggi, kalau tidaknil.var body: some Viewβ cuma dirender selagiisFighting. Menampilkan header dengan label peringatan opsional, bar kapsul berbasisGeometryReader(pita "zona aman" hijau yang diarsir digambar di bawah isian tegangan berwarna sesungguhnya), baris keterangan "Slack"/"Snap", dan β kalaufightDirectiontidak nil β baris yang menunjukkan arah mana ikan sedang menarik (panah) dan arah mana pemain harus memiringkan joran untuk melawan (panah sebaliknya).
Catatan tambahan: let isFighting: Bool, tension: Float, fightDirection: FishFightController.FightDirection? β semuanya diberikan dari luar, tidak ada state lokal. Empat konstanta ambang privat (dangerLow = 0.15, dangerHigh = 0.85, safeLow = 0.3, safeHigh = 0.7) mendefinisikan zona warna.
Terhubung ke: Dibuat instance-nya oleh ContentView, diberi makan dari fishFight.hasFishOnHook/lineTension/fightDirection. Merujuk langsung FishFightController.FightDirection (satu-satunya file View yang mengimpor tipe dari lapisan Gameplay, bukan cuma Shared).
Catatan menarik: Komentar dokumentasi menjelaskan prinsip desain inti secara langsung: "Both ends of the bar are a failure... The safe zone in the middle is shaded so the player can see it, not just infer it from color" β pilihan desain aksesibilitas/kejelasan yang eksplisit. Konstanta safeLow/safeHigh/dangerLow/dangerHigh-nya dirujuk secara angka (bukan berbagi kode) dari komentar tuning FishFightConfig sendiri, yang secara eksplisit bilang ambangnya sendiri "dipilih supaya cocok dengan ambang LineTensionView sendiri... walau keduanya tidak berbagi kode, cuma sejajar secara angka."
21. Views/MainMenuView.swift
Menu utama / layar judul World β titik masuk sebelum Connect β Calibrate β Ready. Sengaja tidak punya tombol mulai/selesai sesi sendiri; dia cuma pernah MEREFLEKSIKAN status, karena tombol fisik "Start Fishing"/"End Fishing" milik Rod sendiri adalah satu-satunya kontrol batas sesi.
Tipe yang didefinisikan: MainMenuView (SwiftUI View).
Fungsi-fungsi penting:
private var isRodReady: Boolβ benar kalauconnectionStateadalah.readyatau.streaming.private var statusText: Stringβ switch atas setiap kasusConnectionStatemenghasilkan baris status yang mudah dibaca manusia ("Searching for Rod...", "Press 'Start Fishing' on Rod to play", "Rod disconnected", "Connection failed", dll).var body: some Viewβ latar hitam, judul game ("Catch the Scott" / "A Two-Device Fishing Experience"), lalu entahCalibrationGuidanceView(kalau Rod terhubung tapi belum dikalibrasi) atau baris status (centang kalau siap, kalau tidak spinner) denganstatusText; tombol "Retry Connection" (cuma ditampilkan selagi belum siap); dan blok bawah dengan "How to Play" (membuka ulang onboarding), baris perangkat yang dipasangkan, dan "Reset Progress" (bergaya destruktif).private var pairedDeviceRow: some View(@ViewBuilder) β cuma ditampilkan kalauhasPairedDevice: menampilkan Rod mana yang dipasangkan dengan perangkat ini dan tombol "Forget Device".
Catatan tambahan: Semua input adalah nilai let yang diberikan dari luar (connectionState, pairedDeviceName, hasPairedDevice, calibrationStep) plus lima closure (onForgetDevice, onResetProgression, onRetryConnection, onShowOnboarding) β sepenuhnya tanpa-state/dikendalikan, cocok dengan pola setiap file View lain di aplikasi ini.
Terhubung ke: Dibuat instance-nya oleh ContentView, disambungkan langsung ke properti/metode WorldViewModel yang cocok. Bergantung ke ConnectionState/CalibrationStep dari Shared, dan langsung menyematkan CalibrationGuidanceView.
Catatan menarik: Komentarnya eksplisit dan tegas soal aturan desain "tidak ada tombol kontrol sesi di sini," mengutip langsung dokumen arsitektur dan menjelaskan persis dua kontrol apa yang MEMANG dimiliki layar ini (pemasangan perangkat, reset progresi) dan kenapa keduanya tidak berhubungan dengan aturan batas-sesi.
22. Views/OnboardingView.swift
Tutorial pertama kali main β walkthrough tiga halaman sederhana yang bisa digeser, menjelaskan apa itu game ini, tujuannya, dan cara kerja sistem level/tier. Juga bisa diakses lagi nanti kapan saja lewat "How to Play" di Main Menu.
Tipe yang didefinisikan:
OnboardingView(SwiftUIView).OnboardingView.Page(private struct):systemImage,title,bodyβ isi satu halaman tutorial.
Fungsi-fungsi penting:
var body: some Viewβ latar hitam; ikon, judul, dan teks isi halaman saat ini; titik-titik indikator halaman; tombol Back/Next (atau "Play" di halaman terakhir). Dilapiskan (bukan ditumpuk diZStackyang sama, khusus supaya tidak mewarisi alignment yang juga akan salah memposisikan konten utama yang ditengahkan β dijelaskan di komentar) dengan tombol "Skip Intro" di kanan atas.private var isLastPage: BoolβpageIndex == pages.count - 1.private var pageDots: some Viewβ baris lingkaran kecil, satu per halaman, terisi untuk halaman saat ini.
Catatan tambahan: @State private var pageIndex = 0 β satu-satunya state yang bisa berubah; let pages: [Page] β tiga halaman tutorial yang dikodekan langsung (intro game, "Your Goal" termasuk mencari Scott, dan "Levels & Fish Tiers" yang menuliskan ambang tangkapan 3/5/10/1 persis yang cocok dengan angka sebenarnya di ProgressionStore).
Terhubung ke: Ditampilkan oleh ContentView kapan pun viewModel.screen == .onboarding; closure onComplete-nya adalah viewModel.completeOnboarding.
Catatan menarik: Sengaja TIDAK menyertakan instruksi kalibrasi β komentar dokumentasi menjelaskan ini sengaja diserahkan ke CalibrationGuidanceView yang terpisah dan bisa dipakai ulang, jadi teks panduan yang persis sama juga mencakup kalibrasi ulang nanti, bukan cuma satu kali pertama ini.
23. Views/ResultView.swift
Kartu hasil pasca-tangkapan β nama spesies, berat/panjang, rating bintang, payout koin, dan (kalau berlaku) lencana "NEW RECORD". Memblokir scene sampai pemain menekan "Start Casting" di Rod lagi.
Tipe yang didefinisikan: ResultView (SwiftUI View).
Fungsi-fungsi penting:
var body: some Viewβ latar redup pluscarddi tengah.private var card: some Viewβ judul "Catch!", nama spesies, baris statistik berat/panjang, baris lima bintang, label payout koin, lencana "NEW RECORD" opsional, dan baris instruksi ("Press 'Start Casting' on Rod to fish again").private var starsRow: some Viewβ lima ikon bintang, terisi sampairesult.stars.private func stat(_:_:) -> some Viewβ kolom label+nilai kecil (dipakai baik untuk berat maupun panjang).
Catatan tambahan: let result: FishCatchResult β satu-satunya input; tidak ada state lokal.
Terhubung ke: Ditampilkan oleh ContentView kapan pun hookPhase == .result dan cutscene sudah selesai dan fishFight.lastCatchResult ada. Bergantung ke Gameplay/FishCatchResult.swift.
Catatan menarik: Komentar dokumentasi secara eksplisit menjelaskan KENAPA sengaja tidak ada tombol tutup dan tombol "Return to Menu" di sini β kedua aksi itu sekarang murni tanggung jawab Rod (memasang lemparan baru otomatis menutup ini lewat WorldViewModel.handle(_:) yang bereaksi terhadap RodState.isCastingArmed; mengakhiri sesi cuma lewat tombol "End Fishing" milik Rod), dan tombol dari sisi World yang melakukan salah satunya cuma akan diam-diam ditimpa balik oleh sinkronisasi state berikutnya dari Rod.
Alur Data Satu Putaran Permainan
Bagian ini melacak satu sesi main lengkap lewat kode sungguhan, dari aplikasi dibuka sampai melihat hasil ikan yang tertangkap, menyebutkan fungsi/tipe nyata di tiap langkah.
1. Aplikasi dibuka.
WorldApp (titik masuk @main) membuka satu WindowGroup berisi ContentView(). ContentView.init() membangun TransportHost dan memberikannya ke WorldViewModel baru. Di WorldViewModel.init(transport:), screen diatur ke .onboarding atau .mainMenu tergantung apakah UserDefaults sudah punya world.hasSeenOnboarding (Self.hasSeenOnboarding), dan closure transport.onStateChange/onMessage disambungkan untuk memperbarui connectionState dan meneruskan pesan Bluetooth masuk ke handle(_:). ContentView.body's content membangun RealityView RealityKit; pada closure make-nya, viewModel.makeSceneEntity() (async) membangun seluruh scene 3D sekali lewat EnvironmentSceneBuilder.build() (danau, dermaga, langit, matahari, pohon/batu/foliase tersebar, shader air dari WaterMaterial.apply), kamera pemain (makePlayerCamera), rig joran (RodRigController.load()), bola kail placeholder, dan entity ikan showcase "tertangkap." viewModel.attachFrameLoop(to:) berlangganan ke SceneEvents.Update RealityKit, jadi tiap frame yang dirender memanggil WorldViewModel.advance(deltaTime:) β tick(deltaTime:) dari sini seterusnya. .onAppear milik ContentView.content memanggil viewModel.start(), yang memanggil transport.connect() secara asinkron β TransportHost mulai memindai Rod lewat Bluetooth.
2. Onboarding (cuma main pertama kali).
Kalau screen == .onboarding, ContentView menampilkan OnboardingView, walkthrough tiga halaman (intro game, teaser tujuan/Scott, ambang unlock tier). Menekan "Play" di halaman terakhir memanggil viewModel.completeOnboarding(), yang menyimpan world.hasSeenOnboarding = true dan mengatur screen = .mainMenu.
3. Menghubungkan dan mengkalibrasi.
MainMenuView ditampilkan selagi screen == .mainMenu, merefleksikan connectionState (lewat callback CBCentralManagerDelegate/CBPeripheralDelegate milik TransportHost yang menggerakkan onStateChange) β menampilkan "Searching for Rod...", lalu "Connecting...", lalu (begitu layanan/characteristic BLE sudah sepenuhnya berlangganan di TransportHost.peripheral(_:didUpdateNotificationStateFor:...)) .ready. Kalau joran belum dikalibrasi (RodState.calibrationStep != .complete, di-stream terus-menerus dari Rod di dalam tiap snapshot RodState), MainMenuView menampilkan CalibrationGuidanceView yang hidup dan reaktif-langkah alih-alih baris status normal, melacak progres kalibrasi Rod sendiri (pose idle β gerakan lempar β gerakan reel β selesai) secara real-time.
4. Memulai sesi.
Rod adalah satu-satunya otoritas kapan sesi dimulai (keputusan desain terdokumentasi, "ADR-038"): pemain menekan "Start Fishing" di aplikasi Rod, yang mengatur RodState.isFishing = true dan menstreamnya lewat BLE. TransportHost mendecodenya sebagai NetworkMessage .state(RodState) dan memanggil onMessage, yang diteruskan ke WorldViewModel.handle(_:). Di sana, state.isFishing yang naik dari false ke true (diperiksa terhadap rodState.isFishing sebelumnya) mengatur screen = .playing dan memanggil sounds.handleScreenChange(isPlaying: true). ContentView sekarang menampilkan HUD lengkap (LineTensionView, GroupBox jumlah-tangkapan/level, CastMeterView) alih-alih menu.
5. Melempar.
Saat pemain benar-benar mengayunkan ponselnya, penerjemah gerakan Rod sendiri menghitung RodMotionPhase (backswing β charging β forwardSwing β release β followThrough) dan, saat rilis, sebuah CastSolution (arah/kekuatan/sudut luncur), di-stream di dalam RodState. CastMeterView merender bar kekuatan pengisian yang hidup dari rodState.castPower/castHoldDuration. Ketika WorldViewModel.handle(_:) melihat state.action == .casting dengan castSolution yang segar, dia menyimpannya sebagai pendingCastSolution. Panggilan tick(deltaTime:) berikutnya, selagi hookPhase == .idle, melihat aksi dan fase yang menunggu itu lalu memanggil launchHook(solution:): ini menurunkan arah bidik sebenarnya dari posisi ujung rig joran yang hidup (bukan CastSolution.direction mentah, yang didokumentasikan sebagai memakai konvensi mesh-placeholder yang sudah basi), menerapkan koreksi 180Β° yang ditemukan secara empiris, membatasinya ke kerucut depan selebar 135Β°, menghitung kecepatan luncur dari kekuatan lemparan, mengatur hookVelocity, memutar suara lempar (sounds.castLaunched()), dan berpindah hookPhase ke .flying. Tiap tick berikutnya selagi .flying, simulateFlyingHook(deltaTime:) mengintegrasikan gravitasi dan posisi sampai kail melintasi bidang air, di titik itu langsung menempel ke permukaan, menghitung jarak lemparan sebenarnya yang ditempuh, memanggil fishFight.resetForNewCast(castDistance:) (yang juga menentukan castZone yang membatasi ukuran ikan apa yang bisa terundi nanti), berpindah ke .hookInWater, dan mengeluarkan .hookSplash (memutar suara cebur dan mengirim event ke Rod).
6. Menunggu gigitan.
Selagi hookPhase == .hookInWater, tiap tick memanggil fishFight.updateSearching(deltaTime:). Sebuah timer acak pertama-tama mengubah isFishInterested = true (WorldViewModel bereaksi dengan mengeluarkan .fishInterested, yang memulai loop suara "tertarik" halus di World dan, di Rod, denyut haptic periodik pelan) β bob visual kail juga dapat dip "gigitan kecil" yang lebih tajam ke bawah lewat updateBobberFloat. Timer acak kedua lalu menyelesaikan gigitan sesungguhnya: FishCatalog.randomSpecies(unlockedTiers:) (dibatasi ProgressionStore.unlockedTiers, yaitu level pemain saat ini) memilih spesies, FishFightController mengundi berat/panjangnya (dibatasi castZone tempat lemparan jatuh, sesuai sizeRollWindow), mengatur hasFishOnHook = true dan memulai sub-fase juking kiri/kanan, lalu mengembalikan .bite. WorldViewModel bereaksi dengan berpindah hookPhase ke .fishOnHook, memanggil ensureFightDistance() (supaya pemain yang sudah sedang reel tidak langsung dapat tangkapan instan), dan mengeluarkan .fishBite.
7. Perkelahian.
Selagi hookPhase adalah .fishOnHook/.reeling, tiap tick: kalau pemain sedang reel (sesuai rodState.action == .reeling), reelHook(deltaTime:) menggerakkan kail menuju joran dengan kecepatan yang diturunkan dari fishFight.reelPullSpeed (kurva risiko/hadiah yang diskalakan tegangan β lebih cepat mendekati zona bahaya-putus) dikurangi fishFight.fishPullSpeed; kalau tidak reel, driftHookAway(deltaTime:) membiarkan ikan secara visual menyeret kail lebih jauh. updateFightDirectionDrift melapiskan dorongan samping visual yang cocok dengan fightDirection juking saat ini. fishFight.updateFight(deltaTime:isReeling:reelSpeed:hookDistance:rodRoll:) adalah resolver gameplay sesungguhnya: dia memeriksa kondisi escape-jarak dulu, lalu memperbarui lineTension dari tarikan reel, laju tarikan ikan, dan apakah pemain melawan arah juking dengan benar lewat roll joran (dibaca dari RodState.roll). Kalau tegangan menyentuh 0, ikan kabur; di 1, senar putus β kedua kasus itu WorldViewModel.resolveFightFailure(_:) mematikan kail, mereset hookPhase ke .idle, dan mengeluarkan event yang cocok (.fishEscaped/.lineBreak), lalu pemain harus melempar dari awal lagi. Sepanjang perkelahian, LineTensionView menampilkan bar tegangan yang hidup dengan zona aman diarsir, dan syncTensionBand() mengeluarkan .reelTensionLow/Medium/High ke Rod kapan pun fishFight.tensionBand melewati ambang, menggerakkan ritme haptic Rod sendiri.
8. Mendaratkan tangkapan.
Kalau pemain berhasil menarik kail sampai ke hookLiftTargetPosition (dalam jarak hookLiftDistance) selagi ikan masih terkait, reelHook memanggil fishFight.resolveCatch() (menambah caughtCount, menyelesaikan bintang/koin/rekor-baru lewat FishRecordStore, dan mungkin naik level lewat ProgressionStore.recordCatch(tier:) β kalau ya, FishFightController.levelUpAnnouncement diatur, nanti ditampilkan LevelUpBannerView), lalu beginCatchCutscene(from:), dan mengeluarkan .catchFish. hookPhase diatur ke .result.
9. Cutscene tangkapan.
Selagi isShowingCatchCutscene bernilai true, ContentView menampilkan CatchCutsceneView (bar letterbox, cahaya, judul "CATCH!" beranimasi pegas) di atas scene RealityKit, dan menyembunyikan blok statistik HUD rutin. Di belakangnya, WorldViewModel.updateCatchCutscene(deltaTime:) menggerakkan entity ikan showcase (mekong_catfish_caught, diskalakan sesuai panjang tangkapan yang sungguhan terundi) lewat lompatan naik β beku β turun, menerapkan dip "bullet time" slow-motion selama fase beku/turun (catchCutsceneTimeDilation) yang juga menyasar ulang kecepatan playback klip animasi baked ikan supaya puncak dramatis yang terautentikasi jatuh tepat di titik tengah fase beku. Kamera (updateCameraFollow) dolly perlahan mendekati ikan/kail sepanjang waktu itu. Begitu durasi total cutscene habis, semuanya dimatikan dan isShowingCatchCutscene berbalik jadi false.
10. Hasil / showcase.
Dengan cutscene selesai dan hookPhase == .result, ContentView menampilkan ResultView, membaca fishFight.lastCatchResult (spesies, berat, panjang, rating bintang, koin, flag rekor-baru). Tidak ada tombol tutup di kartu ini secara sengaja β pemain harus menekan "Start Casting" di Rod lagi, yang mengatur RodState.isCastingArmed = true; WorldViewModel.handle(_:) melihat ini dan memanggil continueFishing(), yang (karena hookPhase == .result) memanggil setHookPhase(.idle), menutup hasil dan mengembalikan alur ke langkah 5 (melempar) untuk ikan berikutnya.
11. Mengakhiri sesi.
Kapan pun, pemain bisa menekan "End Fishing" di Rod (mengatur RodState.isFishing = false) atau link Bluetooth bisa putus. Kedua cara itu membuat WorldViewModel memanggil returnToMenu(): setiap entity dimatikan, state perkelahian/cutscene direset penuh, hookPhase kembali ke .idle, dan screen kembali ke .mainMenu β mendarat kembali di langkah 3, siap terhubung ulang/kalibrasi ulang/mulai lagi tanpa memuat ulang scene 3D dari awal.