Introduction

Dans le premier article de cette série sur les kata de programmation, nous avons mis en place à travers une approche TDD, les structures de données et la logique métier qui constitue la base de notre solution. En effet, les deux structures de données Cell et Game sont normalement indépendants de toute implémentation graphique (UIKIT, SwiftUI).

Dans cet article, nous proposons une implémentation graphique de notre solution à base de UIKIT et CoreGraphics. Nous tenons pas à utiliser une approche TDD au niveau de la construction de notre interface. Nous dédions un autre article prochainement sur la construction graphique à base de TDD.

Bonne lecture 🙂

Simulation finale

Création Graphique avec UIKIT et CoreGraphics

Le jeu de la vie est composé d’un ensemble de cellules qui vivent et meurent à l’intérieur d’une grille. On commencera par la création d’une classe GameView qui constitue la grille. Cette classe héritera de UIView. Pour créer des objects graphiques à l’intérieur de la vue, on doit redéfinir la méthode draw(_ rect: CGRect) et ajouter la logique de création des cellules selon les dimensions de la GameView.

import UIKit

final class GameView: UIView {
    override func draw(_ rect: CGRect) {
        // add drawing logic there
    }
}

Afin de connaître les dimensions et aussi déterminer la couleur des cellules, on doit injecter une instance de la classe Game à l’intérieur de GameView et faire appel au constructeur parent UIView afin d’initialiser la vue.

final class GameView: UIView {
    private let game: Game
    
    init(game: Game) {
        self.game = game
        super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 0))
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func draw(_ rect: CGRect) {
        // add drawing logic there
    }
}

Passons à l’implémentation de la méthode draw. On aura besoin maintenant de parcourir notre game et de créer pour chaque cellule un carré représentatif avec une bordure et un arrière plan selon l’état de la cellule morte ou vivante.

override func draw(_ rect: CGRect) {
   let context = UIGraphicsGetCurrentContext()
   game.cells.forEach { cell in
      context?.setFillColor(fillColorForCell(state: cell.state).cgColor)
      context?.addRect(frameForCell(cell: cell))
      context?.fillPath()
   }
}

func fillColorForCell(state: State) -> UIColor {
   switch state {
   case .Alive:
   return UIColor.green
   case .Dead:
   return UIColor.white
   }
}

func frameForCell(cell: Cell) -> CGRect {
    let dimensions = CGFloat(game.dimensions)
    let cellSize = CGSize(
        width: self.bounds.width / dimensions,
        height: self.bounds.height / dimensions
    )
    return CGRect(
      x: CGFloat(cell.x) * cellSize.width,
      y: CGFloat(cell.y) * cellSize.height,
      width: cellSize.width,
      height: cellSize.height
     )
}

La méthode fillColorForCell(state: State) permet de déterminer la couleur de la cellule selon le state (Alive, Dead)qu’on recalcule à chaque génération. Ensuite la méthode frameForCell(cell: Cell) construira la cellule et retournera à chaque fois un CGRect. Voyons de près cette méthode.

Tout d’abord, on calcule la dimension de la cellule par rapport à la dimension de la grille (notre grille est un carré) et le nombre de cellules choisit ensuite lorsqu’on créera notre GameOfLifeViewController. Alors, la largeur de notre cellule sera égale à au self.bounds.width / dimensions, de la même manière pour la hauteur.

Une fois, on connait les dimensions de notre cellule, il faut déterminer ces coordonnées au niveau de la grille par rapport aux cordonnées x et y définis dans la structure de données Cell. La position X de la cellule sur la grille sera la multiplication de ‘sa position logique‘ par la hauteur ou largeur (CGFloat(cell.x) * cellSize.width par exemple)

On a maintenant la cellule et son background, on utilisera la méthode context?.fillPath() pour dessiner la cellule.

Pour plus d’informations sur CoreGraphics et comment l’utiliser pour dessiner en iOS, cet article de Paul Hudson sera une très bonne entrée.

Je pense qu’on a terminé la construction de la vue. Mettons alors la vue à l’intérieur de notre GameOfLifeViewController.

import UIKit

final class GameOfLifeViewController: UIViewController {

    private let game = Game()
    private let gameView: GameView

    required init?(coder: NSCoder) {
        self.gameView = GameView(game: game)
        super.init(coder: coder)
        initializeGame()
    }

    // MARK: - helpers
    
    private func initializeGame() {
        for _ in 0...200 {
            let x = getRandomLocation()
            let y = getRandomLocation()
            game[x,y]!.state = .Alive
        }
    }

    private func getRandomLocation() -> Int {
        return Int(arc4random()) % game.dimensions
    }
}

Notre GameOfLifeViewController a besoin de la GameView et aussi notre Game. L’idée est de remplir aléatoirement la moitié de la grille (200 cellules) par des cellules vivantes. rappelons que la dimension de la grille est égale à 20*20 (400 cellules) qu’on a définit dans le premier article au niveau de la classe Game.

La méthode initializeGame() permet de réaliser cette initialisation en exploitant la méthode getRandomLocation(). Cette dernière retournera X et Y deux entiers entre 0 et 19 représentant les coordonnées logiques de la cellule.

On a initialisé notre jeu mais on n’a pas encore inséré notre gameView à l’intérieur de notre GameOfLifeViewController. Pour ce faire on peut ajouter ce code dans le ViewDidLoad()

override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        setupGameView()
}

private func setupGameView() {
  let margin: CGFloat = 20.0
  let size = view.frame.width - margin * 2
  let frame = CGRect(
      x: margin,
      y: (view.frame.height - size) / 2.0,
      width: size,
      height: size
    )
   gameView.frame = frame
   gameView.layer.borderColor = UIColor.blue.cgColor
   gameView.layer.borderWidth = 2
   view.addSubview(gameView)
}

On veut garder une marge à droite et à gauche de 20 pixels. La hauteur et la largeur de toute la grille sera égale à la view.frame.width – 40. Afin de centrer la grille, la position y sera égale à (view.frame.height – size) / 2.0.

Si nous lançons maintenant notre code, on aura l’affichage de la grille mais seulement pour une seule génération. Il faut tout simplement appeler chaque ‘n’ secondes la méthode computeNextGeneration() qu’on a définit dans la première partie de cette série d’articles.

Pour ce faire, on peut utiliser les schedulers de la bibliothèque Combine.

var cancellable: Cancellable?

override func viewDidLoad() {
 cancellable = Timer.publisher(every:2, on: .main, in: .default)
     .autoconnect()
     .subscribe(on: DispatchQueue.main)
     .sink { [weak self] in 
        self?.tick()
     }
}

func tick() {
  game.computeNextGeneration()
  gameView.setNeedsDisplay()
}
   

Conclusion

Nous jugeons que l’implémentation graphique de notre jeu de la vie était simple vu que la logique métier était assez indépendante. Vous retrouvez pas aucune utilisation graphique au niveau des class Cell et Game. Néanmoins que l’algorithme de détermination des cellules voisins doit être revu afin de l’optimiser et ça sera l’objectif du prochain article.

A très bientôt.