Skip to content

Instantly share code, notes, and snippets.

@DmitryTsepelev
Last active October 20, 2024 11:55
Show Gist options
  • Save DmitryTsepelev/1e9d73db26aa19d4ad2bd6ddbd67f045 to your computer and use it in GitHub Desktop.
Save DmitryTsepelev/1e9d73db26aa19d4ad2bd6ddbd67f045 to your computer and use it in GitHub Desktop.
Terminal–based game tutorial

Sources for my blog post about terminal–based game

class Game
SLEEP_INTERVAL = 0.2
def run
loop do
draw_screen
sleep SLEEP_INTERVAL
end
end
private
def draw_screen
system "clear"
puts Time.now
end
end
Game.new.run
TREE, SPACE = '🌲', "・"
Level = Struct.new(:map, :enemies, :player, :door)
class LevelBuilder
def initialize(filepath)
@filepath = filepath
end
MAPPING = { 't' => TREE, 's' => SPACE }
def build
Level.new.tap do |level|
level.enemies = []
level.map = File.readlines(@filepath).map do |line|
line.sub("\n", "").chars.map do |c|
MAPPING[c] || SPACE
end
end
end
end
end
class Game
SLEEP_INTERVAL = 0.2
def run
@level = LevelBuilder.new("./map.txt").build
loop do
draw_screen
sleep SLEEP_INTERVAL
end
end
private
def draw_screen
system "clear"
@level.each do |row|
row.each do |cell|
print cell
end
puts
end
end
end
Game.new.run
PLAYER, ENEMY, DOOR, TREE, SPACE = '🧙', '👻', '🚪', '🌲', "・"
Level = Struct.new(:map, :enemies, :player, :door)
DynamicObject = Struct.new(:row_idx, :col_idx, :kind)
class LevelBuilder
def initialize(filepath)
@filepath = filepath
end
MAPPING = { 't' => TREE, 's' => SPACE }
def build
Level.new.tap do |level|
level.enemies = []
level.map = File.readlines(@filepath).map.with_index do |line, row_idx|
line.chars.map.with_index do |c, col_idx|
case c
when 'e'
level.enemies << DynamicObject.new(row_idx, col_idx, :enemy)
SPACE
when 'p'
level.player = DynamicObject.new(row_idx, col_idx, :player)
SPACE
when 'd'
level.door = DynamicObject.new(row_idx, col_idx, :door)
SPACE
else
MAPPING[c]
end
end
end
end
end
end
class Game
SLEEP_INTERVAL = 0.2
def run
@level = LevelBuilder.new("./map.txt").build
loop do
draw_screen
sleep SLEEP_INTERVAL
end
end
private
def draw_screen
system "clear"
@level.map.each_with_index do |row, row_idx|
row.each_with_index do |cell, col_idx|
if @level.player.row_idx == row_idx && @level.player.col_idx == col_idx
print PLAYER
elsif @level.door.row_idx == row_idx && @level.door.col_idx == col_idx
print DOOR
elsif @level.enemies.find { |enemy| enemy.row_idx == row_idx && enemy.col_idx == col_idx }
print ENEMY
else
print cell
end
end
puts "\n"
end
end
end
Game.new.run
PLAYER, ENEMY, DOOR, TREE, SPACE = '🧙', '👻', '🚪', '🌲', "・"
Level = Struct.new(:map, :enemies, :player, :door)
UP, DOWN, RIGHT, LEFT = 119, 115, 100, 97
DynamicObject = Struct.new(:row_idx, :col_idx, :kind) do
def move(dir)
case dir
when RIGHT then self.col_idx += 1
when LEFT then self.col_idx -= 1
when UP then self.row_idx -= 1
when DOWN then self.row_idx += 1
end
end
end
class LevelBuilder
def initialize(filepath)
@filepath = filepath
end
MAPPING = { 't' => TREE, 's' => SPACE }
def build
Level.new.tap do |level|
level.enemies = []
level.map = File.readlines(@filepath).map.with_index do |line, row_idx|
line.chars.map.with_index do |c, col_idx|
case c
when 'e'
level.enemies << DynamicObject.new(row_idx, col_idx, :enemy)
SPACE
when 'p'
level.player = DynamicObject.new(row_idx, col_idx, :player)
SPACE
when 'd'
level.door = DynamicObject.new(row_idx, col_idx, :door)
SPACE
else
MAPPING[c]
end
end
end
end
end
end
class Game
SLEEP_INTERVAL = 0.2
def run
@level = LevelBuilder.new("./map.txt").build
loop do
draw_screen
new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx, :player)
new_player_position.move(get_pressed_key)
@level.player = new_player_position
sleep SLEEP_INTERVAL
end
end
private
def draw_screen
system "clear"
@level.map.each_with_index do |row, row_idx|
row.each_with_index do |cell, col_idx|
if @level.player.row_idx == row_idx && @level.player.col_idx == col_idx
print PLAYER
elsif @level.door.row_idx == row_idx && @level.door.col_idx == col_idx
print DOOR
elsif @level.enemies.find { |enemy| enemy.row_idx == row_idx && enemy.col_idx == col_idx }
print ENEMY
else
print cell
end
end
puts "\n"
end
end
def get_pressed_key
begin
system('stty raw -echo')
(STDIN.read_nonblock(4).ord rescue nil)
ensure
system('stty -raw echo')
end
end
end
Game.new.run
PLAYER, ENEMY, DOOR, TREE, SPACE = '🧙', '👻', '🚪', '🌲', "・"
Level = Struct.new(:map, :enemies, :player, :door)
UP, DOWN, RIGHT, LEFT = 119, 115, 100, 97
DynamicObject = Struct.new(:row_idx, :col_idx, :kind) do
def move(dir)
case dir
when RIGHT then self.col_idx += 1
when LEFT then self.col_idx -= 1
when UP then self.row_idx -= 1
when DOWN then self.row_idx += 1
end
end
end
class LevelBuilder
def initialize(filepath)
@filepath = filepath
end
MAPPING = { 't' => TREE, 's' => SPACE }
def build
Level.new.tap do |level|
level.enemies = []
level.map = File.readlines(@filepath).map.with_index do |line, row_idx|
line.chars.map.with_index do |c, col_idx|
case c
when 'e'
level.enemies << DynamicObject.new(row_idx, col_idx, :enemy)
SPACE
when 'p'
level.player = DynamicObject.new(row_idx, col_idx, :player)
SPACE
when 'd'
level.door = DynamicObject.new(row_idx, col_idx, :door)
SPACE
else
MAPPING[c]
end
end
end
end
end
end
class Game
SLEEP_INTERVAL = 0.2
def run
@level = LevelBuilder.new("./map.txt").build
loop do
draw_screen
new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx, :player)
new_player_position.move(get_pressed_key)
case check_collision(new_player_position.row_idx, new_player_position.col_idx, @level.enemies + [@level.door])
when :door
puts "🎉 Level passed 🎉"
break
when :enemy
puts "☠️ You died ☠️"
break
when nil
@level.player = new_player_position
end
sleep SLEEP_INTERVAL
end
end
private
def draw_screen
system "clear"
@level.map.each_with_index do |row, row_idx|
row.each_with_index do |cell, col_idx|
if @level.player.row_idx == row_idx && @level.player.col_idx == col_idx
print PLAYER
elsif @level.door.row_idx == row_idx && @level.door.col_idx == col_idx
print DOOR
elsif @level.enemies.find { |enemy| enemy.row_idx == row_idx && enemy.col_idx == col_idx }
print ENEMY
else
print cell
end
end
puts "\n"
end
end
def get_pressed_key
begin
system('stty raw -echo')
(STDIN.read_nonblock(4).ord rescue nil)
ensure
system('stty -raw echo')
end
end
def check_collision(row_idx, col_idx, objects)
return :out_of_border if row_idx < 0 || row_idx >= @level.map.length || col_idx < 0 || col_idx >= @level.map[0].length
return :tree if @level.map[row_idx][col_idx] == TREE
objects.find { _1.row_idx == row_idx && _1.col_idx == col_idx }&.kind
end
end
Game.new.run
PLAYER, ENEMY, DOOR, TREE, SPACE = '🧙', '👻', '🚪', '🌲', "・"
Level = Struct.new(:map, :enemies, :player, :door)
UP, DOWN, RIGHT, LEFT = 119, 115, 100, 97
DynamicObject = Struct.new(:row_idx, :col_idx, :kind) do
def move(dir)
case dir
when RIGHT then self.col_idx += 1
when LEFT then self.col_idx -= 1
when UP then self.row_idx -= 1
when DOWN then self.row_idx += 1
end
end
end
class LevelBuilder
def initialize(filepath)
@filepath = filepath
end
MAPPING = { 't' => TREE, 's' => SPACE }
def build
Level.new.tap do |level|
level.enemies = []
level.map = File.readlines(@filepath).map.with_index do |line, row_idx|
line.chars.map.with_index do |c, col_idx|
case c
when 'e'
level.enemies << DynamicObject.new(row_idx, col_idx, :enemy)
SPACE
when 'p'
level.player = DynamicObject.new(row_idx, col_idx, :player)
SPACE
when 'd'
level.door = DynamicObject.new(row_idx, col_idx, :door)
SPACE
else
MAPPING[c]
end
end
end
end
end
end
class Game
SLEEP_INTERVAL = 0.2
def run
@level = LevelBuilder.new("./map.txt").build
loop do
move_enemies
draw_screen
case check_collision(@level.player.row_idx, @level.player.col_idx, @level.enemies)
when :enemy
puts "☠️ You died ☠️"
break
end
new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx, :player)
new_player_position.move(get_pressed_key)
case check_collision(new_player_position.row_idx, new_player_position.col_idx, @level.enemies + [@level.door])
when :door
puts "🎉 Level passed 🎉"
break
when :enemy
puts "☠️ You died ☠️"
break
when nil
@level.player = new_player_position
end
sleep SLEEP_INTERVAL
end
end
private
def draw_screen
system "clear"
@level.map.each_with_index do |row, row_idx|
row.each_with_index do |cell, col_idx|
if @level.player.row_idx == row_idx && @level.player.col_idx == col_idx
print PLAYER
elsif @level.door.row_idx == row_idx && @level.door.col_idx == col_idx
print DOOR
elsif @level.enemies.find { |enemy| enemy.row_idx == row_idx && enemy.col_idx == col_idx }
print ENEMY
else
print cell
end
end
puts "\n"
end
end
def get_pressed_key
begin
system('stty raw -echo')
(STDIN.read_nonblock(4).ord rescue nil)
ensure
system('stty -raw echo')
end
end
def check_collision(row_idx, col_idx, objects)
return :out_of_border if row_idx < 0 || row_idx >= @level.map.length || col_idx < 0 || col_idx >= @level.map[0].length
return :tree if @level.map[row_idx][col_idx] == TREE
objects.find { _1.row_idx == row_idx && _1.col_idx == col_idx }&.kind
end
def move_enemies
@level.enemies.each_with_index do |enemy, idx|
next if rand(1) > 0.8
new_enemy = DynamicObject.new(enemy.row_idx, enemy.col_idx, :enemy)
new_enemy.move([RIGHT, LEFT, UP, DOWN].sample)
@level.enemies[idx] = new_enemy if check_collision(new_enemy.row_idx, new_enemy.col_idx, [@level.door, @level.player]).nil?
end
end
end
Game.new.run
PLAYER, ENEMY, DOOR, TREE, SPACE = '🧙', '👻', '🚪', '🌲', "・"
Level = Struct.new(:map, :enemies, :player, :door)
UP, DOWN, RIGHT, LEFT = 119, 115, 100, 97
DynamicObject = Struct.new(:row_idx, :col_idx, :kind) do
def move(dir)
case dir
when RIGHT then self.col_idx += 1
when LEFT then self.col_idx -= 1
when UP then self.row_idx -= 1
when DOWN then self.row_idx += 1
end
end
end
class LevelBuilder
def initialize(filepath)
@filepath = filepath
end
MAPPING = { 't' => TREE, 's' => SPACE }
def build
Level.new.tap do |level|
level.enemies = []
level.map = File.readlines(@filepath).map.with_index do |line, row_idx|
line.strip.chars.map.with_index do |c, col_idx|
case c
when 'e'
level.enemies << DynamicObject.new(row_idx, col_idx, :enemy)
SPACE
when 'p'
level.player = DynamicObject.new(row_idx, col_idx, :player)
SPACE
when 'd'
level.door = DynamicObject.new(row_idx, col_idx, :door)
SPACE
else
MAPPING[c]
end
end
end
end
end
end
class Screen
def render_level(level)
system "clear"
level.map.each_with_index do |row, row_idx|
row.each_with_index do |cell, col_idx|
if level.player.row_idx == row_idx && level.player.col_idx == col_idx
print PLAYER
elsif level.door.row_idx == row_idx && level.door.col_idx == col_idx
print DOOR
elsif level.enemies.find { |enemy| enemy.row_idx == row_idx && enemy.col_idx == col_idx }
print ENEMY
else
print cell
end
end
puts "\n"
end
end
def render_death_message = puts "☠️ You died ☠️"
def render_level_passed_message = puts "🎉 Level passed 🎉"
end
class Game
SLEEP_INTERVAL = 0.2
def run
screen = Screen.new
@level = LevelBuilder.new("./map.txt").build
loop do
move_enemies
screen.render_level(@level)
case check_collision(@level.player.row_idx, @level.player.col_idx, @level.enemies)
when :enemy
screen.render_death_message
break
end
new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx, :player)
new_player_position.move(get_pressed_key)
case check_collision(new_player_position.row_idx, new_player_position.col_idx, @level.enemies + [@level.door])
when :door
screen.render_level_passed_message
break
when :enemy
screen.render_death_message
break
when nil
@level.player = new_player_position
end
sleep SLEEP_INTERVAL
end
end
private
def get_pressed_key
begin
system('stty raw -echo')
(STDIN.read_nonblock(4).ord rescue nil)
ensure
system('stty -raw echo')
end
end
def check_collision(row_idx, col_idx, objects)
return :out_of_border if row_idx < 0 || row_idx >= @level.map.length || col_idx < 0 || col_idx >= @level.map[0].length
return :tree if @level.map[row_idx][col_idx] == TREE
objects.find { _1.row_idx == row_idx && _1.col_idx == col_idx }&.kind
end
def move_enemies
@level.enemies.each_with_index do |enemy, idx|
next if rand(1) > 0.8
new_enemy = DynamicObject.new(enemy.row_idx, enemy.col_idx, :enemy)
new_enemy.move([RIGHT, LEFT, UP, DOWN].sample)
@level.enemies[idx] = new_enemy if check_collision(new_enemy.row_idx, new_enemy.col_idx, [@level.door]).nil?
end
end
end
Game.new.run
@airled
Copy link

airled commented Oct 19, 2024

Several corrections for 7_game.rb.

Line 30:
Instead of

line.chars.map.with_index do |c, col_idx|

should be

line.strip.chars.map.with_index do |c, col_idx|

Otherwise it adds one nil at the end of each row, so the enemy can step outside of level.

Line 92:
Instead of

new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx)

should be

new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx, :player)

Without it the collision detection works incorrectly on line 124
objects.find { _1.row_idx == row_idx && _1.col_idx == col_idx }&.kind
will return nil instead of :player)

Line 133:

@level.enemies[idx] = new_enemy if check_collision(new_enemy.row_idx, new_enemy.col_idx, [@level.door, @level.player]).nil?

should be

@level.enemies[idx] = new_enemy if check_collision(new_enemy.row_idx, new_enemy.col_idx, [@level.door]).nil?

since the enemy can move to player's position and kill it. With it, the enemy will forever avoid the player as an obstacle.

@DmitryTsepelev
Copy link
Author

@airled thank you so much, updated gist and the post 👏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment