Class: Sc2::Player::Geo

Inherits:
Object
  • Object
show all
Defined in:
lib/sc2ai/player/geo.rb

Overview

Holds map and geography helper functions

Instance Attribute Summary collapse

Instance Method Summary collapse

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

#botSc2::Player

Returns player with active connection.

Returns:



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.

Parameters:

  • length (Integer)

    length of the building, 2 for depot/pylon, 3 for rax/gate

  • on_creep (Boolean) (defaults to: false)

    whether this build location should be on creep

Returns:

  • (Array<Array<(Float, Float)>>)

    Array of [x,y] tuples



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

Parameters:

  • length (Integer)

    length of the building, 2 for depot/pylon, 3 for rax/gate

  • target (Api::Unit, Sc2::Position)

    near where to find a placement

  • random (Integer) (defaults to: 1)

    number of nearest points to randomly choose from. 1 for nearest point.

  • in_power (Boolean) (defaults to: false)

    whether this must be on a power field

Returns:

  • (Api::Point2D, nil)

    buildable location, nil if no buildable location found



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.

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Boolean)

    true if location has creep on it



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

Parameters:

  • input_grid (::Numo::Bit)

    Bit grid like parsed_pathing_grid or parsed_placement_grid

  • length (Integer)

    how many cells to merge, i.e. 3 for finding 3x3 placement



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_positionApi::Point2D

Returns the enemy 2d start position

Returns:



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_pointsArray<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

Examples:

random_expo = expansion_points.sample
expo_resources = geo.expansions[random_expo]

Returns:



589
590
591
# File 'lib/sc2ai/player/geo.rb', line 589

def expansion_points
  expansions.keys
end

#expansionsHash<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

Examples:

random_expo = geo.expansions.keys.sample #=> Point2D
expo_resources = geo.expansions[random_expo] #=> UnitGroup
alive_minerals = expo_resources.minerals & neutral.minerals
geysers = expo_resources.geysers

Returns:



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_unoccupiedHash<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

Examples:

# Lets find the nearest unoccupied expo
expo_pos = expansions_unoccupied.keys.min { |p2d| p2d.distance_to(structures.hq.first) }
# What minerals/geysers does it have?
puts expansions_unoccupied[expo_pos].minerals # or expansions[expo_pos]... => UnitGroup
puts expansions_unoccupied[expo_pos].geysers # or expansions[expo_pos]... => UnitGroup

Returns:



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.

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Boolean)

    true if location has creep on it



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

Returns:

  • (::Numo::Bit)


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

Parameters:

Returns:



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

Parameters:

Returns:



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)
  cached_tags = base_resources.geysers.tags
  observed_tags = bot.neutral.geysers.tags

  missing_tags = cached_tags - observed_tags
  unless missing_tags.empty?
    other_alive_geysers = bot.neutral.geysers.slice(*(observed_tags - cached_tags))
    # For each missing calculated geyser patch...
    missing_tags.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

Parameters:

Returns:



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_centerApi::Point2D

Center of the map

Returns:



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_heightInteger

Gets the map tile height. Range is 1-255. Effected by crop_to_playable_area

Returns:

  • (Integer)


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_xRange

Returns zero to map_width as range

Returns:

  • (Range)

    0 to map_width



62
63
64
# File 'lib/sc2ai/player/geo.rb', line 62

def map_range_x
  0..(map_width)
end

#map_range_yRange

Returns zero to map_height as range

Returns:

  • (Range)

    0 to map_height



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

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Boolean)

    true if partially or fully lifted fog



371
372
373
# File 'lib/sc2ai/player/geo.rb', line 371

def map_seen?(x:, y:)
  visibility(x:, y:) != 0
end

#map_tile_range_xRange

Returns zero to map_width-1 as range

Returns:

  • (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_yRange

Returns zero to map_height-1 as range

Returns:

  • (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)

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Boolean)

    true if fog of war is fully dark



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

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Boolean)

    true if fog is completely lifted



363
364
365
# File 'lib/sc2ai/player/geo.rb', line 363

def map_visible?(x:, y:)
  visibility(x:, y:) == 2
end

#map_widthInteger

Gets the map tile width. Range is 1-255. Effected by crop_to_playable_area

Returns:

  • (Integer)


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

Parameters:

Returns:



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)
  cached_tags = base_resources.minerals.tags
  observed_tags = bot.neutral.minerals.tags

  # 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

  missing_tags = cached_tags - observed_tags
  unless missing_tags.empty?
    other_alive_minerals = bot.neutral.minerals.slice(*(observed_tags - cached_tags))
    # For each missing calculated mineral patch...
    missing_tags.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

Returns:

  • (::Numo::Bit)

    Numo array



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

Examples:

parsed_pathing_grid[0,0] # reads bottom left corner
# use helper function #pathable
pathable?(x: 0, y: 0) # reads bottom left corner

Returns:

  • (::Numo::Bit)

    Numo array



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.

Returns:

  • (::Numo::Bit)


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

Returns:

  • (::Numo::Bit)


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

Returns:

  • (::Numo::SFloat)

    Numo array



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

Returns:

  • (::Numo::SFloat)

    Numo array

See Also:



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.

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Boolean)

    whether tile is patahble



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

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Boolean)

    whether tile is placeable?

See Also:

  • for detecting obstructions


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

Examples:

Randomly randomly adjust both x and y by a range of -3.5 or +3.5
geo.point_random_near(point: structures.hq.first, offset: 3.5)

Parameters:

Returns:



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

Parameters:

Returns:



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

Parameters:

  • source (Sc2::Position)

    location from which we go

  • target (Sc2::Position)

    location towards which we go

  • offset (Float) (defaults to: 0.0)

    how far from source to start

  • increment (Float) (defaults to: 1.0)

    how far apart to gets, i.e. increment = unit.radius*2 to space units in a line

  • count (Integer) (defaults to: 1)

    number of points to retrieve

Returns:



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.

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Boolean)

    true if location is powered



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

#resetvoid

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_positionApi::Point2D

Returns own 2d start position as set by initial camera This differs from position of first base structure

Returns:



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.

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Float)

    z axis position between -16 and 16



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

Parameters:

Returns:

  • (Float)

    z axis position between -16 and 16



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

Parameters:

  • x (Float, Integer)
  • y (Float, Integer)

Returns:

  • (Integer)

    0=HIDDEN,1=SNAPSHOT,2=VISIBLE



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

Parameters:

  • source (Api::Unit)

    either a pylon or a prism

  • unit_type_id (Api::Unit) (defaults to: nil)

    optionally, the unit you wish to place. Stalkers are widest, so use default nil for a mixed composition warp

Returns:



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