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 ...
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(/^ *(?:;.*)? /, "") awidth = clean.lines.map { |row| row.rstrip.size }.max rows = clean.lines.map { |row| row.rstrip.ljust(awidth).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 =~ /As*;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 awidth 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.awidth || 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.