ruby-perlin-2D-map-generator 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ class MapTileGenerator
15
15
  end
16
16
 
17
17
  def generate
18
+ puts "generating #{map_config.width} x #{map_config.height} tiles..." if map_config.verbose
18
19
  positive_quadrant_cartesian_plane
19
20
  end
20
21
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'pathfinding/priority_queue'
5
+
6
+ module Pathfinding
7
+ #
8
+ # An A* Pathfinder to build roads/paths between two coordinates containing
9
+ # different path costs, the heuristic behaviour that can be altered via configuration
10
+ #
11
+ class AStarFinder
12
+ def find_path(start_node, end_node, grid)
13
+ came_from = {}
14
+ g_score = { start_node => 0 }
15
+ f_score = { start_node => manhattan_distance(start_node, end_node) }
16
+
17
+ open_set = Pathfinding::PriorityQueue.new
18
+ open_set.push(start_node, f_score[start_node])
19
+
20
+ closed_set = Set.new
21
+ until open_set.empty?
22
+ current = open_set.pop
23
+
24
+ # Early exit if the current node is in the closed set
25
+ next if closed_set.include?(current)
26
+
27
+ # Mark the current node as visited
28
+ closed_set.add(current)
29
+
30
+ return reconstruct_path(came_from, current) if current == end_node
31
+
32
+ grid.neighbors(current).each do |neighbor|
33
+ tentative_g_score = g_score[current] + 1
34
+
35
+ next if closed_set.include?(neighbor) || (g_score[neighbor] && tentative_g_score >= g_score[neighbor])
36
+
37
+ came_from[neighbor] = current
38
+ g_score[neighbor] = tentative_g_score
39
+ f_score[neighbor] = g_score[neighbor] + heuristic_cost_estimate(neighbor, end_node)
40
+
41
+ open_set.push(neighbor, f_score[neighbor])
42
+ end
43
+ end
44
+
45
+ # No path found
46
+ []
47
+ end
48
+
49
+ private
50
+
51
+ def heuristic_cost_estimate(node, end_node)
52
+ manhattan_distance(node, end_node) +
53
+ (node.path_heuristic - end_node.path_heuristic) + # elevation for natural roads
54
+ (node.road? ? 0 : 1000) # share existing roads
55
+ end
56
+
57
+ def manhattan_distance(node, end_node)
58
+ (node.x - end_node.x).abs + (node.y - end_node.y).abs
59
+ end
60
+
61
+ def reconstruct_path(came_from, current_node)
62
+ path = [current_node]
63
+ while came_from[current_node]
64
+ current_node = came_from[current_node]
65
+ path.unshift(current_node)
66
+ end
67
+ path
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pathfinding
4
+ #
5
+ # Responsible for manipulating and encapsulating behaviour of tiles related
6
+ # to pathfinding
7
+ #
8
+ class Grid
9
+ attr_reader :nodes
10
+
11
+ def initialize(nodes)
12
+ @nodes = nodes
13
+ end
14
+
15
+ # rubocop:disable Naming/MethodParameterName:
16
+ def node(x, y)
17
+ nodes[y][x]
18
+ end
19
+ # rubocop:enable Naming/MethodParameterName:
20
+
21
+ def neighbors(node)
22
+ neighbors = []
23
+ return neighbors unless node.can_haz_road?
24
+
25
+ x = node.x
26
+ y = node.y
27
+
28
+ node_lookup = node(x - 1, y) if x.positive?
29
+ neighbors << node_lookup if !node_lookup.nil? && node_lookup.can_haz_road?
30
+ node_lookup = node(x + 1, y) if x < @nodes[0].size - 1
31
+ neighbors << node_lookup if !node_lookup.nil? && node_lookup.can_haz_road?
32
+ node_lookup = node(x, y - 1) if y.positive?
33
+ neighbors << node_lookup if !node_lookup.nil? && node_lookup.can_haz_road?
34
+ node_lookup = node(x, y + 1) if y < @nodes.size - 1
35
+ neighbors << node_lookup if !node_lookup.nil? && node_lookup.can_haz_road?
36
+
37
+ neighbors
38
+ end
39
+
40
+ def min_max_coordinates
41
+ @min_max_coordinates ||= begin
42
+ min_x = nil
43
+ min_y = nil
44
+ max_x = nil
45
+ max_y = nil
46
+
47
+ @nodes.each do |row|
48
+ row.each do |object|
49
+ x = object.x
50
+ y = object.y
51
+
52
+ # Update minimum x and y values
53
+ min_x = x if min_x.nil? || x < min_x
54
+ min_y = y if min_y.nil? || y < min_y
55
+
56
+ # Update maximum x and y values
57
+ max_x = x if max_x.nil? || x > max_x
58
+ max_y = y if max_y.nil? || y > max_y
59
+ end
60
+ end
61
+
62
+ { min_x: min_x, min_y: min_y, max_x: max_x, max_y: max_y }
63
+ end
64
+ end
65
+
66
+ def edge_nodes
67
+ @edge_nodes ||=
68
+ @nodes.map do |row|
69
+ row.select do |obj|
70
+ obj.x == min_max_coordinates[:min_x] ||
71
+ obj.x == min_max_coordinates[:max_x] ||
72
+ obj.y == min_max_coordinates[:min_y] ||
73
+ obj.y == min_max_coordinates[:max_y]
74
+ end
75
+ end.flatten
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pathfinding
4
+ #
5
+ # A Priority Queue implementation for managing elements with associated priorities.
6
+ # Elements are stored and retrieved based on their priority values.
7
+ #
8
+ # This Priority Queue is implemented using a binary heap, which provides efficient
9
+ # insertion, extraction of the minimum element, and deletion operations.
10
+ #
11
+ class PriorityQueue
12
+ def initialize(&block)
13
+ @heap = []
14
+ @compare = block || proc { |a, b| @priority_hash[a] < @priority_hash[b] }
15
+ @priority_hash = {}
16
+ end
17
+
18
+ def push(item, priority)
19
+ @heap << item
20
+ @priority_hash[item] = priority
21
+ heapify_up(@heap.length - 1)
22
+ end
23
+
24
+ def pop
25
+ return nil if @heap.empty?
26
+
27
+ swap(0, @heap.length - 1)
28
+ popped = @heap.pop
29
+ heapify_down(0)
30
+ popped
31
+ end
32
+
33
+ def peek
34
+ @heap[0]
35
+ end
36
+
37
+ def empty?
38
+ @heap.empty?
39
+ end
40
+
41
+ private
42
+
43
+ def heapify_up(index)
44
+ parent_index = (index - 1) / 2
45
+
46
+ return unless index.positive? && @compare.call(@heap[index], @heap[parent_index])
47
+
48
+ swap(index, parent_index)
49
+ heapify_up(parent_index)
50
+ end
51
+
52
+ def heapify_down(index)
53
+ left_child_index = 2 * index + 1
54
+ right_child_index = 2 * index + 2
55
+ smallest = index
56
+
57
+ smallest = left_child_index if left_child_index < @heap.length && @compare.call(@heap[left_child_index], @heap[smallest])
58
+
59
+ smallest = right_child_index if right_child_index < @heap.length && @compare.call(@heap[right_child_index], @heap[smallest])
60
+
61
+ return unless smallest != index
62
+
63
+ swap(index, smallest)
64
+ heapify_down(smallest)
65
+ end
66
+
67
+ # rubocop:disable Naming/MethodParameterName:
68
+ def swap(i, j)
69
+ @heap[i], @heap[j] = @heap[j], @heap[i]
70
+ end
71
+ # rubocop:enable Naming/MethodParameterName:
72
+ end
73
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoissonDiskSampling
4
+ #
5
+ # Encapsulates area to be sampled by poisson disk sampling
6
+ #
7
+ class SampleArea
8
+ def initialize(grid:)
9
+ @grid = grid.dup
10
+ @points = Array.new(height) { Array.new(width) { false } }
11
+ end
12
+
13
+ def height
14
+ @grid.length
15
+ end
16
+
17
+ def width
18
+ @grid[0].length
19
+ end
20
+
21
+ # rubocop:disable Naming/MethodParameterName:
22
+ def set_sampled_point(x, y)
23
+ @points[y][x] = true
24
+ end
25
+
26
+ def [](x, y)
27
+ @grid[y][x]
28
+ end
29
+
30
+ def sampled_point?(x, y)
31
+ @points[y][x]
32
+ end
33
+
34
+ def point_within_bounds?(x, y)
35
+ y >= 0 && y < height && x >= 0 && x < width
36
+ end
37
+
38
+ def point_within_bounds_and_can_have_road?(x, y)
39
+ point_within_bounds?(x, y) && @grid[y][x].can_haz_road?
40
+ end
41
+ # rubocop:enable Naming/MethodParameterName:
42
+ end
43
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pry-byebug'
4
+ module PoissonDiskSampling
5
+ #
6
+ # Generates X randomly distributed points within given 2D space
7
+ # using possion disk sampling
8
+ #
9
+ class Sampler
10
+ attr_reader :sample_area, :num_attempts, :seed
11
+
12
+ def initialize(sample_area:, num_attempts: 20, seed: rand)
13
+ @sample_area = sample_area
14
+ @num_attempts = num_attempts
15
+ @seed = seed
16
+ end
17
+
18
+ def generate_points(num_of_points, radius, intial_start_point = nil)
19
+ raise ArgumentError, "invalid start argument #{intial_start_point}" if !intial_start_point.nil? && !intial_start_point.is_a?(Array) && intial_start_point.length != 1
20
+
21
+ retreive_points_until_active_list_empty_or_num_points_reached(intial_start_point || generate_and_assign_initial_point, num_of_points, radius)
22
+ end
23
+
24
+ private
25
+
26
+ def retreive_points_until_active_list_empty_or_num_points_reached(active_list, num_of_points, radius)
27
+ points = []
28
+ retrieve_points(active_list, points, num_of_points, radius) until active_list.empty? || points.length == num_of_points
29
+ points
30
+ end
31
+
32
+ def retrieve_points(active_list, points, num_of_points, radius)
33
+ return if active_list.empty?
34
+
35
+ current_point, active_index = retreive_current_point(active_list)
36
+ found = false
37
+
38
+ num_attempts.times do
39
+ new_point = generate_random_point_around(current_point, radius)
40
+ next if new_point.nil?
41
+ next unless new_point.can_haz_town? && neighbours_empty?(new_point, radius)
42
+
43
+ sample_area.set_sampled_point(new_point.x, new_point.y)
44
+ active_list << new_point
45
+ points << new_point
46
+ return points if points.length == num_of_points
47
+
48
+ found = true
49
+ break
50
+ end
51
+
52
+ active_list.delete_at(active_index) unless found
53
+ end
54
+
55
+ def random_value_and_increment_seed(max)
56
+ val = Random.new(seed).rand(max)
57
+ @seed += 1
58
+ val
59
+ end
60
+
61
+ def retreive_current_point(active_list)
62
+ active_index = random_value_and_increment_seed(active_list.length)
63
+ current_point = active_list[active_index]
64
+ [current_point, active_index]
65
+ end
66
+
67
+ def neighbours_empty?(new_point, radius)
68
+ cell_empty = true
69
+ (-radius..radius).each do |dy|
70
+ (-radius..radius).each do |dx|
71
+ x = new_point.x + dx
72
+ y = new_point.y + dy
73
+ next unless sample_area.point_within_bounds?(x, y) && sample_area[x, y]
74
+
75
+ if sample_area.sampled_point?(x, y) && distance(new_point, sample_area[x, y]) < radius
76
+ cell_empty = false
77
+ break
78
+ end
79
+ end
80
+ break unless cell_empty
81
+ end
82
+ end
83
+
84
+ def generate_and_assign_initial_point
85
+ num_attempts.times do
86
+ initial_point_coords = [random_value_and_increment_seed(sample_area.width), random_value_and_increment_seed(sample_area.height)]
87
+
88
+ if sample_area[initial_point_coords[0], initial_point_coords[1]].can_haz_town?
89
+ sample_area.set_sampled_point(initial_point_coords[0], initial_point_coords[1])
90
+ return [sample_area[initial_point_coords[0], initial_point_coords[1]]]
91
+ end
92
+ end
93
+ []
94
+ end
95
+
96
+ def generate_random_point_around(point, radius)
97
+ distance = radius * (random_value_and_increment_seed(1.0) + 1)
98
+ angle = 2 * Math::PI * random_value_and_increment_seed(1.0)
99
+ generated = [point.y + distance * Math.cos(angle), point.x + distance * Math.sin(angle)].map(&:round)
100
+
101
+ sample_area.point_within_bounds?(generated[1], generated[0]) ? sample_area[generated[1], generated[0]] : nil
102
+ end
103
+
104
+ def distance(point1, point2)
105
+ Math.sqrt((point1.y - point2.y)**2 + (point1.x - point2.x)**2)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathfinding/grid'
4
+ require 'pathfinding/a_star_finder'
5
+
6
+ #
7
+ # Generates roads across map tiles, randomly or given specific coordinates
8
+ #
9
+ class RoadGenerator
10
+ attr_reader :grid, :finder
11
+
12
+ def initialize(tiles)
13
+ @grid = Pathfinding::Grid.new(tiles)
14
+ @finder = Pathfinding::AStarFinder.new
15
+ end
16
+
17
+ def generate_num_of_random_roads(config)
18
+ return if config.roads <= 0
19
+
20
+ puts "generating #{config.roads} random roads..." if config.verbose
21
+
22
+ seed = config.road_seed
23
+ (1..config.roads).each do |n|
24
+ puts "generating road #{n}..." if config.verbose
25
+ random_objects_at_edges = random_nodes_not_on_same_edge(seed + n) # add n otherwise each road is the same
26
+ generate_path(
27
+ random_objects_at_edges[0].x,
28
+ random_objects_at_edges[0].y,
29
+ random_objects_at_edges[1].x,
30
+ random_objects_at_edges[1].y
31
+ ).each(&:make_road)
32
+ end
33
+ end
34
+
35
+ def generate_roads_from_coordinate_list(road_paths, verbose)
36
+ return unless (road_paths.length % 4).zero?
37
+
38
+ puts "generating #{road_paths.length / 4} coordinate roads..." if verbose
39
+
40
+ road_paths.each_slice(4) do |road_coordinates|
41
+ generate_path(
42
+ road_coordinates[0],
43
+ road_coordinates[1],
44
+ road_coordinates[2],
45
+ road_coordinates[3]
46
+ ).each(&:make_road)
47
+ end
48
+ end
49
+
50
+ def generate_path(start_x, start_y, end_x, end_y)
51
+ start_node = grid.node(start_x, start_y)
52
+ end_node = grid.node(end_x, end_y)
53
+ finder.find_path(start_node, end_node, grid)
54
+ end
55
+
56
+ private
57
+
58
+ def random_nodes_not_on_same_edge(seed)
59
+ random_generator = Random.new(seed)
60
+ length = @grid.edge_nodes.length
61
+
62
+ loop do
63
+ index1 = random_generator.rand(length)
64
+ index2 = random_generator.rand(length)
65
+ node_one, node_two = @grid.edge_nodes.values_at(index1, index2)
66
+
67
+ return [node_one, node_two] if node_one.x != node_two.x && node_one.y != node_two.y
68
+ end
69
+ end
70
+ end
data/lib/tile.rb CHANGED
@@ -2,17 +2,28 @@
2
2
 
3
3
  require 'biome'
4
4
  require 'flora'
5
+ require 'ansi_colours'
6
+ require 'pry-byebug'
7
+ require 'building'
5
8
 
6
9
  class Tile
7
- attr_reader :x, :y, :height, :moist, :temp, :map
10
+ attr_reader :x, :y, :height, :moist, :temp, :map, :type
8
11
 
9
- def initialize(map:, x:, y:, height: 0, moist: 0, temp: 0)
12
+ TYPES = %i[
13
+ terrain
14
+ road
15
+ ].freeze
16
+
17
+ def initialize(map:, x:, y:, height: 0, moist: 0, temp: 0, type: :terrain)
10
18
  @x = x
11
19
  @y = y
12
20
  @height = height
13
21
  @moist = moist
14
22
  @temp = temp
15
23
  @map = map
24
+ raise ArgumentError, 'invalid tile type' unless TYPES.include?(type)
25
+
26
+ @type = type
16
27
  end
17
28
 
18
29
  def surrounding_tiles(distance = 1)
@@ -32,11 +43,11 @@ class Tile
32
43
  end
33
44
 
34
45
  def items
35
- @items ||= items_generated_with_flora_if_applicable
46
+ @items ||= []
36
47
  end
37
48
 
38
49
  def render_to_standard_output
39
- print biome.colour + (!items.empty? ? item_with_highest_priority.render_symbol : ' ')
50
+ print render_color_by_type + (!items.empty? ? item_with_highest_priority.render_symbol : ' ')
40
51
  print AnsiColours::Background::ANSI_RESET
41
52
  end
42
53
 
@@ -50,6 +61,18 @@ class Tile
50
61
  items.max_by(&:render_priority)
51
62
  end
52
63
 
64
+ def items_contain_flora?
65
+ items_contain?(Flora)
66
+ end
67
+
68
+ def items_contain_building?
69
+ items_contain?(Building)
70
+ end
71
+
72
+ def items_contain?(item_class)
73
+ items.any? { |i| i.is_a?(item_class) }
74
+ end
75
+
53
76
  def to_h
54
77
  {
55
78
  x: x,
@@ -58,24 +81,65 @@ class Tile
58
81
  moist: moist,
59
82
  temp: temp,
60
83
  biome: biome.to_h,
61
- items: items.map(&:to_h)
84
+ items: items.map(&:to_h),
85
+ type: type
62
86
  }
63
87
  end
64
88
 
89
+ def add_town_item(seed)
90
+ add_item(Building.random_town_building(seed))
91
+ end
92
+
93
+ def make_road
94
+ @type = :road
95
+ end
96
+
97
+ def road?
98
+ @type == :road
99
+ end
100
+
101
+ def path_heuristic
102
+ height
103
+ end
104
+
105
+ def can_haz_town?
106
+ !road? && !biome.water? && !biome.high_mountain? && !items_contain_flora?
107
+ end
108
+
109
+ def can_haz_road?
110
+ true unless biome_is_water_and_is_excluded? || biome_is_high_mountain_and_is_excluded? || tile_contains_flora_and_is_excluded? || items_contain_building?
111
+ end
112
+
113
+ def add_flora
114
+ add_item(biome.flora)
115
+ end
116
+
65
117
  private
66
118
 
67
- def items_generated_with_flora_if_applicable
68
- if map.config.generate_flora && biome.flora_available
69
- range_max_value = map.tiles[(y - biome.flora_range)...(y + biome.flora_range)]&.map do |r|
70
- r[(x - biome.flora_range)...(x + biome.flora_range)]
71
- end&.flatten&.map(&:height)&.max
72
- if range_max_value == height
73
- [biome.flora]
74
- else
75
- []
119
+ def biome_is_water_and_is_excluded?
120
+ biome.water? && map.config.road_config.road_exclude_water_path
121
+ end
122
+
123
+ def biome_is_high_mountain_and_is_excluded?
124
+ biome.high_mountain? && map.config.road_config.road_exclude_mountain_path
125
+ end
126
+
127
+ def tile_contains_flora_and_is_excluded?
128
+ items_contain_flora? && map.config.road_config.road_exclude_flora_path
129
+ end
130
+
131
+ def render_color_by_type
132
+ case type
133
+ when :terrain then biome.colour
134
+ when :road
135
+ case height
136
+ when 0.66..1
137
+ AnsiColours::Background::HIGH_ROAD_BLACK
138
+ when 0.33..0.66
139
+ AnsiColours::Background::ROAD_BLACK
140
+ when 0..0.33
141
+ AnsiColours::Background::LOW_ROAD_BLACK
76
142
  end
77
- else
78
- []
79
143
  end
80
144
  end
81
145
  end