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.
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.
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.
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.
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).
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.