Class: Sc2::Player::Geo
- Inherits:
-
Object
- Object
- Sc2::Player::Geo
- Defined in:
- lib/sc2ai/player/geo.rb
Overview
Holds map and geography helper functions
Instance Attribute Summary collapse
-
#bot ⇒ Sc2::Player
Player with active connection.
Instance Method Summary collapse
-
#build_coordinates(length:, on_creep: false, in_power: false) ⇒ Array<Array<(Float, Float)>>
Gets buildable point grid for squares of size, i.e.
-
#build_placement_near(length:, target:, random: 1, in_power: false) ⇒ Api::Point2D?
Gets a buildable location for a square of length, near target.
-
#clamp_to_grid(position) ⇒ Sc2::Position, Api::Point2D
Ensures a Sc2::Position’s x/y stays in map tile range Prevents out of bound exceptions when working with minimap.
-
#creep?(x:, y:) ⇒ Boolean
Returns whether a tile has creep on it, as per minimap One pixel covers one whole block.
-
#divide_grid(input_grid, length) ⇒ Object
TODO: Remove this method if it has no use.
-
#enemy_start_position ⇒ Api::Point2D
Returns the enemy 2d start position.
-
#expansion_points ⇒ Array<Api::Point2D>
Returns a list of 2d points for expansion build locations Does not contain mineral info, but the value can be checked against geo.expansions.
-
#expansions ⇒ Hash<Api::Point2D, UnitGroup>
Gets expos and surrounding minerals The index is a build location for an expo and the value is a UnitGroup, which has minerals and geysers.
-
#expansions_unoccupied ⇒ Hash<Api::Point2D, UnitGroup>
Returns a slice of #expansions where a base hasn’t been built yet The has index is a build position and the value is a UnitGroup of resources for the base.
-
#expo_placement?(x:, y:) ⇒ Boolean
Whether this tile is where an expansion is supposed to be placed.
-
#expo_placement_grid ⇒ ::Numo::Bit
Returns a grid where only the expo locations are marked.
-
#gas_for_base(base) ⇒ Sc2::UnitGroup
Gets gasses for a base or base position.
-
#geysers_for_base(base) ⇒ Sc2::UnitGroup
Gets geysers for a base or base position.
-
#geysers_open_for_base(base) ⇒ Sc2::UnitGroup
Gets geysers which have not been taken for a base or base position.
-
#initialize(bot) ⇒ Geo
constructor
A new instance of Geo.
-
#map_center ⇒ Api::Point2D
Center of the map.
-
#map_height ⇒ Integer
Gets the map tile height.
-
#map_range_x ⇒ Range
Returns zero to map_width as range.
-
#map_range_y ⇒ Range
Returns zero to map_height as range.
-
#map_seen?(x:, y:) ⇒ Boolean
Returns whether point (tile) has been seen before or currently visible.
-
#map_tile_range_x ⇒ Range
Returns zero to map_width-1 as range.
-
#map_tile_range_y ⇒ Range
Returns zero to map_height-1 as range.
-
#map_unseen?(x:, y:) ⇒ Boolean
Returns whether the point (tile) has never been seen/explored before (dark fog).
-
#map_visible?(x:, y:) ⇒ Boolean
Returns whether the point (tile) is currently in vision.
-
#map_width ⇒ Integer
Gets the map tile width.
-
#minerals_for_base(base) ⇒ Sc2::UnitGroup
Gets minerals for a base or base position.
-
#parsed_creep ⇒ ::Numo::Bit
Provides parsed minimap representation of creep spread Caches for this frame.
-
#parsed_pathing_grid ⇒ ::Numo::Bit
Gets the pathable areas as things stand right now in the game Buildings, minerals, structures, etc.
-
#parsed_placement_grid ⇒ ::Numo::Bit
Returns a parsed placement_grid from bot.game_info.start_raw.
-
#parsed_power_grid ⇒ ::Numo::Bit
Returns a grid where powered locations are marked true.
-
#parsed_terrain_height ⇒ ::Numo::SFloat
Returns a parsed terrain_height from bot.game_info.start_raw.
-
#parsed_visibility_grid ⇒ ::Numo::SFloat
Returns a parsed map_state.visibility from bot.observation.raw_data.
-
#pathable?(x:, y:) ⇒ Boolean
Returns whether a x/y block is pathable as per minimap One pixel covers one whole block.
-
#placeable?(x:, y:) ⇒ Boolean
Returns whether a x/y (integer) is placeable as per minimap image data.
-
#point_random_near(pos:, offset: 1.0) ⇒ Api::Point2D
Gets a random point near a location with a positive/negative offset applied to both x and y.
- #point_random_on_circle(pos:, radius: 1.0) ⇒ Api::Point2D
-
#points_nearest_linear(source:, target:, offset: 0.0, increment: 1.0, count: 1) ⇒ Array<Api::Point2D>
Finds points in a straight line.
-
#powered?(x:, y:) ⇒ Boolean
Returns whether a x/y block is powered.
-
#reset ⇒ void
Called once per update loop.
-
#start_position ⇒ Api::Point2D
Returns own 2d start position as set by initial camera This differs from position of first base structure.
-
#terrain_height(x:, y:) ⇒ Float
Returns the terrain height (z) at position x and y Granularity is per placement grid block, since this comes from minimap image data.
-
#terrain_height_for_pos(position) ⇒ Float
Returns the terrain height (z) at position x and y for a point.
-
#visibility(x:, y:) ⇒ Integer
Returns one of three Integer visibility indicators at tile for x & y.
-
#warp_points(source:, unit_type_id: nil) ⇒ Array<Api::Point2D>
Draws a grid within a unit (pylon/prisms) radius, then selects points which are placeable.
Constructor Details
#initialize(bot) ⇒ Geo
Returns a new instance of Geo.
15 16 17 |
# File 'lib/sc2ai/player/geo.rb', line 15 def initialize(bot) @bot = bot end |
Instance Attribute Details
#bot ⇒ Sc2::Player
Returns player with active connection.
12 13 14 |
# File 'lib/sc2ai/player/geo.rb', line 12 def bot @bot end |
Instance Method Details
#build_coordinates(length:, on_creep: false, in_power: false) ⇒ Array<Array<(Float, Float)>>
Gets buildable point grid for squares of size, i.e. 3 = 3x3 placements Uses pathing grid internally, to ignore taken positions Does not query the api and is generally fast.
817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 |
# File 'lib/sc2ai/player/geo.rb', line 817 def build_coordinates(length:, on_creep: false, in_power: false) length = 1 if length < 1 @_build_coordinates ||= {} cache_key = [length, on_creep, in_power].hash return @_build_coordinates[cache_key] unless @_build_coordinates[cache_key].nil? result = [] input_grid = parsed_pathing_grid & parsed_placement_grid & ~expo_placement_grid & ~placement_obstruction_grid input_grid = if on_creep parsed_creep & input_grid else ~parsed_creep & input_grid end input_grid = parsed_power_grid & input_grid if in_power # Dimensions height = input_grid.shape[0] width = input_grid.shape[1] # divide map into tile length and remove remainder blocks capped_height = height / length * length capped_width = width / length * length # Build points are in center of square, i.e. 1.5 inwards for a 3x3 building offset_to_inside = length / 2.0 output_grid = input_grid[0...capped_height, 0...capped_width] .reshape(capped_height / length, length, capped_width / length, length) .all?(1, 3) output_grid.where.each do |true_index| y, x = true_index.divmod(capped_width / length) result << [x * length + offset_to_inside, y * length + offset_to_inside] end @_build_coordinates[cache_key] = result end |
#build_placement_near(length:, target:, random: 1, in_power: false) ⇒ Api::Point2D?
Gets a buildable location for a square of length, near target. Chooses from random amount of nearest locations. For robustness, it is advised to set ‘random` to, i.e. 3, to allow choosing the 3 nearest possible places, should one location be blocked. For zerg, the buildable locations are only on creep. Internally creates a kdtree for building locations based on pathable, placeable and creep
863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 |
# File 'lib/sc2ai/player/geo.rb', line 863 def build_placement_near(length:, target:, random: 1, in_power: false) target = target.pos if target.is_a? Api::Unit random = 1 if random.to_i.negative? length = 1 if length < 1 on_creep = bot.race == Api::Race::ZERG coordinates = build_coordinates(length:, on_creep:, in_power:) cache_key = coordinates.hash @_build_coordinate_tree ||= {} if @_build_coordinate_tree[cache_key].nil? @_build_coordinate_tree[cache_key] = Kdtree.new( coordinates.each_with_index.map { |coords, index| coords + [index] } ) end nearest = @_build_coordinate_tree[cache_key].nearestk(target.x, target.y, random) return nil if nearest.nil? || nearest.empty? coordinates[nearest.sample].to_p2d end |
#clamp_to_grid(position) ⇒ Sc2::Position, Api::Point2D
Ensures a Sc2::Position’s x/y stays in map tile range Prevents out of bound exceptions when working with minimap
88 89 90 91 92 |
# File 'lib/sc2ai/player/geo.rb', line 88 def clamp_to_grid(position) position.y = position.y.clamp(map_tile_range_y) position.x = position.x.clamp(map_tile_range_x) position end |
#creep?(x:, y:) ⇒ Boolean
Returns whether a tile has creep on it, as per minimap One pixel covers one whole block. Corrects float inputs on your behalf.
419 420 421 |
# File 'lib/sc2ai/player/geo.rb', line 419 def creep?(x:, y:) parsed_creep[y.to_i, x.to_i] != 0 end |
#divide_grid(input_grid, length) ⇒ Object
TODO: Remove this method if it has no use. Build points uses this code directly for optimization. Reduce the dimensions of a grid by merging cells using length x length squares. Merged cell keeps it’s 1 value only if all merged cells are equal to 1, else 0
450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 |
# File 'lib/sc2ai/player/geo.rb', line 450 def divide_grid(input_grid, length) height = input_grid.shape[0] width = input_grid.shape[1] new_height = height / length new_width = width / length # Assume everything is placeable. We will check and set 0's below output_grid = Numo::Bit.ones(new_height, new_width) # divide map into tile length and remove remainder blocks capped_height = new_height * length capped_width = new_width * length # These loops are all structured this way, because of speed. y = 0 while y < capped_height x = 0 while x < capped_width # We are on the bottom-left of a placement tile of Length x Length # Check right- and upwards for any negatives and break both loops, as soon as we find one inner_y = 0 while inner_y < length inner_x = 0 while inner_x < length if input_grid[y + inner_y, x + inner_x].zero? output_grid[y / length, x / length] = 0 inner_y = length break end inner_x += 1 end inner_y += 1 end # End of checking sub-cells x += length end y += length end output_grid end |
#enemy_start_position ⇒ Api::Point2D
Returns the enemy 2d start position
502 503 504 |
# File 'lib/sc2ai/player/geo.rb', line 502 def enemy_start_position bot.game_info.start_raw.start_locations.first end |
#expansion_points ⇒ Array<Api::Point2D>
Returns a list of 2d points for expansion build locations Does not contain mineral info, but the value can be checked against geo.expansions
681 682 683 |
# File 'lib/sc2ai/player/geo.rb', line 681 def expansion_points expansions.keys end |
#expansions ⇒ Hash<Api::Point2D, UnitGroup>
Gets expos and surrounding minerals The index is a build location for an expo and the value is a UnitGroup, which has minerals and geysers
514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 |
# File 'lib/sc2ai/player/geo.rb', line 514 def expansions return @expansions unless @expansions.nil? @expansions = {} # An array of offsets to search around the center of resource cluster for points point_search_offsets = (-7..7).to_a.product((-7..7).to_a) point_search_offsets.select! do |x, y| dist = Math.hypot(x, y) dist > 4.0 && dist <= 8.0 end # Split resources by Z axis resources = bot.neutral.minerals - mineral_walls + bot.neutral.geysers resource_group_z = resources.group_by do |resource| resource.pos.z.round # 32 units of Y, most maps will have use 3. round to nearest. end # Cluster over every z level resource_group_z.map do |z, resource_group| # Convert group into numo array of 2d points positions = Numo::DFloat.zeros(resource_group.size, 2) resource_group.each_with_index do |res, index| positions[index, 0] = res.pos.x positions[index, 1] = res.pos.y end # Max 8.5 distance apart for nodes, else it's noise. At least 4 resources for an expo analyzer = Rumale::Clustering::DBSCAN.new(eps: 8.5, min_samples: 4) cluster_marks = analyzer.fit_predict(positions) # for each cluster, grab those indexes to reference the mineral/gas # then work out a placeable position based on their locations (0..cluster_marks.max).each do |cluster_index| clustered_resources = resource_group.select.with_index { |_res, i| cluster_marks[i] == cluster_index } possible_points = {} # Grab center of clustered avg_x = clustered_resources.sum { |res| res.pos.x } / clustered_resources.size avg_y = clustered_resources.sum { |res| res.pos.y } / clustered_resources.size # Round average spot to nearest 0.5 point, since HQ center is at half measure (5 wide) avg_x = avg_x.round + 0.5 avg_y = avg_y.round + 0.5 points_length = point_search_offsets.length i = 0 while i < points_length x = point_search_offsets[i][0] + avg_x y = point_search_offsets[i][1] + avg_y if !map_tile_range_x.include?(x + 1) || !map_tile_range_y.include?(y + 1) i += 1 next end if parsed_placement_grid[y.floor, x.floor].zero? i += 1 next end # Compare this point to each resource to ensure it's far enough away distance_sum = 0 valid_min_distance = clustered_resources.all? do |res| dist = Math.hypot(res.pos.x - x, res.pos.y - y) if Sc2::UnitGroup::TYPE_GEYSER.include?(res.unit_type) min_distance = 7 distance_sum += (dist / 7.0) * dist else min_distance = 6 distance_sum += dist end dist >= min_distance end possible_points[[x, y]] = distance_sum if valid_min_distance i += 1 end next if possible_points.empty? # Choose best fitting point best_point = possible_points.keys[possible_points.values.find_index(possible_points.values.min)] @expansions[best_point.to_p2d] = UnitGroup.new(clustered_resources) # Check if this might be a mirrored base. best_mirror_point = nil geysers = clustered_resources.select { |res| Sc2::UnitGroup::TYPE_GEYSER.include?(res.unit_type) } if geysers.size == 2 if geysers[0].pos.y == geysers[1].pos.y || geysers[0].pos.x == geysers[1].pos.x # Mirrored vertical, potentially best_mirror_point = [ best_point[0], best_point[1] - (best_point[1] - (geysers[0].pos.y + geysers[1].pos.y) / 2.0) * 2.0 ] if best_mirror_point != best_point && possible_points.has_key?(best_mirror_point) @expansions[best_mirror_point.to_p2d] = UnitGroup.new(clustered_resources) else # Wasn't mirrored the one way. How about the other?... # Mirrored horizontal, potentially best_mirror_point = [ best_point[0] - (best_point[0] - (geysers[0].pos.x + geysers[1].pos.x) / 2.0) * 2.0, best_point[1] ] if best_mirror_point != best_point && possible_points.has_key?(best_mirror_point) @expansions[best_mirror_point.to_p2d] = UnitGroup.new(clustered_resources) end end end end end end @expansions end |
#expansions_unoccupied ⇒ Hash<Api::Point2D, UnitGroup>
Returns a slice of #expansions where a base hasn’t been built yet The has index is a build position and the value is a UnitGroup of resources for the base
694 695 696 697 698 |
# File 'lib/sc2ai/player/geo.rb', line 694 def expansions_unoccupied taken_bases = bot.structures.hq.map { |hq| hq.pos.to_p2d } + bot.enemy.structures.hq.map { |hq| hq.pos.to_p2d } remaining_points = expansion_points - taken_bases expansions.slice(*remaining_points) end |
#expo_placement?(x:, y:) ⇒ Boolean
Whether this tile is where an expansion is supposed to be placed. To see if a unit/structure is blocking an expansion, pass their coordinates to this method.
127 128 129 |
# File 'lib/sc2ai/player/geo.rb', line 127 def expo_placement?(x:, y:) expo_placement_grid[y.to_i, x.to_i] == 1 end |
#expo_placement_grid ⇒ ::Numo::Bit
Returns a grid where only the expo locations are marked
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/sc2ai/player/geo.rb', line 133 def expo_placement_grid if @expo_placement_grid.nil? @expo_placement_grid = Numo::Bit.zeros(map_height, map_width) expansion_points.each do |point| x = point.x.floor y = point.y.floor # For zerg, reserve a layer at the bottom for larva->egg if bot.race == Api::Race::ZERG # Reserve one row lower, meaning (y-3) instead of (y-2) @expo_placement_grid[(y - 3).clamp(map_tile_range_y)..(y + 2).clamp(map_tile_range_y), (x - 2).clamp(map_tile_range_x)..(x + 2).clamp(map_tile_range_x)] = 1 else @expo_placement_grid[(y - 2).clamp(map_tile_range_y)..(y + 2).clamp(map_tile_range_y), (x - 2).clamp(map_tile_range_x)..(x + 2).clamp(map_tile_range_x)] = 1 end end end @expo_placement_grid end |
#gas_for_base(base) ⇒ Sc2::UnitGroup
Gets gasses for a base or base position
796 797 798 799 800 801 802 803 804 805 806 807 808 809 |
# File 'lib/sc2ai/player/geo.rb', line 796 def gas_for_base(base) # No gas structures at all yet, return nothing return UnitGroup.new if bot.structures.gas.size.zero? geysers = geysers_for_base(base) # Mineral-only base, return nothing return UnitGroup.new if geysers.size == 0 # Loop and collect gasses places exactly on-top of geysers bot.structures.gas.select do |gas| geysers.any? { |geyser| geyser.pos.to_p2d.eql?(gas.pos.to_p2d) } end end |
#geysers_for_base(base) ⇒ Sc2::UnitGroup
Gets geysers for a base or base position
737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 |
# File 'lib/sc2ai/player/geo.rb', line 737 def geysers_for_base(base) # @see #minerals_for_base for backstory on these fixes base_resources = resources_for_base(base) = base_resources.geysers. = bot.neutral.geysers. = - unless .empty? other_alive_geysers = bot.neutral.geysers.slice(*( - )) # For each missing calculated geyser patch... .each do |tag| missing_resource = base_resources.delete(tag) # Find an alive geyser at that position new_resource = other_alive_geysers.find { |live_geyser| live_geyser.pos == missing_resource.pos } base_resources.add(new_resource) unless new_resource.nil? end end base_resources.geysers end |
#geysers_open_for_base(base) ⇒ Sc2::UnitGroup
Gets geysers which have not been taken for a base or base position
762 763 764 765 766 767 768 769 770 771 772 773 |
# File 'lib/sc2ai/player/geo.rb', line 762 def geysers_open_for_base(base) geysers = geysers_for_base(base) # Mineral-only base, return nothing return UnitGroup.new if geysers.size == 0 # Reject all which have a gas structure on-top gas_positions = bot.structures.gas.map { |gas| gas.pos } geysers.reject do |geyser| gas_positions.include?(geyser.pos) end end |
#map_center ⇒ Api::Point2D
Center of the map
56 57 58 |
# File 'lib/sc2ai/player/geo.rb', line 56 def map_center @map_center ||= Api::Point2D[map_width / 2, map_height / 2] end |
#map_height ⇒ Integer
Gets the map tile height. Range is 1-255. Effected by crop_to_playable_area
49 50 51 52 |
# File 'lib/sc2ai/player/geo.rb', line 49 def map_height # bot.bot.game_info @map_height ||= bot.game_info.start_raw.map_size.y end |
#map_range_x ⇒ Range
Returns zero to map_width as range
62 63 64 |
# File 'lib/sc2ai/player/geo.rb', line 62 def map_range_x 0..(map_width) end |
#map_range_y ⇒ Range
Returns zero to map_height as range
68 69 70 |
# File 'lib/sc2ai/player/geo.rb', line 68 def map_range_y 0..(map_height) end |
#map_seen?(x:, y:) ⇒ Boolean
Returns whether point (tile) has been seen before or currently visible
388 389 390 |
# File 'lib/sc2ai/player/geo.rb', line 388 def map_seen?(x:, y:) visibility(x:, y:) != 0 end |
#map_tile_range_x ⇒ Range
Returns zero to map_width-1 as range
74 75 76 |
# File 'lib/sc2ai/player/geo.rb', line 74 def map_tile_range_x 0..(map_width - 1) end |
#map_tile_range_y ⇒ Range
Returns zero to map_height-1 as range
80 81 82 |
# File 'lib/sc2ai/player/geo.rb', line 80 def map_tile_range_y 0..(map_height - 1) end |
#map_unseen?(x:, y:) ⇒ Boolean
Returns whether the point (tile) has never been seen/explored before (dark fog)
396 397 398 |
# File 'lib/sc2ai/player/geo.rb', line 396 def map_unseen?(x:, y:) !map_seen?(x:, y:) end |
#map_visible?(x:, y:) ⇒ Boolean
Returns whether the point (tile) is currently in vision
380 381 382 |
# File 'lib/sc2ai/player/geo.rb', line 380 def map_visible?(x:, y:) visibility(x:, y:) == 2 end |
#map_width ⇒ Integer
Gets the map tile width. Range is 1-255. Effected by crop_to_playable_area
41 42 43 44 |
# File 'lib/sc2ai/player/geo.rb', line 41 def map_width # bot.bot.game_info @map_width ||= bot.game_info.start_raw.map_size.x end |
#minerals_for_base(base) ⇒ Sc2::UnitGroup
Gets minerals for a base or base position
703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 |
# File 'lib/sc2ai/player/geo.rb', line 703 def minerals_for_base(base) base_resources = resources_for_base(base) = base_resources.minerals. = bot.neutral.minerals. # BACK-STORY: Mineral id's are fixed when in vision. # Snapshots get random id's every time an object leaves vision. # At game launch when we calculate and save minerals, which are mostly snapshot. # Currently, we might have moved vision over minerals, so that their id's have changed. # The alive object share a Position with our cached one, so we can get the correct id and update our cache. # PERF: Fix takes 0.70ms, cache takes 0.10ms - we mostly call cached. This is the way. # PERF: In contrast, repeated calls to neutral.minerals.units_in_circle? always costs 0.22ms = - unless .empty? other_alive_minerals = bot.neutral.minerals.slice(*( - )) # For each missing calculated mineral patch... .each do |tag| missing_resource = base_resources.delete(tag) # Find an alive mineral at that position new_resource = other_alive_minerals.find { |live_mineral| live_mineral.pos == missing_resource.pos } base_resources.add(new_resource) unless new_resource.nil? end end base_resources.minerals end |
#parsed_creep ⇒ ::Numo::Bit
Provides parsed minimap representation of creep spread Caches for this frame
426 427 428 429 430 431 432 433 434 |
# File 'lib/sc2ai/player/geo.rb', line 426 def parsed_creep if @parsed_creep.nil? # || image_data != previous_data image_data = bot.observation.raw_data.map_state.creep # Fix endian for Numo bit parser data = image_data.data.unpack("b*").pack("B*") @parsed_creep = Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x]) end @parsed_creep end |
#parsed_pathing_grid ⇒ ::Numo::Bit
Gets the pathable areas as things stand right now in the game Buildings, minerals, structures, etc. all result in a nonpathable place
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'lib/sc2ai/player/geo.rb', line 307 def parsed_pathing_grid if @parsed_pathing_grid.nil? image_data = bot.game_info.start_raw.pathing_grid # Fix endian for Numo bit parser data = image_data.data.unpack("b*").pack("B*") @parsed_pathing_grid = Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x]) # Remove erroneous mineral blockers as pathable bot.neutral.select_type(Api::UnitTypeId::MINERALFIELD450).each do |mineral| @parsed_pathing_grid[mineral.pos.y.to_i, mineral.pos.x] = 0 @parsed_pathing_grid[mineral.pos.y.to_i, mineral.pos.x - 1] = 0 end end @parsed_pathing_grid end |
#parsed_placement_grid ⇒ ::Numo::Bit
Returns a parsed placement_grid from bot.game_info.start_raw. Each value in [row] holds a boolean value represented as an integer It does not say whether a position is occupied by another building. One pixel covers one whole block. Rounds fractionated positions down.
112 113 114 115 116 117 118 119 120 |
# File 'lib/sc2ai/player/geo.rb', line 112 def parsed_placement_grid if @parsed_placement_grid.nil? image_data = bot.game_info.start_raw.placement_grid # Fix endian for Numo bit parser data = image_data.data.unpack("b*").pack("B*") @parsed_placement_grid = Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x]) end @parsed_placement_grid end |
#parsed_power_grid ⇒ ::Numo::Bit
Returns a grid where powered locations are marked true
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 |
# File 'lib/sc2ai/player/geo.rb', line 192 def parsed_power_grid # Cache for based on power unit tags cache_key = bot.power_sources.map(&:tag).sort.hash return @parsed_power_grid[0] if !@parsed_power_grid.nil? && @parsed_power_grid[1] == cache_key result = Numo::Bit.zeros(map_height, map_width) power_source = bot.power_sources.first if power_source.nil? @parsed_power_grid = [result, cache_key] return result end # Hard-coding this shape for pylon power # 00001111110000 # 00011111111000 # 00111111111100 # 01111111111110 # 11111111111111 # 11111111111111 # 11111100111111 # 11111100111111 # 11111111111111 # 11111111111111 # 01111111111110 # 00111111111100 # 00011111111000 # 00001111110000 # perf: Saving pre-created shape for speed (0.5ms saved) by using hardcode from .to_binary.unpack("C*") blueprint_data = [0, 0, 254, 193, 255, 248, 127, 254, 159, 255, 231, 243, 249, 124, 254, 159, 255, 231, 255, 241, 63, 248, 7, 0, 0].pack("C*") blueprint_pylon = Numo::Bit.from_binary(blueprint_data, [14, 14]) # Warp Prism # 00011000 # 01111110 # 01111110 # 11111111 # 11111111 # 01111110 # 01111110 # 00011000 blueprint_data = [24, 126, 126, 255, 255, 126, 126, 24].pack("C*") blueprint_prism = Numo::Bit.from_binary(blueprint_data, [8, 8]) # Print each power-source on map using shape above bot.power_sources.each do |ps| radius_tile = ps.radius.ceil # Select blueprint for 7-tile radius (Pylon) or 4-tile radius (Prism) blueprint = if radius_tile == 4 blueprint_prism else blueprint_pylon end x_tile = ps.pos.x.floor y_tile = ps.pos.y.floor replace_start_x = (x_tile - radius_tile) replace_end_x = (x_tile + radius_tile - 1) replace_start_y = (y_tile - radius_tile) replace_end_y = (y_tile + radius_tile - 1) bp_start_x = bp_start_y = 0 bp_end_x = bp_end_y = blueprint.shape[0] - 1 # Laborious clamping if blueprint goes over edge if replace_start_x < 0 bp_start_x += replace_start_x replace_start_x = 0 elsif replace_end_x >= map_width bp_end_x += map_width - replace_end_x - 1 replace_end_x = map_width - 1 end if replace_start_y < 0 bp_start_y += replace_start_y replace_start_y = 0 elsif replace_end_y >= map_height bp_end_y += map_height - replace_end_y - 1 replace_end_y = map_height - 1 end # Bitwise OR because previous pylons could overlap result[replace_start_y..replace_end_y, replace_start_x..replace_end_x] = result[replace_start_y..replace_end_y, replace_start_x..replace_end_x] | blueprint[bp_start_y..bp_end_y, bp_start_x..bp_end_x] end bot.power_sources.each do |ps| # For pylons, remove pylon location on ground next if bot.structures.pylons[ps.tag].nil? result[(ps.pos.y.floor - 1)..ps.pos.y.floor, (ps.pos.x.floor - 1)..ps.pos.x.floor] = 0 end @parsed_power_grid = [result, cache_key] result end |
#parsed_terrain_height ⇒ ::Numo::SFloat
Returns a parsed terrain_height from bot.game_info.start_raw. Each value in [row] holds a float value which is the z height
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 |
# File 'lib/sc2ai/player/geo.rb', line 351 def parsed_terrain_height if @parsed_terrain_height.nil? image_data = bot.game_info.start_raw.terrain_height @parsed_terrain_height = Numo::UInt8 .from_binary(image_data.data, [image_data.size.y, image_data.size.x]) .cast_to(Numo::SFloat) # Values are between -16 and +16. The api values is a float height compressed to rgb range (0-255) in that range of 32. # real_height = -16 + (value / 255) * 32 # These are the least bulk operations while still letting Numo run the loops: @parsed_terrain_height *= (32.0 / 255.0) @parsed_terrain_height -= 16.0 end @parsed_terrain_height end |
#parsed_visibility_grid ⇒ ::Numo::SFloat
Returns a parsed map_state.visibility from bot.observation.raw_data. Each value in [row] holds one of three integers (0,1,2) to flag a vision type
404 405 406 407 408 409 410 411 412 |
# File 'lib/sc2ai/player/geo.rb', line 404 def parsed_visibility_grid if @parsed_visibility_grid.nil? image_data = bot.observation.raw_data.map_state.visibility # Fix endian for Numo bit parser data = image_data.data.unpack("b*").pack("B*") @parsed_visibility_grid = Numo::UInt8.from_binary(data, [image_data.size.y, image_data.size.x]) end @parsed_visibility_grid end |
#pathable?(x:, y:) ⇒ Boolean
Returns whether a x/y block is pathable as per minimap One pixel covers one whole block. Corrects float inputs on your behalf.
296 297 298 |
# File 'lib/sc2ai/player/geo.rb', line 296 def pathable?(x:, y:) parsed_pathing_grid[y.to_i, x.to_i] != 0 end |
#placeable?(x:, y:) ⇒ Boolean
Returns whether a x/y (integer) is placeable as per minimap image data. It does not say whether a position is occupied by another building. One pixel covers one whole block. Corrects floats on your behalf
103 104 105 |
# File 'lib/sc2ai/player/geo.rb', line 103 def placeable?(x:, y:) parsed_placement_grid[y.to_i, x.to_i] != 0 end |
#point_random_near(pos:, offset: 1.0) ⇒ Api::Point2D
Gets a random point near a location with a positive/negative offset applied to both x and y
1027 1028 1029 |
# File 'lib/sc2ai/player/geo.rb', line 1027 def point_random_near(pos:, offset: 1.0) pos.random_offset(offset) end |
#point_random_on_circle(pos:, radius: 1.0) ⇒ Api::Point2D
1034 1035 1036 1037 1038 1039 1040 |
# File 'lib/sc2ai/player/geo.rb', line 1034 def point_random_on_circle(pos:, radius: 1.0) angle = rand(0..360) * Math::PI / 180.0 Api::Point2D[ pos.x + (Math.sin(angle) * radius), pos.y + (Math.cos(angle) * radius) ] end |
#points_nearest_linear(source:, target:, offset: 0.0, increment: 1.0, count: 1) ⇒ Array<Api::Point2D>
Finds points in a straight line. In a line, on the angle of source->target point, starting at source+offset, in increments find points on the line up to max distance
984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 |
# File 'lib/sc2ai/player/geo.rb', line 984 def points_nearest_linear(source:, target:, offset: 0.0, increment: 1.0, count: 1) # Normalized angle dx = (target.x - source.x) dy = (target.y - source.y) dist = Math.hypot(dx, dy) dx /= dist dy /= dist # Set start position and offset if necessary start_x = source.x start_y = source.y unless offset.zero? start_x += (dx * offset) start_y += (dy * offset) end # For count times, increment our radius and multiply by angle to get the new point points = [] i = 1 while i < count radius = increment * i point = Api::Point2D[ start_x + (dx * radius), start_y + (dy * radius) ] # ensure we're on the map break unless map_range_x.cover?(point.x) && map_range_y.cover?(point.x) points << point i += 1 end points end |
#powered?(x:, y:) ⇒ Boolean
Returns whether a x/y block is powered. Only fully covered blocks are true. One pixel covers one whole block. Corrects float inputs on your behalf.
287 288 289 |
# File 'lib/sc2ai/player/geo.rb', line 287 def powered?(x:, y:) parsed_power_grid[y.to_i, x.to_i] != 0 end |
#reset ⇒ void
This method returns an undefined value.
Called once per update loop. It will clear memoization and caches where necessary
23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
# File 'lib/sc2ai/player/geo.rb', line 23 def reset # Only re-parse and cache-bust if strings don't match if bot.game_info.start_raw.pathing_grid.data != bot.previous&.game_info&.start_raw&.pathing_grid&.data @parsed_pathing_grid = nil clear_placement_cache end if bot.observation.raw_data.map_state.creep.data != bot.previous.observation.raw_data&.map_state&.creep&.data @parsed_creep = nil clear_placement_cache end if bot.observation.raw_data.map_state.visibility.data != bot.previous.observation.raw_data&.map_state&.visibility&.data @parsed_visibility_grid = nil end end |
#start_position ⇒ Api::Point2D
Returns own 2d start position as set by initial camera This differs from position of first base structure
496 497 498 |
# File 'lib/sc2ai/player/geo.rb', line 496 def start_position @start_position ||= bot.observation.raw_data.player.camera.to_p2d end |
#terrain_height(x:, y:) ⇒ Float
Returns the terrain height (z) at position x and y Granularity is per placement grid block, since this comes from minimap image data.
337 338 339 |
# File 'lib/sc2ai/player/geo.rb', line 337 def terrain_height(x:, y:) parsed_terrain_height[y.to_i, x.to_i] end |
#terrain_height_for_pos(position) ⇒ Float
Returns the terrain height (z) at position x and y for a point
344 345 346 |
# File 'lib/sc2ai/player/geo.rb', line 344 def terrain_height_for_pos(position) terrain_height(x: position.x, y: position.y) end |
#visibility(x:, y:) ⇒ Integer
Returns one of three Integer visibility indicators at tile for x & y
372 373 374 |
# File 'lib/sc2ai/player/geo.rb', line 372 def visibility(x:, y:) parsed_visibility_grid[y.to_i, x.to_i] end |
#warp_points(source:, unit_type_id: nil) ⇒ Array<Api::Point2D>
Draws a grid within a unit (pylon/prisms) radius, then selects points which are placeable
889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 |
# File 'lib/sc2ai/player/geo.rb', line 889 def warp_points(source:, unit_type_id: nil) # power source needed power_source = bot.power_sources.find { |ps| source.tag == ps.tag } return [] if power_source.nil? # hardcoded unit radius, otherwise only obtainable by owning a unit already unit_type_id = Api::UnitTypeId::STALKER if unit_type_id.nil? target_radius = case unit_type_id when Api::UnitTypeId::STALKER 0.625 when Api::UnitTypeId::HIGHTEMPLAR, Api::UnitTypeId::DARKTEMPLAR 0.375 else 0.5 # Adept, zealot, sentry, etc. end unit_width = target_radius * 2 # power source's inner and outer radius outer_radius = power_source.radius # Can not spawn on-top of pylon inner_radius = (source.unit_type == Api::UnitTypeId::PYLON) ? source.radius : 0 # Make a grid of circles packed in triangle formation, covering the power field points = [] y_increment = Math.sqrt(Math.hypot(unit_width, unit_width / 2.0)) offset_row = false # noinspection RubyMismatchedArgumentType # rbs fixed in future patch ((source.pos.y - outer_radius + target_radius)..(source.pos.y + outer_radius - target_radius)).step(y_increment) do |y| ((source.pos.x - outer_radius + target_radius)..(source.pos.x + outer_radius - target_radius)).step(unit_width) do |x| x += target_radius if offset_row points << Api::Point2D[x, y] end offset_row = !offset_row end # Select only grid points inside the outer source and outside the inner source points.select! do |grid_point| gp_distance = source.pos.distance_to(grid_point) gp_distance > inner_radius + target_radius && gp_distance + target_radius < outer_radius end # Find X amount of near units within the radius and subtract their overlap in radius with points # we arbitrarily decided that a pylon will no be surrounded by more than 50 units # We add 2.75 above, which is the fattest ground unit (nexus @ 2.75 radius) units_in_pylon_range = bot.all_units.nearest_to(pos: source.pos, amount: 50) .select_in_circle(point: source.pos, radius: outer_radius + 2.75) # Reject warp points which overlap with units inside points.reject! do |point| # Find units which overlap with our warp points units_in_pylon_range.find do |unit| xd = (unit.pos.x - point.x).abs yd = (unit.pos.y - point.y).abs intersect_distance = target_radius + unit.radius next false if xd > intersect_distance || yd > intersect_distance Math.hypot(xd, yd) < intersect_distance end end # Select only warp points which are on placeable tiles points.reject! do |point| left = (point.x - target_radius).floor.clamp(map_tile_range_x) right = (point.x + target_radius).floor.clamp(map_tile_range_x) top = (point.y + target_radius).floor.clamp(map_tile_range_y) bottom = (point.y - target_radius).floor.clamp(map_tile_range_y) unplaceable = false x = left while x <= right break if unplaceable y = bottom while y <= top unplaceable = !placeable?(x: x, y: y) break if unplaceable y += 1 end x += 1 end unplaceable end points end |