Sources for my blog post about terminal–based game
Last active
October 20, 2024 11:55
-
-
Save DmitryTsepelev/1e9d73db26aa19d4ad2bd6ddbd67f045 to your computer and use it in GitHub Desktop.
Terminal–based game tutorial
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Several corrections for
7_game.rb
.Line 30:
Instead of
should be
Otherwise it adds one
nil
at the end of each row, so the enemy can step outside of level.Line 92:
Instead of
should be
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:
should be
since the enemy can move to player's position and kill it. With it, the enemy will forever avoid the player as an obstacle.