Prédiction de trajectoires

Au fil des années sur Gamemaker puis sur Godot, j’ai développé pas mal de jeux nécessitant de la prédiction de trajectoire. Pour moi c’est les balles: golf, minigolf, billard. Mais il y a plein d’autres applications: certaines armes dans Worms, le lancer d’Angry birds, ou n’importe quel pachinko-like comme Peggle.

Ce qui me régale dans la développement de jeux vidéos, c’est qu’on peut dès cette première seconde de prototype d’un jeu de mini golf et sur chaque mini détail, on peut tomber dans un puits sans fond d’améliorations, d’optimisations, d’ajout de juice.

Dans Gold madness (un de ces jeux que je n’ai jamais fini), j’ai opté pour la base d’une prédiction. Un trait. Pour savoir où la balle partira quand on relâche le clic, c’est simple, fonctionnel et j’ai déjà « perdu » beaucoup de temps sur des jeux avec moins que ça.

Un poil plus complexe en 3D, dans Chilly Greens, je voulais une prédiction fidèle de la trajectoire. Presque plus par défi que pour améliorer le jeu. Et au final, le gameplay ressemble même presque plus à un puzzle game qu’à un jeu de Golf.

Enfin dans Poooooool, retour à la 2D, mais ce coup-ci avec un mix entre prédiction mathématiques et itérations.

Golf madness ou les bases.

Pour Golf madness, dans l’introduction, je parle d’un trait. Mais en vrai, c’est un vecteur, et c’est plus pratique vu qu’il donne directement la direction et la puissance.

Quand l’utilisateur mouse_down, j’enregistre la position de la souris, puis à chaque frame, je regarde la position de la souris et je récupère le vecteur entre ces deux points.

Avec ça, j’ai plus qu’à le dessiner en partant de la balle. On a déjà la bonne direction et la bonne taille. Et voilà le résultat. Ici, j’ai juste ajouté une représentation du drag et une limite pour la puissance du tir.

Exemple d'une prédiction simpleGodot 4.7
extends Node2D

# Arbitrary position here. Could be the character, the ball, etc.
@export var ball_position : Vector2 = Vector2(576, 324)

# The max distance you can drag, to clamp the shot force for example
@export var drag_max_distance : float = 200.0

var is_dragging : bool = false

var drag_init_position : Vector2 
var drag_vector : Vector2 

func _input(event: InputEvent) -> void:
	# On click, save the drag init position
	if event.is_action_pressed("click"):
		is_dragging = true
		drag_init_position = get_viewport().get_mouse_position()
	
	# On click release, stop the drag and reset the drag vector
	if event.is_action_released("click"):
		is_dragging = false
		drag_vector = Vector2.ZERO
		queue_redraw()
		
func _process(_delta: float) -> void:
	if is_dragging:
		# Calc the drag vector
		var drag_current_position = get_viewport().get_mouse_position()
		drag_vector = (drag_init_position - drag_current_position).limit_length(drag_max_distance)
		queue_redraw()
			
func _draw() -> void:
	# Draw the drag vector from the intended position
	if is_dragging:
		draw_line(ball_position, ball_position - drag_vector, Color.WHITE, 5)

Chilly Greens et prédiction parfaite.

Pour Chilly Greens, ma première approche a été d’essayer de faire ça mathématiquement, mais j’ai jamais réussi à rendre ça fiable. J’avais toujours des décalages dû à la friction, ou des manques de précisions en général.

Heureusement, j’ai découvert que la fonction que j’utilisais pour la physique de la balle move_and_collide avait un paramètre test_only.

Si on se réfère à la documentation:

If test_only is true, the body does not move but the would-be collision information is given.

Donc si le paramètre test_only est true, le body (la balle pour nous) ne bouge pas mais les informations de collision éventuelle sont quand même données.

Exactement ce dont j’avais besoin. Avec ça, je peux simuler un tir, récupérer à chaque frame la position de la balle, et retranscrire visuellement chaque étape.

Une réussite instantanée :

Allez, je suis peut-être passé par quelques étapes pour rendre ça viable et agréable. Mais j’aime beaucoup le résultat.

La première chose à résoudre, c’était les rebonds. Quand la balle va vite, il peut y avoir de grandes distances entre deux frames. S’il y a un rebond entre les deux, je ne peux pas simplement dessiner un trait entre les deux positions. On s’en sort en ajoutant au tracé quelques positions clés en plus comme la position de chaque collision.

Le deuxième problème c’est qu’en récupérant la position à chaque frame, la distance entre deux positions dépend de la vitesse de la balle. Donc une fois que j’ai récupéré la trajectoire globale. Je la redécoupe en parts égale pour avoir une prédiction plus esthétique.

Et voilà, pour le résultat final :

Poooooool ou la puissance de Pythagore.

Pour Poooooool c’était une autre histoire. Pour la physique des boules, j’utilise le moteur Godot, donc sans move_and_collide, il fallait que je trouve une autre solution.

Itération simple.

Je sais dans quelle direction va la boule blanche. Donc ça c’est facile. Une solution simple serait de se déplacer d’un step de 1 pixel dans cette direction. Je vérifie s’il y a un contact. Pas de contact ? Je continue. Et on recommence pendant 100, 200, 500 itérations. Si je trouve un contact, je stoppe la fonction, je recule d’une itération et je récupère la position juste avant le contact. Parfait.

Mais on peut essayer d’optimiser un peu. On pourrait agrandir le step de départ. 100px par exemple, ça irait plus vite même on perdrait en précision.

L’idéal serait de commencer avec des grands steps. Une fois qu’on a le contact, on repart de la dernière position avant le contact. On divise la taille du step par 10 et on recommence à partir de cette position pour retrouver le contact. Allez, et une dernière fois en divisant une nouvelle fois par 10 si on veut être ultra précis. Et honnêtement à l’œil, je ne fais plus la différence.

Juste avec cette optimisation, dans l’exemple ci dessous, on passe de 400 steps à 24 steps.

Voila plus qu’à faire ça à chaque frame et on est bon. Bon, ça semble pas être la solution la plus intelligente ou la plus jolie, mais elle fonctionne et je l’utilise toujours dans certains cas spécifiques aujourd’hui.

Mais quand même, j’avais bien envie de trouver une solution mathématique à ce problème.

Retour en 3ème avec Pythagore

En premier, il faut trouver quelles sont les boules avec lesquelles la boule blanche va entrer en collision. Pour ça, on peut faire une projection vectorielle de chaque boule sur le tapis, et regarder si le segment entre la projection et le centre de la boule est plus petit que le rayon de la blanche + le rayon de la boule. Les deux rayons étant identiques, on peut simplifier par le diamètre (2r).

On a trouvé une collision, on cherche maintenant l’endroit exact de la collision. Pour ça, on revient un peu en arrière jusqu’à ce que les boules ne se touchent plus. Et il reste plus qu’à trouver comment calculer ce retrait (en rouge sur l’image).

Et par chance (et par pythagore), c’est assez simple. On connait déjà la longueur de deux des côtés et on est dans un triangle rectangle grâce la projection vectorielle. On connait x, la distance entre le centre de la boule et la projection vectorielle, et on connait la distance voulue entre les deux centres puisque c’est d. Il ne reste plus qu’à appliquer le théorème de pythagore pour trouver y.

Juste avec ce calcul, plus besoin d’itérations pour prédire les contacts sur la table.

Conclusion

Au final, chaque solution présentée peut très bien fonctionner selon le type de jeu et le résultat attendu. Pour exemple, des gens ont adoré et détesté la prédiction parfaite de Chilly Greens, certains pour sa prise en main, d’autres pour son manque de difficulté. Et pour Poooooool que ce soit de l’itération ou un calcul mathématique, la prédiction fonctionnait parfaitement sans lag. Pour Golf madness, la prédiction super simple rend le jeu plus chaotique et fun. Il faut juste essayer au maximum d’aller dans le sens du jeu et du gamefeel qu’on essaie de créer. De mon côté, c’était intéressant de faire plein de versions différentes, convaincu à chaque fois que la nouvelle était la meilleure.

Si vous voulez tester les versions démo des jeux cités:
Chilly greens sur itch
Poooooool sur itch

Avatar de Emmanuel Cook (godDonut)