Bab 5: Dokumen Inti & Arsitektur Aplikasi

Repo aset (tempat kita membuat model ikan, danau, dan joran di Blender) hanyalah setengah cerita. Setengah lainnya ada di repo terpisah, ~/repos/c4/CtSPoC, yang berisi aplikasi game mancing bernama Catch the Scott (disingkat CtS/CtSPoC). Aplikasi ini bukan aplikasi AR β€” kamera perangkat tidak pernah dipakai. RealityKit merender dunia 3D sepenuhnya virtual: pemain berdiri di dermaga kayu, tinggi mata 1,6 m, memancing di danau buatan yang dalam.

Aplikasinya terdiri dari dua program terpisah yang saling terhubung lewat Bluetooth:

  • World β€” berjalan di iPad/Mac, merender seluruh dunia game lewat RealityKit (danau, ikan, dermaga, HUD, menu). Ini adalah "game" itu sendiri.
  • Rod β€” aplikasi iPhone terpisah yang berfungsi sebagai joran fisik. Pemain memegang iPhone seperti joran sungguhan; gerakan tilt/lempar dibaca lewat CoreMotion, lalu dikirim ke World lewat Bluetooth (CoreBluetooth).

Prinsip inti yang diulang di hampir semua dokumen proyek ini: "Rod is an input device. World is the game." β€” Rod hanyalah alat input fisik, World adalah keseluruhan game. Bab ini merangkum lima dokumen inti proyek CtSPoC: AGENTS.md, CLAUDE.md, GAMEPLAY_ARCHITECTURE_V1.md, WINDOW_2.md, dan SOFTWARE_ARCHITECTURE_DOCUMENT.md (disingkat SAD). Kita perlu memahami dokumen-dokumen ini karena kitalah yang menyediakan aset (ikan, dermaga, joran, danau) yang dipakai oleh World β€” jadi kita harus tahu bagaimana aset itu benar-benar dipakai, digerakkan, dan ditampilkan di sisi aplikasi.

Aturan & Panduan Proyek (AGENTS.md / CLAUDE.md)

Ada dua dokumen "aturan main" untuk siapa pun (manusia atau agen AI) yang mengerjakan repo CtSPoC: AGENTS.md (425 baris) dan CLAUDE.md (150 baris). Keduanya sangat mirip isinya β€” CLAUDE.md adalah versi ringkas/lebih operasional dari AGENTS.md, dan dipakai sebagai bacaan pembuka sesi kerja agen AI. Karena isinya tumpang tindih, kita bahas sekaligus di sini, sambil menandai bagian yang benar-benar unik di masing-masing.

Rujukan ke repo aset ini

Kedua dokumen menegaskan: aset 3D yang dipakai World (file .usdz di World/World/Assets/Environment/ dan aset ikan) dibuat di repo sibling ini β€” ~/repos/new-blender-project. Ekspor .usdz dari repo Blender ini adalah sumber kebenaran; salinan di repo aplikasi harus tetap identik byte-per-byte (cek dengan shasum di kedua sisi setelah ada perubahan). Kalau ada aset yang tampil salah secara visual dan tidak ada penjelasan dari kode render/material di sisi aplikasi, langkah pertama adalah curigai file .usdz-nya sendiri (buka dengan unzip + usdcat) sebelum menuduh bug di kode aplikasi β€” contoh kasus nyata: bug "Sky Dome Flat/Gray" yang dicatat di CHECKPOINT.md.

Tujuan proyek & aturan yang sudah usang

AGENTS.md awalnya ditulis saat proyek masih tahap "kerangka spasial" (World merender scene, Rod jadi kontroler fisik, Nearby Interaction/NI untuk kesadaran ruang relatif, CoreMotion untuk orientasi perangkat, CoreHaptics untuk getaran β€” direncanakan menyusul). Ada larangan tegas saat itu: jangan implementasikan gameplay memancing sebelum diminta secara eksplisit. Aturan ini sudah dinyatakan usang oleh dokumennya sendiri β€” gameplay sudah selesai dibangun, detailnya ada di GAMEPLAY_ARCHITECTURE_V1.md dan SAD. Roadmap sprint asli (Sprint 0-8, dari beres-beres proyek sampai gameplay memancing) juga sudah lama selesai dan dipindah ke Archive/COMPLETED_SPRINT_ROADMAP.md; status kerja saat ini ada di CHECKPOINT.md (riwayat kronologis) dan ARCHITECTURE_DECISIONS.md (log ADR/Architecture Decision Record).

Aturan arsitektur & dependensi modul

Workspace bernama CtSPoC.xcworkspace berisi tiga target: World, Rod, Shared. Aturan dependensi: World -> Shared dan Rod -> Shared. World dan Rod tidak boleh saling bergantung langsung β€” ini aturan keras yang diulang di semua dokumen yang dibaca.

Pembagian tanggung jawab folder:

  • Shared: hanya kode yang tidak bergantung platform. Boleh pakai Foundation dan Swift standard library saja. Isinya: Models, Protocols, Utilities. Tidak boleh mengimpor SwiftUI, RealityKit, ARKit, NearbyInteraction, CoreMotion, CoreHaptics, AppKit, atau UIKit.
  • World: RealityKit, state dunia game, sesi spasial, UI debug. Tidak boleh mengakses CoreMotion secara langsung.
  • Rod: CoreMotion, Nearby Interaction, Haptics, state kontroler. Tidak boleh berisi logika rendering RealityKit.

Prinsip desain & gaya kode

Arsitektur memakai pola MVVM (Model-View-ViewModel); mengutamakan komposisi dibanding pewarisan (composition over inheritance); prinsip Single Responsibility (satu class satu tanggung jawab); desain berbasis protocol; lebih suka async/await; hindari state global; hindari singleton kecuali benar-benar perlu secara teknis.

RodState adalah satu-satunya sumber kebenaran yang mewakili state kontroler. Kode rendering dan gameplay membaca dari RodState; tidak boleh ada state kontroler duplikat di tempat lain.

Konvensi penamaan yang dipakai di seluruh proyek:

  • Manager: MotionManager, NearbyInteractionManager, TransportHost, TransportClient.
  • ViewModel: WorldViewModel, RodViewModel.
  • Model: RodState, MotionPacket, SpatialState.
  • Protocol: selalu diberi akhiran Protocol (contoh: TransportProtocol).

Aturan threading: UI dan ViewModel harus jalan di @MainActor; Manager pakai isolasi actor hanya kalau perlu; tidak boleh memblokir main thread. Gaya penulisan kode: utamakan guard dan early return, fungsi kecil, struct immutable; hindari nesting dalam, hindari force unwrap (!), hindari ViewModel/class raksasa. Batas ukuran file yang disarankan: ViewModel di bawah 300 baris, Manager di bawah 400 baris, View di bawah 250 baris β€” kalau lebih besar, harus di-refactor.

Framework yang boleh dipakai: SwiftUI, RealityKit, NearbyInteraction, CoreMotion, CoreHaptics, Foundation β€” selalu pakai API terbaru dari Apple, hindari API yang sudah deprecated. Alur kerja pengembangan: satu sprint dikerjakan tuntas dulu sebelum lanjut; tiap sprint wajib bisa di-build dan dijalankan dengan sukses, serta bisa diuji secara independen; tidak boleh lompat milestone. Aturan Pull Request: setiap PR wajib bisa dikompilasi, punya deskripsi jelas, menyertakan catatan pengujian, dan tidak berisi refactor yang tidak relevan β€” PR harus fokus. "Definition of Done": build tanpa error, tidak menambah warning baru, arsitektur tetap konsisten, kode mengikuti konvensi proyek, siap untuk sprint berikutnya.

Aturan perilaku untuk agen AI: hormati arsitektur yang sudah ada; lebih baik memperluas komponen yang sudah ada daripada bikin baru; pakai ulang model dari Shared; tanya dulu kalau ragu soal keputusan arsitektur, jangan menebak; commit kecil dan bertahap.

Kontrak Kalibrasi & Orientasi

Ini bagian yang lebih baru dan sangat mengikat (load-bearing). Kalibrasi dimiliki sepenuhnya oleh lapisan motion Rod dan model Shared. World sama sekali tidak boleh tahu soal state kalibrasi dan tidak boleh menerapkan offset kalibrasi sendiri. Alur wajib:

CoreMotion -> MotionManager -> referensi kalibrasi -> RodOrientation terkoreksi -> RodState -> Transport -> World

Aturan detailnya: MotionPose hanya berisi pitch/roll/yaw mentah. RodOrientation adalah kontrak quaternion ternormalisasi yang menghadap ke renderer. Kalibrasi menangkap dan menyimpan quaternion referensi posisi diam (Idle). Setiap frame runtime menerapkan rumus inverse(reference) * current di sisi Rod/Shared. RodState.orientation adalah satu-satunya sumber orientasi yang dipakai renderer. Field Euler (pitch/roll/yaw mentah) boleh tetap ada untuk kebutuhan gameplay relatif/diagnostik, tapi rotasi World wajib memakai orientation, bukan Euler. Preview kalibrasi dan runtime harus memakai pipeline terkoreksi yang sama. World dilarang mengimpor/memanggil API kalibrasi, menyimpan state kalibrasi, atau merekonstruksi orientasi terkoreksi dari nilai Euler. Gameplay boleh mengklasifikasi gerakan relatif setelah dikoreksi, tapi tidak boleh mengubah orientasi renderer.

Kontrak State Casting (Lempar Kail) & Transform

Pengisian daya gestur (gesture charging) dimiliki oleh pipeline motion Rod/Shared. Interpreter mempublikasikan RodMotionPhase saat ini dan castPower ternormalisasi ke RodState; saat pelepasan (release), dihasilkan CastSnapshot yang immutable, yang kemudian menjadi CastSolution otoritatif lewat CastResolver.

World memiliki HUD lempar (cast HUD), merender daya secara live dari RodState.castPower. RodState.castSolution adalah satu-satunya hasil lemparan lintas-proses β€” World tidak boleh menghitung ulang hasil lemparan sendiri dari data sensor.

Field RodState.directionX/Y/Z/distance (dari Nearby Interaction) tetap ada sebagai data spasial murni, dikirim untuk kemungkinan pemakaian di masa depan. Sejak ADR-041, tidak ada kode gameplay atau rendering yang membacanya β€” posisi akar joran (rod root) yang sebenarnya berasal dari offset tetap relatif ke kamera (ADR-019), bukan dari ranging NI yang live. Jangan menghidupkan kembali penentuan posisi joran berbasis NI tanpa ADR baru.

Arah/daya/jarak lemparan tidak boleh menimpa posisi atau orientasi spasial joran. Akar joran tetap diam relatif terhadap pose kontroler; entitas kail (hook) yang memiliki lintasan/pergerakan lemparan. Fungsi WorldViewModel.updateRodPose() hanya boleh memperbarui offset kontroler tetap dan RodState.orientation β€” tidak boleh membaca jarak atau arah lemparan.

Kontrak Cast Resolver

RodMotionInterpreter mendeteksi dan mengambil snapshot lemparan; tidak boleh menghitung jarak, fisika, atau balancing gameplay. CastResolver hanya menerima CastSnapshot yang immutable dan menghasilkan CastSolution (arah, daya ternormalisasi, sudut peluncuran). Kode gameplay dan fisika World tidak boleh membaca CoreMotion atau kecepatan sudut mentah secara langsung. Daya dinormalisasi lewat CastConfig; kecepatan sudut mentah tidak boleh pernah melintasi batas motion-ke-gameplay sebagai nilai gameplay.

Kontrak Gameplay Architecture V1 (ringkasan di AGENTS.md)

Bagian ini adalah versi ringkas dari seluruh isi GAMEPLAY_ARCHITECTURE_V1.md (dibahas lengkap di sub-bab berikutnya). Intinya: World memiliki seluruh pengalaman yang dilihat pemain β€” menu, tutorial, instruksi kalibrasi, HUD memancing, simulasi kail dan ikan, hasil tangkapan, progression, dan nantinya inventory/toko/koleksi. Rod tetap jadi kontroler fisik minimal: input gerak, penangkapan kalibrasi, niat lempar/gulung (cast/reel intent), tombol, status koneksi, output haptic. Rod dilarang menampilkan HUD gameplay, info ikan, tutorial, atau hasil tangkapan β€” World satu-satunya pemilik state/visual/feedback gameplay.

Mesin state gameplay World: Connect -> Calibration -> Ready -> Cast -> Hook Flying -> Splash -> Searching -> Interested -> Bite -> Fight -> Catch/Escape -> Result -> Continue/Menu. Cast Meter dan Line Tension Meter adalah komponen HUD World yang membaca state shared/gameplay, tidak pernah membaca CoreMotion langsung. Pemilihan ikan bersifat data-driven lewat tabel biome, tipe air, perlengkapan, umpan, kesulitan, dan probabilitas. Resistensi ikan diekspresikan lewat ketegangan senar (line tension) dan haptic di Rod, bukan health bar generik atau quick-time-event (QTE). Haptic di Rod hanyalah feedback output β€” World yang memutuskan event gameplay dan mengirim kontrak feedback/event bertipe ke Rod.

Catatan penting: tugas yang sifatnya dokumentasi-saja tidak boleh mengubah kode Swift, file project, atau struktur runtime β€” tetap harus dicatat di CHECKPOINT.md. Untuk pertanyaan soal perilaku produk (bukan detail teknis), GAMEPLAY_ARCHITECTURE_V1.md adalah sumber kebenaran.

Isi unik CLAUDE.md: struktur folder, tech stack, dan alur kerja sesi

CLAUDE.md adalah bacaan pembuka untuk sesi kerja agen AI β€” dibaca bersama AGENTS.md sebelum mulai kerja. CHECKPOINT.md adalah riwayat lengkap dan wajib menerima entri baru bertanggal untuk setiap tugas, seperti commit git ringan. Entri checkpoint lama tidak boleh dihapus. Dokumentasi arsitektur/desain terkait juga harus selalu diperbarui.

Struktur folder proyek (pohon beranotasi):

CtSPoC/
β”œβ”€β”€ AGENTS.md / CHECKPOINT.md
β”œβ”€β”€ ARCHITECTURE_DECISIONS.md
β”œβ”€β”€ SOFTWARE_ARCHITECTURE_DOCUMENT.md
β”œβ”€β”€ GAMEPLAY_ARCHITECTURE_V1.md
β”œβ”€β”€ Archive/          # dokumen usang, disimpan untuk sejarah β€” bukan state terkini
β”œβ”€β”€ CtSPoC.xcworkspace/
β”œβ”€β”€ Shared/
β”‚   └── Sources/Shared/
β”‚       β”œβ”€β”€ Models/      # RodState, CastSolution, HookPhase, FishSpecies, kontrak jaringan
β”‚       β”œβ”€β”€ Gameplay/    # MotionProfile, RodMotionInterpreter, CastResolver
β”‚       β”œβ”€β”€ Debug/       # keputusan motion, sampel, recorder
β”‚       β”œβ”€β”€ Protocols/   # Transport protocol
β”‚       └── Spatial/     # konfigurasi sesi
β”œβ”€β”€ Rod/Rod/
β”‚   β”œβ”€β”€ ViewModel/RodViewModel.swift
β”‚   β”œβ”€β”€ Sensors/         # adapter CoreMotion dan Nearby Interaction
β”‚   β”œβ”€β”€ Transport/       # client CoreBluetooth (Rod = BLE peripheral)
β”‚   β”œβ”€β”€ Haptics/         # Core Haptics
β”‚   └── Views/           # hanya view kontroler/debug
└── World/World/
    β”œβ”€β”€ ViewModels/WorldViewModel.swift
    β”œβ”€β”€ Gameplay/        # FishFightController, FishCatalog, FishCatchResult, FishRecordStore
    β”œβ”€β”€ Audio/           # SoundManager, FishingSounds (ADR-040) β€” SFX khusus World
    β”œβ”€β”€ Transport/       # host CoreBluetooth (World = BLE central)
    β”œβ”€β”€ Views/CastMeterView.swift, LineTensionView.swift, ResultView.swift
    └── Assets/Rod.usdz, Assets/SFX/ (ui_click.wav, cast_throw.wav, hook_splash.wav,
        fish_interested_loop.wav, fish_bite.wav, reel_loop.wav, catch_splash.wav,
        ambience_lake.mp3, idle.wav, reel_fight.wav)

Kepemilikan tiap framework secara eksplisit:

  • Swift/Xcode: implementasi dan target aplikasi.
  • SwiftUI: kontrol Rod, layout World, HUD.
  • CoreMotion: hanya milik Rod β€” sikap/akselerasi/kecepatan sudut perangkat.
  • Nearby Interaction: hanya milik Rod β€” jarak/arah spasial. Sesinya hidup dan datanya tetap dikirim, tapi sejak ADR-041 tidak ada kode gameplay/render yang membacanya β€” aspek "kontroler spasial" belum jadi hal penting di fase ini; CoreBluetooth saja sudah cukup untuk kebutuhan gameplay V1.
  • CoreBluetooth: penemuan (discovery) dan transport state Codable antara Rod/World (Rod = BLE peripheral, World = BLE central; menggantikan MultipeerConnectivity, sesuai ADR-035).
  • RealityKit: hanya milik World β€” render joran, kail, air, scene.
  • Core Haptics: hanya milik Rod, lewat HapticManager/FishingHaptics.
  • AVFoundation: hanya milik World, untuk SFX lewat SoundManager/FishingSounds (ADR-040). Rod tidak punya pemutaran audio.
  • Package Swift lokal Shared: hanya kontrak Foundation/Swift dan terjemahan motion/gameplay. Tidak boleh mengimpor UI, rendering, CoreMotion, NI, atau haptics di Shared.

Diagram alur data versi CLAUDE.md: CoreMotion -> MotionManager -> RodMotionInterpreter -> CastSnapshot -> CastResolver -> CastSolution -> RodState -> transport CoreBluetooth -> WorldViewModel -> RealityKit/HUD.

Fase casting tanpa tombol: Idle -> Backswing -> Charging -> ForwardSwing -> Release -> FollowThrough. RodState.castPower hidup (live) selama pengisian daya dan dibekukan saat rilis. RodState.castSolution adalah state lemparan selesai yang bertahan sampai ada lemparan baru atau reset HookPhase.idle.

Aturan kerja sesi (session workflow), tujuh langkah:

  1. Periksa git status dan baca dokumen relevan sebelum mengedit.
  2. Batasi perubahan sesuai lingkup; jangan sentuh perubahan pengguna lain yang tidak terkait.
  3. Pakai apply_patch untuk edit manual; jangan pernah pakai perintah git yang destruktif.
  4. Perbarui bagian dokumentasi yang sesuai untuk setiap perubahan implementasi.
  5. Tambahkan entri bertanggal seperti commit ke CHECKPOINT.md (lingkup, file, alur, verifikasi, keterbatasan yang tersisa).
  6. Jalankan git diff --check dan kompilasi Shared secara terarah sebelum serah terima.
  7. Laporkan secara eksplisit celah verifikasi di perangkat/runtime nyata.

Blok perintah verifikasi yang diberikan apa adanya (fakta konkret, patut dicatat persis):

cd Shared
swiftc -parse-as-library -target arm64-apple-macosx14.0 \
  -emit-module -module-name SharedMotion \
  Sources/Shared/Models/Rod/RodAction.swift \
  Sources/Shared/Models/Spatial/SpatialState.swift \
  Sources/Shared/Models/Gameplay/HookPhase.swift \
  Sources/Shared/Models/Rod/RodState.swift \
  Sources/Shared/Debug/MotionDebugDecision.swift \
  Sources/Shared/Gameplay/MotionProfile.swift \
  Sources/Shared/Gameplay/CastResolver.swift \
  Sources/Shared/Gameplay/RodMotionInterpreter.swift \
  -o /tmp/SharedMotion.swiftmodule
git diff --check

Verifikasi build/run Xcode secara penuh bisa saja terblokir oleh SourcePackages lokal, plugin Observation, simulator, atau ketersediaan signing/hardware β€” artinya batas verifikasi nyata di lingkungan ini hanyalah sebatas type-check, bukan build/run sungguhan. Hal ini juga dikonfirmasi secara independen oleh WINDOW_2.md.

Catatan silang untuk kedua dokumen ini

CLAUDE.md menduplikasi hampir kata-per-kata kontrak Kalibrasi/Casting/Cast-Resolver dari AGENTS.md, hanya dipadatkan jadi prosa mengalir dibanding format aturan berpoin di AGENTS.md β€” sumber sama, dua presentasi. CLAUDE.md juga mengkonfirmasi ADR-035 (MultipeerConnectivity β†’ CoreBluetooth), ADR-041 (NI tidak lagi krusial), ADR-019 (akar joran dilepas dari NI), dan ADR-018 (pengisian daya gestur terpisah dari transform joran) β€” semua ini dibahas jauh lebih lengkap di SAD. WINDOW_2.md mengonfirmasi bahwa CLAUDE.md pernah punya "bug staleness" nyata: dulu masih menyebut transport sebagai MultipeerConnectivity, padahal sudah lama diganti CoreBluetooth (ADR-035) β€” bukti bahwa dokumen ini bisa kadaluarsa dan perlu dicek ulang berkala.

Arsitektur Gameplay (GAMEPLAY_ARCHITECTURE_V1.md)

Dokumen ini berjudul "Version 1.0 (Vision Draft)", bertanggal 2026-07-18, dan secara eksplisit berstatus "Product and architecture contract" (kontrak produk dan arsitektur). Isinya menjawab "seperti apa rasanya game ini seharusnya, dan apa tugas masing-masing perangkat, dari awal sampai akhir" β€” dokumen visi produk/UX sekaligus batas kepemilikan antara World dan Rod. Dokumen ini secara eksplisit menyatakan dirinya sebagai dokumen desain yang tidak dengan sendirinya mengizinkan pekerjaan implementasi β€” jadi ini jawaban "apa/kenapa", sedangkan dokumen lain (ADR, CHECKPOINT) adalah "bagaimana/kapan dibangun".

Visi

Catch the Scott adalah pengalaman memancing dua-perangkat. World (Mac, iPad, dan nanti Vision) adalah keseluruhan pengalaman game; Rod (iPhone) adalah kontroler memancing fisik. Pemain seharusnya hampir tidak pernah melihat layar iPhone selama bermain β€” Rod harus terasa seperti joran sungguhan, bukan layar game kedua.

Prinsip Inti (diulang persis di setiap dokumen dalam kumpulan ini): "Rod is an input device. World is the game." β€” setiap fitur di masa depan wajib menjaga prinsip ini.

Pembagian tanggung jawab perangkat

World memiliki: Menu Utama, Tutorial, Instruksi Kalibrasi, HUD Gameplay, Scene Memancing, AI Ikan, Simulasi Kail, Layar Hasil Tangkapan, Progression, serta sistem masa depan (inventory/toko/koleksi). Rod memiliki (sengaja dibuat minimal): input gerak, penangkapan kalibrasi, niat lempar dan gulung, input tombol, output feedback haptic, status koneksi. Rod dilarang menampilkan HUD gameplay, info ikan, konten tutorial, progression, atau hasil tangkapan.

Antarmuka Rod V1

Rancangan layar kontroler akhir yang dituju (dibedakan tegas dari tooling debug saat ini):

Catch the Scott
CONNECTED
[ Start Fishing ]
[ Start Casting ]
[ Calibrate ]
[ End Fishing ]

Tombol "Start Casting" (ADR-050) hanya muncul saat sedang memancing dan mengaktifkan (arm) satu percobaan lempar pada satu waktu β€” casting tidak selalu "hidup" dari gerakan ponsel biasa; tombol ini dimatikan setiap kali kail tidak dalam keadaan idle. Nilai-nilai debug yang ada saat ini secara eksplisit disebut sebagai alat pengembangan sementara, bukan bagian dari pengalaman kontroler final.

Filosofi UI World: perhatian pemain harus tertuju pada World β€” panduan tutorial/kalibrasi, cast meter, feedback pencarian/ketertarikan ikan, feedback gigitan/perlawanan, ketegangan senar, hasil tangkapan, pilihan lanjut/progression. Rod tetap tenang secara visual, hanya berkomunikasi lewat haptic.

Alur Gameplay (Gameplay Loop)

Alur state kanonik (sama dengan versi ringkas di AGENTS.md):

Launch -> Connect Rod -> Calibration -> Ready -> Start Fishing -> Cast -> Hook Flying ->
Hook Splash -> Searching -> Fish Interested -> Bite -> Fight -> Catch atau Escape ->
Catch Cutscene (hanya saat tangkap) -> Result -> Continue -> Cast Again

Detail per fase:

  • Connect: World menunggu sampai Rod terhubung; pemain tidak bisa lanjut tanpa kontroler.
  • Calibration: instruksi seluruhnya muncul di World; Rod hanya menangkap orientasi referensi dan mengirim perintah/hasil kalibrasi. Alurnya: "Pegang joranmu secara alami β†’ Tekan Calibrate β†’ Bagus! β†’ Lakukan satu lemparan β†’ Kalibrasi Selesai."
  • Ready: World masuk mode memancing idle, menampilkan HUD gameplay; pemain boleh mulai memancing.
  • Cast: gerakan alami (jarak ayunan mundur/backswing, lalu sentakan maju), bukan meter berbasis tombol β€” tapi pemain harus menekan "Start Casting" dulu untuk mengaktifkan satu percobaan, supaya gerakan ponsel biasa tidak salah terbaca sebagai lemparan yang tidak disengaja. World menampilkan Cast Meter yang digerakkan motion (daya hasil resolusi dari sistem motion Rod) begitu diaktifkan; meter memudar setelah peluncuran.
  • Hook Flying dan Splash: kail bergerak memakai arah peluncuran, sudut, dan daya lemparan; RodRoot tidak ikut bergerak. Feedback splash milik World; Rod mungkin dapat konfirmasi haptic kecil.
  • Searching: kail tidak langsung dapat ikan β€” World menjalankan fase pencarian/pemilihan ikan, dengan opsi riak air, gelembung, bayangan ikan.
  • Fish Interested: ikan di dekatnya jadi tertarik tapi belum menggigit; World menampilkan gerakan halus, Rod memberi getaran periodik pelan.
  • Bite: event tersendiri β€” World memberi splash kuat/animasi senar/feedback ketegangan, Rod memberi pulsa haptic kuat.

Perlawanan Ikan (Fish Fight)

Ini interaksi gameplay inti β€” secara eksplisit disebut "bukan quick-time event", menggabungkan gerakan fisik joran, menggulung (reeling), ketegangan senar, dan feedback haptic.

  • Line Tension (ketegangan senar) menggantikan health bar generik: pemain menjaga ketegangan tetap di zona aman; ketegangan yang terlalu tinggi atau terlalu rendah dalam waktu lama membuat ikan lolos, dan bisa membuat senar putus.
  • Haptic mengomunikasikan resistensi lewat ritme, bukan cuma intensitas: ikan lemah = ketukan berjarak jarang; ikan sedang = ketukan teratur; ikan besar = ketukan cepat berulang; resistensi ekstrem = getaran terus-menerus. World yang memutuskan event gameplay; Rod hanya merender feedback bertipe itu sebagai output haptic β€” Rod tidak memutuskan state ikan.

Catch Cutscene (Adegan Tangkapan)

Dokumen ini menjelaskan rancangan yang dituju; SAD nanti mendokumentasikan banyak iterasi pembangunannya terhadap spesifikasi ini. Input gulung terakhir yang berhasil menangkap ikan tidak langsung memotong ke layar Result β€” World memutar cutscene singkat: kamera memotong dekat ke ikan+pelampung, ikan melompat keluar dari air/ke dermaga, menahan diri/membeku di puncak lompatan sejenak (pilihan desain yang disengaja β€” lompat-lalu-jatuh instan terasa terlalu cepat untuk terbaca sebagai momen cutscene sungguhan), lalu mereda, sambil judul "CATCH!" muncul. Cutscene berakhir sendiri setelah durasi tetap yang singkat β€” tidak ada input pemain yang bisa membatalkannya.

Rancangan menuntut ikan animasi sungguhan (bukan bola placeholder), diskalakan sesuai panjang tangkapan yang benar-benar di-roll, memegang pose vertikal tetap (menggantung kepala di atas seolah tergantung di kail, menyamping ke kamera supaya siluet penuh terbaca), animasi meronta yang diputar lebih cepat dari tempo aslinya supaya terasa seperti benar-benar melawan. Kamera mendorong maju perlahan sepanjang cutscene (bukan shot statis); bar letterbox masuk dari atas/bawah; glow lembut membingkai judul; HUD gameplay rutin memudar selama durasi ini.

Rancangan juga menuntut efek slow-motion sungguhan di titik tengah jeda (sekitar 1/3 kecepatan normal) dengan animasi meronta ikan melambat/mempercepat serentak dengannya, plus jeda slow-motion kedua tepat saat cutscene berakhir sebelum menetap kembali ke air. Target total panjang cutscene: tetap 5 detik waktu nyata, dari tangkap sampai layar hasil. Kail harus tetap terlihat menempel di mulut ikan sepanjang waktu, berukuran sesuai ikan, dengan senar yang terlihat membentang ke joran yang dipegang (bukan mengambang bebas).

Hasil Tangkapan & Lanjut

Setelah cutscene, World menampilkan: nama ikan, berat, panjang, bintang, koin, status rekor baru. Rod memutar haptic perayaan. World lalu menawarkan pilihan Lanjut Memancing atau Kembali ke Menu.

Sistem Ikan & Progression

Setiap spesies memiliki data statis: Species -> Weight Range -> Length Range -> Resistance Range -> Value -> Difficulty. Setiap tangkapan membuat instance ikan teracak (dua ikan spesies sama bisa beda berat/panjang). Progression didasarkan pada perlengkapan (kail, umpan, senar, reel); perlengkapan tier lebih tinggi membuka kolam ikan lebih besar dan meningkatkan peluang tangkap. Pemilihan ikan bersifat data-driven: Biome -> Water Type -> Equipment -> Bait -> Difficulty -> Probability Table -> Selected Fish.

Batas Arsitektur

Rod: Motion Input -> RodMotionInterpreter -> Cast Resolver -> Transport
World: Transport -> Game State -> Gameplay Systems -> HUD -> Fish AI -> Hook Physics ->
       Visual Feedback -> Rendering

Rod memiliki gerakan fisik, input tombol, output haptic. World memiliki gameplay, simulasi, visual, tutorial, progression, feedback pemain. Pemisahan ini tidak boleh dilanggar. Sistem gameplay wajib memakai kontrak shared, bukan membaca CoreMotion atau detail implementasi Rod lainnya secara langsung. Akar kontroler World tetap menempel secara spasial ke pemain; hanya kail dan objek senar masa depan yang bergerak melintasi scene.

Kriteria Sukses V1

  • Rod berhasil terhubung.
  • Pemain mengkalibrasi secara alami.
  • Pemain melakukan lemparan fisik.
  • Kail mendarat di air.
  • Pemilihan ikan dimulai.
  • Ikan bisa menjadi tertarik dan menggigit.
  • Pemain menggulung memakai gerakan fisik.
  • Ketegangan senar menciptakan gameplay yang bermakna.
  • Haptic dinamis mengomunikasikan resistensi ikan.
  • Ikan tertangkap atau lolos.
  • World menampilkan layar hasil.
  • Pemain bisa langsung memulai sesi memancing baru.

Tujuan jangka panjang: perhatian pemain harus tetap di World; Rod harus "menghilang" ke alam bawah sadar pemain sebagai kontroler fisik, sehingga pemain merasa sedang memegang joran sungguhan sementara dunia game terbentang di depan mereka.

Catatan silang

Dokumen ini adalah yang paling sering dikutip dari seluruh set β€” AGENTS.md dan CLAUDE.md sama-sama mengulang Prinsip Inti dan sebagian besar daftar tanggung jawab perangkat nyaris kata-per-kata. Seluruh bagian SAD berjudul "Fish Species and Fight Simulation", "Catch Cutscene", "Fish Fight Tuning Config", dan "Fish Tiers and Progression" adalah riwayat implementasi nyata dari sistem yang dispesifikasikan dokumen ini pada level visi β€” dibaca bersama, dokumen ini memberi "kenapa/rasa yang dituju", sementara SAD memberi "apa yang benar-benar dibangun dan setiap penyesuaian di sepanjang jalan". Beberapa angka konkret di sini (misalnya "cutscene tetap 5 detik") cocok persis dengan entri SAD yang menjelaskan bagaimana angka itu diturunkan/disetel (ADR-075 memakai perhitungan integral untuk menyelesaikan durasi settle agar tepat 5,0 detik) β€” bukti bahwa spesifikasi dokumen visi ini benar-benar dipenuhi secara presisi, bukan cuma didekati. Gerbang "Start Casting" yang dijelaskan sebagai rancangan di sini adalah ADR-050 di SAD/WINDOW_2 β€” sudah dibangun. Tidak ada bagian dari dokumen ini yang ditandai usang oleh dokumen itu sendiri; per tanggal dokumen-dokumen lain yang paling baru (sampai ADR-076 di SAD), dokumen ini masih dianggap kontrak produk yang berlaku.

Catatan Serah Terima Sesi (WINDOW_2.md)

WINDOW_2.md (185 baris) adalah dokumen serah terima sesi β€” sengaja ditulis karena jendela konteks sesi coding sebelumnya sudah mulai panjang, supaya sesi/agen berikutnya bisa langsung paham situasi dalam sekali baca, tanpa harus menelusuri ulang seluruh log. Dokumen ini secara eksplisit bukan pengganti CHECKPOINT.md (riwayat permanen lengkap) atau ARCHITECTURE_DECISIONS.md (teks ADR lengkap) β€” ini ringkasan navigasi di atas keduanya. Ini adalah dokumen paling baru secara waktu dari kelima dokumen yang dibaca, dan memberi narasi paling jelas soal "apa yang baru saja terjadi dan apa yang masih rapuh/belum teruji".

Peringatan status git

Saat dokumen ditulis, branch dev dan origin/dev sama-sama di commit 68d3f4d ("feat: current update progress") β€” sudah ter-push, aman. Tapi working tree saat itu punya banyak perubahan belum ter-commit di atasnya (ADR-056, ADR-057, dan satu koreksi hasil uji langsung terhadap ADR-057), menyentuh file: ARCHITECTURE_DECISIONS.md, CHECKPOINT.md, .xcuserstate, Rod/Rod/Sensors/MotionManager.swift, Rod/Rod/ViewModel/RodViewModel.swift, SOFTWARE_ARCHITECTURE_DOCUMENT.md, Shared/.../CastResolver.swift, Shared/.../RodMotionInterpreter.swift, Shared/.../RodState.swift, World/World/ContentView.swift, World/World/Views/CastMeterView.swift. Dokumen ini secara eksplisit memperingatkan sesi baru mana pun untuk tidak mempercayai snapshot ini tanpa mengecek ulang git status sendiri, karena waktu mungkin sudah berlalu.

Ada juga "kejutan git": HEAD pernah ditemukan dalam keadaan detached di commit yang sama dengan yang ditunjuk dev β€” kemungkinan disebabkan oleh alat eksternal (panel Source Control Xcode dicurigai, bukan sesi ini). Diselesaikan dengan aman lewat git checkout dev tanpa efek samping. Ditandai sebagai sesuatu yang "mungkin terjadi lagi". Ada juga lima entri git stash (stash@{0} sampai stash@{5}) yang bukan milik sesi ini, campuran dari sebelum/selama window ini; hanya satu yang disentuh (dihapus, karena murni noise .xcuserstate) β€” instruksi eksplisit untuk tidak mengutak-atik sisanya karena bukan tanggung jawab sesi ini.

Ringkasan kronologis kejadian di window ini

  1. Membaca seluruh set dokumen (CLAUDE.md, AGENTS.md, CHECKPOINT.md, ARCHITECTURE_DECISIONS.md, SOFTWARE_ARCHITECTURE_DOCUMENT.md, SPATIAL_CONTROLLER_DOCUMENT.md) dan menilai mana yang sudah usang.
  2. Beres-beres dokumentasi (sudah ter-commit di riwayat dev): mengarsipkan SPATIAL_CONTROLLER_DOCUMENT.md dan WINDOW_1.md ke Archive/; mengeluarkan roadmap Sprint 0-8 yang sudah lama mati dari AGENTS.md/SOFTWARE_ARCHITECTURE_DOCUMENT.md; memperbaiki bug staleness nyata di CLAUDE.md (masih menyebut transport sebagai MultipeerConnectivity, padahal sudah lama diganti CoreBluetooth per ADR-035).
  3. Audit Nearby Interaction (ADR-041): dikonfirmasi lewat audit kode langsung (bukan baca dokumen) bahwa NI sudah terpasang penuh (Rod memiliki sesinya, per ADR-014) tapi tidak dibaca oleh kode gameplay/rendering mana pun β€” hanya tampilan debug. Kode dibiarkan apa adanya; dokumen yang sudah melenceng mengklaim sebaliknya diperbaiki.
  4. Perbaikan gerakan reel-in (ADR-042, ADR-043): kail sekarang tetap di ketinggian air sampai dekat dermaga, bukan naik diagonal begitu proses menggulung dimulai; lanjutan (ditemukan lewat branch paralel teman satu tim, feat/playtest-refine, di-merge) memindahkan target angkat menjauh dari jejak dermaga yang benar-benar terukur, supaya pelampung tidak lagi menembus papan dermaga.
  5. Merge nyata dengan pekerjaan paralel rekan tim. origin/main sudah bergerak lewat feat/playtest-refine (perbaikan pendekatan dermaga + catch cutscene, ADR-044 sampai 048) yang secara independen memakai nomor ADR yang sama dengan empat fitur tak-terkait yang dibangun di window ini. Diselesaikan dengan menggabungkan origin/main ke dev dan menomori ulang empat ADR window ini dari 044-047 menjadi 049-052 (mempertahankan 044-048 milik rekan tim sebagai kanonis di main) β€” setiap rujukan silang di keempat dokumen diperbarui agar cocok. Nomor ADR tertinggi saat ini adalah 057 (plus dua amandemen "koreksi hasil uji langsung" di tempat, ke ADR-054 dan ADR-057). Peringatan eksplisit: cek entri teratas ARCHITECTURE_DECISIONS.md sendiri sebelum menambah ADR baru β€” tabrakan ini sudah pernah terjadi sekali.
  6. Empat fitur dari satu permintaan ("3 jobs"): allowlist pemasangan perangkat (ADR-049), gerbang "Start Casting" yang eksplisit (ADR-050), perbaikan rig joran β€” smoothing lengkungan, putaran reel placeholder, perbaikan nyata untuk bug line-follow yang sudah lama ada (ADR-051), dan animasi idle mengambang/mengayun untuk pelampung (ADR-052).
  7. Mekanik arah perlawanan ikan (ADR-053): ikan yang sudah tersangkut kini menyentak kiri/kanan dengan timer acak 5-10 detik per perlawanan; pemain harus melawan dengan memutar joran ke arah berlawanan (RodState.roll) atau menerima penalti ketegangan. Ditampilkan di LineTensionView milik World (Rod tetap tidak punya HUD gameplay).
  8. Perombakan daya lemparan, tiga tahap berturut-turut:
    • ADR-054: mengganti daya lemparan dari pengukuran jarak tarik-mundur (ADR-032) menjadi timer durasi-tahan ("tahan lebih lama = daya lebih besar"). Juga menambah intensitas semua pola haptic Rod, meringankan kesulitan ikan tier 3/4 (fish_c/ scott), dan menambah tombol "Reset Progress" (ADR-055).
    • Koreksi hasil uji langsung untuk ADR-054 (hari yang sama): timer tahan-reset saat ada satu frame flicker klasifikasi yang noise, diam-diam membuang muatan yang sudah terisi β€” diperbaiki dengan hanya memulai timer saat benar-benar belum ter-set, bukan setiap kali frame berganti klasifikasi jadi .cast.
    • ADR-056: menghapus sepenuhnya bonus kecepatan-sentak (flick-speed bonus) dari daya lemparan β€” daya sekarang murni fungsi dari durasi menahan; sentakan hanya jadi pemicu rilis. Membersihkan rantai angularVelocity/laju-rotasi-per-sumbu yang sudah sepenuhnya mati sampai ke MotionManager. Juga ditemukan bahwa titik panggilan CastMeterView ternyata di-comment-out di World/ContentView.swift selama ini β€” diaktifkan kembali, ditambah pembacaan timer live "Holding X.Xs" di sebelah bar daya.
    • ADR-057: mengubah ramp durasi-tahan menjadi mini-game timing yang berosilasi terus-menerus (power = (1 - cos(phase)) / 2, phase digerakkan oleh durasi tahan) β€” "meteran naik turun perlahan", gaya meter ayunan golf. Sentakan mengunci apa pun nilai osilasi persis di saat itu.
    • Koreksi hasil uji langsung untuk ADR-057 (hari yang sama): mengaktifkan kembali (re-arm) sesaat setelah rilis bisa terbaca seolah "daya sudah maksimal begitu tombol ditekan" β€” akar masalahnya adalah timer cooldown lokal Rod pasca-rilis yang memutar ulang daya beku dari lemparan sebelumnya, terlepas dari pengaktifan baru. Diperbaiki dengan membuat pengaktifan baru selalu membatalkan cooldown yang masih tersisa. Juga memperlambat periode osilasi dari 2,0 detik menjadi 3,5 detik sesuai feedback yang sama.

Tabel ADR lengkap di window ini (juga berguna sebagai indeks cepat)

#ApaStatus
ADR-041NI dikonfirmasi ada tapi tidak krusial untuk gameplay V1Perbaikan dokumen saja
ADR-042Reel-in tetap di ketinggian air sampai dekat dermagaKode
ADR-043Target angkat kail dipindah menjauh dari jejak terukur dermagaKode
ADR-044–048Lift kecepatan-terjamin dekat-dermaga + catch cutsceneBukan pekerjaan sesi ini β€” branch feat/playtest-refine rekan tim, di-merge
ADR-049Allowlist pemasangan perangkat lewat CoreBluetoothKode
ADR-050Percobaan lempar butuh pengaktifan eksplisit "Start Casting"Kode
ADR-051Perbaikan rig joran: smoothing lengkungan, reel spin placeholder, perbaikan line-followKode
ADR-052Pelampung mengambang: idle bob + "interest dip" sebelum gigitanKode
ADR-053Arah perlawanan: sentakan kiri/kanan berwaktu, penangkal roll joranKode
ADR-054Timer tahan lemparan (+ koreksi), haptic lebih kuat, tier 3/4 diringankanKode
ADR-055Tombol Reset ProgressKode
ADR-056Daya lemparan menghapus sentakan sepenuhnya; timer live tampil di World; CastMeterView diaktifkan lagiKode
ADR-057Daya lemparan jadi mini-game timing osilasi (+ koreksi)Kode

Apa arti "terverifikasi" di window ini

Ini catatan metodologi yang penting (tidak berubah dari serah terima window sebelumnya, Archive/WINDOW_1.md): setiap entri diverifikasi lewat swiftc -typecheck terhadap modul Shared yang dibangun manual (baik modul arm64-apple-macosx26.0 maupun arm64-apple-ios26.0-simulator, dibangun langsung dari find Sources/Shared -name "*.swift"), lalu seluruh daftar file World/World/Rod/Rod di-typecheck terhadap modul itu. git diff --check dijalankan setelah setiap entri. Tidak ada xcodebuild, tidak ada simulator, tidak ada perangkat sungguhan, untuk apa pun di window ini. xcodebuild tetap terblokir di lingkungan ini karena sertifikat signing yang hilang. Pekerjaan timer-lempar/meter-osilasi khususnya sudah butuh dua putaran "dilaporkan buggy setelah dimainkan, dicari akar masalahnya, diperbaiki" β€” setiap konstanta angka di window ini (periode osilasi, batas durasi-tahan, intensitas haptic, timer/threshold arah perlawanan, dua nilai resistensi yang diringankan) harus dianggap sebagai tebakan tahap pertama, bukan nilai yang sudah final.

Isu yang diketahui, dibawa dari sebelumnya atau baru ditandai

  • Tabrakan penomoran ADR-030/031 β€” SUDAH SELESAI 2026-07-21: pasangan yang muncul akibat merge (camera authoring, mode pengiriman transport) dinomori ulang jadi ADR-058/059; pasangan progression-gameplay (Resistance/Cast Distance) tetap 030/031.
  • Nilai resistensi tier di FishCatalog.swift terus bergeser dari apa yang terakhir diset sesi ini. Nilai saat dokumen ditulis: fish_a 0,35, fish_b 0,50, fish_c 0,65, scott 0,70 β€” cek file itu langsung, jangan percaya angka di ADR mana pun, karena sudah disetel ulang manual lebih dari sekali di luar proses ADR resmi.
  • DisclosureGroup "Developer Details" di Rod sengaja di-comment-out di Rod/Rod/ContentView.swift (dengan satu baris trailing-whitespace nyasar di sekitar baris 60 yang masih ditandai git diff --check) β€” sudah ada sebelum sesi ini, dibiarkan sesuai kebiasaan proyek untuk tidak menyentuh state yang terlihat seperti pekerjaan orang lain yang masih berjalan.
  • Belum ada satu pun yang benar-benar dimainkan di perangkat sungguhan di window ini β€” disebut sebagai "fakta paling penting bagi siapa pun yang melanjutkan pekerjaan ini".

Saran langkah berikutnya (urutan prioritas)

  1. Commit dan push pekerjaan yang belum ter-commit (ADR-056/057 + kedua koreksinya) kalau belum β€” cek git status dulu.
  2. Mainkan gamenya β€” khususnya mekanik lemparan (paling banyak berubah: tiga ADR, dua koreksi hasil uji langsung dalam satu window). Periode osilasi 3,5 detik dan keseluruhan rasa "mini-game timing" belum teruji.
  3. Kalau mekanik arah perlawanan (ADR-053) dimainkan: threshold roll (fightDirectionRollThreshold, 0,15 radian) dan besaran relief/penalti masih murni tebakan β€” cek apakah melawan tarikan ikan terasa achievable/rewarding.
  4. Tabrakan ADR-030/031 β€” sudah selesai (lihat isu yang diketahui).
  5. Kriteria penerimaan Wi-Fi-off milik MIGRATION_transport_to_corebluetooth.md (dari ADR-035) belum pernah dicek, menurut catatan setiap window sebelumnya β€” layak dilakukan sebelum mempercayai lapisan transport lebih jauh dari sekadar "type-check-nya lolos".

Catatan silang untuk WINDOW_2.md

Dokumen ini adalah indeks meta di atas bagian-bagian terbaru SAD sendiri β€” hampir setiap nomor ADR yang disebutkannya (041 sampai 057, plus 058/059/061-076 lewat cakupan SAD) didokumentasikan secara independen dan jauh lebih dalam secara teknis di SOFTWARE_ARCHITECTURE_DOCUMENT.md. Untuk keperluan buku ini, WINDOW_2.md paling cocok dipakai sebagai sumber "narasi kronologis / apa yang terjadi dan urutannya", sementara SAD adalah sumber "cara kerjanya persis seperti apa". WINDOW_2.md secara eksplisit menyebut dirinya menggantikan sebagian Archive/WINDOW_1.md (yang masih dibutuhkan hanya untuk riwayat ADR-030 sampai ADR-039). Item pemasangan perangkat (ADR-049), gerbang casting (ADR-050), rig joran (ADR-051), pelampung mengambang (ADR-052), arah perlawanan (ADR-053), dan daya lemparan (ADR-054/056/057) juga semuanya didokumentasikan secara teknis penuh di bagian SAD "CoreBluetooth Transport", "Cast Resolver Pipeline", "Rod Rig Visual Fixes", "Floating Bobber", dan paragraf arah-perlawanan di dalam "Fish Species and Fight Simulation" β€” perlakukan ringkasan WINDOW_2 soal item-item ini sebagai penunjuk, bukan sumber utama. Tidak ada bagian WINDOW_2.md sendiri yang ditandai usang oleh dokumennya β€” ini artefak paling baru dalam kumpulan ini (merujuk kejadian sampai 2026-07-21, cocok dengan tanggal sesi saat ini). Namun perlu dicatat: SAD merujuk nomor ADR sampai ADR-076, jauh lebih tinggi dari yang dibahas WINDOW_2.md (sampai sekitar ADR-057/058/059) β€” artinya WINDOW_2.md, meski secara nama adalah "handoff" terbaru, sebenarnya sudah agak ketinggalan dibanding SAD dan tidak boleh dianggap sebagai narasi paling mutakhir.

Dokumen Arsitektur Software (SAD)

SOFTWARE_ARCHITECTURE_DOCUMENT.md (1505 baris, dibaca penuh dalam 3 bagian) adalah referensi arsitektur paling detail dan paling hidup (living document) di repo ini. Isinya menjawab "bagaimana setiap sistem benar-benar bekerja secara konkret, dan apa riwayat setiap penyesuaian/perbaikan bug yang membentuknya". Dokumen ini dimulai sebagai kerangka arsitektur yang cukup generik (visi, komponen, alur data, threading, mesin state, aturan kode β€” sebagian besar tumpang tindih dengan AGENTS.md/CLAUDE.md di level tinggi), lalu kira-kira dari titik tengah dokumen dan seterusnya, berubah jadi log pembangunan yang sangat padat, ADR-demi-ADR, untuk sistem gameplay yang sesungguhnya (lempar, kalibrasi, perlawanan ikan, cutscene, transport, menu, onboarding, progression) β€” secara efektif jadi lampiran teknis gabungan dari visi GAMEPLAY_ARCHITECTURE_V1.md dan log ADR ARCHITECTURE_DECISIONS.md, dalam bentuk prosa.

Bagian A β€” Kerangka arsitektur dasar (baris 1-234, sebagian besar tumpang tindih dengan AGENTS.md/CLAUDE.md)

Visi sama dengan AGENTS.md: World (macOS) menjadi host RealityKit; Rod (iPhone) adalah kontroler fisik; NI untuk kesadaran spasial relatif; CoreMotion untuk orientasi; CoreHaptics menyusul. Frasa "Gameplay is intentionally excluded from Phase 1" sudah historis/usang (gameplay sekarang sudah dibangun). Diagram arsitektur: Shared (Models/Protocols/Utilities) dengan World dan Rod masing-masing bergantung padanya; World/Rod tidak pernah saling bergantung.

Komponen: Shared (RodState, MotionPacket, SpatialState, protocol Transport, helper Codable; tidak pernah mengimpor RealityKit/ CoreMotion/SwiftUI). World (rendering RealityKit, simulasi dunia, sesi NI, menerima RodState, overlay debug, efek suara per ADR-040; folder Audio/Reality/Spatial/ Transport/ViewModels/Views). Rod (CoreMotion, sesi NI, menghasilkan RodState, overlay debug, haptics di masa depan; folder Motion/Spatial/Transport/ Haptics/Views).

Alur data dasar: CoreMotion -> MotionManager -> RodState -> Transport -> World -> RealityKit; NI memperbarui RodState secara independen.

Threading: UI/RealityKit di MainActor; callback CoreMotion masuk lewat manager terisolasi; Transport pakai async/await; delegate NI masuk ke ViewModel. Mesin state (generik, versi asli): Idle -> Searching -> Connecting -> Session Ready -> Streaming -> Disconnected (lalu ulang) β€” ini mesin state level rendah untuk transport/koneksi, berbeda dari mesin state gameplay yang jauh lebih kaya di GAMEPLAY_ARCHITECTURE_V1.md.

Tanggung jawab class: Rod (MotionManager, NearbyInteractionManager, TransportClient, RodViewModel); World (NearbyInteractionManager, TransportHost, WorldViewModel, RealityScene); Shared (RodState, MotionPacket, TransportProtocol). Penanganan error: harus pulih dari invalidasi sesi, peer terputus, update spasial hilang, motion tidak tersedia β€” selalu coba sambung ulang. Aturan kode: MVVM saja; SRP; Shared hanya platform-independen; tidak ada singleton kecuali dibutuhkan; async/await diutamakan; desain protocol-first.

Rencana implementasi: mencatat bahwa roadmap Sprint 0-8 asli sudah lama selesai, dipindah ke Archive/COMPLETED_SPRINT_ROADMAP.md; proyek sekarang di tahap "Gameplay V1 refinement" berkelanjutan, state terkini ada di CHECKPOINT.md/ ARCHITECTURE_DECISIONS.md. Instruksi agen (10 aturan bernomor): satu sprint sekali jalan; setiap sprint kompilasi dulu sebelum lanjut; jangan pernah memasukkan gameplay sebelum Sprint 8 (sudah usang); jangan pernah melewati model Shared; pisahkan NI/Motion/RealityKit/Transport dalam modul terpisah; komposisi dibanding pewarisan; hanya API SDK terbaru Apple; class kecil dan mudah diuji; ViewModel bebas dari kode rendering; RodState adalah satu-satunya sumber kebenaran.

Bagian B β€” Pipeline Kalibrasi & Cast Resolver (baris 236-345)

Pipeline Orientasi Kalibrasi: Raw Device Orientation -> Calibration Reference -> Corrected RodOrientation -> RodState.orientation -> World (plus cabang -> Relative Motion Pose -> Gameplay). Rod menangkap/menyimpan referensi Idle, menerapkan inverse(reference) * current setiap frame, mengirim hasil terkoreksi lewat RodState.orientation. World hanya merendernya β€” tidak boleh menghitung kalibrasi, menerapkan offset, mengingat referensi Idle, atau memakai pose relatif untuk rotasi visual (kontrak sama seperti di AGENTS.md/CLAUDE.md, ini pengulangan ketiga).

Pipeline Cast Resolver: RodMotionInterpreter -> CastSnapshot -> RodState -> CastResolver -> CastSolution -> Gameplay/World Physics. CastSnapshot bersifat immutable: orientasi terkoreksi, timestamp, backswingHoldDuration (detik berapa lama pose backswing/pengisian daya ditahan sebelum rilis, di-reset saat gestur baru). CastResolver menghitung arah/sudut peluncuran/daya sebagai osilasi meter timing berkelanjutan (ADR-057), bukan ramp pengisian:

phase = backswingHoldDuration * 2Ο€ / oscillationPeriod
power = (1 - cos(phase)) / 2

Daya: 0 di awal, 1 di setengah periode, kembali ke 0 di periode penuh, berulang terus selama backswing ditahan β€” mini-game timing "kejar puncak" ("meteran naik turun perlahan"), menggantikan ramp monoton dari ADR-054/056. Sentakan (forwardMotion, sebuah pengecekan threshold-akselerasi di RodMotionInterpreter, tidak berubah sejak ADR-032) tetap yang memicu rilis β€” mengunci apa pun bacaan osilasi di saat itu, tidak pernah menskalakan hasilnya (sesuai ADR-056). oscillationPeriod = 3,5 detik (di CastConfig). RodMotionInterpreter melacak castHoldStartTimestamp (diset begitu lemparan diaktifkan dan pose pertama kali terbaca sebagai percobaan lempar) dan holdDuration, dikirim live sebagai RodState.castHoldDuration (bukan cuma castPower ternormalisasi) supaya World bisa menampilkan timer sungguhan di samping bar daya. castPower live milik interpreter menerapkan rumus yang identik secara lokal (disinkronkan dengan konstanta di CastConfig) supaya meter di layar selalu cocok dengan apa yang akan dihasilkan saat rilis. CastSnapshot.angularVelocity/batas kecepatan di CastConfig dan rotationRateX/Y/Z per-sumbu (pipa lama untuk bonus sentakan) dihapus sepenuhnya (ADR-056), dikonfirmasi lewat grep bahwa tidak ada lagi yang bergantung padanya.

Pengaktifan ulang mengalahkan freeze-frame basi (koreksi ADR-057): penekanan "Start Casting" yang baru me-reset castEndsAt ke waktu sekarang sebelum pengecekan early-return followThrough, supaya cooldown dari lemparan sebelumnya yang masih berjalan tidak bisa membocorkan releasedCastPower yang beku ke percobaan baru.

CastMeterView sekarang benar-benar ditampilkan (ADR-056): titik panggilannya di World/ContentView.swift sempat di-comment-out; view itu sendiri sudah benar (menurut temuan ADR-045) tapi tidak pernah masuk render tree. Sekarang diaktifkan kembali, dengan parameter holdDuration yang menampilkan "Holding X.Xs".

Pengaktifan eksplisit diwajibkan (ADR-050): RodMotionInterpreter.interpret(_:isCastingArmed:) memperlakukan pose yang terklasifikasi sebagai lemparan persis seperti .idle (tanpa pelacakan backswing, tanpa pengisian daya, tanpa meter) kecuali isCastingArmed bernilai true. RodViewModel.startCasting() milik Rod adalah satu-satunya setter, dijaga dengan kondisi isFishing && (hookPhase == .idle || hookPhase == .result) (cabang .result berasal dari ADR-061); dibersihkan saat lemparan sungguhan terpicu, saat hookPhase meninggalkan idle/result, atau saat endFishing() dipanggil.

Meter mulai tepat saat pengaktifan terjadi (ADR-061): sebelumnya logika pengisian/osilasi hanya berjalan di dalam case .cast: pada switch klasifikasi pose, sehingga menekan "Start Casting" sambil masih memegang ponsel dalam posisi idle terkalibrasi membuat meter macet di 0 sampai ponsel bergeser ke castPose β€” jeda yang tak terjelaskan. Logika pengisian saat diaktifkan sekarang berjalan tanpa syarat setiap kali isCastingArmed bernilai true, sebelum switch klasifikasi; switch hanya berlaku selagi belum diaktifkan.

Definisi selesai per sprint (diulang): build, jalan di perangkat target, tanpa warning, kode di-review untuk arsitektur, siap sprint berikutnya.

Bagian C β€” Pengisian Gestur, Pemisahan Kontroler/Kail, dan Aim (baris 345-440)

Pengisian Gestur dan Presentasi Lemparan: Rod mempublikasikan fase pengisian + daya ternormalisasi live di RodState, supaya World merender meter live tanpa membaca CoreMotion. Saat rilis, CastResolver mengubah snapshot jadi satu CastSolution otoritatif. Transform kontroler bersifat spasial-saja: jarak/arah NI memosisikan akar joran; arah/daya/jarak lemparan hanya menggerakkan lintasan kail. World menjaga akar joran tetap stabil selama lemparan, hanya menggerakkan entitas kail.

Pemisahan Kontroler dan Kail di World: WorldViewModel.updateRodPose() hanya urusan kontroler β€” menerapkan controllerOffset tetap relatif-kamera + RodState.orientation yang terkoreksi; tidak pernah memeriksa arah/jarak lemparan atau fase kail.

Data spasial NI dikonfirmasi tidak dipakai di mana pun (ADR-041): Rod tetap memiliki/menjalankan sesi NI (ADR-014), RodState.directionX/Y/Z/distance tetap mengirim tiap sampel, tapi audit kode penuh tidak menemukan satu pun konsumen gameplay/rendering/connection-gating β€” offset tetap milik updateRodPose() adalah satu-satunya sumber posisi joran yang sebenarnya. Ini akurat sampai ADR-018; ADR-019 dengan sengaja melepaskan akar joran dari data spasial live (memperbaiki joran yang terlihat bergerak saat lemparan), dan tidak ada yang sejak itu mengembalikan NI jadi peran krusial. Instalasinya tetap ada untuk fitur masa depan β€” V1 tidak pernah membutuhkannya; CoreBluetooth saja sudah membawa seluruh alur saat ini.

Peluncuran/simulasi kail memiliki seluruh terjemahan gameplay: mulai dari ujung joran, menerima kecepatan dari CastSolution, jatuh karena gravitasi, menggulung ke arah kontroler; akar joran tidak berubah sepanjang proses.

Arah peluncuran dari geometri rig live, bukan CastSolution.direction (ADR-033): CastResolver (di Shared) menghitung direction dengan memutar vektor referensi tetap yang tidak tahu-menahu soal mesh β€” Shared memang tidak tahu apa-apa soal rig yang dirender World, dan memang tidak boleh tahu. Rig World bisa punya (dan pernah punya, lewat sebuah merge integrasi aset) sumbu "ujung" lokal yang berbeda dari yang diasumsikan referensi itu, diam-diam membuat setiap lemparan salah arah dengan offset tetap. Perbaikan: WorldViewModel.launchHook(solution:) menurunkan arah peluncuran horizontal sendiri, secara live, dari tipEntity.position(relativeTo: nil) - controllerEntity.position saat peluncuran β€” benar secara konstruksi, tidak bergantung konvensi rest-pose rig mana pun. Hanya arah yang diturunkan ulang seperti ini; solution.power/launchAngle/ distance tetap dari CastSolution.

Arah peluncuran dibatasi ke kerucut depan (ADR-034): castConeHalfAngle = 67,5Β° (total kerucut 135Β°) dari arah depan kamera yang tetap saat peluncuran, diterapkan sebelum dipakai. Perhitungan peluncuran selalu memakai konstanta (0,0,-1) tanpa memandang orientasi kamera live, jadi atan2 yang memberi sudut bertanda dari lurus ke depan tetap aman walau kamera bergerak sesudahnya. Ini jaminan hasil yang disengaja, lepas dari benar-tidaknya penurunan aim β€” sebuah lemparan tidak akan pernah mendarat di belakang atau jauh ke samping pemain.

aimCorrectionAngle memutar aim mentah sebelum dibatasi (ADR-034): pengujian langsung menunjukkan aim mentah dari rig cenderung bias ke kiri dari arah yang sebenarnya dimaksud β€” setiap lemparan mendarat di luar kerucut dan terpaku di tepi kirinya (juga menjelaskan penurunan jarak setelah pembatasan diterapkan). Pembacaan debug khusus (debugRawAimDegrees/debugCorrectedAimDegrees) mengukur bias mentah sebenarnya sebesar -174Β° (hampir terbalik penuh, bukan seperempat putaran) β€” tebakan awal 90Β° tidak cukup. aimCorrectionAngle sekarang bernilai .pi (180Β°) β€” satu konstanta yang bisa disetel, masih empiris, dengan sisa bias kecil sekitar 6Β° yang menunggu pembacaan live berikutnya.

Kamera mengikuti kail: updateCameraFollow(), dipanggil setiap tick(), mengayunkan playerCamera mengikuti di belakang/atas kail dan melihat ke arahnya selama .flying/.fishOnHook/.reeling, lalu kembali ke pose pemain tetap (resetCameraToPlayerPose(_:)) di luar itu. Awalnya alat bantu debug khusus fase terbang; dipromosikan jadi perilaku penuh yang dilihat pemain selama lempar dan perlawanan oleh ADR-039 ("biar proses reel nya jadi lebih dramatis"). Tidak memengaruhi pembatasan arah peluncuran, karena itu dihitung sekali saat peluncuran sebelum kail/kamera bergerak.

Bagian D β€” Perbaikan Visual Rig Joran (ADR-051, baris 441-464)

World/World/Reality/RodRigController.swift menggerakkan pose tulang live tiap frame untuk rod_main.usdz/rod_line.usdz (keduanya dikirim hanya sebagai skeleton rest-pose). Tiga perbaikan:

  • Smoothing lengkungan: applyBend(pitch:roll:) (sekarang mutating) melewatkan pitch/roll mentah lewat low-pass filter (bendSmoothing = 0,25) sebelum menghitung sudut lengkung tiap sendi; rotasi utama seluruh rig (RodState.orientation, ADR-016) tidak disentuh β€” ini hanya menghaluskan lapisan lentur sekunder.
  • Putaran reel placeholder: rod_main.usdz sama sekali tidak punya geometri/sendi reel (dikonfirmasi dengan mengekstrak/membaca aset langsung). Placeholder bola pipih ditempel dekat sendi Handle, diputar lewat applyReelSpin(reelSpeed:isReeling:deltaTime:), dipanggil dari tick(deltaTime:) bersama applyLineTension. Reel yang benar-benar dimodel adalah tugas lanjutan di repo aset.
  • Perbaikan line-follow: applyLineTension(_:) sebelumnya memutar kelenturan senar mengelilingi sumbu-X lokal tetap, tapi akar senar mewarisi orientasi dunia penuh dari sendi Tip joran, sehingga di bawah kombinasi pitch+roll, sumbu kelenturan ikut berputar mengikuti roll ujung joran, membuat senar bercabang menjauh dari bidang lengkung joran (isu lama yang belum diperbaiki). Sekarang membaca orientasi dunia terkini dari ujung joran dan memutar sumbu kelenturan dengan inversnya lebih dulu, sehingga kelenturan tetap menunjuk arah dunia yang stabil tanpa memandang roll joran.

Bagian E β€” Rekap Arsitektur Gameplay V1 + evolusi Hook Phase (baris 466-503)

Mengulang kontrak produk (Connect→Calibration→Ready→Cast→...→Result→Continue/Menu) dan kerangka perlawanan "bukan QTE / bukan health bar", cocok dengan GAMEPLAY_ARCHITECTURE_V1.md.

Kail Menangkap Ikan tanpa Ikan Visual Dulu (ADR-023, menggantikan ADR-022): sebelum ikan benar-benar dirender, HookPhase (di Shared) dibuat untuk merefleksikan apakah ikan benar-benar tersangkut:

idle -> flying -> hookInWater -> fishOnHook -> reeling -> idle
       (searching)   (bite)      (fight)     (catch)

Pada ADR-023, WorldViewModel melacak keterikatan gigitan secara privat (biteTimer, hasFishOnHook) alih-alih mengkodekannya ke HookPhase β€” state itu sejak itu pindah ke FishFightController. fishBite/catchFish sudah ada di FishingEvent sebelum ADR-023 dan sudah ditangani oleh FishingHaptics.swift milik Rod, tapi World tidak pernah mengirimkannya sebelum ADR-023 β€” inilah pemakaian nyata pertamanya.

Bagian F β€” Spesies Ikan dan Simulasi Perlawanan (baris 505-947, bagian paling padat)

Rancangan dicatat lewat ADR-024, ADR-026, ADR-028 (menggantikan mekanisme ketegangan/pelolosan lebih awal dari ADR-024/ADR-027). FishFightController (di World, ObservableObject) memiliki seluruh simulasi gigitan/perlawanan/tangkapan; WorldViewModel tidak menyimpan state ini, hanya menggerakkannya lewat tick(deltaTime:):

.hookInWater -> fishFight.updateSearching(deltaTime:) -> true -> HookPhase .fishOnHook, kirim .fishBite
.fishOnHook/.reeling -> sedang reel? reelHook() : driftHookAway()
  -> fishFight.updateFight(...) -> .escaped -> kail dinonaktifkan, HookPhase .idle, kirim .fishEscaped (ADR-039)
                                 -> .lineBreak -> kail dinonaktifkan, HookPhase .idle, kirim .lineBreak
reelHook() mencapai joran (hasFishOnHook) -> resolveCatch(), kirim .catchFish, beginCatchCutscene(),
  HookPhase .result (ADR-045)
catch cutscene selesai -> kail dinonaktifkan, isShowingCatchCutscene=false, ResultView ditampilkan
splash baru -> fishFight.resetForNewCast()

WorldViewModel tetap memiliki transisi HookPhase, entitas kail, dan transport FishingEvent; FishFightController tidak tahu apa-apa soal transport/entitas/HookPhase.

Data spesies: FishSpecies.swift (di Shared, Codable, hanya Foundation): id, name, weightRange, lengthRange, resistance (0...1), value, tier (ADR-037); plus FishSpeciesRange (pasangan min/max, karena ClosedRange tidak otomatis mendukung Codable). Tidak ada referensi mesh/aset. World/Gameplay/FishCatalog.swift adalah daftar konten yang dimiliki World (all: [FishSpecies], satu per tier sejak ADR-037) dan fungsi pemilih randomSpecies(unlockedTiers:).

Zona aman ketegangan (tension safe zone) β€” ikan benar-benar melawan secara fisik: FishFightController.lineTension: Float (0...1), mulai dari initialTension (0,5) saat gigitan, diperbarui tiap tick di updateFight(deltaTime:isReeling:reelSpeed:hookDistance:):

reelPull = sedang reel ? reelSpeed * baseReelTensionRate(0,55) : 0
lineTension += (reelPull - effectiveFishPullRate) * deltaTime
effectiveFishPullRate = baseFishPullRate(0,15) * (1 + resistance * pullRateResistanceMultiplier(3,0))

hookDistance > fightStartDistance + effectiveEscapeAllowance -> .escaped (senar terlalu panjang, ADR-039)
effectiveEscapeAllowance = max(minEscapeDistanceAllowance(2m),
                                escapeDistanceAllowance(6m) - resistance * escapeAllowanceResistancePenalty(3,5))
lineTension <= 0 -> .escaped (senar kendur)
lineTension >= 1 -> .lineBreak (menggulung terlalu keras)
.escaped atau .lineBreak -> kail dinonaktifkan, HookPhase .idle (butuh lemparan baru β€” revisi ADR-039;
  sebelumnya hanya .lineBreak yang berlaku begini)

Semua konstanta penyetelan ini hidup di FishFightConfig (di Shared, baru β€” lihat detail di bawah). Tidak menggulung membuat ketegangan turun menuju 0 (ikan menang); menggulung menaikkannya; menggulung terlalu keras terlalu lama mendorongnya melewati 1. Ikan juga secara fisik menyeret kail: fishPullSpeed = basePullSpeed(0,2625) * (1 + resistance * pullSpeedResistanceMultiplier(1,8)), nol kecuali sedang tersangkut. basePullSpeed disetel ulang dua kali dari nilai awal 0,7: dipotong separuh jadi 0,35 ("fish pull speed rasanya terlalu kencang"), lalu dipotong lagi 25% jadi 0,2625 ("kurangin keduanya by 25%") β€” bagian dari catatan tambahan hasil uji langsung ADR-039. Dibaca oleh driftHookAway(deltaTime:) saat kail beristirahat, dan β€” bagian tarik-menarik ADR-039β€” dikurangkan dari kecepatan reel pemain saat sedang menggulung, sehingga kecepatan bersih negatif bisa menjauhkan kail bahkan di tengah proses menggulung.

Kecepatan reel-in adalah risiko/imbalan berskala ketegangan (penyetelan ulang ADR-039): sebelumnya konstanta WorldViewModel.reelSpeed tetap (3,0 -> 4,5 -> 3,375 lewat dua tahap). Permintaan langsung dari pengguna: "balance pull nya dengan cara: ketika hijau, 1 rotasi reel misalnya narik 0,5m/s, dan ketika ke merah reel distance nya naik jadi 1m/s gitu, jadi ada reward untuk player yang berani stay di red zone". FishFightController.reelPullSpeed sekarang menghitung kecepatan tarik per unit dari lineTension: reelPullSpeedSafe pada/di bawah reelSpeedSafeTension (0,7, cocok dengan tepi zona hijau di LineTensionView), diinterpolasi linear naik ke reelPullSpeedDanger begitu ketegangan mencapai reelSpeedDangerTension (0,85, cocok dengan tepi zona merah). Kecepatan aman/bahaya melewati beberapa tahap: pertama 0,5/1,0 m/s (terlalu lambat untuk dimainkan), lalu sempat 3,375/5,0 m/s (mengembalikan magnitudo lama), lalu disetel ulang secara eksplisit jadi 2,0/4,0 m/s ("coba base nya 2 m/s... naik jadi 4 m/s di area merah") β€” nilai saat ini. reelHook membaca fishFight.reelPullSpeed hanya selama hasFishOnHook; pengambilan senar kosong tetap memakai konstanta reelSpeed lama. Batas minimum turun dari 0,39375 ke angka datar 0,1 untuk jalur perlawanan ikan.

Reel-in/menjauh tetap di ketinggian air sampai dekat dermaga (ADR-042): kedua cabang bercabang berdasarkan jarak horizontal (X/Z) ke titik target β€” di luar hookLiftDistance (1,0 m) kail dipatok di waterHeight, hanya bergerak horizontal; di dalam jarak itu, gerakan beralih jadi 3D penuh untuk naik ke sisanya.

Resolusi zona-angkat instan, tanpa pendakian bertahap (ADR-061, menggantikan ADR-044): ADR-044 membuat reelHook bergerak menuju hookLiftTargetPosition dengan kecepatan tetap dockLiftSpeed (3,5 m/s) begitu masuk hookLiftDistance, baru selesai dalam jarak 0,15 m lagi dari target β€” memperbaiki bug "ikan mengambang" awal tapi memunculkan versi lebih kecil dari keluhan yang sama (kail "melayang dikit" dekat dermaga). reelHook sekarang langsung menempatkan hookEntity.position tepat ke hookLiftTargetPosition dan menyelesaikan tangkapan/pengambilan-kosong pada tick yang sama saat kail pertama kali melewati hookLiftDistance β€” pendaratan "disentak naik" instan. dockLiftSpeed dan pengecekan jarak-3D untuk tangkapan sekarang jadi kode mati. driftHookAway (hanya saat tidak reel) tidak berubah.

Target adalah hookLiftTargetPosition, bukan titik render joran (ADR-043): hookLiftTargetPosition = [0,22, 1,95, -4,7] berbagi X/Y dengan dockAnchorPosition tapi tidak Z-nya β€” memuat dock_main.usdz langsung dan mengukur visualBounds-nya menunjukkan XZ milik dockAnchorPosition berada jauh di dalam jejak padat dermaga (X di [-2,04, 2,04], Z di [-4,5, 4,5]), sehingga mengangkat menuju titik itu membuat kail naik menembus lantai dermaga. Memindahkan Z ke -4,7 (sedikit melewati tepi mesh yang menghadap air, hasil pengukuran) membuat kenaikan terjadi di ruang terbuka. dockAnchorPosition/controllerEntity tetap jadi titik render untuk joran yang dipegang dan untuk ensureFightDistance() (ADR-031) β€” hanya gerakan reel/menjauh dan perhitungan jarak-tangkap yang menuju titik baru ini.

Catch cutscene sebagai presentasi WorldViewModel, bukan HookPhase baru (ADR-045): saat benar-benar menangkap ikan, pendaratan zona-angkat memanggil fishFight.resolveCatch() dan beginCatchCutscene(from:) alih-alih langsung menonaktifkan kail, sambil tetap langsung mengeset HookPhase = .result (supaya lempar/reel tetap terkunci). isShowingCatchCutscene + struct privat CatchCutscene {origin, elapsed} menggerakkan gerakan vertikal tiga fase placeholder di tick(deltaTime:)'s case .result: naik perlahan ke catchCutsceneJumpHeight (0,6 m) selama catchCutsceneRiseDuration (0,35 detik), diam tanpa gerak selama catchCutsceneFreezeDuration (0,9 detik, ADR-047 β€” supaya terbaca sebagai jeda yang disengaja, bukan kedipan singkat), lalu turun perlahan selama catchCutsceneSettleDuration (0,35 detik pada awalnya), lalu menonaktifkan kail dan mematikan flag. ContentView menampilkan CatchCutsceneView (judul "CATCH!"); kondisi ResultView mendapat tambahan !isShowingCatchCutscene. HookPhase (di Shared) tidak berubah β€” murni presentasi World. Lompatan ini secara eksplisit hanyalah placeholder β€” bola yang sama dipakai sepanjang ADR-042 sampai ADR-044, belum ada mesh ikan terpisah saat itu.

Kamera memotong ke kail selama cutscene (ADR-046): updateCameraFollow() mendapat cabang pertama baru β€” selama isShowingCatchCutscene, kamera melihat ke hookEntity dari offset khusus yang lebih dekat, alih-alih followOffset tengah-perlawanan ([0, 1,5, 3,0], ADR-039). Sebelum ini, .result dikecualikan dari daftar fase followsHook β€” lompatan itu diputar sepenuhnya di luar kamera.

Dorongan kamera, letterbox, HUD tersembunyi, hiasan judul (ADR-048): offset kamera statis dari ADR-046 (sekarang catchCutsceneCameraEndOffset) menjadi ujung dari dolly yang di-easing (smoothstep) dari offset lebih lebar catchCutsceneCameraStartOffset ([0, 1,2, 2,4]) selama catchCutsceneTotalDuration (properti terhitung yang menjumlahkan tiga durasi fase ADR-047). CatchCutsceneView mendapat bar letterbox, vignette latar belakang, glow radial di belakang judul, dan spring dengan damping rendah di kemunculan judul (terlihat overshoot/"pop"). HUD statistik memudar selama cutscene. Cue suara tangkapan (catch_splash.wav lewat reelHook's emit(.catchFish)) sudah ada sebelumnya, tidak ada perubahan audio yang dibutuhkan.

Ikan animasi sungguhan menggantikan bola placeholder (ADR-068): caughtFishEntity, dimuat sekali lewat Entity(named: "mekong_catfish_caught"), menggantikan hookEntity di cutscene: beginCatchCutscene(from:) menonaktifkan hookEntity, mengaktifkan caughtFishEntity, menskalakannya ke panjang tangkapan yang benar-benar di-roll (fishFight.lastCatchResult.length dibandingkan dengan panjang tubuh aset yang sebenarnya, 2,0 m, dibatasi minimum 0,35), memutar satu-satunya animasi bakunya (klip "Caught" struggle→calm→showcase). Perhitungan tinggi naik/beku/turun dari ADR-047 dijadikan satu fungsi bersama catchCutsceneHeight(elapsed:) yang dipakai baik untuk update posisi kail/ikan maupun dorongan kamera ADR-048. Bersumber dari repo aset sibling ~/repos/new-blender-project (sepuluh spesies ikan, empat state baku tiap spesies — Cruise/Bite/Caught/Preview); hanya Mekong Giant Catfish yang dipakai di sini — dokumentasi repo itu sendiri (menurut bagian ini) mencatat cacat orientasi/normal/UV ekspor yang diketahui pada lima spesies lain (dikonfirmasi lewat pengujian on-device pada fitur AR fish-preview terpisah yang sudah dihentikan) dan menyebut Mekong Giant Catfish sebagai acuan yang harus diikuti spesies lain. Memetakan empat tier abstrak FishCatalog ke spesies nyata yang berbeda-beda masih jadi keputusan konten terbuka — setiap tangkapan saat ini menampilkan spesies yang sama tanpa memandang tier. Aset ini berlisensi CC-BY 4.0 (Sketchfab, "Ikan Patin") dan butuh atribusi; belum ada layar kredit untuk menampilkannya.

Orientasi ikan, gantung vertikal, meronta lebih cepat (ADR-069/072/073): dilaporkan langsung setelah melihat langsung hasilnya β€” ikan duduk di rotasi identitas sepanjang cutscene, sehingga bahkan animasi meronta yang secara teknis berjalan pun terbaca pasif/seperti mode preview. fishOrientation(facing:) (penyelarasan busur-terpendek + koreksi roll supaya punggung tetap di atas) ditambahkan tapi akhirnya bukan yang dipakai pose final. Sumbu lokal khusus aset ini (panjang tubuh sepanjang Y, punggung-perut sepanjang Z) dikonfirmasi dengan membaca langsung Skeleton.bindTransforms milik mekong_catfish_caught.usdz β€” nilai upAxis = "Z"-nya berarti aset ini mempertahankan konvensi asli Blender, bukan pertukaran Y-up yang dilakukan exporter glTF. Tiga iterasi: (1) ADR-069 mengarahkan ikan ke posisi live ujung joran β€” pose nyaris-vertikal yang tak terduga ("belum horizontal seperti kondisi ditangkep"); (2) ADR-072 memakai arah dunia horizontal tetap +X (memanfaatkan geometri kamera X=0 bersama untuk menjamin posisi menyamping) β€” tapi terbaca seperti siklus renang biasa begitu dilihat langsung ("ikannya kaya berenang... harusnya kepala nya [di atas], vertical"); (3) ADR-073 tetap "tetap, bukan dinamis" tapi dibalik jadi vertikal: konstruksi matriks rotasi langsung membuat kepala menghadap lurus ke atas (dunia +Y) sambil menyelaraskan sumbu lebar/lateral ikan ke arah pandang kamera -Z β€” diverifikasi lewat eksekusi langsung di Swift REPL. Terpisah, beginCatchCutscene awalnya mengeset .speed = 1.4 pada klip (ADR-069, disetel untuk klip lama yang lebih halus) β€” digantikan oleh ADR-071 begitu klipnya diganti.

Fase beku mendapat amplop slow-motion di dalam kode (ADR-070): cutscene.elapsed (jam cerita tunggal yang dibaca fungsi tinggi maupun dolly kamera) sekarang maju dengan deltaTime * catchCutsceneTimeDilation(elapsed:). Fungsi dilatasi bernilai 1 di luar fase beku, dan di dalamnya mengalami easing dari 1 turun ke catchCutsceneSlowMotionFactor (0,3) selama 20% pertama jendela beku, bertahan di 0,3 selama 60% tengah, lalu easing kembali ke 1 selama 20% terakhir. Karena posisi/kamera diturunkan dari elapsed, memperlambatnya otomatis memperlambat keduanya; caughtFishAnimationController secara terpisah diretarget ke catchCutsceneFishAnimationSpeed * dilation setiap frame supaya meronta ikan melambat/mempercepat serentak. Waktu nyata sepanjang durasi "cerita" fase beku 0,9 detik menjadi sekitar 2,3-2,4 detik saat didilatasikan.

Jeda kedua di akhir; kail/senar tetap menempel (ADR-074/075): fungsi dilatasi digeneralisasi dari "jeda hanya saat beku" menjadi "jeda selama elapsed berada di fase beku ATAU fase turun, yang mana pun berlaku saat itu" β€” memakai ulang bentuk/konstanta ease-in/ hold/ease-out yang sama untuk keduanya, sehingga momen bullet-time kedua diputar saat cutscene berakhir. hookEntity tidak lagi dinonaktifkan di awal cutscene β€” diposisikan ulang tiap frame ke posisi caughtFishEntity + caughtFishHeadOffset sepanjang sumbu kepala ikan, diskalakan sesuai ukuran tampil ikan saat itu. Panggilan ketegangan-senar di tick(deltaTime:) memakai catchCutsceneLineTension baru (0,8) selama cutscene, menggantikan nilai kendur penuh 0 sebelumnya, supaya senar terbaca tegang.

Disetel ulang berdasarkan screenshot langsung (ADR-075): catchCutsceneSettleDuration milik ADR-074 (1,85 detik, hasil "+1,5 detik" datar) menghasilkan sekitar 7+ detik waktu nyata setelah slow-motion diperhitungkan β€” terlalu lama. Diselesaikan dengan benar: untuk fase berdurasi cerita D di bawah bentuk dilatasi bersama, waktu nyata = D Γ— C di mana C = βˆ«β‚€ΒΉ du/dilation(u) β‰ˆ 2,688 (dihitung lewat integrasi numerik) β€” diselesaikan untuk durasi settle supaya total waktu nyata tepat 5,0 detik: 0,35 + CΓ—(0,9 + settle) = 5,0 -> settle β‰ˆ 0,83. Durasi naik/beku tidak disentuh. caughtFishHeadOffset berubah 0,6 -> 1,0 -> 1,15 lewat dua screenshot langsung β€” nilai lama berasal dari posisi bind-pose sendi Head, tapi geometri mesh sebenarnya (dibaca dari atribut extent milik .usdz) mencapai Y lokal β‰ˆ βˆ’1,0, jauh melewati sendi itu; screenshot kedua di 1,0 masih menunjukkan kail sedikit kurang panjang, digeser ke 1,15. hookEntity juga sekarang diskalakan mengikuti caughtFishEntity.scale (sebelumnya berukuran tetap tanpa memandang skala tampil ikan) β€” direset ke 1 di launchHook untuk lemparan berikutnya.

Senar khusus cutscene (ADR-076): rig rod_line.usdz hanya melengkungkan rantai tulang berpanjang tetap tanpa jalur kode untuk memperpanjang jangkauannya, sementara kail di cutscene berada sekitar 2,6 m lebih jauh dari titik joran dibanding jarak tengah-perlawanan mana pun yang bisa dijangkau rig itu, jadi senar tidak pernah mencapai framing cutscene (dikonfirmasi dengan membaca langsung RodRigController.swift). Alih-alih menggerakkan rig berkulit (skinned rig) melebihi panjang aslinya, entitas baru cutsceneLineEntity (silinder tipis polos) diregangkan/diorientasikan lewat perhitungan vektor tiap frame: skala Y lokal ke jarak terukur kail-ke-ujung-joran, posisi di titik tengah keduanya, rotasi lewat simd_quatf(from: [0,1,0], to: normalize(...)) β€” berlabuh ke posisi live ujung joran tiap frame.

Klip "Caught" baru mendaratkan puncaknya di dalam jeda slow-motion secara konstruksi (ADR-071): klip Caught dibuat ulang di Blender sesuai serah terima ADR-070 (90 frame/3,75 detik, naik dari 72 frame/3,0 detik, dikonfirmasi bindTransforms yang identik byte-per-byte β€” hanya animasinya yang diganti). catchCutsceneFishAnimationSpeed menjadi properti terhitung: caughtFishAnimationPeakTime / (catchCutsceneRiseDuration + catchCutsceneFreezeDuration / 2) β€” karena baik elapsed maupun .speed pada animation controller sama-sama diskalakan oleh dilatasi per-frame yang sama, waktu klip yang terpakai per unit waktu cerita menyederhanakan jadi konstanta polos yang tidak bergantung dilatasi, menjamin frame puncak klip (frame 44, β‰ˆ1,83 detik) mendarat tepat di titik tengah waktu-cerita fase beku.

Rumus penskalaan resistensi (ADR-039): kontribusi resistance ke effectiveFishPullRate/fishPullSpeed/allowance pelolosan diskalakan lewat pengalinya sendiri per rumus, bukan ditambahkan langsung β€” ditambahkan setelah pengali implisit awal 1,0/0,5 menghasilkan sebaran yang terlalu kecil antar spesies/tier (lantai baseFishPullRate mendominasi kontribusi resistensi). Pada resistance == 0 setiap spesies identik; pada roll ukuran maksimum, pengali-pengali ini menghasilkan kurva kesulitan nyata melintasi fish_a -> fish_b -> fish_c -> scott.

Haptic per pita ketegangan (ADR-039): FishFightController.tensionBand (nil di luar perlawanan, di bawah tensionBandMediumThreshold = rendah, di bawah tensionBandHighThreshold = sedang, sama-atau-di atasnya = tinggi) dipantau oleh WorldViewModel.syncTensionBand() tiap tick, mengirim FishingEvent.reelTensionLow/Medium/High hanya saat pita berpindah. Rod's FishingHaptics menyimpan pita ini dan berdenyut terus-menerus sesuai ritme pita tersebut (default ketukan berjarak 0,5 detik / teratur 0,28 detik / cepat 0,11 detik, intensitas naik seiring pita). Intensitas pita dinaikkan (ADR-054, "haptic nya kurang kerasa kenceng") dari 0,30/0,45/0,70 menjadi 0,50/0,70/0,95, lalu dimaksimalkan sepenuhnya (ADR-060) jadi 1,0/1,0/1,0 β€” nilai maksimum yang diterima hapticIntensity CoreHaptics, bersamaan dengan setiap pola HapticManager lainnya (lempar, splash, ketukan reel, gigitan, tangkapan, senar putus, lolos, tertarik) juga dinaikkan ke 1,0. sharpness dibiarkan tidak berubah di kedua tahap (mengatur timbre, bukan kekuatan yang terasa) β€” perbedaan antar pita tetap terbaca lewat interval ritme dan sharpness meski intensitasnya identik.

Konfigurasi Penyetelan Perlawanan Ikan (ADR-039): Shared/Gameplay/FishFightConfig.swift mengumpulkan setiap angka penyetelan (konstanta ketegangan senar, tiga pengali resistensi, allowance jarak-pelolosan, dua threshold pita ketegangan, sembilan parameter haptic Rod β€” interval/intensitas/sharpness Γ— rendah/sedang/tinggi) ke dalam satu struct Codable/Equatable, dimaksudkan untuk diedit langsung dengan tangan. FishFightController.init/FishingHaptics.init sama-sama menerima parameter config: FishFightConfig = FishFightConfig() β€” titik panggilan tanpa argumen yang sudah ada tidak terpengaruh, dan kedua class membaca konfigurasi yang sama supaya kesulitan World dan ritme haptic Rod tidak bisa melenceng satu sama lain. maxExpectedCastDistance tetap jadi konstanta privat FishFightController (fisika lemparan terikat ke castSpeed, bukan penyetelan perlawanan).

Kamera perlawanan (ADR-039): updateCameraFollow() melacak kail melintasi .flying, .fishOnHook, .reeling β€” kembali ke pose pemain tetap di luar itu.

Arah perlawanan: sentakan kiri/kanan berwaktu (ADR-053): saat gigitan, fishFight.fightDirection (.left/.right) memulai jendela acak 5-10 detik, berbalik ke sisi berlawanan tiap 0,6-1,8 detik acak, dilapiskan di atas rumus ketegangan (bukan mode kegagalan terpisah). updateFight membaca RodState.roll dan menerapkan delta ketegangan tiap tick:

melawan dengan benar (.left butuh rodRoll >= threshold, .right butuh rodRoll <= -threshold)
  -> tension -= fightDirectionCounterRelief per detik
tidak melawan -> tension += fightDirectionPenalty per detik

Ikan yang menarik ke .left membutuhkan roll joran positif (miring ke kanan) untuk dilawan β€” cocok dengan teknik memancing "tekanan sisi joran" sungguhan. Bagian ini murni visual/terpisah: updateFightDirectionDrift(deltaTime:) menggeser sumbu-X dunia kail menuju arah saat ini (dibatasi dekat sumbu-X joran sendiri) supaya ikan terlihat menyentak ke samping β€” tidak pernah memengaruhi pengecekan ketegangan, yang membaca RodState.roll langsung. Ditampilkan di LineTensionView (HUD World) sebagai "Fish pulling ←/β†’"/ "Lean rod β†’/←" β€” Rod tidak punya cara menyampaikan isyarat kiri/kanan lewat haptic. Ketujuh angka baru ini hidup di FishFightConfig.

Penskalaan resistensi per tangkapan (ADR-030): resistance tidak dibaca langsung dari spesies β€” diskalakan berdasarkan seberapa besar tangkapan spesifik ini di-roll dalam rentang spesiesnya sendiri:

sizeRatio = clamp01((currentWeight*currentLength - minProduct) / (maxProduct - minProduct))
resistance = species.resistance * sizeRatio

di mana min/maxProduct diturunkan dari hasil kali rentang berat/panjang spesies. Tangkapan yang mendekati batas bawah spesies melawan mendekati laju dasar; yang mendekati batas atas melawan pada resistensi penuh spesies.

Tier 3/4 diringankan (ADR-054): batas atas resistensi milik FishCatalog.swift untuk fish_b, fish_c (tier 3, besar), dan scott (tier 4, boss) disetel turun ke 0,10 masing-masing (penyetelan awal yang lebih ringan, 0,75β†’0,60/0,95β†’0,75 untuk fish_c/scott, digantikan oleh pemotongan lebih lanjut ini). Hanya fish_a yang mempertahankan nilai aslinya, 0,35. (Catatan: bagian "known issues" di WINDOW_2.md sendiri melaporkan bahwa angka-angka ini terus bergeser dari yang dinyatakan ADR mana pun β€” menurut catatan yang lebih baru itu, nilai live sebenarnya adalah fish_a 0,35, fish_b 0,50, fish_c 0,65, scott 0,70 β€” sebuah perbedaan lintas-dokumen yang nyata dan sudah diakui; angka "0,10 masing-masing" dari SAD ini tidak boleh dipercaya sebagai nilai terkini tanpa mengecek FishCatalog.swift secara langsung.)

Jarak lemparan menentukan ukuran ikan dalam tiga zona diskrit (ADR-063, menggantikan langit-langit kontinu ADR-031): WorldViewModel mengukur jarak lempar horizontal nyata (simd_distance antara titik peluncuran dan titik splash β€” berbeda dari CastSolution.distance, yang merupakan estimasi waktu-pengisian Rod, tidak terhubung ke fisika peluncuran), diteruskan ke resetForNewCast(castDistance:). Dipublikasikan sebagai lastCastDistance; menurunkan castZone yang mencerminkan tiga sepertiga bar daya milik CastMeterView:

ratio = clamp01(castDistance / maxExpectedCastDistance)   // maxExpectedCastDistance = 20,0
castZone = ratio < 1/3 ? hijau : ratio < 2/3 ? kuning : merah

ADR-031 awalnya membatasi roll gigitan dengan satu langit-langit kontinu (0,3 + 0,7*ratio) β€” hanya membatasi bagian atas, jadi "lempar lebih jauh β†’ ikan lebih besar" hanyalah kecenderungan lunak, bukan jaminan. Permintaan langsung: "jika user hanya bermain di area hijau dari cast, maka ikan bintang 1-2 saja yang bisa ditangkap ... jika kuning, maka bintang 3-4, jika area merah, maka bintang 4-5". sizeRollWindow sekarang memberi tiap zona batas bawah+atas yang tegas, membalik rumus stars(forWeight:in:) (1 + round(4*ratio)) di tiap batas bintang:

hijau:  ratio di [0,000, 0,375) -> bintang 1-2
kuning: ratio di [0,375, 0,875) -> bintang 3-4
merah:  ratio di [0,625, 1,000] -> bintang 4-5

(Kuning/merah sama-sama mencakup [0,625, 0,875) β€” keduanya bisa mendapat ikan bintang-4, disengaja, cocok dengan kata-kata pengguna yang tumpang tindih.) biasedRoll(in:floorRatio:ceilingRatio:) menggantikan versi lama yang hanya punya langit-langit. Karena sizeRatio/resistance diturunkan dari berat/ panjang yang di-roll, lemparan jauh yang mendapat ikan besar otomatis juga jadi perlawanan yang lebih sulit.

Jarak perlawanan terjamin saat gigitan (ADR-031): kalau pemain sudah menggulung selama pencarian, kail bisa saja sudah berada dalam jarak-tangkap tepat saat ikan menggigit β€” tanpa penjagaan, panggilan reelHook berikutnya pada tick yang sama akan langsung menyelesaikan tangkapan, membuat hasFishOnHook jadi true lalu false sebelum SwiftUI sempat merender satu frame, sehingga LineTensionView tidak pernah benar-benar terlihat. ensureFightDistance(), dipanggil segera setelah masuk .fishOnHook, mendorong kail keluar lagi ke minimumFightDistance (0,4 m) sepanjang sumbu joran-ke-kail yang ada, kalau sudah lebih dekat dari itu.

Menggulung tanpa ikan tetap di .hookInWater (ADR-031): reelHook(deltaTime:) hanya memanggil setHookPhase(.reeling) saat hasFishOnHook bernilai true β€” pengambilan kosong menggerakkan kail dan langsung selesai ke .idle tanpa pernah melewati .reeling/.fishOnHook. Ini penting karena cabang .fishOnHook, .reeling di tick() mengasumsikan ada ikan tersangkut dan cabang else-nya (saat tidak reel) tanpa syarat mengeset .fishOnHook.

HUD: LineTensionView.swift merender lineTension dengan zona aman berwarna (0,3...0,7, hijau) dan dua arah kegagalan: di bawah 0,15 menampilkan "LOSING FISH", di atas 0,85 menampilkan "SNAPPING"; terlihat kapan pun hasFishOnHook. Bagian Catch Results milik ContentView menampilkan caughtCount dan lastCatchResult (nama spesies, berat, panjang, hasil tangkap/lolos/senar-putus) lewat FishCatchResult (hanya untuk HUD, tidak Codable/tidak dikirim lewat transport). ContentView mengamati fishFight sebagai @ObservedObject-nya sendiri. FishingEvent.fishEscaped/.lineBreak sudah ada sebelum ADR-024 dan sudah ditangani oleh FishingHaptics.swift, tapi World tidak pernah mengirim keduanya sebelum ADR-024. HapticManager punya pola fishEscaped() tersendiri, bukan menyamakannya dengan pola lineBreak().

Bagian G β€” Ketertarikan Ikan, Layar Hasil, Antarmuka Minimal Rod (ADR-029, baris 1049-1126)

Ketertarikan Ikan: tidak ada case HookPhase baru β€” menggulung sudah diizinkan sepanjang .hookInWater tanpa memandang ketertarikan, jadi ini murni lapisan presentasi di atas fase pencarian yang sudah ada. updateSearching(deltaTime:) mengembalikan SearchEvent? {.interested, .bite}, digerakkan oleh dua timer berurutan (interestTimer lalu biteTimer, masing-masing 0,8-2,0 detik) alih-alih satu timer 1,5-4,0 detik. isFishInterested: Bool adalah @Published; konsumen visualnya di World adalah dip pelampung yang mengambang (ADR-052). FishingEvent.fishInterested terpicu sekali saat ketertarikan dimulai; FishingHaptics.interestPulse(now:) milik Rod membatasi haptic lembut yang berulang, dibersihkan oleh event lain mana pun atau saat fase meninggalkan .hookInWater (stopInterest()).

Layar Hasil: HookPhase mendapat case .result, hanya dimasuki lewat jalur tangkapan, dikecualikan dari allowsCasting/allowsReeling. continueFishing() (dijaga dengan syarat hookPhase == .result) adalah satu-satunya jalan kembali ke .idle β€” awalnya dipanggil oleh tombol ResultView sendiri; ADR-061 menghapus tombol itu, digantikan "Start Casting" milik Rod yang memicu pembatalan yang sama. Lolos/senar-putus sengaja tidak melewati .result β€” tetap transisi langsung dari ADR-028; menjaga setiap nyaris-tangkap di balik modal yang harus ditutup manual dirasa merepotkan.

FishCatchResult mendapat stars: Int (1-5, dari kedekatan dengan batas maksimum spesies), coins: Int (species.value * (0,6 + 0,1*stars)), isNewRecord: Bool (nol/false semua untuk hasil selain .caught). FishRecordStore.swift menyimpan tangkapan terberat per spesies ke UserDefaults sebagai JSON.

Antarmuka Minimal V1 milik Rod: view utama cocok persis dengan dokumen produk β€” judul, status koneksi, tiga tombol (Start Fishing, tombol kalibrasi per-langkah, End Fishing). Baris debug sebelumnya pindah ke DisclosureGroup "Developer Details" yang default-tertutup. RodViewModel mendapat isFishing: Bool/ startFishing()/endFishing() (startFishing() hanya berlaku setelah calibrationStep == .complete). Gestur lempar/gulung mendapat tambahan syarat !isFishing di depan gerbang allowsCasting/allowsReeling yang sudah ada. Celah yang diketahui saat itu: instruksi kalibrasi seharusnya seluruhnya ada di World, tapi teks instruksi Rod sendiri masih tinggal di Rod β€” ditutup kemudian oleh ADR-064 (lihat Bagian H).

Bagian H β€” Pelampung Mengambang, Kecepatan Reel Dinamis, Transport, Menu, Onboarding, Progression (baris 1127-1505)

Pelampung Mengambang (ADR-052): updateBobberFloat(deltaTime:), dipanggil dari case .hookInWater milik tick() kapan pun tidak sedang reel:

idleBob     = sin(bobberBobPhase * idleBobFrequency * 2Ο€) * idleBobAmplitude    (0,02 m, 0,5 Hz)
interestDip = isFishInterested ? -abs(sin(bobberBobPhase * interestDipFrequency * 2Ο€)) *
              interestDipAmplitude : 0                                          (0,06 m, 2,0 Hz)

idleBob selalu ada (realisme daya apung ambient); interestDip hanya berlapis di atasnya saat ikan tertarik β€” hanya ke bawah, lebih tajam/cepat dari idle β€” memberi tanda-tanda visual sebelum gigitan terjadi. Tidak berjalan selama reel atau perlawanan (fase-fase itu sudah punya hookEntity.position.y sendiri). bobberBobPhase di-reset tiap ada splash baru.

Kecepatan Reel Dinamis (ADR-025): klasifikasi .reel sebelumnya membuat reelSpeed jenuh ke nilai biner 0/1 karena max(profile.reelRotationRate, 1) membulatkan penyebut ke lantai 1, padahal laju rotasi perangkat sungguhan rutin melebihi 1 rad/detik. MotionProfile.reelFullRotationRate (laju rotasi puncak yang ditangkap, bukan rata-rata, selama kalibrasi) memberi titik referensi kedua; reelSpeed sekarang jadi ramp linear antara minimum terkalibrasi (0,0) dan puncak yang ditunjukkan pemain sendiri (1,0).

Transport CoreBluetooth (ADR-035): TransportProtocol/ NetworkMessage (di Shared) tidak berubah; hanya implementasinya yang berubah. TransportClient (Rod)/TransportHost (World) adalah penulisan ulang penuh berbasis CoreBluetooth, menggantikan MultipeerConnectivity sepenuhnya. Peran: Rod adalah peripheral BLE (CBPeripheralManager, mengiklankan/mendorong β€” "penyiar aktif" yang cocok dengan "Rod is an input device"); World adalah central BLE (CBCentralManager, memindai/menyambung/bereaksi). Tata letak GATT (UUID sebagai String biasa di SessionConfiguration, bukan CBUUID, supaya Shared tetap bebas-impor CoreBluetooth):

Rod (peripheral)                                World (central)
  characteristic state       --- notify, tidak andal ---> (.state, ~30 Hz)
  characteristic reliableOut --- indicate, andal      ---> (.discoveryToken)
  characteristic command     <--- write-with-response, andal ---  (.hookPhase/.fishingEvent)

World menulis ke characteristic milik Rod adalah satu-satunya cara BLE central mendorong data ke peripheral (tidak ada primitif "central notify") — itulah kenapa arah World→Rod berupa write, bukan meniru notify/indicate. Framing: BLEFraming.swift memberi awalan header panjang 4-byte pada NetworkMessage yang sudah di-encode, memecahnya jadi potongan seukuran MTU; BLEMessageReassembler mengumpulkan sampai lengkap. Jalur .state yang tidak andal tidak pernah dipecah — sampel yang kebesaran langsung dibuang alih-alih berisiko desinkronisasi (sesuai alasan requiresReliableDelivery: "sampel basi tidak berguna"). Kedua jalur andal boleh dipecah, mengandalkan ack per-potongan bawaan CoreBluetooth plus antrean kirim FIFO. Konkurensi: TransportProtocol/ TransportClient/TransportHost semuanya @MainActor (kedua manager dibuat dengan queue: nil, callback delegate sudah mendarat di main thread); protocol delegate Objective-C milik CoreBluetooth memakai @preconcurrency. Sambung ulang otomatis di kedua sisi selagi diinginkan: Rod tetap mengiklankan setelah unsubscribe, World memulai ulang pemindaian setelah terputus/gagal-sambung.

Sambung ulang manual (ADR-061): koneksi kadang terjebak di state yang tidak pernah pulih lewat sambung-ulang otomatis, sebelumnya hanya bisa diperbaiki dengan force-quit World. retryConnection() menjalankan urutan putus-lalu-sambung yang sama seperti restart aplikasi, tanpa kehilangan state screen/fishFight/progression. Muncul sebagai "Retry Connection" di MainMenuView, hanya ditampilkan saat !isRodReady.

Allowlist pemasangan perangkat (ADR-049): PairedDeviceStore (World)/ PairedCentralStore (Rod) menyimpan identifier CoreBluetooth pihak lain lewat UserDefaults setelah koneksi pertama berhasil; kedua jalur discovery mengabaikan perangkat yang tidak cocok sesudahnya. retrievePeripherals(withIdentifiers:) memungkinkan World menyambung ulang tanpa pemindaian baru. forgetPairedDevice() (kedua sisi) menghapus identifier tersimpan. Ditampilkan di Menu Utama World ("Paired with <nama>" + "Forget Device") dan, sejak ADR-062, sebagai baris di layar utama Rod.

Pemasangan yang tidak cocok gagal secara diam-diam di sisi Rod (ADR-062): CoreBluetooth tidak memberi peripheral cara untuk menolak subscription β€” didSubscribeTo yang tidak cocok tetap berhasil di level protokol, Rod hanya tidak pernah mengeset subscribedCentral, jadi tidak ada apa pun yang benar-benar terkirim. didDiscover milik World menghindari jebakan setara dengan memfilter sebelum menyambung (hanya tersedia di sisi central). Pemasangan tersimpan yang basi sebelumnya tidak bisa dipulihkan dari UI Rod sendiri β€” baris "Forget Paired Mac" adalah perbaikannya. Kedua jalur decode transport sekarang mencatat kegagalan di bawah #if DEBUG.

Menu Utama World (ADR-036): WorldViewModel.Screen (.mainMenu/.playing) mengatur overlay di atas scene RealityKit yang selalu terpasang (ZStack, bukan penggantian hierarki view β€” kembali ke menu tidak pernah memicu ulang pemuatan aset). Alur transisi layar lengkap diberikan (onboarding β†’ mainMenu β†’ playing β†’ result β†’ mainMenu, semuanya digerakkan Rod per ADR-038). Rod tidak pernah terputus karena kembali ke menu β€” siklus hidup transport terikat ke masa hidup ContentView sendiri, bukan ke screen.

Onboarding & Panduan Kalibrasi Live (ADR-064): menutup dua celah yang masih terbuka dari "World memiliki Tutorial/Instruksi Kalibrasi" sejak ADR-029. CalibrationStep pindah ke Shared (sebelumnya lokal di Rod), Codable/ Sendable/Equatable, empat case (idle/cast/reel/complete). RodState mendapat calibrationStep (kunci jaringan "cal"). Gerbang kalibrasi berubah dari "World di Menu Utama" (!isFishing) menjadi "HookPhase == .idle" β€” membuat kalibrasi ulang di tengah sesi bisa dijangkau. CalibrationGuidanceView (World): teks reaktif-per-langkah + ajakan "Press [X] on your Rod", ditampilkan inline di MainMenuView dan sebagai overlay scrim di ContentView selama kalibrasi ulang di tengah sesi. Teks kalibrasi milik Rod sendiri sengaja dipertahankan (tidak dihapus) β€” pemain secara fisik sedang memegang Rod saat melakukan gestur, World mungkin tidak ada di pandangan mata mereka. OnboardingView: tiga halaman statis (Welcome; Your Goal; Levels & Fish Tiers β€” ditulis dari kode nyata ProgressionStore.swift: 3 ikan kecil membuka ikan sedang, 5 ikan sedang membuka ikan besar, 10 ikan besar membuka Scott, 1 tangkapan Scott menyelesaikannya β€” dokumen ini mencatat perbedaan dengan deskripsi lama yang kurang jelas "10/10 tiga level" di komentar kode ProgressionStore/prosa ADR-037). Ditampilkan otomatis sebelum .mainMenu pada instalasi baru, bisa dibuka ulang lewat "How to Play".

Tier Ikan & Progression (ADR-037): FishSpecies mendapat tier: FishTier (small/medium/big/boss). FishCatalog mendefinisikan satu spesies per tier: fish_a (.small), fish_b (.medium), fish_c (.big), scott (.boss). ProgressionStore.swift menyimpan jumlah tangkapan kumulatif per tier, menurunkan level (1-3), unlockedTiers, progressTowardNextLevel:

Level 1 (awal): .small bisa ditangkap
-> 10 .small tertangkap -> Level 2: .medium juga bisa ditangkap
-> 10 .medium tertangkap -> Level 3: .big DAN .boss (Scott) juga bisa ditangkap

FishFightController mempublikasikan currentLevel/ levelUpAnnouncement. .boss (Scott) sengaja dibuat jadi tier tersendiri, bukan sekadar puncak rentang .big β€” pertemuan tunggal yang khas dan bernuansa naratif, terbuka di ambang Level 3 yang sama dengan .big (tiga level yang dispesifikasikan, bukan empat). Reset Progress (ADR-055): ProgressionStore.reset() mengembalikan semua hitungan tier ke nol, level kembali ke 1 β€” muncul sebagai tombol di Menu Utama.

Rod Memiliki Batas Sesi (ADR-038): RodState mendapat isFishing: Bool di stream .state ~30Hz yang sudah ada. WorldViewModel.handle(_:) mendeteksi tepi (edge-detect): tepi naik memulai sesi (screen = .playing), tepi turun mengakhiri sesi (returnToMenu()). returnToMenu() sekarang bisa dipanggil kapan saja (tidak hanya setelah tangkapan) dan juga terpicu sebagai fail-safe saat transport masuk state .disconnected/.failed. Kedua tombol kontrol-sesi milik World sendiri dihapus sebagai konsekuensi langsung ("Start Fishing" lama di MainMenuView, "Return to Menu" di ResultView) β€” mempertahankan salah satunya hanya akan diam-diam ditimpa oleh pesan .state berikutnya. ContentView.swift milik Rod menyatukan tumpukan tiga tombol lamanya jadi satu tombol yang label/aksinya berubah mengikuti isFishing; Calibrate pindah jadi item toolbar.

Subsistem Audio World (ADR-040): World/World/Audio/ meniru struktur lapisan haptics milik Rod. SoundManager β€” level rendah AVFoundation, tidak tahu apa-apa soal gameplay, satu AVAudioPlayer yang dimuat awal per case Sound. FishingSounds β€” perute event, switch handle(_ event:) + helper siklus hidup loop, bentuknya sama dengan FishingHaptics.handle(_:). Suara tetap di luar Shared dan di luar transport; WorldViewModel menyalurkan setiap event yang dikirim lewat satu titik emit(_:) supaya suara dan pengiriman transport ke Rod tidak bisa melenceng satu sama lain:

private func emit(_ event: FishingEvent) {
    sounds.handle(event)
    Task { await send(.fishingEvent(event)) }
}

Aset ada di bawah World/World/Assets/SFX/ (grup akar tersinkronisasi Xcode 16 otomatis dikumpulkan, tidak perlu entri .pbxproj). Pemetaan event ke suara diberikan secara eksplisit: hookSplash β†’ splash sekali putar; fishInterested β†’ mulai loop gelembung; fishBite β†’ hentikan loop gelembung + gigitan sekali putar; catchFish β†’ hentikan loop reel + splash tangkapan; fishEscaped/ lineBreak β†’ pakai ulang splash sekali putar (belum ada aset khusus); event pita ketegangan diteruskan demi simetri tapi tetap hanya-haptic. .cast tidak punya event yang dikirim World (Rod menghasilkannya secara lokal), jadi launchHook memanggil sounds.castLaunched() langsung. Loop reel digerakkan tiap tick dari rodState.action/hookPhase, bukan dari event. Trek ambience danau yang berulang diputar sepanjang menu maupun gameplay.

Catatan silang untuk SAD secara keseluruhan

Bagian A (kerangka dasar) hampir sepenuhnya redundan dengan bagian arsitektur/komponen/ threading/aturan-kode di AGENTS.md β€” isi sama, struktur judul berbeda. Aman untuk menjadikan AGENTS.md sebagai acuan untuk materi itu dan membuang duplikatnya di SAD, atau sebaliknya. Bagian B-C (kontrak kalibrasi/cast-resolver) adalah pengulangan ketiga dari aturan yang sama yang sudah diberikan di AGENTS.md dan CLAUDE.md β€” tapi di sini langsung diikuti nilai unik SAD sendiri: riwayat pembangunan ADR-032/054/056/057 yang sesungguhnya soal bagaimana daya lemparan berevolusi (jarak β†’ tahan-durasi β†’ meter timing osilasi), yang dua dokumen lain hanya menyinggung sekilas.

Ada tumpang tindih luas dengan tabel ADR milik WINDOW_2.md (041-057) β€” SAD memberi detail teknis penuh, WINDOW_2.md memberi kerangka narasi/kronologis dan secara eksplisit menandai apa yang masih "belum teruji", yang tidak selalu dibawa oleh prosa SAD sendiri (misalnya, SAD tidak menyatakan sekasar WINDOW_2.md soal "belum ada yang dimainkan di perangkat sungguhan"). SAD secara langsung mengimplementasikan/menjabarkan setiap sistem yang dispesifikasikan GAMEPLAY_ARCHITECTURE_V1.md pada level visi (lempar, perlawanan, ketegangan, haptic, catch cutscene, progression) β€” kedua dokumen ini pasangan pelengkap, bukan duplikat yang tumpang tindih, dan keduanya harus tetap dipertahankan.

Perbedaan internal yang diketahui (ditandai di atas): SAD menyatakan batas atas resistensi tier 3/4 disetel jadi "0,10 masing-masing" (ADR-054), tapi WINDOW_2.md (ditulis belakangan) melaporkan nilai live sebenarnya sebagai fish_b 0,50, fish_c 0,65, scott 0,70 β€” artinya angka-angka ini sudah bergeser dari klaim entri SAD yang paling baru, dan kedua dokumen secara eksplisit memperingatkan untuk tidak mempercayai angka tertulis dari mana pun dibanding membaca FishCatalog.swift langsung. Ini contoh paling jelas dari staleness angka lintas-dokumen yang ditemukan dalam pembacaan ini.

SAD merujuk nomor ADR sampai ADR-076 β€” tertinggi dari dokumen mana pun yang dibaca, mengonfirmasi SAD adalah dokumen yang paling terjaga kemutakhirannya di antara kelima dokumen ini (WINDOW_2.md, meski merupakan handoff sesi terbaru, hanya menarasikan sampai sekitar ADR-057/058/059 plus catatan penyelesaian ADR-030/031; tidak mencakup ADR-062 sampai ADR-076, yang hanya muncul di SAD). Ini berarti WINDOW_2.md sendiri kini agak ketinggalan dibanding SAD dan tidak boleh dianggap sebagai narasi paling mutakhir. Tidak ada bagian SAD yang ditandai usang/salah oleh dokumennya sendiri, di luar banyak tempat yang secara eksplisit menarasikan rantai supersesi (penggantian) miliknya sendiri (misalnya, "ADR-028 menggantikan mekanisme ADR-024/ADR-027", "ADR-061 menggantikan pendakian ADR-044", "ADR-063 menggantikan langit-langit kontinu ADR-031", "ADR-073 menggantikan percobaan orientasi ADR-069/072") β€” semua ini adalah catatan supersesi eksplisit dan disengaja, bukan bug staleness.

Catatan Silang & Hal yang Perlu Diwaspadai

Karena kelima dokumen ini banyak tumpang tindih dan sesekali saling bertentangan, berikut peta ringkas siapa yang jadi sumber kebenaran untuk tiap topik, dan hal-hal spesifik yang perlu diwaspadai kalau kita (tim aset Blender) perlu merujuk balik ke dokumen-dokumen aplikasi ini.

Peta lintas-dokumen untuk keperluan deduplikasi

KonsepSumber paling lengkap/kanonisJuga muncul di (redundan)
"Rod is an input device. World is the game." + pembagian kepemilikan perangkatGAMEPLAY_ARCHITECTURE_V1.mdAGENTS.md (Kontrak Gameplay Architecture V1), CLAUDE.md, SAD (rekap Bagian E)
Aturan dependensi World/Rod/Shared (tidak pernah Rod↔World)AGENTS.mdCLAUDE.md, SAD Bagian A
Kontrak pipeline kalibrasi/orientasiAGENTS.md (Kontrak Kalibrasi dan Orientasi)CLAUDE.md, SAD Bagian B
Kontrak Cast Resolver / pengisian gestur (aturan statis)AGENTS.md (Kontrak State Casting + Cast Resolver)CLAUDE.md, SAD Bagian B-C
Riwayat nyata mekanik daya lemparan (jarak β†’ tahan-durasi β†’ osilasi)SAD (Bagian B)WINDOW_2.md (versi narasi/kronologis)
ADR-041 (NI tidak lagi krusial)SAD Bagian CAGENTS.md, CLAUDE.md, WINDOW_2.md β€” semua menyatakan fakta yang sama
Mesin state gameplay (Connect→...→Continue)GAMEPLAY_ARCHITECTURE_V1.mdAGENTS.md (ringkas), SAD Bagian E (ringkas)
Rancangan catch cutsceneGAMEPLAY_ARCHITECTURE_V1.mdSAD (riwayat pembangunan ADR-045 s/d ADR-076 unik untuk SAD, tidak diduplikasi di tempat lain)
Kronologi/indeks ADR-041 s/d 057WINDOW_2.md (tabel + narasi)SAD (detail teknis penuh, meluas sampai ADR-076)
Alur kerja sesi / perintah verifikasiCLAUDE.mdTidak diduplikasi di tempat lain β€” unik untuk CLAUDE.md
Kerangka arsitektur generik (komponen, alur data, threading, penanganan error)AGENTS.mdSAD Bagian A (nyaris kata-per-kata)

Hal-hal yang secara eksplisit ditandai usang/sudah digantikan oleh dokumennya sendiri

  • "Jangan implementasikan gameplay memancing" / "Jangan pernah masukkan gameplay sebelum Sprint 8" (AGENTS.md, SAD Bagian A) β€” gameplay sudah lengkap dibangun; kedua dokumen mencatat roadmap Sprint 0-8 "sudah lama selesai".
  • Deskripsi transport di CLAUDE.md sebagai MultipeerConnectivity β€” sudah diperbaiki menurut catatan WINDOW_2.md sendiri, sekarang benar menyebut CoreBluetooth (ADR-035).
  • Tabrakan penomoran ADR-030/031 β€” sudah selesai per 2026-07-21 menurut WINDOW_2.md (pasangan yang bertabrakan dinomori ulang jadi ADR-058/059).
  • Angka resistensi tier 3/4 di ADR-054 milik SAD ("0,10 masing-masing") vs. nilai live yang diamati belakangan oleh WINDOW_2.md (0,50/0,65/0,70) β€” pergeseran angka yang diakui dan belum terselesaikan; jangan anggap salah satu sebagai otoritatif tanpa mengecek FishCatalog.swift langsung.
  • Beberapa percobaan orientasi catch-cutscene (ADR-069, ADR-072) secara eksplisit dijelaskan di SAD sebagai dicoba-lalu-digantikan oleh ADR-073 β€” ini riwayat internal yang disengaja, bukan konten usang yang perlu diperbaiki.

Ketidaksesuaian angka resistensi ikan β€” kasus paling jelas, FishCatalog.swift adalah sumber kebenaran

Ini titik yang paling penting untuk diingat kalau ada yang bertanya soal seberapa sulit ikan tier tinggi ditangkap. SAD (mengikuti ADR-054) menyatakan batas atas resistensi fish_b, fish_c, dan scott disetel ke 0,10 masing-masing, dengan fish_a tetap di 0,35. Tapi WINDOW_2.md, yang ditulis setelahnya, mencatat nilai live sebenarnya di kode berbeda jauh: fish_a 0,35, fish_b 0,50, fish_c 0,65, scott 0,70. Kedua dokumen sama-sama mengakui bahwa nilai-nilai ini "terus bergeser" karena sudah disetel ulang manual di luar proses ADR resmi. Kesimpulannya: jangan percaya angka tertulis di ADR mana pun (baik di SAD maupun di dokumen lain) sebagai nilai final β€” satu-satunya cara memastikan angka resistensi ikan yang sebenarnya berlaku sekarang adalah membuka dan membaca langsung file FishCatalog.swift di repo aplikasi. Ini juga jadi pengingat umum: proyek ini punya kebiasaan menyetel ulang angka gameplay secara langsung di kode tanpa selalu menulis ADR baru, jadi dokumen (termasuk bab buku ini) hanya representasi pada satu titik waktu, bukan jaminan nilai saat ini.

Redundansi yang sengaja dipertahankan vs. yang sebaiknya di-dedup

Kontrak Kalibrasi/Orientasi dan Kontrak Cast Resolver diulang tiga kali lintas dokumen (AGENTS.md, CLAUDE.md, SAD Bagian B) dengan kata-kata yang hampir sama persis β€” ini kandidat kuat untuk dedup, dengan AGENTS.md sebagai versi kanonis (karena memang dokumen "aturan") dan dua lainnya cukup merujuk balik. Sebaliknya, GAMEPLAY_ARCHITECTURE_V1.md (visi/rancangan) dan SAD (riwayat pembangunan sesungguhnya, ADR demi ADR) bukan duplikat meski membahas sistem yang sama β€” keduanya pasangan pelengkap dan sebaiknya tetap dipertahankan berdampingan: yang satu menjawab "kenapa/rasa yang dituju", yang satu menjawab "apa yang benar-benar dibangun dan bagaimana caranya disetel".

Untuk tim aset Blender secara spesifik

Bagian paling relevan dari SAD untuk pekerjaan kita adalah ADR-068 sampai ADR-076 (Bagian F), yang menjelaskan bagaimana aset ikan dari repo Blender ini dipakai di catch cutscene. Poin kuncinya: hanya Mekong Giant Catfish yang dipakai untuk cutscene tangkapan saat ini (lewat aset mekong_catfish_caught.usdz, dimuat sebagai Entity(named: "mekong_catfish_caught")), meski FishCatalog punya empat tier ikan berbeda (fish_a/fish_b/fish_c/scott) β€” pemetaan tier abstrak ke spesies asli yang berbeda-beda masih jadi keputusan konten yang belum diambil di sisi aplikasi. Panjang tubuh aset yang diasumsikan kode aplikasi adalah 2,0 m (dipakai sebagai basis skala terhadap panjang tangkapan yang di-roll, dengan skala minimum 0,35), dan sumbu lokalnya memakai upAxis = "Z" (konvensi asli Blender, bukan swap Y-up ala glTF) β€” panjang tubuh di sumbu Y, punggung-perut di sumbu Z. Kode aplikasi juga membaca offset kepala ikan dari mesh langsung (caughtFishHeadOffset, akhirnya disetel ke 1,15) karena posisi bind-pose sendi Head ternyata tidak mencapai ujung mesh yang sebenarnya. Kalau ada pembaruan pada aset ikan Mekong Giant Catfish di masa depan (skala, sumbu, titik kepala, durasi klip animasi "Caught"), perubahan itu akan langsung memengaruhi konstanta- konstanta ini di sisi aplikasi β€” sepadan untuk dikoordinasikan lintas repo, sesuai anjuran CLAUDE.md di repo ini soal menjaga kedua salinan aset tetap identik byte-per-byte.