I mentioned in a previous blog post that I received an issue request to build games in Glimmer DSL for LibUI as examples.
Three games were built to fully address that issue request: Tetris, Tic-Tac-Toe, and most recently Snake.
In fact, Snake has been built test-first following the MVP (Model / View / Presenter) architectural pattern.
The View code written in Glimmer DSL for LibUI is very simple and short:
# From: https://github.com/AndyObtiva/glimmer-dsl-libui#snake require 'glimmer-dsl-libui' require 'glimmer/data_binding/observer' require_relative 'snake/presenter/grid' class Snake CELL_SIZE = 15 SNAKE_MOVE_DELAY = 0.1 include Glimmer def initialize @game = Model::Game.new @grid = Presenter::Grid.new(@game) @game.start create_gui register_observers end def launch @main_window.show end def register_observers @game.height.times do |row| @game.width.times do |column| Glimmer::DataBinding::Observer.proc do |new_color| @cell_grid[row][column].fill = new_color end.observe(@grid.cells[row][column], :color) end end Glimmer::DataBinding::Observer.proc do |game_over| Glimmer::LibUI.queue_main do if game_over msg_box('Game Over!', "Score: #{@game.score} | High Score: #{@game.high_score}") @game.start end end end.observe(@game, :over) Glimmer::LibUI.timer(SNAKE_MOVE_DELAY) do unless @game.over? @game.snake.move @main_window.title = "Glimmer Snake (Score: #{@game.score} | High Score: #{@game.high_score})" end end end def create_gui @cell_grid = [] @main_window = window('Glimmer Snake', @game.width * CELL_SIZE, @game.height * CELL_SIZE) { resizable false vertical_box { padded false @game.height.times do |row| @cell_grid << [] horizontal_box { padded false @game.width.times do |column| area { @cell_grid.last << path { square(0, 0, CELL_SIZE) fill Presenter::Cell::COLOR_CLEAR } on_key_up do |area_key_event| orientation_and_key = [@game.snake.head.orientation, area_key_event[:ext_key]] case orientation_and_key in [:north, :right] | [:east, :down] | [:south, :left] | [:west, :up] @game.snake.turn_right in [:north, :left] | [:west, :down] | [:south, :right] | [:east, :up] @game.snake.turn_left else # No Op end end } end } end } } end end Snake.new.launch
Basically, the game consists of the following models in the Model layer:
- Game: general manager of the game including scoring and game over state
- Snake: handles snake movement including vertebra locations and collided state
- Vertebra: represents a small part of a snake's body that gets added every time the snake eats an apple
- Apple: represents the apple that is generated at random locations while the snake is moving
Additionally, the game has the following presenters in the Presenter layer:
- Grid: contains all colored 40x40 cells that are shown in the View. The Grid basically monitors the Game Snake and Apple locations and updates its cell colors accordingly following the Observer pattern.
- Cell: represents a single cell with its color that will be shown in the View
Finally, the View layer is simply the Glimmer DSL for LibUI Snake example app, which wires everything together.
Here are the game specs (spec/examples/snake/model/game_spec.rb), which start by gradually testing the movement of a bodyless snake head and then test adding vertabrae bit by bit by eating apples (you may skip if you want to check out the Model and Presenter code included after the specs):
# From: https://github.com/AndyObtiva/glimmer-dsl-libui/blob/master/spec/examples/snake/model/game_spec.rb require 'spec_helper' require 'examples/snake/model/game' RSpec.describe Snake::Model::Game do it 'has a grid of vertebrae of width of 40 and height of 40' do expect(subject).to be_a(Snake::Model::Game) expect(subject.width).to eq(40) expect(subject.height).to eq(40) end it 'starts game by generating snake and apple in random locations' do subject.start expect(subject).to_not be_over expect(subject.score).to eq(0) expect(subject.snake).to be_a(Snake::Model::Snake) expect(subject.snake.length).to eq(1) expect(subject.snake.head).to be_a(Snake::Model::Vertebra) expect(subject.snake.head).to eq(subject.snake.vertebrae.last) expect(subject.snake.head.row).to be_between(0, subject.height) expect(subject.snake.head.column).to be_between(0, subject.width) expect(Snake::Model::Vertebra::ORIENTATIONS).to include(subject.snake.head.orientation) expect(subject.snake.length).to eq(1) expect(subject.apple).to be_a(Snake::Model::Apple) expect(subject.snake.vertebrae.map {|v| [v.row, v.column]}).to_not include([subject.apple.row, subject.apple.column]) expect(subject.apple.row).to be_between(0, subject.height) expect(subject.apple.column).to be_between(0, subject.width) end it 'moves snake of length 1 east without going through a wall' do direction = :east subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(0) expect(subject.snake.head.orientation).to eq(direction) subject.apple.generate(initial_row: 20, initial_column: 20) expect(subject.apple.row).to eq(20) expect(subject.apple.column).to eq(20) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(1) end it 'moves snake of length 1 east going through a wall' do direction = :east subject.start subject.snake.generate(initial_row: 0, initial_column: 39, initial_orientation: direction) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(39) expect(subject.snake.head.orientation).to eq(direction) subject.apple.generate(initial_row: 20, initial_column: 20) expect(subject.apple.row).to eq(20) expect(subject.apple.column).to eq(20) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(0) end it 'moves snake of length 1 west without going through a wall' do direction = :west subject.start subject.snake.generate(initial_row: 0, initial_column: 39, initial_orientation: direction) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(39) expect(subject.snake.head.orientation).to eq(direction) subject.apple.generate(initial_row: 20, initial_column: 20) expect(subject.apple.row).to eq(20) expect(subject.apple.column).to eq(20) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(38) end it 'moves snake of length 1 west going through a wall' do direction = :west subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(0) expect(subject.snake.head.orientation).to eq(direction) subject.apple.generate(initial_row: 20, initial_column: 20) expect(subject.apple.row).to eq(20) expect(subject.apple.column).to eq(20) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(39) end it 'moves snake of length 1 south without going through a wall' do direction = :south subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(0) expect(subject.snake.head.orientation).to eq(direction) subject.apple.generate(initial_row: 20, initial_column: 20) expect(subject.apple.row).to eq(20) expect(subject.apple.column).to eq(20) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(1) expect(subject.snake.head.column).to eq(0) end it 'moves snake of length 1 south going through a wall' do direction = :south subject.start subject.snake.generate(initial_row: 39, initial_column: 0, initial_orientation: direction) expect(subject.snake.head.row).to eq(39) expect(subject.snake.head.column).to eq(0) expect(subject.snake.head.orientation).to eq(direction) subject.apple.generate(initial_row: 20, initial_column: 20) expect(subject.apple.row).to eq(20) expect(subject.apple.column).to eq(20) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(0) end it 'moves snake of length 1 north without going through a wall' do direction = :north subject.start subject.snake.generate(initial_row: 39, initial_column: 0, initial_orientation: direction) expect(subject.snake.head.row).to eq(39) expect(subject.snake.head.column).to eq(0) expect(subject.snake.head.orientation).to eq(direction) subject.apple.generate(initial_row: 20, initial_column: 20) expect(subject.apple.row).to eq(20) expect(subject.apple.column).to eq(20) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(38) expect(subject.snake.head.column).to eq(0) end it 'moves snake of length 1 north going through a wall' do direction = :north subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) expect(subject.snake.head.row).to eq(0) expect(subject.snake.head.column).to eq(0) expect(subject.snake.head.orientation).to eq(direction) subject.apple.generate(initial_row: 20, initial_column: 20) expect(subject.apple.row).to eq(20) expect(subject.apple.column).to eq(20) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(39) expect(subject.snake.head.column).to eq(0) end it 'starts snake going east, moves, turns right south, and moves south' do direction = :east subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) subject.apple.generate(initial_row: 20, initial_column: 20) new_direction = :south subject.snake.move subject.snake.turn_right expect(subject.snake.head.orientation).to eq(new_direction) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(1) expect(subject.snake.head.column).to eq(1) expect(subject.snake.head.orientation).to eq(new_direction) end it 'starts snake going west, moves, turns right north, and moves south' do direction = :west subject.start subject.snake.generate(initial_row: 39, initial_column: 39, initial_orientation: direction) subject.apple.generate(initial_row: 20, initial_column: 20) new_direction = :north subject.snake.move subject.snake.turn_right expect(subject.snake.head.orientation).to eq(new_direction) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(38) expect(subject.snake.head.column).to eq(38) expect(subject.snake.head.orientation).to eq(new_direction) end it 'starts snake going south, moves, turns right west, and moves south' do direction = :south subject.start subject.snake.generate(initial_row: 0, initial_column: 39, initial_orientation: direction) subject.apple.generate(initial_row: 20, initial_column: 20) new_direction = :west subject.snake.move subject.snake.turn_right expect(subject.snake.head.orientation).to eq(new_direction) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(1) expect(subject.snake.head.column).to eq(38) expect(subject.snake.head.orientation).to eq(new_direction) end it 'starts snake going north, moves, turns right east, and moves south' do direction = :north subject.start subject.snake.generate(initial_row: 39, initial_column: 0, initial_orientation: direction) subject.apple.generate(initial_row: 20, initial_column: 20) new_direction = :east subject.snake.move subject.snake.turn_right expect(subject.snake.head.orientation).to eq(new_direction) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(38) expect(subject.snake.head.column).to eq(1) expect(subject.snake.head.orientation).to eq(new_direction) end it 'starts snake going east, moves, turns left north, and moves south' do direction = :east subject.start subject.snake.generate(initial_row: 39, initial_column: 0, initial_orientation: direction) subject.apple.generate(initial_row: 20, initial_column: 20) new_direction = :north subject.snake.move subject.snake.turn_left expect(subject.snake.head.orientation).to eq(new_direction) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(38) expect(subject.snake.head.column).to eq(1) expect(subject.snake.head.orientation).to eq(new_direction) end it 'starts snake going west, moves, turns left south, and moves south' do direction = :west subject.start subject.snake.generate(initial_row: 0, initial_column: 39, initial_orientation: direction) subject.apple.generate(initial_row: 20, initial_column: 20) new_direction = :south subject.snake.move subject.snake.turn_left expect(subject.snake.head.orientation).to eq(new_direction) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(1) expect(subject.snake.head.column).to eq(38) expect(subject.snake.head.orientation).to eq(new_direction) end it 'starts snake going south, moves, turns left east, and moves south' do direction = :south subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) subject.apple.generate(initial_row: 20, initial_column: 20) new_direction = :east subject.snake.move subject.snake.turn_left expect(subject.snake.head.orientation).to eq(new_direction) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(1) expect(subject.snake.head.column).to eq(1) expect(subject.snake.head.orientation).to eq(new_direction) end it 'starts snake going north, moves, turns left west, and moves south' do direction = :north subject.start subject.snake.generate(initial_row: 39, initial_column: 39, initial_orientation: direction) subject.apple.generate(initial_row: 20, initial_column: 20) new_direction = :west subject.snake.move subject.snake.turn_left expect(subject.snake.head.orientation).to eq(new_direction) subject.snake.move expect(subject.snake.length).to eq(1) expect(subject.snake.head.row).to eq(38) expect(subject.snake.head.column).to eq(38) expect(subject.snake.head.orientation).to eq(new_direction) end it 'starts snake going east, moves, turns right south, and eats apple while moving south' do direction = :east subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) subject.apple.generate(initial_row: 1, initial_column: 1) new_direction = :south subject.snake.move subject.snake.turn_right subject.snake.move expect(subject.snake.length).to eq(2) expect(subject.snake.vertebrae[0].row).to eq(0) expect(subject.snake.vertebrae[0].column).to eq(1) expect(subject.snake.vertebrae[0].orientation).to eq(new_direction) expect(subject.snake.vertebrae[1].row).to eq(1) expect(subject.snake.vertebrae[1].column).to eq(1) expect(subject.snake.vertebrae[1].orientation).to eq(new_direction) end it 'starts snake going east, moves, turns right south, eats apple while moving south, turns left, eats apple while moving east' do direction = :east subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) subject.apple.generate(initial_row: 1, initial_column: 1) subject.snake.move subject.snake.turn_right subject.snake.move # eats apple subject.apple.generate(initial_row: 1, initial_column: 2) subject.snake.turn_left subject.snake.move # eats apple expect(subject.snake.length).to eq(3) expect(subject.snake.vertebrae[0].row).to eq(0) expect(subject.snake.vertebrae[0].column).to eq(1) expect(subject.snake.vertebrae[0].orientation).to eq(:south) expect(subject.snake.vertebrae[1].row).to eq(1) expect(subject.snake.vertebrae[1].column).to eq(1) expect(subject.snake.vertebrae[1].orientation).to eq(:east) expect(subject.snake.vertebrae[2].row).to eq(1) expect(subject.snake.vertebrae[2].column).to eq(2) expect(subject.snake.vertebrae[2].orientation).to eq(:east) end it 'starts snake going east, moves, turns right south, eats apple while moving south, turns left, eats apple while moving east, turns right, moves south' do direction = :east subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) subject.apple.generate(initial_row: 1, initial_column: 1) subject.snake.move subject.snake.turn_right subject.snake.move # eats apple subject.apple.generate(initial_row: 1, initial_column: 2) subject.snake.turn_left subject.snake.move # eats apple subject.apple.generate(initial_row: 20, initial_column: 20) subject.snake.turn_right subject.snake.move expect(subject.snake.length).to eq(3) expect(subject.snake.vertebrae[0].row).to eq(1) expect(subject.snake.vertebrae[0].column).to eq(1) expect(subject.snake.vertebrae[0].orientation).to eq(:east) expect(subject.snake.vertebrae[1].row).to eq(1) expect(subject.snake.vertebrae[1].column).to eq(2) expect(subject.snake.vertebrae[1].orientation).to eq(:south) expect(subject.snake.vertebrae[2].row).to eq(2) expect(subject.snake.vertebrae[2].column).to eq(2) expect(subject.snake.vertebrae[2].orientation).to eq(:south) end it 'starts snake going east, moves, turns right south, eats apple while moving south, turns left, eats apple while moving east, turns left, eats apple while moving north, turns left, collides while moving west and game is over' do direction = :east subject.start subject.snake.generate(initial_row: 0, initial_column: 0, initial_orientation: direction) subject.apple.generate(initial_row: 1, initial_column: 1) subject.snake.move # 0, 1 subject.snake.turn_right subject.snake.move # 1, 1 eats apple subject.apple.generate(initial_row: 1, initial_column: 2) subject.snake.turn_left subject.snake.move # 1, 2 eats apple subject.apple.generate(initial_row: 1, initial_column: 3) subject.snake.move # 1, 3 eats apple subject.apple.generate(initial_row: 1, initial_column: 4) subject.snake.move # 1, 4 eats apple subject.snake.turn_left subject.snake.move # 0, 4 subject.snake.turn_left subject.snake.move # 0, 3 subject.snake.turn_left subject.snake.move # 1, 3 (collision) expect(subject).to be_over expect(subject.score).to eq(50 * 4) expect(subject.snake).to be_collided expect(subject.snake.length).to eq(5) expect(subject.snake.vertebrae[0].row).to eq(1) expect(subject.snake.vertebrae[0].column).to eq(2) expect(subject.snake.vertebrae[0].orientation).to eq(:east) expect(subject.snake.vertebrae[1].row).to eq(1) expect(subject.snake.vertebrae[1].column).to eq(3) expect(subject.snake.vertebrae[1].orientation).to eq(:east) expect(subject.snake.vertebrae[2].row).to eq(1) expect(subject.snake.vertebrae[2].column).to eq(4) expect(subject.snake.vertebrae[2].orientation).to eq(:north) expect(subject.snake.vertebrae[3].row).to eq(0) expect(subject.snake.vertebrae[3].column).to eq(4) expect(subject.snake.vertebrae[3].orientation).to eq(:west) expect(subject.snake.vertebrae[4].row).to eq(0) expect(subject.snake.vertebrae[4].column).to eq(3) expect(subject.snake.vertebrae[4].orientation).to eq(:south) end end
Here are the Models:
Game:
require 'fileutils' require_relative 'snake' require_relative 'apple' class Snake module Model class Game WIDTH_DEFAULT = 40 HEIGHT_DEFAULT = 40 FILE_HIGH_SCORE = File.expand_path(File.join(Dir.home, '.glimmer-snake')) attr_reader :width, :height attr_accessor :snake, :apple, :over, :score, :high_score alias over? over def initialize(width = WIDTH_DEFAULT, height = HEIGHT_DEFAULT) @width = width @height = height @snake = Snake.new(self) @apple = Apple.new(self) FileUtils.touch(FILE_HIGH_SCORE) @high_score = File.read(FILE_HIGH_SCORE).to_i rescue 0 end def score=(new_score) @score = new_score self.high_score = @score if @score > @high_score end def high_score=(new_high_score) @high_score = new_high_score File.write(FILE_HIGH_SCORE, @high_score.to_s) rescue => e puts e.full_message end def start self.over = false self.score = 0 self.snake.generate self.apple.generate end # inspect is overridden to prevent printing very long stack traces def inspect "#{super[0, 75]}... >" end end end end
Snake:
require_relative 'vertebra' class Snake module Model class Snake SCORE_EAT_APPLE = 50 RIGHT_TURN_MAP = { north: :east, east: :south, south: :west, west: :north } LEFT_TURN_MAP = RIGHT_TURN_MAP.invert attr_accessor :collided alias collided? collided attr_reader :game # vertebrae and joins are ordered from tail to head attr_accessor :vertebrae def initialize(game) @game = game end # generates a new snake location and orientation from scratch or via dependency injection of what head_cell and orientation are (for testing purposes) def generate(initial_row: nil, initial_column: nil, initial_orientation: nil) self.collided = false initial_vertebra = Vertebra.new(snake: self, row: initial_row, column: initial_column, orientation: initial_orientation) self.vertebrae = [initial_vertebra] end def length @vertebrae.length end def head @vertebrae.last end def tail @vertebrae.first end def remove self.vertebrae.clear self.joins.clear end def move @old_tail = tail.dup @new_head = head.dup case @new_head.orientation when :east @new_head.column = (@new_head.column + 1) % @game.width when :west @new_head.column = (@new_head.column - 1) % @game.width when :south @new_head.row = (@new_head.row + 1) % @game.height when :north @new_head.row = (@new_head.row - 1) % @game.height end if @vertebrae.map {|v| [v.row, v.column]}.include?([@new_head.row, @new_head.column]) self.collided = true @game.over = true else @vertebrae.append(@new_head) @vertebrae.delete(tail) if head.row == @game.apple.row && head.column == @game.apple.column grow @game.apple.generate end end end def turn_right head.orientation = RIGHT_TURN_MAP[head.orientation] end def turn_left head.orientation = LEFT_TURN_MAP[head.orientation] end def grow @game.score += SCORE_EAT_APPLE @vertebrae.prepend(@old_tail) end # inspect is overridden to prevent printing very long stack traces def inspect "#{super[0, 150]}... >" end end end end
Vertebra:
class Snake module Model class Vertebra ORIENTATIONS = %i[north east south west] # orientation is needed for snake occuppied cells (but not apple cells) attr_reader :snake attr_accessor :row, :column, :orientation def initialize(snake: , row: , column: , orientation: ) @row = row || rand(snake.game.height) @column = column || rand(snake.game.width) @orientation = orientation || ORIENTATIONS.sample @snake = snake end # inspect is overridden to prevent printing very long stack traces def inspect "#{super[0, 150]}... >" end end end end
Apple:
class Snake module Model class Apple attr_reader :game attr_accessor :row, :column def initialize(game) @game = game end # generates a new location from scratch or via dependency injection of what cell is (for testing purposes) def generate(initial_row: nil, initial_column: nil) if initial_row && initial_column self.row, self.column = initial_row, initial_column else self.row, self.column = @game.height.times.zip(@game.width.times).reject do |row, column| @game.snake.vertebrae.map {|v| [v.row, v.column]}.include?([row, column]) end.sample end end def remove self.row = nil self.column = nil end # inspect is overridden to prevent printing very long stack traces def inspect "#{super[0, 120]}... >" end end end end
Here are the Presenters:
Grid:
require 'glimmer/data_binding/observer' require_relative '../model/game' require_relative 'cell' class Snake module Presenter class Grid attr_reader :game, :cells def initialize(game = Model::Game.new) @game = game @cells = @game.height.times.map do |row| @game.width.times.map do |column| Cell.new(grid: self, row: row, column: column) end end Glimmer::DataBinding::Observer.proc do |new_vertebrae| occupied_snake_positions = @game.snake.vertebrae.map {|v| [v.row, v.column]} @cells.each_with_index do |row_cells, row| row_cells.each_with_index do |cell, column| if [@game.apple.row, @game.apple.column] == [row, column] cell.color = Cell::COLOR_APPLE elsif occupied_snake_positions.include?([row, column]) cell.color = Cell::COLOR_SNAKE else cell.clear end end end end.observe(@game.snake, :vertebrae) end def clear @cells.each do |row_cells| row_cells.each do |cell| cell.clear end end end # inspect is overridden to prevent printing very long stack traces def inspect "#{super[0, 75]}... >" end end end end
Cell:
class Snake module Presenter class Cell COLOR_CLEAR = :white COLOR_SNAKE = :green COLOR_APPLE = :red attr_reader :row, :column, :grid attr_accessor :color def initialize(grid: ,row: ,column: ) @row = row @column = column @grid = grid end def clear self.color = COLOR_CLEAR unless color == COLOR_CLEAR end # inspect is overridden to prevent printing very long stack traces def inspect "#{super[0, 150]}... >" end end end end
Happy Glimmering!
Top comments (0)
Some comments have been hidden by the post's author - find out more