Create a Game with Ruby

This is my first attempt to create a game. We have to keep in mind that Implementing a game in general is never an easy task. We will try to implement a game called Sokoban, where the goal of the game is to push all the boxes into a certain spots. Sokoban will look something similar to this. But here we will try to get to the logic of the game more than the UI implementation of the game. There are also quite a few implementations of the game on the Internet. We try to follow some of the implementations out there and try extract the logic out as clear as possible. All the existed implementation can looks a bit complicated since the codes already integrate so many features out of the main game.

Layout of the game

We have text file with simple layout as below

    #####
    #   #
    #$  #
  ###  $##
  #  $ $ #
### # ## #   ######
#   # ## #####  ..#
# $  $          ..#
##### ### #@##  ..#
    #     #########
    #######

here # acts as a wall. $ is a box. . is where we want to put those boxes @ is the pusher

The pusher can only push one box. The pusher cannot go against the wall.

We will create level.rb where we read the level from text file containing level. From there we can manipulate the game.

#level.rb
class Level
  #depending where we put level.txt relative to out level.rb
  #.. means one level up
  LEVELS_FILE = File.join(__dir__, *%w[.. level.txt])
   
   # Here is a module called Empty to handle the case of empty space where there is no player and box
  module Empty
    def contents; nil end
    def has_player?; false end
    def has_box?; false end
  end
    
   # Here is the case of empty space
  class Void
    include Empty
  end
  #Here is the case of the wall
  class Wall
    include Empty
  end
  #Here is the case of the floor where there can be pusher and box
  class Floor
    def initialize(contents = nil)
      @contents = contents
    end

    attr_accessor :contents

    def has_player?
      contents.is_a? Player
    end

    def has_box?
      contents.is_a? Box
    end
  end
  
  #Goal is where we want to push the box to. It is a subclass of Floor
  class Goal < Floor; end
  #We have class Player
  class Player; end
  #We have class Box
  class Box; end
  
  #Our goal is to translate level in text format into an Array of instances of above classes.
  #Here we create Constant PARSE_KEY which map each symbol to a Proc of different classes.
  # So we can call something like PARSE_KEY['.'].call to create an instance of the class
  PARSE_KEY = {
    "-" => lambda { Void.new              },
    "#" => lambda { Wall.new              },
    " " => lambda { Floor.new             },
    "." => lambda { Goal.new              },
    "@" => lambda { Floor.new(Player.new) },
    "$" => lambda { Floor.new(Box.new)    },
    "*" => lambda { Goal.new(Box.new)     }
  }
  # Here we are trying to convert string of level which consist of symbol into arrays of instance of classes
  def self.parse(string)
    # we try to build square board. First we remove unnecessary characters.
    clean = string.gsub(/^ *(?:;.*)?\n/, "")
    width = clean.lines.map { |row| row.rstrip.size }.max
    rows  = clean.lines.map { |row| row.rstrip.ljust(width).chars }

    # we fill-in void with "-"
    rows.each do |row|
      row.fill("-", 0, row.index("#"))
      row.fill("-", row.rindex("#") + 1)
    end


    # we construct level with the constructor
    new(
      rows.map { |row|
        row.map { |cell| PARSE_KEY[cell].call }
      }
    )
  end
    
   #We try to read level from text file and build an array of instance of classes
  def self.from_file(num, path = LEVELS_FILE)
    buffer = [ ]
    File.foreach(path) do |line|
      if line =~ /\A\s*;\s*(\d+)/
        if $1.to_i == num
          return parse(buffer.join)
        else
          buffer.clear
        end
      else
        buffer << line
      end
    end
    nil
  end
  #include Module Enumberable to override method each
  include Enumerable

  def initialize(rows)
    @rows = rows
  end

  attr_reader :rows
  private     :rows

  def width
    rows.first.size
  end

  def height
    rows.size
  end
  
  #Access the element of the two dimensional arrays with method [x,y]
  def [](x,y)
    return if x < 0 || y < 0 || x > self.width || y > self.height
    rows[y][x]
  end

  def each(&block)
    rows.each(&block)
  end
end

Main logic of the game

We create game.rb to handle the main logic of the game.

#We need to require level.rb into this class
require_relative "level"

class Game
  def initialize(start_level)
    #set the current level
    @current_level = start_level.is_a?(Integer) &&
      start_level.between?(1,2) ? start_level : 1
    # start the game
    reset
  end

  attr_reader :current_level, :level
  attr_reader :player_x, :player_y, :total_boxes, :boxes_on_goal
  private :player_x, :player_y, :total_boxes, :boxes_on_goal

  def reset
    # Set the level and find the position of the pusher and number of boxes
    @level = Level.from_file(current_level)
    find_player_and_count_boxes
  end
  #method to move the box up, right, down and left
  def move_up
    try_move_to(:y, -1)
  end

  def move_right
    try_move_to(:x, 1)
  end

  def move_down
    try_move_to(:y, 1)
  end

  def move_left
    try_move_to(:x, -1)
  end
  
  #Check if the game is solved
  def solved?
    boxes_on_goal == total_boxes
  end

  def next_level
    @current_level += 1
    reset
  end
  
  #Check if there is no level
  def finished?
    level.nil?
  end
  private

  def find_player_and_count_boxes
    @player_x = nil
    @player_y = nil
    @total_gems = 0
    @gems_on_goal = 0

    return if level.nil?
    #Check the position of the pusher
    level.each_with_index do |row, y|
      row.each_with_index do |cell, x|
        if level[x, y].has_player?
          @player_x = x
          @player_y = y
        elsif level[x, y].has_box?
          #find the total number of the boxes and the number of boxes already on the goal spot
          @total_boxes += 1
          @boxes_on_goal += 1 if level[x, y].is_a?(Level::Goal)
        end
      end
    end
  end
  
  #Check if pusher can move with or without the box
  #If possible move and set the pusher position
  def try_move_to(axis, offset)
    xy                      = [player_x, player_y]
    xy[axis == :x ? 0 : 1] += offset
    move_xy                 = xy.dup
    cell                    = level[*xy]
    xy[axis == :x ? 0 : 1] += offset
    beyond                  = level[*xy]
    if can_move_to?(cell, beyond)
      move_to(cell, beyond)
      @player_x, @player_y = move_xy
    end
  end
  
  #Check if the pusher can move with or without the box
  #If the cell is a floor and cell is empty or cell has box with the next cell is a floor and empty
  def can_move_to?(cell, cell_beyond)
    cell.is_a?(Level::Floor) &&
    ( cell.contents.nil? ||
      ( cell.has_box? &&
        cell_beyond.is_a?(Level::Floor) &&
        cell_beyond.contents.nil? ) )
  end
  #move the pusher and box if existed
  def move_to(cell, beyond)
    if cell.has_gem?
      beyond.contents = cell.contents
      cell.contents   = nil
      if beyond.is_a?(Level::Goal) && !cell.is_a?(Level::Goal)
        @gems_on_goal += 1
      elsif !beyond.is_a?(Level::Goal) && cell.is_a?(Level::Goal)
        @gems_on_goal -= 1
      end
    end
    player                             = level[player_x, player_y].contents
    level[player_x, player_y].contents = nil
    cell.contents                      = player
  end
end

With the Basic logic laid out we can proceed to implement the more interesting part of game, maybe Gosu library. We'll continue in the next post.