sc2ai 0.0.0.pre → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/data/data.json +1 -0
- data/data/data_readable.json +22946 -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 +37900 -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 +1951 -0
- data/lib/sc2ai/api/buff_id.rb +316 -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 +82 -0
- data/lib/sc2ai/api/tech_tree_data.rb +2342 -0
- data/lib/sc2ai/api/unit_type_id.rb +2074 -0
- data/lib/sc2ai/api/upgrade_id.rb +312 -0
- data/lib/sc2ai/cli/cli.rb +177 -0
- data/lib/sc2ai/cli/ladderzip.rb +173 -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
- metadata +353 -9
@@ -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_power_grid[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
|