Package horizons :: Package world :: Package building :: Module buildable
[hide private]
[frames] | no frames]

Source Code for Module horizons.world.building.buildable

  1  # ################################################### 
  2  # Copyright (C) 2008-2017 The Unknown Horizons Team 
  3  # team@unknown-horizons.org 
  4  # This file is part of Unknown Horizons. 
  5  # 
  6  # Unknown Horizons is free software; you can redistribute it and/or modify 
  7  # it under the terms of the GNU General Public License as published by 
  8  # the Free Software Foundation; either version 2 of the License, or 
  9  # (at your option) any later version. 
 10  # 
 11  # This program is distributed in the hope that it will be useful, 
 12  # but WITHOUT ANY WARRANTY; without even the implied warranty of 
 13  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the 
 14  # GNU General Public License for more details. 
 15  # 
 16  # You should have received a copy of the GNU General Public License 
 17  # along with this program; if not, write to the 
 18  # Free Software Foundation, Inc., 
 19  # 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA 
 20  # ################################################### 
 21   
 22  import itertools 
 23  from collections import ChainMap 
 24   
 25  from horizons.constants import BUILDINGS 
 26  from horizons.entities import Entities 
 27  from horizons.i18n import gettext_lazy as LazyT 
 28  from horizons.util.pathfinding.pathfinder import a_star_find_path 
 29  from horizons.util.shapes import Circle, Point, Rect 
 30  from horizons.util.worldobject import WorldObject 
 31  from horizons.world.buildability.terraincache import TerrainRequirement 
32 33 34 -class BuildableErrorTypes:
35 """Killjoy class. Collection of reasons why you can't build.""" 36 NO_ISLAND, UNFIT_TILE, NO_SETTLEMENT, OTHER_PLAYERS_SETTLEMENT, \ 37 OTHER_PLAYERS_SETTLEMENT_ON_ISLAND, OTHER_BUILDING_THERE, UNIT_THERE, NO_COAST, \ 38 NO_OCEAN_NEARBY, ONLY_NEAR_SHIP, NEED_RES_SOURCE, ISLAND_ALREADY_SETTLED, \ 39 NO_FLAT_LAND = range(13) 40 41 text = { 42 NO_ISLAND: LazyT("This building must be built on an island."), 43 UNFIT_TILE: LazyT("This ground is not suitable for this building."), 44 NO_SETTLEMENT: LazyT("This building has to be built within your settlement."), 45 OTHER_PLAYERS_SETTLEMENT: LazyT("This area is already occupied by another player."), 46 OTHER_BUILDING_THERE: LazyT("This area is already occupied by another building."), 47 UNIT_THERE: LazyT("This area is already occupied by a unit."), 48 NO_COAST: LazyT("This building must be built on the coastline."), 49 NO_OCEAN_NEARBY: LazyT("This building has to be placed at the ocean."), 50 ONLY_NEAR_SHIP: LazyT("This spot is too far away from your ship."), 51 NEED_RES_SOURCE: LazyT("This building can only be built on a resource source."), 52 ISLAND_ALREADY_SETTLED: LazyT("You have already settled this island."), 53 NO_FLAT_LAND: LazyT("This building must be partly on flat land.") 54 }
55 # TODO: say res source which one we need, maybe even highlight those
56 57 58 -class _BuildPosition:
59 """A possible build position in form of a data structure. 60 Don't use directly outside of this file"""
61 - def __init__(self, position, rotation, tearset, buildable, action='idle', 62 problem=None):
63 """ 64 @param position: Rect, building position and size 65 @param rotation: int rotation of building 66 @param tearset: list of worldids of buildings to tear for this building to build 67 @param buildable: whether building is actually buildable there 68 @param action: action (animation of building) 69 @param problem: (error number, error string) reason it's not buildable or None 70 """ 71 self.position = position 72 self.rotation = rotation 73 self.tearset = tearset 74 self.buildable = buildable 75 self.action = action 76 self.problem = problem
77
78 - def __bool__(self):
79 """Returns buildable value. This enables code such as "if cls.check_build()""" 80 return self.buildable
81
82 - def __eq__(self, other):
83 if not isinstance(other, _BuildPosition): 84 return False 85 return (self.position == other.position and 86 self.rotation == other.rotation and 87 self.action == other.action and 88 self.tearset == other.tearset)
89
90 - def __ne__(self, other):
91 return not self.__eq__(other)
92
93 - def __hash__(self):
94 return hash((self.position, self.rotation, self.action))
95
96 97 -class _NotBuildableError(Exception):
98 """Internal exception."""
99 - def __init__(self, errortype):
100 super().__init__() 101 self.errortype = errortype
102
103 104 -class Buildable:
105 """Interface for every kind of buildable objects. 106 Contains methods to determine whether a building can be placed on a coordinate, regarding 107 island, settlement, ground requirements etc. Does not care about building costs.""" 108 109 irregular_conditions = False # whether all ground tiles have to fulfill the same conditions 110 terrain_type = TerrainRequirement.LAND 111 112 # check this far for fuzzy build 113 CHECK_NEARBY_LOCATIONS_UP_TO_DISTANCE = 3 114 115 # INTERFACE 116 117 @classmethod
118 - def check_build(cls, session, point, rotation=45, check_settlement=True, ship=None, issuer=None):
119 """Check if a building is buildable here. 120 All tiles, that the building occupies are checked. 121 @param point: Point instance, coords 122 @param rotation: preferred rotation of building 123 @param check_settlement: whether to check for a settlement (for settlementless buildings) 124 @param ship: ship instance if building from ship 125 @return instance of _BuildPosition""" 126 # for non-quadratic buildings, we have to switch width and height depending on the rotation 127 if rotation in [45, 225]: 128 position = Rect.init_from_topleft_and_size(point.x, point.y, cls.size[0], cls.size[1]) 129 else: 130 position = Rect.init_from_topleft_and_size(point.x, point.y, cls.size[1], cls.size[0]) 131 132 buildable = True 133 problem = None 134 tearset = [] 135 try: 136 island = cls._check_island(session, position) 137 # TODO: if the rotation changes here for non-quadratic buildings, wrong results will be returned 138 rotation = cls._check_rotation(session, position, rotation) 139 tearset = cls._check_buildings(session, position, island=island) 140 cls._check_units(session, position) 141 if check_settlement: 142 cls._check_settlement(session, position, ship=ship, issuer=issuer) 143 except _NotBuildableError as e: 144 buildable = False 145 problem = (e.errortype, BuildableErrorTypes.text[e.errortype]) 146 147 return _BuildPosition(position, rotation, tearset, buildable, problem=problem)
148 149 @classmethod
150 - def check_build_line(cls, session, point1, point2, rotation=45, ship=None):
151 """Checks out a line on the map for build possibilities. 152 The line usually is a draw of the mouse. 153 @param point1, point2: Point instance, start and end of the line 154 @param rotation: prefered rotation 155 @param ship: ship instance if building from ship 156 @return list of _BuildPositions 157 """ 158 raise NotImplementedError
159 160 @classmethod
161 - def is_tile_buildable(cls, session, tile, ship, island=None, check_settlement=True):
162 """Checks a tile for buildability. 163 @param tile: Ground object 164 @param ship: Ship instance if building from ship 165 @param island: Island instance, if already known. If None, it will be calculated 166 @param check_settlement: bool, whether to check for settlement 167 @return bool, True for "is buildable" """ 168 position = Point(tile.x, tile.y) 169 try: 170 cls._check_island(session, position, island) 171 if check_settlement: 172 cls._check_settlement(session, position, ship=ship) 173 cls._check_buildings(session, position) 174 except _NotBuildableError: 175 return False 176 177 if cls.irregular_conditions: 178 # check in case not all ground tiles have to fulfill the same conditions (e.g. when 1 tile has to be coast) 179 180 # at least one location that has this tile must be actually buildable 181 # area of the buildings is (x, y) + width/height, therefore all build positions that 182 # include (x, y) are (x, y) - ( [0..width], [0..height] ) 183 return any(cls.check_build(session, Point(tile.x - x_off, tile.y - y_off), ship=ship) 184 for x_off, y_off in itertools.product(range(cls.size[0]), range(cls.size[1]))) 185 else: 186 return True
187 188 @classmethod
189 - def check_build_fuzzy(cls, session, point, *args, **kwargs):
190 """Same as check_build, but consider point to be a vague suggestions 191 and search nearby area for buildable position. 192 Returns one of the closest viable positions or the original position as not buildable if none can be found""" 193 194 # this is some kind of case study of applied functional programming 195 196 def filter_duplicates(gen, transform=lambda x: x): 197 """ 198 @param transform: transforms elements to hashable equivalent 199 """ 200 checked = set() 201 for elem in itertools.filterfalse(lambda e: transform(e) in checked, gen): 202 checked.add(transform(elem)) 203 yield elem
204 205 # generate coords near point, search coords of small circles to larger ones 206 def get_positions(): 207 iters = (iter(Circle(point, radius)) for radius in range(cls.CHECK_NEARBY_LOCATIONS_UP_TO_DISTANCE)) 208 return itertools.chain.from_iterable(iters)
209 210 # generate positions and check for matches 211 check_pos = lambda pos: cls.check_build(session, pos, *args, **kwargs) 212 checked = map(check_pos, 213 filter_duplicates(get_positions(), transform=lambda p: p.to_tuple())) 214 215 # filter positive solutions 216 result_generator = filter(lambda buildpos: buildpos.buildable, checked) 217 218 try: 219 # return first match 220 return next(result_generator) 221 except StopIteration: 222 # No match found, fail with specified parameters. 223 return check_pos(point) 224 225 # PRIVATE PARTS 226 227 @classmethod
228 - def _check_island(cls, session, position, island=None):
229 """Check if there is an island and enough tiles. 230 @throws _NotBuildableError if building can't be built. 231 @param position: coord Point to build at 232 @param island: Island instance if known before""" 233 if island is None: 234 if position.__class__ is Rect: # performance optimization 235 at = position.left, position.top 236 else: 237 at = position.center.to_tuple() 238 island = session.world.get_island_tuple(at) 239 if island is None: 240 raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND) 241 for tup in position.tuple_iter(): 242 # can't use get_tile_tuples since it discards None's 243 tile = island.get_tile_tuple(tup) 244 if tile is None: 245 raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND) 246 if 'constructible' not in tile.classes: 247 raise _NotBuildableError(BuildableErrorTypes.UNFIT_TILE) 248 return island
249 250 @classmethod
251 - def _check_rotation(cls, session, position, rotation):
252 """Returns a possible rotation for this building. 253 @param position: Rect or Point instance, position and size 254 @param rotation: The preferred rotation 255 @return: integer, an available rotation in degrees""" 256 return rotation
257 258 @classmethod
259 - def _check_settlement(cls, session, position, ship=None, issuer=None):
260 """Check that there is a settlement that belongs to the player.""" 261 player = issuer if issuer is not None else session.world.player 262 first_legal_settlement = None 263 for coords in position.tuple_iter(): 264 if coords not in session.world.full_map: 265 raise _NotBuildableError(BuildableErrorTypes.NO_SETTLEMENT) 266 settlement = session.world.full_map[coords].settlement 267 if settlement is None: 268 raise _NotBuildableError(BuildableErrorTypes.NO_SETTLEMENT) 269 if player != settlement.owner: 270 raise _NotBuildableError(BuildableErrorTypes.OTHER_PLAYERS_SETTLEMENT) 271 # there should be exactly one legal settlement under the position 272 assert first_legal_settlement is None or first_legal_settlement is settlement 273 first_legal_settlement = settlement 274 assert first_legal_settlement
275 276 @classmethod
277 - def _check_buildings(cls, session, position, island=None):
278 """Check if there are buildings blocking the build. 279 @return Iterable of worldids of buildings that need to be teared in order to build here""" 280 if island is None: 281 island = session.world.get_island(position.center) 282 # _check_island already confirmed that there must be an island here, so no check for None again 283 tearset = set() 284 for tile in island.get_tiles_tuple(position.tuple_iter()): 285 obj = tile.object 286 if obj is not None: # tile contains an object 287 if obj.buildable_upon: 288 if obj.__class__ is cls: 289 # don't tear trees to build trees over them 290 raise _NotBuildableError(BuildableErrorTypes.OTHER_BUILDING_THERE) 291 # tear it so we can build over it 292 tearset.add(obj.worldid) 293 else: 294 # building is blocking the build 295 raise _NotBuildableError(BuildableErrorTypes.OTHER_BUILDING_THERE) 296 if hasattr(session.manager, 'get_builds_in_construction'): 297 builds_in_construction = session.manager.get_builds_in_construction() 298 for build in builds_in_construction: 299 (sizex, sizey) = Entities.buildings[build.building_class].size 300 for (neededx, neededy) in position.tuple_iter(): 301 if neededx in range(build.x, build.x + sizex) and neededy in range(build.y, build.y + sizey): 302 raise _NotBuildableError(BuildableErrorTypes.OTHER_BUILDING_THERE) 303 return tearset
304 305 @classmethod
306 - def _check_units(cls, session, position):
307 for tup in position.tuple_iter(): 308 if tup in session.world.ground_unit_map: 309 raise _NotBuildableError(BuildableErrorTypes.UNIT_THERE)
310
311 312 -class BuildableSingle(Buildable):
313 """Buildings one can build single. """ 314 @classmethod
315 - def check_build_line(cls, session, point1, point2, rotation=45, ship=None):
316 # only build 1 building at endpoint 317 # correct placement for large buildings (mouse should be at center of building) 318 point2 = point2.copy() # only change copy 319 point2.x -= (cls.size[0] - 1) // 2 320 point2.y -= (cls.size[1] - 1) // 2 321 return [cls.check_build_fuzzy(session, point2, rotation=rotation, ship=ship)]
322
323 324 -class BuildableSingleEverywhere(BuildableSingle):
325 """Buildings, that can be built everywhere. Usually not used for buildings placeable by humans.""" 326 terrain_type = None # type: None 327 328 @classmethod
329 - def check_build(cls, session, point, rotation=45, check_settlement=True, ship=None, issuer=None):
330 # for non-quadratic buildings, we have to switch width and height depending on the rotation 331 if rotation in [45, 225]: 332 position = Rect.init_from_topleft_and_size(point.x, point.y, cls.size[0], cls.size[1]) 333 else: 334 position = Rect.init_from_topleft_and_size(point.x, point.y, cls.size[1], cls.size[0]) 335 336 buildable = True 337 tearset = [] 338 return _BuildPosition(position, rotation, tearset, buildable)
339
340 341 -class BuildableRect(Buildable):
342 """Buildings one can build as a Rectangle, such as Trees""" 343 @classmethod
344 - def check_build_line(cls, session, point1, point2, rotation=45, ship=None):
345 if point1 == point2: 346 # this is actually a masked single build 347 return [cls.check_build_fuzzy(session, point1, rotation=rotation, ship=ship)] 348 possible_builds = [] 349 area = Rect.init_from_corners(point1, point2) 350 # correct placement for large buildings (mouse should be at center of building) 351 area.left -= (cls.size[0] - 1) // 2 352 area.right -= (cls.size[0] - 1) // 2 353 area.top -= (cls.size[1] - 1) // 2 354 area.bottom -= (cls.size[1] - 1) // 2 355 356 xstart, xend = area.left, area.right + 1 357 xstep = cls.size[0] 358 if point1.x > point2.x: 359 xstart, xend = area.right, area.left - 1 360 xstep *= -1 361 362 ystart, yend = area.top, area.bottom + 1 363 ystep = cls.size[1] 364 if point1.y > point2.y: 365 ystart, yend = area.bottom, area.top - 1 366 ystep *= -1 367 368 for x in range(xstart, xend, xstep): 369 for y in range(ystart, yend, ystep): 370 possible_builds.append( 371 cls.check_build(session, Point(x, y), rotation=rotation, ship=ship) 372 ) 373 return possible_builds
374
375 376 -class BuildableLine(Buildable):
377 """Buildings one can build in a line, such as paths""" 378 @classmethod
379 - def check_build_line(cls, session, point1, point2, rotation=45, ship=None):
380 381 # Pathfinding currently only supports buildingsize 1x1, so don't use it in this case 382 if cls.size != (1, 1): 383 return [cls.check_build_fuzzy(session, point2, rotation=rotation, ship=ship)] 384 385 # use pathfinding to get a path, then try to build along it 386 island = session.world.get_island(point1) 387 if island is None: 388 return [] 389 390 if cls.id == BUILDINGS.TRAIL: 391 nodes = island.path_nodes.nodes 392 elif cls.id == BUILDINGS.BARRIER: 393 # Allow nodes that can be walked upon and existing barriers when finding a 394 # build path 395 nodes = ChainMap(island.path_nodes.nodes, island.barrier_nodes.nodes) 396 else: 397 raise Exception('BuildableLine does not support building id {0}'.format(cls.id)) 398 399 path = a_star_find_path(point1.to_tuple(), point2.to_tuple(), nodes, rotation in (45, 225)) 400 if path is None: # can't find a path between these points 401 return [] # TODO: maybe implement alternative strategy 402 403 possible_builds = [] 404 405 #TODO duplicates recalculation code in world.building.path 406 for x, y in path: 407 action = '' 408 for action_char, (xoff, yoff) in \ 409 sorted(BUILDINGS.ACTION.action_offset_dict.items()): # order is important here 410 if action_char in 'abcd' and (xoff + x, yoff + y) in path: 411 action += action_char 412 if action == '': 413 action = 'single' # single trail piece with no neighbors 414 415 build = cls.check_build(session, Point(x, y)) 416 build.action = action 417 possible_builds.append(build) 418 419 return possible_builds
420
421 422 -class BuildableSingleOnCoast(BuildableSingle):
423 """Buildings one can only build on coast, such as the fisher.""" 424 irregular_conditions = True 425 terrain_type = TerrainRequirement.LAND_AND_COAST 426 427 @classmethod
428 - def _check_island(cls, session, position, island=None):
429 # ground has to be either coastline or constructible, > 1 tile must be coastline 430 # can't use super, since it checks all tiles for constructible 431 432 if island is None: 433 island = session.world.get_island(position.center) 434 if island is None: 435 raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND) 436 437 flat_land_found = False 438 coastline_found = False 439 first_coords = None 440 for coords in position.tuple_iter(): 441 if first_coords is None: 442 first_coords = coords 443 # can't use get_tile_tuples since it discards None's 444 tile = island.get_tile_tuple(coords) 445 if tile is None: 446 raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND) 447 if 'coastline' in tile.classes: 448 coastline_found = True 449 elif 'constructible' in tile.classes: 450 flat_land_found = True 451 elif 'constructible' not in tile.classes: # neither coastline, nor constructible 452 raise _NotBuildableError(BuildableErrorTypes.UNFIT_TILE) 453 if not coastline_found: 454 raise _NotBuildableError(BuildableErrorTypes.NO_COAST) 455 elif isinstance(position, Rect) and not flat_land_found: 456 # Flat land is required but this function can be called with just a point that 457 # is used to show the buildability highlight. In that case the flat land 458 # requirement should be ignored. 459 raise _NotBuildableError(BuildableErrorTypes.NO_FLAT_LAND) 460 return island
461 462 @classmethod
463 - def _check_rotation(cls, session, position, rotation):
464 """Rotate so that the building faces the seaside""" 465 # array of coords (points are True if is coastline) 466 coastline = {} 467 x, y = position.origin.to_tuple() 468 for point in position: 469 if session.world.map_dimensions.contains_without_border(point): 470 is_coastline = ('coastline' in session.world.get_tile(point).classes) 471 else: 472 is_coastline = False 473 coastline[point.x - x, point.y - y] = is_coastline 474 475 """ coastline looks something like this: 476 111 477 000 478 000 479 we have to rotate to the direction with most 1s 480 481 Rotations: 482 45 483 135 315 484 225 485 """ 486 coast_line_points_per_side = { 487 45: sum(coastline[(x, 0)] for x in range(0, cls.size[0])), 488 135: sum(coastline[(0, y)] for y in range(0, cls.size[1])), 489 225: sum(coastline[(x, cls.size[1] - 1)] for x in range(0, cls.size[0])), 490 315: sum(coastline[(cls.size[0] - 1, y)] for y in range(0, cls.size[1])), 491 } 492 493 # return rotation with biggest value 494 maximum = -1 495 rotation = -1 496 for rot, val in coast_line_points_per_side.items(): 497 if val > maximum: 498 maximum = val 499 rotation = rot 500 return rotation
501
502 503 -class BuildableSingleOnOcean(BuildableSingleOnCoast):
504 """Requires ocean nearby as well""" 505 terrain_type = TerrainRequirement.LAND_AND_COAST_NEAR_SEA 506 507 @classmethod
508 - def _check_island(cls, session, position, island=None):
509 # this might raise, just let it through 510 super(BuildableSingleOnOcean, cls)._check_island(session, position, island) 511 if island is None: 512 island = session.world.get_island(position.center) 513 if island is None: 514 raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND) 515 posis = position.get_coordinates() 516 for tile in posis: 517 for rad in Circle(Point(*tile), 3): 518 if rad in session.world.water_body and session.world.water_body[rad] == session.world.sea_number: 519 # Found legit see tile 520 return island 521 raise _NotBuildableError(BuildableErrorTypes.NO_OCEAN_NEARBY)
522
523 524 -class BuildableSingleFromShip(BuildableSingleOnOcean):
525 """Buildings that can be build from a ship. Currently only Warehouse.""" 526 @classmethod
527 - def _check_settlement(cls, session, position, ship, issuer=None):
528 # building from ship doesn't require settlements 529 # but a ship nearby: 530 if ship.position.distance(position) > BUILDINGS.BUILD.MAX_BUILDING_SHIP_DISTANCE: 531 raise _NotBuildableError(BuildableErrorTypes.ONLY_NEAR_SHIP) 532 533 for i in position: 534 # and the position mustn't be owned by anyone else 535 settlement = session.world.get_settlement(i) 536 if settlement is not None: 537 raise _NotBuildableError(BuildableErrorTypes.OTHER_PLAYERS_SETTLEMENT) 538 539 # and player mustn't have a settlement here already 540 island = session.world.get_island(position.center) 541 for s in island.settlements: 542 if s.owner == ship.owner: 543 raise _NotBuildableError(BuildableErrorTypes.ISLAND_ALREADY_SETTLED)
544
545 546 -class BuildableSingleOnDeposit(BuildableSingle):
547 """For mines; those buildings are only buildable upon other buildings (clay pit on clay deposit, e.g.) 548 For now, mines can only be built on a single type of deposit. 549 This is specified in object files, and saved in cls.buildable_on_deposit in 550 the buildingclass. 551 """ 552 irregular_conditions = True 553 terrain_type = None # type: None 554 555 @classmethod
556 - def _check_buildings(cls, session, position, island=None):
557 """Check if there are buildings blocking the build""" 558 if island is None: 559 island = session.world.get_island(position.center) 560 deposit = None 561 for tile in island.get_tiles_tuple(position.tuple_iter()): 562 if tile.object is None or \ 563 tile.object.id != cls.buildable_on_deposit_type or \ 564 (deposit is not None and tile.object != deposit): # only build on 1 deposit 565 raise _NotBuildableError(BuildableErrorTypes.NEED_RES_SOURCE) 566 deposit = tile.object 567 return {deposit.worldid}
568 569 @classmethod
570 - def _check_rotation(cls, session, position, rotation):
571 """The rotation should be the same as the one of the underlying mountain""" 572 tearset = cls._check_buildings(session, position) # will raise on problems 573 # rotation fix code is only reached when building is buildable 574 mountain = WorldObject.get_object_by_id(next(iter(tearset))) 575 return mountain.rotation
576