diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml
index ea7aee5..1c9987b 100644
--- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml
@@ -26,6 +26,10 @@
الإعدادات
الصوت
المؤثرات الصوتية للعبة
+ موسيقى
+ موسيقى خلفية
+ مؤثرات صوتية
+ الوضع، المسح، والتعليقات الصوتية
الاهتزاز
ردود فعل لمسية عند الوضع
المظهر الداكن
@@ -50,13 +54,7 @@
كومبو
- اختر قطعة
- اضغط مع الاستمرار على قطعة في الدرج لرفعها.
ضعها على اللوحة
- اسحب القطعة إلى اللوحة لوضعها. املأ صفاً أو عموداً بالكامل لمسحه.
- التالي
- حسناً
- تخطي
هل تستمتع بـ Logica؟
diff --git a/composeApp/src/commonMain/composeResources/values-az/strings.xml b/composeApp/src/commonMain/composeResources/values-az/strings.xml
index 493c78e..f104134 100644
--- a/composeApp/src/commonMain/composeResources/values-az/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-az/strings.xml
@@ -26,6 +26,10 @@
Tənzimləmələr
Səs
Oyunun səs effektləri
+ Musiqi
+ Fon musiqisi
+ Səs effektləri
+ Yerləşdirmə, təmizləmə və səs xətləri
Vibrasiya
Yerləşdirmə zamanı haptik rəy
Tünd mövzu
@@ -50,13 +54,7 @@
Kombo
- Bir fiqur seçin
- Qaldırmaq üçün paneldəki bir fiqura toxunun və saxlayın.
Lövhəyə yerləşdirin
- Yerləşdirmək üçün fiquru lövhəyə sürün. Təmizləmək üçün tam bir sətir və ya sütunu doldurun.
- Növbəti
- Anladım
- Ötür
Logica xoşunuza gəlir?
diff --git a/composeApp/src/commonMain/composeResources/values-be/strings.xml b/composeApp/src/commonMain/composeResources/values-be/strings.xml
index 9002ca9..efb6729 100644
--- a/composeApp/src/commonMain/composeResources/values-be/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-be/strings.xml
@@ -26,6 +26,10 @@
Налады
Гук
Гукавыя эфекты гульні
+ Музыка
+ Фонавая музыка
+ Гукавыя эфекты
+ Размяшчэнне, ачыстка і агучка
Вібрацыя
Тактыльная аддача пры ўсталёўцы
Цёмная тэма
@@ -50,13 +54,7 @@
Комба
- Абярыце фігуру
- Націсніце і ўтрымлівайце фігуру на панэлі, каб падняць яе.
Перацягніце на дошку
- Перацягніце фігуру на дошку, каб размясціць яе. Запоўніце цэлы рад ці слупок, каб ачысціць яго.
- Далей
- Зразумела
- Прапусціць
Падабаецца Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml
index 17c2ceb..1b97e7e 100644
--- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml
@@ -26,6 +26,10 @@
সেটিংস
শব্দ
খেলার শব্দ প্রভাব
+ সঙ্গীত
+ আবহ সঙ্গীত
+ শব্দ প্রভাব
+ প্লেসমেন্ট, ক্লিয়ার এবং ভয়েস লাইন
কম্পন
রাখার সময় হ্যাপটিক ফিডব্যাক
ডার্ক থিম
@@ -50,13 +54,7 @@
কম্বো
- একটি টুকরা চয়ন করুন
- একটি টুকরা তোলার জন্য ট্রেতে থাকা টুকরাটিকে চেপে ধরে রাখুন।
এটি বোর্ডে রাখুন
- এটি রাখার জন্য টুকরাটিকে বোর্ডে টেনে আনুন। এটি সরানোর জন্য একটি সম্পূর্ণ সারি বা কলাম পূরণ করুন।
- পরবর্তী
- বুঝেছি
- এড়িয়ে যান
Logica উপভোগ করছেন?
diff --git a/composeApp/src/commonMain/composeResources/values-da/strings.xml b/composeApp/src/commonMain/composeResources/values-da/strings.xml
index 3adb4c2..4691b5b 100644
--- a/composeApp/src/commonMain/composeResources/values-da/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-da/strings.xml
@@ -26,6 +26,10 @@
Indstillinger
Lyd
Lydeffekter i spillet
+ Musik
+ Baggrundsmusik
+ Lydeffekter
+ Placering, rydning og stemmelinjer
Vibration
Haptisk feedback ved placering
Mørkt tema
@@ -50,13 +54,7 @@
Combo
- Vælg en brik
- Tryk og hold på en brik i bakken for at løfte den.
Placer på brættet
- Træk brikken over på brættet for at placere den. Fyld en hel række eller kolonne for at fjerne den.
- Næste
- Forstået
- Spring over
Nyder du Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml
index 2e71f9a..12afe5c 100644
--- a/composeApp/src/commonMain/composeResources/values-de/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml
@@ -26,6 +26,10 @@
Einstellungen
Ton
Soundeffekte des Spiels
+ Musik
+ Hintergrundmusik
+ Soundeffekte
+ Platzieren, Löschen und Stimmen
Vibration
Haptisches Feedback beim Platzieren
Dunkles Design
@@ -50,13 +54,7 @@
Combo
- Wähle ein Teil
- Tippe und halte ein Teil im Fach, um es anzuheben.
Auf das Spielfeld legen
- Ziehe das Teil auf das Spielfeld, um es zu platzieren. Fülle eine komplette Reihe oder Spalte, um sie zu leeren.
- Weiter
- Verstanden
- Überspringen
Gefällt dir Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml
index 1cfaf63..7de19f0 100644
--- a/composeApp/src/commonMain/composeResources/values-el/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml
@@ -26,6 +26,10 @@
Ρυθμίσεις
Ήχος
Ηχητικά εφέ παιχνιδιού
+ Μουσική
+ Μουσική υποβάθρου
+ Ηχητικά εφέ
+ Τοποθέτηση, καθαρισμός και φωνητικές γραμμές
Δόνηση
Απτική ανάδραση κατά την τοποθέτηση
Σκούρο θέμα
@@ -50,13 +54,7 @@
Combo
- Επίλεξε ένα κομμάτι
- Πάτησε παρατεταμένα ένα κομμάτι στον δίσκο για να το σηκώσεις.
Τοποθέτησέ το στο ταμπλό
- Σύρε το κομμάτι στο ταμπλό για να το τοποθετήσεις. Συμπλήρωσε μια ολόκληρη σειρά ή στήλη για να την εξαφανίσεις.
- Επόμενο
- Έγινε
- Παράλειψη
Σου αρέσει το Logica;
diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml
index b791613..929f80e 100644
--- a/composeApp/src/commonMain/composeResources/values-es/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml
@@ -26,6 +26,10 @@
Ajustes
Sonido
Efectos de sonido del juego
+ Música
+ Música de fondo
+ Efectos de sonido
+ Colocación, limpieza y líneas de voz
Vibración
Respuesta háptica al colocar
Tema oscuro
@@ -50,13 +54,7 @@
Combo
- Elige una pieza
- Mantén presionada una pieza en la bandeja para levantarla.
Colócala en el tablero
- Arrastra la pieza al tablero para colocarla. Completa una fila o columna entera para eliminarla.
- Siguiente
- Entendido
- Omitir
¿Disfrutando Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-fi/strings.xml b/composeApp/src/commonMain/composeResources/values-fi/strings.xml
index 28774cd..a681cf7 100644
--- a/composeApp/src/commonMain/composeResources/values-fi/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fi/strings.xml
@@ -26,6 +26,10 @@
Asetukset
Ääni
Pelin äänitehosteet
+ Musiikki
+ Taustamusiikki
+ Äänitehosteet
+ Sijoittelu, tyhjennys ja puherivit
Värinä
Haptinen palaute asetettaessa
Tumma teema
@@ -50,13 +54,7 @@
Kombo
- Valitse pala
- Nosta pala tarjottimelta painamalla sitä pitkään.
Aseta laudalle
- Vedä pala laudalle asettaaksesi sen. Tyhjennä rivi tai sarake täyttämällä se kokonaan.
- Seuraava
- Selvä
- Ohita
Pidätkö Logicasta?
diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
index 5c73961..8228740 100644
--- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -26,6 +26,10 @@
Paramètres
Son
Effets sonores du jeu
+ Musique
+ Musique de fond
+ Effets sonores
+ Placement, effacement et répliques vocales
Vibration
Retour haptique lors du placement
Thème sombre
@@ -50,13 +54,7 @@
Combo
- Choisis une pièce
- Appuie longuement sur une pièce pour la soulever.
Dépose-la sur la grille
- Fais glisser la pièce sur la grille pour la placer. Remplis une ligne ou une colonne entière pour l'effacer.
- Suivant
- Compris
- Passer
Vous aimez Logica ?
diff --git a/composeApp/src/commonMain/composeResources/values-he/strings.xml b/composeApp/src/commonMain/composeResources/values-he/strings.xml
index bfeb97c..f3d3223 100644
--- a/composeApp/src/commonMain/composeResources/values-he/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-he/strings.xml
@@ -26,6 +26,10 @@
הגדרות
צליל
אפקטים קוליים של המשחק
+ מוזיקה
+ מוזיקת רקע
+ אפקטים קוליים
+ הנחה, ניקוי ודיבור
רטט
משוב רטט בעת הנחה
ערכת נושא כהה
@@ -50,13 +54,7 @@
קומבו
- בחר חלק
- לחץ והחזק חלק במגש כדי להרים אותו.
הנח אותו על הלוח
- גרור את החלק אל הלוח כדי למקם אותו. מלא שורה או עמודה שלמה כדי לנקות אותה.
- הבא
- הבנתי
- דלג
נהנה מ-Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml
index ecff06d..84f00bc 100644
--- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml
@@ -26,6 +26,10 @@
सेटिंग
ध्वनि
खेल ध्वनि प्रभाव
+ संगीत
+ पृष्ठभूमि संगीत
+ ध्वनि प्रभाव
+ प्लेसमेंट, क्लियर और वॉयस लाइन्स
कंपन
रखने पर हैप्टिक फीडबैक
डार्क थीम
@@ -50,13 +54,7 @@
कॉम्बो
- एक टुकड़ा चुनें
- उठाने के लिए ट्रे में एक टुकड़े को दबाकर रखें।
इसे बोर्ड पर रखें
- इसे रखने के लिए टुकड़े को बोर्ड पर खींचें। इसे हटाने के लिए एक पूरी पंक्ति या कॉलम भरें।
- अगला
- समझ गया
- छोड़ें
Logica का आनंद ले रहे हैं?
diff --git a/composeApp/src/commonMain/composeResources/values-hu/strings.xml b/composeApp/src/commonMain/composeResources/values-hu/strings.xml
index c777bf8..962c6ea 100644
--- a/composeApp/src/commonMain/composeResources/values-hu/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-hu/strings.xml
@@ -26,6 +26,10 @@
Beállítások
Hang
Játék hanghatások
+ Zene
+ Háttérzene
+ Hanghatások
+ Elhelyezés, törlés és hangsorok
Rezgés
Haptikus visszajelzés elhelyezéskor
Sötét téma
@@ -50,13 +54,7 @@
Kombó
- Válassz egy elemet
- Érintsd meg hosszan az elemet a tálcán a felemeléséhez.
Helyezd a táblára
- Húzd az elemet a táblára az elhelyezéshez. Tölts meg egy teljes sort vagy oszlopot az eltüntetéséhez.
- Tovább
- Értem
- Kihagyás
Tetszik a Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-hy/strings.xml b/composeApp/src/commonMain/composeResources/values-hy/strings.xml
index df27cbf..8badf97 100644
--- a/composeApp/src/commonMain/composeResources/values-hy/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-hy/strings.xml
@@ -26,6 +26,10 @@
Կարգավորումներ
Ձայն
Խաղի ձայնային էֆեկտներ
+ Երաժշտություն
+ Ֆոնային երաժշտություն
+ Ձայնային էֆեկտներ
+ Տեղադրում, մաքրում և ձայնային տողեր
Թրթռում
Հպման հետադարձ կապ տեղադրման ժամանակ
Մուգ թեմա
@@ -50,13 +54,7 @@
Կոմբո
- Ընտրիր պատկերը
- Սեղմիր և պահիր պատկերը այն բարձրացնելու համար:
Տեղադրիր տախտակին
- Քաշիր պատկերը տախտակին այն տեղադրելու համար: Լրացրու ամբողջական տող կամ սյունակ այն ջնջելու համար:
- Հաջորդը
- Պարզ է
- Բաց թողնել
Հավանու՞մ եք Logica-ն
diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml
index 6228f92..ef4198f 100644
--- a/composeApp/src/commonMain/composeResources/values-id/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml
@@ -26,6 +26,10 @@
Pengaturan
Suara
Efek suara permainan
+ Musik
+ Musik latar
+ Efek suara
+ Penempatan, pembersihan, dan garis suara
Getaran
Umpan balik haptik saat menaruh
Tema Gelap
@@ -50,13 +54,7 @@
Kombo
- Pilih balok
- Ketuk dan tahan balok di baki untuk mengangkatnya.
Letakkan di papan
- Seret balok ke papan untuk menempatkannya. Isi seluruh baris atau kolom untuk menghapusnya.
- Berikutnya
- Mengerti
- Lewati
Menikmati Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml
index eebb42b..8b813f1 100644
--- a/composeApp/src/commonMain/composeResources/values-it/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml
@@ -26,6 +26,10 @@
Impostazioni
Suono
Effetti sonori del gioco
+ Musica
+ Musica di sottofondo
+ Effetti sonori
+ Posizionamento, cancellazione e linee vocali
Vibrazione
Feedback aptico al posizionamento
Tema scuro
@@ -50,13 +54,7 @@
Combo
- Scegli un pezzo
- Tocca e tieni premuto un pezzo nel vassoio per sollevarlo.
Trascinalo sulla griglia
- Trascina il pezzo sulla griglia per posizionarlo. Riempi una riga o una colonna intera per eliminarla.
- Avanti
- Ho capito
- Salta
Ti piace Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
index 28eaafe..e04f2e1 100644
--- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
@@ -26,6 +26,10 @@
設定
サウンド
ゲームの音響効果
+ 音楽
+ BGM
+ 効果音
+ 配置、消去、およびボイスライン
バイブレーション
ブロック配置時の振動
ダークテーマ
@@ -50,13 +54,7 @@
コンボ
- ピースを選ぼう
- トレイにあるピースを長押しして持ち上げます。
ボードに置こう
- ピースをボードにドラッグして配置します。行または列を揃えて消去しましょう。
- 次へ
- わかった
- スキップ
Logicaを楽しんでいますか?
diff --git a/composeApp/src/commonMain/composeResources/values-ka/strings.xml b/composeApp/src/commonMain/composeResources/values-ka/strings.xml
index e21f8d8..9ef155d 100644
--- a/composeApp/src/commonMain/composeResources/values-ka/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ka/strings.xml
@@ -26,6 +26,10 @@
პარამეტრები
ხმა
თამაშის ხმოვანი ეფექტები
+ მუსიკა
+ ფონური მუსიკა
+ ხმის ეფექტები
+ განთავსება, გასუფთავება და ხმოვანი ხაზები
ვიბრაცია
ვიბრაცია ბლოკის დასმისას
მუქი თემა
@@ -50,13 +54,7 @@
კომბო
- აირჩიე ფიგურა
- დააჭირე და გეჭიროს ფიგურა მის ასაღებად.
განათავსე დაფაზე
- გადაიტანე ფიგურა დაფაზე მის განსათავსებლად. შეავსე მთლიანი რიგი ან სვეტი მის გასაქრობად.
- შემდეგი
- გასაგებია
- გამოტოვება
მოგწონთ Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-kk/strings.xml b/composeApp/src/commonMain/composeResources/values-kk/strings.xml
index f76d0bf..0ce9ebd 100644
--- a/composeApp/src/commonMain/composeResources/values-kk/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-kk/strings.xml
@@ -26,6 +26,10 @@
Параметрлер
Дыбыс
Ойынның дыбыстық эффектілері
+ Музыка
+ Фондық музыка
+ Дыбыс әсерлері
+ Орналастыру, тазалау және дыбыстық жолдар
Діріл
Орналастыру кезіндегі тактильді кері байланыс
Қараңғы тақырып
@@ -50,13 +54,7 @@
Комбо
- Фигураны таңдаңыз
- Көтеру үшін панельдегі фигураны басып тұрыңыз.
Тақтаға орналастырыңыз
- Орналастыру үшін фигураны тақтаға сүйреңіз. Тазалау үшін толық жолды немесе бағанды толтырыңыз.
- Келесі
- Түсінікті
- Өткізіп жіберу
Logica ұнады ма?
diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml
index efcb73a..31ce7ab 100644
--- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml
@@ -26,6 +26,10 @@
설정
소리
게임 음향 효과
+ 음악
+ 배경 음악
+ 사운드 효과
+ 배치, 제거 및 음성 대사
진동
블록 배치 시 진동 피드백
다크 테마
@@ -50,13 +54,7 @@
콤보
- 조각 선택하기
- 트레이에 있는 조각을 길게 눌러 들어 올리세요.
보드에 배치하기
- 조각을 보드로 드래그하여 배치하세요. 가로 또는 세로 줄을 채워 조각을 제거하세요.
- 다음
- 확인
- 건너뛰기
Logica가 마음에 드시나요?
diff --git a/composeApp/src/commonMain/composeResources/values-ky/strings.xml b/composeApp/src/commonMain/composeResources/values-ky/strings.xml
index 3e938e6..079e3bd 100644
--- a/composeApp/src/commonMain/composeResources/values-ky/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ky/strings.xml
@@ -26,6 +26,10 @@
Орнотуулар
Үн
Оюндун үн эффекттери
+ Музыка
+ Фондук музыка
+ Үн эффекттери
+ Жайгаштыруу, тазалоо жана үн линиялары
Дирилдөө
Жайгаштырууда тактилдик байланыш
Караңгы тема
@@ -50,13 +54,7 @@
Комбо
- Фигураны тандаңыз
- Көтөрүү үчүн фигураны басып туруңуз.
Тактага жайгаштырыңыз
- Жайгаштыруу үчүн фигураны тактага сүйрөңүз. Тазалоо үчүн толук сапты же тилкени толтуруңуз.
- Кийинки
- Түшүнүктүү
- Өткөрүп жиберүү
Logica жакты беби?
diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml
index 72edcd7..256e1fb 100644
--- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml
@@ -26,6 +26,10 @@
Innstillinger
Lyd
Lydeffekter i spillet
+ Musikk
+ Bakgrunnsmusikk
+ Lydeffekter
+ Plassering, tømming og stemmelinjer
Vibrasjon
Haptisk tilbakemelding ved plassering
Mørkt tema
@@ -50,13 +54,7 @@
Kombo
- Velg en brikke
- Trykk og hold på en brikke i brettet for å løfte den.
Plasser på brettet
- Dra brikken til brettet for å plassere den. Fyll en hel rad eller kolonne for å fjerne den.
- Neste
- Skjønner
- Hopp over
Liker du Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml
index 0137f3a..434f58d 100644
--- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml
@@ -26,6 +26,10 @@
Instellingen
Geluid
Geluidseffecten van het spel
+ Muziek
+ Achtergrondmuziek
+ Geluidseffecten
+ Plaatsen, wissen en stemlijnen
Trillen
Haptische feedback bij plaatsen
Donker thema
@@ -50,13 +54,7 @@
Combo
- Kies een stuk
- Houd een stuk in de lade ingedrukt om het op te tillen.
Leg het op het bord
- Sleep het stuk naar het bord om het te plaatsen. Vul een volledige rij of kolom om deze te wissen.
- Volgende
- Begrepen
- Overslaan
Geniet je van Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
index c4a54e5..c89be49 100644
--- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
@@ -26,6 +26,10 @@
Ustawienia
Dźwięk
Efekty dźwiękowe gry
+ Muzyka
+ Muzyka w tle
+ Efekty dźwiękowe
+ Umieszczanie, czyszczenie i linie głosowe
Wibracje
Haptyczne sprzężenie zwrotne
Ciemny motyw
@@ -50,13 +54,7 @@
Combo
- Wybierz element
- Naciśnij i przytrzymaj element na tacy, aby go podnieść.
Połóż na planszy
- Przeciągnij element na planszę, aby go umieścić. Wypełnij cały wiersz lub kolumnę, aby je wyczyścić.
- Dalej
- Rozumiem
- Pomiń
Podoba Ci się Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
index 8860455..4b86bca 100644
--- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
@@ -26,6 +26,10 @@
Configurações
Som
Efeitos sonoros do jogo
+ Música
+ Música de fundo
+ Efeitos sonoros
+ Colocação, limpeza e linhas de voz
Vibração
Feedback háptico ao colocar
Tema escuro
@@ -50,13 +54,7 @@
Combo
- Escolha uma peça
- Toque e segure uma peça na bandeja para levantá-la.
Coloque no tabuleiro
- Arraste a peça para o tabuleiro para posicioná-la. Preencha uma linha ou coluna inteira para eliminá-la.
- Próximo
- Entendi
- Pular
Gostando de Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-ro/strings.xml b/composeApp/src/commonMain/composeResources/values-ro/strings.xml
index cc03355..81b9f07 100644
--- a/composeApp/src/commonMain/composeResources/values-ro/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ro/strings.xml
@@ -26,6 +26,10 @@
Setări
Sunet
Efecte sonore ale jocului
+ Muzică
+ Muzică de fundal
+ Efecte sonore
+ Plasare, ștergere și replici vocale
Vibrație
Feedback haptic la plasare
Temă întunecată
@@ -50,13 +54,7 @@
Combo
- Alege o piesă
- Apasă lung pe o piesă din tavă pentru a o ridica.
Pune-o pe tablă
- Trage piesa pe tablă pentru a o plasa. Completează un rând sau o coloană întreagă pentru a o elimina.
- Următorul
- Am înțeles
- Omite
Îți place Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
index b0fe784..5077796 100644
--- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
@@ -26,6 +26,10 @@
Настройки
Звук
Звуковые эффекты игры
+ Музыка
+ Фоновая музыка
+ Звуковые эффекты
+ Размещение, очистка и озвучка
Вибрация
Тактильный отклик при установке
Темная тема
@@ -50,13 +54,7 @@
Комбо
- Выберите фигуру
- Нажмите и удерживайте фигуру на панели, чтобы поднять ее.
Перетащите на доску
- Перетащите фигуру на доску, чтобы разместить ее. Заполните целый ряд или столбец, чтобы очистить его.
- Далее
- Понятно
- Пропустить
Нравится Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-sv/strings.xml b/composeApp/src/commonMain/composeResources/values-sv/strings.xml
index 8dbde47..f783e6b 100644
--- a/composeApp/src/commonMain/composeResources/values-sv/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-sv/strings.xml
@@ -26,6 +26,10 @@
Inställningar
Ljud
Ljudeffekter i spelet
+ Musik
+ Bakgrundsmusik
+ Ljudeffekter
+ Placering, rensning och röstlinjer
Vibration
Haptisk feedback vid placering
Mörkt tema
@@ -50,13 +54,7 @@
Kombo
- Välj en bit
- Tryck och håll på en bit i brickan för att lyfta den.
Placera på brädet
- Dra biten till brädet för att placera den. Fyll en hel rad eller kolumn för att rensa den.
- Nästa
- Fattar
- Hoppa över
Gillar du Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-tg/strings.xml b/composeApp/src/commonMain/composeResources/values-tg/strings.xml
index a55ac47..0750c11 100644
--- a/composeApp/src/commonMain/composeResources/values-tg/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-tg/strings.xml
@@ -26,6 +26,10 @@
Танзимот
Овоз
Эффектҳои овозии бозӣ
+ Мусиқӣ
+ Мусиқии заминавӣ
+ Эффектҳои савтӣ
+ Ҷойгиркунӣ, тозакунӣ ва хатҳои овозӣ
Ларзиш
Алоқаи тактилӣ ҳангоми ҷойгиркунӣ
Мавзӯи торик
@@ -50,13 +54,7 @@
Комбо
- Фигураро интихоб кунед
- Барои бардоштан фигураро пахш карда нигоҳ доред.
Дар тахта ҷойгир кунед
- Барои ҷойгир кардан фигураро ба тахта кашед. Барои тоза кардан сатр ё сутуни пурраро пур кунед.
- Оянда
- Фаҳмидам
- Гузаштан
Logica-ро дӯст медоред?
diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml
index 6e09680..c08110a 100644
--- a/composeApp/src/commonMain/composeResources/values-th/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml
@@ -26,6 +26,10 @@
ตั้งค่า
เสียง
เอฟเฟกต์เสียงในเกม
+ ดนตรี
+ เพลงประกอบ
+ เอฟเฟกต์เสียง
+ การวาง การล้าง และเสียงบรรยาย
การสั่น
การสั่นตอบสนองเมื่อวางบล็อก
ธีมมืด
@@ -50,13 +54,7 @@
คอมโบ
- เลือกชิ้นส่วน
- แตะค้างที่ชิ้นส่วนในถาดเพื่อยกขึ้น
วางลงบนกระดาน
- ลากชิ้นส่วนลงบนกระดานเพื่อวาง เติมให้เต็มแถวหรือคอลัมน์เพื่อลบออก
- ถัดไป
- ตกลง
- ข้าม
ชอบ Logica ไหม?
diff --git a/composeApp/src/commonMain/composeResources/values-tk/strings.xml b/composeApp/src/commonMain/composeResources/values-tk/strings.xml
index 317e428..096b6eb 100644
--- a/composeApp/src/commonMain/composeResources/values-tk/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-tk/strings.xml
@@ -26,6 +26,10 @@
Sazlamalar
Ses
Oýnuň ses effektleri
+ Saz
+ Fon sazy
+ Ses effektleri
+ Ýerleşdirme, arassalama we ses setirleri
Wibrasiýa
Ýerleşdireniňizde taktil seslenmesi
Garaňky tema
@@ -50,13 +54,7 @@
Kombo
- Bir şekil saýlaň
- Galdyrmak üçin paneldäki şekili basyp tutuň.
Tagta ýerleşdiriň
- Ýerleşdirmek için şekili tagta süýräň. Arassalamak üçin doly bir setiri ýa-da sütüni dolduryň.
- Indiki
- Düşündim
- Geç
Logica halaýarmy?
diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
index aa64a03..d565745 100644
--- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
@@ -26,6 +26,10 @@
Ayarlar
Ses
Oyun ses efektleri
+ Müzik
+ Arka plan müziği
+ Ses efektleri
+ Yerleştirme, temizleme ve ses hatları
Titreşim
Yerleştirme sırasında dokunsal geri bildirim
Koyu Tema
@@ -50,13 +54,7 @@
Kombo
- Bir parça seç
- Kaldırmak için tepsideki bir parçaya basılı tutun.
Tahtaya yerleştir
- Yerleştirmek için parçayı tahtaya sürükleyin. Temizlemek için tam bir satır veya sütun doldurun.
- Sonraki
- Anladım
- Geç
Logica'yı beğeniyor musunuz?
diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml
index c62fac7..b5c1ab5 100644
--- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml
@@ -26,6 +26,10 @@
Налаштування
Звук
Звукові ефекти гри
+ Музика
+ Фонова музика
+ Звукові ефекти
+ Розміщення, очищення та озвучення
Вібрація
Тактильний відгук при встановленні
Темная тема
@@ -50,13 +54,7 @@
Комбо
- Оберіть фігуру
- Натисніть і утримуйте фігуру на панелі, щоб підняти її.
Перетягніть на дошку
- Перетягніть фігуру на дошку, щоб розмістити її. Заповніть цілий ряд або стовпець, щоб очистити його.
- Далі
- Зрозуміло
- Пропустити
Подобається Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-uz/strings.xml b/composeApp/src/commonMain/composeResources/values-uz/strings.xml
index bc7a068..1424c89 100644
--- a/composeApp/src/commonMain/composeResources/values-uz/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-uz/strings.xml
@@ -26,6 +26,10 @@
Sozlamalar
Ovoz
O'yinning ovoz effektlari
+ Musiqa
+ Fon musiqisi
+ Ovoz effektlari
+ Joylashtirish, tozalash va ovozli liniyalar
Vibratsiya
Joylashtirishda tebranishli aloqa
Tungi mavzu
@@ -50,13 +54,7 @@
Kombo
- Shaklni tanlang
- Ko'tarish uchun shaklni bosib turing.
Doskaga joylashtiring
- Joylashtirish uchun shaklni doskaga suring. Tozalash uchun qator yoki ustunni to'ldiring.
- Keyingi
- Tushunarli
- O'tkazib yuborish
Logica yoqdimi?
diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml
index e477fd9..c263425 100644
--- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml
@@ -26,6 +26,10 @@
Cài đặt
Âm thanh
Hiệu ứng âm thanh trò chơi
+ Nhạc
+ Nhạc nền
+ Hiệu ứng âm thanh
+ Đặt, xóa và lời thoại
Rung
Phản hồi rung khi đặt khối
Giao diện tối
@@ -50,13 +54,7 @@
Combo
- Chọn một mảnh
- Chạm và giữ một mảnh trong khay để nhấc nó lên.
Thả vào bảng
- Kéo mảnh vào bảng để đặt nó. Lấp đầy một hàng hoặc cột để xóa nó.
- Tiếp theo
- Đã hiểu
- Bỏ qua
Bạn thích Logica?
diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml
index 51eb3f6..7c6cc1c 100644
--- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml
@@ -26,6 +26,10 @@
设置
声音
游戏音效
+ 音乐
+ 背景音乐
+ 音效
+ 放置、消除和语音旁白
振动
放置方块时的触感反馈
深色主题
@@ -50,13 +54,7 @@
连击
- 选择方块
- 长按托盘中的方块将其拿起。
放置在棋盘上
- 将方块拖到棋盘上进行放置。填满整行或整列即可消除。
- 下一步
- 知道了
- 跳过
喜欢Logica吗?
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 43700d1..fc9f973 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -26,6 +26,10 @@
Settings
Sound
Game sound effects
+ Music
+ Background music
+ Sound effects
+ Placement, clear, and voice lines
Vibration
Haptic feedback on placement
Dark theme
@@ -50,13 +54,7 @@
Combo
- Pick a piece
- Tap and hold a piece in the tray to lift it.
Drop it on the board
- Drag the piece onto the board to place it. Fill a full row or column to clear it.
- Next
- Got it
- Skip
Enjoying Logica?
diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GestureTutorial.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GestureTutorial.kt
new file mode 100644
index 0000000..c1dbeab
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GestureTutorial.kt
@@ -0,0 +1,322 @@
+package ge.yet3.blokblast.component.overlay
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import androidx.compose.ui.util.lerp
+import blockblast.composeapp.generated.resources.Res
+import blockblast.composeapp.generated.resources.tutorial_grid_title
+import ge.yet.blokblast.domain.model.Piece
+import ge.yet.blokblast.domain.model.Polyomino
+import ge.yet3.blokblast.screen.game.BlockPiece
+import ge.yet3.blokblast.screen.game.effects.ConfettiEffect
+import ge.yet3.blokblast.theme.pieceColor
+import kotlinx.coroutines.delay
+import org.jetbrains.compose.resources.stringResource
+import kotlin.math.roundToInt
+
+// Fingertip hotspot as a fraction of the pointer canvas — the index-finger tip
+// that the gesture aligns to, and the pivot the press-scale shrinks toward.
+private const val TIP_X = 0.41f
+private const val TIP_Y = 0.05f
+
+private val POINTER_W = 64.dp
+private val POINTER_H = 74.dp
+private val GHOST_CELL = 26.dp
+private val GHOST_GAP = 3.dp
+
+// How far above the fingertip the lifted ghost piece floats, mirroring the
+// vertical lift of a real drag so the demo reads like the real gesture.
+private val GHOST_LIFT = 30.dp
+
+/**
+ * Wordless first-launch onboarding: a translucent scrim dims the screen while
+ * an animated hand loops the core gesture — lift the first tray piece and drag
+ * it onto the board — with a ghost copy of [piece] following the fingertip.
+ *
+ * Touches pass straight through: the player dismisses it simply by performing
+ * the gesture themselves. The caller flips [dismissing] on first engagement,
+ * which fires a confetti burst and a fade-out before [onExitComplete] runs
+ * (where the caller persists the "seen" flag and unmounts this overlay).
+ * [trayBounds] and [gridBounds] are in root pixels.
+ */
+@Composable
+fun GestureTutorial(
+ trayBounds: Rect,
+ gridBounds: Rect,
+ piece: Piece?,
+ captionTopPadding: Dp,
+ dismissing: Boolean,
+ onExitComplete: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (piece == null || trayBounds == Rect.Zero || gridBounds == Rect.Zero) return
+
+ val density = LocalDensity.current
+ val cornerPx = with(density) { 16.dp.toPx() }
+ val padPx = with(density) { 6.dp.toPx() }
+
+ // Start at the centre of the first tray slot (left third), end near the
+ // centre of the board.
+ val trayPoint = Offset(trayBounds.left + trayBounds.width / 6f, trayBounds.center.y)
+ val boardPoint = gridBounds.center
+
+ val cols = piece.shape.width
+ val rows = piece.shape.height
+ val ghostWPx = with(density) { (cols * GHOST_CELL + (cols - 1) * GHOST_GAP).toPx() }
+ val ghostHPx = with(density) { (rows * GHOST_CELL + (rows - 1) * GHOST_GAP).toPx() }
+ val pointerWPx = with(density) { POINTER_W.toPx() }
+ val pointerHPx = with(density) { POINTER_H.toPx() }
+ val liftPx = with(density) { GHOST_LIFT.toPx() }
+
+ val progress = remember { Animatable(0f) }
+ val pointerAlpha = remember { Animatable(0f) }
+ val exitAlpha = remember { Animatable(1f) }
+ var pressed by remember { mutableStateOf(false) }
+ var ghostVisible by remember { mutableStateOf(false) }
+ var showConfetti by remember { mutableStateOf(false) }
+
+ // Demo gesture loop — halts the moment the player engages so it doesn't
+ // keep moving under the fade-out.
+ LaunchedEffect(trayPoint, boardPoint, dismissing) {
+ if (dismissing) return@LaunchedEffect
+ while (true) {
+ progress.snapTo(0f)
+ pressed = false
+ ghostVisible = false
+ pointerAlpha.snapTo(0f)
+ pointerAlpha.animateTo(1f, tween(250))
+ delay(400)
+ pressed = true
+ delay(260)
+ ghostVisible = true
+ progress.animateTo(1f, tween(950, easing = FastOutSlowInEasing))
+ delay(160)
+ pressed = false
+ delay(140)
+ ghostVisible = false
+ delay(420)
+ pointerAlpha.animateTo(0f, tween(300))
+ delay(420)
+ }
+ }
+
+ // Exit: pop the confetti, fade the scrim/hand away, then hand control back
+ // to the caller (which persists "seen" and unmounts us). We stay mounted a
+ // beat longer so the confetti has time to fall.
+ LaunchedEffect(dismissing) {
+ if (dismissing) {
+ showConfetti = true
+ exitAlpha.animateTo(0f, tween(380))
+ delay(1100)
+ onExitComplete()
+ }
+ }
+
+ val scrimColor = Color.Black.copy(alpha = 0.5f)
+ val ringColor = MaterialTheme.colorScheme.primary
+
+ Box(modifier = modifier) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ // BlendMode.Clear needs an offscreen layer to actually punch holes;
+ // the same layer's alpha drives the fade-out.
+ .graphicsLayer {
+ compositingStrategy = CompositingStrategy.Offscreen
+ alpha = exitAlpha.value
+ }
+ .drawWithCache {
+ onDrawWithContent {
+ drawRect(scrimColor)
+ drawSpotlight(trayBounds, padPx, cornerPx, ringColor)
+ drawSpotlight(gridBounds, padPx, cornerPx, ringColor)
+ drawContent()
+ }
+ },
+ ) {
+ // Short caption, parked just under the score bar.
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = captionTopPadding)
+ .padding(horizontal = 24.dp)
+ .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(50))
+ .padding(horizontal = 20.dp, vertical = 10.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.tutorial_grid_title),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ // Ghost piece — lifts off the tray and rides above the fingertip.
+ if (ghostVisible) {
+ GhostPiece(
+ shape = piece.shape,
+ color = pieceColor(piece.colorId),
+ modifier = Modifier.offset {
+ val x = lerp(trayPoint.x, boardPoint.x, progress.value)
+ val y = lerp(trayPoint.y, boardPoint.y, progress.value) - liftPx
+ IntOffset(
+ (x - ghostWPx / 2f).roundToInt(),
+ (y - ghostHPx / 2f).roundToInt(),
+ )
+ },
+ )
+ }
+
+ // The pointing hand, fingertip pinned to the current gesture point.
+ val pressScale by animateFloatAsState(if (pressed) 0.9f else 1f, tween(180), label = "press")
+ val rippleAlpha by animateFloatAsState(if (pressed) 0.35f else 0f, tween(180), label = "ripple")
+ Canvas(
+ modifier = Modifier
+ .offset {
+ val x = lerp(trayPoint.x, boardPoint.x, progress.value)
+ val y = lerp(trayPoint.y, boardPoint.y, progress.value)
+ IntOffset(
+ (x - pointerWPx * TIP_X).roundToInt(),
+ (y - pointerHPx * TIP_Y).roundToInt(),
+ )
+ }
+ .size(POINTER_W, POINTER_H)
+ .graphicsLayer {
+ alpha = pointerAlpha.value
+ scaleX = pressScale
+ scaleY = pressScale
+ transformOrigin = TransformOrigin(TIP_X, TIP_Y)
+ },
+ ) {
+ if (rippleAlpha > 0f) {
+ drawCircle(
+ color = Color.White.copy(alpha = rippleAlpha),
+ radius = size.width * 0.32f,
+ center = Offset(size.width * TIP_X, size.height * TIP_Y),
+ )
+ }
+ drawHand()
+ }
+ }
+
+ // Celebration burst — drawn outside the faded layer so it stays vivid.
+ if (showConfetti) ConfettiEffect()
+ }
+}
+
+/** Punches a rounded transparent hole over [target] and outlines it faintly. */
+private fun DrawScope.drawSpotlight(target: Rect, padPx: Float, cornerPx: Float, ring: Color) {
+ if (target == Rect.Zero) return
+ val topLeft = Offset(target.left - padPx, target.top - padPx)
+ val size = Size(target.width + 2f * padPx, target.height + 2f * padPx)
+ val corner = CornerRadius(cornerPx, cornerPx)
+ drawRoundRect(
+ color = Color.Transparent,
+ topLeft = topLeft,
+ size = size,
+ cornerRadius = corner,
+ blendMode = BlendMode.Clear,
+ )
+ drawRoundRect(
+ color = ring.copy(alpha = 0.6f),
+ topLeft = topLeft,
+ size = size,
+ cornerRadius = corner,
+ style = androidx.compose.ui.graphics.drawscope.Stroke(width = 3f),
+ )
+}
+
+/** Draws a stylised pointing hand (index finger up) filling the canvas. */
+private fun DrawScope.drawHand() {
+ val w = size.width
+ val h = size.height
+ // Soft drop shadow, then the white hand on top — overlapping same-colour
+ // shapes hide their seams, so no outline is needed.
+ drawHandShapes(w, h, Color.Black.copy(alpha = 0.22f), Offset(w * 0.04f, h * 0.05f))
+ drawHandShapes(w, h, Color.White, Offset.Zero)
+}
+
+private fun DrawScope.drawHandShapes(w: Float, h: Float, color: Color, o: Offset) {
+ // Palm / fist.
+ drawRoundRect(
+ color = color,
+ topLeft = Offset(w * 0.16f + o.x, h * 0.40f + o.y),
+ size = Size(w * 0.74f, h * 0.58f),
+ cornerRadius = CornerRadius(w * 0.22f, w * 0.22f),
+ )
+ // Index finger (capsule).
+ drawRoundRect(
+ color = color,
+ topLeft = Offset(w * 0.30f + o.x, h * 0.02f + o.y),
+ size = Size(w * 0.22f, h * 0.52f),
+ cornerRadius = CornerRadius(w * 0.11f, w * 0.11f),
+ )
+ // Thumb (capsule, angled out to the left).
+ rotate(degrees = -28f, pivot = Offset(w * 0.24f + o.x, h * 0.62f + o.y)) {
+ drawRoundRect(
+ color = color,
+ topLeft = Offset(w * 0.02f + o.x, h * 0.52f + o.y),
+ size = Size(w * 0.40f, w * 0.22f),
+ cornerRadius = CornerRadius(w * 0.11f, w * 0.11f),
+ )
+ }
+}
+
+@Composable
+private fun GhostPiece(
+ shape: Polyomino,
+ color: Color,
+ modifier: Modifier = Modifier,
+) {
+ val w = shape.width * GHOST_CELL + (shape.width - 1) * GHOST_GAP
+ val h = shape.height * GHOST_CELL + (shape.height - 1) * GHOST_GAP
+ Box(modifier = modifier.size(w, h)) {
+ shape.cells.forEach { pos ->
+ BlockPiece(
+ color = color,
+ cellSize = GHOST_CELL,
+ filled = true,
+ modifier = Modifier.offset(
+ x = pos.x * (GHOST_CELL + GHOST_GAP),
+ y = pos.y * (GHOST_CELL + GHOST_GAP),
+ ),
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/SpotlightTutorial.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/SpotlightTutorial.kt
deleted file mode 100644
index c92d198..0000000
--- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/SpotlightTutorial.kt
+++ /dev/null
@@ -1,253 +0,0 @@
-package ge.yet3.blokblast.component.overlay
-
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.BoxScope
-import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.offset
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.drawWithCache
-import androidx.compose.ui.geometry.CornerRadius
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Rect
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.BlendMode
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.drawscope.Stroke
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.dp
-import blockblast.composeapp.generated.resources.Res
-import blockblast.composeapp.generated.resources.tutorial_done
-import blockblast.composeapp.generated.resources.tutorial_grid_body
-import blockblast.composeapp.generated.resources.tutorial_grid_title
-import blockblast.composeapp.generated.resources.tutorial_next
-import blockblast.composeapp.generated.resources.tutorial_skip
-import blockblast.composeapp.generated.resources.tutorial_tray_body
-import blockblast.composeapp.generated.resources.tutorial_tray_title
-import org.jetbrains.compose.resources.stringResource
-
-/**
- * One step of the spotlight tutorial: a screen-space rectangle to highlight,
- * plus the callout text. [target] in root pixels; pass [Rect.Zero] to dim
- * everything (no cutout).
- */
-data class SpotlightStep(
- val target: Rect,
- val title: String,
- val body: String,
-)
-
-/**
- * Full-screen scrim with a rounded cutout around the current step's target
- * and a callout card placed just below it. Walks through [steps] and calls
- * [onFinished] on the final tap or skip.
- *
- * Touches on the scrim are absorbed so the user cannot interact with the
- * underlying UI while the tutorial is up.
- */
-@Composable
-fun SpotlightTutorial(
- steps: List,
- onFinished: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- if (steps.isEmpty()) return
- var index by remember { mutableIntStateOf(0) }
- val safeIndex = index.coerceIn(0, steps.lastIndex)
- val step = steps[safeIndex]
- val isLast = safeIndex >= steps.lastIndex
-
- val density = LocalDensity.current
- val padPx = with(density) { 8.dp.toPx() }
- val cornerPx = with(density) { 12.dp.toPx() }
-
- val left by animateFloatAsState(step.target.left, tween(280), label = "spotlight-l")
- val top by animateFloatAsState(step.target.top, tween(280), label = "spotlight-t")
- val right by animateFloatAsState(step.target.right, tween(280), label = "spotlight-r")
- val bottom by animateFloatAsState(step.target.bottom, tween(280), label = "spotlight-b")
-
- val scrimColor = Color.Black.copy(alpha = 0.72f)
- val ringColor = MaterialTheme.colorScheme.primary
-
- BoxWithConstraints(
- modifier = modifier
- .fillMaxSize()
- .pointerInput(Unit) {
- awaitPointerEventScope {
- while (true) {
- val event = awaitPointerEvent()
- event.changes.forEach { it.consume() }
- }
- }
- }
- // BlendMode.Clear needs an offscreen layer to actually punch a hole.
- .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
- .drawWithCache {
- // Pass the four floats directly — animateFloatAsState is per-frame
- // and constructing a Rect/Offset/Size each time would allocate on
- // every animation tick. drawWithCache only re-runs when the keys
- // referenced inside change, so we still get caching of the lambda.
- onDrawWithContent {
- drawRect(scrimColor)
- val hasTarget = right > left && bottom > top
- if (hasTarget) {
- val holeLeft = left - padPx
- val holeTop = top - padPx
- val holeWidth = (right - left) + 2f * padPx
- val holeHeight = (bottom - top) + 2f * padPx
- val topLeft = Offset(holeLeft, holeTop)
- val size = Size(holeWidth, holeHeight)
- val corner = CornerRadius(cornerPx, cornerPx)
- drawRoundRect(
- color = Color.Transparent,
- topLeft = topLeft,
- size = size,
- cornerRadius = corner,
- blendMode = BlendMode.Clear,
- )
- drawRoundRect(
- color = ringColor,
- topLeft = topLeft,
- size = size,
- cornerRadius = corner,
- style = Stroke(width = 4f),
- )
- }
- drawContent()
- }
- },
- ) {
- val screenHeightPx = with(density) { maxHeight.toPx() }
- Callout(
- targetTop = top,
- targetBottom = bottom,
- screenHeightPx = screenHeightPx,
- targetVisible = right > left && bottom > top,
- title = step.title,
- body = step.body,
- isLast = isLast,
- onNext = { if (isLast) onFinished() else index = safeIndex + 1 },
- onSkip = onFinished,
- )
- }
-}
-
-@Composable
-private fun BoxScope.Callout(
- targetTop: Float,
- targetBottom: Float,
- screenHeightPx: Float,
- targetVisible: Boolean,
- title: String,
- body: String,
- isLast: Boolean,
- onNext: () -> Unit,
- onSkip: () -> Unit,
-) {
- val density = LocalDensity.current
- val gapPx = with(density) { 16.dp.toPx() }
- val hasTarget = targetVisible
-
- val cardModifier = Modifier
- .padding(horizontal = 24.dp)
- .widthIn(max = 360.dp)
- .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp))
- .padding(20.dp)
-
- // Heuristic: place the callout where there is more vertical space.
- // This prevents the card from being pushed off-screen or behind system bars.
- val spaceAbove = targetTop
- val spaceBelow = screenHeightPx - targetBottom
- val preferAbove = hasTarget && spaceBelow < spaceAbove
-
- Column(
- modifier = if (!hasTarget) {
- Modifier.align(Alignment.Center).then(cardModifier)
- } else if (preferAbove) {
- Modifier
- .align(Alignment.BottomCenter)
- // Alignment.BottomCenter puts the bottom of the card at screenHeightPx.
- // We offset it up (negative y) so its bottom is at targetTop - gapPx.
- .offset { IntOffset(0, (-(screenHeightPx - targetTop + gapPx)).toInt()) }
- .then(cardModifier)
- } else {
- Modifier
- .align(Alignment.TopCenter)
- // Alignment.TopCenter puts the top of the card at y=0.
- // We offset it down so its top is at targetBottom + gapPx.
- .offset { IntOffset(0, (targetBottom + gapPx).toInt()) }
- .then(cardModifier)
- },
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = body,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Row(
- modifier = Modifier.padding(top = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- if (!isLast) {
- TextButton(onClick = onSkip) {
- Text(stringResource(Res.string.tutorial_skip))
- }
- }
- Spacer(Modifier.weight(1f))
- TextButton(onClick = onNext) {
- Text(
- stringResource(
- if (isLast) Res.string.tutorial_done else Res.string.tutorial_next
- ),
- )
- }
- }
- }
-}
-
-/** Standard 2-step tutorial: tray then board. */
-@Composable
-fun rememberGameTutorialSteps(
- trayBounds: Rect,
- gridBounds: Rect,
-): List {
- val trayTitle = stringResource(Res.string.tutorial_tray_title)
- val trayBody = stringResource(Res.string.tutorial_tray_body)
- val gridTitle = stringResource(Res.string.tutorial_grid_title)
- val gridBody = stringResource(Res.string.tutorial_grid_body)
- return remember(trayBounds, gridBounds, trayTitle, trayBody, gridTitle, gridBody) {
- listOf(
- SpotlightStep(target = trayBounds, title = trayTitle, body = trayBody),
- SpotlightStep(target = gridBounds, title = gridTitle, body = gridBody),
- )
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/App.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/App.kt
index 45e3cd8..5a97520 100644
--- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/App.kt
+++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/App.kt
@@ -18,7 +18,7 @@ fun App(rootComponent: RootComponent) {
val darkTheme by rootComponent.darkTheme.collectAsState()
BlockBlastTheme(darkTheme = darkTheme) {
val vibrationEnabled by rootComponent.vibrationEnabled.collectAsState()
- val soundEnabled by rootComponent.soundEnabled.collectAsState()
+ val soundEnabled by rootComponent.sfxEnabled.collectAsState()
val tutorialSeen by rootComponent.tutorialSeen.collectAsState()
val onTutorialSeen = remember(rootComponent) { { rootComponent.onTutorialSeen() } }
CompositionLocalProvider(
diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt
index d4efcde..18eb1b3 100644
--- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt
+++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt
@@ -32,7 +32,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import ge.yet3.blokblast.component.modifier.liftedPieceShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedback
@@ -42,8 +41,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.geometry.Rect
-import ge.yet3.blokblast.component.overlay.SpotlightTutorial
-import ge.yet3.blokblast.component.overlay.rememberGameTutorialSteps
+import ge.yet3.blokblast.component.overlay.GestureTutorial
import ge.yet3.blokblast.theme.LocalOnTutorialSeen
import ge.yet3.blokblast.theme.LocalTutorialSeen
import ge.yet3.blokblast.theme.LocalVibrationEnabled
@@ -104,7 +102,9 @@ private val DRAG_GHOST_VERTICAL_LIFT = 28.dp
fun GameContent(component: GameComponent) {
val uiModel by component.model.subscribeAsState()
val model = uiModel.game
- var selectedPieceId by remember { mutableStateOf(null) }
+ val traySelection by component.pieceTray.selection.subscribeAsState()
+ val selectedPiece = traySelection.piece
+ val traySlots by component.pieceTray.slots.subscribeAsState()
// ── Effect states ────────────────────────────────────────────────────
val dragDrop = rememberDragDropState()
@@ -126,12 +126,23 @@ fun GameContent(component: GameComponent) {
var cellSizePx by remember { mutableFloatStateOf(0f) }
var gapPx by remember { mutableFloatStateOf(0f) }
- // Bounds (root-coords) used by the first-launch spotlight tutorial.
+ // Bounds (root-coords) used by the first-launch gesture tutorial.
var gridBounds by remember { mutableStateOf(Rect.Zero) }
var trayBounds by remember { mutableStateOf(Rect.Zero) }
val tutorialSeen = LocalTutorialSeen.current
val onTutorialSeen = LocalOnTutorialSeen.current
+ // The wordless tutorial dismisses itself the moment the player engages —
+ // either by dragging a piece or tapping one to select it. Dismissal is
+ // local + immediate (a fade-out + confetti) so it never lags behind the
+ // async "seen" persistence; the flag is persisted once the exit finishes.
+ var tutorialDismissing by remember { mutableStateOf(false) }
+ var tutorialDismissed by remember { mutableStateOf(false) }
+ val userEngaged = dragDrop.isDragging || selectedPiece != null
+ LaunchedEffect(userEngaged) {
+ if (userEngaged && !tutorialSeen) tutorialDismissing = true
+ }
+
var prevComboLevel by remember { mutableStateOf(model.comboLevel) }
LaunchedEffect(model.comboLevel) {
if (model.comboLevel > prevComboLevel && model.comboLevel > 0) {
@@ -304,12 +315,12 @@ fun GameContent(component: GameComponent) {
GameGrid(
grid = model.grid,
- selectedPiece = model.currentPieces.firstOrNull { it.pieceId == selectedPieceId },
+ selectedPiece = selectedPiece,
onCellTapped = { x, y ->
- val id = selectedPieceId
- if (id != null) {
- component.onCellClicked(id, x, y)
- selectedPieceId = null
+ val piece = selectedPiece
+ if (piece != null) {
+ component.onCellClicked(piece.pieceId, x, y)
+ component.pieceTray.clearSelection()
}
},
modifier = Modifier
@@ -339,21 +350,13 @@ fun GameContent(component: GameComponent) {
Spacer(Modifier.height(24.dp))
PieceTray(
- pieces = model.currentPieces,
- selectedPieceId = selectedPieceId,
- grid = model.grid,
+ tray = component.pieceTray,
modifier = Modifier
.widthIn(max = 500.dp)
.padding(bottom = 8.dp)
.onGloballyPositioned { trayBounds = it.boundsInRoot() },
- onPieceSelected = { id ->
- if (!dragDrop.isDragging) {
- selectedPieceId = if (selectedPieceId == id) null else id
- }
- },
onDragStart = { piece, startPos, offset ->
if (!dragDrop.isDragging) {
- selectedPieceId = null
dragDrop.startDrag(piece, startPos, offset)
haptic.vibrateIf(vibrationEnabled, HapticFeedbackType.LongPress)
}
@@ -419,15 +422,23 @@ fun GameContent(component: GameComponent) {
)
}
- // ── First-launch spotlight tutorial ─────────────────────────────
- // Shown until the user finishes/skips it; persisted via Settings so
- // it never appears again. Only renders once both targets have been
- // measured so the cutout lands on real geometry.
- if (!tutorialSeen && trayBounds != Rect.Zero && gridBounds != Rect.Zero && !model.isGameOver) {
- val steps = rememberGameTutorialSteps(trayBounds = trayBounds, gridBounds = gridBounds)
- SpotlightTutorial(
- steps = steps,
- onFinished = onTutorialSeen,
+ // ── First-launch gesture tutorial ───────────────────────────────
+ // A wordless looping hand demonstrates the drag gesture. Persisted
+ // via Settings so it never appears again, and only renders once both
+ // targets have been measured so the spotlight lands on real geometry.
+ if (!tutorialSeen && !tutorialDismissed &&
+ trayBounds != Rect.Zero && gridBounds != Rect.Zero && !model.isGameOver
+ ) {
+ GestureTutorial(
+ trayBounds = trayBounds,
+ gridBounds = gridBounds,
+ piece = traySlots.firstOrNull()?.piece,
+ captionTopPadding = innerPadding.calculateTopPadding() + 8.dp,
+ dismissing = tutorialDismissing,
+ onExitComplete = {
+ tutorialDismissed = true
+ onTutorialSeen()
+ },
modifier = Modifier.fillMaxSize(),
)
}
@@ -456,14 +467,14 @@ fun GameContent(component: GameComponent) {
canRevive = model.revivesUsed < 1,
continueCountdownSeconds = continueCountdown,
onReviveClicked = {
- selectedPieceId = null
+ component.pieceTray.clearSelection()
// Show interstitial; revive fires only after it's dismissed.
interstitial.show {
component.onReviveClicked()
}
},
onRestartClicked = {
- selectedPieceId = null
+ component.pieceTray.clearSelection()
component.onRestartClicked()
},
onExitClicked = component::onExitClicked,
diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/PieceTray.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/PieceTray.kt
index 555ca41..133858c 100644
--- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/PieceTray.kt
+++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/PieceTray.kt
@@ -1,5 +1,7 @@
package ge.yet3.blokblast.screen.game
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.animateBounds
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
@@ -15,20 +17,24 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -42,319 +48,332 @@ import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
-import ge.yet.blokblast.domain.model.Grid
+import com.arkivanov.decompose.extensions.compose.subscribeAsState
+import ge.yet.blockblast.feature.game.tray.PieceTrayComponent
+import ge.yet.blockblast.feature.game.tray.TraySlotComponent
import ge.yet.blokblast.domain.model.Piece
import ge.yet.blokblast.domain.model.Polyomino
import ge.yet3.blokblast.theme.pieceColor
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+private typealias DragStart = (piece: Piece, startPosition: Offset, pieceOriginOffset: Offset) -> Unit
+private typealias DragMove = (position: Offset) -> Unit
+private typealias DragEnd = () -> Unit
+
+private const val SLOT_COUNT = 3
+
/**
* Bottom tray showing up to three selectable/draggable pieces.
+ *
+ * Slot identity is owned by [PieceTrayComponent] (keyed on `pieceId`), so
+ * placing a piece keeps every survivor's component alive while `animateBounds`
+ * slides the right-hand neighbours leftward to fill the freed slot. The
+ * entrance Animatable is keyed on `pieceId` too, so it fires only for
+ * newly-arrived pieces.
*/
+@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun PieceTray(
- pieces: List,
- selectedPieceId: Long?,
- onPieceSelected: (Long) -> Unit,
+ tray: PieceTrayComponent,
modifier: Modifier = Modifier,
- grid: Grid? = null,
- onDragStart: ((piece: Piece, startPosition: Offset, pieceOriginOffset: Offset) -> Unit)? = null,
- onDragMove: ((position: Offset) -> Unit)? = null,
- onDragEnd: (() -> Unit)? = null,
+ onDragStart: DragStart? = null,
+ onDragMove: DragMove? = null,
+ onDragEnd: DragEnd? = null,
) {
- Row(
+ val slots by tray.slots.subscribeAsState()
+
+ BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 16.dp, vertical = 12.dp),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalAlignment = Alignment.CenterVertically,
) {
- val trayKey = remember(pieces) { pieces.map { it.pieceId }.joinToString() }
-
- repeat(3) { index ->
- val piece = pieces.getOrNull(index)
- val isSelected = piece != null && piece.pieceId == selectedPieceId
+ // Each slot is exactly 1/3 of the tray, regardless of how many are
+ // present — combined with Arrangement.Start this turns "neighbour
+ // placed" into a fixed-distance leftward slide instead of a reflow.
+ val slotWidth: Dp = maxWidth / SLOT_COUNT
- // Spring-overshoot entrance, staggered per slot.
- // Slot 0 flies in from the left, slot 2 from the right, slot 1
- // from below — a "slot-merge" entrance that reads as 3 distinct
- // pieces converging instead of one synchronized lift.
- val entrance = remember(trayKey, index) { Animatable(0f) }
- val (initialX, initialY) = when (index) {
- 0 -> -160f to 30f
- 2 -> 160f to 30f
- else -> 0f to 80f
- }
- val translateX = remember(trayKey, index) { Animatable(initialX) }
- val translateY = remember(trayKey, index) { Animatable(initialY) }
- androidx.compose.runtime.LaunchedEffect(trayKey, index) {
- kotlinx.coroutines.delay(index * 80L)
- kotlinx.coroutines.coroutineScope {
- launch {
- entrance.animateTo(
- 1f,
- spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = 380f),
+ LookaheadScope {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ slots.forEach { slot ->
+ key(slot.piece.pieceId) {
+ TraySlot(
+ slot = slot,
+ onDragStart = { piece, startPos, originOffset ->
+ tray.clearSelection()
+ onDragStart?.invoke(piece, startPos, originOffset)
+ },
+ onDragMove = onDragMove,
+ onDragEnd = onDragEnd,
+ modifier = Modifier
+ .width(slotWidth)
+ .animateBounds(this@LookaheadScope),
)
}
- launch {
- translateX.animateTo(0f, spring(dampingRatio = 0.6f, stiffness = 320f))
- }
- launch {
- translateY.animateTo(0f, spring(dampingRatio = 0.55f, stiffness = 380f))
- }
}
}
-
- // Can this piece fit anywhere on the board? Drives dim-when-no-fit.
- val canFit = remember(piece, grid) {
- if (piece == null || grid == null) true
- else canPlaceAnywhere(piece.shape, grid)
- }
-
- Box(
- modifier = Modifier
- .weight(1f)
- .graphicsLayer {
- scaleX = entrance.value
- scaleY = entrance.value
- alpha = entrance.value
- translationX = translateX.value
- translationY = translateY.value
- },
- contentAlignment = Alignment.Center,
- ) {
- TraySlot(
- piece = piece,
- isSelected = isSelected,
- canFit = canFit,
- onTap = { if (piece != null) onPieceSelected(piece.pieceId) },
- onDragStart = onDragStart,
- onDragMove = onDragMove,
- onDragEnd = onDragEnd,
- modifier = Modifier.fillMaxWidth(),
- )
- }
}
}
}
@Composable
private fun TraySlot(
- piece: Piece?,
- isSelected: Boolean,
- canFit: Boolean,
- onTap: () -> Unit,
- onDragStart: ((piece: Piece, startPosition: Offset, pieceOriginOffset: Offset) -> Unit)?,
- onDragMove: ((position: Offset) -> Unit)?,
- onDragEnd: (() -> Unit)?,
+ slot: TraySlotComponent,
+ onDragStart: DragStart?,
+ onDragMove: DragMove?,
+ onDragEnd: DragEnd?,
modifier: Modifier = Modifier,
) {
+ val piece = slot.piece
+ val isSelected by slot.isSelected.subscribeAsState()
+ val canFit by slot.canFit.subscribeAsState()
+
+ val entrance = rememberSlotEntrance(piece.pieceId, slot.spawnIndex)
+ val ambient = rememberAmbientLoops()
+
var isPressed by remember { mutableStateOf(false) }
val isHighlighted = isSelected || isPressed
- // Idle breathing & no-fit wiggle. Both are continuous animations and we
- // *must not* read their values in composition scope — doing so makes the
- // entire TraySlot recompose every frame (~60×/s × 3 slots). Instead we
- // hold onto the State objects and read .value inside the
- // graphicsLayer lambda below, which only invalidates the draw layer.
- val breathing = rememberInfiniteTransition(label = "breath")
- val breathScaleState = breathing.animateFloat(
- initialValue = 1f,
- targetValue = 1.04f,
- animationSpec = infiniteRepeatable(
- animation = tween(1400, easing = LinearEasing),
- repeatMode = RepeatMode.Reverse,
- ),
- label = "breathScale",
- )
- val wiggle = rememberInfiniteTransition(label = "wiggle")
- val wiggleAngleState = wiggle.animateFloat(
- initialValue = 0f,
- targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = tween(2400, easing = LinearEasing),
- repeatMode = RepeatMode.Restart,
- ),
- label = "wiggleAngle",
- )
-
- // Discrete-only target — flips between fixed states (pressed / selected /
- // can-fit / no-fit), so the spring only runs on transitions, not every
- // frame. Breathing is layered on top inside graphicsLayer.
val targetScale = when {
isPressed -> 1.08f
isSelected -> 1.12f
canFit -> 1f
else -> 0.92f
}
- val scaleState = animateFloatAsState(
- targetValue = targetScale,
- animationSpec = spring(),
- label = "pieceScale",
- )
+ val pieceScale = animateFloatAsState(targetScale, animationSpec = spring(), label = "pieceScale")
val applyBreath = canFit && !isPressed && !isSelected
- val applyWiggle = !canFit && piece != null
+ val applyWiggle = !canFit
- // Dim/desaturate when this piece can't be placed anywhere. NOTE: keep this
- // as State (no `by` delegate) so reads happen in the draw phase
- // inside graphicsLayer — otherwise every animation tick recomposes the
- // whole TraySlot (~290 recomp/session, observed in Layout Inspector).
- val pieceAlphaState = animateFloatAsState(
+ val pieceAlpha = animateFloatAsState(
targetValue = if (canFit) 1f else 0.45f,
animationSpec = tween(220),
label = "pieceAlpha",
)
- val pColor = piece?.let { pieceColor(it.colorId) }
-
- // Same rule: State read inside drawBehind (draw phase), not here.
- val slotBgState = animateColorAsState(
- targetValue = when {
- isHighlighted && pColor != null -> pColor.copy(alpha = 0.18f)
- else -> MaterialTheme.colorScheme.surfaceVariant
- },
+ val pColor = pieceColor(piece.colorId)
+ val slotBg = animateColorAsState(
+ targetValue = if (isHighlighted) pColor.copy(alpha = 0.18f)
+ else MaterialTheme.colorScheme.surfaceVariant,
animationSpec = tween(120),
label = "slotBg",
)
-
- val borderColor = when {
- isHighlighted && pColor != null -> pColor
- else -> Color.Transparent
- }
-
- var slotOriginInWindow by remember { mutableStateOf(Offset.Zero) }
- val touchSlop = LocalViewConfiguration.current.touchSlop
-
- val currentOnDragStart by rememberUpdatedState(onDragStart)
- val currentOnDragMove by rememberUpdatedState(onDragMove)
- val currentOnDragEnd by rememberUpdatedState(onDragEnd)
- val currentOnTap by rememberUpdatedState(onTap)
+ val borderColor = if (isHighlighted) pColor else Color.Transparent
Box(
modifier = modifier
.padding(6.dp)
.aspectRatio(1f)
- // Animated transforms read their State objects HERE (draw phase),
- // not in composition scope, so frame ticks don't recompose this
- // composable.
+ // First layer: entrance fly-in (keyed on pieceId, fires once per
+ // fresh piece). Second layer: idle/press transforms that read
+ // animated values inside the layer lambda so frame ticks don't
+ // recompose us — only the draw layer invalidates.
+ .graphicsLayer {
+ scaleX = entrance.scale.value
+ scaleY = entrance.scale.value
+ alpha = entrance.scale.value
+ translationX = entrance.translateX.value
+ translationY = entrance.translateY.value
+ }
.graphicsLayer {
- val s = scaleState.value
- val breath = if (applyBreath) breathScaleState.value else 1f
+ val s = pieceScale.value
+ val breath = if (applyBreath) ambient.breathScale.value else 1f
val combined = s * breath
scaleX = combined
scaleY = combined
- rotationZ = if (applyWiggle) {
- val a = wiggleAngleState.value
- if (a < 0.05f) {
- kotlin.math.sin(a / 0.05f * kotlin.math.PI.toFloat() * 4f) * 5f
- } else 0f
- } else 0f
+ rotationZ = if (applyWiggle) wiggleAngle(ambient.wigglePhase.value) else 0f
}
.clip(RoundedCornerShape(14.dp))
- .drawBehind { drawRect(slotBgState.value) }
+ .drawBehind { drawRect(slotBg.value) }
.then(
- if (isHighlighted) Modifier.border(
- width = 2.dp,
- color = borderColor,
- shape = RoundedCornerShape(14.dp),
- ) else Modifier,
+ if (isHighlighted) Modifier.border(2.dp, borderColor, RoundedCornerShape(14.dp))
+ else Modifier,
)
- .onGloballyPositioned { coords ->
- slotOriginInWindow = coords.positionInWindow()
- }
- .then(
- if (piece != null) {
- Modifier.pointerInput(piece.pieceId) {
- awaitPointerEventScope {
- while (true) {
- // Wait for finger down
- val down = awaitPointerEvent()
- if (down.type != PointerEventType.Press) continue
- val downChange = down.changes.firstOrNull() ?: continue
+ .traySlotPointerInput(
+ piece = piece,
+ onPressedChange = { isPressed = it },
+ onTap = slot::onTap,
+ onDragStart = onDragStart,
+ onDragMove = onDragMove,
+ onDragEnd = onDragEnd,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ val visibleColor = if (isHighlighted) pColor else pColor.copy(alpha = 0.6f)
+ Box(modifier = Modifier.graphicsLayer { alpha = pieceAlpha.value }) {
+ MiniPiece(
+ shape = piece.shape,
+ color = visibleColor,
+ shimmerKey = piece.pieceId,
+ )
+ }
+ }
+}
- isPressed = true
- val downPos = downChange.position
- var dragging = false
- var totalDrag = Offset.Zero
+/* ────────────────────────────── Animation helpers ─────────────────────────── */
- // Track move / up
- while (true) {
- val event = awaitPointerEvent()
- val change = event.changes.firstOrNull() ?: break
+private class SlotEntrance(
+ val scale: Animatable,
+ val translateX: Animatable,
+ val translateY: Animatable,
+)
- if (event.type == PointerEventType.Move) {
- val delta = change.position - downPos
- totalDrag = delta
+/**
+ * Spring-overshoot entrance, staggered by [spawnIndex]: slot 0 flies in from
+ * the left, slot 2 from the right, slot 1 from below. Keyed on [pieceId] so
+ * survivors of a partial placement keep their already-settled state.
+ */
+@Composable
+private fun rememberSlotEntrance(pieceId: Long, spawnIndex: Int): SlotEntrance {
+ val (initialX, initialY) = when (spawnIndex) {
+ 0 -> -160f to 30f
+ 2 -> 160f to 30f
+ else -> 0f to 80f
+ }
+ val scale = remember(pieceId) { Animatable(0f) }
+ val translateX = remember(pieceId) { Animatable(initialX) }
+ val translateY = remember(pieceId) { Animatable(initialY) }
+ LaunchedEffect(pieceId) {
+ delay(spawnIndex * 80L)
+ launch {
+ scale.animateTo(
+ 1f,
+ spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = 380f),
+ )
+ }
+ launch { translateX.animateTo(0f, spring(dampingRatio = 0.6f, stiffness = 320f)) }
+ launch { translateY.animateTo(0f, spring(dampingRatio = 0.55f, stiffness = 380f)) }
+ }
+ return remember(pieceId) { SlotEntrance(scale, translateX, translateY) }
+}
- if (!dragging && delta.getDistance() > touchSlop) {
- dragging = true
- val startInWindow = slotOriginInWindow + downPos
- currentOnDragStart?.invoke(piece, startInWindow, downPos)
- }
+private class AmbientLoops(
+ val breathScale: androidx.compose.runtime.State,
+ val wigglePhase: androidx.compose.runtime.State,
+)
- if (dragging) {
- change.consume()
- val posInWindow = slotOriginInWindow + change.position
- currentOnDragMove?.invoke(posInWindow)
- }
- }
+/**
+ * Continuous breathing + wiggle phases. Returned as `State` (not `Float`)
+ * so callers must read them inside a draw-phase lambda — reading in composition
+ * scope would recompose the whole slot 60×/s.
+ */
+@Composable
+private fun rememberAmbientLoops(): AmbientLoops {
+ val breath = rememberInfiniteTransition(label = "breath").animateFloat(
+ initialValue = 1f,
+ targetValue = 1.04f,
+ animationSpec = infiniteRepeatable(
+ tween(1400, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "breathScale",
+ )
+ val wiggle = rememberInfiniteTransition(label = "wiggle").animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ tween(2400, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart,
+ ),
+ label = "wigglePhase",
+ )
+ return remember { AmbientLoops(breath, wiggle) }
+}
- if (event.type == PointerEventType.Release) {
- isPressed = false
- if (dragging) {
- currentOnDragEnd?.invoke()
- } else {
- // It was a tap — toggle selection
- currentOnTap()
- }
- break
- }
- }
+/** Short bursts of rotation at the start of each wiggle cycle. */
+private fun wiggleAngle(phase: Float): Float =
+ if (phase < 0.05f) {
+ kotlin.math.sin(phase / 0.05f * kotlin.math.PI.toFloat() * 4f) * 5f
+ } else {
+ 0f
+ }
+
+/* ──────────────────────────────── Pointer input ───────────────────────────── */
+
+/**
+ * Single-finger tap + long-press-drag handler. Drag starts after the pointer
+ * travels past `touchSlop`; a release without crossing slop is a tap.
+ */
+@Composable
+private fun Modifier.traySlotPointerInput(
+ piece: Piece,
+ onPressedChange: (Boolean) -> Unit,
+ onTap: () -> Unit,
+ onDragStart: DragStart?,
+ onDragMove: DragMove?,
+ onDragEnd: DragEnd?,
+): Modifier {
+ var slotOriginInWindow by remember { mutableStateOf(Offset.Zero) }
+ val touchSlop = LocalViewConfiguration.current.touchSlop
+
+ val onDragStartLatest by rememberUpdatedState(onDragStart)
+ val onDragMoveLatest by rememberUpdatedState(onDragMove)
+ val onDragEndLatest by rememberUpdatedState(onDragEnd)
+ val onTapLatest by rememberUpdatedState(onTap)
+ val onPressedChangeLatest by rememberUpdatedState(onPressedChange)
+
+ return this
+ .onGloballyPositioned { coords -> slotOriginInWindow = coords.positionInWindow() }
+ .pointerInput(piece.pieceId) {
+ awaitPointerEventScope {
+ while (true) {
+ val downEvent = awaitPointerEvent()
+ if (downEvent.type != PointerEventType.Press) continue
+ val downChange = downEvent.changes.firstOrNull() ?: continue
+
+ onPressedChangeLatest(true)
+ val downPos = downChange.position
+ var dragging = false
+
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.firstOrNull() ?: break
- // If the pointer was cancelled
- if (isPressed) {
- isPressed = false
- if (dragging) currentOnDragEnd?.invoke()
+ when (event.type) {
+ PointerEventType.Move -> {
+ val delta = change.position - downPos
+ if (!dragging && delta.getDistance() > touchSlop) {
+ dragging = true
+ onDragStartLatest?.invoke(
+ piece,
+ slotOriginInWindow + downPos,
+ downPos,
+ )
}
+ if (dragging) {
+ change.consume()
+ onDragMoveLatest?.invoke(slotOriginInWindow + change.position)
+ }
+ }
+ PointerEventType.Release -> {
+ onPressedChangeLatest(false)
+ if (dragging) onDragEndLatest?.invoke() else onTapLatest()
+ break
}
}
}
- } else Modifier,
- ),
- contentAlignment = Alignment.Center,
- ) {
- if (piece != null) {
- val baseColor = pieceColor(piece.colorId)
- val visibleColor = if (isHighlighted) baseColor else baseColor.copy(alpha = 0.6f)
- // Apply the animated dim via graphicsLayer so its per-frame ticks
- // invalidate only this MiniPiece's draw, not TraySlot composition.
- Box(modifier = Modifier.graphicsLayer { alpha = pieceAlphaState.value }) {
- MiniPiece(
- shape = piece.shape,
- color = visibleColor,
- shimmerKey = piece.pieceId,
- )
+
+ // Defensive: cancel paths skip the Release branch.
+ onPressedChangeLatest(false)
+ if (dragging) onDragEndLatest?.invoke()
+ }
}
}
- }
}
-private fun canPlaceAnywhere(shape: Polyomino, grid: Grid): Boolean {
- for (y in 0 until Grid.SIZE) {
- for (x in 0 until Grid.SIZE) {
- if (canPlacePiece(shape, x, y, grid)) return true
- }
- }
- return false
-}
+/* ────────────────────────────── Piece rendering ───────────────────────────── */
/**
* Renders a polyomino shape as tiny 3D-like [BlockPiece] cells.
@@ -377,21 +396,16 @@ private fun MiniPiece(
val totalH = rows * cellSize + (rows - 1) * gap
val shimmer = remember(shimmerKey) { Animatable(-0.4f) }
- androidx.compose.runtime.LaunchedEffect(shimmerKey) {
- kotlinx.coroutines.delay(180)
+ LaunchedEffect(shimmerKey) {
+ delay(180)
shimmer.snapTo(-0.4f)
- shimmer.animateTo(
- targetValue = 1.4f,
- animationSpec = tween(650, easing = LinearEasing),
- )
+ shimmer.animateTo(1.4f, tween(650, easing = LinearEasing))
}
Box(
modifier = Modifier
.size(totalW, totalH)
- .graphicsLayer {
- compositingStrategy = CompositingStrategy.Offscreen
- }
+ .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
val p = shimmer.value
diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt
index 34fc048..15fd4b0 100644
--- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt
+++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt
@@ -16,9 +16,11 @@ import blockblast.composeapp.generated.resources.dark_theme
import blockblast.composeapp.generated.resources.dark_theme_subtitle
import blockblast.composeapp.generated.resources.more
import blockblast.composeapp.generated.resources.more_subtitle
+import blockblast.composeapp.generated.resources.music
+import blockblast.composeapp.generated.resources.music_subtitle
import blockblast.composeapp.generated.resources.settings
-import blockblast.composeapp.generated.resources.sound
-import blockblast.composeapp.generated.resources.sound_subtitle
+import blockblast.composeapp.generated.resources.sfx
+import blockblast.composeapp.generated.resources.sfx_subtitle
import blockblast.composeapp.generated.resources.vibration
import blockblast.composeapp.generated.resources.vibration_subtitle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
@@ -51,10 +53,20 @@ fun MainSettingsContent(component: MainSettingsComponent) {
SettingsToggleRow(
icon = NotificationsActive,
- title = stringResource(Res.string.sound),
- subtitle = stringResource(Res.string.sound_subtitle),
- checked = model.soundEnabled,
- onCheckedChange = component::onSoundToggled,
+ title = stringResource(Res.string.music),
+ subtitle = stringResource(Res.string.music_subtitle),
+ checked = model.musicEnabled,
+ onCheckedChange = component::onMusicToggled,
+ )
+
+ SettingsDivider()
+
+ SettingsToggleRow(
+ icon = NotificationsActive,
+ title = stringResource(Res.string.sfx),
+ subtitle = stringResource(Res.string.sfx_subtitle),
+ checked = model.sfxEnabled,
+ onCheckedChange = component::onSfxToggled,
)
SettingsDivider()
diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepository.kt b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepository.kt
index beece2a..8dd176a 100644
--- a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepository.kt
+++ b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepository.kt
@@ -15,8 +15,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
/**
- * Guards every SFX call with the live `soundEnabled` flag, then delegates to the
- * platform bridge.
+ * Guards every SFX/voice call with [SettingsRepository.sfxEnabled] and gates
+ * music separately on [SettingsRepository.musicEnabled].
*
* Music lifecycle is driven by:
* - [musicRequested]: a flow set true by [startMusic] (game session active)
@@ -24,7 +24,7 @@ import kotlinx.coroutines.launch
* - [appForeground]: a flow set false on [onAppBackground] and true on
* [onAppForeground]. Backgrounding the app silences music without
* forgetting that a session is active.
- * - [SettingsRepository.soundEnabled]: user preference.
+ * - [SettingsRepository.musicEnabled]: user preference (music-only).
*
* Music plays iff *all three* are true. A single coroutine collects the
* combine of those flows and serializes start/stop calls to the platform
@@ -70,7 +70,7 @@ internal class DefaultAudioRepository(
combine(
musicRequested,
appForeground,
- settings.soundEnabled,
+ settings.musicEnabled,
) { requested, foreground, enabled -> requested && foreground && enabled }
// distinctUntilChanged is critical: combine() re-emits whenever
// any upstream emits, even if the boolean output didn't change.
@@ -90,19 +90,19 @@ internal class DefaultAudioRepository(
}
}
- private inline fun ifEnabled(block: () -> Unit) {
- if (settings.soundEnabled.value) block()
+ private inline fun ifSfxEnabled(block: () -> Unit) {
+ if (settings.sfxEnabled.value) block()
}
- override suspend fun playPlacementSound() = ifEnabled { player.playPlacement() }
+ override suspend fun playPlacementSound() = ifSfxEnabled { player.playPlacement() }
- override suspend fun playClearSound(lines: Int) = ifEnabled { player.playClear(lines) }
+ override suspend fun playClearSound(lines: Int) = ifSfxEnabled { player.playClear(lines) }
override suspend fun playVoiceFeedback(type: FeedbackType) =
- ifEnabled { player.playVoiceFeedback(type) }
+ ifSfxEnabled { player.playVoiceFeedback(type) }
override suspend fun playVoiceCombo(combo: Int) =
- ifEnabled { player.playVoiceCombo(combo) }
+ ifSfxEnabled { player.playVoiceCombo(combo) }
override suspend fun startMusic() {
musicRequested.value = true
diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt
index 3af3d51..f5151d2 100644
--- a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt
+++ b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt
@@ -42,8 +42,18 @@ internal class SettingsBackedSettingsRepository(
private val writeMutex = Mutex()
- override val soundEnabled: StateFlow =
- settings.getBooleanStateFlow(scope, KEY_SOUND, defaultValue = true)
+ init {
+ // 1.5.0 migration: prior versions had a single KEY_SOUND_LEGACY flag
+ // that gated both music and SFX. Seed each new key from it once so an
+ // existing "muted" user stays muted on the first launch after upgrade.
+ migrateLegacySoundFlag()
+ }
+
+ override val musicEnabled: StateFlow =
+ settings.getBooleanStateFlow(scope, KEY_MUSIC, defaultValue = true)
+
+ override val sfxEnabled: StateFlow =
+ settings.getBooleanStateFlow(scope, KEY_SFX, defaultValue = true)
override val vibrationEnabled: StateFlow =
settings.getBooleanStateFlow(scope, KEY_VIBRATION, defaultValue = true)
@@ -60,8 +70,12 @@ internal class SettingsBackedSettingsRepository(
override val tutorialSeen: StateFlow =
settings.getBooleanStateFlow(scope, KEY_TUTORIAL_SEEN, defaultValue = false)
- override suspend fun setSoundEnabled(enabled: Boolean) = withContext(dispatchers.io) {
- settings.putBoolean(KEY_SOUND, enabled)
+ override suspend fun setMusicEnabled(enabled: Boolean) = withContext(dispatchers.io) {
+ settings.putBoolean(KEY_MUSIC, enabled)
+ }
+
+ override suspend fun setSfxEnabled(enabled: Boolean) = withContext(dispatchers.io) {
+ settings.putBoolean(KEY_SFX, enabled)
}
override suspend fun setVibrationEnabled(enabled: Boolean) = withContext(dispatchers.io) {
@@ -100,8 +114,23 @@ internal class SettingsBackedSettingsRepository(
settings.putBoolean(KEY_TUTORIAL_SEEN, true)
}
+ /**
+ * If a legacy single-flag value is present and neither new key has been
+ * written, copy the legacy value into both. Idempotent: the legacy key is
+ * removed afterwards so this runs at most once per device.
+ */
+ private fun migrateLegacySoundFlag() {
+ if (!settings.hasKey(KEY_SOUND_LEGACY)) return
+ val legacy = settings.getBoolean(KEY_SOUND_LEGACY, true)
+ if (!settings.hasKey(KEY_MUSIC)) settings.putBoolean(KEY_MUSIC, legacy)
+ if (!settings.hasKey(KEY_SFX)) settings.putBoolean(KEY_SFX, legacy)
+ settings.remove(KEY_SOUND_LEGACY)
+ }
+
private companion object {
- const val KEY_SOUND = "blockblast.sound"
+ const val KEY_MUSIC = "blockblast.music"
+ const val KEY_SFX = "blockblast.sfx"
+ const val KEY_SOUND_LEGACY = "blockblast.sound"
const val KEY_VIBRATION = "blockblast.vibration"
const val KEY_DARK = "blockblast.dark_theme"
const val KEY_BEST_SCORE = "blockblast.best_score"
diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt
index 624925c..8c99b21 100644
--- a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt
+++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt
@@ -23,10 +23,11 @@ class DefaultAudioRepositoryTest {
* transitions, so we snapshot and discard that initial emission.
*/
private fun setup(
- sound: Boolean = true,
+ music: Boolean = true,
+ sfx: Boolean = true,
): Triple {
val player = RecordingPlayer()
- val settings = FakeSettings(soundEnabled = sound)
+ val settings = FakeSettings(musicEnabled = music, sfxEnabled = sfx)
val scope = CoroutineScope(UnconfinedTestDispatcher())
val repo = DefaultAudioRepository(player, settings, scope)
player.calls.clear()
@@ -43,12 +44,19 @@ class DefaultAudioRepositoryTest {
}
@Test
- fun startMusic_does_not_start_when_sound_off() = runTest {
- val (repo, player) = setup(sound = false)
+ fun startMusic_does_not_start_when_music_off() = runTest {
+ val (repo, player) = setup(music = false)
repo.startMusic()
assertTrue(player.calls.isEmpty())
}
+ @Test
+ fun startMusic_starts_when_music_on_even_if_sfx_off() = runTest {
+ val (repo, player) = setup(music = true, sfx = false)
+ repo.startMusic()
+ assertEquals(listOf(PlayerCall.Start), player.calls)
+ }
+
@Test
fun stopMusic_stops_active_music() = runTest {
val (repo, player) = setup()
@@ -79,17 +87,27 @@ class DefaultAudioRepositoryTest {
}
@Test
- fun toggling_sound_off_then_on_during_music_stops_then_starts() = runTest {
+ fun toggling_music_off_then_on_during_music_stops_then_starts() = runTest {
val (repo, player, settings) = setup()
repo.startMusic()
- settings.soundFlow.value = false
- settings.soundFlow.value = true
+ settings.musicFlow.value = false
+ settings.musicFlow.value = true
assertEquals(
listOf(PlayerCall.Start, PlayerCall.Stop, PlayerCall.Start),
player.calls,
)
}
+ @Test
+ fun toggling_sfx_does_not_affect_music_playback() = runTest {
+ val (repo, player, settings) = setup()
+ repo.startMusic()
+ val baseline = player.calls.toList()
+ settings.sfxFlow.value = false
+ settings.sfxFlow.value = true
+ assertEquals(baseline, player.calls)
+ }
+
@Test
fun stopMusic_while_backgrounded_does_not_emit_extra_stop() = runTest {
val (repo, player) = setup()
@@ -118,7 +136,7 @@ class DefaultAudioRepositoryTest {
@Test
fun sfx_silent_when_disabled() = runTest {
- val (repo, player) = setup(sound = false)
+ val (repo, player) = setup(sfx = false)
repo.playPlacementSound()
repo.playClearSound(2)
repo.playVoiceFeedback(FeedbackType.GOOD)
@@ -148,15 +166,21 @@ class DefaultAudioRepositoryTest {
override fun release() {}
}
- private class FakeSettings(soundEnabled: Boolean = true) : SettingsRepository {
- val soundFlow = MutableStateFlow(soundEnabled)
- override val soundEnabled: StateFlow = soundFlow.asStateFlow()
+ private class FakeSettings(
+ musicEnabled: Boolean = true,
+ sfxEnabled: Boolean = true,
+ ) : SettingsRepository {
+ val musicFlow = MutableStateFlow(musicEnabled)
+ val sfxFlow = MutableStateFlow(sfxEnabled)
+ override val musicEnabled: StateFlow = musicFlow.asStateFlow()
+ override val sfxEnabled: StateFlow = sfxFlow.asStateFlow()
override val vibrationEnabled = MutableStateFlow(true).asStateFlow()
override val darkTheme = MutableStateFlow(false).asStateFlow()
override val bestScore = MutableStateFlow(0L).asStateFlow()
override val reviewPromptCount = MutableStateFlow(0).asStateFlow()
override val tutorialSeen = MutableStateFlow(false).asStateFlow()
- override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled }
+ override suspend fun setMusicEnabled(enabled: Boolean) { musicFlow.value = enabled }
+ override suspend fun setSfxEnabled(enabled: Boolean) { sfxFlow.value = enabled }
override suspend fun setVibrationEnabled(enabled: Boolean) {}
override suspend fun setDarkTheme(enabled: Boolean) {}
override suspend fun setBestScore(score: Long) {}
diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt
index b3b0f78..785c69c 100644
--- a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt
+++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt
@@ -63,13 +63,15 @@ class DefaultVibrationRepositoryTest {
private class FakeSettings(vibration: Boolean) : SettingsRepository {
val vibrationFlow = MutableStateFlow(vibration)
- override val soundEnabled = MutableStateFlow(true).asStateFlow()
+ override val musicEnabled = MutableStateFlow(true).asStateFlow()
+ override val sfxEnabled = MutableStateFlow(true).asStateFlow()
override val vibrationEnabled: StateFlow = vibrationFlow.asStateFlow()
override val darkTheme = MutableStateFlow(false).asStateFlow()
override val bestScore = MutableStateFlow(0L).asStateFlow()
override val reviewPromptCount = MutableStateFlow(0).asStateFlow()
override val tutorialSeen = MutableStateFlow(false).asStateFlow()
- override suspend fun setSoundEnabled(enabled: Boolean) {}
+ override suspend fun setMusicEnabled(enabled: Boolean) {}
+ override suspend fun setSfxEnabled(enabled: Boolean) {}
override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled }
override suspend fun setDarkTheme(enabled: Boolean) {}
override suspend fun setBestScore(score: Long) {}
diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt
index 0c7bcd3..7dff266 100644
--- a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt
+++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt
@@ -37,7 +37,8 @@ class SettingsBackedSettingsRepositoryTest {
@Test
fun defaults() {
- assertTrue(repo.soundEnabled.value)
+ assertTrue(repo.musicEnabled.value)
+ assertTrue(repo.sfxEnabled.value)
assertTrue(repo.vibrationEnabled.value)
assertFalse(repo.darkTheme.value)
assertEquals(0L, repo.bestScore.value)
@@ -46,9 +47,41 @@ class SettingsBackedSettingsRepositoryTest {
}
@Test
- fun setSoundEnabled_updates_flow() = runTest {
- repo.setSoundEnabled(false)
- assertFalse(repo.soundEnabled.value)
+ fun setMusicEnabled_updates_flow_without_touching_sfx() = runTest {
+ repo.setMusicEnabled(false)
+ assertFalse(repo.musicEnabled.value)
+ assertTrue(repo.sfxEnabled.value)
+ }
+
+ @Test
+ fun setSfxEnabled_updates_flow_without_touching_music() = runTest {
+ repo.setSfxEnabled(false)
+ assertFalse(repo.sfxEnabled.value)
+ assertTrue(repo.musicEnabled.value)
+ }
+
+ @Test
+ fun migrates_legacy_sound_flag_into_both_keys() = runTest {
+ // Simulate an upgrade from a pre-1.5 install with sound = false.
+ val legacySettings = MapSettings().apply { putBoolean("blockblast.sound", false) }
+ val migrated = SettingsBackedSettingsRepository(
+ settings = legacySettings,
+ scope = scope,
+ dispatchers = AppDispatchers(default = Dispatchers.Unconfined, io = Dispatchers.Unconfined),
+ )
+ assertFalse(migrated.musicEnabled.value)
+ assertFalse(migrated.sfxEnabled.value)
+ }
+
+ @Test
+ fun migration_runs_only_once() = runTest {
+ val sharedSettings = MapSettings().apply { putBoolean("blockblast.sound", false) }
+ SettingsBackedSettingsRepository(sharedSettings, scope, AppDispatchers(Dispatchers.Unconfined, Dispatchers.Unconfined))
+ // User re-enables music explicitly after migration.
+ sharedSettings.putBoolean("blockblast.music", true)
+ // Second construction (e.g. process restart) must not overwrite that.
+ val again = SettingsBackedSettingsRepository(sharedSettings, scope, AppDispatchers(Dispatchers.Unconfined, Dispatchers.Unconfined))
+ assertTrue(again.musicEnabled.value)
}
@Test
diff --git a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/AnalyticRepositoryImpl.kt b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/AnalyticRepository.kt
similarity index 100%
rename from core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/AnalyticRepositoryImpl.kt
rename to core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/AnalyticRepository.kt
diff --git a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt
index e622dd8..c0e56b1 100644
--- a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt
+++ b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt
@@ -3,7 +3,12 @@ package ge.yet.blokblast.domain.repository
import kotlinx.coroutines.flow.StateFlow
interface SettingsRepository {
- val soundEnabled: StateFlow
+ /** Background music gate. Independent of [sfxEnabled] since v1.5.0. */
+ val musicEnabled: StateFlow
+
+ /** SFX + voice-line gate (piece placement, line clear, combo voice). */
+ val sfxEnabled: StateFlow
+
val vibrationEnabled: StateFlow
val darkTheme: StateFlow
@@ -16,7 +21,8 @@ interface SettingsRepository {
/** Whether the user has seen (or dismissed) the first-launch tutorial. */
val tutorialSeen: StateFlow
- suspend fun setSoundEnabled(enabled: Boolean)
+ suspend fun setMusicEnabled(enabled: Boolean)
+ suspend fun setSfxEnabled(enabled: Boolean)
suspend fun setVibrationEnabled(enabled: Boolean)
suspend fun setDarkTheme(enabled: Boolean)
diff --git a/fastlane/metadata/android/en-US/changelogs/12.txt b/fastlane/metadata/android/en-US/changelogs/12.txt
new file mode 100644
index 0000000..f3b9c4b
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/12.txt
@@ -0,0 +1 @@
+New-player welcome, reimagined! 🖐️ The first-launch tutorial now shows you the ropes with an animated hand instead of walls of text — just watch the gesture, then play (with a little confetti to celebrate your first move 🎉). 🎵 Music and sound effects split into separate toggles, so you can mute the tunes while keeping those satisfying clicks. The tray also slides more smoothly as pieces settle, and there's the usual round of speed-ups under the hood. Happy puzzling! 🧩✨
diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt
index e23837e..b04e592 100644
--- a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt
+++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt
@@ -4,6 +4,7 @@ import com.app.common.config.AppConfig
import com.app.common.decompose.asValue
import com.app.common.decompose.coroutineScope
import com.arkivanov.decompose.ComponentContext
+import com.arkivanov.decompose.childContext
import com.arkivanov.decompose.router.slot.ChildSlot
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
@@ -18,6 +19,8 @@ import dev.zacsweers.metro.Inject
import ge.yet.blockblast.feature.game.integration.stateToModel
import ge.yet.blockblast.feature.game.reviewprompt.DefaultReviewPromptComponent
import ge.yet.blockblast.feature.game.store.GameAnalyticsLogger
+import ge.yet.blockblast.feature.game.tray.DefaultPieceTrayComponent
+import ge.yet.blockblast.feature.game.tray.PieceTrayComponent
import ge.yet.blockblast.feature.game.store.GameStore
import ge.yet.blockblast.feature.game.store.GameStoreFactory
import ge.yet.blockblast.feature.settings.SettingsComponent
@@ -47,6 +50,11 @@ internal class DefaultGameComponent(
override val model: Value = store.asValue().map(stateToModel)
+ override val pieceTray: PieceTrayComponent = DefaultPieceTrayComponent(
+ componentContext = childContext(key = "PieceTray"),
+ state = store.asValue().map { it.game },
+ )
+
override val sheetSlot: Value> =
childSlot(
source = sheetNavigation,
diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt
index 2b43692..f3b05ad 100644
--- a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt
+++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt
@@ -4,6 +4,7 @@ import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.router.slot.ChildSlot
import com.arkivanov.decompose.value.Value
import ge.yet.blockblast.feature.game.reviewprompt.ReviewPromptComponent
+import ge.yet.blockblast.feature.game.tray.PieceTrayComponent
import ge.yet.blockblast.feature.settings.SettingsComponent
import ge.yet.blokblast.domain.model.GameState
@@ -20,6 +21,8 @@ interface GameComponent {
val sheetSlot: Value>
+ val pieceTray: PieceTrayComponent
+
data class Model(
val game: GameState,
val continueCountdown: Int,
diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponent.kt
new file mode 100644
index 0000000..8bcf1fa
--- /dev/null
+++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponent.kt
@@ -0,0 +1,103 @@
+package ge.yet.blockblast.feature.game.tray
+
+import com.arkivanov.decompose.ComponentContext
+import com.arkivanov.decompose.value.MutableValue
+import com.arkivanov.decompose.value.Value
+import com.arkivanov.essenty.lifecycle.doOnDestroy
+import ge.yet.blokblast.domain.model.GameState
+import ge.yet.blokblast.domain.model.Grid
+import ge.yet.blokblast.domain.model.Piece
+import ge.yet.blokblast.domain.model.Polyomino
+
+/**
+ * Reconciles engine emissions of `currentPieces` (already compacted) onto a
+ * variable-length list of slot components. Identity is keyed by [Piece.pieceId]
+ * — when the engine re-emits with a piece still alive, the same
+ * [DefaultTraySlotComponent] instance is reused, preserving its per-slot UI
+ * animation state across reorderings.
+ */
+internal class DefaultPieceTrayComponent(
+ componentContext: ComponentContext,
+ state: Value,
+) : PieceTrayComponent, ComponentContext by componentContext {
+
+ private val slotsState = MutableValue>(emptyList())
+ override val slots: Value> = slotsState
+
+ private val selectionState = MutableValue(TraySelection.NONE)
+ override val selection: Value = selectionState
+
+ init {
+ val cancellation = state.subscribe { reconcile(it.currentPieces, it.grid) }
+ lifecycle.doOnDestroy { cancellation.cancel() }
+ }
+
+ override fun clearSelection() {
+ if (selectionState.value != TraySelection.NONE) selectionState.value = TraySelection.NONE
+ }
+
+ private fun toggleSelection(pieceId: Long) {
+ val currentlySelected = selectionState.value.piece?.pieceId
+ selectionState.value = when (currentlySelected) {
+ pieceId -> TraySelection.NONE
+ else -> {
+ val piece = slotsState.value.firstOrNull { it.piece.pieceId == pieceId }?.piece
+ if (piece != null) TraySelection(piece) else TraySelection.NONE
+ }
+ }
+ }
+
+ private fun reconcile(currentPieces: List, grid: Grid) {
+ // Re-use existing slot components keyed by pieceId. Survivors keep
+ // their instance (and UI animation state); placed pieces drop out.
+ @Suppress("UNCHECKED_CAST")
+ val existing: Map =
+ (slotsState.value as List)
+ .associateBy { it.piece.pieceId }
+
+ val nextSlots = currentPieces.mapIndexed { index, piece ->
+ existing[piece.pieceId] ?: newSlot(piece, spawnIndex = index)
+ }
+
+ // Refresh canFit on every survivor — the grid can have changed under
+ // a stationary piece (line clear) since the last emission.
+ nextSlots.forEach { it.updateCanFit(canPlaceAnywhere(it.piece.shape, grid)) }
+
+ // Drop a now-invalid selection (selected piece was placed, or a full
+ // refill swapped it out from under the user).
+ val livePieceIds = currentPieces.mapTo(HashSet(currentPieces.size)) { it.pieceId }
+ val selectedPieceId = selectionState.value.piece?.pieceId
+ if (selectedPieceId != null && selectedPieceId !in livePieceIds) {
+ selectionState.value = TraySelection.NONE
+ }
+
+ slotsState.value = nextSlots
+ }
+
+ private fun newSlot(piece: Piece, spawnIndex: Int): DefaultTraySlotComponent =
+ DefaultTraySlotComponent(
+ piece = piece,
+ spawnIndex = spawnIndex,
+ selection = selectionState,
+ onToggleSelection = ::toggleSelection,
+ )
+}
+
+/** True if [shape] has at least one valid placement anywhere on [grid]. */
+private fun canPlaceAnywhere(shape: Polyomino, grid: Grid): Boolean {
+ for (y in 0 until Grid.SIZE) {
+ for (x in 0 until Grid.SIZE) {
+ if (canPlaceAt(shape, x, y, grid)) return true
+ }
+ }
+ return false
+}
+
+private fun canPlaceAt(shape: Polyomino, x: Int, y: Int, grid: Grid): Boolean {
+ for (cell in shape.cells) {
+ val gx = x + cell.x
+ val gy = y + cell.y
+ if (!grid.inBounds(gx, gy) || !grid.isEmpty(gx, gy)) return false
+ }
+ return true
+}
diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultTraySlotComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultTraySlotComponent.kt
new file mode 100644
index 0000000..84b0453
--- /dev/null
+++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultTraySlotComponent.kt
@@ -0,0 +1,28 @@
+package ge.yet.blockblast.feature.game.tray
+
+import com.arkivanov.decompose.value.MutableValue
+import com.arkivanov.decompose.value.Value
+import com.arkivanov.decompose.value.operator.map
+import ge.yet.blokblast.domain.model.Piece
+
+internal class DefaultTraySlotComponent(
+ override val piece: Piece,
+ override val spawnIndex: Int,
+ selection: Value,
+ private val onToggleSelection: (Long) -> Unit,
+) : TraySlotComponent {
+
+ private val canFitState = MutableValue(true)
+ override val canFit: Value = canFitState
+
+ override val isSelected: Value =
+ selection.map { it.piece?.pieceId == piece.pieceId }
+
+ override fun onTap() {
+ onToggleSelection(piece.pieceId)
+ }
+
+ fun updateCanFit(value: Boolean) {
+ if (canFitState.value != value) canFitState.value = value
+ }
+}
diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/PieceTrayComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/PieceTrayComponent.kt
new file mode 100644
index 0000000..a559d11
--- /dev/null
+++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/PieceTrayComponent.kt
@@ -0,0 +1,35 @@
+package ge.yet.blockblast.feature.game.tray
+
+import com.arkivanov.decompose.value.Value
+import ge.yet.blokblast.domain.model.Piece
+
+/**
+ * Bottom-of-screen tray that holds up to three pieces. Mirrors the engine's
+ * compacted `currentPieces` list while keeping the per-piece component
+ * instance stable across emissions (keyed by [Piece.pieceId]), so the UI can
+ * animate position changes without resetting per-slot animation state.
+ */
+interface PieceTrayComponent {
+ /**
+ * Compacted list of 0..3 slots — same order and length as the engine's
+ * `currentPieces`. Slot identity is keyed by `pieceId`, so when a piece is
+ * placed its [TraySlotComponent] is dropped from the list while survivors
+ * retain their existing instances at their new (shifted) indices.
+ */
+ val slots: Value>
+
+ /**
+ * Wrapped because [Value] forbids nullable type arguments — see
+ * [TraySelection.piece] for the contained piece, if any.
+ */
+ val selection: Value
+
+ fun clearSelection()
+}
+
+/** Non-null wrapper around an optional tray selection. */
+data class TraySelection(val piece: Piece? = null) {
+ companion object {
+ val NONE = TraySelection()
+ }
+}
diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/TraySlotComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/TraySlotComponent.kt
new file mode 100644
index 0000000..a611791
--- /dev/null
+++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/TraySlotComponent.kt
@@ -0,0 +1,30 @@
+package ge.yet.blockblast.feature.game.tray
+
+import com.arkivanov.decompose.value.Value
+import ge.yet.blokblast.domain.model.Piece
+
+/**
+ * One piece in the tray. Identity (`==`) is stable across engine emissions as
+ * long as the same [Piece.pieceId] is still in play — placing the piece drops
+ * the component, while merely reordering the tray (e.g. neighbours placed)
+ * keeps this instance alive so its UI-side animation state survives.
+ */
+interface TraySlotComponent {
+ val piece: Piece
+
+ /**
+ * Index this slot held at the moment it was created — used by the
+ * entrance animation to pick a fly-in direction (left/right/bottom). Stays
+ * fixed for the lifetime of the slot; the *current* index can shift if
+ * neighbours are placed.
+ */
+ val spawnIndex: Int
+
+ val isSelected: Value
+
+ /** Whether [piece] can fit anywhere on the current grid. */
+ val canFit: Value
+
+ /** Toggle selection of this slot's piece. */
+ fun onTap()
+}
diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt
index 4b95256..79433bd 100644
--- a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt
+++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt
@@ -226,13 +226,15 @@ class DefaultGameComponentTest {
private class FakeSettings(bestScore: Long = 0L, reviewPromptCount: Int = 0) : SettingsRepository {
private val bestScoreFlow = MutableStateFlow(bestScore)
private val reviewFlow = MutableStateFlow(reviewPromptCount)
- override val soundEnabled = MutableStateFlow(true).asStateFlow()
+ override val musicEnabled = MutableStateFlow(true).asStateFlow()
+ override val sfxEnabled = MutableStateFlow(true).asStateFlow()
override val vibrationEnabled = MutableStateFlow(true).asStateFlow()
override val darkTheme = MutableStateFlow(false).asStateFlow()
override val bestScore: StateFlow = bestScoreFlow.asStateFlow()
override val reviewPromptCount: StateFlow = reviewFlow.asStateFlow()
override val tutorialSeen = MutableStateFlow(false).asStateFlow()
- override suspend fun setSoundEnabled(enabled: Boolean) {}
+ override suspend fun setMusicEnabled(enabled: Boolean) {}
+ override suspend fun setSfxEnabled(enabled: Boolean) {}
override suspend fun setVibrationEnabled(enabled: Boolean) {}
override suspend fun setDarkTheme(enabled: Boolean) {}
override suspend fun setBestScore(score: Long) {
diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt
index 7251fe7..84d1009 100644
--- a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt
+++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt
@@ -456,13 +456,15 @@ private class FakeSettings(
) : SettingsRepository {
private val bestScoreFlow = MutableStateFlow(bestScore)
private val reviewFlow = MutableStateFlow(reviewPromptCount)
- override val soundEnabled = MutableStateFlow(true).asStateFlow()
+ override val musicEnabled = MutableStateFlow(true).asStateFlow()
+ override val sfxEnabled = MutableStateFlow(true).asStateFlow()
override val vibrationEnabled = MutableStateFlow(true).asStateFlow()
override val darkTheme = MutableStateFlow(false).asStateFlow()
override val bestScore: StateFlow = bestScoreFlow.asStateFlow()
override val reviewPromptCount: StateFlow = reviewFlow.asStateFlow()
override val tutorialSeen = MutableStateFlow(false).asStateFlow()
- override suspend fun setSoundEnabled(enabled: Boolean) {}
+ override suspend fun setMusicEnabled(enabled: Boolean) {}
+ override suspend fun setSfxEnabled(enabled: Boolean) {}
override suspend fun setVibrationEnabled(enabled: Boolean) {}
override suspend fun setDarkTheme(enabled: Boolean) {}
override suspend fun setBestScore(score: Long) { if (score > bestScoreFlow.value) bestScoreFlow.value = score }
diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponentTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponentTest.kt
new file mode 100644
index 0000000..2000e39
--- /dev/null
+++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponentTest.kt
@@ -0,0 +1,93 @@
+package ge.yet.blockblast.feature.game.tray
+
+import com.arkivanov.decompose.DefaultComponentContext
+import com.arkivanov.decompose.value.MutableValue
+import com.arkivanov.essenty.lifecycle.LifecycleRegistry
+import com.arkivanov.essenty.lifecycle.resume
+import ge.yet.blokblast.domain.model.GameState
+import ge.yet.blokblast.domain.model.Grid
+import ge.yet.blokblast.domain.model.Piece
+import ge.yet.blokblast.domain.model.Polyomino
+import ge.yet.blokblast.domain.model.Position
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotSame
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+
+class DefaultPieceTrayComponentTest {
+
+ private val dot = Polyomino(id = "dot", cells = listOf(Position(0, 0)))
+ private fun piece(id: Long) = Piece(pieceId = id, shape = dot, colorId = 0)
+
+ private fun build(initial: List): Pair> {
+ val state = MutableValue(GameState(grid = Grid(), currentPieces = initial))
+ val lifecycle = LifecycleRegistry().apply { resume() }
+ val ctx = DefaultComponentContext(lifecycle)
+ val component = DefaultPieceTrayComponent(ctx, state)
+ return component to state
+ }
+
+ @Test
+ fun fresh_tray_emits_pieces_in_engine_order() {
+ val (component, _) = build(listOf(piece(1), piece(2), piece(3)))
+ val slots = component.slots.value
+ assertEquals(listOf(1L, 2L, 3L), slots.map { it.piece.pieceId })
+ assertEquals(listOf(0, 1, 2), slots.map { it.spawnIndex })
+ }
+
+ @Test
+ fun placing_middle_piece_keeps_survivor_component_instances() {
+ val (component, state) = build(listOf(piece(1), piece(2), piece(3)))
+ val originalA = component.slots.value[0]
+ val originalC = component.slots.value[2]
+
+ // Engine compacts: [1, 2, 3] → place 2 → [1, 3]
+ state.value = state.value.copy(currentPieces = listOf(piece(1), piece(3)))
+
+ val slots = component.slots.value
+ assertEquals(2, slots.size)
+ assertSame(originalA, slots[0])
+ assertSame(originalC, slots[1]) // C shifted from index 2 → 1, instance preserved
+ }
+
+ @Test
+ fun full_refill_creates_new_slot_instances() {
+ val (component, state) = build(listOf(piece(1), piece(2), piece(3)))
+ val before = component.slots.value.toList()
+
+ state.value = state.value.copy(currentPieces = emptyList())
+ assertEquals(emptyList(), component.slots.value)
+
+ state.value = state.value.copy(currentPieces = listOf(piece(10), piece(20), piece(30)))
+ val after = component.slots.value
+ assertEquals(listOf(10L, 20L, 30L), after.map { it.piece.pieceId })
+ before.zip(after).forEach { (b, a) -> assertNotSame(b, a) }
+ }
+
+ @Test
+ fun tap_toggles_selection_and_clearSelection_resets() {
+ val (component, _) = build(listOf(piece(1), piece(2), piece(3)))
+ val slot1 = component.slots.value[1]
+ slot1.onTap()
+ assertEquals(2L, component.selection.value.piece?.pieceId)
+ assertEquals(true, slot1.isSelected.value)
+
+ slot1.onTap()
+ assertNull(component.selection.value.piece)
+
+ slot1.onTap()
+ component.clearSelection()
+ assertNull(component.selection.value.piece)
+ }
+
+ @Test
+ fun placing_selected_piece_clears_selection() {
+ val (component, state) = build(listOf(piece(1), piece(2), piece(3)))
+ component.slots.value[1].onTap()
+ assertEquals(2L, component.selection.value.piece?.pieceId)
+
+ state.value = state.value.copy(currentPieces = listOf(piece(1), piece(3)))
+ assertNull(component.selection.value.piece)
+ }
+}
diff --git a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt
index 708c692..724c3d8 100644
--- a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt
+++ b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt
@@ -131,13 +131,15 @@ class DefaultHomeComponentTest {
}
private class StubSettings(bestScore: Long) : SettingsRepository {
- override val soundEnabled = MutableStateFlow(true).asStateFlow()
+ override val musicEnabled = MutableStateFlow(true).asStateFlow()
+ override val sfxEnabled = MutableStateFlow(true).asStateFlow()
override val vibrationEnabled = MutableStateFlow(true).asStateFlow()
override val darkTheme = MutableStateFlow(false).asStateFlow()
override val bestScore = MutableStateFlow(bestScore).asStateFlow()
override val reviewPromptCount = MutableStateFlow(0).asStateFlow()
override val tutorialSeen = MutableStateFlow(false).asStateFlow()
- override suspend fun setSoundEnabled(enabled: Boolean) {}
+ override suspend fun setMusicEnabled(enabled: Boolean) {}
+ override suspend fun setSfxEnabled(enabled: Boolean) {}
override suspend fun setVibrationEnabled(enabled: Boolean) {}
override suspend fun setDarkTheme(enabled: Boolean) {}
override suspend fun setBestScore(score: Long) {}
diff --git a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt
index 6201c8b..42dfa0e 100644
--- a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt
+++ b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt
@@ -125,13 +125,15 @@ class HomeStoreFactoryTest {
}
private class StubSettings(bestScore: Long) : SettingsRepository {
- override val soundEnabled = MutableStateFlow(true).asStateFlow()
+ override val musicEnabled = MutableStateFlow(true).asStateFlow()
+ override val sfxEnabled = MutableStateFlow(true).asStateFlow()
override val vibrationEnabled = MutableStateFlow(true).asStateFlow()
override val darkTheme = MutableStateFlow(false).asStateFlow()
override val bestScore = MutableStateFlow(bestScore).asStateFlow()
override val reviewPromptCount = MutableStateFlow(0).asStateFlow()
override val tutorialSeen = MutableStateFlow(false).asStateFlow()
- override suspend fun setSoundEnabled(enabled: Boolean) {}
+ override suspend fun setMusicEnabled(enabled: Boolean) {}
+ override suspend fun setSfxEnabled(enabled: Boolean) {}
override suspend fun setVibrationEnabled(enabled: Boolean) {}
override suspend fun setDarkTheme(enabled: Boolean) {}
override suspend fun setBestScore(score: Long) {}
diff --git a/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponent.kt b/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponent.kt
index 7550b64..aff674a 100644
--- a/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponent.kt
+++ b/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponent.kt
@@ -40,7 +40,7 @@ internal class DefaultRootComponent(
override val darkTheme: StateFlow = settingsRepository.darkTheme
override val vibrationEnabled: StateFlow = settingsRepository.vibrationEnabled
- override val soundEnabled: StateFlow = settingsRepository.soundEnabled
+ override val sfxEnabled: StateFlow = settingsRepository.sfxEnabled
override val tutorialSeen: StateFlow = settingsRepository.tutorialSeen
override fun onTutorialSeen() {
diff --git a/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/RootComponent.kt b/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/RootComponent.kt
index 49b1b75..4ac3b85 100644
--- a/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/RootComponent.kt
+++ b/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/RootComponent.kt
@@ -24,8 +24,8 @@ interface RootComponent : BackHandlerOwner {
/** Whether haptic feedback is enabled (mirrors Settings toggle). */
val vibrationEnabled: StateFlow
- /** Whether sound effects are enabled (mirrors Settings toggle). */
- val soundEnabled: StateFlow
+ /** Whether SFX / voice feedback are enabled (mirrors Settings toggle). */
+ val sfxEnabled: StateFlow
/** Whether the first-launch tutorial has already been seen / dismissed. */
val tutorialSeen: StateFlow
diff --git a/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt
index dff5b46..9988a2d 100644
--- a/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt
+++ b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt
@@ -44,13 +44,13 @@ class DefaultRootComponentTest {
}
@Test
- fun darkTheme_vibration_sound_tutorial_flows_mirror_settings() {
+ fun darkTheme_vibration_sfx_tutorial_flows_mirror_settings() {
val (component, _, _, settings, _, _) = build()
assertFalse(component.darkTheme.value)
settings.darkFlow.value = true
assertTrue(component.darkTheme.value)
- settings.soundFlow.value = false
- assertFalse(component.soundEnabled.value)
+ settings.sfxFlow.value = false
+ assertFalse(component.sfxEnabled.value)
settings.vibrationFlow.value = false
assertFalse(component.vibrationEnabled.value)
settings.tutorialFlow.value = true
@@ -161,6 +161,15 @@ class DefaultRootComponentTest {
override val sheetSlot = com.arkivanov.decompose.value.MutableValue(
com.arkivanov.decompose.router.slot.ChildSlot(child = null),
)
+ override val pieceTray: ge.yet.blockblast.feature.game.tray.PieceTrayComponent =
+ object : ge.yet.blockblast.feature.game.tray.PieceTrayComponent {
+ override val slots = com.arkivanov.decompose.value.MutableValue(
+ emptyList(),
+ )
+ override val selection =
+ com.arkivanov.decompose.value.MutableValue(ge.yet.blockblast.feature.game.tray.TraySelection.NONE)
+ override fun clearSelection() {}
+ }
override fun onCellClicked(pieceId: Long, x: Int, y: Int) {}
override fun onReviveClicked() {}
override fun onRestartClicked() {}
@@ -183,17 +192,20 @@ class DefaultRootComponentTest {
}
private class FakeSettings : SettingsRepository {
- val soundFlow = MutableStateFlow(true)
+ val musicFlow = MutableStateFlow(true)
+ val sfxFlow = MutableStateFlow(true)
val vibrationFlow = MutableStateFlow(true)
val darkFlow = MutableStateFlow(false)
val tutorialFlow = MutableStateFlow(false)
- override val soundEnabled = soundFlow.asStateFlow()
+ override val musicEnabled = musicFlow.asStateFlow()
+ override val sfxEnabled = sfxFlow.asStateFlow()
override val vibrationEnabled = vibrationFlow.asStateFlow()
override val darkTheme = darkFlow.asStateFlow()
override val tutorialSeen = tutorialFlow.asStateFlow()
override val bestScore = MutableStateFlow(0L).asStateFlow()
override val reviewPromptCount = MutableStateFlow(0).asStateFlow()
- override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled }
+ override suspend fun setMusicEnabled(enabled: Boolean) { musicFlow.value = enabled }
+ override suspend fun setSfxEnabled(enabled: Boolean) { sfxFlow.value = enabled }
override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled }
override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled }
override suspend fun setBestScore(score: Long) {}
diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt
index 11f3193..9d4391e 100644
--- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt
+++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt
@@ -17,8 +17,12 @@ internal class DefaultMainSettingsComponent(
override val model: Value =
store.asValue().map(stateToModel)
- override fun onSoundToggled(enabled: Boolean) {
- store.accept(SettingsStore.Intent.SetSound(enabled))
+ override fun onMusicToggled(enabled: Boolean) {
+ store.accept(SettingsStore.Intent.SetMusic(enabled))
+ }
+
+ override fun onSfxToggled(enabled: Boolean) {
+ store.accept(SettingsStore.Intent.SetSfx(enabled))
}
override fun onVibrationToggled(enabled: Boolean) {
diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt
index c4aa6de..0defab7 100644
--- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt
+++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt
@@ -6,14 +6,16 @@ interface MainSettingsComponent {
val model: Value
- fun onSoundToggled(enabled: Boolean)
+ fun onMusicToggled(enabled: Boolean)
+ fun onSfxToggled(enabled: Boolean)
fun onVibrationToggled(enabled: Boolean)
fun onDarkThemeToggled(enabled: Boolean)
fun onMoreClicked()
fun onBackClicked()
data class Model(
- val soundEnabled: Boolean,
+ val musicEnabled: Boolean,
+ val sfxEnabled: Boolean,
val vibrationEnabled: Boolean,
val darkTheme: Boolean,
)
diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt
index cc902a3..510746f 100644
--- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt
+++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt
@@ -6,7 +6,8 @@ import ge.yet.blockblast.feature.settings.main.store.SettingsStore
internal val stateToModel: (SettingsStore.State) -> MainSettingsComponent.Model =
{ state ->
MainSettingsComponent.Model(
- soundEnabled = state.sound,
+ musicEnabled = state.music,
+ sfxEnabled = state.sfx,
vibrationEnabled = state.vibration,
darkTheme = state.dark,
)
diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt
index c5e7b07..ec4a1b0 100644
--- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt
+++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt
@@ -6,13 +6,16 @@ internal interface SettingsStore : Store by storeFactory.create(
name = "SettingsStore",
initialState = SettingsStore.State(
- sound = settingsRepository.soundEnabled.value,
+ music = settingsRepository.musicEnabled.value,
+ sfx = settingsRepository.sfxEnabled.value,
vibration = settingsRepository.vibrationEnabled.value,
dark = settingsRepository.darkTheme.value,
),
@@ -32,16 +33,21 @@ internal class SettingsStoreFactory(
onAction {
launch {
combine(
- settingsRepository.soundEnabled,
+ settingsRepository.musicEnabled,
+ settingsRepository.sfxEnabled,
settingsRepository.vibrationEnabled,
settingsRepository.darkTheme,
- ) { s, v, d -> SettingsStore.Msg.Snapshot(s, v, d) }
+ ) { m, s, v, d -> SettingsStore.Msg.Snapshot(m, s, v, d) }
.collect { dispatch(it) }
}
}
- onIntent { intent ->
- logSettingChanged(setting = "sound", enabled = intent.enabled)
- launch { settingsRepository.setSoundEnabled(intent.enabled) }
+ onIntent { intent ->
+ logSettingChanged(setting = "music", enabled = intent.enabled)
+ launch { settingsRepository.setMusicEnabled(intent.enabled) }
+ }
+ onIntent { intent ->
+ logSettingChanged(setting = "sfx", enabled = intent.enabled)
+ launch { settingsRepository.setSfxEnabled(intent.enabled) }
}
onIntent { intent ->
logSettingChanged(setting = "vibration", enabled = intent.enabled)
@@ -69,9 +75,10 @@ internal class SettingsStoreFactory(
override fun SettingsStore.State.reduce(msg: SettingsStore.Msg): SettingsStore.State =
when (msg) {
is SettingsStore.Msg.Snapshot -> copy(
- sound = msg.sound,
+ music = msg.music,
+ sfx = msg.sfx,
vibration = msg.vibration,
- dark = msg.dark
+ dark = msg.dark,
)
}
}
diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt
index 218670f..4ef74e0 100644
--- a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt
+++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt
@@ -52,17 +52,28 @@ class DefaultMainSettingsComponentTest {
@Test
fun model_reflects_initial_state() = runTest {
val (component, _, _) = build()
- assertTrue(component.model.value.soundEnabled)
+ assertTrue(component.model.value.musicEnabled)
+ assertTrue(component.model.value.sfxEnabled)
assertTrue(component.model.value.vibrationEnabled)
assertFalse(component.model.value.darkTheme)
}
@Test
- fun onSoundToggled_propagates_to_repository_and_model() = runTest {
+ fun onMusicToggled_propagates_to_repository_and_model() = runTest {
val (component, settings, _) = build()
- component.onSoundToggled(false)
- assertFalse(settings.soundFlow.value)
- assertFalse(component.model.value.soundEnabled)
+ component.onMusicToggled(false)
+ assertFalse(settings.musicFlow.value)
+ assertFalse(component.model.value.musicEnabled)
+ assertTrue(component.model.value.sfxEnabled)
+ }
+
+ @Test
+ fun onSfxToggled_propagates_to_repository_and_model() = runTest {
+ val (component, settings, _) = build()
+ component.onSfxToggled(false)
+ assertFalse(settings.sfxFlow.value)
+ assertFalse(component.model.value.sfxEnabled)
+ assertTrue(component.model.value.musicEnabled)
}
@Test
@@ -94,16 +105,19 @@ class DefaultMainSettingsComponentTest {
}
private class FakeSettings : SettingsRepository {
- val soundFlow = MutableStateFlow(true)
+ val musicFlow = MutableStateFlow(true)
+ val sfxFlow = MutableStateFlow(true)
val vibrationFlow = MutableStateFlow(true)
val darkFlow = MutableStateFlow(false)
- override val soundEnabled: StateFlow = soundFlow.asStateFlow()
+ override val musicEnabled: StateFlow = musicFlow.asStateFlow()
+ override val sfxEnabled: StateFlow = sfxFlow.asStateFlow()
override val vibrationEnabled: StateFlow = vibrationFlow.asStateFlow()
override val darkTheme: StateFlow = darkFlow.asStateFlow()
override val bestScore = MutableStateFlow(0L).asStateFlow()
override val reviewPromptCount = MutableStateFlow(0).asStateFlow()
override val tutorialSeen = MutableStateFlow(false).asStateFlow()
- override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled }
+ override suspend fun setMusicEnabled(enabled: Boolean) { musicFlow.value = enabled }
+ override suspend fun setSfxEnabled(enabled: Boolean) { sfxFlow.value = enabled }
override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled }
override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled }
override suspend fun setBestScore(score: Long) {}
diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt
index ed10ffd..910fe92 100644
--- a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt
+++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt
@@ -7,11 +7,12 @@ import kotlin.test.assertEquals
class MappersTest {
@Test
- fun maps_three_flags_through() {
+ fun maps_all_flags_through() {
val model = stateToModel(
- SettingsStore.State(sound = false, vibration = true, dark = true),
+ SettingsStore.State(music = false, sfx = true, vibration = true, dark = true),
)
- assertEquals(false, model.soundEnabled)
+ assertEquals(false, model.musicEnabled)
+ assertEquals(true, model.sfxEnabled)
assertEquals(true, model.vibrationEnabled)
assertEquals(true, model.darkTheme)
}
@@ -19,7 +20,8 @@ class MappersTest {
@Test
fun maps_default_state() {
val model = stateToModel(SettingsStore.State())
- assertEquals(true, model.soundEnabled)
+ assertEquals(true, model.musicEnabled)
+ assertEquals(true, model.sfxEnabled)
assertEquals(true, model.vibrationEnabled)
assertEquals(false, model.darkTheme)
}
diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt
index cb25583..f533cfa 100644
--- a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt
+++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt
@@ -30,11 +30,12 @@ class SettingsStoreFactoryTest {
fun tearDown() { Dispatchers.resetMain() }
private fun make(
- sound: Boolean = true,
+ music: Boolean = true,
+ sfx: Boolean = true,
vibration: Boolean = true,
dark: Boolean = false,
): Triple {
- val settings = FakeSettings(sound, vibration, dark)
+ val settings = FakeSettings(music, sfx, vibration, dark)
val analytics = RecordingAnalytics()
return Triple(
SettingsStoreFactory(DefaultStoreFactory(), settings, analytics),
@@ -45,33 +46,44 @@ class SettingsStoreFactoryTest {
@Test
fun initial_state_mirrors_settings() = runTest {
- val (f, _, _) = make(sound = false, vibration = true, dark = true)
+ val (f, _, _) = make(music = false, sfx = true, vibration = true, dark = true)
val store = f.create()
- assertFalse(store.state.sound)
+ assertFalse(store.state.music)
+ assertTrue(store.state.sfx)
assertTrue(store.state.vibration)
assertTrue(store.state.dark)
}
@Test
- fun external_settings_change_propagates_to_state() = runTest {
+ fun external_music_change_propagates_to_state() = runTest {
val (f, settings, _) = make()
val store = f.create()
- settings.soundFlow.value = false
- assertFalse(store.state.sound)
+ settings.musicFlow.value = false
+ assertFalse(store.state.music)
+ assertTrue(store.state.sfx)
}
@Test
- fun setSound_writes_and_logs() = runTest {
+ fun setMusic_writes_and_logs() = runTest {
val (f, settings, analytics) = make()
val store = f.create()
- store.accept(SettingsStore.Intent.SetSound(false))
- assertFalse(settings.soundFlow.value)
+ store.accept(SettingsStore.Intent.SetMusic(false))
+ assertFalse(settings.musicFlow.value)
val ev = analytics.events.last()
assertEquals("setting_changed", ev.first)
- assertEquals("sound", ev.second["setting"])
+ assertEquals("music", ev.second["setting"])
assertEquals(false, ev.second["enabled"])
}
+ @Test
+ fun setSfx_writes_and_logs() = runTest {
+ val (f, settings, analytics) = make()
+ val store = f.create()
+ store.accept(SettingsStore.Intent.SetSfx(false))
+ assertFalse(settings.sfxFlow.value)
+ assertNotNull(analytics.events.find { it.first == "setting_changed" && it.second["setting"] == "sfx" })
+ }
+
@Test
fun setVibration_writes_and_logs() = runTest {
val (f, settings, analytics) = make()
@@ -93,20 +105,24 @@ class SettingsStoreFactoryTest {
// ── Fakes ────────────────────────────────────────────────────────────
private class FakeSettings(
- sound: Boolean,
+ music: Boolean,
+ sfx: Boolean,
vibration: Boolean,
dark: Boolean,
) : SettingsRepository {
- val soundFlow = MutableStateFlow(sound)
+ val musicFlow = MutableStateFlow(music)
+ val sfxFlow = MutableStateFlow(sfx)
val vibrationFlow = MutableStateFlow(vibration)
val darkFlow = MutableStateFlow(dark)
- override val soundEnabled: StateFlow = soundFlow.asStateFlow()
+ override val musicEnabled: StateFlow = musicFlow.asStateFlow()
+ override val sfxEnabled: StateFlow = sfxFlow.asStateFlow()
override val vibrationEnabled: StateFlow = vibrationFlow.asStateFlow()
override val darkTheme: StateFlow = darkFlow.asStateFlow()
override val bestScore = MutableStateFlow(0L).asStateFlow()
override val reviewPromptCount = MutableStateFlow(0).asStateFlow()
override val tutorialSeen = MutableStateFlow(false).asStateFlow()
- override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled }
+ override suspend fun setMusicEnabled(enabled: Boolean) { musicFlow.value = enabled }
+ override suspend fun setSfxEnabled(enabled: Boolean) { sfxFlow.value = enabled }
override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled }
override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled }
override suspend fun setBestScore(score: Long) {}
diff --git a/gradle.properties b/gradle.properties
index 16708ce..b2fd1b7 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -12,5 +12,5 @@ android.nonTransitiveRClass=true
android.useAndroidX=true
#App version (single source of truth; CI overrides via -PappVersionName / -PappVersionCode)
-appVersionName=1.4.7
-appVersionCode=11
\ No newline at end of file
+appVersionName=1.5.0
+appVersionCode=12
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c1640a5..878eb0b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,12 +17,12 @@ android-review = "2.0.2"
androidx-activity = "1.13.0"
androidx-core-splashscreen = "1.2.0"
androidx-lifecycle = "2.10.0"
-composeMultiplatform = "1.10.3"
+composeMultiplatform = "1.11.0"
confettikit = "0.8.0"
material3 = "1.10.0-alpha05"
kotlin = "2.3.21"
-metro = "1.0.0"
+metro = "1.1.1"
kotlinx-coroutines = "1.11.0"
diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig
index d860f1f..e3a09eb 100644
--- a/iosApp/Configuration/Config.xcconfig
+++ b/iosApp/Configuration/Config.xcconfig
@@ -3,5 +3,5 @@ TEAM_ID=
PRODUCT_NAME=Logica
PRODUCT_BUNDLE_IDENTIFIER=ge.yet3.blokblast.BlockBlast$(TEAM_ID)
-CURRENT_PROJECT_VERSION=11
-MARKETING_VERSION=1.4.7
\ No newline at end of file
+CURRENT_PROJECT_VERSION=12
+MARKETING_VERSION=1.5.0
\ No newline at end of file