diff --git a/pelita/game.py b/pelita/game.py index cd777bc58..f30e338ec 100644 --- a/pelita/game.py +++ b/pelita/game.py @@ -67,6 +67,35 @@ (64, 32): (20, 60), } +class QtViewer: + def __init__(self, *, address, controller, geometry=None, delay=None, stop_after=None): + self.proc = self._run_external_viewer(address, controller, geometry=geometry, delay=delay, stop_after=stop_after) + + def _run_external_viewer(self, subscribe_sock, controller, geometry, delay, stop_after): + viewer_args = [ str(subscribe_sock) ] + if controller: + viewer_args += ["--controller-address", str(controller)] + if geometry: + viewer_args += ["--geometry", "{0}x{1}".format(*geometry)] + if delay: + viewer_args += ["--delay", str(delay)] + if stop_after is not None: + viewer_args += ["--stop-after", str(stop_after)] + + qtviewer = 'pelita.scripts.pelita_qtviewer' + external_call = [sys.executable, + '-m', + qtviewer] + viewer_args + _logger.debug("Executing: %r", external_call) + # os.setsid will keep the viewer from closing when the main process exits + # a better solution might be to decouple the viewer from the main process + if _mswindows: + p = subprocess.Popen(external_call, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + p = subprocess.Popen(external_call, preexec_fn=os.setsid) + return p + + class TkViewer: def __init__(self, *, address, controller, geometry=None, delay=None, stop_after=None, stop_after_kill=False, fullscreen=False): @@ -284,6 +313,16 @@ def setup_viewers(viewers, print_result=True): geometry=viewer_opts.get('geometry'), delay=viewer_opts.get('delay'), fullscreen=viewer_opts.get('fullscreen')) + elif viewer == 'qt': + zmq_context = zmq.Context() + zmq_publisher = ZMQPublisher(address='tcp://127.0.0.1', zmq_context=zmq_context) + viewer_state['viewers'].append(zmq_publisher) + viewer_state['controller'] = Controller(zmq_context=zmq_context) + + _proc = QtViewer(address=zmq_publisher.socket_addr, controller=viewer_state['controller'].socket_addr, + stop_after=viewer_opts.get('stop_at'), + geometry=viewer_opts.get('geometry'), + delay=viewer_opts.get('delay')) else: raise ValueError(f"Unknown viewer {viewer}.") diff --git a/pelita/scripts/pelita_main.py b/pelita/scripts/pelita_main.py index e9e64148c..cfa7d5498 100755 --- a/pelita/scripts/pelita_main.py +++ b/pelita/scripts/pelita_main.py @@ -274,7 +274,9 @@ def long_help(s): dest='viewer', help=long_help('Use the progress viewer.')) viewer_opt.add_argument('--tk', action='store_const', const='tk', dest='viewer', help='Use the tk viewer (default).') -parser.set_defaults(viewer='tk') +viewer_opt.add_argument('--qt', action='store_const', const='qt', + dest='viewer', help='Use the qt viewer (default).') +parser.set_defaults(viewer='qt') advanced_settings = parser.add_argument_group('Advanced settings') advanced_settings.add_argument('--reply-to', type=str, metavar='URL', dest='reply_to', @@ -344,7 +346,7 @@ def main(): if args.viewer == 'null': viewers = [] - elif args.viewer == 'tk': + elif args.viewer in ('tk', 'qt'): geometry = args.geometry delay = int(1000./args.fps) stop_at = args.stop_at @@ -357,7 +359,7 @@ def main(): "stop_at": stop_at, "stop_after_kill": stop_after_kill } - viewers = [('tk', viewer_options)] + viewers = [(args.viewer, viewer_options)] else: viewers = [(args.viewer, None)] diff --git a/pelita/scripts/pelita_qtviewer.py b/pelita/scripts/pelita_qtviewer.py new file mode 100755 index 000000000..34025a056 --- /dev/null +++ b/pelita/scripts/pelita_qtviewer.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 + +import argparse +import os +import sys + +from PyQt6.QtWidgets import QApplication + +import pelita +from pelita.ui.qt.qt_viewer import QtViewer + +from .script_utils import start_logging + + +def geometry_string(s): + """Get a X-style geometry definition and return a tuple. + + 600x400 -> (600,400) + """ + try: + x_string, y_string = s.split('x') + geometry = (int(x_string), int(y_string)) + except ValueError: + msg = "%s is not a valid geometry specification" %s + raise argparse.ArgumentTypeError(msg) + return geometry + +LOG_QT = os.environ.get("PELITA_LOG_QT", None) + +parser = argparse.ArgumentParser(description='Open a Qt viewer') +parser.add_argument('subscribe_sock', metavar="URL", type=str, + help='subscribe socket') +parser.add_argument('--controller-address', metavar="URL", type=str, + help='controller address') +parser.add_argument('--geometry', type=geometry_string, + help='geometry') +parser.add_argument('--delay', type=int, + help='delay') +parser.add_argument('--export', type=str, metavar="FOLDER", help='png export path') +parser.add_argument('--stop-after', type=int, metavar="N", + help='Stop after N rounds.') +parser._optionals = parser.add_argument_group('Options') +parser.add_argument('--version', help='show the version number and exit', + action='store_const', const=True) +parser.add_argument('--log', help='print debugging log information to' + ' LOGFILE (default \'stderr\')', + metavar='LOGFILE', const='-', nargs='?') + +def main(): + args = parser.parse_args() + if args.version: + print("Pelita {}".format(pelita.__version__)) + sys.exit(0) + + if LOG_QT or args.log: + start_logging(args.log) + + viewer_args = { + 'address': args.subscribe_sock, + 'controller_address': args.controller_address, + 'geometry': args.geometry, + 'delay': args.delay, + 'export': args.export, + 'stop_after': args.stop_after + } + app = QApplication(sys.argv) + app.setApplicationName("Pelita") + app.setApplicationDisplayName("Pelita") + + mainWindow = QtViewer(**{k: v for k, v in list(viewer_args.items()) if v is not None}) + mainWindow.show() + ret = app.exec() + sys.exit(ret) + +if __name__ == '__main__': + main() diff --git a/pelita/ui/qt/qt_items.py b/pelita/ui/qt/qt_items.py new file mode 100644 index 000000000..510d22563 --- /dev/null +++ b/pelita/ui/qt/qt_items.py @@ -0,0 +1,340 @@ + +import cmath +import math +from contextlib import contextmanager + +from PyQt6 import QtCore +from PyQt6.QtCore import QPointF, QRectF +from PyQt6.QtGui import (QColor, QColorConstants, QFont, QPainter, + QPainterPath, QPen) +from PyQt6.QtWidgets import QGraphicsItem + +from ...gamestate_filters import manhattan_dist + +black = QColorConstants.Black + +@contextmanager +def use_painter(painter: QPainter): + # this should automatically ensure that a painter used in a + # trafo is cleaned up after (in case we want to catch an exception) + # not sure if this is working as expected in all cases + painter.save() + try: + yield painter + finally: + painter.restore() + +def de_casteljau_2d(t, coefs): + # given coefficients [ax, ay, bx, by, ...] for a bezier curve [0, 1] + # return coefficients for a bezier curve [t, 1] + + beta = list(coefs) # values in this list are overridden + n = len(beta) // 2 + for j in range(1, n): + for k in range(n - j): + beta[2 * k] = beta[2 * k] * (1 - t) + beta[2 * (k + 1)] * t + beta[2 * k + 1] = beta[2 * k + 1] * (1 - t) + beta[2 * (k + 1) + 1] * t + return beta + +def pairwise_reverse(iterable): + # reverse [ax, ay, bx, by, ..., zx, zy] pairwise to + # [zx, xy, ..., bx, by, ax, ay] + + def gen(): + for i in reversed(range(len(iterable) // 2)): + yield iterable[i * 2] + yield iterable[i * 2 + 1] + return list(gen()) + +def de_casteljau_2d_reversed(t, coefs): + # given coefficients [ax, ay, bx, by, ...] for a bezier curve [0, 1] + # return coefficients for a bezier curve [0, t] + return pairwise_reverse(de_casteljau_2d(t, pairwise_reverse(coefs))) + +class ArrowItem(QGraphicsItem): + def __init__(self, pos, color, req_pos, old_pos, success, parent=None): + super().__init__(parent) + self.setPos(pos[0] + 0.5, pos[1] + 0.5) + self.color = color + self.req_pos = req_pos + self.old_pos = old_pos + self.success = success + + def move(self, pos, color, req_pos, old_pos, success): + self.setPos(pos[0] + 0.5, pos[1] + 0.5) + self.color = color + self.req_pos = req_pos + self.old_pos = old_pos + self.success = success + + def paint(self, painter: QPainter, option, widget): + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + pen = QPen(self.color) + pen.setWidthF(0.05) + painter.setPen(pen) + + if not self.success: + # draw a cross on the previous position + painter.drawLine(QPointF(- 0.3, + 0.3), QPointF(+ 0.3, - 0.3)) + painter.drawLine(QPointF(- 0.3, - 0.3), QPointF(+ 0.3, + 0.3)) + + dist = manhattan_dist(self.req_pos, self.old_pos) + if dist == 0: + # we draw a circle with an arrow head + path = QPainterPath() + path.arcMoveTo(QRectF(- 0.3, - 0.3, 0.6, 0.6), 0) + path.arcTo(QRectF(- 0.3, - 0.3, 0.6, 0.6), 0, -320) + + rotation = 12 + line_pos_1 = (0.3 - 0.1, 0.15) + line_pos_2 = (0.3 + 0.1, 0.15) + + def rotate_around(pos, origin, rotation): + # we need to rotate the angle of the arrow slightly so that it looks nicer + angle = math.pi * rotation / 180 + + ox, oy = origin + px, py = pos + + qx = ox + math.cos(angle) * (px - ox) - math.sin(angle) * (py - oy) + qy = oy + math.sin(angle) * (px - ox) + math.cos(angle) * (py - oy) + + return qx, qy + + path.moveTo(*rotate_around(line_pos_1, (0.3, 0), rotation)) + path.lineTo(QPointF(0.3, 0)) + path.moveTo(*rotate_around(line_pos_2, (0.3, 0), rotation)) + path.lineTo(QPointF(0.3, 0)) + + pen = painter.pen() + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.rotate(- 78) + painter.drawPath(path) + else: + # TODO: Arrows should match the circle design + + dx = (self.req_pos[0] - self.old_pos[0]) + sgn_dx = abs(dx) / dx if dx else 1 + dy = (self.req_pos[1] - self.old_pos[1]) + sgn_dy = abs(dy) / dy if dy else 1 + rotation = math.degrees(cmath.phase(dx - dy*1j)) + + painter.drawLine(QPointF(dx, dy), QPointF(0, 0)) + if dx != 0: + painter.drawLine(QPointF(dx, dy), QPointF(sgn_dx * (abs(dx) - 0.3), sgn_dy * (abs(dy) + 0.3))) + painter.drawLine(QPointF(dx, dy), QPointF(sgn_dx * (abs(dx) - 0.3), sgn_dy * (abs(dy) - 0.3))) + if dy != 0: + painter.drawLine(QPointF(dx, dy), QPointF(sgn_dx * (abs(dx) + 0.3), sgn_dy * (abs(dy) - 0.3))) + painter.drawLine(QPointF(dx, dy), QPointF(sgn_dx * (abs(dx) - 0.3), sgn_dy * (abs(dy) - 0.3))) + + def boundingRect(self) -> QRectF: + # TODO: This could be more exact, depending on the actual direction of the arrow + return QRectF(-1, -1, 3, 3) + + +class FoodItem(QGraphicsItem): + def __init__(self, pos, color, parent=None): + super().__init__(parent) + self.setPos(pos[0] + 0.5, pos[1] + 0.5) + self.color = color + + def paint(self, painter: QPainter, option, widget): + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setBrush(self.color) + painter.setPen(QPen(black, 0.02)) + + painter.drawEllipse(QRectF(-0.2, -0.2, 0.4, 0.4)) + + def boundingRect(self) -> QRectF: + # a little wider than the food + return QRectF(-0.3, -0.3, 0.6, 0.6) + +def step_function(i: float) -> float: + # TODO: This might be used, in case we want to simulate a non-smooth animation + if i < 1/3: return 0.0 + if i < 2/3: return 0.5 + return 1 + +class BotItem(QGraphicsItem): + def __init__(self, color, shadow=False, parent=None): + super().__init__(parent) + self.shadow = shadow + self.color = color + self.bot_type = "D" + + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True) + + # TODO: Animation time should depend on the fps (or only be active in single-step mode) + self._pos_animation = QtCore.QVariantAnimation() + #easing_curve = QtCore.QEasingCurve(QtCore.QEasingCurve.Type.Custom) + #easing_curve.setCustomType(step_function) + #self._pos_animation.setEasingCurve(easing_curve) + self._pos_animation.valueChanged.connect(self.setPos) + + def move_smooth(self, start, end, duration=50): + if self._pos_animation.state() == QtCore.QAbstractAnimation.State.Running: + self._pos_animation.stop() + self._pos_animation.setDuration(duration) + self._pos_animation.setStartValue(start) + self._pos_animation.setEndValue(end) + self._pos_animation.start() + + def move_to(self, start, pos, animate=False): + if not animate or start is None or pos is None: + self.setPos(pos[0], pos[1]) + else: + # must be a QPointF for the QVariantAnimation + self.move_smooth(QPointF(*start), QPointF(*pos)) + + def boundingRect(self): + # Bounding rect must be a little bigger for the outline + return QRectF(-0.02, -0.02, 1.02, 1.02) + + def paint(self, painter: QPainter, option, widget): + if self.bot_type == "D": + paint_destroyer(painter, self.color, self.direction, self.shadow) + else: + paint_harvester(painter, self.color, self.direction, self.shadow) + + +def paint_destroyer(painter: QPainter, color, direction, shadow): + + h = 0.3 # the amplitude of the ‘feet’. higher -> more kraken-like + knee_y = 7/8 # y-position of the knees + cx = 0.5 # how much the feet are slanted. could be used in animation + + # number of full bezier curves = number of bumps between feet + n_bumps = 3 + n_parts = n_bumps * 2 + 1 + + # bezier coeffcients + sine_like_bezier = [0, knee_y, cx, knee_y + h, 1 - cx, knee_y - h, 1, knee_y] + # quarter = de_casteljau_2d(3/4, sine_like_bezier) + half_bezier = de_casteljau_2d_reversed(1/2, sine_like_bezier) + + sx, sy, c1x, c1y, c2x, c2y, ex, ey = sine_like_bezier + + # start a new path + path = QPainterPath(QPointF(sx, sy)) + + for i in range(n_bumps): + # we need to shrink the curve in the width dimension + # and offset it accordingly + offsetx = (2 * i) / n_parts + + c1x = 2 * sine_like_bezier[2] / n_parts + offsetx + c1y = sine_like_bezier[3] + + c2x = 2 * sine_like_bezier[4] / n_parts + offsetx + c2y = sine_like_bezier[5] + + ex = 2 * sine_like_bezier[6] / n_parts + offsetx + ey = sine_like_bezier[7] + + path.cubicTo(c1x, c1y, c2x, c2y, ex, ey) + + # half bezier curve that is missing + offsetx = (n_parts - 1) / n_parts + c1x = 2 * half_bezier[2] / n_parts + offsetx + c1y = half_bezier[3] + + c2x = 2 * half_bezier[4] / n_parts + offsetx + c2y = half_bezier[5] + + ex = 2 * half_bezier[6] / n_parts + offsetx + ey = half_bezier[7] + path.cubicTo(c1x, c1y, c2x, c2y, ex, ey) + + # ghost head + + path.lineTo(1, knee_y) + path.lineTo(1, 0.5) + path.cubicTo(1, -0.15, 0, -0.15, 0, 0.5) + path.closeSubpath() + + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + if not shadow: + painter.setBrush(color) + painter.setOpacity(0.9) # Ghosts are a little transparent + painter.setPen(QPen(black, 0.02)) + + painter.drawPath(path) + + draw_eye(painter, 0.3, 0.3) + draw_eye(painter, 0.7, 0.3) + + +def paint_harvester(painter: QPainter, color, direction, shadow): + rotation = math.degrees(cmath.phase(direction[0] - direction[1]*1j)) + # ensure that the eye is never at the bottom + if 179 < rotation < 181: + flip_eye = True + else: + flip_eye = False + + bounding_rect = QRectF(0, 0, 1, 1) + # bot body + path = QPainterPath(QPointF(0.5, 0.5)) + path.arcTo(bounding_rect, 20, 320) + path.closeSubpath() + + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + if not shadow: + painter.setBrush(color) + painter.setPen(QPen(black, 0.02)) + + # rotate around the 0.5, 0.5 centre point + painter.translate(0.5, 0.5) + painter.rotate(-rotation) + painter.translate(-0.5, -0.5) + + painter.drawPath(path) + if not flip_eye: + draw_eye(painter, 0.7, 0.2) + else: + draw_eye(painter, 0.7, 0.8) + + + +def draw_eye(painter, x, y): + # draw an eye to (relative) location x, y + # assumes that the painter has been trafo’d to a position already + with use_painter(painter) as p: + # eyes + eye_size = 0.1 + p.setBrush(QColor(235, 235, 30)) + p.drawEllipse(QRectF(x - eye_size, y - eye_size, eye_size * 2, eye_size * 2)) + +class EndTextOverlay(QGraphicsItem): + def __init__(self, text, parent: QGraphicsItem = None) -> None: + super().__init__(parent) + self.text = text + + def boundingRect(self): + return QRectF(1, 1, 121, 81) + + def paint(self, painter: QPainter, option, widget): + fill = QColor("#FFC903") + outline = QColor("#ED1B22") + + font = QFont(["Courier", "Courier New"]) + + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.scale(1/6, 1/6) + + painter.setBrush(outline) + painter.setFont(font) + + + # TODO This should be done with a path and outline (drawText cannot do that) + painter.setPen(QPen(outline, 2)) + for i in [-2, -1, 0, 1, 2]: + for j in [-2, -1, 0, 1, 2]: + painter.drawText(QRectF(i * 0.3, j * 0.3, 220, 80), QtCore.Qt.AlignmentFlag.AlignCenter, self.text) + + painter.setPen(QPen(fill, 2)) + painter.drawText(QRectF(0, 0, 220, 80), QtCore.Qt.AlignmentFlag.AlignCenter, self.text) + diff --git a/pelita/ui/qt/qt_pixmaps.py b/pelita/ui/qt/qt_pixmaps.py new file mode 100644 index 000000000..06805e8b5 --- /dev/null +++ b/pelita/ui/qt/qt_pixmaps.py @@ -0,0 +1,121 @@ + +from PyQt6 import QtCore, QtGui +from PyQt6.QtCore import QPointF, QRectF +from PyQt6.QtGui import QBrush, QPainter, QPen + + +def generate_wall(painter: QPainter, shape, walls, dark_mode=True): + maze = [tuple(pos) for pos in walls] + width, height = shape + + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + #painter.scale(12, 12) + + pen_size = 0.05 + painter.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0), pen_size)) + + blue_col = QtGui.QColor(94, 158, 217) + red_col = QtGui.QColor(235, 90, 90) + brown_col = QtGui.QColor(48, 26, 22) + + def move_pos(a, b): + ax, ay = a + bx, by = b + return (ax + bx, ay + by) + + if not dark_mode: + pen_size = 0.6 + painter.setPen(QPen(brown_col, pen_size, cap=QtCore.Qt.PenCapStyle.RoundCap)) + painter.setBrush(QBrush(brown_col, QtCore.Qt.BrushStyle.SolidPattern)) + + for position in maze: + painter.save() + # Translate to the centre of a cell + painter.translate(position[0] + 0.5, position[1] + 0.5) + + x, y = position + neighbors = [(dx, dy) + for dx in [-1, 0, 1] + for dy in [-1, 0, 1] + if (x + dx, y + dy) in maze] + + if not ((0, 1) in neighbors or + (1, 0) in neighbors or + (0, -1) in neighbors or + (-1, 0) in neighbors): + # if there is no direct neighbour, we can’t connect. + # draw only a small dot. + # TODO add diagonal lines + + # PenCapStype.RoundCap means that the stroke will extend by pen_size/2 to each end + painter.drawLine(QPointF(-0.25, 0), QPointF(0.25, 0)) + + else: + neighbors_check = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)] + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + if (dx, dy) in neighbors: + if dx == dy == 0: + continue + if dx * dy != 0: + continue + index = neighbors_check.index((dx, dy)) + if (neighbors_check[(index + 1) % len(neighbors_check)] in neighbors and + neighbors_check[(index - 1) % len(neighbors_check)] in neighbors): + pass + else: + painter.drawLine(QPointF(0, 0), QPointF(dx, dy)) + + # if we are drawing a closed square, fill in the internal part + # detect the square when we are on the bottom-left vertex of it + square_neighbors = {(0,0), (0,-1), (1,0), (1,-1)} + if square_neighbors <= set(neighbors): + painter.drawRect(QRectF(0, 0, 1, -1)) + + + painter.restore() + + else: + + for position in maze: + + if position[0] < width / 2: + painter.setPen(QtGui.QPen(blue_col, pen_size)) + painter.setBrush(blue_col) + else: + painter.setPen(QtGui.QPen(red_col, pen_size)) + painter.setBrush(red_col) + + rot_moves = [(0, [(-1, 0), (-1, -1), ( 0, -1)]), + (90, [( 0, -1), ( 1, -1), ( 1, 0)]), + (180, [( 1, 0), ( 1, 1), ( 0, 1)]), + (270, [( 0, 1), (-1, 1), (-1, 0)])] + + for rot, moves in rot_moves: + # we center on the middle point of the square + painter.save() + painter.translate(position[0] + 0.5, position[1] + 0.5) + painter.rotate(rot) + + wall_moves = [move for move in moves if move_pos(position, move) in maze] + + left, topleft, top, *remainder = moves + + if left in wall_moves and top not in wall_moves: + painter.drawLine(QPointF(-0.5, -0.3), QPointF(0, -0.3)) + + + elif left in wall_moves and top in wall_moves and not topleft in wall_moves: + painter.drawArc(QRectF(-0.7, -0.7, 0.4, 0.4), 0 * 16, -90 * 16) + + elif left in wall_moves and top in wall_moves and topleft in wall_moves: + pass + + elif left not in wall_moves and top not in wall_moves: + painter.drawArc(QRectF(-0.3, -0.3, 0.6, 0.6), 90 * 16, 90 * 16) + + elif left not in wall_moves and top in wall_moves: + painter.drawLine(QPointF(-0.3, -0.5), QPointF(-0.3, 0)) + + painter.restore() + diff --git a/pelita/ui/qt/qt_scene.py b/pelita/ui/qt/qt_scene.py new file mode 100644 index 000000000..f3fca57ec --- /dev/null +++ b/pelita/ui/qt/qt_scene.py @@ -0,0 +1,280 @@ + +from PyQt6 import QtCore, QtGui +from PyQt6.QtCore import QPointF, QRectF +from PyQt6.QtGui import (QColor, QColorConstants, QFont, QPainter, + QPainterPath, QPen, QTransform) +from PyQt6.QtWidgets import (QGraphicsEllipseItem, QGraphicsItem, + QGraphicsScene, QGraphicsView) + +from .qt_items import BotItem, FoodItem, use_painter, ArrowItem +from .qt_pixmaps import generate_wall + +import cmath, math + +black = QColorConstants.Black +blue_col = QColor(94, 158, 217) +red_col = QColor(235, 90, 90) + +class PelitaScene(QGraphicsScene): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.shape = None + self.walls = [] + self.food = [] + self.food_items = None + self.arrow = None + self.bots = [] + self.previous_positions = {} + self.directions = {} + self.bot_items = [] + self.shadow_bot_items = [] + self.game_state = {} + + self.grid = False + + def drawBackground(self, painter: QPainter, rect: QRectF) -> None: + super().drawBackground(painter, rect) + + if not self.shape: + return + + if self.grid: + pen = QPen(black) + pen.setWidth(0) # always 1 pixel regardless of scale + painter.setPen(pen) + w, h = self.shape + for x in range(w + 1): + painter.drawLine(x, 0, x, h + 1) + for y in range(h + 1): + painter.drawLine(0, y, w + 1, y) + + # not the best heuristic but might just do + dark_mode = self.palette().window().color().lightness() < 100 + + generate_wall(painter, self.shape, self.walls, dark_mode=dark_mode) + + def drawForeground(self, painter: QPainter, rect: QRectF): + super().drawForeground(painter, rect) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + + if not self.shape: + return + + if self.grid: + # overlay the zone of no noise + + if not self.game_state: + return + bot = self.game_state['turn'] + if bot is None: + # game has not started yet + return + + try: + old_pos = tuple(self.game_state['requested_moves'][bot]['previous_position']) + except TypeError: + old_pos = self.game_state['bots'][bot] + + def draw_box(pos, fill=None): + with use_painter(painter) as p: + p.translate(pos[0], pos[1]) + pen = QPen(QColorConstants.Black) + pen.setWidthF(0.1) + p.setPen(pen) + if fill: + brush = p.background() + brush.setStyle(QtCore.Qt.BrushStyle.BDiagPattern) + brush.setColor(fill) +# p.setBackground(brush) + #p.backgroundMode() + + p.fillRect(0, 0, 1, 1, brush) + else: + p.drawRect(0, 0, 1, 1) + + # else: + # # dx, dy has to be duplicated because the self.screen coordinates go from -1 to 1 + # # for the current cell + # dx = (self.req_pos[0] - self.position[0]) * 2 + # dy = (self.req_pos[1] - self.position[1]) * 2 + # canvas.create_line(self.screen((0, 0)), self.screen((dx, dy)), fill=BROWN, + # width=scale, tag=(self.tag, "arrow"), capstyle="round") + # # arrow head + # vector = dx + dy * 1j + # phase = cmath.phase(vector) + # head = vector + cmath.rect(0.1, phase) + # head_left = vector - cmath.rect(1, phase) + cmath.rect(0.9, phase - cmath.pi/4) + # head_right = vector - cmath.rect(1, phase) + cmath.rect(0.9, phase + cmath.pi/4) + + # points = [ + # self.screen((head_left.real, head_left.imag)), + # self.screen((head.real, head.imag)), + # self.screen((head_right.real, head_right.imag)) + # ] + # canvas.create_line(points, + # fill=BROWN, width=scale, tag=(self.tag, "arrow"), capstyle="round") + + + draw_box(old_pos) + + sight_distance = self.game_state["sight_distance"] + # starting from old_pos, iterate over all positions that are up to sight_distance + # steps away and put a border around the fields. + border_cells_relative = set( + (dx, dy) + for dx in range(- sight_distance, sight_distance + 1) + for dy in range(- sight_distance, sight_distance + 1) + if abs(dx) + abs(dy) == sight_distance + ) + + def in_maze(x, y): + return 0 <= x < self.game_state['shape'][0] and 0 <= y < self.game_state['shape'][1] + + def on_edge(x, y): + return x == 0 or x == self.game_state['shape'][0] - 1 or y == 0 or y == self.game_state['shape'][1] - 1 + + + def draw_line(pos, color, loc): + pen = QPen(QColorConstants.Black) + pen.setWidthF(0.1) + painter.setPen(pen) + + pos = QPointF(pos[0], pos[1]) + loc = QPointF(loc[0], loc[1]) + painter.drawLine(pos, loc) + + + STRONG_BLUE = blue_col.darker(20) + STRONG_RED = red_col.darker(20) + + LIGHT_BLUE = blue_col #.lighter(20) + LIGHT_RED = red_col #.lighter(20) + + team_col = STRONG_BLUE if bot % 2 == 0 else STRONG_RED + + sight_distance_path = QPainterPath() + for dx in range(- sight_distance, sight_distance + 1): + for dy in range(- sight_distance, sight_distance + 1): + if abs(dx) + abs(dy) > sight_distance: + continue + + pos = (old_pos[0] + dx, old_pos[1] + dy) + if not in_maze(pos[0], pos[1]): + continue + + draw_box(pos, fill=LIGHT_BLUE if bot % 2 == 0 else LIGHT_RED) + continue + + # add edge around cells at the line of sight max + if (dx, dy) in border_cells_relative: + if dx >= 0: + draw_line(pos, loc=(1, 1, 1, -1), color=team_col) + if dx <= 0: + draw_line(pos, loc=(-1, 1, -1, -1), color=team_col) + if dy >= 0: + draw_line(pos, loc=(1, 1, -1, 1), color=team_col) + if dy <= 0: + draw_line(pos, loc=(1, -1, -1, -1), color=team_col) + + # add edge around cells at the edge of the maze + if on_edge(pos[0], pos[1]): + if pos[0] == self.game_state['shape'][0] - 1: + draw_line(pos, loc=(1, 1, 1, -1), color=team_col) + if pos[0] == 0: + draw_line(pos, loc=(-1, 1, -1, -1), color=team_col) + if pos[1] == self.game_state['shape'][1] - 1: + draw_line(pos, loc=(1, 1, -1, 1), color=team_col) + if pos[1] == 0: + draw_line(pos, loc=(1, -1, -1, -1), color=team_col) + + + + ### Methods to interact with the scene + def init_scene(self): + + bot_cols = [ + blue_col, + red_col, + blue_col.lighter(110), + red_col.lighter(110) + ] + + if not self.food_items: + if self.food: + self.food_items = {tuple(pos): FoodItem(pos, blue_col if pos[0] < self.shape[0] / 2 else red_col) for pos in self.food} + for pos, item in self.food_items.items(): + self.addItem(item) + + if not self.bot_items: + if self.bots: + self.bot_items = [BotItem(bot_cols[idx]) for idx, pos in enumerate(self.bots)] + for item in self.bot_items: + item.bot_type = "D" + item.direction = (0, 0) + item.setPos(30, 20) + self.addItem(item) + self.shadow_bot_items = [BotItem(bot_cols[idx], shadow=True) for idx, pos in enumerate(self.bots)] + for item in self.shadow_bot_items: + item.bot_type = "D" + item.direction = (0, 0) + item.setPos(30, 20) + self.addItem(item) + + def move_bot(self, bot_idx, pos): + item = self.bot_items[bot_idx] + + # requested_moves[idx] may be None! + if prev_pos := self.requested_moves[bot_idx] and self.requested_moves[bot_idx]['previous_position']: + direction = pos[0] - prev_pos[0], pos[1] - prev_pos[1] + #print(idx, prev_pos, bot, pos, direction) + else: + direction = (0, 1) + + if bot_idx % 2 == 0: + item.direction = direction + if pos[0] < self.shape[0] / 2: + item.bot_type = "D" + else: + item.bot_type = "H" + + else: + item.direction = direction + if pos[0] < self.shape[0] / 2: + item.bot_type = "H" + else: + item.bot_type = "D" + + item.move_to(prev_pos, pos, animate=bot_idx==self.game_state['turn']) + + + def update_arrow(self): + bot = self.game_state['turn'] + if bot is None: + return + + try: + old_pos = tuple(self.game_state['requested_moves'][bot]['previous_position']) + except TypeError: + old_pos = self.game_state['bots'][bot] + + BROWN = QColor(48, 26, 22) + if not self.arrow: + self.arrow = ArrowItem(old_pos, BROWN, self.game_state['bots'][bot], old_pos, success=self.game_state['requested_moves'][bot]['success']) + self.addItem(self.arrow) + else: + self.arrow.move(old_pos, BROWN, self.game_state['bots'][bot], old_pos, success=self.game_state['requested_moves'][bot]['success']) + self.show_grid() + + def show_grid(self): + if not self.arrow: + return + + if self.grid: + self.arrow.show() + else: + self.arrow.hide() + + def hide_food(self, pos): + if pos in self.food_items: + self.food_items[pos].hide() diff --git a/pelita/ui/qt/qt_viewer.py b/pelita/ui/qt/qt_viewer.py new file mode 100644 index 000000000..448ee843a --- /dev/null +++ b/pelita/ui/qt/qt_viewer.py @@ -0,0 +1,444 @@ + +import json +import logging +import signal +from pathlib import Path + +import zmq +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtCore import (QCoreApplication, QObject, QPointF, QRectF, + QSocketNotifier, pyqtSignal, pyqtSlot) +from PyQt6.QtGui import QKeySequence, QShortcut +from PyQt6.QtWidgets import (QApplication, QGraphicsView, QGridLayout, + QHBoxLayout, QVBoxLayout, QMainWindow, QPushButton, QWidget) + +from ...game import next_round_turn +from .qt_items import EndTextOverlay +from .qt_scene import PelitaScene, blue_col, red_col + +_logger = logging.getLogger(__name__) +_logger.setLevel(logging.DEBUG) + +signal.signal(signal.SIGINT, signal.SIG_DFL) + + + +class ZMQListener(QObject): + signal_received = pyqtSignal(str) + + def __init__(self, address, exit_address): + super().__init__() + self.context = zmq.Context() + self.socket = self.context.socket(zmq.SUB) + self.socket.connect(address) + self.socket.subscribe(b"") + + self.exit_socket = self.context.socket(zmq.PAIR) + self.exit_socket.connect(exit_address) + + # TODO: Not sure if this is working on Windows + self.notifier = QSocketNotifier(self.socket.getsockopt(zmq.FD), QSocketNotifier.Type.Read, self) + self.notifier.activated.connect(self.handle_signal) + + @pyqtSlot() + def handle_signal(self): + while self.socket.getsockopt(zmq.EVENTS) & zmq.POLLIN: + message = self.socket.recv_unicode(zmq.NOBLOCK) + self.signal_received.emit(message) + + +class QtViewer(QMainWindow): + def __init__(self, address, controller_address=None, + geometry=None, delay=None, export=None, stop_after=None, + *args, **kwargs): + super().__init__(*args, **kwargs) + self.setWindowTitle("Pelita") + + if export: + png_export_path = Path(export) + if not png_export_path.is_dir(): + raise RuntimeError(f"Not a directory: {png_export_path}") + self.png_export_path = png_export_path + else: + self.png_export_path = None + + nIOthreads = 2 + self.context = zmq.Context(nIOthreads) + self.exit_socket = self.context.socket(zmq.PAIR) + self.exit_socket.setsockopt(zmq.LINGER, 0) + self.exit_socket.setsockopt(zmq.AFFINITY, 1) + self.exit_socket.setsockopt(zmq.RCVTIMEO, 2000) + exit_address = self.exit_socket.bind_to_random_port('tcp://127.0.0.1') + + self.zmq_listener = ZMQListener(address, 'tcp://127.0.0.1:{}'.format(exit_address)) + self.zmq_listener.signal_received.connect(self.signal_received) + + #QtCore.QTimer.singleShot(0, self.zmq_listener.start) + + if controller_address: + self.controller_socket = self.context.socket(zmq.DEALER) + self.controller_socket.setsockopt(zmq.LINGER, 0) + self.controller_socket.setsockopt(zmq.AFFINITY, 1) + self.controller_socket.setsockopt(zmq.RCVTIMEO, 2000) + self.controller_socket.connect(controller_address) + else: + self.controller_socket = None + + if self.controller_socket: + QtCore.QTimer.singleShot(0, self.request_initial) + + self.setupUi() + + self.running = False + self._observed_steps = set() + + self._min_delay = 1 + self._delay = delay + self._stop_after = stop_after + self._stop_after_delay = delay + if self._stop_after is not None: + self._delay = self._min_delay + + self.pause_button.clicked.connect(self.pause) + self.button.clicked.connect(self.close) + self.button.setShortcut("q") + self.step_button.clicked.connect(self.request_step) + self.step_button.setShortcut("Return") + + self.round_button.clicked.connect(self.request_round) + self.round_button.setShortcut("Shift+Return") + + self.debug_button.clicked.connect(self.toggle_debug) + self.debug_button.setShortcut("#") + + # .activated is faster than .clicked which makes sense here + QShortcut(" ", self).activated.connect(self.pause_button.click) + #QShortcut("q", self).activated.connect(self.button.click) + #QShortcut(QKeySequence("Return"), self).activated.connect(self.request_step) + #QShortcut(QKeySequence("Shift+Return"), self).activated.connect(self.request_round) + + + @QtCore.pyqtSlot() + def toggle_debug(self): + self.scene.grid = not self.scene.grid + self.scene.show_grid() + self.view.resetCachedContent() + self.view.update() + + @QtCore.pyqtSlot() + def pause(self): + self.running = not self.running + self.request_next() + + def resizeEvent(self, event): + if hasattr(self, 'wall_pm'): + del self.wall_pm + + def setupUi(self): + self.resize(900, 620) + + # Create a central widget and set the layout + central_widget = QWidget(self) + self.setCentralWidget(central_widget) + + grid_layout = QGridLayout(central_widget) + + blue_info = QWidget(self) + blue_info_layout = QHBoxLayout(blue_info) + blue_info_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + red_info = QWidget(self) + red_info_layout = QHBoxLayout(red_info) + red_info_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) + + self.team_blue = QtWidgets.QLabel("Score") + self.team_blue.setStyleSheet(f"color: {blue_col.name()}; font-weight: bold;") + self.team_red = QtWidgets.QLabel("Score") + self.team_red.setStyleSheet(f"color: {red_col.name()}; font-weight: bold;") + + self.score_blue = QtWidgets.QLabel("0") + self.score_red = QtWidgets.QLabel("0") + + blue_info_layout.addWidget(self.team_blue) + blue_info_layout.addWidget(self.score_blue) + + red_info_layout.addWidget(self.score_red) + red_info_layout.addWidget(self.team_red) + + + self.stats_blue = QtWidgets.QLabel("Stats") + self.stats_blue.setStyleSheet(f"color: {blue_col.name()};") + self.stats_blue.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + self.stats_red = QtWidgets.QLabel("Stats") + self.stats_red.setStyleSheet(f"color: {red_col.name()};") + self.stats_red.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) + + self.pause_button = QtWidgets.QPushButton("PLAY/PAUSE") + self.step_button = QtWidgets.QPushButton("STEP") + self.round_button = QtWidgets.QPushButton("ROUND") + + self.slower_button = QtWidgets.QPushButton("slower") + self.faster_button = QtWidgets.QPushButton("faster") + self.debug_button = QtWidgets.QPushButton("debug") + + self.button = QtWidgets.QPushButton("QUIT") + + self.scene = PelitaScene() + #self.scene.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + #self.scene.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self.view = GameView(self.scene) + self.view.setCacheMode(QGraphicsView.CacheModeFlag.CacheBackground) + + game_layout_w = QWidget(self) + game_layout = QVBoxLayout(game_layout_w) + game_layout.addWidget(self.view) + game_layout.addStretch() + + bottom_info = QWidget(self) + bottom_info_layout = QHBoxLayout(bottom_info) + + bottom_info_layout.addWidget(self.pause_button) + bottom_info_layout.addWidget(self.step_button) + bottom_info_layout.addWidget(self.round_button) + bottom_info_layout.addWidget(self.debug_button) + + grid_layout.addWidget(blue_info, 0, 0) + grid_layout.addWidget(red_info, 0, 1) + + grid_layout.addWidget(self.stats_blue, 1, 0) + grid_layout.addWidget(self.stats_red, 1, 1) + + grid_layout.addWidget(game_layout_w, 2, 0, 1, 2) + grid_layout.addWidget(bottom_info, 3, 0, 1, 2) + grid_layout.addWidget(self.button, 4, 0, 1, 2) + + +# menubar = QtWidgets.QMenuBar(None) +# self.setMenuBar(menubar) +# self.statusbar = QtWidgets.QStatusBar(self) +# self.setStatusBar(self.statusbar) + + QtCore.QMetaObject.connectSlotsByName(self) + + + def request_initial(self): + if self.controller_socket: + try: + self.controller_socket.send_json({"__action__": "set_initial"}) + except zmq.ZMQError: + print("Socket already closed. Ignoring.") + + self.running = True + + def request_next(self): + if self.running: + self.request_step() + + def request_step(self): + if not self.controller_socket: + return + + if self._game_state['gameover']: + return + + if self._stop_after is not None: + next_step = next_round_turn(self._game_state) + if (next_step['round'] < self._stop_after): + _logger.debug('---> play_step') + try: + self.controller_socket.send_json({"__action__": "play_step"}) + except zmq.ZMQError: + print("Socket already closed. Ignoring.") + else: + self._stop_after = None + self.running = False + self._delay = self._stop_after_delay + else: + _logger.debug('---> play_step') + self.controller_socket.send_json({"__action__": "play_step"}) + + def request_round(self): + if not self.controller_socket: + return + + if self._game_state['gameover']: + return + + if self._game_state['round'] is not None: + next_step = next_round_turn(self._game_state) + self._stop_after = next_step['round'] + 1 + else: + self._stop_after = 1 + self._delay = self._min_delay + self.request_step() + + + def signal_received(self, message): + message = json.loads(message) + observed = message["__data__"] + if observed: + self.observe(observed) + + def observe(self, game_state): + step = (game_state['round'], game_state['turn']) + if step in self._observed_steps: + skip_request = True + else: + skip_request = False + self._observed_steps.add(step) + + # We do this the first time we know what our shape is + # fitInView invalidates the caching of the background + if game_state['shape'] and not self.scene.shape: + self.scene.shape = game_state['shape'] + w, h = self.scene.shape + self.scene.setSceneRect(0, 0, w, h) + self.view.fitInView(0, 0, w, h) + + self._game_state = game_state + self.scene.game_state = game_state + + self.scene.walls = game_state['walls'] + self.scene.food = [tuple(food) for food in game_state['food']] + self.scene.bots = game_state['bots'] + self.scene.requested_moves = game_state['requested_moves'] + + self.scene.init_scene() + + for pos in self.scene.food_items.keys(): + if not pos in self.scene.food: + self.scene.hide_food(pos) + + for idx, pos in enumerate(self.scene.bots): + self.scene.move_bot(idx, pos) + + + # for bot_id, bot_sprite in self.scene.shadow_bot_items.items(): + # if self._grid_enabled: + # shadow_bots = game_state.get('noisy_positions') + # else: + # shadow_bots = None + + # if shadow_bots is None or shadow_bots[bot_id] is None: + # bot_sprite.delete(self.ui.game_canvas) + # else: + # bot_sprite.move_to(shadow_bots[bot_id], + # self.ui.game_canvas, + # game_state, + # force=self.size_changed, + # show_id=self._grid_enabled) + + self.scene.update_arrow() + + self.team_blue.setText(f"{game_state['team_names'][0]}") + self.team_red.setText(f"{game_state['team_names'][1]}") + self.score_blue.setText(str(game_state['score'][0])) + self.score_red.setText(str(game_state['score'][1])) + + + def status(team_idx): + try: + # sum the deaths of both bots in this team + deaths = game_state['deaths'][team_idx] + game_state['deaths'][team_idx+2] + kills = game_state['kills'][team_idx] + game_state['kills'][team_idx+2] + ret = "Errors: %d, Kills: %d, Deaths: %d, Time: %.2f" % (game_state["num_errors"][team_idx], kills, deaths, game_state["team_time"][team_idx]) + return ret + except TypeError: + return "" + + self.stats_blue.setText(status(0)) + self.stats_red.setText(status(1)) + + if game_state['gameover']: + winning_team_idx = game_state.get("whowins") + if winning_team_idx is None: + gameover = EndTextOverlay("GAME OVER") + + elif winning_team_idx in (0, 1): + win_name = game_state["team_names"][winning_team_idx] + + # shorten the winning name + plural = '' if win_name.endswith('s') else 's' + if len(win_name) > 25: + win_name = win_name[:22] + '...' + + gameover = EndTextOverlay(f"GAME OVER\n{win_name} win{plural}!") + + elif winning_team_idx == 2: + gameover = EndTextOverlay("GAME OVER\nDRAW!") + + gameover.setScale(0.5) + + self.scene.addItem(gameover) + + + # TODO: Not sure if we want/need this here + # Qt updates itself just fine once this method returns + self.scene.update() + + + if self.png_export_path: + try: + round_index = game_state['round'] + bot_id = game_state['turn'] + file_name = 'pelita-{}-{}.png'.format(round_index, bot_id) + + self.grab().save(str(self.png_export_path / file_name)) + except TypeError as e: + print(e) + + if self._stop_after is not None: + if self._stop_after == 0: + self._stop_after = None + self.running = False + self._delay = self._stop_after_delay + else: + if skip_request: + _logger.debug("Skipping next request.") + else: + QtCore.QTimer.singleShot(self._delay, self.request_step) + elif self.running: + if skip_request: + _logger.debug("Skipping next request.") + else: + QtCore.QTimer.singleShot(self._delay, self.request_step) + + def closeEvent(self, event): + self.exit_socket.send(b'') + self.exit_socket.close() + + if self.controller_socket: + try: + self.controller_socket.send_json({"__action__": "exit"}) + except zmq.ZMQError: + print("Socket already closed. Ignoring.") + + self.controller_socket.close() + + event.accept() + +class GameView(QGraphicsView): + def __init__(self, scene, parent=None): + super().__init__(scene, parent) + + def minimumHeight(self) -> int: + return super().minimumHeight() + + def hasHeightForWidth(self) -> bool: + return True + + def heightForWidth(self, a0: int) -> int: + return a0 // 2 + + def resizeEvent(self, event) -> None: + if self.scene().shape: + x, y = self.scene().shape + self.fitInView(0, 0, x, y) + return super().resizeEvent(event) + + def event(self, event: QtCore.QEvent) -> bool: + # we monitor the switch to dark mode + if (event.type() == QtCore.QEvent.Type.ApplicationPaletteChange or + event.type() == QtCore.QEvent.Type.PaletteChange): + self.resetCachedContent() + return super().event(event) diff --git a/pyproject.toml b/pyproject.toml index 09ed0413b..f7fc93fff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "zeroconf", "rich", "click", + "pyqt6", ] dynamic = ["version"] @@ -42,6 +43,7 @@ content-type = "text/markdown" pelita = "pelita.scripts.pelita_main:main" pelita-tournament = "pelita.scripts.pelita_tournament:main" pelita-tkviewer = "pelita.scripts.pelita_tkviewer:main" +pelita-qtviewer = "pelita.scripts.pelita_qtviewer:main" pelita-player = "pelita.scripts.pelita_player:main" pelita-server = "pelita.scripts.pelita_server:main"