File indexing completed on 2024-04-28 07:51:07
0001 # -*- coding: utf-8 -*- 0002 0003 """ 0004 Copyright 2008-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de> 0005 0006 SPDX-License-Identifier: GPL-2.0 0007 0008 """ 0009 0010 import weakref 0011 0012 from qt import QGraphicsRectItem 0013 from tile import Tile 0014 from tileset import Tileset 0015 from uitile import UITile 0016 from meld import Meld, MeldList 0017 from hand import Hand 0018 from board import Board 0019 from sound import Sound 0020 0021 from log import logDebug 0022 from common import Internal, Debug, isAlive, StrMixin 0023 0024 0025 class TileAttr(StrMixin): 0026 0027 """a helper class for syncing the hand board, holding relevant 0028 tile attributes. 0029 xoffset and yoffset are expressed in number of tiles but may be 0030 fractional for adding distances between melds""" 0031 0032 def __init__(self, hand, meld=None, idx=None, xoffset=None, yoffset=None): 0033 if isinstance(hand, UITile): 0034 self.tile = hand.tile 0035 self.xoffset = hand.xoffset 0036 self.yoffset = hand.yoffset 0037 self.dark = hand.dark 0038 self.focusable = hand.focusable 0039 else: 0040 self.tile = Tile(meld[idx]) 0041 self.xoffset = xoffset 0042 self.yoffset = yoffset 0043 self.dark = self.setDark() 0044 # dark and focusable are different in a ScoringHandBoard 0045 self.focusable = self.setFocusable(hand, meld, idx) 0046 if self.tile in Debug.focusable: 0047 logDebug('TileAttr %s:%s' % (self.tile, self.focusable)) 0048 0049 def setDark(self): 0050 """should the tile appear darker?""" 0051 return self.tile.isConcealed if self.yoffset == 0 else not self.tile.isKnown 0052 0053 def setFocusable(self, hand, meld, idx): # pylint: disable=unused-argument 0054 """is it focusable?""" 0055 player = hand.player 0056 return ( 0057 not self.tile.isBonus 0058 and self.tile.isKnown 0059 and player == player.game.activePlayer 0060 and player == player.game.myself 0061 and meld.isConcealed and not meld.isKong) 0062 0063 def __str__(self): 0064 return ( 0065 '%s %.2f/%.1f%s%s' % 0066 (self.tile, self.xoffset, self.yoffset, 0067 ' dark' if self.dark else '', 0068 ' focusable' if self.focusable else '')) 0069 0070 0071 class HandBoard(Board): 0072 0073 """a board showing the tiles a player holds""" 0074 # pylint: disable=too-many-public-methods,too-many-instance-attributes 0075 tileAttrClass = TileAttr 0076 penColor = 'blue' 0077 0078 def __init__(self, player): 0079 assert player 0080 self._player = weakref.ref(player) 0081 self.exposedMeldDistance = 0.15 0082 self.concealedMeldDistance = 0.0 0083 self.lowerY = 1.0 0084 Board.__init__(self, 15.6, 2.0, Tileset.current()) 0085 self.isHandBoard = True 0086 self.tileDragEnabled = False 0087 self.setParentItem(player.front) 0088 self.setPosition() 0089 self.setAcceptDrops(True) 0090 Internal.Preferences.addWatch( 0091 'rearrangeMelds', self.rearrangeMeldsChanged) 0092 self.rearrangeMeldsChanged(None, Internal.Preferences.rearrangeMelds) 0093 Internal.Preferences.addWatch( 0094 'showShadows', self.showShadowsChanged) 0095 0096 def computeRect(self): 0097 """also adjust the scale for maximum usage of space""" 0098 Board.computeRect(self) 0099 sideRect = self.player.front.boundingRect() 0100 boardRect = self.boundingRect() 0101 scale = ((sideRect.width() + sideRect.height()) 0102 / (boardRect.width() - boardRect.height())) 0103 self.setScale(scale) 0104 0105 @property 0106 def player(self): 0107 """player is readonly and never None""" 0108 return self._player() if self._player else None 0109 0110 # this is ordered such that pylint does not complain about 0111 # identical code in board.py 0112 0113 @property 0114 def name(self): 0115 """for debugging messages""" 0116 return self.player.name 0117 0118 def showShadowsChanged(self, unusedOldValue, newValue): 0119 """Add or remove the shadows.""" 0120 self.setPosition() 0121 0122 def setPosition(self): 0123 """Position myself""" 0124 show = Internal.Preferences.showShadows 0125 if show: 0126 self.setPos(yHeight=1.5) 0127 else: 0128 self.setPos(yHeight=1.0) 0129 if show: 0130 self.lowerY = 1.2 0131 else: 0132 self.lowerY = 1.0 0133 self.setRect(15.6, 1.0 + self.lowerY) 0134 self._reload(self.tileset, showShadows=show) 0135 self.sync() 0136 0137 def rearrangeMeldsChanged(self, unusedOldValue, newValue): 0138 """when True, concealed melds are grouped""" 0139 self.concealedMeldDistance = ( 0140 self.exposedMeldDistance if newValue else 0.0) 0141 self._reload(self.tileset, self._lightSource) 0142 self.sync() 0143 0144 def focusRectWidth(self): 0145 """how many tiles are in focus rect? We want to focus 0146 the entire meld""" 0147 # playing game: always make only single tiles selectable 0148 return 1 0149 0150 def __str__(self): 0151 return self.player.scoringString() 0152 0153 def lowerHalfTiles(self): 0154 """return a list with all single tiles of the lower half melds 0155 without boni""" 0156 return [x for x in self.uiTiles if x.yoffset > 0 and not x.isBonus] 0157 0158 def newLowerMelds(self): # pylint: disable=no-self-use 0159 """a list of melds for the hand as it should look after sync""" 0160 return [] 0161 0162 def newTilePositions(self): 0163 """return list(TileAttr) for all tiles except bonus tiles. 0164 The tiles are not associated to any board.""" 0165 result = list() 0166 newUpperMelds = list(self.player.exposedMelds) 0167 newLowerMelds = self.newLowerMelds() 0168 for yPos, melds in ((0, newUpperMelds), (self.lowerY, newLowerMelds)): 0169 meldDistance = (self.concealedMeldDistance if yPos 0170 else self.exposedMeldDistance) 0171 meldX = 0 0172 for meld in melds: 0173 for idx in range(len(meld)): 0174 result.append( 0175 self.tileAttrClass(self, meld, idx, meldX, yPos)) 0176 meldX += 1 0177 meldX += meldDistance 0178 return sorted(result, key=lambda x: x.yoffset * 100 + x.xoffset) 0179 0180 def placeBoniInRow(self, bonusTiles, tilePositions, bonusY, keepTogether=True): 0181 """Try to place bonusTiles in upper or in lower row. 0182 tilePositions are the normal tiles, already placed. 0183 If there is no space, return None 0184 0185 returns list(TileAttr)""" 0186 positions = [x.xoffset for x in tilePositions if x.yoffset == bonusY] 0187 rightmostTileX = max(positions) if positions else 0 0188 placeBoni = bonusTiles[:] 0189 while 13 - len(placeBoni) < rightmostTileX + 1 + self.exposedMeldDistance: 0190 if keepTogether: 0191 return list() 0192 placeBoni = placeBoni[:-1] 0193 result = list() 0194 xPos = 13 - len(placeBoni) 0195 newBonusTiles = [self.tileAttrClass(x) for x in placeBoni] 0196 for bonus in sorted(newBonusTiles, key=lambda x: x.tile.key): 0197 bonus.xoffset, bonus.yoffset = xPos, bonusY 0198 bonus.dark = False 0199 result.append(bonus) 0200 xPos += 1 0201 return result 0202 0203 def newBonusPositions(self, bonusTiles, newTilePositions): 0204 """return list(TileAttr) 0205 calculate places for bonus tiles. Put them all in one row, 0206 right adjusted. If necessary, extend to the right even 0207 outside of our board""" 0208 if not bonusTiles: 0209 return list() 0210 bonusTiles = sorted(bonusTiles, key=lambda x: hash(x.tile)) 0211 result = ( 0212 self.placeBoniInRow(bonusTiles, newTilePositions, 0.0) 0213 or 0214 self.placeBoniInRow(bonusTiles, newTilePositions, self.lowerY)) 0215 if not result: 0216 # we cannot place all bonus tiles in the same row! 0217 result = self.placeBoniInRow(bonusTiles, newTilePositions, 0.0, keepTogether=False) 0218 result.extend(self.placeBoniInRow( 0219 bonusTiles[len(result):], newTilePositions, self.lowerY, keepTogether=False)) 0220 0221 assert len(bonusTiles) == len(result) 0222 return result 0223 0224 def placeTiles(self, tiles): 0225 """tiles are all tiles for this board. 0226 returns a list of those uiTiles which are placed on the board""" 0227 oldTiles = dict() 0228 oldBonusTiles = dict() 0229 for uiTile in tiles: 0230 assert isinstance(uiTile, UITile) 0231 if uiTile.isBonus: 0232 targetDict = oldBonusTiles 0233 else: 0234 targetDict = oldTiles 0235 if uiTile.tile not in targetDict.keys(): 0236 targetDict[uiTile.tile] = list() 0237 targetDict[uiTile.tile].append(uiTile) 0238 result = dict() 0239 newPositions = self.newTilePositions() 0240 for newPosition in newPositions: 0241 assert isinstance(newPosition.tile, Tile) 0242 matches = oldTiles.get(newPosition.tile) \ 0243 or oldTiles.get(newPosition.tile.swapped) \ 0244 or oldTiles.get(Tile.unknown) 0245 if not matches and not newPosition.tile.isKnown and oldTiles: 0246 # 13 orphans, robbing Kong, lastTile is single: 0247 # no oldTiles exist 0248 matches = list(oldTiles.values())[0] 0249 if matches: 0250 # no matches happen when we move a uiTile within a board, 0251 # here we simply ignore existing tiles with no matches 0252 matches = sorted( 0253 matches, key=lambda x: 0254 + abs(newPosition.yoffset - x.yoffset) * 100 # pylint: disable=cell-var-from-loop 0255 + abs(newPosition.xoffset - x.xoffset)) # pylint: disable=cell-var-from-loop 0256 # pylint is too cautious here. Check with later versions. 0257 match = matches[0] 0258 result[match] = newPosition 0259 oldTiles[match.tile].remove(match) 0260 if not oldTiles[match.tile]: 0261 del oldTiles[match.tile] 0262 for newBonusPosition in self.newBonusPositions( 0263 [x for x in tiles if x.isBonus], newPositions): 0264 result[oldBonusTiles[newBonusPosition.tile][0]] = newBonusPosition 0265 self._avoidCrossingMovements(result) 0266 for uiTile, newPos in result.items(): 0267 uiTile.level = 0 # for tiles coming from the wall 0268 uiTile.tile = newPos.tile 0269 uiTile.setBoard(self, newPos.xoffset, newPos.yoffset) 0270 uiTile.dark = newPos.dark 0271 uiTile.focusable = newPos.focusable 0272 return result.keys() 0273 0274 def _avoidCrossingMovements(self, places): 0275 """not needed for all HandBoards""" 0276 0277 def sync(self, adding=None): 0278 """place all tiles in HandBoard. 0279 adding tiles: their board is where they come from. Those tiles 0280 are already in the Player tile lists. 0281 The sender board must not be self, see ScoringPlayer.moveMeld""" 0282 if not self.uiTiles and not adding: 0283 return 0284 allTiles = self.uiTiles[:] 0285 if adding: 0286 allTiles.extend(adding) 0287 self.placeTiles(allTiles) 0288 0289 def checkTiles(self): 0290 """does the logical state match the displayed tiles?""" 0291 logExposed = list() 0292 physExposed = list() 0293 logConcealed = list() 0294 physConcealed = list() 0295 for tile in self.player.bonusTiles: 0296 logExposed.append(tile) 0297 for uiTile in self.uiTiles: 0298 if uiTile.yoffset == 0 or uiTile.isBonus: 0299 physExposed.append(uiTile.tile) 0300 else: 0301 physConcealed.append(uiTile.tile) 0302 for meld in self.player.exposedMelds: 0303 logExposed.extend(meld) 0304 if self.player.concealedMelds: 0305 for meld in self.player.concealedMelds: 0306 logConcealed.extend(meld) 0307 else: 0308 logConcealed = sorted(self.player.concealedTiles) 0309 logExposed.sort() 0310 physExposed.sort() 0311 logConcealed.sort() 0312 physConcealed.sort() 0313 assert logExposed == physExposed, ( 0314 '%s: exposed: player %s != hand %s. Check those:%s' % 0315 (self.player, logExposed, physExposed, 0316 set(logExposed) ^ set(physExposed))) 0317 assert logConcealed == physConcealed, ( 0318 '%s: concealed: player %s != hand %s' % 0319 (self.player, logConcealed, physConcealed)) 0320 0321 0322 class PlayingHandBoard(HandBoard): 0323 0324 """a board showing the tiles a player holds""" 0325 # pylint: disable=too-many-public-methods,too-many-instance-attributes 0326 0327 def __init__(self, player): 0328 HandBoard.__init__(self, player) 0329 0330 def sync(self, adding=None): 0331 """place all tiles in HandBoard""" 0332 allTiles = self.uiTiles[:] 0333 if adding: 0334 allTiles.extend(adding) 0335 newTiles = self.placeTiles(allTiles) 0336 source = adding if adding else newTiles 0337 focusCandidates = [x for x in source if x.focusable and x.tile.isConcealed] 0338 if not focusCandidates: 0339 # happens if we just exposed a claimed meld 0340 focusCandidates = [x for x in newTiles if x.focusable and x.tile.isConcealed] 0341 focusCandidates = sorted(focusCandidates, key=lambda x: x.xoffset) 0342 if focusCandidates: 0343 self.focusTile = focusCandidates[0] 0344 Internal.scene.handSelectorChanged(self) 0345 self.hasLogicalFocus = bool(adding) 0346 0347 @Board.focusTile.setter 0348 def focusTile(self, uiTile): # pylint: disable=arguments-differ 0349 Board.focusTile.fset(self, uiTile) 0350 if self.player and Internal.scene.clientDialog: 0351 Internal.scene.clientDialog.focusTileChanged() 0352 0353 def setEnabled(self, enabled): 0354 """enable/disable this board""" 0355 if isAlive(self): 0356 # aborting a running game: the underlying C++ object might 0357 # already have been destroyed 0358 self.tileDragEnabled = ( 0359 enabled 0360 and self.player == self.player.game.myself) 0361 QGraphicsRectItem.setEnabled(self, enabled) 0362 0363 def dragMoveEvent(self, event): # pylint: disable=no-self-use 0364 """only dragging to discard board should be possible""" 0365 event.setAccepted(False) 0366 0367 def _avoidCrossingMovements(self, places): 0368 """"the above is a good approximation but if the board already had more 0369 than one identical tile they often switch places - this should not 0370 happen. So for each element, we make sure that the left-right order is 0371 still the same as before. For this check, ignore all new tiles""" 0372 movingPlaces = self.__movingPlaces(places) 0373 for yOld in 0, self.lowerY: 0374 for yNew in 0, self.lowerY: 0375 items = [x for x in movingPlaces.items() 0376 if (x[0].board == self) 0377 and x[0].yoffset == yOld 0378 and x[1] and x[1].yoffset == yNew 0379 and not x[0].isBonus] 0380 for element in {x[1].tile for x in items}: 0381 items = [x for x in movingPlaces.items() 0382 if x[1].tile is element] 0383 if len(items) > 1: 0384 oldList = sorted((x[0] for x in items), 0385 key=lambda x: 0386 bool(x.board != self) * 1000 + x.xoffset) 0387 newList = sorted((x[1] for x in items), 0388 key=lambda x: x.xoffset) 0389 for idx, oldTile in enumerate(oldList): 0390 places[oldTile] = newList[idx] 0391 0392 def __movingPlaces(self, places): 0393 """filter out the left parts of the rows which do not change 0394 at all""" 0395 rows = [[], []] 0396 for idx, yOld in enumerate([0, self.lowerY]): 0397 rowPlaces = [x for x in places.items() if x[0].yoffset == yOld] 0398 rowPlaces = sorted(rowPlaces, key=lambda x: x[0].xoffset) 0399 smallestX = 999 0400 for tileItem, newPos in places.items(): 0401 if (tileItem.xoffset != newPos.xoffset 0402 or tileItem.yoffset != newPos.yoffset): 0403 if newPos.yoffset == yOld: 0404 smallestX = min(smallestX, newPos.xoffset) 0405 else: 0406 smallestX = min(smallestX, tileItem.xoffset) 0407 rows[idx] = [x for x in rowPlaces 0408 if x[0].xoffset >= smallestX 0409 and x[1].xoffset >= smallestX] 0410 result = dict(rows[0]) 0411 result.update(dict(rows[1])) 0412 return result 0413 0414 def newLowerMelds(self): 0415 """a list of melds for the hand as it should look after sync""" 0416 if self.player.concealedMelds: 0417 result = MeldList(self.player.concealedMelds) 0418 elif self.player.concealedTiles: 0419 tileStr = 'R' + ''.join(x for x in self.player.concealedTiles) 0420 content = Hand(self.player, tileStr) 0421 result = MeldList(content.melds + content.bonusMelds) 0422 else: 0423 return [] 0424 if not Internal.Preferences.rearrangeMelds: 0425 result = MeldList(Meld(x) for x in result.tiles()) 0426 # one meld per tile 0427 result.sort() 0428 return result 0429 0430 def discard(self, tile): 0431 """select the rightmost matching tileItem and move it 0432 to DiscardBoard""" 0433 if self.focusTile and self.focusTile.tile is tile: 0434 lastDiscard = self.focusTile 0435 else: 0436 matchingTiles = sorted(self.tilesByElement(tile), 0437 key=lambda x: x.xoffset) 0438 # if an opponent player discards, we want to discard from the 0439 # right end of the hand# thus minimizing tile movement 0440 # within the hand 0441 lastDiscard = matchingTiles[-1] 0442 Internal.scene.discardBoard.discardTile(lastDiscard) 0443 for uiTile in self.uiTiles: 0444 uiTile.focusable = False 0445 0446 def addUITile(self, uiTile): 0447 """add uiTile to this board""" 0448 Board.addUITile(self, uiTile) 0449 if uiTile.isBonus and not self.player.game.isScoringGame(): 0450 Sound.bonus()