Create a Game with Ruby
Bài đăng này đã không được cập nhật trong 3 năm
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.
All rights reserved