File indexing completed on 2024-04-21 04:01:49

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()