diff --git a/src/main/java/sae/chuzzle/MainActivity.java b/src/main/java/sae/chuzzle/MainActivity.java index 3269e09..1e48cd3 100644 --- a/src/main/java/sae/chuzzle/MainActivity.java +++ b/src/main/java/sae/chuzzle/MainActivity.java @@ -1,6 +1,7 @@ package sae.chuzzle; import android.app.Activity; +import android.content.Intent; import android.os.Bundle; import android.view.View; import android.widget.Button; @@ -10,8 +11,15 @@ import android.widget.TextView; public class MainActivity extends Activity implements View.OnClickListener { + + private Controleur controleur; private Button btnJouer; + private Button btnMenu; + + + private boolean hardMode; + private EtatJeu etatJeu; @Override protected void onCreate(Bundle savedInstanceState) { @@ -19,33 +27,79 @@ public class MainActivity extends Activity implements View.OnClickListener { setContentView(R.layout.activity_main); // --- Modèle --- - long graine = getIntent().getLongExtra("graine", System.currentTimeMillis()); - boolean hardMode = getSharedPreferences("chuzzle_prefs", MODE_PRIVATE) + long graine = getIntent().getLongExtra("graine", System.currentTimeMillis()); + hardMode = getSharedPreferences("chuzzle_prefs", MODE_PRIVATE) .getBoolean("hard_mode", false); boolean daltonien = getSharedPreferences("chuzzle_prefs", MODE_PRIVATE) .getBoolean("daltonien", false); - EtatJeu etatJeu = new EtatJeu(graine, hardMode); + etatJeu = new EtatJeu(graine, hardMode); + + // --- Restauration si retour de pause --- + if (savedInstanceState != null) { + etatJeu.restaurerEtat(savedInstanceState); + } // --- Vue --- VueGrille vueGrille = findViewById(R.id.vueGrille); vueGrille.definirModeDaltonien(daltonien); + + // --- Controleur --- btnJouer = findViewById(R.id.btnJouer); btnJouer.setOnClickListener(this); + btnMenu = findViewById(R.id.btnMenu); + btnMenu.setOnClickListener(this); + + controleur = new Controleur( this, etatJeu, vueGrille, - (TextView) findViewById(R.id.tvScore), - (TextView) findViewById(R.id.tvCoups), + graine, + (TextView) findViewById(R.id.tvScore), + (TextView) findViewById(R.id.tvCoups), (RadioButton) findViewById(R.id.rbLigne), (RadioButton) findViewById(R.id.rbDroite), - (Spinner) findViewById(R.id.spinnerIndex), - btnJouer + (Spinner) findViewById(R.id.spinnerIndex), + btnJouer, + hardMode ); + + // --- Gestion tactile --- + GestionnaireTactile gestionnaireTactile = + new GestionnaireTactile(vueGrille, etatJeu, controleur); + vueGrille.setOnTouchListener(gestionnaireTactile); + + // --- Logique Hard mode --- + if (hardMode) { + // TODO: Ajouter la nouvelle fonctionnalité pour le hard mode ici (en remplacement du timer) + } + } + + // - + // + + // - + // CYCLE DE VIE + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (etatJeu != null) { + etatJeu.sauvegarderEtat(outState); + } + if (controleur != null) { + controleur.sauvegarderEtat(outState); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + } @Override @@ -53,5 +107,10 @@ public class MainActivity extends Activity implements View.OnClickListener { if (v == btnJouer) { controleur.gererCoupJoueur(); } + if (v == btnMenu) { + etatJeu.forcerFinDePartie(); + Intent intent = new Intent(this, MenuActivity.class); + startActivity(intent); + } } -} \ No newline at end of file +} diff --git a/src/src.zip b/src/src.zip new file mode 100644 index 0000000..28b9cec Binary files /dev/null and b/src/src.zip differ diff --git a/src/src/androidTest/java/sae/chuzzle/ExampleInstrumentedTest.java b/src/src/androidTest/java/sae/chuzzle/ExampleInstrumentedTest.java new file mode 100644 index 0000000..dc7075e --- /dev/null +++ b/src/src/androidTest/java/sae/chuzzle/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package sae.chuzzle; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("sae.chuzzle", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/src/src/main/AndroidManifest.xml b/src/src/main/AndroidManifest.xml new file mode 100644 index 0000000..34cdb93 --- /dev/null +++ b/src/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/src/main/java/sae/chuzzle/Controleur.java b/src/src/main/java/sae/chuzzle/Controleur.java new file mode 100644 index 0000000..62d5650 --- /dev/null +++ b/src/src/main/java/sae/chuzzle/Controleur.java @@ -0,0 +1,133 @@ +package sae.chuzzle; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.RadioButton; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import java.util.List; + +public class Controleur { + + private final Activity activite; + private final EtatJeu etatJeu; + private final VueGrille vueGrille; + private final long graine; + + private final TextView tvScore; + private final TextView tvCoups; + private final RadioButton rbLigne; + private final RadioButton rbDroite; + private final Spinner spinnerIndex; + private final Button btnJouer; + + // Hard Mode + private final boolean hardMode; + + // - + // CONSTRUCTEUR + + + public Controleur(Activity activite, EtatJeu etatJeu, VueGrille vueGrille, + long graine, + TextView tvScore, TextView tvCoups, + RadioButton rbLigne, RadioButton rbDroite, + Spinner spinnerIndex, Button btnJouer, + boolean hardMode) { + + this.activite = activite; + this.etatJeu = etatJeu; + this.vueGrille = vueGrille; + this.graine = graine; + this.tvScore = tvScore; + this.tvCoups = tvCoups; + this.rbLigne = rbLigne; + this.rbDroite = rbDroite; + this.spinnerIndex = spinnerIndex; + this.btnJouer = btnJouer; + this.hardMode = hardMode; + + initialiserSpinner(); + rafraichirAffichage(); + } + + // - + // INITIALISATION + + + private void initialiserSpinner() { + String[] indices = {"0", "1", "2", "3", "4", "5"}; + ArrayAdapter adaptateur = new ArrayAdapter<>( + activite, + android.R.layout.simple_spinner_item, + indices + ); + adaptateur.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinnerIndex.setAdapter(adaptateur); + } + + // - + // GESTION DU COUP + + + public void gererCoupJoueur() { + boolean estLigne = rbLigne.isChecked(); + int index = spinnerIndex.getSelectedItemPosition(); + + int sens; + if (rbDroite.isChecked()) { + sens = +1; + } else { + sens = -1; + } + + boolean accepte = etatJeu.appliquerCoup(estLigne, index, sens); + + if (!accepte) { + Toast.makeText(activite, "Coup invalide !", Toast.LENGTH_SHORT).show(); + return; + } + + Toast.makeText(activite, "Coup valide !", Toast.LENGTH_SHORT).show(); + rafraichirAffichage(); + verifierFinDePartie(); + } + + // - + // FIN DE PARTIE + + + public void verifierFinDePartie() { + if (etatJeu.estTerminee()) { + Intent intent = new Intent(activite, FinPartieActivity.class); + intent.putExtra("score", etatJeu.obtenirScore()); + intent.putExtra("nbCoups", etatJeu.obtenirNbCoups()); + intent.putExtra("graine", graine); + activite.startActivity(intent); + } + } + + // - + // MISE A JOUR DE LA VUE + + + public void rafraichirAffichage() { + tvScore.setText("Score : " + etatJeu.obtenirScore()); + tvCoups.setText("Coups : " + etatJeu.obtenirNbCoups()); + vueGrille.definirGrille(etatJeu.obtenirGrille()); + vueGrille.definirVerrous(etatJeu.obtenirVerrous()); + + if (etatJeu.estTerminee()) { + btnJouer.setEnabled(false); + } + } + + public void sauvegarderEtat(Bundle out) { + // Nettoyé de la logique d'objectifs + } +} \ No newline at end of file diff --git a/src/src/main/java/sae/chuzzle/EtatJeu.java b/src/src/main/java/sae/chuzzle/EtatJeu.java new file mode 100644 index 0000000..4d5f670 --- /dev/null +++ b/src/src/main/java/sae/chuzzle/EtatJeu.java @@ -0,0 +1,598 @@ +package sae.chuzzle; + +import android.os.Bundle; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +public class EtatJeu { + + public static final int NB_LIGNES = 6; + public static final int NB_COLONNES = 6; + public static final int NB_TYPES = 7; + + private final int[][] grille = new int[NB_LIGNES][NB_COLONNES]; + private final boolean[][] verrous = new boolean[NB_LIGNES][NB_COLONNES]; + private final Random aleatoire; + + private int score = 0; + private int nbCoups = 0; + private boolean partieTerminee = false; + private boolean hardMode = false; + + //- + // CONSTRUCTEURS + + + public EtatJeu() { + aleatoire = new Random(); + initialiserGrilleSansTriples(); + } + + public EtatJeu(long graine, boolean hardMode) { + aleatoire = new Random(graine); + this.hardMode = hardMode; + initialiserGrilleSansTriples(); + } + + // - + // GETTERS + + + public int obtenirScore() { + return score; + } + + public int obtenirNbCoups() { + return nbCoups; + } + + public boolean estTerminee() { + return partieTerminee; + } + + // - + // SAUVEGARDE ET RESTAURATION + + public void sauvegarderEtat(Bundle bundle) { + int[] flatGrille = new int[NB_LIGNES * NB_COLONNES]; + boolean[] flatVerrous = new boolean[NB_LIGNES * NB_COLONNES]; + + for (int l = 0; l < NB_LIGNES; l++) { + for (int c = 0; c < NB_COLONNES; c++) { + flatGrille[l * NB_COLONNES + c] = grille[l][c]; + flatVerrous[l * NB_COLONNES + c] = verrous[l][c]; + } + } + + bundle.putIntArray("grille", flatGrille); + bundle.putBooleanArray("verrous", flatVerrous); + bundle.putInt("score", score); + bundle.putInt("nbCoups", nbCoups); + bundle.putBoolean("partieTerminee", partieTerminee); + } + + public void restaurerEtat(Bundle bundle) { + if (!bundle.containsKey("grille")) return; + + int[] flatGrille = bundle.getIntArray("grille"); + boolean[] flatVerrous = bundle.getBooleanArray("verrous"); + + if (flatGrille != null && flatVerrous != null) { + for (int l = 0; l < NB_LIGNES; l++) { + for (int c = 0; c < NB_COLONNES; c++) { + grille[l][c] = flatGrille[l * NB_COLONNES + c]; + verrous[l][c] = flatVerrous[l * NB_COLONNES + c]; + } + } + } + + score = bundle.getInt("score"); + nbCoups = bundle.getInt("nbCoups"); + partieTerminee = bundle.getBoolean("partieTerminee"); + } + + public int obtenirCase(int ligne, int colonne) { + return grille[ligne][colonne]; + } + + public int[][] obtenirGrille() { + int[][] copie = new int[NB_LIGNES][NB_COLONNES]; + + for (int l = 0; l < NB_LIGNES; l++) { + System.arraycopy(grille[l], 0, copie[l], 0, NB_COLONNES); + } + + return copie; + } + + public boolean[][] obtenirVerrous() { + boolean[][] copie = new boolean[NB_LIGNES][NB_COLONNES]; + + for (int l = 0; l < NB_LIGNES; l++) { + System.arraycopy(verrous[l], 0, copie[l], 0, NB_COLONNES); + } + + return copie; + } + + // - + // INITIALISATION SANS TRIPLES + // + + private void initialiserGrilleSansTriples() { + for (int ligne = 0; ligne < NB_LIGNES; ligne++) { + for (int colonne = 0; colonne < NB_COLONNES; colonne++) { + + int valeur; + do { + valeur = aleatoire.nextInt(NB_TYPES); + } while (creeTriple(ligne, colonne, valeur)); + + grille[ligne][colonne] = valeur; + } + } + } + + private boolean creeTriple(int ligne, int colonne, int valeur) { + + if (colonne >= 2) { + if (grille[ligne][colonne - 1] == valeur && + grille[ligne][colonne - 2] == valeur) { + return true; + } + } + + if (ligne >= 2) { + if (grille[ligne - 1][colonne] == valeur && + grille[ligne - 2][colonne] == valeur) { + return true; + } + } + + return false; + } + + // - + // DECALAGE CIRCULAIRE + + + public void decalerLigne(int ligne, int sens) { + int s = ((sens % NB_COLONNES) + NB_COLONNES) % NB_COLONNES; + + for (int etape = 0; etape < s; etape++) { + int tmp = grille[ligne][NB_COLONNES - 1]; + + for (int c = NB_COLONNES - 1; c > 0; c--) { + grille[ligne][c] = grille[ligne][c - 1]; + } + + grille[ligne][0] = tmp; + } + } + + public void decalerColonne(int colonne, int sens) { + int s = ((sens % NB_LIGNES) + NB_LIGNES) % NB_LIGNES; + + for (int etape = 0; etape < s; etape++) { + int tmp = grille[NB_LIGNES - 1][colonne]; + + for (int l = NB_LIGNES - 1; l > 0; l--) { + grille[l][colonne] = grille[l - 1][colonne]; + } + + grille[0][colonne] = tmp; + } + } + + // - + // APPLIQUER UN COUP + + + public boolean appliquerCoup(boolean estLigne, int index, int sens) { + if (partieTerminee) { + return false; + } + + // Les verrous bloquent toujours, quel que soit le mode + if (estBloque(estLigne, index)) { + return false; + } + + int[][] sauvegarde = copierGrille(); + + if (estLigne) { + decalerLigne(index, sens); + } else { + decalerColonne(index, sens); + } + + if (trouverSeries().isEmpty()) { + restaurerGrille(sauvegarde); + return false; + } + + nbCoups++; + score += resoudreEtRemplir(); + + // Verrou après chaque coup ; 2 verrous en hard mode (plus difficile) + ajouterVerrou(); + if (hardMode) { + ajouterVerrou(); + } + + if (!aUnCoupValide()) { + partieTerminee = true; + } + + return true; + } + + // - + // TROUVER LES SERIES + + + public List trouverSeries() { + boolean[][] aSupprimer = new boolean[NB_LIGNES][NB_COLONNES]; + + for (int l = 0; l < NB_LIGNES; l++) { + int c = 0; + + while (c < NB_COLONNES) { + int type = grille[l][c]; + int fin = c + 1; + + while (fin < NB_COLONNES && grille[l][fin] == type) { + fin++; + } + + if (fin - c >= 3) { + for (int k = c; k < fin; k++) { + aSupprimer[l][k] = true; + } + } + + c = fin; + } + } + + for (int col = 0; col < NB_COLONNES; col++) { + int l = 0; + + while (l < NB_LIGNES) { + int type = grille[l][col]; + int fin = l + 1; + + while (fin < NB_LIGNES && grille[fin][col] == type) { + fin++; + } + + if (fin - l >= 3) { + for (int k = l; k < fin; k++) { + aSupprimer[k][col] = true; + } + } + + l = fin; + } + } + + List positions = new ArrayList<>(); + + for (int l = 0; l < NB_LIGNES; l++) { + for (int col = 0; col < NB_COLONNES; col++) { + if (aSupprimer[l][col]) { + positions.add(new int[]{l, col}); + } + } + } + + return positions; + } + + // - + // RESOLUTION AVEC CASCADE + + + public int resoudreEtRemplir() { + int baseTotal = 0; + int nbSeries = 0; + + List series = trouverSeries(); + + while (!series.isEmpty()) { + + // Accumuler les points de base et le nombre de séries sur toutes les vagues + baseTotal += calculerPointsBase(series); + nbSeries += compterNbSeries(series); + + boolean[][] aSupprimer = new boolean[NB_LIGNES][NB_COLONNES]; + + for (int[] pos : series) { + aSupprimer[pos[0]][pos[1]] = true; + } + + // Libérer les verrous des cases supprimées + libererVerrous(aSupprimer); + + for (int col = 0; col < NB_COLONNES; col++) { + + List survivants = new ArrayList<>(); + + for (int l = NB_LIGNES - 1; l >= 0; l--) { + if (!aSupprimer[l][col]) { + survivants.add(grille[l][col]); + } + } + + int li = NB_LIGNES - 1; + + for (int val : survivants) { + grille[li][col] = val; + li--; + } + + while (li >= 0) { + grille[li][col] = aleatoire.nextInt(NB_TYPES); + li--; + } + } + + series = trouverSeries(); + } + + if (nbSeries == 0) return 0; + + // Bonus : +50% par série supplémentaire après la première (spec SAÉ) + double bonus = 1.0 + (nbSeries - 1) * 0.5; + return (int) (baseTotal * bonus); + } + + private int calculerPointsBase(List series) { + boolean[][] masque = new boolean[NB_LIGNES][NB_COLONNES]; + + for (int[] pos : series) { + masque[pos[0]][pos[1]] = true; + } + + int total = 0; + + for (int l = 0; l < NB_LIGNES; l++) { + int c = 0; + + while (c < NB_COLONNES) { + if (masque[l][c]) { + int fin = c + 1; + + while (fin < NB_COLONNES && masque[l][fin]) { + fin++; + } + + total += pointsPourLongueur(fin - c); + c = fin; + } else { + c++; + } + } + } + + for (int col = 0; col < NB_COLONNES; col++) { + int l = 0; + + while (l < NB_LIGNES) { + if (masque[l][col]) { + int fin = l + 1; + + while (fin < NB_LIGNES && masque[fin][col]) { + fin++; + } + + total += pointsPourLongueur(fin - l); + l = fin; + } else { + l++; + } + } + } + + return total; + } + + private int compterNbSeries(List series) { + boolean[][] masque = new boolean[NB_LIGNES][NB_COLONNES]; + + for (int[] pos : series) { + masque[pos[0]][pos[1]] = true; + } + + int count = 0; + + // Séries horizontales + for (int l = 0; l < NB_LIGNES; l++) { + int c = 0; + + while (c < NB_COLONNES) { + if (masque[l][c]) { + int fin = c + 1; + + while (fin < NB_COLONNES && masque[l][fin]) { + fin++; + } + + count++; + c = fin; + } else { + c++; + } + } + } + + // Séries verticales + for (int col = 0; col < NB_COLONNES; col++) { + int l = 0; + + while (l < NB_LIGNES) { + if (masque[l][col]) { + int fin = l + 1; + + while (fin < NB_LIGNES && masque[fin][col]) { + fin++; + } + + count++; + l = fin; + } else { + l++; + } + } + } + + return count; + } + + private int pointsPourLongueur(int longueur) { + if (longueur == 3) { + return 8; + } else if (longueur == 4) { + return 16; + } else if (longueur == 5) { + return 32; + } else { + return 64; + } + } + + // - + // VERROUS (hard mode) + + + private boolean estBloque(boolean estLigne, int index) { + if (estLigne) { + for (int col = 0; col < NB_COLONNES; col++) { + if (verrous[index][col]) { + return true; + } + } + } else { + for (int lig = 0; lig < NB_LIGNES; lig++) { + if (verrous[lig][index]) { + return true; + } + } + } + + return false; + } + + private void ajouterVerrou() { + int intervalle = Math.max(1, 5 - nbCoups / 10); + + if (nbCoups % intervalle != 0) { + return; + } + + List casesLibres = new ArrayList<>(); + + for (int l = 0; l < NB_LIGNES; l++) { + for (int col = 0; col < NB_COLONNES; col++) { + if (!verrous[l][col]) { + casesLibres.add(new int[]{l, col}); + } + } + } + + if (casesLibres.isEmpty()) { + return; + } + + int[] caseChoisie = casesLibres.get(aleatoire.nextInt(casesLibres.size())); + verrous[caseChoisie[0]][caseChoisie[1]] = true; + } + + private void libererVerrous(boolean[][] aSupprimer) { + for (int l = 0; l < NB_LIGNES; l++) { + for (int col = 0; col < NB_COLONNES; col++) { + if (aSupprimer[l][col]) { + verrous[l][col] = false; + } + } + } + } + + // - + // DETECTION FIN DE PARTIE + + + public boolean aUnCoupValide() { + // Teste tous les décalages possibles (1 à N-1) pour les lignes + for (int i = 0; i < NB_LIGNES; i++) { + + // AJOUT : Si la ligne est bloquée par un verrou, on ne peut pas la bouger + if (estBloque(true, i)) continue; + + for (int s = 1; s < NB_COLONNES; s++) { + if (coupCreeSerie(true, i, s)) { + return true; + } + } + } + + // Teste tous les décalages possibles (1 à N-1) pour les colonnes + for (int j = 0; j < NB_COLONNES; j++) { + + // AJOUT : Si la colonne est bloquée par un verrou, on ne peut pas la bouger + if (estBloque(false, j)) continue; + + for (int s = 1; s < NB_LIGNES; s++) { + if (coupCreeSerie(false, j, s)) { + return true; + } + } + } + + return false; + } + + public void forcerFinDePartie() { + this.partieTerminee = true; + } + + private boolean coupCreeSerie(boolean estLigne, int index, int sens) { + int[][] sauvegarde = copierGrille(); + + if (estLigne) { + decalerLigne(index, sens); + } else { + decalerColonne(index, sens); + } + + boolean resultat = !trouverSeries().isEmpty(); + + restaurerGrille(sauvegarde); + + return resultat; + } + + // - + // UTILITAIRES PRIVES + + + private int[][] copierGrille() { + int[][] copie = new int[NB_LIGNES][NB_COLONNES]; + + for (int l = 0; l < NB_LIGNES; l++) { + System.arraycopy(grille[l], 0, copie[l], 0, NB_COLONNES); + } + + return copie; + } + + private void restaurerGrille(int[][] sauvegarde) { + for (int l = 0; l < NB_LIGNES; l++) { + System.arraycopy(sauvegarde[l], 0, grille[l], 0, NB_COLONNES); + } + } + + +} diff --git a/src/src/main/java/sae/chuzzle/FinPartieActivity.java b/src/src/main/java/sae/chuzzle/FinPartieActivity.java new file mode 100644 index 0000000..e6fe906 --- /dev/null +++ b/src/src/main/java/sae/chuzzle/FinPartieActivity.java @@ -0,0 +1,50 @@ +package sae.chuzzle; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.widget.Button; +import android.widget.TextView; + +public class FinPartieActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_fin_partie); + + // Récupérer les données passées par le Controleur + int score = getIntent().getIntExtra("score", 0); + int nbCoups = getIntent().getIntExtra("nbCoups", 0); + long graine = getIntent().getLongExtra("graine", 0L); + + // Afficher les données + TextView tvScore = findViewById(R.id.tvFinScore); + TextView tvCoups = findViewById(R.id.tvFinCoups); + TextView tvGraine = findViewById(R.id.tvFinGraine); + + tvScore.setText(String.valueOf(score)); + tvCoups.setText(String.valueOf(nbCoups)); + tvGraine.setText(String.valueOf(graine)); + + // Bouton retour au menu + Button btnMenu = findViewById(R.id.btnFinMenu); + btnMenu.setOnClickListener(v -> { + Intent intent = new Intent(this, MenuActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + finish(); + }); + } + + // - + // RETOUR ARRIERE = menu principal + + @Override + public void onBackPressed() { + Intent intent = new Intent(this, MenuActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + finish(); + } +} diff --git a/src/src/main/java/sae/chuzzle/GestionnaireTactile.java b/src/src/main/java/sae/chuzzle/GestionnaireTactile.java new file mode 100644 index 0000000..3f9fecf --- /dev/null +++ b/src/src/main/java/sae/chuzzle/GestionnaireTactile.java @@ -0,0 +1,140 @@ +package sae.chuzzle; + +import android.view.MotionEvent; +import android.view.View; + +public class GestionnaireTactile implements View.OnTouchListener { + + private static final int NB_LIGNES = EtatJeu.NB_LIGNES; + private static final int NB_COLONNES = EtatJeu.NB_COLONNES; + private static final float SEUIL_DIRECTION_PX = 10f; + + private final VueGrille vueGrille; + private final EtatJeu etatJeu; + private final Controleur controleur; + + private float touchDebutX, touchDebutY; + private int ligneTouchee, colonneTouchee; + private Boolean estLigne; + private int indexGlissement; + private float decalagePx; + private float tailleCase; + + public GestionnaireTactile(VueGrille vueGrille, + EtatJeu etatJeu, + Controleur controleur) { + this.vueGrille = vueGrille; + this.etatJeu = etatJeu; + this.controleur = controleur; + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + + if (etatJeu.estTerminee()) { + return false; + } + + switch (event.getAction()) { + + case MotionEvent.ACTION_DOWN: + tailleCase = calculerTailleCase(); + gererDebut(event); + return true; + + case MotionEvent.ACTION_MOVE: + gererMouvement(event); + return true; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + gererFin(event.getAction() == MotionEvent.ACTION_UP); + return true; + } + + return false; + } + + private void gererDebut(MotionEvent event) { + touchDebutX = event.getX(); + touchDebutY = event.getY(); + estLigne = null; + decalagePx = 0f; + + float margeGauche = (vueGrille.getWidth() - tailleCase * NB_COLONNES) / 2f; + float margeHaut = (vueGrille.getHeight() - tailleCase * NB_LIGNES) / 2f; + + ligneTouchee = (int) ((touchDebutY - margeHaut) / tailleCase); + colonneTouchee = (int) ((touchDebutX - margeGauche) / tailleCase); + + ligneTouchee = Math.max(0, Math.min(NB_LIGNES - 1, ligneTouchee)); + colonneTouchee = Math.max(0, Math.min(NB_COLONNES - 1, colonneTouchee)); + } + + private void gererMouvement(MotionEvent event) { + float dx = event.getX() - touchDebutX; + float dy = event.getY() - touchDebutY; + + if (estLigne == null) { + if (Math.abs(dx) > SEUIL_DIRECTION_PX || Math.abs(dy) > SEUIL_DIRECTION_PX) { + estLigne = Math.abs(dx) >= Math.abs(dy); + + if (estLigne) { + indexGlissement = ligneTouchee; + } else { + indexGlissement = colonneTouchee; + } + } else { + return; + } + } + + if (estLigne) { + decalagePx = dx; + } else { + decalagePx = dy; + } + + // Affiche le glissement réel du doigt (permet la rotation multi-cases) + vueGrille.definirGlissement(estLigne, indexGlissement, decalagePx); + } + + private void gererFin(boolean estRelachement) { + + if (estLigne == null) { + vueGrille.annulerGlissement(); + return; + } + + if (tailleCase <= 1f) { + vueGrille.annulerGlissement(); + estLigne = null; + return; + } + + // Arrondi au nombre de cases le plus proche (permet ±1, ±2, ±3…) + int nbCases = (int) Math.round(decalagePx / tailleCase); + + if (nbCases != 0 && estRelachement) { + boolean accepte = etatJeu.appliquerCoup(estLigne, indexGlissement, nbCases); + if (accepte) { + controleur.rafraichirAffichage(); + controleur.verifierFinDePartie(); + } + } + + vueGrille.annulerGlissement(); + estLigne = null; + } + + private float calculerTailleCase() { + float l = vueGrille.getWidth(); + float h = vueGrille.getHeight(); + + if (l == 0 || h == 0) { + return 1f; + } + + return Math.min(l / NB_COLONNES, h / NB_LIGNES); + } +} \ No newline at end of file diff --git a/src/src/main/java/sae/chuzzle/MainActivity.java b/src/src/main/java/sae/chuzzle/MainActivity.java new file mode 100644 index 0000000..1e48cd3 --- /dev/null +++ b/src/src/main/java/sae/chuzzle/MainActivity.java @@ -0,0 +1,116 @@ +package sae.chuzzle; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.RadioButton; +import android.widget.Spinner; +import android.widget.TextView; + +public class MainActivity extends Activity implements View.OnClickListener { + + + + private Controleur controleur; + private Button btnJouer; + private Button btnMenu; + + + private boolean hardMode; + private EtatJeu etatJeu; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + // --- Modèle --- + long graine = getIntent().getLongExtra("graine", System.currentTimeMillis()); + hardMode = getSharedPreferences("chuzzle_prefs", MODE_PRIVATE) + .getBoolean("hard_mode", false); + boolean daltonien = getSharedPreferences("chuzzle_prefs", MODE_PRIVATE) + .getBoolean("daltonien", false); + + etatJeu = new EtatJeu(graine, hardMode); + + // --- Restauration si retour de pause --- + if (savedInstanceState != null) { + etatJeu.restaurerEtat(savedInstanceState); + } + + // --- Vue --- + VueGrille vueGrille = findViewById(R.id.vueGrille); + vueGrille.definirModeDaltonien(daltonien); + + + + // --- Controleur --- + btnJouer = findViewById(R.id.btnJouer); + btnJouer.setOnClickListener(this); + + btnMenu = findViewById(R.id.btnMenu); + btnMenu.setOnClickListener(this); + + + controleur = new Controleur( + this, + etatJeu, + vueGrille, + graine, + (TextView) findViewById(R.id.tvScore), + (TextView) findViewById(R.id.tvCoups), + (RadioButton) findViewById(R.id.rbLigne), + (RadioButton) findViewById(R.id.rbDroite), + (Spinner) findViewById(R.id.spinnerIndex), + btnJouer, + hardMode + ); + + // --- Gestion tactile --- + GestionnaireTactile gestionnaireTactile = + new GestionnaireTactile(vueGrille, etatJeu, controleur); + vueGrille.setOnTouchListener(gestionnaireTactile); + + // --- Logique Hard mode --- + if (hardMode) { + // TODO: Ajouter la nouvelle fonctionnalité pour le hard mode ici (en remplacement du timer) + } + } + + // - + // + + // - + // CYCLE DE VIE + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (etatJeu != null) { + etatJeu.sauvegarderEtat(outState); + } + if (controleur != null) { + controleur.sauvegarderEtat(outState); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + } + + @Override + public void onClick(View v) { + if (v == btnJouer) { + controleur.gererCoupJoueur(); + } + if (v == btnMenu) { + etatJeu.forcerFinDePartie(); + Intent intent = new Intent(this, MenuActivity.class); + startActivity(intent); + } + } +} diff --git a/src/src/main/java/sae/chuzzle/MenuActivity.java b/src/src/main/java/sae/chuzzle/MenuActivity.java new file mode 100644 index 0000000..a4bd5f5 --- /dev/null +++ b/src/src/main/java/sae/chuzzle/MenuActivity.java @@ -0,0 +1,44 @@ +package sae.chuzzle; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; + +public class MenuActivity extends Activity implements View.OnClickListener { + + private Button btnNouvellePartie; + private Button btnPartieGraine; + private Button btnOptions; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_menu); + + btnNouvellePartie = findViewById(R.id.btnNouvellePartie); + btnPartieGraine = findViewById(R.id.btnPartieGraine); + btnOptions = findViewById(R.id.btnOptions); + + btnNouvellePartie.setOnClickListener(this); + btnPartieGraine.setOnClickListener(this); + btnOptions.setOnClickListener(this); + } + + @Override + public void onClick(View v) { + if (v == btnNouvellePartie) { + long graine = System.currentTimeMillis(); + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra("graine", graine); + startActivity(intent); + } else if (v == btnPartieGraine) { + Intent intent = new Intent(this, SeedActivity.class); + startActivity(intent); + } else if (v == btnOptions) { + Intent intent = new Intent(this, OptionsActivity.class); + startActivity(intent); + } + } +} \ No newline at end of file diff --git a/src/src/main/java/sae/chuzzle/OptionsActivity.java b/src/src/main/java/sae/chuzzle/OptionsActivity.java new file mode 100644 index 0000000..9d0bf7d --- /dev/null +++ b/src/src/main/java/sae/chuzzle/OptionsActivity.java @@ -0,0 +1,58 @@ +package sae.chuzzle; + +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; + +public class OptionsActivity extends Activity + implements CompoundButton.OnCheckedChangeListener, View.OnClickListener { + + private SharedPreferences prefs; + private CheckBox checkDaltonien; + private CheckBox checkHard; + + private Button btnRetour; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_options); + + prefs = getSharedPreferences("chuzzle_prefs", MODE_PRIVATE); + + checkDaltonien = findViewById(R.id.checkDaltonien); + checkHard = findViewById(R.id.checkHard); + btnRetour = findViewById(R.id.btnBack); + + checkDaltonien.setChecked(prefs.getBoolean("daltonien", false)); + checkHard.setChecked(prefs.getBoolean("hard_mode", false)); + + checkDaltonien.setOnCheckedChangeListener(this); + checkHard.setOnCheckedChangeListener(this); + btnRetour.setOnClickListener(this); + } + + @Override + public void onCheckedChanged(CompoundButton bouton, boolean estCoche) { + if (bouton == checkDaltonien) { + prefs.edit().putBoolean("daltonien", estCoche).apply(); + } else if (bouton == checkHard) { + prefs.edit().putBoolean("hard_mode", estCoche).apply(); + } + } + + @Override + public void onClick(View v) { + if (v == btnRetour) { + Intent intent = new Intent(this, MenuActivity.class); + startActivity(intent); + } + + } +} diff --git a/src/src/main/java/sae/chuzzle/SeedActivity.java b/src/src/main/java/sae/chuzzle/SeedActivity.java new file mode 100644 index 0000000..50c1639 --- /dev/null +++ b/src/src/main/java/sae/chuzzle/SeedActivity.java @@ -0,0 +1,67 @@ +package sae.chuzzle; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.Toast; + +public class SeedActivity extends Activity implements View.OnClickListener { + + private EditText etGraine; + private Button btnJouer; + + private Button btnRetour; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_seed); + + etGraine = findViewById(R.id.etGraine); + btnJouer = findViewById(R.id.btnJouer); + btnRetour = findViewById(R.id.btnBack); + + btnRetour.setOnClickListener(this); + + btnJouer.setOnClickListener(this); + } + + @Override + public void onClick(View v) { + if (v == btnJouer) { + lancerPartieAvecGraine(); + } + if (v == btnRetour) { + Intent intent = new Intent(this, MenuActivity.class); + startActivity(intent); + } + } + + private void lancerPartieAvecGraine() { + String texte = etGraine.getText().toString().trim(); + + if (texte.isEmpty()) { + Toast.makeText( + this, + "Veuillez entrer une graine.", + Toast.LENGTH_SHORT + ).show(); + return; + } + + long graine; + + try { + graine = Long.parseLong(texte); + } catch (NumberFormatException e) { + graine = texte.hashCode(); + } + + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra("graine", graine); + startActivity(intent); + } +} \ No newline at end of file diff --git a/src/src/main/java/sae/chuzzle/VueGrille.java b/src/src/main/java/sae/chuzzle/VueGrille.java new file mode 100644 index 0000000..8768302 --- /dev/null +++ b/src/src/main/java/sae/chuzzle/VueGrille.java @@ -0,0 +1,307 @@ +package sae.chuzzle; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; + +public class VueGrille extends View { + + // ========================================================= + // Constantes + // ========================================================= + + private static final int NB_LIGNES = 6; + private static final int NB_COLONNES = 6; + private static final int NB_TYPES = 7; + + /** Symboles pour le mode daltonien, un par type. */ + private static final String[] SYMBOLES = {"●", "■", "▲", "✚", "★", "♦", "✿"}; + + // ========================================================= + // Données métier + // ========================================================= + + /** Grille reçue depuis MainActivity (tableau brut). */ + private int[][] grille = new int[NB_LIGNES][NB_COLONNES]; + + /** Verrous reçus depuis MainActivity. */ + private boolean[][] verrous = new boolean[NB_LIGNES][NB_COLONNES]; + + private boolean modeDaltonien = false; + + // ========================================================= + // État de l'animation de glissement + // ========================================================= + + /** + * true = on anime une ligne, + * false = on anime une colonne, + * null = pas d'animation en cours. + */ + private Boolean animEstLigne = null; + + /** Index de la ligne ou colonne en cours de glissement. */ + private int animIndex = 0; + + /** + * Décalage courant en pixels (positif = droite/bas, + * négatif = gauche/haut). + */ + private float animDecalagePx = 0f; + + // ========================================================= + // Outils de dessin + // ========================================================= + + private final Paint pinceauCase = new Paint(); + private final Paint pinceauSymbole = new Paint(); + + // ========================================================= + // Constructeurs + // ========================================================= + + public VueGrille(Context contexte) { + super(contexte); + initPinceaux(); + } + + public VueGrille(Context context, AttributeSet attrs) { + super(context, attrs); + initPinceaux(); + } + + private void initPinceaux() { + pinceauCase.setAntiAlias(true); + pinceauCase.setStyle(Paint.Style.FILL); + + pinceauSymbole.setAntiAlias(true); + pinceauSymbole.setColor(0xFF000000); + pinceauSymbole.setTextAlign(Paint.Align.CENTER); + } + + // ========================================================= + // API publique + // ========================================================= + + /** + * Reçoit une copie de la grille depuis MainActivity. + * VueGrille ne sait pas d'où viennent ces données, + * elle sait juste les dessiner. + */ + public void definirGrille(int[][] nouvelleGrille) { + for (int l = 0; l < NB_LIGNES; l++) { + System.arraycopy(nouvelleGrille[l], 0, grille[l], 0, NB_COLONNES); + } + invalidate(); + } + + public void definirModeDaltonien(boolean actif) { + this.modeDaltonien = actif; + invalidate(); + } + + public void definirVerrous(boolean[][] nouveauxVerrous) { + for (int l = 0; l < NB_LIGNES; l++) { + System.arraycopy(nouveauxVerrous[l], 0, verrous[l], 0, NB_COLONNES); + } + invalidate(); + } + + // ========================================================= + // API animation de glissement (appelée par GestionnaireTactile) + // ========================================================= + + /** + * Déclenche l'animation de glissement en temps réel. + * + * @param estLigne true = on déplace une ligne, false = une colonne + * @param index index de la ligne ou colonne (0-based) + * @param decalagePx décalage courant en pixels (peut être négatif) + */ + public void definirGlissement(boolean estLigne, int index, float decalagePx) { + this.animEstLigne = estLigne; + this.animIndex = index; + this.animDecalagePx = decalagePx; + invalidate(); + } + + /** + * Annule l'animation de glissement et revient à l'affichage normal. + */ + public void annulerGlissement() { + this.animEstLigne = null; + invalidate(); + } + + // ========================================================= + // Dessin + // ========================================================= + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + + int largeur = getWidth(); + int hauteur = getHeight(); + + float tailleCase = Math.min( + largeur / (float) NB_COLONNES, + hauteur / (float) NB_LIGNES + ); + + float margeGauche = (largeur - tailleCase * NB_COLONNES) / 2f; + float margeHaut = (hauteur - tailleCase * NB_LIGNES) / 2f; + + pinceauSymbole.setTextSize(tailleCase * 0.4f); + + for (int ligne = 0; ligne < NB_LIGNES; ligne++) { + for (int colonne = 0; colonne < NB_COLONNES; colonne++) { + + // -------------------------------------------------- + // Calcul de l'offset d'animation pour cette case + // -------------------------------------------------- + float offsetX = 0f; + float offsetY = 0f; + + if (animEstLigne != null) { + if (animEstLigne && ligne == animIndex) { + // Décalage horizontal de toute la ligne + offsetX = animDecalagePx; + } else if (!animEstLigne && colonne == animIndex) { + // Décalage vertical de toute la colonne + offsetY = animDecalagePx; + } + } + + // -------------------------------------------------- + // Position de base de la case + // -------------------------------------------------- + float x1 = margeGauche + colonne * tailleCase + 6 + offsetX; + float y1 = margeHaut + ligne * tailleCase + 6 + offsetY; + float x2 = margeGauche + (colonne + 1) * tailleCase - 6 + offsetX; + float y2 = margeHaut + (ligne + 1) * tailleCase - 6 + offsetY; + + // -------------------------------------------------- + // Dessin avec wrap-around (la case qui sort d'un + // côté réapparaît de l'autre côté) + // -------------------------------------------------- + dessinerCase(canvas, ligne, colonne, x1, y1, x2, y2, + tailleCase, margeGauche, margeHaut, offsetX, offsetY); + } + } + } + + /** + * Dessine une case à la position donnée. + * Si la case sort des bords (wrap-around), elle est aussi dessinée + * du côté opposé de la grille. + */ + private void dessinerCase(Canvas canvas, + int ligne, int colonne, + float x1, float y1, float x2, float y2, + float tailleCase, + float margeGauche, float margeHaut, + float offsetX, float offsetY) { + + int type = grille[ligne][colonne]; + + // Bornage de la grille (en pixels) + float borneGaucheGrille = margeGauche; + float bordDroiteGrille = margeGauche + NB_COLONNES * tailleCase; + float borneHautGrille = margeHaut; + float bordBasGrille = margeHaut + NB_LIGNES * tailleCase; + + // Largeur totale de la ligne / hauteur totale de la colonne en pixels + float largeurGrille = NB_COLONNES * tailleCase; + float hauteurGrille = NB_LIGNES * tailleCase; + + // Dessine la case principale + dessinerRectCase(canvas, type, ligne, colonne, x1, y1, x2, y2); + + // Wrap-around horizontal (pour les lignes) + if (offsetX != 0f) { + float wrapX1 = x1, wrapX2 = x2; + + if (x2 > bordDroiteGrille) { + // La case déborde à droite → réapparaît à gauche + wrapX1 = x1 - largeurGrille; + wrapX2 = x2 - largeurGrille; + dessinerRectCase(canvas, type, ligne, colonne, wrapX1, y1, wrapX2, y2); + } else if (x1 < borneGaucheGrille) { + // La case déborde à gauche → réapparaît à droite + wrapX1 = x1 + largeurGrille; + wrapX2 = x2 + largeurGrille; + dessinerRectCase(canvas, type, ligne, colonne, wrapX1, y1, wrapX2, y2); + } + } + + // Wrap-around vertical (pour les colonnes) + if (offsetY != 0f) { + float wrapY1 = y1, wrapY2 = y2; + + if (y2 > bordBasGrille) { + // La case déborde en bas → réapparaît en haut + wrapY1 = y1 - hauteurGrille; + wrapY2 = y2 - hauteurGrille; + dessinerRectCase(canvas, type, ligne, colonne, x1, wrapY1, x2, wrapY2); + } else if (y1 < borneHautGrille) { + // La case déborde en haut → réapparaît en bas + wrapY1 = y1 + hauteurGrille; + wrapY2 = y2 + hauteurGrille; + dessinerRectCase(canvas, type, ligne, colonne, x1, wrapY1, x2, wrapY2); + } + } + } + + /** + * Dessine le rectangle coloré d'une case + verrou + symbole daltonien. + */ + private void dessinerRectCase(Canvas canvas, int type, + int ligne, int colonne, + float x1, float y1, float x2, float y2) { + + // Couleur de fond + definirCouleur(type); + canvas.drawRoundRect(x1, y1, x2, y2, 20, 20, pinceauCase); + + // Assombrir si verrouillée + if (verrous[ligne][colonne]) { + pinceauCase.setARGB(120, 0, 0, 0); + canvas.drawRoundRect(x1, y1, x2, y2, 20, 20, pinceauCase); + } + + float cx = (x1 + x2) / 2f; + float cy = (y1 + y2) / 2f + - (pinceauSymbole.descent() + pinceauSymbole.ascent()) / 2f; + + // Symbole daltonien + if (modeDaltonien) { + canvas.drawText(SYMBOLES[type % NB_TYPES], cx, cy, pinceauSymbole); + } + + // Cadenas par-dessus si verrouillée + if (verrous[ligne][colonne]) { + canvas.drawText("🔒", cx, cy, pinceauSymbole); + } + } + + // ========================================================= + // Utilitaire privé + // ========================================================= + + private void definirCouleur(int type) { + switch (type % NB_TYPES) { + case 0: pinceauCase.setARGB(255, 200, 200, 200); break; + case 1: pinceauCase.setARGB(255, 255, 105, 180); break; + case 2: pinceauCase.setARGB(255, 90, 230, 200); break; + case 3: pinceauCase.setARGB(255, 100, 170, 255); break; + case 4: pinceauCase.setARGB(255, 255, 220, 90); break; + case 5: pinceauCase.setARGB(255, 255, 140, 90); break; + case 6: pinceauCase.setARGB(255, 255, 90, 90); break; + } + } +} \ No newline at end of file diff --git a/src/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/src/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/src/main/res/drawable/ic_launcher_background.xml b/src/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/src/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/src/main/res/drawable/ic_launcher_foreground.xml b/src/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/src/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/src/main/res/layout/activity_fin_partie.xml b/src/src/main/res/layout/activity_fin_partie.xml new file mode 100644 index 0000000..bca6399 --- /dev/null +++ b/src/src/main/res/layout/activity_fin_partie.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/src/main/res/layout/activity_seed.xml b/src/src/main/res/layout/activity_seed.xml new file mode 100644 index 0000000..7ac4c4a --- /dev/null +++ b/src/src/main/res/layout/activity_seed.xml @@ -0,0 +1,39 @@ + + + + + + + +