connect_n 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f576942d7bcaf43312e72ace215cb8f92fea00e6f615387151bd2f674c826267
4
+ data.tar.gz: 947c671cb8af51ca156c2fdd3d58d66dae4c0db3994fd4e82f91e3aa13dbb0b4
5
+ SHA512:
6
+ metadata.gz: 7ecfb976380fd669a3094a7825e91ed3d312a7ce557c84e6eaca4d2d6b5bfbb67df35357fcfbdfe943ee45b8c1f338f74fc26d01ed5d9c62963ca383e5a43062
7
+ data.tar.gz: 79c4840a51f839a87a0e8aaa9111e5dc7702628cddfca2c5fb93f255b9b144bd88dfe5f13be2f0ca73121b0fc1a9a49ec5c0a68a62216f06f0d0f48d52ddd089
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectN
4
+ class Board
5
+ attr_reader :table, :empty_disc, :cols_amount, :rows_amount
6
+
7
+ def initialize(rows_amount: 6, cols_amount: 7, empty_disc: '⚪')
8
+ @empty_disc = empty_disc
9
+
10
+ @rows_amount = rows_amount
11
+
12
+ @cols_amount = cols_amount
13
+
14
+ @table = Array.new(rows_amount) { Array.new(cols_amount) { empty_disc } }
15
+ end
16
+
17
+ def initialize_copy(original_board)
18
+ super
19
+ @empty_disc = @empty_disc.clone
20
+ @table = @table.clone.map(&:clone)
21
+ end
22
+
23
+ def drop_disc(disc, at_col:)
24
+ row_num = col_at(at_col).index(empty_disc)
25
+ row_at(row_num)[at_col] = disc
26
+ [row_num, at_col, disc]
27
+ end
28
+
29
+ def valid_pick?(pick)
30
+ valid_col?(pick) and col_at(pick).include?(empty_disc)
31
+ end
32
+
33
+ def filled?
34
+ !table.flatten.include?(empty_disc)
35
+ end
36
+
37
+ def draw
38
+ table.each{ |row| draw_border || draw_row(row) }
39
+ draw_border
40
+ draw_col_nums
41
+ end
42
+
43
+ def cell_at(row_num, col_num)
44
+ return unless valid_cell?(row_num, col_num)
45
+
46
+ row_at(row_num)[col_num]
47
+ end
48
+
49
+ def cols = table.transpose.map(&:reverse)
50
+
51
+ def rows = table
52
+
53
+ def col_at(n)
54
+ return unless valid_col?(n)
55
+
56
+ table.transpose[n].reverse
57
+ end
58
+
59
+ def row_at(n)
60
+ return unless valid_row?(n)
61
+
62
+ n = rows_amount - 1 - n
63
+ table[n]
64
+ end
65
+
66
+ private
67
+
68
+ def draw_border
69
+ cols_amount.times { print '+----' } and puts '+'
70
+ end
71
+
72
+ def draw_row(row)
73
+ puts '| ' + row.join(' | ') + ' |'
74
+ end
75
+
76
+ def draw_col_nums
77
+ print '|'
78
+ (1..cols_amount).each do |num|
79
+ num.even? ? print(' ') : print(' ')
80
+ print num
81
+ num.odd? ? print(' |') : print(' |')
82
+ end
83
+ puts
84
+ end
85
+
86
+ def valid_cell?(row_num, col_num) = valid_row?(row_num) && valid_col?(col_num)
87
+
88
+ def valid_row?(n) = n.between?(0, rows_amount - 1)
89
+
90
+ def valid_col?(n) = n.between?(0, cols_amount - 1)
91
+ end
92
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../player/human_player/human_player'
4
+ require_relative '../player/computer_player/computer_player'
5
+ require_relative '../game/game'
6
+ require_relative '../board/board'
7
+
8
+ module ConnectN
9
+ class Demo
10
+ attr_reader :parameters, :game
11
+
12
+ def initialize
13
+ @parameters = { human_players: [] }
14
+ end
15
+
16
+ def launch
17
+ if !Game.games('connect_n_saved_games.yaml').empty? && Game.resume?
18
+ game_name = Game.select_game_name
19
+ @game = Game.load game_name, 'connect_n_saved_games.yaml'
20
+ return Game.resume game
21
+ end
22
+
23
+ setup_parameters
24
+ @game = if parameters[:mode] == 'multiplayer'
25
+ multiplayer_game
26
+ else
27
+ single_player_game
28
+ end
29
+ game.play('connect_n_saved_games.yaml')
30
+ end
31
+
32
+ private
33
+
34
+ def setup_parameters
35
+ parameters[:human_players].push [human_name, disc]
36
+
37
+ parameters[:cols_amount] = cols_amount
38
+ parameters[:rows_amount] = rows_amount
39
+ parameters[:min_to_win] = min_to_win
40
+
41
+ parameters[:mode] = mode
42
+
43
+ if parameters[:mode] == 'multiplayer'
44
+ parameters[:human_players].push [human_name => disc]
45
+ else
46
+ parameters[:difficulty] = difficulty
47
+ parameters[:human_starts?] = human_starts?
48
+ end
49
+ end
50
+
51
+ def cols_amount
52
+ PROMPT.ask 'How many columns do you want in the board?', convert: :int, default: 7
53
+ end
54
+
55
+ def rows_amount
56
+ PROMPT.ask 'How many rows do you want in the board?', convert: :int, default: 6
57
+ end
58
+
59
+ def min_to_win
60
+ PROMPT.ask(
61
+ 'Minimum number of aligned similar discs necessary to win : ',
62
+ convert: :int,
63
+ default: 4
64
+ )
65
+ end
66
+
67
+ def difficulty
68
+ PROMPT.slider 'Difficulty : ', [*0..10], default: 0
69
+ end
70
+
71
+ def mode
72
+ PROMPT.select('Choose a game mode : ', ['Single Player', 'Multiplayer']).downcase
73
+ end
74
+
75
+ def human_starts?
76
+ PROMPT.yes? 'Do you wanna play first?'
77
+ end
78
+
79
+ def disc
80
+ PROMPT.ask 'Enter a character that will represent your disc : ', default: '🔥' do |q|
81
+ q.validate(/^.?$/)
82
+ q.messages[:valid?] = 'Please enter a single character.'
83
+ end
84
+ end
85
+
86
+ def human_name
87
+ PROMPT.ask 'Enter your name : ', default: ENV['USER']
88
+ end
89
+
90
+ def multiplayer_game
91
+ human_players = multiplayer_players
92
+ board = Board.new(
93
+ cols_amount: parameters[:cols_amount],
94
+ rows_amount: parameters[:rows_amount]
95
+ )
96
+ Game.new(
97
+ board: board,
98
+ first_player: players.first,
99
+ second_player: players.last,
100
+ min_to_win: parameters[:min_to_win]
101
+ )
102
+ end
103
+
104
+ def single_player_game
105
+ board = Board.new(
106
+ cols_amount: parameters[:cols_amount],
107
+ rows_amount: parameters[:rows_amount]
108
+ )
109
+ players = single_player_players(board)
110
+ Game.new(
111
+ board: board,
112
+ first_player: players.first,
113
+ second_player: players.last,
114
+ min_to_win: parameters[:min_to_win]
115
+ )
116
+ end
117
+
118
+ def multiplayer_players
119
+ parameters[:human_players].map do |name, disc|
120
+ HumanPlayer.new(name: name, disc: disc)
121
+ end
122
+ end
123
+
124
+ def single_player_players(board)
125
+ name, disc = parameters[:human_players].first
126
+ players = [
127
+ HumanPlayer.new(name: name, disc: disc),
128
+ ComputerPlayer.new(
129
+ board: board,
130
+ opponent_disc: disc,
131
+ min_to_win: parameters[:min_to_win],
132
+ difficulty: parameters[:difficulty]
133
+ )
134
+ ]
135
+ parameters[:human_starts?] ? players : players.rotate
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-box'
4
+ require 'yaml'
5
+
6
+ require_relative '../player/human_player/human_player'
7
+ require_relative '../player/computer_player/computer_player'
8
+ require_relative '../board/board'
9
+ require_relative '../winnable/winnable'
10
+
11
+ module ConnectN
12
+ class Game
13
+ include Winnable
14
+
15
+ attr_reader :board, :players, :min_to_win
16
+
17
+ PERMITTED_CLASSES = [Symbol, Game, Board, HumanPlayer, ComputerPlayer]
18
+
19
+ def initialize(
20
+ board:,
21
+ first_player:,
22
+ second_player:,
23
+ min_to_win: 4
24
+ )
25
+ @board = board
26
+ @players = [first_player, second_player]
27
+ @min_to_win = min_to_win
28
+ end
29
+
30
+ def play(yaml_fn = nil)
31
+ welcome
32
+ loop do
33
+ current_player = players.first
34
+
35
+ pick = current_player.pick
36
+
37
+ break self.class.save(self, self.class.name_game, yaml_fn) if self.class.save? pick
38
+
39
+ next invalid_pick unless board.valid_pick? pick
40
+
41
+ row_num, col_num, disc = board.drop_disc(current_player.disc, at_col: pick)
42
+
43
+ clear_display
44
+ board.draw
45
+
46
+ break over(current_player) if over?(board, row_num, col_num, disc)
47
+
48
+ players.rotate!
49
+ end
50
+ end
51
+
52
+ def over?(board, row, col, disc) = win?(board, row, col, disc) || board.filled?
53
+
54
+ def play_again? = PROMPT.yes? 'Would you like to play again?'
55
+
56
+ def self.save?(input) = input == ':w'
57
+
58
+ def self.resume? = PROMPT.yes? 'Do you want to resume a game?'
59
+
60
+ def self.resume(game) = game.play
61
+
62
+ def self.load(name, yaml_fn) = games(yaml_fn)[name.to_sym]
63
+
64
+ def self.games(yaml_fn)
65
+ YAML.safe_load_file(
66
+ yaml_fn,
67
+ permitted_classes: PERMITTED_CLASSES,
68
+ aliases: true
69
+ ) || {}
70
+ end
71
+
72
+ def self.save(game, name, yaml_fn)
73
+ games = games(yaml_fn)
74
+ games[name.to_sym] = game
75
+ dumped_games = YAML.dump(games)
76
+ File.write(yaml_fn, dumped_games)
77
+ end
78
+
79
+ def self.name_game = PROMPT.ask 'Name your game : '
80
+
81
+ def self.select_game_name(yaml_fn)
82
+ games = games(yaml_fn).keys.map.with_index(1) { "#{_2} -> #{_1}" }
83
+ PROMPT.select 'Choose a saved game : ', games, convert: :sym
84
+ end
85
+
86
+ def welcome
87
+ text = <<~TEXT
88
+ Welcome to Connect Four
89
+ #{' '}
90
+ To play, Enter a number from 1
91
+ to #{board.cols_amount}
92
+ The number corresponds to the
93
+ column order starting from the
94
+ left.
95
+ Enter anything to proceed.
96
+ TEXT
97
+ puts TTY::Box.frame text, padding: 2, align: :center
98
+ board.draw
99
+ end
100
+
101
+ def invalid_pick
102
+ PROMPT.error 'Invalid Column Number'
103
+ end
104
+
105
+ def over(winner)
106
+ phrase = board.filled? ? 'It is a tie!' : "#{winner.name} has won!"
107
+ puts TTY::Box.sucess(phrase)
108
+ end
109
+
110
+ private
111
+
112
+ def clear_display
113
+ puts "\e[1;1H\e[2J"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../player'
4
+ require_relative '../../winnable/winnable'
5
+
6
+ module ConnectN
7
+ class ComputerPlayer < Player
8
+ include Winnable
9
+
10
+ attr_accessor :difficulty, :delay
11
+ attr_reader :opponent_disc, :min_to_win
12
+
13
+ def initialize(
14
+ name: 'Computer',
15
+ disc: '🧊',
16
+ min_to_win: 4,
17
+ difficulty: 0,
18
+ delay: 0,
19
+ board:,
20
+ opponent_disc:
21
+ )
22
+ super(name: name, disc: disc)
23
+ @min_to_win = min_to_win
24
+ @difficulty = difficulty
25
+ @delay = delay
26
+ @board = board
27
+ @opponent_disc = opponent_disc
28
+ @scores = { disc => 10000, opponent_disc => -10000 }
29
+ end
30
+
31
+ def pick
32
+ sleep delay
33
+ best_score = -Float::INFINITY
34
+ best_pick = nil
35
+ @board.cols_amount.times do |pick|
36
+ next unless @board.valid_pick?(pick)
37
+
38
+ board_copy = @board.clone
39
+ row_num, col_num = board_copy.drop_disc(disc, at_col: pick)
40
+ score = minimax(board_copy, disc, row_num, col_num)
41
+ if score >= best_score
42
+ best_score = score
43
+ best_pick = pick
44
+ end
45
+ end
46
+ best_pick
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :scores
52
+
53
+ def minimax(
54
+ current_board,
55
+ current_disc,
56
+ row_num,
57
+ col_num,
58
+ moves_counter = 0,
59
+ alpha = -Float::INFINITY,
60
+ beta = Float::INFINITY,
61
+ depth = difficulty,
62
+ maximizing: false
63
+ )
64
+ return calculate_win_score(current_disc, moves_counter) if win?(current_board, row_num, col_num, current_disc)
65
+
66
+ return 0 if current_board.filled?
67
+
68
+ return heuristic(current_board, current_disc) if depth <= 0
69
+
70
+ if maximizing
71
+ score = -Float::INFINITY
72
+ @board.cols_amount.times do |pick|
73
+ next unless current_board.valid_pick?(pick)
74
+
75
+ board_copy = current_board.clone
76
+ row_num, col_num = board_copy.drop_disc(disc, at_col: pick)
77
+ score = [
78
+ score,
79
+ minimax(board_copy, disc, row_num, col_num, moves_counter + 1, alpha, beta, depth - 1, maximizing: false)
80
+ ].max
81
+ break if score >= beta
82
+
83
+ alpha = [alpha, score].max
84
+ end
85
+ else
86
+ score = Float::INFINITY
87
+ @board.cols_amount.times do |pick|
88
+ next unless current_board.valid_pick?(pick)
89
+
90
+ board_copy = current_board.clone
91
+ row_num, col_num = board_copy.drop_disc(opponent_disc, at_col: pick)
92
+ score = [
93
+ score,
94
+ minimax(board_copy, opponent_disc, row_num, col_num, moves_counter + 1, alpha, beta, depth - 1, maximizing: true)
95
+ ].min
96
+ break if score <= alpha
97
+
98
+ beta = [beta, score].min
99
+ end
100
+ end
101
+ score
102
+ end
103
+
104
+ def calculate_win_score(disc, moves_counter)
105
+ score = scores[disc] * @board.cols_amount * @board.rows_amount
106
+ score / moves_counter.to_f
107
+ end
108
+
109
+ def heuristic(board, disc)
110
+ value = 0
111
+ board.rows.each do |row|
112
+ row.each_cons(min_to_win).each do |set_of_n|
113
+ disc_count = set_of_n.count disc
114
+ opponent_disc_count = set_of_n.count opponent_disc
115
+ next if [disc_count, opponent_disc_count].none?(&:zero?)
116
+
117
+ value += disc_count
118
+ value -= opponent_disc_count
119
+ end
120
+ end
121
+ value
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-prompt'
4
+
5
+ require_relative '../player'
6
+
7
+ module ConnectN
8
+ class HumanPlayer < Player
9
+ attr_accessor :save_key
10
+
11
+ def initialize(name: 'Human', disc: '🔥', save_key: ':w')
12
+ @save_key = save_key
13
+ super name: name, disc: disc
14
+ end
15
+
16
+ def pick
17
+ input = PROMPT.ask('Please enter a column number : ')
18
+ input == save_key ? input : input.to_i - 1
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectN
4
+ class Player
5
+ attr_accessor :name, :disc
6
+
7
+ def initialize(name:, disc:)
8
+ @name = name
9
+ @disc = disc
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-prompt'
4
+
5
+ module ConnectN
6
+ PROMPT = TTY::Prompt.new
7
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectN
4
+ module Winnable
5
+ def win?(board, row_num, col_num, disc)
6
+ return true if vertical_win?(board, row_num, col_num, disc)
7
+
8
+ # k = -1 => backward diagonal \
9
+ # k = 0 => horizontal --
10
+ # k = 1 => forward diagonal /
11
+ (-1..1).any? do |k|
12
+ l = l_discs(board, row_num, col_num, disc, k)
13
+ r = r_discs(board, row_num, col_num, disc, k)
14
+ l + r + 1 >= min_to_win
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def vertical_win?(board, row_num, col_num, disc)
21
+ v = v_discs(board, row_num, col_num, disc)
22
+ v + 1 >= min_to_win
23
+ end
24
+
25
+ # Count similar discs on the left side
26
+ def l_discs(board, row_num, col_num, disc, k)
27
+ range = (1...min_to_win)
28
+ amount = range.find { |i| board.cell_at(row_num - k * i, col_num - i) != disc }
29
+
30
+ # amount can be nil if there are more similar discs than min_to_win
31
+ # in which case it should count as a win
32
+ amount ||= min_to_win
33
+
34
+ # Subtract the dropped disc
35
+ amount -= 1
36
+ end
37
+
38
+ # Count similar discs on the right side
39
+ def r_discs(board, row_num, col_num, disc, k)
40
+ range = (1...min_to_win)
41
+ amount = range.find { |i| board.cell_at(row_num + k * i, col_num + i) != disc }
42
+
43
+ # amount can be nil if there are more similar discs than min_to_win
44
+ # in which case it should count as a win
45
+ amount ||= min_to_win
46
+
47
+ # Subtract the dropped disc
48
+ amount -= 1
49
+ end
50
+
51
+ # Count similar discs forming a vertical line
52
+ def v_discs(board, row_num, col_num, disc)
53
+ range = (1...min_to_win)
54
+ amount = range.find do |i|
55
+ board.cell_at(row_num - i, col_num) != disc
56
+ end
57
+
58
+ # amount can be nil if there are more similar discs than min_to_win
59
+ # in which case it should count as a win
60
+ amount ||= min_to_win
61
+
62
+ # Subtract the dropped disc
63
+ amount -= 1
64
+ end
65
+ end
66
+ end
data/lib/connect_n.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'connect_n/prompt/prompt'
4
+ require_relative 'connect_n/player/human_player/human_player'
5
+ require_relative 'connect_n/player/computer_player/computer_player'
6
+ require_relative 'connect_n/demo/demo'
7
+ require_relative 'connect_n/game/game'
8
+ require_relative 'connect_n/board/board'
9
+
10
+ module ConnectN; end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: connect_n
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Lhoussaine (Jee-El) Ghallou
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-01-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: tty-prompt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.23.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.23.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: tty-box
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.7.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.7.0
41
+ description: A more general version of connect-4 where you try to connect N similar
42
+ discs. It comes with several features and a friendly API that allows you to customize
43
+ the game however you want!
44
+ email: ''
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - lib/connect_n.rb
50
+ - lib/connect_n/board/board.rb
51
+ - lib/connect_n/demo/demo.rb
52
+ - lib/connect_n/game/game.rb
53
+ - lib/connect_n/player/computer_player/computer_player.rb
54
+ - lib/connect_n/player/human_player/human_player.rb
55
+ - lib/connect_n/player/player.rb
56
+ - lib/connect_n/prompt/prompt.rb
57
+ - lib/connect_n/winnable/winnable.rb
58
+ homepage: https://rubygems.org/gems/connect_n
59
+ licenses:
60
+ - MIT
61
+ metadata: {}
62
+ post_install_message:
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.4.1
78
+ signing_key:
79
+ specification_version: 4
80
+ summary: Connect-N!
81
+ test_files: []