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.



14
15
16
# File 'lib/sc2ai/player/geo.rb', line 14

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



712
713
714
715
716
717
718
719
720
721
722
723
724
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
761
762
763
764
765
766
767
768
769
# File 'lib/sc2ai/player/geo.rb', line 712

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] if !@_build_coordinates[cache_key].nil? && !bot.game_info_stale?

  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

  # Note, these loops are structured for 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
      valid_position = true
      inner_y = 0
      while inner_y < length
        inner_x = 0
        while inner_x < length
          if (input_grid[y + inner_y, x + inner_x]).zero?
            # break sub-cells check and don't save position
            valid_position = false
            inner_y = length
            break
          end
          inner_x += 1
        end
        inner_y += 1
      end
      # End of checking sub-cells

      result << [x + offset_to_inside, y + offset_to_inside] if valid_position
      x += length
    end
    y += length
  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



780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
# File 'lib/sc2ai/player/geo.rb', line 780

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



389
390
391
# File 'lib/sc2ai/player/geo.rb', line 389

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



421
422
423
424
425
426
427
428
429
430
431
432
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
# File 'lib/sc2ai/player/geo.rb', line 421

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:



473
474
475
# File 'lib/sc2ai/player/geo.rb', line 473

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:



576
577
578
# File 'lib/sc2ai/player/geo.rb', line 576

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:



485
486
487
488
489
490
491
492
493
494
495
496
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
# File 'lib/sc2ai/player/geo.rb', line 485

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



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

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



97
98
99
# File 'lib/sc2ai/player/geo.rb', line 97

def expo_placement?(x:, y:)
  expo_placement_grid[y.to_i, x.to_i] == 1
end

#expo_placement_gridNumo::Bit

Returns a grid where only the expo locations are marked

Returns:

  • (Numo::Bit)


103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/sc2ai/player/geo.rb', line 103

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:



691
692
693
694
695
696
697
698
699
700
701
702
703
704
# File 'lib/sc2ai/player/geo.rb', line 691

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:



632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
# File 'lib/sc2ai/player/geo.rb', line 632

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:



657
658
659
660
661
662
663
664
665
666
667
668
# File 'lib/sc2ai/player/geo.rb', line 657

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:



36
37
38
# File 'lib/sc2ai/player/geo.rb', line 36

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)


29
30
31
32
# File 'lib/sc2ai/player/geo.rb', line 29

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



42
43
44
# File 'lib/sc2ai/player/geo.rb', line 42

def map_range_x
  0..(map_width)
end

#map_range_yRange

Returns zero to map_height as range

Returns:

  • (Range)

    0 to map_height



48
49
50
# File 'lib/sc2ai/player/geo.rb', line 48

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



359
360
361
# File 'lib/sc2ai/player/geo.rb', line 359

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

#map_tile_range_xRange

Returns zero to map_width-1 as range

Returns:

  • (Range)


54
55
56
# File 'lib/sc2ai/player/geo.rb', line 54

def map_tile_range_x
  0..(map_width - 1)
end

#map_tile_range_yRange

Returns zero to map_height-1 as range

Returns:

  • (Range)


60
61
62
# File 'lib/sc2ai/player/geo.rb', line 60

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



367
368
369
# File 'lib/sc2ai/player/geo.rb', line 367

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



351
352
353
# File 'lib/sc2ai/player/geo.rb', line 351

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)


21
22
23
24
# File 'lib/sc2ai/player/geo.rb', line 21

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:



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
625
626
627
# File 'lib/sc2ai/player/geo.rb', line 598

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_creepNumo::Bit

Provides parsed minimap representation of creep spread Caches for 4 frames

Returns:

  • (Numo::Bit)

    Numo array



396
397
398
399
400
401
402
403
404
405
# File 'lib/sc2ai/player/geo.rb', line 396

def parsed_creep
  if @parsed_creep.nil? || @parsed_creep[1] + 4 < bot.game_loop
    image_data = bot.observation.raw_data.map_state.creep
    # Fix endian for Numo bit parser
    data = image_data.data.unpack("b*").pack("B*")
    result = ::Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x])
    @parsed_creep = [result, bot.game_loop]
  end
  @parsed_creep[0]
end

#parsed_pathing_gridNumo::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



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/sc2ai/player/geo.rb', line 277

def parsed_pathing_grid
  if bot.game_info_stale?
    previous_data = bot.game_info.start_raw.pathing_grid.data
    bot.refresh_game_info
    # Only re-parse if binary strings don't match
    clear_cached_pathing_grid if previous_data != bot.game_info.start_raw.pathing_grid.data
  end

  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_gridNumo::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)


82
83
84
85
86
87
88
89
90
# File 'lib/sc2ai/player/geo.rb', line 82

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_gridNumo::Bit

Returns a grid where powered locations are marked true

Returns:

  • (Numo::Bit)


162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
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
# File 'lib/sc2ai/player/geo.rb', line 162

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_heightNumo::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



322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/sc2ai/player/geo.rb', line 322

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_gridNumo::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:



375
376
377
378
379
380
381
382
# File 'lib/sc2ai/player/geo.rb', line 375

def parsed_visibility_grid
  if @parsed_visibility_grid.nil?
    image_data = bot.observation.raw_data.map_state.visibility
    @parsed_visibility_grid = ::Numo::UInt8.from_binary(image_data.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



266
267
268
# File 'lib/sc2ai/player/geo.rb', line 266

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


73
74
75
# File 'lib/sc2ai/player/geo.rb', line 73

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:



944
945
946
# File 'lib/sc2ai/player/geo.rb', line 944

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:



951
952
953
954
955
956
957
# File 'lib/sc2ai/player/geo.rb', line 951

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:



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
# File 'lib/sc2ai/player/geo.rb', line 901

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



257
258
259
# File 'lib/sc2ai/player/geo.rb', line 257

def powered?(x:, y:)
  parsed_power_grid[y.to_i, x.to_i] != 0
end

#start_positionApi::Point2D

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

Returns:



467
468
469
# File 'lib/sc2ai/player/geo.rb', line 467

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



308
309
310
# File 'lib/sc2ai/player/geo.rb', line 308

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



315
316
317
# File 'lib/sc2ai/player/geo.rb', line 315

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



343
344
345
# File 'lib/sc2ai/player/geo.rb', line 343

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:



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
881
882
883
884
885
886
887
888
889
# File 'lib/sc2ai/player/geo.rb', line 806

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