# frozen_string_literal: true require "io/console" require "game_of_life/plane" require "game_of_life/universe" require "game_of_life/cell" # Game of Life module entry point module GameOfLife # Base Error class class Error < StandardError; end class << self # Validate and parse the CLI options and set defaults for the dynamic options. # @param options [Hash] CLI options to generate initial conditions # @option options [String | nil] "input" path to a local file or URL to open # @option options [Numeric | nil] "seed" number to use if no "input" is provided # @option options [Numeric] "width" of the universe # @option options [Numeric] "height" of the universe # @option options [Numeric] "delay" introduced after each cycle # @option options [String] "live-cell" representation # @option options [String] "dead-cell" representation # @smell AbcSize, MethodLenght, Cyclomatic and Perceived Complexity needed for parsing and validating options # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/CyclomaticComplexity # rubocop:disable Metrics/PerceivedComplexity # return [Hash] of the parsed options with dynamic defaults based on window size and kernel random def parsed_options(options) max_height = IO.console.winsize[0] - 2 max_width = IO.console.winsize[1] if options["delay"].to_i.negative? fail GameOfLife::Error, "Invalid --delay value. must be positive value (sadly this is how time works -so far-)" end if (options["width"]&.< 1) || (options["width"]&.> max_width) fail GameOfLife::Error, "Invalid --width value. " \ "must be between 1 and #{max_width} (current terminal width)" end if (options["height"]&.< 1) || (options["height"]&.> max_height) fail GameOfLife::Error, "Invalid --height value. " \ "must be between 1 and #{max_height} (current terminal height)" end fail GameOfLife::Error, "Invalid --live-cell value. must be a single character" if options["live-cell"].length > 1 fail GameOfLife::Error, "Invalid --dead-cell value. must be a single character" if options["dead-cell"].length > 1 if options["dead-cell"] == options["live-cell"] fail GameOfLife::Error, "Invalid --dead-cell value. must be a different character than --live-cell" end { # Defaults the hight to less then the height of the terminal to allow banner info # and an extra emtpy line to avoid triggering terminal scroll while flushing "height" => max_height, "width" => max_width, "seed" => rand(100_000), }.merge(options) end # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/CyclomaticComplexity # rubocop:enable Metrics/PerceivedComplexity # Generate a universe based on the options (from an input file/URI) or using a random/passed seed # This method also sets the Classes/Modules constants for the run defining the options for # Cell (Dead/Live) state representations, and the delay introduced after rendering each universe/generation # and the banner for the info/options # # @param options [Hash] CLI options to generate initial conditions # @option options [String | nil] "input" path to a local file or URL to open # @option options [Numeric] "seed" number to use if no "input" is provided (defaults to random) # @option options [Numeric] "width" of the universe # @option options [Numeric] "height" of the universe # @option options [Numeric] "delay" introduced after each cycle # @option options [String] "live-cell" representation # @option options [String] "dead-cell" representation # @return [GameOfLife::Universe] populated with the parsed input/seed def generate(options) # Define the constants for Cell representation GameOfLife::Cell.const_set(:LIVE_CELL, options["live-cell"]) GameOfLife::Cell.const_set(:DEAD_CELL, options["dead-cell"]) # Define the constant used for delay GameOfLife.const_set(:DELAY, options["delay"].to_f) banner = options["input"] ? "Input: #{options['input']}" : "Seed: #{options['seed']}" # Define the constant used for banner info GameOfLife.const_set(:BANNER, banner) return GameOfLife::Generators::Input.new(options) if options["input"] GameOfLife::Generators::Seed.new(options) end # The infinite running loop for rendering the current state of a universe # and then evolving it and repeating. def run(universe) Kernel.loop do GameOfLife.render(universe) universe.evolve! end end # render method to do the terminal screen/scrollback buffer cleaning # and rendering of the current universe/banner info # based on the delay/cell configured by the user def render(universe) # Use Alternate Screen (\e[?1049h) and move cursor to top left (\e[H) # Ref: https://github.com/ruby/ruby/blob/ruby_2_7/lib/irb/easter-egg.rb#L112-L131 # Ref: https://stackoverflow.com/questions/11023929/using-the-alternate-screen-in-a-bash-script # Ref: https://superuser.com/questions/122911/what-commands-can-i-use-to-reset-and-clear-my-terminal print "\e[?1049h\e[H" print universe print "\n #{BANNER}" sleep(DELAY / 1000.0) end end # Generators module for generating the universe initial state module Generators autoload :Input, "game_of_life/generators/input.rb" autoload :Seed, "game_of_life/generators/seed.rb" end end