matthewstyler/ruby-perlin-2D-map-generator

View on GitHub
lib/town_generator.rb

Summary

Maintainability
A
1 hr
Test Coverage
A
98%
# frozen_string_literal: true

require 'poisson_disk_sampling/sampler'
require 'poisson_disk_sampling/sample_area'
require 'road_generator'
require 'map_config'

#
# Generates building tile items using Poisson Disk Sampling for the given tiles
# Roads are generated between the buildings and between towns using A* pathfinding
# and a minimum tree spanning algorithm
#
class TownGenerator
  attr_reader :sample_area, :road_generator

  def initialize(tiles, seed: MapConfig::DEFAULT_TOWN_SEED)
    @sample_area = PoissonDiskSampling::SampleArea.new(grid: tiles)
    @road_generator = RoadGenerator.new(tiles)
    @seed = seed
    @all_town_points = []
  end

  def generate_random_towns(config)
    return if config.towns <= 0

    puts "generating #{config.towns} random towns..." if config.verbose

    @all_town_points.concat(iterate_through_towns(config.towns) do |n|
      generate_random_town(n, config.verbose)
    end)

    generate_roads_between_towns(config.verbose)
  end

  def generate_towns_from_coordinate_list(config)
    return unless (config.towns_to_make.length % 4).zero?

    puts "generating #{config.towns_to_make.length / 4} coordinate towns..." if config.verbose

    @all_town_points.concat(iterate_through_towns((config.towns_to_make.length / 4)) do |n|
      town_values = config.towns_to_make[(n - 1) * 4..].take(4)
      generate_town(n, town_values[2], town_values[3], [sample_area[town_values[0], town_values[1]]], config.verbose)
    end)

    generate_roads_between_towns(config.verbose)
  end

  private

  def iterate_through_towns(num_of_towns)
    (1..num_of_towns).map do |n|
      town_points = yield n
      @seed += 1000
      town_points
    end
  end

  def generate_random_town(town_num, verbose)
    random_town_gen = Random.new(@seed)
    generate_town(town_num, random_town_gen.rand(10..40), random_town_gen.rand(2..4), nil, verbose)
  end

  def generate_town(town_num, num_of_points, radius, initial_coords, verbose)
    puts "generating town #{town_num}..." if verbose

    points = generate_points_for_town(num_of_points, radius, initial_coords)
    generate_town_roads(points, town_num, verbose)
    points
  end

  def generate_points_for_town(num_of_points, radius, intial_coordinates)
    points =
      PoissonDiskSampling::Sampler.new(
        sample_area: sample_area,
        seed: @seed
      ).generate_points(num_of_points, radius, intial_coordinates)
    points.each do |point|
      @seed += 1
      point.add_town_item(@seed)
    end
    points
  end

  def generate_town_roads(points, town_num, verbose)
    puts "generating town #{town_num} roads..." if verbose

    generate_roads_from_connected_pairs(build_minimum_spanning_tree(points, populate_distances_between_each_point(points)))
  end

  def generate_roads_from_connected_pairs(connected_pairs)
    connected_pairs.each do |edge|
      road_to_building_one = place_in_front_or_behind(edge.first)
      road_to_building_two = place_in_front_or_behind(edge.last)

      next if road_to_building_one.nil? || road_to_building_two.nil?

      road_generator.generate_roads_from_coordinate_list(road_to_building_one.concat(road_to_building_two), false)
    end
  end

  def build_minimum_spanning_tree(points, distances)
    connected_pairs = Set.new
    visited = Set.new([points.first]) # Create a set to keep track of visited nodes
    until visited.size == points.size
      edge = find_minimum_edge(distances, visited)
      connected_pairs.add(edge)
      visited.add(edge.last)
    end
    connected_pairs
  end

  def populate_distances_between_each_point(points)
    distances = {}
    points.each_with_index do |point_one, idx_one|
      points[idx_one + 1..].each do |point_two|
        distance = calculate_distance(point_one, point_two)
        distances[[point_one, point_two]] = distance
        distances[[point_two, point_one]] = distance
      end
    end
    distances
  end

  def calculate_distance(point1, point2)
    Math.sqrt((point1.y - point2.y)**2 + (point1.x - point2.x)**2)
  end

  def find_minimum_edge(distances, visited)
    # method to find the minimum edge connecting visited and unvisited nodes
    min_edge = nil
    min_distance = Float::INFINITY

    visited.each do |visited_node|
      distances.each do |edge, distance|
        next if visited.include?(edge.last) || edge.first != visited_node

        if distance < min_distance
          min_distance = distance
          min_edge = edge
        end
      end
    end

    min_edge
  end

  def place_in_front_or_behind(point)
    return [point.x, point.y - 1] if sample_area.point_within_bounds_and_can_have_road?(point.x, point.y - 1)
    return [point.x, point.y + 1] if sample_area.point_within_bounds_and_can_have_road?(point.x, point.y + 1)
    return [point.x - 1, point.y] if sample_area.point_within_bounds_and_can_have_road?(point.x - 1, point.y)
    return [point.x + 1, point.y] if sample_area.point_within_bounds_and_can_have_road?(point.x + 1, point.y)

    nil
  end

  def generate_roads_between_towns(verbose)
    return if @all_town_points.length < 2

    puts 'generating roads between towns...' if verbose

    centroids = @all_town_points.map { |town_points| find_town_centroid(town_points) }
    generate_roads_from_connected_pairs(build_minimum_spanning_tree(centroids, populate_distances_between_each_point(centroids)))
  end

  def find_town_centroid(points)
    total_x = 0
    total_y = 0
    num_coordinates = points.length

    points.each do |point|
      total_x += point.x
      total_y += point.y
    end

    average_x = total_x / num_coordinates.to_f
    average_y = total_y / num_coordinates.to_f

    OpenStruct.new(x: average_x, y: average_y)
  end
end