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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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