Sunday, January 22, 2012

Qt Tutorial #1-14 Facing the Wall

This is based on Qt Tutorial #1-14 Facing the Wall The LCDRange is the same as that used in Qt Tutorial #1-13 and so is not included in this post.


 CannonField
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

'''
    PyQt4 conversion of Qt Tutorial 14
        The cannon can now handle mouse events.
    
    The differences from 113_battle.pyw are:   
        - re-organised code and added slot decorators
        - added methods: mousePressEvent(), mouseMoveEvent(), mouseReleaseEvent()
                         _paintBarrier(), _barrierRect(), _barrelHit()
        - added attribute: barrelPressed, timerCount
                                  
last modified: 2012-01-22 jg
ref: 
    http://doc.trolltech.com/3.3/tutorial1-14.html
'''

from math import (cos, sin, atan)
from random import (seed, randrange)
from PyQt4.QtGui import (QWidget, QColor, QPainter, QSizePolicy,
                         QRegion, QPixmap, QFont, QMatrix)
from PyQt4.QtCore import (Qt, pyqtSignal, pyqtSlot, QRect, QTimer,
                          QPoint, QTime, QSize)

class CannonField(QWidget):
    cRect = None                     # class attribute, only one can exist
    bRect = QRect(33, -4, 15, 8)     # class attribute, cannon barrel definition
    firstTarget = True               # first time we're creating a target

    def __init__(self, parent=None):
        super(QWidget, self).__init__(parent)
        self.setObjectName('cannonField')
        self.ang = 45
        self.f = 0          # force
        self.target = QPoint(0, 0)
        self.gameEnded = False
        self.barrelPressed = False

        # add timer to handle shooting
        self.autoShootTimer = QTimer(self)
        self.autoShootTimer.timeout.connect(self._moveShot)
        self.timerCount = 0
        self.shoot_ang = 0
        self.shoot_f = 0

        # set background colour
        pal = self.palette()
        pal.setColor(self.backgroundRole(), QColor(250, 250, 200))
        self.setPalette(pal)
        self.setAutoFillBackground(True)

        self.newTarget()    # create a target

    # custom signals -----------------------------------------------------------------------------
    angleChanged = pyqtSignal(int, name="angleChanged")
    forceChanged = pyqtSignal(int, name="forceChanged")
    missed = pyqtSignal(name="missed")
    hit = pyqtSignal(name="hit")
    canShoot = pyqtSignal(bool, name="canShoot")

    # custom slots -------------------------------------------------------------------------------
    @pyqtSlot()
    def setGameOver(self):
        if self.gameEnded:
            return
        if self.isShooting():
            self.autoShootTimer.stop()
        self.gameEnded = True
        self.repaint()

    @pyqtSlot()
    def restartGame(self):
        if self.isShooting():
            self.autoShootTimer.stop()
        self.gameEnded = False
        self.target = QPoint(0, 0)    # force repaint of old target  
        self.repaint()
        self.canShoot.emit(True)

    @pyqtSlot()
    def newTarget(self):
        if CannonField.firstTarget:
            # seed random number generator
            CannonField.firstTarget = False
            midnight = QTime(0, 0, 0)
            seed(midnight.secsTo(QTime.currentTime()))

        r = QRegion(self._targetRect())
        self.target = QPoint(200 + randrange(0, 190),
                             10 + randrange(0, 255))
        self.repaint(r.unite(QRegion(self._targetRect())))

    @pyqtSlot()
    def shoot(self):
        if self.isShooting():
            return

        self.timerCount = 0
        self.shoot_ang = self.ang
        self.shoot_f = self.f
        self.autoShootTimer.start(1)
        self.canShoot.emit(False)

    @pyqtSlot(int)
    def setAngle(self, degrees):
        if degrees < 5: degrees = 5
        elif degrees > 70: degrees = 70
        elif self.ang == degrees: return

        self.ang = degrees
        self.repaint(self._cannonRect())
        self.angleChanged.emit(self.ang)

    @pyqtSlot(int)
    def setForce(self, newton):
        if newton < 0: newton = 0
        elif self.f == newton: return

        self.f = newton
        self.forceChanged.emit(self.f)

    @pyqtSlot()
    def _moveShot(self):
        r = self._shotRect()
        self.timerCount += 1
        shotR = self._shotRect()

        if shotR.intersects(self._targetRect()):
            self.autoShootTimer.stop()
            self.hit.emit()
            self.canShoot.emit(True)
        elif (shotR.x() > self.width() or
              shotR.y() > self.height() or
              shotR.intersects(self._barrierRect())):
            self.autoShootTimer.stop()
            self.missed.emit()
            self.canShoot.emit(True)
        else:
            r = r.unite(shotR)
        self.repaint(r)

    # public methods ------------------------------------------------------------------------------------
    def gameOver(self):
        return self.gameEnded

    def isShooting(self):
        return self.autoShootTimer.isActive()

    # method overrides ---------------------------------------------------------------------------
    #    override parent (QWidget) class methods to provide customised behaviours

    def sizePolicy(self):
        return QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)

    def sizeHint(self):
        return QSize(400, 300)

    def paintEvent(self, event):
        updateR = event.rect()
        p = QPainter(self)

        if self.gameEnded:
            p.setPen(Qt.black)
            p.setFont(QFont("Courier", 48, QFont.Bold))
            p.drawText(self.rect(), Qt.AlignCenter, "Game Over")

        if updateR.intersects(self._cannonRect()):
            self._paintCannon(p)
        if updateR.intersects(self._barrierRect()):
            self._paintBarrier(p)
        if self.autoShootTimer.isActive() and updateR.intersects(self._shotRect()):
            self._paintShot(p)
        if updateR.intersects(self._targetRect()):
            self._paintTarget(p)

    def mousePressEvent(self, evt):
        if evt.button() != Qt.LeftButton:
            return
        if self._barrelHit(evt.pos()):
            self.barrelPressed = True

    def mouseMoveEvent(self, evt):
        if not self.barrelPressed:
            return

        pnt = QPoint(evt.pos())
        if pnt.x() <= 0:
            pnt.setX(1)
        if pnt.y() >= self.height():
            pnt.setY(self.height() - 1)

        rad = atan((self.rect().bottom() - pnt.y()) / pnt.x())
        self.setAngle(int(rad * 180 / 3.14159265))

    def mouseReleaseEvent(self, evt):
        if evt.button() == Qt.LeftButton:
            self.barrelPressed = False

    # private methods ----------------------------------------------------------------------------
    #    technically, methods which should not be called by external classes

    def _cannonRect(self):
        if not CannonField.cRect:   # create once
            r = QRect(0, 0, 50, 50)
            r.moveBottomLeft(self.rect().bottomLeft())
            return r
        else:   # already defined so return existing rectangle
            return CannonField.cRect

    def _shotRect(self):
        gravity = 4
        time = self.timerCount / 4.0
        velocity = self.shoot_f
        radians = self.shoot_ang * 3.14159265 / 180

        velx = velocity * cos(radians)
        vely = velocity * sin(radians)
        x0 = (CannonField.bRect.right() + 5) * cos(radians)
        y0 = (CannonField.bRect.right() + 5) * sin(radians)
        x = x0 + velx * time
        y = y0 + vely * time - 0.5 * gravity * time * time

        r = QRect(0, 0, 6, 6)
        r.moveCenter(QPoint(int(x), self.height() - 1 - int(y)))
        return r

    def _targetRect(self):
        r = QRect(0, 0, 20, 10)
        r.moveCenter(QPoint(self.target.x(), self.height() - 1 - self.target.y()))
        return r

    def _barrierRect(self):
        return QRect(145, self.height() - 100, 15, 100)

    def _barrelHit(self, p):
        # p = mouse position
        mtx = QMatrix()
        mtx.translate(0, self.height() - 1)
        mtx.rotate(-self.ang)
        mtx = QMatrix(mtx.inverted()[0])    # returns a tuple, 1st element is 
                                            # the inverted matrix
        print(self.bRect.contains(mtx.map(p)))
        return self.bRect.contains(mtx.map(p))

    # private custom paint methods -------------------------------------------------------------------------------
    def _paintTarget(self, p):
        p.setBrush(Qt.red)
        p.setPen(Qt.black)
        p.drawRect(self._targetRect())

    def _paintShot(self, p):
        p.setBrush(Qt.black)
        p.setPen(Qt.NoPen)
        p.drawRect(self._shotRect())

    def _paintBarrier(self, p):
        p.setBrush(Qt.yellow)
        p.setPen(Qt.black)
        p.drawRect(self._barrierRect())

    def _paintCannon(self, p):
        cr = self._cannonRect()
        pix = QPixmap(cr.size())
        pix.fill(self, cr.topLeft())

        tmp = QPainter(pix)
        tmp.setBrush(Qt.blue)     # brush colour for filling object
        tmp.setPen(Qt.NoPen)      # no special edges

        # set QPainter's origin (0,0) coords to the bottom-left
        tmp.translate(0, pix.height() - 1)

        # draw a quarter circle in the bottom left corner
        tmp.drawPie(QRect(-35, -35, 70, 70), 0, 90 * 16)

        # rotate counter-clockwise 'ang' degrees around the origin
        # and draw the cannon's barrel
        tmp.rotate(-self.ang)
        tmp.drawRect(CannonField.bRect)
        tmp.end()

        # paint the pixmap on the screen
        p.drawPixmap(cr.topLeft(), pix)


def main():
    import sys
    from PyQt4.QtGui import (QApplication)
    app = QApplication(sys.argv)    # required

    w = CannonField()
    w.setGeometry(100, 100, 500, 355)
    w.show()
    #w.setGameOver()

    sys.exit(app.exec_())   # start main event loop, exit when app closed

if __name__ == '__main__':
    main()

GameBoard (previously MyWidget)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

'''
    PyQt4 conversion of Qt Tutorial 14
    
    This is the final example: a complete game.
    We add keyboard accelerators and introduce mouse events to CannonField. 
    We put a frame around the CannonField and add a barrier (wall) to make the game more challenging.
    
    The differences from 113_battle.pyw are:
        - modified _init__()
        - added override for keyPressEvent()
        - blocked resizing, leaving minimizing as only option

    NOTES:
    =====
    The original C++ code placed the cannonField in a styled QVBox.
    In PyQt4 there is no QVBox and QVBoxLayout does not have a 
    setFrameStyle() method.  To reproduce the same look had to:
        1. Create a QFrame
        2. Style the frame
        3. Create a QVBoxLayout
        4. Set the layouts margins to 0
        4. Set the frame layout to the QVBoxLayout
        5. Add the cannonField to the layout
        6. Add the frame to the grid

    PyQt4 does not include QAccel (for creating keyboard accelerators)
    To reproduce the same behaviour i.e. trigger 'shoot()' if 'Enter'
    or 'Return' pressed, needed to override the keyPressEvent()

    BEHAVIOUR:
    =========
    The cannon now shoots when you press Enter. You can also position the cannon's
    angle using the mouse. The barrier makes it a little more challenging to play 
    the game. We also have a nice looking frame around the CannonField.
        
last modified: 2012-01-22 jg
ref: 
    http://doc.trolltech.com/3.3/tutorial1-14.html
'''
import sys
from PyQt4.QtGui import (QApplication, QWidget, QPushButton, QFont, QLabel, QLCDNumber,
                         QVBoxLayout, QGridLayout, QHBoxLayout, QFrame, QSizePolicy)
from PyQt4.QtCore import (Qt)
from t114_lcdrange import (LCDRange)
from t114_cannon import (CannonField)

class GameBoard(QWidget):
    def __init__(self, parent=None, name=''):
        super(GameBoard, self).__init__(parent)
        if name:
            self.setObjectName(name)

        quitBtn = QPushButton('&Quit', self)
        quitBtn.setFont(QFont("Times", 18, QFont.Bold))
        quitBtn.clicked.connect(QApplication.instance().quit)

        angle = LCDRange(wlabel="ANGLE")
        angle.setRange(5, 70)

        # add LCDRange widget to handle force
        force = LCDRange(wlabel="FORCE")
        force.setRange(10, 50)
        self.cannonField = CannonField(self)
        self.cannonField.setContentsMargins(10, 10, 10, 10)
        angle.slider.valueChanged.connect(self.cannonField.setAngle)
        self.cannonField.angleChanged.connect(angle.setValue)

        # add event handling for 'force'
        force.slider.valueChanged.connect(self.cannonField.setForce)
        self.cannonField.forceChanged.connect(force.setValue)

        # handle target hits/misses
        self.cannonField.hit.connect(self.hit)
        self.cannonField.missed.connect(self.missed)

        # buttons
        shootBtn = QPushButton('&Shoot', self)
        shootBtn.setFont(QFont("Times", 18, QFont.Bold))
        shootBtn.clicked.connect(self.fire)
        self.cannonField.canShoot.connect(self.setEnabled)

        restartBtn = QPushButton("&New Game", self)
        restartBtn.setFont(QFont("Times", 18, QFont.Bold))
        restartBtn.clicked.connect(self.newGame)

        self.hits = QLCDNumber(2, self)
        self.shotsLeft = QLCDNumber(2, self)
        self.hitsL = QLabel("HITS", self)
        self.shotsLeftL = QLabel("SHOTS LEFT", self)

        # layout widgets
        grid = QGridLayout()
        grid.addWidget(quitBtn, 0, 0)

        # create a frame to hold the cannonfield
        frame = QFrame()
        frame.setFrameStyle(QFrame.WinPanel | QFrame.Sunken)
        cfvbox = QVBoxLayout()
        cfvbox.setMargin(0)
        cfvbox.addWidget(self.cannonField)
        frame.setLayout(cfvbox)

        grid.addWidget(frame, 1, 1)
        grid.setColumnStretch(1, 10)

        # add the angle and force widgets 
        leftBox = QVBoxLayout()
        grid.addLayout(leftBox, 1, 0)
        leftBox.addWidget(angle)
        leftBox.addWidget(force)

        # add the 'shoot' button
        topBox = QHBoxLayout()
        grid.addLayout(topBox, 0, 1)
        topBox.addWidget(shootBtn)
        topBox.addWidget(self.hits)
        topBox.addWidget(self.hitsL)
        topBox.addWidget(self.shotsLeft)
        topBox.addWidget(self.shotsLeftL)
        topBox.addStretch(1)
        topBox.addWidget(restartBtn)

        self.setLayout(grid)

        angle.setValue(60)
        force.setValue(25)
        angle.setFocus()    # give the LCDRange object keyboard focus

        self.setFixedSize(500, 355) # prevent resizing

        self.newGame()

    def keyPressEvent(self, evt):
        if evt.key() in (Qt.Key_Enter, Qt.Key_Return):
            self.fire()
            evt.accept()

    def fire(self):
        if self.cannonField.gameOver() or self.cannonField.isShooting():
            return
        self.shotsLeft.display(self.shotsLeft.intValue() - 1)
        self.cannonField.shoot()

    def hit(self):
        self.hits.display(self.hits.intValue() + 1)
        if self.shotsLeft.intValue() == 0:
            self.cannonField.setGameOver()
        else:
            self.cannonField.newTarget()

    def missed(self):
        if self.shotsLeft.intValue() == 0:
            self.cannonField.setGameOver()

    def newGame(self):
        self.shotsLeft.display(15)
        self.hits.display(0)
        self.cannonField.restartGame()
        self.cannonField.newTarget()

def main():
    app = QApplication(sys.argv)    # required

    w = GameBoard()
    w.setGeometry(100, 100, 500, 355)
    w.setWindowTitle("Battle Game")
    w.show()

    sys.exit(app.exec_())   # start main event loop, exit when app closed

if __name__ == '__main__':
    main()