File indexing completed on 2024-04-28 07:51:10

0001 # -*- coding: utf-8 -*-
0002 
0003 """
0004 Copyright (C) 2008-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de>
0005 
0006 SPDX-License-Identifier: GPL-2.0
0007 
0008 """
0009 
0010 from twisted.internet.defer import succeed
0011 
0012 from log import logDebug, id4
0013 from mi18n import i18n, i18nc
0014 from common import LIGHTSOURCES, Internal, isAlive, ZValues, Debug
0015 from common import StrMixin, Speeds
0016 from wind import Wind
0017 
0018 from qt import Qt, QMetaObject
0019 from qt import QGraphicsScene, QGraphicsItem, QGraphicsRectItem, QPen, QColor
0020 
0021 from dialogs import QuestionYesNo
0022 from guiutil import decorateWindow, sceneRotation
0023 from board import SelectorBoard, DiscardBoard
0024 from tileset import Tileset
0025 from meld import Meld
0026 from humanclient import HumanClient
0027 from uitile import UITile
0028 from uiwall import UIWall
0029 from animation import AnimationSpeed, afterQueuedAnimations
0030 from scoringdialog import ScoringDialog
0031 
0032 
0033 class FocusRect(QGraphicsRectItem, StrMixin):
0034 
0035     """show a focusRect with blue border around focused tile or meld.
0036     We can NOT do this as a child of the item or the focus rect would
0037     have to stay within tile face: The adjacent tile will cover the
0038     focus rect because Z order is only relevant for items having the
0039     same parent"""
0040 
0041     def __init__(self):
0042         QGraphicsRectItem.__init__(self)
0043         pen = QPen(QColor(Qt.blue))
0044         pen.setWidth(6)
0045         self.setPen(pen)
0046         self.setZValue(ZValues.markerZ)
0047         self._board = None
0048         self.hide()
0049 
0050     @property
0051     def board(self):
0052         """current board the focusrect is on"""
0053         return self._board
0054 
0055     @board.setter
0056     def board(self, value):
0057         """assign and show/hide as needed"""
0058         if value and not isAlive(value):
0059             logDebug(
0060                 'assigning focusRect to a non-alive board %s/%s' %
0061                 (type(value), value))
0062             return
0063         if value:
0064             self._board = value
0065             self.refresh()
0066 
0067     @afterQueuedAnimations
0068     def refresh(self, unusedDeferredResult=None):
0069         """show/hide on correct position after queued animations end"""
0070         board = self.board
0071         if not isAlive(board) or not isAlive(self):
0072             if isAlive(self):
0073                 self.setVisible(False)
0074             return
0075         rect = board.tileFaceRect()
0076         rect.setWidth(rect.width() * board.focusRectWidth())
0077         self.setRect(rect)
0078         self.setRotation(sceneRotation(board))
0079         self.setScale(board.scale())
0080         if board.focusTile:
0081             board.focusTile.setFocus()
0082             self.setPos(board.focusTile.pos)
0083         game = Internal.scene.game
0084         self.setVisible(board.isVisible() and bool(board.focusTile)
0085                         and board.isEnabled() and board.hasLogicalFocus and bool(game) and not game.autoPlay)
0086 
0087 
0088     def __str__(self):
0089         """for debugging"""
0090         return 'FocusRect({} on {})'.format(id4(self), self.board if self.board else 'NOBOARD')
0091 
0092 class SceneWithFocusRect(QGraphicsScene):
0093 
0094     """our scene with a potential Qt bug fix. FocusRect is a blue frame around a tile or meld"""
0095 
0096     def __init__(self):
0097         QGraphicsScene.__init__(self)
0098         self.focusRect = FocusRect()
0099         self.addItem(self.focusRect)
0100 
0101     def focusInEvent(self, event):
0102         """
0103         Work around a qt bug. See U{https://bugreports.qt-project.org/browse/QTBUG-32890}.
0104         This can be reproduced as follows:
0105          - ./kajongg.py --game=2/E2 --demo --ruleset=BMJA
0106                such that the human player is the first one to discard a tile.
0107          - wait until the main screen has been built
0108          - click with the mouse into the middle of that window
0109          - press left arrow key
0110          - this will violate the assertion in UITile.keyPressEvent.
0111         """
0112         prev = self.focusItem()
0113         QGraphicsScene.focusInEvent(self, event)
0114         if prev and bool(prev.flags() & QGraphicsItem.ItemIsFocusable) and prev != self.focusItem():
0115             self.setFocusItem(prev)
0116 
0117     @property
0118     def focusBoard(self):
0119         """get / set the board that has its focusRect shown"""
0120         return self.focusRect.board
0121 
0122     @focusBoard.setter
0123     def focusBoard(self, board):
0124         """get / set the board that has its focusRect shown"""
0125         self.focusRect.board = board
0126 
0127 
0128 class GameScene(SceneWithFocusRect):
0129 
0130     """the game field"""
0131     # pylint: disable=too-many-instance-attributes
0132 
0133     def __init__(self, parent=None):
0134         Internal.scene = self
0135         self.mainWindow = parent
0136         self._game = None
0137         super().__init__()
0138 
0139         self.scoreTable = None
0140         self.explainView = None
0141         self.setupUi()
0142         Internal.Preferences.addWatch('showShadows', self.showShadowsChanged)
0143 
0144     @property
0145     def game(self):
0146         """a proxy"""
0147         return self._game
0148 
0149     @game.setter
0150     def game(self, value):
0151         """if it changes, update GUI"""
0152         changing = self._game != value
0153         game = self._game
0154         self._game = value
0155         if changing:
0156             if value:
0157                 self.mainWindow.updateGUI()
0158             else:
0159                 game.close()
0160                 if self.scoreTable:
0161                     self.scoreTable.hide()
0162                 if self.explainView:
0163                     self.explainView.hide()
0164                 self.mainWindow.scene = None
0165         self.mainWindow.updateGUI()
0166         self.mainWindow.adjustMainView()
0167 
0168     @afterQueuedAnimations
0169     def showShadowsChanged(self, deferredResult, unusedOldValue, unusedNewValue): # pylint: disable=unused-argument
0170         """if the wanted shadow direction changed, apply that change now"""
0171         for uiTile in self.graphicsTileItems():
0172             uiTile.setClippingFlags()
0173         self.applySettings()
0174 
0175     def handSelectorChanged(self, handBoard):
0176         """update all relevant dialogs"""
0177         if self.game and not self.game.finished():
0178             handBoard.player.showInfo()
0179         # first decorate walls - that will compute player.handBoard for
0180         # explainView
0181         if self.explainView:
0182             self.explainView.refresh()
0183 
0184     def setupUi(self):
0185         """prepare scene"""
0186         # pylint: disable=too-many-statements
0187         self.windTileset = Tileset(Internal.Preferences.windTilesetName)
0188 
0189     def showWall(self):
0190         """shows the wall according to the game rules (length may vary)"""
0191         UIWall(self.game)   # sets self.game.wall
0192 
0193     def abort(self):
0194         """abort current game"""
0195         # to be implemented by children
0196 
0197     def adjustSceneView(self):
0198         """adjust the view such that exactly the wanted things are displayed
0199         without having to scroll"""
0200         if self.game:
0201             with AnimationSpeed():
0202                 self.game.wall.decorate4()
0203                 for uiTile in self.game.wall.tiles:
0204                     if uiTile.board:
0205                         uiTile.board.placeTile(uiTile)
0206 
0207     def applySettings(self):
0208         """apply preferences"""
0209         self.mainWindow.actionAngle.setEnabled(
0210             bool(self.game) and Internal.Preferences.showShadows)
0211         with AnimationSpeed():
0212             for item in self.nonTiles():
0213                 if hasattr(item, 'tileset'):
0214                     item.tileset = Tileset.current()
0215 
0216     def prepareHand(self):
0217         """redecorate wall"""
0218         self.mainWindow.updateGUI()
0219         if self.game:
0220             with AnimationSpeed(Speeds.windMarker):
0221                 self.game.wall.decorate4()
0222 
0223     def updateSceneGUI(self):
0224         """update some actions, all auxiliary windows and the statusbar"""
0225         game = self.game
0226         mainWindow = self.mainWindow
0227         for action in [mainWindow.actionScoreGame, mainWindow.actionPlayGame]:
0228             action.setEnabled(not bool(game))
0229         mainWindow.actionAbortGame.setEnabled(bool(game))
0230         mainWindow.actionAngle.setEnabled(
0231             bool(game) and Internal.Preferences.showShadows)
0232         for view in [self.explainView, self.scoreTable]:
0233             if view:
0234                 view.refresh()
0235         self.__showBalance()
0236 
0237     def newLightSource(self):
0238         """next value"""
0239         oldIdx = LIGHTSOURCES.index(self.game.wall.lightSource)
0240         return LIGHTSOURCES[(oldIdx + 1) % 4]
0241 
0242     def changeAngle(self):
0243         """change the lightSource"""
0244         self.game.wall.lightSource = self.newLightSource()
0245         self.focusRect.refresh()
0246         self.mainWindow.adjustMainView()
0247 
0248     def __showBalance(self):
0249         """show the player balances in the status bar"""
0250         sBar = self.mainWindow.statusBar()
0251         if self.game:
0252             for idx, player in enumerate(self.game.players):
0253                 sbMessage = player.localName + ': ' + str(player.balance)
0254                 if sBar.hasItem(idx):
0255                     sBar.changeItem(sbMessage, idx)
0256                 else:
0257                     sBar.insertItem(sbMessage, idx, 1)
0258                     sBar.setItemAlignment(idx, Qt.AlignLeft)
0259         else:
0260             for idx in range(5):
0261                 if sBar.hasItem(idx):
0262                     sBar.removeItem(idx)
0263 
0264     def graphicsTileItems(self):
0265         """return all UITile in the scene"""
0266         return (x for x in self.items() if isinstance(x, UITile))
0267 
0268     def nonTiles(self):
0269         """return all other items in the scene"""
0270         return (x for x in self.items() if not isinstance(x, UITile))
0271 
0272     def removeTiles(self):
0273         """remove all tiles from scene"""
0274         for item in self.graphicsTileItems():
0275             self.removeItem(item)
0276         for wind in Wind.all:
0277             wind.marker = None
0278         self.focusRect.hide()
0279 
0280 
0281 class PlayingScene(GameScene):
0282 
0283     """scene with a playing game"""
0284 
0285     def __init__(self, parent):
0286         self._game = None
0287         self.__startingGame = True
0288         self._clientDialog = None
0289 
0290         super().__init__(parent)
0291 
0292     @GameScene.game.setter
0293     def game(self, value):  # pylint: disable=arguments-differ
0294         game = self._game
0295         changing = value != game
0296         GameScene.game.fset(self, value)
0297         if changing:
0298             self.__startingGame = False
0299         self.mainWindow.actionChat.setEnabled(
0300             bool(value)
0301             and bool(value.client)
0302             and bool(value.client.connection)
0303             and not value.client.connection.url.isLocalGame)
0304         self.mainWindow.actionChat.setChecked(
0305             bool(value)
0306             and bool(value.client)
0307             and bool(value.client.table.chatWindow))
0308 
0309     @property
0310     def clientDialog(self):
0311         """wrapper: hide dialog when it is set to None"""
0312         return self._clientDialog
0313 
0314     @clientDialog.setter
0315     def clientDialog(self, value):
0316         """wrapper: hide dialog when it is set to None"""
0317         if isAlive(self._clientDialog) and not value:
0318             self._clientDialog.timer.stop()
0319             self._clientDialog.hide()
0320         self._clientDialog = value
0321 
0322     def resizeEvent(self, unusedEvent):
0323         """main window changed size"""
0324         if self.clientDialog:
0325             self.clientDialog.placeInField()
0326 
0327     def setupUi(self):
0328         """create all other widgets
0329         we could make the scene view the central widget but I did
0330         not figure out how to correctly draw the background with
0331         QGraphicsView/QGraphicsScene.
0332         QGraphicsView.drawBackground always wants a pixmap
0333         for a huge rect like 4000x3000 where my screen only has
0334         1920x1200"""
0335         # pylint: disable=too-many-statements
0336         GameScene.setupUi(self)
0337         self.setObjectName("PlayingField")
0338 
0339         self.discardBoard = DiscardBoard()
0340         self.addItem(self.discardBoard)
0341 
0342         self.adjustSceneView()
0343 
0344     def showWall(self):
0345         """shows the wall according to the game rules (length may vary)"""
0346         GameScene.showWall(self)
0347         self.discardBoard.maximize()
0348 
0349     def abort(self):
0350         """abort current game"""
0351         def gotAnswer(result, autoPlaying):
0352             """user answered"""
0353             if result:
0354                 self.game = None
0355             else:
0356                 self.mainWindow.actionAutoPlay.setChecked(autoPlaying)
0357             return result
0358         if not self.game:
0359             return succeed(True)
0360         self.mainWindow.actionAutoPlay.setChecked(False)
0361         if self.game.finished():
0362             self.game = None
0363             return succeed(True)
0364         autoPlaying = self.mainWindow.actionAutoPlay.isChecked()
0365         return QuestionYesNo(i18n("Do you really want to abort this game?"), always=True).addCallback(
0366             gotAnswer, autoPlaying)
0367 
0368     def keyPressEvent(self, event):
0369         """if we have a clientDialog, pass event to it"""
0370         mod = event.modifiers()
0371         if mod in (Qt.NoModifier, Qt.ShiftModifier):
0372             if self.clientDialog:
0373                 self.clientDialog.keyPressEvent(event)
0374         GameScene.keyPressEvent(self, event)
0375 
0376     def adjustSceneView(self):
0377         """adjust the view such that exactly the wanted things are displayed
0378         without having to scroll"""
0379         if self.game:
0380             with AnimationSpeed():
0381                 self.discardBoard.maximize()
0382         GameScene.adjustSceneView(self)
0383 
0384     @property
0385     def startingGame(self):
0386         """are we trying to start a game?"""
0387         return self.__startingGame
0388 
0389     @startingGame.setter
0390     def startingGame(self, value):
0391         """are we trying to start a game?"""
0392         if value != self.__startingGame:
0393             self.__startingGame = value
0394             self.mainWindow.updateGUI()
0395 
0396     def applySettings(self):
0397         """apply preferences"""
0398         GameScene.applySettings(self)
0399         self.discardBoard.showShadows = Internal.Preferences.showShadows
0400 
0401     def toggleDemoMode(self, checked):
0402         """switch on / off for autoPlay"""
0403         if self.game:
0404             self.focusRect.refresh()  # show/hide it
0405             self.game.autoPlay = checked
0406             if checked and self.clientDialog:
0407                 self.clientDialog.proposeAction()
0408                                                 # an illegal action might have
0409                                                 # focus
0410                 self.clientDialog.selectButton()
0411                                                # select default, abort timeout
0412 
0413     def updateSceneGUI(self):
0414         """update some actions, all auxiliary windows and the statusbar"""
0415         if not isAlive(self):
0416             return
0417         GameScene.updateSceneGUI(self)
0418         game = self.game
0419         mainWindow = self.mainWindow
0420         if not game:
0421             connections = [x.connection for x in HumanClient.humanClients if x.connection]
0422             title = ', '.join('{name}/{url}'.format(name=x.username, url=x.url) for x in connections)
0423             if title:
0424                 decorateWindow(mainWindow, title)
0425         else:
0426             decorateWindow(mainWindow, str(game.seed))
0427         for action in [mainWindow.actionScoreGame, mainWindow.actionPlayGame]:
0428             action.setEnabled(not bool(game))
0429         mainWindow.actionAbortGame.setEnabled(bool(game))
0430         self.discardBoard.setVisible(bool(game))
0431         mainWindow.actionAutoPlay.setEnabled(not self.startingGame)
0432         mainWindow.actionChat.setEnabled(bool(game) and bool(game.client)
0433                                          and bool(game.client.connection)
0434                                          and not game.client.connection.url.isLocalGame and not self.startingGame)
0435             # chatting on tables before game started works with chat button per
0436             # table
0437         mainWindow.actionChat.setChecked(
0438             mainWindow.actionChat.isEnabled(
0439             ) and bool(
0440                 game.client.table.chatWindow))
0441 
0442     def changeAngle(self):
0443         """now that no animation is running, really change"""
0444         self.discardBoard.lightSource = self.newLightSource()
0445         GameScene.changeAngle(self)
0446 
0447 
0448 class ScoringScene(GameScene):
0449 
0450     """a scoring game"""
0451     # pylint: disable=too-many-instance-attributes
0452 
0453     def __init__(self, parent=None):
0454         self.scoringDialog = None
0455         super().__init__(parent)
0456         self.selectorBoard.hasLogicalFocus = True
0457 
0458     @GameScene.game.setter
0459     def game(self, value):  # pylint: disable=arguments-differ
0460         game = self._game
0461         changing = value != game
0462         GameScene.game.fset(self, value)
0463         if changing:
0464             if value is not None:
0465                 self.scoringDialog = ScoringDialog(scene=self)
0466             else:
0467                 self.scoringDialog.hide()
0468                 self.scoringDialog = None
0469 
0470     def handSelectorChanged(self, handBoard):
0471         """update all relevant dialogs"""
0472         GameScene.handSelectorChanged(self, handBoard)
0473         if self.scoringDialog:
0474             self.scoringDialog.slotInputChanged()
0475 
0476     def setupUi(self):
0477         """create all other widgets"""
0478         GameScene.setupUi(self)
0479         self.setObjectName("ScoringScene")
0480         self.selectorBoard = SelectorBoard()
0481         self.addItem(self.selectorBoard)
0482         QMetaObject.connectSlotsByName(self)
0483 
0484     def abort(self):
0485         """abort current game"""
0486         def answered(result):
0487             """got answer"""
0488             if result:
0489                 self.game = None
0490             return result
0491         if Debug.quit:
0492             logDebug('ScoringScene.abort invoked')
0493         if not self.game:
0494             return succeed(True)
0495         if self.game.finished():
0496             self.game = None
0497             return succeed(True)
0498         return QuestionYesNo(i18n("Do you really want to abort this game?"), always=True).addCallback(answered)
0499 
0500     def __moveTile(self, uiTile, wind, toConcealed):
0501         """the user pressed a wind letter or X for center, wanting to move a uiTile there"""
0502         # this tells the receiving board that this is keyboard, not mouse navigation>
0503         # needed for useful placement of the popup menu
0504         currentBoard = uiTile.board
0505         if wind == 'X':
0506             receiver = self.selectorBoard
0507         else:
0508             receiver = self.game.players[wind].handBoard
0509         movingMeld = currentBoard.uiMeldWithTile(uiTile)
0510         if receiver != currentBoard or toConcealed != movingMeld.meld.isConcealed:
0511             movingLastMeld = movingMeld.meld == self.computeLastMeld()
0512             if movingLastMeld:
0513                 self.scoringDialog.clearLastTileCombo()
0514             receiver.dropTile(uiTile, toConcealed)
0515             if movingLastMeld and receiver == currentBoard:
0516                 self.scoringDialog.fillLastTileCombo()
0517 
0518     def __navigateScoringGame(self, event):
0519         """keyboard navigation in a scoring game"""
0520         mod = event.modifiers()
0521         key = event.key()
0522         wind = chr(key % 128)
0523         windsX = ''.join(x.char for x in Wind.all)
0524         moveCommands = i18nc('kajongg:keyboard commands for moving tiles to the players '
0525                              'with wind ESWN or to the central tile selector (X)', windsX)
0526         uiTile = self.focusItem()
0527         if wind in moveCommands:
0528             # translate i18n wind key to ESWN:
0529             wind = windsX[moveCommands.index(wind)]
0530             self.__moveTile(uiTile, wind, bool(mod & Qt.ShiftModifier))
0531             return True
0532         if key == Qt.Key_Tab and self.game:
0533             tabItems = [self.selectorBoard]
0534             tabItems.extend(p.handBoard for p in self.game.players if p.handBoard.uiTiles)
0535             tabItems.append(tabItems[0])
0536             currentBoard = uiTile.board
0537             currIdx = 0
0538             while tabItems[currIdx] != currentBoard and currIdx < len(tabItems) - 2:
0539                 currIdx += 1
0540             tabItems[currIdx + 1].hasLogicalFocus = True
0541             return True
0542         return False
0543 
0544     def keyPressEvent(self, event):
0545         """navigate in the selectorboard"""
0546         mod = event.modifiers()
0547         if mod in (Qt.NoModifier, Qt.ShiftModifier):
0548             if self.game:
0549                 if self.__navigateScoringGame(event):
0550                     return
0551         GameScene.keyPressEvent(self, event)
0552 
0553     def adjustSceneView(self):
0554         """adjust the view such that exactly the wanted things are displayed
0555         without having to scroll"""
0556         if self.game:
0557             with AnimationSpeed():
0558                 self.selectorBoard.maximize()
0559         GameScene.adjustSceneView(self)
0560 
0561     def prepareHand(self):
0562         """redecorate wall"""
0563         GameScene.prepareHand(self)
0564         if self.scoringDialog:
0565             self.scoringDialog.clearLastTileCombo()
0566 
0567     def updateSceneGUI(self):
0568         """update some actions, all auxiliary windows and the statusbar"""
0569         if not isAlive(self):
0570             return
0571         GameScene.updateSceneGUI(self)
0572         game = self.game
0573         mainWindow = self.mainWindow
0574         for action in [mainWindow.actionScoreGame, mainWindow.actionPlayGame]:
0575             action.setEnabled(not bool(game))
0576         mainWindow.actionAbortGame.setEnabled(bool(game))
0577         self.selectorBoard.setVisible(bool(game))
0578         self.selectorBoard.setEnabled(bool(game))
0579 
0580     def changeAngle(self):
0581         """now that no animation is running, really change"""
0582         self.selectorBoard.lightSource = self.newLightSource()
0583         GameScene.changeAngle(self)
0584 
0585     def computeLastTile(self):
0586         """compile hand info into a string as needed by the scoring engine"""
0587         if self.scoringDialog:
0588             # is None while ScoringGame is created
0589             return self.scoringDialog.computeLastTile()
0590         return None
0591 
0592     def computeLastMeld(self):
0593         """compile hand info into a string as needed by the scoring engine"""
0594         if self.scoringDialog:
0595             # is None while ScoringGame is created
0596             cbLastMeld = self.scoringDialog.cbLastMeld
0597             idx = cbLastMeld.currentIndex()
0598             if idx >= 0:
0599                 return Meld(cbLastMeld.itemData(idx))
0600         return Meld()