Finalisation de notre Machine à Sous en Rust avec Ratatui : Gestion des Mises, Calcul des Gains et Récompenses

Par akhakimov, 22 mars, 2025
écart type

Bonjour! Voilà, nous arrivons à la fin de cette série d'articles. Le temps passe vite, et ce septième article marque la conclusion de cette série. Dans le blog précédent, ensemble, nous avons réussi à mettre en œuvre une fenêtre contextuelle pour nos utilisateurs afin d'ajouter plus d'interaction avec notre application machine à sous. Cette fenêtre permettra désormais de modifier la mise et d'ajouter de la monnaie. Aujourd'hui, nous nous assurerons de récupérer les chiffres réels représentant le montant de l'utilisateur et de récompenser ses gains. C'est parti!

La démonstration de ce que nous allons accomplir ensemble dans ce blog.

Quoi faire?

C'est effectivement assez ironique et amusant de savoir que nous travaillons sur une machine à sous et que nous n'avons pas encore les fonctionnalités principales d'une machine à sous après six articles complets. Pas d'inquiétude, c'est temps! Mais c'est précisément ce qui rend le développement d'un projet si intéressant. Chaque étape est importante et il faut parfois établir des bases solides et mettre en place des structures avant de se lancer dans les fonctionnalités principales.

Dans un tutoriel aussi simple que le nôtre, cela prouve que la construction d’une interface utilisateur, la gestion des contrôles des boutons du clavier et des fenêtres avec Ratatui demande beaucoup de préparation avant de pouvoir intégrer des fonctionnalités de base.

Comme vous avez vu dans la démo, on va pouvoir:

  • Changer la mise
  • Ajouter de la monnaie
  • Afficher les paiements en fonctions de la mise actuelle
  • Dépenser de l'argent
  • Gagner de l'argent lorsque nous obtenons une combinaison de symboles

Commençons par l'affichage des prix et des combinaisons.

Affichage des paiements

Afficher les combinaisons

Pour garder les combinaisons possibles voulus, on va devoir créer un autre Struct appelé Montant. Montant va nous servir à ajuster la mise, le totale et les pertes. Pour les gains, c'est un cas spécial.

Créez un fichier nommé mod.rs dans le nouveau dossier nommé montant dans src (src/montant/mod.rs). Incluez le répértoire dans main.rs en ajoutant mod montant;

Définir Montant de cette manière:

pub struct Montant {
    pub mise: f32,
    pub total: f32,
    pub combinaisons: Vec<(String, f32)>,
}

combinaison est un vecteur qui permet uniquement à afficher les combinaisons de symboles cherchés. combinaison garde des collections d'éléments de différents types, communément nommé des tuples. Il va garder les emojis représentant certains symboles avec leur prix.

Formater un vecteur

Marge Si vous vous rappelez bien, on a une zone qui s'appelle info_layout, laquelle prend des textes avec le widget Ratatui Paragraph. Le texte inséré dans Paragraph ne prend pas en compte la taille des textes. Il ne saute pas de ligne automatiquement pour nous.

Comme la constante CONTENU dans src/iu/constants.rs affichait des informations erronées, nous devons la supprimer, car les constantes en Rust ne sont pas mutables. Nous devrons implémenter une solution alternative pour afficher les nouvelles informationss.

On va tout simplement créer une fonction appeler afficher qui retourne un vecteur contenant des string.

impl Montant {
	pub fn afficher(&self) -> Vec<String> {
		let mut affichage = Vec::new();

		affichage.push(format!(
			"Mise: ${:.2}\nTotale: ${:.2}",
			self.mise, self.total
		));

		let combinaison = self
			.combinaisons
			.iter()
			.map(|(symboles, retour)| format!("{} = ${:.2}", symboles, retour * self.mise))
			.collect::<Vec<_>>()
			.chunks(3)
			.map(|groupe| groupe.join(" | "))
			.collect::<Vec<_>>()
			.join("\n");
		affichage.push(combinaison);

		affichage.push("TOURNER".to_string());

		affichage
	}
}

Explication

Wow ! Voici un exemple des avantages de Rust : grâce à ses itérateurs et fermetures, le langage permet d'écrire un code très concis et lisible. Les méthodes comme map, collect et chunks facilitent le traitement des collections, rendant le code plus explicite.

Le premier élément du vecteur va nous permettre d'afficher la mise et le total acuelle. {:.2} (1) est un spécificateur de format utilisé dans le formatage des macros comme format!, dans notre cas. On itère combinaisons, formatant chaque paire (symboles, retour) sous forme de chaîne affichant le symbole et une valeur calculée. Il regroupe ensuite ces chaînes formatées en blocs de trois, joignant chaque groupe par « | » et enfin concatène tous les groupes avec \n pour sauter la ligne. Pourquoi multiplier retour * self.mise? Lorsque la mise va être modifié, le prix changera également en fonction du pari. Puis, on ajoute "TOURNER" pour notre bouton.

Dans src/iu/iu_machine/mod.rs , changer par

for ((index, titre), contenue) in TITRE
	.iter()
	.enumerate()
	.zip(application.montant.afficher().iter())

Dépense et gains

Cette partie est la plus facile, je vais vous montrer directement le code:

pub fn changer_mise(&mut self, mise: f32) {
    self.mise = mise;
}

pub fn ajouter_total(&mut self, total: f32) {
    self.total += total;
}

pub fn dépenser(&mut self) {
    self.total -= self.mise;
    self.perdu();
}

fn perdu(&mut self) {
    if self.total <= 0.0 {
        self.total = 0.0;
        self.mise = 0.0;
    }
}

Explication

Cet extrait de code définit des mutateurs permettant de modifier la mise, d'ajouter au total et de dépenser de l'argent. J'aimerais implémenter une gestion complète des pertes du joueur, mais par manque de temps, on s'assure simplement que lorsqu'il perd tout son argent, sa mise et son total restent à zéro. Il pourra se refinancer lui-même, mais il n'y a pas de gestion de fin de partie.

Soumettre l'entrée de l'utilisateur

Puisque c'est Application qui gère la gestion des entrées utilisateur, on ajoute montant comme nouveau champ.

pub struct Application {
    pub mixeur: Mixeur,
    pub symboles: Vec<Symbole>,
    pub montant: Montant, <-----------------
    pub saisie: Input,
    pub saisie_mode: SaisieMode,
    pub affichage_contextuel: bool,
    pub type_contextuel: TypeContextuel,
}

Dans la méthodeinitiliser() de l'Application, ajouter:

let montant = Montant {
	mise: 2.0,
	total: 450.0,
	gains: vec![
		("💠💠💠".to_string(), 2.0),
		("🍺🍺🍺".to_string(), 2.2),
		("⭐⭐⭐".to_string(), 2.0),
		("🍒🍒🍒".to_string(), 1.0),
		("🔔🔔🔔".to_string(), 2.0),
		("🍋🍋🍋".to_string(), 1.4),
		("🍌🍌🍌".to_string(), 2.0),
	],
};

et passé montant au constructeur Self

Pour soumettre une entrée, il faut d'abord définir dans quel type de fenêtre contextuelle nous sommes. Sommes-nous en train de changer la mise ou le total ? Nous allons gérer cette fonctionnalité de la manière suivante:

pub fn soumettre(&mut self) {
    match self.type_contextuel {
        TypeContextuel::Mise => self
            .montant
            .changer_mise(self.saisie.value().parse::<f32>().unwrap()),
        TypeContextuel::Totale => self
            .montant
            .ajouter_total(self.saisie.value().parse::<f32>().unwrap()),
    }
}

Et voilà, nous utilisons nos mutateurs pour modifier les champs du montant. C'est aussi simple que ça! Étant donné que saisie.value() retourne une chaîne de caractères (String), il faut convertir son type, car les mutateurs attendent un f32. En Rust, on utilise la méthode parse() (2). La syntaxe peut paraître étrange, mais l'opérateur ::<T> permet de spécifier en quel type nous voulons effectuer la conversion.

Controle

Il ne nous reste plus qu'à implémenter les fonctionnalités liées aux touches du clavier.

Pour la touche Entrée :

event::KeyCode::Enter => {
    application.soumettre();
    application.arrêter_édition();
}

Pour la touche Espace:

event::KeyCode::Char(' ') => {
	application.mélanger_symboles();
	application.montant.dépenser();
}

Calcul mathématique des prix

Et voilà ! La partie la plus intéressante et la plus importante de ce blog. Nous allons maintenant nous concentrer sur le calcul du gain lorsque le joueur obtient une combinaison. Gardez à l'esprit que nous avons 3 rouleaux et 7 symboles de poids différents. En fait, nous conserverons l'idée, comme pour une maison ou une entreprise, d'utiliser le taux de retour au joueur. Nous sommes une maison avare. Cela nous permet de conserver 55 % du gain du joueur. Ce qui veut dire que sur le long terme, pour chaque dollar misé, le joueur récupérera en moyenne 45 sous.

L'idée d'utiliser des multiplicateurs n'est pas du tout attrayante. Dire, par exemple, que la combinaison la plus rare rapporte 500 fois de la mise est injuste. Pourquoi? Dans notre contexte, les symboles sont pondérés. Certaines combinaisons seront plus rares que d'autres. Alors, comment déterminer le multiplicateur? Est-ce 500 fois ou 20 000 fois? Il est possible qu'à long terme, nous souhaitions ajuster les cotes. Les multiplicateurs changeront alors à nouveau.

Nous allons donc calculer les ratios en fonction de la probabilité. La formule est simple (3):

  • Probabilité de la combinaison = P(A∩B∩C)=P(A)×P(B)×P(C)
  • Paiment = Taux / Probabilité de la combinaison * Mise

Où A, B et C sont chacun calculé par = Cote/Poids totale Où poids totale est la somme de toutes les cotes de tous les symboles

Ex: 3 Diamants

  • 1 diamant = cote = 1
  • poids totale = 76
  • Probabilité de la combinaison = (1/76)^3 = 1/4389761​ ≈ 0.00000228 ≈ 0.000228%
  • Paiment = 45% / 0.00000228 * 2 = 394,736 $

Conclusion: Après avoir avoir misé 2$, si j'obtient 3 diamants, je gagne 394,736 $.

Il existe des formules beaucoup plus sophistiquées. Cette formule nous va.

Codons

Tout les codes ajoutés sont dans Impl de Application application.rs

Commençons par le plus simple: Calculer le poids totale des symboles:

pub fn poids_total(symboles: &[Symbole]) -> f32 {
	symboles
		.iter()
		.map(|symbole| symbole.pondération as f32)
		.sum()
}

Parcourt un tableau de Symbole, convertit la pondération de chaque symbole en f32 parce que pondération est en u8 , puis retourne la somme totale de ces pondérations sous forme de f32.


Par la suite la probabilité de la combinaison, la fonction s'appelle pondérations():

pub fn pondérations(symboles: &[Symbole], combinaison: Vec<Type>) -> Vec<f32> {
	combinaison
	.iter()
		.filter_map(|type_cherché| {
		    symboles
		        .iter()
		        .find(|&symbole| symbole.type_ == *type_cherché)
		        .map(|symbole| symbole.pondération.clone() as f32)
		})
		.collect::<Vec<_>>()
}

Prend en entrée un tableau de Symbole et un vecteur de types, appelé combinaison. Elle parcourt chaque élément de combinaison puis par la suitere cherche un Symbole correspondant au type dans le tableau symboles à l'aide de find. Si un Symbole est trouvé, sa pondération est extraite, clonée, et convertie en f32. Les pondérations obtenues sont par la suite collectées dans un vecteur.

Exemple de sortie: [14, 12, 1]


Passons maintenant à trouver le paiement idéal:

pub fn calculer_paiement_combinaison(symboles: &[Symbole], combinaison: Vec<Type>) -> f32 {
	let mut probabilité = 1.0;
	let taux_de_retour_au_joueur = 0.45;

	for pondération in Self::pondérations(symboles, combinaison) {
	    probabilité *= pondération / Self::poids_total(symboles)
	}

	let paiement = taux_de_retour_au_joueur / probabilité;
	paiement.round()
}

On itère la sortie de la fonction pondérations() et calcul la probabilité:

Ex: 14/76 * 12/76 * 1/76

Et calcul le paiement et arrondi à l'unité près

Ce qui est plutôt intéressant avec la fonction calculer_paiement_combinaison() c'est qu'elle est effectivement dépendante de l'état de Application, mais elle ne fait pas directement partie de la structure Application. À la place, elle est définie dans l'implémentation de Application (Impl), ce qui la rend disponible à travers la structure. Cependant, elle agit comme une fonction utilitaire pour calculer les paiements en fonction des symboles et de leurs pondérations, ce qui implique qu'elle doit avoir accès à certaines informations contenues dans Application. Elle est plus utilitaire.


Changer montant dans Application:

À la place de passer des chiffres aux hazards comme deuxième éléments du tuple, utiliser par exemple:

Self::calculer_paiement_combinaison(&SYMBOLES, vec![Type::Diamant; 3])

Récompenser le joueur

pub fn payer(&mut self) {
	if self.similaire(self.symboles.clone()) {
	   let symbole_type = self.symboles[0].type_.clone();
	   let montant_gagné =
	       Self::calculer_paiement_combinaison(&SYMBOLES, vec![symbole_type; 3])
	           * self.montant.mise;
	   self.montant.ajouter_total(montant_gagné);
	}
}

pub fn similaire(&self, symboles: Vec<Symbole>) -> bool {
	symboles
	   .get(0)
	   .map(|premier_symbole| symboles.iter().all(|x| x.type_ == premier_symbole.type_))
	   .unwrap_or(true)
}

Pour l'intstant, la seul manière de se faire récomponser, le joueur doit avoir 3 symboles similaires (4).

Ajouter par la suite payer() à la touche touches du Espace:

event::KeyCode::Char(' ') => {
	application.mélanger_symboles();
	application.montant.dépenser();
	application.payer(); <------------
}

Conclusion

Dans ce blog, nous avons implémenté la gestion des mises et d'argent en récupérant l'entrée utilisateur, l'affichage des gains et le calcul des probabilités de récompense.

La démonstration de ce que nous avons accompli ensemble dans ce blog.

Ce qui pourrait être intéressant d'ajouter:

  • Enregistrez dans un fichier ou une base de données toutes les dépenses effectuées et les pertes.
  • Une page d'accueil principale
  • Gestion du jeu terminé
  • Optez pour plus de 3 rouleaux (grille 4 x 5)
  • Tour de bonus intense
  • Jackpot progressif
  • Graphique et animation
  • Personnalisation de l'interface (de la machine)
  • Multiplicateurs dynamiques
  • Mini-jeux
  • Suivi de statistique

Remerciement

Je tiens à remercier tous ceux qui m'ont soutenu et encouragé tout au long de la rédaction de ce tutoriel Ratatui. Merci également à la communauté de développeurs de Ratatui. Mais surtout, merci à vous, chers lecteurs, d'avoir pris le temps de suivre ce tutoriel. J'espère qu'il vous aura été utile et inspirant pour vos projets de développement.

Code

Pour revenir au code vous pouvez accéder au Projet.

Références

  1. STACK OVERFLOW, How does one round a floating point number to a specific number of digits, https://stackoverflow.com/questions/28655362/how-does-one-round-a-floating-point-number-to-a-specified-number-of-digits (Page consultée le 20 mars 2025).
  2. STACK OVERFLOW, Convert a String to Int https://stackoverflow.com/questions/27043268/convert-a-string-to-int (Page consultée le 20 mars 2025).
  3. Anne PERRUT, Cours de probabilités et statistiques , 2010, https://math.univ-lyon1.fr/irem/IMG/pdf/PolyTunis_A_Perrut.pdf (Page consultée le 21 mars 2025).
  4. GITHUB, _ Determining if a Rust Vector has all equal elements_ https://sts10.github.io/2019/06/06/is-all-equal-function.html (Page consultée le 21 mars 2025).

Étiquettes

Commentaires1

qtang

il y a 4 heures 17 min

Super article! J'ai beaucoup aimé ta manière claire et structurée d'aborder les concepts techniques, ce qui facilite vraiment la compréhension, même pour quelqu'un qui n'est pas familier avec Rust. L'explication détaillée des calculs probabilistes était particulièrement intéressante! Selon toi, quelle fonctionnalité complémentaire (par exemple le jackpot progressif ou les mini-jeux) ajouterait le plus de valeur à une machine à sous comme celle-ci?