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 diShared.
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:
- Periksa
git statusdan baca dokumen relevan sebelum mengedit. - Batasi perubahan sesuai lingkup; jangan sentuh perubahan pengguna lain yang tidak terkait.
- Pakai
apply_patchuntuk edit manual; jangan pernah pakai perintah git yang destruktif. - Perbarui bagian dokumentasi yang sesuai untuk setiap perubahan implementasi.
- Tambahkan entri bertanggal seperti commit ke
CHECKPOINT.md(lingkup, file, alur, verifikasi, keterbatasan yang tersisa). - Jalankan
git diff --checkdan kompilasiSharedsecara terarah sebelum serah terima. - 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
- 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. - Beres-beres dokumentasi (sudah ter-commit di riwayat
dev): mengarsipkanSPATIAL_CONTROLLER_DOCUMENT.mddanWINDOW_1.mdkeArchive/; mengeluarkan roadmap Sprint 0-8 yang sudah lama mati dariAGENTS.md/SOFTWARE_ARCHITECTURE_DOCUMENT.md; memperbaiki bug staleness nyata diCLAUDE.md(masih menyebut transport sebagai MultipeerConnectivity, padahal sudah lama diganti CoreBluetooth per ADR-035). - 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.
- 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. - Merge nyata dengan pekerjaan paralel rekan tim.
origin/mainsudah bergerak lewatfeat/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 menggabungkanorigin/mainkedevdan menomori ulang empat ADR window ini dari 044-047 menjadi 049-052 (mempertahankan 044-048 milik rekan tim sebagai kanonis dimain) β 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 teratasARCHITECTURE_DECISIONS.mdsendiri sebelum menambah ADR baru β tabrakan ini sudah pernah terjadi sekali. - 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).
- 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 diLineTensionViewmilik World (Rod tetap tidak punya HUD gameplay). - 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 keMotionManager. Juga ditemukan bahwa titik panggilanCastMeterViewternyata di-comment-out diWorld/ContentView.swiftselama 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.
- 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 (
Tabel ADR lengkap di window ini (juga berguna sebagai indeks cepat)
| # | Apa | Status |
|---|---|---|
| ADR-041 | NI dikonfirmasi ada tapi tidak krusial untuk gameplay V1 | Perbaikan dokumen saja |
| ADR-042 | Reel-in tetap di ketinggian air sampai dekat dermaga | Kode |
| ADR-043 | Target angkat kail dipindah menjauh dari jejak terukur dermaga | Kode |
| ADR-044β048 | Lift kecepatan-terjamin dekat-dermaga + catch cutscene | Bukan pekerjaan sesi ini β branch feat/playtest-refine rekan tim, di-merge |
| ADR-049 | Allowlist pemasangan perangkat lewat CoreBluetooth | Kode |
| ADR-050 | Percobaan lempar butuh pengaktifan eksplisit "Start Casting" | Kode |
| ADR-051 | Perbaikan rig joran: smoothing lengkungan, reel spin placeholder, perbaikan line-follow | Kode |
| ADR-052 | Pelampung mengambang: idle bob + "interest dip" sebelum gigitan | Kode |
| ADR-053 | Arah perlawanan: sentakan kiri/kanan berwaktu, penangkal roll joran | Kode |
| ADR-054 | Timer tahan lemparan (+ koreksi), haptic lebih kuat, tier 3/4 diringankan | Kode |
| ADR-055 | Tombol Reset Progress | Kode |
| ADR-056 | Daya lemparan menghapus sentakan sepenuhnya; timer live tampil di World; CastMeterView diaktifkan lagi | Kode |
| ADR-057 | Daya 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.swiftterus bergeser dari apa yang terakhir diset sesi ini. Nilai saat dokumen ditulis:fish_a0,35,fish_b0,50,fish_c0,65,scott0,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 diRod/Rod/ContentView.swift(dengan satu baris trailing-whitespace nyasar di sekitar baris 60 yang masih ditandaigit 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)
- Commit dan push pekerjaan yang belum ter-commit (ADR-056/057 + kedua koreksinya) kalau belum β
cek
git statusdulu. - 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.
- 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. - Tabrakan ADR-030/031 β sudah selesai (lihat isu yang diketahui).
- 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:)(sekarangmutating) 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.usdzsama sekali tidak punya geometri/sendi reel (dikonfirmasi dengan mengekstrak/membaca aset langsung). Placeholder bola pipih ditempel dekat sendiHandle, diputar lewatapplyReelSpin(reelSpeed:isReeling:deltaTime:), dipanggil daritick(deltaTime:)bersamaapplyLineTension. 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
| Konsep | Sumber paling lengkap/kanonis | Juga muncul di (redundan) |
|---|---|---|
| "Rod is an input device. World is the game." + pembagian kepemilikan perangkat | GAMEPLAY_ARCHITECTURE_V1.md | AGENTS.md (Kontrak Gameplay Architecture V1), CLAUDE.md, SAD (rekap Bagian E) |
| Aturan dependensi World/Rod/Shared (tidak pernah RodβWorld) | AGENTS.md | CLAUDE.md, SAD Bagian A |
| Kontrak pipeline kalibrasi/orientasi | AGENTS.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 C | AGENTS.md, CLAUDE.md, WINDOW_2.md β semua menyatakan fakta yang sama |
| Mesin state gameplay (Connectβ...βContinue) | GAMEPLAY_ARCHITECTURE_V1.md | AGENTS.md (ringkas), SAD Bagian E (ringkas) |
| Rancangan catch cutscene | GAMEPLAY_ARCHITECTURE_V1.md | SAD (riwayat pembangunan ADR-045 s/d ADR-076 unik untuk SAD, tidak diduplikasi di tempat lain) |
| Kronologi/indeks ADR-041 s/d 057 | WINDOW_2.md (tabel + narasi) | SAD (detail teknis penuh, meluas sampai ADR-076) |
| Alur kerja sesi / perintah verifikasi | CLAUDE.md | Tidak diduplikasi di tempat lain β unik untuk CLAUDE.md |
| Kerangka arsitektur generik (komponen, alur data, threading, penanganan error) | AGENTS.md | SAD 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.mdsebagai MultipeerConnectivity β sudah diperbaiki menurut catatanWINDOW_2.mdsendiri, 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 mengecekFishCatalog.swiftlangsung. - 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.