diff --git a/.gitignore b/.gitignore index f17da10..493eb5f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ *.a mkmf.log Gemfile.lock +.irb_history \ No newline at end of file diff --git a/.irbrc b/.irbrc new file mode 100644 index 0000000..071db6d --- /dev/null +++ b/.irbrc @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +def require_all_files(directory) + Dir["#{directory}/**/*.rb"].each { |file| require file } +end + +# Require all files in the project_root directory and its subdirectories +require_all_files(File.expand_path(__dir__)) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 6f68542..f648e40 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -29,7 +29,9 @@ Metrics/AbcSize: Metrics/ClassLength: Exclude: - 'lib/CLI/command.rb' - Max: 197 + - 'lib/biome.rb' + - 'test/**/*' + Max: 150 # Offense count: 2 # Configuration parameters: AllowedMethods, AllowedPatterns. @@ -45,6 +47,8 @@ Metrics/MethodLength: # Configuration parameters: CountKeywordArgs, MaxOptionalParameters. Metrics/ParameterLists: Max: 6 + Exclude: + - 'lib/tile.rb' # Offense count: 2 # Configuration parameters: AllowedMethods, AllowedPatterns. diff --git a/README.md b/README.md index e8d4901..cf5421b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![CodeQL](https://github.com/matthewstyler/ruby-perlin-2D-map-generator/workflows/CodeQL/badge.svg) [![Downloads](https://img.shields.io/gem/dt/ruby-perlin-2D-map-generator.svg?style=flat)](https://rubygems.org/gems/ruby-perlin-2D-map-generator) -A gem that procedurally generates seeded and customizable 2D map using perlin noise. +A gem that procedurally generates seeded and customizable 2D map with optional roads using perlin noise. Include the gem in your project, or use the executable from the command line. @@ -34,6 +34,7 @@ gem install ruby-perlin-2D-map-generator See Command line Usage for full customization, below are some examples. Alter the temperature, moisture or elevation seeds to alter these maps: - Plains with random terrain evens: `ruby-perlin-2D-map-generator render` +- Plains with random terrain events and two roads: `ruby-perlin-2D-map-generator render --roads=2` - Desert (increase temperature, decrease moisture): `ruby-perlin-2D-map-generator render --temp=100 --moisture=-100` - Mountainous with lakes (increase elevation, increase moisture) `ruby-perlin-2D-map-generator render --elevation=25 --moisture=25` - Islands (decreaes elevation, increase moisture): `ruby-perlin-2D-map-generator render --elevation=-40 --moisture=25` @@ -44,21 +45,39 @@ See Command line Usage for full customization, below are some examples. Alter th --width=int The width of the generated map (default 128) --height=int The height of the generated map (default 128) +--roads=int Add this many roads through the map, + starting and ending at edges + (default 0) + --hs=int The seed for a terrains height perlin generation (default 10) --ms=int The seed for a terrains moist perlin generation (default 300) --ts=int The seed for a terrains temperature perlin generation (default 3000) +--rs=int The seed for generating roads + (default 100) ---elevation=float Adjust each generated elevation by this percent (0 - +--elevation=float Adjust each generated elevation by this percent (-100 - 100) (default 0.0) ---moisture=float Adjust each generated moisture by this percent (0 - +--moisture=float Adjust each generated moisture by this percent (-100 - 100) (default 0.0) ---temp=float Adjust each generated temperature by this percent (0 +--temp=float Adjust each generated temperature by this percent (-100 - 100) (default 0.0) ``` +## Roads and the heuristic +Roads can be generated by providing a positive integer to the `roads=` argument. Roads are randomly seeded to begin +and start at an axis (but not the same axis). + +A* pathfinding is used to generate the roads with a heuristic that uses manhattan distance, favours existing roads and similar elevations in adjacent tiles. + +Roads can be configured to include/exclude generating paths thorugh water, mountains and flora. + +Tiles containing roads are of type `road`, those without are of type `terrain`. + +The `--roads_to_make` option allows you to specify multiple pairs of coordinates to attempt to build paths, subject to the heuristic and other option constraints. Expects a a single list, but must be sets of 4, example of two roads: `--roads_to_make=0,0,50,50,0,0,75,75` + # Generate without rendering ```irb @@ -112,7 +131,7 @@ $ ruby-perlin-2D-map-generator --help ```bash Usage: ruby-perlin-2D-map-generator [OPTIONS] (DESCRIBE | RENDER) -Generate a seeded customizable procedurally generated 2D map. +Generate a seeded customizable procedurally generated 2D map with optional roads. Rendered in the console using ansi colours, or described as a 2D array of hashes with each tiles information. @@ -128,40 +147,64 @@ Keywords: coordinate tile details Options: - --elevation=float Adjust each generated elevation by this percent (0 - - 100) (default 0.0) - --fhx=float The frequency for height generation across the x-axis - (default 2.5) - --fhy=float The frequency for height generation across the y-axis - (default 2.5) - --fmx=float The frequency for moist generation across the x-axis - (default 2.5) - --fmy=float The frequency for moist generation across the y-axis - (default 2.5) - --ftx=float The frequency for temp generation across the x-axis - (default 2.5) - --fty=float The frequency for temp generation across the y-axis - (default 2.5) - --gf=bool Generate flora, significantly affects performance - --height=int The height of the generated map (default 128) - -h, --help Print usage - --hs=int The seed for a terrains height perlin generation - (default 10) - --moisture=float Adjust each generated moisture by this percent (0 - - 100) (default 0.0) - --ms=int The seed for a terrains moist perlin generation - (default 300) - --oh=int Octaves for height generation (default 3) - --om=int Octaves for moist generation (default 3) - --ot=int Octaves for temp generation (default 3) - --ph=float Persistance for height generation (default 1.0) - --pm=float Persistance for moist generation (default 1.0) - --pt=float Persistance for temp generation (default 1.0) - --temp=float Adjust each generated temperature by this percent (0 - - 100) (default 0.0) - --ts=int The seed for a terrains temperature perlin generation - (default 3000) - --width=int The width of the generated map (default 128) + --elevation=float Adjust each generated elevation by + this percent (-100 - 100) (default 0.0) + --fhx=float The frequency for height generation + across the x-axis (default 2.5) + --fhy=float The frequency for height generation + across the y-axis (default 2.5) + --fmx=float The frequency for moist generation + across the x-axis (default 2.5) + --fmy=float The frequency for moist generation + across the y-axis (default 2.5) + --ftx=float The frequency for temp generation + across the x-axis (default 2.5) + --fty=float The frequency for temp generation + across the y-axis (default 2.5) + --gf=bool Generate flora, significantly affects + performance + --height=int The height of the generated map + (default 128) + -h, --help Print usage + --hs=int The seed for a terrains height perlin + generation (default 10) + --moisture=float Adjust each generated moisture by + this percent (-100 - 100) (default 0.0) + --ms=int The seed for a terrains moist perlin + generation (default 300) + --oh=int Octaves for height generation + (default 3) + --om=int Octaves for moist generation (default + 3) + --ot=int Octaves for temp generation (default + 3) + --ph=float Persistance for height generation + (default 1.0) + --pm=float Persistance for moist generation + (default 1.0) + --pt=float Persistance for temp generation + (default 1.0) + --road_exclude_flora_path=bool Controls if roads will run tiles + containing flora + --road_exclude_mountain_path=bool Controls if roads will run through + high mountains + --road_exclude_water_path=bool Controls if roads will run through + water + --roads=int Add this many roads through the map, + starting and ending at edges (default + 0) + --roads_to_make ints Attempt to create a road from a start + and end point (4 integers), can be + supplied multiple paths + (default []) + --rs=int The seed for generating roads + (default 100) + --temp=float Adjust each generated temperature by + this percent (-100 - 100) (default 0.0) + --ts=int The seed for a terrains temperature + perlin generation (default 3000) + --width=int The width of the generated map + (default 128) Examples: Render with defaults @@ -169,6 +212,9 @@ Examples: Render with options $ ruby-perlin-2D-map-generator render --elevation=-40 --moisture=25 --hs=1 + + Render with roads + $ ruby-perlin-2D-map-generator render --roads=2 Describe tile [1, 1] $ ruby-perlin-2D-map-generator describe coordinates=1,1 diff --git a/Rakefile b/Rakefile index 9b3a1b8..cd26ad7 100644 --- a/Rakefile +++ b/Rakefile @@ -10,7 +10,7 @@ Minitest::TestTask.create(:test) do |t| end task :irb do - exec 'irb -I lib -r ./lib/**/*' + exec 'irb -I lib' end task :lint do diff --git a/lib/CLI/command.rb b/lib/CLI/command.rb index 09902dc..4060274 100644 --- a/lib/CLI/command.rb +++ b/lib/CLI/command.rb @@ -12,7 +12,7 @@ class Command no_command - desc 'Generate a seeded customizable procedurally generated 2D map. Rendered in the console ' \ + desc 'Generate a seeded customizable procedurally generated 2D map with optional roads. Rendered in the console ' \ ' using ansi colours, or described as a 2D array of hashes with each tiles information.' example 'Render with defaults', @@ -21,6 +21,9 @@ class Command example 'Render with options', ' $ ruby-perlin-2D-map-generator render --elevation=-40 --moisture=25 --hs=1' + example 'Render with roads', + ' $ ruby-perlin-2D-map-generator render --roads=2' + example 'Describe tile [1, 1]', ' $ ruby-perlin-2D-map-generator describe coordinates=1,1' end @@ -41,6 +44,15 @@ class Command desc 'Used with the describe command, only returns the given coordinate tile details' end + option :roads_to_make do + arity one + long '--roads_to_make ints' + convert :int_list + validate ->(v) { v >= 0 } + desc 'Attempt to create a road from a start and end point (4 integers), can be supplied multiple paths' + default MapConfig::DEFAULT_ROADS_TO_MAKE + end + option :height_seed do long '--hs int' # or @@ -210,7 +222,7 @@ class Command long '--temp float' long '--temp=float' - desc 'Adjust each generated temperature by this percent (0 - 100)' + desc 'Adjust each generated temperature by this percent (-100 - 100)' convert ->(val) { val.to_f / 100.0 } validate ->(val) { val >= -1.0 && val <= 1.0 } default MapConfig::DEFAULT_TEMP_ADJUSTMENT @@ -220,7 +232,7 @@ class Command long '--elevation float' long '--elevation=float' - desc 'Adjust each generated elevation by this percent (0 - 100)' + desc 'Adjust each generated elevation by this percent (-100 - 100)' convert ->(val) { val.to_f / 100.0 } validate ->(val) { val >= -1.0 && val <= 1.0 } default MapConfig::DEFAULT_HEIGHT_ADJUSTMENT @@ -230,12 +242,58 @@ class Command long '--moisture float' long '--moisture=float' - desc 'Adjust each generated moisture by this percent (0 - 100)' + desc 'Adjust each generated moisture by this percent (-100 - 100)' convert ->(val) { val.to_f / 100.0 } validate ->(val) { val >= -1.0 && val <= 1.0 } default MapConfig::DEFAULT_MOIST_ADJUSTMENT end + option :roads do + long '--roads int' + long '--roads=int' + + desc 'Add this many roads through the map, starting and ending at edges' + convert Integer + validate ->(val) { val >= 0 } + default MapConfig::DEFAULT_NUM_OF_ROADS + end + + option :road_seed do + long '--rs int' + long '--rs=int' + + desc 'The seed for generating roads' + convert Integer + default MapConfig::DEFAULT_ROAD_SEED + end + + option :road_exclude_water_path do + long '--road_exclude_water_path bool' + long '--road_exclude_water_path=bool' + + desc 'Controls if roads will run through water' + convert :bool + default MapConfig::DEFAULT_ROAD_EXCLUDE_WATER_PATH + end + + option :road_exclude_mountain_path do + long '--road_exclude_mountain_path bool' + long '--road_exclude_mountain_path=bool' + + desc 'Controls if roads will run through high mountains' + convert :bool + default MapConfig::DEFAULT_ROAD_EXCLUDE_MOUNTAIN_PATH + end + + option :road_exclude_flora_path do + long '--road_exclude_flora_path bool' + long '--road_exclude_flora_path=bool' + + desc 'Controls if roads will run tiles containing flora' + convert :bool + default MapConfig::DEFAULT_ROAD_EXCLUDE_FLORA_PATH + end + flag :help do short '-h' long '--help' @@ -259,10 +317,9 @@ def execute_command map = Map.new(map_config: MapConfig.new( width: params[:width], height: params[:height], - perlin_height_config: perlin_height_config, - perlin_moist_config: perlin_moist_config, - perlin_temp_config: perlin_temp_config, - generate_flora: params[:generate_flora] + all_perlin_configs: MapConfig::AllPerlinConfigs.new(perlin_height_config, perlin_moist_config, perlin_temp_config), + generate_flora: params[:generate_flora], + road_config: MapConfig::RoadConfig.new(*params.to_h.slice(:road_seed, :roads, :road_exclude_water_path, :road_exclude_mountain_path, :road_exclude_flora_path, :roads_to_make).values) )) case params[:command] when 'render' then map.render diff --git a/lib/ansi_colours.rb b/lib/ansi_colours.rb index 4a0eaba..5f3c0ae 100644 --- a/lib/ansi_colours.rb +++ b/lib/ansi_colours.rb @@ -26,6 +26,9 @@ module Background TAIGA_HIGHLAND = "\e[48;5;65m" TAIGA_COAST = "\e[48;5;17m" ICE = "\e[48;5;159m" + LOW_ROAD_BLACK = "\e[48;5;241m" + ROAD_BLACK = "\e[48;5;239m" + HIGH_ROAD_BLACK = "\e[48;5;236m" ANSI_RESET = "\033[0m" end end diff --git a/lib/biome.rb b/lib/biome.rb index f48c82a..d316e86 100644 --- a/lib/biome.rb +++ b/lib/biome.rb @@ -32,6 +32,10 @@ def taiga? TAIGA_TERRAIN.include?(self) end + def high_mountain? + HIGH_MOUNTAIN.include?(self) + end + def flora_available !flora_range.nil? end @@ -132,6 +136,12 @@ def self.from(elevation, moist, temp) TAIGA_COAST ].freeze + HIGH_MOUNTAIN = [ + SNOW, + ROCKS, + MOUNTAIN + ].freeze + LAND_TERRAIN = (ALL_TERRAIN - WATER_TERRAIN).freeze class << self diff --git a/lib/map.rb b/lib/map.rb index e0b660c..1027adb 100644 --- a/lib/map.rb +++ b/lib/map.rb @@ -2,6 +2,7 @@ require 'map_tile_generator' require 'map_config' +require 'road_generator' class Map attr_reader :config @@ -30,6 +31,18 @@ def [](x, y) # rubocop:enable Naming/MethodParameterName: def tiles - @tiles ||= MapTileGenerator.new(map: self).generate + return @tiles if @tiles + + @tiles = generate_tiles + road_generator = RoadGenerator.new(@tiles) + road_generator.generate_num_of_random_roads(config.road_config) + road_generator.generate_roads_from_coordinate_list(config.road_config.roads_to_make) + @tiles + end + + private + + def generate_tiles + MapTileGenerator.new(map: self).generate end end diff --git a/lib/map_config.rb b/lib/map_config.rb index 77742e6..c168607 100644 --- a/lib/map_config.rb +++ b/lib/map_config.rb @@ -25,25 +25,43 @@ class MapConfig DEFAULT_TEMP_X_FREQUENCY = 2.5 DEFAULT_TEMP_ADJUSTMENT = 0.0 + DEFAULT_ROAD_SEED = 100 + DEFAULT_NUM_OF_ROADS = 0 + DEFAULT_ROAD_EXCLUDE_WATER_PATH = true + DEFAULT_ROAD_EXCLUDE_MOUNTAIN_PATH = true + DEFAULT_ROAD_EXCLUDE_FLORA_PATH = true + DEFAULT_ROADS_TO_MAKE = [].freeze + PERLIN_CONFIG_OPTIONS = %i[width height noise_seed octaves x_frequency y_frequency persistance adjustment].freeze - PerlinConfig = Struct.new(*PERLIN_CONFIG_OPTIONS) + ALL_PERLIN_CONFIGS = %i[perlin_height_config perlin_moist_config perlin_temp_config].freeze + ROAD_CONFIG_OPTIONS = %i[road_seed roads road_exclude_water_path road_exclude_mountain_path road_exclude_flora_path roads_to_make].freeze - attr_reader :generate_flora, :perlin_height_config, :perlin_moist_config, :perlin_temp_config, :width, :height + PerlinConfig = Struct.new(*PERLIN_CONFIG_OPTIONS) + AllPerlinConfigs = Struct.new(*ALL_PERLIN_CONFIGS) + RoadConfig = Struct.new(*ROAD_CONFIG_OPTIONS) - def initialize(perlin_height_config: default_perlin_height_config, perlin_moist_config: default_perlin_moist_config, perlin_temp_config: default_perlin_temp_config, width: DEFAULT_TILE_COUNT, - height: DEFAULT_TILE_COUNT, generate_flora: DEFAULT_GENERATE_FLORA) - raise ArgumentError unless perlin_height_config.is_a?(PerlinConfig) && perlin_moist_config.is_a?(PerlinConfig) + attr_reader :generate_flora, :perlin_height_config, :perlin_moist_config, :perlin_temp_config, :width, :height, :road_config + def initialize(all_perlin_configs: default_perlin_configs, width: DEFAULT_TILE_COUNT, + height: DEFAULT_TILE_COUNT, generate_flora: DEFAULT_GENERATE_FLORA, road_config: default_road_config) + validate(all_perlin_configs) @generate_flora = generate_flora - @perlin_height_config = perlin_height_config - @perlin_moist_config = perlin_moist_config - @perlin_temp_config = perlin_temp_config + @perlin_height_config = all_perlin_configs.perlin_height_config + @perlin_moist_config = all_perlin_configs.perlin_moist_config + @perlin_temp_config = all_perlin_configs.perlin_temp_config @width = width @height = height + @road_config = road_config end private + def validate(all_perlin_configs) + unless all_perlin_configs.perlin_height_config.is_a?(PerlinConfig) && all_perlin_configs.perlin_moist_config.is_a?(PerlinConfig) && all_perlin_configs.perlin_temp_config.is_a?(PerlinConfig) + raise ArgumentError + end + end + def default_perlin_height_config PerlinConfig.new(DEFAULT_TILE_COUNT, DEFAULT_TILE_COUNT, DEFAULT_HEIGHT_SEED, DEFAULT_HEIGHT_OCTAVES, DEFAULT_HEIGHT_X_FREQUENCY, DEFAULT_HEIGHT_Y_FREQUENCY, DEFAULT_HEIGHT_PERSISTANCE, DEFAULT_HEIGHT_ADJUSTMENT) @@ -58,4 +76,12 @@ def default_perlin_temp_config PerlinConfig.new(DEFAULT_TILE_COUNT, DEFAULT_TILE_COUNT, DEFAULT_TEMP_SEED, DEFAULT_TEMP_OCTAVES, DEFAULT_TEMP_X_FREQUENCY, DEFAULT_TEMP_Y_FREQUENCY, DEFAULT_TEMP_PERSISTANCE, DEFAULT_TEMP_ADJUSTMENT) end + + def default_road_config + RoadConfig.new(DEFAULT_ROAD_SEED, DEFAULT_NUM_OF_ROADS, DEFAULT_ROAD_EXCLUDE_WATER_PATH, DEFAULT_ROAD_EXCLUDE_MOUNTAIN_PATH, DEFAULT_ROAD_EXCLUDE_FLORA_PATH, DEFAULT_ROADS_TO_MAKE) + end + + def default_perlin_configs + AllPerlinConfigs.new(default_perlin_height_config, default_perlin_moist_config, default_perlin_temp_config) + end end diff --git a/lib/pathfinding/a_star_finder.rb b/lib/pathfinding/a_star_finder.rb new file mode 100644 index 0000000..e97ce96 --- /dev/null +++ b/lib/pathfinding/a_star_finder.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Pathfinding + # + # An A* Pathfinder to build roads/paths between two coordinates containing + # different path costs, the heuristic behaviour that can be altered via configuration + # + class AStarFinder + def find_path(start_node, end_node, grid) + open_set = [start_node] + came_from = {} + g_score = { start_node => 0 } + f_score = { start_node => heuristic_cost_estimate(start_node, end_node) } + + until open_set.empty? + current_node = open_set.min_by { |node| f_score[node] } + + return reconstruct_path(came_from, current_node) if current_node == end_node + + open_set.delete(current_node) + + grid.neighbors(current_node).each do |neighbor| + tentative_g_score = g_score[current_node] + 1 + + next unless !g_score[neighbor] || tentative_g_score < g_score[neighbor] + + came_from[neighbor] = current_node + g_score[neighbor] = tentative_g_score + f_score[neighbor] = g_score[neighbor] + heuristic_cost_estimate(neighbor, end_node) + + open_set << neighbor unless open_set.include?(neighbor) + end + end + + # No path found + [] + end + + private + + def heuristic_cost_estimate(node, end_node) + (node.x - end_node.x).abs + + (node.y - end_node.y).abs + + (node.path_heuristic - end_node.path_heuristic) + # elevation for natural roads + (node.road? ? 0 : 5) # share existing roads + end + + def reconstruct_path(came_from, current_node) + path = [current_node] + while came_from[current_node] + current_node = came_from[current_node] + path.unshift(current_node) + end + path + end + end +end diff --git a/lib/pathfinding/grid.rb b/lib/pathfinding/grid.rb new file mode 100644 index 0000000..e07cbf5 --- /dev/null +++ b/lib/pathfinding/grid.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Pathfinding + # + # Responsible for manipulating and encapsulating behaviour of tiles related + # to pathfinding + # + class Grid + attr_reader :nodes + + def initialize(nodes) + @nodes = nodes + end + + # rubocop:disable Naming/MethodParameterName: + def node(x, y) + nodes[y][x] + end + # rubocop:enable Naming/MethodParameterName: + + def neighbors(node) + neighbors = [] + return neighbors unless node.can_contain_road? + + x = node.x + y = node.y + + node_lookup = node(x - 1, y) if x.positive? + neighbors << node_lookup if !node_lookup.nil? && node_lookup.can_contain_road? + node_lookup = node(x + 1, y) if x < @nodes[0].size - 1 + neighbors << node_lookup if !node_lookup.nil? && node_lookup.can_contain_road? + node_lookup = node(x, y - 1) if y.positive? + neighbors << node_lookup if !node_lookup.nil? && node_lookup.can_contain_road? + node_lookup = node(x, y + 1) if y < @nodes.size - 1 + neighbors << node_lookup if !node_lookup.nil? && node_lookup.can_contain_road? + + neighbors + end + + def min_max_coordinates + @min_max_coordinates ||= begin + min_x = nil + min_y = nil + max_x = nil + max_y = nil + + @nodes.each do |row| + row.each do |object| + x = object.x + y = object.y + + # Update minimum x and y values + min_x = x if min_x.nil? || x < min_x + min_y = y if min_y.nil? || y < min_y + + # Update maximum x and y values + max_x = x if max_x.nil? || x > max_x + max_y = y if max_y.nil? || y > max_y + end + end + + { min_x: min_x, min_y: min_y, max_x: max_x, max_y: max_y } + end + end + + def edge_nodes + @edge_nodes ||= + @nodes.map do |row| + row.select do |obj| + obj.x == min_max_coordinates[:min_x] || + obj.x == min_max_coordinates[:max_x] || + obj.y == min_max_coordinates[:min_y] || + obj.y == min_max_coordinates[:max_y] + end + end.flatten + end + end +end diff --git a/lib/road_generator.rb b/lib/road_generator.rb new file mode 100644 index 0000000..df9ff69 --- /dev/null +++ b/lib/road_generator.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'pathfinding/grid' +require 'pathfinding/a_star_finder' + +# +# Generates roads across map tiles, randomly or given specific coordinates +# +class RoadGenerator + attr_reader :grid, :finder + + def initialize(tiles) + @grid = Pathfinding::Grid.new(tiles) + @finder = Pathfinding::AStarFinder.new + end + + def generate_num_of_random_roads(config) + return if config.roads <= 0 + + seed = config.road_seed + (1..config.roads).each do |n| + random_objects_at_edges = random_nodes_not_on_same_edge(seed + n) # add n otherwise each road is the same + generate_path( + random_objects_at_edges[0].x, + random_objects_at_edges[0].y, + random_objects_at_edges[1].x, + random_objects_at_edges[1].y + ).each(&:make_road) + end + end + + def generate_roads_from_coordinate_list(road_paths) + road_paths.each_slice(4) do |road_coordinates| + generate_path( + road_coordinates[0], + road_coordinates[1], + road_coordinates[2], + road_coordinates[3] + ).each(&:make_road) + end + end + + def generate_path(start_x, start_y, end_x, end_y) + start_node = grid.node(start_x, start_y) + end_node = grid.node(end_x, end_y) + finder.find_path(start_node, end_node, grid) + end + + private + + def random_nodes_not_on_same_edge(seed) + random_generator = Random.new(seed) + length = @grid.edge_nodes.length + + loop do + index1 = random_generator.rand(length) + index2 = random_generator.rand(length) + node_one, node_two = @grid.edge_nodes.values_at(index1, index2) + + return [node_one, node_two] if node_one.x != node_two.x && node_one.y != node_two.y + end + end +end diff --git a/lib/tile.rb b/lib/tile.rb index d3bc398..09e6a9d 100644 --- a/lib/tile.rb +++ b/lib/tile.rb @@ -2,17 +2,27 @@ require 'biome' require 'flora' +require 'ansi_colours' +require 'pry-byebug' class Tile - attr_reader :x, :y, :height, :moist, :temp, :map + attr_reader :x, :y, :height, :moist, :temp, :map, :type - def initialize(map:, x:, y:, height: 0, moist: 0, temp: 0) + TYPES = %i[ + terrain + road + ].freeze + + def initialize(map:, x:, y:, height: 0, moist: 0, temp: 0, type: :terrain) @x = x @y = y @height = height @moist = moist @temp = temp @map = map + raise ArgumentError, 'invalid tile type' unless TYPES.include?(type) + + @type = type end def surrounding_tiles(distance = 1) @@ -36,7 +46,7 @@ def items end def render_to_standard_output - print biome.colour + (!items.empty? ? item_with_highest_priority.render_symbol : ' ') + print render_color_by_type + (!items.empty? ? item_with_highest_priority.render_symbol : ' ') print AnsiColours::Background::ANSI_RESET end @@ -50,6 +60,10 @@ def item_with_highest_priority items.max_by(&:render_priority) end + def items_contain_flora? + items.any? { |i| i.is_a?(Flora) } + end + def to_h { x: x, @@ -58,12 +72,56 @@ def to_h moist: moist, temp: temp, biome: biome.to_h, - items: items.map(&:to_h) + items: items.map(&:to_h), + type: type } end + def make_road + @type = :road + end + + def road? + @type == :road + end + + def path_heuristic + height + end + + def can_contain_road? + return true unless biome_is_water_and_is_excluded? || biome_is_high_mountain_and_is_excluded? || tile_contains_flora_and_is_excluded? + end + private + def biome_is_water_and_is_excluded? + biome.water? && map.config.road_config.road_exclude_water_path + end + + def biome_is_high_mountain_and_is_excluded? + biome.high_mountain? && map.config.road_config.road_exclude_mountain_path + end + + def tile_contains_flora_and_is_excluded? + items_contain_flora? && map.config.road_config.road_exclude_flora_path + end + + def render_color_by_type + case type + when :terrain then biome.colour + when :road + case height + when 0.66..1 + AnsiColours::Background::HIGH_ROAD_BLACK + when 0.33..0.66 + AnsiColours::Background::ROAD_BLACK + when 0..0.33 + AnsiColours::Background::LOW_ROAD_BLACK + end + end + end + def items_generated_with_flora_if_applicable if map.config.generate_flora && biome.flora_available range_max_value = map.tiles[(y - biome.flora_range)...(y + biome.flora_range)]&.map do |r| diff --git a/ruby-perlin-2D-map-generator.gemspec b/ruby-perlin-2D-map-generator.gemspec index b709a3a..cb4ef4f 100644 --- a/ruby-perlin-2D-map-generator.gemspec +++ b/ruby-perlin-2D-map-generator.gemspec @@ -3,9 +3,9 @@ Gem::Specification.new do |s| s.name = 'ruby-perlin-2D-map-generator' s.version = '0.0.4' - s.summary = 'Procedurally generate seeded and customizable 2D maps, rendered with ansi colours or described in a 2D array of hashes' - s.description = 'A gem that procedurally generates a seeded and customizable 2D map using perlin noise. Map can be rendered in console ' \ - 'using ansi colors or returned as 2D array of hashes describing each tile and binome. Completely' \ + s.summary = 'Procedurally generate seeded and customizable 2D maps with optional roads, rendered with ansi colours or described in a 2D array of hashes' + s.description = 'A gem that procedurally generates a seeded and customizable 2D map with optional roads using perlin noise. Map can be rendered in console ' \ + 'using ansi colors or returned as 2D array of hashes describing each tile and binome. Completely ' \ 'customizable, use the --help option for full usage details.' s.authors = ['Tyler Matthews (matthewstyler)'] s.email = 'matthews.tyl@gmail.com' @@ -25,6 +25,7 @@ Gem::Specification.new do |s| s.add_development_dependency 'minitest', '~> 5.18' s.add_development_dependency 'mocha', '~> 2.1.0' + s.add_development_dependency 'pry-byebug', '~> 3.10.1' s.add_development_dependency 'rake', '~> 13.0.6' s.add_development_dependency 'rubocop', '~> 1.55.1' s.add_development_dependency 'simplecov', '~> 0.22.0' diff --git a/test/map_config_test.rb b/test/map_config_test.rb index 92c4dcb..968c7ed 100644 --- a/test/map_config_test.rb +++ b/test/map_config_test.rb @@ -43,9 +43,7 @@ def setup @generate_flora = false @map_config = MapConfig.new( - perlin_height_config: @perlin_height_config, - perlin_moist_config: @perlin_moist_config, - perlin_temp_config: @perlin_temp_config, + all_perlin_configs: MapConfig::AllPerlinConfigs.new(@perlin_height_config, @perlin_moist_config, @perlin_temp_config), width: @width, height: @height, generate_flora: @generate_flora @@ -65,9 +63,7 @@ def test_initialize_with_invalid_perlin_height_config invalid_perlin_height_config = :invalid assert_raises(ArgumentError) do MapConfig.new( - perlin_height_config: invalid_perlin_height_config, - perlin_moist_config: @perlin_moist_config, - perlin_temp_config: @perlin_temp_config + all_perlin_configs: MapConfig::AllPerlinConfigs.new(invalid_perlin_height_config, @perlin_moist_config, @perlin_temp_config) ) end end @@ -76,9 +72,7 @@ def test_initialize_with_invalid_perlin_moist_config invalid_perlin_moist_config = :invalid assert_raises(ArgumentError) do MapConfig.new( - perlin_height_config: @perlin_height_config, - perlin_moist_config: invalid_perlin_moist_config, - perlin_temp_config: @perlin_temp_config + all_perlin_configs: MapConfig::AllPerlinConfigs.new(@perlin_height_config, invalid_perlin_moist_config, @perlin_temp_config) ) end end diff --git a/test/map_test.rb b/test/map_test.rb index 30f496a..ac89e05 100644 --- a/test/map_test.rb +++ b/test/map_test.rb @@ -3,6 +3,7 @@ require 'test_helper' require 'mocha/minitest' require 'map' +require 'road_generator' class MapTest < Minitest::Test def test_initialize_with_default_config @@ -18,10 +19,18 @@ def test_initialize_with_default_config def test_initialize_with_custom_config map_config = mock('MapConfig') + road_config = MapConfig::RoadConfig.new(1, 2, 3, 4, 5, [1, 2, 3, 4]) + map_config.expects(:road_config).twice.returns(road_config) generator_mock = mock('MapTileGenerator') + road_generator_mock = mock('RoadGenerator') + MapTileGenerator.expects(:new).with(anything).returns(generator_mock) generator_mock.expects(:generate).returns([['Test2']]) + RoadGenerator.expects(:new).with([['Test2']]).returns(road_generator_mock) + road_generator_mock.expects(:generate_num_of_random_roads).with(road_config) + road_generator_mock.expects(:generate_roads_from_coordinate_list).with([1, 2, 3, 4]) + map = Map.new(map_config: map_config) assert_equal map_config, map.config diff --git a/test/pathfinding/a_star_finder_test.rb b/test/pathfinding/a_star_finder_test.rb new file mode 100644 index 0000000..aa541dd --- /dev/null +++ b/test/pathfinding/a_star_finder_test.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'pathfinding/a_star_finder' +require 'pathfinding/grid' + +class TestAStarFinder < Minitest::Test + class Node + attr_reader :x, :y + + # rubocop:disable Naming/MethodParameterName: + def initialize(x, y, can_contain_road: true) + @x = x + @y = y + @can_contain_road = can_contain_road + end + # rubocop:enable Naming/MethodParameterName: + + def path_heuristic + 0 + end + + def can_contain_road? + @can_contain_road + end + + def road? + false + end + end + + def setup + @astar_finder = Pathfinding::AStarFinder.new + end + + def test_returns_empty_path_if_start_and_end_nodes_are_the_same + nodes = [ + [Node.new(0, 0), Node.new(1, 0), Node.new(2, 0)], + [Node.new(0, 1), Node.new(1, 1), Node.new(2, 1)], + [Node.new(0, 2), Node.new(1, 2), Node.new(2, 2)] + ] + grid = Pathfinding::Grid.new(nodes) + start_node = grid.node(1, 1) + end_node = grid.node(1, 1) + + path = @astar_finder.find_path(start_node, end_node, grid) + + assert_equal [start_node], path + end + + def test_finds_path_from_top_left_to_bottom_right + nodes = [ + [Node.new(0, 0), Node.new(1, 0), Node.new(2, 0)], + [Node.new(0, 1), Node.new(1, 1), Node.new(2, 1)], + [Node.new(0, 2), Node.new(1, 2), Node.new(2, 2)] + ] + grid = Pathfinding::Grid.new(nodes) + start_node = grid.node(0, 0) + end_node = grid.node(2, 2) + + path = @astar_finder.find_path(start_node, end_node, grid) + + assert_equal [start_node, grid.node(1, 0), grid.node(2, 0), grid.node(2, 1), end_node], path + end + + def test_returns_empty_path_if_no_path_is_found + nodes = [ + [Node.new(0, 0), Node.new(1, 0), Node.new(2, 0)], + [nil, nil, nil], + [Node.new(0, 2), Node.new(1, 2), Node.new(2, 2)] + ] + grid = Pathfinding::Grid.new(nodes) + start_node = grid.node(0, 0) + end_node = grid.node(2, 2) + + path = @astar_finder.find_path(start_node, end_node, grid) + + assert_empty path, 'No valid path should be found due to the obstacle.' + end + + def test_finds_path_with_obstacles + nodes = [ + [Node.new(0, 0), Node.new(1, 0), nil, Node.new(3, 0)], + [Node.new(0, 1), nil, nil, Node.new(3, 1)], + [Node.new(0, 2), Node.new(1, 2), Node.new(2, 2), Node.new(3, 2)], + [Node.new(0, 3), Node.new(1, 3), Node.new(2, 3), Node.new(3, 3)] + ] + grid = Pathfinding::Grid.new(nodes) + start_node = grid.node(0, 0) + end_node = grid.node(3, 3) + + path = @astar_finder.find_path(start_node, end_node, grid) + + expected_path = [ + start_node, grid.node(0, 1), grid.node(0, 2), + grid.node(1, 2), grid.node(2, 2), grid.node(3, 2), + grid.node(3, 3) + ] + + assert_equal expected_path, path, 'The A* pathfinder should find the correct path around obstacles.' + end + + def test_finds_path_with_non_walkable_paths + nodes = [ + [Node.new(0, 0), Node.new(1, 0), Node.new(2, 0, can_contain_road: false), Node.new(3, 0)], + [Node.new(0, 1), Node.new(1, 1, can_contain_road: false), Node.new(2, 1, can_contain_road: false), Node.new(3, 1)], + [Node.new(0, 2), Node.new(1, 2), Node.new(2, 2), Node.new(3, 2)], + [Node.new(0, 3), Node.new(1, 3), Node.new(2, 3), Node.new(3, 3)] + ] + grid = Pathfinding::Grid.new(nodes) + start_node = grid.node(0, 0) + end_node = grid.node(3, 3) + + path = @astar_finder.find_path(start_node, end_node, grid) + + expected_path = [ + start_node, grid.node(0, 1), grid.node(0, 2), + grid.node(1, 2), grid.node(2, 2), grid.node(3, 2), + grid.node(3, 3) + ] + + assert_equal expected_path, path, 'The A* pathfinder should find the correct path around obstacles.' + end +end diff --git a/test/pathfinding/grid_test.rb b/test/pathfinding/grid_test.rb new file mode 100644 index 0000000..635790d --- /dev/null +++ b/test/pathfinding/grid_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'pathfinding/grid' + +class GridTest < Minitest::Test + def setup + # Sample grid data for testing + nodes = [ + [Node.new(0, 0, false), Node.new(1, 0, true), Node.new(2, 0, true)], + [Node.new(0, 1, true), Node.new(1, 1, true), Node.new(2, 1, true)], + [Node.new(0, 2, true), Node.new(1, 2, true), Node.new(2, 2, false)] + ] + @grid = Pathfinding::Grid.new(nodes) + end + + class Node + # Replace this with your Node class implementation or use a mock/fake Node class. + attr_reader :x, :y + + # rubocop:disable Naming/MethodParameterName: + def initialize(x, y, road) + @x = x + @y = y + @road = road + end + # rubocop:enable Naming/MethodParameterName: + + def can_contain_road? + @road + end + end + + def test_node_method_returns_correct_node + node = @grid.node(1, 2) + assert_instance_of Node, node + assert_equal 1, node.x + assert_equal 2, node.y + end + + def test_neighbors_method_returns_correct_neighbors + # In this sample grid, (1, 1) node has four neighbors with roads (top, bottom, left, right) + neighbors = @grid.neighbors(@grid.node(1, 1)) + assert_equal 4, neighbors.size + + # Assuming Node objects have a `can_contain_road?` method + neighbors.each do |neighbor| + assert_equal true, neighbor.can_contain_road? + end + end + + def test_min_max_coordinates_method_returns_correct_values + min_max = @grid.min_max_coordinates + assert_equal({ min_x: 0, min_y: 0, max_x: 2, max_y: 2 }, min_max) + end +end diff --git a/test/tile_test.rb b/test/tile_test.rb index 50c6cbe..481f206 100644 --- a/test/tile_test.rb +++ b/test/tile_test.rb @@ -15,6 +15,7 @@ def setup @height = 0.5 @moist = 0.5 @temp = 0.5 + @type = :terrain @tile = Tile.new( map: @map, @@ -22,7 +23,8 @@ def setup y: @y, height: @height, moist: @moist, - temp: @temp + temp: @temp, + type: @type ) end @@ -33,6 +35,7 @@ def test_initialize_with_valid_parameters assert_equal @height, @tile.height assert_equal @moist, @tile.moist assert_equal @temp, @tile.temp + assert_equal @type, @tile.type end def test_surrounding_tiles @@ -135,9 +138,57 @@ def test_to_h moist: @moist, temp: @temp, biome: biome_hash, - items: [tile_item_hash] + items: [tile_item_hash], + type: :terrain } assert_equal expected_hash, @tile.to_h end + + def test_invalid_tile_type_raises_error + result = assert_raises ArgumentError, 'invalid tile type' do + Tile.new( + map: @map, + x: @x, + y: @y, + type: :not_real + ) + end + + assert_equal 'invalid tile type', result.to_s + end + + def test_make_road + tile = Tile.new( + map: @map, + x: @x, + y: @y + ) + assert_equal :terrain, tile.type + tile.make_road + assert_equal :road, tile.type + end + + def test_tile_path_heuristic_is_elevatione + assert_equal @tile.height, @tile.path_heuristic + end + + def test_tile_can_contain_road + tile = Tile.new( + map: @map, + x: @x, + y: @y, + height: @height, + moist: @moist, + temp: @temp, + type: @type + ) + + assert tile.can_contain_road? + + tile.biome.expects(:water?).returns(true) + @map.config.expects(:road_config).returns(OpenStruct.new(road_exclude_water_path: true)) + + refute tile.can_contain_road? + end end