From 75a03a7155f145db6adfef45c4fba491f7918fd4 Mon Sep 17 00:00:00 2001 From: Cyberarm Date: Thu, 24 Oct 2019 13:30:30 -0500 Subject: [PATCH] Roughed in some basic pathfinding, adapted from CitySim's --- i-mic-rts.rb | 1 + lib/director.rb | 9 ++ lib/entities/units/harvester.rb | 8 +- lib/entity.rb | 14 +++ lib/map.rb | 61 ++++++---- lib/pathfinder.rb | 8 ++ lib/pathfinding/base_pathfinder.rb | 189 +++++++++++++++++++++++++++++ lib/states/game.rb | 2 +- 8 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 lib/pathfinder.rb create mode 100644 lib/pathfinding/base_pathfinder.rb diff --git a/i-mic-rts.rb b/i-mic-rts.rb index 80f2f07..68af85d 100755 --- a/i-mic-rts.rb +++ b/i-mic-rts.rb @@ -28,6 +28,7 @@ require_relative "lib/zorder" require_relative "lib/entity" require_relative "lib/map" require_relative "lib/tiled_map" +require_relative "lib/pathfinder" require_relative "lib/order" require_relative "lib/friendly_hash" diff --git a/lib/director.rb b/lib/director.rb index 118f6fe..f6ed32d 100644 --- a/lib/director.rb +++ b/lib/director.rb @@ -57,6 +57,15 @@ class IMICRTS @players.find { |player| player.id == id } end + def find_path(player:, entity:, goal:, travels_along: :ground, allow_diagonal: false, klass: IMICRTS::Pathfinder::BasePathfinder) + if klass.cached_path(entity, goal, travels_along) + puts "using a cached path!" if true#Setting.enabled?(:debug_mode) + return klass.cached_path(entity, goal, travels_along) + end + + klass.new(director: self, entity: entity, goal: goal, travels_along: travels_along, allow_diagonal: allow_diagonal) + end + def record_order(order_id, *args) if order = Order.get(order_id) struct = order.struct(args) diff --git a/lib/entities/units/harvester.rb b/lib/entities/units/harvester.rb index fbe8b4d..9250d10 100644 --- a/lib/entities/units/harvester.rb +++ b/lib/entities/units/harvester.rb @@ -18,11 +18,11 @@ IMICRTS::Entity.define_entity(:harvester, :unit, 1400, "Harvests ore") do |entit end entity.define_singleton_method(:seek_ore) do - ore = director.map.ores.compact.sort_by { |ore| next unless ore; ore.position.distance(entity.position) }.first + # ore = director.map.ores.compact.sort_by { |ore| next unless ore; ore.position.distance(entity.position) }.first - n = (ore.position - entity.position).normalized - n.z = 0 - entity.position += n * 3 + # n = (ore.position - entity.position).normalized + # n.z = 0 + # entity.position += n * 3 end entity.define_singleton_method(:seek_refinery) do diff --git a/lib/entity.rb b/lib/entity.rb index 56b66bb..9040529 100644 --- a/lib/entity.rb +++ b/lib/entity.rb @@ -24,6 +24,7 @@ class IMICRTS @id = id @position = position @angle = angle + @director = director @radius = 32 / 2 @target = nil @@ -66,6 +67,7 @@ class IMICRTS def target=(entity) @target = entity + @path = @director.find_path(player: @player, entity: self, goal: @target) end def hit?(x_or_vector, y = nil) @@ -92,6 +94,9 @@ class IMICRTS def update rotate_towards(@target) if @target && @movement + + if @movement + end end def tick(tick_id) @@ -114,6 +119,15 @@ class IMICRTS def draw_gizmos Gosu.draw_rect(@position.x - @radius, @position.y - (@radius + 2), @radius * 2, 2, Gosu::Color::GREEN, ZOrder::ENTITY_GIZMOS) + if @path + @path.path.each_with_index do |node, i| + next_node = @path.path.dig(i + 1) + if next_node + Gosu.draw_line(node.tile.position.x, node.tile.position.y, Gosu::Color::RED, next_node.tile.position.x, next_node.tile.position.y, Gosu::Color::RED, ZOrder::ENTITY_GIZMOS) + end + end + end + if @target.is_a?(IMICRTS::Entity) Gosu.draw_line(@position.x, @position.y, @target_color, @target.position.x, @target.position.y, @target_color, ZOrder::ENTITY_GIZMOS) if @target else diff --git a/lib/map.rb b/lib/map.rb index 5c0197d..f6ee58d 100644 --- a/lib/map.rb +++ b/lib/map.rb @@ -1,7 +1,5 @@ class IMICRTS class Map - Tile = Struct.new(:position, :color, :image, :state, :type) - attr_reader :tile_size, :tiles, :ores def initialize(map_file:) @tiled_map = TiledMap.new(map_file) @@ -9,8 +7,8 @@ class IMICRTS @width, @height = @tiled_map.width, @tiled_map.height @tile_size = @tiled_map.tile_size - @tiles = [] - @ores = [] + @tiles = {} + @ores = {} @tiled_map.layers.each do |layer| layer.height.times do |y| @@ -27,13 +25,16 @@ class IMICRTS def add_terrain(x, y, tile_id) if tile = @tiled_map.get_tile(tile_id - 1) - @tiles << Tile.new( - CyberarmEngine::Vector.new(x * @tile_size, y * @tile_size, ZOrder::TILE), - nil, - tile.image, - :yes, - tile.data.type + _tile = Tile.new( + position: CyberarmEngine::Vector.new(x * @tile_size, y * @tile_size, ZOrder::TILE), + image: tile.image, + visible: true, + type: tile.data.type.to_sym, + tile_size: @tile_size ) + + @tiles[x] ||= {} + @tiles[x][y] = _tile else raise "No such tile!" end @@ -41,15 +42,16 @@ class IMICRTS def add_ore(x, y, tile_id) if tile = @tiled_map.get_tile(tile_id - 1) - @ores << Tile.new( - CyberarmEngine::Vector.new(x * @tile_size, y * @tile_size, ZOrder::ORE), - nil, - tile.image, - :yes, - nil + _ore = Tile.new( + position: CyberarmEngine::Vector.new(x * @tile_size, y * @tile_size, ZOrder::ORE), + image: tile.image, + visible: true, + type: nil, + tile_size: @tile_size ) - else - @ores << nil + + @ores[x] ||= {} + @ores[x][y] = _ore end end @@ -80,11 +82,11 @@ class IMICRTS next if _y < 0 || _y > @height if tile = tile_at(_x, _y) - _tiles.push(tile) if tile.state != :unexplored + _tiles.push(tile) if tile.visible end if ore = ore_at(_x, _y) - _tiles.push(ore) if ore.state != :unexplored + _tiles.push(ore) if ore.visible end end end @@ -93,11 +95,26 @@ class IMICRTS end def tile_at(x, y) - @tiles[x + y * @width] + @tiles.dig(x, y) end def ore_at(x, y) - @ores[x + y * @width] + @ores.dig(x, y) + end + + class Tile + attr_accessor :position, :grid_position, :image, :visible, :entity, :type + def initialize(position:, image:, visible:, type:, tile_size:) + @position = position + @grid_position = position.clone + @grid_position /= tile_size + @grid_position.x, @grid_position.y = @grid_position.x.floor, @grid_position.y.floor + + @image = image + @visible = visible + @entity = nil + @type = type + end end end end \ No newline at end of file diff --git a/lib/pathfinder.rb b/lib/pathfinder.rb new file mode 100644 index 0000000..73e93ad --- /dev/null +++ b/lib/pathfinder.rb @@ -0,0 +1,8 @@ +class IMICRTS + class Pathfinder + end +end + +Dir.glob("#{IMICRTS::GAME_ROOT_PATH}/lib/pathfinding/*.rb").each do |pathing_method| + require_relative pathing_method +end \ No newline at end of file diff --git a/lib/pathfinding/base_pathfinder.rb b/lib/pathfinding/base_pathfinder.rb new file mode 100644 index 0000000..e93531f --- /dev/null +++ b/lib/pathfinding/base_pathfinder.rb @@ -0,0 +1,189 @@ +class IMICRTS + class Pathfinder + class BasePathfinder + Node = Struct.new(:tile, :parent, :distance, :cost) + CACHE = {} + + def self.cached_path(source, goal, travels_along) + found_path = CACHE.dig(travels_along, source, goal) + if found_path + found_path = nil unless found_path.valid? + end + + return found_path + end + + def self.cache_path(path) + CACHE[path.travels_along] ||= {} + CACHE[path.travels_along][path.source] ||= {} + CACHE[path.travels_along][path.source][path.goal] = path + + return path + end + + attr_reader :map, :source, :goal, :travels_along, :allow_diagonal + attr_reader :path, :age + def initialize(director:, entity:, goal:, travels_along: :ground, allow_diagonal: false) + @director = director + @map = @director.map + @entity = entity + _goal = goal.clone + _goal /= @map.tile_size + _goal.x, _goal.y = _goal.x.floor, _goal.y.floor + @goal = _goal + + @travels_along = travels_along + @allow_diagonal = allow_diagonal + @age = Gosu.milliseconds + + @created_nodes = 0 + @nodes = [] + @path = [] + @tiles = @map.tiles.values.map { |columns| columns.values }.flatten.select { |tile| tile } + + @visited = Hash.new do |hash, value| + hash[value] = Hash.new {|h, v| h[v] = false} + end + + @depth = 0 + @max_depth = @tiles.size + @seeking = true + + position = entity.position.clone + position.x, position.y = position.x.floor, position.y.floor + position /= @map.tile_size + + @current_node = create_node(position.x, position.y) + unless @current_node + puts "Failed to find path!" if true + return + end + + @current_node.distance = 0 + @current_node.cost = 0 + add_node @current_node + + find + + Pathfinder.cache_path(self) if @path.size > 0 && false#Setting.enabled?(:cache_paths) + end + + # Checks if Map still has all of paths required tiles + def valid? + valid = true + @path.each do |node| + unless @map.tiles.dig(node.tile.grid_position.x, node.tile.grid_position.y).entity == node.tile.entity + valid = false + break + end + end + + return valid + end + + def find + while(@seeking && @depth < @max_depth) + seek + end + + if @depth >= @max_depth + puts "Failed to find path from: #{@source.x}:#{@source.y} (#{@map.grid.dig(@source.x,@source.y).element.class}) to: #{@goal.position.x}:#{@goal.position.y} (#{@goal.element.class}) [#{@depth}/#{@max_depth} depth]" if true#Setting.enabled?(:debug_mode) + end + end + + def at_goal? + @current_node.tile.grid_position.distance(@goal) < 1.1 + end + + def seek + unless @current_node && @map.tiles.dig(@goal.x, @goal.y) + @seeking = false + return + end + + @nodes.delete(@current_node) # delete visited nodes + @visited[@current_node.tile.grid_position.x][@current_node.tile.grid_position.y] = true + + if at_goal? + until(@current_node.parent.nil?) + @path << @current_node + @current_node = @current_node.parent + end + @path.reverse! + + @seeking = false + puts "Generated path with #{@path.size} steps, #{@created_nodes} nodes created. [#{@depth}/#{@max_depth} depth]" if true#Setting.enabled?(:debug_mode) + return + end + + #LEFT + add_node create_node(@current_node.tile.grid_position.x - 1, @current_node.tile.grid_position.y, @current_node) + # RIGHT + add_node create_node(@current_node.tile.grid_position.x + 1, @current_node.tile.grid_position.y, @current_node) + # UP + add_node create_node(@current_node.tile.grid_position.x, @current_node.tile.grid_position.y - 1, @current_node) + # DOWN + add_node create_node(@current_node.tile.grid_position.x, @current_node.tile.grid_position.y + 1, @current_node) + + # TODO: Add diagonal nodes, if requested + if @allow_diagonal + end + + @current_node = next_node + @depth += 1 + end + + def node_visited?(node) + @visited[node.tile.grid_position.x][node.tile.grid_position.y] + end + + def add_node(node) + return unless node + + @nodes << node + return node + end + + def create_node(x, y, parent = nil) + return unless tile = @map.tiles.dig(x, y) + return unless tile.type == @travels_along + return if tile.entity + return if @visited.dig(x, y) + return if @nodes.detect {|node| node.tile.grid_position.x == x && node.tile.grid_position.y == y} + + node = Node.new + node.tile = tile + node.parent = parent + node.distance = parent.distance + 1 if parent + node.cost = 0 + + @created_nodes += 1 + return node + end + + def next_node + fittest = nil + fittest_distance = Float::INFINITY + + distance = nil + @nodes.each do |node| + next if node == @current_node + + distance = node.tile.grid_position.distance(@goal) + + if distance < fittest_distance + if fittest && (node.distance + node.cost) < (fittest.distance + fittest.cost) + fittest = node + fittest_distance = distance + else + fittest = node + fittest_distance = distance + end + end + end + + return fittest + end + end + end +end \ No newline at end of file diff --git a/lib/states/game.rb b/lib/states/game.rb index 3cc0621..bd6a5c3 100644 --- a/lib/states/game.rb +++ b/lib/states/game.rb @@ -116,7 +116,7 @@ class IMICRTS World Mouse Y: #{mouse.y} Director Tick: #{@director.current_tick} - Tile: X: #{tile.position.x} Y: #{tile.position.y} Type: #{tile.type} + #{ tile ? "Tile: X: #{tile.position.x} Y: #{tile.position.y} Type: #{tile.type}" : ""} ).lines.map { |line| line.strip }.join("\n") @debug_info.x = @sidebar.width + 20