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.
-
#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.
725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 |
# File 'lib/sc2ai/player/geo.rb', line 725 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
771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 |
# File 'lib/sc2ai/player/geo.rb', line 771 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 |
#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.
402 403 404 |
# File 'lib/sc2ai/player/geo.rb', line 402 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
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 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 |
# File 'lib/sc2ai/player/geo.rb', line 433 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
485 486 487 |
# File 'lib/sc2ai/player/geo.rb', line 485 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
589 590 591 |
# File 'lib/sc2ai/player/geo.rb', line 589 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
497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 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 |
# File 'lib/sc2ai/player/geo.rb', line 497 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 && dist <= 8 end # Split resources by Z axis resources = bot.neutral.minerals + 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) 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
602 603 604 605 606 |
# File 'lib/sc2ai/player/geo.rb', line 602 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.
117 118 119 |
# File 'lib/sc2ai/player/geo.rb', line 117 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
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/sc2ai/player/geo.rb', line 123 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
704 705 706 707 708 709 710 711 712 713 714 715 716 717 |
# File 'lib/sc2ai/player/geo.rb', line 704 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
645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 |
# File 'lib/sc2ai/player/geo.rb', line 645 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
670 671 672 673 674 675 676 677 678 679 680 681 |
# File 'lib/sc2ai/player/geo.rb', line 670 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
371 372 373 |
# File 'lib/sc2ai/player/geo.rb', line 371 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)
379 380 381 |
# File 'lib/sc2ai/player/geo.rb', line 379 def map_unseen?(x:, y:) !map_seen?(x:, y:) end |
#map_visible?(x:, y:) ⇒ Boolean
Returns whether the point (tile) is currently in vision
363 364 365 |
# File 'lib/sc2ai/player/geo.rb', line 363 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
611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 |
# File 'lib/sc2ai/player/geo.rb', line 611 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
409 410 411 412 413 414 415 416 417 |
# File 'lib/sc2ai/player/geo.rb', line 409 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
297 298 299 300 301 302 303 304 305 |
# File 'lib/sc2ai/player/geo.rb', line 297 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]) 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.
102 103 104 105 106 107 108 109 110 |
# File 'lib/sc2ai/player/geo.rb', line 102 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
182 183 184 185 186 187 188 189 190 191 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 |
# File 'lib/sc2ai/player/geo.rb', line 182 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
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 |
# File 'lib/sc2ai/player/geo.rb', line 334 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
387 388 389 390 391 392 393 394 395 |
# File 'lib/sc2ai/player/geo.rb', line 387 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.
286 287 288 |
# File 'lib/sc2ai/player/geo.rb', line 286 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
93 94 95 |
# File 'lib/sc2ai/player/geo.rb', line 93 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
935 936 937 |
# File 'lib/sc2ai/player/geo.rb', line 935 def point_random_near(pos:, offset: 1.0) pos.random_offset(offset) end |
#point_random_on_circle(pos:, radius: 1.0) ⇒ Api::Point2D
942 943 944 945 946 947 948 |
# File 'lib/sc2ai/player/geo.rb', line 942 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
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 |
# File 'lib/sc2ai/player/geo.rb', line 892 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.
277 278 279 |
# File 'lib/sc2ai/player/geo.rb', line 277 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
479 480 481 |
# File 'lib/sc2ai/player/geo.rb', line 479 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.
320 321 322 |
# File 'lib/sc2ai/player/geo.rb', line 320 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
327 328 329 |
# File 'lib/sc2ai/player/geo.rb', line 327 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
355 356 357 |
# File 'lib/sc2ai/player/geo.rb', line 355 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
797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 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 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 |
# File 'lib/sc2ai/player/geo.rb', line 797 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 |