diff --git a/extras/examples/opengl_qt.py b/extras/examples/opengl_qt.py new file mode 100644 index 000000000..24a2e9e8d --- /dev/null +++ b/extras/examples/opengl_qt.py @@ -0,0 +1,118 @@ +import sys + +from PyQt6.QtGui import QSurfaceFormat +from PyQt6.QtOpenGLWidgets import QOpenGLWidget +from PyQt6.QtWidgets import QMainWindow, QApplication +from PyQt6.QtCore import Qt, QSize + +from OpenGL.GL import (glPixelZoom) + +from pyboy import PyBoy +from pyboy.utils import WindowEvent + +ROWS, COLS = 144, 160 + +if len(sys.argv) > 1: + filename = sys.argv[1] +else: + print("Usage: python opengl_qt.py [ROM file]") + exit(1) + + +class PyBoyOpenGL(QOpenGLWidget): + def __init__(self, pyboy: PyBoy, parent=None): + super().__init__(parent=parent) + self.pyboy = pyboy + self.setFormat(QSurfaceFormat.defaultFormat()) + self.setUpdatesEnabled(True) + + self.update() + + def paintGL(self): + self.pyboy.tick() + self.update() + + def resizeGL(self, width, height): + glPixelZoom(width / COLS, height / ROWS) + + +class PyBoyWindow(QMainWindow): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.pyboy = PyBoy(filename, window="OpenGLHeadless") + + self.game_widget = PyBoyOpenGL(self.pyboy) + + self.setCentralWidget(self.game_widget) + self.resize(800, 600) + self.setWindowTitle("PyBoy OpenGL") + + self.show() + + def keyPressEvent(self, event): + """Handle key press events""" + key = event.key() + if key == Qt.Key.Key_A: + self.pyboy.events.append(WindowEvent(WindowEvent.PRESS_BUTTON_A)) + elif key == Qt.Key.Key_S: + self.pyboy.events.append(WindowEvent(WindowEvent.PRESS_BUTTON_B)) + elif key == Qt.Key.Key_Up: + self.pyboy.events.append(WindowEvent(WindowEvent.PRESS_ARROW_UP)) + elif key == Qt.Key.Key_Down: + self.pyboy.events.append(WindowEvent(WindowEvent.PRESS_ARROW_DOWN)) + elif key == Qt.Key.Key_Left: + self.pyboy.events.append(WindowEvent(WindowEvent.PRESS_ARROW_LEFT)) + elif key == Qt.Key.Key_Right: + self.pyboy.events.append(WindowEvent(WindowEvent.PRESS_ARROW_RIGHT)) + elif key == Qt.Key.Key_Z: + self.pyboy.events.append(WindowEvent(WindowEvent.STATE_SAVE)) + elif key == Qt.Key.Key_X: + self.pyboy.events.append(WindowEvent(WindowEvent.STATE_LOAD)) + elif key == Qt.Key.Key_Return: + self.pyboy.events.append(WindowEvent(WindowEvent.PRESS_BUTTON_START)) + elif key == Qt.Key.Key_Backspace: + self.pyboy.events.append(WindowEvent(WindowEvent.PRESS_BUTTON_SELECT)) + elif key == Qt.Key.Key_Space: + self.pyboy.events.append(WindowEvent(WindowEvent.PRESS_SPEED_UP)) + elif key == Qt.Key.Key_Escape: + self.pyboy.events.append(WindowEvent(WindowEvent.QUIT)) + + def keyReleaseEvent(self, event): + """Handle key release events""" + key = event.key() + if key == Qt.Key.Key_A: + self.pyboy.events.append(WindowEvent(WindowEvent.RELEASE_BUTTON_A)) + elif key == Qt.Key.Key_S: + self.pyboy.events.append(WindowEvent(WindowEvent.RELEASE_BUTTON_B)) + elif key == Qt.Key.Key_Up: + self.pyboy.events.append(WindowEvent(WindowEvent.RELEASE_ARROW_UP)) + elif key == Qt.Key.Key_Down: + self.pyboy.events.append(WindowEvent(WindowEvent.RELEASE_ARROW_DOWN)) + elif key == Qt.Key.Key_Left: + self.pyboy.events.append(WindowEvent(WindowEvent.RELEASE_ARROW_LEFT)) + elif key == Qt.Key.Key_Right: + self.pyboy.events.append(WindowEvent(WindowEvent.RELEASE_ARROW_RIGHT)) + elif key == Qt.Key.Key_Space: + self.pyboy.events.append(WindowEvent(WindowEvent.RELEASE_SPEED_UP)) + elif key == Qt.Key.Key_Backspace: + self.pyboy.events.append(WindowEvent(WindowEvent.RELEASE_BUTTON_SELECT)) + elif key == Qt.Key.Key_Return: + self.pyboy.events.append(WindowEvent(WindowEvent.RELEASE_BUTTON_START)) + + def resizeEvent(self, event): + # Resize the game widget while keeping the aspect ratio + size = event.size() + width, height = size.width(), size.height() + aspect_ratio = COLS / ROWS + if width / height > aspect_ratio: + width = height * aspect_ratio + else: + height = width / aspect_ratio + self.game_widget.resize(QSize(int(width), int(height))) + self.game_widget.move(int((size.width() - width) / 2), int((size.height() - height) / 2)) + + +if __name__ == "__main__": + app = QApplication([]) + window = PyBoyWindow() + sys.exit(app.exec()) diff --git a/pyboy/plugins/manager.pxd b/pyboy/plugins/manager.pxd index a770eedf7..4664a662e 100644 --- a/pyboy/plugins/manager.pxd +++ b/pyboy/plugins/manager.pxd @@ -9,6 +9,7 @@ from pyboy.plugins.base_plugin cimport PyBoyGameWrapper # imports from pyboy.plugins.window_sdl2 cimport WindowSDL2 from pyboy.plugins.window_open_gl cimport WindowOpenGL +from pyboy.plugins.window_open_gl_headless cimport WindowOpenGLHeadless from pyboy.plugins.window_null cimport WindowNull from pyboy.plugins.debug cimport Debug from pyboy.plugins.disable_input cimport DisableInput @@ -34,6 +35,7 @@ cdef class PluginManager: # plugin_cdef cdef public WindowSDL2 window_sdl2 cdef public WindowOpenGL window_open_gl + cdef public WindowOpenGLHeadless window_open_gl_headless cdef public WindowNull window_null cdef public Debug debug cdef public DisableInput disable_input @@ -50,6 +52,7 @@ cdef class PluginManager: cdef public GameWrapperPokemonPinball game_wrapper_pokemon_pinball cdef bint window_sdl2_enabled cdef bint window_open_gl_enabled + cdef bint window_open_gl_headless_enabled cdef bint window_null_enabled cdef bint debug_enabled cdef bint disable_input_enabled diff --git a/pyboy/plugins/manager.py b/pyboy/plugins/manager.py index df279c8da..addd6c0aa 100644 --- a/pyboy/plugins/manager.py +++ b/pyboy/plugins/manager.py @@ -8,6 +8,7 @@ # imports from pyboy.plugins.window_sdl2 import WindowSDL2 # isort:skip from pyboy.plugins.window_open_gl import WindowOpenGL # isort:skip +from pyboy.plugins.window_open_gl_headless import WindowOpenGLHeadless # isort:skip from pyboy.plugins.window_null import WindowNull # isort:skip from pyboy.plugins.debug import Debug # isort:skip from pyboy.plugins.disable_input import DisableInput # isort:skip @@ -29,6 +30,7 @@ def parser_arguments(): # yield_plugins yield WindowSDL2.argv yield WindowOpenGL.argv + yield WindowOpenGLHeadless.argv yield WindowNull.argv yield Debug.argv yield DisableInput.argv @@ -58,6 +60,8 @@ def __init__(self, pyboy, mb, pyboy_argv): self.window_sdl2_enabled = self.window_sdl2.enabled() self.window_open_gl = WindowOpenGL(pyboy, mb, pyboy_argv) self.window_open_gl_enabled = self.window_open_gl.enabled() + self.window_open_gl_headless = WindowOpenGLHeadless(pyboy, mb, pyboy_argv) + self.window_open_gl_headless_enabled = self.window_open_gl_headless.enabled() self.window_null = WindowNull(pyboy, mb, pyboy_argv) self.window_null_enabled = self.window_null.enabled() self.debug = Debug(pyboy, mb, pyboy_argv) @@ -105,6 +109,8 @@ def handle_events(self, events): events = self.window_sdl2.handle_events(events) if self.window_open_gl_enabled: events = self.window_open_gl.handle_events(events) + if self.window_open_gl_headless_enabled: + events = self.window_open_gl_headless.handle_events(events) if self.window_null_enabled: events = self.window_null.handle_events(events) if self.debug_enabled: @@ -191,6 +197,8 @@ def _post_tick_windows(self): self.window_sdl2.post_tick() if self.window_open_gl_enabled: self.window_open_gl.post_tick() + if self.window_open_gl_headless_enabled: + self.window_open_gl_headless.post_tick() if self.window_null_enabled: self.window_null.post_tick() if self.debug_enabled: @@ -208,6 +216,9 @@ def frame_limiter(self, speed): if self.window_open_gl_enabled: done = self.window_open_gl.frame_limiter(speed) if done: return + if self.window_open_gl_headless_enabled: + done = self.window_open_gl_headless.frame_limiter(speed) + if done: return if self.window_null_enabled: done = self.window_null.frame_limiter(speed) if done: return @@ -223,6 +234,9 @@ def window_title(self): title += self.window_sdl2.window_title() if self.window_open_gl_enabled: title += self.window_open_gl.window_title() + # Not sure if this is needed + # if self.window_open_gl_headless_enabled: + # title += self.window_open_gl_headless.window_title() if self.window_null_enabled: title += self.window_null.window_title() if self.debug_enabled: @@ -262,6 +276,8 @@ def stop(self): self.window_sdl2.stop() if self.window_open_gl_enabled: self.window_open_gl.stop() + if self.window_open_gl_headless_enabled: + self.window_open_gl_headless.stop() if self.window_null_enabled: self.window_null.stop() if self.debug_enabled: diff --git a/pyboy/plugins/window_open_gl_headless.pxd b/pyboy/plugins/window_open_gl_headless.pxd new file mode 100644 index 000000000..1a3ec3fca --- /dev/null +++ b/pyboy/plugins/window_open_gl_headless.pxd @@ -0,0 +1,32 @@ +# +# License: See LICENSE.md file +# GitHub: https://github.com/Baekalfen/PyBoy +# + +import cython +cimport cython +from libc.stdint cimport uint8_t, uint16_t, uint32_t + +from pyboy.logging.logging cimport Logger +from pyboy.plugins.base_plugin cimport PyBoyWindowPlugin + +cimport cython +from libc.stdint cimport int64_t, uint8_t, uint16_t, uint32_t + +from pyboy.plugins.base_plugin cimport PyBoyWindowPlugin + +cdef Logger logger + +cdef int ROWS, COLS + +cdef class WindowOpenGLHeadless(PyBoyWindowPlugin): + cdef list events + + cdef int64_t _ftime + cdef void _glkeyboard(self, str, int, int, bint) noexcept + cdef void _glkeyboardspecial(self, char, int, int, bint) noexcept + + # TODO: Callbacks don't really work, when Cythonized + cpdef void _gldraw(self) noexcept + @cython.locals(scale=double) + cpdef void _glreshape(self, int, int) noexcept diff --git a/pyboy/plugins/window_open_gl_headless.py b/pyboy/plugins/window_open_gl_headless.py new file mode 100644 index 000000000..1bbc9c345 --- /dev/null +++ b/pyboy/plugins/window_open_gl_headless.py @@ -0,0 +1,102 @@ +# +# License: See LICENSE.md file +# GitHub: https://github.com/Baekalfen/PyBoy +# + +import time + +import numpy as np + +import pyboy +from pyboy import utils +from pyboy.plugins.base_plugin import PyBoyWindowPlugin +from pyboy.utils import WindowEvent + +logger = pyboy.logging.get_logger(__name__) + +try: + from OpenGL.GL import (GL_COLOR_BUFFER_BIT, GL_DEPTH_BUFFER_BIT, GL_RGBA, GL_UNSIGNED_INT_8_8_8_8_REV, glClear, + glDrawPixels, glFlush, glPixelZoom) + opengl_enabled = True +except (ImportError, AttributeError): + opengl_enabled = False + +ROWS, COLS = 144, 160 + + +class WindowOpenGLHeadless(PyBoyWindowPlugin): + def __init__(self, pyboy, mb, pyboy_argv): + super().__init__(pyboy, mb, pyboy_argv) + + if not self.enabled(): + return + + self.events = [] + + self._ftime = time.perf_counter_ns() + + # Cython does not cooperate with lambdas + def _key(self, c, x, y): + self._glkeyboard(c.decode("ascii"), x, y, False) + + def _keyUp(self, c, x, y): + self._glkeyboard(c.decode("ascii"), x, y, True) + + def _spec(self, c, x, y): + self._glkeyboardspecial(c, x, y, False) + + def _specUp(self, c, x, y): + self._glkeyboardspecial(c, x, y, True) + + def set_title(self, title): + pass + + def handle_events(self, events): + events += self.events + self.events = [] + return events + + def _glkeyboardspecial(self, c, x, y, up): + # Keybindings should be handled by your own code + # EG: pyboy.events.append(WindowEvent.PRESS_ARROW_UP) + pass + + def _glkeyboard(self, c, x, y, up): + # Keybindings should be handled by your own code + # EG: pyboy.events.append(WindowEvent.PRESS_ARROW_UP) + pass + + def _glreshape(self, width, height): + scale = max(min(height / ROWS, width / COLS), 1) + self._scaledresolution = (round(scale * COLS), round(scale * ROWS)) + glPixelZoom(scale, scale) + + def _gldraw(self): + buf = np.asarray(self.renderer._screenbuffer)[::-1, :] + glDrawPixels(COLS, ROWS, GL_RGBA, GL_UNSIGNED_INT_8_8_8_8_REV, buf) + glFlush() + + def frame_limiter(self, speed): + self._ftime += int((1.0 / (60.0*speed)) * 1_000_000_000) + now = time.perf_counter_ns() + if (self._ftime > now): + delay = (self._ftime - now) // 1_000_000 + time.sleep(delay / 1000) + else: + self._ftime = now + return True + + def enabled(self): + if self.pyboy_argv.get("window") == "OpenGLHeadless": + if opengl_enabled: + return True + else: + logger.error("Missing depencency \"PyOpenGL\". OpenGL window disabled") + return False + + def post_tick(self): + self._gldraw() + + def stop(self): + pass + diff --git a/pyboy/pyboy.py b/pyboy/pyboy.py index f2c969134..3a4b3112c 100644 --- a/pyboy/pyboy.py +++ b/pyboy/pyboy.py @@ -106,8 +106,8 @@ def __init__( ) window = kwargs.pop("window_type") - if window not in ["SDL2", "OpenGL", "null", "headless", "dummy"]: - raise KeyError(f'Unknown window type: {window}. Use "SDL2", "OpenGL", or "null"') + if window not in ["SDL2", "OpenGL", "OpenGLHeadless", "null", "headless", "dummy"]: + raise KeyError(f'Unknown window type: {window}. Use "SDL2", "OpenGL", "OpenGLHeadless" or "null"') kwargs["window"] = window kwargs["scale"] = scale