Aller au contenu
Accueil » Blog » Down the Mines : gentille structure et méchantes collisions de notre jeu !

Down the Mines : gentille structure et méchantes collisions de notre jeu !

Après avoir géré les premiers réglages de l’architecture de Down the Mines, il faut considérer ses bases en termes de code. On va donc structurer le jeu autour du concept d’Ebitengine, et aller jusqu’à gérer les collisions !

Les méthodes demandées par Ebiten

Ebiten, pour fonctionner, demande d’implémenter 3 méthodes : Update, Draw et Layout. Grâce à ces 3 méthodes, il peut gérer la taille de la fenêtre (Layout), modifier et afficher l’écran du jeu à chaque tick (Update et Draw). D’ailleurs, voici un exemple du Hello World tiré du site d’Ebitengine :

type Game struct{}

func (g *Game) Update() error {
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	ebitenutil.DebugPrint(screen, "Hello, World!")
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
	return 320, 240
}

func main() {
	ebiten.SetWindowSize(640, 480)
	ebiten.SetWindowTitle("Hello, World!")
	if err := ebiten.RunGame(&Game{}); err != nil {
		log.Fatal(err)
	}
}

Pour notre jeu, ces 3 méthodes sont plutôt simple à implémenter. Mais intéressons-nous d’abord à la structure de notre jeu. Celui-ci comprend une carte, et un joueur. La carte change à chaque niveau. C’est pour ça que notre structure intègre les 3 informations.

type Game struct {
	Level  int
	Mapp   *Map
	Player *Player
}

Derrière, en découlent nos méthodes, qui ne font que le relai du jeu vers le joueur et la carte.

func (game *Game) Update() error {
	game.Player.Update(game)

	return nil
}

func (game *Game) Draw(screen *ebiten.Image) {
	game.Mapp.Draw(screen, game)
	game.Player.Draw(screen)
}

func (game *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
	return GAME_WIDTH + 2*SCREEN_MARGIN, GAME_HEIGHT + 2*SCREEN_MARGIN
}

Nota Bene : j’ai ajouté des marges (noires pour l’instant) autour du jeu, pour plus tard y mettre un décor et quelques informations.

Histoire d’être complet et de vous donner une meilleure idée du jeu, voici les structures Map et Player.

type Player struct {
	HP             int
	Stamina        int
	Position       *utils.Position
	Renderable
	directionUp    bool
	directionDown  bool
	directionLeft  bool
	directionRight bool
}

type Map struct {
	Map      [][]Cell
	toRedraw bool
	img      *ebiten.Image
	trou     *Trou
}

type Cell struct {
	Position utils.Position
	Layers   []IRenderable
}

Le Renderable, et son interface IRenderable, sont des conteneurs pour la logique d’affichage de chaque élément de la carte. La carte, justement, qui est composée de plusieurs cellules (Cell), chacune comprenant des Layers qui sont… des Renderable !

A partir de là, le joueur s’affiche lui-même aux bonnes coordonnées, la carte également (en bouclant sur chacune de ses cellules), et la fenêtre s’affiche correctement.

Un écran de mon jeu
La fenêtre qui s’affiche ressemble à ça à ce stade.

Dans la méthode update, on gère le clavier pour pouvoir modifier les coordonnées du joueur si l’utilisateur appuie sur une flèche directionnelle.

func (player *Player) Update(game *Game) {
	var deltaX float64 = 0
	var deltaY float64 = 0
	var speed = float64(300 / ebiten.TPS()) // Move 300 px / sec

	if ebiten.IsKeyPressed(ebiten.KeyShift) {
		speed *= 1.5
	}

	if ebiten.IsKeyPressed(ebiten.KeyDown) && player.Position.Y < GAME_HEIGHT-int(SPRITE_HEIGHT) {
		deltaY = math.Min(speed, float64(GAME_HEIGHT-player.Position.Y)-SPRITE_HEIGHT)
	}

	if ebiten.IsKeyPressed(ebiten.KeyUp) && player.Position.Y > 0 {
		deltaY = math.Max(-speed, float64(-player.Position.Y))
	}

	if ebiten.IsKeyPressed(ebiten.KeyRight) && player.Position.X < GAME_WIDTH-int(SPRITE_WIDTH) {
		deltaX = math.Min(speed, float64(GAME_WIDTH-player.Position.X)-SPRITE_WIDTH)
	}

	if ebiten.IsKeyPressed(ebiten.KeyLeft) && player.Position.X > 0 {
		deltaX = math.Max(-speed, float64(-player.Position.X))
	}

	if deltaX != 0 || deltaY != 0 {
		// Check for diagonal movement
		if deltaX != 0 && deltaY != 0 {
			factor := speed / math.Sqrt(deltaX*deltaX+deltaY*deltaY)
			deltaX *= factor
			deltaY *= factor
		}

		player.moveBy(int(deltaX), int(deltaY))

		if b, rend := game.Mapp.existsCollisionWithPlayer(player); b {
			rend.handleCollision(game)
		}
	}
}

De la gestion des collisions

Dans l’extrait de code précédent, on note à la fin une recherche de collision. Je vous fais une petite mise en contexte à ce propos.

La structure d’un Renderable ressemble à ce qui suit.

type Renderable struct {
	ID     int
	ZIndex int
	Asset  *ebiten.Image
	utils.CollisionArea
}

Chaque objet sur la carte est un Renderable, sauf le joueur qui est un peu à part. Chaque objet a donc son propre asset, comme je l’expliquais dans l’article précédent.

Les sprites du jeu Down the Mines
Les différents Renderable de Down the Mines.

Sauf que chacun de ces objets peut entrer en collision avec le joueur. Et c’est là que ça se complique. Car pour détecter l’entrée en collision de deux éléments, les coordonnées ne suffisent pas. Il faut également prendre en compte leur forme et la taille qu’ils prennent.

Pour simplifier le problème, je n’ai traité que deux formes : le cercle et le carré. Et à partir de là, en donnant un centre à la forme, en connaissant sa taille et la position du centre, on peut en déduire s’il y a collision entre deux surfaces de collision (aussi appelées hit box en anglais).

type CollisionArea struct {
	Center   Position
	IsCircle bool     // Est-ce un cercle (true) ou un carré (false) ?
	Size     int      // = le rayon du cercle ou le demi-côté du carré (en pixels)
}

Vu que j’ai simplifié le problème, j’ai pu le résoudre d’une manière assez simple. Pour commencer, si les deux hit boxes sont des cercles, il suffit de calculer la distance qui sépare les centres et de voir si c’est plus proche que les deux rayons. Pour deux carrés, il suffit de voir si les distances, sur l’axe des abscisses comme des ordonnées, sont plus courtes que les deux tailles des carrés.

Là où ça se complique légèrement, c’est dans le cas mixte carré-cercle. Il faut alors déduire le point du carré le plus proche du centre du cercle, et voir si ce point-là est plus proche du centre que la taille du rayon.

Pour déterminer le point du carré le plus proche, c’est relativement facile en se projetant dans un repère cartésien.

Illustration de la collision ou non d'un cercle et d'un carré dans un repère cartésien
On voit ici que le point le plus proche pour le carré bleu a une distance au centre rouge plus petite que le rayon du cercle, alors que le point le plus proche du carré vert non.

On a maintenant un système qui nous permet de repérer des collisions entre nos objets ! C’est génial ! Je vous le mets en intégral, avec mes commentaires en anglais. 🙂

func (area CollisionArea) CollidesWith(other CollisionArea) bool {
	if area.IsCircle && other.IsCircle { // Both circles
		// We check if circles are closer than the sum of their two radiuses
		return area.Center.NormTo(other.Center) <= float64(area.Size+other.Size)

	} else if !area.IsCircle && !other.IsCircle { // Both squares
		// We check that their "centers" are close enough on each axis
		return math.Abs(float64(other.Center.X-area.Center.X)) <= float64(area.Size+other.Size)/2 &&
			math.Abs(float64(other.Center.Y-area.Center.Y)) <= float64(area.Size+other.Size)/2

	} else { // One of each
		var square = area
		var circle = other

		if square.IsCircle {
			square = other
			circle = area
		}

		/**
		 * 1. Let's calculate the point
		 * from the square
		 * closest to the center of the circle
		 */
		var closestPoint Position

		// Other is a square
		var p0 = Position{
			// p0 is the bottom-left corner of other
			X: square.Center.X - square.Size,
			Y: square.Center.Y - square.Size,
		}

		var p2 = Position{
			// p2 is the top-right corner of other
			X: square.Center.X + square.Size,
			Y: square.Center.Y + square.Size,
		}

		// Find closest X
		if circle.Center.X > p2.X {
			closestPoint.X = p2.X
		} else if circle.Center.X < p0.X {
			closestPoint.X = p0.X
		} else {
			closestPoint.X = circle.Center.X
		}

		// Find closest Y
		if circle.Center.Y > p2.Y {
			closestPoint.Y = p2.Y
		} else if circle.Center.Y < p0.Y {
			closestPoint.Y = p0.Y
		} else {
			closestPoint.Y = circle.Center.Y
		}

		/**
		 * 2. Calculate ditance
		 * between the closest point and the center of the circle
		 * to know if in circle or not
		 * (so if collision or not)
		 */
		return closestPoint.NormTo(circle.Center) <= float64(circle.Size)
	}
}

A partir de là, ça devient intéressant. En faisant en sorte que chaque Renderable sache quoi faire en cas de collision avec le joueur, par exemple l’escalier avance d’un niveau, le joyau disparaît, le rocher repousse le joueur, … on a un jeu qui commence à ressemble à quelque chose.

Une petite démo du jeu en l’état actuel des choses.

En bonus, voici à quoi ressemble le jeu en mode « Matrix » (en fait c’est juste que je désactive l’affichage des Renderable, mais l’affichage des debugs de hit box reste actif).

Le jeu Down the Mines, mais en mode "Matrix"
Le jeu Down the Mines, mais en mode « Matrix ».

Résumé

Si on résume, on a donc :

  • Un jeu qui s’affiche correctement
  • Des Renderable qui s’auto-gèrent et s’affichent eux-mêmes
  • Une détection de collision fonctionnelle
  • Une interaction entre le joueur et les différents objets du jeu

Continuer sa lecture

Cet article s’inscrit dans une série d’articles qui traitent de la création de mon jeu, Down the Mines, que vous pouvez par ailleurs retrouver en ligne sur son site dédié.

Vous pouvez retrouver l’article précédent de la série, qui parlait de la mise en place de l’architecture et des assets du jeu et s’intitulait Créons ensemble les bases de notre « joli » petit jeu vidéo.

Le prochain article parlera de l’amélioration de l’UI du jeu, et j’en mettrai ici le lien dès qu’il sera paru.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *