class Pool
Constants
- BALL_IDS
- BALL_RADIUS
- BALL_SYMBOLS
- FRICTION
- MAX_ANGLE
- MAX_PLAYERS
- MAX_SIM_STEPS
- MAX_STRENGTH
- MIN_ANGLE
- MIN_STRENGTH
- POCKETS
- POCKET_RADIUS
- SIM_DT
- STOP_SPEED
- TABLE_HEIGHT
- TABLE_WIDTH
Public Class Methods
Source
# File plugins/pool.rb, line 136 def self.active_game(channel, games: default_games) active_channel_game(channel, games: games) end
Source
# File plugins/pool.rb, line 308 def self.assign_group(game, player, ball_id) return if game[:assignments][player] group = solid?(ball_id) ? :solids : :stripes opponent = game[:players].find { |name| name != player } game[:assignments][player] = group game[:assignments][opponent] = group == :solids ? :stripes : :solids if opponent end
Source
# File plugins/pool.rb, line 127 def self.ball_group(id) return :cue if id == :cue return :eight if id == 8 return :solids if solid?(id) return :stripes if stripe?(id) nil end
Source
# File plugins/pool.rb, line 113 def self.ball_symbol(id, pocketed: false) return "·" if pocketed BALL_SYMBOLS.fetch(id, "?") end
Source
# File plugins/pool.rb, line 177 def self.build_racked_balls balls = rack_positions.map do |pos| { id: pos[:id], x: pos[:x], y: pos[:y], vx: 0.0, vy: 0.0, pocketed: false } end balls << { id: :cue, x: TABLE_WIDTH * 0.22, y: TABLE_HEIGHT / 2.0, vx: 0.0, vy: 0.0, pocketed: false } balls end
Source
# File plugins/pool.rb, line 293 def self.check_pockets(game) newly_pocketed = [] game[:balls].each do |ball| next if ball[:pocketed] if point_in_pocket?(ball[:x], ball[:y]) ball[:pocketed] = true ball[:vx] = 0.0 ball[:vy] = 0.0 newly_pocketed << ball end end newly_pocketed end
Source
# File plugins/pool.rb, line 140 def self.clear_game(channel, games: default_games) clear_channel_game(channel, games: games) end
Source
# File plugins/pool.rb, line 90 def self.command_error { error: usage_message } end
Source
# File plugins/pool.rb, line 15 def self.command_pattern /pool(?: (.+))?$/i end
Source
# File plugins/pool.rb, line 144 def self.cue_ball(game) game[:balls].find { |ball| ball[:id] == :cue && !ball[:pocketed] } end
Source
# File plugins/pool.rb, line 148 def self.current_player(game) game[:players][game[:turn_index]] end
Source
# File plugins/pool.rb, line 526 def self.dispatch_command(args_text, user:, channel: "#default", games: default_games) parsed = parse_command(args_text.to_s) return { error: parsed[:error] } if parsed[:error] return { message: usage_message } if parsed[:action] == :help case parsed[:action] when :new new_game(user, channel: channel, games: games) when :join join_game(user, channel: channel, games: games) when :start start_game(user, channel: channel, games: games) when :table { message: table_status(channel: channel, games: games) } when :shoot shoot(user, parsed[:angle], parsed[:strength], channel: channel, games: games) else { message: usage_message } end end
Source
# File plugins/pool.rb, line 105 def self.distance(x1, y1, x2, y2) Math.hypot(x2 - x1, y2 - y1) end
Source
# File plugins/pool.rb, line 453 def self.draw_table_grid(game) grid = grid_dimensions rows = Array.new(grid[:height]) { Array.new(grid[:width], "·") } rows[0] = "╔#{'═' * (grid[:width] - 2)}╗".chars rows[grid[:height] - 1] = "╚#{'═' * (grid[:width] - 2)}╝".chars (1...(grid[:height] - 1)).each do |y| rows[y][0] = "║" rows[y][grid[:width] - 1] = "║" end pocket_cols = [1, grid[:width] / 2, grid[:width] - 2] pocket_rows = [0, grid[:height] - 1] pocket_rows.each do |y| pocket_cols.each { |x| rows[y][x] = "◎" } end game[:balls].reject { |ball| ball[:pocketed] }.each do |ball| gx, gy = world_to_grid(ball[:x], ball[:y], grid) next if gx < 1 || gx >= grid[:width] - 1 || gy < 1 || gy >= grid[:height] - 1 rows[gy][gx] = ball_symbol(ball[:id]) end rows.map(&:join) end
Source
# File plugins/pool.rb, line 489 def self.format_bottom_line(game, footer) active = game[:balls].reject { |ball| ball[:pocketed] } cue = active.find { |ball| ball[:id] == :cue } counts = "on table: #{active.length}" cue_pos = cue ? "cue @ #{cue[:x].round(1)},#{cue[:y].round(1)}" : "cue pocketed" [footer || "Ready.", counts, cue_pos].join(" │ ") end
Source
# File plugins/pool.rb, line 497 def self.format_table_view(game, header: nil, footer: nil) [ format_top_line(game, header), draw_table_grid(game).join("\n"), format_bottom_line(game, footer) ].join("\n") end
Source
# File plugins/pool.rb, line 480 def self.format_top_line(game, header) players = game[:players].join(" vs ") assignments = game[:assignments].map { |name, group| "#{name}:#{group}" }.join(" ") parts = [header || "Pool table"] parts << players unless players.empty? parts << assignments unless assignments.empty? parts.join(" │ ") end
Source
# File plugins/pool.rb, line 443 def self.grid_dimensions { width: 42, height: 12 } end
Source
# File plugins/pool.rb, line 327 def self.group_cleared?(game, group) target = group == :solids ? (1..7).to_a : (9..15).to_a (target - game[:pocketed][group]).empty? end
Source
# File plugins/pool.rb, line 214 def self.join_game(user, channel:, games: default_games) game = active_game(channel, games: games) return { error: "#{user}, no table here — use !pool new." } unless game return { error: "#{user}, the game already started." } unless game[:stage] == :lobby normalized = normalize_user(user) return { error: "#{user}, you're already seated." } if game[:players].include?(normalized) return { error: "#{user}, table is full (max #{MAX_PLAYERS} players)." } if game[:players].length >= MAX_PLAYERS game[:players] << normalized { message: "#{normalized} joined. Players: #{game[:players].join(', ')} — !pool start when ready." } end
Source
# File plugins/pool.rb, line 192 def self.new_game(user, channel:, games: default_games) key = channel.to_s return { error: "#{user}, a pool table is already open here." } if games[key] normalized = normalize_user(user) games[key] = { host: normalized, stage: :lobby, players: [normalized], turn_index: 0, balls: [], assignments: {}, pocketed: { solids: [], stripes: [] }, last_shot: nil, winner: nil } { message: "Pool table opened by #{normalized}. !pool join (2 players max) | !pool start" } end
Source
# File plugins/pool.rb, line 62 def self.parse_command(text) parts = text.to_s.strip.split(/\s+/) return { action: :help } if parts.empty? case parts[0].downcase when "help" { action: :help } when "new" { action: :new } when "join" { action: :join } when "start" { action: :start } when "table", "status" { action: :table } when "shoot" return command_error unless parts.length >= 3 angle = parts[1].to_f strength = parts[2].to_i return command_error unless valid_shot?(angle, strength) { action: :shoot, angle: angle, strength: strength } else command_error end end
Source
# File plugins/pool.rb, line 109 def self.point_in_pocket?(x, y) POCKETS.any? { |pocket| distance(x, y, pocket[:x], pocket[:y]) <= POCKET_RADIUS } end
Source
# File plugins/pool.rb, line 547 def self.pool(args_text, user:, channel: "#default", games: default_games) result = dispatch_command(args_text, user: user, channel: channel, games: games) return result[:error] if result[:error] result[:message] || usage_message end
Source
# File plugins/pool.rb, line 152 def self.rack_positions apex_x = TABLE_WIDTH * 0.72 apex_y = TABLE_HEIGHT / 2.0 spacing = BALL_RADIUS * 2.05 rows = [ [8], [1, 9], [2, 10, 3], [11, 4, 12, 5], [13, 6, 14, 7, 15] ] positions = [] rows.each_with_index do |row, row_index| row_y = apex_y - ((row.length - 1) * spacing) / 2.0 row.each_with_index do |ball_id, col_index| positions << { id: ball_id, x: apex_x + row_index * spacing * 0.87, y: row_y + col_index * spacing } end end positions end
Source
# File plugins/pool.rb, line 317 def self.record_pocketed(game, ball_id) return if ball_id == :cue if solid?(ball_id) game[:pocketed][:solids] << ball_id unless game[:pocketed][:solids].include?(ball_id) elsif stripe?(ball_id) game[:pocketed][:stripes] << ball_id unless game[:pocketed][:stripes].include?(ball_id) end end
Source
# File plugins/pool.rb, line 244 def self.reflect_wall(ball) min_x = BALL_RADIUS max_x = TABLE_WIDTH - BALL_RADIUS min_y = BALL_RADIUS max_y = TABLE_HEIGHT - BALL_RADIUS if ball[:x] < min_x ball[:x] = min_x ball[:vx] = ball[:vx].abs * FRICTION elsif ball[:x] > max_x ball[:x] = max_x ball[:vx] = -ball[:vx].abs * FRICTION end if ball[:y] < min_y ball[:y] = min_y ball[:vy] = ball[:vy].abs * FRICTION elsif ball[:y] > max_y ball[:y] = max_y ball[:vy] = -ball[:vy].abs * FRICTION end end
Source
# File plugins/pool.rb, line 505 def self.render_table(game) format_table_view(game) end
Source
# File plugins/pool.rb, line 267 def self.resolve_ball_collision(a, b) return if a[:pocketed] || b[:pocketed] dist = distance(a[:x], a[:y], b[:x], b[:y]) min_dist = BALL_RADIUS * 2.0 return if dist.zero? || dist >= min_dist nx = (b[:x] - a[:x]) / dist ny = (b[:y] - a[:y]) / dist overlap = min_dist - dist a[:x] -= nx * overlap / 2.0 a[:y] -= ny * overlap / 2.0 b[:x] += nx * overlap / 2.0 b[:y] += ny * overlap / 2.0 dvx = a[:vx] - b[:vx] dvy = a[:vy] - b[:vy] impulse = dvx * nx + dvy * ny return if impulse.positive? a[:vx] -= impulse * nx a[:vy] -= impulse * ny b[:vx] += impulse * nx b[:vy] += impulse * ny end
Source
# File plugins/pool.rb, line 363 def self.shoot(user, angle, strength, channel:, games: default_games) game = active_game(channel, games: games) return { error: "#{user}, no table here." } unless game return { error: "#{user}, game hasn't started — !pool start." } unless game[:stage] == :playing normalized = normalize_user(user) return { error: "#{user}, you're not at this table." } unless game[:players].include?(normalized) return { error: "#{user}, wait — it's #{current_player(game)}'s turn." } unless current_player(game) == normalized cue = cue_ball(game) return { error: "#{user}, cue ball is pocketed — replace and shoot." } unless cue cue[:vx], cue[:vy] = velocity_from_shot(angle, strength) simulate_shot(game) shot_notes = [] foul = false continue_turn = false newly_pocketed = game[:balls].select { |ball| ball[:pocketed] } newly_pocketed.each do |ball| if ball[:id] == :cue foul = true shot_notes << "scratch — cue ball pocketed" ball[:pocketed] = false ball[:x] = TABLE_WIDTH * 0.22 ball[:y] = TABLE_HEIGHT / 2.0 ball[:vx] = 0.0 ball[:vy] = 0.0 elsif ball[:id] == 8 player_group = game[:assignments][normalized] if player_group.nil? || !group_cleared?(game, player_group) game[:stage] = :finished opponent = game[:players].find { |name| name != normalized } game[:winner] = opponent shot_notes << "⑧ sunk early — #{opponent} wins" else game[:stage] = :finished game[:winner] = normalized shot_notes << "⑧ sunk — #{normalized} wins" end else record_pocketed(game, ball[:id]) assign_group(game, normalized, ball[:id]) if game[:assignments][normalized].nil? player_group = game[:assignments][normalized] if player_group && ball_group(ball[:id]) == player_group continue_turn = true shot_notes << "#{ball_symbol(ball[:id])} pocketed" else shot_notes << "#{ball_symbol(ball[:id])} pocketed (opponent's ball)" end end end unless game[:stage] == :finished if foul game[:turn_index] = (game[:turn_index] + 1) % game[:players].length elsif continue_turn # same shooter else game[:turn_index] = (game[:turn_index] + 1) % game[:players].length end end game[:last_shot] = { player: normalized, angle: angle, strength: strength, notes: shot_notes } header = if game[:stage] == :finished "#{game[:winner]} wins the frame" else "#{normalized} shot #{angle.round}° @ #{strength} — next: #{current_player(game)}" end footer = shot_notes.empty? ? "Balls at rest." : shot_notes.join(" | ") { message: format_table_view(game, header: header, footer: footer) } end
Source
# File plugins/pool.rb, line 332 def self.simulate_shot(game) steps = 0 while steps < MAX_SIM_STEPS moving = false game[:balls].each do |ball| next if ball[:pocketed] ball[:x] += ball[:vx] * SIM_DT ball[:y] += ball[:vy] * SIM_DT reflect_wall(ball) speed = Math.hypot(ball[:vx], ball[:vy]) if speed.positive? moving = true ball[:vx] *= FRICTION ball[:vy] *= FRICTION if Math.hypot(ball[:vx], ball[:vy]) < STOP_SPEED ball[:vx] = 0.0 ball[:vy] = 0.0 end end end balls = game[:balls].reject { |ball| ball[:pocketed] } balls.combination(2).each { |a, b| resolve_ball_collision(a, b) } check_pockets(game) break unless moving steps += 1 end end
Source
# File plugins/pool.rb, line 119 def self.solid?(id) id.is_a?(Integer) && id.between?(1, 7) end
Source
# File plugins/pool.rb, line 227 def self.start_game(user, channel:, games: default_games) game = active_game(channel, games: games) return { error: "#{user}, no table here." } unless game return { error: "#{user}, only the host can start." } unless normalize_user(user) == game[:host] return { error: "#{user}, game already in progress." } unless game[:stage] == :lobby game[:stage] = :playing game[:balls] = build_racked_balls game[:turn_index] = 0 game[:assignments] = {} game[:pocketed] = { solids: [], stripes: [] } game[:last_shot] = nil game[:winner] = nil { message: format_table_view(game, header: "Break shot — #{current_player(game)} to shoot") } end
Source
# File plugins/pool.rb, line 123 def self.stripe?(id) id.is_a?(Integer) && id.between?(9, 15) end
Source
# File plugins/pool.rb, line 509 def self.table_status(channel:, games: default_games) game = active_game(channel, games: games) return "No pool table in this channel — !pool new to open one." unless game case game[:stage] when :lobby format_table_view( game.merge(balls: []), header: "Lobby — host: #{game[:host]} | players: #{game[:players].join(', ')}" ) when :finished format_table_view(game, header: "Game over — #{game[:winner]} wins") else format_table_view(game, header: "#{current_player(game)} to shoot") end end
Source
# File plugins/pool.rb, line 57 def self.usage_message "Usage: !pool new — open a table | !pool join | !pool start | " \ "!pool shoot <angle> <strength> | !pool table" end
Source
# File plugins/pool.rb, line 94 def self.valid_shot?(angle, strength) angle >= MIN_ANGLE && angle <= MAX_ANGLE && strength >= MIN_STRENGTH && strength <= MAX_STRENGTH end
Source
# File plugins/pool.rb, line 99 def self.velocity_from_shot(angle_deg, strength) speed = strength / 10.0 rad = angle_deg * Math::PI / 180.0 [Math.cos(rad) * speed, -Math.sin(rad) * speed] end
Source
# File plugins/pool.rb, line 447 def self.world_to_grid(x, y, grid) gx = ((x / TABLE_WIDTH) * (grid[:width] - 2)).round + 1 gy = ((y / TABLE_HEIGHT) * (grid[:height] - 2)).round + 1 [gx, gy] end
Public Instance Methods
Source
# File plugins/pool.rb, line 564 def execute(m, args_text = nil) channel = m.channel ? m.channel.name : m.user.nick result = self.class.dispatch_command( args_text, user: m.user.nick, channel: channel, games: self.class.default_games ) if result[:error] themed_flood_safe_reply(m, result[:error]) return end themed_flood_safe_reply(m, result[:message]) if result[:message] end
Source
# File plugins/pool.rb, line 554 def pool(args_text = nil, **options) channel = options[:channel] || "#default" self.class.pool( args_text, user: options[:user], channel: channel, games: options[:games] || self.class.default_games ) end