File indexing completed on 2024-03-24 04:04:35
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()