sc2ai 0.0.0.pre → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/data/data.json +1 -0
- data/data/data_readable.json +22842 -0
- data/data/sc2ai/protocol/common.proto +59 -0
- data/data/sc2ai/protocol/data.proto +120 -0
- data/data/sc2ai/protocol/debug.proto +127 -0
- data/data/sc2ai/protocol/error.proto +221 -0
- data/data/sc2ai/protocol/query.proto +55 -0
- data/data/sc2ai/protocol/raw.proto +202 -0
- data/data/sc2ai/protocol/sc2api.proto +718 -0
- data/data/sc2ai/protocol/score.proto +108 -0
- data/data/sc2ai/protocol/spatial.proto +115 -0
- data/data/sc2ai/protocol/ui.proto +145 -0
- data/data/setup/setup.SC2Map +0 -0
- data/data/setup/setup.SC2Replay +0 -0
- data/data/stableid.json +35730 -0
- data/data/versions.json +554 -0
- data/exe/sc2ai +35 -0
- data/lib/docker_build/Dockerfile.ruby +74 -0
- data/lib/docker_build/docker-compose-base-image.yml +10 -0
- data/lib/docker_build/docker-compose-ladderzip.yml +9 -0
- data/lib/sc2ai/api/ability_id.rb +1644 -0
- data/lib/sc2ai/api/buff_id.rb +306 -0
- data/lib/sc2ai/api/data.rb +101 -0
- data/lib/sc2ai/api/effect_id.rb +20 -0
- data/lib/sc2ai/api/tech_tree.rb +83 -0
- data/lib/sc2ai/api/tech_tree_data.rb +2338 -0
- data/lib/sc2ai/api/unit_type_id.rb +2022 -0
- data/lib/sc2ai/api/upgrade_id.rb +310 -0
- data/lib/sc2ai/cli/cli.rb +175 -0
- data/lib/sc2ai/cli/ladderzip.rb +154 -0
- data/lib/sc2ai/cli/new.rb +88 -0
- data/lib/sc2ai/configuration.rb +145 -0
- data/lib/sc2ai/connection/connection_listener.rb +30 -0
- data/lib/sc2ai/connection/requests.rb +417 -0
- data/lib/sc2ai/connection/status_listener.rb +15 -0
- data/lib/sc2ai/connection.rb +146 -0
- data/lib/sc2ai/local_play/client/configurable_options.rb +115 -0
- data/lib/sc2ai/local_play/client.rb +159 -0
- data/lib/sc2ai/local_play/client_manager.rb +70 -0
- data/lib/sc2ai/local_play/map_file.rb +48 -0
- data/lib/sc2ai/local_play/match.rb +184 -0
- data/lib/sc2ai/overrides/array.rb +14 -0
- data/lib/sc2ai/overrides/async/process/child.rb +31 -0
- data/lib/sc2ai/overrides/kernel.rb +33 -0
- data/lib/sc2ai/paths.rb +294 -0
- data/lib/sc2ai/player/actions.rb +386 -0
- data/lib/sc2ai/player/debug.rb +224 -0
- data/lib/sc2ai/player/game_state.rb +131 -0
- data/lib/sc2ai/player/geometry.rb +766 -0
- data/lib/sc2ai/player/previous_state.rb +49 -0
- data/lib/sc2ai/player/units.rb +337 -0
- data/lib/sc2ai/player.rb +661 -0
- data/lib/sc2ai/ports.rb +152 -0
- data/lib/sc2ai/protocol/_meta_documentation.rb +39 -0
- data/lib/sc2ai/protocol/common_pb.rb +43 -0
- data/lib/sc2ai/protocol/data_pb.rb +47 -0
- data/lib/sc2ai/protocol/debug_pb.rb +56 -0
- data/lib/sc2ai/protocol/error_pb.rb +36 -0
- data/lib/sc2ai/protocol/extensions/color.rb +20 -0
- data/lib/sc2ai/protocol/extensions/point.rb +23 -0
- data/lib/sc2ai/protocol/extensions/point_2_d.rb +26 -0
- data/lib/sc2ai/protocol/extensions/position.rb +202 -0
- data/lib/sc2ai/protocol/extensions/power_source.rb +19 -0
- data/lib/sc2ai/protocol/extensions/unit.rb +489 -0
- data/lib/sc2ai/protocol/query_pb.rb +47 -0
- data/lib/sc2ai/protocol/raw_pb.rb +57 -0
- data/lib/sc2ai/protocol/sc2api_pb.rb +130 -0
- data/lib/sc2ai/protocol/score_pb.rb +40 -0
- data/lib/sc2ai/protocol/spatial_pb.rb +48 -0
- data/lib/sc2ai/protocol/ui_pb.rb +56 -0
- data/lib/sc2ai/unit_group/action_ext.rb +74 -0
- data/lib/sc2ai/unit_group/filter_ext.rb +379 -0
- data/lib/sc2ai/unit_group.rb +277 -0
- data/lib/sc2ai/version.rb +2 -1
- data/lib/sc2ai.rb +93 -2
- data/lib/templates/ladderzip/bin/ladder.tt +23 -0
- data/lib/templates/new/.ladderignore +20 -0
- data/lib/templates/new/Gemfile.tt +7 -0
- data/lib/templates/new/api/common.proto +59 -0
- data/lib/templates/new/api/data.proto +120 -0
- data/lib/templates/new/api/debug.proto +127 -0
- data/lib/templates/new/api/error.proto +221 -0
- data/lib/templates/new/api/query.proto +55 -0
- data/lib/templates/new/api/raw.proto +202 -0
- data/lib/templates/new/api/sc2api.proto +718 -0
- data/lib/templates/new/api/score.proto +108 -0
- data/lib/templates/new/api/spatial.proto +115 -0
- data/lib/templates/new/api/ui.proto +145 -0
- data/lib/templates/new/boot.rb.tt +6 -0
- data/lib/templates/new/my_bot.rb.tt +23 -0
- data/lib/templates/new/run_example_match.rb.tt +14 -0
- data/sc2ai.gemspec +80 -0
- metadata +344 -13
@@ -0,0 +1,766 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rumale/clustering/dbscan"
|
4
|
+
require "rumale/pairwise_metric"
|
5
|
+
|
6
|
+
module Sc2
|
7
|
+
class Player
|
8
|
+
# Holds map and geography helper functions
|
9
|
+
class Geometry
|
10
|
+
# @!attribute Holds the parent bot object
|
11
|
+
# @return [Sc2::Player] player with active connection
|
12
|
+
attr_accessor :bot
|
13
|
+
|
14
|
+
def initialize(bot)
|
15
|
+
@bot = bot
|
16
|
+
end
|
17
|
+
|
18
|
+
# Gets the map tile width. Range is 1-255.
|
19
|
+
# Effected by crop_to_playable_area
|
20
|
+
# @return [Integer]
|
21
|
+
def map_width
|
22
|
+
# bot.bot.game_info
|
23
|
+
bot.game_info.start_raw.map_size.x
|
24
|
+
end
|
25
|
+
|
26
|
+
# Gets the map tile height. Range is 1-255.
|
27
|
+
# Effected by crop_to_playable_area
|
28
|
+
# @return [Integer]
|
29
|
+
def map_height
|
30
|
+
# bot.bot.game_info
|
31
|
+
bot.game_info.start_raw.map_size.y
|
32
|
+
end
|
33
|
+
|
34
|
+
# Returns zero to map_width as range
|
35
|
+
# @return [Range] 0 to map_width
|
36
|
+
def map_range_x
|
37
|
+
0..(map_width)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Returns zero to map_height as range
|
41
|
+
# @return [Range] 0 to map_height
|
42
|
+
def map_range_y
|
43
|
+
0..(map_height)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns zero to map_width-1 as range
|
47
|
+
# @return [Range]
|
48
|
+
def map_tile_range_x
|
49
|
+
0..(map_width - 1)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Returns zero to map_height-1 as range
|
53
|
+
# @return [Range]
|
54
|
+
def map_tile_range_y
|
55
|
+
0..(map_height - 1)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Map Parsing functions -----
|
59
|
+
|
60
|
+
# Returns whether a x/y (integer) is placeable as per minimap image data.
|
61
|
+
# It does not say whether a position is occupied by another building.
|
62
|
+
# One pixel covers one whole block. Corrects floats on your behalf
|
63
|
+
# @param x [Float, Integer]
|
64
|
+
# @param y [Float, Integer]
|
65
|
+
# @return [Boolean] whether tile is placeable?
|
66
|
+
# @see Sc2::Player#pathable? for detecting obstructions
|
67
|
+
def placeable?(x:, y:)
|
68
|
+
parsed_placement_grid[y.to_i, x.to_i] != 0
|
69
|
+
end
|
70
|
+
|
71
|
+
# Returns a parsed placement_grid from bot.game_info.start_raw.
|
72
|
+
# Each value in [row][column] holds a boolean value represented as an integer
|
73
|
+
# It does not say whether a position is occupied by another building.
|
74
|
+
# One pixel covers one whole block. Rounds fractionated positions down.
|
75
|
+
def parsed_placement_grid
|
76
|
+
if @parsed_placement_grid.nil?
|
77
|
+
image_data = bot.game_info.start_raw.placement_grid
|
78
|
+
# Fix endian for Numo bit parser
|
79
|
+
data = image_data.data.unpack("b*").pack("B*")
|
80
|
+
@parsed_placement_grid = ::Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x])
|
81
|
+
end
|
82
|
+
@parsed_placement_grid
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns a grid where ony the expo locations are marked
|
86
|
+
def expo_placement_grid
|
87
|
+
if @expo_placement_grid.nil?
|
88
|
+
@expo_placement_grid = Numo::Bit.zeros(map_height, map_width)
|
89
|
+
expansion_points.each do |point|
|
90
|
+
x = point.x.floor
|
91
|
+
y = point.y.floor
|
92
|
+
@expo_placement_grid[(y - 2).clamp(map_tile_range_y)..(y + 2).clamp(map_tile_range_y),
|
93
|
+
(x - 2).clamp(map_tile_range_y)..(x + 2).clamp(map_tile_range_y)] = 1
|
94
|
+
end
|
95
|
+
end
|
96
|
+
@expo_placement_grid
|
97
|
+
end
|
98
|
+
|
99
|
+
# Returns a grid where powered locations are marked true
|
100
|
+
def parsed_power_grid
|
101
|
+
# Cache for based on power unit tags
|
102
|
+
cache_key = bot.power_sources.map(&:tag).sort.hash
|
103
|
+
return @parsed_power_grid[0] if !@parsed_power_grid.nil? && @parsed_power_grid[1] == cache_key
|
104
|
+
|
105
|
+
result = Numo::Bit.zeros(map_height, map_width)
|
106
|
+
power_source = bot.power_sources.first
|
107
|
+
if power_source.nil?
|
108
|
+
@parsed_power_grid = [result, cache_key]
|
109
|
+
return result
|
110
|
+
end
|
111
|
+
|
112
|
+
radius = power_source.radius
|
113
|
+
radius_tile = radius.ceil
|
114
|
+
|
115
|
+
# Keep this code-block, should we need to make power sources dynamic again:
|
116
|
+
# START: Dynamic blueprint
|
117
|
+
# # Build a blueprint and mark it everywhere we need to
|
118
|
+
# # Lets mark everything as powered with 1 and then disable non-powered with a 0
|
119
|
+
# blueprint = Numo::Bit.ones(radius.ceil * 2, radius.ceil * 2)
|
120
|
+
# #blueprint[radius_tile, radius_tile] = 0
|
121
|
+
# blueprint[(radius_tile - 1)..radius_tile, (radius_tile - 1)..radius_tile] = 0
|
122
|
+
# # Loop over top-right quadrant of a circle, so we don't have to +/- for distance calcs.
|
123
|
+
# # Additionally, we only measure if in the upper triangle, since the inner is all inside the circle.
|
124
|
+
# # Then apply to all four quadrants.
|
125
|
+
# quadrant_size = radius_tile - 1
|
126
|
+
# point_search_offsets = (0..quadrant_size).to_a.product((0..quadrant_size).to_a)
|
127
|
+
# point_search_offsets.each do |y, x|
|
128
|
+
# next if x < quadrant_size - y # Only upper Triangle
|
129
|
+
#
|
130
|
+
# dist = Math.hypot(x, y)
|
131
|
+
# if dist >= radius
|
132
|
+
# # Mark as outside x4
|
133
|
+
# blueprint[radius_tile + y, radius_tile + x] = 0
|
134
|
+
# blueprint[radius_tile + y, radius_tile - 1 - x] = 0
|
135
|
+
# blueprint[radius_tile - 1 - y, radius_tile + x] = 0
|
136
|
+
# blueprint[radius_tile - 1 - y, radius_tile - 1 - x] = 0
|
137
|
+
# end
|
138
|
+
# end
|
139
|
+
# END: Dynamic blueprint ---
|
140
|
+
|
141
|
+
# Hard-coding this shape for pylon power
|
142
|
+
# 00001111110000
|
143
|
+
# 00111111111100
|
144
|
+
# 01111111111110
|
145
|
+
# 01111111111110
|
146
|
+
# 11111111111111
|
147
|
+
# 11111111111111
|
148
|
+
# 11111100111111
|
149
|
+
# 11111100111111
|
150
|
+
# 11111111111111
|
151
|
+
# 11111111111111
|
152
|
+
# 01111111111110
|
153
|
+
# 01111111111110
|
154
|
+
# 00111111111100
|
155
|
+
# 00001111110000
|
156
|
+
|
157
|
+
# perf: Saving pre-created shape for speed (0.5ms saved) by using hardcode from .to_binary.unpack("C*")
|
158
|
+
blueprint_data = [240, 3, 255, 227, 255, 249, 127, 255, 255, 255, 255, 243, 255, 252, 255, 255, 255, 239, 255, 249, 127, 252, 15, 252, 0].pack("C*")
|
159
|
+
blueprint = ::Numo::Bit.from_binary(blueprint_data, [radius_tile * 2, radius_tile * 2])
|
160
|
+
|
161
|
+
bot.power_sources.each do |ps|
|
162
|
+
x_tile = ps.pos.x.floor
|
163
|
+
y_tile = ps.pos.y.floor
|
164
|
+
replace_start_x = (x_tile - radius_tile)
|
165
|
+
replace_end_x = (x_tile + radius_tile - 1)
|
166
|
+
replace_start_y = (y_tile - radius_tile)
|
167
|
+
replace_end_y = (y_tile + radius_tile - 1)
|
168
|
+
bp_start_x = bp_start_y = 0
|
169
|
+
bp_end_x = bp_end_y = blueprint.shape[0] - 1
|
170
|
+
|
171
|
+
# Laborious clamping if blueprint goes over edge
|
172
|
+
if replace_start_x < 0
|
173
|
+
bp_start_x += replace_start_x
|
174
|
+
replace_start_x = 0
|
175
|
+
elsif replace_end_x >= map_width
|
176
|
+
bp_end_x += map_width - replace_end_x - 1
|
177
|
+
replace_end_x = map_width - 1
|
178
|
+
end
|
179
|
+
if replace_start_y < 0
|
180
|
+
bp_start_y += replace_start_y
|
181
|
+
replace_start_y = 0
|
182
|
+
elsif replace_end_y >= map_height
|
183
|
+
bp_end_y += map_height - replace_end_y - 1
|
184
|
+
replace_end_y = map_height - 1
|
185
|
+
end
|
186
|
+
|
187
|
+
# Bitwise OR because previous pylons could overlap
|
188
|
+
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]
|
189
|
+
end
|
190
|
+
bot.power_sources.each do |ps|
|
191
|
+
# For pylons, remove pylon location on ground
|
192
|
+
next if bot.structures.pylons[ps.tag].nil?
|
193
|
+
result[(ps.pos.y.floor - 1)..ps.pos.y.floor, (ps.pos.x.floor - 1)..ps.pos.x.floor] = 0
|
194
|
+
end
|
195
|
+
@parsed_power_grid = [result, cache_key]
|
196
|
+
result
|
197
|
+
end
|
198
|
+
|
199
|
+
# Returns whether a x/y block is powered. Only fully covered blocks are true.
|
200
|
+
# One pixel covers one whole block. Corrects float inputs on your behalf.
|
201
|
+
# @param x [Float, Integer]
|
202
|
+
# @param y [Float, Integer]
|
203
|
+
# @return [Boolean] true if location is powered
|
204
|
+
def powered?(x:, y:)
|
205
|
+
parsed_creep[y.to_i, x.to_i] != 0
|
206
|
+
end
|
207
|
+
|
208
|
+
# Returns whether a x/y block is pathable as per minimap
|
209
|
+
# One pixel covers one whole block. Corrects float inputs on your behalf.
|
210
|
+
# @param x [Float, Integer]
|
211
|
+
# @param y [Float, Integer]
|
212
|
+
# @return [Boolean] whether tile is patahble
|
213
|
+
def pathable?(x:, y:)
|
214
|
+
parsed_pathing_grid[y.to_i, x.to_i] != 0
|
215
|
+
end
|
216
|
+
|
217
|
+
# Gets the pathable areas as things stand right now in the game
|
218
|
+
# Buildings, minerals, structures, etc. all result in a nonpathable place
|
219
|
+
# @example
|
220
|
+
# parsed_pathing_grid[0,0] # reads bottom left corner
|
221
|
+
# # use helper function #pathable
|
222
|
+
# pathable?(x: 0, y: 0) # reads bottom left corner
|
223
|
+
# @return [Numo::Bit] Numo array
|
224
|
+
def parsed_pathing_grid
|
225
|
+
if bot.game_info_stale?
|
226
|
+
previous_data = bot.game_info.start_raw.pathing_grid.data
|
227
|
+
bot.refresh_game_info
|
228
|
+
# Only re-parse if binary strings don't match
|
229
|
+
clear_cached_pathing_grid if previous_data != bot.game_info.start_raw.pathing_grid.data
|
230
|
+
end
|
231
|
+
|
232
|
+
if @parsed_pathing_grid.nil?
|
233
|
+
image_data = bot.game_info.start_raw.pathing_grid
|
234
|
+
# Fix endian for Numo bit parser
|
235
|
+
data = image_data.data.unpack("b*").pack("B*")
|
236
|
+
@parsed_pathing_grid = ::Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x])
|
237
|
+
end
|
238
|
+
@parsed_pathing_grid
|
239
|
+
end
|
240
|
+
|
241
|
+
# Clears pathing-grid dependent objects like placements.
|
242
|
+
# Called when pathing grid gets updated
|
243
|
+
#
|
244
|
+
private def clear_cached_pathing_grid
|
245
|
+
@parsed_pathing_grid = nil
|
246
|
+
@_build_coordinates = {}
|
247
|
+
@_build_coordinate_tree = {}
|
248
|
+
end
|
249
|
+
|
250
|
+
# Returns the terrain height (z) at position x and y
|
251
|
+
# Granularity is per placement grid block, since this comes from minimap image data.
|
252
|
+
# @param x [Float, Integer]
|
253
|
+
# @param y [Float, Integer]
|
254
|
+
# @return [Float] z axis position between -16 and 16
|
255
|
+
def terrain_height(x:, y:)
|
256
|
+
parsed_terrain_height[y.to_i, x.to_i]
|
257
|
+
end
|
258
|
+
|
259
|
+
# Returns a parsed terrain_height from bot.game_info.start_raw.
|
260
|
+
# Each value in [row][column] holds a float value which is the z height
|
261
|
+
# @return [Numo::SFloat] Numo array
|
262
|
+
def parsed_terrain_height
|
263
|
+
if @parsed_terrain_height.nil?
|
264
|
+
|
265
|
+
image_data = bot.game_info.start_raw.terrain_height
|
266
|
+
@parsed_terrain_height = ::Numo::UInt8.from_binary(image_data.data,
|
267
|
+
[image_data.size.y, image_data.size.x])
|
268
|
+
.cast_to(Numo::SFloat)
|
269
|
+
|
270
|
+
# Values are between -16 and +16. The api values is a float height compressed to rgb range (0-255) in that range of 32.
|
271
|
+
# real_height = -16 + (value / 255) * 32
|
272
|
+
# These are the least bulk operations while still letting Numo run the loops:
|
273
|
+
@parsed_terrain_height *= (32.0 / 255.0)
|
274
|
+
@parsed_terrain_height -= 16.0
|
275
|
+
end
|
276
|
+
@parsed_terrain_height
|
277
|
+
end
|
278
|
+
|
279
|
+
# Returns one of three Integer visibility indicators at tile for x & y
|
280
|
+
# @param x [Float, Integer]
|
281
|
+
# @param y [Float, Integer]
|
282
|
+
# @return [Integer] 0=Hidden,1= Snapshot,2=Visible
|
283
|
+
def visibility(x:, y:)
|
284
|
+
parsed_visibility_grid[y.to_i, x.to_i]
|
285
|
+
end
|
286
|
+
|
287
|
+
# Returns whether the point (tile) is currently in vision
|
288
|
+
# @param x [Float, Integer]
|
289
|
+
# @param y [Float, Integer]
|
290
|
+
# @return [Boolean] true if fog is completely lifted
|
291
|
+
def map_visible?(x:, y:)
|
292
|
+
visibility(x:, y:) == 2
|
293
|
+
end
|
294
|
+
|
295
|
+
# Returns whether point (tile) has been seen before or currently visible
|
296
|
+
# @param x [Float, Integer]
|
297
|
+
# @param y [Float, Integer]
|
298
|
+
# @return [Boolean] true if partially or fully lifted fog
|
299
|
+
def map_seen?(x:, y:)
|
300
|
+
visibility(x:, y:) != 0
|
301
|
+
end
|
302
|
+
|
303
|
+
# Returns whether the point (tile) has never been seen/explored before (dark fog)
|
304
|
+
# @param x [Float, Integer]
|
305
|
+
# @param y [Float, Integer]
|
306
|
+
# @return [Boolean] true if fog of war is fully dark
|
307
|
+
def map_unseen?(x:, y:)
|
308
|
+
!map_seen?(x:, y:)
|
309
|
+
end
|
310
|
+
|
311
|
+
# Returns a parsed map_state.visibility from bot.observation.raw_data.
|
312
|
+
# Each value in [row][column] holds one of three integers (0,1,2) to flag a vision type
|
313
|
+
# @see #visibility for reading from this value
|
314
|
+
# @return [Numo::SFloat] Numo array
|
315
|
+
def parsed_visibility_grid
|
316
|
+
if @parsed_visibility_grid.nil?
|
317
|
+
image_data = bot.observation.raw_data.map_state.visibility
|
318
|
+
@parsed_visibility_grid = ::Numo::UInt8.from_binary(image_data.data,
|
319
|
+
[image_data.size.y, image_data.size.x])
|
320
|
+
end
|
321
|
+
@parsed_visibility_grid
|
322
|
+
end
|
323
|
+
|
324
|
+
# Returns whether a tile has creep on it, as per minimap
|
325
|
+
# One pixel covers one whole block. Corrects float inputs on your behalf.
|
326
|
+
# @param x [Float, Integer]
|
327
|
+
# @param y [Float, Integer]
|
328
|
+
# @return [Boolean] true if location has creep on it
|
329
|
+
def creep?(x:, y:)
|
330
|
+
parsed_creep[y.to_i, x.to_i] != 0
|
331
|
+
end
|
332
|
+
|
333
|
+
# Provides parsed minimap representation of creep spread
|
334
|
+
# @return [Numo::Bit] Numo array
|
335
|
+
def parsed_creep
|
336
|
+
if @parsed_creep.nil?
|
337
|
+
image_data = bot.observation.raw_data.map_state.creep
|
338
|
+
# Fix endian for Numo bit parser
|
339
|
+
data = image_data.data.unpack("b*").pack("B*")
|
340
|
+
@parsed_creep = ::Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x])
|
341
|
+
end
|
342
|
+
@parsed_creep
|
343
|
+
end
|
344
|
+
|
345
|
+
# TODO: Removing. Better name or more features for this? Maybe check nearest units.
|
346
|
+
# Checks map data for placeability (without querying api)
|
347
|
+
# You can manually query this instead for potential better results with
|
348
|
+
# @see Sc2::Connection::Requests#query_placements for a slow (5ms) alternative
|
349
|
+
# @return [Boolean] whether coordinate is placeable and pathable
|
350
|
+
# def can_place?(x:, y:)
|
351
|
+
# placeable?(x: x, y: y) && pathable?(x: x, y: y)
|
352
|
+
# end
|
353
|
+
|
354
|
+
# TODO: Remove this method if it has no use. Build points uses this code directly for optimization.
|
355
|
+
# Reduce the dimensions of a grid by merging cells using length x length squares.
|
356
|
+
# Merged cell keeps it's 1 value only if all merged cells are equal to 1, else 0
|
357
|
+
# @param input_grid [Numo::Bit] Bit grid like parsed_pathing_grid or parsed_placement_grid
|
358
|
+
# @param length [Integer] how many cells to merge, i.e. 3 for finding 3x3 placement
|
359
|
+
def divide_grid(input_grid, length)
|
360
|
+
height = input_grid.shape[0]
|
361
|
+
width = input_grid.shape[1]
|
362
|
+
|
363
|
+
new_height = height / length
|
364
|
+
new_width = width / length
|
365
|
+
|
366
|
+
# Assume everything is placeable. We will check and set 0's below
|
367
|
+
output_grid = Numo::Bit.ones(new_height, new_width)
|
368
|
+
|
369
|
+
# divide map into tile length and remove remainder blocks
|
370
|
+
capped_height = new_height * length
|
371
|
+
capped_width = new_width * length
|
372
|
+
|
373
|
+
# These loops are all structured this way, because of speed.
|
374
|
+
y = 0
|
375
|
+
while y < capped_height
|
376
|
+
x = 0
|
377
|
+
while x < capped_width
|
378
|
+
# We are on the bottom-left of a placement tile of Length x Length
|
379
|
+
# Check right- and upwards for any negatives and break both loops, as soon as we find one
|
380
|
+
inner_y = 0
|
381
|
+
while inner_y < length
|
382
|
+
inner_x = 0
|
383
|
+
while inner_x < length
|
384
|
+
if (input_grid[y + inner_y, x + inner_x]).zero?
|
385
|
+
output_grid[y / length, x / length] = 0
|
386
|
+
inner_y = length
|
387
|
+
break
|
388
|
+
end
|
389
|
+
inner_x += 1
|
390
|
+
end
|
391
|
+
inner_y += 1
|
392
|
+
end
|
393
|
+
# End of checking sub-cells
|
394
|
+
|
395
|
+
x += length
|
396
|
+
end
|
397
|
+
y += length
|
398
|
+
end
|
399
|
+
output_grid
|
400
|
+
end
|
401
|
+
|
402
|
+
# Gets expos and surrounding minerals
|
403
|
+
# The index is a build location for an expo and the value is a UnitGroup, which has minerals and geysers
|
404
|
+
# @example
|
405
|
+
# random_expo = expansions.keys.sample #=> Point2D
|
406
|
+
# expo_resources = geo.expansions[random_expo] #=> UnitGroup
|
407
|
+
# alive_minerals = expo_resources.minerals - neutral.minerals
|
408
|
+
# geysers = expo_resources.geysers
|
409
|
+
# @return [Hash<Api::Point2D, UnitGroup>] Location => UnitGroup of resources (minerals+geysers)
|
410
|
+
def expansions
|
411
|
+
return @expansions unless @expansions.nil?
|
412
|
+
|
413
|
+
@expansions = {}
|
414
|
+
|
415
|
+
# An array of offsets to search around the center of resource cluster for points
|
416
|
+
point_search_offsets = (-7..7).to_a.product((-7..7).to_a)
|
417
|
+
point_search_offsets.select! do |x, y|
|
418
|
+
dist = Math.hypot(x, y)
|
419
|
+
dist > 4 && dist <= 8
|
420
|
+
end
|
421
|
+
|
422
|
+
# Split resources by Z axis
|
423
|
+
resources = bot.neutral.minerals + bot.neutral.geysers
|
424
|
+
resource_group_z = resources.group_by do |resource|
|
425
|
+
resource.pos.z.round # 32 units of Y, most maps will have use 3. round to nearest.
|
426
|
+
end
|
427
|
+
|
428
|
+
# Cluster over every z level
|
429
|
+
resource_group_z.map do |z, resource_group|
|
430
|
+
# Convert group into numo array of 2d points
|
431
|
+
positions = Numo::DFloat.zeros(resource_group.size, 2)
|
432
|
+
resource_group.each_with_index do |res, index|
|
433
|
+
positions[index, 0] = res.pos.x
|
434
|
+
positions[index, 1] = res.pos.y
|
435
|
+
end
|
436
|
+
# Max 8.5 distance apart for nodes, else it's noise. At least 4 resources for an expo
|
437
|
+
analyzer = Rumale::Clustering::DBSCAN.new(eps: 8.5, min_samples: 4)
|
438
|
+
cluster_marks = analyzer.fit_predict(positions)
|
439
|
+
|
440
|
+
# for each cluster, grab those indexes to reference the mineral/gas
|
441
|
+
# then work out a placeable position based on their locations
|
442
|
+
(0..cluster_marks.max).each do |cluster_index|
|
443
|
+
clustered_resources = resource_group.select.with_index { |_res, i| cluster_marks[i] == cluster_index }
|
444
|
+
possible_points = {}
|
445
|
+
|
446
|
+
# Grab center of clustered
|
447
|
+
avg_x = clustered_resources.sum { |res| res.pos.x } / clustered_resources.size
|
448
|
+
avg_y = clustered_resources.sum { |res| res.pos.y } / clustered_resources.size
|
449
|
+
# Round average spot to nearest 0.5 point, since HQ center is at half measure (5 wide)
|
450
|
+
avg_x = avg_x.round + 0.5
|
451
|
+
avg_y = avg_y.round + 0.5
|
452
|
+
|
453
|
+
points_length = point_search_offsets.length
|
454
|
+
i = 0
|
455
|
+
while i < points_length
|
456
|
+
x = point_search_offsets[i][0] + avg_x
|
457
|
+
y = point_search_offsets[i][1] + avg_y
|
458
|
+
|
459
|
+
if !map_tile_range_x.include?(x + 1) || !map_tile_range_y.include?(y + 1)
|
460
|
+
i += 1
|
461
|
+
next
|
462
|
+
end
|
463
|
+
|
464
|
+
if parsed_placement_grid[y.floor, x.floor].zero?
|
465
|
+
i += 1
|
466
|
+
next
|
467
|
+
end
|
468
|
+
|
469
|
+
# Compare this point to each resource to ensure it's far enough away
|
470
|
+
distance_sum = 0
|
471
|
+
valid_min_distance = clustered_resources.all? do |res|
|
472
|
+
dist = Math.hypot(res.pos.x - x, res.pos.y - y)
|
473
|
+
if Sc2::UnitGroup::TYPE_GEYSER.include?(res.unit_type)
|
474
|
+
min_distance = 7
|
475
|
+
distance_sum += (dist / 7.0) * dist
|
476
|
+
else
|
477
|
+
min_distance = 6
|
478
|
+
distance_sum += dist
|
479
|
+
end
|
480
|
+
dist >= min_distance
|
481
|
+
end
|
482
|
+
possible_points[[x, y]] = distance_sum if valid_min_distance
|
483
|
+
|
484
|
+
i += 1
|
485
|
+
end
|
486
|
+
# Choose best fitting point
|
487
|
+
best_point = possible_points.keys[possible_points.values.find_index(possible_points.values.min)]
|
488
|
+
@expansions[best_point.to_p2d] = UnitGroup.new(clustered_resources)
|
489
|
+
end
|
490
|
+
end
|
491
|
+
@expansions
|
492
|
+
end
|
493
|
+
|
494
|
+
# Returns a list of 2d points for expansion build locations
|
495
|
+
# Does not contain mineral info, but the value can be checked against geo.expansions
|
496
|
+
#
|
497
|
+
# @example
|
498
|
+
# random_expo = expansion_points.sample
|
499
|
+
# expo_resources = geo.expansions[random_expo]
|
500
|
+
# @return [Array<Api::Point2D>] points where expansions can be placed
|
501
|
+
def expansion_points
|
502
|
+
expansions.keys
|
503
|
+
end
|
504
|
+
|
505
|
+
# Returns a slice of #expansions where a base hasn't been built yet
|
506
|
+
# @example
|
507
|
+
# # Lets find the nearest unoccupied expo
|
508
|
+
# expo_pos = expansions_unoccupied.keys.min { |p2d| p2d.distance_to(structures.hq.first) }
|
509
|
+
# # What minerals/geysers does it have?
|
510
|
+
# puts expansions_unoccupied[expo_pos].minerals # or expansions[expo_pos]... => UnitGroup
|
511
|
+
# puts expansions_unoccupied[expo_pos].geysers # or expansions[expo_pos]... => UnitGroup
|
512
|
+
# @return [Hash<Api::Point2D], UnitGroup] Location => UnitGroup of resources (minerals+geysers)
|
513
|
+
def expansions_unoccupied
|
514
|
+
taken_bases = bot.structures.hq.map { |hq| hq.pos.to_p2d } + bot.enemy.structures.hq.map { |hq| hq.pos.to_p2d }
|
515
|
+
remaining_points = expansion_points - taken_bases
|
516
|
+
expansions.slice(*remaining_points)
|
517
|
+
end
|
518
|
+
|
519
|
+
# Gets buildable point grid for squares of size, i.e. 3 = 3x3 placements
|
520
|
+
# Uses pathing grid internally, to ignore taken positions
|
521
|
+
# Does not query the api and is generally fast.
|
522
|
+
# @param length [Integer] length of the building, 2 for depot/pylon, 3 for rax/gate
|
523
|
+
# @param on_creep [Boolean] whether this build locatin should be on creep
|
524
|
+
def build_coordinates(length:, on_creep: false, in_power: false)
|
525
|
+
length = 1 if length < 1
|
526
|
+
@_build_coordinates ||= {}
|
527
|
+
cache_key = [length, on_creep].hash
|
528
|
+
return @_build_coordinates[cache_key] if !@_build_coordinates[cache_key].nil? && !bot.game_info_stale?
|
529
|
+
|
530
|
+
result = []
|
531
|
+
input_grid = parsed_pathing_grid & parsed_placement_grid & ~expo_placement_grid
|
532
|
+
input_grid = parsed_creep & input_grid if on_creep
|
533
|
+
input_grid = parsed_power_grid & input_grid if in_power
|
534
|
+
|
535
|
+
# Dimensions
|
536
|
+
height = input_grid.shape[0]
|
537
|
+
width = input_grid.shape[1]
|
538
|
+
|
539
|
+
# divide map into tile length and remove remainder blocks
|
540
|
+
capped_height = height / length * length
|
541
|
+
capped_width = width / length * length
|
542
|
+
|
543
|
+
# Build points are in center of square, i.e. 1.5 inwards for a 3x3 building
|
544
|
+
offset_to_inside = length / 2.0
|
545
|
+
|
546
|
+
# Note, these loops are structured for speed
|
547
|
+
y = 0
|
548
|
+
while y < capped_height
|
549
|
+
x = 0
|
550
|
+
while x < capped_width
|
551
|
+
# We are on the bottom-left of a placement tile of Length x Length
|
552
|
+
# Check right- and upwards for any negatives and break both loops, as soon as we find one
|
553
|
+
valid_position = true
|
554
|
+
inner_y = 0
|
555
|
+
while inner_y < length
|
556
|
+
inner_x = 0
|
557
|
+
while inner_x < length
|
558
|
+
if (input_grid[y + inner_y, x + inner_x]).zero?
|
559
|
+
# break sub-cells check and don't save position
|
560
|
+
valid_position = false
|
561
|
+
inner_y = length
|
562
|
+
break
|
563
|
+
end
|
564
|
+
inner_x += 1
|
565
|
+
end
|
566
|
+
inner_y += 1
|
567
|
+
end
|
568
|
+
# End of checking sub-cells
|
569
|
+
|
570
|
+
result << [x + offset_to_inside, y + offset_to_inside] if valid_position
|
571
|
+
x += length
|
572
|
+
end
|
573
|
+
y += length
|
574
|
+
end
|
575
|
+
@_build_coordinates[cache_key] = result
|
576
|
+
end
|
577
|
+
|
578
|
+
# Gets a buildable location for a square of length, near target. Chooses from random amount of nearest locations.
|
579
|
+
# 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.
|
580
|
+
# For zerg, the buildable locations are only on creep.
|
581
|
+
# Internally creates a kdtree for building locations based on pathable, placeable and creep
|
582
|
+
# @param length [Integer] length of the building, 2 for depot/pylon, 3 for rax/gate
|
583
|
+
# @param target [Api::Unit, Sc2::Position] near where to find a placement
|
584
|
+
# @param random [Integer] number of nearest points to randomly choose from. 1 for nearest point.
|
585
|
+
# @return [Api::Point2D, nil] buildable location, nil if no buildable location found
|
586
|
+
def build_placement_near(length:, target:, random: 1)
|
587
|
+
target = target.pos if target.is_a? Api::Unit
|
588
|
+
random = 1 if random.to_i.negative?
|
589
|
+
length = 1 if length < 1
|
590
|
+
on_creep = bot.race == Api::Race::Zerg
|
591
|
+
|
592
|
+
coordinates = build_coordinates(length:, on_creep:)
|
593
|
+
@_build_coordinate_tree ||= {}
|
594
|
+
cache_key = [length, on_creep].hash
|
595
|
+
if @_build_coordinate_tree[cache_key].nil?
|
596
|
+
@_build_coordinate_tree[cache_key] = Kdtree.new(
|
597
|
+
coordinates.each_with_index.map { |coords, index| coords + [index] }
|
598
|
+
)
|
599
|
+
end
|
600
|
+
nearest = @_build_coordinate_tree[cache_key].nearestk(target.x, target.y, random)
|
601
|
+
return nil if nearest.nil?
|
602
|
+
|
603
|
+
coordinates[nearest.sample].to_p2d
|
604
|
+
end
|
605
|
+
|
606
|
+
# Protoss ------
|
607
|
+
|
608
|
+
# Draws a grid within a unit (pylon/prisms) radius, then selects points which are placeable
|
609
|
+
# @param source [Api::Unit] either a pylon or a prism
|
610
|
+
# @param unit_type_id [Api::Unit] optionally, the unit you wish to place. Stalkers are widest, so use default nil for a mixed composition warp
|
611
|
+
# @return [Array<Api::Point2D>] an array of 2d points where theoretically placeable
|
612
|
+
def warp_points(source:, unit_type_id: nil)
|
613
|
+
# power source needed
|
614
|
+
power_source = bot.power_sources.find { |ps| source.tag == ps.tag }
|
615
|
+
return [] if power_source.nil?
|
616
|
+
|
617
|
+
# hardcoded unit radius, otherwise only obtainable by owning a unit already
|
618
|
+
unit_type_id = Api::UnitTypeId::STALKER if unit_type_id.nil?
|
619
|
+
target_radius = case unit_type_id
|
620
|
+
when Api::UnitTypeId::STALKER
|
621
|
+
0.625
|
622
|
+
when Api::UnitTypeId::HIGHTEMPLAR, Api::UnitTypeId::DARKTEMPLAR
|
623
|
+
0.375
|
624
|
+
else
|
625
|
+
0.5 # Adept, zealot, sentry, etc.
|
626
|
+
end
|
627
|
+
unit_width = target_radius * 2
|
628
|
+
|
629
|
+
# power source's inner and outer radius
|
630
|
+
outer_radius = power_source.radius
|
631
|
+
# Can not spawn on-top of pylon
|
632
|
+
inner_radius = (source.unit_type == Api::UnitTypeId::PYLON) ? source.radius : 0
|
633
|
+
|
634
|
+
# Make a grid of circles packed in triangle formation, covering the power field
|
635
|
+
points = []
|
636
|
+
y_increment = Math.sqrt(Math.hypot(unit_width, unit_width / 2.0))
|
637
|
+
offset_row = false
|
638
|
+
# noinspection RubyMismatchedArgumentType # rbs fixed in future patch
|
639
|
+
((source.pos.y - outer_radius + target_radius)..(source.pos.y + outer_radius - target_radius)).step(y_increment) do |y|
|
640
|
+
((source.pos.x - outer_radius + target_radius)..(source.pos.x + outer_radius - target_radius)).step(unit_width) do |x|
|
641
|
+
x += target_radius if offset_row
|
642
|
+
points << Api::Point2D[x, y]
|
643
|
+
end
|
644
|
+
offset_row = !offset_row
|
645
|
+
end
|
646
|
+
|
647
|
+
# Select only grid points inside the outer source and outside the inner source
|
648
|
+
points.select! do |grid_point|
|
649
|
+
gp_distance = source.pos.distance_to(grid_point)
|
650
|
+
gp_distance > inner_radius + target_radius && gp_distance + target_radius < outer_radius
|
651
|
+
end
|
652
|
+
|
653
|
+
# Find X amount of near units within the radius and subtract their overlap in radius with points
|
654
|
+
# we arbitrarily decided that a pylon will no be surrounded by more than 50 units
|
655
|
+
# We add 2.75 above, which is the fattest ground unit (nexus @ 2.75 radius)
|
656
|
+
units_in_pylon_range = bot.all_units.nearest_to(pos: source.pos, amount: 50)
|
657
|
+
.select_in_circle(point: source.pos, radius: outer_radius + 2.75)
|
658
|
+
|
659
|
+
# Reject warp points which overlap with units inside
|
660
|
+
points.reject! do |point|
|
661
|
+
# Find units which overlap with our warp points
|
662
|
+
units_in_pylon_range.find do |unit|
|
663
|
+
xd = (unit.pos.x - point.x).abs
|
664
|
+
yd = (unit.pos.y - point.y).abs
|
665
|
+
intersect_distance = target_radius + unit.radius
|
666
|
+
next false if xd > intersect_distance || yd > intersect_distance
|
667
|
+
|
668
|
+
Math.hypot(xd, yd) < intersect_distance
|
669
|
+
end
|
670
|
+
end
|
671
|
+
|
672
|
+
# Select only warp points which are on placeable tiles
|
673
|
+
points.reject! do |point|
|
674
|
+
left = (point.x - target_radius).floor.clamp(map_tile_range_x)
|
675
|
+
right = (point.x + target_radius).floor.clamp(map_tile_range_x)
|
676
|
+
top = (point.y + target_radius).floor.clamp(map_tile_range_y)
|
677
|
+
bottom = (point.y - target_radius).floor.clamp(map_tile_range_y)
|
678
|
+
|
679
|
+
unplaceable = false
|
680
|
+
x = left
|
681
|
+
while x <= right
|
682
|
+
break if unplaceable
|
683
|
+
y = bottom
|
684
|
+
while y <= top
|
685
|
+
unplaceable = !placeable?(x: x, y: y)
|
686
|
+
break if unplaceable
|
687
|
+
y += 1
|
688
|
+
end
|
689
|
+
x += 1
|
690
|
+
end
|
691
|
+
unplaceable
|
692
|
+
end
|
693
|
+
|
694
|
+
points
|
695
|
+
end
|
696
|
+
|
697
|
+
# Geometry helpers ---
|
698
|
+
|
699
|
+
# Finds points in a straight line.
|
700
|
+
# 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
|
701
|
+
# @param source [Sc2::Position] location from which we go
|
702
|
+
# @param target [Sc2::Position] location towards which we go
|
703
|
+
# @param offset [Float] how far from source to start
|
704
|
+
# @param increment [Float] how far apart to gets, i.e. increment = unit.radius*2 to space units in a line
|
705
|
+
# @param count [Integer] number of points to retrieve
|
706
|
+
# @return [Array<Api::Point2D>] points up to a max of count
|
707
|
+
def points_nearest_linear(source:, target:, offset: 0.0, increment: 1.0, count: 1)
|
708
|
+
# Normalized angle
|
709
|
+
dx = (target.x - source.x)
|
710
|
+
dy = (target.y - source.y)
|
711
|
+
dist = Math.hypot(dx, dy)
|
712
|
+
dx /= dist
|
713
|
+
dy /= dist
|
714
|
+
|
715
|
+
# Set start position and offset if necessary
|
716
|
+
start_x = source.x
|
717
|
+
start_y = source.y
|
718
|
+
unless offset.zero?
|
719
|
+
start_x += (dx * offset)
|
720
|
+
start_y += (dy * offset)
|
721
|
+
end
|
722
|
+
|
723
|
+
# For count times, increment our radius and multiply by angle to get the new point
|
724
|
+
points = []
|
725
|
+
i = 1
|
726
|
+
while i < count
|
727
|
+
radius = increment * i
|
728
|
+
point = Api::Point2D[
|
729
|
+
start_x + (dx * radius),
|
730
|
+
start_y + (dy * radius)
|
731
|
+
]
|
732
|
+
|
733
|
+
# ensure we're on the map
|
734
|
+
break unless map_range_x.cover?(point.x) && map_range_y.cover?(point.x)
|
735
|
+
|
736
|
+
points << point
|
737
|
+
i += 1
|
738
|
+
end
|
739
|
+
|
740
|
+
points
|
741
|
+
end
|
742
|
+
|
743
|
+
# Gets a random point near a location with a positive/negative offset applied to both x and y
|
744
|
+
# @example
|
745
|
+
# Randomly randomly adjust both x and y by a range of -3.5 or +3.5
|
746
|
+
# geo.point_random_near(point: structures.hq.first, offset: 3.5)
|
747
|
+
# @param pos [Sc2::Location]
|
748
|
+
# @param offset [Float]
|
749
|
+
# @return [Api::Point2D]
|
750
|
+
def point_random_near(pos:, offset: 1.0)
|
751
|
+
pos.random_offset(offset)
|
752
|
+
end
|
753
|
+
|
754
|
+
# @param pos [Sc2::Location]
|
755
|
+
# @param radius [Float]
|
756
|
+
# @return [Api::Point2D]
|
757
|
+
def point_random_on_circle(pos:, radius: 1.0)
|
758
|
+
angle = rand(0..360) * Math::PI / 180.0
|
759
|
+
Api::Point2D[
|
760
|
+
pos.x + (Math.sin(angle) * radius),
|
761
|
+
pos.y + (Math.cos(angle) * radius)
|
762
|
+
]
|
763
|
+
end
|
764
|
+
end
|
765
|
+
end
|
766
|
+
end
|