From 120c0ebd39cd76ce313e65115fe6e0e72c5fe90a Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Thu, 9 Feb 2023 23:39:59 +0100 Subject: [PATCH 01/11] WIP: PyQt6 --- pelita/game.py | 2 +- pelita/scripts/pelita_qtviewer.py | 76 +++++++ pelita/ui/qt/qt_items.py | 219 +++++++++++++++++++ pelita/ui/qt/qt_pixmaps.py | 119 +++++++++++ pelita/ui/qt/qt_scene.py | 99 +++++++++ pelita/ui/qt/qt_viewer.py | 343 ++++++++++++++++++++++++++++++ pyproject.toml | 2 + 7 files changed, 859 insertions(+), 1 deletion(-) create mode 100755 pelita/scripts/pelita_qtviewer.py create mode 100644 pelita/ui/qt/qt_items.py create mode 100644 pelita/ui/qt/qt_pixmaps.py create mode 100644 pelita/ui/qt/qt_scene.py create mode 100644 pelita/ui/qt/qt_viewer.py diff --git a/pelita/game.py b/pelita/game.py index 189791dc0..a998aa9f5 100644 --- a/pelita/game.py +++ b/pelita/game.py @@ -50,7 +50,7 @@ def _run_external_viewer(self, subscribe_sock, controller, geometry, delay, stop if stop_after is not None: viewer_args += ["--stop-after", str(stop_after)] - tkviewer = 'pelita.scripts.pelita_tkviewer' + tkviewer = 'pelita.scripts.pelita_qtviewer' external_call = [sys.executable, '-m', tkviewer] + viewer_args 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..b38339b75 --- /dev/null +++ b/pelita/ui/qt/qt_items.py @@ -0,0 +1,219 @@ + +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 + +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 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: + return QRectF(0, 0, 1, 1) + + +class BotItem(QGraphicsItem): + def __init__(self, color, parent=None): + super().__init__(parent) + self.color = color + self.bot_type = "D" + + 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) + else: + paint_harvester(painter, self.color, self.direction) + + +def paint_destroyer(painter: QPainter, color, direction): + + 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) + 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): + rotation = math.degrees(cmath.phase(direction[0] - direction[1]*1j)) + + bounding_rect = QRectF(0, 0, 1, 1) + # bot body + path = QPainterPath(QPointF(0.5, 0.5)) + path.arcTo(bounding_rect, 20 + rotation, 320) + path.closeSubpath() + + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setBrush(color) + painter.setPen(QPen(black, 0.02)) + + painter.drawPath(path) + + if direction == (0, 1): # down + draw_eye(painter, 0.3, 0.4) + elif direction == (1, 0): # right + draw_eye(painter, 0.4, 0.3) + elif direction == (0, -1): # up + draw_eye(painter, 0.3, 0.6) + elif direction == (-1, 0): # left + draw_eye(painter, 0.6, 0.3) + else: + # right + draw_eye(painter, 0.4, 0.3) + + +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..f7efe970f --- /dev/null +++ b/pelita/ui/qt/qt_pixmaps.py @@ -0,0 +1,119 @@ + +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() + 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 + + painter.drawLine(QPointF(-0.3, 0), QPointF(0.3, 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..42c8367d1 --- /dev/null +++ b/pelita/ui/qt/qt_scene.py @@ -0,0 +1,99 @@ + +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 +from .qt_pixmaps import generate_wall + +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.bots = [] + self.previous_positions = {} + self.directions = {} + self.bot_items = [] + + def drawBackground(self, painter: QPainter, rect: QRectF) -> None: + super().drawBackground(painter, rect) + + if not self.shape: + return + + # 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) + + if not self.shape: + return + + ### 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) + + 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.setPos(pos[0], pos[1]) + + 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..7cc9e062a --- /dev/null +++ b/pelita/ui/qt/qt_viewer.py @@ -0,0 +1,343 @@ + +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, QMainWindow, QPushButton, QWidget) + +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, + *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("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 = True + + self.pause_button.clicked.connect(self.pause) + self.pause_button.setShortcut(" ") + self.button.clicked.connect(self.close) + self.button.setShortcut("q") + self.step_button.clicked.connect(self.request_step) + self.step_button.setShortcut("Return") + + #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 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) + + 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) + + 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(self.view, 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.") + + def request_next(self): + if self.running: + self.request_step() + + def request_step(self): + if self.controller_socket: + try: + self.controller_socket.send_json({"__action__": "play_step"}) + except zmq.ZMQError: + print("Socket already closed. Ignoring.") + + def request_round(self): + # TODO: needs to be implemented in frontend + if self.controller_socket: + try: + self.controller_socket.send_json({"__action__": "play_round"}) + except zmq.ZMQError: + print("Socket already closed. Ignoring.") + + + def signal_received(self, message): + message = json.loads(message) + observed = message["__data__"] + if observed: + self.observe(observed) + + def observe(self, observed): + + # We do this the first time we know what our shape is + # fitInView invalidates the caching of the background + if observed['shape'] and not self.scene.shape: + self.scene.shape = observed['shape'] + w, h = self.scene.shape + self.scene.setSceneRect(0, 0, w, h) + self.view.fitInView(0, 0, w, h) + + self.scene.walls = observed['walls'] + self.scene.food = [tuple(food) for food in observed['food']] + self.scene.bots = observed['bots'] + self.scene.requested_moves = observed['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) + + self.team_blue.setText(f"{observed['team_names'][0]}") + self.team_red.setText(f"{observed['team_names'][1]}") + self.score_blue.setText(str(observed['score'][0])) + self.score_red.setText(str(observed['score'][1])) + + + def status(team_idx): + try: + # sum the deaths of both bots in this team + deaths = observed['deaths'][team_idx] + observed['deaths'][team_idx+2] + kills = observed['kills'][team_idx] + observed['kills'][team_idx+2] + ret = "Errors: %d, Kills: %d, Deaths: %d, Time: %.2f" % (observed["num_errors"][team_idx], kills, deaths, observed["team_time"][team_idx]) + return ret + except TypeError: + return "" + + self.stats_blue.setText(status(0)) + self.stats_red.setText(status(1)) + + if observed['gameover']: + winning_team_idx = observed.get("whowins") + if winning_team_idx is None: + gameover = EndTextOverlay("GAME OVER") + + elif winning_team_idx in (0, 1): + win_name = observed["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_index'] + bot_id = game_state['bot_id'] + 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.running: + QtCore.QTimer.singleShot(0, self.request_next) + + + 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 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 df1d59c89..71b316627 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "pytest>=4", "zeroconf", "rich", + "pyqt6", ] dynamic = ["version"] @@ -40,6 +41,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-createlayout = "pelita.scripts.pelita_createlayout:main" From 96cb96eede29dfa26a102d29e21e44bcdcb9ff17 Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Fri, 19 May 2023 21:44:53 +0200 Subject: [PATCH 02/11] ENH: Add --qt arguments for cli --- pelita/game.py | 39 ++++++++++++++++++++++++++++++++--- pelita/scripts/pelita_main.py | 4 +++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/pelita/game.py b/pelita/game.py index a998aa9f5..25c4fdbd7 100644 --- a/pelita/game.py +++ b/pelita/game.py @@ -33,6 +33,35 @@ NOISE_RADIUS = 5 +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): self.proc = self._run_external_viewer(address, controller, geometry=geometry, delay=delay, stop_after=stop_after) @@ -50,7 +79,7 @@ def _run_external_viewer(self, subscribe_sock, controller, geometry, delay, stop if stop_after is not None: viewer_args += ["--stop-after", str(stop_after)] - tkviewer = 'pelita.scripts.pelita_qtviewer' + tkviewer = 'pelita.scripts.pelita_tkviewer' external_call = [sys.executable, '-m', tkviewer] + viewer_args @@ -226,14 +255,18 @@ def setup_viewers(viewers=None, options=None, print_result=True): viewer_state['viewers'].append(ReplyToViewer(viewer[1])) elif len(viewer) == 2 and viewer[0] == 'write-replay-to': viewer_state['viewers'].append(ReplayWriter(open(viewer[1], 'w'))) - elif viewer in ('tk', 'tk-no-sync'): + elif viewer in ('tk', 'tk-no-sync', 'qt'): if not zmq_publisher: zmq_publisher = ZMQPublisher(address='tcp://127.0.0.1:*') viewer_state['viewers'].append(zmq_publisher) if viewer == 'tk': viewer_state['controller'] = setup_controller() + cls = TkViewer + if viewer == 'qt': + viewer_state['controller'] = setup_controller() + cls = QtViewer if viewer_state['controller']: - proc = TkViewer(address=zmq_publisher.socket_addr, controller=viewer_state['controller'].socket_addr, + proc = cls(address=zmq_publisher.socket_addr, controller=viewer_state['controller'].socket_addr, stop_after=options.get('stop_at'), geometry=options.get('geometry'), delay=options.get('delay')) diff --git a/pelita/scripts/pelita_main.py b/pelita/scripts/pelita_main.py index f6a048287..f450dc2e3 100755 --- a/pelita/scripts/pelita_main.py +++ b/pelita/scripts/pelita_main.py @@ -187,7 +187,9 @@ def long_help(s): dest='viewer', help='Use the tk viewer (default).') viewer_opt.add_argument('--tk-no-sync', action='store_const', const='tk-no-sync', dest='viewer', help=long_help('Uses the tk viewer in an unsynchronized mode.')) -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', From 97d62a2bd1048f588a04154e2bdb051111c4777a Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Wed, 17 May 2023 14:47:53 +0200 Subject: [PATCH 03/11] ENH: Better pausing. --- pelita/ui/qt/qt_viewer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pelita/ui/qt/qt_viewer.py b/pelita/ui/qt/qt_viewer.py index 7cc9e062a..44254b03f 100644 --- a/pelita/ui/qt/qt_viewer.py +++ b/pelita/ui/qt/qt_viewer.py @@ -88,16 +88,16 @@ def __init__(self, address, controller_address=None, self.setupUi() - self.running = True + self.running = False self.pause_button.clicked.connect(self.pause) - self.pause_button.setShortcut(" ") self.button.clicked.connect(self.close) self.button.setShortcut("q") self.step_button.clicked.connect(self.request_step) self.step_button.setShortcut("Return") - #QShortcut(" ", self).activated.connect(self.pause_button.click) + # .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) @@ -200,6 +200,8 @@ def request_initial(self): except zmq.ZMQError: print("Socket already closed. Ignoring.") + self.running = True + def request_next(self): if self.running: self.request_step() From 0b9858f08e2e255d2f7dca8d3e33b3cc3761781d Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Wed, 17 May 2023 14:48:08 +0200 Subject: [PATCH 04/11] ENF: Code for the grid --- pelita/ui/qt/qt_scene.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pelita/ui/qt/qt_scene.py b/pelita/ui/qt/qt_scene.py index 42c8367d1..59efe0527 100644 --- a/pelita/ui/qt/qt_scene.py +++ b/pelita/ui/qt/qt_scene.py @@ -26,6 +26,8 @@ def __init__(self, *args, **kwargs): self.directions = {} self.bot_items = [] + self.grid = False + def drawBackground(self, painter: QPainter, rect: QRectF) -> None: super().drawBackground(painter, rect) @@ -37,6 +39,17 @@ def drawBackground(self, painter: QPainter, rect: QRectF) -> None: generate_wall(painter, self.shape, self.walls, dark_mode=dark_mode) + if self.grid: + + pen = QPen(black) + pen.setWidth(0.01) + 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) + def drawForeground(self, painter: QPainter, rect: QRectF): super().drawForeground(painter, rect) From e4475b16b9774f2e4d7222a5da81515623b03638 Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Wed, 17 May 2023 14:59:49 +0200 Subject: [PATCH 05/11] ENH: Relocate eye to better match previous look --- pelita/ui/qt/qt_items.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/pelita/ui/qt/qt_items.py b/pelita/ui/qt/qt_items.py index b38339b75..dab1518af 100644 --- a/pelita/ui/qt/qt_items.py +++ b/pelita/ui/qt/qt_items.py @@ -152,30 +152,33 @@ def paint_destroyer(painter: QPainter, color, direction): def paint_harvester(painter: QPainter, color, direction): 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 + rotation, 320) + path.arcTo(bounding_rect, 20, 320) path.closeSubpath() painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setBrush(color) painter.setPen(QPen(black, 0.02)) - painter.drawPath(path) + # rotate around the 0.5, 0.5 centre point + painter.translate(0.5, 0.5) + painter.rotate(-rotation) + painter.translate(-0.5, -0.5) - if direction == (0, 1): # down - draw_eye(painter, 0.3, 0.4) - elif direction == (1, 0): # right - draw_eye(painter, 0.4, 0.3) - elif direction == (0, -1): # up - draw_eye(painter, 0.3, 0.6) - elif direction == (-1, 0): # left - draw_eye(painter, 0.6, 0.3) + painter.drawPath(path) + if not flip_eye: + draw_eye(painter, 0.7, 0.2) else: - # right - draw_eye(painter, 0.4, 0.3) + draw_eye(painter, 0.7, 0.8) + def draw_eye(painter, x, y): From c3d3ed6543babcacf10e43dc22565f9c6c2d72e4 Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Wed, 17 May 2023 18:30:01 +0200 Subject: [PATCH 06/11] ENH: Improved grid mode --- pelita/ui/qt/qt_pixmaps.py | 4 +++- pelita/ui/qt/qt_scene.py | 12 ++++++------ pelita/ui/qt/qt_viewer.py | 9 +++++++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/pelita/ui/qt/qt_pixmaps.py b/pelita/ui/qt/qt_pixmaps.py index f7efe970f..06805e8b5 100644 --- a/pelita/ui/qt/qt_pixmaps.py +++ b/pelita/ui/qt/qt_pixmaps.py @@ -30,6 +30,7 @@ def move_pos(a, b): 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 @@ -46,7 +47,8 @@ def move_pos(a, b): # draw only a small dot. # TODO add diagonal lines - painter.drawLine(QPointF(-0.3, 0), QPointF(0.3, 0)) + # 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)] diff --git a/pelita/ui/qt/qt_scene.py b/pelita/ui/qt/qt_scene.py index 59efe0527..a2b315f46 100644 --- a/pelita/ui/qt/qt_scene.py +++ b/pelita/ui/qt/qt_scene.py @@ -34,15 +34,10 @@ def drawBackground(self, painter: QPainter, rect: QRectF) -> None: if not self.shape: return - # 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) - if self.grid: pen = QPen(black) - pen.setWidth(0.01) + pen.setWidth(0) # always 1 pixel regardless of scale painter.setPen(pen) w, h = self.shape for x in range(w + 1): @@ -50,6 +45,11 @@ def drawBackground(self, painter: QPainter, rect: QRectF) -> None: 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) diff --git a/pelita/ui/qt/qt_viewer.py b/pelita/ui/qt/qt_viewer.py index 44254b03f..dd5d42e16 100644 --- a/pelita/ui/qt/qt_viewer.py +++ b/pelita/ui/qt/qt_viewer.py @@ -96,6 +96,9 @@ def __init__(self, address, controller_address=None, self.step_button.clicked.connect(self.request_step) self.step_button.setShortcut("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) @@ -103,6 +106,11 @@ def __init__(self, address, controller_address=None, QShortcut(QKeySequence("Shift+Return"), self).activated.connect(self.request_round) + @QtCore.pyqtSlot() + def toggle_debug(self): + self.scene.grid = not self.scene.grid + self.view.resetCachedContent() + @QtCore.pyqtSlot() def pause(self): self.running = not self.running @@ -173,6 +181,7 @@ def setupUi(self): 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) From 4b3ed56360aa0911e76130502d16a039ec6802d0 Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Fri, 19 May 2023 21:36:51 +0200 Subject: [PATCH 07/11] ENH: Fixed aspect ratio --- pelita/ui/qt/qt_viewer.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/pelita/ui/qt/qt_viewer.py b/pelita/ui/qt/qt_viewer.py index dd5d42e16..1e42e4529 100644 --- a/pelita/ui/qt/qt_viewer.py +++ b/pelita/ui/qt/qt_viewer.py @@ -10,7 +10,7 @@ QSocketNotifier, pyqtSignal, pyqtSlot) from PyQt6.QtGui import QKeySequence, QShortcut from PyQt6.QtWidgets import (QApplication, QGraphicsView, QGridLayout, - QHBoxLayout, QMainWindow, QPushButton, QWidget) + QHBoxLayout, QVBoxLayout, QMainWindow, QPushButton, QWidget) from .qt_items import EndTextOverlay from .qt_scene import PelitaScene, blue_col, red_col @@ -175,6 +175,11 @@ def setupUi(self): 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) @@ -189,7 +194,7 @@ def setupUi(self): grid_layout.addWidget(self.stats_blue, 1, 0) grid_layout.addWidget(self.stats_red, 1, 1) - grid_layout.addWidget(self.view, 2, 0, 1, 2) + 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) @@ -340,6 +345,15 @@ 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 From ff266543158e79da0d632463ed9bc85000fc162b Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Mon, 5 Jun 2023 13:26:42 +0200 Subject: [PATCH 08/11] ENH: Improved debug mode --- pelita/ui/qt/qt_items.py | 87 +++++++++++++++++++- pelita/ui/qt/qt_scene.py | 162 +++++++++++++++++++++++++++++++++++++- pelita/ui/qt/qt_viewer.py | 126 +++++++++++++++++++++-------- 3 files changed, 339 insertions(+), 36 deletions(-) diff --git a/pelita/ui/qt/qt_items.py b/pelita/ui/qt/qt_items.py index dab1518af..da7cab31c 100644 --- a/pelita/ui/qt/qt_items.py +++ b/pelita/ui/qt/qt_items.py @@ -9,6 +9,8 @@ QPainterPath, QPen) from PyQt6.QtWidgets import QGraphicsItem +from ...gamestate_filters import manhattan_dist + black = QColorConstants.Black @contextmanager @@ -49,6 +51,88 @@ def de_casteljau_2d_reversed(t, coefs): # 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): @@ -64,7 +148,8 @@ def paint(self, painter: QPainter, option, widget): painter.drawEllipse(QRectF(-0.2, -0.2, 0.4, 0.4)) def boundingRect(self) -> QRectF: - return QRectF(0, 0, 1, 1) + # a little wider than the food + return QRectF(-0.3, -0.3, 0.6, 0.6) class BotItem(QGraphicsItem): diff --git a/pelita/ui/qt/qt_scene.py b/pelita/ui/qt/qt_scene.py index a2b315f46..1102b8353 100644 --- a/pelita/ui/qt/qt_scene.py +++ b/pelita/ui/qt/qt_scene.py @@ -6,9 +6,11 @@ from PyQt6.QtWidgets import (QGraphicsEllipseItem, QGraphicsItem, QGraphicsScene, QGraphicsView) -from .qt_items import BotItem, FoodItem +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) @@ -21,10 +23,12 @@ def __init__(self, *args, **kwargs): self.walls = [] self.food = [] self.food_items = None + self.arrow = None self.bots = [] self.previous_positions = {} self.directions = {} self.bot_items = [] + self.game_state = {} self.grid = False @@ -35,7 +39,6 @@ def drawBackground(self, painter: QPainter, rect: QRectF) -> None: return if self.grid: - pen = QPen(black) pen.setWidth(0) # always 1 pixel regardless of scale painter.setPen(pen) @@ -52,10 +55,140 @@ def drawBackground(self, painter: QPainter, rect: QRectF) -> None: 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): @@ -107,6 +240,31 @@ def move_bot(self, bot_idx, pos): item.setPos(pos[0], pos[1]) + 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 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 index 1e42e4529..635b070f3 100644 --- a/pelita/ui/qt/qt_viewer.py +++ b/pelita/ui/qt/qt_viewer.py @@ -12,6 +12,7 @@ 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 @@ -48,7 +49,7 @@ def handle_signal(self): class QtViewer(QMainWindow): def __init__(self, address, controller_address=None, - geometry=None, delay=None, export=None, + geometry=None, delay=None, export=None, stop_after=None, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowTitle("Pelita") @@ -89,6 +90,14 @@ def __init__(self, address, controller_address=None, 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) @@ -96,6 +105,9 @@ def __init__(self, address, controller_address=None, 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("#") @@ -103,13 +115,15 @@ def __init__(self, address, controller_address=None, 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) + #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): @@ -221,19 +235,42 @@ def request_next(self): self.request_step() def request_step(self): - if self.controller_socket: - try: - self.controller_socket.send_json({"__action__": "play_step"}) - except zmq.ZMQError: - print("Socket already closed. Ignoring.") + 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): - # TODO: needs to be implemented in frontend - if self.controller_socket: - try: - self.controller_socket.send_json({"__action__": "play_round"}) - except zmq.ZMQError: - print("Socket already closed. Ignoring.") + 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): @@ -242,20 +279,29 @@ def signal_received(self, message): if observed: self.observe(observed) - def observe(self, 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 observed['shape'] and not self.scene.shape: - self.scene.shape = observed['shape'] + 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.scene.walls = observed['walls'] - self.scene.food = [tuple(food) for food in observed['food']] - self.scene.bots = observed['bots'] - self.scene.requested_moves = observed['requested_moves'] + 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() @@ -266,18 +312,20 @@ def observe(self, observed): for idx, pos in enumerate(self.scene.bots): self.scene.move_bot(idx, pos) - self.team_blue.setText(f"{observed['team_names'][0]}") - self.team_red.setText(f"{observed['team_names'][1]}") - self.score_blue.setText(str(observed['score'][0])) - self.score_red.setText(str(observed['score'][1])) + 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 = observed['deaths'][team_idx] + observed['deaths'][team_idx+2] - kills = observed['kills'][team_idx] + observed['kills'][team_idx+2] - ret = "Errors: %d, Kills: %d, Deaths: %d, Time: %.2f" % (observed["num_errors"][team_idx], kills, deaths, observed["team_time"][team_idx]) + 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 "" @@ -285,13 +333,13 @@ def status(team_idx): self.stats_blue.setText(status(0)) self.stats_red.setText(status(1)) - if observed['gameover']: - winning_team_idx = observed.get("whowins") + 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 = observed["team_names"][winning_team_idx] + win_name = game_state["team_names"][winning_team_idx] # shorten the winning name plural = '' if win_name.endswith('s') else 's' @@ -323,9 +371,21 @@ def status(team_idx): except TypeError as e: print(e) - if self.running: - QtCore.QTimer.singleShot(0, self.request_next) - + 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'') From 8eacfcb146ed8717e85a999546376b19ac112d62 Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Mon, 5 Jun 2023 13:28:55 +0200 Subject: [PATCH 09/11] ENH: Animate the movement --- pelita/ui/qt/qt_items.py | 30 ++++++++++++++++++++++++++++++ pelita/ui/qt/qt_scene.py | 3 ++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/pelita/ui/qt/qt_items.py b/pelita/ui/qt/qt_items.py index da7cab31c..71704dfc0 100644 --- a/pelita/ui/qt/qt_items.py +++ b/pelita/ui/qt/qt_items.py @@ -151,6 +151,11 @@ 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, parent=None): @@ -158,6 +163,31 @@ def __init__(self, color, parent=None): 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) diff --git a/pelita/ui/qt/qt_scene.py b/pelita/ui/qt/qt_scene.py index 1102b8353..2742413b6 100644 --- a/pelita/ui/qt/qt_scene.py +++ b/pelita/ui/qt/qt_scene.py @@ -238,7 +238,8 @@ def move_bot(self, bot_idx, pos): else: item.bot_type = "D" - item.setPos(pos[0], pos[1]) + item.move_to(prev_pos, pos, animate=bot_idx==self.game_state['turn']) + def update_arrow(self): bot = self.game_state['turn'] From 7691e6cd52b9b1bc8061dc619cea3d7266cde3e8 Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Mon, 5 Jun 2023 15:24:33 +0200 Subject: [PATCH 10/11] BF: Fix uninitialised arrow --- pelita/ui/qt/qt_scene.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pelita/ui/qt/qt_scene.py b/pelita/ui/qt/qt_scene.py index 2742413b6..390f57358 100644 --- a/pelita/ui/qt/qt_scene.py +++ b/pelita/ui/qt/qt_scene.py @@ -259,8 +259,10 @@ def update_arrow(self): 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: From 8eacad7ecc8e63569e52e5c9b68e1f4a6e29ecd0 Mon Sep 17 00:00:00 2001 From: Rike-Benjamin Schuppner Date: Mon, 26 Jun 2023 18:14:38 -0600 Subject: [PATCH 11/11] bump --- pelita/ui/qt/qt_items.py | 17 ++++++++++------- pelita/ui/qt/qt_scene.py | 7 +++++++ pelita/ui/qt/qt_viewer.py | 22 +++++++++++++++++++--- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/pelita/ui/qt/qt_items.py b/pelita/ui/qt/qt_items.py index 71704dfc0..510d22563 100644 --- a/pelita/ui/qt/qt_items.py +++ b/pelita/ui/qt/qt_items.py @@ -158,8 +158,9 @@ def step_function(i: float) -> float: return 1 class BotItem(QGraphicsItem): - def __init__(self, color, parent=None): + def __init__(self, color, shadow=False, parent=None): super().__init__(parent) + self.shadow = shadow self.color = color self.bot_type = "D" @@ -194,12 +195,12 @@ def boundingRect(self): def paint(self, painter: QPainter, option, widget): if self.bot_type == "D": - paint_destroyer(painter, self.color, self.direction) + paint_destroyer(painter, self.color, self.direction, self.shadow) else: - paint_harvester(painter, self.color, self.direction) + paint_harvester(painter, self.color, self.direction, self.shadow) -def paint_destroyer(painter: QPainter, color, direction): +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 @@ -255,7 +256,8 @@ def paint_destroyer(painter: QPainter, color, direction): path.closeSubpath() painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.setBrush(color) + if not shadow: + painter.setBrush(color) painter.setOpacity(0.9) # Ghosts are a little transparent painter.setPen(QPen(black, 0.02)) @@ -265,7 +267,7 @@ def paint_destroyer(painter: QPainter, color, direction): draw_eye(painter, 0.7, 0.3) -def paint_harvester(painter: QPainter, color, direction): +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: @@ -280,7 +282,8 @@ def paint_harvester(painter: QPainter, color, direction): path.closeSubpath() painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.setBrush(color) + if not shadow: + painter.setBrush(color) painter.setPen(QPen(black, 0.02)) # rotate around the 0.5, 0.5 centre point diff --git a/pelita/ui/qt/qt_scene.py b/pelita/ui/qt/qt_scene.py index 390f57358..f3fca57ec 100644 --- a/pelita/ui/qt/qt_scene.py +++ b/pelita/ui/qt/qt_scene.py @@ -28,6 +28,7 @@ def __init__(self, *args, **kwargs): self.previous_positions = {} self.directions = {} self.bot_items = [] + self.shadow_bot_items = [] self.game_state = {} self.grid = False @@ -213,6 +214,12 @@ def init_scene(self): 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] diff --git a/pelita/ui/qt/qt_viewer.py b/pelita/ui/qt/qt_viewer.py index 635b070f3..448ee843a 100644 --- a/pelita/ui/qt/qt_viewer.py +++ b/pelita/ui/qt/qt_viewer.py @@ -57,7 +57,7 @@ def __init__(self, address, controller_address=None, if export: png_export_path = Path(export) if not png_export_path.is_dir(): - raise RuntimeError("Not a directory: {png_export_path}") + raise RuntimeError(f"Not a directory: {png_export_path}") self.png_export_path = png_export_path else: self.png_export_path = None @@ -312,6 +312,22 @@ def observe(self, game_state): 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]}") @@ -363,8 +379,8 @@ def status(team_idx): if self.png_export_path: try: - round_index = game_state['round_index'] - bot_id = game_state['bot_id'] + 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))