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