From 4cb0211bc6c64bc94db61fd14e624f3a89da320f Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Fri, 24 Jan 2025 23:32:27 -0500 Subject: [PATCH 001/279] (#2502) Fix logo centering (#2518) --- doc/_static/css/layout.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/_static/css/layout.css b/doc/_static/css/layout.css index 9760bebb5b..5236e6bd88 100644 --- a/doc/_static/css/layout.css +++ b/doc/_static/css/layout.css @@ -26,11 +26,11 @@ content: none; } .wy-side-nav-search .icon-home img.logo { - margin: 0 !important; + margin: 0 auto; padding: 0; max-width: 100px; - padding-bottom: 16px; } + .wy-side-nav-search > div.version { font-size: 85%; /* Same monospace as our `pre` blocks */ From 458c20fee5d99cda7054b5685479018ac91655c6 Mon Sep 17 00:00:00 2001 From: elegantiron <128913032+elegantiron@users.noreply.github.com> Date: Sat, 25 Jan 2025 01:19:43 -0500 Subject: [PATCH 002/279] Update color.py (#2512) adds __getnewargs__() so Color can be un/pickled --- arcade/types/color.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/arcade/types/color.py b/arcade/types/color.py index 541511d3f8..98ee2284f1 100644 --- a/arcade/types/color.py +++ b/arcade/types/color.py @@ -140,6 +140,9 @@ def __new__(cls, r: int, g: int, b: int, a: int = 255): # https://github.com/python/mypy/issues/8541 return super().__new__(cls, (r, g, b, a)) # type: ignore + def __getnewargs__(self) -> tuple[int, int, int, int]: + return self.r, self.g, self.b, self.a + def __deepcopy__(self, _) -> Self: """Allow :py:func:`~copy.deepcopy` to be used with Color""" return self.__class__(r=self.r, g=self.g, b=self.b, a=self.a) From b07f43f861dc2f7dd41eb6b9ba23fdaf5029a221 Mon Sep 17 00:00:00 2001 From: "A. J. Andrews" <86714785+DragonMoffon@users.noreply.github.com> Date: Sat, 25 Jan 2025 23:29:48 +1300 Subject: [PATCH 003/279] update changelog with release date (#2519) --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a33b523dde..f9ff0d475d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ These are the breaking API changes. Use this as a quick reference for updating 2 * Dropped Python 3.8 support completely. * Texture management has completely changed in 3.0. In the past, we - cached everything, which caused issues for larger - projects that needed memory management. Functions like `Arcade.load_texture` no longer cache textures. + cached everything, which caused issues for larger projects that needed + memory management. Functions like `Arcade.load_texture` no longer cache textures. * Removed the poorly named `Window.set_viewport` and `set_viewport` methods. `Camera2D` has completely superseded their functionality. * Fixed `ArcadeContext` assuming that the projection and view matrices were aligned to the xy-plane and Orthographic. It is now safe to use full 3D matrices with Arcade. @@ -218,6 +218,10 @@ These are the breaking API changes. Use this as a quick reference for updating 2 * Added a `Color` object with a plethora of useful methods. * Windows Text glyphs are now created with DirectWrite instead of GDI. * Removal of various deprecated functions and parameters. +* OpenGL matrix uniforms are now supported properly +* OpenGL uniforms now accept buffer protocol objects +* Sprite's visible flag is now handled correctly +* `Window.run()` now supports a view argument. * OpenGL examples moved to _`examples/gl `_ from _"experiments/examples"_ @@ -354,4 +358,4 @@ We would also like to thank the contributors who spent their valuable time solvi Finally, thank you to the [Pyglet](https://github.com/pyglet/pyglet) team! Pyglet is the backbone of Arcade, and this library wouldn't be possible without them. -3.0.0 changes span from Mar 12, 2022 – +3.0.0 changes span from Dec 31, 2022 – Jan 25, 2025 (756 days!!) From d672539401dda3ab50d525e02c18e15313ff1b5e Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Sat, 25 Jan 2025 09:28:18 -0500 Subject: [PATCH 004/279] Fix CSS breakage for instructions and embeds ahead of 3.0 (#2520) * Temp-fix brittle dev release name handling * Syncs sound, music, and video now * Comment out ref generation for embeds * Clean up embed code * Add anti-preload hints * Temp fix copy buttons of both kinds * Clean up some support code * Clean up conf.py + move things to a utils item * Comments * Fix resource handle copyables for smaller display ports * Get resource page cleaned up with usage instructions * Fix CSS * Fix generation and add explanation of videos * Some proofreading and experimental warnings * Remove big commented-out block --- .../resources_Top-Level_Resources.rst | 25 +++- doc/_includes/resources_Video.rst | 24 ++++ doc/_static/css/custom.css | 23 +++- doc/_static/icons/tabler/copy.svg | 1 + doc/conf.py | 100 ++++++++++----- util/create_resources_listing.py | 87 +++++++++---- util/doc_helpers/real_filesystem.py | 119 ++++++++++++++++++ 7 files changed, 314 insertions(+), 65 deletions(-) create mode 100644 doc/_includes/resources_Video.rst create mode 100644 doc/_static/icons/tabler/copy.svg create mode 100644 util/doc_helpers/real_filesystem.py diff --git a/doc/_includes/resources_Top-Level_Resources.rst b/doc/_includes/resources_Top-Level_Resources.rst index b692784457..0b4553c3c3 100644 --- a/doc/_includes/resources_Top-Level_Resources.rst +++ b/doc/_includes/resources_Top-Level_Resources.rst @@ -1,8 +1,25 @@ This logo of the snake doubles as a quick way to test Arcade's resource handles. -#. Mouse over the copy button (|Example Copy Button|) below -#. It should change color to indicate you've hovered -#. Click to copy +.. raw:: html -Paste in your favorite text editor! +
    +
  1. Look down toward the Arcade logo below until you see the file name
  2. +
  3. Look to the right edge of the file name ('logo.png')
  4. +
  5. There should be a copy button <(
    +
    )
  6. +
  7. Click or tap it.
  8. +
+Click the button or tap it if you're on mobile. Then try pasting in your favorite text editor. It +should look like this:: + + ':resources:/logo.png' + +This string is what Arcade calls a **:ref:`resource handle `**. They let you load +images, sound, and other data without worrying about where exactly data is on a computer. To learn +more, including how to define your own handle prefix, see :ref:`resource_handles`. + +To get started with game code right away, please see the following: + +* :ref:`example-code` +* :ref:`main-page-tutorials` diff --git a/doc/_includes/resources_Video.rst b/doc/_includes/resources_Video.rst new file mode 100644 index 0000000000..031fe91a4f --- /dev/null +++ b/doc/_includes/resources_Video.rst @@ -0,0 +1,24 @@ +.. _resources_video: + +Video +----- + +Arcade offers experimental support for video playback through :py:mod:`pyglet` and other libraries. + +.. warning:: These features are works-in-progress! + + Please the following to learn more: + + * :ref:`future_api` + * The undocumented `experimental folder `_ + +To make testing easier, Arcade includes the small video file embedded below. However, runnign the +examples below may require installing both :ref:`guide-supportedmedia-ffmpeg` and additional libraries: + +* The `cv2-based video examples `_ +* The `cv2-based shadertoy example `_ + +The links above use the unstable development branch of Arcade to gain access to the latest :py:mod:`pyglet` +and Arcade features. If you have questions or want to help develop these examples further, we'd love to hear +from you. The Arcade `Discord server `_ and `GitHub repository `_ always welcome +new community members. diff --git a/doc/_static/css/custom.css b/doc/_static/css/custom.css index f633e7e9ef..88aae3daa1 100644 --- a/doc/_static/css/custom.css +++ b/doc/_static/css/custom.css @@ -262,11 +262,18 @@ table.resource-table td > .resource-thumb.file-icon { .resource-handle { - display: inline-block; + /* Flex props keep this all on one line when the viewport with's small */ + display: inline-flex; + flex-direction: row; + flex-shrink: 0; + + /* Make the button round so it's nice-looking */ border-radius: 0.4em; border: 1px solid rgb(0, 0, 0, 0); + width: fit-content !important; } + .resource-handle:has(button.arcade-ezcopy:hover) { border-color: #54c079; color: #54c079; @@ -314,20 +321,28 @@ table.resource-table td > .resource-thumb.file-icon { .resource-table * > .literal:has(+ button.arcade-ezcopy) { border-radius: 0.4em 0 0 0.4em !important; } -.resource-table .literal + button.arcade-ezcopy { +.resource-table * > .literal + button.arcade-ezcopy { border-radius: 0 0.4em 0.4em 0 !important; } - -.arcade-ezcopy > img { +.arcade-ezcopy > *:is(img, svg) { margin: 0; width: 100%; + max-width: 100%; height: 100%; + max-height: 100%; } .arcade-ezcopy:hover { background-color: #54c079; } +/* Give it some breathing room inside the inline HTML we're using for the moment + # pending: post-3.0 clean-up +*/ +li .arcade-ezcopy.doc-ui-example-dummy { + margin-left: 0.2em; + margin-right: 0.2em; +} table.colorTable { border-width: 1px; diff --git a/doc/_static/icons/tabler/copy.svg b/doc/_static/icons/tabler/copy.svg new file mode 100644 index 0000000000..3e71440c85 --- /dev/null +++ b/doc/_static/icons/tabler/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/conf.py b/doc/conf.py index 11df9852e7..4816a97c30 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,23 +1,34 @@ #!/usr/bin/env python """Sphinx configuration file""" from __future__ import annotations + from functools import cache import logging from pathlib import Path -from textwrap import dedent from typing import Any, NamedTuple -import docutils.nodes -import os import re import runpy -import sphinx.ext.autodoc -import sphinx.transforms import sys from docutils import nodes -from docutils.nodes import literal from sphinx.util.docutils import SphinxRole +HERE = Path(__file__).resolve() +REPO_LOCAL_ROOT = HERE.parent.parent +ARCADE_MODULE = REPO_LOCAL_ROOT / "arcade" +UTIL_DIR = REPO_LOCAL_ROOT / "util" + +log = logging.getLogger('conf.py') +logging.basicConfig(level=logging.INFO) + +sys.path.insert(0, str(REPO_LOCAL_ROOT)) +sys.path.insert(0, str(ARCADE_MODULE)) +log.info(f"Inserted elements in system path: First two are now:") +for i in range(2): + log.info(f" {i}: {sys.path[i]!r}") + +from util.doc_helpers.real_filesystem import copy_media + # As of pyglet==2.1.dev7, this is no longer set in pyglet/__init__.py # because Jupyter / IPython always load Sphinx into sys.modules. See # the following for more info: @@ -27,15 +38,7 @@ # --- Pre-processing Tasks -log = logging.getLogger('conf.py') -logging.basicConfig(level=logging.INFO) - -HERE = Path(__file__).resolve() -REPO_LOCAL_ROOT = HERE.parent.parent - -ARCADE_MODULE = REPO_LOCAL_ROOT / "arcade" -UTIL_DIR = REPO_LOCAL_ROOT / "util" - +# Report our diagnostic info log.info(f"Absolute path for our conf.py : {str(HERE)!r}") log.info(f"Absolute path for the repo root : {str(REPO_LOCAL_ROOT)!r}") log.info(f"Absolute path for the arcade module : {str(REPO_LOCAL_ROOT)!r}") @@ -43,28 +46,39 @@ # _temp_version = (REPO_LOCAL_ROOT / "arcade" / "VERSION").read_text().replace("-",'') -sys.path.insert(0, str(REPO_LOCAL_ROOT)) -sys.path.insert(0, str(ARCADE_MODULE)) -log.info(f"Inserted elements in system path: First two are now:") -for i in range(2): - log.info(f" {i}: {sys.path[i]!r}") - # Don't change to # from arcade.version import VERSION # or read the docs build will fail. from version import VERSION # pyright: ignore [reportMissingImports] -log.info(f"Got version {VERSION!r}") +log.info(f" Got version {VERSION!r}") -REPO_URL_BASE="https://github.com/pythonarcade/arcade" -if 'dev' in VERSION: - GIT_REF = 'development' - log.info(f"Got .dev release: using {GIT_REF!r}") -else: + +# Check whether the version ends in an all-digit string +VERSION_PARTS = [] +for part in VERSION.split('.'): + if part.isdigit(): + VERSION_PARTS.append(int(part)) + else: + VERSION_PARTS.append(part) + +print() +if VERSION_PARTS[-1].isdigit(): GIT_REF = VERSION - log.info(f"Got real release: using {GIT_REF!r}") + log.info(" !!!!! APPEARS TO BE A REAL RELEASE !!!!!") +else: + GIT_REF = 'development' + log.info(" - - - Building as a dev release - - -") + +print() +print(f" {GIT_REF=!r}") +print(f" {VERSION=!r}") +print() + # We'll pass this to our generation scripts to initialize their globals +REPO_URL_BASE="https://github.com/pythonarcade/arcade" FMT_URL_REF_BASE=f"{REPO_URL_BASE}/blob/{GIT_REF}" + RESOURCE_GLOBALS = dict( GIT_REF=GIT_REF, BASE_URL_REPO=REPO_URL_BASE, @@ -104,6 +118,22 @@ def run_util(filename, run_name="__main__", init_globals=None): # Run the generate quick API index script run_util('../util/update_quick_index.py') + +src_res_dir = ARCADE_MODULE / 'resources/assets' +out_res_dir = REPO_LOCAL_ROOT / 'build/html/_static/assets' + +# pending: post-3.0 cleanup to find the right source events to make this work? +# if exc or app.builder.format != "html": +# return +# static_dir = (app.outdir / '_static').resolve() +copy_what = { # pending: post-3.0 cleanup to tie this into resource generation correctly + 'sounds': ('*.wav', '*.ogg', '*.mp3'), + 'music': ('*.wav', '*.ogg', '*.mp3'), + 'video': ('*.mp4', '*.webm', ) +} +copy_media(src_res_dir, out_res_dir, copy_what) + + autodoc_inherit_docstrings = False autodoc_default_options = { 'members': True, @@ -136,6 +166,12 @@ def run_util(filename, run_name="__main__", init_globals=None): 'doc.extensions.prettyspecialmethods', # Forker plugin for prettifying special methods ] +# pending: post-3.0 cleanup: +# 1. Setting this breaks the CSS for both the plugin's buttons and our "custom" ones +# 2. Since our custom ones are only on the gui page for now, it's okay +# Note: tabler doesn't require attribution + it's the original theme for this icon set +# copybutton_image_svg = (REPO_LOCAL_ROOT / "doc/_static/icons/tabler/copy.svg").read_text() + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -272,7 +308,6 @@ def run_util(filename, run_name="__main__", init_globals=None): rst_prolog = "\n".join(PROLOG_PARTS) - def strip_init_return_typehint(app, what, name, obj, options, signature, return_annotation): # Prevent a the `-> None` annotation from appearing after classes. # This annotation comes from the `__init__`, but it renders on the class, @@ -281,6 +316,7 @@ def strip_init_return_typehint(app, what, name, obj, options, signature, return_ if what == "class" and return_annotation is None: return (signature, None) + def inspect_docstring_for_member( _app, what: str, @@ -407,7 +443,6 @@ def on_autodoc_process_bases(app, name, obj, options, bases): bases[:] = [base for base in bases if base is not object] - class A(NamedTuple): dirname: str comment: str = "" @@ -439,7 +474,7 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: '/api_docs/resources.html#', page_id]), ) - print("HALP?", locals()) + log.info(" Attempted ResourceRole", locals()) return [node], [] @@ -452,6 +487,7 @@ def setup(app): print(f" {comment}") # Separate stylesheets loosely by category. + # pending: sphinx >= 8.1.4 to remove the sphinx_static_file_temp_fix.py app.add_css_file("css/colors.css") app.add_css_file("css/layout.css") app.add_css_file("css/custom.css") @@ -467,6 +503,8 @@ def setup(app): app.connect('autodoc-process-bases', on_autodoc_process_bases) # app.add_transform(Transform) app.add_role('resource', ResourceRole()) + # Don't do anything that can fail on this event or it'll kill your build hard + # app.connect('build-finished', throws_exception) # ------------------------------------------------------ # Old hacks that breaks the api docs. !!! DO NOT USE !!! diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index 0aa04010bf..1f453ed5f6 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -64,6 +64,7 @@ def announce_templating(var_name): MODULE_DIR = Path(__file__).parent.resolve() ARCADE_ROOT = MODULE_DIR.parent RESOURCE_DIR = ARCADE_ROOT / "arcade" / "resources" +ASSET_DIR = RESOURCE_DIR / "assets" DOC_ROOT = ARCADE_ROOT / "doc" INCLUDES_ROOT = DOC_ROOT / "_includes" OUT_FILE = DOC_ROOT / "api_docs" / "resources.rst" @@ -135,7 +136,7 @@ class TableConfigDict(TypedDict): class HeadingConfigDict(TypedDict): - ref_target: NotRequired[str] + ref_target: NotRequired[str | bool] skip: NotRequired[bool] value: NotRequired[str] level: NotRequired[int] @@ -143,7 +144,7 @@ class HeadingConfigDict(TypedDict): class HandleLevelConfigDict(TypedDict): heading: NotRequired[HeadingConfigDict] - include: NotRequired[str] + include: NotRequired[str | bool] list_table: NotRequired[TableConfigDict] @@ -197,6 +198,14 @@ class HandleLevelConfigDict(TypedDict): }, ":resources:/gui_basic_assets/window/": { "heading": {"value": "Window & Panel"} + }, + ":resources:/video/": { + # pending: post-3.0 cleanup # trains are hats + # "heading:": { + # "value": "Video", + # "ref_target": "resources_video" + # }, + "include": "resources_Video.rst" } } @@ -237,7 +246,7 @@ def do_heading( out, relative_heading_level: int, heading_text: str, - ref_target: str | None = None + ref_target: str | bool | None = None ) -> None: """Writes a heading to the output file. @@ -254,8 +263,10 @@ def do_heading( print(f"doing heading: {heading_text!r} {relative_heading_level}") num_headings = len(headings_lookup) + if ref_target is True: + ref_target = f"resources-{heading_text}.rst" if ref_target: - out.write(f".. _{ref_target}:\n\n") + out.write(f".. _{ref_target.lower()}:\n\n") if relative_heading_level >= num_headings: # pending: post-3.0 cleanup @@ -415,16 +426,20 @@ def process_resource_directory(out, dir: Path): # Heading config fetch and write use_level = local_heading_config.get('level', heading_level) - use_target = local_heading_config.get('ref_target', None) use_value = local_heading_config.get('value', None) if use_value is None: use_value = format_title_part(handle_steps_parts[heading_level]) + use_target = local_heading_config.get('ref_target', None) + if isinstance(use_target, bool) and use_target: + use_target = f"resources_{use_value.lower()}" do_heading(out, use_level, use_value, ref_target=use_target) out.write(f"\n.. comment `{handle_step_whole!r}``\n\n") # Include any include .rst # pending: inline via pluginification if include := local_config.get("include", None): + if isinstance(include, bool) and include: + include = f"resources_{use_value}.rst" if isinstance(include, str): include = INCLUDES_ROOT / include log.info(f" INCLUDE: Include resolving to {include})") @@ -461,6 +476,10 @@ def indent( # pending: post-3.0 refactor # why would indent come after the tex return new.getvalue() +# pending: post-3.0 cleanup, I don't have time to make this CSS nice right now. +COPY_BUTTON_PATH = "_static/icons/tabler/copy.svg" +#COPY_BUTTON_RAW = (DOC_ROOT / "_static/icons/tabler/copy.svg").read_text().strip() + "\n" + def html_copyable( value: str, @@ -470,14 +489,14 @@ def html_copyable( if string_quote_char: value = f"{string_quote_char}{value}{string_quote_char}" escaped = html.escape(value) - raw = ( f"\n" f" \n" f" {escaped}\n" f" \n" f" \n" f"\n" f"
\n\n") @@ -693,15 +712,17 @@ def start(): config = MEDIA_EMBED[suffix] kind = config.get('media_kind') mime_suffix = config.get('mime_suffix') - file_path = FMT_URL_REF_EMBED.format(resource_path) - + # file_path = FMT_URL_REF_EMBED.format(resource_path) + rel = path.relative_to(RESOURCE_DIR) + file_path = f"/_static/{str(rel)}" out.write(f" {start()} - .. raw:: html\n\n") out.write(indent( " ", resource_copyable)) out.write(f" .. raw:: html\n\n") out.write(indent(" ", - f"<{kind} class=\"resource-thumb\" controls>\n" + # Using preload="none" is gentler on GitHub and readthedocs + f"<{kind} class=\"resource-thumb\" controls preload=\"none\">\n" f" \n" f"\n\n")) @@ -741,11 +762,25 @@ def resources(): do_heading(out, 0, "Built-In Resources") - out.write("\n\n:resource:`:resources:/gui_basic_assets/window/panel_green.png`\n\n") + # pending: post-3.0 cleanup: get the linking working + # out.write("\n\n:resource:`:resources:/gui_basic_assets/window/panel_green.png`\n\n") # out.write("Linking test: :ref:`resources-gui-basic-assets-window-panel-green-png`.\n") - out.write("Every file below is included when you :ref:`install Arcade `. This includes the images,\n" - "sounds, fonts, and other files to help you get started quickly. You can still download them\n" - "separately, but Arcade's resource handle system will usually be easier.\n") + + out.write("Every file below is included when you :ref:`install Arcade `.\n\n" + "Afterward, you can try running one of Arcade's :py:ref:`examples `,\n" + "such as the one below:\n\n" + ".. code-block:: shell\n" + " :caption: Taken from :ref:`sprite_collect_coins`\n" + "\n" + " python -m arcade.examples.sprite_collect_coins\n" + "\n" + "If the example mini-game runs, every image, sound, font, and example Tiled map below should\n" + "work with zero additional software. You can still download the resources from this page for\n" + "convenience, or visit `Kenney.nl`_ for more permissively licensed game assets.\n" + "\n" + "The one feature which may require additional software is Arcade's experimental video playback\n" + "support. The :ref:`resources_video` section below will explain further.\n") + do_heading(out, 1, "Do I have to credit anyone?") # Injecting the links.rst doesn't seem to be working? out.write("That's a good question and one you should always ask when searching for assets online.\n" @@ -754,7 +789,7 @@ def resources(): "are specifically released under `CC0 `_" " or similar terms.\n") out.write("Most are from `Kenney.nl `_.\n") # pending: post-3.0 cleanup. - + logo = html.escape("'logo.png'") do_heading(out, 1, "How do I use these?") out.write( # '.. |Example Copy Button| raw:: html\n\n' @@ -762,17 +797,17 @@ def resources(): # ' \n\n' # ' \n\n' # + - "Arcade helps save time through **resource handle** strings. These strings start with\n" - "``':resources:'``. After you've installed Arcade, you'll need to:\n\n" - "#. Find the copy button (|Example Copy Button|) after a filename below\n" - "#. Click it to copy the string, such as ``':resources:/logo.png'``\n" - "#. Use the appropriate loading functions to load and display the data\n\n" - "Try it below with the Arcade logo, or see the following to learn more\n:" - "\n\n" - "* :ref:`Sprite Examples ` for example code\n" - "* :ref:`The Platformer Tutorial ` for step-by-step guidance\n" - "* The :ref:`resource_handles` page of the manual covers them in more depth\n" - "\n" + f"Each file preview below has the following items above it:\n\n" + f".. raw:: html\n\n" + f"
    \n" + f"
  1. A file name as a single-quoted string ({logo})
  2. \n" + f"
  3. A copy button to the right of the string (
    " + f"
    )
  4. \n" + f"
\n\n" + + + "Click the button above a preview to copy the **resource handle** string for loading the asset.\n" + "Any image or sound on this page should work after installing Arcade with zero additional dependencies.\n" + "Full example code and manual sections for any relevant functions are linked below." ) out.write("\n") diff --git a/util/doc_helpers/real_filesystem.py b/util/doc_helpers/real_filesystem.py new file mode 100644 index 0000000000..911c8083a9 --- /dev/null +++ b/util/doc_helpers/real_filesystem.py @@ -0,0 +1,119 @@ +""" +Helpers for dealing with the real-world file system. + +""" +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Generator, TypeVar, Hashable, Iterable, Mapping, Sequence, Callable +import logging + +H = TypeVar('H', bound=Hashable) + +FILE = Path(__file__) +REPO_ROOT = Path(__file__).parent.parent.resolve() +log = logging.getLogger(str(FILE.relative_to(REPO_ROOT))) + + +def dest_older(src: Path | str, dest: Path | str) -> bool: + """True if ``dest`` is older than ``src``. + + This works because git does not bother syncing the modified times + on files. It delegates that data to the commit history. + + Args: + src: A str or :py:class:`pathlib.Path`. + dest: A str or :py:class:`pathlib.Path`. + """ + return Path(src).stat().st_mtime > dest.stat().st_mtime + + +def multi_glob( + p: str | Path, + *globs: str, + predicate: Callable[[Path], bool] | None = None +) -> Generator[Path, None, None]: + """Chain multiple :py:class:`pathlib.Path.glob` results into one. + + The + Args: + p: the path to merge glob args for + globs: The glob strings to use. + predicate: An optional filter predicate. + Yields: + A series of paths with possible duplicates. + """ + p = Path(p) + for glob in globs: + if predicate: + yield from filter(predicate, p.glob(glob)) + else: + yield from p.glob(glob) + + +def unique(items: Iterable[H], seen: set | None = None) -> Generator[H, None, None]: + """Filter hashable ``items`` by adding them to a ``seen`` set during iteration. + + Passing a re-used set in allows efficiently visiting nodes. + + Args: + items: An iterable of hashables to reject duplicates from. + seen: specify a set rather than creating a new one for this call. + """ + if seen is None: + seen = set() + for new in (elt for elt in items if elt not in seen): + seen.add(new) + yield new + + +def sync_dir(src_dir: Path, dest_dir: Path, *globs: str, done: set | None = None) -> None: + """Sync a directory's files by using :py:mod:`pathlib` style ``globs``. + + Args: + src_dir: The source directory to read. + dest_dir: Where to sync any globbed files from. + globs: Match these and sync them into ``dest_dir``. + done: Pass a set of visited paths to enforce custom uniqueness. + """ + if not src_dir.is_dir(): + raise ValueError(f"source is not a directory: {src_dir}") + if dest_dir.is_file(): + raise ValueError(f"dest dir is not a directory: {dest_dir}") + for src_file in unique(multi_glob(src_dir, *globs), seen=done): + dest_file = dest_dir / src_file.name + + if not dest_file.exists() or dest_older(src_file, dest_file): + dest_file.parent.mkdir(parents=True, exist_ok=True) + log.info(f' Copying media file {src_file} to {dest_file}') + + shutil.copyfile(src_file, dest_file) + + +def copy_media( + src_root: Path | str, + dest_root: Path | str, + items: Mapping[str | Path, Sequence[str]], + done: set | None = None +) -> None: + """A more configurable version of the file syncing scripts we use. + + Args: + src_root: Where to start looking for matching ``items`` + dest_root: Where to write the new items. + items: A mapping of folder names to glob sequences. + done: A set to use as a uniqueness check, if any. + """ + + if done is None: + done = set() + logging.info("") + for dir_name, sub_items in items.items(): + print(f" Copying... {' '.join(map(repr, sub_items))}...") + + src_sub = (src_root / dir_name).resolve() + dest_sub = dest_root / dir_name + print(" from :", src_sub) + print(" to :", dest_sub) + sync_dir(src_sub, dest_sub, *items, done=done) From a8d65f980429029670042217af44a1e54ee5531d Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Sat, 25 Jan 2025 12:37:41 -0500 Subject: [PATCH 005/279] RTD remap trick since sphinx hides the config (#2521) * RTD remap trick since sphinx hides the config * Comments + we are no longer asking nicely to copy files --- doc/conf.py | 69 +++++++++++++++++++++++------ util/create_resources_listing.py | 20 ++++++--- util/doc_helpers/real_filesystem.py | 5 ++- 3 files changed, 71 insertions(+), 23 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 4816a97c30..2b68f260b6 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -2,6 +2,7 @@ """Sphinx configuration file""" from __future__ import annotations +import os from functools import cache import logging from pathlib import Path @@ -27,8 +28,23 @@ for i in range(2): log.info(f" {i}: {sys.path[i]!r}") + +# Grab readthedocs env variables for logging + use +# https://docs.readthedocs.com/platform/stable/reference/environment-variables.html +# Their GH comments suggest they want to move away from "magic" injection as +# part of the readthedocs theme, so this seems like the best option for us. +log.info(" Env variables...") +col_width = max(map(len, os.environ.keys())) +READTHEDOCS = dict() +ENV = dict() +for k, v in os.environ.items(): + if k.startswith('READTHEDOCS_'): + READTHEDOCS[k.removeprefix('READTHEDOCS_')] = v + ENV[k] = v + from util.doc_helpers.real_filesystem import copy_media + # As of pyglet==2.1.dev7, this is no longer set in pyglet/__init__.py # because Jupyter / IPython always load Sphinx into sys.modules. See # the following for more info: @@ -44,6 +60,10 @@ log.info(f"Absolute path for the arcade module : {str(REPO_LOCAL_ROOT)!r}") log.info(f"Absolute path for the util dir : {str(UTIL_DIR)!r}") +print() +for k, v in ENV.items(): + log.info(f"Env variable {k:{col_width}} : {v!r}") + # _temp_version = (REPO_LOCAL_ROOT / "arcade" / "VERSION").read_text().replace("-",'') # Don't change to @@ -52,26 +72,23 @@ from version import VERSION # pyright: ignore [reportMissingImports] log.info(f" Got version {VERSION!r}") - # Check whether the version ends in an all-digit string -VERSION_PARTS = [] +ARCADE_VERSION_PARTS = [] for part in VERSION.split('.'): if part.isdigit(): - VERSION_PARTS.append(int(part)) + ARCADE_VERSION_PARTS.append(part) else: - VERSION_PARTS.append(part) + ARCADE_VERSION_PARTS.append(part) print() -if VERSION_PARTS[-1].isdigit(): - GIT_REF = VERSION - log.info(" !!!!! APPEARS TO BE A REAL RELEASE !!!!!") +GIT_REF = 'development' +if READTHEDOCS: + if READTHEDOCS.get('VERSION') in ('latest', 'stable'): + log.info(" !!!!! APPEARS TO BE A REAL RELEASE !!!!!") + else: + log.info(" +++++ Building a PR or development +++++") else: - GIT_REF = 'development' - log.info(" - - - Building as a dev release - - -") - -print() -print(f" {GIT_REF=!r}") -print(f" {VERSION=!r}") + log.info(" - - - Building outside readthedocs +++++") print() @@ -80,11 +97,14 @@ FMT_URL_REF_BASE=f"{REPO_URL_BASE}/blob/{GIT_REF}" RESOURCE_GLOBALS = dict( - GIT_REF=GIT_REF, + GIT_REF=GIT_REF, # pending: post-3.0 clean-up, not sure if things use it now? + # This may be more useful according to some doc? (It's unclear) + # https://docs.readthedocs.com/platform/stable/reference/environment-variables.html#envvar-READTHEDOCS_GIT_COMMIT_HASH BASE_URL_REPO=REPO_URL_BASE, # This double-bracket escapes brackets in f-strings FMT_URL_REF_PAGE=f"{FMT_URL_REF_BASE}/{{}}", FMT_URL_REF_EMBED=f"{FMT_URL_REF_BASE}/{{}}?raw=true", + RTD_EVIL=READTHEDOCS['CANONICAL_URL'] if READTHEDOCS else "" # pending: post-3.0 cleanup ) def run_util(filename, run_name="__main__", init_globals=None): @@ -119,6 +139,8 @@ def run_util(filename, run_name="__main__", init_globals=None): run_util('../util/update_quick_index.py') +OUT_STATIC = REPO_LOCAL_ROOT / 'build/html/_static/' + src_res_dir = ARCADE_MODULE / 'resources/assets' out_res_dir = REPO_LOCAL_ROOT / 'build/html/_static/assets' @@ -133,6 +155,25 @@ def run_util(filename, run_name="__main__", init_globals=None): } copy_media(src_res_dir, out_res_dir, copy_what) +# We are no longer asking. We are copying. +copy_media( + REPO_LOCAL_ROOT / "doc/_static/icons", + OUT_STATIC / "icons" , + { + 'tabler': ("*.svg",) + } +) +copy_media( + REPO_LOCAL_ROOT / "doc/_static/", + OUT_STATIC , + { + 'filetiles': ("*.png",) + } +) +#copy_media( +# REP / "" +#) + autodoc_inherit_docstrings = False autodoc_default_options = { diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index 1f453ed5f6..8581ff94e1 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -43,7 +43,10 @@ def announce_templating(var_name): # The following are provided via runpy.run_path's init_globals keyword # in conf.py. Uncomment for easy debugger run without IDE config. +_ = RTD_EVIL # noqa # explode ASAP or the links will all be broken +log.info(f" RTD EVIL: {RTD_EVIL!r}") # noqa try: + _ = GIT_REF # noqa except Exception as _: GIT_REF = "development" @@ -61,6 +64,10 @@ def announce_templating(var_name): announce_templating("FMT_URL_REF_EMBED") +def src_kludge(strpath): # pending: post-3.0 cleanup: # evil evil evil evil + """We inject what RTD says the canonical domain is up top + the version""" + return f"{RTD_EVIL}{strpath}" + MODULE_DIR = Path(__file__).parent.resolve() ARCADE_ROOT = MODULE_DIR.parent RESOURCE_DIR = ARCADE_ROOT / "arcade" / "resources" @@ -495,7 +502,7 @@ def html_copyable( f" {escaped}\n" f" \n" f" \n" f"\n" @@ -621,17 +628,16 @@ def do_filetile(out, suffix: str | None = None, state: str = None): p = FILETILE_DIR / f"type-{suffix.strip('.')}.png" log.info(f" FILETILE: {p}") if p.exists(): - print(" KNOWN!") + print(f" KNOWN! {p.name!r}") name = p.name else: name = f"type-unknown.png" print(" ... unknown :(") else: name = "state-error.png" - out.write(indent(f" ", f".. raw:: html\n\n" - f" \n\n")) + f" \n\n")) def process_resource_files( @@ -723,7 +729,7 @@ def start(): out.write(indent(" ", # Using preload="none" is gentler on GitHub and readthedocs f"<{kind} class=\"resource-thumb\" controls preload=\"none\">\n" - f" \n" + f" \n" f"\n\n")) # Fonts @@ -743,7 +749,7 @@ def start(): # File tiles we don't have previews for else:# suffix == ".json": - file_path = FMT_URL_REF_PAGE.format(resource_path) + # file_path = FMT_URL_REF_PAGE.format(resource_path) out.write(f" {start()} - .. raw:: html\n\n") out.write(indent(" ", resource_copyable)) @@ -802,7 +808,7 @@ def resources(): f"
    \n" f"
  1. A file name as a single-quoted string ({logo})
  2. \n" f"
  3. A copy button to the right of the string (
    " - f"
    )
  4. \n" + f")\n" f"
\n\n" + "Click the button above a preview to copy the **resource handle** string for loading the asset.\n" diff --git a/util/doc_helpers/real_filesystem.py b/util/doc_helpers/real_filesystem.py index 911c8083a9..aa3478776b 100644 --- a/util/doc_helpers/real_filesystem.py +++ b/util/doc_helpers/real_filesystem.py @@ -13,7 +13,7 @@ FILE = Path(__file__) REPO_ROOT = Path(__file__).parent.parent.resolve() -log = logging.getLogger(str(FILE.relative_to(REPO_ROOT))) +log = logging.getLogger(FILE.name) def dest_older(src: Path | str, dest: Path | str) -> bool: @@ -89,7 +89,8 @@ def sync_dir(src_dir: Path, dest_dir: Path, *globs: str, done: set | None = None log.info(f' Copying media file {src_file} to {dest_file}') shutil.copyfile(src_file, dest_file) - + else: + log.info(f" Skipping media file {src_file} to {dest_file}") def copy_media( src_root: Path | str, From cda476182257a7d0cb3fc48876ef43ae62e46b91 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Sat, 25 Jan 2025 13:14:39 -0500 Subject: [PATCH 006/279] Revert copying files due to RTD issues (#2522) * RTD remap trick since sphinx hides the config * Comments + we are no longer asking nicely to copy files * Fix again * fix * Fix more --- doc/_includes/resources_Top-Level_Resources.rst | 4 ++-- util/create_resources_listing.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/doc/_includes/resources_Top-Level_Resources.rst b/doc/_includes/resources_Top-Level_Resources.rst index 0b4553c3c3..7cc39175c7 100644 --- a/doc/_includes/resources_Top-Level_Resources.rst +++ b/doc/_includes/resources_Top-Level_Resources.rst @@ -3,9 +3,9 @@ This logo of the snake doubles as a quick way to test Arcade's resource handles. .. raw:: html
    -
  1. Look down toward the Arcade logo below until you see the file name
  2. +
  3. Look down toward the Arcade logo below until you see the file name
  4. Look to the right edge of the file name ('logo.png')
  5. -
  6. There should be a copy button <(
    +
  7. There should be a copy button (
    )
  8. Click or tap it.
diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index 8581ff94e1..8dd12e388c 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -484,7 +484,7 @@ def indent( # pending: post-3.0 refactor # why would indent come after the tex return new.getvalue() # pending: post-3.0 cleanup, I don't have time to make this CSS nice right now. -COPY_BUTTON_PATH = "_static/icons/tabler/copy.svg" +COPY_BUTTON_PATH = "https://raw.githubusercontent.com/pythonarcade/arcade/refs/heads/development/doc/_static/icons/tabler/copy.svg" #COPY_BUTTON_RAW = (DOC_ROOT / "_static/icons/tabler/copy.svg").read_text().strip() + "\n" @@ -496,13 +496,15 @@ def html_copyable( if string_quote_char: value = f"{string_quote_char}{value}{string_quote_char}" escaped = html.escape(value) + # src = src_kludge(COPY_BUTTON_PATH) + src = FMT_URL_REF_EMBED.format(COPY_BUTTON_PATH) raw = ( f"\n" f" \n" f" {escaped}\n" f" \n" f" \n" f"\n" @@ -718,9 +720,9 @@ def start(): config = MEDIA_EMBED[suffix] kind = config.get('media_kind') mime_suffix = config.get('mime_suffix') - # file_path = FMT_URL_REF_EMBED.format(resource_path) - rel = path.relative_to(RESOURCE_DIR) - file_path = f"/_static/{str(rel)}" + file_path = FMT_URL_REF_EMBED.format(resource_path) + #rel = path.relative_to(RESOURCE_DIR) + #file_path = src_kludge(f"/_static/{str(rel)}") out.write(f" {start()} - .. raw:: html\n\n") out.write(indent( " ", resource_copyable)) @@ -729,6 +731,7 @@ def start(): out.write(indent(" ", # Using preload="none" is gentler on GitHub and readthedocs f"<{kind} class=\"resource-thumb\" controls preload=\"none\">\n" + f" \n" f" \n" f"\n\n")) From 255d0b18d2681281ec154f0fcac4ab1503b7688d Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Sat, 25 Jan 2025 13:25:28 -0500 Subject: [PATCH 007/279] Another (#2523) * RTD remap trick since sphinx hides the config * Comments + we are no longer asking nicely to copy files * Fix again * fix * Fix more * Inline another * Another --- doc/_includes/resources_Top-Level_Resources.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/_includes/resources_Top-Level_Resources.rst b/doc/_includes/resources_Top-Level_Resources.rst index 7cc39175c7..5b9abb4ce7 100644 --- a/doc/_includes/resources_Top-Level_Resources.rst +++ b/doc/_includes/resources_Top-Level_Resources.rst @@ -6,7 +6,7 @@ This logo of the snake doubles as a quick way to test Arcade's resource handles.
  • Look down toward the Arcade logo below until you see the file name
  • Look to the right edge of the file name ('logo.png')
  • There should be a copy button (
    -
    )
  • + )
  • Click or tap it.
  • From cdd6f66aef330856a84446f8a34d176b7e66c2f5 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Mon, 27 Jan 2025 05:12:59 -0500 Subject: [PATCH 008/279] Fix GitHub stars on the home page, our pyglet intersphinx config, and small issues on resources (#2524) * Edit front page text * Fix alignment of GH button. * Swap to pyglet's latest build for interpshinx to match build changes * Fix styles and redundant information on resources page * Fix video heading doubling --- .../resources_Top-Level_Resources.rst | 19 ++------ doc/_includes/resources_Video.rst | 5 -- doc/_static/css/custom.css | 17 +++++-- doc/conf.py | 4 +- doc/index.rst | 34 ++++++-------- util/create_resources_listing.py | 46 +++++++++++++------ 6 files changed, 63 insertions(+), 62 deletions(-) diff --git a/doc/_includes/resources_Top-Level_Resources.rst b/doc/_includes/resources_Top-Level_Resources.rst index 5b9abb4ce7..3c21f458b2 100644 --- a/doc/_includes/resources_Top-Level_Resources.rst +++ b/doc/_includes/resources_Top-Level_Resources.rst @@ -1,4 +1,4 @@ -This logo of the snake doubles as a quick way to test Arcade's resource handles. +The Arcade logo doubles as a quick way to test :ref:`resource handles `. .. raw:: html @@ -7,19 +7,6 @@ This logo of the snake doubles as a quick way to test Arcade's resource handles.
  • Look to the right edge of the file name ('logo.png')
  • There should be a copy button (
    )
  • -
  • Click or tap it.
  • +
  • Click or tap it to copy the string
  • +
  • Try pasting it in your favorite text editor!
  • - -Click the button or tap it if you're on mobile. Then try pasting in your favorite text editor. It -should look like this:: - - ':resources:/logo.png' - -This string is what Arcade calls a **:ref:`resource handle `**. They let you load -images, sound, and other data without worrying about where exactly data is on a computer. To learn -more, including how to define your own handle prefix, see :ref:`resource_handles`. - -To get started with game code right away, please see the following: - -* :ref:`example-code` -* :ref:`main-page-tutorials` diff --git a/doc/_includes/resources_Video.rst b/doc/_includes/resources_Video.rst index 031fe91a4f..9ae0cc0c3f 100644 --- a/doc/_includes/resources_Video.rst +++ b/doc/_includes/resources_Video.rst @@ -1,8 +1,3 @@ -.. _resources_video: - -Video ------ - Arcade offers experimental support for video playback through :py:mod:`pyglet` and other libraries. .. warning:: These features are works-in-progress! diff --git a/doc/_static/css/custom.css b/doc/_static/css/custom.css index 88aae3daa1..3e33eb2830 100644 --- a/doc/_static/css/custom.css +++ b/doc/_static/css/custom.css @@ -57,12 +57,15 @@ title + list appear like other categories */ width: 100%; } -/* Wrappers and formatting for sprinter, START HERE, github star button -to align them neatly */ +/* Align the GitHub stars button since Sphinx doesn't offer us great control over headings */ .main-page-item-wrapper-header { align-items: center; - display: flex; - margin: 10px; + float: right; + display: relative; +} +/* IMPORTANT: unlike flex, you have to clear float! */ +.main-page-item-wrapper-header::after { + float: clear; } .main-page-box { width: 100%; @@ -97,7 +100,8 @@ to align them neatly */ width: 100%; display: flex; } */ -.main-page-box > .main-page-box-gh { + +.main-page-box-gh { display: flex; align-items: center; margin-right: 0px; @@ -342,6 +346,9 @@ table.resource-table td > .resource-thumb.file-icon { li .arcade-ezcopy.doc-ui-example-dummy { margin-left: 0.2em; margin-right: 0.2em; + /* Override 12 px bottom margin */ + margin-top: inherit !important; + margin-bottom: inherit !important; } table.colorTable { diff --git a/doc/conf.py b/doc/conf.py index 2b68f260b6..963b8cd597 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -323,7 +323,9 @@ def run_util(filename, run_name="__main__", init_globals=None): # Configuration for intersphinx enabling linking other projects intersphinx_mapping = { 'python': ('https://docs.python.org/3', None), - 'pyglet': ('https://pyglet.readthedocs.io/en/development/', None), + # As of January 25th, pyglet's 2.1.X branch is on this URL and their + # development build on readthedocs is for their in-progress 3.0.0 alpha. + 'pyglet': ('https://pyglet.readthedocs.io/en/latest/', None), 'PIL': ('https://pillow.readthedocs.io/en/stable', None), 'pymunk': ('https://www.pymunk.org/en/latest/', None), } diff --git a/doc/index.rst b/doc/index.rst index 1fd4d6284c..bac6112449 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,32 +1,26 @@ :hide-toc: +.. container:: main-page-item-wrapper-header + + .. raw:: html + +
    + +
    .. _main_page: The Python Arcade Library ========================= - -.. container:: main-page-item-wrapper-header - - .. raw:: html - -
    - -
    - -
    -
    - Arcade is an easy-to-learn Python library for creating 2D games and more. The -friendly API caters to both beginners and experts alike. Do you want to make -something small, or explore the full power of shaders? It's up to you. +friendly API caters to both beginners and experts alike. Do you want to craft +craft your take on a 2D classic, or explore the full power of shaders? It's up to you. What will you make? diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index 8dd12e388c..e1f21a936a 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -143,7 +143,7 @@ class TableConfigDict(TypedDict): class HeadingConfigDict(TypedDict): - ref_target: NotRequired[str | bool] + ref_target: NotRequired[str] skip: NotRequired[bool] value: NotRequired[str] level: NotRequired[int] @@ -169,6 +169,7 @@ class HandleLevelConfigDict(TypedDict): ":resources:/": { "heading": { "value": "Top-Level Resources", + "ref_target": "resources-top-level-resources", "level": 1 }, "include": "resources_Top-Level_Resources.rst" @@ -208,10 +209,10 @@ class HandleLevelConfigDict(TypedDict): }, ":resources:/video/": { # pending: post-3.0 cleanup # trains are hats - # "heading:": { - # "value": "Video", - # "ref_target": "resources_video" - # }, + "heading:": { + "value": "Video", + "ref_target": "resources_video" + }, "include": "resources_Video.rst" } } @@ -273,6 +274,7 @@ def do_heading( if ref_target is True: ref_target = f"resources-{heading_text}.rst" if ref_target: + print(f" writing ref target {repr(heading_text)}") out.write(f".. _{ref_target.lower()}:\n\n") if relative_heading_level >= num_headings: @@ -427,19 +429,24 @@ def process_resource_directory(out, dir: Path): local_config = RESOURCE_HANDLE_CONFIGS.get(handle_step_whole, {}) local_heading_config = local_config.get('heading', {}) - # print("proceeding...", - # "\n config ", local_config, - # "\n heading_config ", local_heading_config, sep = "") + print("proceeding...", + "\n config ", local_config, + "\n heading_config ", local_heading_config, sep = "") # Heading config fetch and write use_level = local_heading_config.get('level', heading_level) use_value = local_heading_config.get('value', None) + use_target = local_heading_config.get('ref_target', None) if use_value is None: use_value = format_title_part(handle_steps_parts[heading_level]) - use_target = local_heading_config.get('ref_target', None) - if isinstance(use_target, bool) and use_target: - use_target = f"resources_{use_value.lower()}" + if use_target is None: + use_target = f"resources-{use_value.lower()}" + + for k, v in locals().items(): + if k.startswith("use_"): + print(repr(k), ":", repr(v)) + print(f" got target: {use_target!r}") do_heading(out, use_level, use_value, ref_target=use_target) out.write(f"\n.. comment `{handle_step_whole!r}``\n\n") @@ -788,7 +795,7 @@ def resources(): "convenience, or visit `Kenney.nl`_ for more permissively licensed game assets.\n" "\n" "The one feature which may require additional software is Arcade's experimental video playback\n" - "support. The :ref:`resources_video` section below will explain further.\n") + "support. The :ref:`resources-video` section below will explain further.\n") do_heading(out, 1, "Do I have to credit anyone?") # Injecting the links.rst doesn't seem to be working? @@ -814,9 +821,18 @@ def resources(): f")\n" f" \n\n" + - "Click the button above a preview to copy the **resource handle** string for loading the asset.\n" - "Any image or sound on this page should work after installing Arcade with zero additional dependencies.\n" - "Full example code and manual sections for any relevant functions are linked below." + "Click the button above a preview to copy the **resource handle** string for loading the asset. It should\n" + "look something like this::\n\n" + " ':resources:/logo.png'\n" + "\n" + "Each resource preview on this page has a button which copies a corresponding string. These\n" + "resource handle strings allow using Arcade's built-in assets without worrying where a file is\n" + "on a computer.\n\n" + "To learn more, please see:\n\n" + "* The :ref:`resources-top-level-resources` section for a short tutorial on resource handles\n" + "* :ref:`example-code` for runnable example code\n" + "* :ref:`main-page-tutorials` for a step-by-step introduction to Arcade\n" + "* :ref:`resource_handles` for in-depth explanations of resource handles\n\n" ) out.write("\n") From 333ce6e7fb45323da6ebd9f566e5de8ee2bbe30e Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Fri, 31 Jan 2025 18:28:01 +0100 Subject: [PATCH 009/279] Remove outdated type: ignore (#2530) --- arcade/__init__.py | 6 +++--- arcade/application.py | 8 ++++---- arcade/geometry.py | 2 +- arcade/hitbox/base.py | 2 +- arcade/paths.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index a27eb7e38a..5c7f012d82 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -68,14 +68,14 @@ def configure_logging(level: int | None = None): # Env variable shortcut for headless mode headless: Final[bool] = bool(os.environ.get("ARCADE_HEADLESS")) if headless: - pyglet.options.headless = headless # type: ignore # pending https://github.com/pyglet/pyglet/issues/1164 + pyglet.options.headless = headless from arcade import utils # Disable shadow window on macs and in headless mode. if sys.platform == "darwin" or os.environ.get("ARCADE_HEADLESS") or utils.is_raspberry_pi(): - pyglet.options.shadow_window = False # type: ignore # pending https://github.com/pyglet/pyglet/issues/1164 + pyglet.options.shadow_window = False # Imports from modules that don't do anything circular @@ -147,7 +147,7 @@ def configure_logging(level: int | None = None): from .screenshot import get_pixel # We don't have joysticks game controllers in headless mode -if not headless: # type: ignore +if not headless: from .joysticks import get_game_controllers from .joysticks import get_joysticks from .controller import ControllerManager diff --git a/arcade/application.py b/arcade/application.py index 275e44d437..33a404783d 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -60,7 +60,7 @@ def get_screens() -> list[Screen]: List of screens, one for each monitor. """ display = pyglet.display.get_display() - return display.get_screens() # type: ignore # pending: https://github.com/pyglet/pyglet/pull/1176 # noqa + return display.get_screens() class NoOpenGLException(Exception): @@ -228,9 +228,9 @@ def __init__( style=style, ) # pending: weird import tricks resolved - self.register_event_type("on_update") # type: ignore - self.register_event_type("on_action") # type: ignore - self.register_event_type("on_fixed_update") # type: ignore + self.register_event_type("on_update") + self.register_event_type("on_action") + self.register_event_type("on_fixed_update") except pyglet.window.NoSuchConfigException: raise NoOpenGLException( "Unable to create an OpenGL 3.3+ context. " diff --git a/arcade/geometry.py b/arcade/geometry.py index 739d1c3955..22b8e3dbaf 100644 --- a/arcade/geometry.py +++ b/arcade/geometry.py @@ -58,7 +58,7 @@ def are_polygons_intersecting(poly_a: Point2List, poly_b: Point2List) -> bool: max_b = projected # Avoid typing.cast() because this is a very hot path - if max_a <= min_b or max_b <= min_a: # type: ignore + if max_a <= min_b or max_b <= min_a: return False return True diff --git a/arcade/hitbox/base.py b/arcade/hitbox/base.py index 97a88eba2b..9e4aa4dd21 100644 --- a/arcade/hitbox/base.py +++ b/arcade/hitbox/base.py @@ -240,7 +240,7 @@ def _adjust_point(point) -> Point2: self._adjusted_points = [_adjust_point(point) for point in self.points] self._adjusted_cache_dirty = False - return self._adjusted_points # type: ignore [return-value] + return self._adjusted_points class RotatableHitBox(HitBox): diff --git a/arcade/paths.py b/arcade/paths.py index 9e60af0e62..cdca17a024 100644 --- a/arcade/paths.py +++ b/arcade/paths.py @@ -88,7 +88,7 @@ def __init__( self.bottom = bottom if diagonal_movement: - self.movement_directions = ( # type: ignore + self.movement_directions = ( (1, 0), (-1, 0), (0, 1), From 7264ac31894084ff3dbeb56dc40f77a367528854 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Fri, 31 Jan 2025 18:58:14 +0100 Subject: [PATCH 010/279] Fix docstring args --- arcade/application.py | 8 ++++---- arcade/cache/hit_box.py | 6 ++---- arcade/camera/camera_2d.py | 13 +++++++------ arcade/camera/data_types.py | 2 -- arcade/camera/default.py | 4 ++-- arcade/camera/orthographic.py | 2 -- arcade/draw/rect.py | 4 ++-- arcade/future/light/lights.py | 3 ++- arcade/future/video/video_player.py | 2 +- arcade/gl/framebuffer.py | 2 -- arcade/gl/glsl.py | 2 -- arcade/gui/surface.py | 1 - arcade/paths.py | 3 --- arcade/sections.py | 4 ++-- arcade/shape_list.py | 1 - arcade/sound.py | 2 +- arcade/sprite/animated.py | 4 +--- arcade/texture/manager.py | 17 ++++------------- arcade/texture_atlas/atlas_default.py | 10 ++++++---- arcade/texture_atlas/base.py | 10 ++++++---- arcade/texture_atlas/region.py | 2 -- 21 files changed, 40 insertions(+), 62 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index 275e44d437..e399c43c85 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -1388,9 +1388,9 @@ def on_mouse_drag( Change in x since the last time this method was called dy: Change in y since the last time this method was called - buttons: + _buttons: Which button is pressed - modifiers: + _modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) active during this event. See :ref:`keyboard_modifiers`. """ @@ -1493,9 +1493,9 @@ def on_key_release(self, _symbol: int, _modifiers: int) -> bool | None: * Showing which keys are currently pressed down Args: - symbol: + _symbol: Key that was released - modifiers: + _modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) active during this event. See :ref:`keyboard_modifiers`. """ diff --git a/arcade/cache/hit_box.py b/arcade/cache/hit_box.py index 91e042e0df..da2e3d6096 100644 --- a/arcade/cache/hit_box.py +++ b/arcade/cache/hit_box.py @@ -59,10 +59,8 @@ def get(self, name_or_texture: str | Texture) -> Point2List | None: points = cache.get("hash|(0, 1, 2, 3)|simple|") Args: - keys: + name_or_texture: The texture or cache name to get the hit box for - hit_box_algorithm: - The hit box algorithm used """ from arcade import Texture @@ -85,7 +83,7 @@ def put(self, name_or_texture: str | Texture, points: Point2List) -> None: cache.put("my_custom_points", points) Args: - keys: + name_or_texture: The texture or cache name to store the hit box for points: The hit box points diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index a20936ef9e..2bbf205d21 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -343,13 +343,14 @@ def match_window( Should be called when the window is resized. Args: - and_projection: Flag whether to also equalize the projection to the viewport. + viewport: Flag whether to equalise the viewport to the value. + projection: Flag whether to also equalize the projection to the viewport. On by default - and_scissor: Flag whether to also equalize the scissor box to the viewport. + scissor: Flag whether to also equalize the scissor box to the viewport. On by default - and_position: Flag whether to also center the camera to the viewport. + position: Flag whether to also center the camera to the viewport. Off by default - aspect_ratio: The ratio between width and height that the viewport should + aspect: The ratio between width and height that the viewport should be constrained to. If unset then the viewport just matches the window size. The aspect ratio describes how much larger the width should be compared to the height. i.e. for an aspect ratio of ``4:3`` you should @@ -383,7 +384,7 @@ def match_target( scissor: Flag whether to update the scissor value. position: Flag whether to also center the camera to the value. Off by default - aspect_ratio: The ratio between width and height that the value should + aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. If unset then the value will not be updated. @@ -425,7 +426,7 @@ def update_values( scissor: Flag whether to update the scissor value. position: Flag whether to also center the camera to the value. Off by default - aspect_ratio: The ratio between width and height that the value should + aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. If unset then the value will not be updated. diff --git a/arcade/camera/data_types.py b/arcade/camera/data_types.py index c0515762d3..3432a4853b 100644 --- a/arcade/camera/data_types.py +++ b/arcade/camera/data_types.py @@ -450,8 +450,6 @@ def unproject(self, screen_coordinate: Point) -> Vec3: Args: screen_coordinate: A 2D position in pixels should generally be inside the range of the active viewport. - depth: The depth of the query. This can be though of how far along the forward vector - the final coord will be. Returns: A 3D vector in world space. """ diff --git a/arcade/camera/default.py b/arcade/camera/default.py index fbf4b63a44..cba9602fc1 100644 --- a/arcade/camera/default.py +++ b/arcade/camera/default.py @@ -24,7 +24,7 @@ class ViewportProjector: Args: viewport: The viewport to project to. - window: The window to bind the camera to. Defaults to the currently active window. + context: The window context to bind the camera to. Defaults to the currently active window. """ def __init__( @@ -108,7 +108,7 @@ class DefaultProjector(ViewportProjector): no instance where a developer would want to use this class. Args: - window: The window to bind the camera to. Defaults to the currently active window. + context: The window context to bind the camera to. Defaults to the currently active window. """ def __init__(self, *, context: ArcadeContext | None = None): diff --git a/arcade/camera/orthographic.py b/arcade/camera/orthographic.py index 71fdbdbcf0..78061cae92 100644 --- a/arcade/camera/orthographic.py +++ b/arcade/camera/orthographic.py @@ -181,11 +181,9 @@ def unproject(self, screen_coordinate: Point) -> Vec3: Args: screen_coordinate: A 2D position in pixels from the bottom left of the screen. This should ALWAYS be in the range of 0.0 - screen size. - depth: The depth of the query Returns: A 3D vector in world space. """ - _projection = generate_orthographic_matrix(self._projection, self._view.zoom) _view = generate_view_matrix(self._view) return unproject_orthographic(screen_coordinate, self.viewport.lbwh_int, _view, _projection) diff --git a/arcade/draw/rect.py b/arcade/draw/rect.py index 21352a0b7b..22a3070c83 100644 --- a/arcade/draw/rect.py +++ b/arcade/draw/rect.py @@ -207,9 +207,9 @@ def draw_lbwh_rectangle_outline( Draw a rectangle extending from bottom left to top right Args: - bottom_left_x: + left: The x coordinate of the left edge of the rectangle. - bottom_left_y: + bottom: The y coordinate of the bottom of the rectangle. width: The width of the rectangle. diff --git a/arcade/future/light/lights.py b/arcade/future/light/lights.py index ece9550427..e792ef4b0c 100644 --- a/arcade/future/light/lights.py +++ b/arcade/future/light/lights.py @@ -87,7 +87,8 @@ class LightLayer(RenderTargetTexture): The size of a layer should ideally be of the same size and the screen. Args: - size: Width and height of light layer + width: Width of light layer + height: Height of light layer """ def __init__(self, width: int, height: int): diff --git a/arcade/future/video/video_player.py b/arcade/future/video/video_player.py index 9d3fb53ad4..096b80c9ee 100644 --- a/arcade/future/video/video_player.py +++ b/arcade/future/video/video_player.py @@ -41,7 +41,7 @@ def draw(self, left: int = 0, bottom: int = 0, size: tuple[int, int] | None = No Args: size: Pass None as one of the elements if you want to use the - dimension(width, height) attribute. + dimension(width, height) attribute. """ if size and len(size) == 2: self._width = size[0] or self.width diff --git a/arcade/gl/framebuffer.py b/arcade/gl/framebuffer.py index 09b0ab4d18..15b7edc0ed 100644 --- a/arcade/gl/framebuffer.py +++ b/arcade/gl/framebuffer.py @@ -347,8 +347,6 @@ def clear( A 3 or 4 component tuple containing the color in normalized form depth: Value to clear the depth buffer (unused) - normalized: - If the color values are normalized or not viewport: The viewport range to clear """ diff --git a/arcade/gl/glsl.py b/arcade/gl/glsl.py index 9c5dd91898..ad3bb54119 100644 --- a/arcade/gl/glsl.py +++ b/arcade/gl/glsl.py @@ -32,8 +32,6 @@ class ShaderSource: Common source code to inject source_type: The shader type - depth_attachment: - A depth attachment (optional) """ def __init__( diff --git a/arcade/gui/surface.py b/arcade/gui/surface.py index 5e7af4d86a..ab1b5c958a 100644 --- a/arcade/gui/surface.py +++ b/arcade/gui/surface.py @@ -229,7 +229,6 @@ def draw( Args: area: Limit the area in the surface we're drawing (l, b, w, h) - pixelated: If True, the texture will be rendered pixelated """ # Set blend function blend_func = self.ctx.blend_func diff --git a/arcade/paths.py b/arcade/paths.py index 9e60af0e62..e426118dcf 100644 --- a/arcade/paths.py +++ b/arcade/paths.py @@ -253,9 +253,6 @@ class AStarBarrierList: Bottom of playing field top (int): Top of playing field - barrier_list: - SpriteList of barriers to use in _AStarSearch, - ``None`` if not recalculated Attributes: grid_size: diff --git a/arcade/sections.py b/arcade/sections.py index eabff304b1..d69b2e9ddb 100644 --- a/arcade/sections.py +++ b/arcade/sections.py @@ -425,8 +425,8 @@ def on_key_release(self, _symbol: int, _modifiers: int): Called when the user releases a key. Args: - symbol: the key released - modifiers: the modifiers pressed + _symbol: the key released + _modifiers: the modifiers pressed """ pass diff --git a/arcade/shape_list.py b/arcade/shape_list.py index 4cd950c5f0..c3c87b672a 100644 --- a/arcade/shape_list.py +++ b/arcade/shape_list.py @@ -247,7 +247,6 @@ def create_lines( Args: point_list: A list of points that make up the shape. color: A color such as a :py:class:`~arcade.types.Color` - line_width: Width of the line """ return create_line_generic(point_list, color, gl.GL_LINES) diff --git a/arcade/sound.py b/arcade/sound.py index 7c38862cbe..4bc3549114 100644 --- a/arcade/sound.py +++ b/arcade/sound.py @@ -317,7 +317,7 @@ def play_sound( try: return sound.play(volume, pan, loop, speed) except Exception as ex: - logger.warn("Error playing sound.", ex) + logger.warning("Error playing sound.", ex) return None diff --git a/arcade/sprite/animated.py b/arcade/sprite/animated.py index 5c8d543072..7325561c98 100644 --- a/arcade/sprite/animated.py +++ b/arcade/sprite/animated.py @@ -52,8 +52,6 @@ class TextureAnimation: Args: keyframes: List of keyframes for the animation. - loop: - If the animation should loop. """ __slots__ = ("_keyframes", "_duration_ms", "_timeline") @@ -358,7 +356,7 @@ def update_animation(self, delta_time: float = 1 / 60) -> None: self.texture = texture_list[self.cur_texture_index] if self._texture is None: - logger.warn("Error, no texture set") + logger.warning("Error, no texture set") else: self.width = self._texture.width * self.scale_x self.height = self._texture.height * self.scale_x diff --git a/arcade/texture/manager.py b/arcade/texture/manager.py index 9a6c2bd9f3..40f5477a1b 100644 --- a/arcade/texture/manager.py +++ b/arcade/texture/manager.py @@ -133,7 +133,7 @@ def load_or_get_spritesheet_texture( hit_box_algorithm: hitbox.HitBoxAlgorithm | None = None, ) -> Texture: """ - Slice out a a texture at x, y, width, height from a sprite sheet. + Slice out a texture slice from a sprite sheet. * If the spritesheet is not already loaded, it will be loaded and cached. * If the sliced texture is already cached, it will be returned instead. @@ -141,14 +141,8 @@ def load_or_get_spritesheet_texture( Args: path: Path to the sprite sheet image - x: - X position of the texture in the sprite sheet - y: - Y position of the texture in the sprite sheet - width: - Width of the texture - height: - Height of the texture + rect: + Slice of the texture in the sprite sheet. hit_box_algorithm (optional): Hit box algorithm to use. If not specified, the global default will be used. """ @@ -188,9 +182,6 @@ def load_or_get_image( Path of the file to load. hash: Optional override for image hash - cache: - If ``True``, the image will be cached. If ``False``, the - image will not be cached or returned from the cache. mode: The mode to use for the image. Default is "RGBA". """ @@ -223,7 +214,7 @@ def load_or_get_texture( entire image is loaded. Args: - file_name: + file_path: Path to the image file. x (optional): X coordinate of the texture in the image. diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index 55fdbd1d97..b61aa2ae43 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -605,7 +605,7 @@ def get_texture_id(self, texture: "Texture") -> int: Get the internal id for a Texture in the atlas Args: - atlas_name: The name of the texture in the atlas + texture: The texture to get. """ return self._texture_uvs.get_slot_or_raise(texture.atlas_name) @@ -883,7 +883,7 @@ def to_image( Number of components. (3 = RGB, 4 = RGBA) draw_borders: Draw region borders into image - color: + border_color: RGB color of the borders Returns: A pillow image containing the atlas texture @@ -931,7 +931,7 @@ def show( Number of components. (3 = RGB, 4 = RGBA) draw_borders: Draw region borders into image - color: + border_color: RGB color of the borders """ self.to_image( @@ -962,7 +962,9 @@ def save( Flip the image horizontally components: Number of components. (3 = RGB, 4 = RGBA) - color: + draw_borders: + Draw region borders into image + border_color: RGB color of the borders """ self.to_image( diff --git a/arcade/texture_atlas/base.py b/arcade/texture_atlas/base.py index 27c693f4b5..216238c19d 100644 --- a/arcade/texture_atlas/base.py +++ b/arcade/texture_atlas/base.py @@ -207,7 +207,7 @@ def get_texture_id(self, texture: Texture) -> int: Get the internal id for a Texture in the atlas Args: - atlas_name: The name of the texture in the atlas + texture: The texture to get """ ... @@ -364,7 +364,7 @@ def to_image( Number of components. (3 = RGB, 4 = RGBA) draw_borders: Draw region borders into image - color: + border_color: RGB color of the borders Returns: A pillow image containing the atlas texture @@ -392,7 +392,7 @@ def show( Number of components. (3 = RGB, 4 = RGBA) draw_borders: Draw region borders into image - color: + border_color: RGB color of the borders """ self.to_image( @@ -425,7 +425,9 @@ def save( Flip the image horizontally components: Number of components. (3 = RGB, 4 = RGBA) - color: + draw_borders: + Draw region borders into image + border_color: RGB color of the borders """ diff --git a/arcade/texture_atlas/region.py b/arcade/texture_atlas/region.py index dcb15dbd22..bec593a587 100644 --- a/arcade/texture_atlas/region.py +++ b/arcade/texture_atlas/region.py @@ -40,8 +40,6 @@ class AtlasRegion: Args: atlas: The atlas this region belongs to - texture: - The Arcade texture x: The x position of the texture y: From e3560088ac6d3c1586322b0666068fff4fbb0577 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Sat, 1 Feb 2025 22:51:12 +0100 Subject: [PATCH 011/279] Remove unused speedometer-icon.svg (#2537) --- speedometer-icon.svg | 42 ------------------------------------------ 1 file changed, 42 deletions(-) delete mode 100644 speedometer-icon.svg diff --git a/speedometer-icon.svg b/speedometer-icon.svg deleted file mode 100644 index 79e63862fc..0000000000 --- a/speedometer-icon.svg +++ /dev/null @@ -1,42 +0,0 @@ - -image/svg+xml - - - - - - - - \ No newline at end of file From b28844e2df8ba18c9e6523ad14a0293cd02a84e6 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Sun, 2 Feb 2025 00:11:16 +0100 Subject: [PATCH 012/279] Remove outdated comments in pyproject.toml (#2529) --- pyproject.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7dd312d5ce..36ce0b76c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,10 +20,6 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - # Fallback to use pyglet's development branch as a rolling release package - # at the cost of slow download and constant pip install -I -e .[dev] - # "pyglet@git+https://github.com/pyglet/pyglet.git@development#egg=pyglet", - # Expected future dev preview release on PyPI (not yet released) "pyglet~=2.1.0", "pillow~=11.0.0", "pymunk~=6.9.0", @@ -42,7 +38,6 @@ Book = "https://learn.arcade.academy" [project.optional-dependencies] # Used for dev work dev = [ - # --- Documentation: Sphinx 7 based currently "sphinx==8.1.3", # April 2024 | Updated 2024-07-15, 7.4+ is broken with sphinx-autobuild "sphinx_rtd_theme==3.0.2", # Nov 2024 "sphinx-rtd-dark-mode==1.3.0", From e3e02b6a816f4311ed92cea7a82b9d4b0bddc8d1 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Sun, 2 Feb 2025 00:17:19 +0100 Subject: [PATCH 013/279] Remove deprecated function read_tmx (#2528) --- arcade/__init__.py | 2 -- arcade/tilemap/__init__.py | 4 ++-- arcade/tilemap/tilemap.py | 15 +++------------ 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index 5c7f012d82..e687956869 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -196,7 +196,6 @@ def configure_logging(level: int | None = None): from .physics_engines import PhysicsEngineSimple from .tilemap import load_tilemap -from .tilemap import read_tmx from .tilemap import TileMap from .pymunk_physics_engine import PymunkPhysicsEngine @@ -372,7 +371,6 @@ def configure_logging(level: int | None = None): "open_window", "print_timings", "play_sound", - "read_tmx", "load_tilemap", "run", "schedule", diff --git a/arcade/tilemap/__init__.py b/arcade/tilemap/__init__.py index d8aff38519..fe296379ea 100644 --- a/arcade/tilemap/__init__.py +++ b/arcade/tilemap/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from .tilemap import TileMap, load_tilemap, read_tmx +from .tilemap import TileMap, load_tilemap -__all__ = ["TileMap", "load_tilemap", "read_tmx"] +__all__ = ["TileMap", "load_tilemap"] diff --git a/arcade/tilemap/tilemap.py b/arcade/tilemap/tilemap.py index 8dd5e81ccc..e74cf4d101 100644 --- a/arcade/tilemap/tilemap.py +++ b/arcade/tilemap/tilemap.py @@ -48,7 +48,7 @@ _FLIPPED_VERTICALLY_FLAG = 0x40000000 _FLIPPED_DIAGONALLY_FLAG = 0x20000000 -__all__ = ["TileMap", "load_tilemap", "read_tmx"] +__all__ = ["TileMap", "load_tilemap"] prop_to_float = cast(Callable[[pytiled_parser.Property], float], float) @@ -237,7 +237,7 @@ def __init__( ) -> None: if not map_file and not tiled_map: raise AttributeError( - "Initialized TileMap with an empty map_file or no map_object argument" + "Initialized TileMap with an empty map_file or no tiled_map argument" ) if tiled_map: @@ -430,7 +430,7 @@ def _get_tile_by_gid(self, tile_gid: int) -> pytiled_parser.Tile | None: if existing_ref: tile_ref = existing_ref else: - tile_ref = pytiled_parser.Tile(id=(tile_id), image=tileset.image) + tile_ref = pytiled_parser.Tile(id=tile_id, image=tileset.image) elif tileset.tiles is None and tileset.image is not None: # Not in this tileset, move to the next continue @@ -1075,12 +1075,3 @@ def load_tilemap( texture_atlas=texture_atlas, lazy=lazy, ) - - -def read_tmx(map_file: str | Path) -> pytiled_parser.TiledMap: - """ - Deprecated function to raise a warning that it has been removed. - - Exists to provide info for outdated code bases. - """ - raise DeprecationWarning("The read_tmx function has been replaced by the new TileMap class.") From 6a52632b47aea82aaef5a262e4114e024edd1b89 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 2 Feb 2025 00:39:53 +0100 Subject: [PATCH 014/279] Input fixes (#2541) * Example use old SpriteList.on_update * _generate_next_value_ is a staticmethod --- arcade/future/input/input_manager_example.py | 2 +- arcade/future/input/inputs.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/arcade/future/input/input_manager_example.py b/arcade/future/input/input_manager_example.py index e8815d7485..50b715a959 100644 --- a/arcade/future/input/input_manager_example.py +++ b/arcade/future/input/input_manager_example.py @@ -213,7 +213,7 @@ def on_key_press(self, key, modifiers): player.input_manager.allow_keyboard = False def on_update(self, delta_time: float): - self.player_list.on_update(delta_time) + self.player_list.update(delta_time) for label, player in zip(self.player_device_labels, self.players): position_x, position_y = player.position position_y += FLOAT_HEIGHT diff --git a/arcade/future/input/inputs.py b/arcade/future/input/inputs.py index 05eaa50cbd..e6b8b764b0 100644 --- a/arcade/future/input/inputs.py +++ b/arcade/future/input/inputs.py @@ -37,6 +37,7 @@ def __new__(cls, value, *args, **kwargs): def __str__(self): return str(self.value) + @staticmethod def _generate_next_value_(name, *_): return name From 33b96517d5ee3d073330bd98db88a8d1046da427 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 2 Feb 2025 01:31:11 +0100 Subject: [PATCH 015/279] Skip compute shader tutorial on darwin (#2542) --- tests/integration/tutorials/test_tutorials.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/tutorials/test_tutorials.py b/tests/integration/tutorials/test_tutorials.py index dd179b276b..0a379bb242 100644 --- a/tests/integration/tutorials/test_tutorials.py +++ b/tests/integration/tutorials/test_tutorials.py @@ -6,6 +6,8 @@ import contextlib from importlib.machinery import SourceFileLoader from pathlib import Path +import platform + import pytest import arcade @@ -34,6 +36,9 @@ def find_tutorials(): ) def test_tutorials(window_proxy, file_path, allow_stdout): """Run all tutorials""" + if "compute_shader" in str(file_path) and platform.system() == "darwin": + raise pytest.skip(f"compute_shader tutorial not working on OS X") + os.environ["ARCADE_TEST"] = "TRUE" stdout = io.StringIO() with contextlib.redirect_stdout(stdout): From 56e4c965d47af29b4388e20225d5d6af72f72896 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 2 Feb 2025 01:53:59 +0100 Subject: [PATCH 016/279] Load liberation fonts in conftest.py (#2543) --- tests/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index d5c08bb653..b053294291 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,9 @@ from arcade import Rect, LBWH from arcade import gl # from arcade.texture import default_texture_cache +# NOTE: Load liberation fonts in unit tests +arcade.resources.load_liberation_fonts() + PROJECT_ROOT = (Path(__file__).parent.parent).resolve() FIXTURE_ROOT = PROJECT_ROOT / "tests" / "fixtures" From 6449ce554bd7d03e981d41d0c9bd53f61f7b19e0 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 2 Feb 2025 02:16:44 +0100 Subject: [PATCH 017/279] Fix minimap example window resize (#2544) --- arcade/examples/minimap_texture.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/arcade/examples/minimap_texture.py b/arcade/examples/minimap_texture.py index 4837d303ee..a817617afb 100644 --- a/arcade/examples/minimap_texture.py +++ b/arcade/examples/minimap_texture.py @@ -16,6 +16,7 @@ WINDOW_WIDTH = 1280 WINDOW_HEIGHT = 720 +ASPECT_RATIO = WINDOW_WIDTH / WINDOW_HEIGHT WINDOW_TITLE = "Minimap Example" # How many pixels to keep as a minimum margin between the character @@ -203,8 +204,8 @@ def on_resize(self, width: int, height: int): Handle the user grabbing the edge and resizing the window. """ super().on_resize(width, height) - self.camera_sprites.match_window() - self.camera_gui.match_window() + self.camera_sprites.match_window(aspect=ASPECT_RATIO, projection=False) + self.camera_gui.match_window(aspect=ASPECT_RATIO, projection=False) def main(): From 64546b7e73f4f7ec3ffbdf45690a141a12aa17a7 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 2 Feb 2025 02:32:10 +0100 Subject: [PATCH 018/279] Examples: Fix incorrect usage of close() in some examples (#2545) --- arcade/examples/astar_pathfinding.py | 2 +- arcade/examples/asteroid_smasher.py | 2 +- arcade/examples/dual_stick_shooter.py | 2 +- arcade/examples/particle_fireworks.py | 2 +- arcade/examples/slime_invaders.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/arcade/examples/astar_pathfinding.py b/arcade/examples/astar_pathfinding.py index b82d0811bc..04773e9fa5 100644 --- a/arcade/examples/astar_pathfinding.py +++ b/arcade/examples/astar_pathfinding.py @@ -215,7 +215,7 @@ def on_key_press(self, key, modifiers): self.right_pressed = True # Close the window / exit game elif key == arcade.key.ESCAPE: - self.close() + self.window.close() def on_key_release(self, key, modifiers): """Called when the user releases a key. """ diff --git a/arcade/examples/asteroid_smasher.py b/arcade/examples/asteroid_smasher.py index 904e0e33a6..4fa9e4c6ad 100644 --- a/arcade/examples/asteroid_smasher.py +++ b/arcade/examples/asteroid_smasher.py @@ -316,7 +316,7 @@ def on_key_press(self, symbol, modifiers): self.start_new_game() # Quit if the player hits escape elif symbol == arcade.key.ESCAPE: - self.close() + self.window.close() def on_key_release(self, symbol, modifiers): """ Called whenever a key is released. """ diff --git a/arcade/examples/dual_stick_shooter.py b/arcade/examples/dual_stick_shooter.py index fee80267b4..bdaaca40ec 100644 --- a/arcade/examples/dual_stick_shooter.py +++ b/arcade/examples/dual_stick_shooter.py @@ -279,7 +279,7 @@ def on_key_press(self, key, modifiers): self.player.start_pressed = True # close the window if the user hits the escape key elif key == arcade.key.ESCAPE: - self.close() + self.window.close() rad = math.atan2(self.player.change_y, self.player.change_x) self.player.angle = math.degrees(rad) + ROTATE_OFFSET diff --git a/arcade/examples/particle_fireworks.py b/arcade/examples/particle_fireworks.py index 9b99c55907..2e44f21fff 100644 --- a/arcade/examples/particle_fireworks.py +++ b/arcade/examples/particle_fireworks.py @@ -369,7 +369,7 @@ def on_draw(self): def on_key_press(self, key, modifiers): if key == arcade.key.ESCAPE: - arcade.close_window() + self.window.close() def firework_spark_mutator(particle: FadeParticle): diff --git a/arcade/examples/slime_invaders.py b/arcade/examples/slime_invaders.py index aaea79ac87..f4ef41d7e7 100644 --- a/arcade/examples/slime_invaders.py +++ b/arcade/examples/slime_invaders.py @@ -200,7 +200,7 @@ def on_draw(self): def on_key_press(self, key, modifiers): if key == arcade.key.ESCAPE: - self.close() + self.window.close() def on_mouse_motion(self, x, y, dx, dy): """ From 8b846c50da984de7bc250aeabc1b48e562e26cc2 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 2 Feb 2025 02:52:17 +0100 Subject: [PATCH 019/279] Fix docstrings in video_player.py --- arcade/future/video/video_player.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/arcade/future/video/video_player.py b/arcade/future/video/video_player.py index 096b80c9ee..548f16c1ec 100644 --- a/arcade/future/video/video_player.py +++ b/arcade/future/video/video_player.py @@ -37,11 +37,16 @@ def __init__(self, path: str | Path, loop: bool = False): def draw(self, left: int = 0, bottom: int = 0, size: tuple[int, int] | None = None) -> None: """ - Call this in `on_draw`. + Draw the current video frame. Args: - size: Pass None as one of the elements if you want to use the - dimension(width, height) attribute. + left: + Window position from the left. + bottom: + Window position from the bottom. + size: + The size of the video rectangle. + If `None`, the video will be drawn in its original size. """ if size and len(size) == 2: self._width = size[0] or self.width From 6a69c80f3b54e916f8722c143da54f38178894b2 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 2 Feb 2025 10:12:10 +0100 Subject: [PATCH 020/279] fix hide method of dropdown when no parent exists (#2539) --- arcade/gui/widgets/dropdown.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index 90e5fbe5d8..451026310f 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -28,7 +28,8 @@ def show(self, manager: UIManager): def hide(self): """Hide the overlay.""" - self.parent.remove(self) + if self.parent: + self.parent.remove(self) def on_event(self, event: UIEvent) -> Optional[bool]: if isinstance(event, UIMousePressEvent): @@ -164,8 +165,8 @@ def _on_button_click(self, _: UIOnClickEvent): def _on_option_click(self, event: UIOnClickEvent): source: UIFlatButton = event.source - self.value = source.text self._overlay.hide() + self.value = source.text def do_layout(self): """Position the overlay, this is not a common thing to do in do_layout, From 39501b8f835a692fed16548857342d5cdac78cca Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Sun, 2 Feb 2025 14:15:28 +0100 Subject: [PATCH 021/279] Fix pytest.skip for MacOS in test_tutorials.py (#2547) --- tests/integration/tutorials/test_tutorials.py | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/tests/integration/tutorials/test_tutorials.py b/tests/integration/tutorials/test_tutorials.py index 0a379bb242..748cae5536 100644 --- a/tests/integration/tutorials/test_tutorials.py +++ b/tests/integration/tutorials/test_tutorials.py @@ -1,12 +1,12 @@ """ -FInd and run all tutorials in the doc/tutorials directory +Find and run all tutorials in the doc/tutorials directory """ import io import os import contextlib from importlib.machinery import SourceFileLoader from pathlib import Path -import platform +import sys import pytest import arcade @@ -16,18 +16,10 @@ def find_tutorials(): - # Loop the directory of tutorials dirs - for dir in TUTORIAL_DIR.iterdir(): - if not dir.is_dir(): + for path in TUTORIAL_DIR.rglob("*.py"): + if path.stem.startswith("_"): continue - - print(dir) - # Find python files in each tutorial dir - for file in dir.glob("*.py"): - if file.stem.startswith("_"): - continue - # print("->", file) - yield file, file.stem in ALLOW_STDOUT + yield path, path.stem in ALLOW_STDOUT @pytest.mark.parametrize( @@ -36,8 +28,8 @@ def find_tutorials(): ) def test_tutorials(window_proxy, file_path, allow_stdout): """Run all tutorials""" - if "compute_shader" in str(file_path) and platform.system() == "darwin": - raise pytest.skip(f"compute_shader tutorial not working on OS X") + if file_path.parent.name == "compute_shader" and sys.platform == "darwin": + raise pytest.skip("compute_shader tutorial not working on MacOS") os.environ["ARCADE_TEST"] = "TRUE" stdout = io.StringIO() From 1ee0d89c9c22a3dc9283aa8729ad768b5f8a8fb6 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 2 Feb 2025 14:15:48 +0100 Subject: [PATCH 022/279] dual_stick_shooter: ESC should only close the window (#2548) --- arcade/examples/dual_stick_shooter.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/arcade/examples/dual_stick_shooter.py b/arcade/examples/dual_stick_shooter.py index bdaaca40ec..aba4ca5390 100644 --- a/arcade/examples/dual_stick_shooter.py +++ b/arcade/examples/dual_stick_shooter.py @@ -275,8 +275,6 @@ def on_key_press(self, key, modifiers): self.player.shoot_left_pressed = True elif key == arcade.key.DOWN: self.player.shoot_down_pressed = True - elif key == arcade.key.ESCAPE: - self.player.start_pressed = True # close the window if the user hits the escape key elif key == arcade.key.ESCAPE: self.window.close() From 3c17ecae64bf4419f036cfad07f292cb7ba1071c Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Sun, 2 Feb 2025 14:26:47 +0100 Subject: [PATCH 023/279] Small docs and example cleanup (#2546) --- arcade/context.py | 3 ++ arcade/examples/light_demo.py | 2 +- arcade/examples/line_of_sight.py | 46 ++++++++++----------- arcade/experimental/shadertoy.py | 2 + arcade/texture/spritesheet.py | 3 ++ arcade/tilemap/tilemap.py | 3 ++ doc/tutorials/platform_tutorial/step_12.rst | 2 +- 7 files changed, 34 insertions(+), 27 deletions(-) diff --git a/arcade/context.py b/arcade/context.py index d4603a9416..9a3a8d7e55 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -495,6 +495,9 @@ def load_texture( internal_format (optional): The internal format of the texture. This can be used to override the default internal format when using sRGBA or compressed textures. + immutable (optional): + Make the storage (not the contents) immutable. This can sometimes be + required when using textures with compute shaders. compressed (optional): If the internal format is a compressed format meaning your texture will be compressed by the GPU. diff --git a/arcade/examples/light_demo.py b/arcade/examples/light_demo.py index 89bab2ebf3..0dca24efc1 100644 --- a/arcade/examples/light_demo.py +++ b/arcade/examples/light_demo.py @@ -47,7 +47,7 @@ def __init__(self): self.physics_engine = None # Camera - self.cam: arcade.camera.Camera2D = None + self.camera: arcade.camera.Camera2D = None # --- Light related --- # List of all the lights diff --git a/arcade/examples/line_of_sight.py b/arcade/examples/line_of_sight.py index 1a65300882..54f0e6dee7 100644 --- a/arcade/examples/line_of_sight.py +++ b/arcade/examples/line_of_sight.py @@ -120,31 +120,27 @@ def on_draw(self): """ Render the screen. """ - try: - # This command has to happen before we start drawing - self.clear() - - # Draw all the sprites. - self.player_list.draw() - self.wall_list.draw() - self.enemy_list.draw() - - for enemy in self.enemy_list: - if arcade.has_line_of_sight( - self.player.position, enemy.position, self.wall_list - ): - color = arcade.color.RED - else: - color = arcade.color.WHITE - arcade.draw_line(self.player.center_x, - self.player.center_y, - enemy.center_x, - enemy.center_y, - color, - 2) - - except Exception as e: - print(e) + # This command has to happen before we start drawing + self.clear() + + # Draw all the sprites. + self.player_list.draw() + self.wall_list.draw() + self.enemy_list.draw() + + for enemy in self.enemy_list: + if arcade.has_line_of_sight( + self.player.position, enemy.position, self.wall_list + ): + color = arcade.color.RED + else: + color = arcade.color.WHITE + arcade.draw_line(self.player.center_x, + self.player.center_y, + enemy.center_x, + enemy.center_y, + color, + 2) def on_update(self, delta_time): """ Movement and game logic """ diff --git a/arcade/experimental/shadertoy.py b/arcade/experimental/shadertoy.py index a195955516..b304fc473a 100644 --- a/arcade/experimental/shadertoy.py +++ b/arcade/experimental/shadertoy.py @@ -278,6 +278,8 @@ def render( Override the size frame: Override frame + frame_rate: + Override frame_rate """ self._time = time if time is not None else self._time self._time_delta = time_delta if time_delta is not None else self._time_delta diff --git a/arcade/texture/spritesheet.py b/arcade/texture/spritesheet.py index 9e509129e1..33772895d5 100644 --- a/arcade/texture/spritesheet.py +++ b/arcade/texture/spritesheet.py @@ -147,6 +147,9 @@ def get_texture( hit_box_algorithm: Hit box algorithm to use for the texture. If not provided, the default hit box algorithm will be used. + y_up: + Sets the coordinate space of the image to assert (0, 0) + in the bottom left. """ im = self.get_image(rect, y_up) texture = Texture(im, hit_box_algorithm=hit_box_algorithm) diff --git a/arcade/tilemap/tilemap.py b/arcade/tilemap/tilemap.py index e74cf4d101..e81e5efc19 100644 --- a/arcade/tilemap/tilemap.py +++ b/arcade/tilemap/tilemap.py @@ -1062,6 +1062,9 @@ def load_tilemap( Can be used to offset the position of all sprites and objects within the map. This will be applied in addition to any offsets from Tiled. This value can be overridden with the layer_options dict. + texture_atlas: + A default texture atlas to use for the SpriteLists created by this map. + If not supplied the global default atlas will be used. lazy: SpriteLists will be created lazily. """ diff --git a/doc/tutorials/platform_tutorial/step_12.rst b/doc/tutorials/platform_tutorial/step_12.rst index 51491c6a0e..6357a90c14 100644 --- a/doc/tutorials/platform_tutorial/step_12.rst +++ b/doc/tutorials/platform_tutorial/step_12.rst @@ -89,7 +89,7 @@ at this stage is ready for drawing and we don't need to do anything else to it(o ``layer_options`` is a special dictionary that can be provided to the ``load_tilemap`` function. This will send special options for each layer into the map loader. In this example our map has a layer called ``Platforms``, and we want to enable spatial hashing on it. Much like we did for our ``wall`` SpriteList - before. For more info on the layer options dictionary and the available keys, check out :class`arcade.TileMap` + before. For more info on the layer options dictionary and the available keys, check out :class:`arcade.TileMap` At this point we only have one piece of code left to change. In switching to our new map, you may have noticed by the ``layer_options`` dictionary that we now have a layer named ``Platforms``. Previously in our Scene we were calling From 23df10638a1076985dc6ad3ec66fa8ee3ff4cd51 Mon Sep 17 00:00:00 2001 From: elegantiron <128913032+elegantiron@users.noreply.github.com> Date: Tue, 4 Feb 2025 03:58:59 -0500 Subject: [PATCH 024/279] Update section __init__ type hints (#2527) * Update section __init__ type hints * Update left, bottom, width, and height to hint that they accept int or float * fixes for the automated checks --- arcade/sections.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/arcade/sections.py b/arcade/sections.py index d69b2e9ddb..e6dda97f26 100644 --- a/arcade/sections.py +++ b/arcade/sections.py @@ -56,10 +56,10 @@ class Section: def __init__( self, - left: int, - bottom: int, - width: int, - height: int, + left: int | float, + bottom: int | float, + width: int | float, + height: int | float, *, name: str | None = None, accept_keyboard_keys: bool | Iterable = True, @@ -116,19 +116,19 @@ def __init__( # section position into the current viewport # if screen is resized it's upto the user to move or resize each section # (section will receive on_resize event) - self._left: int = left - self._bottom: int = bottom - self._width: int = width - self._height: int = height - self._right: int = left + width - self._top: int = bottom + height + self._left: int | float = left + self._bottom: int | float = bottom + self._width: int | float = width + self._height: int | float = height + self._right: int | float = left + width + self._top: int | float = bottom + height # section event capture dimensions # if section is modal, capture all events on the screen - self._ec_left: int = 0 if self._modal else self._left - self._ec_right: int = self.window.width if self._modal else self._right - self._ec_bottom: int = 0 if self._modal else self._bottom - self._ec_top: int = self.window.height if self._modal else self._top + self._ec_left: int | float = 0 if self._modal else self._left + self._ec_right: int | float = self.window.width if self._modal else self._right + self._ec_bottom: int | float = 0 if self._modal else self._bottom + self._ec_top: int | float = self.window.height if self._modal else self._top self.camera: Projector | None = None """optional section camera""" @@ -180,7 +180,7 @@ def draw_order(self) -> int: return self._draw_order @property - def left(self) -> int: + def left(self) -> int | float: """Left edge of this section""" return self._left @@ -192,7 +192,7 @@ def left(self, value: int): self._ec_right = self.window.width if self._modal else self._right @property - def bottom(self) -> int: + def bottom(self) -> int | float: """The bottom edge of this section""" return self._bottom @@ -204,7 +204,7 @@ def bottom(self, value: int): self._ec_top = self.window.height if self._modal else self._top @property - def width(self) -> int: + def width(self) -> int | float: """The width of this section""" return self._width @@ -215,7 +215,7 @@ def width(self, value: int): self._ec_right = self.window.width if self._modal else self._right @property - def height(self) -> int: + def height(self) -> int | float: """The height of this section""" return self._height @@ -226,7 +226,7 @@ def height(self, value: int): self._ec_top = self.window.height if self._modal else self._top @property - def right(self) -> int: + def right(self) -> int | float: """Right edge of this section""" return self._right @@ -238,7 +238,7 @@ def right(self, value: int): self._ec_left = 0 if self._modal else self._left @property - def top(self) -> int: + def top(self) -> int | float: """Top edge of this section""" return self._top From cf2ea6240634193040a49204179a66a83dbcf31d Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 4 Feb 2025 23:11:56 +0100 Subject: [PATCH 025/279] gui: Fix UIManager not clearing the surface when label triggered a full render during a render cycle (#2552) --- arcade/gui/ui_manager.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index d793a177d0..a55d2013aa 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -254,6 +254,9 @@ def _do_layout(self): def _do_render(self, force=False): layers = sorted(self.children.keys()) force = force or self._requires_render + # already reset here, so it can be set again in case during rendering a + # widget requests a re-rendering like a UILabel updating its font + self._requires_render = False for layer in layers: surface = self._get_surface(layer) @@ -267,8 +270,6 @@ def _do_render(self, force=False): for child in self.children[layer]: child._do_render(surface, force) - self._requires_render = False - def enable(self) -> None: """Registers handler functions (`on_...`) to :py:attr:`arcade.gui.UIElement` From 11d1ebac6ae1dfdd783fcd619cc44ee2748bc197 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 4 Feb 2025 23:55:03 +0100 Subject: [PATCH 026/279] fix UIWIdgets pass events to children in wrong order (#2553) --- arcade/gui/widgets/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index cf39e3b690..b7441fefd8 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -186,7 +186,7 @@ def on_event(self, event: UIEvent) -> Optional[bool]: if self.visible: # pass event to children - for child in self.children: + for child in reversed(self.children): if child.dispatch_event("on_event", event): return EVENT_HANDLED From f4f4521d4cd263e0e5f82e791bc475044bb35c77 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Fri, 7 Feb 2025 17:56:59 +0100 Subject: [PATCH 027/279] Clarify error message when the atlas is full (#2555) * Clarify error message when the atlas is full * Unnecessary f-string * Fix annotation issues in the draw module * Import sorting --- arcade/draw/line.py | 6 +++--- arcade/draw/polygon.py | 4 ++-- arcade/texture_atlas/uv_data.py | 6 +++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/arcade/draw/line.py b/arcade/draw/line.py index 7473a02b7c..a2e232f6ea 100644 --- a/arcade/draw/line.py +++ b/arcade/draw/line.py @@ -1,7 +1,7 @@ import array from arcade import gl -from arcade.types import Color, Point2List, RGBOrA255 +from arcade.types import Color, Point2, Point2List, RGBOrA255 from arcade.window_commands import get_window from .helpers import _generic_draw_line_strip, get_points_for_thick_line @@ -23,8 +23,8 @@ def draw_line_strip(point_list: Point2List, color: RGBOrA255, line_width: float if line_width == 1: _generic_draw_line_strip(point_list, color, gl.LINE_STRIP) else: - triangle_point_list: Point2List = [] - # This needs a lot of improvement + triangle_point_list: list[Point2] = [] + # FIXME: This needs a lot of improvement last_point = None for point in point_list: if last_point is not None: diff --git a/arcade/draw/polygon.py b/arcade/draw/polygon.py index 25921d3604..7ea68aa6fd 100644 --- a/arcade/draw/polygon.py +++ b/arcade/draw/polygon.py @@ -1,6 +1,6 @@ from arcade import gl from arcade.earclip import earclip -from arcade.types import Point2List, RGBOrA255 +from arcade.types import Point2, Point2List, RGBOrA255 from .helpers import _generic_draw_line_strip, get_points_for_thick_line @@ -40,7 +40,7 @@ def draw_polygon_outline(point_list: Point2List, color: RGBOrA255, line_width: f new_point_list.append(point_list[0]) # Create a place to store the triangles we'll use to thicken the line - triangle_point_list = [] + triangle_point_list: list[Point2] = [] # This needs a lot of improvement last_point = None diff --git a/arcade/texture_atlas/uv_data.py b/arcade/texture_atlas/uv_data.py index e8b9fee2ec..1cd439c566 100644 --- a/arcade/texture_atlas/uv_data.py +++ b/arcade/texture_atlas/uv_data.py @@ -124,7 +124,11 @@ def get_existing_or_free_slot(self, name: str) -> int: return slot except IndexError: raise Exception( - ("No more free slots in the UV texture. " f"Max number of slots: {self._num_slots}") + ( + "No more free slots in the UV texture." + f"Max number of textures: {self._num_slots}." + "Consider creating a texture atlas with a larger capacity." + ) ) def free_slot_by_name(self, name: str) -> None: From 5aa322d6c8be6d672af4873efccbeeec2da15ef7 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Tue, 11 Feb 2025 22:10:09 +0100 Subject: [PATCH 028/279] Small UI docs and example cleanup (#2561) --- arcade/examples/gui/0_basic_setup.py | 6 ++---- arcade/gui/view.py | 2 +- doc/programming_guide/gui/concepts.rst | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/arcade/examples/gui/0_basic_setup.py b/arcade/examples/gui/0_basic_setup.py index cf7f43da43..11bf0a64bb 100644 --- a/arcade/examples/gui/0_basic_setup.py +++ b/arcade/examples/gui/0_basic_setup.py @@ -33,7 +33,7 @@ def __init__(self): # Create a UIManager self.ui = UIManager() - # Create a anchor layout, which can be used to position widgets on screen + # Create an anchor layout, which can be used to position widgets on screen anchor = self.ui.add(UIAnchorLayout()) # Add a button switch to the other View. @@ -43,7 +43,6 @@ def __init__(self): texture=TEX_RED_BUTTON_NORMAL, texture_hovered=TEX_RED_BUTTON_HOVER, texture_pressed=TEX_RED_BUTTON_PRESS, - on_click=lambda: self.window.show_view(self.window.views["other"]), ) ) @@ -78,7 +77,7 @@ def __init__(self): super().__init__() self.background_color = arcade.uicolor.BLUE_PETER_RIVER - # Create a anchor layout, which can be used to position widgets on screen + # Create an anchor layout, which can be used to position widgets on screen anchor = self.add_widget(UIAnchorLayout()) # Add a button switch to the other View. @@ -88,7 +87,6 @@ def __init__(self): texture=TEX_RED_BUTTON_NORMAL, texture_hovered=TEX_RED_BUTTON_HOVER, texture_pressed=TEX_RED_BUTTON_PRESS, - on_click=lambda: self.window.show_view(self.window.views["my"]), ) ) diff --git a/arcade/gui/view.py b/arcade/gui/view.py index 38449b86e5..df3cf01648 100644 --- a/arcade/gui/view.py +++ b/arcade/gui/view.py @@ -21,7 +21,7 @@ class UIView(View): The screen is cleared before on_draw_before_ui is called with the background color of the window. - If you override ``on_show_view`` or ``on_show_view``, + If you override ``on_show_view`` or ``on_hide_view``, don't forget to call super().on_show_view() or super().on_hide_view(). """ diff --git a/doc/programming_guide/gui/concepts.rst b/doc/programming_guide/gui/concepts.rst index e72fd09f88..c9817bf914 100644 --- a/doc/programming_guide/gui/concepts.rst +++ b/doc/programming_guide/gui/concepts.rst @@ -35,8 +35,8 @@ And disable it with :py:meth:`~arcade.gui.UIManager.disable()` within :py:meth:` To draw the GUI, call :py:meth:`~arcade.gui.UIManager.draw` within the :py:meth:`~arcade.View.on_draw` method. -The :py:class`~arcade.gui.UIView` class is a subclass of :py:class:`~arcade.View` and provides -a convenient way to use the GUI. It instanciates a :py:class:`~arcade.gui.UIManager` which can be accessed +The :py:class:`~arcade.gui.UIView` class is a subclass of :py:class:`~arcade.View` and provides +a convenient way to use the GUI. It instantiates a :py:class:`~arcade.gui.UIManager` which can be accessed via the :py:attr:`~arcade.gui.UIView.ui` attribute. It automatically enables and disables the :py:class:`~arcade.gui.UIManager` when the view is shown or hidden. From 4b179668ab548d761dde7adb41233348e9eaf5e8 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Wed, 12 Feb 2025 23:05:44 +0100 Subject: [PATCH 029/279] gui:iterate on dropdown style options (#2559) --- arcade/gui/widgets/dropdown.py | 68 ++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index 451026310f..a162f5b66f 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -60,10 +60,50 @@ def on_change(event: UIOnChangeEvent): height: Height of each of the option. default: The default value shown. options: The options displayed when the layout is clicked. + primary_style: The style of the primary button. + dropdown_style: The style of the buttons in the dropdown. + active_style: The style of the dropdown button, which represents the active option. """ DIVIDER = None + DEFAULT_BUTTON_STYLE = { + "normal": UIFlatButton.UIStyle( + font_color=uicolor.GREEN_NEPHRITIS, + ), + "hover": UIFlatButton.UIStyle( + font_color=uicolor.WHITE, + bg=uicolor.DARK_BLUE_WET_ASPHALT, + border=uicolor.GRAY_CONCRETE, + ), + "press": UIFlatButton.UIStyle( + font_color=uicolor.DARK_BLUE_MIDNIGHT_BLUE, + bg=uicolor.WHITE_CLOUDS, + border=uicolor.GRAY_CONCRETE, + ), + "disabled": UIFlatButton.UIStyle( + font_color=uicolor.WHITE_SILVER, + bg=uicolor.GRAY_ASBESTOS, + ), + } + DEFAULT_DROPDOWN_STYLE = { + "normal": UIFlatButton.UIStyle(), + "hover": UIFlatButton.UIStyle( + font_color=uicolor.WHITE, + bg=uicolor.DARK_BLUE_WET_ASPHALT, + border=uicolor.GRAY_CONCRETE, + ), + "press": UIFlatButton.UIStyle( + font_color=uicolor.DARK_BLUE_MIDNIGHT_BLUE, + bg=uicolor.WHITE_CLOUDS, + border=uicolor.GRAY_CONCRETE, + ), + "disabled": UIFlatButton.UIStyle( + font_color=uicolor.WHITE_SILVER, + bg=uicolor.GRAY_ASBESTOS, + ), + } + def __init__( self, *, @@ -73,8 +113,18 @@ def __init__( height: float = 30, default: Optional[str] = None, options: Optional[list[Union[str, None]]] = None, + primary_style=None, + dropdown_style=None, + active_style=None, **kwargs, ): + if primary_style is None: + primary_style = self.DEFAULT_BUTTON_STYLE + if dropdown_style is None: + dropdown_style = self.DEFAULT_DROPDOWN_STYLE + if active_style is None: + active_style = self.DEFAULT_BUTTON_STYLE + # TODO handle if default value not in options or options empty if options is None: options = [] @@ -83,13 +133,14 @@ def __init__( super().__init__(x=x, y=y, width=width, height=height, **kwargs) + self._default_style = deepcopy(primary_style) + self._dropdown_style = deepcopy(dropdown_style) + self._active_style = deepcopy(active_style) + # Setup button showing value - style = deepcopy(UIFlatButton.DEFAULT_STYLE) - style["hover"].font_color = uicolor.GREEN_NEPHRITIS self._default_button = UIFlatButton( - text=self._value or "", width=self.width, height=self.height, style=style + text=self._value or "", width=self.width, height=self.height, style=self._default_style ) - self._default_button.on_click = self._on_button_click # type: ignore self._overlay = _UIDropdownOverlay() @@ -100,8 +151,6 @@ def __init__( self.register_event_type("on_change") - self.with_border(color=arcade.color.RED) - @property def value(self) -> Optional[str]: """Current selected option.""" @@ -122,11 +171,6 @@ def _update_options(self): # generate options self._overlay.clear() - # is there another way then deepcopy, does it matter? - # ("premature optimization is the root of all evil") - active_style = deepcopy(UIFlatButton.DEFAULT_STYLE) - active_style["normal"]["bg"] = uicolor.GREEN_NEPHRITIS - for option in self._options: if option is None: # None = UIDropdown.DIVIDER, required by pyright self._overlay.add( @@ -139,7 +183,7 @@ def _update_options(self): text=option, width=self.width, height=self.height, - style=active_style if self.value == option else UIFlatButton.DEFAULT_STYLE, + style=self._active_style if self.value == option else self._dropdown_style, ) ) button.on_click = self._on_option_click From 2e66730c1305fcd4addfb409230d14654a780a8c Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Wed, 12 Feb 2025 21:28:38 -0500 Subject: [PATCH 030/279] Fix broken links and formatting on the sample games page (#2562) * Fix broken links in community games page * Fix duplicate target names * Crop large blank transparent area from PyOverheadGame screenshot * Put genre and authorship info below heading for most (non-multiple) headings * Show target site in external links (GitHub, itch, etc) * Fix tabs vs spaces * Remove dead link for one game (Kayzee was deleted / set to private by the author) --- doc/community/games/sample_games.rst | 181 +++++++++++------- doc/images/community/games/PyOverheadGame.png | Bin 118793 -> 97325 bytes 2 files changed, 109 insertions(+), 72 deletions(-) diff --git a/doc/community/games/sample_games.rst b/doc/community/games/sample_games.rst index 9c734fe53a..2e066ec238 100644 --- a/doc/community/games/sample_games.rst +++ b/doc/community/games/sample_games.rst @@ -3,11 +3,11 @@ User-submitted Games ==================== -Here are some sample games made with Arcade. -Have a game you'd like to share here? E-mail +The games below are by Arcade by users around the world. +If you've made a game you'd like to share here, e-mail paul@cravenfamily.com. -You also might want to check out sample Arcade games from: +For more games made with Arcade, please see the pages below: * :ref:`2020_game_jam` * :ref:`concept_games` @@ -16,31 +16,35 @@ You also might want to check out sample Arcade games from: Brazier ~~~~~~~ +A top-down timed survival trial game by DragonMoffon. + .. image:: /images/community/games/brazier.png :width: 400px -- [Itch page](https://dragonmoffon.itch.io/brazier) -- [GitHub page](https://github.com/DragonMoffon/MiniJam160-Light) +- `Brazier on itch.io `_ +- `GitHub repo for Brazier `_ PhotoShip ~~~~~~~~~ +A top-down space space survival game by clodon2. + .. image:: /images/community/games/photoship.png :width: 400px -- [Itch page](https://clodon.itch.io/photoship) -- [GitHub page](https://github.com/clodon2/PhotoShip) +- `PhotoShip on itch.io `_ +- `GitHub repo for PhotoShip `_ Space Station Builder ~~~~~~~~~~~~~~~~~~~~~ -Build your own space station! +Build your own space station! By aliskda. .. image:: /images/community/games/space_station_builder.gif :width: 500px -Download at [Kosmolonia on itch.io](https://aliskda.itch.io/kosmolonia). +`Kosmolonia on itch.io `_ Notepad Doodles ~~~~~~~~~~~~~~~ @@ -50,85 +54,98 @@ Survive waves of monsters! .. image:: /images/community/games/notepad_doodles.png :width: 400px -Download at [Notepad Doodles on itch.io](https://arkturdev.itch.io/notepad-doodles). +`Notepad Doodles on itch.io `_ BoxHead Survivor ~~~~~~~~~~~~~~~~ +A top-down 2D shooter game by Unchained112. + .. image:: /images/community/games/boxhead_survivor.jpg :width: 560px -A top-down 2D shooter game. - -* Playable builds at `itch.io `_ -* Source on `GitHub at Unchained112/BoxHead2D `_ +* `BoxHead Survivor on itch.io `_ +* `GitHub repo for BoxHead Survivor `_ Temporum ~~~~~~~~ +A 2D turn-based tactics game by DragonMoffon. + .. raw:: html - -`Temporum `_, by DragonMoffon +`GitHub repo for Temporum `_ SOL Defender ~~~~~~~~~~~~ +A top-down space shooter game by DragonMoffon. + .. raw:: html -SOL Defender, by DragonMoffon Binary Defense ~~~~~~~~~~~~~~ +A top-down tower defense game by KommentatorForAll. + .. raw:: html -`Binary Defense `_ by KommentatorForAll + +`GitHub repo for Binary Defense `_ Space Invaders ~~~~~~~~~~~~~~ +A re-implementation of the classic `Space Invaders (Wikipedia) `_ +by Paul Craven. + .. image:: /images/community/games/space_invaders.png :width: 560px -`Space Invaders `_ +`GitHub repo for Space Invaders `_ Ready or Not? ~~~~~~~~~~~~~ +A local multiplayer action RPG by Akash 5 Panickar. + .. raw:: html -`Ready or Not? `_ a local multiplayer action -RPG by Akash S Panickar. +`GitHub repo for Ready or Not? `_ + Age of Divisiveness ~~~~~~~~~~~~~~~~~~~ +An extensive turn-based empire building game with strong influences +from Civilization I and Settlers. Created by Patryk Majewski, Krzysztof +Szymaniak, Gabriel Wechta, and Błażej Wróbel. + .. image:: /images/community/games/age_of_divisiveness.gif :width: 75% -`Age of Divisiveness `_ by -Patryk Majewski, Krzysztof Szymaniak, Gabriel Wechta, Błażej Wróbel - -Multiplayer LAN game with strong Civilization I and old Settlers vibe! -Very extensive. +`GitHub repo for Age of Divisiveness `_ Fishy-Game ~~~~~~~~~~ +Survive as a fish by eating smaller fish and avoiding bigger ones. By LiorAvrahami. + .. image:: /images/community/games/fishy-game.png :width: 75% -`Fishy Game `_ by LiorAvrahami + +`GitHub repo for Fishy-Game `_ Adventure ~~~~~~~~~ @@ -137,147 +154,167 @@ Adventure -`Adventure GitHub `_ +`GitHub repo for Adventure `_ Transcience Animation ~~~~~~~~~~~~~~~~~~~~~ +An side-view animation created with Arcade by SunTzunami. + .. image:: /images/community/games/transcience.gif :width: 75% -`Transcience Animation `_ +`GitHub repo for Transcience Animation `_ Stellar Arena Demo ~~~~~~~~~~~~~~~~~~ +A top-down arena shooter using the mouse and keyboard. + .. raw:: html -`Stellar Arena Demo `_ +`GitHub repo for Stellar Arena Demo `_ Battle Bros ~~~~~~~~~~~ +A 2D fighting game in the style of Mortal Kombat. + .. image:: /images/community/games/battlebros.gif :width: 50% -`Battle Bros `_ Mortal Kombat style game. +`GitHub repo for Battle Bros `_ Rabbit Herder ~~~~~~~~~~~~~ +Use carrots and potions to herd a rabbit through a maze. + .. image:: /images/community/games/rabbit_herder.gif :width: 50% -`Rabbit Herder `_, -use carrots and potions to herd a rabbit through a maze. +`GitHub repo for Rabbit Herder `_ The Great Skeleton War ~~~~~~~~~~~~~~~~~~~~~~ +An intense tower defense game, where there's always something new to discover. + .. raw:: html - + -`The Great Skeleton War`_, an intense tower defense game, where there's always something new to discover. +`GitHub repo for The Great Skeleton War`_ -.. _The Great Skeleton War: https://github.com/BlakeDalmas/Python/tree/master/The%20Great%20Skeleton%20War +.. _GitHub repo for The Great Skeleton War: https://github.com/BlakeDalmas/Python/tree/master/The%20Great%20Skeleton%20War Python Knife Hit ~~~~~~~~~~~~~~~~ +A knife throwing game by akmalhakimi1911. + .. figure:: /images/community/games/python_knife_hit.png - :width: 50% + :width: 50% -https://github.com/akmalhakimi1991/python-knife-hit +`GitHub repo for Python Knife Hit `_ Kayzee ~~~~~~ +A side-view 2D game by wamiqurrehman093. + .. figure:: /images/community/games/kayzee.png - :width: 50% + :width: 50% - `Kayzee Game `_ lixingqiu Games ~~~~~~~~~~~~~~~ .. figure:: /images/community/games/eight_planet.gif - :width: 50% + :width: 50% - An `Eight planet simulation `_ + An `Eight planet simulation `_ .. figure:: /images/community/games/midway.png - :width: 50% + :width: 50% - `Midway Island War `_ + `Midway Island War `_ .. figure:: /images/community/games/angry_bird.gif - :width: 50% + :width: 50% - `Angry Bird `_ + `Angry Bird `_ .. figure:: /images/community/games/octopus.gif - :width: 50% + :width: 50% - `Octopus `_ + `Octopus `_ Space Typer ~~~~~~~~~~~ +A space-themed typing game by thecodeah. + .. image:: /images/community/games/space_typer.png :width: 75% -`Space Typer`_ - A typing game +`GitHub repo for Space Typer`_ -.. _Space Typer: https://github.com/thecodeah/space-typer +.. _GitHub repo for Space Typer: https://github.com/thecodeah/space-typer FlapPy Bird ~~~~~~~~~~~ +A Flappy Bird clone writtein in Arcade by iJohnMaged. + .. image:: /images/community/games/flappy.png :width: 25% -`FlapPy-Bird`_ - A bird-game clone. - +`GitHub repo for FlapPy-Bird`_ - -.. _FlapPy-Bird: https://github.com/iJohnMaged/FlapPy-Bird +.. _GitHub repo for FlapPy-Bird: https://github.com/iJohnMaged/FlapPy-Bird PyOverheadGame ~~~~~~~~~~~~~~ +A top-down exploration gamze with multiple rooms and pick-ups to collect. + .. image:: /images/community/games/PyOverheadGame.png :width: 75% -PyOverheadGame_, a 2D overhead game where you go through several rooms and pick up keys and other objects. +`GitHub repo for PyOverheadGame`_ -.. _PyOverheadGame: https://github.com/albertz/PyOverheadGame +.. _GitHub repo for PyOverheadGame: https://github.com/albertz/PyOverheadGame Dungeon ~~~~~~~ +Explore a maze, collecting arrows and coins. By BlakeDalmas. + .. image:: /images/community/games/blake.png :width: 75% -Dungeon_, explore a maze picking up arrows and coins. +`GitHub repo for Dungeon`_ -.. _Dungeon: https://github.com/BlakeDalmas/Python/tree/master/Dungeon%20Game +.. _GitHub repo for Dungeon: https://github.com/BlakeDalmas/Python/tree/master/Dungeon%20Game Two Worlds ~~~~~~~~~~ +A castle adventure through a dungeon and caverns by Paul Craven. + .. image:: /images/community/games/two_worlds.png :width: 75% -`Two Worlds`_, a castle adventure with a dungeon and caverns underneath it. +`GitHub repo for Two Worlds`_ -.. _Two Worlds: https://github.com/pvcraven/two_worlds +.. _GitHub repo for Two Worlds: https://github.com/pvcraven/two_worlds Simpson College Spring 2017 CMSC 150 Course ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -286,49 +323,49 @@ These games were created by first-semester programming students. .. raw:: html - + .. raw:: html - + .. raw:: html - + .. raw:: html - + .. raw:: html - + .. raw:: html - + .. raw:: html - + .. raw:: html - + .. raw:: html - \ + \ .. raw:: html - + .. raw:: html - + .. raw:: html - + diff --git a/doc/images/community/games/PyOverheadGame.png b/doc/images/community/games/PyOverheadGame.png index dc33a821cc6e61378f775434f5127e886437bca2..349fa22e185941669eb490c4ec5db3e369c0c990 100644 GIT binary patch literal 97325 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

    {C?GDVkeoo1WV$#y_ZCWDw|)%%!# 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 j z(@T{?7WVb@&|Uu<1sAe};)<(l+J(=0L7-ZPAiw$E>!ztd~LtKz>5cvxCm+R&27OZ7q{`NAkGjWgjM z7<1LouT00%G#Hg$0QLZm}0S%VSoS&YX!_z`=;V zXqOB`fD8h@wKxHMPVx z!tHZpy84AH4rekG3gl6*BYX2*7P=o#9g0=KMf2skIiWvkM%Gm~;WLcChKI2Q#`zx# z3+=&1KSZLlssQWdjQAKfxRo3-yJL4<$>#uzE8+hdEh~dUGG0Nz9!o)0z5pz z8Q6)Sw&kqozbDRlZznK^!83M6u&#d=utgbJ|qeBJ}*yn#9c8IgZVl-(z@F>IM|k>o#f=<(K_LI z>iE&YGJ9>WU|hJvjoT6r#`t=LAIUwfU# zCv+q4Vmv9p7RyPV>i%(dJPVUWn0@4_JP{+`iK~d3E-;+J==mM@Jzo5Xmy?SRS&~>p)}nIPo-yjQe3~kNX7$ajO16jw;7V=6(ZjCL^-jSSj0nZ zeSp}U=h)ob1o>IfM_8pBh&_0NAvQx-a2?VC67V_I zUQ=3Lc#zDqv0cD^=0U=$efGANrhsGFO$AE?8XE21x!r|JR-%W^8=ut|6pkMLBCMPK l=Z~rX>gD;{BDBMGaFY9scw*0k0(KhkrK4$}QLJto{vX(1a=8Ei literal 118793 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 Date: Thu, 13 Feb 2025 04:26:24 -0500 Subject: [PATCH 031/279] Change version to 3.0.0 (#2564) Co-authored-by: Paul Craven --- arcade/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/VERSION b/arcade/VERSION index bb31ba2ee7..56fea8a08d 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.0.0-rc.3 \ No newline at end of file +3.0.0 \ No newline at end of file From 4d66523929be4d5bccf0b3e7ba4fd1581b41c424 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 13 Feb 2025 12:06:24 +0100 Subject: [PATCH 032/279] add changelog for 3.0.1 (#2563) --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ff0d475d..56caa3ff3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. + +## Version 3.0.1 (unreleased) + +### Bug Fixes + +- Fixed blurriness in `UIWidget` text during interaction +- Resolved issue with `UIDropdown.hide()` when no parent exists +- Corrected event order bug in `UIWidget` + +### Enhancements + +- Added style support for `UIDropbox` buttons + ## Version 3.0.0 Version 3.0.0 is a major update to Arcade. It breaks compatibility with the 2.6 API. From a8ced82b1f8a08ead2c3f08d5cb8a23f45e2c707 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Fri, 14 Feb 2025 02:44:46 -0500 Subject: [PATCH 033/279] Fix typos and change phrasing for academia (#2565) * Style fix: s/Python Arcade/Arcade/ in body text * Typo, phrasing, and style fixes on the For Educators & Researchers page * Fix typo * Improve phrasing * Cross-ref with PyPI link --- doc/about/for_academia.rst | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/doc/about/for_academia.rst b/doc/about/for_academia.rst index 1eb5811a2d..30966de79e 100644 --- a/doc/about/for_academia.rst +++ b/doc/about/for_academia.rst @@ -5,7 +5,7 @@ For Educators & Researchers .. _citation template: https://github.com/pythonarcade/arcade#citation -Python Arcade was created by Paul V. Craven while teaching at Simpson College. +Arcade was created by Paul V. Craven while teaching at Simpson College. In addition to the main Arcade 3.0 documentation you are currently reading, there are further resources to help academic users. These include: @@ -32,37 +32,39 @@ To learn more about using this template, please consult the following: Version Considerations ---------------------- -Most users will be better off using Arade 3.0. +Most users will be best served by `Arcade's latest release from PyPI `_ -The main case for continuing to use ``2.6.X`` releases is reliance on teaching -materials which have not yet been updated, including the :ref:`academia_arcade_book`. +For new games, the features and improved efficiency of Arcade 3 make it the +best choice. Upgrading existing games is also worthwhile. +The main case for using ``2.6.X`` releases is when you must use teaching +materials which lack an updated version for Arcade 3.0+. This includes the +companion :ref:`academia_arcade_book` covered in depth below. -.. _academia_arcade_book: -Arcade Book -^^^^^^^^^^^ +.. _academia_arcade_book: -The creator of Arcade wrote an `Arcade book`_ which covers Python basics in greater depth -than the main Arcade documentation. +Arcade Textbook +^^^^^^^^^^^^^^^ +The creator of Arcade wrote an `Arcade Textbook `_ which covers Python basics +n greater depth than the main Arcade documentation. -It may be some time until the `Arcade book`_ is updated for Arcade 3.0. Doing so requires a -separate effort after the 3.0 release due to the the scale and number of changes since -Arcade 2.6. +It may be a while before the `Arcade Textbook `_ is updated for Arcade 3.0. This +is a large undertaking due to the number and scale of changes since Arcade 2.6. -Similarities to this Documentation -"""""""""""""""""""""""""""""""""" +Similarities +"""""""""""" -Both the book and the documentation you are currently reading provide: +Both the textbook and the documentation you are currently reading provide: * all-ages learning resources * gentle introductions to Python and Arcade -Differences from this Documentation -""""""""""""""""""""""""""""""""""" +Differences +""""""""""" The book caters more heavily to beginners and educators by providing the following in a traditional chapter and curriculum structure: @@ -141,7 +143,6 @@ SBCs based on RISC-V CPUs are likely to lack: * beginner-friendly documentation - Credit Card Rule """""""""""""""" From 297bce3859b67422772ec135f3c64795b4e38299 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Sat, 15 Feb 2025 05:19:31 -0500 Subject: [PATCH 034/279] Fix Kenney.nl links in permissive licensing page (#2567) * Fix Kenney.nl links in permissive licensing page * Use Kenney.nl instead of Kenney_nl for ref name * Fix typo (s/Kenny/Kenney/ on resources page) * Update the permissive licensing page and resource listing page for consistency * Use links.rst CC0 ref declaration in resource listing * s/by Kenney.nl/from Kenney.nl/ permissive licensing page --- doc/_includes/links.rst | 3 ++- doc/about/permissive_licensing.rst | 4 ++-- util/create_resources_listing.py | 5 ++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/_includes/links.rst b/doc/_includes/links.rst index 85e9d2863f..ff7174513c 100644 --- a/doc/_includes/links.rst +++ b/doc/_includes/links.rst @@ -24,7 +24,8 @@ .. Arcade-related community engagement stuff .. _PyWeek: https://pyweek.org/ .. _The Python Discord: https://www.pythondiscord.com/ -.. _Kenney_nl: https://kenney.nl/ +.. Sphinx does not like the _ if we write Kenney_nl. +.. _Kenney.nl: https://kenney.nl/ .. Concepts .. _CC0: https://creativecommons.org/publicdomain/#publicdomain-cc0-10 diff --git a/doc/about/permissive_licensing.rst b/doc/about/permissive_licensing.rst index 7291115f19..83a0455029 100644 --- a/doc/about/permissive_licensing.rst +++ b/doc/about/permissive_licensing.rst @@ -91,7 +91,7 @@ If something requires special handling, we'll warn you about it. Where are all these assets from? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Mostly from `Kenney.nl `_. Kenny is famous for creating a repository of free, high-quality +Mostly from `Kenney.nl`_. Kenney is famous for creating a repository of free, high-quality `CC0`_ (public domain) game assets. His work is funded by donations and `Kenney's Patreon `_. @@ -102,7 +102,7 @@ It's the lawyer version saying the following:

    "I give permission to everyone to use this for whatever. Go make something cool!"
    -Although Arcade includes a few bundled assets which aren't from `Kenny.nl `_, we've made sure +Although Arcade includes a few bundled assets which aren't from `Kenney.nl`_, we've made sure they're released under a similar license. diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index e1f21a936a..ee2c1e7fb5 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -802,9 +802,8 @@ def resources(): out.write("That's a good question and one you should always ask when searching for assets online.\n" "To help users get started quickly, the Arcade team makes sure to only bundle assets which\n" # pending: post-3.0 cleanup # Why does it refuse to accept external links definitions? Who knows? - "are specifically released under `CC0 `_" - " or similar terms.\n") - out.write("Most are from `Kenney.nl `_.\n") # pending: post-3.0 cleanup. + "are specifically released under `CC0`_ or similar terms.\n") + out.write("Most are from `Kenney.nl`_.\n") # pending: post-3.0 cleanup. logo = html.escape("'logo.png'") do_heading(out, 1, "How do I use these?") out.write( From 9c986e2dec16edc6a3e43b48d9ddb84f1ae1b748 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Sat, 15 Feb 2025 05:54:50 -0500 Subject: [PATCH 035/279] Fix version display in title bar (#2568) * Move version parsing / prettying into a nice_version function * Use it to set a NICE_VERSION variable * Set RELEASE and VERSION both to NICE_VERSION * Update logging to show both NICE_VERSION and raw VERSION in output --- doc/conf.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 963b8cd597..f6df785287 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -70,15 +70,27 @@ # from arcade.version import VERSION # or read the docs build will fail. from version import VERSION # pyright: ignore [reportMissingImports] -log.info(f" Got version {VERSION!r}") +log.info(f" Got raw version {VERSION=!r}") + + +def nice_version(version_string: str) -> str: + """Format raw VERSION by removing leading zeroes. + + When importing VERSION, Python defaults to formatting it as + 3.00 as of February 2025. + """ + out = [] + for part in version_string.split('.'): + try: + out.append(str(int(part))) + except ValueError as _: + out.append(part) + return '.'.join(out) + + +NICE_VERSION = nice_version(VERSION) +log.info(f" Got nice version {NICE_VERSION=!r}") -# Check whether the version ends in an all-digit string -ARCADE_VERSION_PARTS = [] -for part in VERSION.split('.'): - if part.isdigit(): - ARCADE_VERSION_PARTS.append(part) - else: - ARCADE_VERSION_PARTS.append(part) print() GIT_REF = 'development' @@ -233,9 +245,9 @@ def run_util(filename, run_name="__main__", init_globals=None): # built documents. # # The short X.Y version. -version = VERSION +version = NICE_VERSION # The full version, including alpha/beta/rc tags. -release = RELEASE +release = NICE_VERSION # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. From dd2b4178e62d169e942855981948eff90229526f Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Sun, 16 Feb 2025 23:50:33 -0500 Subject: [PATCH 036/279] Fix arcade.version.VERSION conversion from GitHub CI format (#2569) * Fix converting from GH Action CI to Py version * Use explicitly clear regex approach to read version string * Add tests for specified behavior to our CI * Document the process clearly with a link to GH action config file * Increase exception specificity and test detail * Prevent dev panic by including failure reason in the error logs * Add test cases to cover more bad values (hex + bad dev_preview numbers) * Remove per-field check for now * Fix missing name + formatting * Placate Henry Ford's ghost (black formatter) * Proofreading and style fixes * Fix black formatter putting two f-strings onto a line w/o merging them * Fix typos * Add a Final[str] annotation to the VERSION constant * Cut down and rearrange the top-level docstring for clarity * Extended mode regex formatting to make it pretty * Update comment phrasing to be extra clear * Add special-casing to avoid churn on page titles in 3.0 / latest --- arcade/version.py | 170 ++++++++++++++++++++++++++++++------- doc/conf.py | 18 +++- tests/unit/test_version.py | 79 +++++++++++++++++ 3 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 tests/unit/test_version.py diff --git a/arcade/version.py b/arcade/version.py index 9f52ba93d0..6b0d79d7b8 100644 --- a/arcade/version.py +++ b/arcade/version.py @@ -1,48 +1,160 @@ """ -Version +Loads the Arcade version into a Python-readable ``VERSION`` string. -We are using a github action to bump the VERSION file versions. +Everyday Arcade users may prefer accessing the ``VERSION`` string +from Arcade's top-level alias: -2.7.3-dev.5 -will go to: -2.7.3-dev.6 +.. code-block:: python -Problem is, python doesn't like that last period: -2.7.3-dev.5 -should be -2.7.3.dev5 -...and our github action doesn't like that pattern. -So this will delete that last period and flip around the dash. + import sys + import arcade + + if arcade.version < "3.0.0": + # Using file=sys.stderr prints to the error stream (usually prints red) + print("This game requires Arcade 3.0.0+ to run!", file=sys.stderr) + + +Arcade contributors will benefit from understanding how and why +this file loads and converts the contents of the ``VERSION`` file. + +After a release build succeeds, GitHub's CI is configured to do +the following: + +#. Push the package files to PyPI +#. Call the ``remorses/bump-version@js`` action to auto-increment + Arcade's version on the development branch + +This is where an edge case arises: + +#. Our CI expects ``3.1.0-dev.1`` for dev preview builds +#. Python expects ``3.1.0.dev1`` for dev preview builds + +The ``VERSION`` file in this file's directory stores the version +in the form the GH Action prefers. This allows it to auto-increment +the version number on the ``development`` branch after we make an +Arcade release to PyPI. + +The auto-bump action is configured by the following file: +https://github.com/pythonarcade/arcade/blob/development/.github/workflows/bump_version.yml + +As an example, the GH action would auto-increment a dev preview's +version after releasing the 5th dev preview of ``3.1.0`` by updating +the ``VERSION`` file from this: + +.. code-block:: + + 3.1.0-dev.5 + +...to this: + +.. code-block:: + + 3.1.0-dev.6 -ALSO note that this bumps the version AFTER the deploy. -So if we are at version 2.7.3.dev5 that's the version deploy. Bump will bump it to dev6. """ from __future__ import annotations -import os +import re +import sys +from pathlib import Path +from typing import Final + +_HERE = Path(__file__).parent + +# Grab version numbers + optional dev point preview +# Assumes $MAJOR.$MINOR.$POINT format with optional -dev$DEV_PREVIEW +# Q: Why did you use regex?! +# A: If the dev_preview field is invalid, the whole match fails instantly +_VERSION_REGEX = re.compile( + r""" + # First three version number fields + (?P[0-9]+) + \.(?P[0-9]+) + \.(?P[0-9]+) + # Optional dev preview suffix + (?: + -dev # Dev prefix as a literal + \. # Point + (?P[0-9]+) # Dev preview number + )? + """, + re.X, +) + + +def _parse_python_friendly_version(version_for_github_actions: str) -> str: + """Convert a GitHub CI version string to a Python-friendly one. + + For example, ``3.1.0-dev.1`` would become ``3.1.0.dev1``. + Args: + version_for_github_actions: + A raw GitHub CI version string, as read from a file. + Returns: + A Python-friendly version string. + """ + # Quick preflight check: we don't support tuple format here! + if not isinstance(version_for_github_actions, str): + raise TypeError( + f"Expected a string of the format MAJOR.MINOR.POINT" + f"or MAJOR.MINOR.POINT-dev.DEV_PREVIEW," + f"not {version_for_github_actions!r}" + ) -def _rreplace(s, old, new, occurrence): - li = s.rsplit(old, occurrence) - return new.join(li) + # Attempt to extract our raw data + match = _VERSION_REGEX.fullmatch(version_for_github_actions.strip()) + if match is None: + raise ValueError( + f"String does not appear to be a version number: {version_for_github_actions!r}" + ) + # Build final output, including a dev preview version if present + group_dict = match.groupdict() + major, minor, point, dev_preview = group_dict.values() + parts = [major, minor, point] + if dev_preview is not None: + parts.append(f"dev{dev_preview}") + joined = ".".join(parts) -def _get_version(): - dirname = os.path.dirname(__file__) or "." - my_path = f"{dirname}/VERSION" + return joined + +def _parse_py_version_from_github_ci_file( + version_path: str | Path = _HERE / "VERSION", write_errors_to=sys.stderr +) -> str: + """Parse a Python-friendly version from a ``bump-version``-compatible file. + + On failure, it will: + + #. Print an error to stderr + #. Return "0.0.0" + + Args: + version_path: + The VERSION file's path, defaulting to the same directory as + this file. + write_errors_to: + Makes CI simpler by allowing a stream mock to be passed easily. + Returns: + Either a converted version or "0.0.0" on failure. + """ + data = "0.0.0" try: - text_file = open(my_path, "r") - data = text_file.read().strip() - text_file.close() - data = _rreplace(data, ".", "", 1) - data = _rreplace(data, "-", ".", 1) - except Exception: - print(f"ERROR: Unable to load version number via '{my_path}'.") - data = "0.0.0" + raw = Path(version_path).resolve().read_text().strip() + data = _parse_python_friendly_version(raw) + except Exception as e: + print( + f"ERROR: Unable to load version number via '{str(version_path)}': {e}", + file=write_errors_to, + ) return data -VERSION = _get_version() +VERSION: Final[str] = _parse_py_version_from_github_ci_file() +"""A Python-friendly version string. + +This value is converted from the GitHub-style ``VERSION`` file at the +top-level of the arcade module. +""" diff --git a/doc/conf.py b/doc/conf.py index f6df785287..799a5bb7aa 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -88,7 +88,23 @@ def nice_version(version_string: str) -> str: return '.'.join(out) -NICE_VERSION = nice_version(VERSION) +# pending: 3.0.1 or 3.1 release? +# Maintain title bar continuity for live doc showing 3.0 as the version +VERSION_SPECIAL_CASES = {'3.0.0': '3.0'} + + +def _specialcase_version(nice: str) -> str: + if nice in VERSION_SPECIAL_CASES: + new = VERSION_SPECIAL_CASES[nice] + log.info(f" Special-casing version {nice!r} to {new!r}") + else: + new = nice + return new + + +NICE_VERSION = _specialcase_version(nice_version(VERSION)) +# pending: end + log.info(f" Got nice version {NICE_VERSION=!r}") diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 0000000000..932e2723b7 --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,79 @@ +import sys +import tempfile +from unittest import mock + +import pytest +from arcade.version import ( + _parse_python_friendly_version, + _parse_py_version_from_github_ci_file +) + + +@pytest.mark.parametrize("value, expected", [ + ("3.0.0-dev.1", "3.0.0.dev1"), + ("3.0.0", "3.0.0"), + # Edge cases + ("11.22.333-dev.4444", "11.22.333.dev4444"), + ("11.22.333", "11.22.333"), +]) +class TestParsingWellFormedData: + def test_parse_python_friendly_version( + self, value, expected + ): + assert _parse_python_friendly_version(value) == expected + + def test_parse_py_version_from_github_ci_file( + self, value, expected + ): + + with tempfile.NamedTemporaryFile("w", delete=False) as f: + f.write(value) + f.close() + + assert _parse_py_version_from_github_ci_file( + f.name + ) == expected + + +@pytest.mark.parametrize( + "bad_value", ( + '', + "This string is not a version number at all!" + # Malformed version numbers + "3", + "3.", + "3.1", + "3.1.", + "3.1.2.", + "3.1.0.dev", + "3.1.0-dev." + # Hex is not valid in version numbers + "A", + "3.A.", + "3.1.A", + "3.1.0.A", + "3.1.0-dev.A" + ) +) +def test_parse_python_friendly_version_raises_value_errors(bad_value): + with pytest.raises(ValueError): + _parse_python_friendly_version(bad_value) + + +@pytest.mark.parametrize('bad_type', ( + None, + 0xBAD, + 0.1234, + (3, 1, 0), + ('3', '1' '0') +)) +def test_parse_python_friendly_version_raises_typeerror_on_bad_values(bad_type): + with pytest.raises(TypeError): + _parse_python_friendly_version(bad_type) # type: ignore # Type mistmatch is the point + + +def test_parse_py_version_from_github_ci_file_returns_zeroes_on_errors(): + fake_stderr = mock.MagicMock(sys.stderr) + assert _parse_py_version_from_github_ci_file( + "FILEDOESNOTEXIST", write_errors_to=fake_stderr + ) == "0.0.0" From ea6e01f4c778ff0b545edbc438e615efae579361 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Mon, 17 Feb 2025 09:27:45 -0600 Subject: [PATCH 037/279] Bump version (#2573) --- arcade/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/VERSION b/arcade/VERSION index 56fea8a08d..13d683ccbf 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.0.0 \ No newline at end of file +3.0.1 \ No newline at end of file From fb309c9ebd097a2d844489e3b98fa948b4b9a777 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Mon, 17 Feb 2025 20:34:40 +0100 Subject: [PATCH 038/279] Fix GUI cam on_resize examples (#2572) * Remove unused GUI camera in sprite_move_scrolling_shake.py * Fix camera GUI on_resize in sprite_move_scrolling.py --- arcade/examples/sprite_move_scrolling.py | 3 +-- arcade/examples/sprite_move_scrolling_shake.py | 7 ++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/arcade/examples/sprite_move_scrolling.py b/arcade/examples/sprite_move_scrolling.py index 121e304e34..ecdf753bb1 100644 --- a/arcade/examples/sprite_move_scrolling.py +++ b/arcade/examples/sprite_move_scrolling.py @@ -182,13 +182,12 @@ def on_resize(self, width: int, height: int): """ super().on_resize(width, height) self.camera_sprites.match_window() - self.camera_gui.match_window() def main(): """ Main function """ # Create a window class. This is what actually shows up on screen - window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) + window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, resizable=True) # Create and setup the GameView game = GameView() diff --git a/arcade/examples/sprite_move_scrolling_shake.py b/arcade/examples/sprite_move_scrolling_shake.py index b4e0b65e6b..e0f3f58cc8 100644 --- a/arcade/examples/sprite_move_scrolling_shake.py +++ b/arcade/examples/sprite_move_scrolling_shake.py @@ -52,10 +52,8 @@ def __init__(self): # Physics engine so we don't run into walls. self.physics_engine = None - # Create the cameras. One for the GUI, one for the sprites. - # We scroll the 'sprite world' but not the GUI. + # Create camera that will follow the player sprite. self.camera_sprites = arcade.camera.Camera2D() - self.camera_gui = arcade.camera.Camera2D() self.camera_shake = arcade.camera.grips.ScreenShake2D( self.camera_sprites.view_data, @@ -197,13 +195,12 @@ def on_resize(self, width: int, height: int): """ super().on_resize(width, height) self.camera_sprites.match_window() - self.camera_gui.match_window(position=True) def main(): """ Main function """ # Create a window class. This is what actually shows up on screen - window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) + window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, resizable=True) # Create and setup the GameView game = GameView() From f3ad4a6d3b2d4ea31cbd3b402b902c3d35bafa86 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Mon, 17 Feb 2025 20:35:31 +0100 Subject: [PATCH 039/279] Fix name of platformer engine classes in docs (#2571) --- arcade/physics_engines.py | 2 +- doc/_archive/get_started.rst | 4 ++-- doc/get_started/arcade_book.rst | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index 9b7aa5c4ff..bedce69dd4 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -299,7 +299,7 @@ class PhysicsEngineSimple: * You don't need anything else For side-scrolling games focused on jumping puzzles, you may want - the :py:class:`PlatformerPhysicsEngine` instead. Experienced users + the :py:class:`PhysicsEnginePlatformer` instead. Experienced users may want to try the :py:class:`~arcade.pymunk_physics_engine.PymunkPhysicsEngine`. diff --git a/doc/_archive/get_started.rst b/doc/_archive/get_started.rst index 7cb06d27c9..ef2f1a61bf 100644 --- a/doc/_archive/get_started.rst +++ b/doc/_archive/get_started.rst @@ -105,10 +105,10 @@ Arcade Skill Tree * Physics Engines - * SimplePhysicsEngine - Platformer tutorial :ref:`platformer_part_three`, + * PhysicsEngineSimple - Platformer tutorial :ref:`platformer_part_three`, Learn Arcade Book `Simple Physics Engine `_, Example :ref:`sprite_move_walls` - * PlatformerPhysicsEngine - From the platformer tutorial: :ref:`platformer_part_four`, + * PhysicsEnginePlatformer - From the platformer tutorial: :ref:`platformer_part_four`, * :ref:`sprite_moving_platforms` * Ladders - Platformer tutorial :ref:`platformer_part_ten` diff --git a/doc/get_started/arcade_book.rst b/doc/get_started/arcade_book.rst index 48ebb28d48..30163083cb 100644 --- a/doc/get_started/arcade_book.rst +++ b/doc/get_started/arcade_book.rst @@ -106,10 +106,10 @@ Arcade Skill Tree * Physics Engines - * SimplePhysicsEngine - Platformer tutorial :ref:`platformer_part_three`, + * PhysicsEngineSimple - Platformer tutorial :ref:`platformer_part_three`, Learn Arcade Book `Simple Physics Engine `_, Example :ref:`sprite_move_walls` - * PlatformerPhysicsEngine - From the platformer tutorial: :ref:`platformer_part_four`, + * PhysicsEnginePlatformer - From the platformer tutorial: :ref:`platformer_part_four`, * :ref:`sprite_moving_platforms` * Ladders - Platformer tutorial :ref:`platformer_part_ten` From 4d09ac60af459671acddbe1663805894d16edef8 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Mon, 17 Feb 2025 20:36:07 +0100 Subject: [PATCH 040/279] Make on_key_release args consistent with on_key_press (#2570) --- arcade/application.py | 6 +++--- arcade/sections.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index b728120124..bdfad6c1d8 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -1478,7 +1478,7 @@ def on_key_press(self, symbol: int, modifiers: int) -> bool | None: """ return False - def on_key_release(self, _symbol: int, _modifiers: int) -> bool | None: + def on_key_release(self, symbol: int, modifiers: int) -> bool | None: """ Called once when a key gets released. @@ -1493,9 +1493,9 @@ def on_key_release(self, _symbol: int, _modifiers: int) -> bool | None: * Showing which keys are currently pressed down Args: - _symbol: + symbol: Key that was released - _modifiers: + modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) active during this event. See :ref:`keyboard_modifiers`. """ diff --git a/arcade/sections.py b/arcade/sections.py index e6dda97f26..0e1f66ae17 100644 --- a/arcade/sections.py +++ b/arcade/sections.py @@ -420,13 +420,13 @@ def on_key_press(self, symbol: int, modifiers: int): """ pass - def on_key_release(self, _symbol: int, _modifiers: int): + def on_key_release(self, symbol: int, modifiers: int): """ Called when the user releases a key. Args: - _symbol: the key released - _modifiers: the modifiers pressed + symbol: the key released + modifiers: the modifiers pressed """ pass From 33c4e2d8f2fe0b71682449240769934cf36fe67d Mon Sep 17 00:00:00 2001 From: Alex Varas <346508+alej0varas@users.noreply.github.com> Date: Tue, 18 Feb 2025 13:06:08 +0100 Subject: [PATCH 041/279] Fix typo in schedule_once (#2574) --- arcade/window_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/window_commands.py b/arcade/window_commands.py index 9bb6302fc1..3aff40a04f 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -328,7 +328,7 @@ def some_action(delta_time): print(delta_time) # Call the function once after 1 second - arcade.schedule_one(some_action, 1) + arcade.schedule_once(some_action, 1) Args: function_pointer: From 5743504b43416e4a4c68bca4fe7503bca67ab2ee Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Tue, 18 Feb 2025 16:17:53 -0500 Subject: [PATCH 042/279] Delete old formatting kludges (#2575) --- doc/conf.py | 41 +++-------------------------------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 799a5bb7aa..03f5320b39 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -70,42 +70,7 @@ # from arcade.version import VERSION # or read the docs build will fail. from version import VERSION # pyright: ignore [reportMissingImports] -log.info(f" Got raw version {VERSION=!r}") - - -def nice_version(version_string: str) -> str: - """Format raw VERSION by removing leading zeroes. - - When importing VERSION, Python defaults to formatting it as - 3.00 as of February 2025. - """ - out = [] - for part in version_string.split('.'): - try: - out.append(str(int(part))) - except ValueError as _: - out.append(part) - return '.'.join(out) - - -# pending: 3.0.1 or 3.1 release? -# Maintain title bar continuity for live doc showing 3.0 as the version -VERSION_SPECIAL_CASES = {'3.0.0': '3.0'} - - -def _specialcase_version(nice: str) -> str: - if nice in VERSION_SPECIAL_CASES: - new = VERSION_SPECIAL_CASES[nice] - log.info(f" Special-casing version {nice!r} to {new!r}") - else: - new = nice - return new - - -NICE_VERSION = _specialcase_version(nice_version(VERSION)) -# pending: end - -log.info(f" Got nice version {NICE_VERSION=!r}") +log.info(f" Got version {VERSION=!r}") print() @@ -261,9 +226,9 @@ def run_util(filename, run_name="__main__", init_globals=None): # built documents. # # The short X.Y version. -version = NICE_VERSION +version = VERSION # The full version, including alpha/beta/rc tags. -release = NICE_VERSION +release = VERSION # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. From 79defbfa55854929fbeabd186e54eb84eb286b4e Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Sun, 23 Feb 2025 18:54:12 +0100 Subject: [PATCH 043/279] Switch from black to ruff format (#2550) * Improve make.py a bit * ruff format changes on arcade * Switch from black to ruff format * Fix ruff-check in .github/workflows/test.yml --------- Co-authored-by: Einar Forselv --- .github/workflows/test.yml | 16 +- CONTRIBUTING.md | 16 +- arcade/application.py | 2 +- arcade/camera/camera_2d.py | 12 +- arcade/camera/data_types.py | 5 +- arcade/camera/static.py | 1 - arcade/draw/rect.py | 4 +- arcade/experimental/atlas_render_into.py | 1 - arcade/experimental/atlas_replace_image.py | 1 - arcade/experimental/geo_culling_check.py | 1 - arcade/experimental/perspective_parallax.py | 1 - arcade/experimental/postprocessing.py | 1 - arcade/experimental/query_demo.py | 1 - .../experimental/render_offscreen_animated.py | 1 - arcade/experimental/shadertoy_demo.py | 1 - arcade/experimental/shadertoy_demo_simple.py | 1 - arcade/experimental/shadertoy_textures.py | 1 - arcade/experimental/shapes_perf.py | 1 - arcade/experimental/texture_transforms.py | 1 - arcade/future/background/__init__.py | 1 - arcade/future/input/input_manager_example.py | 5 +- arcade/future/input/input_mapping.py | 4 - arcade/future/input/manager.py | 1 - arcade/gl/buffer.py | 1 - arcade/isometric.py | 1 - arcade/math.py | 3 +- arcade/perf_graph.py | 1 - arcade/physics_engines.py | 4 - arcade/shape_list.py | 1 - arcade/texture/texture.py | 2 +- arcade/utils.py | 34 ++-- arcade/window_commands.py | 4 +- make.py | 185 +++++++----------- pyproject.toml | 33 ++-- 34 files changed, 132 insertions(+), 216 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1fd324ee2a..3b89cde3c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,14 @@ jobs: id: wheel run: | python -m pip install -e .[dev] + - name: "code-inspection: formatting" + if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} + run: | + python ./make.py format --check + - name: "code-inspection: ruff-check" + if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} + run: | + python ./make.py ruff-check - name: "code-inspection: mypy" if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} run: | @@ -44,14 +52,6 @@ jobs: if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} run: | python ./make.py pyright - - name: "code-inspection: ruff" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py ruff - - name: "code-inspection: formatting" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py format --check # Prepare the Pull Request Payload artifact. If this fails, # we fail silently using the `continue-on-error` option. It's # nice if this succeeds, but if it fails for any reason, it diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68d2b3604e..0864aef4dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,24 +81,20 @@ steps at the end of this document. ## Formatting -Arcade uses [Black](https://black.readthedocs.io/en/stable) for autoformatting our code. +Arcade uses [ruff format](https://docs.astral.sh/ruff/formatter/) for autoformatting our code +as well as sorting imports. This can be run both with our `make.py` script, as well as setup for your editor to run it automatically. -See [this link](https://black.readthedocs.io/en/stable/integrations/editors.html) for more information on -Black integration for your specific editor. +See [this link](https://docs.astral.sh/ruff/editors/) for more information on ruff editor integration. -The following command will run black for you if you do not want to configure your editor to do it. It can be -a good idea to run this command when you are finished working anyway, as our CI will use this to check that -the formatting is correct. +The following command will run ``ruff format`` for you if you do not want to configure your editor to do it. +It can be a good idea to run this command when you are finished working anyway, +as our CI will use this to check that the formatting is correct. ```bash python make.py format ``` -In addition to Black, this will sort the imports using [Ruff](https://docs.astral.sh/ruff/). If you want to set up -your editor to run this, please see [this link](https://docs.astral.sh/ruff/integrations/) for more information on -Ruff integration for your specific editor. - Docstring should be formatted using [Google Style](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html). The minium for docstrings is covering all parameters in an `Args:` block. diff --git a/arcade/application.py b/arcade/application.py index bdfad6c1d8..3b158e385a 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -962,7 +962,7 @@ def show_view(self, new_view: View) -> None: """ if not isinstance(new_view, View): raise TypeError( - f"Window.show_view() takes an arcade.View," f"but it got a {type(new_view)}." + f"Window.show_view() takes an arcade.View, but it got a {type(new_view)}." ) self._ctx.screen.use() diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index 2bbf205d21..5aefd360a7 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -124,15 +124,15 @@ def __init__( if projection is not None: if left == right: raise ZeroProjectionDimension( - (f"projection width is 0 due to equal {left=}" f"and {right=} values") + f"projection width is 0 due to equal {left=} and {right=} values" ) if bottom == top: raise ZeroProjectionDimension( - (f"projection height is 0 due to equal {bottom=}" f"and {top=}") + f"projection height is 0 due to equal {bottom=} and {top=}" ) if near == far: raise ZeroProjectionDimension( - f"projection depth is 0 due to equal {near=}" f"and {far=} values" + f"projection depth is 0 due to equal {near=} and {far=} values" ) pos_x = position[0] if position is not None else half_width @@ -223,17 +223,17 @@ def from_camera_data( left, right = projection_data.left, projection_data.right if projection_data.left == projection_data.right: raise ZeroProjectionDimension( - (f"projection width is 0 due to equal {left=}" f"and {right=} values") + (f"projection width is 0 due to equal {left=}and {right=} values") ) bottom, top = projection_data.bottom, projection_data.top if bottom == top: raise ZeroProjectionDimension( - (f"projection height is 0 due to equal {bottom=}" f"and {top=}") + (f"projection height is 0 due to equal {bottom=}and {top=}") ) near, far = projection_data.near, projection_data.far if near == far: raise ZeroProjectionDimension( - f"projection depth is 0 due to equal {near=}" f"and {far=} values" + f"projection depth is 0 due to equal {near=}and {far=} values" ) # build a new camera with defaults and then apply the provided camera objects. diff --git a/arcade/camera/data_types.py b/arcade/camera/data_types.py index 3432a4853b..32b2219a34 100644 --- a/arcade/camera/data_types.py +++ b/arcade/camera/data_types.py @@ -84,7 +84,6 @@ def __init__( forward: Point3 = (0.0, 0.0, -1.0), zoom: float = 1.0, ): - self.position: tuple[float, float, float] = position """A 3D vector which describes where the camera is located.""" @@ -260,9 +259,7 @@ def lrbt(self, new_lrbt: tuple[float, float, float, float]): self.rect = LRBT(*new_lrbt) def __str__(self): - return ( - f"OrthographicProjection<" f"LRBT={self.rect.lrbt}, " f"{self.near=}, " f"{self.far=}" - ) + return f"OrthographicProjection" def __repr__(self): return self.__str__() diff --git a/arcade/camera/static.py b/arcade/camera/static.py index c09651068f..dff4151219 100644 --- a/arcade/camera/static.py +++ b/arcade/camera/static.py @@ -27,7 +27,6 @@ class _StaticCamera: - def __init__( self, view_matrix: Mat4, diff --git a/arcade/draw/rect.py b/arcade/draw/rect.py index 22a3070c83..eb87f6e21d 100644 --- a/arcade/draw/rect.py +++ b/arcade/draw/rect.py @@ -187,10 +187,10 @@ def draw_lrbt_rectangle_outline( ValueError: Raised if left > right or top < bottom. """ if left > right: - raise ValueError("Left coordinate must be less than or equal to " "the right coordinate") + raise ValueError("Left coordinate must be less than or equal to the right coordinate") if bottom > top: - raise ValueError("Bottom coordinate must be less than or equal to " "the top coordinate") + raise ValueError("Bottom coordinate must be less than or equal to the top coordinate") draw_rect_outline(LRBT(left, right, bottom, top), color, border_width) diff --git a/arcade/experimental/atlas_render_into.py b/arcade/experimental/atlas_render_into.py index 7685ac4050..23ddfcc78c 100644 --- a/arcade/experimental/atlas_render_into.py +++ b/arcade/experimental/atlas_render_into.py @@ -10,7 +10,6 @@ class AtlasRenderDemo(arcade.Window): - def __init__(self): super().__init__(1280, 720, "Atlas Render Demo") self.atlas = arcade.DefaultTextureAtlas((600, 600)) diff --git a/arcade/experimental/atlas_replace_image.py b/arcade/experimental/atlas_replace_image.py index 7bfc38b4ea..3a1d949e5a 100644 --- a/arcade/experimental/atlas_replace_image.py +++ b/arcade/experimental/atlas_replace_image.py @@ -68,7 +68,6 @@ class AtlasReplaceImage(arcade.Window): - def __init__(self): super().__init__(800, 600, "Replacing images in atlas") self.sprite_1 = arcade.Sprite(TEXTURE_PATHS[-1], center_x=200, center_y=300) diff --git a/arcade/experimental/geo_culling_check.py b/arcade/experimental/geo_culling_check.py index a9d7f2715e..1193b8cff6 100644 --- a/arcade/experimental/geo_culling_check.py +++ b/arcade/experimental/geo_culling_check.py @@ -16,7 +16,6 @@ class GeoCullingTest(arcade.Window): - def __init__(self): super().__init__(1280, 720, "Cull test", resizable=True) self.proj = 0, self.width, 0, self.height diff --git a/arcade/experimental/perspective_parallax.py b/arcade/experimental/perspective_parallax.py index 24d791c7ee..864ad733ec 100644 --- a/arcade/experimental/perspective_parallax.py +++ b/arcade/experimental/perspective_parallax.py @@ -20,7 +20,6 @@ class PerspectiveParallax(arcade.Window): - def __init__(self): super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, "Perspective Parallax") self.t = 0.0 diff --git a/arcade/experimental/postprocessing.py b/arcade/experimental/postprocessing.py index 5dbe6fcaaf..bc6a0e7a12 100644 --- a/arcade/experimental/postprocessing.py +++ b/arcade/experimental/postprocessing.py @@ -54,7 +54,6 @@ def resize(self, size: tuple[int, int]): class GaussianBlurPass(PostProcessing): - def __init__(self, size, kernel_size=5, sigma=2, multiplier=0, step=1): super().__init__(size) self._kernel_size = kernel_size diff --git a/arcade/experimental/query_demo.py b/arcade/experimental/query_demo.py index de9b37c46c..1439cdafbb 100644 --- a/arcade/experimental/query_demo.py +++ b/arcade/experimental/query_demo.py @@ -12,7 +12,6 @@ class MyGame(arcade.Window): - def __init__(self, width, height, title): super().__init__(width, height, title) # vsync must be off when measuring rendering calls diff --git a/arcade/experimental/render_offscreen_animated.py b/arcade/experimental/render_offscreen_animated.py index 226adb8816..7af10484b3 100644 --- a/arcade/experimental/render_offscreen_animated.py +++ b/arcade/experimental/render_offscreen_animated.py @@ -63,7 +63,6 @@ def make_skyline( color_list = [] while building_center_x < width: - # Is there a gap between the buildings? if random.random() < gap_chance: gap_width = random.randrange(10, 50) diff --git a/arcade/experimental/shadertoy_demo.py b/arcade/experimental/shadertoy_demo.py index 4e5fb6d760..cb83416935 100644 --- a/arcade/experimental/shadertoy_demo.py +++ b/arcade/experimental/shadertoy_demo.py @@ -7,7 +7,6 @@ class MyGame(arcade.Window): - def __init__(self, width, height, title): super().__init__(width, height, title, resizable=True) self.shadertoy = Shadertoy.create_from_file( diff --git a/arcade/experimental/shadertoy_demo_simple.py b/arcade/experimental/shadertoy_demo_simple.py index 18f1c8d070..f9423aecbf 100644 --- a/arcade/experimental/shadertoy_demo_simple.py +++ b/arcade/experimental/shadertoy_demo_simple.py @@ -10,7 +10,6 @@ class MyGame(arcade.Window): - def __init__(self, width, height, title): super().__init__(width, height, title, resizable=True) self.shadertoy = Shadertoy( diff --git a/arcade/experimental/shadertoy_textures.py b/arcade/experimental/shadertoy_textures.py index a2d5bd8171..8f06deb6f3 100644 --- a/arcade/experimental/shadertoy_textures.py +++ b/arcade/experimental/shadertoy_textures.py @@ -15,7 +15,6 @@ class MyGame(arcade.Window): - def __init__(self, width, height, title): super().__init__(width, height, title, resizable=True) self.shadertoy = Shadertoy( diff --git a/arcade/experimental/shapes_perf.py b/arcade/experimental/shapes_perf.py index 64b0d3e164..f339543ed4 100644 --- a/arcade/experimental/shapes_perf.py +++ b/arcade/experimental/shapes_perf.py @@ -76,7 +76,6 @@ def random_radius(start=5, end=25): class GameWindow(arcade.Window): - def __init__(self, width, height, title): super().__init__(width, height, title, antialiasing=True, resizable=True) self.set_vsync(False) diff --git a/arcade/experimental/texture_transforms.py b/arcade/experimental/texture_transforms.py index 3c4f0c98e3..88ebb283f3 100644 --- a/arcade/experimental/texture_transforms.py +++ b/arcade/experimental/texture_transforms.py @@ -18,7 +18,6 @@ class App(arcade.Window): - def __init__(self): super().__init__(1200, 600, "Atlas Revamp Check") paths = [ diff --git a/arcade/future/background/__init__.py b/arcade/future/background/__init__.py index 5951c1a597..ad3bc3d588 100644 --- a/arcade/future/background/__init__.py +++ b/arcade/future/background/__init__.py @@ -55,7 +55,6 @@ def background_from_file( shader: gl.Program | None = None, geometry: gl.Geometry | None = None, ) -> Background: - texture = BackgroundTexture.from_file(tex_src, offset, scale, angle, filters) if size is None: size = texture.texture.size diff --git a/arcade/future/input/input_manager_example.py b/arcade/future/input/input_manager_example.py index 50b715a959..87e573054a 100644 --- a/arcade/future/input/input_manager_example.py +++ b/arcade/future/input/input_manager_example.py @@ -25,7 +25,6 @@ class Player(arcade.Sprite): - def __init__( self, texture, @@ -58,7 +57,6 @@ def on_action(self, action: str, state: ActionState): class Game(arcade.Window): - def __init__( self, player_textures: Sequence[str] = DEFAULT_TEXTURES, @@ -76,7 +74,7 @@ def __init__( self._max_players = max_players self.key_to_player_index: dict[Keys, int] = { - getattr(Keys, f"KEY_{i + 1 }"): i for i in range(0, max_players) + getattr(Keys, f"KEY_{i + 1}"): i for i in range(0, max_players) } self.players: list[Player | None] = [] @@ -201,7 +199,6 @@ def on_draw(self): self.device_labels_batch.draw() def on_key_press(self, key, modifiers): - player_index = self.key_to_player_index.get(Keys(key), None) if player_index is None or player_index >= len(self.players): return diff --git a/arcade/future/input/input_mapping.py b/arcade/future/input/input_mapping.py index 39b38e0fb7..d4caeed990 100644 --- a/arcade/future/input/input_mapping.py +++ b/arcade/future/input/input_mapping.py @@ -7,7 +7,6 @@ class Action: - def __init__(self, name: str) -> None: self.name = name self._mappings: set[ActionMapping] = set() @@ -20,7 +19,6 @@ def remove_mapping(self, mapping: ActionMapping) -> None: class Axis: - def __init__(self, name: str) -> None: self.name = name self._mappings: set[AxisMapping] = set() @@ -51,7 +49,6 @@ def __init__(self, input: inputs.InputEnum): class ActionMapping(InputMapping): - def __init__( self, input: inputs.InputEnum, @@ -71,7 +68,6 @@ def __init__( class AxisMapping(InputMapping): - def __init__(self, input: inputs.InputEnum, scale: float): super().__init__(input) self._scale = scale diff --git a/arcade/future/input/manager.py b/arcade/future/input/manager.py index 976fb9e386..104a6635d9 100644 --- a/arcade/future/input/manager.py +++ b/arcade/future/input/manager.py @@ -64,7 +64,6 @@ class InputDevice(Enum): class InputManager: - def __init__( self, controller: Controller | None = None, diff --git a/arcade/gl/buffer.py b/arcade/gl/buffer.py index d03db1da0c..de565dee41 100644 --- a/arcade/gl/buffer.py +++ b/arcade/gl/buffer.py @@ -56,7 +56,6 @@ def __init__( reserve: int = 0, usage: str = "static", ): - self._ctx = ctx self._glo = glo = gl.GLuint() self._size = -1 diff --git a/arcade/isometric.py b/arcade/isometric.py index d1b6c5a8d1..097cb21772 100644 --- a/arcade/isometric.py +++ b/arcade/isometric.py @@ -33,7 +33,6 @@ def screen_to_isometric_grid( def create_isometric_grid_lines( width: int, height: int, tile_width: int, tile_height: int, color: RGBA255, line_width: int ) -> ShapeElementList: - # Grid lines 1 shape_list: ShapeElementList = ShapeElementList() diff --git a/arcade/math.py b/arcade/math.py index 879e8f1e9e..86a55473d9 100644 --- a/arcade/math.py +++ b/arcade/math.py @@ -388,8 +388,7 @@ def rescale_relative_to_point(source: Point2, target: Point2, factor: AsFloat | return target except ValueError: raise ValueError( - "factor must be a float, int, or tuple-like " - "which unpacks as two float-like values" + "factor must be a float, int, or tuple-like which unpacks as two float-like values" ) except TypeError: raise TypeError( diff --git a/arcade/perf_graph.py b/arcade/perf_graph.py index bf22977e2f..2b34781bc5 100644 --- a/arcade/perf_graph.py +++ b/arcade/perf_graph.py @@ -320,7 +320,6 @@ def update_graph(self, delta_time: float) -> None: # Render to the internal texture # This ugly spacing is intentional to make type checking work. with atlas.render_into(self.minimap_texture, projection=self.proj) as fbo: # type: ignore - # Set the background color fbo.clear(color=self.background_color) diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index bedce69dd4..065ce8504f 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -115,7 +115,6 @@ def _move_sprite( # --- Rotate rotating_hit_list = [] if moving_sprite.change_angle: - # Rotate moving_sprite.angle += moving_sprite.change_angle @@ -123,7 +122,6 @@ def _move_sprite( rotating_hit_list = check_for_collision_with_lists(moving_sprite, can_collide) if len(rotating_hit_list) > 0: - max_distance = (moving_sprite.width + moving_sprite.height) / 2 # Resolve any collisions by this weird kludge @@ -199,7 +197,6 @@ def _move_sprite( exit_loop = False while not exit_loop: - loop_count += 1 # print(f"{cur_x_change=}, {upper_bound=}, {lower_bound=}, {loop_count=}") @@ -782,7 +779,6 @@ def update(self) -> list[BasicSprite]: for platform_list in self.platforms: for platform in platform_list: if platform.change_x != 0 or platform.change_y != 0: - # Check x boundaries and move the platform in x direction if platform.boundary_left and platform.left <= platform.boundary_left: platform.left = platform.boundary_left diff --git a/arcade/shape_list.py b/arcade/shape_list.py index c3c87b672a..f1ead8cf9b 100644 --- a/arcade/shape_list.py +++ b/arcade/shape_list.py @@ -469,7 +469,6 @@ def create_rectangle( if filled: data[-2:] = reversed(data[-2:]) else: - i_lb = ( center_x - width / 2 + border_width / 2, center_y - height / 2 + border_width / 2, diff --git a/arcade/texture/texture.py b/arcade/texture/texture.py index 7a83d45d06..df3f11cabb 100644 --- a/arcade/texture/texture.py +++ b/arcade/texture/texture.py @@ -170,7 +170,7 @@ def __init__( self._image_data = image else: raise TypeError( - "image must be an instance of PIL.Image.Image or ImageData, " f"not {type(image)}" + f"image must be an instance of PIL.Image.Image or ImageData, not {type(image)}" ) # Set the size of the texture since this is immutable diff --git a/arcade/utils.py b/arcade/utils.py index db62683f16..0785f1a622 100644 --- a/arcade/utils.py +++ b/arcade/utils.py @@ -3,6 +3,7 @@ IMPORTANT: These should be standalone and not rely on any Arcade imports """ + from __future__ import annotations import platform @@ -23,7 +24,7 @@ "is_str_or_noniterable", "grow_sequence", "is_raspberry_pi", - "get_raspberry_pi_info" + "get_raspberry_pi_info", ] # Since this module forbids importing from the rest of @@ -45,6 +46,7 @@ class Chain(Generic[_T]): Arguments: components: The sequences of items to join. """ + def __init__(self, *components: Sequence[_T]): self.components: list[Sequence[_T]] = list(components) @@ -151,9 +153,9 @@ def is_str_or_noniterable(item: Any) -> bool: def grow_sequence( - destination: MutableSequence[_T], - source: _T | Iterable[_T], - append_if: Callable[[_T | Iterable[_T]], bool] = is_str_or_noniterable + destination: MutableSequence[_T], + source: _T | Iterable[_T], + append_if: Callable[[_T | Iterable[_T]], bool] = is_str_or_noniterable, ) -> None: """Append when ``append_if(to_add)`` is ``True``, extend otherwise. @@ -235,18 +237,21 @@ def __init__(self, nasty_state: HypotheticalNasty): this_line_raises = copy.deepcopy(instance) this_line_also_raises = copy.copy(instance) """ + def __copy__(self): # noqa - raise NotImplementedError( - f"{self.__class__.__name__} does not implement __copy__, but" - f"you may implement it on a custom subclass." - ) - decorated_type.__copy__ = __copy__ + raise NotImplementedError( + f"{self.__class__.__name__} does not implement __copy__, but" + f"you may implement it on a custom subclass." + ) + + decorated_type.__copy__ = __copy__ def __deepcopy__(self, memo): # noqa - raise NotImplementedError( - f"{self.__class__.__name__} does not implement __deepcopy__," - f" but you may implement it on a custom subclass." - ) + raise NotImplementedError( + f"{self.__class__.__name__} does not implement __deepcopy__," + f" but you may implement it on a custom subclass." + ) + decorated_type.__deepcopy__ = __deepcopy__ return decorated_type @@ -310,8 +315,7 @@ def unpack_asfloat_or_point(value: AsFloat | Point2) -> Point2: x, y = value except ValueError: raise ValueError( - "value must be a float, int, or tuple-like " - "which unpacks as two float-like values" + "value must be a float, int, or tuple-like which unpacks as two float-like values" ) except TypeError: raise TypeError( diff --git a/arcade/window_commands.py b/arcade/window_commands.py index 3aff40a04f..a739dd5116 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -59,9 +59,7 @@ def get_window() -> "Window": :return: Handle to the current window. """ if _window is None: - raise RuntimeError( - ("No window is active. " "It has not been created yet, or it was closed.") - ) + raise RuntimeError("No window is active. It has not been created yet, or it was closed.") return _window diff --git a/make.py b/make.py index cf9d0921c6..9639db7330 100755 --- a/make.py +++ b/make.py @@ -11,6 +11,7 @@ * CONTRIBUTING.md * The output of python make.py --help """ + from __future__ import annotations import os @@ -29,7 +30,6 @@ def _resolve(p: PathLike, strict: bool = False) -> Path: PROJECT_ROOT = _resolve(Path(__file__).parent, strict=True) - # General sphinx state / options SPHINX_OPTS = [] SPHINX_BUILD = "sphinx-build" @@ -38,13 +38,11 @@ def _resolve(p: PathLike, strict: bool = False) -> Path: DOC_DIR = "doc" BUILD_DIR = "build" - # Used for user output; relative to project root FULL_DOC_DIR = PROJECT_ROOT / DOC_DIR # FULL_BUILD_PREFIX = f"{DOCDIR}/{BUILDDIR}" FULL_BUILD_DIR = PROJECT_ROOT / BUILD_DIR - # Linting RUFF = "ruff" RUFFOPTS = ["check"] @@ -54,9 +52,6 @@ def _resolve(p: PathLike, strict: bool = False) -> Path: MYPYOPTS = ["arcade"] PYRIGHT = "pyright" PYRIGHTOPTS = [] -BLACK = "black" -BLACKOPTS = ["arcade"] -ISORTOPTS = ["check", "--select", "I"] # Testing PYTEST = "pytest" @@ -75,7 +70,6 @@ def _resolve(p: PathLike, strict: bool = False) -> Path: # This allows for internationalization / localization of doc. I18NSPHINXOPTS = [*PAPER_SIZE_OPTS[PAPER_SIZE], *SPHINX_OPTS, "."] - # User-friendly check for dependencies and binaries binaries = ["sphinx-build", "sphinx-autobuild"] libraries = ["typer"] @@ -83,9 +77,8 @@ def _resolve(p: PathLike, strict: bool = False) -> Path: not_found = [binary for binary in binaries if which(binary) is None] if not_found: print("Command-line tools not found: " + ", ".join(not_found)) - print( - "Did you forget to install them with `pip`? See CONTRIBUTING.md file for instructions." - ) + print("Did you forget to install them with `pip`?") + print("See CONTRIBUTING.md file for instructions.") exit(1) for library in libraries: @@ -99,12 +92,10 @@ def find(library): not_found = [library for library in libraries if not find(library)] if not_found: print("Python dependencies not found: " + ", ".join(not_found)) - print( - "Did you forget to install them with `pip`? See CONTRIBUTING.md file for instructions." - ) + print("Did you forget to install them with `pip`?") + print("See CONTRIBUTING.md file for instructions.") exit(1) - import typer app = typer.Typer() @@ -152,12 +143,17 @@ def run(args: str | list[str], cd: PathLike | None = None) -> None: args: the command to run. cd: a directory to switch into beforehand, if any. """ + cmd = " ".join(args) + print(">> Running command:", cmd) if cd is not None: with cd_context(_resolve(cd, strict=True)): result = subprocess.run(args) else: result = subprocess.run(args) + print(">> Command finished:", cmd, "\n") + + # TODO: Should we exit here? Or continue to let other commands run also? if result.returncode != 0: exit(result.returncode) @@ -196,7 +192,20 @@ def serve(): ) -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs") +def linkcheck(): + """ + to check all external links for integrity + """ + run_doc([SPHINX_BUILD, "-b", "linkcheck", *ALLSPHINXOPTS, f"{BUILD_DIR}/linkcheck"]) + print() + print( + "Link check complete; look for any errors in the above output " + + f"or in {FULL_BUILD_DIR}/linkcheck/output.txt." + ) + + +@app.command(rich_help_panel="Docs Extra Formats") def dirhtml(): """ to make HTML files named index.html in directories @@ -206,7 +215,7 @@ def dirhtml(): print(f"Build finished. The HTML pages are in {FULL_BUILD_DIR}/dirhtml.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def singlehtml(): """ to make a single large HTML file @@ -216,7 +225,7 @@ def singlehtml(): print(f"Build finished. The HTML page is in {FULL_BUILD_DIR}/singlehtml.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def pickle(): """ to make pickle files @@ -226,7 +235,7 @@ def pickle(): print("Build finished; now you can process the pickle files.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def json(): """ to make JSON files @@ -236,7 +245,7 @@ def json(): print("Build finished; now you can process the JSON files.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def htmlhelp(): """ to make HTML files and a HTML help project @@ -249,38 +258,7 @@ def htmlhelp(): ) -@app.command(rich_help_panel="Additional Doc Formats") -def qthelp(): - """ - to make HTML files and a qthelp project - """ - run_doc([SPHINX_BUILD, "-b", "qthelp", *ALLSPHINXOPTS, f"{BUILD_DIR}/qthelp"]) - print() - print( - 'Build finished; now you can run "qcollectiongenerator" with the' - + f".qhcp project file in {FULL_BUILD_DIR}/qthelp, like this:" - ) - print(f"# qcollectiongenerator {FULL_BUILD_DIR}/qthelp/Arcade.qhcp") - print("To view the help file:") - print(f"# assistant -collectionFile {FULL_BUILD_DIR}/qthelp/Arcade.qhc") - - -@app.command(rich_help_panel="Additional Doc Formats") -def applehelp(): - """ - to make an Apple Help Book - """ - run_doc([SPHINX_BUILD, "-b", "applehelp", *ALLSPHINXOPTS, f"{BUILD_DIR}/applehelp"]) - print() - print(f"Build finished. The help book is in {FULL_BUILD_DIR}/applehelp.") - print( - "N.B. You won't be able to view it unless you put it in" - + "~/Library/Documentation/Help or install it in your application" - + "bundle." - ) - - -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def devhelp(): """ to make HTML files and a Devhelp project @@ -295,7 +273,7 @@ def devhelp(): print("# devhelp") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def epub(): """ to make an epub @@ -305,7 +283,7 @@ def epub(): print(f"Build finished. The epub file is in {FULL_BUILD_DIR}/epub.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def latex(): """ to make LaTeX files, you can set PAPER_SIZE=a4 or PAPER_SIZE=letter @@ -319,7 +297,7 @@ def latex(): ) -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def latexpdf(): """ to make LaTeX files and run them through pdflatex @@ -330,7 +308,7 @@ def latexpdf(): print(f"pdflatex finished; the PDF files are in {FULL_BUILD_DIR}/latex.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def latexpdfja(): """ to make LaTeX files and run them through platex/dvipdfmx @@ -341,7 +319,7 @@ def latexpdfja(): print(f"pdflatex finished; the PDF files are in {FULL_BUILD_DIR}/latex.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def text(): """ to make text files @@ -351,7 +329,7 @@ def text(): print(f"Build finished. The text files are in {FULL_BUILD_DIR}/text.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def man(): """ to make manual pages @@ -361,7 +339,7 @@ def man(): print(f"Build finished. The manual pages are in {FULL_BUILD_DIR}/man.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def texinfo(): """ to make Texinfo files @@ -375,7 +353,7 @@ def texinfo(): ) -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def info(): """ to make Texinfo files and run them through makeinfo @@ -386,7 +364,7 @@ def info(): print(f"makeinfo finished; the Info files are in {FULL_BUILD_DIR}/texinfo.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def gettext(): """ to make PO message catalogs @@ -396,7 +374,7 @@ def gettext(): print(f"Build finished. The message catalogs are in {FULL_BUILD_DIR}/locale.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def changes(): """ to make an overview of all changed/added/deprecated items @@ -406,20 +384,7 @@ def changes(): print(f"The overview file is in {FULL_BUILD_DIR}/changes.") -@app.command(rich_help_panel="Code Quality") -def linkcheck(): - """ - to check all external links for integrity - """ - run_doc([SPHINX_BUILD, "-b", "linkcheck", *ALLSPHINXOPTS, f"{BUILD_DIR}/linkcheck"]) - print() - print( - "Link check complete; look for any errors in the above output " - + f"or in {FULL_BUILD_DIR}/linkcheck/output.txt." - ) - - -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def doctest(): """ to run all doctests embedded in the documentation (if enabled) @@ -431,7 +396,7 @@ def doctest(): ) -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def coverage(): """ to run coverage check of the documentation (if enabled) @@ -443,14 +408,14 @@ def coverage(): ) -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def xml(): run_doc([SPHINX_BUILD, "-b", "xml", *ALLSPHINXOPTS, f"{BUILD_DIR}/xml"]) print() print(f"Build finished. The XML files are in {FULL_BUILD_DIR}/xml.") -@app.command(rich_help_panel="Additional Doc Formats") +@app.command(rich_help_panel="Docs Extra Formats") def pseudoxml(): run_doc([SPHINX_BUILD, "-b", "pseudoxml", *ALLSPHINXOPTS, f"{BUILD_DIR}/pseudoxml"]) print() @@ -460,61 +425,58 @@ def pseudoxml(): @app.command(rich_help_panel="Code Quality") def lint(): """ - Run all linting tasks: ruff, mypy, and pyright (Run this before making a pull request!) + Run tasks: ruff, mypy, and pyright (Run this before making a pull request!) """ - ruff() + ruff_check() mypy() pyright() - print("Linting Complete.") @app.command(rich_help_panel="Code Quality") -def ruff(): +def ruff_check(): + """Run ruff check for code quality""" run([RUFF, *RUFFOPTS, RUFFOPTS_PACKAGE]) - print("Ruff Finished.") - - -@app.command(rich_help_panel="Code Quality") -def mypy(): - "Typecheck using mypy" - run([MYPY, *MYPYOPTS]) - print("MyPy Finished.") @app.command(rich_help_panel="Code Quality") -def pyright(): - "Typecheck using pyright" - run([PYRIGHT, *PYRIGHTOPTS]) - print("Pyright Finished.") +def format(check: bool = False): + """Format code and sort imports with ruff""" + ruff_format(check) + ruff_isort(check) @app.command(rich_help_panel="Code Quality") -def format(check: bool = False): - "Format code using black" - black(check) - isort(check) - print("Formatting Complete.") +def ruff_format(check: bool = False): + """Format code using ruff""" + ruff_fmt = [RUFF, "format"] + if check: + ruff_fmt.append("--check") + run(ruff_fmt) @app.command(rich_help_panel="Code Quality") -def isort(check: bool = False): - "Format code using isort(actually ruff)" +def ruff_isort(check: bool = False): + """Sort imports with ruff""" if not check: RUFFOPTS_ISORT.append("--fix") run([RUFF, *RUFFOPTS_ISORT, RUFFOPTS_PACKAGE]) @app.command(rich_help_panel="Code Quality") -def black(check: bool = False): - "Format code using black" - if check: - BLACKOPTS.append("--check") - run([BLACK, *BLACKOPTS]) - print("Black Finished.") +def mypy(): + """Typecheck using mypy""" + run([MYPY, *MYPYOPTS]) + + +@app.command(rich_help_panel="Code Quality") +def pyright(): + """Typecheck using pyright""" + run([PYRIGHT, *PYRIGHTOPTS]) @app.command(rich_help_panel="Code Quality") def test_full(): + """Run all tests""" run([PYTEST, TESTDIR]) @@ -524,17 +486,14 @@ def test(): run([PYTEST, UNITTESTS]) -SHELLS_WITH_AUTOCOMPLETE = ("bash", "zsh", "fish", "powershell", "powersh") - - @app.command(rich_help_panel="Shell Completion") def whichshell(): - """to find out which shell your system seems to be running""" - + """Find out which shell your system seems to be running""" shell_name = Path(os.environ.get("SHELL")).stem print(f"Your default shell appears to be: {shell_name}") - if shell_name in SHELLS_WITH_AUTOCOMPLETE: + shells = ("bash", "zsh", "fish", "powershell", "powersh") + if shell_name in shells: print("This shell is known to support tab-completion!") print("See CONTRIBUTING.md for more information on how to enable it.") diff --git a/pyproject.toml b/pyproject.toml index 36ce0b76c4..a91a8f59aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,6 @@ dev = [ "pytest-mock", "coverage", "coveralls", # Do we really need this? - "black", "ruff", "mypy", "pyright==1.1.387", @@ -83,11 +82,6 @@ build-backend = "setuptools.build_meta" [tool.ruff] -# --- Description of what we ignore --- -# -# E731 do not assign a lambda expression, use a def -# E741 Ambiguous variable name -# F811: redefinition line-length = 100 output-format = "full" exclude = [ @@ -103,7 +97,12 @@ exclude = [ "bugs", "arcade/examples/platform_tutorial", ] -lint.ignore = ["E731", "E741"] +lint.ignore = [ + "E731", # E731 do not assign a lambda expression, use a def + "E741", # E741 Ambiguous variable name + # F811: redefinition +] + lint.select = [ "E", "F", @@ -112,6 +111,13 @@ lint.select = [ "W", ] +[tool.ruff.format] +docstring-code-format = false +exclude = [ + "arcade/examples/*", + "benchmarks/*", +] + # This ignores __init__.py files and examples for import sorting [tool.ruff.lint.per-file-ignores] "__init__.py" = ["I"] @@ -170,19 +176,6 @@ omit = [ "./Win*/*", ] -[tool.black] -line-length = 100 -force-exclude = ''' -( - doc - | arcade/examples - | arcade/gui/examples - | tests - | util - | benchmarks -) -''' - [[tool.mypy.overrides]] module = "pyglet.*" ignore_missing_imports = true From 21fe9805a1d1f7f22f9f4b1a7c5ce89461f4c699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Doeraene?= Date: Wed, 26 Feb 2025 16:27:12 +0100 Subject: [PATCH 044/279] Fix the type signature of collision check methods. (#2578) * Fix argument annotation for collides_with_list since the type of the sprites in the result depends on the type of sprites in the given `sprite_list`. * Fix argument annotation for collides_with_sprite * Closes #2576 --- arcade/sprite/base.py | 4 ++-- arcade/sprite_list/collision.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py index d8ff67bc6e..c4a5a75872 100644 --- a/arcade/sprite/base.py +++ b/arcade/sprite/base.py @@ -801,7 +801,7 @@ def collides_with_point(self, point: Point2) -> bool: x, y = point return is_point_in_polygon(x, y, self.hit_box.get_adjusted_points()) - def collides_with_sprite(self: SpriteType, other: SpriteType) -> bool: + def collides_with_sprite(self, other: BasicSprite) -> bool: """Will check if a sprite is overlapping (colliding) another Sprite. Args: @@ -813,7 +813,7 @@ def collides_with_sprite(self: SpriteType, other: SpriteType) -> bool: return check_for_collision(self, other) - def collides_with_list(self: SpriteType, sprite_list: "SpriteList") -> list[SpriteType]: + def collides_with_list(self, sprite_list: SpriteList[SpriteType]) -> list[SpriteType]: """Check if current sprite is overlapping with any other sprite in a list Args: diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 914320d0fa..118e115775 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -187,8 +187,8 @@ def _get_nearby_sprites( def check_for_collision_with_list( - sprite: SpriteType, - sprite_list: SpriteList, + sprite: BasicSprite, + sprite_list: SpriteList[SpriteType], method: int = 0, ) -> List[SpriteType]: """ From 2501fcfbbb7d34c5a35db0588c9597a44a4c6c8a Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Fri, 28 Feb 2025 22:48:37 +0100 Subject: [PATCH 045/279] Fix UIMessageBox title (#2582) - Add title as argument in docstring - Use the title argument as the title UILabel's text instead of always setting it to "Message" --- arcade/gui/constructs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/arcade/gui/constructs.py b/arcade/gui/constructs.py index f5470c105f..4dcc24d5d3 100644 --- a/arcade/gui/constructs.py +++ b/arcade/gui/constructs.py @@ -29,6 +29,7 @@ def on_action(event: UIOnActionEvent): width: Width of the message box height: Height of the message box message_text: Text to show as message to the user + title: Title of the message box, displayed on the top buttons: List of strings, which are shown as buttons """ @@ -68,7 +69,7 @@ def __init__( if title: title_label = frame.add( child=UILabel( - text="Message", + text=title, font_size=16, size_hint=(1, 0), align="center", From 8a41838a9dd89c8f35e89b22111936effbdd43d8 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Fri, 28 Feb 2025 17:59:40 -0500 Subject: [PATCH 046/279] Switch to bumping the version file manually (#2581) * Remove unmaintained and brittle version bump CI * Replace obsolete version parsing logic * Update version.py to only parse the Python-style version strings * Update tests * Add more edge cases, including mutual exclusivity between dev previews and release candidates * Run ./make.py format --- .github/workflows/bump_version.yml | 24 ---- .github/workflows/push_build_to_test_pypi.yml | 16 +-- arcade/version.py | 116 ++++++++---------- tests/unit/test_version.py | 20 +-- 4 files changed, 63 insertions(+), 113 deletions(-) delete mode 100644 .github/workflows/bump_version.yml diff --git a/.github/workflows/bump_version.yml b/.github/workflows/bump_version.yml deleted file mode 100644 index 2ee5f1837c..0000000000 --- a/.github/workflows/bump_version.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Bump Version - -on: - workflow_dispatch: - -jobs: - # --- Bump version - bump-version: - - runs-on: ubuntu-latest - environment: deploy-pypi-test - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - token: ${{ secrets.MY_TOKEN }} - - name: Bump versions - uses: remorses/bump-version@js - with: - version_file: ./arcade/VERSION - prerelease_tag: rc - env: - GITHUB_TOKEN: ${{ secrets.MY_TOKEN }} diff --git a/.github/workflows/push_build_to_test_pypi.yml b/.github/workflows/push_build_to_test_pypi.yml index b556272e41..491627115a 100644 --- a/.github/workflows/push_build_to_test_pypi.yml +++ b/.github/workflows/push_build_to_test_pypi.yml @@ -5,21 +5,7 @@ on: jobs: # --- Bump version - bump-version: - - runs-on: ubuntu-latest - environment: deploy-pypi-test - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Bump versions - uses: remorses/bump-version@js - with: - version_file: ./arcade/VERSION - prerelease_tag: dev - env: - GITHUB_TOKEN: ${{ secrets.MY_TOKEN }} + # ( this is manual until we find a better-tested, maintained bump action ) # --- Deploy to pypi deploy-to-pypi-test: diff --git a/arcade/version.py b/arcade/version.py index 6b0d79d7b8..2105f1e27e 100644 --- a/arcade/version.py +++ b/arcade/version.py @@ -13,43 +13,16 @@ # Using file=sys.stderr prints to the error stream (usually prints red) print("This game requires Arcade 3.0.0+ to run!", file=sys.stderr) +The ``VERSION`` constant in this module will be loaded from a file in the +same directory. It will contain the following: -Arcade contributors will benefit from understanding how and why -this file loads and converts the contents of the ``VERSION`` file. +#. major +#. minor +#. point +#. (Optional) one and _only_ one of: -After a release build succeeds, GitHub's CI is configured to do -the following: - -#. Push the package files to PyPI -#. Call the ``remorses/bump-version@js`` action to auto-increment - Arcade's version on the development branch - -This is where an edge case arises: - -#. Our CI expects ``3.1.0-dev.1`` for dev preview builds -#. Python expects ``3.1.0.dev1`` for dev preview builds - -The ``VERSION`` file in this file's directory stores the version -in the form the GH Action prefers. This allows it to auto-increment -the version number on the ``development`` branch after we make an -Arcade release to PyPI. - -The auto-bump action is configured by the following file: -https://github.com/pythonarcade/arcade/blob/development/.github/workflows/bump_version.yml - -As an example, the GH action would auto-increment a dev preview's -version after releasing the 5th dev preview of ``3.1.0`` by updating -the ``VERSION`` file from this: - -.. code-block:: - - 3.1.0-dev.5 - -...to this: - -.. code-block:: - - 3.1.0-dev.6 + * .dev{DEV_PREVIEW_NUMBER} + * rc{RC_NUMBER} """ @@ -63,72 +36,83 @@ _HERE = Path(__file__).parent # Grab version numbers + optional dev point preview -# Assumes $MAJOR.$MINOR.$POINT format with optional -dev$DEV_PREVIEW +# Assumes: +# {MAJOR}.{MINOR}.{POINT} format +# optional: one and ONLY one of: +# 1. dev{DEV_PREVIEW} +# 2. rc{RC_NUMBER} # Q: Why did you use regex?! # A: If the dev_preview field is invalid, the whole match fails instantly -_VERSION_REGEX = re.compile( +_VERSION_REGEX: Final[re.Pattern] = re.compile( r""" # First three version number fields (?P[0-9]+) - \.(?P[0-9]+) - \.(?P[0-9]+) - # Optional dev preview suffix + \.(?P0|[1-9][0-9]*) + \.(?P0|[1-9][0-9]*) + # Optional and mutually exclusive: dev preview or rc number (?: - -dev # Dev prefix as a literal - \. # Point - (?P[0-9]+) # Dev preview number + \.(?Pdev(?:0|[1-9][0-9]*)) + | # XOR: can't be both a preview and an rc + (?Prc(?:0|[1-9][0-9]*)) )? """, re.X, ) -def _parse_python_friendly_version(version_for_github_actions: str) -> str: - """Convert a GitHub CI version string to a Python-friendly one. +def _parse_python_friendly_version( + raw_version: str, pattern: re.Pattern[str] = _VERSION_REGEX +) -> str: + """Read a GitHub CI version string to a Python-friendly one. For example, ``3.1.0-dev.1`` would become ``3.1.0.dev1``. Args: - version_for_github_actions: + raw_version: A raw GitHub CI version string, as read from a file. Returns: A Python-friendly version string. """ # Quick preflight check: we don't support tuple format here! - if not isinstance(version_for_github_actions, str): - raise TypeError( - f"Expected a string of the format MAJOR.MINOR.POINT" - f"or MAJOR.MINOR.POINT-dev.DEV_PREVIEW," - f"not {version_for_github_actions!r}" - ) - - # Attempt to extract our raw data - match = _VERSION_REGEX.fullmatch(version_for_github_actions.strip()) - if match is None: - raise ValueError( - f"String does not appear to be a version number: {version_for_github_actions!r}" + problem = None + if not isinstance(raw_version, str): + problem = TypeError + elif (match := pattern.fullmatch(raw_version)) is None: + problem = ValueError + if problem: + raise problem( + f"{raw_version=!r} not a str of the format MAJOR.MINOR" + f"POINT with at most one of dev{{DEV_PREVIEW}} or" + f"rc{{RC_NUMBER}}," ) # Build final output, including a dev preview version if present - group_dict = match.groupdict() - major, minor, point, dev_preview = group_dict.values() - parts = [major, minor, point] - if dev_preview is not None: - parts.append(f"dev{dev_preview}") + group_dict: dict[str, str | None] = match.groupdict() # type: ignore + parts: list[str] = [group_dict[k] for k in ("major", "minor", "point")] # type: ignore + dev_preview, rc_number = (group_dict[k] for k in ("dev_preview", "rc_number")) + + if dev_preview and rc_number: + raise ValueError(f"Can't have both {dev_preview=!r} and {rc_number=!r}") + elif dev_preview: + parts.append(dev_preview) + joined = ".".join(parts) + if rc_number: + joined += rc_number return joined -def _parse_py_version_from_github_ci_file( +def _parse_py_version_from_file( version_path: str | Path = _HERE / "VERSION", write_errors_to=sys.stderr ) -> str: - """Parse a Python-friendly version from a ``bump-version``-compatible file. + """Read & validate the VERSION file as from a limited subset. On failure, it will: #. Print an error to stderr #. Return "0.0.0" + #. (Indirectly) cause any PyPI uploads to fail Args: version_path: @@ -152,7 +136,7 @@ def _parse_py_version_from_github_ci_file( return data -VERSION: Final[str] = _parse_py_version_from_github_ci_file() +VERSION: Final[str] = _parse_py_version_from_file() """A Python-friendly version string. This value is converted from the GitHub-style ``VERSION`` file at the diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 932e2723b7..01f05ae703 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -5,16 +5,17 @@ import pytest from arcade.version import ( _parse_python_friendly_version, - _parse_py_version_from_github_ci_file + _parse_py_version_from_file ) @pytest.mark.parametrize("value, expected", [ - ("3.0.0-dev.1", "3.0.0.dev1"), + ("3.0.0.dev1", "3.0.0.dev1"), ("3.0.0", "3.0.0"), # Edge cases - ("11.22.333-dev.4444", "11.22.333.dev4444"), + ("11.22.333.dev4444", "11.22.333.dev4444"), ("11.22.333", "11.22.333"), + ("111.2222.3333rc0", "111.2222.3333rc0") ]) class TestParsingWellFormedData: def test_parse_python_friendly_version( @@ -22,7 +23,7 @@ def test_parse_python_friendly_version( ): assert _parse_python_friendly_version(value) == expected - def test_parse_py_version_from_github_ci_file( + def test_parse_py_version_from_file( self, value, expected ): @@ -30,7 +31,7 @@ def test_parse_py_version_from_github_ci_file( f.write(value) f.close() - assert _parse_py_version_from_github_ci_file( + assert _parse_py_version_from_file( f.name ) == expected @@ -47,12 +48,15 @@ def test_parse_py_version_from_github_ci_file( "3.1.2.", "3.1.0.dev", "3.1.0-dev." + "3.1.0-dev.4" # No longer valid input # Hex is not valid in version numbers "A", "3.A.", "3.1.A", "3.1.0.A", - "3.1.0-dev.A" + "3.1.0-dev.A", + # Can't be both a release candidate and a dev preview + "3.1.0.dev4rc1" ) ) def test_parse_python_friendly_version_raises_value_errors(bad_value): @@ -72,8 +76,8 @@ def test_parse_python_friendly_version_raises_typeerror_on_bad_values(bad_type): _parse_python_friendly_version(bad_type) # type: ignore # Type mistmatch is the point -def test_parse_py_version_from_github_ci_file_returns_zeroes_on_errors(): +def test_parse_py_version_from_file_returns_zeroes_on_errors(): fake_stderr = mock.MagicMock(sys.stderr) - assert _parse_py_version_from_github_ci_file( + assert _parse_py_version_from_file( "FILEDOESNOTEXIST", write_errors_to=fake_stderr ) == "0.0.0" From a246aecfc1bbd7391871e0e2c2280ca987fca17b Mon Sep 17 00:00:00 2001 From: Alexander Lacson <41433185+max-torch@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:42:42 +0800 Subject: [PATCH 047/279] Fix typo in Install section of docs (#2590) Closes #2584 --- doc/get_started/install.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/get_started/install.rst b/doc/get_started/install.rst index 60e9f0b4a6..863a1a03db 100644 --- a/doc/get_started/install.rst +++ b/doc/get_started/install.rst @@ -29,7 +29,7 @@ In general, even older convertible Windows tablets will work as long as they: .. note:: ARM-based Windows or Linux tablets may have issues. - These devices may or may not work. the :ref:`requirements_raspi` + See the section on the :ref:`requirements_raspi` below. Windows """"""" From add7fb00ecc190c62d1fac5cb1c31bee36dcc467 Mon Sep 17 00:00:00 2001 From: MrValdez Date: Mon, 3 Mar 2025 11:48:22 +0800 Subject: [PATCH 048/279] Fixed broken link in README (#2589) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c5e814bd25..d7d34d08d9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It is ideal for beginning programmers or programmers who want to create 2D games without learning a complex framework. [pyglet]: https://github.com/pyglet/pyglet -[Games Made with Arcade]: https://api.arcade.academy/en/latest/sample_games.html +[Games Made with Arcade]: https://api.arcade.academy/en/latest/community/games/sample_games.html Arcade is built on top of [pyglet][] and OpenGL. See [Games Made with Arcade][] for example game jam entries and more. From 753663af88e9e49394027a537e06b18e189fc50b Mon Sep 17 00:00:00 2001 From: tyrael <116419708+44mira@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:50:16 +0800 Subject: [PATCH 049/279] Allow SpriteList.draw_hit_boxes to accept RGB colors (#2591) * Alter type annotation for `color` parameter in `draw_hit_boxes` * Allow `draw_hit_boxes`to take RGB colors * Formatting --- arcade/sprite_list/sprite_list.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 69a6f39de7..67b5ca1b47 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -24,17 +24,12 @@ cast, ) -from arcade import ( - Sprite, - SpriteType, - get_window, - gl, -) +from arcade import Sprite, SpriteType, get_window, gl from arcade.gl import Program, Texture2D from arcade.gl.buffer import Buffer from arcade.gl.types import BlendFunction, OpenGlFilter, PyGLenum from arcade.gl.vertex_array import Geometry -from arcade.types import RGBA255, Color, RGBANormalized, RGBOrANormalized +from arcade.types import RGBA255, Color, RGBANormalized, RGBOrA255, RGBOrANormalized from arcade.utils import copy_dunders_unimplemented if TYPE_CHECKING: @@ -1131,7 +1126,9 @@ def draw( if blend_function is not None: self.ctx.blend_func = prev_blend_func - def draw_hit_boxes(self, color: RGBA255 = (0, 0, 0, 255), line_thickness: float = 1.0) -> None: + def draw_hit_boxes( + self, color: RGBOrA255 = (0, 0, 0, 255), line_thickness: float = 1.0 + ) -> None: """ Draw all the hit boxes in this list. @@ -1141,9 +1138,10 @@ def draw_hit_boxes(self, color: RGBA255 = (0, 0, 0, 255), line_thickness: float color: The color of the hit boxes line_thickness: The thickness of the lines """ - # NOTE: Find a way to efficiently draw this + converted_color = Color.from_iterable(color) + for sprite in self.sprite_list: - sprite.draw_hit_box(color, line_thickness) + sprite.draw_hit_box(converted_color, line_thickness) def _normalize_index_buffer(self) -> None: """ From 69b7523bd280bfe1760e4bf705fbf2e47d8da531 Mon Sep 17 00:00:00 2001 From: MrValdez Date: Mon, 3 Mar 2025 12:29:19 +0800 Subject: [PATCH 050/279] added explicit tag around shield images. (#2593) * added alt and title to enable text hover. * added back missing hover text. * added hover text for "GitHub Contributors" and "GitHub Stars". --- README.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d7d34d08d9..11d90c959e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ # Welcome to The Arcade Library!

    - - http://makeapullrequest.com - http://www.firsttimersonly.com/" + + MIT License + + + Pull Requests Welcome + + + First Timers Friendly +

    Arcade is an easy-to-learn Python library for creating 2D video games. @@ -18,10 +24,18 @@ for example game jam entries and more. [Arcade Discord Server]: https://discord.gg/ZjGDqMp

    - - - - + + PyPI - Downloads + + + GitHub Commit Activity + + + GitHub Contributors + + + GitHub Stars +

    ## Stable Documentation From 8be369966ada1ecdc0153168abac068ecb534f02 Mon Sep 17 00:00:00 2001 From: Alexander Lacson <41433185+max-torch@users.noreply.github.com> Date: Mon, 3 Mar 2025 12:33:08 +0800 Subject: [PATCH 051/279] Bandaid fix for link to pyglet module not working in Install section of docs (#2594) Closes #2585 --- doc/get_started/install.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/get_started/install.rst b/doc/get_started/install.rst index 863a1a03db..5733fcf709 100644 --- a/doc/get_started/install.rst +++ b/doc/get_started/install.rst @@ -10,9 +10,12 @@ Install Requirements ------------ -:mod:`pyglet` +Arcade requires a desktop, laptop, or compatible Single-Board Computer (SBC) with: -All systems require Python 3.9 or higher on a desktop or laptop device. +#. Python 3.9 or higher +#. Graphics drivers with support for either: + * OpenGL 3.3+ + * GLES 3.1+ with extensions on SBCs :ref:`Web ` and :ref:`mobile ` are currently unsupported. From 89ec2ab7a0e6db30dcf696a52691b4b1c8723856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Harriet=20Asi=C3=B1ero?= Date: Tue, 4 Mar 2025 13:18:03 +0800 Subject: [PATCH 052/279] Corrected the typo in report bugs tip in the MIT license section (#2597) --- doc/about/permissive_licensing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/about/permissive_licensing.rst b/doc/about/permissive_licensing.rst index 83a0455029..0c402abc19 100644 --- a/doc/about/permissive_licensing.rst +++ b/doc/about/permissive_licensing.rst @@ -64,7 +64,7 @@ using Arcade is an agreement to the following: * You won't claim you wrote the whole library yourself * You understand Arcade's features may have bugs -.. tip:: If you see a bug or a typo, we love :ref:`contributing-bug-reports`. +.. tip:: If you see a bug or a typo, we love you to :ref:`report it`. For more information on the MIT license, please see the following for a quick intro: From b3db2db48fa23a646ed3b0375fbfcaad285cfe97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Harriet=20Asi=C3=B1ero?= Date: Tue, 4 Mar 2025 13:21:24 +0800 Subject: [PATCH 053/279] Corrected the typo in Update Interpolation section in the Event Loop page (#2598) --- doc/programming_guide/event_loop.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/programming_guide/event_loop.rst b/doc/programming_guide/event_loop.rst index 8cc5a10125..0f3edf5c09 100644 --- a/doc/programming_guide/event_loop.rst +++ b/doc/programming_guide/event_loop.rst @@ -100,7 +100,7 @@ Update Interpolation Because fixed updates work on the accumulation of time this may not sync with the ``on_draw`` or ``on_update`` events. In extreme cases this can cause a visible stuttering to objects moved within ``on_fixed_update``. To prevent this, ``GLOBAL_FIXED_CLOCK`` provides -the ``accumulated`` and ``fraction``properties. By storing the last frame's position information it is possible +the ``accumulated`` and ``fraction`` properties. By storing the last frame's position information it is possible to use ``fraction`` to interpolate towards the next calculated positions. For a visual representation of this effect look at ``arcade.examples.fixed_update_interpolation``. From be83d1d98f26ba0b859962a33b207fec7b231529 Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Tue, 4 Mar 2025 21:38:19 +0100 Subject: [PATCH 054/279] Fix broken pyglet windowing links (#2599) --- arcade/application.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/arcade/application.py b/arcade/application.py index 3b158e385a..d86e702fc8 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -82,10 +82,8 @@ class Window(pyglet.window.Window): multiple windows, consider using multiple views or divide the window into sections. - .. _pyglet_pg_window_size_position: - .. https://pyglet.readthedocs.io/en/latest/programming_guide/windowing.html#size-and-position - .. _pyglet_pg_window_style: - .. https://pyglet.readthedocs.io/en/latest/programming_guide/windowing.html#window-style + .. _pyglet_pg_window_size_position: https://pyglet.readthedocs.io/en/latest/programming_guide/windowing.html#size-and-position + .. _pyglet_pg_window_style: https://pyglet.readthedocs.io/en/latest/programming_guide/windowing.html#window-style Args: width (optional): From fcd6a1d02ed1276c2a2a29259a88dff8d2a742f6 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 4 Mar 2025 23:51:19 +0100 Subject: [PATCH 055/279] Gui/3.x fixes (#2600) * gui/fix color picker example added buttons to multiple layouts * gui/fix with_background accepts iterables as color * gui/fix division by zero error, when container size is 0 * update release notes --- CHANGELOG.md | 12 ++++++++++++ arcade/examples/gui/5_uicolor_picker.py | 10 ++++------ arcade/gui/widgets/__init__.py | 3 +++ arcade/gui/widgets/layout.py | 8 ++++++++ .../unit/gui/test_layouting_box_main_algorithm.py | 15 +++++++++++++++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56caa3ff3c..2537aaf645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Version 3.0.1 (unreleased) +### Improvements + +- `UIWidget.with_background` now accepts a tuple for color + +### Bug Fixes + +- Fixed division error in box layout algorithm +- Fix example added buttons to multiple layouts + + +## Version 3.0.1 + ### Bug Fixes - Fixed blurriness in `UIWidget` text during interaction diff --git a/arcade/examples/gui/5_uicolor_picker.py b/arcade/examples/gui/5_uicolor_picker.py index 55e794bfea..46be1a2db6 100644 --- a/arcade/examples/gui/5_uicolor_picker.py +++ b/arcade/examples/gui/5_uicolor_picker.py @@ -144,12 +144,10 @@ def __init__(self): ) ) for i, (name, color) in enumerate(self.colors.items()): - button = self.root.add( - ColorButton( - color_name=name, - color=color, - size_hint=(1, 1), - ) + button = ColorButton( + color_name=name, + color=color, + size_hint=(1, 1), ) self.grid.add(button, row=i // 5, column=i % 5) diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index b7441fefd8..7d4f341a9c 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -461,6 +461,9 @@ def with_background( self """ if color is not ...: + if color is not None: + color = Color.from_iterable(color) + self._bg_color = color if texture is not ...: diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index 40764723d5..61cdfcd3a1 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from dataclasses import dataclass from typing import Dict, Iterable, List, Optional, Tuple, TypeVar @@ -900,6 +901,13 @@ def _box_axis_algorithm(constraints: list[_C], container_size: float) -> List[fl Returns: List of tuples with the sizes of each element """ + + # if there is no space, return the min value of each constraint + # this will cause a overflow, so we give a warning + if container_size <= 0: + warnings.warn("Container size is 0, cannot calculate sizes for children.") + return [c.min for c in constraints] + # adjust hint value based on min and max values for c in constraints: c.hint = max(c.min / container_size, c.hint) diff --git a/tests/unit/gui/test_layouting_box_main_algorithm.py b/tests/unit/gui/test_layouting_box_main_algorithm.py index 092f7b410e..c111028d0a 100644 --- a/tests/unit/gui/test_layouting_box_main_algorithm.py +++ b/tests/unit/gui/test_layouting_box_main_algorithm.py @@ -31,6 +31,21 @@ def test_shw_smaller_1(window): assert sizes == [10, 10, 50] +def test_container_size_zero(window): + # GIVEN + entries = [ + _C(hint=0.1, min=50, max=None), + _C(hint=0.1, min=50, max=None), + _C(hint=0.5, min=50, max=None), + ] + + # WHEN + sizes = _box_axis_algorithm(entries, 0) + + # THEN + assert sizes == [50, 50, 50] + + def test_complex_example_with_max_value(): # GIVEN entries = [ From c741d47178f42fddf9ce1446a14167c1cbf34991 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 7 Mar 2025 22:34:43 +0100 Subject: [PATCH 056/279] update precommit hooks --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 29fbc4bff5..953a84a4e6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,15 @@ default_language_version: - python: python3.9 + python: python3.10 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.9.10 hooks: # Run the linter. - id: ruff @@ -17,11 +17,11 @@ repos: # Run the formatter. - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.10.1' + rev: 'v1.15.0' hooks: - id: mypy args: [ --explicit-package-bases ] - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.370 + rev: v1.1.396 hooks: - id: pyright From ba02f2409d82a2773bce6e111808e2e88af00bd3 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 7 Mar 2025 22:38:21 +0100 Subject: [PATCH 057/279] Gui/performance (#2603) * fix UITextWidget subclasses did not set style during construction, causing a double render * gui: fix update font triggered render on each frame, when font_color was not a Color --- arcade/gui/widgets/buttons.py | 12 ++++++++++++ arcade/gui/widgets/text.py | 3 +++ 2 files changed, 15 insertions(+) diff --git a/arcade/gui/widgets/buttons.py b/arcade/gui/widgets/buttons.py index 2c339db9a9..c3588e4284 100644 --- a/arcade/gui/widgets/buttons.py +++ b/arcade/gui/widgets/buttons.py @@ -140,6 +140,12 @@ def __init__( bind(self, "_textures", self.trigger_render) + # prepare label with default style + _style = self.get_current_style() + if _style is None: + raise ValueError(f"No style found for state {self.get_current_state()}") + self._apply_style(_style) + def get_current_state(self) -> str: """Returns the current state of the button i.e.disabled, press, hover or normal.""" if self.disabled: @@ -330,6 +336,12 @@ def __init__( **kwargs, ) + # prepare label with default style + _style = self.get_current_style() + if _style is None: + raise ValueError(f"No style found for state {self.get_current_state()}") + self._apply_style(_style) + def get_current_state(self) -> str: """Returns the current state of the button i.e.disabled, press, hover or normal.""" if self.disabled: diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index e79b198140..16f8e932b5 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -273,6 +273,9 @@ def update_font( font_bold = bold if bold is not None else self._label.bold font_italic = italic if italic is not None else self._label.italic + # ensure type of font_color, label will allways be a color + font_color = Color.from_iterable(font_color) + # Check if values actually changed, if then update and trigger render font_name_changed = self._label.font_name != font_name font_size_changed = self._label.font_size != font_size From 78061b968699a2ee1e69d8d4911c5467de99b159 Mon Sep 17 00:00:00 2001 From: "A. J. Andrews" <86714785+DragonMoffon@users.noreply.github.com> Date: Mon, 10 Mar 2025 08:52:53 +1300 Subject: [PATCH 058/279] Updating how arcade dispatched 'on_draw' and 'on_update' events (#2587) * Updating how arcade dispatched events Based on work by Cspotcode and Einarf. Hopefully better commented, and does not disregard the difference between draw rate and update rate. * Clean up window_commands * Docs + changelog etc * Reformat --------- Co-authored-by: Einar Forselv --- CHANGELOG.md | 520 ++++++++++++++------------- arcade/application.py | 98 +++-- arcade/window_commands.py | 52 +-- doc/programming_guide/event_loop.rst | 33 +- 4 files changed, 370 insertions(+), 333 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2537aaf645..0f33b004d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,19 +3,24 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - -## Version 3.0.1 (unreleased) +## Version 3.0.2 (unreleased) ### Improvements - `UIWidget.with_background` now accepts a tuple for color +- Many docstring and documentation fixes ### Bug Fixes +- Major fix for smooth frame rates. Due to a weakness with how certain + events are handled in the pyglet clock we now trigger `on_update`, `on_draw`, + and `on_fixed_update` from a single event in pyglet and dispatch the events + manually in Arcade. This should greatly improve the smoothness of scrolling + and animations. A new limitation is that the draw rate cannot be faster than + the update rate. - Fixed division error in box layout algorithm - Fix example added buttons to multiple layouts - ## Version 3.0.1 ### Bug Fixes @@ -23,6 +28,11 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fixed blurriness in `UIWidget` text during interaction - Resolved issue with `UIDropdown.hide()` when no parent exists - Corrected event order bug in `UIWidget` +- Clarify error message when the texture atlas is full. + The default texture atlas has a limit of 8192 textures. + This can be increased by the user if needed or the user + can create multiple texture atlases. +- Many docstring and documentation fixes ### Enhancements @@ -33,232 +43,232 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. Version 3.0.0 is a major update to Arcade. It breaks compatibility with the 2.6 API. ### Breaking Changes + These are the breaking API changes. Use this as a quick reference for updating 2.6 code. You can find more details in later sections. Lots of behavior has changed even if the interface hasn't. If you are porting old code, read through these logs thoroughly. -* Dropped Python 3.8 support completely. -* Texture management has completely changed in 3.0. In the past, we +- Dropped Python 3.8 support completely. +- Texture management has completely changed in 3.0. In the past, we cached everything, which caused issues for larger projects that needed memory management. Functions like `Arcade.load_texture` no longer cache textures. -* Removed the poorly named `Window.set_viewport` and `set_viewport` methods. +- Removed the poorly named `Window.set_viewport` and `set_viewport` methods. `Camera2D` has completely superseded their functionality. -* Fixed `ArcadeContext` assuming that the projection and view matrices were aligned to the xy-plane and Orthographic. It is now safe to use full 3D matrices with Arcade. -* The `Sprite` initializer has been simplified. It's no longer possible to +- Fixed `ArcadeContext` assuming that the projection and view matrices were aligned to the xy-plane and Orthographic. It is now safe to use full 3D matrices with Arcade. +- The `Sprite` initializer has been simplified. It's no longer possible to slice or transform textures through parameters in the sprite initializer. Use the `Texture` class to manipulate the sprite's texture. It supports transforms like rotating, scaling, flipping, and slicing. -* `Sprite.angle` has changed to clockwise. -* `Sprite.on_update` has been removed. Use `Sprite.update` instead. It has a `delta_time` parameter and accepts both `*args` and `**kwargs` +- `Sprite.angle` has changed to clockwise. +- `Sprite.on_update` has been removed. Use `Sprite.update` instead. It has a `delta_time` parameter and accepts both `*args` and `**kwargs` to support custom parameters. The same applies to `SpriteList`. -* `Sprite.draw` has been removed. Use either `arcade.draw.draw_sprite` +- `Sprite.draw` has been removed. Use either `arcade.draw.draw_sprite` or an `arcade.SpriteList`. -* Removed `Sprite.face_point` and `Sprite.collision_radius`. -* The deprecated `update()` function has been removed from the +- Removed `Sprite.face_point` and `Sprite.collision_radius`. +- The deprecated `update()` function has been removed from the `arcade.Window`, `arcade.View`, `arcade.Section`, and `arcade.SectionManager` classes. Instead, please use the `arcade.Window.on_update()` function. It works the same as the `update` function but has a "delta_time" parameter, which holds the time in seconds since the last update. -* The `update_rate` parameter of `arcade.Window` can no longer be set to `None`. It previously defaulted to `1 / 60` but could be set to `None`. The default is still the same, but setting it to None will not do anything. -* Sprites created from the `~arcade.tilemap.TileMap` class would previously set a key in the `Sprite.properties` dictionary named `type`. This key has been renamed to "class " in keeping with Tiled renaming the key. -* The `arcade.text_pillow` and `arcade.text_pyglet` modules have been completely removed. The Pillow implementation is gone, and the Pyglet version has been renamed `arcade.text`. -* Due to the above change. `arcade.create_text_sprite` has been reworked to use the Pyglet-based text system. It has no API-breaking changes, but the underlying functionality has changed drastically. It may be worth rechecking the docs if you use this function. The main concern is if you are using a custom `arcade.TextureAtlas`. -* Buffered shapes (shape list items) have been moved to their sub-module. -* The `use_spatial_hash` parameter for `SpriteList` and `TileMap` is now a `bool` instead of `Optional[bool]` -* `arcade.draw_text()` and `arcade.text.Text` arguments have changed. `x` and `y ` have replaced `start_x` and `start_y`. `align` no longer interferes with `multiline`. -* Moved or removed items from `arcade.util`: - * Removed: - * `arcade.util.generate_uuid_from_kwargs` - * `arcade.util._Vec2`: - * This was an internal class as indicated by the `_` prefix - * It was an old version of pyglet's `pyglet.math.Vec2` - * Arcade code now uses `pyglet.math.Vec2` directly - * Moved to `arcade.math`: - * `arcade.util.rand_in_circle` is now: - * located at `arcade.math.rand_in_circle` - * better at returning an even distribution of points [PR2426](https://github.com/pythonarcade/arcade/pull/2426) (remove any `math.sqrt` wrapping it) - * `arcade.util.rand_on_circle` is now `arcade.math.rand_on_circle` - * `arcade.util.lerp` is now: - * located at `arcade.math.lerp` - * compatible with any type which implements numerical `+`, `-`, and `*` operators - * NOTE: lerping vectors may be more efficient when using dedicated functions and methods: - * When lerping `pylget.math.Vec2`, use one of: - * `pyglet.math.Vec2`'s [built in `lerp` method](https://pyglet.readthedocs.io/en/development/modules/math.html#pyglet.math.Vec2.lerp) - * `arcade.math.lerp_2d` for general `tuple` compatibility - * When lerping `pylget.math.Vec3`, use one of: - * `pyglet.math.Vec3`'s [built in `lerp` method](https://pyglet.readthedocs.io/en/development/modules/math.html#pyglet.math.Vec3.lerp) - * `arcade.math.lerp_2d` for general `tuple` compatibility - * `arcade.util.lerp_vec` is now `arcade.math.lerp_2d` - * `arcade.util.rand_in_rect` is now `arcade.math.rand_in_rect` - * `arcade.util.rand_on_line` is now `arcade.math.rand_on_line` - * `arcade.util.rand_angle_360_deg` is now `arcade.math.rand_angle_360_deg` - * `arcade.util.rand_angle_spread_deg` is now `arcade.math.rand_angle_spread_deg` - * `arcade.util.rand_spread_deg` is now `arcade.math.rand_spread_deg` - * `arcade.util.rand_magnitude` is now `arcade.math.rand_magnitude` - - -* GUI - * Removed `arcade.gui.widgets.UIWrapper`. It is now a part of `arcade.gui.widgets.UILayout`. - * Removed `arcade.gui.widgets.UIBorder`. It is now a part of `arcade.gui.widgets.UIWidget`. - * Removed `arcade.gui.widgets.UIPadding`. It is now a part of `arcade.gui.widgets.UIWidget`. - * Removed `arcade.gui.widgets.UITexturePane`. It is now a part of `arcade.gui.widgets.UIWidget`. - * Removed `arcade.gui.widgets.UIAnchorWidget` has been replaced by `arcade.gui.widgets.UIAnchorLayout`. -* Resources - * Removed unused resources from `resources/gui_basic_assets`. - * `items/shield_gold.png` - * `items/sword_gold.png` - * `slider_thumb.png` - * `slider_track.png` - * `toggle/switch_green.png` - * `toggle/switch_red.png` +- The `update_rate` parameter of `arcade.Window` can no longer be set to `None`. It previously defaulted to `1 / 60` but could be set to `None`. The default is still the same, but setting it to None will not do anything. +- Sprites created from the `~arcade.tilemap.TileMap` class would previously set a key in the `Sprite.properties` dictionary named `type`. This key has been renamed to "class " in keeping with Tiled renaming the key. +- The `arcade.text_pillow` and `arcade.text_pyglet` modules have been completely removed. The Pillow implementation is gone, and the Pyglet version has been renamed `arcade.text`. +- Due to the above change. `arcade.create_text_sprite` has been reworked to use the Pyglet-based text system. It has no API-breaking changes, but the underlying functionality has changed drastically. It may be worth rechecking the docs if you use this function. The main concern is if you are using a custom `arcade.TextureAtlas`. +- Buffered shapes (shape list items) have been moved to their sub-module. +- The `use_spatial_hash` parameter for `SpriteList` and `TileMap` is now a `bool` instead of `Optional[bool]` +- `arcade.draw_text()` and `arcade.text.Text` arguments have changed. `x` and `y` have replaced `start_x` and `start_y`. `align` no longer interferes with `multiline`. +- Moved or removed items from `arcade.util`: + - Removed: + - `arcade.util.generate_uuid_from_kwargs` + - `arcade.util._Vec2`: + - This was an internal class as indicated by the `_` prefix + - It was an old version of pyglet's `pyglet.math.Vec2` + - Arcade code now uses `pyglet.math.Vec2` directly + - Moved to `arcade.math`: + - `arcade.util.rand_in_circle` is now: + - located at `arcade.math.rand_in_circle` + - better at returning an even distribution of points [PR2426](https://github.com/pythonarcade/arcade/pull/2426) (remove any `math.sqrt` wrapping it) + - `arcade.util.rand_on_circle` is now `arcade.math.rand_on_circle` + - `arcade.util.lerp` is now: + - located at `arcade.math.lerp` + - compatible with any type which implements numerical `+`, `-`, and `*` operators + - NOTE: lerping vectors may be more efficient when using dedicated functions and methods: + - When lerping `pylget.math.Vec2`, use one of: + - `pyglet.math.Vec2`'s [built in `lerp` method](https://pyglet.readthedocs.io/en/development/modules/math.html#pyglet.math.Vec2.lerp) + - `arcade.math.lerp_2d` for general `tuple` compatibility + - When lerping `pylget.math.Vec3`, use one of: + - `pyglet.math.Vec3`'s [built in `lerp` method](https://pyglet.readthedocs.io/en/development/modules/math.html#pyglet.math.Vec3.lerp) + - `arcade.math.lerp_2d` for general `tuple` compatibility + - `arcade.util.lerp_vec` is now `arcade.math.lerp_2d` + - `arcade.util.rand_in_rect` is now `arcade.math.rand_in_rect` + - `arcade.util.rand_on_line` is now `arcade.math.rand_on_line` + - `arcade.util.rand_angle_360_deg` is now `arcade.math.rand_angle_360_deg` + - `arcade.util.rand_angle_spread_deg` is now `arcade.math.rand_angle_spread_deg` + - `arcade.util.rand_spread_deg` is now `arcade.math.rand_spread_deg` + - `arcade.util.rand_magnitude` is now `arcade.math.rand_magnitude` + +- GUI + - Removed `arcade.gui.widgets.UIWrapper`. It is now a part of `arcade.gui.widgets.UILayout`. + - Removed `arcade.gui.widgets.UIBorder`. It is now a part of `arcade.gui.widgets.UIWidget`. + - Removed `arcade.gui.widgets.UIPadding`. It is now a part of `arcade.gui.widgets.UIWidget`. + - Removed `arcade.gui.widgets.UITexturePane`. It is now a part of `arcade.gui.widgets.UIWidget`. + - Removed `arcade.gui.widgets.UIAnchorWidget` has been replaced by `arcade.gui.widgets.UIAnchorLayout`. +- Resources + - Removed unused resources from `resources/gui_basic_assets`. + - `items/shield_gold.png` + - `items/sword_gold.png` + - `slider_thumb.png` + - `slider_track.png` + - `toggle/switch_green.png` + - `toggle/switch_red.png` ### Featured Updates -* The texture atlas has been heavily reworked to be more efficient. +- The texture atlas has been heavily reworked to be more efficient. -* Alpha blending (handling of transparency) is no longer globally enabled but instead enabled when needed. draw functions and objects like +- Alpha blending (handling of transparency) is no longer globally enabled but instead enabled when needed. draw functions and objects like `SpriteList` and `ShapeElementList` have new arguments to toggle blending states. Blending states are now reset after drawing. -* Arcade now supports OpenGL ES 3.1/3.2 and has been +- Arcade now supports OpenGL ES 3.1/3.2 and has been tested on the Raspberry Pi 4 and 5. Any model using the Cortex-A72 or Cortex-A76 CPU should work. Use images from 2024 or later for best results. -* Arcade now supports freely mixing Pyglet and Arcade code. You can now freely use Pyglet batches and Labels when preferred over Arcade's types. Note that texture/image handling is still a separate system. -* A fully functioning 2D camera allows for moving, rotating, and zooming and works with Arcade and Pyglet. -* Added a new `GLOBAL_CLOCK` and `GLOBAL_FIXED_CLOCK` accessable from `arcade.clock`. which provides global access to elapsed time, number of frames, and the last delta_time. +- Arcade now supports freely mixing Pyglet and Arcade code. You can now freely use Pyglet batches and Labels when preferred over Arcade's types. Note that texture/image handling is still a separate system. +- A fully functioning 2D camera allows for moving, rotating, and zooming and works with Arcade and Pyglet. +- Added a new `GLOBAL_CLOCK` and `GLOBAL_FIXED_CLOCK` accessable from `arcade.clock`. which provides global access to elapsed time, number of frames, and the last delta_time. ### Window and View -* Removed the `update` function in favor of `arcade.Window.on_update()`. -* The `update_rate` parameter in the constructor can no longer be set to `None` and must be a float. -* A new `draw_rate` parameter in `arcade.Window.__init__`, controls the call interval of `arcade.Window.on_draw(). It is now possible to separate the draw and update speeds of Arcade windows. Keeping `draw_rate` close to the refresh rate of the user's monitor while setting `update_rate` to a much shorter interval can greatly improve the perceived smoothness of your application. -* `open_window()` now accepts `**kwargs` to pass additional parameters to the `arcade.Window` constructor. -* `arcade.View` - * Removal of the ``update`` function in favor of `arcade.View.on_update()`. -* `arcade.Section` and `arcade.SectionManager` - * Removal of the ``update`` function in favor of `arcade.Section.on_update()`. -* Added a whole new `on_fixed_update` method, which is called with a regular delta time - * Is also available for `arcade. View`. - * Control the rate of fixed updates with the `fixed_rate`. +- Removed the `update` function in favor of `arcade.Window.on_update()`. +- The `update_rate` parameter in the constructor can no longer be set to `None` and must be a float. +- A new `draw_rate` parameter in `arcade.Window.__init__`, controls the call interval of `arcade.Window.on_draw(). It is now possible to separate the draw and update speeds of Arcade windows. Keeping`draw_rate` close to the refresh rate of the user's monitor while setting `update_rate` to a much shorter interval can greatly improve the perceived smoothness of your application. +- `open_window()` now accepts `**kwargs` to pass additional parameters to the `arcade.Window` constructor. +- `arcade.View` + - Removal of the ``update`` function in favor of `arcade.View.on_update()`. +- `arcade.Section` and `arcade.SectionManager` + - Removal of the ``update`` function in favor of `arcade.Section.on_update()`. +- Added a whole new `on_fixed_update` method, which is called with a regular delta time + - Is also available for `arcade. View`. + - Control the rate of fixed updates with the `fixed_rate`. parameter in `Window.__init__`. - * Control the max number of fixed updates per regular update with the `fixed_rate_cap` + - Control the max number of fixed updates per regular update with the `fixed_rate_cap` parameter in `Window.__init__`. - * See the updated event loop docs for an in-depth explanation of ``on_fixed_update`` vs. ``on_update``. +- See the updated event loop docs for an in-depth explanation of ``on_fixed_update`` vs. ``on_update``. ### Camera -* Created `arcade.camera.Camera2D`, which allows for easy manipulation of Arcade and Pyglet's rendering matrices. -* Created `arcade.camera.PerspectiveProjector` and `arcade.camera.OrthographicProjector`. +- Created `arcade.camera.Camera2D`, which allows for easy manipulation of Arcade and Pyglet's rendering matrices. +- Created `arcade.camera.PerspectiveProjector` and `arcade.camera.OrthographicProjector`. Which can manipulate the matrices in 3D space. -* Created methods to rotate and move cameras. -* Created methods to generate view and projection matrices needed by projector objects. -* Created static projector classes to set the matrices with constant values. -* Added a default camera that automatically adjusts to the active render target. -* Added a camera shake object that makes adding a camera shake to a game easy. -* All `Projector` classes provide methods to project to and from the screen and world coordinates. +- Created methods to rotate and move cameras. +- Created methods to generate view and projection matrices needed by projector objects. +- Created static projector classes to set the matrices with constant values. +- Added a default camera that automatically adjusts to the active render target. +- Added a camera shake object that makes adding a camera shake to a game easy. +- All `Projector` classes provide methods to project to and from the screen and world coordinates. ### Textures -* `arcade.load_texture` has been simplified to load the entire image. Use `arcade.load_spritesheet` to use better versions of the old arguments. -* `arcade.get_default_texture` and `arcade.get_default_image` are new methods to give `arcade.Sprite` their default texture. -* Added `sync_texture_image` to the `DefaultTextureAtlas` method to sync the texture in the atlas back into the internal pillow image in the `arcade.Texture`. -* DefaultTextureAtlas: Added `get_texture_image` method to get pixel data of a texture in the atlas as a pillow image. +- `arcade.load_texture` has been simplified to load the entire image. Use `arcade.load_spritesheet` to use better versions of the old arguments. +- `arcade.get_default_texture` and `arcade.get_default_image` are new methods to give `arcade.Sprite` their default texture. +- Added `sync_texture_image` to the `DefaultTextureAtlas` method to sync the texture in the atlas back into the internal pillow image in the `arcade.Texture`. +- DefaultTextureAtlas: Added `get_texture_image` method to get pixel data of a texture in the atlas as a pillow image. ### GUI -* `arcade.gui.widgets.UIWidget` - * Supports padding, border, and background (color or texture). - * Visibility: visible=False will prevent widget rendering. It will also +- `arcade.gui.widgets.UIWidget` + - Supports padding, border, and background (color or texture). + - Visibility: visible=False will prevent widget rendering. It will also not receive any UI events. - * Dropped `arcade.gui.widget.UIWidget.with_space_around`. - * `UIWidget.with_` methods no longer wrap the widget. They only change the attributes. - * Fixed a blending issue when rendering the GUI surface to the screen. - * Now supports nine-patch background textures. - * General performance improvements. - * Some attributes were removed from the public interface; use `UIWidget.with_` methods instead. - * `UIWidget.border_width` - * `UIWidget.border_color` - * `UIWidget.bg_color` - * `UIWidget.bg_texture` - * `UIWidget.padding_top` - * `UIWidget.padding_right` - * `UIWidget.padding_bottom` - * `UIWidget.padding_left` - * Update and add example code. - * Iterable (providing direct children) - -* Updated widgets - * `arcade.gui.widgets.text.UIInputText` emits an `on_change` event. - * `arcade.gui.widgets.slider.UITextureSlider` texture names changed to fit the naming pattern. - -* New widgets: - * `arcade.gui.widgets.dropdown.UIDropdown` - * `arcade.gui.widgets.image.UIImage` - * `arcade.gui.widgets.slider.UISlider` - * `arcade.gui.widgets.constructs.UIButtonRow` + - Dropped `arcade.gui.widget.UIWidget.with_space_around`. + - `UIWidget.with_` methods no longer wrap the widget. They only change the attributes. + - Fixed a blending issue when rendering the GUI surface to the screen. + - Now supports nine-patch background textures. + - General performance improvements. + - Some attributes were removed from the public interface; use `UIWidget.with_` methods instead. + - `UIWidget.border_width` + - `UIWidget.border_color` + - `UIWidget.bg_color` + - `UIWidget.bg_texture` + - `UIWidget.padding_top` + - `UIWidget.padding_right` + - `UIWidget.padding_bottom` + - `UIWidget.padding_left` + - Update and add example code. + - Iterable (providing direct children) + +- Updated widgets + - `arcade.gui.widgets.text.UIInputText` emits an `on_change` event. + - `arcade.gui.widgets.slider.UITextureSlider` texture names changed to fit the naming pattern. + +- New widgets: + - `arcade.gui.widgets.dropdown.UIDropdown` + - `arcade.gui.widgets.image.UIImage` + - `arcade.gui.widgets.slider.UISlider` + - `arcade.gui.widgets.constructs.UIButtonRow` ([PR1580](https://github.com/pythonarcade/arcade/pull/1580)) -* `arcade.gui.UIInteractiveWidget` only reacts to left mouse button events. +- `arcade.gui.UIInteractiveWidget` only reacts to left mouse button events. -* Arcade `arcade.gui.property.Property`: - * Properties are observable attributes (supports primitive, list, and dict). A Listener can be bound with `arcade.gui.property.bind`. -* All `arcade.gui.UILayout`s now support `size_hint`, `size_hint_min`, and `size_hint_max`. - * `arcade.gui.UIBoxLayout` - * `arcade.gui.UIAnchorLayout` - * `Arcade.gui.UIGridLayout` [PR1478](https://github.com/pythonarcade/arcade/pull/1478) +- Arcade `arcade.gui.property.Property`: + - Properties are observable attributes (supports primitive, list, and dict). A Listener can be bound with `arcade.gui.property.bind`. +- All `arcade.gui.UILayout`s now support `size_hint`, `size_hint_min`, and `size_hint_max`. + - `arcade.gui.UIBoxLayout` + - `arcade.gui.UIAnchorLayout` + - `Arcade.gui.UIGridLayout` [PR1478](https://github.com/pythonarcade/arcade/pull/1478) -* Added color-consistent assets to `arcade.resources.gui_basic_assets`. -* Provide GUI-friendly color constants in `arcade.uicolor`. -* Replace deprecated usage of `arcade.draw_text`. +- Added color-consistent assets to `arcade.resources.gui_basic_assets`. +- Provide GUI-friendly color constants in `arcade.uicolor`. +- Replace deprecated usage of `arcade.draw_text`. ### Rect -* Added a `Rect` type, making working with axis-aligned rectangles easy. - * Provides functions to create a full `Rect` object from four values. - * Provides methods to move and scale the `Rect`. - * Provides methods to compare against the `Rect` with 2D points and other `Rects`. -* Added `AnchorPoint` helpers and aliases for `Vec2`s in the range (0 - 1). -* Added several helper methods for creating `Rect` objects. - * `LRBT(left, right, bottom, top)` - * `LBWH(left, bottom, width, height)` - * `XYWH(x, y, width, height, anchor = AnchorPoint.CENTER)` - * `XYRR(center_x, center_y, half_width, half_height)` (this is mostly used for GL.) - * `Viewport(left, bottom, width, height)` (where all inputs are `int`s.) -* Several properties in the library now return a `Rect`: - * `Window.rect` - * `BasicSprite.rect` - * `OrthographicProjectionData.rect` - * `UIWidget.rect` - * `Section.rect` -* The drawing functions `draw_rect_filled()` and `draw_rect_outline()` can be used to draw a `Rect` directly. +- Added a `Rect` type, making working with axis-aligned rectangles easy. + - Provides functions to create a full `Rect` object from four values. + - Provides methods to move and scale the `Rect`. + - Provides methods to compare against the `Rect` with 2D points and other `Rects`. +- Added `AnchorPoint` helpers and aliases for `Vec2`s in the range (0 - 1). +- Added several helper methods for creating `Rect` objects. + - `LRBT(left, right, bottom, top)` + - `LBWH(left, bottom, width, height)` + - `XYWH(x, y, width, height, anchor = AnchorPoint.CENTER)` + - `XYRR(center_x, center_y, half_width, half_height)` (this is mostly used for GL.) + - `Viewport(left, bottom, width, height)` (where all inputs are `int`s.) +- Several properties in the library now return a `Rect`: + - `Window.rect` + - `BasicSprite.rect` + - `OrthographicProjectionData.rect` + - `UIWidget.rect` + - `Section.rect` +- The drawing functions `draw_rect_filled()` and `draw_rect_outline()` can be used to draw a `Rect` directly. ### Misc Changes -* `arcade.experimental` has been split into two submodules, `experimental` and `future`. - * `future` includes all incomplete features we intend to include in Arcade eventually - * `experimental` is any interesting code that may not end up as Arcade features. -* `arcade.color_from_hex_string` changed to follow the CSS hex string standard. -* Made Pyglet's math classes accessible within Arcade. -* Arcade's utility math functions have more robust typing. -* Added `Point`, `Point2`, `Point3` type aliases for tuples and vectors. -* Added `Sequence` types for all three `Point` aliases. -* Added a `Color` object with a plethora of useful methods. -* Windows Text glyphs are now created with DirectWrite instead of GDI. -* Removal of various deprecated functions and parameters. -* OpenGL matrix uniforms are now supported properly -* OpenGL uniforms now accept buffer protocol objects -* Sprite's visible flag is now handled correctly -* `Window.run()` now supports a view argument. -* OpenGL examples moved to _`examples/gl `_ +- `arcade.experimental` has been split into two submodules, `experimental` and `future`. + - `future` includes all incomplete features we intend to include in Arcade eventually + - `experimental` is any interesting code that may not end up as Arcade features. +- `arcade.color_from_hex_string` changed to follow the CSS hex string standard. +- Made Pyglet's math classes accessible within Arcade. +- Arcade's utility math functions have more robust typing. +- Added `Point`, `Point2`, `Point3` type aliases for tuples and vectors. +- Added `Sequence` types for all three `Point` aliases. +- Added a `Color` object with a plethora of useful methods. +- Windows Text glyphs are now created with DirectWrite instead of GDI. +- Removal of various deprecated functions and parameters. +- OpenGL matrix uniforms are now supported properly +- OpenGL uniforms now accept buffer protocol objects +- Sprite's visible flag is now handled correctly +- `Window.run()` now supports a view argument. +- OpenGL examples moved to _`examples/gl `_ from _"experiments/examples"_ ### Sprites -* Created `BasicSprite`, the absolute minimum required for an Arcade sprite most users will do well sticking with `Sprite`. -* `Sprite.draw` has been completely removed. It was a wasteful and slow way to render a sprite. Use an `arcade.SpriteList` or `arcade.draw.draw_sprite`. -* `Sprite.visible` no longer overrides the sprite's alpha, allowing for toggling transparent sprites. -* `Sprite.face_towards` has been removed as it did not behave as expected and is not strictly for sprites. -* `Sprite.collision_radius` has been removed as it is no longer used in collision checking. Sprites now only rely on their hitbox. -* The `arcade.Sprite.__init__` has been changed to remove all references to texture loading. Use `arcade.load_texture` and `arcade.load_spritesheet` for more complex behavior. -* `arcade.Sprite.angle` now rotates clockwise following standard game behavior. It may break common linear algebra methods, but reversing the resulting angles is easy. +- Created `BasicSprite`, the absolute minimum required for an Arcade sprite most users will do well sticking with `Sprite`. +- `Sprite.draw` has been completely removed. It was a wasteful and slow way to render a sprite. Use an `arcade.SpriteList` or `arcade.draw.draw_sprite`. +- `Sprite.visible` no longer overrides the sprite's alpha, allowing for toggling transparent sprites. +- `Sprite.face_towards` has been removed as it did not behave as expected and is not strictly for sprites. +- `Sprite.collision_radius` has been removed as it is no longer used in collision checking. Sprites now only rely on their hitbox. +- The `arcade.Sprite.__init__` has been changed to remove all references to texture loading. Use `arcade.load_texture` and `arcade.load_spritesheet` for more complex behavior. +- `arcade.Sprite.angle` now rotates clockwise following standard game behavior. It may break common linear algebra methods, but reversing the resulting angles is easy. ### Controller Input @@ -267,120 +277,122 @@ However, most people can treat it as depreciated. It is an alias to Pyglet's joy ### Text -* Complete removal of the old PIL-based text system. In 2.6, we switched to the newer Pyglet-based system, but there were still remnants of the PIL implementation—namely, the `arcade.create_text_sprite` function. There's no API breaking change, but if you are using the function, it would be worth reading the new docs, as there are some different considerations when using a custom `arcade.TextureAtlas`. This function is faster than the old PIL implementation. Texture generation happens almost entirely on the GPU now. -* The `arcade.text_pillow` module no longer exists. -* `arcade.text_pyglet` has been renamed `arcade.text`. -* `arcade.draw_text` and `arcade.Text` now accept a `z` parameter that defaults to 0. Previous text versions had the same default. +- Complete removal of the old PIL-based text system. In 2.6, we switched to the newer Pyglet-based system, but there were still remnants of the PIL implementation—namely, the `arcade.create_text_sprite` function. There's no API breaking change, but if you are using the function, it would be worth reading the new docs, as there are some different considerations when using a custom `arcade.TextureAtlas`. This function is faster than the old PIL implementation. Texture generation happens almost entirely on the GPU now. +- The `arcade.text_pillow` module no longer exists. +- `arcade.text_pyglet` has been renamed `arcade.text`. +- `arcade.draw_text` and `arcade.Text` now accept a `z` parameter that defaults to 0. Previous text versions had the same default. ### `arcade.draw_*` -* `arcade.draw_commands` has been renamed `arcade.draw`. -* Added `arcade.draw.draw_lbwh_rectangle_textured` which replaces +- `arcade.draw_commands` has been renamed `arcade.draw`. +- Added `arcade.draw.draw_lbwh_rectangle_textured` which replaces the now-deprecated `arcade.draw_commands.draw_lrwh_rectangle_textured`. Usage has stayed the same as it was misnamed. ### OpenGL -* Support for OpenGL ES 3.1 and 3.2. 3.2 is fully supported, and 3.1 is only supported if the driver provides the `EXT_geometry_shader` extension. It is part of the minimum spec in 3.2, so it is guaranteed to be there. Arcade only needs this extension to function with 3.1. - * For example, the Raspberry Pi 4/5 only supports OpenGL ES 3.1 but provides the extension, making it fully compatible with Arcade. -* Textures now support immutable storage for OpenGL ES compatibility. -* Arcade is now using Pyglet's projection and view matrix. +- Support for OpenGL ES 3.1 and 3.2. 3.2 is fully supported, and 3.1 is only supported if the driver provides the `EXT_geometry_shader` extension. It is part of the minimum spec in 3.2, so it is guaranteed to be there. Arcade only needs this extension to function with 3.1. +- For example, the Raspberry Pi 4/5 only supports OpenGL ES 3.1 but provides the extension, making it fully compatible with Arcade. +- Textures now support immutable storage for OpenGL ES compatibility. +- Arcade is now using Pyglet's projection and view matrix. All functions setting matrices will update the Pyglet window's `view` and `projection` attributes. Arcade shaders are also using Pyglet's `WindowBlock` UBO. -* Uniforms are now set using `glProgramUniform` instead of `glUniform` when the extension is available, improving performance. -* Fixed many implicit type conversions in the shader code for broader support. -* Added `front_face` property on the context for configuring the winding order of triangles. -* Added `cull_face` property to the context to configure what triangle faces to cull. -* Added support for bindless textures. -* Added support for 64-bit integer uniforms. -* Added support for 64-bit float uniforms. +- Uniforms are now set using `glProgramUniform` instead of `glUniform` when the extension is available, improving performance. +- Fixed many implicit type conversions in the shader code for broader support. +- Added `front_face` property on the context for configuring the winding order of triangles. +- Added `cull_face` property to the context to configure what triangle faces to cull. +- Added support for bindless textures. +- Added support for 64-bit integer uniforms. +- Added support for 64-bit float uniforms. ### TileMap -* Now supports tiles defined as a sub-rectangle of an image. See [Tiled 1.9 Release Notes](https://www.mapeditor.org/2022/06/25/tiled-1-9-released.html) for more information on this feature. -* Changed the `Sprite.properties` key "type" to "class" to stay in line with Tiled's API. -* You can now define a custom texture atlas for SpriteLists created in a TileMap. You can provide a default with the `texture_atlas` parameter of the `arcade.tilemap.Tilemap` and `arcade.tilemap.load_tilemap`. The new `texture_atlas` key of the `layer_options` dict lets you control it per layer. The global `TextureAtlas` will be used by default (This is how it works pre-Arcade 3.0). -* Fixed animated tiles from sprite sheets. +- Now supports tiles defined as a sub-rectangle of an image. See [Tiled 1.9 Release Notes](https://www.mapeditor.org/2022/06/25/tiled-1-9-released.html) for more information on this feature. +- Changed the `Sprite.properties` key "type" to "class" to stay in line with Tiled's API. +- You can now define a custom texture atlas for SpriteLists created in a TileMap. You can provide a default with the `texture_atlas` parameter of the `arcade.tilemap.Tilemap` and `arcade.tilemap.load_tilemap`. The new `texture_atlas` key of the `layer_options` dict lets you control it per layer. The global `TextureAtlas` will be used by default (This is how it works pre-Arcade 3.0). +- Fixed animated tiles from sprite sheets. ### Collision Detection -* Collision detection is now even faster. -* Remove Shapely for collision detection, as Python 3.11+ is faster without it. +- Collision detection is now even faster. +- Remove Shapely for collision detection, as Python 3.11+ is faster without it. ### Shape Lists -* New buffered `Arcade.create_triangles_strip_filled_with_colors`. -* `arcade.shape_list` now contains all items that can rendered using an `arcade.ShapeElementList`. +- New buffered `Arcade.create_triangles_strip_filled_with_colors`. +- `arcade.shape_list` now contains all items that can rendered using an `arcade.ShapeElementList`. ### Documentation -* Example code page has been reorganized. -* [CONTRIBUTING.md](https://github.com/pythonarcade/arcade/blob/development/CONTRIBUTING.md) has been updated. -* Improved `background_parallax` example. -* More detailed information on how Arcade's event loop works. -* The platformer tutorial has been overhauled. +- Example code page has been reorganized. +- [CONTRIBUTING.md](https://github.com/pythonarcade/arcade/blob/development/CONTRIBUTING.md) has been updated. +- Improved `background_parallax` example. +- More detailed information on how Arcade's event loop works. +- The platformer tutorial has been overhauled. ### Future -* These features are all in active development, and their API can change anytime. Feedback is always appreciated. -* Started on a system for drawing large background textures with parallax scrolling. These don't use an `arcade.TextureAtlas` so they aren't batched, preventing your Atlas' from being filled with massive images. -* Started on an event-based input system, which includes improved Enums for key, mouse, and controller inputs. Using the InputManager, you can define custom actions that can rebound at run time and have multiple valid keys. -* Added a method to bootstrap `arcade.clock.Clock`, adding functionality to add sub-clocks that their parent will tick. This makes it much safer to manipulate the time of particular game objects. -* A new subclass of `arcade.BasicSprite` that used `pyglet.math.Vec2` for most of its properties. It has a heavy performance hit but is much nicer to work with. -* The experimental lighting features have been promoted to the future, but their implementation is very volatile. If you have ideas for what you'd like from a lighting module, please share them on Discord. +- These features are all in active development, and their API can change anytime. Feedback is always appreciated. +- Started on a system for drawing large background textures with parallax scrolling. These don't use an `arcade.TextureAtlas` so they aren't batched, preventing your Atlas' from being filled with massive images. +- Started on an event-based input system, which includes improved Enums for key, mouse, and controller inputs. Using the InputManager, you can define custom actions that can rebound at run time and have multiple valid keys. +- Added a method to bootstrap `arcade.clock.Clock`, adding functionality to add sub-clocks that their parent will tick. This makes it much safer to manipulate the time of particular game objects. +- A new subclass of `arcade.BasicSprite` that used `pyglet.math.Vec2` for most of its properties. It has a heavy performance hit but is much nicer to work with. +- The experimental lighting features have been promoted to the future, but their implementation is very volatile. If you have ideas for what you'd like from a lighting module, please share them on Discord. ### Contributors Contributing to a release comes in many forms. It can be code, documentation, testing, or providing feedback. It's hard to keep track of all the people involved in a release, but we want to thank everyone who has helped in any shape or form. We appreciate all of you! -#### The Arcade Team: +#### The Arcade Team -* [Andrew Bradley](https://github.com/cspotcode) -* [Alejandro Casanovas](https://github.com/janscas) -* [Cleptomania](https://github.com/Cleptomania) -* [DigiDuncan](https://github.com/DigiDuncan) -* [DragonMoffon](https://github.com/DragonMoffon) -* [Einar Forselv](https://github.com/einarf) -* [Maic Siemering](https://github.com/eruvanos) -* [pushfoo](https://github.com/pushfoo) -* [pvcraven](https://github.com/pvcraven) +- [Andrew Bradley](https://github.com/cspotcode) +- [Alejandro Casanovas](https://github.com/janscas) +- [Cleptomania](https://github.com/Cleptomania) +- [DigiDuncan](https://github.com/DigiDuncan) +- [DragonMoffon](https://github.com/DragonMoffon) +- [Einar Forselv](https://github.com/einarf) +- [Maic Siemering](https://github.com/eruvanos) +- [pushfoo](https://github.com/pushfoo) +- [pvcraven](https://github.com/pvcraven) We would also like to thank the contributors who spent their valuable time solving issues, squashing bugs, and writing documentation. We appreciate your help; you helped get 3.0 out the door! -#### Notable contributors: -* [DarkLight1337](https://github.com/DarkLight1337) helped the team untangle type annotation issues for cameras -* [Mohammad Ibrahim](https://github.com/Ibrahim2750mi) was a massive help with the GUI and various other parts of the library. -* [ryyst](https://github.com/ryyst) completely revitalized the Arcade Docs. +#### Notable contributors + +- [DarkLight1337](https://github.com/DarkLight1337) helped the team untangle type annotation issues for cameras + +- [Mohammad Ibrahim](https://github.com/Ibrahim2750mi) was a massive help with the GUI and various other parts of the library. +- [ryyst](https://github.com/ryyst) completely revitalized the Arcade Docs. #### Contributors -* [Aizen](https://github.com/feiyuhuahuo) -* [Aurelio Lopez](https://github.com/Aurelioghs) -* [BrettskiPy](https://github.com/BrettskiPy) -* [Brian Stormont](https://github.com/MostTornBrain) -* [cacheguy](https://github.com/cacheguy) -* [Code Apprentice](https://github.com/codeguru42) -* [Dominik](https://github.com/NotYou404) -* [Ethan Chan](https://github.com/eschan147) -* [FriendlyGecko](https://github.com/FriendlyGecko) -* [Grant Hur](https://github.com/gran4) -* [Ian Currie](https://github.com/iansedano) -* [Jack Ashwell](https://github.com/JackAshwell11) -* [kosvitko](https://github.com/kosvitko) -* [L Cai](https://github.com/lbcai) -* [Miles Curry](https://github.com/MiCurry) -* [MrWardKKHS](https://github.com/MrWardKKHS) -* [Natalie Fearnley](https://github.com/nfearnley) -* [Omar Mohammed](https://github.com/osm3000) -* [Raccoon](https://github.com/bandit-masked) -* [Raxeli1](https://github.com/Rapen765) -* [Rémi Vanicat](https://github.com/vanicat) -* [Rich Saupe](https://github.com/sabadam32) -* [Shadow](https://github.com/shadow7412) -* [Shivani Arbat](https://github.com/shivaniarbat) -* [Snipy7374](https://github.com/Snipy7374) -* [Tiffany Xiao](https://github.com/tiffanyxiao) -* [Wilson (Fengchi) Wang](https://github.com/FengchiW) +- [Aizen](https://github.com/feiyuhuahuo) +- [Aurelio Lopez](https://github.com/Aurelioghs) +- [BrettskiPy](https://github.com/BrettskiPy) +- [Brian Stormont](https://github.com/MostTornBrain) +- [cacheguy](https://github.com/cacheguy) +- [Code Apprentice](https://github.com/codeguru42) +- [Dominik](https://github.com/NotYou404) +- [Ethan Chan](https://github.com/eschan147) +- [FriendlyGecko](https://github.com/FriendlyGecko) +- [Grant Hur](https://github.com/gran4) +- [Ian Currie](https://github.com/iansedano) +- [Jack Ashwell](https://github.com/JackAshwell11) +- [kosvitko](https://github.com/kosvitko) +- [L Cai](https://github.com/lbcai) +- [Miles Curry](https://github.com/MiCurry) +- [MrWardKKHS](https://github.com/MrWardKKHS) +- [Natalie Fearnley](https://github.com/nfearnley) +- [Omar Mohammed](https://github.com/osm3000) +- [Raccoon](https://github.com/bandit-masked) +- [Raxeli1](https://github.com/Rapen765) +- [Rémi Vanicat](https://github.com/vanicat) +- [Rich Saupe](https://github.com/sabadam32) +- [Shadow](https://github.com/shadow7412) +- [Shivani Arbat](https://github.com/shivaniarbat) +- [Snipy7374](https://github.com/Snipy7374) +- [Tiffany Xiao](https://github.com/tiffanyxiao) +- [Wilson (Fengchi) Wang](https://github.com/FengchiW) Finally, thank you to the [Pyglet](https://github.com/pyglet/pyglet) team! Pyglet is the backbone of Arcade, and this library wouldn't be possible without them. -3.0.0 changes span from Dec 31, 2022 – Jan 25, 2025 (756 days!!) +3.0.0 changes span from Dec 31, 2022 – Jan 25, 2025 (756 days!!) diff --git a/arcade/application.py b/arcade/application.py index d86e702fc8..a0979daae7 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -248,7 +248,16 @@ def __init__( # that is done by the run() function. run() will pull this draw rate from # the Window and use it. Calls to set_draw_rate only need # to be done if changing it after the application has been started. - self._draw_rate = draw_rate + + # To ensure that draws are never de-synced from updates and wasted the draw rate + # is forced to be slower than or equal to the update rate. + # This works because pyglet ensures that a scheduled event takes as long or longer than the + # call rate, but never less. + assert update_rate <= draw_rate, ( + "An arcade window's draw rate cannot be faster than its update rate" + ) + self._draw_rate = max(update_rate, draw_rate) + self._accumulated_draw_time: float = 0.0 # Fixed rate cannot be changed post initialization as this throws off physics sims. # If more time resolution is needed in fixed updates, devs can do 'sub-stepping'. @@ -503,6 +512,32 @@ def on_fixed_update(self, delta_time: float): """ pass + def _dispatch_frame(self, delta_time: float) -> None: + """ + To handle the de-syncing of on_draw and on_update that can occur when the events aren't + linked. Dispatch frame keeps them in sync by always ensuring on_draw happens along-side + an on_update. This requires that the draw frequencies is less than or equal to the update + frequency. + + This only works because pyglet will only dispatch events after the call rate, or longer. + This means if the update rate and draw rate are equal they will both always be called. + The modulus on the accumulated draw time means that when the update rate is greater + than the draw rate no time is lost. + + Args: + delta_time: The amount of time since the last update. + """ + self._dispatch_updates(delta_time) + self._accumulated_draw_time += delta_time + + if self._draw_rate <= self._accumulated_draw_time: + # Because we only ever dispatch one draw event per loop + # we only need the modulus to keep time, if we didn't care + # it could be set to zero instead. + # ! This should maybe be fixed at 'self._draw_rate', discuss. + self.draw(self._accumulated_draw_time) + self._accumulated_draw_time %= self._draw_rate + def _dispatch_updates(self, delta_time: float) -> None: """ Internal function that is scheduled with Pyglet's clock, this function gets @@ -525,6 +560,31 @@ def _dispatch_updates(self, delta_time: float) -> None: fixed_count += 1 self.dispatch_event("on_update", GLOBAL_CLOCK.delta_time) + def flip(self) -> None: + """ + Present the rendered content to the screen. + + This is not necessary to call when using the standard standard + event loop. The event loop will automatically call this method + after ``on_draw`` has been called. + + Window framebuffers normally have a back and front buffer meaning + they are "double buffered". Content is always drawn into the back + buffer while the front buffer contains the previous frame. + Swapping the buffers makes the back buffer visible and hides the + front buffer. This is done to prevent flickering and tearing. + + This method also garbage collects OpenGL resources if there are + any dead resources to collect. If you override this method, make + sure to call the super method to ensure that the garbage collection + is done. + """ + # Garbage collect OpenGL resources + num_collected = self.ctx.gc() # noqa: F841 + # LOG.debug("Garbage collected %s OpenGL resource(s)", num_collected) + + super().flip() # type: ignore # Window typed at runtime + def set_update_rate(self, rate: float) -> None: """ Set how often the on_update function should be dispatched. @@ -537,20 +597,23 @@ def set_update_rate(self, rate: float) -> None: rate: Update frequency in seconds """ self._update_rate = rate - pyglet.clock.unschedule(self._dispatch_updates) - pyglet.clock.schedule_interval(self._dispatch_updates, rate) + pyglet.clock.unschedule(self._dispatch_frame) + pyglet.clock.schedule_interval(self._dispatch_frame, rate) def set_draw_rate(self, rate: float) -> None: """ Set how often the on_draw function should be run. + The draw rate cannot currently be faster than the update rate. + For example:: # Set the draw rate to 60 frames per second. set.set_draw_rate(1 / 60) """ - self._draw_rate = rate - pyglet.clock.unschedule(pyglet.app.event_loop._redraw_windows) - pyglet.clock.schedule_interval(pyglet.app.event_loop._redraw_windows, self._draw_rate) + assert self._update_rate <= rate, ( + "An arcade window's draw rate cannot be faster than its update rate" + ) + self._draw_rate = max(self._update_rate, rate) def on_mouse_motion(self, x: int, y: int, dx: int, dy: int) -> EVENT_HANDLE_STATE: """ @@ -1017,29 +1080,6 @@ def hide_view(self) -> None: self.remove_handlers(self._current_view) self._current_view = None - def flip(self) -> None: - """ - Present the rendered content to the screen. - - This is not necessary to call when using the standard standard - event loop. The event loop will automatically call this method - after ``on_draw`` has been called. - - Window framebuffers normally have a back and front buffer meaning - they are "double buffered". Content is always drawn into the back - buffer while the front buffer contains the previous frame. - Swapping the buffers makes the back buffer visible and hides the - front buffer. This is done to prevent flickering and tearing. - - This method also garbage collects OpenGL resources if there are - any dead resources to collect. - """ - # Garbage collect OpenGL resources - num_collected = self.ctx.gc() - LOG.debug("Garbage collected %s OpenGL resource(s)", num_collected) - - super().flip() # type: ignore # Window typed at runtime - def switch_to(self) -> None: """Switch the this window context. diff --git a/arcade/window_commands.py b/arcade/window_commands.py index a739dd5116..92013ed513 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -8,6 +8,7 @@ import gc import os +import time from typing import TYPE_CHECKING, Callable import pyglet @@ -104,6 +105,9 @@ def run(view: View | None = None) -> None: This is a blocking function starting pyglet's event loop meaning it will start to dispatch events such as ``on_draw`` and ``on_update``. + This function also handles special cases for running in headless mode + and running unit tests. + Args: view: The view to display when starting the run. Defaults to None. """ @@ -112,13 +116,12 @@ def run(view: View | None = None) -> None: if view is not None: window.show_view(view) - # Used in some unit test + # Some unite tests needs to run a single draw/update cycle if os.environ.get("ARCADE_TEST"): window.on_update(1.0 / 60.0) window.on_draw() elif window.headless: # We are entering headless more an will emulate an event loop - import time # Ensure the initial delta time is not 0 to be # more in line with how a normal window works. @@ -134,51 +137,18 @@ def run(view: View | None = None) -> None: if window.context: active.on_draw() - # windwow could be closed in on_draw + # Window could be closed in on_draw if window.context: window.flip() now = time.perf_counter() delta_time, last_time = now - last_time, now else: - import sys - - if sys.platform != "win32": - # For non windows platforms, just do pyglet run - pyglet.app.run(window._draw_rate) - else: - # Ok, some Windows platforms have a timer resolution > 15 ms. That can - # drop our FPS to 32 FPS or so. This reduces resolution so we can keep - # FPS up. - import contextlib - import ctypes - from ctypes import wintypes - - winmm = ctypes.WinDLL("winmm") - - class TIMECAPS(ctypes.Structure): - _fields_ = (("wPeriodMin", wintypes.UINT), ("wPeriodMax", wintypes.UINT)) - - def _check_time_err(err, func, args): - if err: - raise WindowsError("%s error %d" % (func.__name__, err)) - return args - - winmm.timeGetDevCaps.errcheck = _check_time_err - winmm.timeBeginPeriod.errcheck = _check_time_err - winmm.timeEndPeriod.errcheck = _check_time_err - - @contextlib.contextmanager - def timer_resolution(msecs=0): - caps = TIMECAPS() - winmm.timeGetDevCaps(ctypes.byref(caps), ctypes.sizeof(caps)) - msecs = min(max(msecs, caps.wPeriodMin), caps.wPeriodMax) - winmm.timeBeginPeriod(msecs) - yield - winmm.timeEndPeriod(msecs) - - with timer_resolution(msecs=10): - pyglet.app.run(window._draw_rate) + # Start the standard event loop (blocking) + # Note that we pass None as the interval here because we register + # a single interval event to dispatch frames because separate interval + # events cause serious issues with framerate smoothness. + pyglet.app.run(None) def exit() -> None: diff --git a/doc/programming_guide/event_loop.rst b/doc/programming_guide/event_loop.rst index 0f3edf5c09..36532cab90 100644 --- a/doc/programming_guide/event_loop.rst +++ b/doc/programming_guide/event_loop.rst @@ -11,35 +11,44 @@ handler via :py:func:`arcade.Window.push_handlers`. :py:func:`on_draw` ^^^^^^^^^^^^^^^^^^ -provides a hook to render to the window. After the ``on_draw`` event, the window + +Provides a hook to render to the window. After the ``on_draw`` event, the window will draw to the screen. By default, this attempts to occur every 1/60 seconds or once every 16.7 milliseconds. It can be changed when initializing your -:py:class:`arcade.Window` with the ``draw_rate`` argument. Setting the draw rate -to a value above a screen's refresh rate can cause tearing unless you set the -``vsync`` argument to true. We recommend keeping your ``draw_rate`` around the -screen's refresh rate. After every draw event camera state will be reset. -This means that non-default cameras must be reused on every draw event. +:py:class:`arcade.Window` with the ``draw_rate`` argument. + +Setting the draw rate to a value above a screen's refresh rate can cause tearing +unless you set the ``vsync`` argument to true. We recommend keeping your +``draw_rate`` around the screen's refresh rate. However, most users should just +use the default values. Having a draw rate higher than the update rate is not +possible and will raise an error. + +After every draw event camera state will be reset. This means that non-default +cameras must be reused on every draw event. :py:func:`on_update` ^^^^^^^^^^^^^^^^^^^^ + provides a hook to update state which needs to happen at a roughly regular interval. The update event is not strictly paired to the draw event, but they share the same thread. This can cause a bottle-neck if one is significantly slower than the other. The event also provides a ``delta_time`` argument which is the time elapsed since the last ``on_update`` event. You can change the rate at which ``on_update`` is called with -the ``update_rate`` argument when initialising your :py:class:`arcade.Window`. +the ``update_rate`` argument when initializing your :py:class:`arcade.Window`. :py:func:`on_fixed_update` ^^^^^^^^^^^^^^^^^^^^^^^^^^ + provides a hook to update state which must happen with an exactly regular interval. Because Arcade can't ensure the event is actually fired regularly it stores how much time has passed since the last update, and once enough time has passed it releases an ``on_fixed_update`` call. The fixed update always provides the same ``delta_time`` argument. You can change the rate at which ``on__fixed_update`` is -called with the ``fixed_rate`` argument when initialising your :py:class:`arcade.Window`. +called with the ``fixed_rate`` argument when initializing your :py:class:`arcade.Window`. Time ---- + While the underlying library, pyglet, provide a clock for scheduling events it is closely tied to the window's own events. For simple time keeping Arcade provides global clock objects. Both clocks can be imported from ``arcade.clock`` as @@ -47,6 +56,7 @@ clock objects. Both clocks can be imported from ``arcade.clock`` as :py:class:`arcade.Clock` ^^^^^^^^^^^^^^^^^^^^^^^^ + The base Arcade clock tracks the elapsed time in seconds, the total number of clock ticks, and the amount of time that elapsed since the last tick. The currently active window automatically ticks the ``GLOBAL_CLOCK`` every ``on_update``. @@ -56,6 +66,7 @@ can be created on the fly. :py:class:`arcade.FixedClock` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + The fixed clock tracks the same values as the normal clock, but has two special features. Firstly it enforces that the ``delta_time`` passed into its ``tick`` method is always the same. This is because advanced physics engines require consistent time. Secondly the fixed clock @@ -65,6 +76,7 @@ but ensure they have a sibling. Up Coming ^^^^^^^^^ + In future version of arcade :py:class:`Clock` will be updated to allow for sub clocks. Sub clocks will be ticked by their parent clock rather than be manually updated. Sub clocks will make it easier to control the flow of time for specific groups of objects. Such as only @@ -74,11 +86,13 @@ If you find any bugs do not hesitate to raise an issue on the github. More on Fixed update -------------------- + The ``on_fixed_update`` event can be an extremely powerful tool, but it has many complications that should be taken into account. If used imporperly the event can grind a game to a halt. Death Spiral ^^^^^^^^^^^^ + A fixed update represents a very specific amount of time. If all of the computations take longer than the fixed update represents than the ammount of time accumulated between update events will grow. If this happens for multiple frames the game will begin to spiral. The @@ -97,6 +111,7 @@ to calculate for. Update Interpolation ^^^^^^^^^^^^^^^^^^^^ + Because fixed updates work on the accumulation of time this may not sync with the ``on_draw`` or ``on_update`` events. In extreme cases this can cause a visible stuttering to objects moved within ``on_fixed_update``. To prevent this, ``GLOBAL_FIXED_CLOCK`` provides @@ -151,4 +166,4 @@ a number of reasons. We cannot guarantee that vertical sync is disabled if this is enforced on driver level. The vast amount of -driver defaults lets the application control this. \ No newline at end of file +driver defaults lets the application control this. From c2c34c82794295565a7d4a64a962ad514d8f6e47 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Mon, 10 Mar 2025 21:24:59 +0100 Subject: [PATCH 059/279] patch pyglet event loop until it is fixed for macOS --- arcade/window_commands.py | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/arcade/window_commands.py b/arcade/window_commands.py index 92013ed513..fbfb2cceaa 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -8,12 +8,13 @@ import gc import os +import sys import time -from typing import TYPE_CHECKING, Callable +from typing import Callable, TYPE_CHECKING import pyglet -from arcade.types import RGBA255, Color +from arcade.types import Color, RGBA255 if TYPE_CHECKING: from arcade import Window @@ -143,6 +144,40 @@ def run(view: View | None = None) -> None: now = time.perf_counter() delta_time, last_time = now - last_time, now + elif sys.platform == "darwin": + # On macOS we have to patch the eventloop until a new pyglet version is released + eventloop = pyglet.app.event_loop + + def patched_run(interval=1 / 60): + if interval is None: + pass + elif not interval: + eventloop.clock.schedule(eventloop._redraw_windows) + else: + eventloop.clock.schedule_interval(eventloop._redraw_windows, interval) + + eventloop.has_exit = False + + from pyglet.window import Window + + Window._enable_event_queue = False + + # Dispatch pending events + for window in pyglet.app.windows: + window.switch_to() + window.dispatch_pending_events() + + eventloop.platform_event_loop = pyglet.app.platform_event_loop + + eventloop.dispatch_event("on_enter") + eventloop.is_running = True + + eventloop.platform_event_loop.nsapp_start(interval or 0) + + eventloop.run = patched_run + + pyglet.app.run(None) + else: # Start the standard event loop (blocking) # Note that we pass None as the interval here because we register From ec3367750f7bf4dd161013f1c9770f762e6a1513 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Mon, 10 Mar 2025 22:00:16 +0100 Subject: [PATCH 060/279] fix typing --- arcade/window_commands.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/arcade/window_commands.py b/arcade/window_commands.py index fbfb2cceaa..da4c9e2123 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -10,11 +10,11 @@ import os import sys import time -from typing import Callable, TYPE_CHECKING +from typing import TYPE_CHECKING, Callable import pyglet -from arcade.types import Color, RGBA255 +from arcade.types import RGBA255, Color if TYPE_CHECKING: from arcade import Window @@ -148,7 +148,7 @@ def run(view: View | None = None) -> None: # On macOS we have to patch the eventloop until a new pyglet version is released eventloop = pyglet.app.event_loop - def patched_run(interval=1 / 60): + def patched_run(interval=1 / 60): # type: ignore if interval is None: pass elif not interval: @@ -167,14 +167,14 @@ def patched_run(interval=1 / 60): window.switch_to() window.dispatch_pending_events() - eventloop.platform_event_loop = pyglet.app.platform_event_loop + eventloop.platform_event_loop = pyglet.app.platform_event_loop # type: ignore eventloop.dispatch_event("on_enter") eventloop.is_running = True - eventloop.platform_event_loop.nsapp_start(interval or 0) + eventloop.platform_event_loop.nsapp_start(interval or 0) # type: ignore - eventloop.run = patched_run + eventloop.run = patched_run # type: ignore pyglet.app.run(None) From 46ce22d28fc12e8bf1d22c6ebb87d376773b3222 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Tue, 11 Mar 2025 08:30:12 -0500 Subject: [PATCH 061/279] Update version (#2607) Co-authored-by: Paul V Craven --- arcade/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/VERSION b/arcade/VERSION index 13d683ccbf..d9c62ed923 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.0.1 \ No newline at end of file +3.0.2 \ No newline at end of file From 27d2866b014b98682f456d767e87c7a49d96b60f Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 15 Mar 2025 21:50:45 +0100 Subject: [PATCH 062/279] Make NinePatchTexture lazy (#2610) --- arcade/gui/nine_patch.py | 71 ++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/arcade/gui/nine_patch.py b/arcade/gui/nine_patch.py index 36269da9d4..d152ff3354 100644 --- a/arcade/gui/nine_patch.py +++ b/arcade/gui/nine_patch.py @@ -1,9 +1,8 @@ from __future__ import annotations -from typing import Optional - import arcade import arcade.gl as gl +from arcade.texture_atlas.base import TextureAtlasBase class NinePatchTexture: @@ -72,39 +71,66 @@ def __init__( top: int, texture: arcade.Texture, *, - atlas: Optional[arcade.DefaultTextureAtlas] = None, + atlas: TextureAtlasBase | None = None, ): - self._ctx = arcade.get_window().ctx + self._initialized = False + self._texture = texture + self._custom_atlas = atlas + # pixel texture co-ordinate start and end of central box. + self._left = left + self._right = right + self._bottom = bottom + self._top = top + + self._check_sizes() + + # Created in _init_deferred + self._program: gl.program.Program + self._geometry: gl.Geometry + self._ctx: arcade.ArcadeContext + self._atlas: TextureAtlasBase + try: + self._init_deferred() + except Exception: + pass + + def _init_deferred(self): + """Deferred initialization when lazy loaded""" + self._ctx = arcade.get_window().ctx # TODO: Cache in context? - self._program = self.ctx.load_program( + self._program = self._ctx.load_program( vertex_shader=":system:shaders/gui/nine_patch_vs.glsl", geometry_shader=":system:shaders/gui/nine_patch_gs.glsl", fragment_shader=":system:shaders/gui/nine_patch_fs.glsl", ) # Configure texture channels - self.program.set_uniform_safe("uv_texture", 0) - self.program["sprite_texture"] = 1 + self._program.set_uniform_safe("uv_texture", 0) + self._program["sprite_texture"] = 1 - # TODO: Cache in context - self._geometry = self.ctx.geometry() + # TODO: Cache in context? + self._geometry = self._ctx.geometry() # References for the texture - self._atlas = atlas or self.ctx.default_atlas - self._texture = texture - self._add_to_atlas(texture) + self._atlas = self._custom_atlas or self._ctx.default_atlas + self._add_to_atlas(self.texture) - # pixel texture co-ordinate start and end of central box. - self._left = left - self._right = right - self._bottom = bottom - self._top = top + print("NinePatchTexture initialized") + self._initialized = True - self._check_sizes() + def initialize(self) -> None: + """ + Manually initialize the NinePatchTexture if it was lazy loaded. + This has no effect if the NinePatchTexture was already initialized. + """ + if self._initialized: + self._init_deferred() @property def ctx(self) -> arcade.ArcadeContext: """The OpenGL context.""" + if not self._initialized: + raise RuntimeError("The NinePatchTexture has not been initialized") return self._ctx @property @@ -123,10 +149,16 @@ def program(self) -> gl.program.Program: Returns the default shader if no other shader is assigned. """ + if not self._initialized: + raise RuntimeError("The NinePatchTexture has not been initialized") + return self._program @program.setter def program(self, program: gl.program.Program): + if not self._initialized: + raise RuntimeError("The NinePatchTexture has not been initialized") + self._program = program def _add_to_atlas(self, texture: arcade.Texture): @@ -209,6 +241,9 @@ def draw_rect( pixelated: Whether to draw with nearest neighbor interpolation """ + if not self._initialized: + self._init_deferred() + if blend: self._ctx.enable_only(self._ctx.BLEND) else: From 04cb8f5e3a6dcf43da27a6147caef1bca9f7435c Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Mon, 17 Mar 2025 23:23:31 +0100 Subject: [PATCH 063/279] Clean up the path resolver for doc members (#2612) * Split the import resolver into its own module * basic testing --- util/doc_helpers/__init__.py | 209 +++++++++------------------- util/doc_helpers/import_resolver.py | 140 +++++++++++++++++++ 2 files changed, 207 insertions(+), 142 deletions(-) create mode 100644 util/doc_helpers/import_resolver.py diff --git a/util/doc_helpers/__init__.py b/util/doc_helpers/__init__.py index 0b96253741..3426f7e4db 100644 --- a/util/doc_helpers/__init__.py +++ b/util/doc_helpers/__init__.py @@ -1,35 +1,12 @@ -from __future__ import annotations - import re -import ast -import dataclasses -from pathlib import Path from typing import Iterable +from pathlib import Path from .vfs import VirtualFile, Vfs, F - - -__all__ = ( - 'get_module_path', - 'EMPTY_TUPLE', - 'F', - 'SharedPaths', - 'NotExcludedBy', - 'VirtualFile', - 'Vfs' -) - +from .import_resolver import build_import_tree EMPTY_TUPLE = tuple() - - -class SharedPaths: - """These are often used to set up a Vfs and open files.""" - REPO_UTILS_DIR = Path(__file__).parent.parent.resolve() - REPO_ROOT = REPO_UTILS_DIR.parent - ARCADE_ROOT = REPO_ROOT / "arcade" - DOC_ROOT = REPO_ROOT / "doc" - API_DOC_ROOT = DOC_ROOT / "api_docs" +_VALID_MODULE_SEGMENT = re.compile(r"[_a-zA-Z][_a-z0-9]*") class NotExcludedBy: @@ -45,7 +22,14 @@ def __call__(self, item) -> bool: return item not in self.items -_VALID_MODULE_SEGMENT = re.compile(r"[_a-zA-Z][_a-z0-9]*") +class SharedPaths: + """These are often used to set up a Vfs and open files.""" + REPO_UTILS_DIR = Path(__file__).parent.parent.resolve() + REPO_ROOT = REPO_UTILS_DIR.parent + ARCADE_ROOT = REPO_ROOT / "arcade" + DOC_ROOT = REPO_ROOT / "doc" + API_DOC_ROOT = DOC_ROOT / "api_docs" + def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path: @@ -90,127 +74,68 @@ def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path: f"{module}") return current +class SharedPaths: + """These are often used to set up a Vfs and open files.""" + REPO_UTILS_DIR = Path(__file__).parent.parent.resolve() + REPO_ROOT = REPO_UTILS_DIR.parent + ARCADE_ROOT = REPO_ROOT / "arcade" + DOC_ROOT = REPO_ROOT / "doc" + API_DOC_ROOT = DOC_ROOT / "api_docs" -# Tools for resolving the lowest import of a member in Arcade. -# Members are imported in various `__init__` files and we want -# present. arcade.Sprite instead of arcade.sprite.Sprite as an example. -# Build a tree using the ast module looking at the __init__ files -# and recurse the tree to find the lowest import of a member. - -@dataclasses.dataclass -class ImportNode: - """A node in the import tree.""" - name: str - parent: ImportNode | None = None - children: list[ImportNode] = dataclasses.field(default_factory=list) - imports: list[Import] = dataclasses.field(default_factory=list) - level: int = 0 - - def get_full_module_path(self) -> str: - """Get the module path from the root to this node.""" - if self.parent is None: - return self.name - - name = self.parent.get_full_module_path() - if name: - return f"{name}.{self.name}" - return self.name - - def resolve(self, full_path: str) -> str: - """Return the lowest import of a member in the tree.""" - name = full_path.split(".")[-1] - - # Find an import in this module likely to be the one we want. - for imp in self.imports: - if imp.name == name and imp.from_module in full_path: - return f"{imp.module}.{imp.name}" - - # Move on to children - for child in self.children: - result = child.resolve(full_path) - if result: - return result - - # Return the full path if we can't find any relevant imports. - # It means the member is in a sub-module and are not importer anywhere. - return full_path - - def print_tree(self, depth=0): - """Print the tree.""" - print(" " * depth * 4, "---", self.name) - for imp in self.imports: - print(" " * (depth + 1) * 4, f"-> {imp}") - for child in self.children: - child.print_tree(depth + 1) - - -@dataclasses.dataclass -class Import: - """Unified representation of an import statement.""" - name: str # name of the member - module: str # The module this import is from - from_module: str # The module the member was imported from - - -def build_import_tree(root: Path) -> ImportNode: - """ - Build a tree of all the modules in a package. + +def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path: + """Quick-n-dirty module path estimation relative to the repo root. Args: - root: The root of the package to build the tree from. + module: A module path in the project. + Raises: + ValueError: When a can't be computed. Returns: - The root node of the tree. + An absolute file path to the module """ - node = _parse_import_node_recursive(root, parent=None) - if node is None: - raise RuntimeError("No __init__.py found in root") - return node + # Convert module.name.here to module/name/here + current = root + for index, part in enumerate(module.split('.')): + if not _VALID_MODULE_SEGMENT.fullmatch(part): + raise ValueError( + f'Invalid module segment at index {index}: {part!r}') + # else: + # print(current, part) + current /= part + + # Account for the two kinds of modules: + # 1. arcade/module.py + # 2. arcade/module/__init__.py + as_package = current / "__init__.py" + have_package = as_package.is_file() + as_file = current.with_suffix('.py') + have_file = as_file.is_file() + + # TODO: When 3.10 becomes our min Python, make this a match-case? + if have_package and have_file: + raise ValueError( + f"Module conflict between {as_package} and {as_file}") + elif have_package: + current = as_package + elif have_file: + current = as_file + else: + raise ValueError( + f"No folder package or file module detected for " + f"{module}") + return current -def _parse_import_node_recursive( - path: Path, - parent: ImportNode | None = None, - depth=0, -) -> ImportNode | None: - """Quickly gather import data using ast in a simplified/unified format. - This is a recursive function that works itself down the directory tree - looking for __init__.py files and parsing them for imports. - """ - _file = path / "__init__.py" - if not _file.exists(): - return None - - # Build the node - name = _file.parts[-2] - node = ImportNode(name, parent=parent) - module = ast.parse(_file.read_text()) - - full_module_path = node.get_full_module_path() - - for ast_node in ast.walk(module): - if isinstance(ast_node, ast.Import): - for alias in ast_node.names: - if not alias.name.startswith("arcade."): - continue - imp = Import( - name=alias.name.split(".")[-1], - module=full_module_path, - from_module=".".join(alias.name.split(".")[:-1]) - ) - node.imports.append(imp) - elif isinstance(ast_node, ast.ImportFrom): - if ast_node.level == 0 and not ast_node.module.startswith("arcade"): - continue - for alias in ast_node.names: - imp = Import(alias.name, full_module_path, ast_node.module) - node.imports.append(imp) - - # Recurse subdirectories - for child_dir in path.iterdir(): - child = _parse_import_node_recursive(child_dir, parent=node, depth=depth + 1) - if child: - node.children.append(child) - - return node + +__all__ = ( + 'get_module_path', + 'SharedPaths', + 'EMPTY_TUPLE', + 'F', + 'NotExcludedBy', + 'VirtualFile', + 'Vfs', + 'build_import_tree', +) diff --git a/util/doc_helpers/import_resolver.py b/util/doc_helpers/import_resolver.py new file mode 100644 index 0000000000..ff45ab3d01 --- /dev/null +++ b/util/doc_helpers/import_resolver.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import ast +import dataclasses +from pathlib import Path + + +# Tools for resolving the lowest import of a member in Arcade. +# Members are imported in various `__init__` files and we want +# present. arcade.Sprite instead of arcade.sprite.Sprite as an example. +# Build a tree using the ast module looking at the __init__ files +# and recurse the tree to find the lowest import of a member. + +@dataclasses.dataclass +class ImportNode: + """A node in the import tree.""" + name: str + parent: ImportNode | None = None + children: list[ImportNode] = dataclasses.field(default_factory=list) + imports: list[Import] = dataclasses.field(default_factory=list) + level: int = 0 + + def get_full_module_path(self) -> str: + """Get the module path from the root to this node.""" + if self.parent is None: + return self.name + + name = self.parent.get_full_module_path() + if name: + return f"{name}.{self.name}" + return self.name + + def resolve(self, full_path: str) -> str: + """Return the lowest import of a member in the tree.""" + name = full_path.split(".")[-1] + + # Find an import in this module likely to be the one we want. + for imp in self.imports: + if imp.name == name and imp.from_module in full_path: + return f"{imp.module}.{imp.name}" + + # Move on to children + for child in self.children: + result = child.resolve(full_path) + if result: + return result + + # Return the full path if we can't find any relevant imports. + # It means the member is in a sub-module and are not importer anywhere. + return full_path + + def print_tree(self, depth=0): + """Print the tree.""" + print(" " * depth * 4, "---", self.name) + for imp in self.imports: + print(" " * (depth + 1) * 4, f"-> {imp}") + for child in self.children: + child.print_tree(depth + 1) + + +@dataclasses.dataclass +class Import: + """Unified representation of an import statement.""" + name: str # name of the member + module: str # The module this import is from + from_module: str # The module the member was imported from + + +def build_import_tree(root: Path) -> ImportNode: + """ + Build a tree of all the modules in a package. + + Args: + root: The root of the package to build the tree from. + Returns: + The root node of the tree. + """ + node = _parse_import_node_recursive(root, parent=None) + if node is None: + raise RuntimeError("No __init__.py found in root") + return node + + +def _parse_import_node_recursive( + path: Path, + parent: ImportNode | None = None, + depth=0, +) -> ImportNode | None: + """Quickly gather import data using ast in a simplified/unified format. + + This is a recursive function that works itself down the directory tree + looking for __init__.py files and parsing them for imports. + """ + _file = path / "__init__.py" + if not _file.exists(): + return None + + # Build the node + name = _file.parts[-2] + node = ImportNode(name, parent=parent) + module = ast.parse(_file.read_text()) + + full_module_path = node.get_full_module_path() + + for ast_node in ast.walk(module): + if isinstance(ast_node, ast.Import): + for alias in ast_node.names: + if not alias.name.startswith("arcade."): + continue + imp = Import( + name=alias.name.split(".")[-1], + module=full_module_path, + from_module=".".join(alias.name.split(".")[:-1]) + ) + node.imports.append(imp) + elif isinstance(ast_node, ast.ImportFrom): + if ast_node.level == 0 and not ast_node.module.startswith("arcade"): + continue + for alias in ast_node.names: + imp = Import(alias.name, full_module_path, ast_node.module) + node.imports.append(imp) + + # Recurse subdirectories + for child_dir in path.iterdir(): + child = _parse_import_node_recursive(child_dir, parent=node, depth=depth + 1) + if child: + node.children.append(child) + + return node + + +if __name__ == "__main__": + # Basic testing. cwd: util/ + root = build_import_tree(Path(__file__).parent.parent.parent.resolve() / "arcade") + + # Check paths + path = root.resolve("arcade.sprite.Sprite") + print(path) + path = root.resolve("arcade.camera.Camera2D") + print(path) From a1cb4bad6051e3eec68e1cdf3676844d4ed08ed9 Mon Sep 17 00:00:00 2001 From: Christoph Deil Date: Tue, 18 Mar 2025 00:38:54 +0100 Subject: [PATCH 064/279] Remove arcade/camera/README.md since it is in docs (#2560) --- arcade/camera/README.md | 90 ----------------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 arcade/camera/README.md diff --git a/arcade/camera/README.md b/arcade/camera/README.md deleted file mode 100644 index 72c5a52a62..0000000000 --- a/arcade/camera/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# Arcade Camera - -This is an overview of how the new Arcade Cameras work. - -## Key Concepts - -### World Space -Whenever an object has a position within Arcade that position is in world space. How much 1 unit in world -space represents is arbitrary. For example when a sprite has a scale of 1.0 then 1 unit in world space is -equal to one pixel of the sprite's source texture. This does not necessarily equate to one pixel on the screen. - -### Screen Space -The final positions of anything drawn to screen is in screen space. The mouse positions returned by window -events like `on_mouse_press` are also in screen space. Moving 1 unit in screen space is equivalent to moving -one pixel. Often positions in screen space are integer values, but this is not a strict rule. - -### View Matrices -The view matrix represents what part of world space should be focused on. It is made of three components. -The first is the position. This represents what world space position should be at (0, 0, 0). The second is -the forward vector. This is the direction which is considered forward and backwards in world space. the -final component is the up vector. Which determines what world space positions are upwards or downwards in -world space. - -The goal of the view matrix is to prepare everything in world space for projection into screen space. It -achieves this by applying its three components to every world space position. In the end any object with -a world space position equal to the view matrix position will be at (0, 0, 0). Any object along the forward -vector after moving will be placed along the z-axis, and any object along the up vector will be place along -the y-axis. This transformation moves the objects from screen space into view space. Importantly one unit in -world space is equal to one unit in view space - -### Projection Matrices -The projection matrix takes the positions of objects in view space and projects them into screen space. -depending on the type of projection matrix how this exactly applies changes. Projection matrices alone -do not fully project objects into screen space, instead they transform positions into unit space. This -special coordinate space ranges from -1 to 1 in the x, y, and z axis. Anything within this range will -be transformed into screen space, and everything outside this range is discarded and left undrawn. -you can conceptualise projection matrices as taking a 6 sided 3D volume in view space and -squashing it down into a uniformly sized cube. In every case the closest position projected along the -z-axis is given by the near value, while the furthest is given by the far value. - -#### orthographic -In an orthographic projection the distance from the origin does not impact how much a position gets projected. -This type of projection can be visualised as a rectangular prism with a set width, height, and depth -determined by left, right, bottom, top, near, far values. These values tell you the bounding box of positions -in view space which get projected. - -#### perspective -In an orthographic projection the distance from the origin directly impacts how much a position is projected. -This type of projection can be visualised as a rectangular prism with the sharp end removed. This shape means -that more positions further away from the origin will be squashed down into unit space. This makes objects -that are further away appear smaller. The shape of the prism is determined by an aspect ratio, the field of view, -and the near and far values. The aspect ratio defines the ratio between the height and width of the projection. -The field of view is half of the angle used to determine the height of the projection at a particular depth. - -### Viewports -The final concept to cover is the viewport. This is the pixel area which the unit space will scale to. The ratio -between the size of the viewport and the size of the projection determines the relationship between units in -world space and pixels in screen space. For example if width and height of an orthographic projection is equal -to the width and height of the viewport then one unit in world space will equal one pixel in screen space. This -is the default for arcade. - -The viewport also defines which pixels get drawn to in the final image. Generally this is equal to the entire -screen, but it is possible to draw to only a specific area by defining the right viewport. Note that doing this -will change the ratio of the viewport and projection, so ensure that they match if you would like to keep the same -unit to pixel ratio. Any position outside the viewport which would normally be a valid pixel position will -not be drawn. - -## Key Objects - -- Objects which modify the view and perspective matrices are called "Projectors" - - `arcade.camera.Projector` is a `Protocol` used internally by arcade - - `Projector.use()` sets the internal projection and view matrices used by Arcade and Pyglet - - `Projector.activate()` is the same as use, but works within a context manager using the `with` syntax - - `Projector.unproject(screen_coordinate)` -provides a way to find the world position of any pixel position on screen. - - `Projector.project(world_coordinate)` -provides a way to find the screen position of any position in the world. -- There are multiple data types which provide the information required to make view and projection matrices - - `camera.CameraData` holds the position, forward, and up vectors along with a zoom value used to create the -view matrix - - `camera.OrthographicProjectionData` holds the left, right, bottom, top, near, far values needed to create a -orthographic projection matrix - - `camera.PerspectiveProjectionData` holds the aspect ratio, field of view, near and far needed to create a -perspective projection matrix. -- There are three primary `Projectors` in `arcade.camera` - - `arcade.camera.Camera2D` is locked to the x-y plane and is perfect for most use cases within arcade. - - `arcade.camera.OrthographicProjector` can be freely positioned in 3D space, but the scale of objects does not -depend on the distance to the projector - - `arcade.camera.PerspectiveProjector` can be freely position in 3D space, -and objects look smaller the further from the camera they are From 32cb543e33ceac817875e4fb7e1d66300d559862 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Tue, 18 Mar 2025 00:40:17 +0100 Subject: [PATCH 065/279] Fix member resolving in docs + camera docs (#2613) --- arcade/examples/background_blending.py | 2 +- arcade/examples/background_groups.py | 2 +- arcade/examples/background_parallax.py | 2 +- arcade/examples/background_scrolling.py | 2 +- arcade/examples/background_stationary.py | 2 +- arcade/examples/camera_platform.py | 4 +-- arcade/examples/full_screen_example.py | 2 +- arcade/examples/gl/custom_sprite.py | 2 +- arcade/examples/light_demo.py | 4 +-- arcade/examples/line_of_sight.py | 2 +- arcade/examples/maze_depth_first.py | 2 +- arcade/examples/maze_recursive.py | 2 +- arcade/examples/minimap_camera.py | 6 ++-- arcade/examples/minimap_texture.py | 4 +-- arcade/examples/perspective.py | 2 +- .../examples/platform_tutorial/07_camera.py | 2 +- arcade/examples/platform_tutorial/08_coins.py | 2 +- arcade/examples/platform_tutorial/09_sound.py | 2 +- arcade/examples/platform_tutorial/10_score.py | 4 +-- arcade/examples/platform_tutorial/11_scene.py | 4 +-- arcade/examples/platform_tutorial/12_tiled.py | 4 +-- .../platform_tutorial/13_more_layers.py | 4 +-- .../platform_tutorial/14_multiple_levels.py | 4 +-- .../15_ladders_moving_platforms.py | 4 +-- .../platform_tutorial/16_better_input.py | 4 +-- .../platform_tutorial/17_animations.py | 4 +-- .../examples/platform_tutorial/18_enemies.py | 4 +-- .../examples/platform_tutorial/19_shooting.py | 4 +-- arcade/examples/platform_tutorial/20_views.py | 4 +-- arcade/examples/procedural_caves_bsp.py | 4 +-- arcade/examples/procedural_caves_cellular.py | 4 +-- arcade/examples/sprite_move_scrolling.py | 4 +-- arcade/examples/sprite_move_scrolling_box.py | 4 +-- .../examples/sprite_move_scrolling_shake.py | 2 +- arcade/examples/sprite_moving_platforms.py | 4 +-- arcade/examples/sprite_tiled_map.py | 4 +-- .../examples/sprite_tiled_map_with_levels.py | 4 +-- arcade/examples/template_platformer.py | 4 +-- doc/programming_guide/camera.rst | 28 +++++++++---------- doc/tutorials/lights/01_light_demo.py | 2 +- doc/tutorials/lights/light_demo.py | 2 +- doc/tutorials/platform_tutorial/step_07.rst | 6 ++-- doc/tutorials/raycasting/step_08.py | 4 +-- util/doc_helpers/import_resolver.py | 17 +++++++---- 44 files changed, 95 insertions(+), 88 deletions(-) diff --git a/arcade/examples/background_blending.py b/arcade/examples/background_blending.py index 5a982c4d98..9c192e000a 100644 --- a/arcade/examples/background_blending.py +++ b/arcade/examples/background_blending.py @@ -24,7 +24,7 @@ class GameView(arcade.View): def __init__(self): super().__init__() - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Load the first background from file. Sized to match the screen self.background_1 = background.Background.from_file( diff --git a/arcade/examples/background_groups.py b/arcade/examples/background_groups.py index 42d2d9d876..7bb9827426 100644 --- a/arcade/examples/background_groups.py +++ b/arcade/examples/background_groups.py @@ -29,7 +29,7 @@ def __init__(self): # Set the background color to equal to that of the first background. self.background_color = (5, 44, 70) - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # create a background group which will hold all the backgrounds. self.backgrounds = background.BackgroundGroup() diff --git a/arcade/examples/background_parallax.py b/arcade/examples/background_parallax.py index 85c0b6b16d..102a22d894 100644 --- a/arcade/examples/background_parallax.py +++ b/arcade/examples/background_parallax.py @@ -40,7 +40,7 @@ def __init__(self): # Set the background color to match the sky in the background images self.background_color = (162, 84, 162, 255) - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Create a background group to hold all the landscape's layers self.backgrounds = background.ParallaxGroup() diff --git a/arcade/examples/background_scrolling.py b/arcade/examples/background_scrolling.py index 00423adfe9..43a9b86a99 100644 --- a/arcade/examples/background_scrolling.py +++ b/arcade/examples/background_scrolling.py @@ -24,7 +24,7 @@ class GameView(arcade.View): def __init__(self): super().__init__() - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Load the background from file. Sized to match the screen self.background = background.Background.from_file( diff --git a/arcade/examples/background_stationary.py b/arcade/examples/background_stationary.py index 14ec658eab..b08acb4a55 100644 --- a/arcade/examples/background_stationary.py +++ b/arcade/examples/background_stationary.py @@ -23,7 +23,7 @@ class GameView(arcade.View): def __init__(self): super().__init__() - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Load the background from file. It defaults to the size of the texture # with the bottom left corner at (0, 0). diff --git a/arcade/examples/camera_platform.py b/arcade/examples/camera_platform.py index 09e0c1d69e..da59fd52b0 100644 --- a/arcade/examples/camera_platform.py +++ b/arcade/examples/camera_platform.py @@ -68,7 +68,7 @@ def __init__(self): self.fps_message = None # Cameras - self.camera: arcade.camera.Camera2D = None + self.camera: arcade.Camera2D = None self.gui_camera = None self.camera_shake = None @@ -131,7 +131,7 @@ def setup(self): self.player_sprite.center_y = 128 self.scene.add_sprite("Player", self.player_sprite) - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() self.camera_shake = arcade.camera.grips.ScreenShake2D(self.camera.view_data, max_amplitude=12.5, diff --git a/arcade/examples/full_screen_example.py b/arcade/examples/full_screen_example.py index 72796196e7..18282de52b 100644 --- a/arcade/examples/full_screen_example.py +++ b/arcade/examples/full_screen_example.py @@ -43,7 +43,7 @@ def __init__(self): self.example_image = arcade.load_texture(":resources:images/tiles/boxCrate_double.png") # The camera used to update the viewport and projection on screen resize. - self.camera = arcade.camera.Camera2D( + self.camera = arcade.Camera2D( position=(0, 0), projection=LRBT(left=0, right=WINDOW_WIDTH, bottom=0, top=WINDOW_HEIGHT), viewport=self.window.rect diff --git a/arcade/examples/gl/custom_sprite.py b/arcade/examples/gl/custom_sprite.py index cc4b9f21a9..2718be7b07 100644 --- a/arcade/examples/gl/custom_sprite.py +++ b/arcade/examples/gl/custom_sprite.py @@ -34,7 +34,7 @@ class GeoSprites(arcade.Window): def __init__(self): super().__init__(800, 600, "Custom Sprites", resizable=True) - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() self.program = self.ctx.program( vertex_shader=""" #version 330 diff --git a/arcade/examples/light_demo.py b/arcade/examples/light_demo.py index 0dca24efc1..82031d338d 100644 --- a/arcade/examples/light_demo.py +++ b/arcade/examples/light_demo.py @@ -47,7 +47,7 @@ def __init__(self): self.physics_engine = None # Camera - self.camera: arcade.camera.Camera2D = None + self.camera: arcade.Camera2D = None # --- Light related --- # List of all the lights @@ -59,7 +59,7 @@ def setup(self): """ Create everything """ # Create camera - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Create sprite lists self.background_sprite_list = arcade.SpriteList() diff --git a/arcade/examples/line_of_sight.py b/arcade/examples/line_of_sight.py index 54f0e6dee7..e3fcb380af 100644 --- a/arcade/examples/line_of_sight.py +++ b/arcade/examples/line_of_sight.py @@ -70,7 +70,7 @@ def setup(self): """ Set up the game and initialize the variables. """ # Camera - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Sprite lists self.player_list = arcade.SpriteList() diff --git a/arcade/examples/maze_depth_first.py b/arcade/examples/maze_depth_first.py index da2b66ceca..8f423d29ba 100644 --- a/arcade/examples/maze_depth_first.py +++ b/arcade/examples/maze_depth_first.py @@ -196,7 +196,7 @@ def setup(self): self.background_color = arcade.color.AMAZON # Setup Camera - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() def on_draw(self): """ diff --git a/arcade/examples/maze_recursive.py b/arcade/examples/maze_recursive.py index 3cb90eb588..ec6e4b4707 100644 --- a/arcade/examples/maze_recursive.py +++ b/arcade/examples/maze_recursive.py @@ -249,7 +249,7 @@ def setup(self): self.background_color = arcade.color.AMAZON # setup camera - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() def on_draw(self): """ Render the screen. """ diff --git a/arcade/examples/minimap_camera.py b/arcade/examples/minimap_camera.py index 9035490e4a..947a7480ab 100644 --- a/arcade/examples/minimap_camera.py +++ b/arcade/examples/minimap_camera.py @@ -60,7 +60,7 @@ def __init__(self): minimap_projection = arcade.XYWH( 0.0, 0.0, MAP_PROJECTION_WIDTH, MAP_PROJECTION_HEIGHT ) - self.camera_minimap = arcade.camera.Camera2D( + self.camera_minimap = arcade.Camera2D( viewport=minimap_viewport, projection=minimap_projection ) @@ -70,8 +70,8 @@ def __init__(self): self.physics_engine = None # Camera for sprites, and one for our GUI - self.camera_sprites = arcade.camera.Camera2D() - self.camera_gui = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() + self.camera_gui = arcade.Camera2D() self.selected_camera = self.camera_minimap # texts diff --git a/arcade/examples/minimap_texture.py b/arcade/examples/minimap_texture.py index a817617afb..97d310f58e 100644 --- a/arcade/examples/minimap_texture.py +++ b/arcade/examples/minimap_texture.py @@ -62,8 +62,8 @@ def __init__(self): self.physics_engine = None # Camera for sprites, and one for our GUI - self.camera_sprites = arcade.camera.Camera2D() - self.camera_gui = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() + self.camera_gui = arcade.Camera2D() def setup(self): """ Set up the game and initialize the variables. """ diff --git a/arcade/examples/perspective.py b/arcade/examples/perspective.py index efeb4ba2ab..315218a5b4 100644 --- a/arcade/examples/perspective.py +++ b/arcade/examples/perspective.py @@ -112,7 +112,7 @@ def __init__(self): # Create a 2D camera for rendering to the fbo # by setting the camera's render target it will automatically # size and position itself correctly - self.offscreen_cam = arcade.camera.Camera2D( + self.offscreen_cam = arcade.Camera2D( render_target=self.fbo ) diff --git a/arcade/examples/platform_tutorial/07_camera.py b/arcade/examples/platform_tutorial/07_camera.py index b33eddff0b..002862eeaf 100644 --- a/arcade/examples/platform_tutorial/07_camera.py +++ b/arcade/examples/platform_tutorial/07_camera.py @@ -96,7 +96,7 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() self.background_color = arcade.csscolor.CORNFLOWER_BLUE diff --git a/arcade/examples/platform_tutorial/08_coins.py b/arcade/examples/platform_tutorial/08_coins.py index 8add596dbd..dfe0fdf260 100644 --- a/arcade/examples/platform_tutorial/08_coins.py +++ b/arcade/examples/platform_tutorial/08_coins.py @@ -108,7 +108,7 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() self.background_color = arcade.csscolor.CORNFLOWER_BLUE diff --git a/arcade/examples/platform_tutorial/09_sound.py b/arcade/examples/platform_tutorial/09_sound.py index 485a48a0b2..3524b4cb94 100644 --- a/arcade/examples/platform_tutorial/09_sound.py +++ b/arcade/examples/platform_tutorial/09_sound.py @@ -112,7 +112,7 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() self.background_color = arcade.csscolor.CORNFLOWER_BLUE diff --git a/arcade/examples/platform_tutorial/10_score.py b/arcade/examples/platform_tutorial/10_score.py index c12e1c9bf5..d183abc1c8 100644 --- a/arcade/examples/platform_tutorial/10_score.py +++ b/arcade/examples/platform_tutorial/10_score.py @@ -121,10 +121,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset our score to 0 self.score = 0 diff --git a/arcade/examples/platform_tutorial/11_scene.py b/arcade/examples/platform_tutorial/11_scene.py index 90b88b1e72..ae22ee5023 100644 --- a/arcade/examples/platform_tutorial/11_scene.py +++ b/arcade/examples/platform_tutorial/11_scene.py @@ -110,10 +110,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset our score to 0 self.score = 0 diff --git a/arcade/examples/platform_tutorial/12_tiled.py b/arcade/examples/platform_tutorial/12_tiled.py index 6bdb9e34a2..995358957a 100644 --- a/arcade/examples/platform_tutorial/12_tiled.py +++ b/arcade/examples/platform_tutorial/12_tiled.py @@ -97,10 +97,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset our score to 0 self.score = 0 diff --git a/arcade/examples/platform_tutorial/13_more_layers.py b/arcade/examples/platform_tutorial/13_more_layers.py index a0f950c9b5..c3fe1298b4 100644 --- a/arcade/examples/platform_tutorial/13_more_layers.py +++ b/arcade/examples/platform_tutorial/13_more_layers.py @@ -105,10 +105,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset our score to 0 self.score = 0 diff --git a/arcade/examples/platform_tutorial/14_multiple_levels.py b/arcade/examples/platform_tutorial/14_multiple_levels.py index 9c456dea57..9daeb96426 100644 --- a/arcade/examples/platform_tutorial/14_multiple_levels.py +++ b/arcade/examples/platform_tutorial/14_multiple_levels.py @@ -114,10 +114,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset the score if we should if self.reset_score: diff --git a/arcade/examples/platform_tutorial/15_ladders_moving_platforms.py b/arcade/examples/platform_tutorial/15_ladders_moving_platforms.py index e35bd479ef..d0a0a9c106 100644 --- a/arcade/examples/platform_tutorial/15_ladders_moving_platforms.py +++ b/arcade/examples/platform_tutorial/15_ladders_moving_platforms.py @@ -114,10 +114,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset the score if we should if self.reset_score: diff --git a/arcade/examples/platform_tutorial/16_better_input.py b/arcade/examples/platform_tutorial/16_better_input.py index 3b0fa58f8e..714f1d1e1a 100644 --- a/arcade/examples/platform_tutorial/16_better_input.py +++ b/arcade/examples/platform_tutorial/16_better_input.py @@ -120,10 +120,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset the score if we should if self.reset_score: diff --git a/arcade/examples/platform_tutorial/17_animations.py b/arcade/examples/platform_tutorial/17_animations.py index aa1c16d2b5..b6f36e65ff 100644 --- a/arcade/examples/platform_tutorial/17_animations.py +++ b/arcade/examples/platform_tutorial/17_animations.py @@ -200,10 +200,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset the score if we should if self.reset_score: diff --git a/arcade/examples/platform_tutorial/18_enemies.py b/arcade/examples/platform_tutorial/18_enemies.py index 2752c2fe44..148d38d24b 100644 --- a/arcade/examples/platform_tutorial/18_enemies.py +++ b/arcade/examples/platform_tutorial/18_enemies.py @@ -277,10 +277,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset the score if we should if self.reset_score: diff --git a/arcade/examples/platform_tutorial/19_shooting.py b/arcade/examples/platform_tutorial/19_shooting.py index 732c20bd61..428d962778 100644 --- a/arcade/examples/platform_tutorial/19_shooting.py +++ b/arcade/examples/platform_tutorial/19_shooting.py @@ -284,10 +284,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset the score if we should if self.reset_score: diff --git a/arcade/examples/platform_tutorial/20_views.py b/arcade/examples/platform_tutorial/20_views.py index a2417c4ca2..8f12a760a2 100644 --- a/arcade/examples/platform_tutorial/20_views.py +++ b/arcade/examples/platform_tutorial/20_views.py @@ -305,10 +305,10 @@ def setup(self): ) # Initialize our camera, setting a viewport the size of our window. - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() # Initialize our gui camera, initial settings are the same as our world camera. - self.gui_camera = arcade.camera.Camera2D() + self.gui_camera = arcade.Camera2D() # Reset the score if we should if self.reset_score: diff --git a/arcade/examples/procedural_caves_bsp.py b/arcade/examples/procedural_caves_bsp.py index 52dce63f83..8efb0f578a 100644 --- a/arcade/examples/procedural_caves_bsp.py +++ b/arcade/examples/procedural_caves_bsp.py @@ -287,8 +287,8 @@ def __init__(self): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.camera.Camera2D() - self.camera_gui = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() + self.camera_gui = arcade.Camera2D() self.background_color = arcade.color.BLACK diff --git a/arcade/examples/procedural_caves_cellular.py b/arcade/examples/procedural_caves_cellular.py index 907f92835a..6ed1bd22ef 100644 --- a/arcade/examples/procedural_caves_cellular.py +++ b/arcade/examples/procedural_caves_cellular.py @@ -163,8 +163,8 @@ def __init__(self): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.camera.Camera2D() - self.camera_gui = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() + self.camera_gui = arcade.Camera2D() self.window.background_color = arcade.color.BLACK diff --git a/arcade/examples/sprite_move_scrolling.py b/arcade/examples/sprite_move_scrolling.py index ecdf753bb1..8bc3a52542 100644 --- a/arcade/examples/sprite_move_scrolling.py +++ b/arcade/examples/sprite_move_scrolling.py @@ -54,8 +54,8 @@ def __init__(self): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.camera.Camera2D() - self.camera_gui = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() + self.camera_gui = arcade.Camera2D() def setup(self): """ Set up the game and initialize the variables. """ diff --git a/arcade/examples/sprite_move_scrolling_box.py b/arcade/examples/sprite_move_scrolling_box.py index 880a8f02a3..c0d5dbd53f 100644 --- a/arcade/examples/sprite_move_scrolling_box.py +++ b/arcade/examples/sprite_move_scrolling_box.py @@ -61,8 +61,8 @@ def __init__(self): self.up_pressed = False self.down_pressed = False - self.camera_sprites = arcade.camera.Camera2D() - self.camera_gui = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() + self.camera_gui = arcade.Camera2D() def setup(self): """ Set up the game and initialize the variables. """ diff --git a/arcade/examples/sprite_move_scrolling_shake.py b/arcade/examples/sprite_move_scrolling_shake.py index e0f3f58cc8..c26d94c8bb 100644 --- a/arcade/examples/sprite_move_scrolling_shake.py +++ b/arcade/examples/sprite_move_scrolling_shake.py @@ -53,7 +53,7 @@ def __init__(self): self.physics_engine = None # Create camera that will follow the player sprite. - self.camera_sprites = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() self.camera_shake = arcade.camera.grips.ScreenShake2D( self.camera_sprites.view_data, diff --git a/arcade/examples/sprite_moving_platforms.py b/arcade/examples/sprite_moving_platforms.py index 41df824e67..c7ba7be86a 100644 --- a/arcade/examples/sprite_moving_platforms.py +++ b/arcade/examples/sprite_moving_platforms.py @@ -54,8 +54,8 @@ def __init__(self): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.camera.Camera2D() - self.camera_gui = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() + self.camera_gui = arcade.Camera2D() self.left_down = False self.right_down = False diff --git a/arcade/examples/sprite_tiled_map.py b/arcade/examples/sprite_tiled_map.py index 0ab2b64550..59e8f17bca 100644 --- a/arcade/examples/sprite_tiled_map.py +++ b/arcade/examples/sprite_tiled_map.py @@ -121,8 +121,8 @@ def setup(self): self.player_sprite, walls, gravity_constant=GRAVITY ) - self.camera = arcade.camera.Camera2D() - self.gui_camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() + self.gui_camera = arcade.Camera2D() # Use the tilemap to limit the camera's position # we don't offset the max_y position to give a better experience. diff --git a/arcade/examples/sprite_tiled_map_with_levels.py b/arcade/examples/sprite_tiled_map_with_levels.py index 26645cc756..2cd2b0a84e 100644 --- a/arcade/examples/sprite_tiled_map_with_levels.py +++ b/arcade/examples/sprite_tiled_map_with_levels.py @@ -70,8 +70,8 @@ def setup(self): scale=PLAYER_SCALING, ) - self.game_camera = arcade.camera.Camera2D() - self.gui_camera = arcade.camera.Camera2D() + self.game_camera = arcade.Camera2D() + self.gui_camera = arcade.Camera2D() self.fps_text = arcade.Text('FPS:', 10, 10, arcade.color.BLACK, 14) self.game_over_text = arcade.Text( diff --git a/arcade/examples/template_platformer.py b/arcade/examples/template_platformer.py index da7b62ba84..00a53ee0e6 100644 --- a/arcade/examples/template_platformer.py +++ b/arcade/examples/template_platformer.py @@ -37,14 +37,14 @@ def __init__(self): super().__init__() # A Camera that can be used for scrolling the screen - self.camera_sprites = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() # A rectangle that is used to constrain the camera's position. # we update it when we load the tilemap self.camera_bounds = self.window.rect # A non-scrolling camera that can be used to draw GUI elements - self.camera_gui = arcade.camera.Camera2D() + self.camera_gui = arcade.Camera2D() # The scene which helps draw multiple spritelists in order. self.scene = self.create_scene() diff --git a/doc/programming_guide/camera.rst b/doc/programming_guide/camera.rst index 4efeb2a5e2..1f8645a5f8 100644 --- a/doc/programming_guide/camera.rst +++ b/doc/programming_guide/camera.rst @@ -40,21 +40,21 @@ depending on the type of projection matrix how this exactly applies changes. Pro do not fully project objects into screen space, instead they transform positions into unit space. This special coordinate space ranges from -1 to 1 in the x, y, and z axis. Anything within this range will be transformed into screen space, and everything outside this range is discarded and left undrawn. -you can conceptualise projection matrices as taking a 6 sided 3D volume in view space and +you can conceptualize projection matrices as taking a 6 sided 3D volume in view space and squashing it down into a uniformly sized cube. In every case the closest position projected along the z-axis is given by the near value, while the furthest is given by the far value. orthographic """""""""""" In an orthographic projection the distance from the origin does not impact how much a position gets projected. -This type of projection can be visualised as a rectangular prism with a set width, height, and depth +This type of projection can be visualized as a rectangular prism with a set width, height, and depth determined by left, right, bottom, top, near, far values. These values tell you the bounding box of positions in view space which get projected. perspective """"""""""" In an orthographic projection the distance from the origin directly impacts how much a position is projected. -This type of projection can be visualised as a rectangular prism with the sharp end removed. This shape means +This type of projection can be visualized as a rectangular prism with the sharp end removed. This shape means that more positions further away from the origin will be squashed down into unit space. This makes objects that are further away appear smaller. The shape of the prism is determined by an aspect ratio, the field of view, and the near and far values. The aspect ratio defines the ratio between the height and width of the projection. @@ -79,20 +79,20 @@ Key Objects - Objects which modify the view and perspective matrices are called "Projectors" - - :py:class:`arcade.camera.Projector` is a :py:class:`Protocol` used internally by arcade - - :py:func:`Projector.use()` sets the internal projection and view matrices used by Arcade and Pyglet - - :py:func:`Projector.activate()` is the same as use, but works within a context manager using the ``with`` syntax - - :py:func:`Projector.unproject(screen_coordinate)` provides a way to find the world position of any pixel position on screen. - - :py:func:`Projector.project(world_coordinate)` provides a way to find the screen position of any position in the world. + - :py:class:`~arcade.camera.Projector` is a :py:class:`Protocol` used internally by arcade + - :py:func:`~arcade.camera.Projector.use` sets the internal projection and view matrices used by Arcade and Pyglet + - :py:func:`~arcade.camera.Projector.activate` is the same as use, but works within a context manager using the ``with`` syntax + - :py:func:`~arcade.camera.Projector.unproject` provides a way to find the world position of any pixel position on screen. + - :py:func:`~arcade.camera.Projector.project` provides a way to find the screen position of any position in the world. - There are multiple data types which provide the information required to make view and projection matrices - - :py:class:`camera.CameraData` holds the position, forward, and up vectors along with a zoom value used to create the view matrix - - :py:class:`camera.OrthographicProjectionData` holds the left, right, bottom, top, near, far values needed to create a orthographic projection matrix - - :py:class:`camera.PerspectiveProjectionData` holds the aspect ratio, field of view, near and far needed to create a perspective projection matrix. + - :py:class:`~arcade.camera.CameraData` holds the position, forward, and up vectors along with a zoom value used to create the view matrix + - :py:class:`~arcade.camera.OrthographicProjectionData` holds the left, right, bottom, top, near, far values needed to create a orthographic projection matrix + - :py:class:`~arcade.camera.PerspectiveProjectionData` holds the aspect ratio, field of view, near and far needed to create a perspective projection matrix. - There are three primary `Projectors` in `arcade.camera` - - :py:class:`arcade.camera.Camera2D` is locked to the x-y plane and is perfect for most use cases within arcade. - - :py:class:`arcade.camera.OrthographicProjector` can be freely positioned in 3D space, and the scale of objects does not depend on the distance from the projector. - - :py:class:`arcade.camera.PerspectiveProjector` can be freely positioned in 3D space, and objects look smaller the further from the camera they are. + - :py:class:`~arcade.Camera2D` is locked to the x-y plane and is perfect for most use cases within arcade. + - :py:class:`~arcade.camera.OrthographicProjector` can be freely positioned in 3D space, and the scale of objects does not depend on the distance from the projector. + - :py:class:`~arcade.camera.PerspectiveProjector` can be freely positioned in 3D space, and objects look smaller the further from the camera they are. diff --git a/doc/tutorials/lights/01_light_demo.py b/doc/tutorials/lights/01_light_demo.py index a7043e2c3c..94bb598752 100644 --- a/doc/tutorials/lights/01_light_demo.py +++ b/doc/tutorials/lights/01_light_demo.py @@ -52,7 +52,7 @@ def setup(self): self.physics_engine = arcade.PhysicsEngineSimple(self.player_sprite, self.wall_list) # setup camera - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() def on_draw(self): """ Draw everything. """ diff --git a/doc/tutorials/lights/light_demo.py b/doc/tutorials/lights/light_demo.py index fb85fbb0c2..6fb8feb01b 100644 --- a/doc/tutorials/lights/light_demo.py +++ b/doc/tutorials/lights/light_demo.py @@ -186,7 +186,7 @@ def setup(self): self.physics_engine = arcade.PhysicsEngineSimple(self.player_sprite, self.wall_list) # setup camera - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() def on_draw(self): """ Draw everything. """ diff --git a/doc/tutorials/platform_tutorial/step_07.rst b/doc/tutorials/platform_tutorial/step_07.rst index 27a29ccf4c..3fca0fdd49 100644 --- a/doc/tutorials/platform_tutorial/step_07.rst +++ b/doc/tutorials/platform_tutorial/step_07.rst @@ -7,7 +7,7 @@ Now that our player can move and jump around, we need to give them a way to expl beyond the original window. If you've ever played a platformer game, you might be familiar with the concept of the screen scrolling to reveal more of the map as the player moves. -To achieve this, we can use a Camera. Since we are making a 2D game, ``arcade.camera.Camera2D`` will +To achieve this, we can use a Camera. Since we are making a 2D game, :py:class:`~arcade.Camera2D` will be easiest. To start with, let's go ahead and add a variable in our ``__init__`` function to hold it: @@ -20,9 +20,9 @@ Next we can go to our setup function, and initialize it like so: .. code-block:: - self.camera = arcade.camera.Camera2D() + self.camera = arcade.Camera2D() -Since we're drawing to the entire screen, we can use ``Camera2D``'s default settings. +Since we're drawing to the entire screen, we can use :py:class:`~arcade.Camera2D`'s default settings. In other circumstances, we can create or adjust the camera so it has a different viewport. In order to use our camera when drawing things to the screen, we only need to add one line to our ``on_draw`` diff --git a/doc/tutorials/raycasting/step_08.py b/doc/tutorials/raycasting/step_08.py index 22df7eccd6..b613a8cb17 100644 --- a/doc/tutorials/raycasting/step_08.py +++ b/doc/tutorials/raycasting/step_08.py @@ -44,8 +44,8 @@ def __init__(self, width, height, title): self.physics_engine = None # Create cameras used for scrolling - self.camera_sprites = arcade.camera.Camera2D() - self.camera_gui = arcade.camera.Camera2D() + self.camera_sprites = arcade.Camera2D() + self.camera_gui = arcade.Camera2D() self.generate_sprites() diff --git a/util/doc_helpers/import_resolver.py b/util/doc_helpers/import_resolver.py index ff45ab3d01..259875a8c7 100644 --- a/util/doc_helpers/import_resolver.py +++ b/util/doc_helpers/import_resolver.py @@ -30,24 +30,29 @@ def get_full_module_path(self) -> str: return f"{name}.{self.name}" return self.name - def resolve(self, full_path: str) -> str: + def resolve(self, full_path: str, level=0) -> str | None: """Return the lowest import of a member in the tree.""" name = full_path.split(".")[-1] # Find an import in this module likely to be the one we want. for imp in self.imports: if imp.name == name and imp.from_module in full_path: + print(f"Found: {imp.name} in {imp.module}") return f"{imp.module}.{imp.name}" # Move on to children for child in self.children: - result = child.resolve(full_path) + print(f"Checking child: {child.name}") + result = child.resolve(full_path, level + 1) if result: return result - # Return the full path if we can't find any relevant imports. - # It means the member is in a sub-module and are not importer anywhere. - return full_path + # We're back from recursing and didn't find anything. + if level == 0: + return full_path + + # Nothing was found in this subtree. + return None def print_tree(self, depth=0): """Print the tree.""" @@ -138,3 +143,5 @@ def _parse_import_node_recursive( print(path) path = root.resolve("arcade.camera.Camera2D") print(path) + path = root.resolve("arcade.camera.data_types.Projector") + print(path) From fca403953ee7976c865ecd47f4c77109cae63d29 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Tue, 18 Mar 2025 01:21:12 +0100 Subject: [PATCH 066/279] Fix possible incorrectly resolved member path (#2614) Don't look in sub-modules that are not in the original member path. --- util/doc_helpers/import_resolver.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/util/doc_helpers/import_resolver.py b/util/doc_helpers/import_resolver.py index 259875a8c7..d4b88a5dc7 100644 --- a/util/doc_helpers/import_resolver.py +++ b/util/doc_helpers/import_resolver.py @@ -33,19 +33,22 @@ def get_full_module_path(self) -> str: def resolve(self, full_path: str, level=0) -> str | None: """Return the lowest import of a member in the tree.""" name = full_path.split(".")[-1] + modules = full_path.split(".") # Find an import in this module likely to be the one we want. for imp in self.imports: if imp.name == name and imp.from_module in full_path: - print(f"Found: {imp.name} in {imp.module}") + # print(f"Found: {imp.name} in {imp.module}") return f"{imp.module}.{imp.name}" # Move on to children + module = modules[level + 1] for child in self.children: - print(f"Checking child: {child.name}") - result = child.resolve(full_path, level + 1) - if result: - return result + if child.name == module: + # print(f"Checking child: {child.name}") + result = child.resolve(full_path, level + 1) + if result: + return result # We're back from recursing and didn't find anything. if level == 0: From 94f1d5732e68637429810955b19a7207d66e5905 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Tue, 18 Mar 2025 09:59:35 +0100 Subject: [PATCH 067/279] Fix NinePatch.initialize (#2615) The initialized test was reversed --- arcade/gui/nine_patch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/gui/nine_patch.py b/arcade/gui/nine_patch.py index d152ff3354..881b783805 100644 --- a/arcade/gui/nine_patch.py +++ b/arcade/gui/nine_patch.py @@ -123,7 +123,7 @@ def initialize(self) -> None: Manually initialize the NinePatchTexture if it was lazy loaded. This has no effect if the NinePatchTexture was already initialized. """ - if self._initialized: + if not self._initialized: self._init_deferred() @property From 538c9d52b892f37754fbc44086a7b212d5791a27 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 6 Feb 2025 23:10:36 +0100 Subject: [PATCH 068/279] gui:controller support draft --- arcade/examples/gui/exp_controller_support.py | 118 ++++++++ arcade/gui/experimental/controller.py | 278 ++++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100644 arcade/examples/gui/exp_controller_support.py create mode 100644 arcade/gui/experimental/controller.py diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py new file mode 100644 index 0000000000..09861990d0 --- /dev/null +++ b/arcade/examples/gui/exp_controller_support.py @@ -0,0 +1,118 @@ +from typing import Optional + +import arcade +from arcade import Texture +from arcade.gui import ( + UIAnchorLayout, + UIBoxLayout, + UIEvent, + UIFlatButton, + UIImage, + UIMouseFilterMixin, + UIOnClickEvent, + UIView, +) +from arcade.gui.experimental.controller import ( + UIControllerBridge, + UIControllerButtonPressEvent, + UIControllerDpadEvent, + UIFocusGroup, +) + + +class ControllerIndicator(UIAnchorLayout): + BLANK_TEX = Texture.create_empty("empty", (40, 40), arcade.color.TRANSPARENT_BLACK) + + def __init__(self): + super().__init__() + + self._indicator = self.add(UIImage(texture=self.BLANK_TEX), anchor_y="bottom", align_y=10) + + def on_event(self, event: UIEvent) -> Optional[bool]: + if isinstance(event, UIControllerButtonPressEvent): + self._indicator.texture = arcade.load_texture( + f":resources:onscreen_controls/flat_dark/{event.button}.png" + ) + arcade.unschedule(self.reset) + arcade.schedule_once(self.reset, 0.5) + elif isinstance(event, UIControllerDpadEvent): + tex_map = { + (1, 0): "right", + (-1, 0): "left", + (0, 1): "up", + (0, -1): "down", + } + + if event.vector in tex_map: + self._indicator.texture = arcade.load_texture( + f":resources:onscreen_controls/flat_dark/{tex_map[event.vector]}.png" + ) + arcade.unschedule(self.reset) + arcade.schedule_once(self.reset, 0.5) + + return super().on_event(event) + + def reset(self, *_): + print("Reset") + self._indicator.texture = self.BLANK_TEX + self.trigger_full_render() + + +class ControllerModal(UIMouseFilterMixin, UIFocusGroup): + def __init__(self): + super().__init__(size_hint=(0.8, 0.8)) + self.with_background(color=arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE) + + root = self.add(UIBoxLayout(space_between=10)) + + root.add(UIFlatButton(text="Modal Button 1")) + root.add(UIFlatButton(text="Modal Button 2")) + root.add(UIFlatButton(text="Modal Button 3")) + root.add(UIFlatButton(text="Close")).on_click = self.close + + self.detect_focusable_widgets() + + def on_event(self, event): + if super().on_event(event): + return True + + if isinstance(event, UIControllerButtonPressEvent): + if event.button == "b": + self.close(None) + return True + + return False + + def close(self, event): + print("Close") + # self.trigger_full_render() + self.trigger_full_render() + self.parent.remove(self) + + +class MyView(UIView): + def __init__(self): + super().__init__() + arcade.set_background_color(arcade.color.AMAZON) + + self.controller_bridge = UIControllerBridge(self.ui) + + self.root = self.add_widget(ControllerIndicator()) + self.root = self.root.add(UIFocusGroup()) + box = self.root.add(UIBoxLayout(space_between=10), anchor_x="left") + + box.add(UIFlatButton(text="Button 1")).on_click = self.on_button_click + box.add(UIFlatButton(text="Button 2")).on_click = self.on_button_click + box.add(UIFlatButton(text="Button 3")).on_click = self.on_button_click + + self.root.detect_focusable_widgets() + + def on_button_click(self, event: UIOnClickEvent): + print("Button clicked") + self.root.add(ControllerModal()) + + +if __name__ == "__main__": + window = arcade.Window(title="Controller UI Example") + window.show_view(MyView()) + arcade.run() diff --git a/arcade/gui/experimental/controller.py b/arcade/gui/experimental/controller.py new file mode 100644 index 0000000000..7d20c208d8 --- /dev/null +++ b/arcade/gui/experimental/controller.py @@ -0,0 +1,278 @@ +from dataclasses import dataclass +from typing import Optional + +from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED +from pyglet.input import Controller +from pyglet.math import Vec2 + +import arcade +from arcade import ControllerManager, MOUSE_BUTTON_LEFT +from arcade.gui import ( + ListProperty, + Property, + Surface, + UIAnchorLayout, + UIEvent, + UIInteractiveWidget, + UIManager, + UIMousePressEvent, + UIMouseReleaseEvent, + UIWidget, + bind, +) + + +@dataclass +class UIControllerEvent(UIEvent): + """Base class for all UI controller events. + + Args: + source: The controller that triggered the event. + """ + + +@dataclass +class UIControllerStickEvent(UIControllerEvent): + """Triggered when a controller stick is moved. + + Args: + name: The name of the stick. + vector: The value of the stick. + """ + + name: str + vector: Vec2 + + +@dataclass +class UIControllerTriggerEvent(UIControllerEvent): + """Triggered when a controller trigger is moved. + + Args: + name: The name of the trigger. + value: The value of the trigger. + """ + + name: str + value: float + + +@dataclass +class UIControllerButtonPressEvent(UIControllerEvent): + """Triggered when a controller button is pressed. + + Args: + button: The name of the button. + """ + + button: str + + +@dataclass +class UIControllerButtonReleaseEvent(UIControllerEvent): + """Triggered when a controller button is released. + + Args: + button: The name of the button. + """ + + button: str + + +@dataclass +class UIControllerDpadEvent(UIControllerEvent): + """Triggered when a controller dpad is moved. + + Args: + vector: The value of the dpad. + """ + + vector: Vec2 + + +class ControllerListener: + """Interface for listening to controller events""" + + def on_stick_motion(self, controller: Controller, name: str, value: Vec2): + pass + + def on_trigger_motion(self, controller: Controller, name: str, value: float): + pass + + def on_button_press(self, controller: Controller, button_name: str): + pass + + def on_button_release(self, controller: Controller, button_name: str): + pass + + def on_dpad_motion(self, controller: Controller, value: Vec2): + pass + + +class UIControllerBridge(ControllerListener): + """Translates controller events to UIEvents and passes them to the UIManager + + Controller events are not consumed by the UIControllerBridge, + so they can be used by other systems. + + #TODO change this + This implicates, that the UIControllerBridge should be the first listener in the chain and + that other systems should be aware, when not to act on events (like when the UI is active). + """ + + def __init__(self, ui: UIManager): + self.ui = ui + self.cm = ControllerManager() + + self.cm.push_handlers(self) + # bind to existing controllers + for controller in self.cm.get_controllers(): + print("Controller connected", controller) + self.on_connect(controller) + + def on_connect(self, controller: Controller): + controller.push_handlers(self) + controller.open() + + def on_disconnect(self, controller: Controller): + controller.remove_handlers(self) + controller.close() + + # Controller event mapping + def on_stick_motion(self, controller: Controller, name, value): + self.ui.dispatch_ui_event(UIControllerStickEvent(controller, name, value)) + + def on_trigger_motion(self, controller: Controller, name, value): + self.ui.dispatch_ui_event(UIControllerTriggerEvent(controller, name, value)) + + def on_button_press(self, controller: Controller, button): + self.ui.dispatch_ui_event(UIControllerButtonPressEvent(controller, button)) + + def on_button_release(self, controller: Controller, button): + self.ui.dispatch_ui_event(UIControllerButtonReleaseEvent(controller, button)) + + def on_dpad_motion(self, controller: Controller, value): + self.ui.dispatch_ui_event(UIControllerDpadEvent(controller, value)) + + +class UIFocusGroup(UIAnchorLayout): + """A group of widgets that can be focused. + + UIFocusGroup maintains two lists of widgets: + - The list of focusable widgets. + - The list of widgets in. + + Use detect_focusable_widgets to automatically detect focusable widgets + or add_widget to add them manually. + + """ + + _widgets = ListProperty[UIWidget]() + _focused = Property(0) + + def __init__(self, size_hint=(1, 1), **kwargs): + super().__init__(size_hint=size_hint, **kwargs) + + bind(self, "_focused", self.trigger_full_render) + bind(self, "_widgets", self.trigger_full_render) + + def on_event(self, event: UIEvent) -> Optional[bool]: + + if super().on_event(event): + return EVENT_HANDLED + + if isinstance(event, UIControllerDpadEvent): + if event.vector.x == 1 or event.vector.y == -1: + self.focus_next() + return EVENT_HANDLED + elif event.vector.x == -1 or event.vector.y == 1: + self.focus_previous() + return EVENT_HANDLED + + elif isinstance(event, UIControllerButtonPressEvent): + if event.button == "a": + self.start_interaction() + return EVENT_HANDLED + elif isinstance(event, UIControllerButtonReleaseEvent): + if event.button == "a": + self.end_interaction() + return EVENT_HANDLED + + return EVENT_UNHANDLED + + def add_widget(self, widget): + self._widgets.append(widget) + + @classmethod + def _walk_widgets(cls, root: UIWidget): + for child in reversed(root.children): + yield child + yield from cls._walk_widgets(child) + + def detect_focusable_widgets(self, root: UIWidget = None): + """Automatically detect focusable widgets.""" + if root is None: + root = self + + widgets = self._walk_widgets(root) + + focusable_widgets = [] + for widget in reversed(list(widgets)): + if self.is_focusable(widget): + focusable_widgets.append(widget) + + self._widgets = focusable_widgets + + def focus_next(self): + self._focused += 1 + if self._focused >= len(self._widgets): + self._focused = 0 + + def focus_previous(self): + self._focused -= 1 + if self._focused < 0: + self._focused = len(self._widgets) - 1 + + def start_interaction(self): + widget = self._widgets[self._focused] + + if isinstance(widget, UIInteractiveWidget): + widget.dispatch_ui_event( + UIMousePressEvent( + source=self, + x=widget.rect.center_x, + y=widget.rect.center_y, + button=MOUSE_BUTTON_LEFT, + modifiers=0, + ) + ) + else: + print("Cannot interact widget") + + def end_interaction(self): + widget = self._widgets[self._focused] + + if isinstance(widget, UIInteractiveWidget): + widget.dispatch_ui_event( + UIMouseReleaseEvent( + source=self, + x=widget.rect.center_x, + y=widget.rect.center_y, + button=MOUSE_BUTTON_LEFT, + modifiers=0, + ) + ) + + # TODO render after children rendered + def do_render(self, surface: Surface): + surface.limit(None) + widget = self._widgets[self._focused] + arcade.draw_rect_outline( + rect=widget.rect, + color=arcade.color.WHITE, + border_width=2, + ) + + @staticmethod + def is_focusable(widget): + return isinstance(widget, UIInteractiveWidget) From fe0254a355c1ac9bdd2b3221ea367edd001da4d1 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 14 Feb 2025 21:23:03 +0100 Subject: [PATCH 069/279] experimental controller support incl inventory example --- arcade/examples/gui/exp_controller_support.py | 101 ++++- .../gui/exp_controller_support_grid.py | 88 ++++ arcade/examples/gui/exp_inventory_demo.py | 396 ++++++++++++++++++ arcade/gui/experimental/controller.py | 177 ++------ arcade/gui/experimental/focus.py | 297 +++++++++++++ .../input_prompt/xbox/controller_xbox360.png | Bin 0 -> 979 bytes .../xbox/controller_xbox_adaptive.png | Bin 0 -> 614 bytes .../input_prompt/xbox/controller_xboxone.png | Bin 0 -> 927 bytes .../xbox/controller_xboxseries.png | Bin 0 -> 970 bytes .../input_prompt/xbox/xbox_button_a.png | Bin 0 -> 982 bytes .../xbox/xbox_button_a_outline.png | Bin 0 -> 1269 bytes .../input_prompt/xbox/xbox_button_b.png | Bin 0 -> 900 bytes .../xbox/xbox_button_b_outline.png | Bin 0 -> 1196 bytes .../input_prompt/xbox/xbox_button_back.png | Bin 0 -> 860 bytes .../xbox/xbox_button_back_icon.png | Bin 0 -> 673 bytes .../xbox/xbox_button_back_icon_outline.png | Bin 0 -> 882 bytes .../xbox/xbox_button_back_outline.png | Bin 0 -> 1059 bytes .../input_prompt/xbox/xbox_button_color_a.png | Bin 0 -> 982 bytes .../xbox/xbox_button_color_a_outline.png | Bin 0 -> 1269 bytes .../input_prompt/xbox/xbox_button_color_b.png | Bin 0 -> 900 bytes .../xbox/xbox_button_color_b_outline.png | Bin 0 -> 1191 bytes .../input_prompt/xbox/xbox_button_color_x.png | Bin 0 -> 1036 bytes .../xbox/xbox_button_color_x_outline.png | Bin 0 -> 1336 bytes .../input_prompt/xbox/xbox_button_color_y.png | Bin 0 -> 941 bytes .../xbox/xbox_button_color_y_outline.png | Bin 0 -> 1253 bytes .../input_prompt/xbox/xbox_button_menu.png | Bin 0 -> 774 bytes .../xbox/xbox_button_menu_outline.png | Bin 0 -> 1086 bytes .../input_prompt/xbox/xbox_button_share.png | Bin 0 -> 658 bytes .../xbox/xbox_button_share_outline.png | Bin 0 -> 880 bytes .../input_prompt/xbox/xbox_button_start.png | Bin 0 -> 879 bytes .../xbox/xbox_button_start_icon.png | Bin 0 -> 666 bytes .../xbox/xbox_button_start_icon_outline.png | Bin 0 -> 885 bytes .../xbox/xbox_button_start_outline.png | Bin 0 -> 1077 bytes .../input_prompt/xbox/xbox_button_view.png | Bin 0 -> 846 bytes .../xbox/xbox_button_view_outline.png | Bin 0 -> 1168 bytes .../input_prompt/xbox/xbox_button_x.png | Bin 0 -> 1037 bytes .../xbox/xbox_button_x_outline.png | Bin 0 -> 1336 bytes .../input_prompt/xbox/xbox_button_y.png | Bin 0 -> 941 bytes .../xbox/xbox_button_y_outline.png | Bin 0 -> 1253 bytes .../assets/input_prompt/xbox/xbox_dpad.png | Bin 0 -> 351 bytes .../input_prompt/xbox/xbox_dpad_all.png | Bin 0 -> 351 bytes .../input_prompt/xbox/xbox_dpad_down.png | Bin 0 -> 416 bytes .../xbox/xbox_dpad_down_outline.png | Bin 0 -> 400 bytes .../xbox/xbox_dpad_horizontal.png | Bin 0 -> 435 bytes .../xbox/xbox_dpad_horizontal_outline.png | Bin 0 -> 377 bytes .../input_prompt/xbox/xbox_dpad_left.png | Bin 0 -> 434 bytes .../xbox/xbox_dpad_left_outline.png | Bin 0 -> 389 bytes .../input_prompt/xbox/xbox_dpad_none.png | Bin 0 -> 398 bytes .../input_prompt/xbox/xbox_dpad_right.png | Bin 0 -> 427 bytes .../xbox/xbox_dpad_right_outline.png | Bin 0 -> 391 bytes .../input_prompt/xbox/xbox_dpad_round.png | Bin 0 -> 1032 bytes .../input_prompt/xbox/xbox_dpad_round_all.png | Bin 0 -> 1111 bytes .../xbox/xbox_dpad_round_down.png | Bin 0 -> 1112 bytes .../xbox/xbox_dpad_round_horizontal.png | Bin 0 -> 1118 bytes .../xbox/xbox_dpad_round_left.png | Bin 0 -> 1125 bytes .../xbox/xbox_dpad_round_right.png | Bin 0 -> 1117 bytes .../input_prompt/xbox/xbox_dpad_round_up.png | Bin 0 -> 1128 bytes .../xbox/xbox_dpad_round_vertical.png | Bin 0 -> 1128 bytes .../assets/input_prompt/xbox/xbox_dpad_up.png | Bin 0 -> 436 bytes .../xbox/xbox_dpad_up_outline.png | Bin 0 -> 394 bytes .../input_prompt/xbox/xbox_dpad_vertical.png | Bin 0 -> 422 bytes .../xbox/xbox_dpad_vertical_outline.png | Bin 0 -> 376 bytes .../assets/input_prompt/xbox/xbox_guide.png | Bin 0 -> 1193 bytes .../input_prompt/xbox/xbox_guide_outline.png | Bin 0 -> 1597 bytes .../assets/input_prompt/xbox/xbox_lb.png | Bin 0 -> 589 bytes .../input_prompt/xbox/xbox_lb_outline.png | Bin 0 -> 718 bytes .../assets/input_prompt/xbox/xbox_ls.png | Bin 0 -> 971 bytes .../input_prompt/xbox/xbox_ls_outline.png | Bin 0 -> 1280 bytes .../assets/input_prompt/xbox/xbox_lt.png | Bin 0 -> 533 bytes .../input_prompt/xbox/xbox_lt_outline.png | Bin 0 -> 722 bytes .../assets/input_prompt/xbox/xbox_rb.png | Bin 0 -> 684 bytes .../input_prompt/xbox/xbox_rb_outline.png | Bin 0 -> 800 bytes .../assets/input_prompt/xbox/xbox_rs.png | Bin 0 -> 1065 bytes .../input_prompt/xbox/xbox_rs_outline.png | Bin 0 -> 1354 bytes .../assets/input_prompt/xbox/xbox_rt.png | Bin 0 -> 673 bytes .../input_prompt/xbox/xbox_rt_outline.png | Bin 0 -> 824 bytes .../assets/input_prompt/xbox/xbox_stick_l.png | Bin 0 -> 1228 bytes .../input_prompt/xbox/xbox_stick_l_down.png | Bin 0 -> 1329 bytes .../xbox/xbox_stick_l_horizontal.png | Bin 0 -> 1257 bytes .../input_prompt/xbox/xbox_stick_l_left.png | Bin 0 -> 1263 bytes .../input_prompt/xbox/xbox_stick_l_press.png | Bin 0 -> 1309 bytes .../input_prompt/xbox/xbox_stick_l_right.png | Bin 0 -> 1248 bytes .../input_prompt/xbox/xbox_stick_l_up.png | Bin 0 -> 1319 bytes .../xbox/xbox_stick_l_vertical.png | Bin 0 -> 1405 bytes .../assets/input_prompt/xbox/xbox_stick_r.png | Bin 0 -> 1286 bytes .../input_prompt/xbox/xbox_stick_r_down.png | Bin 0 -> 1384 bytes .../xbox/xbox_stick_r_horizontal.png | Bin 0 -> 1324 bytes .../input_prompt/xbox/xbox_stick_r_left.png | Bin 0 -> 1317 bytes .../input_prompt/xbox/xbox_stick_r_press.png | Bin 0 -> 1364 bytes .../input_prompt/xbox/xbox_stick_r_right.png | Bin 0 -> 1321 bytes .../input_prompt/xbox/xbox_stick_r_up.png | Bin 0 -> 1370 bytes .../xbox/xbox_stick_r_vertical.png | Bin 0 -> 1465 bytes .../input_prompt/xbox/xbox_stick_side_l.png | Bin 0 -> 565 bytes .../input_prompt/xbox/xbox_stick_side_r.png | Bin 0 -> 654 bytes .../input_prompt/xbox/xbox_stick_top_l.png | Bin 0 -> 1268 bytes .../input_prompt/xbox/xbox_stick_top_r.png | Bin 0 -> 1359 bytes 96 files changed, 886 insertions(+), 173 deletions(-) create mode 100644 arcade/examples/gui/exp_controller_support_grid.py create mode 100644 arcade/examples/gui/exp_inventory_demo.py create mode 100644 arcade/gui/experimental/focus.py create mode 100755 arcade/resources/assets/input_prompt/xbox/controller_xbox360.png create mode 100755 arcade/resources/assets/input_prompt/xbox/controller_xbox_adaptive.png create mode 100755 arcade/resources/assets/input_prompt/xbox/controller_xboxone.png create mode 100755 arcade/resources/assets/input_prompt/xbox/controller_xboxseries.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_a.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_a_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_b.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_b_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_back.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_back_icon.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_back_icon_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_back_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_a.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_a_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_b.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_b_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_x.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_x_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_y.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_y_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_menu.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_menu_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_share.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_share_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_start.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_start_icon.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_start_icon_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_start_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_view.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_view_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_x.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_x_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_y.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_y_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_all.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_down.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_down_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_left.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_left_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_none.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_right.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_right_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_all.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_down.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_horizontal.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_left.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_right.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_up.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_vertical.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_up.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_up_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_guide.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_guide_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_lb.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_lb_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_ls.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_ls_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_lt.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_lt_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rb.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rb_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rs.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rs_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rt.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rt_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_down.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_horizontal.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_left.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_press.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_right.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_up.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_vertical.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_down.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_horizontal.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_left.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_press.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_right.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_up.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_vertical.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_side_l.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_side_r.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_top_l.png create mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_top_r.png diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py index 09861990d0..d2f97cec52 100644 --- a/arcade/examples/gui/exp_controller_support.py +++ b/arcade/examples/gui/exp_controller_support.py @@ -14,48 +14,107 @@ ) from arcade.gui.experimental.controller import ( UIControllerBridge, + UIControllerButtonEvent, UIControllerButtonPressEvent, UIControllerDpadEvent, - UIFocusGroup, + UIControllerEvent, + UIControllerStickEvent, + UIControllerTriggerEvent, ) +from arcade.gui.experimental.focus import UIFocusGroup +from arcade.types import Color class ControllerIndicator(UIAnchorLayout): + """ + A widget that displays the last controller input. + """ + BLANK_TEX = Texture.create_empty("empty", (40, 40), arcade.color.TRANSPARENT_BLACK) + TEXTURE_CACHE = {} def __init__(self): super().__init__() self._indicator = self.add(UIImage(texture=self.BLANK_TEX), anchor_y="bottom", align_y=10) + self._indicator.with_background(color=Color(0, 0, 0, 0)) + self._indicator._strong_background = True + + @classmethod + def get_texture(cls, path: str) -> Texture: + if path not in cls.TEXTURE_CACHE: + cls.TEXTURE_CACHE[path] = arcade.load_texture(path) + return cls.TEXTURE_CACHE[path] + + @classmethod + def input_prompts(cls, event: UIControllerEvent) -> Texture | None: + if isinstance(event, UIControllerButtonEvent): + match event.button: + case "a": + return cls.get_texture(":resources:input_prompt/xbox/xbox_button_a.png") + case "b": + return cls.get_texture(":resources:input_prompt/xbox/xbox_button_b.png") + case "x": + return cls.get_texture(":resources:input_prompt/xbox/xbox_button_x.png") + case "y": + return cls.get_texture(":resources:input_prompt/xbox/xbox_button_y.png") + case "rightshoulder": + return cls.get_texture(":resources:input_prompt/xbox/xbox_rb.png") + case "leftshoulder": + return cls.get_texture(":resources:input_prompt/xbox/xbox_lb.png") + case "start": + return cls.get_texture(":resources:input_prompt/xbox/xbox_button_start.png") + case "back": + return cls.get_texture(":resources:input_prompt/xbox/xbox_button_back.png") + + if isinstance(event, UIControllerTriggerEvent): + match event.name: + case "lefttrigger": + return cls.get_texture(":resources:input_prompt/xbox/xbox_lt.png") + case "righttrigger": + return cls.get_texture(":resources:input_prompt/xbox/xbox_rt.png") + + if isinstance(event, UIControllerDpadEvent): + match event.vector: + case (1, 0): + return cls.get_texture(":resources:input_prompt/xbox/xbox_dpad_right.png") + case (-1, 0): + return cls.get_texture(":resources:input_prompt/xbox/xbox_dpad_left.png") + case (0, 1): + return cls.get_texture(":resources:input_prompt/xbox/xbox_dpad_up.png") + case (0, -1): + return cls.get_texture(":resources:input_prompt/xbox/xbox_dpad_down.png") + + if isinstance(event, UIControllerStickEvent) and event.vector.length() > 0.2: + stick = "l" if event.name == "leftstick" else "r" + + # map atan2(y, x) to direction string (up, down, left, right) + heading = event.vector.heading() + if 0.785 > heading > -0.785: + return cls.get_texture(f":resources:input_prompt/xbox/xbox_stick_{stick}_right.png") + elif 0.785 < heading < 2.356: + return cls.get_texture(f":resources:input_prompt/xbox/xbox_stick_{stick}_up.png") + elif heading > 2.356 or heading < -2.356: + return cls.get_texture(f":resources:input_prompt/xbox/xbox_stick_{stick}_left.png") + elif -2.356 < heading < -0.785: + return cls.get_texture(f":resources:input_prompt/xbox/xbox_stick_{stick}_down.png") + + return None def on_event(self, event: UIEvent) -> Optional[bool]: - if isinstance(event, UIControllerButtonPressEvent): - self._indicator.texture = arcade.load_texture( - f":resources:onscreen_controls/flat_dark/{event.button}.png" - ) - arcade.unschedule(self.reset) - arcade.schedule_once(self.reset, 0.5) - elif isinstance(event, UIControllerDpadEvent): - tex_map = { - (1, 0): "right", - (-1, 0): "left", - (0, 1): "up", - (0, -1): "down", - } - - if event.vector in tex_map: - self._indicator.texture = arcade.load_texture( - f":resources:onscreen_controls/flat_dark/{tex_map[event.vector]}.png" - ) + if isinstance(event, UIControllerEvent): + input_texture = self.input_prompts(event) + + if input_texture: + self._indicator.texture = input_texture + arcade.unschedule(self.reset) arcade.schedule_once(self.reset, 0.5) return super().on_event(event) def reset(self, *_): - print("Reset") self._indicator.texture = self.BLANK_TEX - self.trigger_full_render() class ControllerModal(UIMouseFilterMixin, UIFocusGroup): diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py new file mode 100644 index 0000000000..5cddcae31b --- /dev/null +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -0,0 +1,88 @@ +from typing import Dict, Tuple + +import arcade +from arcade.examples.gui.exp_controller_support import ControllerIndicator +from arcade.gui import ( + UIFlatButton, + UIGridLayout, + UIView, +) +from arcade.gui.experimental.controller import ( + UIControllerBridge, +) +from arcade.gui.experimental.focus import Focusable, UIFocusGroup + + +class FocusableButton(Focusable, UIFlatButton): + pass + + +def setup_grid_focus_transition(grid: Dict[Tuple[int, int], Focusable]): + """Setup focus transition in grid. + + Connect focus transition between `Focusable` in grid. + + Args: + grid: Dict[Tuple[int, int], Focusable]: grid of Focusable widgets. + key represents position in grid (x,y) + + """ + + cols = max(x for x, y in grid.keys()) + 1 + rows = max(y for x, y in grid.keys()) + 1 + for c in range(cols): + for r in range(rows): + btn = grid.get((c, r)) + if btn is None or not isinstance(btn, Focusable): + continue + + if c > 0: + btn.neighbor_left = grid.get((c - 1, r)) + else: + btn.neighbor_left = grid.get((cols - 1, r)) + + if c < cols - 1: + btn.neighbor_right = grid.get((c + 1, r)) + else: + btn.neighbor_right = grid.get((0, r)) + + if r > 0: + btn.neighbor_up = grid.get((c, r - 1)) + else: + btn.neighbor_up = grid.get((c, rows - 1)) + + if r < rows - 1: + btn.neighbor_down = grid.get((c, r + 1)) + else: + btn.neighbor_down = grid.get((c, 0)) + + +class MyView(UIView): + def __init__(self): + super().__init__() + arcade.set_background_color(arcade.color.AMAZON) + + self.controller_bridge = UIControllerBridge(self.ui) + + self.root = self.add_widget(ControllerIndicator()) + self.root = self.root.add(UIFocusGroup()) + grid = self.root.add( + UIGridLayout(column_count=3, row_count=3, vertical_spacing=10, horizontal_spacing=10) + ) + + _grid = {} + for i in range(9): + btn = FocusableButton(text=f"Button {i}") + _grid[(i % 3, i // 3)] = btn + grid.add(btn, column=i % 3, row=i // 3) + + # connect focus transition in grid + setup_grid_focus_transition(_grid) + + self.root.detect_focusable_widgets() + + +if __name__ == "__main__": + window = arcade.Window(title="Controller UI Example") + window.show_view(MyView()) + arcade.run() diff --git a/arcade/examples/gui/exp_inventory_demo.py b/arcade/examples/gui/exp_inventory_demo.py new file mode 100644 index 0000000000..c91fad1dd4 --- /dev/null +++ b/arcade/examples/gui/exp_inventory_demo.py @@ -0,0 +1,396 @@ +""" + +Example of a full functional inventory system. + +This example demonstrates how to create a simple inventory system. + +Main features are: +- Inventory slots +- Equipment slots +- Move items between slots +- Controller support + +""" + +# TODO: Drag and Drop + +from typing import List + +import pyglet.font +from pyglet.gl import GL_NEAREST + +import arcade +from arcade import Rect, open_window +from arcade.examples.gui.exp_controller_support_grid import ( + ControllerIndicator, + setup_grid_focus_transition, +) +from arcade.gui import ( + Property, + Surface, + UIAnchorLayout, + UIBoxLayout, + UIFlatButton, + UIGridLayout, + UILabel, + UIOnClickEvent, + UIView, + UIWidget, + bind, +) +from arcade.gui.experimental.controller import UIControllerBridge +from arcade.gui.experimental.focus import Focusable, UIFocusGroup +from arcade.resources import load_kenney_fonts + + +class Item: + """Base class for all items.""" + + def __init__(self, symbol: str): + self.symbol = symbol + + +class Inventory: + """ + Basic inventory class. + + Contains items and manages items. + + + inventory = Inventory(10) + inventory.add(Item("🍎")) + inventory.add(Item("🍌")) + inventory.add(Item("🍇")) + + + for item in inventory: + print(item.symbol) + + inventory.remove(inventory[0]) + """ + + def __init__(self, capacity: int): + self._items: List[Item | None] = [None for _ in range(capacity)] + self.capacity = capacity + + def add(self, item: Item): + empty_slot = None + for i, slot in enumerate(self._items): + if slot is None: + empty_slot = i + break + + if empty_slot is not None: + self._items[empty_slot] = item + else: + raise ValueError("Inventory is full.") + + def is_full(self): + return len(self._items) == self.capacity + + def remove(self, item: Item): + for i, slot in enumerate(self._items): + if slot == item: + self._items[i] = None + return + + def __getitem__(self, index: int): + return self._items[index] + + def __setitem__(self, index: int, value: Item): + self._items[index] = value + + def __iter__(self): + yield from self._items + + +class Equipment(Inventory): + """Equipment inventory. + + Contains three slots for head, chest and legs. + """ + + def __init__(self): + super().__init__(3) + + @property + def head(self) -> Item: + return self[0] + + @head.setter + def head(self, value): + self[0] = value + + @property + def chest(self) -> Item: + return self[1] + + @chest.setter + def chest(self, value): + self[1] = value + + @property + def legs(self) -> Item: + return self[2] + + @legs.setter + def legs(self, value): + self[2] = value + + +class InventorySlotUI(Focusable, UIFlatButton): + """Represents a single inventory slot. + The slot accesses a specific index in the inventory. + + Emits an on_click event. + """ + + def __init__(self, inventory: Inventory, index: int, **kwargs): + super().__init__(size_hint=(1, 1), **kwargs) + self.ui_label.update_font(font_size=24) + self._inventory = inventory + self._index = index + + item = inventory[index] + if item: + self.text = item.symbol + + @property + def item(self) -> Item | None: + return self._inventory[self._index] + + @item.setter + def item(self, value): + self._inventory[self._index] = value + self._on_item_change() + + def _on_item_change(self, *args): + if self.item: + self.text = self.item.symbol + else: + self.text = "" + + +class EquipmentSlotUI(InventorySlotUI): + pass + + +class InventoryUI(UIGridLayout): + """Manages inventory slots. + + Emits an `on_slot_clicked(slot)` event when a slot is clicked. + + """ + + def __init__(self, inventory: Inventory, **kwargs): + super().__init__( + size_hint=(0.7, 1), + column_count=6, + row_count=5, + align_vertical="center", + align_horizontal="center", + vertical_spacing=10, + horizontal_spacing=10, + **kwargs, + ) + self.with_padding(all=10) + self.with_border(color=arcade.color.WHITE, width=2) + + self.inventory = inventory + self.grid = {} + + for i, item in enumerate(inventory): + slot = InventorySlotUI(inventory, i) + # fill left to right, bottom to top (6x5 grid) + self.add(slot, column=i % 6, row=i // 6) + self.grid[(i % 6, i // 6)] = slot + slot.on_click = self._on_slot_click + + InventoryUI.register_event_type("on_slot_clicked") + + def _on_slot_click(self, event: UIOnClickEvent): + # propagate slot click event to parent + self.dispatch_event("on_slot_clicked", event.source) + + def on_slot_clicked(self, event: UIOnClickEvent): + pass + + +class EquipmentUI(UIBoxLayout): + """Contains three slots for equipment items. + + - Head + - Chest + - Legs + + Emits an `on_slot_clicked(slot)` event when a slot is clicked. + + """ + + def __init__(self, **kwargs): + super().__init__(size_hint=(0.3, 1), space_between=10, **kwargs) + self.with_padding(all=20) + self.with_border(color=arcade.color.WHITE, width=2) + + equipment = Equipment() + + self.head_slot = self.add(EquipmentSlotUI(equipment, 0)) + self.head_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.head_slot) + + self.chest_slot = self.add(EquipmentSlotUI(equipment, 1)) + self.chest_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.chest_slot) + + self.legs_slot = self.add(EquipmentSlotUI(equipment, 2)) + self.legs_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.legs_slot) + + EquipmentUI.register_event_type("on_slot_clicked") + + +class ActiveSlotTrackerMixin(UIWidget): + """ + Mixin class to track the active slot. + """ + + active_slot = Property(None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + bind(self, "active_slot", self.trigger_render) + + def do_render(self, surface: Surface): + surface.limit(None) + if self.active_slot: + rect: Rect = self.active_slot.rect + + rect = rect.resize(*(rect.size + (2, 2))) + arcade.draw_rect_outline(rect, arcade.uicolor.RED_ALIZARIN, 2) + + return super().do_render(surface) + + def on_slot_clicked(self, clicked_slot: InventorySlotUI): + if self.active_slot: + # disable active slot + if clicked_slot == self.active_slot: + self.active_slot = None + return + + else: + # swap items + src_item = self.active_slot.item + dst_item = clicked_slot.item + + self.active_slot.item = dst_item + clicked_slot.item = src_item + + self.active_slot = None + return + + else: + # activate slot if contains item + if clicked_slot.item: + self.active_slot = clicked_slot + + +class InventoryModal(ActiveSlotTrackerMixin, UIFocusGroup, UIAnchorLayout): + def __init__(self, inventory: Inventory, **kwargs): + super().__init__(size_hint=(0.8, 0.8), **kwargs) + self.with_padding(all=10) + self.with_background(color=arcade.uicolor.GREEN_GREEN_SEA) + self._debug = True + + self.add( + UILabel(text="Inventory", font_size=20, font_name="Kenney Blocks", bold=True), + anchor_y="top", + ) + + content = UIBoxLayout(size_hint=(1, 0.9), vertical=False, space_between=10) + self.add(content, anchor_y="bottom") + + inv_ui = content.add(InventoryUI(inventory)) + inv_ui.on_slot_clicked = self.on_slot_clicked + + eq_ui = content.add(EquipmentUI()) + eq_ui.on_slot_clicked = self.on_slot_clicked + + # prepare focusable widgets + widget_grid = inv_ui.grid + setup_grid_focus_transition(widget_grid) # setup default transitions in a grid + + # add transitions to equipment slots + cols = max(x for x, y in widget_grid.keys()) + rows = max(y for x, y in widget_grid.keys()) + + equipment_slots = [eq_ui.head_slot, eq_ui.chest_slot, eq_ui.legs_slot] + + # connect inventory slots with equipment slots + slots_to_eq_ratio = (rows + 1) / len(equipment_slots) + for i in range(rows + 1): + eq_index = int(i // slots_to_eq_ratio) + eq_slot = equipment_slots[eq_index] + + inv_slot = widget_grid[(cols, i)] + + inv_slot.neighbor_right = eq_slot + eq_slot.neighbor_left = inv_slot + + # focusable widgets + self.detect_focusable_widgets() + + # close button, not focusable (controller use B to close) + close_button = self.add( + # todo: find out why X is not in center + UIFlatButton(text="X", width=40, height=40), + anchor_x="right", + anchor_y="top", + ) + close_button.on_click = lambda _: self.close() + + def close(self): + self.trigger_full_render() + self.parent.remove(self) + + +class MyView(UIView): + def __init__(self): + super().__init__() + + self.cb = UIControllerBridge(self.ui) + + self.background_color = arcade.color.BLACK + + self.inventory = Inventory(30) + + self.inventory.add(Item("🍎")) + self.inventory.add(Item("🍌")) + self.inventory.add(Item("🍇")) + + self.root = self.add_widget(UIAnchorLayout()) + self.add_widget(ControllerIndicator()) + + self.show_inventory() + + def show_inventory(self): + self.root.add(InventoryModal(self.inventory)) + + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + if symbol == arcade.key.I: + print("Show inventory") + for i, item in enumerate(self.inventory): + print(i, item.symbol if item else "-") + return True + + def on_draw_before_ui(self): + pass + + +if __name__ == "__main__": + # pixelate the font + pyglet.font.base.Font.texture_min_filter = GL_NEAREST + pyglet.font.base.Font.texture_mag_filter = GL_NEAREST + + load_kenney_fonts() + + open_window(window_title="Minimal example", width=1280, height=720, resizable=True).show_view( + MyView() + ) + arcade.run() diff --git a/arcade/gui/experimental/controller.py b/arcade/gui/experimental/controller.py index 7d20c208d8..040f12e43d 100644 --- a/arcade/gui/experimental/controller.py +++ b/arcade/gui/experimental/controller.py @@ -1,24 +1,12 @@ from dataclasses import dataclass -from typing import Optional -from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from pyglet.input import Controller from pyglet.math import Vec2 -import arcade -from arcade import ControllerManager, MOUSE_BUTTON_LEFT +from arcade import ControllerManager from arcade.gui import ( - ListProperty, - Property, - Surface, - UIAnchorLayout, UIEvent, - UIInteractiveWidget, UIManager, - UIMousePressEvent, - UIMouseReleaseEvent, - UIWidget, - bind, ) @@ -58,8 +46,8 @@ class UIControllerTriggerEvent(UIControllerEvent): @dataclass -class UIControllerButtonPressEvent(UIControllerEvent): - """Triggered when a controller button is pressed. +class UIControllerButtonEvent(UIControllerEvent): + """Triggered when a controller button used. Args: button: The name of the button. @@ -69,14 +57,21 @@ class UIControllerButtonPressEvent(UIControllerEvent): @dataclass -class UIControllerButtonReleaseEvent(UIControllerEvent): - """Triggered when a controller button is released. +class UIControllerButtonPressEvent(UIControllerButtonEvent): + """Triggered when a controller button is pressed. Args: button: The name of the button. """ - button: str + +@dataclass +class UIControllerButtonReleaseEvent(UIControllerButtonEvent): + """Triggered when a controller button is released. + + Args: + button: The name of the button. + """ @dataclass @@ -90,7 +85,7 @@ class UIControllerDpadEvent(UIControllerEvent): vector: Vec2 -class ControllerListener: +class _ControllerListener: """Interface for listening to controller events""" def on_stick_motion(self, controller: Controller, name: str, value: Vec2): @@ -109,13 +104,14 @@ def on_dpad_motion(self, controller: Controller, value: Vec2): pass -class UIControllerBridge(ControllerListener): - """Translates controller events to UIEvents and passes them to the UIManager +class UIControllerBridge(_ControllerListener): + """Translates controller events to UIEvents and passes them to the UIManager. + + Controller are automatically connected and disconnected. - Controller events are not consumed by the UIControllerBridge, - so they can be used by other systems. + Controller events are consumed by the UIControllerBridge, + if the UIEvent is consumed by the UIManager. - #TODO change this This implicates, that the UIControllerBridge should be the first listener in the chain and that other systems should be aware, when not to act on events (like when the UI is active). """ @@ -140,139 +136,16 @@ def on_disconnect(self, controller: Controller): # Controller event mapping def on_stick_motion(self, controller: Controller, name, value): - self.ui.dispatch_ui_event(UIControllerStickEvent(controller, name, value)) + return self.ui.dispatch_ui_event(UIControllerStickEvent(controller, name, value)) def on_trigger_motion(self, controller: Controller, name, value): - self.ui.dispatch_ui_event(UIControllerTriggerEvent(controller, name, value)) + return self.ui.dispatch_ui_event(UIControllerTriggerEvent(controller, name, value)) def on_button_press(self, controller: Controller, button): - self.ui.dispatch_ui_event(UIControllerButtonPressEvent(controller, button)) + return self.ui.dispatch_ui_event(UIControllerButtonPressEvent(controller, button)) def on_button_release(self, controller: Controller, button): - self.ui.dispatch_ui_event(UIControllerButtonReleaseEvent(controller, button)) + return self.ui.dispatch_ui_event(UIControllerButtonReleaseEvent(controller, button)) def on_dpad_motion(self, controller: Controller, value): - self.ui.dispatch_ui_event(UIControllerDpadEvent(controller, value)) - - -class UIFocusGroup(UIAnchorLayout): - """A group of widgets that can be focused. - - UIFocusGroup maintains two lists of widgets: - - The list of focusable widgets. - - The list of widgets in. - - Use detect_focusable_widgets to automatically detect focusable widgets - or add_widget to add them manually. - - """ - - _widgets = ListProperty[UIWidget]() - _focused = Property(0) - - def __init__(self, size_hint=(1, 1), **kwargs): - super().__init__(size_hint=size_hint, **kwargs) - - bind(self, "_focused", self.trigger_full_render) - bind(self, "_widgets", self.trigger_full_render) - - def on_event(self, event: UIEvent) -> Optional[bool]: - - if super().on_event(event): - return EVENT_HANDLED - - if isinstance(event, UIControllerDpadEvent): - if event.vector.x == 1 or event.vector.y == -1: - self.focus_next() - return EVENT_HANDLED - elif event.vector.x == -1 or event.vector.y == 1: - self.focus_previous() - return EVENT_HANDLED - - elif isinstance(event, UIControllerButtonPressEvent): - if event.button == "a": - self.start_interaction() - return EVENT_HANDLED - elif isinstance(event, UIControllerButtonReleaseEvent): - if event.button == "a": - self.end_interaction() - return EVENT_HANDLED - - return EVENT_UNHANDLED - - def add_widget(self, widget): - self._widgets.append(widget) - - @classmethod - def _walk_widgets(cls, root: UIWidget): - for child in reversed(root.children): - yield child - yield from cls._walk_widgets(child) - - def detect_focusable_widgets(self, root: UIWidget = None): - """Automatically detect focusable widgets.""" - if root is None: - root = self - - widgets = self._walk_widgets(root) - - focusable_widgets = [] - for widget in reversed(list(widgets)): - if self.is_focusable(widget): - focusable_widgets.append(widget) - - self._widgets = focusable_widgets - - def focus_next(self): - self._focused += 1 - if self._focused >= len(self._widgets): - self._focused = 0 - - def focus_previous(self): - self._focused -= 1 - if self._focused < 0: - self._focused = len(self._widgets) - 1 - - def start_interaction(self): - widget = self._widgets[self._focused] - - if isinstance(widget, UIInteractiveWidget): - widget.dispatch_ui_event( - UIMousePressEvent( - source=self, - x=widget.rect.center_x, - y=widget.rect.center_y, - button=MOUSE_BUTTON_LEFT, - modifiers=0, - ) - ) - else: - print("Cannot interact widget") - - def end_interaction(self): - widget = self._widgets[self._focused] - - if isinstance(widget, UIInteractiveWidget): - widget.dispatch_ui_event( - UIMouseReleaseEvent( - source=self, - x=widget.rect.center_x, - y=widget.rect.center_y, - button=MOUSE_BUTTON_LEFT, - modifiers=0, - ) - ) - - # TODO render after children rendered - def do_render(self, surface: Surface): - surface.limit(None) - widget = self._widgets[self._focused] - arcade.draw_rect_outline( - rect=widget.rect, - color=arcade.color.WHITE, - border_width=2, - ) - - @staticmethod - def is_focusable(widget): - return isinstance(widget, UIInteractiveWidget) + return self.ui.dispatch_ui_event(UIControllerDpadEvent(controller, value)) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py new file mode 100644 index 0000000000..09e512f9bb --- /dev/null +++ b/arcade/gui/experimental/focus.py @@ -0,0 +1,297 @@ +from typing import Optional + +from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED +from pyglet.math import Vec2 + +import arcade +from arcade import MOUSE_BUTTON_LEFT +from arcade.gui import ( + ListProperty, + Property, + Surface, + UIAnchorLayout, + UIEvent, + UIInteractiveWidget, + UIKeyPressEvent, + UIKeyReleaseEvent, + UIManager, + UIMousePressEvent, + UIMouseReleaseEvent, + UIWidget, + bind, +) +from arcade.gui.experimental.controller import ( + UIControllerButtonPressEvent, + UIControllerButtonReleaseEvent, + UIControllerDpadEvent, +) + + +class Focusable(UIWidget): + """ + A widget that can be focused and provides additional information about focus behavior. + + Attributes: + + neighbor_up: The widget above this widget. + neighbor_right: The widget right of this widget. + neighbor_down: The widget below this widget. + neighbor_left: The widget left of this widget. + + """ + + # todo set focused when focused + focused = Property(False) + + neighbor_up: UIWidget | None = None + neighbor_right: UIWidget | None = None + neighbor_down: UIWidget | None = None + neighbor_left: UIWidget | None = None + + @property + def ui(self) -> UIManager | None: + """The UIManager this widget is attached to.""" + w = self + while w.parent: + if isinstance(w.parent, UIManager): + return w.parent + w = self.parent + return None + + +class UIFocusGroup(UIAnchorLayout): + """A group of widgets that can be focused. + + UIFocusGroup maintains two lists of widgets: + - The list of focusable widgets. + - The list of widgets in. + + Use `detect_focusable_widgets()` to automatically detect focusable widgets + or add_widget to add them manually. + + The Group can be navigated with the keyboard or controller. + + - DPAD: Navigate between focusable widgets. (up, down, left, right) + - TAB: Navigate between focusable widgets. + - A Button or SPACE: Interact with the focused widget. + + """ + + _focusable_widgets = ListProperty[UIWidget]() + _focused = Property(0) + + _debug = Property(False) + + def __init__(self, size_hint=(1, 1), **kwargs): + super().__init__(size_hint=size_hint, **kwargs) + + bind(self, "_debug", self.trigger_full_render) + bind(self, "_focused", self.trigger_full_render) + bind(self, "_focusable_widgets", self.trigger_full_render) + + def on_event(self, event: UIEvent) -> Optional[bool]: + if super().on_event(event): + return EVENT_HANDLED + + if isinstance(event, UIKeyPressEvent): + if event.symbol == arcade.key.TAB: + if event.modifiers & arcade.key.MOD_SHIFT: + self.focus_previous() + else: + self.focus_next() + + return EVENT_HANDLED + + elif event.symbol == arcade.key.SPACE: + self.start_interaction() + return EVENT_HANDLED + + elif isinstance(event, UIKeyReleaseEvent): + if event.symbol == arcade.key.SPACE: + self.end_interaction() + return EVENT_HANDLED + + if isinstance(event, UIControllerDpadEvent): + if event.vector.x == 1: + self.focus_right() + return EVENT_HANDLED + + elif event.vector.y == 1: + self.focus_up() + return EVENT_HANDLED + + elif event.vector.x == -1: + self.focus_left() + return EVENT_HANDLED + + elif event.vector.y == -1: + self.focus_down() + return EVENT_HANDLED + + elif isinstance(event, UIControllerButtonPressEvent): + if event.button == "a": + self.start_interaction() + return EVENT_HANDLED + elif isinstance(event, UIControllerButtonReleaseEvent): + if event.button == "a": + self.end_interaction() + return EVENT_HANDLED + + return EVENT_UNHANDLED + + def add_widget(self, widget): + self._focusable_widgets.append(widget) + + @classmethod + def _walk_widgets(cls, root: UIWidget): + for child in reversed(root.children): + yield child + yield from cls._walk_widgets(child) + + def detect_focusable_widgets(self, root: UIWidget = None): + """Automatically detect focusable widgets.""" + if root is None: + root = self + + widgets = self._walk_widgets(root) + + focusable_widgets = [] + for widget in reversed(list(widgets)): + if self.is_focusable(widget): + focusable_widgets.append(widget) + + self._focusable_widgets = focusable_widgets + + def focus_up(self): + widget = self._focusable_widgets[self._focused] + if isinstance(widget, Focusable): + if widget.neighbor_up: + _index = self._focusable_widgets.index(widget.neighbor_up) + self._focused = _index + return + + self.focus_previous() + + def focus_down(self): + widget = self._focusable_widgets[self._focused] + if isinstance(widget, Focusable): + if widget.neighbor_down: + _index = self._focusable_widgets.index(widget.neighbor_down) + self._focused = _index + return + + self.focus_next() + + def focus_left(self): + widget = self._focusable_widgets[self._focused] + if isinstance(widget, Focusable): + if widget.neighbor_left: + _index = self._focusable_widgets.index(widget.neighbor_left) + self._focused = _index + return + + self.focus_previous() + + def focus_right(self): + widget = self._focusable_widgets[self._focused] + if isinstance(widget, Focusable): + if widget.neighbor_right: + _index = self._focusable_widgets.index(widget.neighbor_right) + self._focused = _index + return + + self.focus_next() + + def focus_next(self): + self._focused += 1 + if self._focused >= len(self._focusable_widgets): + self._focused = 0 + + def focus_previous(self): + self._focused -= 1 + if self._focused < 0: + self._focused = len(self._focusable_widgets) - 1 + + def start_interaction(self): + widget = self._focusable_widgets[self._focused] + + if isinstance(widget, UIInteractiveWidget): + widget.dispatch_ui_event( + UIMousePressEvent( + source=self, + x=widget.rect.center_x, + y=widget.rect.center_y, + button=MOUSE_BUTTON_LEFT, + modifiers=0, + ) + ) + else: + print("Cannot interact widget") + + def end_interaction(self): + widget = self._focusable_widgets[self._focused] + + if isinstance(widget, UIInteractiveWidget): + widget.dispatch_ui_event( + UIMouseReleaseEvent( + source=self, + x=widget.rect.center_x, + y=widget.rect.center_y, + button=MOUSE_BUTTON_LEFT, + modifiers=0, + ) + ) + + def _do_render(self, surface: Surface, force=False) -> bool: + # TODO: add a post child render hook to UIWidget + rendered = super()._do_render(surface, force) + + if rendered: + self.do_post_render(surface) + + return rendered + + def do_post_render(self, surface: Surface): + surface.limit(None) + widget = self._focusable_widgets[self._focused] + arcade.draw_rect_outline( + rect=widget.rect, + color=arcade.color.WHITE, + border_width=2, + ) + + if self._debug: + # debugging + if isinstance(widget, Focusable): + if widget.neighbor_up: + self._draw_indicator( + widget.rect.top_center, + widget.neighbor_up.rect.bottom_center, + color=arcade.color.RED, + ) + if widget.neighbor_down: + self._draw_indicator( + widget.rect.bottom_center, + widget.neighbor_down.rect.top_center, + color=arcade.color.GREEN, + ) + if widget.neighbor_left: + self._draw_indicator( + widget.rect.center_left, + widget.neighbor_left.rect.center_right, + color=arcade.color.BLUE, + ) + if widget.neighbor_right: + self._draw_indicator( + widget.rect.center_right, + widget.neighbor_right.rect.center_left, + color=arcade.color.ORANGE, + ) + + def _draw_indicator(self, start: Vec2, end: Vec2, color=arcade.color.WHITE): + arcade.draw_line(start.x, start.y, end.x, end.y, color, 2) + arcade.draw_circle_filled(end.x, end.y, 5, color, num_segments=4) + + @staticmethod + def is_focusable(widget): + return isinstance(widget, (Focusable, UIInteractiveWidget)) diff --git a/arcade/resources/assets/input_prompt/xbox/controller_xbox360.png b/arcade/resources/assets/input_prompt/xbox/controller_xbox360.png new file mode 100755 index 0000000000000000000000000000000000000000..d7b71da573d148fed476b3e2a12df46c402901e2 GIT binary patch literal 979 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDXAW%nvG~mP0JCp{r~oDs%o?2O3?WHD@bMf`ml<)BJETI zc1Albi8tS;Z`^t6z;3I)n=*S+w6S>JEOG8h3>ML8M0C-iZ&6o}5%% zp~MiPDz1ONZBMr$$ch< z&uR~zGdS=I+IDpoUMMg>@Ob*cV~mqqq#0kO%TFzy*3BUD^@`72Mvd!23*>JfDMxA(_xVu~M9AEqS8M*7_9dsp+JT2gOu{kBew5jkVhmLUL zgRWwCpsgWc1%{9F->@*cZ)-n?R|;jr_+Kr&SkmfZ=$Um zRA?#d=D2o7N0Kdb?ZFrIX^N|gbf4B=G+hd3@FP@%iJKlcWfq{`l01Hv0oDgW;6VO#hxa*YuV=HI^vFN-a^Lp3=eF;cVovF^X?tfmfBrYGA6acB3d*`J z>#hY^*eJ98xb;Nvya(gLd!fD=Zaf>lp46Qs$UN=W6K){&?AH@xAQiRNbe7_ST@3N_ zZ!eyiZQJIczd_UJ@y*tZzn|T`KBigPZIsMqV7rkuk%#Mqqu`Fc6M7^B8p^ciT%RO5 z?Fa*}`XoW-Nt$*0D?dMLPDwF;crX6O{Yw{nWF|O;y}6>BeQ(yxdWL!LxdbaLd)4>O znf9I4{7;!(TgTe~DWM4foLCu> literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/controller_xboxone.png b/arcade/resources/assets/input_prompt/xbox/controller_xboxone.png new file mode 100755 index 0000000000000000000000000000000000000000..33c8b759776cb1197163ded329cd1afb4df82e6e GIT binary patch literal 927 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDq)B>co-7)6tF-2JOA5_yxXB%YK(K|8c+UHd@XOkQ%7UintGWgMxF!)W{Cp~Yz7Tb zX2XB>1;^ZdF6175that0TgT0%^9ziw<#&Iooh^4HwLzmci_`qJHbY_2O3N*8*j7x~ zr8L8@!Ef%u-wcMO?r)fG)HS?ijJV5xVVi8h!RPG9AMhj`V0|%Nmu-W>3^#^|dZ#0I zpWkL+lezu$`LU%YdhAm(na(IYs7=p|d9<(TtZu`O(+vso@~7BD78vO1nK3N8f4yF7 zm(9MZTIWJLO-&g}G8y_W8|{l?kXZ1tVdzO^tFD^ zx~WWz_Us!yXY>b_<$5;Ms`CG~Z%J!d&d@ja#=5Oj)h%bN|NqBIh~cJ#f9oe7-$S=# zI6~AP%;(%%aqHLlb+&RBZgSUuXI3v(SofZ_d+81<_C;$&Gha;B+PZ?{#q7pk^#_?) z{;=JgoK{hGoR5h^B|(Adi}vnC>%KFemzdDtz`WypVMoz&&x$oGOB*~*4ovwzacSLx zTl2VYDd{X-b?~+>*Mm-mL%%+1H0&CVx;-VS2E#;eML-q2HA( z2Lc)APc~qlzrs>tfso{ex%2Fv^A#^x;c&q9xX}wQg%19vdlC)@D)bXr7$3$tFfhJ+ z_JZ-p+XZZPckcHU{n2H}4=a(Vw{=*zWI{OevZ)f6w=#&YY`9gtP;kSVClOMNN39ID z*C<mnBHmE2;yDLXyl*SU;%m-c`08a&Tg_inK@-?f@4 zOnZ^pi{MSy!@o`6^FR85+8@oEW*kLtG#px+OuR1tmYy^lTdK8Z|HEt=bd>4Sk4tNT PS%ksU)z4*}Q$iB}<%z7W literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/controller_xboxseries.png b/arcade/resources/assets/input_prompt/xbox/controller_xboxseries.png new file mode 100755 index 0000000000000000000000000000000000000000..8c0a1954651e740afcf05aa848cb1093584a8999 GIT binary patch literal 970 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD&Kv2kLZ0qV?%HJUWR`H`({Ul50}PnxKWD?&Dv4{p z`^+xiPvdR=_RXr{#ob>MB!dGNSNfIO#W634yRUL;1vkT^J!Khvdl?ENcAFgWW7uDI zs+_51Q)_3@2Y?_LrphD=k?}q%Fym`{ku{ET# zu+&FS;n!&3-_5g@;qqhd^EHe5>`mWR?rJkNWwNmecrvFbe%3n14zq^ihwl8AzkBiS zwl3lGi5B}2f9%3Gp+M{Fk3AI<{3#6V*Ee6_n9%FesKK1yJ5}q) z1IvH?$*wFGOKdt<^{J}gacW`{IM4Vkd+JQF|Hlhe7)wf%p4SBAaJ#5-ns9g=ZM8kY zFoUnbXMTzXL-KjPCq>UZ7zC#B+I@9%`(Ji^8IJ-7gWm4_oE{u``R^>f$iP^9?1RJBEnl1`X@otR{NPYe zPPh>(CsV*yhUV3AvJQ%jY*GwW+y|C9gm4Fza;yB(;S^PI)O6V(yf5lr#OVietP2YB z{%}p*5PhsIYT7johW1ZNUkfE$cbqAbXyDw+ax-j&8cT!L`otq&+*%w&jzrJ&<78?0 zt=6!b`Nr4Pn?f7D#5RPmPPorHA+%u<|GA(rz0=!Y7ip$_p^!T90j-n6V*(YZdhvs}KW|nt!rS z(nWvnrKvS+3=zc--^+9K9G%M>e{S_O?j>ChS?*70`MdJptp9;G=6_-S*Ov16p5*6G tz2`i(ufM3L^?$BJ10xpn;5=g;!_u@vuXx@vtp?^M22WQ%mvv4FO#qb3xG4Yt literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_a.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_a.png new file mode 100755 index 0000000000000000000000000000000000000000..2399fc263be2c40edfe062170cfdfa21370876f6 GIT binary patch literal 982 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDvQL(vI4BQEfIt{EJ z<}<0(?~<`o-hFwwJo5#;_@`>?-h8QHn4i4T<$`Df)xzmE{8WV?BZxwW1M2r z@Q5KskpDP?M_nt}kIpyN-x121< zXJYYc`h8uy_P#*{W5*O0dGVVb=jU%{n8I>5IqLkn!W--p zw%sY*tY9v!#4hZwbEN#!sm1P_Up>51zrWN~EcHb})bEyv-A5R|wcYr5hvE3d-S$lG zB5#@&O==GL-TzIHbDU{V(; zo3=&18x!Pm*B=4pucw7gRP zkGIZX(ODD(ubJ3l<|GL__D_qfYIHT9MZ(r3| zU>hbAjI6}Xk}&H8&N i36$P9Fn|;Peum_{^Xu$%m(2j?ECx?kKbLh*2~7aTJiwm- literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_a_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_a_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..8dd7cb9f77034b57f8b1695c3b178f4173163f1e GIT binary patch literal 1269 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDpk*#bgXQrNYTiBtd#Qw#SOFeRS067-Sd4VT>iPc)1!K4mZ2BK~ z_NuR|2Rln|{lU|JD||Z|m<75`V@tl4ZHzDEO_+J`>PK^?Jl4+)-)!r6K0g(kX7`4b z;r^!o$3wq-WjM`v#pEBq<4cD{hxm`)x#5ziWZAI2rlk7L-)}t5vJFq_L$9)x33qI6 z5M#EO>2yq}qnI)4-6fIdB9j;vrdUsXn|qOwtsqwCLrM@IE5{7=_i3$!F?FGTzUWfZ6@i>l-KPSUhgL92DDtj)`{=oYlCZ3azL^(e_5#%|^ z<(R2q8{T?t!4`pOuZ0@&_)e!@6-48JxQH5EpdfsfPv<$uU3b0ii6rhHD^7|J{{{m zBPPPh&dgqY)yYg30nZ zd1uvbC)ZdPT6?WIUnpy?@OWv*@^>GS7W}w+W@qfh#~0uAFHrvf%eD2!=C4=NDtDb> zn;A0c+y#{*Pd}RJr)#Z?%Y z#$2lOV9qeweV3!{i(=u0?Mps?-x(Hug=_v?(bT=i79alIm9cHrhRXPLGxJN2UY)x1 z?liW#FO6E~-~4*?Z9$A$u=w0n{14Vm{chj#MZW7pTvvAB;lKRd(ht~nFy=LYsXE5Q X2Rm5AnP+eU3myhfS3j3^P6zanPW!H8vaQ5mNGKOV0Ol7}t z;f@shfln+veoh~%m{#;3if-u86FbT9fSDnr;ULo<-5Fkat1~#6{&Upm-QM*;VGpxK z3xo5QW;TPWR#C|mHO2;}gb&FIj51OUyNtg-5M8igV5w~hA9kxPWm0mfR|BB^MDsqL>oh_ zRLe|}mRgPtry2ImR(!ap*o4L5%bt$7`dq$%4@nJmh77+|H?lRXW_(pw%99h$+@mM7 zeD((CC9R3ycXv&`7|L_$_=?G9g8%+LxA@JJvVY1N>7^-`mVBA`DSL8IWK`ek*r;{7 zKD)LV1WrryjGgte>+)w-1<~cZKJAg5@_ZKC(X@*T0=8rvSmJ8@>&){X(xRpht}olY z+^I4qIPi8!{2EW&yJAJlO@1<{RBhf>{o{R4-cIlBRz+-L=GFhruN_(P?%Kcar)Ep0 zW;dLXy?wVXmD!IW$2@WUpOcxJWbUe+{9yH){XjxZzL`x<5M%Sj4_r@#_zJ$;3vD#H z|5g1&#TBDT6Q^Wqq};0c8@wc@OFGkhLebs#E<0!RDlAui$$3!uPh`_EB|(!*U$&-r zs{j6JJhdcf(!b{{Gjs~mPE>JQhQ8$~HTJ#{<8kqXaJcc(yQVzno=DA5D&M!mHt&OC zsAWR;oAW21{MFq4PegF<_Bk%|XZ=q!L`tUi(jP8(+s@hm%0>*Hu6{1-oD!M<+d`uD literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_b_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_b_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..474d894f8b2976f5fd2eb083e9e9ea1c98086706 GIT binary patch literal 1196 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD+5n zO^X!-9HIps9lreEUuI;U<&&hZvvcOkrt2$Y7L;3UsHt>d&S2nOz-ZFIc7fqvFoXDk zA3r7TITY+?GmwlJmd9c{=lq_LT14wbdCtnyeOPB|b)l4=1q3vpu*x{r@h`eJly~ z|5r?ZvT(5npMyz#+w|R)zJiRb3(9g&m3+;+9zT_R!+M4Cf9#A~n=Kk{KkF|$DOdKt z?KHy-?{oi4#h;fn++`3wTd(|pN60{b)zSiuG)0L6YIe1iclK7c>G2-8kgwX?XT!3< zWPuCgy%5HWEDK^9;#TqaTDE#KoPO!|=!VIyF2)*B-kM-debxz#Yh+F?ii$)}8Kn1y2R)blbi9F|m6Gn}W9oWjEJUA>XL;+9!y%fk+4)|A+W zntCs03)cn4E7@%rzA3O5xX5Y$ymf>r;~IaWupz_etwjg7@_hTs_$ynphFQWw@`&0j z35Fflf_B_nIf17jW$}%dt*_(nGaR#N$V|CXz$_8T5XNc5EBNi{D~8XXQX4*`GHkAq zbf}kZIFVZC$yOuFFnfL5w7wqu6wYQjOT#HqhjN1Ngc=>SyDrH((fHH&~plEs5 z=i(gp1P|3IOP_arbhq4hVVQ^qmzT*dugB;ON>u|ebI)zOQ%X^ zpIaBIb#%EX?<235D!YqC_*Z@63Mn+dGSfc5k7yB>2j0xmkwb^fqr@}ia6 zcO){Z%ni4%&WgG9m!o7!{{Ddd_ov54|MLh=$=lx`Ja?Uk!1n}M@$`i6>slNNDt%hw zsvqW`nby2G&p9Gfi`g)HrivUdONcTjhslmDjg4E>Hgs?ptt+U$_$NlVTQff?QL54* z?f8+kiMtv4FNLXXo9H>+)M(?jm{}drQ$=FaG-0iCfQnyHcsKF>z6y zlRnSiymWgP&%Iir53WdkyKLEQuwr>%bmHfkJC=T4s&vQMVs0Dnh4bhAujIb0Ikn%_ zcWu(`nHR1}r@j1A>|`?g%8i=5>nfiPFG;Z4xb?Q<@hR*I7t@xTZ;!6Md;8z^-LcEr y3o76Koow{^&z(bwA#qnO{7byZAO+9U_H(}PN?DjPV=u70VDNPHb6Mw<&;$VL@Fx@i literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_back.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_back.png new file mode 100755 index 0000000000000000000000000000000000000000..3318c6a5adfc5f8eaaa6d57f997d43d3fda034ed GIT binary patch literal 860 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDEaktaqI1@ zmy?>TJ0Uu zH+ekgo{-FR!LxwTL~XK39m7tU2W5@S4l{EW?O`afV|dGaz}M}e9K-TI!G8W%w?@SV zfBUNNKf83j_qpYFc0MUBJr!ch;AgzySb)abR@e1&eqGov8@=J}SD85se&=UA=KQ#S z+QF6m^}5{W7TjlJeWB0rjN_uL)3dbV!pVQ8Lz+`YB*?efV*Te$UV->ttR z?RA&QgC*g9mi62jeU=Ha`W6nIj8pU`JSCcI~U~@{f;UL2`%>xWxOHYNe>#XKu zDbU#zT>WbOWac;9Zt6Dt-m1j4z?Qd=b-^X(Gs(Z09K0F~H|gs+Fs{mayFO6-4pT^6 z;Q`hQx#F4qI~H*9yKFCH*fvq1xRHnNXoXC}&j}gd7D^O0dKE~>GX7AUIn&-f=7PKI z@pl#hvSJsGB?}bD%zu16DoJqa&0~L-#$J|*zOZqA_Vsy}6g6kAE$Lhux0K<^wymxb zFV6n^&pPLL(*6lk>TGrYZ>V87!|m{dS)fjT`5RYu3&tbed(?9>+>-x$|nMK}Md@hcCDm`0GWX`wlWG0|lXkZ_kz~!L-O>7@` zWO!Z_5hzwK{36rKU^v;xqD3(wT6*Q;wYMW3>?ZzmJh7Z%5X%P@om-u+ya7a6`@$07L1{qlQS4;UtC+4DPeGQL># zXZIiB_YA&M=W}KxJ6w30&ZNP^7&(1D_X+0%t4^P1UE}N!x=t;x*M`OC)YUUAKF$qB z_Tmm|4T~rLUEEM$su0H;v5rkfG(4>#QZwP%{IzC_9&HmW-2Uj%R?*G;iyxIpeC&Vx zs91)ZYto~j2_9aX9tBPE?7H+Qa7x#yPmcnoyE-u=7ZogOb*1P{X zWlUE8cK`JM`a1JBV$gTe~DWM4fZEH8w literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_back_icon_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_back_icon_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..a9ee088896fda59792127c27a7c3ec7e4ed52c5b GIT binary patch literal 882 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDrIOlcp4mC^_V{W^WR;)=So@s1eO9T>&buIKABFCUKz1pr-@Ob0So#k&5-N%Uzu}4 z?!}Cfa0c6UmuUvC{TQnJrZzb;FwW^(8MUZrEkkkhFW$sFrVDna-PgY|JbU}xef_kT z>mAhoMjEd!|B#WzI4xUv>B@h(N56gzVmFABOU-ub*;`Y-hOxqO{`Reb=l1y}En}YZ zt>4>wL9OLQCsvEE-nYeGsdp$aC-lGH6D{q^->$05dZ6@j{aaC4#s>up7dHPpJ&TFI zAcownh`35J9YnGvD+8?HsWSF6CwZS3cv90-w%}D%`I5+s-5vzwY$jo1#PS3*$lof`^&1Z zy;L~Vo@c{xB~GRV+kUT=FU)>_J(O|Am(+%bw;4X&W_bLWfp7Bd3n8L`!<)GfgdF`*FXjMPY%{1pH;pN$V@vJTmWyg0t z|9oUy;+gX3MHBLz^&1uztXTBLb-8#`K-L1+l~aAZ-T%(CRy~!P{#$d$<_423?WZ13 zRpPwvxnF7DlsLgCmwh70+J z*PUuS`uv33D%M)*H;YpGC!YO%WMN`P=rgwCWov)kK34Yn_><*M4>E-xS>~;B;}G6s zT~rZUC|Pul}e>S|B^ z*wZ`5qduEWZSMXn!MXljJ zeAAfD%%gjMzhDoiaYGB!vM|T?tt%D?DZFcp*uA0s_+LR6)(cyC_N0WcFfzP8@_ZVD ziq;i|2Em35>FQb4j?)=@*rsh`RWRJ$#<992ufpJ>UikIU-R=9lE9{uxyi$G;mn<+* z+r{L;oSW}@bk@YryS{sV#To1D1HaNv1iP@yF__M_{8q&(Hn-^Rk;JR#f4*P-_=WEI z+zhb>d(#IDGn(H#;ZQQ!u78bx_4J1a7!I{>sAtG2`d4cpXC?Gmhp*G_d75qgiHp0; zr?F0$w^eRY$^6^rv`oLVCiFU_F}j`SzS--^aiq2{%<1k&XvT_ zGCL$^es=twf9LcWnB1P(?)|#T^1#~}sf;%+$tjm|8A z3|{BIo)T}=VtJ8t{KGdMMvm(qGu4|cWu8s3UbII3!aDUYt^uz)lGa(gW8V^a|9TSt;FLJGm9H({u)o;aV5hQduSIhhi;8>I&c2eu+J7JS2;DsS zH|B2cJ`077*LQz-6{5T%IWOqj&!~r zk$1_|lzHASy9t*frJE9cm+qcubhpI&Skwvow96ZvRI^0NgD6re{`Eo6NRJ-xl z8OzA8g?nzPLYsDUuUC00004XF*Lt006O% z3;baP0000pP)t-se6|36wg7y#0DZUsez*X8w*Y*&0DH9neYOC6w*Y*%0DZOqce?<6 zw*Y;&00000eYXJQXy*X{000nlQchFPkFPIJUvD4pf4@Hu-=6?3wDXMs00S~fL_t(| z+U=X$a@-&gM3F#3T+RP~ZJesu-p$I;4TDpuVqR^DBc{1duX_8(p%%abSO5!P0W5$8 z@Sg(2aJuY2BfB%i9|O=XU*#sQ?DTwqkW;Bd%3&seb=B7YFgQC2!0rW%-A(|&o97Tf zfVf?SiffCK045bWk`V)lxpYO2G5}VU&QMkY;3xxej03nb6mAqCm%+$m0K@G!3Dms& zV>(dN+iwum089}I(+8k0DS%7&07S9&-w9#|fG>jKc>q^L!?6Im2!|#Clr(_i09+9d z#{fuEnSk&;@E>2%H}eoC5>l4gehjpR$hV zK)?VDpHfsb1_D6e_HYgk{W4VD8Ay-Mr0yKN0t8lqj0^(+;8+PVGIRid?@A{q91j5W zbtam)Hh|_NXe}p0-2^x|32qxF!=nk{I0;@CCqvf+*eMAfU*CkuaBl*5N`m{>D?{Hf zcmYV11RodKM9-hM0F(sXV_MIyao7hCA3r)#G86&G&kMI>L9Rd*6HlmK}7U`B=l0QoiL0hgh`OjxOj1c8~*({mX$kG26aD#6=0(fkc8$JIOd zrVQK5v0*=f>p(NSC2N%BS~Aq5o6^*}eov#D(#9}2TL9EAU7K#2a`Z7#CP(_ zqervq;WT`|bww6s=x|#5gjBo40rrO2@!SB%k-I+vM-=RWqLPDH`;Yb!1-zp9J_ose zd^^zRyRYd&eX8&+8KT#8Z&aloj~%`#)pGzeep&5jwC{=A@+r5~mMK_l^6hU_#Uv)_ z_EY3)Mn)RIuw}dLM-os0kWE60eC1S2mTq-V@!HH6K%;ywRy=gQ1_q(xRqnTN1u0W5$8umBdo0$2dQ0N+{9u5x+{LI3~&07*qoM6N<$ Eg4fEZApigX literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_color_a_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_color_a_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..9699da0f5f9c258ba96df29944d9edec328a9a15 GIT binary patch literal 1269 zcmVC00004XF*Lt006O% z3;baP0000pP)t-se7FF7wg7y#0Diate7697w*Y;(0DZOqd$$06wg7j#0DZRrd$j<3 zw*Y;%00000eYXI}RPD3?000nlQchEE&#zB^-!G3}KM$WDzwZE9ct};ei49_7NIRV|&kihL3kjjn{Er*m05`x5a0A=`H^2?> zp8{Az(uK7-Nyh#)fQNMPnP}0;$M*q@o1RRYocShzm+a_o2I;>9;KwJ}M*jr>eE2#9 z5WuQ~?xyia_-i9M9yTW`+Y>;1bU&!1(u^8D5Jfit>-PN;Gc`+lQfK=B_}#?HR%Ujz zgYN;Lw+b#z_R~Q&(%#w zNCyOsf5FTBcuk0a0}zWfU3B$!v0rfwV41r?@>;-_(vj4|b z4D|rZ0n{}94?*cbqQVcw+}K26A~}G!d{lef{q_t)9f0J2Rn-P|6Ns~b76Uk$LdFt^ za{+qp(lqB3k^sJBJC*Hi(H;hHT`cfqQVs?1m?vMp%S$eyc|qa9FfWA-xSR+Vxqt6>R3deON(I0Kkhmll>FBt!f9 z&Hw-sT-1--Y6Le-0syZssX~I#j!|Y_=Yc>Q2%i7}w1YB0?!$}(rwNr|W@40H+JH)s z2$f+-Zxy9As89)7i^@=?Rd@mzAr0#7;^#mazLsh%0)&jcrX?u0sI#J3W^5hW=$qGTKFFjZJ)}XWyma7tgY7Wa~I&>-> z`=!%xgfAxS)$HJi&m-hySaCIDFQ*}&zvP{A#pU#U6oq0en2shhG7J^hbCK_7y^iE% zmX@KfNl-8;n)N!uY;#hERVkX~Ys+0nm|R#wJ~lrms>x|B5;UB@OUXyprRf%n>XIqm z&1i%(r4KMui8e0tN}RcyKI0qifFN$oCfj3tJ8?Totj}>BHz4Dsh^OxGVxale%(-3B zRr1ST=R#(&tpPdzDy(XZ;hl*&ns<3%q zu9i9Yf;1f4~rY%2Dkxk ffE(ZjxB-3vC00004XF*Lt006O% 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 R8Zad?c)I$ztaD0e0stH4WxW6Z literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_menu_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_menu_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..1848da3400b7f7ba2ee9c5ceb90721bd37a205b9 GIT binary patch literal 1086 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD#8t!5Ke3p%O0i#I++XV(G2j(CC zjB{9@o;|&X>GeOU150Wml*IPTTF1U2POrl=e?7|uF4@}DfL}@sclBLfe`RI(^6<1k z2SeVE57syBW&Wow_|<=wvw1bA0^j=gZ7Y`FTxhM(mBn!G{eA8eS7hFJ?p@8ipj`O0 z`^>u+%XYCH_)+)Jp-9(uPrczM#w)ispFDYOud-VqV;r|uchH>LpH0she|&mbuh;aP zA?LUJv7I-LIecPP=&gS=rFzaR6($~soQox&R+LBo=_+Qp&2z5rzuJK?1KtPMJ~q$u zpMCG%Lq7(IbN}ksEz)GaAtaFfhuu+6(eYV(QrfH&J}RsmcAY=4e)sjs3$>X8eys0W z<802jf#U(ofp85KCB_ZP33FE8=#b?KWSHAM*C|>#YBFQZRE|Gfn*OX8K0fz8_31~C ziJ-$#`E-UCB??CvoN5kAFuWFGk!tX$U|>)1dKT)j^FcY^BZf6MPfTWTQexl0aO{d) z4fBor;X8IQy!-xs8pDDh&koiNYRaw*KWaBsedZGS+;qfc`7MUvq|An23b%!Xn|JJ< z^@~AGE=J({!G(4Q1il~AZ}=xAe`n8z^Gq{b_G~(P?E5!vhd6G9%?vLU9;hz5GF#e# zIfH?dAy(c&m@)5s%Xa1;+5F1?-^(9=bgRilz0rF z&imHv`E0%Y##{cnHvTo790rP<0!lNq6C5^ko>6HMj`w2D{4=M+^`onfq$2-?PZE(* z+blOM@tV0wUP0Oa#FVp7Sv03FIlaj4Tkb^BB{9~9XD&-_o*DUmO8XQUw_j{`IG;H6 z|7v9K0bf{`}qu$E=@ejz`*#<)5S5Q;?~<+ zw>LEz@URAmyx6<(|NpJWPcKfGtY+J1Y`vZ-GAP>XQPJ-r9iUNY;J|r?q#LJ{qtxfI z=PJ%(N?NAIcj_XCK(RCr-$L#ivAKr>@2+D_as8#PxSC<3N$C^|K8~%DF1(8yuJkDN zGHy!~yUn3se)vR@L)Nm1OBf#idEO-4u>M-ZE7gX(HveNca5H$%7T}hjaBGT3OhXDo z$CTy`FTxad-SyZmr@Knpq5rXGDK{U3-?Vt80v!j@XW|@M>;gxAvNj!Hm|*k&fv0+d zRf(pQY_oZT{2wi!#`jF09;{8_DiS|1yT!fx)eV;#<|#WHF0sb2`ilp?3Xj>{U?zS+ zq~ZJd{&Npwfx&LaaBXKp=thB$%VM6dSQOFa%i#CXz=$#B=;IqK4@9MfxE-$MoYY}h za&w9o*M?cXFJDSXFhtz`blK2=L4aG}Iiu41r&FFBHM{rQ*6@B`axjNF$4Ib^5 zyIBtl@21S1STA~|;}1{6^eHc!YX2!{hgiM-;}^9`_4XeX{jkgXpZpJ5XTC;k+5Mb| zn*FK8@%%co!s4!Pn|)n7(M)e|me~aJjJt7doF#_Yar}S8x*|XS3O&dda-(>W7?<_u gLqFI-2?q^)U{Bb1PQjlkn-e7A>FVdQ&MBb@02xXhaR2}S literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_share_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_share_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..9f9141784512d27c516c97e0583215ccaaa11610 GIT binary patch literal 880 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD4q>EaktaqI1@ zo0A?Z@VI_#y1DWH|Lo<~X+r5uXHr+rdZ+c$^oG^62@XL={>^taU|`0A4y;<;zF}&@|E8HdP5Nm(` zWbWTdlRWodXL+gT?{<|-AWmO!nc2RrHMX+17^a20%$z2Y9Jg`z76zN<^E0pMq|b}a zSk2Dy!ffAE0q-~g)xX*CtIUHN8yE~aYpzu-n15B*)+_Cf&5>(C>*1+)uCP6u$cG>f(C%*7M}s%~uPJ9IvcpnU@im zvBhyBPurE_6MoFjOWc=lB%jgPb)xLMZ6!l7kIIkeBQs{1#h!9Ip5uOVzLx0gTe~DWM4f D<^PWA literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_start.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_start.png new file mode 100755 index 0000000000000000000000000000000000000000..907a954a2a1b8f7635694a65b4b5bc433e1437c2 GIT binary patch literal 879 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD4!>EaktaqI1@ z>(g2kc-q#AXwLp#e`~9blCqcJoXyWw%##m3lvX_HKjYc+)7KOjn6RJ&`xz&ch;%z1 zxaOuj)s<02eL_ylX(xtB&dZJ}urOX(qImnul6g!Y*zd0A_#@aL^5I6y8TJKITr-yK zWMCHAq{yGpl4SObp+F!)4YR}(bWCufFEhTAG8_hgw0CPxZ3+`Sqi&YLhPQjj6zX}ebm04xQ?F>)N^C~Zcw}{H8CwGhFd5Tqf z!zOH4uC_gOl0u>C%#*Lpt_cjCz&!tc+KqFC)k|FJ@x2nk>ISA)2@6! z;IMJlo=g8@ckWBrTJU4OwAlUon`SgTe~DWM4f DqUDO< literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_start_icon.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_start_icon.png new file mode 100755 index 0000000000000000000000000000000000000000..ac6c97fa6ec6f63e8d5a991d889f890343f92acb GIT binary patch literal 666 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDpom-{{GUA7=s@h6eWVDm-0V%j)}R zN5<)sA`?z26!uK&VOTlA$f89vfq(9mi-+sP0{oWPb37GMFtw7^eaX0Uvg4OEtRY5S ztC(|A#AdSvoG|E;2{>u8N+e;<|E&*L70R+5`h^bU&HJx-gSElh^2l8N6;Fhwa(}2` z(3d~-D7x|4*Nltz8p=X`)Yw;Z&tqVEeY`GgYQ}nz2!?yoAF6k{T(E5LF>3IbAlabE z>cP^?_#&yn;llF+#f#nU07(cM4a&c%eL<=lySjJqyn#&b2g<)C3OePQR1TGoI zJ+Uq73URz1;mq5*8LvK5joHc&_wVXjfzZ`XOLUH2T_f_^&h6^EWaLa=jPgpe0q;pUNu;B@Wr=fYCW@Weu(<0XR`U>*K&62 p{$p{%dwcg7*Gv3gfEHmOt{vk{?sG-@%sxLrJWp3Ymvv4FO#uCLB`4x;TbZ+DxmERt*P6u7Cz6kp&EJ z=7;+Xd*(j!f8%sVZq}OjTpzq|tyb3ECwxG^I$425P{8NjY~|Snq8nl|{<%fyG1lDC zcmML1;rFlS?wiltS1f&b-gtGV{{A!DL^sIkD~9jhx7V_6?>5F|$*w!6i9GJxYQ2?V zUbp_PYdX*81!t^eU-QDGPC9#%&$CzF>uqXa+@U1@r_9hvvh7V4!@?cKH@4+9 z1u!o7{>tlMz%(Pi4Ys%Ke-*bfK4sr_ec|FWD=s@K*fGznJ3g=Vyt{fs!FN8VbNkN< zGgWJDignNl_h4Y2@I1bZZCh~XY(~!t)h(B03h#>>F===oSjgb>U}vRok7=F(gRsEm z^+$RRL@_q7Op|0z`lWS+L4nbDQ^`KwV{5q@I3E2x_&~m4VxPfs1_y(=`Yu-Ox1t(! zHaw5@e$JV;j;*8V=eH{wOz-k#kAlKB zy~8ZJhaa973Vb!SLn^rN)wHhLX39HjD~yYDZyR3tH|^lt9X1iA3XZqh`Bk2JOw(fU z`28mPrt`{n#(>{XlbJ22OfxjlV946PhVezjzaK`zyAsx9?Jsd?Vvwu){FlwRS$@JF zdDk~JFTWT5iMh@B$LibD>|5Wn{ujDFy!gA3)hgk^#Cti{zo_Ll3G?igmB`XtS>%53 z@v-w)V=kZVPmX(W_5YnY)AY2~b2;rSNEVj%{7|@e%fBNvu1VM5E~~VZdn#9FG|{GV z-}}|Kk0nYr_N%Q)nE1N-;C_+aQru$uz0DR!KI{(OK5L`<$!A+16)ep7de1jPEW1BD zb@$uwr^^+eWC;iCnme15Q)7F>(bsJ|1$K+Be{Xqo8Ao_wv5%Oq>@Rr{^n?gXlMlGx Y$n5?2#XhG7nD7}qUHx3vIVCg!06}S{n*aa+ literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_start_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_start_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..ae48df9cd77928037e4095776aa4cca2da6b1003 GIT binary patch literal 1077 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD)x!e}4Xc{`U-LRIK(hFfdQ_ba4!+xb=3{ z%}t9H1RM&J+5i4uFUxys(E?o~Q`6A9`sQjXaxbU8^L|#Z!@?-QfCc@NX0YR{n*7wU zuDflbIqL&2z4fkI_c$6>FAY-Q=x1oVwKPh{G@kKF`|bY%SJWBSO+UY=()NIa{k~;c zQBO0yx0~8ty=eY6g8M?dyyBa+^~F;A?p|l)Ik-5&w;yTX2Rr!;^WxR2{eP zHj-J8U-^Hk!<`w7I|C2gSoPwDuGVUnpgxB!u{Rf=`q&|2z92iY;^iZ;6ozjKc3W8< z9Q8fRu!DC(Znpn)aUGU~k_~Hg8T4C?_npIVpv)|jC<-Z)bv+V#xlh)3Fw zZ<@XCdzW!eyFTcG&dJ?t_KB!QNAZciDifV6n!>YTXZ0?lPcC0M941fLXxr(hlm6S} zg_qyt66@FZ=4Vtq+h9~B_KRbIaQ5DJQ9o|R1|Qp7yk~x0-ABE@914>(w(Nao&hYSk z-O+A_;3UCRhUGgY)NSwDFiZ(8_?4<~V8fAne_1A6JK)Q(XLTeWn?;bHa6>>ynHqb6 zM~Uij8GDvk@P7(Q3b&umb!!jB61t&FpkKgSr7gt_* zmMJ2^%8y&9@MGS?sfBxl+}A3k&7XB?>Uyq8wr5XoaTop6xXHcEK5pm!<5zE8dwE`4 z-=163%U-^Fb33m`;I7vV+gK$Yr2Y)d`nZ1sqY%r1>EF~RFZ>a(q@pXc+~V6l-Nxm~ zby|$GwrW^xaWvG)J%9YuvgjG_XP5Y%b+~KGzw>gWbkpL;$M0=k9e_vjoKmL9F{`m~)GBe#77?@Uhx;TbZ+0&N9@YdSPR6FhKmYu1uiJ8~dx~sd@7+A@Kf9&uR8`apq~^_!Nn{jDfTDdG3uGFr zeuvII6lljYVP*KfmA5p1Gxybf>bCm*kkjK!(U;nFxXPK=0!{#&l^xKQD?El?=w)uhG zKZg%o0ykGI;+wEqbU_;Lf*HO4^)$E~k`HGUF>K77C^F&Zj!gm|Hn7>GFeH6e;ymz) zQ^JPx;A++dz7NGFi1@M^s7qWB%@q|W5htheR-~V%8!!z!T|Mm-* zYfG3eTz&h){~$}koxY1_9aR-pO;2%aSQ9rzit)?oB58(AI_mxmpAw5Im?rpdPXEg6 zAiw;9*i5d5+IjBw4BH$V?=tf;+*x$+U1OT(we)Q-br@cl-7Wsk_~wIO<~PQan$1!b zvi$z7`fqMLlWREB zv033-^?{R%)I7|)o6fSENT)u`O5C!$<)HCTjzbf6+uWJA?qB~>ueaes|L5r4o3Ffe pf0|3S$mUneuhmEB9AJVWY38Df+qv}TMFCSZgQu&X%Q~loCII76e~AD9 literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_view_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_view_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..33e28fbe638eec9f97e3d50959b7c3a67cdfd14f GIT binary patch literal 1168 zcmb7E`#0Nn82)^H;}$~5=(LKWGWSc12qn>!q;bESTg;ugE4Buy9kiQ!doVgtp{zBj zyVfPlp{}P5+ZfxzsrH1r#8@yI%g)(9u=kwzdEV!o=lSt@?|Ha6qmbv3000z)O!5*l zsCIie2W; z3(M0qb>JxOGC^y?-y8toEeeU~lfArM_r8{;C11#D)WY6A^B=h4o%lmRIr_!^mc1ao zYFcg`L6=)GwdRA14R8iS;WdOGaX_vb^Z6Ra@y|qTs108j)L78E_zGUa@ye~7Pbh%J zG^Z-+be?g7}N$lCYumw89*ConGB!&Y=31dZA; zcrO*BpC-LKDrAF{$`73O#y7A!M7}CIN7UCMt`)^&tbA+83N&jbB{XaI+wfH=_SC=4 zs-X_lNR!o9=Tt1}K>#wKS1)n2(ouP&i5@dL;~}4U7<*mnuIyOI%>sef!8lpc?0eB$ zufc~uqm&3OFTMdTe<*o@OFcAgMvklmig5XQwLEQZ!grDz8iBSVtI^7wDiG*OCu2lF z+HHXXZI`*92%Qd++K%Tg^u;Ju9Gfr)O^G_>P~Q-y*j!g!$nuRE^0+ZRS$5VS?( z_Xd!32jR@;2{YBqV^#K}IrF5ML(96HYoqCOhX`BbQuy$ZfEurqL!ScbKcPb9Rq2-L zasEBo-6!UJ1}7KFWohU3*wmYJvBLrx5tvQQq`YB^2eWElCtxpW*=guBFYc}5{8vWt z)l7$uWp`d=)e#y^3zU@6Fj6sV;3BM)iQ||Z(s_>VXWTR;Dsd9-#yM3LUX-%)Zgv@m zC$%vaDiJosY0HHu&+43kIaE_H)_k_lTveybMqg3iytoN#*DNw?>}{ARH;`=t*tn&# zG^??O(2x2^duoC6$Di)@%WlP47Z*$o31ba6PD>N)JPmC!Eh2}0@hr-c-_WEkeXbLg zUEg1;zb!OdWhssn|4L6C2%KLQCf_>w(R6il{lnAwopJ4l3s2hP`DbH||FxAM%?Ej4 Xa(mF)hm?yG{{%p};YMnAq~-quO_>)B literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_x.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_x.png new file mode 100755 index 0000000000000000000000000000000000000000..b04ef414e98dbc15274a7525c3242778643c1e39 GIT binary patch literal 1037 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD0p`xmLntP|TKVCd=^Q zX(fk(LxARHos|ll3$CBp7TV3&GGnz4nv-v5_eXHXhHU!j8OLga}O*#`e0hLhJBST=sG7i!^Zu;spWj(tPq6&>*d>4~3x z8hoV^9y8w1$(zQkAj#;W;&mR7>yO zb=oiVZ*$!ig#(wfa~1}y*vI8pq2wXa;I{3eXm{O{t4}+hH?_;p`(IGOcb-@M9^Vv; ze*b504~ix@UcLHoi`8PQJVpkcRk{2A=5nv-+r)5Svw-KbD8s(Udn(Heb}|K|e*LuV zdwgrzA|{5FF5j+qT$Pc1?#Xc6>*c#Q(@O*kRTvap`}POET0BEqL4OZ}!div!`U$f< z8C-hr%bP4{KI6yaaCbpTa0IW)<1itHD?7v+#T{gISqlD6dEw~A^vma}=oKJf>$x-Qgpd8q9PHaqXtHIY-1A^dLeVn%`bu@TprS?qQIZekuFa42q_IPpcjsvTA*R!{0 z=LOijYt&->GyltiThS)xB~7OV-FsgmV4Tmcb*p4P_t{ow{)yce&F-?lUM9C)^4*kQ zb_{a)Vef6P-Iu)kN$+mf;hh@-KE2ky*6SIu^m;t=`*R-N%o*>t&aFDlX2WnI?~v3F z^W|}$UR}E$6ZiTbpF_<3?pV27w;0(Lo?abce@JlmdVk)HH}>6^mw22hr@YW@<_Sl& z>*cj6Q&uY~uVq>sGlxy&uK(IO>OHo1=9^yFafQbu>GReP9_qXQboQ0hT~oUkpl$Q$ zjKu5Mgq6FDm(7lBKFj^2chAGDXBp))I%eEadp5zke#hM%5fijDtq#}4+^yJuz0AJy r$iC>etL04p9Zmw}5(5Tsma%8h6FaAHeZ&5<3_#%N>gTe~DWM4f%?;yp literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_x_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_x_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..e5dcc05019c60eea0bf3da2aa1ce16deec8a6a5a GIT binary patch literal 1336 zcmb7^|3A|S9LGPKO=HGzOevNxbs?4=^QCXoFn7Mpx6MW7YeGlK6@B5uxsBtDbc~hM z)S;q$$?|m)VVOwQ$wZ@W9rCSwne2A`2lskB-tXu8{dm0JkJoRnEQ+TqN_Cqm001aA zGRa3l@V`PRDf;q6{+a@?D53`u03NWA8=-K;j;FZ$Im`c#6i z2XNtXKdOyxXn~sgbt+3pU}N$hG#f~7*E}>p>FY_p^Il8&A zziDt%;Af&+M$q!P8b*@wQz#-+;}w|cm?b7W!NAn{i*wx)VoY19SwFLHG+F-sG4PzN zz35iB+|X##8?@8(=;1BU)ms^F%T3m@$HluPEXs^=TCz$Ahh{zU<=Mj)Gf@2msPmwF z*kxSW=Y{D#gq3RJ_IcaV1Oh5u_q}bfWsIO59jU)vZ=dirbxL`iF+?kYp({|OU1U{U zqZ1Jt5M!lr+!cwd+wlW5M-6n(DF^UiKCFy%O4pc>wrAy{CRW=xs=rAoB5bOw<{HF$ zU#%8<9PpinE-Ux+=M@}Tf#bGp#a*s21FD+q%#A*Jt7-FVNs$A&J+&FJGJv zhJ;8K^pab0F?>m+V}R@Wn)Zb@Q6R@O<);7LJklxa``u9GVGe?-u4lP_QvL2irID^U z_=(dui-=Og@FIq@lEI@QLd4D&!_*$qdVbi2NpIRo1P1_=h{1O}dl z{~`+deO`aETklM==T!)_d?jqnrCQyvbj{a8*EU5kvF!Tt@0`Oy4uvzKTP!6Q9=uEC zP;i*nr?n~h)GUU&wMo%?r5yCOp7grL$Pt~pMK^=t&DO);>KInVy6t6HS{C(^`Ng;M zLKeF}AAi?q{QbN@4C9YgDf@U8WTP0|vlvfg{i_#x!qyNWs(XrY#VRRJ#-Pw`Dl9j) zFq%m+gqA9EHawF)u!r-U7V84fhnfy6)Wi}Rl9&Sm8V(%2`sa)6O(XHW%p49mbHAUt zxW1u2)?SFC;A?u$PuE3N{uV#G9von>DeC!sdP#gr<;|1p9=vS+Zf{}hUdB834NF|% zy!krT&Gn38TXtEMt~QQ3uFJr%(DUu%eYujpi&+?4cI^5!+3;N8yiOKD27xD2?%$hd zs5t!vD?@bWoc;O#Kjfuu>+#TGxX+#QegEF>Q#0nWDntg&RErc+7E8!G_a;DSZY0AB zsr$2~cb;HMxV&KR)`>BZ4cD}!*FN6LAa~n#$HXg46&gh!jiPuT=qaS`>A%Qq!LH&c zzm(&HAfHO;7Bz>rKhJz;?bycf@i((bbi<;Febo#XsVJ8B3qC%oOco%sNp$o9ir3^ErVq${v$XeICBV<_<{ z7tVcEIQyYz<|amu9~-Qqe)KnG?mW50%0orT@tfM^3G9}_1^vwI_vg(T-dRNL&~sLSLdb3a{Exv=h1nS zI-?RhH#=P0ePC&p$covnhn}(S>NP6d8gOf_>}S6`KaHa4-M0_N#ZA$2zuEkwF!$5H tCz|)}c1WCDzRbP%{|0D6W;6h%YJOI=i&-ms-_|k!fv2mV%Q~loCIC5QwLbs= literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_y_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_y_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..affbcfa3c02005b8a81b942da35ce8c53511b828 GIT binary patch literal 1253 zcmb7EYf#d86#el~NioO`dT5BY>8g=YQtFn0De2@ZGb*!EoU+oCDcyo>`im*Bw)vna zQy0cI&2$zjMSK+GBS{T>lp(2TCFW}?Q%iGn*>C%G@65U9o|${*&i!%(VIh00E!SEC z0IUN8{K8EP{R4P{FnYunM|g2TorfLG+~p& zL-r?yW=joMj%)3ADc!}GPhgw9^F?-=q@Wq_t}@|~PTw8tM^atd z%@#G4Rg-Re1EF$38@!nUTIT9bza$9P_rh|F?uubTwUYoKqIuhK$7W_fA7_}eDkRB^ zLh$yPC)7W3;T(xDbup6!Iw&JcHCf*FgILLfIeVlFjoY-UH`416(}4{2r)JJ8E-BX- zcYw4jZt?9O9e@!=S}vf|aLmdW>nX<%l7XET#q><%Al6#vxT1PhT@oMfX$f(5K7U6WOWBn##$;R`nF;j= z*!w8<;jYt3S+{C5_g622tHy;nzkl74QoXqFP-o#d&Q+7$%K|0~Nlhzf(mhn%LtST; zEhF~NJIX3vWzcj{6o*zlNmJdnfxSLB5aeFJnkq)<_%)xg@&;QKUq4s`c`qeq#M)5z zEKpo6Y{^y!h8{nGu`H>4;!U0sT(lt$BQMa3pG$|FZ^3mKxG%gHKj;{6YU&#a$|C04 zsC&RuVs675BPg}&nBvU>x3%0?F!e0RLs7#UY`_DXMK8!9if;M@Kan@CKtr5$kFPa( zT2XoXg#iIZJ;IY2AU_#@M3l(j4L5^nS=80trIAo>;cZFTVKS9|Cex|38LW9Ry2-zm z1Ze`aYvz245$Zy?S!A&bQtjGlCzelwgw)`igWax3Ec-<5dc)apmsy5Kp4+`PdKFx+ za+N~33kM43a14%lf>v>stQ^(};P{=DUdZ&Psj$M@XSV6f3@>$6ZIShrU$-iBZ{1b9 z6=T)gTFTo_HLHCv31?^dr(LU?~xfay(mOoa-U6sR!u5; z(w zk)dx>DJ$v6NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh0azWsXn`ThGD4zHCC0g7Jsba4#HxcBy^Azzb$L|bB7 z--9=*DJ5Unidga()D_ko6ENq9^7Vh2wqU<%%|(j`zu(qg+nzc3bh|fDa~S`e?OW^D z-jh#Fc&z?9bivnyUVz0NsoW_CeJ-mWV8Ps)7t4_ zQVN$F4l=Z|Suih=x*&O=8>}qC{6fZadA1cM5AqsfnDg$R-#sn;`?IzpD;@j#yM_KN zAfqNUsLguZ>L7adqmx4avmfF9j}zC-{jNS%G}T0G|-o z=Z0?2joe-s02!{&jNG0XIz2Z6F@Rj45}?#P`LIwRi>oBaFZh0azWsXn`ThGD4zHCC z0g7Jsba4#HxcBy^Azzb$L|bB7--9=*DJ5Unidga()D_ko6ENq9^7Vh2wqU<%%|(j` zzu(qg+nzc3bh|fDa~S`e?OW^D-jh#Fc&z?9bivnyUVz0NsoW_CeJ-mWV8Ps)7t4_QVN$F4l=Z|Suih=x*&O=8>}qC{6fZadA1cM5Aqsf znDg$R-#sn;`?IzpD;@j#yM_KNAfqNUsLguZ>L7adqmx4avmfF9j}zC-{jrxWP!PsM&_GSk!QwwJ2PXqL0wqCy!Sd_v_s`G2U*B)PUVeW6 zeuna`_uYVUKRsO>Ln>~)y>*kf*+9TGaC7ow)pz@Eng~cJe`4u;mz&V{$6R@LQ86D- zEeL$5Zph`byw3krW|=R4#H++^sR^qBFEQ1aehzZj!WhUkBlmy~>y literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_down_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_down_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..f31d0a5c8a0ffcde2b4c4e5888fb654d15bc5c5c GIT binary patch literal 400 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1F{QUL#{rl}1=9NxXWME)q@pN$v$+-9Sh9TD>1Ce7N z&)7DZZ*YI{?$MhC?i<84O3E(;29&IJ+wx>e*{006Pc{63OKR>1@64Q>ShS9&z%Hu-GY*X*Sr$MnbrzvI33Vx)M1fQy$~DuZMkpx73MR7Kn)BG ze{2m-Y+T&Xef-vvqBD$jYkZB4>s&Z~OQrY>vo0f-Yy#(mmkn(Uhh;v0PR_me|I(lR zEMRRloDrVy<&zxVhExkIxWZV@^u+3c)(LCtS>HY8&AfSn`>DzF`}=cM`*oIm+Y~MZ z)*`_lpkA!Qaw?^-)nVS5kL&Gv+c$2z)&JRX&0ZOA#+N`f22u|=*&h}u2^S|AfwXwK L`njxgN@xNAC~~Ys literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal.png new file mode 100755 index 0000000000000000000000000000000000000000..09dba4e577b31c522b8e8e0bb4bd545bb24b77df GIT binary patch literal 435 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1!2l#}z z{s)8SMsCjy-GIy&1|X5=hG5dj^_dY!5D1<7wtE1_nk3PZ!6Kid%1QdGa+I2(Sh|lT6<5zW$m= zBy(dstWYpOk=uH|ME`2`OF3-neUblVka_1H1i3BNnc=7 zRoJ4o;5t*4a6lI0QcjL22PEAL2R<|UaTlm1TxALqHppiD{Q3Il&wqa?E%3L#xTTrR zU`xzNWYq<-58nQaj$h- Wd<%a5J@D}#NXXOG&t;ucLK6T=O|5zW literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..5f7094ace2626fc293f06f49b32edd4fd2912008 GIT binary patch literal 377 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIzK_Wk=A_S;*k14Z9@x;Tbp+AP{xp#v^f#g2`WD>gGXuI0a?FyU7NYvU1iErE=_Gk3S;g~@cRVc`%^aCq>% z(XUUHWr_VYx6_3@8OLv}ufyXJPbP-X?Q!chik22QpRMg<84uJOph}+#+?1=pb%XAXYKMr q`)gu#ci$h|H8uI)MxezGGu6F_eYU2lEp!9!F7srr_TW@bg3pFbUum%c0mKJFJyWW&rK-PK2r6s$< z3+moq33+`q6sQ&i8tyZnnf%#K?bAKq&2|g=+rQpwQ03Cy#UOmNN9({ThSgjzQXH0Z zb*M8zl!M6w@(j1qjd>I!&*bG>t22b&(60-xXER{4bx5+#XDGeElC%9Y+lNc65rQ8s zu$Cw(ykJs7(#`OWU!sIDj-_W;gDcCHT@63~eg0D+KmFdZm%_exUogyh=*5Ply5T*e zUGjf1RvpXF>s1oY-PrHSIDLOv_x`J8SJ~DatKCuW&CJCPRlh^#1KWzErMbRm+rvRZ Mp00i_>zopr04OA?5&!@I literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_left_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_left_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..c813a64ae1857efb32621658edd14fe7d44c0323 GIT binary patch literal 389 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIeH_Wk=A+z*}i0E+(fba4#HxcBykAzzb$$gz*- zWDY79FyD-w)7!~ja6$S6C&%i*!);d=R4#Av%!t4JWu+m@{`}V$?*GbPwTgc^&;WzV z2RpQ0Y)vv0XErt}o601Q52+wK|-*g zh;gZojz8NIqX!2YeAw>nn_oWfxxfNh{VAV~zopr09P=e AwEzGB literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_none.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_none.png new file mode 100755 index 0000000000000000000000000000000000000000..d36e045f234a572bb4bf39f88236b6f2a3a2d5f0 GIT binary patch literal 398 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1F{QUL%?fdf?rYK3NF)%PPdAc};WZZjuV=wO^1A(^0 z>ynMUCpcy@TwH#Vo|KEEt0A!)8v1+)+J!aGw{p5kNkGCXrebrpQo#z%Q~lo FCIEA}rhfnc literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_right.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_right.png new file mode 100755 index 0000000000000000000000000000000000000000..0f874acfe7541cf90fcd3c6887da2c9c448a4791 GIT binary patch literal 427 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1!2l#}z zJ~wiEZs_*H!1cKyknQ@+$nBY-(|<4kiU7F~HW~@k4b%t}%;s`P26BW;g8YK(@8{dk zUoU^Zet$ohvA>_eYU2lEp!5$<7srr_TW@bg3N;(>umo<}AiZJZyZUP}3=@J=x>T;# zHhs6&3VnSv6sQ&i8tyaeJeFCUEArSzugW34y)?_gX!#OWmSXOp15cRMG#7kjTq}~m z$_P;nCLi!K?Ebxd&7R2n*S|00xo~smr@By1g_(R4*yRO$I8X448)P#2i(klMG#5{Z za$rN!dEh(qBC!j*86%>XraG=uRU|C;~ z!@&X{AW;^#q~Ub@?z?Yo)@B`eST^tQD)t%+sL}&Z8^oVHU7Gtct~LxL^YlsqFv& literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_right_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_right_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..0c60966d78e3c8c3e6582a1d84479de95c8844f4 GIT binary patch literal 391 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIeH_Wk=A+z*}i0E+(hba4#HxcBzPM&3gP0pOnvu72FTm_tCp zpd!Iq`%H4zNeMQ$W3z<0WO|IH1tNAk@G`CB&Jgg3S-{lz?8KhC)6RA=H%?_>WMbj? zW0UaohR1;~$D@80$uRBO7u@2$J?3J4zSE%5Kdt7Y2f@Y zpUGwa)R_4bPd|HX&wRt%Zlhqj-tWH*cIqigsrBj(8gJh0m+;!bsldR+e2ACf)5}s0 zr-mK#R&*8jDl^77m8zKYC_Fp%IQ;p1{XO6FauuFguqo)gKd<-x@TuP`RC^dKq)zYN z%9hvLc)YaX82{m0a#4X>LaZ8kKHa|>rG4f)!>rHB8@Dp@vMjjwU*3^7zu)c+!;wE; z4D%Y83oO_e=JF=kyED$&&8l!IzFwDM-bIFhLK%jyPaiOF^|36l_YgVIm&{lY&ZM)L zQB2n1;6;WjY7O65W-xY4X6O?6P$R;6AUWOA;X(J>EQ_5fe9V8DwwxARrSejj@r5|U zNvG4F+85X{?YJcWP4p@|M?xlnS-snZK;Sf~53@6$hZU7*Bz z!mg=x9=mKS*gu@P?_D)_#?1H!tPD>iBf2)+?@66FpEJ*QIQsFALH@r`os7cdtGBlv>R8o%vSd#$>y0BXZv;j@j#61=`m;{; zrt`wDt6wX62L5u7jJrGIWo+owr!L7sueIVPC9l1DZ$`ztppZ+m3a_u8bgd`fZ};`f zzn?yvl7IfZ-&UD(uiskg2k5-fJCfSqaB*VBswIrOR4#rA*`<`}bku6DX2y zBbHSkuys=Hn0o22+{&`)-B;(T&%9F9B&{=RHLpU3p3b$8469!qYq=hy%P{4T_g%@I z7a2mb*tOYEd89GcWGwSQL*&awb#R6 zehFPzu(Ic@o%gcqGgnW&_HM_=yXtzgFFjdjzP-J9myF%#h5KJ^y7{|2JkE7SRQGM) j{#pMM4MEWW%_{me`A2&8-b<1P=0yfiS3j3^P6nS2{ycsD^Yn%1 zMs7f}ORt^=IZB`;$S*kmeE)j;`~LCz^8D}X_s?f=RQT<|z`)$*>EaktaqI2u*OO)& z@U;3ewR9JneRnXZ`@j9pmd%;Fk4{om-gR4Y{(Zh*$Bsoz^1t%0d#R8ElLrH*0;5U; z%ZK?)COa=J`>nb-^xhxi2Cv!IDz8YzuH~OF`;elR&ptJWwHIH=^X!V_TyW27(YCK# z3|~IjuqZif&Xo0jyKEwh2KVu>z%E9Uw0}Q7mc0A@(z&+YuJ^OyvWbia zpDWf(W4KYd?7Ly*mk&I5blES=JAUh=_G`&i)0rgh|1SN^e5WS)tPw+gP5DRWm>8DE zr1k%Ug%$q4h_zu5n9tcDoWQVjGGjrijKftg#x0>d4$Sp`cQUNYRbsGGXMbR2*HAWz z;R8D-(~3Dd4XbA`%rtR0#(cp~iXn>S=v~H(3^K(Nx)~>y*cQTnui{A7;U_P+@fW-lZ!yWl7QVyN@ zjgMPibmX^8Vrkgd#BTC$@`D34yrTK6$4-k}NPV*Qpp1h1vj4d=nxyVcNIk*H@IcPu z4(G#qr91pzY8Om!c@q3reD(R#WOs%Gul7g$T_=5*A@oDD^kViI(=}FzZJ4}5Cq!A} zh3uY_Om`M+FJRliJJ+JHYHsjt1qbmHRY|P5&5{lq^&A9Q3SKkqV41)mbl^Mtk5Bp! zr!(&pozkooCv(5BiI>ibmW7pW|*U-I;uj_|*n z$V|(C84=Y={wLq2j#uPh>Fg_R;wl`r_lS{?sR&H!I3| zKL0qleS<)ck6n}C`u`DOT1`_|cWf^bWeQvO+}u#Pa;1&ks-A@9%0D@6XFq(u>toJO zJ-xPthW&;nm;P)`@m4qAw`S?*<2R2OZ=Ixjjwd?*Uf|(!o^s>UH|9utRm|HtEAwq$ zNSph4k;_lcnQy;t&Adcw?aixijvu}Gdv#Fx{CgeiE~koqUGas#8x#}JjI@W@DIsGW UvoLEXFt;*zy85}Sb4q9e04;bNga7~l literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_down.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_down.png new file mode 100755 index 0000000000000000000000000000000000000000..3dddd32954019b7f1ba68dcf96d0c549afdb309f GIT binary patch literal 1112 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1g2Ka=y z{wDOC{oLL6#9yq8-)seg=mMsY2Ce``tpoL3 z2EB7XKTNt;`uNLv2DazZXKXmS`{6%^#oH$uygpvdbf)jizxIP?%b8{<8u31}X83UT zw7?98;N310b>A-GOvn+LZJNa}*}rC=>EqPH{fje&JL)bm&X|2WfA{UgcV!{Q*$iJg zU%m}u-hJdiowdWG_M;Qzq?EE(r7+list?avx9C3GgfH?Fm(ERQWbC;9|MZ53FE7?w zGCYdsa1ah)Puit3>*WUVikD&p> z4^Q6vGXMG*r8-MyCB?KKQ*C(4 z7jN`UX@=@}hS{tg-&s4r(KMg`!-v@)9x}-F-rL6N77^29cYLm%!|}R4-fvks#@$QR z?DbV{b8e|Cv^m1*wZHZ0!52Hs9Yv-ocb?p}?@hg7?*C2eo_2ChoHe)a`JV5eKW@=A zRi1qLa`w6ZuQRiYyp}(2FTeHwcVcywAGe7A6RkP(MOeCeTs8+WtZs9e9Q?9x$N$AK z%e5!G06zlN5q2wfkmrFd)0+*yVp|e|g5~hjh zMAbHgg%~b0y)MMy^YCbvo163jkJDvq^&GU$#T|D))(Q*Yt#;}D`U%&SA5lIm8ykr;nB=Jf`#NU?A!yD}4ch|=~jg6^k zxEpcl!imcfH+1I){okcKcmI90$YAZ=Mvu0cOTOnYFOYhmm&v#5W4d;&Dy?oum zz2uvgNzkl RMSwY$!PC{xWt~$(69B6=5)l9Z literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_horizontal.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_horizontal.png new file mode 100755 index 0000000000000000000000000000000000000000..f19d5c08b7accf74d8fd58b2db67fbe55a00cce7 GIT binary patch literal 1118 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wDhq~@xH}1TS$atGt@@46|&odUi@{ax3J?-zO&oAr(|2%#E^Yn%1 zMs7f}XO?b11=J=`666ibm`DT|LT@*U`+<|;0yJ8RSoGbsjySo}VI-uykNWhg& zv%BtwRx7*f*-mWDJQx_Y`14!Ft(uJ*OIL1ZF`1R~ceW{W7<$$lK*Jc z3=6Gy*O+DOd92E>^6lBx#cuH6|L&Mt)->4zH|-RhZ#y(NY&`xyaKo#2w{{mWT(T2( z;MQm} z3ktjk*c=PP9Sj&EIlDe`R!p;I+cCL5?GGcbY|dGUgk}HNePp{J%V5q^z+5M;QOACu zfsyS&d1I13!ySA}{@>1+&QMmP+_vx=!-DJwSN`YJF@8|^qW-^hL5D-i zE;&8%*Baan_NSf}?eKF+SoA^PD}Uo5#y!*J{`YB`2b zkUqOj@}q8@2TezhzIgsajeAFBec$}slRU)?5~C--7oYT(&#S+vL0MVjugJ9X?2-o( z(^{XNIF>SRy?gwDk5=a&N((1tn|-y5-dq>8-E^_}^5?1tD!)FPa&O*!b}559*YnT6 zWbV4`!FX))0mh?;1kd^~rgbXMS{l4dj{VD@;$02NyMA{t9@~A!@UGq6XA6wJ+G(EM zZn^60U;fqB6YO=}*X4>l<||QM@GvGK!n%OAVVI+?5Qy)X-I(JN+b+(YL*d zrW`d6kstE?ichY-ZS&;Qu1U-xiPyhJaV>BbmgwO*(6YQhg0mrV@{vWh&fZFM&W5F4 z+;Q1MsQhkA;C1N-B2)gEzw>_kdr8sMhbv>=7Bww*H;P`k$Edh0T6ObIap&*XcQ5t2 zdAXnQ#&t!`-nyS#?`h7x^UZia`)~EF&(Gd`nB85U}*J>F> literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_left.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_left.png new file mode 100755 index 0000000000000000000000000000000000000000..a031aa25cb4145db6259810ad9b923f363b7fa05 GIT binary patch literal 1125 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wDuN!wh)OEj($atGt@@46|SKhIoXDs^IJ?-zO&wrjie_JybUFsyxd|YU}VVW14K*RstN!hFq?HXSEX~@atmt=9<^I!aM+i~~u zpA0AdI5Es>U@BO^%%I1Az%Pnn@>xcS57*G-#G%_*N9aAZNn(ft{0i!vb!G zFphvs<`5$W{#y(;)FfW=o?vsxYZBbiaP^)cgOv3L#wx2DR_S+QuCz=3WI7A$5~a<~ujZlOPm z;d<}eNer*EY+o{!+|b;^7~$<$9oul#f2PWTU18mN2l#fgnKL}z#*i$;u#FWQe+Ry^ z|M)ch!)eBAGk&;=FF5)r>5Ao@%Q zvw-Siw`EldqS%Ggu{j%KDm!H>Us4g$^TK@cfwbX+7 z@BGidWIFn|lSx9WmC1FHOYt&>v|b_KRbJ&0F1Gtz=ISl>`CfPGMPbLO66?*r3omWZ zURpLgXzf$+)xzLo`fZz?I%v@QhX})d1=hzrYYKTFU!^}T_sUu zHEo_jkz`u?v-%uSaruPn&p4zie?@Iu5^S}9-ICAK^Cxvi-4sf1T(f=d&dcYSN;iAl zxWjhti&5d$fLpdECEqwhl|^=*KKiz{-L!rAg|Cm!KRx+-?dSR32A|)|J^L%uN!wh)OEj($atGt@@46|SKhIoXDs^r>GQ|#X@8zRe_lG0JRB}1o;K8KcC;PAMbC^e_#In{QCV2J`D4e85o#*JY5_^DsH`<{c_T6 z1)heD8xOKScvq{^_`UwL?X9#+Y$t`L?Y=GfMf$Kf%Zr|XDqpQR#SQN=ez$HuJv+Gym-@=QLTU7zwU$n){H({g08;S3>T(9 zR}^4mvsIsVrfLFSKEEKCNIruM$eZufa`^9t(4Ka>x z6$cwf!=|Ql3Hsax>@UA^IjoJGW7KfJ%xf)Ei|G300t!yE_A*I)XO#$NytI#DB`69% z{AJwtNA*t;1AA5eYz8@n{Sqd}*XkV5z9}PpA}fd4!eQab@5Lv@+}6$KDL7u>rGMvB zLJC8kpU!p}4j-orwxSQ-eJs)9_t6OxS=y3zzCCxLq}-gcY=-Z%n}cm^=Bvv*c$r#V z(WkC4i;>yl9+!$nSda;j<+&u|+NP3%3sr(GlRlTQW?Jp|RZuh0^RjN%e3zw4>0Y~Q zgSN`rM#@gA*(UQKtVimFTSI5?h6|qA(BZUFNf>e@-0QZPuIqLuA1PVktQt_lEHGf-{pHFA@p1l@Wvi^Hk&OaOO z+-7&*Rq19sKhM6s{2t>vuMeyzk8b+*@7#}qN~Ku~-Ug|Cl^28MB`pWm3g$^xXC81a S-)#@fvkacDelF{r5}E*Ly8?Xx literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_up.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_up.png new file mode 100755 index 0000000000000000000000000000000000000000..f18f7189571a297731bbb38eaec7de714a9b2099 GIT binary patch literal 1128 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wDq(4)RMoSKL2_8{FQg?3%kJQ zMs7f}`TYztfZ7B~g8YKlpYPAtxA&Kizt8`^e*S(2vHH^g3=GWEJzX3_DsH`ot4=?X|&G!2`L)G)=GdA4Z{qG+`v$>$* zh*bZ-pTnqQTcyR$a4ntb0gFUKpAkdgbjB~6MGoAV&JgkA^?BBg&AJR6fqV*Vb`9N= z7(TFbGQC)=l`ahh*`8C zM&ZOXX67l%8YiMB>l|Qt`Yz#-Pdd`YN{#v;MxR=n$InPiPvu z?&dvwYc|Mu_&8;p)SG!r;FR!W#_TSkTLEI(>w|)fO)O1sek;k}rnbDI$}WA;)53f6 z<~?TZV!O+Fn?ppyNOhKjXM@?)jG|Dha%7do58f}#+NQJ%(}fT zJC!l2?7qNl7Q2RxISflm&#*hpI_~i~>NKOm#<{mdZFFN8);#OtlGww;p!Mj0s!SXc zgZM;^tf`Xxx)Te`fD3ttnM!NLD>K!i_b}w|f;IVq+1l75BzNV_qy?-Hj@p0a!!1E|73QjsW<)qgoV%Cv;X?L?@B%#e&1b# kuhvI|G;jq#v(^LYYZuQ-ySlwq17=?aPgg&ebxsLQ03f0h7XSbN literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_vertical.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_vertical.png new file mode 100755 index 0000000000000000000000000000000000000000..b1c36ae9a02d25405346b8d45e9a6de19ce54606 GIT binary patch literal 1128 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wD>xhgGb=|*i-1)L}-P_cXSKhIoXDs^IJ?-zO&oAr(|2%#E^Yn%1 zMs7f}*{UMgfZ7B~g8YKdug~xI*O#}Cf6srve*S)j3X{oC85o$Sd%8G=RNQ(y`}L&R z20X325@(L8&9;`-tNXwG&X&!$%h_Xd~NP{|Axep#lnRZg&iyt7=#>{JQz3? z82>D1SbJ*g%lg)fJG1^vIjr(iZ)3Fhe0#U_gFI#q zmg@iWH!?ijw~LRR;gd0A0TYh{(73QR#wor!4a;~KGahcY7e7#z%ixgznDIsr|AJf} zh99jgj8n|?8gyqeD498&XTD%&#;}F6shX*Yan>}(gbUHO3JvVPHaGDFJUn`hhw%)f zx55wZ2YT)cCLQ@M!tsyEAwKy)HP*I}H36J$QSdB;KZQens^Lwty3#_V;$1A2Uyp)?#9)VAEq( z{OjNH(8->iNz-MQlh(Qi8|zkTe05LHB9A|<-@Pra;a0E% z%dzPP7VKe|b>}*puH!q_69Ey$%5{h0OygnGdS7B$+>Ol)ChuSbT}M;2-1vrJc%`Z4dZL6tn7fti91hN!u_ZQ#A)`orNATqUhLf_lMGG0Z zzTFYH&0@!3V~VnkfUk<2aCfzM z-n&2M-+ujOykj=0{Mhvy*Y8#I`sg*C=Ml@i{i8q@kJ1(BzVd*ruhEXWOq^`uX_w&h#wR=sA+x;;sf?{?60d zothKYHfdh^y0oa>w__gMbUkOX^Yhuap}7q~OASryuZX6-*I)W>zpY^H`c%o1)nE9# ek+M|{!6C>Y415pmU7B3Usx>{!wxhJ1b)~asHnD>ZeH}RQ2J|w-Tt-V%Twlj_!!7l}*sw^i0Y@>(C@#4%a9H#%{3XDZ{rUNPXNkOislQ3$Rhj*5@8wt8{k$-1-y}vP zYaSFh94+e$ayVMx10>461vS*NTI(nLKmYdhyT7+3K9pJKzq@~};TTL0ANw8#V}Yfy Ty+7Xdf{gKW^>bP0l+XkKBVDgY literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_up_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_up_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..0aa2b5779d035586666c72f8ae3fcdb38682cdb3 GIT binary patch literal 394 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIeE`ThGDw%p-Y1d9Iiba4#HxcBykAzzb&Kx<-F z-vhxNjWP!gG0*T=%j(`BED*K$2wzxdn*YkFk!5A-#)eYO?@PV@NOjjc71b4U0*y%H zmnrM5T^p%;^}r!Nvy8@bRja2sJY-%hbfV^fM&lLsD%FIOKxGUJ2kIFo%X}_Qo_nO| zO#T9g^w8BD0WTURGR)*NVDTt*@NalNz4~S1*1~_G;uGelfwesN?4bGguO`cuobx9S zTQg?K7AQNcW7cchvzg(n^;u)~83$U6Voo={4l7DAlur$p0&DuAx`5|-1jm+(GTe<; zvyv4jxEKFf8~(U%P4Bm-d&INVj|Vwe0TrHMt6<>gEDD|WXYyx|K2KLamvv4FO#nJ* BqXqx~ literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical.png new file mode 100755 index 0000000000000000000000000000000000000000..123229ba99a2057dcf716bb79d75b4f649bdb580 GIT binary patch literal 422 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1!2l#}z z{s)8SMsCjy-GIy&1|X5=hG5dj^_dY!5D1<7wtEp!8=?7srr_TW@bg^EDgruwD$f$Enho@<016 z+byY;F^;Qe^8Wa9YtwE0bBsX6AW+A@;hWvDPm3gK_rGH7|Fp4PYJ=!vUv7!+n;NWV z%o3(CMM+=qVtncVRtzP6Fdw+oI_LGtr!u>qN-S8pX_j(BNUN%}L1^Y=PKhlI2U#q1 z56o=P;zQQ+ojv94pB$!1ViSB0EMnF99)Eu6?~esjZ~D&8UUfo0Oa1wR!gi5Y=}JYD@< J);T3K0RW!Utq}kK literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..d919814ee83910670b75bcca1545cbf271c97f66 GIT binary patch literal 376 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 literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_lb.png b/arcade/resources/assets/input_prompt/xbox/xbox_lb.png new file mode 100755 index 0000000000000000000000000000000000000000..b7b55df791a184830d799aabedd69c6855570d3c GIT binary patch literal 589 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDgPP#Q1=b=_XT1 zIRpEKiG~a6{$#w?UViep;Q=q_hHG{?%%3bCT+Y117Q%R-J-SkO!vq$Eqo*14nJn^` z`5q_}{7}BX*0$o^bjITf4bL{eozY;ywZM^~mnn`v_GOtvy!B;vi{t|?#~AW?D_pW2 z40rRiTPsGEJN;6h8}MFWPk!}h&l4;%#~6;f9bppL#F}En>SO$pA+O4m`G8D7>Fe#w zQ`+?w*s7Q8Vle-Zry*qjp-gAeQd{|sSznctW7bV%9J1Cu}l1B(I!BZmW= z`A71=wi_GvD}^50|2`t!iZMANI9}sApTRZtrikNa4Ksy8eRLacs_){s%CJ@_bB9;M z?>|4YHl5bantt=O_U8h-!}*~Mzb!j`&nKCyAH2qJsQOJw`M$!P+*%Cx_t)RhFp7)w zQI>kJZhC%c&b~b&SABnHE%y55F8;=&;oFB(xjjp5IIo1(FKsNkZ!Oo!%yZ>$Jm(4F z1l8RAOcU4-ygtElLNcMxr{NA`^%JHib5d_G$emz#%06fQ{`-vTm)Rv2@*QA&aI=|} zMT7MrV{7borU$2Yhlwuu@~W9NW5ryp2J^EGk}=Ju4VU!VSbQER#4+zP-In>LRFg>2Ec7&i(toO68Boc`eL=gb53h6T)SX$RzHG76h3Nai^tM7Q0Yxxt!0 z|o^!|+f@7Fdn8?XM;>;#Wmq*$(Dv`9QG|J@-jADE&TJYD@<);T3K0RVl7 BLGJ(n literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_ls.png b/arcade/resources/assets/input_prompt/xbox/xbox_ls.png new file mode 100755 index 0000000000000000000000000000000000000000..cb6eb93afb0ea96d3939ff7f01499e1da5413736 GIT binary patch literal 971 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD8SHKh49x1DE{-7;x8B~4 z%sZmM!w@jZ;fCb>|66jGzLXM^F1Y3OWBbJqA_@`(M;}jj);YigL-wpJ+A*KxC(haM z(5#_Q(@8C|&iyC5NG zgJj7!Gau7SFnn`9We*h1G$BR|-NJ8c!y6G}M|UEN7JQ;df^|Gl#KvroxVC z3~v@qV9iM2>}6Atb2!8_BhKNUXh-!jhf1X<3=_Ep3K>{)76vrj5vcheo!X-L`;J6* zw)gK9Ele&}4|WL(3*KZ8Z(y}LkPvR%&0t~mz(suuALEA<2gQ%~0y<% zJARkrgk0CN?cqKQ6CQb4>|)r*|KVwL`IRE=^ZY+#CNJckaEx*5a^b^8K2^K+%B=o) z^+NbA2g4;P&yHuka84DNb#RHv_X0yz>s^yBRYlEX7U@5n8Xxs;1%t+tr;Cb{lTJ3& z#LbK4GmPHszJy7kJYMG8y|bC6)$`botUhq!-`kx^%;m^zD2w0^nL%Ue5#`Dv`5rZpR-QOCU39x(Y$^@+|y6vsaDF56*mN*maM2cbj0wF zCu{Gv+5_ zO^X$H8k)NrVs3q}zx8%raP(0Dp@nyEO8(p#{gAmx`DEj5d$|I}7zXhJtPrZ6OJJ^% z-JUk{Q%Bb`>Kt2cZ8XXJ^mE2J^WPttvG=!1gXxo3{%pziED6W0i+jE?GyHj)%Hh*s zky>Ql&wsF9H+Ld-zJkW?Foo#Co6Y!PDF4`44VqSo3}U z7hju_q?3#ruGv3)Di=4|)q(NADqr>3+L*86=NQ*mTkQTfyTR(9kweY8`ETBS+3;HC zBa1`E&i{Wznq?A99eiKgb7Zk`YRQ|qpAER_k=eNXhtJ!>@0X2vj2N8jRi&6KIUO<` zx|w7`7B1j)*vU})+Q{UoNB~3hW!Z-nSzA(>f9Oe71ZbXT-C*OdC$X=FB_NFPHvfTX zqKwQQd-)S=n~pFyoD%N+Xn)?kXCsF(QyRffQ)lFffo zmwA^LuoUb!cHulwP%|$r%;w*})yWJJXE-i0$W<#;pOKJcJMdnADucsg|1W>9tV)RK zXIQ}(z+m{0q5A!15w(VrhMxv}Ju+2I<&2Xh6qtdtcS zM44pn-B2{L;Y zI{T$p8) zn`|7mPGVu5wrC}zjo0bS;BB$9f=pho_rI>$mAH8N(w9E=49eb@*KS)IJ@4GTkLi(u z2`}fYTw=7|LA5+~{#V(lRcx=$Jku+ZNPp$V&Dy>4v1-MpA512t?Olz_{5OXfH_5r~ zmT&Yc`MNqnboQ;&)9f!z(YmU?qVwDZm5%I>+jPza*zOi%a{D+#!^i38%JX~w?~MD` zvWwMTw{txk)2Y=AuY0dvJKzu{{9x}VzbOUk*M4nP6T5uxNYKYu2R0|axwfJ7*38?d zLgt2TH#G{)wOl+k``LEar;>|zI%{8FCi!f=L747mv+%F8oQMT3GzLWR$6_FRqJ(Uj}UUza<)XiYYF?hQAxvXEQ>vQ9-b^1GOh}XIst@&OvP%KQJ>6{Y-Ba46om^kp9smcGi_S3X| z?IJ}bN1bAhI3OM8m^ODaB9kmCwZBCce~;Kprk<5WPg9l(8!R~`((GaWpl)vdTDkh5j6JF+ zHYWawTw46Pjz28j^&PXy7aNwvQOozQ{u;I4Y!1izSL?Vh37f6vRA^ve0(uNc{9sc2 XcEeFt=ffgk#4vce`njxgN@xNAiP+|# literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_lt_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_lt_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..8792f4660837f21c30c7328946ff6ee03cf07c24 GIT binary patch literal 722 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD#k^5Lkb(PKnCH*$S(h0K4U@c2o!6PjlJO!{beepW73Ymt zB@PvvbU7^+UjE#mX~%Jo;rZ%SANPyr&tzb6U_2DWXD+P|a?gL}hX0}n#~p}gNU+=K zaA2qXfhy*0bqsR%b#mD+eC$0`*1)^dH=MU1E@l^_*n=cvhJOF1vS<^Y-I1}J8>#?jYQ-<3)oQ zLw!uDul@lB|3)>2cLz)ta2`0{aN>S|^noN{^^H>*SNk^|2%NgBp`d>E7e*b$uA)ML zXkmeS`yW?}A9($5y86Sf47YZzR<&S^=xV>OZGPyx&4F(o4Ue@sr)G5?4S)A@;c-ry}Gee!-Hht}Q*H!(~*(*#K65ZgKf55EPcEs=m$6;|`s$%eT L^>bP0l+XkK@=iti literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_rb.png b/arcade/resources/assets/input_prompt/xbox/xbox_rb.png new file mode 100755 index 0000000000000000000000000000000000000000..6582f391cd6050d2deb7cc79889774fe751c69b6 GIT binary patch literal 684 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDEaktaqI1^ z+eOU^0&EG71X&92|IfUgGRtg9!-Cy6&i$!=KH*4yOpIUP?V}5UhM|Fm{oEJIV(go> z9{-ayzc+0&!|jVZ4e~4)C3p;5et<2zF^Kf}pgX*;xzq}4Ss$_hUE1RJ5 zO=2~}Jb8z8Oa>ENdY(2!vN&v$*bo%mn01j=?!8kyhr-Gar;joOg)=|8yFgW8R?D=8 z4ATs*4%?<>472!|tA~+3GyU54mkgx!$Yfzw|K7&Gh!uSsvW*{8l>0yy72wud?mBEB=w;L3M^~Qtp-1 z*C$Ug{rELg^_E*?!$U@v>-o8#we$9yZ)Eg(zAZ_!;(u(Q!y4gzy&Ni>FG>PFB{z7o zeF*EQQDa@k=pehYrH4WKy}b3*34#ei`5ya(jF_HHl2YV+A#6T}HTc7xOOAX}Wrqp^ z<`*}FidiYVdc8MQ% zIhc|8CUR4-R`fh$*CgO?ND=C?qPrknlIlAdx4bR*Dw`Kl$SFZUz zIMtu0-dH!m;*gI_T=uv22PIEG{!NmNb~QMs_k$gjqR_yEF8-s7&RJYDee?k& Jvd$@?2>|7+Hs1gM literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_rb_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_rb_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..e8a78e81d10834dfff6c8a2ef111782756167d16 GIT binary patch literal 800 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDfFZ%gLM7(?|1G{!zDM6_9htht`1F1r*ZqyDmTNlX^_m!Y5*U~z4luA8G{BjE zVjGGMzl{q@t=z)dJo6mW9FON+ymvVr(uEyAHdhH7sI4+uRnDMzQFQ%lM!kt*irS2m z<7a)lB@t}B_MPt;gLTd~c3U0TuqSTr%zIh3y=__t?l8V9>6&Q2QACU3uFO7>$h=qm zza0()U+_2S{5CZp#_)mLb;kR175G?~_!ynH-#Rn#L8{_|pUHnuwj?G?hek%G-iLMA9X?P z*OxB2sP|$8!)sQ)8!bE$R$L_w^CRbe(=uPZMxABPHSPlKAJ%;HL?;O(l)q}&x9{M? zK-P$|+S+U9E4hV@?v|}K7h;Gz?ajY!f`?iq+reHIfwNYNw&@BqG*&;eb@)~tynN#n zX8V~u8#MoJWk}#KTe@3>f%RdUYezWKlCwc}6@FZrYJ4|O= zo-nj-Ibft{=`j6Chc+Wq=0bg$rl-@-9$Szo@XWnpFT25XrW7+aUg1EN;|p>aG8iA% zX|?GqYcSrF_!HGJVRc6^L)W2(|4bh~Ft$CI#d2?r()X4=xd&Y5S^r*q`flg%L;nO8 zc&^V14^Q3qAxA^_ZEWs58*abj7nf--I-r^|yPx@+|*^-@H?g8V9?$>Z0%`b i|MjgP~sJ z0DEF^O~fXpAKMuvX2Rmq zLxvnR^KQlkdl=`=R@f25V4^9)n3875$>w3hz{z!B3*%E=f%iL^zIF#N6~r*`u`Mv3 zohfkOCM!R0(S>g^8k70HAB$)Uc)eJN<3K6HK8>W^g2H?iZ0QXX6dBg9%UW<#hk-#y z;K57r@&d<9Mg~Ey4+Yb_9`0@8nr!UA#$0iA&Gs(IwfqSW7`}7wPS_=Nne~PsLwsuY zu_<4x#SfSy7?r$yA!@3vIEAB7fEY8*OTIR0v&*!Rsp?9toEZ@p4z{p@< z_y14u1qt;sMiG_=Pp$V!{^^^%SkJ+Yxg~g&2d{`!!;e3651D@1{g}1liv?P$*rVRL7y<$e3h*%kfoGh}ApM^$ZPlR@d(Go3!g%EZum+f~j_;O11CHhXJXz z>4#rPS6|UTnfF)MHpg+RN_1P-rLf3(-O&NE?W;F#y7O)Bs|~Dja_S1bo-deXwk?R6 zE`C{YiD|aU@|~Mlmwr#$-)pwK_nS@lwgYoHd!+Kun<{LONj}K;>Rcmp*JsH&jC~s) zJf8dIxai&+dc7GtmX$o^{;hqyWc$?gzoIsprZx<0c5C`%Ub`RJ60`3#^D2HZ?!Ujd zUmZQ7{p#z~<9j71p3gS@b~CT?G@}Va2ESwFpVKQB*6cD>?!P|kKBLw7?PGpQz9~?NJr9Y~6&+=dtoiPj?AS6lt8YI_TkH6J`cWSr7ji z7I);C3%He=+-B?((|PH#+&;+jd3xTV&ZvVz=`Cx_8@#V}1eh;wIm`H@*P_rg=vHj* zZ#i>+1=n{Kg}d)$^DF7S75ZD4{HbnnZq3IIiM3|V@}~a}BWI=m3>Wmzh%M~A{{)y> O89ZJ6T-G@yGywpsR^Ju? literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_rs_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_rs_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..7ea3310ae146948d1a34640f135925c3d04cf1fd GIT binary patch literal 1354 zcmb7^{Xf$S6vw~YjE%-T)T9>&T;cDorFh0r8Lmv42mSXZc)N|DE; zOvSipCc31HaZ8x86t!F`YOB@F-R(cPpV#Z0_c^cEIj_%epZrkTHk9TXO#lE;RDa5L z6~X@s0aNvl(&8^Fs3mv@djr7jGg{wb;Hup*G$_n><^M?aq&i>Sa$K&O&z zhtEt$=bG7-lXSY?0n*NvlPafLD#bhO@Z`c}S&0O@3hdTkZhrkwxj^;@%^ra(Os;dB zi^M;?G`!WQWI&$VIgUz$O#v>VA6T&li9v~q-3-E>Tom){Bng~tnG)(SKPt&%HwIlG z=O~#Z<7%ZIcnQ->thWQit$ZIWblh^ZBO}Y^d$z+CU(QP-cRer~L$a%=y~q+`mtuej z&M>RLApDFvSu-PB-TKw~^8S1G0!1xSU^8+xlyYpL2_^cq-2QrUa+g-xNV-_}mc$4m zkj3UT;Ta1?Knbge>9hhBmGMRiAC!TB)g}b%1l1o{Ir2)@kF_^DZB)lJ)%Y0uqc zmppJLWCJc&h$LHdZ>R!HxEd#l7d87LFZ_fgqx#lT{Tr>D@`d5ItK^~yc5J|$E`Z5r z7FJ&SP;uJB!Lp}hbe(~>v*n&(sx^=It^z^PCb!hg5`wS9))Y{r);Y1}(LqHG6WUy0 z=n?-#(@bLCE}G&rV(r0A6AZgLjxF1mydHX4uNUPQdBF2^zGh9Kr5xU#<{n9e7c^7O zLVJbM35MKTa%kRWq^v7taYvjjzr8UC-v{Y$Cp?o$@}9^Z!-PB*M9_Uc;O5dm(@dku z-LCE#m!3F*_J~hMEPg=j%)uN60>k@WRb#{s=r*TxPYbWgeTphE=3Nd;awsrYLfBXv z*AacQ+AaNp`PD&5r`z4izq1gC!9CSS2A3m$dHaPwE63YrzmE?-s)J_IPwH-L%LE~x ztdjA%PR(gXqu*jzO&)lXZul)n4*JUkx$^5^?l7un(1Drc^2EbM`Wkp+WW$B$v+_s6IF_& z`Irb?za@?$(PIfzpIdR$Q4V8CYcsMViw7f6&ZfZ=!j!glcC+M;$@DMr=e;^|+I~h} znGZ{(my23{Q>5;4%Ut-&|2dC1wbC=A-UdklO{G6TYTe)Vh@sHirb;t_>Pw?EZe&I3R^_DQCx~h8rv)4}%(*{22GiZ(L{R&%WHoPMZ0_ zGL3`-(_Oh)E4UIGj-TUX-@wMRL;tSB0kc@19qU)T{5j89MpoTG(DuHeT=i*DmLGQ> zEN1w6pP5T)LlXPLCxQJBuiZEt&&MIkqmkgSF5*DI#)goC4NYuo7!JG*zma>;wIOvo zt4_m8ThaI18dw8vHQW{3z#x=p$}`D0QH|kS)B&asQ~o*##PBk+Zs5rCFuOGG!*j6@ z3?9L6&g(v4?5JC_jDbl?;m_JB3?3N=y4Z5qcu!8zopr08l0`X8-^I literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_rt_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_rt_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..862cbb2972da477462066db04f2364af59f2fea1 GIT binary patch literal 824 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 literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_horizontal.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_horizontal.png new file mode 100755 index 0000000000000000000000000000000000000000..c513f8dfd42999030cd653f5acf589e9ab236bde GIT binary patch literal 1257 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD8fBt*@`27qj-uY)%}`<~nO6K@(=D_3#2=d@AAOxZU4nr*fq|!i(SU*Nz<@GeH1vKNbI_}|uUcF_M?%dpwHHovgYTi>tx z;WIgg^Be#D)h|f-_LkwE>D)CF0^;K^N*RljK5)*b9?Wh z=ilS2OCCIyIrjG8EGb4a#oLbem$#T5v{nmgUvf5C@urySoHgC|f6INjxl&8)&zs%< z?(xaW%FSX~Y0vo8{hwNnyHu!(`DDgLgPO0n@h9;B5Ukp5zb5Ah*P}5=zdin1^7sJ|b?^e1e7&BPi zJK8?ygL)X3)uT9HHue1sU${c*z~k$=I}a^uS$%ZrjV}xmF5GM{P5W!-S&{sSvb)+0x< zfL_Zq%=|xR8go)okuJk;rG_p2B5I6=4`t;Qw4Gjrbt*&~M`oJo#x$f_O;3wh?Ka`; z!aw@+uCFhBmC%+W%DKvRWxu?ZUu?yyBc@k3rSRy~!#hE8@`a7K`< zw9hF~?Z%xY{A>qy9obwTQ@ObL^Y;GJx41qO{$f1Hwe60|9q!wyn;=Pk_1gp#PcqjZ%Ianoc_S!OQ~UAkH*qD~6+up0E+33qmDbc^ zmAL2K&aJus_dSnhn(+LNSK(XbMXIm+6@yk6v8nx75N=Sykn3|RE8WF?Zi`&!szOoK z%|8xsT-EgI)li)y?EE$>?T>MI*VUsDZC1jYBe>Q|`RVNNa_G5gRP3cMs`E5VP+!7v zSFU5UNl5w4@U=f33d(1cFHEk;{q=Ts-^0?=E83*px;$6D+Z@lc8^|7vA%MOvzkx)esxSueDt&U@TnV-v67Y344g_|q)kbaX>iXv49u(&T^VwW4begq z_!?HqB~-C2_@_H*d6i3csz-w8mp$LR9;UG;Fo&3|l-b<#bln|Lt?o+I>23QRt?RZjZ&q5G4V_57ti6!*9~h=5;Xufv2mV%Q~lo FCIDu7MBD%X literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_left.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_left.png new file mode 100755 index 0000000000000000000000000000000000000000..1cab90bf850e5a168de5e7f9685175be39d6f768 GIT binary patch literal 1263 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD}HhFuxIEGZ*dOQ2} zq{kXO4u;Q?34IU%#n_Bf+$?VCGlOhK}6#eC7`}q(91SV34d8`c<^Yi0i|}0)B_vr@xE!>Nf9~ z(|Wa>fyw>X`~DxZBKw(HtWzQjlQo(YR>w~&`5v~D*OaYb()(4L`2#x-bG5A6`e}_# z(i@g13jaau>INF~fpS5(nJhyY{v)9pPbUvEiCWZ3%w;caw z@dOBnES)9GxPkH1>ff!NEONZl_ybPG8%IfTY%wkFihg~J^~Ncdh=)&SI?Q%{{+}go z|FwCS?jM?DyVB=<*mWu>p~|puD>YB-0iS-Lhja;d8Jtq%CkMPE?%+yG+F!Z zOy5<%ejY8;X6)T^Yl5AxaKim{Q+x6<7k|C}=)7~J74L)Nzib;sefN8IuQZq=4Ah?;ayn=LE!+yNH3FI8@vrE+|vHW@W7_S;^( z>*>KucXTAYeRsPotaO?yd9TmtasQphqn9L3Czvf{y%j9>XQHn*W1-v0$w6#8e}p;T z?&n!sQ;OZkfjz7reJ(RAXRn zcqn)`oZ*i#=K+fYUhEP3nH=i7TeiMjo5jz`^m{r(N7jSLXrudU-kGn|+1&GV->tpX zkGNg#Gi8)<$3A&)_NA%7$*6CNmR4$qPepP`L^0pC{vTVl{=N( zeCf-EhA~$mR+)TD*D|6^cHPx~aL?m$&g-1VJ56-mn{Xv2k*`Y&$4XXMj^-8nGCyV(CnpA$;rau7K_?Xc5xDe904RNm$)E4 zZmF=Ygzzpir&$^PC27F!Q`#&4qHalcHNEmgbs%xNE_50mlP|C<`Ksv0zu8Br>b>!X z8hN{L_4I7t@{H~v(*UB@121(db;8V3si!d`m|ep4uDCP;YDG`7KiW`!7%$CA-W?8_ zsl+^z;@)Gb42!}$jQggasuO>?xdjN<@r!CR1{lh^)t z`#bG_e$%)I#ZtS_XFIFt>SPGD5XGtdgF_T9i8+-q?$IDB1vo!Pm>+F5y%mSyj+@!O z+Xy%0i;fuvfl@)S0Af9iT?^nZC;D#44Y~8XBo{5yKM}CNx&<^N;Uf;ZzTnyi43;VxLwN0R8lpFi^Wwe8E+F2N9yZyJ^l4&ve_r zLPd9VE(D7>lWtUfaxu%2C_>0+pkXDx8nJMPgH8YN3ARfKm$+YVBICMRPTQkT2oq?bAV1||g!7j!wcA=u)md{k1nyxkg}U|bj_C3z7y zT<rp~3D=50@k}Q}JZ@zw2Xb?}| z=yk=5+}z%6Awj+$WIJ0l{Y7xBI_*f+NYm0!xJ{9=1i>kW7}^_rINHjO;hlWVC1Y*8&QV MNg;lsos_))070K^b^rhX literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_right.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_right.png new file mode 100755 index 0000000000000000000000000000000000000000..256df0ce0ad555f44a87128a27e37eea478a05c8 GIT binary patch literal 1248 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD+S5D zla?y*I54uN1poh^y?oX3{O(x}Ojh2ad(2qdXV`8(`fd7j2?pi_2A&2+0|vGO|2ZFo zT&g-d?V9IKdu9Xws=E`nzRIim)k+}tR2Ro{cJeMjt%s{PrF zGmItMA0MvjV*bW{C}^t_k3#i|KaEQ5OPrGr=gqQN$o53vGEt-E$-4bV7lw1Us|h5q z@pMQ|)K8lo(9goO#{B2*snT87C%=olqTaQLwa#uq_6d(^a~Qs#5@^^zkMVB3)ef%@ zk2QoC6LuXn)KFc;{DiHcI+fu-jHtu=T!xwKhaWc`@I6pJ=lwmd0t-bOqq~epN*Cv| ze-Jn@(_w*GhU^^CLWXagOcsK78195JUz2MPWjV2Ux5MFtJ(Wk<8fx3Vv&uNc+s%K$ zEO2691JjJte3#i1Hq9TEN?OM61fHzrj{UsU8=(8 z>}2~6yD=JMOn{ojEN^s*UTNRES(-}Il4rFT>bf2r5e5%O*iP+u7AIyukGj!c`n*U?D>EB&; zjY%9UpRT#~Uq9ebish-SmMoi$KkY{u5QRV;8q_EQ*-QC6bAq-Tz?E^SsaNoaemHbACIgy**vjR0*m805z(s zlaHLNf29n|^TJ5+vK)$$4jv8w@C(<;LY3sbh4%rP^VaKbNM`Zn=C}(H?Y+LuY=$BGgmb5YPB=KC6p6ypM jYRzT?2gcL%-!MeZ5lgxA*yaFVv{RY6tUWR*fWBc z3|m^-n1se4Ws_dDbyT z%zd@&tAM_r&0P>Ri_hp}*ukFk(Qs|>cBvDueFH04`DyBz5H~0+F%i=tqt7`ya3`-Nj@i$8hMFgq# zlAe5UE6=l-`-WBESA}Km&%3t^3a5SN9s%ZVe&HGj>nOnBhS*UGm!j^#G;U&M40tvttPmtVSsU?w?r#Hv_WEVPnOQL^XsgJWI)gbw z#JFN5jDd7S`)k@Vvz0>Z%4Qn>n)N?o5h_FVc3T+Kh|X=X(sy@r-&lg5^BGOxplhg# zH!8L6=+p#Z)w!?TVpCq>md)ML&0L~wO&`;A&*APIGTh)U&BBzFPQ;^3cg0%r46{0& zrz*$q(Iotbrmn%;Z^Eew1V7GPliq-*Qv)%e=<>lp{8KlrXmPvC$4IC1D?w+k1yr#G zr0sM5+w54IZ?xUY^>$6O54_g@w3PVsw>uO9u zs(vaz%hrDsd(qZd)xOD55Cjzt9-KQr9?r1VQ# zYJbN|_4N;K;%jIo$%2f=>F6PAO6+}MTTL40SK4&r@ua3W&O;c!dMu~%x$w^>Mnhcn z2~HTXWH_l>YC>X!5R+CGvfoq<9;ir#PwTlhB|5xE!yvbQk|yNgdA}*w9(l$-wC8Vg zA_}aEi6bZkkUgEgtAjm8U=lsT1gq+-gmTpa1Pz(?8MvpHGhiiOE zes?2ZdGdCFjak#hx~xCOGSY{alm;W1@hid+tp;DMR%6E(f_dkqS1Ef$YE1YI_zyCe X9^6trG~2Nve^r3$?CEsNF(l(3%iCPK literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_vertical.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_vertical.png new file mode 100755 index 0000000000000000000000000000000000000000..7b2e6aa84c83290f4af578aa979589783fdf1bdc GIT binary patch literal 1405 zcmb7^`#aQm6vsbbGZ>d)gpA8F>)s5xq%l%v#wFwul9gmyu`@2M2u&ehvlXiid2$() z!K890qcMOMs+Py{G^*W-EOASz6NX@$vL`+5SHvS(4b*TUcw73KQ?=eI$mQ zeONEGquyeaPg^Qg2Fr6?f#lMVyEE0FIlNFjn>B$`gxzt$P~RqbVIa|74nj;bs`I)^ zuOF1#k|H{s>7D5R?LFAG1*#>-*ImGT>Xq-hydzsUNV9-^&=NF=PeqS{!4%6iJ|cKh z8IxnTvMJYNC>9~Z^1p>hS$kbHp(?{RmhkmbsHb2P9=xEl7uRP+ANf|h+yr) z%b&WVf{2=wpw*(`8&ky^m5p)wAZcI7&P@vA+v?TDS?-V_pj*uR6;QCvsV8+l2Z67n z;$G-h-3`a`hE`Vv#@*g7_jWr>#A)1sJ3KPk2*KQfAoS;-5ahh)P?)k>lfqj3IcSF z;A07|7V7+z${6_LV;5CNm#2XqIS%EIg~wiWnA%I{9`-d$W=0@TS-E3K?yy1kz1ndi zR*2+4(z#s*=^?;HXbvdDGa1VpjpeFt7#QlSNO?sHy-#6lYQZM74{b3z?~NYw8bMNd zs`ut}EJ?|?*#$-U^DJd$UM2KvYZoLi1ehnXV}a!J-_py0a}6k7LXf^({9DY)ob;j? z=?Ub|n3eTuJIYf+^L)Ppd_WWnZ0sdXgyu11H$?IDru;(C7KqqDf$SFEQ7E6WU8o01 z2JD1P!xnjI;?(2rXyk1L-l%m@RL`^xcn26eHPad!B<-gNd(_*4W^FdbcQ+bBF<; zON^Y9J290bBpYKJIE}E|3+N}TFkyJ)o&(G&cd`d&B~>4wuC5jyqJ%b|X|#f;n3js8 zBPlBp$FTO6B6*Hx;U}SJ`PivF1xrB5J zwfpT;WLPs6LkqGsLH(=2YwC3r;2oRbmc5*^nKcV@oy?Tx5w zm`H@aniw(0=Rj%6!8f=zd)5L$*;I|Tix6@mj1bNZGSHjlXW-X;u2lw>cb>3Qo1713 z!sdQc1VSMd{o%q7X0MD|g|BKRg~`eOh(JGdObD{*7FOYje^eUcN=ruiTwiNhfMPeL z-lbvxD4F*l8Owfnw%@-$ymgS;XR@4YUft8Ga5cpY^USso^E&US6F&ERg_0GfUa;LO;2QxOJ(=*9+lBGRz( zp4GeRuDSZl0dZ)6Hvg<1la0&c(}~sxRHC;WEZb?#A4yGRZ1b#Ie*J;Mc!%?)sGJ(x z2Evkb1`1cp+{1Inv9{#r$+AFfJ3or%WFlLOAVtJrZuWzAaTo&h(>-E$^o>YilE&|2 zv`ot8Ck_B_`1xsTCFM&=suW`-o-1zxWM8nzj<^}yFl|-RbI#_$O!eieiq4Lub?Gyn z8;zqH`w@xyESRm(6z?6sJ-jH>2K@?akNpSo9i2|4X0-yz9|geO#oM{g@g(aXMRbVD literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r.png new file mode 100755 index 0000000000000000000000000000000000000000..852e140575cdb8919b6399c66c16909dd668b6bf GIT binary patch literal 1286 zcmb7EeKga182|1CW3+?{GlUW?Ekr^SHe4^e?Dj^6?m4?RG|f_^{Je&gx5Sp0MbYF$ zLLu!ZqaspW(WJ^RiqoclW>h?|shsJfG)t&ht6XU(Y9p7D(06GSUJ7==l10 z1*;hP7Z{YP&q@n_s0hvU2=D;lPCj-iQcb1pY5sKY)&El}lWN_dHEmust>pvGLyQeV4SFH4uD?i{mu&6x+f>=X2q1>U(?0bJz5sMu>}KD$}(R;4hB|4U-3x8&Dao#Rl8Dbtz!-ct9$S^~)G7 z8Z5Q_7?E`Hl_$0C*LTUkxhAVg=U47M#_zGxKYe*ZZtFsxKhq2Dq z0tq1tD7jS1eVa+-Z_;*F4fTy%jt;rOO*(~24oV^%u_$ldAleGuoT^i3OQY|GooeLB zs8L#c8wCjsFI=EVJ@)S|;vvgyG>x^%ig-P2^0ZlVCGOG?K=9o)#`Vej`}&{z7ub## zg+PmZ88fi#jG`Lx2@g-6ds;+5l^dF5e}Xkf5&A~J)ozjm^&nxFv{3c6jW2fNOGtcd z_DKjrOFc6`nW2_Q;g_9(lNcH(83whtf?H0I7h>XCdXYxtHShmk+4k2M+~P~X(S{#8 zAex$VV$cb?I4RYgCaN#NIX_SuTg1E|+_!~yZOhlU3Lq|=~o z*Qwwn8zeM0e82}ZmfTCcP(1a*>W~GI)Zay0Xcj%N)H`yhH}YDF}_>6G2C;ud_9wND~m^|C55|%rto~mef!*Yc=D($|0^Nv*u)%x#Tc8N zIN;HXhE;8yw^|I;u|Hgz`PcAHH=UCUNk_h;E2YTJ!7m@Z4gx6h* literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_down.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_down.png new file mode 100755 index 0000000000000000000000000000000000000000..1930ed680138e4f769f3c7113f940c157dca49de GIT binary patch literal 1384 zcmb7^c|6p47{|Y34%dva>lw%M^&dfM-gd81?H8yu8wUWy5bJe)1Eu=EUDz9;7 z&>)j@L@Fs`NTb+g6_s+$*x70S+JE0000>0gZ5q` zLO&z{7xizv!VM7+;p9VP02=e91;I$sZtUsi;~@M$B{GT56&R_jqDeN^%ia5oO@6Y+ za{Ug2pm)#9uwB4Mm76U3)az_d_KD|yDxHj;RYzahnZ6SI^>2^%do`!!PNeO;9$5R^ z6vJgE`!uihP~YMXoA@r_qW)LlTTQD}4yT{Wfr081(4 zsaO>z=f+5c4Wahl*!oq)I?J_A?`RvaY~CnIwk_-paHt3uLzp>Q3nTkn%KQ-gAaOh^ z3BkNX+zEoNQ)(6llIqSIY5)`p;xa*7jF%xAEzYt{dz1_R+&#skc@y9yR)M4yq}1do zW|B_5lxdec^87K zR}wmpl=WNFXM1mk_(n$VwjR&3(A>P{b}dw4V8m02IW?hF<#b#gqOQ{AFnZXsmeI$0 zHoh9P^x=wZ&vD~?cz@v-I=5t;o8@>Xz%qQcGOXoj=(A~>1)-Qx`taN}Z%>R%yvp+1 z?JowV`)p&;&sUs|BmxO2w%nl9yER&WldHvC*x03sZFA5$GL-TzdPeaZ^<8 z-w$I+Z+<#(3Q<|7w^8LsA2S@g9>Ncrgz7#kKbeVat0zMd(m$aOD zw*2z;fyCd><9-el>u4grWhRyyQKGun{;(muR6Ez@W2n|v!UByMqR;o%7(Mu_<@SIX?ME} zGt~GxMXs?n4NC@EEqQ8f!zjl0zZqR_jlOJwGAZ;-ukM-{5Yy9%MsXyk&r%xhci@2K zyV1D)qzJjnXjF`fI%8zrq|_sCO1tWzW^p>#AMmt0lx&0Lm>o+ns0h)#x#=Z7mom5? zCTjuJL-p{EbiD52BsH2;>rkekcP@_Urz}{H@NQlF;%_|oJcq~^BE5>!DEf}0%r$z`#M`n>OX$Ehd2NL literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_horizontal.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_horizontal.png new file mode 100755 index 0000000000000000000000000000000000000000..f7fa95fb6a575c20d4665c62fae109a9eab13f17 GIT binary patch literal 1324 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD8fByS;{rwDAxNg}3ZR+rJaSW-r^>+5# zZHu*d+LSd;r0@N|dhTZHTv1OI$Lephwmx|-=PjpLDcr=|A1_zHe22mO0562v&-p+> zYpS{PN{)B`8E3dn6;Dbr^}EkHYy> zgG%Su>;8Y{S$<_+azQk5BhRb}Rg7NWPcvP-bJFb%v&Ng*ce9tQXfx$v+xq>~rOKrH zTnAnS?lM?ce4xc$bTY5DO^Nlx7;dgdFJ`~}mb`ZU=T40R|KzSO`Z==Ko|gUl`R`p1 z-=8CiapuAWWY*v?V~{eRl-Uq|&*o2@TY zd+ZPA{J@!?vsO(c!Ph{$LA@t~?Z9hS*-s0OF@`ZMsGV_%fh9rlOwE=AMa~Al(h?pH z2A-e#&Wr~PFKuP@&i?1kz&B?Ccfv__C+UQFFKSJ6-!oXON<$Vli$uV_f zLB@t--AW90JEfv5?F$({*f||yycWgyVoKeFZUcrdmd)N9OEZi^Q*e!EG*Ey-VTajENg1?+R~aS7v+A?`71#cWEwTMiReH*rcD27hO?iIA_9; zR>NUulA*Ddrvk@(}< zRNs`X8EamgTXu2DE%O;A8Q)lsU6WyAFn@l_zHg7=k6&*;zo}xZTXbJJ;^Ma1EzybA zIbzBTaXWne-afPP_CKE~ z>^iD0*0PHhTAF(t$TCeSulu>(_P!Xym90~!-Ew!~{*l-rG9z-y%p0!n6)rGtG0ePq zt|P8gsETFHYb~#|kKD>3%QZ|FiIv5$WF6o6Sbk~H8vb>uD`rTrZ(6?Q>COi$7!1Q4 zKb~f}C2zW=)ZggS>D0sDQ=Mz4*4~ZgKT^>8Q@}qaZuaMIXFE^7p0`>mQ!0~djaB;o znI5;f>eHlsqW4;Ax|RM)dT>SX!rT}=D|v&-Ig{>78x^ZtpH`n}P*wIyY>K2%!}>YA z`3=$z3p$=@GyI#x^n~TW5%wATm=65!dJ=7`blit?5%bKQ~H X9K5yVheiUhykhWl^>bP0l+XkKqN-ux literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_left.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_left.png new file mode 100755 index 0000000000000000000000000000000000000000..0d6b849e5e81c439f64aac9b0128d9249f1dd79b GIT binary patch literal 1317 zcmb7E`BPJ86#ZWE0$~fXYfuuTAX`8pB7++{iDCi)r&z57K?wqa2qBWHk3~%t5U7l7 zlA4GRL#$YFL1b~E;Kou=5CX9TQ2_%OQg)io^dIP*x%ZqibMBnq&d))#06lHIHUL16 zO7Uf=5&3tqX!TXf^5@h5M|PJ>RLKU|<=CblNLg3k%)&n2m>| zTi@Bgssz}#nyXtu*VpWqFSejRr9>YTpSD7xN>_4-LojoE2WQ_m9FnztkU*BzaJ%9W zEaud~(0;hhS3d}XNYCK1H~@Zce$JqIBA#1HkBOffNl!A!XGO) z{uKJyVbPd(bCieb;luM*TmxEj91qZ!PM9UU8VD`Be{_VbC#z_TUO=0))$J`WvIUY$z|aoNpBK#MF;Ie`Q2qAtBSeWI8DUo?p0**|Hd zZJ`gRH$E3GCY#G3_<}RRu>@$j3R2f2&{yuU`HvlxZ4kDX(gKz1zs5_m4x}}oL&Lqi zJ}w9^G3l%&pCwR@i9~-GlFbRs6NR>GsSyC$bjS|$v=^Gfj;k=`UnW;G!R6z52H-;e zHPYGS!GT!=OlN5;a~H?Z)fz(qbQqbA_t9WlCh2V$hdg1HjW=lG1cw6G z(Exie5WtHCzP1&*ExF9?B)tQ3n9v-Y(Z+o+Y%yH)Gy4(Bw0*(vgRi6x{XUxzzKb zTUXsjy)zGMr9?6fL>r>McYU5L4ta|2`;Kw(#&~}C_2fu5(Tjz%V=9bm*HNE&*F8wQ zgifv-$#E)D6$jr24Z@uutW8y#MakG?jQn~pM20Dvi1m=Y`Te4GbNmDGQen51y*AkD zW+2&Ln0U!w8C$v;*B@RpjR>-yvAJR+dNEOpA-2?y^O3qu%W0zOSO-;}`6?1sVS>ik zL^DzPX=vd-JvSRt-!KS=DGSt(Z`@`Z(n9W(;gx;?BCBVKNhe>XvRXBNY2}+6p4jlPgXb8bEAM_? zmFy0(pBzU8f_aIqpOOG!6?UKn(p}f|6O1gA3Pz^ZAUQ>bVVY@!#m@D8P%p@`DIDH0 zb)+4=gzBsWB~owxEZuyby?s$qoabM@d42`M=ncs!kmP0Rm#jp%{MR5%R3H+blHa_` TE;pxIT~vVTNAtbq&CK`*JA-Cm literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_press.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_press.png new file mode 100755 index 0000000000000000000000000000000000000000..faed4c47ff53a9fbc5446df2645d3347bb4ab70d GIT binary patch literal 1364 zcmb7^`#;lr9LGO1%O%WA5p8q7G}kza5?z`K2 zpPbOYQijX>SALm94#il{J)QuxUs^AXLdbUqvOi^)?EjS9BtPH4&R>yFnwk3of>Hu6 z;oRxHwk*+M?sOdz5J{&v@=v#Yy*(*8JYkKX{OT4pXzyPNo2F#(+$^F0wrEK4ctA2k zT6^b(d1v|d_X7H%_pls*h3H6wO?CQ%`nm2L=A*<%7|u7CR0vMorYb4tN@lX4X5{Tj z5n`g*X**h}13wptXcT%$GRjh)&arxRiqU57@36(fh59|8jd7C+A^=c_sZ%C7gLFi( z1wR^8^$=3eN1Dqvl>gGD34%?=OewNm_2Dx{;5C*p=SI)4V7y61K*gPO9%k_Df-8sD z{>`p5@SHjv=p)1QS|f>{nMl8yzS-d`umcNA#@0L7PjEX@*Ltm%?Zq+Jdezl5_{skI zYiF@gTff7|A6QQ=Y;njoSScRA7RUaP zY6I2&;hu&?o3{f!d)$meZfp1DXF$syTM;TqzF%lh+6p;$sZ03es9Aykj<7?$zUO4#IQ|G3P5v9`qauYX0Xj~cuoL`v(2)&rK}PTg)Pg+Ot)x$p z&KuVs1=Bx?zTa(ZfM@$JWkUJb_rQSfq72m+F$eGt0shGLUNOx|1HY&DzMNO1o2#x|X6?l)w0x~J&0=zxXn%9Jy<(ZAg zOB(iL$u*SIS*KX&z$)ve`tKA)$*#EF^XqOn zJL)Gq6KAxn$BRFfRIwC;f2nRWYC8~nksABv>qn-GnZDZaSk;%EbV}bI<33#$_XW;u zB(1#Nd`$ECa83E#y*jkW^7!e>0Gq|N$132}_)0RZf6~{+(F$*6n!a$1;<6d|%!q+2 z_)6S_vY%2YZL6c literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_right.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_right.png new file mode 100755 index 0000000000000000000000000000000000000000..0473f3ac6a473077ff8a4dd2cbc0379af72e8c08 GIT binary patch literal 1321 zcmb7^|3A}v6vyBDjLm0^;X#`(-Hj`^d|RZrbG0$#`+T{KL{sUiL@I7|uYFJ ztE;*_qBXJW`ck$`CMjPZ`I@1aZmua3+vTqNAKdeJobx*8@i?!?`RP1!SOH#moDmKH zfcM_T3|1-fB{k5hx+uN0q7t=(41WdyHTjxKj=E}gVEKi5tpA@-nN;frf{ah9j&LG4 zAS5w=CpYk`2lFp=#(%YoX#pC^Ga0H&=e(JW&>x5A%4YMX$XbXqbk?dvv2)I+Jl$DFy{nLpA*{-XkuF;NYOfh~F&4#K{$!`NKtOuirQPpfB->^_V270Nys({) zTuhttr9|sGJ2{S`*?z~4Mn~Ao-3;Rz(EP>sYn{^L(^PFjyR}vHFHg?~fm4tC8L5dS zBiZ|a(oFfDNbyOFL=`uLMPISZ15q!yC5D4>2neyDVPG2B5s9*GTYDQ$|!-~pjJNKpuQPT zE^e7DC`)(O*QfyKcNZlkL!SNS>0md7+q86ka_+Y?n5CTRL+SIHb!bvYsh%{N1SPE) zyBKI`iSHv#TiJsoVwfP~XV{0Y!v*kU8II6|fyjggEn z=ro>{u)UcQv6(=Ile9F^w&6KA;XK2JdPxKe9=8Y!4psDQ z@rWP8HD#(pavCfQ9Z-B*Oi8V{S@e8<8*(|Vo`>Q;NiDe5CR;NAF~_$KMai{$y+9)u zPU02bS`zi6DrkSYxXFXz))3#4#vuU?jEe3dL}NIx)}h8>c4W)qz+rsmb>=OX01FoB z?wdGC6ARV(#KJeMsw+EXz|h6DrEjZuUuko*zPE+Z*g!2u@XjwHhI1!kgG4>yOm3)F zYMEVoRG;TmXJ2B~9GWFTFS`Wy&zW%!eLoPpwJJ8bs$U-s5i5UaTo?w$$Vv2dU?hb4I6fn~*@D!3lWRi6Dz!|oZ#KYAbtzly_^=f6m~ z^lXtgsE~aU>tsJxXWch!9BdixezhbIcW}+Cc3f3Jq7#LcM@n-<=cetf|AUDB-Y-Ov YW2=fqv|cebLPx# literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_up.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_up.png new file mode 100755 index 0000000000000000000000000000000000000000..98d7d1e4a948176cd0b4fe64a3fe91bfdf95a710 GIT binary patch literal 1370 zcmb7^|34FW9LGP~e3@m&%9pfV7S<^ZMJr)*H<=pwvV^W$oNp`A=}^R?YtEONiM}kY zICqrq5oXO)L~g!+SqO(%Ov$&@mQME{-0Sgpzn|~-HDr!}YqJGwdoP=0Nl@}%n8zRR6Pcl!E&wDz=iu3MG3yf$f{^K|n% zbL|kRJz;Zcy~W6h_b5KFWoo;uIX!lvIMQPVW_dFec}AKn9Zo9r%@=-D`&)so6&dFQp@NHz6U70T@?HtrB32^cEiy zDuP2Ii%T*_4v7ie5m#{ucjTa$kU8Sg0b^h_N9Dobz`O!3{cXMuF3p;cg*OAdH~ai1 z*1+Ag^m5mS1huk;Ft0j6T=hu-!$QTqUHov~0G?idwv&`pDS(UFN&H)b^PA&Z*DG~_ z|0{g|B0W&&8E}x7vWNYd#Osq=tU(d2FS=o}-u6>Qr7=*3ETEk@gn{>>(J#QaCvw-z zl3qF7gZocK+X+?e+*tuRHVI*g9d?Ly|8Rb1lM?P1ze?NHPM2ssv|Iw1;e>hh(xL`S zahL0qO*qnE|JS!s-ZXE_*=ZI^Pv{Au%m+NFcOVusOrkxuUG(Vy=p)=1GQrqi-go;! zgBhBoqS-v1jPV3l%*_D#)zq!j6|4K}sM=&mld*BA@Rvu=Z|WL~?+icZQ87*YN=6x& zX;~GfO?*IL1Oj~=%alKE{4j2aRXa!uyJ5jG9DL>Mgmp4YO~jMV{Uekl_4l? zBZJp7my4!_tBN#?c77?8LSt&}LyGA^&bbq$sok&1aDQv$Xn-wLh8lSQ3lH+7o2gdw0`je}?&Us&lzGPVrp)TZbx z!~PBd$3hJ>fsZ*Da;SO@#ykeuRn}_OkWN(D7RaAW#m!8}@s|r)>WowC%6Mo)GR(}c z1y#V+TG{JMhSfYcznLGXij_rjUcgKi<3j<}N8QDl9SttuPHnlX`YUI5iOqKme&Z3p ziH+coB)TZ2v5S@E)oLB>UV#8I^0qQ*7<=5lJTi3kN~z>hwr52q%+_>YRklGsfu8?; zr&|l9yx6~7w}N+|>N~vIQr56sz?hgRQtqwkit>w>QSNv(2l1ZI4P54W-fkemVkCyx z2&&FKbL`8M#_E^D{ckJjn0xk}Owh79i;fnuy=uSIfR& zU6eCO9o%suy9;(YlPOPuKf{zJg$eSdk$FN9Vaj<2INMg->?~w;qgT9l^f0wIe1J*| zFLi8R=Ct>&1ng_}*w8kFICv@}T+fc#y@XbcW|Z$wtd2F(qI*wgNa)KSg_T=ZOn6^n zmG=)9U{hqin6xg09uqm+5qVmFEYNrn`_mOv^H%KZ<*V=V1}EN$#UVqukQ$%Q9!Q%s z>x)V|eEml1pktF(!O76rGqmfxWBeF)i?L611g>8l1};AcA&RzJ?6lXRhMV2{k%;TQ z546!QHjS!@NeAYVTaSXLHX39S>nW_D8eCtXpHs7ik7nQ;6-0=Ugw^M6q?a>`&&q4t zBVE|@#G#7wzGmq7J3RtcU#SpTfE&0txgs(6Y1=t&o`HliMfuntMHC|=U_;ER*{1@GiiuwQm literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_vertical.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_vertical.png new file mode 100755 index 0000000000000000000000000000000000000000..5d2cfe94bb206e2eba7eccd410911a2d46f12c35 GIT binary patch literal 1465 zcmb7E`#;lr9R7S8GYm^*a@&-OoZP8{Vj`{P5;n4Abiv$CSxk(txkheXmc+3x%0f1* zD3^JmagH68N~9<$p_FQ*)y~eJaNe)i`+45a>-9XpJU_fo`FeY5sOhNz05piZ-Tf2{ z|F4g_epirE1i^~{fS_1i7N)Gww!WkQ5BdKYMMz;^BV$Dsn44tpi0z>JM-2f=BA-WR+n4=#q#TH+~qv3sT$h0MSr!?_h+sf$6%9Sq;*rbje zo_ydr>PEfElr3Ozuw38O{80F^2VB;8(NO78996Cx$wed*4b&P-RZ5cl-Co*2?!C+=!w=G6LeX#ed^R;hYz|2T`F`FYVp;nZ91Z86m ztutEWF~(Ya@6T_~EUqW*G{$BCen(KKxG(DGq{UW(rw{3zo7vwC5` z&IcjD$Qoa3ldz;}eRv!JUtxbu#r!oV1cZqrdj+0Z4Evrf6hO%eRz|FAX~ z#9||n`3UYS_Q(Llq|y1lymAQ7?cZhuIE~v{oY~N)_&-ayL=4bNj`KgMM)q-rOd_G{ zntU(%VX`VRn6M7o$aSeSgNJYD)}Kcv+LZ!z-zpl2u`Dl9QMS64t>nvH=4}J5{h z*5@l(p>fjyPNVIH!MXDdJUn7Z%>*!-QzfW`*nijv20AWB5D3P^oC9&J)7^7D_72;ygAjMF5gY zb!ufTFQX=xb7YRoS_K|v3`aKZ#fejBSj{7A6XS}`om2BEnk$(L2rlFoyWz?8YBWbGb8%hYmm=%Wdxb$f!eX9y<}blj zO2vWpF>SB*bkT3$tQwEMqwqM->4KfjNw0aq6afpXV;-`quaJ~APFoigsAyu;KS5qi z?zyp%y`mV=QyP6Q?`Gg_$Ye%*Z0@zqm5b8RGsU5w#VNPqUSyhx%&FahcRc1r#^pg~ z-&F!PoJ8f#{^%x4%%4_17fc%$TE7oh>1A(7nbsioXofWopNV_LFY<)@lNhF5@2{9G zLo-TwGEtQMl<>3Z8Bp5rkv^F`)77l{&w`|~kmQpU)T!k>Zn-PbVC3Nd^3d$h*icdj zxvx!QqWNVW<)u16S=BOaMc&-}U-@O}n!ss1JZ&o+t?h1uXhCDB5vc}}O-_KZoysQG zq^Sev>_s9T%@cnTN?Hzs;p3ZXu`d!;lmDqgSA8|6NpdH5|BZvw>#dc#5Ywbo+luB Lc)Q+Wc( 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 Date: Fri, 7 Mar 2025 22:23:59 +0100 Subject: [PATCH 070/279] more robust controller connection handling, just in case --- arcade/gui/experimental/controller.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/arcade/gui/experimental/controller.py b/arcade/gui/experimental/controller.py index 040f12e43d..54be7245d3 100644 --- a/arcade/gui/experimental/controller.py +++ b/arcade/gui/experimental/controller.py @@ -1,3 +1,4 @@ +import warnings from dataclasses import dataclass from pyglet.input import Controller @@ -128,11 +129,19 @@ def __init__(self, ui: UIManager): def on_connect(self, controller: Controller): controller.push_handlers(self) - controller.open() + + try: + controller.open() + except Exception as e: + warnings.warn(f"Failed to open controller {controller}: {e}") def on_disconnect(self, controller: Controller): controller.remove_handlers(self) - controller.close() + + try: + controller.close() + except Exception as e: + warnings.warn(f"Failed to close controller {controller}: {e}") # Controller event mapping def on_stick_motion(self, controller: Controller, name, value): From a81ca2be516c79a547cda95291488942effe1bb6 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 7 Mar 2025 22:27:35 +0100 Subject: [PATCH 071/279] fix lib imports and extract UIFocusMixin from UIFocusGroup --- arcade/gui/experimental/focus.py | 82 ++++++++++++++++------- arcade/gui/experimental/password_input.py | 4 +- arcade/gui/experimental/scroll_area.py | 11 ++- 3 files changed, 65 insertions(+), 32 deletions(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 09e512f9bb..33bd7e402c 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -1,3 +1,4 @@ +import warnings from typing import Optional from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED @@ -5,26 +6,23 @@ import arcade from arcade import MOUSE_BUTTON_LEFT -from arcade.gui import ( - ListProperty, - Property, - Surface, - UIAnchorLayout, +from arcade.gui.events import ( UIEvent, - UIInteractiveWidget, UIKeyPressEvent, UIKeyReleaseEvent, - UIManager, UIMousePressEvent, UIMouseReleaseEvent, - UIWidget, - bind, ) from arcade.gui.experimental.controller import ( UIControllerButtonPressEvent, UIControllerButtonReleaseEvent, UIControllerDpadEvent, ) +from arcade.gui.property import ListProperty, Property, bind +from arcade.gui.surface import Surface +from arcade.gui.ui_manager import UIManager +from arcade.gui.widgets import UIInteractiveWidget, UIWidget +from arcade.gui.widgets.layout import UIAnchorLayout class Focusable(UIWidget): @@ -59,7 +57,7 @@ def ui(self) -> UIManager | None: return None -class UIFocusGroup(UIAnchorLayout): +class UIFocusMixin(UIWidget): """A group of widgets that can be focused. UIFocusGroup maintains two lists of widgets: @@ -79,11 +77,12 @@ class UIFocusGroup(UIAnchorLayout): _focusable_widgets = ListProperty[UIWidget]() _focused = Property(0) + _interacting: UIWidget | None = None _debug = Property(False) - def __init__(self, size_hint=(1, 1), **kwargs): - super().__init__(size_hint=size_hint, **kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) bind(self, "_debug", self.trigger_full_render) bind(self, "_focused", self.trigger_full_render) @@ -112,21 +111,35 @@ def on_event(self, event: UIEvent) -> Optional[bool]: return EVENT_HANDLED if isinstance(event, UIControllerDpadEvent): - if event.vector.x == 1: - self.focus_right() - return EVENT_HANDLED + if self._interacting: + # pass dpad events to the interacting widget + if event.vector.x == 1 and isinstance(self._interacting, UIBaseSlider): + self._interacting.norm_value += 0.1 + return EVENT_HANDLED - elif event.vector.y == 1: - self.focus_up() - return EVENT_HANDLED + elif event.vector.x == -1 and isinstance(self._interacting, UIBaseSlider): + self._interacting.norm_value -= 0.1 + return EVENT_HANDLED - elif event.vector.x == -1: - self.focus_left() return EVENT_HANDLED - elif event.vector.y == -1: - self.focus_down() - return EVENT_HANDLED + else: + # switch focus + if event.vector.x == 1: + self.focus_right() + return EVENT_HANDLED + + elif event.vector.y == 1: + self.focus_up() + return EVENT_HANDLED + + elif event.vector.x == -1: + self.focus_left() + return EVENT_HANDLED + + elif event.vector.y == -1: + self.focus_down() + return EVENT_HANDLED elif isinstance(event, UIControllerButtonPressEvent): if event.button == "a": @@ -225,6 +238,7 @@ def start_interaction(self): modifiers=0, ) ) + self._interacting = widget else: print("Cannot interact widget") @@ -232,11 +246,20 @@ def end_interaction(self): widget = self._focusable_widgets[self._focused] if isinstance(widget, UIInteractiveWidget): + if isinstance(self._interacting, UIBaseSlider): + # if slider, release outside the slider + x = self._interacting.rect.left - 1 + y = self._interacting.rect.bottom - 1 + else: + x = widget.rect.center_x + y = widget.rect.center_y + + self._interacting = None widget.dispatch_ui_event( UIMouseReleaseEvent( source=self, - x=widget.rect.center_x, - y=widget.rect.center_y, + x=x, + y=y, button=MOUSE_BUTTON_LEFT, modifiers=0, ) @@ -253,6 +276,11 @@ def _do_render(self, surface: Surface, force=False) -> bool: def do_post_render(self, surface: Surface): surface.limit(None) + + if self._focused < len(self._focusable_widgets): + warnings.warn("Focused widget is out of range") + return + widget = self._focusable_widgets[self._focused] arcade.draw_rect_outline( rect=widget.rect, @@ -295,3 +323,7 @@ def _draw_indicator(self, start: Vec2, end: Vec2, color=arcade.color.WHITE): @staticmethod def is_focusable(widget): return isinstance(widget, (Focusable, UIInteractiveWidget)) + + +class UIFocusGroup(UIFocusMixin, UIAnchorLayout): + pass diff --git a/arcade/gui/experimental/password_input.py b/arcade/gui/experimental/password_input.py index c0cbd3ecee..b778bee159 100644 --- a/arcade/gui/experimental/password_input.py +++ b/arcade/gui/experimental/password_input.py @@ -2,7 +2,9 @@ from typing import Optional -from arcade.gui import Surface, UIEvent, UIInputText, UITextInputEvent +from arcade.gui.events import UIEvent, UITextInputEvent +from arcade.gui.surface import Surface +from arcade.gui.widgets.text import UIInputText class UIPasswordInput(UIInputText): diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index b33c907782..31d44e0eb6 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -6,20 +6,19 @@ import arcade from arcade import XYWH -from arcade.gui import ( - Property, - Surface, +from arcade.gui.events import ( UIEvent, - UILayout, UIMouseDragEvent, UIMouseEvent, UIMouseMovementEvent, UIMousePressEvent, UIMouseReleaseEvent, UIMouseScrollEvent, - UIWidget, - bind, ) +from arcade.gui.property import Property, bind +from arcade.gui.surface import Surface +from arcade.gui.widgets import UIWidget +from arcade.gui.widgets.layout import UILayout from arcade.types import LBWH From 818c5ad16077c061bdac3a535edd29ef4051e8bc Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 7 Mar 2025 22:43:22 +0100 Subject: [PATCH 072/279] make UIDropDown support controller --- arcade/gui/widgets/dropdown.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index a162f5b66f..376a95ef98 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -9,13 +9,15 @@ from arcade import uicolor from arcade.gui import UIEvent, UIMousePressEvent from arcade.gui.events import UIOnChangeEvent, UIOnClickEvent +from arcade.gui.experimental.controller import UIControllerButtonPressEvent +from arcade.gui.experimental.focus import UIFocusMixin from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UILayout, UIWidget from arcade.gui.widgets.buttons import UIFlatButton from arcade.gui.widgets.layout import UIBoxLayout -class _UIDropdownOverlay(UIBoxLayout): +class _UIDropdownOverlay(UIFocusMixin, UIBoxLayout): """Represents the dropdown options overlay. Currently only handles closing the overlay when clicked outside of the options. @@ -37,6 +39,13 @@ def on_event(self, event: UIEvent) -> Optional[bool]: if not self.rect.point_in_rect((event.x, event.y)): self.hide() return EVENT_HANDLED + + if isinstance(event, UIControllerButtonPressEvent): + # TODO find a better and more generic way to handle controller events for this + if event.button == "b": + self.hide() + return EVENT_HANDLED + return super().on_event(event) @@ -188,6 +197,8 @@ def _update_options(self): ) button.on_click = self._on_option_click + self._overlay.detect_focusable_widgets() + def _find_ui_manager(self): # search tree for UIManager parent = self.parent From 7489e4cc5c7e14fe211449118b1ea174eea6eabb Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 7 Mar 2025 22:50:37 +0100 Subject: [PATCH 073/279] wip slider support --- arcade/examples/gui/exp_controller_support.py | 11 +++++++++-- arcade/gui/experimental/focus.py | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py index d2f97cec52..fd6fe55bef 100644 --- a/arcade/examples/gui/exp_controller_support.py +++ b/arcade/examples/gui/exp_controller_support.py @@ -5,11 +5,13 @@ from arcade.gui import ( UIAnchorLayout, UIBoxLayout, + UIDropdown, UIEvent, UIFlatButton, UIImage, UIMouseFilterMixin, UIOnClickEvent, + UISlider, UIView, ) from arcade.gui.experimental.controller import ( @@ -156,14 +158,19 @@ def __init__(self): self.controller_bridge = UIControllerBridge(self.ui) - self.root = self.add_widget(ControllerIndicator()) - self.root = self.root.add(UIFocusGroup()) + base = self.add_widget(ControllerIndicator()) + self.root = base.add(UIFocusGroup()) + self.root.with_padding(left=10) box = self.root.add(UIBoxLayout(space_between=10), anchor_x="left") box.add(UIFlatButton(text="Button 1")).on_click = self.on_button_click box.add(UIFlatButton(text="Button 2")).on_click = self.on_button_click box.add(UIFlatButton(text="Button 3")).on_click = self.on_button_click + box.add(UIDropdown(default="Option 1", options=["Option 1", "Option 2", "Option 3"])) + + box.add(UISlider(value=0.5, min_value=0, max_value=1, width=200)) + self.root.detect_focusable_widgets() def on_button_click(self, event: UIOnClickEvent): diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 33bd7e402c..27610365cc 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -23,6 +23,7 @@ from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UIInteractiveWidget, UIWidget from arcade.gui.widgets.layout import UIAnchorLayout +from arcade.gui.widgets.slider import UIBaseSlider class Focusable(UIWidget): @@ -112,6 +113,7 @@ def on_event(self, event: UIEvent) -> Optional[bool]: if isinstance(event, UIControllerDpadEvent): if self._interacting: + # TODO this should be handled in the slider! # pass dpad events to the interacting widget if event.vector.x == 1 and isinstance(self._interacting, UIBaseSlider): self._interacting.norm_value += 0.1 @@ -277,7 +279,7 @@ def _do_render(self, surface: Surface, force=False) -> bool: def do_post_render(self, surface: Surface): surface.limit(None) - if self._focused < len(self._focusable_widgets): + if len(self._focusable_widgets) < self._focused < 0: warnings.warn("Focused widget is out of range") return From 91ec3971bd08c5c5f586ed71145e217d3bc1575e Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 7 Mar 2025 23:07:33 +0100 Subject: [PATCH 074/279] UISlider dispatch on_change when changed via dpad, this is only a workaround --- arcade/gui/widgets/slider.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index d040885944..390f9c3ebc 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -95,6 +95,21 @@ def __init__( self.register_event_type("on_change") + def _change_value(self, value: float): + # TODO changing the value itself should trigger this event + # current problem is, that the property does not pass the old value to change listeners + if value < self.min_value: + value = self.min_value + elif value > self.max_value: + value = self.max_value + + if self.value == value: + return + + old_value = self.value + self.value = value + self.dispatch_event("on_change", UIOnChangeEvent(self, old_value, self.value)) + def _x_for_value(self, value: float): """Provides the x coordinate for the given value.""" @@ -110,7 +125,8 @@ def norm_value(self): @norm_value.setter def norm_value(self, value): """Normalized value between 0.0 and 1.0""" - self.value = min(value * (self.max_value - self.min_value) + self.min_value, self.max_value) + new_value = min(value * (self.max_value - self.min_value) + self.min_value, self.max_value) + self._change_value(new_value) @property def _thumb_x(self): @@ -181,9 +197,8 @@ def on_event(self, event: UIEvent) -> Optional[bool]: if isinstance(event, UIMouseDragEvent): if self.pressed: - old_value = self.value self._thumb_x = event.x - self.dispatch_event("on_change", UIOnChangeEvent(self, old_value, self.value)) + return EVENT_HANDLED return EVENT_UNHANDLED From 500d2401a80e95f24dc657bef183d6a79ae08b54 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 7 Mar 2025 23:07:49 +0100 Subject: [PATCH 075/279] reset focus when out of range --- arcade/gui/experimental/focus.py | 1 + 1 file changed, 1 insertion(+) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 27610365cc..c45dd2567f 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -281,6 +281,7 @@ def do_post_render(self, surface: Surface): if len(self._focusable_widgets) < self._focused < 0: warnings.warn("Focused widget is out of range") + self._focused = 0 return widget = self._focusable_widgets[self._focused] From 4711cf52b03f8bc4ac69f4ece1a54290d1d50802 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 7 Mar 2025 23:08:08 +0100 Subject: [PATCH 076/279] controller example listen to slider changes --- arcade/examples/gui/exp_controller_support.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py index fd6fe55bef..d99cf2cf98 100644 --- a/arcade/examples/gui/exp_controller_support.py +++ b/arcade/examples/gui/exp_controller_support.py @@ -10,6 +10,7 @@ UIFlatButton, UIImage, UIMouseFilterMixin, + UIOnChangeEvent, UIOnClickEvent, UISlider, UIView, @@ -169,7 +170,11 @@ def __init__(self): box.add(UIDropdown(default="Option 1", options=["Option 1", "Option 2", "Option 3"])) - box.add(UISlider(value=0.5, min_value=0, max_value=1, width=200)) + slider = box.add(UISlider(value=0.5, min_value=0, max_value=1, width=200)) + + @slider.event + def on_change(event: UIOnChangeEvent): + print(f"Slider value changed: {event}") self.root.detect_focusable_widgets() From 9eaa54286f601a80bb12007da6288c6af6cadcef Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 7 Mar 2025 23:15:28 +0100 Subject: [PATCH 077/279] fix focus out of range --- arcade/gui/experimental/focus.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index c45dd2567f..5abb775a2c 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -154,6 +154,16 @@ def on_event(self, event: UIEvent) -> Optional[bool]: return EVENT_UNHANDLED + def _get_focused_widget(self) -> UIWidget | None: + if len(self._focusable_widgets) == 0: + return None + + if len(self._focusable_widgets) < self._focused < 0: + warnings.warn("Focused widget is out of range") + self._focused = 0 + + return self._focusable_widgets[self._focused] + def add_widget(self, widget): self._focusable_widgets.append(widget) @@ -178,7 +188,7 @@ def detect_focusable_widgets(self, root: UIWidget = None): self._focusable_widgets = focusable_widgets def focus_up(self): - widget = self._focusable_widgets[self._focused] + widget = self._get_focused_widget() if isinstance(widget, Focusable): if widget.neighbor_up: _index = self._focusable_widgets.index(widget.neighbor_up) @@ -188,7 +198,7 @@ def focus_up(self): self.focus_previous() def focus_down(self): - widget = self._focusable_widgets[self._focused] + widget = self._get_focused_widget() if isinstance(widget, Focusable): if widget.neighbor_down: _index = self._focusable_widgets.index(widget.neighbor_down) @@ -198,7 +208,7 @@ def focus_down(self): self.focus_next() def focus_left(self): - widget = self._focusable_widgets[self._focused] + widget = self._get_focused_widget() if isinstance(widget, Focusable): if widget.neighbor_left: _index = self._focusable_widgets.index(widget.neighbor_left) @@ -208,7 +218,7 @@ def focus_left(self): self.focus_previous() def focus_right(self): - widget = self._focusable_widgets[self._focused] + widget = self._get_focused_widget() if isinstance(widget, Focusable): if widget.neighbor_right: _index = self._focusable_widgets.index(widget.neighbor_right) @@ -228,7 +238,7 @@ def focus_previous(self): self._focused = len(self._focusable_widgets) - 1 def start_interaction(self): - widget = self._focusable_widgets[self._focused] + widget = self._get_focused_widget() if isinstance(widget, UIInteractiveWidget): widget.dispatch_ui_event( @@ -245,7 +255,7 @@ def start_interaction(self): print("Cannot interact widget") def end_interaction(self): - widget = self._focusable_widgets[self._focused] + widget = self._get_focused_widget() if isinstance(widget, UIInteractiveWidget): if isinstance(self._interacting, UIBaseSlider): @@ -279,12 +289,7 @@ def _do_render(self, surface: Surface, force=False) -> bool: def do_post_render(self, surface: Surface): surface.limit(None) - if len(self._focusable_widgets) < self._focused < 0: - warnings.warn("Focused widget is out of range") - self._focused = 0 - return - - widget = self._focusable_widgets[self._focused] + widget = self._get_focused_widget() arcade.draw_rect_outline( rect=widget.rect, color=arcade.color.WHITE, From 7dc467df209c0deb4379a07fdb68e79058a02059 Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Sat, 8 Mar 2025 10:38:38 +0100 Subject: [PATCH 078/279] Add UIFocusMixin do_post_render None widget handling (#2605) - Adds a None check to do_post_render, so it doesnt crash when trying to access the widget's rect if the widget is None --- arcade/gui/experimental/focus.py | 67 ++++++++++++++++---------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 5abb775a2c..ab0de1bba3 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -290,39 +290,40 @@ def do_post_render(self, surface: Surface): surface.limit(None) widget = self._get_focused_widget() - arcade.draw_rect_outline( - rect=widget.rect, - color=arcade.color.WHITE, - border_width=2, - ) - - if self._debug: - # debugging - if isinstance(widget, Focusable): - if widget.neighbor_up: - self._draw_indicator( - widget.rect.top_center, - widget.neighbor_up.rect.bottom_center, - color=arcade.color.RED, - ) - if widget.neighbor_down: - self._draw_indicator( - widget.rect.bottom_center, - widget.neighbor_down.rect.top_center, - color=arcade.color.GREEN, - ) - if widget.neighbor_left: - self._draw_indicator( - widget.rect.center_left, - widget.neighbor_left.rect.center_right, - color=arcade.color.BLUE, - ) - if widget.neighbor_right: - self._draw_indicator( - widget.rect.center_right, - widget.neighbor_right.rect.center_left, - color=arcade.color.ORANGE, - ) + if widget: + arcade.draw_rect_outline( + rect=widget.rect, + color=arcade.color.WHITE, + border_width=2, + ) + + if self._debug: + # debugging + if isinstance(widget, Focusable): + if widget.neighbor_up: + self._draw_indicator( + widget.rect.top_center, + widget.neighbor_up.rect.bottom_center, + color=arcade.color.RED, + ) + if widget.neighbor_down: + self._draw_indicator( + widget.rect.bottom_center, + widget.neighbor_down.rect.top_center, + color=arcade.color.GREEN, + ) + if widget.neighbor_left: + self._draw_indicator( + widget.rect.center_left, + widget.neighbor_left.rect.center_right, + color=arcade.color.BLUE, + ) + if widget.neighbor_right: + self._draw_indicator( + widget.rect.center_right, + widget.neighbor_right.rect.center_left, + color=arcade.color.ORANGE, + ) def _draw_indicator(self, start: Vec2, end: Vec2, color=arcade.color.WHITE): arcade.draw_line(start.x, start.y, end.x, end.y, color, 2) From 8db8913ea21902c6a01d2efc7ac6f3f7265aa3cf Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 8 Mar 2025 21:05:35 +0100 Subject: [PATCH 079/279] adding more workarounds to handle scrollarea setups, set focused on widget --- arcade/gui/experimental/focus.py | 108 ++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 29 deletions(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index ab0de1bba3..1d4e7da52e 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -5,7 +5,7 @@ from pyglet.math import Vec2 import arcade -from arcade import MOUSE_BUTTON_LEFT +from arcade import LBWH, MOUSE_BUTTON_LEFT from arcade.gui.events import ( UIEvent, UIKeyPressEvent, @@ -57,6 +57,36 @@ def ui(self) -> UIManager | None: w = self.parent return None + def _render_focus(self, surface: Surface): + # this will be properly integrated into widget + self.prepare_render(surface) + arcade.draw_rect_outline( + rect=LBWH(0, 0, self.content_width, self.content_height), + color=arcade.color.WHITE, + border_width=4, + ) + + def _do_render(self, surface: Surface, force=False) -> bool: + rendered = False + + should_render = force or self._requires_render + if should_render and self.visible: + rendered = True + self.do_render_base(surface) + self.do_render(surface) + + if self.focused: + self._render_focus(surface) + + self._requires_render = False + + # only render children if self is visible + if self.visible: + for child in self.children: + rendered |= child._do_render(surface, should_render) + + return rendered + class UIFocusMixin(UIWidget): """A group of widgets that can be focused. @@ -154,6 +184,18 @@ def on_event(self, event: UIEvent) -> Optional[bool]: return EVENT_UNHANDLED + def _ensure_focused_property(self): + # TODO this is a hack, to set the focused property on the focused widget + # this should be properly handled in a property or so + + focused = self._get_focused_widget() + + for widget in self._focusable_widgets: + if widget == focused: + widget.focused = True + else: + widget.focused = False + def _get_focused_widget(self) -> UIWidget | None: if len(self._focusable_widgets) == 0: return None @@ -278,6 +320,8 @@ def end_interaction(self): ) def _do_render(self, surface: Surface, force=False) -> bool: + self._ensure_focused_property() # TODO this is a hack, to set the focused property on the focused widget + # TODO: add a post child render hook to UIWidget rendered = super()._do_render(surface, force) @@ -290,40 +334,46 @@ def do_post_render(self, surface: Surface): surface.limit(None) widget = self._get_focused_widget() - if widget: + if not widget: + return + + if isinstance(widget, Focusable): + # Focusable widgets care about focus themselves + pass + else: arcade.draw_rect_outline( rect=widget.rect, color=arcade.color.WHITE, border_width=2, ) - if self._debug: - # debugging - if isinstance(widget, Focusable): - if widget.neighbor_up: - self._draw_indicator( - widget.rect.top_center, - widget.neighbor_up.rect.bottom_center, - color=arcade.color.RED, - ) - if widget.neighbor_down: - self._draw_indicator( - widget.rect.bottom_center, - widget.neighbor_down.rect.top_center, - color=arcade.color.GREEN, - ) - if widget.neighbor_left: - self._draw_indicator( - widget.rect.center_left, - widget.neighbor_left.rect.center_right, - color=arcade.color.BLUE, - ) - if widget.neighbor_right: - self._draw_indicator( - widget.rect.center_right, - widget.neighbor_right.rect.center_left, - color=arcade.color.ORANGE, - ) + if self._debug: + # debugging + if isinstance(widget, Focusable): + if widget.neighbor_up: + self._draw_indicator( + widget.rect.top_center, + widget.neighbor_up.rect.bottom_center, + color=arcade.color.RED, + ) + if widget.neighbor_down: + self._draw_indicator( + widget.rect.bottom_center, + widget.neighbor_down.rect.top_center, + color=arcade.color.GREEN, + ) + if widget.neighbor_left: + self._draw_indicator( + widget.rect.center_left, + widget.neighbor_left.rect.center_right, + color=arcade.color.BLUE, + ) + if widget.neighbor_right: + self._draw_indicator( + widget.rect.center_right, + widget.neighbor_right.rect.center_left, + color=arcade.color.ORANGE, + ) def _draw_indicator(self, start: Vec2, end: Vec2, color=arcade.color.WHITE): arcade.draw_line(start.x, start.y, end.x, end.y, color, 2) From 3107eaade9f8143fcf0cd12d5d78b57926376fbc Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 8 Mar 2025 22:52:54 +0100 Subject: [PATCH 080/279] introduce ControllerWindow, UIManager accepts controller input from window --- arcade/gui/experimental/controller.py | 11 +++++---- arcade/gui/ui_manager.py | 33 +++++++++++++++++++++++++++ arcade/gui/view.py | 18 +++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/arcade/gui/experimental/controller.py b/arcade/gui/experimental/controller.py index 54be7245d3..c81e778b4f 100644 --- a/arcade/gui/experimental/controller.py +++ b/arcade/gui/experimental/controller.py @@ -1,14 +1,15 @@ import warnings from dataclasses import dataclass +from typing import TYPE_CHECKING from pyglet.input import Controller from pyglet.math import Vec2 from arcade import ControllerManager -from arcade.gui import ( - UIEvent, - UIManager, -) +from arcade.gui.events import UIEvent + +if TYPE_CHECKING: + from arcade.gui.ui_manager import UIManager @dataclass @@ -117,7 +118,7 @@ class UIControllerBridge(_ControllerListener): that other systems should be aware, when not to act on events (like when the UI is active). """ - def __init__(self, ui: UIManager): + def __init__(self, ui: "UIManager"): self.ui = ui self.cm = ControllerManager() diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index a55d2013aa..5d479f7455 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -14,6 +14,7 @@ from typing import Iterable, Optional, TypeVar, Union from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher +from pyglet.input import Controller from typing_extensions import TypeGuard import arcade @@ -31,6 +32,13 @@ UITextMotionEvent, UITextMotionSelectEvent, ) +from arcade.gui.experimental.controller import ( + UIControllerButtonPressEvent, + UIControllerButtonReleaseEvent, + UIControllerDpadEvent, + UIControllerStickEvent, + UIControllerTriggerEvent, +) from arcade.gui.surface import Surface from arcade.gui.widgets import UIWidget from arcade.types import LBWH, AnchorPoint, Point2, Rect @@ -293,6 +301,11 @@ def enable(self) -> None: self.on_text, self.on_text_motion, self.on_text_motion_select, + self.on_stick_motion, + self.on_trigger_motion, + self.on_button_press, + self.on_button_release, + self.on_dpad_motion, ) def disable(self) -> None: @@ -316,6 +329,11 @@ def disable(self) -> None: self.on_text, self.on_text_motion, self.on_text_motion_select, + self.on_stick_motion, + self.on_trigger_motion, + self.on_button_press, + self.on_button_release, + self.on_dpad_motion, ) def on_update(self, time_delta): @@ -452,6 +470,21 @@ def on_resize(self, width, height): self.trigger_render() + def on_stick_motion(self, controller: Controller, name, value): + return self.dispatch_ui_event(UIControllerStickEvent(controller, name, value)) + + def on_trigger_motion(self, controller: Controller, name, value): + return self.dispatch_ui_event(UIControllerTriggerEvent(controller, name, value)) + + def on_button_press(self, controller: Controller, button): + return self.dispatch_ui_event(UIControllerButtonPressEvent(controller, button)) + + def on_button_release(self, controller: Controller, button): + return self.dispatch_ui_event(UIControllerButtonReleaseEvent(controller, button)) + + def on_dpad_motion(self, controller: Controller, value): + return self.dispatch_ui_event(UIControllerDpadEvent(controller, value)) + @property def rect(self) -> Rect: """The rect of the UIManager, which is the window size.""" diff --git a/arcade/gui/view.py b/arcade/gui/view.py index df3cf01648..571439a1eb 100644 --- a/arcade/gui/view.py +++ b/arcade/gui/view.py @@ -2,6 +2,8 @@ from typing import TypeVar +from pyglet.input import Controller + from arcade import View from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UIWidget @@ -58,3 +60,19 @@ def on_draw_before_ui(self): def on_draw_after_ui(self): """Use this method to draw custom elements after the UI elements are drawn.""" pass + + # Controller event mapping + def on_stick_motion(self, controller: Controller, name, value): + pass + + def on_trigger_motion(self, controller: Controller, name, value): + pass + + def on_button_press(self, controller: Controller, button): + pass + + def on_button_release(self, controller: Controller, button): + pass + + def on_dpad_motion(self, controller: Controller, value): + pass From 895c8b7f978a2833db6195e6e59a9c62e888742a Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 9 Mar 2025 19:11:01 +0100 Subject: [PATCH 081/279] Add missing file --- arcade/experimental/controller_window.py | 93 ++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 arcade/experimental/controller_window.py diff --git a/arcade/experimental/controller_window.py b/arcade/experimental/controller_window.py new file mode 100644 index 0000000000..4f1e01788d --- /dev/null +++ b/arcade/experimental/controller_window.py @@ -0,0 +1,93 @@ +import warnings + +from pyglet.input import Controller + +import arcade +from arcade import ControllerManager + + +class _WindowControllerBridge: + """Translates controller events to UIEvents and passes them to the UIManager. + + Controller are automatically connected and disconnected. + + Controller events are consumed by the UIControllerBridge, + if the UIEvent is consumed by the UIManager. + + This implicates, that the UIControllerBridge should be the first listener in the chain and + that other systems should be aware, when not to act on events (like when the UI is active). + """ + + + def __init__(self, window: arcade.Window): + self.window = window + + self.cm = ControllerManager() + self.cm.push_handlers(self) + + # bind to existing controllers + for controller in self.cm.get_controllers(): + self.on_connect(controller) + + def on_connect(self, controller: Controller): + controller.push_handlers(self) + + try: + controller.open() + except Exception as e: + warnings.warn(f"Failed to open controller {controller}: {e}") + + def on_disconnect(self, controller: Controller): + controller.remove_handlers(self) + + try: + controller.close() + except Exception as e: + warnings.warn(f"Failed to close controller {controller}: {e}") + + # Controller event mapping + def on_stick_motion(self, controller: Controller, name, value): + return self.window.dispatch_event("on_stick_motion", controller, name, value) + + def on_trigger_motion(self, controller: Controller, name, value): + return self.window.dispatch_event("on_trigger_motion", controller, name, value) + + def on_button_press(self, controller: Controller, button): + return self.window.dispatch_event("on_button_press", controller, button) + + def on_button_release(self, controller: Controller, button): + return self.window.dispatch_event("on_button_release", controller, button) + + def on_dpad_motion(self, controller: Controller, value): + return self.window.dispatch_event("on_dpad_motion", controller, value) + + +class ControllerWindow(arcade.Window): + """A window that listens to controller events and dispatches them via on_... hooks.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cb = _WindowControllerBridge(self) + + # Controller event mapping + def on_stick_motion(self, controller: Controller, name, value): + pass + + def on_trigger_motion(self, controller: Controller, name, value): + pass + + def on_button_press(self, controller: Controller, button): + pass + + def on_button_release(self, controller: Controller, button): + pass + + def on_dpad_motion(self, controller: Controller, value): + pass + + +ControllerWindow.register_event_type("on_stick_motion") +ControllerWindow.register_event_type("on_trigger_motion") +ControllerWindow.register_event_type("on_button_press") +ControllerWindow.register_event_type("on_button_release") +ControllerWindow.register_event_type("on_dpad_motion") From adfa0ab58eb24583dd762ab2c1a514b58381595e Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 9 Mar 2025 20:05:36 +0100 Subject: [PATCH 082/279] Fix a bug where there are less widgets than focus index --- arcade/gui/experimental/focus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 1d4e7da52e..6f34f9e90f 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -200,7 +200,7 @@ def _get_focused_widget(self) -> UIWidget | None: if len(self._focusable_widgets) == 0: return None - if len(self._focusable_widgets) < self._focused < 0: + if len(self._focusable_widgets) <= self._focused < 0: warnings.warn("Focused widget is out of range") self._focused = 0 From bab1b29dfcc2c29ee7bc897e6d3f3903561562c4 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 9 Mar 2025 21:05:21 +0100 Subject: [PATCH 083/279] fix tests and type hints --- arcade/examples/gui/exp_controller_support.py | 49 +++++++++++------- .../gui/exp_controller_support_grid.py | 13 ++++- arcade/examples/gui/exp_inventory_demo.py | 25 +++++---- arcade/gui/experimental/focus.py | 34 ++++++------ arcade/gui/ui_manager.py | 39 ++++++++++---- .../input_prompt/xbox/xbox_button_a.png | Bin 982 -> 0 bytes .../xbox/xbox_button_a_outline.png | Bin 1269 -> 0 bytes .../input_prompt/xbox/xbox_button_b.png | Bin 900 -> 0 bytes .../xbox/xbox_button_b_outline.png | Bin 1196 -> 0 bytes .../input_prompt/xbox/xbox_button_back.png | Bin 860 -> 0 bytes .../xbox/xbox_button_back_icon.png | Bin 673 -> 0 bytes .../xbox/xbox_button_back_icon_outline.png | Bin 882 -> 0 bytes .../xbox/xbox_button_back_outline.png | Bin 1059 -> 0 bytes .../input_prompt/xbox/xbox_button_color_a.png | Bin 982 -> 0 bytes .../xbox/xbox_button_color_a_outline.png | Bin 1269 -> 0 bytes .../input_prompt/xbox/xbox_button_color_b.png | Bin 900 -> 0 bytes .../xbox/xbox_button_color_b_outline.png | Bin 1191 -> 0 bytes .../input_prompt/xbox/xbox_button_color_x.png | Bin 1036 -> 0 bytes .../xbox/xbox_button_color_x_outline.png | Bin 1336 -> 0 bytes .../input_prompt/xbox/xbox_button_color_y.png | Bin 941 -> 0 bytes .../xbox/xbox_button_color_y_outline.png | Bin 1253 -> 0 bytes .../input_prompt/xbox/xbox_button_menu.png | Bin 774 -> 0 bytes .../xbox/xbox_button_menu_outline.png | Bin 1086 -> 0 bytes .../input_prompt/xbox/xbox_button_share.png | Bin 658 -> 0 bytes .../xbox/xbox_button_share_outline.png | Bin 880 -> 0 bytes .../input_prompt/xbox/xbox_button_start.png | Bin 879 -> 0 bytes .../xbox/xbox_button_start_icon.png | Bin 666 -> 0 bytes .../xbox/xbox_button_start_icon_outline.png | Bin 885 -> 0 bytes .../xbox/xbox_button_start_outline.png | Bin 1077 -> 0 bytes .../input_prompt/xbox/xbox_button_view.png | Bin 846 -> 0 bytes .../xbox/xbox_button_view_outline.png | Bin 1168 -> 0 bytes .../input_prompt/xbox/xbox_button_x.png | Bin 1037 -> 0 bytes .../xbox/xbox_button_x_outline.png | Bin 1336 -> 0 bytes .../input_prompt/xbox/xbox_button_y.png | Bin 941 -> 0 bytes .../xbox/xbox_button_y_outline.png | Bin 1253 -> 0 bytes .../assets/input_prompt/xbox/xbox_dpad.png | Bin 351 -> 0 bytes .../input_prompt/xbox/xbox_dpad_all.png | Bin 351 -> 0 bytes .../input_prompt/xbox/xbox_dpad_down.png | Bin 416 -> 0 bytes .../xbox/xbox_dpad_down_outline.png | Bin 400 -> 0 bytes .../xbox/xbox_dpad_horizontal.png | Bin 435 -> 0 bytes .../xbox/xbox_dpad_horizontal_outline.png | Bin 377 -> 0 bytes .../input_prompt/xbox/xbox_dpad_left.png | Bin 434 -> 0 bytes .../xbox/xbox_dpad_left_outline.png | Bin 389 -> 0 bytes .../input_prompt/xbox/xbox_dpad_none.png | Bin 398 -> 0 bytes .../input_prompt/xbox/xbox_dpad_right.png | Bin 427 -> 0 bytes .../xbox/xbox_dpad_right_outline.png | Bin 391 -> 0 bytes .../input_prompt/xbox/xbox_dpad_round.png | Bin 1032 -> 0 bytes .../input_prompt/xbox/xbox_dpad_round_all.png | Bin 1111 -> 0 bytes .../xbox/xbox_dpad_round_down.png | Bin 1112 -> 0 bytes .../xbox/xbox_dpad_round_horizontal.png | Bin 1118 -> 0 bytes .../xbox/xbox_dpad_round_left.png | Bin 1125 -> 0 bytes .../xbox/xbox_dpad_round_right.png | Bin 1117 -> 0 bytes .../input_prompt/xbox/xbox_dpad_round_up.png | Bin 1128 -> 0 bytes .../xbox/xbox_dpad_round_vertical.png | Bin 1128 -> 0 bytes .../assets/input_prompt/xbox/xbox_dpad_up.png | Bin 436 -> 0 bytes .../xbox/xbox_dpad_up_outline.png | Bin 394 -> 0 bytes .../input_prompt/xbox/xbox_dpad_vertical.png | Bin 422 -> 0 bytes .../xbox/xbox_dpad_vertical_outline.png | Bin 376 -> 0 bytes .../assets/input_prompt/xbox/xbox_guide.png | Bin 1193 -> 0 bytes .../input_prompt/xbox/xbox_guide_outline.png | Bin 1597 -> 0 bytes .../assets/input_prompt/xbox/xbox_lb.png | Bin 589 -> 0 bytes .../input_prompt/xbox/xbox_lb_outline.png | Bin 718 -> 0 bytes .../assets/input_prompt/xbox/xbox_ls.png | Bin 971 -> 0 bytes .../input_prompt/xbox/xbox_ls_outline.png | Bin 1280 -> 0 bytes .../assets/input_prompt/xbox/xbox_lt.png | Bin 533 -> 0 bytes .../input_prompt/xbox/xbox_lt_outline.png | Bin 722 -> 0 bytes .../assets/input_prompt/xbox/xbox_rb.png | Bin 684 -> 0 bytes .../input_prompt/xbox/xbox_rb_outline.png | Bin 800 -> 0 bytes .../assets/input_prompt/xbox/xbox_rs.png | Bin 1065 -> 0 bytes .../input_prompt/xbox/xbox_rs_outline.png | Bin 1354 -> 0 bytes .../assets/input_prompt/xbox/xbox_rt.png | Bin 673 -> 0 bytes .../input_prompt/xbox/xbox_rt_outline.png | Bin 824 -> 0 bytes .../assets/input_prompt/xbox/xbox_stick_l.png | Bin 1228 -> 0 bytes .../input_prompt/xbox/xbox_stick_l_down.png | Bin 1329 -> 0 bytes .../xbox/xbox_stick_l_horizontal.png | Bin 1257 -> 0 bytes .../input_prompt/xbox/xbox_stick_l_left.png | Bin 1263 -> 0 bytes .../input_prompt/xbox/xbox_stick_l_press.png | Bin 1309 -> 0 bytes .../input_prompt/xbox/xbox_stick_l_right.png | Bin 1248 -> 0 bytes .../input_prompt/xbox/xbox_stick_l_up.png | Bin 1319 -> 0 bytes .../xbox/xbox_stick_l_vertical.png | Bin 1405 -> 0 bytes .../assets/input_prompt/xbox/xbox_stick_r.png | Bin 1286 -> 0 bytes .../input_prompt/xbox/xbox_stick_r_down.png | Bin 1384 -> 0 bytes .../xbox/xbox_stick_r_horizontal.png | Bin 1324 -> 0 bytes .../input_prompt/xbox/xbox_stick_r_left.png | Bin 1317 -> 0 bytes .../input_prompt/xbox/xbox_stick_r_press.png | Bin 1364 -> 0 bytes .../input_prompt/xbox/xbox_stick_r_right.png | Bin 1321 -> 0 bytes .../input_prompt/xbox/xbox_stick_r_up.png | Bin 1370 -> 0 bytes .../xbox/xbox_stick_r_vertical.png | Bin 1465 -> 0 bytes .../input_prompt/xbox/xbox_stick_side_l.png | Bin 565 -> 0 bytes .../input_prompt/xbox/xbox_stick_side_r.png | Bin 654 -> 0 bytes .../input_prompt/xbox/xbox_stick_top_l.png | Bin 1268 -> 0 bytes .../input_prompt/xbox/xbox_stick_top_r.png | Bin 1359 -> 0 bytes tests/unit/resources/test_list_resources.py | 4 +- 93 files changed, 107 insertions(+), 57 deletions(-) delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_a.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_a_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_b.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_b_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_back.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_back_icon.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_back_icon_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_back_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_a.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_a_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_b.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_b_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_x.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_x_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_y.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_color_y_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_menu.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_menu_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_share.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_share_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_start.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_start_icon.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_start_icon_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_start_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_view.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_view_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_x.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_x_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_y.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_button_y_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_all.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_down.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_down_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_left.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_left_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_none.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_right.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_right_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_all.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_down.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_horizontal.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_left.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_right.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_up.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_vertical.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_up.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_up_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_guide.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_guide_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_lb.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_lb_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_ls.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_ls_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_lt.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_lt_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rb.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rb_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rs.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rs_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rt.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_rt_outline.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_down.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_horizontal.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_left.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_press.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_right.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_up.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_l_vertical.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_down.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_horizontal.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_left.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_press.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_right.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_up.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_r_vertical.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_side_l.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_side_r.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_top_l.png delete mode 100755 arcade/resources/assets/input_prompt/xbox/xbox_stick_top_r.png diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py index d99cf2cf98..07fc8a9af4 100644 --- a/arcade/examples/gui/exp_controller_support.py +++ b/arcade/examples/gui/exp_controller_support.py @@ -1,3 +1,14 @@ +""" +Example demonstrating controller support in an Arcade GUI. + +This example shows how to integrate controller input with the Arcade GUI framework. +It includes a controller indicator widget that displays the last controller input, +and a modal dialog that can be navigated using a controller. + +If Arcade and Python are properly installed, you can run this example with: +python -m arcade.examples.gui.exp_controller_support +""" + from typing import Optional import arcade @@ -34,7 +45,7 @@ class ControllerIndicator(UIAnchorLayout): """ BLANK_TEX = Texture.create_empty("empty", (40, 40), arcade.color.TRANSPARENT_BLACK) - TEXTURE_CACHE = {} + TEXTURE_CACHE: dict[str, Texture] = {} def __init__(self): super().__init__() @@ -54,39 +65,39 @@ def input_prompts(cls, event: UIControllerEvent) -> Texture | None: if isinstance(event, UIControllerButtonEvent): match event.button: case "a": - return cls.get_texture(":resources:input_prompt/xbox/xbox_button_a.png") + return cls.get_texture(":resources:input_prompt/xbox/button_a.png") case "b": - return cls.get_texture(":resources:input_prompt/xbox/xbox_button_b.png") + return cls.get_texture(":resources:input_prompt/xbox/button_b.png") case "x": - return cls.get_texture(":resources:input_prompt/xbox/xbox_button_x.png") + return cls.get_texture(":resources:input_prompt/xbox/button_x.png") case "y": - return cls.get_texture(":resources:input_prompt/xbox/xbox_button_y.png") + return cls.get_texture(":resources:input_prompt/xbox/button_y.png") case "rightshoulder": - return cls.get_texture(":resources:input_prompt/xbox/xbox_rb.png") + return cls.get_texture(":resources:input_prompt/xbox/rb.png") case "leftshoulder": - return cls.get_texture(":resources:input_prompt/xbox/xbox_lb.png") + return cls.get_texture(":resources:input_prompt/xbox/lb.png") case "start": - return cls.get_texture(":resources:input_prompt/xbox/xbox_button_start.png") + return cls.get_texture(":resources:input_prompt/xbox/button_start.png") case "back": - return cls.get_texture(":resources:input_prompt/xbox/xbox_button_back.png") + return cls.get_texture(":resources:input_prompt/xbox/button_back.png") if isinstance(event, UIControllerTriggerEvent): match event.name: case "lefttrigger": - return cls.get_texture(":resources:input_prompt/xbox/xbox_lt.png") + return cls.get_texture(":resources:input_prompt/xbox/lt.png") case "righttrigger": - return cls.get_texture(":resources:input_prompt/xbox/xbox_rt.png") + return cls.get_texture(":resources:input_prompt/xbox/rt.png") if isinstance(event, UIControllerDpadEvent): match event.vector: case (1, 0): - return cls.get_texture(":resources:input_prompt/xbox/xbox_dpad_right.png") + return cls.get_texture(":resources:input_prompt/xbox/dpad_right.png") case (-1, 0): - return cls.get_texture(":resources:input_prompt/xbox/xbox_dpad_left.png") + return cls.get_texture(":resources:input_prompt/xbox/dpad_left.png") case (0, 1): - return cls.get_texture(":resources:input_prompt/xbox/xbox_dpad_up.png") + return cls.get_texture(":resources:input_prompt/xbox/dpad_up.png") case (0, -1): - return cls.get_texture(":resources:input_prompt/xbox/xbox_dpad_down.png") + return cls.get_texture(":resources:input_prompt/xbox/dpad_down.png") if isinstance(event, UIControllerStickEvent) and event.vector.length() > 0.2: stick = "l" if event.name == "leftstick" else "r" @@ -94,13 +105,13 @@ def input_prompts(cls, event: UIControllerEvent) -> Texture | None: # map atan2(y, x) to direction string (up, down, left, right) heading = event.vector.heading() if 0.785 > heading > -0.785: - return cls.get_texture(f":resources:input_prompt/xbox/xbox_stick_{stick}_right.png") + return cls.get_texture(f":resources:input_prompt/xbox/stick_{stick}_right.png") elif 0.785 < heading < 2.356: - return cls.get_texture(f":resources:input_prompt/xbox/xbox_stick_{stick}_up.png") + return cls.get_texture(f":resources:input_prompt/xbox/stick_{stick}_up.png") elif heading > 2.356 or heading < -2.356: - return cls.get_texture(f":resources:input_prompt/xbox/xbox_stick_{stick}_left.png") + return cls.get_texture(f":resources:input_prompt/xbox/stick_{stick}_left.png") elif -2.356 < heading < -0.785: - return cls.get_texture(f":resources:input_prompt/xbox/xbox_stick_{stick}_down.png") + return cls.get_texture(f":resources:input_prompt/xbox/stick_{stick}_down.png") return None diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py index 5cddcae31b..b7173967ab 100644 --- a/arcade/examples/gui/exp_controller_support_grid.py +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -1,3 +1,13 @@ +""" +Example demonstrating a grid layout with focusable buttons in an Arcade GUI. + +This example shows how to create a grid layout with buttons that can be navigated using a controller. +It includes a focus transition setup to allow smooth navigation between buttons in the grid. + +If Arcade and Python are properly installed, you can run this example with: +python -m arcade.examples.gui.exp_controller_support_grid +""" + from typing import Dict, Tuple import arcade @@ -6,6 +16,7 @@ UIFlatButton, UIGridLayout, UIView, + UIWidget, ) from arcade.gui.experimental.controller import ( UIControllerBridge, @@ -17,7 +28,7 @@ class FocusableButton(Focusable, UIFlatButton): pass -def setup_grid_focus_transition(grid: Dict[Tuple[int, int], Focusable]): +def setup_grid_focus_transition(grid: Dict[Tuple[int, int], UIWidget]): """Setup focus transition in grid. Connect focus transition between `Focusable` in grid. diff --git a/arcade/examples/gui/exp_inventory_demo.py b/arcade/examples/gui/exp_inventory_demo.py index c91fad1dd4..86faf0ff92 100644 --- a/arcade/examples/gui/exp_inventory_demo.py +++ b/arcade/examples/gui/exp_inventory_demo.py @@ -10,8 +10,11 @@ - Move items between slots - Controller support +If Arcade and Python are properly installed, you can run this example with: +python -m arcade.examples.gui.exp_inventory_demo """ +from functools import partial # TODO: Drag and Drop from typing import List @@ -204,7 +207,7 @@ def __init__(self, inventory: Inventory, **kwargs): # fill left to right, bottom to top (6x5 grid) self.add(slot, column=i % 6, row=i // 6) self.grid[(i % 6, i // 6)] = slot - slot.on_click = self._on_slot_click + slot.on_click = self._on_slot_click # type: ignore InventoryUI.register_event_type("on_slot_clicked") @@ -235,13 +238,13 @@ def __init__(self, **kwargs): equipment = Equipment() self.head_slot = self.add(EquipmentSlotUI(equipment, 0)) - self.head_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.head_slot) + self.head_slot.on_click = partial(self.dispatch_event, "on_slot_clicked", self.head_slot) self.chest_slot = self.add(EquipmentSlotUI(equipment, 1)) - self.chest_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.chest_slot) + self.chest_slot.on_click = partial(self.dispatch_event, "on_slot_clicked", self.chest_slot) self.legs_slot = self.add(EquipmentSlotUI(equipment, 2)) - self.legs_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.legs_slot) + self.legs_slot.on_click = partial(self.dispatch_event, "on_slot_clicked", self.legs_slot) EquipmentUI.register_event_type("on_slot_clicked") @@ -251,7 +254,7 @@ class ActiveSlotTrackerMixin(UIWidget): Mixin class to track the active slot. """ - active_slot = Property(None) + active_slot = Property[InventorySlotUI | None](None) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -307,14 +310,16 @@ def __init__(self, inventory: Inventory, **kwargs): self.add(content, anchor_y="bottom") inv_ui = content.add(InventoryUI(inventory)) - inv_ui.on_slot_clicked = self.on_slot_clicked + inv_ui.on_slot_clicked = self.on_slot_clicked # type: ignore eq_ui = content.add(EquipmentUI()) - eq_ui.on_slot_clicked = self.on_slot_clicked + eq_ui.on_slot_clicked = self.on_slot_clicked # type: ignore # prepare focusable widgets widget_grid = inv_ui.grid - setup_grid_focus_transition(widget_grid) # setup default transitions in a grid + setup_grid_focus_transition( + widget_grid # type: ignore + ) # setup default transitions in a grid # add transitions to equipment slots cols = max(x for x, y in widget_grid.keys()) @@ -343,7 +348,7 @@ def __init__(self, inventory: Inventory, **kwargs): anchor_x="right", anchor_y="top", ) - close_button.on_click = lambda _: self.close() + close_button.on_click = lambda _: self.close() # type: ignore def close(self): self.trigger_full_render() @@ -379,6 +384,8 @@ def on_key_press(self, symbol: int, modifiers: int) -> bool | None: print(i, item.symbol if item else "-") return True + return super().on_key_press(symbol, modifiers) + def on_draw_before_ui(self): pass diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 6f34f9e90f..fe5ce58910 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -50,11 +50,13 @@ class Focusable(UIWidget): @property def ui(self) -> UIManager | None: """The UIManager this widget is attached to.""" - w = self - while w.parent: - if isinstance(w.parent, UIManager): - return w.parent - w = self.parent + w: UIWidget | None = self + while w and w.parent: + parent = w.parent + if isinstance(parent, UIManager): + return parent + + w = parent return None def _render_focus(self, surface: Surface): @@ -191,10 +193,11 @@ def _ensure_focused_property(self): focused = self._get_focused_widget() for widget in self._focusable_widgets: - if widget == focused: - widget.focused = True - else: - widget.focused = False + if isinstance(widget, Focusable): + if widget == focused: + widget.focused = True + else: + widget.focused = False def _get_focused_widget(self) -> UIWidget | None: if len(self._focusable_widgets) == 0: @@ -215,7 +218,7 @@ def _walk_widgets(cls, root: UIWidget): yield child yield from cls._walk_widgets(child) - def detect_focusable_widgets(self, root: UIWidget = None): + def detect_focusable_widgets(self, root: UIWidget | None = None): """Automatically detect focusable widgets.""" if root is None: root = self @@ -286,8 +289,8 @@ def start_interaction(self): widget.dispatch_ui_event( UIMousePressEvent( source=self, - x=widget.rect.center_x, - y=widget.rect.center_y, + x=int(widget.rect.center_x), + y=int(widget.rect.center_y), button=MOUSE_BUTTON_LEFT, modifiers=0, ) @@ -312,15 +315,16 @@ def end_interaction(self): widget.dispatch_ui_event( UIMouseReleaseEvent( source=self, - x=x, - y=y, + x=int(x), + y=int(y), button=MOUSE_BUTTON_LEFT, modifiers=0, ) ) def _do_render(self, surface: Surface, force=False) -> bool: - self._ensure_focused_property() # TODO this is a hack, to set the focused property on the focused widget + # TODO this is a hack, to set the focused property on the focused widget + self._ensure_focused_property() # TODO: add a post child render hook to UIWidget rendered = super()._do_render(surface, force) diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 5d479f7455..c1cda8f98e 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -18,6 +18,7 @@ from typing_extensions import TypeGuard import arcade +from arcade.experimental.controller_window import ControllerWindow from arcade.gui import UIEvent from arcade.gui.events import ( UIKeyPressEvent, @@ -41,7 +42,7 @@ ) from arcade.gui.surface import Surface from arcade.gui.widgets import UIWidget -from arcade.types import LBWH, AnchorPoint, Point2, Rect +from arcade.types import AnchorPoint, LBWH, Point2, Rect W = TypeVar("W", bound=UIWidget) @@ -288,6 +289,18 @@ def enable(self) -> None: """ if not self._enabled: self._enabled = True + + if isinstance(self.window, ControllerWindow): + controller_handlers = { + self.on_stick_motion, + self.on_trigger_motion, + self.on_button_press, + self.on_button_release, + self.on_dpad_motion, + } + else: + controller_handlers = set() + self.window.push_handlers( self.on_resize, self.on_update, @@ -301,11 +314,7 @@ def enable(self) -> None: self.on_text, self.on_text_motion, self.on_text_motion_select, - self.on_stick_motion, - self.on_trigger_motion, - self.on_button_press, - self.on_button_release, - self.on_dpad_motion, + *controller_handlers, ) def disable(self) -> None: @@ -316,6 +325,18 @@ def disable(self) -> None: """ if self._enabled: self._enabled = False + + if isinstance(self.window, ControllerWindow): + controller_handlers = { + self.on_stick_motion, + self.on_trigger_motion, + self.on_button_press, + self.on_button_release, + self.on_dpad_motion, + } + else: + controller_handlers = set() + self.window.remove_handlers( self.on_resize, self.on_update, @@ -329,11 +350,7 @@ def disable(self) -> None: self.on_text, self.on_text_motion, self.on_text_motion_select, - self.on_stick_motion, - self.on_trigger_motion, - self.on_button_press, - self.on_button_release, - self.on_dpad_motion, + *controller_handlers, ) def on_update(self, time_delta): diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_a.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_a.png deleted file mode 100755 index 2399fc263be2c40edfe062170cfdfa21370876f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 982 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDvQL(vI4BQEfIt{EJ z<}<0(?~<`o-hFwwJo5#;_@`>?-h8QHn4i4T<$`Df)xzmE{8WV?BZxwW1M2r z@Q5KskpDP?M_nt}kIpyN-x121< zXJYYc`h8uy_P#*{W5*O0dGVVb=jU%{n8I>5IqLkn!W--p zw%sY*tY9v!#4hZwbEN#!sm1P_Up>51zrWN~EcHb})bEyv-A5R|wcYr5hvE3d-S$lG zB5#@&O==GL-TzIHbDU{V(; zo3=&18x!Pm*B=4pucw7gRP zkGIZX(ODD(ubJ3l<|GL__D_qfYIHT9MZ(r3| zU>hbAjI6}Xk}&H8&N i36$P9Fn|;Peum_{^Xu$%m(2j?ECx?kKbLh*2~7aTJiwm- diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_a_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_a_outline.png deleted file mode 100755 index 8dd7cb9f77034b57f8b1695c3b178f4173163f1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1269 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDpk*#bgXQrNYTiBtd#Qw#SOFeRS067-Sd4VT>iPc)1!K4mZ2BK~ z_NuR|2Rln|{lU|JD||Z|m<75`V@tl4ZHzDEO_+J`>PK^?Jl4+)-)!r6K0g(kX7`4b z;r^!o$3wq-WjM`v#pEBq<4cD{hxm`)x#5ziWZAI2rlk7L-)}t5vJFq_L$9)x33qI6 z5M#EO>2yq}qnI)4-6fIdB9j;vrdUsXn|qOwtsqwCLrM@IE5{7=_i3$!F?FGTzUWfZ6@i>l-KPSUhgL92DDtj)`{=oYlCZ3azL^(e_5#%|^ z<(R2q8{T?t!4`pOuZ0@&_)e!@6-48JxQH5EpdfsfPv<$uU3b0ii6rhHD^7|J{{{m zBPPPh&dgqY)yYg30nZ zd1uvbC)ZdPT6?WIUnpy?@OWv*@^>GS7W}w+W@qfh#~0uAFHrvf%eD2!=C4=NDtDb> zn;A0c+y#{*Pd}RJr)#Z?%Y z#$2lOV9qeweV3!{i(=u0?Mps?-x(Hug=_v?(bT=i79alIm9cHrhRXPLGxJN2UY)x1 z?liW#FO6E~-~4*?Z9$A$u=w0n{14Vm{chj#MZW7pTvvAB;lKRd(ht~nFy=LYsXE5Q X2Rm5AnP+eU3myhfS3j3^P6zanPW!H8vaQ5mNGKOV0Ol7}t z;f@shfln+veoh~%m{#;3if-u86FbT9fSDnr;ULo<-5Fkat1~#6{&Upm-QM*;VGpxK z3xo5QW;TPWR#C|mHO2;}gb&FIj51OUyNtg-5M8igV5w~hA9kxPWm0mfR|BB^MDsqL>oh_ zRLe|}mRgPtry2ImR(!ap*o4L5%bt$7`dq$%4@nJmh77+|H?lRXW_(pw%99h$+@mM7 zeD((CC9R3ycXv&`7|L_$_=?G9g8%+LxA@JJvVY1N>7^-`mVBA`DSL8IWK`ek*r;{7 zKD)LV1WrryjGgte>+)w-1<~cZKJAg5@_ZKC(X@*T0=8rvSmJ8@>&){X(xRpht}olY z+^I4qIPi8!{2EW&yJAJlO@1<{RBhf>{o{R4-cIlBRz+-L=GFhruN_(P?%Kcar)Ep0 zW;dLXy?wVXmD!IW$2@WUpOcxJWbUe+{9yH){XjxZzL`x<5M%Sj4_r@#_zJ$;3vD#H z|5g1&#TBDT6Q^Wqq};0c8@wc@OFGkhLebs#E<0!RDlAui$$3!uPh`_EB|(!*U$&-r zs{j6JJhdcf(!b{{Gjs~mPE>JQhQ8$~HTJ#{<8kqXaJcc(yQVzno=DA5D&M!mHt&OC zsAWR;oAW21{MFq4PegF<_Bk%|XZ=q!L`tUi(jP8(+s@hm%0>*Hu6{1-oD!M<+d`uD diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_b_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_b_outline.png deleted file mode 100755 index 474d894f8b2976f5fd2eb083e9e9ea1c98086706..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1196 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD+5n zO^X!-9HIps9lreEUuI;U<&&hZvvcOkrt2$Y7L;3UsHt>d&S2nOz-ZFIc7fqvFoXDk zA3r7TITY+?GmwlJmd9c{=lq_LT14wbdCtnyeOPB|b)l4=1q3vpu*x{r@h`eJly~ z|5r?ZvT(5npMyz#+w|R)zJiRb3(9g&m3+;+9zT_R!+M4Cf9#A~n=Kk{KkF|$DOdKt z?KHy-?{oi4#h;fn++`3wTd(|pN60{b)zSiuG)0L6YIe1iclK7c>G2-8kgwX?XT!3< zWPuCgy%5HWEDK^9;#TqaTDE#KoPO!|=!VIyF2)*B-kM-debxz#Yh+F?ii$)}8Kn1y2R)blbi9F|m6Gn}W9oWjEJUA>XL;+9!y%fk+4)|A+W zntCs03)cn4E7@%rzA3O5xX5Y$ymf>r;~IaWupz_etwjg7@_hTs_$ynphFQWw@`&0j z35Fflf_B_nIf17jW$}%dt*_(nGaR#N$V|CXz$_8T5XNc5EBNi{D~8XXQX4*`GHkAq zbf}kZIFVZC$yOuFFnfL5w7wqu6wYQjOT#HqhjN1Ngc=>SyDrH((fHH&~plEs5 z=i(gp1P|3IOP_arbhq4hVVQ^qmzT*dugB;ON>u|ebI)zOQ%X^ zpIaBIb#%EX?<235D!YqC_*Z@63Mn+dGSfc5k7yB>2j0xmkwb^fqr@}ia6 zcO){Z%ni4%&WgG9m!o7!{{Ddd_ov54|MLh=$=lx`Ja?Uk!1n}M@$`i6>slNNDt%hw zsvqW`nby2G&p9Gfi`g)HrivUdONcTjhslmDjg4E>Hgs?ptt+U$_$NlVTQff?QL54* z?f8+kiMtv4FNLXXo9H>+)M(?jm{}drQ$=FaG-0iCfQnyHcsKF>z6y zlRnSiymWgP&%Iir53WdkyKLEQuwr>%bmHfkJC=T4s&vQMVs0Dnh4bhAujIb0Ikn%_ zcWu(`nHR1}r@j1A>|`?g%8i=5>nfiPFG;Z4xb?Q<@hR*I7t@xTZ;!6Md;8z^-LcEr y3o76Koow{^&z(bwA#qnO{7byZAO+9U_H(}PN?DjPV=u70VDNPHb6Mw<&;$VL@Fx@i diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_back.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_back.png deleted file mode 100755 index 3318c6a5adfc5f8eaaa6d57f997d43d3fda034ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 860 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDEaktaqI1@ zmy?>TJ0Uu zH+ekgo{-FR!LxwTL~XK39m7tU2W5@S4l{EW?O`afV|dGaz}M}e9K-TI!G8W%w?@SV zfBUNNKf83j_qpYFc0MUBJr!ch;AgzySb)abR@e1&eqGov8@=J}SD85se&=UA=KQ#S z+QF6m^}5{W7TjlJeWB0rjN_uL)3dbV!pVQ8Lz+`YB*?efV*Te$UV->ttR z?RA&QgC*g9mi62jeU=Ha`W6nIj8pU`JSCcI~U~@{f;UL2`%>xWxOHYNe>#XKu zDbU#zT>WbOWac;9Zt6Dt-m1j4z?Qd=b-^X(Gs(Z09K0F~H|gs+Fs{mayFO6-4pT^6 z;Q`hQx#F4qI~H*9yKFCH*fvq1xRHnNXoXC}&j}gd7D^O0dKE~>GX7AUIn&-f=7PKI z@pl#hvSJsGB?}bD%zu16DoJqa&0~L-#$J|*zOZqA_Vsy}6g6kAE$Lhux0K<^wymxb zFV6n^&pPLL(*6lk>TGrYZ>V87!|m{dS)fjT`5RYu3&tbed(?9>+>-x$|nMK}Md@hcCDm`0GWX`wlWG0|lXkZ_kz~!L-O>7@` zWO!Z_5hzwK{36rKU^v;xqD3(wT6*Q;wYMW3>?ZzmJh7Z%5X%P@om-u+ya7a6`@$07L1{qlQS4;UtC+4DPeGQL># zXZIiB_YA&M=W}KxJ6w30&ZNP^7&(1D_X+0%t4^P1UE}N!x=t;x*M`OC)YUUAKF$qB z_Tmm|4T~rLUEEM$su0H;v5rkfG(4>#QZwP%{IzC_9&HmW-2Uj%R?*G;iyxIpeC&Vx zs91)ZYto~j2_9aX9tBPE?7H+Qa7x#yPmcnoyE-u=7ZogOb*1P{X zWlUE8cK`JM`a1JBV$gTe~DWM4fZEH8w diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_back_icon_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_back_icon_outline.png deleted file mode 100755 index a9ee088896fda59792127c27a7c3ec7e4ed52c5b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 882 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDrIOlcp4mC^_V{W^WR;)=So@s1eO9T>&buIKABFCUKz1pr-@Ob0So#k&5-N%Uzu}4 z?!}Cfa0c6UmuUvC{TQnJrZzb;FwW^(8MUZrEkkkhFW$sFrVDna-PgY|JbU}xef_kT z>mAhoMjEd!|B#WzI4xUv>B@h(N56gzVmFABOU-ub*;`Y-hOxqO{`Reb=l1y}En}YZ zt>4>wL9OLQCsvEE-nYeGsdp$aC-lGH6D{q^->$05dZ6@j{aaC4#s>up7dHPpJ&TFI zAcownh`35J9YnGvD+8?HsWSF6CwZS3cv90-w%}D%`I5+s-5vzwY$jo1#PS3*$lof`^&1Z zy;L~Vo@c{xB~GRV+kUT=FU)>_J(O|Am(+%bw;4X&W_bLWfp7Bd3n8L`!<)GfgdF`*FXjMPY%{1pH;pN$V@vJTmWyg0t z|9oUy;+gX3MHBLz^&1uztXTBLb-8#`K-L1+l~aAZ-T%(CRy~!P{#$d$<_423?WZ13 zRpPwvxnF7DlsLgCmwh70+J z*PUuS`uv33D%M)*H;YpGC!YO%WMN`P=rgwCWov)kK34Yn_><*M4>E-xS>~;B;}G6s zT~rZUC|Pul}e>S|B^ z*wZ`5qduEWZSMXn!MXljJ zeAAfD%%gjMzhDoiaYGB!vM|T?tt%D?DZFcp*uA0s_+LR6)(cyC_N0WcFfzP8@_ZVD ziq;i|2Em35>FQb4j?)=@*rsh`RWRJ$#<992ufpJ>UikIU-R=9lE9{uxyi$G;mn<+* z+r{L;oSW}@bk@YryS{sV#To1D1HaNv1iP@yF__M_{8q&(Hn-^Rk;JR#f4*P-_=WEI z+zhb>d(#IDGn(H#;ZQQ!u78bx_4J1a7!I{>sAtG2`d4cpXC?Gmhp*G_d75qgiHp0; zr?F0$w^eRY$^6^rv`oLVCiFU_F}j`SzS--^aiq2{%<1k&XvT_ zGCL$^es=twf9LcWnB1P(?)|#T^1#~}sf;%+$tjm|8A z3|{BIo)T}=VtJ8t{KGdMMvm(qGu4|cWu8s3UbII3!aDUYt^uz)lGa(gW8V^a|9TSt;FLJGm9H({u)o;aV5hQduSIhhi;8>I&c2eu+J7JS2;DsS zH|B2cJ`077*LQz-6{5T%IWOqj&!~r zk$1_|lzHASy9t*frJE9cm+qcubhpI&Skwvow96ZvRI^0NgD6re{`Eo6NRJ-xl z8OzA8g?nzPLYsDUuUC00004XF*Lt006O% z3;baP0000pP)t-se6|36wg7y#0DZUsez*X8w*Y*&0DH9neYOC6w*Y*%0DZOqce?<6 zw*Y;&00000eYXJQXy*X{000nlQchFPkFPIJUvD4pf4@Hu-=6?3wDXMs00S~fL_t(| z+U=X$a@-&gM3F#3T+RP~ZJesu-p$I;4TDpuVqR^DBc{1duX_8(p%%abSO5!P0W5$8 z@Sg(2aJuY2BfB%i9|O=XU*#sQ?DTwqkW;Bd%3&seb=B7YFgQC2!0rW%-A(|&o97Tf zfVf?SiffCK045bWk`V)lxpYO2G5}VU&QMkY;3xxej03nb6mAqCm%+$m0K@G!3Dms& zV>(dN+iwum089}I(+8k0DS%7&07S9&-w9#|fG>jKc>q^L!?6Im2!|#Clr(_i09+9d z#{fuEnSk&;@E>2%H}eoC5>l4gehjpR$hV zK)?VDpHfsb1_D6e_HYgk{W4VD8Ay-Mr0yKN0t8lqj0^(+;8+PVGIRid?@A{q91j5W zbtam)Hh|_NXe}p0-2^x|32qxF!=nk{I0;@CCqvf+*eMAfU*CkuaBl*5N`m{>D?{Hf zcmYV11RodKM9-hM0F(sXV_MIyao7hCA3r)#G86&G&kMI>L9Rd*6HlmK}7U`B=l0QoiL0hgh`OjxOj1c8~*({mX$kG26aD#6=0(fkc8$JIOd zrVQK5v0*=f>p(NSC2N%BS~Aq5o6^*}eov#D(#9}2TL9EAU7K#2a`Z7#CP(_ zqervq;WT`|bww6s=x|#5gjBo40rrO2@!SB%k-I+vM-=RWqLPDH`;Yb!1-zp9J_ose zd^^zRyRYd&eX8&+8KT#8Z&aloj~%`#)pGzeep&5jwC{=A@+r5~mMK_l^6hU_#Uv)_ z_EY3)Mn)RIuw}dLM-os0kWE60eC1S2mTq-V@!HH6K%;ywRy=gQ1_q(xRqnTN1u0W5$8umBdo0$2dQ0N+{9u5x+{LI3~&07*qoM6N<$ Eg4fEZApigX diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_color_a_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_color_a_outline.png deleted file mode 100755 index 9699da0f5f9c258ba96df29944d9edec328a9a15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1269 zcmVC00004XF*Lt006O% z3;baP0000pP)t-se7FF7wg7y#0Diate7697w*Y;(0DZOqd$$06wg7j#0DZRrd$j<3 zw*Y;%00000eYXI}RPD3?000nlQchEE&#zB^-!G3}KM$WDzwZE9ct};ei49_7NIRV|&kihL3kjjn{Er*m05`x5a0A=`H^2?> zp8{Az(uK7-Nyh#)fQNMPnP}0;$M*q@o1RRYocShzm+a_o2I;>9;KwJ}M*jr>eE2#9 z5WuQ~?xyia_-i9M9yTW`+Y>;1bU&!1(u^8D5Jfit>-PN;Gc`+lQfK=B_}#?HR%Ujz zgYN;Lw+b#z_R~Q&(%#w zNCyOsf5FTBcuk0a0}zWfU3B$!v0rfwV41r?@>;-_(vj4|b z4D|rZ0n{}94?*cbqQVcw+}K26A~}G!d{lef{q_t)9f0J2Rn-P|6Ns~b76Uk$LdFt^ za{+qp(lqB3k^sJBJC*Hi(H;hHT`cfqQVs?1m?vMp%S$eyc|qa9FfWA-xSR+Vxqt6>R3deON(I0Kkhmll>FBt!f9 z&Hw-sT-1--Y6Le-0syZssX~I#j!|Y_=Yc>Q2%i7}w1YB0?!$}(rwNr|W@40H+JH)s z2$f+-Zxy9As89)7i^@=?Rd@mzAr0#7;^#mazLsh%0)&jcrX?u0sI#J3W^5hW=$qGTKFFjZJ)}XWyma7tgY7Wa~I&>-> z`=!%xgfAxS)$HJi&m-hySaCIDFQ*}&zvP{A#pU#U6oq0en2shhG7J^hbCK_7y^iE% zmX@KfNl-8;n)N!uY;#hERVkX~Ys+0nm|R#wJ~lrms>x|B5;UB@OUXyprRf%n>XIqm z&1i%(r4KMui8e0tN}RcyKI0qifFN$oCfj3tJ8?Totj}>BHz4Dsh^OxGVxale%(-3B zRr1ST=R#(&tpPdzDy(XZ;hl*&ns<3%q zu9i9Yf;1f4~rY%2Dkxk ffE(ZjxB-3vC00004XF*Lt006O% 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 R8Zad?c)I$ztaD0e0stH4WxW6Z diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_menu_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_menu_outline.png deleted file mode 100755 index 1848da3400b7f7ba2ee9c5ceb90721bd37a205b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1086 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD#8t!5Ke3p%O0i#I++XV(G2j(CC zjB{9@o;|&X>GeOU150Wml*IPTTF1U2POrl=e?7|uF4@}DfL}@sclBLfe`RI(^6<1k z2SeVE57syBW&Wow_|<=wvw1bA0^j=gZ7Y`FTxhM(mBn!G{eA8eS7hFJ?p@8ipj`O0 z`^>u+%XYCH_)+)Jp-9(uPrczM#w)ispFDYOud-VqV;r|uchH>LpH0she|&mbuh;aP zA?LUJv7I-LIecPP=&gS=rFzaR6($~soQox&R+LBo=_+Qp&2z5rzuJK?1KtPMJ~q$u zpMCG%Lq7(IbN}ksEz)GaAtaFfhuu+6(eYV(QrfH&J}RsmcAY=4e)sjs3$>X8eys0W z<802jf#U(ofp85KCB_ZP33FE8=#b?KWSHAM*C|>#YBFQZRE|Gfn*OX8K0fz8_31~C ziJ-$#`E-UCB??CvoN5kAFuWFGk!tX$U|>)1dKT)j^FcY^BZf6MPfTWTQexl0aO{d) z4fBor;X8IQy!-xs8pDDh&koiNYRaw*KWaBsedZGS+;qfc`7MUvq|An23b%!Xn|JJ< z^@~AGE=J({!G(4Q1il~AZ}=xAe`n8z^Gq{b_G~(P?E5!vhd6G9%?vLU9;hz5GF#e# zIfH?dAy(c&m@)5s%Xa1;+5F1?-^(9=bgRilz0rF z&imHv`E0%Y##{cnHvTo790rP<0!lNq6C5^ko>6HMj`w2D{4=M+^`onfq$2-?PZE(* z+blOM@tV0wUP0Oa#FVp7Sv03FIlaj4Tkb^BB{9~9XD&-_o*DUmO8XQUw_j{`IG;H6 z|7v9K0bf{`}qu$E=@ejz`*#<)5S5Q;?~<+ zw>LEz@URAmyx6<(|NpJWPcKfGtY+J1Y`vZ-GAP>XQPJ-r9iUNY;J|r?q#LJ{qtxfI z=PJ%(N?NAIcj_XCK(RCr-$L#ivAKr>@2+D_as8#PxSC<3N$C^|K8~%DF1(8yuJkDN zGHy!~yUn3se)vR@L)Nm1OBf#idEO-4u>M-ZE7gX(HveNca5H$%7T}hjaBGT3OhXDo z$CTy`FTxad-SyZmr@Knpq5rXGDK{U3-?Vt80v!j@XW|@M>;gxAvNj!Hm|*k&fv0+d zRf(pQY_oZT{2wi!#`jF09;{8_DiS|1yT!fx)eV;#<|#WHF0sb2`ilp?3Xj>{U?zS+ zq~ZJd{&Npwfx&LaaBXKp=thB$%VM6dSQOFa%i#CXz=$#B=;IqK4@9MfxE-$MoYY}h za&w9o*M?cXFJDSXFhtz`blK2=L4aG}Iiu41r&FFBHM{rQ*6@B`axjNF$4Ib^5 zyIBtl@21S1STA~|;}1{6^eHc!YX2!{hgiM-;}^9`_4XeX{jkgXpZpJ5XTC;k+5Mb| zn*FK8@%%co!s4!Pn|)n7(M)e|me~aJjJt7doF#_Yar}S8x*|XS3O&dda-(>W7?<_u gLqFI-2?q^)U{Bb1PQjlkn-e7A>FVdQ&MBb@02xXhaR2}S diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_share_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_share_outline.png deleted file mode 100755 index 9f9141784512d27c516c97e0583215ccaaa11610..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 880 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD4q>EaktaqI1@ zo0A?Z@VI_#y1DWH|Lo<~X+r5uXHr+rdZ+c$^oG^62@XL={>^taU|`0A4y;<;zF}&@|E8HdP5Nm(` zWbWTdlRWodXL+gT?{<|-AWmO!nc2RrHMX+17^a20%$z2Y9Jg`z76zN<^E0pMq|b}a zSk2Dy!ffAE0q-~g)xX*CtIUHN8yE~aYpzu-n15B*)+_Cf&5>(C>*1+)uCP6u$cG>f(C%*7M}s%~uPJ9IvcpnU@im zvBhyBPurE_6MoFjOWc=lB%jgPb)xLMZ6!l7kIIkeBQs{1#h!9Ip5uOVzLx0gTe~DWM4f D<^PWA diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_start.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_start.png deleted file mode 100755 index 907a954a2a1b8f7635694a65b4b5bc433e1437c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 879 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD4!>EaktaqI1@ z>(g2kc-q#AXwLp#e`~9blCqcJoXyWw%##m3lvX_HKjYc+)7KOjn6RJ&`xz&ch;%z1 zxaOuj)s<02eL_ylX(xtB&dZJ}urOX(qImnul6g!Y*zd0A_#@aL^5I6y8TJKITr-yK zWMCHAq{yGpl4SObp+F!)4YR}(bWCufFEhTAG8_hgw0CPxZ3+`Sqi&YLhPQjj6zX}ebm04xQ?F>)N^C~Zcw}{H8CwGhFd5Tqf z!zOH4uC_gOl0u>C%#*Lpt_cjCz&!tc+KqFC)k|FJ@x2nk>ISA)2@6! z;IMJlo=g8@ckWBrTJU4OwAlUon`SgTe~DWM4f DqUDO< diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_start_icon.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_start_icon.png deleted file mode 100755 index ac6c97fa6ec6f63e8d5a991d889f890343f92acb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 666 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDpom-{{GUA7=s@h6eWVDm-0V%j)}R zN5<)sA`?z26!uK&VOTlA$f89vfq(9mi-+sP0{oWPb37GMFtw7^eaX0Uvg4OEtRY5S ztC(|A#AdSvoG|E;2{>u8N+e;<|E&*L70R+5`h^bU&HJx-gSElh^2l8N6;Fhwa(}2` z(3d~-D7x|4*Nltz8p=X`)Yw;Z&tqVEeY`GgYQ}nz2!?yoAF6k{T(E5LF>3IbAlabE z>cP^?_#&yn;llF+#f#nU07(cM4a&c%eL<=lySjJqyn#&b2g<)C3OePQR1TGoI zJ+Uq73URz1;mq5*8LvK5joHc&_wVXjfzZ`XOLUH2T_f_^&h6^EWaLa=jPgpe0q;pUNu;B@Wr=fYCW@Weu(<0XR`U>*K&62 p{$p{%dwcg7*Gv3gfEHmOt{vk{?sG-@%sxLrJWp3Ymvv4FO#uCLB`4x;TbZ+DxmERt*P6u7Cz6kp&EJ z=7;+Xd*(j!f8%sVZq}OjTpzq|tyb3ECwxG^I$425P{8NjY~|Snq8nl|{<%fyG1lDC zcmML1;rFlS?wiltS1f&b-gtGV{{A!DL^sIkD~9jhx7V_6?>5F|$*w!6i9GJxYQ2?V zUbp_PYdX*81!t^eU-QDGPC9#%&$CzF>uqXa+@U1@r_9hvvh7V4!@?cKH@4+9 z1u!o7{>tlMz%(Pi4Ys%Ke-*bfK4sr_ec|FWD=s@K*fGznJ3g=Vyt{fs!FN8VbNkN< zGgWJDignNl_h4Y2@I1bZZCh~XY(~!t)h(B03h#>>F===oSjgb>U}vRok7=F(gRsEm z^+$RRL@_q7Op|0z`lWS+L4nbDQ^`KwV{5q@I3E2x_&~m4VxPfs1_y(=`Yu-Ox1t(! zHaw5@e$JV;j;*8V=eH{wOz-k#kAlKB zy~8ZJhaa973Vb!SLn^rN)wHhLX39HjD~yYDZyR3tH|^lt9X1iA3XZqh`Bk2JOw(fU z`28mPrt`{n#(>{XlbJ22OfxjlV946PhVezjzaK`zyAsx9?Jsd?Vvwu){FlwRS$@JF zdDk~JFTWT5iMh@B$LibD>|5Wn{ujDFy!gA3)hgk^#Cti{zo_Ll3G?igmB`XtS>%53 z@v-w)V=kZVPmX(W_5YnY)AY2~b2;rSNEVj%{7|@e%fBNvu1VM5E~~VZdn#9FG|{GV z-}}|Kk0nYr_N%Q)nE1N-;C_+aQru$uz0DR!KI{(OK5L`<$!A+16)ep7de1jPEW1BD zb@$uwr^^+eWC;iCnme15Q)7F>(bsJ|1$K+Be{Xqo8Ao_wv5%Oq>@Rr{^n?gXlMlGx Y$n5?2#XhG7nD7}qUHx3vIVCg!06}S{n*aa+ diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_start_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_start_outline.png deleted file mode 100755 index ae48df9cd77928037e4095776aa4cca2da6b1003..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1077 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD)x!e}4Xc{`U-LRIK(hFfdQ_ba4!+xb=3{ z%}t9H1RM&J+5i4uFUxys(E?o~Q`6A9`sQjXaxbU8^L|#Z!@?-QfCc@NX0YR{n*7wU zuDflbIqL&2z4fkI_c$6>FAY-Q=x1oVwKPh{G@kKF`|bY%SJWBSO+UY=()NIa{k~;c zQBO0yx0~8ty=eY6g8M?dyyBa+^~F;A?p|l)Ik-5&w;yTX2Rr!;^WxR2{eP zHj-J8U-^Hk!<`w7I|C2gSoPwDuGVUnpgxB!u{Rf=`q&|2z92iY;^iZ;6ozjKc3W8< z9Q8fRu!DC(Znpn)aUGU~k_~Hg8T4C?_npIVpv)|jC<-Z)bv+V#xlh)3Fw zZ<@XCdzW!eyFTcG&dJ?t_KB!QNAZciDifV6n!>YTXZ0?lPcC0M941fLXxr(hlm6S} zg_qyt66@FZ=4Vtq+h9~B_KRbIaQ5DJQ9o|R1|Qp7yk~x0-ABE@914>(w(Nao&hYSk z-O+A_;3UCRhUGgY)NSwDFiZ(8_?4<~V8fAne_1A6JK)Q(XLTeWn?;bHa6>>ynHqb6 zM~Uij8GDvk@P7(Q3b&umb!!jB61t&FpkKgSr7gt_* zmMJ2^%8y&9@MGS?sfBxl+}A3k&7XB?>Uyq8wr5XoaTop6xXHcEK5pm!<5zE8dwE`4 z-=163%U-^Fb33m`;I7vV+gK$Yr2Y)d`nZ1sqY%r1>EF~RFZ>a(q@pXc+~V6l-Nxm~ zby|$GwrW^xaWvG)J%9YuvgjG_XP5Y%b+~KGzw>gWbkpL;$M0=k9e_vjoKmL9F{`m~)GBe#77?@Uhx;TbZ+0&N9@YdSPR6FhKmYu1uiJ8~dx~sd@7+A@Kf9&uR8`apq~^_!Nn{jDfTDdG3uGFr zeuvII6lljYVP*KfmA5p1Gxybf>bCm*kkjK!(U;nFxXPK=0!{#&l^xKQD?El?=w)uhG zKZg%o0ykGI;+wEqbU_;Lf*HO4^)$E~k`HGUF>K77C^F&Zj!gm|Hn7>GFeH6e;ymz) zQ^JPx;A++dz7NGFi1@M^s7qWB%@q|W5htheR-~V%8!!z!T|Mm-* zYfG3eTz&h){~$}koxY1_9aR-pO;2%aSQ9rzit)?oB58(AI_mxmpAw5Im?rpdPXEg6 zAiw;9*i5d5+IjBw4BH$V?=tf;+*x$+U1OT(we)Q-br@cl-7Wsk_~wIO<~PQan$1!b zvi$z7`fqMLlWREB zv033-^?{R%)I7|)o6fSENT)u`O5C!$<)HCTjzbf6+uWJA?qB~>ueaes|L5r4o3Ffe pf0|3S$mUneuhmEB9AJVWY38Df+qv}TMFCSZgQu&X%Q~loCII76e~AD9 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_view_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_view_outline.png deleted file mode 100755 index 33e28fbe638eec9f97e3d50959b7c3a67cdfd14f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1168 zcmb7E`#0Nn82)^H;}$~5=(LKWGWSc12qn>!q;bESTg;ugE4Buy9kiQ!doVgtp{zBj zyVfPlp{}P5+ZfxzsrH1r#8@yI%g)(9u=kwzdEV!o=lSt@?|Ha6qmbv3000z)O!5*l zsCIie2W; z3(M0qb>JxOGC^y?-y8toEeeU~lfArM_r8{;C11#D)WY6A^B=h4o%lmRIr_!^mc1ao zYFcg`L6=)GwdRA14R8iS;WdOGaX_vb^Z6Ra@y|qTs108j)L78E_zGUa@ye~7Pbh%J zG^Z-+be?g7}N$lCYumw89*ConGB!&Y=31dZA; zcrO*BpC-LKDrAF{$`73O#y7A!M7}CIN7UCMt`)^&tbA+83N&jbB{XaI+wfH=_SC=4 zs-X_lNR!o9=Tt1}K>#wKS1)n2(ouP&i5@dL;~}4U7<*mnuIyOI%>sef!8lpc?0eB$ zufc~uqm&3OFTMdTe<*o@OFcAgMvklmig5XQwLEQZ!grDz8iBSVtI^7wDiG*OCu2lF z+HHXXZI`*92%Qd++K%Tg^u;Ju9Gfr)O^G_>P~Q-y*j!g!$nuRE^0+ZRS$5VS?( z_Xd!32jR@;2{YBqV^#K}IrF5ML(96HYoqCOhX`BbQuy$ZfEurqL!ScbKcPb9Rq2-L zasEBo-6!UJ1}7KFWohU3*wmYJvBLrx5tvQQq`YB^2eWElCtxpW*=guBFYc}5{8vWt z)l7$uWp`d=)e#y^3zU@6Fj6sV;3BM)iQ||Z(s_>VXWTR;Dsd9-#yM3LUX-%)Zgv@m zC$%vaDiJosY0HHu&+43kIaE_H)_k_lTveybMqg3iytoN#*DNw?>}{ARH;`=t*tn&# zG^??O(2x2^duoC6$Di)@%WlP47Z*$o31ba6PD>N)JPmC!Eh2}0@hr-c-_WEkeXbLg zUEg1;zb!OdWhssn|4L6C2%KLQCf_>w(R6il{lnAwopJ4l3s2hP`DbH||FxAM%?Ej4 Xa(mF)hm?yG{{%p};YMnAq~-quO_>)B diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_x.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_x.png deleted file mode 100755 index b04ef414e98dbc15274a7525c3242778643c1e39..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1037 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD0p`xmLntP|TKVCd=^Q zX(fk(LxARHos|ll3$CBp7TV3&GGnz4nv-v5_eXHXhHU!j8OLga}O*#`e0hLhJBST=sG7i!^Zu;spWj(tPq6&>*d>4~3x z8hoV^9y8w1$(zQkAj#;W;&mR7>yO zb=oiVZ*$!ig#(wfa~1}y*vI8pq2wXa;I{3eXm{O{t4}+hH?_;p`(IGOcb-@M9^Vv; ze*b504~ix@UcLHoi`8PQJVpkcRk{2A=5nv-+r)5Svw-KbD8s(Udn(Heb}|K|e*LuV zdwgrzA|{5FF5j+qT$Pc1?#Xc6>*c#Q(@O*kRTvap`}POET0BEqL4OZ}!div!`U$f< z8C-hr%bP4{KI6yaaCbpTa0IW)<1itHD?7v+#T{gISqlD6dEw~A^vma}=oKJf>$x-Qgpd8q9PHaqXtHIY-1A^dLeVn%`bu@TprS?qQIZekuFa42q_IPpcjsvTA*R!{0 z=LOijYt&->GyltiThS)xB~7OV-FsgmV4Tmcb*p4P_t{ow{)yce&F-?lUM9C)^4*kQ zb_{a)Vef6P-Iu)kN$+mf;hh@-KE2ky*6SIu^m;t=`*R-N%o*>t&aFDlX2WnI?~v3F z^W|}$UR}E$6ZiTbpF_<3?pV27w;0(Lo?abce@JlmdVk)HH}>6^mw22hr@YW@<_Sl& z>*cj6Q&uY~uVq>sGlxy&uK(IO>OHo1=9^yFafQbu>GReP9_qXQboQ0hT~oUkpl$Q$ zjKu5Mgq6FDm(7lBKFj^2chAGDXBp))I%eEadp5zke#hM%5fijDtq#}4+^yJuz0AJy r$iC>etL04p9Zmw}5(5Tsma%8h6FaAHeZ&5<3_#%N>gTe~DWM4f%?;yp diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_x_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_x_outline.png deleted file mode 100755 index e5dcc05019c60eea0bf3da2aa1ce16deec8a6a5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1336 zcmb7^|3A|S9LGPKO=HGzOevNxbs?4=^QCXoFn7Mpx6MW7YeGlK6@B5uxsBtDbc~hM z)S;q$$?|m)VVOwQ$wZ@W9rCSwne2A`2lskB-tXu8{dm0JkJoRnEQ+TqN_Cqm001aA zGRa3l@V`PRDf;q6{+a@?D53`u03NWA8=-K;j;FZ$Im`c#6i z2XNtXKdOyxXn~sgbt+3pU}N$hG#f~7*E}>p>FY_p^Il8&A zziDt%;Af&+M$q!P8b*@wQz#-+;}w|cm?b7W!NAn{i*wx)VoY19SwFLHG+F-sG4PzN zz35iB+|X##8?@8(=;1BU)ms^F%T3m@$HluPEXs^=TCz$Ahh{zU<=Mj)Gf@2msPmwF z*kxSW=Y{D#gq3RJ_IcaV1Oh5u_q}bfWsIO59jU)vZ=dirbxL`iF+?kYp({|OU1U{U zqZ1Jt5M!lr+!cwd+wlW5M-6n(DF^UiKCFy%O4pc>wrAy{CRW=xs=rAoB5bOw<{HF$ zU#%8<9PpinE-Ux+=M@}Tf#bGp#a*s21FD+q%#A*Jt7-FVNs$A&J+&FJGJv zhJ;8K^pab0F?>m+V}R@Wn)Zb@Q6R@O<);7LJklxa``u9GVGe?-u4lP_QvL2irID^U z_=(dui-=Og@FIq@lEI@QLd4D&!_*$qdVbi2NpIRo1P1_=h{1O}dl z{~`+deO`aETklM==T!)_d?jqnrCQyvbj{a8*EU5kvF!Tt@0`Oy4uvzKTP!6Q9=uEC zP;i*nr?n~h)GUU&wMo%?r5yCOp7grL$Pt~pMK^=t&DO);>KInVy6t6HS{C(^`Ng;M zLKeF}AAi?q{QbN@4C9YgDf@U8WTP0|vlvfg{i_#x!qyNWs(XrY#VRRJ#-Pw`Dl9j) zFq%m+gqA9EHawF)u!r-U7V84fhnfy6)Wi}Rl9&Sm8V(%2`sa)6O(XHW%p49mbHAUt zxW1u2)?SFC;A?u$PuE3N{uV#G9von>DeC!sdP#gr<;|1p9=vS+Zf{}hUdB834NF|% zy!krT&Gn38TXtEMt~QQ3uFJr%(DUu%eYujpi&+?4cI^5!+3;N8yiOKD27xD2?%$hd zs5t!vD?@bWoc;O#Kjfuu>+#TGxX+#QegEF>Q#0nWDntg&RErc+7E8!G_a;DSZY0AB zsr$2~cb;HMxV&KR)`>BZ4cD}!*FN6LAa~n#$HXg46&gh!jiPuT=qaS`>A%Qq!LH&c zzm(&HAfHO;7Bz>rKhJz;?bycf@i((bbi<;Febo#XsVJ8B3qC%oOco%sNp$o9ir3^ErVq${v$XeICBV<_<{ z7tVcEIQyYz<|amu9~-Qqe)KnG?mW50%0orT@tfM^3G9}_1^vwI_vg(T-dRNL&~sLSLdb3a{Exv=h1nS zI-?RhH#=P0ePC&p$covnhn}(S>NP6d8gOf_>}S6`KaHa4-M0_N#ZA$2zuEkwF!$5H tCz|)}c1WCDzRbP%{|0D6W;6h%YJOI=i&-ms-_|k!fv2mV%Q~loCIC5QwLbs= diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_button_y_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_button_y_outline.png deleted file mode 100755 index affbcfa3c02005b8a81b942da35ce8c53511b828..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1253 zcmb7EYf#d86#el~NioO`dT5BY>8g=YQtFn0De2@ZGb*!EoU+oCDcyo>`im*Bw)vna zQy0cI&2$zjMSK+GBS{T>lp(2TCFW}?Q%iGn*>C%G@65U9o|${*&i!%(VIh00E!SEC z0IUN8{K8EP{R4P{FnYunM|g2TorfLG+~p& zL-r?yW=joMj%)3ADc!}GPhgw9^F?-=q@Wq_t}@|~PTw8tM^atd z%@#G4Rg-Re1EF$38@!nUTIT9bza$9P_rh|F?uubTwUYoKqIuhK$7W_fA7_}eDkRB^ zLh$yPC)7W3;T(xDbup6!Iw&JcHCf*FgILLfIeVlFjoY-UH`416(}4{2r)JJ8E-BX- zcYw4jZt?9O9e@!=S}vf|aLmdW>nX<%l7XET#q><%Al6#vxT1PhT@oMfX$f(5K7U6WOWBn##$;R`nF;j= z*!w8<;jYt3S+{C5_g622tHy;nzkl74QoXqFP-o#d&Q+7$%K|0~Nlhzf(mhn%LtST; zEhF~NJIX3vWzcj{6o*zlNmJdnfxSLB5aeFJnkq)<_%)xg@&;QKUq4s`c`qeq#M)5z zEKpo6Y{^y!h8{nGu`H>4;!U0sT(lt$BQMa3pG$|FZ^3mKxG%gHKj;{6YU&#a$|C04 zsC&RuVs675BPg}&nBvU>x3%0?F!e0RLs7#UY`_DXMK8!9if;M@Kan@CKtr5$kFPa( zT2XoXg#iIZJ;IY2AU_#@M3l(j4L5^nS=80trIAo>;cZFTVKS9|Cex|38LW9Ry2-zm z1Ze`aYvz245$Zy?S!A&bQtjGlCzelwgw)`igWax3Ec-<5dc)apmsy5Kp4+`PdKFx+ za+N~33kM43a14%lf>v>stQ^(};P{=DUdZ&Psj$M@XSV6f3@>$6ZIShrU$-iBZ{1b9 z6=T)gTFTo_HLHCv31?^dr(LU?~xfay(mOoa-U6sR!u5; z(w zk)dx>DJ$v6NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh0azWsXn`ThGD4zHCC0g7Jsba4#HxcBy^Azzb$L|bB7 z--9=*DJ5Unidga()D_ko6ENq9^7Vh2wqU<%%|(j`zu(qg+nzc3bh|fDa~S`e?OW^D z-jh#Fc&z?9bivnyUVz0NsoW_CeJ-mWV8Ps)7t4_ zQVN$F4l=Z|Suih=x*&O=8>}qC{6fZadA1cM5AqsfnDg$R-#sn;`?IzpD;@j#yM_KN zAfqNUsLguZ>L7adqmx4avmfF9j}zC-{jNS%G}T0G|-o z=Z0?2joe-s02!{&jNG0XIz2Z6F@Rj45}?#P`LIwRi>oBaFZh0azWsXn`ThGD4zHCC z0g7Jsba4#HxcBy^Azzb$L|bB7--9=*DJ5Unidga()D_ko6ENq9^7Vh2wqU<%%|(j` zzu(qg+nzc3bh|fDa~S`e?OW^D-jh#Fc&z?9bivnyUVz0NsoW_CeJ-mWV8Ps)7t4_QVN$F4l=Z|Suih=x*&O=8>}qC{6fZadA1cM5Aqsf znDg$R-#sn;`?IzpD;@j#yM_KNAfqNUsLguZ>L7adqmx4avmfF9j}zC-{jrxWP!PsM&_GSk!QwwJ2PXqL0wqCy!Sd_v_s`G2U*B)PUVeW6 zeuna`_uYVUKRsO>Ln>~)y>*kf*+9TGaC7ow)pz@Eng~cJe`4u;mz&V{$6R@LQ86D- zEeL$5Zph`byw3krW|=R4#H++^sR^qBFEQ1aehzZj!WhUkBlmy~>y diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_down_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_down_outline.png deleted file mode 100755 index f31d0a5c8a0ffcde2b4c4e5888fb654d15bc5c5c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 400 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1F{QUL#{rl}1=9NxXWME)q@pN$v$+-9Sh9TD>1Ce7N z&)7DZZ*YI{?$MhC?i<84O3E(;29&IJ+wx>e*{006Pc{63OKR>1@64Q>ShS9&z%Hu-GY*X*Sr$MnbrzvI33Vx)M1fQy$~DuZMkpx73MR7Kn)BG ze{2m-Y+T&Xef-vvqBD$jYkZB4>s&Z~OQrY>vo0f-Yy#(mmkn(Uhh;v0PR_me|I(lR zEMRRloDrVy<&zxVhExkIxWZV@^u+3c)(LCtS>HY8&AfSn`>DzF`}=cM`*oIm+Y~MZ z)*`_lpkA!Qaw?^-)nVS5kL&Gv+c$2z)&JRX&0ZOA#+N`f22u|=*&h}u2^S|AfwXwK L`njxgN@xNAC~~Ys diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal.png deleted file mode 100755 index 09dba4e577b31c522b8e8e0bb4bd545bb24b77df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 435 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1!2l#}z z{s)8SMsCjy-GIy&1|X5=hG5dj^_dY!5D1<7wtE1_nk3PZ!6Kid%1QdGa+I2(Sh|lT6<5zW$m= zBy(dstWYpOk=uH|ME`2`OF3-neUblVka_1H1i3BNnc=7 zRoJ4o;5t*4a6lI0QcjL22PEAL2R<|UaTlm1TxALqHppiD{Q3Il&wqa?E%3L#xTTrR zU`xzNWYq<-58nQaj$h- Wd<%a5J@D}#NXXOG&t;ucLK6T=O|5zW diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_horizontal_outline.png deleted file mode 100755 index 5f7094ace2626fc293f06f49b32edd4fd2912008..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 377 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIzK_Wk=A_S;*k14Z9@x;Tbp+AP{xp#v^f#g2`WD>gGXuI0a?FyU7NYvU1iErE=_Gk3S;g~@cRVc`%^aCq>% z(XUUHWr_VYx6_3@8OLv}ufyXJPbP-X?Q!chik22QpRMg<84uJOph}+#+?1=pb%XAXYKMr q`)gu#ci$h|H8uI)MxezGGu6F_eYU2lEp!9!F7srr_TW@bg3pFbUum%c0mKJFJyWW&rK-PK2r6s$< z3+moq33+`q6sQ&i8tyZnnf%#K?bAKq&2|g=+rQpwQ03Cy#UOmNN9({ThSgjzQXH0Z zb*M8zl!M6w@(j1qjd>I!&*bG>t22b&(60-xXER{4bx5+#XDGeElC%9Y+lNc65rQ8s zu$Cw(ykJs7(#`OWU!sIDj-_W;gDcCHT@63~eg0D+KmFdZm%_exUogyh=*5Ply5T*e zUGjf1RvpXF>s1oY-PrHSIDLOv_x`J8SJ~DatKCuW&CJCPRlh^#1KWzErMbRm+rvRZ Mp00i_>zopr04OA?5&!@I diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_left_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_left_outline.png deleted file mode 100755 index c813a64ae1857efb32621658edd14fe7d44c0323..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 389 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIeH_Wk=A+z*}i0E+(fba4#HxcBykAzzb$$gz*- zWDY79FyD-w)7!~ja6$S6C&%i*!);d=R4#Av%!t4JWu+m@{`}V$?*GbPwTgc^&;WzV z2RpQ0Y)vv0XErt}o601Q52+wK|-*g zh;gZojz8NIqX!2YeAw>nn_oWfxxfNh{VAV~zopr09P=e AwEzGB diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_none.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_none.png deleted file mode 100755 index d36e045f234a572bb4bf39f88236b6f2a3a2d5f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 398 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1F{QUL%?fdf?rYK3NF)%PPdAc};WZZjuV=wO^1A(^0 z>ynMUCpcy@TwH#Vo|KEEt0A!)8v1+)+J!aGw{p5kNkGCXrebrpQo#z%Q~lo FCIEA}rhfnc diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_right.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_right.png deleted file mode 100755 index 0f874acfe7541cf90fcd3c6887da2c9c448a4791..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 427 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1!2l#}z zJ~wiEZs_*H!1cKyknQ@+$nBY-(|<4kiU7F~HW~@k4b%t}%;s`P26BW;g8YK(@8{dk zUoU^Zet$ohvA>_eYU2lEp!5$<7srr_TW@bg3N;(>umo<}AiZJZyZUP}3=@J=x>T;# zHhs6&3VnSv6sQ&i8tyaeJeFCUEArSzugW34y)?_gX!#OWmSXOp15cRMG#7kjTq}~m z$_P;nCLi!K?Ebxd&7R2n*S|00xo~smr@By1g_(R4*yRO$I8X448)P#2i(klMG#5{Z za$rN!dEh(qBC!j*86%>XraG=uRU|C;~ z!@&X{AW;^#q~Ub@?z?Yo)@B`eST^tQD)t%+sL}&Z8^oVHU7Gtct~LxL^YlsqFv& diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_right_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_right_outline.png deleted file mode 100755 index 0c60966d78e3c8c3e6582a1d84479de95c8844f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 391 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIeH_Wk=A+z*}i0E+(hba4#HxcBzPM&3gP0pOnvu72FTm_tCp zpd!Iq`%H4zNeMQ$W3z<0WO|IH1tNAk@G`CB&Jgg3S-{lz?8KhC)6RA=H%?_>WMbj? zW0UaohR1;~$D@80$uRBO7u@2$J?3J4zSE%5Kdt7Y2f@Y zpUGwa)R_4bPd|HX&wRt%Zlhqj-tWH*cIqigsrBj(8gJh0m+;!bsldR+e2ACf)5}s0 zr-mK#R&*8jDl^77m8zKYC_Fp%IQ;p1{XO6FauuFguqo)gKd<-x@TuP`RC^dKq)zYN z%9hvLc)YaX82{m0a#4X>LaZ8kKHa|>rG4f)!>rHB8@Dp@vMjjwU*3^7zu)c+!;wE; z4D%Y83oO_e=JF=kyED$&&8l!IzFwDM-bIFhLK%jyPaiOF^|36l_YgVIm&{lY&ZM)L zQB2n1;6;WjY7O65W-xY4X6O?6P$R;6AUWOA;X(J>EQ_5fe9V8DwwxARrSejj@r5|U zNvG4F+85X{?YJcWP4p@|M?xlnS-snZK;Sf~53@6$hZU7*Bz z!mg=x9=mKS*gu@P?_D)_#?1H!tPD>iBf2)+?@66FpEJ*QIQsFALH@r`os7cdtGBlv>R8o%vSd#$>y0BXZv;j@j#61=`m;{; zrt`wDt6wX62L5u7jJrGIWo+owr!L7sueIVPC9l1DZ$`ztppZ+m3a_u8bgd`fZ};`f zzn?yvl7IfZ-&UD(uiskg2k5-fJCfSqaB*VBswIrOR4#rA*`<`}bku6DX2y zBbHSkuys=Hn0o22+{&`)-B;(T&%9F9B&{=RHLpU3p3b$8469!qYq=hy%P{4T_g%@I z7a2mb*tOYEd89GcWGwSQL*&awb#R6 zehFPzu(Ic@o%gcqGgnW&_HM_=yXtzgFFjdjzP-J9myF%#h5KJ^y7{|2JkE7SRQGM) j{#pMM4MEWW%_{me`A2&8-b<1P=0yfiS3j3^P6nS2{ycsD^Yn%1 zMs7f}ORt^=IZB`;$S*kmeE)j;`~LCz^8D}X_s?f=RQT<|z`)$*>EaktaqI2u*OO)& z@U;3ewR9JneRnXZ`@j9pmd%;Fk4{om-gR4Y{(Zh*$Bsoz^1t%0d#R8ElLrH*0;5U; z%ZK?)COa=J`>nb-^xhxi2Cv!IDz8YzuH~OF`;elR&ptJWwHIH=^X!V_TyW27(YCK# z3|~IjuqZif&Xo0jyKEwh2KVu>z%E9Uw0}Q7mc0A@(z&+YuJ^OyvWbia zpDWf(W4KYd?7Ly*mk&I5blES=JAUh=_G`&i)0rgh|1SN^e5WS)tPw+gP5DRWm>8DE zr1k%Ug%$q4h_zu5n9tcDoWQVjGGjrijKftg#x0>d4$Sp`cQUNYRbsGGXMbR2*HAWz z;R8D-(~3Dd4XbA`%rtR0#(cp~iXn>S=v~H(3^K(Nx)~>y*cQTnui{A7;U_P+@fW-lZ!yWl7QVyN@ zjgMPibmX^8Vrkgd#BTC$@`D34yrTK6$4-k}NPV*Qpp1h1vj4d=nxyVcNIk*H@IcPu z4(G#qr91pzY8Om!c@q3reD(R#WOs%Gul7g$T_=5*A@oDD^kViI(=}FzZJ4}5Cq!A} zh3uY_Om`M+FJRliJJ+JHYHsjt1qbmHRY|P5&5{lq^&A9Q3SKkqV41)mbl^Mtk5Bp! zr!(&pozkooCv(5BiI>ibmW7pW|*U-I;uj_|*n z$V|(C84=Y={wLq2j#uPh>Fg_R;wl`r_lS{?sR&H!I3| zKL0qleS<)ck6n}C`u`DOT1`_|cWf^bWeQvO+}u#Pa;1&ks-A@9%0D@6XFq(u>toJO zJ-xPthW&;nm;P)`@m4qAw`S?*<2R2OZ=Ixjjwd?*Uf|(!o^s>UH|9utRm|HtEAwq$ zNSph4k;_lcnQy;t&Adcw?aixijvu}Gdv#Fx{CgeiE~koqUGas#8x#}JjI@W@DIsGW UvoLEXFt;*zy85}Sb4q9e04;bNga7~l diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_down.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_down.png deleted file mode 100755 index 3dddd32954019b7f1ba68dcf96d0c549afdb309f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1112 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1g2Ka=y z{wDOC{oLL6#9yq8-)seg=mMsY2Ce``tpoL3 z2EB7XKTNt;`uNLv2DazZXKXmS`{6%^#oH$uygpvdbf)jizxIP?%b8{<8u31}X83UT zw7?98;N310b>A-GOvn+LZJNa}*}rC=>EqPH{fje&JL)bm&X|2WfA{UgcV!{Q*$iJg zU%m}u-hJdiowdWG_M;Qzq?EE(r7+list?avx9C3GgfH?Fm(ERQWbC;9|MZ53FE7?w zGCYdsa1ah)Puit3>*WUVikD&p> z4^Q6vGXMG*r8-MyCB?KKQ*C(4 z7jN`UX@=@}hS{tg-&s4r(KMg`!-v@)9x}-F-rL6N77^29cYLm%!|}R4-fvks#@$QR z?DbV{b8e|Cv^m1*wZHZ0!52Hs9Yv-ocb?p}?@hg7?*C2eo_2ChoHe)a`JV5eKW@=A zRi1qLa`w6ZuQRiYyp}(2FTeHwcVcywAGe7A6RkP(MOeCeTs8+WtZs9e9Q?9x$N$AK z%e5!G06zlN5q2wfkmrFd)0+*yVp|e|g5~hjh zMAbHgg%~b0y)MMy^YCbvo163jkJDvq^&GU$#T|D))(Q*Yt#;}D`U%&SA5lIm8ykr;nB=Jf`#NU?A!yD}4ch|=~jg6^k zxEpcl!imcfH+1I){okcKcmI90$YAZ=Mvu0cOTOnYFOYhmm&v#5W4d;&Dy?oum zz2uvgNzkl RMSwY$!PC{xWt~$(69B6=5)l9Z diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_horizontal.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_horizontal.png deleted file mode 100755 index f19d5c08b7accf74d8fd58b2db67fbe55a00cce7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1118 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wDhq~@xH}1TS$atGt@@46|&odUi@{ax3J?-zO&oAr(|2%#E^Yn%1 zMs7f}XO?b11=J=`666ibm`DT|LT@*U`+<|;0yJ8RSoGbsjySo}VI-uykNWhg& zv%BtwRx7*f*-mWDJQx_Y`14!Ft(uJ*OIL1ZF`1R~ceW{W7<$$lK*Jc z3=6Gy*O+DOd92E>^6lBx#cuH6|L&Mt)->4zH|-RhZ#y(NY&`xyaKo#2w{{mWT(T2( z;MQm} z3ktjk*c=PP9Sj&EIlDe`R!p;I+cCL5?GGcbY|dGUgk}HNePp{J%V5q^z+5M;QOACu zfsyS&d1I13!ySA}{@>1+&QMmP+_vx=!-DJwSN`YJF@8|^qW-^hL5D-i zE;&8%*Baan_NSf}?eKF+SoA^PD}Uo5#y!*J{`YB`2b zkUqOj@}q8@2TezhzIgsajeAFBec$}slRU)?5~C--7oYT(&#S+vL0MVjugJ9X?2-o( z(^{XNIF>SRy?gwDk5=a&N((1tn|-y5-dq>8-E^_}^5?1tD!)FPa&O*!b}559*YnT6 zWbV4`!FX))0mh?;1kd^~rgbXMS{l4dj{VD@;$02NyMA{t9@~A!@UGq6XA6wJ+G(EM zZn^60U;fqB6YO=}*X4>l<||QM@GvGK!n%OAVVI+?5Qy)X-I(JN+b+(YL*d zrW`d6kstE?ichY-ZS&;Qu1U-xiPyhJaV>BbmgwO*(6YQhg0mrV@{vWh&fZFM&W5F4 z+;Q1MsQhkA;C1N-B2)gEzw>_kdr8sMhbv>=7Bww*H;P`k$Edh0T6ObIap&*XcQ5t2 zdAXnQ#&t!`-nyS#?`h7x^UZia`)~EF&(Gd`nB85U}*J>F> diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_left.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_left.png deleted file mode 100755 index a031aa25cb4145db6259810ad9b923f363b7fa05..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1125 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wDuN!wh)OEj($atGt@@46|SKhIoXDs^IJ?-zO&wrjie_JybUFsyxd|YU}VVW14K*RstN!hFq?HXSEX~@atmt=9<^I!aM+i~~u zpA0AdI5Es>U@BO^%%I1Az%Pnn@>xcS57*G-#G%_*N9aAZNn(ft{0i!vb!G zFphvs<`5$W{#y(;)FfW=o?vsxYZBbiaP^)cgOv3L#wx2DR_S+QuCz=3WI7A$5~a<~ujZlOPm z;d<}eNer*EY+o{!+|b;^7~$<$9oul#f2PWTU18mN2l#fgnKL}z#*i$;u#FWQe+Ry^ z|M)ch!)eBAGk&;=FF5)r>5Ao@%Q zvw-Siw`EldqS%Ggu{j%KDm!H>Us4g$^TK@cfwbX+7 z@BGidWIFn|lSx9WmC1FHOYt&>v|b_KRbJ&0F1Gtz=ISl>`CfPGMPbLO66?*r3omWZ zURpLgXzf$+)xzLo`fZz?I%v@QhX})d1=hzrYYKTFU!^}T_sUu zHEo_jkz`u?v-%uSaruPn&p4zie?@Iu5^S}9-ICAK^Cxvi-4sf1T(f=d&dcYSN;iAl zxWjhti&5d$fLpdECEqwhl|^=*KKiz{-L!rAg|Cm!KRx+-?dSR32A|)|J^L%uN!wh)OEj($atGt@@46|SKhIoXDs^r>GQ|#X@8zRe_lG0JRB}1o;K8KcC;PAMbC^e_#In{QCV2J`D4e85o#*JY5_^DsH`<{c_T6 z1)heD8xOKScvq{^_`UwL?X9#+Y$t`L?Y=GfMf$Kf%Zr|XDqpQR#SQN=ez$HuJv+Gym-@=QLTU7zwU$n){H({g08;S3>T(9 zR}^4mvsIsVrfLFSKEEKCNIruM$eZufa`^9t(4Ka>x z6$cwf!=|Ql3Hsax>@UA^IjoJGW7KfJ%xf)Ei|G300t!yE_A*I)XO#$NytI#DB`69% z{AJwtNA*t;1AA5eYz8@n{Sqd}*XkV5z9}PpA}fd4!eQab@5Lv@+}6$KDL7u>rGMvB zLJC8kpU!p}4j-orwxSQ-eJs)9_t6OxS=y3zzCCxLq}-gcY=-Z%n}cm^=Bvv*c$r#V z(WkC4i;>yl9+!$nSda;j<+&u|+NP3%3sr(GlRlTQW?Jp|RZuh0^RjN%e3zw4>0Y~Q zgSN`rM#@gA*(UQKtVimFTSI5?h6|qA(BZUFNf>e@-0QZPuIqLuA1PVktQt_lEHGf-{pHFA@p1l@Wvi^Hk&OaOO z+-7&*Rq19sKhM6s{2t>vuMeyzk8b+*@7#}qN~Ku~-Ug|Cl^28MB`pWm3g$^xXC81a S-)#@fvkacDelF{r5}E*Ly8?Xx diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_up.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_up.png deleted file mode 100755 index f18f7189571a297731bbb38eaec7de714a9b2099..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1128 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wDq(4)RMoSKL2_8{FQg?3%kJQ zMs7f}`TYztfZ7B~g8YKlpYPAtxA&Kizt8`^e*S(2vHH^g3=GWEJzX3_DsH`ot4=?X|&G!2`L)G)=GdA4Z{qG+`v$>$* zh*bZ-pTnqQTcyR$a4ntb0gFUKpAkdgbjB~6MGoAV&JgkA^?BBg&AJR6fqV*Vb`9N= z7(TFbGQC)=l`ahh*`8C zM&ZOXX67l%8YiMB>l|Qt`Yz#-Pdd`YN{#v;MxR=n$InPiPvu z?&dvwYc|Mu_&8;p)SG!r;FR!W#_TSkTLEI(>w|)fO)O1sek;k}rnbDI$}WA;)53f6 z<~?TZV!O+Fn?ppyNOhKjXM@?)jG|Dha%7do58f}#+NQJ%(}fT zJC!l2?7qNl7Q2RxISflm&#*hpI_~i~>NKOm#<{mdZFFN8);#OtlGww;p!Mj0s!SXc zgZM;^tf`Xxx)Te`fD3ttnM!NLD>K!i_b}w|f;IVq+1l75BzNV_qy?-Hj@p0a!!1E|73QjsW<)qgoV%Cv;X?L?@B%#e&1b# kuhvI|G;jq#v(^LYYZuQ-ySlwq17=?aPgg&ebxsLQ03f0h7XSbN diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_vertical.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_round_vertical.png deleted file mode 100755 index b1c36ae9a02d25405346b8d45e9a6de19ce54606..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1128 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wD>xhgGb=|*i-1)L}-P_cXSKhIoXDs^IJ?-zO&oAr(|2%#E^Yn%1 zMs7f}*{UMgfZ7B~g8YKdug~xI*O#}Cf6srve*S)j3X{oC85o$Sd%8G=RNQ(y`}L&R z20X325@(L8&9;`-tNXwG&X&!$%h_Xd~NP{|Axep#lnRZg&iyt7=#>{JQz3? z82>D1SbJ*g%lg)fJG1^vIjr(iZ)3Fhe0#U_gFI#q zmg@iWH!?ijw~LRR;gd0A0TYh{(73QR#wor!4a;~KGahcY7e7#z%ixgznDIsr|AJf} zh99jgj8n|?8gyqeD498&XTD%&#;}F6shX*Yan>}(gbUHO3JvVPHaGDFJUn`hhw%)f zx55wZ2YT)cCLQ@M!tsyEAwKy)HP*I}H36J$QSdB;KZQens^Lwty3#_V;$1A2Uyp)?#9)VAEq( z{OjNH(8->iNz-MQlh(Qi8|zkTe05LHB9A|<-@Pra;a0E% z%dzPP7VKe|b>}*puH!q_69Ey$%5{h0OygnGdS7B$+>Ol)ChuSbT}M;2-1vrJc%`Z4dZL6tn7fti91hN!u_ZQ#A)`orNATqUhLf_lMGG0Z zzTFYH&0@!3V~VnkfUk<2aCfzM z-n&2M-+ujOykj=0{Mhvy*Y8#I`sg*C=Ml@i{i8q@kJ1(BzVd*ruhEXWOq^`uX_w&h#wR=sA+x;;sf?{?60d zothKYHfdh^y0oa>w__gMbUkOX^Yhuap}7q~OASryuZX6-*I)W>zpY^H`c%o1)nE9# ek+M|{!6C>Y415pmU7B3Usx>{!wxhJ1b)~asHnD>ZeH}RQ2J|w-Tt-V%Twlj_!!7l}*sw^i0Y@>(C@#4%a9H#%{3XDZ{rUNPXNkOislQ3$Rhj*5@8wt8{k$-1-y}vP zYaSFh94+e$ayVMx10>461vS*NTI(nLKmYdhyT7+3K9pJKzq@~};TTL0ANw8#V}Yfy Ty+7Xdf{gKW^>bP0l+XkKBVDgY diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_up_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_up_outline.png deleted file mode 100755 index 0aa2b5779d035586666c72f8ae3fcdb38682cdb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 394 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIeE`ThGDw%p-Y1d9Iiba4#HxcBykAzzb&Kx<-F z-vhxNjWP!gG0*T=%j(`BED*K$2wzxdn*YkFk!5A-#)eYO?@PV@NOjjc71b4U0*y%H zmnrM5T^p%;^}r!Nvy8@bRja2sJY-%hbfV^fM&lLsD%FIOKxGUJ2kIFo%X}_Qo_nO| zO#T9g^w8BD0WTURGR)*NVDTt*@NalNz4~S1*1~_G;uGelfwesN?4bGguO`cuobx9S zTQg?K7AQNcW7cchvzg(n^;u)~83$U6Voo={4l7DAlur$p0&DuAx`5|-1jm+(GTe<; zvyv4jxEKFf8~(U%P4Bm-d&INVj|Vwe0TrHMt6<>gEDD|WXYyx|K2KLamvv4FO#nJ* BqXqx~ diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical.png deleted file mode 100755 index 123229ba99a2057dcf716bb79d75b4f649bdb580..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 422 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1!2l#}z z{s)8SMsCjy-GIy&1|X5=hG5dj^_dY!5D1<7wtEp!8=?7srr_TW@bg^EDgruwD$f$Enho@<016 z+byY;F^;Qe^8Wa9YtwE0bBsX6AW+A@;hWvDPm3gK_rGH7|Fp4PYJ=!vUv7!+n;NWV z%o3(CMM+=qVtncVRtzP6Fdw+oI_LGtr!u>qN-S8pX_j(BNUN%}L1^Y=PKhlI2U#q1 z56o=P;zQQ+ojv94pB$!1ViSB0EMnF99)Eu6?~esjZ~D&8UUfo0Oa1wR!gi5Y=}JYD@< J);T3K0RW!Utq}kK diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_dpad_vertical_outline.png deleted file mode 100755 index d919814ee83910670b75bcca1545cbf271c97f66..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 376 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 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_lb.png b/arcade/resources/assets/input_prompt/xbox/xbox_lb.png deleted file mode 100755 index b7b55df791a184830d799aabedd69c6855570d3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 589 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDgPP#Q1=b=_XT1 zIRpEKiG~a6{$#w?UViep;Q=q_hHG{?%%3bCT+Y117Q%R-J-SkO!vq$Eqo*14nJn^` z`5q_}{7}BX*0$o^bjITf4bL{eozY;ywZM^~mnn`v_GOtvy!B;vi{t|?#~AW?D_pW2 z40rRiTPsGEJN;6h8}MFWPk!}h&l4;%#~6;f9bppL#F}En>SO$pA+O4m`G8D7>Fe#w zQ`+?w*s7Q8Vle-Zry*qjp-gAeQd{|sSznctW7bV%9J1Cu}l1B(I!BZmW= z`A71=wi_GvD}^50|2`t!iZMANI9}sApTRZtrikNa4Ksy8eRLacs_){s%CJ@_bB9;M z?>|4YHl5bantt=O_U8h-!}*~Mzb!j`&nKCyAH2qJsQOJw`M$!P+*%Cx_t)RhFp7)w zQI>kJZhC%c&b~b&SABnHE%y55F8;=&;oFB(xjjp5IIo1(FKsNkZ!Oo!%yZ>$Jm(4F z1l8RAOcU4-ygtElLNcMxr{NA`^%JHib5d_G$emz#%06fQ{`-vTm)Rv2@*QA&aI=|} zMT7MrV{7borU$2Yhlwuu@~W9NW5ryp2J^EGk}=Ju4VU!VSbQER#4+zP-In>LRFg>2Ec7&i(toO68Boc`eL=gb53h6T)SX$RzHG76h3Nai^tM7Q0Yxxt!0 z|o^!|+f@7Fdn8?XM;>;#Wmq*$(Dv`9QG|J@-jADE&TJYD@<);T3K0RVl7 BLGJ(n diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_ls.png b/arcade/resources/assets/input_prompt/xbox/xbox_ls.png deleted file mode 100755 index cb6eb93afb0ea96d3939ff7f01499e1da5413736..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 971 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD8SHKh49x1DE{-7;x8B~4 z%sZmM!w@jZ;fCb>|66jGzLXM^F1Y3OWBbJqA_@`(M;}jj);YigL-wpJ+A*KxC(haM z(5#_Q(@8C|&iyC5NG zgJj7!Gau7SFnn`9We*h1G$BR|-NJ8c!y6G}M|UEN7JQ;df^|Gl#KvroxVC z3~v@qV9iM2>}6Atb2!8_BhKNUXh-!jhf1X<3=_Ep3K>{)76vrj5vcheo!X-L`;J6* zw)gK9Ele&}4|WL(3*KZ8Z(y}LkPvR%&0t~mz(suuALEA<2gQ%~0y<% zJARkrgk0CN?cqKQ6CQb4>|)r*|KVwL`IRE=^ZY+#CNJckaEx*5a^b^8K2^K+%B=o) z^+NbA2g4;P&yHuka84DNb#RHv_X0yz>s^yBRYlEX7U@5n8Xxs;1%t+tr;Cb{lTJ3& z#LbK4GmPHszJy7kJYMG8y|bC6)$`botUhq!-`kx^%;m^zD2w0^nL%Ue5#`Dv`5rZpR-QOCU39x(Y$^@+|y6vsaDF56*mN*maM2cbj0wF zCu{Gv+5_ zO^X$H8k)NrVs3q}zx8%raP(0Dp@nyEO8(p#{gAmx`DEj5d$|I}7zXhJtPrZ6OJJ^% z-JUk{Q%Bb`>Kt2cZ8XXJ^mE2J^WPttvG=!1gXxo3{%pziED6W0i+jE?GyHj)%Hh*s zky>Ql&wsF9H+Ld-zJkW?Foo#Co6Y!PDF4`44VqSo3}U z7hju_q?3#ruGv3)Di=4|)q(NADqr>3+L*86=NQ*mTkQTfyTR(9kweY8`ETBS+3;HC zBa1`E&i{Wznq?A99eiKgb7Zk`YRQ|qpAER_k=eNXhtJ!>@0X2vj2N8jRi&6KIUO<` zx|w7`7B1j)*vU})+Q{UoNB~3hW!Z-nSzA(>f9Oe71ZbXT-C*OdC$X=FB_NFPHvfTX zqKwQQd-)S=n~pFyoD%N+Xn)?kXCsF(QyRffQ)lFffo zmwA^LuoUb!cHulwP%|$r%;w*})yWJJXE-i0$W<#;pOKJcJMdnADucsg|1W>9tV)RK zXIQ}(z+m{0q5A!15w(VrhMxv}Ju+2I<&2Xh6qtdtcS zM44pn-B2{L;Y zI{T$p8) zn`|7mPGVu5wrC}zjo0bS;BB$9f=pho_rI>$mAH8N(w9E=49eb@*KS)IJ@4GTkLi(u z2`}fYTw=7|LA5+~{#V(lRcx=$Jku+ZNPp$V&Dy>4v1-MpA512t?Olz_{5OXfH_5r~ zmT&Yc`MNqnboQ;&)9f!z(YmU?qVwDZm5%I>+jPza*zOi%a{D+#!^i38%JX~w?~MD` zvWwMTw{txk)2Y=AuY0dvJKzu{{9x}VzbOUk*M4nP6T5uxNYKYu2R0|axwfJ7*38?d zLgt2TH#G{)wOl+k``LEar;>|zI%{8FCi!f=L747mv+%F8oQMT3GzLWR$6_FRqJ(Uj}UUza<)XiYYF?hQAxvXEQ>vQ9-b^1GOh}XIst@&OvP%KQJ>6{Y-Ba46om^kp9smcGi_S3X| z?IJ}bN1bAhI3OM8m^ODaB9kmCwZBCce~;Kprk<5WPg9l(8!R~`((GaWpl)vdTDkh5j6JF+ zHYWawTw46Pjz28j^&PXy7aNwvQOozQ{u;I4Y!1izSL?Vh37f6vRA^ve0(uNc{9sc2 XcEeFt=ffgk#4vce`njxgN@xNAiP+|# diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_lt_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_lt_outline.png deleted file mode 100755 index 8792f4660837f21c30c7328946ff6ee03cf07c24..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 722 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD#k^5Lkb(PKnCH*$S(h0K4U@c2o!6PjlJO!{beepW73Ymt zB@PvvbU7^+UjE#mX~%Jo;rZ%SANPyr&tzb6U_2DWXD+P|a?gL}hX0}n#~p}gNU+=K zaA2qXfhy*0bqsR%b#mD+eC$0`*1)^dH=MU1E@l^_*n=cvhJOF1vS<^Y-I1}J8>#?jYQ-<3)oQ zLw!uDul@lB|3)>2cLz)ta2`0{aN>S|^noN{^^H>*SNk^|2%NgBp`d>E7e*b$uA)ML zXkmeS`yW?}A9($5y86Sf47YZzR<&S^=xV>OZGPyx&4F(o4Ue@sr)G5?4S)A@;c-ry}Gee!-Hht}Q*H!(~*(*#K65ZgKf55EPcEs=m$6;|`s$%eT L^>bP0l+XkK@=iti diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_rb.png b/arcade/resources/assets/input_prompt/xbox/xbox_rb.png deleted file mode 100755 index 6582f391cd6050d2deb7cc79889774fe751c69b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 684 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDEaktaqI1^ z+eOU^0&EG71X&92|IfUgGRtg9!-Cy6&i$!=KH*4yOpIUP?V}5UhM|Fm{oEJIV(go> z9{-ayzc+0&!|jVZ4e~4)C3p;5et<2zF^Kf}pgX*;xzq}4Ss$_hUE1RJ5 zO=2~}Jb8z8Oa>ENdY(2!vN&v$*bo%mn01j=?!8kyhr-Gar;joOg)=|8yFgW8R?D=8 z4ATs*4%?<>472!|tA~+3GyU54mkgx!$Yfzw|K7&Gh!uSsvW*{8l>0yy72wud?mBEB=w;L3M^~Qtp-1 z*C$Ug{rELg^_E*?!$U@v>-o8#we$9yZ)Eg(zAZ_!;(u(Q!y4gzy&Ni>FG>PFB{z7o zeF*EQQDa@k=pehYrH4WKy}b3*34#ei`5ya(jF_HHl2YV+A#6T}HTc7xOOAX}Wrqp^ z<`*}FidiYVdc8MQ% zIhc|8CUR4-R`fh$*CgO?ND=C?qPrknlIlAdx4bR*Dw`Kl$SFZUz zIMtu0-dH!m;*gI_T=uv22PIEG{!NmNb~QMs_k$gjqR_yEF8-s7&RJYDee?k& Jvd$@?2>|7+Hs1gM diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_rb_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_rb_outline.png deleted file mode 100755 index e8a78e81d10834dfff6c8a2ef111782756167d16..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 800 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDfFZ%gLM7(?|1G{!zDM6_9htht`1F1r*ZqyDmTNlX^_m!Y5*U~z4luA8G{BjE zVjGGMzl{q@t=z)dJo6mW9FON+ymvVr(uEyAHdhH7sI4+uRnDMzQFQ%lM!kt*irS2m z<7a)lB@t}B_MPt;gLTd~c3U0TuqSTr%zIh3y=__t?l8V9>6&Q2QACU3uFO7>$h=qm zza0()U+_2S{5CZp#_)mLb;kR175G?~_!ynH-#Rn#L8{_|pUHnuwj?G?hek%G-iLMA9X?P z*OxB2sP|$8!)sQ)8!bE$R$L_w^CRbe(=uPZMxABPHSPlKAJ%;HL?;O(l)q}&x9{M? zK-P$|+S+U9E4hV@?v|}K7h;Gz?ajY!f`?iq+reHIfwNYNw&@BqG*&;eb@)~tynN#n zX8V~u8#MoJWk}#KTe@3>f%RdUYezWKlCwc}6@FZrYJ4|O= zo-nj-Ibft{=`j6Chc+Wq=0bg$rl-@-9$Szo@XWnpFT25XrW7+aUg1EN;|p>aG8iA% zX|?GqYcSrF_!HGJVRc6^L)W2(|4bh~Ft$CI#d2?r()X4=xd&Y5S^r*q`flg%L;nO8 zc&^V14^Q3qAxA^_ZEWs58*abj7nf--I-r^|yPx@+|*^-@H?g8V9?$>Z0%`b i|MjgP~sJ z0DEF^O~fXpAKMuvX2Rmq zLxvnR^KQlkdl=`=R@f25V4^9)n3875$>w3hz{z!B3*%E=f%iL^zIF#N6~r*`u`Mv3 zohfkOCM!R0(S>g^8k70HAB$)Uc)eJN<3K6HK8>W^g2H?iZ0QXX6dBg9%UW<#hk-#y z;K57r@&d<9Mg~Ey4+Yb_9`0@8nr!UA#$0iA&Gs(IwfqSW7`}7wPS_=Nne~PsLwsuY zu_<4x#SfSy7?r$yA!@3vIEAB7fEY8*OTIR0v&*!Rsp?9toEZ@p4z{p@< z_y14u1qt;sMiG_=Pp$V!{^^^%SkJ+Yxg~g&2d{`!!;e3651D@1{g}1liv?P$*rVRL7y<$e3h*%kfoGh}ApM^$ZPlR@d(Go3!g%EZum+f~j_;O11CHhXJXz z>4#rPS6|UTnfF)MHpg+RN_1P-rLf3(-O&NE?W;F#y7O)Bs|~Dja_S1bo-deXwk?R6 zE`C{YiD|aU@|~Mlmwr#$-)pwK_nS@lwgYoHd!+Kun<{LONj}K;>Rcmp*JsH&jC~s) zJf8dIxai&+dc7GtmX$o^{;hqyWc$?gzoIsprZx<0c5C`%Ub`RJ60`3#^D2HZ?!Ujd zUmZQ7{p#z~<9j71p3gS@b~CT?G@}Va2ESwFpVKQB*6cD>?!P|kKBLw7?PGpQz9~?NJr9Y~6&+=dtoiPj?AS6lt8YI_TkH6J`cWSr7ji z7I);C3%He=+-B?((|PH#+&;+jd3xTV&ZvVz=`Cx_8@#V}1eh;wIm`H@*P_rg=vHj* zZ#i>+1=n{Kg}d)$^DF7S75ZD4{HbnnZq3IIiM3|V@}~a}BWI=m3>Wmzh%M~A{{)y> O89ZJ6T-G@yGywpsR^Ju? diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_rs_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_rs_outline.png deleted file mode 100755 index 7ea3310ae146948d1a34640f135925c3d04cf1fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1354 zcmb7^{Xf$S6vw~YjE%-T)T9>&T;cDorFh0r8Lmv42mSXZc)N|DE; zOvSipCc31HaZ8x86t!F`YOB@F-R(cPpV#Z0_c^cEIj_%epZrkTHk9TXO#lE;RDa5L z6~X@s0aNvl(&8^Fs3mv@djr7jGg{wb;Hup*G$_n><^M?aq&i>Sa$K&O&z zhtEt$=bG7-lXSY?0n*NvlPafLD#bhO@Z`c}S&0O@3hdTkZhrkwxj^;@%^ra(Os;dB zi^M;?G`!WQWI&$VIgUz$O#v>VA6T&li9v~q-3-E>Tom){Bng~tnG)(SKPt&%HwIlG z=O~#Z<7%ZIcnQ->thWQit$ZIWblh^ZBO}Y^d$z+CU(QP-cRer~L$a%=y~q+`mtuej z&M>RLApDFvSu-PB-TKw~^8S1G0!1xSU^8+xlyYpL2_^cq-2QrUa+g-xNV-_}mc$4m zkj3UT;Ta1?Knbge>9hhBmGMRiAC!TB)g}b%1l1o{Ir2)@kF_^DZB)lJ)%Y0uqc zmppJLWCJc&h$LHdZ>R!HxEd#l7d87LFZ_fgqx#lT{Tr>D@`d5ItK^~yc5J|$E`Z5r z7FJ&SP;uJB!Lp}hbe(~>v*n&(sx^=It^z^PCb!hg5`wS9))Y{r);Y1}(LqHG6WUy0 z=n?-#(@bLCE}G&rV(r0A6AZgLjxF1mydHX4uNUPQdBF2^zGh9Kr5xU#<{n9e7c^7O zLVJbM35MKTa%kRWq^v7taYvjjzr8UC-v{Y$Cp?o$@}9^Z!-PB*M9_Uc;O5dm(@dku z-LCE#m!3F*_J~hMEPg=j%)uN60>k@WRb#{s=r*TxPYbWgeTphE=3Nd;awsrYLfBXv z*AacQ+AaNp`PD&5r`z4izq1gC!9CSS2A3m$dHaPwE63YrzmE?-s)J_IPwH-L%LE~x ztdjA%PR(gXqu*jzO&)lXZul)n4*JUkx$^5^?l7un(1Drc^2EbM`Wkp+WW$B$v+_s6IF_& z`Irb?za@?$(PIfzpIdR$Q4V8CYcsMViw7f6&ZfZ=!j!glcC+M;$@DMr=e;^|+I~h} znGZ{(my23{Q>5;4%Ut-&|2dC1wbC=A-UdklO{G6TYTe)Vh@sHirb;t_>Pw?EZe&I3R^_DQCx~h8rv)4}%(*{22GiZ(L{R&%WHoPMZ0_ zGL3`-(_Oh)E4UIGj-TUX-@wMRL;tSB0kc@19qU)T{5j89MpoTG(DuHeT=i*DmLGQ> zEN1w6pP5T)LlXPLCxQJBuiZEt&&MIkqmkgSF5*DI#)goC4NYuo7!JG*zma>;wIOvo zt4_m8ThaI18dw8vHQW{3z#x=p$}`D0QH|kS)B&asQ~o*##PBk+Zs5rCFuOGG!*j6@ z3?9L6&g(v4?5JC_jDbl?;m_JB3?3N=y4Z5qcu!8zopr08l0`X8-^I diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_rt_outline.png b/arcade/resources/assets/input_prompt/xbox/xbox_rt_outline.png deleted file mode 100755 index 862cbb2972da477462066db04f2364af59f2fea1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 824 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 diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_horizontal.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_horizontal.png deleted file mode 100755 index c513f8dfd42999030cd653f5acf589e9ab236bde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1257 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD8fBt*@`27qj-uY)%}`<~nO6K@(=D_3#2=d@AAOxZU4nr*fq|!i(SU*Nz<@GeH1vKNbI_}|uUcF_M?%dpwHHovgYTi>tx z;WIgg^Be#D)h|f-_LkwE>D)CF0^;K^N*RljK5)*b9?Wh z=ilS2OCCIyIrjG8EGb4a#oLbem$#T5v{nmgUvf5C@urySoHgC|f6INjxl&8)&zs%< z?(xaW%FSX~Y0vo8{hwNnyHu!(`DDgLgPO0n@h9;B5Ukp5zb5Ah*P}5=zdin1^7sJ|b?^e1e7&BPi zJK8?ygL)X3)uT9HHue1sU${c*z~k$=I}a^uS$%ZrjV}xmF5GM{P5W!-S&{sSvb)+0x< zfL_Zq%=|xR8go)okuJk;rG_p2B5I6=4`t;Qw4Gjrbt*&~M`oJo#x$f_O;3wh?Ka`; z!aw@+uCFhBmC%+W%DKvRWxu?ZUu?yyBc@k3rSRy~!#hE8@`a7K`< zw9hF~?Z%xY{A>qy9obwTQ@ObL^Y;GJx41qO{$f1Hwe60|9q!wyn;=Pk_1gp#PcqjZ%Ianoc_S!OQ~UAkH*qD~6+up0E+33qmDbc^ zmAL2K&aJus_dSnhn(+LNSK(XbMXIm+6@yk6v8nx75N=Sykn3|RE8WF?Zi`&!szOoK z%|8xsT-EgI)li)y?EE$>?T>MI*VUsDZC1jYBe>Q|`RVNNa_G5gRP3cMs`E5VP+!7v zSFU5UNl5w4@U=f33d(1cFHEk;{q=Ts-^0?=E83*px;$6D+Z@lc8^|7vA%MOvzkx)esxSueDt&U@TnV-v67Y344g_|q)kbaX>iXv49u(&T^VwW4begq z_!?HqB~-C2_@_H*d6i3csz-w8mp$LR9;UG;Fo&3|l-b<#bln|Lt?o+I>23QRt?RZjZ&q5G4V_57ti6!*9~h=5;Xufv2mV%Q~lo FCIDu7MBD%X diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_left.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_left.png deleted file mode 100755 index 1cab90bf850e5a168de5e7f9685175be39d6f768..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1263 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD}HhFuxIEGZ*dOQ2} zq{kXO4u;Q?34IU%#n_Bf+$?VCGlOhK}6#eC7`}q(91SV34d8`c<^Yi0i|}0)B_vr@xE!>Nf9~ z(|Wa>fyw>X`~DxZBKw(HtWzQjlQo(YR>w~&`5v~D*OaYb()(4L`2#x-bG5A6`e}_# z(i@g13jaau>INF~fpS5(nJhyY{v)9pPbUvEiCWZ3%w;caw z@dOBnES)9GxPkH1>ff!NEONZl_ybPG8%IfTY%wkFihg~J^~Ncdh=)&SI?Q%{{+}go z|FwCS?jM?DyVB=<*mWu>p~|puD>YB-0iS-Lhja;d8Jtq%CkMPE?%+yG+F!Z zOy5<%ejY8;X6)T^Yl5AxaKim{Q+x6<7k|C}=)7~J74L)Nzib;sefN8IuQZq=4Ah?;ayn=LE!+yNH3FI8@vrE+|vHW@W7_S;^( z>*>KucXTAYeRsPotaO?yd9TmtasQphqn9L3Czvf{y%j9>XQHn*W1-v0$w6#8e}p;T z?&n!sQ;OZkfjz7reJ(RAXRn zcqn)`oZ*i#=K+fYUhEP3nH=i7TeiMjo5jz`^m{r(N7jSLXrudU-kGn|+1&GV->tpX zkGNg#Gi8)<$3A&)_NA%7$*6CNmR4$qPepP`L^0pC{vTVl{=N( zeCf-EhA~$mR+)TD*D|6^cHPx~aL?m$&g-1VJ56-mn{Xv2k*`Y&$4XXMj^-8nGCyV(CnpA$;rau7K_?Xc5xDe904RNm$)E4 zZmF=Ygzzpir&$^PC27F!Q`#&4qHalcHNEmgbs%xNE_50mlP|C<`Ksv0zu8Br>b>!X z8hN{L_4I7t@{H~v(*UB@121(db;8V3si!d`m|ep4uDCP;YDG`7KiW`!7%$CA-W?8_ zsl+^z;@)Gb42!}$jQggasuO>?xdjN<@r!CR1{lh^)t z`#bG_e$%)I#ZtS_XFIFt>SPGD5XGtdgF_T9i8+-q?$IDB1vo!Pm>+F5y%mSyj+@!O z+Xy%0i;fuvfl@)S0Af9iT?^nZC;D#44Y~8XBo{5yKM}CNx&<^N;Uf;ZzTnyi43;VxLwN0R8lpFi^Wwe8E+F2N9yZyJ^l4&ve_r zLPd9VE(D7>lWtUfaxu%2C_>0+pkXDx8nJMPgH8YN3ARfKm$+YVBICMRPTQkT2oq?bAV1||g!7j!wcA=u)md{k1nyxkg}U|bj_C3z7y zT<rp~3D=50@k}Q}JZ@zw2Xb?}| z=yk=5+}z%6Awj+$WIJ0l{Y7xBI_*f+NYm0!xJ{9=1i>kW7}^_rINHjO;hlWVC1Y*8&QV MNg;lsos_))070K^b^rhX diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_right.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_right.png deleted file mode 100755 index 256df0ce0ad555f44a87128a27e37eea478a05c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1248 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD+S5D zla?y*I54uN1poh^y?oX3{O(x}Ojh2ad(2qdXV`8(`fd7j2?pi_2A&2+0|vGO|2ZFo zT&g-d?V9IKdu9Xws=E`nzRIim)k+}tR2Ro{cJeMjt%s{PrF zGmItMA0MvjV*bW{C}^t_k3#i|KaEQ5OPrGr=gqQN$o53vGEt-E$-4bV7lw1Us|h5q z@pMQ|)K8lo(9goO#{B2*snT87C%=olqTaQLwa#uq_6d(^a~Qs#5@^^zkMVB3)ef%@ zk2QoC6LuXn)KFc;{DiHcI+fu-jHtu=T!xwKhaWc`@I6pJ=lwmd0t-bOqq~epN*Cv| ze-Jn@(_w*GhU^^CLWXagOcsK78195JUz2MPWjV2Ux5MFtJ(Wk<8fx3Vv&uNc+s%K$ zEO2691JjJte3#i1Hq9TEN?OM61fHzrj{UsU8=(8 z>}2~6yD=JMOn{ojEN^s*UTNRES(-}Il4rFT>bf2r5e5%O*iP+u7AIyukGj!c`n*U?D>EB&; zjY%9UpRT#~Uq9ebish-SmMoi$KkY{u5QRV;8q_EQ*-QC6bAq-Tz?E^SsaNoaemHbACIgy**vjR0*m805z(s zlaHLNf29n|^TJ5+vK)$$4jv8w@C(<;LY3sbh4%rP^VaKbNM`Zn=C}(H?Y+LuY=$BGgmb5YPB=KC6p6ypM jYRzT?2gcL%-!MeZ5lgxA*yaFVv{RY6tUWR*fWBc z3|m^-n1se4Ws_dDbyT z%zd@&tAM_r&0P>Ri_hp}*ukFk(Qs|>cBvDueFH04`DyBz5H~0+F%i=tqt7`ya3`-Nj@i$8hMFgq# zlAe5UE6=l-`-WBESA}Km&%3t^3a5SN9s%ZVe&HGj>nOnBhS*UGm!j^#G;U&M40tvttPmtVSsU?w?r#Hv_WEVPnOQL^XsgJWI)gbw z#JFN5jDd7S`)k@Vvz0>Z%4Qn>n)N?o5h_FVc3T+Kh|X=X(sy@r-&lg5^BGOxplhg# zH!8L6=+p#Z)w!?TVpCq>md)ML&0L~wO&`;A&*APIGTh)U&BBzFPQ;^3cg0%r46{0& zrz*$q(Iotbrmn%;Z^Eew1V7GPliq-*Qv)%e=<>lp{8KlrXmPvC$4IC1D?w+k1yr#G zr0sM5+w54IZ?xUY^>$6O54_g@w3PVsw>uO9u zs(vaz%hrDsd(qZd)xOD55Cjzt9-KQr9?r1VQ# zYJbN|_4N;K;%jIo$%2f=>F6PAO6+}MTTL40SK4&r@ua3W&O;c!dMu~%x$w^>Mnhcn z2~HTXWH_l>YC>X!5R+CGvfoq<9;ir#PwTlhB|5xE!yvbQk|yNgdA}*w9(l$-wC8Vg zA_}aEi6bZkkUgEgtAjm8U=lsT1gq+-gmTpa1Pz(?8MvpHGhiiOE zes?2ZdGdCFjak#hx~xCOGSY{alm;W1@hid+tp;DMR%6E(f_dkqS1Ef$YE1YI_zyCe X9^6trG~2Nve^r3$?CEsNF(l(3%iCPK diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_vertical.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_l_vertical.png deleted file mode 100755 index 7b2e6aa84c83290f4af578aa979589783fdf1bdc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1405 zcmb7^`#aQm6vsbbGZ>d)gpA8F>)s5xq%l%v#wFwul9gmyu`@2M2u&ehvlXiid2$() z!K890qcMOMs+Py{G^*W-EOASz6NX@$vL`+5SHvS(4b*TUcw73KQ?=eI$mQ zeONEGquyeaPg^Qg2Fr6?f#lMVyEE0FIlNFjn>B$`gxzt$P~RqbVIa|74nj;bs`I)^ zuOF1#k|H{s>7D5R?LFAG1*#>-*ImGT>Xq-hydzsUNV9-^&=NF=PeqS{!4%6iJ|cKh z8IxnTvMJYNC>9~Z^1p>hS$kbHp(?{RmhkmbsHb2P9=xEl7uRP+ANf|h+yr) z%b&WVf{2=wpw*(`8&ky^m5p)wAZcI7&P@vA+v?TDS?-V_pj*uR6;QCvsV8+l2Z67n z;$G-h-3`a`hE`Vv#@*g7_jWr>#A)1sJ3KPk2*KQfAoS;-5ahh)P?)k>lfqj3IcSF z;A07|7V7+z${6_LV;5CNm#2XqIS%EIg~wiWnA%I{9`-d$W=0@TS-E3K?yy1kz1ndi zR*2+4(z#s*=^?;HXbvdDGa1VpjpeFt7#QlSNO?sHy-#6lYQZM74{b3z?~NYw8bMNd zs`ut}EJ?|?*#$-U^DJd$UM2KvYZoLi1ehnXV}a!J-_py0a}6k7LXf^({9DY)ob;j? z=?Ub|n3eTuJIYf+^L)Ppd_WWnZ0sdXgyu11H$?IDru;(C7KqqDf$SFEQ7E6WU8o01 z2JD1P!xnjI;?(2rXyk1L-l%m@RL`^xcn26eHPad!B<-gNd(_*4W^FdbcQ+bBF<; zON^Y9J290bBpYKJIE}E|3+N}TFkyJ)o&(G&cd`d&B~>4wuC5jyqJ%b|X|#f;n3js8 zBPlBp$FTO6B6*Hx;U}SJ`PivF1xrB5J zwfpT;WLPs6LkqGsLH(=2YwC3r;2oRbmc5*^nKcV@oy?Tx5w zm`H@aniw(0=Rj%6!8f=zd)5L$*;I|Tix6@mj1bNZGSHjlXW-X;u2lw>cb>3Qo1713 z!sdQc1VSMd{o%q7X0MD|g|BKRg~`eOh(JGdObD{*7FOYje^eUcN=ruiTwiNhfMPeL z-lbvxD4F*l8Owfnw%@-$ymgS;XR@4YUft8Ga5cpY^USso^E&US6F&ERg_0GfUa;LO;2QxOJ(=*9+lBGRz( zp4GeRuDSZl0dZ)6Hvg<1la0&c(}~sxRHC;WEZb?#A4yGRZ1b#Ie*J;Mc!%?)sGJ(x z2Evkb1`1cp+{1Inv9{#r$+AFfJ3or%WFlLOAVtJrZuWzAaTo&h(>-E$^o>YilE&|2 zv`ot8Ck_B_`1xsTCFM&=suW`-o-1zxWM8nzj<^}yFl|-RbI#_$O!eieiq4Lub?Gyn z8;zqH`w@xyESRm(6z?6sJ-jH>2K@?akNpSo9i2|4X0-yz9|geO#oM{g@g(aXMRbVD diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r.png deleted file mode 100755 index 852e140575cdb8919b6399c66c16909dd668b6bf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1286 zcmb7EeKga182|1CW3+?{GlUW?Ekr^SHe4^e?Dj^6?m4?RG|f_^{Je&gx5Sp0MbYF$ zLLu!ZqaspW(WJ^RiqoclW>h?|shsJfG)t&ht6XU(Y9p7D(06GSUJ7==l10 z1*;hP7Z{YP&q@n_s0hvU2=D;lPCj-iQcb1pY5sKY)&El}lWN_dHEmust>pvGLyQeV4SFH4uD?i{mu&6x+f>=X2q1>U(?0bJz5sMu>}KD$}(R;4hB|4U-3x8&Dao#Rl8Dbtz!-ct9$S^~)G7 z8Z5Q_7?E`Hl_$0C*LTUkxhAVg=U47M#_zGxKYe*ZZtFsxKhq2Dq z0tq1tD7jS1eVa+-Z_;*F4fTy%jt;rOO*(~24oV^%u_$ldAleGuoT^i3OQY|GooeLB zs8L#c8wCjsFI=EVJ@)S|;vvgyG>x^%ig-P2^0ZlVCGOG?K=9o)#`Vej`}&{z7ub## zg+PmZ88fi#jG`Lx2@g-6ds;+5l^dF5e}Xkf5&A~J)ozjm^&nxFv{3c6jW2fNOGtcd z_DKjrOFc6`nW2_Q;g_9(lNcH(83whtf?H0I7h>XCdXYxtHShmk+4k2M+~P~X(S{#8 zAex$VV$cb?I4RYgCaN#NIX_SuTg1E|+_!~yZOhlU3Lq|=~o z*Qwwn8zeM0e82}ZmfTCcP(1a*>W~GI)Zay0Xcj%N)H`yhH}YDF}_>6G2C;ud_9wND~m^|C55|%rto~mef!*Yc=D($|0^Nv*u)%x#Tc8N zIN;HXhE;8yw^|I;u|Hgz`PcAHH=UCUNk_h;E2YTJ!7m@Z4gx6h* diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_down.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_down.png deleted file mode 100755 index 1930ed680138e4f769f3c7113f940c157dca49de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1384 zcmb7^c|6p47{|Y34%dva>lw%M^&dfM-gd81?H8yu8wUWy5bJe)1Eu=EUDz9;7 z&>)j@L@Fs`NTb+g6_s+$*x70S+JE0000>0gZ5q` zLO&z{7xizv!VM7+;p9VP02=e91;I$sZtUsi;~@M$B{GT56&R_jqDeN^%ia5oO@6Y+ za{Ug2pm)#9uwB4Mm76U3)az_d_KD|yDxHj;RYzahnZ6SI^>2^%do`!!PNeO;9$5R^ z6vJgE`!uihP~YMXoA@r_qW)LlTTQD}4yT{Wfr081(4 zsaO>z=f+5c4Wahl*!oq)I?J_A?`RvaY~CnIwk_-paHt3uLzp>Q3nTkn%KQ-gAaOh^ z3BkNX+zEoNQ)(6llIqSIY5)`p;xa*7jF%xAEzYt{dz1_R+&#skc@y9yR)M4yq}1do zW|B_5lxdec^87K zR}wmpl=WNFXM1mk_(n$VwjR&3(A>P{b}dw4V8m02IW?hF<#b#gqOQ{AFnZXsmeI$0 zHoh9P^x=wZ&vD~?cz@v-I=5t;o8@>Xz%qQcGOXoj=(A~>1)-Qx`taN}Z%>R%yvp+1 z?JowV`)p&;&sUs|BmxO2w%nl9yER&WldHvC*x03sZFA5$GL-TzdPeaZ^<8 z-w$I+Z+<#(3Q<|7w^8LsA2S@g9>Ncrgz7#kKbeVat0zMd(m$aOD zw*2z;fyCd><9-el>u4grWhRyyQKGun{;(muR6Ez@W2n|v!UByMqR;o%7(Mu_<@SIX?ME} zGt~GxMXs?n4NC@EEqQ8f!zjl0zZqR_jlOJwGAZ;-ukM-{5Yy9%MsXyk&r%xhci@2K zyV1D)qzJjnXjF`fI%8zrq|_sCO1tWzW^p>#AMmt0lx&0Lm>o+ns0h)#x#=Z7mom5? zCTjuJL-p{EbiD52BsH2;>rkekcP@_Urz}{H@NQlF;%_|oJcq~^BE5>!DEf}0%r$z`#M`n>OX$Ehd2NL diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_horizontal.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_horizontal.png deleted file mode 100755 index f7fa95fb6a575c20d4665c62fae109a9eab13f17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1324 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD8fByS;{rwDAxNg}3ZR+rJaSW-r^>+5# zZHu*d+LSd;r0@N|dhTZHTv1OI$Lephwmx|-=PjpLDcr=|A1_zHe22mO0562v&-p+> zYpS{PN{)B`8E3dn6;Dbr^}EkHYy> zgG%Su>;8Y{S$<_+azQk5BhRb}Rg7NWPcvP-bJFb%v&Ng*ce9tQXfx$v+xq>~rOKrH zTnAnS?lM?ce4xc$bTY5DO^Nlx7;dgdFJ`~}mb`ZU=T40R|KzSO`Z==Ko|gUl`R`p1 z-=8CiapuAWWY*v?V~{eRl-Uq|&*o2@TY zd+ZPA{J@!?vsO(c!Ph{$LA@t~?Z9hS*-s0OF@`ZMsGV_%fh9rlOwE=AMa~Al(h?pH z2A-e#&Wr~PFKuP@&i?1kz&B?Ccfv__C+UQFFKSJ6-!oXON<$Vli$uV_f zLB@t--AW90JEfv5?F$({*f||yycWgyVoKeFZUcrdmd)N9OEZi^Q*e!EG*Ey-VTajENg1?+R~aS7v+A?`71#cWEwTMiReH*rcD27hO?iIA_9; zR>NUulA*Ddrvk@(}< zRNs`X8EamgTXu2DE%O;A8Q)lsU6WyAFn@l_zHg7=k6&*;zo}xZTXbJJ;^Ma1EzybA zIbzBTaXWne-afPP_CKE~ z>^iD0*0PHhTAF(t$TCeSulu>(_P!Xym90~!-Ew!~{*l-rG9z-y%p0!n6)rGtG0ePq zt|P8gsETFHYb~#|kKD>3%QZ|FiIv5$WF6o6Sbk~H8vb>uD`rTrZ(6?Q>COi$7!1Q4 zKb~f}C2zW=)ZggS>D0sDQ=Mz4*4~ZgKT^>8Q@}qaZuaMIXFE^7p0`>mQ!0~djaB;o znI5;f>eHlsqW4;Ax|RM)dT>SX!rT}=D|v&-Ig{>78x^ZtpH`n}P*wIyY>K2%!}>YA z`3=$z3p$=@GyI#x^n~TW5%wATm=65!dJ=7`blit?5%bKQ~H X9K5yVheiUhykhWl^>bP0l+XkKqN-ux diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_left.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_left.png deleted file mode 100755 index 0d6b849e5e81c439f64aac9b0128d9249f1dd79b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1317 zcmb7E`BPJ86#ZWE0$~fXYfuuTAX`8pB7++{iDCi)r&z57K?wqa2qBWHk3~%t5U7l7 zlA4GRL#$YFL1b~E;Kou=5CX9TQ2_%OQg)io^dIP*x%ZqibMBnq&d))#06lHIHUL16 zO7Uf=5&3tqX!TXf^5@h5M|PJ>RLKU|<=CblNLg3k%)&n2m>| zTi@Bgssz}#nyXtu*VpWqFSejRr9>YTpSD7xN>_4-LojoE2WQ_m9FnztkU*BzaJ%9W zEaud~(0;hhS3d}XNYCK1H~@Zce$JqIBA#1HkBOffNl!A!XGO) z{uKJyVbPd(bCieb;luM*TmxEj91qZ!PM9UU8VD`Be{_VbC#z_TUO=0))$J`WvIUY$z|aoNpBK#MF;Ie`Q2qAtBSeWI8DUo?p0**|Hd zZJ`gRH$E3GCY#G3_<}RRu>@$j3R2f2&{yuU`HvlxZ4kDX(gKz1zs5_m4x}}oL&Lqi zJ}w9^G3l%&pCwR@i9~-GlFbRs6NR>GsSyC$bjS|$v=^Gfj;k=`UnW;G!R6z52H-;e zHPYGS!GT!=OlN5;a~H?Z)fz(qbQqbA_t9WlCh2V$hdg1HjW=lG1cw6G z(Exie5WtHCzP1&*ExF9?B)tQ3n9v-Y(Z+o+Y%yH)Gy4(Bw0*(vgRi6x{XUxzzKb zTUXsjy)zGMr9?6fL>r>McYU5L4ta|2`;Kw(#&~}C_2fu5(Tjz%V=9bm*HNE&*F8wQ zgifv-$#E)D6$jr24Z@uutW8y#MakG?jQn~pM20Dvi1m=Y`Te4GbNmDGQen51y*AkD zW+2&Ln0U!w8C$v;*B@RpjR>-yvAJR+dNEOpA-2?y^O3qu%W0zOSO-;}`6?1sVS>ik zL^DzPX=vd-JvSRt-!KS=DGSt(Z`@`Z(n9W(;gx;?BCBVKNhe>XvRXBNY2}+6p4jlPgXb8bEAM_? zmFy0(pBzU8f_aIqpOOG!6?UKn(p}f|6O1gA3Pz^ZAUQ>bVVY@!#m@D8P%p@`DIDH0 zb)+4=gzBsWB~owxEZuyby?s$qoabM@d42`M=ncs!kmP0Rm#jp%{MR5%R3H+blHa_` TE;pxIT~vVTNAtbq&CK`*JA-Cm diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_press.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_press.png deleted file mode 100755 index faed4c47ff53a9fbc5446df2645d3347bb4ab70d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1364 zcmb7^`#;lr9LGO1%O%WA5p8q7G}kza5?z`K2 zpPbOYQijX>SALm94#il{J)QuxUs^AXLdbUqvOi^)?EjS9BtPH4&R>yFnwk3of>Hu6 z;oRxHwk*+M?sOdz5J{&v@=v#Yy*(*8JYkKX{OT4pXzyPNo2F#(+$^F0wrEK4ctA2k zT6^b(d1v|d_X7H%_pls*h3H6wO?CQ%`nm2L=A*<%7|u7CR0vMorYb4tN@lX4X5{Tj z5n`g*X**h}13wptXcT%$GRjh)&arxRiqU57@36(fh59|8jd7C+A^=c_sZ%C7gLFi( z1wR^8^$=3eN1Dqvl>gGD34%?=OewNm_2Dx{;5C*p=SI)4V7y61K*gPO9%k_Df-8sD z{>`p5@SHjv=p)1QS|f>{nMl8yzS-d`umcNA#@0L7PjEX@*Ltm%?Zq+Jdezl5_{skI zYiF@gTff7|A6QQ=Y;njoSScRA7RUaP zY6I2&;hu&?o3{f!d)$meZfp1DXF$syTM;TqzF%lh+6p;$sZ03es9Aykj<7?$zUO4#IQ|G3P5v9`qauYX0Xj~cuoL`v(2)&rK}PTg)Pg+Ot)x$p z&KuVs1=Bx?zTa(ZfM@$JWkUJb_rQSfq72m+F$eGt0shGLUNOx|1HY&DzMNO1o2#x|X6?l)w0x~J&0=zxXn%9Jy<(ZAg zOB(iL$u*SIS*KX&z$)ve`tKA)$*#EF^XqOn zJL)Gq6KAxn$BRFfRIwC;f2nRWYC8~nksABv>qn-GnZDZaSk;%EbV}bI<33#$_XW;u zB(1#Nd`$ECa83E#y*jkW^7!e>0Gq|N$132}_)0RZf6~{+(F$*6n!a$1;<6d|%!q+2 z_)6S_vY%2YZL6c diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_right.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_right.png deleted file mode 100755 index 0473f3ac6a473077ff8a4dd2cbc0379af72e8c08..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1321 zcmb7^|3A}v6vyBDjLm0^;X#`(-Hj`^d|RZrbG0$#`+T{KL{sUiL@I7|uYFJ ztE;*_qBXJW`ck$`CMjPZ`I@1aZmua3+vTqNAKdeJobx*8@i?!?`RP1!SOH#moDmKH zfcM_T3|1-fB{k5hx+uN0q7t=(41WdyHTjxKj=E}gVEKi5tpA@-nN;frf{ah9j&LG4 zAS5w=CpYk`2lFp=#(%YoX#pC^Ga0H&=e(JW&>x5A%4YMX$XbXqbk?dvv2)I+Jl$DFy{nLpA*{-XkuF;NYOfh~F&4#K{$!`NKtOuirQPpfB->^_V270Nys({) zTuhttr9|sGJ2{S`*?z~4Mn~Ao-3;Rz(EP>sYn{^L(^PFjyR}vHFHg?~fm4tC8L5dS zBiZ|a(oFfDNbyOFL=`uLMPISZ15q!yC5D4>2neyDVPG2B5s9*GTYDQ$|!-~pjJNKpuQPT zE^e7DC`)(O*QfyKcNZlkL!SNS>0md7+q86ka_+Y?n5CTRL+SIHb!bvYsh%{N1SPE) zyBKI`iSHv#TiJsoVwfP~XV{0Y!v*kU8II6|fyjggEn z=ro>{u)UcQv6(=Ile9F^w&6KA;XK2JdPxKe9=8Y!4psDQ z@rWP8HD#(pavCfQ9Z-B*Oi8V{S@e8<8*(|Vo`>Q;NiDe5CR;NAF~_$KMai{$y+9)u zPU02bS`zi6DrkSYxXFXz))3#4#vuU?jEe3dL}NIx)}h8>c4W)qz+rsmb>=OX01FoB z?wdGC6ARV(#KJeMsw+EXz|h6DrEjZuUuko*zPE+Z*g!2u@XjwHhI1!kgG4>yOm3)F zYMEVoRG;TmXJ2B~9GWFTFS`Wy&zW%!eLoPpwJJ8bs$U-s5i5UaTo?w$$Vv2dU?hb4I6fn~*@D!3lWRi6Dz!|oZ#KYAbtzly_^=f6m~ z^lXtgsE~aU>tsJxXWch!9BdixezhbIcW}+Cc3f3Jq7#LcM@n-<=cetf|AUDB-Y-Ov YW2=fqv|cebLPx# diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_up.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_up.png deleted file mode 100755 index 98d7d1e4a948176cd0b4fe64a3fe91bfdf95a710..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1370 zcmb7^|34FW9LGP~e3@m&%9pfV7S<^ZMJr)*H<=pwvV^W$oNp`A=}^R?YtEONiM}kY zICqrq5oXO)L~g!+SqO(%Ov$&@mQME{-0Sgpzn|~-HDr!}YqJGwdoP=0Nl@}%n8zRR6Pcl!E&wDz=iu3MG3yf$f{^K|n% zbL|kRJz;Zcy~W6h_b5KFWoo;uIX!lvIMQPVW_dFec}AKn9Zo9r%@=-D`&)so6&dFQp@NHz6U70T@?HtrB32^cEiy zDuP2Ii%T*_4v7ie5m#{ucjTa$kU8Sg0b^h_N9Dobz`O!3{cXMuF3p;cg*OAdH~ai1 z*1+Ag^m5mS1huk;Ft0j6T=hu-!$QTqUHov~0G?idwv&`pDS(UFN&H)b^PA&Z*DG~_ z|0{g|B0W&&8E}x7vWNYd#Osq=tU(d2FS=o}-u6>Qr7=*3ETEk@gn{>>(J#QaCvw-z zl3qF7gZocK+X+?e+*tuRHVI*g9d?Ly|8Rb1lM?P1ze?NHPM2ssv|Iw1;e>hh(xL`S zahL0qO*qnE|JS!s-ZXE_*=ZI^Pv{Au%m+NFcOVusOrkxuUG(Vy=p)=1GQrqi-go;! zgBhBoqS-v1jPV3l%*_D#)zq!j6|4K}sM=&mld*BA@Rvu=Z|WL~?+icZQ87*YN=6x& zX;~GfO?*IL1Oj~=%alKE{4j2aRXa!uyJ5jG9DL>Mgmp4YO~jMV{Uekl_4l? zBZJp7my4!_tBN#?c77?8LSt&}LyGA^&bbq$sok&1aDQv$Xn-wLh8lSQ3lH+7o2gdw0`je}?&Us&lzGPVrp)TZbx z!~PBd$3hJ>fsZ*Da;SO@#ykeuRn}_OkWN(D7RaAW#m!8}@s|r)>WowC%6Mo)GR(}c z1y#V+TG{JMhSfYcznLGXij_rjUcgKi<3j<}N8QDl9SttuPHnlX`YUI5iOqKme&Z3p ziH+coB)TZ2v5S@E)oLB>UV#8I^0qQ*7<=5lJTi3kN~z>hwr52q%+_>YRklGsfu8?; zr&|l9yx6~7w}N+|>N~vIQr56sz?hgRQtqwkit>w>QSNv(2l1ZI4P54W-fkemVkCyx z2&&FKbL`8M#_E^D{ckJjn0xk}Owh79i;fnuy=uSIfR& zU6eCO9o%suy9;(YlPOPuKf{zJg$eSdk$FN9Vaj<2INMg->?~w;qgT9l^f0wIe1J*| zFLi8R=Ct>&1ng_}*w8kFICv@}T+fc#y@XbcW|Z$wtd2F(qI*wgNa)KSg_T=ZOn6^n zmG=)9U{hqin6xg09uqm+5qVmFEYNrn`_mOv^H%KZ<*V=V1}EN$#UVqukQ$%Q9!Q%s z>x)V|eEml1pktF(!O76rGqmfxWBeF)i?L611g>8l1};AcA&RzJ?6lXRhMV2{k%;TQ z546!QHjS!@NeAYVTaSXLHX39S>nW_D8eCtXpHs7ik7nQ;6-0=Ugw^M6q?a>`&&q4t zBVE|@#G#7wzGmq7J3RtcU#SpTfE&0txgs(6Y1=t&o`HliMfuntMHC|=U_;ER*{1@GiiuwQm diff --git a/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_vertical.png b/arcade/resources/assets/input_prompt/xbox/xbox_stick_r_vertical.png deleted file mode 100755 index 5d2cfe94bb206e2eba7eccd410911a2d46f12c35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1465 zcmb7E`#;lr9R7S8GYm^*a@&-OoZP8{Vj`{P5;n4Abiv$CSxk(txkheXmc+3x%0f1* zD3^JmagH68N~9<$p_FQ*)y~eJaNe)i`+45a>-9XpJU_fo`FeY5sOhNz05piZ-Tf2{ z|F4g_epirE1i^~{fS_1i7N)Gww!WkQ5BdKYMMz;^BV$Dsn44tpi0z>JM-2f=BA-WR+n4=#q#TH+~qv3sT$h0MSr!?_h+sf$6%9Sq;*rbje zo_ydr>PEfElr3Ozuw38O{80F^2VB;8(NO78996Cx$wed*4b&P-RZ5cl-Co*2?!C+=!w=G6LeX#ed^R;hYz|2T`F`FYVp;nZ91Z86m ztutEWF~(Ya@6T_~EUqW*G{$BCen(KKxG(DGq{UW(rw{3zo7vwC5` z&IcjD$Qoa3ldz;}eRv!JUtxbu#r!oV1cZqrdj+0Z4Evrf6hO%eRz|FAX~ z#9||n`3UYS_Q(Llq|y1lymAQ7?cZhuIE~v{oY~N)_&-ayL=4bNj`KgMM)q-rOd_G{ zntU(%VX`VRn6M7o$aSeSgNJYD)}Kcv+LZ!z-zpl2u`Dl9QMS64t>nvH=4}J5{h z*5@l(p>fjyPNVIH!MXDdJUn7Z%>*!-QzfW`*nijv20AWB5D3P^oC9&J)7^7D_72;ygAjMF5gY zb!ufTFQX=xb7YRoS_K|v3`aKZ#fejBSj{7A6XS}`om2BEnk$(L2rlFoyWz?8YBWbGb8%hYmm=%Wdxb$f!eX9y<}blj zO2vWpF>SB*bkT3$tQwEMqwqM->4KfjNw0aq6afpXV;-`quaJ~APFoigsAyu;KS5qi z?zyp%y`mV=QyP6Q?`Gg_$Ye%*Z0@zqm5b8RGsU5w#VNPqUSyhx%&FahcRc1r#^pg~ z-&F!PoJ8f#{^%x4%%4_17fc%$TE7oh>1A(7nbsioXofWopNV_LFY<)@lNhF5@2{9G zLo-TwGEtQMl<>3Z8Bp5rkv^F`)77l{&w`|~kmQpU)T!k>Zn-PbVC3Nd^3d$h*icdj zxvx!QqWNVW<)u16S=BOaMc&-}U-@O}n!ss1JZ&o+t?h1uXhCDB5vc}}O-_KZoysQG zq^Sev>_s9T%@cnTN?Hzs;p3ZXu`d!;lmDqgSA8|6NpdH5|BZvw>#dc#5Ywbo+luB Lc)Q+Wc( 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 Date: Sun, 9 Mar 2025 21:06:57 +0100 Subject: [PATCH 084/279] add missing resources --- .../assets/input_prompt/xbox/button_a.png | Bin 0 -> 982 bytes .../input_prompt/xbox/button_a_outline.png | Bin 0 -> 1269 bytes .../assets/input_prompt/xbox/button_b.png | Bin 0 -> 900 bytes .../input_prompt/xbox/button_b_outline.png | Bin 0 -> 1196 bytes .../assets/input_prompt/xbox/button_back.png | Bin 0 -> 860 bytes .../input_prompt/xbox/button_back_icon.png | Bin 0 -> 673 bytes .../xbox/button_back_icon_outline.png | Bin 0 -> 882 bytes .../input_prompt/xbox/button_back_outline.png | Bin 0 -> 1059 bytes .../assets/input_prompt/xbox/button_color_a.png | Bin 0 -> 982 bytes .../input_prompt/xbox/button_color_a_outline.png | Bin 0 -> 1269 bytes .../assets/input_prompt/xbox/button_color_b.png | Bin 0 -> 900 bytes .../input_prompt/xbox/button_color_b_outline.png | Bin 0 -> 1191 bytes .../assets/input_prompt/xbox/button_color_x.png | Bin 0 -> 1036 bytes .../input_prompt/xbox/button_color_x_outline.png | Bin 0 -> 1336 bytes .../assets/input_prompt/xbox/button_color_y.png | Bin 0 -> 941 bytes .../input_prompt/xbox/button_color_y_outline.png | Bin 0 -> 1253 bytes .../assets/input_prompt/xbox/button_menu.png | Bin 0 -> 774 bytes .../input_prompt/xbox/button_menu_outline.png | Bin 0 -> 1086 bytes .../assets/input_prompt/xbox/button_share.png | Bin 0 -> 658 bytes .../input_prompt/xbox/button_share_outline.png | Bin 0 -> 880 bytes .../assets/input_prompt/xbox/button_start.png | Bin 0 -> 879 bytes .../input_prompt/xbox/button_start_icon.png | Bin 0 -> 666 bytes .../xbox/button_start_icon_outline.png | Bin 0 -> 885 bytes .../input_prompt/xbox/button_start_outline.png | Bin 0 -> 1077 bytes .../assets/input_prompt/xbox/button_view.png | Bin 0 -> 846 bytes .../input_prompt/xbox/button_view_outline.png | Bin 0 -> 1168 bytes .../assets/input_prompt/xbox/button_x.png | Bin 0 -> 1037 bytes .../input_prompt/xbox/button_x_outline.png | Bin 0 -> 1336 bytes .../assets/input_prompt/xbox/button_y.png | Bin 0 -> 941 bytes .../input_prompt/xbox/button_y_outline.png | Bin 0 -> 1253 bytes .../resources/assets/input_prompt/xbox/dpad.png | Bin 0 -> 351 bytes .../assets/input_prompt/xbox/dpad_all.png | Bin 0 -> 351 bytes .../assets/input_prompt/xbox/dpad_down.png | Bin 0 -> 416 bytes .../input_prompt/xbox/dpad_down_outline.png | Bin 0 -> 400 bytes .../assets/input_prompt/xbox/dpad_horizontal.png | Bin 0 -> 435 bytes .../xbox/dpad_horizontal_outline.png | Bin 0 -> 377 bytes .../assets/input_prompt/xbox/dpad_left.png | Bin 0 -> 434 bytes .../input_prompt/xbox/dpad_left_outline.png | Bin 0 -> 389 bytes .../assets/input_prompt/xbox/dpad_none.png | Bin 0 -> 398 bytes .../assets/input_prompt/xbox/dpad_right.png | Bin 0 -> 427 bytes .../input_prompt/xbox/dpad_right_outline.png | Bin 0 -> 391 bytes .../assets/input_prompt/xbox/dpad_round.png | Bin 0 -> 1032 bytes .../assets/input_prompt/xbox/dpad_round_all.png | Bin 0 -> 1111 bytes .../assets/input_prompt/xbox/dpad_round_down.png | Bin 0 -> 1112 bytes .../input_prompt/xbox/dpad_round_horizontal.png | Bin 0 -> 1118 bytes .../assets/input_prompt/xbox/dpad_round_left.png | Bin 0 -> 1125 bytes .../input_prompt/xbox/dpad_round_right.png | Bin 0 -> 1117 bytes .../assets/input_prompt/xbox/dpad_round_up.png | Bin 0 -> 1128 bytes .../input_prompt/xbox/dpad_round_vertical.png | Bin 0 -> 1128 bytes .../assets/input_prompt/xbox/dpad_up.png | Bin 0 -> 436 bytes .../assets/input_prompt/xbox/dpad_up_outline.png | Bin 0 -> 394 bytes .../assets/input_prompt/xbox/dpad_vertical.png | Bin 0 -> 422 bytes .../input_prompt/xbox/dpad_vertical_outline.png | Bin 0 -> 376 bytes .../resources/assets/input_prompt/xbox/guide.png | Bin 0 -> 1193 bytes .../assets/input_prompt/xbox/guide_outline.png | Bin 0 -> 1597 bytes arcade/resources/assets/input_prompt/xbox/lb.png | Bin 0 -> 589 bytes .../assets/input_prompt/xbox/lb_outline.png | Bin 0 -> 718 bytes arcade/resources/assets/input_prompt/xbox/ls.png | Bin 0 -> 971 bytes .../assets/input_prompt/xbox/ls_outline.png | Bin 0 -> 1280 bytes arcade/resources/assets/input_prompt/xbox/lt.png | Bin 0 -> 533 bytes .../assets/input_prompt/xbox/lt_outline.png | Bin 0 -> 722 bytes arcade/resources/assets/input_prompt/xbox/rb.png | Bin 0 -> 684 bytes .../assets/input_prompt/xbox/rb_outline.png | Bin 0 -> 800 bytes arcade/resources/assets/input_prompt/xbox/rs.png | Bin 0 -> 1065 bytes .../assets/input_prompt/xbox/rs_outline.png | Bin 0 -> 1354 bytes arcade/resources/assets/input_prompt/xbox/rt.png | Bin 0 -> 673 bytes .../assets/input_prompt/xbox/rt_outline.png | Bin 0 -> 824 bytes .../assets/input_prompt/xbox/stick_l.png | Bin 0 -> 1228 bytes .../assets/input_prompt/xbox/stick_l_down.png | Bin 0 -> 1329 bytes .../input_prompt/xbox/stick_l_horizontal.png | Bin 0 -> 1257 bytes .../assets/input_prompt/xbox/stick_l_left.png | Bin 0 -> 1263 bytes .../assets/input_prompt/xbox/stick_l_press.png | Bin 0 -> 1309 bytes .../assets/input_prompt/xbox/stick_l_right.png | Bin 0 -> 1248 bytes .../assets/input_prompt/xbox/stick_l_up.png | Bin 0 -> 1319 bytes .../input_prompt/xbox/stick_l_vertical.png | Bin 0 -> 1405 bytes .../assets/input_prompt/xbox/stick_r.png | Bin 0 -> 1286 bytes .../assets/input_prompt/xbox/stick_r_down.png | Bin 0 -> 1384 bytes .../input_prompt/xbox/stick_r_horizontal.png | Bin 0 -> 1324 bytes .../assets/input_prompt/xbox/stick_r_left.png | Bin 0 -> 1317 bytes .../assets/input_prompt/xbox/stick_r_press.png | Bin 0 -> 1364 bytes .../assets/input_prompt/xbox/stick_r_right.png | Bin 0 -> 1321 bytes .../assets/input_prompt/xbox/stick_r_up.png | Bin 0 -> 1370 bytes .../input_prompt/xbox/stick_r_vertical.png | Bin 0 -> 1465 bytes .../assets/input_prompt/xbox/stick_side_l.png | Bin 0 -> 565 bytes .../assets/input_prompt/xbox/stick_side_r.png | Bin 0 -> 654 bytes .../assets/input_prompt/xbox/stick_top_l.png | Bin 0 -> 1268 bytes .../assets/input_prompt/xbox/stick_top_r.png | Bin 0 -> 1359 bytes 87 files changed, 0 insertions(+), 0 deletions(-) create mode 100755 arcade/resources/assets/input_prompt/xbox/button_a.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_a_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_b.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_b_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_back.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_back_icon.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_back_icon_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_back_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_color_a.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_color_a_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_color_b.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_color_b_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_color_x.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_color_x_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_color_y.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_color_y_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_menu.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_menu_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_share.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_share_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_start.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_start_icon.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_start_icon_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_start_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_view.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_view_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_x.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_x_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_y.png create mode 100755 arcade/resources/assets/input_prompt/xbox/button_y_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_all.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_down.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_down_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_horizontal.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_horizontal_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_left.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_left_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_none.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_right.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_right_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_round.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_round_all.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_round_down.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_round_horizontal.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_round_left.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_round_right.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_round_up.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_round_vertical.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_up.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_up_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_vertical.png create mode 100755 arcade/resources/assets/input_prompt/xbox/dpad_vertical_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/guide.png create mode 100755 arcade/resources/assets/input_prompt/xbox/guide_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/lb.png create mode 100755 arcade/resources/assets/input_prompt/xbox/lb_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/ls.png create mode 100755 arcade/resources/assets/input_prompt/xbox/ls_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/lt.png create mode 100755 arcade/resources/assets/input_prompt/xbox/lt_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/rb.png create mode 100755 arcade/resources/assets/input_prompt/xbox/rb_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/rs.png create mode 100755 arcade/resources/assets/input_prompt/xbox/rs_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/rt.png create mode 100755 arcade/resources/assets/input_prompt/xbox/rt_outline.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_l.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_l_down.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_l_horizontal.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_l_left.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_l_press.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_l_right.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_l_up.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_l_vertical.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_r.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_r_down.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_r_horizontal.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_r_left.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_r_press.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_r_right.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_r_up.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_r_vertical.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_side_l.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_side_r.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_top_l.png create mode 100755 arcade/resources/assets/input_prompt/xbox/stick_top_r.png diff --git a/arcade/resources/assets/input_prompt/xbox/button_a.png b/arcade/resources/assets/input_prompt/xbox/button_a.png new file mode 100755 index 0000000000000000000000000000000000000000..2399fc263be2c40edfe062170cfdfa21370876f6 GIT binary patch literal 982 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDvQL(vI4BQEfIt{EJ z<}<0(?~<`o-hFwwJo5#;_@`>?-h8QHn4i4T<$`Df)xzmE{8WV?BZxwW1M2r z@Q5KskpDP?M_nt}kIpyN-x121< zXJYYc`h8uy_P#*{W5*O0dGVVb=jU%{n8I>5IqLkn!W--p zw%sY*tY9v!#4hZwbEN#!sm1P_Up>51zrWN~EcHb})bEyv-A5R|wcYr5hvE3d-S$lG zB5#@&O==GL-TzIHbDU{V(; zo3=&18x!Pm*B=4pucw7gRP zkGIZX(ODD(ubJ3l<|GL__D_qfYIHT9MZ(r3| zU>hbAjI6}Xk}&H8&N i36$P9Fn|;Peum_{^Xu$%m(2j?ECx?kKbLh*2~7aTJiwm- literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_a_outline.png b/arcade/resources/assets/input_prompt/xbox/button_a_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..8dd7cb9f77034b57f8b1695c3b178f4173163f1e GIT binary patch literal 1269 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDpk*#bgXQrNYTiBtd#Qw#SOFeRS067-Sd4VT>iPc)1!K4mZ2BK~ z_NuR|2Rln|{lU|JD||Z|m<75`V@tl4ZHzDEO_+J`>PK^?Jl4+)-)!r6K0g(kX7`4b z;r^!o$3wq-WjM`v#pEBq<4cD{hxm`)x#5ziWZAI2rlk7L-)}t5vJFq_L$9)x33qI6 z5M#EO>2yq}qnI)4-6fIdB9j;vrdUsXn|qOwtsqwCLrM@IE5{7=_i3$!F?FGTzUWfZ6@i>l-KPSUhgL92DDtj)`{=oYlCZ3azL^(e_5#%|^ z<(R2q8{T?t!4`pOuZ0@&_)e!@6-48JxQH5EpdfsfPv<$uU3b0ii6rhHD^7|J{{{m zBPPPh&dgqY)yYg30nZ zd1uvbC)ZdPT6?WIUnpy?@OWv*@^>GS7W}w+W@qfh#~0uAFHrvf%eD2!=C4=NDtDb> zn;A0c+y#{*Pd}RJr)#Z?%Y z#$2lOV9qeweV3!{i(=u0?Mps?-x(Hug=_v?(bT=i79alIm9cHrhRXPLGxJN2UY)x1 z?liW#FO6E~-~4*?Z9$A$u=w0n{14Vm{chj#MZW7pTvvAB;lKRd(ht~nFy=LYsXE5Q X2Rm5AnP+eU3myhfS3j3^P6zanPW!H8vaQ5mNGKOV0Ol7}t z;f@shfln+veoh~%m{#;3if-u86FbT9fSDnr;ULo<-5Fkat1~#6{&Upm-QM*;VGpxK z3xo5QW;TPWR#C|mHO2;}gb&FIj51OUyNtg-5M8igV5w~hA9kxPWm0mfR|BB^MDsqL>oh_ zRLe|}mRgPtry2ImR(!ap*o4L5%bt$7`dq$%4@nJmh77+|H?lRXW_(pw%99h$+@mM7 zeD((CC9R3ycXv&`7|L_$_=?G9g8%+LxA@JJvVY1N>7^-`mVBA`DSL8IWK`ek*r;{7 zKD)LV1WrryjGgte>+)w-1<~cZKJAg5@_ZKC(X@*T0=8rvSmJ8@>&){X(xRpht}olY z+^I4qIPi8!{2EW&yJAJlO@1<{RBhf>{o{R4-cIlBRz+-L=GFhruN_(P?%Kcar)Ep0 zW;dLXy?wVXmD!IW$2@WUpOcxJWbUe+{9yH){XjxZzL`x<5M%Sj4_r@#_zJ$;3vD#H z|5g1&#TBDT6Q^Wqq};0c8@wc@OFGkhLebs#E<0!RDlAui$$3!uPh`_EB|(!*U$&-r zs{j6JJhdcf(!b{{Gjs~mPE>JQhQ8$~HTJ#{<8kqXaJcc(yQVzno=DA5D&M!mHt&OC zsAWR;oAW21{MFq4PegF<_Bk%|XZ=q!L`tUi(jP8(+s@hm%0>*Hu6{1-oD!M<+d`uD literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_b_outline.png b/arcade/resources/assets/input_prompt/xbox/button_b_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..474d894f8b2976f5fd2eb083e9e9ea1c98086706 GIT binary patch literal 1196 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD+5n zO^X!-9HIps9lreEUuI;U<&&hZvvcOkrt2$Y7L;3UsHt>d&S2nOz-ZFIc7fqvFoXDk zA3r7TITY+?GmwlJmd9c{=lq_LT14wbdCtnyeOPB|b)l4=1q3vpu*x{r@h`eJly~ z|5r?ZvT(5npMyz#+w|R)zJiRb3(9g&m3+;+9zT_R!+M4Cf9#A~n=Kk{KkF|$DOdKt z?KHy-?{oi4#h;fn++`3wTd(|pN60{b)zSiuG)0L6YIe1iclK7c>G2-8kgwX?XT!3< zWPuCgy%5HWEDK^9;#TqaTDE#KoPO!|=!VIyF2)*B-kM-debxz#Yh+F?ii$)}8Kn1y2R)blbi9F|m6Gn}W9oWjEJUA>XL;+9!y%fk+4)|A+W zntCs03)cn4E7@%rzA3O5xX5Y$ymf>r;~IaWupz_etwjg7@_hTs_$ynphFQWw@`&0j z35Fflf_B_nIf17jW$}%dt*_(nGaR#N$V|CXz$_8T5XNc5EBNi{D~8XXQX4*`GHkAq zbf}kZIFVZC$yOuFFnfL5w7wqu6wYQjOT#HqhjN1Ngc=>SyDrH((fHH&~plEs5 z=i(gp1P|3IOP_arbhq4hVVQ^qmzT*dugB;ON>u|ebI)zOQ%X^ zpIaBIb#%EX?<235D!YqC_*Z@63Mn+dGSfc5k7yB>2j0xmkwb^fqr@}ia6 zcO){Z%ni4%&WgG9m!o7!{{Ddd_ov54|MLh=$=lx`Ja?Uk!1n}M@$`i6>slNNDt%hw zsvqW`nby2G&p9Gfi`g)HrivUdONcTjhslmDjg4E>Hgs?ptt+U$_$NlVTQff?QL54* z?f8+kiMtv4FNLXXo9H>+)M(?jm{}drQ$=FaG-0iCfQnyHcsKF>z6y zlRnSiymWgP&%Iir53WdkyKLEQuwr>%bmHfkJC=T4s&vQMVs0Dnh4bhAujIb0Ikn%_ zcWu(`nHR1}r@j1A>|`?g%8i=5>nfiPFG;Z4xb?Q<@hR*I7t@xTZ;!6Md;8z^-LcEr y3o76Koow{^&z(bwA#qnO{7byZAO+9U_H(}PN?DjPV=u70VDNPHb6Mw<&;$VL@Fx@i literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_back.png b/arcade/resources/assets/input_prompt/xbox/button_back.png new file mode 100755 index 0000000000000000000000000000000000000000..3318c6a5adfc5f8eaaa6d57f997d43d3fda034ed GIT binary patch literal 860 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDEaktaqI1@ zmy?>TJ0Uu zH+ekgo{-FR!LxwTL~XK39m7tU2W5@S4l{EW?O`afV|dGaz}M}e9K-TI!G8W%w?@SV zfBUNNKf83j_qpYFc0MUBJr!ch;AgzySb)abR@e1&eqGov8@=J}SD85se&=UA=KQ#S z+QF6m^}5{W7TjlJeWB0rjN_uL)3dbV!pVQ8Lz+`YB*?efV*Te$UV->ttR z?RA&QgC*g9mi62jeU=Ha`W6nIj8pU`JSCcI~U~@{f;UL2`%>xWxOHYNe>#XKu zDbU#zT>WbOWac;9Zt6Dt-m1j4z?Qd=b-^X(Gs(Z09K0F~H|gs+Fs{mayFO6-4pT^6 z;Q`hQx#F4qI~H*9yKFCH*fvq1xRHnNXoXC}&j}gd7D^O0dKE~>GX7AUIn&-f=7PKI z@pl#hvSJsGB?}bD%zu16DoJqa&0~L-#$J|*zOZqA_Vsy}6g6kAE$Lhux0K<^wymxb zFV6n^&pPLL(*6lk>TGrYZ>V87!|m{dS)fjT`5RYu3&tbed(?9>+>-x$|nMK}Md@hcCDm`0GWX`wlWG0|lXkZ_kz~!L-O>7@` zWO!Z_5hzwK{36rKU^v;xqD3(wT6*Q;wYMW3>?ZzmJh7Z%5X%P@om-u+ya7a6`@$07L1{qlQS4;UtC+4DPeGQL># zXZIiB_YA&M=W}KxJ6w30&ZNP^7&(1D_X+0%t4^P1UE}N!x=t;x*M`OC)YUUAKF$qB z_Tmm|4T~rLUEEM$su0H;v5rkfG(4>#QZwP%{IzC_9&HmW-2Uj%R?*G;iyxIpeC&Vx zs91)ZYto~j2_9aX9tBPE?7H+Qa7x#yPmcnoyE-u=7ZogOb*1P{X zWlUE8cK`JM`a1JBV$gTe~DWM4fZEH8w literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_back_icon_outline.png b/arcade/resources/assets/input_prompt/xbox/button_back_icon_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..a9ee088896fda59792127c27a7c3ec7e4ed52c5b GIT binary patch literal 882 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDrIOlcp4mC^_V{W^WR;)=So@s1eO9T>&buIKABFCUKz1pr-@Ob0So#k&5-N%Uzu}4 z?!}Cfa0c6UmuUvC{TQnJrZzb;FwW^(8MUZrEkkkhFW$sFrVDna-PgY|JbU}xef_kT z>mAhoMjEd!|B#WzI4xUv>B@h(N56gzVmFABOU-ub*;`Y-hOxqO{`Reb=l1y}En}YZ zt>4>wL9OLQCsvEE-nYeGsdp$aC-lGH6D{q^->$05dZ6@j{aaC4#s>up7dHPpJ&TFI zAcownh`35J9YnGvD+8?HsWSF6CwZS3cv90-w%}D%`I5+s-5vzwY$jo1#PS3*$lof`^&1Z zy;L~Vo@c{xB~GRV+kUT=FU)>_J(O|Am(+%bw;4X&W_bLWfp7Bd3n8L`!<)GfgdF`*FXjMPY%{1pH;pN$V@vJTmWyg0t z|9oUy;+gX3MHBLz^&1uztXTBLb-8#`K-L1+l~aAZ-T%(CRy~!P{#$d$<_423?WZ13 zRpPwvxnF7DlsLgCmwh70+J z*PUuS`uv33D%M)*H;YpGC!YO%WMN`P=rgwCWov)kK34Yn_><*M4>E-xS>~;B;}G6s zT~rZUC|Pul}e>S|B^ z*wZ`5qduEWZSMXn!MXljJ zeAAfD%%gjMzhDoiaYGB!vM|T?tt%D?DZFcp*uA0s_+LR6)(cyC_N0WcFfzP8@_ZVD ziq;i|2Em35>FQb4j?)=@*rsh`RWRJ$#<992ufpJ>UikIU-R=9lE9{uxyi$G;mn<+* z+r{L;oSW}@bk@YryS{sV#To1D1HaNv1iP@yF__M_{8q&(Hn-^Rk;JR#f4*P-_=WEI z+zhb>d(#IDGn(H#;ZQQ!u78bx_4J1a7!I{>sAtG2`d4cpXC?Gmhp*G_d75qgiHp0; zr?F0$w^eRY$^6^rv`oLVCiFU_F}j`SzS--^aiq2{%<1k&XvT_ zGCL$^es=twf9LcWnB1P(?)|#T^1#~}sf;%+$tjm|8A z3|{BIo)T}=VtJ8t{KGdMMvm(qGu4|cWu8s3UbII3!aDUYt^uz)lGa(gW8V^a|9TSt;FLJGm9H({u)o;aV5hQduSIhhi;8>I&c2eu+J7JS2;DsS zH|B2cJ`077*LQz-6{5T%IWOqj&!~r zk$1_|lzHASy9t*frJE9cm+qcubhpI&Skwvow96ZvRI^0NgD6re{`Eo6NRJ-xl z8OzA8g?nzPLYsDUuUC00004XF*Lt006O% z3;baP0000pP)t-se6|36wg7y#0DZUsez*X8w*Y*&0DH9neYOC6w*Y*%0DZOqce?<6 zw*Y;&00000eYXJQXy*X{000nlQchFPkFPIJUvD4pf4@Hu-=6?3wDXMs00S~fL_t(| z+U=X$a@-&gM3F#3T+RP~ZJesu-p$I;4TDpuVqR^DBc{1duX_8(p%%abSO5!P0W5$8 z@Sg(2aJuY2BfB%i9|O=XU*#sQ?DTwqkW;Bd%3&seb=B7YFgQC2!0rW%-A(|&o97Tf zfVf?SiffCK045bWk`V)lxpYO2G5}VU&QMkY;3xxej03nb6mAqCm%+$m0K@G!3Dms& zV>(dN+iwum089}I(+8k0DS%7&07S9&-w9#|fG>jKc>q^L!?6Im2!|#Clr(_i09+9d z#{fuEnSk&;@E>2%H}eoC5>l4gehjpR$hV zK)?VDpHfsb1_D6e_HYgk{W4VD8Ay-Mr0yKN0t8lqj0^(+;8+PVGIRid?@A{q91j5W zbtam)Hh|_NXe}p0-2^x|32qxF!=nk{I0;@CCqvf+*eMAfU*CkuaBl*5N`m{>D?{Hf zcmYV11RodKM9-hM0F(sXV_MIyao7hCA3r)#G86&G&kMI>L9Rd*6HlmK}7U`B=l0QoiL0hgh`OjxOj1c8~*({mX$kG26aD#6=0(fkc8$JIOd zrVQK5v0*=f>p(NSC2N%BS~Aq5o6^*}eov#D(#9}2TL9EAU7K#2a`Z7#CP(_ zqervq;WT`|bww6s=x|#5gjBo40rrO2@!SB%k-I+vM-=RWqLPDH`;Yb!1-zp9J_ose zd^^zRyRYd&eX8&+8KT#8Z&aloj~%`#)pGzeep&5jwC{=A@+r5~mMK_l^6hU_#Uv)_ z_EY3)Mn)RIuw}dLM-os0kWE60eC1S2mTq-V@!HH6K%;ywRy=gQ1_q(xRqnTN1u0W5$8umBdo0$2dQ0N+{9u5x+{LI3~&07*qoM6N<$ Eg4fEZApigX literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_color_a_outline.png b/arcade/resources/assets/input_prompt/xbox/button_color_a_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..9699da0f5f9c258ba96df29944d9edec328a9a15 GIT binary patch literal 1269 zcmVC00004XF*Lt006O% z3;baP0000pP)t-se7FF7wg7y#0Diate7697w*Y;(0DZOqd$$06wg7j#0DZRrd$j<3 zw*Y;%00000eYXI}RPD3?000nlQchEE&#zB^-!G3}KM$WDzwZE9ct};ei49_7NIRV|&kihL3kjjn{Er*m05`x5a0A=`H^2?> zp8{Az(uK7-Nyh#)fQNMPnP}0;$M*q@o1RRYocShzm+a_o2I;>9;KwJ}M*jr>eE2#9 z5WuQ~?xyia_-i9M9yTW`+Y>;1bU&!1(u^8D5Jfit>-PN;Gc`+lQfK=B_}#?HR%Ujz zgYN;Lw+b#z_R~Q&(%#w zNCyOsf5FTBcuk0a0}zWfU3B$!v0rfwV41r?@>;-_(vj4|b z4D|rZ0n{}94?*cbqQVcw+}K26A~}G!d{lef{q_t)9f0J2Rn-P|6Ns~b76Uk$LdFt^ za{+qp(lqB3k^sJBJC*Hi(H;hHT`cfqQVs?1m?vMp%S$eyc|qa9FfWA-xSR+Vxqt6>R3deON(I0Kkhmll>FBt!f9 z&Hw-sT-1--Y6Le-0syZssX~I#j!|Y_=Yc>Q2%i7}w1YB0?!$}(rwNr|W@40H+JH)s z2$f+-Zxy9As89)7i^@=?Rd@mzAr0#7;^#mazLsh%0)&jcrX?u0sI#J3W^5hW=$qGTKFFjZJ)}XWyma7tgY7Wa~I&>-> z`=!%xgfAxS)$HJi&m-hySaCIDFQ*}&zvP{A#pU#U6oq0en2shhG7J^hbCK_7y^iE% zmX@KfNl-8;n)N!uY;#hERVkX~Ys+0nm|R#wJ~lrms>x|B5;UB@OUXyprRf%n>XIqm z&1i%(r4KMui8e0tN}RcyKI0qifFN$oCfj3tJ8?Totj}>BHz4Dsh^OxGVxale%(-3B zRr1ST=R#(&tpPdzDy(XZ;hl*&ns<3%q zu9i9Yf;1f4~rY%2Dkxk ffE(ZjxB-3vC00004XF*Lt006O% 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 R8Zad?c)I$ztaD0e0stH4WxW6Z literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_menu_outline.png b/arcade/resources/assets/input_prompt/xbox/button_menu_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..1848da3400b7f7ba2ee9c5ceb90721bd37a205b9 GIT binary patch literal 1086 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD#8t!5Ke3p%O0i#I++XV(G2j(CC zjB{9@o;|&X>GeOU150Wml*IPTTF1U2POrl=e?7|uF4@}DfL}@sclBLfe`RI(^6<1k z2SeVE57syBW&Wow_|<=wvw1bA0^j=gZ7Y`FTxhM(mBn!G{eA8eS7hFJ?p@8ipj`O0 z`^>u+%XYCH_)+)Jp-9(uPrczM#w)ispFDYOud-VqV;r|uchH>LpH0she|&mbuh;aP zA?LUJv7I-LIecPP=&gS=rFzaR6($~soQox&R+LBo=_+Qp&2z5rzuJK?1KtPMJ~q$u zpMCG%Lq7(IbN}ksEz)GaAtaFfhuu+6(eYV(QrfH&J}RsmcAY=4e)sjs3$>X8eys0W z<802jf#U(ofp85KCB_ZP33FE8=#b?KWSHAM*C|>#YBFQZRE|Gfn*OX8K0fz8_31~C ziJ-$#`E-UCB??CvoN5kAFuWFGk!tX$U|>)1dKT)j^FcY^BZf6MPfTWTQexl0aO{d) z4fBor;X8IQy!-xs8pDDh&koiNYRaw*KWaBsedZGS+;qfc`7MUvq|An23b%!Xn|JJ< z^@~AGE=J({!G(4Q1il~AZ}=xAe`n8z^Gq{b_G~(P?E5!vhd6G9%?vLU9;hz5GF#e# zIfH?dAy(c&m@)5s%Xa1;+5F1?-^(9=bgRilz0rF z&imHv`E0%Y##{cnHvTo790rP<0!lNq6C5^ko>6HMj`w2D{4=M+^`onfq$2-?PZE(* z+blOM@tV0wUP0Oa#FVp7Sv03FIlaj4Tkb^BB{9~9XD&-_o*DUmO8XQUw_j{`IG;H6 z|7v9K0bf{`}qu$E=@ejz`*#<)5S5Q;?~<+ zw>LEz@URAmyx6<(|NpJWPcKfGtY+J1Y`vZ-GAP>XQPJ-r9iUNY;J|r?q#LJ{qtxfI z=PJ%(N?NAIcj_XCK(RCr-$L#ivAKr>@2+D_as8#PxSC<3N$C^|K8~%DF1(8yuJkDN zGHy!~yUn3se)vR@L)Nm1OBf#idEO-4u>M-ZE7gX(HveNca5H$%7T}hjaBGT3OhXDo z$CTy`FTxad-SyZmr@Knpq5rXGDK{U3-?Vt80v!j@XW|@M>;gxAvNj!Hm|*k&fv0+d zRf(pQY_oZT{2wi!#`jF09;{8_DiS|1yT!fx)eV;#<|#WHF0sb2`ilp?3Xj>{U?zS+ zq~ZJd{&Npwfx&LaaBXKp=thB$%VM6dSQOFa%i#CXz=$#B=;IqK4@9MfxE-$MoYY}h za&w9o*M?cXFJDSXFhtz`blK2=L4aG}Iiu41r&FFBHM{rQ*6@B`axjNF$4Ib^5 zyIBtl@21S1STA~|;}1{6^eHc!YX2!{hgiM-;}^9`_4XeX{jkgXpZpJ5XTC;k+5Mb| zn*FK8@%%co!s4!Pn|)n7(M)e|me~aJjJt7doF#_Yar}S8x*|XS3O&dda-(>W7?<_u gLqFI-2?q^)U{Bb1PQjlkn-e7A>FVdQ&MBb@02xXhaR2}S literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_share_outline.png b/arcade/resources/assets/input_prompt/xbox/button_share_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..9f9141784512d27c516c97e0583215ccaaa11610 GIT binary patch literal 880 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD4q>EaktaqI1@ zo0A?Z@VI_#y1DWH|Lo<~X+r5uXHr+rdZ+c$^oG^62@XL={>^taU|`0A4y;<;zF}&@|E8HdP5Nm(` zWbWTdlRWodXL+gT?{<|-AWmO!nc2RrHMX+17^a20%$z2Y9Jg`z76zN<^E0pMq|b}a zSk2Dy!ffAE0q-~g)xX*CtIUHN8yE~aYpzu-n15B*)+_Cf&5>(C>*1+)uCP6u$cG>f(C%*7M}s%~uPJ9IvcpnU@im zvBhyBPurE_6MoFjOWc=lB%jgPb)xLMZ6!l7kIIkeBQs{1#h!9Ip5uOVzLx0gTe~DWM4f D<^PWA literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_start.png b/arcade/resources/assets/input_prompt/xbox/button_start.png new file mode 100755 index 0000000000000000000000000000000000000000..907a954a2a1b8f7635694a65b4b5bc433e1437c2 GIT binary patch literal 879 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD4!>EaktaqI1@ z>(g2kc-q#AXwLp#e`~9blCqcJoXyWw%##m3lvX_HKjYc+)7KOjn6RJ&`xz&ch;%z1 zxaOuj)s<02eL_ylX(xtB&dZJ}urOX(qImnul6g!Y*zd0A_#@aL^5I6y8TJKITr-yK zWMCHAq{yGpl4SObp+F!)4YR}(bWCufFEhTAG8_hgw0CPxZ3+`Sqi&YLhPQjj6zX}ebm04xQ?F>)N^C~Zcw}{H8CwGhFd5Tqf z!zOH4uC_gOl0u>C%#*Lpt_cjCz&!tc+KqFC)k|FJ@x2nk>ISA)2@6! z;IMJlo=g8@ckWBrTJU4OwAlUon`SgTe~DWM4f DqUDO< literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_start_icon.png b/arcade/resources/assets/input_prompt/xbox/button_start_icon.png new file mode 100755 index 0000000000000000000000000000000000000000..ac6c97fa6ec6f63e8d5a991d889f890343f92acb GIT binary patch literal 666 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDpom-{{GUA7=s@h6eWVDm-0V%j)}R zN5<)sA`?z26!uK&VOTlA$f89vfq(9mi-+sP0{oWPb37GMFtw7^eaX0Uvg4OEtRY5S ztC(|A#AdSvoG|E;2{>u8N+e;<|E&*L70R+5`h^bU&HJx-gSElh^2l8N6;Fhwa(}2` z(3d~-D7x|4*Nltz8p=X`)Yw;Z&tqVEeY`GgYQ}nz2!?yoAF6k{T(E5LF>3IbAlabE z>cP^?_#&yn;llF+#f#nU07(cM4a&c%eL<=lySjJqyn#&b2g<)C3OePQR1TGoI zJ+Uq73URz1;mq5*8LvK5joHc&_wVXjfzZ`XOLUH2T_f_^&h6^EWaLa=jPgpe0q;pUNu;B@Wr=fYCW@Weu(<0XR`U>*K&62 p{$p{%dwcg7*Gv3gfEHmOt{vk{?sG-@%sxLrJWp3Ymvv4FO#uCLB`4x;TbZ+DxmERt*P6u7Cz6kp&EJ z=7;+Xd*(j!f8%sVZq}OjTpzq|tyb3ECwxG^I$425P{8NjY~|Snq8nl|{<%fyG1lDC zcmML1;rFlS?wiltS1f&b-gtGV{{A!DL^sIkD~9jhx7V_6?>5F|$*w!6i9GJxYQ2?V zUbp_PYdX*81!t^eU-QDGPC9#%&$CzF>uqXa+@U1@r_9hvvh7V4!@?cKH@4+9 z1u!o7{>tlMz%(Pi4Ys%Ke-*bfK4sr_ec|FWD=s@K*fGznJ3g=Vyt{fs!FN8VbNkN< zGgWJDignNl_h4Y2@I1bZZCh~XY(~!t)h(B03h#>>F===oSjgb>U}vRok7=F(gRsEm z^+$RRL@_q7Op|0z`lWS+L4nbDQ^`KwV{5q@I3E2x_&~m4VxPfs1_y(=`Yu-Ox1t(! zHaw5@e$JV;j;*8V=eH{wOz-k#kAlKB zy~8ZJhaa973Vb!SLn^rN)wHhLX39HjD~yYDZyR3tH|^lt9X1iA3XZqh`Bk2JOw(fU z`28mPrt`{n#(>{XlbJ22OfxjlV946PhVezjzaK`zyAsx9?Jsd?Vvwu){FlwRS$@JF zdDk~JFTWT5iMh@B$LibD>|5Wn{ujDFy!gA3)hgk^#Cti{zo_Ll3G?igmB`XtS>%53 z@v-w)V=kZVPmX(W_5YnY)AY2~b2;rSNEVj%{7|@e%fBNvu1VM5E~~VZdn#9FG|{GV z-}}|Kk0nYr_N%Q)nE1N-;C_+aQru$uz0DR!KI{(OK5L`<$!A+16)ep7de1jPEW1BD zb@$uwr^^+eWC;iCnme15Q)7F>(bsJ|1$K+Be{Xqo8Ao_wv5%Oq>@Rr{^n?gXlMlGx Y$n5?2#XhG7nD7}qUHx3vIVCg!06}S{n*aa+ literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_start_outline.png b/arcade/resources/assets/input_prompt/xbox/button_start_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..ae48df9cd77928037e4095776aa4cca2da6b1003 GIT binary patch literal 1077 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD)x!e}4Xc{`U-LRIK(hFfdQ_ba4!+xb=3{ z%}t9H1RM&J+5i4uFUxys(E?o~Q`6A9`sQjXaxbU8^L|#Z!@?-QfCc@NX0YR{n*7wU zuDflbIqL&2z4fkI_c$6>FAY-Q=x1oVwKPh{G@kKF`|bY%SJWBSO+UY=()NIa{k~;c zQBO0yx0~8ty=eY6g8M?dyyBa+^~F;A?p|l)Ik-5&w;yTX2Rr!;^WxR2{eP zHj-J8U-^Hk!<`w7I|C2gSoPwDuGVUnpgxB!u{Rf=`q&|2z92iY;^iZ;6ozjKc3W8< z9Q8fRu!DC(Znpn)aUGU~k_~Hg8T4C?_npIVpv)|jC<-Z)bv+V#xlh)3Fw zZ<@XCdzW!eyFTcG&dJ?t_KB!QNAZciDifV6n!>YTXZ0?lPcC0M941fLXxr(hlm6S} zg_qyt66@FZ=4Vtq+h9~B_KRbIaQ5DJQ9o|R1|Qp7yk~x0-ABE@914>(w(Nao&hYSk z-O+A_;3UCRhUGgY)NSwDFiZ(8_?4<~V8fAne_1A6JK)Q(XLTeWn?;bHa6>>ynHqb6 zM~Uij8GDvk@P7(Q3b&umb!!jB61t&FpkKgSr7gt_* zmMJ2^%8y&9@MGS?sfBxl+}A3k&7XB?>Uyq8wr5XoaTop6xXHcEK5pm!<5zE8dwE`4 z-=163%U-^Fb33m`;I7vV+gK$Yr2Y)d`nZ1sqY%r1>EF~RFZ>a(q@pXc+~V6l-Nxm~ zby|$GwrW^xaWvG)J%9YuvgjG_XP5Y%b+~KGzw>gWbkpL;$M0=k9e_vjoKmL9F{`m~)GBe#77?@Uhx;TbZ+0&N9@YdSPR6FhKmYu1uiJ8~dx~sd@7+A@Kf9&uR8`apq~^_!Nn{jDfTDdG3uGFr zeuvII6lljYVP*KfmA5p1Gxybf>bCm*kkjK!(U;nFxXPK=0!{#&l^xKQD?El?=w)uhG zKZg%o0ykGI;+wEqbU_;Lf*HO4^)$E~k`HGUF>K77C^F&Zj!gm|Hn7>GFeH6e;ymz) zQ^JPx;A++dz7NGFi1@M^s7qWB%@q|W5htheR-~V%8!!z!T|Mm-* zYfG3eTz&h){~$}koxY1_9aR-pO;2%aSQ9rzit)?oB58(AI_mxmpAw5Im?rpdPXEg6 zAiw;9*i5d5+IjBw4BH$V?=tf;+*x$+U1OT(we)Q-br@cl-7Wsk_~wIO<~PQan$1!b zvi$z7`fqMLlWREB zv033-^?{R%)I7|)o6fSENT)u`O5C!$<)HCTjzbf6+uWJA?qB~>ueaes|L5r4o3Ffe pf0|3S$mUneuhmEB9AJVWY38Df+qv}TMFCSZgQu&X%Q~loCII76e~AD9 literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_view_outline.png b/arcade/resources/assets/input_prompt/xbox/button_view_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..33e28fbe638eec9f97e3d50959b7c3a67cdfd14f GIT binary patch literal 1168 zcmb7E`#0Nn82)^H;}$~5=(LKWGWSc12qn>!q;bESTg;ugE4Buy9kiQ!doVgtp{zBj zyVfPlp{}P5+ZfxzsrH1r#8@yI%g)(9u=kwzdEV!o=lSt@?|Ha6qmbv3000z)O!5*l zsCIie2W; z3(M0qb>JxOGC^y?-y8toEeeU~lfArM_r8{;C11#D)WY6A^B=h4o%lmRIr_!^mc1ao zYFcg`L6=)GwdRA14R8iS;WdOGaX_vb^Z6Ra@y|qTs108j)L78E_zGUa@ye~7Pbh%J zG^Z-+be?g7}N$lCYumw89*ConGB!&Y=31dZA; zcrO*BpC-LKDrAF{$`73O#y7A!M7}CIN7UCMt`)^&tbA+83N&jbB{XaI+wfH=_SC=4 zs-X_lNR!o9=Tt1}K>#wKS1)n2(ouP&i5@dL;~}4U7<*mnuIyOI%>sef!8lpc?0eB$ zufc~uqm&3OFTMdTe<*o@OFcAgMvklmig5XQwLEQZ!grDz8iBSVtI^7wDiG*OCu2lF z+HHXXZI`*92%Qd++K%Tg^u;Ju9Gfr)O^G_>P~Q-y*j!g!$nuRE^0+ZRS$5VS?( z_Xd!32jR@;2{YBqV^#K}IrF5ML(96HYoqCOhX`BbQuy$ZfEurqL!ScbKcPb9Rq2-L zasEBo-6!UJ1}7KFWohU3*wmYJvBLrx5tvQQq`YB^2eWElCtxpW*=guBFYc}5{8vWt z)l7$uWp`d=)e#y^3zU@6Fj6sV;3BM)iQ||Z(s_>VXWTR;Dsd9-#yM3LUX-%)Zgv@m zC$%vaDiJosY0HHu&+43kIaE_H)_k_lTveybMqg3iytoN#*DNw?>}{ARH;`=t*tn&# zG^??O(2x2^duoC6$Di)@%WlP47Z*$o31ba6PD>N)JPmC!Eh2}0@hr-c-_WEkeXbLg zUEg1;zb!OdWhssn|4L6C2%KLQCf_>w(R6il{lnAwopJ4l3s2hP`DbH||FxAM%?Ej4 Xa(mF)hm?yG{{%p};YMnAq~-quO_>)B literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_x.png b/arcade/resources/assets/input_prompt/xbox/button_x.png new file mode 100755 index 0000000000000000000000000000000000000000..b04ef414e98dbc15274a7525c3242778643c1e39 GIT binary patch literal 1037 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD0p`xmLntP|TKVCd=^Q zX(fk(LxARHos|ll3$CBp7TV3&GGnz4nv-v5_eXHXhHU!j8OLga}O*#`e0hLhJBST=sG7i!^Zu;spWj(tPq6&>*d>4~3x z8hoV^9y8w1$(zQkAj#;W;&mR7>yO zb=oiVZ*$!ig#(wfa~1}y*vI8pq2wXa;I{3eXm{O{t4}+hH?_;p`(IGOcb-@M9^Vv; ze*b504~ix@UcLHoi`8PQJVpkcRk{2A=5nv-+r)5Svw-KbD8s(Udn(Heb}|K|e*LuV zdwgrzA|{5FF5j+qT$Pc1?#Xc6>*c#Q(@O*kRTvap`}POET0BEqL4OZ}!div!`U$f< z8C-hr%bP4{KI6yaaCbpTa0IW)<1itHD?7v+#T{gISqlD6dEw~A^vma}=oKJf>$x-Qgpd8q9PHaqXtHIY-1A^dLeVn%`bu@TprS?qQIZekuFa42q_IPpcjsvTA*R!{0 z=LOijYt&->GyltiThS)xB~7OV-FsgmV4Tmcb*p4P_t{ow{)yce&F-?lUM9C)^4*kQ zb_{a)Vef6P-Iu)kN$+mf;hh@-KE2ky*6SIu^m;t=`*R-N%o*>t&aFDlX2WnI?~v3F z^W|}$UR}E$6ZiTbpF_<3?pV27w;0(Lo?abce@JlmdVk)HH}>6^mw22hr@YW@<_Sl& z>*cj6Q&uY~uVq>sGlxy&uK(IO>OHo1=9^yFafQbu>GReP9_qXQboQ0hT~oUkpl$Q$ zjKu5Mgq6FDm(7lBKFj^2chAGDXBp))I%eEadp5zke#hM%5fijDtq#}4+^yJuz0AJy r$iC>etL04p9Zmw}5(5Tsma%8h6FaAHeZ&5<3_#%N>gTe~DWM4f%?;yp literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_x_outline.png b/arcade/resources/assets/input_prompt/xbox/button_x_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..e5dcc05019c60eea0bf3da2aa1ce16deec8a6a5a GIT binary patch literal 1336 zcmb7^|3A|S9LGPKO=HGzOevNxbs?4=^QCXoFn7Mpx6MW7YeGlK6@B5uxsBtDbc~hM z)S;q$$?|m)VVOwQ$wZ@W9rCSwne2A`2lskB-tXu8{dm0JkJoRnEQ+TqN_Cqm001aA zGRa3l@V`PRDf;q6{+a@?D53`u03NWA8=-K;j;FZ$Im`c#6i z2XNtXKdOyxXn~sgbt+3pU}N$hG#f~7*E}>p>FY_p^Il8&A zziDt%;Af&+M$q!P8b*@wQz#-+;}w|cm?b7W!NAn{i*wx)VoY19SwFLHG+F-sG4PzN zz35iB+|X##8?@8(=;1BU)ms^F%T3m@$HluPEXs^=TCz$Ahh{zU<=Mj)Gf@2msPmwF z*kxSW=Y{D#gq3RJ_IcaV1Oh5u_q}bfWsIO59jU)vZ=dirbxL`iF+?kYp({|OU1U{U zqZ1Jt5M!lr+!cwd+wlW5M-6n(DF^UiKCFy%O4pc>wrAy{CRW=xs=rAoB5bOw<{HF$ zU#%8<9PpinE-Ux+=M@}Tf#bGp#a*s21FD+q%#A*Jt7-FVNs$A&J+&FJGJv zhJ;8K^pab0F?>m+V}R@Wn)Zb@Q6R@O<);7LJklxa``u9GVGe?-u4lP_QvL2irID^U z_=(dui-=Og@FIq@lEI@QLd4D&!_*$qdVbi2NpIRo1P1_=h{1O}dl z{~`+deO`aETklM==T!)_d?jqnrCQyvbj{a8*EU5kvF!Tt@0`Oy4uvzKTP!6Q9=uEC zP;i*nr?n~h)GUU&wMo%?r5yCOp7grL$Pt~pMK^=t&DO);>KInVy6t6HS{C(^`Ng;M zLKeF}AAi?q{QbN@4C9YgDf@U8WTP0|vlvfg{i_#x!qyNWs(XrY#VRRJ#-Pw`Dl9j) zFq%m+gqA9EHawF)u!r-U7V84fhnfy6)Wi}Rl9&Sm8V(%2`sa)6O(XHW%p49mbHAUt zxW1u2)?SFC;A?u$PuE3N{uV#G9von>DeC!sdP#gr<;|1p9=vS+Zf{}hUdB834NF|% zy!krT&Gn38TXtEMt~QQ3uFJr%(DUu%eYujpi&+?4cI^5!+3;N8yiOKD27xD2?%$hd zs5t!vD?@bWoc;O#Kjfuu>+#TGxX+#QegEF>Q#0nWDntg&RErc+7E8!G_a;DSZY0AB zsr$2~cb;HMxV&KR)`>BZ4cD}!*FN6LAa~n#$HXg46&gh!jiPuT=qaS`>A%Qq!LH&c zzm(&HAfHO;7Bz>rKhJz;?bycf@i((bbi<;Febo#XsVJ8B3qC%oOco%sNp$o9ir3^ErVq${v$XeICBV<_<{ z7tVcEIQyYz<|amu9~-Qqe)KnG?mW50%0orT@tfM^3G9}_1^vwI_vg(T-dRNL&~sLSLdb3a{Exv=h1nS zI-?RhH#=P0ePC&p$covnhn}(S>NP6d8gOf_>}S6`KaHa4-M0_N#ZA$2zuEkwF!$5H tCz|)}c1WCDzRbP%{|0D6W;6h%YJOI=i&-ms-_|k!fv2mV%Q~loCIC5QwLbs= literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/button_y_outline.png b/arcade/resources/assets/input_prompt/xbox/button_y_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..affbcfa3c02005b8a81b942da35ce8c53511b828 GIT binary patch literal 1253 zcmb7EYf#d86#el~NioO`dT5BY>8g=YQtFn0De2@ZGb*!EoU+oCDcyo>`im*Bw)vna zQy0cI&2$zjMSK+GBS{T>lp(2TCFW}?Q%iGn*>C%G@65U9o|${*&i!%(VIh00E!SEC z0IUN8{K8EP{R4P{FnYunM|g2TorfLG+~p& zL-r?yW=joMj%)3ADc!}GPhgw9^F?-=q@Wq_t}@|~PTw8tM^atd z%@#G4Rg-Re1EF$38@!nUTIT9bza$9P_rh|F?uubTwUYoKqIuhK$7W_fA7_}eDkRB^ zLh$yPC)7W3;T(xDbup6!Iw&JcHCf*FgILLfIeVlFjoY-UH`416(}4{2r)JJ8E-BX- zcYw4jZt?9O9e@!=S}vf|aLmdW>nX<%l7XET#q><%Al6#vxT1PhT@oMfX$f(5K7U6WOWBn##$;R`nF;j= z*!w8<;jYt3S+{C5_g622tHy;nzkl74QoXqFP-o#d&Q+7$%K|0~Nlhzf(mhn%LtST; zEhF~NJIX3vWzcj{6o*zlNmJdnfxSLB5aeFJnkq)<_%)xg@&;QKUq4s`c`qeq#M)5z zEKpo6Y{^y!h8{nGu`H>4;!U0sT(lt$BQMa3pG$|FZ^3mKxG%gHKj;{6YU&#a$|C04 zsC&RuVs675BPg}&nBvU>x3%0?F!e0RLs7#UY`_DXMK8!9if;M@Kan@CKtr5$kFPa( zT2XoXg#iIZJ;IY2AU_#@M3l(j4L5^nS=80trIAo>;cZFTVKS9|Cex|38LW9Ry2-zm z1Ze`aYvz245$Zy?S!A&bQtjGlCzelwgw)`igWax3Ec-<5dc)apmsy5Kp4+`PdKFx+ za+N~33kM43a14%lf>v>stQ^(};P{=DUdZ&Psj$M@XSV6f3@>$6ZIShrU$-iBZ{1b9 z6=T)gTFTo_HLHCv31?^dr(LU?~xfay(mOoa-U6sR!u5; z(w zk)dx>DJ$v6NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh0azWsXn`ThGD4zHCC0g7Jsba4#HxcBy^Azzb$L|bB7 z--9=*DJ5Unidga()D_ko6ENq9^7Vh2wqU<%%|(j`zu(qg+nzc3bh|fDa~S`e?OW^D z-jh#Fc&z?9bivnyUVz0NsoW_CeJ-mWV8Ps)7t4_ zQVN$F4l=Z|Suih=x*&O=8>}qC{6fZadA1cM5AqsfnDg$R-#sn;`?IzpD;@j#yM_KN zAfqNUsLguZ>L7adqmx4avmfF9j}zC-{jNS%G}T0G|-o z=Z0?2joe-s02!{&jNG0XIz2Z6F@Rj45}?#P`LIwRi>oBaFZh0azWsXn`ThGD4zHCC z0g7Jsba4#HxcBy^Azzb$L|bB7--9=*DJ5Unidga()D_ko6ENq9^7Vh2wqU<%%|(j` zzu(qg+nzc3bh|fDa~S`e?OW^D-jh#Fc&z?9bivnyUVz0NsoW_CeJ-mWV8Ps)7t4_QVN$F4l=Z|Suih=x*&O=8>}qC{6fZadA1cM5Aqsf znDg$R-#sn;`?IzpD;@j#yM_KNAfqNUsLguZ>L7adqmx4avmfF9j}zC-{jrxWP!PsM&_GSk!QwwJ2PXqL0wqCy!Sd_v_s`G2U*B)PUVeW6 zeuna`_uYVUKRsO>Ln>~)y>*kf*+9TGaC7ow)pz@Eng~cJe`4u;mz&V{$6R@LQ86D- zEeL$5Zph`byw3krW|=R4#H++^sR^qBFEQ1aehzZj!WhUkBlmy~>y literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_down_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_down_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..f31d0a5c8a0ffcde2b4c4e5888fb654d15bc5c5c GIT binary patch literal 400 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1F{QUL#{rl}1=9NxXWME)q@pN$v$+-9Sh9TD>1Ce7N z&)7DZZ*YI{?$MhC?i<84O3E(;29&IJ+wx>e*{006Pc{63OKR>1@64Q>ShS9&z%Hu-GY*X*Sr$MnbrzvI33Vx)M1fQy$~DuZMkpx73MR7Kn)BG ze{2m-Y+T&Xef-vvqBD$jYkZB4>s&Z~OQrY>vo0f-Yy#(mmkn(Uhh;v0PR_me|I(lR zEMRRloDrVy<&zxVhExkIxWZV@^u+3c)(LCtS>HY8&AfSn`>DzF`}=cM`*oIm+Y~MZ z)*`_lpkA!Qaw?^-)nVS5kL&Gv+c$2z)&JRX&0ZOA#+N`f22u|=*&h}u2^S|AfwXwK L`njxgN@xNAC~~Ys literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_horizontal.png b/arcade/resources/assets/input_prompt/xbox/dpad_horizontal.png new file mode 100755 index 0000000000000000000000000000000000000000..09dba4e577b31c522b8e8e0bb4bd545bb24b77df GIT binary patch literal 435 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1!2l#}z z{s)8SMsCjy-GIy&1|X5=hG5dj^_dY!5D1<7wtE1_nk3PZ!6Kid%1QdGa+I2(Sh|lT6<5zW$m= zBy(dstWYpOk=uH|ME`2`OF3-neUblVka_1H1i3BNnc=7 zRoJ4o;5t*4a6lI0QcjL22PEAL2R<|UaTlm1TxALqHppiD{Q3Il&wqa?E%3L#xTTrR zU`xzNWYq<-58nQaj$h- Wd<%a5J@D}#NXXOG&t;ucLK6T=O|5zW literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_horizontal_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_horizontal_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..5f7094ace2626fc293f06f49b32edd4fd2912008 GIT binary patch literal 377 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIzK_Wk=A_S;*k14Z9@x;Tbp+AP{xp#v^f#g2`WD>gGXuI0a?FyU7NYvU1iErE=_Gk3S;g~@cRVc`%^aCq>% z(XUUHWr_VYx6_3@8OLv}ufyXJPbP-X?Q!chik22QpRMg<84uJOph}+#+?1=pb%XAXYKMr q`)gu#ci$h|H8uI)MxezGGu6F_eYU2lEp!9!F7srr_TW@bg3pFbUum%c0mKJFJyWW&rK-PK2r6s$< z3+moq33+`q6sQ&i8tyZnnf%#K?bAKq&2|g=+rQpwQ03Cy#UOmNN9({ThSgjzQXH0Z zb*M8zl!M6w@(j1qjd>I!&*bG>t22b&(60-xXER{4bx5+#XDGeElC%9Y+lNc65rQ8s zu$Cw(ykJs7(#`OWU!sIDj-_W;gDcCHT@63~eg0D+KmFdZm%_exUogyh=*5Ply5T*e zUGjf1RvpXF>s1oY-PrHSIDLOv_x`J8SJ~DatKCuW&CJCPRlh^#1KWzErMbRm+rvRZ Mp00i_>zopr04OA?5&!@I literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_left_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_left_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..c813a64ae1857efb32621658edd14fe7d44c0323 GIT binary patch literal 389 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIeH_Wk=A+z*}i0E+(fba4#HxcBykAzzb$$gz*- zWDY79FyD-w)7!~ja6$S6C&%i*!);d=R4#Av%!t4JWu+m@{`}V$?*GbPwTgc^&;WzV z2RpQ0Y)vv0XErt}o601Q52+wK|-*g zh;gZojz8NIqX!2YeAw>nn_oWfxxfNh{VAV~zopr09P=e AwEzGB literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_none.png b/arcade/resources/assets/input_prompt/xbox/dpad_none.png new file mode 100755 index 0000000000000000000000000000000000000000..d36e045f234a572bb4bf39f88236b6f2a3a2d5f0 GIT binary patch literal 398 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1F{QUL%?fdf?rYK3NF)%PPdAc};WZZjuV=wO^1A(^0 z>ynMUCpcy@TwH#Vo|KEEt0A!)8v1+)+J!aGw{p5kNkGCXrebrpQo#z%Q~lo FCIEA}rhfnc literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_right.png b/arcade/resources/assets/input_prompt/xbox/dpad_right.png new file mode 100755 index 0000000000000000000000000000000000000000..0f874acfe7541cf90fcd3c6887da2c9c448a4791 GIT binary patch literal 427 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1!2l#}z zJ~wiEZs_*H!1cKyknQ@+$nBY-(|<4kiU7F~HW~@k4b%t}%;s`P26BW;g8YK(@8{dk zUoU^Zet$ohvA>_eYU2lEp!5$<7srr_TW@bg3N;(>umo<}AiZJZyZUP}3=@J=x>T;# zHhs6&3VnSv6sQ&i8tyaeJeFCUEArSzugW34y)?_gX!#OWmSXOp15cRMG#7kjTq}~m z$_P;nCLi!K?Ebxd&7R2n*S|00xo~smr@By1g_(R4*yRO$I8X448)P#2i(klMG#5{Z za$rN!dEh(qBC!j*86%>XraG=uRU|C;~ z!@&X{AW;^#q~Ub@?z?Yo)@B`eST^tQD)t%+sL}&Z8^oVHU7Gtct~LxL^YlsqFv& literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_right_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_right_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..0c60966d78e3c8c3e6582a1d84479de95c8844f4 GIT binary patch literal 391 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIeH_Wk=A+z*}i0E+(hba4#HxcBzPM&3gP0pOnvu72FTm_tCp zpd!Iq`%H4zNeMQ$W3z<0WO|IH1tNAk@G`CB&Jgg3S-{lz?8KhC)6RA=H%?_>WMbj? zW0UaohR1;~$D@80$uRBO7u@2$J?3J4zSE%5Kdt7Y2f@Y zpUGwa)R_4bPd|HX&wRt%Zlhqj-tWH*cIqigsrBj(8gJh0m+;!bsldR+e2ACf)5}s0 zr-mK#R&*8jDl^77m8zKYC_Fp%IQ;p1{XO6FauuFguqo)gKd<-x@TuP`RC^dKq)zYN z%9hvLc)YaX82{m0a#4X>LaZ8kKHa|>rG4f)!>rHB8@Dp@vMjjwU*3^7zu)c+!;wE; z4D%Y83oO_e=JF=kyED$&&8l!IzFwDM-bIFhLK%jyPaiOF^|36l_YgVIm&{lY&ZM)L zQB2n1;6;WjY7O65W-xY4X6O?6P$R;6AUWOA;X(J>EQ_5fe9V8DwwxARrSejj@r5|U zNvG4F+85X{?YJcWP4p@|M?xlnS-snZK;Sf~53@6$hZU7*Bz z!mg=x9=mKS*gu@P?_D)_#?1H!tPD>iBf2)+?@66FpEJ*QIQsFALH@r`os7cdtGBlv>R8o%vSd#$>y0BXZv;j@j#61=`m;{; zrt`wDt6wX62L5u7jJrGIWo+owr!L7sueIVPC9l1DZ$`ztppZ+m3a_u8bgd`fZ};`f zzn?yvl7IfZ-&UD(uiskg2k5-fJCfSqaB*VBswIrOR4#rA*`<`}bku6DX2y zBbHSkuys=Hn0o22+{&`)-B;(T&%9F9B&{=RHLpU3p3b$8469!qYq=hy%P{4T_g%@I z7a2mb*tOYEd89GcWGwSQL*&awb#R6 zehFPzu(Ic@o%gcqGgnW&_HM_=yXtzgFFjdjzP-J9myF%#h5KJ^y7{|2JkE7SRQGM) j{#pMM4MEWW%_{me`A2&8-b<1P=0yfiS3j3^P6nS2{ycsD^Yn%1 zMs7f}ORt^=IZB`;$S*kmeE)j;`~LCz^8D}X_s?f=RQT<|z`)$*>EaktaqI2u*OO)& z@U;3ewR9JneRnXZ`@j9pmd%;Fk4{om-gR4Y{(Zh*$Bsoz^1t%0d#R8ElLrH*0;5U; z%ZK?)COa=J`>nb-^xhxi2Cv!IDz8YzuH~OF`;elR&ptJWwHIH=^X!V_TyW27(YCK# z3|~IjuqZif&Xo0jyKEwh2KVu>z%E9Uw0}Q7mc0A@(z&+YuJ^OyvWbia zpDWf(W4KYd?7Ly*mk&I5blES=JAUh=_G`&i)0rgh|1SN^e5WS)tPw+gP5DRWm>8DE zr1k%Ug%$q4h_zu5n9tcDoWQVjGGjrijKftg#x0>d4$Sp`cQUNYRbsGGXMbR2*HAWz z;R8D-(~3Dd4XbA`%rtR0#(cp~iXn>S=v~H(3^K(Nx)~>y*cQTnui{A7;U_P+@fW-lZ!yWl7QVyN@ zjgMPibmX^8Vrkgd#BTC$@`D34yrTK6$4-k}NPV*Qpp1h1vj4d=nxyVcNIk*H@IcPu z4(G#qr91pzY8Om!c@q3reD(R#WOs%Gul7g$T_=5*A@oDD^kViI(=}FzZJ4}5Cq!A} zh3uY_Om`M+FJRliJJ+JHYHsjt1qbmHRY|P5&5{lq^&A9Q3SKkqV41)mbl^Mtk5Bp! zr!(&pozkooCv(5BiI>ibmW7pW|*U-I;uj_|*n z$V|(C84=Y={wLq2j#uPh>Fg_R;wl`r_lS{?sR&H!I3| zKL0qleS<)ck6n}C`u`DOT1`_|cWf^bWeQvO+}u#Pa;1&ks-A@9%0D@6XFq(u>toJO zJ-xPthW&;nm;P)`@m4qAw`S?*<2R2OZ=Ixjjwd?*Uf|(!o^s>UH|9utRm|HtEAwq$ zNSph4k;_lcnQy;t&Adcw?aixijvu}Gdv#Fx{CgeiE~koqUGas#8x#}JjI@W@DIsGW UvoLEXFt;*zy85}Sb4q9e04;bNga7~l literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_down.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_down.png new file mode 100755 index 0000000000000000000000000000000000000000..3dddd32954019b7f1ba68dcf96d0c549afdb309f GIT binary patch literal 1112 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1g2Ka=y z{wDOC{oLL6#9yq8-)seg=mMsY2Ce``tpoL3 z2EB7XKTNt;`uNLv2DazZXKXmS`{6%^#oH$uygpvdbf)jizxIP?%b8{<8u31}X83UT zw7?98;N310b>A-GOvn+LZJNa}*}rC=>EqPH{fje&JL)bm&X|2WfA{UgcV!{Q*$iJg zU%m}u-hJdiowdWG_M;Qzq?EE(r7+list?avx9C3GgfH?Fm(ERQWbC;9|MZ53FE7?w zGCYdsa1ah)Puit3>*WUVikD&p> z4^Q6vGXMG*r8-MyCB?KKQ*C(4 z7jN`UX@=@}hS{tg-&s4r(KMg`!-v@)9x}-F-rL6N77^29cYLm%!|}R4-fvks#@$QR z?DbV{b8e|Cv^m1*wZHZ0!52Hs9Yv-ocb?p}?@hg7?*C2eo_2ChoHe)a`JV5eKW@=A zRi1qLa`w6ZuQRiYyp}(2FTeHwcVcywAGe7A6RkP(MOeCeTs8+WtZs9e9Q?9x$N$AK z%e5!G06zlN5q2wfkmrFd)0+*yVp|e|g5~hjh zMAbHgg%~b0y)MMy^YCbvo163jkJDvq^&GU$#T|D))(Q*Yt#;}D`U%&SA5lIm8ykr;nB=Jf`#NU?A!yD}4ch|=~jg6^k zxEpcl!imcfH+1I){okcKcmI90$YAZ=Mvu0cOTOnYFOYhmm&v#5W4d;&Dy?oum zz2uvgNzkl RMSwY$!PC{xWt~$(69B6=5)l9Z literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_horizontal.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_horizontal.png new file mode 100755 index 0000000000000000000000000000000000000000..f19d5c08b7accf74d8fd58b2db67fbe55a00cce7 GIT binary patch literal 1118 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wDhq~@xH}1TS$atGt@@46|&odUi@{ax3J?-zO&oAr(|2%#E^Yn%1 zMs7f}XO?b11=J=`666ibm`DT|LT@*U`+<|;0yJ8RSoGbsjySo}VI-uykNWhg& zv%BtwRx7*f*-mWDJQx_Y`14!Ft(uJ*OIL1ZF`1R~ceW{W7<$$lK*Jc z3=6Gy*O+DOd92E>^6lBx#cuH6|L&Mt)->4zH|-RhZ#y(NY&`xyaKo#2w{{mWT(T2( z;MQm} z3ktjk*c=PP9Sj&EIlDe`R!p;I+cCL5?GGcbY|dGUgk}HNePp{J%V5q^z+5M;QOACu zfsyS&d1I13!ySA}{@>1+&QMmP+_vx=!-DJwSN`YJF@8|^qW-^hL5D-i zE;&8%*Baan_NSf}?eKF+SoA^PD}Uo5#y!*J{`YB`2b zkUqOj@}q8@2TezhzIgsajeAFBec$}slRU)?5~C--7oYT(&#S+vL0MVjugJ9X?2-o( z(^{XNIF>SRy?gwDk5=a&N((1tn|-y5-dq>8-E^_}^5?1tD!)FPa&O*!b}559*YnT6 zWbV4`!FX))0mh?;1kd^~rgbXMS{l4dj{VD@;$02NyMA{t9@~A!@UGq6XA6wJ+G(EM zZn^60U;fqB6YO=}*X4>l<||QM@GvGK!n%OAVVI+?5Qy)X-I(JN+b+(YL*d zrW`d6kstE?ichY-ZS&;Qu1U-xiPyhJaV>BbmgwO*(6YQhg0mrV@{vWh&fZFM&W5F4 z+;Q1MsQhkA;C1N-B2)gEzw>_kdr8sMhbv>=7Bww*H;P`k$Edh0T6ObIap&*XcQ5t2 zdAXnQ#&t!`-nyS#?`h7x^UZia`)~EF&(Gd`nB85U}*J>F> literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_left.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_left.png new file mode 100755 index 0000000000000000000000000000000000000000..a031aa25cb4145db6259810ad9b923f363b7fa05 GIT binary patch literal 1125 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wDuN!wh)OEj($atGt@@46|SKhIoXDs^IJ?-zO&wrjie_JybUFsyxd|YU}VVW14K*RstN!hFq?HXSEX~@atmt=9<^I!aM+i~~u zpA0AdI5Es>U@BO^%%I1Az%Pnn@>xcS57*G-#G%_*N9aAZNn(ft{0i!vb!G zFphvs<`5$W{#y(;)FfW=o?vsxYZBbiaP^)cgOv3L#wx2DR_S+QuCz=3WI7A$5~a<~ujZlOPm z;d<}eNer*EY+o{!+|b;^7~$<$9oul#f2PWTU18mN2l#fgnKL}z#*i$;u#FWQe+Ry^ z|M)ch!)eBAGk&;=FF5)r>5Ao@%Q zvw-Siw`EldqS%Ggu{j%KDm!H>Us4g$^TK@cfwbX+7 z@BGidWIFn|lSx9WmC1FHOYt&>v|b_KRbJ&0F1Gtz=ISl>`CfPGMPbLO66?*r3omWZ zURpLgXzf$+)xzLo`fZz?I%v@QhX})d1=hzrYYKTFU!^}T_sUu zHEo_jkz`u?v-%uSaruPn&p4zie?@Iu5^S}9-ICAK^Cxvi-4sf1T(f=d&dcYSN;iAl zxWjhti&5d$fLpdECEqwhl|^=*KKiz{-L!rAg|Cm!KRx+-?dSR32A|)|J^L%uN!wh)OEj($atGt@@46|SKhIoXDs^r>GQ|#X@8zRe_lG0JRB}1o;K8KcC;PAMbC^e_#In{QCV2J`D4e85o#*JY5_^DsH`<{c_T6 z1)heD8xOKScvq{^_`UwL?X9#+Y$t`L?Y=GfMf$Kf%Zr|XDqpQR#SQN=ez$HuJv+Gym-@=QLTU7zwU$n){H({g08;S3>T(9 zR}^4mvsIsVrfLFSKEEKCNIruM$eZufa`^9t(4Ka>x z6$cwf!=|Ql3Hsax>@UA^IjoJGW7KfJ%xf)Ei|G300t!yE_A*I)XO#$NytI#DB`69% z{AJwtNA*t;1AA5eYz8@n{Sqd}*XkV5z9}PpA}fd4!eQab@5Lv@+}6$KDL7u>rGMvB zLJC8kpU!p}4j-orwxSQ-eJs)9_t6OxS=y3zzCCxLq}-gcY=-Z%n}cm^=Bvv*c$r#V z(WkC4i;>yl9+!$nSda;j<+&u|+NP3%3sr(GlRlTQW?Jp|RZuh0^RjN%e3zw4>0Y~Q zgSN`rM#@gA*(UQKtVimFTSI5?h6|qA(BZUFNf>e@-0QZPuIqLuA1PVktQt_lEHGf-{pHFA@p1l@Wvi^Hk&OaOO z+-7&*Rq19sKhM6s{2t>vuMeyzk8b+*@7#}qN~Ku~-Ug|Cl^28MB`pWm3g$^xXC81a S-)#@fvkacDelF{r5}E*Ly8?Xx literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_up.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_up.png new file mode 100755 index 0000000000000000000000000000000000000000..f18f7189571a297731bbb38eaec7de714a9b2099 GIT binary patch literal 1128 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wDq(4)RMoSKL2_8{FQg?3%kJQ zMs7f}`TYztfZ7B~g8YKlpYPAtxA&Kizt8`^e*S(2vHH^g3=GWEJzX3_DsH`ot4=?X|&G!2`L)G)=GdA4Z{qG+`v$>$* zh*bZ-pTnqQTcyR$a4ntb0gFUKpAkdgbjB~6MGoAV&JgkA^?BBg&AJR6fqV*Vb`9N= z7(TFbGQC)=l`ahh*`8C zM&ZOXX67l%8YiMB>l|Qt`Yz#-Pdd`YN{#v;MxR=n$InPiPvu z?&dvwYc|Mu_&8;p)SG!r;FR!W#_TSkTLEI(>w|)fO)O1sek;k}rnbDI$}WA;)53f6 z<~?TZV!O+Fn?ppyNOhKjXM@?)jG|Dha%7do58f}#+NQJ%(}fT zJC!l2?7qNl7Q2RxISflm&#*hpI_~i~>NKOm#<{mdZFFN8);#OtlGww;p!Mj0s!SXc zgZM;^tf`Xxx)Te`fD3ttnM!NLD>K!i_b}w|f;IVq+1l75BzNV_qy?-Hj@p0a!!1E|73QjsW<)qgoV%Cv;X?L?@B%#e&1b# kuhvI|G;jq#v(^LYYZuQ-ySlwq17=?aPgg&ebxsLQ03f0h7XSbN literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_vertical.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_vertical.png new file mode 100755 index 0000000000000000000000000000000000000000..b1c36ae9a02d25405346b8d45e9a6de19ce54606 GIT binary patch literal 1128 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~q1o(uw z{wD>xhgGb=|*i-1)L}-P_cXSKhIoXDs^IJ?-zO&oAr(|2%#E^Yn%1 zMs7f}*{UMgfZ7B~g8YKdug~xI*O#}Cf6srve*S)j3X{oC85o$Sd%8G=RNQ(y`}L&R z20X325@(L8&9;`-tNXwG&X&!$%h_Xd~NP{|Axep#lnRZg&iyt7=#>{JQz3? z82>D1SbJ*g%lg)fJG1^vIjr(iZ)3Fhe0#U_gFI#q zmg@iWH!?ijw~LRR;gd0A0TYh{(73QR#wor!4a;~KGahcY7e7#z%ixgznDIsr|AJf} zh99jgj8n|?8gyqeD498&XTD%&#;}F6shX*Yan>}(gbUHO3JvVPHaGDFJUn`hhw%)f zx55wZ2YT)cCLQ@M!tsyEAwKy)HP*I}H36J$QSdB;KZQens^Lwty3#_V;$1A2Uyp)?#9)VAEq( z{OjNH(8->iNz-MQlh(Qi8|zkTe05LHB9A|<-@Pra;a0E% z%dzPP7VKe|b>}*puH!q_69Ey$%5{h0OygnGdS7B$+>Ol)ChuSbT}M;2-1vrJc%`Z4dZL6tn7fti91hN!u_ZQ#A)`orNATqUhLf_lMGG0Z zzTFYH&0@!3V~VnkfUk<2aCfzM z-n&2M-+ujOykj=0{Mhvy*Y8#I`sg*C=Ml@i{i8q@kJ1(BzVd*ruhEXWOq^`uX_w&h#wR=sA+x;;sf?{?60d zothKYHfdh^y0oa>w__gMbUkOX^Yhuap}7q~OASryuZX6-*I)W>zpY^H`c%o1)nE9# ek+M|{!6C>Y415pmU7B3Usx>{!wxhJ1b)~asHnD>ZeH}RQ2J|w-Tt-V%Twlj_!!7l}*sw^i0Y@>(C@#4%a9H#%{3XDZ{rUNPXNkOislQ3$Rhj*5@8wt8{k$-1-y}vP zYaSFh94+e$ayVMx10>461vS*NTI(nLKmYdhyT7+3K9pJKzq@~};TTL0ANw8#V}Yfy Ty+7Xdf{gKW^>bP0l+XkKBVDgY literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_up_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_up_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..0aa2b5779d035586666c72f8ae3fcdb38682cdb3 GIT binary patch literal 394 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G}T0G|-o z|F{8A2~e_Mb?rSMi>oBaFZh1_eEIeE`ThGDw%p-Y1d9Iiba4#HxcBykAzzb&Kx<-F z-vhxNjWP!gG0*T=%j(`BED*K$2wzxdn*YkFk!5A-#)eYO?@PV@NOjjc71b4U0*y%H zmnrM5T^p%;^}r!Nvy8@bRja2sJY-%hbfV^fM&lLsD%FIOKxGUJ2kIFo%X}_Qo_nO| zO#T9g^w8BD0WTURGR)*NVDTt*@NalNz4~S1*1~_G;uGelfwesN?4bGguO`cuobx9S zTQg?K7AQNcW7cchvzg(n^;u)~83$U6Voo={4l7DAlur$p0&DuAx`5|-1jm+(GTe<; zvyv4jxEKFf8~(U%P4Bm-d&INVj|Vwe0TrHMt6<>gEDD|WXYyx|K2KLamvv4FO#nJ* BqXqx~ literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_vertical.png b/arcade/resources/assets/input_prompt/xbox/dpad_vertical.png new file mode 100755 index 0000000000000000000000000000000000000000..123229ba99a2057dcf716bb79d75b4f649bdb580 GIT binary patch literal 422 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O`1!2l#}z z{s)8SMsCjy-GIy&1|X5=hG5dj^_dY!5D1<7wtEp!8=?7srr_TW@bg^EDgruwD$f$Enho@<016 z+byY;F^;Qe^8Wa9YtwE0bBsX6AW+A@;hWvDPm3gK_rGH7|Fp4PYJ=!vUv7!+n;NWV z%o3(CMM+=qVtncVRtzP6Fdw+oI_LGtr!u>qN-S8pX_j(BNUN%}L1^Y=PKhlI2U#q1 z56o=P;zQQ+ojv94pB$!1ViSB0EMnF99)Eu6?~esjZ~D&8UUfo0Oa1wR!gi5Y=}JYD@< J);T3K0RW!Utq}kK literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_vertical_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_vertical_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..d919814ee83910670b75bcca1545cbf271c97f66 GIT binary patch literal 376 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 literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/lb.png b/arcade/resources/assets/input_prompt/xbox/lb.png new file mode 100755 index 0000000000000000000000000000000000000000..b7b55df791a184830d799aabedd69c6855570d3c GIT binary patch literal 589 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDgPP#Q1=b=_XT1 zIRpEKiG~a6{$#w?UViep;Q=q_hHG{?%%3bCT+Y117Q%R-J-SkO!vq$Eqo*14nJn^` z`5q_}{7}BX*0$o^bjITf4bL{eozY;ywZM^~mnn`v_GOtvy!B;vi{t|?#~AW?D_pW2 z40rRiTPsGEJN;6h8}MFWPk!}h&l4;%#~6;f9bppL#F}En>SO$pA+O4m`G8D7>Fe#w zQ`+?w*s7Q8Vle-Zry*qjp-gAeQd{|sSznctW7bV%9J1Cu}l1B(I!BZmW= z`A71=wi_GvD}^50|2`t!iZMANI9}sApTRZtrikNa4Ksy8eRLacs_){s%CJ@_bB9;M z?>|4YHl5bantt=O_U8h-!}*~Mzb!j`&nKCyAH2qJsQOJw`M$!P+*%Cx_t)RhFp7)w zQI>kJZhC%c&b~b&SABnHE%y55F8;=&;oFB(xjjp5IIo1(FKsNkZ!Oo!%yZ>$Jm(4F z1l8RAOcU4-ygtElLNcMxr{NA`^%JHib5d_G$emz#%06fQ{`-vTm)Rv2@*QA&aI=|} zMT7MrV{7borU$2Yhlwuu@~W9NW5ryp2J^EGk}=Ju4VU!VSbQER#4+zP-In>LRFg>2Ec7&i(toO68Boc`eL=gb53h6T)SX$RzHG76h3Nai^tM7Q0Yxxt!0 z|o^!|+f@7Fdn8?XM;>;#Wmq*$(Dv`9QG|J@-jADE&TJYD@<);T3K0RVl7 BLGJ(n literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/ls.png b/arcade/resources/assets/input_prompt/xbox/ls.png new file mode 100755 index 0000000000000000000000000000000000000000..cb6eb93afb0ea96d3939ff7f01499e1da5413736 GIT binary patch literal 971 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD8SHKh49x1DE{-7;x8B~4 z%sZmM!w@jZ;fCb>|66jGzLXM^F1Y3OWBbJqA_@`(M;}jj);YigL-wpJ+A*KxC(haM z(5#_Q(@8C|&iyC5NG zgJj7!Gau7SFnn`9We*h1G$BR|-NJ8c!y6G}M|UEN7JQ;df^|Gl#KvroxVC z3~v@qV9iM2>}6Atb2!8_BhKNUXh-!jhf1X<3=_Ep3K>{)76vrj5vcheo!X-L`;J6* zw)gK9Ele&}4|WL(3*KZ8Z(y}LkPvR%&0t~mz(suuALEA<2gQ%~0y<% zJARkrgk0CN?cqKQ6CQb4>|)r*|KVwL`IRE=^ZY+#CNJckaEx*5a^b^8K2^K+%B=o) z^+NbA2g4;P&yHuka84DNb#RHv_X0yz>s^yBRYlEX7U@5n8Xxs;1%t+tr;Cb{lTJ3& z#LbK4GmPHszJy7kJYMG8y|bC6)$`botUhq!-`kx^%;m^zD2w0^nL%Ue5#`Dv`5rZpR-QOCU39x(Y$^@+|y6vsaDF56*mN*maM2cbj0wF zCu{Gv+5_ zO^X$H8k)NrVs3q}zx8%raP(0Dp@nyEO8(p#{gAmx`DEj5d$|I}7zXhJtPrZ6OJJ^% z-JUk{Q%Bb`>Kt2cZ8XXJ^mE2J^WPttvG=!1gXxo3{%pziED6W0i+jE?GyHj)%Hh*s zky>Ql&wsF9H+Ld-zJkW?Foo#Co6Y!PDF4`44VqSo3}U z7hju_q?3#ruGv3)Di=4|)q(NADqr>3+L*86=NQ*mTkQTfyTR(9kweY8`ETBS+3;HC zBa1`E&i{Wznq?A99eiKgb7Zk`YRQ|qpAER_k=eNXhtJ!>@0X2vj2N8jRi&6KIUO<` zx|w7`7B1j)*vU})+Q{UoNB~3hW!Z-nSzA(>f9Oe71ZbXT-C*OdC$X=FB_NFPHvfTX zqKwQQd-)S=n~pFyoD%N+Xn)?kXCsF(QyRffQ)lFffo zmwA^LuoUb!cHulwP%|$r%;w*})yWJJXE-i0$W<#;pOKJcJMdnADucsg|1W>9tV)RK zXIQ}(z+m{0q5A!15w(VrhMxv}Ju+2I<&2Xh6qtdtcS zM44pn-B2{L;Y zI{T$p8) zn`|7mPGVu5wrC}zjo0bS;BB$9f=pho_rI>$mAH8N(w9E=49eb@*KS)IJ@4GTkLi(u z2`}fYTw=7|LA5+~{#V(lRcx=$Jku+ZNPp$V&Dy>4v1-MpA512t?Olz_{5OXfH_5r~ zmT&Yc`MNqnboQ;&)9f!z(YmU?qVwDZm5%I>+jPza*zOi%a{D+#!^i38%JX~w?~MD` zvWwMTw{txk)2Y=AuY0dvJKzu{{9x}VzbOUk*M4nP6T5uxNYKYu2R0|axwfJ7*38?d zLgt2TH#G{)wOl+k``LEar;>|zI%{8FCi!f=L747mv+%F8oQMT3GzLWR$6_FRqJ(Uj}UUza<)XiYYF?hQAxvXEQ>vQ9-b^1GOh}XIst@&OvP%KQJ>6{Y-Ba46om^kp9smcGi_S3X| z?IJ}bN1bAhI3OM8m^ODaB9kmCwZBCce~;Kprk<5WPg9l(8!R~`((GaWpl)vdTDkh5j6JF+ zHYWawTw46Pjz28j^&PXy7aNwvQOozQ{u;I4Y!1izSL?Vh37f6vRA^ve0(uNc{9sc2 XcEeFt=ffgk#4vce`njxgN@xNAiP+|# literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/lt_outline.png b/arcade/resources/assets/input_prompt/xbox/lt_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..8792f4660837f21c30c7328946ff6ee03cf07c24 GIT binary patch literal 722 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD#k^5Lkb(PKnCH*$S(h0K4U@c2o!6PjlJO!{beepW73Ymt zB@PvvbU7^+UjE#mX~%Jo;rZ%SANPyr&tzb6U_2DWXD+P|a?gL}hX0}n#~p}gNU+=K zaA2qXfhy*0bqsR%b#mD+eC$0`*1)^dH=MU1E@l^_*n=cvhJOF1vS<^Y-I1}J8>#?jYQ-<3)oQ zLw!uDul@lB|3)>2cLz)ta2`0{aN>S|^noN{^^H>*SNk^|2%NgBp`d>E7e*b$uA)ML zXkmeS`yW?}A9($5y86Sf47YZzR<&S^=xV>OZGPyx&4F(o4Ue@sr)G5?4S)A@;c-ry}Gee!-Hht}Q*H!(~*(*#K65ZgKf55EPcEs=m$6;|`s$%eT L^>bP0l+XkK@=iti literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/rb.png b/arcade/resources/assets/input_prompt/xbox/rb.png new file mode 100755 index 0000000000000000000000000000000000000000..6582f391cd6050d2deb7cc79889774fe751c69b6 GIT binary patch literal 684 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDEaktaqI1^ z+eOU^0&EG71X&92|IfUgGRtg9!-Cy6&i$!=KH*4yOpIUP?V}5UhM|Fm{oEJIV(go> z9{-ayzc+0&!|jVZ4e~4)C3p;5et<2zF^Kf}pgX*;xzq}4Ss$_hUE1RJ5 zO=2~}Jb8z8Oa>ENdY(2!vN&v$*bo%mn01j=?!8kyhr-Gar;joOg)=|8yFgW8R?D=8 z4ATs*4%?<>472!|tA~+3GyU54mkgx!$Yfzw|K7&Gh!uSsvW*{8l>0yy72wud?mBEB=w;L3M^~Qtp-1 z*C$Ug{rELg^_E*?!$U@v>-o8#we$9yZ)Eg(zAZ_!;(u(Q!y4gzy&Ni>FG>PFB{z7o zeF*EQQDa@k=pehYrH4WKy}b3*34#ei`5ya(jF_HHl2YV+A#6T}HTc7xOOAX}Wrqp^ z<`*}FidiYVdc8MQ% zIhc|8CUR4-R`fh$*CgO?ND=C?qPrknlIlAdx4bR*Dw`Kl$SFZUz zIMtu0-dH!m;*gI_T=uv22PIEG{!NmNb~QMs_k$gjqR_yEF8-s7&RJYDee?k& Jvd$@?2>|7+Hs1gM literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/rb_outline.png b/arcade/resources/assets/input_prompt/xbox/rb_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..e8a78e81d10834dfff6c8a2ef111782756167d16 GIT binary patch literal 800 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wDfFZ%gLM7(?|1G{!zDM6_9htht`1F1r*ZqyDmTNlX^_m!Y5*U~z4luA8G{BjE zVjGGMzl{q@t=z)dJo6mW9FON+ymvVr(uEyAHdhH7sI4+uRnDMzQFQ%lM!kt*irS2m z<7a)lB@t}B_MPt;gLTd~c3U0TuqSTr%zIh3y=__t?l8V9>6&Q2QACU3uFO7>$h=qm zza0()U+_2S{5CZp#_)mLb;kR175G?~_!ynH-#Rn#L8{_|pUHnuwj?G?hek%G-iLMA9X?P z*OxB2sP|$8!)sQ)8!bE$R$L_w^CRbe(=uPZMxABPHSPlKAJ%;HL?;O(l)q}&x9{M? zK-P$|+S+U9E4hV@?v|}K7h;Gz?ajY!f`?iq+reHIfwNYNw&@BqG*&;eb@)~tynN#n zX8V~u8#MoJWk}#KTe@3>f%RdUYezWKlCwc}6@FZrYJ4|O= zo-nj-Ibft{=`j6Chc+Wq=0bg$rl-@-9$Szo@XWnpFT25XrW7+aUg1EN;|p>aG8iA% zX|?GqYcSrF_!HGJVRc6^L)W2(|4bh~Ft$CI#d2?r()X4=xd&Y5S^r*q`flg%L;nO8 zc&^V14^Q3qAxA^_ZEWs58*abj7nf--I-r^|yPx@+|*^-@H?g8V9?$>Z0%`b i|MjgP~sJ z0DEF^O~fXpAKMuvX2Rmq zLxvnR^KQlkdl=`=R@f25V4^9)n3875$>w3hz{z!B3*%E=f%iL^zIF#N6~r*`u`Mv3 zohfkOCM!R0(S>g^8k70HAB$)Uc)eJN<3K6HK8>W^g2H?iZ0QXX6dBg9%UW<#hk-#y z;K57r@&d<9Mg~Ey4+Yb_9`0@8nr!UA#$0iA&Gs(IwfqSW7`}7wPS_=Nne~PsLwsuY zu_<4x#SfSy7?r$yA!@3vIEAB7fEY8*OTIR0v&*!Rsp?9toEZ@p4z{p@< z_y14u1qt;sMiG_=Pp$V!{^^^%SkJ+Yxg~g&2d{`!!;e3651D@1{g}1liv?P$*rVRL7y<$e3h*%kfoGh}ApM^$ZPlR@d(Go3!g%EZum+f~j_;O11CHhXJXz z>4#rPS6|UTnfF)MHpg+RN_1P-rLf3(-O&NE?W;F#y7O)Bs|~Dja_S1bo-deXwk?R6 zE`C{YiD|aU@|~Mlmwr#$-)pwK_nS@lwgYoHd!+Kun<{LONj}K;>Rcmp*JsH&jC~s) zJf8dIxai&+dc7GtmX$o^{;hqyWc$?gzoIsprZx<0c5C`%Ub`RJ60`3#^D2HZ?!Ujd zUmZQ7{p#z~<9j71p3gS@b~CT?G@}Va2ESwFpVKQB*6cD>?!P|kKBLw7?PGpQz9~?NJr9Y~6&+=dtoiPj?AS6lt8YI_TkH6J`cWSr7ji z7I);C3%He=+-B?((|PH#+&;+jd3xTV&ZvVz=`Cx_8@#V}1eh;wIm`H@*P_rg=vHj* zZ#i>+1=n{Kg}d)$^DF7S75ZD4{HbnnZq3IIiM3|V@}~a}BWI=m3>Wmzh%M~A{{)y> O89ZJ6T-G@yGywpsR^Ju? literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/rs_outline.png b/arcade/resources/assets/input_prompt/xbox/rs_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..7ea3310ae146948d1a34640f135925c3d04cf1fd GIT binary patch literal 1354 zcmb7^{Xf$S6vw~YjE%-T)T9>&T;cDorFh0r8Lmv42mSXZc)N|DE; zOvSipCc31HaZ8x86t!F`YOB@F-R(cPpV#Z0_c^cEIj_%epZrkTHk9TXO#lE;RDa5L z6~X@s0aNvl(&8^Fs3mv@djr7jGg{wb;Hup*G$_n><^M?aq&i>Sa$K&O&z zhtEt$=bG7-lXSY?0n*NvlPafLD#bhO@Z`c}S&0O@3hdTkZhrkwxj^;@%^ra(Os;dB zi^M;?G`!WQWI&$VIgUz$O#v>VA6T&li9v~q-3-E>Tom){Bng~tnG)(SKPt&%HwIlG z=O~#Z<7%ZIcnQ->thWQit$ZIWblh^ZBO}Y^d$z+CU(QP-cRer~L$a%=y~q+`mtuej z&M>RLApDFvSu-PB-TKw~^8S1G0!1xSU^8+xlyYpL2_^cq-2QrUa+g-xNV-_}mc$4m zkj3UT;Ta1?Knbge>9hhBmGMRiAC!TB)g}b%1l1o{Ir2)@kF_^DZB)lJ)%Y0uqc zmppJLWCJc&h$LHdZ>R!HxEd#l7d87LFZ_fgqx#lT{Tr>D@`d5ItK^~yc5J|$E`Z5r z7FJ&SP;uJB!Lp}hbe(~>v*n&(sx^=It^z^PCb!hg5`wS9))Y{r);Y1}(LqHG6WUy0 z=n?-#(@bLCE}G&rV(r0A6AZgLjxF1mydHX4uNUPQdBF2^zGh9Kr5xU#<{n9e7c^7O zLVJbM35MKTa%kRWq^v7taYvjjzr8UC-v{Y$Cp?o$@}9^Z!-PB*M9_Uc;O5dm(@dku z-LCE#m!3F*_J~hMEPg=j%)uN60>k@WRb#{s=r*TxPYbWgeTphE=3Nd;awsrYLfBXv z*AacQ+AaNp`PD&5r`z4izq1gC!9CSS2A3m$dHaPwE63YrzmE?-s)J_IPwH-L%LE~x ztdjA%PR(gXqu*jzO&)lXZul)n4*JUkx$^5^?l7un(1Drc^2EbM`Wkp+WW$B$v+_s6IF_& z`Irb?za@?$(PIfzpIdR$Q4V8CYcsMViw7f6&ZfZ=!j!glcC+M;$@DMr=e;^|+I~h} znGZ{(my23{Q>5;4%Ut-&|2dC1wbC=A-UdklO{G6TYTe)Vh@sHirb;t_>Pw?EZe&I3R^_DQCx~h8rv)4}%(*{22GiZ(L{R&%WHoPMZ0_ zGL3`-(_Oh)E4UIGj-TUX-@wMRL;tSB0kc@19qU)T{5j89MpoTG(DuHeT=i*DmLGQ> zEN1w6pP5T)LlXPLCxQJBuiZEt&&MIkqmkgSF5*DI#)goC4NYuo7!JG*zma>;wIOvo zt4_m8ThaI18dw8vHQW{3z#x=p$}`D0QH|kS)B&asQ~o*##PBk+Zs5rCFuOGG!*j6@ z3?9L6&g(v4?5JC_jDbl?;m_JB3?3N=y4Z5qcu!8zopr08l0`X8-^I literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/rt_outline.png b/arcade/resources/assets/input_prompt/xbox/rt_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..862cbb2972da477462066db04f2364af59f2fea1 GIT binary patch literal 824 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 literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_horizontal.png b/arcade/resources/assets/input_prompt/xbox/stick_l_horizontal.png new file mode 100755 index 0000000000000000000000000000000000000000..c513f8dfd42999030cd653f5acf589e9ab236bde GIT binary patch literal 1257 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD8fBt*@`27qj-uY)%}`<~nO6K@(=D_3#2=d@AAOxZU4nr*fq|!i(SU*Nz<@GeH1vKNbI_}|uUcF_M?%dpwHHovgYTi>tx z;WIgg^Be#D)h|f-_LkwE>D)CF0^;K^N*RljK5)*b9?Wh z=ilS2OCCIyIrjG8EGb4a#oLbem$#T5v{nmgUvf5C@urySoHgC|f6INjxl&8)&zs%< z?(xaW%FSX~Y0vo8{hwNnyHu!(`DDgLgPO0n@h9;B5Ukp5zb5Ah*P}5=zdin1^7sJ|b?^e1e7&BPi zJK8?ygL)X3)uT9HHue1sU${c*z~k$=I}a^uS$%ZrjV}xmF5GM{P5W!-S&{sSvb)+0x< zfL_Zq%=|xR8go)okuJk;rG_p2B5I6=4`t;Qw4Gjrbt*&~M`oJo#x$f_O;3wh?Ka`; z!aw@+uCFhBmC%+W%DKvRWxu?ZUu?yyBc@k3rSRy~!#hE8@`a7K`< zw9hF~?Z%xY{A>qy9obwTQ@ObL^Y;GJx41qO{$f1Hwe60|9q!wyn;=Pk_1gp#PcqjZ%Ianoc_S!OQ~UAkH*qD~6+up0E+33qmDbc^ zmAL2K&aJus_dSnhn(+LNSK(XbMXIm+6@yk6v8nx75N=Sykn3|RE8WF?Zi`&!szOoK z%|8xsT-EgI)li)y?EE$>?T>MI*VUsDZC1jYBe>Q|`RVNNa_G5gRP3cMs`E5VP+!7v zSFU5UNl5w4@U=f33d(1cFHEk;{q=Ts-^0?=E83*px;$6D+Z@lc8^|7vA%MOvzkx)esxSueDt&U@TnV-v67Y344g_|q)kbaX>iXv49u(&T^VwW4begq z_!?HqB~-C2_@_H*d6i3csz-w8mp$LR9;UG;Fo&3|l-b<#bln|Lt?o+I>23QRt?RZjZ&q5G4V_57ti6!*9~h=5;Xufv2mV%Q~lo FCIDu7MBD%X literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_left.png b/arcade/resources/assets/input_prompt/xbox/stick_l_left.png new file mode 100755 index 0000000000000000000000000000000000000000..1cab90bf850e5a168de5e7f9685175be39d6f768 GIT binary patch literal 1263 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD}HhFuxIEGZ*dOQ2} zq{kXO4u;Q?34IU%#n_Bf+$?VCGlOhK}6#eC7`}q(91SV34d8`c<^Yi0i|}0)B_vr@xE!>Nf9~ z(|Wa>fyw>X`~DxZBKw(HtWzQjlQo(YR>w~&`5v~D*OaYb()(4L`2#x-bG5A6`e}_# z(i@g13jaau>INF~fpS5(nJhyY{v)9pPbUvEiCWZ3%w;caw z@dOBnES)9GxPkH1>ff!NEONZl_ybPG8%IfTY%wkFihg~J^~Ncdh=)&SI?Q%{{+}go z|FwCS?jM?DyVB=<*mWu>p~|puD>YB-0iS-Lhja;d8Jtq%CkMPE?%+yG+F!Z zOy5<%ejY8;X6)T^Yl5AxaKim{Q+x6<7k|C}=)7~J74L)Nzib;sefN8IuQZq=4Ah?;ayn=LE!+yNH3FI8@vrE+|vHW@W7_S;^( z>*>KucXTAYeRsPotaO?yd9TmtasQphqn9L3Czvf{y%j9>XQHn*W1-v0$w6#8e}p;T z?&n!sQ;OZkfjz7reJ(RAXRn zcqn)`oZ*i#=K+fYUhEP3nH=i7TeiMjo5jz`^m{r(N7jSLXrudU-kGn|+1&GV->tpX zkGNg#Gi8)<$3A&)_NA%7$*6CNmR4$qPepP`L^0pC{vTVl{=N( zeCf-EhA~$mR+)TD*D|6^cHPx~aL?m$&g-1VJ56-mn{Xv2k*`Y&$4XXMj^-8nGCyV(CnpA$;rau7K_?Xc5xDe904RNm$)E4 zZmF=Ygzzpir&$^PC27F!Q`#&4qHalcHNEmgbs%xNE_50mlP|C<`Ksv0zu8Br>b>!X z8hN{L_4I7t@{H~v(*UB@121(db;8V3si!d`m|ep4uDCP;YDG`7KiW`!7%$CA-W?8_ zsl+^z;@)Gb42!}$jQggasuO>?xdjN<@r!CR1{lh^)t z`#bG_e$%)I#ZtS_XFIFt>SPGD5XGtdgF_T9i8+-q?$IDB1vo!Pm>+F5y%mSyj+@!O z+Xy%0i;fuvfl@)S0Af9iT?^nZC;D#44Y~8XBo{5yKM}CNx&<^N;Uf;ZzTnyi43;VxLwN0R8lpFi^Wwe8E+F2N9yZyJ^l4&ve_r zLPd9VE(D7>lWtUfaxu%2C_>0+pkXDx8nJMPgH8YN3ARfKm$+YVBICMRPTQkT2oq?bAV1||g!7j!wcA=u)md{k1nyxkg}U|bj_C3z7y zT<rp~3D=50@k}Q}JZ@zw2Xb?}| z=yk=5+}z%6Awj+$WIJ0l{Y7xBI_*f+NYm0!xJ{9=1i>kW7}^_rINHjO;hlWVC1Y*8&QV MNg;lsos_))070K^b^rhX literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_right.png b/arcade/resources/assets/input_prompt/xbox/stick_l_right.png new file mode 100755 index 0000000000000000000000000000000000000000..256df0ce0ad555f44a87128a27e37eea478a05c8 GIT binary patch literal 1248 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD+S5D zla?y*I54uN1poh^y?oX3{O(x}Ojh2ad(2qdXV`8(`fd7j2?pi_2A&2+0|vGO|2ZFo zT&g-d?V9IKdu9Xws=E`nzRIim)k+}tR2Ro{cJeMjt%s{PrF zGmItMA0MvjV*bW{C}^t_k3#i|KaEQ5OPrGr=gqQN$o53vGEt-E$-4bV7lw1Us|h5q z@pMQ|)K8lo(9goO#{B2*snT87C%=olqTaQLwa#uq_6d(^a~Qs#5@^^zkMVB3)ef%@ zk2QoC6LuXn)KFc;{DiHcI+fu-jHtu=T!xwKhaWc`@I6pJ=lwmd0t-bOqq~epN*Cv| ze-Jn@(_w*GhU^^CLWXagOcsK78195JUz2MPWjV2Ux5MFtJ(Wk<8fx3Vv&uNc+s%K$ zEO2691JjJte3#i1Hq9TEN?OM61fHzrj{UsU8=(8 z>}2~6yD=JMOn{ojEN^s*UTNRES(-}Il4rFT>bf2r5e5%O*iP+u7AIyukGj!c`n*U?D>EB&; zjY%9UpRT#~Uq9ebish-SmMoi$KkY{u5QRV;8q_EQ*-QC6bAq-Tz?E^SsaNoaemHbACIgy**vjR0*m805z(s zlaHLNf29n|^TJ5+vK)$$4jv8w@C(<;LY3sbh4%rP^VaKbNM`Zn=C}(H?Y+LuY=$BGgmb5YPB=KC6p6ypM jYRzT?2gcL%-!MeZ5lgxA*yaFVv{RY6tUWR*fWBc z3|m^-n1se4Ws_dDbyT z%zd@&tAM_r&0P>Ri_hp}*ukFk(Qs|>cBvDueFH04`DyBz5H~0+F%i=tqt7`ya3`-Nj@i$8hMFgq# zlAe5UE6=l-`-WBESA}Km&%3t^3a5SN9s%ZVe&HGj>nOnBhS*UGm!j^#G;U&M40tvttPmtVSsU?w?r#Hv_WEVPnOQL^XsgJWI)gbw z#JFN5jDd7S`)k@Vvz0>Z%4Qn>n)N?o5h_FVc3T+Kh|X=X(sy@r-&lg5^BGOxplhg# zH!8L6=+p#Z)w!?TVpCq>md)ML&0L~wO&`;A&*APIGTh)U&BBzFPQ;^3cg0%r46{0& zrz*$q(Iotbrmn%;Z^Eew1V7GPliq-*Qv)%e=<>lp{8KlrXmPvC$4IC1D?w+k1yr#G zr0sM5+w54IZ?xUY^>$6O54_g@w3PVsw>uO9u zs(vaz%hrDsd(qZd)xOD55Cjzt9-KQr9?r1VQ# zYJbN|_4N;K;%jIo$%2f=>F6PAO6+}MTTL40SK4&r@ua3W&O;c!dMu~%x$w^>Mnhcn z2~HTXWH_l>YC>X!5R+CGvfoq<9;ir#PwTlhB|5xE!yvbQk|yNgdA}*w9(l$-wC8Vg zA_}aEi6bZkkUgEgtAjm8U=lsT1gq+-gmTpa1Pz(?8MvpHGhiiOE zes?2ZdGdCFjak#hx~xCOGSY{alm;W1@hid+tp;DMR%6E(f_dkqS1Ef$YE1YI_zyCe X9^6trG~2Nve^r3$?CEsNF(l(3%iCPK literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_vertical.png b/arcade/resources/assets/input_prompt/xbox/stick_l_vertical.png new file mode 100755 index 0000000000000000000000000000000000000000..7b2e6aa84c83290f4af578aa979589783fdf1bdc GIT binary patch literal 1405 zcmb7^`#aQm6vsbbGZ>d)gpA8F>)s5xq%l%v#wFwul9gmyu`@2M2u&ehvlXiid2$() z!K890qcMOMs+Py{G^*W-EOASz6NX@$vL`+5SHvS(4b*TUcw73KQ?=eI$mQ zeONEGquyeaPg^Qg2Fr6?f#lMVyEE0FIlNFjn>B$`gxzt$P~RqbVIa|74nj;bs`I)^ zuOF1#k|H{s>7D5R?LFAG1*#>-*ImGT>Xq-hydzsUNV9-^&=NF=PeqS{!4%6iJ|cKh z8IxnTvMJYNC>9~Z^1p>hS$kbHp(?{RmhkmbsHb2P9=xEl7uRP+ANf|h+yr) z%b&WVf{2=wpw*(`8&ky^m5p)wAZcI7&P@vA+v?TDS?-V_pj*uR6;QCvsV8+l2Z67n z;$G-h-3`a`hE`Vv#@*g7_jWr>#A)1sJ3KPk2*KQfAoS;-5ahh)P?)k>lfqj3IcSF z;A07|7V7+z${6_LV;5CNm#2XqIS%EIg~wiWnA%I{9`-d$W=0@TS-E3K?yy1kz1ndi zR*2+4(z#s*=^?;HXbvdDGa1VpjpeFt7#QlSNO?sHy-#6lYQZM74{b3z?~NYw8bMNd zs`ut}EJ?|?*#$-U^DJd$UM2KvYZoLi1ehnXV}a!J-_py0a}6k7LXf^({9DY)ob;j? z=?Ub|n3eTuJIYf+^L)Ppd_WWnZ0sdXgyu11H$?IDru;(C7KqqDf$SFEQ7E6WU8o01 z2JD1P!xnjI;?(2rXyk1L-l%m@RL`^xcn26eHPad!B<-gNd(_*4W^FdbcQ+bBF<; zON^Y9J290bBpYKJIE}E|3+N}TFkyJ)o&(G&cd`d&B~>4wuC5jyqJ%b|X|#f;n3js8 zBPlBp$FTO6B6*Hx;U}SJ`PivF1xrB5J zwfpT;WLPs6LkqGsLH(=2YwC3r;2oRbmc5*^nKcV@oy?Tx5w zm`H@aniw(0=Rj%6!8f=zd)5L$*;I|Tix6@mj1bNZGSHjlXW-X;u2lw>cb>3Qo1713 z!sdQc1VSMd{o%q7X0MD|g|BKRg~`eOh(JGdObD{*7FOYje^eUcN=ruiTwiNhfMPeL z-lbvxD4F*l8Owfnw%@-$ymgS;XR@4YUft8Ga5cpY^USso^E&US6F&ERg_0GfUa;LO;2QxOJ(=*9+lBGRz( zp4GeRuDSZl0dZ)6Hvg<1la0&c(}~sxRHC;WEZb?#A4yGRZ1b#Ie*J;Mc!%?)sGJ(x z2Evkb1`1cp+{1Inv9{#r$+AFfJ3or%WFlLOAVtJrZuWzAaTo&h(>-E$^o>YilE&|2 zv`ot8Ck_B_`1xsTCFM&=suW`-o-1zxWM8nzj<^}yFl|-RbI#_$O!eieiq4Lub?Gyn z8;zqH`w@xyESRm(6z?6sJ-jH>2K@?akNpSo9i2|4X0-yz9|geO#oM{g@g(aXMRbVD literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r.png b/arcade/resources/assets/input_prompt/xbox/stick_r.png new file mode 100755 index 0000000000000000000000000000000000000000..852e140575cdb8919b6399c66c16909dd668b6bf GIT binary patch literal 1286 zcmb7EeKga182|1CW3+?{GlUW?Ekr^SHe4^e?Dj^6?m4?RG|f_^{Je&gx5Sp0MbYF$ zLLu!ZqaspW(WJ^RiqoclW>h?|shsJfG)t&ht6XU(Y9p7D(06GSUJ7==l10 z1*;hP7Z{YP&q@n_s0hvU2=D;lPCj-iQcb1pY5sKY)&El}lWN_dHEmust>pvGLyQeV4SFH4uD?i{mu&6x+f>=X2q1>U(?0bJz5sMu>}KD$}(R;4hB|4U-3x8&Dao#Rl8Dbtz!-ct9$S^~)G7 z8Z5Q_7?E`Hl_$0C*LTUkxhAVg=U47M#_zGxKYe*ZZtFsxKhq2Dq z0tq1tD7jS1eVa+-Z_;*F4fTy%jt;rOO*(~24oV^%u_$ldAleGuoT^i3OQY|GooeLB zs8L#c8wCjsFI=EVJ@)S|;vvgyG>x^%ig-P2^0ZlVCGOG?K=9o)#`Vej`}&{z7ub## zg+PmZ88fi#jG`Lx2@g-6ds;+5l^dF5e}Xkf5&A~J)ozjm^&nxFv{3c6jW2fNOGtcd z_DKjrOFc6`nW2_Q;g_9(lNcH(83whtf?H0I7h>XCdXYxtHShmk+4k2M+~P~X(S{#8 zAex$VV$cb?I4RYgCaN#NIX_SuTg1E|+_!~yZOhlU3Lq|=~o z*Qwwn8zeM0e82}ZmfTCcP(1a*>W~GI)Zay0Xcj%N)H`yhH}YDF}_>6G2C;ud_9wND~m^|C55|%rto~mef!*Yc=D($|0^Nv*u)%x#Tc8N zIN;HXhE;8yw^|I;u|Hgz`PcAHH=UCUNk_h;E2YTJ!7m@Z4gx6h* literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_down.png b/arcade/resources/assets/input_prompt/xbox/stick_r_down.png new file mode 100755 index 0000000000000000000000000000000000000000..1930ed680138e4f769f3c7113f940c157dca49de GIT binary patch literal 1384 zcmb7^c|6p47{|Y34%dva>lw%M^&dfM-gd81?H8yu8wUWy5bJe)1Eu=EUDz9;7 z&>)j@L@Fs`NTb+g6_s+$*x70S+JE0000>0gZ5q` zLO&z{7xizv!VM7+;p9VP02=e91;I$sZtUsi;~@M$B{GT56&R_jqDeN^%ia5oO@6Y+ za{Ug2pm)#9uwB4Mm76U3)az_d_KD|yDxHj;RYzahnZ6SI^>2^%do`!!PNeO;9$5R^ z6vJgE`!uihP~YMXoA@r_qW)LlTTQD}4yT{Wfr081(4 zsaO>z=f+5c4Wahl*!oq)I?J_A?`RvaY~CnIwk_-paHt3uLzp>Q3nTkn%KQ-gAaOh^ z3BkNX+zEoNQ)(6llIqSIY5)`p;xa*7jF%xAEzYt{dz1_R+&#skc@y9yR)M4yq}1do zW|B_5lxdec^87K zR}wmpl=WNFXM1mk_(n$VwjR&3(A>P{b}dw4V8m02IW?hF<#b#gqOQ{AFnZXsmeI$0 zHoh9P^x=wZ&vD~?cz@v-I=5t;o8@>Xz%qQcGOXoj=(A~>1)-Qx`taN}Z%>R%yvp+1 z?JowV`)p&;&sUs|BmxO2w%nl9yER&WldHvC*x03sZFA5$GL-TzdPeaZ^<8 z-w$I+Z+<#(3Q<|7w^8LsA2S@g9>Ncrgz7#kKbeVat0zMd(m$aOD zw*2z;fyCd><9-el>u4grWhRyyQKGun{;(muR6Ez@W2n|v!UByMqR;o%7(Mu_<@SIX?ME} zGt~GxMXs?n4NC@EEqQ8f!zjl0zZqR_jlOJwGAZ;-ukM-{5Yy9%MsXyk&r%xhci@2K zyV1D)qzJjnXjF`fI%8zrq|_sCO1tWzW^p>#AMmt0lx&0Lm>o+ns0h)#x#=Z7mom5? zCTjuJL-p{EbiD52BsH2;>rkekcP@_Urz}{H@NQlF;%_|oJcq~^BE5>!DEf}0%r$z`#M`n>OX$Ehd2NL literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_horizontal.png b/arcade/resources/assets/input_prompt/xbox/stick_r_horizontal.png new file mode 100755 index 0000000000000000000000000000000000000000..f7fa95fb6a575c20d4665c62fae109a9eab13f17 GIT binary patch literal 1324 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&v7|ftIx;Y9?C1WI$O_~e2l#}z z{wD8fByS;{rwDAxNg}3ZR+rJaSW-r^>+5# zZHu*d+LSd;r0@N|dhTZHTv1OI$Lephwmx|-=PjpLDcr=|A1_zHe22mO0562v&-p+> zYpS{PN{)B`8E3dn6;Dbr^}EkHYy> zgG%Su>;8Y{S$<_+azQk5BhRb}Rg7NWPcvP-bJFb%v&Ng*ce9tQXfx$v+xq>~rOKrH zTnAnS?lM?ce4xc$bTY5DO^Nlx7;dgdFJ`~}mb`ZU=T40R|KzSO`Z==Ko|gUl`R`p1 z-=8CiapuAWWY*v?V~{eRl-Uq|&*o2@TY zd+ZPA{J@!?vsO(c!Ph{$LA@t~?Z9hS*-s0OF@`ZMsGV_%fh9rlOwE=AMa~Al(h?pH z2A-e#&Wr~PFKuP@&i?1kz&B?Ccfv__C+UQFFKSJ6-!oXON<$Vli$uV_f zLB@t--AW90JEfv5?F$({*f||yycWgyVoKeFZUcrdmd)N9OEZi^Q*e!EG*Ey-VTajENg1?+R~aS7v+A?`71#cWEwTMiReH*rcD27hO?iIA_9; zR>NUulA*Ddrvk@(}< zRNs`X8EamgTXu2DE%O;A8Q)lsU6WyAFn@l_zHg7=k6&*;zo}xZTXbJJ;^Ma1EzybA zIbzBTaXWne-afPP_CKE~ z>^iD0*0PHhTAF(t$TCeSulu>(_P!Xym90~!-Ew!~{*l-rG9z-y%p0!n6)rGtG0ePq zt|P8gsETFHYb~#|kKD>3%QZ|FiIv5$WF6o6Sbk~H8vb>uD`rTrZ(6?Q>COi$7!1Q4 zKb~f}C2zW=)ZggS>D0sDQ=Mz4*4~ZgKT^>8Q@}qaZuaMIXFE^7p0`>mQ!0~djaB;o znI5;f>eHlsqW4;Ax|RM)dT>SX!rT}=D|v&-Ig{>78x^ZtpH`n}P*wIyY>K2%!}>YA z`3=$z3p$=@GyI#x^n~TW5%wATm=65!dJ=7`blit?5%bKQ~H X9K5yVheiUhykhWl^>bP0l+XkKqN-ux literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_left.png b/arcade/resources/assets/input_prompt/xbox/stick_r_left.png new file mode 100755 index 0000000000000000000000000000000000000000..0d6b849e5e81c439f64aac9b0128d9249f1dd79b GIT binary patch literal 1317 zcmb7E`BPJ86#ZWE0$~fXYfuuTAX`8pB7++{iDCi)r&z57K?wqa2qBWHk3~%t5U7l7 zlA4GRL#$YFL1b~E;Kou=5CX9TQ2_%OQg)io^dIP*x%ZqibMBnq&d))#06lHIHUL16 zO7Uf=5&3tqX!TXf^5@h5M|PJ>RLKU|<=CblNLg3k%)&n2m>| zTi@Bgssz}#nyXtu*VpWqFSejRr9>YTpSD7xN>_4-LojoE2WQ_m9FnztkU*BzaJ%9W zEaud~(0;hhS3d}XNYCK1H~@Zce$JqIBA#1HkBOffNl!A!XGO) z{uKJyVbPd(bCieb;luM*TmxEj91qZ!PM9UU8VD`Be{_VbC#z_TUO=0))$J`WvIUY$z|aoNpBK#MF;Ie`Q2qAtBSeWI8DUo?p0**|Hd zZJ`gRH$E3GCY#G3_<}RRu>@$j3R2f2&{yuU`HvlxZ4kDX(gKz1zs5_m4x}}oL&Lqi zJ}w9^G3l%&pCwR@i9~-GlFbRs6NR>GsSyC$bjS|$v=^Gfj;k=`UnW;G!R6z52H-;e zHPYGS!GT!=OlN5;a~H?Z)fz(qbQqbA_t9WlCh2V$hdg1HjW=lG1cw6G z(Exie5WtHCzP1&*ExF9?B)tQ3n9v-Y(Z+o+Y%yH)Gy4(Bw0*(vgRi6x{XUxzzKb zTUXsjy)zGMr9?6fL>r>McYU5L4ta|2`;Kw(#&~}C_2fu5(Tjz%V=9bm*HNE&*F8wQ zgifv-$#E)D6$jr24Z@uutW8y#MakG?jQn~pM20Dvi1m=Y`Te4GbNmDGQen51y*AkD zW+2&Ln0U!w8C$v;*B@RpjR>-yvAJR+dNEOpA-2?y^O3qu%W0zOSO-;}`6?1sVS>ik zL^DzPX=vd-JvSRt-!KS=DGSt(Z`@`Z(n9W(;gx;?BCBVKNhe>XvRXBNY2}+6p4jlPgXb8bEAM_? zmFy0(pBzU8f_aIqpOOG!6?UKn(p}f|6O1gA3Pz^ZAUQ>bVVY@!#m@D8P%p@`DIDH0 zb)+4=gzBsWB~owxEZuyby?s$qoabM@d42`M=ncs!kmP0Rm#jp%{MR5%R3H+blHa_` TE;pxIT~vVTNAtbq&CK`*JA-Cm literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_press.png b/arcade/resources/assets/input_prompt/xbox/stick_r_press.png new file mode 100755 index 0000000000000000000000000000000000000000..faed4c47ff53a9fbc5446df2645d3347bb4ab70d GIT binary patch literal 1364 zcmb7^`#;lr9LGO1%O%WA5p8q7G}kza5?z`K2 zpPbOYQijX>SALm94#il{J)QuxUs^AXLdbUqvOi^)?EjS9BtPH4&R>yFnwk3of>Hu6 z;oRxHwk*+M?sOdz5J{&v@=v#Yy*(*8JYkKX{OT4pXzyPNo2F#(+$^F0wrEK4ctA2k zT6^b(d1v|d_X7H%_pls*h3H6wO?CQ%`nm2L=A*<%7|u7CR0vMorYb4tN@lX4X5{Tj z5n`g*X**h}13wptXcT%$GRjh)&arxRiqU57@36(fh59|8jd7C+A^=c_sZ%C7gLFi( z1wR^8^$=3eN1Dqvl>gGD34%?=OewNm_2Dx{;5C*p=SI)4V7y61K*gPO9%k_Df-8sD z{>`p5@SHjv=p)1QS|f>{nMl8yzS-d`umcNA#@0L7PjEX@*Ltm%?Zq+Jdezl5_{skI zYiF@gTff7|A6QQ=Y;njoSScRA7RUaP zY6I2&;hu&?o3{f!d)$meZfp1DXF$syTM;TqzF%lh+6p;$sZ03es9Aykj<7?$zUO4#IQ|G3P5v9`qauYX0Xj~cuoL`v(2)&rK}PTg)Pg+Ot)x$p z&KuVs1=Bx?zTa(ZfM@$JWkUJb_rQSfq72m+F$eGt0shGLUNOx|1HY&DzMNO1o2#x|X6?l)w0x~J&0=zxXn%9Jy<(ZAg zOB(iL$u*SIS*KX&z$)ve`tKA)$*#EF^XqOn zJL)Gq6KAxn$BRFfRIwC;f2nRWYC8~nksABv>qn-GnZDZaSk;%EbV}bI<33#$_XW;u zB(1#Nd`$ECa83E#y*jkW^7!e>0Gq|N$132}_)0RZf6~{+(F$*6n!a$1;<6d|%!q+2 z_)6S_vY%2YZL6c literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_right.png b/arcade/resources/assets/input_prompt/xbox/stick_r_right.png new file mode 100755 index 0000000000000000000000000000000000000000..0473f3ac6a473077ff8a4dd2cbc0379af72e8c08 GIT binary patch literal 1321 zcmb7^|3A}v6vyBDjLm0^;X#`(-Hj`^d|RZrbG0$#`+T{KL{sUiL@I7|uYFJ ztE;*_qBXJW`ck$`CMjPZ`I@1aZmua3+vTqNAKdeJobx*8@i?!?`RP1!SOH#moDmKH zfcM_T3|1-fB{k5hx+uN0q7t=(41WdyHTjxKj=E}gVEKi5tpA@-nN;frf{ah9j&LG4 zAS5w=CpYk`2lFp=#(%YoX#pC^Ga0H&=e(JW&>x5A%4YMX$XbXqbk?dvv2)I+Jl$DFy{nLpA*{-XkuF;NYOfh~F&4#K{$!`NKtOuirQPpfB->^_V270Nys({) zTuhttr9|sGJ2{S`*?z~4Mn~Ao-3;Rz(EP>sYn{^L(^PFjyR}vHFHg?~fm4tC8L5dS zBiZ|a(oFfDNbyOFL=`uLMPISZ15q!yC5D4>2neyDVPG2B5s9*GTYDQ$|!-~pjJNKpuQPT zE^e7DC`)(O*QfyKcNZlkL!SNS>0md7+q86ka_+Y?n5CTRL+SIHb!bvYsh%{N1SPE) zyBKI`iSHv#TiJsoVwfP~XV{0Y!v*kU8II6|fyjggEn z=ro>{u)UcQv6(=Ile9F^w&6KA;XK2JdPxKe9=8Y!4psDQ z@rWP8HD#(pavCfQ9Z-B*Oi8V{S@e8<8*(|Vo`>Q;NiDe5CR;NAF~_$KMai{$y+9)u zPU02bS`zi6DrkSYxXFXz))3#4#vuU?jEe3dL}NIx)}h8>c4W)qz+rsmb>=OX01FoB z?wdGC6ARV(#KJeMsw+EXz|h6DrEjZuUuko*zPE+Z*g!2u@XjwHhI1!kgG4>yOm3)F zYMEVoRG;TmXJ2B~9GWFTFS`Wy&zW%!eLoPpwJJ8bs$U-s5i5UaTo?w$$Vv2dU?hb4I6fn~*@D!3lWRi6Dz!|oZ#KYAbtzly_^=f6m~ z^lXtgsE~aU>tsJxXWch!9BdixezhbIcW}+Cc3f3Jq7#LcM@n-<=cetf|AUDB-Y-Ov YW2=fqv|cebLPx# literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_up.png b/arcade/resources/assets/input_prompt/xbox/stick_r_up.png new file mode 100755 index 0000000000000000000000000000000000000000..98d7d1e4a948176cd0b4fe64a3fe91bfdf95a710 GIT binary patch literal 1370 zcmb7^|34FW9LGP~e3@m&%9pfV7S<^ZMJr)*H<=pwvV^W$oNp`A=}^R?YtEONiM}kY zICqrq5oXO)L~g!+SqO(%Ov$&@mQME{-0Sgpzn|~-HDr!}YqJGwdoP=0Nl@}%n8zRR6Pcl!E&wDz=iu3MG3yf$f{^K|n% zbL|kRJz;Zcy~W6h_b5KFWoo;uIX!lvIMQPVW_dFec}AKn9Zo9r%@=-D`&)so6&dFQp@NHz6U70T@?HtrB32^cEiy zDuP2Ii%T*_4v7ie5m#{ucjTa$kU8Sg0b^h_N9Dobz`O!3{cXMuF3p;cg*OAdH~ai1 z*1+Ag^m5mS1huk;Ft0j6T=hu-!$QTqUHov~0G?idwv&`pDS(UFN&H)b^PA&Z*DG~_ z|0{g|B0W&&8E}x7vWNYd#Osq=tU(d2FS=o}-u6>Qr7=*3ETEk@gn{>>(J#QaCvw-z zl3qF7gZocK+X+?e+*tuRHVI*g9d?Ly|8Rb1lM?P1ze?NHPM2ssv|Iw1;e>hh(xL`S zahL0qO*qnE|JS!s-ZXE_*=ZI^Pv{Au%m+NFcOVusOrkxuUG(Vy=p)=1GQrqi-go;! zgBhBoqS-v1jPV3l%*_D#)zq!j6|4K}sM=&mld*BA@Rvu=Z|WL~?+icZQ87*YN=6x& zX;~GfO?*IL1Oj~=%alKE{4j2aRXa!uyJ5jG9DL>Mgmp4YO~jMV{Uekl_4l? zBZJp7my4!_tBN#?c77?8LSt&}LyGA^&bbq$sok&1aDQv$Xn-wLh8lSQ3lH+7o2gdw0`je}?&Us&lzGPVrp)TZbx z!~PBd$3hJ>fsZ*Da;SO@#ykeuRn}_OkWN(D7RaAW#m!8}@s|r)>WowC%6Mo)GR(}c z1y#V+TG{JMhSfYcznLGXij_rjUcgKi<3j<}N8QDl9SttuPHnlX`YUI5iOqKme&Z3p ziH+coB)TZ2v5S@E)oLB>UV#8I^0qQ*7<=5lJTi3kN~z>hwr52q%+_>YRklGsfu8?; zr&|l9yx6~7w}N+|>N~vIQr56sz?hgRQtqwkit>w>QSNv(2l1ZI4P54W-fkemVkCyx z2&&FKbL`8M#_E^D{ckJjn0xk}Owh79i;fnuy=uSIfR& zU6eCO9o%suy9;(YlPOPuKf{zJg$eSdk$FN9Vaj<2INMg->?~w;qgT9l^f0wIe1J*| zFLi8R=Ct>&1ng_}*w8kFICv@}T+fc#y@XbcW|Z$wtd2F(qI*wgNa)KSg_T=ZOn6^n zmG=)9U{hqin6xg09uqm+5qVmFEYNrn`_mOv^H%KZ<*V=V1}EN$#UVqukQ$%Q9!Q%s z>x)V|eEml1pktF(!O76rGqmfxWBeF)i?L611g>8l1};AcA&RzJ?6lXRhMV2{k%;TQ z546!QHjS!@NeAYVTaSXLHX39S>nW_D8eCtXpHs7ik7nQ;6-0=Ugw^M6q?a>`&&q4t zBVE|@#G#7wzGmq7J3RtcU#SpTfE&0txgs(6Y1=t&o`HliMfuntMHC|=U_;ER*{1@GiiuwQm literal 0 HcmV?d00001 diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_vertical.png b/arcade/resources/assets/input_prompt/xbox/stick_r_vertical.png new file mode 100755 index 0000000000000000000000000000000000000000..5d2cfe94bb206e2eba7eccd410911a2d46f12c35 GIT binary patch literal 1465 zcmb7E`#;lr9R7S8GYm^*a@&-OoZP8{Vj`{P5;n4Abiv$CSxk(txkheXmc+3x%0f1* zD3^JmagH68N~9<$p_FQ*)y~eJaNe)i`+45a>-9XpJU_fo`FeY5sOhNz05piZ-Tf2{ z|F4g_epirE1i^~{fS_1i7N)Gww!WkQ5BdKYMMz;^BV$Dsn44tpi0z>JM-2f=BA-WR+n4=#q#TH+~qv3sT$h0MSr!?_h+sf$6%9Sq;*rbje zo_ydr>PEfElr3Ozuw38O{80F^2VB;8(NO78996Cx$wed*4b&P-RZ5cl-Co*2?!C+=!w=G6LeX#ed^R;hYz|2T`F`FYVp;nZ91Z86m ztutEWF~(Ya@6T_~EUqW*G{$BCen(KKxG(DGq{UW(rw{3zo7vwC5` z&IcjD$Qoa3ldz;}eRv!JUtxbu#r!oV1cZqrdj+0Z4Evrf6hO%eRz|FAX~ z#9||n`3UYS_Q(Llq|y1lymAQ7?cZhuIE~v{oY~N)_&-ayL=4bNj`KgMM)q-rOd_G{ zntU(%VX`VRn6M7o$aSeSgNJYD)}Kcv+LZ!z-zpl2u`Dl9QMS64t>nvH=4}J5{h z*5@l(p>fjyPNVIH!MXDdJUn7Z%>*!-QzfW`*nijv20AWB5D3P^oC9&J)7^7D_72;ygAjMF5gY zb!ufTFQX=xb7YRoS_K|v3`aKZ#fejBSj{7A6XS}`om2BEnk$(L2rlFoyWz?8YBWbGb8%hYmm=%Wdxb$f!eX9y<}blj zO2vWpF>SB*bkT3$tQwEMqwqM->4KfjNw0aq6afpXV;-`quaJ~APFoigsAyu;KS5qi z?zyp%y`mV=QyP6Q?`Gg_$Ye%*Z0@zqm5b8RGsU5w#VNPqUSyhx%&FahcRc1r#^pg~ z-&F!PoJ8f#{^%x4%%4_17fc%$TE7oh>1A(7nbsioXofWopNV_LFY<)@lNhF5@2{9G zLo-TwGEtQMl<>3Z8Bp5rkv^F`)77l{&w`|~kmQpU)T!k>Zn-PbVC3Nd^3d$h*icdj zxvx!QqWNVW<)u16S=BOaMc&-}U-@O}n!ss1JZ&o+t?h1uXhCDB5vc}}O-_KZoysQG zq^Sev>_s9T%@cnTN?Hzs;p3ZXu`d!;lmDqgSA8|6NpdH5|BZvw>#dc#5Ywbo+luB Lc)Q+Wc( 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 Date: Sun, 23 Mar 2025 15:37:55 +0100 Subject: [PATCH 085/279] Fix broken references in docs: First pass (#2616) * Enable nitpicky mode * Sound.source was never exposed in docs * Fix broken refs in sound manual * Fix performance_tips * Fix gui docs * Fix event_loop * arcade.gl: Remove optional markers in docstrings * gl module fixes * Remove optional annotations in napoleon docstrings * Camera fixes * Fix a loose reference to pyglet.math.Vec3 by prefixing the full path * More doc fixes * Missing docstrings in camera module * More fixes * Disable nitpicky --------- Co-authored-by: pushfoo <36696816+pushfoo@users.noreply.github.com> --- CONTRIBUTING.md | 3 +- arcade/application.py | 64 ++++++++++---------- arcade/camera/camera_2d.py | 21 +++---- arcade/camera/data_types.py | 9 ++- arcade/camera/perspective.py | 6 +- arcade/camera/projection_functions.py | 36 +++++++++++ arcade/context.py | 28 ++++----- arcade/gl/buffer.py | 4 +- arcade/gl/context.py | 30 +++++----- arcade/gl/framebuffer.py | 4 +- arcade/gl/program.py | 14 ++--- arcade/gl/texture.py | 2 +- arcade/gl/texture_array.py | 4 +- arcade/gl/types.py | 30 ++++++---- arcade/gl/vertex_array.py | 6 +- arcade/gui/view.py | 3 + arcade/gui/widgets/__init__.py | 6 +- arcade/sections.py | 18 +++--- arcade/shape_list.py | 3 + arcade/sound.py | 3 + arcade/sprite/animated.py | 2 +- arcade/text.py | 70 +++++++++++----------- arcade/texture/generate.py | 10 ++-- arcade/texture/loading.py | 2 +- arcade/texture/manager.py | 12 ++-- arcade/texture/spritesheet.py | 4 +- arcade/texture/texture.py | 4 +- arcade/texture_atlas/atlas_default.py | 4 +- arcade/texture_atlas/region.py | 2 +- arcade/utils.py | 2 +- doc/api_docs/gl/index.rst | 1 + doc/api_docs/gl/types.rst | 38 ++++++++++++ doc/example_code/shape_list_demo.rst | 2 +- doc/example_code/sprite_move_scrolling.rst | 2 +- doc/extensions/prettyspecialmethods.py | 3 + doc/programming_guide/camera.rst | 2 +- doc/programming_guide/event_loop.rst | 24 ++++---- doc/programming_guide/gui/concepts.rst | 61 ++++++++++--------- doc/programming_guide/gui/own_layout.rst | 4 +- doc/programming_guide/gui/own_widgets.rst | 8 +-- doc/programming_guide/performance_tips.rst | 16 ++--- doc/programming_guide/sound.rst | 24 ++++---- pyproject.toml | 7 +-- util/update_quick_index.py | 8 +++ 44 files changed, 350 insertions(+), 256 deletions(-) create mode 100644 doc/api_docs/gl/types.rst diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0864aef4dd..38ca1b7333 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,7 +103,7 @@ The minium for docstrings is covering all parameters in an `Args:` block. Args: width: The width of something height: The height of something - title (optional): The title of something + title: The title of something ``` * `Args:` should be used for all parameters @@ -115,7 +115,6 @@ Args: * `Attributes:` we should try to avoid it and instead document the attributes in the code * Types are visible in the api docs. It's not mandatory to include types in docstring, however, simple types like `int`, `str`, `float`, `bool` can be included. -* Using `optional` is a good way to indicate that a parameter is optional. * Properties and attribute docs don't need a type when this is already clear from type or return annotation. diff --git a/arcade/application.py b/arcade/application.py index a0979daae7..edf55efb04 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -86,51 +86,51 @@ class Window(pyglet.window.Window): .. _pyglet_pg_window_style: https://pyglet.readthedocs.io/en/latest/programming_guide/windowing.html#window-style Args: - width (optional): + width: Window width. Defaults to 1280. - height (optional): + height: Window height. Defaults to 720. - title (optional): + title: The title/caption of the window - fullscreen (optional): + fullscreen: Should this be full screen? - resizable (optional): + resizable: Can the user resize the window? - update_rate (optional): + update_rate: How frequently to run the on_update event. - draw_rate (optional): + draw_rate: How frequently to run the on_draw event. (this is the FPS limit) - fixed_rate (optional): + fixed_rate: How frequently should the fixed_updates run, fixed updates will always run at this rate. - fixed_frame_cap (optional): + fixed_frame_cap: The maximum number of fixed updates that can occur in one update loop. defaults to infinite. If large lag spikes cause your game to freeze, try setting this to a smaller number. This may cause your physics to lag behind temporarily. - antialiasing (optional): + antialiasing: Use multisampling framebuffer (antialiasing) samples: Number of samples used in antialiasing (default 4). Usually this is 2, 4, 8 or 16. - gl_version (optional): What OpenGL version to request. + gl_version: What OpenGL version to request. This is ``(3, 3)`` by default and can be overridden when using more advanced OpenGL features. - screen (optional): Pass a pyglet :py:class:`~pyglet.display.Screen` to + screen: Pass a pyglet :py:class:`~pyglet.display.Screen` to request the window be placed on it. See `pyglet's window size & position guide `_ to learn more. - style (optional): Request a non-default window style, such as borderless. + style: Request a non-default window style, such as borderless. Some styles only work in certain situations. See `pyglet's guide to window style `_ to learn more. - visible (optional): + visible: Should the window be visible immediately - vsync (optional): + vsync: Wait for vertical screen refresh before swapping buffer This can make animations and movement look smoother. - gc_mode (optional): Decides how OpenGL objects should be garbage collected + gc_mode: Decides how OpenGL objects should be garbage collected ("context_gc" (default) or "auto") - center_window (optional): + center_window: If true, will center the window. - enable_polling (optional): + enable_polling: Enabled input polling capability. This makes the :py:attr:`keyboard` and :py:attr:`mouse` attributes available for use. @@ -356,17 +356,17 @@ def clear( # type: ignore # not sure what to do here, BaseWindow.clear is stati set through :py:attr:`~arcade.Window.background_color`. Args: - color (optional): + color: Override the current background color with one of the following: 1. A :py:class:`~arcade.types.Color` instance 2. A 3 or 4-length RGB/RGBA :py:class:`tuple` of byte values (0 to 255) - color_normalized (optional): + color_normalized: override the current background color using normalized values (0.0 to 1.0). For example, (1.0, 0.0, 0.0, 1.0) making the window contents red. - viewport (optional): + viewport: The area of the window to clear. By default, the entire window is cleared. The viewport format is ``(x, y, width, height)``. """ @@ -455,19 +455,19 @@ def set_fullscreen( to the size it was before entering fullscreen mode. Args: - fullscreen (optional): + fullscreen: Should we enter or leave fullscreen mode? - screen (optional): + screen: Which screen should we display on? See :func:`get_screens` - mode (optional): + mode: The screen will be switched to the given mode. The mode must have been obtained by enumerating `Screen.get_modes`. If None, an appropriate mode will be selected from the given `width` and `height`. - width (optional): - Override the width of the window. Will be rounded to :py:attr:`int`. - height (optional): - Override the height of the window. Will be rounded to :py:attr:`int`. + width: + Override the width of the window. Will be rounded to :py:class:`int`. + height: + Override the height of the window. Will be rounded to :py:class:`int`. """ # fmt: off super().set_fullscreen( @@ -1280,7 +1280,7 @@ class View: and a game over screen. Each of these could be a different view. Args: - window (optional): + window: The window this view is associated with. If None, the current window is used. (Normally you don't need to provide this). """ @@ -1304,15 +1304,15 @@ def clear( set through :py:attr:`arcade.View.background_color`. Args: - color(optional): + color: override the current background color with one of the following: 1. A :py:class:`~arcade.types.Color` instance 2. A 3 or 4-length RGB/RGBA :py:class:`tuple` of byte values (0 to 255) - color_normalized (optional): + color_normalized: Override the current background color using normalized values (0.0 to 1.0). For example, (1.0, 0.0, 0.0, 1.0) making the window contents red. - viewport (optional): + viewport: The viewport range to clear """ if color is None and color_normalized is None: diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index 5aefd360a7..d0b048a9b4 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -197,10 +197,10 @@ def from_camera_data( Args: camera_data: - A :py:class:`~arcade.camera.data.CameraData` + A :py:class:`~arcade.camera.CameraData` describing the position, up, forward and zoom. projection_data: - A :py:class:`~arcade.camera.data.OrthographicProjectionData` + A :py:class:`~arcade.camera.OrthographicProjectionData` which describes the left, right, top, bottom, far, near planes and the viewport for an orthographic projection. render_target: @@ -377,10 +377,10 @@ def match_target( Sets the viewport to the size of the Camera2D's render target. Args: - viewport: Flag whether to equalise the viewport to the area of the render target - projection: Flag whether to equalise the size of the projection to - match the render target - The projection center stays fixed, and the new projection matches only in size. + viewport: Flag whether to equalize the viewport to the area of the render target + projection: Flag whether to equalize the size of the projection to + match the render target. + The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to also center the camera to the value. Off by default @@ -415,14 +415,14 @@ def update_values( aspect: float | None = None, ): """ - Convienence method for updating the viewport, projection, position + Convenience method for updating the viewport, projection, position and a few others with the same value. Args: value: The rect that the values will be derived from. - viewport: Flag whether to equalise the viewport to the value. - projection: Flag whether to equalise the size of the projection to match the value. - The projection center stays fixed, and the new projection matches only in size. + viewport: Flag whether to equalize the viewport to the value. + projection: Flag whether to equalize the size of the projection to match the value. + The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to also center the camera to the value. Off by default @@ -456,6 +456,7 @@ def update_values( def aabb(self) -> Rect: """ Retrieve the axis-aligned bounds box of the camera's view area. + If the camera isn't rotated , this will be precisely the view area, but it will cover a larger area when it is rotated. Useful for CPU culling """ diff --git a/arcade/camera/data_types.py b/arcade/camera/data_types.py index 32b2219a34..2291c4f564 100644 --- a/arcade/camera/data_types.py +++ b/arcade/camera/data_types.py @@ -417,15 +417,14 @@ def use(self) -> None: :py:attr:`~arcade.Window.current_camera` to this object #. Calculate any required view and projection matrices #. Set any resulting values on the current - :py:class:`~arcade.context.ArcadeContext`, including the: + :py:class:`~arcade.ArcadeContext`, including the: - * :py:attr:`~arcade.context.ArcadeContext.viewport` - * :py:attr:`~arcade.context.ArcadeContext.view_matrix` - * :py:attr:`~arcade.context.ArcadeContext.projection_matrix` + * :py:attr:`~arcade.ArcadeContext.viewport` + * :py:attr:`~arcade.ArcadeContext.view_matrix` + * :py:attr:`~arcade.ArcadeContext.projection_matrix` This method should **never** handle cleanup. That is the responsibility of :py:attr:`.activate`. - """ ... diff --git a/arcade/camera/perspective.py b/arcade/camera/perspective.py index 3d3bf336e7..0e1b26a83f 100644 --- a/arcade/camera/perspective.py +++ b/arcade/camera/perspective.py @@ -108,7 +108,7 @@ def generate_projection_matrix(self) -> Mat4: """Generates a projection matrix. This is an alias of - :py:class:`arcade.camera.get_perspective_matrix`. + :py:meth:`generate_perspective_matrix`. """ return generate_perspective_matrix(self._projection, self._view.zoom) @@ -116,7 +116,7 @@ def generate_view_matrix(self) -> Mat4: """Generates a view matrix. This is an alias of= - :py:class:`arcade.camera.get_view_matrix`. + :py:meth:`generate_view_matrix`. """ return generate_view_matrix(self._view) @@ -171,7 +171,7 @@ def project(self, world_coordinate: Point) -> Vec2: """Convert world coordinates to pixel screen coordinates. If a 2D :py:class:`~pyglet.math.Vec2` is provided instead of a 3D - :py:class:`Vec3`, then one will be calculated to the best of the + :py:class:`~pyglet.math.Vec3`, then one will be calculated to the best of the method's ability. Args: diff --git a/arcade/camera/projection_functions.py b/arcade/camera/projection_functions.py index 2745fb90f5..5321b7e21f 100644 --- a/arcade/camera/projection_functions.py +++ b/arcade/camera/projection_functions.py @@ -125,6 +125,15 @@ def project_orthographic( view_matrix: Mat4, projection_matrix: Mat4, ) -> Vec2: + """ + Project a world coordinate to a screen coordinate using an orthographic projection. + + Args: + world_coordinate: The world coordinate to project. + viewport: The viewport of the camera. + view_matrix: The view matrix of the camera. + projection_matrix: The projection matrix of the camera. + """ x, y, *_z = world_coordinate z = 0.0 if not _z else _z[0] @@ -144,6 +153,15 @@ def unproject_orthographic( view_matrix: Mat4, projection_matrix: Mat4, ) -> Vec3: + """ + Unproject a screen coordinate to a world coordinate using an orthographic projection. + + Args: + screen_coordinate: The screen coordinate to unproject. + viewport: The viewport of the camera. + view_matrix: The view matrix of the camera. + projection_matrix: The projection matrix of the camera. + """ x, y, *_z = screen_coordinate z = 0.0 if not _z else _z[0] @@ -165,6 +183,15 @@ def project_perspective( view_matrix: Mat4, projection_matrix: Mat4, ) -> Vec2: + """ + Project a world coordinate to a screen coordinate using a perspective projection. + + Args: + world_coordinate: The world coordinate to project. + viewport: The viewport of the camera. + view_matrix: The view matrix of the camera. + projection_matrix: The projection matrix of the camera. + """ x, y, *_z = world_coordinate z = 1.0 if not _z else _z[0] @@ -188,6 +215,15 @@ def unproject_perspective( view_matrix: Mat4, projection_matrix: Mat4, ) -> Vec3: + """ + Unproject a screen coordinate to a world coordinate using a perspective projection. + + Args: + screen_coordinate: The screen coordinate to unproject. + viewport: The viewport of the camera. + view_matrix: The view matrix of the camera. + projection_matrix: The projection matrix of + """ x, y, *_z = screen_coordinate z = 1.0 if not _z else _z[0] diff --git a/arcade/context.py b/arcade/context.py index 9a3a8d7e55..8ca0b58749 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -358,23 +358,23 @@ def load_program( Args: vertex_shader: Path to the vertex shader. - fragment_shader (optional): - Path to the fragment shader (optional). - geometry_shader (optional): - Path to the geometry shader (optional). - tess_control_shader (optional): + fragment_shader: + Path to the fragment shader. + geometry_shader: + Path to the geometry shader. + tess_control_shader: Tessellation Control Shader. - tess_evaluation_shader (optional): + tess_evaluation_shader: Tessellation Evaluation Shader. - common (optional): + common: Common files to be included in all shaders. - defines (optional): + defines: Substitute `#define` values in the source. - varyings (optional): + varyings: The name of the out attributes in a transform shader. This is normally not necessary since we auto detect them, but some more complex out structures we can't detect. - varyings_capture_mode (optional): + varyings_capture_mode: The capture mode for transforms. Based on these settings, the `transform()` method will accept a single @@ -435,7 +435,7 @@ def load_compute_shader( Args: path: Path to texture - common (optional): + common: Common sources injected into compute shader """ from arcade.resources import resolve @@ -492,13 +492,13 @@ def load_texture( The min and mag filter. Default is ``None``. build_mipmaps: Build mipmaps for the texture. Default is ``False``. - internal_format (optional): + internal_format: The internal format of the texture. This can be used to override the default internal format when using sRGBA or compressed textures. - immutable (optional): + immutable: Make the storage (not the contents) immutable. This can sometimes be required when using textures with compute shaders. - compressed (optional): + compressed: If the internal format is a compressed format meaning your texture will be compressed by the GPU. """ diff --git a/arcade/gl/buffer.py b/arcade/gl/buffer.py index de565dee41..7504bc46bd 100644 --- a/arcade/gl/buffer.py +++ b/arcade/gl/buffer.py @@ -249,10 +249,10 @@ def orphan(self, size: int = -1, double: bool = False): it will be deallocated by OpenGL when completed. Args: - size: (optional) + size: New size of buffer. -1 will retain the current size. Takes precedence over ``double`` parameter if specified. - double (optional): + double: Is passed in with `True` the buffer size will be doubled from its current size. """ diff --git a/arcade/gl/context.py b/arcade/gl/context.py index b7f33c24f6..d1a86c08bb 100644 --- a/arcade/gl/context.py +++ b/arcade/gl/context.py @@ -1029,7 +1029,7 @@ def texture( dtype: The data type of each component: f1, f2, f4 / i1, i2, i4 / u1, u2, u4 data: - The texture data (optional). Can be ``bytes`` + The texture data. Can be ``bytes`` or any object supporting the buffer protocol. wrap_x: How the texture wraps in x direction @@ -1114,7 +1114,7 @@ def depth_texture( Args: size: The size of the texture - data (optional): + data: The texture data. Can be``bytes`` or any object supporting the buffer protocol. """ @@ -1200,13 +1200,13 @@ def geometry( ) Args: - content (optional): + content: List of :py:class:`~arcade.gl.BufferDescription` - index_buffer (optional): + index_buffer: Index/element buffer - mode (optional): + mode: The default draw mode - mode (optional): + mode: The default draw mode index_element_size: Byte size of a single index/element in the index buffer. @@ -1241,23 +1241,23 @@ def program( Args: vertex_shader: vertex shader source - fragment_shader (optional): + fragment_shader: fragment shader source - geometry_shader (optional): + geometry_shader: geometry shader source - tess_control_shader (optional): + tess_control_shader: tessellation control shader source - tess_evaluation_shader (optional): + tess_evaluation_shader: tessellation evaluation shader source - common (optional): + common: Common shader sources injected into all shaders - defines (optional): + defines: Substitute #defines values in the source - varyings (optional): + varyings: The name of the out attributes in a transform shader. This is normally not necessary since we auto detect them, but some more complex out structures we can't detect. - varyings_capture_mode (optional): + varyings_capture_mode: The capture mode for transforms. - ``"interleaved"`` means all out attribute will be written to a single buffer. @@ -1326,7 +1326,7 @@ def compute_shader(self, *, source: str, common: Iterable[str] = ()) -> ComputeS Args: source: The glsl source - common (optional): + common: Common / library source injected into compute shader """ src = ShaderSource(self, source, common, gl.GL_COMPUTE_SHADER) diff --git a/arcade/gl/framebuffer.py b/arcade/gl/framebuffer.py index 15b7edc0ed..188c5a5634 100644 --- a/arcade/gl/framebuffer.py +++ b/arcade/gl/framebuffer.py @@ -41,9 +41,9 @@ class Framebuffer: Args: ctx: The context this framebuffer belongs to - color_attachments (optional): + color_attachments: A color attachment or a list of color attachments - depth_attachment (optional): + depth_attachment: A depth attachment """ diff --git a/arcade/gl/program.py b/arcade/gl/program.py index 7f531c0cb1..27d05504cc 100644 --- a/arcade/gl/program.py +++ b/arcade/gl/program.py @@ -45,19 +45,19 @@ class Program: Args: ctx: The context this program belongs to - vertex_shader (optional): + vertex_shader: Vertex shader source - fragment_shader (optional): + fragment_shader: Fragment shader source - geometry_shader (optional)v: + geometry_shader: Geometry shader source - tess_control_shader (optional): + tess_control_shader: Tessellation control shader source - tess_evaluation_shader (optional): + tess_evaluation_shader: Tessellation evaluation shader source - varyings (optional): + varyings: List of out attributes used in transform feedback. - varyings_capture_mode (optional): + varyings_capture_mode: The capture mode for transforms. ``"interleaved"`` means all out attribute will be written to a single buffer. ``"separate"`` means each out attribute will be written separate buffers. diff --git a/arcade/gl/texture.py b/arcade/gl/texture.py index 18ae743628..479fa23ce8 100644 --- a/arcade/gl/texture.py +++ b/arcade/gl/texture.py @@ -56,7 +56,7 @@ class Texture2D: dtype: The data type of each component: f1, f2, f4 / i1, i2, i4 / u1, u2, u4 data: - The texture data (optional). Can be bytes or any object supporting + The texture data. Can be bytes or any object supporting the buffer protocol. filter: The minification/magnification filter of the texture diff --git a/arcade/gl/texture_array.py b/arcade/gl/texture_array.py index f80aa9773e..c75d666e82 100644 --- a/arcade/gl/texture_array.py +++ b/arcade/gl/texture_array.py @@ -57,7 +57,7 @@ class TextureArray: dtype: The data type of each component: f1, f2, f4 / i1, i2, i4 / u1, u2, u4 data: - The texture data (optional). Can be bytes or any object supporting + The texture data. Can be bytes or any object supporting the buffer protocol. filter: The minification/magnification filter of the texture @@ -662,7 +662,7 @@ def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> :class:`~arcade.gl.Buffer` or buffer protocol object with data to write. level: The texture level to write (LoD level, now layer) - viewport (optional): + viewport: The area of the texture to write. Should be a 3 or 5-component tuple `(x, y, layer, width, height)` writes to an area of a single layer. If not provided the entire texture is written to. diff --git a/arcade/gl/types.py b/arcade/gl/types.py index 414fec15f1..190ea147ad 100644 --- a/arcade/gl/types.py +++ b/arcade/gl/types.py @@ -23,8 +23,8 @@ tuple[PyGLenum, PyGLenum], tuple[PyGLenum, PyGLenum, PyGLenum, PyGLenum] ] -# Depth compare functions -compare_funcs = { +#: Depth compare functions +compare_funcs: dict[str | None, int] = { None: gl.GL_NONE, "<=": gl.GL_LEQUAL, "<": gl.GL_LESS, @@ -36,8 +36,8 @@ "1": gl.GL_ALWAYS, } -# Swizzle conversion lookup -swizzle_enum_to_str = { +#: Swizzle conversion lookup +swizzle_enum_to_str: dict[int, str] = { gl.GL_RED: "R", gl.GL_GREEN: "G", gl.GL_BLUE: "B", @@ -45,7 +45,9 @@ gl.GL_ZERO: "0", gl.GL_ONE: "1", } -swizzle_str_to_enum = { + +#: Swizzle conversion lookup +swizzle_str_to_enum: dict[str, int] = { "R": gl.GL_RED, "G": gl.GL_GREEN, "B": gl.GL_BLUE, @@ -62,7 +64,7 @@ gl.GL_RGB_INTEGER, gl.GL_RGBA_INTEGER, ) -# format: (base_format, internal_format, type, size) +#: Pixel format lookup (base_format, internal_format, type, size) pixel_formats = { # float formats "f1": ( @@ -124,7 +126,7 @@ } -# String representation of a shader type +#: String representation of a shader types SHADER_TYPE_NAMES = { gl.GL_VERTEX_SHADER: "vertex shader", gl.GL_FRAGMENT_SHADER: "fragment shader", @@ -133,6 +135,7 @@ gl.GL_TESS_EVALUATION_SHADER: "tessellation evaluation shader", } +#: Lookup table for OpenGL type names GL_NAMES = { gl.GL_HALF_FLOAT: "GL_HALF_FLOAT", gl.GL_FLOAT: "GL_FLOAT", @@ -154,7 +157,7 @@ def gl_name(gl_type: PyGLenum | None) -> str | PyGLenum | None: class AttribFormat: - """ " + """ Represents a vertex attribute in a BufferDescription / Program. This is attribute metadata used when attempting to map vertex shader inputs. @@ -166,9 +169,9 @@ class AttribFormat: The OpenGL type such as GL_FLOAT, GL_HALF_FLOAT etc. bytes_per_component: Number of bytes for a single component - offset (optional): + offset: Offset for BufferDescription - location (optional): + location: Location for program attribute """ @@ -227,7 +230,7 @@ class BufferDescription: be used once for the whole geometry. The geometry will be repeated a number of times equal to the number of items in the Buffer. - Example:: + .. code-block:: python # Describe my_buffer # It contains two floating point numbers being a 2d position @@ -386,7 +389,7 @@ def __eq__(self, other) -> bool: class TypeInfo: """ - Describes an opengl type + Describes an opengl type. Args: name: @@ -515,7 +518,8 @@ class GLTypes: } @classmethod - def get(cls, enum: int): + def get(cls, enum: int) -> TypeInfo: + """Get the TypeInfo for a given""" try: return cls.types[enum] except KeyError: diff --git a/arcade/gl/vertex_array.py b/arcade/gl/vertex_array.py index ce4f659e4f..80ed9eda9a 100644 --- a/arcade/gl/vertex_array.py +++ b/arcade/gl/vertex_array.py @@ -37,11 +37,11 @@ class VertexArray: The context this object belongs to program: The program to use - content (optional): + content: List of BufferDescriptions - index_buffer (optional): + index_buffer: Index/element buffer - index_element_size (optional): + index_element_size: Byte size of the index buffer datatype. """ diff --git a/arcade/gui/view.py b/arcade/gui/view.py index df3cf01648..110f62734d 100644 --- a/arcade/gui/view.py +++ b/arcade/gui/view.py @@ -29,6 +29,9 @@ class UIView(View): def __init__(self): super().__init__() self.ui = UIManager() + """ + The UIManager of this view. + """ def add_widget(self, widget: W) -> W: """Add a widget to the UIManager of this view.""" diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 7d4f341a9c..26a56d4c42 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -396,9 +396,9 @@ def resize(self, *, width=None, height=None, anchor: Vec2 = AnchorPoint.CENTER): """Resizes the widget. Args: - width (optional): new width - height (optional): new height - anchor (optional): anchor point for resizing, default is center + width: new width + height: new height + anchor: anchor point for resizing, default is center """ self.rect = self.rect.resize(width=width, height=height, anchor=anchor) diff --git a/arcade/sections.py b/arcade/sections.py index 0e1f66ae17..4b34ab10b8 100644 --- a/arcade/sections.py +++ b/arcade/sections.py @@ -29,14 +29,16 @@ class Section: width: the width of this section height: the height of this section name: the name of this section - bool | Iterable accept_keyboard_keys: whether this section - captures keyboard keys through. keyboard events. If the param is an iterable - means the keyboard keys that are captured in press/release events: - for example: ``[arcade.key.UP, arcade.key.DOWN]`` will only capture this two keys - bool Iterable accept_mouse_events: whether this section - captures mouse events. If the param is an iterable means the mouse events - that are captured. for example: ``['on_mouse_press', 'on_mouse_release']`` - will only capture this two events. + accept_keyboard_keys: + bool | Iterable accept_keyboard_keys: whether this section + captures keyboard keys through. keyboard events. If the param is an iterable + means the keyboard keys that are captured in press/release events: + for example: ``[arcade.key.UP, arcade.key.DOWN]`` will only capture this two keys + accept_mouse_events: + bool Iterable accept_mouse_events: whether this section + captures mouse events. If the param is an iterable means the mouse events + that are captured. for example: ``['on_mouse_press', 'on_mouse_release']`` + will only capture this two events. prevent_dispatch: a list of event names that will not be dispatched to subsequent sections. You can pass None (default) or {True} to prevent the dispatch of all events. prevent_dispatch_view: a list of event names that will not be dispatched to the view. diff --git a/arcade/shape_list.py b/arcade/shape_list.py index f1ead8cf9b..c1a191b30f 100644 --- a/arcade/shape_list.py +++ b/arcade/shape_list.py @@ -825,6 +825,9 @@ def create_ellipse_filled_with_colors( TShape = TypeVar("TShape", bound=Shape) +""" +Type variable for Shape or subclasses. +""" @copy_dunders_unimplemented diff --git a/arcade/sound.py b/arcade/sound.py index 4bc3549114..6530f4d781 100644 --- a/arcade/sound.py +++ b/arcade/sound.py @@ -71,6 +71,9 @@ def __init__(self, file_name: str | Path, streaming: bool = False): self.file_name = str(file_name) self.source: Source = media.load(self.file_name, streaming=streaming) + """ + The :py:class:`pyglet.media.Source` object that holds the audio data. + """ if self.source.duration is None: raise ValueError( diff --git a/arcade/sprite/animated.py b/arcade/sprite/animated.py index 7325561c98..fec99c65c4 100644 --- a/arcade/sprite/animated.py +++ b/arcade/sprite/animated.py @@ -27,7 +27,7 @@ class TextureKeyframe: Texture to display for this keyframe. duration: Duration in milliseconds to display this keyframe. - tile_id (optional): + tile_id: Tile ID for this keyframe (only used for tiled maps). This can be ignored when not using tiled maps. """ diff --git a/arcade/text.py b/arcade/text.py index 39b0d9c55e..ae03a12170 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -204,25 +204,25 @@ class Text: text: Initial text to display. Can be an empty string x: x position to align the text's anchor point with y: y position to align the text's anchor point with - z (optional): z position to align the text's anchor point with - color (optional): Color of the text as an RGBA tuple or a + z: z position to align the text's anchor point with + color: Color of the text as an RGBA tuple or a :py:class:`~arcade.types.Color` instance. - font_size (optional): Size of the text in points - width (optional): A width limit in pixels - align (optional): Horizontal alignment; values other than "left" require width to be set. + font_size: Size of the text in points + width: A width limit in pixels + align: Horizontal alignment; values other than "left" require width to be set. Valid options: ``"left"``, ``"center"``, ``"right"``. - font_name (optional): A font name, path to a font file, or list of names - bold (optional): Whether to draw the text as bold, and if a string, + font_name: A font name, path to a font file, or list of names + bold: Whether to draw the text as bold, and if a string, how bold. See :py:attr:`.bold` to learn more. - italic (optional): Whether to draw the text as italic - anchor_x (optional): How to calculate the anchor point's x coordinate. + italic: Whether to draw the text as italic + anchor_x: How to calculate the anchor point's x coordinate. Options: "left", "center", or "right" - anchor_y (optional): How to calculate the anchor point's y coordinate. + anchor_y: How to calculate the anchor point's y coordinate. Options: "top", "bottom", "center", or "baseline". - multiline (optional): Requires width to be set; enables word wrap rather than clipping - rotation (optional): rotation in degrees, clockwise from horizontal - batch (optional): The batch to add the text to (for batch rendering text) - group (optional): The specific group in a a batch to add the text to + multiline: Requires width to be set; enables word wrap rather than clipping + rotation: rotation in degrees, clockwise from horizontal + batch: The batch to add the text to (for batch rendering text) + group: The specific group in a a batch to add the text to (for batch rendering text) All constructor arguments other than ``text`` have a corresponding @@ -738,19 +738,19 @@ def create_text_sprite( Args: text: Initial text to display. Can be an empty string - color (optional): Color of the text as an RGBA tuple or a + color: Color of the text as an RGBA tuple or a :py:class:`~arcade.types.Color` instance. - font_size (optional): Size of the text in points - width (optional): A width limit in pixels - align (optional): Horizontal alignment; values other than "left" require width to be set. + font_size: Size of the text in points + width: A width limit in pixels + align: Horizontal alignment; values other than "left" require width to be set. Valid options: ``"left"``, ``"center"``, ``"right"``. - font_name (optional): A font name, path to a font file, or list of names - bold (optional): Whether to draw the text as bold, and if a string, + font_name: A font name, path to a font file, or list of names + bold: Whether to draw the text as bold, and if a string, how bold. See :py:attr:`arcade.gui.widgets.text.bold` to learn more. - italic (optional): Whether to draw the text as italic - anchor_x (optional): How to calculate the anchor point's x coordinate. + italic: Whether to draw the text as italic + anchor_x: How to calculate the anchor point's x coordinate. Options: "left", "center", or "right" - multiline (optional): Requires width to be set; enables word wrap rather than clipping + multiline: Requires width to be set; enables word wrap rather than clipping background_color: The background color of the text. If None, the background will be transparent. texture_atlas: The texture atlas to use for the @@ -843,23 +843,23 @@ def draw_text( text: Initial text to display. Can be an empty string x: x position to align the text's anchor point with y: y position to align the text's anchor point with - z (optional): z position to align the text's anchor point with - color (optional): Color of the text as an RGBA tuple or a + z: z position to align the text's anchor point with + color: Color of the text as an RGBA tuple or a :py:class:`~arcade.types.Color` instance. - font_size (optional): Size of the text in points - width (optional): A width limit in pixels - align (optional): Horizontal alignment; values other than "left" require width to be set. + font_size: Size of the text in points + width: A width limit in pixels + align: Horizontal alignment; values other than "left" require width to be set. Valid options: ``"left"``, ``"center"``, ``"right"``. - font_name (optional): A font name, path to a font file, or list of names - bold (optional): Whether to draw the text as bold, and if a string, + font_name: A font name, path to a font file, or list of names + bold: Whether to draw the text as bold, and if a string, how bold. See :py:attr:`arcade.gui.widgets.text.bold` to learn more. - italic (optional): Whether to draw the text as italic - anchor_x (optional): How to calculate the anchor point's x coordinate. + italic: Whether to draw the text as italic + anchor_x: How to calculate the anchor point's x coordinate. Options: "left", "center", or "right" - anchor_y (optional): How to calculate the anchor point's y coordinate. + anchor_y: How to calculate the anchor point's y coordinate. Options: "top", "bottom", "center", or "baseline". - multiline (optional): Requires width to be set; enables word wrap rather than clipping - rotation (optional): rotation in degrees, clockwise from horizontal + multiline: Requires width to be set; enables word wrap rather than clipping + rotation: rotation in degrees, clockwise from horizontal By default, the text is placed so that: diff --git a/arcade/texture/generate.py b/arcade/texture/generate.py index cdd1b6c304..af98ce7dc3 100644 --- a/arcade/texture/generate.py +++ b/arcade/texture/generate.py @@ -27,10 +27,10 @@ def make_circle_texture( Diameter of the circle and dimensions of the square :class:`Texture` returned. color: Color of the circle as a :py:class:`~arcade.types.Color` instance a 3 or 4 tuple. - name (optional): + name: A unique name for the texture. If not provided, a name will be generated. This is used for caching and unique identifier for texture atlases. - hit_box_algorithm (optional): + hit_box_algorithm: The hit box algorithm to use for this texture. If not provided, the default hit box algorithm will be used. """ @@ -65,10 +65,10 @@ def make_soft_circle_texture( Alpha value of the circle at its center. outer_alpha: Alpha value of the circle at its edges. - name (optional): + name: A unique name for the texture. If not provided, a name will be generated. This is used for caching and unique identifier for texture atlases. - hit_box_algorithm (optional): + hit_box_algorithm: The hit box algorithm to use for this texture. If not provided, the default hit box algorithm will be used. """ @@ -125,7 +125,7 @@ def make_soft_square_texture( Alpha value of the square at its center. outer_alpha: Alpha value of the square at its edges. - name (optional): + name: A unique name for the texture. If not provided, a name will be generated. This is used for caching and unique identifier for texture atlases. """ diff --git a/arcade/texture/loading.py b/arcade/texture/loading.py index 5c56d5c5e9..a100929077 100644 --- a/arcade/texture/loading.py +++ b/arcade/texture/loading.py @@ -41,7 +41,7 @@ def load_texture( Args: file_path: Path to the image file - hit_box_algorithm (optional): + hit_box_algorithm: The hit box algorithm to use for this texture. If not specified the global default will be used. hash: diff --git a/arcade/texture/manager.py b/arcade/texture/manager.py index 40f5477a1b..b72f76dc57 100644 --- a/arcade/texture/manager.py +++ b/arcade/texture/manager.py @@ -143,7 +143,7 @@ def load_or_get_spritesheet_texture( Path to the sprite sheet image rect: Slice of the texture in the sprite sheet. - hit_box_algorithm (optional): + hit_box_algorithm: Hit box algorithm to use. If not specified, the global default will be used. """ real_path = self._get_real_path(path) @@ -216,15 +216,15 @@ def load_or_get_texture( Args: file_path: Path to the image file. - x (optional): + x: X coordinate of the texture in the image. - y (optional): + y: Y coordinate of the texture in the image. - width (optional): + width: Width of the texture in the image. - height (optional): + height: Height of the texture in the image. - hit_box_algorithm (optional): + hit_box_algorithm: The hit box algorithm to use for this texture. If not specified, the global default will be used. """ diff --git a/arcade/texture/spritesheet.py b/arcade/texture/spritesheet.py index 33772895d5..cd432e29f9 100644 --- a/arcade/texture/spritesheet.py +++ b/arcade/texture/spritesheet.py @@ -28,8 +28,8 @@ class SpriteSheet: (0, 0) in the upper left corner. This matches the coordinate system used by PIL. Args: - path (optional) Path to the image to load. - image (optional): PIL image to use. + path Path to the image to load. + image: PIL image to use. """ def __init__( diff --git a/arcade/texture/texture.py b/arcade/texture/texture.py index df3f11cabb..4919e9cd50 100644 --- a/arcade/texture/texture.py +++ b/arcade/texture/texture.py @@ -436,9 +436,9 @@ def create_empty( and uniqueness in texture atlases. size: The xy size of the internal image - color (optional): + color: The color to fill the texture with - hit_box_points (optional): + hit_box_points: A list of hitbox points for the texture """ return Texture( diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index b61aa2ae43..8223063939 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -87,11 +87,11 @@ class DefaultTextureAtlas(TextureAtlasBase): The number of edge pixels to repeat around images in the atlas. This kind of padding is important to avoid edge artifacts. Default is 1 pixel. - textures (optional): + textures: Optional sequence of textures to add to the atlas on creation auto_resize: Automatically resize the atlas when full. Default is ``True``. - ctx (optional): + ctx: The context for this atlas (will use window context if left empty) capacity: The number of textures the atlas keeps track of. diff --git a/arcade/texture_atlas/region.py b/arcade/texture_atlas/region.py index bec593a587..8b14eebb49 100644 --- a/arcade/texture_atlas/region.py +++ b/arcade/texture_atlas/region.py @@ -48,7 +48,7 @@ class AtlasRegion: The width of the texture in pixels height: The height of the texture in pixels - texture_coordinates (optional): + texture_coordinates: The texture coordinates for this region. If not provided, they will be calculated. """ diff --git a/arcade/utils.py b/arcade/utils.py index 0785f1a622..490be539fb 100644 --- a/arcade/utils.py +++ b/arcade/utils.py @@ -118,7 +118,7 @@ def is_nonstr_iterable(item: Any) -> bool: you can also pass it as an argument to other functions. These include: * The :py:func:`.grow_sequence` utility function - * Python's built-in :py:func:`filter` + * Python's built-in filter function .. note:: This is the opposite of :py:func:`is_str_or_noniterable`. diff --git a/doc/api_docs/gl/index.rst b/doc/api_docs/gl/index.rst index 99bc39443a..d8afa586fc 100644 --- a/doc/api_docs/gl/index.rst +++ b/doc/api_docs/gl/index.rst @@ -49,5 +49,6 @@ directory (git). sampler utils exceptions + types .. _ModernGL: https://github.com/moderngl/moderngl diff --git a/doc/api_docs/gl/types.rst b/doc/api_docs/gl/types.rst new file mode 100644 index 0000000000..efb98dbe6c --- /dev/null +++ b/doc/api_docs/gl/types.rst @@ -0,0 +1,38 @@ + +.. py:currentmodule:: arcade.gl.types + +Types +===== + +.. autodata:: BufferProtocol +.. autodata:: BufferOrBufferProtocol +.. autodata:: PyGLenum +.. autodata:: GLuintLike +.. autodata:: PyGLuint +.. autodata:: OpenGlFilter +.. autodata:: BlendFunction + +.. autodata:: compare_funcs +.. autodata:: swizzle_enum_to_str +.. autodata:: swizzle_str_to_enum +.. autodata:: pixel_formats + +.. autodata:: SHADER_TYPE_NAMES +.. autodata:: GL_NAMES + +.. autofunction:: gl_name + +.. autoclass:: AttribFormat + :members: + :undoc-members: + :member-order: bysource + +.. autoclass:: TypeInfo + :members: + :undoc-members: + :member-order: bysource + +.. autoclass:: GLTypes + :members: + :undoc-members: + :member-order: bysource diff --git a/doc/example_code/shape_list_demo.rst b/doc/example_code/shape_list_demo.rst index ace5b8bb92..0fc3d57b56 100644 --- a/doc/example_code/shape_list_demo.rst +++ b/doc/example_code/shape_list_demo.rst @@ -5,7 +5,7 @@ ShapeElementList Explanation ============================ -If you are drawing a lot of items on your screen, the :class:`arcade.ShapeElementList` can +If you are drawing a lot of items on your screen, the :class:`arcade.shape_list.ShapeElementList` can speed your drawing. How does it work? Say we have a screen with about 9,600 rectangles: diff --git a/doc/example_code/sprite_move_scrolling.rst b/doc/example_code/sprite_move_scrolling.rst index 5034015d42..327a4bbd29 100644 --- a/doc/example_code/sprite_move_scrolling.rst +++ b/doc/example_code/sprite_move_scrolling.rst @@ -5,7 +5,7 @@ Move with a Scrolling Screen - Centered ======================================= -Using a :class:`arcade.Camera`, a program can easily scroll around a larger +Using a :class:`arcade.Camera2D`, a program can easily scroll around a larger "world" while only showing part of it on the screen. If you are displaying a GUI or some other items that should NOT scroll, you'll diff --git a/doc/extensions/prettyspecialmethods.py b/doc/extensions/prettyspecialmethods.py index 4da89a089b..6bd7f861e3 100644 --- a/doc/extensions/prettyspecialmethods.py +++ b/doc/extensions/prettyspecialmethods.py @@ -214,6 +214,9 @@ def brackets(parameters_node): '__sizeof__': function_transformer('sys.getsizeof'), '__dir__': function_transformer('dir'), '__reversed__': function_transformer('reversed'), + + '__enter__': function_transformer('enter'), + '__exit__': function_transformer('exit'), } diff --git a/doc/programming_guide/camera.rst b/doc/programming_guide/camera.rst index 1f8645a5f8..06b837483b 100644 --- a/doc/programming_guide/camera.rst +++ b/doc/programming_guide/camera.rst @@ -15,7 +15,7 @@ equal to one pixel of the sprite's source texture. This does not necessarily equ Screen Space ^^^^^^^^^^^^ The final positions of anything drawn to screen is in screen space. The mouse positions returned by window -events like :py:func:`on_mouse_press` are also in screen space. Moving 1 unit in screen space is equivalent to moving +events like ``on_mouse_press`` are also in screen space. Moving 1 unit in screen space is equivalent to moving one pixel. Often positions in screen space are integer values, but this is not a strict rule. View Matrices diff --git a/doc/programming_guide/event_loop.rst b/doc/programming_guide/event_loop.rst index 36532cab90..820ca26d08 100644 --- a/doc/programming_guide/event_loop.rst +++ b/doc/programming_guide/event_loop.rst @@ -54,33 +54,33 @@ to the window's own events. For simple time keeping Arcade provides global clock objects. Both clocks can be imported from ``arcade.clock`` as ``GLOBAL_CLOCK`` and ``GLOBAL_FIXED_CLOCK`` -:py:class:`arcade.Clock` -^^^^^^^^^^^^^^^^^^^^^^^^ +:py:class:`~arcade.clock.Clock` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The base Arcade clock tracks the elapsed time in seconds, the total number of clock ticks, and the amount of time that elapsed since the last tick. The currently active window automatically ticks the ``GLOBAL_CLOCK`` every ``on_update``. This means there is no reason to manually tick it. If you need more -clocks, possibly ticking at a different rate, an :py:class:`arcade.Clock` +clocks, possibly ticking at a different rate, an :py:class:`arcade.clock.Clock` can be created on the fly. -:py:class:`arcade.FixedClock` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:py:class:`~arcade.clock.FixedClock` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The fixed clock tracks the same values as the normal clock, but has two special features. Firstly it enforces that the ``delta_time`` passed into its ``tick`` method is always the same. This is because advanced physics engines require consistent time. Secondly the fixed clock requires a sibling regular clock. It uses this clock to track how offset from the true time it is. -Like the regular clock you may make a new :py:class:`arcade.FixedClock` at any time, +Like the regular clock you may make a new :py:class:`~arcade.clock.FixedClock` at any time, but ensure they have a sibling. Up Coming ^^^^^^^^^ -In future version of arcade :py:class:`Clock` will be updated to allow for sub clocks. +In future version of arcade :py:class:`~arcade.clock.Clock` will be updated to allow for sub clocks. Sub clocks will be ticked by their parent clock rather than be manually updated. Sub clocks will make it easier to control the flow of time for specific groups of objects. Such as only -slowing enemies or excluding UI elements. To gain access to a draft :py:class:`arcade.Clock` +slowing enemies or excluding UI elements. To gain access to a draft :py:class:`~arcade.clock.Clock` you can find it in ``arcade.future.sub_clock``. This version of the sub clock is not final. If you find any bugs do not hesitate to raise an issue on the github. @@ -88,22 +88,22 @@ More on Fixed update -------------------- The ``on_fixed_update`` event can be an extremely powerful tool, but it has many complications -that should be taken into account. If used imporperly the event can grind a game to a halt. +that should be taken into account. If used improperly the event can grind a game to a halt. Death Spiral ^^^^^^^^^^^^ A fixed update represents a very specific amount of time. If all of the computations take -longer than the fixed update represents than the ammount of time accumulated between update +longer than the fixed update represents than the amount of time accumulated between update events will grow. If this happens for multiple frames the game will begin to spiral. The first few frames of the spiral will lead to one update cycle requiring two fixed update calls. This will increase the extra time accumulated until three fixed updates must occur at once. This will continue to happen until either: the fixed updates start taking less time, or the game crashes. -There are a few solutions to this issue. The simplist method, which works best when there may be spikes in +There are a few solutions to this issue. The simplest method, which works best when there may be spikes in computation time that quickly settle, is to clamp the max number of fixed updates that can occur in a single -frame. In Arcade this is done by setting the ``fixed_frame_cap`` argument when initialising your +frame. In Arcade this is done by setting the ``fixed_frame_cap`` argument when initializing your :py:class:`arcade.Window`. The second method is to slow-down time temporarily. By changing the ``_tick_speed`` of Arcade's ``GLOBAL_CLOCK`` is is possible to slow down the accumulation of time. For example setting ``GLOBAL_CLOCK._tick_speed = 0.5`` would allow the fixed update twice as many frames diff --git a/doc/programming_guide/gui/concepts.rst b/doc/programming_guide/gui/concepts.rst index c9817bf914..ac24797acc 100644 --- a/doc/programming_guide/gui/concepts.rst +++ b/doc/programming_guide/gui/concepts.rst @@ -8,7 +8,7 @@ The GUI is structured like a tree; every widget can have other widgets as children. The root of the tree is the :py:class:`~arcade.gui.UIManager`. The -:py:class:`UIManager` connects the user interactions with the GUI. Read more about +:py:class:`~arcade.gui.UIManager` connects the user interactions with the GUI. Read more about :ref:`UIEvent`. Classes of Arcade's GUI code are prefixed with ``UI-`` to make them easy to @@ -41,7 +41,6 @@ via the :py:attr:`~arcade.gui.UIView.ui` attribute. It automatically enables and disables the :py:class:`~arcade.gui.UIManager` when the view is shown or hidden. - UIWidget ```````` @@ -52,14 +51,14 @@ such as buttons or labels. User interaction with widgets is processed within :py:meth:`~arcade.gui.UIWidget.on_event`. -A :class:`UIWidget` has following properties. +A :class:`~arcade.gui.UIWidget` has following properties. ``rect`` A tuple with four slots. The first two are x and y coordinates (bottom left of the widget), and the last two are width and height. ``children`` - Child widgets rendered within this widget. A :class:`UIWidget` will not + Child widgets rendered within this widget. A :class:`~arcade.gui.UIWidget` will not move or resize its children; use a :py:class:`~arcade.gui.UILayout` instead. @@ -84,17 +83,17 @@ A :class:`UIWidget` has following properties. ``size_hint_min`` A tuple of two integers defining the minimum width and height of the - widget. These values should be taken into account by :class:`UILayout` when + widget. These values should be taken into account by :class:`~arcade.gui.UILayout` when a ``size_hint`` is given for the axis. ``size_hint_max`` A tuple of two integers defining the maximum width and height of the - widget. These values should be taken into account by :class:`UILayout` when + widget. These values should be taken into account by :class:`~arcade.gui.UILayout` when a ``size_hint`` is given for the axis. .. warning:: Size hints do nothing on their own! - They are hints to :class:`UILayout` instances, which may choose to use or + They are hints to :class:`~arcade.gui.UILayout` instances, which may choose to use or ignore them. UILayout @@ -168,8 +167,8 @@ changes only once. **Example**: Executed steps within :py:class:`~arcade.gui.UIBoxLayout`: -1. :py:meth:`~arcade.UIBoxLayout.prepare_layout` updates own size_hints -2. :py:meth:`~arcade.UIBoxLayout.do_layout` +1. :py:meth:`~arcade.gui.UIBoxLayout.prepare_layout` updates own size_hints +2. :py:meth:`~arcade.gui.UIBoxLayout.do_layout` 1. Collect current ``size``, ``size_hint``, ``size_hint_min`` of children 2. Calculate the new position and sizes 3. Set position and size of children @@ -222,17 +221,17 @@ changes only once. Size hint support ^^^^^^^^^^^^^^^^^ -+--------------------------+------------+----------------+----------------+ -| | size_hint | size_hint_min | size_hint_max | -+==========================+============+================+================+ -| :class:`UIAnchorLayout` | X | X | X | -+--------------------------+------------+----------------+----------------+ -| :class:`UIBoxLayout` | X | X | X | -+--------------------------+------------+----------------+----------------+ -| :class:`UIGridLayout` | X | X | X | -+--------------------------+------------+----------------+----------------+ -| :class:`UIManager` | X | X | X | -+--------------------------+------------+----------------+----------------+ ++--------------------------------------+------------+----------------+----------------+ +| | size_hint | size_hint_min | size_hint_max | ++======================================+============+================+================+ +| :class:`~arcade.gui.UIAnchorLayout` | X | X | X | ++--------------------------------------+------------+----------------+----------------+ +| :class:`~arcade.gui.UIBoxLayout` | X | X | X | ++--------------------------------------+------------+----------------+----------------+ +| :class:`~arcade.gui.UIGridLayout` | X | X | X | ++--------------------------------------+------------+----------------+----------------+ +| :class:`~arcade.gui.UIManager` | X | X | X | ++--------------------------------------+------------+----------------+----------------+ UIMixin ======= @@ -242,9 +241,9 @@ behaviour. Currently the available Mixins are still under heavy development. Available: -- :py:class:`UIDraggableMixin` - Makes a widget draggable with the mouse. -- :py:class:`UIMouseFilterMixin` - Captures all mouse events. -- :py:class:`UIWindowLikeMixin` - Makes a widget behave like a window, combining draggable and mouse filter behaviour. +- :py:class:`~arcade.gui.UIDraggableMixin` - Makes a widget draggable with the mouse. +- :py:class:`~arcade.gui.UIMouseFilterMixin` - Captures all mouse events. +- :py:class:`~arcade.gui.UIWindowLikeMixin` - Makes a widget behave like a window, combining draggable and mouse filter behaviour. UIConstructs ============ @@ -253,8 +252,8 @@ Constructs are predefined structures of widgets and layouts like a message box. Available: -- :py:class:`UIMessageBox` - A simple message box with a title, message and buttons. -- :py:class:`UIButtonRow` - A row of buttons. +- :py:class:`~arcade.gui.UIMessageBox` - A simple message box with a title, message and buttons. +- :py:class:`arcade.gui.UIButtonRow` - A row of buttons. Available Elements ================== @@ -273,7 +272,7 @@ can use the :py:attr:`~arcade.gui.UITextWidget.ui_label` attribute to get the Flat button ^^^^^^^^^^^ -**Name**: :py:class:`~arcade.gui.FlatButton` +**Name**: :py:class:`~arcade.gui.UIFlatButton` A flat button for simple interactions (hover, press, release, click). This button is created with a simple rectangle. Flat buttons can quickly create a @@ -373,14 +372,14 @@ parameters. ``bold`` and ``italic`` will set the text to bold or italic. ``align`` specifies the justification of the text. Additionally it takes ``font_name``, ``font_size``, and ``text_color`` options. -Using the :py:attr:`~arcade.gui.UILabel.label` property accesses the internal +Using the :py:attr:`~arcade.gui.UILabel`'s ``_label`` property accesses the internal :py:class:`~arcade.Text` class. .. hint:: A :py:attr:`~arcade.gui.UILabel.text` attribute can modify the displayed text. Beware-calling this again and again will give a lot of lag. Use - :py:meth:`~arcade.Text.begin_update` and py:meth:`~arcade.Text.end_update` - to speed things up. + :py:meth:`~arcade.Text.__enter__` through the ``with`` statement to speed + things up multiple changes to text instances. Text input field ^^^^^^^^^^^^^^^^ @@ -426,7 +425,7 @@ Arcade's GUI events are fully typed dataclasses, which provide information about an event affecting the UI. All pyglet window events are converted by the -:py:class:`~arcade.gui.UIManager` into :class:`UIEvents` and passed via +:py:class:`~arcade.gui.UIManager` into :class:`~arcade.gui.UIEvent` and passed via :py:meth:`~pyglet.event.EventDispatcher.dispatch_event` to the :py:meth:`~arcade.gui.UIWidget.on_event` callbacks. @@ -513,7 +512,7 @@ Different event systems Arcade's GUI uses different event systems, dependent on the required flow. A game developer should mostly interact with user-interface events, which are -dispatched from specific :py:class:`~arcade.gui.UIWidget`s like an ``on_click`` +dispatched from a specific :py:class:`~arcade.gui.UIWidget` like an ``on_click`` of a button. In cases where a developer implement own widgets themselves or want to diff --git a/doc/programming_guide/gui/own_layout.rst b/doc/programming_guide/gui/own_layout.rst index d054264c68..3232ca9bcc 100644 --- a/doc/programming_guide/gui/own_layout.rst +++ b/doc/programming_guide/gui/own_layout.rst @@ -20,7 +20,7 @@ The main method you need to implement is: - :meth:`arcade.gui.UILayout.do_layout` - This method is called to layout the child widgets. -Widgets added to the layout are accessible via the :attr:`arcade.gui.UILayout._children` attribute, +Widgets added to the layout are accessible via the ``arcade.gui.UILayout._children`` attribute, which is a list of all added widgets with the parameter provided when added. Children should be placed within the bounds of the layout. @@ -36,5 +36,3 @@ Example `CircleLayout` ~~~~~~~~~~~~~~~~~~~~~~ .. literalinclude:: ../../../arcade/examples/gui/own_layout.py - - diff --git a/doc/programming_guide/gui/own_widgets.rst b/doc/programming_guide/gui/own_widgets.rst index 495ab79fc2..dcad37500f 100644 --- a/doc/programming_guide/gui/own_widgets.rst +++ b/doc/programming_guide/gui/own_widgets.rst @@ -27,12 +27,12 @@ You can also make use of other base classes, which provide a more specialized in Further baseclasses are: - :class:`arcade.gui.UIInteractiveWidget` - `UIInteractiveWidget` is a baseclass for widgets that can be interacted with. - It handles mouse events and provides properties like `hovered` or `pressed` and an :meth:`on_click` method. + A base class for widgets that can be interacted with. + It handles mouse events and provides properties like `hovered` or `pressed` and an :meth:`~arcade.gui.UIInteractiveWidget.on_click` method. - :class:`arcade.gui.UIAnchorLayout` - `UIAnchorLayout` is basically a frame, which can be used to place widgets - to a position within itself. This makes it a great baseclass for a widget containing + Basically a frame, which can be used to place widgets + to a position within itself. This makes it a great base class for a widget containing multiple other widgets. (Examples: `MessageBox`, `Card`, etc.) If your widget should act more as a general layout, position various widgets and handle their size, diff --git a/doc/programming_guide/performance_tips.rst b/doc/programming_guide/performance_tips.rst index e6319a79f1..7caaa1175d 100644 --- a/doc/programming_guide/performance_tips.rst +++ b/doc/programming_guide/performance_tips.rst @@ -35,7 +35,7 @@ The Simplest Solutions are Slow """"""""""""""""""""""""""""""" The simplest approach is a for loop over every wall. Even if the hitboxes -of both the player and the ground :py:class:`Sprite` objects are squares, +of both the player and the ground :py:class:`~arcade.Sprite` objects are squares, it will still be a lot of work. Game developers often use **Big O** notation to describe: @@ -79,7 +79,7 @@ Which should I use? .. [#] Arcade's non-PyMunk physics both engines assume it will be enabled - for any :py:class:`~arcade.sprite_list.SpriteList` provided via their + for any :py:class:`~arcade.SpriteList` provided via their ``walls`` argument. .. _collision_detection_performance_hashing: @@ -88,7 +88,7 @@ Spatial Hashing ^^^^^^^^^^^^^^^ **Spatial hashing** is meant for collision checking sprites -against a :py:class:`~arcade.sprite_list.SpriteList` of +against a :py:class:`~arcade.SpriteList` of **non-moving** sprites: * checking collisions against hashed sprites becomes much faster @@ -99,14 +99,14 @@ uses a **hash map** (:py:class:`dict`) of grid square coordinates to lists of :py:class:`~arcade.Sprite` objects in each square. How does this help us? We may need as few as zero hitbox checks to collide -a given sprite against a :py:class:`~arcade.sprite_list.SpriteList`. Yes, +a given sprite against a :py:class:`~arcade.SpriteList`. Yes, **zero**: .. image:: images/spatial_hash_grid_mockup.png :alt: A blue bird is alone in its own grid square. #. The sand-colored ground consists of sprites in a - :py:class:`~arcade.sprite_list.SpriteList` with spatial hashing enabled + :py:class:`~arcade.SpriteList` with spatial hashing enabled #. The bright green lines show the grid square boundaries #. The moving sprites are the blue bird and the red angry faces @@ -131,12 +131,12 @@ Enabling Spatial Hashing """""""""""""""""""""""" The best way to enable spatial hashing on a -:py:class:`~arcade.sprite_list.SpriteList` is before anything else, +:py:class:`~arcade.SpriteList` is before anything else, especially before gameplay. The simplest way is passing ``use_spatial_hash=True`` when creating and storing the list inside a :py:class:`~arcade.Window` or -:py:class:`~arcade.view.View`: +:py:class:`~arcade.View`: .. code-block:: python @@ -260,7 +260,7 @@ The rest of this section will cover how to avoid that. Drawing Shapes ^^^^^^^^^^^^^^ -The :py:mod:`arcade.draw` module is slow despite being convenient. +The ``arcade.draw_*`` functions are slow despite being convenient. This is because it does not perform batched drawing. Instead of sending batches of shapes to draw, it sends them individually. diff --git a/doc/programming_guide/sound.rst b/doc/programming_guide/sound.rst index 31b3b3192d..6ae28095df 100644 --- a/doc/programming_guide/sound.rst +++ b/doc/programming_guide/sound.rst @@ -152,14 +152,14 @@ The first way to play it is passing it to :py:func:`arcade.play_sound`: We store the return value because it is a special object which lets us control this specific playback of the :py:class:`Sound` data. -.. important:: You **must** pass a :py:class:`Sound`, not a path! +.. important:: You **must** pass a :py:class:`~arcade.Sound`, not a path! If you pass :py:func:`arcade.play_sound` anything other than a :py:class:`Sound` or ``None``, it will raise a :py:class:`TypeError`. -To avoid making this mistake, you can call the :py:class:`Sound` -data's :py:meth:`Sound.play` method instead: +To avoid making this mistake, you can call the :py:class:`~arcade.Sound` +data's :py:meth:`~arcade.Sound.play` method instead: .. code-block:: python @@ -179,7 +179,7 @@ Stopping Sounds Sound data vs Playbacks """"""""""""""""""""""" -Arcade uses the :py:mod:`pyglet` multimedia library to handle sound. +Arcade uses the pyglet multimedia library to handle sound. Each playback of a :py:class:`Sound` has its own |pyglet Player| object to control it: @@ -212,7 +212,7 @@ The first is to choose which function we'll pass its arcade.stop_sound(self.coin_playback_1) -* The :py:class:`Sound` data's :py:meth:`Sound.stop` +* The :py:class:`~arcade.Sound` data's :py:meth:`~arcade.Sound.stop` method: .. code-block:: python @@ -366,7 +366,7 @@ There are more ways to alter playback than stopping. Some are more qualitative. Many of them can be applied to both new and ongoing sound data playbacks, but in different ways. -Both :py:func:`play_sound` and :py:meth:`Sound.play` support the +Both :py:func:`arcade.play_sound` and :py:meth:`arcade.Sound.play` support the following advanced arguments: .. list-table:: @@ -376,22 +376,22 @@ following advanced arguments: - Values - Meaning - * - :py:attr:`~pyglet.media.player.Player.volume` + * - volume - :py:class:`float` between ``0.0`` (silent) and ``1.0`` (full volume) - A scaling factor for the original audio file. - * - :py:attr:`~pyglet.media.player.Player.pan` + * - pan - A :py:class:`float` between ``-1.0`` (left) and ``1.0`` (right) - The left / right channel balance - * - ``loop`` + * - loop - :py:class:`bool` (``True`` / ``False``) - Whether to restart playback automatically after finishing. [#streamingnoloop]_ - * - ``speed`` + * - speed - :py:class:`float` greater than ``0.0`` - The scaling factor for playback speed (and pitch) @@ -479,7 +479,7 @@ these keywords are similar or identical to those of properties on more: * :py:func:`arcade.play_sound` -* :py:meth:`Sound.play` +* :py:meth:`arcade.Sound.play` * :ref:`sound_speed_demo` .. _sound-compat: @@ -695,7 +695,7 @@ The most obvious external library for audio handling is pyglet: * It offers far better control over media than Arcade * You may have already used parts of it directly for :ref:`sound-intermediate-playback` -Note that :py:class:`Sound`'s :py:attr:`~Sound.source` attribute holds a +Note that the :py:class:`arcade.Sound.source` attribute holds a :py:class:`pyglet.media.Source`. This means you can start off by cleanly using Arcade's resource and sound loading with pyglet features as needed. diff --git a/pyproject.toml b/pyproject.toml index a91a8f59aa..0c9d917f0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "pyglet~=2.1.0", + "pyglet~=2.1.3", "pillow~=11.0.0", "pymunk~=6.9.0", "pytiled-parser~=2.2.9", @@ -113,10 +113,7 @@ lint.select = [ [tool.ruff.format] docstring-code-format = false -exclude = [ - "arcade/examples/*", - "benchmarks/*", -] +exclude = ["arcade/examples/*", "benchmarks/*"] # This ignores __init__.py files and examples for import sorting [tool.ruff.lint.per-file-ignores] diff --git a/util/update_quick_index.py b/util/update_quick_index.py index 1dca8953bb..8f4b41b8d6 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -551,6 +551,14 @@ def iter_declarations( for name in filter(member_not_excluded, kind_list): yield name, IMPORT_TREE.resolve(f"{module_name}.{name}") + # # Attributes + # for name, full_name in iter_declarations('type'): + # quick_index_file.write(f" * - :py:attr:`{full_name}`\n") + # quick_index_file.write(f" - {title}\n") + + # api_file.write(f".. autodata:: {full_name}\n") + # api_file.write("\n") + # Classes for name, full_name in iter_declarations('class'): quick_index_file.write(f" * - :py:class:`{full_name}`\n") From b258f3b22b9f3b50990b197da21c502243e8e7d7 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Sun, 23 Mar 2025 12:40:54 -0400 Subject: [PATCH 086/279] Temp fix and document 3.11+ fix for contexts (#2617) * Add work-around for 3.11+ warnings from Sphinx breaking PIL context managers (Weird, right?) * Add some notes on Sphinx interference with streams --- util/create_resources_listing.py | 83 +++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index ee2c1e7fb5..48219f22bf 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -649,6 +649,71 @@ def do_filetile(out, suffix: str | None = None, state: str = None): f" \n\n")) +# pending: a fix for Pillow / Sphinx interactions? +def read_image_size(path: Path | str) -> tuple[int, int]: + """Get the size of a raster image and close the file. + + This function ensures Sphinx does not break ``with`` + blocks using :py:func:`PIL.Image.open`: + + Pillow makes assumptions about streams which Sphinx + may interfere with: + + #. Pillow assumes things about stream read / write + #. Sphinx sometimes changes stream read / write global + #. This makes :py:func:`PIL.Image.open` fail to close files + #. Python 3.11+ reports unclosed files with warning + + This is where the problem begins: + + * When nitpicky mode is off, the logs are filled with noise + * When it is on, build can break + + The fix below is good-enough to get build running. To dive + deper, start with these: + + #. Pillow dislikes things which alter stream read/write + (See https://github.com/python-pillow/Pillow/issues/2760) + #. Sphinx overrides logging stream handling + (See https://www.sphinx-doc.org/en/master/extdev/logging.html#sphinx.util.logging.getLogger) + + Args: + path: A path to an image file to read the size of. + + Returns: + A ``(width, height)`` tuple of the image size. + """ + # Isolating this in a function prevents Sphinx and other + # "magic" stream things from breaking the context manager. + # If you care to investigate, see the docstring's links. + with PIL.Image.open(path) as im: + return im.size + + +def read_size_info(path: Path) -> str: + """Cleanliness wrapper for reading image sizes. + + #. SVGs say they are SVGs + #. Raster graphics report pixel size + #. All else says it couldn't get size info. + + Args: + path: A path to an image file. + + Returns: + The formatted size info as either dimensions or + another status string. + """ + if path.suffix == ".svg": + return "Scalable Vector Graphic" + + elif (pair := read_image_size(path)): + width, height = pair + return f"{width} px x {height} px" + + return "Could not read size info" + + def process_resource_files( out, file_list: List[Path], @@ -707,18 +772,12 @@ def start(): #out.write(indent(" ", tile_rst_code)) size_info = None - if suffix == ".svg": - size_info = "Scalable Vector Graphic" - else: - try: - im = PIL.Image.open(path) - im_width, im_height = im.size - size_info = f"{im_width}px x {im_height}px" - except Exception as e: - log.warning(f"FAILED to read size info for {path}:\n {e}") - - if size_info is None: - size_info = "Could not read size info" + try: + size_info = read_size_info(path) + except Exception as e: + log.warning(f"FAILED to read size info for {path}:\n {e}") + + parts.append(f"*({size_info})*\n") out.write(indent(" ", '\n'.join(parts))) out.write("\n\n") From a2dab0cb3c9efdea80467ba82e08ad2dabb2888e Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 25 Mar 2025 23:33:14 +0100 Subject: [PATCH 087/279] Add method to access connected controllers --- arcade/experimental/controller_window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/arcade/experimental/controller_window.py b/arcade/experimental/controller_window.py index 4f1e01788d..847a6ce2e0 100644 --- a/arcade/experimental/controller_window.py +++ b/arcade/experimental/controller_window.py @@ -69,6 +69,10 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.cb = _WindowControllerBridge(self) + def get_controllers(self) -> list[Controller]: + """Return a list of connected controllers.""" + return self.cb.cm.get_controllers() + # Controller event mapping def on_stick_motion(self, controller: Controller, name, value): pass From 784909af8f6b6d9537256d1e5ac46620b905b19c Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 28 Mar 2025 10:50:02 +0100 Subject: [PATCH 088/279] gui: improve scroll bar thumb size and appearance --- arcade/gui/experimental/scroll_area.py | 53 +++++++++++++++++++------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index b33c907782..7d03419658 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -18,7 +18,7 @@ UIMouseReleaseEvent, UIMouseScrollEvent, UIWidget, - bind, + bind, UIKeyPressEvent, ) from arcade.types import LBWH @@ -42,7 +42,7 @@ def __init__(self, scroll_area: UIScrollArea, vertical: bool = True): self.with_border(color=arcade.uicolor.GRAY_CONCRETE) self.vertical = vertical - self._scroll_bar_size = 20 + # self._scroll_bar_size = 20 bind(self, "_thumb_hover", self.trigger_render) bind(self, "_dragging", self.trigger_render) @@ -52,6 +52,10 @@ def __init__(self, scroll_area: UIScrollArea, vertical: bool = True): bind(scroll_area, "content_width", self.trigger_full_render) def on_event(self, event: UIEvent) -> Optional[bool]: + # check if we are scrollable + if not self._scrollable(): + return EVENT_UNHANDLED + # detect if event is mouse down and inside the scroll thumb # if so, start dragging the thumb thumb_rect_relative = self._thumb_rect() @@ -68,20 +72,20 @@ def on_event(self, event: UIEvent) -> Optional[bool]: # if so, update the scroll position if isinstance(event, UIMouseDragEvent) and self._dragging: sx, sy = event.pos - self.rect.bottom_left - sx -= self._scroll_bar_size / 2 - sy -= self._scroll_bar_size / 2 + sx -= self._scroll_bar_size() / 2 + sy -= self._scroll_bar_size() / 2 scroll_area = self.scroll_area if self.vertical: - available_track_size = self.content_height - self._scroll_bar_size + available_track_size = self.content_height - self._scroll_bar_size() target_progress = 1 - sy / available_track_size target_progress = max(0, min(1, target_progress)) scroll_range = scroll_area.surface.height - scroll_area.content_height scroll_area.scroll_y = -target_progress * scroll_range else: - available_track_size = self.content_width - self._scroll_bar_size + available_track_size = self.content_width - self._scroll_bar_size() target_progress = sx / available_track_size target_progress = max(0, min(1, target_progress)) scroll_range = scroll_area.surface.width - scroll_area.content_width @@ -95,7 +99,28 @@ def on_event(self, event: UIEvent) -> Optional[bool]: self._dragging = False return True + if isinstance(event, UIKeyPressEvent): + print(self._scroll_bar_size()) + return EVENT_UNHANDLED + + def _scroll_bar_size(self): + # based on: https://stackoverflow.com/a/16367035 + + content_size = self.scroll_area.surface.height if self.vertical else self.scroll_area.surface.width + view_size = self.scroll_area.content_height if self.vertical else self.scroll_area.content_width + ratio = view_size / content_size + + scoll_range = self.content_height if self.vertical else self.content_width + + return scoll_range * ratio + + def _scrollable(self): + return ( + self.scroll_area.surface.height - self.scroll_area.content_height + if self.vertical + else self.scroll_area.surface.width - self.scroll_area.content_width + ) > 0 def _thumb_rect(self): """Calculate the rect of the thumb.""" @@ -108,24 +133,24 @@ def _thumb_rect(self): else scroll_area.surface.width - scroll_area.content_width ) - if scroll_range <= 0: - # content is smaller than the scroll area, no need for a thumb - return XYWH(0, 0, 0, 0) + if not self._scrollable(): + # content is smaller than the scroll area, full size thumb + return LBWH(0,0, self.content_width, self.content_height) scroll_progress = -scroll_value / scroll_range content_size = self.content_height if self.vertical else self.content_width - available_track_size = content_size - self._scroll_bar_size + available_track_size = content_size - self._scroll_bar_size() if self.vertical: - scroll_bar_y = self._scroll_bar_size / 2 + available_track_size * (1 - scroll_progress) + scroll_bar_y = self._scroll_bar_size() / 2 + available_track_size * (1 - scroll_progress) scroll_bar_x = self.content_width / 2 - return XYWH(scroll_bar_x, scroll_bar_y, self.content_width, self._scroll_bar_size) + return XYWH(scroll_bar_x, scroll_bar_y, self.content_width, self._scroll_bar_size()) else: - scroll_bar_x = self._scroll_bar_size / 2 + available_track_size * scroll_progress + scroll_bar_x = self._scroll_bar_size() / 2 + available_track_size * scroll_progress scroll_bar_y = self.content_height / 2 - return XYWH(scroll_bar_x, scroll_bar_y, self._scroll_bar_size, self.content_height) + return XYWH(scroll_bar_x, scroll_bar_y, self._scroll_bar_size(), self.content_height) def do_render(self, surface: Surface): """Render the scroll bar.""" From 65ee7e516d71b1e79b80042f81c27d89929a1976 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 28 Mar 2025 10:58:39 +0100 Subject: [PATCH 089/279] fix linter --- arcade/gui/experimental/scroll_area.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index 7d03419658..4722c01c96 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -10,6 +10,7 @@ Property, Surface, UIEvent, + UIKeyPressEvent, UILayout, UIMouseDragEvent, UIMouseEvent, @@ -18,7 +19,7 @@ UIMouseReleaseEvent, UIMouseScrollEvent, UIWidget, - bind, UIKeyPressEvent, + bind, ) from arcade.types import LBWH @@ -103,12 +104,16 @@ def on_event(self, event: UIEvent) -> Optional[bool]: print(self._scroll_bar_size()) return EVENT_UNHANDLED - + def _scroll_bar_size(self): # based on: https://stackoverflow.com/a/16367035 - content_size = self.scroll_area.surface.height if self.vertical else self.scroll_area.surface.width - view_size = self.scroll_area.content_height if self.vertical else self.scroll_area.content_width + content_size = ( + self.scroll_area.surface.height if self.vertical else self.scroll_area.surface.width + ) + view_size = ( + self.scroll_area.content_height if self.vertical else self.scroll_area.content_width + ) ratio = view_size / content_size scoll_range = self.content_height if self.vertical else self.content_width @@ -135,7 +140,7 @@ def _thumb_rect(self): if not self._scrollable(): # content is smaller than the scroll area, full size thumb - return LBWH(0,0, self.content_width, self.content_height) + return LBWH(0, 0, self.content_width, self.content_height) scroll_progress = -scroll_value / scroll_range @@ -143,7 +148,9 @@ def _thumb_rect(self): available_track_size = content_size - self._scroll_bar_size() if self.vertical: - scroll_bar_y = self._scroll_bar_size() / 2 + available_track_size * (1 - scroll_progress) + scroll_bar_y = self._scroll_bar_size() / 2 + available_track_size * ( + 1 - scroll_progress + ) scroll_bar_x = self.content_width / 2 return XYWH(scroll_bar_x, scroll_bar_y, self.content_width, self._scroll_bar_size()) From 4b32537ca64718e1c7c89bfea85d09f43b96e34a Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 28 Mar 2025 11:06:25 +0100 Subject: [PATCH 090/279] fix linter --- arcade/examples/gui/exp_controller_support_grid.py | 5 +++-- arcade/experimental/controller_window.py | 1 - arcade/gui/ui_manager.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py index b7173967ab..115ceb6c75 100644 --- a/arcade/examples/gui/exp_controller_support_grid.py +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -1,7 +1,8 @@ """ Example demonstrating a grid layout with focusable buttons in an Arcade GUI. -This example shows how to create a grid layout with buttons that can be navigated using a controller. +This example shows how to create a grid layout with buttons +that can be navigated using a controller. It includes a focus transition setup to allow smooth navigation between buttons in the grid. If Arcade and Python are properly installed, you can run this example with: @@ -31,7 +32,7 @@ class FocusableButton(Focusable, UIFlatButton): def setup_grid_focus_transition(grid: Dict[Tuple[int, int], UIWidget]): """Setup focus transition in grid. - Connect focus transition between `Focusable` in grid. + Connect focus transition between `Focusable` in grid. Args: grid: Dict[Tuple[int, int], Focusable]: grid of Focusable widgets. diff --git a/arcade/experimental/controller_window.py b/arcade/experimental/controller_window.py index 847a6ce2e0..959206f42c 100644 --- a/arcade/experimental/controller_window.py +++ b/arcade/experimental/controller_window.py @@ -18,7 +18,6 @@ class _WindowControllerBridge: that other systems should be aware, when not to act on events (like when the UI is active). """ - def __init__(self, window: arcade.Window): self.window = window diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index c1cda8f98e..767bc50a51 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -42,7 +42,7 @@ ) from arcade.gui.surface import Surface from arcade.gui.widgets import UIWidget -from arcade.types import AnchorPoint, LBWH, Point2, Rect +from arcade.types import LBWH, AnchorPoint, Point2, Rect W = TypeVar("W", bound=UIWidget) From be976c40a296cd645617f85e1454e018af0d40cb Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 28 Mar 2025 11:07:58 +0100 Subject: [PATCH 091/279] fix linter --- arcade/examples/gui/exp_controller_support_grid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py index 115ceb6c75..4fd8eaa95b 100644 --- a/arcade/examples/gui/exp_controller_support_grid.py +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -32,7 +32,7 @@ class FocusableButton(Focusable, UIFlatButton): def setup_grid_focus_transition(grid: Dict[Tuple[int, int], UIWidget]): """Setup focus transition in grid. - Connect focus transition between `Focusable` in grid. + Connect focus transition between `Focusable` in grid. Args: grid: Dict[Tuple[int, int], Focusable]: grid of Focusable widgets. From 61806a69b813ccd91db6cd51be5870ca61368819 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 28 Mar 2025 14:46:24 +0100 Subject: [PATCH 092/279] gui: add restricted input --- arcade/examples/gui/exp_restricted_input.py | 34 ++++++++ arcade/gui/experimental/restricted_input.py | 92 +++++++++++++++++++++ tests/unit/gui/test_exp_restricted_input.py | 51 ++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 arcade/examples/gui/exp_restricted_input.py create mode 100644 arcade/gui/experimental/restricted_input.py create mode 100644 tests/unit/gui/test_exp_restricted_input.py diff --git a/arcade/examples/gui/exp_restricted_input.py b/arcade/examples/gui/exp_restricted_input.py new file mode 100644 index 0000000000..04c05ecd48 --- /dev/null +++ b/arcade/examples/gui/exp_restricted_input.py @@ -0,0 +1,34 @@ +"""Example of using experimental UIRestrictedInputText. + +If Arcade and Python are properly installed, you can run this example with: +python -m arcade.examples.gui.own_widgets +""" + +from __future__ import annotations + +import arcade +from arcade.gui import UIAnchorLayout, UIBoxLayout, UIView +from arcade.gui.experimental.restricted_input import UIIntInput + + +class MyView(UIView): + def __init__(self): + super().__init__() + self.background_color = arcade.uicolor.BLUE_BELIZE_HOLE + + root = self.ui.add(UIAnchorLayout()) + bars = root.add(UIBoxLayout(space_between=10)) + + # UIWidget based progress bar + self.input_field = UIIntInput(width=300, height=40, font_size=22) + bars.add(self.input_field) + + +def main(): + window = arcade.Window(antialiasing=False) + window.show_view(MyView()) + arcade.run() + + +if __name__ == "__main__": + main() diff --git a/arcade/gui/experimental/restricted_input.py b/arcade/gui/experimental/restricted_input.py new file mode 100644 index 0000000000..c3d228160a --- /dev/null +++ b/arcade/gui/experimental/restricted_input.py @@ -0,0 +1,92 @@ +""" +This is an experimental implementation of a restricted input field. +If the implementation is successful, the feature will be merged into the existing UIInputText class. +""" + +from typing import Optional + + +from arcade.gui import UIEvent, UIInputText + + +class UIRestrictedInput(UIInputText): + """ + A text input field that restricts the input to a certain type. + + This class is meant to be subclassed to create custom input fields + that restrict the input by providing a custom validation method. + + Invalid inputs are dropped. + """ + + @property + def text(self): + """Text of the input field.""" + return self.doc.text + + @text.setter + def text(self, text: str): + if not self.validate(text): + # if the text is invalid, do not update the text + return + + # we can not call super().text = text here: https://bugs.python.org/issue14965 + UIInputText.text.__set__(self, text) # type: ignore + + def on_event(self, event: UIEvent) -> Optional[bool]: + # check if text changed during event handling, + # if so we need to validate the new text + old_text = self.text + pos = self.caret.position + + result = super().on_event(event) + if not self.validate(self.text): + self.text = old_text + self.caret.position = pos + + return result + + def validate(self, text) -> bool: + """Override this method to add custom validation logic. + + Be aware that an empty string should always be valid. + """ + return True + + +class UIIntInput(UIRestrictedInput): + def validate(self, text) -> bool: + if text == "": + return True + + try: + int(text) + return True + except ValueError: + return False + + +class UIFloatInput(UIRestrictedInput): + def validate(self, text) -> bool: + if text == "": + return True + + try: + float(text) + return True + except ValueError: + return False + + +class UIRegexInput(UIRestrictedInput): + def __init__(self, *args, pattern: str = r".*", **kwargs): + super().__init__() + self.pattern = pattern + + def validate(self, text: str) -> bool: + if text == "": + return True + + import re + + return re.match(self.pattern, text) is not None diff --git a/tests/unit/gui/test_exp_restricted_input.py b/tests/unit/gui/test_exp_restricted_input.py new file mode 100644 index 0000000000..468368197b --- /dev/null +++ b/tests/unit/gui/test_exp_restricted_input.py @@ -0,0 +1,51 @@ +from arcade.gui.experimental import UIPasswordInput +from arcade.gui.experimental.restricted_input import UIRestrictedInput, UIIntInput, UIRegexInput + + +def test_restricted_input_ignore_invalid_input(ui): + class FailingInput(UIRestrictedInput): + def validate(self, text) -> bool: + return text == "" + + fi = ui.add(FailingInput()) + + # WHEN + ui.click(fi.center_x, fi.center_y) + for l in "abcdef-.,1234567890": + ui.type_text(l) + + assert fi.text == "" + + +def test_int_input_accepts_only_digits(ui): + fi = ui.add(UIIntInput()) + + # WHEN + ui.click(fi.center_x, fi.center_y) + for l in "abcdef-.,1234567890": + ui.type_text(l) + + assert fi.text == "1234567890" + + +def test_float_input_accepts_only_float(ui): + fi = ui.add(UIIntInput()) + + # WHEN + ui.click(fi.center_x, fi.center_y) + for l in "abcdef-.,1234567890": + ui.type_text(l) + + assert fi.text == "1234567890" + + +def test_regex_input_accepts_only_matching_patterns(ui): + fi = ui.add(UIRegexInput(pattern="^[0-9]+$")) + + # WHEN + ui.click(fi.center_x, fi.center_y) + for l in "abcdef-.,1234567890": + ui.type_text(l) + + assert fi.text == "1234567890" + From 5513aa669f43c784606b2f74fe187558c2c13b3c Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 28 Mar 2025 14:50:37 +0100 Subject: [PATCH 093/279] fix example missing execution string --- arcade/examples/gui/exp_restricted_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/examples/gui/exp_restricted_input.py b/arcade/examples/gui/exp_restricted_input.py index 04c05ecd48..1611cc798f 100644 --- a/arcade/examples/gui/exp_restricted_input.py +++ b/arcade/examples/gui/exp_restricted_input.py @@ -1,7 +1,7 @@ """Example of using experimental UIRestrictedInputText. If Arcade and Python are properly installed, you can run this example with: -python -m arcade.examples.gui.own_widgets +python -m arcade.examples.gui.exp_restricted_input """ from __future__ import annotations From 2276c9e3683c3ccd0b79f92b7b37d2f7cc9f7166 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 28 Mar 2025 14:55:32 +0100 Subject: [PATCH 094/279] fix import format --- arcade/gui/experimental/restricted_input.py | 1 - 1 file changed, 1 deletion(-) diff --git a/arcade/gui/experimental/restricted_input.py b/arcade/gui/experimental/restricted_input.py index c3d228160a..1d7a41494c 100644 --- a/arcade/gui/experimental/restricted_input.py +++ b/arcade/gui/experimental/restricted_input.py @@ -5,7 +5,6 @@ from typing import Optional - from arcade.gui import UIEvent, UIInputText From 9e2461270e8416fbf97e20ff15b5f32e394bb72e Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Fri, 28 Mar 2025 15:49:37 +0100 Subject: [PATCH 095/279] Disable shadow window on all platforms (#2621) --- arcade/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index e687956869..41641639ef 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -71,11 +71,11 @@ def configure_logging(level: int | None = None): pyglet.options.headless = headless -from arcade import utils - +# from arcade import utils # Disable shadow window on macs and in headless mode. -if sys.platform == "darwin" or os.environ.get("ARCADE_HEADLESS") or utils.is_raspberry_pi(): - pyglet.options.shadow_window = False +# if sys.platform == "darwin" or os.environ.get("ARCADE_HEADLESS") or utils.is_raspberry_pi(): +# NOTE: We always disable shadow window now to have consistent behavior across platforms. +pyglet.options.shadow_window = False # Imports from modules that don't do anything circular From 35fcdfa7b2c73e2835269852f69bb5f328b9e32c Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 28 Mar 2025 17:34:38 +0100 Subject: [PATCH 096/279] Drop 3.9 (#2622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * drop Python 3.9 🥳 * update to Python 3.10 code * fix linter * fix future import * Tutorial line fixes --------- Co-authored-by: Einar Forselv --- .github/workflows/selfhosted_runner.yml | 2 +- CHANGELOG.md | 7 +- arcade/__init__.py | 2 - arcade/__main__.py | 2 - arcade/__pyinstaller/__init__.py | 2 - arcade/__pyinstaller/hook-arcade.py | 2 - arcade/cache/__init__.py | 2 - arcade/cache/texture.py | 2 - arcade/camera/data_types.py | 2 - arcade/camera/grips/position.py | 2 - arcade/camera/grips/rotate.py | 2 - arcade/camera/grips/screen_shake_2d.py | 2 - arcade/camera/grips/strafe.py | 2 - arcade/camera/projection_functions.py | 2 - arcade/clock.py | 2 - arcade/color/__init__.py | 2 - arcade/context.py | 2 - arcade/controller.py | 2 - arcade/csscolor/__init__.py | 2 - arcade/draw/rect.py | 2 - arcade/earclip.py | 2 - arcade/easing.py | 2 - arcade/examples/depth_of_field.py | 2 - arcade/examples/gui/2_widgets.py | 2 - arcade/examples/gui/3_buttons.py | 2 - arcade/examples/gui/4_with_camera.py | 9 +- arcade/examples/gui/6_size_hints.py | 2 - arcade/examples/gui/exp_hidden_password.py | 2 - arcade/examples/gui/exp_restricted_input.py | 2 - arcade/examples/gui/exp_scroll_area.py | 3 - arcade/examples/gui/ninepatch.py | 2 - arcade/examples/gui/own_layout.py | 2 - arcade/examples/gui/own_widget.py | 2 - arcade/examples/particle_fireworks.py | 1 - arcade/examples/performance_statistics.py | 1 - arcade/examples/pymunk_demo_top_down.py | 1 - arcade/examples/sections_demo_1.py | 2 - arcade/examples/sections_demo_3.py | 2 - arcade/examples/sprite_depth_cosine.py | 2 - arcade/examples/threaded_loading.py | 2 - arcade/experimental/__init__.py | 2 - arcade/experimental/atlas_load_save.py | 112 ----------- arcade/experimental/atlas_render_into.py | 2 - arcade/experimental/atlas_replace_image.py | 2 - arcade/experimental/bloom_filter.py | 2 - arcade/experimental/crt_filter.py | 2 - arcade/experimental/gaussian_kernel.py | 2 - arcade/experimental/geo_culling_check.py | 2 - arcade/experimental/postprocessing.py | 2 - arcade/experimental/profiling.py | 2 - arcade/experimental/pygame_interaction.py | 2 - arcade/experimental/query_demo.py | 2 - .../experimental/render_offscreen_animated.py | 2 - arcade/experimental/shadertoy.py | 2 - arcade/experimental/shadertoy_demo.py | 2 - arcade/experimental/shadertoy_demo_simple.py | 2 - arcade/experimental/shadertoy_textures.py | 2 - arcade/experimental/shadertoy_video_cv2.py | 2 - arcade/experimental/shapes_buffered_2_glow.py | 2 - arcade/experimental/shapes_perf.py | 2 - arcade/experimental/texture_transforms.py | 2 - arcade/future/background/__init__.py | 2 - arcade/future/background/background.py | 2 - .../future/background/background_texture.py | 2 - arcade/future/background/groups.py | 2 - arcade/future/input/input_manager_example.py | 6 +- arcade/future/input/input_mapping.py | 1 - arcade/future/input/inputs.py | 2 - arcade/future/input/raw_dicts.py | 2 - arcade/future/light/light_demo.py | 2 - arcade/future/light/light_demo_perf.py | 2 - arcade/future/light/lights.py | 2 - arcade/future/sub_clock.py | 4 +- arcade/future/texture_render_target.py | 2 - arcade/future/video/video_cv2.py | 2 - arcade/future/video/video_player.py | 2 - arcade/future/video/video_record_cv2.py | 2 - arcade/geometry.py | 2 - arcade/gl/__init__.py | 2 - arcade/gl/enums.py | 2 - arcade/gl/exceptions.py | 3 - arcade/gl/geometry.py | 2 - arcade/gl/glsl.py | 2 - arcade/gl/program.py | 2 - arcade/gl/types.py | 2 - arcade/gl/uniform.py | 2 - arcade/gl/utils.py | 2 - arcade/gui/__init__.py | 2 - arcade/gui/constructs.py | 8 +- arcade/gui/events.py | 2 - arcade/gui/experimental/password_input.py | 6 +- arcade/gui/experimental/restricted_input.py | 4 +- arcade/gui/experimental/scroll_area.py | 6 +- arcade/gui/experimental/typed_text_input.py | 6 +- arcade/gui/mixins.py | 8 +- arcade/gui/nine_patch.py | 2 - arcade/gui/property.py | 2 - arcade/gui/style.py | 2 - arcade/gui/surface.py | 6 +- arcade/gui/ui_manager.py | 8 +- arcade/gui/view.py | 2 - arcade/gui/widgets/__init__.py | 20 +- arcade/gui/widgets/buttons.py | 10 +- arcade/gui/widgets/dropdown.py | 8 +- arcade/gui/widgets/image.py | 2 - arcade/gui/widgets/layout.py | 10 +- arcade/gui/widgets/slider.py | 4 +- arcade/gui/widgets/text.py | 28 ++- arcade/gui/widgets/toggle.py | 8 +- arcade/hitbox/__init__.py | 2 - arcade/hitbox/bounding_box.py | 2 - arcade/hitbox/pymunk.py | 2 - arcade/hitbox/simple.py | 2 - arcade/isometric.py | 2 - arcade/joysticks.py | 2 - arcade/key/__init__.py | 1 - arcade/management/__init__.py | 2 - arcade/math.py | 2 - arcade/particles/__init__.py | 2 - arcade/particles/emitter_simple.py | 2 - arcade/particles/particle.py | 2 - arcade/paths.py | 2 - arcade/perf_graph.py | 2 - arcade/perf_info.py | 2 - arcade/physics_engines.py | 2 - arcade/pymunk_physics_engine.py | 2 - arcade/resources/__init__.py | 2 - arcade/scene.py | 2 - arcade/screenshot.py | 2 - arcade/shape_list.py | 2 - arcade/sound.py | 2 - arcade/sprite/__init__.py | 2 - arcade/sprite/animated.py | 2 - arcade/sprite/enums.py | 2 - arcade/sprite/mixins.py | 3 - arcade/sprite/sprite.py | 2 - arcade/sprite_list/__init__.py | 2 - arcade/sprite_list/collision.py | 2 - arcade/sprite_list/spatial_hash.py | 2 - arcade/text.py | 2 - arcade/texture/__init__.py | 2 - arcade/texture/generate.py | 2 - arcade/texture/loading.py | 2 - arcade/texture/manager.py | 2 - arcade/texture/tools.py | 2 - arcade/texture/transforms.py | 2 - arcade/texture_atlas/__init__.py | 2 - arcade/texture_atlas/atlas_array.py | 2 - arcade/texture_atlas/atlas_bindless.py | 2 - arcade/texture_atlas/helpers.py | 187 ------------------ arcade/texture_atlas/ref_counters.py | 2 - arcade/tilemap/__init__.py | 2 - arcade/types/__init__.py | 2 - arcade/types/numbers.py | 2 - arcade/types/vector_like.py | 2 - arcade/utils.py | 2 - arcade/version.py | 2 - benchmarks/sprite/sprite_alt.py | 2 - doc/conf.py | 2 - doc/tutorials/card_game/index.rst | 2 +- doc/tutorials/card_game/solitaire_11.py | 61 +++--- doc/tutorials/pymunk_platformer/index.rst | 10 +- .../pymunk_demo_platformer.py | 16 +- .../pymunk_demo_platformer_02.py | 2 - .../pymunk_demo_platformer_03.py | 27 +-- .../pymunk_demo_platformer_04.py | 33 ++-- .../pymunk_demo_platformer_05.py | 71 ++++--- .../pymunk_demo_platformer_06.py | 73 +++---- .../pymunk_demo_platformer_07.py | 12 +- .../pymunk_demo_platformer_08.py | 76 +++---- .../pymunk_demo_platformer_09.py | 13 +- .../pymunk_demo_platformer_10.py | 12 +- .../pymunk_demo_platformer_11.py | 15 +- .../pymunk_demo_platformer_12.py | 169 +++++++++------- make.py | 2 - pyproject.toml | 3 +- tests/conftest.py | 2 - .../sprite_collision_inspector.py | 2 - tests/unit/color/test_module_color.py | 6 +- tests/unit/color/test_module_csscolor.py | 4 +- tests/unit/gui/__init__.py | 2 - tests/unit/rect/test_rect_creation_helpers.py | 2 - util/create_resources_listing.py | 2 - util/doc_helpers/import_resolver.py | 5 +- util/doc_helpers/real_filesystem.py | 2 - util/doc_helpers/vfs.py | 2 - util/update_quick_index.py | 2 - 187 files changed, 400 insertions(+), 967 deletions(-) delete mode 100644 arcade/experimental/atlas_load_save.py delete mode 100644 arcade/texture_atlas/helpers.py diff --git a/.github/workflows/selfhosted_runner.yml b/.github/workflows/selfhosted_runner.yml index 7d672e7672..c0caacd8b7 100644 --- a/.github/workflows/selfhosted_runner.yml +++ b/.github/workflows/selfhosted_runner.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ['3.9.13', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13'] architecture: ['x64'] steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f33b004d4..9ee2958e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,12 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## Version 3.0.2 (unreleased) + +## Version 3.1 (unreleased) + +- Drop Python 3.9 support + +## Version 3.0.2 ### Improvements diff --git a/arcade/__init__.py b/arcade/__init__.py index 41641639ef..cef66f34b9 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -4,8 +4,6 @@ A Python simple, easy to use module for creating 2D games. """ -from __future__ import annotations - # flake8: noqa: E402 # Error out if we import Arcade with an incompatible version of Python. import sys diff --git a/arcade/__main__.py b/arcade/__main__.py index 62409a6fd0..1a318f0781 100644 --- a/arcade/__main__.py +++ b/arcade/__main__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from arcade.management import show_info if __name__ == "__main__": diff --git a/arcade/__pyinstaller/__init__.py b/arcade/__pyinstaller/__init__.py index 9309da9cc5..1c52aadf4b 100644 --- a/arcade/__pyinstaller/__init__.py +++ b/arcade/__pyinstaller/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os diff --git a/arcade/__pyinstaller/hook-arcade.py b/arcade/__pyinstaller/hook-arcade.py index 9035f95b7c..76f89a4932 100644 --- a/arcade/__pyinstaller/hook-arcade.py +++ b/arcade/__pyinstaller/hook-arcade.py @@ -10,8 +10,6 @@ https://api.arcade.academy/en/latest/tutorials/bundling_with_pyinstaller/index.html """ -from __future__ import annotations - from importlib.util import find_spec from pathlib import Path diff --git a/arcade/cache/__init__.py b/arcade/cache/__init__.py index f0aa68c6b8..33e67538a4 100644 --- a/arcade/cache/__init__.py +++ b/arcade/cache/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any from .hit_box import HitBoxCache from .texture import TextureCache diff --git a/arcade/cache/texture.py b/arcade/cache/texture.py index 2ed64e950b..4f1008dca0 100644 --- a/arcade/cache/texture.py +++ b/arcade/cache/texture.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path from typing import TYPE_CHECKING diff --git a/arcade/camera/data_types.py b/arcade/camera/data_types.py index 2291c4f564..c67b90c32c 100644 --- a/arcade/camera/data_types.py +++ b/arcade/camera/data_types.py @@ -4,8 +4,6 @@ wide usage throughout Arcade's camera code. """ -from __future__ import annotations - from contextlib import contextmanager from typing import Final, Generator, Protocol diff --git a/arcade/camera/grips/position.py b/arcade/camera/grips/position.py index ebc3dcb2ef..2f22c25bc9 100644 --- a/arcade/camera/grips/position.py +++ b/arcade/camera/grips/position.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pyglet.math import Vec3 from arcade.camera import CameraData diff --git a/arcade/camera/grips/rotate.py b/arcade/camera/grips/rotate.py index 4d70f199fc..02432ec420 100644 --- a/arcade/camera/grips/rotate.py +++ b/arcade/camera/grips/rotate.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pyglet.math import Vec3 from arcade.camera.data_types import CameraData diff --git a/arcade/camera/grips/screen_shake_2d.py b/arcade/camera/grips/screen_shake_2d.py index b51fbbee34..eb51fa7516 100644 --- a/arcade/camera/grips/screen_shake_2d.py +++ b/arcade/camera/grips/screen_shake_2d.py @@ -3,8 +3,6 @@ Provides an easy way to cause a camera to shake. """ -from __future__ import annotations - from math import exp, floor, log, pi, sin from random import randint, uniform diff --git a/arcade/camera/grips/strafe.py b/arcade/camera/grips/strafe.py index 3d3ffc5d33..30eafe0247 100644 --- a/arcade/camera/grips/strafe.py +++ b/arcade/camera/grips/strafe.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pyglet.math import Vec3 from arcade.camera.data_types import CameraData diff --git a/arcade/camera/projection_functions.py b/arcade/camera/projection_functions.py index 5321b7e21f..dda0fb4f6e 100644 --- a/arcade/camera/projection_functions.py +++ b/arcade/camera/projection_functions.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from math import pi, tan from pyglet.math import Mat4, Vec2, Vec3, Vec4 diff --git a/arcade/clock.py b/arcade/clock.py index 967b7c2f78..1ebb20e597 100644 --- a/arcade/clock.py +++ b/arcade/clock.py @@ -1,5 +1,3 @@ -from __future__ import annotations - __all__ = ( "Clock", "FixedClock", diff --git a/arcade/color/__init__.py b/arcade/color/__init__.py index 23ebf27dcd..2cfe51e998 100644 --- a/arcade/color/__init__.py +++ b/arcade/color/__init__.py @@ -2,8 +2,6 @@ This module pre-defines several colors. """ -from __future__ import annotations - from arcade.types import Color AERO_BLUE = Color(201, 255, 229, 255) diff --git a/arcade/context.py b/arcade/context.py index 8ca0b58749..56164afc4d 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -3,8 +3,6 @@ Contains pre-loaded programs """ -from __future__ import annotations - from pathlib import Path from typing import Any, Iterable, Sequence diff --git a/arcade/controller.py b/arcade/controller.py index 10d5e8f098..0e096f8e9f 100644 --- a/arcade/controller.py +++ b/arcade/controller.py @@ -5,8 +5,6 @@ https://pyglet.readthedocs.io/en/latest/programming_guide/input.html#using-controllers """ -from __future__ import annotations - import pyglet.input __all__ = ["get_controllers", "ControllerManager"] diff --git a/arcade/csscolor/__init__.py b/arcade/csscolor/__init__.py index 67f633ecd7..a19aad3f3f 100644 --- a/arcade/csscolor/__init__.py +++ b/arcade/csscolor/__init__.py @@ -3,8 +3,6 @@ https://www.w3.org/TR/2018/PR-css-color-3-20180315/ """ -from __future__ import annotations - from arcade.types import Color ALICE_BLUE = Color(240, 248, 255, 255) diff --git a/arcade/draw/rect.py b/arcade/draw/rect.py index eb87f6e21d..c7790d68b4 100644 --- a/arcade/draw/rect.py +++ b/arcade/draw/rect.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import array from arcade import gl diff --git a/arcade/earclip.py b/arcade/earclip.py index c00e5d4da6..8d071f965d 100644 --- a/arcade/earclip.py +++ b/arcade/earclip.py @@ -3,8 +3,6 @@ from: https://github.com/linuxlewis/tripy/blob/master/tripy.py """ -from __future__ import annotations - from arcade.types import Point2, Point2List diff --git a/arcade/easing.py b/arcade/easing.py index 3d69dee3b5..2ce734dee9 100644 --- a/arcade/easing.py +++ b/arcade/easing.py @@ -2,8 +2,6 @@ Functions used to support easing """ -from __future__ import annotations - from dataclasses import dataclass from math import cos, pi, sin from typing import Callable diff --git a/arcade/examples/depth_of_field.py b/arcade/examples/depth_of_field.py index e1fcf40515..a3c8f7f882 100644 --- a/arcade/examples/depth_of_field.py +++ b/arcade/examples/depth_of_field.py @@ -21,8 +21,6 @@ python -m arcade.examples.depth_of_field """ -from __future__ import annotations - from contextlib import contextmanager from math import cos, pi from random import randint, uniform diff --git a/arcade/examples/gui/2_widgets.py b/arcade/examples/gui/2_widgets.py index 973963f23b..7a15819d55 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -6,8 +6,6 @@ python -m arcade.examples.gui.2_widgets """ -from __future__ import annotations - import textwrap from copy import deepcopy diff --git a/arcade/examples/gui/3_buttons.py b/arcade/examples/gui/3_buttons.py index e33373b72f..d628feb185 100644 --- a/arcade/examples/gui/3_buttons.py +++ b/arcade/examples/gui/3_buttons.py @@ -8,8 +8,6 @@ python -m arcade.examples.gui.3_buttons """ -from __future__ import annotations - import arcade from arcade.gui import ( UIAnchorLayout, diff --git a/arcade/examples/gui/4_with_camera.py b/arcade/examples/gui/4_with_camera.py index 6a5325b156..08c277b099 100644 --- a/arcade/examples/gui/4_with_camera.py +++ b/arcade/examples/gui/4_with_camera.py @@ -10,11 +10,8 @@ python -m arcade.examples.gui.4_with_camera """ -from __future__ import annotations - import math import random -from typing import Optional import arcade from arcade.gui import UIAnchorLayout, UIBoxLayout, UIFlatButton, UILabel, UIOnClickEvent, UIView @@ -164,7 +161,7 @@ def on_draw_before_ui(self): self.sprites.draw() self.coins.draw() - def on_update(self, delta_time: float) -> Optional[bool]: + def on_update(self, delta_time: float) -> bool | None: if self._total_time > self._game_duration: # ad new UI label to show the end of the game game_over_text = self.ui.add( @@ -244,7 +241,7 @@ def on_update(self, delta_time: float) -> Optional[bool]: return False - def on_key_press(self, symbol: int, modifiers: int) -> Optional[bool]: + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: self.keys.add(symbol) if symbol == arcade.key.ESCAPE: @@ -254,7 +251,7 @@ def on_key_press(self, symbol: int, modifiers: int) -> Optional[bool]: return False - def on_key_release(self, symbol: int, modifiers: int) -> Optional[bool]: + def on_key_release(self, symbol: int, modifiers: int) -> bool | None: if symbol in self.keys: self.keys.remove(symbol) return False diff --git a/arcade/examples/gui/6_size_hints.py b/arcade/examples/gui/6_size_hints.py index e9196b99a9..6f95713118 100644 --- a/arcade/examples/gui/6_size_hints.py +++ b/arcade/examples/gui/6_size_hints.py @@ -17,8 +17,6 @@ python -m arcade.examples.gui.6_size_hints """ -from __future__ import annotations - import textwrap import arcade diff --git a/arcade/examples/gui/exp_hidden_password.py b/arcade/examples/gui/exp_hidden_password.py index 722abb692b..8c83ffeff5 100644 --- a/arcade/examples/gui/exp_hidden_password.py +++ b/arcade/examples/gui/exp_hidden_password.py @@ -12,8 +12,6 @@ python -m arcade.examples.gui.exp_hidden_password """ -from __future__ import annotations - import arcade from arcade.gui import UIInputText, UIOnClickEvent, UIView from arcade.gui.experimental.password_input import UIPasswordInput diff --git a/arcade/examples/gui/exp_restricted_input.py b/arcade/examples/gui/exp_restricted_input.py index 1611cc798f..bfa6d8deb8 100644 --- a/arcade/examples/gui/exp_restricted_input.py +++ b/arcade/examples/gui/exp_restricted_input.py @@ -4,8 +4,6 @@ python -m arcade.examples.gui.exp_restricted_input """ -from __future__ import annotations - import arcade from arcade.gui import UIAnchorLayout, UIBoxLayout, UIView from arcade.gui.experimental.restricted_input import UIIntInput diff --git a/arcade/examples/gui/exp_scroll_area.py b/arcade/examples/gui/exp_scroll_area.py index 7b035fb7a3..49463bc6c5 100644 --- a/arcade/examples/gui/exp_scroll_area.py +++ b/arcade/examples/gui/exp_scroll_area.py @@ -9,9 +9,6 @@ python -m arcade.examples.gui.exp_scroll_area """ -from __future__ import annotations - - import arcade from arcade.gui import UIAnchorLayout, UIBoxLayout, UIFlatButton, UIView from arcade.gui.experimental import UIScrollArea diff --git a/arcade/examples/gui/ninepatch.py b/arcade/examples/gui/ninepatch.py index 25239bf259..644c1b6967 100644 --- a/arcade/examples/gui/ninepatch.py +++ b/arcade/examples/gui/ninepatch.py @@ -9,8 +9,6 @@ python -m arcade.examples.gui.ninepatch """ -from __future__ import annotations - import arcade from arcade import load_texture from arcade.gui import UIManager, UIAnchorLayout, UIWidget, NinePatchTexture diff --git a/arcade/examples/gui/own_layout.py b/arcade/examples/gui/own_layout.py index 8550e43892..8b0dea84cb 100644 --- a/arcade/examples/gui/own_layout.py +++ b/arcade/examples/gui/own_layout.py @@ -8,8 +8,6 @@ python -m arcade.examples.gui.own_layout """ -from __future__ import annotations - from math import cos, sin from typing import TypeVar diff --git a/arcade/examples/gui/own_widget.py b/arcade/examples/gui/own_widget.py index 715bb8f4c8..befc5cb6ce 100644 --- a/arcade/examples/gui/own_widget.py +++ b/arcade/examples/gui/own_widget.py @@ -22,8 +22,6 @@ python -m arcade.examples.gui.own_widgets """ -from __future__ import annotations - import arcade from arcade.gui import Property, UIAnchorLayout, UIBoxLayout, UISpace, UIView, UIWidget, bind from arcade.types import Color diff --git a/arcade/examples/particle_fireworks.py b/arcade/examples/particle_fireworks.py index 2e44f21fff..caef7117f6 100644 --- a/arcade/examples/particle_fireworks.py +++ b/arcade/examples/particle_fireworks.py @@ -6,7 +6,6 @@ If Python and Arcade are installed, this example can be run from the command line with: python -m arcade.examples.particle_fireworks """ -from __future__ import annotations import random import pyglet diff --git a/arcade/examples/performance_statistics.py b/arcade/examples/performance_statistics.py index 3360ae9552..f117f42918 100644 --- a/arcade/examples/performance_statistics.py +++ b/arcade/examples/performance_statistics.py @@ -19,7 +19,6 @@ command line with: python -m arcade.examples.performance_statistics """ -from __future__ import annotations import random import arcade diff --git a/arcade/examples/pymunk_demo_top_down.py b/arcade/examples/pymunk_demo_top_down.py index 65b73c795c..0cef3abc47 100644 --- a/arcade/examples/pymunk_demo_top_down.py +++ b/arcade/examples/pymunk_demo_top_down.py @@ -5,7 +5,6 @@ If Python and Arcade are installed, this example can be run from the command line with: python -m arcade.examples.pymunk_demo_top_down """ -from __future__ import annotations import math import random import arcade diff --git a/arcade/examples/sections_demo_1.py b/arcade/examples/sections_demo_1.py index ac314aa461..9f42df9586 100644 --- a/arcade/examples/sections_demo_1.py +++ b/arcade/examples/sections_demo_1.py @@ -16,8 +16,6 @@ python -m arcade.examples.sections_demo_1 """ -from __future__ import annotations - import arcade from arcade import SectionManager diff --git a/arcade/examples/sections_demo_3.py b/arcade/examples/sections_demo_3.py index e7d657339b..a05640b0cd 100644 --- a/arcade/examples/sections_demo_3.py +++ b/arcade/examples/sections_demo_3.py @@ -22,8 +22,6 @@ python -m arcade.examples.sections_demo_3 """ -from __future__ import annotations - from math import sqrt import arcade diff --git a/arcade/examples/sprite_depth_cosine.py b/arcade/examples/sprite_depth_cosine.py index ef519a66aa..82c9dea9fb 100644 --- a/arcade/examples/sprite_depth_cosine.py +++ b/arcade/examples/sprite_depth_cosine.py @@ -14,8 +14,6 @@ python -m arcade.examples.sprite_depth_cosine """ -from __future__ import annotations - import math from pyglet.graphics import Batch diff --git a/arcade/examples/threaded_loading.py b/arcade/examples/threaded_loading.py index 494d2ade15..b79ee25e47 100644 --- a/arcade/examples/threaded_loading.py +++ b/arcade/examples/threaded_loading.py @@ -26,8 +26,6 @@ If Python and Arcade are installed, this example can be run from the command line with: python -m arcade.examples.threaded_loading """ -from __future__ import annotations - import sys from time import sleep diff --git a/arcade/experimental/__init__.py b/arcade/experimental/__init__.py index 34cfea8e19..df282fc342 100644 --- a/arcade/experimental/__init__.py +++ b/arcade/experimental/__init__.py @@ -2,8 +2,6 @@ Experimental stuff. API may change. """ -from __future__ import annotations - from .shadertoy import Shadertoy, ShadertoyBuffer, ShadertoyBase from .crt_filter import CRTFilter from .bloom_filter import BloomFilter diff --git a/arcade/experimental/atlas_load_save.py b/arcade/experimental/atlas_load_save.py deleted file mode 100644 index 1e102df6df..0000000000 --- a/arcade/experimental/atlas_load_save.py +++ /dev/null @@ -1,112 +0,0 @@ -# """ -# Quick and dirty atlas load/save testing. -# Loading and saving atlases are not officially supported. -# This is simply an experiment. - -# Dump atlas: -# python arcade/experimental/atlas_load_save.py save - -# Load atlas: -# python arcade/experimental/atlas_load_save.py load -# """ - -# from __future__ import annotations - -# import sys -# import math -# import pprint -# from typing import Dict, Tuple, List -# from time import perf_counter -# from pathlib import Path -# import arcade -# from arcade.texture_atlas.helpers import save_atlas, load_atlas - -# MODE = 'save' -# RESOURCE_ROOT = arcade.resources.ASSET_PATH -# DESTINATION = Path.cwd() - -# texture_paths: List[Path] = [] -# texture_paths += RESOURCE_ROOT.glob("images/enemies/*.png") -# texture_paths += RESOURCE_ROOT.glob("images/items/*.png") -# texture_paths += RESOURCE_ROOT.glob("images/alien/*.png") -# texture_paths += RESOURCE_ROOT.glob("images/tiles/*.png") - - -# def populate_atlas(atlas: arcade.TextureAtlas) -> Tuple[int, Dict[str, float]]: -# """Populate the atlas with all the resources we can find""" -# perf_data = {} -# textures = [] -# t = perf_counter() -# for path in texture_paths: -# texture = arcade.load_texture(path, hit_box_algorithm=arcade.hitbox.algo_simple) -# textures.append(texture) -# perf_data['load_textures'] = perf_counter() - t - -# t = perf_counter() -# for texture in textures: -# atlas.add(texture) -# perf_data['add_textures'] = perf_counter() - t - -# return len(textures), perf_data - - -# class AtlasLoadSave(arcade.Window): -# """ -# This class demonstrates how to load and save texture atlases. -# """ - -# def __init__(self): -# super().__init__(1280, 720, "Atlas Load Save") -# self.done = False - -# if MODE == "save": -# t = perf_counter() -# self.atlas = arcade.TextureAtlas((1024, 1024)) -# count, perf_data = populate_atlas(self.atlas) -# print(f'Populated atlas with {count} texture in {perf_counter() - t:.2f} seconds') -# save_atlas( -# self.atlas, -# directory=Path.cwd(), -# name="test", -# resource_root=RESOURCE_ROOT, -# ) -# self.done = True -# if MODE == "load": -# t = perf_counter() -# self.atlas, perf_data = load_atlas(Path.cwd() / 'test.json', RESOURCE_ROOT) -# print(f'Loaded atlas in {perf_counter() - t:.2f} seconds') -# pprint.pprint(perf_data, indent=2) -# # self.done = True - -# # Make a sprite for each texture -# self.sp = arcade.SpriteList(atlas=self.atlas) -# for i, texture in enumerate(self.atlas.textures): -# pos = i * 64 -# sprite = arcade.Sprite( -# texture, -# center_x=32 + math.fmod(pos, self.width), -# center_y=32 + math.floor(pos / self.width) * 64, -# scale=0.45, -# ) -# self.sp.append(sprite) - -# print(f'Atlas has {len(self.atlas._textures)} textures') - -# # self.atlas.show(draw_borders=True) - -# def on_draw(self): -# self.clear() -# self.sp.draw(pixelated=True) - -# def on_update(self, delta_time: float): -# if self.done: -# self.close() - - -# if len(sys.argv) < 2 or sys.argv[1] not in ('load', 'save'): -# print('Usage: atlas_load_save.py [save|load]') -# sys.exit(1) - -# MODE = sys.argv[1] - -# AtlasLoadSave().run() diff --git a/arcade/experimental/atlas_render_into.py b/arcade/experimental/atlas_render_into.py index 23ddfcc78c..d527dd2a39 100644 --- a/arcade/experimental/atlas_render_into.py +++ b/arcade/experimental/atlas_render_into.py @@ -2,8 +2,6 @@ Render into a sub-section of a texture atlas """ -from __future__ import annotations - import math import arcade diff --git a/arcade/experimental/atlas_replace_image.py b/arcade/experimental/atlas_replace_image.py index 3a1d949e5a..8bd284419b 100644 --- a/arcade/experimental/atlas_replace_image.py +++ b/arcade/experimental/atlas_replace_image.py @@ -5,8 +5,6 @@ over time at (not too frequently) we can update the underlying atlas directly. """ -from __future__ import annotations - from itertools import cycle import arcade diff --git a/arcade/experimental/bloom_filter.py b/arcade/experimental/bloom_filter.py index dc9fffe8df..7f380d9c5c 100644 --- a/arcade/experimental/bloom_filter.py +++ b/arcade/experimental/bloom_filter.py @@ -2,8 +2,6 @@ See: https://www.shadertoy.com/view/lsBfRc """ -from __future__ import annotations - from arcade.experimental import Shadertoy diff --git a/arcade/experimental/crt_filter.py b/arcade/experimental/crt_filter.py index 10667a4141..02cde40908 100644 --- a/arcade/experimental/crt_filter.py +++ b/arcade/experimental/crt_filter.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pyglet.math import Vec2 from arcade.experimental import Shadertoy diff --git a/arcade/experimental/gaussian_kernel.py b/arcade/experimental/gaussian_kernel.py index 4b219b3446..c39563b0aa 100644 --- a/arcade/experimental/gaussian_kernel.py +++ b/arcade/experimental/gaussian_kernel.py @@ -4,8 +4,6 @@ https://observablehq.com/@jobleonard/gaussian-kernel-calculater """ -from __future__ import annotations - import math SQRT2 = math.sqrt(2) diff --git a/arcade/experimental/geo_culling_check.py b/arcade/experimental/geo_culling_check.py index 1193b8cff6..ef76fb8754 100644 --- a/arcade/experimental/geo_culling_check.py +++ b/arcade/experimental/geo_culling_check.py @@ -6,8 +6,6 @@ Simply run the program and move draw the sprites around using the mouse. """ -from __future__ import annotations - import PIL from pyglet.math import Mat4 diff --git a/arcade/experimental/postprocessing.py b/arcade/experimental/postprocessing.py index bc6a0e7a12..b98278c938 100644 --- a/arcade/experimental/postprocessing.py +++ b/arcade/experimental/postprocessing.py @@ -2,8 +2,6 @@ Post-processing shaders. """ -from __future__ import annotations - from arcade import get_window from arcade.context import ArcadeContext from arcade.experimental.gaussian_kernel import gaussian_kernel diff --git a/arcade/experimental/profiling.py b/arcade/experimental/profiling.py index 7807b3b021..26e3d40037 100644 --- a/arcade/experimental/profiling.py +++ b/arcade/experimental/profiling.py @@ -2,8 +2,6 @@ Simple experimental profiler. This api is not stable. """ -from __future__ import annotations - import cProfile import pstats from contextlib import contextmanager diff --git a/arcade/experimental/pygame_interaction.py b/arcade/experimental/pygame_interaction.py index 308d9e0611..fa224abed7 100644 --- a/arcade/experimental/pygame_interaction.py +++ b/arcade/experimental/pygame_interaction.py @@ -13,8 +13,6 @@ """ -from __future__ import annotations - import math import pygame # type: ignore diff --git a/arcade/experimental/query_demo.py b/arcade/experimental/query_demo.py index 1439cdafbb..42a7222460 100644 --- a/arcade/experimental/query_demo.py +++ b/arcade/experimental/query_demo.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import math import time diff --git a/arcade/experimental/render_offscreen_animated.py b/arcade/experimental/render_offscreen_animated.py index 7af10484b3..ad8a2b7943 100644 --- a/arcade/experimental/render_offscreen_animated.py +++ b/arcade/experimental/render_offscreen_animated.py @@ -5,8 +5,6 @@ python -m arcade.examples.shape_list_skylines """ -from __future__ import annotations - import random import time diff --git a/arcade/experimental/shadertoy.py b/arcade/experimental/shadertoy.py index b304fc473a..ca13c53b86 100644 --- a/arcade/experimental/shadertoy.py +++ b/arcade/experimental/shadertoy.py @@ -15,8 +15,6 @@ uniform float iSampleRate; // sound sample rate (i.e., 44100) """ -from __future__ import annotations - import string from datetime import datetime from pathlib import Path diff --git a/arcade/experimental/shadertoy_demo.py b/arcade/experimental/shadertoy_demo.py index cb83416935..35ac1d0db4 100644 --- a/arcade/experimental/shadertoy_demo.py +++ b/arcade/experimental/shadertoy_demo.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path import arcade diff --git a/arcade/experimental/shadertoy_demo_simple.py b/arcade/experimental/shadertoy_demo_simple.py index f9423aecbf..cad3087e23 100644 --- a/arcade/experimental/shadertoy_demo_simple.py +++ b/arcade/experimental/shadertoy_demo_simple.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import arcade from arcade.experimental.shadertoy import Shadertoy diff --git a/arcade/experimental/shadertoy_textures.py b/arcade/experimental/shadertoy_textures.py index 8f06deb6f3..78ac2f9062 100644 --- a/arcade/experimental/shadertoy_textures.py +++ b/arcade/experimental/shadertoy_textures.py @@ -3,8 +3,6 @@ We simply mix the two texture layers. """ -from __future__ import annotations - import arcade from arcade.experimental.shadertoy import Shadertoy diff --git a/arcade/experimental/shadertoy_video_cv2.py b/arcade/experimental/shadertoy_video_cv2.py index 581231170a..7dfb033ff3 100644 --- a/arcade/experimental/shadertoy_video_cv2.py +++ b/arcade/experimental/shadertoy_video_cv2.py @@ -6,8 +6,6 @@ """ -from __future__ import annotations - import cv2 # type: ignore import arcade diff --git a/arcade/experimental/shapes_buffered_2_glow.py b/arcade/experimental/shapes_buffered_2_glow.py index 452c3aa0fc..2abc284e8b 100644 --- a/arcade/experimental/shapes_buffered_2_glow.py +++ b/arcade/experimental/shapes_buffered_2_glow.py @@ -7,8 +7,6 @@ python -m arcade.examples.shapes_buffered """ -from __future__ import annotations - import random from pyglet import gl diff --git a/arcade/experimental/shapes_perf.py b/arcade/experimental/shapes_perf.py index f339543ed4..b4dc8f5c65 100644 --- a/arcade/experimental/shapes_perf.py +++ b/arcade/experimental/shapes_perf.py @@ -2,8 +2,6 @@ This is for testing geometry shader shapes. Please keep. """ -from __future__ import annotations - import math import random import time diff --git a/arcade/experimental/texture_transforms.py b/arcade/experimental/texture_transforms.py index 88ebb283f3..27b4cc9c0e 100644 --- a/arcade/experimental/texture_transforms.py +++ b/arcade/experimental/texture_transforms.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import random import arcade diff --git a/arcade/future/background/__init__.py b/arcade/future/background/__init__.py index ad3bc3d588..22e701ba75 100644 --- a/arcade/future/background/__init__.py +++ b/arcade/future/background/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Tuple from PIL import Image diff --git a/arcade/future/background/background.py b/arcade/future/background/background.py index 58054b2c5a..e75177fe80 100644 --- a/arcade/future/background/background.py +++ b/arcade/future/background/background.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import arcade.gl as gl from arcade.future.background import BackgroundTexture from arcade.window_commands import get_window diff --git a/arcade/future/background/background_texture.py b/arcade/future/background/background_texture.py index 6db7fdf37b..ec15360a9a 100644 --- a/arcade/future/background/background_texture.py +++ b/arcade/future/background/background_texture.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from PIL import Image from pyglet.math import Mat3 diff --git a/arcade/future/background/groups.py b/arcade/future/background/groups.py index b6ee801631..081bd09a83 100644 --- a/arcade/future/background/groups.py +++ b/arcade/future/background/groups.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import arcade.gl as gl from arcade.future.background import Background diff --git a/arcade/future/input/input_manager_example.py b/arcade/future/input/input_manager_example.py index 87e573054a..0fce91380c 100644 --- a/arcade/future/input/input_manager_example.py +++ b/arcade/future/input/input_manager_example.py @@ -1,8 +1,6 @@ # type: ignore -from __future__ import annotations - import random -from typing import Optional, Sequence +from typing import Sequence import pyglet from pyglet.input import Controller @@ -30,7 +28,7 @@ def __init__( texture, walls: arcade.SpriteList, input_manager_template: InputManager, - controller: Optional[pyglet.input.Controller] = None, + controller: pyglet.input.Controller | None = None, center_x: float = 0.0, center_y: float = 0.0, x_max_speed: float = 300.0, diff --git a/arcade/future/input/input_mapping.py b/arcade/future/input/input_mapping.py index d4caeed990..a6acd962d5 100644 --- a/arcade/future/input/input_mapping.py +++ b/arcade/future/input/input_mapping.py @@ -1,5 +1,4 @@ # type: ignore - from __future__ import annotations from arcade.future.input import inputs diff --git a/arcade/future/input/inputs.py b/arcade/future/input/inputs.py index e6b8b764b0..add46e8237 100644 --- a/arcade/future/input/inputs.py +++ b/arcade/future/input/inputs.py @@ -5,8 +5,6 @@ However Controller buttons and axes are mapped to their Pyglet string values. """ -from __future__ import annotations - from enum import Enum, auto from sys import platform from typing import Type diff --git a/arcade/future/input/raw_dicts.py b/arcade/future/input/raw_dicts.py index 5f44fc2c4f..7ec453982a 100644 --- a/arcade/future/input/raw_dicts.py +++ b/arcade/future/input/raw_dicts.py @@ -3,8 +3,6 @@ Placing them here prevents circular import issues. """ -from __future__ import annotations - from typing import Union from typing_extensions import TypedDict diff --git a/arcade/future/light/light_demo.py b/arcade/future/light/light_demo.py index f3114d960a..cfdedd3dcf 100644 --- a/arcade/future/light/light_demo.py +++ b/arcade/future/light/light_demo.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import math import arcade diff --git a/arcade/future/light/light_demo_perf.py b/arcade/future/light/light_demo_perf.py index 5611d032fd..e0684fb392 100644 --- a/arcade/future/light/light_demo_perf.py +++ b/arcade/future/light/light_demo_perf.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import math import random diff --git a/arcade/future/light/lights.py b/arcade/future/light/lights.py index e792ef4b0c..42fb51e45e 100644 --- a/arcade/future/light/lights.py +++ b/arcade/future/light/lights.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from array import array from typing import Iterator, Sequence diff --git a/arcade/future/sub_clock.py b/arcade/future/sub_clock.py index 72ad712133..339e324db0 100644 --- a/arcade/future/sub_clock.py +++ b/arcade/future/sub_clock.py @@ -1,11 +1,11 @@ from __future__ import annotations -from typing import Optional, Union +from typing import Union from arcade.clock import GLOBAL_CLOCK, Clock -def boot_strap_clock(clock: Optional[Clock] = None) -> Clock: +def boot_strap_clock(clock: Clock | None = None) -> Clock: """ Because the sub_clock is not a fully featured part of Arcade we have to manipulate the clocks before the can be used with sub_clocks. diff --git a/arcade/future/texture_render_target.py b/arcade/future/texture_render_target.py index 96fbed491b..88cb5e1e3d 100644 --- a/arcade/future/texture_render_target.py +++ b/arcade/future/texture_render_target.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from arcade import get_window from arcade.color import TRANSPARENT_BLACK from arcade.gl import geometry diff --git a/arcade/future/video/video_cv2.py b/arcade/future/video/video_cv2.py index 6ef9c610cd..6f38a3a538 100644 --- a/arcade/future/video/video_cv2.py +++ b/arcade/future/video/video_cv2.py @@ -9,8 +9,6 @@ pip install opencv-python """ -from __future__ import annotations - from math import floor from pathlib import Path diff --git a/arcade/future/video/video_player.py b/arcade/future/video/video_player.py index 548f16c1ec..4962011ae6 100644 --- a/arcade/future/video/video_player.py +++ b/arcade/future/video/video_player.py @@ -5,8 +5,6 @@ and you might need to tell pyglet where it's located. """ -from __future__ import annotations - from pathlib import Path # import sys diff --git a/arcade/future/video/video_record_cv2.py b/arcade/future/video/video_record_cv2.py index 69309c988c..9dd7089f62 100644 --- a/arcade/future/video/video_record_cv2.py +++ b/arcade/future/video/video_record_cv2.py @@ -19,8 +19,6 @@ pip install opencv-python numpy """ -from __future__ import annotations - import cv2 # type: ignore import numpy # type: ignore import pyglet.gl as gl diff --git a/arcade/geometry.py b/arcade/geometry.py index 22b8e3dbaf..3a60abc779 100644 --- a/arcade/geometry.py +++ b/arcade/geometry.py @@ -6,8 +6,6 @@ Point in polygon function from https://www.geeksforgeeks.org/how-to-check-if-a-given-point-lies-inside-a-polygon/ """ -from __future__ import annotations - from sys import maxsize as sys_int_maxsize from arcade.types import Point2, Point2List diff --git a/arcade/gl/__init__.py b/arcade/gl/__init__.py index e669428fea..ad05c7c84a 100644 --- a/arcade/gl/__init__.py +++ b/arcade/gl/__init__.py @@ -15,8 +15,6 @@ and is only recommended for more advanced users """ -from __future__ import annotations - from .context import Context from .types import BufferDescription from .compute_shader import ComputeShader diff --git a/arcade/gl/enums.py b/arcade/gl/enums.py index 9d87a2cdcb..775fec45a0 100644 --- a/arcade/gl/enums.py +++ b/arcade/gl/enums.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pyglet import gl # Texture min/mag filters diff --git a/arcade/gl/exceptions.py b/arcade/gl/exceptions.py index c90ded6c35..0f6c849272 100644 --- a/arcade/gl/exceptions.py +++ b/arcade/gl/exceptions.py @@ -1,6 +1,3 @@ -from __future__ import annotations - - class ShaderException(Exception): """Exception class for shader-specific problems.""" diff --git a/arcade/gl/geometry.py b/arcade/gl/geometry.py index 697adf59a7..524d6930b1 100644 --- a/arcade/gl/geometry.py +++ b/arcade/gl/geometry.py @@ -2,8 +2,6 @@ A module providing commonly used geometry """ -from __future__ import annotations - import math from array import array diff --git a/arcade/gl/glsl.py b/arcade/gl/glsl.py index ad3bb54119..61580bb6a5 100644 --- a/arcade/gl/glsl.py +++ b/arcade/gl/glsl.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import re from typing import TYPE_CHECKING, Iterable diff --git a/arcade/gl/program.py b/arcade/gl/program.py index 27d05504cc..b75a91a790 100644 --- a/arcade/gl/program.py +++ b/arcade/gl/program.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import typing import weakref from ctypes import ( diff --git a/arcade/gl/types.py b/arcade/gl/types.py index 190ea147ad..1c7e4589fc 100644 --- a/arcade/gl/types.py +++ b/arcade/gl/types.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import re from typing import Iterable, Sequence, Union diff --git a/arcade/gl/uniform.py b/arcade/gl/uniform.py index 6ca4af7472..bf4584e920 100644 --- a/arcade/gl/uniform.py +++ b/arcade/gl/uniform.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import struct from ctypes import POINTER, c_double, c_float, c_int, c_uint, cast diff --git a/arcade/gl/utils.py b/arcade/gl/utils.py index 06a4a14663..cf3249cb3f 100644 --- a/arcade/gl/utils.py +++ b/arcade/gl/utils.py @@ -2,8 +2,6 @@ Various utility functions for the gl module. """ -from __future__ import annotations - from array import array from ctypes import c_byte from typing import Any diff --git a/arcade/gui/__init__.py b/arcade/gui/__init__.py index 231438c00f..693974f52f 100644 --- a/arcade/gui/__init__.py +++ b/arcade/gui/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from arcade.gui.constructs import UIMessageBox, UIButtonRow from arcade.gui.events import UIEvent from arcade.gui.events import UIKeyEvent diff --git a/arcade/gui/constructs.py b/arcade/gui/constructs.py index 4dcc24d5d3..de73d774f5 100644 --- a/arcade/gui/constructs.py +++ b/arcade/gui/constructs.py @@ -1,8 +1,6 @@ """Constructs, are prepared widget combinations, you can use for common use-cases""" -from __future__ import annotations - -from typing import Any, Optional +from typing import Any import arcade from arcade import uicolor @@ -140,8 +138,8 @@ def __init__( vertical: bool = False, align: str = "center", size_hint: Any = (0, 0), - size_hint_min: Optional[Any] = None, - size_hint_max: Optional[Any] = None, + size_hint_min: Any | None = None, + size_hint_max: Any | None = None, space_between: int = 10, button_factory: type = UIFlatButton, **kwargs, diff --git a/arcade/gui/events.py b/arcade/gui/events.py index 03bb97bbf8..a150d1ce5a 100644 --- a/arcade/gui/events.py +++ b/arcade/gui/events.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/arcade/gui/experimental/password_input.py b/arcade/gui/experimental/password_input.py index c0cbd3ecee..a9b9ecc01c 100644 --- a/arcade/gui/experimental/password_input.py +++ b/arcade/gui/experimental/password_input.py @@ -1,7 +1,3 @@ -from __future__ import annotations - -from typing import Optional - from arcade.gui import Surface, UIEvent, UIInputText, UITextInputEvent @@ -13,7 +9,7 @@ class UIPasswordInput(UIInputText): """ - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: """Remove new lines from the input, which are not allowed in passwords.""" if isinstance(event, UITextInputEvent): event.text = event.text.replace("\n", "").replace("\r", "") diff --git a/arcade/gui/experimental/restricted_input.py b/arcade/gui/experimental/restricted_input.py index 1d7a41494c..1d9e3ea5d0 100644 --- a/arcade/gui/experimental/restricted_input.py +++ b/arcade/gui/experimental/restricted_input.py @@ -3,8 +3,6 @@ If the implementation is successful, the feature will be merged into the existing UIInputText class. """ -from typing import Optional - from arcade.gui import UIEvent, UIInputText @@ -32,7 +30,7 @@ def text(self, text: str): # we can not call super().text = text here: https://bugs.python.org/issue14965 UIInputText.text.__set__(self, text) # type: ignore - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: # check if text changed during event handling, # if so we need to validate the new text old_text = self.text diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index 4722c01c96..cc1bc55352 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable, Optional +from typing import Iterable from pyglet.event import EVENT_UNHANDLED @@ -52,7 +52,7 @@ def __init__(self, scroll_area: UIScrollArea, vertical: bool = True): bind(scroll_area, "content_height", self.trigger_full_render) bind(scroll_area, "content_width", self.trigger_full_render) - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: # check if we are scrollable if not self._scrollable(): return EVENT_UNHANDLED @@ -341,7 +341,7 @@ def _get_scroll_offset(self): return self.scroll_x, -normal_pos_y - self.scroll_y - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: """Handle scrolling of the widget.""" if isinstance(event, UIMouseDragEvent) and not self.rect.point_in_rect(event.pos): return EVENT_UNHANDLED diff --git a/arcade/gui/experimental/typed_text_input.py b/arcade/gui/experimental/typed_text_input.py index 3d3c8d2b35..348f14b395 100644 --- a/arcade/gui/experimental/typed_text_input.py +++ b/arcade/gui/experimental/typed_text_input.py @@ -1,6 +1,4 @@ -from __future__ import annotations - -from typing import Callable, Generic, Optional, Type, TypeVar, cast +from typing import Callable, Generic, Type, TypeVar, cast import arcade from arcade.color import BLACK, RED, WHITE @@ -148,7 +146,7 @@ def _checked_parse(self, text: str): if self.emit_parse_exceptions: raise e - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: # print(f"In {type_name(event)}") if isinstance(event, UITextInputEvent) and self._active: text = event.text.replace("\r", "").replace("\r", "") diff --git a/arcade/gui/mixins.py b/arcade/gui/mixins.py index c7f27d9ed3..e902407a02 100644 --- a/arcade/gui/mixins.py +++ b/arcade/gui/mixins.py @@ -1,7 +1,3 @@ -from __future__ import annotations - -from typing import Optional - from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from typing_extensions import override @@ -39,7 +35,7 @@ def do_layout(self): self.rect = self.rect.align_top(rect.top).align_left(rect.left) @override - def on_event(self, event) -> Optional[bool]: + def on_event(self, event) -> bool | None: """Handle dragging of the widget.""" if isinstance(event, UIMousePressEvent): if event.button == arcade.MOUSE_BUTTON_LEFT and self.rect.point_in_rect(event.pos): @@ -69,7 +65,7 @@ class UIMouseFilterMixin(UIWidget): """ @override - def on_event(self, event) -> Optional[bool]: + def on_event(self, event) -> bool | None: """Catch all mouse events, that are inside this widget.""" if super().on_event(event): return EVENT_HANDLED diff --git a/arcade/gui/nine_patch.py b/arcade/gui/nine_patch.py index 881b783805..4481f95423 100644 --- a/arcade/gui/nine_patch.py +++ b/arcade/gui/nine_patch.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import arcade import arcade.gl as gl from arcade.texture_atlas.base import TextureAtlasBase diff --git a/arcade/gui/property.py b/arcade/gui/property.py index 03e54419a9..95e46ee244 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import sys import traceback from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, cast diff --git a/arcade/gui/style.py b/arcade/gui/style.py index 7f9304914b..674894193f 100644 --- a/arcade/gui/style.py +++ b/arcade/gui/style.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from abc import abstractmethod from dataclasses import dataclass from typing import Any, Generic, TypeVar, overload diff --git a/arcade/gui/surface.py b/arcade/gui/surface.py index ab1b5c958a..2376ec7bcf 100644 --- a/arcade/gui/surface.py +++ b/arcade/gui/surface.py @@ -1,7 +1,5 @@ -from __future__ import annotations - from contextlib import contextmanager -from typing import Generator, Optional +from typing import Generator from PIL import Image from typing_extensions import Self @@ -219,7 +217,7 @@ def limit(self, rect: Rect | None = None): def draw( self, - area: Optional[Rect] = None, + area: Rect | None = None, ) -> None: """Draws the contents of the surface. diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index a55d2013aa..ffa4c055b0 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -8,10 +8,8 @@ - TextArea with scroll support """ -from __future__ import annotations - from collections import defaultdict -from typing import Iterable, Optional, TypeVar, Union +from typing import Iterable, TypeVar, Union from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher from typing_extensions import TypeGuard @@ -95,7 +93,7 @@ def on_draw(): DEFAULT_LAYER = 0 OVERLAY_LAYER = 10 - def __init__(self, window: Optional[arcade.Window] = None): + def __init__(self, window: arcade.Window | None = None): super().__init__() self.window = window or arcade.get_window() @@ -147,7 +145,7 @@ def remove(self, child: UIWidget): self.trigger_render() def walk_widgets( - self, *, root: Optional[UIWidget] = None, layer=DEFAULT_LAYER + self, *, root: UIWidget | None = None, layer=DEFAULT_LAYER ) -> Iterable[UIWidget]: """Walks through widget tree, in reverse draw order (most top drawn widget first) diff --git a/arcade/gui/view.py b/arcade/gui/view.py index 110f62734d..885f4587a1 100644 --- a/arcade/gui/view.py +++ b/arcade/gui/view.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import TypeVar from arcade import View diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 26a56d4c42..d03b8382e1 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from abc import ABC from typing import Dict, Iterable, List, NamedTuple, Optional, TYPE_CHECKING, Tuple, TypeVar, Union @@ -95,7 +93,7 @@ def __init__( ): self._requires_render = True self.rect = LBWH(x, y, width, height) - self.parent: Optional[Union[UIManager, UIWidget]] = None + self.parent: UIManager | UIWidget | None = None # Size hints are properties that can be used by layouts self.size_hint = size_hint @@ -178,7 +176,7 @@ def on_update(self, dt): """Custom logic which will be triggered.""" pass - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: """Passes :class:`UIEvent` s through the widget tree.""" # UpdateEvents are past to the first invisible widget if isinstance(event, UIOnUpdateEvent): @@ -419,11 +417,11 @@ def with_border(self, *, width=2, color: Color | None = arcade.color.GRAY) -> Se def with_padding( self, *, - top: Optional[int] = None, - right: Optional[int] = None, - bottom: Optional[int] = None, - left: Optional[int] = None, - all: Optional[int] = None, + top: int | None = None, + right: int | None = None, + bottom: int | None = None, + left: int | None = None, + all: int | None = None, ) -> Self: """Changes the padding to the given values if set. Returns itself @@ -591,7 +589,7 @@ def __init__( bind(self, "hovered", self.trigger_render) bind(self, "disabled", self.trigger_render) - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: """Handles mouse events and triggers on_click event if the widget is clicked. This also sets the hovered and pressed state of the widget. @@ -721,7 +719,7 @@ def __init__( y=0, width=100, height=100, - sprite: Optional[Sprite] = None, + sprite: Sprite | None = None, size_hint=None, size_hint_min=None, size_hint_max=None, diff --git a/arcade/gui/widgets/buttons.py b/arcade/gui/widgets/buttons.py index c3588e4284..22ffa4db2f 100644 --- a/arcade/gui/widgets/buttons.py +++ b/arcade/gui/widgets/buttons.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from dataclasses import dataclass from typing import Optional, Union @@ -80,15 +78,15 @@ def __init__( *, x: float = 0, y: float = 0, - width: Optional[float] = None, - height: Optional[float] = None, + width: float | None = None, + height: float | None = None, texture: Union[None, Texture, NinePatchTexture] = None, texture_hovered: Union[None, Texture, NinePatchTexture] = None, texture_pressed: Union[None, Texture, NinePatchTexture] = None, texture_disabled: Union[None, Texture, NinePatchTexture] = None, text: str = "", multiline: bool = False, - scale: Optional[float] = None, + scale: float | None = None, style: Optional[dict[str, UIStyleBase]] = None, size_hint=None, size_hint_min=None, @@ -226,7 +224,7 @@ class UIFlatButtonStyle(UIStyleBase): font_name: FontNameOrNames = ("Kenney Future", "arial", "calibri") font_color: RGBA255 = color.WHITE bg: RGBA255 = uicolor.DARK_BLUE_MIDNIGHT_BLUE - border: Optional[RGBA255] = None + border: RGBA255 | None = None border_width: int = 0 diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index a162f5b66f..a7aa6968e9 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from copy import deepcopy from typing import Optional, Union @@ -111,7 +109,7 @@ def __init__( y: float = 0, width: float = 150, height: float = 30, - default: Optional[str] = None, + default: str | None = None, options: Optional[list[Union[str, None]]] = None, primary_style=None, dropdown_style=None, @@ -152,12 +150,12 @@ def __init__( self.register_event_type("on_change") @property - def value(self) -> Optional[str]: + def value(self) -> str | None: """Current selected option.""" return self._value @value.setter - def value(self, value: Optional[str]): + def value(self, value: str | None): """Change the current selected option to a new option.""" old_value = self._value self._value = value diff --git a/arcade/gui/widgets/image.py b/arcade/gui/widgets/image.py index c5e981f825..b4b4e90eab 100644 --- a/arcade/gui/widgets/image.py +++ b/arcade/gui/widgets/image.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import math from typing import Union diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index 61cdfcd3a1..a3d7205f32 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -2,7 +2,7 @@ import warnings from dataclasses import dataclass -from typing import Dict, Iterable, List, Optional, Tuple, TypeVar +from typing import Dict, Iterable, List, Tuple, TypeVar from typing_extensions import Literal, override @@ -103,9 +103,9 @@ def add( self, child: W, *, - anchor_x: Optional[str] = None, + anchor_x: str | None = None, align_x: float = 0, - anchor_y: Optional[str] = None, + anchor_y: str | None = None, align_y: float = 0, **kwargs, ) -> W: @@ -140,9 +140,9 @@ def add( def _place_child( self, child: UIWidget, - anchor_x: Optional[str] = None, + anchor_x: str | None = None, align_x: float = 0, - anchor_y: Optional[str] = None, + anchor_y: str | None = None, align_y: float = 0, ): anchor_x = anchor_x or self.default_anchor_x diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index d040885944..70878e913b 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -3,7 +3,7 @@ import warnings from abc import ABCMeta, abstractmethod from dataclasses import dataclass -from typing import Mapping, Optional, Union +from typing import Mapping, Union from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from typing_extensions import override @@ -165,7 +165,7 @@ def _render_thumb(self, surface: Surface): pass @override - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: """ Args: event: Event to handle. diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 16f8e932b5..cfa37a460a 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -1,7 +1,3 @@ -from __future__ import annotations - -from typing import Optional - import pyglet from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from pyglet.text.caret import Caret @@ -82,8 +78,8 @@ def __init__( *, x: float = 0, y: float = 0, - width: Optional[float] = None, - height: Optional[float] = None, + width: float | None = None, + height: float | None = None, font_name=("calibri", "arial"), font_size: float = 12, text_color: RGBOrA255 = arcade.color.WHITE, @@ -248,11 +244,11 @@ def _update_size_hint_min(self): def update_font( self, - font_name: Optional[FontNameOrNames] = None, - font_size: Optional[float] = None, - font_color: Optional[Color] = None, - bold: Optional[bool | str] = None, - italic: Optional[bool] = None, + font_name: FontNameOrNames | None = None, + font_size: float | None = None, + font_color: Color | None = None, + bold: bool | str | None = None, + italic: bool | None = None, ): """Update font of the label. @@ -347,9 +343,9 @@ def __init__(self, *, text: str, multiline: bool = False, **kwargs): def place_text( self, - anchor_x: Optional[str] = None, + anchor_x: str | None = None, align_x: float = 0, - anchor_y: Optional[str] = None, + anchor_y: str | None = None, align_y: float = 0, **kwargs, ) -> UILabel: @@ -524,7 +520,7 @@ def on_update(self, dt): self.trigger_full_render() @override - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: """Handle events for the text input field. Text input is only active when the user clicks on the input field.""" @@ -690,7 +686,7 @@ def __init__( italic=False, text_color: RGBA255 = arcade.color.WHITE, multiline: bool = True, - scroll_speed: Optional[float] = None, + scroll_speed: float | None = None, size_hint=None, size_hint_min=None, size_hint_max=None, @@ -781,7 +777,7 @@ def do_render(self, surface: Surface): self.layout.draw() @override - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: """Handle scrolling of the widget.""" if isinstance(event, UIMouseScrollEvent): if self.rect.point_in_rect(event.pos): diff --git a/arcade/gui/widgets/toggle.py b/arcade/gui/widgets/toggle.py index d854dce105..5e1786efe2 100644 --- a/arcade/gui/widgets/toggle.py +++ b/arcade/gui/widgets/toggle.py @@ -1,7 +1,3 @@ -from __future__ import annotations - -from typing import Optional - from PIL import ImageEnhance from typing_extensions import override @@ -42,8 +38,8 @@ def __init__( y: float = 0, width: float = 100, height: float = 50, - on_texture: Optional[Texture] = None, - off_texture: Optional[Texture] = None, + on_texture: Texture | None = None, + off_texture: Texture | None = None, value=False, size_hint=None, size_hint_min=None, diff --git a/arcade/hitbox/__init__.py b/arcade/hitbox/__init__.py index ab5d304dcf..becf40679d 100644 --- a/arcade/hitbox/__init__.py +++ b/arcade/hitbox/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from PIL.Image import Image from arcade.types import Point2List diff --git a/arcade/hitbox/bounding_box.py b/arcade/hitbox/bounding_box.py index 7f6d99d62e..1550df8379 100644 --- a/arcade/hitbox/bounding_box.py +++ b/arcade/hitbox/bounding_box.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from PIL.Image import Image from arcade.types import Point2List diff --git a/arcade/hitbox/pymunk.py b/arcade/hitbox/pymunk.py index 104c731d0f..91fd5056d6 100644 --- a/arcade/hitbox/pymunk.py +++ b/arcade/hitbox/pymunk.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pymunk from PIL.Image import Image from pymunk import Vec2d diff --git a/arcade/hitbox/simple.py b/arcade/hitbox/simple.py index c6b7b25d25..718cbaf893 100644 --- a/arcade/hitbox/simple.py +++ b/arcade/hitbox/simple.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from PIL.Image import Image from arcade.types import Point, Point2List diff --git a/arcade/isometric.py b/arcade/isometric.py index 097cb21772..e2166d1214 100644 --- a/arcade/isometric.py +++ b/arcade/isometric.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from arcade.shape_list import ShapeElementList, create_line from arcade.types import RGBA255 diff --git a/arcade/joysticks.py b/arcade/joysticks.py index 40279956b2..826d1690e0 100644 --- a/arcade/joysticks.py +++ b/arcade/joysticks.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pyglet.input from pyglet.input import Joystick diff --git a/arcade/key/__init__.py b/arcade/key/__init__.py index d0207f6e1f..fb1227c6ef 100644 --- a/arcade/key/__init__.py +++ b/arcade/key/__init__.py @@ -2,7 +2,6 @@ Constants used to signify what keys on the keyboard were pressed. """ -from __future__ import annotations from sys import platform # Key modifiers diff --git a/arcade/management/__init__.py b/arcade/management/__init__.py index 28b8310dd3..ec5c7522f0 100644 --- a/arcade/management/__init__.py +++ b/arcade/management/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path import shutil import sys diff --git a/arcade/math.py b/arcade/math.py index 86a55473d9..bf9d03de60 100644 --- a/arcade/math.py +++ b/arcade/math.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import math import random from typing import TypeVar diff --git a/arcade/particles/__init__.py b/arcade/particles/__init__.py index d1121372c5..609ab39535 100644 --- a/arcade/particles/__init__.py +++ b/arcade/particles/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from .particle import ( Particle, EternalParticle, diff --git a/arcade/particles/emitter_simple.py b/arcade/particles/emitter_simple.py index b69bbdc41b..e049932b23 100644 --- a/arcade/particles/emitter_simple.py +++ b/arcade/particles/emitter_simple.py @@ -5,8 +5,6 @@ to start using particle systems. """ -from __future__ import annotations - import random from typing import Sequence diff --git a/arcade/particles/particle.py b/arcade/particles/particle.py index b161103e5c..0b0f9ee20d 100644 --- a/arcade/particles/particle.py +++ b/arcade/particles/particle.py @@ -3,8 +3,6 @@ Often used in large quantity to produce visual effects effects """ -from __future__ import annotations - from typing import Literal from arcade.math import clamp, lerp diff --git a/arcade/paths.py b/arcade/paths.py index 439d0ff937..8c9e65c168 100644 --- a/arcade/paths.py +++ b/arcade/paths.py @@ -2,8 +2,6 @@ Classic A-star algorithm for path finding. """ -from __future__ import annotations - import math from arcade import Sprite, SpriteList, check_for_collision_with_list, get_sprites_at_point diff --git a/arcade/perf_graph.py b/arcade/perf_graph.py index 2b34781bc5..1809d31f8f 100644 --- a/arcade/perf_graph.py +++ b/arcade/perf_graph.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import random import pyglet.clock diff --git a/arcade/perf_info.py b/arcade/perf_info.py index efb1b67e32..2559a0547c 100644 --- a/arcade/perf_info.py +++ b/arcade/perf_info.py @@ -2,8 +2,6 @@ Utility functions to keep performance information """ -from __future__ import annotations - import time from collections import deque diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index 065ce8504f..ba2f69bd44 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -2,8 +2,6 @@ Physics engines for top-down or platformers. """ -from __future__ import annotations - import math from typing import Iterable diff --git a/arcade/pymunk_physics_engine.py b/arcade/pymunk_physics_engine.py index e1a9e856e8..8d47d7453a 100644 --- a/arcade/pymunk_physics_engine.py +++ b/arcade/pymunk_physics_engine.py @@ -2,8 +2,6 @@ Pymunk Physics Engine """ -from __future__ import annotations - import logging import math from typing import Callable diff --git a/arcade/resources/__init__.py b/arcade/resources/__init__.py index f471190530..4397312bc5 100644 --- a/arcade/resources/__init__.py +++ b/arcade/resources/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path from typing import Sequence from arcade.exceptions import warning, ReplacementWarning diff --git a/arcade/scene.py b/arcade/scene.py index f551523b82..d9063b4027 100644 --- a/arcade/scene.py +++ b/arcade/scene.py @@ -10,8 +10,6 @@ * Control sprite list draw order within the group """ -from __future__ import annotations - from typing import Iterable from warnings import warn diff --git a/arcade/screenshot.py b/arcade/screenshot.py index 2a330c5a42..28b7a1b0d5 100644 --- a/arcade/screenshot.py +++ b/arcade/screenshot.py @@ -4,8 +4,6 @@ These functions are flawed because they only read from the screen. """ -from __future__ import annotations - import PIL.Image import PIL.ImageOps diff --git a/arcade/shape_list.py b/arcade/shape_list.py index c1a191b30f..af01cde14b 100644 --- a/arcade/shape_list.py +++ b/arcade/shape_list.py @@ -6,8 +6,6 @@ the graphics card for much faster render times. """ -from __future__ import annotations - import itertools import math from array import array diff --git a/arcade/sound.py b/arcade/sound.py index 6530f4d781..292a6e08ba 100644 --- a/arcade/sound.py +++ b/arcade/sound.py @@ -1,7 +1,5 @@ """Sound Library.""" -from __future__ import annotations - import logging import math import os diff --git a/arcade/sprite/__init__.py b/arcade/sprite/__init__.py index 9a6ad5c9d3..724680a915 100644 --- a/arcade/sprite/__init__.py +++ b/arcade/sprite/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path import PIL.Image diff --git a/arcade/sprite/animated.py b/arcade/sprite/animated.py index fec99c65c4..0a88035f3f 100644 --- a/arcade/sprite/animated.py +++ b/arcade/sprite/animated.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import bisect import logging import math diff --git a/arcade/sprite/enums.py b/arcade/sprite/enums.py index 15c5bbb91d..3239c9af73 100644 --- a/arcade/sprite/enums.py +++ b/arcade/sprite/enums.py @@ -1,5 +1,3 @@ -from __future__ import annotations - FACE_RIGHT = 1 FACE_LEFT = 2 FACE_UP = 3 diff --git a/arcade/sprite/mixins.py b/arcade/sprite/mixins.py index a8189aff19..4c0ca18af6 100644 --- a/arcade/sprite/mixins.py +++ b/arcade/sprite/mixins.py @@ -1,6 +1,3 @@ -from __future__ import annotations - - class PyMunk: """Object used to hold pymunk info for a sprite.""" diff --git a/arcade/sprite/sprite.py b/arcade/sprite/sprite.py index ece5d3c3fe..761cf86672 100644 --- a/arcade/sprite/sprite.py +++ b/arcade/sprite/sprite.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import math from pathlib import Path from typing import TYPE_CHECKING, Any diff --git a/arcade/sprite_list/__init__.py b/arcade/sprite_list/__init__.py index fa5857699f..465fe90ddf 100644 --- a/arcade/sprite_list/__init__.py +++ b/arcade/sprite_list/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from .sprite_list import SpriteList from .spatial_hash import SpatialHash from .collision import ( diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 118e115775..f7955f0d3a 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import struct from typing import ( Iterable, diff --git a/arcade/sprite_list/spatial_hash.py b/arcade/sprite_list/spatial_hash.py index 80c717a76e..53ddb3ffdc 100644 --- a/arcade/sprite_list/spatial_hash.py +++ b/arcade/sprite_list/spatial_hash.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from math import trunc from typing import Generic diff --git a/arcade/text.py b/arcade/text.py index ae03a12170..9ab51b21df 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -2,8 +2,6 @@ Drawing text with pyglet label """ -from __future__ import annotations - from ctypes import c_int, c_ubyte from pathlib import Path from typing import Any, Union diff --git a/arcade/texture/__init__.py b/arcade/texture/__init__.py index 38c4afc1bd..a6a6949f63 100644 --- a/arcade/texture/__init__.py +++ b/arcade/texture/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from .texture import Texture, ImageData from .spritesheet import SpriteSheet from .loading import ( diff --git a/arcade/texture/generate.py b/arcade/texture/generate.py index af98ce7dc3..3d5b2b0ba7 100644 --- a/arcade/texture/generate.py +++ b/arcade/texture/generate.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import PIL.Image import PIL.ImageDraw import PIL.ImageOps diff --git a/arcade/texture/loading.py b/arcade/texture/loading.py index a100929077..6f9f91e128 100644 --- a/arcade/texture/loading.py +++ b/arcade/texture/loading.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path from PIL import Image diff --git a/arcade/texture/manager.py b/arcade/texture/manager.py index b72f76dc57..b45ef01282 100644 --- a/arcade/texture/manager.py +++ b/arcade/texture/manager.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path import PIL.Image diff --git a/arcade/texture/tools.py b/arcade/texture/tools.py index de16349982..8c55fcac58 100644 --- a/arcade/texture/tools.py +++ b/arcade/texture/tools.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from PIL import Image, ImageDraw import arcade diff --git a/arcade/texture/transforms.py b/arcade/texture/transforms.py index 1e8a7de19a..5c7fcf45e7 100644 --- a/arcade/texture/transforms.py +++ b/arcade/texture/transforms.py @@ -6,8 +6,6 @@ transform the texture coordinates and hit box points. """ -from __future__ import annotations - from enum import Enum from arcade.math import rotate_point diff --git a/arcade/texture_atlas/__init__.py b/arcade/texture_atlas/__init__.py index 452f41853d..89bfbf5204 100644 --- a/arcade/texture_atlas/__init__.py +++ b/arcade/texture_atlas/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from .atlas_default import ( DefaultTextureAtlas, AtlasRegion, diff --git a/arcade/texture_atlas/atlas_array.py b/arcade/texture_atlas/atlas_array.py index e9b23ebd6c..5f15eab310 100644 --- a/arcade/texture_atlas/atlas_array.py +++ b/arcade/texture_atlas/atlas_array.py @@ -2,8 +2,6 @@ THIS IS WORK IN PROGRESS. DO NOT USE. """ -from __future__ import annotations - from typing import ( TYPE_CHECKING, Tuple, diff --git a/arcade/texture_atlas/atlas_bindless.py b/arcade/texture_atlas/atlas_bindless.py index 0a3d4ce979..0db4933057 100644 --- a/arcade/texture_atlas/atlas_bindless.py +++ b/arcade/texture_atlas/atlas_bindless.py @@ -2,8 +2,6 @@ THIS IS WORK IN PROGRESS. DO NOT USE. """ -from __future__ import annotations - from typing import TYPE_CHECKING from .base import TextureAtlasBase diff --git a/arcade/texture_atlas/helpers.py b/arcade/texture_atlas/helpers.py deleted file mode 100644 index c6b0fb8dfc..0000000000 --- a/arcade/texture_atlas/helpers.py +++ /dev/null @@ -1,187 +0,0 @@ -# """ -# THIS IS AN EXPERIMENTAL MODULE WITH NO GUARANTEES OF STABILITY OR SUPPORT. -# """ -# from __future__ import annotations - -# import json -# from pathlib import Path -# from time import perf_counter -# from typing import Dict, Tuple, cast - -# import PIL.Image - -# import arcade -# from arcade import cache -# from arcade.texture import ImageData, Texture - -# from .atlas_2d import AtlasRegion, DefaultTextureAtlas - - -# class FakeImage: -# """A fake PIL image""" -# def __init__(self, size): -# self.size = size - -# @property -# def width(self): -# return self.size[0] - -# @property -# def height(self): -# return self.size[1] - - -# def _dump_region_info(region: AtlasRegion): -# return { -# "pos": [region.x, region.y], -# "size": [region.width, region.height], -# "uvs": region.texture_coordinates, -# } - - -# def save_atlas(atlas: DefaultTextureAtlas, directory: Path, name: str, resource_root: Path): -# """ -# Dump the atlas to a file. This includes the atlas image -# and metadata. - -# Args: -# atlas: The atlas to dump -# directory: The directory to dump the atlas to -# name: The name of the atlas -# """ -# # Dump the image -# atlas.save(directory / f"{name}.png", flip=False) - -# meta = { -# 'name': name, -# 'atlas_file': f"{name}.png", -# 'size': atlas.size, -# 'border': atlas.border, -# 'textures': [], -# 'images': [], -# } -# # Images -# images = [] -# for image in atlas._images: -# images.append({ -# "hash": image.hash, -# "region": _dump_region_info(atlas.get_image_region_info(image.hash)), -# }) -# meta['images'] = images - -# # Textures -# textures = [] -# for texture in atlas.textures: -# if texture.file_path is None: -# raise ValueError("Can't save a texture not loaded from a file") - -# textures.append({ -# "hash": texture.image_data.hash, -# "path": texture.file_path.relative_to(resource_root).as_posix(), -# "crop": texture.crop_values, -# "points": texture.hit_box_points, -# "region": _dump_region_info(atlas.get_texture_region_info(texture.atlas_name)), -# "vertex_order": texture._vertex_order, -# }) - -# meta['textures'] = textures - -# # Dump the metadata -# with open(directory / f"{name}.json", 'w') as fd: -# json.dump(meta, fd, indent=2) - - -# def load_atlas( -# meta_file: Path, -# resource_root: Path -# ) -> Tuple[TextureAtlas, Dict[str, float]]: -# """ -# Load a texture atlas from disk. -# """ -# ctx = arcade.get_window().ctx -# perf_data = {} - -# t = perf_counter() -# # Load metadata -# with open(meta_file, 'r') as fd: -# meta = json.load(fd) -# perf_data['load_meta'] = perf_counter() - t - -# t = perf_counter() -# atlas = DefaultTextureAtlas( -# meta['size'], -# border=meta["border"], -# auto_resize=False, -# ) -# perf_data['create_atlas'] = perf_counter() - t - -# # Inject the atlas image -# t = perf_counter() -# atlas._texture = ctx.load_texture(meta['atlas_file'], flip=False) -# atlas._fbo = ctx.framebuffer(color_attachments=[atlas._texture]) -# perf_data['load_texture'] = perf_counter() - t - -# # Recreate images -# t = perf_counter() -# image_map: Dict[str, ImageData] = {} -# for im in meta['images']: -# image_data = ImageData( -# cast(PIL.Image.Image, FakeImage(im['region']['size'])), -# im['hash'], -# ) -# atlas._images.add(image_data) -# image_map[image_data.hash] = image_data -# # cache.image_data_cache.put() -# region = AtlasRegion( -# atlas, -# im['region']['pos'][0], -# im['region']['pos'][1], -# im['region']['size'][0], -# im['region']['size'][1], -# tuple(im['region']['uvs']), # type: ignore -# ) -# atlas._image_regions[image_data.hash] = region -# # Get a slot for the image and write the uv data -# slot = atlas._image_uv_slots_free.popleft() -# atlas._image_uv_slots[image_data.hash] = slot -# for i in range(8): -# atlas._image_uv_data[slot * 8 + i] = region.texture_coordinates[i] - -# perf_data['create_images'] = perf_counter() - t - -# # Recreate textures -# t = perf_counter() -# for tex in meta['textures']: -# texture = Texture( -# image_map[tex['hash']], -# hit_box_points=tex['points'], -# ) -# texture._vertex_order = tuple(tex['vertex_order']) # type: ignore -# texture._update_cache_names() -# atlas._textures[texture.atlas_name] = texture -# # Cache the texture strongly so it doesn't get garbage collected -# cache.texture_cache.put(texture, file_path=resource_root / tex['hash']) -# texture.file_path = resource_root / tex['path'] -# texture.crop_values = tex['crop'] -# region = AtlasRegion( -# atlas, -# tex['region']['pos'][0], -# tex['region']['pos'][1], -# tex['region']['size'][0], -# tex['region']['size'][1], -# tuple(tex['region']['uvs']), # type: ignore -# ) -# atlas._texture_regions[texture.atlas_name] = region -# # Get a slot for the image and write the uv data -# slot = atlas._texture_uv_slots_free.popleft() -# atlas._texture_uv_slots[texture.atlas_name] = slot -# for i in range(8): -# atlas._texture_uv_data[slot * 8 + i] = region.texture_coordinates[i] - -# perf_data['create_textures'] = perf_counter() - t - -# # Write the uv data to vram -# atlas.use_uv_texture() - -# return atlas, perf_data -# return atlas, perf_data diff --git a/arcade/texture_atlas/ref_counters.py b/arcade/texture_atlas/ref_counters.py index f77e432456..6740f8b54f 100644 --- a/arcade/texture_atlas/ref_counters.py +++ b/arcade/texture_atlas/ref_counters.py @@ -8,8 +8,6 @@ simply a texture using the same image and the same vertex order. """ -from __future__ import annotations - from typing import TYPE_CHECKING, Dict if TYPE_CHECKING: diff --git a/arcade/tilemap/__init__.py b/arcade/tilemap/__init__.py index fe296379ea..cd9a07b6bf 100644 --- a/arcade/tilemap/__init__.py +++ b/arcade/tilemap/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from .tilemap import TileMap, load_tilemap __all__ = ["TileMap", "load_tilemap"] diff --git a/arcade/types/__init__.py b/arcade/types/__init__.py index e71eef2e5c..da08112cd0 100644 --- a/arcade/types/__init__.py +++ b/arcade/types/__init__.py @@ -20,8 +20,6 @@ they go in the :py:mod:`arcade.types.color` submodule. """ -from __future__ import annotations - # Don't lint import order since we have conditional compatibility shims # flake8: noqa: E402 import sys diff --git a/arcade/types/numbers.py b/arcade/types/numbers.py index 8a5fbc623f..6ece433e08 100644 --- a/arcade/types/numbers.py +++ b/arcade/types/numbers.py @@ -5,8 +5,6 @@ circular imports or partially initialized modules. """ -from __future__ import annotations - from typing import Union #: 1. Makes pyright happier while also telling readers diff --git a/arcade/types/vector_like.py b/arcade/types/vector_like.py index fe31da458a..782694468c 100644 --- a/arcade/types/vector_like.py +++ b/arcade/types/vector_like.py @@ -7,8 +7,6 @@ """ -from __future__ import annotations - from typing import Sequence, Union from pyglet.math import Vec2, Vec3 diff --git a/arcade/utils.py b/arcade/utils.py index 490be539fb..547820d47e 100644 --- a/arcade/utils.py +++ b/arcade/utils.py @@ -4,8 +4,6 @@ IMPORTANT: These should be standalone and not rely on any Arcade imports """ -from __future__ import annotations - import platform import sys from collections.abc import MutableSequence diff --git a/arcade/version.py b/arcade/version.py index 2105f1e27e..bcf99536fb 100644 --- a/arcade/version.py +++ b/arcade/version.py @@ -26,8 +26,6 @@ """ -from __future__ import annotations - import re import sys from pathlib import Path diff --git a/benchmarks/sprite/sprite_alt.py b/benchmarks/sprite/sprite_alt.py index 066a57d4f2..db09941b4e 100644 --- a/benchmarks/sprite/sprite_alt.py +++ b/benchmarks/sprite/sprite_alt.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import TYPE_CHECKING, Any, Iterable, TypeVar import arcade diff --git a/doc/conf.py b/doc/conf.py index 03f5320b39..82b8bd05d5 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,7 +1,5 @@ #!/usr/bin/env python """Sphinx configuration file""" -from __future__ import annotations - import os from functools import cache import logging diff --git a/doc/tutorials/card_game/index.rst b/doc/tutorials/card_game/index.rst index 5cc666cd4d..7c080d84fb 100644 --- a/doc/tutorials/card_game/index.rst +++ b/doc/tutorials/card_game/index.rst @@ -549,7 +549,7 @@ so we can go through it again. :caption: Flipping of Bottom Deck :linenos: :pyobject: MyGame.on_mouse_press - :emphasize-lines: 15-33, 56-71 + :emphasize-lines: 16-34, 59-74 Test ~~~~ diff --git a/doc/tutorials/card_game/solitaire_11.py b/doc/tutorials/card_game/solitaire_11.py index e7cdaa9d49..1322983419 100644 --- a/doc/tutorials/card_game/solitaire_11.py +++ b/doc/tutorials/card_game/solitaire_11.py @@ -1,9 +1,9 @@ """ Solitaire clone. """ -from typing import Optional import random + import arcade # Screen title and size @@ -71,10 +71,10 @@ class Card(arcade.Sprite): - """ Card sprite """ + """Card sprite""" def __init__(self, suit, value, scale=1): - """ Card constructor """ + """Card constructor""" # Attributes for suit and value self.suit = suit @@ -86,29 +86,29 @@ def __init__(self, suit, value, scale=1): super().__init__(FACE_DOWN_IMAGE, scale, hit_box_algorithm="None") def face_down(self): - """ Turn card face-down """ + """Turn card face-down""" self.texture = arcade.load_texture(FACE_DOWN_IMAGE) self.is_face_up = False def face_up(self): - """ Turn card face-up """ + """Turn card face-up""" self.texture = arcade.load_texture(self.image_file_name) self.is_face_up = True @property def is_face_down(self): - """ Is this card face down? """ + """Is this card face down?""" return not self.is_face_up class MyGame(arcade.Window): - """ Main application class. """ + """Main application class.""" def __init__(self): super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE) # Sprite list with all the cards, no matter what pile they are in. - self.card_list: Optional[arcade.SpriteList] = None + self.card_list: arcade.SpriteList | None = None self.background_color = arcade.color.AMAZON @@ -126,7 +126,7 @@ def __init__(self): self.piles = None def setup(self): - """ Set up the game here. Call this function to restart the game. """ + """Set up the game here. Call this function to restart the game.""" # List of cards we are dragging with the mouse self.held_cards = [] @@ -204,7 +204,7 @@ def setup(self): self.piles[i][-1].face_up() def on_draw(self): - """ Render the screen. """ + """Render the screen.""" # Clear the screen self.clear() @@ -215,27 +215,26 @@ def on_draw(self): self.card_list.draw() def pull_to_top(self, card: arcade.Sprite): - """ Pull card to top of rendering order (last to render, looks on-top) """ + """Pull card to top of rendering order (last to render, looks on-top)""" # Remove, and append to the end self.card_list.remove(card) self.card_list.append(card) def on_key_press(self, symbol: int, modifiers: int): - """ User presses key """ + """User presses key""" if symbol == arcade.key.R: # Restart self.setup() def on_mouse_press(self, x, y, button, key_modifiers): - """ Called when the user presses a mouse button. """ + """Called when the user presses a mouse button.""" # Get list of cards we've clicked on cards = arcade.get_sprites_at_point((x, y), self.card_list) # Have we clicked on a card? if len(cards) > 0: - # Might be a stack of cards, get the top one primary_card = cards[-1] assert isinstance(primary_card, Card) @@ -283,7 +282,6 @@ def on_mouse_press(self, x, y, button, key_modifiers): self.pull_to_top(card) else: - # Click on a mat instead of a card? mats = arcade.get_sprites_at_point((x, y), self.pile_mat_list) @@ -292,7 +290,10 @@ def on_mouse_press(self, x, y, button, key_modifiers): mat_index = self.pile_mat_list.index(mat) # Is it our turned over flip mat? and no cards on it? - if mat_index == BOTTOM_FACE_DOWN_PILE and len(self.piles[BOTTOM_FACE_DOWN_PILE]) == 0: + if ( + mat_index == BOTTOM_FACE_DOWN_PILE + and len(self.piles[BOTTOM_FACE_DOWN_PILE]) == 0 + ): # Flip the deck back over so we can restart temp_list = self.piles[BOTTOM_FACE_UP_PILE].copy() for card in reversed(temp_list): @@ -302,26 +303,25 @@ def on_mouse_press(self, x, y, button, key_modifiers): card.position = self.pile_mat_list[BOTTOM_FACE_DOWN_PILE].position def remove_card_from_pile(self, card): - """ Remove card from whatever pile it was in. """ + """Remove card from whatever pile it was in.""" for pile in self.piles: if card in pile: pile.remove(card) break def get_pile_for_card(self, card): - """ What pile is this card in? """ + """What pile is this card in?""" for index, pile in enumerate(self.piles): if card in pile: return index def move_card_to_new_pile(self, card, pile_index): - """ Move the card to a new pile """ + """Move the card to a new pile""" self.remove_card_from_pile(card) self.piles[pile_index].append(card) - def on_mouse_release(self, x: float, y: float, button: int, - modifiers: int): - """ Called when the user presses a mouse button. """ + def on_mouse_release(self, x: float, y: float, button: int, modifiers: int): + """Called when the user presses a mouse button.""" # If we don't have any cards, who cares if len(self.held_cards) == 0: @@ -333,7 +333,6 @@ def on_mouse_release(self, x: float, y: float, button: int, # See if we are in contact with the closest pile if arcade.check_for_collision(self.held_cards[0], pile): - # What pile is it? pile_index = self.pile_mat_list.index(pile) @@ -349,14 +348,18 @@ def on_mouse_release(self, x: float, y: float, button: int, # Move cards to proper position top_card = self.piles[pile_index][-1] for i, dropped_card in enumerate(self.held_cards): - dropped_card.position = top_card.center_x, \ - top_card.center_y - CARD_VERTICAL_OFFSET * (i + 1) + dropped_card.position = ( + top_card.center_x, + top_card.center_y - CARD_VERTICAL_OFFSET * (i + 1), + ) else: # Are there no cards in the middle play pile? for i, dropped_card in enumerate(self.held_cards): # Move cards to proper position - dropped_card.position = pile.center_x, \ - pile.center_y - CARD_VERTICAL_OFFSET * i + dropped_card.position = ( + pile.center_x, + pile.center_y - CARD_VERTICAL_OFFSET * i, + ) for card in self.held_cards: # Cards are in the right position, but we need to move them to the right list @@ -385,7 +388,7 @@ def on_mouse_release(self, x: float, y: float, button: int, self.held_cards = [] def on_mouse_motion(self, x: float, y: float, dx: float, dy: float): - """ User moves mouse """ + """User moves mouse""" # If we are holding cards, move them with the mouse for card in self.held_cards: @@ -394,7 +397,7 @@ def on_mouse_motion(self, x: float, y: float, dx: float, dy: float): def main(): - """ Main function """ + """Main function""" window = MyGame() window.setup() arcade.run() diff --git a/doc/tutorials/pymunk_platformer/index.rst b/doc/tutorials/pymunk_platformer/index.rst index 7cf41c8d5a..4255867afe 100644 --- a/doc/tutorials/pymunk_platformer/index.rst +++ b/doc/tutorials/pymunk_platformer/index.rst @@ -69,7 +69,7 @@ When you run this program, the screen should be larger. :caption: Adding some constants :linenos: :lines: 1-29 - :emphasize-lines: 4-26 + :emphasize-lines: 6-24 * :ref:`pymunk_demo_platformer_02` * :ref:`pymunk_demo_platformer_02_diff` @@ -223,8 +223,8 @@ We'll apply a different force later, if the player happens to be airborne. .. literalinclude:: pymunk_demo_platformer_06.py :caption: Add Player Movement - Constants and Attributes :linenos: - :lines: 48-71 - :emphasize-lines: 1-2, 22-24 + :lines: 48-75 + :emphasize-lines: 1-2, 23-25 We need to track if the left/right keys are held down. To do this we define instance variables ``left_pressed`` and ``right_pressed``. These are set to @@ -234,7 +234,7 @@ appropriate values in the key press and release handlers. :caption: Handle Key Up and Down Events :linenos: :lines: 159-173 - :emphasize-lines: 4-7, 12-15 + :emphasize-lines: 2-5, 10-13 Finally, we need to apply the correct force in ``on_update``. Force is specified in a tuple with horizontal force first, and vertical force second. @@ -245,7 +245,7 @@ We also set the friction when we are moving to zero, and when we are not moving .. literalinclude:: pymunk_demo_platformer_06.py :caption: Apply Force to Move Player :linenos: - :lines: 175-196 + :lines: 181-199 :emphasize-lines: 4-19 * :ref:`pymunk_demo_platformer_06` diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py index f316acd685..322beaf907 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer.py @@ -3,12 +3,12 @@ Platformer """ +import logging import math + import arcade -from typing import Optional from arcade.pymunk_physics_engine import PymunkPhysicsEngine -import logging LOG = logging.getLogger(__name__) SCREEN_TITLE = "PyMunk Top-Down" @@ -167,13 +167,13 @@ def __init__(self, width, height, title): super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[arcade.Sprite] = None + self.player_sprite: arcade.Sprite|None= None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList|None= None + self.wall_list: arcade.SpriteList|None= None + self.bullet_list: arcade.SpriteList|None= None + self.item_list: arcade.SpriteList|None= None # Track the current state of what key is pressed self.left_pressed: bool = False @@ -182,7 +182,7 @@ def __init__(self, width, height, title): self.down_pressed: bool = False # The PyMunk physics engine! - self.physics_engine: Optional[PymunkPhysicsEngine] = None + self.physics_engine: PymunkPhysicsEngine|None= None # Set background color self.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_02.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_02.py index c301cb68c1..2f4d96fcd5 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_02.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_02.py @@ -1,8 +1,6 @@ """ Example of Pymunk Physics Engine Platformer """ -import math -from typing import Optional import arcade SCREEN_TITLE = "PyMunk Platformer" diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_03.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_03.py index 5a5e5e0537..fedf84a346 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_03.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_03.py @@ -1,6 +1,7 @@ """ Example of Pymunk Physics Engine Platformer """ + import math from typing import Optional import arcade @@ -27,22 +28,22 @@ class GameWindow(arcade.Window): - """ Main Window """ + """Main Window""" def __init__(self, width, height, title): - """ Create the variables """ + """Create the variables""" # Init the parent class super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[arcade.Sprite] = None + self.player_sprite: arcade.Sprite | None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList | None = None + self.wall_list: arcade.SpriteList | None = None + self.bullet_list: arcade.SpriteList | None = None + self.item_list: arcade.SpriteList | None = None # Track the current state of what key is pressed self.left_pressed: bool = False @@ -52,28 +53,28 @@ def __init__(self, width, height, title): self.background_color = arcade.color.AMAZON def setup(self): - """ Set up everything with the game """ + """Set up everything with the game""" pass def on_key_press(self, key, modifiers): - """Called whenever a key is pressed. """ + """Called whenever a key is pressed.""" pass def on_key_release(self, key, modifiers): - """Called when the user releases a key. """ + """Called when the user releases a key.""" pass def on_update(self, delta_time): - """ Movement and game logic """ + """Movement and game logic""" pass def on_draw(self): - """ Draw everything """ + """Draw everything""" self.clear() def main(): - """ Main function """ + """Main function""" window = GameWindow(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE) window.setup() arcade.run() diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_04.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_04.py index 803fd6fd5e..f5563d35a0 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_04.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_04.py @@ -1,6 +1,7 @@ """ Example of Pymunk Physics Engine Platformer """ + import math from typing import Optional import arcade @@ -27,22 +28,22 @@ class GameWindow(arcade.Window): - """ Main Window """ + """Main Window""" def __init__(self, width, height, title): - """ Create the variables """ + """Create the variables""" # Init the parent class super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[arcade.Sprite] = None + self.player_sprite: arcade.Sprite | None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList | None = None + self.wall_list: arcade.SpriteList | None = None + self.bullet_list: arcade.SpriteList | None = None + self.item_list: arcade.SpriteList | None = None # Track the current state of what key is pressed self.left_pressed: bool = False @@ -52,7 +53,7 @@ def __init__(self, width, height, title): self.background_color = arcade.color.AMAZON def setup(self): - """ Set up everything with the game """ + """Set up everything with the game""" # Create the sprite lists self.player_list = arcade.SpriteList() @@ -69,8 +70,10 @@ def setup(self): self.item_list = tile_map.sprite_lists["Dynamic Items"] # Create player sprite - self.player_sprite = arcade.Sprite(":resources:images/animated_characters/female_person/femalePerson_idle.png", - SPRITE_SCALING_PLAYER) + self.player_sprite = arcade.Sprite( + ":resources:images/animated_characters/female_person/femalePerson_idle.png", + SPRITE_SCALING_PLAYER, + ) # Set player location grid_x = 1 grid_y = 1 @@ -80,19 +83,19 @@ def setup(self): self.player_list.append(self.player_sprite) def on_key_press(self, key, modifiers): - """Called whenever a key is pressed. """ + """Called whenever a key is pressed.""" pass def on_key_release(self, key, modifiers): - """Called when the user releases a key. """ + """Called when the user releases a key.""" pass def on_update(self, delta_time): - """ Movement and game logic """ + """Movement and game logic""" pass def on_draw(self): - """ Draw everything """ + """Draw everything""" self.clear() self.wall_list.draw() self.bullet_list.draw() @@ -101,7 +104,7 @@ def on_draw(self): def main(): - """ Main function """ + """Main function""" window = GameWindow(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE) window.setup() arcade.run() diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_05.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_05.py index d89341a725..2f49f98cdb 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_05.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_05.py @@ -1,6 +1,7 @@ """ Example of Pymunk Physics Engine Platformer """ + from typing import Optional import arcade @@ -47,35 +48,35 @@ class GameWindow(arcade.Window): - """ Main Window """ + """Main Window""" def __init__(self, width, height, title): - """ Create the variables """ + """Create the variables""" # Init the parent class super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[arcade.Sprite] = None + self.player_sprite: arcade.Sprite | None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList | None = None + self.wall_list: arcade.SpriteList | None = None + self.bullet_list: arcade.SpriteList | None = None + self.item_list: arcade.SpriteList | None = None # Track the current state of what key is pressed self.left_pressed: bool = False self.right_pressed: bool = False # Physics engine - self.physics_engine = Optional[arcade.PymunkPhysicsEngine] + self.physics_engine: arcade.PymunkPhysicsEngine | None = None # Set background color self.background_color = arcade.color.AMAZON def setup(self): - """ Set up everything with the game """ + """Set up everything with the game""" # Create the sprite lists self.player_list = arcade.SpriteList() @@ -92,8 +93,10 @@ def setup(self): self.item_list = tile_map.sprite_lists["Dynamic Items"] # Create player sprite - self.player_sprite = arcade.Sprite(":resources:images/animated_characters/female_person/femalePerson_idle.png", - SPRITE_SCALING_PLAYER) + self.player_sprite = arcade.Sprite( + ":resources:images/animated_characters/female_person/femalePerson_idle.png", + SPRITE_SCALING_PLAYER, + ) # Set player location grid_x = 1 grid_y = 1 @@ -116,8 +119,7 @@ def setup(self): gravity = (0, -GRAVITY) # Create the physics engine - self.physics_engine = arcade.PymunkPhysicsEngine(damping=damping, - gravity=gravity) + self.physics_engine = arcade.PymunkPhysicsEngine(damping=damping, gravity=gravity) # Add the player. # For the player, we set the damping to a lower value, which increases @@ -129,13 +131,15 @@ def setup(self): # Friction is between two objects in contact. It is important to remember # in top-down games that friction moving along the 'floor' is controlled # by damping. - self.physics_engine.add_sprite(self.player_sprite, - friction=PLAYER_FRICTION, - mass=PLAYER_MASS, - moment_of_inertia=arcade.PymunkPhysicsEngine.MOMENT_INF, - collision_type="player", - max_horizontal_velocity=PLAYER_MAX_HORIZONTAL_SPEED, - max_vertical_velocity=PLAYER_MAX_VERTICAL_SPEED) + self.physics_engine.add_sprite( + self.player_sprite, + friction=PLAYER_FRICTION, + mass=PLAYER_MASS, + moment_of_inertia=arcade.PymunkPhysicsEngine.MOMENT_INF, + collision_type="player", + max_horizontal_velocity=PLAYER_MAX_HORIZONTAL_SPEED, + max_vertical_velocity=PLAYER_MAX_VERTICAL_SPEED, + ) # Create the walls. # By setting the body type to PymunkPhysicsEngine.STATIC the walls can't @@ -144,38 +148,41 @@ def setup(self): # PymunkPhysicsEngine.KINEMATIC objects will move, but are assumed to be # repositioned by code and don't respond to physics forces. # Dynamic is default. - self.physics_engine.add_sprite_list(self.wall_list, - friction=WALL_FRICTION, - collision_type="wall", - body_type=arcade.PymunkPhysicsEngine.STATIC) + self.physics_engine.add_sprite_list( + self.wall_list, + friction=WALL_FRICTION, + collision_type="wall", + body_type=arcade.PymunkPhysicsEngine.STATIC, + ) # Create the items - self.physics_engine.add_sprite_list(self.item_list, - friction=DYNAMIC_ITEM_FRICTION, - collision_type="item") + self.physics_engine.add_sprite_list( + self.item_list, friction=DYNAMIC_ITEM_FRICTION, collision_type="item" + ) def on_key_press(self, key, modifiers): - """Called whenever a key is pressed. """ + """Called whenever a key is pressed.""" pass def on_key_release(self, key, modifiers): - """Called when the user releases a key. """ + """Called when the user releases a key.""" pass def on_update(self, delta_time): - """ Movement and game logic """ + """Movement and game logic""" self.physics_engine.step() def on_draw(self): - """ Draw everything """ + """Draw everything""" self.clear() self.wall_list.draw() self.bullet_list.draw() self.item_list.draw() self.player_list.draw() + def main(): - """ Main function """ + """Main function""" window = GameWindow(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE) window.setup() arcade.run() diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_06.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_06.py index 29847d9611..267adb3edf 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_06.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_06.py @@ -1,7 +1,7 @@ """ Example of Pymunk Physics Engine Platformer """ -from typing import Optional + import arcade SCREEN_TITLE = "PyMunk Platformer" @@ -48,36 +48,37 @@ # Force applied while on the ground PLAYER_MOVE_FORCE_ON_GROUND = 8000 + class GameWindow(arcade.Window): - """ Main Window """ + """Main Window""" def __init__(self, width, height, title): - """ Create the variables """ + """Create the variables""" # Init the parent class super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[arcade.Sprite] = None + self.player_sprite: arcade.Sprite | None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList | None = None + self.wall_list: arcade.SpriteList | None = None + self.bullet_list: arcade.SpriteList | None = None + self.item_list: arcade.SpriteList | None = None # Track the current state of what key is pressed self.left_pressed: bool = False self.right_pressed: bool = False # Physics engine - self.physics_engine = Optional[arcade.PymunkPhysicsEngine] + self.physics_engine: arcade.PymunkPhysicsEngine | None = None # Set background color self.background_color = arcade.color.AMAZON def setup(self): - """ Set up everything with the game """ + """Set up everything with the game""" # Create the sprite lists self.player_list = arcade.SpriteList() @@ -94,8 +95,10 @@ def setup(self): self.item_list = tile_map.sprite_lists["Dynamic Items"] # Create player sprite - self.player_sprite = arcade.Sprite(":resources:images/animated_characters/female_person/femalePerson_idle.png", - SPRITE_SCALING_PLAYER) + self.player_sprite = arcade.Sprite( + ":resources:images/animated_characters/female_person/femalePerson_idle.png", + SPRITE_SCALING_PLAYER, + ) # Set player location grid_x = 1 grid_y = 1 @@ -118,8 +121,7 @@ def setup(self): gravity = (0, -GRAVITY) # Create the physics engine - self.physics_engine = arcade.PymunkPhysicsEngine(damping=damping, - gravity=gravity) + self.physics_engine = arcade.PymunkPhysicsEngine(damping=damping, gravity=gravity) # Add the player. # For the player, we set the damping to a lower value, which increases @@ -131,13 +133,15 @@ def setup(self): # Friction is between two objects in contact. It is important to remember # in top-down games that friction moving along the 'floor' is controlled # by damping. - self.physics_engine.add_sprite(self.player_sprite, - friction=PLAYER_FRICTION, - mass=PLAYER_MASS, - moment_of_inertia=arcade.PymunkPhysicsEngine.MOMENT_INF, - collision_type="player", - max_horizontal_velocity=PLAYER_MAX_HORIZONTAL_SPEED, - max_vertical_velocity=PLAYER_MAX_VERTICAL_SPEED) + self.physics_engine.add_sprite( + self.player_sprite, + friction=PLAYER_FRICTION, + mass=PLAYER_MASS, + moment_of_inertia=arcade.PymunkPhysicsEngine.MOMENT_INF, + collision_type="player", + max_horizontal_velocity=PLAYER_MAX_HORIZONTAL_SPEED, + max_vertical_velocity=PLAYER_MAX_VERTICAL_SPEED, + ) # Create the walls. # By setting the body type to PymunkPhysicsEngine.STATIC the walls can't @@ -146,18 +150,20 @@ def setup(self): # PymunkPhysicsEngine.KINEMATIC objects will move, but are assumed to be # repositioned by code and don't respond to physics forces. # Dynamic is default. - self.physics_engine.add_sprite_list(self.wall_list, - friction=WALL_FRICTION, - collision_type="wall", - body_type=arcade.PymunkPhysicsEngine.STATIC) + self.physics_engine.add_sprite_list( + self.wall_list, + friction=WALL_FRICTION, + collision_type="wall", + body_type=arcade.PymunkPhysicsEngine.STATIC, + ) # Create the items - self.physics_engine.add_sprite_list(self.item_list, - friction=DYNAMIC_ITEM_FRICTION, - collision_type="item") + self.physics_engine.add_sprite_list( + self.item_list, friction=DYNAMIC_ITEM_FRICTION, collision_type="item" + ) def on_key_press(self, key, modifiers): - """Called whenever a key is pressed. """ + """Called whenever a key is pressed.""" if key == arcade.key.LEFT: self.left_pressed = True @@ -165,7 +171,7 @@ def on_key_press(self, key, modifiers): self.right_pressed = True def on_key_release(self, key, modifiers): - """Called when the user releases a key. """ + """Called when the user releases a key.""" if key == arcade.key.LEFT: self.left_pressed = False @@ -173,7 +179,7 @@ def on_key_release(self, key, modifiers): self.right_pressed = False def on_update(self, delta_time): - """ Movement and game logic """ + """Movement and game logic""" # Update player forces based on keys pressed if self.left_pressed and not self.right_pressed: @@ -196,15 +202,16 @@ def on_update(self, delta_time): self.physics_engine.step() def on_draw(self): - """ Draw everything """ + """Draw everything""" self.clear() self.wall_list.draw() self.bullet_list.draw() self.item_list.draw() self.player_list.draw() + def main(): - """ Main function """ + """Main function""" window = GameWindow(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE) window.setup() arcade.run() diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_07.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_07.py index acf86426d8..eb2f29dc06 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_07.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_07.py @@ -65,20 +65,20 @@ def __init__(self, width, height, title): super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[arcade.Sprite] = None + self.player_sprite: arcade.Sprite|None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList|None = None + self.wall_list: arcade.SpriteList|None = None + self.bullet_list: arcade.SpriteList|None = None + self.item_list: arcade.SpriteList|None = None # Track the current state of what key is pressed self.left_pressed: bool = False self.right_pressed: bool = False # Physics engine - self.physics_engine = Optional[arcade.PymunkPhysicsEngine] + self.physics_engine: arcade.PymunkPhysicsEngine | None = None # Set background color self.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py index 7c871bcc5a..b2c28e1cb8 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_08.py @@ -1,7 +1,7 @@ """ Example of Pymunk Physics Engine Platformer """ -from typing import Optional + import arcade SCREEN_TITLE = "PyMunk Platformer" @@ -66,9 +66,10 @@ class PlayerSprite(arcade.Sprite): - """ Player Sprite """ + """Player Sprite""" + def __init__(self): - """ Init """ + """Init""" # Let parent initialize super().__init__(scale=SPRITE_SCALING_PLAYER) @@ -83,7 +84,7 @@ def __init__(self): # Load textures for idle, jump, and fall states idle_texture = arcade.load_texture(f"{main_path}_idle.png") jump_texture = arcade.load_texture(f"{main_path}_jump.png") - fall_texture = arcade.load_texture(f"{main_path}_fall.png") + fall_texture = arcade.load_texture(f"{main_path}_fall.png") # Make pairs of textures facing left and right self.idle_texture_pair = idle_texture, idle_texture.flip_left_right() self.jump_texture_pair = jump_texture, jump_texture.flip_left_right() @@ -108,7 +109,7 @@ def __init__(self): self.x_odometer = 0 def pymunk_moved(self, physics_engine, dx, dy, d_angle): - """ Handle being moved by the pymunk engine """ + """Handle being moved by the pymunk engine""" # Figure out if we need to face left or right if dx < -DEAD_ZONE and self.character_face_direction == RIGHT_FACING: self.character_face_direction = LEFT_FACING @@ -137,7 +138,6 @@ def pymunk_moved(self, physics_engine, dx, dy, d_angle): # Have we moved far enough to change the texture? if abs(self.x_odometer) > DISTANCE_TO_CHANGE_TEXTURE: - # Reset the odometer self.x_odometer = 0 @@ -149,35 +149,35 @@ def pymunk_moved(self, physics_engine, dx, dy, d_angle): class GameWindow(arcade.Window): - """ Main Window """ + """Main Window""" def __init__(self, width, height, title): - """ Create the variables """ + """Create the variables""" # Init the parent class super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[PlayerSprite] = None + self.player_sprite: PlayerSprite | None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList | None = None + self.wall_list: arcade.SpriteList | None = None + self.bullet_list: arcade.SpriteList | None = None + self.item_list: arcade.SpriteList | None = None # Track the current state of what key is pressed self.left_pressed: bool = False self.right_pressed: bool = False # Physics engine - self.physics_engine = Optional[arcade.PymunkPhysicsEngine] + self.physics_engine: arcade.PymunkPhysicsEngine | None = None # Set background color self.background_color = arcade.color.AMAZON def setup(self): - """ Set up everything with the game """ + """Set up everything with the game""" # Create the sprite lists self.player_list = arcade.SpriteList() @@ -218,8 +218,7 @@ def setup(self): gravity = (0, -GRAVITY) # Create the physics engine - self.physics_engine = arcade.PymunkPhysicsEngine(damping=damping, - gravity=gravity) + self.physics_engine = arcade.PymunkPhysicsEngine(damping=damping, gravity=gravity) # Add the player. # For the player, we set the damping to a lower value, which increases @@ -231,13 +230,15 @@ def setup(self): # Friction is between two objects in contact. It is important to remember # in top-down games that friction moving along the 'floor' is controlled # by damping. - self.physics_engine.add_sprite(self.player_sprite, - friction=PLAYER_FRICTION, - mass=PLAYER_MASS, - moment_of_inertia=arcade.PymunkPhysicsEngine.MOMENT_INF, - collision_type="player", - max_horizontal_velocity=PLAYER_MAX_HORIZONTAL_SPEED, - max_vertical_velocity=PLAYER_MAX_VERTICAL_SPEED) + self.physics_engine.add_sprite( + self.player_sprite, + friction=PLAYER_FRICTION, + mass=PLAYER_MASS, + moment_of_inertia=arcade.PymunkPhysicsEngine.MOMENT_INF, + collision_type="player", + max_horizontal_velocity=PLAYER_MAX_HORIZONTAL_SPEED, + max_vertical_velocity=PLAYER_MAX_VERTICAL_SPEED, + ) # Create the walls. # By setting the body type to PymunkPhysicsEngine.STATIC the walls can't @@ -246,18 +247,20 @@ def setup(self): # PymunkPhysicsEngine.KINEMATIC objects will move, but are assumed to be # repositioned by code and don't respond to physics forces. # Dynamic is default. - self.physics_engine.add_sprite_list(self.wall_list, - friction=WALL_FRICTION, - collision_type="wall", - body_type=arcade.PymunkPhysicsEngine.STATIC) + self.physics_engine.add_sprite_list( + self.wall_list, + friction=WALL_FRICTION, + collision_type="wall", + body_type=arcade.PymunkPhysicsEngine.STATIC, + ) # Create the items - self.physics_engine.add_sprite_list(self.item_list, - friction=DYNAMIC_ITEM_FRICTION, - collision_type="item") + self.physics_engine.add_sprite_list( + self.item_list, friction=DYNAMIC_ITEM_FRICTION, collision_type="item" + ) def on_key_press(self, key, modifiers): - """Called whenever a key is pressed. """ + """Called whenever a key is pressed.""" if key == arcade.key.LEFT: self.left_pressed = True @@ -271,7 +274,7 @@ def on_key_press(self, key, modifiers): self.physics_engine.apply_impulse(self.player_sprite, impulse) def on_key_release(self, key, modifiers): - """Called when the user releases a key. """ + """Called when the user releases a key.""" if key == arcade.key.LEFT: self.left_pressed = False @@ -279,7 +282,7 @@ def on_key_release(self, key, modifiers): self.right_pressed = False def on_update(self, delta_time): - """ Movement and game logic """ + """Movement and game logic""" is_on_ground = self.physics_engine.is_on_ground(self.player_sprite) # Update player forces based on keys pressed @@ -309,15 +312,16 @@ def on_update(self, delta_time): self.physics_engine.step() def on_draw(self): - """ Draw everything """ + """Draw everything""" self.clear() self.wall_list.draw() self.bullet_list.draw() self.item_list.draw() self.player_list.draw() + def main(): - """ Main function """ + """Main function""" window = GameWindow(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE) window.setup() arcade.run() diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py index 5c410f02d2..c344ddbae1 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_09.py @@ -3,6 +3,7 @@ """ import math from typing import Optional + import arcade SCREEN_TITLE = "PyMunk Platformer" @@ -168,20 +169,20 @@ def __init__(self, width, height, title): super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[PlayerSprite] = None + self.player_sprite: PlayerSprite|None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList|None = None + self.wall_list: arcade.SpriteList|None = None + self.bullet_list: arcade.SpriteList|None = None + self.item_list: arcade.SpriteList|None = None # Track the current state of what key is pressed self.left_pressed: bool = False self.right_pressed: bool = False # Physics engine - self.physics_engine = Optional[arcade.PymunkPhysicsEngine] + self.physics_engine: arcade.PymunkPhysicsEngine | None = None # Set background color self.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py index a1ec9f0fe7..b3381953c2 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_10.py @@ -177,20 +177,20 @@ def __init__(self, width, height, title): super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[PlayerSprite] = None + self.player_sprite: PlayerSprite|None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList|None = None + self.wall_list: arcade.SpriteList|None = None + self.bullet_list: arcade.SpriteList|None = None + self.item_list: arcade.SpriteList|None = None # Track the current state of what key is pressed self.left_pressed: bool = False self.right_pressed: bool = False # Physics engine - self.physics_engine = Optional[arcade.PymunkPhysicsEngine] + self.physics_engine: arcade.PymunkPhysicsEngine | None = None # Set background color self.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py index cd76c04232..0a340a9313 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_11.py @@ -3,6 +3,7 @@ """ import math from typing import Optional + import arcade SCREEN_TITLE = "PyMunk Platformer" @@ -177,21 +178,21 @@ def __init__(self, width, height, title): super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[PlayerSprite] = None + self.player_sprite: PlayerSprite|None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None - self.moving_sprites_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList|None = None + self.wall_list: arcade.SpriteList|None = None + self.bullet_list: arcade.SpriteList|None = None + self.item_list: arcade.SpriteList|None = None + self.moving_sprites_list: arcade.SpriteList|None = None # Track the current state of what key is pressed self.left_pressed: bool = False self.right_pressed: bool = False # Physics engine - self.physics_engine = Optional[arcade.PymunkPhysicsEngine] + self.physics_engine: arcade.PymunkPhysicsEngine | None = None # Set background color self.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py index b1d20eadb7..45661a82b1 100644 --- a/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py +++ b/doc/tutorials/pymunk_platformer/pymunk_demo_platformer_12.py @@ -1,6 +1,7 @@ """ Example of Pymunk Physics Engine Platformer """ + import math from typing import Optional import arcade @@ -76,11 +77,12 @@ class PlayerSprite(arcade.Sprite): - """ Player Sprite """ - def __init__(self, - ladder_list: arcade.SpriteList, - hit_box_algorithm: arcade.hitbox.HitBoxAlgorithm): - """ Init """ + """Player Sprite""" + + def __init__( + self, ladder_list: arcade.SpriteList, hit_box_algorithm: arcade.hitbox.HitBoxAlgorithm + ): + """Init""" # Let parent initialize super().__init__(scale=SPRITE_SCALING_PLAYER) @@ -92,7 +94,9 @@ def __init__(self, # main_path = ":resources:images/animated_characters/zombie/zombie" # main_path = ":resources:images/animated_characters/robot/robot" - idle_texture = arcade.load_texture(f"{main_path}_idle.png", hit_box_algorithm=hit_box_algorithm) + idle_texture = arcade.load_texture( + f"{main_path}_idle.png", hit_box_algorithm=hit_box_algorithm + ) jump_texture = arcade.load_texture(f"{main_path}_jump.png") fall_texture = arcade.load_texture(f"{main_path}_fall.png") @@ -131,7 +135,7 @@ def __init__(self, self.is_on_ladder = False def pymunk_moved(self, physics_engine, dx, dy, d_angle): - """ Handle being moved by the pymunk engine """ + """Handle being moved by the pymunk engine""" # Figure out if we need to face left or right if dx < -DEAD_ZONE and self.character_face_direction == RIGHT_FACING: self.character_face_direction = LEFT_FACING @@ -162,7 +166,6 @@ def pymunk_moved(self, physics_engine, dx, dy, d_angle): if self.is_on_ladder and not is_on_ground: # Have we moved far enough to change the texture? if abs(self.y_odometer) > DISTANCE_TO_CHANGE_TEXTURE: - # Reset the odometer self.y_odometer = 0 @@ -190,7 +193,6 @@ def pymunk_moved(self, physics_engine, dx, dy, d_angle): # Have we moved far enough to change the texture? if abs(self.x_odometer) > DISTANCE_TO_CHANGE_TEXTURE: - # Reset the odometer self.x_odometer = 0 @@ -200,33 +202,36 @@ def pymunk_moved(self, physics_engine, dx, dy, d_angle): self.cur_texture = 0 self.texture = self.walk_textures[self.cur_texture][self.character_face_direction] + class BulletSprite(arcade.SpriteSolidColor): - """ Bullet Sprite """ + """Bullet Sprite""" + def pymunk_moved(self, physics_engine, dx, dy, d_angle): - """ Handle when the sprite is moved by the physics engine. """ + """Handle when the sprite is moved by the physics engine.""" # If the bullet falls below the screen, remove it if self.center_y < -100: self.remove_from_sprite_lists() + class GameWindow(arcade.Window): - """ Main Window """ + """Main Window""" def __init__(self, width, height, title): - """ Create the variables """ + """Create the variables""" # Init the parent class super().__init__(width, height, title) # Player sprite - self.player_sprite: Optional[PlayerSprite] = None + self.player_sprite: PlayerSprite | None = None # Sprite lists we need - self.player_list: Optional[arcade.SpriteList] = None - self.wall_list: Optional[arcade.SpriteList] = None - self.bullet_list: Optional[arcade.SpriteList] = None - self.item_list: Optional[arcade.SpriteList] = None - self.moving_sprites_list: Optional[arcade.SpriteList] = None - self.ladder_list: Optional[arcade.SpriteList] = None + self.player_list: arcade.SpriteList | None = None + self.wall_list: arcade.SpriteList | None = None + self.bullet_list: arcade.SpriteList | None = None + self.item_list: arcade.SpriteList | None = None + self.moving_sprites_list: arcade.SpriteList | None = None + self.ladder_list: arcade.SpriteList | None = None # Track the current state of what key is pressed self.left_pressed: bool = False @@ -241,7 +246,7 @@ def __init__(self, width, height, title): self.background_color = arcade.color.AMAZON def setup(self): - """ Set up everything with the game """ + """Set up everything with the game""" # Create the sprite lists self.player_list = arcade.SpriteList() @@ -257,10 +262,12 @@ def setup(self): self.wall_list = tile_map.sprite_lists["Platforms"] self.item_list = tile_map.sprite_lists["Dynamic Items"] self.ladder_list = tile_map.sprite_lists["Ladders"] - self.moving_sprites_list = tile_map.sprite_lists['Moving Platforms'] + self.moving_sprites_list = tile_map.sprite_lists["Moving Platforms"] # Create player sprite - self.player_sprite = PlayerSprite(self.ladder_list, hit_box_algorithm=arcade.hitbox.algo_detailed) + self.player_sprite = PlayerSprite( + self.ladder_list, hit_box_algorithm=arcade.hitbox.algo_detailed + ) # Set player location grid_x = 1 @@ -284,17 +291,16 @@ def setup(self): gravity = (0, -GRAVITY) # Create the physics engine - self.physics_engine = arcade.PymunkPhysicsEngine(damping=damping, - gravity=gravity) + self.physics_engine = arcade.PymunkPhysicsEngine(damping=damping, gravity=gravity) def wall_hit_handler(bullet_sprite, _wall_sprite, _arbiter, _space, _data): - """ Called for bullet/wall collision """ + """Called for bullet/wall collision""" bullet_sprite.remove_from_sprite_lists() self.physics_engine.add_collision_handler("bullet", "wall", post_handler=wall_hit_handler) def item_hit_handler(bullet_sprite, item_sprite, _arbiter, _space, _data): - """ Called for bullet/wall collision """ + """Called for bullet/wall collision""" bullet_sprite.remove_from_sprite_lists() item_sprite.remove_from_sprite_lists() @@ -310,13 +316,15 @@ def item_hit_handler(bullet_sprite, item_sprite, _arbiter, _space, _data): # Friction is between two objects in contact. It is important to remember # in top-down games that friction moving along the 'floor' is controlled # by damping. - self.physics_engine.add_sprite(self.player_sprite, - friction=PLAYER_FRICTION, - mass=PLAYER_MASS, - moment_of_inertia=arcade.PymunkPhysicsEngine.MOMENT_INF, - collision_type="player", - max_horizontal_velocity=PLAYER_MAX_HORIZONTAL_SPEED, - max_vertical_velocity=PLAYER_MAX_VERTICAL_SPEED) + self.physics_engine.add_sprite( + self.player_sprite, + friction=PLAYER_FRICTION, + mass=PLAYER_MASS, + moment_of_inertia=arcade.PymunkPhysicsEngine.MOMENT_INF, + collision_type="player", + max_horizontal_velocity=PLAYER_MAX_HORIZONTAL_SPEED, + max_vertical_velocity=PLAYER_MAX_VERTICAL_SPEED, + ) # Create the walls. # By setting the body type to PymunkPhysicsEngine.STATIC the walls can't @@ -325,22 +333,25 @@ def item_hit_handler(bullet_sprite, item_sprite, _arbiter, _space, _data): # PymunkPhysicsEngine.KINEMATIC objects will move, but are assumed to be # repositioned by code and don't respond to physics forces. # Dynamic is default. - self.physics_engine.add_sprite_list(self.wall_list, - friction=WALL_FRICTION, - collision_type="wall", - body_type=arcade.PymunkPhysicsEngine.STATIC) + self.physics_engine.add_sprite_list( + self.wall_list, + friction=WALL_FRICTION, + collision_type="wall", + body_type=arcade.PymunkPhysicsEngine.STATIC, + ) # Create the items - self.physics_engine.add_sprite_list(self.item_list, - friction=DYNAMIC_ITEM_FRICTION, - collision_type="item") + self.physics_engine.add_sprite_list( + self.item_list, friction=DYNAMIC_ITEM_FRICTION, collision_type="item" + ) # Add kinematic sprites - self.physics_engine.add_sprite_list(self.moving_sprites_list, - body_type=arcade.PymunkPhysicsEngine.KINEMATIC) + self.physics_engine.add_sprite_list( + self.moving_sprites_list, body_type=arcade.PymunkPhysicsEngine.KINEMATIC + ) def on_key_press(self, key, modifiers): - """Called whenever a key is pressed. """ + """Called whenever a key is pressed.""" if key in (arcade.key.LEFT, arcade.key.A): self.left_pressed = True @@ -349,8 +360,10 @@ def on_key_press(self, key, modifiers): elif key in (arcade.key.UP, arcade.key.W): self.up_pressed = True # find out if player is standing on ground, and not on a ladder - if self.physics_engine.is_on_ground(self.player_sprite) \ - and not self.player_sprite.is_on_ladder: + if ( + self.physics_engine.is_on_ground(self.player_sprite) + and not self.player_sprite.is_on_ladder + ): # She is! Go ahead and jump impulse = (0, PLAYER_JUMP_IMPULSE) self.physics_engine.apply_impulse(self.player_sprite, impulse) @@ -358,7 +371,7 @@ def on_key_press(self, key, modifiers): self.down_pressed = True def on_key_release(self, key, modifiers): - """Called when the user releases a key. """ + """Called when the user releases a key.""" if key in (arcade.key.LEFT, arcade.key.A): self.left_pressed = False @@ -370,7 +383,7 @@ def on_key_release(self, key, modifiers): self.down_pressed = False def on_mouse_press(self, x, y, button, modifiers): - """ Called whenever the mouse button is clicked. """ + """Called whenever the mouse button is clicked.""" bullet = BulletSprite(width=20, height=5, color=arcade.color.DARK_YELLOW) self.bullet_list.append(bullet) @@ -411,20 +424,22 @@ def on_mouse_press(self, x, y, button, modifiers): bullet_gravity = (0, -BULLET_GRAVITY) # Add the sprite. This needs to be done AFTER setting the fields above. - self.physics_engine.add_sprite(bullet, - mass=BULLET_MASS, - damping=1.0, - friction=0.6, - collision_type="bullet", - gravity=bullet_gravity, - elasticity=0.9) + self.physics_engine.add_sprite( + bullet, + mass=BULLET_MASS, + damping=1.0, + friction=0.6, + collision_type="bullet", + gravity=bullet_gravity, + elasticity=0.9, + ) # Add force to bullet force = (BULLET_MOVE_FORCE, 0) self.physics_engine.apply_force(bullet, force) def on_update(self, delta_time): - """ Movement and game logic """ + """Movement and game logic""" is_on_ground = self.physics_engine.is_on_ground(self.player_sprite) # Update player forces based on keys pressed @@ -471,31 +486,42 @@ def on_update(self, delta_time): # For each moving sprite, see if we've reached a boundary and need to # reverse course. for moving_sprite in self.moving_sprites_list: - if moving_sprite.boundary_right and \ - moving_sprite.change_x > 0 and \ - moving_sprite.right > moving_sprite.boundary_right: + if ( + moving_sprite.boundary_right + and moving_sprite.change_x > 0 + and moving_sprite.right > moving_sprite.boundary_right + ): moving_sprite.change_x *= -1 - elif moving_sprite.boundary_left and \ - moving_sprite.change_x < 0 and \ - moving_sprite.left > moving_sprite.boundary_left: + elif ( + moving_sprite.boundary_left + and moving_sprite.change_x < 0 + and moving_sprite.left > moving_sprite.boundary_left + ): moving_sprite.change_x *= -1 - if moving_sprite.boundary_top and \ - moving_sprite.change_y > 0 and \ - moving_sprite.top > moving_sprite.boundary_top: + if ( + moving_sprite.boundary_top + and moving_sprite.change_y > 0 + and moving_sprite.top > moving_sprite.boundary_top + ): moving_sprite.change_y *= -1 - elif moving_sprite.boundary_bottom and \ - moving_sprite.change_y < 0 and \ - moving_sprite.bottom < moving_sprite.boundary_bottom: + elif ( + moving_sprite.boundary_bottom + and moving_sprite.change_y < 0 + and moving_sprite.bottom < moving_sprite.boundary_bottom + ): moving_sprite.change_y *= -1 # Figure out and set our moving platform velocity. # Pymunk uses velocity is in pixels per second. If we instead have # pixels per frame, we need to convert. - velocity = (moving_sprite.change_x * 1 / delta_time, moving_sprite.change_y * 1 / delta_time) + velocity = ( + moving_sprite.change_x * 1 / delta_time, + moving_sprite.change_y * 1 / delta_time, + ) self.physics_engine.set_velocity(moving_sprite, velocity) def on_draw(self): - """ Draw everything """ + """Draw everything""" self.clear() self.wall_list.draw() self.ladder_list.draw() @@ -509,8 +535,9 @@ def on_draw(self): # for item in self.item_list: # item.draw_hit_box(arcade.color.RED) + def main(): - """ Main function """ + """Main function""" window = GameWindow(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE) window.setup() arcade.run() diff --git a/make.py b/make.py index 9639db7330..9b90466b5a 100755 --- a/make.py +++ b/make.py @@ -12,8 +12,6 @@ * The output of python make.py --help """ -from __future__ import annotations - import os import subprocess from contextlib import contextmanager diff --git a/pyproject.toml b/pyproject.toml index 0c9d917f0f..3a995d57c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,13 @@ description = "Arcade Game Development Library" readme = "README.md" authors = [{ name = "Paul Vincent Craven", email = "paul@cravenfamily.com" }] license = { file = "license.rst" } -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/tests/conftest.py b/tests/conftest.py index b053294291..d11069917a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os from pathlib import Path diff --git a/tests/manual_smoke/sprite_collision_inspector.py b/tests/manual_smoke/sprite_collision_inspector.py index b18ac88f2f..401c317098 100644 --- a/tests/manual_smoke/sprite_collision_inspector.py +++ b/tests/manual_smoke/sprite_collision_inspector.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import builtins from typing import TypeVar, Type, Generic, Any, Callable diff --git a/tests/unit/color/test_module_color.py b/tests/unit/color/test_module_color.py index 16c221d228..0ab742fb1a 100644 --- a/tests/unit/color/test_module_color.py +++ b/tests/unit/color/test_module_color.py @@ -1,6 +1,6 @@ - def test_colors(): from arcade import color + names = color.__dict__.keys() - # number of colors + 1 real import + 1 annotations - assert 1016 + 1 + 1 == len(names) + # number of colors + 1 real import + assert 1016 + 1 == len(names) diff --git a/tests/unit/color/test_module_csscolor.py b/tests/unit/color/test_module_csscolor.py index c23e062e65..f238c95ecb 100644 --- a/tests/unit/color/test_module_csscolor.py +++ b/tests/unit/color/test_module_csscolor.py @@ -2,5 +2,5 @@ def test_csscolors(): from arcade import csscolor names = csscolor.__dict__.keys() - # number of colors + 1 import + 1 annotations - assert 156 + 1 + 1 == len(names) + # number of colors + 1 import + assert 156 + 1 == len(names) diff --git a/tests/unit/gui/__init__.py b/tests/unit/gui/__init__.py index d2de0281bd..99f284eaf9 100644 --- a/tests/unit/gui/__init__.py +++ b/tests/unit/gui/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations # allow |-type hinting in Python 3.9 - from abc import abstractmethod from contextlib import contextmanager from typing import List diff --git a/tests/unit/rect/test_rect_creation_helpers.py b/tests/unit/rect/test_rect_creation_helpers.py index ab6f9f83b4..928145762d 100644 --- a/tests/unit/rect/test_rect_creation_helpers.py +++ b/tests/unit/rect/test_rect_creation_helpers.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from arcade.types.rect import LBWH, LRBT, XYWH, XYRR, Rect diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index 48219f22bf..8213a0cde2 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -5,8 +5,6 @@ """ # fmt: off # ruff: noqa -from __future__ import annotations - import copy import html import re diff --git a/util/doc_helpers/import_resolver.py b/util/doc_helpers/import_resolver.py index d4b88a5dc7..9d99d4aafe 100644 --- a/util/doc_helpers/import_resolver.py +++ b/util/doc_helpers/import_resolver.py @@ -11,9 +11,11 @@ # Build a tree using the ast module looking at the __init__ files # and recurse the tree to find the lowest import of a member. + @dataclasses.dataclass class ImportNode: """A node in the import tree.""" + name: str parent: ImportNode | None = None children: list[ImportNode] = dataclasses.field(default_factory=list) @@ -69,6 +71,7 @@ def print_tree(self, depth=0): @dataclasses.dataclass class Import: """Unified representation of an import statement.""" + name: str # name of the member module: str # The module this import is from from_module: str # The module the member was imported from @@ -118,7 +121,7 @@ def _parse_import_node_recursive( imp = Import( name=alias.name.split(".")[-1], module=full_module_path, - from_module=".".join(alias.name.split(".")[:-1]) + from_module=".".join(alias.name.split(".")[:-1]), ) node.imports.append(imp) elif isinstance(ast_node, ast.ImportFrom): diff --git a/util/doc_helpers/real_filesystem.py b/util/doc_helpers/real_filesystem.py index aa3478776b..69ff96beb3 100644 --- a/util/doc_helpers/real_filesystem.py +++ b/util/doc_helpers/real_filesystem.py @@ -2,8 +2,6 @@ Helpers for dealing with the real-world file system. """ -from __future__ import annotations - import shutil from pathlib import Path from typing import Generator, TypeVar, Hashable, Iterable, Mapping, Sequence, Callable diff --git a/util/doc_helpers/vfs.py b/util/doc_helpers/vfs.py index 3cc3ff2875..c88baafce1 100644 --- a/util/doc_helpers/vfs.py +++ b/util/doc_helpers/vfs.py @@ -27,8 +27,6 @@ by reading each file before write and aborting if its contents would be unchanged. """ -from __future__ import annotations -import os from contextlib import suppress, contextmanager from io import StringIO from pathlib import Path diff --git a/util/update_quick_index.py b/util/update_quick_index.py index 8f4b41b8d6..c9a0731d8a 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -11,8 +11,6 @@ a # --- so you can skip between them in diffs or your favorite editor via hotkeys. """ -from __future__ import annotations - import re import sys from collections.abc import Mapping From 68a9c7b4f9597c88ca3d4aa1b96f1667a6d4c7ca Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Fri, 28 Mar 2025 18:11:26 +0100 Subject: [PATCH 097/279] remove dead code from spritelist (#2623) --- arcade/sprite_list/sprite_list.py | 69 ------------------------------- 1 file changed, 69 deletions(-) diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 67b5ca1b47..d06303a784 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -7,7 +7,6 @@ from __future__ import annotations -# import logging import random from array import array from collections import deque @@ -36,13 +35,6 @@ from arcade import DefaultTextureAtlas, Texture from arcade.texture_atlas import TextureAtlasBase -# LOG = logging.getLogger(__name__) - -# The slot index that makes a sprite invisible. -# 2^31-1 is usually reserved for primitive restart -# NOTE: Possibly we want to use slot 0 for this? -_SPRITE_SLOT_INVISIBLE = 2000000000 - # The default capacity from spritelists _DEFAULT_CAPACITY = 100 @@ -181,13 +173,6 @@ def __init__( self.properties: dict[str, Any] | None = None - # LOG.debug( - # "[%s] Creating SpriteList use_spatial_hash=%s capacity=%s", - # id(self), - # use_spatial_hash, - # self._buf_capacity, - # ) - # Check if the window/context is available try: get_window() @@ -275,8 +260,6 @@ def __getitem__(self, i: int) -> SpriteType: def __setitem__(self, index: int, sprite: SpriteType) -> None: """Replace a sprite at a specific index""" - # print(f"{id(self)} : {id(sprite)} __setitem__({index})") - try: existing_index = self.sprite_list.index(sprite) # raise ValueError if existing_index == index: @@ -385,7 +368,6 @@ def alpha(self) -> int: @alpha.setter def alpha(self, value: int) -> None: - # value = clamp(value, 0, 255) self._color = self._color[0], self._color[1], self._color[2], value / 255 @property @@ -402,7 +384,6 @@ def alpha_normalized(self) -> float: @alpha_normalized.setter def alpha_normalized(self, value: float) -> None: - # value = clamp(value, 0.0, 1.0) self._color = self._color[0], self._color[1], self._color[2], value @property @@ -667,10 +648,6 @@ def append(self, sprite: SpriteType) -> None: if self.spatial_hash is not None: self.spatial_hash.add(sprite) - # Load additional textures attached to the sprite - # if hasattr(sprite, "textures") and self._initialized: - # for texture in sprite.textures or []: - # self._atlas.add(texture) if self._initialized: if sprite.texture is None: raise ValueError("Sprite must have a texture when added to a SpriteList") @@ -707,7 +684,6 @@ def remove(self, sprite: SpriteType) -> None: Args: sprite: Item to remove from the list """ - # print(f"{id(self)} : {id(sprite)} remove") try: slot = self.sprite_slot[sprite] except KeyError: @@ -719,12 +695,6 @@ def remove(self, sprite: SpriteType) -> None: self._sprite_buffer_free_slots.append(slot) - # NOTE: Optimize this by deferring removal? - # Defer removal - # Set the sprite as invisible in the index buffer - # idx_slot = self._sprite_index_data.index(slot) - # self._sprite_index_data[idx_slot] = _SPRITE_SLOT_INVISIBLE - # Brutal resize for now. Optimize later self._sprite_index_data.remove(slot) self._sprite_index_data.append(0) @@ -864,13 +834,10 @@ def enable_spatial_hashing(self, spatial_hash_cell_size: int = 128) -> None: spatial_hash_cell_size: The size of the cell in the spatial hash. """ if self.spatial_hash is None or self.spatial_hash.cell_size != spatial_hash_cell_size: - # LOG.debug("Enabled spatial hashing with cell size %s", spatial_hash_cell_size) from .spatial_hash import SpatialHash self.spatial_hash = SpatialHash(cell_size=spatial_hash_cell_size) self._recalculate_spatial_hashes() - # else: - # LOG.debug("Spatial hashing is already enabled with size %s", spatial_hash_cell_size) def _recalculate_spatial_hashes(self) -> None: if self.spatial_hash is None: @@ -903,7 +870,6 @@ def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None: *args: Additional positional arguments **kwargs: Additional keyword arguments """ - # NOTE: Can we limit this to animated sprites? for sprite in self.sprite_list: sprite.update_animation(delta_time, *args, **kwargs) @@ -966,20 +932,6 @@ def write_sprite_buffers_to_gpu(self) -> None: self._write_sprite_buffers_to_gpu() def _write_sprite_buffers_to_gpu(self) -> None: - # LOG.debug( - # ( - # "[%s] SpriteList._write_sprite_buffers_to_gpu: " - # "pos=%s, size=%s, angle=%s, color=%s tex=%s idx=%s" - # ), - # id(self), - # self._sprite_pos_changed, - # self._sprite_size_changed, - # self._sprite_angle_changed, - # self._sprite_color_changed, - # self._sprite_texture_changed, - # self._sprite_index_changed, - # ) - if self._sprite_pos_changed and self._sprite_pos_buf: self._sprite_pos_buf.orphan() self._sprite_pos_buf.write(self._sprite_pos_data) @@ -1172,13 +1124,6 @@ def _grow_sprite_buffers(self) -> None: extend_by = self._buf_capacity self._buf_capacity = self._buf_capacity * 2 - # LOG.debug( - # "(%s) Increasing buffer capacity from %s to %s", - # self._sprite_buffer_slots, - # extend_by, - # self._buf_capacity, - # ) - # Extend the buffers so we don't lose the old data self._sprite_pos_data.extend([0] * extend_by * 3) self._sprite_size_data.extend([0] * extend_by * 2) @@ -1208,20 +1153,6 @@ def _grow_index_buffer(self) -> None: extend_by = self._idx_capacity self._idx_capacity = self._idx_capacity * 2 - # LOG.debug( - # "Buffers: index_slots=%s sprite_slots=%s over-allocation-ratio=%s", - # self._sprite_index_slots, - # self._sprite_buffer_slots, - # self._sprite_index_slots / self._sprite_buffer_slots, - # ) - - # LOG.debug( - # "(%s) Increasing index capacity from %s to %s", - # self._sprite_index_slots, - # extend_by, - # self._idx_capacity, - # ) - self._sprite_index_data.extend([0] * extend_by) if self._initialized and self._sprite_index_buf: self._sprite_index_buf.orphan(size=self._idx_capacity * 4) From ef757d1e526356ab1b1249a29af1117f36c2cd52 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Fri, 28 Mar 2025 22:12:18 +0100 Subject: [PATCH 098/279] Make spritelist remove() and pop() more preformant (#2624) * Make SpriteList.pop() O(1) instead of O(n) * Make SpriteList.remove() 3 x faster Because the index buffer and spritelist share the same index we only need to resolve the index once. Removing by value in an array is a lot more expensive than removing by value in a list. * Add complexity info in docstrings --- arcade/sprite_list/sprite_list.py | 36 +++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index d06303a784..2f39ce3ff1 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -600,7 +600,8 @@ def clear(self, *, capacity: int | None = None, deep: bool = True) -> None: self._init_deferred() def pop(self, index: int = -1) -> SpriteType: - """Attempt to pop a sprite from the list. + """ + Attempt to pop a sprite from the list. This works like :external:ref:`popping from ` a standard Python :py:class:`list`: @@ -609,6 +610,9 @@ def pop(self, index: int = -1) -> SpriteType: #. If no ``index`` is passed, try to pop the last :py:class:`Sprite` in the list + This is the most efficient way to remove a sprite from the list. + The complexity of this method is ``O(1)``. + Args: index: Index of sprite to remove (defaults to ``-1`` for the last item) @@ -616,8 +620,24 @@ def pop(self, index: int = -1) -> SpriteType: if len(self.sprite_list) == 0: raise IndexError("pop from empty list") - sprite = self.sprite_list[index] - self.remove(sprite) + sprite = self.sprite_list.pop(index) + try: + slot = self.sprite_slot[sprite] + except KeyError: + raise ValueError("Sprite is not in the SpriteList") + + sprite.sprite_lists.remove(self) + del self.sprite_slot[sprite] + self._sprite_buffer_free_slots.append(slot) + + _ = self._sprite_index_data.pop(index) + self._sprite_index_data.append(0) + self._sprite_index_slots -= 1 + self._sprite_index_changed = True + + if self.spatial_hash is not None: + self.spatial_hash.remove(sprite) + return sprite def append(self, sprite: SpriteType) -> None: @@ -681,6 +701,10 @@ def remove(self, sprite: SpriteType) -> None: """ Remove a specific sprite from the list. + Note that this method is ``O(N)`` in complexity and will have + and increased cost the more sprites you have in the list. + A faster option is to use :py:meth:`pop` or :py:meth:`swap`. + Args: sprite: Item to remove from the list """ @@ -689,14 +713,14 @@ def remove(self, sprite: SpriteType) -> None: except KeyError: raise ValueError("Sprite is not in the SpriteList") - self.sprite_list.remove(sprite) + index = self.sprite_list.index(sprite) + self.sprite_list.pop(index) sprite.sprite_lists.remove(self) del self.sprite_slot[sprite] self._sprite_buffer_free_slots.append(slot) - # Brutal resize for now. Optimize later - self._sprite_index_data.remove(slot) + self._sprite_index_data.pop(index) self._sprite_index_data.append(0) self._sprite_index_slots -= 1 self._sprite_index_changed = True From a39c3dab542a9f82f6ea79c1008c0ac09f3c020d Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Fri, 28 Mar 2025 23:44:50 +0100 Subject: [PATCH 099/279] Example Fixes (#2625) * Increase resolution to 720p * Remove excessive debug prints * Update animal facts api to v2 * Derp --- arcade/examples/net_process_animal_facts.py | 13 ++++++++----- arcade/examples/perspective.py | 2 +- arcade/examples/procedural_caves_bsp.py | 4 ++-- arcade/examples/procedural_caves_cellular.py | 4 ++-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/arcade/examples/net_process_animal_facts.py b/arcade/examples/net_process_animal_facts.py index 1e4c1e351b..cd6e32582f 100644 --- a/arcade/examples/net_process_animal_facts.py +++ b/arcade/examples/net_process_animal_facts.py @@ -30,6 +30,7 @@ """ import PIL.Image import random +import traceback import time import json import urllib.request @@ -206,8 +207,8 @@ def do_work(self, in_queue, out_queue): try: out_queue.put(selected_type.get_fact()) out_queue.put(selected_type.get_image()) - except Exception as e: - print("Error:", e) + except Exception: + traceback.print_exc() def start(self) -> int: """Start the process and wait for it to be ready""" @@ -229,11 +230,12 @@ def get_image(self) -> arcade.Texture: class CatFacts(Facts): - """Get random cat facts and iamges""" + """Get random cat facts and images""" def get_fact(self) -> str: with urllib.request.urlopen("https://meowfacts.herokuapp.com") as fd: data = json.loads(fd.read().decode("utf-8")) + print(data) return data["data"][0] def get_image(self) -> arcade.Texture: @@ -254,9 +256,10 @@ def __init__(self): self.images = [i for i in self.images if not i.endswith(".mp4")] def get_fact(self) -> str: - with urllib.request.urlopen("http://dog-api.kinduff.com/api/facts") as fd: + with urllib.request.urlopen("https://dogapi.dog/api/v2/facts") as fd: data = json.loads(fd.read().decode("utf-8")) - return data["facts"][0] + print(data) + return data["data"][0]["attributes"]["body"] def get_image(self) -> arcade.Texture: """Get a random dog image from https://random.dog""" diff --git a/arcade/examples/perspective.py b/arcade/examples/perspective.py index 315218a5b4..6c40865207 100644 --- a/arcade/examples/perspective.py +++ b/arcade/examples/perspective.py @@ -123,7 +123,7 @@ def on_update(self, delta_time: float): (1.0, 0.0, 0.0), (0, 0, 3), 180 * self.window.time ) view_data.forward, view_data.up = arcade.camera.grips.look_at(view_data, (0.0, 0.0, 0.0)) - print(view_data) + # print(view_data) def on_draw(self): diff --git a/arcade/examples/procedural_caves_bsp.py b/arcade/examples/procedural_caves_bsp.py index 8efb0f578a..fe6d05c196 100644 --- a/arcade/examples/procedural_caves_bsp.py +++ b/arcade/examples/procedural_caves_bsp.py @@ -37,8 +37,8 @@ VIEWPORT_MARGIN = 300 # How big the window is -WINDOW_WIDTH = 800 -WINDOW_HEIGHT = 600 +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 720 WINDOW_TITLE = "Procedural Caves BSP Example" MERGE_SPRITES = False diff --git a/arcade/examples/procedural_caves_cellular.py b/arcade/examples/procedural_caves_cellular.py index 6ed1bd22ef..bb773f9171 100644 --- a/arcade/examples/procedural_caves_cellular.py +++ b/arcade/examples/procedural_caves_cellular.py @@ -35,8 +35,8 @@ VIEWPORT_MARGIN = 300 # How big the window is -WINDOW_WIDTH = 800 -WINDOW_HEIGHT = 600 +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 720 WINDOW_TITLE = "Procedural Caves Cellular Automata Example" # How fast the camera pans to the player. 1.0 is instant. From 47761fd433a025ad15422a9a5822557612ae477a Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 29 Mar 2025 00:23:59 +0100 Subject: [PATCH 100/279] Make adjusted hitbox points ~35% faster (#2626) Restore the optimizations from the old hitbox code. --- arcade/hitbox/base.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/arcade/hitbox/base.py b/arcade/hitbox/base.py index 9e4aa4dd21..9225ebf5ab 100644 --- a/arcade/hitbox/base.py +++ b/arcade/hitbox/base.py @@ -230,15 +230,18 @@ def get_adjusted_points(self) -> Point2List: if not self._adjusted_cache_dirty: return self._adjusted_points # type: ignore + position_x, position_y = self._position + scale_x, scale_y = self._scale + def _adjust_point(point) -> Point2: x, y = point - x *= self.scale[0] - y *= self.scale[1] + x *= scale_x + y *= scale_y - return (x + self.position[0], y + self.position[1]) + return (x + position_x, y + position_y) - self._adjusted_points = [_adjust_point(point) for point in self.points] + self._adjusted_points = [_adjust_point(point) for point in self._points] self._adjusted_cache_dirty = False return self._adjusted_points @@ -295,14 +298,16 @@ def get_adjusted_points(self) -> Point2List: return self._adjusted_points rad = radians(-self._angle) + scale_x, scale_y = self._scale + position_x, position_y = self._position rad_cos = cos(rad) rad_sin = sin(rad) def _adjust_point(point) -> Point2: x, y = point - x *= self.scale[0] - y *= self.scale[1] + x *= scale_x + y *= scale_y if rad: rot_x = x * rad_cos - y * rad_sin @@ -311,10 +316,10 @@ def _adjust_point(point) -> Point2: y = rot_y return ( - x + self.position[0], - y + self.position[1], + x + position_x, + y + position_y, ) - self._adjusted_points = [_adjust_point(point) for point in self.points] + self._adjusted_points = [_adjust_point(point) for point in self._points] self._adjusted_cache_dirty = False return self._adjusted_points From fe2f203ce1872f0bd16340db6a02a20506a95576 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 29 Mar 2025 01:51:19 +0100 Subject: [PATCH 101/279] Make SpriteList.draw_hit_boxes ~20 times faster (#2627) --- arcade/sprite_list/sprite_list.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 2f39ce3ff1..35f642a8ca 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -28,7 +28,7 @@ from arcade.gl.buffer import Buffer from arcade.gl.types import BlendFunction, OpenGlFilter, PyGLenum from arcade.gl.vertex_array import Geometry -from arcade.types import RGBA255, Color, RGBANormalized, RGBOrA255, RGBOrANormalized +from arcade.types import RGBA255, Color, Point2, RGBANormalized, RGBOrA255, RGBOrANormalized from arcade.utils import copy_dunders_unimplemented if TYPE_CHECKING: @@ -1114,10 +1114,22 @@ def draw_hit_boxes( color: The color of the hit boxes line_thickness: The thickness of the lines """ + import arcade + converted_color = Color.from_iterable(color) + points: list[Point2] = [] + # TODO: Make this faster in the future + # NOTE: This will be easier when/if we change to triangles for sprite in self.sprite_list: - sprite.draw_hit_box(converted_color, line_thickness) + adjusted_points = sprite.hit_box.get_adjusted_points() + for i in range(len(adjusted_points) - 1): + points.append(adjusted_points[i]) + points.append(adjusted_points[i + 1]) + points.append(adjusted_points[-1]) + points.append(adjusted_points[0]) + + arcade.draw_lines(points, color=converted_color, line_width=line_thickness) def _normalize_index_buffer(self) -> None: """ From 7eef14129030e8f39abb5e29de95dbe247b95582 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 29 Mar 2025 04:05:05 +0100 Subject: [PATCH 102/279] Support drawing hitboxes using RBG or RGBA (#2628) * Support drawing hitboxes using RBG or RGBA * Fix import order --- arcade/scene.py | 4 ++-- arcade/sprite/base.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/arcade/scene.py b/arcade/scene.py index d9063b4027..dac305ec7b 100644 --- a/arcade/scene.py +++ b/arcade/scene.py @@ -16,7 +16,7 @@ from arcade import Sprite, SpriteList from arcade.gl.types import BlendFunction, OpenGlFilter from arcade.tilemap import TileMap -from arcade.types import RGBA255, Color +from arcade.types import Color, RGBOrA255 __all__ = ["Scene", "SceneKeyError"] @@ -499,7 +499,7 @@ def draw( def draw_hit_boxes( self, - color: RGBA255 = Color(0, 0, 0, 255), + color: RGBOrA255 = Color(0, 0, 0, 255), line_thickness: float = 1.0, names: Iterable[str] | None = None, ) -> None: diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py index c4a5a75872..d07dc7c9c1 100644 --- a/arcade/sprite/base.py +++ b/arcade/sprite/base.py @@ -7,7 +7,7 @@ from arcade.exceptions import ReplacementWarning, warning from arcade.hitbox import HitBox from arcade.texture import Texture -from arcade.types import LRBT, RGBA255, AsFloat, Color, Point, Point2, Point2List, Rect, RGBOrA255 +from arcade.types import LRBT, AsFloat, Color, Point, Point2, Point2List, Rect, RGBOrA255 from arcade.utils import copy_dunders_unimplemented if TYPE_CHECKING: @@ -764,7 +764,7 @@ def remove_from_sprite_lists(self) -> None: # ----- Drawing Methods ----- - def draw_hit_box(self, color: RGBA255 = BLACK, line_thickness: float = 2.0) -> None: + def draw_hit_box(self, color: RGBOrA255 = BLACK, line_thickness: float = 2.0) -> None: """ Draw a sprite's hit-box. This is useful for debugging. @@ -774,10 +774,11 @@ def draw_hit_box(self, color: RGBA255 = BLACK, line_thickness: float = 2.0) -> N line_thickness: How thick the box should be """ + converted_color = Color.from_iterable(color) points: Point2List = self.hit_box.get_adjusted_points() # NOTE: This is a COPY operation. We don't want to modify the points. points = tuple(points) + tuple(points[:-1]) - arcade.draw_line_strip(points, color=color, line_width=line_thickness) + arcade.draw_line_strip(points, color=converted_color, line_width=line_thickness) # ---- Shortcut Methods ---- From 35cf9676f26b6a3e39b8d3e7ccb96ee4859d6275 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 29 Mar 2025 11:44:50 +0100 Subject: [PATCH 103/279] Trim down options in make.py (#2629) * Trim down options in make.py * Remove pyright command * remove more stuff * Re-enable pyright * Re-enable mypy * Re-enable missing commands * Regroup commands + change descriptions --- make.py | 486 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 243 insertions(+), 243 deletions(-) diff --git a/make.py b/make.py index 9b90466b5a..ced4ec11dd 100755 --- a/make.py +++ b/make.py @@ -163,7 +163,7 @@ def run_doc(args: str | list[str]) -> None: @app.command(rich_help_panel="Docs") def clean(): """ - Delete built website files. + Clean / Delete the documentation build directory """ if os.path.exists(FULL_BUILD_DIR): for item in Path(FULL_BUILD_DIR).glob("*"): @@ -173,7 +173,7 @@ def clean(): @app.command(rich_help_panel="Docs") def html(): """ - to make standalone HTML files + Build the documentation (HTML) """ run_doc([SPHINX_BUILD, "-b", "html", *ALLSPHINXOPTS, f"{BUILD_DIR}/html"]) print() @@ -183,7 +183,7 @@ def html(): @app.command(rich_help_panel="Docs") def serve(): """ - Build and serve standalone HTML files, with automatic rebuilds and live reload. + Build and serve the docs with automatic rebuilds and live reload. """ run_doc( [SPHINX_AUTOBUILD, *SPHINXAUTOBUILDOPTS, "-b", "html", *ALLSPHINXOPTS, f"{BUILD_DIR}/html"] @@ -193,7 +193,7 @@ def serve(): @app.command(rich_help_panel="Docs") def linkcheck(): """ - to check all external links for integrity + Check for broken links in the documentation """ run_doc([SPHINX_BUILD, "-b", "linkcheck", *ALLSPHINXOPTS, f"{BUILD_DIR}/linkcheck"]) print() @@ -203,234 +203,234 @@ def linkcheck(): ) -@app.command(rich_help_panel="Docs Extra Formats") -def dirhtml(): - """ - to make HTML files named index.html in directories - """ - run_doc([SPHINX_BUILD, "-b", "dirhtml", *ALLSPHINXOPTS, f"{BUILD_DIR}/dirhtml"]) - print() - print(f"Build finished. The HTML pages are in {FULL_BUILD_DIR}/dirhtml.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def singlehtml(): - """ - to make a single large HTML file - """ - run_doc([SPHINX_BUILD, "-b", "singlehtml", *ALLSPHINXOPTS, f"{BUILD_DIR}/singlehtml"]) - print() - print(f"Build finished. The HTML page is in {FULL_BUILD_DIR}/singlehtml.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def pickle(): - """ - to make pickle files - """ - run_doc([SPHINX_BUILD, "-b", "pickle", *ALLSPHINXOPTS, f"{BUILD_DIR}/pickle"]) - print() - print("Build finished; now you can process the pickle files.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def json(): - """ - to make JSON files - """ - run_doc([SPHINX_BUILD, "-b", "json", *ALLSPHINXOPTS, f"{BUILD_DIR}/json"]) - print() - print("Build finished; now you can process the JSON files.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def htmlhelp(): - """ - to make HTML files and a HTML help project - """ - run_doc([SPHINX_BUILD, "-b", "htmlhelp", *ALLSPHINXOPTS, f"{BUILD_DIR}/htmlhelp"]) - print() - print( - "Build finished; now you can run HTML Help Workshop with the" - + f".hhp project file in {FULL_BUILD_DIR}/htmlhelp." - ) - - -@app.command(rich_help_panel="Docs Extra Formats") -def devhelp(): - """ - to make HTML files and a Devhelp project - """ - home = Path.home().expanduser().resolve(strict=True) - run_doc([SPHINX_BUILD, "-b", "devhelp", *ALLSPHINXOPTS, f"{BUILD_DIR}/devhelp"]) - print() - print("Build finished.") - print("To view the help file:") - print(f"# mkdir -p {home}/.local/share/devhelp/Arcade") - print(f"# ln -s {FULL_BUILD_DIR}/devhelp {home}/.local/share/devhelp/Arcade") - print("# devhelp") - - -@app.command(rich_help_panel="Docs Extra Formats") -def epub(): - """ - to make an epub - """ - run_doc([SPHINX_BUILD, "-b", "epub", *ALLSPHINXOPTS, f"{BUILD_DIR}/epub"]) - print() - print(f"Build finished. The epub file is in {FULL_BUILD_DIR}/epub.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def latex(): - """ - to make LaTeX files, you can set PAPER_SIZE=a4 or PAPER_SIZE=letter - """ - run_doc([SPHINX_BUILD, "-b", "latex", *ALLSPHINXOPTS, f"{BUILD_DIR}/latex"]) - print() - print(f"Build finished; the LaTeX files are in {FULL_BUILD_DIR}/latex.") - print( - "Run `make' in that directory to run these through (pdf)latex" - + "(use `make latexpdf' here to do that automatically)." - ) - - -@app.command(rich_help_panel="Docs Extra Formats") -def latexpdf(): - """ - to make LaTeX files and run them through pdflatex - """ - run_doc([SPHINX_BUILD, "-b", "latex", *ALLSPHINXOPTS, f"{BUILD_DIR}/latex"]) - print("Running LaTeX files through pdflatex...") - run_doc(["make", "-C", f"{BUILD_DIR}/latex", "all-pdf"]) - print(f"pdflatex finished; the PDF files are in {FULL_BUILD_DIR}/latex.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def latexpdfja(): - """ - to make LaTeX files and run them through platex/dvipdfmx - """ - run_doc([SPHINX_BUILD, "-b", "latex", *ALLSPHINXOPTS, f"{BUILD_DIR}/latex"]) - print("Running LaTeX files through platex and dvipdfmx...") - run_doc(["make", "-C", f"{BUILD_DIR}/latex", "all-pdf-ja"]) - print(f"pdflatex finished; the PDF files are in {FULL_BUILD_DIR}/latex.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def text(): - """ - to make text files - """ - run_doc([SPHINX_BUILD, "-b", "text", *ALLSPHINXOPTS, f"{BUILD_DIR}/text"]) - print() - print(f"Build finished. The text files are in {FULL_BUILD_DIR}/text.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def man(): - """ - to make manual pages - """ - run_doc([SPHINX_BUILD, "-b", "man", *ALLSPHINXOPTS, f"{BUILD_DIR}/man"]) - print() - print(f"Build finished. The manual pages are in {FULL_BUILD_DIR}/man.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def texinfo(): - """ - to make Texinfo files - """ - run_doc([SPHINX_BUILD, "-b", "texinfo", *ALLSPHINXOPTS, f"{BUILD_DIR}/texinfo"]) - print() - print(f"Build finished. The Texinfo files are in {FULL_BUILD_DIR}/texinfo.") - print( - "Run `make' in that directory to run these through makeinfo" - + "(use `make info' here to do that automatically)." - ) - - -@app.command(rich_help_panel="Docs Extra Formats") -def info(): - """ - to make Texinfo files and run them through makeinfo - """ - run_doc([SPHINX_BUILD, "-b", "texinfo", *ALLSPHINXOPTS, f"{BUILD_DIR}/texinfo"]) - print("Running Texinfo files through makeinfo...") - run_doc(["make", "-C", f"{BUILD_DIR}/texinfo", "info"]) - print(f"makeinfo finished; the Info files are in {FULL_BUILD_DIR}/texinfo.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def gettext(): - """ - to make PO message catalogs - """ - run_doc([SPHINX_BUILD, "-b", "gettext", *I18NSPHINXOPTS, f"{BUILD_DIR}/locale"]) - print() - print(f"Build finished. The message catalogs are in {FULL_BUILD_DIR}/locale.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def changes(): - """ - to make an overview of all changed/added/deprecated items - """ - run_doc([SPHINX_BUILD, "-b", "changes", *ALLSPHINXOPTS, f"{BUILD_DIR}/changes"]) - print() - print(f"The overview file is in {FULL_BUILD_DIR}/changes.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def doctest(): - """ - to run all doctests embedded in the documentation (if enabled) - """ - run_doc([SPHINX_BUILD, "-b", "doctest", *ALLSPHINXOPTS, f"{BUILD_DIR}/doctest"]) - print( - "Testing of doctests in the sources finished, look at the " - + f"results in {FULL_BUILD_DIR}/doctest/output.txt." - ) - - -@app.command(rich_help_panel="Docs Extra Formats") -def coverage(): - """ - to run coverage check of the documentation (if enabled) - """ - run_doc([SPHINX_BUILD, "-b", "coverage", *ALLSPHINXOPTS, f"{BUILD_DIR}/coverage"]) - print( - "Testing of coverage in the sources finished, look at the " - + f"results in {FULL_BUILD_DIR}/coverage/python.txt." - ) - - -@app.command(rich_help_panel="Docs Extra Formats") -def xml(): - run_doc([SPHINX_BUILD, "-b", "xml", *ALLSPHINXOPTS, f"{BUILD_DIR}/xml"]) - print() - print(f"Build finished. The XML files are in {FULL_BUILD_DIR}/xml.") - - -@app.command(rich_help_panel="Docs Extra Formats") -def pseudoxml(): - run_doc([SPHINX_BUILD, "-b", "pseudoxml", *ALLSPHINXOPTS, f"{BUILD_DIR}/pseudoxml"]) - print() - print(f"Build finished. The pseudo-XML files are in {FULL_BUILD_DIR}/pseudoxml.") +# @app.command(rich_help_panel="Docs Extra Formats") +# def dirhtml(): +# """ +# to make HTML files named index.html in directories +# """ +# run_doc([SPHINX_BUILD, "-b", "dirhtml", *ALLSPHINXOPTS, f"{BUILD_DIR}/dirhtml"]) +# print() +# print(f"Build finished. The HTML pages are in {FULL_BUILD_DIR}/dirhtml.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def singlehtml(): +# """ +# to make a single large HTML file +# """ +# run_doc([SPHINX_BUILD, "-b", "singlehtml", *ALLSPHINXOPTS, f"{BUILD_DIR}/singlehtml"]) +# print() +# print(f"Build finished. The HTML page is in {FULL_BUILD_DIR}/singlehtml.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def pickle(): +# """ +# to make pickle files +# """ +# run_doc([SPHINX_BUILD, "-b", "pickle", *ALLSPHINXOPTS, f"{BUILD_DIR}/pickle"]) +# print() +# print("Build finished; now you can process the pickle files.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def json(): +# """ +# to make JSON files +# """ +# run_doc([SPHINX_BUILD, "-b", "json", *ALLSPHINXOPTS, f"{BUILD_DIR}/json"]) +# print() +# print("Build finished; now you can process the JSON files.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def htmlhelp(): +# """ +# to make HTML files and a HTML help project +# """ +# run_doc([SPHINX_BUILD, "-b", "htmlhelp", *ALLSPHINXOPTS, f"{BUILD_DIR}/htmlhelp"]) +# print() +# print( +# "Build finished; now you can run HTML Help Workshop with the" +# + f".hhp project file in {FULL_BUILD_DIR}/htmlhelp." +# ) + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def devhelp(): +# """ +# to make HTML files and a Devhelp project +# """ +# home = Path.home().expanduser().resolve(strict=True) +# run_doc([SPHINX_BUILD, "-b", "devhelp", *ALLSPHINXOPTS, f"{BUILD_DIR}/devhelp"]) +# print() +# print("Build finished.") +# print("To view the help file:") +# print(f"# mkdir -p {home}/.local/share/devhelp/Arcade") +# print(f"# ln -s {FULL_BUILD_DIR}/devhelp {home}/.local/share/devhelp/Arcade") +# print("# devhelp") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def epub(): +# """ +# to make an epub +# """ +# run_doc([SPHINX_BUILD, "-b", "epub", *ALLSPHINXOPTS, f"{BUILD_DIR}/epub"]) +# print() +# print(f"Build finished. The epub file is in {FULL_BUILD_DIR}/epub.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def latex(): +# """ +# to make LaTeX files, you can set PAPER_SIZE=a4 or PAPER_SIZE=letter +# """ +# run_doc([SPHINX_BUILD, "-b", "latex", *ALLSPHINXOPTS, f"{BUILD_DIR}/latex"]) +# print() +# print(f"Build finished; the LaTeX files are in {FULL_BUILD_DIR}/latex.") +# print( +# "Run `make' in that directory to run these through (pdf)latex" +# + "(use `make latexpdf' here to do that automatically)." +# ) + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def latexpdf(): +# """ +# to make LaTeX files and run them through pdflatex +# """ +# run_doc([SPHINX_BUILD, "-b", "latex", *ALLSPHINXOPTS, f"{BUILD_DIR}/latex"]) +# print("Running LaTeX files through pdflatex...") +# run_doc(["make", "-C", f"{BUILD_DIR}/latex", "all-pdf"]) +# print(f"pdflatex finished; the PDF files are in {FULL_BUILD_DIR}/latex.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def latexpdfja(): +# """ +# to make LaTeX files and run them through platex/dvipdfmx +# """ +# run_doc([SPHINX_BUILD, "-b", "latex", *ALLSPHINXOPTS, f"{BUILD_DIR}/latex"]) +# print("Running LaTeX files through platex and dvipdfmx...") +# run_doc(["make", "-C", f"{BUILD_DIR}/latex", "all-pdf-ja"]) +# print(f"pdflatex finished; the PDF files are in {FULL_BUILD_DIR}/latex.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def text(): +# """ +# to make text files +# """ +# run_doc([SPHINX_BUILD, "-b", "text", *ALLSPHINXOPTS, f"{BUILD_DIR}/text"]) +# print() +# print(f"Build finished. The text files are in {FULL_BUILD_DIR}/text.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def man(): +# """ +# to make manual pages +# """ +# run_doc([SPHINX_BUILD, "-b", "man", *ALLSPHINXOPTS, f"{BUILD_DIR}/man"]) +# print() +# print(f"Build finished. The manual pages are in {FULL_BUILD_DIR}/man.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def texinfo(): +# """ +# to make Texinfo files +# """ +# run_doc([SPHINX_BUILD, "-b", "texinfo", *ALLSPHINXOPTS, f"{BUILD_DIR}/texinfo"]) +# print() +# print(f"Build finished. The Texinfo files are in {FULL_BUILD_DIR}/texinfo.") +# print( +# "Run `make' in that directory to run these through makeinfo" +# + "(use `make info' here to do that automatically)." +# ) + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def info(): +# """ +# to make Texinfo files and run them through makeinfo +# """ +# run_doc([SPHINX_BUILD, "-b", "texinfo", *ALLSPHINXOPTS, f"{BUILD_DIR}/texinfo"]) +# print("Running Texinfo files through makeinfo...") +# run_doc(["make", "-C", f"{BUILD_DIR}/texinfo", "info"]) +# print(f"makeinfo finished; the Info files are in {FULL_BUILD_DIR}/texinfo.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def gettext(): +# """ +# to make PO message catalogs +# """ +# run_doc([SPHINX_BUILD, "-b", "gettext", *I18NSPHINXOPTS, f"{BUILD_DIR}/locale"]) +# print() +# print(f"Build finished. The message catalogs are in {FULL_BUILD_DIR}/locale.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def changes(): +# """ +# to make an overview of all changed/added/deprecated items +# """ +# run_doc([SPHINX_BUILD, "-b", "changes", *ALLSPHINXOPTS, f"{BUILD_DIR}/changes"]) +# print() +# print(f"The overview file is in {FULL_BUILD_DIR}/changes.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def doctest(): +# """ +# to run all doctests embedded in the documentation (if enabled) +# """ +# run_doc([SPHINX_BUILD, "-b", "doctest", *ALLSPHINXOPTS, f"{BUILD_DIR}/doctest"]) +# print( +# "Testing of doctests in the sources finished, look at the " +# + f"results in {FULL_BUILD_DIR}/doctest/output.txt." +# ) + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def coverage(): +# """ +# to run coverage check of the documentation (if enabled) +# """ +# run_doc([SPHINX_BUILD, "-b", "coverage", *ALLSPHINXOPTS, f"{BUILD_DIR}/coverage"]) +# print( +# "Testing of coverage in the sources finished, look at the " +# + f"results in {FULL_BUILD_DIR}/coverage/python.txt." +# ) + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def xml(): +# run_doc([SPHINX_BUILD, "-b", "xml", *ALLSPHINXOPTS, f"{BUILD_DIR}/xml"]) +# print() +# print(f"Build finished. The XML files are in {FULL_BUILD_DIR}/xml.") + + +# @app.command(rich_help_panel="Docs Extra Formats") +# def pseudoxml(): +# run_doc([SPHINX_BUILD, "-b", "pseudoxml", *ALLSPHINXOPTS, f"{BUILD_DIR}/pseudoxml"]) +# print() +# print(f"Build finished. The pseudo-XML files are in {FULL_BUILD_DIR}/pseudoxml.") @app.command(rich_help_panel="Code Quality") def lint(): """ - Run tasks: ruff, mypy, and pyright (Run this before making a pull request!) + Run tasks: ruff, mypy, and pyright (Run before making a pull request) """ ruff_check() mypy() pyright() -@app.command(rich_help_panel="Code Quality") +@app.command(rich_help_panel="Code Quality - Advanced") def ruff_check(): """Run ruff check for code quality""" run([RUFF, *RUFFOPTS, RUFFOPTS_PACKAGE]) @@ -438,12 +438,12 @@ def ruff_check(): @app.command(rich_help_panel="Code Quality") def format(check: bool = False): - """Format code and sort imports with ruff""" + """Format code (Run before making a pull request)""" ruff_format(check) ruff_isort(check) -@app.command(rich_help_panel="Code Quality") +@app.command(rich_help_panel="Code Quality - Advanced") def ruff_format(check: bool = False): """Format code using ruff""" ruff_fmt = [RUFF, "format"] @@ -452,7 +452,7 @@ def ruff_format(check: bool = False): run(ruff_fmt) -@app.command(rich_help_panel="Code Quality") +@app.command(rich_help_panel="Code Quality - Advanced") def ruff_isort(check: bool = False): """Sort imports with ruff""" if not check: @@ -460,40 +460,40 @@ def ruff_isort(check: bool = False): run([RUFF, *RUFFOPTS_ISORT, RUFFOPTS_PACKAGE]) -@app.command(rich_help_panel="Code Quality") +@app.command(rich_help_panel="Code Quality - Advanced") def mypy(): """Typecheck using mypy""" run([MYPY, *MYPYOPTS]) -@app.command(rich_help_panel="Code Quality") +@app.command(rich_help_panel="Code Quality - Advanced") def pyright(): """Typecheck using pyright""" run([PYRIGHT, *PYRIGHTOPTS]) -@app.command(rich_help_panel="Code Quality") -def test_full(): - """Run all tests""" - run([PYTEST, TESTDIR]) - - -@app.command(rich_help_panel="Code Quality") +@app.command(rich_help_panel="Tests") def test(): - """Run unit tests (Run this before making a pull request!)""" + """Run unit tests""" run([PYTEST, UNITTESTS]) -@app.command(rich_help_panel="Shell Completion") -def whichshell(): - """Find out which shell your system seems to be running""" - shell_name = Path(os.environ.get("SHELL")).stem - print(f"Your default shell appears to be: {shell_name}") +@app.command(rich_help_panel="Tests") +def test_full(): + """Run unit and integration tests""" + run([PYTEST, TESTDIR]) + + +# @app.command(rich_help_panel="Shell Completion") +# def whichshell(): +# """Find out which shell your system seems to be running""" +# shell_name = Path(os.environ.get("SHELL")).stem +# print(f"Your default shell appears to be: {shell_name}") - shells = ("bash", "zsh", "fish", "powershell", "powersh") - if shell_name in shells: - print("This shell is known to support tab-completion!") - print("See CONTRIBUTING.md for more information on how to enable it.") +# shells = ("bash", "zsh", "fish", "powershell", "powersh") +# if shell_name in shells: +# print("This shell is known to support tab-completion!") +# print("See CONTRIBUTING.md for more information on how to enable it.") if __name__ == "__main__": From ae8c950df895e4355e3afe2c9a5acfd0bff777ed Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 29 Mar 2025 13:07:20 +0100 Subject: [PATCH 104/279] Reduce excessive output when building docs + fix missing docstring warnings (#2630) * All output from docs build should use logging * Make warning the default log level * Missing docstring in pymunk_physics_engine * Missing docstrings --- arcade/camera/data_types.py | 11 ++++- arcade/clock.py | 3 ++ arcade/easing.py | 3 ++ .../future/background/background_texture.py | 42 +++++++++++++++++++ arcade/future/light/lights.py | 5 +++ arcade/gl/types.py | 12 ++++++ arcade/gl/uniform.py | 7 +++- arcade/isometric.py | 33 +++++++++++++++ arcade/pymunk_physics_engine.py | 9 ++++ doc/conf.py | 9 ++-- util/create_resources_listing.py | 39 +++++++++-------- util/doc_helpers/real_filesystem.py | 6 +-- 12 files changed, 151 insertions(+), 28 deletions(-) diff --git a/arcade/camera/data_types.py b/arcade/camera/data_types.py index c67b90c32c..50c6704d6d 100644 --- a/arcade/camera/data_types.py +++ b/arcade/camera/data_types.py @@ -427,7 +427,16 @@ def use(self) -> None: ... @contextmanager - def activate(self) -> Generator[Self, None, None]: ... + def activate(self) -> Generator[Self, None, None]: + """ + Activate this projector for rendering. + + This is a context manager and should be used with a ``with`` statement:: + + with projector.activate(): + # Render with this projector + """ + ... def project(self, world_coordinate: Point) -> Vec2: """ diff --git a/arcade/clock.py b/arcade/clock.py index 1ebb20e597..047c95bbfc 100644 --- a/arcade/clock.py +++ b/arcade/clock.py @@ -95,6 +95,9 @@ def ticks_since(self, tick: int) -> int: @property def max_deltatime(self) -> float | None: + """ + The maximum deltatime that the clock will allow. If a large dt is passed into + """ return self._max_deltatime @property diff --git a/arcade/easing.py b/arcade/easing.py index 2ce734dee9..a88dfb4900 100644 --- a/arcade/easing.py +++ b/arcade/easing.py @@ -23,6 +23,9 @@ class EasingData: ease_function: Callable def reset(self) -> None: + """ + Reset the easing data to its initial state. + """ self.cur_period = self.start_period diff --git a/arcade/future/background/background_texture.py b/arcade/future/background/background_texture.py index ec15360a9a..be8dbbd0e9 100644 --- a/arcade/future/background/background_texture.py +++ b/arcade/future/background/background_texture.py @@ -15,6 +15,12 @@ class BackgroundTexture: The Mat3s define the scaling, rotation, and translation of the pixel data in the texture. see background_fs.glsl in resources/shaders for an implementation of this. + + Args: + texture: The texture to use as the background. + offset: The offset of the texture in pixels. + scale: The scale of the texture. + angle: The angle of the texture in radians. """ def __init__( @@ -41,6 +47,10 @@ def pixel_transform(self): @property def scale(self) -> float: + """ + Get or set the scale of the texture. This is a multiplier on the size of the texture. + Default value is ``1.0``. + """ return self._scale @scale.setter @@ -50,6 +60,10 @@ def scale(self, value: float): @property def angle(self) -> float: + """ + Get or set the angle of the texture. This is a rotation in radians. + Default value is ``0.0``. + """ return self._angle @angle.setter @@ -59,6 +73,10 @@ def angle(self, value: float): @property def offset(self) -> tuple[float, float]: + """ + Get or set the offset of the texture. This is a translation in pixels. + Default value is ``(0.0, 0.0)``. + """ return self._offset @offset.setter @@ -134,6 +152,17 @@ def render_target( color_attachments: list[gl.Texture2D] | None = None, depth_attachment: gl.Texture2D | None = None, ) -> gl.Framebuffer: + """ + Create a framebuffer for the texture. + + This framebuffer is used to render to the texture. The framebuffer is created with the + texture as the color attachment. + + Args: + context: The context to use for the framebuffer. + color_attachments: The color attachments to use for the framebuffer." + depth_attachment: The depth attachment to use for the framebuffer." + """ if color_attachments is None: color_attachments = [] return context.framebuffer( @@ -149,6 +178,19 @@ def from_file( angle: float = 0.0, filters=(gl.NEAREST, gl.NEAREST), ): + """ " + Create a BackgroundTexture from a file. + This is a convenience function to create a BackgroundTexture from a file. + + The file is loaded using PIL and converted to a texture. + + Args: + tex_src: The file to load. + offset: The offset of the texture in pixels. + scale: The scale of the texture. + angle: The angle of the texture in radians. + filters: The filters to use for the texture. + """ _context = get_window().ctx with Image.open(resolve(tex_src)).convert("RGBA") as img: diff --git a/arcade/future/light/lights.py b/arcade/future/light/lights.py index 42fb51e45e..243cb51570 100644 --- a/arcade/future/light/lights.py +++ b/arcade/future/light/lights.py @@ -122,19 +122,23 @@ def __init__(self, width: int, height: int): @property def diffuse_texture(self): + """The diffuse texture""" return self.texture @property def light_texture(self): + """The light texture""" return self._light_buffer.color_attachments[0] def resize(self, width, height): + """Resize the light layer""" super().resize(width, height) self._light_buffer = self.ctx.framebuffer( color_attachments=self.ctx.texture((width, height), components=3) ) def clear(self): + """Clear the light layer""" super().clear() self._light_buffer.clear() @@ -145,6 +149,7 @@ def add(self, light: Light): self._rebuild = True def extend(self, lights: Sequence[Light]): + """Add a list of lights to the layer""" for light in lights: self.add(light) diff --git a/arcade/gl/types.py b/arcade/gl/types.py index 1c7e4589fc..2af7fcbd64 100644 --- a/arcade/gl/types.py +++ b/arcade/gl/types.py @@ -192,11 +192,17 @@ def __init__( location=0, ): self.name = name + """The name of the attribute in the program""" self.gl_type = gl_type + """The OpenGL type of the attribute""" self.components = components + """Number of components for this attribute (1, 2, 3 or 4)""" self.bytes_per_component = bytes_per_component + """How many bytes for a single component""" self.offset = offset + """Offset of the attribute in the buffer""" self.location = location + """Location of the attribute in the program""" @property def bytes_total(self) -> int: @@ -408,13 +414,19 @@ def __init__( self, name: str, enum: GLenumLike, gl_type: PyGLenum, gl_size: int, components: int ): self.name = name + """The string representation of this type""" self.enum = enum + """The OpenEL enum of this type""" self.gl_type = gl_type + """The base OpenGL data type""" self.gl_size = gl_size + """The size of the base OpenGL data type""" self.components = components + """The number of components (1, 2, 3 or 4)""" @property def size(self) -> int: + """The total size of this type in bytes""" return self.gl_size * self.components def __repr__(self) -> str: diff --git a/arcade/gl/uniform.py b/arcade/gl/uniform.py index bf4584e920..87d9e3a264 100644 --- a/arcade/gl/uniform.py +++ b/arcade/gl/uniform.py @@ -1,5 +1,6 @@ import struct from ctypes import POINTER, c_double, c_float, c_int, c_uint, cast +from typing import Callable from pyglet import gl @@ -173,8 +174,10 @@ def __init__(self, ctx, program_id, location, name, data_type, array_length): self._array_length = array_length # Number of components (including per array entry) self._components = 0 - #: The getter function configured for this uniform - #: The setter function configured for this uniform + self.getter: Callable + """The getter function configured for this uniform""" + self.setter: Callable + """The setter function configured for this uniform""" self._setup_getters_and_setters() @property diff --git a/arcade/isometric.py b/arcade/isometric.py index e2166d1214..55c718d4a2 100644 --- a/arcade/isometric.py +++ b/arcade/isometric.py @@ -5,6 +5,17 @@ def isometric_grid_to_screen( tile_x: int, tile_y: int, width: int, height: int, tile_width: int, tile_height: int ) -> tuple[int, int]: + """ + Convert isometric grid coordinates to screen coordinates. + + Args: + tile_x: The x coordinate of the tile in the isometric grid. + tile_y: The y coordinate of the tile in the isometric grid. + width: The width of the screen. + height: The height of the screen. + tile_width: The width of a tile in pixels. + tile_height: The height of a tile in pixels. + """ screen_x = tile_width * tile_x // 2 + height * tile_width // 2 - tile_y * tile_width // 2 screen_y = ( (height - tile_y - 1) * tile_height // 2 @@ -17,6 +28,17 @@ def isometric_grid_to_screen( def screen_to_isometric_grid( screen_x: int, screen_y: int, width: int, height: int, tile_width: int, tile_height: int ) -> tuple[int, int]: + """ + Convert screen coordinates to isometric grid coordinates. + + Args: + screen_x: The x coordinate on the screen. + screen_y: The y coordinate on the screen. + width: The width of the screen. + height: The height of the screen. + tile_width: The width of a tile in pixels. + tile_height: The height of a tile in pixels. + """ x2 = (1 / tile_width * screen_x / 2 - 1 / tile_height * screen_y / 2 + width / 2) * 2 - ( width / 2 + 0.5 ) @@ -31,6 +53,17 @@ def screen_to_isometric_grid( def create_isometric_grid_lines( width: int, height: int, tile_width: int, tile_height: int, color: RGBA255, line_width: int ) -> ShapeElementList: + """ + Create a ShapeElementList of isometric grid lines. + + Args: + width: The width of the grid in tiles. + height: The height of the grid in tiles. + tile_width: The width of a tile in pixels. + tile_height: The height of a tile in pixels. + color: The color of the lines. + line_width: The width of the lines. + """ # Grid lines 1 shape_list: ShapeElementList = ShapeElementList() diff --git a/arcade/pymunk_physics_engine.py b/arcade/pymunk_physics_engine.py index 8d47d7453a..da5199a2a4 100644 --- a/arcade/pymunk_physics_engine.py +++ b/arcade/pymunk_physics_engine.py @@ -499,6 +499,15 @@ def set_position(self, sprite: Sprite, position: pymunk.Vec2d | tuple[float, flo physics_object.body.position = position def set_rotation(self, sprite: Sprite, rotation: float) -> None: + """ + Set the rotation of the sprite + + Args: + sprite: + A sprite known to the physics engine. + rotation: + The angle in degrees (clockwise). + """ physics_object = self.get_physics_object(sprite) if physics_object.body is None: raise PymunkException( diff --git a/doc/conf.py b/doc/conf.py index 82b8bd05d5..a7c5da0f5c 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -18,7 +18,8 @@ UTIL_DIR = REPO_LOCAL_ROOT / "util" log = logging.getLogger('conf.py') -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.WARNING) +# logging.basicConfig(level=logging.INFO) sys.path.insert(0, str(REPO_LOCAL_ROOT)) sys.path.insert(0, str(ARCADE_MODULE)) @@ -513,12 +514,12 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: def setup(app): - print("Diagnostic info since readthedocs doesn't use our make.py:") + log.info("Diagnostic info since readthedocs doesn't use our make.py:") for attr, comment in APP_CONFIG_DIRS: val = getattr(app, attr, None) - print(f" {attr}: {val!r}") + log.info(f"{attr}: {val!r}") if comment: - print(f" {comment}") + log.info(f" {comment}") # Separate stylesheets loosely by category. # pending: sphinx >= 8.1.4 to remove the sphinx_static_file_temp_fix.py diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index 8213a0cde2..150b9cb072 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -266,13 +266,13 @@ def do_heading( ref_target: ``True`` to auto-generate it or a str to use a specific one. """ out.write("\n") - print(f"doing heading: {heading_text!r} {relative_heading_level}") + log.info(f"doing heading: {heading_text!r} {relative_heading_level}") num_headings = len(headings_lookup) if ref_target is True: ref_target = f"resources-{heading_text}.rst" if ref_target: - print(f" writing ref target {repr(heading_text)}") + log.info(f" writing ref target {repr(heading_text)}") out.write(f".. _{ref_target.lower()}:\n\n") if relative_heading_level >= num_headings: @@ -389,9 +389,9 @@ def process_resource_directory(out, dir: Path): file_list = filter_dir(path, keep=is_unskipped_file) num_files = len(file_list) if num_files <= 0: - print(f" SKIP: No files... {num_files}") + log.info(f" SKIP: No files... {num_files}") else: - print(" HAS FILES!") + log.info(" HAS FILES!") handle_raw = path_as_resource_handle(path, suffix="/") config: HandleLevelConfigDict = RESOURCE_HANDLE_CONFIGS.get(handle_raw, {}) resource_handle = handle_raw.removesuffix('./') @@ -408,28 +408,31 @@ def process_resource_directory(out, dir: Path): handle_steps_wholes.append( f"{handle_steps_wholes[-1]}{handle_step_whole}/") - print(" Subdir Config:") + log.info(" Subdir Config:") _l = locals() for k in filter(lambda _k: 'handle' in _k and('steps' in _k or _k.count('_') <2), _l.keys()): - print(f" {k} : {_l.get(k, None)!r}" if k else '') + log.info(f" {k} : {_l.get(k, None)!r}" if k else '') # Process headings and render any new ones we haven't seen for heading_level, handle_step_whole in enumerate(handle_steps_wholes, start=0): - print(" heading check", (heading_level, handle_step_whole)) + log.info(" heading check", (heading_level, handle_step_whole)) if handle_step_whole in SKIP_HANDLES: - print(" skipping excluded") + log.info(" skipping excluded") continue if handle_step_whole in visited_headings: - print(" skipping visited") + log.info(" skipping visited") continue visited_headings.add(handle_step_whole) local_config = RESOURCE_HANDLE_CONFIGS.get(handle_step_whole, {}) local_heading_config = local_config.get('heading', {}) - print("proceeding...", - "\n config ", local_config, - "\n heading_config ", local_heading_config, sep = "") + log.info( + ("proceeding... " + f"\n config {local_config}", + f"\n heading_config {local_heading_config}" + ) + ) # Heading config fetch and write use_level = local_heading_config.get('level', heading_level) @@ -442,9 +445,9 @@ def process_resource_directory(out, dir: Path): for k, v in locals().items(): if k.startswith("use_"): - print(repr(k), ":", repr(v)) + log.info("%s : %s", repr(k), repr(v)) - print(f" got target: {use_target!r}") + log.info(f" got target: {use_target!r}") do_heading(out, use_level, use_value, ref_target=use_target) out.write(f"\n.. comment `{handle_step_whole!r}``\n\n") @@ -548,7 +551,7 @@ def from_path(cls, path: Path) -> Self: face_name_pieces = (face_name_parts.get("face_name") or '').split('_') raw_name = ' '.join(face_name_pieces) - print(face_name_parts) + log.info(face_name_parts) styles = tuple(BRITTLE_CAP_WORD_REGEX.findall( face_name_parts.get('styles', None) or '')) @@ -635,11 +638,11 @@ def do_filetile(out, suffix: str | None = None, state: str = None): p = FILETILE_DIR / f"type-{suffix.strip('.')}.png" log.info(f" FILETILE: {p}") if p.exists(): - print(f" KNOWN! {p.name!r}") + log.info(f" KNOWN! {p.name!r}") name = p.name else: name = f"type-unknown.png" - print(" ... unknown :(") + log.info(" ... unknown :(") else: name = "state-error.png" out.write(indent(f" ", @@ -895,7 +898,7 @@ def resources(): process_resource_directory(out, RESOURCE_DIR) out.close() - print("Done creating resources.rst") + log.info("Done creating resources.rst") vfs = Vfs() diff --git a/util/doc_helpers/real_filesystem.py b/util/doc_helpers/real_filesystem.py index 69ff96beb3..0cfc635be2 100644 --- a/util/doc_helpers/real_filesystem.py +++ b/util/doc_helpers/real_filesystem.py @@ -109,10 +109,10 @@ def copy_media( done = set() logging.info("") for dir_name, sub_items in items.items(): - print(f" Copying... {' '.join(map(repr, sub_items))}...") + log.info(f" Copying... {' '.join(map(repr, sub_items))}...") src_sub = (src_root / dir_name).resolve() dest_sub = dest_root / dir_name - print(" from :", src_sub) - print(" to :", dest_sub) + log.info(" from :", src_sub) + log.info(" to :", dest_sub) sync_dir(src_sub, dest_sub, *items, done=done) From 0dc37b483ea6f1c539d2936856f25b5fa317aee6 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 29 Mar 2025 21:37:33 +0100 Subject: [PATCH 105/279] add ControllerView and dispatch on_connect/disconnect events --- arcade/experimental/controller_window.py | 62 +++++++++++++++++++++++- arcade/gui/view.py | 18 ------- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/arcade/experimental/controller_window.py b/arcade/experimental/controller_window.py index 959206f42c..2e6d9e2cba 100644 --- a/arcade/experimental/controller_window.py +++ b/arcade/experimental/controller_window.py @@ -36,6 +36,8 @@ def on_connect(self, controller: Controller): except Exception as e: warnings.warn(f"Failed to open controller {controller}: {e}") + self.window.dispatch_event("on_connect", controller) + def on_disconnect(self, controller: Controller): controller.remove_handlers(self) @@ -44,7 +46,9 @@ def on_disconnect(self, controller: Controller): except Exception as e: warnings.warn(f"Failed to close controller {controller}: {e}") - # Controller event mapping + self.window.dispatch_event("on_disconnect", controller) + + # Controller input event mapping def on_stick_motion(self, controller: Controller, name, value): return self.window.dispatch_event("on_stick_motion", controller, name, value) @@ -62,7 +66,8 @@ def on_dpad_motion(self, controller: Controller, value): class ControllerWindow(arcade.Window): - """A window that listens to controller events and dispatches them via on_... hooks.""" + """A window that automatically opens and listens to controller events + and dispatches them via on_... hooks.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -73,24 +78,77 @@ def get_controllers(self) -> list[Controller]: return self.cb.cm.get_controllers() # Controller event mapping + def on_connect(self, controller: Controller): + """Called when a controller is connected. + The controller is already opened and ready to be used. + """ + pass + + def on_disconnect(self, controller: Controller): + """Called when a controller is disconnected.""" + pass + def on_stick_motion(self, controller: Controller, name, value): + """Called when a stick is moved.""" pass def on_trigger_motion(self, controller: Controller, name, value): + """Called when a trigger is moved.""" pass def on_button_press(self, controller: Controller, button): + """Called when a button is pressed.""" pass def on_button_release(self, controller: Controller, button): + """Called when a button is released.""" pass def on_dpad_motion(self, controller: Controller, value): + """Called when the dpad is moved.""" pass +ControllerWindow.register_event_type("on_connect") +ControllerWindow.register_event_type("on_disconnect") ControllerWindow.register_event_type("on_stick_motion") ControllerWindow.register_event_type("on_trigger_motion") ControllerWindow.register_event_type("on_button_press") ControllerWindow.register_event_type("on_button_release") ControllerWindow.register_event_type("on_dpad_motion") + + +class ControllerView(arcade.View): + """A view which predefines the controller event mapping methods. + + Can be used with a ControllerWindow to handle controller events.""" + + def on_connect(self, controller: Controller): + """Called when a controller is connected. + The controller is already opened and ready to be used. + """ + pass + + def on_disconnect(self, controller: Controller): + """Called when a controller is disconnected.""" + pass + + def on_stick_motion(self, controller: Controller, name, value): + """Called when a stick is moved.""" + pass + + def on_trigger_motion(self, controller: Controller, name, value): + """Called when a trigger is moved.""" + pass + + def on_button_press(self, controller: Controller, button): + """Called when a button is pressed.""" + pass + + def on_button_release(self, controller: Controller, button): + """Called when a button is released.""" + pass + + def on_dpad_motion(self, controller: Controller, value): + """Called when the dpad is moved.""" + pass diff --git a/arcade/gui/view.py b/arcade/gui/view.py index 6dc67205f3..885f4587a1 100644 --- a/arcade/gui/view.py +++ b/arcade/gui/view.py @@ -1,7 +1,5 @@ from typing import TypeVar -from pyglet.input import Controller - from arcade import View from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UIWidget @@ -61,19 +59,3 @@ def on_draw_before_ui(self): def on_draw_after_ui(self): """Use this method to draw custom elements after the UI elements are drawn.""" pass - - # Controller event mapping - def on_stick_motion(self, controller: Controller, name, value): - pass - - def on_trigger_motion(self, controller: Controller, name, value): - pass - - def on_button_press(self, controller: Controller, button): - pass - - def on_button_release(self, controller: Controller, button): - pass - - def on_dpad_motion(self, controller: Controller, value): - pass From 763c47748193a6918bb6dfbffed0545cfdf10fa7 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 29 Mar 2025 21:45:41 +0100 Subject: [PATCH 106/279] remove ogg sound test, which only runs with optional dependencies and additional os libraries --- tests/unit/test_sound.py | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/tests/unit/test_sound.py b/tests/unit/test_sound.py index 50c6d4bdfd..a5a3a6ca97 100644 --- a/tests/unit/test_sound.py +++ b/tests/unit/test_sound.py @@ -13,11 +13,9 @@ def test_sound_normal_load_and_playback(window): laser_wav = arcade.load_sound(":resources:sounds/laser1.wav") laser_mp3 = arcade.load_sound(":resources:sounds/laser1.mp3") - laser_ogg = arcade.load_sound(":resources:sounds/laser1.ogg") laser_wav_stream = arcade.load_sound(":resources:sounds/laser1.wav", streaming=True) laser_mp3_stream = arcade.load_sound(":resources:sounds/laser1.mp3", streaming=True) - laser_ogg_stream = arcade.load_sound(":resources:sounds/laser1.ogg", streaming=True) frame_count = 0 @@ -46,32 +44,13 @@ def update(dt): laser_wav_stream.stop(player) assert laser_wav_stream.is_playing(player) is False - player = laser_ogg.play(volume=0.5) - assert laser_ogg.get_volume(player) == 0.5 - laser_ogg.set_volume(1.0, player) - assert laser_ogg.get_volume(player) == 1.0 - if frame_count == 60: - assert laser_ogg.is_playing(player) is True - laser_ogg.stop(player) - assert laser_ogg.is_playing(player) is False - - player = laser_ogg_stream.play(volume=0.5) - assert laser_ogg_stream.get_volume(player) == 0.5 - laser_ogg_stream.set_volume(1.0, player) - assert laser_ogg_stream.get_volume(player) == 1.0 - - if frame_count == 80: - assert laser_ogg_stream.is_playing(player) is True - laser_ogg_stream.stop(player) - assert laser_ogg_stream.is_playing(player) is False - player = laser_mp3.play(volume=0.5) assert laser_mp3.get_volume(player) == 0.5 laser_mp3.set_volume(1.0, player) assert laser_mp3.get_volume(player) == 1.0 - if frame_count == 100: + if frame_count == 80: assert laser_mp3.is_playing(player) is True laser_mp3.stop(player) assert laser_mp3.is_playing(player) is False @@ -81,7 +60,7 @@ def update(dt): laser_mp3_stream.set_volume(1.0, player) assert laser_mp3_stream.get_volume(player) == 1.0 - if frame_count == 120: + if frame_count == 100: assert laser_mp3_stream.is_playing(player) is True laser_mp3_stream.stop(player) assert laser_mp3_stream.is_playing(player) is False From dafdbe94f948eb8a441089a22166ec9751c93bd9 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 29 Mar 2025 23:39:03 +0100 Subject: [PATCH 107/279] gui: make UIInputText a styled widget and support invalid state --- CHANGELOG.md | 14 +++ arcade/examples/gui/2_widgets.py | 30 +++++- arcade/gui/widgets/text.py | 141 +++++++++++++++++++++++++---- tests/unit/gui/test_uiinputtext.py | 39 ++++++++ 4 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 tests/unit/gui/test_uiinputtext.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee2958e48..f0f55a5af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Version 3.1 (unreleased) - Drop Python 3.9 support +- Disable shadow window on all platforms to provide a consistent experience +- Performance + - Improved performance of `arcade.SpriteList.remove()` and `arcade.SpriteList.pop()` + - Improved performance of `arcade.hitbox.Hitbox.get_adjusted_points()` ~35% + - Improved performance of `arcade.SpriteList.draw_hit_boxes()` ~20x +- GUI + - `arcade.gui.widgets.text.UIInputText` + - now supports styles for `normal`, `disabled`, `hovered`, `pressed` and `invalid` states + - provides a `invalid` property to indicate if the input is invalid + - Added experimental `arcade.gui.experimental.UIRestrictedInput` + a subclass of `UIInputText` that restricts the input to a specific set of characters + - `arcade.gui.NinePatchTexture` is now lazy and can be created before a window exists allowing creation during imports. + - Improve `arcade.gui.experimental.scroll_area.ScrollBar` behavior to match HTML scrollbars +- Support drawing hitboxes using RBG or RGBA ## Version 3.0.2 diff --git a/arcade/examples/gui/2_widgets.py b/arcade/examples/gui/2_widgets.py index 7a15819d55..0596f92b33 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -34,6 +34,7 @@ UITextureToggle, UIView, ) +from arcade.gui.experimental import UIPasswordInput # Load system fonts arcade.resources.load_kenney_fonts() @@ -256,14 +257,14 @@ def _show_text_widgets(self): self._body.clear() - box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left") + box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left", space_between=10) self._body.add(box) box.add(UILabel("Text Widgets", font_name=DEFAULT_FONT, font_size=32)) box.add(UISpace(size_hint=(1, 0.1))) row_1 = UIBoxLayout(vertical=False, size_hint=(1, 0.1)) box.add(row_1) - row_1.add(UILabel("Name: ", font_name=DEFAULT_FONT, font_size=24)) + row_1.add(UILabel("Username: ", font_name=DEFAULT_FONT, font_size=24)) name_input = row_1.add( UIInputText( width=400, @@ -274,6 +275,25 @@ def _show_text_widgets(self): border_width=2, ) ) + + row_2 = UIBoxLayout(vertical=False, size_hint=(1, 0.1)) + box.add(row_2) + row_2.add(UILabel("Password: ", font_name=DEFAULT_FONT, font_size=24)) + pw_input = row_2.add( + UIPasswordInput( + width=400, + height=40, + font_name=DEFAULT_FONT, + font_size=24, + border_color=arcade.uicolor.GRAY_CONCRETE, + border_width=2, + ) + ) + + @pw_input.event("on_change") + def on_text_change(event: UIOnChangeEvent): + event.source.invalid = event.new_value != "arcade" + welcome_label = box.add( UILabel("Nice to meet you ''", font_name=DEFAULT_FONT, font_size=24) ) @@ -618,4 +638,10 @@ def main(): if __name__ == "__main__": + import pyglet + + pyglet.options.text_antialiasing = False + pyglet.font.base.Font.texture_min_filter = pyglet.gl.GL_NEAREST + pyglet.font.base.Font.texture_mag_filter = pyglet.gl.GL_NEAREST + main() diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index cfa37a460a..12b9b237be 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -1,3 +1,8 @@ +import warnings +from copy import deepcopy +from dataclasses import dataclass +from typing import Union + import pyglet from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from pyglet.text.caret import Caret @@ -5,6 +10,7 @@ from typing_extensions import Literal, override import arcade +from arcade import uicolor from arcade.gui.events import ( UIEvent, UIMouseDragEvent, @@ -12,13 +18,15 @@ UIMousePressEvent, UIMouseScrollEvent, UIOnChangeEvent, + UIOnClickEvent, UITextInputEvent, UITextMotionEvent, UITextMotionSelectEvent, ) -from arcade.gui.property import bind +from arcade.gui.property import Property, bind +from arcade.gui.style import UIStyleBase, UIStyledWidget from arcade.gui.surface import Surface -from arcade.gui.widgets import UIWidget +from arcade.gui.widgets import UIInteractiveWidget, UIWidget from arcade.gui.widgets.layout import UIAnchorLayout from arcade.text import FontNameOrNames from arcade.types import LBWH, RGBA255, Color, RGBOrA255 @@ -399,7 +407,27 @@ def ui_label(self) -> UILabel: return self._label -class UIInputText(UIWidget): +@dataclass +class UIInputTextStyle(UIStyleBase): + """Used to style the UITextWidget for different states. Below is its use case. + + .. code:: py + + button = UIInputText(style={"normal": UIInputText.UIStyle(...),}) + + Args: + bg: Background color. + border: Border color. + border_width: Width of the border. + + """ + + bg: RGBA255 | None = None + border: RGBA255 | None = uicolor.WHITE + border_width: int = 2 + + +class UIInputText(UIStyledWidget[UIInputTextStyle], UIInteractiveWidget): """An input field the user can type text into. This is useful in returning @@ -432,9 +460,6 @@ class UIInputText(UIWidget): is the same thing as a :py:class:`~arcade.gui.UITextArea`. caret_color: An RGBA or RGB color for the caret with each channel between 0 and 255, inclusive. - border_color: An RGBA or RGB color for the border with each - channel between 0 and 255, inclusive, can be None to remove border. - border_width: Width of the border in pixels. size_hint: A tuple of floats between 0 and 1 defining the amount of space of the parent should be requested. size_hint_min: Minimum size hint width and height in pixel. @@ -447,13 +472,36 @@ class UIInputText(UIWidget): # position 0. LAYOUT_OFFSET = 1 + # Style + UIStyle = UIInputTextStyle + + DEFAULT_STYLE = { + "normal": UIStyle(), + "hover": UIStyle( + border=uicolor.WHITE_CLOUDS, + ), + "press": UIStyle( + border=uicolor.WHITE_SILVER, + ), + "disabled": UIStyle( + bg=uicolor.WHITE_SILVER, + ), + "invalid": UIStyle( + bg=uicolor.RED_ALIZARIN.replace(a=42), + border=uicolor.RED_ALIZARIN, + ), + } + + # Properties + invalid = Property(False) + def __init__( self, *, x: float = 0, y: float = 0, width: float = 100, - height: float = 23, # required height for font size 12 + border width 1 + height: float = 25, # required height for font size 12 + border width 1 text: str = "", font_name=("Arial",), font_size: float = 12, @@ -465,8 +513,24 @@ def __init__( size_hint=None, size_hint_min=None, size_hint_max=None, + style: Union[dict[str, UIInputTextStyle], None] = None, **kwargs, ): + if border_color != arcade.color.WHITE or border_width != 2: + warnings.warn( + "UIInputText is now a UIStyledWidget. " + "Use the style dict to set the border color and width.", + DeprecationWarning, + stacklevel=1, + ) + + # adjusting style to set border color and width + style = style or UIInputText.DEFAULT_STYLE + style = deepcopy(style) + + style["normal"].border = border_color + style["normal"].border_width = border_width + super().__init__( x=x, y=y, @@ -475,11 +539,10 @@ def __init__( size_hint=size_hint, size_hint_min=size_hint_min, size_hint_max=size_hint_max, + style=style or UIInputText.DEFAULT_STYLE, **kwargs, ) - self.with_border(color=border_color, width=border_width) - self._active = False self._text_color = Color.from_iterable(text_color) @@ -506,6 +569,44 @@ def __init__( self.register_event_type("on_change") + bind(self, "hovered", self._apply_style) + bind(self, "pressed", self._apply_style) + bind(self, "invalid", self._apply_style) + bind(self, "disabled", self._apply_style) + + # initial style application + self._apply_style() + + def _apply_style(self): + style = self.get_current_style() + + self.with_background( + color=Color.from_iterable(style.bg) if style.bg else None, + ) + self.with_border( + color=Color.from_iterable(style.border) if style.border else None, + width=style.border_width, + ) + self.trigger_full_render() + + @override + def get_current_state(self) -> str: + """Get the current state of the slider. + + Returns: + ""normal"", ""hover"", ""press"" or ""disabled"". + """ + if self.disabled: + return "disabled" + elif self.pressed: + return "press" + elif self.hovered: + return "hover" + elif self.invalid: + return "invalid" + else: + return "normal" + def _get_caret_blink_state(self): """Check whether or not the caret is currently blinking or not.""" return self.caret.visible and self._active and self.caret._blink_visible @@ -519,18 +620,14 @@ def on_update(self, dt): self._blink_state = current_state self.trigger_full_render() + def on_click(self, event: UIOnClickEvent): + self.activate() + @override def on_event(self, event: UIEvent) -> bool | None: """Handle events for the text input field. Text input is only active when the user clicks on the input field.""" - # If not active, check to activate, return - if not self._active and isinstance(event, UIMousePressEvent): - if self.rect.point_in_rect(event.pos): - self.activate() - # return unhandled to allow other widgets to deactivate - return EVENT_UNHANDLED - # If active check to deactivate if self._active and isinstance(event, UIMousePressEvent): if self.rect.point_in_rect(event.pos): @@ -571,10 +668,7 @@ def on_event(self, event: UIEvent) -> bool | None: if old_text != self.text: self.dispatch_event("on_change", UIOnChangeEvent(self, old_text, self.text)) - if super().on_event(event): - return EVENT_HANDLED - - return EVENT_UNHANDLED + return super().on_event(event) @property def active(self) -> bool: @@ -585,6 +679,9 @@ def active(self) -> bool: def activate(self): """Programmatically activate the text input field.""" + if self._active: + return + self._active = True self.trigger_full_render() self.caret.on_activate() @@ -592,6 +689,10 @@ def activate(self): def deactivate(self): """Programmatically deactivate the text input field.""" + + if not self._active: + return + self._active = False self.trigger_full_render() self.caret.on_deactivate() diff --git a/tests/unit/gui/test_uiinputtext.py b/tests/unit/gui/test_uiinputtext.py new file mode 100644 index 0000000000..bd321e2ce6 --- /dev/null +++ b/tests/unit/gui/test_uiinputtext.py @@ -0,0 +1,39 @@ +from arcade.gui import UIInputText + + +def test_activates_on_click(ui): + # GIVEN + it = UIInputText(height=30, width=120) + ui.add(it) + + assert it.active is False + + # WHEN + ui.click(*it.center) + + # THEN + assert it.active + + +def test_deactivates_on_click(ui): + # GIVEN + it = UIInputText(height=30, width=120) + ui.add(it) + it.activate() + + # WHEN + ui.click(*it.rect.top_left - (1, 0)) + + # THEN + assert it.active is False + + +def test_changes_state_invalid(ui): + # GIVEN + it = UIInputText(height=30, width=120) + + # WHEN + it.invalid = True + + # THEN + assert it.get_current_state() == "invalid" From 1e2445a6fdcbd2f65b0a3274a4bc6ffb0610c468 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 29 Mar 2025 23:44:05 +0100 Subject: [PATCH 108/279] fix linter --- arcade/examples/gui/2_widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/arcade/examples/gui/2_widgets.py b/arcade/examples/gui/2_widgets.py index 0596f92b33..71ee818522 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -291,7 +291,7 @@ def _show_text_widgets(self): ) @pw_input.event("on_change") - def on_text_change(event: UIOnChangeEvent): + def _(event: UIOnChangeEvent): event.source.invalid = event.new_value != "arcade" welcome_label = box.add( @@ -299,7 +299,7 @@ def on_text_change(event: UIOnChangeEvent): ) @name_input.event("on_change") - def on_text_change(event: UIOnChangeEvent): + def _(event: UIOnChangeEvent): welcome_label.text = f"Nice to meet you `{event.new_value}`" box.add(UISpace(size_hint=(1, 0.3))) # Fill some of the left space From d152d2b2eff6d7ecb781f599ab963663effe777f Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 30 Mar 2025 22:30:32 +0200 Subject: [PATCH 109/279] remove pyglet config from example --- arcade/examples/gui/2_widgets.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/arcade/examples/gui/2_widgets.py b/arcade/examples/gui/2_widgets.py index 71ee818522..7cf71828ce 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -638,10 +638,4 @@ def main(): if __name__ == "__main__": - import pyglet - - pyglet.options.text_antialiasing = False - pyglet.font.base.Font.texture_min_filter = pyglet.gl.GL_NEAREST - pyglet.font.base.Font.texture_mag_filter = pyglet.gl.GL_NEAREST - main() From 6f71d7b51eb851a457b9406c6613f9ba520c6388 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 30 Mar 2025 22:48:53 +0200 Subject: [PATCH 110/279] Update CHANGELOG.md --- CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f55a5af5..2648d706c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,13 +13,13 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Improved performance of `arcade.hitbox.Hitbox.get_adjusted_points()` ~35% - Improved performance of `arcade.SpriteList.draw_hit_boxes()` ~20x - GUI - - `arcade.gui.widgets.text.UIInputText` - - now supports styles for `normal`, `disabled`, `hovered`, `pressed` and `invalid` states - - provides a `invalid` property to indicate if the input is invalid - - Added experimental `arcade.gui.experimental.UIRestrictedInput` - a subclass of `UIInputText` that restricts the input to a specific set of characters - - `arcade.gui.NinePatchTexture` is now lazy and can be created before a window exists allowing creation during imports. - - Improve `arcade.gui.experimental.scroll_area.ScrollBar` behavior to match HTML scrollbars + - `arcade.gui.widgets.text.UIInputText` + - now supports styles for `normal`, `disabled`, `hovered`, `pressed` and `invalid` states + - provides a `invalid` property to indicate if the input is invalid + - Added experimental `arcade.gui.experimental.UIRestrictedInput` + a subclass of `UIInputText` that restricts the input to a specific set of characters + - `arcade.gui.NinePatchTexture` is now lazy and can be created before a window exists allowing creation during imports. + - Improve `arcade.gui.experimental.scroll_area.ScrollBar` behavior to match HTML scrollbars - Support drawing hitboxes using RBG or RGBA ## Version 3.0.2 From 7829a5f30a7fe68b44ac0d18cdb63b2917bb5d4a Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Mon, 31 Mar 2025 00:20:03 +0200 Subject: [PATCH 111/279] Documentation improvements (#2634) * refs in resources * platform_tutorial * pymunk_platformer * views * card_game * Notice in shader tutorials * menu tutorial * sections * spritelists * opengl_notes * resource_handlers * texture_atlas --- arcade/resources/__init__.py | 4 +-- doc/programming_guide/opengl_notes.rst | 2 +- doc/programming_guide/resource_handlers.rst | 2 +- doc/programming_guide/sections.rst | 2 +- doc/programming_guide/sprites/spritelists.rst | 2 +- doc/programming_guide/texture_atlas.rst | 2 +- doc/tutorials/card_game/index.rst | 2 +- doc/tutorials/menu/index.rst | 26 +++++++++---------- doc/tutorials/platform_tutorial/step_04.rst | 2 +- doc/tutorials/pymunk_platformer/index.rst | 16 ++++++------ doc/tutorials/shader_tutorials.rst | 8 ++++-- doc/tutorials/views/index.rst | 16 ++++++------ 12 files changed, 44 insertions(+), 40 deletions(-) diff --git a/arcade/resources/__init__.py b/arcade/resources/__init__.py index 4397312bc5..d14aff5229 100644 --- a/arcade/resources/__init__.py +++ b/arcade/resources/__init__.py @@ -36,7 +36,7 @@ def resolve_resource_path(path: str | Path) -> Path: If the path is a string it tries to resolve it as a resource handle or convert it to a Path object. - If the path is a Path object it will ``Path.resolve()`` it + If the path is a Path object it will :py:meth:`~pathlib.Path.resolve` it unless it's not absolute and return it. Example:: @@ -57,7 +57,7 @@ def resolve(path: str | Path) -> Path: If the path is a string it tries to resolve it as a resource handle or convert it to a Path object. - If the path is a Path object it will ``Path.resolve()`` it + If the path is a Path object it will :py:meth:`~pathlib.Path.resolve` it unless it's not absolute and return it. Example:: diff --git a/doc/programming_guide/opengl_notes.rst b/doc/programming_guide/opengl_notes.rst index 6e45987a4d..92217c59dc 100644 --- a/doc/programming_guide/opengl_notes.rst +++ b/doc/programming_guide/opengl_notes.rst @@ -91,7 +91,7 @@ SpriteList & Threads SpriteLists can be created in threads if they are created with the ``lazy=True`` parameters. This ensures OpenGL resources are not created until the -first ``draw()`` call or ``initialize()`` is called. +first :py:meth:`~arcade.SpriteList.draw()` call or :py:meth:`~arcade.SpriteList.initialize` is called. .. _prog-guide-gl-buffer-protocol-typing: diff --git a/doc/programming_guide/resource_handlers.rst b/doc/programming_guide/resource_handlers.rst index ea09b910e4..11428fca36 100644 --- a/doc/programming_guide/resource_handlers.rst +++ b/doc/programming_guide/resource_handlers.rst @@ -221,7 +221,7 @@ Import the Path Class Before Arcade """"""""""""""""""""""""""""""""""" To use :py:mod:`pathlib`, you usually only need to import -py:class:`~pathlib.Path` from it. +:py:class:`~pathlib.Path` from it. Since Python developers usually import built-ins before add-on libraries like Arcade, we'll do the same here: diff --git a/doc/programming_guide/sections.rst b/doc/programming_guide/sections.rst index 9b1ae7fdfd..e238921abd 100644 --- a/doc/programming_guide/sections.rst +++ b/doc/programming_guide/sections.rst @@ -228,7 +228,7 @@ Properties of a :class:`~arcade.Section`: Other handy :class:`~arcade.Section` properties: - block_updates: if True this section will not have the ``on_update`` method called. -- camera: this is meant to hold a ``arcade.Camera`` but it is None by default. The SectionManager will trigger the use of the camera when is needed automatically. +- camera: this is meant to hold a :py:class:`~arcade.Camera2D` but it is None by default. The SectionManager will trigger the use of the camera when is needed automatically. Handy :class:`~arcade.Section`: methods: diff --git a/doc/programming_guide/sprites/spritelists.rst b/doc/programming_guide/sprites/spritelists.rst index f9251ebada..cbef241341 100644 --- a/doc/programming_guide/sprites/spritelists.rst +++ b/doc/programming_guide/sprites/spritelists.rst @@ -12,7 +12,7 @@ Each sprite describes where a game object is & how to draw it. This includes: * Where to find the image data * How big the image should be -The rest of this page will explain using the ``SpriteList`` class to draw +The rest of this page will explain using the :py:class:`~arcade.SpriteList` class to draw sprites to the screen. diff --git a/doc/programming_guide/texture_atlas.rst b/doc/programming_guide/texture_atlas.rst index 9258a053d1..5e2da09e7d 100644 --- a/doc/programming_guide/texture_atlas.rst +++ b/doc/programming_guide/texture_atlas.rst @@ -55,7 +55,7 @@ Most users will not be aware that Arcade is using a texture atlas under the hood. More advanced users can take advantage of these if they run into limitations. -Arcade has a global default texture atlas stored in ``window.ctx.default_atlas``. +Arcade has a global default texture atlas stored in :py:attr:`arcade.Window.ctx.default_atlas`. This is an instance of :py:class:`arcade.ArcadeContext` where the low level rendering API is accessed (OpenGL). diff --git a/doc/tutorials/card_game/index.rst b/doc/tutorials/card_game/index.rst index 7c080d84fb..71157e4df4 100644 --- a/doc/tutorials/card_game/index.rst +++ b/doc/tutorials/card_game/index.rst @@ -51,7 +51,7 @@ Card Class ~~~~~~~~~~ Next up, we'll create a card class. The card class is a subclass of -``arcade.Sprite``. It will have attributes for the suit and value of the +:py:class:`~arcade.Sprite`. It will have attributes for the suit and value of the card, and auto-load the image for the card based on that. We'll use the entire image as the hit box, so we don't need to go through the diff --git a/doc/tutorials/menu/index.rst b/doc/tutorials/menu/index.rst index 27af7ae0fb..f90a307d60 100644 --- a/doc/tutorials/menu/index.rst +++ b/doc/tutorials/menu/index.rst @@ -46,14 +46,14 @@ Modify the MainView ~~~~~~~~~~~~~~~~~~~~ We are going to add a button to change the view. For drawing a button we would -need a ``UIManager``. +need an :py:class:`~arcade.gui.UIManager`. .. literalinclude:: menu_02.py :caption: Initialising the Manager :lines: 19-22 :emphasize-lines: 3 -After initialising the manager we need to enable it when the view is shown and +After initializing the manager we need to enable it when the view is shown and disable it when the view is hidden. .. literalinclude:: menu_02.py @@ -74,12 +74,12 @@ We also need to draw the children of the menu in ``on_draw``. :emphasize-lines: 7 Now we have successfully setup the manager, we can now add a button to the view. -We are using ``UIAnchorLayout`` to position the button. We also setup a function +We are using :py:class:`~arcade.gui.UIAnchorLayout` to position the button. We also setup a function which is called when the button is clicked. .. literalinclude:: menu_02.py :pyobject: MainView.__init__ - :caption: Initialising the Button + :caption: Initializing the Button :emphasize-lines: 8-12 Initialise the Menu View @@ -123,7 +123,7 @@ First we setup buttons for resume, starting a new game, volume, options and exit Displaying the Buttons in a Grid ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -After setting up the buttons we add them to ``UIGridLayout``, so that they can +After setting up the buttons we add them to :py:class:`~arcade.gui.UIGridLayout`, so that they can displayed in a grid like manner. .. literalinclude:: menu_03.py @@ -167,7 +167,7 @@ Adding ``on_click`` Callback for Volume and Options ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Now we need to implement an actual menu for volume and options, for that we have -to make a class that acts like a window. Using ``UIMouseFilterMixin`` we catch +to make a class that acts like a window. Using :py:class:`~arcade.gui.UIMouseFilterMixin` we catch all the events happening for the parent and respond nothing to them. Thus making it act like a window/view. @@ -221,7 +221,7 @@ Now you might be getting a little idea why we have edited the parameters but Adding a Title label -------------------- -We will be adding a ``UILabel`` that explains the menu. ``UISpace`` is a widget +We will be adding a :py:class:`~arcade.gui.UILabel` that explains the menu. :py:class:`~arcade.gui.UISpace` is a widget that can be used to add space around some widget, you can set its color to the background color so it appears invisible. @@ -236,10 +236,10 @@ Adding it to the widget layout. :lines: 238-239 -Adding a Input Field +Adding an Input Field ~~~~~~~~~~~~~~~~~~~~~ -We will use ``UIInputText`` to add an input field. The ``with_border()`` +We will use :py:class:`~arcade.gui.UIInputText` to add an input field. The :py:meth:`~arcade.gui.UIWidget.with_border` function creates a border around the widget with color(default argument is black) black and thickness(default argument is 2px) 2px. Add this just below the title label. @@ -263,7 +263,7 @@ in the last also for those of you who are skipping through this section :P. Adding a Toggle Button ~~~~~~~~~~~~~~~~~~~~~~ -Don't go on the section title much, in Arcade the ``UITextureToggle`` is not +Don't go on the section title much, in Arcade the :py:class:`~arcade.gui.UITextureToggle` is not really a button it switches between two textures when clicked. Yes, it functions like a button but by "is not really a button" we meant that it doesn't inherits the button class. We also pair it up horizontally with the @@ -283,7 +283,7 @@ field. Adding a Dropdown ~~~~~~~~~~~~~~~~~ -We add a dropdown by using ``UIDropdown``. +We add a dropdown by using :py:class:`~arcade.gui.UIDropdown`. .. literalinclude:: menu_05.py :caption: Adding dropdown @@ -298,9 +298,9 @@ Adding it to the widget layout. Adding a Slider ~~~~~~~~~~~~~~~ -The final widget. In Arcade you can use ``UISlider`` to implement a slider. +The final widget. In Arcade you can use :py:class:`~arcade.gui.UISlider` to implement a slider. Theres a functionality to style the slider, this is also present for -``UIFlatButton`` and ``UITextureButton``. +:py:class:`~arcade.gui.UIFlatButton` and :py:class:`~arcade.gui.UITextureButton`. .. literalinclude:: menu_05.py :caption: Adding slider diff --git a/doc/tutorials/platform_tutorial/step_04.rst b/doc/tutorials/platform_tutorial/step_04.rst index 96bd66e32e..29fd8d8597 100644 --- a/doc/tutorials/platform_tutorial/step_04.rst +++ b/doc/tutorials/platform_tutorial/step_04.rst @@ -46,7 +46,7 @@ functions, based on the key that was pressed or released, we will move our chara elif key == arcade.key.RIGHT or key == arcade.key.D: self.player_sprite.change_x = 0 -In these boxes, we are modifying the ``change_x`` and ``change_y`` attributes on our +In these boxes, we are modifying the :py:attr:`~arcade.Sprite.change_x` and :py:attr:`~arcade.Sprite.change_y` attributes on our player Sprite. Changing these values will not actually perform the move on the Sprite. In order to apply this change, we need to create a physics engine with our Sprite, and update the physics engine every frame. The physics engine will then be responsible diff --git a/doc/tutorials/pymunk_platformer/index.rst b/doc/tutorials/pymunk_platformer/index.rst index 4255867afe..1a83438565 100644 --- a/doc/tutorials/pymunk_platformer/index.rst +++ b/doc/tutorials/pymunk_platformer/index.rst @@ -88,7 +88,7 @@ lines of code like this: self.player_list: Optional[arcade.SpriteList] = None This means the ``player_list`` attribute is going to be an instance of -``SpriteList`` or ``None``. If you don't want to mess with typing, then +:py:class:`~arcade.SpriteList` or ``None``. If you don't want to mess with typing, then this code also works just as well: .. code-block:: @@ -121,7 +121,7 @@ If you aren't sure how to use the Tiled Map Editor, see :ref:`platformer_part_ei Now, in the ``setup`` function, we are going add code to: -* Create instances of ``SpriteList`` for each group of sprites we are doing +* Create instances of :py:class:`~arcade.SpriteList` for each group of sprites we are doing to work with. * Create the player sprite. * Read in the tiled map. @@ -298,7 +298,7 @@ Then we will adjust the left/right force depending on if we are grounded or not: Add Player Animation -------------------- -To create a player animation, we make a custom child class of ``Sprite``. +To create a player animation, we make a custom child class of :py:class:`~arcade.Sprite`. We load each frame of animation that we need, including a mirror image of it. We will flip the player to face left or right. If the player is in the air, we'll @@ -318,7 +318,7 @@ animation, so that the feet appear in-sync with the ground. :linenos: :lines: 58-66 -Next, we create a ``Player`` class that is a child to ``arcade.Sprite``. This +Next, we create a ``Player`` class that is a child to :py:class:`~arcade.Sprite`. This class will update the player animation. The ``__init__`` method loads all of the textures. Here we use Kenney.nl's @@ -327,8 +327,8 @@ It has six different characters you can choose from with the same layout, so it makes changing as simple as changing which line is enabled. There are eight textures for walking, and textures for idle, jumping, and falling. -As the character can face left or right, we use ``arcade.load_texture_pair`` -which will load both a regular image, and one that's mirrored. +As the character can face left or right, we prepare a standard and mirrored +version of each texture by using :py:meth:`~arcade.Texture.flip_left_right`. For the multi-frame walking animation, we use an "odometer." We need to move a certain number of pixels before changing the animation. If this value is too @@ -344,7 +344,7 @@ called. This can be used to update the animation. :linenos: :pyobject: PlayerSprite -Important! At this point, we are still creating an instance of ``arcade.Sprite`` +Important! At this point, we are still creating an instance of :py:class:`~arcade.Sprite` and **not** ``PlayerSprite``. We need to go back to the ``setup`` method and replace the line that creates the ``player`` instance with: @@ -434,7 +434,7 @@ If our y value is too low, we'll remove the bullet. :pyobject: BulletSprite And, of course, once we create the bullet we have to update our code to use -it instead of the plain ``arcade.Sprite`` class. +it instead of the plain :py:class:`~arcade.Sprite` class. .. literalinclude:: pymunk_demo_platformer_10.py :caption: Destroy Bullets - Bullet Sprite diff --git a/doc/tutorials/shader_tutorials.rst b/doc/tutorials/shader_tutorials.rst index 04564e5619..b18a8a7463 100644 --- a/doc/tutorials/shader_tutorials.rst +++ b/doc/tutorials/shader_tutorials.rst @@ -1,5 +1,5 @@ -Shaders -======= +Shaders - Shadertoy +=================== .. _tutorials_shaders: @@ -8,6 +8,10 @@ draw & shade objects. They offer power, flexibility, and efficiency far beyond what you could achieve using shapes or :py:class:`~arcade.Sprite` instances alone. The tutorials below serve as an introduction to shaders. +.. Note:: Note that "shadertoy" shaders is only a small subset of what is possible with + shaders. Shadertoy shaders only use the pixel shader and will do everything + in "screen space". There are other shaders using geometry and more generic compute + processing. Arcade supports these as well, but they are not covered in this tutorial. .. toctree:: :maxdepth: 1 diff --git a/doc/tutorials/views/index.rst b/doc/tutorials/views/index.rst index b50145a963..3cb47bf2c9 100644 --- a/doc/tutorials/views/index.rst +++ b/doc/tutorials/views/index.rst @@ -17,9 +17,9 @@ You can use this to support adding screens such as: * Game over screens * Pause screens -The ``View`` class is a lot like the ``Window`` class that you are already used -to. The ``View`` class has methods for ``on_update`` and ``on_draw`` just like -``Window``. We can change the current view to quickly change the code that is +The :py:class:`~arcade.View` class is a lot like the :py:class:`~arcade.Window` class that you are already used +to. The :py:class:`~arcade.View` class has methods for ``on_update`` and ``on_draw`` just like +:py:class:`~arcade.Window`. We can change the current view to quickly change the code that is managing what is drawn on the window and handling user input. If you know ahead of time you want to use views, you can build your code around @@ -44,14 +44,14 @@ class: class MyGame(arcade.Window): -Change it to derive from ``arcade.View`` instead of ``arcade.Window``. +Change it to derive from :py:class:`arcade.View` instead of :py:class:`arcade.Window`. I also suggest using "View" as part of the name: .. code-block:: python class GameView(arcade.View): -This will require a couple other updates. The ``View`` class does not control +This will require a couple other updates. The :py:class:`~arcade.View` class does not control the size of the window, so we'll need to take that out of the call to the parent class. Change: @@ -65,8 +65,8 @@ to: super().__init__() -The ``Window`` class still controls if the mouse is visible or not, so to hide -the mouse, we'll need to use the ``window`` attribute that is part of the ``View`` +The :py:class:`~arcade.Window` class still controls if the mouse is visible or not, so to hide +the mouse, we'll need to use the ``window`` attribute that is part of the :py:class:`~arcade.View` class. Change: .. code-block:: python @@ -108,7 +108,7 @@ it: class InstructionView(arcade.View): -Then we need to define the ``on_show_view`` method that will be run once when we +Then we need to define the :py:meth:`~arcade.View.on_show_view` method that will be run once when we switch to this view. In this case, we don't need to do much, just set the background color. If the game is one that scrolls, we'll also need to reset the viewport so that (0, 0) is back to the lower-left coordinate. From 11deaa7d7505330c4cb52427eb4d9e0b07ffdc2f Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Wed, 2 Apr 2025 22:31:04 +0200 Subject: [PATCH 112/279] Fix pushing keyboard and mouse to the same stack level (#2636) --- arcade/application.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/arcade/application.py b/arcade/application.py index edf55efb04..7b7e17297d 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -314,7 +314,8 @@ def __init__( else: self.mouse = pyglet.window.mouse.MouseStateHandler() - self.push_handlers(self.keyboard, self.mouse) + self.push_handlers(self.keyboard) + self.push_handlers(self.mouse) else: self.keyboard = None self.mouse = None From 85052e00b71ab78e5144539180d5d18c4795ff99 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Wed, 2 Apr 2025 22:35:31 +0200 Subject: [PATCH 113/279] Update CHANGELOG.md (#2637) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2648d706c2..f1f61a43da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - `arcade.gui.NinePatchTexture` is now lazy and can be created before a window exists allowing creation during imports. - Improve `arcade.gui.experimental.scroll_area.ScrollBar` behavior to match HTML scrollbars - Support drawing hitboxes using RBG or RGBA +- Fixed a bug causing some events to not trigger on the window's keyboard and mouse state handlers ## Version 3.0.2 From 23670cab3176dafb852ce8b0971da4c3efb435d4 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Wed, 2 Apr 2025 22:54:07 +0200 Subject: [PATCH 114/279] Update CHANGELOG.md (#2638) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f61a43da..8a7d7d5b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Drop Python 3.9 support - Disable shadow window on all platforms to provide a consistent experience - Performance - - Improved performance of `arcade.SpriteList.remove()` and `arcade.SpriteList.pop()` + - Improved performance of `arcade.SpriteList.remove()` ~300% + - Improved `arcade.SpriteList.pop()` performance making it `O(1)` instead of `O(N)` - Improved performance of `arcade.hitbox.Hitbox.get_adjusted_points()` ~35% - Improved performance of `arcade.SpriteList.draw_hit_boxes()` ~20x - GUI @@ -22,6 +23,8 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Improve `arcade.gui.experimental.scroll_area.ScrollBar` behavior to match HTML scrollbars - Support drawing hitboxes using RBG or RGBA - Fixed a bug causing some events to not trigger on the window's keyboard and mouse state handlers +- Many documenation fixes and improvements +- Various example fixes ## Version 3.0.2 From 69070533d42993896ee515108c9cb7697d2c1f4d Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Wed, 2 Apr 2025 23:07:20 +0200 Subject: [PATCH 115/279] Remove unnecessary debug print from NinePatch (#2639) --- arcade/gui/nine_patch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/arcade/gui/nine_patch.py b/arcade/gui/nine_patch.py index 4481f95423..0147d1e7e0 100644 --- a/arcade/gui/nine_patch.py +++ b/arcade/gui/nine_patch.py @@ -113,7 +113,6 @@ def _init_deferred(self): self._atlas = self._custom_atlas or self._ctx.default_atlas self._add_to_atlas(self.texture) - print("NinePatchTexture initialized") self._initialized = True def initialize(self) -> None: From 93d560384cf2b8d0de92ed85802a51a974f0b48b Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Wed, 2 Apr 2025 16:23:55 -0500 Subject: [PATCH 116/279] Update release version to 3.1.0 (#2640) Co-authored-by: Paul V Craven --- CHANGELOG.md | 2 +- arcade/VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7d7d5b5b..65ab8e0c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## Version 3.1 (unreleased) +## Version 3.1 - Drop Python 3.9 support - Disable shadow window on all platforms to provide a consistent experience diff --git a/arcade/VERSION b/arcade/VERSION index d9c62ed923..a0cd9f0ccb 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.0.2 \ No newline at end of file +3.1.0 \ No newline at end of file From 09a45313b81baa19335ed548d81878a0fd68ee30 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Wed, 2 Apr 2025 07:50:21 +0200 Subject: [PATCH 117/279] gui: allow setting layer and index in UIView.add_widget --- arcade/gui/ui_manager.py | 2 +- arcade/gui/view.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index ffa4c055b0..b97505ce3f 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -320,7 +320,7 @@ def on_update(self, time_delta): """Dispatches an update event to all widgets in the UIManager.""" return self.dispatch_ui_event(UIOnUpdateEvent(self, time_delta)) - def draw(self, pixelated=False) -> None: + def draw(self, **kwargs) -> None: """Will draw all widgets to the window. UIManager caches all rendered widgets into a framebuffer (something like a diff --git a/arcade/gui/view.py b/arcade/gui/view.py index 885f4587a1..7190952797 100644 --- a/arcade/gui/view.py +++ b/arcade/gui/view.py @@ -31,9 +31,15 @@ def __init__(self): The UIManager of this view. """ - def add_widget(self, widget: W) -> W: - """Add a widget to the UIManager of this view.""" - return self.ui.add(widget) + def add_widget(self, widget: W, *, index=None, layer=UIManager.DEFAULT_LAYER) -> W: + """Add a widget to the UIManager of this view. + + Args: + widget: widget to add + index: position a widget is added, None has the highest priority + layer: layer which the widget should be added to, higher layer are above + """ + return self.ui.add(widget, index=index, layer=layer) def on_show_view(self): """If subclassing UIView, don't forget to call super().on_show_view().""" From bb8cc8e9efc16e13a59e51e6a33187a66e50cf2a Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 3 Apr 2025 06:48:40 +0200 Subject: [PATCH 118/279] gui: fix UIScrollbar.add returning None --- arcade/gui/experimental/scroll_area.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index cc1bc55352..25b75d5d48 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable +from typing import Iterable, TypeVar from pyglet.event import EVENT_UNHANDLED @@ -23,6 +23,8 @@ ) from arcade.types import LBWH +W = TypeVar("W", bound="UIWidget") + class UIScrollBar(UIWidget): """Scroll bar for a UIScrollLayout. @@ -210,7 +212,7 @@ def __init__( y: float = 0, width: float = 300, height: float = 300, - children: Iterable["UIWidget"] = tuple(), + children: Iterable[UIWidget] = tuple(), size_hint=None, size_hint_min=None, size_hint_max=None, @@ -242,7 +244,7 @@ def __init__( bind(self, "scroll_x", self.trigger_full_render) bind(self, "scroll_y", self.trigger_full_render) - def add(self, child: "UIWidget", **kwargs): + def add(self, child: W, **kwargs) -> W: """Add a child to the widget.""" if self._children: raise ValueError("UIScrollArea can only have one child") @@ -250,7 +252,9 @@ def add(self, child: "UIWidget", **kwargs): super().add(child, **kwargs) self.trigger_full_render() - def remove(self, child: "UIWidget"): + return child + + def remove(self, child: UIWidget): """Remove a child from the widget.""" super().remove(child) self.trigger_full_render() From 0e06e13529dfb6e3a4bf932a70efbf8535c3c1ef Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 3 Apr 2025 06:52:26 +0200 Subject: [PATCH 119/279] add changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ab8e0c7d..a39aded817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. +## Version 3.1.1 (unreleased) + +- GUI + - Fix `UIScrollArea.add` always returning None + + ## Version 3.1 - Drop Python 3.9 support From 7e2131da249a95d28e70a42f0697336db9871116 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 3 Apr 2025 06:53:09 +0200 Subject: [PATCH 120/279] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a39aded817..d1998dc04e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - GUI - Fix `UIScrollArea.add` always returning None + - Support `layer` in `UIView.add_widget()` ## Version 3.1 From f926a818b1577abda5a7d82f599713276d373a5c Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Thu, 3 Apr 2025 19:20:48 +0200 Subject: [PATCH 121/279] Make arcade.Text lazy (#2642) * Make arcade.Text lazy * Fix size function return type * Try to fix size function return type * Make code lines shorter to fix typing errors * remove trailing whitespace and fix type of size function * Add the label property and make arguments private - Add the label property and use it in the code, if its not initialized, raise a RuntimeError. - Make the `arguments` and `kwargs` private, using _ * Fix label property errors * Fix docstring and formatting * Fix formatting * Only initialize label if its not already * Use a dict for both args and kwargs * Fix font_name typing issue * fix pyglet.text.Label typing issue --- arcade/text.py | 208 ++++++++++++++++++++++++++++--------------------- 1 file changed, 118 insertions(+), 90 deletions(-) diff --git a/arcade/text.py b/arcade/text.py index 9ab51b21df..0a521f83f6 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -276,54 +276,81 @@ def __init__( z: float = 0, **kwargs, ): - # Raises a RuntimeError if no window for better user feedback - arcade.get_window() - - if align not in ("left", "center", "right"): - raise ValueError("The 'align' parameter must be equal to 'left', 'right', or 'center'.") - - if multiline and not width: - raise ValueError( - f"The 'width' parameter must be set to a non-zero value when 'multiline' is True, " - f"but got {width!r}." - ) - - adjusted_font = _attempt_font_name_resolution(font_name) - - self._label = pyglet.text.Label( + self._initialized = False + self._arguments = dict( text=text, - # pyglet is lying about what it takes here and float is entirely valid - x=x, # type: ignore - y=y, # type: ignore - z=z, # type: ignore - font_name=adjusted_font, - # TODO: Fix this upstream (Mac & Linux seem to allow float) - font_size=font_size, # type: ignore - # use type: ignore since cast is slow & pyglet used Literal - anchor_x=anchor_x, # type: ignore - anchor_y=anchor_y, # type: ignore + x=x, + y=y, color=Color.from_iterable(color), + font_size=font_size, width=width, - align=align, # type: ignore + align=align, + font_name=font_name, weight=pyglet.text.Weight.BOLD if bold else pyglet.text.Weight.NORMAL, italic=italic, + anchor_x=anchor_x, + anchor_y=anchor_y, multiline=multiline, rotation=rotation, - # type: ignore # pending https://github.com/pyglet/pyglet/issues/843 batch=batch, group=group, + z=z, **kwargs, ) + if align not in ("left", "center", "right"): + raise ValueError("The 'align' parameter must be equal to 'left', 'right', or 'center'.") + + if multiline and not width: + raise ValueError( + f"The 'width' parameter must be set to a non-zero value when 'multiline' is True, " + f"but got {width!r}." + ) + + try: + self._init_deferred() + except Exception: + self._initialized = False + + @property + def label(self) -> pyglet.text.Label: + """ + The underlying pyglet.Label instance. + """ + if not self._initialized: + self._init_deferred() + return self._label + + def initialize(self) -> None: + """ + Manually initialize the Text if it was lazy loaded. + This has no effect if the Text was already initialized. + """ + if self._initialized: + return + self._init_deferred() + + def _init_deferred(self): + """ + Deferred initialization when lazy loaded + """ + + arcade.get_window() + + self._arguments["font_name"] = _attempt_font_name_resolution(self._arguments["font_name"]) # type: ignore + self._label = pyglet.text.Label(**self._arguments) # type: ignore + + self._initialized = True + def __enter__(self): """ Update multiple attributes of this text, using efficient update mechanism of the underlying ``pyglet.Label`` """ - self._label.begin_update() + self.label.begin_update() def __exit__(self, exc_type, exc_val, exc_tb): - self._label.end_update() + self.label.end_update() @property def batch(self) -> pyglet.graphics.Batch | None: @@ -331,11 +358,11 @@ def batch(self) -> pyglet.graphics.Batch | None: Can be unset by setting to ``None``. """ - return self._label.batch + return self.label.batch @batch.setter def batch(self, batch: pyglet.graphics.Batch): - self._label.batch = batch + self.label.batch = batch @property def group(self) -> pyglet.graphics.Group | None: @@ -346,11 +373,11 @@ def group(self) -> pyglet.graphics.Group | None: batching very large sets of text needing to separate into groups or even mix with other pyglet batch content. """ - return self._label.group + return self.label.group @group.setter def group(self, group: pyglet.graphics.Group): - self._label.group = group + self.label.group = group @property def value(self) -> str: @@ -359,14 +386,14 @@ def value(self) -> str: The value assigned will be converted to a string. """ - return self._label.text + return self.label.text @value.setter def value(self, value: Any): value = str(value) - if self._label.text == value: + if self.label.text == value: return - self._label.text = value + self.label.text = value @property def text(self) -> str: @@ -377,71 +404,71 @@ def text(self) -> str: This is an alias for :py:attr:`~arcade.Text.value` """ - return self._label.text + return self.label.text @text.setter def text(self, value: Any): value = str(value) - if self._label.text == value: + if self.label.text == value: return - self._label.text = value + self.label.text = value @property def x(self) -> float: """Get or set the x position of the label.""" - return self._label.x + return self.label.x @x.setter def x(self, x: float) -> None: - if self._label.x == x: + if self.label.x == x: return - self._label.x = x + self.label.x = x @property def y(self) -> float: """Get or set the y position of the label.""" - return self._label.y + return self.label.y @y.setter def y(self, y: float): - if self._label.y == y: + if self.label.y == y: return - self._label.y = y + self.label.y = y @property def z(self) -> float: """Get or set the z position of the label.""" - return self._label.z + return self.label.z @z.setter def z(self, z: float): - if self._label.z == z: + if self.label.z == z: return - self._label.z = z + self.label.z = z @property def font_name(self) -> FontNameOrNames: """Get or set the font name(s) for the label.""" - if not isinstance(self._label.font_name, str): - return tuple(self._label.font_name) + if not isinstance(self.label.font_name, str): + return tuple(self.label.font_name) else: - return self._label.font_name + return self.label.font_name @font_name.setter def font_name(self, font_name: FontNameOrNames) -> None: if isinstance(font_name, str): - self._label.font_name = font_name + self.label.font_name = font_name else: - self._label.font_name = list(font_name) + self.label.font_name = list(font_name) @property def font_size(self) -> float: """Get or set the font size of the label.""" - return self._label.font_size + return self.label.font_size @font_size.setter def font_size(self, font_size: float): - self._label.font_size = font_size + self.label.font_size = font_size @property def anchor_x(self) -> str: @@ -450,11 +477,11 @@ def anchor_x(self) -> str: Options: ``"left"``, ``"center"``, or ``"right"`` """ - return self._label.anchor_x + return self.label.anchor_x @anchor_x.setter def anchor_x(self, anchor_x: str): - self._label.anchor_x = anchor_x # type: ignore + self.label.anchor_x = anchor_x # type: ignore @property def anchor_y(self) -> str: @@ -463,29 +490,29 @@ def anchor_y(self) -> str: Options : ``"top"``, ``"bottom"``, ``"center"``, or ``"baseline"`` """ - return self._label.anchor_y + return self.label.anchor_y @anchor_y.setter def anchor_y(self, anchor_y: str): - self._label.anchor_y = anchor_y # type: ignore + self.label.anchor_y = anchor_y # type: ignore @property def rotation(self) -> float: """Get or set the clockwise rotation""" - return self._label.rotation + return self.label.rotation @rotation.setter def rotation(self, rotation: float): - self._label.rotation = rotation + self.label.rotation = rotation @property def color(self) -> Color: """Get or set the text color for the label.""" - return Color.from_iterable(self._label.color) + return Color.from_iterable(self.label.color) @color.setter def color(self, color: RGBOrA255): - self._label.color = Color.from_iterable(color) + self.label.color = Color.from_iterable(color) @property def width(self) -> int | None: @@ -496,11 +523,11 @@ def width(self) -> int | None: If you are looking for the physical size if the text, see :py:attr:`~arcade.Text.content_width` """ - return self._label.width + return self.label.width @width.setter def width(self, width: int): - self._label.width = width + self.label.width = width @property def height(self) -> int | None: @@ -511,51 +538,51 @@ def height(self) -> int | None: If you are looking for the physical size if the text, see :py:attr:`~arcade.Text.content_height` """ - return self._label.height + return self.label.height @height.setter def height(self, value: int): - self._label.height = value + self.label.height = value @property def size(self): """Get the size of the label.""" - return self._label.width, self._label.height + return self.label.width, self.label.height @property def content_width(self) -> int: """Get the pixel width of the text contents.""" - return self._label.content_width + return self.label.content_width @property def content_height(self) -> int: """Get the pixel height of the text content.""" - return self._label.content_height + return self.label.content_height @property def left(self) -> float: """Pixel location of the left content border.""" - return self._label.left + return self.label.left @property def right(self) -> float: """Pixel location of the right content border.""" - return self._label.right + return self.label.right @property def top(self) -> float: """Pixel location of the top content border.""" - return self._label.top + return self.label.top @property def bottom(self) -> float: """Pixel location of the bottom content border.""" - return self._label.bottom + return self.label.bottom @property def content_size(self) -> tuple[int, int]: """Get the pixel width and height of the text contents.""" - return self._label.content_width, self._label.content_height + return self.label.content_width, self.label.content_height @property def align(self) -> str: @@ -563,11 +590,11 @@ def align(self) -> str: Valid options: ``"left"``, ``"center"``, ``"right"``. """ - return self._label.get_style("align") # type: ignore + return self.label.get_style("align") # type: ignore @align.setter def align(self, align: str): - self._label.set_style("align", align) + self.label.set_style("align", align) @property def bold(self) -> bool | str: @@ -583,29 +610,29 @@ def bold(self) -> bool | str: * ``"light"`` """ - return self._label.weight == pyglet.text.Weight.BOLD + return self.label.weight == pyglet.text.Weight.BOLD @bold.setter def bold(self, bold: bool | str): - self._label.weight = pyglet.text.Weight.BOLD if bold else pyglet.text.Weight.NORMAL + self.label.weight = pyglet.text.Weight.BOLD if bold else pyglet.text.Weight.NORMAL @property def italic(self) -> bool | str: """Get or set the italic state of the label.""" - return self._label.italic + return self.label.italic @italic.setter def italic(self, italic: bool | str): - self._label.italic = italic + self.label.italic = italic @property def multiline(self) -> bool: """Get or set the multiline flag of the label.""" - return self._label.multiline + return self.label.multiline @multiline.setter def multiline(self, multiline: bool): - self._label.multiline = multiline + self.label.multiline = multiline def draw(self) -> None: """ @@ -618,7 +645,8 @@ def draw(self) -> None: instance. For information on how to do this, see :ref:`sprite_move_scrolling`. """ - _draw_pyglet_label(self._label) + self._init_deferred() + _draw_pyglet_label(self.label) def draw_debug( self, @@ -649,7 +677,7 @@ def draw_debug( # Draw anchor arcade.draw_point(self.x, self.y, color=anchor_color, size=6) - _draw_pyglet_label(self._label) + _draw_pyglet_label(self.label) @property def position(self) -> Point: @@ -659,7 +687,7 @@ def position(self) -> Point: This is faster than setting x and y position separately because the underlying geometry only needs to change position once. """ - return self._label.x, self._label.y + return self.label.x, self.label.y @position.setter def position(self, point: Point): @@ -667,9 +695,9 @@ def position(self, point: Point): x, y, *z = point if z: - self._label.position = x, y, z[0] + self.label.position = x, y, z[0] else: - self._label.position = x, y, self._label.z + self.label.position = x, y, self.label.z @property def tracking(self) -> float | None: @@ -683,12 +711,12 @@ def tracking(self) -> float | None: Returns: a pixel amount, or None if the tracking is inconsistent. """ - kerning = self._label.get_style("kerning") + kerning = self.label.get_style("kerning") return kerning if kerning != pyglet.text.document.STYLE_INDETERMINATE else None @tracking.setter def tracking(self, value: float): - self._label.set_style("kerning", value) + self.label.set_style("kerning", value) def em_to_px(self, em: float) -> float: """Convert from an em value to a pixel amount. From 4f940df193533f1cdc5db4d58efef64fbb5b332a Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Thu, 3 Apr 2025 20:59:49 +0200 Subject: [PATCH 122/279] Update CHANGELOG.md (#2643) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ab8e0c7d..845c220903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,11 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. +## Version 3.1.1 -## Version 3.1 +* Text objects are now lazy and can be created before the window + +## Version 3.1.0 - Drop Python 3.9 support - Disable shadow window on all platforms to provide a consistent experience From 037e85d13a0716263cd13ff6076de6bc39972f28 Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Thu, 3 Apr 2025 23:01:08 +0200 Subject: [PATCH 123/279] Fix lazy text objects re-initializing multiple times (#2644) * Fix draw functions not initializing correctly - draw_debug was not calling init_deferred at all - draw was not checking if it was initialized already, making it very slow * Fix formatting * Remove unneeded init_deferred from draw functions --- arcade/text.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/arcade/text.py b/arcade/text.py index 0a521f83f6..fef9a0d800 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -276,7 +276,6 @@ def __init__( z: float = 0, **kwargs, ): - self._initialized = False self._arguments = dict( text=text, x=x, @@ -645,7 +644,6 @@ def draw(self) -> None: instance. For information on how to do this, see :ref:`sprite_move_scrolling`. """ - self._init_deferred() _draw_pyglet_label(self.label) def draw_debug( From 94c42806cf63df636e2ae6eb2e8a0ceb25e31225 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Thu, 3 Apr 2025 23:14:53 +0200 Subject: [PATCH 124/279] Minor Text tweak (#2645) --- arcade/text.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/arcade/text.py b/arcade/text.py index fef9a0d800..896b9c1399 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -306,10 +306,11 @@ def __init__( f"but got {width!r}." ) + self._initialized = False try: self._init_deferred() except Exception: - self._initialized = False + pass @property def label(self) -> pyglet.text.Label: @@ -333,12 +334,11 @@ def _init_deferred(self): """ Deferred initialization when lazy loaded """ - + # NOTE: Give the user a clear error message stating that the window is not created yet arcade.get_window() self._arguments["font_name"] = _attempt_font_name_resolution(self._arguments["font_name"]) # type: ignore self._label = pyglet.text.Label(**self._arguments) # type: ignore - self._initialized = True def __enter__(self): From 7f7fe955e7ebf234af0d27956d58f225337f6af5 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 8 Apr 2025 22:40:47 +0200 Subject: [PATCH 125/279] update pre-commit hooks --- .pre-commit-config.yaml | 5 ++++- arcade/gl/texture.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 953a84a4e6..e0052c5d77 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,8 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.10 + # Ruff version. + rev: v0.11.4 hooks: # Run the linter. - id: ruff @@ -21,7 +22,9 @@ repos: hooks: - id: mypy args: [ --explicit-package-bases ] + language: system - repo: https://github.com/RobertCraigie/pyright-python rev: v1.1.396 hooks: - id: pyright + language: system diff --git a/arcade/gl/texture.py b/arcade/gl/texture.py index 479fa23ce8..fd88e6217d 100644 --- a/arcade/gl/texture.py +++ b/arcade/gl/texture.py @@ -136,8 +136,8 @@ def __init__( self._component_size = 0 self._alignment = 1 self._target = target - self._samples = min(max(0, samples), self._ctx.info.MAX_SAMPLES) - self._depth = depth + self._samples: int = min(max(0, samples), self._ctx.info.MAX_SAMPLES) + self._depth: bool = depth self._immutable = immutable self._compare_func: str | None = None self._anisotropy = 1.0 From 66fef225d4774c45af76f57f14b7a9767fec8e79 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 8 Apr 2025 22:46:02 +0200 Subject: [PATCH 126/279] gui: replace experimental UIControllerBridge with experimental ControllerWindow --- arcade/examples/gui/exp_controller_support.py | 16 +- .../gui/exp_controller_support_grid.py | 10 +- arcade/gui/events.py | 75 ++++++++ arcade/gui/experimental/controller.py | 161 ------------------ arcade/gui/experimental/focus.py | 2 +- arcade/gui/ui_manager.py | 2 +- arcade/gui/widgets/dropdown.py | 2 +- 7 files changed, 88 insertions(+), 180 deletions(-) delete mode 100644 arcade/gui/experimental/controller.py diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py index 07fc8a9af4..f85b207fbd 100644 --- a/arcade/examples/gui/exp_controller_support.py +++ b/arcade/examples/gui/exp_controller_support.py @@ -13,6 +13,7 @@ import arcade from arcade import Texture +from arcade.experimental.controller_window import ControllerWindow, ControllerView from arcade.gui import ( UIAnchorLayout, UIBoxLayout, @@ -26,8 +27,7 @@ UISlider, UIView, ) -from arcade.gui.experimental.controller import ( - UIControllerBridge, +from arcade.gui.events import ( UIControllerButtonEvent, UIControllerButtonPressEvent, UIControllerDpadEvent, @@ -138,9 +138,9 @@ def __init__(self): root = self.add(UIBoxLayout(space_between=10)) - root.add(UIFlatButton(text="Modal Button 1")) - root.add(UIFlatButton(text="Modal Button 2")) - root.add(UIFlatButton(text="Modal Button 3")) + root.add(UIFlatButton(text="Modal Button 1", width=200)) + root.add(UIFlatButton(text="Modal Button 2", width=200)) + root.add(UIFlatButton(text="Modal Button 3", width=200)) root.add(UIFlatButton(text="Close")).on_click = self.close self.detect_focusable_widgets() @@ -163,13 +163,11 @@ def close(self, event): self.parent.remove(self) -class MyView(UIView): +class MyView(ControllerView, UIView): def __init__(self): super().__init__() arcade.set_background_color(arcade.color.AMAZON) - self.controller_bridge = UIControllerBridge(self.ui) - base = self.add_widget(ControllerIndicator()) self.root = base.add(UIFocusGroup()) self.root.with_padding(left=10) @@ -195,6 +193,6 @@ def on_button_click(self, event: UIOnClickEvent): if __name__ == "__main__": - window = arcade.Window(title="Controller UI Example") + window = ControllerWindow(title="Controller UI Example") window.show_view(MyView()) arcade.run() diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py index 4fd8eaa95b..dfef1ca0d8 100644 --- a/arcade/examples/gui/exp_controller_support_grid.py +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -13,15 +13,13 @@ import arcade from arcade.examples.gui.exp_controller_support import ControllerIndicator +from arcade.experimental.controller_window import ControllerView, ControllerWindow from arcade.gui import ( UIFlatButton, UIGridLayout, UIView, UIWidget, ) -from arcade.gui.experimental.controller import ( - UIControllerBridge, -) from arcade.gui.experimental.focus import Focusable, UIFocusGroup @@ -69,13 +67,11 @@ def setup_grid_focus_transition(grid: Dict[Tuple[int, int], UIWidget]): btn.neighbor_down = grid.get((c, 0)) -class MyView(UIView): +class MyView(ControllerView, UIView): def __init__(self): super().__init__() arcade.set_background_color(arcade.color.AMAZON) - self.controller_bridge = UIControllerBridge(self.ui) - self.root = self.add_widget(ControllerIndicator()) self.root = self.root.add(UIFocusGroup()) grid = self.root.add( @@ -95,6 +91,6 @@ def __init__(self): if __name__ == "__main__": - window = arcade.Window(title="Controller UI Example") + window = ControllerWindow(title="Controller UI Example") window.show_view(MyView()) arcade.run() diff --git a/arcade/gui/events.py b/arcade/gui/events.py index a150d1ce5a..d5ca7df207 100644 --- a/arcade/gui/events.py +++ b/arcade/gui/events.py @@ -236,3 +236,78 @@ class UIOnActionEvent(UIEvent): """ action: Any + + +@dataclass +class UIControllerEvent(UIEvent): + """Base class for all UI controller events. + + Args: + source: The controller that triggered the event. + """ + + +@dataclass +class UIControllerStickEvent(UIControllerEvent): + """Triggered when a controller stick is moved. + + Args: + name: The name of the stick. + vector: The value of the stick. + """ + + name: str + vector: Vec2 + + +@dataclass +class UIControllerTriggerEvent(UIControllerEvent): + """Triggered when a controller trigger is moved. + + Args: + name: The name of the trigger. + value: The value of the trigger. + """ + + name: str + value: float + + +@dataclass +class UIControllerButtonEvent(UIControllerEvent): + """Triggered when a controller button used. + + Args: + button: The name of the button. + """ + + button: str + + +@dataclass +class UIControllerButtonPressEvent(UIControllerButtonEvent): + """Triggered when a controller button is pressed. + + Args: + button: The name of the button. + """ + + +@dataclass +class UIControllerButtonReleaseEvent(UIControllerButtonEvent): + """Triggered when a controller button is released. + + Args: + button: The name of the button. + """ + + +@dataclass +class UIControllerDpadEvent(UIControllerEvent): + """Triggered when a controller dpad is moved. + + Args: + vector: The value of the dpad. + """ + + vector: Vec2 diff --git a/arcade/gui/experimental/controller.py b/arcade/gui/experimental/controller.py deleted file mode 100644 index c81e778b4f..0000000000 --- a/arcade/gui/experimental/controller.py +++ /dev/null @@ -1,161 +0,0 @@ -import warnings -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from pyglet.input import Controller -from pyglet.math import Vec2 - -from arcade import ControllerManager -from arcade.gui.events import UIEvent - -if TYPE_CHECKING: - from arcade.gui.ui_manager import UIManager - - -@dataclass -class UIControllerEvent(UIEvent): - """Base class for all UI controller events. - - Args: - source: The controller that triggered the event. - """ - - -@dataclass -class UIControllerStickEvent(UIControllerEvent): - """Triggered when a controller stick is moved. - - Args: - name: The name of the stick. - vector: The value of the stick. - """ - - name: str - vector: Vec2 - - -@dataclass -class UIControllerTriggerEvent(UIControllerEvent): - """Triggered when a controller trigger is moved. - - Args: - name: The name of the trigger. - value: The value of the trigger. - """ - - name: str - value: float - - -@dataclass -class UIControllerButtonEvent(UIControllerEvent): - """Triggered when a controller button used. - - Args: - button: The name of the button. - """ - - button: str - - -@dataclass -class UIControllerButtonPressEvent(UIControllerButtonEvent): - """Triggered when a controller button is pressed. - - Args: - button: The name of the button. - """ - - -@dataclass -class UIControllerButtonReleaseEvent(UIControllerButtonEvent): - """Triggered when a controller button is released. - - Args: - button: The name of the button. - """ - - -@dataclass -class UIControllerDpadEvent(UIControllerEvent): - """Triggered when a controller dpad is moved. - - Args: - vector: The value of the dpad. - """ - - vector: Vec2 - - -class _ControllerListener: - """Interface for listening to controller events""" - - def on_stick_motion(self, controller: Controller, name: str, value: Vec2): - pass - - def on_trigger_motion(self, controller: Controller, name: str, value: float): - pass - - def on_button_press(self, controller: Controller, button_name: str): - pass - - def on_button_release(self, controller: Controller, button_name: str): - pass - - def on_dpad_motion(self, controller: Controller, value: Vec2): - pass - - -class UIControllerBridge(_ControllerListener): - """Translates controller events to UIEvents and passes them to the UIManager. - - Controller are automatically connected and disconnected. - - Controller events are consumed by the UIControllerBridge, - if the UIEvent is consumed by the UIManager. - - This implicates, that the UIControllerBridge should be the first listener in the chain and - that other systems should be aware, when not to act on events (like when the UI is active). - """ - - def __init__(self, ui: "UIManager"): - self.ui = ui - self.cm = ControllerManager() - - self.cm.push_handlers(self) - # bind to existing controllers - for controller in self.cm.get_controllers(): - print("Controller connected", controller) - self.on_connect(controller) - - def on_connect(self, controller: Controller): - controller.push_handlers(self) - - try: - controller.open() - except Exception as e: - warnings.warn(f"Failed to open controller {controller}: {e}") - - def on_disconnect(self, controller: Controller): - controller.remove_handlers(self) - - try: - controller.close() - except Exception as e: - warnings.warn(f"Failed to close controller {controller}: {e}") - - # Controller event mapping - def on_stick_motion(self, controller: Controller, name, value): - return self.ui.dispatch_ui_event(UIControllerStickEvent(controller, name, value)) - - def on_trigger_motion(self, controller: Controller, name, value): - return self.ui.dispatch_ui_event(UIControllerTriggerEvent(controller, name, value)) - - def on_button_press(self, controller: Controller, button): - return self.ui.dispatch_ui_event(UIControllerButtonPressEvent(controller, button)) - - def on_button_release(self, controller: Controller, button): - return self.ui.dispatch_ui_event(UIControllerButtonReleaseEvent(controller, button)) - - def on_dpad_motion(self, controller: Controller, value): - return self.ui.dispatch_ui_event(UIControllerDpadEvent(controller, value)) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index fe5ce58910..6da1a309c0 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -13,7 +13,7 @@ UIMousePressEvent, UIMouseReleaseEvent, ) -from arcade.gui.experimental.controller import ( +from arcade.gui.events import ( UIControllerButtonPressEvent, UIControllerButtonReleaseEvent, UIControllerDpadEvent, diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 463286b2e3..3521806810 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -31,7 +31,7 @@ UITextMotionEvent, UITextMotionSelectEvent, ) -from arcade.gui.experimental.controller import ( +from arcade.gui.events import ( UIControllerButtonPressEvent, UIControllerButtonReleaseEvent, UIControllerDpadEvent, diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index 14e264511d..0c7693d618 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -7,7 +7,7 @@ from arcade import uicolor from arcade.gui import UIEvent, UIMousePressEvent from arcade.gui.events import UIOnChangeEvent, UIOnClickEvent -from arcade.gui.experimental.controller import UIControllerButtonPressEvent +from arcade.gui.events import UIControllerButtonPressEvent from arcade.gui.experimental.focus import UIFocusMixin from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UILayout, UIWidget From 22b130da277e6c2ecc34533397e4bc8480c2ecc4 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 11 Apr 2025 22:57:30 +0200 Subject: [PATCH 127/279] gui: fix example --- arcade/examples/gui/exp_inventory_demo.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/arcade/examples/gui/exp_inventory_demo.py b/arcade/examples/gui/exp_inventory_demo.py index 86faf0ff92..05f68c1c90 100644 --- a/arcade/examples/gui/exp_inventory_demo.py +++ b/arcade/examples/gui/exp_inventory_demo.py @@ -15,19 +15,20 @@ """ from functools import partial -# TODO: Drag and Drop +# TODO: Drag and Drop from typing import List import pyglet.font from pyglet.gl import GL_NEAREST import arcade -from arcade import Rect, open_window +from arcade import Rect from arcade.examples.gui.exp_controller_support_grid import ( ControllerIndicator, setup_grid_focus_transition, ) +from arcade.experimental.controller_window import ControllerWindow from arcade.gui import ( Property, Surface, @@ -41,7 +42,6 @@ UIWidget, bind, ) -from arcade.gui.experimental.controller import UIControllerBridge from arcade.gui.experimental.focus import Focusable, UIFocusGroup from arcade.resources import load_kenney_fonts @@ -359,8 +359,6 @@ class MyView(UIView): def __init__(self): super().__init__() - self.cb = UIControllerBridge(self.ui) - self.background_color = arcade.color.BLACK self.inventory = Inventory(30) @@ -397,7 +395,7 @@ def on_draw_before_ui(self): load_kenney_fonts() - open_window(window_title="Minimal example", width=1280, height=720, resizable=True).show_view( + ControllerWindow(title="Minimal example", width=1280, height=720, resizable=True).show_view( MyView() ) arcade.run() From 2e455be7a75170192a74549517cbdc9bd988780d Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 11 Apr 2025 23:13:51 +0200 Subject: [PATCH 128/279] gui: fix exp_inventory_demo.py --- arcade/examples/gui/exp_inventory_demo.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/arcade/examples/gui/exp_inventory_demo.py b/arcade/examples/gui/exp_inventory_demo.py index 05f68c1c90..7afd24e6b7 100644 --- a/arcade/examples/gui/exp_inventory_demo.py +++ b/arcade/examples/gui/exp_inventory_demo.py @@ -14,8 +14,6 @@ python -m arcade.examples.gui.exp_inventory_demo """ -from functools import partial - # TODO: Drag and Drop from typing import List @@ -238,13 +236,13 @@ def __init__(self, **kwargs): equipment = Equipment() self.head_slot = self.add(EquipmentSlotUI(equipment, 0)) - self.head_slot.on_click = partial(self.dispatch_event, "on_slot_clicked", self.head_slot) + self.head_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.head_slot) self.chest_slot = self.add(EquipmentSlotUI(equipment, 1)) - self.chest_slot.on_click = partial(self.dispatch_event, "on_slot_clicked", self.chest_slot) + self.chest_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.chest_slot) self.legs_slot = self.add(EquipmentSlotUI(equipment, 2)) - self.legs_slot.on_click = partial(self.dispatch_event, "on_slot_clicked", self.legs_slot) + self.legs_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.legs_slot) EquipmentUI.register_event_type("on_slot_clicked") From 23bcfc2137adc487c89867f403c2fae02fc9029d Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 15 Apr 2025 20:40:47 +0200 Subject: [PATCH 129/279] gui: fix formating --- arcade/gui/experimental/focus.py | 8 +++----- arcade/gui/ui_manager.py | 12 +++++------- arcade/gui/widgets/dropdown.py | 3 +-- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 6da1a309c0..c96d81551a 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -7,17 +7,15 @@ import arcade from arcade import LBWH, MOUSE_BUTTON_LEFT from arcade.gui.events import ( + UIControllerButtonPressEvent, + UIControllerButtonReleaseEvent, + UIControllerDpadEvent, UIEvent, UIKeyPressEvent, UIKeyReleaseEvent, UIMousePressEvent, UIMouseReleaseEvent, ) -from arcade.gui.events import ( - UIControllerButtonPressEvent, - UIControllerButtonReleaseEvent, - UIControllerDpadEvent, -) from arcade.gui.property import ListProperty, Property, bind from arcade.gui.surface import Surface from arcade.gui.ui_manager import UIManager diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 3521806810..c881d46802 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -19,6 +19,11 @@ from arcade.experimental.controller_window import ControllerWindow from arcade.gui import UIEvent from arcade.gui.events import ( + UIControllerButtonPressEvent, + UIControllerButtonReleaseEvent, + UIControllerDpadEvent, + UIControllerStickEvent, + UIControllerTriggerEvent, UIKeyPressEvent, UIKeyReleaseEvent, UIMouseDragEvent, @@ -31,13 +36,6 @@ UITextMotionEvent, UITextMotionSelectEvent, ) -from arcade.gui.events import ( - UIControllerButtonPressEvent, - UIControllerButtonReleaseEvent, - UIControllerDpadEvent, - UIControllerStickEvent, - UIControllerTriggerEvent, -) from arcade.gui.surface import Surface from arcade.gui.widgets import UIWidget from arcade.types import LBWH, AnchorPoint, Point2, Rect diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index 0c7693d618..a647423c3f 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -6,8 +6,7 @@ import arcade from arcade import uicolor from arcade.gui import UIEvent, UIMousePressEvent -from arcade.gui.events import UIOnChangeEvent, UIOnClickEvent -from arcade.gui.events import UIControllerButtonPressEvent +from arcade.gui.events import UIControllerButtonPressEvent, UIOnChangeEvent, UIOnClickEvent from arcade.gui.experimental.focus import UIFocusMixin from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UILayout, UIWidget From 4a3621a156188fd50d1e4eb7b6698a7a5175e7bc Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 15 Apr 2025 20:51:02 +0200 Subject: [PATCH 130/279] fix line length test raises on >=100 instead >100 --- tests/integration/examples/test_line_lengths.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/examples/test_line_lengths.py b/tests/integration/examples/test_line_lengths.py index 50b65d42a2..5682c61539 100644 --- a/tests/integration/examples/test_line_lengths.py +++ b/tests/integration/examples/test_line_lengths.py @@ -1,6 +1,6 @@ """ Examples should never exceed a certain line length to ensure readability -in the documentation. The source code gets clipped after 90 ish characters. +in the documentation. The source code gets clipped after 100 ish characters. Adapted from util/check_example_line_length.py """ @@ -29,7 +29,7 @@ def is_ignored(path: Path): def test_line_lengths(): paths = EXAMPLE_ROOT.glob("**/*.py") - regex = re.compile("^.{100}.*$") + regex = re.compile("^.{100}.+$") grand_total = 0 file_count = 0 @@ -42,7 +42,7 @@ def test_line_lengths(): with open(path, encoding="utf8") as f: for line in f: line_no += 1 - result = regex.search(line.strip("\r")) + result = regex.search(line.strip("\r").strip("\n")) if result: print(f" {path.relative_to(EXAMPLE_ROOT)}:{line_no}: " + line.strip()) grand_total += 1 From 5e7b63a143d7ba99b0448ff23e703dea0965df95 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 19 Apr 2025 22:07:28 +0200 Subject: [PATCH 131/279] gui: controller support - add connect/disconnect callbacks - update inventory example - UIFlatButton and UITexturedButton show hover style - Add FocusMode to UIWidget, to control focus behaviour --- ...ry_demo.py => exp_controller_inventory.py} | 62 +++-- arcade/gui/events.py | 18 ++ arcade/gui/experimental/focus.py | 229 ++++++++---------- arcade/gui/ui_manager.py | 14 ++ arcade/gui/widgets/__init__.py | 52 +++- arcade/gui/widgets/buttons.py | 4 +- arcade/gui/widgets/dropdown.py | 12 +- 7 files changed, 235 insertions(+), 156 deletions(-) rename arcade/examples/gui/{exp_inventory_demo.py => exp_controller_inventory.py} (86%) diff --git a/arcade/examples/gui/exp_inventory_demo.py b/arcade/examples/gui/exp_controller_inventory.py similarity index 86% rename from arcade/examples/gui/exp_inventory_demo.py rename to arcade/examples/gui/exp_controller_inventory.py index 7afd24e6b7..6535e2349f 100644 --- a/arcade/examples/gui/exp_inventory_demo.py +++ b/arcade/examples/gui/exp_controller_inventory.py @@ -11,14 +11,16 @@ - Controller support If Arcade and Python are properly installed, you can run this example with: -python -m arcade.examples.gui.exp_inventory_demo +python -m arcade.examples.gui.exp_controller_inventory """ # TODO: Drag and Drop -from typing import List +from typing import List, Optional import pyglet.font +from pyglet.event import EVENT_HANDLED from pyglet.gl import GL_NEAREST +from pyglet.input import Controller import arcade from arcade import Rect @@ -26,7 +28,7 @@ ControllerIndicator, setup_grid_focus_transition, ) -from arcade.experimental.controller_window import ControllerWindow +from arcade.experimental.controller_window import ControllerWindow, ControllerView from arcade.gui import ( Property, Surface, @@ -39,7 +41,9 @@ UIView, UIWidget, bind, + UIEvent, ) +from arcade.gui.events import UIControllerButtonPressEvent from arcade.gui.experimental.focus import Focusable, UIFocusGroup from arcade.resources import load_kenney_fonts @@ -297,7 +301,7 @@ def __init__(self, inventory: Inventory, **kwargs): super().__init__(size_hint=(0.8, 0.8), **kwargs) self.with_padding(all=10) self.with_background(color=arcade.uicolor.GREEN_GREEN_SEA) - self._debug = True + self._debug = False self.add( UILabel(text="Inventory", font_size=20, font_name="Kenney Blocks", bold=True), @@ -336,24 +340,32 @@ def __init__(self, inventory: Inventory, **kwargs): inv_slot.neighbor_right = eq_slot eq_slot.neighbor_left = inv_slot - # focusable widgets - self.detect_focusable_widgets() - - # close button, not focusable (controller use B to close) - close_button = self.add( + # close button not part of the normal focus rotation, but can be focused with "b" + self.close_button = self.add( # todo: find out why X is not in center UIFlatButton(text="X", width=40, height=40), anchor_x="right", anchor_y="top", ) - close_button.on_click = lambda _: self.close() # type: ignore + self.close_button.on_click = lambda _: self.close() # type: ignore + + # init controller support + self.detect_focusable_widgets() + + def on_event(self, event: UIEvent) -> Optional[bool]: + if isinstance(event, UIControllerButtonPressEvent): + if event.button == "b": + self.set_focus(self.close_button) + return EVENT_HANDLED + + return super().on_event(event) def close(self): + self.visible = False self.trigger_full_render() - self.parent.remove(self) -class MyView(UIView): +class MyView(UIView, ControllerView): def __init__(self): super().__init__() @@ -368,26 +380,40 @@ def __init__(self): self.root = self.add_widget(UIAnchorLayout()) self.add_widget(ControllerIndicator()) - self.show_inventory() + text = self.root.add( + UILabel( + text="Open Inventory with 'Select' button on a controller or 'I' key", font_size=24 + ) + ) + text.fit_content() + text.center_on_screen() + + self._inventory_modal = self.root.add(InventoryModal(self.inventory)) - def show_inventory(self): - self.root.add(InventoryModal(self.inventory)) + def toggle_inventory(self): + self._inventory_modal.visible = not self._inventory_modal.visible def on_key_press(self, symbol: int, modifiers: int) -> bool | None: if symbol == arcade.key.I: - print("Show inventory") - for i, item in enumerate(self.inventory): - print(i, item.symbol if item else "-") + self.toggle_inventory() return True return super().on_key_press(symbol, modifiers) + def on_button_press(self, controller: Controller, button): + if button == "back": + self.toggle_inventory() + return True + + return super().on_button_press(controller, button) + def on_draw_before_ui(self): pass if __name__ == "__main__": # pixelate the font + pyglet.options.text_antialiasing = False pyglet.font.base.Font.texture_min_filter = GL_NEAREST pyglet.font.base.Font.texture_mag_filter = GL_NEAREST diff --git a/arcade/gui/events.py b/arcade/gui/events.py index d5ca7df207..27476d439c 100644 --- a/arcade/gui/events.py +++ b/arcade/gui/events.py @@ -247,6 +247,24 @@ class UIControllerEvent(UIEvent): """ +@dataclass +class UIControllerConnectEvent(UIControllerEvent): + """Triggered when a controller is connected. + + Args: + source: The controller that triggered the event. + """ + + +@dataclass +class UIControllerDisconnectEvent(UIControllerEvent): + """Triggered when a controller is disconnected. + + Args: + source: The controller that triggered the event. + """ + + @dataclass class UIControllerStickEvent(UIControllerEvent): """Triggered when a controller stick is moved. diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index c96d81551a..2cff7bdc65 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -1,15 +1,17 @@ import warnings +from types import EllipsisType from typing import Optional from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from pyglet.math import Vec2 import arcade -from arcade import LBWH, MOUSE_BUTTON_LEFT +from arcade import MOUSE_BUTTON_LEFT from arcade.gui.events import ( UIControllerButtonPressEvent, UIControllerButtonReleaseEvent, UIControllerDpadEvent, + UIControllerEvent, UIEvent, UIKeyPressEvent, UIKeyReleaseEvent, @@ -18,15 +20,14 @@ ) from arcade.gui.property import ListProperty, Property, bind from arcade.gui.surface import Surface -from arcade.gui.ui_manager import UIManager -from arcade.gui.widgets import UIInteractiveWidget, UIWidget +from arcade.gui.widgets import FocusMode, UIInteractiveWidget, UIWidget from arcade.gui.widgets.layout import UIAnchorLayout from arcade.gui.widgets.slider import UIBaseSlider class Focusable(UIWidget): """ - A widget that can be focused and provides additional information about focus behavior. + A widget that provides additional information about focus neighbors. Attributes: @@ -34,80 +35,36 @@ class Focusable(UIWidget): neighbor_right: The widget right of this widget. neighbor_down: The widget below this widget. neighbor_left: The widget left of this widget. - """ - # todo set focused when focused - focused = Property(False) + focus_mode = FocusMode.ALL neighbor_up: UIWidget | None = None neighbor_right: UIWidget | None = None neighbor_down: UIWidget | None = None neighbor_left: UIWidget | None = None - @property - def ui(self) -> UIManager | None: - """The UIManager this widget is attached to.""" - w: UIWidget | None = self - while w and w.parent: - parent = w.parent - if isinstance(parent, UIManager): - return parent - - w = parent - return None - - def _render_focus(self, surface: Surface): - # this will be properly integrated into widget - self.prepare_render(surface) - arcade.draw_rect_outline( - rect=LBWH(0, 0, self.content_width, self.content_height), - color=arcade.color.WHITE, - border_width=4, - ) - - def _do_render(self, surface: Surface, force=False) -> bool: - rendered = False - - should_render = force or self._requires_render - if should_render and self.visible: - rendered = True - self.do_render_base(surface) - self.do_render(surface) - - if self.focused: - self._render_focus(surface) - - self._requires_render = False - - # only render children if self is visible - if self.visible: - for child in self.children: - rendered |= child._do_render(surface, should_render) - - return rendered - class UIFocusMixin(UIWidget): """A group of widgets that can be focused. UIFocusGroup maintains two lists of widgets: - The list of focusable widgets. - - The list of widgets in. + - The list of widgets within (normal widget children). Use `detect_focusable_widgets()` to automatically detect focusable widgets - or add_widget to add them manually. + or explicitly use `add_widget()`. - The Group can be navigated with the keyboard or controller. + The Group can be navigated with the keyboard (TAB/ SHIFT + TAB) or controller (DPad). - DPAD: Navigate between focusable widgets. (up, down, left, right) - TAB: Navigate between focusable widgets. - - A Button or SPACE: Interact with the focused widget. + - 'A' Button or SPACE: Interact with the focused widget. """ + _focused_widget = Property[UIWidget | None](None) _focusable_widgets = ListProperty[UIWidget]() - _focused = Property(0) _interacting: UIWidget | None = None _debug = Property(False) @@ -116,13 +73,21 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) bind(self, "_debug", self.trigger_full_render) - bind(self, "_focused", self.trigger_full_render) + bind(self, "_focused_widget", self.trigger_full_render) bind(self, "_focusable_widgets", self.trigger_full_render) def on_event(self, event: UIEvent) -> Optional[bool]: + # pass events to children first, including controller events + # so they can handle them if super().on_event(event): return EVENT_HANDLED + if isinstance(event, UIControllerEvent): + # if no focused widget, set the first focusable widget + if self.focused_widget is None and self._focusable_widgets: + self.set_focus() + return EVENT_HANDLED + if isinstance(event, UIKeyPressEvent): if event.symbol == arcade.key.TAB: if event.modifiers & arcade.key.MOD_SHIFT: @@ -141,7 +106,7 @@ def on_event(self, event: UIEvent) -> Optional[bool]: self.end_interaction() return EVENT_HANDLED - if isinstance(event, UIControllerDpadEvent): + elif isinstance(event, UIControllerDpadEvent): if self._interacting: # TODO this should be handled in the slider! # pass dpad events to the interacting widget @@ -184,44 +149,15 @@ def on_event(self, event: UIEvent) -> Optional[bool]: return EVENT_UNHANDLED - def _ensure_focused_property(self): - # TODO this is a hack, to set the focused property on the focused widget - # this should be properly handled in a property or so - - focused = self._get_focused_widget() - - for widget in self._focusable_widgets: - if isinstance(widget, Focusable): - if widget == focused: - widget.focused = True - else: - widget.focused = False - - def _get_focused_widget(self) -> UIWidget | None: - if len(self._focusable_widgets) == 0: - return None - - if len(self._focusable_widgets) <= self._focused < 0: - warnings.warn("Focused widget is out of range") - self._focused = 0 - - return self._focusable_widgets[self._focused] - - def add_widget(self, widget): - self._focusable_widgets.append(widget) - @classmethod def _walk_widgets(cls, root: UIWidget): for child in reversed(root.children): yield child yield from cls._walk_widgets(child) - def detect_focusable_widgets(self, root: UIWidget | None = None): + def detect_focusable_widgets(self): """Automatically detect focusable widgets.""" - if root is None: - root = self - - widgets = self._walk_widgets(root) + widgets = self._walk_widgets(self) focusable_widgets = [] for widget in reversed(list(widgets)): @@ -230,58 +166,119 @@ def detect_focusable_widgets(self, root: UIWidget | None = None): self._focusable_widgets = focusable_widgets + @property + def focused_widget(self) -> UIWidget | None: + """Return the currently focused widget. + If no widget is focused, return None.""" + return self._focused_widget + + def set_focus(self, widget: UIWidget | None | EllipsisType = ...): + """Set the focus to a specific widget. + + Set the focus to a specific widget. The widget must be in the list of + focusable widgets. If the widget is not in the list, a ValueError is raised. + + Setting the focus to None will remove the focus from the current widget. + If `...` is passed (default), the focus will be set to the first + focusable widget in the list. + + Args: + widget: The widget to focus. + """ + # de-focus the current widget + if widget is None: + if self.focused_widget is not None: + self.focused_widget.focused = False + self._focused_widget = None + return + + # resolve ... + if widget is Ellipsis: + if self._focusable_widgets: + widget = self._focusable_widgets[0] + else: + raise ValueError( + "No focusable widgets in the group, " + "use `detect_focusable_widgets()` to detect them." + ) + + # handle new focus + if widget not in self._focusable_widgets: + raise ValueError("Widget is not focusable or not in the group.") + + if self.focused_widget is not None: + self.focused_widget.focused = False + widget.focused = True + self._focused_widget = widget + def focus_up(self): - widget = self._get_focused_widget() + widget = self.focused_widget if isinstance(widget, Focusable): if widget.neighbor_up: - _index = self._focusable_widgets.index(widget.neighbor_up) - self._focused = _index + self.set_focus(widget.neighbor_up) return self.focus_previous() def focus_down(self): - widget = self._get_focused_widget() + widget = self.focused_widget if isinstance(widget, Focusable): if widget.neighbor_down: - _index = self._focusable_widgets.index(widget.neighbor_down) - self._focused = _index + self.set_focus(widget.neighbor_down) return self.focus_next() def focus_left(self): - widget = self._get_focused_widget() + widget = self.focused_widget if isinstance(widget, Focusable): if widget.neighbor_left: - _index = self._focusable_widgets.index(widget.neighbor_left) - self._focused = _index + self.set_focus(widget.neighbor_left) return self.focus_previous() def focus_right(self): - widget = self._get_focused_widget() + widget = self.focused_widget if isinstance(widget, Focusable): if widget.neighbor_right: - _index = self._focusable_widgets.index(widget.neighbor_right) - self._focused = _index + self.set_focus(widget.neighbor_right) return self.focus_next() def focus_next(self): - self._focused += 1 - if self._focused >= len(self._focusable_widgets): - self._focused = 0 + """Focus the next widget in the list of focusable widgets of this group""" + if self.focused_widget is None: + warnings.warn("No focused widget. Do not change focus.") + return + + if self.focused_widget not in self._focusable_widgets: + warnings.warn("Focused widget not in focusable widgets list. Do not change focus.") + return + + focused_index = self._focusable_widgets.index(self.focused_widget) + 1 + focused_index %= len(self._focusable_widgets) # wrap around + self.set_focus(self._focusable_widgets[focused_index]) def focus_previous(self): - self._focused -= 1 - if self._focused < 0: - self._focused = len(self._focusable_widgets) - 1 + """Focus the previous widget in the list of focusable widgets of this group""" + if self.focused_widget is None: + warnings.warn("No focused widget. Do not change focus.") + return + + if self.focused_widget not in self._focusable_widgets: + warnings.warn("Focused widget not in focusable widgets list. Do not change focus.") + return + + focused_index = self._focusable_widgets.index(self.focused_widget) - 1 + # automatically wrap around via index -1 + self.set_focus(self._focusable_widgets[focused_index]) def start_interaction(self): - widget = self._get_focused_widget() + # TODO this should be handled in the widget + + widget = self.focused_widget if isinstance(widget, UIInteractiveWidget): widget.dispatch_ui_event( @@ -298,7 +295,7 @@ def start_interaction(self): print("Cannot interact widget") def end_interaction(self): - widget = self._get_focused_widget() + widget = self.focused_widget if isinstance(widget, UIInteractiveWidget): if isinstance(self._interacting, UIBaseSlider): @@ -321,10 +318,6 @@ def end_interaction(self): ) def _do_render(self, surface: Surface, force=False) -> bool: - # TODO this is a hack, to set the focused property on the focused widget - self._ensure_focused_property() - - # TODO: add a post child render hook to UIWidget rendered = super()._do_render(surface, force) if rendered: @@ -335,20 +328,10 @@ def _do_render(self, surface: Surface, force=False) -> bool: def do_post_render(self, surface: Surface): surface.limit(None) - widget = self._get_focused_widget() + widget = self.focused_widget if not widget: return - if isinstance(widget, Focusable): - # Focusable widgets care about focus themselves - pass - else: - arcade.draw_rect_outline( - rect=widget.rect, - color=arcade.color.WHITE, - border_width=2, - ) - if self._debug: # debugging if isinstance(widget, Focusable): @@ -383,7 +366,7 @@ def _draw_indicator(self, start: Vec2, end: Vec2, color=arcade.color.WHITE): @staticmethod def is_focusable(widget): - return isinstance(widget, (Focusable, UIInteractiveWidget)) + return widget.focus_mode is not FocusMode.NONE class UIFocusGroup(UIFocusMixin, UIAnchorLayout): diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index c881d46802..1fb813839d 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -35,6 +35,8 @@ UITextInputEvent, UITextMotionEvent, UITextMotionSelectEvent, + UIControllerConnectEvent, + UIControllerDisconnectEvent, ) from arcade.gui.surface import Surface from arcade.gui.widgets import UIWidget @@ -288,6 +290,8 @@ def enable(self) -> None: if isinstance(self.window, ControllerWindow): controller_handlers = { + self.on_connect, + self.on_disconnect, self.on_stick_motion, self.on_trigger_motion, self.on_button_press, @@ -324,6 +328,8 @@ def disable(self) -> None: if isinstance(self.window, ControllerWindow): controller_handlers = { + self.on_connect, + self.on_disconnect, self.on_stick_motion, self.on_trigger_motion, self.on_button_press, @@ -483,6 +489,14 @@ def on_resize(self, width, height): self.trigger_render() + def on_connect(self, controller: Controller): + """Called when a controller is connected.""" + self.dispatch_ui_event(UIControllerConnectEvent(controller)) + + def on_disconnect(self, controller: Controller): + """Called when a controller is disconnected.""" + self.dispatch_ui_event(UIControllerDisconnectEvent(controller)) + def on_stick_motion(self, controller: Controller, name, value): return self.dispatch_ui_event(UIControllerStickEvent(controller, name, value)) diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index d03b8382e1..e2b0e69565 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -1,5 +1,8 @@ +from __future__ import annotations + from abc import ABC -from typing import Dict, Iterable, List, NamedTuple, Optional, TYPE_CHECKING, Tuple, TypeVar, Union +from enum import IntEnum +from typing import TYPE_CHECKING, Dict, Iterable, List, NamedTuple, Optional, Tuple, TypeVar, Union from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher from pyglet.math import Vec2 @@ -25,11 +28,22 @@ if TYPE_CHECKING: from arcade.gui.ui_manager import UIManager -__all__ = ["Surface", "UIDummy"] - W = TypeVar("W", bound="UIWidget") +class FocusMode(IntEnum): + """Defines the focus mode of a widget. + + 0: Not focusable + 1: Focusable + + We might support different focus modes in the future, but for now on/off is enough. + """ + + NONE = 0 + ALL = 2 + + class _ChildEntry(NamedTuple): child: "UIWidget" data: Dict @@ -57,6 +71,8 @@ class UIWidget(EventDispatcher, ABC): rect = Property(LBWH(0, 0, 1, 1)) visible = Property(True) + focused = Property(False) + focus_mode: FocusMode = FocusMode.NONE size_hint = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) size_hint_min = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) @@ -107,6 +123,7 @@ def __init__( self.add(child) bind(self, "rect", self.trigger_full_render) + bind(self, "focused", self.trigger_full_render) bind( self, "visible", self.trigger_full_render ) # TODO maybe trigger_parent_render would be enough @@ -242,6 +259,8 @@ def _do_render(self, surface: Surface, force=False) -> bool: rendered = True self.do_render_base(surface) self.do_render(surface) + if self.focused: + self.do_render_focus(surface) self._requires_render = False # only render children if self is visible @@ -292,6 +311,15 @@ def do_render(self, surface: Surface): """ pass + def do_render_focus(self, surface: Surface): + """Render the widgets focus representation overlay`""" + self.prepare_render(surface) + arcade.draw_rect_outline( + rect=LBWH(0, 0, self.content_width, self.content_height), + color=arcade.color.WHITE, + border_width=4, + ) + def dispatch_ui_event(self, event: UIEvent): """Dispatch a :class:`UIEvent` using pyglet event dispatch mechanism""" return self.dispatch_event("on_event", event) @@ -314,6 +342,19 @@ def scale(self, factor: AsFloat, anchor: Vec2 = AnchorPoint.CENTER): """ self.rect = self.rect.scale(new_scale=factor, anchor=anchor) + def get_ui_manager(self) -> UIManager | None: + """The UIManager this widget is attached to. During creation, this will be None.""" + from arcade.gui.ui_manager import UIManager + + w: UIWidget | None = self + while w and w.parent: + parent = w.parent + if isinstance(parent, UIManager): + return parent + + w = parent + return None + @property def left(self) -> float: """Left coordinate of the widget""" @@ -550,6 +591,8 @@ class UIInteractiveWidget(UIWidget): the interaction (default: left mouse button) """ + focus_mode = FocusMode.ALL + # States hovered = Property(False) """True if the mouse is over the widget""" @@ -867,3 +910,6 @@ def color(self): @color.setter def color(self, value): self.with_background(color=value) + + +__all__ = ["Surface", "UIDummy", "FocusMode", "UIInteractiveWidget", "UIWidget"] diff --git a/arcade/gui/widgets/buttons.py b/arcade/gui/widgets/buttons.py index 22ffa4db2f..60136e5398 100644 --- a/arcade/gui/widgets/buttons.py +++ b/arcade/gui/widgets/buttons.py @@ -150,7 +150,7 @@ def get_current_state(self) -> str: return "disabled" elif self.pressed: return "press" - elif self.hovered: + elif self.hovered or self.focused: return "hover" else: return "normal" @@ -346,7 +346,7 @@ def get_current_state(self) -> str: return "disabled" elif self.pressed: return "press" - elif self.hovered: + elif self.hovered or self.focused: return "hover" else: return "normal" diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index a647423c3f..27c2332911 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -27,6 +27,7 @@ def show(self, manager: UIManager): def hide(self): """Hide the overlay.""" + self.set_focus(None) if self.parent: self.parent.remove(self) @@ -196,17 +197,8 @@ def _update_options(self): self._overlay.detect_focusable_widgets() - def _find_ui_manager(self): - # search tree for UIManager - parent = self.parent - while isinstance(parent, UIWidget): - # - parent = parent.parent - - return parent if isinstance(parent, UIManager) else None - def _show_overlay(self): - manager = self._find_ui_manager() + manager = self.get_ui_manager() if manager is None: raise Exception("UIDropdown could not find UIManager in its parents.") From 86c8c984b6850881a3b9947dcabcab76092e243b Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 19 Apr 2025 22:46:13 +0200 Subject: [PATCH 132/279] gui: controller support - add tests - add type hints --- .pre-commit-config.yaml | 2 + arcade/gui/ui_manager.py | 11 ++--- tests/unit/gui/test_focus.py | 80 ++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 tests/unit/gui/test_focus.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e0052c5d77..dd6633f90d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,8 +23,10 @@ repos: - id: mypy args: [ --explicit-package-bases ] language: system + exclude: ^tests/ - repo: https://github.com/RobertCraigie/pyright-python rev: v1.1.396 hooks: - id: pyright language: system + exclude: ^tests/ diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 1fb813839d..e127ebdbaf 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -13,6 +13,7 @@ from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher from pyglet.input import Controller +from pyglet.math import Vec2 from typing_extensions import TypeGuard import arcade @@ -497,19 +498,19 @@ def on_disconnect(self, controller: Controller): """Called when a controller is disconnected.""" self.dispatch_ui_event(UIControllerDisconnectEvent(controller)) - def on_stick_motion(self, controller: Controller, name, value): + def on_stick_motion(self, controller: Controller, name: str, value: Vec2): return self.dispatch_ui_event(UIControllerStickEvent(controller, name, value)) - def on_trigger_motion(self, controller: Controller, name, value): + def on_trigger_motion(self, controller: Controller, name: str, value: float): return self.dispatch_ui_event(UIControllerTriggerEvent(controller, name, value)) - def on_button_press(self, controller: Controller, button): + def on_button_press(self, controller: Controller, button: str): return self.dispatch_ui_event(UIControllerButtonPressEvent(controller, button)) - def on_button_release(self, controller: Controller, button): + def on_button_release(self, controller: Controller, button: str): return self.dispatch_ui_event(UIControllerButtonReleaseEvent(controller, button)) - def on_dpad_motion(self, controller: Controller, value): + def on_dpad_motion(self, controller: Controller, value: Vec2): return self.dispatch_ui_event(UIControllerDpadEvent(controller, value)) @property diff --git a/tests/unit/gui/test_focus.py b/tests/unit/gui/test_focus.py new file mode 100644 index 0000000000..ce38fc9153 --- /dev/null +++ b/tests/unit/gui/test_focus.py @@ -0,0 +1,80 @@ +from pyglet.math import Vec2 + +from arcade.gui import UIFlatButton +from arcade.gui.experimental.focus import UIFocusGroup + + +def test_focus_group_no_focus_set_by_default(ui): + group = UIFocusGroup() + _ = group.add(UIFlatButton()) + + group.detect_focusable_widgets() + + assert group.focused_widget is None + +def test_focus_group_focus_set(ui): + group = UIFocusGroup() + + assert group.focused_widget is None + btn_1 = group.add(UIFlatButton()) + btn_2 = group.add(UIFlatButton()) + + group.detect_focusable_widgets() + + group.set_focus(btn_1) + + assert group.focused_widget == btn_1 + assert btn_1.focused is True + assert btn_2.focused is False + +def test_nested_groups_button_press(ui): + """ + Test when nested UIFocusGroups are used. + + The inner group should consume the focus event and not pass it to the outer group. + """ + + group_1 = ui.add(UIFocusGroup()) + btn_1 = group_1.add(UIFlatButton()) + + group_2 = group_1.add(UIFocusGroup()) + btn_2 = group_2.add(UIFlatButton()) + + group_1.detect_focusable_widgets() + group_2.detect_focusable_widgets() + + group_1.set_focus(btn_1) + group_2.set_focus(btn_2) + + ui.on_button_press(None, "a") + + assert btn_1.pressed is False + assert btn_2.pressed is True + +def test_nested_groups_dpad(ui): + """ + Test when nested UIFocusGroups are used. + + The inner group should consume the focus event and not pass it to the outer group. + """ + + group_1 = ui.add(UIFocusGroup()) + btn_1_1 = group_1.add(UIFlatButton()) + btn_1_2 = group_1.add(UIFlatButton()) + + group_2 = group_1.add(UIFocusGroup()) + btn_2_1 = group_2.add(UIFlatButton()) + btn_2_2 = group_2.add(UIFlatButton()) + + group_1.detect_focusable_widgets() + group_2.detect_focusable_widgets() + + group_1.set_focus(btn_1_1) + group_2.set_focus(btn_2_1) + + ui.on_dpad_motion(None, Vec2(0, 1)) + + assert btn_1_1.focused is True + assert btn_1_2.focused is False + assert btn_2_1.focused is False + assert btn_2_2.focused is True From 1521df6ef338ae1534815dbeed2331d344b6e7f2 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 19 Apr 2025 22:49:19 +0200 Subject: [PATCH 133/279] fix example --- arcade/examples/gui/exp_controller_inventory.py | 1 - 1 file changed, 1 deletion(-) diff --git a/arcade/examples/gui/exp_controller_inventory.py b/arcade/examples/gui/exp_controller_inventory.py index 6535e2349f..4938a70501 100644 --- a/arcade/examples/gui/exp_controller_inventory.py +++ b/arcade/examples/gui/exp_controller_inventory.py @@ -413,7 +413,6 @@ def on_draw_before_ui(self): if __name__ == "__main__": # pixelate the font - pyglet.options.text_antialiasing = False pyglet.font.base.Font.texture_min_filter = GL_NEAREST pyglet.font.base.Font.texture_mag_filter = GL_NEAREST From b9bc901680f328357e749bb17acb4e5ade700316 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 19 Apr 2025 23:19:11 +0200 Subject: [PATCH 134/279] sort imports --- arcade/gui/ui_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index e127ebdbaf..6d25be8bf7 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -22,6 +22,8 @@ from arcade.gui.events import ( UIControllerButtonPressEvent, UIControllerButtonReleaseEvent, + UIControllerConnectEvent, + UIControllerDisconnectEvent, UIControllerDpadEvent, UIControllerStickEvent, UIControllerTriggerEvent, @@ -36,8 +38,6 @@ UITextInputEvent, UITextMotionEvent, UITextMotionSelectEvent, - UIControllerConnectEvent, - UIControllerDisconnectEvent, ) from arcade.gui.surface import Surface from arcade.gui.widgets import UIWidget From 5465869776175433534e057c87a8b4a8b9af710c Mon Sep 17 00:00:00 2001 From: Arialdis Japa Date: Sun, 20 Apr 2025 21:21:38 -0700 Subject: [PATCH 135/279] Fix typo in Text class documentation --- arcade/text.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/text.py b/arcade/text.py index 896b9c1399..a836d6b5b1 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -187,7 +187,7 @@ class Text: text_2 = Text("Hello, World 2", 0, 100, batch=batch) text_3 = Text("Hello, World 2", 0, 150, batch=batch) # Draw the batch - bach.draw() + batch.draw() # Remove a text instance from the batch text_2.batch = None From 56c658f247e693688f38ace8134f620d9dc9d3e7 Mon Sep 17 00:00:00 2001 From: Alexey Date: Wed, 23 Apr 2025 14:36:03 +0700 Subject: [PATCH 136/279] Update conf.py (#2650) --- doc/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index a7c5da0f5c..29ef09dbc3 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -217,7 +217,7 @@ def run_util(filename, run_name="__main__", init_globals=None): # General information about the project. project = 'Python Arcade Library' -copyright = '2024, Paul Vincent Craven' +copyright = '2025, Paul Vincent Craven' author = 'Paul Vincent Craven' # The version info for the project you're documenting, acts as replacement for From 861f1e2a0c3af222fa9750256999935c01733ad2 Mon Sep 17 00:00:00 2001 From: Omar Mohammed Date: Thu, 24 Apr 2025 08:54:02 +0200 Subject: [PATCH 137/279] Update step_10.rst - Camera line is incorrect (#2651) Very simple fix, to change: ``` self.gui_camera = arcade.SimpleCamera(viewport=(0, 0, width, height)) ``` to ``` self.gui_camera = arcade.Camera2D() ``` since there is no `SimpleCamera` --- doc/tutorials/platform_tutorial/step_10.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/tutorials/platform_tutorial/step_10.rst b/doc/tutorials/platform_tutorial/step_10.rst index 5922db1ced..bcd7ee5a89 100644 --- a/doc/tutorials/platform_tutorial/step_10.rst +++ b/doc/tutorials/platform_tutorial/step_10.rst @@ -34,7 +34,7 @@ our score. This will just be an integer initially set to 0. We will set this in self.score = 0 # Within setup - self.gui_camera = arcade.SimpleCamera(viewport=(0, 0, width, height)) + self.gui_camera = arcade.Camera2D() self.score = 0 Now we can go into our ``on_update`` function, and when the player collects a coin, we can increment our score variable. From b3f73618aef291dbb54b422458a25741797ca9b1 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 26 Apr 2025 20:44:59 +0200 Subject: [PATCH 138/279] update changelog for version 3.2 --- CHANGELOG.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28eb11fbbe..6defbb42ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,15 +3,12 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## Version 3.1.2 (unreleased) +## Version 3.2 (unreleased) - GUI - - Fix `UIScrollArea.add` always returning None + - Fix `UIScrollArea.add` always returning None - Support `layer` in `UIView.add_widget()` - -## Version 3.1.1 - -* Text objects are now lazy and can be created before the window +- Text objects are now lazy and can be created before the window ## Version 3.1.0 @@ -26,11 +23,11 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - `arcade.gui.widgets.text.UIInputText` - now supports styles for `normal`, `disabled`, `hovered`, `pressed` and `invalid` states - provides a `invalid` property to indicate if the input is invalid - - Added experimental `arcade.gui.experimental.UIRestrictedInput` + - Added experimental `arcade.gui.experimental.UIRestrictedInput` a subclass of `UIInputText` that restricts the input to a specific set of characters - `arcade.gui.NinePatchTexture` is now lazy and can be created before a window exists allowing creation during imports. - Improve `arcade.gui.experimental.scroll_area.ScrollBar` behavior to match HTML scrollbars -- Support drawing hitboxes using RBG or RGBA +- Support drawing hitboxes using RBG or RGBA - Fixed a bug causing some events to not trigger on the window's keyboard and mouse state handlers - Many documenation fixes and improvements - Various example fixes From c1d6edbab2bc3ba828a1a17ffd6b673e116e1973 Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Sat, 26 Apr 2025 21:44:05 +0200 Subject: [PATCH 139/279] Add step argument to UISliders --- arcade/gui/widgets/slider.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index 70878e913b..b073a3a9db 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -47,6 +47,7 @@ class UIBaseSlider(UIInteractiveWidget, metaclass=ABCMeta): size_hint_min: Minimum size hint of the slider. size_hint_max: Maximum size hint of the slider. style: Used to style the slider for different states. + step: Smallest change the slider value can move by. **kwargs: Passed to UIInteractiveWidget. """ @@ -67,6 +68,7 @@ def __init__( size_hint_min=None, size_hint_max=None, style: Union[Mapping[str, UISliderStyle], None] = None, + step: Union[float, None] = None, **kwargs, ): super().__init__( @@ -81,7 +83,8 @@ def __init__( **kwargs, ) - self.value = value + self.step = step + self.value = self._apply_step(value) self.min_value = min_value self.max_value = max_value @@ -95,6 +98,13 @@ def __init__( self.register_event_type("on_change") + def _apply_step(self, value: float): + if self.step: + inverse = 1 / self.step + return round(value * inverse) / inverse + + return value + def _x_for_value(self, value: float): """Provides the x coordinate for the given value.""" @@ -110,7 +120,9 @@ def norm_value(self): @norm_value.setter def norm_value(self, value): """Normalized value between 0.0 and 1.0""" - self.value = min(value * (self.max_value - self.min_value) + self.min_value, self.max_value) + self.value = self._apply_step( + min(value * (self.max_value - self.min_value) + self.min_value, self.max_value) + ) @property def _thumb_x(self): @@ -254,7 +266,7 @@ class UISlider(UIStyledWidget[UISliderStyle], UIBaseSlider): width: Width of the slider. height: Height of the slider. style: Used to style the slider for different states. - + step: Smallest change the slider value can move by. """ UIStyle = UISliderStyle @@ -294,6 +306,7 @@ def __init__( size_hint_min=None, size_hint_max=None, style: Union[dict[str, UISliderStyle], None] = None, + step: Union[float, None] = None, **kwargs, ): super().__init__( @@ -308,6 +321,7 @@ def __init__( size_hint_min=size_hint_min, size_hint_max=size_hint_max, style=style or UISlider.DEFAULT_STYLE, + step=step, **kwargs, ) From f8bb36b20dc74da532be5a612835b5ece654b492 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 26 Apr 2025 23:08:00 +0200 Subject: [PATCH 140/279] gui: rename Focusable, add documentation about controller support --- .../examples/gui/exp_controller_inventory.py | 4 +- .../gui/exp_controller_support_grid.py | 6 +- arcade/gui/experimental/focus.py | 24 +-- .../gui/controller_support.rst | 198 ++++++++++++++++++ doc/programming_guide/gui/index.rst | 4 +- 5 files changed, 216 insertions(+), 20 deletions(-) create mode 100644 doc/programming_guide/gui/controller_support.rst diff --git a/arcade/examples/gui/exp_controller_inventory.py b/arcade/examples/gui/exp_controller_inventory.py index 4938a70501..11a9724504 100644 --- a/arcade/examples/gui/exp_controller_inventory.py +++ b/arcade/examples/gui/exp_controller_inventory.py @@ -44,7 +44,7 @@ UIEvent, ) from arcade.gui.events import UIControllerButtonPressEvent -from arcade.gui.experimental.focus import Focusable, UIFocusGroup +from arcade.gui.experimental.focus import UIFocusable, UIFocusGroup from arcade.resources import load_kenney_fonts @@ -143,7 +143,7 @@ def legs(self, value): self[2] = value -class InventorySlotUI(Focusable, UIFlatButton): +class InventorySlotUI(UIFocusable, UIFlatButton): """Represents a single inventory slot. The slot accesses a specific index in the inventory. diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py index dfef1ca0d8..4833b0fcab 100644 --- a/arcade/examples/gui/exp_controller_support_grid.py +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -20,10 +20,10 @@ UIView, UIWidget, ) -from arcade.gui.experimental.focus import Focusable, UIFocusGroup +from arcade.gui.experimental.focus import UIFocusable, UIFocusGroup -class FocusableButton(Focusable, UIFlatButton): +class FocusableButton(UIFocusable, UIFlatButton): pass @@ -43,7 +43,7 @@ def setup_grid_focus_transition(grid: Dict[Tuple[int, int], UIWidget]): for c in range(cols): for r in range(rows): btn = grid.get((c, r)) - if btn is None or not isinstance(btn, Focusable): + if btn is None or not isinstance(btn, UIFocusable): continue if c > 0: diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 2cff7bdc65..204c8efad6 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -25,7 +25,7 @@ from arcade.gui.widgets.slider import UIBaseSlider -class Focusable(UIWidget): +class UIFocusable(UIWidget): """ A widget that provides additional information about focus neighbors. @@ -98,12 +98,12 @@ def on_event(self, event: UIEvent) -> Optional[bool]: return EVENT_HANDLED elif event.symbol == arcade.key.SPACE: - self.start_interaction() + self._start_interaction() return EVENT_HANDLED elif isinstance(event, UIKeyReleaseEvent): if event.symbol == arcade.key.SPACE: - self.end_interaction() + self._end_interaction() return EVENT_HANDLED elif isinstance(event, UIControllerDpadEvent): @@ -140,11 +140,11 @@ def on_event(self, event: UIEvent) -> Optional[bool]: elif isinstance(event, UIControllerButtonPressEvent): if event.button == "a": - self.start_interaction() + self._start_interaction() return EVENT_HANDLED elif isinstance(event, UIControllerButtonReleaseEvent): if event.button == "a": - self.end_interaction() + self._end_interaction() return EVENT_HANDLED return EVENT_UNHANDLED @@ -213,7 +213,7 @@ def set_focus(self, widget: UIWidget | None | EllipsisType = ...): def focus_up(self): widget = self.focused_widget - if isinstance(widget, Focusable): + if isinstance(widget, UIFocusable): if widget.neighbor_up: self.set_focus(widget.neighbor_up) return @@ -222,7 +222,7 @@ def focus_up(self): def focus_down(self): widget = self.focused_widget - if isinstance(widget, Focusable): + if isinstance(widget, UIFocusable): if widget.neighbor_down: self.set_focus(widget.neighbor_down) return @@ -231,7 +231,7 @@ def focus_down(self): def focus_left(self): widget = self.focused_widget - if isinstance(widget, Focusable): + if isinstance(widget, UIFocusable): if widget.neighbor_left: self.set_focus(widget.neighbor_left) return @@ -240,7 +240,7 @@ def focus_left(self): def focus_right(self): widget = self.focused_widget - if isinstance(widget, Focusable): + if isinstance(widget, UIFocusable): if widget.neighbor_right: self.set_focus(widget.neighbor_right) return @@ -275,7 +275,7 @@ def focus_previous(self): # automatically wrap around via index -1 self.set_focus(self._focusable_widgets[focused_index]) - def start_interaction(self): + def _start_interaction(self): # TODO this should be handled in the widget widget = self.focused_widget @@ -294,7 +294,7 @@ def start_interaction(self): else: print("Cannot interact widget") - def end_interaction(self): + def _end_interaction(self): widget = self.focused_widget if isinstance(widget, UIInteractiveWidget): @@ -334,7 +334,7 @@ def do_post_render(self, surface: Surface): if self._debug: # debugging - if isinstance(widget, Focusable): + if isinstance(widget, UIFocusable): if widget.neighbor_up: self._draw_indicator( widget.rect.top_center, diff --git a/doc/programming_guide/gui/controller_support.rst b/doc/programming_guide/gui/controller_support.rst new file mode 100644 index 0000000000..d443632f0b --- /dev/null +++ b/doc/programming_guide/gui/controller_support.rst @@ -0,0 +1,198 @@ +.. _gui_controller_support: + +GUI Controller Support +---------------------- + +The `arcade.gui` module now includes **experimental controller support**, allowing you to navigate through GUI elements using a game controller. This feature introduces the `ControllerWindow` and `ControllerView` classes, which provide controller-specific functionality. + +Below is a guide on how to set up and use this feature effectively. + +Basic Setup +~~~~~~~~~~~ + +To use controller support, you need to use the `ControllerWindow` and `ControllerView` classes. +These classes provide the necessary hooks for handling controller input and managing focus within the GUI. + +The following code makes use of the `UIView` class, which simplifies the process of setting up a view with a `UIManager`. + +Setting Up Controller Support +````````````````````````````` + +The `ControllerWindow` is an instance of `arcade.Window` that integrates controller input handling. The `ControllerView` class provides controller-specific callbacks, +which are used by the `UIManager` to handle controller events. + +Below is an example of how to set up a controller-enabled application: + +.. code-block:: python + + import arcade + from arcade.gui import UIView + from arcade.experimental.controller_window import ControllerWindow, ControllerView + + + class MyControllerView(ControllerView, UIView): + def __init__(self): + super().__init__() + + # Initialize your GUI elements here + + # react to controller events for your game + def on_connect(self, controller): + print(f"Controller connected: {controller}") + + def on_disconnect(self, controller): + print(f"Controller disconnected: {controller}") + + def on_stick_motion(self, controller, stick, value): + print(f"Stick {stick} moved to {value} on controller {controller}") + + def on_trigger_motion(self, controller, trigger, value): + print(f"Trigger {trigger} moved to {value} on controller {controller}") + + def on_button_press(self, controller, button): + print(f"Button {button} pressed on controller {controller}") + + def on_button_release(self, controller, button): + print(f"Button {button} released on controller {controller}") + + def on_dpad_motion(self, controller, value): + print(f"D-Pad moved to {value} on controller {controller}") + + + if __name__ == "__main__": + window = ControllerWindow(title="Controller Support Example") + view = MyControllerView() + window.show_view(view) + arcade.run() + + +Managing Focus with `FocusGroups` +````````````````````````````````` + +To enable controller navigation, you must group your interactive GUI elements into a `UIFocusGroup`. +A `UIFocusGroup` allows the controller to cycle through the elements and ensures that only one element is focused at a time. + +A single `UIFocusGroup` can be added to the `UIManager` as a root widget acting as a `UIAnchorLayout`. + +.. code-block:: python + + from arcade.experimental.controller_window import ControllerView, ControllerWindow + from arcade.gui import UIFlatButton, UIBoxLayout, UIView + from arcade.gui.experimental.focus import UIFocusGroup + + + class MyControllerView(ControllerView, UIView): + def __init__(self): + super().__init__() + + # Create buttons and add them to the focus group + fg = UIFocusGroup() + self.ui.add(fg) + + box = UIBoxLayout() + fg.add(box) + + button1 = UIFlatButton(text="Button 1", width=200) + button2 = UIFlatButton(text="Button 2", width=200) + + box.add(button1) + box.add(button2) + + # initialize the focus group, detect focusable widgets and set the initial focus + fg.detect_focusable_widgets() + fg.set_focus() + + + if __name__ == "__main__": + window = ControllerWindow(title="Controller Support Example") + view = MyControllerView() + window.show_view(view) + window.run() + + +Setting Initial Focus +````````````````````` + +It is essential to set the initial focus for the `UIFocusGroup`. Without this, the controller will not know which element to start with. + +.. code-block:: python + + # Set the initial focus + self.focus_group.set_focus() + +Summary +``````` +To use the experimental controller support in `arcade.gui`: + +1. Use `ControllerWindow` as your main application window. +2. Use `ControllerView` to provide controller-specific callbacks for the `UIManager`. +3. Group interactive elements into a `UIFocusGroup` for navigation. +4. Set the initial focus for the `UIFocusGroup` to enable proper navigation. + +This setup allows you to create a fully functional GUI that can be navigated using a game controller. Note that this feature is experimental and may be subject to changes in future releases. + + +Advanced Usage +~~~~~~~~~~~~~~ + +Nested `UIFocusGroups` +`````````````````````` + +When using nested `UIFocusGroups`, only one `UIFocusGroup` will be navigated at a time. +This is particularly useful for scenarios like modals or overlays, where you want to temporarily restrict navigation to +a specific set of elements. For example, the `UIDropdown` widget uses this feature to handle focus within its dropdown +menu while isolating it from the rest of the interface. + + +Advanced focus direction +```````````````````````` + +To provide a more advanced focus direction, you can use the `UIFocusable` class. + +The `UIFocusable` class allows you to define directional neighbors (`neighbor_up`, `neighbor_down`, `neighbor_left`, `neighbor_right`) for a widget. +These neighbors determine how focus moves between widgets when navigating with a controller or keyboard. + +Here is an example of how to use the `UIFocusable` class: + +.. code-block:: python + + from arcade.gui import UIFlatButton, UIGridLayout + from arcade.gui.experimental.focus import UIFocusGroup, UIFocusable + + class MyButton(UIFlatButton, UIFocusable): + def __init__(self, text, width): + super().__init__(text=text, width=width) + + + # Create focusable buttons + button1 = MyButton(text="Button 1", width=200) + button2 = MyButton(text="Button 2", width=200) + button3 = MyButton(text="Button 3", width=200) + button4 = MyButton(text="Button 4", width=200) + + # Set directional neighbors + button1.neighbor_right = button2 + button1.neighbor_down = button3 + button2.neighbor_left = button1 + button2.neighbor_down = button4 + button3.neighbor_up = button1 + button3.neighbor_right = button4 + button4.neighbor_up = button2 + button4.neighbor_left = button3 + + # Add buttons to a focus group + fg = UIFocusGroup() + + grid_layout = UIGridLayout(column_count=2, row_count=2, vertical_spacing=10) + grid_layout.add(button1, col_num=0, row_num=0) + grid_layout.add(button2, col_num=1, row_num=0) + grid_layout.add(button3, col_num=0, row_num=1) + grid_layout.add(button4, col_num=1, row_num=1) + + fg.add(grid_layout) + + # Detect focusable widgets and set the initial focus + fg.detect_focusable_widgets() + fg.set_focus(button1) + +This setup allows you to define custom navigation paths between widgets, enabling more complex focus behavior. diff --git a/doc/programming_guide/gui/index.rst b/doc/programming_guide/gui/index.rst index 44129d87bd..c3a1179a14 100644 --- a/doc/programming_guide/gui/index.rst +++ b/doc/programming_guide/gui/index.rst @@ -35,6 +35,4 @@ Find the required information in the following sections: style own_widgets own_layout - - - + controller_support From ed17c937dd6fa7ebc59a2f56971fe1e2596ad46e Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 26 Apr 2025 23:12:34 +0200 Subject: [PATCH 141/279] changelog: add experimental controller support with documentation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6defbb42ea..e404b2222a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - GUI - Fix `UIScrollArea.add` always returning None - Support `layer` in `UIView.add_widget()` + - Experimental controller support (incl. documentation) - Text objects are now lazy and can be created before the window ## Version 3.1.0 From 437dcb86cbeff2ddca445b51da527ac435d9a337 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 26 Apr 2025 23:31:53 +0200 Subject: [PATCH 142/279] remove mac workaround --- arcade/window_commands.py | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/arcade/window_commands.py b/arcade/window_commands.py index da4c9e2123..92013ed513 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -8,7 +8,6 @@ import gc import os -import sys import time from typing import TYPE_CHECKING, Callable @@ -144,40 +143,6 @@ def run(view: View | None = None) -> None: now = time.perf_counter() delta_time, last_time = now - last_time, now - elif sys.platform == "darwin": - # On macOS we have to patch the eventloop until a new pyglet version is released - eventloop = pyglet.app.event_loop - - def patched_run(interval=1 / 60): # type: ignore - if interval is None: - pass - elif not interval: - eventloop.clock.schedule(eventloop._redraw_windows) - else: - eventloop.clock.schedule_interval(eventloop._redraw_windows, interval) - - eventloop.has_exit = False - - from pyglet.window import Window - - Window._enable_event_queue = False - - # Dispatch pending events - for window in pyglet.app.windows: - window.switch_to() - window.dispatch_pending_events() - - eventloop.platform_event_loop = pyglet.app.platform_event_loop # type: ignore - - eventloop.dispatch_event("on_enter") - eventloop.is_running = True - - eventloop.platform_event_loop.nsapp_start(interval or 0) # type: ignore - - eventloop.run = patched_run # type: ignore - - pyglet.app.run(None) - else: # Start the standard event loop (blocking) # Note that we pass None as the interval here because we register From c98afc37c57e59c2a8a106543ee19e6943841597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Doeraene?= Date: Tue, 29 Apr 2025 16:57:10 +0200 Subject: [PATCH 143/279] Introduce SpriteSequence, a covariant supertype of SpriteList. (#2647) * Remove redundant call to `clear()` in `remove_from_sprite_lists()`. As we are just out of a while loop whose stopping condition is that `sprite_lists` is empty, calling `clear()` on it is redundant. * Concentrate updates to `BasicSprite.sprite_lists` in a pair of methods. There was already a method `register_sprite_list` to handle all additions to `sprite_lists`. We add a corresponding method `_unregister_sprite_list` to handle removals. We make the typings of these methods stricter, so that we can enforce the correct typing invariant on `sprite_lists`. We also make that invariant clearer in a comment. `sprite_lists` is unfortunately unsafely visible to everyone. So a user of the class could still violate the invariants. At least now the *intended* usage is safe. * Remove dead code attribute Sprite._sprite_list. * Fix the type signature of `get_closest_sprite`. This is similar to the fix done to `check_for_collision_with_list` done in c387717ffe20f05accf3f906a86b7bbf3fbdd12d. * A few better types in arcade.future and arcade.particles. Adding type parameters to some `SpriteList`s. One allows to get rid of a cast. * Introduce SpriteSequence, a covariant supertype of SpriteList. This is done by analogy to `collections.abc.Sequence`, which is a covariant supertype of `list`. Before this commit, many parts of the codebase used `SpriteList`s without type arguments (defaulting to `Unknown`). That was the only way to allow reasonable usages of the given methods and attributes. However, doing so results in weaker typing. Using `SpriteSequence`, we can add correct type arguments to almost all of the references that were using `SpriteList`s before. The only missing pieces are `Scene` and `TileMap`. Unfortunately, their APIs are fundamentally unsound wrt. the type arguments of their `SpriteList`s. We cannot make it sound without breaking their APIs, so we do not change them. As a bonus, we can now create lists of `SpriteList`s with varying type arguments, and generically call `draw` or `update` on them. Previously, the only common supertype of `SpriteList[A]` and `SpriteList[B]` was `object`, which meant it was not possible to call those methods on them. In a sense, that ability mostly subsumes the convenience provided by `Scene`. A `list[SpriteSequence[BasicSprite]]` is almost as convenient, while being type-safe. --- CHANGELOG.md | 3 + arcade/__init__.py | 4 + arcade/future/input/input_manager_example.py | 6 +- arcade/future/light/light_demo.py | 2 +- arcade/particles/emitter.py | 6 +- arcade/paths.py | 16 +- arcade/physics_engines.py | 58 +++--- arcade/sprite/__init__.py | 3 +- arcade/sprite/base.py | 21 ++- arcade/sprite/sprite.py | 7 +- arcade/sprite_list/__init__.py | 3 +- arcade/sprite_list/collision.py | 26 +-- arcade/sprite_list/spatial_hash.py | 79 +++++--- arcade/sprite_list/sprite_list.py | 186 +++++++++++++------ tests/unit/spritelist/test_spritesequence.py | 26 +++ 15 files changed, 306 insertions(+), 140 deletions(-) create mode 100644 tests/unit/spritelist/test_spritesequence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6defbb42ea..010a5ef2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fix `UIScrollArea.add` always returning None - Support `layer` in `UIView.add_widget()` - Text objects are now lazy and can be created before the window +- Introduce `arcade.SpriteSequence[T]` as a covariant supertype of `arcade.SpriteList[T]` + (this is similar to Python's `Sequence[T]`, which is a supertype of `list[T]`) + and various improvements to the typing of the API that leverage it ## Version 3.1.0 diff --git a/arcade/__init__.py b/arcade/__init__.py index cef66f34b9..97b18275fa 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -168,6 +168,7 @@ def configure_logging(level: int | None = None): from .sprite import PyMunk from .sprite import PymunkMixin from .sprite import SpriteType +from .sprite import SpriteType_co from .sprite import Sprite from .sprite import BasicSprite @@ -176,6 +177,7 @@ def configure_logging(level: int | None = None): from .sprite import SpriteSolidColor from .sprite_list import SpriteList +from .sprite_list import SpriteSequence from .sprite_list import check_for_collision from .sprite_list import check_for_collision_with_list from .sprite_list import check_for_collision_with_lists @@ -283,9 +285,11 @@ def configure_logging(level: int | None = None): "BasicSprite", "Sprite", "SpriteType", + "SpriteType_co", "PymunkMixin", "SpriteCircle", "SpriteList", + "SpriteSequence", "SpriteSolidColor", "Text", "Texture", diff --git a/arcade/future/input/input_manager_example.py b/arcade/future/input/input_manager_example.py index 0fce91380c..284c31c87c 100644 --- a/arcade/future/input/input_manager_example.py +++ b/arcade/future/input/input_manager_example.py @@ -26,7 +26,7 @@ class Player(arcade.Sprite): def __init__( self, texture, - walls: arcade.SpriteList, + walls: arcade.SpriteSequence[arcade.BasicSprite], input_manager_template: InputManager, controller: pyglet.input.Controller | None = None, center_x: float = 0.0, @@ -76,11 +76,11 @@ def __init__( } self.players: list[Player | None] = [] - self.player_list = arcade.SpriteList() + self.player_list: arcade.SpriteList[Player] = arcade.SpriteList() self.device_labels_batch = pyglet.graphics.Batch() self.player_device_labels: list[arcade.Text | None] = [] - self.wall_list = arcade.SpriteList(use_spatial_hash=True) + self.wall_list: arcade.SpriteList[arcade.Sprite] = arcade.SpriteList(use_spatial_hash=True) for x in range(0, self.width + 64, 64): wall = arcade.Sprite(":resources:images/tiles/grassMid.png", scale=0.5) diff --git a/arcade/future/light/light_demo.py b/arcade/future/light/light_demo.py index cfdedd3dcf..dee3839cee 100644 --- a/arcade/future/light/light_demo.py +++ b/arcade/future/light/light_demo.py @@ -18,7 +18,7 @@ def __init__(self, width, height, title): super().__init__(width, height, title) self.background = arcade.load_texture(":resources:images/backgrounds/abstract_1.jpg") - self.torch_list = arcade.SpriteList() + self.torch_list: arcade.SpriteList[arcade.Sprite] = arcade.SpriteList() self.torch_list.extend( [ arcade.Sprite( diff --git a/arcade/particles/emitter.py b/arcade/particles/emitter.py index ec6b248301..6067472b1d 100644 --- a/arcade/particles/emitter.py +++ b/arcade/particles/emitter.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Callable, cast +from typing import Callable import arcade from arcade import Vec2 @@ -151,7 +151,7 @@ def __init__( self.particle_factory = particle_factory self._emit_done_cb = emit_done_cb self._reap_cb = reap_cb - self._particles: arcade.SpriteList = arcade.SpriteList(use_spatial_hash=False) + self._particles: arcade.SpriteList[Particle] = arcade.SpriteList(use_spatial_hash=False) def _emit(self): """ @@ -189,7 +189,7 @@ def update(self, delta_time: float = 1 / 60): for _ in range(emit_count): self._emit() self._particles.update(delta_time) - particles_to_reap = [p for p in self._particles if cast(Particle, p).can_reap()] + particles_to_reap = [p for p in self._particles if p.can_reap()] for dead_particle in particles_to_reap: dead_particle.kill() diff --git a/arcade/paths.py b/arcade/paths.py index 8c9e65c168..2990a801ea 100644 --- a/arcade/paths.py +++ b/arcade/paths.py @@ -4,14 +4,22 @@ import math -from arcade import Sprite, SpriteList, check_for_collision_with_list, get_sprites_at_point +from arcade import ( + BasicSprite, + Sprite, + SpriteSequence, + check_for_collision_with_list, + get_sprites_at_point, +) from arcade.math import get_distance, lerp_2d from arcade.types import Point2 __all__ = ["AStarBarrierList", "astar_calculate_path", "has_line_of_sight"] -def _spot_is_blocked(position: Point2, moving_sprite: Sprite, blocking_sprites: SpriteList) -> bool: +def _spot_is_blocked( + position: Point2, moving_sprite: Sprite, blocking_sprites: SpriteSequence[BasicSprite] +) -> bool: """ Return if position is blocked @@ -275,7 +283,7 @@ class AStarBarrierList: def __init__( self, moving_sprite: Sprite, - blocking_sprites: SpriteList, + blocking_sprites: SpriteSequence[BasicSprite], grid_size: int, left: int, right: int, @@ -372,7 +380,7 @@ def astar_calculate_path( def has_line_of_sight( observer: Point2, target: Point2, - walls: SpriteList, + walls: SpriteSequence[BasicSprite], max_distance: float = float("inf"), check_resolution: int = 2, ) -> bool: diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index ba2f69bd44..6f2dce0b86 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -8,7 +8,7 @@ from arcade import ( BasicSprite, Sprite, - SpriteList, + SpriteSequence, SpriteType, check_for_collision, check_for_collision_with_lists, @@ -20,7 +20,7 @@ from arcade.utils import Chain, copy_dunders_unimplemented -def _wiggle_until_free(colliding: Sprite, walls: Iterable[SpriteList]) -> None: +def _wiggle_until_free(colliding: Sprite, walls: Iterable[SpriteSequence[BasicSprite]]) -> None: """Kludge to 'guess' a colliding sprite out of a collision. It works by iterating over increasing wiggle sizes of 8 points @@ -80,7 +80,7 @@ def _wiggle_until_free(colliding: Sprite, walls: Iterable[SpriteList]) -> None: def _move_sprite( - moving_sprite: Sprite, can_collide: Iterable[SpriteList[SpriteType]], ramp_up: bool + moving_sprite: Sprite, can_collide: Iterable[SpriteSequence[SpriteType]], ramp_up: bool ) -> list[SpriteType]: """Update a sprite's angle and position, returning a list of collisions. @@ -273,11 +273,14 @@ def _move_sprite( return complete_hit_list -def _add_to_list(dest: list[SpriteList], source: SpriteList | Iterable[SpriteList] | None) -> None: - """Helper function to add a SpriteList or list of SpriteLists to a list.""" +def _add_to_list( + dest: list[SpriteSequence[SpriteType]], + source: SpriteSequence[SpriteType] | Iterable[SpriteSequence[SpriteType]] | None, +) -> None: + """Helper function to add a SpriteSequence or list of SpriteSequences to a list.""" if not source: return - elif isinstance(source, SpriteList): + elif isinstance(source, SpriteSequence): dest.append(source) else: dest.extend(source) @@ -310,17 +313,17 @@ class PhysicsEngineSimple: def __init__( self, player_sprite: Sprite, - walls: SpriteList | Iterable[SpriteList] | None = None, + walls: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None, ) -> None: self.player_sprite: Sprite = player_sprite """The player-controlled :py:class:`.Sprite`.""" - self._walls: list[SpriteList] = [] + self._walls: list[SpriteSequence[BasicSprite]] = [] if walls: _add_to_list(self._walls, walls) @property - def walls(self) -> list[SpriteList]: + def walls(self) -> list[SpriteSequence[BasicSprite]]: """Which :py:class:`.SpriteList` instances block player movement. .. important:: Avoid moving sprites in these lists! @@ -334,7 +337,10 @@ def walls(self) -> list[SpriteList]: return self._walls @walls.setter - def walls(self, walls: SpriteList | Iterable[SpriteList] | None = None) -> None: + def walls( + self, + walls: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None, + ) -> None: if walls: _add_to_list(self._walls, walls) else: @@ -429,17 +435,17 @@ class PhysicsEnginePlatformer: def __init__( self, player_sprite: Sprite, - platforms: SpriteList | Iterable[SpriteList] | None = None, + platforms: SpriteSequence[Sprite] | Iterable[SpriteSequence[Sprite]] | None = None, gravity_constant: float = 0.5, - ladders: SpriteList | Iterable[SpriteList] | None = None, - walls: SpriteList | Iterable[SpriteList] | None = None, + ladders: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None, + walls: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None, ) -> None: if not isinstance(player_sprite, Sprite): raise TypeError("player_sprite must be a Sprite, not a basic_sprite!") - self._ladders: list[SpriteList] = [] - self._platforms: list[SpriteList] = [] - self._walls: list[SpriteList] = [] + self._ladders: list[SpriteSequence[BasicSprite]] = [] + self._platforms: list[SpriteSequence[Sprite]] = [] + self._walls: list[SpriteSequence[BasicSprite]] = [] self._all_obstacles = Chain(self._walls, self._platforms) _add_to_list(self._ladders, ladders) @@ -517,7 +523,7 @@ def __init__( # TODO: figure out what do do with 15_ladders_moving_platforms.py # It's no longer used by any example or tutorial file @property - def ladders(self) -> list[SpriteList]: + def ladders(self) -> list[SpriteSequence[BasicSprite]]: """Ladders turn off gravity while touched by the player. This means that whenever the :py:attr:`player_sprite` collides @@ -533,7 +539,10 @@ def ladders(self) -> list[SpriteList]: return self._ladders @ladders.setter - def ladders(self, ladders: SpriteList | Iterable[SpriteList] | None = None) -> None: + def ladders( + self, + ladders: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None, + ) -> None: if ladders: _add_to_list(self._ladders, ladders) else: @@ -544,7 +553,7 @@ def ladders(self) -> None: self._ladders.clear() @property - def platforms(self) -> list[SpriteList]: + def platforms(self) -> list[SpriteSequence[Sprite]]: """:py:class:`~arcade.sprite_list.sprite_list.SpriteList` instances containing platforms. .. important:: For best performance, put non-moving terrain in @@ -575,7 +584,9 @@ def platforms(self) -> list[SpriteList]: return self._platforms @platforms.setter - def platforms(self, platforms: SpriteList | Iterable[SpriteList] | None = None) -> None: + def platforms( + self, platforms: SpriteSequence[Sprite] | Iterable[SpriteSequence[Sprite]] | None = None + ) -> None: if platforms: _add_to_list(self._platforms, platforms) else: @@ -586,7 +597,7 @@ def platforms(self) -> None: self._platforms.clear() @property - def walls(self) -> list[SpriteList]: + def walls(self) -> list[SpriteSequence[BasicSprite]]: """Exposes the :py:class:`SpriteList` instances use as terrain. .. important:: For best performance, only add non-moving sprites! @@ -611,7 +622,10 @@ def walls(self) -> list[SpriteList]: return self._walls @walls.setter - def walls(self, walls: SpriteList | Iterable[SpriteList] | None = None) -> None: + def walls( + self, + walls: SpriteSequence[BasicSprite] | Iterable[SpriteSequence[BasicSprite]] | None = None, + ) -> None: if walls: _add_to_list(self._walls, walls) else: diff --git a/arcade/sprite/__init__.py b/arcade/sprite/__init__.py index 724680a915..611ae41c0d 100644 --- a/arcade/sprite/__init__.py +++ b/arcade/sprite/__init__.py @@ -4,7 +4,7 @@ from arcade.texture import Texture from arcade.resources import resolve -from .base import BasicSprite, SpriteType +from .base import BasicSprite, SpriteType, SpriteType_co from .sprite import Sprite from .mixins import PymunkMixin, PyMunk from .animated import ( @@ -69,6 +69,7 @@ def load_animated_gif(resource_name: str | Path) -> TextureAnimationSprite: __all__ = [ "SpriteType", + "SpriteType_co", "BasicSprite", "Sprite", "PyMunk", diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py index d07dc7c9c1..2a785e0366 100644 --- a/arcade/sprite/base.py +++ b/arcade/sprite/base.py @@ -16,6 +16,9 @@ # Type from sprite that can be any BasicSprite or any subclass of BasicSprite SpriteType = TypeVar("SpriteType", bound="BasicSprite") +# Same as SpriteType, for covariant type parameters +SpriteType_co = TypeVar("SpriteType_co", bound="BasicSprite", covariant=True) + @copy_dunders_unimplemented # See https://github.com/pythonarcade/arcade/issues/2074 class BasicSprite: @@ -70,7 +73,15 @@ def __init__( self._height = height * self._scale[1] self._visible = bool(visible) self._color: Color = WHITE - self.sprite_lists: list["SpriteList"] = [] + + # In a more powerful type system, this would be typed as + # list[SpriteList[? super Self]] + # i.e., a list of SpriteList's with varying type arguments, but where + # each of those type arguments is known to be a supertype of Self. + # All changes to this list should go through the pair of methods + # register_sprite_list, _unregister_sprite_list. + # They ensure that the above typing invariant is preserved. + self.sprite_lists: list["SpriteList[Any]"] = [] """The sprite lists this sprite is a member of""" # Core properties we don't use, but spritelist expects it @@ -747,7 +758,7 @@ def update_spatial_hash(self) -> None: if sprite_list.spatial_hash is not None: sprite_list.spatial_hash.move(self) - def register_sprite_list(self, new_list: SpriteList) -> None: + def register_sprite_list(self: SpriteType, new_list: SpriteList[SpriteType]) -> None: """ Register this sprite as belonging to a list. @@ -755,13 +766,15 @@ def register_sprite_list(self, new_list: SpriteList) -> None: """ self.sprite_lists.append(new_list) + def _unregister_sprite_list(self: SpriteType, new_list: SpriteList[SpriteType]) -> None: + """Unregister this sprite as belonging to a list.""" + self.sprite_lists.remove(new_list) + def remove_from_sprite_lists(self) -> None: """Remove the sprite from all sprite lists.""" while len(self.sprite_lists) > 0: self.sprite_lists[0].remove(self) - self.sprite_lists.clear() - # ----- Drawing Methods ----- def draw_hit_box(self, color: RGBOrA255 = BLACK, line_thickness: float = 2.0) -> None: diff --git a/arcade/sprite/sprite.py b/arcade/sprite/sprite.py index 761cf86672..e67cf5795e 100644 --- a/arcade/sprite/sprite.py +++ b/arcade/sprite/sprite.py @@ -1,6 +1,6 @@ import math from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any import arcade from arcade import Texture @@ -11,10 +11,6 @@ from .base import BasicSprite from .mixins import PymunkMixin -if TYPE_CHECKING: # handle import cycle caused by type hinting - from arcade.sprite_list import SpriteList - - __all__ = ["Sprite"] @@ -141,7 +137,6 @@ def __init__( self.physics_engines: list[Any] = [] """List of physics engines that have registered this sprite.""" - self._sprite_list: SpriteList | None = None # Debug properties self.guid: str | None = None """A unique id for debugging purposes.""" diff --git a/arcade/sprite_list/__init__.py b/arcade/sprite_list/__init__.py index 465fe90ddf..f8b93309bf 100644 --- a/arcade/sprite_list/__init__.py +++ b/arcade/sprite_list/__init__.py @@ -1,4 +1,4 @@ -from .sprite_list import SpriteList +from .sprite_list import SpriteList, SpriteSequence from .spatial_hash import SpatialHash from .collision import ( get_distance_between_sprites, @@ -14,6 +14,7 @@ __all__ = [ "SpriteList", + "SpriteSequence", "SpatialHash", "get_distance_between_sprites", "get_closest_sprite", diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index f7955f0d3a..c8526d252d 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -17,7 +17,7 @@ from arcade.types import Point from arcade.types.rect import Rect -from .sprite_list import SpriteList +from .sprite_list import SpriteSequence def get_distance_between_sprites(sprite1: SpriteType, sprite2: SpriteType) -> float: @@ -32,7 +32,7 @@ def get_distance_between_sprites(sprite1: SpriteType, sprite2: SpriteType) -> fl def get_closest_sprite( - sprite: SpriteType, sprite_list: SpriteList + sprite: BasicSprite, sprite_list: SpriteSequence[SpriteType] ) -> Tuple[SpriteType, float] | None: """ Given a Sprite and SpriteList, returns the closest sprite, and its distance. @@ -74,7 +74,7 @@ def check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool: if __debug__: if not isinstance(sprite1, BasicSprite): raise TypeError("Parameter 1 is not an instance of a Sprite class.") - if isinstance(sprite2, SpriteList): + if isinstance(sprite2, SpriteSequence): raise TypeError( "Parameter 2 is a instance of the SpriteList instead of a required " "Sprite. See if you meant to call check_for_collision_with_list instead " @@ -133,7 +133,7 @@ def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool: def _get_nearby_sprites( - sprite: BasicSprite, sprite_list: SpriteList[SpriteType] + sprite: BasicSprite, sprite_list: SpriteSequence[SpriteType] ) -> List[SpriteType]: sprite_count = len(sprite_list) if sprite_count == 0: @@ -186,7 +186,7 @@ def _get_nearby_sprites( def check_for_collision_with_list( sprite: BasicSprite, - sprite_list: SpriteList[SpriteType], + sprite_list: SpriteSequence[SpriteType], method: int = 0, ) -> List[SpriteType]: """ @@ -218,7 +218,7 @@ def check_for_collision_with_list( f"Parameter 1 is not an instance of the Sprite class, " f"it is an instance of {type(sprite)}." ) - if not isinstance(sprite_list, SpriteList): + if not isinstance(sprite_list, SpriteSequence): raise TypeError(f"Parameter 2 is a {type(sprite_list)} instead of expected SpriteList.") sprites_to_check: Iterable[SpriteType] @@ -246,7 +246,7 @@ def check_for_collision_with_list( def check_for_collision_with_lists( sprite: BasicSprite, - sprite_lists: Iterable[SpriteList[SpriteType]], + sprite_lists: Iterable[SpriteSequence[SpriteType]], method=1, ) -> List[SpriteType]: """ @@ -290,7 +290,7 @@ def check_for_collision_with_lists( return sprites -def get_sprites_at_point(point: Point, sprite_list: SpriteList[SpriteType]) -> List[SpriteType]: +def get_sprites_at_point(point: Point, sprite_list: SpriteSequence[SpriteType]) -> List[SpriteType]: """ Get a list of sprites at a particular point. This function sees if any sprite overlaps the specified point. If a sprite has a different center_x/center_y but touches the point, @@ -303,7 +303,7 @@ def get_sprites_at_point(point: Point, sprite_list: SpriteList[SpriteType]) -> L :returns: List of sprites colliding, or an empty list. """ if __debug__: - if not isinstance(sprite_list, SpriteList): + if not isinstance(sprite_list, SpriteSequence): raise TypeError(f"Parameter 2 is a {type(sprite_list)} instead of expected SpriteList.") sprites_to_check: Iterable[SpriteType] @@ -321,7 +321,7 @@ def get_sprites_at_point(point: Point, sprite_list: SpriteList[SpriteType]) -> L def get_sprites_at_exact_point( - point: Point, sprite_list: SpriteList[SpriteType] + point: Point, sprite_list: SpriteSequence[SpriteType] ) -> List[SpriteType]: """ Get a list of sprites whose center_x, center_y match the given point. @@ -334,7 +334,7 @@ def get_sprites_at_exact_point( List of sprites colliding, or an empty list. """ if __debug__: - if not isinstance(sprite_list, SpriteList): + if not isinstance(sprite_list, SpriteSequence): raise TypeError(f"Parameter 2 is a {type(sprite_list)} instead of expected SpriteList.") sprites_to_check: Iterable[SpriteType] @@ -349,7 +349,7 @@ def get_sprites_at_exact_point( return [s for s in sprites_to_check if s.position == point] -def get_sprites_in_rect(rect: Rect, sprite_list: SpriteList[SpriteType]) -> List[SpriteType]: +def get_sprites_in_rect(rect: Rect, sprite_list: SpriteSequence[SpriteType]) -> List[SpriteType]: """ Get a list of sprites in a particular rectangle. This function sees if any sprite overlaps the specified rectangle. If a sprite has a different @@ -365,7 +365,7 @@ def get_sprites_in_rect(rect: Rect, sprite_list: SpriteList[SpriteType]) -> List List of sprites colliding, or an empty list. """ if __debug__: - if not isinstance(sprite_list, SpriteList): + if not isinstance(sprite_list, SpriteSequence): raise TypeError(f"Parameter 2 is a {type(sprite_list)} instead of expected SpriteList.") rect_points = rect.to_points() diff --git a/arcade/sprite_list/spatial_hash.py b/arcade/sprite_list/spatial_hash.py index 53ddb3ffdc..f16778d03b 100644 --- a/arcade/sprite_list/spatial_hash.py +++ b/arcade/sprite_list/spatial_hash.py @@ -1,13 +1,68 @@ +from abc import abstractmethod +from collections.abc import Set from math import trunc -from typing import Generic +from typing import Protocol -from arcade.sprite import SpriteType +from arcade.sprite import SpriteType, SpriteType_co from arcade.sprite.base import BasicSprite from arcade.types import IPoint, Point from arcade.types.rect import Rect -class SpatialHash(Generic[SpriteType]): +class ReadOnlySpatialHash(Protocol[SpriteType_co]): + """A read-only view of a :py:class:`.SpatialHash` which helps preserve safety. + + This works like the read-only views of Python's built-in :py:class:`dict` + and other types. As an every-day user, it means that the underlying + `SpatialHash` may contain subclasses of the annotated type, but not + superclasses. + + This ensures predicable behavior via type safety in cases where: + + #. A spatial hash is annotated with a specific type + #. It is then manipulated outside the original context with a broader type + + Advanced users who want more information on the specifics should see the + comments of :py:class:`~arcade.sprite_list.SpriteList`. + """ + + @abstractmethod + def get_sprites_near_sprite(self, sprite: BasicSprite) -> Set[SpriteType_co]: + """ + Get all the sprites that are in the same buckets as the given sprite. + + Args: + sprite: The sprite to check + """ + ... + + @abstractmethod + def get_sprites_near_point(self, point: Point) -> Set[SpriteType_co]: + """ + Return sprites in the same bucket as the given point. + + Args: + point: The point to check + """ + ... + + @abstractmethod + def get_sprites_near_rect(self, rect: Rect) -> Set[SpriteType_co]: + """ + Return sprites in the same buckets as the given rectangle. + + .. tip:: Use :py:mod:`arcade.types.rect`'s helper functions to create + rectangle objects! + + Args: + rect: + The rectangle to check as a :py:class:`~arcade.types.rect.Rect` + object. + """ + ... + + +class SpatialHash(ReadOnlySpatialHash[SpriteType]): """A data structure best for collision checks with non-moving sprites. It subdivides space into a grid of squares, each with sides of length @@ -104,12 +159,6 @@ def remove(self, sprite: SpriteType) -> None: del self.buckets_for_sprite[sprite] def get_sprites_near_sprite(self, sprite: BasicSprite) -> set[SpriteType]: - """ - Get all the sprites that are in the same buckets as the given sprite. - - Args: - sprite: The sprite to check - """ min_point = trunc(sprite.left), trunc(sprite.bottom) max_point = trunc(sprite.right), trunc(sprite.top) @@ -126,23 +175,11 @@ def get_sprites_near_sprite(self, sprite: BasicSprite) -> set[SpriteType]: return close_by_sprites def get_sprites_near_point(self, point: Point) -> set[SpriteType]: - """ - Return sprites in the same bucket as the given point. - - Args: - point: The point to check - """ hash_point = self.hash((trunc(point[0]), trunc(point[1]))) # Return a copy of the set. return set(self.contents.setdefault(hash_point, set())) def get_sprites_near_rect(self, rect: Rect) -> set[SpriteType]: - """ - Return sprites in the same buckets as the given rectangle. - - Args: - rect: The rectangle to check (left, right, bottom, top) - """ left, right, bottom, top = rect.lrbt min_point = trunc(left), trunc(bottom) max_point = trunc(right), trunc(top) diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 35f642a8ca..8bf44a91c0 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -8,6 +8,7 @@ from __future__ import annotations import random +from abc import abstractmethod from array import array from collections import deque from typing import ( @@ -15,15 +16,15 @@ Any, Callable, ClassVar, + Collection, Deque, - Generic, Iterable, Iterator, Sized, cast, ) -from arcade import Sprite, SpriteType, get_window, gl +from arcade import Sprite, SpriteType, SpriteType_co, get_window, gl from arcade.gl import Program, Texture2D from arcade.gl.buffer import Buffer from arcade.gl.types import BlendFunction, OpenGlFilter, PyGLenum @@ -39,8 +40,108 @@ _DEFAULT_CAPACITY = 100 +class SpriteSequence(Collection[SpriteType_co]): + """A read-only view of a :py:class:`.SpriteList`. + + Like other read-only generics such as :py:class:`collections.abc.Sequence`, + a `SpriteSequence` requires sprites be of a covariant type relative to their + annotated type. + + See :py:class:`.SpriteList` for more details. + """ + + from ..sprite_list import spatial_hash as sh + + @property + @abstractmethod + def spatial_hash(self) -> sh.ReadOnlySpatialHash[SpriteType_co] | None: ... + + @abstractmethod + def __getitem__(self, index: int) -> SpriteType_co: + """Return the sprite at the given index.""" + ... + + @abstractmethod + def update(self, delta_time: float = 1 / 60, *args, **kwargs) -> None: + """ + Call the update() method on each sprite in the list. + + Args: + delta_time: Time since last update in seconds + *args: Additional positional arguments + **kwargs: Additional keyword arguments + """ + ... + + @abstractmethod + def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None: + """ + Call the update_animation in every sprite in the sprite list. + + Args: + delta_time: Time since last update in seconds + *args: Additional positional arguments + **kwargs: Additional keyword arguments + """ + ... + + @abstractmethod + def draw( + self, + *, + filter: PyGLenum | OpenGlFilter | None = None, + pixelated: bool | None = None, + blend_function: BlendFunction | None = None, + ) -> None: + """ + Draw this list of sprites. + + Uninitialized sprite lists will first create OpenGL resources + before drawing. This may cause a performance stutter when the + following are true: + + 1. You created the sprite list with ``lazy=True`` + 2. You did not call :py:meth:`~SpriteList.initialize` before drawing + 3. You are initializing many sprites and/or lists at once + + See :ref:`pg_spritelist_advanced_lazy_spritelists` to learn more. + + Args: + filter: + Optional parameter to set OpenGL filter, such as + `gl.GL_NEAREST` to avoid smoothing. + pixelated: + ``True`` for pixelated and ``False`` for smooth interpolation. + Shortcut for setting filter to GL_NEAREST for a pixelated look. + The filter parameter have precedence over this. + blend_function: + Optional parameter to set the OpenGL blend function used for drawing + the sprite list, such as 'arcade.Window.ctx.BLEND_ADDITIVE' or + 'arcade.Window.ctx.BLEND_DEFAULT' + """ + ... + + @abstractmethod + def draw_hit_boxes( + self, color: RGBOrA255 = (0, 0, 0, 255), line_thickness: float = 1.0 + ) -> None: + """ + Draw all the hit boxes in this list. + + .. warning:: This method is slow and should only be used for debugging. + + Args: + color: The color of the hit boxes + line_thickness: The thickness of the lines + """ + ... + + @abstractmethod + def _write_sprite_buffers_to_gpu(self) -> None: ... + + @copy_dunders_unimplemented # Temp fixes https://github.com/pythonarcade/arcade/issues/2074 -class SpriteList(Generic[SpriteType]): +class SpriteList(SpriteSequence[SpriteType]): """ The purpose of the spriteList is to batch draw a list of sprites. Drawing single sprites will not get you anywhere performance wise @@ -100,6 +201,20 @@ class SpriteList(Generic[SpriteType]): #: arcade.SpriteList.DEFAULT_TEXTURE_FILTER = gl.NEAREST, gl.NEAREST DEFAULT_TEXTURE_FILTER: ClassVar[tuple[int, int]] = gl.LINEAR, gl.LINEAR + # Declare `special_hash` as an attribute that implements the abstract + # property from `SpriteSequence`. It needs an explicit type here because + # it is better than the inherited type. + # More subtle: it requires to be initialized as a *class* attribute with + # `= None` to "delete" the abstract property definition from the class. + # Without that trick, attempt to instantiate a SpriteList results in a + # TypeError: Can't instantiate abstract class SpriteList + # without an implementation for abstract method 'spatial_hash' + # The abstract property is actually implemented as an attribute (for + # efficiency), so it is OK to silence the issue like that. + from ..sprite_list import spatial_hash as sh + + spatial_hash: sh.SpatialHash[SpriteType] | None = None + def __init__( self, use_spatial_hash: bool = False, @@ -167,7 +282,7 @@ def __init__( from .spatial_hash import SpatialHash self._spatial_hash_cell_size = spatial_hash_cell_size - self.spatial_hash: SpatialHash[SpriteType] | None = None + self.spatial_hash = None if use_spatial_hash: self.spatial_hash = SpatialHash(cell_size=self._spatial_hash_cell_size) @@ -247,7 +362,7 @@ def __len__(self) -> int: """Return the length of the sprite list.""" return len(self.sprite_list) - def __contains__(self, sprite: Sprite) -> bool: + def __contains__(self, sprite: object) -> bool: """Return if the sprite list contains the given sprite""" return sprite in self.sprite_slot @@ -269,7 +384,7 @@ def __setitem__(self, index: int, sprite: SpriteType) -> None: pass sprite_to_be_removed = self.sprite_list[index] - sprite_to_be_removed.sprite_lists.remove(self) + sprite_to_be_removed._unregister_sprite_list(self) self.sprite_list[index] = sprite # Replace sprite sprite.register_sprite_list(self) @@ -567,7 +682,7 @@ def clear(self, *, capacity: int | None = None, deep: bool = True) -> None: # Manually remove the spritelist from all sprites if deep: for sprite in self.sprite_list: - sprite.sprite_lists.remove(self) + sprite._unregister_sprite_list(self) self.sprite_list = [] self.sprite_slot = dict() @@ -626,7 +741,7 @@ def pop(self, index: int = -1) -> SpriteType: except KeyError: raise ValueError("Sprite is not in the SpriteList") - sprite.sprite_lists.remove(self) + sprite._unregister_sprite_list(self) del self.sprite_slot[sprite] self._sprite_buffer_free_slots.append(slot) @@ -715,7 +830,7 @@ def remove(self, sprite: SpriteType) -> None: index = self.sprite_list.index(sprite) self.sprite_list.pop(index) - sprite.sprite_lists.remove(self) + sprite._unregister_sprite_list(self) del self.sprite_slot[sprite] self._sprite_buffer_free_slots.append(slot) @@ -728,7 +843,7 @@ def remove(self, sprite: SpriteType) -> None: if self.spatial_hash is not None: self.spatial_hash.remove(sprite) - def extend(self, sprites: Iterable[SpriteType] | SpriteList[SpriteType]) -> None: + def extend(self, sprites: Iterable[SpriteType]) -> None: """ Extends the current list with the given iterable @@ -874,26 +989,10 @@ def _recalculate_spatial_hashes(self) -> None: self.spatial_hash.add(sprite) def update(self, delta_time: float = 1 / 60, *args, **kwargs) -> None: - """ - Call the update() method on each sprite in the list. - - Args: - delta_time: Time since last update in seconds - *args: Additional positional arguments - **kwargs: Additional keyword arguments - """ for sprite in self.sprite_list: sprite.update(delta_time, *args, **kwargs) def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None: - """ - Call the update_animation in every sprite in the sprite list. - - Args: - delta_time: Time since last update in seconds - *args: Additional positional arguments - **kwargs: Additional keyword arguments - """ for sprite in self.sprite_list: sprite.update_animation(delta_time, *args, **kwargs) @@ -1009,32 +1108,6 @@ def draw( pixelated: bool | None = None, blend_function: BlendFunction | None = None, ) -> None: - """ - Draw this list of sprites. - - Uninitialized sprite lists will first create OpenGL resources - before drawing. This may cause a performance stutter when the - following are true: - - 1. You created the sprite list with ``lazy=True`` - 2. You did not call :py:meth:`~SpriteList.initialize` before drawing - 3. You are initializing many sprites and/or lists at once - - See :ref:`pg_spritelist_advanced_lazy_spritelists` to learn more. - - Args: - filter: - Optional parameter to set OpenGL filter, such as - `gl.GL_NEAREST` to avoid smoothing. - pixelated: - ``True`` for pixelated and ``False`` for smooth interpolation. - Shortcut for setting filter to GL_NEAREST for a pixelated look. - The filter parameter have precedence over this. - blend_function: - Optional parameter to set the OpenGL blend function used for drawing - the sprite list, such as 'arcade.Window.ctx.BLEND_ADDITIVE' or - 'arcade.Window.ctx.BLEND_DEFAULT' - """ if len(self.sprite_list) == 0 or not self._visible or self.alpha_normalized == 0.0: return @@ -1105,15 +1178,6 @@ def draw( def draw_hit_boxes( self, color: RGBOrA255 = (0, 0, 0, 255), line_thickness: float = 1.0 ) -> None: - """ - Draw all the hit boxes in this list. - - .. warning:: This method is slow and should only be used for debugging. - - Args: - color: The color of the hit boxes - line_thickness: The thickness of the lines - """ import arcade converted_color = Color.from_iterable(color) diff --git a/tests/unit/spritelist/test_spritesequence.py b/tests/unit/spritelist/test_spritesequence.py new file mode 100644 index 0000000000..454ba7a7cb --- /dev/null +++ b/tests/unit/spritelist/test_spritesequence.py @@ -0,0 +1,26 @@ +import arcade + +class _CustomSpriteSolidColor(arcade.SpriteSolidColor): + pass + +def test_collective_draw(window: arcade.Window) -> None: + sprite_list1: arcade.SpriteList[arcade.Sprite] = arcade.SpriteList() + sprite_list1.append(arcade.SpriteSolidColor(16, 16, color=(255, 0, 0, 1))) + + sprite_list2: arcade.SpriteList[_CustomSpriteSolidColor] = arcade.SpriteList() + sprite_list2.append(_CustomSpriteSolidColor(16, 16, color=(255, 0, 0, 1))) + + # It really is a SpriteList with a good type; this would not typecheck otherwise + custom_sprite: _CustomSpriteSolidColor = sprite_list2[0] # assert_type + + # Assert that SpriteSequence is truly covariant: + # It can be used as a common type for different types of SpriteLists. + scene: list[arcade.SpriteSequence[arcade.Sprite]] = [ + sprite_list1, + sprite_list2, + ] + sprite: arcade.Sprite = scene[0][0] # assert_type + + # We can collectively draw all the SpriteSequences. + for sprite_list in scene: + sprite_list.draw() From 83f94edc85cd7480cc7d9dee2da44823091ddd5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Doeraene?= Date: Thu, 1 May 2025 19:05:21 +0200 Subject: [PATCH 144/279] Fix #2658: Use `is not None` for `boundary_left` and `boundary_right`. (#2659) Like was already done for `boundary_top` and `boundary_bottom`. --- arcade/physics_engines.py | 10 ++++-- .../test_physics_engine_platformer.py | 32 +++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index 6f2dce0b86..ab8c1412dd 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -792,12 +792,18 @@ def update(self) -> list[BasicSprite]: for platform in platform_list: if platform.change_x != 0 or platform.change_y != 0: # Check x boundaries and move the platform in x direction - if platform.boundary_left and platform.left <= platform.boundary_left: + if ( + platform.boundary_left is not None + and platform.left <= platform.boundary_left + ): platform.left = platform.boundary_left if platform.change_x < 0: platform.change_x *= -1 - if platform.boundary_right and platform.right >= platform.boundary_right: + if ( + platform.boundary_right is not None + and platform.right >= platform.boundary_right + ): platform.right = platform.boundary_right if platform.change_x > 0: platform.change_x *= -1 diff --git a/tests/unit/physics_engine/test_physics_engine_platformer.py b/tests/unit/physics_engine/test_physics_engine_platformer.py index 0eca1c7ca5..9818c2857b 100644 --- a/tests/unit/physics_engine/test_physics_engine_platformer.py +++ b/tests/unit/physics_engine/test_physics_engine_platformer.py @@ -26,9 +26,22 @@ def test_physics_engine(window): sprite.center_y = 32 wall_list.append(sprite) + platform_list = arcade.SpriteList[arcade.Sprite]() + platform = arcade.Sprite( + ":resources:images/tiles/boxCrate_double.png", + scale=CHARACTER_SCALING, + center_x=64, + center_y=256, + ) + platform.boundary_left = 0 # 0 in particular was problematic, see #2658 + platform.boundary_right = 128 + platform.change_x = 8 + platform_list.append(platform) + physics_engine = arcade.PhysicsEnginePlatformer( character_sprite, - wall_list, + walls=wall_list, + platforms=platform_list, gravity_constant=GRAVITY, ) @@ -48,10 +61,23 @@ def update(td): assert physics_engine.can_jump() is True character_sprite.change_y = 15 physics_engine.increment_jump_counter() - window.test() + + window.test(frames=7) + assert physics_engine.can_jump() is True + assert platform.center_x == 80 # it bounced against the boundary_right + assert platform.change_x == -8 character_sprite.change_y = 15 physics_engine.increment_jump_counter() - window.test() + + window.test(frames=6) + assert physics_engine.can_jump() is False + assert platform.center_x == 32 # right at the boundary + assert platform.change_x == -8 # still going left physics_engine.disable_multi_jump() + + window.test(frames=3) + + assert platform.center_x == 32 + 24 # it bounced against the boundary_left + assert platform.change_x == +8 From 84bc13a9136bac71f526a3f5b269c99d9aa8f00d Mon Sep 17 00:00:00 2001 From: Kyle McVeigh Date: Fri, 2 May 2025 17:48:42 -0500 Subject: [PATCH 145/279] Add screenshot and add mama nyahs house of tarot to example list --- doc/community/games/sample_games.rst | 12 ++++++++++++ doc/images/community/games/house_of_tarot.png | Bin 0 -> 862171 bytes 2 files changed, 12 insertions(+) create mode 100644 doc/images/community/games/house_of_tarot.png diff --git a/doc/community/games/sample_games.rst b/doc/community/games/sample_games.rst index 2e066ec238..4f173c9d96 100644 --- a/doc/community/games/sample_games.rst +++ b/doc/community/games/sample_games.rst @@ -316,6 +316,18 @@ A castle adventure through a dungeon and caverns by Paul Craven. .. _GitHub repo for Two Worlds: https://github.com/pvcraven/two_worlds +Mama Nyah's House of Tarot +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A tarot card reading game currently available on Steam made by Cat & Mallard Studios + +.. image:: /images/community/games/house_of_tarot.png + :width: 75% + +`GitHub repo for Mama Nyah's House of Tarot`_ + +.. _GitHub repo for Mama Nyah's House of Tarot: https://github.com/DevinReid/Tarot_Generate_Arcade + Simpson College Spring 2017 CMSC 150 Course ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/images/community/games/house_of_tarot.png b/doc/images/community/games/house_of_tarot.png new file mode 100644 index 0000000000000000000000000000000000000000..5adcca23445fcfa125cacf4a4ef2c791d5980e56 GIT binary patch literal 862171 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

    og2hbznF_dovx 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

    cVsNhv3JQ3 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

    8QfDp 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

    Au!0r3?n@=!`_;`$llriK>I%!*jk;y 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

    `|xwWId_J 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%0A*M|P*^E<_jqnQX&GQCAN?l&+3uy=E{{uJqJr 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

    P12esX*%i9O{#5-D^sxvbu~kW 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Νq7xe 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 G_lW;5HD|2*_;{IT?qzUPw5x-zLtrx)Pq;H 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℘^Yyb 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⪼Qc)NG6c7l!pjIzx7RL+ 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 Rwt4^n002ovPDHLkV1kMtEMEWs literal 0 HcmV?d00001 From b811743ce7759ee964f550dc896a9e3ed0772e02 Mon Sep 17 00:00:00 2001 From: Kyle McVeigh Date: Sat, 3 May 2025 12:34:22 -0500 Subject: [PATCH 146/279] Add link to steam page --- doc/community/games/sample_games.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/community/games/sample_games.rst b/doc/community/games/sample_games.rst index 4f173c9d96..1a313bf320 100644 --- a/doc/community/games/sample_games.rst +++ b/doc/community/games/sample_games.rst @@ -328,6 +328,10 @@ A tarot card reading game currently available on Steam made by Cat & Mallard Stu .. _GitHub repo for Mama Nyah's House of Tarot: https://github.com/DevinReid/Tarot_Generate_Arcade +`Steam page for Mama Nyah's House of Tarot`_ + +.. _Steam page for Mama Nyah's House of Tarot: https://store.steampowered.com/app/3582900/Mama_Nyahs_House_of_Tarot/ + Simpson College Spring 2017 CMSC 150 Course ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From d015e818b0eaee16f64a2b325b91a72a37ad35bc Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 3 May 2025 20:32:50 +0200 Subject: [PATCH 147/279] check_for_collision: Trigger spritelist init if needed (#2662) --- arcade/sprite_list/collision.py | 2 +- arcade/sprite_list/sprite_list.py | 6 +++--- tests/unit/sprite/test_sprite_collision.py | 12 ++++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index c8526d252d..4d58f65f46 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -154,7 +154,7 @@ def _get_nearby_sprites( # Run the transform shader emitting sprites close to the configured position and size. # This runs in a query so we can measure the number of sprites emitted. with ctx.collision_query: - sprite_list._geometry.transform( # type: ignore + sprite_list.geometry.transform( # type: ignore ctx.collision_detection_program, buffer, vertices=sprite_count, diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 8bf44a91c0..f2ca56e606 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -521,10 +521,10 @@ def geometry(self) -> Geometry: in float in_texture; in vec4 in_color; """ - if not self._geometry: - raise ValueError("SpriteList is not initialized.") + if not self._initialized: + self.initialize() - return self._geometry + return self._geometry # type: ignore @property def buffer_positions(self) -> Buffer: diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index 0de3e2e6a6..33d6803e28 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -311,3 +311,15 @@ def test_get_sprites_in_rect(use_spatial_hash): assert set(arcade.get_sprites_in_rect(arcade.LRBT(100, 200, 100, 200), sp)) == set() assert set(arcade.get_sprites_in_rect(arcade.LRBT(-100, 0, -100, 0), sp)) == {b, d} assert set(arcade.get_sprites_in_rect(arcade.LRBT(100, 0, 100, 0), sp)) == {a, c} + + +def test_cpu_collision_with_lazy_list(window): + """ + Do GPU collision check with lazy list. + This ensures that check_for_collision_with_list() will trigger + a spritelist initialization if his is not done yet. + """ + sprite = arcade.SpriteSolidColor(50, 50, color=arcade.csscolor.RED) + spritelist = arcade.SpriteList(lazy=True) + spritelist.append(arcade.SpriteSolidColor(50, 50, color=arcade.csscolor.RED)) + arcade.check_for_collision_with_list(sprite, spritelist, method=2) From f9c7f63d3450e7c08f14faa9920c2290b66eb635 Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Sat, 3 May 2025 21:27:35 +0200 Subject: [PATCH 148/279] Fix ScrollArea redrawing every frame The _requires_render property was not reset to False after rendering. This caused the UIScrollArea to re-render every frame. --- arcade/gui/experimental/scroll_area.py | 1 + 1 file changed, 1 insertion(+) diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index 25b75d5d48..eb3e6afbad 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -326,6 +326,7 @@ def _do_render(self, surface: Surface, force=False) -> bool: self.do_render_base(surface) self.do_render(surface) self._rendered = True + self._requires_render = False return rendered From 821712fbf7db5bc6fea0c7fad91d612a6f74a94c Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Sat, 3 May 2025 23:30:12 +0200 Subject: [PATCH 149/279] Fix sprite bullets aimed example does not play sounds (#2665) --- arcade/examples/sprite_bullets_aimed.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/arcade/examples/sprite_bullets_aimed.py b/arcade/examples/sprite_bullets_aimed.py index 198ce6936b..999df74ab5 100644 --- a/arcade/examples/sprite_bullets_aimed.py +++ b/arcade/examples/sprite_bullets_aimed.py @@ -109,6 +109,9 @@ def on_draw(self): def on_mouse_press(self, x, y, button, modifiers): """ Called whenever the mouse button is clicked. """ + # Play the gun sound + self.gun_sound.play() + # Create a bullet bullet = arcade.Sprite( ":resources:images/space_shooter/laserBlue01.png", @@ -159,10 +162,12 @@ def on_update(self, delta_time): # Check this bullet to see if it hit a coin hit_list = arcade.check_for_collision_with_list(bullet, self.coin_list) - # If it did, get rid of the bullet + # If it did, get rid of the bullet and play the hit sound if len(hit_list) > 0: bullet.remove_from_sprite_lists() + self.hit_sound.play() + # For every coin we hit, add to the score and remove the coin for coin in hit_list: coin.remove_from_sprite_lists() From e76abba3f9f44cc2a78569426b0b591fd5f19096 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 4 May 2025 11:54:43 +0200 Subject: [PATCH 150/279] GUI surface shader work (#2657) * GUI surface shader work * Lazy update vertices + left/bottom fix * Type fix --- arcade/gui/surface.py | 70 +++++++++++++++++-- .../system/shaders/gui/surface_gs.glsl | 60 ---------------- .../system/shaders/gui/surface_vs.glsl | 13 +++- 3 files changed, 75 insertions(+), 68 deletions(-) delete mode 100644 arcade/resources/system/shaders/gui/surface_gs.glsl diff --git a/arcade/gui/surface.py b/arcade/gui/surface.py index 2376ec7bcf..200ebce209 100644 --- a/arcade/gui/surface.py +++ b/arcade/gui/surface.py @@ -1,14 +1,16 @@ +from array import array from contextlib import contextmanager from typing import Generator from PIL import Image +from pyglet.math import Vec2, Vec4 from typing_extensions import Self import arcade from arcade import Texture from arcade.camera import CameraData, OrthographicProjectionData, OrthographicProjector from arcade.color import TRANSPARENT_BLACK -from arcade.gl import Framebuffer +from arcade.gl import BufferDescription, Framebuffer from arcade.gui.nine_patch import NinePatchTexture from arcade.types import LBWH, RGBA255, Point, Rect @@ -37,6 +39,7 @@ def __init__( self._pos = position self._pixel_ratio = pixel_ratio self._pixelated = False + self._area: Rect | None = None # Cached area for the last draw call self.texture = self.ctx.texture(self.size_scaled, components=4) self.fbo: Framebuffer = self.ctx.framebuffer(color_attachments=[self.texture]) @@ -53,12 +56,17 @@ def __init__( *self.ctx.BLEND_DEFAULT, ) - self._geometry = self.ctx.geometry() + # 5 floats per vertex (pos 3f, tex 2f) with 4 vertices + self._buffer = self.ctx.buffer(reserve=4 * 5 * 4) + self._geometry = self.ctx.geometry( + content=[BufferDescription(self._buffer, "3f 2f", ["in_pos", "in_uv"])], + mode=self.ctx.TRIANGLE_STRIP, + ) self._program = self.ctx.load_program( vertex_shader=":system:shaders/gui/surface_vs.glsl", - geometry_shader=":system:shaders/gui/surface_gs.glsl", fragment_shader=":system:shaders/gui/surface_fs.glsl", ) + self._update_geometry() self._cam = OrthographicProjector( view=CameraData(), @@ -228,6 +236,8 @@ def draw( area: Limit the area in the surface we're drawing (l, b, w, h) """ + self._update_geometry(area=area) + # Set blend function blend_func = self.ctx.blend_func self.ctx.blend_func = self.blend_func_render @@ -239,10 +249,7 @@ def draw( self.texture.filter = self.ctx.LINEAR, self.ctx.LINEAR self.texture.use(0) - self._program["pos"] = self._pos - self._program["size"] = self._size - self._program["area"] = (0, 0, *self._size) if not area else area.lbwh - self._geometry.render(self._program, vertices=1) + self._geometry.render(self._program) # Restore blend function self.ctx.blend_func = blend_func @@ -267,3 +274,52 @@ def resize(self, *, size: tuple[int, int], pixel_ratio: float) -> None: def to_image(self) -> Image.Image: """Convert the surface to an PIL image""" return self.ctx.get_framebuffer_image(self.fbo) + + def _update_geometry(self, area: Rect | None = None) -> None: + """ + Update the internal geometry of the surface mesh. + + The geometry is a triangle strip with 4 vertices. + """ + if area is None: + area = LBWH(0, 0, *self.size) + + if self._area == area: + return + self._area = area + + # Clamp the area inside the surface + # This is the local area inside the surface + _size = Vec2(*self.size) + _pos = Vec2(*self.position) + _area_pos = Vec2(area.left, area.bottom) + _area_size = Vec2(area.width, area.height) + + b1 = _area_pos.clamp(Vec2(0.0), _size) + end_point = _area_pos + _area_size + b2 = end_point.clamp(Vec2(0.0), _size) + b = b2 - b1 + l_area = Vec4(b1.x, b1.y, b.x, b.y) + + # Create the 4 corners of the rectangle + # These are the final/global coordinates rendered + p_ll = _pos + l_area.xy # type: ignore + p_lr = _pos + l_area.xy + Vec2(l_area.z, 0.0) # type: ignore + p_ul = _pos + l_area.xy + Vec2(0.0, l_area.w) # type: ignore + p_ur = _pos + l_area.xy + l_area.zw # type: ignore + + # Calculate the UV coordinates + bottom = l_area.y / _size.y + left = l_area.x / _size.x + top = (l_area.y + l_area.w) / _size.y + right = (l_area.x + l_area.z) / _size.x + + # fmt: off + vertices = array("f", ( + p_ll.x, p_ll.y, 0.0, left, bottom, + p_lr.x, p_lr.y, 0.0, right, bottom, + p_ul.x, p_ul.y, 0.0, left, top, + p_ur.x, p_ur.y, 0.0, right, top, + )) + # fmt: on + self._buffer.write(vertices) diff --git a/arcade/resources/system/shaders/gui/surface_gs.glsl b/arcade/resources/system/shaders/gui/surface_gs.glsl deleted file mode 100644 index 926c3e825b..0000000000 --- a/arcade/resources/system/shaders/gui/surface_gs.glsl +++ /dev/null @@ -1,60 +0,0 @@ -#version 330 - -layout (points) in; -layout (triangle_strip, max_vertices = 4) out; - -uniform WindowBlock { - mat4 projection; - mat4 view; -} window; - -uniform vec2 pos; -uniform vec2 size; -uniform vec4 area; - -out vec2 uv; - -void main() { - mat4 mvp = window.projection * window.view; - - // Clamp the area inside the surface - // This is the local area inside the surface - // Format is (x, y, width, height) - vec2 b1 = clamp(area.xy, vec2(0.0), size); - vec2 b2 = clamp(area.xy + area.zw, vec2(0.0), size); - vec4 l_area = vec4( - clamp(area.xy, vec2(0.0), size), - b2 - b1 - ); - - // Create the 4 corners of the rectangle - // These are the final/global coordinates rendered - vec2 p_ll = pos + l_area.xy; - vec2 p_lr = pos + l_area.xy + vec2(l_area.z, 0.0); - vec2 p_ul = pos + l_area.xy + vec2(0, l_area.w);; - vec2 p_ur = pos + l_area.xy + l_area.zw; - - // Calculate the UV coordinates - float bottom = l_area.y / size.y; - float left = l_area.x / size.x; - float top = (l_area.y + l_area.w) / size.y; - float right = (l_area.x + l_area.z) / size.x; - - gl_Position = mvp * vec4(p_ll, 0.0, 1.0); - uv = vec2(left, bottom); - EmitVertex(); - - gl_Position = mvp * vec4(p_lr, 0.0, 1.0); - uv = vec2(right, bottom); - EmitVertex(); - - gl_Position = mvp * vec4(p_ul, 0.0, 1.0); - uv = vec2(left, top); - EmitVertex(); - - gl_Position = mvp * vec4(p_ur, 0.0, 1.0); - uv = vec2(right, top); - EmitVertex(); - - EndPrimitive(); -} diff --git a/arcade/resources/system/shaders/gui/surface_vs.glsl b/arcade/resources/system/shaders/gui/surface_vs.glsl index aa6ad9e8ac..ab8953538c 100644 --- a/arcade/resources/system/shaders/gui/surface_vs.glsl +++ b/arcade/resources/system/shaders/gui/surface_vs.glsl @@ -1,5 +1,16 @@ #version 330 +uniform WindowBlock { + mat4 projection; + mat4 view; +} window; + +in vec3 in_pos; +in vec2 in_uv; + +out vec2 uv; + void main() { - gl_Position = vec4(0.0, 0.0, 0.0, 1.0); + gl_Position = window.projection * window.view * vec4(in_pos, 1.0); + uv = in_uv; } From ae98f5e4bb36699def11a623e16381791afe8ae2 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 4 May 2025 20:41:01 +0200 Subject: [PATCH 151/279] gui: add visualisation to the slider step feature and update changelog --- CHANGELOG.md | 2 + arcade/examples/gui/2_widgets.py | 51 +++++++++---- arcade/examples/gui/6_size_hints.py | 14 ++-- arcade/gui/widgets/slider.py | 107 ++++++++++++++++++++++++++-- 4 files changed, 149 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 010a5ef2f5..e36ddc20fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - GUI - Fix `UIScrollArea.add` always returning None - Support `layer` in `UIView.add_widget()` + - Fix a bug which caused `UIScrollArea` to refresh on every frame + - Add stepping to `UISlider` (thanks [csd4ni3l](https://github.com/csd4ni3l)) - Text objects are now lazy and can be created before the window - Introduce `arcade.SpriteSequence[T]` as a covariant supertype of `arcade.SpriteList[T]` (this is similar to Python's `Sequence[T]`, which is a supertype of `list[T]`) diff --git a/arcade/examples/gui/2_widgets.py b/arcade/examples/gui/2_widgets.py index 7cf71828ce..74223dc0c3 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -410,12 +410,35 @@ def _show_interactive_widgets(self): size_hint=(0.3, 0), ) ) - slider_row.add( + s1 = slider_row.add(UISlider(size_hint=(0.3, 1), step=1, style=UISlider.NO_STEP_STYLE)) + s2 = slider_row.add( + UISlider( + size_hint=(0.3, 1), + step=5, + ) + ) + s3 = slider_row.add( UISlider( - size_hint=(0.2, None), + size_hint=(0.3, 1), + step=10, ) ) + @s1.event("on_change") + def _(event: UIOnChangeEvent): + s2.value = event.new_value + s3.value = event.new_value + + @s2.event("on_change") + def _(event: UIOnChangeEvent): + s1.value = event.new_value + s3.value = event.new_value + + @s3.event("on_change") + def _(event: UIOnChangeEvent): + s1.value = event.new_value + s2.value = event.new_value + tex_slider_row = UIBoxLayout(vertical=False, size_hint=(1, 0.1), space_between=10) box.add(tex_slider_row) @@ -428,7 +451,7 @@ def _show_interactive_widgets(self): ) ) - s1 = tex_slider_row.add( + ts1 = tex_slider_row.add( UITextureSlider( thumb_texture=TEX_SLIDER_THUMB_BLUE, track_texture=NinePatchTexture(10, 10, 10, 10, TEX_SLIDER_TRACK_BLUE), @@ -440,7 +463,7 @@ def _show_interactive_widgets(self): green_style["normal"].filled_track = arcade.uicolor.GREEN_GREEN_SEA green_style["hover"].filled_track = arcade.uicolor.GREEN_EMERALD green_style["press"].filled_track = arcade.uicolor.GREEN_GREEN_SEA - s2 = tex_slider_row.add( + ts2 = tex_slider_row.add( UITextureSlider( thumb_texture=TEX_SLIDER_THUMB_GREEN, track_texture=NinePatchTexture(10, 10, 10, 10, TEX_SLIDER_TRACK_GREEN), @@ -453,7 +476,7 @@ def _show_interactive_widgets(self): red_style["normal"].filled_track = arcade.uicolor.RED_POMEGRANATE red_style["hover"].filled_track = arcade.uicolor.RED_ALIZARIN red_style["press"].filled_track = arcade.uicolor.RED_POMEGRANATE - s3 = tex_slider_row.add( + ts3 = tex_slider_row.add( UITextureSlider( thumb_texture=TEX_SLIDER_THUMB_RED, track_texture=NinePatchTexture(10, 10, 10, 10, TEX_SLIDER_TRACK_RED), @@ -462,20 +485,20 @@ def _show_interactive_widgets(self): ) ) - @s1.event("on_change") + @ts1.event("on_change") def _(event: UIOnChangeEvent): - s2.value = event.new_value - s3.value = event.new_value + ts2.value = event.new_value + ts3.value = event.new_value - @s2.event("on_change") + @ts2.event("on_change") def _(event: UIOnChangeEvent): - s1.value = event.new_value - s3.value = event.new_value + ts1.value = event.new_value + ts3.value = event.new_value - @s3.event("on_change") + @ts3.event("on_change") def _(event: UIOnChangeEvent): - s1.value = event.new_value - s2.value = event.new_value + ts1.value = event.new_value + ts2.value = event.new_value box.add(UISpace(size_hint=(0.2, 0.1))) text_area = box.add( diff --git a/arcade/examples/gui/6_size_hints.py b/arcade/examples/gui/6_size_hints.py index 6f95713118..8801eb8470 100644 --- a/arcade/examples/gui/6_size_hints.py +++ b/arcade/examples/gui/6_size_hints.py @@ -98,7 +98,9 @@ def __init__(self): width_slider_box = center_box.add(UIBoxLayout(vertical=False, size_hint=(1, 0))) width_slider_box.add(UILabel("Modify size_hint:", bold=True)) width_slider = width_slider_box.add( - arcade.gui.UISlider(min_value=0, max_value=10, value=0, size_hint=None, height=30) + arcade.gui.UISlider( + min_value=0, max_value=1, value=0, size_hint=None, height=30, step=0.1 + ) ) width_value = width_slider_box.add(UILabel(bold=True)) @@ -116,17 +118,17 @@ def __init__(self): def update_size_hint_value(value: float): width_value.text = f"({value:.2f})" - dummy1.size_hint = (value / 10, 1) - dummy1.text = f"size_hint = ({value / 10:.2f}, 1)" + dummy1.size_hint = (value, 1) + dummy1.text = f"size_hint = ({value:.2f}, 1)" - dummy2.size_hint = (1 - value / 10, 1) - dummy2.text = f"size_hint = ({1 - value / 10:.2f}, 1)" + dummy2.size_hint = (1 - value, 1) + dummy2.text = f"size_hint = ({1 - value:.2f}, 1)" @width_slider.event("on_change") def on_change(event: UIOnChangeEvent): update_size_hint_value(event.new_value) - initial_value = 10 + initial_value = 1 width_slider.value = initial_value update_size_hint_value(initial_value) diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index b073a3a9db..b7c6ed0ab8 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -84,7 +84,7 @@ def __init__( ) self.step = step - self.value = self._apply_step(value) + self.value = value self.min_value = min_value self.max_value = max_value @@ -92,12 +92,21 @@ def __init__( # trigger render on value changes bind(self, "value", self.trigger_full_render) + bind(self, "value", self._ensure_step) bind(self, "hovered", self.trigger_render) bind(self, "pressed", self.trigger_render) bind(self, "disabled", self.trigger_render) self.register_event_type("on_change") + def _ensure_step(self): + """Ensure that the step is applied.""" + if self.step is not None: + # this will trigger the change once again + # only option to prevent this would be to make `value` a property, + # which might break code of users + self.value = self._apply_step(self.value) + def _apply_step(self, value: float): if self.step: inverse = 1 / self.step @@ -120,9 +129,7 @@ def norm_value(self): @norm_value.setter def norm_value(self, value): """Normalized value between 0.0 and 1.0""" - self.value = self._apply_step( - min(value * (self.max_value - self.min_value) + self.min_value, self.max_value) - ) + self.value = min(value * (self.max_value - self.min_value) + self.min_value, self.max_value) @property def _thumb_x(self): @@ -147,6 +154,7 @@ def do_render(self, surface: Surface): """Render the slider, including track and thumb.""" self.prepare_render(surface) self._render_track(surface) + self._render_steps(surface) self._render_thumb(surface) @abstractmethod @@ -162,6 +170,19 @@ def _render_track(self, surface: Surface): """ pass + @abstractmethod + def _render_steps(self, surface: Surface): + """Render the steps of the slider track. + + This method should be implemented in a slider implementation. + + Steps should stay within self.content_rect. + + Args: + surface: Surface to render on. + """ + pass + @abstractmethod def _render_thumb(self, surface: Surface): """Render the thumb of the slider. @@ -236,15 +257,18 @@ class UISliderStyle(UIStyleBase): border: Border color. border_width: Width of the border. filled_track: Color of the filled track. + filled_step: Color of the step in filled area. unfilled_track: Color of the unfilled track. - + unfilled_step: Color of the step in unfilled area. """ bg: RGBA255 = uicolor.WHITE_SILVER border: RGBA255 = uicolor.DARK_BLUE_MIDNIGHT_BLUE border_width: int = 2 filled_track: RGBA255 = uicolor.DARK_BLUE_MIDNIGHT_BLUE + filled_step: RGBA255 | None = uicolor.BLUE_PETER_RIVER unfilled_track: RGBA255 = uicolor.WHITE_SILVER + unfilled_step: RGBA255 | None = uicolor.BLUE_PETER_RIVER class UISlider(UIStyledWidget[UISliderStyle], UIBaseSlider): @@ -277,20 +301,54 @@ class UISlider(UIStyledWidget[UISliderStyle], UIBaseSlider): border=uicolor.BLUE_PETER_RIVER, border_width=2, filled_track=uicolor.BLUE_PETER_RIVER, + filled_step=uicolor.DARK_BLUE_MIDNIGHT_BLUE, + ), + "press": UIStyle( + bg=uicolor.BLUE_PETER_RIVER, + border=uicolor.DARK_BLUE_WET_ASPHALT, + border_width=3, + filled_track=uicolor.BLUE_PETER_RIVER, + filled_step=uicolor.DARK_BLUE_MIDNIGHT_BLUE, + ), + "disabled": UIStyle( + bg=uicolor.WHITE_SILVER, + border_width=1, + filled_track=uicolor.GRAY_ASBESTOS, + unfilled_track=uicolor.WHITE_SILVER, + ), + } + + NO_STEP_STYLE = { + "normal": UIStyle( + filled_step=None, + unfilled_step=None, + ), + "hover": UIStyle( + border=uicolor.BLUE_PETER_RIVER, + border_width=2, + filled_track=uicolor.BLUE_PETER_RIVER, + filled_step=None, + unfilled_step=None, ), "press": UIStyle( bg=uicolor.BLUE_PETER_RIVER, border=uicolor.DARK_BLUE_WET_ASPHALT, border_width=3, filled_track=uicolor.BLUE_PETER_RIVER, + filled_step=None, + unfilled_step=None, ), "disabled": UIStyle( bg=uicolor.WHITE_SILVER, border_width=1, filled_track=uicolor.GRAY_ASBESTOS, unfilled_track=uicolor.WHITE_SILVER, + filled_step=None, + unfilled_step=None, ), } + """Removing the step colors from the style. + So sliders with a step value do not show the steps visually.""" def __init__( self, @@ -374,6 +432,45 @@ def _render_track(self, surface: Surface): fg_slider_color, ) + @override + def _render_steps(self, surface: Surface): + if not self.step: + return + + style = self.get_current_style() + if style is None: + warnings.warn(f"No style found for state {self.get_current_state()}", UserWarning) + return + + unfilled_steps = style.get("unfilled_step", UISlider.UIStyle.unfilled_step) + filled_steps = style.get("filled_step", UISlider.UIStyle.filled_step) + + def float_range(start, stop, step): + while start < stop: + yield start + start += step + yield stop + + steps = list(float_range(self.min_value, self.max_value, self.step)) + + for v in steps: + step_x = self._x_for_value(v) - self.content_rect.left + step_color = filled_steps if v <= self.value else unfilled_steps + + if step_color: + # bigger circle for first and last step + circle_size = self._cursor_width // 4 + if v in (steps[0], steps[-1]): + circle_size = self._cursor_width // 2 + + arcade.draw_circle_filled( + step_x, + self.content_height // 2, + circle_size, + step_color, + num_segments=8, + ) + @override def _render_thumb(self, surface: Surface): style = self.get_current_style() From 767e87b29e6b6c182f7e81047ee27e146899d75a Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 4 May 2025 20:52:08 +0200 Subject: [PATCH 152/279] Don't use geo shader for nine patch (#2668) --- arcade/gui/nine_patch.py | 265 +++++++++++++++--- .../system/shaders/gui/nine_patch_fs.glsl | 4 +- .../system/shaders/gui/nine_patch_gs.glsl | 242 ---------------- .../system/shaders/gui/nine_patch_vs.glsl | 12 +- 4 files changed, 243 insertions(+), 280 deletions(-) delete mode 100644 arcade/resources/system/shaders/gui/nine_patch_gs.glsl diff --git a/arcade/gui/nine_patch.py b/arcade/gui/nine_patch.py index 0147d1e7e0..424ac3fa98 100644 --- a/arcade/gui/nine_patch.py +++ b/arcade/gui/nine_patch.py @@ -1,6 +1,10 @@ +from array import array + import arcade -import arcade.gl as gl +from arcade.gl import Buffer, BufferDescription, Geometry, Program +from arcade.math import Vec2 from arcade.texture_atlas.base import TextureAtlasBase +from arcade.types import Rect class NinePatchTexture: @@ -74,6 +78,7 @@ def __init__( self._initialized = False self._texture = texture self._custom_atlas = atlas + self._geometry_cache: tuple[int, int, int, int, Rect] | None = None # pixel texture co-ordinate start and end of central box. self._left = left @@ -84,8 +89,9 @@ def __init__( self._check_sizes() # Created in _init_deferred - self._program: gl.program.Program - self._geometry: gl.Geometry + self._buffer: Buffer + self._program: Program + self._geometry: Geometry self._ctx: arcade.ArcadeContext self._atlas: TextureAtlasBase try: @@ -93,28 +99,6 @@ def __init__( except Exception: pass - def _init_deferred(self): - """Deferred initialization when lazy loaded""" - self._ctx = arcade.get_window().ctx - # TODO: Cache in context? - self._program = self._ctx.load_program( - vertex_shader=":system:shaders/gui/nine_patch_vs.glsl", - geometry_shader=":system:shaders/gui/nine_patch_gs.glsl", - fragment_shader=":system:shaders/gui/nine_patch_fs.glsl", - ) - # Configure texture channels - self._program.set_uniform_safe("uv_texture", 0) - self._program["sprite_texture"] = 1 - - # TODO: Cache in context? - self._geometry = self._ctx.geometry() - - # References for the texture - self._atlas = self._custom_atlas or self._ctx.default_atlas - self._add_to_atlas(self.texture) - - self._initialized = True - def initialize(self) -> None: """ Manually initialize the NinePatchTexture if it was lazy loaded. @@ -141,7 +125,7 @@ def texture(self, texture: arcade.Texture): self._add_to_atlas(texture) @property - def program(self) -> gl.program.Program: + def program(self) -> Program: """Get or set the shader program. Returns the default shader if no other shader is assigned. @@ -152,7 +136,7 @@ def program(self) -> gl.program.Program: return self._program @program.setter - def program(self, program: gl.program.Program): + def program(self, program: Program): if not self._initialized: raise RuntimeError("The NinePatchTexture has not been initialized") @@ -241,26 +225,22 @@ def draw_rect( if not self._initialized: self._init_deferred() + self._create_geometry(rect) + if blend: self._ctx.enable_only(self._ctx.BLEND) else: self._ctx.disable(self._ctx.BLEND) - self.program.set_uniform_safe("texture_id", self._atlas.get_texture_id(self._texture)) if pixelated: self._atlas.texture.filter = self._ctx.NEAREST, self._ctx.NEAREST else: self._atlas.texture.filter = self._ctx.LINEAR, self._ctx.LINEAR - self.program["position"] = rect.bottom_left - self.program["start"] = self._left, self._bottom - self.program["end"] = self.width - self._right, self.height - self._top - self.program["size"] = rect.size - self.program["t_size"] = self._texture.size - self._atlas.use_uv_texture(0) self._atlas.texture.use(1) - self._geometry.render(self._program, vertices=1) + + self._geometry.render(self._program) if blend: self._ctx.disable(self._ctx.BLEND) @@ -282,3 +262,218 @@ def _check_sizes(self): raise ValueError("Left and right border must be smaller than texture width") if self._bottom + self._top > self._texture.height: raise ValueError("Bottom and top border must be smaller than texture height") + + def _init_deferred(self): + """Deferred initialization when lazy loaded""" + self._ctx = arcade.get_window().ctx + # TODO: Cache in context? + self._program = self._ctx.load_program( + vertex_shader=":system:shaders/gui/nine_patch_vs.glsl", + fragment_shader=":system:shaders/gui/nine_patch_fs.glsl", + ) + # Configure texture channels + self._program.set_uniform_safe("uv_texture", 0) + self._program["sprite_texture"] = 1 + + # 4 byte floats * 4 floats * 4 vertices * 9 patches + self._buffer = self._ctx.buffer(reserve=576) + # fmt: off + self._ibo = self._ctx.buffer( + data=array("i", + [ + # Triangulate the patches + # First rot + 0, 1, 2, + 3, 1, 2, + + 4, 5, 6, + 7, 5, 6, + + 8, 9, 10, + 11, 9, 10, + + # Middle row + 12, 13, 14, + 15, 13, 14, + + 16, 17, 18, + 19, 17, 18, + + 20, 21, 22, + 23, 21, 22, + + # Bottom row + 24, 25, 26, + 27, 25, 26, + + 28, 29, 30, + 31, 29, 30, + + 32, 33, 34, + 35, 33, 34, + ] + ), + ) + # fmt: on + self._geometry = self._ctx.geometry( + content=[BufferDescription(self._buffer, "2f 2f", ["in_position", "in_uv"])], + index_buffer=self._ibo, + mode=self._ctx.TRIANGLES, + index_element_size=4, + ) + + # References for the texture + self._atlas = self._custom_atlas or self._ctx.default_atlas + self._add_to_atlas(self.texture) + + # NOTE: Important to create geometry after the texture is added to the atlas + # self._create_geometry(LBWH(0, 0, self.width, self.height)) + self._initialized = True + + def _create_geometry(self, rect: Rect): + """Create vertices for the 9-patch texture.""" + # NOTE: This was ported from glsl geometry shader to python + # Simulate old uniforms + cache_key = (self._left, self._right, self._bottom, self._top, rect) + if cache_key == self._geometry_cache: + return + self._geometry_cache = cache_key + + position = rect.bottom_left + start = Vec2(self._left, self._bottom) + end = Vec2(self.width - self._right, self.height - self._top) + size = rect.size + t_size = Vec2(*self._texture.size) + atlas_size = Vec2(*self._atlas.size) + + # Patch points starting from upper left row by row + p1 = position + Vec2(0.0, size.y) + p2 = position + Vec2(start.x, size.y) + p3 = position + Vec2(size.x - (t_size.x - end.x), size.y) + p4 = position + Vec2(size.x, size.y) + + y = size.y - (t_size.y - end.y) + p5 = position + Vec2(0.0, y) + p6 = position + Vec2(start.x, y) + p7 = position + Vec2(size.x - (t_size.x - end.x), y) + p8 = position + Vec2(size.x, y) + + p9 = position + Vec2(0.0, start.y) + p10 = position + Vec2(start.x, start.y) + p11 = position + Vec2(size.x - (t_size.x - end.x), start.y) + p12 = position + Vec2(size.x, start.y) + + p13 = position + Vec2(0.0, 0.0) + p14 = position + Vec2(start.x, 0.0) + p15 = position + Vec2(size.x - (t_size.x - end.x), 0.0) + p16 = position + Vec2(size.x, 0.0) + + # Date: Sun, 4 May 2025 21:56:50 +0200 Subject: [PATCH 153/279] merge development, fix some naming --- arcade/gui/widgets/slider.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index e3b7d5b49a..9d7fccede6 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -114,9 +114,9 @@ def _apply_step(self, value: float): return value - def _change_value(self, value: float): - # TODO changing the value itself should trigger this event - # current problem is, that the property does not pass the old value to change listeners + def _set_value(self, value: float): + # TODO changing the value itself should trigger `on_change` event + # current problem is, that the property does not pass the old value to listeners if value < self.min_value: value = self.min_value elif value > self.max_value: @@ -145,7 +145,7 @@ def norm_value(self): def norm_value(self, value): """Normalized value between 0.0 and 1.0""" new_value = min(value * (self.max_value - self.min_value) + self.min_value, self.max_value) - self._change_value(new_value) + self._set_value(new_value) @property def _thumb_x(self): From edf78665dfa90e0fadfabfe5e471a1323d3bf284 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Fri, 9 May 2025 20:45:22 +0200 Subject: [PATCH 154/279] Atlas: Fix memory leak from self reference (#2678) --- arcade/texture_atlas/atlas_default.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index 8223063939..964090e5a3 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -10,7 +10,7 @@ TYPE_CHECKING, Sequence, ) -from weakref import WeakSet, WeakValueDictionary, finalize +from weakref import WeakSet, WeakValueDictionary, finalize, ref import PIL.Image from PIL import Image, ImageDraw @@ -356,14 +356,25 @@ def _add_texture_ref(self, texture: Texture, create_finalizer=True) -> None: self._image_ref_count.inc_ref(texture.image_data) if create_finalizer: - ref = finalize( + atlas_ref = ref(self) + + # NOTE: The finalizer needs to be completely decoupled from the atlas + # or it will self-reference and not die unless all the textures in it + # are removed. This lead to leaking orphaned atlases when you have + # pre-loaded shared textures in multiple atlases. + def finalizer_callback(atlas_name, hash): + atlas = atlas_ref() + if atlas is not None: + atlas._remove_texture_by_identifiers(atlas_name, hash) + + finalizer_ref = finalize( texture, - self._remove_texture_by_identifiers, + finalizer_callback, texture.atlas_name, texture.image_data.hash, ) # Don't bother removing texture on program exit - ref.atexit = False + finalizer_ref.atexit = False self._finalizers_created += 1 self._textures_added += 1 From 84ea50b7444c9136c145288b4144024ee39d9609 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Fri, 9 May 2025 21:16:27 +0200 Subject: [PATCH 155/279] Bump minimum pyglet version to 2.1.5 (#2679) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3a995d57c2..a07274b1ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "pyglet~=2.1.3", + "pyglet~=2.1.5", "pillow~=11.0.0", "pymunk~=6.9.0", "pytiled-parser~=2.2.9", From 441287b5953eb9f48c703f01360fabc1f5c99ae7 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Fri, 9 May 2025 21:38:04 +0200 Subject: [PATCH 156/279] Update CHANGELOG.md (#2680) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ebd825d9e..6c9df797bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Introduce `arcade.SpriteSequence[T]` as a covariant supertype of `arcade.SpriteList[T]` (this is similar to Python's `Sequence[T]`, which is a supertype of `list[T]`) and various improvements to the typing of the API that leverage it +- Fixed a nasty memory leak in texture atlases. This only affects projects managing their own atlases +- Fixed a bug causing some events to not trigger on the window's keyboard and mouse state handlers +- New minimum pyglet version is now 2.1.5 +- Some shaders programs were rewritten to not use geometry shaders in preparation for webgl support ## Version 3.1.0 From b3c30bf4399c3e485a7a5846574c27f89e04f6bb Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 9 May 2025 21:48:37 +0200 Subject: [PATCH 157/279] Upgrade to pyton 3.10 (#2677) * enable pyupgrade for ruff, migrate to 3.10 featured typing * fix arcade python version warning (req 3.10 * fix formating * revert arcade.gl changes to reduce merge issues for gl abstraction PR --- arcade/__init__.py | 4 +- arcade/application.py | 5 ++- arcade/cache/hit_box.py | 2 +- arcade/cache/image_data.py | 4 +- arcade/camera/camera_2d.py | 9 ++-- arcade/camera/data_types.py | 3 +- arcade/camera/default.py | 3 +- arcade/camera/orthographic.py | 3 +- arcade/camera/perspective.py | 3 +- arcade/camera/static.py | 3 +- arcade/context.py | 3 +- arcade/easing.py | 2 +- arcade/examples/dual_stick_shooter.py | 16 +++---- arcade/examples/gl/3d_cube_with_cubes.py | 4 +- arcade/examples/gl/bindless_texture.py | 3 +- arcade/examples/gl/render_indirect.py | 4 +- ...st_interaction_visualize_dist_los_trans.py | 4 +- .../examples/gui/exp_controller_inventory.py | 5 +-- arcade/examples/gui/exp_controller_support.py | 3 +- .../gui/exp_controller_support_grid.py | 3 +- arcade/examples/particle_systems.py | 4 +- arcade/examples/sprite_health.py | 15 +++---- arcade/exceptions.py | 4 +- arcade/experimental/shadertoy.py | 2 +- arcade/experimental/shapes_perf.py | 8 ++-- arcade/future/background/__init__.py | 14 +++--- arcade/future/background/groups.py | 2 +- arcade/future/input/input_manager_example.py | 2 +- arcade/future/input/input_mapping.py | 2 +- arcade/future/input/inputs.py | 7 ++- arcade/future/input/manager.py | 3 +- arcade/future/input/raw_dicts.py | 4 +- arcade/future/light/lights.py | 2 +- arcade/future/sub_clock.py | 4 +- arcade/gui/experimental/focus.py | 3 +- arcade/gui/experimental/scroll_area.py | 3 +- arcade/gui/experimental/typed_text_input.py | 9 ++-- arcade/gui/property.py | 11 ++--- arcade/gui/surface.py | 2 +- arcade/gui/ui_manager.py | 6 +-- arcade/gui/widgets/__init__.py | 45 ++++++++++--------- arcade/gui/widgets/buttons.py | 16 +++---- arcade/gui/widgets/dropdown.py | 5 +-- arcade/gui/widgets/image.py | 5 +-- arcade/gui/widgets/layout.py | 19 ++++---- arcade/gui/widgets/slider.py | 14 +++--- arcade/gui/widgets/text.py | 6 +-- arcade/math.py | 2 +- arcade/particles/emitter.py | 4 +- arcade/particles/emitter_simple.py | 2 +- arcade/paths.py | 2 +- arcade/physics_engines.py | 2 +- arcade/pymunk_physics_engine.py | 2 +- arcade/resources/__init__.py | 10 ++--- arcade/scene.py | 4 +- arcade/sections.py | 5 ++- arcade/shape_list.py | 3 +- arcade/sound.py | 2 +- arcade/sprite/base.py | 17 ++++--- arcade/sprite/sprite.py | 2 +- arcade/sprite_list/collision.py | 22 ++++----- arcade/sprite_list/sprite_list.py | 15 +++---- arcade/text.py | 4 +- arcade/texture_atlas/atlas_array.py | 5 +-- arcade/texture_atlas/atlas_default.py | 18 ++++---- arcade/texture_atlas/base.py | 2 +- arcade/texture_atlas/ref_counters.py | 6 +-- arcade/texture_atlas/region.py | 10 ++--- arcade/texture_atlas/uv_data.py | 14 +++--- arcade/tilemap/tilemap.py | 20 ++++----- arcade/types/__init__.py | 15 ++++--- arcade/types/color.py | 9 ++-- arcade/types/numbers.py | 4 +- arcade/types/vector_like.py | 8 ++-- arcade/utils.py | 15 +++---- arcade/window_commands.py | 15 +++---- benchmarks/sprite/main.py | 22 ++++++--- benchmarks/sprite/sprite_alt.py | 14 +++--- make.py | 3 +- pyproject.toml | 1 + 80 files changed, 284 insertions(+), 298 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index 97b18275fa..a5152680d6 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -12,8 +12,8 @@ from pathlib import Path -if sys.version_info[0] < 3 or (sys.version_info[0] == 3 and sys.version_info[1] < 9): - sys.exit("The Arcade Library requires Python 3.9 or higher.") +if sys.version_info[0] < 3 or (sys.version_info[0] == 3 and sys.version_info[1] < 10): + sys.exit("The Arcade Library requires Python 3.10 or higher.") def configure_logging(level: int | None = None): diff --git a/arcade/application.py b/arcade/application.py index 7b7e17297d..763661bd7d 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -8,7 +8,8 @@ import logging import os import time -from typing import TYPE_CHECKING, Sequence +from collections.abc import Sequence +from typing import TYPE_CHECKING import pyglet import pyglet.gl as gl @@ -36,7 +37,7 @@ MOUSE_BUTTON_MIDDLE = 2 MOUSE_BUTTON_RIGHT = 4 -_window: "Window" +_window: Window __all__ = [ "get_screens", diff --git a/arcade/cache/hit_box.py b/arcade/cache/hit_box.py index da2e3d6096..c6b59637b6 100644 --- a/arcade/cache/hit_box.py +++ b/arcade/cache/hit_box.py @@ -118,7 +118,7 @@ def load(self, path: str | Path) -> None: with gzip.open(path, mode="rb") as fd: data = json.loads(fd.read()) else: - with open(path, mode="r") as fd: + with open(path) as fd: data = json.loads(fd.read()) for key, value in data.items(): diff --git a/arcade/cache/image_data.py b/arcade/cache/image_data.py index 0929728ba1..ac5991dfd7 100644 --- a/arcade/cache/image_data.py +++ b/arcade/cache/image_data.py @@ -23,9 +23,9 @@ class ImageDataCache: """ def __init__(self): - self._entries: dict[str, "ImageData"] = {} + self._entries: dict[str, ImageData] = {} - def put(self, name: str, image: "ImageData"): + def put(self, name: str, image: ImageData): """ Add an image to the cache. diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index d0b048a9b4..aa6b4a25d9 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -1,8 +1,9 @@ from __future__ import annotations +from collections.abc import Generator from contextlib import contextmanager from math import atan2, cos, degrees, radians, sin -from typing import TYPE_CHECKING, Generator +from typing import TYPE_CHECKING from pyglet.math import Vec2, Vec3 from typing_extensions import Self @@ -223,12 +224,12 @@ def from_camera_data( left, right = projection_data.left, projection_data.right if projection_data.left == projection_data.right: raise ZeroProjectionDimension( - (f"projection width is 0 due to equal {left=}and {right=} values") + f"projection width is 0 due to equal {left=}and {right=} values" ) bottom, top = projection_data.bottom, projection_data.top if bottom == top: raise ZeroProjectionDimension( - (f"projection height is 0 due to equal {bottom=}and {top=}") + f"projection height is 0 due to equal {bottom=}and {top=}" ) near, far = projection_data.near, projection_data.far if near == far: @@ -583,7 +584,7 @@ def projection(self) -> Rect: def projection(self, value: Rect) -> None: # Unpack and validate if not value: - raise ZeroProjectionDimension((f"Projection area is 0, {value.lrbt}")) + raise ZeroProjectionDimension(f"Projection area is 0, {value.lrbt}") _z = self._camera_data.zoom diff --git a/arcade/camera/data_types.py b/arcade/camera/data_types.py index 50c6704d6d..15b32a29b7 100644 --- a/arcade/camera/data_types.py +++ b/arcade/camera/data_types.py @@ -4,8 +4,9 @@ wide usage throughout Arcade's camera code. """ +from collections.abc import Generator from contextlib import contextmanager -from typing import Final, Generator, Protocol +from typing import Final, Protocol from pyglet.math import Vec2, Vec3 from typing_extensions import Self diff --git a/arcade/camera/default.py b/arcade/camera/default.py index cba9602fc1..dcb94d4b9f 100644 --- a/arcade/camera/default.py +++ b/arcade/camera/default.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Generator from contextlib import contextmanager -from typing import TYPE_CHECKING, Generator +from typing import TYPE_CHECKING from pyglet.math import Mat4, Vec2, Vec3 from typing_extensions import Self diff --git a/arcade/camera/orthographic.py b/arcade/camera/orthographic.py index 78061cae92..76b0ab10af 100644 --- a/arcade/camera/orthographic.py +++ b/arcade/camera/orthographic.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Generator from contextlib import contextmanager -from typing import TYPE_CHECKING, Generator +from typing import TYPE_CHECKING from pyglet.math import Mat4, Vec2, Vec3 from typing_extensions import Self diff --git a/arcade/camera/perspective.py b/arcade/camera/perspective.py index 0e1b26a83f..89ca15a0ed 100644 --- a/arcade/camera/perspective.py +++ b/arcade/camera/perspective.py @@ -1,8 +1,9 @@ from __future__ import annotations +from collections.abc import Generator from contextlib import contextmanager from math import radians, tan -from typing import TYPE_CHECKING, Generator +from typing import TYPE_CHECKING from pyglet.math import Mat4, Vec2, Vec3 from typing_extensions import Self diff --git a/arcade/camera/static.py b/arcade/camera/static.py index dff4151219..8bbca56e3c 100644 --- a/arcade/camera/static.py +++ b/arcade/camera/static.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Callable, Generator from contextlib import contextmanager -from typing import TYPE_CHECKING, Callable, Generator +from typing import TYPE_CHECKING from pyglet.math import Mat4, Vec2, Vec3 diff --git a/arcade/context.py b/arcade/context.py index 56164afc4d..42ff53496d 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -3,8 +3,9 @@ Contains pre-loaded programs """ +from collections.abc import Iterable, Sequence from pathlib import Path -from typing import Any, Iterable, Sequence +from typing import Any import pyglet from PIL import Image diff --git a/arcade/easing.py b/arcade/easing.py index a88dfb4900..aa2a2ddf5b 100644 --- a/arcade/easing.py +++ b/arcade/easing.py @@ -2,9 +2,9 @@ Functions used to support easing """ +from collections.abc import Callable from dataclasses import dataclass from math import cos, pi, sin -from typing import Callable from .math import get_distance diff --git a/arcade/examples/dual_stick_shooter.py b/arcade/examples/dual_stick_shooter.py index aba4ca5390..d8f7711efe 100644 --- a/arcade/examples/dual_stick_shooter.py +++ b/arcade/examples/dual_stick_shooter.py @@ -31,17 +31,17 @@ def dump_obj(obj): for key in sorted(vars(obj)): val = getattr(obj, key) - print("{:30} = {} ({})".format(key, val, type(val).__name__)) + print(f"{key:30} = {val} ({type(val).__name__})") def dump_controller(controller): - print("========== {}".format(controller)) - print("Left X {}".format(controller.leftx)) - print("Left Y {}".format(controller.lefty)) - print("Left Trigger {}".format(controller.lefttrigger)) - print("Right X {}".format(controller.rightx)) - print("Right Y {}".format(controller.righty)) - print("Right Trigger {}".format(controller.righttrigger)) + print(f"========== {controller}") + print(f"Left X {controller.leftx}") + print(f"Left Y {controller.lefty}") + print(f"Left Trigger {controller.lefttrigger}") + print(f"Right X {controller.rightx}") + print(f"Right Y {controller.righty}") + print(f"Right Trigger {controller.righttrigger}") print("========== Extra controller") dump_obj(controller) print("========== Extra controller.device") diff --git a/arcade/examples/gl/3d_cube_with_cubes.py b/arcade/examples/gl/3d_cube_with_cubes.py index 4ef7082995..198c786321 100644 --- a/arcade/examples/gl/3d_cube_with_cubes.py +++ b/arcade/examples/gl/3d_cube_with_cubes.py @@ -97,11 +97,11 @@ def __init__(self, width, height, title): self.frame = 0 self.fbo1 = self.ctx.framebuffer( - color_attachments=[self.ctx.texture((self.get_size()))], + color_attachments=[self.ctx.texture(self.get_size())], depth_attachment=self.ctx.depth_texture(self.get_size()), ) self.fbo2 = self.ctx.framebuffer( - color_attachments=[self.ctx.texture((self.get_size()))], + color_attachments=[self.ctx.texture(self.get_size())], depth_attachment=self.ctx.depth_texture(self.get_size()), ) diff --git a/arcade/examples/gl/bindless_texture.py b/arcade/examples/gl/bindless_texture.py index b6e8db146e..c86a7d3348 100644 --- a/arcade/examples/gl/bindless_texture.py +++ b/arcade/examples/gl/bindless_texture.py @@ -26,7 +26,6 @@ """ from array import array -from typing import List from itertools import cycle import arcade @@ -144,7 +143,7 @@ def __init__(self): ) self.handles = [] - self.textures: List[Texture2D] = [] + self.textures: list[Texture2D] = [] # Make a cycle iterator from Arcade's resources (images) resources = arcade.resources.list_built_in_assets(name="female", extensions=(".png",)) resource_cycle = cycle(resources) diff --git a/arcade/examples/gl/render_indirect.py b/arcade/examples/gl/render_indirect.py index 6af50d1b7b..234f70a46c 100644 --- a/arcade/examples/gl/render_indirect.py +++ b/arcade/examples/gl/render_indirect.py @@ -238,12 +238,12 @@ def on_draw(self): # to prove that partial rendering also works. # NOTE: These values can be skewed if vsync is enabled print( - ( + f"[{method}] " f"Pixels written = {self.query.samples_passed // 4}, " f"Primitives Generated = {self.query.primitives_generated}, " f"time = {self.query.time_elapsed / 1_000_000_000}s" - ) + ) def on_update(self, delta_time: float): diff --git a/arcade/examples/gl/spritelist_interaction_visualize_dist_los_trans.py b/arcade/examples/gl/spritelist_interaction_visualize_dist_los_trans.py index 6653ca58a9..0dd505ef01 100644 --- a/arcade/examples/gl/spritelist_interaction_visualize_dist_los_trans.py +++ b/arcade/examples/gl/spritelist_interaction_visualize_dist_los_trans.py @@ -202,11 +202,11 @@ def on_draw(self): ) print("Indices found:", sprite_indices) print( - ( + f"max(sprite_indices) = {max(sprite_indices)} | " f"len(self.coins) = {len(self.coins)} | " f"sprite_indices = {len(sprite_indices)}" - ) + ) # Resolve the list of selected sprites and remove them sprites = [self.coins[int(i)] for i in sprite_indices] diff --git a/arcade/examples/gui/exp_controller_inventory.py b/arcade/examples/gui/exp_controller_inventory.py index 11a9724504..ffe99f02b0 100644 --- a/arcade/examples/gui/exp_controller_inventory.py +++ b/arcade/examples/gui/exp_controller_inventory.py @@ -15,7 +15,6 @@ """ # TODO: Drag and Drop -from typing import List, Optional import pyglet.font from pyglet.event import EVENT_HANDLED @@ -75,7 +74,7 @@ class Inventory: """ def __init__(self, capacity: int): - self._items: List[Item | None] = [None for _ in range(capacity)] + self._items: list[Item | None] = [None for _ in range(capacity)] self.capacity = capacity def add(self, item: Item): @@ -352,7 +351,7 @@ def __init__(self, inventory: Inventory, **kwargs): # init controller support self.detect_focusable_widgets() - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: if isinstance(event, UIControllerButtonPressEvent): if event.button == "b": self.set_focus(self.close_button) diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py index f85b207fbd..40dd979722 100644 --- a/arcade/examples/gui/exp_controller_support.py +++ b/arcade/examples/gui/exp_controller_support.py @@ -9,7 +9,6 @@ python -m arcade.examples.gui.exp_controller_support """ -from typing import Optional import arcade from arcade import Texture @@ -115,7 +114,7 @@ def input_prompts(cls, event: UIControllerEvent) -> Texture | None: return None - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: if isinstance(event, UIControllerEvent): input_texture = self.input_prompts(event) diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py index 4833b0fcab..51f1dd0772 100644 --- a/arcade/examples/gui/exp_controller_support_grid.py +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -9,7 +9,6 @@ python -m arcade.examples.gui.exp_controller_support_grid """ -from typing import Dict, Tuple import arcade from arcade.examples.gui.exp_controller_support import ControllerIndicator @@ -27,7 +26,7 @@ class FocusableButton(UIFocusable, UIFlatButton): pass -def setup_grid_focus_transition(grid: Dict[Tuple[int, int], UIWidget]): +def setup_grid_focus_transition(grid: dict[tuple[int, int], UIWidget]): """Setup focus transition in grid. Connect focus transition between `Focusable` in grid. diff --git a/arcade/examples/particle_systems.py b/arcade/examples/particle_systems.py index 6ba6fcf1cf..c39df62124 100644 --- a/arcade/examples/particle_systems.py +++ b/arcade/examples/particle_systems.py @@ -808,7 +808,7 @@ def __init__(self): def next_emitter(self, _time_delta): self.emitter_factory_id = (self.emitter_factory_id + 1) % len(self.factories) - print("Changing emitter to {}".format(self.emitter_factory_id)) + print(f"Changing emitter to {self.emitter_factory_id}") self.emitter_timeout = 0 self.label, self.emitter = self.factories[self.emitter_factory_id]() @@ -827,7 +827,7 @@ def on_draw(self): self.clear() arcade.draw_sprite(self.obj) if self.label: - arcade.draw_text("#{} {}".format(self.emitter_factory_id, self.label), + arcade.draw_text(f"#{self.emitter_factory_id} {self.label}", WINDOW_WIDTH / 2, WINDOW_HEIGHT - 25, arcade.color.PALE_GOLD, 20, width=WINDOW_WIDTH, anchor_x="center") diff --git a/arcade/examples/sprite_health.py b/arcade/examples/sprite_health.py index 636cc45c6e..f9947b5bbd 100644 --- a/arcade/examples/sprite_health.py +++ b/arcade/examples/sprite_health.py @@ -7,7 +7,6 @@ python -m arcade.examples.sprite_health """ import math -from typing import Tuple import arcade from arcade.types import Color @@ -102,13 +101,13 @@ def __init__( self, owner: Player, sprite_list: arcade.SpriteList, - position: Tuple[float, float] = (0, 0), + position: tuple[float, float] = (0, 0), full_color: Color = arcade.color.GREEN, background_color: Color = arcade.color.BLACK, width: int = 100, height: int = 4, border_size: int = 4, - scale: Tuple[float, float] = (1.0, 1.0), + scale: tuple[float, float] = (1.0, 1.0), ) -> None: # Store the reference to the owner and the sprite list self.owner: Player = owner @@ -120,7 +119,7 @@ def __init__( self._center_x: float = 0.0 self._center_y: float = 0.0 self._fullness: float = 0.0 - self._scale: Tuple[float, float] = (1.0, 1.0) + self._scale: tuple[float, float] = (1.0, 1.0) # Create the boxes needed to represent the indicator bar self._background_box: arcade.SpriteSolidColor = arcade.SpriteSolidColor( @@ -220,12 +219,12 @@ def fullness(self, new_fullness: float) -> None: self.full_box.left = self._center_x - (self._bar_width / 2) * self.scale[0] @property - def position(self) -> Tuple[float, float]: + def position(self) -> tuple[float, float]: """Returns the current position of the bar.""" return self._center_x, self._center_y @position.setter - def position(self, new_position: Tuple[float, float]) -> None: + def position(self, new_position: tuple[float, float]) -> None: """Sets the new position of the bar.""" # Check if the position has changed. If so, change the bar's position if new_position != self.position: @@ -237,12 +236,12 @@ def position(self, new_position: Tuple[float, float]) -> None: self.full_box.left = self._center_x - (self._bar_width / 2) * self.scale[0] @property - def scale(self) -> Tuple[float, float]: + def scale(self) -> tuple[float, float]: """Returns the scale of the bar.""" return self._scale @scale.setter - def scale(self, value: Tuple[float, float]) -> None: + def scale(self, value: tuple[float, float]) -> None: """Sets the new scale of the bar.""" # Check if the scale has changed. If so, change the bar's scale if value != self.scale: diff --git a/arcade/exceptions.py b/arcade/exceptions.py index 945d07e07a..a3b651823f 100644 --- a/arcade/exceptions.py +++ b/arcade/exceptions.py @@ -4,7 +4,7 @@ import functools import warnings -from typing import Type, TypeVar +from typing import TypeVar __all__ = [ "OutsideRangeError", @@ -19,7 +19,7 @@ # Since this module forbids importing from the rest of # Arcade, we make our own local type variables. -_TType = TypeVar("_TType", bound=Type) +_TType = TypeVar("_TType", bound=type) _CT = TypeVar("_CT") # Comparable type, ie supports the <= operator diff --git a/arcade/experimental/shadertoy.py b/arcade/experimental/shadertoy.py index ca13c53b86..41158e7279 100644 --- a/arcade/experimental/shadertoy.py +++ b/arcade/experimental/shadertoy.py @@ -430,7 +430,7 @@ def resize(self, size: tuple[int, int]): return self._size = size # Resize the internal texture and fbo + clear - self._texture.resize((self._size)) + self._texture.resize(self._size) self._fbo.resize() self._fbo.clear() diff --git a/arcade/experimental/shapes_perf.py b/arcade/experimental/shapes_perf.py index b4dc8f5c65..51887961d4 100644 --- a/arcade/experimental/shapes_perf.py +++ b/arcade/experimental/shapes_perf.py @@ -212,11 +212,9 @@ def on_draw(self): if self.execution_time > 1.0 and self.frames > 0: print( - ( - f"frames {self.frames}, " - f"execution time {round(self.execution_time, 3)}, " - f"frame time {round(self.execution_time / self.frames, 3)}" - ) + f"frames {self.frames}, " + f"execution time {round(self.execution_time, 3)}, " + f"frame time {round(self.execution_time / self.frames, 3)}" ) self.execution_time = 0 self.frames = 0 diff --git a/arcade/future/background/__init__.py b/arcade/future/background/__init__.py index 22e701ba75..53e8806a1e 100644 --- a/arcade/future/background/__init__.py +++ b/arcade/future/background/__init__.py @@ -1,5 +1,3 @@ -from typing import Tuple - from PIL import Image import arcade.gl as gl @@ -22,7 +20,7 @@ def texture_from_file( tex_src: str, - offset: Tuple[float, float] = (0.0, 0.0), + offset: tuple[float, float] = (0.0, 0.0), scale: float = 1.0, angle: float = 0.0, filters=(gl.NEAREST, gl.NEAREST), @@ -41,15 +39,15 @@ def texture_from_file( def background_from_file( tex_src: str, - pos: Tuple[float, float] = (0.0, 0.0), - size: Tuple[int, int] | None = None, - offset: Tuple[float, float] = (0.0, 0.0), + pos: tuple[float, float] = (0.0, 0.0), + size: tuple[int, int] | None = None, + offset: tuple[float, float] = (0.0, 0.0), scale: float = 1.0, angle: float = 0.0, *, filters=(gl.NEAREST, gl.NEAREST), - color: Tuple[int, int, int] | None = None, - color_norm: Tuple[float, float, float] | None = None, + color: tuple[int, int, int] | None = None, + color_norm: tuple[float, float, float] | None = None, shader: gl.Program | None = None, geometry: gl.Geometry | None = None, ) -> Background: diff --git a/arcade/future/background/groups.py b/arcade/future/background/groups.py index 081bd09a83..e1722441c2 100644 --- a/arcade/future/background/groups.py +++ b/arcade/future/background/groups.py @@ -135,7 +135,7 @@ def __getitem__(self, item: int): return self._backgrounds[item], self._depths[item] def __setitem__(self, key: int, value: Background | float): - if isinstance(value, (float, int)): + if isinstance(value, float | int): self._depths[key] = value else: self._backgrounds[key] = value diff --git a/arcade/future/input/input_manager_example.py b/arcade/future/input/input_manager_example.py index 284c31c87c..56bc942ee7 100644 --- a/arcade/future/input/input_manager_example.py +++ b/arcade/future/input/input_manager_example.py @@ -1,6 +1,6 @@ # type: ignore import random -from typing import Sequence +from collections.abc import Sequence import pyglet from pyglet.input import Controller diff --git a/arcade/future/input/input_mapping.py b/arcade/future/input/input_mapping.py index a6acd962d5..3198db7401 100644 --- a/arcade/future/input/input_mapping.py +++ b/arcade/future/input/input_mapping.py @@ -42,7 +42,7 @@ def __init__(self, input: inputs.InputEnum): except KeyError: raise TypeError( f"Got {input} input specified for ActionMapping must be of of: " - f"{', '.join((t.__name__ for t in inputs.CLASS_TO_INPUT_TYPE.keys()))}" + f"{', '.join(t.__name__ for t in inputs.CLASS_TO_INPUT_TYPE.keys())}" ) self._input = input diff --git a/arcade/future/input/inputs.py b/arcade/future/input/inputs.py index add46e8237..1371e750bb 100644 --- a/arcade/future/input/inputs.py +++ b/arcade/future/input/inputs.py @@ -7,7 +7,6 @@ from enum import Enum, auto from sys import platform -from typing import Type from arcade.future.input.raw_dicts import RawBindBase @@ -28,7 +27,7 @@ class InputEnum(Enum): class StrEnum(str, InputEnum): def __new__(cls, value, *args, **kwargs): - if not isinstance(value, (str, auto)): + if not isinstance(value, str | auto): raise TypeError(f"Values of StrEnums must be strings: {value!r} is a {type(value)}") return super().__new__(cls, value, *args, **kwargs) @@ -362,7 +361,7 @@ class MouseButtons(InputEnum): # 2. Types are hashable # It may be worth encapsulating this approach since we have other if # ladders without case-specific logic remaining in the controller code. -CLASS_TO_INPUT_TYPE: dict[Type[InputEnum], InputType] = { +CLASS_TO_INPUT_TYPE: dict[type[InputEnum], InputType] = { Keys: InputType.KEYBOARD, MouseButtons: InputType.MOUSE_BUTTON, MouseAxes: InputType.MOUSE_AXIS, @@ -370,7 +369,7 @@ class MouseButtons(InputEnum): ControllerAxes: InputType.CONTROLLER_AXIS, } -INPUT_TYPE_TO_CLASS: dict[InputType, Type[InputEnum]] = { +INPUT_TYPE_TO_CLASS: dict[InputType, type[InputEnum]] = { InputType.KEYBOARD: Keys, InputType.MOUSE_BUTTON: MouseButtons, InputType.MOUSE_AXIS: MouseAxes, diff --git a/arcade/future/input/manager.py b/arcade/future/input/manager.py index 104a6635d9..a530907be1 100644 --- a/arcade/future/input/manager.py +++ b/arcade/future/input/manager.py @@ -1,8 +1,9 @@ # type: ignore from __future__ import annotations +from collections.abc import Callable from enum import Enum -from typing import Any, Callable, TypeVar +from typing import Any, TypeVar import pyglet from pyglet.input.base import Controller diff --git a/arcade/future/input/raw_dicts.py b/arcade/future/input/raw_dicts.py index 7ec453982a..f767f2e12a 100644 --- a/arcade/future/input/raw_dicts.py +++ b/arcade/future/input/raw_dicts.py @@ -3,8 +3,6 @@ Placing them here prevents circular import issues. """ -from typing import Union - from typing_extensions import TypedDict @@ -23,7 +21,7 @@ class RawBindBase(TypedDict): """ input_type: int - input: Union[str, int] + input: str | int class RawActionMapping(RawBindBase): diff --git a/arcade/future/light/lights.py b/arcade/future/light/lights.py index 243cb51570..916af693c8 100644 --- a/arcade/future/light/lights.py +++ b/arcade/future/light/lights.py @@ -1,5 +1,5 @@ from array import array -from typing import Iterator, Sequence +from collections.abc import Iterator, Sequence from arcade import gl from arcade.color import WHITE diff --git a/arcade/future/sub_clock.py b/arcade/future/sub_clock.py index 339e324db0..ab32c39f66 100644 --- a/arcade/future/sub_clock.py +++ b/arcade/future/sub_clock.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Union - from arcade.clock import GLOBAL_CLOCK, Clock @@ -55,7 +53,7 @@ class SubClock(Clock): i.e. a value of 0.5 means time elapsed half as fast for this clock. Defaults to 1.0. """ - def __init__(self, parent: Union[Clock, SubClock, None] = None, tick_speed: float = 1) -> None: + def __init__(self, parent: Clock | SubClock | None = None, tick_speed: float = 1) -> None: parent = parent or GLOBAL_CLOCK super().__init__(parent._elapsed_time, parent._tick, tick_speed) self.children: list[SubClock] = [] diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 204c8efad6..02c660c189 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -1,6 +1,5 @@ import warnings from types import EllipsisType -from typing import Optional from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from pyglet.math import Vec2 @@ -76,7 +75,7 @@ def __init__(self, *args, **kwargs): bind(self, "_focused_widget", self.trigger_full_render) bind(self, "_focusable_widgets", self.trigger_full_render) - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: # pass events to children first, including controller events # so they can handle them if super().on_event(event): diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index 0b0a22e77f..fbe715cd0e 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Iterable, TypeVar +from collections.abc import Iterable +from typing import TypeVar from pyglet.event import EVENT_UNHANDLED diff --git a/arcade/gui/experimental/typed_text_input.py b/arcade/gui/experimental/typed_text_input.py index 348f14b395..e11c2b2a01 100644 --- a/arcade/gui/experimental/typed_text_input.py +++ b/arcade/gui/experimental/typed_text_input.py @@ -1,4 +1,5 @@ -from typing import Callable, Generic, Type, TypeVar, cast +from collections.abc import Callable +from typing import Generic, TypeVar, cast import arcade from arcade.color import BLACK, RED, WHITE @@ -80,7 +81,7 @@ class UITypedTextInput(UIInputText, Generic[T]): def __init__( self, - parsed_type: Type[T], + parsed_type: type[T], *, to_str: Callable[[T], str] = repr, from_str: Callable[[str], T] | None = None, @@ -121,7 +122,7 @@ def __init__( self.emit_parse_exceptions = emit_parse_exceptions self._error_color = error_color self._valid_color = text_color - self._parsed_type: Type[T] = parsed_type + self._parsed_type: type[T] = parsed_type self._to_str = to_str self._from_str: Callable[[str], T] = cast(Callable[[str], T], from_str or parsed_type) self._parsed_value: T = self._from_str(self.text) @@ -157,7 +158,7 @@ def on_event(self, event: UIEvent) -> bool | None: return handled @property - def parsed_type(self) -> Type[T]: + def parsed_type(self) -> type[T]: """Get the type this input field expects to parse. .. note:: This is not meant to be changed after creation. diff --git a/arcade/gui/property.py b/arcade/gui/property.py index 95e46ee244..c9332c3ad4 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -1,6 +1,7 @@ import sys import traceback -from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, cast +from collections.abc import Callable +from typing import Any, Generic, TypeVar, cast from weakref import WeakKeyDictionary, ref from typing_extensions import Self, overload, override @@ -59,8 +60,8 @@ class MyObject: def __init__( self, - default: Optional[P] = None, - default_factory: Optional[Callable[[Any, Any], P]] = None, + default: P | None = None, + default_factory: Callable[[Any, Any], P] | None = None, ): if default_factory is None: default_factory = lambda prop, instance: cast(P, default) @@ -276,7 +277,7 @@ def update(self, *args): V = TypeVar("V") -class DictProperty(Property[Dict[K, V]], Generic[K, V]): +class DictProperty(Property[dict[K, V]], Generic[K, V]): """Property that represents a dict. Only dict are allowed. Any other classes are forbidden. @@ -384,7 +385,7 @@ def reverse(self): self.dispatch() -class ListProperty(Property[List[P]], Generic[P]): +class ListProperty(Property[list[P]], Generic[P]): """Property that represents a list. Only list are allowed. Any other classes are forbidden. diff --git a/arcade/gui/surface.py b/arcade/gui/surface.py index 200ebce209..0ac859b7fd 100644 --- a/arcade/gui/surface.py +++ b/arcade/gui/surface.py @@ -1,6 +1,6 @@ from array import array +from collections.abc import Generator from contextlib import contextmanager -from typing import Generator from PIL import Image from pyglet.math import Vec2, Vec4 diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 6d25be8bf7..59be4e19ad 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -9,12 +9,12 @@ """ from collections import defaultdict -from typing import Iterable, TypeVar, Union +from collections.abc import Iterable +from typing import TypeGuard, TypeVar from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher from pyglet.input import Controller from pyglet.math import Vec2 -from typing_extensions import TypeGuard import arcade from arcade.experimental.controller_window import ControllerWindow @@ -400,7 +400,7 @@ def adjust_mouse_coordinates(self, x: float, y: float) -> tuple[float, float]: x_, y_, *c = self.camera.unproject((x, y)) # convert screen to ui coordinates return x_, y_ - def on_event(self, event) -> Union[bool, None]: + def on_event(self, event) -> bool | None: """Forwards an event to all widgets in the UIManager.""" layers = sorted(self.children.keys(), reverse=True) for layer in layers: diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index e2b0e69565..244024cb37 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -1,8 +1,9 @@ from __future__ import annotations from abc import ABC +from collections.abc import Iterable from enum import IntEnum -from typing import TYPE_CHECKING, Dict, Iterable, List, NamedTuple, Optional, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, NamedTuple, TypeVar from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher from pyglet.math import Vec2 @@ -45,8 +46,8 @@ class FocusMode(IntEnum): class _ChildEntry(NamedTuple): - child: "UIWidget" - data: Dict + child: UIWidget + data: dict @copy_dunders_unimplemented @@ -74,15 +75,15 @@ class UIWidget(EventDispatcher, ABC): focused = Property(False) focus_mode: FocusMode = FocusMode.NONE - size_hint = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) - size_hint_min = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) - size_hint_max = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) + size_hint = Property[tuple[float | None, float | None] | None](None) + size_hint_min = Property[tuple[float | None, float | None] | None](None) + size_hint_max = Property[tuple[float | None, float | None] | None](None) _children = ListProperty[_ChildEntry]() _border_width = Property(0) - _border_color = Property[Optional[Color]](arcade.color.BLACK) - _bg_color = Property[Optional[Color]]() - _bg_tex = Property[Union[Texture, NinePatchTexture, None]]() + _border_color = Property[Color | None](arcade.color.BLACK) + _bg_color = Property[Color | None]() + _bg_tex = Property[Texture | NinePatchTexture | None]() _padding_top = Property(0) _padding_right = Property(0) _padding_bottom = Property(0) @@ -100,11 +101,11 @@ def __init__( y: float = 0, width: float = 100, height: float = 100, - children: Iterable["UIWidget"] = tuple(), + children: Iterable[UIWidget] = tuple(), # Properties which might be used by layouts - size_hint: Optional[Tuple[float | None, float | None]] = None, # in percentage - size_hint_min: Optional[Tuple[float | None, float | None]] = None, # in pixel - size_hint_max: Optional[Tuple[float | None, float | None]] = None, # in pixel + size_hint: tuple[float | None, float | None] | None = None, # in percentage + size_hint_min: tuple[float | None, float | None] | None = None, # in pixel + size_hint_max: tuple[float | None, float | None] | None = None, # in pixel **kwargs, ): self._requires_render = True @@ -165,7 +166,7 @@ def add(self, child: W, **kwargs) -> W: return child - def remove(self, child: "UIWidget") -> dict | None: + def remove(self, child: UIWidget) -> dict | None: """Removes a child from the UIManager which was directly added to it. This will not remove widgets which are added to a child of UIManager. @@ -207,7 +208,7 @@ def on_event(self, event: UIEvent) -> bool | None: return EVENT_UNHANDLED - def _walk_parents(self) -> Iterable[Union["UIWidget", "UIManager"]]: + def _walk_parents(self) -> Iterable[UIWidget | UIManager]: parent = self.parent while isinstance(parent, UIWidget): yield parent @@ -386,7 +387,7 @@ def center(self) -> Vec2: return self.rect.center @center.setter - def center(self, value: Tuple[int, int]): + def center(self, value: tuple[int, int]): self.rect = self.rect.align_center(value) @property @@ -410,7 +411,7 @@ def padding(self): ) @padding.setter - def padding(self, args: Union[int, Tuple[int, int], Tuple[int, int, int, int]]): + def padding(self, args: int | tuple[int, int] | tuple[int, int, int, int]): if isinstance(args, int): # self.padding = 10 -> 10, 10, 10, 10 args = (args, args, args, args) @@ -424,7 +425,7 @@ def padding(self, args: Union[int, Tuple[int, int], Tuple[int, int, int, int]]): self._padding_left = pl @property - def children(self) -> List["UIWidget"]: + def children(self) -> list[UIWidget]: """Provides all child widgets.""" return [child for child, data in self._children] @@ -484,8 +485,8 @@ def with_padding( def with_background( self, *, - color: Union[None, Color] = ..., # type: ignore - texture: Union[None, Texture, NinePatchTexture] = ..., # type: ignore + color: None | Color = ..., # type: ignore + texture: None | Texture | NinePatchTexture = ..., # type: ignore ) -> Self: """Set widgets background. @@ -511,7 +512,7 @@ def with_background( return self @property - def content_size(self) -> Tuple[float, float]: + def content_size(self) -> tuple[float, float]: """Returns the size of the content area, which is the size of the widget minus padding and border.""" return self.content_width, self.content_height @@ -811,7 +812,7 @@ class UILayout(UIWidget): """ @staticmethod - def min_size_of(child: UIWidget) -> Tuple[float, float]: + def min_size_of(child: UIWidget) -> tuple[float, float]: """Resolves the minimum size of a child. If it has a size_hint set for the axis, it will use size_hint_min if set, otherwise the actual size will be used. """ diff --git a/arcade/gui/widgets/buttons.py b/arcade/gui/widgets/buttons.py index 60136e5398..adc0919e74 100644 --- a/arcade/gui/widgets/buttons.py +++ b/arcade/gui/widgets/buttons.py @@ -1,7 +1,5 @@ from dataclasses import dataclass -from typing import Optional, Union - -from typing_extensions import TypeAlias +from typing import TypeAlias import arcade from arcade import Texture, color, uicolor @@ -56,7 +54,7 @@ class UITextureButton(UIInteractiveWidget, UIStyledWidget[UITextureButtonStyle], size_hint_max: max width and height in pixel """ - _textures = DictProperty[str, Union[Texture, NinePatchTexture]]() + _textures = DictProperty[str, Texture | NinePatchTexture]() UIStyle = UITextureButtonStyle @@ -80,14 +78,14 @@ def __init__( y: float = 0, width: float | None = None, height: float | None = None, - texture: Union[None, Texture, NinePatchTexture] = None, - texture_hovered: Union[None, Texture, NinePatchTexture] = None, - texture_pressed: Union[None, Texture, NinePatchTexture] = None, - texture_disabled: Union[None, Texture, NinePatchTexture] = None, + texture: None | Texture | NinePatchTexture = None, + texture_hovered: None | Texture | NinePatchTexture = None, + texture_pressed: None | Texture | NinePatchTexture = None, + texture_disabled: None | Texture | NinePatchTexture = None, text: str = "", multiline: bool = False, scale: float | None = None, - style: Optional[dict[str, UIStyleBase]] = None, + style: dict[str, UIStyleBase] | None = None, size_hint=None, size_hint_min=None, size_hint_max=None, diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index 27c2332911..afa75c495a 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -1,5 +1,4 @@ from copy import deepcopy -from typing import Optional, Union from pyglet.event import EVENT_HANDLED @@ -31,7 +30,7 @@ def hide(self): if self.parent: self.parent.remove(self) - def on_event(self, event: UIEvent) -> Optional[bool]: + def on_event(self, event: UIEvent) -> bool | None: if isinstance(event, UIMousePressEvent): # Click outside of dropdown options if not self.rect.point_in_rect((event.x, event.y)): @@ -119,7 +118,7 @@ def __init__( width: float = 150, height: float = 30, default: str | None = None, - options: Optional[list[Union[str, None]]] = None, + options: list[str | None] | None = None, primary_style=None, dropdown_style=None, active_style=None, diff --git a/arcade/gui/widgets/image.py b/arcade/gui/widgets/image.py index b4b4e90eab..f732753168 100644 --- a/arcade/gui/widgets/image.py +++ b/arcade/gui/widgets/image.py @@ -1,5 +1,4 @@ import math -from typing import Union from typing_extensions import override @@ -28,7 +27,7 @@ class UIImage(UIWidget): **kwargs: passed to UIWidget """ - texture = Property[Union[Texture, NinePatchTexture]]() + texture = Property[Texture | NinePatchTexture]() """Texture to show""" alpha = Property(255) """Alpha value of the texture, value between 0 and 255. @@ -40,7 +39,7 @@ class UIImage(UIWidget): def __init__( self, *, - texture: Union[Texture, NinePatchTexture], + texture: Texture | NinePatchTexture, width: float | None = None, height: float | None = None, angle: int = 0, diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index a3d7205f32..4a10d19d38 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -1,10 +1,11 @@ from __future__ import annotations import warnings +from collections.abc import Iterable from dataclasses import dataclass -from typing import Dict, Iterable, List, Tuple, TypeVar +from typing import Literal, TypeVar -from typing_extensions import Literal, override +from typing_extensions import override from arcade.gui.property import bind, unbind from arcade.gui.widgets import UILayout, UIWidget, _ChildEntry @@ -74,7 +75,7 @@ def __init__( y: float = 0, width: float = 1, height: float = 1, - children: Iterable["UIWidget"] = tuple(), + children: Iterable[UIWidget] = tuple(), size_hint=(1, 1), size_hint_min=None, size_hint_max=None, @@ -296,7 +297,7 @@ def add(self, child: W, **kwargs) -> W: return super().add(child, **kwargs) @override - def remove(self, child: "UIWidget"): + def remove(self, child: UIWidget): """Remove a child from the layout.""" # unsubscribe from child's changes unbind(child, "_children", self._trigger_size_hint_update) @@ -482,7 +483,7 @@ def __init__( row_count: int = 1, **kwargs, ): - super(UIGridLayout, self).__init__( + super().__init__( x=x, y=y, width=width, @@ -550,7 +551,7 @@ def add( **kwargs, ) - def remove(self, child: "UIWidget"): + def remove(self, child: UIWidget): """Remove a child from the layout.""" # unsubscribe from child's changes unbind(child, "_children", self._trigger_size_hint_update) @@ -677,7 +678,7 @@ def do_layout(self): for i in range(self.row_count): rows.append([]) - lookup: Dict[Tuple[int, int], _ChildEntry] = {} + lookup: dict[tuple[int, int], _ChildEntry] = {} for entry in self._children: col_num = entry.data["column"] row_num = entry.data["row"] @@ -868,7 +869,7 @@ def from_widget_height(widget: UIWidget) -> _C: return _C.from_widget(widget, "height") -def _box_orthogonal_algorithm(constraints: list[_C], container_size: float) -> List[float]: +def _box_orthogonal_algorithm(constraints: list[_C], container_size: float) -> list[float]: """Calculate the 1 dimensional size of each entry based on the hint value and the available space in the container. @@ -889,7 +890,7 @@ def _box_orthogonal_algorithm(constraints: list[_C], container_size: float) -> L return [c._final_size for c in constraints] -def _box_axis_algorithm(constraints: list[_C], container_size: float) -> List[float]: +def _box_axis_algorithm(constraints: list[_C], container_size: float) -> list[float]: """ The box algorithm calculates the 1 dimensional size of each entry based on the hint value and the available space in the container. diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index 9d7fccede6..b17847acd0 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -2,8 +2,8 @@ import warnings from abc import ABCMeta, abstractmethod +from collections.abc import Mapping from dataclasses import dataclass -from typing import Mapping, Union from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from typing_extensions import override @@ -67,8 +67,8 @@ def __init__( size_hint=None, size_hint_min=None, size_hint_max=None, - style: Union[Mapping[str, UISliderStyle], None] = None, - step: Union[float, None] = None, + style: Mapping[str, UISliderStyle] | None = None, + step: float | None = None, **kwargs, ): super().__init__( @@ -378,8 +378,8 @@ def __init__( size_hint=None, size_hint_min=None, size_hint_max=None, - style: Union[dict[str, UISliderStyle], None] = None, - step: Union[float, None] = None, + style: dict[str, UISliderStyle] | None = None, + step: float | None = None, **kwargs, ): super().__init__( @@ -530,8 +530,8 @@ class UITextureSlider(UISlider): def __init__( self, - track_texture: Union[Texture, NinePatchTexture], - thumb_texture: Union[Texture, NinePatchTexture], + track_texture: Texture | NinePatchTexture, + thumb_texture: Texture | NinePatchTexture, style=None, **kwargs, ): diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 12b9b237be..91353eeb43 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -1,13 +1,13 @@ import warnings from copy import deepcopy from dataclasses import dataclass -from typing import Union +from typing import Literal import pyglet from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from pyglet.text.caret import Caret from pyglet.text.document import AbstractDocument -from typing_extensions import Literal, override +from typing_extensions import override import arcade from arcade import uicolor @@ -513,7 +513,7 @@ def __init__( size_hint=None, size_hint_min=None, size_hint_max=None, - style: Union[dict[str, UIInputTextStyle], None] = None, + style: dict[str, UIInputTextStyle] | None = None, **kwargs, ): if border_color != arcade.color.WHITE or border_width != 2: diff --git a/arcade/math.py b/arcade/math.py index bf9d03de60..a35237fcad 100644 --- a/arcade/math.py +++ b/arcade/math.py @@ -375,7 +375,7 @@ def rescale_relative_to_point(source: Point2, target: Point2, factor: AsFloat | The rescaled point. """ - if isinstance(factor, (float, int)): + if isinstance(factor, float | int): if factor == 1.0: return target scale_x = scale_y = factor diff --git a/arcade/particles/emitter.py b/arcade/particles/emitter.py index 6067472b1d..c5e7e7a8a1 100644 --- a/arcade/particles/emitter.py +++ b/arcade/particles/emitter.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Callable +from collections.abc import Callable import arcade from arcade import Vec2 @@ -135,7 +135,7 @@ def __init__( self, center_xy: Point, emit_controller: EmitController, - particle_factory: Callable[["Emitter"], Particle], + particle_factory: Callable[[Emitter], Particle], change_xy: Velocity = (0.0, 0.0), emit_done_cb: Callable[[Emitter], None] | None = None, reap_cb: Callable[[], None] | None = None, diff --git a/arcade/particles/emitter_simple.py b/arcade/particles/emitter_simple.py index e049932b23..04523a6e72 100644 --- a/arcade/particles/emitter_simple.py +++ b/arcade/particles/emitter_simple.py @@ -6,7 +6,7 @@ """ import random -from typing import Sequence +from collections.abc import Sequence from arcade.math import rand_in_circle, rand_on_circle from arcade.types import PathOrTexture, Point diff --git a/arcade/paths.py b/arcade/paths.py index 2990a801ea..a6cb3b249c 100644 --- a/arcade/paths.py +++ b/arcade/paths.py @@ -58,7 +58,7 @@ def _heuristic(start: Point2, goal: Point2) -> float: return d * (dx + dy) + (d2 - 2 * d) * min(dx, dy) -class _AStarGraph(object): +class _AStarGraph: """ A grid which tracks 2 barriers and a moving sprite. diff --git a/arcade/physics_engines.py b/arcade/physics_engines.py index ab8c1412dd..d68275d77e 100644 --- a/arcade/physics_engines.py +++ b/arcade/physics_engines.py @@ -3,7 +3,7 @@ """ import math -from typing import Iterable +from collections.abc import Iterable from arcade import ( BasicSprite, diff --git a/arcade/pymunk_physics_engine.py b/arcade/pymunk_physics_engine.py index da5199a2a4..7e966d5724 100644 --- a/arcade/pymunk_physics_engine.py +++ b/arcade/pymunk_physics_engine.py @@ -4,7 +4,7 @@ import logging import math -from typing import Callable +from collections.abc import Callable import pymunk from pyglet.math import Vec2 diff --git a/arcade/resources/__init__.py b/arcade/resources/__init__.py index d14aff5229..b506e338c6 100644 --- a/arcade/resources/__init__.py +++ b/arcade/resources/__init__.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Sequence +from collections.abc import Sequence from arcade.exceptions import warning, ReplacementWarning #: The absolute path to this directory @@ -93,11 +93,9 @@ def resolve(path: str | Path) -> Path: else: searched_paths = "\n".join(f"-> {p}" for p in reversed(paths)) raise FileNotFoundError( - ( - f"Cannot locate resource '{resource}' using handle " - f"'{handle}' in any of the following paths:\n" - f"{searched_paths}" - ) + f"Cannot locate resource '{resource}' using handle " + f"'{handle}' in any of the following paths:\n" + f"{searched_paths}" ) # Always convert into a Path object diff --git a/arcade/scene.py b/arcade/scene.py index dac305ec7b..0d9a3fb060 100644 --- a/arcade/scene.py +++ b/arcade/scene.py @@ -10,7 +10,7 @@ * Control sprite list draw order within the group """ -from typing import Iterable +from collections.abc import Iterable from warnings import warn from arcade import Sprite, SpriteList @@ -404,7 +404,7 @@ def update( **kwargs: Additional keyword arguments propagated down to sprites """ # Due to api changes in 3.0 we sanity check delta_time - if not isinstance(delta_time, (int, float)): + if not isinstance(delta_time, int | float): raise TypeError( f"Expected a number for delta_time, but got {type(delta_time)} instead." ) diff --git a/arcade/sections.py b/arcade/sections.py index 4b34ab10b8..7426a1a4f1 100644 --- a/arcade/sections.py +++ b/arcade/sections.py @@ -1,7 +1,8 @@ from __future__ import annotations import math -from typing import TYPE_CHECKING, Generator, Iterable +from collections.abc import Generator, Iterable +from typing import TYPE_CHECKING from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED @@ -264,7 +265,7 @@ def window(self): else: return self._view.window - def overlaps_with(self, section: "Section") -> bool: + def overlaps_with(self, section: Section) -> bool: """Checks if this section overlaps with another section Args: diff --git a/arcade/shape_list.py b/arcade/shape_list.py index af01cde14b..08d1437531 100644 --- a/arcade/shape_list.py +++ b/arcade/shape_list.py @@ -10,10 +10,9 @@ import math from array import array from collections import OrderedDict +from collections.abc import Iterable, Sequence from typing import ( Generic, - Iterable, - Sequence, TypeVar, cast, ) diff --git a/arcade/sound.py b/arcade/sound.py index 292a6e08ba..5eac3d2cdd 100644 --- a/arcade/sound.py +++ b/arcade/sound.py @@ -311,7 +311,7 @@ def play_sound( elif not isinstance(sound, Sound): raise TypeError( f"Error, got {sound!r} instead of an arcade.Sound." - if not isinstance(sound, (str, Path, bytes)) + if not isinstance(sound, str | Path | bytes) else " Make sure to use load_sound first, then play the result with play_sound." ) diff --git a/arcade/sprite/base.py b/arcade/sprite/base.py index 2a785e0366..8ecec9657e 100644 --- a/arcade/sprite/base.py +++ b/arcade/sprite/base.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterable, TypeVar +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, TypeVar import arcade from arcade.color import BLACK, WHITE @@ -68,7 +69,7 @@ def __init__( self._depth = 0.0 self._texture = texture width, height = texture.size - self._scale = (scale, scale) if isinstance(scale, (float, int)) else (scale[0], scale[1]) + self._scale = (scale, scale) if isinstance(scale, float | int) else (scale[0], scale[1]) # noqa: UP038 self._width = width * self._scale[0] self._height = height * self._scale[1] self._visible = bool(visible) @@ -81,7 +82,7 @@ def __init__( # All changes to this list should go through the pair of methods # register_sprite_list, _unregister_sprite_list. # They ensure that the above typing invariant is preserved. - self.sprite_lists: list["SpriteList[Any]"] = [] + self.sprite_lists: list[SpriteList[Any]] = [] """The sprite lists this sprite is a member of""" # Core properties we don't use, but spritelist expects it @@ -292,7 +293,7 @@ def scale(self) -> Point2: @scale.setter def scale(self, new_scale: Point2 | AsFloat): - if isinstance(new_scale, (float, int)): + if isinstance(new_scale, float | int): scale_x = new_scale scale_y = new_scale @@ -456,10 +457,8 @@ def rgb(self, color: RGBOrA255): except ValueError: # It's always a length issue raise ValueError( - ( - f"{self.__class__.__name__},rgb takes 3 or 4 channel" - f" colors, but got {len(color)} channels" - ) + f"{self.__class__.__name__},rgb takes 3 or 4 channel" + f" colors, but got {len(color)} channels" ) # Unpack to avoid index / . overhead & prep for repack @@ -661,7 +660,7 @@ def rescale_relative_to_point(self, point: Point2, scale_by: AsFloat | Point2) - """ # abort if the multiplier wouldn't do anything - if isinstance(scale_by, (float, int)): + if isinstance(scale_by, float | int): if scale_by == 1.0: return factor_x = scale_by diff --git a/arcade/sprite/sprite.py b/arcade/sprite/sprite.py index e67cf5795e..7f54dff850 100644 --- a/arcade/sprite/sprite.py +++ b/arcade/sprite/sprite.py @@ -73,7 +73,7 @@ def __init__( if isinstance(path_or_texture, Texture): _texture = path_or_texture _textures = [_texture] - elif isinstance(path_or_texture, (str, Path)): + elif isinstance(path_or_texture, str | Path): _texture = arcade.texture.default_texture_cache.load_or_get_texture(path_or_texture) _textures = [_texture] else: diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 4d58f65f46..60330bb1eb 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -1,9 +1,5 @@ import struct -from typing import ( - Iterable, - List, - Tuple, -) +from collections.abc import Iterable from arcade import ( get_window, @@ -33,7 +29,7 @@ def get_distance_between_sprites(sprite1: SpriteType, sprite2: SpriteType) -> fl def get_closest_sprite( sprite: BasicSprite, sprite_list: SpriteSequence[SpriteType] -) -> Tuple[SpriteType, float] | None: +) -> tuple[SpriteType, float] | None: """ Given a Sprite and SpriteList, returns the closest sprite, and its distance. @@ -134,7 +130,7 @@ def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool: def _get_nearby_sprites( sprite: BasicSprite, sprite_list: SpriteSequence[SpriteType] -) -> List[SpriteType]: +) -> list[SpriteType]: sprite_count = len(sprite_list) if sprite_count == 0: return [] @@ -188,7 +184,7 @@ def check_for_collision_with_list( sprite: BasicSprite, sprite_list: SpriteSequence[SpriteType], method: int = 0, -) -> List[SpriteType]: +) -> list[SpriteType]: """ Check for a collision between a sprite, and a list of sprites. @@ -248,7 +244,7 @@ def check_for_collision_with_lists( sprite: BasicSprite, sprite_lists: Iterable[SpriteSequence[SpriteType]], method=1, -) -> List[SpriteType]: +) -> list[SpriteType]: """ Check for a collision between a Sprite, and a list of SpriteLists. @@ -271,7 +267,7 @@ def check_for_collision_with_lists( f"it is an instance of {type(sprite)}." ) - sprites: List[SpriteType] = [] + sprites: list[SpriteType] = [] sprites_to_check: Iterable[SpriteType] for sprite_list in sprite_lists: @@ -290,7 +286,7 @@ def check_for_collision_with_lists( return sprites -def get_sprites_at_point(point: Point, sprite_list: SpriteSequence[SpriteType]) -> List[SpriteType]: +def get_sprites_at_point(point: Point, sprite_list: SpriteSequence[SpriteType]) -> list[SpriteType]: """ Get a list of sprites at a particular point. This function sees if any sprite overlaps the specified point. If a sprite has a different center_x/center_y but touches the point, @@ -322,7 +318,7 @@ def get_sprites_at_point(point: Point, sprite_list: SpriteSequence[SpriteType]) def get_sprites_at_exact_point( point: Point, sprite_list: SpriteSequence[SpriteType] -) -> List[SpriteType]: +) -> list[SpriteType]: """ Get a list of sprites whose center_x, center_y match the given point. This does NOT return sprites that overlap the point, the center has to be an exact match. @@ -349,7 +345,7 @@ def get_sprites_at_exact_point( return [s for s in sprites_to_check if s.position == point] -def get_sprites_in_rect(rect: Rect, sprite_list: SpriteSequence[SpriteType]) -> List[SpriteType]: +def get_sprites_in_rect(rect: Rect, sprite_list: SpriteSequence[SpriteType]) -> list[SpriteType]: """ Get a list of sprites in a particular rectangle. This function sees if any sprite overlaps the specified rectangle. If a sprite has a different diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index f2ca56e606..8c07f359ff 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -11,16 +11,11 @@ from abc import abstractmethod from array import array from collections import deque +from collections.abc import Callable, Collection, Iterable, Iterator, Sized from typing import ( TYPE_CHECKING, Any, - Callable, ClassVar, - Collection, - Deque, - Iterable, - Iterator, - Sized, cast, ) @@ -241,7 +236,7 @@ def __init__( # Number of slots used in the index buffer self._sprite_index_slots = 0 # List of free slots in the sprite buffers. These are filled when sprites are removed. - self._sprite_buffer_free_slots: Deque[int] = deque() + self._sprite_buffer_free_slots: deque[int] = deque() # List of sprites in the sprite list self.sprite_list: list[SpriteType] = [] @@ -998,8 +993,8 @@ def update_animation(self, delta_time: float = 1 / 60, *args, **kwargs) -> None: def _get_center(self) -> tuple[float, float]: """Get the mean center coordinates of all sprites in the list.""" - x = sum((sprite.center_x for sprite in self.sprite_list)) / len(self.sprite_list) - y = sum((sprite.center_y for sprite in self.sprite_list)) / len(self.sprite_list) + x = sum(sprite.center_x for sprite in self.sprite_list) / len(self.sprite_list) + y = sum(sprite.center_y for sprite in self.sprite_list) / len(self.sprite_list) return x, y center = property(_get_center) @@ -1023,7 +1018,7 @@ def move(self, change_x: float, change_y: float) -> None: sprite.center_x += change_x sprite.center_y += change_y - def preload_textures(self, texture_list: Iterable["Texture"]) -> None: + def preload_textures(self, texture_list: Iterable[Texture]) -> None: """ Preload a set of textures that will be used for sprites in this sprite list. diff --git a/arcade/text.py b/arcade/text.py index a836d6b5b1..3ceec48bfb 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -4,7 +4,7 @@ from ctypes import c_int, c_ubyte from pathlib import Path -from typing import Any, Union +from typing import Any import pyglet @@ -98,7 +98,7 @@ def load_font(path: str | Path) -> None: pyglet.font.add_file(str(file_path)) -FontNameOrNames = Union[str, tuple[str, ...]] +FontNameOrNames = str | tuple[str, ...] def _attempt_font_name_resolution(font_name: FontNameOrNames) -> str: diff --git a/arcade/texture_atlas/atlas_array.py b/arcade/texture_atlas/atlas_array.py index 5f15eab310..1505843f63 100644 --- a/arcade/texture_atlas/atlas_array.py +++ b/arcade/texture_atlas/atlas_array.py @@ -4,7 +4,6 @@ from typing import ( TYPE_CHECKING, - Tuple, ) from .base import TextureAtlasBase @@ -23,13 +22,13 @@ class TextureArrayAtlas(TextureAtlasBase): layers (int): The number of layers (number of textures to store) """ - def __init__(self, ctx: ArcadeContext | None, size: Tuple[int, int], layers: int): + def __init__(self, ctx: ArcadeContext | None, size: tuple[int, int], layers: int): super().__init__(ctx) self._size = size self._layers = layers @property - def size(self) -> Tuple[int, int]: + def size(self) -> tuple[int, int]: """The texture size in pixels per layer.""" return self._size diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index 964090e5a3..f920b7675d 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -2,13 +2,13 @@ import contextlib import copy +from collections.abc import Sequence # import logging # import time from pathlib import Path from typing import ( TYPE_CHECKING, - Sequence, ) from weakref import WeakSet, WeakValueDictionary, finalize, ref @@ -423,7 +423,7 @@ def _allocate_texture(self, texture: Texture) -> tuple[int, AtlasRegion]: return slot, texture_region - def _allocate_image(self, image_data: "ImageData") -> tuple[int, int, int, AtlasRegion]: + def _allocate_image(self, image_data: ImageData) -> tuple[int, int, int, AtlasRegion]: """ Attempts to allocate space for an image in the atlas or update the existing space for the image. @@ -568,7 +568,7 @@ def _remove_texture_by_identifiers(self, atlas_name: str, hash: str): self._textures_removed += 1 - def update_texture_image(self, texture: "Texture"): + def update_texture_image(self, texture: Texture): """ Updates the internal image of a texture in the atlas texture. The new image needs to be the exact same size as the original @@ -611,7 +611,7 @@ def get_texture_region_info(self, atlas_name: str) -> AtlasRegion: """ return self._texture_regions[atlas_name] - def get_texture_id(self, texture: "Texture") -> int: + def get_texture_id(self, texture: Texture) -> int: """ Get the internal id for a Texture in the atlas @@ -620,7 +620,7 @@ def get_texture_id(self, texture: "Texture") -> int: """ return self._texture_uvs.get_slot_or_raise(texture.atlas_name) - def has_texture(self, texture: "Texture") -> bool: + def has_texture(self, texture: Texture) -> bool: """ Check if a texture is already in the atlas. @@ -629,7 +629,7 @@ def has_texture(self, texture: "Texture") -> bool: """ return texture in self._textures - def has_unique_texture(self, texture: "Texture") -> bool: + def has_unique_texture(self, texture: Texture) -> bool: """ Check if the atlas already have a texture with the same image data and vertex order @@ -789,7 +789,7 @@ def use_uv_texture(self, unit: int = 0) -> None: @contextlib.contextmanager def render_into( self, - texture: "Texture", + texture: Texture, projection: tuple[float, float, float, float] | None = None, ): """ @@ -842,7 +842,7 @@ def render_into( fbo.viewport = 0, 0, *self._fbo.size prev_camera.use() - def read_texture_image_from_atlas(self, texture: "Texture") -> Image.Image: + def read_texture_image_from_atlas(self, texture: Texture) -> Image.Image: """ Read the pixel data for a texture directly from the atlas texture on the GPU. The contents of this image can be altered by rendering into the atlas and @@ -863,7 +863,7 @@ def read_texture_image_from_atlas(self, texture: "Texture") -> Image.Image: data = self.fbo.read(viewport=viewport, components=4) return Image.frombytes("RGBA", (region.width, region.height), data) - def update_texture_image_from_atlas(self, texture: "Texture") -> None: + def update_texture_image_from_atlas(self, texture: Texture) -> None: """ Update the Arcade Texture's internal image with the pixel data content from the atlas texture on the GPU. This can be useful if you render diff --git a/arcade/texture_atlas/base.py b/arcade/texture_atlas/base.py index 216238c19d..1a089f83da 100644 --- a/arcade/texture_atlas/base.py +++ b/arcade/texture_atlas/base.py @@ -251,7 +251,7 @@ def use_uv_texture(self, unit: int = 0) -> None: @contextlib.contextmanager def render_into( self, - texture: "Texture", + texture: Texture, projection: tuple[float, float, float, float] | None = None, ): """ diff --git a/arcade/texture_atlas/ref_counters.py b/arcade/texture_atlas/ref_counters.py index 6740f8b54f..197df53c1a 100644 --- a/arcade/texture_atlas/ref_counters.py +++ b/arcade/texture_atlas/ref_counters.py @@ -8,7 +8,7 @@ simply a texture using the same image and the same vertex order. """ -from typing import TYPE_CHECKING, Dict +from typing import TYPE_CHECKING if TYPE_CHECKING: from arcade.texture import ImageData, Texture @@ -23,7 +23,7 @@ class ImageDataRefCounter: """ def __init__(self) -> None: - self._data: Dict[str, int] = {} + self._data: dict[str, int] = {} self._num_decref = 0 def inc_ref(self, image_data: "ImageData") -> None: @@ -123,7 +123,7 @@ class UniqueTextureRefCounter: """ def __init__(self) -> None: - self._data: Dict[str, int] = {} + self._data: dict[str, int] = {} self._num_decref = 0 def inc_ref(self, image_data: "Texture") -> None: diff --git a/arcade/texture_atlas/region.py b/arcade/texture_atlas/region.py index 8b14eebb49..86de2ae3d3 100644 --- a/arcade/texture_atlas/region.py +++ b/arcade/texture_atlas/region.py @@ -120,12 +120,10 @@ def verify_image_size(self, image_data: ImageData): """ if image_data.size != (self.width, self.height): raise RuntimeError( - ( - f"ImageData '{image_data.hash}' change their internal image " - f"size from {self.width}x{self.height} to " - f"{image_data.width}x{image_data.height}. " - "It's not possible to fit this into the old allocated area in the atlas. " - ) + f"ImageData '{image_data.hash}' change their internal image " + f"size from {self.width}x{self.height} to " + f"{image_data.width}x{image_data.height}. " + "It's not possible to fit this into the old allocated area in the atlas. " ) def __repr__(self) -> str: diff --git a/arcade/texture_atlas/uv_data.py b/arcade/texture_atlas/uv_data.py index 1cd439c566..8fe4c7576f 100644 --- a/arcade/texture_atlas/uv_data.py +++ b/arcade/texture_atlas/uv_data.py @@ -6,7 +6,7 @@ from array import array from collections import deque -from typing import TYPE_CHECKING, Dict +from typing import TYPE_CHECKING from .base import ( UV_TEXTURE_WIDTH, @@ -60,7 +60,7 @@ def __init__(self, ctx: ArcadeContext, capacity: int): # Python resources: data + tracker for slots # 8 floats per texture (4 x vec2 coordinates) self._data = array("f", [0] * self._num_slots * 8) - self._slots: Dict[str, int] = dict() + self._slots: dict[str, int] = dict() self._slots_free = deque(i for i in range(0, self._num_slots)) def clone_with_slots(self) -> UVData: @@ -89,7 +89,7 @@ def num_free_slots(self) -> int: return len(self._slots_free) @property - def texture(self) -> "Texture2D": + def texture(self) -> Texture2D: """The opengl texture containing the texture coordinates""" return self._texture @@ -124,11 +124,9 @@ def get_existing_or_free_slot(self, name: str) -> int: return slot except IndexError: raise Exception( - ( - "No more free slots in the UV texture." - f"Max number of textures: {self._num_slots}." - "Consider creating a texture atlas with a larger capacity." - ) + "No more free slots in the UV texture." + f"Max number of textures: {self._num_slots}." + "Consider creating a texture atlas with a larger capacity." ) def free_slot_by_name(self, name: str) -> None: diff --git a/arcade/tilemap/tilemap.py b/arcade/tilemap/tilemap.py index e81e5efc19..3828c6c9c8 100644 --- a/arcade/tilemap/tilemap.py +++ b/arcade/tilemap/tilemap.py @@ -13,9 +13,9 @@ import math import os from collections import OrderedDict -from collections.abc import Sequence +from collections.abc import Callable, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, List, cast +from typing import TYPE_CHECKING, Any, cast import pytiled_parser import pytiled_parser.tiled_object @@ -373,7 +373,7 @@ def get_tilemap_layer(self, layer_path: str) -> pytiled_parser.Layer | None: """ assert isinstance(layer_path, str) - def _get_tilemap_layer(my_path: List[str], layers): + def _get_tilemap_layer(my_path: list[str], layers): layer_name = my_path.pop(0) for my_layer in layers: if my_layer.name == layer_name: @@ -670,7 +670,7 @@ def _create_sprite_from_tile( def _process_image_layer( self, layer: pytiled_parser.ImageLayer, - texture_atlas: "DefaultTextureAtlas", + texture_atlas: DefaultTextureAtlas, scaling: float = 1.0, use_spatial_hash: bool = False, hit_box_algorithm: HitBoxAlgorithm | None = None, @@ -758,7 +758,7 @@ def _process_image_layer( def _process_tile_layer( self, layer: pytiled_parser.TileLayer, - texture_atlas: "DefaultTextureAtlas", + texture_atlas: DefaultTextureAtlas, scaling: float = 1.0, use_spatial_hash: bool = False, hit_box_algorithm: HitBoxAlgorithm | None = None, @@ -786,11 +786,9 @@ def _process_tile_layer( tile = self._get_tile_by_gid(item) if tile is None: raise ValueError( - ( - f"Couldn't find tile for item {item} in layer " - f"'{layer.name}' in file '{self.tiled_map.map_file}'" - f"at ({column_index}, {row_index})." - ) + f"Couldn't find tile for item {item} in layer " + f"'{layer.name}' in file '{self.tiled_map.map_file}'" + f"at ({column_index}, {row_index})." ) my_sprite = self._create_sprite_from_tile( @@ -836,7 +834,7 @@ def _process_tile_layer( def _process_object_layer( self, layer: pytiled_parser.ObjectLayer, - texture_atlas: "DefaultTextureAtlas", + texture_atlas: DefaultTextureAtlas, scaling: float = 1.0, use_spatial_hash: bool = False, hit_box_algorithm: HitBoxAlgorithm | None = None, diff --git a/arcade/types/__init__.py b/arcade/types/__init__.py index da08112cd0..04a97f5ec9 100644 --- a/arcade/types/__init__.py +++ b/arcade/types/__init__.py @@ -24,7 +24,8 @@ # flake8: noqa: E402 import sys from pathlib import Path -from typing import Any, NamedTuple, Union, TYPE_CHECKING, TypeVar, Iterable, Protocol +from typing import Any, NamedTuple, TYPE_CHECKING, TypeVar, Protocol, Union +from collections.abc import Iterable from pytiled_parser import Properties @@ -43,7 +44,7 @@ # use an ABC which registers virtual subclasses. This will not work # with ctypes.Array since virtual subclasses must be concrete. # See: https://peps.python.org/pep-0688/ - BufferProtocol = Union[ByteString, memoryview, array, ctypes.Array] + BufferProtocol = ByteString | memoryview | array | ctypes.Array # Since it's used everywhere, we'll prevent partial submodule @@ -143,7 +144,7 @@ _T = TypeVar("_T") -OneOrIterableOf = Union[_T, Iterable[_T]] +OneOrIterableOf = Union[_T, Iterable[_T]] # noqa: UP007 """Either an instance of something or an iterable of them. When writing loading code which is not performance critical, @@ -225,9 +226,9 @@ def __mul__(self, value: _T_contra, /) -> _T_co: ... # Path handling -PathLike = Union[str, Path, bytes] +PathLike = str | Path | bytes _POr = TypeVar("_POr") # Allows PathOr[TypeNameHere] syntax -PathOr = Union[PathLike, _POr] +PathOr = Union[PathLike, _POr] # noqa: UP007 # Specific utility resource aliases with type imports @@ -241,7 +242,7 @@ def __mul__(self, value: _T_contra, /) -> _T_co: ... class TiledObject(NamedTuple): """Object in a tilemaps""" - shape: Union[Point, PointList, tuple[int, int, int, int]] + shape: Point | PointList | tuple[int, int, int, int] """Shape of the object""" properties: Properties | None = None """Properties of the object""" @@ -260,4 +261,4 @@ class SupportsDunderGT(Protocol[_T_contra]): def __gt__(self, other: _T_contra, /) -> bool: ... -SupportsRichComparison = Union[SupportsDunderLT[Any], SupportsDunderGT[Any]] +SupportsRichComparison = SupportsDunderLT[Any] | SupportsDunderGT[Any] diff --git a/arcade/types/color.py b/arcade/types/color.py index 98ee2284f1..685e7cc9e4 100644 --- a/arcade/types/color.py +++ b/arcade/types/color.py @@ -21,9 +21,10 @@ from __future__ import annotations import random -from typing import Iterable, TypeVar, Union +from collections.abc import Iterable +from typing import Final, TypeVar -from typing_extensions import Final, Self +from typing_extensions import Self from arcade.exceptions import ByteRangeError, IntOutsideRangeError, NormalizedRangeError @@ -63,12 +64,12 @@ # Color type aliases. -ChannelType = TypeVar("ChannelType") +ChannelType = TypeVar("ChannelType", int, float) # Generic color aliases RGB = tuple[ChannelType, ChannelType, ChannelType] RGBA = tuple[ChannelType, ChannelType, ChannelType, ChannelType] -RGBOrA = Union[RGB[ChannelType], RGBA[ChannelType]] +RGBOrA = RGB[ChannelType] | RGBA[ChannelType] # Specific color aliases RGB255 = RGB[int] diff --git a/arcade/types/numbers.py b/arcade/types/numbers.py index 6ece433e08..318cab7b31 100644 --- a/arcade/types/numbers.py +++ b/arcade/types/numbers.py @@ -5,10 +5,8 @@ circular imports or partially initialized modules. """ -from typing import Union - #: 1. Makes pyright happier while also telling readers #: 2. Tells readers we're converting any ints to floats -AsFloat = Union[float, int] +AsFloat = float | int __all__ = ["AsFloat"] diff --git a/arcade/types/vector_like.py b/arcade/types/vector_like.py index 782694468c..1965786585 100644 --- a/arcade/types/vector_like.py +++ b/arcade/types/vector_like.py @@ -7,17 +7,17 @@ """ -from typing import Sequence, Union +from collections.abc import Sequence from pyglet.math import Vec2, Vec3 from arcade.types.numbers import AsFloat #: Matches both :py:class:`~pyglet.math.Vec2` and tuples of two numbers. -Point2 = Union[tuple[AsFloat, AsFloat], Vec2] +Point2 = tuple[AsFloat, AsFloat] | Vec2 #: Matches both :py:class:`~pyglet.math.Vec3` and tuples of three numbers. -Point3 = Union[tuple[AsFloat, AsFloat, AsFloat], Vec3] +Point3 = tuple[AsFloat, AsFloat, AsFloat] | Vec3 #: Matches any 2D or 3D point, including: @@ -32,7 +32,7 @@ #: This works the same way as :py:attr:`arcade.types.RGBOrA255` to #: annotate RGB tuples, RGBA tuples, and :py:class:`tuple` or a #: :py:class:`Color` instances. -Point = Union[Point2, Point3] +Point = Point2 | Point3 PointList = Sequence[Point] Point2List = Sequence[Point2] diff --git a/arcade/utils.py b/arcade/utils.py index 547820d47e..25595390c3 100644 --- a/arcade/utils.py +++ b/arcade/utils.py @@ -6,10 +6,10 @@ import platform import sys -from collections.abc import MutableSequence +from collections.abc import Callable, Generator, Iterable, MutableSequence, Sequence from itertools import chain from pathlib import Path -from typing import Any, Callable, Generator, Generic, Iterable, Sequence, Type, TypeVar +from typing import Any, Generic, TypeVar from arcade.types import AsFloat, Point2 @@ -28,7 +28,7 @@ # Since this module forbids importing from the rest of # Arcade, we make our own local type variables. _T = TypeVar("_T") -_TType = TypeVar("_TType", bound=Type) +_TType = TypeVar("_TType", bound=type) class Chain(Generic[_T]): @@ -49,8 +49,7 @@ def __init__(self, *components: Sequence[_T]): self.components: list[Sequence[_T]] = list(components) def __iter__(self) -> Generator[_T, None, None]: - for item in chain.from_iterable(self.components): - yield item + yield from chain.from_iterable(self.components) def as_type(item: Any) -> type: @@ -242,7 +241,7 @@ def __copy__(self): # noqa f"you may implement it on a custom subclass." ) - decorated_type.__copy__ = __copy__ + decorated_type.__copy__ = __copy__ # type: ignore def __deepcopy__(self, memo): # noqa raise NotImplementedError( @@ -250,7 +249,7 @@ def __deepcopy__(self, memo): # noqa f" but you may implement it on a custom subclass." ) - decorated_type.__deepcopy__ = __deepcopy__ + decorated_type.__deepcopy__ = __deepcopy__ # type: ignore return decorated_type @@ -306,7 +305,7 @@ def unpack_asfloat_or_point(value: AsFloat | Point2) -> Point2: A Point2 that is either equal to value, or is equal to (value, value) """ - if isinstance(value, (float, int)): + if isinstance(value, float | int): x = y = value else: try: diff --git a/arcade/window_commands.py b/arcade/window_commands.py index 92013ed513..99d2f41c05 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -9,7 +9,8 @@ import gc import os import time -from typing import TYPE_CHECKING, Callable +from collections.abc import Callable +from typing import TYPE_CHECKING import pyglet @@ -53,7 +54,7 @@ def get_display_size(screen_id: int = 0) -> tuple[int, int]: return screen.width, screen.height -def get_window() -> "Window": +def get_window() -> Window: """ Return a handle to the current window. @@ -180,12 +181,10 @@ def start_render(pixelated=False, blend=True) -> None: window = get_window() if window._start_finish_render_data is not None: raise RuntimeError( - ( - "start_render() can only be called once during the application's lifetime " - "and should only be used when calling draw functions at module level in " - "a simple script to produce a static image. If you are seeing this error " - "you likely intended to call clear() instead." - ) + "start_render() can only be called once during the application's lifetime " + "and should only be used when calling draw functions at module level in " + "a simple script to produce a static image. If you are seeing this error " + "you likely intended to call clear() instead." ) window._start_finish_render_data = StartFinishRenderData(pixelated=pixelated, blend=blend) diff --git a/benchmarks/sprite/main.py b/benchmarks/sprite/main.py index e7aaf9bc71..0e9001de89 100644 --- a/benchmarks/sprite/main.py +++ b/benchmarks/sprite/main.py @@ -1,6 +1,7 @@ """ Quick and dirty system measuring differences between two sprite classes. """ + import gc import math import timeit @@ -17,9 +18,19 @@ MEASUREMENT_CONFIG = [ {"name": "populate", "number": N, "measure_method": "populate", "post_methods": ["flush"]}, {"name": "scale_set", "number": N, "measure_method": "scale_set", "post_methods": []}, - {"name": "scale_set_uniform", "number": N, "measure_method": "scale_set_uniform", "post_methods": []}, + { + "name": "scale_set_uniform", + "number": N, + "measure_method": "scale_set_uniform", + "post_methods": [], + }, {"name": "scale_mult", "number": N, "measure_method": "scale_mult", "post_methods": []}, - {"name": "scale_mult_uniform", "number": N, "measure_method": "scale_mult_uniform", "post_methods": []}, + { + "name": "scale_mult_uniform", + "number": N, + "measure_method": "scale_mult_uniform", + "post_methods": [], + }, ] @@ -94,6 +105,7 @@ def scale_mult(self): class SpriteCollectionA(SpriteCollection): sprite_type = SpriteA + class SpriteCollectionB(SpriteCollection): sprite_type = SpriteB @@ -138,11 +150,11 @@ def main(): a = SpriteCollectionA() b = SpriteCollectionB() - m1 = measure_sprite_collection(a) + _ = measure_sprite_collection(a) gc_until_nothing() - m2 = measure_sprite_collection(b) + _ = measure_sprite_collection(b) # FIXME: Compare measurements -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/benchmarks/sprite/sprite_alt.py b/benchmarks/sprite/sprite_alt.py index db09941b4e..61d79dded6 100644 --- a/benchmarks/sprite/sprite_alt.py +++ b/benchmarks/sprite/sprite_alt.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Any, Iterable, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar +from collections.abc import Iterable import arcade from arcade.color import BLACK, WHITE @@ -63,12 +64,12 @@ def __init__( self._depth = 0.0 self._texture = texture width, height = texture.size - self._scale = (scale, scale) if isinstance(scale, (float, int)) else scale + self._scale = (scale, scale) if isinstance(scale, float | int) else scale self._width = width * self._scale[0] self._height = height * self._scale[1] self._visible = bool(visible) self._color: Color = WHITE - self.sprite_lists: list["SpriteList"] = [] + self.sprite_lists: list[SpriteList] = [] """The sprite lists this sprite is a member of""" # Core properties we don't use, but spritelist expects it @@ -417,7 +418,6 @@ def rgb(self) -> tuple[int, int, int]: @rgb.setter def rgb(self, color: RGBOrA255): - # Fast validation of size by unpacking channel values try: r, g, b, *_a = color @@ -426,10 +426,8 @@ def rgb(self, color: RGBOrA255): except ValueError: # It's always a length issue raise ValueError( - ( - f"{self.__class__.__name__},rgb takes 3 or 4 channel" - f" colors, but got {len(color)} channels" - ) + f"{self.__class__.__name__},rgb takes 3 or 4 channel" + f" colors, but got {len(color)} channels" ) # Unpack to avoid index / . overhead & prep for repack diff --git a/make.py b/make.py index ced4ec11dd..463de5daf0 100755 --- a/make.py +++ b/make.py @@ -17,7 +17,8 @@ from contextlib import contextmanager from pathlib import Path from shutil import rmtree, which -from typing import Generator, Union +from typing import Union +from collections.abc import Generator PathLike = Union[Path, str, bytes] diff --git a/pyproject.toml b/pyproject.toml index a07274b1ee..97d6dc398f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,7 @@ lint.select = [ # Whitespace linting must be re-enabled manually for ruff # see https://beta.ruff.rs/docs/configuration/#using-pyprojecttoml "W", + # "UP", wait for arcade.gl abstraction merge ] [tool.ruff.format] From 02fe853ba3bd9226e59ca91b26407221050f497c Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Fri, 9 May 2025 15:08:12 -0500 Subject: [PATCH 158/279] Update version number (#2681) Co-authored-by: Paul V Craven --- arcade/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/VERSION b/arcade/VERSION index a0cd9f0ccb..a4f52a5dbb 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.1.0 \ No newline at end of file +3.2.0 \ No newline at end of file From b694d4c1cac06997e91524d0898bded774e84625 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Fri, 9 May 2025 15:14:52 -0500 Subject: [PATCH 159/279] Update changelog (#2683) Co-authored-by: Paul V Craven --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9df797bd..25b6a568e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## Version 3.2 (unreleased) +## Version 3.2 - GUI - Fix `UIScrollArea.add` always returning None From 580dd21603c662502326678a2edcde5a72a42f3e Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 10 May 2025 21:42:53 +0200 Subject: [PATCH 160/279] Don't dispatch on_draw if the window is closed (#2684) * Don't dispatch on_draw if the window is closed * Add close attribute to the window * Add changes to CHANGELOG --- CHANGELOG.md | 5 +++++ arcade/application.py | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b6a568e9..1759f78704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. +## Unreleased + +- Fixed an issue causing a crash when closing the window +- Added `Window.close` (bool) attribute indicating if the window is closed + ## Version 3.2 - GUI diff --git a/arcade/application.py b/arcade/application.py index 763661bd7d..4e77e047ea 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -172,6 +172,8 @@ def __init__( gl_version = 3, 1 gl_api = "gles" + self.closed = False + """Indicates if the window was closed""" self.headless: bool = arcade.headless """If True, the window is running in headless mode.""" @@ -429,6 +431,7 @@ def run(self, view: View | None = None) -> None: def close(self) -> None: """Close the Window.""" + self.closed = True super().close() # Make sure we don't reference the window any more set_window(None) @@ -537,7 +540,10 @@ def _dispatch_frame(self, delta_time: float) -> None: # we only need the modulus to keep time, if we didn't care # it could be set to zero instead. # ! This should maybe be fixed at 'self._draw_rate', discuss. - self.draw(self._accumulated_draw_time) + + # In case the window close in on_update, on_fixed_update or input callbacks + if not self.closed: + self.draw(self._accumulated_draw_time) self._accumulated_draw_time %= self._draw_rate def _dispatch_updates(self, delta_time: float) -> None: From 23c57f0a3105f7c415c4b31032cd3f4f552dc77c Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 9 May 2025 23:25:20 +0200 Subject: [PATCH 161/279] remove some type ignores --- arcade/gui/widgets/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 244024cb37..ce551638c7 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -3,6 +3,7 @@ from abc import ABC from collections.abc import Iterable from enum import IntEnum +from types import EllipsisType from typing import TYPE_CHECKING, NamedTuple, TypeVar from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher @@ -485,8 +486,8 @@ def with_padding( def with_background( self, *, - color: None | Color = ..., # type: ignore - texture: None | Texture | NinePatchTexture = ..., # type: ignore + color: Color | EllipsisType | None = ..., + texture: Texture | NinePatchTexture | EllipsisType | None = ..., ) -> Self: """Set widgets background. From 4b1e9d42cee9e7c6ef3f3d7787903fd0075553e7 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 11 May 2025 23:46:07 +0200 Subject: [PATCH 162/279] Allow property listener to receive: - no args - instance - instance, value - instance, value, old value --- arcade/gui/property.py | 69 ++++++++++++++++++++++++++------- tests/unit/gui/test_property.py | 10 ++--- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/arcade/gui/property.py b/arcade/gui/property.py index c9332c3ad4..c1fe26152b 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -1,6 +1,8 @@ +import inspect import sys import traceback from collections.abc import Callable +from contextlib import suppress from typing import Any, Generic, TypeVar, cast from weakref import WeakKeyDictionary, ref @@ -9,18 +11,64 @@ P = TypeVar("P") +NoArgListener = Callable[[], None] +InstanceListener = Callable[[Any], None] +InstanceValueListener = Callable[[Any, Any], None] +InstanceNewOldListener = Callable[[Any, Any, Any], None] +AnyListener = NoArgListener | InstanceListener | InstanceValueListener | InstanceNewOldListener + + class _Obs(Generic[P]): """ Internal holder for Property value and change listeners """ - __slots__ = ("value", "listeners") + __slots__ = ("value", "_listeners") def __init__(self, value: P): self.value = value # This will keep any added listener even if it is not referenced anymore # and would be garbage collected - self.listeners: set[Callable[[Any, P], Any] | Callable[[], Any]] = set() + self._listeners: dict[AnyListener, InstanceNewOldListener] = dict() + + def add( + self, + callback: AnyListener, + ): + """Add a callback to the list of listeners""" + self._listeners[callback] = _Obs._normalize_callback(callback) + + def remove(self, callback): + """Remove a callback from the list of listeners""" + if callback in self._listeners: + del self._listeners[callback] + + @property + def listeners(self) -> list[InstanceNewOldListener]: + return list(self._listeners.values()) + + @staticmethod + def _normalize_callback(callback) -> InstanceNewOldListener: + """Normalizes the callback so every callback can be invoked with the same signature.""" + signature = inspect.signature(callback) + + with suppress(TypeError): + signature.bind(1, 1, 1) + return lambda instance, new, old: callback(instance, new, old) + + with suppress(TypeError): + signature.bind(1, 1) + return lambda instance, new, old: callback(instance, new) + + with suppress(TypeError): + signature.bind(1) + return lambda instance, new, old: callback(instance) + + with suppress(TypeError): + signature.bind() + return lambda instance, new, old: callback() + + raise TypeError("Callback is not callable") class Property(Generic[P]): @@ -85,10 +133,11 @@ def set(self, instance, value): """Set value for owner instance""" obs = self._get_obs(instance) if obs.value != value: + old = obs.value obs.value = value - self.dispatch(instance, value) + self.dispatch(instance, value, old) - def dispatch(self, instance, value): + def dispatch(self, instance, value, old_value): """Notifies every listener, which subscribed to the change event. Args: @@ -99,13 +148,7 @@ def dispatch(self, instance, value): obs = self._get_obs(instance) for listener in obs.listeners: try: - try: - # FIXME if listener() raises an error, the invalid call will be - # also shown as an exception - listener(instance, value) # type: ignore - except TypeError: - # If the listener does not accept arguments, we call it without it - listener() # type: ignore + listener(instance, value, old_value) except Exception: print( f"Change listener for {instance}.{self.name} = {value} raised an exception!", @@ -126,7 +169,7 @@ def bind(self, instance, callback): # Instance methods are bound methods, which can not be referenced by normal `ref()` # if listeners would be a WeakSet, we would have to add listeners as WeakMethod # ourselves into `WeakSet.data`. - obs.listeners.add(callback) + obs.add(callback) def unbind(self, instance, callback): """Unbinds a function from the change event of the property. @@ -136,7 +179,7 @@ def unbind(self, instance, callback): callback: The callback to unbind. """ obs = self._get_obs(instance) - obs.listeners.remove(callback) + obs.remove(callback) def __set_name__(self, owner, name): self.name = name diff --git a/tests/unit/gui/test_property.py b/tests/unit/gui/test_property.py index 9ac5cefada..67d92c727b 100644 --- a/tests/unit/gui/test_property.py +++ b/tests/unit/gui/test_property.py @@ -70,7 +70,7 @@ def test_bind_callback_with_star_args(): # WHEN my_obj.name = "New Name" - assert observer.call_args == (my_obj, "New Name") + assert observer.call_args == (my_obj, "New Name", None) # Remove reference of call_args to my_obj, otherwise it will keep the object alive observer.call_args = None @@ -152,12 +152,12 @@ def test_gc_keeps_bound_methods(): bind(obj, "name", observer.call) - assert len(MyObject.name.obs[obj].listeners) == 1 + assert len(MyObject.name.obs[obj]._listeners) == 1 del observer gc.collect() - assert len(MyObject.name.obs[obj].listeners) == 1 + assert len(MyObject.name.obs[obj]._listeners) == 1 def test_gc_keeps_temp_methods(): @@ -170,8 +170,8 @@ def callback(*args, **kwargs): bind(obj, "name", callback) - assert len(MyObject.name.obs[obj].listeners) == 1 + assert len(MyObject.name.obs[obj]._listeners) == 1 del callback - assert len(MyObject.name.obs[obj].listeners) == 1 + assert len(MyObject.name.obs[obj]._listeners) == 1 From a1211eac1ebf55ae1dd66877809902dee91f1a74 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 11 May 2025 23:48:15 +0200 Subject: [PATCH 163/279] Update change log --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1759f78704..dd32999bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fixed an issue causing a crash when closing the window - Added `Window.close` (bool) attribute indicating if the window is closed +- GUI + - Property listener can now receive: + - no args + - instance + - instance, value + - instance, value, old value + > This is a breaking change. If you are using the listener with *args. ## Version 3.2 From dc237226b6fa30aff2eebda460bbbf5f32d86445 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Mon, 12 May 2025 00:08:49 +0200 Subject: [PATCH 164/279] Add tests for property callback --- tests/unit/gui/test_property.py | 81 +++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/tests/unit/gui/test_property.py b/tests/unit/gui/test_property.py index 67d92c727b..9f24401701 100644 --- a/tests/unit/gui/test_property.py +++ b/tests/unit/gui/test_property.py @@ -10,19 +10,35 @@ class MyObject: class Observer: call_args = None called = False + count = 0 def call(self): self.call_args = tuple() self.called = True + self.count += 1 - def call_with_args(self, instance, value): + def call_with_instance(self, instance): + """Match expected signature of 2 parameters""" + self.call_args = (instance,) + self.called = True + self.count += 1 + + def call_with_instance_value(self, instance, value): """Match expected signature of 2 parameters""" self.call_args = (instance, value) self.called = True + self.count += 1 - def __call__(self, *args): + def call_with_instance_value_old(self, instance, value, old): + """Match expected signature of 2 parameters""" + self.call_args = (instance, value, old) + self.called = True + self.count += 1 + + def call_with_args(self, *args): self.call_args = args self.called = True + self.count += 1 def test_bind_callback(): @@ -39,16 +55,44 @@ def test_bind_callback(): assert observer.call_args == tuple() -def test_bind_callback_with_args(): - """ - A bound callback can have 0 or 2 arguments. - 0 arguments are used for simple callbacks, like `log_change`. - 2 arguments are used for callbacks that need to know the instance and the new value. - """ +def test_bind_callback_only_once(): observer = Observer() my_obj = MyObject() - bind(my_obj, "name", observer.call_with_args) + bind(my_obj, "name", observer.call) + bind(my_obj, "name", observer.call) + + assert not observer.call_args + + # WHEN + my_obj.name = "New Name" + + assert observer.call_args == tuple() + assert observer.count == 1 + + +def test_bind_callback_accepts_instance(): + observer = Observer() + + my_obj = MyObject() + bind(my_obj, "name", observer.call_with_instance) + + assert not observer.call_args + + # WHEN + my_obj.name = "New Name" + + assert observer.call_args == (my_obj,) + + # Remove reference of call_args to my_obj, otherwise it will keep the object alive + observer.call_args = None + + +def test_bind_callback_accepts_instance_value(): + observer = Observer() + + my_obj = MyObject() + bind(my_obj, "name", observer.call_with_instance_value) assert not observer.call_args @@ -61,11 +105,28 @@ def test_bind_callback_with_args(): observer.call_args = None +def test_bind_callback_accepts_instance_value_old(): + observer = Observer() + + my_obj = MyObject() + bind(my_obj, "name", observer.call_with_instance_value_old) + + assert not observer.call_args + + # WHEN + my_obj.name = "New Name" + + assert observer.call_args == (my_obj, "New Name", None) + + # Remove reference of call_args to my_obj, otherwise it will keep the object alive + observer.call_args = None + + def test_bind_callback_with_star_args(): observer = Observer() my_obj = MyObject() - bind(my_obj, "name", observer) + bind(my_obj, "name", observer.call_with_args) # WHEN my_obj.name = "New Name" From d5cc38483dd1b208a7d8d77ae1f948707af00c76 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Mon, 12 May 2025 00:20:54 +0200 Subject: [PATCH 165/279] Make DictProperty and ListProperty also dispatch old state --- arcade/gui/property.py | 105 ++++++++++++++++++-------------- tests/unit/gui/test_property.py | 5 ++ 2 files changed, 63 insertions(+), 47 deletions(-) diff --git a/arcade/gui/property.py b/arcade/gui/property.py index c1fe26152b..0c19f3c80c 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -2,7 +2,7 @@ import sys import traceback from collections.abc import Callable -from contextlib import suppress +from contextlib import suppress, contextmanager from typing import Any, Generic, TypeVar, cast from weakref import WeakKeyDictionary, ref @@ -142,7 +142,8 @@ def dispatch(self, instance, value, old_value): Args: instance: Property instance - value: new value to set + value: new value set + old_value: previous value """ obs = self._get_obs(instance) @@ -275,45 +276,49 @@ def __init__(self, prop: Property, instance, *args): self.obj = ref(instance) super().__init__(*args) - def dispatch(self): - self.prop.dispatch(self.obj(), self) + @contextmanager + def _dispatch(self): + """This is a context manager which will dispatch the change event + when the context is exited. + """ + old_value = self.copy() + yield + self.prop.dispatch(self.obj(), self, old_value) @override def __setitem__(self, key, value): - dict.__setitem__(self, key, value) - self.dispatch() + with self._dispatch(): + dict.__setitem__(self, key, value) @override def __delitem__(self, key): - dict.__delitem__(self, key) - self.dispatch() + with self._dispatch(): + dict.__delitem__(self, key) @override def clear(self): - dict.clear(self) - self.dispatch() + with self._dispatch(): + dict.clear(self) @override def pop(self, *args): - result = dict.pop(self, *args) - self.dispatch() - return result + with self._dispatch(): + return dict.pop(self, *args) @override def popitem(self): - result = dict.popitem(self) - self.dispatch() - return result + with self._dispatch(): + return dict.popitem(self) @override def setdefault(self, *args): - dict.setdefault(self, *args) - self.dispatch() + with self._dispatch(): + return dict.setdefault(self, *args) @override def update(self, *args): - dict.update(self, *args) - self.dispatch() + with self._dispatch(): + dict.update(self, *args) K = TypeVar("K") @@ -352,80 +357,86 @@ def __init__(self, prop: Property, instance, *args): self.obj = ref(instance) super().__init__(*args) - def dispatch(self): - """Dispatches the change event.""" - self.prop.dispatch(self.obj(), self) + @contextmanager + def _dispatch(self): + """Dispatches the change event. + This is a context manager which will dispatch the change event + when the context is exited. + """ + old_value = self.copy() + yield + self.prop.dispatch(self.obj(), self, old_value) @override def __setitem__(self, key, value): - list.__setitem__(self, key, value) - self.dispatch() + with self._dispatch(): + list.__setitem__(self, key, value) @override def __delitem__(self, key): - list.__delitem__(self, key) - self.dispatch() + with self._dispatch(): + list.__delitem__(self, key) @override def __iadd__(self, *args): - list.__iadd__(self, *args) - self.dispatch() + with self._dispatch(): + list.__iadd__(self, *args) return self @override def __imul__(self, *args): - list.__imul__(self, *args) - self.dispatch() + with self._dispatch(): + list.__imul__(self, *args) return self @override def append(self, *args): """Proxy for list.append() which dispatches the change event.""" - list.append(self, *args) - self.dispatch() + with self._dispatch(): + list.append(self, *args) @override def clear(self): """Proxy for list.clear() which dispatches the change event.""" - list.clear(self) - self.dispatch() + with self._dispatch(): + list.clear(self) @override def remove(self, *args): """Proxy for list.remove() which dispatches the change event.""" - list.remove(self, *args) - self.dispatch() + with self._dispatch(): + list.remove(self, *args) @override def insert(self, *args): """Proxy for list.insert() which dispatches the change event.""" - list.insert(self, *args) - self.dispatch() + with self._dispatch(): + list.insert(self, *args) @override def pop(self, *args): """Proxy for list.pop() which dispatches the change""" - result = list.pop(self, *args) - self.dispatch() + with self._dispatch(): + result = list.pop(self, *args) return result @override def extend(self, *args): """Proxy for list.extend() which dispatches the change event.""" - list.extend(self, *args) - self.dispatch() + with self._dispatch(): + list.extend(self, *args) @override def sort(self, **kwargs): """Proxy for list.sort() which dispatches the change event.""" - list.sort(self, **kwargs) - self.dispatch() + with self._dispatch(): + list.sort(self, **kwargs) @override def reverse(self): """Proxy for list.reverse() which dispatches the change event.""" - list.reverse(self) - self.dispatch() + with self._dispatch(): + list.reverse(self) class ListProperty(Property[list[P]], Generic[P]): diff --git a/tests/unit/gui/test_property.py b/tests/unit/gui/test_property.py index 9f24401701..64a11047e7 100644 --- a/tests/unit/gui/test_property.py +++ b/tests/unit/gui/test_property.py @@ -40,6 +40,11 @@ def call_with_args(self, *args): self.called = True self.count += 1 + def __call__(self, *args, **kwargs): + self.call_args = args + self.called = True + self.count += 1 + def test_bind_callback(): observer = Observer() From 101352b6434c0a378132076c379c8cbe2180fd17 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 16 May 2025 16:00:35 +0100 Subject: [PATCH 166/279] Make property changes backwards compatible also for *args --- CHANGELOG.md | 2 +- arcade/gui/property.py | 8 ++++---- tests/unit/gui/test_property.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd32999bb2..8bf273c8c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - instance - instance, value - instance, value, old value - > This is a breaking change. If you are using the listener with *args. + > Listener accepting `*args` receive `instance, value` like in previous versions. ## Version 3.2 diff --git a/arcade/gui/property.py b/arcade/gui/property.py index 0c19f3c80c..b96710ac46 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -52,14 +52,14 @@ def _normalize_callback(callback) -> InstanceNewOldListener: """Normalizes the callback so every callback can be invoked with the same signature.""" signature = inspect.signature(callback) - with suppress(TypeError): - signature.bind(1, 1, 1) - return lambda instance, new, old: callback(instance, new, old) - with suppress(TypeError): signature.bind(1, 1) return lambda instance, new, old: callback(instance, new) + with suppress(TypeError): + signature.bind(1, 1, 1) + return lambda instance, new, old: callback(instance, new, old) + with suppress(TypeError): signature.bind(1) return lambda instance, new, old: callback(instance) diff --git a/tests/unit/gui/test_property.py b/tests/unit/gui/test_property.py index 64a11047e7..9c783e06a1 100644 --- a/tests/unit/gui/test_property.py +++ b/tests/unit/gui/test_property.py @@ -136,7 +136,7 @@ def test_bind_callback_with_star_args(): # WHEN my_obj.name = "New Name" - assert observer.call_args == (my_obj, "New Name", None) + assert observer.call_args == (my_obj, "New Name") # Remove reference of call_args to my_obj, otherwise it will keep the object alive observer.call_args = None From 60df022ec45466ace03dc4af99f5bc067ef85e18 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 16 May 2025 18:09:43 +0100 Subject: [PATCH 167/279] Fix some typing --- arcade/types/rect.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/arcade/types/rect.py b/arcade/types/rect.py index cac1acab1b..3e2db6d25a 100644 --- a/arcade/types/rect.py +++ b/arcade/types/rect.py @@ -717,16 +717,16 @@ def kwargs(self) -> RectKwargs: checking. See :py:class:`typing.TypedDict` to learn more. """ - return { - "left": self.left, - "right": self.right, - "bottom": self.bottom, - "top": self.top, - "x": self.x, - "y": self.y, - "width": self.width, - "height": self.height, - } + return RectKwargs( + left=self.left, + right=self.right, + bottom=self.bottom, + top=self.top, + x=self.x, + y=self.y, + width=self.width, + height=self.height, + ) # Since __repr__ is handled automatically by NamedTuple, we focus on # human-readable spot-check values for __str__ instead. From 16a1b7df840cc63fb1881d3136bb164a81f198bd Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Fri, 16 May 2025 21:19:30 -0400 Subject: [PATCH 168/279] Abstract the arcade.gl package in order to support different rendering backends (#2666) * ABC based arcade.gl backend abstraction example * Abstraction of programs and VAOs/geometry. Also some cleanup * Texture and Compute Shader abstraction * Framebuffer abstraction * Abstract samplers * Abstract texture arrays * Abstract queries * Finished context/info abstraction * Types abstraction and more context cleanup * replace pyglet.gl with enums * Work on tests * Kind of hacky fix for tessellation example * Fix stats counting for compute shaders * ruff format * Fix some linting problems with getter/setters * line length linting * Rename gl backend to opengl * Good-enough arcade.gl doc fixes * Explain the design per Clepto's words * Get some mac survival guide suggestions written (no compute shader -> frag + FBO magic) * Good-enough additions of module prefixes for classes * Delete stuff that's broken or just slowing Clepto down (Gotta go fast) * Typing fixes, backends package is excluded from typing for now --------- Co-authored-by: pushfoo <36696816+pushfoo@users.noreply.github.com> --- arcade/application.py | 22 +- arcade/context.py | 7 +- arcade/examples/gl/tessellation.py | 3 +- arcade/gl/backends/__init__.py | 0 arcade/gl/backends/opengl/__init__.py | 0 arcade/gl/backends/opengl/buffer.py | 287 +++++++ arcade/gl/backends/opengl/compute_shader.py | 272 +++++++ arcade/gl/backends/opengl/context.py | 537 +++++++++++++ arcade/gl/backends/opengl/framebuffer.py | 419 ++++++++++ arcade/gl/{ => backends/opengl}/glsl.py | 6 +- arcade/gl/backends/opengl/program.py | 548 +++++++++++++ arcade/gl/backends/opengl/provider.py | 14 + arcade/gl/backends/opengl/query.py | 127 +++ arcade/gl/backends/opengl/sampler.py | 127 +++ arcade/gl/backends/opengl/texture.py | 752 ++++++++++++++++++ arcade/gl/backends/opengl/texture_array.py | 696 ++++++++++++++++ arcade/gl/{ => backends/opengl}/uniform.py | 2 +- arcade/gl/{ => backends/opengl}/utils.py | 0 arcade/gl/backends/opengl/vertex_array.py | 492 ++++++++++++ arcade/gl/buffer.py | 166 +--- arcade/gl/compute_shader.py | 221 +---- arcade/gl/context.py | 638 +++++---------- arcade/gl/enums.py | 186 ++++- arcade/gl/framebuffer.py | 318 +------- arcade/gl/program.py | 417 +--------- arcade/gl/provider.py | 59 ++ arcade/gl/query.py | 83 +- arcade/gl/sampler.py | 102 +-- arcade/gl/texture.py | 438 ++-------- arcade/gl/texture_array.py | 432 +--------- arcade/gl/types.py | 256 +++--- arcade/gl/vertex_array.py | 340 +------- arcade/management/__init__.py | 6 +- arcade/utils.py | 5 + doc/api_docs/gl/buffer.rst | 6 +- doc/api_docs/gl/context.rst | 16 +- doc/api_docs/gl/framebuffer.rst | 4 +- doc/api_docs/gl/geometry.rst | 4 +- doc/api_docs/gl/index.rst | 201 ++++- doc/api_docs/gl/program.rst | 28 +- doc/api_docs/gl/query.rst | 4 +- doc/api_docs/gl/sampler.rst | 4 +- doc/api_docs/gl/texture.rst | 4 +- doc/api_docs/gl/texture_array.rst | 4 +- doc/api_docs/gl/types.rst | 8 +- doc/api_docs/gl/utils.rst | 11 +- pyproject.toml | 8 + tests/conftest.py | 23 +- tests/unit/gl/__init__.py | 0 tests/unit/gl/backends/__init__.py | 0 tests/unit/gl/backends/gl/__init__.py | 0 .../gl/{ => backends/gl}/test_gl_program.py | 9 +- tests/unit/gl/test_gl_context.py | 6 +- tests/unit/gl/test_gl_query.py | 2 +- tests/unit/gl/test_gl_texture.py | 4 +- 55 files changed, 5355 insertions(+), 2969 deletions(-) create mode 100644 arcade/gl/backends/__init__.py create mode 100644 arcade/gl/backends/opengl/__init__.py create mode 100644 arcade/gl/backends/opengl/buffer.py create mode 100644 arcade/gl/backends/opengl/compute_shader.py create mode 100644 arcade/gl/backends/opengl/context.py create mode 100644 arcade/gl/backends/opengl/framebuffer.py rename arcade/gl/{ => backends/opengl}/glsl.py (97%) create mode 100644 arcade/gl/backends/opengl/program.py create mode 100644 arcade/gl/backends/opengl/provider.py create mode 100644 arcade/gl/backends/opengl/query.py create mode 100644 arcade/gl/backends/opengl/sampler.py create mode 100644 arcade/gl/backends/opengl/texture.py create mode 100644 arcade/gl/backends/opengl/texture_array.py rename arcade/gl/{ => backends/opengl}/uniform.py (99%) rename arcade/gl/{ => backends/opengl}/utils.py (100%) create mode 100644 arcade/gl/backends/opengl/vertex_array.py create mode 100644 arcade/gl/provider.py create mode 100644 tests/unit/gl/__init__.py create mode 100644 tests/unit/gl/backends/__init__.py create mode 100644 tests/unit/gl/backends/gl/__init__.py rename tests/unit/gl/{ => backends/gl}/test_gl_program.py (97%) diff --git a/arcade/application.py b/arcade/application.py index 4e77e047ea..759e7a7c3b 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -22,8 +22,9 @@ from arcade.clock import GLOBAL_CLOCK, GLOBAL_FIXED_CLOCK, _setup_clock, _setup_fixed_clock from arcade.color import BLACK from arcade.context import ArcadeContext +from arcade.gl.provider import get_arcade_context, set_provider from arcade.types import LBWH, Color, Rect, RGBANormalized, RGBOrA255 -from arcade.utils import is_raspberry_pi +from arcade.utils import is_pyodide, is_raspberry_pi from arcade.window_commands import get_display_size, set_window if TYPE_CHECKING: @@ -157,7 +158,7 @@ def __init__( center_window: bool = False, samples: int = 4, enable_polling: bool = True, - gl_api: str = "gl", + gl_api: str = "opengl", draw_rate: float = 1 / 60, fixed_rate: float = 1.0 / 60.0, fixed_frame_cap: int | None = None, @@ -167,10 +168,17 @@ def __init__( if os.environ.get("REPL_ID"): antialiasing = False + desired_gl_provider = "opengl" + if is_pyodide(): + gl_api = "webgl" + + if gl_api == "webgl": + desired_gl_provider = "webgl" + # Detect Raspberry Pi and switch to OpenGL ES 3.1 if is_raspberry_pi(): gl_version = 3, 1 - gl_api = "gles" + gl_api = "opengles" self.closed = False """Indicates if the window was closed""" @@ -184,7 +192,7 @@ def __init__( config = gl.Config( major_version=gl_version[0], minor_version=gl_version[1], - opengl_api=gl_api, # type: ignore # pending: upstream fix + opengl_api=gl_api.replace("open", ""), # type: ignore # pending: upstream fix double_buffer=True, sample_buffers=1, samples=samples, @@ -208,7 +216,7 @@ def __init__( config = gl.Config( major_version=gl_version[0], minor_version=gl_version[1], - opengl_api=gl_api, # type: ignore # pending: upstream fix + opengl_api=gl_api.replace("open", ""), # type: ignore # pending: upstream fix double_buffer=True, depth_size=24, stencil_size=8, @@ -277,7 +285,9 @@ def __init__( self.push_handlers(on_resize=self._on_resize) - self._ctx: ArcadeContext = ArcadeContext(self, gc_mode=gc_mode, gl_api=gl_api) + set_provider(desired_gl_provider) + self._ctx: ArcadeContext = get_arcade_context(self, gc_mode=gc_mode, gl_api=gl_api) + # self._ctx: ArcadeContext = ArcadeContext(self, gc_mode=gc_mode, gl_api=gl_api) self._background_color: Color = BLACK self._current_view: View | None = None diff --git a/arcade/context.py b/arcade/context.py index 42ff53496d..b9ae6b372f 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -21,7 +21,6 @@ from arcade.gl.framebuffer import Framebuffer from arcade.gl.program import Program from arcade.gl.texture import Texture2D -from arcade.gl.types import PyGLenum from arcade.gl.vertex_array import Geometry from arcade.texture_atlas import DefaultTextureAtlas, TextureAtlasBase @@ -451,9 +450,9 @@ def load_texture( path: str | Path, *, flip: bool = True, - wrap_x: PyGLenum | None = None, - wrap_y: PyGLenum | None = None, - filter: tuple[PyGLenum, PyGLenum] | None = None, + wrap_x=None, + wrap_y=None, + filter=None, build_mipmaps: bool = False, internal_format: int | None = None, immutable: bool = False, diff --git a/arcade/examples/gl/tessellation.py b/arcade/examples/gl/tessellation.py index c506cd91a1..8a3efb3ee2 100644 --- a/arcade/examples/gl/tessellation.py +++ b/arcade/examples/gl/tessellation.py @@ -10,6 +10,7 @@ import arcade from arcade.gl import BufferDescription +import pyglet.gl WINDOW_WIDTH = 1280 WINDOW_HEIGHT = 720 @@ -106,7 +107,7 @@ def __init__(self, width, height, title): def on_draw(self): self.clear() self.program["time"] = self.time - self.geometry.render(self.program, mode=self.ctx.PATCHES) + self.geometry.render(self.program, mode=pyglet.gl.GL_PATCHES) if __name__ == "__main__": diff --git a/arcade/gl/backends/__init__.py b/arcade/gl/backends/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/gl/backends/opengl/__init__.py b/arcade/gl/backends/opengl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/gl/backends/opengl/buffer.py b/arcade/gl/backends/opengl/buffer.py new file mode 100644 index 0000000000..0fc7d40ef2 --- /dev/null +++ b/arcade/gl/backends/opengl/buffer.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import weakref +from ctypes import byref, string_at +from typing import TYPE_CHECKING + +from pyglet import gl + +from arcade.gl.buffer import Buffer +from arcade.types import BufferProtocol + +from .utils import data_to_ctypes + +if TYPE_CHECKING: + from arcade.gl import Context + +_usages = { + "static": gl.GL_STATIC_DRAW, + "dynamic": gl.GL_DYNAMIC_DRAW, + "stream": gl.GL_STREAM_DRAW, +} + + +class OpenGLBuffer(Buffer): + """OpenGL buffer object. Buffers store byte data and upload it + to graphics memory so shader programs can process the data. + They are used for storage of vertex data, + element data (vertex indexing), uniform block data etc. + + The ``data`` parameter can be anything that implements the + `Buffer Protocol `_. + + This includes ``bytes``, ``bytearray``, ``array.array``, and + more. You may need to use typing workarounds for non-builtin + types. See :ref:`prog-guide-gl-buffer-protocol-typing` for more + information. + + .. warning:: Buffer objects should be created using :py:meth:`arcade.gl.Context.buffer` + + Args: + ctx: + The context this buffer belongs to + data: + The data this buffer should contain. It can be a ``bytes`` instance or any + object supporting the buffer protocol. + reserve: + Create a buffer of a specific byte size + usage: + A hit of this buffer is ``static`` or ``dynamic`` (can mostly be ignored) + """ + + __slots__ = "_glo", "_usage" + + def __init__( + self, + ctx: Context, + data: BufferProtocol | None = None, + reserve: int = 0, + usage: str = "static", + ): + super().__init__(ctx) + self._usage = _usages[usage] + self._glo = glo = gl.GLuint() + gl.glGenBuffers(1, byref(self._glo)) + # print(f"glGenBuffers() -> {self._glo.value}") + if self._glo.value == 0: + raise RuntimeError("Cannot create Buffer object.") + + # print(f"glBindBuffer({self._glo.value})") + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._glo) + # print(f"glBufferData(gl.GL_ARRAY_BUFFER, {self._size}, data, {self._usage})") + + if data is not None and len(data) > 0: # type: ignore + self._size, data = data_to_ctypes(data) + gl.glBufferData(gl.GL_ARRAY_BUFFER, self._size, data, self._usage) + elif reserve > 0: + self._size = reserve + # populate the buffer with zero byte values + data = (gl.GLubyte * self._size)() + gl.glBufferData(gl.GL_ARRAY_BUFFER, self._size, data, self._usage) + else: + raise ValueError("Buffer takes byte data or number of reserved bytes") + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, OpenGLBuffer.delete_glo, self.ctx, glo) + + def __repr__(self): + return f"" + + def __del__(self): + # Intercept garbage collection if we are using Context.gc() + if self._ctx.gc_mode == "context_gc" and self._glo.value > 0: + self._ctx.objects.append(self) + + @property + def glo(self) -> gl.GLuint: + """The OpenGL resource id.""" + return self._glo + + def delete(self) -> None: + """ + Destroy the underlying OpenGL resource. + + .. warning:: Don't use this unless you know exactly what you are doing. + """ + OpenGLBuffer.delete_glo(self._ctx, self._glo) + self._glo.value = 0 + + @staticmethod + def delete_glo(ctx: Context, glo: gl.GLuint): + """ + Release/delete open gl buffer. + + This is automatically called when the object is garbage collected. + + Args: + ctx: + The context the buffer belongs to + glo: + The OpenGL buffer id + """ + # If we have no context, then we are shutting down, so skip this + if gl.current_context is None: + return + + if glo.value != 0: + gl.glDeleteBuffers(1, byref(glo)) + glo.value = 0 + + ctx.stats.decr("buffer") + + def read(self, size: int = -1, offset: int = 0) -> bytes: + """Read data from the buffer. + + Args: + size: + The bytes to read. -1 means the entire buffer (default) + offset: + Byte read offset + """ + if size == -1: + size = self._size - offset + + # Catch this before confusing INVALID_OPERATION is raised + if size < 1: + raise ValueError( + "Attempting to read 0 or less bytes from buffer: " + f"buffer size={self._size} | params: size={size}, offset={offset}" + ) + + # Manually detect this so it doesn't raise a confusing INVALID_VALUE error + if size + offset > self._size: + raise ValueError( + ( + "Attempting to read outside the buffer. " + f"Buffer size: {self._size} " + f"Reading from {offset} to {size + offset}" + ) + ) + + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._glo) + ptr = gl.glMapBufferRange(gl.GL_ARRAY_BUFFER, offset, size, gl.GL_MAP_READ_BIT) + data = string_at(ptr, size=size) + gl.glUnmapBuffer(gl.GL_ARRAY_BUFFER) + return data + + def write(self, data: BufferProtocol, offset: int = 0): + """Write byte data to the buffer from a buffer protocol object. + + The ``data`` value can be anything that implements the + `Buffer Protocol `_. + + This includes ``bytes``, ``bytearray``, ``array.array``, and + more. You may need to use typing workarounds for non-builtin + types. See :ref:`prog-guide-gl-buffer-protocol-typing` for more + information. + + If the supplied data is larger than the buffer, it will be + truncated to fit. If the supplied data is smaller than the + buffer, the remaining bytes will be left unchanged. + + Args: + data: + The byte data to write. This can be bytes or any object + supporting the buffer protocol. + offset: + The byte offset + """ + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._glo) + size, data = data_to_ctypes(data) + # Ensure we don't write outside the buffer + size = min(size, self._size - offset) + if size < 0: + raise ValueError("Attempting to write negative number bytes to buffer") + gl.glBufferSubData(gl.GL_ARRAY_BUFFER, gl.GLintptr(offset), size, data) + + def copy_from_buffer(self, source: Buffer, size=-1, offset=0, source_offset=0): + """Copy data into this buffer from another buffer. + + Args: + source: + The buffer to copy from + size: + The amount of bytes to copy + offset: + The byte offset to write the data in this buffer + source_offset: + The byte offset to read from the source buffer + """ + # Read the entire source buffer into this buffer + if size == -1: + size = source.size + + # TODO: Check buffer bounds + if size + source_offset > source.size: + raise ValueError("Attempting to read outside the source buffer") + + if size + offset > self._size: + raise ValueError("Attempting to write outside the buffer") + + gl.glBindBuffer(gl.GL_COPY_READ_BUFFER, source.glo) + gl.glBindBuffer(gl.GL_COPY_WRITE_BUFFER, self._glo) + gl.glCopyBufferSubData( + gl.GL_COPY_READ_BUFFER, + gl.GL_COPY_WRITE_BUFFER, + gl.GLintptr(source_offset), # readOffset + gl.GLintptr(offset), # writeOffset + size, # size (number of bytes to copy) + ) + + def orphan(self, size: int = -1, double: bool = False): + """ + Re-allocate the entire buffer memory. This can be used to resize + a buffer or for re-specification (orphan the buffer to avoid blocking). + + If the current buffer is busy in rendering operations + it will be deallocated by OpenGL when completed. + + Args: + size: + New size of buffer. -1 will retain the current size. + Takes precedence over ``double`` parameter if specified. + double: + Is passed in with `True` the buffer size will be doubled + from its current size. + """ + if size > 0: + self._size = size + elif double is True: + self._size *= 2 + + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._glo) + gl.glBufferData(gl.GL_ARRAY_BUFFER, self._size, None, self._usage) + + def bind_to_uniform_block(self, binding: int = 0, offset: int = 0, size: int = -1): + """Bind this buffer to a uniform block location. + In most cases it will be sufficient to only provide a binding location. + + Args: + binding: + The binding location + offset: + Byte offset + size: + Size of the buffer to bind. + """ + if size < 0: + size = self.size + + gl.glBindBufferRange(gl.GL_UNIFORM_BUFFER, binding, self._glo, offset, size) + + def bind_to_storage_buffer(self, *, binding=0, offset=0, size=-1): + """ + Bind this buffer as a shader storage buffer. + + Args: + binding: + The binding location + offset: + Byte offset in the buffer + size: + The size in bytes. The entire buffer will be mapped by default. + """ + if size < 0: + size = self.size + + gl.glBindBufferRange(gl.GL_SHADER_STORAGE_BUFFER, binding, self._glo, offset, size) diff --git a/arcade/gl/backends/opengl/compute_shader.py b/arcade/gl/backends/opengl/compute_shader.py new file mode 100644 index 0000000000..67a1e8543c --- /dev/null +++ b/arcade/gl/backends/opengl/compute_shader.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import weakref +from ctypes import ( + POINTER, + byref, + c_buffer, + c_char, + c_char_p, + c_int, + cast, + create_string_buffer, + pointer, +) +from typing import TYPE_CHECKING + +from pyglet import gl + +from arcade.gl.compute_shader import ComputeShader + +from .uniform import Uniform, UniformBlock + +if TYPE_CHECKING: + from arcade.gl import Context + + +class OpenGLComputeShader(ComputeShader): + """ + A higher level wrapper for an OpenGL compute shader. + + Args: + ctx: + The context this shader belongs to. + glsl_source: + The GLSL source code for the compute shader. + """ + + def __init__(self, ctx: Context, glsl_source: str) -> None: + super().__init__(ctx, glsl_source) + self._uniforms: dict[str, UniformBlock | Uniform] = dict() + + from arcade.gl import ShaderException + + # Create the program + self._glo = glo = gl.glCreateProgram() + if not self._glo: + raise ShaderException("Failed to create program object") + + self._shader_obj = gl.glCreateShader(gl.GL_COMPUTE_SHADER) + if not self._shader_obj: + raise ShaderException("Failed to create compute shader object") + + # Set source + source_bytes = self._source.encode("utf-8") + strings = byref(cast(c_char_p(source_bytes), POINTER(c_char))) + lengths = pointer(c_int(len(source_bytes))) + gl.glShaderSource(self._shader_obj, 1, strings, lengths) + + # Compile and check result + gl.glCompileShader(self._shader_obj) + result = c_int() + gl.glGetShaderiv(self._shader_obj, gl.GL_COMPILE_STATUS, byref(result)) + if result.value == gl.GL_FALSE: + msg = create_string_buffer(512) + length = c_int() + gl.glGetShaderInfoLog(self._shader_obj, 512, byref(length), msg) + raise ShaderException( + ( + f"Error compiling compute shader " + f"({result.value}): {msg.value.decode('utf-8')}\n" + f"---- [compute shader] ---\n" + ) + + "\n".join( + f"{str(i + 1).zfill(3)}: {line} " + for i, line in enumerate(self._source.split("\n")) + ) + ) + + # Attach and link shader + gl.glAttachShader(self._glo, self._shader_obj) + gl.glLinkProgram(self._glo) + gl.glDeleteShader(self._shader_obj) + status = c_int() + gl.glGetProgramiv(self._glo, gl.GL_LINK_STATUS, status) + if not status.value: + length = c_int() + gl.glGetProgramiv(self._glo, gl.GL_INFO_LOG_LENGTH, length) + log = c_buffer(length.value) + gl.glGetProgramInfoLog(self._glo, len(log), None, log) + raise ShaderException("Program link error: {}".format(log.value.decode())) + + self._introspect_uniforms() + self._introspect_uniform_blocks() + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, OpenGLComputeShader.delete_glo, self._ctx, glo) + + @property + def glo(self) -> int: + """The name/id of the OpenGL resource""" + return self._glo + + def _use(self) -> None: + """ + Use/activate the compute shader. + + .. Note:: + + This is not necessary to call in normal use cases + since ``run()`` already does this for you. + """ + gl.glUseProgram(self._glo) + self._ctx.active_program = self + + def run(self, group_x=1, group_y=1, group_z=1) -> None: + """ + Run the compute shader. + + When running a compute shader we specify how many work groups should + be executed on the ``x``, ``y`` and ``z`` dimension. The size of the work group + is defined in the compute shader. + + .. code:: glsl + + // Work group with one dimension. 16 work groups executed. + layout(local_size_x=16) in; + // Work group with two dimensions. 256 work groups executed. + layout(local_size_x=16, local_size_y=16) in; + // Work group with three dimensions. 4096 work groups executed. + layout(local_size_x=16, local_size_y=16, local_size_z=16) in; + + Group sizes are ``1`` by default. If your compute shader doesn't specify + a size for a dimension or uses ``1`` as size you don't have to supply + this parameter. + + Args: + group_x: The number of work groups to be launched in the X dimension. + group_y: The number of work groups to be launched in the y dimension. + group_z: The number of work groups to be launched in the z dimension. + """ + self._use() + gl.glDispatchCompute(group_x, group_y, group_z) + + def __getitem__(self, item) -> Uniform | UniformBlock: + """Get a uniform or uniform block""" + try: + uniform = self._uniforms[item] + except KeyError: + raise KeyError(f"Uniform with the name `{item}` was not found.") + + return uniform.getter() + + def __setitem__(self, key, value): + """Set a uniform value""" + # Ensure we are setting the uniform on this program + # if self._ctx.active_program != self: + # self.use() + + try: + uniform = self._uniforms[key] + except KeyError: + raise KeyError(f"Uniform with the name `{key}` was not found.") + + uniform.setter(value) + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and self._glo > 0: + self._ctx.objects.append(self) + + def delete(self) -> None: + """ + Destroy the internal compute shader object. + + This is normally not necessary, but depends on the + garbage collection configured in the context. + """ + OpenGLComputeShader.delete_glo(self._ctx, self._glo) + self._glo = 0 + + @staticmethod + def delete_glo(ctx, prog_id): + """ + Low level method for destroying a compute shader by id. + + Args: + ctx: The context this program belongs to. + prog_id: The OpenGL id of the program. + """ + # Check to see if the context was already cleaned up from program + # shut down. If so, we don't need to delete the shaders. + if gl.current_context is None: + return + + gl.glDeleteProgram(prog_id) + # TODO: Count compute shaders + ctx.stats.decr("compute_shader") + + def _introspect_uniforms(self): + """Figure out what uniforms are available and build an internal map""" + # Number of active uniforms in the program + active_uniforms = gl.GLint(0) + gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_UNIFORMS, byref(active_uniforms)) + + # Loop all the active uniforms + for index in range(active_uniforms.value): + # Query uniform information like name, type, size etc. + u_name, u_type, u_size = self._query_uniform(index) + u_location = gl.glGetUniformLocation(self._glo, u_name.encode()) + + # Skip uniforms that may be in Uniform Blocks + # TODO: We should handle all uniforms + if u_location == -1: + # print(f"Uniform {u_location} {u_name} {u_size} {u_type} skipped") + continue + + u_name = u_name.replace("[0]", "") # Remove array suffix + self._uniforms[u_name] = Uniform( + self._ctx, self._glo, u_location, u_name, u_type, u_size + ) + + def _introspect_uniform_blocks(self): + """Finds uniform blocks and maps the to python objectss""" + active_uniform_blocks = gl.GLint(0) + gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_UNIFORM_BLOCKS, byref(active_uniform_blocks)) + # print('GL_ACTIVE_UNIFORM_BLOCKS', active_uniform_blocks) + + for loc in range(active_uniform_blocks.value): + index, size, name = self._query_uniform_block(loc) + block = UniformBlock(self._glo, index, size, name) + self._uniforms[name] = block + + def _query_uniform(self, location: int) -> tuple[str, int, int]: + """Retrieve Uniform information at given location. + + Returns the name, the type as a GLenum (GL_FLOAT, ...) and the size. Size is + greater than 1 only for Uniform arrays, like an array of floats or an array + of Matrices. + """ + u_size = gl.GLint() + u_type = gl.GLenum() + buf_size = 192 # max uniform character length + u_name = create_string_buffer(buf_size) + gl.glGetActiveUniform( + self._glo, # program to query + location, # location to query + buf_size, # size of the character/name buffer + None, # the number of characters actually written by OpenGL in the string + u_size, # size of the uniform variable + u_type, # data type of the uniform variable + u_name, # string buffer for storing the name + ) + return u_name.value.decode(), u_type.value, u_size.value + + def _query_uniform_block(self, location: int) -> tuple[int, int, str]: + """Query active uniform block by retrieving the name and index and size""" + # Query name + u_size = gl.GLint() + buf_size = 192 # max uniform character length + u_name = create_string_buffer(buf_size) + gl.glGetActiveUniformBlockName( + self._glo, # program to query + location, # location to query + 256, # max size if the name + u_size, # length + u_name, + ) + # Query index + index = gl.glGetUniformBlockIndex(self._glo, u_name) + # Query size + b_size = gl.GLint() + gl.glGetActiveUniformBlockiv(self._glo, index, gl.GL_UNIFORM_BLOCK_DATA_SIZE, b_size) + return index, b_size.value, u_name.value.decode() diff --git a/arcade/gl/backends/opengl/context.py b/arcade/gl/backends/opengl/context.py new file mode 100644 index 0000000000..2a7753d253 --- /dev/null +++ b/arcade/gl/backends/opengl/context.py @@ -0,0 +1,537 @@ +from ctypes import c_char_p, c_float, c_int, cast +from typing import Dict, Iterable, List, Sequence, Tuple + +import pyglet +from pyglet import gl + +from arcade.context import ArcadeContext +from arcade.gl import enums +from arcade.gl.context import Context, Info +from arcade.gl.types import BufferDescription, PyGLenum +from arcade.types import BufferProtocol + +from .buffer import OpenGLBuffer +from .compute_shader import OpenGLComputeShader +from .framebuffer import OpenGLDefaultFrameBuffer, OpenGLFramebuffer +from .glsl import ShaderSource +from .program import OpenGLProgram +from .query import OpenGLQuery +from .sampler import OpenGLSampler +from .texture import OpenGLTexture2D +from .texture_array import OpenGLTextureArray +from .vertex_array import OpenGLGeometry + + +class OpenGLContext(Context): + #: The OpenGL api. Usually "opengl" or "opengles". + gl_api: str = "opengl" + + _valid_apis = ("opengl", "opengles") + + def __init__( + self, window: pyglet.window.Window, gc_mode: str = "context_gc", gl_api: str = "opengl" + ): + super().__init__(window, gc_mode) + + if gl_api not in self._valid_apis: + if gl_api == "webgl": + raise ValueError( + "Tried to create a OpenGLContext with WebGL api selected. " + + f"Valid options for this backend are: {self._valid_apis}" + ) + raise ValueError(f"Invalid gl_api. Options are: {self._valid_apis}") + self.gl_api = gl_api + + self._gl_version = (self._info.MAJOR_VERSION, self._info.MINOR_VERSION) + + # Hardcoded states + # This should always be enabled + # gl.glEnable(gl.GL_TEXTURE_CUBE_MAP_SEAMLESS) + # Set primitive restart index to -1 by default + if self.gl_api == "opengles": + gl.glEnable(gl.GL_PRIMITIVE_RESTART_FIXED_INDEX) + else: + gl.glEnable(gl.GL_PRIMITIVE_RESTART) + + # Detect support for glProgramUniform. + # Assumed to be supported in gles + self._ext_separate_shader_objects_enabled = True + if self.gl_api == "opengl": + have_ext = gl.gl_info.have_extension("GL_ARB_separate_shader_objects") + self._ext_separate_shader_objects_enabled = self.gl_version >= (4, 1) or have_ext + + # We enable scissor testing by default. + # This is always set to the same value as the viewport + # to avoid background color affecting areas outside the viewport + gl.glEnable(gl.GL_SCISSOR_TEST) + + @property + def gl_version(self) -> Tuple[int, int]: + """ + The OpenGL major and minor version as a tuple. + + This is the reported OpenGL version from + drivers and might be a higher version than + you requested. + """ + return self._gl_version + + @Context.extensions.getter + def extensions(self) -> set[str]: + return gl.gl_info.get_extensions() + + @property + def error(self) -> str | None: + """Check OpenGL error + + Returns a string representation of the occurring error + or ``None`` of no errors has occurred. + + Example:: + + err = ctx.error + if err: + raise RuntimeError("OpenGL error: {err}") + """ + err = gl.glGetError() + if err == enums.NO_ERROR: + return None + + return self._errors.get(err, "UNKNOWN_ERROR") + + def enable(self, *flags: int): + self._flags.update(flags) + + for flag in flags: + gl.glEnable(flag) + + def enable_only(self, *args: int): + self._flags = set(args) + + if self.BLEND in self._flags: + gl.glEnable(self.BLEND) + else: + gl.glDisable(self.BLEND) + + if self.DEPTH_TEST in self._flags: + gl.glEnable(self.DEPTH_TEST) + else: + gl.glDisable(self.DEPTH_TEST) + + if self.CULL_FACE in self._flags: + gl.glEnable(self.CULL_FACE) + else: + gl.glDisable(self.CULL_FACE) + + if self.gl_api == "opengl": + if gl.GL_PROGRAM_POINT_SIZE in self._flags: + gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) + else: + gl.glDisable(gl.GL_PROGRAM_POINT_SIZE) + + def disable(self, *args): + self._flags -= set(args) + + for flag in args: + gl.glDisable(flag) + + @Context.blend_func.setter + def blend_func(self, value: Tuple[int, int] | Tuple[int, int, int, int]): + self._blend_func = value + if len(value) == 2: + gl.glBlendFunc(*value) + elif len(value) == 4: + gl.glBlendFuncSeparate(*value) + else: + ValueError("blend_func takes a tuple of 2 or 4 values") + + @property + def front_face(self) -> str: + value = c_int() + gl.glGetIntegerv(gl.GL_FRONT_FACE, value) + return "cw" if value.value == gl.GL_CW else "ccw" + + @front_face.setter + def front_face(self, value: str): + if value not in ["cw", "ccw"]: + raise ValueError("front_face must be 'cw' or 'ccw'") + gl.glFrontFace(gl.GL_CW if value == "cw" else gl.GL_CCW) + + @property + def cull_face(self) -> str: + value = c_int() + gl.glGetIntegerv(gl.GL_CULL_FACE_MODE, value) + return self._cull_face_options_reverse[value.value] + + @cull_face.setter + def cull_face(self, value): + if value not in self._cull_face_options: + raise ValueError("cull_face must be", list(self._cull_face_options.keys())) + + gl.glCullFace(self._cull_face_options[value]) + + @Context.wireframe.setter + def wireframe(self, value: bool): + self._wireframe = value + if value: + gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) + else: + gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) + + @property + def patch_vertices(self) -> int: + value = c_int() + gl.glGetIntegerv(gl.GL_PATCH_VERTICES, value) + return value.value + + @patch_vertices.setter + def patch_vertices(self, value: int): + if not isinstance(value, int): + raise TypeError("patch_vertices must be an integer") + + gl.glPatchParameteri(gl.GL_PATCH_VERTICES, value) + + @Context.point_size.setter + def point_size(self, value: float): + if self.gl_api == "opengl": + gl.glPointSize(self._point_size) + self._point_size = value + + @Context.primitive_restart_index.setter + def primitive_restart_index(self, value: int): + self._primitive_restart_index = value + if self.gl_api == "opengl": + gl.glPrimitiveRestartIndex(value) + + def finish(self) -> None: + gl.glFinish() + + def flush(self) -> None: + gl.glFlush() + + def _create_default_framebuffer(self) -> OpenGLDefaultFrameBuffer: + return OpenGLDefaultFrameBuffer(self) + + def buffer( + self, *, data: BufferProtocol | None = None, reserve: int = 0, usage: str = "static" + ) -> OpenGLBuffer: + return OpenGLBuffer(self, data, reserve=reserve, usage=usage) + + def program( + self, + *, + vertex_shader: str, + fragment_shader: str | None = None, + geometry_shader: str | None = None, + tess_control_shader: str | None = None, + tess_evaluation_shader: str | None = None, + common: List[str] | None = None, + defines: Dict[str, str] | None = None, + varyings: Sequence[str] | None = None, + varyings_capture_mode: str = "interleaved", + ) -> OpenGLProgram: + source_vs = ShaderSource(self, vertex_shader, common, gl.GL_VERTEX_SHADER) + source_fs = ( + ShaderSource(self, fragment_shader, common, gl.GL_FRAGMENT_SHADER) + if fragment_shader + else None + ) + source_geo = ( + ShaderSource(self, geometry_shader, common, gl.GL_GEOMETRY_SHADER) + if geometry_shader + else None + ) + source_tc = ( + ShaderSource(self, tess_control_shader, common, gl.GL_TESS_CONTROL_SHADER) + if tess_control_shader + else None + ) + source_te = ( + ShaderSource(self, tess_evaluation_shader, common, gl.GL_TESS_EVALUATION_SHADER) + if tess_evaluation_shader + else None + ) + + # If we don't have a fragment shader we are doing transform feedback. + # When a geometry shader is present the out attributes will be located there + out_attributes = list(varyings) if varyings is not None else [] # type: List[str] + if not source_fs and not out_attributes: + if source_geo: + out_attributes = source_geo.out_attributes + else: + out_attributes = source_vs.out_attributes + + return OpenGLProgram( + self, + vertex_shader=source_vs.get_source(defines=defines), + fragment_shader=source_fs.get_source(defines=defines) if source_fs else None, + geometry_shader=source_geo.get_source(defines=defines) if source_geo else None, + tess_control_shader=source_tc.get_source(defines=defines) if source_tc else None, + tess_evaluation_shader=source_te.get_source(defines=defines) if source_te else None, + varyings=out_attributes, + varyings_capture_mode=varyings_capture_mode, + ) + + def geometry( + self, + content: Sequence[BufferDescription] | None = None, + index_buffer: OpenGLBuffer | None = None, + mode: int | None = None, + index_element_size: int = 4, + ): + return OpenGLGeometry( + self, + content, + index_buffer=index_buffer, + mode=mode, + index_element_size=index_element_size, + ) + + def compute_shader(self, *, source: str, common: Iterable[str] = ()) -> OpenGLComputeShader: + src = ShaderSource(self, source, common, pyglet.gl.GL_COMPUTE_SHADER) + return OpenGLComputeShader(self, src.get_source()) + + def texture( + self, + size: Tuple[int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + wrap_x: PyGLenum | None = None, + wrap_y: PyGLenum | None = None, + filter: Tuple[PyGLenum, PyGLenum] | None = None, + samples: int = 0, + immutable: bool = False, + internal_format: PyGLenum | None = None, + compressed: bool = False, + compressed_data: bool = False, + ) -> OpenGLTexture2D: + compressed = compressed or compressed_data + + return OpenGLTexture2D( + self, + size, + components=components, + data=data, + dtype=dtype, + wrap_x=wrap_x, + wrap_y=wrap_y, + filter=filter, + samples=samples, + immutable=immutable, + internal_format=internal_format, + compressed=compressed, + compressed_data=compressed_data, + ) + + def depth_texture( + self, size: Tuple[int, int], *, data: BufferProtocol | None = None + ) -> OpenGLTexture2D: + return OpenGLTexture2D(self, size, data=data, depth=True) + + def framebuffer( + self, + *, + color_attachments: OpenGLTexture2D | List[OpenGLTexture2D] | None = None, + depth_attachment: OpenGLTexture2D | None = None, + ) -> OpenGLFramebuffer: + return OpenGLFramebuffer( + self, color_attachments=color_attachments or [], depth_attachment=depth_attachment + ) + + def copy_framebuffer( + self, + src: OpenGLFramebuffer, + dst: OpenGLFramebuffer, + src_attachment_index: int = 0, + depth: bool = True, + ): + # Set source and dest framebuffer + gl.glBindFramebuffer(gl.GL_READ_FRAMEBUFFER, src.glo) + gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, dst.glo) + + # TODO: We can support blitting multiple layers here + gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0 + src_attachment_index) + if dst.is_default: + gl.glDrawBuffer(gl.GL_BACK) + else: + gl.glDrawBuffer(gl.GL_COLOR_ATTACHMENT0) + + # gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, src._glo) + gl.glBlitFramebuffer( + 0, + 0, + src.width, + src.height, # Make source and dest size the same + 0, + 0, + src.width, + src.height, + gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT, + gl.GL_NEAREST, + ) + + # Reset states. We can also apply previous states here + gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0) + + def sampler(self, texture: OpenGLTexture2D) -> OpenGLSampler: + """ + Create a sampler object for a texture. + + Args: + texture: + The texture to create a sampler for + """ + return OpenGLSampler(self, texture) + + def texture_array( + self, + size: Tuple[int, int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + wrap_x: PyGLenum | None = None, + wrap_y: PyGLenum | None = None, + filter: Tuple[PyGLenum, PyGLenum] | None = None, + ) -> OpenGLTextureArray: + return OpenGLTextureArray( + self, + size, + components=components, + dtype=dtype, + data=data, + wrap_x=wrap_x, + wrap_y=wrap_y, + filter=filter, + ) + + def query(self, *, samples=True, time=True, primitives=True) -> OpenGLQuery: + return OpenGLQuery(self, samples=samples, time=time, primitives=primitives) + + +class OpenGLArcadeContext(ArcadeContext, OpenGLContext): + def __init__(self, *args, **kwargs): + OpenGLContext.__init__(self, *args, **kwargs) + ArcadeContext.__init__(self, *args, **kwargs) + + +class OpenGLInfo(Info): + """OpenGL info and capabilities""" + + def __init__(self, ctx): + super().__init__(ctx) + + self.MINOR_VERSION = self.get(gl.GL_MINOR_VERSION) + """Minor version number of the OpenGL API supported by the current context""" + + self.MAJOR_VERSION = self.get(gl.GL_MAJOR_VERSION) + """Major version number of the OpenGL API supported by the current context.""" + + self.MAX_COLOR_TEXTURE_SAMPLES = self.get(gl.GL_MAX_COLOR_TEXTURE_SAMPLES) + """Maximum number of samples in a color multisample texture""" + + self.MAX_COMBINED_GEOMETRY_UNIFORM_COMPONENTS = self.get( + gl.GL_MAX_COMBINED_GEOMETRY_UNIFORM_COMPONENTS + ) + """Number of words for geometry shader uniform variables in all uniform blocks""" + + self.MAX_DEPTH_TEXTURE_SAMPLES = self.get(gl.GL_MAX_DEPTH_TEXTURE_SAMPLES) + """Maximum number of samples in a multisample depth or depth-stencil texture""" + + self.MAX_GEOMETRY_INPUT_COMPONENTS = self.get(gl.GL_MAX_GEOMETRY_INPUT_COMPONENTS) + """Maximum number of components of inputs read by a geometry shader""" + + self.MAX_GEOMETRY_OUTPUT_COMPONENTS = self.get(gl.GL_MAX_GEOMETRY_OUTPUT_COMPONENTS) + """Maximum number of components of outputs written by a geometry shader""" + + self.MAX_GEOMETRY_TEXTURE_IMAGE_UNITS = self.get(gl.GL_MAX_GEOMETRY_TEXTURE_IMAGE_UNITS) + """ + Maximum supported texture image units that can be used to access texture + maps from the geometry shader + """ + + self.MAX_GEOMETRY_UNIFORM_BLOCKS = self.get(gl.GL_MAX_GEOMETRY_UNIFORM_BLOCKS) + """Maximum number of uniform blocks per geometry shader""" + + self.MAX_GEOMETRY_UNIFORM_COMPONENTS = self.get(gl.GL_MAX_GEOMETRY_UNIFORM_COMPONENTS) + """ + Maximum number of individual floating-point, integer, or boolean values that can + be held in uniform variable storage for a geometry shader + """ + + self.MAX_INTEGER_SAMPLES = self.get(gl.GL_MAX_INTEGER_SAMPLES) + """Maximum number of samples supported in integer format multisample buffers""" + + self.MAX_SAMPLE_MASK_WORDS = self.get(gl.GL_MAX_SAMPLE_MASK_WORDS) + """Maximum number of sample mask words""" + + self.POINT_SIZE_RANGE = self.get_int_tuple(gl.GL_POINT_SIZE_RANGE, 2) + """The minimum and maximum point size""" + + # This error checking doesn't actually need any implementation specific details + # However we need to do it here instead of the common class to catch all possible + # errors because of implementation specific gets. + err = self._ctx.error + if err: + from warnings import warn + + warn(f"Error happened while querying of limits. {err}") + + def get_int_tuple(self, enum, length: int): + """ + Get an enum as an int tuple + + Args: + enum: The enum to query + length: The length of the tuple + """ + try: + values = (c_int * length)() + gl.glGetIntegerv(enum, values) + return tuple(values) + except pyglet.gl.lib.GLException: + return tuple([0] * length) + + def get(self, enum, default=0) -> int: + """ + Get an integer limit. + + Args: + enum: The enum to query + default: The default value if the query fails + """ + try: + value = c_int() + gl.glGetIntegerv(enum, value) + return value.value + except pyglet.gl.lib.GLException: + return default + + def get_float(self, enum, default=0.0) -> float: + """ + Get a float limit + + Args: + enum: The enum to query + default: The default value if the query fails + """ + try: + value = c_float() + gl.glGetFloatv(enum, value) + return value.value + except pyglet.gl.lib.GLException: + return default + + def get_str(self, enum) -> str: + """ + Get a string limit. + + Args: + enum: The enum to query + """ + try: + return cast(gl.glGetString(enum), c_char_p).value.decode() # type: ignore + except pyglet.gl.lib.GLException: + return "Unknown" diff --git a/arcade/gl/backends/opengl/framebuffer.py b/arcade/gl/backends/opengl/framebuffer.py new file mode 100644 index 0000000000..419cd2f86f --- /dev/null +++ b/arcade/gl/backends/opengl/framebuffer.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +import weakref +from ctypes import Array, c_int, c_uint, string_at +from typing import TYPE_CHECKING + +from pyglet import gl + +from arcade.gl.framebuffer import DefaultFrameBuffer, Framebuffer +from arcade.gl.types import pixel_formats +from arcade.types import RGBOrA255, RGBOrANormalized + +from .texture import OpenGLTexture2D + +if TYPE_CHECKING: + from arcade.gl import Context + + +class OpenGLFramebuffer(Framebuffer): + """ + An offscreen render target also called a Framebuffer Object in OpenGL. + This implementation is using texture attachments. When creating a + Framebuffer we supply it with textures we want our scene rendered into. + The advantage of using texture attachments is the ability we get + to keep working on the contents of the framebuffer. + + The best way to create framebuffer is through :py:meth:`arcade.gl.Context.framebuffer`:: + + # Create a 100 x 100 framebuffer with one attachment + ctx.framebuffer(color_attachments=[ctx.texture((100, 100), components=4)]) + + # Create a 100 x 100 framebuffer with two attachments + # Shaders can be configured writing to the different layers + ctx.framebuffer( + color_attachments=[ + ctx.texture((100, 100), components=4), + ctx.texture((100, 100), components=4), + ] + ) + + Args: + ctx: + The context this framebuffer belongs to + color_attachments: + A color attachment or a list of color attachments + depth_attachment: + A depth attachment + """ + + __slots__ = "_glo" + + def __init__( + self, + ctx: "Context", + *, + color_attachments: OpenGLTexture2D | list[OpenGLTexture2D], + depth_attachment: OpenGLTexture2D | None = None, + ): + super().__init__( + ctx, color_attachments=color_attachments, depth_attachment=depth_attachment + ) + self._glo = fbo_id = gl.GLuint() # The OpenGL alias/name + + # Create the framebuffer object + gl.glGenFramebuffers(1, self._glo) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._glo) + + # Attach textures to it + for i, tex in enumerate(self._color_attachments): + # TODO: Possibly support attaching a specific mipmap level + # but we can read from specific mip levels from shaders. + gl.glFramebufferTexture2D( + gl.GL_FRAMEBUFFER, + gl.GL_COLOR_ATTACHMENT0 + i, + tex._target, + tex.glo, + 0, # Level 0 + ) + + if self.depth_attachment: + gl.glFramebufferTexture2D( + gl.GL_FRAMEBUFFER, + gl.GL_DEPTH_ATTACHMENT, + self.depth_attachment._target, + self.depth_attachment.glo, + 0, + ) + + # Ensure the framebuffer is sane! + self._check_completeness() + + # Set up draw buffers. This is simply a prepared list of attachments enums + # we use in the use() method to activate the different color attachment layers + layers = [gl.GL_COLOR_ATTACHMENT0 + i for i, _ in enumerate(self._color_attachments)] + # pyglet wants this as a ctypes thingy, so let's prepare it + self._draw_buffers: Array[c_uint] | None = (gl.GLuint * len(layers))(*layers) + + # Restore the original bound framebuffer to avoid confusion + self.ctx.active_framebuffer.use(force=True) + + if self._ctx.gc_mode == "auto" and not self.is_default: + weakref.finalize(self, OpenGLFramebuffer.delete_glo, ctx, fbo_id) + + def __del__(self): + # Intercept garbage collection if we are using Context.gc() + if self._ctx.gc_mode == "context_gc" and not self.is_default and self._glo.value > 0: + self._ctx.objects.append(self) + + @property + def glo(self) -> gl.GLuint: + """The OpenGL id/name of the framebuffer.""" + return self._glo + + @Framebuffer.viewport.setter + def viewport(self, value: tuple[int, int, int, int]): + if not isinstance(value, tuple) or len(value) != 4: + raise ValueError("viewport should be a 4-component tuple") + + self._viewport = value + + # If the framebuffer is bound we need to set the viewport. + # Otherwise it will be set on use() + if self._ctx.active_framebuffer == self: + gl.glViewport(*self._viewport) + if self._scissor is None: + gl.glScissor(*self._viewport) + else: + gl.glScissor(*self._scissor) + + @Framebuffer.scissor.setter + def scissor(self, value): + self._scissor = value + + if self._scissor is None: + if self._ctx.active_framebuffer == self: + gl.glScissor(*self._viewport) + else: + if self._ctx.active_framebuffer == self: + gl.glScissor(*self._scissor) + + @Framebuffer.depth_mask.setter + def depth_mask(self, value: bool): + self._depth_mask = value + # Set state if framebuffer is active + if self._ctx.active_framebuffer == self: + gl.glDepthMask(self._depth_mask) + + def _use(self, *, force: bool = False): + """Internal use that do not change the global active framebuffer""" + if self.ctx.active_framebuffer == self and not force: + return + + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._glo) + + # NOTE: gl.glDrawBuffer(GL_NONE) if no texture attachments (future) + # NOTE: Default framebuffer currently has this set to None + if self._draw_buffers: + gl.glDrawBuffers(len(self._draw_buffers), self._draw_buffers) + + gl.glDepthMask(self._depth_mask) + gl.glViewport(*self._viewport) + if self._scissor is not None: + gl.glScissor(*self._scissor) + else: + gl.glScissor(*self._viewport) + + def clear( + self, + *, + color: RGBOrA255 | None = None, + color_normalized: RGBOrANormalized | None = None, + depth: float = 1.0, + viewport: tuple[int, int, int, int] | None = None, + ): + """ + Clears the framebuffer:: + + # Clear the framebuffer using Arcade's colors (not normalized) + fb.clear(color=arcade.color.WHITE) + + # Clear framebuffer using the color red in normalized form + fbo.clear(color_normalized=(1.0, 0.0, 0.0, 1.0)) + + If the background color is an ``RGB`` value instead of ``RGBA``` + we assume alpha value 255. + + Args: + color: + A 3 or 4 component tuple containing the color + (prioritized over color_normalized) + color_normalized: + A 3 or 4 component tuple containing the color in normalized form + depth: + Value to clear the depth buffer (unused) + viewport: + The viewport range to clear + """ + with self.activate(): + scissor_values = self._scissor + + if viewport: + self.scissor = viewport + else: + self.scissor = None + + clear_color = 0.0, 0.0, 0.0, 0.0 + if color is not None: + if len(color) == 3: + clear_color = color[0] / 255, color[1] / 255, color[2] / 255, 1.0 + elif len(color) == 4: + clear_color = color[0] / 255, color[1] / 255, color[2] / 255, color[3] / 255 + else: + raise ValueError("Color should be a 3 or 4 component tuple") + elif color_normalized is not None: + if len(color_normalized) == 3: + clear_color = color_normalized[0], color_normalized[1], color_normalized[2], 1.0 + elif len(color_normalized) == 4: + clear_color = color_normalized + else: + raise ValueError("Color should be a 3 or 4 component tuple") + + gl.glClearColor(*clear_color) + + if self.depth_attachment: + if self._ctx.gl_api == "opengl": + gl.glClearDepth(depth) + else: # gles only supports glClearDepthf + gl.glClearDepthf(depth) + + gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) + else: + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + + self.scissor = scissor_values + + def read(self, *, viewport=None, components=3, attachment=0, dtype="f1") -> bytes: + """ + Read the raw framebuffer pixels. + + Reading data from a framebuffer is much more powerful than + reading date from textures. We can specify more or less + what format we want the data. It's not uncommon to throw + textures into framebuffers just to get access to this read + api. + + Args: + viewport: + The x, y, with, height area to read. + components: + The number of components to read. 1, 2, 3 or 4. + This will determine the format to read. + attachment: + The attachment id to read from + dtype: + The data type to read. Pixel data will be converted to this format. + """ + # TODO: use texture attachment info to determine read format? + try: + frmt = pixel_formats[dtype] + base_format = frmt[0][components] + pixel_type = frmt[2] + component_size = frmt[3] + except Exception: + raise ValueError(f"Invalid dtype '{dtype}'") + + with self.activate(): + # Configure attachment to read from. Does not work on default framebuffer. + if not self.is_default: + gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0 + attachment) + + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) + + if viewport: + x, y, width, height = viewport + else: + x, y, width, height = 0, 0, *self.size + + data = (gl.GLubyte * (components * component_size * width * height))(0) + gl.glReadPixels(x, y, width, height, base_format, pixel_type, data) + + if not self.is_default: + gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0) # Reset to default + + return string_at(data, len(data)) + + def delete(self): + """ + Destroy the underlying OpenGL resource. + + .. warning:: Don't use this unless you know exactly what you are doing. + """ + OpenGLFramebuffer.delete_glo(self._ctx, self._glo) + self._glo.value = 0 + + @staticmethod + def delete_glo(ctx, framebuffer_id): + """ + Destroys the framebuffer object + + Args: + ctx: + The context this framebuffer belongs to + framebuffer_id: + Framebuffer id destroy (glo) + """ + if gl.current_context is None: + return + + gl.glDeleteFramebuffers(1, framebuffer_id) + ctx.stats.decr("framebuffer") + + @staticmethod + def _check_completeness() -> None: + """ + Checks the completeness of the framebuffer. + + If the framebuffer is not complete, we cannot continue. + """ + # See completeness rules : https://www.khronos.org/opengl/wiki/Framebuffer_Object + states = { + gl.GL_FRAMEBUFFER_UNSUPPORTED: "Framebuffer unsupported. Try another format.", + gl.GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT: "Framebuffer incomplete attachment.", + gl.GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: "Framebuffer missing attachment.", + gl.GL_FRAMEBUFFER_INCOMPLETE_DIMENSIONS_EXT: "Framebuffer unsupported dimension.", + gl.GL_FRAMEBUFFER_INCOMPLETE_FORMATS_EXT: "Framebuffer incomplete formats.", + gl.GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER: "Framebuffer incomplete draw buffer.", + gl.GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER: "Framebuffer incomplete read buffer.", + gl.GL_FRAMEBUFFER_COMPLETE: "Framebuffer is complete.", + } + + status = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) + if status != gl.GL_FRAMEBUFFER_COMPLETE: + raise ValueError( + "Framebuffer is incomplete. {}".format(states.get(status, "Unknown error")) + ) + + def __repr__(self): + return "".format(self._glo.value) + + +class OpenGLDefaultFrameBuffer(DefaultFrameBuffer, OpenGLFramebuffer): + """ + Represents the default framebuffer. + + This is the framebuffer of the window itself and need some special handling. + + We are not allowed to destroy this framebuffer since it's owned by pyglet. + This framebuffer can also change size and pixel ratio at any point. + + We're doing some initial introspection to guess somewhat sane initial values. + Since this is a dynamic framebuffer we cannot trust the internal values. + We can only trust what the pyglet window itself reports related to window size + and framebuffer size. This should be updated in the ``on_resize`` callback. + """ + + is_default = True + """Is this the default framebuffer? (window buffer)""" + + def __init__(self, ctx: "Context"): + super().__init__(ctx) + + value = c_int() + gl.glGetIntegerv(gl.GL_DRAW_FRAMEBUFFER_BINDING, value) + self._glo = gl.GLuint(value.value) + + # Query viewport values by inspecting the scissor box + values = (c_int * 4)() + gl.glGetIntegerv(gl.GL_SCISSOR_BOX, values) + x, y, width, height = list(values) + + self._viewport = x, y, width, height + self._scissor = None + self._width = width + self._height = height + + @DefaultFrameBuffer.viewport.setter + def viewport(self, value: tuple[int, int, int, int]): + if not isinstance(value, tuple) or len(value) != 4: + raise ValueError("viewport should be a 4-component tuple") + + ratio = self.ctx.window.get_pixel_ratio() + self._viewport = ( + int(value[0] * ratio), + int(value[1] * ratio), + int(value[2] * ratio), + int(value[3] * ratio), + ) + + # If the framebuffer is bound we need to set the viewport. + # Otherwise it will be set on use() + if self._ctx.active_framebuffer == self: + gl.glViewport(*self._viewport) + if self._scissor is None: + # FIXME: Probably should be set to the framebuffer size + gl.glScissor(*self._viewport) + else: + gl.glScissor(*self._scissor) + + @DefaultFrameBuffer.scissor.setter + def scissor(self, value): + if value is None: + # FIXME: Do we need to reset something here? + self._scissor = None + if self._ctx.active_framebuffer == self: + gl.glScissor(*self._viewport) + else: + ratio = self.ctx.window.get_pixel_ratio() + self._scissor = ( + int(value[0] * ratio), + int(value[1] * ratio), + int(value[2] * ratio), + int(value[3] * ratio), + ) + + # If the framebuffer is bound we need to set the scissor box. + # Otherwise it will be set on use() + if self._ctx.active_framebuffer == self: + gl.glScissor(*self._scissor) diff --git a/arcade/gl/glsl.py b/arcade/gl/backends/opengl/glsl.py similarity index 97% rename from arcade/gl/glsl.py rename to arcade/gl/backends/opengl/glsl.py index 61580bb6a5..2abcf7650f 100644 --- a/arcade/gl/glsl.py +++ b/arcade/gl/backends/opengl/glsl.py @@ -6,8 +6,8 @@ if TYPE_CHECKING: from .context import Context as ArcadeGlContext -from .exceptions import ShaderException -from .types import SHADER_TYPE_NAMES, PyGLenum +from arcade.gl.exceptions import ShaderException +from arcade.gl.types import SHADER_TYPE_NAMES, PyGLenum class ShaderSource: @@ -50,7 +50,7 @@ def __init__( self._version = self._find_glsl_version() # GLES specific modifications - if ctx.gl_api == "gles": + if ctx.gl_api == "opengles": # TODO: Use the version from the context self._lines[0] = "#version 310 es" self._lines.insert(1, "precision mediump float;") diff --git a/arcade/gl/backends/opengl/program.py b/arcade/gl/backends/opengl/program.py new file mode 100644 index 0000000000..61b08cfd93 --- /dev/null +++ b/arcade/gl/backends/opengl/program.py @@ -0,0 +1,548 @@ +from __future__ import annotations + +import typing +import weakref +from ctypes import ( + POINTER, + byref, + c_buffer, + c_char, + c_char_p, + c_int, + cast, + create_string_buffer, + pointer, +) +from typing import TYPE_CHECKING, Any, Iterable + +from pyglet import gl + +from arcade.gl.exceptions import ShaderException +from arcade.gl.program import Program +from arcade.gl.types import SHADER_TYPE_NAMES, AttribFormat, GLTypes, PyGLenum + +from .uniform import Uniform, UniformBlock + +if TYPE_CHECKING: + from arcade.gl import Context + + +class OpenGLProgram(Program): + """ + Compiled and linked shader program. + + Currently supports + + - vertex shader + - fragment shader + - geometry shader + - tessellation control shader + - tessellation evaluation shader + + Transform feedback also supported when output attributes + names are passed in the varyings parameter. + + The best way to create a program instance is through :py:meth:`arcade.gl.Context.program` + + Args: + ctx: + The context this program belongs to + vertex_shader: + Vertex shader source + fragment_shader: + Fragment shader source + geometry_shader: + Geometry shader source + tess_control_shader: + Tessellation control shader source + tess_evaluation_shader: + Tessellation evaluation shader source + varyings: + List of out attributes used in transform feedback. + varyings_capture_mode: + The capture mode for transforms. + ``"interleaved"`` means all out attribute will be written to a single buffer. + ``"separate"`` means each out attribute will be written separate buffers. + Based on these settings the `transform()` method will accept a single + buffer or a list of buffer. + """ + + __slots__ = ( + "_glo", + "_uniforms", + "_varyings", + "_geometry_info", + "_attributes", + ) + + _valid_capture_modes = ("interleaved", "separate") + + def __init__( + self, + ctx: Context, + *, + vertex_shader: str, + fragment_shader: str | None = None, + geometry_shader: str | None = None, + tess_control_shader: str | None = None, + tess_evaluation_shader: str | None = None, + varyings: list[str] | None = None, + varyings_capture_mode: str = "interleaved", + ): + super().__init__(ctx) + self._glo = glo = gl.glCreateProgram() + self._varyings = varyings or [] + self._varyings_capture_mode = varyings_capture_mode.strip().lower() + self._geometry_info = (0, 0, 0) + self._attributes = [] # type: list[AttribFormat] + #: Internal cache key used with vertex arrays + self._uniforms: dict[str, Uniform | UniformBlock] = {} + + if self._varyings_capture_mode not in self._valid_capture_modes: + raise ValueError( + f"Invalid capture mode '{self._varyings_capture_mode}'. " + f"Valid modes are: {self._valid_capture_modes}." + ) + + shaders: list[tuple[str, int]] = [(vertex_shader, gl.GL_VERTEX_SHADER)] + if fragment_shader: + shaders.append((fragment_shader, gl.GL_FRAGMENT_SHADER)) + if geometry_shader: + shaders.append((geometry_shader, gl.GL_GEOMETRY_SHADER)) + if tess_control_shader: + shaders.append((tess_control_shader, gl.GL_TESS_CONTROL_SHADER)) + if tess_evaluation_shader: + shaders.append((tess_evaluation_shader, gl.GL_TESS_EVALUATION_SHADER)) + + # Inject a dummy fragment shader on gles when doing transforms + if self._ctx.gl_api == "opengles" and not fragment_shader: + dummy_frag_src = """ + #version 310 es + precision mediump float; + out vec4 fragColor; + void main() { fragColor = vec4(1.0); } + """ + shaders.append((dummy_frag_src, gl.GL_FRAGMENT_SHADER)) + + shaders_id = [] + for shader_code, shader_type in shaders: + shader = OpenGLProgram.compile_shader(shader_code, shader_type) + gl.glAttachShader(self._glo, shader) + shaders_id.append(shader) + + # For now we assume varyings can be set up if no fragment shader + if not fragment_shader: + self._configure_varyings() + + OpenGLProgram.link(self._glo) + + if geometry_shader: + geometry_in = gl.GLint() + geometry_out = gl.GLint() + geometry_vertices = gl.GLint() + gl.glGetProgramiv(self._glo, gl.GL_GEOMETRY_INPUT_TYPE, geometry_in) + gl.glGetProgramiv(self._glo, gl.GL_GEOMETRY_OUTPUT_TYPE, geometry_out) + gl.glGetProgramiv(self._glo, gl.GL_GEOMETRY_VERTICES_OUT, geometry_vertices) + self._geometry_info = ( + geometry_in.value, + geometry_out.value, + geometry_vertices.value, + ) + + # Delete shaders (not needed after linking) + for shader in shaders_id: + gl.glDeleteShader(shader) + gl.glDetachShader(self._glo, shader) + + # Handle uniforms + self._introspect_attributes() + self._introspect_uniforms() + self._introspect_uniform_blocks() + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, OpenGLProgram.delete_glo, self._ctx, glo) + + def __del__(self): + # Intercept garbage collection if we are using Context.gc() + if self._ctx.gc_mode == "context_gc" and self._glo > 0: + self._ctx.objects.append(self) + + @property + def ctx(self) -> "Context": + """The context this program belongs to.""" + return self._ctx + + @property + def glo(self) -> int: + """The OpenGL resource id for this program.""" + return self._glo + + @property + def attributes(self) -> Iterable[AttribFormat]: + """List of attribute information.""" + return self._attributes + + @property + def varyings(self) -> list[str]: + """Out attributes names used in transform feedback.""" + return self._varyings + + @property + def out_attributes(self) -> list[str]: + """ + Out attributes names used in transform feedback. + + Alias for `varyings`. + """ + return self._varyings + + @property + def varyings_capture_mode(self) -> str: + """ + Get the capture more for transform feedback (single, multiple). + + This is a read only property since capture mode + can only be set before the program is linked. + """ + return self._varyings_capture_mode + + @property + def geometry_input(self) -> int: + """ + The geometry shader's input primitive type. + + This an be compared with ``GL_TRIANGLES``, ``GL_POINTS`` etc. + and is queried when the program is created. + """ + return self._geometry_info[0] + + @property + def geometry_output(self) -> int: + """The geometry shader's output primitive type. + + This an be compared with ``GL_TRIANGLES``, ``GL_POINTS`` etc. + and is queried when the program is created. + """ + return self._geometry_info[1] + + @property + def geometry_vertices(self) -> int: + """ + The maximum number of vertices that can be emitted. + This is queried when the program is created. + """ + return self._geometry_info[2] + + def delete(self): + """ + Destroy the underlying OpenGL resource. + + Don't use this unless you know exactly what you are doing. + """ + OpenGLProgram.delete_glo(self._ctx, self._glo) + self._glo = 0 + + @staticmethod + def delete_glo(ctx, prog_id): + """ + Deletes a program. This is normally called automatically when the + program is garbage collected. + + Args: + ctx: + The context this program belongs to + prog_id: + The OpenGL resource id + """ + # Check to see if the context was already cleaned up from program + # shut down. If so, we don't need to delete the shaders. + if gl.current_context is None: + return + + gl.glDeleteProgram(prog_id) + ctx.stats.decr("program") + + def __getitem__(self, item) -> Uniform | UniformBlock: + """Get a uniform or uniform block""" + try: + uniform = self._uniforms[item] + except KeyError: + raise KeyError(f"Uniform with the name `{item}` was not found.") + + return uniform.getter() + + def __setitem__(self, key, value): + """ + Set a uniform value. + + Example:: + + program['color'] = 1.0, 1.0, 1.0, 1.0 + program['mvp'] = projection @ view @ model + + Args: + key: + The uniform name + value: + The uniform value + """ + try: + uniform = self._uniforms[key] + except KeyError: + raise KeyError(f"Uniform with the name `{key}` was not found.") + + uniform.setter(value) + + def set_uniform_safe(self, name: str, value: Any): + """ + Safely set a uniform catching KeyError. + + Args: + name: + The uniform name + value: + The uniform value + """ + try: + self[name] = value + except KeyError: + pass + + def set_uniform_array_safe(self, name: str, value: list[Any]): + """ + Safely set a uniform array. + + Arrays can be shortened by the glsl compiler not all elements are determined + to be in use. This function checks the length of the actual array and sets a + subset of the values if needed. If the uniform don't exist no action will be + done. + + Args: + name: + Name of uniform + value: + List of values + """ + if name not in self._uniforms: + return + + uniform = typing.cast(Uniform, self._uniforms[name]) + _len = uniform._array_length * uniform._components + if _len == 1: + self.set_uniform_safe(name, value[0]) + else: + self.set_uniform_safe(name, value[:_len]) + + def use(self): + """ + Activates the shader. + + This is normally done for you automatically. + """ + # IMPORTANT: This is the only place glUseProgram should be called + # so we can track active program. + # if self._ctx.active_program != self: + gl.glUseProgram(self._glo) + # self._ctx.active_program = self + + def _configure_varyings(self): + """Set up transform feedback varyings""" + if not self._varyings: + return + + # Covert names to char** + c_array = (c_char_p * len(self._varyings))() + for i, name in enumerate(self._varyings): + c_array[i] = name.encode() + + ptr = cast(c_array, POINTER(POINTER(c_char))) + + # Are we capturing in interlaved or separate buffers? + mode = ( + gl.GL_INTERLEAVED_ATTRIBS + if self._varyings_capture_mode == "interleaved" + else gl.GL_SEPARATE_ATTRIBS + ) + + gl.glTransformFeedbackVaryings( + self._glo, # program + len(self._varyings), # number of varying variables used for transform feedback + ptr, # zero-terminated strings specifying the names of the varying variables + mode, + ) + + def _introspect_attributes(self): + """Introspect and store detailed info about an attribute""" + # TODO: Ensure gl_* attributes are ignored + num_attrs = gl.GLint() + gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_ATTRIBUTES, num_attrs) + num_varyings = gl.GLint() + gl.glGetProgramiv(self._glo, gl.GL_TRANSFORM_FEEDBACK_VARYINGS, num_varyings) + # print(f"attrs {num_attrs.value} varyings={num_varyings.value}") + + for i in range(num_attrs.value): + c_name = create_string_buffer(256) + c_size = gl.GLint() + c_type = gl.GLenum() + gl.glGetActiveAttrib( + self._glo, # program to query + i, # index (not the same as location) + 256, # max attr name size + None, # c_length, # length of name + c_size, # size of attribute (array or not) + c_type, # attribute type (enum) + c_name, # name buffer + ) + + # Get the actual location. Do not trust the original order + location = gl.glGetAttribLocation(self._glo, c_name) + + # print(c_name.value, c_size, c_type) + type_info = GLTypes.get(c_type.value) + # print(type_info) + self._attributes.append( + AttribFormat( + c_name.value.decode(), + type_info.gl_type, + type_info.components, + type_info.gl_size, + location=location, + ) + ) + + # The attribute key is used to cache VertexArrays + self.attribute_key = ":".join( + f"{attr.name}[{attr.gl_type}/{attr.components}]" for attr in self._attributes + ) + + def _introspect_uniforms(self): + """Figure out what uniforms are available and build an internal map""" + # Number of active uniforms in the program + active_uniforms = gl.GLint(0) + gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_UNIFORMS, byref(active_uniforms)) + + # Loop all the active uniforms + for index in range(active_uniforms.value): + # Query uniform information like name, type, size etc. + u_name, u_type, u_size = self._query_uniform(index) + u_location = gl.glGetUniformLocation(self._glo, u_name.encode()) + + # Skip uniforms that may be in Uniform Blocks + # TODO: We should handle all uniforms + if u_location == -1: + # print(f"Uniform {u_location} {u_name} {u_size} {u_type} skipped") + continue + + u_name = u_name.replace("[0]", "") # Remove array suffix + self._uniforms[u_name] = Uniform( + self._ctx, self._glo, u_location, u_name, u_type, u_size + ) + + def _introspect_uniform_blocks(self): + active_uniform_blocks = gl.GLint(0) + gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_UNIFORM_BLOCKS, byref(active_uniform_blocks)) + # print('GL_ACTIVE_UNIFORM_BLOCKS', active_uniform_blocks) + + for loc in range(active_uniform_blocks.value): + index, size, name = self._query_uniform_block(loc) + block = UniformBlock(self._glo, index, size, name) + self._uniforms[name] = block + + def _query_uniform(self, location: int) -> tuple[str, int, int]: + """Retrieve Uniform information at given location. + + Returns the name, the type as a GLenum (GL_FLOAT, ...) and the size. Size is + greater than 1 only for Uniform arrays, like an array of floats or an array + of Matrices. + """ + u_size = gl.GLint() + u_type = gl.GLenum() + buf_size = 192 # max uniform character length + u_name = create_string_buffer(buf_size) + gl.glGetActiveUniform( + self._glo, # program to query + location, # location to query + buf_size, # size of the character/name buffer + None, # the number of characters actually written by OpenGL in the string + u_size, # size of the uniform variable + u_type, # data type of the uniform variable + u_name, # string buffer for storing the name + ) + return u_name.value.decode(), u_type.value, u_size.value + + def _query_uniform_block(self, location: int) -> tuple[int, int, str]: + """Query active uniform block by retrieving the name and index and size""" + # Query name + u_size = gl.GLint() + buf_size = 192 # max uniform character length + u_name = create_string_buffer(buf_size) + gl.glGetActiveUniformBlockName( + self._glo, # program to query + location, # location to query + 256, # max size if the name + u_size, # length + u_name, + ) + # Query index + index = gl.glGetUniformBlockIndex(self._glo, u_name) + # Query size + b_size = gl.GLint() + gl.glGetActiveUniformBlockiv(self._glo, index, gl.GL_UNIFORM_BLOCK_DATA_SIZE, b_size) + return index, b_size.value, u_name.value.decode() + + @staticmethod + def compile_shader(source: str, shader_type: PyGLenum) -> gl.GLuint: + """ + Compile the shader code of the given type. + + Args: + source: + The shader source code + shader_type: + The type of shader to compile. + ``GL_VERTEX_SHADER``, ``GL_FRAGMENT_SHADER`` etc. + + Returns: + The created shader id + """ + shader = gl.glCreateShader(shader_type) + source_bytes = source.encode("utf-8") + # Turn the source code string into an array of c_char_p arrays. + strings = byref(cast(c_char_p(source_bytes), POINTER(c_char))) + # Make an array with the strings lengths + lengths = pointer(c_int(len(source_bytes))) + gl.glShaderSource(shader, 1, strings, lengths) + gl.glCompileShader(shader) + result = c_int() + gl.glGetShaderiv(shader, gl.GL_COMPILE_STATUS, byref(result)) + if result.value == gl.GL_FALSE: + msg = create_string_buffer(512) + length = c_int() + gl.glGetShaderInfoLog(shader, 512, byref(length), msg) + raise ShaderException( + ( + f"Error compiling {SHADER_TYPE_NAMES[shader_type]} " + f"({result.value}): {msg.value.decode('utf-8')}\n" + f"---- [{SHADER_TYPE_NAMES[shader_type]}] ---\n" + ) + + "\n".join( + f"{str(i + 1).zfill(3)}: {line} " for i, line in enumerate(source.split("\n")) + ) + ) + return shader + + @staticmethod + def link(glo): + """Link a shader program""" + gl.glLinkProgram(glo) + status = c_int() + gl.glGetProgramiv(glo, gl.GL_LINK_STATUS, status) + if not status.value: + length = c_int() + gl.glGetProgramiv(glo, gl.GL_INFO_LOG_LENGTH, length) + log = c_buffer(length.value) + gl.glGetProgramInfoLog(glo, len(log), None, log) + raise ShaderException("Program link error: {}".format(log.value.decode())) + + def __repr__(self): + return "".format(self._glo) diff --git a/arcade/gl/backends/opengl/provider.py b/arcade/gl/backends/opengl/provider.py new file mode 100644 index 0000000000..2113dd4a9d --- /dev/null +++ b/arcade/gl/backends/opengl/provider.py @@ -0,0 +1,14 @@ +from arcade.gl.provider import BaseProvider + +from .context import OpenGLArcadeContext, OpenGLContext, OpenGLInfo + + +class Provider(BaseProvider): + def create_context(self, *args, **kwargs): + return OpenGLContext(*args, **kwargs) + + def create_info(self, ctx): + return OpenGLInfo(ctx) + + def create_arcade_context(self, *args, **kwargs): + return OpenGLArcadeContext(*args, **kwargs) diff --git a/arcade/gl/backends/opengl/query.py b/arcade/gl/backends/opengl/query.py new file mode 100644 index 0000000000..76b66b5470 --- /dev/null +++ b/arcade/gl/backends/opengl/query.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +from pyglet import gl + +from arcade.gl.query import Query + +if TYPE_CHECKING: + from arcade.gl import Context + + +class OpenGLQuery(Query): + """ + A query object to perform low level measurements of OpenGL rendering calls. + + The best way to create a program instance is through :py:meth:`arcade.gl.Context.query` + + Example usage:: + + query = ctx.query() + with query: + geometry.render(..) + + print('samples_passed:', query.samples_passed) + print('time_elapsed:', query.time_elapsed) + print('primitives_generated:', query.primitives_generated) + + Args: + ctx: + The context this query object belongs to + samples: + Enable counting written samples + time: + Enable measuring time elapsed + primitives: + Enable counting primitives + """ + + __slots__ = ( + "_glo_samples_passed", + "_glo_time_elapsed", + "_glo_primitives_generated", + ) + + def __init__(self, ctx: Context, samples=True, time=True, primitives=True): + super().__init__(ctx, samples, time, primitives) + + glos = [] + + self._glo_samples_passed = glo_samples_passed = gl.GLuint() + if self._samples_enabled: + gl.glGenQueries(1, self._glo_samples_passed) + glos.append(glo_samples_passed) + + self._glo_time_elapsed = glo_time_elapsed = gl.GLuint() + if self._time_enabled: + gl.glGenQueries(1, self._glo_time_elapsed) + glos.append(glo_time_elapsed) + + self._glo_primitives_generated = glo_primitives_generated = gl.GLuint() + if self._primitives_enabled: + gl.glGenQueries(1, self._glo_primitives_generated) + glos.append(glo_primitives_generated) + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, OpenGLQuery.delete_glo, self._ctx, glos) + + def __enter__(self): + if self._ctx.gl_api == "opengl": + if self._samples_enabled: + gl.glBeginQuery(gl.GL_SAMPLES_PASSED, self._glo_samples_passed) + if self._time_enabled: + gl.glBeginQuery(gl.GL_TIME_ELAPSED, self._glo_time_elapsed) + if self._primitives_enabled: + gl.glBeginQuery(gl.GL_PRIMITIVES_GENERATED, self._glo_primitives_generated) + + def __exit__(self, exc_type, exc_val, exc_tb): + if self._ctx.gl_api == "opengl": + if self._samples_enabled: + gl.glEndQuery(gl.GL_SAMPLES_PASSED) + value = gl.GLint() + gl.glGetQueryObjectiv(self._glo_samples_passed, gl.GL_QUERY_RESULT, value) + self._samples = value.value + + if self._time_enabled: + gl.glEndQuery(gl.GL_TIME_ELAPSED) + value = gl.GLint() + gl.glGetQueryObjectiv(self._glo_time_elapsed, gl.GL_QUERY_RESULT, value) + self._time = value.value + + if self._primitives_enabled: + gl.glEndQuery(gl.GL_PRIMITIVES_GENERATED) + value = gl.GLint() + gl.glGetQueryObjectiv(self._glo_primitives_generated, gl.GL_QUERY_RESULT, value) + self._primitives = value.value + + def delete(self): + """ + Destroy the underlying OpenGL resource. + + Don't use this unless you know exactly what you are doing. + """ + OpenGLQuery.delete_glo( + self._ctx, + [ + self._glo_samples_passed, + self._glo_time_elapsed, + self._glo_primitives_generated, + ], + ) + + @staticmethod + def delete_glo(ctx, glos) -> None: + """ + Delete this query object. + + This is automatically called when the object is garbage collected. + """ + if gl.current_context is None: + return + + for glo in glos: + gl.glDeleteQueries(1, glo) + + ctx.stats.decr("query") diff --git a/arcade/gl/backends/opengl/sampler.py b/arcade/gl/backends/opengl/sampler.py new file mode 100644 index 0000000000..538c71767f --- /dev/null +++ b/arcade/gl/backends/opengl/sampler.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import weakref +from ctypes import byref, c_uint32 +from typing import TYPE_CHECKING + +from pyglet import gl + +from arcade.gl.sampler import Sampler +from arcade.gl.types import PyGLuint, compare_funcs + +if TYPE_CHECKING: + from arcade.gl import Context, Texture2D + + +class OpenGLSampler(Sampler): + """ + OpenGL sampler object. + + When bound to a texture unit it overrides all the + sampling parameters of the texture channel. + """ + + def __init__( + self, + ctx: Context, + texture: Texture2D, + *, + filter: tuple[PyGLuint, PyGLuint] | None = None, + wrap_x: PyGLuint | None = None, + wrap_y: PyGLuint | None = None, + ): + super().__init__(ctx, texture, filter=filter, wrap_x=wrap_x, wrap_y=wrap_y) + self._glo = -1 + + value = c_uint32() + gl.glGenSamplers(1, byref(value)) + self._glo = value.value + + # Default filters for float and integer textures + # Integer textures should have NEAREST interpolation + # by default 3.3 core doesn't really support it consistently. + if "f" in self.texture._dtype: + self._filter = gl.GL_LINEAR, gl.GL_LINEAR + else: + self._filter = gl.GL_NEAREST, gl.GL_NEAREST + + self._wrap_x = gl.GL_REPEAT + self._wrap_y = gl.GL_REPEAT + + # Only set texture parameters on non-multisample textures + if self.texture._samples == 0: + self.filter = filter or self._filter + self.wrap_x = wrap_x or self._wrap_x + self.wrap_y = wrap_y or self._wrap_y + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, OpenGLSampler.delete_glo, self._glo) + + @property + def glo(self) -> PyGLuint: + """The OpenGL sampler id""" + return self._glo + + def use(self, unit: int): + """ + Bind the sampler to a texture unit + """ + gl.glBindSampler(unit, self._glo) + + def clear(self, unit: int): + """ + Unbind the sampler from a texture unit + """ + gl.glBindSampler(unit, 0) + + @Sampler.filter.setter + def filter(self, value: tuple[int, int]): + if not isinstance(value, tuple) or not len(value) == 2: + raise ValueError("Texture filter must be a 2 component tuple (min, mag)") + + self._filter = value + gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_MIN_FILTER, self._filter[0]) + gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_MAG_FILTER, self._filter[1]) + + @Sampler.wrap_x.setter + def wrap_x(self, value: int): + self._wrap_x = value + gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_WRAP_S, value) + + @Sampler.wrap_y.setter + def wrap_y(self, value: int): + self._wrap_y = value + gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_WRAP_T, value) + + @Sampler.anisotropy.setter + def anisotropy(self, value): + self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) + gl.glSamplerParameterf(self._glo, gl.GL_TEXTURE_MAX_ANISOTROPY, self._anisotropy) + + @Sampler.compare_func.setter + def compare_func(self, value: str | None): + if not self.texture._depth: + raise ValueError("Depth comparison function can only be set on depth textures") + + if not isinstance(value, str) and value is not None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + func = compare_funcs.get(value, None) + if func is None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + self._compare_func = value + if value is None: + gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_NONE) + else: + gl.glSamplerParameteri( + self._glo, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_COMPARE_REF_TO_TEXTURE + ) + gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_COMPARE_FUNC, func) + + @staticmethod + def delete_glo(glo: int) -> None: + """ + Delete the OpenGL object + """ + gl.glDeleteSamplers(1, glo) diff --git a/arcade/gl/backends/opengl/texture.py b/arcade/gl/backends/opengl/texture.py new file mode 100644 index 0000000000..13f668e91b --- /dev/null +++ b/arcade/gl/backends/opengl/texture.py @@ -0,0 +1,752 @@ +from __future__ import annotations + +import weakref +from ctypes import byref, string_at +from typing import TYPE_CHECKING + +from pyglet import gl + +from arcade.gl.texture import Texture2D +from arcade.gl.types import ( + BufferOrBufferProtocol, + PyGLuint, + compare_funcs, + pixel_formats, +) +from arcade.types import BufferProtocol + +from .buffer import Buffer +from .utils import data_to_ctypes + +if TYPE_CHECKING: # handle import cycle caused by type hinting + from arcade.gl import Context + +#: Swizzle conversion lookup +swizzle_enum_to_str: dict[int, str] = { + gl.GL_RED: "R", + gl.GL_GREEN: "G", + gl.GL_BLUE: "B", + gl.GL_ALPHA: "A", + gl.GL_ZERO: "0", + gl.GL_ONE: "1", +} + +#: Swizzle conversion lookup +swizzle_str_to_enum: dict[str, int] = { + "R": gl.GL_RED, + "G": gl.GL_GREEN, + "B": gl.GL_BLUE, + "A": gl.GL_ALPHA, + "0": gl.GL_ZERO, + "1": gl.GL_ONE, +} + + +class OpenGLTexture2D(Texture2D): + """ + An OpenGL 2D texture. + We can create an empty black texture or a texture from byte data. + A texture can also be created with different datatypes such as + float, integer or unsigned integer. + + The best way to create a texture instance is through :py:meth:`arcade.gl.Context.texture` + + Supported ``dtype`` values are:: + + # Float formats + 'f1': UNSIGNED_BYTE + 'f2': HALF_FLOAT + 'f4': FLOAT + # int formats + 'i1': BYTE + 'i2': SHORT + 'i4': INT + # uint formats + 'u1': UNSIGNED_BYTE + 'u2': UNSIGNED_SHORT + 'u4': UNSIGNED_INT + + Args: + ctx: + The context the object belongs to + size: + The size of the texture + components: + The number of components (1: R, 2: RG, 3: RGB, 4: RGBA) + dtype: + The data type of each component: f1, f2, f4 / i1, i2, i4 / u1, u2, u4 + data: + The texture data. Can be bytes or any object supporting + the buffer protocol. + filter: + The minification/magnification filter of the texture + wrap_x: + Wrap mode x + wrap_y: + Wrap mode y + target: + The texture type (Ignored. Legacy) + depth: + creates a depth texture if `True` + samples: + Creates a multisampled texture for values > 0. + This value will be clamped between 0 and the max + sample capability reported by the drivers. + immutable: + Make the storage (not the contents) immutable. This can sometimes be + required when using textures with compute shaders. + internal_format: + The internal format of the texture + compressed: + Is the texture compressed? + compressed_data: + The raw compressed data + """ + + __slots__ = ( + "_glo", + "_target", + ) + + def __init__( + self, + ctx: Context, + size: tuple[int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + filter: tuple[PyGLuint, PyGLuint] | None = None, + wrap_x: PyGLuint | None = None, + wrap_y: PyGLuint | None = None, + depth=False, + samples: int = 0, + immutable: bool = False, + internal_format: PyGLuint | None = None, + compressed: bool = False, + compressed_data: bool = False, + ): + super().__init__( + ctx, + size, + components=components, + dtype=dtype, + data=data, + filter=filter, + wrap_x=wrap_x, + wrap_y=wrap_y, + depth=depth, + samples=samples, + immutable=immutable, + internal_format=internal_format, + compressed=compressed, + compressed_data=compressed_data, + ) + self._glo = glo = gl.GLuint() + + # Default filters for float and integer textures + # Integer textures should have NEAREST interpolation + # by default 3.3 core doesn't really support it consistently. + if "f" in self._dtype: + self._filter = gl.GL_LINEAR, gl.GL_LINEAR + else: + self._filter = gl.GL_NEAREST, gl.GL_NEAREST + + self._target = gl.GL_TEXTURE_2D if self._samples == 0 else gl.GL_TEXTURE_2D_MULTISAMPLE + + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glGenTextures(1, byref(self._glo)) + + if self._glo.value == 0: + raise RuntimeError("Cannot create Texture. OpenGL failed to generate a texture id") + + gl.glBindTexture(self._target, self._glo) + + self._texture_2d(data) + + # Only set texture parameters on non-multisample textures + if self._samples == 0: + self.filter = filter or self._filter + self.wrap_x = wrap_x or self._wrap_x + self.wrap_y = wrap_y or self._wrap_y + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, OpenGLTexture2D.delete_glo, self._ctx, glo) + + def resize(self, size: tuple[int, int]): + """ + Resize the texture. This will re-allocate the internal + memory and all pixel data will be lost. + + .. note:: Immutable textures cannot be resized. + + Args: + size: The new size of the texture + """ + if self._immutable: + raise ValueError("Immutable textures cannot be resized") + + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + + self._width, self._height = size + + self._texture_2d(None) + + def __del__(self): + # Intercept garbage collection if we are using Context.gc() + if self._ctx.gc_mode == "context_gc" and self._glo.value > 0: + self._ctx.objects.append(self) + + def _texture_2d(self, data): + """Create a 2D texture""" + # Start by resolving the texture format + try: + format_info = pixel_formats[self._dtype] + except KeyError: + raise ValueError( + f"dype '{self._dtype}' not support. Supported types are : " + f"{tuple(pixel_formats.keys())}" + ) + _format, _internal_format, self._type, self._component_size = format_info + if data is not None: + byte_length, data = data_to_ctypes(data) + self._validate_data_size(data, byte_length, self._width, self._height) + + # If we are dealing with a multisampled texture we have less options + if self._target == gl.GL_TEXTURE_2D_MULTISAMPLE: + gl.glTexImage2DMultisample( + self._target, + self._samples, + _internal_format[self._components], + self._width, + self._height, + True, # Fixed sample locations + ) + return + + # Make sure we unpack the pixel data with correct alignment + # or we'll end up with corrupted textures + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, self._alignment) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, self._alignment) + + # Create depth 2d texture + if self._depth: + gl.glTexImage2D( + self._target, + 0, # level + gl.GL_DEPTH_COMPONENT24, + self._width, + self._height, + 0, + gl.GL_DEPTH_COMPONENT, + gl.GL_UNSIGNED_INT, # gl.GL_FLOAT, + data, + ) + self.compare_func = "<=" + # Create normal 2d texture + else: + try: + self._format = _format[self._components] + if self._internal_format is None: + self._internal_format = _internal_format[self._components] + + if self._immutable: + # Specify immutable storage for this texture. + # glTexStorage2D can only be called once + gl.glTexStorage2D( + self._target, + 1, # Levels + self._internal_format, + self._width, + self._height, + ) + if data: + self.write(data) + else: + # glTexImage2D can be called multiple times to re-allocate storage + # Specify mutable storage for this texture. + if self._compressed_data is True: + gl.glCompressedTexImage2D( + self._target, # target + 0, # level + self._internal_format, # internal_format + self._width, # width + self._height, # height + 0, # border + len(data), # size + data, # data + ) + else: + gl.glTexImage2D( + self._target, # target + 0, # level + self._internal_format, # internal_format + self._width, # width + self._height, # height + 0, # border + self._format, # format + self._type, # type + data, # data + ) + except gl.GLException as ex: + raise gl.GLException( + ( + f"Unable to create texture: {ex} : dtype={self._dtype} " + f"size={self.size} components={self._components} " + f"MAX_TEXTURE_SIZE = {self.ctx.info.MAX_TEXTURE_SIZE}" + f": {ex}" + ) + ) + + @property + def ctx(self) -> Context: + """The context this texture belongs to.""" + return self._ctx + + @property + def glo(self) -> gl.GLuint: + """The OpenGL texture id""" + return self._glo + + @property + def compressed(self) -> bool: + """Is this using a compressed format?""" + return self._compressed + + @property + def width(self) -> int: + """The width of the texture in pixels""" + return self._width + + @property + def height(self) -> int: + """The height of the texture in pixels""" + return self._height + + @property + def dtype(self) -> str: + """The data type of each component""" + return self._dtype + + @property + def size(self) -> tuple[int, int]: + """The size of the texture as a tuple""" + return self._width, self._height + + @property + def samples(self) -> int: + """Number of samples if multisampling is enabled (read only)""" + return self._samples + + @property + def byte_size(self) -> int: + """The byte size of the texture.""" + return pixel_formats[self._dtype][3] * self._components * self.width * self.height + + @property + def components(self) -> int: + """Number of components in the texture""" + return self._components + + @property + def component_size(self) -> int: + """Size in bytes of each component""" + return self._component_size + + @property + def depth(self) -> bool: + """If this is a depth texture.""" + return self._depth + + @property + def immutable(self) -> bool: + """Does this texture have immutable storage?""" + return self._immutable + + @property + def swizzle(self) -> str: + """ + The swizzle mask of the texture (Default ``'RGBA'``). + + The swizzle mask change/reorder the ``vec4`` value returned by the ``texture()`` function + in a GLSL shaders. This is represented by a 4 character string were each + character can be:: + + 'R' GL_RED + 'G' GL_GREEN + 'B' GL_BLUE + 'A' GL_ALPHA + '0' GL_ZERO + '1' GL_ONE + + Example:: + + # Alpha channel will always return 1.0 + texture.swizzle = 'RGB1' + + # Only return the red component. The rest is masked to 0.0 + texture.swizzle = 'R000' + + # Reverse the components + texture.swizzle = 'ABGR' + """ + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + + # Read the current swizzle values from the texture + swizzle_r = gl.GLint() + swizzle_g = gl.GLint() + swizzle_b = gl.GLint() + swizzle_a = gl.GLint() + + gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_r) + gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_g) + gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_b) + gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_a) + + swizzle_str = "" + for v in [swizzle_r, swizzle_g, swizzle_b, swizzle_a]: + swizzle_str += swizzle_enum_to_str[v.value] + + return swizzle_str + + @swizzle.setter + def swizzle(self, value: str): + if not isinstance(value, str): + raise ValueError(f"Swizzle must be a string, not '{type(str)}'") + + if len(value) != 4: + raise ValueError("Swizzle must be a string of length 4") + + swizzle_enums = [] + for c in value: + try: + c = c.upper() + swizzle_enums.append(swizzle_str_to_enum[c]) + except KeyError: + raise ValueError(f"Swizzle value '{c}' invalid. Must be one of RGBA01") + + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + + gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_enums[0]) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_enums[1]) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_enums[2]) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_enums[3]) + + @Texture2D.filter.setter + def filter(self, value: tuple[int, int]): + if not isinstance(value, tuple) or not len(value) == 2: + raise ValueError("Texture filter must be a 2 component tuple (min, mag)") + + self._filter = value + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_MIN_FILTER, self._filter[0]) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_MAG_FILTER, self._filter[1]) + + @Texture2D.wrap_x.setter + def wrap_x(self, value: int): + self._wrap_x = value + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_S, value) + + @Texture2D.wrap_y.setter + def wrap_y(self, value: int): + self._wrap_y = value + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_T, value) + + @Texture2D.anisotropy.setter + def anisotropy(self, value): + self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glTexParameterf(self._target, gl.GL_TEXTURE_MAX_ANISOTROPY, self._anisotropy) + + @Texture2D.compare_func.setter + def compare_func(self, value: str | None): + if not self._depth: + raise ValueError("Depth comparison function can only be set on depth textures") + + if not isinstance(value, str) and value is not None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + func = compare_funcs.get(value, None) + if func is None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + self._compare_func = value + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + if value is None: + gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_NONE) + else: + gl.glTexParameteri( + self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_COMPARE_REF_TO_TEXTURE + ) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_FUNC, func) + + def read(self, level: int = 0, alignment: int = 1) -> bytes: + """ + Read the contents of the texture. + + Args: + level: + The texture level to read + alignment: + Alignment of the start of each row in memory in number of bytes. + Possible values: 1,2,4 + """ + if self._samples > 0: + raise ValueError("Multisampled textures cannot be read directly") + + if self._ctx.gl_api == "opengl": + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, alignment) + + buffer = ( + gl.GLubyte * (self.width * self.height * self._component_size * self._components) + )() + gl.glGetTexImage(gl.GL_TEXTURE_2D, level, self._format, self._type, buffer) + return string_at(buffer, len(buffer)) + elif self._ctx.gl_api == "opengles": + fbo = self._ctx.framebuffer(color_attachments=[self]) + return fbo.read(components=self._components, dtype=self._dtype) + else: + raise ValueError("Unknown gl_api: '{self._ctx.gl_api}'") + + def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> None: + """Write byte data from the passed source to the texture. + + The ``data`` value can be either an + :py:class:`arcade.gl.Buffer` or anything that implements the + `Buffer Protocol `_. + + The latter category includes ``bytes``, ``bytearray``, + ``array.array``, and more. You may need to use typing + workarounds for non-builtin types. See + :ref:`prog-guide-gl-buffer-protocol-typing` for more + information. + + Args: + data: + :class:`~arcade.gl.Buffer` or buffer protocol object with data to write. + level: + The texture level to write + viewport: + The area of the texture to write. 2 or 4 component tuple. + (x, y, w, h) or (w, h). Default is the full texture. + """ + # TODO: Support writing to layers using viewport + alignment + if self._samples > 0: + raise ValueError("Writing to multisampled textures not supported") + + x, y, w, h = 0, 0, self._width, self._height + if viewport: + if len(viewport) == 2: + w, h = viewport + elif len(viewport) == 4: + x, y, w, h = viewport + else: + raise ValueError("Viewport must be of length 2 or 4") + + if isinstance(data, Buffer): + gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, data.glo) + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) + gl.glTexSubImage2D(self._target, level, x, y, w, h, self._format, self._type, 0) + gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, 0) + else: + byte_size, data = data_to_ctypes(data) + self._validate_data_size(data, byte_size, w, h) + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) + gl.glTexSubImage2D( + self._target, # target + level, # level + x, # x offset + y, # y offset + w, # width + h, # height + self._format, # format + self._type, # type + data, # pixel data + ) + + def _validate_data_size(self, byte_data, byte_size, width, height) -> None: + """Validate the size of the data to be written to the texture""" + # TODO: Validate data size for compressed textures + # This might be a bit tricky since the size of the compressed + # data would depend on the algorithm used. + if self._compressed is True: + return + + expected_size = width * height * self._component_size * self._components + if byte_size != expected_size: + raise ValueError( + f"Data size {len(byte_data)} does not match expected size {expected_size}" + ) + if len(byte_data) != byte_size: + raise ValueError( + f"Data size {len(byte_data)} does not match reported size {expected_size}" + ) + + def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: + """Generate mipmaps for this texture. + + The default values usually work well. + + Mipmaps are successively smaller versions of an original + texture with special filtering applied. Using mipmaps allows + OpenGL to render scaled versions of original textures with fewer + scaling artifacts. + + Mipmaps can be made for textures of any size. Each mipmap + version halves the width and height of the previous one (e.g. + 256 x 256, 128 x 128, 64 x 64, etc) down to a minimum of 1 x 1. + + .. note:: Mipmaps will only be used if a texture's filter is + configured with a mipmap-type minification:: + + # Set up linear interpolating minification filter + texture.filter = ctx.LINEAR_MIPMAP_LINEAR, ctx.LINEAR + + Args: + base: + Level the mipmaps start at (usually 0) + max_level: + The maximum number of levels to generate + + Also see: https://www.khronos.org/opengl/wiki/Texture#Mip_maps + """ + if self._samples > 0: + raise ValueError("Multisampled textures don't support mimpmaps") + + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(gl.GL_TEXTURE_2D, self._glo) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_BASE_LEVEL, base) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAX_LEVEL, max_level) + gl.glGenerateMipmap(gl.GL_TEXTURE_2D) + + def delete(self): + """ + Destroy the underlying OpenGL resource. + + Don't use this unless you know exactly what you are doing. + """ + OpenGLTexture2D.delete_glo(self._ctx, self._glo) + self._glo.value = 0 + + @staticmethod + def delete_glo(ctx: "Context", glo: gl.GLuint): + """ + Destroy the texture. + + This is called automatically when the object is garbage collected. + + Args: + ctx: OpenGL Context + glo: The OpenGL texture id + """ + # If we have no context, then we are shutting down, so skip this + if gl.current_context is None: + return + + if glo.value != 0: + gl.glDeleteTextures(1, byref(glo)) + + ctx.stats.decr("texture") + + def use(self, unit: int = 0) -> None: + """Bind the texture to a channel, + + Args: + unit: The texture unit to bind the texture. + """ + gl.glActiveTexture(gl.GL_TEXTURE0 + unit) + gl.glBindTexture(self._target, self._glo) + + def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: int = 0): + """ + Bind textures to image units. + + Note that either or both ``read`` and ``write`` needs to be ``True``. + The supported modes are: read only, write only, read-write + + Args: + unit: The image unit + read: The compute shader intends to read from this image + write: The compute shader intends to write to this image + level: The mipmap level to bind + """ + if self._ctx.gl_api == "opengles" and not self._immutable: + raise ValueError("Textures bound to image units must be created with immutable=True") + + access = gl.GL_READ_WRITE + if read and write: + access = gl.GL_READ_WRITE + elif read and not write: + access = gl.GL_READ_ONLY + elif not read and write: + access = gl.GL_WRITE_ONLY + else: + raise ValueError("Illegal access mode. The texture must at least be read or write only") + + gl.glBindImageTexture(unit, self._glo, level, 0, 0, access, self._internal_format) + + def get_handle(self, resident: bool = True) -> int: + """ + Get a handle for bindless texture access. + + Once a handle is created its parameters cannot be changed. + Attempting to do so will have no effect. (filter, wrap etc). + There is no way to undo this immutability. + + Handles cannot be used by shaders until they are resident. + This method can be called multiple times to move a texture + in and out of residency:: + + >> texture.get_handle(resident=False) + 4294969856 + >> texture.get_handle(resident=True) + 4294969856 + + Ths same handle is returned if the handle already exists. + + .. note:: Limitations from the OpenGL wiki + + The amount of storage available for resident images/textures may be less + than the total storage for textures that is available. As such, you should + attempt to minimize the time a texture spends being resident. Do not attempt + to take steps like making textures resident/un-resident every frame or something. + But if you are finished using a texture for some time, make it un-resident. + + Args: + resident: Make the texture resident. + """ + handle = gl.glGetTextureHandleARB(self._glo) + is_resident = gl.glIsTextureHandleResidentARB(handle) + + # Ensure we don't try to make a resident texture resident again + if resident: + if not is_resident: + gl.glMakeTextureHandleResidentARB(handle) + else: + if is_resident: + gl.glMakeTextureHandleNonResidentARB(handle) + + return handle + + def __repr__(self) -> str: + return "".format( + self._glo.value, self._width, self._height, self._components + ) diff --git a/arcade/gl/backends/opengl/texture_array.py b/arcade/gl/backends/opengl/texture_array.py new file mode 100644 index 0000000000..8b1456989d --- /dev/null +++ b/arcade/gl/backends/opengl/texture_array.py @@ -0,0 +1,696 @@ +from __future__ import annotations + +import weakref +from ctypes import byref, string_at +from typing import TYPE_CHECKING + +from pyglet import gl + +from arcade.gl.texture_array import TextureArray +from arcade.gl.types import ( + BufferOrBufferProtocol, + PyGLuint, + compare_funcs, + pixel_formats, +) +from arcade.types import BufferProtocol + +from .buffer import Buffer +from .utils import data_to_ctypes + +if TYPE_CHECKING: # handle import cycle caused by type hinting + from arcade.gl import Context + +#: Swizzle conversion lookup +swizzle_enum_to_str: dict[int, str] = { + gl.GL_RED: "R", + gl.GL_GREEN: "G", + gl.GL_BLUE: "B", + gl.GL_ALPHA: "A", + gl.GL_ZERO: "0", + gl.GL_ONE: "1", +} + +#: Swizzle conversion lookup +swizzle_str_to_enum: dict[str, int] = { + "R": gl.GL_RED, + "G": gl.GL_GREEN, + "B": gl.GL_BLUE, + "A": gl.GL_ALPHA, + "0": gl.GL_ZERO, + "1": gl.GL_ONE, +} + + +class OpenGLTextureArray(TextureArray): + """ + An OpenGL 2D texture array. + + We can create an empty black texture or a texture from byte data. + A texture can also be created with different datatypes such as + float, integer or unsigned integer. + + The best way to create a texture instance is through :py:meth:`arcade.gl.Context.texture` + + Supported ``dtype`` values are:: + + # Float formats + 'f1': UNSIGNED_BYTE + 'f2': HALF_FLOAT + 'f4': FLOAT + # int formats + 'i1': BYTE + 'i2': SHORT + 'i4': INT + # uint formats + 'u1': UNSIGNED_BYTE + 'u2': UNSIGNED_SHORT + 'u4': UNSIGNED_INT + + Args: + ctx: + The context the object belongs to + size: + The size of the texture (width, height, layers) + components: + The number of components (1: R, 2: RG, 3: RGB, 4: RGBA) + dtype: + The data type of each component: f1, f2, f4 / i1, i2, i4 / u1, u2, u4 + data: + The texture data. Can be bytes or any object supporting + the buffer protocol. + filter: + The minification/magnification filter of the texture + wrap_x: + Wrap mode x + wrap_y: + Wrap mode y + target: + The texture type (Ignored. Legacy) + depth: + creates a depth texture if `True` + samples: + Creates a multisampled texture for values > 0. + This value will be clamped between 0 and the max + sample capability reported by the drivers. + immutable: + Make the storage (not the contents) immutable. This can sometimes be + required when using textures with compute shaders. + internal_format: + The internal format of the texture + compressed: + Is the texture compressed? + compressed_data: + The raw compressed data + """ + + __slots__ = ( + "_glo", + "_target", + ) + + def __init__( + self, + ctx: Context, + size: tuple[int, int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + filter: tuple[PyGLuint, PyGLuint] | None = None, + wrap_x: PyGLuint | None = None, + wrap_y: PyGLuint | None = None, + depth=False, + samples: int = 0, + immutable: bool = False, + internal_format: PyGLuint | None = None, + compressed: bool = False, + compressed_data: bool = False, + ): + super().__init__( + ctx, + size, + components=components, + dtype=dtype, + data=data, + filter=filter, + wrap_x=wrap_x, + wrap_y=wrap_y, + depth=depth, + samples=samples, + immutable=immutable, + internal_format=internal_format, + compressed=compressed, + compressed_data=compressed_data, + ) + self._glo = glo = gl.GLuint() + + # Default filters for float and integer textures + # Integer textures should have NEAREST interpolation + # by default 3.3 core doesn't really support it consistently. + if "f" in self._dtype: + self._filter = gl.GL_LINEAR, gl.GL_LINEAR + else: + self._filter = gl.GL_NEAREST, gl.GL_NEAREST + self._wrap_x = gl.GL_REPEAT + self._wrap_y = gl.GL_REPEAT + + self._target = ( + gl.GL_TEXTURE_2D_ARRAY if self._samples == 0 else gl.GL_TEXTURE_2D_MULTISAMPLE_ARRAY + ) + + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glGenTextures(1, byref(self._glo)) + + if self._glo.value == 0: + raise RuntimeError("Cannot create Texture. OpenGL failed to generate a texture id") + + gl.glBindTexture(self._target, self._glo) + + self._texture_2d_array(data) + + # Only set texture parameters on non-multisample textures + if self._samples == 0: + self.filter = filter or self._filter + self.wrap_x = wrap_x or self._wrap_x + self.wrap_y = wrap_y or self._wrap_y + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, OpenGLTextureArray.delete_glo, self._ctx, glo) + + def resize(self, size: tuple[int, int]): + """ + Resize the texture. This will re-allocate the internal + memory and all pixel data will be lost. + + .. note:: Immutable textures cannot be resized. + + Args: + size: The new size of the texture + """ + if self._immutable: + raise ValueError("Immutable textures cannot be resized") + + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + + self._width, self._height = size + + self._texture_2d_array(None) + + def __del__(self): + # Intercept garbage collection if we are using Context.gc() + if self._ctx.gc_mode == "context_gc" and self._glo.value > 0: + self._ctx.objects.append(self) + + def _texture_2d_array(self, data): + """Create a 2D texture""" + # Start by resolving the texture format + try: + format_info = pixel_formats[self._dtype] + except KeyError: + raise ValueError( + f"dype '{self._dtype}' not support. Supported types are : " + f"{tuple(pixel_formats.keys())}" + ) + _format, _internal_format, self._type, self._component_size = format_info + if data is not None: + byte_length, data = data_to_ctypes(data) + self._validate_data_size(data, byte_length, self._width, self._height, self._layers) + + # If we are dealing with a multisampled texture we have less options + if self._target == gl.GL_TEXTURE_2D_MULTISAMPLE_ARRAY: + gl.glTexImage3DMultisample( + self._target, + self._samples, + _internal_format[self._components], + self._width, + self._height, + self._layers, + True, # Fixed sample locations + ) + return + + # Make sure we unpack the pixel data with correct alignment + # or we'll end up with corrupted textures + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, self._alignment) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, self._alignment) + + # Create depth 2d texture + if self._depth: + gl.glTexImage3D( + self._target, + 0, # level + gl.GL_DEPTH_COMPONENT24, + self._width, + self._height, + self._layers, + 0, + gl.GL_DEPTH_COMPONENT, + gl.GL_UNSIGNED_INT, # gl.GL_FLOAT, + data, + ) + self.compare_func = "<=" + # Create normal 2d texture + else: + try: + self._format = _format[self._components] + if self._internal_format is None: + self._internal_format = _internal_format[self._components] + + if self._immutable: + # Specify immutable storage for this texture. + # glTexStorage2D can only be called once + gl.glTexStorage3D( + self._target, + 1, # Levels + self._internal_format, + self._width, + self._height, + self._layers, + ) + if data: + self.write(data) + else: + # glTexImage2D can be called multiple times to re-allocate storage + # Specify mutable storage for this texture. + if self._compressed_data is True: + gl.glCompressedTexImage3D( + self._target, # target + 0, # level + self._internal_format, # internal_format + self._width, # width + self._height, # height + self._layers, # layers + 0, # border + len(data), # size + data, # data + ) + else: + gl.glTexImage3D( + self._target, # target + 0, # level + self._internal_format, # internal_format + self._width, # width + self._height, # height + self._layers, # layers + 0, # border + self._format, # format + self._type, # type + data, # data + ) + except gl.GLException as ex: + raise gl.GLException( + ( + f"Unable to create texture: {ex} : dtype={self._dtype} " + f"size={self.size} components={self._components} " + f"MAX_TEXTURE_SIZE = {self.ctx.info.MAX_TEXTURE_SIZE}" + f": {ex}" + ) + ) + + @property + def glo(self) -> gl.GLuint: + """The OpenGL texture id""" + return self._glo + + @property + def swizzle(self) -> str: + """ + The swizzle mask of the texture (Default ``'RGBA'``). + + The swizzle mask change/reorder the ``vec4`` value returned by the ``texture()`` function + in a GLSL shaders. This is represented by a 4 character string were each + character can be:: + + 'R' GL_RED + 'G' GL_GREEN + 'B' GL_BLUE + 'A' GL_ALPHA + '0' GL_ZERO + '1' GL_ONE + + Example:: + + # Alpha channel will always return 1.0 + texture.swizzle = 'RGB1' + + # Only return the red component. The rest is masked to 0.0 + texture.swizzle = 'R000' + + # Reverse the components + texture.swizzle = 'ABGR' + """ + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + + # Read the current swizzle values from the texture + swizzle_r = gl.GLint() + swizzle_g = gl.GLint() + swizzle_b = gl.GLint() + swizzle_a = gl.GLint() + + gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_r) + gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_g) + gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_b) + gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_a) + + swizzle_str = "" + for v in [swizzle_r, swizzle_g, swizzle_b, swizzle_a]: + swizzle_str += swizzle_enum_to_str[v.value] + + return swizzle_str + + @swizzle.setter + def swizzle(self, value: str): + if not isinstance(value, str): + raise ValueError(f"Swizzle must be a string, not '{type(str)}'") + + if len(value) != 4: + raise ValueError("Swizzle must be a string of length 4") + + swizzle_enums = [] + for c in value: + try: + c = c.upper() + swizzle_enums.append(swizzle_str_to_enum[c]) + except KeyError: + raise ValueError(f"Swizzle value '{c}' invalid. Must be one of RGBA01") + + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + + gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_enums[0]) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_enums[1]) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_enums[2]) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_enums[3]) + + @TextureArray.filter.setter + def filter(self, value: tuple[int, int]): + if not isinstance(value, tuple) or not len(value) == 2: + raise ValueError("Texture filter must be a 2 component tuple (min, mag)") + + self._filter = value + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_MIN_FILTER, self._filter[0]) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_MAG_FILTER, self._filter[1]) + + @TextureArray.wrap_x.setter + def wrap_x(self, value: int): + self._wrap_x = value + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_S, value) + + @TextureArray.wrap_y.setter + def wrap_y(self, value: int): + self._wrap_y = value + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_T, value) + + @TextureArray.anisotropy.setter + def anisotropy(self, value): + self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glTexParameterf(self._target, gl.GL_TEXTURE_MAX_ANISOTROPY, self._anisotropy) + + @TextureArray.compare_func.setter + def compare_func(self, value: str | None): + if not self._depth: + raise ValueError("Depth comparison function can only be set on depth textures") + + if not isinstance(value, str) and value is not None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + func = compare_funcs.get(value, None) + if func is None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + self._compare_func = value + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + if value is None: + gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_NONE) + else: + gl.glTexParameteri( + self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_COMPARE_REF_TO_TEXTURE + ) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_FUNC, func) + + def read(self, level: int = 0, alignment: int = 1) -> bytes: + """ + Read the contents of the texture. + + Args: + level: + The texture level to read + alignment: + Alignment of the start of each row in memory in number of bytes. + Possible values: 1,2,4 + """ + if self._samples > 0: + raise ValueError("Multisampled textures cannot be read directly") + + if self._ctx.gl_api == "opengl": + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, alignment) + + buffer = ( + gl.GLubyte + * (self.width * self.height * self.layers * self._component_size * self._components) + )() + gl.glGetTexImage(self._target, level, self._format, self._type, buffer) + return string_at(buffer, len(buffer)) + elif self._ctx.gl_api == "opengles": + # FIXME: Check if we can attach a layer to the framebuffer. See Texture2D.read() + raise ValueError("Reading texture array data not supported in GLES yet") + else: + raise ValueError("Unknown gl_api: '{self._ctx.gl_api}'") + + def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> None: + """Write byte data into layers of the texture. + + The ``data`` value can be either an + :py:class:`arcade.gl.Buffer` or anything that implements the + `Buffer Protocol `_. + + The latter category includes ``bytes``, ``bytearray``, + ``array.array``, and more. You may need to use typing + workarounds for non-builtin types. See + :ref:`prog-guide-gl-buffer-protocol-typing` for more + information. + + Args: + data: + :class:`~arcade.gl.Buffer` or buffer protocol object with data to write. + level: + The texture level to write (LoD level, now layer) + viewport: + The area of the texture to write. Should be a 3 or 5-component tuple + `(x, y, layer, width, height)` writes to an area of a single layer. + If not provided the entire texture is written to. + """ + # TODO: Support writing to layers using viewport + alignment + if self._samples > 0: + raise ValueError("Writing to multisampled textures not supported") + + x, y, l, w, h = ( + 0, + 0, + 0, + self._width, + self._height, + ) + if viewport: + # TODO: Add more options here. For now we support writing to a single layer + # (width, hight, num_layers) is a suggestion from moderngl + # if len(viewport) == 3: + # w, h, l = viewport + if len(viewport) == 5: + x, y, l, w, h = viewport + else: + raise ValueError("Viewport must be of length 5") + + if isinstance(data, Buffer): + gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, data.glo) + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) + gl.glTexSubImage3D(self._target, level, x, y, w, h, l, self._format, self._type, 0) + gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, 0) + else: + byte_size, data = data_to_ctypes(data) + self._validate_data_size(data, byte_size, w, h, 1) # Single layer + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) + gl.glTexSubImage3D( + self._target, # target + level, # level + x, # x offset + y, # y offset + l, # layer + w, # width + h, # height + 1, # depth (one layer) + self._format, # format + self._type, # type + data, # pixel data + ) + + def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: + """Generate mipmaps for this texture. + + The default values usually work well. + + Mipmaps are successively smaller versions of an original + texture with special filtering applied. Using mipmaps allows + OpenGL to render scaled versions of original textures with fewer + scaling artifacts. + + Mipmaps can be made for textures of any size. Each mipmap + version halves the width and height of the previous one (e.g. + 256 x 256, 128 x 128, 64 x 64, etc) down to a minimum of 1 x 1. + + .. note:: Mipmaps will only be used if a texture's filter is + configured with a mipmap-type minification:: + + # Set up linear interpolating minification filter + texture.filter = ctx.LINEAR_MIPMAP_LINEAR, ctx.LINEAR + + Args: + base: + Level the mipmaps start at (usually 0) + max_level: + The maximum number of levels to generate + + Also see: https://www.khronos.org/opengl/wiki/Texture#Mip_maps + """ + if self._samples > 0: + raise ValueError("Multisampled textures don't support mimpmaps") + + gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) + gl.glBindTexture(self._target, self._glo) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_BASE_LEVEL, base) + gl.glTexParameteri(self._target, gl.GL_TEXTURE_MAX_LEVEL, max_level) + gl.glGenerateMipmap(self._target) + + def delete(self): + """ + Destroy the underlying OpenGL resource. + + Don't use this unless you know exactly what you are doing. + """ + self.delete_glo(self._ctx, self._glo) + self._glo.value = 0 + + @staticmethod + def delete_glo(ctx: "Context", glo: gl.GLuint): + """ + Destroy the texture. + + This is called automatically when the object is garbage collected. + + Args: + ctx: OpenGL Context + glo: The OpenGL texture id + """ + # If we have no context, then we are shutting down, so skip this + if gl.current_context is None: + return + + if glo.value != 0: + gl.glDeleteTextures(1, byref(glo)) + + ctx.stats.decr("texture") + + def use(self, unit: int = 0) -> None: + """Bind the texture to a channel, + + Args: + unit: The texture unit to bind the texture. + """ + gl.glActiveTexture(gl.GL_TEXTURE0 + unit) + gl.glBindTexture(self._target, self._glo) + + def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: int = 0): + """ + Bind textures to image units. + + Note that either or both ``read`` and ``write`` needs to be ``True``. + The supported modes are: read only, write only, read-write + + Args: + unit: The image unit + read: The compute shader intends to read from this image + write: The compute shader intends to write to this image + level: The mipmap level to bind + """ + if self._ctx.gl_api == "opengles" and not self._immutable: + raise ValueError("Textures bound to image units must be created with immutable=True") + + access = gl.GL_READ_WRITE + if read and write: + access = gl.GL_READ_WRITE + elif read and not write: + access = gl.GL_READ_ONLY + elif not read and write: + access = gl.GL_WRITE_ONLY + else: + raise ValueError("Illegal access mode. The texture must at least be read or write only") + + gl.glBindImageTexture(unit, self._glo, level, 0, 0, access, self._internal_format) + + def get_handle(self, resident: bool = True) -> int: + """ + Get a handle for bindless texture access. + + Once a handle is created its parameters cannot be changed. + Attempting to do so will have no effect. (filter, wrap etc). + There is no way to undo this immutability. + + Handles cannot be used by shaders until they are resident. + This method can be called multiple times to move a texture + in and out of residency:: + + >> texture.get_handle(resident=False) + 4294969856 + >> texture.get_handle(resident=True) + 4294969856 + + Ths same handle is returned if the handle already exists. + + .. note:: Limitations from the OpenGL wiki + + The amount of storage available for resident images/textures may be less + than the total storage for textures that is available. As such, you should + attempt to minimize the time a texture spends being resident. Do not attempt + to take steps like making textures resident/un-resident every frame or something. + But if you are finished using a texture for some time, make it un-resident. + + Args: + resident: Make the texture resident. + """ + handle = gl.glGetTextureHandleARB(self._glo) + is_resident = gl.glIsTextureHandleResidentARB(handle) + + # Ensure we don't try to make a resident texture resident again + if resident: + if not is_resident: + gl.glMakeTextureHandleResidentARB(handle) + else: + if is_resident: + gl.glMakeTextureHandleNonResidentARB(handle) + + return handle + + def __repr__(self) -> str: + return "".format( + self._glo.value, self._width, self._layers, self._height, self._components + ) diff --git a/arcade/gl/uniform.py b/arcade/gl/backends/opengl/uniform.py similarity index 99% rename from arcade/gl/uniform.py rename to arcade/gl/backends/opengl/uniform.py index 87d9e3a264..f664bc320d 100644 --- a/arcade/gl/uniform.py +++ b/arcade/gl/backends/opengl/uniform.py @@ -4,7 +4,7 @@ from pyglet import gl -from .exceptions import ShaderException +from arcade.gl.exceptions import ShaderException class Uniform: diff --git a/arcade/gl/utils.py b/arcade/gl/backends/opengl/utils.py similarity index 100% rename from arcade/gl/utils.py rename to arcade/gl/backends/opengl/utils.py diff --git a/arcade/gl/backends/opengl/vertex_array.py b/arcade/gl/backends/opengl/vertex_array.py new file mode 100644 index 0000000000..27293978e0 --- /dev/null +++ b/arcade/gl/backends/opengl/vertex_array.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +import weakref +from ctypes import byref, c_void_p +from typing import TYPE_CHECKING, Sequence + +from pyglet import gl + +from arcade.gl.types import BufferDescription, GLenumLike, GLuintLike, gl_name +from arcade.gl.vertex_array import Geometry, VertexArray + +from .buffer import Buffer +from .program import Program + +if TYPE_CHECKING: + from arcade.gl import Context + +# Index buffer types based on index element size +index_types = [ + None, # 0 (not supported) + gl.GL_UNSIGNED_BYTE, # 1 ubyte8 + gl.GL_UNSIGNED_SHORT, # 2 ubyte16 + None, # 3 (not supported) + gl.GL_UNSIGNED_INT, # 4 ubyte32 +] + + +class OpenGLVertexArray(VertexArray): + """ + Wrapper for Vertex Array Objects (VAOs). + + This objects should not be instantiated from user code. + Use :py:class:`arcade.gl.Geometry` instead. It will create VAO instances for you + automatically. There is a lot of complex interaction between programs + and vertex arrays that will be done for you automatically. + + Args: + ctx: + The context this object belongs to + program: + The program to use + content: + List of BufferDescriptions + index_buffer: + Index/element buffer + index_element_size: + Byte size of the index buffer datatype. + """ + + __slots__ = ( + "glo", + "_index_element_type", + ) + + def __init__( + self, + ctx: Context, + program: Program, + content: Sequence[BufferDescription], + index_buffer: Buffer | None = None, + index_element_size: int = 4, + ) -> None: + super().__init__(ctx, program, content, index_buffer, index_element_size) + + self.glo = glo = gl.GLuint() + """The OpenGL resource ID""" + + self._index_element_type = index_types[index_element_size] + + self._build(program, content, index_buffer) + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, OpenGLVertexArray.delete_glo, self.ctx, glo) + + def __repr__(self) -> str: + return f"" + + def __del__(self) -> None: + # Intercept garbage collection if we are using Context.gc() + if self._ctx.gc_mode == "context_gc" and self.glo.value > 0: + self._ctx.objects.append(self) + + def delete(self) -> None: + """ + Destroy the underlying OpenGL resource. + + Don't use this unless you know exactly what you are doing. + """ + OpenGLVertexArray.delete_glo(self._ctx, self.glo) + self.glo.value = 0 + + @staticmethod + def delete_glo(ctx: Context, glo: gl.GLuint) -> None: + """ + Delete the OpenGL resource. + + This is automatically called when this object is garbage collected. + """ + # If we have no context, then we are shutting down, so skip this + if gl.current_context is None: + return + + if glo.value != 0: + gl.glDeleteVertexArrays(1, byref(glo)) + glo.value = 0 + + ctx.stats.decr("vertex_array") + + def _build( + self, program: Program, content: Sequence[BufferDescription], index_buffer: Buffer | None + ) -> None: + """ + Build a vertex array compatible with the program passed in. + + This method will bind the vertex array and set up all the vertex attributes + according to the program's attribute specifications. + + Args: + program: + The program to use + content: + List of BufferDescriptions + index_buffer: + Index/element buffer + """ + gl.glGenVertexArrays(1, byref(self.glo)) + gl.glBindVertexArray(self.glo) + + if index_buffer is not None: + gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, index_buffer.glo) + + # Lookup dict for BufferDescription attrib names + descr_attribs = {attr.name: (descr, attr) for descr in content for attr in descr.formats} + + # Build the vao according to the shader's attribute specifications + for _, prog_attr in enumerate(program.attributes): + # Do we actually have an attribute with this name in buffer descriptions? + if prog_attr.name is not None and prog_attr.name.startswith("gl_"): + continue + try: + buff_descr, attr_descr = descr_attribs[prog_attr.name] + except KeyError: + raise ValueError( + ( + f"Program needs attribute '{prog_attr.name}', but is not present in buffer " + f"description. Buffer descriptions: {content}" + ) + ) + + # Make sure components described in BufferDescription and in the shader match + if prog_attr.components != attr_descr.components: + raise ValueError( + ( + f"Program attribute '{prog_attr.name}' has {prog_attr.components} " + f"components while the buffer description has {attr_descr.components} " + " components. " + ) + ) + + gl.glEnableVertexAttribArray(prog_attr.location) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, buff_descr.buffer.glo) + + # TODO: Detect normalization + normalized = gl.GL_TRUE if attr_descr.name in buff_descr.normalized else gl.GL_FALSE + + # Map attributes groups + float_types = (gl.GL_FLOAT, gl.GL_HALF_FLOAT) + double_types = (gl.GL_DOUBLE,) + int_types = ( + gl.GL_INT, + gl.GL_UNSIGNED_INT, + gl.GL_SHORT, + gl.GL_UNSIGNED_SHORT, + gl.GL_BYTE, + gl.GL_UNSIGNED_BYTE, + ) + attrib_type = attr_descr.gl_type + # Normalized integers must be mapped as floats + if attrib_type in int_types and buff_descr.normalized: + attrib_type = prog_attr.gl_type + + # Sanity check attribute types between shader and buffer description + if attrib_type != prog_attr.gl_type: + raise ValueError( + ( + f"Program attribute '{prog_attr.name}' has type " + f"{gl_name(prog_attr.gl_type)} " + f"while the buffer description has type {gl_name(attr_descr.gl_type)}. " + ) + ) + + if attrib_type in float_types: + gl.glVertexAttribPointer( + prog_attr.location, # attrib location + attr_descr.components, # 1, 2, 3 or 4 + attr_descr.gl_type, # GL_FLOAT etc + normalized, # normalize + buff_descr.stride, + c_void_p(attr_descr.offset), + ) + elif attrib_type in double_types: + gl.glVertexAttribLPointer( + prog_attr.location, # attrib location + attr_descr.components, # 1, 2, 3 or 4 + attr_descr.gl_type, # GL_DOUBLE etc + buff_descr.stride, + c_void_p(attr_descr.offset), + ) + elif attrib_type in int_types: + gl.glVertexAttribIPointer( + prog_attr.location, # attrib location + attr_descr.components, # 1, 2, 3 or 4 + attr_descr.gl_type, # GL_FLOAT etc + buff_descr.stride, + c_void_p(attr_descr.offset), + ) + else: + raise ValueError(f"Unsupported attribute type: {attr_descr.gl_type}") + + # print(( + # f"gl.glVertexAttribXPointer(\n" + # f" {prog_attr.location}, # attrib location\n" + # f" {attr_descr.components}, # 1, 2, 3 or 4\n" + # f" {attr_descr.gl_type}, # GL_FLOAT etc\n" + # f" {normalized}, # normalize\n" + # f" {buff_descr.stride},\n" + # f" c_void_p({attr_descr.offset}),\n" + # )) + # TODO: Sanity check this + if buff_descr.instanced: + gl.glVertexAttribDivisor(prog_attr.location, 1) + + def render( + self, mode: GLenumLike, first: int = 0, vertices: int = 0, instances: int = 1 + ) -> None: + """ + Render the VertexArray to the currently active framebuffer. + + Args: + mode: + Primitive type to render. TRIANGLES, LINES etc. + first: + The first vertex to render from + vertices: + Number of vertices to render + instances: + OpenGL instance, used in using vertices over and over + """ + gl.glBindVertexArray(self.glo) + if self._ibo is not None: + # HACK: re-bind index buffer just in case. + # pyglet rendering was somehow replacing the index buffer. + gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self._ibo.glo) + gl.glDrawElementsInstanced( + mode, + vertices, + self._index_element_type, + first * self._index_element_size, + instances, + ) + else: + gl.glDrawArraysInstanced(mode, first, vertices, instances) + + def render_indirect(self, buffer: Buffer, mode: GLuintLike, count, first, stride) -> None: + """ + Render the VertexArray to the framebuffer using indirect rendering. + + .. Warning:: This requires OpenGL 4.3 + + Args: + buffer: + The buffer containing one or multiple draw parameters + mode: + Primitive type to render. TRIANGLES, LINES etc. + count: + The number if indirect draw calls to run + first: + The first indirect draw call to start on + stride: + The byte stride of the draw command buffer. + Keep the default (0) if the buffer is tightly packed. + """ + # The default buffer stride for array and indexed + _stride = 20 if self._ibo is not None else 16 + stride = stride or _stride + if stride % 4 != 0 or stride < 0: + raise ValueError(f"stride must be positive integer in multiples of 4, not {stride}.") + + # The maximum number of draw calls in the buffer + max_commands = buffer.size // stride + if count < 0: + count = max_commands + elif (first + count) > max_commands: + raise ValueError( + "Attempt to issue rendering commands outside the buffer. " + f"first = {first}, count = {count} is reaching past " + f"the buffer end. The buffer have room for {max_commands} " + f"draw commands. byte size {buffer.size}, stride {stride}." + ) + + gl.glBindVertexArray(self.glo) + gl.glBindBuffer(gl.GL_DRAW_INDIRECT_BUFFER, buffer._glo) + if self._ibo: + gl.glMultiDrawElementsIndirect( + mode, self._index_element_type, first * stride, count, stride + ) + else: + gl.glMultiDrawArraysIndirect(mode, first * stride, count, stride) + + def transform_interleaved( + self, + buffer: Buffer, + mode: GLenumLike, + output_mode: GLenumLike, + first: int = 0, + vertices: int = 0, + instances: int = 1, + buffer_offset=0, + ) -> None: + """ + Run a transform feedback. + + Args: + buffer: + The buffer to write the output + mode: + The input primitive mode + output_mode: + The output primitive mode + first: + Offset start vertex + vertices: + Number of vertices to render + instances: + Number of instances to render + buffer_offset: + Byte offset for the buffer (target) + """ + if vertices < 0: + raise ValueError(f"Cannot determine the number of vertices: {vertices}") + + if buffer_offset >= buffer.size: + raise ValueError("buffer_offset at end or past the buffer size") + + gl.glBindVertexArray(self.glo) + gl.glEnable(gl.GL_RASTERIZER_DISCARD) + + if buffer_offset > 0: + gl.glBindBufferRange( + gl.GL_TRANSFORM_FEEDBACK_BUFFER, + 0, + buffer.glo, + buffer_offset, + buffer.size - buffer_offset, + ) + else: + gl.glBindBufferBase(gl.GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer.glo) + + gl.glBeginTransformFeedback(output_mode) + + if self._ibo is not None: + count = self._ibo.size // 4 + # TODO: Support first argument by offsetting pointer (second last arg) + gl.glDrawElementsInstanced(mode, vertices or count, gl.GL_UNSIGNED_INT, None, instances) + else: + # print(f"glDrawArraysInstanced({mode}, {first}, {vertices}, {instances})") + gl.glDrawArraysInstanced(mode, first, vertices, instances) + + gl.glEndTransformFeedback() + gl.glDisable(gl.GL_RASTERIZER_DISCARD) + + def transform_separate( + self, + buffers: list[Buffer], + mode: GLenumLike, + output_mode: GLenumLike, + first: int = 0, + vertices: int = 0, + instances: int = 1, + buffer_offset=0, + ) -> None: + """ + Run a transform feedback writing to separate buffers. + + Args: + buffers: + The buffers to write the output + mode: + The input primitive mode + output_mode: + The output primitive mode + first: + Offset start vertex + vertices: + Number of vertices to render + instances: + Number of instances to render + buffer_offset: + Byte offset for the buffer (target) + """ + if vertices < 0: + raise ValueError(f"Cannot determine the number of vertices: {vertices}") + + # Get size from the smallest buffer + size = min(buf.size for buf in buffers) + if buffer_offset >= size: + raise ValueError("buffer_offset at end or past the buffer size") + + gl.glBindVertexArray(self.glo) + gl.glEnable(gl.GL_RASTERIZER_DISCARD) + + if buffer_offset > 0: + for index, buffer in enumerate(buffers): + gl.glBindBufferRange( + gl.GL_TRANSFORM_FEEDBACK_BUFFER, + index, + buffer.glo, + buffer_offset, + buffer.size - buffer_offset, + ) + else: + for index, buffer in enumerate(buffers): + gl.glBindBufferBase(gl.GL_TRANSFORM_FEEDBACK_BUFFER, index, buffer.glo) + + gl.glBeginTransformFeedback(output_mode) + + if self._ibo is not None: + count = self._ibo.size // 4 + # TODO: Support first argument by offsetting pointer (second last arg) + gl.glDrawElementsInstanced(mode, vertices or count, gl.GL_UNSIGNED_INT, None, instances) + else: + # print(f"glDrawArraysInstanced({mode}, {first}, {vertices}, {instances})") + gl.glDrawArraysInstanced(mode, first, vertices, instances) + + gl.glEndTransformFeedback() + gl.glDisable(gl.GL_RASTERIZER_DISCARD) + + +class OpenGLGeometry(Geometry): + """A higher level abstraction of the VertexArray. + + It generates VertexArray instances on the fly internally matching the incoming + program. This means we can render the same geometry with different programs + as long as the :py:class:`~arcade.gl.Program` and :py:class:`~arcade.gl.BufferDescription` + have compatible attributes. This is an extremely powerful concept that allows + for very flexible rendering pipelines and saves the user from a lot of manual + bookkeeping. + + Geometry objects should be created through :py:meth:`arcade.gl.Context.geometry` + + Args: + ctx: + The context this object belongs to + content: + List of BufferDescriptions + index_buffer: + Index/element buffer + mode: + The default draw mode + index_element_size: + Byte size of the index buffer datatype. + Can be 1, 2 or 4 (8, 16 or 32bit integer) + """ + + def __init__( + self, + ctx: "Context", + content: Sequence[BufferDescription] | None, + index_buffer: Buffer | None = None, + mode: int | None = None, + index_element_size: int = 4, + ) -> None: + super().__init__(ctx, content, index_buffer, mode, index_element_size) + + def _generate_vao(self, program: Program) -> VertexArray: + """ + Create a new VertexArray for the given program. + + Args: + program: The program to use + """ + # print(f"Generating vao for key {program.attribute_key}") + + vao = OpenGLVertexArray( + self._ctx, + program, + self._content, + index_buffer=self._index_buffer, + index_element_size=self._index_element_size, + ) + self._vao_cache[program.attribute_key] = vao + return vao diff --git a/arcade/gl/buffer.py b/arcade/gl/buffer.py index 7504bc46bd..294c3730b9 100644 --- a/arcade/gl/buffer.py +++ b/arcade/gl/buffer.py @@ -1,20 +1,15 @@ from __future__ import annotations -import weakref -from ctypes import byref, string_at +from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from pyglet import gl - from arcade.types import BufferProtocol -from .utils import data_to_ctypes - if TYPE_CHECKING: from arcade.gl import Context -class Buffer: +class Buffer(ABC): """OpenGL buffer object. Buffers store byte data and upload it to graphics memory so shader programs can process the data. They are used for storage of vertex data, @@ -42,58 +37,16 @@ class Buffer: A hit of this buffer is ``static`` or ``dynamic`` (can mostly be ignored) """ - __slots__ = "_ctx", "_glo", "_size", "_usage", "__weakref__" - _usages = { - "static": gl.GL_STATIC_DRAW, - "dynamic": gl.GL_DYNAMIC_DRAW, - "stream": gl.GL_STREAM_DRAW, - } + __slots__ = "_ctx", "_size", "__weakref__" def __init__( self, ctx: Context, - data: BufferProtocol | None = None, - reserve: int = 0, - usage: str = "static", ): self._ctx = ctx - self._glo = glo = gl.GLuint() self._size = -1 - self._usage = Buffer._usages[usage] - - gl.glGenBuffers(1, byref(self._glo)) - # print(f"glGenBuffers() -> {self._glo.value}") - if self._glo.value == 0: - raise RuntimeError("Cannot create Buffer object.") - - # print(f"glBindBuffer({self._glo.value})") - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._glo) - # print(f"glBufferData(gl.GL_ARRAY_BUFFER, {self._size}, data, {self._usage})") - - if data is not None and len(data) > 0: # type: ignore - self._size, data = data_to_ctypes(data) - gl.glBufferData(gl.GL_ARRAY_BUFFER, self._size, data, self._usage) - elif reserve > 0: - self._size = reserve - # populate the buffer with zero byte values - data = (gl.GLubyte * self._size)() - gl.glBufferData(gl.GL_ARRAY_BUFFER, self._size, data, self._usage) - else: - raise ValueError("Buffer takes byte data or number of reserved bytes") - - if self._ctx.gc_mode == "auto": - weakref.finalize(self, Buffer.delete_glo, self.ctx, glo) - self._ctx.stats.incr("buffer") - def __repr__(self): - return f"" - - def __del__(self): - # Intercept garbage collection if we are using Context.gc() - if self._ctx.gc_mode == "context_gc" and self._glo.value > 0: - self._ctx.objects.append(self) - @property def size(self) -> int: """The byte size of the buffer.""" @@ -104,43 +57,16 @@ def ctx(self) -> "Context": """The context this resource belongs to.""" return self._ctx - @property - def glo(self) -> gl.GLuint: - """The OpenGL resource id.""" - return self._glo - + @abstractmethod def delete(self) -> None: """ - Destroy the underlying OpenGL resource. + Destroy the underlying native buffer resource. .. warning:: Don't use this unless you know exactly what you are doing. """ - Buffer.delete_glo(self._ctx, self._glo) - self._glo.value = 0 - - @staticmethod - def delete_glo(ctx: Context, glo: gl.GLuint): - """ - Release/delete open gl buffer. - - This is automatically called when the object is garbage collected. - - Args: - ctx: - The context the buffer belongs to - glo: - The OpenGL buffer id - """ - # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: - return - - if glo.value != 0: - gl.glDeleteBuffers(1, byref(glo)) - glo.value = 0 - - ctx.stats.decr("buffer") + pass + @abstractmethod def read(self, size: int = -1, offset: int = 0) -> bytes: """Read data from the buffer. @@ -150,32 +76,9 @@ def read(self, size: int = -1, offset: int = 0) -> bytes: offset: Byte read offset """ - if size == -1: - size = self._size - offset - - # Catch this before confusing INVALID_OPERATION is raised - if size < 1: - raise ValueError( - "Attempting to read 0 or less bytes from buffer: " - f"buffer size={self._size} | params: size={size}, offset={offset}" - ) - - # Manually detect this so it doesn't raise a confusing INVALID_VALUE error - if size + offset > self._size: - raise ValueError( - ( - "Attempting to read outside the buffer. " - f"Buffer size: {self._size} " - f"Reading from {offset} to {size + offset}" - ) - ) - - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._glo) - ptr = gl.glMapBufferRange(gl.GL_ARRAY_BUFFER, offset, size, gl.GL_MAP_READ_BIT) - data = string_at(ptr, size=size) - gl.glUnmapBuffer(gl.GL_ARRAY_BUFFER) - return data + pass + @abstractmethod def write(self, data: BufferProtocol, offset: int = 0): """Write byte data to the buffer from a buffer protocol object. @@ -198,14 +101,9 @@ def write(self, data: BufferProtocol, offset: int = 0): offset: The byte offset """ - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._glo) - size, data = data_to_ctypes(data) - # Ensure we don't write outside the buffer - size = min(size, self._size - offset) - if size < 0: - raise ValueError("Attempting to write negative number bytes to buffer") - gl.glBufferSubData(gl.GL_ARRAY_BUFFER, gl.GLintptr(offset), size, data) + pass + @abstractmethod def copy_from_buffer(self, source: Buffer, size=-1, offset=0, source_offset=0): """Copy data into this buffer from another buffer. @@ -219,27 +117,9 @@ def copy_from_buffer(self, source: Buffer, size=-1, offset=0, source_offset=0): source_offset: The byte offset to read from the source buffer """ - # Read the entire source buffer into this buffer - if size == -1: - size = source.size - - # TODO: Check buffer bounds - if size + source_offset > source.size: - raise ValueError("Attempting to read outside the source buffer") - - if size + offset > self._size: - raise ValueError("Attempting to write outside the buffer") - - gl.glBindBuffer(gl.GL_COPY_READ_BUFFER, source.glo) - gl.glBindBuffer(gl.GL_COPY_WRITE_BUFFER, self._glo) - gl.glCopyBufferSubData( - gl.GL_COPY_READ_BUFFER, - gl.GL_COPY_WRITE_BUFFER, - gl.GLintptr(source_offset), # readOffset - gl.GLintptr(offset), # writeOffset - size, # size (number of bytes to copy) - ) + pass + @abstractmethod def orphan(self, size: int = -1, double: bool = False): """ Re-allocate the entire buffer memory. This can be used to resize @@ -256,14 +136,9 @@ def orphan(self, size: int = -1, double: bool = False): Is passed in with `True` the buffer size will be doubled from its current size. """ - if size > 0: - self._size = size - elif double is True: - self._size *= 2 - - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._glo) - gl.glBufferData(gl.GL_ARRAY_BUFFER, self._size, None, self._usage) + pass + @abstractmethod def bind_to_uniform_block(self, binding: int = 0, offset: int = 0, size: int = -1): """Bind this buffer to a uniform block location. In most cases it will be sufficient to only provide a binding location. @@ -276,11 +151,9 @@ def bind_to_uniform_block(self, binding: int = 0, offset: int = 0, size: int = - size: Size of the buffer to bind. """ - if size < 0: - size = self.size - - gl.glBindBufferRange(gl.GL_UNIFORM_BUFFER, binding, self._glo, offset, size) + pass + @abstractmethod def bind_to_storage_buffer(self, *, binding=0, offset=0, size=-1): """ Bind this buffer as a shader storage buffer. @@ -293,7 +166,4 @@ def bind_to_storage_buffer(self, *, binding=0, offset=0, size=-1): size: The size in bytes. The entire buffer will be mapped by default. """ - if size < 0: - size = self.size - - gl.glBindBufferRange(gl.GL_SHADER_STORAGE_BUFFER, binding, self._glo, offset, size) + pass diff --git a/arcade/gl/compute_shader.py b/arcade/gl/compute_shader.py index cffa27724f..39ee385496 100644 --- a/arcade/gl/compute_shader.py +++ b/arcade/gl/compute_shader.py @@ -1,28 +1,13 @@ from __future__ import annotations -import weakref -from ctypes import ( - POINTER, - byref, - c_buffer, - c_char, - c_char_p, - c_int, - cast, - create_string_buffer, - pointer, -) +from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from pyglet import gl - -from .uniform import Uniform, UniformBlock - if TYPE_CHECKING: from arcade.gl import Context -class ComputeShader: +class ComputeShader(ABC): """ A higher level wrapper for an OpenGL compute shader. @@ -36,83 +21,10 @@ class ComputeShader: def __init__(self, ctx: Context, glsl_source: str) -> None: self._ctx = ctx self._source = glsl_source - self._uniforms: dict[str, UniformBlock | Uniform] = dict() - - from arcade.gl import ShaderException - - # Create the program - self._glo = glo = gl.glCreateProgram() - if not self._glo: - raise ShaderException("Failed to create program object") - - self._shader_obj = gl.glCreateShader(gl.GL_COMPUTE_SHADER) - if not self._shader_obj: - raise ShaderException("Failed to create compute shader object") - - # Set source - source_bytes = self._source.encode("utf-8") - strings = byref(cast(c_char_p(source_bytes), POINTER(c_char))) - lengths = pointer(c_int(len(source_bytes))) - gl.glShaderSource(self._shader_obj, 1, strings, lengths) - - # Compile and check result - gl.glCompileShader(self._shader_obj) - result = c_int() - gl.glGetShaderiv(self._shader_obj, gl.GL_COMPILE_STATUS, byref(result)) - if result.value == gl.GL_FALSE: - msg = create_string_buffer(512) - length = c_int() - gl.glGetShaderInfoLog(self._shader_obj, 512, byref(length), msg) - raise ShaderException( - ( - f"Error compiling compute shader " - f"({result.value}): {msg.value.decode('utf-8')}\n" - f"---- [compute shader] ---\n" - ) - + "\n".join( - f"{str(i + 1).zfill(3)}: {line} " - for i, line in enumerate(self._source.split("\n")) - ) - ) - - # Attach and link shader - gl.glAttachShader(self._glo, self._shader_obj) - gl.glLinkProgram(self._glo) - gl.glDeleteShader(self._shader_obj) - status = c_int() - gl.glGetProgramiv(self._glo, gl.GL_LINK_STATUS, status) - if not status.value: - length = c_int() - gl.glGetProgramiv(self._glo, gl.GL_INFO_LOG_LENGTH, length) - log = c_buffer(length.value) - gl.glGetProgramInfoLog(self._glo, len(log), None, log) - raise ShaderException("Program link error: {}".format(log.value.decode())) - - self._introspect_uniforms() - self._introspect_uniform_blocks() - - if self._ctx.gc_mode == "auto": - weakref.finalize(self, ComputeShader.delete_glo, self._ctx, glo) ctx.stats.incr("compute_shader") - @property - def glo(self) -> int: - """The name/id of the OpenGL resource""" - return self._glo - - def _use(self) -> None: - """ - Use/activate the compute shader. - - .. Note:: - - This is not necessary to call in normal use cases - since ``run()`` already does this for you. - """ - gl.glUseProgram(self._glo) - self._ctx.active_program = self - + @abstractmethod def run(self, group_x=1, group_y=1, group_z=1) -> None: """ Run the compute shader. @@ -139,38 +51,12 @@ def run(self, group_x=1, group_y=1, group_z=1) -> None: group_y: The number of work groups to be launched in the y dimension. group_z: The number of work groups to be launched in the z dimension. """ - self._use() - gl.glDispatchCompute(group_x, group_y, group_z) - - def __getitem__(self, item) -> Uniform | UniformBlock: - """Get a uniform or uniform block""" - try: - uniform = self._uniforms[item] - except KeyError: - raise KeyError(f"Uniform with the name `{item}` was not found.") - - return uniform.getter() - - def __setitem__(self, key, value): - """Set a uniform value""" - # Ensure we are setting the uniform on this program - # if self._ctx.active_program != self: - # self.use() - - try: - uniform = self._uniforms[key] - except KeyError: - raise KeyError(f"Uniform with the name `{key}` was not found.") - - uniform.setter(value) + raise NotImplementedError("The enabled graphics backend does not support this method.") def __hash__(self) -> int: return id(self) - def __del__(self): - if self._ctx.gc_mode == "context_gc" and self._glo > 0: - self._ctx.objects.append(self) - + @abstractmethod def delete(self) -> None: """ Destroy the internal compute shader object. @@ -178,99 +64,4 @@ def delete(self) -> None: This is normally not necessary, but depends on the garbage collection configured in the context. """ - ComputeShader.delete_glo(self._ctx, self._glo) - self._glo = 0 - - @staticmethod - def delete_glo(ctx, prog_id): - """ - Low level method for destroying a compute shader by id. - - Args: - ctx: The context this program belongs to. - prog_id: The OpenGL id of the program. - """ - # Check to see if the context was already cleaned up from program - # shut down. If so, we don't need to delete the shaders. - if gl.current_context is None: - return - - gl.glDeleteProgram(prog_id) - # TODO: Count compute shaders - ctx.stats.decr("compute_shader") - - def _introspect_uniforms(self): - """Figure out what uniforms are available and build an internal map""" - # Number of active uniforms in the program - active_uniforms = gl.GLint(0) - gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_UNIFORMS, byref(active_uniforms)) - - # Loop all the active uniforms - for index in range(active_uniforms.value): - # Query uniform information like name, type, size etc. - u_name, u_type, u_size = self._query_uniform(index) - u_location = gl.glGetUniformLocation(self._glo, u_name.encode()) - - # Skip uniforms that may be in Uniform Blocks - # TODO: We should handle all uniforms - if u_location == -1: - # print(f"Uniform {u_location} {u_name} {u_size} {u_type} skipped") - continue - - u_name = u_name.replace("[0]", "") # Remove array suffix - self._uniforms[u_name] = Uniform( - self._ctx, self._glo, u_location, u_name, u_type, u_size - ) - - def _introspect_uniform_blocks(self): - """Finds uniform blocks and maps the to python objectss""" - active_uniform_blocks = gl.GLint(0) - gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_UNIFORM_BLOCKS, byref(active_uniform_blocks)) - # print('GL_ACTIVE_UNIFORM_BLOCKS', active_uniform_blocks) - - for loc in range(active_uniform_blocks.value): - index, size, name = self._query_uniform_block(loc) - block = UniformBlock(self._glo, index, size, name) - self._uniforms[name] = block - - def _query_uniform(self, location: int) -> tuple[str, int, int]: - """Retrieve Uniform information at given location. - - Returns the name, the type as a GLenum (GL_FLOAT, ...) and the size. Size is - greater than 1 only for Uniform arrays, like an array of floats or an array - of Matrices. - """ - u_size = gl.GLint() - u_type = gl.GLenum() - buf_size = 192 # max uniform character length - u_name = create_string_buffer(buf_size) - gl.glGetActiveUniform( - self._glo, # program to query - location, # location to query - buf_size, # size of the character/name buffer - None, # the number of characters actually written by OpenGL in the string - u_size, # size of the uniform variable - u_type, # data type of the uniform variable - u_name, # string buffer for storing the name - ) - return u_name.value.decode(), u_type.value, u_size.value - - def _query_uniform_block(self, location: int) -> tuple[int, int, str]: - """Query active uniform block by retrieving the name and index and size""" - # Query name - u_size = gl.GLint() - buf_size = 192 # max uniform character length - u_name = create_string_buffer(buf_size) - gl.glGetActiveUniformBlockName( - self._glo, # program to query - location, # location to query - 256, # max size if the name - u_size, # length - u_name, - ) - # Query index - index = gl.glGetUniformBlockIndex(self._glo, u_name) - # Query size - b_size = gl.GLint() - gl.glGetActiveUniformBlockiv(self._glo, index, gl.GL_UNIFORM_BLOCK_DATA_SIZE, b_size) - return index, b_size.value, u_name.value.decode() + raise NotImplementedError("The enabled graphics backend does not support this method.") diff --git a/arcade/gl/context.py b/arcade/gl/context.py index d1a86c08bb..ded2574d1a 100644 --- a/arcade/gl/context.py +++ b/arcade/gl/context.py @@ -2,9 +2,9 @@ import logging import weakref +from abc import ABC, abstractmethod from collections import deque from contextlib import contextmanager -from ctypes import c_char_p, c_float, c_int, cast from typing import ( Any, Deque, @@ -19,27 +19,25 @@ ) import pyglet -import pyglet.gl.lib -from pyglet import gl from pyglet.window import Window from ..types import BufferProtocol +from . import enums from .buffer import Buffer from .compute_shader import ComputeShader from .framebuffer import DefaultFrameBuffer, Framebuffer -from .glsl import ShaderSource from .program import Program +from .provider import get_provider from .query import Query from .sampler import Sampler from .texture import Texture2D from .texture_array import TextureArray -from .types import BufferDescription, GLenumLike, PyGLenum from .vertex_array import Geometry LOG = logging.getLogger(__name__) -class Context: +class Context(ABC): """ Represents an OpenGL context. This context belongs to a pyglet window. normally accessed through ``window.ctx``. @@ -57,221 +55,177 @@ class Context: active: Context | None = None """The active context""" - #: The OpenGL api. Usually "gl" or "gles". - gl_api: str = "gl" - # --- Store the most commonly used OpenGL constants # Texture - NEAREST = 0x2600 + NEAREST = enums.NEAREST """Texture interpolation - Nearest pixel""" - LINEAR = 0x2601 + LINEAR = enums.LINEAR """Texture interpolation - Linear interpolate""" - NEAREST_MIPMAP_NEAREST = 0x2700 + NEAREST_MIPMAP_NEAREST = enums.NEAREST_MIPMAP_NEAREST """Texture interpolation - Minification filter for mipmaps""" - LINEAR_MIPMAP_NEAREST = 0x2701 + LINEAR_MIPMAP_NEAREST = enums.LINEAR_MIPMAP_NEAREST """Texture interpolation - Minification filter for mipmaps""" - NEAREST_MIPMAP_LINEAR = 0x2702 + NEAREST_MIPMAP_LINEAR = enums.NEAREST_MIPMAP_LINEAR """Texture interpolation - Minification filter for mipmaps""" - LINEAR_MIPMAP_LINEAR = 0x2703 + LINEAR_MIPMAP_LINEAR = enums.LINEAR_MIPMAP_LINEAR """Texture interpolation - Minification filter for mipmaps""" - REPEAT = gl.GL_REPEAT + REPEAT = enums.REPEAT """Texture wrap mode - Repeat""" - CLAMP_TO_EDGE = gl.GL_CLAMP_TO_EDGE + CLAMP_TO_EDGE = enums.CLAMP_TO_EDGE """Texture wrap mode - Clamp to border pixel""" - CLAMP_TO_BORDER = gl.GL_CLAMP_TO_BORDER - """Texture wrap mode - Clamp to border color""" - - MIRRORED_REPEAT = gl.GL_MIRRORED_REPEAT + MIRRORED_REPEAT = enums.MIRRORED_REPEAT """Texture wrap mode - Repeat mirrored""" # Flags - BLEND = gl.GL_BLEND + BLEND = enums.BLEND """Context flag - Blending""" - DEPTH_TEST = gl.GL_DEPTH_TEST + DEPTH_TEST = enums.DEPTH_TEST """Context flag - Depth testing""" - CULL_FACE = gl.GL_CULL_FACE + CULL_FACE = enums.CULL_FACE """Context flag - Face culling""" - PROGRAM_POINT_SIZE = gl.GL_PROGRAM_POINT_SIZE - """ - Context flag - Enables ``gl_PointSize`` in vertex or geometry shaders. - - When enabled we can write to ``gl_PointSize`` in the vertex shader to specify the point size - for each individual point. - - If this value is not set in the shader the behavior is undefined. This means the points may - or may not appear depending if the drivers enforce some default value for ``gl_PointSize``. - - When disabled :py:attr:`point_size` is used. - """ - # Blend functions - ZERO = 0x0000 + ZERO = enums.ZERO """Blend function""" - ONE = 0x0001 + ONE = enums.ONE """Blend function""" - SRC_COLOR = 0x0300 + SRC_COLOR = enums.SRC_COLOR """Blend function""" - ONE_MINUS_SRC_COLOR = 0x0301 + ONE_MINUS_SRC_COLOR = enums.ONE_MINUS_SRC_COLOR """Blend function""" - SRC_ALPHA = 0x0302 + SRC_ALPHA = enums.SRC_ALPHA """Blend function""" - ONE_MINUS_SRC_ALPHA = 0x0303 + ONE_MINUS_SRC_ALPHA = enums.ONE_MINUS_SRC_ALPHA """Blend function""" - DST_ALPHA = 0x0304 + DST_ALPHA = enums.DST_ALPHA """Blend function""" - ONE_MINUS_DST_ALPHA = 0x0305 + ONE_MINUS_DST_ALPHA = enums.ONE_MINUS_DST_ALPHA """Blend function""" - DST_COLOR = 0x0306 + DST_COLOR = enums.DST_COLOR """Blend function""" - ONE_MINUS_DST_COLOR = 0x0307 + ONE_MINUS_DST_COLOR = enums.ONE_MINUS_DST_COLOR """Blend function""" # Blend equations - FUNC_ADD = 0x8006 + FUNC_ADD = enums.FUNC_ADD """Blend equation - source + destination""" - FUNC_SUBTRACT = 0x800A + FUNC_SUBTRACT = enums.FUNC_SUBTRACT """Blend equation - source - destination""" - FUNC_REVERSE_SUBTRACT = 0x800B + FUNC_REVERSE_SUBTRACT = enums.FUNC_REVERSE_SUBTRACT """Blend equation - destination - source""" - MIN = 0x8007 + MIN = enums.MIN """Blend equation - Minimum of source and destination""" - MAX = 0x8008 + MAX = enums.MAX """Blend equation - Maximum of source and destination""" # Blend mode shortcuts - BLEND_DEFAULT = 0x0302, 0x0303 + BLEND_DEFAULT = enums.BLEND_DEFAULT """Blend mode shortcut for default blend mode - ``SRC_ALPHA, ONE_MINUS_SRC_ALPHA``""" - BLEND_ADDITIVE = 0x0001, 0x0001 + BLEND_ADDITIVE = enums.BLEND_ADDITIVE """Blend mode shortcut for additive blending - ``ONE, ONE``""" - BLEND_PREMULTIPLIED_ALPHA = 0x0302, 0x0001 + BLEND_PREMULTIPLIED_ALPHA = enums.BLEND_PREMULTIPLIED_ALPHA """Blend mode shortcut for pre-multiplied alpha - ``SRC_ALPHA, ONE``""" # VertexArray: Primitives - POINTS = gl.GL_POINTS # 0 + POINTS = enums.POINTS # 0 """Primitive mode - points""" - LINES = gl.GL_LINES # 1 + LINES = enums.LINES # 1 """Primitive mode - lines""" - LINE_LOOP = gl.GL_LINE_LOOP # 2 + LINE_LOOP = enums.LINE_LOOP # 2 """Primitive mode - line loop""" - LINE_STRIP = gl.GL_LINE_STRIP # 3 + LINE_STRIP = enums.LINE_STRIP # 3 """Primitive mode - line strip""" - TRIANGLES = gl.GL_TRIANGLES # 4 + TRIANGLES = enums.TRIANGLES # 4 """Primitive mode - triangles""" - TRIANGLE_STRIP = gl.GL_TRIANGLE_STRIP # 5 + TRIANGLE_STRIP = enums.TRIANGLE_STRIP # 5 """Primitive mode - triangle strip""" - TRIANGLE_FAN = gl.GL_TRIANGLE_FAN # 6 + TRIANGLE_FAN = enums.TRIANGLE_FAN # 6 """Primitive mode - triangle fan""" - LINES_ADJACENCY = gl.GL_LINES_ADJACENCY # 10 + ##### ADJACENCY VALUES ARE NOT SUPPORTED BY WEBGL + ##### WE ARE LEAVING THESE VALUES IN THE COMMON IMPLEMENTATION + ##### TO MAKE IMPLEMENTATION EASIER, BECAUSE WEBGL WILL FAIL + ##### BEFORE USAGE OF THESE MATTERS + + LINES_ADJACENCY = enums.LINES_ADJACENCY # 10 """Primitive mode - lines with adjacency""" - LINE_STRIP_ADJACENCY = gl.GL_LINE_STRIP_ADJACENCY # 11 + LINE_STRIP_ADJACENCY = enums.LINE_STRIP_ADJACENCY # 11 """Primitive mode - line strip with adjacency""" - TRIANGLES_ADJACENCY = gl.GL_TRIANGLES_ADJACENCY # 12 + TRIANGLES_ADJACENCY = enums.TRIANGLES_ADJACENCY # 12 """Primitive mode - triangles with adjacency""" - TRIANGLE_STRIP_ADJACENCY = gl.GL_TRIANGLE_STRIP_ADJACENCY # 13 + TRIANGLE_STRIP_ADJACENCY = enums.TRIANGLE_STRIP_ADJACENCY # 13 """Primitive mode - triangle strip with adjacency""" - PATCHES = gl.GL_PATCHES - """Primitive mode - Patch (tessellation)""" - # The most common error enums _errors = { - gl.GL_INVALID_ENUM: "GL_INVALID_ENUM", - gl.GL_INVALID_VALUE: "GL_INVALID_VALUE", - gl.GL_INVALID_OPERATION: "GL_INVALID_OPERATION", - gl.GL_INVALID_FRAMEBUFFER_OPERATION: "GL_INVALID_FRAMEBUFFER_OPERATION", - gl.GL_OUT_OF_MEMORY: "GL_OUT_OF_MEMORY", - gl.GL_STACK_UNDERFLOW: "GL_STACK_UNDERFLOW", - gl.GL_STACK_OVERFLOW: "GL_STACK_OVERFLOW", + enums.INVALID_ENUM: "INVALID_ENUM", + enums.INVALID_VALUE: "INVALID_VALUE", + enums.INVALID_OPERATION: "INVALID_OPERATION", + enums.INVALID_FRAMEBUFFER_OPERATION: "INVALID_FRAMEBUFFER_OPERATION", + enums.OUT_OF_MEMORY: "OUT_OF_MEMORY", } - _valid_apis = ("gl", "gles") def __init__( self, window: pyglet.window.Window, # type: ignore gc_mode: str = "context_gc", - gl_api: str = "gl", + gl_api: str = "gl", # This is ignored here, but used in implementation classes ): self._window_ref = weakref.ref(window) - if gl_api not in self._valid_apis: - raise ValueError(f"Invalid gl_api. Options are: {self._valid_apis}") - self.gl_api = gl_api - self._info = GLInfo(self) - self._gl_version = (self._info.MAJOR_VERSION, self._info.MINOR_VERSION) + self._info = get_provider().create_info(self) + Context.activate(self) # Texture unit we use when doing operations on textures to avoid # affecting currently bound textures in the first units self.default_texture_unit: int = self._info.MAX_TEXTURE_IMAGE_UNITS - 1 # Detect the default framebuffer - self._screen = DefaultFrameBuffer(self) + self._screen = self._create_default_framebuffer() # Tracking active program self.active_program: Program | ComputeShader | None = None # Tracking active framebuffer. On context creation the window is the default render target self.active_framebuffer: Framebuffer = self._screen self._stats: ContextStats = ContextStats(warn_threshold=1000) - # Hardcoded states - # This should always be enabled - # gl.glEnable(gl.GL_TEXTURE_CUBE_MAP_SEAMLESS) - # Set primitive restart index to -1 by default - if self.gl_api == "gles": - gl.glEnable(gl.GL_PRIMITIVE_RESTART_FIXED_INDEX) - else: - gl.glEnable(gl.GL_PRIMITIVE_RESTART) - self._primitive_restart_index = -1 self.primitive_restart_index = self._primitive_restart_index - # Detect support for glProgramUniform. - # Assumed to be supported in gles - self._ext_separate_shader_objects_enabled = True - if self.gl_api == "gl": - have_ext = gl.gl_info.have_extension("GL_ARB_separate_shader_objects") - self._ext_separate_shader_objects_enabled = self.gl_version >= (4, 1) or have_ext - - # We enable scissor testing by default. - # This is always set to the same value as the viewport - # to avoid background color affecting areas outside the viewport - gl.glEnable(gl.GL_SCISSOR_TEST) - # States self._blend_func: Tuple[int, int] | Tuple[int, int, int, int] = self.BLEND_DEFAULT self._point_size = 1.0 @@ -279,14 +233,14 @@ def __init__( self._wireframe = False # Options for cull_face self._cull_face_options = { - "front": gl.GL_FRONT, - "back": gl.GL_BACK, - "front_and_back": gl.GL_FRONT_AND_BACK, + "front": enums.FRONT, + "back": enums.BACK, + "front_and_back": enums.FRONT_AND_BACK, } self._cull_face_options_reverse = { - gl.GL_FRONT: "front", - gl.GL_BACK: "back", - gl.GL_FRONT_AND_BACK: "front_and_back", + enums.FRONT: "front", + enums.BACK: "back", + enums.FRONT_AND_BACK: "front_and_back", } # Context GC as default. We need to call Context.gc() to free opengl resources @@ -296,8 +250,12 @@ def __init__( #: This can be used during debugging. self.objects: Deque[Any] = deque() + @abstractmethod + def _create_default_framebuffer(self) -> DefaultFrameBuffer: + raise NotImplementedError("The enabled graphics backend does not support this method.") + @property - def info(self) -> GLInfo: + def info(self) -> Info: """ Get the info object for this context containing information about hardware/driver limits and other information. @@ -314,6 +272,7 @@ def info(self) -> GLInfo: return self._info @property + @abstractmethod def extensions(self) -> set[str]: """ Get a set of supported OpenGL extensions strings for this context. @@ -326,7 +285,7 @@ def extensions(self) -> set[str]: expected_extensions = {"GL_ARB_bindless_texture", "GL_ARB_get_program_binary"} ctx.extensions & expected_extensions == expected_extensions """ - return gl.gl_info.get_extensions() + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def stats(self) -> ContextStats: @@ -370,17 +329,6 @@ def fbo(self) -> Framebuffer: """ return self.active_framebuffer - @property - def gl_version(self) -> Tuple[int, int]: - """ - The OpenGL major and minor version as a tuple. - - This is the reported OpenGL version from - drivers and might be a higher version than - you requested. - """ - return self._gl_version - def gc(self) -> int: """ Run garbage collection of OpenGL objects for this context. @@ -427,6 +375,7 @@ def gc_mode(self, value: str): self._gc_mode = value @property + @abstractmethod def error(self) -> str | None: """Check OpenGL error @@ -439,11 +388,7 @@ def error(self) -> str | None: if err: raise RuntimeError("OpenGL error: {err}") """ - err = gl.glGetError() - if err == gl.GL_NO_ERROR: - return None - - return self._errors.get(err, "GL_UNKNOWN_ERROR") + raise NotImplementedError("The enabled graphics backend does not support this method.") @classmethod def activate(cls, ctx: Context): @@ -457,6 +402,7 @@ def activate(cls, ctx: Context): """ cls.active = ctx + @abstractmethod def enable(self, *flags: int): """ Enables one or more context flags:: @@ -469,11 +415,9 @@ def enable(self, *flags: int): Args: *flags: The flags to enable """ - self._flags.update(flags) - - for flag in flags: - gl.glEnable(flag) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def enable_only(self, *args: int): """ Enable only some flags. This will disable all other flags. @@ -490,28 +434,7 @@ def enable_only(self, *args: int): Args: *args: The flags to enable """ - self._flags = set(args) - - if self.BLEND in self._flags: - gl.glEnable(self.BLEND) - else: - gl.glDisable(self.BLEND) - - if self.DEPTH_TEST in self._flags: - gl.glEnable(self.DEPTH_TEST) - else: - gl.glDisable(self.DEPTH_TEST) - - if self.CULL_FACE in self._flags: - gl.glEnable(self.CULL_FACE) - else: - gl.glDisable(self.CULL_FACE) - - if self.gl_api == "gl": - if self.PROGRAM_POINT_SIZE in self._flags: - gl.glEnable(self.PROGRAM_POINT_SIZE) - else: - gl.glDisable(self.PROGRAM_POINT_SIZE) + raise NotImplementedError("The enabled graphics backend does not support this method.") @contextmanager def enabled(self, *flags): @@ -556,6 +479,7 @@ def enabled_only(self, *flags): finally: self.enable_only(*old_flags) + @abstractmethod def disable(self, *args): """ Disable one or more context flags:: @@ -565,10 +489,7 @@ def disable(self, *args): # Multiple flags ctx.disable(ctx.DEPTH_TEST, ctx.CULL_FACE) """ - self._flags -= set(args) - - for flag in args: - gl.glDisable(flag) + raise NotImplementedError("The enabled graphics backend does not support this method.") def is_enabled(self, flag) -> bool: """ @@ -678,19 +599,15 @@ def blend_func(self) -> Tuple[int, int] | Tuple[int, int, int, int]: return self._blend_func @blend_func.setter + @abstractmethod def blend_func(self, value: Tuple[int, int] | Tuple[int, int, int, int]): - self._blend_func = value - if len(value) == 2: - gl.glBlendFunc(*value) - elif len(value) == 4: - gl.glBlendFuncSeparate(*value) - else: - ValueError("blend_func takes a tuple of 2 or 4 values") + raise NotImplementedError("The enabled graphics backend does not support this method.") # def blend_equation(self) # Default is FUNC_ADD @property + @abstractmethod def front_face(self) -> str: """ Configure front face winding order of triangles. @@ -701,17 +618,15 @@ def front_face(self) -> str: ctx.front_face = "cw" ctx.front_face = "ccw" """ - value = c_int() - gl.glGetIntegerv(gl.GL_FRONT_FACE, value) - return "cw" if value.value == gl.GL_CW else "ccw" + raise NotImplementedError("The enabled graphics backend does not support this method.") @front_face.setter + @abstractmethod def front_face(self, value: str): - if value not in ["cw", "ccw"]: - raise ValueError("front_face must be 'cw' or 'ccw'") - gl.glFrontFace(gl.GL_CW if value == "cw" else gl.GL_CCW) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property + @abstractmethod def cull_face(self) -> str: """ The face side to cull when face culling is enabled. @@ -723,16 +638,11 @@ def cull_face(self) -> str: ctx.cull_face = "back" ctx.cull_face = "front_and_back" """ - value = c_int() - gl.glGetIntegerv(gl.GL_CULL_FACE_MODE, value) - return self._cull_face_options_reverse[value.value] + raise NotImplementedError("The enabled graphics backend does not support this method.") @cull_face.setter def cull_face(self, value): - if value not in self._cull_face_options: - raise ValueError("cull_face must be", list(self._cull_face_options.keys())) - - gl.glCullFace(self._cull_face_options[value]) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def wireframe(self) -> bool: @@ -745,14 +655,12 @@ def wireframe(self) -> bool: return self._wireframe @wireframe.setter + @abstractmethod def wireframe(self, value: bool): - self._wireframe = value - if value: - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) - else: - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property + @abstractmethod def patch_vertices(self) -> int: """ Get or set number of vertices that will be used to make up a single patch primitive. @@ -760,16 +668,12 @@ def patch_vertices(self) -> int: Patch primitives are consumed by the tessellation control shader (if present) and subsequently used for tessellation. """ - value = c_int() - gl.glGetIntegerv(gl.GL_PATCH_VERTICES, value) - return value.value + raise NotImplementedError("The enabled graphics backend does not support this method.") @patch_vertices.setter + @abstractmethod def patch_vertices(self, value: int): - if not isinstance(value, int): - raise TypeError("patch_vertices must be an integer") - - gl.glPatchParameteri(gl.GL_PATCH_VERTICES, value) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def point_size(self) -> float: @@ -793,10 +697,9 @@ def point_size(self) -> float: return self._point_size @point_size.setter + @abstractmethod def point_size(self, value: float): - if self.gl_api == "gl": - gl.glPointSize(self._point_size) - self._point_size = value + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def primitive_restart_index(self) -> int: @@ -811,11 +714,11 @@ def primitive_restart_index(self) -> int: return self._primitive_restart_index @primitive_restart_index.setter + @abstractmethod def primitive_restart_index(self, value: int): - self._primitive_restart_index = value - if self.gl_api == "gl": - gl.glPrimitiveRestartIndex(value) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def finish(self) -> None: """ Wait until all OpenGL rendering commands are completed. @@ -823,8 +726,9 @@ def finish(self) -> None: This function will actually stall until all work is done and may have severe performance implications. """ - gl.glFinish() + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def flush(self) -> None: """ Flush the OpenGL command buffer. @@ -834,10 +738,11 @@ def flush(self) -> None: ensure that all commands are sent to the GPU before doing something else. """ - gl.glFlush() + raise NotImplementedError("The enabled graphics backend does not support this method.") # Various utility methods + @abstractmethod def copy_framebuffer( self, src: Framebuffer, @@ -868,36 +773,11 @@ def copy_framebuffer( depth: Also copy depth attachment if present """ - # Set source and dest framebuffer - gl.glBindFramebuffer(gl.GL_READ_FRAMEBUFFER, src._glo) - gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, dst._glo) - - # TODO: We can support blitting multiple layers here - gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0 + src_attachment_index) - if dst.is_default: - gl.glDrawBuffer(gl.GL_BACK) - else: - gl.glDrawBuffer(gl.GL_COLOR_ATTACHMENT0) - - # gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, src._glo) - gl.glBlitFramebuffer( - 0, - 0, - src.width, - src.height, # Make source and dest size the same - 0, - 0, - src.width, - src.height, - gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT, - gl.GL_NEAREST, - ) - - # Reset states. We can also apply previous states here - gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0) + raise NotImplementedError("The enabled graphics backend does not support this method.") # --- Resource methods --- + @abstractmethod def buffer( self, *, data: BufferProtocol | None = None, reserve: int = 0, usage: str = "static" ) -> Buffer: @@ -947,8 +827,9 @@ def buffer( usage: Buffer usage. 'static', 'dynamic' or 'stream' """ - return Buffer(self, data, reserve=reserve, usage=usage) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def framebuffer( self, *, @@ -963,10 +844,9 @@ def framebuffer( depth_attachment: Depth texture """ - return Framebuffer( - self, color_attachments=color_attachments or [], depth_attachment=depth_attachment - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def texture( self, size: Tuple[int, int], @@ -974,12 +854,12 @@ def texture( components: int = 4, dtype: str = "f1", data: BufferProtocol | None = None, - wrap_x: PyGLenum | None = None, - wrap_y: PyGLenum | None = None, - filter: Tuple[PyGLenum, PyGLenum] | None = None, + wrap_x=None, + wrap_y=None, + filter=None, samples: int = 0, immutable: bool = False, - internal_format: PyGLenum | None = None, + internal_format=None, compressed: bool = False, compressed_data: bool = False, ) -> Texture2D: @@ -1052,24 +932,9 @@ def texture( Set to True if you are passing in raw compressed pixel data. This implies ``compressed=True``. """ - compressed = compressed or compressed_data - - return Texture2D( - self, - size, - components=components, - data=data, - dtype=dtype, - wrap_x=wrap_x, - wrap_y=wrap_y, - filter=filter, - samples=samples, - immutable=immutable, - internal_format=internal_format, - compressed=compressed, - compressed_data=compressed_data, - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def texture_array( self, size: Tuple[int, int, int], @@ -1077,9 +942,9 @@ def texture_array( components: int = 4, dtype: str = "f1", data: BufferProtocol | None = None, - wrap_x: PyGLenum | None = None, - wrap_y: PyGLenum | None = None, - filter: Tuple[PyGLenum, PyGLenum] | None = None, + wrap_x=None, + wrap_y=None, + filter=None, ) -> TextureArray: """ Create a 2D Texture Array. @@ -1093,17 +958,9 @@ def texture_array( See :py:meth:`~arcade.gl.Context.texture` for arguments. """ - return TextureArray( - self, - size, - components=components, - dtype=dtype, - data=data, - wrap_x=wrap_x, - wrap_y=wrap_y, - filter=filter, - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def depth_texture( self, size: Tuple[int, int], *, data: BufferProtocol | None = None ) -> Texture2D: @@ -1118,8 +975,9 @@ def depth_texture( The texture data. Can be``bytes`` or any object supporting the buffer protocol. """ - return Texture2D(self, size, data=data, depth=True) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def sampler(self, texture: Texture2D) -> Sampler: """ Create a sampler object for a texture. @@ -1128,15 +986,16 @@ def sampler(self, texture: Texture2D) -> Sampler: texture: The texture to create a sampler for """ - return Sampler(self, texture) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def geometry( self, - content: Sequence[BufferDescription] | None = None, + content=None, index_buffer: Buffer | None = None, mode: int | None = None, index_element_size: int = 4, - ): + ) -> Geometry: """ Create a Geometry instance. This is Arcade's version of a vertex array adding a lot of convenience for the user. Geometry objects are fairly light. They are @@ -1213,14 +1072,9 @@ def geometry( In other words, the index buffer can be 1, 2 or 4 byte integers. Can be 1, 2 or 4 (8, 16 or 32 bit unsigned integer) """ - return Geometry( - self, - content, - index_buffer=index_buffer, - mode=mode, - index_element_size=index_element_size, - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def program( self, *, @@ -1266,48 +1120,9 @@ def program( Based on these settings the ``transform()`` method will accept a single buffer or a list of buffer. """ - source_vs = ShaderSource(self, vertex_shader, common, gl.GL_VERTEX_SHADER) - source_fs = ( - ShaderSource(self, fragment_shader, common, gl.GL_FRAGMENT_SHADER) - if fragment_shader - else None - ) - source_geo = ( - ShaderSource(self, geometry_shader, common, gl.GL_GEOMETRY_SHADER) - if geometry_shader - else None - ) - source_tc = ( - ShaderSource(self, tess_control_shader, common, gl.GL_TESS_CONTROL_SHADER) - if tess_control_shader - else None - ) - source_te = ( - ShaderSource(self, tess_evaluation_shader, common, gl.GL_TESS_EVALUATION_SHADER) - if tess_evaluation_shader - else None - ) - - # If we don't have a fragment shader we are doing transform feedback. - # When a geometry shader is present the out attributes will be located there - out_attributes = list(varyings) if varyings is not None else [] # type: List[str] - if not source_fs and not out_attributes: - if source_geo: - out_attributes = source_geo.out_attributes - else: - out_attributes = source_vs.out_attributes - - return Program( - self, - vertex_shader=source_vs.get_source(defines=defines), - fragment_shader=source_fs.get_source(defines=defines) if source_fs else None, - geometry_shader=source_geo.get_source(defines=defines) if source_geo else None, - tess_control_shader=source_tc.get_source(defines=defines) if source_tc else None, - tess_evaluation_shader=source_te.get_source(defines=defines) if source_te else None, - varyings=out_attributes, - varyings_capture_mode=varyings_capture_mode, - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def query(self, *, samples=True, time=True, primitives=True) -> Query: """ Create a query object for measuring rendering calls in opengl. @@ -1317,8 +1132,9 @@ def query(self, *, samples=True, time=True, primitives=True) -> Query: time: Measure rendering duration primitives: Collect the number of primitives emitted """ - return Query(self, samples=samples, time=time, primitives=primitives) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def compute_shader(self, *, source: str, common: Iterable[str] = ()) -> ComputeShader: """ Create a compute shader. @@ -1329,8 +1145,7 @@ def compute_shader(self, *, source: str, common: Iterable[str] = ()) -> ComputeS common: Common / library source injected into compute shader """ - src = ShaderSource(self, source, common, gl.GL_COMPUTE_SHADER) - return ComputeShader(self, src.get_source()) + raise NotImplementedError("The enabled graphics backend does not support this method.") class ContextStats: @@ -1395,187 +1210,137 @@ def decr(self, key): setattr(self, key, (created, freed + 1)) -class GLInfo: +class Info(ABC): """OpenGL info and capabilities""" def __init__(self, ctx): self._ctx = ctx - self.MINOR_VERSION = self.get(gl.GL_MINOR_VERSION) - """Minor version number of the OpenGL API supported by the current context""" - - self.MAJOR_VERSION = self.get(gl.GL_MAJOR_VERSION) - """Major version number of the OpenGL API supported by the current context.""" - - self.VENDOR = self.get_str(gl.GL_VENDOR) + self.VENDOR = self.get_str(enums.VENDOR) """The vendor string. For example 'NVIDIA Corporation'""" - self.RENDERER = self.get_str(gl.GL_RENDERER) + self.RENDERER = self.get_str(enums.RENDERER) """The renderer things. For example "NVIDIA GeForce RTX 2080 SUPER/PCIe/SSE2""" - self.SAMPLE_BUFFERS = self.get(gl.GL_SAMPLE_BUFFERS) + self.SAMPLE_BUFFERS = self.get(enums.SAMPLE_BUFFERS) """Value indicating the number of sample buffers associated with the framebuffer""" - self.SUBPIXEL_BITS = self.get(gl.GL_SUBPIXEL_BITS) + self.SUBPIXEL_BITS = self.get(enums.SUBPIXEL_BITS) """ An estimate of the number of bits of subpixel resolution that are used to position rasterized geometry in window coordinates """ - self.UNIFORM_BUFFER_OFFSET_ALIGNMENT = self.get(gl.GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT) + self.UNIFORM_BUFFER_OFFSET_ALIGNMENT = self.get(enums.UNIFORM_BUFFER_OFFSET_ALIGNMENT) """Minimum required alignment for uniform buffer sizes and offset""" - self.MAX_ARRAY_TEXTURE_LAYERS = self.get(gl.GL_MAX_ARRAY_TEXTURE_LAYERS) + self.MAX_ARRAY_TEXTURE_LAYERS = self.get(enums.MAX_ARRAY_TEXTURE_LAYERS) """ Value indicates the maximum number of layers allowed in an array texture, and must be at least 256 """ - self.MAX_3D_TEXTURE_SIZE = self.get(gl.GL_MAX_3D_TEXTURE_SIZE) + self.MAX_3D_TEXTURE_SIZE = self.get(enums.MAX_3D_TEXTURE_SIZE) """ A rough estimate of the largest 3D texture that the GL can handle. The value must be at least 64 """ - self.MAX_COLOR_ATTACHMENTS = self.get(gl.GL_MAX_COLOR_ATTACHMENTS) + self.MAX_COLOR_ATTACHMENTS = self.get(enums.MAX_COLOR_ATTACHMENTS) """Maximum number of color attachments in a framebuffer""" - self.MAX_COLOR_TEXTURE_SAMPLES = self.get(gl.GL_MAX_COLOR_TEXTURE_SAMPLES) - """Maximum number of samples in a color multisample texture""" + self.MAX_COMBINED_VERTEX_UNIFORM_COMPONENTS = self.get( + enums.MAX_COMBINED_VERTEX_UNIFORM_COMPONENTS + ) + """Number of words for vertex shader uniform variables in all uniform blocks""" self.MAX_COMBINED_FRAGMENT_UNIFORM_COMPONENTS = self.get( - gl.GL_MAX_COMBINED_FRAGMENT_UNIFORM_COMPONENTS + enums.MAX_COMBINED_FRAGMENT_UNIFORM_COMPONENTS ) """the number of words for fragment shader uniform variables in all uniform blocks""" - self.MAX_COMBINED_GEOMETRY_UNIFORM_COMPONENTS = self.get( - gl.GL_MAX_COMBINED_GEOMETRY_UNIFORM_COMPONENTS - ) - """Number of words for geometry shader uniform variables in all uniform blocks""" - - self.MAX_COMBINED_TEXTURE_IMAGE_UNITS = self.get(gl.GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS) + self.MAX_COMBINED_TEXTURE_IMAGE_UNITS = self.get(enums.MAX_COMBINED_TEXTURE_IMAGE_UNITS) """ Maximum supported texture image units that can be used to access texture maps from the vertex shader """ - self.MAX_COMBINED_UNIFORM_BLOCKS = self.get(gl.GL_MAX_COMBINED_UNIFORM_BLOCKS) + self.MAX_COMBINED_UNIFORM_BLOCKS = self.get(enums.MAX_COMBINED_UNIFORM_BLOCKS) """Maximum number of uniform blocks per program""" - self.MAX_COMBINED_VERTEX_UNIFORM_COMPONENTS = self.get( - gl.GL_MAX_COMBINED_VERTEX_UNIFORM_COMPONENTS - ) - """Number of words for vertex shader uniform variables in all uniform blocks""" - - self.MAX_CUBE_MAP_TEXTURE_SIZE = self.get(gl.GL_MAX_CUBE_MAP_TEXTURE_SIZE) + self.MAX_CUBE_MAP_TEXTURE_SIZE = self.get(enums.MAX_CUBE_MAP_TEXTURE_SIZE) """A rough estimate of the largest cube-map texture that the GL can handle""" - self.MAX_DEPTH_TEXTURE_SAMPLES = self.get(gl.GL_MAX_DEPTH_TEXTURE_SAMPLES) - """Maximum number of samples in a multisample depth or depth-stencil texture""" - - self.MAX_DRAW_BUFFERS = self.get(gl.GL_MAX_DRAW_BUFFERS) + self.MAX_DRAW_BUFFERS = self.get(enums.MAX_DRAW_BUFFERS) """Maximum number of simultaneous outputs that may be written in a fragment shader""" - self.MAX_ELEMENTS_INDICES = self.get(gl.GL_MAX_ELEMENTS_INDICES) - """Recommended maximum number of vertex array indices""" - - self.MAX_ELEMENTS_VERTICES = self.get(gl.GL_MAX_ELEMENTS_VERTICES) + self.MAX_ELEMENTS_VERTICES = self.get(enums.MAX_ELEMENTS_VERTICES) """Recommended maximum number of vertex array vertices""" - self.MAX_FRAGMENT_INPUT_COMPONENTS = self.get(gl.GL_MAX_FRAGMENT_INPUT_COMPONENTS) + self.MAX_ELEMENTS_INDICES = self.get(enums.MAX_ELEMENTS_INDICES) + """Recommended maximum number of vertex array indices""" + + self.MAX_FRAGMENT_INPUT_COMPONENTS = self.get(enums.MAX_FRAGMENT_INPUT_COMPONENTS) """Maximum number of components of the inputs read by the fragment shader""" - self.MAX_FRAGMENT_UNIFORM_COMPONENTS = self.get(gl.GL_MAX_FRAGMENT_UNIFORM_COMPONENTS) + self.MAX_FRAGMENT_UNIFORM_COMPONENTS = self.get(enums.MAX_FRAGMENT_UNIFORM_COMPONENTS) """ Maximum number of individual floating-point, integer, or boolean values that can be held in uniform variable storage for a fragment shader """ - self.MAX_FRAGMENT_UNIFORM_VECTORS = self.get(gl.GL_MAX_FRAGMENT_UNIFORM_VECTORS) + self.MAX_FRAGMENT_UNIFORM_VECTORS = self.get(enums.MAX_FRAGMENT_UNIFORM_VECTORS) """ Maximum number of individual 4-vectors of floating-point, integer, or boolean values that can be held in uniform variable storage for a fragment shader """ - self.MAX_FRAGMENT_UNIFORM_BLOCKS = self.get(gl.GL_MAX_FRAGMENT_UNIFORM_BLOCKS) + self.MAX_FRAGMENT_UNIFORM_BLOCKS = self.get(enums.MAX_FRAGMENT_UNIFORM_BLOCKS) """Maximum number of uniform blocks per fragment shader.""" - self.MAX_GEOMETRY_INPUT_COMPONENTS = self.get(gl.GL_MAX_GEOMETRY_INPUT_COMPONENTS) - """Maximum number of components of inputs read by a geometry shader""" - - self.MAX_GEOMETRY_OUTPUT_COMPONENTS = self.get(gl.GL_MAX_GEOMETRY_OUTPUT_COMPONENTS) - """Maximum number of components of outputs written by a geometry shader""" - - self.MAX_GEOMETRY_TEXTURE_IMAGE_UNITS = self.get(gl.GL_MAX_GEOMETRY_TEXTURE_IMAGE_UNITS) - """ - Maximum supported texture image units that can be used to access texture - maps from the geometry shader - """ - - self.MAX_GEOMETRY_UNIFORM_BLOCKS = self.get(gl.GL_MAX_GEOMETRY_UNIFORM_BLOCKS) - """Maximum number of uniform blocks per geometry shader""" - - self.MAX_GEOMETRY_UNIFORM_COMPONENTS = self.get(gl.GL_MAX_GEOMETRY_UNIFORM_COMPONENTS) - """ - Maximum number of individual floating-point, integer, or boolean values that can - be held in uniform variable storage for a geometry shader - """ - - self.MAX_INTEGER_SAMPLES = self.get(gl.GL_MAX_INTEGER_SAMPLES) - """Maximum number of samples supported in integer format multisample buffers""" - - self.MAX_SAMPLES = self.get(gl.GL_MAX_SAMPLES) + self.MAX_SAMPLES = self.get(enums.MAX_SAMPLES) """Maximum samples for a framebuffer""" - self.MAX_RENDERBUFFER_SIZE = self.get(gl.GL_MAX_RENDERBUFFER_SIZE) + self.MAX_RENDERBUFFER_SIZE = self.get(enums.MAX_RENDERBUFFER_SIZE) """Maximum supported size for renderbuffers""" - self.MAX_SAMPLE_MASK_WORDS = self.get(gl.GL_MAX_SAMPLE_MASK_WORDS) - """Maximum number of sample mask words""" - - self.MAX_UNIFORM_BUFFER_BINDINGS = self.get(gl.GL_MAX_UNIFORM_BUFFER_BINDINGS) - """Maximum number of uniform buffer binding points on the context""" - - self.MAX_UNIFORM_BUFFER_BINDINGS = self.get(gl.GL_MAX_UNIFORM_BUFFER_BINDINGS) + self.MAX_UNIFORM_BUFFER_BINDINGS = self.get(enums.MAX_UNIFORM_BUFFER_BINDINGS) """Maximum number of uniform buffer binding points on the context""" - self.MAX_TEXTURE_SIZE = self.get(gl.GL_MAX_TEXTURE_SIZE) + self.MAX_TEXTURE_SIZE = self.get(enums.MAX_TEXTURE_SIZE) """The value gives a rough estimate of the largest texture that the GL can handle""" - self.MAX_UNIFORM_BUFFER_BINDINGS = self.get(gl.GL_MAX_UNIFORM_BUFFER_BINDINGS) - """Maximum number of uniform buffer binding points on the context""" - - self.MAX_UNIFORM_BLOCK_SIZE = self.get(gl.GL_MAX_UNIFORM_BLOCK_SIZE) + self.MAX_UNIFORM_BLOCK_SIZE = self.get(enums.MAX_UNIFORM_BLOCK_SIZE) """Maximum size in basic machine units of a uniform block""" - self.MAX_VARYING_VECTORS = self.get(gl.GL_MAX_VARYING_VECTORS) + self.MAX_VARYING_VECTORS = self.get(enums.MAX_VARYING_VECTORS) """The number 4-vectors for varying variables""" - self.MAX_VERTEX_ATTRIBS = self.get(gl.GL_MAX_VERTEX_ATTRIBS) + self.MAX_VERTEX_ATTRIBS = self.get(enums.MAX_VERTEX_ATTRIBS) """Maximum number of 4-component generic vertex attributes accessible to a vertex shader.""" - self.MAX_VERTEX_TEXTURE_IMAGE_UNITS = self.get(gl.GL_MAX_VERTEX_TEXTURE_IMAGE_UNITS) + self.MAX_VERTEX_TEXTURE_IMAGE_UNITS = self.get(enums.MAX_VERTEX_TEXTURE_IMAGE_UNITS) """ Maximum supported texture image units that can be used to access texture maps from the vertex shader. """ - self.MAX_VERTEX_UNIFORM_COMPONENTS = self.get(gl.GL_MAX_VERTEX_UNIFORM_COMPONENTS) + self.MAX_VERTEX_UNIFORM_COMPONENTS = self.get(enums.MAX_VERTEX_UNIFORM_COMPONENTS) """ Maximum number of individual floating-point, integer, or boolean values that can be held in uniform variable storage for a vertex shader """ - self.MAX_VERTEX_UNIFORM_VECTORS = self.get(gl.GL_MAX_VERTEX_UNIFORM_VECTORS) + self.MAX_VERTEX_UNIFORM_VECTORS = self.get(enums.MAX_VERTEX_UNIFORM_VECTORS) """ Maximum number of 4-vectors that may be held in uniform variable storage for the vertex shader """ - self.MAX_VERTEX_OUTPUT_COMPONENTS = self.get(gl.GL_MAX_VERTEX_OUTPUT_COMPONENTS) + self.MAX_VERTEX_OUTPUT_COMPONENTS = self.get(enums.MAX_VERTEX_OUTPUT_COMPONENTS) """Maximum number of components of output written by a vertex shader""" - self.MAX_VERTEX_UNIFORM_BLOCKS = self.get(gl.GL_MAX_VERTEX_UNIFORM_BLOCKS) + self.MAX_VERTEX_UNIFORM_BLOCKS = self.get(enums.MAX_VERTEX_UNIFORM_BLOCKS) """Maximum number of uniform blocks per vertex shader.""" # self.MAX_VERTEX_ATTRIB_RELATIVE_OFFSET = self.get( @@ -1583,42 +1348,34 @@ def __init__(self, ctx): # ) # self.MAX_VERTEX_ATTRIB_BINDINGS = self.get(gl.GL_MAX_VERTEX_ATTRIB_BINDINGS) - self.MAX_TEXTURE_IMAGE_UNITS = self.get(gl.GL_MAX_TEXTURE_IMAGE_UNITS) + self.MAX_TEXTURE_IMAGE_UNITS = self.get(enums.MAX_TEXTURE_IMAGE_UNITS) """Number of texture units""" - self.MAX_TEXTURE_MAX_ANISOTROPY = self.get_float(gl.GL_MAX_TEXTURE_MAX_ANISOTROPY, 1.0) + self.MAX_TEXTURE_MAX_ANISOTROPY = self.get_float(enums.MAX_TEXTURE_MAX_ANISOTROPY, 1.0) """The highest supported anisotropy value. Usually 8.0 or 16.0.""" - self.MAX_VIEWPORT_DIMS: Tuple[int, int] = self.get_int_tuple(gl.GL_MAX_VIEWPORT_DIMS, 2) + self.MAX_VIEWPORT_DIMS: Tuple[int, int] = self.get_int_tuple(enums.MAX_VIEWPORT_DIMS, 2) """ The maximum support window or framebuffer viewport. This is usually the same as the maximum texture size """ self.MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS = self.get( - gl.GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS + enums.MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS ) """ How many buffers we can have as output when doing a transform(feedback). This is usually 4. """ - self.POINT_SIZE_RANGE = self.get_int_tuple(gl.GL_POINT_SIZE_RANGE, 2) - """The minimum and maximum point size""" - - err = self._ctx.error - if err: - from warnings import warn - - warn("Error happened while querying of limits. Moving on ..") - @overload - def get_int_tuple(self, enum: GLenumLike, length: Literal[2]) -> Tuple[int, int]: ... + def get_int_tuple(self, enum, length: Literal[2]) -> Tuple[int, int]: ... @overload - def get_int_tuple(self, enum: GLenumLike, length: int) -> Tuple[int, ...]: ... + def get_int_tuple(self, enum, length: int) -> Tuple[int, ...]: ... - def get_int_tuple(self, enum: GLenumLike, length: int): + @abstractmethod + def get_int_tuple(self, enum, length: int): """ Get an enum as an int tuple @@ -1626,14 +1383,10 @@ def get_int_tuple(self, enum: GLenumLike, length: int): enum: The enum to query length: The length of the tuple """ - try: - values = (c_int * length)() - gl.glGetIntegerv(enum, values) - return tuple(values) - except pyglet.gl.lib.GLException: - return tuple([0] * length) + raise NotImplementedError("The enabled graphics backend does not support this method.") - def get(self, enum: GLenumLike, default=0) -> int: + @abstractmethod + def get(self, enum, default=0) -> int: """ Get an integer limit. @@ -1641,14 +1394,10 @@ def get(self, enum: GLenumLike, default=0) -> int: enum: The enum to query default: The default value if the query fails """ - try: - value = c_int() - gl.glGetIntegerv(enum, value) - return value.value - except pyglet.gl.lib.GLException: - return default + raise NotImplementedError("The enabled graphics backend does not support this method.") - def get_float(self, enum: GLenumLike, default=0.0) -> float: + @abstractmethod + def get_float(self, enum, default=0.0) -> float: """ Get a float limit @@ -1656,21 +1405,14 @@ def get_float(self, enum: GLenumLike, default=0.0) -> float: enum: The enum to query default: The default value if the query fails """ - try: - value = c_float() - gl.glGetFloatv(enum, value) - return value.value - except pyglet.gl.lib.GLException: - return default + raise NotImplementedError("The enabled graphics backend does not support this method.") - def get_str(self, enum: GLenumLike) -> str: + @abstractmethod + def get_str(self, enum) -> str: """ Get a string limit. Args: enum: The enum to query """ - try: - return cast(gl.glGetString(enum), c_char_p).value.decode() # type: ignore - except pyglet.gl.lib.GLException: - return "Unknown" + raise NotImplementedError("The enabled graphics backend does not support this method.") diff --git a/arcade/gl/enums.py b/arcade/gl/enums.py index 775fec45a0..4b410dd7df 100644 --- a/arcade/gl/enums.py +++ b/arcade/gl/enums.py @@ -1,4 +1,68 @@ -from pyglet import gl +""" +This module contains hard-coded hexadecimal enum values for OpenGL and WebGL values. +Only enums which are present in both OpenGL and WebGL are included, all of these enums, +based on the specifications, are the same. + +We are storing them here, so that we do not rely on importing the common subset of them from any +particular backend implementation's library(e.g. pyglet, JS via pyodide, etc). +""" + +NONE = 0 +NEVER = 0x0200 +LESS = 0x0201 +EQUAL = 0x0202 +LEQUAL = 0x0203 +GREATER = 0x0204 +NOTEQUAL = 0x0205 +GEQUAL = 0x0206 +ALWAYS = 0x0207 + +# Get Parameters +VENDOR = 0x1F00 +RENDERER = 0x1F01 +VERSION = 0x1F02 +SAMPLE_BUFFERS = 0x80A8 +SUBPIXEL_BITS = 0x0D50 +UNIFORM_BUFFER_OFFSET_ALIGNMENT = 0x8A34 +MAX_ARRAY_TEXTURE_LAYERS = 0x88FF +MAX_3D_TEXTURE_SIZE = 0x8073 +MAX_COLOR_ATTACHMENTS = 0x8CDF +MAX_COMBINED_VERTEX_UNIFORM_COMPONENTS = 0x8A31 +MAX_COMBINED_FRAGMENT_UNIFORM_COMPONENTS = 0x8A33 +MAX_COMBINED_TEXTURE_IMAGE_UNITS = 0x8B4D +MAX_COMBINED_UNIFORM_BLOCKS = 0x8A2E +MAX_CUBE_MAP_TEXTURE_SIZE = 0x851C +MAX_DRAW_BUFFERS = 0x8824 +MAX_ELEMENTS_VERTICES = 0x80E8 +MAX_ELEMENTS_INDICES = 0x80E9 +MAX_FRAGMENT_INPUT_COMPONENTS = 0x9125 +MAX_FRAGMENT_UNIFORM_COMPONENTS = 0x8B49 +MAX_FRAGMENT_UNIFORM_VECTORS = 0x8DFD +MAX_FRAGMENT_UNIFORM_BLOCKS = 0x8A2D +MAX_SAMPLES = 0x8D57 +MAX_RENDERBUFFER_SIZE = 0x84E8 +MAX_UNIFORM_BUFFER_BINDINGS = 0x8A2F +MAX_TEXTURE_SIZE = 0x0D33 +MAX_UNIFORM_BLOCK_SIZE = 0x8A30 +MAX_VARYING_VECTORS = 0x8DFC +MAX_VERTEX_ATTRIBS = 0x8869 +MAX_VERTEX_TEXTURE_IMAGE_UNITS = 0x8B4C +MAX_VERTEX_UNIFORM_COMPONENTS = 0x8B4A +MAX_VERTEX_UNIFORM_VECTORS = 0x8DFB +MAX_VERTEX_UNIFORM_BLOCKS = 0x8A2B +MAX_VERTEX_OUTPUT_COMPONENTS = 0x9122 +MAX_TEXTURE_IMAGE_UNITS = 0x8872 + +# Technically comes from EXT_texture_filter_anisotropic in WebGL, but it's widely available +MAX_TEXTURE_MAX_ANISOTROPY = 0x84FF + +MAX_VIEWPORT_DIMS = 0x0D3A +MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS = 0x8C8B + +# Enable Flags +BLEND = 0x0BE2 +DEPTH_TEST = 0x0B71 +CULL_FACE = 0x0B44 # Texture min/mag filters NEAREST = 0x2600 @@ -9,10 +73,9 @@ LINEAR_MIPMAP_LINEAR = 0x2703 # Texture wrapping -REPEAT = gl.GL_REPEAT -CLAMP_TO_EDGE = gl.GL_CLAMP_TO_EDGE -CLAMP_TO_BORDER = gl.GL_CLAMP_TO_BORDER -MIRRORED_REPEAT = gl.GL_MIRRORED_REPEAT +REPEAT = 0x2901 +CLAMP_TO_EDGE = 0x812F +MIRRORED_REPEAT = 0x8370 # Blend functions ZERO = 0x0000 @@ -60,3 +123,116 @@ TRIANGLES_ADJACENCY = 12 TRIANGLE_STRIP_ADJACENCY = 13 PATCHES = 14 + +# Errors +NO_ERROR = 0 +INVALID_ENUM = 0x0500 +INVALID_VALUE = 0x0501 +INVALID_OPERATION = 0x0502 +INVALID_FRAMEBUFFER_OPERATION = 0x0506 +OUT_OF_MEMORY = 0x0505 + +FRONT = 0x0404 +BACK = 0x0405 +FRONT_AND_BACK = 0x0408 + +RED = 0x1903 +RG = 0x8227 +RGB = 0x1907 +RGBA = 0x1908 +RED_INTEGER = 0x8D94 +RG_INTEGER = 0x8228 +RGB_INTEGER = 0x8D98 +RGBA_INTEGER = 0x8D99 + +R8 = 0x8229 +RG8 = 0x822B +RGB8 = 0x8051 +RGBA8 = 0x8058 +R16F = 0x822D +RG16F = 0x822F +RGB16F = 0x881B +RGBA16F = 0x881A +R32F = 0x822E +RG32F = 0x8230 +RGB32F = 0x8815 +RGBA32F = 0x8814 +R8I = 0x8231 +RG8I = 0x8237 +RGB8I = 0x8D8F +RGBA8I = 0x8D8E +R16I = 0x8233 +RG16I = 0x8239 +RGB16I = 0x8D89 +RGBA16I = 0x8D88 +R32I = 0x8235 +RG32I = 0x823B +RGB32I = 0x8D83 +RGBA32I = 0x8D82 +R8UI = 0x8232 +RG8UI = 0x8238 +RGB8UI = 0x8D7D +RGBA8UI = 0x8D7C +R16UI = 0x8234 +RG16UI = 0x823A +RGB16UI = 0x8D77 +RGBA16UI = 0x8D76 +R32UI = 0x8236 +RG32UI = 0x823C +RGB32UI = 0x8D71 +RGBA32UI = 0x8D70 + +BYTE = 0x1400 +UNSIGNED_BYTE = 0x1401 +SHORT = 0x1402 +UNSIGNED_SHORT = 0x1403 +INT = 0x1404 +UNSIGNED_INT = 0x1405 +FLOAT = 0x1406 +HALF_FLOAT = 0x140B +DOUBLE = 0x140A # Not supported in WebGL, but left in common to make implementation easier + +FLOAT_VEC2 = 0x8B50 +FLOAT_VEC3 = 0x8B51 +FLOAT_VEC4 = 0x8B52 +INT_VEC2 = 0x8B53 +INT_VEC3 = 0x8B54 +INT_VEC4 = 0x8B55 +BOOL = 0x8B56 +BOOL_VEC2 = 0x8B57 +BOOL_VEC3 = 0x8B58 +BOOL_VEC4 = 0x8B59 +UNSIGNED_INT_VEC2 = 0x8DC6 +UNSIGNED_INT_VEC3 = 0x8DC7 +UNSIGNED_INT_VEC4 = 0x8DC8 +FLOAT_MAT2 = 0x8B5A +FLOAT_MAT3 = 0x8B5B +FLOAT_MAT4 = 0x8B5C +FLOAT_MAT2x3 = 0x8B65 +FLOAT_MAT2x4 = 0x8B66 +FLOAT_MAT3x2 = 0x8B67 +FLOAT_MAT3x4 = 0x8B68 +FLOAT_MAT4x2 = 0x8B69 +FLOAT_MAT4x3 = 0x8B6A + +# Double Vectors - Unsupported by WebGL +DOUBLE_VEC2 = 0x8FFC +DOUBLE_VEC3 = 0x8FFD +DOUBLE_VEC4 = 0x8FFE + +# Double Matrices - Unsupported by WebGL +DOUBLE_MAT2 = 0x8F46 +DOUBLE_MAT3 = 0x8F47 +DOUBLE_MAT4 = 0x8F48 +DOUBLE_MAT2x3 = 0x8F49 +DOUBLE_MAT2x4 = 0x8F4A +DOUBLE_MAT3x2 = 0x8F4B +DOUBLE_MAT3x4 = 0x8F4C +DOUBLE_MAT4x2 = 0x8F4D +DOUBLE_MAT4x3 = 0x8F4E + +VERTEX_SHADER = 0x8B31 +FRAGMENT_SHADER = 0x8B30 +GEOMETRY_SHADER = 0x8DD9 # Not supported in WebGL +TESS_CONTROL_SHADER = 0x8E88 # Not supported in WebGL +TESS_EVALUATION_SHADER = 0x8E87 # Not supported in WebGL diff --git a/arcade/gl/framebuffer.py b/arcade/gl/framebuffer.py index 188c5a5634..65d109161c 100644 --- a/arcade/gl/framebuffer.py +++ b/arcade/gl/framebuffer.py @@ -1,22 +1,18 @@ from __future__ import annotations -import weakref +from abc import ABC, abstractmethod from contextlib import contextmanager -from ctypes import Array, c_int, c_uint, string_at from typing import TYPE_CHECKING, Generator -from pyglet import gl - from arcade.types import RGBOrA255, RGBOrANormalized from .texture import Texture2D -from .types import pixel_formats if TYPE_CHECKING: from arcade.gl import Context -class Framebuffer: +class Framebuffer(ABC): """ An offscreen render target also called a Framebuffer Object in OpenGL. This implementation is using texture attachments. When creating a @@ -51,7 +47,6 @@ class Framebuffer: is_default = False __slots__ = ( "_ctx", - "_glo", "_width", "_height", "_color_attachments", @@ -72,7 +67,6 @@ def __init__( color_attachments: Texture2D | list[Texture2D], depth_attachment: Texture2D | None = None, ): - self._glo = fbo_id = gl.GLuint() # The OpenGL alias/name self._ctx = ctx if not color_attachments: raise ValueError("Framebuffer must at least have one color attachment") @@ -85,10 +79,6 @@ def __init__( self._depth_mask = True # Determines if the depth buffer should be affected self._prev_fbo = None - # Create the framebuffer object - gl.glGenFramebuffers(1, self._glo) - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._glo) - # Ensure all attachments have the same size. # OpenGL do actually support different sizes, # but let's keep this simple with high compatibility. @@ -96,55 +86,10 @@ def __init__( self._viewport = 0, 0, self._width, self._height self._scissor: tuple[int, int, int, int] | None = None - # Attach textures to it - for i, tex in enumerate(self._color_attachments): - # TODO: Possibly support attaching a specific mipmap level - # but we can read from specific mip levels from shaders. - gl.glFramebufferTexture2D( - gl.GL_FRAMEBUFFER, - gl.GL_COLOR_ATTACHMENT0 + i, - tex._target, - tex.glo, - 0, # Level 0 - ) - - if self.depth_attachment: - gl.glFramebufferTexture2D( - gl.GL_FRAMEBUFFER, - gl.GL_DEPTH_ATTACHMENT, - self.depth_attachment._target, - self.depth_attachment.glo, - 0, - ) - - # Ensure the framebuffer is sane! - self._check_completeness() - - # Set up draw buffers. This is simply a prepared list of attachments enums - # we use in the use() method to activate the different color attachment layers - layers = [gl.GL_COLOR_ATTACHMENT0 + i for i, _ in enumerate(self._color_attachments)] - # pyglet wants this as a ctypes thingy, so let's prepare it - self._draw_buffers: Array[c_uint] | None = (gl.GLuint * len(layers))(*layers) - - # Restore the original bound framebuffer to avoid confusion - self.ctx.active_framebuffer.use(force=True) - - if self._ctx.gc_mode == "auto" and not self.is_default: - weakref.finalize(self, Framebuffer.delete_glo, ctx, fbo_id) - self.ctx.stats.incr("framebuffer") - def __del__(self): - # Intercept garbage collection if we are using Context.gc() - if self._ctx.gc_mode == "context_gc" and not self.is_default and self._glo.value > 0: - self._ctx.objects.append(self) - @property - def glo(self) -> gl.GLuint: - """The OpenGL id/name of the framebuffer.""" - return self._glo - - def _get_viewport(self) -> tuple[int, int, int, int]: + def viewport(self) -> tuple[int, int, int, int]: """ Get or set the framebuffer's viewport. @@ -162,24 +107,13 @@ def _get_viewport(self) -> tuple[int, int, int, int]: """ return self._viewport - def _set_viewport(self, value: tuple[int, int, int, int]): - if not isinstance(value, tuple) or len(value) != 4: - raise ValueError("viewport should be a 4-component tuple") - - self._viewport = value + @viewport.setter + @abstractmethod + def viewport(self, value: tuple[int, int, int, int]): + raise NotImplementedError("The enabled graphics backend does not support this method.") - # If the framebuffer is bound we need to set the viewport. - # Otherwise it will be set on use() - if self._ctx.active_framebuffer == self: - gl.glViewport(*self._viewport) - if self._scissor is None: - gl.glScissor(*self._viewport) - else: - gl.glScissor(*self._scissor) - - viewport = property(_get_viewport, _set_viewport) - - def _get_scissor(self) -> tuple[int, int, int, int] | None: + @property + def scissor(self) -> tuple[int, int, int, int] | None: """ Get or set the scissor box for this framebuffer. @@ -199,17 +133,10 @@ def _get_scissor(self) -> tuple[int, int, int, int] | None: """ return self._scissor - def _set_scissor(self, value): - self._scissor = value - - if self._scissor is None: - if self._ctx.active_framebuffer == self: - gl.glScissor(*self._viewport) - else: - if self._ctx.active_framebuffer == self: - gl.glScissor(*self._scissor) - - scissor = property(_get_scissor, _set_scissor) + @scissor.setter + @abstractmethod + def scissor(self, value): + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def ctx(self) -> "Context": @@ -260,11 +187,9 @@ def depth_mask(self) -> bool: return self._depth_mask @depth_mask.setter + @abstractmethod def depth_mask(self, value: bool): - self._depth_mask = value - # Set state if framebuffer is active - if self._ctx.active_framebuffer == self: - gl.glDepthMask(self._depth_mask) + raise NotImplementedError("The enabled graphics backend does not support this method.") def __enter__(self): self._prev_fbo = self._ctx.active_framebuffer @@ -300,25 +225,12 @@ def use(self, *, force: bool = False): self._use(force=force) self._ctx.active_framebuffer = self + @abstractmethod def _use(self, *, force: bool = False): """Internal use that do not change the global active framebuffer""" - if self.ctx.active_framebuffer == self and not force: - return - - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._glo) - - # NOTE: gl.glDrawBuffer(GL_NONE) if no texture attachments (future) - # NOTE: Default framebuffer currently has this set to None - if self._draw_buffers: - gl.glDrawBuffers(len(self._draw_buffers), self._draw_buffers) - - gl.glDepthMask(self._depth_mask) - gl.glViewport(*self._viewport) - if self._scissor is not None: - gl.glScissor(*self._scissor) - else: - gl.glScissor(*self._viewport) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def clear( self, *, @@ -350,44 +262,9 @@ def clear( viewport: The viewport range to clear """ - with self.activate(): - scissor_values = self._scissor - - if viewport: - self.scissor = viewport - else: - self.scissor = None - - clear_color = 0.0, 0.0, 0.0, 0.0 - if color is not None: - if len(color) == 3: - clear_color = color[0] / 255, color[1] / 255, color[2] / 255, 1.0 - elif len(color) == 4: - clear_color = color[0] / 255, color[1] / 255, color[2] / 255, color[3] / 255 - else: - raise ValueError("Color should be a 3 or 4 component tuple") - elif color_normalized is not None: - if len(color_normalized) == 3: - clear_color = color_normalized[0], color_normalized[1], color_normalized[2], 1.0 - elif len(color_normalized) == 4: - clear_color = color_normalized - else: - raise ValueError("Color should be a 3 or 4 component tuple") - - gl.glClearColor(*clear_color) - - if self.depth_attachment: - if self._ctx.gl_api == "gl": - gl.glClearDepth(depth) - else: # gles only supports glClearDepthf - gl.glClearDepthf(depth) - - gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) - else: - gl.glClear(gl.GL_COLOR_BUFFER_BIT) - - self.scissor = scissor_values + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def read(self, *, viewport=None, components=3, attachment=0, dtype="f1") -> bytes: """ Read the raw framebuffer pixels. @@ -409,35 +286,7 @@ def read(self, *, viewport=None, components=3, attachment=0, dtype="f1") -> byte dtype: The data type to read. Pixel data will be converted to this format. """ - # TODO: use texture attachment info to determine read format? - try: - frmt = pixel_formats[dtype] - base_format = frmt[0][components] - pixel_type = frmt[2] - component_size = frmt[3] - except Exception: - raise ValueError(f"Invalid dtype '{dtype}'") - - with self.activate(): - # Configure attachment to read from. Does not work on default framebuffer. - if not self.is_default: - gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0 + attachment) - - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) - gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) - - if viewport: - x, y, width, height = viewport - else: - x, y, width, height = 0, 0, *self.size - - data = (gl.GLubyte * (components * component_size * width * height))(0) - gl.glReadPixels(x, y, width, height, base_format, pixel_type, data) - - if not self.is_default: - gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0) # Reset to default - - return string_at(data, len(data)) + raise NotImplementedError("The enabled graphics backend does not support this method.") def resize(self): """ @@ -448,31 +297,14 @@ def resize(self): self._width, self._height = self._detect_size() self.viewport = 0, 0, self.width, self._height + @abstractmethod def delete(self): """ Destroy the underlying OpenGL resource. .. warning:: Don't use this unless you know exactly what you are doing. """ - Framebuffer.delete_glo(self._ctx, self._glo) - self._glo.value = 0 - - @staticmethod - def delete_glo(ctx, framebuffer_id): - """ - Destroys the framebuffer object - - Args: - ctx: - The context this framebuffer belongs to - framebuffer_id: - Framebuffer id destroy (glo) - """ - if gl.current_context is None: - return - - gl.glDeleteFramebuffers(1, framebuffer_id) - ctx.stats.decr("framebuffer") + raise NotImplementedError("The enabled graphics backend does not support this method.") def _detect_size(self) -> tuple[int, int]: """Detect the size of the framebuffer based on the attachments""" @@ -492,36 +324,8 @@ def _detect_size(self) -> tuple[int, int]: raise ValueError("All framebuffer attachments should have the same size") return expected_size - @staticmethod - def _check_completeness() -> None: - """ - Checks the completeness of the framebuffer. - If the framebuffer is not complete, we cannot continue. - """ - # See completeness rules : https://www.khronos.org/opengl/wiki/Framebuffer_Object - states = { - gl.GL_FRAMEBUFFER_UNSUPPORTED: "Framebuffer unsupported. Try another format.", - gl.GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT: "Framebuffer incomplete attachment.", - gl.GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: "Framebuffer missing attachment.", - gl.GL_FRAMEBUFFER_INCOMPLETE_DIMENSIONS_EXT: "Framebuffer unsupported dimension.", - gl.GL_FRAMEBUFFER_INCOMPLETE_FORMATS_EXT: "Framebuffer incomplete formats.", - gl.GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER: "Framebuffer incomplete draw buffer.", - gl.GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER: "Framebuffer incomplete read buffer.", - gl.GL_FRAMEBUFFER_COMPLETE: "Framebuffer is complete.", - } - - status = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) - if status != gl.GL_FRAMEBUFFER_COMPLETE: - raise ValueError( - "Framebuffer is incomplete. {}".format(states.get(status, "Unknown error")) - ) - - def __repr__(self): - return "".format(self._glo.value) - - -class DefaultFrameBuffer(Framebuffer): +class DefaultFrameBuffer(Framebuffer, ABC): """ Represents the default framebuffer. @@ -539,8 +343,6 @@ class DefaultFrameBuffer(Framebuffer): is_default = True """Is this the default framebuffer? (window buffer)""" - __slots__ = () - def __init__(self, ctx: "Context"): self._ctx = ctx # TODO: Can we query this? @@ -550,10 +352,6 @@ def __init__(self, ctx: "Context"): self._depth_attachment = None self._depth_mask = True - value = c_int() - gl.glGetIntegerv(gl.GL_DRAW_FRAMEBUFFER_BINDING, value) - self._glo = gl.GLuint(value.value) - # Query draw buffers. This will most likely return GL_BACK # value = c_int() # gl.glGetIntegerv(gl.GL_DRAW_BUFFER0, value) @@ -562,16 +360,6 @@ def __init__(self, ctx: "Context"): # NOTE: Don't query for now self._draw_buffers = None - # Query viewport values by inspecting the scissor box - values = (c_int * 4)() - gl.glGetIntegerv(gl.GL_SCISSOR_BOX, values) - x, y, width, height = list(values) - - self._viewport = x, y, width, height - self._scissor = None - self._width = width - self._height = height - # HACK: Signal the default framebuffer having depth buffer self._depth_attachment = True # type: ignore @@ -594,7 +382,8 @@ def _get_framebuffer_size(self) -> tuple[int, int]: """Get the framebuffer size of the window""" return self._ctx.window.get_framebuffer_size() - def _get_viewport(self) -> tuple[int, int, int, int]: + @property + def viewport(self) -> tuple[int, int, int, int]: """ Get or set the framebuffer's viewport. The viewport parameter are ``(x, y, width, height)``. @@ -617,31 +406,13 @@ def _get_viewport(self) -> tuple[int, int, int, int]: int(self._viewport[3] / ratio), ) - def _set_viewport(self, value: tuple[int, int, int, int]): - if not isinstance(value, tuple) or len(value) != 4: - raise ValueError("viewport should be a 4-component tuple") - - ratio = self.ctx.window.get_pixel_ratio() - self._viewport = ( - int(value[0] * ratio), - int(value[1] * ratio), - int(value[2] * ratio), - int(value[3] * ratio), - ) - - # If the framebuffer is bound we need to set the viewport. - # Otherwise it will be set on use() - if self._ctx.active_framebuffer == self: - gl.glViewport(*self._viewport) - if self._scissor is None: - # FIXME: Probably should be set to the framebuffer size - gl.glScissor(*self._viewport) - else: - gl.glScissor(*self._scissor) + @viewport.setter + @abstractmethod + def viewport(self, value: tuple[int, int, int, int]): + raise NotImplementedError("The enabled graphics backend does not support this method.") - viewport = property(_get_viewport, _set_viewport) - - def _get_scissor(self) -> tuple[int, int, int, int] | None: + @property + def scissor(self) -> tuple[int, int, int, int] | None: """ Get or set the scissor box for this framebuffer. @@ -667,24 +438,7 @@ def _get_scissor(self) -> tuple[int, int, int, int] | None: int(self._scissor[3] / ratio), ) - def _set_scissor(self, value): - if value is None: - # FIXME: Do we need to reset something here? - self._scissor = None - if self._ctx.active_framebuffer == self: - gl.glScissor(*self._viewport) - else: - ratio = self.ctx.window.get_pixel_ratio() - self._scissor = ( - int(value[0] * ratio), - int(value[1] * ratio), - int(value[2] * ratio), - int(value[3] * ratio), - ) - - # If the framebuffer is bound we need to set the scissor box. - # Otherwise it will be set on use() - if self._ctx.active_framebuffer == self: - gl.glScissor(*self._scissor) - - scissor = property(_get_scissor, _set_scissor) + @scissor.setter + @abstractmethod + def scissor(self, value): + raise NotImplementedError("The enabled graphics backend does not support this method.") diff --git a/arcade/gl/program.py b/arcade/gl/program.py index b75a91a790..4eb56fe3bc 100644 --- a/arcade/gl/program.py +++ b/arcade/gl/program.py @@ -1,29 +1,13 @@ -import typing -import weakref -from ctypes import ( - POINTER, - byref, - c_buffer, - c_char, - c_char_p, - c_int, - cast, - create_string_buffer, - pointer, -) -from typing import TYPE_CHECKING, Any, Iterable - -from pyglet import gl +from __future__ import annotations -from .exceptions import ShaderException -from .types import SHADER_TYPE_NAMES, AttribFormat, GLTypes, PyGLenum -from .uniform import Uniform, UniformBlock +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Iterable if TYPE_CHECKING: from arcade.gl import Context -class Program: +class Program(ABC): """ Compiled and linked shader program. @@ -65,110 +49,21 @@ class Program: __slots__ = ( "_ctx", - "_glo", - "_uniforms", - "_varyings", "_varyings_capture_mode", - "_geometry_info", - "_attributes", "attribute_key", "__weakref__", ) - _valid_capture_modes = ("interleaved", "separate") - def __init__( self, - ctx: "Context", + ctx: Context, *, - vertex_shader: str, - fragment_shader: str | None = None, - geometry_shader: str | None = None, - tess_control_shader: str | None = None, - tess_evaluation_shader: str | None = None, - varyings: list[str] | None = None, varyings_capture_mode: str = "interleaved", ): self._ctx = ctx - self._glo = glo = gl.glCreateProgram() - self._varyings = varyings or [] - self._varyings_capture_mode = varyings_capture_mode.strip().lower() - self._geometry_info = (0, 0, 0) - self._attributes = [] # type: list[AttribFormat] - #: Internal cache key used with vertex arrays self.attribute_key = "INVALID" # type: str - self._uniforms: dict[str, Uniform | UniformBlock] = {} - - if self._varyings_capture_mode not in self._valid_capture_modes: - raise ValueError( - f"Invalid capture mode '{self._varyings_capture_mode}'. " - f"Valid modes are: {self._valid_capture_modes}." - ) - - shaders: list[tuple[str, int]] = [(vertex_shader, gl.GL_VERTEX_SHADER)] - if fragment_shader: - shaders.append((fragment_shader, gl.GL_FRAGMENT_SHADER)) - if geometry_shader: - shaders.append((geometry_shader, gl.GL_GEOMETRY_SHADER)) - if tess_control_shader: - shaders.append((tess_control_shader, gl.GL_TESS_CONTROL_SHADER)) - if tess_evaluation_shader: - shaders.append((tess_evaluation_shader, gl.GL_TESS_EVALUATION_SHADER)) - - # Inject a dummy fragment shader on gles when doing transforms - if self._ctx.gl_api == "gles" and not fragment_shader: - dummy_frag_src = """ - #version 310 es - precision mediump float; - out vec4 fragColor; - void main() { fragColor = vec4(1.0); } - """ - shaders.append((dummy_frag_src, gl.GL_FRAGMENT_SHADER)) - - shaders_id = [] - for shader_code, shader_type in shaders: - shader = Program.compile_shader(shader_code, shader_type) - gl.glAttachShader(self._glo, shader) - shaders_id.append(shader) - - # For now we assume varyings can be set up if no fragment shader - if not fragment_shader: - self._configure_varyings() - - Program.link(self._glo) - - if geometry_shader: - geometry_in = gl.GLint() - geometry_out = gl.GLint() - geometry_vertices = gl.GLint() - gl.glGetProgramiv(self._glo, gl.GL_GEOMETRY_INPUT_TYPE, geometry_in) - gl.glGetProgramiv(self._glo, gl.GL_GEOMETRY_OUTPUT_TYPE, geometry_out) - gl.glGetProgramiv(self._glo, gl.GL_GEOMETRY_VERTICES_OUT, geometry_vertices) - self._geometry_info = ( - geometry_in.value, - geometry_out.value, - geometry_vertices.value, - ) - - # Delete shaders (not needed after linking) - for shader in shaders_id: - gl.glDeleteShader(shader) - gl.glDetachShader(self._glo, shader) - - # Handle uniforms - self._introspect_attributes() - self._introspect_uniforms() - self._introspect_uniform_blocks() - - if self._ctx.gc_mode == "auto": - weakref.finalize(self, Program.delete_glo, self._ctx, glo) - - self.ctx.stats.incr("program") - - def __del__(self): - # Intercept garbage collection if we are using Context.gc() - if self._ctx.gc_mode == "context_gc" and self._glo > 0: - self._ctx.objects.append(self) + self._varyings_capture_mode = varyings_capture_mode.strip().lower() + self._ctx.stats.incr("program") @property def ctx(self) -> "Context": @@ -176,30 +71,31 @@ def ctx(self) -> "Context": return self._ctx @property - def glo(self) -> int: - """The OpenGL resource id for this program.""" - return self._glo - - @property - def attributes(self) -> Iterable[AttribFormat]: + @abstractmethod + def attributes( + self, + ) -> Iterable: # TODO: Typing on this Iterable, need generic type for AttribFormat? """List of attribute information.""" - return self._attributes + raise NotImplementedError("The enabled graphics backend does not support this method.") @property + @abstractmethod def varyings(self) -> list[str]: """Out attributes names used in transform feedback.""" - return self._varyings + raise NotImplementedError("The enabled graphics backend does not support this method.") @property + @abstractmethod def out_attributes(self) -> list[str]: """ Out attributes names used in transform feedback. Alias for `varyings`. """ - return self._varyings + raise NotImplementedError("The enabled graphics backend does not support this method.") @property + @abstractmethod def varyings_capture_mode(self) -> str: """ Get the capture more for transform feedback (single, multiple). @@ -207,9 +103,10 @@ def varyings_capture_mode(self) -> str: This is a read only property since capture mode can only be set before the program is linked. """ - return self._varyings_capture_mode + raise NotImplementedError("The enabled graphics backend does not support this method.") @property + @abstractmethod def geometry_input(self) -> int: """ The geometry shader's input primitive type. @@ -217,63 +114,42 @@ def geometry_input(self) -> int: This an be compared with ``GL_TRIANGLES``, ``GL_POINTS`` etc. and is queried when the program is created. """ - return self._geometry_info[0] + raise NotImplementedError("The enabled graphics backend does not support this method.") @property + @abstractmethod def geometry_output(self) -> int: """The geometry shader's output primitive type. This an be compared with ``GL_TRIANGLES``, ``GL_POINTS`` etc. and is queried when the program is created. """ - return self._geometry_info[1] + raise NotImplementedError("The enabled graphics backend does not support this method.") @property + @abstractmethod def geometry_vertices(self) -> int: """ The maximum number of vertices that can be emitted. This is queried when the program is created. """ - return self._geometry_info[2] + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def delete(self): """ Destroy the underlying OpenGL resource. Don't use this unless you know exactly what you are doing. """ - Program.delete_glo(self._ctx, self._glo) - self._glo = 0 + raise NotImplementedError("The enabled graphics backend does not support this method.") - @staticmethod - def delete_glo(ctx, prog_id): - """ - Deletes a program. This is normally called automatically when the - program is garbage collected. - - Args: - ctx: - The context this program belongs to - prog_id: - The OpenGL resource id - """ - # Check to see if the context was already cleaned up from program - # shut down. If so, we don't need to delete the shaders. - if gl.current_context is None: - return - - gl.glDeleteProgram(prog_id) - ctx.stats.decr("program") - - def __getitem__(self, item) -> Uniform | UniformBlock: + @abstractmethod + def __getitem__(self, item): # TODO: typing, this should return Uniform | UniformBlock """Get a uniform or uniform block""" - try: - uniform = self._uniforms[item] - except KeyError: - raise KeyError(f"Uniform with the name `{item}` was not found.") - - return uniform.getter() + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def __setitem__(self, key, value): """ Set a uniform value. @@ -289,13 +165,9 @@ def __setitem__(self, key, value): value: The uniform value """ - try: - uniform = self._uniforms[key] - except KeyError: - raise KeyError(f"Uniform with the name `{key}` was not found.") - - uniform.setter(value) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def set_uniform_safe(self, name: str, value: Any): """ Safely set a uniform catching KeyError. @@ -306,11 +178,9 @@ def set_uniform_safe(self, name: str, value: Any): value: The uniform value """ - try: - self[name] = value - except KeyError: - pass + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def set_uniform_array_safe(self, name: str, value: list[Any]): """ Safely set a uniform array. @@ -326,226 +196,13 @@ def set_uniform_array_safe(self, name: str, value: list[Any]): value: List of values """ - if name not in self._uniforms: - return - - uniform = typing.cast(Uniform, self._uniforms[name]) - _len = uniform._array_length * uniform._components - if _len == 1: - self.set_uniform_safe(name, value[0]) - else: - self.set_uniform_safe(name, value[:_len]) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def use(self): """ Activates the shader. This is normally done for you automatically. """ - # IMPORTANT: This is the only place glUseProgram should be called - # so we can track active program. - # if self._ctx.active_program != self: - gl.glUseProgram(self._glo) - # self._ctx.active_program = self - - def _configure_varyings(self): - """Set up transform feedback varyings""" - if not self._varyings: - return - - # Covert names to char** - c_array = (c_char_p * len(self._varyings))() - for i, name in enumerate(self._varyings): - c_array[i] = name.encode() - - ptr = cast(c_array, POINTER(POINTER(c_char))) - - # Are we capturing in interlaved or separate buffers? - mode = ( - gl.GL_INTERLEAVED_ATTRIBS - if self._varyings_capture_mode == "interleaved" - else gl.GL_SEPARATE_ATTRIBS - ) - - gl.glTransformFeedbackVaryings( - self._glo, # program - len(self._varyings), # number of varying variables used for transform feedback - ptr, # zero-terminated strings specifying the names of the varying variables - mode, - ) - - def _introspect_attributes(self): - """Introspect and store detailed info about an attribute""" - # TODO: Ensure gl_* attributes are ignored - num_attrs = gl.GLint() - gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_ATTRIBUTES, num_attrs) - num_varyings = gl.GLint() - gl.glGetProgramiv(self._glo, gl.GL_TRANSFORM_FEEDBACK_VARYINGS, num_varyings) - # print(f"attrs {num_attrs.value} varyings={num_varyings.value}") - - for i in range(num_attrs.value): - c_name = create_string_buffer(256) - c_size = gl.GLint() - c_type = gl.GLenum() - gl.glGetActiveAttrib( - self._glo, # program to query - i, # index (not the same as location) - 256, # max attr name size - None, # c_length, # length of name - c_size, # size of attribute (array or not) - c_type, # attribute type (enum) - c_name, # name buffer - ) - - # Get the actual location. Do not trust the original order - location = gl.glGetAttribLocation(self._glo, c_name) - - # print(c_name.value, c_size, c_type) - type_info = GLTypes.get(c_type.value) - # print(type_info) - self._attributes.append( - AttribFormat( - c_name.value.decode(), - type_info.gl_type, - type_info.components, - type_info.gl_size, - location=location, - ) - ) - - # The attribute key is used to cache VertexArrays - self.attribute_key = ":".join( - f"{attr.name}[{attr.gl_type}/{attr.components}]" for attr in self._attributes - ) - - def _introspect_uniforms(self): - """Figure out what uniforms are available and build an internal map""" - # Number of active uniforms in the program - active_uniforms = gl.GLint(0) - gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_UNIFORMS, byref(active_uniforms)) - - # Loop all the active uniforms - for index in range(active_uniforms.value): - # Query uniform information like name, type, size etc. - u_name, u_type, u_size = self._query_uniform(index) - u_location = gl.glGetUniformLocation(self._glo, u_name.encode()) - - # Skip uniforms that may be in Uniform Blocks - # TODO: We should handle all uniforms - if u_location == -1: - # print(f"Uniform {u_location} {u_name} {u_size} {u_type} skipped") - continue - - u_name = u_name.replace("[0]", "") # Remove array suffix - self._uniforms[u_name] = Uniform( - self._ctx, self._glo, u_location, u_name, u_type, u_size - ) - - def _introspect_uniform_blocks(self): - active_uniform_blocks = gl.GLint(0) - gl.glGetProgramiv(self._glo, gl.GL_ACTIVE_UNIFORM_BLOCKS, byref(active_uniform_blocks)) - # print('GL_ACTIVE_UNIFORM_BLOCKS', active_uniform_blocks) - - for loc in range(active_uniform_blocks.value): - index, size, name = self._query_uniform_block(loc) - block = UniformBlock(self._glo, index, size, name) - self._uniforms[name] = block - - def _query_uniform(self, location: int) -> tuple[str, int, int]: - """Retrieve Uniform information at given location. - - Returns the name, the type as a GLenum (GL_FLOAT, ...) and the size. Size is - greater than 1 only for Uniform arrays, like an array of floats or an array - of Matrices. - """ - u_size = gl.GLint() - u_type = gl.GLenum() - buf_size = 192 # max uniform character length - u_name = create_string_buffer(buf_size) - gl.glGetActiveUniform( - self._glo, # program to query - location, # location to query - buf_size, # size of the character/name buffer - None, # the number of characters actually written by OpenGL in the string - u_size, # size of the uniform variable - u_type, # data type of the uniform variable - u_name, # string buffer for storing the name - ) - return u_name.value.decode(), u_type.value, u_size.value - - def _query_uniform_block(self, location: int) -> tuple[int, int, str]: - """Query active uniform block by retrieving the name and index and size""" - # Query name - u_size = gl.GLint() - buf_size = 192 # max uniform character length - u_name = create_string_buffer(buf_size) - gl.glGetActiveUniformBlockName( - self._glo, # program to query - location, # location to query - 256, # max size if the name - u_size, # length - u_name, - ) - # Query index - index = gl.glGetUniformBlockIndex(self._glo, u_name) - # Query size - b_size = gl.GLint() - gl.glGetActiveUniformBlockiv(self._glo, index, gl.GL_UNIFORM_BLOCK_DATA_SIZE, b_size) - return index, b_size.value, u_name.value.decode() - - @staticmethod - def compile_shader(source: str, shader_type: PyGLenum) -> gl.GLuint: - """ - Compile the shader code of the given type. - - Args: - source: - The shader source code - shader_type: - The type of shader to compile. - ``GL_VERTEX_SHADER``, ``GL_FRAGMENT_SHADER`` etc. - - Returns: - The created shader id - """ - shader = gl.glCreateShader(shader_type) - source_bytes = source.encode("utf-8") - # Turn the source code string into an array of c_char_p arrays. - strings = byref(cast(c_char_p(source_bytes), POINTER(c_char))) - # Make an array with the strings lengths - lengths = pointer(c_int(len(source_bytes))) - gl.glShaderSource(shader, 1, strings, lengths) - gl.glCompileShader(shader) - result = c_int() - gl.glGetShaderiv(shader, gl.GL_COMPILE_STATUS, byref(result)) - if result.value == gl.GL_FALSE: - msg = create_string_buffer(512) - length = c_int() - gl.glGetShaderInfoLog(shader, 512, byref(length), msg) - raise ShaderException( - ( - f"Error compiling {SHADER_TYPE_NAMES[shader_type]} " - f"({result.value}): {msg.value.decode('utf-8')}\n" - f"---- [{SHADER_TYPE_NAMES[shader_type]}] ---\n" - ) - + "\n".join( - f"{str(i + 1).zfill(3)}: {line} " for i, line in enumerate(source.split("\n")) - ) - ) - return shader - - @staticmethod - def link(glo): - """Link a shader program""" - gl.glLinkProgram(glo) - status = c_int() - gl.glGetProgramiv(glo, gl.GL_LINK_STATUS, status) - if not status.value: - length = c_int() - gl.glGetProgramiv(glo, gl.GL_INFO_LOG_LENGTH, length) - log = c_buffer(length.value) - gl.glGetProgramInfoLog(glo, len(log), None, log) - raise ShaderException("Program link error: {}".format(log.value.decode())) - - def __repr__(self): - return "".format(self._glo) + raise NotImplementedError("The enabled graphics backend does not support this method.") diff --git a/arcade/gl/provider.py b/arcade/gl/provider.py new file mode 100644 index 0000000000..c014be94c0 --- /dev/null +++ b/arcade/gl/provider.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import importlib +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from arcade.context import ArcadeContext + + from .context import Context, Info + +_current_provider: Optional[BaseProvider] = None + + +def set_provider(provider_name: str): + global _current_provider + + try: + module = importlib.import_module(f"arcade.gl.backends.{provider_name}.provider") + _current_provider = module.Provider() + except ImportError as e: + print(e) + raise ImportError(f"arcade.gl Backend Provider '{provider_name}' not found") + + +def get_provider(): + return _current_provider + + +def get_context(*args, **kwargs) -> Context: + if _current_provider is None: + set_provider("opengl") + + assert _current_provider is not None # this can't really be None at this point, but mypy + + return _current_provider.create_context(*args, **kwargs) + + +def get_arcade_context(*args, **kwargs) -> ArcadeContext: + if _current_provider is None: + set_provider("opengl") + + assert _current_provider is not None # this can't really be None at this point, but mypy + + return _current_provider.create_arcade_context(*args, **kwargs) + + +class BaseProvider(ABC): + @abstractmethod + def create_context(self, *args, **kwargs) -> Context: + pass + + @abstractmethod + def create_info(self, ctx: Context) -> Info: + pass + + @abstractmethod + def create_arcade_context(self, *args, **kwargs) -> ArcadeContext: + pass diff --git a/arcade/gl/query.py b/arcade/gl/query.py index 66dc53345a..dacf2eed99 100644 --- a/arcade/gl/query.py +++ b/arcade/gl/query.py @@ -1,15 +1,13 @@ from __future__ import annotations -import weakref +from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from pyglet import gl - if TYPE_CHECKING: from arcade.gl import Context -class Query: +class Query(ABC): """ A query object to perform low level measurements of OpenGL rendering calls. @@ -38,9 +36,6 @@ class Query: __slots__ = ( "_ctx", - "_glo_samples_passed", - "_glo_time_elapsed", - "_glo_primitives_generated", "__weakref__", "_samples_enabled", "_time_enabled", @@ -65,28 +60,8 @@ def __init__(self, ctx: Context, samples=True, time=True, primitives=True): self._time = 0 self._primitives = 0 - glos = [] - - self._glo_samples_passed = glo_samples_passed = gl.GLuint() - if self._samples_enabled: - gl.glGenQueries(1, self._glo_samples_passed) - glos.append(glo_samples_passed) - - self._glo_time_elapsed = glo_time_elapsed = gl.GLuint() - if self._time_enabled: - gl.glGenQueries(1, self._glo_time_elapsed) - glos.append(glo_time_elapsed) - - self._glo_primitives_generated = glo_primitives_generated = gl.GLuint() - if self._primitives_enabled: - gl.glGenQueries(1, self._glo_primitives_generated) - glos.append(glo_primitives_generated) - self.ctx.stats.incr("query") - if self._ctx.gc_mode == "auto": - weakref.finalize(self, Query.delete_glo, self._ctx, glos) - def __del__(self): if self._ctx.gc_mode == "context_gc": self._ctx.objects.append(self) @@ -120,61 +95,19 @@ def primitives_generated(self) -> int: """ return self._primitives + @abstractmethod def __enter__(self): - if self._ctx.gl_api == "gl": - if self._samples_enabled: - gl.glBeginQuery(gl.GL_SAMPLES_PASSED, self._glo_samples_passed) - if self._time_enabled: - gl.glBeginQuery(gl.GL_TIME_ELAPSED, self._glo_time_elapsed) - if self._primitives_enabled: - gl.glBeginQuery(gl.GL_PRIMITIVES_GENERATED, self._glo_primitives_generated) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def __exit__(self, exc_type, exc_val, exc_tb): - if self._ctx.gl_api == "gl": - if self._samples_enabled: - gl.glEndQuery(gl.GL_SAMPLES_PASSED) - value = gl.GLint() - gl.glGetQueryObjectiv(self._glo_samples_passed, gl.GL_QUERY_RESULT, value) - self._samples = value.value - - if self._time_enabled: - gl.glEndQuery(gl.GL_TIME_ELAPSED) - value = gl.GLint() - gl.glGetQueryObjectiv(self._glo_time_elapsed, gl.GL_QUERY_RESULT, value) - self._time = value.value - - if self._primitives_enabled: - gl.glEndQuery(gl.GL_PRIMITIVES_GENERATED) - value = gl.GLint() - gl.glGetQueryObjectiv(self._glo_primitives_generated, gl.GL_QUERY_RESULT, value) - self._primitives = value.value + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def delete(self): """ Destroy the underlying OpenGL resource. Don't use this unless you know exactly what you are doing. """ - Query.delete_glo( - self._ctx, - [ - self._glo_samples_passed, - self._glo_time_elapsed, - self._glo_primitives_generated, - ], - ) - - @staticmethod - def delete_glo(ctx, glos) -> None: - """ - Delete this query object. - - This is automatically called when the object is garbage collected. - """ - if gl.current_context is None: - return - - for glo in glos: - gl.glDeleteQueries(1, glo) - - ctx.stats.decr("query") + raise NotImplementedError("The enabled graphics backend does not support this method.") diff --git a/arcade/gl/sampler.py b/arcade/gl/sampler.py index 7fb662fc4b..8e8259ad1f 100644 --- a/arcade/gl/sampler.py +++ b/arcade/gl/sampler.py @@ -1,18 +1,13 @@ from __future__ import annotations -import weakref -from ctypes import byref, c_uint32 +from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from pyglet import gl - -from .types import PyGLuint, compare_funcs - if TYPE_CHECKING: from arcade.gl import Context, Texture2D -class Sampler: +class Sampler(ABC): """ OpenGL sampler object. @@ -25,57 +20,38 @@ def __init__( ctx: "Context", texture: Texture2D, *, - filter: tuple[PyGLuint, PyGLuint] | None = None, - wrap_x: PyGLuint | None = None, - wrap_y: PyGLuint | None = None, + filter=None, # TODO: Typing, should be tuple[PyGLuint, PyGLuint] | None + wrap_x=None, # TODO: Typing, should be PyGLuint | None + wrap_y=None, # TODO: Typing, should be PyGLuint | None ): self._ctx = ctx - self._glo = -1 - value = c_uint32() - gl.glGenSamplers(1, byref(value)) - self._glo = value.value + # These three ultimately need to be set by the implementing backend. + # We're creating them here first to trick some of the methods on the + # base class to being able to see them. So that we don't have to + # implement a getter on every backend + self._filter = (0, 0) # Mypy needs this to be a tuple[int, int] to be happy + self._wrap_x = 0 # Mypy needs this to be an int to be happy + self._wrap_y = 0 # Mypy needs this to be an int to be happy self.texture = texture - # Default filters for float and integer textures - # Integer textures should have NEAREST interpolation - # by default 3.3 core doesn't really support it consistently. - if "f" in self.texture._dtype: - self._filter = gl.GL_LINEAR, gl.GL_LINEAR - else: - self._filter = gl.GL_NEAREST, gl.GL_NEAREST - - self._wrap_x = gl.GL_REPEAT - self._wrap_y = gl.GL_REPEAT self._anisotropy = 1.0 self._compare_func: str | None = None - # Only set texture parameters on non-multisample textures - if self.texture._samples == 0: - self.filter = filter or self._filter - self.wrap_x = wrap_x or self._wrap_x - self.wrap_y = wrap_y or self._wrap_y - - if self._ctx.gc_mode == "auto": - weakref.finalize(self, Sampler.delete_glo, self._glo) - - @property - def glo(self) -> PyGLuint: - """The OpenGL sampler id""" - return self._glo - + @abstractmethod def use(self, unit: int): """ Bind the sampler to a texture unit """ - gl.glBindSampler(unit, self._glo) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def clear(self, unit: int): """ Unbind the sampler from a texture unit """ - gl.glBindSampler(unit, 0) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def filter(self) -> tuple[int, int]: @@ -109,13 +85,9 @@ def filter(self) -> tuple[int, int]: return self._filter @filter.setter + @abstractmethod def filter(self, value: tuple[int, int]): - if not isinstance(value, tuple) or not len(value) == 2: - raise ValueError("Texture filter must be a 2 component tuple (min, mag)") - - self._filter = value - gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_MIN_FILTER, self._filter[0]) - gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_MAG_FILTER, self._filter[1]) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def wrap_x(self) -> int: @@ -140,9 +112,9 @@ def wrap_x(self) -> int: return self._wrap_x @wrap_x.setter + @abstractmethod def wrap_x(self, value: int): - self._wrap_x = value - gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_WRAP_S, value) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def wrap_y(self) -> int: @@ -167,9 +139,9 @@ def wrap_y(self) -> int: return self._wrap_y @wrap_y.setter + @abstractmethod def wrap_y(self, value: int): - self._wrap_y = value - gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_WRAP_T, value) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def anisotropy(self) -> float: @@ -178,8 +150,7 @@ def anisotropy(self) -> float: @anisotropy.setter def anisotropy(self, value): - self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) - gl.glSamplerParameterf(self._glo, gl.GL_TEXTURE_MAX_ANISOTROPY, self._anisotropy) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def compare_func(self) -> str | None: @@ -199,29 +170,6 @@ def compare_func(self) -> str | None: return self._compare_func @compare_func.setter + @abstractmethod def compare_func(self, value: str | None): - if not self.texture._depth: - raise ValueError("Depth comparison function can only be set on depth textures") - - if not isinstance(value, str) and value is not None: - raise ValueError(f"value must be as string: {self._compare_funcs.keys()}") - - func = compare_funcs.get(value, None) - if func is None: - raise ValueError(f"value must be as string: {compare_funcs.keys()}") - - self._compare_func = value - if value is None: - gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_NONE) - else: - gl.glSamplerParameteri( - self._glo, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_COMPARE_REF_TO_TEXTURE - ) - gl.glSamplerParameteri(self._glo, gl.GL_TEXTURE_COMPARE_FUNC, func) - - @staticmethod - def delete_glo(glo: int) -> None: - """ - Delete the OpenGL object - """ - gl.glDeleteSamplers(1, glo) + raise NotImplementedError("The enabled graphics backend does not support this method.") diff --git a/arcade/gl/texture.py b/arcade/gl/texture.py index fd88e6217d..60227e3fd2 100644 --- a/arcade/gl/texture.py +++ b/arcade/gl/texture.py @@ -1,28 +1,20 @@ from __future__ import annotations -import weakref -from ctypes import byref, string_at +from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from pyglet import gl - from ..types import BufferProtocol -from .buffer import Buffer +from . import enums from .types import ( BufferOrBufferProtocol, - PyGLuint, - compare_funcs, pixel_formats, - swizzle_enum_to_str, - swizzle_str_to_enum, ) -from .utils import data_to_ctypes if TYPE_CHECKING: # handle import cycle caused by type hinting from arcade.gl import Context -class Texture2D: +class Texture2D(ABC): """ An OpenGL 2D texture. We can create an empty black texture or a texture from byte data. @@ -85,11 +77,9 @@ class Texture2D: __slots__ = ( "_ctx", - "_glo", "_width", "_height", "_dtype", - "_target", "_components", "_alignment", "_depth", @@ -117,42 +107,39 @@ def __init__( components: int = 4, dtype: str = "f1", data: BufferProtocol | None = None, - filter: tuple[PyGLuint, PyGLuint] | None = None, - wrap_x: PyGLuint | None = None, - wrap_y: PyGLuint | None = None, - target=gl.GL_TEXTURE_2D, + filter=None, # TODO: typing, should be tuple[PyGLuint, PyGLuint] + wrap_x=None, # TODO: typing, should be PyGLuint | None + wrap_y=None, # TODO: typing, should be PyGLuint | None depth=False, samples: int = 0, immutable: bool = False, - internal_format: PyGLuint | None = None, + internal_format=None, # TODO: typing, shouldb e PyGLuint | None compressed: bool = False, compressed_data: bool = False, ): - self._glo = glo = gl.GLuint() self._ctx = ctx self._width, self._height = size self._dtype = dtype self._components = components self._component_size = 0 self._alignment = 1 - self._target = target - self._samples: int = min(max(0, samples), self._ctx.info.MAX_SAMPLES) - self._depth: bool = depth + self._samples = min(max(0, samples), self._ctx.info.MAX_SAMPLES) + self._depth = depth self._immutable = immutable self._compare_func: str | None = None self._anisotropy = 1.0 self._internal_format = internal_format self._compressed = compressed self._compressed_data = compressed_data - # Default filters for float and integer textures - # Integer textures should have NEAREST interpolation - # by default 3.3 core doesn't really support it consistently. - if "f" in self._dtype: - self._filter = gl.GL_LINEAR, gl.GL_LINEAR - else: - self._filter = gl.GL_NEAREST, gl.GL_NEAREST - self._wrap_x = gl.GL_REPEAT - self._wrap_y = gl.GL_REPEAT + + # _filter ultimately need to be set by the implementing backend. + # We're creating it here first to trick some of the methods on the + # base class to being able to see it. So that we don't have to + # implement a getter on every backend + self._filter = (0, 0) # Mypy needs this to be a tuple[int, int] to be happy + + self._wrap_x = enums.REPEAT + self._wrap_y = enums.REPEAT if self._components not in [1, 2, 3, 4]: raise ValueError("Components must be 1, 2, 3 or 4") @@ -162,29 +149,9 @@ def __init__( "Multisampled textures are not writable (cannot be initialized with data)" ) - self._target = gl.GL_TEXTURE_2D if self._samples == 0 else gl.GL_TEXTURE_2D_MULTISAMPLE - - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glGenTextures(1, byref(self._glo)) - - if self._glo.value == 0: - raise RuntimeError("Cannot create Texture. OpenGL failed to generate a texture id") - - gl.glBindTexture(self._target, self._glo) - - self._texture_2d(data) - - # Only set texture parameters on non-multisample textures - if self._samples == 0: - self.filter = filter or self._filter - self.wrap_x = wrap_x or self._wrap_x - self.wrap_y = wrap_y or self._wrap_y - - if self._ctx.gc_mode == "auto": - weakref.finalize(self, Texture2D.delete_glo, self._ctx, glo) - self.ctx.stats.incr("texture") + @abstractmethod def resize(self, size: tuple[int, int]): """ Resize the texture. This will re-allocate the internal @@ -195,132 +162,13 @@ def resize(self, size: tuple[int, int]): Args: size: The new size of the texture """ - if self._immutable: - raise ValueError("Immutable textures cannot be resized") - - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - - self._width, self._height = size - - self._texture_2d(None) - - def __del__(self): - # Intercept garbage collection if we are using Context.gc() - if self._ctx.gc_mode == "context_gc" and self._glo.value > 0: - self._ctx.objects.append(self) - - def _texture_2d(self, data): - """Create a 2D texture""" - # Start by resolving the texture format - try: - format_info = pixel_formats[self._dtype] - except KeyError: - raise ValueError( - f"dype '{self._dtype}' not support. Supported types are : " - f"{tuple(pixel_formats.keys())}" - ) - _format, _internal_format, self._type, self._component_size = format_info - if data is not None: - byte_length, data = data_to_ctypes(data) - self._validate_data_size(data, byte_length, self._width, self._height) - - # If we are dealing with a multisampled texture we have less options - if self._target == gl.GL_TEXTURE_2D_MULTISAMPLE: - gl.glTexImage2DMultisample( - self._target, - self._samples, - _internal_format[self._components], - self._width, - self._height, - True, # Fixed sample locations - ) - return - - # Make sure we unpack the pixel data with correct alignment - # or we'll end up with corrupted textures - gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, self._alignment) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, self._alignment) - - # Create depth 2d texture - if self._depth: - gl.glTexImage2D( - self._target, - 0, # level - gl.GL_DEPTH_COMPONENT24, - self._width, - self._height, - 0, - gl.GL_DEPTH_COMPONENT, - gl.GL_UNSIGNED_INT, # gl.GL_FLOAT, - data, - ) - self.compare_func = "<=" - # Create normal 2d texture - else: - try: - self._format = _format[self._components] - if self._internal_format is None: - self._internal_format = _internal_format[self._components] - - if self._immutable: - # Specify immutable storage for this texture. - # glTexStorage2D can only be called once - gl.glTexStorage2D( - self._target, - 1, # Levels - self._internal_format, - self._width, - self._height, - ) - if data: - self.write(data) - else: - # glTexImage2D can be called multiple times to re-allocate storage - # Specify mutable storage for this texture. - if self._compressed_data is True: - gl.glCompressedTexImage2D( - self._target, # target - 0, # level - self._internal_format, # internal_format - self._width, # width - self._height, # height - 0, # border - len(data), # size - data, # data - ) - else: - gl.glTexImage2D( - self._target, # target - 0, # level - self._internal_format, # internal_format - self._width, # width - self._height, # height - 0, # border - self._format, # format - self._type, # type - data, # data - ) - except gl.GLException as ex: - raise gl.GLException( - ( - f"Unable to create texture: {ex} : dtype={self._dtype} " - f"size={self.size} components={self._components} " - f"MAX_TEXTURE_SIZE = {self.ctx.info.MAX_TEXTURE_SIZE}" - f": {ex}" - ) - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def ctx(self) -> Context: """The context this texture belongs to.""" return self._ctx - @property - def glo(self) -> gl.GLuint: - """The OpenGL texture id""" - return self._glo - @property def compressed(self) -> bool: """Is this using a compressed format?""" @@ -377,6 +225,7 @@ def immutable(self) -> bool: return self._immutable @property + @abstractmethod def swizzle(self) -> str: """ The swizzle mask of the texture (Default ``'RGBA'``). @@ -403,49 +252,12 @@ def swizzle(self) -> str: # Reverse the components texture.swizzle = 'ABGR' """ - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - - # Read the current swizzle values from the texture - swizzle_r = gl.GLint() - swizzle_g = gl.GLint() - swizzle_b = gl.GLint() - swizzle_a = gl.GLint() - - gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_r) - gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_g) - gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_b) - gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_a) - - swizzle_str = "" - for v in [swizzle_r, swizzle_g, swizzle_b, swizzle_a]: - swizzle_str += swizzle_enum_to_str[v.value] - - return swizzle_str + raise NotImplementedError("The enabled graphics backend does not support this method.") @swizzle.setter + @abstractmethod def swizzle(self, value: str): - if not isinstance(value, str): - raise ValueError(f"Swizzle must be a string, not '{type(str)}'") - - if len(value) != 4: - raise ValueError("Swizzle must be a string of length 4") - - swizzle_enums = [] - for c in value: - try: - c = c.upper() - swizzle_enums.append(swizzle_str_to_enum[c]) - except KeyError: - raise ValueError(f"Swizzle value '{c}' invalid. Must be one of RGBA01") - - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - - gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_enums[0]) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_enums[1]) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_enums[2]) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_enums[3]) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def filter(self) -> tuple[int, int]: @@ -479,15 +291,9 @@ def filter(self) -> tuple[int, int]: return self._filter @filter.setter + @abstractmethod def filter(self, value: tuple[int, int]): - if not isinstance(value, tuple) or not len(value) == 2: - raise ValueError("Texture filter must be a 2 component tuple (min, mag)") - - self._filter = value - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_MIN_FILTER, self._filter[0]) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_MAG_FILTER, self._filter[1]) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def wrap_x(self) -> int: @@ -512,11 +318,9 @@ def wrap_x(self) -> int: return self._wrap_x @wrap_x.setter + @abstractmethod def wrap_x(self, value: int): - self._wrap_x = value - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_S, value) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def wrap_y(self) -> int: @@ -541,11 +345,9 @@ def wrap_y(self) -> int: return self._wrap_y @wrap_y.setter + @abstractmethod def wrap_y(self, value: int): - self._wrap_y = value - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_T, value) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def anisotropy(self) -> float: @@ -553,11 +355,9 @@ def anisotropy(self) -> float: return self._anisotropy @anisotropy.setter + @abstractmethod def anisotropy(self, value): - self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glTexParameterf(self._target, gl.GL_TEXTURE_MAX_ANISOTROPY, self._anisotropy) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def compare_func(self) -> str | None: @@ -577,28 +377,11 @@ def compare_func(self) -> str | None: return self._compare_func @compare_func.setter + @abstractmethod def compare_func(self, value: str | None): - if not self._depth: - raise ValueError("Depth comparison function can only be set on depth textures") - - if not isinstance(value, str) and value is not None: - raise ValueError(f"value must be as string: {self._compare_funcs.keys()}") - - func = compare_funcs.get(value, None) - if func is None: - raise ValueError(f"value must be as string: {compare_funcs.keys()}") - - self._compare_func = value - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - if value is None: - gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_NONE) - else: - gl.glTexParameteri( - self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_COMPARE_REF_TO_TEXTURE - ) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_FUNC, func) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def read(self, level: int = 0, alignment: int = 1) -> bytes: """ Read the contents of the texture. @@ -610,25 +393,9 @@ def read(self, level: int = 0, alignment: int = 1) -> bytes: Alignment of the start of each row in memory in number of bytes. Possible values: 1,2,4 """ - if self._samples > 0: - raise ValueError("Multisampled textures cannot be read directly") - - if self._ctx.gl_api == "gl": - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, alignment) - - buffer = ( - gl.GLubyte * (self.width * self.height * self._component_size * self._components) - )() - gl.glGetTexImage(gl.GL_TEXTURE_2D, level, self._format, self._type, buffer) - return string_at(buffer, len(buffer)) - elif self._ctx.gl_api == "gles": - fbo = self._ctx.framebuffer(color_attachments=[self]) - return fbo.read(components=self._components, dtype=self._dtype) - else: - raise ValueError("Unknown gl_api: '{self._ctx.gl_api}'") + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> None: """Write byte data from the passed source to the texture. @@ -651,64 +418,9 @@ def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> The area of the texture to write. 2 or 4 component tuple. (x, y, w, h) or (w, h). Default is the full texture. """ - # TODO: Support writing to layers using viewport + alignment - if self._samples > 0: - raise ValueError("Writing to multisampled textures not supported") - - x, y, w, h = 0, 0, self._width, self._height - if viewport: - if len(viewport) == 2: - w, h = viewport - elif len(viewport) == 4: - x, y, w, h = viewport - else: - raise ValueError("Viewport must be of length 2 or 4") - - if isinstance(data, Buffer): - gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, data.glo) - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) - gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) - gl.glTexSubImage2D(self._target, level, x, y, w, h, self._format, self._type, 0) - gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, 0) - else: - byte_size, data = data_to_ctypes(data) - self._validate_data_size(data, byte_size, w, h) - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) - gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) - gl.glTexSubImage2D( - self._target, # target - level, # level - x, # x offset - y, # y offset - w, # width - h, # height - self._format, # format - self._type, # type - data, # pixel data - ) - - def _validate_data_size(self, byte_data, byte_size, width, height) -> None: - """Validate the size of the data to be written to the texture""" - # TODO: Validate data size for compressed textures - # This might be a bit tricky since the size of the compressed - # data would depend on the algorithm used. - if self._compressed is True: - return - - expected_size = width * height * self._component_size * self._components - if byte_size != expected_size: - raise ValueError( - f"Data size {len(byte_data)} does not match expected size {expected_size}" - ) - if len(byte_data) != byte_size: - raise ValueError( - f"Data size {len(byte_data)} does not match reported size {expected_size}" - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: """Generate mipmaps for this texture. @@ -737,53 +449,27 @@ def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: Also see: https://www.khronos.org/opengl/wiki/Texture#Mip_maps """ - if self._samples > 0: - raise ValueError("Multisampled textures don't support mimpmaps") - - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(gl.GL_TEXTURE_2D, self._glo) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_BASE_LEVEL, base) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAX_LEVEL, max_level) - gl.glGenerateMipmap(gl.GL_TEXTURE_2D) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def delete(self): """ Destroy the underlying OpenGL resource. Don't use this unless you know exactly what you are doing. """ - Texture2D.delete_glo(self._ctx, self._glo) - self._glo.value = 0 - - @staticmethod - def delete_glo(ctx: "Context", glo: gl.GLuint): - """ - Destroy the texture. - - This is called automatically when the object is garbage collected. - - Args: - ctx: OpenGL Context - glo: The OpenGL texture id - """ - # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: - return - - if glo.value != 0: - gl.glDeleteTextures(1, byref(glo)) - - ctx.stats.decr("texture") + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def use(self, unit: int = 0) -> None: """Bind the texture to a channel, Args: unit: The texture unit to bind the texture. """ - gl.glActiveTexture(gl.GL_TEXTURE0 + unit) - gl.glBindTexture(self._target, self._glo) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: int = 0): """ Bind textures to image units. @@ -797,21 +483,9 @@ def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: write: The compute shader intends to write to this image level: The mipmap level to bind """ - if self._ctx.gl_api == "gles" and not self._immutable: - raise ValueError("Textures bound to image units must be created with immutable=True") - - access = gl.GL_READ_WRITE - if read and write: - access = gl.GL_READ_WRITE - elif read and not write: - access = gl.GL_READ_ONLY - elif not read and write: - access = gl.GL_WRITE_ONLY - else: - raise ValueError("Illegal access mode. The texture must at least be read or write only") - - gl.glBindImageTexture(unit, self._glo, level, 0, 0, access, self._internal_format) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def get_handle(self, resident: bool = True) -> int: """ Get a handle for bindless texture access. @@ -842,20 +516,4 @@ def get_handle(self, resident: bool = True) -> int: Args: resident: Make the texture resident. """ - handle = gl.glGetTextureHandleARB(self._glo) - is_resident = gl.glIsTextureHandleResidentARB(handle) - - # Ensure we don't try to make a resident texture resident again - if resident: - if not is_resident: - gl.glMakeTextureHandleResidentARB(handle) - else: - if is_resident: - gl.glMakeTextureHandleNonResidentARB(handle) - - return handle - - def __repr__(self) -> str: - return "".format( - self._glo.value, self._width, self._height, self._components - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") diff --git a/arcade/gl/texture_array.py b/arcade/gl/texture_array.py index c75d666e82..369ee617d6 100644 --- a/arcade/gl/texture_array.py +++ b/arcade/gl/texture_array.py @@ -1,28 +1,19 @@ from __future__ import annotations -import weakref -from ctypes import byref, string_at +from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from pyglet import gl - from ..types import BufferProtocol -from .buffer import Buffer from .types import ( BufferOrBufferProtocol, - PyGLuint, - compare_funcs, pixel_formats, - swizzle_enum_to_str, - swizzle_str_to_enum, ) -from .utils import data_to_ctypes if TYPE_CHECKING: # handle import cycle caused by type hinting from arcade.gl import Context -class TextureArray: +class TextureArray(ABC): """ An OpenGL 2D texture array. @@ -86,12 +77,10 @@ class TextureArray: __slots__ = ( "_ctx", - "_glo", "_width", "_height", "_layers", "_dtype", - "_target", "_components", "_alignment", "_depth", @@ -119,25 +108,22 @@ def __init__( components: int = 4, dtype: str = "f1", data: BufferProtocol | None = None, - filter: tuple[PyGLuint, PyGLuint] | None = None, - wrap_x: PyGLuint | None = None, - wrap_y: PyGLuint | None = None, - target=gl.GL_TEXTURE_2D_ARRAY, + filter=None, + wrap_x=None, + wrap_y=None, depth=False, samples: int = 0, immutable: bool = False, - internal_format: PyGLuint | None = None, + internal_format=None, compressed: bool = False, compressed_data: bool = False, ): - self._glo = glo = gl.GLuint() self._ctx = ctx self._width, self._height, self._layers = size self._dtype = dtype self._components = components self._component_size = 0 self._alignment = 1 - self._target = target self._samples = min(max(0, samples), self._ctx.info.MAX_SAMPLES) self._depth = depth self._immutable = immutable @@ -146,15 +132,14 @@ def __init__( self._internal_format = internal_format self._compressed = compressed self._compressed_data = compressed_data - # Default filters for float and integer textures - # Integer textures should have NEAREST interpolation - # by default 3.3 core doesn't really support it consistently. - if "f" in self._dtype: - self._filter = gl.GL_LINEAR, gl.GL_LINEAR - else: - self._filter = gl.GL_NEAREST, gl.GL_NEAREST - self._wrap_x = gl.GL_REPEAT - self._wrap_y = gl.GL_REPEAT + + # These three ultimately need to be set by the implementing backend. + # We're creating them here first to trick some of the methods on the + # base class to being able to see them. So that we don't have to + # implement a getter on every backend + self._filter = (0, 0) # Mypy needs this to be a tuple[int, int] to be happy + self._wrap_x = 0 # Mypy needs this to be an int to be happy + self._wrap_y = 0 # Mypy needs this to be an int to be happy if self._components not in [1, 2, 3, 4]: raise ValueError("Components must be 1, 2, 3 or 4") @@ -164,31 +149,9 @@ def __init__( "Multisampled textures are not writable (cannot be initialized with data)" ) - self._target = ( - gl.GL_TEXTURE_2D_ARRAY if self._samples == 0 else gl.GL_TEXTURE_2D_MULTISAMPLE_ARRAY - ) - - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glGenTextures(1, byref(self._glo)) - - if self._glo.value == 0: - raise RuntimeError("Cannot create Texture. OpenGL failed to generate a texture id") - - gl.glBindTexture(self._target, self._glo) - - self._texture_2d_array(data) - - # Only set texture parameters on non-multisample textures - if self._samples == 0: - self.filter = filter or self._filter - self.wrap_x = wrap_x or self._wrap_x - self.wrap_y = wrap_y or self._wrap_y - - if self._ctx.gc_mode == "auto": - weakref.finalize(self, TextureArray.delete_glo, self._ctx, glo) - self.ctx.stats.incr("texture") + @abstractmethod def resize(self, size: tuple[int, int]): """ Resize the texture. This will re-allocate the internal @@ -199,137 +162,13 @@ def resize(self, size: tuple[int, int]): Args: size: The new size of the texture """ - if self._immutable: - raise ValueError("Immutable textures cannot be resized") - - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - - self._width, self._height = size - - self._texture_2d_array(None) - - def __del__(self): - # Intercept garbage collection if we are using Context.gc() - if self._ctx.gc_mode == "context_gc" and self._glo.value > 0: - self._ctx.objects.append(self) - - def _texture_2d_array(self, data): - """Create a 2D texture""" - # Start by resolving the texture format - try: - format_info = pixel_formats[self._dtype] - except KeyError: - raise ValueError( - f"dype '{self._dtype}' not support. Supported types are : " - f"{tuple(pixel_formats.keys())}" - ) - _format, _internal_format, self._type, self._component_size = format_info - if data is not None: - byte_length, data = data_to_ctypes(data) - self._validate_data_size(data, byte_length, self._width, self._height, self._layers) - - # If we are dealing with a multisampled texture we have less options - if self._target == gl.GL_TEXTURE_2D_MULTISAMPLE_ARRAY: - gl.glTexImage3DMultisample( - self._target, - self._samples, - _internal_format[self._components], - self._width, - self._height, - self._layers, - True, # Fixed sample locations - ) - return - - # Make sure we unpack the pixel data with correct alignment - # or we'll end up with corrupted textures - gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, self._alignment) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, self._alignment) - - # Create depth 2d texture - if self._depth: - gl.glTexImage3D( - self._target, - 0, # level - gl.GL_DEPTH_COMPONENT24, - self._width, - self._height, - self._layers, - 0, - gl.GL_DEPTH_COMPONENT, - gl.GL_UNSIGNED_INT, # gl.GL_FLOAT, - data, - ) - self.compare_func = "<=" - # Create normal 2d texture - else: - try: - self._format = _format[self._components] - if self._internal_format is None: - self._internal_format = _internal_format[self._components] - - if self._immutable: - # Specify immutable storage for this texture. - # glTexStorage2D can only be called once - gl.glTexStorage3D( - self._target, - 1, # Levels - self._internal_format, - self._width, - self._height, - self._layers, - ) - if data: - self.write(data) - else: - # glTexImage2D can be called multiple times to re-allocate storage - # Specify mutable storage for this texture. - if self._compressed_data is True: - gl.glCompressedTexImage3D( - self._target, # target - 0, # level - self._internal_format, # internal_format - self._width, # width - self._height, # height - self._layers, # layers - 0, # border - len(data), # size - data, # data - ) - else: - gl.glTexImage3D( - self._target, # target - 0, # level - self._internal_format, # internal_format - self._width, # width - self._height, # height - self._layers, # layers - 0, # border - self._format, # format - self._type, # type - data, # data - ) - except gl.GLException as ex: - raise gl.GLException( - ( - f"Unable to create texture: {ex} : dtype={self._dtype} " - f"size={self.size} components={self._components} " - f"MAX_TEXTURE_SIZE = {self.ctx.info.MAX_TEXTURE_SIZE}" - f": {ex}" - ) - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def ctx(self) -> Context: """The context this texture belongs to.""" return self._ctx - @property - def glo(self) -> gl.GLuint: - """The OpenGL texture id""" - return self._glo - @property def compressed(self) -> bool: """Is this using a compressed format?""" @@ -391,6 +230,7 @@ def immutable(self) -> bool: return self._immutable @property + @abstractmethod def swizzle(self) -> str: """ The swizzle mask of the texture (Default ``'RGBA'``). @@ -417,49 +257,12 @@ def swizzle(self) -> str: # Reverse the components texture.swizzle = 'ABGR' """ - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - - # Read the current swizzle values from the texture - swizzle_r = gl.GLint() - swizzle_g = gl.GLint() - swizzle_b = gl.GLint() - swizzle_a = gl.GLint() - - gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_r) - gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_g) - gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_b) - gl.glGetTexParameteriv(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_a) - - swizzle_str = "" - for v in [swizzle_r, swizzle_g, swizzle_b, swizzle_a]: - swizzle_str += swizzle_enum_to_str[v.value] - - return swizzle_str + raise NotImplementedError("The enabled graphics backend does not support this method.") @swizzle.setter + @abstractmethod def swizzle(self, value: str): - if not isinstance(value, str): - raise ValueError(f"Swizzle must be a string, not '{type(str)}'") - - if len(value) != 4: - raise ValueError("Swizzle must be a string of length 4") - - swizzle_enums = [] - for c in value: - try: - c = c.upper() - swizzle_enums.append(swizzle_str_to_enum[c]) - except KeyError: - raise ValueError(f"Swizzle value '{c}' invalid. Must be one of RGBA01") - - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - - gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_R, swizzle_enums[0]) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_G, swizzle_enums[1]) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_B, swizzle_enums[2]) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_SWIZZLE_A, swizzle_enums[3]) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def filter(self) -> tuple[int, int]: @@ -493,15 +296,9 @@ def filter(self) -> tuple[int, int]: return self._filter @filter.setter + @abstractmethod def filter(self, value: tuple[int, int]): - if not isinstance(value, tuple) or not len(value) == 2: - raise ValueError("Texture filter must be a 2 component tuple (min, mag)") - - self._filter = value - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_MIN_FILTER, self._filter[0]) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_MAG_FILTER, self._filter[1]) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def wrap_x(self) -> int: @@ -526,11 +323,9 @@ def wrap_x(self) -> int: return self._wrap_x @wrap_x.setter + @abstractmethod def wrap_x(self, value: int): - self._wrap_x = value - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_S, value) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def wrap_y(self) -> int: @@ -555,11 +350,9 @@ def wrap_y(self) -> int: return self._wrap_y @wrap_y.setter + @abstractmethod def wrap_y(self, value: int): - self._wrap_y = value - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_WRAP_T, value) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def anisotropy(self) -> float: @@ -567,11 +360,9 @@ def anisotropy(self) -> float: return self._anisotropy @anisotropy.setter + @abstractmethod def anisotropy(self, value): - self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glTexParameterf(self._target, gl.GL_TEXTURE_MAX_ANISOTROPY, self._anisotropy) + raise NotImplementedError("The enabled graphics backend does not support this method.") @property def compare_func(self) -> str | None: @@ -591,28 +382,11 @@ def compare_func(self) -> str | None: return self._compare_func @compare_func.setter + @abstractmethod def compare_func(self, value: str | None): - if not self._depth: - raise ValueError("Depth comparison function can only be set on depth textures") - - if not isinstance(value, str) and value is not None: - raise ValueError(f"value must be as string: {self._compare_funcs.keys()}") - - func = compare_funcs.get(value, None) - if func is None: - raise ValueError(f"value must be as string: {compare_funcs.keys()}") - - self._compare_func = value - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - if value is None: - gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_NONE) - else: - gl.glTexParameteri( - self._target, gl.GL_TEXTURE_COMPARE_MODE, gl.GL_COMPARE_REF_TO_TEXTURE - ) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_COMPARE_FUNC, func) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def read(self, level: int = 0, alignment: int = 1) -> bytes: """ Read the contents of the texture. @@ -624,26 +398,9 @@ def read(self, level: int = 0, alignment: int = 1) -> bytes: Alignment of the start of each row in memory in number of bytes. Possible values: 1,2,4 """ - if self._samples > 0: - raise ValueError("Multisampled textures cannot be read directly") - - if self._ctx.gl_api == "gl": - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, alignment) - - buffer = ( - gl.GLubyte - * (self.width * self.height * self.layers * self._component_size * self._components) - )() - gl.glGetTexImage(self._target, level, self._format, self._type, buffer) - return string_at(buffer, len(buffer)) - elif self._ctx.gl_api == "gles": - # FIXME: Check if we can attach a layer to the framebuffer. See Texture2D.read() - raise ValueError("Reading texture array data not supported in GLES yet") - else: - raise ValueError("Unknown gl_api: '{self._ctx.gl_api}'") + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> None: """Write byte data into layers of the texture. @@ -667,55 +424,7 @@ def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> `(x, y, layer, width, height)` writes to an area of a single layer. If not provided the entire texture is written to. """ - # TODO: Support writing to layers using viewport + alignment - if self._samples > 0: - raise ValueError("Writing to multisampled textures not supported") - - x, y, l, w, h = ( - 0, - 0, - 0, - self._width, - self._height, - ) - if viewport: - # TODO: Add more options here. For now we support writing to a single layer - # (width, hight, num_layers) is a suggestion from moderngl - # if len(viewport) == 3: - # w, h, l = viewport - if len(viewport) == 5: - x, y, l, w, h = viewport - else: - raise ValueError("Viewport must be of length 5") - - if isinstance(data, Buffer): - gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, data.glo) - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) - gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) - gl.glTexSubImage3D(self._target, level, x, y, w, h, l, self._format, self._type, 0) - gl.glBindBuffer(gl.GL_PIXEL_UNPACK_BUFFER, 0) - else: - byte_size, data = data_to_ctypes(data) - self._validate_data_size(data, byte_size, w, h, 1) # Single layer - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) - gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) - gl.glTexSubImage3D( - self._target, # target - level, # level - x, # x offset - y, # y offset - l, # layer - w, # width - h, # height - 1, # depth (one layer) - self._format, # format - self._type, # type - data, # pixel data - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") def _validate_data_size( self, byte_data, byte_size: int, width: int, height: int, layers: int @@ -737,6 +446,7 @@ def _validate_data_size( f"Data size {len(byte_data)} does not match reported size {expected_size}" ) + @abstractmethod def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: """Generate mipmaps for this texture. @@ -765,53 +475,27 @@ def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: Also see: https://www.khronos.org/opengl/wiki/Texture#Mip_maps """ - if self._samples > 0: - raise ValueError("Multisampled textures don't support mimpmaps") - - gl.glActiveTexture(gl.GL_TEXTURE0 + self._ctx.default_texture_unit) - gl.glBindTexture(self._target, self._glo) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_BASE_LEVEL, base) - gl.glTexParameteri(self._target, gl.GL_TEXTURE_MAX_LEVEL, max_level) - gl.glGenerateMipmap(self._target) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def delete(self): """ Destroy the underlying OpenGL resource. Don't use this unless you know exactly what you are doing. """ - self.delete_glo(self._ctx, self._glo) - self._glo.value = 0 - - @staticmethod - def delete_glo(ctx: "Context", glo: gl.GLuint): - """ - Destroy the texture. - - This is called automatically when the object is garbage collected. - - Args: - ctx: OpenGL Context - glo: The OpenGL texture id - """ - # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: - return - - if glo.value != 0: - gl.glDeleteTextures(1, byref(glo)) - - ctx.stats.decr("texture") + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def use(self, unit: int = 0) -> None: """Bind the texture to a channel, Args: unit: The texture unit to bind the texture. """ - gl.glActiveTexture(gl.GL_TEXTURE0 + unit) - gl.glBindTexture(self._target, self._glo) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: int = 0): """ Bind textures to image units. @@ -825,21 +509,9 @@ def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: write: The compute shader intends to write to this image level: The mipmap level to bind """ - if self._ctx.gl_api == "gles" and not self._immutable: - raise ValueError("Textures bound to image units must be created with immutable=True") - - access = gl.GL_READ_WRITE - if read and write: - access = gl.GL_READ_WRITE - elif read and not write: - access = gl.GL_READ_ONLY - elif not read and write: - access = gl.GL_WRITE_ONLY - else: - raise ValueError("Illegal access mode. The texture must at least be read or write only") - - gl.glBindImageTexture(unit, self._glo, level, 0, 0, access, self._internal_format) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def get_handle(self, resident: bool = True) -> int: """ Get a handle for bindless texture access. @@ -870,20 +542,4 @@ def get_handle(self, resident: bool = True) -> int: Args: resident: Make the texture resident. """ - handle = gl.glGetTextureHandleARB(self._glo) - is_resident = gl.glIsTextureHandleResidentARB(handle) - - # Ensure we don't try to make a resident texture resident again - if resident: - if not is_resident: - gl.glMakeTextureHandleResidentARB(handle) - else: - if is_resident: - gl.glMakeTextureHandleNonResidentARB(handle) - - return handle - - def __repr__(self) -> str: - return "".format( - self._glo.value, self._width, self._layers, self._height, self._components - ) + raise NotImplementedError("The enabled graphics backend does not support this method.") diff --git a/arcade/gl/types.py b/arcade/gl/types.py index 2af7fcbd64..ec63583b39 100644 --- a/arcade/gl/types.py +++ b/arcade/gl/types.py @@ -1,18 +1,16 @@ import re -from typing import Iterable, Sequence, Union - -from pyglet import gl -from typing_extensions import TypeAlias +from typing import Iterable, Sequence, TypeAlias, Union from arcade.types import BufferProtocol +from . import enums from .buffer import Buffer BufferOrBufferProtocol = Union[BufferProtocol, Buffer] -GLenumLike = Union[gl.GLenum, int] +GLenumLike = int PyGLenum = int -GLuintLike = Union[gl.GLuint, int] +GLuintLike = int PyGLuint = int @@ -23,102 +21,82 @@ #: Depth compare functions compare_funcs: dict[str | None, int] = { - None: gl.GL_NONE, - "<=": gl.GL_LEQUAL, - "<": gl.GL_LESS, - ">=": gl.GL_GEQUAL, - ">": gl.GL_GREATER, - "==": gl.GL_EQUAL, - "!=": gl.GL_NOTEQUAL, - "0": gl.GL_NEVER, - "1": gl.GL_ALWAYS, -} - -#: Swizzle conversion lookup -swizzle_enum_to_str: dict[int, str] = { - gl.GL_RED: "R", - gl.GL_GREEN: "G", - gl.GL_BLUE: "B", - gl.GL_ALPHA: "A", - gl.GL_ZERO: "0", - gl.GL_ONE: "1", + None: enums.NONE, + "<=": enums.LEQUAL, + "<": enums.LESS, + ">=": enums.GEQUAL, + ">": enums.GREATER, + "==": enums.EQUAL, + "!=": enums.NOTEQUAL, + "0": enums.NEVER, + "1": enums.ALWAYS, } -#: Swizzle conversion lookup -swizzle_str_to_enum: dict[str, int] = { - "R": gl.GL_RED, - "G": gl.GL_GREEN, - "B": gl.GL_BLUE, - "A": gl.GL_ALPHA, - "0": gl.GL_ZERO, - "1": gl.GL_ONE, -} - -_float_base_format = (0, gl.GL_RED, gl.GL_RG, gl.GL_RGB, gl.GL_RGBA) +_float_base_format = (0, enums.RED, enums.RG, enums.RGB, enums.RGBA) _int_base_format = ( 0, - gl.GL_RED_INTEGER, - gl.GL_RG_INTEGER, - gl.GL_RGB_INTEGER, - gl.GL_RGBA_INTEGER, + enums.RED_INTEGER, + enums.RG_INTEGER, + enums.RGB_INTEGER, + enums.RGBA_INTEGER, ) #: Pixel format lookup (base_format, internal_format, type, size) pixel_formats = { # float formats "f1": ( _float_base_format, - (0, gl.GL_R8, gl.GL_RG8, gl.GL_RGB8, gl.GL_RGBA8), - gl.GL_UNSIGNED_BYTE, + (0, enums.R8, enums.RG8, enums.RGB8, enums.RGBA8), + enums.UNSIGNED_BYTE, 1, ), "f2": ( _float_base_format, - (0, gl.GL_R16F, gl.GL_RG16F, gl.GL_RGB16F, gl.GL_RGBA16F), - gl.GL_HALF_FLOAT, + (0, enums.R16F, enums.RG16F, enums.RGB16F, enums.RGBA16F), + enums.HALF_FLOAT, 2, ), "f4": ( _float_base_format, - (0, gl.GL_R32F, gl.GL_RG32F, gl.GL_RGB32F, gl.GL_RGBA32F), - gl.GL_FLOAT, + (0, enums.R32F, enums.RG32F, enums.RGB32F, enums.RGBA32F), + enums.FLOAT, 4, ), # int formats "i1": ( _int_base_format, - (0, gl.GL_R8I, gl.GL_RG8I, gl.GL_RGB8I, gl.GL_RGBA8I), - gl.GL_BYTE, + (0, enums.R8I, enums.RG8I, enums.RGB8I, enums.RGBA8I), + enums.BYTE, 1, ), "i2": ( _int_base_format, - (0, gl.GL_R16I, gl.GL_RG16I, gl.GL_RGB16I, gl.GL_RGBA16I), - gl.GL_SHORT, + (0, enums.R16I, enums.RG16I, enums.RGB16I, enums.RGBA16I), + enums.SHORT, 2, ), "i4": ( _int_base_format, - (0, gl.GL_R32I, gl.GL_RG32I, gl.GL_RGB32I, gl.GL_RGBA32I), - gl.GL_INT, + (0, enums.R32I, enums.RG32I, enums.RGB32I, enums.RGBA32I), + enums.INT, 4, ), # uint formats "u1": ( _int_base_format, - (0, gl.GL_R8UI, gl.GL_RG8UI, gl.GL_RGB8UI, gl.GL_RGBA8UI), - gl.GL_UNSIGNED_BYTE, + (0, enums.R8UI, enums.RG8UI, enums.RGB8UI, enums.RGBA8UI), + enums.UNSIGNED_BYTE, 1, ), "u2": ( _int_base_format, - (0, gl.GL_R16UI, gl.GL_RG16UI, gl.GL_RGB16UI, gl.GL_RGBA16UI), - gl.GL_UNSIGNED_SHORT, + (0, enums.R16UI, enums.RG16UI, enums.RGB16UI, enums.RGBA16UI), + enums.UNSIGNED_SHORT, 2, ), "u4": ( _int_base_format, - (0, gl.GL_R32UI, gl.GL_RG32UI, gl.GL_RGB32UI, gl.GL_RGBA32UI), - gl.GL_UNSIGNED_INT, + (0, enums.R32UI, enums.RG32UI, enums.RGB32UI, enums.RGBA32UI), + enums.UNSIGNED_INT, 4, ), } @@ -126,28 +104,28 @@ #: String representation of a shader types SHADER_TYPE_NAMES = { - gl.GL_VERTEX_SHADER: "vertex shader", - gl.GL_FRAGMENT_SHADER: "fragment shader", - gl.GL_GEOMETRY_SHADER: "geometry shader", - gl.GL_TESS_CONTROL_SHADER: "tessellation control shader", - gl.GL_TESS_EVALUATION_SHADER: "tessellation evaluation shader", + enums.VERTEX_SHADER: "vertex shader", + enums.FRAGMENT_SHADER: "fragment shader", + enums.GEOMETRY_SHADER: "geometry shader", # Not supported in WebGL + enums.TESS_CONTROL_SHADER: "tessellation control shader", # Not supported in WebGL + enums.TESS_EVALUATION_SHADER: "tessellation evaluation shader", # Not supported in WebGL } #: Lookup table for OpenGL type names GL_NAMES = { - gl.GL_HALF_FLOAT: "GL_HALF_FLOAT", - gl.GL_FLOAT: "GL_FLOAT", - gl.GL_DOUBLE: "GL_DOUBLE", - gl.GL_INT: "GL_INT", - gl.GL_UNSIGNED_INT: "GL_UNSIGNED_INT", - gl.GL_SHORT: "GL_SHORT", - gl.GL_UNSIGNED_SHORT: "GL_UNSIGNED_SHORT", - gl.GL_BYTE: "GL_BYTE", - gl.GL_UNSIGNED_BYTE: "GL_UNSIGNED_BYTE", + enums.HALF_FLOAT: "GL_HALF_FLOAT", + enums.FLOAT: "GL_FLOAT", + enums.DOUBLE: "GL_DOUBLE", # Double not supported in WebGL + enums.INT: "GL_INT", + enums.UNSIGNED_INT: "GL_UNSIGNED_INT", + enums.SHORT: "GL_SHORT", + enums.UNSIGNED_SHORT: "GL_UNSIGNED_SHORT", + enums.BYTE: "GL_BYTE", + enums.UNSIGNED_BYTE: "GL_UNSIGNED_BYTE", } -def gl_name(gl_type: PyGLenum | None) -> str | PyGLenum | None: +def gl_name(gl_type): """Return the name of a gl type""" if gl_type is None: return None @@ -185,7 +163,7 @@ class AttribFormat: def __init__( self, name: str | None, - gl_type: PyGLenum | None, + gl_type, components: int, bytes_per_component: int, offset=0, @@ -256,24 +234,24 @@ class BufferDescription: # Describe all variants of a format string to simplify parsing (single component) # format: gl_type, byte_size - _formats: dict[str, tuple[PyGLenum | None, int]] = { + _formats: dict[str, tuple] = { # (gl enum, byte size) # Floats - "f": (gl.GL_FLOAT, 4), - "f1": (gl.GL_UNSIGNED_BYTE, 1), - "f2": (gl.GL_HALF_FLOAT, 2), - "f4": (gl.GL_FLOAT, 4), - "f8": (gl.GL_DOUBLE, 8), + "f": (enums.FLOAT, 4), + "f1": (enums.UNSIGNED_BYTE, 1), + "f2": (enums.HALF_FLOAT, 2), + "f4": (enums.FLOAT, 4), + "f8": (enums.DOUBLE, 8), # Double unsupported by WebGL # Unsigned integers - "u": (gl.GL_UNSIGNED_INT, 4), - "u1": (gl.GL_UNSIGNED_BYTE, 1), - "u2": (gl.GL_UNSIGNED_SHORT, 2), - "u4": (gl.GL_UNSIGNED_INT, 4), + "u": (enums.UNSIGNED_INT, 4), + "u1": (enums.UNSIGNED_BYTE, 1), + "u2": (enums.UNSIGNED_SHORT, 2), + "u4": (enums.UNSIGNED_INT, 4), # Signed integers - "i": (gl.GL_INT, 4), - "i1": (gl.GL_BYTE, 1), - "i2": (gl.GL_SHORT, 2), - "i4": (gl.GL_INT, 4), + "i": (enums.INT, 4), + "i1": (enums.BYTE, 1), + "i2": (enums.SHORT, 2), + "i4": (enums.INT, 4), # Padding (1, 2, 4, 8 bytes) "x": (None, 1), "x1": (None, 1), @@ -410,9 +388,7 @@ class TypeInfo: __slots__ = "name", "enum", "gl_type", "gl_size", "components" - def __init__( - self, name: str, enum: GLenumLike, gl_type: PyGLenum, gl_size: int, components: int - ): + def __init__(self, name: str, enum, gl_type, gl_size: int, components: int): self.name = name """The string representation of this type""" self.enum = enum @@ -463,67 +439,67 @@ class GLTypes: types = { # Floats - gl.GL_FLOAT: TypeInfo("GL_FLOAT", gl.GL_FLOAT, gl.GL_FLOAT, 4, 1), - gl.GL_FLOAT_VEC2: TypeInfo("GL_FLOAT_VEC2", gl.GL_FLOAT_VEC2, gl.GL_FLOAT, 4, 2), - gl.GL_FLOAT_VEC3: TypeInfo("GL_FLOAT_VEC3", gl.GL_FLOAT_VEC3, gl.GL_FLOAT, 4, 3), - gl.GL_FLOAT_VEC4: TypeInfo("GL_FLOAT_VEC4", gl.GL_FLOAT_VEC4, gl.GL_FLOAT, 4, 4), - # Doubles - gl.GL_DOUBLE: TypeInfo("GL_DOUBLE", gl.GL_DOUBLE, gl.GL_DOUBLE, 8, 1), - gl.GL_DOUBLE_VEC2: TypeInfo("GL_DOUBLE_VEC2", gl.GL_DOUBLE_VEC2, gl.GL_DOUBLE, 8, 2), - gl.GL_DOUBLE_VEC3: TypeInfo("GL_DOUBLE_VEC3", gl.GL_DOUBLE_VEC3, gl.GL_DOUBLE, 8, 3), - gl.GL_DOUBLE_VEC4: TypeInfo("GL_DOUBLE_VEC4", gl.GL_DOUBLE_VEC4, gl.GL_DOUBLE, 8, 4), + enums.FLOAT: TypeInfo("GL_FLOAT", enums.FLOAT, enums.FLOAT, 4, 1), + enums.FLOAT_VEC2: TypeInfo("GL_FLOAT_VEC2", enums.FLOAT_VEC2, enums.FLOAT, 4, 2), + enums.FLOAT_VEC3: TypeInfo("GL_FLOAT_VEC3", enums.FLOAT_VEC3, enums.FLOAT, 4, 3), + enums.FLOAT_VEC4: TypeInfo("GL_FLOAT_VEC4", enums.FLOAT_VEC4, enums.FLOAT, 4, 4), + # Doubles -- Unsupported by WebGL + enums.DOUBLE: TypeInfo("GL_DOUBLE", enums.DOUBLE, enums.DOUBLE, 8, 1), + enums.DOUBLE_VEC2: TypeInfo("GL_DOUBLE_VEC2", enums.DOUBLE_VEC2, enums.DOUBLE, 8, 2), + enums.DOUBLE_VEC3: TypeInfo("GL_DOUBLE_VEC3", enums.DOUBLE_VEC3, enums.DOUBLE, 8, 3), + enums.DOUBLE_VEC4: TypeInfo("GL_DOUBLE_VEC4", enums.DOUBLE_VEC4, enums.DOUBLE, 8, 4), # Booleans (ubyte) - gl.GL_BOOL: TypeInfo("GL_BOOL", gl.GL_BOOL, gl.GL_BOOL, 1, 1), - gl.GL_BOOL_VEC2: TypeInfo("GL_BOOL_VEC2", gl.GL_BOOL_VEC2, gl.GL_BOOL, 1, 2), - gl.GL_BOOL_VEC3: TypeInfo("GL_BOOL_VEC3", gl.GL_BOOL_VEC3, gl.GL_BOOL, 1, 3), - gl.GL_BOOL_VEC4: TypeInfo("GL_BOOL_VEC4", gl.GL_BOOL_VEC4, gl.GL_BOOL, 1, 4), + enums.BOOL: TypeInfo("GL_BOOL", enums.BOOL, enums.BOOL, 1, 1), + enums.BOOL_VEC2: TypeInfo("GL_BOOL_VEC2", enums.BOOL_VEC2, enums.BOOL, 1, 2), + enums.BOOL_VEC3: TypeInfo("GL_BOOL_VEC3", enums.BOOL_VEC3, enums.BOOL, 1, 3), + enums.BOOL_VEC4: TypeInfo("GL_BOOL_VEC4", enums.BOOL_VEC4, enums.BOOL, 1, 4), # Integers - gl.GL_INT: TypeInfo("GL_INT", gl.GL_INT, gl.GL_INT, 4, 1), - gl.GL_INT_VEC2: TypeInfo("GL_INT_VEC2", gl.GL_INT_VEC2, gl.GL_INT, 4, 2), - gl.GL_INT_VEC3: TypeInfo("GL_INT_VEC3", gl.GL_INT_VEC3, gl.GL_INT, 4, 3), - gl.GL_INT_VEC4: TypeInfo("GL_INT_VEC4", gl.GL_INT_VEC4, gl.GL_INT, 4, 4), + enums.INT: TypeInfo("GL_INT", enums.INT, enums.INT, 4, 1), + enums.INT_VEC2: TypeInfo("GL_INT_VEC2", enums.INT_VEC2, enums.INT, 4, 2), + enums.INT_VEC3: TypeInfo("GL_INT_VEC3", enums.INT_VEC3, enums.INT, 4, 3), + enums.INT_VEC4: TypeInfo("GL_INT_VEC4", enums.INT_VEC4, enums.INT, 4, 4), # Unsigned Integers - gl.GL_UNSIGNED_INT: TypeInfo( - "GL_UNSIGNED_INT", gl.GL_UNSIGNED_INT, gl.GL_UNSIGNED_INT, 4, 1 + enums.UNSIGNED_INT: TypeInfo( + "GL_UNSIGNED_INT", enums.UNSIGNED_INT, enums.UNSIGNED_INT, 4, 1 ), - gl.GL_UNSIGNED_INT_VEC2: TypeInfo( - "GL_UNSIGNED_INT_VEC2", gl.GL_UNSIGNED_INT_VEC2, gl.GL_UNSIGNED_INT, 4, 2 + enums.UNSIGNED_INT_VEC2: TypeInfo( + "GL_UNSIGNED_INT_VEC2", enums.UNSIGNED_INT_VEC2, enums.UNSIGNED_INT, 4, 2 ), - gl.GL_UNSIGNED_INT_VEC3: TypeInfo( - "GL_UNSIGNED_INT_VEC3", gl.GL_UNSIGNED_INT_VEC3, gl.GL_UNSIGNED_INT, 4, 3 + enums.UNSIGNED_INT_VEC3: TypeInfo( + "GL_UNSIGNED_INT_VEC3", enums.UNSIGNED_INT_VEC3, enums.UNSIGNED_INT, 4, 3 ), - gl.GL_UNSIGNED_INT_VEC4: TypeInfo( - "GL_UNSIGNED_INT_VEC4", gl.GL_UNSIGNED_INT_VEC4, gl.GL_UNSIGNED_INT, 4, 4 + enums.UNSIGNED_INT_VEC4: TypeInfo( + "GL_UNSIGNED_INT_VEC4", enums.UNSIGNED_INT_VEC4, enums.UNSIGNED_INT, 4, 4 ), # Unsigned Short (mostly used for short index buffers) - gl.GL_UNSIGNED_SHORT: TypeInfo( - "GL.GL_UNSIGNED_SHORT", gl.GL_UNSIGNED_SHORT, gl.GL_UNSIGNED_SHORT, 2, 2 + enums.UNSIGNED_SHORT: TypeInfo( + "GL.GL_UNSIGNED_SHORT", enums.UNSIGNED_SHORT, enums.UNSIGNED_SHORT, 2, 2 ), # Byte - gl.GL_BYTE: TypeInfo("GL_BYTE", gl.GL_BYTE, gl.GL_BYTE, 1, 1), - gl.GL_UNSIGNED_BYTE: TypeInfo( - "GL_UNSIGNED_BYTE", gl.GL_UNSIGNED_BYTE, gl.GL_UNSIGNED_BYTE, 1, 1 + enums.BYTE: TypeInfo("GL_BYTE", enums.BYTE, enums.BYTE, 1, 1), + enums.UNSIGNED_BYTE: TypeInfo( + "GL_UNSIGNED_BYTE", enums.UNSIGNED_BYTE, enums.UNSIGNED_BYTE, 1, 1 ), # Matrices - gl.GL_FLOAT_MAT2: TypeInfo("GL_FLOAT_MAT2", gl.GL_FLOAT_MAT2, gl.GL_FLOAT, 4, 4), - gl.GL_FLOAT_MAT3: TypeInfo("GL_FLOAT_MAT3", gl.GL_FLOAT_MAT3, gl.GL_FLOAT, 4, 9), - gl.GL_FLOAT_MAT4: TypeInfo("GL_FLOAT_MAT4", gl.GL_FLOAT_MAT4, gl.GL_FLOAT, 4, 16), - gl.GL_FLOAT_MAT2x3: TypeInfo("GL_FLOAT_MAT2x3", gl.GL_FLOAT_MAT2x3, gl.GL_FLOAT, 4, 6), - gl.GL_FLOAT_MAT2x4: TypeInfo("GL_FLOAT_MAT2x4", gl.GL_FLOAT_MAT2x4, gl.GL_FLOAT, 4, 8), - gl.GL_FLOAT_MAT3x2: TypeInfo("GL_FLOAT_MAT3x2", gl.GL_FLOAT_MAT3x2, gl.GL_FLOAT, 4, 6), - gl.GL_FLOAT_MAT3x4: TypeInfo("GL_FLOAT_MAT3x4", gl.GL_FLOAT_MAT3x4, gl.GL_FLOAT, 4, 12), - gl.GL_FLOAT_MAT4x2: TypeInfo("GL_FLOAT_MAT4x2", gl.GL_FLOAT_MAT4x2, gl.GL_FLOAT, 4, 8), - gl.GL_FLOAT_MAT4x3: TypeInfo("GL_FLOAT_MAT4x3", gl.GL_FLOAT_MAT4x3, gl.GL_FLOAT, 4, 12), - # Double matrices - gl.GL_DOUBLE_MAT2: TypeInfo("GL_DOUBLE_MAT2", gl.GL_DOUBLE_MAT2, gl.GL_DOUBLE, 8, 4), - gl.GL_DOUBLE_MAT3: TypeInfo("GL_DOUBLE_MAT3", gl.GL_DOUBLE_MAT3, gl.GL_DOUBLE, 8, 9), - gl.GL_DOUBLE_MAT4: TypeInfo("GL_DOUBLE_MAT4", gl.GL_DOUBLE_MAT4, gl.GL_DOUBLE, 8, 16), - gl.GL_DOUBLE_MAT2x3: TypeInfo("GL_DOUBLE_MAT2x3", gl.GL_DOUBLE_MAT2x3, gl.GL_DOUBLE, 8, 6), - gl.GL_DOUBLE_MAT2x4: TypeInfo("GL_DOUBLE_MAT2x4", gl.GL_DOUBLE_MAT2x4, gl.GL_DOUBLE, 8, 8), - gl.GL_DOUBLE_MAT3x2: TypeInfo("GL_DOUBLE_MAT3x2", gl.GL_DOUBLE_MAT3x2, gl.GL_DOUBLE, 8, 6), - gl.GL_DOUBLE_MAT3x4: TypeInfo("GL_DOUBLE_MAT3x4", gl.GL_DOUBLE_MAT3x4, gl.GL_DOUBLE, 8, 12), - gl.GL_DOUBLE_MAT4x2: TypeInfo("GL_DOUBLE_MAT4x2", gl.GL_DOUBLE_MAT4x2, gl.GL_DOUBLE, 8, 8), - gl.GL_DOUBLE_MAT4x3: TypeInfo("GL_DOUBLE_MAT4x3", gl.GL_DOUBLE_MAT4x3, gl.GL_DOUBLE, 8, 12), + enums.FLOAT_MAT2: TypeInfo("GL_FLOAT_MAT2", enums.FLOAT_MAT2, enums.FLOAT, 4, 4), + enums.FLOAT_MAT3: TypeInfo("GL_FLOAT_MAT3", enums.FLOAT_MAT3, enums.FLOAT, 4, 9), + enums.FLOAT_MAT4: TypeInfo("GL_FLOAT_MAT4", enums.FLOAT_MAT4, enums.FLOAT, 4, 16), + enums.FLOAT_MAT2x3: TypeInfo("GL_FLOAT_MAT2x3", enums.FLOAT_MAT2x3, enums.FLOAT, 4, 6), + enums.FLOAT_MAT2x4: TypeInfo("GL_FLOAT_MAT2x4", enums.FLOAT_MAT2x4, enums.FLOAT, 4, 8), + enums.FLOAT_MAT3x2: TypeInfo("GL_FLOAT_MAT3x2", enums.FLOAT_MAT3x2, enums.FLOAT, 4, 6), + enums.FLOAT_MAT3x4: TypeInfo("GL_FLOAT_MAT3x4", enums.FLOAT_MAT3x4, enums.FLOAT, 4, 12), + enums.FLOAT_MAT4x2: TypeInfo("GL_FLOAT_MAT4x2", enums.FLOAT_MAT4x2, enums.FLOAT, 4, 8), + enums.FLOAT_MAT4x3: TypeInfo("GL_FLOAT_MAT4x3", enums.FLOAT_MAT4x3, enums.FLOAT, 4, 12), + # Double matrices -- unsupported by WebGL + enums.DOUBLE_MAT2: TypeInfo("GL_DOUBLE_MAT2", enums.DOUBLE_MAT2, enums.DOUBLE, 8, 4), + enums.DOUBLE_MAT3: TypeInfo("GL_DOUBLE_MAT3", enums.DOUBLE_MAT3, enums.DOUBLE, 8, 9), + enums.DOUBLE_MAT4: TypeInfo("GL_DOUBLE_MAT4", enums.DOUBLE_MAT4, enums.DOUBLE, 8, 16), + enums.DOUBLE_MAT2x3: TypeInfo("GL_DOUBLE_MAT2x3", enums.DOUBLE_MAT2x3, enums.DOUBLE, 8, 6), + enums.DOUBLE_MAT2x4: TypeInfo("GL_DOUBLE_MAT2x4", enums.DOUBLE_MAT2x4, enums.DOUBLE, 8, 8), + enums.DOUBLE_MAT3x2: TypeInfo("GL_DOUBLE_MAT3x2", enums.DOUBLE_MAT3x2, enums.DOUBLE, 8, 6), + enums.DOUBLE_MAT3x4: TypeInfo("GL_DOUBLE_MAT3x4", enums.DOUBLE_MAT3x4, enums.DOUBLE, 8, 12), + enums.DOUBLE_MAT4x2: TypeInfo("GL_DOUBLE_MAT4x2", enums.DOUBLE_MAT4x2, enums.DOUBLE, 8, 8), + enums.DOUBLE_MAT4x3: TypeInfo("GL_DOUBLE_MAT4x3", enums.DOUBLE_MAT4x3, enums.DOUBLE, 8, 12), # TODO: Add sampler types if needed. Only needed for better uniform introspection. } diff --git a/arcade/gl/vertex_array.py b/arcade/gl/vertex_array.py index 80ed9eda9a..5a8a764c97 100644 --- a/arcade/gl/vertex_array.py +++ b/arcade/gl/vertex_array.py @@ -1,29 +1,17 @@ from __future__ import annotations import weakref -from ctypes import byref, c_void_p +from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Sequence -from pyglet import gl - from .buffer import Buffer from .program import Program -from .types import BufferDescription, GLenumLike, GLuintLike, gl_name if TYPE_CHECKING: from arcade.gl import Context -# Index buffer types based on index element size -index_types = [ - None, # 0 (not supported) - gl.GL_UNSIGNED_BYTE, # 1 ubyte8 - gl.GL_UNSIGNED_SHORT, # 2 ubyte16 - None, # 3 (not supported) - gl.GL_UNSIGNED_INT, # 4 ubyte32 -] - -class VertexArray: +class VertexArray(ABC): """ Wrapper for Vertex Array Objects (VAOs). @@ -47,12 +35,10 @@ class VertexArray: __slots__ = ( "_ctx", - "glo", "_program", "_content", "_ibo", "_index_element_size", - "_index_element_type", "_num_vertices", "__weakref__", ) @@ -61,7 +47,7 @@ def __init__( self, ctx: Context, program: Program, - content: Sequence[BufferDescription], + content: Sequence, # TODO: typing, this should be Sequence[BufferDescription] index_buffer: Buffer | None = None, index_element_size: int = 4, ) -> None: @@ -69,29 +55,12 @@ def __init__( self._program = program self._content = content - self.glo = glo = gl.GLuint() - """The OpenGL resource ID""" - self._num_vertices = -1 self._ibo = index_buffer self._index_element_size = index_element_size - self._index_element_type = index_types[index_element_size] - - self._build(program, content, index_buffer) - - if self._ctx.gc_mode == "auto": - weakref.finalize(self, VertexArray.delete_glo, self.ctx, glo) self.ctx.stats.incr("vertex_array") - def __repr__(self) -> str: - return f"" - - def __del__(self) -> None: - # Intercept garbage collection if we are using Context.gc() - if self._ctx.gc_mode == "context_gc" and self.glo.value > 0: - self._ctx.objects.append(self) - @property def ctx(self) -> Context: """The Context this object belongs to.""" @@ -112,158 +81,22 @@ def num_vertices(self) -> int: """The number of vertices.""" return self._num_vertices + @abstractmethod def delete(self) -> None: """ Destroy the underlying OpenGL resource. Don't use this unless you know exactly what you are doing. """ - VertexArray.delete_glo(self._ctx, self.glo) - self.glo.value = 0 - - @staticmethod - def delete_glo(ctx: Context, glo: gl.GLuint) -> None: - """ - Delete the OpenGL resource. - - This is automatically called when this object is garbage collected. - """ - # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: - return - - if glo.value != 0: - gl.glDeleteVertexArrays(1, byref(glo)) - glo.value = 0 - - ctx.stats.decr("vertex_array") - - def _build( - self, program: Program, content: Sequence[BufferDescription], index_buffer: Buffer | None - ) -> None: - """ - Build a vertex array compatible with the program passed in. - - This method will bind the vertex array and set up all the vertex attributes - according to the program's attribute specifications. - - Args: - program: - The program to use - content: - List of BufferDescriptions - index_buffer: - Index/element buffer - """ - gl.glGenVertexArrays(1, byref(self.glo)) - gl.glBindVertexArray(self.glo) - - if index_buffer is not None: - gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, index_buffer.glo) - - # Lookup dict for BufferDescription attrib names - descr_attribs = {attr.name: (descr, attr) for descr in content for attr in descr.formats} - - # Build the vao according to the shader's attribute specifications - for _, prog_attr in enumerate(program.attributes): - # Do we actually have an attribute with this name in buffer descriptions? - if prog_attr.name is not None and prog_attr.name.startswith("gl_"): - continue - try: - buff_descr, attr_descr = descr_attribs[prog_attr.name] - except KeyError: - raise ValueError( - ( - f"Program needs attribute '{prog_attr.name}', but is not present in buffer " - f"description. Buffer descriptions: {content}" - ) - ) - - # Make sure components described in BufferDescription and in the shader match - if prog_attr.components != attr_descr.components: - raise ValueError( - ( - f"Program attribute '{prog_attr.name}' has {prog_attr.components} " - f"components while the buffer description has {attr_descr.components} " - " components. " - ) - ) - - gl.glEnableVertexAttribArray(prog_attr.location) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, buff_descr.buffer.glo) - - # TODO: Detect normalization - normalized = gl.GL_TRUE if attr_descr.name in buff_descr.normalized else gl.GL_FALSE - - # Map attributes groups - float_types = (gl.GL_FLOAT, gl.GL_HALF_FLOAT) - double_types = (gl.GL_DOUBLE,) - int_types = ( - gl.GL_INT, - gl.GL_UNSIGNED_INT, - gl.GL_SHORT, - gl.GL_UNSIGNED_SHORT, - gl.GL_BYTE, - gl.GL_UNSIGNED_BYTE, - ) - attrib_type = attr_descr.gl_type - # Normalized integers must be mapped as floats - if attrib_type in int_types and buff_descr.normalized: - attrib_type = prog_attr.gl_type - - # Sanity check attribute types between shader and buffer description - if attrib_type != prog_attr.gl_type: - raise ValueError( - ( - f"Program attribute '{prog_attr.name}' has type " - f"{gl_name(prog_attr.gl_type)} " - f"while the buffer description has type {gl_name(attr_descr.gl_type)}. " - ) - ) - - if attrib_type in float_types: - gl.glVertexAttribPointer( - prog_attr.location, # attrib location - attr_descr.components, # 1, 2, 3 or 4 - attr_descr.gl_type, # GL_FLOAT etc - normalized, # normalize - buff_descr.stride, - c_void_p(attr_descr.offset), - ) - elif attrib_type in double_types: - gl.glVertexAttribLPointer( - prog_attr.location, # attrib location - attr_descr.components, # 1, 2, 3 or 4 - attr_descr.gl_type, # GL_DOUBLE etc - buff_descr.stride, - c_void_p(attr_descr.offset), - ) - elif attrib_type in int_types: - gl.glVertexAttribIPointer( - prog_attr.location, # attrib location - attr_descr.components, # 1, 2, 3 or 4 - attr_descr.gl_type, # GL_FLOAT etc - buff_descr.stride, - c_void_p(attr_descr.offset), - ) - else: - raise ValueError(f"Unsupported attribute type: {attr_descr.gl_type}") - - # print(( - # f"gl.glVertexAttribXPointer(\n" - # f" {prog_attr.location}, # attrib location\n" - # f" {attr_descr.components}, # 1, 2, 3 or 4\n" - # f" {attr_descr.gl_type}, # GL_FLOAT etc\n" - # f" {normalized}, # normalize\n" - # f" {buff_descr.stride},\n" - # f" c_void_p({attr_descr.offset}),\n" - # )) - # TODO: Sanity check this - if buff_descr.instanced: - gl.glVertexAttribDivisor(prog_attr.location, 1) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def render( - self, mode: GLenumLike, first: int = 0, vertices: int = 0, instances: int = 1 + self, + mode: int, + first: int = 0, + vertices: int = 0, + instances: int = 1, # TODO: typing, mode can also be a ctypes uint in GL backend ) -> None: """ Render the VertexArray to the currently active framebuffer. @@ -278,22 +111,12 @@ def render( instances: OpenGL instance, used in using vertices over and over """ - gl.glBindVertexArray(self.glo) - if self._ibo is not None: - # HACK: re-bind index buffer just in case. - # pyglet rendering was somehow replacing the index buffer. - gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self._ibo.glo) - gl.glDrawElementsInstanced( - mode, - vertices, - self._index_element_type, - first * self._index_element_size, - instances, - ) - else: - gl.glDrawArraysInstanced(mode, first, vertices, instances) + raise NotImplementedError("The enabled graphics backend does not support this method.") - def render_indirect(self, buffer: Buffer, mode: GLuintLike, count, first, stride) -> None: + @abstractmethod + def render_indirect( + self, buffer: Buffer, mode: int, count, first, stride + ) -> None: # TODO: typing, mode can also be a ctypes uint in GL backend """ Render the VertexArray to the framebuffer using indirect rendering. @@ -312,38 +135,14 @@ def render_indirect(self, buffer: Buffer, mode: GLuintLike, count, first, stride The byte stride of the draw command buffer. Keep the default (0) if the buffer is tightly packed. """ - # The default buffer stride for array and indexed - _stride = 20 if self._ibo is not None else 16 - stride = stride or _stride - if stride % 4 != 0 or stride < 0: - raise ValueError(f"stride must be positive integer in multiples of 4, not {stride}.") - - # The maximum number of draw calls in the buffer - max_commands = buffer.size // stride - if count < 0: - count = max_commands - elif (first + count) > max_commands: - raise ValueError( - "Attempt to issue rendering commands outside the buffer. " - f"first = {first}, count = {count} is reaching past " - f"the buffer end. The buffer have room for {max_commands} " - f"draw commands. byte size {buffer.size}, stride {stride}." - ) - - gl.glBindVertexArray(self.glo) - gl.glBindBuffer(gl.GL_DRAW_INDIRECT_BUFFER, buffer._glo) - if self._ibo: - gl.glMultiDrawElementsIndirect( - mode, self._index_element_type, first * stride, count, stride - ) - else: - gl.glMultiDrawArraysIndirect(mode, first * stride, count, stride) + raise NotImplementedError("The enabled graphics backend does not support this method.") + @abstractmethod def transform_interleaved( self, buffer: Buffer, - mode: GLenumLike, - output_mode: GLenumLike, + mode, # TODO, typing. This should be GLenumLike type + output_mode, # TODO, typing. This should be GLenumLike type first: int = 0, vertices: int = 0, instances: int = 1, @@ -368,44 +167,13 @@ def transform_interleaved( buffer_offset: Byte offset for the buffer (target) """ - if vertices < 0: - raise ValueError(f"Cannot determine the number of vertices: {vertices}") - - if buffer_offset >= buffer.size: - raise ValueError("buffer_offset at end or past the buffer size") - - gl.glBindVertexArray(self.glo) - gl.glEnable(gl.GL_RASTERIZER_DISCARD) - - if buffer_offset > 0: - gl.glBindBufferRange( - gl.GL_TRANSFORM_FEEDBACK_BUFFER, - 0, - buffer.glo, - buffer_offset, - buffer.size - buffer_offset, - ) - else: - gl.glBindBufferBase(gl.GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer.glo) - - gl.glBeginTransformFeedback(output_mode) - - if self._ibo is not None: - count = self._ibo.size // 4 - # TODO: Support first argument by offsetting pointer (second last arg) - gl.glDrawElementsInstanced(mode, vertices or count, gl.GL_UNSIGNED_INT, None, instances) - else: - # print(f"glDrawArraysInstanced({mode}, {first}, {vertices}, {instances})") - gl.glDrawArraysInstanced(mode, first, vertices, instances) - - gl.glEndTransformFeedback() - gl.glDisable(gl.GL_RASTERIZER_DISCARD) + raise NotImplementedError("The enabled graphics backend does not support this method.") def transform_separate( self, buffers: list[Buffer], - mode: GLenumLike, - output_mode: GLenumLike, + mode, # TODO, typing. This should be GLenumLike type + output_mode, # TODO, typing. This should be GLenumLike type first: int = 0, vertices: int = 0, instances: int = 1, @@ -430,45 +198,10 @@ def transform_separate( buffer_offset: Byte offset for the buffer (target) """ - if vertices < 0: - raise ValueError(f"Cannot determine the number of vertices: {vertices}") - - # Get size from the smallest buffer - size = min(buf.size for buf in buffers) - if buffer_offset >= size: - raise ValueError("buffer_offset at end or past the buffer size") - - gl.glBindVertexArray(self.glo) - gl.glEnable(gl.GL_RASTERIZER_DISCARD) - - if buffer_offset > 0: - for index, buffer in enumerate(buffers): - gl.glBindBufferRange( - gl.GL_TRANSFORM_FEEDBACK_BUFFER, - index, - buffer.glo, - buffer_offset, - buffer.size - buffer_offset, - ) - else: - for index, buffer in enumerate(buffers): - gl.glBindBufferBase(gl.GL_TRANSFORM_FEEDBACK_BUFFER, index, buffer.glo) - - gl.glBeginTransformFeedback(output_mode) - - if self._ibo is not None: - count = self._ibo.size // 4 - # TODO: Support first argument by offsetting pointer (second last arg) - gl.glDrawElementsInstanced(mode, vertices or count, gl.GL_UNSIGNED_INT, None, instances) - else: - # print(f"glDrawArraysInstanced({mode}, {first}, {vertices}, {instances})") - gl.glDrawArraysInstanced(mode, first, vertices, instances) - - gl.glEndTransformFeedback() - gl.glDisable(gl.GL_RASTERIZER_DISCARD) + raise NotImplementedError("The enabled graphics backend does not support this method.") -class Geometry: +class Geometry(ABC): """A higher level abstraction of the VertexArray. It generates VertexArray instances on the fly internally matching the incoming @@ -507,8 +240,8 @@ class Geometry: def __init__( self, - ctx: "Context", - content: Sequence[BufferDescription] | None, + ctx: Context, + content: Sequence | None, # TODO: typing, this should be Sequence[BufferDescription] index_buffer: Buffer | None = None, mode: int | None = None, index_element_size: int = 4, @@ -564,7 +297,7 @@ def num_vertices(self) -> int: def num_vertices(self, value: int): self._num_vertices = value - def append_buffer_description(self, descr: BufferDescription): + def append_buffer_description(self, descr): # TODO: typing, descr should be BufferDescription """ Append a new BufferDescription to the existing Geometry. @@ -592,7 +325,7 @@ def render( self, program: Program, *, - mode: GLenumLike | None = None, + mode=None, # TODO: typing, mode should be GLenumLike | None first: int = 0, vertices: int | None = None, instances: int = 1, @@ -670,7 +403,7 @@ def render_indirect( program: Program, buffer: Buffer, *, - mode: GLuintLike | None = None, + mode=None, # TODO: typing, mode should be GLuintLike | None count: int = -1, first: int = 0, stride: int = 0, @@ -808,6 +541,7 @@ def flush(self) -> None: """ self._vao_cache = {} + @abstractmethod def _generate_vao(self, program: Program) -> VertexArray: """ Create a new VertexArray for the given program. @@ -815,17 +549,7 @@ def _generate_vao(self, program: Program) -> VertexArray: Args: program: The program to use """ - # print(f"Generating vao for key {program.attribute_key}") - - vao = VertexArray( - self._ctx, - program, - self._content, - index_buffer=self._index_buffer, - index_element_size=self._index_element_size, - ) - self._vao_cache[program.attribute_key] = vao - return vao + raise NotImplementedError("The enabled graphics backend does not support this method.") @staticmethod def _release(ctx) -> None: diff --git a/arcade/management/__init__.py b/arcade/management/__init__.py index ec5c7522f0..1b098fcb3c 100644 --- a/arcade/management/__init__.py +++ b/arcade/management/__init__.py @@ -28,7 +28,11 @@ def show_info(): print("-" * len(version_str)) print("vendor:", window.ctx.info.VENDOR) print("renderer:", window.ctx.info.RENDERER) - print("version:", window.ctx.gl_version) + # TODO: Abstracted GL backend + # The context doesn't necessarily have this, this will need changed later + # Probably the context needs to provide an info function that will spit back relevent stuff + # rather than hardcoding the things we want here + print("version:", window.ctx.gl_version) # type: ignore print("python:", sys.version) print("platform:", sys.platform) print("pyglet version:", pyglet.version) diff --git a/arcade/utils.py b/arcade/utils.py index 25595390c3..7be6757ae1 100644 --- a/arcade/utils.py +++ b/arcade/utils.py @@ -21,6 +21,7 @@ "is_nonstr_iterable", "is_str_or_noniterable", "grow_sequence", + "is_pyodide", "is_raspberry_pi", "get_raspberry_pi_info", ] @@ -254,6 +255,10 @@ def __deepcopy__(self, memo): # noqa return decorated_type +def is_pyodide() -> bool: + return False + + def is_raspberry_pi() -> bool: """Determine if the host is a raspberry pi.""" return get_raspberry_pi_info()[0] diff --git a/doc/api_docs/gl/buffer.rst b/doc/api_docs/gl/buffer.rst index 33d3ca1c8c..5ab536c755 100644 --- a/doc/api_docs/gl/buffer.rst +++ b/doc/api_docs/gl/buffer.rst @@ -1,10 +1,10 @@ .. py:currentmodule:: arcade -Buffer -====== +Buffer (base) +============= -.. autoclass:: arcade.gl.Buffer +.. autoclass:: arcade.gl.buffer.Buffer :members: :undoc-members: :show-inheritance: diff --git a/doc/api_docs/gl/context.rst b/doc/api_docs/gl/context.rst index e75491cba4..754742a937 100644 --- a/doc/api_docs/gl/context.rst +++ b/doc/api_docs/gl/context.rst @@ -4,26 +4,26 @@ Context ======= -Context -------- +Context (Base) +-------------- -.. autoclass:: arcade.gl.Context +.. autoclass:: arcade.gl.context.Context :members: :undoc-members: :show-inheritance: :member-order: bysource -ContextStats ------------- +ContextStats (Base) +------------------- .. autoclass:: arcade.gl.context.ContextStats :members: :member-order: bysource -GLInfo ------- +Info (Base) +------------- -.. autoclass:: arcade.gl.context.GLInfo +.. autoclass:: arcade.gl.context.Info :members: :undoc-members: :member-order: bysource diff --git a/doc/api_docs/gl/framebuffer.rst b/doc/api_docs/gl/framebuffer.rst index d61c90359f..230ca34676 100644 --- a/doc/api_docs/gl/framebuffer.rst +++ b/doc/api_docs/gl/framebuffer.rst @@ -1,8 +1,8 @@ .. py:currentmodule:: arcade -Framebuffer -=========== +Framebuffer (Base) +================== .. autoclass:: arcade.gl.Framebuffer :members: diff --git a/doc/api_docs/gl/geometry.rst b/doc/api_docs/gl/geometry.rst index a82d992dea..6d6baa6691 100644 --- a/doc/api_docs/gl/geometry.rst +++ b/doc/api_docs/gl/geometry.rst @@ -1,8 +1,8 @@ .. py:currentmodule:: arcade -Geometry -======== +Geometry (Base) +=============== .. autoclass:: arcade.gl.Geometry :members: diff --git a/doc/api_docs/gl/index.rst b/doc/api_docs/gl/index.rst index d8afa586fc..e1e353e250 100644 --- a/doc/api_docs/gl/index.rst +++ b/doc/api_docs/gl/index.rst @@ -1,24 +1,182 @@ .. _arcade-api-gl: -OpenGL -====== - -This is the low level rendering API in Arcade and is used -internally for all drawing/rendering. It's a higher level -wrapper over OpenGL 3.3+ core and gives the user easy -access to GPU programs (shaders), textures, framebuffers, -queries, buffers, vertex arrays/geometry and compute shaders -(Note that compute shaders are not supported on MacOS). - -This API is also heavily inspired by ModernGL_. It's basically -a subset of ModernGL_ except we are using pyglet's -OpenGL bindings. However, we don't have the context -flexibility and speed of ModernGL_. - -The higher level abstraction is the main selling point because -it's much easier to learn, use and understand. -It saves the user from an enormous amount of work -and protects them from the most common pitfalls. +Arcade's Graphics Layer +======================= + +The ``arcade.gl`` module is a "graphics layer" around platform-specific backends. + +This module is meant to provide advanced users with: + +#. a pluggable backend API for porting to new platforms +#. consistent abstractions of low-level graphics primitives +#. avoiding common pitfalls graphics programming + +.. _arcade-api-gl-usage: + +Using This Module +----------------- + +This module **does not** aim to be a perfect copy of any other graphics API. + +.. warning:: + + This module assumes you are familiar with low-level graphics programming! + +Instead, it takes inspiration from ModernGL_ to build on :py:mod:`pyglet.gl` +with more :py:mod:`ctypes` bindings. + + + +The low-level API primitives and their reference implementation include: + +.. list-table:: + :header-rows: 1 + + * - Primitive + - Base (:py:mod:`arcade.gl`) + - OpenGL Reference Subclass (:py:mod:`arcade.gl.backends.opengl`) + + * - GPU programs (shaders) + - :py:class:`program.Program ` + - :py:class:`~arcade.gl.backends.opengl.program.Program` + + * - low-level texture objects [#textureTypes]_ + - :py:class:`arcade.gl.texture.Texture` + - :py:class:`~arcade.gl.backends.opengl.texture.Texture` + + * - framebuffers + - :py:class:`arcade.gl.framebuffer.Framebuffer` + - :py:class:`~arcade.gl.backends.opengl.framebuffer.Framebuffer` + + * - queries + - :py:class:`arcade.gl.query.Query` + - :py:class:`~arcade.gl.backends.opengl.query.Query` + + * - buffers + - :py:class:`arcade.gl.buffer.Buffer` + - :py:class:`~arcade.gl.backends.opengl.buffer.Buffer` + + * - vertex arrays/geometry + - :py:class:`arcade.gl.vertex_array.VertexArray` + - :py:class:`~arcade.gl.backends.opengl.vertex_array.VertexArray` + + * - Compute shaders [#macOS]_ + - :py:class:`arcade.gl.compute_shader.ComputerShader` + - :py:class:`~arcade.gl.backends.opengl.compute_shader.ComputerShader` + +Usage Reference +^^^^^^^^^^^^^^^ + +.. list-table:: + + * - Reference Backend + - :py:mod:`arcade.gl.backends.opengl` + + * - Abstraction examples + - See the `experimental examples`_ folder in the GitHub repo + +.. _experimental examples: https://github.com/pythonarcade/arcade/tree/development/arcade/experimental + +.. [#macOS] Compute shaders are not available on all platforms (see :ref:`arcade-api-gl-mac_no_compute_shaders`) +.. [#textureTypes] Most users want :py:class:`arcade.texture.Texture` (see :ref:`arcade-api-gl-two_texture_types`) + + +Graphics API Gotchas +-------------------- + +.. _arcade-api-gl-mac_no_compute_shaders: + +No Computer Shaders On Mac +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This limitation is due to how macOS handles OpenGL. + +Compute shaders became a core OpenGL feature in 4.3. However, Apple froze +OpenGL for macOS as a maintenance-only API at OpenGL 4.1 on both Intel and +M-series Macs. As a result, there are no compute shaders on Mac. + +Alternatives +"""""""""""" + +Clever use of fragments shaders and framebuffer objects (FBOs) can +sometimes provide equivalent results for specific cases. Since WebGL +also lacks compute shaders, older WebGL code can be a useful reference +for implementing Mac-compatible compute functionality within OpenGL. + +.. _arcade-api-gl-two_texture_types: + +Two Texture Types? +^^^^^^^^^^^^^^^^^^ + +This module includes low-level and platform-specific classes. + +Most users will want to use the high-lever :py:class:`arcade.Texture` +class. If you are still unsure, consult the table below: + +.. list-table:: + + * - Module + - Target Audience + - Contents + + * - :py:mod:`arcade.texture` + - Everyday users + - Friendly texture object suitable for implementing gameplay + + * - :py:mod:`arcade.gl.backends` ``texture`` submodules + - Platform-specific internals + - Low-level abstractions which handle platform-specific behavior for: + + * Low-level graphics APIs + * Operating systems + * Edge cases too specific to mention here + + +Supported Backends +------------------ + +Current Backends +^^^^^^^^^^^^^^^^ + +OpenGL Backend +"""""""""""""" + +The current implemented backend is the OpenGL/GLES wrapper in :py:mod:`arcade.gl.backends.opengl`. + +To maximize hardware support, it requires at least one of the following: + +* OpenGL 3.3+ +* GLES with certain extensions + +It avoids binary dependencies by using Python's built-in :py:mod:`ctypes` +module via both :py:mod:`pyglet` and Arcade's added OpenGL bindings. + +This ensures Arcade can run on most desktop and laptop hardware from the past +decade, just like :py:mod:`pyglet`. This portability trades away a bit of speed +and context-handling flexibility compared to ModernGL_. + +Future Backends +^^^^^^^^^^^^^^^ + +Web +""" + +Web browser support is an ongoing effort. + +The current plan is to implement a WebGPU backend running locally in-browser +via pyodide. This will ensure better performance and feature parity with desktop +environments compared to the archived `arcade-web`_ protoype. + +.. _arcade-web: https://github.com/pythonarcade/arcade-web + +Adding Backends +""""""""""""""" + +A new backend requires adding a submodule in :py:mod:`arcade.gl.backends` +which handles any initialization tasks to implement the following: + +* concreted versions of the classes in :ref:`arcade-api-gl-usage` +* exposes a concrete implementation of :py:class:`~arcade.gl.provider.BaseProvider` Note that all resources are created through the :py:class:`arcade.gl.Context` / :py:class:`arcade.ArcadeContext`. @@ -31,10 +189,6 @@ The :py:class:`arcade.ArcadeContext` on the other hand extends the default Context with Arcade-specific helper methods and should only be used by arcade. -Some prior knowledge of OpenGL might be needed to understand -how this API works, but we do have examples in the experimental -directory (git). - .. toctree:: :maxdepth: 1 @@ -47,7 +201,6 @@ directory (git). query program sampler - utils exceptions types diff --git a/doc/api_docs/gl/program.rst b/doc/api_docs/gl/program.rst index 15b5a1b1a9..d96ff9ec55 100644 --- a/doc/api_docs/gl/program.rst +++ b/doc/api_docs/gl/program.rst @@ -1,35 +1,11 @@ .. py:currentmodule:: arcade -Shader / Program -================ +Shader / Program (Base) +======================= .. autoclass:: arcade.gl.Program :members: :undoc-members: :show-inheritance: :member-order: bysource - -.. autoclass:: arcade.gl.ComputeShader - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource - -.. autoclass:: arcade.gl.uniform.Uniform - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource - -.. autoclass:: arcade.gl.uniform.UniformBlock - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource - -.. autoclass:: arcade.gl.glsl.ShaderSource - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/doc/api_docs/gl/query.rst b/doc/api_docs/gl/query.rst index 662c43760d..5b5b9200b9 100644 --- a/doc/api_docs/gl/query.rst +++ b/doc/api_docs/gl/query.rst @@ -1,8 +1,8 @@ .. py:currentmodule:: arcade -Query -===== +Query (Base) +============ .. autoclass:: arcade.gl.Query :members: diff --git a/doc/api_docs/gl/sampler.rst b/doc/api_docs/gl/sampler.rst index 7793ecb8d2..78d9c0fd2b 100644 --- a/doc/api_docs/gl/sampler.rst +++ b/doc/api_docs/gl/sampler.rst @@ -1,8 +1,8 @@ .. py:currentmodule:: arcade -Sampler -======= +Sampler (Base) +============== .. autoclass:: arcade.gl.Sampler :members: diff --git a/doc/api_docs/gl/texture.rst b/doc/api_docs/gl/texture.rst index 68f36e652a..bdd84cd0db 100644 --- a/doc/api_docs/gl/texture.rst +++ b/doc/api_docs/gl/texture.rst @@ -1,8 +1,8 @@ .. py:currentmodule:: arcade -Texture -======= +Texture(Base) +============= .. autoclass:: arcade.gl.Texture2D :members: diff --git a/doc/api_docs/gl/texture_array.rst b/doc/api_docs/gl/texture_array.rst index 03821fbfa0..087c1de8b2 100644 --- a/doc/api_docs/gl/texture_array.rst +++ b/doc/api_docs/gl/texture_array.rst @@ -1,8 +1,8 @@ .. py:currentmodule:: arcade -TextureArray -============ +TextureArray (Base) +=================== .. autoclass:: arcade.gl.TextureArray :members: diff --git a/doc/api_docs/gl/types.rst b/doc/api_docs/gl/types.rst index efb98dbe6c..da293179aa 100644 --- a/doc/api_docs/gl/types.rst +++ b/doc/api_docs/gl/types.rst @@ -1,8 +1,8 @@ .. py:currentmodule:: arcade.gl.types -Types -===== +Types (Base) +============ .. autodata:: BufferProtocol .. autodata:: BufferOrBufferProtocol @@ -13,8 +13,8 @@ Types .. autodata:: BlendFunction .. autodata:: compare_funcs -.. autodata:: swizzle_enum_to_str -.. autodata:: swizzle_str_to_enum +.. .. autodata:: swizzle_enum_to_str +.. .. autodata:: swizzle_str_to_enum .. autodata:: pixel_formats .. autodata:: SHADER_TYPE_NAMES diff --git a/doc/api_docs/gl/utils.rst b/doc/api_docs/gl/utils.rst index 48a126e384..a9847e230f 100644 --- a/doc/api_docs/gl/utils.rst +++ b/doc/api_docs/gl/utils.rst @@ -1,10 +1,3 @@ -.. py:currentmodule:: arcade +:orphan: -utils -===== - -.. automodule:: arcade.gl.utils - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource +.. temp freefloating since this isn't a "required" part of the spec? diff --git a/pyproject.toml b/pyproject.toml index 97d6dc398f..ffd028c5d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,9 @@ exclude = ["arcade/examples/*", "benchmarks/*"] [tool.mypy] disable_error_code = "annotation-unchecked" +exclude = [ + "arcade/gl/backends" +] [tool.pytest.ini_options] norecursedirs = [ @@ -137,6 +140,10 @@ norecursedirs = [ "dist", "tempt", ] +markers = [ + "backendgl: Run OpenGL (or OpenGL ES) backend specific tests", + "backendwebgl: Run WebGL backend specific tests" +] [tool.pyright] include = ["arcade"] @@ -145,6 +152,7 @@ exclude = [ "arcade/__pyinstaller", "arcade/examples", "arcade/experimental", + "arcade/gl/backends", "tests", "doc", "make.py", diff --git a/tests/conftest.py b/tests/conftest.py index d11069917a..ab5f3728e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,12 +26,33 @@ FIXTURE_ROOT = PROJECT_ROOT / "tests" / "fixtures" arcade.resources.add_resource_handle("fixtures", FIXTURE_ROOT) REAL_WINDOW_CLASS = arcade.Window +GL_BACKEND = "opengl" WINDOW = None OFFSCREEN = None +POSSIBLE_BACKENDS = [ + "backendopengl", + "backendwebgl" +] + arcade.resources.load_kenney_fonts() +def pytest_addoption(parser): + parser.addoption("--gl-backend", default="opengl") + +def pytest_configure(config): + global GL_BACKEND + GL_BACKEND = config.option.gl_backend + +def pytest_collection_modifyitems(config, items): + desired_backend = "backend" + GL_BACKEND + for item in items: + for backend in POSSIBLE_BACKENDS: + if backend in item.keywords: + if backend != desired_backend: + item.add_marker(pytest.mark.skip(f"Skipping GL backend specific test for {backend}")) + def make_window_caption(request=None, prefix="Testing", sep=" - ") -> str: """Centralizes test name customization. @@ -51,7 +72,7 @@ def create_window(width=1280, height=720, caption="Testing", **kwargs): global WINDOW if not WINDOW: WINDOW = REAL_WINDOW_CLASS( - width=width, height=height, title=caption, vsync=False, antialiasing=False + width=width, height=height, title=caption, vsync=False, antialiasing=False, gl_api = GL_BACKEND ) WINDOW.set_vsync(False) # This value is being monkey-patched into the Window class so that tests can identify if we are using diff --git a/tests/unit/gl/__init__.py b/tests/unit/gl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/gl/backends/__init__.py b/tests/unit/gl/backends/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/gl/backends/gl/__init__.py b/tests/unit/gl/backends/gl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/gl/test_gl_program.py b/tests/unit/gl/backends/gl/test_gl_program.py similarity index 97% rename from tests/unit/gl/test_gl_program.py rename to tests/unit/gl/backends/gl/test_gl_program.py index 2873e2dae4..5ea19a789f 100644 --- a/tests/unit/gl/test_gl_program.py +++ b/tests/unit/gl/backends/gl/test_gl_program.py @@ -4,9 +4,10 @@ from pyglet import gl from pyglet.math import Mat4, Mat3 from arcade.gl import ShaderException -from arcade.gl.uniform import UniformBlock -from arcade.gl.glsl import ShaderSource +from arcade.gl.backends.opengl.uniform import UniformBlock +from arcade.gl.backends.opengl.glsl import ShaderSource +pytestmark = pytest.mark.backendgl def test_shader_source(ctx): """Test shader source parsing""" @@ -28,9 +29,9 @@ def test_shader_source(ctx): None, gl.GL_VERTEX_SHADER, ) - if ctx.gl_api == "gl": + if ctx.gl_api == "opengl": assert source_wrapper.version == 330 - elif ctx.gl_api == "gles": + elif ctx.gl_api == "opengles": assert source_wrapper.version == 310 assert source_wrapper.out_attributes == ['out_pos', 'out_velocity'] diff --git a/tests/unit/gl/test_gl_context.py b/tests/unit/gl/test_gl_context.py index ede56b34b1..ebf1b21475 100644 --- a/tests/unit/gl/test_gl_context.py +++ b/tests/unit/gl/test_gl_context.py @@ -6,9 +6,9 @@ def test_ctx(ctx): - if ctx.gl_api == "gl": + if ctx.gl_api == "opengl": assert ctx.gl_version >= (3, 3) - elif ctx.gl_api == "gles": + elif ctx.gl_api == "opengles": assert ctx.gl_version >= (3, 1) else: raise ValueError(f"Unsupported api: {ctx.gl_api}") @@ -77,7 +77,7 @@ def test_enable_disable(ctx): assert ctx.is_enabled(ctx.BLEND) is False assert len(ctx._flags) == 2 - ctx.enable_only(ctx.BLEND, ctx.CULL_FACE, ctx.DEPTH_TEST, ctx.PROGRAM_POINT_SIZE) + ctx.enable_only(ctx.BLEND, ctx.CULL_FACE, ctx.DEPTH_TEST) def test_enabled(ctx): diff --git a/tests/unit/gl/test_gl_query.py b/tests/unit/gl/test_gl_query.py index e024b266cd..efd0bd2ab6 100644 --- a/tests/unit/gl/test_gl_query.py +++ b/tests/unit/gl/test_gl_query.py @@ -33,7 +33,7 @@ def test_create(window: arcade.Window): quad.render(program) # gles query doesn't support time and written samples - if ctx.gl_api == "gl": + if ctx.gl_api == "opengl": assert query.time_elapsed > 0 assert query.samples_passed >= SCREEN_WIDTH * SCREEN_HEIGHT diff --git a/tests/unit/gl/test_gl_texture.py b/tests/unit/gl/test_gl_texture.py index 2e41a63026..a78a9b31d4 100644 --- a/tests/unit/gl/test_gl_texture.py +++ b/tests/unit/gl/test_gl_texture.py @@ -26,9 +26,9 @@ def test_properties(ctx): with pytest.raises(ValueError): texture.filter = None - texture.wrap_x = ctx.CLAMP_TO_BORDER + texture.wrap_x = ctx.CLAMP_TO_EDGE texture.wrap_y = ctx.CLAMP_TO_EDGE - assert texture.wrap_x == ctx.CLAMP_TO_BORDER + assert texture.wrap_x == ctx.CLAMP_TO_EDGE assert texture.wrap_y == ctx.CLAMP_TO_EDGE From 0edd68f1e19ba6958a864b93d49adccea8d93c65 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sat, 17 May 2025 22:31:43 -0400 Subject: [PATCH 169/279] Improve the UIGridLayout top-level docstring * Fix formatting * Rephrase for clarity --- arcade/gui/widgets/layout.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index 4a10d19d38..086989cd45 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -425,24 +425,29 @@ def __str__(self): class UIGridLayout(UILayout): - """Place widgets in a grid. + """Arranges each child widget over one or more columns and rows. - Widgets can span multiple columns and rows. - By default, the layout will only use the minimal required space (``size_hint = (0, 0)``). + The layout's ``size_hint`` requests a target size as a :py:class:`tuple` + of ``(x, y)`` floats as ratios relative to the layout's parent: - Widgets can provide a ``size_hint`` to request dynamic space relative to the layout size. - A size_hint of ``(1, 1)`` will fill the available space, while ``(0.1, 0.1)`` - will use maximum 10% of the layouts total size. + * The default of ``(0, 0)`` requests the minimum possible space + * ``(1.0, 1.0)`` requests the maximum possible space + * ``(0.1, 0.1)`` requests 10% of the layout parent's total size - Children are resized based on ``size_hint``. Maximum and minimum - ``size_hint``s only take effect if a ``size_hint`` is given. + Each child widget's ``size_hint`` value will be used to: - The layouts ``size_hint_min`` is automatically - updated based on the minimal required space by children, after layouting. + * control its size within the layout + * automatically re-calculate the layout's ``size_hint_min`` - The width of columns and height of rows are calculated based on the size hints of the children. - The highest size_hint_min of a child in a column or row is used. If a child has no size_hint, - the actual size is considered. + The widths of each column and height of each row is calculated are calculated + from the size hint values of child widgets along each. For each, the maximum + ``size_hint_min`` along its axis will be used. If a widget lacks ``size_hint`` + values, its current "actual" size will be used instead. + + .. note:: + + Maximum and minimum size hints only take effect when a ``size_hint`` + is set. Args: x: ``x`` coordinate of bottom left corner. From ed423b4b485ba65148acad147090f10ecf9d89c2 Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sat, 17 May 2025 22:35:20 -0400 Subject: [PATCH 170/279] Add quotes to string literals for alignment --- arcade/gui/widgets/layout.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index 086989cd45..54b290fbf0 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -454,11 +454,11 @@ class UIGridLayout(UILayout): y: ``y`` coordinate of bottom left corner. width: Width of the layout. height: Height of the layout. - align_horizontal: Align children in orthogonal direction. - Options include ``left``, ``center``, and ``right``. - align_vertical: Align children in orthogonal direction. Options - include ``top``, ``center``, and ``bottom``. - children: Initial list of children. More can be added later. + align_horizontal: Align children in along the X axis to the + ``"left"``, ``"center"``, or ``"right"``. + align_vertical: Align children in along the Y axis to the + ``"top"``, ``"center"``, or ``"bottom"``. + children: An initial iterable of children. More can be added later. size_hint: A size hint for :py:class:`~arcade.gui.UILayout`, if the :py:class:`~arcade.gui.UIWidget` would like to grow. size_hint_max: Maximum width and height in pixels. From 370d6bb1349a7a38317c3550cc51b2ef850e64ca Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sat, 17 May 2025 22:54:46 -0400 Subject: [PATCH 171/279] Fix phrasing in top-level docstring --- arcade/gui/widgets/layout.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index 54b290fbf0..ca0939eb5c 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -439,10 +439,16 @@ class UIGridLayout(UILayout): * control its size within the layout * automatically re-calculate the layout's ``size_hint_min`` - The widths of each column and height of each row is calculated are calculated - from the size hint values of child widgets along each. For each, the maximum - ``size_hint_min`` along its axis will be used. If a widget lacks ``size_hint`` - values, its current "actual" size will be used instead. + The width of each column and height of each row will be calculated + on update by reading the sizing data of each child widget. For each + row/column, the corresponding values along its axis will be read + from widgets along its axis: + + * the maximum ``size_hint_min`` of its widgets + * the minimum ``size_hint_max`` of its widgets + + If any widget lacks size hint data, its "actual" will be used instead. + See :py:meth:`~.do_layout` to learn more. .. note:: From 68c4725d5a1c6d644ec559471be1a6010c9e213c Mon Sep 17 00:00:00 2001 From: pushfoo <36696816+pushfoo@users.noreply.github.com> Date: Sat, 17 May 2025 23:42:26 -0400 Subject: [PATCH 172/279] Elaborate on UIGridLayout.do_layout's docstring --- arcade/gui/widgets/layout.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index ca0939eb5c..386532d707 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -662,18 +662,26 @@ def _update_size_hints(self): ) def do_layout(self): - """Executes the layout algorithm. - - Children are placed in a grid layout based on the size hints. + """Arrange children in the grid based on their size hints. Algorithm --------- - 0. generate list for all rows and columns - 1. per column, collect max of size_hint_min and max size_hint (widths) - 2. per row, collect max of size_hint_min and max size_hint (heights) - 3. use box layout algorithm to distribute space - 4. place widgets in grid layout + 0. Generate lists of child widgets for each row and column + 1. For each column, calculate the following values: + + * If a widget lacks size hints, substitute the "actual" width instead + * The :py:func:`max` of all its child ``size_hint_min[0]`` values + * The :py:func:`max` of all its child ``size_hint[0]`` values + + 2. For each row, calculate the following values: + + * If a widget lacks size hints, substitute the "actual" height instead + * The :py:func:`max` of all its child ``size_hint_min[1]`` values + * The :py:func:`max` of all its child ``size_hint[1]`` values + + 3. Use box layout algorithm to allocate on-screen space + 4. Re-size and place the widgets to match the calculated grid layout """ From 8df726c898ebb594268fb57599415135cbd1f2b0 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Mon, 19 May 2025 22:09:34 +0200 Subject: [PATCH 173/279] update Scene to return SpriteList --- arcade/scene.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/arcade/scene.py b/arcade/scene.py index 0d9a3fb060..101f54a779 100644 --- a/arcade/scene.py +++ b/arcade/scene.py @@ -11,6 +11,7 @@ """ from collections.abc import Iterable +from typing import TypeVar from warnings import warn from arcade import Sprite, SpriteList @@ -20,6 +21,8 @@ __all__ = ["Scene", "SceneKeyError"] +_S = TypeVar("_S", bound=Sprite) + class SceneKeyError(KeyError): """ @@ -151,7 +154,7 @@ def __getitem__(self, key: str) -> SpriteList: raise SceneKeyError(key) - def add_sprite(self, name: str, sprite: Sprite) -> None: + def add_sprite(self, name: str, sprite: _S) -> _S: """ Add a Sprite to the SpriteList with the specified name. @@ -177,12 +180,14 @@ def add_sprite(self, name: str, sprite: Sprite) -> None: new_list.append(sprite) self.add_sprite_list(name=name, sprite_list=new_list) + return sprite + def add_sprite_list( self, name: str, use_spatial_hash: bool = False, sprite_list: SpriteList | None = None, - ) -> None: + ) -> SpriteList: """ Add a SpriteList to the scene with the specified name. @@ -207,6 +212,7 @@ def add_sprite_list( ) self._name_mapping[name] = sprite_list self._sprite_lists.append(sprite_list) + return sprite_list def add_sprite_list_before( self, @@ -214,7 +220,7 @@ def add_sprite_list_before( before: str, use_spatial_hash: bool = False, sprite_list: SpriteList | None = None, - ) -> None: + ) -> SpriteList: """ Add a sprite list to the scene with the specified name before another SpriteList. @@ -244,6 +250,7 @@ def add_sprite_list_before( before_list = self._name_mapping[before] index = self._sprite_lists.index(before_list) self._sprite_lists.insert(index, sprite_list) + return sprite_list def move_sprite_list_before( self, @@ -279,7 +286,7 @@ def add_sprite_list_after( after: str, use_spatial_hash: bool = False, sprite_list: SpriteList | None = None, - ) -> None: + ) -> SpriteList: """ Add a SpriteList to the scene with the specified name after a specific SpriteList. @@ -309,6 +316,7 @@ def add_sprite_list_after( after_list = self._name_mapping[after] index = self._sprite_lists.index(after_list) + 1 self._sprite_lists.insert(index, sprite_list) + return sprite_list def move_sprite_list_after( self, @@ -338,19 +346,21 @@ def move_sprite_list_after( old_index = self._sprite_lists.index(name_list) self._sprite_lists.insert(new_index, self._sprite_lists.pop(old_index)) - def remove_sprite_list_by_index(self, index: int) -> None: + def remove_sprite_list_by_index(self, index: int) -> SpriteList: """ Remove a layer from the scene by its index in the draw order. Args: index: The index of the sprite list to remove. """ - self.remove_sprite_list_by_object(self._sprite_lists[index]) + sprite_list = self._sprite_lists[index] + self.remove_sprite_list_by_object(sprite_list) + return sprite_list def remove_sprite_list_by_name( self, name: str, - ) -> None: + ) -> SpriteList: """ Remove a layer from the scene by its name. @@ -363,6 +373,7 @@ def remove_sprite_list_by_name( sprite_list = self._name_mapping[name] self._sprite_lists.remove(sprite_list) del self._name_mapping[name] + return sprite_list def remove_sprite_list_by_object(self, sprite_list: SpriteList) -> None: """ From 65b5662bc643862bd8ee3dc016415f9b51b3122d Mon Sep 17 00:00:00 2001 From: jeramyrd <117464784+jeramyrd@users.noreply.github.com> Date: Tue, 20 May 2025 09:29:23 -0400 Subject: [PATCH 174/279] Optional update for the platform tutorial. (#2690) * first change to test * condense install section * condense install section - format 2 * testing an external link * install and step 1 finalized * step_02 done * fixing ~~~ thing --------- Co-authored-by: Dickerson --- .pre-commit-config.yaml | 2 +- doc/_archive/install/windows.rst | 5 +- doc/get_started/install.rst | 7 ++ doc/tutorials/platform_tutorial/step_01.rst | 117 ++++++++++++++++---- doc/tutorials/platform_tutorial/step_02.rst | 105 ++++++++++++------ 5 files changed, 177 insertions(+), 59 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd6633f90d..d71f581fff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ default_language_version: - python: python3.10 + python: python3.13 repos: - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/doc/_archive/install/windows.rst b/doc/_archive/install/windows.rst index 08e40885bc..1fb69c274c 100644 --- a/doc/_archive/install/windows.rst +++ b/doc/_archive/install/windows.rst @@ -2,7 +2,10 @@ Windows ======= To develop with the Arcade library, we need to install Python, then install -Arcade. +Arcade. If you are comfortable setting things up and know how to run a +command prompt, this is a decent guide to setting things up. If the idea +of a virtual environment is new, a better guide for setting up Python is +here: `Your Python Coding Environment on Windows: Setup Guide `_ Step 1: Install Python ---------------------- diff --git a/doc/get_started/install.rst b/doc/get_started/install.rst index 5733fcf709..34c8e7223d 100644 --- a/doc/get_started/install.rst +++ b/doc/get_started/install.rst @@ -8,6 +8,13 @@ Install .. _install_requirements: +.. note:: These steps require some basic computer admin knowledge. + + If you are comfortable installing programs and know how to run a + command prompt, this is a decent guide to setting things up. If the idea + of a virtual environment is new, a better guide for setting up Python is + here: `Your Python Coding Environment on Windows: Setup Guide `_ + Requirements ------------ Arcade requires a desktop, laptop, or compatible Single-Board Computer (SBC) with: diff --git a/doc/tutorials/platform_tutorial/step_01.rst b/doc/tutorials/platform_tutorial/step_01.rst index db74cdedeb..d28e0eb9cd 100644 --- a/doc/tutorials/platform_tutorial/step_01.rst +++ b/doc/tutorials/platform_tutorial/step_01.rst @@ -4,25 +4,36 @@ Step 1 - Install and Open a Window ---------------------------------- -Our first step is to make sure everything is installed, and that we can at least -get a window open. +You will do two things in this section. + +1) Run the code from the arcade module directly. This will open a simple window. + +2) Create your own file with the copied code to work on for the rest of the tutorial. + Run it, you will get the same window, but THIS copy you control! Installation ~~~~~~~~~~~~ -* Make sure Python is installed. `Download Python here `_ - if you don't already have it. +For any of this to work, you need Python with the Arcade module installed. + +* Setup instruction are here: :ref:`install`. + +Verify the Installation (Step 1) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Run the following code from a terminal in the folder that contains the arcade directory. -* Make sure the `Arcade library `_ is installed. +.. code-block:: - * You should first setup a virtual environment (venv) and activate it. - * Install Arcade with ``pip install arcade``. - * Here are the longer, official :ref:`install`. + python -m arcade.examples.platform_tutorial.01_open_window -Open a Window -~~~~~~~~~~~~~ +You should end up with a window like this: -The example below opens up a blank window. Set up a project and get the code -below working. +.. image:: step_01.png + :width: 75% + +The window is pretty useless... You can minimize it and even close it! However, if you got +this going, you have succeeded in what is generally the hardest part of any new project +- getting the environment setup! Congratulations! (Commence party dance sequence!) .. note:: @@ -31,14 +42,78 @@ below working. interesting things we can do first. Therefore we'll stick with a fixed-size window for this tutorial. + +Coding...The fun part! (Step 2) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +You also need a code editor. There are dozens of options and we can't keep up with them +here. A simple text editor could work, but better options abound. A web search is your +friend - search for "Python editor" and pick one that looks good. Visual Studio Code +(VS Code) is a nice free option. PyCharm is another. + + +* Open your editor of choice and make a new file, call it main.py (the name is important!) +* Copy this code into it. + .. literalinclude:: ../../../arcade/examples/platform_tutorial/01_open_window.py :caption: 01_open_window.py - Open a Window :linenos: -You should end up with a window like this: +* Run the code from the same environment and in the folder with the code: -.. image:: step_01.png - :width: 75% +.. code-block:: + + python main.py + +What is this code? +~~~~~~~~~~~~~~~~~~ + +Explaining in depth every part of the code would be tedious and pretty overwhelming, +and ultimatly not the most useful way to learn. But knowing the intent of sections is good! + +Comments + Triple quotations mark the begin and end of comments - sections of + code that the computer ignores. It is for us humans! + A second way of making a comment is the '#' symbol which lets python know to + ignore everything on that line after the symbol. + +import arcade + This tells python which tools it needs to use to run the code. The magic sauce that does + all the heavy lifting for the program so that you can focus on being creative! + +# Constants section + Remember how we said the window is a fixed size? Can you guess WHAT size it is fixed to + from these lines? Did you notice a title on the window (go ahead and peek!). + +class GameView(arcade.Window): + Classes are extremely important programming concepts. In simple terms, it is a collection + of data that you do things with. It keeps things organized! + +def __init__(self): + Data sets that make up class objects need initial information. You provide that here. + +def setup(self): + pass? what are we passing? Well, nothing, this is a place holder for future awesome code. + pass tells Python that the function does nothing at the moment and it can move along. + +def on_draw(self): + Right now this function runs a simple command - self.clear(). Notice all the comments! That + is so that you (the coder) knows what is going on in the function. More on this section to follow! + +def main(): + This is the entry point for Python. When you run a program, Python looks for a main() function + and runs it. Then three steps happen: + +1) window = GameView() -> Your window object is created. Your __init__ function is automatically called. +2) window.setup() - > You run the window object's setup function... Which does nothing at the moment (pass!) +3) arcade.run() -> You turn on the engine and see the results. The program stays here until the + window is closed. Then with nothing else to do, the program will terminate. + +if __name__ == "__main__": + If Python was run by starting this file in particular, then its "__name__" value will be "__main__". + And the main() function will be called. Remember how we said the name of the file was important? + +Challenge Exercises +~~~~~~~~~~~~~~~~~~~ Once you get the code working, try figuring out how to adjust the code so you can: @@ -51,10 +126,8 @@ Once you get the code working, try figuring out how to adjust the code so you ca * Look through the documentation for the :class:`arcade.Window` class to get an idea of everything it can do. - -Run This Chapter -~~~~~~~~~~~~~~~~ - -.. code-block:: - - python -m arcade.examples.platform_tutorial.01_open_window +* Break it! Yes this sounds bad, but comment out some sections of the code and try to run it. + You will make mistakes and this can help you get familure with error messages. Also, see if + you can see in the error message WHERE the problem exists or other such clues. You already + know because you made them... Make sure you save the good code and it is functional again + before moving on. diff --git a/doc/tutorials/platform_tutorial/step_02.rst b/doc/tutorials/platform_tutorial/step_02.rst index f0b1ae3ee0..ccd15f978d 100644 --- a/doc/tutorials/platform_tutorial/step_02.rst +++ b/doc/tutorials/platform_tutorial/step_02.rst @@ -3,31 +3,46 @@ Step 2 - Textures and Sprites ----------------------------- -Our next step in this tutorial is to draw something on the Screen. In order to -do that we need to cover two topics, Textures and Sprites. +Our next step in this tutorial is to draw something on the Screen. -At the end of this chapter, we'll have something that looks like this. It's largely the -same as last chapter, but now we are drawing a character onto the screen: +To see what we will accomplish, run this code: + +.. code-block:: + + python -m arcade.examples.platform_tutorial.02_draw_sprites + + +You should see the same window, but now with a character on the screen: .. image:: images/title_02.png :width: 70% +Images in 2D games are created using Textures and Sprites. Let's discuss those ideas. + Textures ~~~~~~~~ Textures are largely just an object to contain image data. Whenever you load an image -file in Arcade, for example a ``.png`` or ``.jpeg`` file. It becomes a Texture. +file in Arcade, for example a ``.png`` or ``.jpeg`` file, it becomes a Texture. To do this, internally Arcade uses Pyglet to load the image data, and the texture is responsible for keeping track of this image data. We can create a texture with a simple command, this can be done inside of our ``__init__`` -function. Go ahead and create a texture that we will use to draw a player. +function. Go ahead and create a texture that we will use to draw a player by adding this code +into the __init__ function of the GameView class. Right below the "super()..." statement is fine. .. code-block:: self.player_texture = arcade.load_texture(":resources:images/animated_characters/female_adventurer/femaleAdventurer_idle.png") +What is the code doing? + the 'self.' part the of statement officially attaches the stuff that comes behind it to become part + of the class it is attached to. In this case, ANY function inside GameView now has access to a + variable self.player_texture. That is how we 'share' the class data with the class methods (functions). + arcade.load_texture() does all the dirty work of accepting a path to a image file and making it + useful for the program. + .. note:: You might be wondering where this image file is coming from? And what is ``:resources:`` about? @@ -46,12 +61,15 @@ function. Go ahead and create a texture that we will use to draw a player. Sprites ~~~~~~~ -If Textures are an instance of a particular image, then :class:`arcade.Sprite` is an instance of that image -on the screen. Say we have a ground or wall texture. We only have one instance of the texture, but we can create -multiple instances of Sprite, because we want to have many walls. These will use the same texture, but draw it -in different positions, or even with different scaling, rotation, or colors/post-processing effects. +While the texture data is now 'saved' into the class as a variable. We can't use it as is, we need to convert it +to a Sprite. If Textures are an instance of a particular image from a file, then :class:`arcade.Sprite` is an +instance of that image that can be put on the screen later on. Say we have a ground or wall texture. We only have +one instance of the texture, but we can create multiple instances of Sprite, because we want to have many walls. +These will use the same texture, but draw it in different positions, or even with different scaling, rotation, +or colors/post-processing effects. -Creating a Sprite is simple, we can make one for our player in our ``__init__`` function, and then set it's position. +Creating a Sprite is simple, we can make one for our player in our ``__init__`` function. Make sure it is +right after the previous statement. See the challenge section for why this is important! .. code-block:: @@ -61,16 +79,54 @@ Creating a Sprite is simple, we can make one for our player in our ``__init__`` .. note:: - You can also skip ``arcade.load_texture`` from the previous step and pass the image file to ``arcade.Sprite`` in place of the Texture object. + You can also skip ``arcade.load_texture`` from the previous step and pass the image file to ``arcade.Sprite`` in place of the Texture object. A Texture will automatically be created for you. However, it may desirable in larger projects to manage your textures directly. -Now we can draw the sprite by adding this to our ``on_draw`` function: + +Rendering +~~~~~~~~~ + +If you ran your program as is, you will notice.... nothing new! We have simply given the class a +texture and defined a sprite. But no instructions on what to do with that data. Remember, classes are objects +that HAVE data (our image in sprite-form now) and DO stuff with the data. + +Rendering is how we get our cool Sprite onto our window by adding the next command to our ``on_draw`` function. Place it under the +"# Code to draw other things will go here" comment. .. code-block:: + # Code to draw other things will go here arcade.draw_sprite(self.player_sprite) -We're now drawing a Sprite to the screen! In the next chapter, we will introduce techniques to draw many(even hundreds of thousands) sprites at once. +Now run the code! You will have to remember the code to run your program from now on :) + +.. code-block:: + + python main.py + +Challenge +~~~~~~~~~ + +* Play with the center_x and center_y variables - what do they do? +* Move the load.texture statement after the arcade.Sprite(self.player_texture) statement and run it. + It fails. Why? Well, its because computers do things in order. If you try to use a variable before + defining it, you will get an error. Computers don't try to guess what you want or bother looking + around for the item. If it is not there, it instantly gives up. +* See if you can find the location of the resources and use a different image file. +* use a loop to create several copies of the image to the window - each with different locations. Hint: + add the 'import random' module (another toolbox for Python) right under the import arcade statement. + With the random module you can use random.randint(X, Y) which will give a random number between + X and Y (including possibly X and Y). +* EXTREME challenge - make your own image file - even a stick figure! And use that instead. paint.net + is a good free option. aseprite.org is a low cost option and esotericsoftware.com has Spine for a + more expensive option. MANY others exist. Don't spend too much time on this though :) Just enough to + get your creative juices flowing! Maybe just use a picture of your face! +* Explore the documentation for the :class:`arcade.Texture` class +* Explore the documentation for the :class:`arcade.Sprite` class + +Once you are finished with any or all challenges - make sure your code matches this so that future +tutorial steps still work! Feel free to comment out sections of custom code you want to keep playing +with later. Source Code ~~~~~~~~~~~ @@ -79,24 +135,3 @@ Source Code :caption: 02_draw_sprites - Draw and Position Sprites :linenos: :emphasize-lines: 24-30, 44-45 - -Running this code should result in a character being drawn on the screen, like in -the image at the start of the chapter. - -* Documentation for the :class:`arcade.Texture` class -* Documentation for the :class:`arcade.Sprite` class - -.. note:: - - Once you have the code up and working, try adjusting the code for the following: - - * Adjust the code and try putting the sprite in new positions(Try setting the positions using other attributes of Sprite) - * Use different images for the texture (see :ref:`resources` for the built-in images, or try using your own images.) - * Practice placing more sprites in different ways(like placing many with a loop) - -Run This Chapter -~~~~~~~~~~~~~~~~ - -.. code-block:: - - python -m arcade.examples.platform_tutorial.02_draw_sprites From 0b0200f9b370eaaa1a14e770d5eaf15d39b49009 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 20 May 2025 17:09:11 +0200 Subject: [PATCH 175/279] update Scene to return SpriteList (#2689) --- arcade/scene.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/arcade/scene.py b/arcade/scene.py index 0d9a3fb060..101f54a779 100644 --- a/arcade/scene.py +++ b/arcade/scene.py @@ -11,6 +11,7 @@ """ from collections.abc import Iterable +from typing import TypeVar from warnings import warn from arcade import Sprite, SpriteList @@ -20,6 +21,8 @@ __all__ = ["Scene", "SceneKeyError"] +_S = TypeVar("_S", bound=Sprite) + class SceneKeyError(KeyError): """ @@ -151,7 +154,7 @@ def __getitem__(self, key: str) -> SpriteList: raise SceneKeyError(key) - def add_sprite(self, name: str, sprite: Sprite) -> None: + def add_sprite(self, name: str, sprite: _S) -> _S: """ Add a Sprite to the SpriteList with the specified name. @@ -177,12 +180,14 @@ def add_sprite(self, name: str, sprite: Sprite) -> None: new_list.append(sprite) self.add_sprite_list(name=name, sprite_list=new_list) + return sprite + def add_sprite_list( self, name: str, use_spatial_hash: bool = False, sprite_list: SpriteList | None = None, - ) -> None: + ) -> SpriteList: """ Add a SpriteList to the scene with the specified name. @@ -207,6 +212,7 @@ def add_sprite_list( ) self._name_mapping[name] = sprite_list self._sprite_lists.append(sprite_list) + return sprite_list def add_sprite_list_before( self, @@ -214,7 +220,7 @@ def add_sprite_list_before( before: str, use_spatial_hash: bool = False, sprite_list: SpriteList | None = None, - ) -> None: + ) -> SpriteList: """ Add a sprite list to the scene with the specified name before another SpriteList. @@ -244,6 +250,7 @@ def add_sprite_list_before( before_list = self._name_mapping[before] index = self._sprite_lists.index(before_list) self._sprite_lists.insert(index, sprite_list) + return sprite_list def move_sprite_list_before( self, @@ -279,7 +286,7 @@ def add_sprite_list_after( after: str, use_spatial_hash: bool = False, sprite_list: SpriteList | None = None, - ) -> None: + ) -> SpriteList: """ Add a SpriteList to the scene with the specified name after a specific SpriteList. @@ -309,6 +316,7 @@ def add_sprite_list_after( after_list = self._name_mapping[after] index = self._sprite_lists.index(after_list) + 1 self._sprite_lists.insert(index, sprite_list) + return sprite_list def move_sprite_list_after( self, @@ -338,19 +346,21 @@ def move_sprite_list_after( old_index = self._sprite_lists.index(name_list) self._sprite_lists.insert(new_index, self._sprite_lists.pop(old_index)) - def remove_sprite_list_by_index(self, index: int) -> None: + def remove_sprite_list_by_index(self, index: int) -> SpriteList: """ Remove a layer from the scene by its index in the draw order. Args: index: The index of the sprite list to remove. """ - self.remove_sprite_list_by_object(self._sprite_lists[index]) + sprite_list = self._sprite_lists[index] + self.remove_sprite_list_by_object(sprite_list) + return sprite_list def remove_sprite_list_by_name( self, name: str, - ) -> None: + ) -> SpriteList: """ Remove a layer from the scene by its name. @@ -363,6 +373,7 @@ def remove_sprite_list_by_name( sprite_list = self._name_mapping[name] self._sprite_lists.remove(sprite_list) del self._name_mapping[name] + return sprite_list def remove_sprite_list_by_object(self, sprite_list: SpriteList) -> None: """ From 1c4d3d5382624047c320d0af3e0333864acee197 Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Tue, 20 May 2025 12:00:44 -0400 Subject: [PATCH 176/279] Add method for syncing a Sprite's hitbox to it's pymunk Shape (#2691) * Add method for updating a sprite's hitbox in pymunk * Formatting * formatting --- arcade/pymunk_physics_engine.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/arcade/pymunk_physics_engine.py b/arcade/pymunk_physics_engine.py index 7e966d5724..04e0e721d4 100644 --- a/arcade/pymunk_physics_engine.py +++ b/arcade/pymunk_physics_engine.py @@ -600,6 +600,32 @@ def _f4(arbiter, space, data): if separate_handler: h.separate = _f4 + def update_sprite(self, sprite: Sprite) -> None: + """ + Updates a Sprite's Shape to match it's current hitbox + + Args: + sprite: The Sprite to update + """ + physics_object = self.sprites[sprite] + old_shape = physics_object.shape + assert old_shape is not None, """ + Tried to update the shape for a Sprite which does not currently have a shape + """ + + # Set the physics shape to the sprite's hitbox + poly = sprite.hit_box.points + scaled_poly = [[x * sprite.scale_x for x in z] for z in poly] + shape = pymunk.Poly(physics_object.body, scaled_poly, radius=old_shape.radius) # type: ignore + + shape.collision_type = old_shape.collision_type + shape.elasticity = old_shape.elasticity + shape.friction = old_shape.friction + + self.space.remove(old_shape) + self.space.add(shape) + physics_object.shape = shape + def resync_sprites(self) -> None: """ Set visual sprites to be the same location as physics engine sprites. From ef467983d981eef3f4e7f2a2a57ffe0669a665ac Mon Sep 17 00:00:00 2001 From: jeramyrd <117464784+jeramyrd@users.noreply.github.com> Date: Tue, 20 May 2025 12:43:53 -0400 Subject: [PATCH 177/279] done with step_03.rst (#2693) Co-authored-by: Dickerson --- .../platform_tutorial/03_more_sprites.py | 5 +- doc/conf.py | 1 + doc/tutorials/platform_tutorial/step_02.rst | 2 +- doc/tutorials/platform_tutorial/step_03.rst | 98 +++++++++++++------ pyproject.toml | 1 + 5 files changed, 73 insertions(+), 34 deletions(-) diff --git a/arcade/examples/platform_tutorial/03_more_sprites.py b/arcade/examples/platform_tutorial/03_more_sprites.py index 053e12cc1a..5541bc34af 100644 --- a/arcade/examples/platform_tutorial/03_more_sprites.py +++ b/arcade/examples/platform_tutorial/03_more_sprites.py @@ -49,7 +49,7 @@ def __init__(self): # Create the ground # This shows using a loop to place multiple sprites horizontally for x in range(0, 1250, 64): - wall = arcade.Sprite(":resources:images/tiles/grassMid.png", scale=0.5) + wall = arcade.Sprite(":resources:images/tiles/grassMid.png", scale=TILE_SCALING) wall.center_x = x wall.center_y = 32 self.wall_list.append(wall) @@ -61,8 +61,7 @@ def __init__(self): for coordinate in coordinate_list: # Add a crate on the ground wall = arcade.Sprite( - ":resources:images/tiles/boxCrate_double.png", scale=0.5 - ) + ":resources:images/tiles/boxCrate_double.png", scale=TILE_SCALING) wall.position = coordinate self.wall_list.append(wall) diff --git a/doc/conf.py b/doc/conf.py index 29ef09dbc3..c3b187d76e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -196,6 +196,7 @@ def run_util(filename, run_name="__main__", init_globals=None): 'sphinx.ext.viewcode', # display code with line numbers and line highlighting 'sphinx_copybutton', # Adds a copy button to code blocks 'sphinx_sitemap', # sitemap.xml generation + 'sphinx_togglebutton', #A way to toggle sections of text/code on or off. 'doc.extensions.prettyspecialmethods', # Forker plugin for prettifying special methods ] diff --git a/doc/tutorials/platform_tutorial/step_02.rst b/doc/tutorials/platform_tutorial/step_02.rst index ccd15f978d..c346817320 100644 --- a/doc/tutorials/platform_tutorial/step_02.rst +++ b/doc/tutorials/platform_tutorial/step_02.rst @@ -134,4 +134,4 @@ Source Code .. literalinclude:: ../../../arcade/examples/platform_tutorial/02_draw_sprites.py :caption: 02_draw_sprites - Draw and Position Sprites :linenos: - :emphasize-lines: 24-30, 44-45 + :emphasize-lines: 24-32, 46-47 diff --git a/doc/tutorials/platform_tutorial/step_03.rst b/doc/tutorials/platform_tutorial/step_03.rst index 167d8a8a08..6ab576f954 100644 --- a/doc/tutorials/platform_tutorial/step_03.rst +++ b/doc/tutorials/platform_tutorial/step_03.rst @@ -15,16 +15,21 @@ At the end, we'll have something like this: SpriteList ~~~~~~~~~~ -:class:`arcade.SpriteList` exists to draw a collection of Sprites all at once. Let's say for example that you have -100,000 box Sprites that you want to draw. Without SpriteList you would have to put all of your sprites into a list, -and then run a for loop over that which calls ``draw()`` on every sprite. - -This approach is extremely un-performant. Instead, you can add all of your boxes to a :class:`arcade.SpriteList` +Imagine how you (as a human) would draw this picture. Maybe you would draw the character first. Then +the first box, then the second box, etc... Now a long strip of green and brown stuff we will call grass. +So likely your first thought to programming would be to make a loop over a list of sprites and call ``draw()`` +on every sprite, one at a time. Computers are fast right? Yes, but this approach is extremely inefficient. +See the note below on how Arcade uses GPUs. What if we could draw EVERY object at the same time! Computers +can surpass the frail limitiation of you mortal humans! + +But to do that, the computer does need your help (and the computer humbly apologizes for the insult!). +:class:`arcade.SpriteList` exists to draw a collection of Sprites all at once. Let's say for example that +you have 100,000 box Sprites that you want to draw. You can add all of your boxes to a :class:`arcade.SpriteList` and then draw the SpriteList. Doing this, you are able to draw all 100,000 sprites for approximately the exact -same cost as drawing one sprite. +same cost as drawing one sprite. Which is pretty amazing. .. note:: - This is due to Arcade being a heavily GPU based library. GPUs are really good at doing things in batches. + Arcade is a heavily GPU based library. GPUs are really good at doing things in batches. This means we can send all the information about our sprites to the GPU, and then tell it to draw them all at once. However if we just draw one sprite at a time, then we have to go on a round trip from our CPU to our GPU every time. @@ -33,34 +38,57 @@ Even if you are only drawing one Sprite, you should still create a SpriteList fo it is never better to draw an individual Sprite than it is to add it to a SpriteList. In fact, calling ``draw()`` on a Sprite just creates a SpriteList internally to draw that Sprite with. -Let's go ahead and create one for our player inside our ``__init__`` function, and add the player to it. +Quiz - where in our code should we place the data object of our :class:`arcade.SpriteList`? + +.. toggle:: + + If you guessed the ``__init__`` function you are correct! + +So that is where we are going to put the following code. Just add it to the bottom of the list of other data objects. .. code-block:: self.player_list = arcade.SpriteList() self.player_list.append(self.player_sprite) -Then in our ``on_draw`` function, we can draw the SpriteList for the character instead of drawing the Sprite directly: +Then in our ``on_draw`` function, we can draw the SpriteList for the character instead of drawing the Sprite directly. +So we will replace this line of code: + +.. code-block:: + + arcade.draw_sprite(self.player_sprite) + +with this line of code: .. code-block:: self.player_list.draw() +Now run it and you get your character on the screen. All by herself... alone in an empty world... sad face. + +Time to make the world! +~~~~~~~~~~~~~~~~~~~~~~~ + Now let's try and build a world for our character. To do this, we'll create a new SpriteList for the objects we'll draw, -we can do this in our ``__init__`` function. +we can do this again in our ``__init__`` function. .. code-block:: self.wall_list = arcade.SpriteList(use_spatial_hash=True) -There's a little bit to unpack in this snippet of code. Let's address each issue: +Wait, why did we make a new list of Sprites? I thought the idea was to make one master list of everything we needed to draw? +What is a spatial_hash and why is it True? + +Great questions! 1. Why not just use the same SpriteList we used for our player, and why is it named walls? Eventually we will want to do collision detection between our character and these objects. In addition to drawing, SpriteLists also serve as a utility for collision detection. You can for example check for collisions between two SpriteLists, or pass SpriteLists into several physics - engines. We will explore these topics in later chapters. + engines. We will explore these topics in later chapters. And you will notice that we are going to + pass in pictures of grass and boxes - which we will treat as barriers. So they will be added to + the 'walls' 2. What is ``use_spatial_hash``? @@ -74,7 +102,7 @@ With our newly created SpriteList, let's go ahead and add some objects to it. We .. code-block:: for x in range(0, 1250, 64): - wall = arcade.Sprite(":resources:images/tiles/grassMid.png", TILE_SCALING) + wall = arcade.Sprite(":resources:images/tiles/grassMid.png", scale=TILE_SCALING) wall.center_x = x wall.center_y = 32 self.wall_list.append(wall) @@ -82,8 +110,7 @@ With our newly created SpriteList, let's go ahead and add some objects to it. We coordinate_list = [[512, 96], [256, 96], [768, 96]] for coordinate in coordinate_list: wall = arcade.Sprite( - ":resources:images/tiles/boxCrate_double.png", scale=0.5 - ) + ":resources:images/tiles/boxCrate_double.png", scale=TILE_SCALING) wall.position = coordinate self.wall_list.append(wall) @@ -103,26 +130,37 @@ Finally all we need to do in order to draw our new world, is draw the SpriteList self.wall_list.draw() -Source Code -~~~~~~~~~~~ +Run the code and... wait the image is instantly disappearing! What did we do wrong? Try and debug it! -.. literalinclude:: ../../../arcade/examples/platform_tutorial/03_more_sprites.py - :caption: 03_more_sprites - Many Sprites with a SpriteList - :linenos: - :emphasize-lines: 35-65, 80-81 +.. toggle:: -* Documentation for the :class:`arcade.SpriteList` class + You might have noticed that we use TILE_SCALING but we never defined it! So the image starts creating, but crashes + before we get to even see what is wrong! This is a hard bug to find as there is no error message. -.. note:: + Solution: add this up by the '# constants' section + TILE_SCALING = 0.5 - Once you have the code up and working, try-out the following: - * See if you can change the colors of all the boxes and ground using the SpriteList - * Try and make a SpriteList invisible +Challenge +~~~~~~~~~ +* Try changing the new variable TILE_SCALING, what does it do? +* Make some floating boxes. Do it either randomly or fixed. +* Read the documentation for the :class:`arcade.SpriteList` class +* See if you can change the colors of all the boxes and ground using the SpriteList +* Try and make a SpriteList invisible +* EXTREME Challeng Our __init__ class is getting pretty bloated. The loops we created to make the walls would be better suited + to have their own function that we call. See if you can make a function 'def build_walls(self):' that has that same + code. Then call that function with build_walls() after you define the self.wall_list. -Run This Chapter -~~~~~~~~~~~~~~~~ +Your code should produce the same image as shown on the top of this page. Before proceeding, make sure you comment +out custom code so we are on the same page. Also, if you are still having problems, compare with this code: -.. code-block:: +Source Code +~~~~~~~~~~~ + +.. literalinclude:: ../../../arcade/examples/platform_tutorial/03_more_sprites.py + :caption: 03_more_sprites - Many Sprites with a SpriteList + :linenos: + :emphasize-lines: 13-14, 37-39, 41-47, 49-66, 80-82 - python -m arcade.examples.platform_tutorial.03_more_sprites +* Documentation for the :class:`arcade.SpriteList` class diff --git a/pyproject.toml b/pyproject.toml index ffd028c5d3..e6f66f707a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dev = [ "sphinx-autobuild==2024.10.3", # April 2024 | Due to this, Python 3.10+ is required to serve docs "sphinx-copybutton==0.5.2", # April 2023 "sphinx-sitemap==2.6.0", # April 2024 + "sphinx-togglebutton==0.3.2", # May 2025 "pygments==2.19.1", # 2.18 has breaking changes in lexer "docutils==0.21.2", # ? # "pyyaml==6.0.1", From b3c0ee81f429ffe433b69e8720dc3fb77ce0e85e Mon Sep 17 00:00:00 2001 From: Nayar Joolfoo Date: Tue, 20 May 2025 21:18:31 +0400 Subject: [PATCH 178/279] Update emphasize lines to match code (#2692) --- doc/tutorials/platform_tutorial/step_07.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/tutorials/platform_tutorial/step_07.rst b/doc/tutorials/platform_tutorial/step_07.rst index 3fca0fdd49..1d5b902ab5 100644 --- a/doc/tutorials/platform_tutorial/step_07.rst +++ b/doc/tutorials/platform_tutorial/step_07.rst @@ -55,7 +55,7 @@ Source Code .. literalinclude:: ../../../arcade/examples/platform_tutorial/07_camera.py :caption: Adding a Camera :linenos: - :emphasize-lines: 49-50, 96-97, 107-108, 120-121 + :emphasize-lines: 49-50, 98-99, 109-110, 122-123 Run This Chapter ~~~~~~~~~~~~~~~~ From 0c357c383c0617ae53b28b896e12aa4c3a7b7a2c Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Tue, 20 May 2025 13:27:57 -0400 Subject: [PATCH 179/279] fix the format exclusions and format --- doc/conf.py | 215 +++++++------- pyproject.toml | 10 +- tests/__init__.py | 1 + tests/conftest.py | 20 +- tests/doc/check_examples_2.py | 8 +- tests/doc/check_samples.py | 5 +- tests/integration/examples/test_examples.py | 29 +- tests/integration/tutorials/test_tutorials.py | 3 +- .../sprite_collision_inspector.py | 67 ++--- tests/unit/atlas/conftest.py | 3 +- tests/unit/atlas/test_basics.py | 6 +- tests/unit/atlas/test_gc.py | 58 +++- tests/unit/atlas/test_rebuild_resize.py | 24 +- tests/unit/atlas/test_region.py | 15 +- .../test_update_texture_image_from_atlas.py | 1 + tests/unit/camera/test_camera2d.py | 6 +- .../camera/test_camera_controller_methods.py | 8 +- tests/unit/camera/test_camera_shake.py | 52 +++- .../camera/test_orthographic_projector.py | 54 ++-- .../unit/camera/test_perspective_projector.py | 35 +-- tests/unit/camera/test_viewport_projector.py | 7 +- tests/unit/color/test_color_type.py | 7 +- tests/unit/color/test_module_csscolor.py | 2 +- tests/unit/draw/test_drawing_primitives.py | 59 +--- tests/unit/draw/test_rect.py | 2 +- tests/unit/geometry/test_is_point_in_box.py | 8 +- tests/unit/gl/backends/gl/test_gl_program.py | 75 ++--- tests/unit/gl/test_gl_buffer.py | 44 +-- tests/unit/gl/test_gl_buffer_description.py | 4 +- tests/unit/gl/test_gl_context.py | 18 +- tests/unit/gl/test_gl_framebuffer.py | 27 +- tests/unit/gl/test_gl_gc.py | 4 +- tests/unit/gl/test_gl_geometry.py | 1 + tests/unit/gl/test_gl_texture.py | 60 ++-- tests/unit/gl/test_gl_texture_array.py | 2 +- tests/unit/gl/test_gl_types.py | 26 +- tests/unit/gl/test_gl_vertex_array.py | 60 ++-- tests/unit/gui/test_exp_restricted_input.py | 1 - tests/unit/gui/test_focus.py | 3 + tests/unit/gui/test_ninepatch_draw.py | 18 +- tests/unit/hitbox/test_black_image.py | 1 + tests/unit/hitbox/test_hitbox.py | 2 +- tests/unit/hitbox/test_hitbox_algo_legacy.py | 4 +- tests/unit/paths/test_astar.py | 75 +++-- tests/unit/paths/test_line_of_sight.py | 8 +- .../physics_engine/test_physics_engine2.py | 19 +- .../test_physics_engine_platformer.py | 10 +- tests/unit/physics_engine/test_pymunk.py | 31 +- tests/unit/rect/test_rect_creation_helpers.py | 3 +- tests/unit/rect/test_rect_instances.py | 28 +- tests/unit/resources/test_handles.py | 1 - tests/unit/scene/test_scene_dunder_methods.py | 2 +- .../scene/test_scene_remove_sprite_lists.py | 3 + .../unit/shape_list/test_buffered_drawing.py | 32 +- .../shape_list/test_buffered_line_strip.py | 5 +- tests/unit/sprite/test_copy_dunders.py | 4 +- tests/unit/sprite/test_sprite.py | 74 ++--- .../sprite/test_sprite_animated_walking.py | 25 +- tests/unit/sprite/test_sprite_collision.py | 4 +- tests/unit/sprite/test_sprite_colored.py | 1 + tests/unit/sprite/test_sprite_hitbox.py | 4 +- tests/unit/spritelist/test_spatial_hash.py | 13 +- tests/unit/spritelist/test_spritelist.py | 16 +- .../spritelist/test_spritelist_buffers.py | 13 +- tests/unit/spritelist/test_spritelist_draw.py | 2 + tests/unit/spritelist/test_spritelist_lazy.py | 6 +- tests/unit/spritelist/test_spritesequence.py | 6 +- tests/unit/test_arcade.py | 9 +- tests/unit/test_clock.py | 15 +- tests/unit/test_example_docstrings.py | 2 - tests/unit/test_isometric.py | 19 +- tests/unit/test_key.py | 7 +- tests/unit/test_screenshot.py | 4 +- tests/unit/test_shadertoy.py | 31 +- tests/unit/test_utils.py | 6 +- tests/unit/test_version.py | 58 ++-- tests/unit/text/test_text.py | 168 ++++++++--- tests/unit/text/test_text_error_handling.py | 20 +- .../text/test_text_instance_properties.py | 14 +- tests/unit/text/test_text_sprite.py | 4 +- tests/unit/texture/test_manager.py | 9 +- tests/unit/texture/test_sprite_sheet.py | 37 ++- tests/unit/texture/test_texture.py | 15 +- tests/unit/texture/test_texture_tools.py | 2 +- .../texture/test_texture_transform_render.py | 9 +- .../texture/test_texture_transform_values.py | 7 +- tests/unit/texture/test_textures.py | 4 +- tests/unit/tilemap/test_img_layer.py | 6 +- tests/unit/tilemap/test_rotation_flip.py | 57 ++-- tests/unit/tilemap/test_tilemap_objects.py | 7 +- tests/unit/window/test_view.py | 3 +- tests/unit/window/test_window.py | 5 +- util/create_resources_listing.py | 1 + util/doc_helpers/__init__.py | 56 ++-- util/doc_helpers/real_filesystem.py | 18 +- util/doc_helpers/vfs.py | 3 +- util/generate_example_thumbnails.py | 12 +- util/generate_hit_box_cache.py | 3 +- util/sphinx_static_file_temp_fix.py | 29 +- util/sync_example_code_with_rst.py | 3 +- util/update_quick_index.py | 279 ++++++------------ 101 files changed, 1200 insertions(+), 1162 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index c3b187d76e..21558432d2 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,5 +1,6 @@ #!/usr/bin/env python """Sphinx configuration file""" + import os from functools import cache import logging @@ -17,7 +18,7 @@ ARCADE_MODULE = REPO_LOCAL_ROOT / "arcade" UTIL_DIR = REPO_LOCAL_ROOT / "util" -log = logging.getLogger('conf.py') +log = logging.getLogger("conf.py") logging.basicConfig(level=logging.WARNING) # logging.basicConfig(level=logging.INFO) @@ -37,8 +38,8 @@ READTHEDOCS = dict() ENV = dict() for k, v in os.environ.items(): - if k.startswith('READTHEDOCS_'): - READTHEDOCS[k.removeprefix('READTHEDOCS_')] = v + if k.startswith("READTHEDOCS_"): + READTHEDOCS[k.removeprefix("READTHEDOCS_")] = v ENV[k] = v from util.doc_helpers.real_filesystem import copy_media @@ -68,14 +69,15 @@ # Don't change to # from arcade.version import VERSION # or read the docs build will fail. -from version import VERSION # pyright: ignore [reportMissingImports] +from version import VERSION # pyright: ignore [reportMissingImports] + log.info(f" Got version {VERSION=!r}") print() -GIT_REF = 'development' +GIT_REF = "development" if READTHEDOCS: - if READTHEDOCS.get('VERSION') in ('latest', 'stable'): + if READTHEDOCS.get("VERSION") in ("latest", "stable"): log.info(" !!!!! APPEARS TO BE A REAL RELEASE !!!!!") else: log.info(" +++++ Building a PR or development +++++") @@ -85,8 +87,8 @@ # We'll pass this to our generation scripts to initialize their globals -REPO_URL_BASE="https://github.com/pythonarcade/arcade" -FMT_URL_REF_BASE=f"{REPO_URL_BASE}/blob/{GIT_REF}" +REPO_URL_BASE = "https://github.com/pythonarcade/arcade" +FMT_URL_REF_BASE = f"{REPO_URL_BASE}/blob/{GIT_REF}" RESOURCE_GLOBALS = dict( GIT_REF=GIT_REF, # pending: post-3.0 clean-up, not sure if things use it now? @@ -96,11 +98,11 @@ # This double-bracket escapes brackets in f-strings FMT_URL_REF_PAGE=f"{FMT_URL_REF_BASE}/{{}}", FMT_URL_REF_EMBED=f"{FMT_URL_REF_BASE}/{{}}?raw=true", - RTD_EVIL=READTHEDOCS['CANONICAL_URL'] if READTHEDOCS else "" # pending: post-3.0 cleanup + RTD_EVIL=READTHEDOCS["CANONICAL_URL"] if READTHEDOCS else "", # pending: post-3.0 cleanup ) -def run_util(filename, run_name="__main__", init_globals=None): +def run_util(filename, run_name="__main__", init_globals=None): full_absolute_path = UTIL_DIR / filename full_str = str(full_absolute_path) @@ -108,7 +110,7 @@ def run_util(filename, run_name="__main__", init_globals=None): log.info(f" run_name={run_name!r}") kwargs = dict(run_name=run_name) if init_globals is not None: - kwargs['init_globals'] = init_globals + kwargs["init_globals"] = init_globals log.info(f" init_globals={{") num_left = len(init_globals) for k, v in init_globals.items(): @@ -119,6 +121,7 @@ def run_util(filename, run_name="__main__", init_globals=None): runpy.run_path(full_str, **kwargs) + # Temp fix for Sphinx not copying static files # pending: post-3.0 refactor # Enable by creating a .ENABLE_DEVMACHINE_SPHINX_STATIC_FIX run_util("sphinx_static_file_temp_fix.py") @@ -128,56 +131,47 @@ def run_util(filename, run_name="__main__", init_globals=None): # Create a tabular representation of the resources with embeds run_util("create_resources_listing.py", init_globals=RESOURCE_GLOBALS) # Run the generate quick API index script -run_util('../util/update_quick_index.py') +run_util("../util/update_quick_index.py") -OUT_STATIC = REPO_LOCAL_ROOT / 'build/html/_static/' +OUT_STATIC = REPO_LOCAL_ROOT / "build/html/_static/" -src_res_dir = ARCADE_MODULE / 'resources/assets' -out_res_dir = REPO_LOCAL_ROOT / 'build/html/_static/assets' +src_res_dir = ARCADE_MODULE / "resources/assets" +out_res_dir = REPO_LOCAL_ROOT / "build/html/_static/assets" # pending: post-3.0 cleanup to find the right source events to make this work? # if exc or app.builder.format != "html": # return # static_dir = (app.outdir / '_static').resolve() copy_what = { # pending: post-3.0 cleanup to tie this into resource generation correctly - 'sounds': ('*.wav', '*.ogg', '*.mp3'), - 'music': ('*.wav', '*.ogg', '*.mp3'), - 'video': ('*.mp4', '*.webm', ) + "sounds": ("*.wav", "*.ogg", "*.mp3"), + "music": ("*.wav", "*.ogg", "*.mp3"), + "video": ( + "*.mp4", + "*.webm", + ), } copy_media(src_res_dir, out_res_dir, copy_what) # We are no longer asking. We are copying. -copy_media( - REPO_LOCAL_ROOT / "doc/_static/icons", - OUT_STATIC / "icons" , - { - 'tabler': ("*.svg",) - } -) -copy_media( - REPO_LOCAL_ROOT / "doc/_static/", - OUT_STATIC , - { - 'filetiles': ("*.png",) - } -) -#copy_media( +copy_media(REPO_LOCAL_ROOT / "doc/_static/icons", OUT_STATIC / "icons", {"tabler": ("*.svg",)}) +copy_media(REPO_LOCAL_ROOT / "doc/_static/", OUT_STATIC, {"filetiles": ("*.png",)}) +# copy_media( # REP / "" -#) +# ) autodoc_inherit_docstrings = False autodoc_default_options = { - 'members': True, + "members": True, # 'member-order': 'groupwise', - 'member-order': 'alphabetical', - 'undoc-members': True, - 'show-inheritance': True + "member-order": "alphabetical", + "undoc-members": True, + "show-inheritance": True, } -toc_object_entries_show_parents = 'hide' +toc_object_entries_show_parents = "hide" # Special methods in api docs gets a special prefix emoji -prettyspecialmethods_signature_prefix = '🧙' +prettyspecialmethods_signature_prefix = "🧙" RELEASE = VERSION @@ -187,17 +181,17 @@ def run_util(filename, run_name="__main__", init_globals=None): # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx_rtd_theme', # Read the Docs theme - 'sphinx_rtd_dark_mode', # Dark mode for the RTD theme - 'sphinx.ext.autodoc', # API doc generation tools - 'sphinx.ext.napoleon', # Support for NumPy and Google style docstrings - 'sphinx.ext.imgconverter', # Converts .gif for PDF doc build - 'sphinx.ext.intersphinx', # Link to other projects' docs - 'sphinx.ext.viewcode', # display code with line numbers and line highlighting - 'sphinx_copybutton', # Adds a copy button to code blocks - 'sphinx_sitemap', # sitemap.xml generation - 'sphinx_togglebutton', #A way to toggle sections of text/code on or off. - 'doc.extensions.prettyspecialmethods', # Forker plugin for prettifying special methods + "sphinx_rtd_theme", # Read the Docs theme + "sphinx_rtd_dark_mode", # Dark mode for the RTD theme + "sphinx.ext.autodoc", # API doc generation tools + "sphinx.ext.napoleon", # Support for NumPy and Google style docstrings + "sphinx.ext.imgconverter", # Converts .gif for PDF doc build + "sphinx.ext.intersphinx", # Link to other projects' docs + "sphinx.ext.viewcode", # display code with line numbers and line highlighting + "sphinx_copybutton", # Adds a copy button to code blocks + "sphinx_sitemap", # sitemap.xml generation + "sphinx_togglebutton", # A way to toggle sections of text/code on or off. + "doc.extensions.prettyspecialmethods", # Forker plugin for prettifying special methods ] # pending: post-3.0 cleanup: @@ -207,19 +201,19 @@ def run_util(filename, run_name="__main__", init_globals=None): # copybutton_image_svg = (REPO_LOCAL_ROOT / "doc/_static/icons/tabler/copy.svg").read_text() # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'Python Arcade Library' -copyright = '2025, Paul Vincent Craven' -author = 'Paul Vincent Craven' +project = "Python Arcade Library" +copyright = "2025, Paul Vincent Craven" +author = "Paul Vincent Craven" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -246,7 +240,7 @@ def run_util(filename, run_name="__main__", init_globals=None): ] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'default' # will use "sphinx" or the theme's default +pygments_style = "default" # will use "sphinx" or the theme's default # If true, `todo` and `todoList` produce output, else they produce nothing. # todo_include_todos = True @@ -262,81 +256,79 @@ def run_util(filename, run_name="__main__", init_globals=None): # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # See sphinx-rtd-theme docs for details on each option: # https://sphinx-rtd-theme.readthedocs.io/en/stable/configuring.html html_theme_options = { - 'logo_only': False, - 'sticky_navigation': True, - 'navigation_depth': 3, - 'collapse_navigation': False, + "logo_only": False, + "sticky_navigation": True, + "navigation_depth": 3, + "collapse_navigation": False, } # The single config option provided by sphinx-rtd-dark-mode # https://github.com/MrDogeBro/sphinx_rtd_dark_mode#config default_dark_mode = True -html_title = f'Python Arcade {version}' +html_title = f"Python Arcade {version}" html_js_files = [ - 'https://code.jquery.com/jquery-3.6.3.min.js', - 'https://cdn.datatables.net/1.13.2/js/jquery.dataTables.min.js', + "https://code.jquery.com/jquery-3.6.3.min.js", + "https://cdn.datatables.net/1.13.2/js/jquery.dataTables.min.js", ] html_css_files = [ - 'https://cdn.datatables.net/1.13.2/css/jquery.dataTables.min.css', + "https://cdn.datatables.net/1.13.2/css/jquery.dataTables.min.css", ] # The name of an image file (relative to this directory) to place at the top # of the sidebar. -html_logo = '_static/android-chrome-192x192.png' +html_logo = "_static/android-chrome-192x192.png" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -html_favicon = '_static/favicon.ico' +html_favicon = "_static/favicon.ico" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -html_extra_path = ['html_extra'] +html_extra_path = ["html_extra"] # Output file base name for HTML help builder. -htmlhelp_basename = 'Arcade' -html_baseurl = 'https://api.arcade.academy/' +htmlhelp_basename = "Arcade" +html_baseurl = "https://api.arcade.academy/" # Fix line numbers on code listings until the RTD theme updates to sphinx 4+ # html_codeblock_linenos_style = 'table' # Configuration for intersphinx enabling linking other projects intersphinx_mapping = { - 'python': ('https://docs.python.org/3', None), + "python": ("https://docs.python.org/3", None), # As of January 25th, pyglet's 2.1.X branch is on this URL and their # development build on readthedocs is for their in-progress 3.0.0 alpha. - 'pyglet': ('https://pyglet.readthedocs.io/en/latest/', None), - 'PIL': ('https://pillow.readthedocs.io/en/stable', None), - 'pymunk': ('https://www.pymunk.org/en/latest/', None), + "pyglet": ("https://pyglet.readthedocs.io/en/latest/", None), + "PIL": ("https://pillow.readthedocs.io/en/stable", None), + "pymunk": ("https://www.pymunk.org/en/latest/", None), } # These will be joined as one block and prepended to every source file. # Substitutions for |version| and |release| are predefined by Sphinx. PROLOG_PARTS = [ - #".. include:: /links.rst", + # ".. include:: /links.rst", ".. |pyglet Player| replace:: pyglet :py:class:`~pyglet.media.player.Player`", ".. _Arcade's License File on GitHub: {FMT_URL_REF_BASE}/license.rst", - ( # Allows explaining how to copy anywhere in the doc. - '.. |Example Copy Button| raw:: html\n\n' + ".. |Example Copy Button| raw:: html\n\n" '
    \n' ' \n\n' - '
    \n\n' - ) - + " \n\n" + ), ] with open("_includes/links.rst") as f: PROLOG_PARTS.extend(f.readlines()) @@ -391,10 +383,12 @@ def inspect_docstring_for_member( if what == "class": doc = _obj.__init__.__doc__ if doc and isinstance(doc, str) and not doc.startswith("Initialize self"): - raise ValueError(( - f"Class {name} has a docstring on __init__. " - "The class docstring should cover docs for the initializer:\n {_obj.__init__.__doc__}" - )) + raise ValueError( + ( + f"Class {name} has a docstring on __init__. " + "The class docstring should cover docs for the initializer:\n {_obj.__init__.__doc__}" + ) + ) def generate_color_table(filename, source): @@ -411,7 +405,9 @@ def generate_color_table(filename, source): # green '(?P\d*)' followed by # blue '(?P\d*)' followed by # alpha '(?P\d*)' - color_match = re.compile(r'(?P[a-z_A-Z]*)(?:[ =]*Color[ (]*)(?P\d*)[ ,]*(?P\d*)[ ,]*(?P\d*)[ ,]*(?P\d*)') + color_match = re.compile( + r"(?P[a-z_A-Z]*)(?:[ =]*Color[ (]*)(?P\d*)[ ,]*(?P\d*)[ ,]*(?P\d*)[ ,]*(?P\d*)" + ) with open(filename) as color_file: for line in color_file: @@ -421,7 +417,7 @@ def generate_color_table(filename, source): continue name, r, g, b, a = matches.groupdict().values() - color_rgb_comma_sep= f"{r}, {g}, {b}" + color_rgb_comma_sep = f"{r}, {g}, {b}" # Generate the alpha for CSS color function rgba_css = f"rgba({color_rgb_comma_sep}, {int(a) / 255!s:.4})" @@ -429,12 +425,14 @@ def generate_color_table(filename, source): append_text += " " append_text += ( f"" - f"" - f"{name}" + f'' + f'{name}' f"" f"" ) - append_text += f"
     
    " + append_text += ( + f'
     
    ' + ) append_text += f"({color_rgb_comma_sep}, {a})" append_text += "\n" @@ -461,6 +459,7 @@ def source_read_handler(_app, doc_name: str, source): Event handler for source-read event. Where we can modify the source of a document before it is parsed. """ + def _get_dir(app, path): path = get_module_root(_app.confdir) / path print(f"Generated corrected module path: {path!r}") @@ -474,6 +473,7 @@ def _get_dir(app, path): elif doc_name == "api_docs/arcade.uicolor": generate_color_table(_get_dir(_app, "uicolor.py"), source) + def on_autodoc_process_bases(app, name, obj, options, bases): """We don't care about the `object` base class, so remove it from the list of bases.""" bases[:] = [base for base in bases if base is not object] @@ -485,10 +485,10 @@ class A(NamedTuple): APP_CONFIG_DIRS = ( - A('outdir'), - A('srcdir', 'NOTE: This is reST source, not Python source!'), - A('confdir'), - A('doctreedir'), + A("outdir"), + A("srcdir", "NOTE: This is reST source, not Python source!"), + A("confdir"), + A("doctreedir"), ) @@ -497,18 +497,16 @@ class ResourceRole(SphinxRole): # pending: 3.1 This needs improvement. """ + def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: raw = self.text.removeprefix(":resource:") - page_id = self.text\ - .replace(':', '')\ - .replace('/', '-')\ - .replace('_', '-')\ - .replace('.', '-') + page_id = self.text.replace(":", "").replace("/", "-").replace("_", "-").replace(".", "-") filename = f"'{raw.split('/')[-1]}'" - node = nodes.reference(text=filename, refuri=''.join([ - '/api_docs/resources.html#', page_id]), - ) + node = nodes.reference( + text=filename, + refuri="".join(["/api_docs/resources.html#", page_id]), + ) log.info(" Attempted ResourceRole", locals()) return [node], [] @@ -533,15 +531,16 @@ def setup(app): # IMPORTANT: We can't use app.add_autodocumenter! # See the docstring of ClassDocumenter above for why. # sphinx.ext.autodoc.ClassDocumenter = ClassDocumenter - app.connect('source-read', source_read_handler) + app.connect("source-read", source_read_handler) app.connect("autodoc-process-docstring", inspect_docstring_for_member) - app.connect('autodoc-process-signature', strip_init_return_typehint, -1000) - app.connect('autodoc-process-bases', on_autodoc_process_bases) + app.connect("autodoc-process-signature", strip_init_return_typehint, -1000) + app.connect("autodoc-process-bases", on_autodoc_process_bases) # app.add_transform(Transform) - app.add_role('resource', ResourceRole()) + app.add_role("resource", ResourceRole()) # Don't do anything that can fail on this event or it'll kill your build hard # app.connect('build-finished', throws_exception) + # ------------------------------------------------------ # Old hacks that breaks the api docs. !!! DO NOT USE !!! # ------------------------------------------------------ diff --git a/pyproject.toml b/pyproject.toml index e6f66f707a..e67436c235 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ build-backend = "setuptools.build_meta" [tool.ruff] line-length = 100 output-format = "full" -exclude = [ +lint.exclude = [ "venv", ".venv*", "tests", @@ -114,7 +114,7 @@ lint.select = [ [tool.ruff.format] docstring-code-format = false -exclude = ["arcade/examples/*", "benchmarks/*"] +exclude = ["arcade/examples/*", "benchmarks/*", "doc/*"] # This ignores __init__.py files and examples for import sorting [tool.ruff.lint.per-file-ignores] @@ -126,9 +126,7 @@ exclude = ["arcade/examples/*", "benchmarks/*"] [tool.mypy] disable_error_code = "annotation-unchecked" -exclude = [ - "arcade/gl/backends" -] +exclude = ["arcade/gl/backends"] [tool.pytest.ini_options] norecursedirs = [ @@ -143,7 +141,7 @@ norecursedirs = [ ] markers = [ "backendgl: Run OpenGL (or OpenGL ES) backend specific tests", - "backendwebgl: Run WebGL backend specific tests" + "backendwebgl: Run WebGL backend specific tests", ] [tool.pyright] diff --git a/tests/__init__.py b/tests/__init__.py index 40bd75a955..6a85605a56 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -3,4 +3,5 @@ # Headless mode if os.environ.get("ARCADE_HEADLESS_TEST"): import pyglet + pyglet.options.headless = True diff --git a/tests/conftest.py b/tests/conftest.py index ab5f3728e7..4310fb6288 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ from arcade.clock import GLOBAL_CLOCK, GLOBAL_FIXED_CLOCK from arcade import Rect, LBWH from arcade import gl + # from arcade.texture import default_texture_cache # NOTE: Load liberation fonts in unit tests arcade.resources.load_liberation_fonts() @@ -30,10 +31,7 @@ WINDOW = None OFFSCREEN = None -POSSIBLE_BACKENDS = [ - "backendopengl", - "backendwebgl" -] +POSSIBLE_BACKENDS = ["backendopengl", "backendwebgl"] arcade.resources.load_kenney_fonts() @@ -41,17 +39,22 @@ def pytest_addoption(parser): parser.addoption("--gl-backend", default="opengl") + def pytest_configure(config): global GL_BACKEND GL_BACKEND = config.option.gl_backend + def pytest_collection_modifyitems(config, items): desired_backend = "backend" + GL_BACKEND for item in items: for backend in POSSIBLE_BACKENDS: if backend in item.keywords: if backend != desired_backend: - item.add_marker(pytest.mark.skip(f"Skipping GL backend specific test for {backend}")) + item.add_marker( + pytest.mark.skip(f"Skipping GL backend specific test for {backend}") + ) + def make_window_caption(request=None, prefix="Testing", sep=" - ") -> str: """Centralizes test name customization. @@ -72,7 +75,12 @@ def create_window(width=1280, height=720, caption="Testing", **kwargs): global WINDOW if not WINDOW: WINDOW = REAL_WINDOW_CLASS( - width=width, height=height, title=caption, vsync=False, antialiasing=False, gl_api = GL_BACKEND + width=width, + height=height, + title=caption, + vsync=False, + antialiasing=False, + gl_api=GL_BACKEND, ) WINDOW.set_vsync(False) # This value is being monkey-patched into the Window class so that tests can identify if we are using diff --git a/tests/doc/check_examples_2.py b/tests/doc/check_examples_2.py index bb259d9096..b974732ef1 100644 --- a/tests/doc/check_examples_2.py +++ b/tests/doc/check_examples_2.py @@ -4,10 +4,11 @@ def get_references_in_index(): - txt = Path('../../doc/example_code/how_to_examples/index.rst').read_text() + txt = Path("../../doc/example_code/how_to_examples/index.rst").read_text() references_in_index = re.findall(":ref:`(.*)`", txt) return references_in_index + def get_references_in_rsts(): mypath = Path("../../doc/example_code/how_to_examples/") @@ -24,6 +25,7 @@ def get_references_in_rsts(): return references + def main(): references_in_index = get_references_in_index() files_to_reference = get_references_in_rsts() @@ -32,7 +34,9 @@ def main(): if not reference in references_in_index: print(f"index.rst is missing any mention of '{reference}'") - print("Done with checking to make sure references in doc/examples/*.rst are in doc/examples/index.rst") + print( + "Done with checking to make sure references in doc/examples/*.rst are in doc/examples/index.rst" + ) main() diff --git a/tests/doc/check_samples.py b/tests/doc/check_samples.py index a0b23bd577..e84ba7af31 100644 --- a/tests/doc/check_samples.py +++ b/tests/doc/check_samples.py @@ -27,7 +27,7 @@ def main(): # See if there are rst files for all the py files for py_file in python_example_filename_list: - base_name = py_file[:len(py_file) - 3] + base_name = py_file[: len(py_file) - 3] rst_name = base_name + ".rst" if rst_name not in python_rst_filename_list: print("Missing " + rst_name) @@ -35,9 +35,10 @@ def main(): # See if there are py files for all the rst files print() for rst_file in python_rst_filename_list: - base_name = rst_file[:len(rst_file) - 4] + base_name = rst_file[: len(rst_file) - 4] py_name = base_name + ".py" if py_name not in python_example_filename_list: print("Missing " + py_name) + main() diff --git a/tests/integration/examples/test_examples.py b/tests/integration/examples/test_examples.py index 84ddcb7057..f80c900048 100644 --- a/tests/integration/examples/test_examples.py +++ b/tests/integration/examples/test_examples.py @@ -1,6 +1,7 @@ """ Import and run all examples one frame """ + import contextlib import io import inspect @@ -15,24 +16,20 @@ # File path, module path EXAMPLE_LOCATIONS = [ - ( - Path(arcade.__file__).parent / "examples", - "arcade.examples" - ), + (Path(arcade.__file__).parent / "examples", "arcade.examples"), ( Path(arcade.__file__).parent / "examples" / "platform_tutorial", - "arcade.examples.platform_tutorial" - ), - ( - Path(arcade.__file__).parent / "examples" / "gl", - "arcade.examples.gl" + "arcade.examples.platform_tutorial", ), + (Path(arcade.__file__).parent / "examples" / "gl", "arcade.examples.gl"), ] # These examples are allowed to print to stdout -ALLOW_STDOUT = set([ - "arcade.examples.dual_stick_shooter", - "transform_multi", -]) +ALLOW_STDOUT = set( + [ + "arcade.examples.dual_stick_shooter", + "transform_multi", + ] +) IGNORE_PATTERNS = [ "net_process_animal_facts", # Starts network process "transform_emit", # Broken @@ -42,16 +39,19 @@ "bindless", # Bindless textures cannot be run in unit test ] + def list_examples(): for path, module_path in EXAMPLE_LOCATIONS: for example in path.glob("*.py"): if example.stem.startswith("_"): continue + def is_ignored(example): for pattern in IGNORE_PATTERNS: if pattern in example.stem: return True return False + if is_ignored(example): continue yield f"{module_path}.{example.stem}", example, True @@ -85,13 +85,12 @@ def test_examples(window_proxy, module_path, file_path, allow_stdout): # Manually load the module as __main__ so it runs on import loader = SourceFileLoader("__main__", str(file_path)) loader.exec_module(loader.load_module()) - + # Reset the global clock's tick speed # is this a good argument against a global scope clock? # yes. arcade.clock.GLOBAL_CLOCK.set_tick_speed(1.0) - if not allow_stdout: output = stdout.getvalue() assert not output, f"Example {module_path} printed to stdout: {output}" diff --git a/tests/integration/tutorials/test_tutorials.py b/tests/integration/tutorials/test_tutorials.py index 748cae5536..e7ee269f60 100644 --- a/tests/integration/tutorials/test_tutorials.py +++ b/tests/integration/tutorials/test_tutorials.py @@ -1,6 +1,7 @@ """ Find and run all tutorials in the doc/tutorials directory """ + import io import os import contextlib @@ -11,7 +12,7 @@ import pytest import arcade -TUTORIAL_DIR = Path(arcade.__file__).parent.parent / "doc" /"tutorials" +TUTORIAL_DIR = Path(arcade.__file__).parent.parent / "doc" / "tutorials" ALLOW_STDOUT = {} diff --git a/tests/manual_smoke/sprite_collision_inspector.py b/tests/manual_smoke/sprite_collision_inspector.py index 401c317098..b38b725c39 100644 --- a/tests/manual_smoke/sprite_collision_inspector.py +++ b/tests/manual_smoke/sprite_collision_inspector.py @@ -14,11 +14,12 @@ TEX_GREY_PANEL_RAW = load_texture(":resources:gui_basic_assets/window/grey_panel.png") -T = TypeVar('T') +T = TypeVar("T") + def _tname(t: Any) -> str: if not isinstance(t, builtins.type): - return t.__class__.__name__ + return t.__class__.__name__ else: return t.__name__ @@ -61,7 +62,7 @@ def __init__( size_hint=size_hint, size_hint_min=size_hint_min, size_hint_max=size_hint_max, - **kwargs + **kwargs, ) self._error_color = error_color self._parsed_type: Type[T] = parsed_type @@ -102,9 +103,7 @@ def color(self, new_color: RGBOrA255) -> None: return self.caret.color = validated - self.doc.set_style( - 0, len(self.text), dict(color=validated) - ) + self.doc.set_style(0, len(self.text), dict(color=validated)) self.trigger_full_render() @property @@ -123,7 +122,6 @@ def text(self, new_text: str) -> None: raise e - def draw_crosshair( where: tuple[float, float], color=arcade.color.BLACK, @@ -131,58 +129,40 @@ def draw_crosshair( border_width: float = 1.0, ) -> None: x, y = where - arcade.draw.circle.draw_circle_outline( - x, y, - radius, - color=color, - border_width=border_width - ) - arcade.draw.draw_line( - x, y - radius, x, y + radius, - color=color, line_width=border_width) + arcade.draw.circle.draw_circle_outline(x, y, radius, color=color, border_width=border_width) + arcade.draw.draw_line(x, y - radius, x, y + radius, color=color, line_width=border_width) - arcade.draw.draw_line( - x - radius, y, x + radius, y, - color=color, line_width=border_width) + arcade.draw.draw_line(x - radius, y, x + radius, y, color=color, line_width=border_width) class MyGame(arcade.Window): - def add_field_row(self, label_text: str, widget: UIWidget) -> None: children = ( arcade.gui.widgets.text.UITextArea( - text=label_text, - width=100, - height=20, - color=arcade.color.BLACK, - font_size=12 + text=label_text, width=100, height=20, color=arcade.color.BLACK, font_size=12 ), - widget + widget, ) row = UIBoxLayout(vertical=False, space_between=10, children=children) self.rows.add(row) - def __init__( - self, - width: int = 1280, - height: int = 720, - grid_tile_px: int = 100 - ): - + def __init__(self, width: int = 1280, height: int = 720, grid_tile_px: int = 100): super().__init__(width, height, "Collision Inspector") # why does this need a context again? self.nine_patch = NinePatchTexture( - left=5, right=5, top=5, bottom=5, texture=TEX_GREY_PANEL_RAW) + left=5, right=5, top=5, bottom=5, texture=TEX_GREY_PANEL_RAW + ) self.ui = UIManager() self.spritelist: SpriteList[Sprite] = arcade.SpriteList() - textbox_template = dict(width=40, height=20, text_color=arcade.color.BLACK) - self.cursor_x_field = UIInputText( - text="1.0", **textbox_template).with_background(texture=self.nine_patch) + self.cursor_x_field = UIInputText(text="1.0", **textbox_template).with_background( + texture=self.nine_patch + ) - self.cursor_y_field = UIInputText( - text="1.0", **textbox_template).with_background(texture=self.nine_patch) + self.cursor_y_field = UIInputText(text="1.0", **textbox_template).with_background( + texture=self.nine_patch + ) self.rows = UIBoxLayout(space_between=20).with_background(color=arcade.color.GRAY) @@ -206,11 +186,7 @@ def __init__( self.on_widget = False def build_sprite_grid( - self, - columns: int, - rows: int, - grid_tile_px: int, - offset: tuple[float, float] = (0, 0) + self, columns: int, rows: int, grid_tile_px: int, offset: tuple[float, float] = (0, 0) ): offset_x, offset_y = offset self.spritelist.clear() @@ -254,4 +230,5 @@ def on_draw(self): self.ui.draw() -MyGame().run() \ No newline at end of file + +MyGame().run() diff --git a/tests/unit/atlas/conftest.py b/tests/unit/atlas/conftest.py index 699182ff6e..68792dc623 100644 --- a/tests/unit/atlas/conftest.py +++ b/tests/unit/atlas/conftest.py @@ -8,7 +8,6 @@ def common(): class Common: - @staticmethod def check_internals( atlas: arcade.DefaultTextureAtlas, @@ -30,7 +29,7 @@ def check_internals( # Unique textures assert len(atlas._unique_textures) == unique_textures - assert len(atlas._texture_uvs) == unique_textures # potentially also test free slots + assert len(atlas._texture_uvs) == unique_textures # potentially also test free slots assert len(atlas._texture_regions) == unique_textures assert len(atlas._unique_texture_ref_count) == unique_textures diff --git a/tests/unit/atlas/test_basics.py b/tests/unit/atlas/test_basics.py index e7cdf371ef..1d682a2827 100644 --- a/tests/unit/atlas/test_basics.py +++ b/tests/unit/atlas/test_basics.py @@ -120,9 +120,9 @@ def test_update_texture_image(ctx): atlas.update_texture_image(tex_3) # Test pixels one pixel in the middle of each texture to verify # the images was replaced with colored textures - assert b'\xff\x00\x00\xff' == atlas.fbo.read(viewport=(32, 32, 1, 1), components=4) - assert b'\x00\xff\x00\xff' == atlas.fbo.read(viewport=(96, 32, 1, 1), components=4) - assert b'\x00\x00\xff\xff' == atlas.fbo.read(viewport=(160, 32, 1, 1), components=4) + assert b"\xff\x00\x00\xff" == atlas.fbo.read(viewport=(32, 32, 1, 1), components=4) + assert b"\x00\xff\x00\xff" == atlas.fbo.read(viewport=(96, 32, 1, 1), components=4) + assert b"\x00\x00\xff\xff" == atlas.fbo.read(viewport=(160, 32, 1, 1), components=4) def test_uv_buffers_after_change(ctx): diff --git a/tests/unit/atlas/test_gc.py b/tests/unit/atlas/test_gc.py index 946e4adead..e637e483b2 100644 --- a/tests/unit/atlas/test_gc.py +++ b/tests/unit/atlas/test_gc.py @@ -2,7 +2,6 @@ import arcade - def test_gc_image_multi_ref(ctx, common): """Test how atlas handles unique textures with the same image""" atlas = arcade.DefaultTextureAtlas((256, 256)) @@ -17,26 +16,45 @@ def test_gc_image_multi_ref(ctx, common): for i, texture in enumerate((texture_1, texture_2, texture_3, texture_4, texture_5, texture_6)): atlas.add(texture) - common.check_internals(atlas, images=1, textures=i + 1, unique_textures=i + 1, textures_added=i + 1, textures_removed=0) + common.check_internals( + atlas, + images=1, + textures=i + 1, + unique_textures=i + 1, + textures_added=i + 1, + textures_removed=0, + ) texture = None common.check_internals(atlas, images=1, textures=6, unique_textures=6) # # Remove a texture one by one texture_1 = None - common.check_internals(atlas, images=1, textures=5, unique_textures=5, textures_added=6, textures_removed=1) + common.check_internals( + atlas, images=1, textures=5, unique_textures=5, textures_added=6, textures_removed=1 + ) texture_2 = None - common.check_internals(atlas, images=1, textures=4, unique_textures=4, textures_added=6, textures_removed=2) + common.check_internals( + atlas, images=1, textures=4, unique_textures=4, textures_added=6, textures_removed=2 + ) texture_3 = None - common.check_internals(atlas, images=1, textures=3, unique_textures=3, textures_added=6, textures_removed=3) + common.check_internals( + atlas, images=1, textures=3, unique_textures=3, textures_added=6, textures_removed=3 + ) texture_4 = None - common.check_internals(atlas, images=1, textures=2, unique_textures=2, textures_added=6, textures_removed=4) + common.check_internals( + atlas, images=1, textures=2, unique_textures=2, textures_added=6, textures_removed=4 + ) texture_5 = None - common.check_internals(atlas, images=1, textures=1, unique_textures=1, textures_added=6, textures_removed=5) + common.check_internals( + atlas, images=1, textures=1, unique_textures=1, textures_added=6, textures_removed=5 + ) texture_6 = None gc.collect() gc.collect() - common.check_internals(atlas, images=0, textures=0, unique_textures=0, textures_added=6, textures_removed=6) + common.check_internals( + atlas, images=0, textures=0, unique_textures=0, textures_added=6, textures_removed=6 + ) def test_gc_image_multi_ref_duplicates(ctx, common): @@ -45,20 +63,30 @@ def test_gc_image_multi_ref_duplicates(ctx, common): # Load the texture multiple times texture_1 = arcade.load_texture(":assets:images/topdown_tanks/tank_sand.png") # unique 1 - texture_2 = texture_1.rotate_90() # unique 2 + texture_2 = texture_1.rotate_90() # unique 2 texture_3 = arcade.load_texture(":assets:images/topdown_tanks/tank_sand.png") # duplicate or 1 - texture_4 = texture_3.rotate_180() # unique 3 + texture_4 = texture_3.rotate_180() # unique 3 # Add them one by one and check the internals atlas.add(texture_1) - common.check_internals(atlas, images=1, textures=1, unique_textures=1, textures_added=1, textures_removed=0) + common.check_internals( + atlas, images=1, textures=1, unique_textures=1, textures_added=1, textures_removed=0 + ) atlas.add(texture_2) - common.check_internals(atlas, images=1, textures=2, unique_textures=2, textures_added=2, textures_removed=0) + common.check_internals( + atlas, images=1, textures=2, unique_textures=2, textures_added=2, textures_removed=0 + ) atlas.add(texture_3) - common.check_internals(atlas, images=1, textures=3, unique_textures=2, textures_added=3, textures_removed=0) + common.check_internals( + atlas, images=1, textures=3, unique_textures=2, textures_added=3, textures_removed=0 + ) atlas.add(texture_4) - common.check_internals(atlas, images=1, textures=4, unique_textures=3, textures_added=4, textures_removed=0) + common.check_internals( + atlas, images=1, textures=4, unique_textures=3, textures_added=4, textures_removed=0 + ) # Remove a texture one by one and check the internals texture_1 = None - common.check_internals(atlas, images=1, textures=3, unique_textures=3, textures_added=4, textures_removed=1) + common.check_internals( + atlas, images=1, textures=3, unique_textures=3, textures_added=4, textures_removed=1 + ) diff --git a/tests/unit/atlas/test_rebuild_resize.py b/tests/unit/atlas/test_rebuild_resize.py index 483967c79d..0b381c6911 100644 --- a/tests/unit/atlas/test_rebuild_resize.py +++ b/tests/unit/atlas/test_rebuild_resize.py @@ -17,7 +17,9 @@ def test_rebuild(ctx, common): slot_b, region_b = atlas.add(tex_small) region_a = atlas.get_texture_region_info(tex_big.atlas_name) region_b = atlas.get_texture_region_info(tex_small.atlas_name) - common.check_internals(atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0) + common.check_internals( + atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0 + ) # Re-build and check states atlas.rebuild() @@ -25,7 +27,9 @@ def test_rebuild(ctx, common): assert slot_b == atlas.get_texture_id(tex_small) region_aa = atlas.get_texture_region_info(tex_big.atlas_name) region_bb = atlas.get_texture_region_info(tex_small.atlas_name) - common.check_internals(atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0) + common.check_internals( + atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0 + ) # The textures have switched places in the atlas and should # have the same left position @@ -34,7 +38,9 @@ def test_rebuild(ctx, common): assert region_b.texture_coordinates[0] != region_bb.texture_coordinates[0] assert region_a.texture_coordinates[0] != region_aa.texture_coordinates[0] - common.check_internals(atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0) + common.check_internals( + atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0 + ) def test_resize(ctx, common): @@ -47,9 +53,13 @@ def test_resize(ctx, common): atlas.add(t1) atlas.add(t2) - common.check_internals(atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0) + common.check_internals( + atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0 + ) atlas.resize((50, 100)) - common.check_internals(atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0) + common.check_internals( + atlas, images=2, textures=2, unique_textures=2, textures_added=2, textures_removed=0 + ) assert atlas._textures_added == 2 assert atlas._finalizers_created == 2 @@ -65,7 +75,9 @@ def test_resize(ctx, common): t1 = arcade.Texture(image=PIL.Image.new("RGBA", (50, 50), (255, 0, 0, 255))) t2 = arcade.Texture(image=PIL.Image.new("RGBA", (50, 50), (0, 255, 0, 255))) atlas.add(t1) - common.check_internals(atlas, images=1, textures=1, unique_textures=1, textures_added=1, textures_removed=0) + common.check_internals( + atlas, images=1, textures=1, unique_textures=1, textures_added=1, textures_removed=0 + ) with pytest.raises(AllocatorException): atlas.add(t2) diff --git a/tests/unit/atlas/test_region.py b/tests/unit/atlas/test_region.py index 59dbdfdb0f..be03f8bbf2 100644 --- a/tests/unit/atlas/test_region.py +++ b/tests/unit/atlas/test_region.py @@ -1,5 +1,6 @@ """Test AtlasRegion class.""" -import pytest + +import pytest import PIL.Image from arcade.texture_atlas.region import AtlasRegion @@ -18,10 +19,14 @@ def test_region_coordinates_simple(ctx): # Simulate the half pixel location a, b = 0, 1.0 assert region.texture_coordinates == ( - a, a, - b, a, - a, b, - b, b, + a, + a, + b, + a, + a, + b, + b, + b, ) diff --git a/tests/unit/atlas/test_update_texture_image_from_atlas.py b/tests/unit/atlas/test_update_texture_image_from_atlas.py index 362830caf3..f44a4e6e82 100644 --- a/tests/unit/atlas/test_update_texture_image_from_atlas.py +++ b/tests/unit/atlas/test_update_texture_image_from_atlas.py @@ -1,6 +1,7 @@ """ Test syncing atlas textures back into PIL images. """ + from PIL import Image, ImageDraw import arcade diff --git a/tests/unit/camera/test_camera2d.py b/tests/unit/camera/test_camera2d.py index a7a199d1fd..0760405075 100644 --- a/tests/unit/camera/test_camera2d.py +++ b/tests/unit/camera/test_camera2d.py @@ -142,7 +142,8 @@ def test_move_camera_and_unproject(window: Window): assert screen_coordinate == (pytest.approx(0), pytest.approx(0)) -@pytest.mark.parametrize('angle', ROTATIONS) + +@pytest.mark.parametrize("angle", ROTATIONS) def test_rotate_camera_with_angle(window: Window, angle: float): camera = Camera2D() camera.angle = angle @@ -151,7 +152,8 @@ def test_rotate_camera_with_angle(window: Window, angle: float): assert camera.up.x == pytest.approx(up.x) assert camera.up.y == pytest.approx(up.y) -@pytest.mark.parametrize('angle', ROTATIONS) + +@pytest.mark.parametrize("angle", ROTATIONS) def test_camera_corner_properties(window: Window, angle: float): camera = Camera2D(projection=LRBT(-1.0, 1.0, -1.0, 1.0), position=(0.0, 0.0)) camera.angle = angle diff --git a/tests/unit/camera/test_camera_controller_methods.py b/tests/unit/camera/test_camera_controller_methods.py index c937d08daa..87848412fe 100644 --- a/tests/unit/camera/test_camera_controller_methods.py +++ b/tests/unit/camera/test_camera_controller_methods.py @@ -16,7 +16,9 @@ def test_strafe(): # Then for dirs in directions: camera_data.position = grips.strafe(camera_data, dirs) - assert camera_data.position == (dirs[0], dirs[1], 0.0), f"Strafe failed to move the camera data correctly, {dirs}" + assert camera_data.position == (dirs[0], dirs[1], 0.0), ( + f"Strafe failed to move the camera data correctly, {dirs}" + ) camera_data.position = (0.0, 0.0, 0.0) # Given @@ -25,7 +27,9 @@ def test_strafe(): for dirs in directions: camera_data.position = grips.strafe(camera_data, dirs) - assert camera_data.position == (0.0, dirs[1], dirs[0]), f"Strafe failed to move the camera data correctly, {dirs}" + assert camera_data.position == (0.0, dirs[1], dirs[0]), ( + f"Strafe failed to move the camera data correctly, {dirs}" + ) camera_data.position = (0.0, 0.0, 0.0) diff --git a/tests/unit/camera/test_camera_shake.py b/tests/unit/camera/test_camera_shake.py index 6e431bbfb4..7346f370c5 100644 --- a/tests/unit/camera/test_camera_shake.py +++ b/tests/unit/camera/test_camera_shake.py @@ -15,10 +15,18 @@ def test_reset(window: Window): screen_shaker.reset() # Then - assert screen_shaker._current_dir == 0.0, "ScreenShakeController failed to reset properly [current_dir]" - assert screen_shaker._last_vector == (0.0, 0.0, 0.0), "ScreenShakeController failed to reset properly [last_vector]" - assert screen_shaker._length_shaking == 0.0, "ScreenShakeController failed to reset properly [length_shaking]" - assert screen_shaker._last_update_time == 0.0, "ScreenShakeController failed to reset properly [last_update_time]" + assert screen_shaker._current_dir == 0.0, ( + "ScreenShakeController failed to reset properly [current_dir]" + ) + assert screen_shaker._last_vector == (0.0, 0.0, 0.0), ( + "ScreenShakeController failed to reset properly [last_vector]" + ) + assert screen_shaker._length_shaking == 0.0, ( + "ScreenShakeController failed to reset properly [length_shaking]" + ) + assert screen_shaker._last_update_time == 0.0, ( + "ScreenShakeController failed to reset properly [last_update_time]" + ) def test_update(window: Window): @@ -27,21 +35,25 @@ def test_update(window: Window): screen_shaker = ScreenShake2D(camera_view) # When - screen_shaker.update(1/60) + screen_shaker.update(1 / 60) # Then - assert screen_shaker._length_shaking == 0.0, "ScreenShakeController updated when it had not started" + assert screen_shaker._length_shaking == 0.0, ( + "ScreenShakeController updated when it had not started" + ) # When screen_shaker.start() - screen_shaker.update(1/60) + screen_shaker.update(1 / 60) # Then - assert screen_shaker._length_shaking == 1/60, "ScreenShakeController failed to update by the correct dt" + assert screen_shaker._length_shaking == 1 / 60, ( + "ScreenShakeController failed to update by the correct dt" + ) # When screen_shaker.stop() - screen_shaker.update(1/60) + screen_shaker.update(1 / 60) # Then assert screen_shaker._length_shaking == 0.0, "ScreenShakeController failed to stop updating" @@ -51,7 +63,9 @@ def test_update(window: Window): screen_shaker.update(2.0) # Then - assert not screen_shaker.shaking, "ScreenShakeController failed to stop when shaking for too long" + assert not screen_shaker.shaking, ( + "ScreenShakeController failed to stop when shaking for too long" + ) def test_update_camera(window: Window): @@ -63,12 +77,16 @@ def test_update_camera(window: Window): # When screen_shaker.start() - screen_shaker.update(1/60) + screen_shaker.update(1 / 60) screen_shaker.update_camera() # Then - assert camera_view.position != cam_pos, "ScreenShakeController failed to change the camera's position" - assert screen_shaker._last_vector != (0.0, 0.0, 0.0), "ScreenShakeController failed to store the last vector" + assert camera_view.position != cam_pos, ( + "ScreenShakeController failed to change the camera's position" + ) + assert screen_shaker._last_vector != (0.0, 0.0, 0.0), ( + "ScreenShakeController failed to store the last vector" + ) _adjust_test = ( camera_view.position[0] - screen_shaker._last_vector[0], camera_view.position[1] - screen_shaker._last_vector[1], @@ -90,5 +108,9 @@ def test_readjust_camera(window: Window): screen_shaker.readjust_camera() # Then - assert camera_view.position == cam_pos, "ScreenShakeController failed to readjust the camera position" - assert screen_shaker._last_vector == (0.0, 0.0, 0.0), "ScreenShakeController failed to reset the last vector" + assert camera_view.position == cam_pos, ( + "ScreenShakeController failed to readjust the camera position" + ) + assert screen_shaker._last_vector == (0.0, 0.0, 0.0), ( + "ScreenShakeController failed to reset the last vector" + ) diff --git a/tests/unit/camera/test_orthographic_projector.py b/tests/unit/camera/test_orthographic_projector.py index 3c2220d7f2..8834a71f2e 100644 --- a/tests/unit/camera/test_orthographic_projector.py +++ b/tests/unit/camera/test_orthographic_projector.py @@ -69,7 +69,7 @@ def test_orthographic_projector_map_coordinates_move(window: Window, width, heig ortho_camera = camera.OrthographicProjector() default_view = ortho_camera.view - half_width, half_height = window.width//2, window.height//2 + half_width, half_height = window.width // 2, window.height // 2 mouse_pos_a = (half_width, half_height) mouse_pos_b = (100.0, 100.0) @@ -79,10 +79,8 @@ def test_orthographic_projector_map_coordinates_move(window: Window, width, heig # Then assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx((0.0, 0.0, 0.0)) - assert ( - tuple(ortho_camera.unproject(mouse_pos_b)) - == - pytest.approx((-half_width+100.0, -half_height+100, 0.0)) + assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( + (-half_width + 100.0, -half_height + 100, 0.0) ) # And @@ -92,10 +90,8 @@ def test_orthographic_projector_map_coordinates_move(window: Window, width, heig # Then assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, 0.0)) - assert ( - tuple(ortho_camera.unproject(mouse_pos_b)) - == - pytest.approx((-half_width+200.0, -half_height+200.0, 0.0)) + assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( + (-half_width + 200.0, -half_height + 200.0, 0.0) ) @@ -106,7 +102,7 @@ def test_orthographic_projector_map_coordinates_rotate(window: Window, width, he ortho_camera = camera.OrthographicProjector() default_view = ortho_camera.view - half_width, half_height = window.width//2, window.height//2 + half_width, half_height = window.width // 2, window.height // 2 mouse_pos_a = (half_width, half_height) mouse_pos_b = (100.0, 100.0) @@ -117,10 +113,8 @@ def test_orthographic_projector_map_coordinates_rotate(window: Window, width, he # Then assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx((0.0, 0.0, 0.0)) - assert ( - tuple(ortho_camera.unproject(mouse_pos_b)) - == - pytest.approx((-half_height+100.0, half_width-100.0, 0.0)) + assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( + (-half_height + 100.0, half_width - 100.0, 0.0) ) # And @@ -135,10 +129,8 @@ def test_orthographic_projector_map_coordinates_rotate(window: Window, width, he b_rotated_y = -b_shift_x / (2.0**0.5) + b_shift_y / (2.0**0.5) + 100 # Then assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, 0.0)) - assert ( - tuple(ortho_camera.unproject(mouse_pos_b)) - == - pytest.approx((b_rotated_x, b_rotated_y, 0.0)) + assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( + (b_rotated_x, b_rotated_y, 0.0) ) @@ -149,7 +141,7 @@ def test_orthographic_projector_map_coordinates_zoom(window: Window, width, heig ortho_camera = camera.OrthographicProjector() default_view = ortho_camera.view - half_width, half_height = window.width//2, window.height//2 + half_width, half_height = window.width // 2, window.height // 2 mouse_pos_a = (window.width, window.height) mouse_pos_b = (100.0, 100.0) @@ -158,15 +150,11 @@ def test_orthographic_projector_map_coordinates_zoom(window: Window, width, heig default_view.zoom = 2.0 # Then - assert ( - tuple(ortho_camera.unproject(mouse_pos_a)) - == - pytest.approx(Vec3(window.width*0.75, window.height*0.75, 0.0)) + assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx( + Vec3(window.width * 0.75, window.height * 0.75, 0.0) ) - assert ( - tuple(ortho_camera.unproject(mouse_pos_b)) - == - pytest.approx((half_width + (100 - half_width)*0.5, half_height + (100 - half_height)*0.5, 0.0)) + assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( + (half_width + (100 - half_width) * 0.5, half_height + (100 - half_height) * 0.5, 0.0) ) # And @@ -176,13 +164,9 @@ def test_orthographic_projector_map_coordinates_zoom(window: Window, width, heig default_view.zoom = 0.25 # Then - assert ( - tuple(ortho_camera.unproject(mouse_pos_a)) - == - pytest.approx((window.width*2.0, window.height*2.0, 0.0)) + assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx( + (window.width * 2.0, window.height * 2.0, 0.0) ) - assert ( - tuple(ortho_camera.unproject(mouse_pos_b)) - == - pytest.approx(((100 - half_width)*4.0, (100 - half_height)*4.0, 0.0)) + assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( + ((100 - half_width) * 4.0, (100 - half_height) * 4.0, 0.0) ) diff --git a/tests/unit/camera/test_perspective_projector.py b/tests/unit/camera/test_perspective_projector.py index 8201e6ed3d..e4e0caa906 100644 --- a/tests/unit/camera/test_perspective_projector.py +++ b/tests/unit/camera/test_perspective_projector.py @@ -51,7 +51,7 @@ def test_perspective_projector_map_coordinates(window: Window, width, height): window.set_size(width, height) persp_camera = camera.PerspectiveProjector() - depth = (0.5 * persp_camera.viewport.height / tan(radians(0.5 * persp_camera._projection.fov))) + depth = 0.5 * persp_camera.viewport.height / tan(radians(0.5 * persp_camera._projection.fov)) # When mouse_pos_a = (100.0, 100.0) @@ -71,9 +71,9 @@ def test_perspective_projector_map_coordinates_move(window: Window, width, heigh persp_camera = camera.PerspectiveProjector() default_view = persp_camera.view - depth = (0.5 * persp_camera.viewport.height / tan(radians(0.5 * persp_camera._projection.fov))) + depth = 0.5 * persp_camera.viewport.height / tan(radians(0.5 * persp_camera._projection.fov)) - half_width, half_height = window.width//2, window.height//2 + half_width, half_height = window.width // 2, window.height // 2 mouse_pos_a = (half_width, half_height) mouse_pos_b = (100.0, 100.0) @@ -83,10 +83,8 @@ def test_perspective_projector_map_coordinates_move(window: Window, width, heigh # Then assert tuple(persp_camera.unproject(mouse_pos_a)) == pytest.approx((0.0, 0.0, depth)) - assert ( - tuple(persp_camera.unproject(mouse_pos_b)) - == - pytest.approx((-half_width+100.0, -half_height+100, depth)) + assert tuple(persp_camera.unproject(mouse_pos_b)) == pytest.approx( + (-half_width + 100.0, -half_height + 100, depth) ) # And @@ -96,10 +94,8 @@ def test_perspective_projector_map_coordinates_move(window: Window, width, heigh # Then assert tuple(persp_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, depth)) - assert ( - tuple(persp_camera.unproject(mouse_pos_b)) - == - pytest.approx((-half_width+200.0, -half_height+200.0, depth)) + assert tuple(persp_camera.unproject(mouse_pos_b)) == pytest.approx( + (-half_width + 200.0, -half_height + 200.0, depth) ) @@ -110,9 +106,9 @@ def test_perspective_projector_map_coordinates_rotate(window: Window, width, hei persp_camera = camera.PerspectiveProjector() default_view = persp_camera.view - depth = (0.5 * persp_camera.viewport.height / tan(radians(0.5 * persp_camera._projection.fov))) + depth = 0.5 * persp_camera.viewport.height / tan(radians(0.5 * persp_camera._projection.fov)) - half_width, half_height = window.width//2, window.height//2 + half_width, half_height = window.width // 2, window.height // 2 mouse_pos_a = (half_width, half_height) mouse_pos_b = (100.0, 100.0) @@ -123,10 +119,8 @@ def test_perspective_projector_map_coordinates_rotate(window: Window, width, hei # Then assert tuple(persp_camera.unproject(mouse_pos_a)) == pytest.approx((0.0, 0.0, depth)) - assert ( - tuple(persp_camera.unproject(mouse_pos_b)) - == - pytest.approx((-half_height+100.0, half_width-100.0, depth)) + assert tuple(persp_camera.unproject(mouse_pos_b)) == pytest.approx( + (-half_height + 100.0, half_width - 100.0, depth) ) # And @@ -141,9 +135,6 @@ def test_perspective_projector_map_coordinates_rotate(window: Window, width, hei b_rotated_y = -b_shift_x / (2.0**0.5) + b_shift_y / (2.0**0.5) + 100 # Then assert tuple(persp_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, depth)) - assert ( - tuple(persp_camera.unproject(mouse_pos_b)) - == - pytest.approx((b_rotated_x, b_rotated_y, depth)) + assert tuple(persp_camera.unproject(mouse_pos_b)) == pytest.approx( + (b_rotated_x, b_rotated_y, depth) ) - diff --git a/tests/unit/camera/test_viewport_projector.py b/tests/unit/camera/test_viewport_projector.py index ebd33d55e4..f99098ff1a 100644 --- a/tests/unit/camera/test_viewport_projector.py +++ b/tests/unit/camera/test_viewport_projector.py @@ -5,11 +5,13 @@ from arcade import camera, Window from arcade.types import Point, LBWH, Rect + @pytest.mark.parametrize("wrld_pos", [Vec2(100, 150), Vec2(1280, 720), Vec3(500, 500, -10)]) def test_viewport_projector_project(window: Window, wrld_pos: Point): cam = camera.default.ViewportProjector() assert cam.project(wrld_pos) == wrld_pos.xy + @pytest.mark.parametrize("wrld_pos", [Vec2(100, 150), Vec2(1280, 720), Vec3(500, 500, -10)]) def test_viewport_projector_unproject(window: Window, wrld_pos: Point): cam = camera.default.ViewportProjector() @@ -17,7 +19,10 @@ def test_viewport_projector_unproject(window: Window, wrld_pos: Point): assert cam.unproject(wrld_pos) == Vec3(x, y, 0.0 if not z else z[0]) -@pytest.mark.parametrize("viewport", [LBWH(0.0, 0.0, 100, 200), LBWH(100, 100, 20, 40), LBWH(300, 20, 20, 700)]) + +@pytest.mark.parametrize( + "viewport", [LBWH(0.0, 0.0, 100, 200), LBWH(100, 100, 20, 40), LBWH(300, 20, 20, 700)] +) def test_viewport_projector_viewport(window: Window, viewport: Rect): cam = camera.default.ViewportProjector() assert cam.viewport.lbwh_int == window.ctx.viewport diff --git a/tests/unit/color/test_color_type.py b/tests/unit/color/test_color_type.py index 7bf5dc11f7..c92f66fe7d 100644 --- a/tests/unit/color/test_color_type.py +++ b/tests/unit/color/test_color_type.py @@ -173,7 +173,7 @@ def test_color_from_hex_string(): assert Color.from_hex_string("#fff") == (255, 255, 255, 255) assert Color.from_hex_string("FFF") == (255, 255, 255, 255) - for bad_value in ("ppp", 'ff', "e"): + for bad_value in ("ppp", "ff", "e"): with pytest.raises(ValueError): Color.from_hex_string(bad_value) @@ -197,7 +197,7 @@ def test_color_rgb_property(): # Spot check unique colors assert colors.COBALT.rgb == (0, 71, 171) - assert Color(1,3,5,7).rgb == (1, 3, 5) + assert Color(1, 3, 5, 7).rgb == (1, 3, 5) def test_deepcopy_color_values(): @@ -246,11 +246,10 @@ def randint_is_constant(monkeypatch): repeated value (128, or 0x80 in hex) to represent a channel fetched from random rather than taken from user input. """ - monkeypatch.setattr('random.randint', Mock(return_value=0x80808080)) + monkeypatch.setattr("random.randint", Mock(return_value=0x80808080)) def test_color_random(randint_is_constant): - for combo in product((None, 0), repeat=4): color = Color.random(*combo) for channel_value, channel_arg in zip(color, combo): diff --git a/tests/unit/color/test_module_csscolor.py b/tests/unit/color/test_module_csscolor.py index f238c95ecb..dce43eba3e 100644 --- a/tests/unit/color/test_module_csscolor.py +++ b/tests/unit/color/test_module_csscolor.py @@ -1,6 +1,6 @@ - def test_csscolors(): from arcade import csscolor + names = csscolor.__dict__.keys() # number of colors + 1 import assert 156 + 1 == len(names) diff --git a/tests/unit/draw/test_drawing_primitives.py b/tests/unit/draw/test_drawing_primitives.py index 2d429670e4..8c3d81ced9 100644 --- a/tests/unit/draw/test_drawing_primitives.py +++ b/tests/unit/draw/test_drawing_primitives.py @@ -23,12 +23,7 @@ def test_draw_primitives(window): # Draw a set of points arcade.draw_text("draw_points", 123, 405, arcade.color.BLACK, 12) - point_list = ((165, 495), - (165, 480), - (165, 465), - (195, 495), - (195, 480), - (195, 465)) + point_list = ((165, 495), (165, 480), (165, 465), (195, 495), (195, 480), (195, 465)) arcade.draw_points(point_list, arcade.color.ZAFFRE, 10) # Draw a line @@ -37,45 +32,23 @@ def test_draw_primitives(window): # Draw a set of lines arcade.draw_text("draw_lines", 363, 405, arcade.color.BLACK, 12) - point_list = ((390, 450), - (450, 450), - (390, 480), - (450, 480), - (390, 510), - (450, 510) - ) + point_list = ((390, 450), (450, 450), (390, 480), (450, 480), (390, 510), (450, 510)) arcade.draw_lines(point_list, arcade.color.BLUE, 3) # Draw a line strip arcade.draw_text("draw_line_strip", 483, 405, arcade.color.BLACK, 12) - point_list = ((510, 450), - (570, 450), - (510, 480), - (570, 480), - (510, 510), - (570, 510) - ) + point_list = ((510, 450), (570, 450), (510, 480), (570, 480), (510, 510), (570, 510)) arcade.draw_line_strip(point_list, arcade.color.TROPICAL_RAIN_FOREST, 3) arcade.draw_line_strip(point_list, arcade.color.BEIGE) # Draw a polygon arcade.draw_text("draw_polygon_outline", 3, 207, arcade.color.BLACK, 9) - point_list = ((30, 240), - (45, 240), - (60, 255), - (60, 285), - (45, 300), - (30, 300)) + point_list = ((30, 240), (45, 240), (60, 255), (60, 285), (45, 300), (30, 300)) arcade.draw_polygon_outline(point_list, arcade.color.SPANISH_VIOLET, 3) # Draw a filled in polygon arcade.draw_text("draw_polygon_filled", 123, 207, arcade.color.BLACK, 9) - point_list = ((150, 240), - (165, 240), - (180, 255), - (180, 285), - (165, 300), - (150, 300)) + point_list = ((150, 240), (165, 240), (180, 255), (180, 285), (165, 300), (150, 300)) arcade.draw_polygon_filled(point_list, arcade.color.SPANISH_VIOLET) # Draw an outline of a circle @@ -90,28 +63,24 @@ def test_draw_primitives(window): # Draw an ellipse outline, and another one rotated arcade.draw_text("draw_ellipse_outline", 483, 207, arcade.color.BLACK, 10) arcade.draw_ellipse_outline(540, 273, 15, 36, arcade.color.AMBER, 3) - arcade.draw_ellipse_outline(540, 336, 15, 36, - arcade.color.BLACK_BEAN, 3, 45) + arcade.draw_ellipse_outline(540, 336, 15, 36, arcade.color.BLACK_BEAN, 3, 45) # Draw a filled ellipse, and another one rotated arcade.draw_text("draw_ellipse_filled", 3, 3, arcade.color.BLACK, 10) arcade.draw_ellipse_filled(60, 81, 15, 36, arcade.color.AMBER) - arcade.draw_ellipse_filled(60, 144, 15, 36, - arcade.color.BLACK_BEAN, 45) + arcade.draw_ellipse_filled(60, 144, 15, 36, arcade.color.BLACK_BEAN, 45) # Draw an arc, and another one rotated arcade.draw_text("draw_arc/filled_arc", 123, 3, arcade.color.BLACK, 10) - arcade.draw_arc_outline(150, 81, 15, 36, - arcade.color.BRIGHT_MAROON, 90, 360) - arcade.draw_arc_filled(150, 144, 15, 36, - arcade.color.BOTTLE_GREEN, 90, 360, 45) + arcade.draw_arc_outline(150, 81, 15, 36, arcade.color.BRIGHT_MAROON, 90, 360) + arcade.draw_arc_filled(150, 144, 15, 36, arcade.color.BOTTLE_GREEN, 90, 360, 45) # Draw an rectangle outline arcade.draw_text("draw_rect", 243, 3, arcade.color.BLACK, 10) - arcade.draw_rect_outline(arcade.rect.XYWH(295, 100, 45, 65), - arcade.color.BRITISH_RACING_GREEN) - arcade.draw_rect_outline(arcade.rect.XYWH(295, 160, 20, 45), - arcade.color.BRITISH_RACING_GREEN, 3, 45) + arcade.draw_rect_outline(arcade.rect.XYWH(295, 100, 45, 65), arcade.color.BRITISH_RACING_GREEN) + arcade.draw_rect_outline( + arcade.rect.XYWH(295, 160, 20, 45), arcade.color.BRITISH_RACING_GREEN, 3, 45 + ) # Draw a filled in rectangle arcade.draw_text("draw_filled_rect", 363, 3, arcade.color.BLACK, 10) @@ -122,7 +91,7 @@ def test_draw_primitives(window): # Image from kenney.nl asset pack #1 arcade.draw_text("draw_bitmap", 483, 3, arcade.color.BLACK, 12) texture = arcade.load_texture(":resources:images/space_shooter/playerShip1_orange.png") - scale = .6 + scale = 0.6 # arcade.draw_texture_rectangle(540, 120, scale * texture.width, # scale * texture.height, texture, 0) # arcade.draw_texture_rectangle(540, 60, scale * texture.width, diff --git a/tests/unit/draw/test_rect.py b/tests/unit/draw/test_rect.py index 767f78925d..caceb02f72 100644 --- a/tests/unit/draw/test_rect.py +++ b/tests/unit/draw/test_rect.py @@ -16,7 +16,7 @@ def test_draw_texture_rect(window, offscreen): """Draw a texture rect and compare it to the expected image.""" region = LBWH(0, 0, *TEXTURE.size) - arcade.draw_texture_rect(TEXTURE, region, blend=False, pixelated=True) + arcade.draw_texture_rect(TEXTURE, region, blend=False, pixelated=True) screen_image = offscreen.read_region_image(region, components=3) expected_image = TEXTURE.image.convert("RGB") diff --git a/tests/unit/geometry/test_is_point_in_box.py b/tests/unit/geometry/test_is_point_in_box.py index 8cbd408d22..631ed8d0c4 100644 --- a/tests/unit/geometry/test_is_point_in_box.py +++ b/tests/unit/geometry/test_is_point_in_box.py @@ -29,12 +29,12 @@ def test_point_outside_1px(): def test_zero_box(): """ A box selection with zero width or height - + The selection area should always be included as a hit. """ # 1 x 1 pixel box - assert is_point_in_box((0, 0), (0, 0), (0, 0)) is True + assert is_point_in_box((0, 0), (0, 0), (0, 0)) is True # 1 x 100 pixel box - assert is_point_in_box((0, 0), (50, 0), (100, 0)) is True + assert is_point_in_box((0, 0), (50, 0), (100, 0)) is True # 100 x 1 pixel box - assert is_point_in_box((0, 0), (0, 50), (0, 100)) is True + assert is_point_in_box((0, 0), (0, 50), (0, 100)) is True diff --git a/tests/unit/gl/backends/gl/test_gl_program.py b/tests/unit/gl/backends/gl/test_gl_program.py index 5ea19a789f..5a7971e2ce 100644 --- a/tests/unit/gl/backends/gl/test_gl_program.py +++ b/tests/unit/gl/backends/gl/test_gl_program.py @@ -9,6 +9,7 @@ pytestmark = pytest.mark.backendgl + def test_shader_source(ctx): """Test shader source parsing""" source_wrapper = ShaderSource( @@ -34,10 +35,10 @@ def test_shader_source(ctx): elif ctx.gl_api == "opengles": assert source_wrapper.version == 310 - assert source_wrapper.out_attributes == ['out_pos', 'out_velocity'] - source = source_wrapper.get_source(defines={'TEST': 1, 'TEST2': '2'}) - assert '#define TEST 1' in source - assert '#define TEST2 2' in source + assert source_wrapper.out_attributes == ["out_pos", "out_velocity"] + source = source_wrapper.get_source(defines={"TEST": 1, "TEST2": "2"}) + assert "#define TEST 1" in source + assert "#define TEST2 2" in source def test_shader_source_empty(ctx): @@ -50,12 +51,7 @@ def test_shader_source_missing_version(ctx): with pytest.raises(ShaderException): ShaderSource( ctx, - ( - "in vec3 in_vert\n" - "void main() {\n" - " gl_Position = vec3(in_vert, 1.0);\n" - "}\n" - ), + ("in vec3 in_vert\nvoid main() {\n gl_Position = vec3(in_vert, 1.0);\n}\n"), None, gl.GL_VERTEX_SHADER, ) @@ -66,25 +62,14 @@ def test_shader_source_malformed(ctx): with pytest.raises(ShaderException): ShaderSource( ctx, - ( - "in in_vert\n" - "void main() \n" - " gl_Position = vec3(in_vert, 1.0)\n" - "}\n" - ), + ("in in_vert\nvoid main() \n gl_Position = vec3(in_vert, 1.0)\n}\n"), None, gl.GL_VERTEX_SHADER, ) with pytest.raises(ShaderException): ShaderSource( ctx, - ( - "#version\n" - "in in_vert\n" - "void main() \n" - " gl_Position = vec3(in_vert, 1.0)\n" - "}\n" - ), + ("#version\nin in_vert\nvoid main() \n gl_Position = vec3(in_vert, 1.0)\n}\n"), None, gl.GL_VERTEX_SHADER, ) @@ -104,8 +89,8 @@ def test_shader_source_malformed(ctx): None, gl.GL_VERTEX_SHADER, ) - source = wrapper.get_source(defines={'TEST': 1}) - assert 'TEST 1' in source + source = wrapper.get_source(defines={"TEST": 1}) + assert "TEST 1" in source def test_shader_program_broken_out(ctx): @@ -122,7 +107,7 @@ def test_shader_program_broken_out(ctx): None, gl.GL_VERTEX_SHADER, ) - wrapper.out_attributes == ['out_vert'] + wrapper.out_attributes == ["out_vert"] def test_program_basic(ctx): @@ -158,25 +143,25 @@ def test_program_basic(ctx): assert program.glo > 0 program.use() # assert ctx.active_program == program - assert repr(program).startswith('= (4, 1): program = ctx.program( @@ -310,7 +296,7 @@ def test_varyings(ctx): } """, ) - assert program.varyings == ['out_pos', 'out_velocity'] + assert program.varyings == ["out_pos", "out_velocity"] # Illegal varying names with pytest.raises(ShaderException): @@ -318,9 +304,9 @@ def test_varyings(ctx): # Mapping one of two varyings program = ctx.program(vertex_shader=src, varyings=["out_pos"]) - assert program.varyings == ['out_pos'] + assert program.varyings == ["out_pos"] program = ctx.program(vertex_shader=src, varyings=["out_velocity"]) - assert program.varyings == ['out_velocity'] + assert program.varyings == ["out_velocity"] # Configure capture mode program = ctx.program(vertex_shader=src, varyings_capture_mode="interleaved") @@ -332,7 +318,8 @@ def test_varyings(ctx): def test_uniform_block(ctx): """Test uniform block""" # Simple tranform with a uniform block - program = ctx.program(vertex_shader=""" + program = ctx.program( + vertex_shader=""" #version 330 uniform Projection { uniform mat4 matrix; @@ -346,9 +333,9 @@ def test_uniform_block(ctx): """ ) # Obtain the ubo info and modify binding + test properties - ubo = program['Projection'] + ubo = program["Projection"] assert isinstance(ubo, UniformBlock) - program['Projection'] = 1 + program["Projection"] = 1 assert ubo.binding == 1 ubo.binding = 0 assert ubo.binding == 0 @@ -362,4 +349,4 @@ def test_uniform_block(ctx): vao = ctx.geometry() ubo_buffer.bind_to_uniform_block(0) vao.transform(program, buffer, vertices=1) - assert struct.unpack('2f', buffer.read()) == pytest.approx((1, 1), 0.01) + assert struct.unpack("2f", buffer.read()) == pytest.approx((1, 1), 0.01) diff --git a/tests/unit/gl/test_gl_buffer.py b/tests/unit/gl/test_gl_buffer.py index f8b43126b2..1a919a2e80 100644 --- a/tests/unit/gl/test_gl_buffer.py +++ b/tests/unit/gl/test_gl_buffer.py @@ -3,7 +3,7 @@ def test_properties(ctx): - buffer = ctx.buffer(data=b'Hello world') + buffer = ctx.buffer(data=b"Hello world") assert buffer.glo.value > 0 assert buffer.ctx == ctx @@ -14,10 +14,10 @@ def test_create_empty(ctx): def test_read(ctx): - buffer = ctx.buffer(data=b'Hello world') - assert buffer.read() == b'Hello world' - assert buffer.read(size=5) == b'Hello' - assert buffer.read(size=5, offset=6) == b'world' + buffer = ctx.buffer(data=b"Hello world") + assert buffer.read() == b"Hello world" + assert buffer.read(size=5) == b"Hello" + assert buffer.read(size=5, offset=6) == b"world" # Reading outside buffer by 1 byte with pytest.raises(ValueError): @@ -38,58 +38,58 @@ def test_read(ctx): def test_write(ctx): # Attempt to write too much data buffer = ctx.buffer(reserve=4) - buffer.write(b'Hello World') - assert buffer.read() == b'Hell' + buffer.write(b"Hello World") + assert buffer.read() == b"Hell" # Write too little data buffer = ctx.buffer(reserve=8) - buffer.write(b'Hello') - assert buffer.read() == b'Hello\x00\x00\x00' + buffer.write(b"Hello") + assert buffer.read() == b"Hello\x00\x00\x00" # Write with offset buffer = ctx.buffer(reserve=8) - buffer.write(b'test', offset=4) - assert buffer.read() == b'\x00\x00\x00\x00test' + buffer.write(b"test", offset=4) + assert buffer.read() == b"\x00\x00\x00\x00test" # Clipped data with offset buffer = ctx.buffer(reserve=8) - buffer.write(b'test', offset=6) - assert buffer.read() == b'\x00\x00\x00\x00\x00\x00te' + buffer.write(b"test", offset=6) + assert buffer.read() == b"\x00\x00\x00\x00\x00\x00te" # Write 0 bytes buffer = ctx.buffer(reserve=8) - buffer.write(b'test', offset=8) + buffer.write(b"test", offset=8) # Go past the buffer buffer = ctx.buffer(reserve=8) with pytest.raises(ValueError): - buffer.write(b'test', offset=9) + buffer.write(b"test", offset=9) def test_write_buffer_protocol(ctx): """Write data to buffer with buffer protocol""" - data = array('f', [1, 2, 3, 4]) + data = array("f", [1, 2, 3, 4]) buff = ctx.buffer(data=data) assert buff.read() == data.tobytes() def test_orphan(ctx): - buffer = ctx.buffer(data=b'Hello world') + buffer = ctx.buffer(data=b"Hello world") buffer.orphan(size=20) assert buffer.size == 20 assert len(buffer.read()) == 20 - buffer.write(b'Testing') - assert buffer.read(size=7) == b'Testing' - buffer.write(b'Testing', offset=10) - assert buffer.read(offset=10, size=7) == b'Testing' + buffer.write(b"Testing") + assert buffer.read(size=7) == b"Testing" + buffer.write(b"Testing", offset=10) + assert buffer.read(offset=10, size=7) == b"Testing" buffer.orphan(double=True) assert buffer.size == 40 def test_copy(ctx): - buffer = ctx.buffer(data=b'Hello world') + buffer = ctx.buffer(data=b"Hello world") source = ctx.buffer(reserve=20) buffer.copy_from_buffer(source, size=10, offset=0) # Copy out of bounds in the source buffer diff --git a/tests/unit/gl/test_gl_buffer_description.py b/tests/unit/gl/test_gl_buffer_description.py index 156a1d013b..14c3ca1d79 100644 --- a/tests/unit/gl/test_gl_buffer_description.py +++ b/tests/unit/gl/test_gl_buffer_description.py @@ -6,10 +6,10 @@ def test_buffer_description(ctx): # TODO: components > 4 # TODO: padding buffer = ctx.buffer(reserve=4 * 8) - attribute_names = ['in_vert', 'in_uv'] + attribute_names = ["in_vert", "in_uv"] descr = BufferDescription( buffer, - '2f 2f', + "2f 2f", attribute_names, ) assert descr.num_vertices == 2 diff --git a/tests/unit/gl/test_gl_context.py b/tests/unit/gl/test_gl_context.py index ebf1b21475..e413d8c9b7 100644 --- a/tests/unit/gl/test_gl_context.py +++ b/tests/unit/gl/test_gl_context.py @@ -1,6 +1,7 @@ """ Low level tests for OpenGL 3.3 wrappers. """ + import pytest from pyglet.math import Mat4 @@ -39,6 +40,7 @@ def test_view_matrix(window): with pytest.raises(ValueError): window.ctx.view_matrix = "moo" + def test_projection_matrix(window): """Test setting projection matrix directly""" window.ctx.projection_matrix = Mat4() @@ -81,7 +83,7 @@ def test_enable_disable(ctx): def test_enabled(ctx): - """Enabled only context manager""" + """Enabled only context manager""" assert ctx.is_enabled(ctx.BLEND) assert not ctx.is_enabled(ctx.DEPTH_TEST) @@ -94,7 +96,7 @@ def test_enabled(ctx): def test_enabled_only(ctx): - """Enabled only context manager""" + """Enabled only context manager""" assert ctx.is_enabled(ctx.BLEND) with ctx.enabled_only(ctx.DEPTH_TEST): @@ -107,12 +109,16 @@ def test_enabled_only(ctx): def test_load_texture(ctx): # Default flipped and read value of corner pixel - texture = ctx.load_texture(":resources:images/test_textures/test_texture.png", build_mipmaps=True) - assert texture.read()[:4] == b'\x00\x00\xff\xff' # Blue + texture = ctx.load_texture( + ":resources:images/test_textures/test_texture.png", build_mipmaps=True + ) + assert texture.read()[:4] == b"\x00\x00\xff\xff" # Blue # Don't flip the texture - texture = ctx.load_texture(":resources:images/test_textures/test_texture.png", flip=False, build_mipmaps=True) - assert texture.read()[:4] == b'\xff\x00\x00\xff' # Red + texture = ctx.load_texture( + ":resources:images/test_textures/test_texture.png", flip=False, build_mipmaps=True + ) + assert texture.read()[:4] == b"\xff\x00\x00\xff" # Red def test_shader_include(ctx): diff --git a/tests/unit/gl/test_gl_framebuffer.py b/tests/unit/gl/test_gl_framebuffer.py index a809945d58..18f29f01f8 100644 --- a/tests/unit/gl/test_gl_framebuffer.py +++ b/tests/unit/gl/test_gl_framebuffer.py @@ -2,8 +2,10 @@ import arcade -def create(ctx, width, height, components=4, layers=1, dtype='f1'): - layers = [ctx.texture((width, height), components=components, dtype=dtype) for _ in range(layers)] +def create(ctx, width, height, components=4, layers=1, dtype="f1"): + layers = [ + ctx.texture((width, height), components=components, dtype=dtype) for _ in range(layers) + ] return ctx.framebuffer(color_attachments=layers) @@ -18,7 +20,7 @@ def test_properties(ctx): assert fb.viewport == (0, 0, 10, 20) assert fb.depth_attachment is None assert fb.depth_mask is True - assert repr(fb).startswith('= (4, 3): created, freed = ctx.stats.compute_shader - compute_shader = ctx.compute_shader(source=COMPUTE_SHADER_SOURCE) + compute_shader = ctx.compute_shader(source=COMPUTE_SHADER_SOURCE) assert ctx.stats.compute_shader == (created + 1, freed) compute_shader = None gc.collect() if ctx.gc_mode == "context_gc": collected = ctx.gc() assert collected > 0 - assert ctx.stats.compute_shader == (created + 1, freed + 1) + assert ctx.stats.compute_shader == (created + 1, freed + 1) # query created, freed = ctx.stats.query diff --git a/tests/unit/gl/test_gl_geometry.py b/tests/unit/gl/test_gl_geometry.py index a76b0971d1..8b1b5d6e9f 100644 --- a/tests/unit/gl/test_gl_geometry.py +++ b/tests/unit/gl/test_gl_geometry.py @@ -1,6 +1,7 @@ """ Low level tests for OpenGL 3.3 wrappers. """ + from arcade.gl import geometry diff --git a/tests/unit/gl/test_gl_texture.py b/tests/unit/gl/test_gl_texture.py index a78a9b31d4..3ff6272bb5 100644 --- a/tests/unit/gl/test_gl_texture.py +++ b/tests/unit/gl/test_gl_texture.py @@ -14,7 +14,7 @@ def test_default_properties(ctx): assert texture.filter == (ctx.LINEAR, ctx.LINEAR) assert texture.wrap_x == ctx.REPEAT assert texture.wrap_y == ctx.REPEAT - assert repr(texture).startswith(' tuple[NinePatchTexture, Image.Image,]: +) -> tuple[ + NinePatchTexture, + Image.Image, +]: """Create a ninepatch texture with the given borders.""" # Manually create a ninepatch texture. # We make it white by default and draw a red rectangle in the middle. @@ -31,14 +34,19 @@ def create_ninepatch( # NOTE: Pillow's 0,0 is top left, Arcade's is bottom left. patch_image = Image.new("RGBA", texture_size, (255, 255, 255, 255)) draw = ImageDraw.Draw(patch_image) - draw.rectangle((left, top, texture_size[0] - right - 1, texture_size[1] - bottom - 1), fill=(255, 0, 0, 255)) + draw.rectangle( + (left, top, texture_size[0] - right - 1, texture_size[1] - bottom - 1), + fill=(255, 0, 0, 255), + ) texture = arcade.Texture(patch_image) # patch_image.show() # Create the expected image expected_image = Image.new("RGBA", patch_size, (255, 255, 255, 255)) draw = ImageDraw.Draw(expected_image) - draw.rectangle((left, top, patch_size[0] - right - 1, patch_size[1] - bottom - 1), fill=(255, 0, 0, 255)) + draw.rectangle( + (left, top, patch_size[0] - right - 1, patch_size[1] - bottom - 1), fill=(255, 0, 0, 255) + ) return NinePatchTexture( texture=texture, @@ -67,7 +75,9 @@ def test_draw(ctx, fbo, left, right, bottom, top): ) with fbo.activate(): fbo.clear() - ctx.projection_matrix = Mat4.orthogonal_projection(0, PATCH_SIZE[0], 0, PATCH_SIZE[1], -100, 100) + ctx.projection_matrix = Mat4.orthogonal_projection( + 0, PATCH_SIZE[0], 0, PATCH_SIZE[1], -100, 100 + ) patch.draw_rect( rect=LBWH(0, 0, PATCH_SIZE[0], PATCH_SIZE[1]), pixelated=True, diff --git a/tests/unit/hitbox/test_black_image.py b/tests/unit/hitbox/test_black_image.py index 78624322bd..9558bf6a17 100644 --- a/tests/unit/hitbox/test_black_image.py +++ b/tests/unit/hitbox/test_black_image.py @@ -1,6 +1,7 @@ """ Test fallback for hitbox creation with empty textures. """ + from PIL import Image import arcade diff --git a/tests/unit/hitbox/test_hitbox.py b/tests/unit/hitbox/test_hitbox.py index 0cdde55057..ba601bd109 100644 --- a/tests/unit/hitbox/test_hitbox.py +++ b/tests/unit/hitbox/test_hitbox.py @@ -49,4 +49,4 @@ def test_create_rotatable(): rot_p = rot.get_adjusted_points() for i, (a, b) in enumerate(zip(rot_90, rot_p)): - assert a == pytest.approx(b, abs = 1e-6), f"[{i}] {a} != {b}" + assert a == pytest.approx(b, abs=1e-6), f"[{i}] {a} != {b}" diff --git a/tests/unit/hitbox/test_hitbox_algo_legacy.py b/tests/unit/hitbox/test_hitbox_algo_legacy.py index 7b4ed49de3..92e4972655 100644 --- a/tests/unit/hitbox/test_hitbox_algo_legacy.py +++ b/tests/unit/hitbox/test_hitbox_algo_legacy.py @@ -7,7 +7,7 @@ def test_calculate_hit_box_points_simple(): # Completely filled RGBA image image = Image.new("RGBA", (100, 100), (255, 255, 255, 255)) expected_points = ((-50.0, -50.0), (50.0, -50.0), (50.0, 50.0), (-50.0, 50.0)) - points = hitbox.calculate_hit_box_points_simple(image) + points = hitbox.calculate_hit_box_points_simple(image) assert points == expected_points # Fail trying RGB @@ -20,7 +20,7 @@ def test_calculate_hit_box_points_detailed(): # Completely filled RGBA image image = Image.new("RGBA", (100, 100), (255, 255, 255, 255)) expected_points = ((-50.0, -50.0), (50.0, -50.0), (50.0, 50.0), (-50.0, 50.0)) - points = hitbox.calculate_hit_box_points_detailed(image) + points = hitbox.calculate_hit_box_points_detailed(image) assert points == expected_points # Fail trying RGB diff --git a/tests/unit/paths/test_astar.py b/tests/unit/paths/test_astar.py index d7994a6c7a..4f2772a03e 100644 --- a/tests/unit/paths/test_astar.py +++ b/tests/unit/paths/test_astar.py @@ -1,6 +1,7 @@ """ Test for A-Star path routing """ + import arcade SPRITE_IMAGE_SIZE = 128 @@ -17,14 +18,18 @@ def test_astar(window): enemy_list = arcade.SpriteList() # Set up the player - player = arcade.Sprite(":resources:images/animated_characters/female_person/femalePerson_idle.png", - scale=SPRITE_SCALING) + player = arcade.Sprite( + ":resources:images/animated_characters/female_person/femalePerson_idle.png", + scale=SPRITE_SCALING, + ) player.center_x = SPRITE_SIZE * 1 player.center_y = SPRITE_SIZE * 1 player_list.append(player) # Set enemies - enemy = arcade.Sprite(":resources:images/animated_characters/zombie/zombie_idle.png", scale=SPRITE_SCALING) + enemy = arcade.Sprite( + ":resources:images/animated_characters/zombie/zombie_idle.png", scale=SPRITE_SCALING + ) enemy.center_x = SPRITE_SIZE * 5 enemy.center_y = SPRITE_SIZE * 5 enemy_list.append(enemy) @@ -46,30 +51,40 @@ def test_astar(window): # Note: If the enemy sprites are the same size, we only need to calculate # one of these. We do NOT need a different one for each enemy. The sprite # is just used for a size calculation. - barrier_list = arcade.AStarBarrierList(enemy, - wall_list, - grid_size, - playing_field_left_boundary, - playing_field_right_boundary, - playing_field_bottom_boundary, - playing_field_top_boundary) + barrier_list = arcade.AStarBarrierList( + enemy, + wall_list, + grid_size, + playing_field_left_boundary, + playing_field_right_boundary, + playing_field_bottom_boundary, + playing_field_top_boundary, + ) # print() - path = arcade.astar_calculate_path(enemy.position, - player.position, - barrier_list, - diagonal_movement=False) + path = arcade.astar_calculate_path( + enemy.position, player.position, barrier_list, diagonal_movement=False + ) # barrier_list.recalculate() # print(f"barrier_list: {barrier_list.barrier_list}") # print("Path 1", path) - assert path == [(160, 160), (128, 160), (128, 128), (96, 128), (96, 96), (64, 96), (64, 64), (32, 64), (32, 32)] - - path = arcade.astar_calculate_path(enemy.position, - player.position, - barrier_list, - diagonal_movement=True) + assert path == [ + (160, 160), + (128, 160), + (128, 128), + (96, 128), + (96, 96), + (64, 96), + (64, 64), + (32, 64), + (32, 32), + ] + + path = arcade.astar_calculate_path( + enemy.position, player.position, barrier_list, diagonal_movement=True + ) assert path == [(160, 160), (128, 128), (96, 96), (64, 64), (32, 32)] # print("Path 2", path) @@ -100,9 +115,17 @@ def test_astar(window): barrier_list.recalculate() - path = arcade.astar_calculate_path(enemy.position, - player.position, - barrier_list, - diagonal_movement=True) - - assert path == [(160, 160), (128, 160), (96, 192), (64, 160), (64, 128), (64, 96), (64, 64), (32, 32)] + path = arcade.astar_calculate_path( + enemy.position, player.position, barrier_list, diagonal_movement=True + ) + + assert path == [ + (160, 160), + (128, 160), + (96, 192), + (64, 160), + (64, 128), + (64, 96), + (64, 64), + (32, 32), + ] diff --git a/tests/unit/paths/test_line_of_sight.py b/tests/unit/paths/test_line_of_sight.py index 944b62f305..76afeff28d 100644 --- a/tests/unit/paths/test_line_of_sight.py +++ b/tests/unit/paths/test_line_of_sight.py @@ -2,11 +2,15 @@ def test_line_of_sight(window): - player = arcade.Sprite(":resources:images/animated_characters/female_person/femalePerson_idle.png") + player = arcade.Sprite( + ":resources:images/animated_characters/female_person/femalePerson_idle.png" + ) player.center_x = 0 player.center_y = 350 - enemy = arcade.Sprite(":resources:images/animated_characters/female_person/femalePerson_idle.png") + enemy = arcade.Sprite( + ":resources:images/animated_characters/female_person/femalePerson_idle.png" + ) enemy.center_x = 250 enemy.center_y = 350 diff --git a/tests/unit/physics_engine/test_physics_engine2.py b/tests/unit/physics_engine/test_physics_engine2.py index 0b50a60281..8fdeca83ea 100644 --- a/tests/unit/physics_engine/test_physics_engine2.py +++ b/tests/unit/physics_engine/test_physics_engine2.py @@ -1,4 +1,5 @@ -""" Physics engine tests. """ +"""Physics engine tests.""" + import copy import pytest @@ -17,9 +18,10 @@ def check_spritelists_prop_clears_instead_of_overwrites(engine, prop_name: str): * extra allocations from copying lists (see arcade.utils.Chain) * Avoid GC thrash from creating & deleting items - """ + """ + def _get_current(): - return getattr(engine, f'_{prop_name}') + return getattr(engine, f"_{prop_name}") original_list = _get_current() @@ -258,7 +260,8 @@ def simple_engine_tests(moving_sprite, wall_list, physics_engine): else: assert len(collisions) == 1 - check_spritelists_prop_clears_instead_of_overwrites(physics_engine, 'walls') + check_spritelists_prop_clears_instead_of_overwrites(physics_engine, "walls") + def platformer_tests(moving_sprite, wall_list, physics_engine): wall_sprite_1 = wall_list[0] @@ -327,8 +330,8 @@ def platformer_tests(moving_sprite, wall_list, physics_engine): collisions = physics_engine.update() assert moving_sprite.position == (3, -6) - check_spritelists_prop_clears_instead_of_overwrites(physics_engine, 'platforms') - check_spritelists_prop_clears_instead_of_overwrites(physics_engine, 'ladders') + check_spritelists_prop_clears_instead_of_overwrites(physics_engine, "platforms") + check_spritelists_prop_clears_instead_of_overwrites(physics_engine, "ladders") # Temp fix for https://github.com/pythonarcade/arcade/issues/2074 @@ -357,9 +360,7 @@ def test_main(window: arcade.Window): simple_engine_tests(moving_sprite, wall_list, physics_engine) nocopy_tests(physics_engine) - physics_engine = arcade.PhysicsEnginePlatformer( - moving_sprite, wall_list, gravity_constant=0.0 - ) + physics_engine = arcade.PhysicsEnginePlatformer(moving_sprite, wall_list, gravity_constant=0.0) basic_tests(moving_sprite, wall_list, physics_engine) platformer_tests(moving_sprite, wall_list, physics_engine) nocopy_tests(physics_engine) diff --git a/tests/unit/physics_engine/test_physics_engine_platformer.py b/tests/unit/physics_engine/test_physics_engine_platformer.py index 9818c2857b..a3fffc80f8 100644 --- a/tests/unit/physics_engine/test_physics_engine_platformer.py +++ b/tests/unit/physics_engine/test_physics_engine_platformer.py @@ -33,7 +33,7 @@ def test_physics_engine(window): center_x=64, center_y=256, ) - platform.boundary_left = 0 # 0 in particular was problematic, see #2658 + platform.boundary_left = 0 # 0 in particular was problematic, see #2658 platform.boundary_right = 128 platform.change_x = 8 platform_list.append(platform) @@ -65,7 +65,7 @@ def update(td): window.test(frames=7) assert physics_engine.can_jump() is True - assert platform.center_x == 80 # it bounced against the boundary_right + assert platform.center_x == 80 # it bounced against the boundary_right assert platform.change_x == -8 character_sprite.change_y = 15 physics_engine.increment_jump_counter() @@ -73,11 +73,11 @@ def update(td): window.test(frames=6) assert physics_engine.can_jump() is False - assert platform.center_x == 32 # right at the boundary - assert platform.change_x == -8 # still going left + assert platform.center_x == 32 # right at the boundary + assert platform.change_x == -8 # still going left physics_engine.disable_multi_jump() window.test(frames=3) - assert platform.center_x == 32 + 24 # it bounced against the boundary_left + assert platform.center_x == 32 + 24 # it bounced against the boundary_left assert platform.change_x == +8 diff --git a/tests/unit/physics_engine/test_pymunk.py b/tests/unit/physics_engine/test_pymunk.py index 838051caf4..dfcc07780d 100644 --- a/tests/unit/physics_engine/test_pymunk.py +++ b/tests/unit/physics_engine/test_pymunk.py @@ -7,8 +7,7 @@ def test_pymunk(): - physics_engine = arcade.PymunkPhysicsEngine(damping=1.0, - gravity=(0, -100)) + physics_engine = arcade.PymunkPhysicsEngine(damping=1.0, gravity=(0, -100)) my_sprite = arcade.SpriteSolidColor(50, 50, color=arcade.color.WHITE) @@ -18,20 +17,20 @@ def test_pymunk(): physics_engine.add_sprite(my_sprite) physics_engine.step(1.0) - assert(my_sprite.center_y == 0) + assert my_sprite.center_y == 0 physics_engine.step(1.0) - assert(my_sprite.center_y == -100.0) + assert my_sprite.center_y == -100.0 physics_engine.step(1.0) - assert(my_sprite.center_y == -300.0) + assert my_sprite.center_y == -300.0 # Temp fix for https://github.com/pythonarcade/arcade/issues/2074 def test_pymunk_engine_nocopy(): import copy - physics_engine = arcade.PymunkPhysicsEngine( - damping=1.0, gravity=(0, -100)) + + physics_engine = arcade.PymunkPhysicsEngine(damping=1.0, gravity=(0, -100)) with pytest.raises(NotImplementedError): _ = copy.copy(physics_engine) @@ -39,15 +38,12 @@ def test_pymunk_engine_nocopy(): _ = copy.deepcopy(physics_engine) -@pytest.mark.parametrize("moment_of_inertia_arg_name", - ( - "moment_of_inertia", - )) +@pytest.mark.parametrize("moment_of_inertia_arg_name", ("moment_of_inertia",)) def test_pymunk_add_sprite_moment_backwards_compatibility(moment_of_inertia_arg_name): """ Ensure that all supported kwarg aliases for moment of inertia work """ - physics_engine = arcade.PymunkPhysicsEngine(damping=1.0, gravity=(0,0)) + physics_engine = arcade.PymunkPhysicsEngine(damping=1.0, gravity=(0, 0)) sprite = arcade.SpriteSolidColor(32, 32, color=arcade.color.RED) @@ -71,10 +67,13 @@ def test_pymunk_hitbox_algorithm_trace_image_only_takes_rgba(): """ algo = PymunkHitBoxAlgorithm() + def mode(m: str) -> Image.Image: return Image.new( - m, # type: ignore - (10, 10), 0) + m, # type: ignore + (10, 10), + 0, + ) with pytest.raises(ValueError): algo.trace_image(mode("1")) @@ -91,6 +90,4 @@ def mode(m: str) -> Image.Image: with pytest.raises(ValueError): algo.trace_image(mode("HSV")) - assert isinstance( - algo.trace_image(mode("RGBA")), pymunk.autogeometry.PolylineSet) - + assert isinstance(algo.trace_image(mode("RGBA")), pymunk.autogeometry.PolylineSet) diff --git a/tests/unit/rect/test_rect_creation_helpers.py b/tests/unit/rect/test_rect_creation_helpers.py index 928145762d..f78cb24027 100644 --- a/tests/unit/rect/test_rect_creation_helpers.py +++ b/tests/unit/rect/test_rect_creation_helpers.py @@ -97,7 +97,6 @@ def test_kwargtangle_missing_args(): def test_kwargtangle_none_args(): - # LRBT with pytest.raises(ValueError): _ = Rect.from_kwargs(left=0, right=0, bottom=0, top=None) @@ -109,7 +108,7 @@ def test_kwargtangle_none_args(): _ = Rect.from_kwargs(left=0, right=None, top=0, bottom=0) with pytest.raises(ValueError): - _ = Rect.from_kwargs(left = None, right=0, top=0, bottom=0) + _ = Rect.from_kwargs(left=None, right=0, top=0, bottom=0) # LBWH with pytest.raises(ValueError): diff --git a/tests/unit/rect/test_rect_instances.py b/tests/unit/rect/test_rect_instances.py index 267a6c5d36..faed78c0ec 100644 --- a/tests/unit/rect/test_rect_instances.py +++ b/tests/unit/rect/test_rect_instances.py @@ -21,15 +21,11 @@ def test_attributes(): def test_equivalency(): assert ( - LBWH(10, 10, 10, 10) - == - LRBT(10, 20, 10, 20) - == - XYWH(15, 15, 10, 10) - == - XYRR(15, 15, 5, 5) - == - A_RECT + LBWH(10, 10, 10, 10) + == LRBT(10, 20, 10, 20) + == XYWH(15, 15, 10, 10) + == XYRR(15, 15, 5, 5) + == A_RECT ) @@ -93,27 +89,21 @@ def test_at_position(): def test_views(): assert A_RECT.lrbt == (10, 20, 10, 20) assert A_RECT.lbwh == (10, 10, 10, 10) - assert A_RECT.xyrr == (15, 15, 5, 5) + assert A_RECT.xyrr == (15, 15, 5, 5) assert A_RECT.xywh == (15, 15, 10, 10) -class SubclassedRect(Rect): - ... +class SubclassedRect(Rect): ... ALL_ZEROES = tuple((0 for _ in Rect._fields)) -def _formats_correctly( - func: Callable[[Any], str], - starts_with_format: str, - instance: Any -) -> bool: +def _formats_correctly(func: Callable[[Any], str], starts_with_format: str, instance: Any) -> bool: """True if func(instance) starts w/ its class name as specified.""" class_name = instance.__class__.__name__ - return func(instance).startswith( - starts_with_format.format(class_name=class_name)) + return func(instance).startswith(starts_with_format.format(class_name=class_name)) def test_repr(): diff --git a/tests/unit/resources/test_handles.py b/tests/unit/resources/test_handles.py index d36bd53ccc..6fa242e328 100644 --- a/tests/unit/resources/test_handles.py +++ b/tests/unit/resources/test_handles.py @@ -38,4 +38,3 @@ def test_add_handles(monkeypatch): def test_misc(): path = resources.resolve(":resources:images/cards/cardBack_blue1.png") assert resources.resolve(path) == path - diff --git a/tests/unit/scene/test_scene_dunder_methods.py b/tests/unit/scene/test_scene_dunder_methods.py index 191bf48fb6..e10f1bde2f 100644 --- a/tests/unit/scene/test_scene_dunder_methods.py +++ b/tests/unit/scene/test_scene_dunder_methods.py @@ -47,7 +47,7 @@ def test_contains(): scene = arcade.Scene() assert "Walls" not in scene assert None not in scene - + walls_spriteList = arcade.SpriteList() scene.add_sprite_list("Walls", use_spatial_hash=True, sprite_list=walls_spriteList) assert "Walls" in scene diff --git a/tests/unit/scene/test_scene_remove_sprite_lists.py b/tests/unit/scene/test_scene_remove_sprite_lists.py index 2a8f2901a6..4dcd35571b 100644 --- a/tests/unit/scene/test_scene_remove_sprite_lists.py +++ b/tests/unit/scene/test_scene_remove_sprite_lists.py @@ -1,6 +1,7 @@ import arcade import pytest + def test_remove_sprite_list_by_index(): scene = arcade.Scene() scene.add_sprite_list("Player") @@ -10,6 +11,7 @@ def test_remove_sprite_list_by_index(): with pytest.raises(KeyError): scene["Player"] + def test_remove_sprite_list_by_name(): scene = arcade.Scene() scene.add_sprite_list("Walls") @@ -19,6 +21,7 @@ def test_remove_sprite_list_by_name(): with pytest.raises(KeyError): scene["Walls"] + def test_remove_sprite_list_by_object(): scene = arcade.Scene() scene.add_sprite_list("Coins") diff --git a/tests/unit/shape_list/test_buffered_drawing.py b/tests/unit/shape_list/test_buffered_drawing.py index 990ae670d8..715b95f258 100644 --- a/tests/unit/shape_list/test_buffered_drawing.py +++ b/tests/unit/shape_list/test_buffered_drawing.py @@ -23,7 +23,6 @@ @pytest.fixture def shape_list_instance() -> ShapeElementList: - shape_list = ShapeElementList() center_x = 0 @@ -34,11 +33,15 @@ def shape_list_instance() -> ShapeElementList: shape_list.append(shape) center_x += 40 - shape = create_ellipse_outline(center_x, center_y, width, height, arcade.color.RED, border_width=1) + shape = create_ellipse_outline( + center_x, center_y, width, height, arcade.color.RED, border_width=1 + ) shape_list.append(shape) center_x += 40 - shape = create_ellipse_outline(center_x, center_y, width, height, arcade.color.DARK_RED, border_width=1) + shape = create_ellipse_outline( + center_x, center_y, width, height, arcade.color.DARK_RED, border_width=1 + ) shape_list.append(shape) shape = create_line(0, 0, 80, 0, arcade.color.BLUE, line_width=1) @@ -54,24 +57,24 @@ def shape_list_instance() -> ShapeElementList: outside_color = arcade.color.AERO_BLUE inside_color = arcade.color.AFRICAN_VIOLET tilt_angle = 45 - shape = create_ellipse_filled_with_colors(center_x, center_y, - width, height, - outside_color, inside_color, - tilt_angle) + shape = create_ellipse_filled_with_colors( + center_x, center_y, width, height, outside_color, inside_color, tilt_angle + ) shape_list.append(shape) center_x = 0 center_y = -50 width = 20 height = 20 - shape = create_rectangle_filled(center_x, center_y, width, height, - arcade.color.WHITE) + shape = create_rectangle_filled(center_x, center_y, width, height, arcade.color.WHITE) shape_list.append(shape) - shape = create_rectangle_outline(center_x, center_y, width, height, - arcade.color.BLACK, border_width=1) + shape = create_rectangle_outline( + center_x, center_y, width, height, arcade.color.BLACK, border_width=1 + ) shape_list.append(shape) - shape = create_rectangle_outline(center_x, center_y, width, height, - arcade.color.AMERICAN_ROSE, border_width=1) + shape = create_rectangle_outline( + center_x, center_y, width, height, arcade.color.AMERICAN_ROSE, border_width=1 + ) shape_list.append(shape) color1 = (215, 214, 165) @@ -93,7 +96,6 @@ def shape_list_instance() -> ShapeElementList: def test_shape_copy_dunders_raise_notimplemented_error(window, shape_list_instance): - for shape in shape_list_instance: with pytest.raises(NotImplementedError): _ = copy.copy(shape) @@ -103,7 +105,6 @@ def test_shape_copy_dunders_raise_notimplemented_error(window, shape_list_instan # Temp fix for https://github.com/pythonarcade/arcade/issues/2074 def test_shapeelementlist_copy_dunders_raise_notimplemented_error(window, shape_list_instance): - with pytest.raises(NotImplementedError): _ = copy.copy(shape_list_instance) @@ -112,7 +113,6 @@ def test_shapeelementlist_copy_dunders_raise_notimplemented_error(window, shape_ def test_buffered_drawing(window, shape_list_instance): - shape_list_instance.center_x = 200 shape_list_instance.center_y = 200 diff --git a/tests/unit/shape_list/test_buffered_line_strip.py b/tests/unit/shape_list/test_buffered_line_strip.py index caa3caa4df..4b58c59699 100644 --- a/tests/unit/shape_list/test_buffered_line_strip.py +++ b/tests/unit/shape_list/test_buffered_line_strip.py @@ -6,10 +6,7 @@ def test_buffered_lines(window): window.background_color = arcade.color.WHITE window.clear() - point_list = ([0, 100], - [100, 100], - [100, 300], - [300, 300]) + point_list = ([0, 100], [100, 100], [100, 300], [300, 300]) line_strip = shape_list.create_line_strip(point_list, arcade.csscolor.BLACK, 10) line_strip.draw() diff --git a/tests/unit/sprite/test_copy_dunders.py b/tests/unit/sprite/test_copy_dunders.py index 02f6fbfd7e..6975b1e76e 100644 --- a/tests/unit/sprite/test_copy_dunders.py +++ b/tests/unit/sprite/test_copy_dunders.py @@ -12,7 +12,9 @@ def test_copy_dunders_raise_notimplementederror(): """ # Make sure BasicSprite raises NotImplementedError - texture = arcade.load_texture(":resources:images/animated_characters/female_person/femalePerson_idle.png") + texture = arcade.load_texture( + ":resources:images/animated_characters/female_person/femalePerson_idle.png" + ) basic_sprite = arcade.BasicSprite(texture) with pytest.raises(NotImplementedError): diff --git a/tests/unit/sprite/test_sprite.py b/tests/unit/sprite/test_sprite.py index b7d7bee261..2201a15629 100644 --- a/tests/unit/sprite/test_sprite.py +++ b/tests/unit/sprite/test_sprite.py @@ -1,13 +1,16 @@ """ Strictly unit tests for the sprite class. """ + import pytest as pytest import arcade from pyglet.math import Vec2 frame_counter = 0 -SPRITE_TEXTURE_FEMALE_PERSON_IDLE = arcade.load_texture(":resources:images/animated_characters/female_person/femalePerson_idle.png") +SPRITE_TEXTURE_FEMALE_PERSON_IDLE = arcade.load_texture( + ":resources:images/animated_characters/female_person/femalePerson_idle.png" +) SPRITE_TEXTURE_GOLD_COIN = arcade.load_texture(":resources:images/items/coinGold.png") @@ -69,9 +72,7 @@ def test_set_size(): sprite.size = 1 -@pytest.mark.parametrize('not_a_texture', [ - 1, "not_a_texture", (1, 2, 3) -]) +@pytest.mark.parametrize("not_a_texture", [1, "not_a_texture", (1, 2, 3)]) def test_sprite_texture_setter_raises_type_error_when_given_non_texture(not_a_texture): sprite = arcade.Sprite(SPRITE_TEXTURE_GOLD_COIN, scale=1.0) with pytest.raises(TypeError): @@ -125,6 +126,7 @@ def test_sprite_rgb_property_basics(): assert sprite.color.a == 15 assert sprite.alpha == 15 + def test_sprite_scale_constructor(window): sprite = arcade.BasicSprite(SPRITE_TEXTURE_GOLD_COIN, scale=2.0) assert sprite.scale == (2.0, 2.0) @@ -137,7 +139,7 @@ def test_sprite_scale_constructor(window): sprite = arcade.Sprite(SPRITE_TEXTURE_GOLD_COIN, scale=(1.0, 2.0, 10.0, 100.0)) assert sprite.scale == (1.0, 2.0) with pytest.raises(TypeError): - sprite = arcade.sprite(SPRITE_TEXTURE_GOLD_COIN, scale = test_sprite_scale_constructor) + sprite = arcade.sprite(SPRITE_TEXTURE_GOLD_COIN, scale=test_sprite_scale_constructor) sprite = arcade.Sprite(SPRITE_TEXTURE_GOLD_COIN, scale=1.0) assert isinstance(sprite.scale, tuple) @@ -258,6 +260,7 @@ def test_sprite_scale_resets_mismatched_xy_settings(window): assert sprite.width == 40 assert sprite.height == 40 + def test_sprite_scale_invalid(window): sprite = arcade.Sprite(SPRITE_TEXTURE_FEMALE_PERSON_IDLE) @@ -266,6 +269,7 @@ def test_sprite_scale_invalid(window): with pytest.raises(TypeError): sprite.scale = test_sprite_scale_invalid + # TODO: Possibly separate into a movement module def test_strafe(window): """Test if strafe moves the sprite in the correct direction.""" @@ -310,10 +314,7 @@ def sprite_64x64_at_position(x, y): # expected: # 1. sprite is 3.31 times further away from origin to upper right # 2. sprite is now 3.31 times larger - sprite_1 = sprite_64x64_at_position( - window_center_x + 50, - window_center_y - 50 - ) + sprite_1 = sprite_64x64_at_position(window_center_x + 50, window_center_y - 50) sprite_1.rescale_relative_to_point((0, 0), 3.31) assert sprite_1.scale == (3.31, 3.31) assert sprite_1.center_x == (window_center_x + 50) * 3.31 @@ -328,10 +329,7 @@ def sprite_64x64_at_position(x, y): # result: # 1. sprite distance doubled # 2. sprite scale doubled - sprite_2 = sprite_64x64_at_position( - window_center_x + 10, - window_center_y + 10 - ) + sprite_2 = sprite_64x64_at_position(window_center_x + 10, window_center_y + 10) sprite_2.scale = (2.0, 1.0) sprite_2.rescale_relative_to_point(window_center, 2.0) assert sprite_2.scale == (4.0, 2.0) @@ -347,10 +345,7 @@ def sprite_64x64_at_position(x, y): # result: # 1. sprite distance tripled # 2. sprite scale tripled - sprite_3 = sprite_64x64_at_position( - window_center_x - 10, - window_center_y - 10 - ) + sprite_3 = sprite_64x64_at_position(window_center_x - 10, window_center_y - 10) sprite_3.scale = (0.5, 1.5) sprite_3.rescale_relative_to_point(window_center, 3.0) assert sprite_3.scale == (1.5, 4.5) @@ -381,10 +376,7 @@ def sprite_64x64_at_position(x, y): # edge case: point != sprite center, factor == 1.0 # expected : no movement or size change occurs - sprite_6 = sprite_64x64_at_position( - window_center_x - 81, - window_center_y + 81 - ) + sprite_6 = sprite_64x64_at_position(window_center_x - 81, window_center_y + 81) sprite_6.rescale_relative_to_point((50, 40), 1.0) assert sprite_6.scale == (1.0, 1.0) assert sprite_6.center_x == window_center_x - 81 @@ -394,10 +386,7 @@ def sprite_64x64_at_position(x, y): # edge case: point != sprite center, factor == 1.0 # expected : no movement or size change occurs - sprite_7 = sprite_64x64_at_position( - window_center_x - 81, - window_center_y + 81 - ) + sprite_7 = sprite_64x64_at_position(window_center_x - 81, window_center_y + 81) sprite_7.rescale_relative_to_point((50, 40), 1.0) assert sprite_7.scale == (1.0, 1.0) assert sprite_7.center_x == window_center_x - 81 @@ -409,10 +398,7 @@ def sprite_64x64_at_position(x, y): # expected : # 1. sprite teleports to opposite side of point # 2. sprite has negative versions of scale data - sprite_8 = sprite_64x64_at_position( - window_center_x - 81, - window_center_y + 81 - ) + sprite_8 = sprite_64x64_at_position(window_center_x - 81, window_center_y + 81) sprite_8.rescale_relative_to_point(window_center, -1.0) assert sprite_8.scale == (-1.0, -1.0) assert sprite_8.center_x == window_center_x + 81 @@ -426,16 +412,10 @@ def test_rescale_relative_to_point_with_vec_quants(window): window_center_x, window_center_y = window_center def sprite_64x64_at_position(x, y): - return arcade.Sprite( - ":resources:images/items/gold_1.png", - center_x=x, center_y=y - ) + return arcade.Sprite(":resources:images/items/gold_1.png", center_x=x, center_y=y) # sprite with initial _scale[0] == _scale[1] works with identical scale - sprite_1 = sprite_64x64_at_position( - window_center_x + 50, - window_center_y - 50 - ) + sprite_1 = sprite_64x64_at_position(window_center_x + 50, window_center_y - 50) sprite_1.rescale_relative_to_point((0, 0), (3.31, 3.31)) assert sprite_1.scale == (3.31, 3.31) assert sprite_1.center_x == (window_center_x + 50) * 3.31 @@ -444,10 +424,7 @@ def sprite_64x64_at_position(x, y): assert sprite_1.height == 64 * 3.31 # sprite with x scale > y scale works correctly - sprite_2 = sprite_64x64_at_position( - window_center_x + 10, - window_center_y + 10 - ) + sprite_2 = sprite_64x64_at_position(window_center_x + 10, window_center_y + 10) sprite_2.scale = (2.0, 1.0) sprite_2.rescale_relative_to_point(window_center, (2.0, 2.0)) assert sprite_2.scale == (4.0, 2.0) @@ -457,10 +434,7 @@ def sprite_64x64_at_position(x, y): assert sprite_2.height == 128 # sprite with y scale > x scale works correctly - sprite_3 = sprite_64x64_at_position( - window_center_x - 10, - window_center_y - 10 - ) + sprite_3 = sprite_64x64_at_position(window_center_x - 10, window_center_y - 10) sprite_3.scale = (0.5, 1.5) sprite_3.rescale_relative_to_point(window_center, (3.0, 3.0)) assert sprite_3.scale == (1.5, 4.5) @@ -481,10 +455,7 @@ def sprite_64x64_at_position(x, y): # edge case: point != sprite center, factor == 1.0 # expected : no movement or size change occurs - sprite_5 = sprite_64x64_at_position( - window_center_x - 81, - window_center_y + 81 - ) + sprite_5 = sprite_64x64_at_position(window_center_x - 81, window_center_y + 81) sprite_5.rescale_relative_to_point((50, 40), (1.0, 1.0)) assert sprite_5.scale == (1.0, 1.0) assert sprite_5.center_x == window_center_x - 81 @@ -504,10 +475,7 @@ def sprite_64x64_at_position(x, y): # edge case: point != sprite center, factor == 1.0 # expected : no movement or size change occurs - sprite_7 = sprite_64x64_at_position( - window_center_x - 81, - window_center_y + 81 - ) + sprite_7 = sprite_64x64_at_position(window_center_x - 81, window_center_y + 81) sprite_7.rescale_relative_to_point((50, 40), (1.0, 1.0)) assert sprite_7.scale == (1.0, 1.0) assert sprite_7.center_x == window_center_x - 81 diff --git a/tests/unit/sprite/test_sprite_animated_walking.py b/tests/unit/sprite/test_sprite_animated_walking.py index fb2bfee07f..46cf3ca92c 100644 --- a/tests/unit/sprite/test_sprite_animated_walking.py +++ b/tests/unit/sprite/test_sprite_animated_walking.py @@ -19,19 +19,34 @@ def test_sprite_animated_old(window: arcade.Window): player.scale = 1.0 player.stand_right_textures = [] player.stand_right_textures.append( - arcade.load_texture(":resources:images/animated_characters/female_person/femalePerson_idle.png")) + arcade.load_texture( + ":resources:images/animated_characters/female_person/femalePerson_idle.png" + ) + ) player.stand_left_textures = [tex.flip_left_right() for tex in player.stand_right_textures] player.walk_right_textures = [] player.walk_right_textures.append( - arcade.load_texture(":resources:images/animated_characters/female_person/femalePerson_walk0.png")) + arcade.load_texture( + ":resources:images/animated_characters/female_person/femalePerson_walk0.png" + ) + ) player.walk_right_textures.append( - arcade.load_texture(":resources:images/animated_characters/female_person/femalePerson_walk1.png")) + arcade.load_texture( + ":resources:images/animated_characters/female_person/femalePerson_walk1.png" + ) + ) player.walk_right_textures.append( - arcade.load_texture(":resources:images/animated_characters/female_person/femalePerson_walk2.png")) + arcade.load_texture( + ":resources:images/animated_characters/female_person/femalePerson_walk2.png" + ) + ) player.walk_right_textures.append( - arcade.load_texture(":resources:images/animated_characters/female_person/femalePerson_walk3.png")) + arcade.load_texture( + ":resources:images/animated_characters/female_person/femalePerson_walk3.png" + ) + ) player.walk_left_textures = [tex.flip_left_right() for tex in player.walk_right_textures] diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index 33d6803e28..dce9fd284b 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -4,12 +4,11 @@ def test_sprites_at_point(): - coin_list = arcade.SpriteList() sprite = arcade.SpriteSolidColor(50, 50, color=arcade.csscolor.RED) # an adjacent sprite with the same level horizontal bottom edge sprite2 = arcade.SpriteSolidColor(50, 50, center_x=50, center_y=0, color=arcade.csscolor.RED) - + coin_list.append(sprite) coin_list.append(sprite2) @@ -97,7 +96,6 @@ def test_sprite_collides_with_sprite(): sprite_two.center_x = 10 assert sprite_one.collides_with_sprite(sprite_two) is False - # Borders, opposite side sprite_two.center_x = -10 assert sprite_one.collides_with_sprite(sprite_two) is False diff --git a/tests/unit/sprite/test_sprite_colored.py b/tests/unit/sprite/test_sprite_colored.py index 1cad4df8a9..0470683118 100644 --- a/tests/unit/sprite/test_sprite_colored.py +++ b/tests/unit/sprite/test_sprite_colored.py @@ -1,6 +1,7 @@ """ SpriteCircle and SpriteSolidColor """ + import arcade diff --git a/tests/unit/sprite/test_sprite_hitbox.py b/tests/unit/sprite/test_sprite_hitbox.py index 5a44ba8b80..13bbf1f407 100644 --- a/tests/unit/sprite/test_sprite_hitbox.py +++ b/tests/unit/sprite/test_sprite_hitbox.py @@ -4,9 +4,7 @@ def test_1(): # setup - my_sprite = arcade.Sprite( - arcade.make_soft_square_texture(20, arcade.color.RED, 0, 255) - ) + my_sprite = arcade.Sprite(arcade.make_soft_square_texture(20, arcade.color.RED, 0, 255)) hit_box = arcade.hitbox.HitBox(((-10, -10), (-10, 10), (10, 10), (10, -10))) my_sprite.hit_box = hit_box my_sprite.scale = 1.0 diff --git a/tests/unit/spritelist/test_spatial_hash.py b/tests/unit/spritelist/test_spatial_hash.py index 4e02ea448a..91a316f24d 100644 --- a/tests/unit/spritelist/test_spatial_hash.py +++ b/tests/unit/spritelist/test_spatial_hash.py @@ -10,14 +10,17 @@ def test_create(): assert sh.buckets_for_sprite == {} assert sh.count == 0 + def test_incorrect_str_input(): with pytest.raises(TypeError): sh = SpatialHash(cell_size="10") - + + def test_incorrect_inf_input(): with pytest.raises(TypeError): sh = SpatialHash(cell_size=float("inf")) + def test_reset(): sh = SpatialHash(cell_size=10) sh.add(arcade.SpriteSolidColor(10, 10, color=arcade.color.RED)) @@ -85,16 +88,12 @@ def get_nearby_sprites(): sh.add(sprite_1) sh.add(sprite_2) - nearby_sprites = sh.get_sprites_near_sprite( - arcade.SpriteSolidColor(10, 10, center_x=-5) - ) + nearby_sprites = sh.get_sprites_near_sprite(arcade.SpriteSolidColor(10, 10, center_x=-5)) assert isinstance(nearby_sprites, set) assert len(nearby_sprites) == 1 assert nearby_sprites[0] == sprite_1 - nearby_sprites = sh.get_sprites_near_sprite( - arcade.SpriteSolidColor(10, 10, center_x=0) - ) + nearby_sprites = sh.get_sprites_near_sprite(arcade.SpriteSolidColor(10, 10, center_x=0)) assert isinstance(nearby_sprites, set) assert len(nearby_sprites) == 2 assert nearby_sprites == set([sprite_1, sprite_2]) diff --git a/tests/unit/spritelist/test_spritelist.py b/tests/unit/spritelist/test_spritelist.py index be20383c37..53187f2ee5 100644 --- a/tests/unit/spritelist/test_spritelist.py +++ b/tests/unit/spritelist/test_spritelist.py @@ -47,10 +47,10 @@ def test_copy_dunder_stubs_raise_notimplementederror(): import copy with pytest.raises(NotImplementedError): - _ = copy.copy(spritelist) + _ = copy.copy(spritelist) with pytest.raises(NotImplementedError): - _ = copy.deepcopy(spritelist) + _ = copy.deepcopy(spritelist) def test_it_can_extend_a_spritelist_from_a_list(): @@ -93,7 +93,7 @@ def sprite_grid_generator(cols: int, rows: int, cell_size: float): height=32, color=arcade.color.RED, center_x=col * cell_size, - center_y=row * cell_size + center_y=row * cell_size, ) sprite_list.extend(sprite_grid_generator(3, 5, 1.0)) @@ -183,7 +183,9 @@ def test_can_shuffle(ctx): spritelist.draw() # Ensure the index buffer is referring to the correct slots # Raw buffer from OpenGL - index_data = struct.unpack(f"{num_sprites}i", spritelist._sprite_index_buf.read()[:num_sprites * 4]) + index_data = struct.unpack( + f"{num_sprites}i", spritelist._sprite_index_buf.read()[: num_sprites * 4] + ) for i, sprite in enumerate(spritelist): # Check if slots are updated slot = spritelist.sprite_slot[sprite] @@ -219,7 +221,7 @@ def test_sort(ctx): assert spritelist._sprite_index_data[0:3] == array("f", [0, 1, 2]) -@pytest.mark.parametrize('capacity', (128, 512, 1024)) +@pytest.mark.parametrize("capacity", (128, 512, 1024)) def test_clear(ctx, capacity): sp = arcade.SpriteList(capacity=capacity) sp.clear(capacity=None) @@ -259,7 +261,7 @@ def test_color(): # Alpha sp.alpha = 172 assert sp.alpha == 172 - assert sp.alpha_normalized == pytest.approx(172/255, rel=0.01) + assert sp.alpha_normalized == pytest.approx(172 / 255, rel=0.01) # Setting float RGBA works sp.color_normalized = 0.1, 0.2, 0.3, 0.4 @@ -295,7 +297,7 @@ def test_swap(window): sprites = [ arcade.SpriteSolidColor(10, 10, color=arcade.color.RED), - arcade.SpriteSolidColor(10, 10, color=arcade.color.GREEN) + arcade.SpriteSolidColor(10, 10, color=arcade.color.GREEN), ] sl = arcade.SpriteList() sl.extend(sprites) diff --git a/tests/unit/spritelist/test_spritelist_buffers.py b/tests/unit/spritelist/test_spritelist_buffers.py index 10648fe40c..5b8aa89a3c 100644 --- a/tests/unit/spritelist/test_spritelist_buffers.py +++ b/tests/unit/spritelist/test_spritelist_buffers.py @@ -2,6 +2,7 @@ Ensure internal buffers are sized correctly. Incorrectly sized buffers can lead to segfaults. """ + import struct import arcade @@ -100,12 +101,12 @@ def test_buffer_sizes(ctx: arcade.ArcadeContext): # Test the contents of the arrays and buffers. # Prepare expected data - expected_pos_data = struct.pack('12f', *[v for p in positions for v in p]) - expected_color_data = struct.pack('16B', *[v for c in colors for v in c]) - expected_size_data = struct.pack('8f', *[v for s in sizes for v in s]) - expected_angle_data = struct.pack('4f', *angles) + expected_pos_data = struct.pack("12f", *[v for p in positions for v in p]) + expected_color_data = struct.pack("16B", *[v for c in colors for v in c]) + expected_size_data = struct.pack("8f", *[v for s in sizes for v in s]) + expected_angle_data = struct.pack("4f", *angles) expected_texture_data = struct.pack( - '4f', + "4f", *[ctx.default_atlas.get_texture_id(sprite.texture) for sprite in sprites], ) @@ -124,4 +125,4 @@ def test_buffer_sizes(ctx: arcade.ArcadeContext): assert sp._sprite_texture_data.tobytes() == expected_texture_data # Index buffer - assert sp._sprite_index_buf.read() == struct.pack('4i', 0, 1, 2, 3) + assert sp._sprite_index_buf.read() == struct.pack("4i", 0, 1, 2, 3) diff --git a/tests/unit/spritelist/test_spritelist_draw.py b/tests/unit/spritelist/test_spritelist_draw.py index c19cbcd191..eea6a3a5c8 100644 --- a/tests/unit/spritelist/test_spritelist_draw.py +++ b/tests/unit/spritelist/test_spritelist_draw.py @@ -6,9 +6,11 @@ def test_visible(window, monkeypatch): """Ensure invisible spritelists are not drawn""" sp = arcade.SpriteList() + # Monkeypatch Geometry.render to raise an error if called def mock_draw(*args, **kwargs): raise AssertionError("Should not be called") + monkeypatch.setattr(Geometry, "render", mock_draw) # Empty spritelist should not be rendered diff --git a/tests/unit/spritelist/test_spritelist_lazy.py b/tests/unit/spritelist/test_spritelist_lazy.py index 4d4781e8e9..014600d186 100644 --- a/tests/unit/spritelist/test_spritelist_lazy.py +++ b/tests/unit/spritelist/test_spritelist_lazy.py @@ -14,9 +14,7 @@ def test_create_lazy_equals_true(): # Make sure CPU-only behavior still works correctly for x in range(100): - spritelist.append( - arcade.Sprite(":resources:images/items/coinGold.png", center_x=x * 64) - ) + spritelist.append(arcade.Sprite(":resources:images/items/coinGold.png", center_x=x * 64)) assert len(spritelist) == 100 assert spritelist.spatial_hash is not None assert spritelist._initialized is False @@ -37,7 +35,7 @@ def test_manual_initialization_after_lazy_equals_true(window): spritelist.remove(sprite) # Make sure initialization still worked correctly. - spritelist.initialize() + spritelist.initialize() assert spritelist._initialized assert spritelist._sprite_pos_buf assert spritelist._geometry diff --git a/tests/unit/spritelist/test_spritesequence.py b/tests/unit/spritelist/test_spritesequence.py index 454ba7a7cb..2212c241e0 100644 --- a/tests/unit/spritelist/test_spritesequence.py +++ b/tests/unit/spritelist/test_spritesequence.py @@ -1,8 +1,10 @@ import arcade + class _CustomSpriteSolidColor(arcade.SpriteSolidColor): pass + def test_collective_draw(window: arcade.Window) -> None: sprite_list1: arcade.SpriteList[arcade.Sprite] = arcade.SpriteList() sprite_list1.append(arcade.SpriteSolidColor(16, 16, color=(255, 0, 0, 1))) @@ -11,7 +13,7 @@ def test_collective_draw(window: arcade.Window) -> None: sprite_list2.append(_CustomSpriteSolidColor(16, 16, color=(255, 0, 0, 1))) # It really is a SpriteList with a good type; this would not typecheck otherwise - custom_sprite: _CustomSpriteSolidColor = sprite_list2[0] # assert_type + custom_sprite: _CustomSpriteSolidColor = sprite_list2[0] # assert_type # Assert that SpriteSequence is truly covariant: # It can be used as a common type for different types of SpriteLists. @@ -19,7 +21,7 @@ def test_collective_draw(window: arcade.Window) -> None: sprite_list1, sprite_list2, ] - sprite: arcade.Sprite = scene[0][0] # assert_type + sprite: arcade.Sprite = scene[0][0] # assert_type # We can collectively draw all the SpriteSequences. for sprite_list in scene: diff --git a/tests/unit/test_arcade.py b/tests/unit/test_arcade.py index 56e05ac4a0..243afe5cad 100644 --- a/tests/unit/test_arcade.py +++ b/tests/unit/test_arcade.py @@ -12,8 +12,9 @@ def test_import(): """Compare arcade.__all__ to the actual module contents""" import arcade - global_names = set(k for k in globals() if not k.startswith('_')) - arcade_names = set(k for k in arcade.__dict__ if not k.startswith('_')) + + global_names = set(k for k in globals() if not k.startswith("_")) + arcade_names = set(k for k in arcade.__dict__ if not k.startswith("_")) # Get the common members common = global_names.intersection(arcade_names) @@ -23,7 +24,7 @@ def test_import(): attr_type = type(attr) if attr_type in builtin_types: remaining.remove(name) - elif not attr.__module__.startswith('arcade.'): + elif not attr.__module__.startswith("arcade."): remaining.remove(name) assert len(remaining) == 0 @@ -31,5 +32,5 @@ def test_import(): def test_logging(): arcade.configure_logging(logging.WARNING) - logger = logging.getLogger('arcade') + logger = logging.getLogger("arcade") assert logger.level == logging.WARNING diff --git a/tests/unit/test_clock.py b/tests/unit/test_clock.py index 7b6f75c71a..abed4d9a1e 100644 --- a/tests/unit/test_clock.py +++ b/tests/unit/test_clock.py @@ -2,22 +2,23 @@ from arcade.clock import Clock, FixedClock, GLOBAL_CLOCK, GLOBAL_FIXED_CLOCK + def test_clock(): # GLOBAL_CLOCK.set_tick_speed(1.0) time = GLOBAL_CLOCK.time ticks = GLOBAL_CLOCK.ticks - GLOBAL_CLOCK.tick(1.0/60.0) - assert GLOBAL_CLOCK.time == time + 1.0/60.0 + GLOBAL_CLOCK.tick(1.0 / 60.0) + assert GLOBAL_CLOCK.time == time + 1.0 / 60.0 assert GLOBAL_CLOCK.ticks == ticks + 1 - assert GLOBAL_CLOCK.delta_time == 1.0/60.0 + assert GLOBAL_CLOCK.delta_time == 1.0 / 60.0 - GLOBAL_CLOCK.set_max_deltatime(1.0/100.0) + GLOBAL_CLOCK.set_max_deltatime(1.0 / 100.0) GLOBAL_CLOCK.tick(1.0 / 60.0) - assert GLOBAL_CLOCK.time == time + 1.0/60.0 + 1.0 / 100.0 + assert GLOBAL_CLOCK.time == time + 1.0 / 60.0 + 1.0 / 100.0 assert GLOBAL_CLOCK.ticks == ticks + 2 - assert GLOBAL_CLOCK.delta_time == 1.0/100.0 + assert GLOBAL_CLOCK.delta_time == 1.0 / 100.0 GLOBAL_CLOCK.set_max_deltatime() with pytest.raises(ValueError): - GLOBAL_FIXED_CLOCK.tick(1.0) \ No newline at end of file + GLOBAL_FIXED_CLOCK.tick(1.0) diff --git a/tests/unit/test_example_docstrings.py b/tests/unit/test_example_docstrings.py index 9bda1cd8f2..f70bb76c1e 100644 --- a/tests/unit/test_example_docstrings.py +++ b/tests/unit/test_example_docstrings.py @@ -62,7 +62,6 @@ def check_submodules(parent_module_absolute_name: str) -> None: # Check all modules nested immediately inside it on the file system for finder, child_module_name, is_pkg in pkgutil.iter_modules(parent_module_file_path): - child_module_file_path = Path(finder.path) / f"{child_module_name}.py" child_module_absolute_name = f"{parent_module_absolute_name}.{child_module_name}" @@ -80,7 +79,6 @@ def test_docstrings(): # For each immediate child folder module in arcade.examples, # check the immediate child python files for correct docstrings. for folder_submodule_path in Path(arcade.examples.__path__[0]).iterdir(): - # Skip file modules we already covered above outside the loop if not folder_submodule_path.is_dir(): continue diff --git a/tests/unit/test_isometric.py b/tests/unit/test_isometric.py index 1df2d1ec4b..07cdc3b775 100644 --- a/tests/unit/test_isometric.py +++ b/tests/unit/test_isometric.py @@ -13,9 +13,7 @@ def test_isometric_grid_to_screen(window): height = 10 tile_width = 64 tile_height = 64 - x, y = isometric_grid_to_screen(tile_x, tile_y, - width, height, - tile_width, tile_height) + x, y = isometric_grid_to_screen(tile_x, tile_y, width, height, tile_width, tile_height) assert x == 320 assert y == 608 @@ -25,9 +23,7 @@ def test_isometric_grid_to_screen(window): height = 10 tile_width = 64 tile_height = 64 - x, y = isometric_grid_to_screen(tile_x, tile_y, - width, height, - tile_width, tile_height) + x, y = isometric_grid_to_screen(tile_x, tile_y, width, height, tile_width, tile_height) assert x == 320 assert y == 480 @@ -39,20 +35,19 @@ def test_screen_to_isometric_grid(window): height = 10 tile_width = 64 tile_height = 64 - x, y = screen_to_isometric_grid(screen_x, screen_y, - width, height, - tile_width, tile_height) + x, y = screen_to_isometric_grid(screen_x, screen_y, width, height, tile_width, tile_height) print(x, y) assert x == 4 assert y == 14 + def test_create_isometric_grid_lines(window): width = 10 height = 10 tile_width = 64 tile_height = 64 - lines = create_isometric_grid_lines(width, height, - tile_width, tile_height, - arcade.color.BLACK, 2) + lines = create_isometric_grid_lines( + width, height, tile_width, tile_height, arcade.color.BLACK, 2 + ) assert lines diff --git a/tests/unit/test_key.py b/tests/unit/test_key.py index 2712eddf4b..b039c5cdb4 100644 --- a/tests/unit/test_key.py +++ b/tests/unit/test_key.py @@ -1,8 +1,5 @@ - def test_key(): from arcade import key - names = [ - k for k in key.__dict__.keys() - if not k.startswith("__") and k.isupper() - ] + + names = [k for k in key.__dict__.keys() if not k.startswith("__") and k.isupper()] assert 205 == len(names) diff --git a/tests/unit/test_screenshot.py b/tests/unit/test_screenshot.py index e15f44d6d6..b210eb29a0 100644 --- a/tests/unit/test_screenshot.py +++ b/tests/unit/test_screenshot.py @@ -14,7 +14,7 @@ def test_get_image(window): """Get image from active framebuffer.""" window.clear(color=arcade.color.WHITE) image = arcade.get_image() - assert image.tobytes()[0:16] == b'\xff' * 16 + assert image.tobytes()[0:16] == b"\xff" * 16 image = arcade.get_image(components=3) - assert image.tobytes()[0:16] == b'\xff' * 16 + assert image.tobytes()[0:16] == b"\xff" * 16 diff --git a/tests/unit/test_shadertoy.py b/tests/unit/test_shadertoy.py index 827bccfdb8..1813e890ce 100644 --- a/tests/unit/test_shadertoy.py +++ b/tests/unit/test_shadertoy.py @@ -2,26 +2,26 @@ from arcade.experimental import Shadertoy, ShadertoyBuffer from arcade.gl import Program, Texture2D + def glsl(inner: str): - return ( - "void mainImage(out vec4 fragColor, in vec2 fragCoord)\n" - "{\n" - f"{inner}\n" - "}\n" - ) + return f"void mainImage(out vec4 fragColor, in vec2 fragCoord)\n{{\n{inner}\n}}\n" def test_create_from_file(ctx): - st = Shadertoy.create_from_file((100, 200), ":resources:shaders/shadertoy/crt_monitor_filter.glsl") + st = Shadertoy.create_from_file( + (100, 200), ":resources:shaders/shadertoy/crt_monitor_filter.glsl" + ) check_internals(st) with pytest.raises(FileNotFoundError): st = Shadertoy.create_from_file((100, 200), "something.glsl") + def test_create(ctx): st = Shadertoy((120, 130), glsl("fragColor = vec4(1.0, 1.0, 1.0, 1.0);")) check_internals(st) + def test_buffers(ctx): st = Shadertoy((120, 130), glsl("fragColor = vec4(1.0, 1.0, 1.0, 1.0);")) buffer_a = st.create_buffer(glsl("fragColor = vec4(1.0, 0.0, 0.0, 1.0);")) @@ -50,6 +50,7 @@ def test_buffers(ctx): assert st.buffer_c == buffer_c assert st.buffer_d == buffer_d + def test_getters_setters(ctx): st = Shadertoy((120, 130), glsl("fragColor = vec4(1.0, 1.0, 1.0, 1.0);")) assert st.size == (120, 130) @@ -83,10 +84,18 @@ def test_getters_setters(ctx): st.channel_2 = tx3 st.channel_3 = tx4 assert st._channel_resolution == [ - 10, 11, 1, - 12, 13, 1, - 14, 15, 1, - 16, 17, 1, + 10, + 11, + 1, + 12, + 13, + 1, + 14, + 15, + 1, + 16, + 17, + 1, ] diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 9cd77a1d6c..6474fdd7f5 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -4,13 +4,13 @@ Can run these tests individually with: python -m pytest tests/unit/test_utils.py """ + from typing import Callable, Any from arcade import utils -class _Dummy: - ... +class _Dummy: ... def fn_returns_expect_for_nonstr_iterables(fn: Callable[[Any], bool], expect: bool): @@ -27,7 +27,7 @@ def fn_returns_expect_for_nonstr_iterables(fn: Callable[[Any], bool], expect: bo def fn_returns_expect_for_noniterables(fn: Callable[[Any], bool], expect: bool): assert fn(1) == expect # Numbers - assert fn(complex(1,2)) == expect # Numbers part 2: complex is not iterable like pyglet vecs + assert fn(complex(1, 2)) == expect # Numbers part 2: complex is not iterable like pyglet vecs assert fn(type) == expect # types assert fn(_Dummy()) == expect # Instances assert fn(print) == expect # Functions diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 01f05ae703..617ce28040 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -3,42 +3,36 @@ from unittest import mock import pytest -from arcade.version import ( - _parse_python_friendly_version, - _parse_py_version_from_file -) +from arcade.version import _parse_python_friendly_version, _parse_py_version_from_file -@pytest.mark.parametrize("value, expected", [ - ("3.0.0.dev1", "3.0.0.dev1"), - ("3.0.0", "3.0.0"), - # Edge cases - ("11.22.333.dev4444", "11.22.333.dev4444"), - ("11.22.333", "11.22.333"), - ("111.2222.3333rc0", "111.2222.3333rc0") -]) +@pytest.mark.parametrize( + "value, expected", + [ + ("3.0.0.dev1", "3.0.0.dev1"), + ("3.0.0", "3.0.0"), + # Edge cases + ("11.22.333.dev4444", "11.22.333.dev4444"), + ("11.22.333", "11.22.333"), + ("111.2222.3333rc0", "111.2222.3333rc0"), + ], +) class TestParsingWellFormedData: - def test_parse_python_friendly_version( - self, value, expected - ): + def test_parse_python_friendly_version(self, value, expected): assert _parse_python_friendly_version(value) == expected - def test_parse_py_version_from_file( - self, value, expected - ): - + def test_parse_py_version_from_file(self, value, expected): with tempfile.NamedTemporaryFile("w", delete=False) as f: f.write(value) f.close() - assert _parse_py_version_from_file( - f.name - ) == expected + assert _parse_py_version_from_file(f.name) == expected @pytest.mark.parametrize( - "bad_value", ( - '', + "bad_value", + ( + "", "This string is not a version number at all!" # Malformed version numbers "3", @@ -56,21 +50,15 @@ def test_parse_py_version_from_file( "3.1.0.A", "3.1.0-dev.A", # Can't be both a release candidate and a dev preview - "3.1.0.dev4rc1" - ) + "3.1.0.dev4rc1", + ), ) def test_parse_python_friendly_version_raises_value_errors(bad_value): with pytest.raises(ValueError): _parse_python_friendly_version(bad_value) -@pytest.mark.parametrize('bad_type', ( - None, - 0xBAD, - 0.1234, - (3, 1, 0), - ('3', '1' '0') -)) +@pytest.mark.parametrize("bad_type", (None, 0xBAD, 0.1234, (3, 1, 0), ("3", "10"))) def test_parse_python_friendly_version_raises_typeerror_on_bad_values(bad_type): with pytest.raises(TypeError): _parse_python_friendly_version(bad_type) # type: ignore # Type mistmatch is the point @@ -78,6 +66,4 @@ def test_parse_python_friendly_version_raises_typeerror_on_bad_values(bad_type): def test_parse_py_version_from_file_returns_zeroes_on_errors(): fake_stderr = mock.MagicMock(sys.stderr) - assert _parse_py_version_from_file( - "FILEDOESNOTEXIST", write_errors_to=fake_stderr - ) == "0.0.0" + assert _parse_py_version_from_file("FILEDOESNOTEXIST", write_errors_to=fake_stderr) == "0.0.0" diff --git a/tests/unit/text/test_text.py b/tests/unit/text/test_text.py index d7b0692e01..993fe75a94 100644 --- a/tests/unit/text/test_text.py +++ b/tests/unit/text/test_text.py @@ -23,47 +23,95 @@ def test_text(window): arcade.draw_text("Test Text", current_x, current_y, arcade.color.BLACK, 12) current_y -= LINE_HEIGHT - arcade.draw_text("Test Text Anchor Left", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_x="left") + arcade.draw_text( + "Test Text Anchor Left", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_x="left", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - arcade.draw_text("Test Text Anchor Center", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_x="center") + arcade.draw_text( + "Test Text Anchor Center", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_x="center", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - arcade.draw_text("Test Text Anchor Right", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_x="right") + arcade.draw_text( + "Test Text Anchor Right", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_x="right", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - arcade.draw_text("Test Text Anchor Top", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_y="top") + arcade.draw_text( + "Test Text Anchor Top", SCREEN_WIDTH // 2, current_y, arcade.color.BLACK, 12, anchor_y="top" + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - arcade.draw_text("Test Text Anchor Center", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_y="center") + arcade.draw_text( + "Test Text Anchor Center", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_y="center", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - arcade.draw_text("Test Text Anchor Bottom", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_y="bottom") + arcade.draw_text( + "Test Text Anchor Bottom", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_y="bottom", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) field_width = SCREEN_WIDTH current_y -= LINE_HEIGHT - arcade.draw_text(f"Test Text Field Width {field_width}", current_x, current_y, - arcade.color.BLACK, 12, font_name="arial", width=field_width) - - current_y -= LINE_HEIGHT - arcade.draw_text(f"Centered Test Text Field Width {field_width}", current_x, current_y, - arcade.color.BLACK, 12, font_name="arial", width=field_width, align="center") + arcade.draw_text( + f"Test Text Field Width {field_width}", + current_x, + current_y, + arcade.color.BLACK, + 12, + font_name="arial", + width=field_width, + ) + + current_y -= LINE_HEIGHT + arcade.draw_text( + f"Centered Test Text Field Width {field_width}", + current_x, + current_y, + arcade.color.BLACK, + 12, + font_name="arial", + width=field_width, + align="center", + ) current_y -= LINE_HEIGHT font_name = ("comic", "arial") - arcade.draw_text("Different font", current_x, current_y, arcade.color.BLACK, 12, font_name=font_name) + arcade.draw_text( + "Different font", current_x, current_y, arcade.color.BLACK, 12, font_name=font_name + ) current_y -= LINE_HEIGHT @@ -104,43 +152,89 @@ def new_text(*args, **kwargs) -> None: new_text("Test Text", current_x, current_y, arcade.color.BLACK, 12) current_y -= LINE_HEIGHT - new_text("Test Text Anchor Left", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_x="left") + new_text( + "Test Text Anchor Left", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_x="left", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - new_text("Test Text Anchor Center", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_x="center") + new_text( + "Test Text Anchor Center", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_x="center", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - new_text("Test Text Anchor Right", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_x="right") + new_text( + "Test Text Anchor Right", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_x="right", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - new_text("Test Text Anchor Top", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_y="top") + new_text( + "Test Text Anchor Top", SCREEN_WIDTH // 2, current_y, arcade.color.BLACK, 12, anchor_y="top" + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - new_text("Test Text Anchor Center", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_y="center") + new_text( + "Test Text Anchor Center", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_y="center", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) current_y -= LINE_HEIGHT - new_text("Test Text Anchor Bottom", SCREEN_WIDTH // 2, current_y, - arcade.color.BLACK, 12, anchor_y="bottom") + new_text( + "Test Text Anchor Bottom", + SCREEN_WIDTH // 2, + current_y, + arcade.color.BLACK, + 12, + anchor_y="bottom", + ) arcade.draw_point(SCREEN_WIDTH // 2, current_y, arcade.color.RED, 5) field_width = SCREEN_WIDTH current_y -= LINE_HEIGHT - new_text(f"Test Text Field Width {field_width}", current_x, current_y, - arcade.color.BLACK, 12, font_name="arial", width=field_width) - - current_y -= LINE_HEIGHT - new_text(f"Centered Test Text Field Width {field_width}", current_x, current_y, - arcade.color.BLACK, 12, font_name="arial", width=field_width, align="center") + new_text( + f"Test Text Field Width {field_width}", + current_x, + current_y, + arcade.color.BLACK, + 12, + font_name="arial", + width=field_width, + ) + + current_y -= LINE_HEIGHT + new_text( + f"Centered Test Text Field Width {field_width}", + current_x, + current_y, + arcade.color.BLACK, + 12, + font_name="arial", + width=field_width, + align="center", + ) current_y -= LINE_HEIGHT font_name = ("comic", "arial") diff --git a/tests/unit/text/test_text_error_handling.py b/tests/unit/text/test_text_error_handling.py index 4a46dd29e5..7e756ed757 100644 --- a/tests/unit/text/test_text_error_handling.py +++ b/tests/unit/text/test_text_error_handling.py @@ -7,21 +7,33 @@ def test_text_instance_raise_multiline_error(window): with pytest.raises(ValueError) as e: _ = arcade.Text("Initial text", 0, 0, width=0, multiline=True) - assert e.value.args[0] == "The 'width' parameter must be set to a non-zero value when 'multiline' is True, but got 0." + assert ( + e.value.args[0] + == "The 'width' parameter must be set to a non-zero value when 'multiline' is True, but got 0." + ) with pytest.raises(ValueError) as e: _ = arcade.Text("Initial text", 0, 0, width=None, multiline=True) - assert e.value.args[0] == "The 'width' parameter must be set to a non-zero value when 'multiline' is True, but got None." + assert ( + e.value.args[0] + == "The 'width' parameter must be set to a non-zero value when 'multiline' is True, but got None." + ) def test_text_function_raise_multiline_error(window): with pytest.raises(ValueError) as e: _ = arcade.draw_text("Initial text", 0, 0, width=0, multiline=True) - assert e.value.args[0] == "The 'width' parameter must be set to a non-zero value when 'multiline' is True, but got 0." + assert ( + e.value.args[0] + == "The 'width' parameter must be set to a non-zero value when 'multiline' is True, but got 0." + ) with pytest.raises(ValueError) as e: _ = arcade.draw_text("Initial text", 0, 0, width=None, multiline=True) - assert e.value.args[0] == "The 'width' parameter must be set to a non-zero value when 'multiline' is True, but got None." + assert ( + e.value.args[0] + == "The 'width' parameter must be set to a non-zero value when 'multiline' is True, but got None." + ) diff --git a/tests/unit/text/test_text_instance_properties.py b/tests/unit/text/test_text_instance_properties.py index 837347a75b..a7e129ad6b 100644 --- a/tests/unit/text/test_text_instance_properties.py +++ b/tests/unit/text/test_text_instance_properties.py @@ -18,11 +18,10 @@ def instance() -> arcade.Text: ("width", 600), ("bold", True), ("italic", True), - ("rotation", 45.0) - ) + ("rotation", 45.0), + ), ) def test_text_instance_simple_property(ctx, instance, prop_name, prop_new_value): - assert getattr(instance, prop_name) != prop_new_value setattr(instance, prop_name, prop_new_value) assert getattr(instance, prop_name) == prop_new_value @@ -33,11 +32,10 @@ def test_text_instance_simple_property(ctx, instance, prop_name, prop_new_value) ( ("anchor_x", ("left", "center", "right")), ("anchor_y", ("top", "center", "baseline", "bottom")), - ("bold", (True, False)) - ) + ("bold", (True, False)), + ), ) def test_text_instance_discrete_prop_valid_values(ctx, prop_name, valid_values): - for value in valid_values: i = arcade.Text("Initial text", 0.0, 0.0) @@ -46,7 +44,6 @@ def test_text_instance_discrete_prop_valid_values(ctx, prop_name, valid_values): def test_text_instance_multiline_setter(ctx): - # this requires width to be set or pyglet.label will throw errors instance = arcade.Text("Initial text", 0.0, 0.0, width=400) @@ -57,7 +54,6 @@ def test_text_instance_multiline_setter(ctx): @pytest.mark.parametrize("align", ("center", "right")) def test_text_instance_align_not_left(ctx, align): - # width must be set instance = arcade.Text("Initial text", 0, 0, width=500) @@ -71,13 +67,11 @@ def test_text_instance_align_not_left(ctx, align): def test_text_instance_position_setter(instance): - instance.position = (20.0, 40.0) assert instance.x == 20.0 assert instance.y == 40.0 def test_text_instance_position_getter(): - instance = arcade.Text("Initial text", 20.0, 40.0) assert instance.position == (20.0, 40.0) diff --git a/tests/unit/text/test_text_sprite.py b/tests/unit/text/test_text_sprite.py index 53d403721f..01b93f05ec 100644 --- a/tests/unit/text/test_text_sprite.py +++ b/tests/unit/text/test_text_sprite.py @@ -2,9 +2,7 @@ import arcade -@pytest.mark.parametrize( - 'color', (arcade.color.WHITE, (255, 255, 255), (255, 255, 255, 255)) -) +@pytest.mark.parametrize("color", (arcade.color.WHITE, (255, 255, 255), (255, 255, 255, 255))) def test_create(window, color): sprite = arcade.create_text_sprite("Hello World", color) assert isinstance(sprite, arcade.Sprite) diff --git a/tests/unit/texture/test_manager.py b/tests/unit/texture/test_manager.py index 4b88499b64..8626d84a99 100644 --- a/tests/unit/texture/test_manager.py +++ b/tests/unit/texture/test_manager.py @@ -1,10 +1,12 @@ """Test the TextureCacheManager""" + import arcade from arcade.types.rect import LBWH SPRITESHEET_PATH = ":assets:images/spritesheets/codepage_437.png" TEST_TEXTURE = ":assets:images/test_textures/test_texture.png" + def test_create(): arcade.texture.TextureCacheManager() @@ -34,7 +36,10 @@ def test_load_spritesheet_texture(): # Load a few more textures for i in range(10): texture = manager.load_or_get_spritesheet_texture(SPRITESHEET_PATH, LBWH(i * 9, 0, 8, 16)) - assert manager.load_or_get_spritesheet_texture(SPRITESHEET_PATH, LBWH(i * 9, 0, 8, 16)) == texture + assert ( + manager.load_or_get_spritesheet_texture(SPRITESHEET_PATH, LBWH(i * 9, 0, 8, 16)) + == texture + ) # We should still have 1 spritesheet assert len(manager._sprite_sheets) == 1 @@ -47,7 +52,7 @@ def test_load_spritesheet_texture(): manager.flush() assert len(manager._sprite_sheets) == 0 assert len(manager.texture_cache._file_entries) == 0 - assert len(manager.texture_cache._entries) == 0 + assert len(manager.texture_cache._entries) == 0 assert len(manager.image_data_cache) == 0 diff --git a/tests/unit/texture/test_sprite_sheet.py b/tests/unit/texture/test_sprite_sheet.py index 0fc39e07e9..feb2c37a53 100644 --- a/tests/unit/texture/test_sprite_sheet.py +++ b/tests/unit/texture/test_sprite_sheet.py @@ -13,12 +13,14 @@ def get_dollar_sign(sprite_sheet: arcade.SpriteSheet): Crop out the dollar sign from the sprite sheet. """ # left, upper, right, and lower - return sprite_sheet.image.crop(( - 9 * 4, # left: 4th column - 16, # upper: second row - 9 * 4 + 8, # right: 8 pixels wide - 16 + 16 # lower: 16 pixels tall - )) + return sprite_sheet.image.crop( + ( + 9 * 4, # left: 4th column + 16, # upper: second row + 9 * 4 + 8, # right: 8 pixels wide + 16 + 16, # lower: 16 pixels tall + ) + ) @pytest.fixture(scope="module") @@ -75,18 +77,27 @@ def test_get_image(sprite_sheet): # Crop out the dollar sign using upper left origin im = sprite_sheet.get_image( - LBWH(9 * 4, # 4th column - 16, # second row - 8, 16)) + LBWH( + 9 * 4, # 4th column + 16, # second row + 8, + 16, + ) + ) assert isinstance(im, Image.Image) assert im.size == (8, 16) assert im.tobytes() == dollar_sign.tobytes() # Crop out the dollar sign using lower left origin im = sprite_sheet.get_image( - LBWH(9 * 4, # 4th column - 16 * 6, # 6th row - 8,16), True) + LBWH( + 9 * 4, # 4th column + 16 * 6, # 6th row + 8, + 16, + ), + True, + ) assert isinstance(im, Image.Image) assert im.size == (8, 16) assert im.tobytes() == dollar_sign.tobytes() @@ -125,5 +136,5 @@ def test_get_texture_grid(sprite_sheet): assert len(textures) == 255 for texture in textures: assert texture.image.size == (8, 16) - + assert textures[36].image.tobytes() == get_dollar_sign(sprite_sheet).tobytes() diff --git a/tests/unit/texture/test_texture.py b/tests/unit/texture/test_texture.py index 59f6ff129c..5d535516f5 100644 --- a/tests/unit/texture/test_texture.py +++ b/tests/unit/texture/test_texture.py @@ -10,8 +10,14 @@ def test_create(): assert texture.height == 10 assert texture.file_path is None assert texture.crop_values is None - assert texture.image_data.hash == "7a12e561363385e9dfeeab326368731c030ed4b374e7f5897ac819159d2884c5" - assert texture.cache_name == f"{texture.image_data.hash}|{texture._vertex_order}|{texture.hit_box_algorithm.cache_name}|" + assert ( + texture.image_data.hash + == "7a12e561363385e9dfeeab326368731c030ed4b374e7f5897ac819159d2884c5" + ) + assert ( + texture.cache_name + == f"{texture.image_data.hash}|{texture._vertex_order}|{texture.hit_box_algorithm.cache_name}|" + ) with pytest.raises(TypeError): _ = arcade.Texture("not valid image data") @@ -19,7 +25,10 @@ def test_create(): def test_create_override_name(): texture = arcade.Texture(Image.new("RGBA", (10, 10)), hash="test") - assert texture.cache_name == f"test|{texture._vertex_order}|{texture.hit_box_algorithm.cache_name}|" + assert ( + texture.cache_name + == f"test|{texture._vertex_order}|{texture.hit_box_algorithm.cache_name}|" + ) def test_hitbox_algo_selection(): diff --git a/tests/unit/texture/test_texture_tools.py b/tests/unit/texture/test_texture_tools.py index fea7ab7144..bee7b94403 100644 --- a/tests/unit/texture/test_texture_tools.py +++ b/tests/unit/texture/test_texture_tools.py @@ -3,7 +3,7 @@ ImageData, get_default_texture, get_default_image, - default_texture_cache + default_texture_cache, ) diff --git a/tests/unit/texture/test_texture_transform_render.py b/tests/unit/texture/test_texture_transform_render.py index 45f685efc0..bb5b6baf9b 100644 --- a/tests/unit/texture/test_texture_transform_render.py +++ b/tests/unit/texture/test_texture_transform_render.py @@ -1,6 +1,7 @@ """ Ensure we are emulating PIL's transforms correctly. """ + import arcade import pytest from pyglet.math import Mat4 @@ -15,6 +16,7 @@ TransposeTransform, TransverseTransform, ) + # Arcade transform, PIL transform TRANSFORMS = [ (Rotate90Transform, Image.Transpose.ROTATE_270, 1), @@ -23,9 +25,10 @@ (FlipLeftRightTransform, Image.FLIP_LEFT_RIGHT, 4), (FlipTopBottomTransform, Image.FLIP_TOP_BOTTOM, 5), (TransposeTransform, Image.TRANSPOSE, 6), - (TransverseTransform, Image.TRANSVERSE, 7) + (TransverseTransform, Image.TRANSVERSE, 7), ] + @pytest.fixture(scope="module") def image(): im = Image.new("RGBA", (8, 8)) @@ -48,7 +51,9 @@ def test_rotate90_transform(ctx: arcade.ArcadeContext, image, transform, pil_tra sprite = arcade.Sprite(texture, center_x=image.width // 2, center_y=image.height // 2) with fbo.activate(): fbo.clear() - ctx.projection_matrix = Mat4.orthogonal_projection(0, image.width, 0, image.height, -100, 100) + ctx.projection_matrix = Mat4.orthogonal_projection( + 0, image.width, 0, image.height, -100, 100 + ) arcade.draw_sprite(sprite, pixelated=True) expected_image = image.transpose(pil_transform) diff --git a/tests/unit/texture/test_texture_transform_values.py b/tests/unit/texture/test_texture_transform_values.py index 82f3b98180..9667b75993 100644 --- a/tests/unit/texture/test_texture_transform_values.py +++ b/tests/unit/texture/test_texture_transform_values.py @@ -8,12 +8,9 @@ TransverseTransform, VertexOrder, ) + # Hit box points to test for transformations -HIT_BOX_POINTS = ( - (1.0, 1.0), - (2.0, 2.0), - (2.0, 1.0) -) +HIT_BOX_POINTS = ((1.0, 1.0), (2.0, 2.0), (2.0, 1.0)) ORDER = ( VertexOrder.UPPER_LEFT.value, VertexOrder.UPPER_RIGHT.value, diff --git a/tests/unit/texture/test_textures.py b/tests/unit/texture/test_textures.py index 75c2f53959..3153b5bfbb 100644 --- a/tests/unit/texture/test_textures.py +++ b/tests/unit/texture/test_textures.py @@ -27,7 +27,7 @@ def test_texture_constructor_hit_box_algo(): def test_load_texture(): - """Create texture with different """ + """Create texture with different""" path = ":resources:images/test_textures/test_texture.png" # Basic loading tex = arcade.load_texture(path) @@ -65,5 +65,5 @@ def test_crate_empty(): (-128.0, -128.0), (128.0, -128.0), (128.0, 128.0), - (-128.0, 128.0) + (-128.0, 128.0), ) diff --git a/tests/unit/tilemap/test_img_layer.py b/tests/unit/tilemap/test_img_layer.py index 7c6896ee5a..74ccd2cfd0 100644 --- a/tests/unit/tilemap/test_img_layer.py +++ b/tests/unit/tilemap/test_img_layer.py @@ -19,7 +19,7 @@ def test_image_layer(): assert "img-offset" in tile_map.sprite_lists assert len(tile_map.sprite_lists["img-offset"]) == 1 image = tile_map.sprite_lists["img-offset"][0] - + assert image.width == 1024 assert image.height == 600 assert image.left == 1280 @@ -43,8 +43,8 @@ def test_image_layer_with_scaling(): assert "img-offset" in tile_map.sprite_lists assert len(tile_map.sprite_lists["img-offset"]) == 1 image = tile_map.sprite_lists["img-offset"][0] - + assert image.width == 512 assert image.height == 300 assert image.left == 640 - assert image.top == 704 \ No newline at end of file + assert image.top == 704 diff --git a/tests/unit/tilemap/test_rotation_flip.py b/tests/unit/tilemap/test_rotation_flip.py index 4ffe96859e..3c8b76b344 100644 --- a/tests/unit/tilemap/test_rotation_flip.py +++ b/tests/unit/tilemap/test_rotation_flip.py @@ -20,7 +20,6 @@ def test_rotation_mirror(): assert my_map.width == 11 assert my_map.height == 10 - # --- Platforms --- assert "Blocking Sprites" in my_map.sprite_lists wall_list = my_map.sprite_lists["Blocking Sprites"] @@ -39,17 +38,23 @@ def test_rotation_mirror(): # Transpose and flipped horizontally wall = wall_list[2] assert wall.position == (448, 64) - assert wall.texture._vertex_order == tt.FlipLeftRightTransform.transform_vertex_order(tt.TransposeTransform.order) + assert wall.texture._vertex_order == tt.FlipLeftRightTransform.transform_vertex_order( + tt.TransposeTransform.order + ) # Transposed, flipped vertically and horizontally wall = wall_list[3] assert wall.position == (576, 64) - assert wall.texture._vertex_order == _transform(tt.TransposeTransform, tt.FlipLeftRightTransform, tt.FlipTopBottomTransform) + assert wall.texture._vertex_order == _transform( + tt.TransposeTransform, tt.FlipLeftRightTransform, tt.FlipTopBottomTransform + ) # Horizontal flip and flipped vertically wall = wall_list[4] assert wall.position == (832, 64) - assert wall.texture._vertex_order == _transform(tt.FlipLeftRightTransform, tt.FlipTopBottomTransform) + assert wall.texture._vertex_order == _transform( + tt.FlipLeftRightTransform, tt.FlipTopBottomTransform + ) # Vertical flip wall = wall_list[5] @@ -59,7 +64,9 @@ def test_rotation_mirror(): # Transposed and flipped vertically wall = wall_list[6] assert wall.position == (1216, 64) - assert wall.texture._vertex_order == _transform(tt.TransposeTransform, tt.FlipTopBottomTransform) + assert wall.texture._vertex_order == _transform( + tt.TransposeTransform, tt.FlipTopBottomTransform + ) # Transposed wall = wall_list[7] @@ -78,7 +85,7 @@ def test_object_rotation_orientation(): # --- Object --- assert "Objects Sprites" in my_map.sprite_lists wall_list = my_map.sprite_lists["Objects Sprites"] - + # Check for the direction of rotation # not rotated the top is aligned with the grid wall = wall_list[16] @@ -96,7 +103,7 @@ def test_object_rotation_orientation(): assert (wall.left / 128).is_integer() assert not (wall.right / 128).is_integer() - # Turned 180 + # Turned 180 wall = wall_list[18] assert wall.properties["name"] == "r2" assert not (wall.top / 128).is_integer() @@ -104,7 +111,7 @@ def test_object_rotation_orientation(): assert (wall.left / 128).is_integer() assert (wall.right / 128).is_integer() - # Turned 270 to the left (90 to the right) + # Turned 270 to the left (90 to the right) wall = wall_list[19] assert wall.properties["name"] == "r3" assert (wall.top / 128).is_integer() @@ -121,68 +128,68 @@ def test_object_rotation_placement(): assert "Objects Sprites" in my_map.sprite_lists wall_list = my_map.sprite_lists["Objects Sprites"] - line = 64+128*2 + line = 64 + 128 * 2 wall = wall_list[0] assert wall.properties["name"] == "not" assert wall.position == (64, line) wall = wall_list[1] assert wall.properties["name"] == "h" - assert wall.position == (64+128*1, line) + assert wall.position == (64 + 128 * 1, line) wall = wall_list[2] assert wall.properties["name"] == "90" - assert wall.position == (64+128*3, line) + assert wall.position == (64 + 128 * 3, line) wall = wall_list[3] assert wall.properties["name"] == "h90" - assert wall.position == (64+128*4, line) + assert wall.position == (64 + 128 * 4, line) wall = wall_list[4] assert wall.properties["name"] == "180" - assert wall.position == (64+128*6, line) - + assert wall.position == (64 + 128 * 6, line) + wall = wall_list[5] assert wall.properties["name"] == "h180" - assert wall.position == (64+128*7, line) + assert wall.position == (64 + 128 * 7, line) wall = wall_list[6] assert wall.properties["name"] == "-90" - assert wall.position == (64+128*9, line) + assert wall.position == (64 + 128 * 9, line) wall = wall_list[7] assert wall.properties["name"] == "h-90" - assert wall.position == (64+128*10, line) + assert wall.position == (64 + 128 * 10, line) - line = 64+128*4 + line = 64 + 128 * 4 wall = wall_list[8] assert wall.properties["name"] == "v" assert wall.position == (64, line) wall = wall_list[9] assert wall.properties["name"] == "hv" - assert wall.position == (64+128*1, line) + assert wall.position == (64 + 128 * 1, line) wall = wall_list[10] assert wall.properties["name"] == "v90" - assert wall.position == (64+128*3, line) + assert wall.position == (64 + 128 * 3, line) wall = wall_list[11] assert wall.properties["name"] == "hv90" - assert wall.position == (64+128*4, line) + assert wall.position == (64 + 128 * 4, line) wall = wall_list[12] assert wall.properties["name"] == "v180" - assert wall.position == (64+128*6, line) + assert wall.position == (64 + 128 * 6, line) wall = wall_list[13] assert wall.properties["name"] == "hv180" - assert wall.position == (64+128*7, line) + assert wall.position == (64 + 128 * 7, line) wall = wall_list[14] assert wall.properties["name"] == "v-90" - assert wall.position == (64+128*9, line) + assert wall.position == (64 + 128 * 9, line) wall = wall_list[15] assert wall.properties["name"] == "hv-90" - assert wall.position == (64+128*10, line) \ No newline at end of file + assert wall.position == (64 + 128 * 10, line) diff --git a/tests/unit/tilemap/test_tilemap_objects.py b/tests/unit/tilemap/test_tilemap_objects.py index 1b93fb7ea1..648a08c343 100644 --- a/tests/unit/tilemap/test_tilemap_objects.py +++ b/tests/unit/tilemap/test_tilemap_objects.py @@ -39,7 +39,12 @@ def test_one(): rectangle = tile_map.object_lists["Shapes"][0] assert isclose(rectangle.shape[2][0] - rectangle.shape[0][0], 573.60, abs_tol=0.02) assert isclose(rectangle.shape[0][1] - rectangle.shape[2][1], 469.04, abs_tol=0.02) - assert isclose(tile_map.tiled_map.map_size.height * tile_map.tiled_map.tile_size[1] - rectangle.shape[0][1], 630.37, abs_tol=0.02) + assert isclose( + tile_map.tiled_map.map_size.height * tile_map.tiled_map.tile_size[1] + - rectangle.shape[0][1], + 630.37, + abs_tol=0.02, + ) # # # # Test getting layer in group diff --git a/tests/unit/window/test_view.py b/tests/unit/window/test_view.py index 35147fe4c0..ac5ab72726 100644 --- a/tests/unit/window/test_view.py +++ b/tests/unit/window/test_view.py @@ -24,7 +24,8 @@ def test_on_hide_view_called(window): window.show_view(view2) hide_mock.assert_called_once() - + + def test_view_background_color(window): view = View(window, color.ARCADE_GREEN) assert view.background_color == color.ARCADE_GREEN diff --git a/tests/unit/window/test_window.py b/tests/unit/window/test_window.py index 4635e51632..1ba75f4dc6 100644 --- a/tests/unit/window/test_window.py +++ b/tests/unit/window/test_window.py @@ -47,11 +47,12 @@ def test_window(window: arcade.Window): def f(): pass - arcade.schedule(f, 1/60) + arcade.schedule(f, 1 / 60) time.sleep(0.01) arcade.unschedule(f) window.test() + def test_window_with_view_arg(window: arcade.Window): class TestView(arcade.View): def __init__(self): @@ -60,12 +61,14 @@ def __init__(self): def on_show_view(self): self.on_show_called = True + v = TestView() window.run(view=v) assert v.on_show_called assert window.current_view is v + def test_start_finish_render(window): """Test start and finish render""" # start_render must be called first diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index 150b9cb072..8b08654738 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -3,6 +3,7 @@ Generate quick API indexes in Restructured Text Format for Sphinx documentation. """ + # fmt: off # ruff: noqa import copy diff --git a/util/doc_helpers/__init__.py b/util/doc_helpers/__init__.py index 3426f7e4db..069dcfbcc6 100644 --- a/util/doc_helpers/__init__.py +++ b/util/doc_helpers/__init__.py @@ -15,6 +15,7 @@ class NotExcludedBy: This is here because we may eventually define excludes at per-module level in our config below instead of a single list. """ + def __init__(self, collection: Iterable): self.items = set(collection) @@ -24,6 +25,7 @@ def __call__(self, item) -> bool: class SharedPaths: """These are often used to set up a Vfs and open files.""" + REPO_UTILS_DIR = Path(__file__).parent.parent.resolve() REPO_ROOT = REPO_UTILS_DIR.parent ARCADE_ROOT = REPO_ROOT / "arcade" @@ -31,8 +33,7 @@ class SharedPaths: API_DOC_ROOT = DOC_ROOT / "api_docs" - -def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path: +def get_module_path(module: str, root=SharedPaths.REPO_ROOT) -> Path: """Quick-n-dirty module path estimation relative to the repo root. Args: @@ -44,10 +45,9 @@ def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path: """ # Convert module.name.here to module/name/here current = root - for index, part in enumerate(module.split('.')): + for index, part in enumerate(module.split(".")): if not _VALID_MODULE_SEGMENT.fullmatch(part): - raise ValueError( - f'Invalid module segment at index {index}: {part!r}') + raise ValueError(f"Invalid module segment at index {index}: {part!r}") # else: # print(current, part) current /= part @@ -57,25 +57,25 @@ def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path: # 2. arcade/module/__init__.py as_package = current / "__init__.py" have_package = as_package.is_file() - as_file = current.with_suffix('.py') + as_file = current.with_suffix(".py") have_file = as_file.is_file() # TODO: When 3.10 becomes our min Python, make this a match-case? if have_package and have_file: - raise ValueError( - f"Module conflict between {as_package} and {as_file}") + raise ValueError(f"Module conflict between {as_package} and {as_file}") elif have_package: current = as_package elif have_file: current = as_file else: - raise ValueError( - f"No folder package or file module detected for " - f"{module}") + raise ValueError(f"No folder package or file module detected for {module}") return current + + class SharedPaths: """These are often used to set up a Vfs and open files.""" + REPO_UTILS_DIR = Path(__file__).parent.parent.resolve() REPO_ROOT = REPO_UTILS_DIR.parent ARCADE_ROOT = REPO_ROOT / "arcade" @@ -83,8 +83,7 @@ class SharedPaths: API_DOC_ROOT = DOC_ROOT / "api_docs" - -def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path: +def get_module_path(module: str, root=SharedPaths.REPO_ROOT) -> Path: """Quick-n-dirty module path estimation relative to the repo root. Args: @@ -96,10 +95,9 @@ def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path: """ # Convert module.name.here to module/name/here current = root - for index, part in enumerate(module.split('.')): + for index, part in enumerate(module.split(".")): if not _VALID_MODULE_SEGMENT.fullmatch(part): - raise ValueError( - f'Invalid module segment at index {index}: {part!r}') + raise ValueError(f"Invalid module segment at index {index}: {part!r}") # else: # print(current, part) current /= part @@ -109,33 +107,29 @@ def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path: # 2. arcade/module/__init__.py as_package = current / "__init__.py" have_package = as_package.is_file() - as_file = current.with_suffix('.py') + as_file = current.with_suffix(".py") have_file = as_file.is_file() # TODO: When 3.10 becomes our min Python, make this a match-case? if have_package and have_file: - raise ValueError( - f"Module conflict between {as_package} and {as_file}") + raise ValueError(f"Module conflict between {as_package} and {as_file}") elif have_package: current = as_package elif have_file: current = as_file else: - raise ValueError( - f"No folder package or file module detected for " - f"{module}") + raise ValueError(f"No folder package or file module detected for {module}") return current - __all__ = ( - 'get_module_path', - 'SharedPaths', - 'EMPTY_TUPLE', - 'F', - 'NotExcludedBy', - 'VirtualFile', - 'Vfs', - 'build_import_tree', + "get_module_path", + "SharedPaths", + "EMPTY_TUPLE", + "F", + "NotExcludedBy", + "VirtualFile", + "Vfs", + "build_import_tree", ) diff --git a/util/doc_helpers/real_filesystem.py b/util/doc_helpers/real_filesystem.py index 0cfc635be2..f7efa1962a 100644 --- a/util/doc_helpers/real_filesystem.py +++ b/util/doc_helpers/real_filesystem.py @@ -2,12 +2,13 @@ Helpers for dealing with the real-world file system. """ + import shutil from pathlib import Path from typing import Generator, TypeVar, Hashable, Iterable, Mapping, Sequence, Callable import logging -H = TypeVar('H', bound=Hashable) +H = TypeVar("H", bound=Hashable) FILE = Path(__file__) REPO_ROOT = Path(__file__).parent.parent.resolve() @@ -28,9 +29,7 @@ def dest_older(src: Path | str, dest: Path | str) -> bool: def multi_glob( - p: str | Path, - *globs: str, - predicate: Callable[[Path], bool] | None = None + p: str | Path, *globs: str, predicate: Callable[[Path], bool] | None = None ) -> Generator[Path, None, None]: """Chain multiple :py:class:`pathlib.Path.glob` results into one. @@ -84,17 +83,18 @@ def sync_dir(src_dir: Path, dest_dir: Path, *globs: str, done: set | None = None if not dest_file.exists() or dest_older(src_file, dest_file): dest_file.parent.mkdir(parents=True, exist_ok=True) - log.info(f' Copying media file {src_file} to {dest_file}') + log.info(f" Copying media file {src_file} to {dest_file}") shutil.copyfile(src_file, dest_file) else: log.info(f" Skipping media file {src_file} to {dest_file}") + def copy_media( - src_root: Path | str, - dest_root: Path | str, - items: Mapping[str | Path, Sequence[str]], - done: set | None = None + src_root: Path | str, + dest_root: Path | str, + items: Mapping[str | Path, Sequence[str]], + done: set | None = None, ) -> None: """A more configurable version of the file syncing scripts we use. diff --git a/util/doc_helpers/vfs.py b/util/doc_helpers/vfs.py index c88baafce1..3f0b1f438f 100644 --- a/util/doc_helpers/vfs.py +++ b/util/doc_helpers/vfs.py @@ -27,6 +27,7 @@ by reading each file before write and aborting if its contents would be unchanged. """ + from contextlib import suppress, contextmanager from io import StringIO from pathlib import Path @@ -73,7 +74,7 @@ def _write_to_disk(self): f.write(content) -F = TypeVar('F', bound=VirtualFile) +F = TypeVar("F", bound=VirtualFile) class Vfs(Generic[F]): diff --git a/util/generate_example_thumbnails.py b/util/generate_example_thumbnails.py index 83d572d4be..3f1db775a7 100644 --- a/util/generate_example_thumbnails.py +++ b/util/generate_example_thumbnails.py @@ -5,11 +5,11 @@ def main(): - input_path = Path('example_code/images') - output_path = Path('example_code/images/thumbs/') + input_path = Path("example_code/images") + output_path = Path("example_code/images/thumbs/") - png_input_files = input_path.glob('*.png') - gif_input_files = input_path.glob('*.gif') + png_input_files = input_path.glob("*.png") + gif_input_files = input_path.glob("*.gif") modified_files = [] @@ -40,12 +40,12 @@ def generate_thumbnails(input_files, output_path): output_path.mkdir(exist_ok=True) for input_file in input_files: - print('Generating thumbnail: ' + input_file.name) + print("Generating thumbnail: " + input_file.name) im = Image.open(input_file) im.thumbnail(size) im.save(output_path / input_file.name) print("Done generating thumbnails.") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/util/generate_hit_box_cache.py b/util/generate_hit_box_cache.py index 7391af350f..f61d76f23d 100644 --- a/util/generate_hit_box_cache.py +++ b/util/generate_hit_box_cache.py @@ -1,6 +1,7 @@ """ Generates a cache of hit boxes for all the built in resources. """ + import sys import time from pathlib import Path @@ -42,7 +43,7 @@ algorithm = "simple" textures.append(arcade.load_texture(path, hit_box_algorithm=algorithm)) algorithm = "detailed" - textures.append(arcade.load_texture(path, hit_box_algorithm=algorithm)) + textures.append(arcade.load_texture(path, hit_box_algorithm=algorithm)) except Exception as e: print() print(f"Error loading ({algorithm}): {path.relative_to(RESOURCE_DIR)}") diff --git a/util/sphinx_static_file_temp_fix.py b/util/sphinx_static_file_temp_fix.py index 80d8107508..b728b9b485 100644 --- a/util/sphinx_static_file_temp_fix.py +++ b/util/sphinx_static_file_temp_fix.py @@ -42,6 +42,7 @@ 4. Our customizations to be tested with all of the above """ + import shutil import sys import logging @@ -76,23 +77,13 @@ # You can add per-dir config the lazy way: # 1. copy & paste this block # 2. modifying it with filtering - **{ - source_file: BUILD_CSS_DIR / source_file.name - for source_file in SOURCE_CSS_DIR.glob("*.*") - }, - **{ - source_file: BUILD_JS_DIR / source_file.name - for source_file in SOURCE_JS_DIR.glob("*.*") - }, + **{source_file: BUILD_CSS_DIR / source_file.name for source_file in SOURCE_CSS_DIR.glob("*.*")}, + **{source_file: BUILD_JS_DIR / source_file.name for source_file in SOURCE_JS_DIR.glob("*.*")}, } # pending: some clever use of util/doc_helpers/vfs.py -def force_sync( - src: Path, - dest: Path, - dry: bool = False -) -> None: +def force_sync(src: Path, dest: Path, dry: bool = False) -> None: """Sync a single file from ``src`` to ``dest``. Caveats: @@ -120,7 +111,9 @@ def main(): skip_reason = None if not ENABLE_DEVMACHINE_SPHINX_STATIC_FIX.exists(): - skip_reason = f"SKIP: Force sync not enabled by a {ENABLE_DEVMACHINE_SPHINX_STATIC_FIX} file!" + skip_reason = ( + f"SKIP: Force sync not enabled by a {ENABLE_DEVMACHINE_SPHINX_STATIC_FIX} file!" + ) elif not BUILD_HTML_DIR.exists(): skip_reason = f"SKIP: {BUILD_HTML_DIR} does not exist yet." @@ -129,11 +122,13 @@ def main(): else: # indented so we can grep for Done force-syncing in the logs from sphinx import __version__ as sphinx_version + log.info(f" SYNC: Force-sync enable file found and build-dir exists") - if sphinx_version >= '8.1.4': + if sphinx_version >= "8.1.4": log.warning( - ' Sphinx >= 8.1.4 may patch broken _static copy\n' - ' (see https://github.com/sphinx-doc/sphinx/issues/1810)') + " Sphinx >= 8.1.4 may patch broken _static copy\n" + " (see https://github.com/sphinx-doc/sphinx/issues/1810)" + ) for src, dest in force_copy_on_change.items(): force_sync(src, dest) diff --git a/util/sync_example_code_with_rst.py b/util/sync_example_code_with_rst.py index 7f01a980b6..648a3c7ed3 100644 --- a/util/sync_example_code_with_rst.py +++ b/util/sync_example_code_with_rst.py @@ -3,6 +3,7 @@ # .. literalinclude:: ../../arcade/examples/array_backed_grid_buffered.py # :ref:`platformer_tutorial` import re + literal_include_pattern = re.compile(r"literalinclude:: .*/(.*\.py)$") ref_pattern = re.compile(":ref:`(.*)`") @@ -30,7 +31,6 @@ def check_directory_for_python_files(self, my_path: Path): def check_rst_file(self, cur_node: Path): if cur_node.name.endswith(".rst"): - i = 0 try: for i, line in enumerate(open(cur_node, encoding="utf-8")): @@ -42,7 +42,6 @@ def check_rst_file(self, cur_node: Path): def check_for_rst_ref(self, cur_node: Path): if cur_node.name.endswith(".rst"): - i = 0 try: for i, line in enumerate(open(cur_node, encoding="utf-8")): diff --git a/util/update_quick_index.py b/util/update_quick_index.py index c9a0731d8a..e6729b1af1 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -11,6 +11,7 @@ a # --- so you can skip between them in diffs or your favorite editor via hotkeys. """ + import re import sys from collections.abc import Mapping @@ -41,12 +42,10 @@ # --- 1. Special rules & excludes --- -RULE_SHOW_INHERITANCE = (':show-inheritance:',) -RULE_INHERITED_MEMBERS = (':inherited-members:',) +RULE_SHOW_INHERITANCE = (":show-inheritance:",) +RULE_INHERITED_MEMBERS = (":inherited-members:",) -MEMBER_SPECIAL_RULES = { - "arcade.ArcadeContext" : RULE_SHOW_INHERITANCE + RULE_INHERITED_MEMBERS -} +MEMBER_SPECIAL_RULES = {"arcade.ArcadeContext": RULE_SHOW_INHERITANCE + RULE_INHERITED_MEMBERS} # Module and class members to exclude @@ -69,8 +68,8 @@ "arcade.types.vector_like", "arcade.types.color", "arcade.types.rect", - "arcade.types.box" - ] + "arcade.types.box", + ], }, "resources.rst": { "title": "Resources", @@ -90,7 +89,7 @@ "arcade.draw.polygon", "arcade.draw.rect", "arcade.draw.triangle", - ] + ], }, "sprites.rst": { "title": "Sprites", @@ -101,8 +100,8 @@ "arcade.sprite.colored", "arcade.sprite.mixins", "arcade.sprite.animated", - "arcade.sprite.enums" - ] + "arcade.sprite.enums", + ], }, "sprite_list.rst": { "title": "Sprite Lists", @@ -110,8 +109,8 @@ "arcade.sprite_list", "arcade.sprite_list.sprite_list", "arcade.sprite_list.spatial_hash", - "arcade.sprite_list.collision" - ] + "arcade.sprite_list.collision", + ], }, "clock.rst": { "title": "Clock", @@ -119,30 +118,10 @@ "arcade.clock", ], }, - "text.rst": { - "title": "Text", - "use_declarations_in": [ - "arcade.text" - ] - }, - "camera_2d.rst": { - "title": "Camera 2D", - "use_declarations_in": [ - "arcade.camera.camera_2d" - ] - }, - "sprite_scenes.rst": { - "title": "Sprite Scenes", - "use_declarations_in": [ - "arcade.scene" - ] - }, - "tilemap.rst": { - "title": "Tiled Map Reader", - "use_declarations_in": [ - "arcade.tilemap.tilemap" - ] - }, + "text.rst": {"title": "Text", "use_declarations_in": ["arcade.text"]}, + "camera_2d.rst": {"title": "Camera 2D", "use_declarations_in": ["arcade.camera.camera_2d"]}, + "sprite_scenes.rst": {"title": "Sprite Scenes", "use_declarations_in": ["arcade.scene"]}, + "tilemap.rst": {"title": "Tiled Map Reader", "use_declarations_in": ["arcade.tilemap.tilemap"]}, "texture.rst": { "title": "Texture Management", "use_declarations_in": [ @@ -152,8 +131,8 @@ "arcade.texture.generate", "arcade.texture.manager", "arcade.texture.spritesheet", - "arcade.texture.tools" - ] + "arcade.texture.tools", + ], }, "hitbox.rst": { "title": "Hitbox", @@ -167,9 +146,7 @@ }, "texture_transforms.rst": { "title": "Texture Transforms", - "use_declarations_in": [ - "arcade.texture.transforms" - ] + "use_declarations_in": ["arcade.texture.transforms"], }, "texture_atlas.rst": { "title": "Texture Atlas", @@ -180,40 +157,22 @@ "arcade.texture_atlas.region", "arcade.texture_atlas.uv_data", "arcade.texture_atlas.ref_counters", - ] + ], }, "perf_info.rst": { "title": "Performance Information", - "use_declarations_in": [ - "arcade.perf_info", - "arcade.perf_graph" - ] + "use_declarations_in": ["arcade.perf_info", "arcade.perf_graph"], }, "physics_engines.rst": { "title": "Physics Engines", - "use_declarations_in": [ - "arcade.physics_engines", - "arcade.pymunk_physics_engine" - ] - }, - "geometry.rst": { - "title": "Geometry Support", - "use_declarations_in": [ - "arcade.geometry" - ] + "use_declarations_in": ["arcade.physics_engines", "arcade.pymunk_physics_engine"], }, + "geometry.rst": {"title": "Geometry Support", "use_declarations_in": ["arcade.geometry"]}, "game_controller.rst": { "title": "Game Controller", - "use_declarations_in": [ - "arcade.controller" - ] - }, - "joysticks.rst": { - "title": "Joystick", - "use_declarations_in": [ - "arcade.joysticks" - ] + "use_declarations_in": ["arcade.controller"], }, + "joysticks.rst": {"title": "Joystick", "use_declarations_in": ["arcade.joysticks"]}, "window.rst": { "title": "Window and View", "use_declarations_in": [ @@ -221,64 +180,23 @@ "arcade.window_commands", "arcade.sections", "arcade.screenshot", - ] - }, - "sound.rst": { - "title": "Sound", - "use_declarations_in": [ - "arcade.sound" - ] - }, - "path_finding.rst": { - "title": "Pathfinding", - "use_declarations_in": [ - "arcade.paths" - ] + ], }, + "sound.rst": {"title": "Sound", "use_declarations_in": ["arcade.sound"]}, + "path_finding.rst": {"title": "Pathfinding", "use_declarations_in": ["arcade.paths"]}, "isometric.rst": { "title": "Isometric Map (incomplete)", - "use_declarations_in": [ - "arcade.isometric" - ] - }, - "easing.rst": { - "title": "Easing", - "use_declarations_in": [ - "arcade.easing" - ] + "use_declarations_in": ["arcade.isometric"], }, + "easing.rst": {"title": "Easing", "use_declarations_in": ["arcade.easing"]}, "utility.rst": { "title": "Misc Utility Functions", - "use_declarations_in": [ - "arcade", - "arcade.__main__", - "arcade.utils" - ] - }, - "drawing_batch.rst": { - "title": "Shape Lists", - "use_declarations_in": [ - "arcade.shape_list" - ] - }, - "open_gl.rst": { - "title": "OpenGL Context", - "use_declarations_in": [ - "arcade.context" - ] - }, - "math.rst": { - "title": "Math", - "use_declarations_in": [ - "arcade.math" - ] - }, - "earclip.rst": { - "title": "Earclip", - "use_declarations_in": [ - "arcade.earclip" - ] + "use_declarations_in": ["arcade", "arcade.__main__", "arcade.utils"], }, + "drawing_batch.rst": {"title": "Shape Lists", "use_declarations_in": ["arcade.shape_list"]}, + "open_gl.rst": {"title": "OpenGL Context", "use_declarations_in": ["arcade.context"]}, + "math.rst": {"title": "Math", "use_declarations_in": ["arcade.math"]}, + "earclip.rst": {"title": "Earclip", "use_declarations_in": ["arcade.earclip"]}, "gui.rst": { "title": "GUI", "use_declarations_in": [ @@ -288,8 +206,8 @@ "arcade.gui.surface", "arcade.gui.ui_manager", "arcade.gui.nine_patch", - "arcade.gui.view" - ] + "arcade.gui.view", + ], }, "gui_widgets.rst": { "title": "GUI Widgets", @@ -301,51 +219,37 @@ "arcade.gui.widgets.slider", "arcade.gui.widgets.text", "arcade.gui.widgets.toggle", - "arcade.gui.widgets.image" - ] - }, - "gui_events.rst": { - "title": "GUI Events", - "use_declarations_in": [ - "arcade.gui.events" - ] + "arcade.gui.widgets.image", + ], }, + "gui_events.rst": {"title": "GUI Events", "use_declarations_in": ["arcade.gui.events"]}, "gui_properties.rst": { "title": "GUI Properties", - "use_declarations_in": [ - "arcade.gui.property" - ] - }, - "gui_style.rst": { - "title": "GUI Style", - "use_declarations_in": [ - "arcade.gui.style" - ] + "use_declarations_in": ["arcade.gui.property"], }, + "gui_style.rst": {"title": "GUI Style", "use_declarations_in": ["arcade.gui.style"]}, "gui_experimental.rst": { "title": "GUI Experimental Features", "use_declarations_in": [ "arcade.gui.experimental.password_input", "arcade.gui.experimental.scroll_area", - "arcade.gui.experimental.typed_text_input" - ] + "arcade.gui.experimental.typed_text_input", + ], }, "advanced_cameras.rst": { - "title": "Advanced Camera Features", - "use_declarations_in": [ - "arcade.camera.data_types", - "arcade.camera.projection_functions", - "arcade.camera.orthographic", - "arcade.camera.perspective", - "arcade.camera.default", - "arcade.camera.static" - ] + "title": "Advanced Camera Features", + "use_declarations_in": [ + "arcade.camera.data_types", + "arcade.camera.projection_functions", + "arcade.camera.orthographic", + "arcade.camera.perspective", + "arcade.camera.default", + "arcade.camera.static", + ], }, "exceptions.rst": { - "title": "Exceptions", - "use_declarations_in": [ - "arcade.exceptions" - ], + "title": "Exceptions", + "use_declarations_in": ["arcade.exceptions"], }, "start_finish_render.rst": { "title": "Start/Finish Render", @@ -363,19 +267,19 @@ ], }, "future.rst": { - "title": "Future Features", - "use_declarations_in": [ - "arcade.future.texture_render_target", - "arcade.future.input.inputs", - "arcade.future.input.manager", - "arcade.future.input.input_mapping", - "arcade.future.input.raw_dicts", - "arcade.future.background.background_texture", - "arcade.future.background.background", - "arcade.future.background.groups", - "arcade.future.light.lights", - "arcade.future.video.video_player" - ] + "title": "Future Features", + "use_declarations_in": [ + "arcade.future.texture_render_target", + "arcade.future.input.inputs", + "arcade.future.input.manager", + "arcade.future.input.input_mapping", + "arcade.future.input.raw_dicts", + "arcade.future.background.background_texture", + "arcade.future.background.background", + "arcade.future.background.groups", + "arcade.future.light.lights", + "arcade.future.video.video_player", + ], }, } @@ -385,7 +289,7 @@ # Return structure of parsing looks like this DeclarationsDict = dict[ str, # "kind" name or "*" - list[str] # A list of member names + list[str], # A list of member names ] # Patterns + default config dict @@ -395,16 +299,15 @@ # in the rect, box, and other modules. FUNCTION_RE = re.compile("^def ([a-zA-Z][a-zA-Z0-9_]*)") TYPE_RE = re.compile("^(?!LOG =)([A-Za-z][A-Za-z0-9_]*) =") -DEFAULT_EXPRESSIONS = { - 'class': CLASS_RE, - 'function': FUNCTION_RE, +DEFAULT_EXPRESSIONS = { + "class": CLASS_RE, + "function": FUNCTION_RE, # 'type': TYPE_RE } def get_file_declarations( - filepath: Path, - kind_to_regex: Mapping[str, re.Pattern] = DEFAULT_EXPRESSIONS + filepath: Path, kind_to_regex: Mapping[str, re.Pattern] = DEFAULT_EXPRESSIONS ) -> DeclarationsDict: """Use a mapping of kind names to regex to get declarations. @@ -434,7 +337,7 @@ def get_file_declarations( filename = filepath.name # Set up our return value dict - parsed_values = {'*':[]} + parsed_values = {"*": []} for kind_name, exp in kind_to_regex.items(): # print(f" ...with {group_name} expression {e.pattern!r}") parsed_values[kind_name] = [] @@ -446,7 +349,7 @@ def get_file_declarations( for kind, exp in kind_to_regex.items(): parsed_raw = exp.findall(line) parsed_values[kind].extend(parsed_raw) - parsed_values['*'].extend(parsed_raw) + parsed_values["*"].extend(parsed_raw) except Exception as e: print(f"Exception processing {filename} on line {line_no}: {e}") @@ -457,7 +360,6 @@ def get_file_declarations( return parsed_values - # --- 4. API file generation --- def generate_api_file(api_file_name: str, vfs: Vfs): """ @@ -481,8 +383,8 @@ def generate_api_file(api_file_name: str, vfs: Vfs): try: full_api_file_name = API_DOC_GENERATION_DIR / api_file_name - title = page_config.get('title') - use_declarations_in = page_config.get('use_declarations_in', EMPTY_TUPLE) + title = page_config.get("title") + use_declarations_in = page_config.get("use_declarations_in", EMPTY_TUPLE) # print(f"API filename {api_file_name} gets {title=} with {use_declarations_in=}") except Exception as e: @@ -527,7 +429,8 @@ def generate_api_file(api_file_name: str, vfs: Vfs): if "test" in module_name: print( f"WARNING: {module_name!r} appears to contain tests." - f"Those belong in the 'tests/' directory!") + f"Those belong in the 'tests/' directory!" + ) continue # TODO: Figure out how to reliably parse & render types? @@ -535,16 +438,15 @@ def generate_api_file(api_file_name: str, vfs: Vfs): member_lists = get_file_declarations(module_path) # Skip a file if we got no imports - if not len(member_lists['*']): + if not len(member_lists["*"]): print( f"WARNING: No members parsed for {module_name!r} with" f" inferred path {module_path!r}. Check & update your" - f"config?") + f"config?" + ) continue - def iter_declarations( - kind: str - ) -> Generator[tuple[str, str], None, None]: + def iter_declarations(kind: str) -> Generator[tuple[str, str], None, None]: kind_list = member_lists[kind] for name in filter(member_not_excluded, kind_list): yield name, IMPORT_TREE.resolve(f"{module_name}.{name}") @@ -558,7 +460,7 @@ def iter_declarations( # api_file.write("\n") # Classes - for name, full_name in iter_declarations('class'): + for name, full_name in iter_declarations("class"): quick_index_file.write(f" * - :py:class:`{full_name}`\n") quick_index_file.write(f" - {title}\n") @@ -578,7 +480,7 @@ def iter_declarations( # text_file.write(f" - {path_name}\n") # Functions - for name, full_name in iter_declarations('function'): + for name, full_name in iter_declarations("function"): quick_index_file.write(f" * - :py:func:`{full_name}`\n") quick_index_file.write(f" - {title}\n") @@ -595,18 +497,18 @@ def main(): vfs = Vfs() # Delete the API directory files - vfs.request_culling_unwritten(API_DOC_GENERATION_DIR, '*.rst') + vfs.request_culling_unwritten(API_DOC_GENERATION_DIR, "*.rst") # Open in "w" mode to clear with vfs.open_ctx(QUICK_INDEX_FILE_PATH, "w") as text_file: - text_file.include_file( - REPO_ROOT / 'util' / 'template_quick_index.rst') + text_file.include_file(REPO_ROOT / "util" / "template_quick_index.rst") # text_file.write("The Arcade module\n") # text_file.write("-----------------\n\n") - text_file.write(dedent( - """ + text_file.write( + dedent( + """ .. list-table:: :widths: 50 50 :header-rows: 1 @@ -616,7 +518,8 @@ def main(): * - Name - Group """ - )) + ) + ) for filename in API_FILE_TO_TITLE_AND_MODULES.keys(): generate_api_file(filename, vfs) From 380bd05196fae122678327aa79cc2bb4af5e39fb Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Tue, 20 May 2025 13:33:50 -0400 Subject: [PATCH 180/279] fix example --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e67436c235..b840af03f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ lint.exclude = [ ".pytest_cache", "temp", "bugs", - "arcade/examples/platform_tutorial", + "arcade/examples/*", ] lint.ignore = [ "E731", # E731 do not assign a lambda expression, use a def From 7b55f018411a303db9b95912dd128780d9361cc3 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 22 May 2025 22:17:21 +0200 Subject: [PATCH 181/279] Ignore inputs in FocusGroup if no widget is focused --- arcade/gui/experimental/focus.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 02c660c189..e806150120 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -87,7 +87,11 @@ def on_event(self, event: UIEvent) -> bool | None: self.set_focus() return EVENT_HANDLED - if isinstance(event, UIKeyPressEvent): + if self.focused_widget is None: + # no focused widget, ignore events + return EVENT_UNHANDLED + + elif isinstance(event, UIKeyPressEvent): if event.symbol == arcade.key.TAB: if event.modifiers & arcade.key.MOD_SHIFT: self.focus_previous() From 36bd517c33c5c85610f703d078d183a473965d27 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 24 May 2025 14:41:44 +0200 Subject: [PATCH 182/279] Atlas resize shader without geometry shader (#2698) --- arcade/context.py | 10 ++- .../system/shaders/atlas/resize_fs.glsl | 3 +- .../system/shaders/atlas/resize_gs.glsl | 6 +- .../shaders/atlas/resize_simple_fs.glsl | 13 ++++ .../shaders/atlas/resize_simple_vs.glsl | 76 +++++++++++++++++++ arcade/texture_atlas/atlas_default.py | 12 +-- tests/unit/atlas/test_basics.py | 2 +- 7 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 arcade/resources/system/shaders/atlas/resize_simple_fs.glsl create mode 100644 arcade/resources/system/shaders/atlas/resize_simple_vs.glsl diff --git a/arcade/context.py b/arcade/context.py index b9ae6b372f..58d5f94b5f 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -131,9 +131,13 @@ def __init__( ) # Atlas shaders self.atlas_resize_program: Program = self.load_program( - vertex_shader=":system:shaders/atlas/resize_vs.glsl", - geometry_shader=":system:shaders/atlas/resize_gs.glsl", - fragment_shader=":system:shaders/atlas/resize_fs.glsl", + # NOTE: This is the geo shader version of the atlas resize program. + # vertex_shader=":system:shaders/atlas/resize_vs.glsl", + # geometry_shader=":system:shaders/atlas/resize_gs.glsl", + # fragment_shader=":system:shaders/atlas/resize_fs.glsl", + # Vertex and fragment shader version + vertex_shader=":system:shaders/atlas/resize_simple_vs.glsl", + fragment_shader=":system:shaders/atlas/resize_simple_fs.glsl", ) self.atlas_resize_program["atlas_old"] = 0 # Configure texture channels self.atlas_resize_program["atlas_new"] = 1 diff --git a/arcade/resources/system/shaders/atlas/resize_fs.glsl b/arcade/resources/system/shaders/atlas/resize_fs.glsl index deff9722b6..081eef2b24 100644 --- a/arcade/resources/system/shaders/atlas/resize_fs.glsl +++ b/arcade/resources/system/shaders/atlas/resize_fs.glsl @@ -1,6 +1,7 @@ #version 330 -// The old atlas texture. +// The old atlas texture. We copy sections to the new atlas texture +// by render into an fbo with the target texture as the color attachment. uniform sampler2D atlas_old; out vec4 fragColor; diff --git a/arcade/resources/system/shaders/atlas/resize_gs.glsl b/arcade/resources/system/shaders/atlas/resize_gs.glsl index a1bf6d77f1..25a496a8d2 100644 --- a/arcade/resources/system/shaders/atlas/resize_gs.glsl +++ b/arcade/resources/system/shaders/atlas/resize_gs.glsl @@ -4,11 +4,13 @@ #include :system:shaders/lib/sprite.glsl -// Old and new texture coordiantes +// Old and new texture coordinates uniform sampler2D atlas_old; uniform sampler2D atlas_new; + uniform sampler2D texcoords_old; uniform sampler2D texcoords_new; + uniform mat4 projection; uniform float border; @@ -33,7 +35,7 @@ void main() { // absolute value of the diagonal * size + border * 2 vec2 size = abs(new_uv3 - new_uv0) * vec2(size_new) + vec2(border * 2.0); - // We need to offset the old coordiantes by border size + // We need to offset the old coordinates by border size vec2 pix_offset = vec2(border) / vec2(size_old); // ( // 0.015625, 0.015625, # minus, minus diff --git a/arcade/resources/system/shaders/atlas/resize_simple_fs.glsl b/arcade/resources/system/shaders/atlas/resize_simple_fs.glsl new file mode 100644 index 0000000000..fb394386ab --- /dev/null +++ b/arcade/resources/system/shaders/atlas/resize_simple_fs.glsl @@ -0,0 +1,13 @@ +#version 330 +// Atlas resize without geometry shader + +// The old atlas texture. We copy sections to the new atlas texture +// by render into an fbo with the target texture as the color attachment. +uniform sampler2D atlas_old; + +out vec4 fragColor; +in vec2 uv; + +void main() { + fragColor = texture(atlas_old, uv); +} diff --git a/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl b/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl new file mode 100644 index 0000000000..4daecf6e66 --- /dev/null +++ b/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl @@ -0,0 +1,76 @@ +#version 330 +// Atlas resize without geometry shader + +// The render target for this program is the new +// texture atlas texture + +#include :system:shaders/lib/sprite.glsl + +// Old and new texture coordinates +uniform sampler2D atlas_old; +uniform sampler2D atlas_new; + +uniform sampler2D texcoords_old; +uniform sampler2D texcoords_new; + +uniform mat4 projection; +uniform float border; + +out vec2 uv; + +void main() { + // Get the texture sizes + ivec2 size_old = textureSize(atlas_old, 0).xy; + ivec2 size_new = textureSize(atlas_new, 0).xy; + + // Read texture coordinates from UV texture here + int texture_id = gl_VertexID / 6; + vec2 old_uv0, old_uv1, old_uv2, old_uv3; + getSpriteUVs(texcoords_old, texture_id, old_uv0, old_uv1, old_uv2, old_uv3); + vec2 new_uv0, new_uv1, new_uv2, new_uv3; + getSpriteUVs(texcoords_new, texture_id, new_uv0, new_uv1, new_uv2, new_uv3); + + // Lower left corner flipped * size - border + vec2 pos = vec2(new_uv2.x, 1.0 - new_uv2.y) * vec2(size_new) - vec2(border); + // absolute value of the diagonal * size + border * 2 + vec2 size = abs(new_uv3 - new_uv0) * vec2(size_new) + vec2(border * 2.0); + + // We need to offset the old coordinates by border size + vec2 pix_offset = vec2(border) / vec2(size_old); + + // Emit two triangles over 6 vertices + switch (gl_VertexID % 6) { + // First triangle + case 0: + // upper left + uv = old_uv0 - pix_offset; + gl_Position = projection * vec4(pos + vec2(0.0, size.y), 0.0, 1.0); + break; + case 1: + // lower left + uv = old_uv2 + vec2(-pix_offset.x, pix_offset.y); + gl_Position = projection * vec4(pos, 0.0, 1.0); + break; + case 2: + // upper right + uv = old_uv1 + vec2(pix_offset.x, -pix_offset.y); + gl_Position = projection * vec4(pos + vec2(size.x, size.y), 0.0, 1.0); + break; + // Second triangle + case 3: + // lower left + uv = old_uv2 + vec2(-pix_offset.x, pix_offset.y); + gl_Position = projection * vec4(pos, 0.0, 1.0); + break; + case 4: + // upper right + uv = old_uv1 + vec2(pix_offset.x, -pix_offset.y); + gl_Position = projection * vec4(pos + vec2(size.x, size.y), 0.0, 1.0); + break; + case 5: + // lower right + uv = old_uv3 + pix_offset; + gl_Position = projection * vec4(pos + vec2(size.x, 0.0), 0.0, 1.0); + break; + } +} diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index f920b7675d..77a60174f0 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -103,7 +103,7 @@ def __init__( self, size: tuple[int, int], *, - border: int = 1, + border: int = 2, textures: Sequence[Texture] | None = None, auto_resize: bool = True, ctx: ArcadeContext | None = None, @@ -667,8 +667,7 @@ def resize(self, size: tuple[int, int], force=False) -> None: force: Force a resize even if the size is the same """ - # LOG.info("[%s] Resizing atlas from %s to %s", id(self), self._size, size) - # print("Resizing atlas from", self._size, "to", size) + print("Resizing atlas from", self._size, "to", size) # Only resize if the size actually changed if size == self._size and not force: @@ -732,8 +731,9 @@ def resize(self, size: tuple[int, int], force=False) -> None: with self._ctx.enabled_only(): self._ctx.geometry_empty.render( self._ctx.atlas_resize_program, - mode=self._ctx.POINTS, - vertices=self.max_width, + mode=self._ctx.TRIANGLES, + # Two triangles per texture + vertices=UV_TEXTURE_WIDTH * self._capacity * 6, ) # duration = time.perf_counter() - resize_start @@ -746,7 +746,7 @@ def rebuild(self) -> None: This method also tries to organize the textures more efficiently ordering them by size. The texture ids will persist so the sprite list doesn't need to be rebuilt. """ - # LOG.info("Rebuilding atlas") + print("Rebuilding atlas") # Hold a reference to the old textures textures = self.textures diff --git a/tests/unit/atlas/test_basics.py b/tests/unit/atlas/test_basics.py index 1d682a2827..018ab9e09b 100644 --- a/tests/unit/atlas/test_basics.py +++ b/tests/unit/atlas/test_basics.py @@ -11,7 +11,7 @@ def test_create(ctx, common): assert atlas.width == 100 assert atlas.height == 200 assert atlas.size == (100, 200) - assert atlas.border == 1 + assert atlas.border == 2 assert atlas.auto_resize is True assert isinstance(atlas.max_size, tuple) assert atlas.max_size > (0, 0) From 8a92d054965498f99e9d79944911a46638162caf Mon Sep 17 00:00:00 2001 From: Syed Mehdi <114935139+Infamous003@users.noreply.github.com> Date: Tue, 27 May 2025 00:50:24 +0530 Subject: [PATCH 183/279] Fix: Remove unnecessary vertical scrollbar (Resolves #2686) (#2700) * Fix: Remove vertical scrollbar from zoomed out views of resource embed page * Fix: Add missing backticks in CONTRIBUTING.md codeblock --- CONTRIBUTING.md | 4 ++-- doc/_static/css/custom.css | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38ca1b7333..915b818a66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -254,10 +254,10 @@ language. On Linux distros based on Debian and Ubuntu, you may need to install the following packages to build PDFs: -``console +```console sudo apt install latexmk sudo apt install texlive-latex-extra -`` +``` To reduce the large (300 MB+) install size of the second package, you may be able to use the `--no-install-recommends` flag. diff --git a/doc/_static/css/custom.css b/doc/_static/css/custom.css index 3e33eb2830..8e61f66a11 100644 --- a/doc/_static/css/custom.css +++ b/doc/_static/css/custom.css @@ -293,6 +293,10 @@ table.resource-table td > .resource-thumb.file-icon { /* Not clear why this doesn't work for the .caption-text */ font-size: 1em !important; } +.resource-handle > .literal { + /* Removes the verticle scroll when you zoom out more than 100% */ + overflow-y: hidden; +} /* Imitate sphinx-copybutton style */ From 4d98e6e6a3c9f149036001017b88070239f083bb Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 27 May 2025 13:38:52 +0200 Subject: [PATCH 184/279] fix label cut off text caused by multiline and kerning --- arcade/gui/widgets/text.py | 7 ++++--- tests/unit/gui/test_uilabel.py | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 91353eeb43..fd8d877d78 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -126,8 +126,8 @@ def __init__( self._strong_background = True if adaptive_multiline: - # +1 is required to prevent line wrap - width = self._label.content_width + 1 + # +1 is required to prevent line wrap, +1 is required to prevent issues with kerning + width = self._label.content_width + 2 super().__init__( x=x, @@ -242,7 +242,8 @@ def _update_label(self): def _update_size_hint_min(self): """Update the minimum size hint based on the label content size.""" - min_width = self._label.content_width + 1 # +1 required to prevent line wrap + # +1 is required to prevent line wrap, +1 is required to prevent issues with kerning + min_width = self._label.content_width + 2 min_width += self._padding_left + self._padding_right + 2 * self._border_width min_height = self._label.content_height diff --git a/tests/unit/gui/test_uilabel.py b/tests/unit/gui/test_uilabel.py index ae443873f4..a78828d166 100644 --- a/tests/unit/gui/test_uilabel.py +++ b/tests/unit/gui/test_uilabel.py @@ -1,6 +1,5 @@ from unittest.mock import Mock -import pytest from pyglet.math import Vec2 from arcade.gui import UILabel @@ -192,7 +191,7 @@ def test_integration_with_layout_fit_to_content(ui): ui.execute_layout() # auto size should fit the text - assert label.rect.width == 44 + assert label.rect.width == 45 assert label.rect.height == 12 # even when text changed @@ -221,7 +220,7 @@ def test_fit_content_overrides_width(ui): label.fit_content() - assert label.rect.width == 44 + assert label.rect.width == 45 assert label.rect.height == 12 From cd5357e2ab7c802b168ad97b9b4d2839005369d0 Mon Sep 17 00:00:00 2001 From: Alejandro Casanovas Date: Tue, 27 May 2025 13:53:08 +0200 Subject: [PATCH 185/279] fix for mouse enter/leave events when a section is disabled or removed --- arcade/sections.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/arcade/sections.py b/arcade/sections.py index 7426a1a4f1..ef2f19879d 100644 --- a/arcade/sections.py +++ b/arcade/sections.py @@ -612,6 +612,8 @@ def remove_section(self, section: Section) -> None: section.on_hide_section() section._view = None + if section in self.mouse_over_sections: + self.mouse_over_sections.remove(section) self._sections.remove(section) # keep sections order updated in the lists of sections @@ -940,7 +942,7 @@ def dispatch_mouse_enter_leave_events( prevent_dispatch_el = EVENT_UNHANDLED # prevent dispatch for enter/leave events for section in before_sections: - if section not in current_sections: + if section.enabled and section not in current_sections: if prevent_dispatch_el is EVENT_HANDLED: break # dispatch on_mouse_leave to before_section @@ -1053,11 +1055,12 @@ def on_mouse_leave(self, x: int, y: int, *args, **kwargs) -> bool | None: """ prevent_dispatch = EVENT_UNHANDLED for section in self.mouse_over_sections: - if prevent_dispatch is EVENT_HANDLED: - break - prevent_dispatch = self.dispatch_mouse_event( - "on_mouse_leave", x, y, *args, **kwargs, current_section=section - ) + if section.enabled: + if prevent_dispatch is EVENT_HANDLED: + break + prevent_dispatch = self.dispatch_mouse_event( + "on_mouse_leave", x, y, *args, **kwargs, current_section=section + ) # clear the sections the mouse is over as it's out of the screen self.mouse_over_sections = [] return prevent_dispatch From db2c0e692481d9240d9e3c12e7e304064a3539aa Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 27 May 2025 14:13:29 +0200 Subject: [PATCH 186/279] add changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1759f78704..61fff6f8db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fixed an issue causing a crash when closing the window - Added `Window.close` (bool) attribute indicating if the window is closed +- GUI + - Fix `UILabel` with enabled multiline sometimes cut off text ## Version 3.2 From 3501dbd6f26819e639d85bbd1ef7d2e3390eb853 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Thu, 29 May 2025 22:33:43 +0200 Subject: [PATCH 187/279] New spritelist rendering system - Support for non-geo shader system (#2699) * Initial work * Initial attempt separating out spritelist data * More spritelist rendering work * Merge pos and angle buffers * Prepare data for non-geo shaders * Create renderer for non-geo shaders * More work .. * Fix texture formats * First working version * Remove debug prints in atlas * Remove debug code in texture data * Support 1M sprites * Remove debug stuff * Rewrite gpu collision for spritelists * Working gpu collision with texture backend * Remove debug print * GLSL: avoid C-style initialization * Fix tests and typing * Type fix after mypy upgrade * import order * test: Add WindowProxy.register_event_type * test: Skip spritelist interaction for now * Remaining sprite work - UV bias - Single sprite rendering - Cache more stuff in context * lint --- arcade/context.py | 54 + arcade/draw/rect.py | 9 +- arcade/examples/sections_demo_3.py | 4 +- .../shaders/collision/col_tex_trans_gs.glsl | 33 + .../shaders/collision/col_tex_trans_vs.glsl | 20 + .../shaders/collision/col_trans_gs.glsl | 1 + .../shaders/collision/col_trans_vs.glsl | 4 +- .../resources/system/shaders/lib/sprite.glsl | 25 +- .../sprite_list_geometry_cull_geo.glsl | 2 +- .../sprites/sprite_list_geometry_fs.glsl | 8 +- .../sprites/sprite_list_geometry_vs.glsl | 7 +- .../sprites/sprite_list_simple_fs.glsl | 23 + .../sprites/sprite_list_simple_vs.glsl | 75 ++ .../sprites/sprite_single_simple_vs.glsl | 70 ++ arcade/sprite_list/collision.py | 49 +- arcade/sprite_list/sprite_list.py | 1114 ++++++++++++----- arcade/texture_atlas/atlas_default.py | 4 +- tests/conftest.py | 4 + tests/integration/examples/test_examples.py | 1 + tests/unit/spritelist/test_spritelist.py | 16 +- .../spritelist/test_spritelist_buffers.py | 78 +- tests/unit/spritelist/test_spritelist_lazy.py | 7 +- 22 files changed, 1160 insertions(+), 448 deletions(-) create mode 100644 arcade/resources/system/shaders/collision/col_tex_trans_gs.glsl create mode 100644 arcade/resources/system/shaders/collision/col_tex_trans_vs.glsl create mode 100644 arcade/resources/system/shaders/sprites/sprite_list_simple_fs.glsl create mode 100644 arcade/resources/system/shaders/sprites/sprite_list_simple_vs.glsl create mode 100644 arcade/resources/system/shaders/sprites/sprite_single_simple_vs.glsl diff --git a/arcade/context.py b/arcade/context.py index 58d5f94b5f..2e714fa08f 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -3,6 +3,7 @@ Contains pre-loaded programs """ +from array import array from collections.abc import Iterable, Sequence from pathlib import Path from typing import Any @@ -99,6 +100,20 @@ def __init__( self.sprite_list_program_cull["sprite_texture"] = 0 self.sprite_list_program_cull["uv_texture"] = 1 + self.sprite_list_program_no_geo = self.load_program( + vertex_shader=":system:shaders/sprites/sprite_list_simple_vs.glsl", + fragment_shader=":system:shaders/sprites/sprite_list_simple_fs.glsl", + ) + self.sprite_list_program_no_geo["sprite_texture"] = 0 + self.sprite_list_program_no_geo["uv_texture"] = 1 + # Per-instance data + self.sprite_list_program_no_geo["pos_data"] = 2 + self.sprite_list_program_no_geo["size_data"] = 3 + self.sprite_list_program_no_geo["color_data"] = 4 + self.sprite_list_program_no_geo["texture_id_data"] = 5 + self.sprite_list_program_no_geo["index_data"] = 6 + + # Geo shader single sprite program self.sprite_program_single = self.load_program( vertex_shader=":system:shaders/sprites/sprite_single_vs.glsl", geometry_shader=":system:shaders/sprites/sprite_list_geometry_no_cull_geo.glsl", @@ -107,6 +122,34 @@ def __init__( self.sprite_program_single["sprite_texture"] = 0 self.sprite_program_single["uv_texture"] = 1 self.sprite_program_single["spritelist_color"] = 1.0, 1.0, 1.0, 1.0 + # Non-geometry shader single sprite program + self.sprite_program_single_simple = self.load_program( + vertex_shader=":system:shaders/sprites/sprite_single_simple_vs.glsl", + fragment_shader=":system:shaders/sprites/sprite_list_simple_fs.glsl", + ) + self.sprite_program_single_simple["sprite_texture"] = 0 + self.sprite_program_single_simple["uv_texture"] = 1 + self.sprite_program_single_simple["spritelist_color"] = 1.0, 1.0, 1.0, 1.0 + + # fmt: off + self.spritelist_geometry_simple = self.geometry( + [ + BufferDescription( + self.buffer( + data=array("f", [ + -0.5, +0.5, # Upper left + -0.5, -0.5, # lower left + +0.5, +0.5, # upper right + +0.5, -0.5, # lower right + ]) + ), + "2f", + ["in_pos"] + ), + ], + mode=self.TRIANGLE_STRIP, + ) + # fmt: on # Shapes self.shape_line_program: Program = self.load_program( @@ -144,11 +187,22 @@ def __init__( self.atlas_resize_program["texcoords_old"] = 2 self.atlas_resize_program["texcoords_new"] = 3 + # NOTE: These should not be created when WebGL is used # SpriteList collision resources + # Buffer version of the collision detection program. self.collision_detection_program = self.load_program( vertex_shader=":system:shaders/collision/col_trans_vs.glsl", geometry_shader=":system:shaders/collision/col_trans_gs.glsl", ) + # Texture version of the collision detection program. + self.collision_detection_program_simple = self.load_program( + vertex_shader=":system:shaders/collision/col_tex_trans_vs.glsl", + geometry_shader=":system:shaders/collision/col_tex_trans_gs.glsl", + ) + self.collision_detection_program_simple["pos_angle_data"] = 0 + self.collision_detection_program_simple["size_data"] = 1 + self.collision_detection_program_simple["index_data"] = 2 + self.collision_buffer = self.buffer(reserve=1024 * 4) self.collision_query = self.query(samples=False, time=False, primitives=True) diff --git a/arcade/draw/rect.py b/arcade/draw/rect.py index c7790d68b4..0fc3d832ee 100644 --- a/arcade/draw/rect.py +++ b/arcade/draw/rect.py @@ -54,7 +54,7 @@ def draw_texture_rect( ctx.disable(ctx.BLEND) atlas = atlas or ctx.default_atlas - program = ctx.sprite_program_single + program = ctx.sprite_program_single_simple texture_id, _ = atlas.add(texture) if pixelated: @@ -68,14 +68,13 @@ def draw_texture_rect( atlas.use_uv_texture(unit=1) geometry = ctx.geometry_empty - program["pos"] = rect.x, rect.y, 0 + program["pos_rot"] = rect.x, rect.y, 0, angle program["color"] = color.normalized program["size"] = rect.width, rect.height - program["angle"] = angle - program["texture_id"] = float(texture_id) + program["texture_id"] = texture_id program["spritelist_color"] = 1.0, 1.0, 1.0, alpha_normalized - geometry.render(program, mode=gl.POINTS, vertices=1) + geometry.render(program, mode=gl.TRIANGLE_STRIP, vertices=4) if blend: ctx.disable(ctx.BLEND) diff --git a/arcade/examples/sections_demo_3.py b/arcade/examples/sections_demo_3.py index a05640b0cd..d95a1084d3 100644 --- a/arcade/examples/sections_demo_3.py +++ b/arcade/examples/sections_demo_3.py @@ -87,7 +87,7 @@ def draw_button(self): def on_resize(self, width: int, height: int): """set position on screen resize""" self.left = width // 3 - self.bottom = (height // 2) - self.height // 2 + self.bottom = (height // 2) - self.height // 2 # type: ignore pos = self.left + self.width / 2, self.bottom + self.height / 2 self.button.position = pos @@ -203,7 +203,7 @@ def on_mouse_press(self, x: float, y: float, button: int, modifiers: int): def on_resize(self, width: int, height: int): # stick to the right - self.left = width - self.width + self.left = width - self.width # type: ignore self.height = height - self.view.info_bar.height self.button_stop.position = self.left + self.width / 2, self.top - 80 diff --git a/arcade/resources/system/shaders/collision/col_tex_trans_gs.glsl b/arcade/resources/system/shaders/collision/col_tex_trans_gs.glsl new file mode 100644 index 0000000000..ac2e453dad --- /dev/null +++ b/arcade/resources/system/shaders/collision/col_tex_trans_gs.glsl @@ -0,0 +1,33 @@ +#version 330 +// Texture version if collision shader + +layout(points) in; +layout(points, max_vertices=1) out; + +uniform vec2 check_pos; +uniform vec2 check_size; + +in vec2 pos[]; +in vec2 size[]; + +out int spriteIndex; + +void main() { + // Calculate the distance between the sprite center + // and the point we want to check + float dist = distance(pos[0], check_pos); + + // Get the maximum x and y size + // max() works per component + vec2 size = max(size[0], check_size); + + // Destroy the sprite if too far away + if (dist < max(size.x, size.y) * 1.42) { + // Set the sprite index to the current primitive id + // We are only processing points, so it will match + // the spritelist index + spriteIndex = int(gl_PrimitiveIDIn); + EmitVertex(); + } + +} diff --git a/arcade/resources/system/shaders/collision/col_tex_trans_vs.glsl b/arcade/resources/system/shaders/collision/col_tex_trans_vs.glsl new file mode 100644 index 0000000000..d638489af0 --- /dev/null +++ b/arcade/resources/system/shaders/collision/col_tex_trans_vs.glsl @@ -0,0 +1,20 @@ +#version 330 +// Texture version if collision shader + +#include :system:shaders/lib/sprite.glsl + +uniform sampler2D pos_angle_data; +uniform sampler2D size_data; +uniform isampler2D index_data; + +out vec2 pos; +out vec2 size; + +void main() { + int index = getInstanceIndex(index_data, gl_VertexID); + vec4 _pos_rot = getInstancePosRot(pos_angle_data, index); + vec2 _size = getInstanceSize(size_data, index); + + pos = _pos_rot.xy; + size = _size.xy; +} diff --git a/arcade/resources/system/shaders/collision/col_trans_gs.glsl b/arcade/resources/system/shaders/collision/col_trans_gs.glsl index 98524c0098..068ce68e0d 100644 --- a/arcade/resources/system/shaders/collision/col_trans_gs.glsl +++ b/arcade/resources/system/shaders/collision/col_trans_gs.glsl @@ -1,4 +1,5 @@ #version 330 +// Buffer version if collision shader layout(points) in; layout(points, max_vertices=1) out; diff --git a/arcade/resources/system/shaders/collision/col_trans_vs.glsl b/arcade/resources/system/shaders/collision/col_trans_vs.glsl index 9a54e2f12a..9474fbf8a1 100644 --- a/arcade/resources/system/shaders/collision/col_trans_vs.glsl +++ b/arcade/resources/system/shaders/collision/col_trans_vs.glsl @@ -1,7 +1,7 @@ #version 330 -// A simple passthrough shader forwarding data to the geomtry shader +// Buffer version if collision shader -in vec3 in_pos; +in vec4 in_pos; in vec2 in_size; out vec2 pos; diff --git a/arcade/resources/system/shaders/lib/sprite.glsl b/arcade/resources/system/shaders/lib/sprite.glsl index f56908f248..bc96369108 100644 --- a/arcade/resources/system/shaders/lib/sprite.glsl +++ b/arcade/resources/system/shaders/lib/sprite.glsl @@ -1,4 +1,4 @@ -// Fetch texture coordiantes from uv texture +// Fetch texture coordinates from uv texture void getSpriteUVs(sampler2D uvData, int texture_id, out vec2 uv0, out vec2 uv1, out vec2 uv2, out vec2 uv3) { texture_id *= 2; // Calculate the position in the texture. Basic "line wrapping". @@ -14,3 +14,26 @@ void getSpriteUVs(sampler2D uvData, int texture_id, out vec2 uv0, out vec2 uv1, uv2 = data_2.xy; uv3 = data_2.zw; } + +// Functions for fetching per-instance data from textures. +// These are used with the shader program that uses instancing to render sprites +// meaning there is no geo shader involved. This should work for WebGL. +vec4 getInstancePosRot(sampler2D posData, int index) { + return texelFetch(posData, ivec2(index % 256, index / 256), 0); +} + +vec2 getInstanceSize(sampler2D sizeData, int index) { + return texelFetch(sizeData, ivec2(index % 256, index / 256), 0).xy; +} + +vec4 getInstanceColor(sampler2D colorData, int index) { + return texelFetch(colorData, ivec2(index % 256, index / 256), 0); +} + +int getInstanceTextureId(sampler2D textureIdData, int index) { + return int(texelFetch(textureIdData, ivec2(index % 256, index / 256), 0).x); +} + +int getInstanceIndex(isampler2D indexData, int index) { + return texelFetch(indexData, ivec2(index % 256, index / 256), 0).x; +} diff --git a/arcade/resources/system/shaders/sprites/sprite_list_geometry_cull_geo.glsl b/arcade/resources/system/shaders/sprites/sprite_list_geometry_cull_geo.glsl index eadbfdea61..644be8aaaf 100644 --- a/arcade/resources/system/shaders/sprites/sprite_list_geometry_cull_geo.glsl +++ b/arcade/resources/system/shaders/sprites/sprite_list_geometry_cull_geo.glsl @@ -42,7 +42,7 @@ void main() { mat4 mvp = window.projection * window.view; // Do viewport culling for sprites. // We do this in normalized device coordinates to make it simple - // apply projection to the center point. This is important so we get zooming/scrollig right + // apply projection to the center point. This is important so we get zooming/scrolling right vec2 ct = (mvp * vec4(center.xy, 0.0, 1.0)).xy; // We can get away with cheaper calculation of size // The length of the diagonal is the cheapest estimation in case rotation is applied diff --git a/arcade/resources/system/shaders/sprites/sprite_list_geometry_fs.glsl b/arcade/resources/system/shaders/sprites/sprite_list_geometry_fs.glsl index 0149f48c24..7032ae354f 100644 --- a/arcade/resources/system/shaders/sprites/sprite_list_geometry_fs.glsl +++ b/arcade/resources/system/shaders/sprites/sprite_list_geometry_fs.glsl @@ -11,11 +11,11 @@ in vec4 gs_color; out vec4 f_color; void main() { - vec4 basecolor = texture(sprite_texture, gs_uv); - basecolor *= gs_color * spritelist_color; + vec4 base_color = texture(sprite_texture, gs_uv); + base_color *= gs_color * spritelist_color; // Alpha test - if (basecolor.a == 0.0) { + if (base_color.a == 0.0) { discard; } - f_color = basecolor; + f_color = base_color; } diff --git a/arcade/resources/system/shaders/sprites/sprite_list_geometry_vs.glsl b/arcade/resources/system/shaders/sprites/sprite_list_geometry_vs.glsl index ddc169c8ec..9f6c8fe247 100644 --- a/arcade/resources/system/shaders/sprites/sprite_list_geometry_vs.glsl +++ b/arcade/resources/system/shaders/sprites/sprite_list_geometry_vs.glsl @@ -1,7 +1,6 @@ #version 330 -in vec3 in_pos; -in float in_angle; +in vec4 in_pos; in vec2 in_size; in float in_texture; in vec4 in_color; @@ -12,8 +11,8 @@ out vec2 v_size; out float v_texture; void main() { - gl_Position = vec4(in_pos, 1.0); - v_angle = in_angle; + gl_Position = vec4(in_pos.xyz, 1.0); + v_angle = in_pos.w; v_color = in_color; v_size = in_size; v_texture = in_texture; diff --git a/arcade/resources/system/shaders/sprites/sprite_list_simple_fs.glsl b/arcade/resources/system/shaders/sprites/sprite_list_simple_fs.glsl new file mode 100644 index 0000000000..5f6a8ee4c3 --- /dev/null +++ b/arcade/resources/system/shaders/sprites/sprite_list_simple_fs.glsl @@ -0,0 +1,23 @@ +#version 330 +// vert/frag only version of the sprite list shader + +// Texture atlas +uniform sampler2D sprite_texture; +// Global color set on the sprite list +uniform vec4 spritelist_color; + +in vec2 v_uv; +in vec4 v_color; + +out vec4 f_color; + +void main() { + // vec4 base_color = v_color; + vec4 base_color = texture(sprite_texture, v_uv); + base_color *= v_color * spritelist_color; + // Alpha test + if (base_color.a == 0.0) { + discard; + } + f_color = base_color; +} diff --git a/arcade/resources/system/shaders/sprites/sprite_list_simple_vs.glsl b/arcade/resources/system/shaders/sprites/sprite_list_simple_vs.glsl new file mode 100644 index 0000000000..fe6a852118 --- /dev/null +++ b/arcade/resources/system/shaders/sprites/sprite_list_simple_vs.glsl @@ -0,0 +1,75 @@ +#version 330 +// vert/frag only version of the sprite list shader + +uniform WindowBlock { + mat4 projection; + mat4 view; +} window; + +// Texture atlas +uniform sampler2D sprite_texture; +// Texture containing UVs for the entire atlas +uniform sampler2D uv_texture; + +// Per instance data +uniform sampler2D pos_data; +uniform sampler2D size_data; +uniform sampler2D color_data; +uniform sampler2D texture_id_data; +uniform isampler2D index_data; + +// How much half-pixel offset to apply to the UVs. +// 0.0 is no offset, 1.0 is half a pixel offset +uniform float uv_offset_bias; + +// Instanced geometry (rectangle as triangle strip) +in vec2 in_pos; + +// Output to frag shader +out vec2 v_uv; +out vec4 v_color; + +#include :system:shaders/lib/sprite.glsl + +void main() { + // Reading per-instance data from textures. + // First we need take the index texture into account to get the correct rendering order. + int index = getInstanceIndex(index_data, gl_InstanceID); + vec4 pos_rot = getInstancePosRot(pos_data, index); + vec2 size = getInstanceSize(size_data, index); + vec4 color = getInstanceColor(color_data, index); + int texture_id = getInstanceTextureId(texture_id_data, index); + // Read texture coordinates from UV texture here + vec2 uv0, uv1, uv2, uv3; + getSpriteUVs(uv_texture, texture_id, uv0, uv1, uv2, uv3); + + vec3 center = pos_rot.xyz; + float angle = radians(pos_rot.w); + mat2 rot = mat2( + cos(angle), -sin(angle), + sin(angle), cos(angle) + ); + + mat4 mvp = window.projection * window.view; + + // Apply half pixel offset modified by bias. + // What bias to set depends on the texture filtering mode. + // Linear can have 1.0 bias while nearest should have 0.0 (unless scale is 1:1) + // uvs ( + // 0.0, 0.0, + // 1.0, 0.0, + // 0.0, 1.0, + // 1.0, 1.0 + // ) + vec2 hp = 0.5 / vec2(textureSize(sprite_texture, 0)) * uv_offset_bias; + uv0 += hp; + uv1 += vec2(-hp.x, hp.y); + uv2 += vec2(hp.x, -hp.y); + uv3 += -hp; + + int vertex_id = gl_VertexID % 4; + vec2 uvs[4] = vec2[4](uv0, uv2, uv1, uv3); + v_color = color; + gl_Position = mvp * vec4(rot * (in_pos * size) + center.xy, 0.0, 1.0); + v_uv = uvs[vertex_id]; +} diff --git a/arcade/resources/system/shaders/sprites/sprite_single_simple_vs.glsl b/arcade/resources/system/shaders/sprites/sprite_single_simple_vs.glsl new file mode 100644 index 0000000000..c3a9821f8d --- /dev/null +++ b/arcade/resources/system/shaders/sprites/sprite_single_simple_vs.glsl @@ -0,0 +1,70 @@ +#version 330 +// vert/frag only version of the sprite list shader + +uniform WindowBlock { + mat4 projection; + mat4 view; +} window; + +// Texture atlas +uniform sampler2D sprite_texture; +// Texture containing UVs for the entire atlas +uniform sampler2D uv_texture; + +uniform vec4 pos_rot; // rect.x, rect.y, 0, angle +uniform vec4 color; // color.normalized +uniform vec2 size; // rect.width, rect.height +uniform int texture_id; + +// How much half-pixel offset to apply to the UVs. +// 0.0 is no offset, 1.0 is half a pixel offset +uniform float uv_offset_bias; + +// Output to frag shader +out vec2 v_uv; +out vec4 v_color; + +#include :system:shaders/lib/sprite.glsl + + +const vec2 vertices[4] = vec2[4]( + vec2(-0.5, +0.5), // Upper left + vec2(-0.5, -0.5), // lower left + vec2(+0.5, +0.5), // upper right + vec2(+0.5, -0.5) // lower right +); + +void main() { + vec2 uv0, uv1, uv2, uv3; + getSpriteUVs(uv_texture, texture_id, uv0, uv1, uv2, uv3); + + vec3 center = pos_rot.xyz; + float angle = radians(pos_rot.w); + mat2 rot = mat2( + cos(angle), -sin(angle), + sin(angle), cos(angle) + ); + + mat4 mvp = window.projection * window.view; + + // Apply half pixel offset modified by bias. + // What bias to set depends on the texture filtering mode. + // Linear can have 1.0 bias while nearest should have 0.0 (unless scale is 1:1) + // uvs ( + // 0.0, 0.0, + // 1.0, 0.0, + // 0.0, 1.0, + // 1.0, 1.0 + // ) + vec2 hp = 0.5 / vec2(textureSize(sprite_texture, 0)) * uv_offset_bias; + uv0 += hp; + uv1 += vec2(-hp.x, hp.y); + uv2 += vec2(hp.x, -hp.y); + uv3 += -hp; + + int vertex_id = gl_VertexID % 4; + vec2 uvs[4] = vec2[4](uv0, uv2, uv1, uv3); + v_color = color; + gl_Position = mvp * vec4(rot * (vertices[vertex_id] * size) + center.xy, 0.0, 1.0); + v_uv = uvs[vertex_id]; +} diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 60330bb1eb..48f8323e18 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -1,9 +1,5 @@ -import struct from collections.abc import Iterable -from arcade import ( - get_window, -) from arcade.geometry import ( are_polygons_intersecting, is_point_in_polygon, @@ -134,50 +130,7 @@ def _get_nearby_sprites( sprite_count = len(sprite_list) if sprite_count == 0: return [] - - # Update the position and size to check - ctx = get_window().ctx - sprite_list._write_sprite_buffers_to_gpu() - - ctx.collision_detection_program["check_pos"] = sprite.position - ctx.collision_detection_program["check_size"] = sprite.width, sprite.height - - # Ensure the result buffer can fit all the sprites (worst case) - buffer = ctx.collision_buffer - if buffer.size < sprite_count * 4: - buffer.orphan(size=sprite_count * 4) - - # Run the transform shader emitting sprites close to the configured position and size. - # This runs in a query so we can measure the number of sprites emitted. - with ctx.collision_query: - sprite_list.geometry.transform( # type: ignore - ctx.collision_detection_program, - buffer, - vertices=sprite_count, - ) - - # Store the number of sprites emitted - emit_count = ctx.collision_query.primitives_generated - # print( - # emit_count, - # ctx.collision_query.time_elapsed, - # ctx.collision_query.time_elapsed / 1_000_000_000, - # ) - - # If no sprites emitted we can just return an empty list - if emit_count == 0: - return [] - - # # Debug block for transform data to keep around - # print("emit_count", emit_count) - # data = buffer.read(size=emit_count * 4) - # print("bytes", data) - # print("data", struct.unpack(f'{emit_count}i', data)) - - # .. otherwise build and return a list of the sprites selected by the transform - return [ - sprite_list[i] for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4)) - ] + return sprite_list.get_nearby_sprites_gpu(sprite.position, sprite.size) def check_for_collision_with_list( diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 8c07f359ff..83887a83b6 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -8,6 +8,7 @@ from __future__ import annotations import random +import struct from abc import abstractmethod from array import array from collections import deque @@ -24,15 +25,23 @@ from arcade.gl.buffer import Buffer from arcade.gl.types import BlendFunction, OpenGlFilter, PyGLenum from arcade.gl.vertex_array import Geometry -from arcade.types import RGBA255, Color, Point2, RGBANormalized, RGBOrA255, RGBOrANormalized +from arcade.types import RGBA255, Color, Point, Point2, RGBANormalized, RGBOrA255, RGBOrANormalized from arcade.utils import copy_dunders_unimplemented if TYPE_CHECKING: - from arcade import DefaultTextureAtlas, Texture + from arcade import ArcadeContext, Texture from arcade.texture_atlas import TextureAtlasBase -# The default capacity from spritelists -_DEFAULT_CAPACITY = 100 + +def _align_capacity(capacity: int) -> int: + """ + Aligns the capacity to be a multiple of 256. + This is important to make the data compatible with different + types of storage such as buffers and textures. + """ + if capacity <= 0: + return 256 + return (capacity + 255) // 256 * 256 class SpriteSequence(Collection[SpriteType_co]): @@ -131,6 +140,23 @@ def draw_hit_boxes( """ ... + @abstractmethod + def get_nearby_sprites_gpu(self, pos: Point, size: Point) -> list[SpriteType_co]: + """ + Get a list of sprites that are nearby the given position and size + using the gpu. No spatial hashing is needed. This is a very fast method + to find nearby sprites in large spritelists but is very expensive + if the method is called many times per frame or if the sprite list + is small. + + Args: + pos: The position to check for nearby sprites. + size: The size of the area to check for nearby sprites. + Returns: + A list of sprites nearby the given position and size. + """ + ... + @abstractmethod def _write_sprite_buffers_to_gpu(self) -> None: ... @@ -227,10 +253,11 @@ def __init__( self._blend = True self._color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0) + capacity = _align_capacity(capacity) # The initial capacity of the spritelist buffers (internal) - self._buf_capacity = abs(capacity) or _DEFAULT_CAPACITY + self._buf_capacity = capacity # The initial capacity of the index buffer (internal) - self._idx_capacity = abs(capacity) or _DEFAULT_CAPACITY + self._idx_capacity = capacity # The number of slots used in the sprite buffer self._sprite_buffer_slots = 0 # Number of slots used in the index buffer @@ -245,30 +272,20 @@ def __init__( self.sprite_slot: dict[SpriteType, int] = dict() # Python representation of buffer data - self._sprite_pos_data = array("f", [0] * self._buf_capacity * 3) + # NOTE: The number of components must be 1, 2 or 4. 3 floats is not supported + # for most iGPUs due to alignment issues. + self._sprite_pos_angle_data = array("f", [0] * self._buf_capacity * 4) self._sprite_size_data = array("f", [0] * self._buf_capacity * 2) - self._sprite_angle_data = array("f", [0] * self._buf_capacity) self._sprite_color_data = array("B", [0] * self._buf_capacity * 4) self._sprite_texture_data = array("f", [0] * self._buf_capacity) # Index buffer self._sprite_index_data = array("i", [0] * self._idx_capacity) - # Define and annotate storage space for buffers - self._sprite_pos_buf: Buffer | None = None - self._sprite_size_buf: Buffer | None = None - self._sprite_angle_buf: Buffer | None = None - self._sprite_color_buf: Buffer | None = None - self._sprite_texture_buf: Buffer | None = None - - # Index buffer - self._sprite_index_buf: Buffer | None = None - - self._geometry: Geometry | None = None + self._data: SpriteListData | None = None # Flags for signaling if a buffer needs to be written to the OpenGL buffer - self._sprite_pos_changed: bool = False + self._sprite_pos_angle_changed: bool = False self._sprite_size_changed: bool = False - self._sprite_angle_changed: bool = False self._sprite_color_changed: bool = False self._sprite_texture_changed: bool = False self._sprite_index_changed: bool = False @@ -301,41 +318,20 @@ def _init_deferred(self) -> None: return self.ctx = get_window().ctx - self.program = self.ctx.sprite_list_program_cull if not self._atlas: self._atlas = self.ctx.default_atlas - # Buffers for each sprite attribute (read by shader) with initial capacity - self._sprite_pos_buf = self.ctx.buffer(reserve=self._buf_capacity * 12) # 3 x 32 bit floats - self._sprite_size_buf = self.ctx.buffer(reserve=self._buf_capacity * 8) # 2 x 32 bit floats - self._sprite_angle_buf = self.ctx.buffer(reserve=self._buf_capacity * 4) # 32 bit float - self._sprite_color_buf = self.ctx.buffer(reserve=self._buf_capacity * 4) # 4 x bytes colors - self._sprite_texture_buf = self.ctx.buffer(reserve=self._buf_capacity * 4) # 32 bit int - # Index buffer - self._sprite_index_buf = self.ctx.buffer( - reserve=self._idx_capacity * 4 - ) # 32 bit unsigned integers - - contents = [ - gl.BufferDescription(self._sprite_pos_buf, "3f", ["in_pos"]), - gl.BufferDescription(self._sprite_size_buf, "2f", ["in_size"]), - gl.BufferDescription(self._sprite_angle_buf, "1f", ["in_angle"]), - gl.BufferDescription(self._sprite_texture_buf, "1f", ["in_texture"]), - gl.BufferDescription( - self._sprite_color_buf, - "4f1", - ["in_color"], - ), - ] - self._geometry = self.ctx.geometry( - contents, - index_buffer=self._sprite_index_buf, - index_element_size=4, # 32 bit integers - ) - + # NOTE: Instantiate the appropriate spritelist data class here + # Desktop GL (with geo shader) + # self._data = SpriteListBufferData( + # self.ctx, capacity=self._buf_capacity, atlas=self._atlas + # ) + # WebGL (without geo shader) + self._data = SpriteListTextureData(self.ctx, capacity=self._buf_capacity, atlas=self._atlas) self._initialized = True # Load all the textures and write texture coordinates into buffers. + # This is important for lazy spritelists. for sprite in self.sprite_list: if sprite._texture is None: raise ValueError("Attempting to use a sprite without a texture") @@ -346,9 +342,8 @@ def _init_deferred(self) -> None: for texture in sprite.textures or []: self._atlas.add(texture) - self._sprite_pos_changed = True + self._sprite_pos_angle_changed = True self._sprite_size_changed = True - self._sprite_angle_changed = True self._sprite_color_changed = True self._sprite_texture_changed = True self._sprite_index_changed = True @@ -502,131 +497,12 @@ def atlas(self) -> TextureAtlasBase | None: return self._atlas @property - def geometry(self) -> Geometry: - """ - Returns the internal OpenGL geometry for this spritelist. - This can be used to execute custom shaders with the - spritelist data. - - One or multiple of the following inputs must be defined in your vertex shader:: - - in vec2 in_pos; - in float in_angle; - in vec2 in_size; - in float in_texture; - in vec4 in_color; - """ + def data(self) -> SpriteListData: + """Get the sprite data for this spritelist.""" if not self._initialized: self.initialize() - return self._geometry # type: ignore - - @property - def buffer_positions(self) -> Buffer: - """ - Get the internal OpenGL position buffer for this spritelist. - - The buffer contains 32 bit float values with - x, y and z positions. These are the center positions - for each sprite. - - This buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` - instance with name ``in_pos``. - """ - if self._sprite_pos_buf is None: - raise ValueError("SpriteList is not initialized") - return self._sprite_pos_buf - - @property - def buffer_sizes(self) -> Buffer: - """ - Get the internal OpenGL size buffer for this spritelist. - - The buffer contains 32 bit float width and height values. - - This buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` - instance with name ``in_size``. - """ - if self._sprite_size_buf is None: - raise ValueError("SpriteList is not initialized") - return self._sprite_size_buf - - @property - def buffer_angles(self) -> Buffer: - """ - Get the internal OpenGL angle buffer for the spritelist. - - This buffer contains a series of 32 bit floats - representing the rotation angle for each sprite in degrees. - - This buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` - instance with name ``in_angle``. - """ - if self._sprite_angle_buf is None: - raise ValueError("SpriteList is not initialized") - return self._sprite_angle_buf - - @property - def buffer_colors(self) -> Buffer: - """ - Get the internal OpenGL color buffer for this spritelist. - - This buffer contains a series of 32 bit floats representing - the RGBA color for each sprite. 4 x floats = RGBA. - - - This buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` - instance with name ``in_color``. - """ - if self._sprite_color_buf is None: - raise ValueError("SpriteList is not initialized") - return self._sprite_color_buf - - @property - def buffer_textures(self) -> Buffer: - """ - Get the internal openGL texture id buffer for the spritelist. - - This buffer contains a series of single 32 bit floats referencing - a texture ID. This ID references a texture in the texture - atlas assigned to this spritelist. The ID is used to look up - texture coordinates in a 32bit floating point texture the - texture atlas provides. This system makes sure we can resize - and rebuild a texture atlas without having to rebuild every - single spritelist. - - This buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` - instance with name ``in_texture``. - - Note that it should ideally an unsigned integer, but due to - compatibility we store them as 32 bit floats. We cast them - to integers in the shader. - """ - if self._sprite_texture_buf is None: - raise ValueError("SpriteList is not initialized") - return self._sprite_texture_buf - - @property - def buffer_indices(self) -> Buffer: - """ - Get the internal index buffer for this spritelist. - - The data in the other buffers are not in the correct order - matching ``spritelist[i]``. The index buffer has to be - used used to resolve the right order. It simply contains - a series of integers referencing locations in the other buffers. - - Also note that the length of this buffer might be bigger than - the number of sprites. Rely on ``len(spritelist)`` for the - correct length. - - This index buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` - instance and will be automatically be applied the the input buffers - when rendering or transforming. - """ - if self._sprite_index_buf is None: - raise ValueError("SpriteList is not initialized") - return self._sprite_index_buf + return self._data # type: ignore[return-value] def _next_slot(self) -> int: """ @@ -687,7 +563,7 @@ def clear(self, *, capacity: int | None = None, deep: bool = True) -> None: self.spatial_hash = SpatialHash(cell_size=self._spatial_hash_cell_size) # Clear the slot_idx and slot info and other states - capacity = abs(capacity or self._buf_capacity) + capacity = _align_capacity(capacity or self._buf_capacity) self._buf_capacity = capacity self._idx_capacity = capacity @@ -697,9 +573,8 @@ def clear(self, *, capacity: int | None = None, deep: bool = True) -> None: # Reset buffers # Python representation of buffer data - self._sprite_pos_data = array("f", [0] * self._buf_capacity * 3) + self._sprite_pos_angle_data = array("f", [0] * self._buf_capacity * 4) self._sprite_size_data = array("f", [0] * self._buf_capacity * 2) - self._sprite_angle_data = array("f", [0] * self._buf_capacity) self._sprite_color_data = array("B", [0] * self._buf_capacity * 4) self._sprite_texture_data = array("f", [0] * self._buf_capacity) # Index buffer @@ -757,7 +632,6 @@ def append(self, sprite: SpriteType) -> None: Args: sprite: Sprite to add to the list. """ - # print(f"{id(self)} : {id(sprite)} append") if sprite in self.sprite_slot: raise ValueError("Sprite already in SpriteList") @@ -870,7 +744,6 @@ def insert(self, index: int, sprite: SpriteType) -> None: self._update_all(sprite) # Allocate room in the index buffer - self._normalize_index_buffer() # idx_slot = self._sprite_index_slots self._sprite_index_slots += 1 self._grow_index_buffer() @@ -882,9 +755,6 @@ def insert(self, index: int, sprite: SpriteType) -> None: def reverse(self) -> None: """Reverses the current list in-place""" - # Ensure the index buffer is normalized - self._normalize_index_buffer() - # Reverse the sprites and index buffer self.sprite_list.reverse() # This seems to be the reasonable way to reverse a subset of an array @@ -900,9 +770,6 @@ def shuffle(self) -> None: # to shuffle the sprite_list and index buffer in # in the same operation. We don't change the sprite buffers - # Make sure the index buffer is the same length as the sprite list - self._normalize_index_buffer() - # zip index and sprite into pairs and shuffle pairs = list(zip(self.sprite_list, self._sprite_index_data)) random.shuffle(pairs) @@ -945,9 +812,6 @@ def create_y_pos_comparison(sprite): reverse: If set to ``True`` the sprites will be sorted in reverse """ - # Ensure the index buffer is normalized - self._normalize_index_buffer() - # In-place sort the spritelist self.sprite_list.sort(key=key, reverse=reverse) # Loop over the sorted sprites and assign new values in index buffer @@ -1050,35 +914,28 @@ def write_sprite_buffers_to_gpu(self) -> None: self._write_sprite_buffers_to_gpu() def _write_sprite_buffers_to_gpu(self) -> None: - if self._sprite_pos_changed and self._sprite_pos_buf: - self._sprite_pos_buf.orphan() - self._sprite_pos_buf.write(self._sprite_pos_data) - self._sprite_pos_changed = False - - if self._sprite_size_changed and self._sprite_size_buf: - self._sprite_size_buf.orphan() - self._sprite_size_buf.write(self._sprite_size_data) - self._sprite_size_changed = False - - if self._sprite_angle_changed and self._sprite_angle_buf: - self._sprite_angle_buf.orphan() - self._sprite_angle_buf.write(self._sprite_angle_data) - self._sprite_angle_changed = False - - if self._sprite_color_changed and self._sprite_color_buf: - self._sprite_color_buf.orphan() - self._sprite_color_buf.write(self._sprite_color_data) - self._sprite_color_changed = False - - if self._sprite_texture_changed and self._sprite_texture_buf: - self._sprite_texture_buf.orphan() - self._sprite_texture_buf.write(self._sprite_texture_data) - self._sprite_texture_changed = False + if not self._initialized: + self._init_deferred() - if self._sprite_index_changed and self._sprite_index_buf: - self._sprite_index_buf.orphan() - self._sprite_index_buf.write(self._sprite_index_data) - self._sprite_index_changed = False + self.data.write_sprite_buffers_to_gpu( + # Buffer data + self._sprite_pos_angle_data, + self._sprite_size_data, + self._sprite_color_data, + self._sprite_texture_data, + self._sprite_index_data, + # Changed flags + self._sprite_pos_angle_changed, + self._sprite_size_changed, + self._sprite_color_changed, + self._sprite_texture_changed, + self._sprite_index_changed, + ) + self._sprite_pos_angle_changed = False + self._sprite_size_changed = False + self._sprite_color_changed = False + self._sprite_texture_changed = False + self._sprite_index_changed = False def initialize(self) -> None: """ @@ -1107,69 +964,18 @@ def draw( return self._init_deferred() - if not self.program: - raise ValueError("Attempting to render without shader program.") self._write_sprite_buffers_to_gpu() - - prev_blend_func = self.ctx.blend_func - if self._blend: - self.ctx.enable(self.ctx.BLEND) - # Set custom blend function or revert to default - if blend_function is not None: - self.ctx.blend_func = blend_function - else: - self.ctx.blend_func = self.ctx.BLEND_DEFAULT - else: - self.ctx.disable(self.ctx.BLEND) - - # Workarounds for Optional[TextureAtlas] + slow . lookup speed - atlas: DefaultTextureAtlas = self.atlas # type: ignore - atlas_texture: Texture2D = atlas.texture - - # Set custom filter or reset to default - if filter: - if hasattr( - filter, - "__len__", - ): # assume it's a collection - if len(cast(Sized, filter)) != 2: - raise ValueError("Can't use sequence of length != 2") - atlas_texture.filter = tuple(filter) # type: ignore - else: # assume it's an int - atlas_texture.filter = cast(OpenGlFilter, (filter, filter)) - else: - # Handle the pixelated shortcut if filter is not set - if pixelated: - atlas_texture.filter = self.ctx.NEAREST, self.ctx.NEAREST - else: - atlas_texture.filter = self.DEFAULT_TEXTURE_FILTER - - self.program["spritelist_color"] = self._color - - # Control center pixel interpolation: - # 0.0 = raw interpolation using texture corners - # 1.0 = center pixel interpolation - if self.ctx.NEAREST in atlas_texture.filter: - self.program.set_uniform_safe("uv_offset_bias", 0.0) - else: - self.program.set_uniform_safe("uv_offset_bias", 1.0) - - atlas_texture.use(0) - atlas.use_uv_texture(1) - if not self._geometry: - raise ValueError("Attempting to render without '_geometry' field being set.") - self._geometry.render( - self.program, - mode=self.ctx.POINTS, - vertices=self._sprite_index_slots, + self.data.render( + atlas=self._atlas, # type: ignore + count=self._sprite_index_slots, + color=self._color, + default_texture_filter=self.DEFAULT_TEXTURE_FILTER, + filter=filter, + pixelated=pixelated, + blend_function=blend_function, + blend=self._blend, ) - # Leave global states to default - if self._blend: - self.ctx.disable(self.ctx.BLEND) - if blend_function is not None: - self.ctx.blend_func = prev_blend_func - def draw_hit_boxes( self, color: RGBOrA255 = (0, 0, 0, 255), line_thickness: float = 1.0 ) -> None: @@ -1190,24 +996,29 @@ def draw_hit_boxes( arcade.draw_lines(points, color=converted_color, line_width=line_thickness) - def _normalize_index_buffer(self) -> None: - """ - Removes unused slots in the index buffer. - The other buffers don't need this because they re-use slots. - New sprites on the other hand always needs to be added - to the end of the index buffer to preserve order - """ - # NOTE: Currently we keep the index buffer normalized - # but we can increase the performance in the future - # delaying normalization. - # Need counter for how many slots are used in index buffer. - # 1) Sort the deleted indices (descending) and pop() them in a loop - # 2) Create a new array.array and manually copy every - # item in the list except the deleted index slots - # 3) Use a transform (gpu) to trim the index buffer and - # read this buffer back into a new array using array.from_bytes - # NOTE: Right now the index buffer is always normalized - pass + def get_nearby_sprites_gpu(self, pos: Point, size: Point) -> list[SpriteType]: + """ + Get a list of sprites that are nearby the given position and size + using the gpu. No spatial hashing is needed. This is a very fast method + to find nearby sprites in large spritelists but is very expensive + if the method is called many times per frame or if the sprite list + is small. + + Args: + pos: The position to check for nearby sprites. + size: The size of the area to check for nearby sprites. + Returns: + A list of sprites nearby the given position and size. + """ + if not self._initialized: + self._init_deferred() + + if len(self.sprite_list) == 0: + return [] + + self._write_sprite_buffers_to_gpu() + indices = self.data.get_nearby_sprite_indices(pos, size, len(self.sprite_list)) + return [self.sprite_list[i] for i in indices] def _grow_sprite_buffers(self) -> None: """Double the internal buffer sizes""" @@ -1220,28 +1031,22 @@ def _grow_sprite_buffers(self) -> None: self._buf_capacity = self._buf_capacity * 2 # Extend the buffers so we don't lose the old data - self._sprite_pos_data.extend([0] * extend_by * 3) + self._sprite_pos_angle_data.extend([0] * extend_by * 4) self._sprite_size_data.extend([0] * extend_by * 2) - self._sprite_angle_data.extend([0] * extend_by) self._sprite_color_data.extend([0] * extend_by * 4) self._sprite_texture_data.extend([0] * extend_by) if self._initialized: - # Proper initialization implies these buffers are allocated - self._sprite_pos_buf.orphan(double=True) # type: ignore - self._sprite_size_buf.orphan(double=True) # type: ignore - self._sprite_angle_buf.orphan(double=True) # type: ignore - self._sprite_color_buf.orphan(double=True) # type: ignore - self._sprite_texture_buf.orphan(double=True) # type: ignore - - self._sprite_pos_changed = True + self.data.grow_sprite_buffers() + + self._sprite_pos_angle_changed = True self._sprite_size_changed = True - self._sprite_angle_changed = True self._sprite_color_changed = True self._sprite_texture_changed = True def _grow_index_buffer(self) -> None: # Extend the index buffer capacity if needed + # TODO: We might not need this any more since index buffer is always normalized if self._sprite_index_slots <= self._idx_capacity: return @@ -1249,8 +1054,8 @@ def _grow_index_buffer(self) -> None: self._idx_capacity = self._idx_capacity * 2 self._sprite_index_data.extend([0] * extend_by) - if self._initialized and self._sprite_index_buf: - self._sprite_index_buf.orphan(size=self._idx_capacity * 4) + if self._initialized: + self.data.grow_index_buffer() self._sprite_index_changed = True @@ -1264,17 +1069,16 @@ def _update_all(self, sprite: SpriteType) -> None: """ slot = self.sprite_slot[sprite] # position - self._sprite_pos_data[slot * 3] = sprite._position[0] - self._sprite_pos_data[slot * 3 + 1] = sprite._position[1] - self._sprite_pos_data[slot * 3 + 2] = sprite._depth - self._sprite_pos_changed = True + self._sprite_pos_angle_data[slot * 4] = sprite._position[0] + self._sprite_pos_angle_data[slot * 4 + 1] = sprite._position[1] + self._sprite_pos_angle_data[slot * 4 + 2] = sprite._depth + self._sprite_pos_angle_data[slot * 4 + 3] = sprite._angle + self._sprite_pos_angle_changed = True # size self._sprite_size_data[slot * 2] = sprite._width self._sprite_size_data[slot * 2 + 1] = sprite._height self._sprite_size_changed = True # angle - self._sprite_angle_data[slot] = sprite._angle - self._sprite_angle_changed = True # color self._sprite_color_data[slot * 4] = sprite._color[0] self._sprite_color_data[slot * 4 + 1] = sprite._color[1] @@ -1337,9 +1141,9 @@ def _update_position(self, sprite: SpriteType) -> None: sprite: Sprite to update. """ slot = self.sprite_slot[sprite] - self._sprite_pos_data[slot * 3] = sprite._position[0] - self._sprite_pos_data[slot * 3 + 1] = sprite._position[1] - self._sprite_pos_changed = True + self._sprite_pos_angle_data[slot * 4] = sprite._position[0] + self._sprite_pos_angle_data[slot * 4 + 1] = sprite._position[1] + self._sprite_pos_angle_changed = True def _update_position_x(self, sprite: SpriteType) -> None: """ @@ -1353,8 +1157,8 @@ def _update_position_x(self, sprite: SpriteType) -> None: sprite: Sprite to update. """ slot = self.sprite_slot[sprite] - self._sprite_pos_data[slot * 3] = sprite._position[0] - self._sprite_pos_changed = True + self._sprite_pos_angle_data[slot * 4] = sprite._position[0] + self._sprite_pos_angle_changed = True def _update_position_y(self, sprite: SpriteType) -> None: """ @@ -1368,8 +1172,8 @@ def _update_position_y(self, sprite: SpriteType) -> None: sprite: Sprite to update. """ slot = self.sprite_slot[sprite] - self._sprite_pos_data[slot * 3 + 1] = sprite._position[1] - self._sprite_pos_changed = True + self._sprite_pos_angle_data[slot * 4 + 1] = sprite._position[1] + self._sprite_pos_angle_changed = True def _update_depth(self, sprite: SpriteType) -> None: """ @@ -1380,8 +1184,8 @@ def _update_depth(self, sprite: SpriteType) -> None: sprite: Sprite to update. """ slot = self.sprite_slot[sprite] - self._sprite_pos_data[slot * 3 + 2] = sprite._depth - self._sprite_pos_changed = True + self._sprite_pos_angle_data[slot * 4 + 2] = sprite._depth + self._sprite_pos_angle_changed = True def _update_color(self, sprite: SpriteType) -> None: """ @@ -1445,5 +1249,667 @@ def _update_angle(self, sprite: SpriteType) -> None: sprite: Sprite to update. """ slot = self.sprite_slot[sprite] - self._sprite_angle_data[slot] = sprite._angle - self._sprite_angle_changed = True + self._sprite_pos_angle_data[slot * 4 + 3] = sprite._angle + self._sprite_pos_angle_changed = True + + +class SpriteListData: + """Base class for sprite list data.""" + + def __init__(self, ctx: ArcadeContext, capacity: int) -> None: + self.ctx = ctx + self._buf_capacity = capacity + self._idx_capacity = capacity + + # Generic GPU storage for sprite data + self._storage_pos_angle: Buffer | Texture2D + self._storage_size: Buffer | Texture2D + self._storage_color: Buffer | Texture2D + self._storage_texture_id: Buffer | Texture2D + self._storage_index: Buffer | Texture2D + + @property + def storage_positions_angle(self) -> Buffer | Texture2D: + """ + Returns the buffer for sprite positions and angles. + This is a buffer of 4 x 32 bit floats (x, y, depth, angle). + """ + return self._storage_pos_angle + + @property + def storage_size(self) -> Buffer | Texture2D: + """ + Returns the buffer for sprite sizes. + This is a buffer of 2 x 32 bit floats (width, height). + """ + return self._storage_size + + @property + def storage_color(self) -> Buffer | Texture2D: + """ + Returns the buffer for sprite colors. + This is a buffer of 4 x bytes (r, g, b, a). + """ + return self._storage_color + + @property + def storage_texture_id(self) -> Buffer | Texture2D: + """ + Returns the buffer for sprite texture IDs. + This is a buffer of 32 bit integers (texture ID). + """ + return self._storage_texture_id + + @property + def storage_index(self) -> Buffer | Texture2D: + """ + Returns the buffer for sprite indices. + This is a buffer of 32 bit unsigned integers (sprite index). + """ + return self._storage_index + + def write_sprite_buffers_to_gpu( + self, + # The data itself + sprite_pos_angle_data, + sprite_size_data, + sprite_color_data, + sprite_texture_data, + sprite_index_data, + # Changed flags + sprite_pos_angle_changed: bool = True, + sprite_size_changed: bool = True, + sprite_color_changed: bool = True, + sprite_texture_changed: bool = True, + sprite_index_changed: bool = True, + ) -> None: + """ + Write the sprite buffers to the GPU. + + Args: + sprite_pos_angle_data: Array of sprite positions. + sprite_size_data: Array of sprite sizes. + sprite_color_data: Array of sprite colors. + sprite_texture_data: Array of sprite texture IDs. + sprite_index_data: Array of sprite indices. + sprite_pos_angle_changed: Whether the position data has changed. + sprite_size_changed: Whether the size data has changed. + sprite_color_changed: Whether the color data has changed. + sprite_texture_changed: Whether the texture data has changed. + sprite_index_changed: Whether the index data has changed. + """ + raise NotImplementedError("This method should be implemented in subclasses.") + + def grow_sprite_buffers(self) -> None: + """ + Grow the sprite buffer to accommodate more sprites. + + This method is called when the internal buffer capacity is exceeded. + It should increase the buffer size and prepare for more sprites. + """ + raise NotImplementedError("This method should be implemented in subclasses.") + + def grow_index_buffer(self) -> None: + """ + Grow the index buffer to accommodate more sprites. + + This method is called when the internal index buffer capacity is exceeded. + It should increase the index buffer size and prepare for more sprites. + """ + raise NotImplementedError("This method should be implemented in subclasses.") + + def render( + self, + *, + atlas: TextureAtlasBase, + count: int, + color: tuple[float, float, float, float], + default_texture_filter: OpenGlFilter, + filter: PyGLenum | OpenGlFilter | None = None, + pixelated: bool | None = None, + blend_function: BlendFunction | None = None, + blend: bool = True, + ) -> None: + """ + Render the sprite list using the provided shader program. + + Args: + filter: Texture filter to use. + pixelated: Whether to use pixelated rendering. + blend_function: Blend function to use for rendering. + """ + raise NotImplementedError("This method should be implemented in subclasses.") + + def get_nearby_sprite_indices(self, pos: Point, size: Point, length: int) -> list[int]: + """ + Get indices of sprites that are nearby the given position and size. + + Args: + pos: The position to check for nearby sprites. + size: The size of the area to check for nearby sprites. + length: The number of sprites in the list. + Returns: + A list of indices of nearby sprites. + """ + raise NotImplementedError("This method should be implemented in subclasses.") + + +class SpriteListBufferData(SpriteListData): + """Container for all gpu data used by the SpriteList.""" + + def __init__(self, ctx: ArcadeContext, capacity: int, atlas: TextureAtlasBase) -> None: + self.ctx = ctx + self._buf_capacity = capacity + self._idx_capacity = capacity + self._atlas = atlas + + # Buffers for each sprite attribute (read by shader) with initial capacity + self._storage_pos_angle: Buffer = self.ctx.buffer( + reserve=self._buf_capacity * 16 + ) # 4 x 32 bit floats + self._storage_size: Buffer = self.ctx.buffer( + reserve=self._buf_capacity * 8 + ) # 2 x 32 bit floats + self._storage_color: Buffer = self.ctx.buffer( + reserve=self._buf_capacity * 4 + ) # 4 x bytes colors + self._storage_texture_id: Buffer = self.ctx.buffer( + reserve=self._buf_capacity * 4 + ) # 32 bit int + # Index buffer + self._storage_index: Buffer = self.ctx.buffer( + reserve=self._idx_capacity * 4 + ) # 32 bit unsigned integers + + contents = [ + gl.BufferDescription(self._storage_pos_angle, "4f", ["in_pos"]), + gl.BufferDescription(self._storage_size, "2f", ["in_size"]), + gl.BufferDescription(self._storage_texture_id, "1f", ["in_texture"]), + gl.BufferDescription( + self._storage_color, + "4f1", + ["in_color"], + ), + ] + # Geometry shader version + self.program = self.ctx.sprite_list_program_cull + if not self._atlas: + self._atlas = self.ctx.default_atlas + self._geometry = self.ctx.geometry( + contents, + index_buffer=self._storage_index, + index_element_size=4, # 32 bit integers + ) + + @property + def geometry(self) -> Geometry: + """ + Returns the internal OpenGL geometry for this spritelist. + This can be used to execute custom shaders with the + spritelist data. + + One or multiple of the following inputs must be defined in your vertex shader:: + + in vec2 in_pos; + in float in_angle; + in vec2 in_size; + in float in_texture; + in vec4 in_color; + """ + return self._geometry + + @property + def buffer_positions_angle(self) -> Buffer: + """ + Get the internal OpenGL position buffer for this spritelist. + + The buffer contains 32 bit float values with + x, y and z positions. These are the center positions + for each sprite. + + This buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` + instance with name ``in_pos``. + """ + return self._storage_pos_angle + + @property + def buffer_sizes(self) -> Buffer: + """ + Get the internal OpenGL size buffer for this spritelist. + + The buffer contains 32 bit float width and height values. + + This buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` + instance with name ``in_size``. + """ + return self._storage_size + + @property + def buffer_colors(self) -> Buffer: + """ + Get the internal OpenGL color buffer for this spritelist. + + This buffer contains a series of 32 bit floats representing + the RGBA color for each sprite. 4 x floats = RGBA. + + This buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` + instance with name ``in_color``. + """ + return self._storage_color + + @property + def buffer_textures(self) -> Buffer: + """ + Get the internal openGL texture id buffer for the spritelist. + + This buffer contains a series of single 32 bit floats referencing + a texture ID. This ID references a texture in the texture + atlas assigned to this spritelist. The ID is used to look up + texture coordinates in a 32bit floating point texture the + texture atlas provides. This system makes sure we can resize + and rebuild a texture atlas without having to rebuild every + single spritelist. + + This buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` + instance with name ``in_texture``. + + Note that it should ideally an unsigned integer, but due to + compatibility we store them as 32 bit floats. We cast them + to integers in the shader. + """ + return self._storage_texture_id + + @property + def buffer_indices(self) -> Buffer: + """ + Get the internal index buffer for this spritelist. + + The data in the other buffers are not in the correct order + matching ``spritelist[i]``. The index buffer has to be + used used to resolve the right order. It simply contains + a series of integers referencing locations in the other buffers. + + Also note that the length of this buffer might be bigger than + the number of sprites. Rely on ``len(spritelist)`` for the + correct length. + + This index buffer is attached to the :py:attr:`~arcade.SpriteList.geometry` + instance and will be automatically be applied the the input buffers + when rendering or transforming. + """ + return self._storage_index + + def write_sprite_buffers_to_gpu( + self, + # The data itself + sprite_pos_angle_data, + sprite_size_data, + sprite_color_data, + sprite_texture_data, + sprite_index_data, + # Changed flags + sprite_pos_angle_changed: bool = True, + sprite_size_changed: bool = True, + sprite_color_changed: bool = True, + sprite_texture_changed: bool = True, + sprite_index_changed: bool = True, + ) -> None: + """ + Write the sprite buffers to the GPU. + + Args: + sprite_pos_angle_data: Array of sprite positions. + sprite_size_data: Array of sprite sizes. + sprite_color_data: Array of sprite colors. + sprite_texture_data: Array of sprite texture IDs. + sprite_index_data: Array of sprite indices. + sprite_size_changed: Whether the size data has changed. + sprite_color_changed: Whether the color data has changed. + sprite_texture_changed: Whether the texture data has changed. + sprite_index_changed: Whether the index data has changed. + """ + if sprite_pos_angle_changed: + self._storage_pos_angle.orphan() + self._storage_pos_angle.write(sprite_pos_angle_data) + self._sprite_pos_angle_changed = False + + if sprite_size_changed: + self._storage_size.orphan() + self._storage_size.write(sprite_size_data) + self._sprite_size_changed = False + + if sprite_color_changed: + self._storage_color.orphan() + self._storage_color.write(sprite_color_data) + self._sprite_color_changed = False + + if sprite_texture_changed: + self._storage_texture_id.orphan() + self._storage_texture_id.write(sprite_texture_data) + self._sprite_texture_changed = False + + if sprite_index_changed: + self._storage_index.orphan() + self._storage_index.write(sprite_index_data) + self._sprite_index_changed = False + + def grow_sprite_buffers(self) -> None: + self._storage_pos_angle.orphan(double=True) + self._storage_size.orphan(double=True) + self._storage_color.orphan(double=True) + self._storage_texture_id.orphan(double=True) + + def grow_index_buffer(self) -> None: + self._storage_index.orphan(double=True) + + def render( + self, + *, + atlas: TextureAtlasBase, + count: int, + color: tuple[float, float, float, float], + default_texture_filter: OpenGlFilter, + filter: PyGLenum | OpenGlFilter | None = None, + pixelated: bool | None = None, + blend_function: BlendFunction | None = None, + blend: bool = True, + ) -> None: + """ + Render the sprite list using the provided shader program. + + Args: + filter: Texture filter to use. + pixelated: Whether to use pixelated rendering. + blend_function: Blend function to use for rendering. + """ + if not self.program: + raise ValueError("Attempting to render without shader program.") + + prev_blend_func = self.ctx.blend_func + if blend: + self.ctx.enable(self.ctx.BLEND) + # Set custom blend function or revert to default + if blend_function is not None: + self.ctx.blend_func = blend_function + else: + self.ctx.blend_func = self.ctx.BLEND_DEFAULT + else: + self.ctx.disable(self.ctx.BLEND) + + atlas_texture: Texture2D = atlas.texture + + # Set custom filter or reset to default + if filter: + if hasattr( + filter, + "__len__", + ): # assume it's a collection + if len(cast(Sized, filter)) != 2: + raise ValueError("Can't use sequence of length != 2") + atlas_texture.filter = tuple(filter) # type: ignore + else: # assume it's an int + atlas_texture.filter = cast(OpenGlFilter, (filter, filter)) + else: + # Handle the pixelated shortcut if filter is not set + if pixelated: + atlas_texture.filter = self.ctx.NEAREST, self.ctx.NEAREST + else: + atlas_texture.filter = default_texture_filter + + self.program["spritelist_color"] = color + + # Control center pixel interpolation: + # 0.0 = raw interpolation using texture corners + # 1.0 = center pixel interpolation + if self.ctx.NEAREST in atlas_texture.filter: + self.program.set_uniform_safe("uv_offset_bias", 0.0) + else: + self.program.set_uniform_safe("uv_offset_bias", 1.0) + + atlas_texture.use(0) + atlas.use_uv_texture(1) + self._geometry.render( + self.program, + mode=self.ctx.POINTS, + vertices=count, + ) + + # Leave global states to default + if blend: + self.ctx.disable(self.ctx.BLEND) + if blend_function is not None: + self.ctx.blend_func = prev_blend_func + + def get_nearby_sprite_indices(self, pos: Point, size: Point, length: int) -> list[int]: + """ + Get indices of sprites that are nearby the given position and size. + + Args: + pos: The position to check for nearby sprites. + size: The size of the area to check for nearby sprites. + length: The number of sprites in the spritelist. + Returns: + A list of indices of nearby sprites. + """ + ctx = self.ctx + ctx.collision_detection_program["check_pos"] = pos + ctx.collision_detection_program["check_size"] = size + buffer = ctx.collision_buffer + with ctx.collision_query: + self._geometry.transform( # type: ignore + ctx.collision_detection_program, + buffer, + vertices=length, + ) + + # Store the number of sprites emitted + emit_count = ctx.collision_query.primitives_generated + if emit_count == 0: + return [] + return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] + + +class SpriteListTextureData(SpriteListData): + """Container for all gpu data used by the SpriteList without buffers.""" + + def __init__(self, ctx: ArcadeContext, capacity: int, atlas: TextureAtlasBase) -> None: + self.ctx = ctx + self._buf_capacity = capacity + self._idx_capacity = capacity + self._atlas = atlas + + # Program without geo shader + self.program = self.ctx.sprite_list_program_no_geo + self._atlas = atlas or self.ctx.default_atlas + self._geometry = self.ctx.spritelist_geometry_simple + + # Texture buffers for per-sprite data. These are looked up using gl_InstanceID + self._storage_pos_angle: Texture2D = self.ctx.texture( + size=(capacity, 1), components=4, dtype="f4" + ) + self._storage_size: Texture2D = self.ctx.texture( + size=(capacity, 1), components=2, dtype="f4" + ) + self._storage_color: Texture2D = self.ctx.texture( + size=(capacity, 1), components=4, dtype="f1" + ) + self._storage_texture_id: Texture2D = self.ctx.texture( + size=(capacity, 1), components=1, dtype="f4" + ) + self._storage_index: Texture2D = self.ctx.texture( + size=(capacity, 1), components=1, dtype="i4" + ) + + def write_sprite_buffers_to_gpu( + self, + # The data itself + sprite_pos_angle_data, + sprite_size_data, + sprite_color_data, + sprite_texture_data, + sprite_index_data, + # Changed flags + sprite_pos_angle_changed: bool = True, + sprite_size_changed: bool = True, + sprite_color_changed: bool = True, + sprite_texture_changed: bool = True, + sprite_index_changed: bool = True, + ) -> None: + """ + Write the sprite buffers to the GPU. + + Args: + sprite_pos_angle_data: Array of sprite positions. + sprite_size_data: Array of sprite sizes. + sprite_color_data: Array of sprite colors. + sprite_texture_data: Array of sprite texture IDs. + sprite_index_data: Array of sprite indices. + sprite_pos_angle_changed: Whether the position data has changed. + sprite_size_changed: Whether the size data has changed. + sprite_color_changed: Whether the color data has changed. + sprite_texture_changed: Whether the texture data has changed. + sprite_index_changed: Whether the index data has changed. + """ + if sprite_pos_angle_changed: + self._storage_pos_angle.write(sprite_pos_angle_data) + + if sprite_size_changed: + self._storage_size.write(sprite_size_data) + + if sprite_color_changed: + self._storage_color.write(sprite_color_data) + + if sprite_texture_changed: + self._storage_texture_id.write(sprite_texture_data) + + if sprite_index_changed: + self._storage_index.write(sprite_index_data) + + def grow_sprite_buffers(self) -> None: + """Double the internal storage""" + # Double the capacity + self._buf_capacity = self._buf_capacity * 2 + + # Extend the textures so we don't lose the old data + self._storage_pos_angle.resize((256, self._buf_capacity // 256)) + self._storage_size.resize((256, self._buf_capacity // 256)) + self._storage_color.resize((256, self._buf_capacity // 256)) + self._storage_texture_id.resize((256, self._buf_capacity // 256)) + + def grow_index_buffer(self) -> None: + """Double the internal index buffer storage""" + self._idx_capacity = self._idx_capacity * 2 + self._storage_index.resize((256, self._idx_capacity // 256)) + + def render( + self, + *, + atlas: TextureAtlasBase, + count: int, + color: tuple[float, float, float, float], + default_texture_filter: OpenGlFilter, + filter: PyGLenum | OpenGlFilter | None = None, + pixelated: bool | None = None, + blend_function: BlendFunction | None = None, + blend: bool = True, + ) -> None: + """Render the sprite list using the provided shader program.""" + if not self.program: + raise ValueError("Attempting to render without shader program.") + + prev_blend_func = self.ctx.blend_func + if blend: + self.ctx.enable(self.ctx.BLEND) + # Set custom blend function or revert to default + if blend_function is not None: + self.ctx.blend_func = blend_function + else: + self.ctx.blend_func = self.ctx.BLEND_DEFAULT + else: + self.ctx.disable(self.ctx.BLEND) + + atlas_texture: Texture2D = atlas.texture + + # Set custom filter or reset to default + if filter: + if hasattr( + filter, + "__len__", + ): # assume it's a collection + if len(cast(Sized, filter)) != 2: + raise ValueError("Can't use sequence of length != 2") + atlas_texture.filter = tuple(filter) # type: ignore + else: # assume it's an int + atlas_texture.filter = cast(OpenGlFilter, (filter, filter)) + else: + # Handle the pixelated shortcut if filter is not set + if pixelated: + atlas_texture.filter = self.ctx.NEAREST, self.ctx.NEAREST + else: + atlas_texture.filter = default_texture_filter + + try: + self.program["spritelist_color"] = color + except KeyError: + pass + + # Control center pixel interpolation: + # 0.0 = raw interpolation using texture corners + # 1.0 = center pixel interpolation + if self.ctx.NEAREST in atlas_texture.filter: + self.program.set_uniform_safe("uv_offset_bias", 0.0) + else: + self.program.set_uniform_safe("uv_offset_bias", 1.0) + + atlas_texture.use(0) + atlas.use_uv_texture(1) + # Per-instance data + self._storage_pos_angle.use(2) + self._storage_size.use(3) + self._storage_color.use(4) + self._storage_texture_id.use(5) + self._storage_index.use(6) + + self._geometry.render( + self.program, + instances=count, + ) + + # Leave global states to default + if blend: + self.ctx.disable(self.ctx.BLEND) + if blend_function is not None: + self.ctx.blend_func = prev_blend_func + + def get_nearby_sprite_indices(self, pos: Point, size: Point, length: int) -> list[int]: + """ + Get indices of sprites that are nearby the given position and size. + + Args: + pos: The position to check for nearby sprites. + size: The size of the area to check for nearby sprites. + length: The number of sprites in the spritelist. + Returns: + A list of indices of nearby sprites. + """ + ctx = self.ctx + buffer = ctx.collision_buffer + program = ctx.collision_detection_program_simple + program["check_pos"] = pos + program["check_size"] = size + + self._storage_pos_angle.use(0) + self._storage_size.use(1) + self._storage_index.use(2) + + with ctx.collision_query: + ctx.geometry_empty.transform( + program, + buffer, + vertices=length, + ) + emit_count = ctx.collision_query.primitives_generated + # print(f"Collision query emitted {emit_count} sprites") + if emit_count == 0: + return [] + return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index 77a60174f0..52ec23823e 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -667,7 +667,7 @@ def resize(self, size: tuple[int, int], force=False) -> None: force: Force a resize even if the size is the same """ - print("Resizing atlas from", self._size, "to", size) + # print("Resizing atlas from", self._size, "to", size) # Only resize if the size actually changed if size == self._size and not force: @@ -746,7 +746,7 @@ def rebuild(self) -> None: This method also tries to organize the textures more efficiently ordering them by size. The texture ids will persist so the sprite list doesn't need to be rebuilt. """ - print("Rebuilding atlas") + # print("Rebuilding atlas") # Hold a reference to the old textures textures = self.textures diff --git a/tests/conftest.py b/tests/conftest.py index 4310fb6288..513f5c9baf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -348,6 +348,10 @@ def center_window(self): def set_vsync(self, vsync): self.window.set_vsync(vsync) + @staticmethod + def register_event_type(*args, **kwargs): + pass + @property def default_camera(self): """ diff --git a/tests/integration/examples/test_examples.py b/tests/integration/examples/test_examples.py index f80c900048..f440805a79 100644 --- a/tests/integration/examples/test_examples.py +++ b/tests/integration/examples/test_examples.py @@ -37,6 +37,7 @@ "multisample", # Anything requiring multisampling we can't run in unit test "indirect", # Indirect rendering cannot be run in unit test "bindless", # Bindless textures cannot be run in unit test + "spritelist_interaction", # Currently only works for spritelist buffer backend. Not textures. ] diff --git a/tests/unit/spritelist/test_spritelist.py b/tests/unit/spritelist/test_spritelist.py index 53187f2ee5..b0f371eced 100644 --- a/tests/unit/spritelist/test_spritelist.py +++ b/tests/unit/spritelist/test_spritelist.py @@ -184,7 +184,7 @@ def test_can_shuffle(ctx): # Ensure the index buffer is referring to the correct slots # Raw buffer from OpenGL index_data = struct.unpack( - f"{num_sprites}i", spritelist._sprite_index_buf.read()[: num_sprites * 4] + f"{num_sprites}i", spritelist.data.storage_index.read()[: num_sprites * 4] ) for i, sprite in enumerate(spritelist): # Check if slots are updated @@ -221,14 +221,14 @@ def test_sort(ctx): assert spritelist._sprite_index_data[0:3] == array("f", [0, 1, 2]) -@pytest.mark.parametrize("capacity", (128, 512, 1024)) +@pytest.mark.parametrize("capacity", (256, 512, 1024)) def test_clear(ctx, capacity): sp = arcade.SpriteList(capacity=capacity) sp.clear(capacity=None) assert len(sp._sprite_index_data) == capacity - assert len(sp._sprite_pos_data) == capacity * 3 - assert sp._sprite_index_buf.size == capacity * 4 - assert sp._sprite_pos_buf.size == capacity * 4 * 3 + assert len(sp._sprite_pos_angle_data) == capacity * 4 + assert len(sp.data.storage_index.read()) == capacity * 4 + assert len(sp.data.storage_positions_angle.read()) == capacity * 4 * 4 sp.extend(make_named_sprites(capacity)) sp.clear(capacity=capacity) @@ -237,9 +237,9 @@ def test_clear(ctx, capacity): assert sp._sprite_buffer_slots == 0 assert sp.atlas is not None assert len(sp._sprite_index_data) == capacity - assert len(sp._sprite_pos_data) == capacity * 3 - assert sp._sprite_index_buf.size == capacity * 4 - assert sp._sprite_pos_buf.size == capacity * 4 * 3 + assert len(sp._sprite_pos_angle_data) == capacity * 4 + assert len(sp.data.storage_index.read()) == capacity * 4 + assert len(sp.data.storage_positions_angle.read()) == capacity * 4 * 4 def test_color(): diff --git a/tests/unit/spritelist/test_spritelist_buffers.py b/tests/unit/spritelist/test_spritelist_buffers.py index 5b8aa89a3c..2063565c8c 100644 --- a/tests/unit/spritelist/test_spritelist_buffers.py +++ b/tests/unit/spritelist/test_spritelist_buffers.py @@ -10,19 +10,17 @@ def check_buff_sizes(sp: arcade.SpriteList): # Buffers capacity = sp._buf_capacity - assert sp._sprite_pos_buf.size == 12 * capacity # 3 floats - assert sp._sprite_angle_buf.size == 4 * capacity # 1 float - assert sp._sprite_color_buf.size == 4 * capacity # 4 floats - assert sp._sprite_size_buf.size == 8 * capacity # 2 floats - assert sp._sprite_texture_buf.size == 4 * capacity # 1 int + assert len(sp.data.storage_positions_angle.read()) == 16 * capacity # 3 floats + assert len(sp.data.storage_color.read()) == 4 * capacity # 4 floats + assert len(sp.data.storage_size.read()) == 8 * capacity # 2 floats + assert len(sp.data.storage_texture_id.read()) == 4 * capacity # 1 int # Arrays - assert len(sp._sprite_pos_data) == 3 * capacity # 3 floats - assert len(sp._sprite_angle_data) == 1 * capacity # 1 float + assert len(sp._sprite_pos_angle_data) == 4 * capacity # 3 floats assert len(sp._sprite_color_data) == 4 * capacity # 1 float assert len(sp._sprite_size_data) == 2 * capacity # 2 floats assert len(sp._sprite_texture_data) == 1 * capacity # 1 int # Index buffer - assert sp._sprite_index_buf.size == 4 * sp._idx_capacity # 1 int + assert len(sp.data.storage_index.read()) == 4 * sp._idx_capacity # 1 int assert len(sp._sprite_index_data) == 1 * sp._idx_capacity # 1 int # Slots assert len(sp.sprite_slot) == len(sp) @@ -40,11 +38,11 @@ def test_buffer_sizes(ctx: arcade.ArcadeContext): arcade.color.GREEN, arcade.color.BLUE, ) - positions = ( - (0, 1, 2), - (10, 11, 12), - (20, 21, 22), - (30, 31, 32), + positions_angle = ( + (0, 1, 2, 0), + (10, 11, 12, 1), + (20, 21, 22, 2), + (30, 31, 32, 3), ) sizes = ( (10, 20), @@ -52,77 +50,73 @@ def test_buffer_sizes(ctx: arcade.ArcadeContext): (50, 60), (70, 80), ) - angles = (0, 90, 180, 270) sprites = [] - for color, size, angle, pos in zip(colors, sizes, angles, positions): + for color, size, pos_angle in zip(colors, sizes, positions_angle): sprite = arcade.SpriteSolidColor( *size, - center_x=pos[0], - center_y=pos[1], + center_x=pos_angle[0], + center_y=pos_angle[1], color=color, - angle=angle, + angle=pos_angle[3], ) - sprite.depth = pos[2] + sprite.depth = pos_angle[2] sprites.append(sprite) sp: arcade.SpriteList = arcade.SpriteList(capacity=1) # Initial capacity - assert sp._buf_capacity == 1 - assert sp._idx_capacity == 1 + assert sp._buf_capacity == 256 + assert sp._idx_capacity == 256 check_buff_sizes(sp) # After adding a sprite filling the capacity (1) sp.append(sprites[0]) - assert sp._buf_capacity == 1 - assert sp._idx_capacity == 1 + assert sp._buf_capacity == 256 + assert sp._idx_capacity == 256 check_buff_sizes(sp) # Adding one more sprite should double the capacity (2) sp.append(sprites[1]) - assert sp._buf_capacity == 2 - assert sp._idx_capacity == 2 + assert sp._buf_capacity == 256 + assert sp._idx_capacity == 256 check_buff_sizes(sp) # Adding 1 more sprites to pass max capacity (3) sp.append(sprites[2]) - assert sp._buf_capacity == 4 - assert sp._idx_capacity == 4 + assert sp._buf_capacity == 256 + assert sp._idx_capacity == 256 check_buff_sizes(sp) # Adding 1 more sprites to pass max capacity (4) sp.append(sprites[3]) - assert sp._buf_capacity == 4 - assert sp._idx_capacity == 4 + assert sp._buf_capacity == 256 + assert sp._idx_capacity == 256 check_buff_sizes(sp) sp.write_sprite_buffers_to_gpu() # Test the contents of the arrays and buffers. # Prepare expected data - expected_pos_data = struct.pack("12f", *[v for p in positions for v in p]) + expected_pos_data = struct.pack("16f", *[v for p in positions_angle for v in p]) expected_color_data = struct.pack("16B", *[v for c in colors for v in c]) expected_size_data = struct.pack("8f", *[v for s in sizes for v in s]) - expected_angle_data = struct.pack("4f", *angles) expected_texture_data = struct.pack( "4f", *[ctx.default_atlas.get_texture_id(sprite.texture) for sprite in sprites], ) # Check the buffers - assert sp._sprite_pos_buf.read() == expected_pos_data - assert sp._sprite_color_buf.read() == expected_color_data - assert sp._sprite_size_buf.read() == expected_size_data - assert sp._sprite_angle_buf.read() == expected_angle_data - assert sp._sprite_texture_buf.read() == expected_texture_data + assert sp.data.storage_positions_angle.read().startswith(expected_pos_data) + assert sp.data.storage_color.read().startswith(expected_color_data) + assert sp.data.storage_size.read().startswith(expected_size_data) + assert sp.data.storage_texture_id.read().startswith(expected_texture_data) # Check arrays - assert sp._sprite_pos_data.tobytes() == expected_pos_data - assert sp._sprite_color_data.tobytes() == expected_color_data - assert sp._sprite_size_data.tobytes() == expected_size_data - assert sp._sprite_angle_data.tobytes() == expected_angle_data - assert sp._sprite_texture_data.tobytes() == expected_texture_data + assert sp._sprite_pos_angle_data.tobytes().startswith(expected_pos_data) + assert sp._sprite_color_data.tobytes().startswith(expected_color_data) + assert sp._sprite_size_data.tobytes().startswith(expected_size_data) + assert sp._sprite_texture_data.tobytes().startswith(expected_texture_data) # Index buffer - assert sp._sprite_index_buf.read() == struct.pack("4i", 0, 1, 2, 3) + assert sp.data.storage_index.read().startswith(struct.pack("4i", 0, 1, 2, 3)) diff --git a/tests/unit/spritelist/test_spritelist_lazy.py b/tests/unit/spritelist/test_spritelist_lazy.py index 014600d186..b373e8160c 100644 --- a/tests/unit/spritelist/test_spritelist_lazy.py +++ b/tests/unit/spritelist/test_spritelist_lazy.py @@ -8,9 +8,7 @@ def test_create_lazy_equals_true(): spritelist = arcade.SpriteList(lazy=True, use_spatial_hash=True) # Make sure OpenGL abstractions are not created - assert spritelist._sprite_pos_buf == None - assert spritelist._geometry == None - assert spritelist.atlas is None + assert spritelist._data is None # Make sure CPU-only behavior still works correctly for x in range(100): @@ -37,8 +35,7 @@ def test_manual_initialization_after_lazy_equals_true(window): # Make sure initialization still worked correctly. spritelist.initialize() assert spritelist._initialized - assert spritelist._sprite_pos_buf - assert spritelist._geometry + assert spritelist._data assert isinstance(spritelist.atlas, DefaultTextureAtlas) # Uncomment the next line and set a breakpoint on it to From 846315017c1b4c2f6c90d1ce85c0af5b2bb6fa88 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 30 May 2025 13:13:31 +0200 Subject: [PATCH 188/279] gui: enhance UIWidget usability with intuitive property setters for position and size --- CHANGELOG.md | 5 ++++ arcade/gui/widgets/__init__.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61fff6f8db..7a61c66357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Added `Window.close` (bool) attribute indicating if the window is closed - GUI - Fix `UILabel` with enabled multiline sometimes cut off text + - Improved `UIWidget` usability for resizing and positioning: + - Added property setters for `width`, `height`, and `size` that ensure positive values + - Added property setters for `center_x` and `center_y` + - Added property setters for `left`, `right`, `top`, and `bottom` + - Users can now set widget position and size more intuitively without needing to access the `rect` property ## Version 3.2 diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 244024cb37..cd610327b9 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -361,21 +361,37 @@ def left(self) -> float: """Left coordinate of the widget""" return self.rect.left + @left.setter + def left(self, value: float): + self.rect = LBWH(value, self.bottom, self.width, self.height) + @property def right(self) -> float: """Right coordinate of the widget""" return self.rect.right + @right.setter + def right(self, value: float): + self.rect = LBWH(value - self.width, self.bottom, self.width, self.height) + @property def bottom(self) -> float: """Bottom coordinate of the widget""" return self.rect.bottom + @bottom.setter + def bottom(self, value: float): + self.rect = LBWH(self.left, value, self.width, self.height) + @property def top(self) -> float: """Top coordinate of the widget""" return self.rect.top + @top.setter + def top(self, value: float): + self.rect = LBWH(self.left, value - self.height, self.width, self.height) + @property def position(self) -> Vec2: """Returns bottom left coordinates""" @@ -395,11 +411,19 @@ def center_x(self) -> float: """Center x coordinate""" return self.rect.x + @center_x.setter + def center_x(self, value: float): + self.rect = self.rect.align_center_x(value) + @property def center_y(self) -> float: """Center y coordinate""" return self.rect.y + @center_y.setter + def center_y(self, value: float): + self.rect = self.rect.align_center_y(value) + @property def padding(self): """Returns padding as tuple (top, right, bottom, left)""" @@ -545,16 +569,35 @@ def width(self) -> float: """Width of the widget.""" return self.rect.width + @width.setter + def width(self, value: float): + if value <= 0: + raise ValueError("Width must be positive") + self.rect = LBWH(self.left, self.bottom, value, self.height) + @property def height(self) -> float: """Height of the widget.""" return self.rect.height + @height.setter + def height(self, value: float): + if value <= 0: + raise ValueError("Height must be positive") + self.rect = LBWH(self.left, self.bottom, self.width, value) + @property def size(self) -> Vec2: """Size of the widget.""" return Vec2(self.width, self.height) + @size.setter + def size(self, value): + width, height = value + if width <= 0 or height <= 0: + raise ValueError("Width and height must be positive") + self.rect = LBWH(self.left, self.bottom, width, height) + def center_on_screen(self: W) -> W: """Places this widget in the center of the current window. From 03f38093a6c64761c36e14345f72515e22bda22b Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 30 May 2025 13:17:00 +0200 Subject: [PATCH 189/279] Update arcade/gui/widgets/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- arcade/gui/widgets/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index cd610327b9..b2695bd171 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -592,7 +592,7 @@ def size(self) -> Vec2: return Vec2(self.width, self.height) @size.setter - def size(self, value): + def size(self, value: Tuple[float, float] | Vec2): width, height = value if width <= 0 or height <= 0: raise ValueError("Width and height must be positive") From 4efa6b003c7e9b85dffb24da2767e29ad3e0a596 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 30 May 2025 13:20:36 +0200 Subject: [PATCH 190/279] fix --- arcade/gui/widgets/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index b2695bd171..95881b48db 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -592,7 +592,7 @@ def size(self) -> Vec2: return Vec2(self.width, self.height) @size.setter - def size(self, value: Tuple[float, float] | Vec2): + def size(self, value: tuple[float, float] | Vec2): width, height = value if width <= 0 or height <= 0: raise ValueError("Width and height must be positive") From d497e25977c004c59fe44169baaa1836ee07baa3 Mon Sep 17 00:00:00 2001 From: Syed Mehdi <114935139+Infamous003@users.noreply.github.com> Date: Sat, 31 May 2025 02:18:14 +0530 Subject: [PATCH 191/279] Fix broken link (closes #2706) (#2708) --- doc/example_code/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index d6201d69a6..5d5359e8e7 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -66,7 +66,7 @@ Animating Drawing Primitives .. figure:: images/thumbs/shapes.png :figwidth: 170px - :target: shapes-slow.html + :target: shapes.html#shapes-slow :ref:`shapes-slow` From b27ce88f6e8ae1642e9251278a680efa7c59fd70 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Tue, 3 Jun 2025 01:38:03 +0300 Subject: [PATCH 192/279] Raise exception of `blend_func` issue (#2711) * Raise exception of `blend_func` issue Signed-off-by: Emmanuel Ferdman * Update arcade/gl/backends/opengl/context.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Signed-off-by: Emmanuel Ferdman Co-authored-by: Einar Forselv Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- arcade/gl/backends/opengl/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/gl/backends/opengl/context.py b/arcade/gl/backends/opengl/context.py index 2a7753d253..24f34e47a3 100644 --- a/arcade/gl/backends/opengl/context.py +++ b/arcade/gl/backends/opengl/context.py @@ -143,7 +143,7 @@ def blend_func(self, value: Tuple[int, int] | Tuple[int, int, int, int]): elif len(value) == 4: gl.glBlendFuncSeparate(*value) else: - ValueError("blend_func takes a tuple of 2 or 4 values") + raise ValueError(f"blend_func takes a tuple of 2 or 4 values, got {len(value)}") @property def front_face(self) -> str: From cb6733ac9283365fc1e9c92dbf817acded487c89 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 7 Jun 2025 15:51:09 +0200 Subject: [PATCH 193/279] Remove geo shader for points and rectangles (#2713) --- arcade/context.py | 31 +++++++++++++- arcade/draw/point.py | 3 +- arcade/draw/rect.py | 2 +- .../rectangle/filled_unbuffered_geo.glsl | 42 ------------------- .../rectangle/filled_unbuffered_vs.glsl | 19 ++++++++- 5 files changed, 49 insertions(+), 48 deletions(-) delete mode 100644 arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_geo.glsl diff --git a/arcade/context.py b/arcade/context.py index 2e714fa08f..f011b5833d 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -170,8 +170,8 @@ def __init__( self.shape_rectangle_filled_unbuffered_program = self.load_program( vertex_shader=":system:shaders/shapes/rectangle/filled_unbuffered_vs.glsl", fragment_shader=":system:shaders/shapes/rectangle/filled_unbuffered_fs.glsl", - geometry_shader=":system:shaders/shapes/rectangle/filled_unbuffered_geo.glsl", ) + # Atlas shaders self.atlas_resize_program: Program = self.load_program( # NOTE: This is the geo shader version of the atlas resize program. @@ -253,9 +253,36 @@ def __init__( ) # rectangle filled self.shape_rectangle_filled_unbuffered_buffer = self.buffer(reserve=8) + # fmt: off self.shape_rectangle_filled_unbuffered_geometry: Geometry = self.geometry( - [BufferDescription(self.shape_rectangle_filled_unbuffered_buffer, "2f", ["in_vert"])] + [ + # Instanced quad (triangle strip) + BufferDescription( + self.buffer( + data=array( + "f", + [ + -0.5, +0.5, # Upper left + -0.5, -0.5, # lower left + +0.5, +0.5, # upper right + +0.5, -0.5, # lower right + ], + ) + ), + "2f", + ["in_vert"], + ), + # Per instance data + BufferDescription( + self.shape_rectangle_filled_unbuffered_buffer, + "2f", + ["in_instance_pos"], + instanced=True + ), + ], + mode=self.TRIANGLE_STRIP, ) + # fmt: on self.geometry_empty: Geometry = self.geometry() self._atlas: TextureAtlasBase | None = None diff --git a/arcade/draw/point.py b/arcade/draw/point.py index 9a4ddb6d74..9898f66527 100644 --- a/arcade/draw/point.py +++ b/arcade/draw/point.py @@ -68,7 +68,6 @@ def draw_points(point_list: Point2List, color: RGBOrA255, size: float = 1.0) -> # Resize buffer data_size = num_points * 8 - # if data_size > buffer.size: buffer.orphan(size=data_size) ctx.enable(ctx.BLEND) @@ -79,6 +78,6 @@ def draw_points(point_list: Point2List, color: RGBOrA255, size: float = 1.0) -> buffer.write(data=point_array) # Only render the # of points we have complete data for - geometry.render(program, mode=ctx.POINTS, vertices=data_size // 8) + geometry.render(program, instances=num_points) ctx.disable(ctx.BLEND) diff --git a/arcade/draw/rect.py b/arcade/draw/rect.py index 0fc3d832ee..8e9deb237a 100644 --- a/arcade/draw/rect.py +++ b/arcade/draw/rect.py @@ -395,7 +395,7 @@ def draw_rect_filled(rect: Rect, color: RGBOrA255, tilt_angle: float = 0) -> Non buffer.orphan() buffer.write(data=array.array("f", (rect.x, rect.y))) - geometry.render(program, mode=ctx.POINTS, vertices=1) + geometry.render(program, instances=1) ctx.disable(ctx.BLEND) diff --git a/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_geo.glsl b/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_geo.glsl deleted file mode 100644 index fc30127855..0000000000 --- a/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_geo.glsl +++ /dev/null @@ -1,42 +0,0 @@ -#version 330 - -layout (points) in; -layout (triangle_strip, max_vertices = 4) out; - -uniform WindowBlock { - mat4 projection; - mat4 view; -} window; - -// [w, h, tilt] -uniform vec3 shape; - -void main() { - // Get center of the circle - vec2 center = gl_in[0].gl_Position.xy; - - // Calculate rotation/tilt - float angle = radians(shape.z); - mat2 rot = mat2( - cos(angle), -sin(angle), - sin(angle), cos(angle) - ); - vec2 size = shape.xy / 2.0; - - // Emit quad as triangle strip - vec2 p1 = rot * vec2(-size.x, size.y); - vec2 p2 = rot * vec2(-size.x, -size.y); - vec2 p3 = rot * vec2( size.x, size.y); - vec2 p4 = rot * vec2( size.x, -size.y); - - gl_Position = window.projection * window.view * vec4(p1 + center, 0.0, 1.0); - EmitVertex(); - gl_Position = window.projection * window.view * vec4(p2 + center, 0.0, 1.0); - EmitVertex(); - gl_Position = window.projection * window.view * vec4(p3 + center, 0.0, 1.0); - EmitVertex(); - gl_Position = window.projection * window.view * vec4(p4 + center, 0.0, 1.0); - EmitVertex(); - - EndPrimitive(); -} diff --git a/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_vs.glsl b/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_vs.glsl index 02be2f2654..6560d426c9 100644 --- a/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_vs.glsl +++ b/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_vs.glsl @@ -1,7 +1,24 @@ #version 330 +uniform WindowBlock { + mat4 projection; + mat4 view; +} window; + +// [w, h, tilt] +uniform vec3 shape; + in vec2 in_vert; +in vec2 in_instance_pos; void main() { - gl_Position = vec4(in_vert, 0.0, 1.0); + float angle = radians(shape.z); + mat2 rot = mat2( + cos(angle), -sin(angle), + sin(angle), cos(angle) + ); + // vec2 size = shape.xy / 2.0; + mat4 mvp = window.projection * window.view; + vec2 pos = in_instance_pos + (in_vert * shape.xy); + gl_Position = mvp * vec4(rot * pos, 0.0, 1.0); } From cb5799ff61ee078cee8c533eabfce89ea0117a92 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 7 Jun 2025 20:56:24 +0200 Subject: [PATCH 194/279] Draw lines with no geometry shader (#2714) --- arcade/context.py | 33 +++++++++++++---- arcade/draw/line.py | 4 +- .../shaders/shapes/line/unbuffered_geo.glsl | 37 ------------------- .../shaders/shapes/line/unbuffered_vs.glsl | 26 ++++++++++++- 4 files changed, 52 insertions(+), 48 deletions(-) delete mode 100644 arcade/resources/system/shaders/shapes/line/unbuffered_geo.glsl diff --git a/arcade/context.py b/arcade/context.py index f011b5833d..2cbbc33d7b 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -155,7 +155,6 @@ def __init__( self.shape_line_program: Program = self.load_program( vertex_shader=":system:shaders/shapes/line/unbuffered_vs.glsl", fragment_shader=":system:shaders/shapes/line/unbuffered_fs.glsl", - geometry_shader=":system:shaders/shapes/line/unbuffered_geo.glsl", ) self.shape_ellipse_filled_unbuffered_program: Program = self.load_program( vertex_shader=":system:shaders/shapes/ellipse/filled_unbuffered_vs.glsl", @@ -230,16 +229,34 @@ def __init__( ] ) # Shape line(s) - # Reserve space for 1000 lines (2f pos, 4f color) - # TODO: Different version for buffered and unbuffered - # TODO: Make round-robin buffers self.shape_line_buffer_pos = self.buffer(reserve=8 * 10) - # self.shape_line_buffer_color = self.buffer(reserve=4 * 10) self.shape_line_geometry = self.geometry( [ - BufferDescription(self.shape_line_buffer_pos, "2f", ["in_vert"]), - # BufferDescription(self.shape_line_buffer_color, '4f1', ['in_color']) - ] + # Instanced quad (triangle strip) + BufferDescription( + self.buffer( + data=array( + "f", + [ + 0.0, # 4 dummy vertices + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + ) + ), + "2f", + ["in_vert"], + ), + BufferDescription( + self.shape_line_buffer_pos, "4f", ["in_instance_pos"], instanced=True + ), + ], + mode=self.TRIANGLE_STRIP, ) # ellipse/circle filled self.shape_ellipse_unbuffered_buffer = self.buffer(reserve=8) diff --git a/arcade/draw/line.py b/arcade/draw/line.py index a2e232f6ea..daf5c244be 100644 --- a/arcade/draw/line.py +++ b/arcade/draw/line.py @@ -80,7 +80,7 @@ def draw_line( line_pos_buffer.write(data=array.array("f", (start_x, start_y, end_x, end_y))) ctx.enable(ctx.BLEND) - geometry.render(program, mode=gl.LINES, vertices=2) + geometry.render(program, instances=1) ctx.disable(ctx.BLEND) @@ -127,6 +127,6 @@ def draw_lines(point_list: Point2List, color: RGBOrA255, line_width: float = 1) program["color"] = color_normalized line_buffer_pos.write(data=line_pos_array) - geometry.render(program, mode=gl.LINES, vertices=num_points) + geometry.render(program, instances=num_points // 2) ctx.disable(ctx.BLEND) diff --git a/arcade/resources/system/shaders/shapes/line/unbuffered_geo.glsl b/arcade/resources/system/shaders/shapes/line/unbuffered_geo.glsl deleted file mode 100644 index 5cf17b0587..0000000000 --- a/arcade/resources/system/shaders/shapes/line/unbuffered_geo.glsl +++ /dev/null @@ -1,37 +0,0 @@ -#version 330 - -layout (lines) in; -layout (triangle_strip, max_vertices = 4) out; - -uniform WindowBlock { - mat4 projection; - mat4 view; -} window; - -uniform float line_width; - -vec2 lineNormal2D(vec2 start, vec2 end) { - vec2 n = end - start; - return normalize(vec2(-n.y, n.x)); -} - -void main() { - // Get the line segment - vec2 line_start = gl_in[0].gl_Position.xy; - vec2 line_end = gl_in[1].gl_Position.xy; - - // Calculate normal - vec2 normal = lineNormal2D(line_start, line_end) * line_width / 2.0; - - // Emit a quad using a line strip with the correct line width - gl_Position = window.projection * window.view * vec4(line_start + normal, 0.0, 1.0); - EmitVertex(); - gl_Position = window.projection * window.view * vec4(line_start - normal, 0.0, 1.0); - EmitVertex(); - gl_Position = window.projection * window.view * vec4(line_end + normal, 0.0, 1.0); - EmitVertex(); - gl_Position = window.projection * window.view * vec4(line_end - normal, 0.0, 1.0); - EmitVertex(); - - EndPrimitive(); -} diff --git a/arcade/resources/system/shaders/shapes/line/unbuffered_vs.glsl b/arcade/resources/system/shaders/shapes/line/unbuffered_vs.glsl index 02be2f2654..e886e7bfca 100644 --- a/arcade/resources/system/shaders/shapes/line/unbuffered_vs.glsl +++ b/arcade/resources/system/shaders/shapes/line/unbuffered_vs.glsl @@ -1,7 +1,31 @@ #version 330 +uniform WindowBlock { + mat4 projection; + mat4 view; +} window; + +uniform float line_width; + in vec2 in_vert; +in vec4 in_instance_pos; + +vec2 lineNormal2D(vec2 start, vec2 end) { + vec2 n = end - start; + return normalize(vec2(-n.y, n.x)); +} void main() { - gl_Position = vec4(in_vert, 0.0, 1.0); + vec2 line_start = in_instance_pos.xy; + vec2 line_end = in_instance_pos.zw; + + vec2 normal = lineNormal2D(line_start, line_end) * line_width / 2.0; + mat4 mvp = window.projection * window.view; + vec2 positions[4] = vec2[4]( + line_start + normal, + line_start - normal, + line_end + normal, + line_end - normal + ); + gl_Position = mvp * vec4(positions[gl_VertexID % 4], 0.0, 1.0); } From 72599fcd6d8eece84915ad9c172260decb35f2ae Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sat, 7 Jun 2025 23:48:31 +0200 Subject: [PATCH 195/279] Circle / ellipse no geo shader (#2715) * Fix rect rotation * unbuffered ellipse shader: remove geometry shader * Remove unused in attribute * ellipse outline no geometry shader --- arcade/context.py | 16 +--- arcade/draw/circle.py | 42 ++++++---- .../shapes/ellipse/filled_unbuffered_geo.glsl | 68 ----------------- .../shapes/ellipse/filled_unbuffered_vs.glsl | 34 ++++++++- .../ellipse/outline_unbuffered_geo.glsl | 76 ------------------- .../shapes/ellipse/outline_unbuffered_vs.glsl | 42 +++++++++- .../rectangle/filled_unbuffered_vs.glsl | 4 +- 7 files changed, 105 insertions(+), 177 deletions(-) delete mode 100644 arcade/resources/system/shaders/shapes/ellipse/filled_unbuffered_geo.glsl delete mode 100644 arcade/resources/system/shaders/shapes/ellipse/outline_unbuffered_geo.glsl diff --git a/arcade/context.py b/arcade/context.py index 2cbbc33d7b..b727fa1795 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -159,12 +159,10 @@ def __init__( self.shape_ellipse_filled_unbuffered_program: Program = self.load_program( vertex_shader=":system:shaders/shapes/ellipse/filled_unbuffered_vs.glsl", fragment_shader=":system:shaders/shapes/ellipse/filled_unbuffered_fs.glsl", - geometry_shader=":system:shaders/shapes/ellipse/filled_unbuffered_geo.glsl", ) self.shape_ellipse_outline_unbuffered_program: Program = self.load_program( vertex_shader=":system:shaders/shapes/ellipse/outline_unbuffered_vs.glsl", fragment_shader=":system:shaders/shapes/ellipse/outline_unbuffered_fs.glsl", - geometry_shader=":system:shaders/shapes/ellipse/outline_unbuffered_geo.glsl", ) self.shape_rectangle_filled_unbuffered_program = self.load_program( vertex_shader=":system:shaders/shapes/rectangle/filled_unbuffered_vs.glsl", @@ -258,16 +256,10 @@ def __init__( ], mode=self.TRIANGLE_STRIP, ) - # ellipse/circle filled - self.shape_ellipse_unbuffered_buffer = self.buffer(reserve=8) - self.shape_ellipse_unbuffered_geometry: Geometry = self.geometry( - [BufferDescription(self.shape_ellipse_unbuffered_buffer, "2f", ["in_vert"])] - ) - # ellipse/circle outline - self.shape_ellipse_outline_unbuffered_buffer = self.buffer(reserve=8) - self.shape_ellipse_outline_unbuffered_geometry: Geometry = self.geometry( - [BufferDescription(self.shape_ellipse_outline_unbuffered_buffer, "2f", ["in_vert"])] - ) + # ellipse/circle filled. Empty geometry. We generate it on the fly in the vertex shader. + self.shape_ellipse_unbuffered_geometry: Geometry = self.geometry() + # ellipse/circle outline. Empty geometry. We generate it on the fly in the vertex shader. + self.shape_ellipse_outline_unbuffered_geometry: Geometry = self.geometry() # rectangle filled self.shape_rectangle_filled_unbuffered_buffer = self.buffer(reserve=8) # fmt: off diff --git a/arcade/draw/circle.py b/arcade/draw/circle.py index 729859c55f..c5bc4e98c2 100644 --- a/arcade/draw/circle.py +++ b/arcade/draw/circle.py @@ -1,6 +1,3 @@ -import array - -from arcade import gl from arcade.types import Color, RGBOrA255 from arcade.window_commands import get_window @@ -129,23 +126,31 @@ def draw_ellipse_filled( # Fail immediately if we have no window or context window = get_window() ctx = window.ctx - ctx.enable(ctx.BLEND) program = ctx.shape_ellipse_filled_unbuffered_program geometry = ctx.shape_ellipse_unbuffered_geometry - buffer = ctx.shape_ellipse_unbuffered_buffer # type: ignore # Normalize the color because this shader takes a float uniform color_normalized = Color.from_iterable(color).normalized + # Auto select number of segments if not specified + if num_segments == -1: + size = max(width, height) + if size <= 12: + num_segments = 6 + else: + num_segments = int(size) // 2 + + if num_segments < 3: + num_segments = 3 + # Pass data to the shader + program["center"] = center_x, center_y program["color"] = color_normalized program["shape"] = width / 2, height / 2, tilt_angle program["segments"] = num_segments - buffer.orphan() - buffer.write(data=array.array("f", (center_x, center_y))) - - geometry.render(program, mode=gl.POINTS, vertices=1) + ctx.enable(ctx.BLEND) + geometry.render(program, mode=ctx.TRIANGLES, vertices=num_segments * 3) ctx.disable(ctx.BLEND) @@ -190,20 +195,27 @@ def draw_ellipse_outline( ctx = window.ctx program = ctx.shape_ellipse_outline_unbuffered_program geometry = ctx.shape_ellipse_outline_unbuffered_geometry - buffer = ctx.shape_ellipse_outline_unbuffered_buffer # type: ignore # Normalize the color because this shader takes a float uniform color_normalized = Color.from_iterable(color).normalized - ctx.enable(ctx.BLEND) + # Auto select number of segments if not specified + if num_segments == -1: + size = max(width, height) + if size <= 12: + num_segments = 6 + else: + num_segments = int(size) // 2 + + if num_segments < 3: + num_segments = 3 # Pass data to shader + program["center"] = center_x, center_y program["color"] = color_normalized program["shape"] = width / 2, height / 2, tilt_angle, border_width program["segments"] = num_segments - buffer.orphan() - buffer.write(data=array.array("f", (center_x, center_y))) - - geometry.render(program, mode=gl.POINTS, vertices=1) + ctx.enable(ctx.BLEND) + geometry.render(program, mode=ctx.TRIANGLES, vertices=num_segments * 6) ctx.disable(ctx.BLEND) diff --git a/arcade/resources/system/shaders/shapes/ellipse/filled_unbuffered_geo.glsl b/arcade/resources/system/shaders/shapes/ellipse/filled_unbuffered_geo.glsl deleted file mode 100644 index e1c9a7f377..0000000000 --- a/arcade/resources/system/shaders/shapes/ellipse/filled_unbuffered_geo.glsl +++ /dev/null @@ -1,68 +0,0 @@ -#version 330 - -// 3 points per segment, max of 256 points, so 85 * 3 = 255 -const int MIN_SEGMENTS = 3; -const int MAX_SEGMENTS = 112; -const float PI = 3.141592; - -layout (points) in; -// TODO: We might want to increase the number of emitted vertices, but core 3.3 says 256 is min requirement. -// TODO: Normally 4096 is supported, but let's stay on the safe side -layout (triangle_strip, max_vertices = 256) out; - -uniform WindowBlock { - mat4 projection; - mat4 view; -} window; - -uniform int segments; -// [w, h, tilt] -uniform vec3 shape; - -void main() { - // Get center of the circle - vec2 center = gl_in[0].gl_Position.xy; - int segments_selected = segments; - - // Calculate rotation/tilt - float angle = radians(shape.z); - mat2 rot = mat2( - cos(angle), -sin(angle), - sin(angle), cos(angle) - ); - - if (segments_selected < 0) { - // Estimate the number of segments needed based on size - float size = max(shape.x, shape.y); - if (size <= 4.0) - segments_selected = 4; - else if (size <= 16.0) - segments_selected = 16; - else - segments_selected = 32; - } - // Clamp number of segments - segments_selected = clamp(segments_selected, MIN_SEGMENTS, MAX_SEGMENTS); - - // sin(v), cos(v) travels clockwise around the circle starting at 0, 1 (top of circle) - float st = PI * 2.0 / float(segments_selected); - - for (int i = 0; i < segments_selected; i++) { - gl_Position = window.projection * window.view * vec4(center, 0.0, 1.0); - EmitVertex(); - - // Calculate the ellipse/circle using 0, 0 as origin - vec2 p1 = vec2(sin((float(i) + 1.0) * st), cos((float(i) + 1.0) * st)) * shape.xy; - // Rotate the circle and then add translation to get the right origin - gl_Position = window.projection * window.view * vec4((rot * p1) + center, 0.0, 1.0); - EmitVertex(); - - // Calculate the ellipse/circle using 0, 0 as origin - vec2 p2 = vec2(sin(float(i) * st), cos(float(i) * st)) * shape.xy; - // Rotate the circle and then add translation to get the right origin - gl_Position = window.projection * window.view * vec4((rot * p2) + center, 0.0, 1.0); - EmitVertex(); - - EndPrimitive(); - } -} diff --git a/arcade/resources/system/shaders/shapes/ellipse/filled_unbuffered_vs.glsl b/arcade/resources/system/shaders/shapes/ellipse/filled_unbuffered_vs.glsl index 02be2f2654..efd5f7414d 100644 --- a/arcade/resources/system/shaders/shapes/ellipse/filled_unbuffered_vs.glsl +++ b/arcade/resources/system/shaders/shapes/ellipse/filled_unbuffered_vs.glsl @@ -1,7 +1,37 @@ #version 330 -in vec2 in_vert; +uniform WindowBlock { + mat4 projection; + mat4 view; +} window; + +uniform vec2 center; +uniform int segments; +// [w, h, tilt] +uniform vec3 shape; + +const float PI = 3.141592; void main() { - gl_Position = vec4(in_vert, 0.0, 1.0); + int triangle_id = gl_VertexID / 3; + int vertex_id = gl_VertexID % 3; + + // Calculate rotation/tilt + float angle = radians(shape.z); + mat2 rot = mat2( + cos(angle), -sin(angle), + sin(angle), cos(angle) + ); + // Calculate the positions for the full triangle in the current segment + vec2 positions[3] = vec2[3]( + vec2(0.0, 0.0), + vec2(sin((float(triangle_id) + 1.0) * (PI * 2.0 / float(segments))), + cos((float(triangle_id) + 1.0) * (PI * 2.0 / float(segments)))) * shape.xy, + vec2(sin(float(triangle_id) * (PI * 2.0 / float(segments))), + cos(float(triangle_id) * (PI * 2.0 / float(segments)))) * shape.xy + ); + + mat4 mvp = window.projection * window.view; + vec4 pos = vec4(rot * positions[vertex_id] + center, 0.0, 1.0); + gl_Position = mvp * pos; } diff --git a/arcade/resources/system/shaders/shapes/ellipse/outline_unbuffered_geo.glsl b/arcade/resources/system/shaders/shapes/ellipse/outline_unbuffered_geo.glsl deleted file mode 100644 index 2b9668ab1b..0000000000 --- a/arcade/resources/system/shaders/shapes/ellipse/outline_unbuffered_geo.glsl +++ /dev/null @@ -1,76 +0,0 @@ -#version 330 - -// 3 points per segment, max of 256 points, so 85 * 3 = 255 -const int MIN_SEGMENTS = 3; -const int MAX_SEGMENTS = 112; -const float PI = 3.141592; - -layout (points) in; -// TODO: We might want to increase the number of emitted vertices, but core 3.3 says 256 is min requirement. -// TODO: Normally 4096 is supported, but let's stay on the safe side -layout (triangle_strip, max_vertices = 256) out; - -uniform WindowBlock { - mat4 projection; - mat4 view; -} window; - -uniform int segments; -// [w, h, tilt, thickness] -uniform vec4 shape; - -void main() { - // Get center of the circle - vec2 center = gl_in[0].gl_Position.xy; - int segments_selected = segments; - - // Calculate rotation/tilt - float angle = radians(shape.z); - mat2 rot = mat2( - cos(angle), -sin(angle), - sin(angle), cos(angle) - ); - - if (segments_selected < 0) { - // Estimate the number of segments needed based on size - float size = max(shape.x, shape.y); - if (size <= 4.0) - segments_selected = 4; - else if (size <= 16.0) - segments_selected = 16; - else - segments_selected = 32; - } - // Clamp number of segments - segments_selected = clamp(segments_selected, MIN_SEGMENTS, MAX_SEGMENTS); - - // sin(v), cos(v) travels clockwise around the circle starting at 0, 1 (top of circle) - float st = PI * 2.0 / float(segments_selected); - - // Draw thick circle with triangle strip. This can be handled as a single primitive by the gpu. - // Number of vertices is segments * 2 + 2, so we need to emit the initial vertex first - - // First outer vertex - vec2 p_start = vec2(sin(0.0), cos(0.0)) * shape.xy; - gl_Position = window.projection * window.view * vec4((rot * p_start) + center, 0.0, 1.0); - EmitVertex(); - - // Draw cross segments from inner to outer - for (int i = 0; i < segments_selected; i++) { - // Inner vertex - vec2 p1 = vec2(sin(float(i) * st), cos(float(i) * st)) * (shape.xy - vec2(shape.w)); - gl_Position = window.projection * window.view * vec4((rot * p1) + center, 0.0, 1.0); - EmitVertex(); - - // Outer vertex - vec2 p2 = vec2(sin((float(i) + 1.0) * st), cos((float(i) + 1.0) * st)) * shape.xy; - gl_Position = window.projection * window.view * vec4((rot * p2) + center, 0.0, 1.0); - EmitVertex(); - } - // Last inner vertex to wrap up - vec2 p_end = vec2(sin(0.0), cos(0.0)) * (shape.xy - vec2(shape.w)); - gl_Position = window.projection * window.view * vec4((rot * p_end) + center, 0.0, 1.0); - EmitVertex(); - - EndPrimitive(); -} diff --git a/arcade/resources/system/shaders/shapes/ellipse/outline_unbuffered_vs.glsl b/arcade/resources/system/shaders/shapes/ellipse/outline_unbuffered_vs.glsl index 02be2f2654..8c0aea9da3 100644 --- a/arcade/resources/system/shaders/shapes/ellipse/outline_unbuffered_vs.glsl +++ b/arcade/resources/system/shaders/shapes/ellipse/outline_unbuffered_vs.glsl @@ -1,7 +1,45 @@ #version 330 -in vec2 in_vert; +uniform WindowBlock { + mat4 projection; + mat4 view; +} window; + +uniform vec2 center; +uniform int segments; +// [w, h, tilt, thickness] +uniform vec4 shape; + +const float PI = 3.141592; void main() { - gl_Position = vec4(in_vert, 0.0, 1.0); + // Two triangles per line segment of the outline + int segment_id = gl_VertexID / 6; + int vertex_id = gl_VertexID % 6; + + // Calculate rotation/tilt + float angle = radians(shape.z); + mat2 rot = mat2( + cos(angle), -sin(angle), + sin(angle), cos(angle) + ); + + // sin(v), cos(v) travels clockwise around the circle starting at 0, 1 (top of circle) + float st = PI * 2.0 / float(segments); + + // calculate the four points of the line segment + // Inner and outer points for the start of line segment + vec2 p0 = vec2(sin(float(segment_id) * st), cos(float(segment_id) * st)) * shape.xy; + vec2 p1 = vec2(sin(float(segment_id) * st), cos(float(segment_id) * st)) * (shape.xy - vec2(shape.w)); + + // Inner and outer points for the end of line segment + vec2 p2 = vec2(sin((float(segment_id) + 1.0) * st), cos((float(segment_id) + 1.0) * st)) * shape.xy; + vec2 p3 = vec2(sin((float(segment_id) + 1.0) * st), cos((float(segment_id) + 1.0) * st)) * (shape.xy - vec2(shape.w)); + + vec2 position[6] = vec2[6]( + p1, p0, p2, // first triangle + p1, p2, p3 // second triangle + ); + mat4 mvp = window.projection * window.view; + gl_Position = mvp * vec4(rot * position[vertex_id] + center, 0.0, 1.0); } diff --git a/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_vs.glsl b/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_vs.glsl index 6560d426c9..fbeb22178c 100644 --- a/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_vs.glsl +++ b/arcade/resources/system/shaders/shapes/rectangle/filled_unbuffered_vs.glsl @@ -19,6 +19,6 @@ void main() { ); // vec2 size = shape.xy / 2.0; mat4 mvp = window.projection * window.view; - vec2 pos = in_instance_pos + (in_vert * shape.xy); - gl_Position = mvp * vec4(rot * pos, 0.0, 1.0); + vec2 pos = in_instance_pos + (rot * (in_vert * shape.xy)); + gl_Position = mvp * vec4(pos, 0.0, 1.0); } From c5d70f52af09e779f5e5816646758121c77ec4f5 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Sun, 8 Jun 2025 00:36:50 +0200 Subject: [PATCH 196/279] Lights without geometry shader (#2716) --- arcade/future/light/lights.py | 28 +++++++++-- .../shaders/lights/point_lights_fs.glsl | 2 +- .../shaders/lights/point_lights_geo.glsl | 49 ------------------- .../shaders/lights/point_lights_vs.glsl | 31 ++++++++---- 4 files changed, 46 insertions(+), 64 deletions(-) delete mode 100644 arcade/resources/system/shaders/lights/point_lights_geo.glsl diff --git a/arcade/future/light/lights.py b/arcade/future/light/lights.py index 916af693c8..cc2e7f5531 100644 --- a/arcade/future/light/lights.py +++ b/arcade/future/light/lights.py @@ -97,18 +97,36 @@ def __init__(self, width: int, height: int): self._rebuild = False self._stride = 28 self._buffer = self.ctx.buffer(reserve=self._stride * 100) + # fmt: off + vertex_data = array('f', [ + -1.0, +1.0, 0.0, 1.0, + -1.0, -1.0, 0.0, 0.0, + +1.0, +1.0, 1.0, 1.0, + +1.0, -1.0, 1.0, 0.0, + ]) + # fmt: on self._vao = self.ctx.geometry( [ + gl.BufferDescription( + self.ctx.buffer(data=vertex_data), + "2f 2f", + ["in_vert", "in_uv"], + ), gl.BufferDescription( self._buffer, "2f 1f 1f 3f", - ["in_vert", "in_radius", "in_attenuation", "in_color"], + [ + "in_instance_position", + "in_instance_radius", + "in_instance_attenuation", + "in_instance_color", + ], + instanced=True, ), ] ) self._light_program = self.ctx.load_program( vertex_shader=":system:shaders/lights/point_lights_vs.glsl", - geometry_shader=":system:shaders/lights/point_lights_geo.glsl", fragment_shader=":system:shaders/lights/point_lights_fs.glsl", ) self._combine_program = self.ctx.load_program( @@ -214,10 +232,12 @@ def draw( self._light_buffer.use() self._light_buffer.clear() if len(self._lights) > 0: - self._light_program["position"] = position + self._light_program["offset"] = position self.ctx.enable(self.ctx.BLEND) self.ctx.blend_func = self.ctx.BLEND_ADDITIVE - self._vao.render(self._light_program, mode=self.ctx.POINTS, vertices=len(self._lights)) + self._vao.render( + self._light_program, mode=self.ctx.TRIANGLE_STRIP, instances=len(self._lights) + ) self.ctx.blend_func = self.ctx.BLEND_DEFAULT # Combine pass diff --git a/arcade/resources/system/shaders/lights/point_lights_fs.glsl b/arcade/resources/system/shaders/lights/point_lights_fs.glsl index fe6897a338..10b03e13f4 100644 --- a/arcade/resources/system/shaders/lights/point_lights_fs.glsl +++ b/arcade/resources/system/shaders/lights/point_lights_fs.glsl @@ -1,7 +1,7 @@ #version 330 - out vec4 f_color; + in vec2 uv; in float attenuation; in vec3 color; diff --git a/arcade/resources/system/shaders/lights/point_lights_geo.glsl b/arcade/resources/system/shaders/lights/point_lights_geo.glsl deleted file mode 100644 index cdfcb463d3..0000000000 --- a/arcade/resources/system/shaders/lights/point_lights_geo.glsl +++ /dev/null @@ -1,49 +0,0 @@ -#version 330 -layout (points) in; -layout (triangle_strip, max_vertices = 4) out; - -uniform WindowBlock { - mat4 projection; - mat4 view; -} window; - -uniform vec2 position; - -in float vs_radius[]; -in float vs_attenuation[]; -in vec3 vs_color[]; - -out vec2 uv; -out float attenuation; -out vec3 color; - -void main() { - vec2 center = gl_in[0].gl_Position.xy; - float radius = vs_radius[0]; - - gl_Position = window.projection * window.view * vec4(center + vec2(-radius, radius) + position, 0.0, 1.0); - uv = vec2(0.0, 1.0); - attenuation = vs_attenuation[0]; - color = vs_color[0]; - EmitVertex(); - - gl_Position = window.projection * window.view * vec4(center + vec2(-radius, -radius) + position, 0.0, 1.0); - uv = vec2(0.0, 0.0); - attenuation = vs_attenuation[0]; - color = vs_color[0]; - EmitVertex(); - - gl_Position = window.projection * window.view * vec4(center + vec2(radius, radius) + position, 0.0, 1.0); - uv = vec2(1.0, 1.0); - attenuation = vs_attenuation[0]; - color = vs_color[0]; - EmitVertex(); - - gl_Position = window.projection * window.view * vec4(center + vec2(radius, -radius) + position, 0.0, 1.0); - uv = vec2(1.0, 0.0); - attenuation = vs_attenuation[0]; - color = vs_color[0]; - EmitVertex(); - - EndPrimitive(); -} diff --git a/arcade/resources/system/shaders/lights/point_lights_vs.glsl b/arcade/resources/system/shaders/lights/point_lights_vs.glsl index 70847946d7..d5a4dbf6a4 100644 --- a/arcade/resources/system/shaders/lights/point_lights_vs.glsl +++ b/arcade/resources/system/shaders/lights/point_lights_vs.glsl @@ -1,17 +1,28 @@ #version 330 +uniform WindowBlock { + mat4 projection; + mat4 view; +} window; + +uniform vec2 offset; + in vec2 in_vert; -in float in_radius; -in float in_attenuation; -in vec3 in_color; +in vec2 in_uv; + +in vec2 in_instance_position; +in float in_instance_radius; +in float in_instance_attenuation; +in vec3 in_instance_color; -out float vs_radius; -out float vs_attenuation; -out vec3 vs_color; +out float attenuation; +out vec3 color; +out vec2 uv; void main() { - gl_Position = vec4(in_vert, 0.0, 1.0); - vs_radius = in_radius; - vs_attenuation = in_attenuation; - vs_color = in_color / 255.0; + vec2 position = (in_vert * in_instance_radius) + in_instance_position + offset; + gl_Position = window.projection * window.view * vec4(position, 0.0, 1.0); + uv = in_uv; + attenuation = in_instance_attenuation; + color = in_instance_color / 255.0; } From c35f846f7358e60bfaf46d5e0aec6075a87735f2 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Mon, 9 Jun 2025 13:29:00 +0200 Subject: [PATCH 197/279] GL example fixes (#2718) * Fix spritelist_interaction_hijack_positions * Fix spritelist_interaction_bouncing_coins * Fix spritelist_interaction_visualize_dist_los_trans * Fix spritelist_interaction_visualize_dist_los * Fix spritelist_interaction_visualize_dist --- .../spritelist_interaction_bouncing_coins.py | 24 +++++++------- ...spritelist_interaction_hijack_positions.py | 23 ++++++++++---- .../spritelist_interaction_visualize_dist.py | 6 ++-- ...ritelist_interaction_visualize_dist_los.py | 6 ++-- ...st_interaction_visualize_dist_los_trans.py | 6 ++-- arcade/sprite_list/sprite_list.py | 31 +++++++------------ 6 files changed, 50 insertions(+), 46 deletions(-) diff --git a/arcade/examples/gl/spritelist_interaction_bouncing_coins.py b/arcade/examples/gl/spritelist_interaction_bouncing_coins.py index 65bf1d1795..d9f38e1d75 100644 --- a/arcade/examples/gl/spritelist_interaction_bouncing_coins.py +++ b/arcade/examples/gl/spritelist_interaction_bouncing_coins.py @@ -15,9 +15,10 @@ from array import array from random import randint, uniform +from typing import cast import arcade -from arcade.gl.types import BufferDescription +from arcade.gl import BufferDescription, Buffer from arcade import hitbox WINDOW_WIDTH = 1280 @@ -32,7 +33,7 @@ def __init__(self): super().__init__(WINDOW_WIDTH, WINDOW_HEIGHT, resizable=True) # Generate lots of coins in random positions - self.coins = arcade.SpriteList(use_spatial_hash=None) + self.coins = arcade.SpriteList(use_spatial_hash=False) texture = arcade.load_texture( ":resources:images/items/coinGold.png", hit_box_algorithm=hitbox.algo_bounding_box, @@ -59,14 +60,14 @@ def __init__(self): uniform float delta_time; uniform vec2 size; - in vec3 in_pos; + in vec4 in_pos_angle; in vec2 in_vel; - out vec3 out_pos; + out vec4 out_pos_angle; out vec2 out_vel; void main() { - vec2 pos = in_pos.xy + in_vel * 100.0 * delta_time; + vec2 pos = in_pos_angle.xy + in_vel * 100.0 * delta_time; vec2 vel = in_vel; if (pos.x > size.x) { pos.x = size.x; @@ -84,7 +85,7 @@ def __init__(self): pos.y = 0.0; vel.y *= -1.0; } - out_pos = vec3(pos, in_pos.z); + out_pos_angle = vec4(pos, in_pos_angle.zw); out_vel = vel; } """, @@ -103,19 +104,20 @@ def __init__(self): self.buffer_velocity_2 = self.ctx.buffer(reserve=self.buffer_velocity_1.size) # Create a buffer with the same size as the position buffer in the spritelist. # It's important that these match because we're copying that buffer into this one. - self.buffer_pos_copy = self.ctx.buffer(reserve=self.coins.buffer_positions.size) + self.buffer_pos_angle = cast(Buffer, self.coins.data.storage_positions_angle) + self.buffer_pos_angle_copy = self.ctx.buffer(reserve=self.buffer_pos_angle.size) # Geometry input: Copied positions and first velocity buffer self.geometry_1 = self.ctx.geometry( [ - BufferDescription(self.buffer_pos_copy, "3f", ["in_pos"]), + BufferDescription(self.buffer_pos_angle_copy, "4f", ["in_pos_angle"]), BufferDescription(self.buffer_velocity_1, "2f", ["in_vel"]), ] ) # Geometry input: Copied positions and second velocity buffer self.geometry_2 = self.ctx.geometry( [ - BufferDescription(self.buffer_pos_copy, "3f", ["in_pos"]), + BufferDescription(self.buffer_pos_angle_copy, "4f", ["in_pos_angle"]), BufferDescription(self.buffer_velocity_2, "2f", ["in_vel"]), ] ) @@ -124,13 +126,13 @@ def on_draw(self): self.clear() # Copy the position buffer. This happens on the gpu side. - self.buffer_pos_copy.copy_from_buffer(self.coins.buffer_positions) + self.buffer_pos_angle_copy.copy_from_buffer(self.buffer_pos_angle) # Run the transform writing new positions and velocities self.geometry_1.transform( self.program, [ - self.coins.buffer_positions, + self.buffer_pos_angle, self.buffer_velocity_2, ], ) diff --git a/arcade/examples/gl/spritelist_interaction_hijack_positions.py b/arcade/examples/gl/spritelist_interaction_hijack_positions.py index 19323a185c..d3da2cf72d 100644 --- a/arcade/examples/gl/spritelist_interaction_hijack_positions.py +++ b/arcade/examples/gl/spritelist_interaction_hijack_positions.py @@ -9,8 +9,12 @@ """ import math +from typing import cast + import arcade from arcade import hitbox +from arcade.sprite_list.sprite_list import SpriteListBufferData +from arcade.gl import Buffer NUM_COINS = 500 @@ -38,23 +42,24 @@ def __init__(self): // The current time to add some movement uniform float time; - // The "bendyness" value accelerating rotations + // The "bendiness" value accelerating rotations uniform float bend; // The current size of the screen uniform vec2 size; // The new positions we are writing into a new buffer - out vec3 out_pos; + out vec4 out_pos; void main() { // gl_VertexID is the sprite position in the spritelist. // We can use that to value to create unique positions with // some simple math. float vertId = float(gl_VertexID); - out_pos = vec3(size, 0.0) / 2.0 + vec3( + out_pos = vec4(size, 0.0, 0.0) / 2.0 + vec4( sin(vertId + time + vertId * bend), cos(vertId + time + vertId * bend), + 0.0, 0.0 ) * vertId; } @@ -65,11 +70,17 @@ def __init__(self): def on_draw(self): self.clear() + if not isinstance(self.coins.data, SpriteListBufferData): + raise RuntimeError( + "The spritelist data must be of type SpriteListBufferData." + ) + pos_angle_buffer = cast(Buffer, self.coins.data.storage_positions_angle) + # Write the new positions directly into the position # buffer of the spritelist. A little bit rude, but it works. - self.coins.geometry.transform( + self.coins.data.geometry.transform( self.position_program, - self.coins.buffer_positions, + pos_angle_buffer, vertices=len(self.coins), ) self.coins.draw() @@ -77,7 +88,7 @@ def on_draw(self): def on_update(self, delta_time: float): # Keep updating the current time to animation the movement self.position_program["time"] = self.time / 4 - # Update the "bendyness" value + # Update the "bendiness" value self.position_program["bend"] = math.cos(self.time) / 400 def on_resize(self, width: int, height: int): diff --git a/arcade/examples/gl/spritelist_interaction_visualize_dist.py b/arcade/examples/gl/spritelist_interaction_visualize_dist.py index 1bab87056a..b87926b7d0 100644 --- a/arcade/examples/gl/spritelist_interaction_visualize_dist.py +++ b/arcade/examples/gl/spritelist_interaction_visualize_dist.py @@ -49,14 +49,14 @@ def __init__(self): #version 330 // Sprite positions from SpriteList - in vec3 in_pos; + in vec4 in_pos; // Output to geometry shader out vec3 v_position; void main() { // This shader just forwards info to geo shader - v_position = in_pos; + v_position = in_pos.xyz; } """, geometry_shader=""" @@ -120,7 +120,7 @@ def on_draw(self): # use to run our shader/gpu program. It only requires that we # use correctly named input name(s). in_pos in this example # what will automatically map in the position buffer to the vertex shader. - self.coins.geometry.render(self.program_visualize_dist, vertices=len(self.coins)) + self.coins.data.geometry.render(self.program_visualize_dist, vertices=len(self.coins)) arcade.draw_sprite(self.player) # Visualize the interaction radius diff --git a/arcade/examples/gl/spritelist_interaction_visualize_dist_los.py b/arcade/examples/gl/spritelist_interaction_visualize_dist_los.py index 8e0e30f2e0..bc29895d66 100644 --- a/arcade/examples/gl/spritelist_interaction_visualize_dist_los.py +++ b/arcade/examples/gl/spritelist_interaction_visualize_dist_los.py @@ -72,14 +72,14 @@ def __init__(self): #version 330 // Sprite positions from SpriteList - in vec3 in_pos; + in vec4 in_pos; // Output to geometry shader out vec3 v_position; void main() { // This shader just forwards info to geo shader - v_position = in_pos; + v_position = in_pos.xyz; } """, geometry_shader=""" @@ -178,7 +178,7 @@ def on_draw(self): # use to run our shader/gpu program. It only requires that we # use correctly named input name(s). in_pos in this example # what will automatically map in the position buffer to the vertex shader. - self.coins.geometry.render(self.program_visualize_dist, vertices=len(self.coins)) + self.coins.data.geometry.render(self.program_visualize_dist, vertices=len(self.coins)) arcade.draw_sprite(self.player) # Visualize the interaction radius diff --git a/arcade/examples/gl/spritelist_interaction_visualize_dist_los_trans.py b/arcade/examples/gl/spritelist_interaction_visualize_dist_los_trans.py index 0dd505ef01..8467036bc3 100644 --- a/arcade/examples/gl/spritelist_interaction_visualize_dist_los_trans.py +++ b/arcade/examples/gl/spritelist_interaction_visualize_dist_los_trans.py @@ -80,14 +80,14 @@ def __init__(self): #version 330 // Sprite positions from SpriteList - in vec3 in_pos; + in vec4 in_pos; // Output to geometry shader out vec3 v_position; void main() { // This shader just forwards info to geo shader - v_position = in_pos; + v_position = in_pos.xyz; } """, geometry_shader=""" @@ -183,7 +183,7 @@ def on_draw(self): # use to run our shader/gpu program. It only requires that we # use correctly named input name(s). in_pos in this example # what will automatically map in the position buffer to the vertex shader. - self.coins.geometry.transform( + self.coins.data.geometry.transform( self.program_select_sprites, self.result_buffer, vertices=len(self.coins), diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 83887a83b6..1eadcad1c8 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -323,11 +323,11 @@ def _init_deferred(self) -> None: # NOTE: Instantiate the appropriate spritelist data class here # Desktop GL (with geo shader) - # self._data = SpriteListBufferData( + self._data = SpriteListBufferData(self.ctx, capacity=self._buf_capacity, atlas=self._atlas) + # WebGL (without geo shader) + # self._data = SpriteListTextureData( # self.ctx, capacity=self._buf_capacity, atlas=self._atlas # ) - # WebGL (without geo shader) - self._data = SpriteListTextureData(self.ctx, capacity=self._buf_capacity, atlas=self._atlas) self._initialized = True # Load all the textures and write texture coordinates into buffers. @@ -1260,6 +1260,7 @@ def __init__(self, ctx: ArcadeContext, capacity: int) -> None: self.ctx = ctx self._buf_capacity = capacity self._idx_capacity = capacity + self._geometry: Geometry # Generic GPU storage for sprite data self._storage_pos_angle: Buffer | Texture2D @@ -1268,6 +1269,13 @@ def __init__(self, ctx: ArcadeContext, capacity: int) -> None: self._storage_texture_id: Buffer | Texture2D self._storage_index: Buffer | Texture2D + @property + def geometry(self) -> Geometry: + """ + Returns the internal OpenGL geometry for this spritelist. + """ + return self._geometry + @property def storage_positions_angle(self) -> Buffer | Texture2D: """ @@ -1441,23 +1449,6 @@ def __init__(self, ctx: ArcadeContext, capacity: int, atlas: TextureAtlasBase) - index_element_size=4, # 32 bit integers ) - @property - def geometry(self) -> Geometry: - """ - Returns the internal OpenGL geometry for this spritelist. - This can be used to execute custom shaders with the - spritelist data. - - One or multiple of the following inputs must be defined in your vertex shader:: - - in vec2 in_pos; - in float in_angle; - in vec2 in_size; - in float in_texture; - in vec4 in_color; - """ - return self._geometry - @property def buffer_positions_angle(self) -> Buffer: """ From 2d2ade48aaad887a0f03bf842b6d22d07ba6f641 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Mon, 9 Jun 2025 13:37:17 +0200 Subject: [PATCH 198/279] Update year in README and license files (#2719) --- README.md | 26 +++++++++++++------------- license.rst | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 11d90c959e..78e69d01b7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Welcome to The Arcade Library! +# Welcome to The Arcade Library + + + \ No newline at end of file diff --git a/webplayground/server.py b/webplayground/server.py new file mode 100644 index 0000000000..4aba5090f7 --- /dev/null +++ b/webplayground/server.py @@ -0,0 +1,72 @@ +#! /usr/bin/env python + +import importlib +import os +import pkgutil +import shutil +import subprocess +import sys +from pathlib import Path + +from bottle import route, run, static_file, template # type: ignore + +from arcade import examples + +here = Path(__file__).parent.resolve() + +path_arcade = Path("../") +arcade_wheel_filename = "arcade-4.0.0.dev1-py3-none-any.whl" +path_arcade_wheel = path_arcade / "dist" / arcade_wheel_filename + + +def find_modules(module): + path_list = [] + spec_list = [] + for importer, modname, ispkg in pkgutil.walk_packages(module.__path__): + import_path = f"{module.__name__}.{modname}" + if ispkg: + pkg = importlib.import_module(import_path) + path_list.extend(find_modules(pkg)) + else: + path_list.append(import_path) + for spec in spec_list: + del sys.modules[spec.name] + return path_list + + +@route("/static/") +def whl(filepath): + return static_file(filepath, root="./") + + +@route("/") +def index(): + examples_list = find_modules(examples) + return template("index.tpl", examples=examples_list) + + +@route("/example") +@route("/example/") +def example(name="platform_tutorial.01_open_window"): + return template( + "example.tpl", + name=name, + arcade_wheel=arcade_wheel_filename, + ) + + +def main(): + # Get us in this file's parent directory + os.chdir(here) + + # Go to arcade and build a wheel + os.chdir(path_arcade) + subprocess.run(["python", "-m", "build", "--wheel", "--outdir", "dist"]) + os.chdir(here) + shutil.copy(path_arcade_wheel, f"./{arcade_wheel_filename}") + + run(host="localhost", port=8000) + + +if __name__ == "__main__": + main() From f020c83f278938da6fbc27c364b19bc5db10970a Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Fri, 26 Dec 2025 21:14:30 -0500 Subject: [PATCH 254/279] More GitHub Actions Cleanup for 4.0 (#2803) --- .github/workflows/code_quality.yml | 20 +++-- .github/workflows/create_commit_note_log.yml | 58 -------------- .github/workflows/docs.yml | 35 -------- .github/workflows/push_build_to_prod_pypi.yml | 80 ++++++++++++------- .github/workflows/push_build_to_test_pypi.yml | 35 -------- .github/workflows/status_embed.yaml | 4 +- 6 files changed, 64 insertions(+), 168 deletions(-) delete mode 100644 .github/workflows/create_commit_note_log.yml delete mode 100644 .github/workflows/docs.yml delete mode 100644 .github/workflows/push_build_to_test_pypi.yml diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 77b1cff767..f853b24ac5 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -11,7 +11,7 @@ on: jobs: - build: + lints: name: Code Inspections runs-on: ubuntu-latest @@ -51,8 +51,14 @@ jobs: continue-on-error: true run: uv run make.py pyright - verifytypes: - name: Verify Types + - name: Build Docs + continue-on-error: true + run: uv run make.py docs-full + + # This is a second job instead of an extra step because it takes the longest + # So having it as a second job lets it run in parallel to the other checks. + docs: + name: Build Documentation runs-on: ubuntu-latest steps: @@ -68,10 +74,8 @@ jobs: with: enable-cache: true - - name: Sync UV project + - name: Sync UV Project run: uv sync - - name: Pyright Type Completeness - # Suppress exit code because we do not expect to reach 100% type completeness any time soon - run: uv run pyright --verifytypes arcade || true - + - name: build-docs + run: uv run make.py docs-full diff --git a/.github/workflows/create_commit_note_log.yml b/.github/workflows/create_commit_note_log.yml deleted file mode 100644 index 58bbe5c1cb..0000000000 --- a/.github/workflows/create_commit_note_log.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Create Commit Note Log - -on: - push: - tags: - - "*" - workflow_dispatch: - inputs: - git-tag: - description: tag to create notes off of - required: true - -jobs: - create-commit-note-log: - name: Create Commit Note Log - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set tag from input - id: tag-input - if: github.event_name == 'workflow_dispatch' - run: |- - tag=${{ github.event.inputs.git-tag }} - if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo ::set-output name=tag::${{ github.event.inputs.git-tag }} - else - echo "Tag not found" - exit 1 - fi - - name: Set tag from commit - id: tag-commit - if: github.event_name != 'workflow_dispatch' - run: |- - git_ref="${GITHUB_REF#refs/*/}" - echo ::set-output name=tag::$git_ref - - name: Get Git commit history - id: git-commit - run: |- - current_tag=${{ steps.tag-input.outputs.tag || steps.tag-commit.outputs.tag }} - previous_tag=$(git describe --abbrev=0 --match "*" --tags $current_tag^) - echo "current_tag=$current_tag" - echo "previous_tag=$previous_tag" - echo "commit_history" - echo "==============" - while read -r; - do - echo "- $REPLY" | tee -a body.md - done < <(git log --pretty=oneline --abbrev-commit --decorate-refs-exclude=refs/tags $current_tag...$previous_tag) - echo "==============" - echo ::set-output name=tag::$current_tag - - uses: ncipollo/release-action@v1 - with: - bodyFile: "body.md" - tag: ${{ steps.git-commit.outputs.tag }} - allowUpdates: true \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index fe60bc4499..0000000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Builds the doc in PRs - -name: Docs Build - -on: - push: - branches: [development, maintenance] - pull_request: - branches: [development, maintenance] - workflow_dispatch: - -jobs: - - build: - name: Build Documentation - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install UV - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - - - name: Sync UV Project - run: uv sync - - - name: build-docs - run: uv run make.py docs-full diff --git a/.github/workflows/push_build_to_prod_pypi.yml b/.github/workflows/push_build_to_prod_pypi.yml index 7091b7d873..5bf6526b62 100644 --- a/.github/workflows/push_build_to_prod_pypi.yml +++ b/.github/workflows/push_build_to_prod_pypi.yml @@ -1,38 +1,58 @@ -name: Distribute build to PyPi Production - on: - workflow_dispatch: - inputs: - tag: - description: 'Tag to deploy' - required: true - type: string - -jobs: + push: + tags: + - '*' - # --- Deploy to pypi - deploy-to-pypi-prod: +name: Create a Release +jobs: + run: runs-on: ubuntu-latest - environment: deploy-pypi-prod + environment: + name: deploy-pypi-prod + permissions: + id-token: write + contents: read steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-tags: 'true' - ref: ${{ github.event.inputs.tag }} - - name: Set up Python + - uses: actions/checkout@v5 + + - name: Setup Python uses: actions/setup-python@v5 with: - python-version: 3.x - - name: Install dependencies - run: >- - python -m pip install build twine - - name: Build and Upload to Prod PyPI + python-version: '3.14' + + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Build Project + run: uv build + + - name: Publish to PyPi + run: uv publish + + - name: Generate Release Notes run: | - python -m build --sdist --wheel --outdir dist/ - python3 -m twine upload dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TWINE_PROD_TOKEN }} - TWINE_REPOSITORY: pypi + current_tag=${{ github.ref_name }} + previous_tag=$(git describe --abbrev=0 --match "*" --tags $current_tag^) + echo "current_tag=$current_tag" + echo "previous_tag=$previous_tag" + echo "commit_history" + echo "==============" + while read -r; + do + echo "- $REPLY" | tee -a body.md + done < <(git log --pretty=oneline --abbrev-commit --decorate-refs-exclude=refs/tags $current_tag...$previous_tag) + echo "==============" + + - name: Publish GitHub Release + uses: ncipollo/release-action@v1 + with: + bodyFile: "body.md" + tag: ${{ github.ref_name }} + + + + + \ No newline at end of file diff --git a/.github/workflows/push_build_to_test_pypi.yml b/.github/workflows/push_build_to_test_pypi.yml deleted file mode 100644 index 491627115a..0000000000 --- a/.github/workflows/push_build_to_test_pypi.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Distribute build to PyPi Test - -on: - workflow_dispatch: - -jobs: - # --- Bump version - # ( this is manual until we find a better-tested, maintained bump action ) - - # --- Deploy to pypi - deploy-to-pypi-test: - - runs-on: ubuntu-latest - environment: deploy-pypi-test - needs: bump-version - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-tags: 'true' - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.x - - name: Install dependencies - run: >- - python3 -m pip install build twine - - name: Build and Upload to Test PyPI - run: | - python3 -m build --sdist --wheel --outdir dist/ - python3 -m twine upload dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TWINE_TEST_TOKEN }} - TWINE_REPOSITORY: testpypi diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml index 41ab5e29ff..f8027c4ca8 100644 --- a/.github/workflows/status_embed.yaml +++ b/.github/workflows/status_embed.yaml @@ -3,8 +3,8 @@ name: Status Embed on: workflow_run: workflows: - - GitHub Ubuntu test - - Windows self-hosted test + - PyTest + - Code Quality types: - completed From ab5ca569770550d6a39926517b66ea7eb540a90d Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Fri, 26 Dec 2025 21:19:21 -0500 Subject: [PATCH 255/279] Dependency Cleanup for WASM environments (#2804) * Make pymunk optional * Some more dependency cleanup for WASM environment --- arcade/__init__.py | 4 +++- arcade/hitbox/__init__.py | 10 ++++++---- pyproject.toml | 10 +++++++--- webplayground/example.tpl | 7 +++---- webplayground/server.py | 2 +- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index e10cf5a079..dbf9f2bad7 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -192,10 +192,12 @@ def configure_logging(level: int | None = None): from .tilemap import load_tilemap from .tilemap import TileMap -if sys.platform != "emscripten": +try: from .pymunk_physics_engine import PymunkPhysicsEngine from .pymunk_physics_engine import PymunkPhysicsObject from .pymunk_physics_engine import PymunkException +except ImportError: + pass from .version import VERSION diff --git a/arcade/hitbox/__init__.py b/arcade/hitbox/__init__.py index d8881c4bff..01086e7a9b 100644 --- a/arcade/hitbox/__init__.py +++ b/arcade/hitbox/__init__.py @@ -1,7 +1,6 @@ from PIL.Image import Image from arcade.types import Point2List -from arcade.utils import is_pyodide from .base import HitBox, HitBoxAlgorithm, RotatableHitBox from .bounding_box import BoundingHitBoxAlgorithm @@ -10,12 +9,15 @@ #: The simple hit box algorithm. algo_simple = SimpleHitBoxAlgorithm() -#: The detailed hit box algorithm. -if not is_pyodide(): +#: The detailed hit box algorithm. This depends on pymunk and will fallback to the simple algorithm. +try: from .pymunk import PymunkHitBoxAlgorithm - algo_detailed = PymunkHitBoxAlgorithm() +except ImportError: + print("WARNING: Running without PyMunk. The detailed hitbox algorithm will fallback to simple") + algo_detailed = SimpleHitBoxAlgorithm() + #: The bounding box hit box algorithm. algo_bounding_box = BoundingHitBoxAlgorithm() diff --git a/pyproject.toml b/pyproject.toml index ec18226f4f..dc46ffefff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,7 @@ classifiers = [ ] dependencies = [ "pyglet==3.0.dev1", - "pillow~=12.0.0", - "pymunk~=7.2.0", + "pillow>=11.3.0", "pytiled-parser~=2.2.9", ] dynamic = ["version"] @@ -36,6 +35,9 @@ Source = "https://github.com/pythonarcade/arcade" Book = "https://learn.arcade.academy" [dependency-groups] +extras = [ + "pymunk~=7.2.0" +] # Used for dev work dev = [ "sphinx==8.1.3", # April 2024 | Updated 2024-07-15, 7.4+ is broken with sphinx-autobuild @@ -62,8 +64,10 @@ dev = [ "click==8.1.7", # Temp fix until we bump typer "typer==0.12.5", # Needed for make.py "wheel", - "bottle" # Used for web testing playground + "bottle", # Used for web testing playground + {include-group = "extras"} ] + # Testing only testing_libraries = ["pytest", "pytest-mock", "pytest-cov", "pyyaml==6.0.1"] diff --git a/webplayground/example.tpl b/webplayground/example.tpl index 1288c96c7e..1c686f9e61 100644 --- a/webplayground/example.tpl +++ b/webplayground/example.tpl @@ -4,7 +4,7 @@ % title = name.split(".")[-1] {{title}} - + @@ -13,9 +13,8 @@ let pyodide = await loadPyodide(); await pyodide.loadPackage("micropip"); const micropip = pyodide.pyimport("micropip"); - await pyodide.loadPackage("pillow"); // Arcade needs Pillow - await micropip.install("pyglet==3.0.dev1", pre=true) - await micropip.install("http://localhost:8000/static/{{arcade_wheel}}"); + await pyodide.loadPackage("pillow"); + await micropip.install("http://localhost:8000/static/{{arcade_wheel}}", pre=true); // We are importing like this because some example files have numbers in the name, and you can't use those in normal import statements pyodide.runPython(` diff --git a/webplayground/server.py b/webplayground/server.py index 4aba5090f7..1a4b1c7566 100644 --- a/webplayground/server.py +++ b/webplayground/server.py @@ -61,7 +61,7 @@ def main(): # Go to arcade and build a wheel os.chdir(path_arcade) - subprocess.run(["python", "-m", "build", "--wheel", "--outdir", "dist"]) + subprocess.run(["uv", "build"]) os.chdir(here) shutil.copy(path_arcade_wheel, f"./{arcade_wheel_filename}") From 34b13efb7ebfea421b52160ddb9aa710e2cb2090 Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Sat, 27 Dec 2025 03:54:21 -0500 Subject: [PATCH 256/279] Remove pixel scaling for default framebuffer from WebGL backend (#2805) --- arcade/gl/backends/webgl/framebuffer.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/arcade/gl/backends/webgl/framebuffer.py b/arcade/gl/backends/webgl/framebuffer.py index a5bf4a18cf..a4d30975b3 100644 --- a/arcade/gl/backends/webgl/framebuffer.py +++ b/arcade/gl/backends/webgl/framebuffer.py @@ -256,7 +256,7 @@ def __init__(self, ctx: WebGLContext): @DefaultFrameBuffer.viewport.setter def viewport(self, value: tuple[int, int, int, int]): - # This is the exact same as the WebGLFramebuffer setter + # This is very similar to the OpenGL backend setter # WebGL backend doesn't need to handle pixel scaling for the # default framebuffer like desktop does, the browser does that # for us. However we need a separate implementation for the @@ -264,13 +264,7 @@ def viewport(self, value: tuple[int, int, int, int]): if not isinstance(value, tuple) or len(value) != 4: raise ValueError("viewport shouldbe a 4-component tuple") - ratio = self.ctx.window.get_pixel_ratio() - self._viewport = ( - int(value[0] * ratio), - int(value[1] * ratio), - int(value[2] * ratio), - int(value[3] * ratio), - ) + self._viewport = value if self._ctx.active_framebuffer == self: self._ctx._gl.viewport(*self._viewport) @@ -281,23 +275,11 @@ def viewport(self, value: tuple[int, int, int, int]): @DefaultFrameBuffer.scissor.setter def scissor(self, value): - # This is the exact same as the WebGLFramebuffer setter - # WebGL backend doesn't need to handle pixel scaling for the - # default framebuffer like desktop does, the browser does that - # for us. However we need a separate implementation for the - # function because of ABC if value is None: self._scissor = None if self._ctx.active_framebuffer == self: self._ctx._gl.scissor(*self._viewport) else: - ratio = self.ctx.window.get_pixel_ratio() - self._scissor = ( - int(value[0] * ratio), - int(value[1] * ratio), - int(value[2] * ratio), - int(value[3] * ratio), - ) - + self._scissor = value if self._ctx.active_framebuffer == self: self._ctx._gl.scissor(*self._scissor) From 9984b18323bed34d98eabb1e52e957e380a1fdfa Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sun, 28 Dec 2025 03:31:29 +0100 Subject: [PATCH 257/279] Simplify web tinkering (#2806) * Remove pixel scaling for default framebuffer from WebGL backend * Add local scripts support for testing in the web environment, update web readme --------- Co-authored-by: Darren Eberly --- webplayground/README.md | 26 +++++-- webplayground/index.tpl | 23 ++++++ webplayground/local.tpl | 75 +++++++++++++++++++ webplayground/local_run.tpl | 80 +++++++++++++++++++++ webplayground/local_scripts/README.md | 59 +++++++++++++++ webplayground/local_scripts/example_test.py | 43 +++++++++++ webplayground/server.py | 42 ++++++++++- 7 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 webplayground/local.tpl create mode 100644 webplayground/local_run.tpl create mode 100644 webplayground/local_scripts/README.md create mode 100644 webplayground/local_scripts/example_test.py diff --git a/webplayground/README.md b/webplayground/README.md index df6b4b0df5..1491686e64 100644 --- a/webplayground/README.md +++ b/webplayground/README.md @@ -5,18 +5,30 @@ An http server is provided with the `server.py` file. This file can be run with The index page will provide a list of all Arcade examples. This is generated dynamically on the fly when the page is loaded, and will show all examples in the `arcade.examples` package. This generates links which can be followed to open any example in the browser. -There are some pre-requesites to running this server. It assumes that you have the `development` branch of Pyglet -checked out and in a folder named `pyglet` directly next to your Arcade repo directory. You will also need to have -the `build` and `flit` packages from PyPi installed. These are used by Pyglet and Arcade to build wheel files, -but are not generally installed for local development. +## Testing Local Scripts -Assuming you have Pyglet ready to go, you can then start the server. It will build wheels for both Pyglet and Arcade, and copy them -into this directory. This means that if you make any, you will need to restart this server in order to build new wheels. +You can now test your own local scripts **without restarting the server**! + +1. Navigate to `http://localhost:8000/local` in your browser +2. Place your Python scripts in the `local_scripts/` directory +3. Scripts should have a `main()` function as the entry point +4. The page will automatically list all `.py` files in that directory +5. Click any script to run it in the browser +6. Edit your scripts and refresh the browser page to see changes - no server restart needed! + +See `local_scripts/README.md` and `local_scripts/example_test.py` for more details and examples. + +## Prerequisites + +You will need to have `uv` installed to build the Arcade wheel. You can install it with: + +When you start the server, it will automatically build an Arcade wheel and copy it into this directory. +This means that if you make any changes to Arcade code, you will need to restart the server to build a new wheel with your changes. ## How does this work? The web server itself is built with a nice little HTTP server library named [Bottle](https://github.com/bottlepy/bottle). We need to run an HTTP server locally -to load anything into WASM in the browser, it will not work if we just server it files directly due to browser security constraints. For the Arcade examples specifically, +to load anything into WASM in the browser, as it will not work if we just serve files directly due to browser security constraints. For the Arcade examples specifically, we are taking advantage of the fact that the example code is packaged directly inside of Arcade to enable executing them in the browser. If we need to add extra code that is not part of the Arcade package, that will require extension of this server to handle packaging it properly for loading into WASM, and then diff --git a/webplayground/index.tpl b/webplayground/index.tpl index 4c5a8e11b5..66e42fd740 100644 --- a/webplayground/index.tpl +++ b/webplayground/index.tpl @@ -3,9 +3,32 @@ Arcade Examples + +

    Arcade Examples

    + 🧪 Test Local Scripts +

    Built-in Examples:

    @@ -40,24 +40,24 @@ for example game jam entries and more. ## Stable Documentation -Read the stable documentation at https://api.arcade.academy. +Read the stable documentation at . ## Development Previews -Preview the next release at https://api.arcade.academy/en/development/. +Preview the next release at . ## Citation ``` - @Online{PythonArcade, - author = {Paul Vincent Craven}, - title = {Easy to use Python library for creating 2D Arcade games.}, - date = {2023-01-01}, - publisher = {GitHub}, - journal = {GitHub repository}, - howpublished = {\url{https://github.com/pythonarcade/arcade}}, - commit = {} - } +@Online{PythonArcade, + author = {Paul Vincent Craven}, + title = {Easy to use Python library for creating 2D Arcade games.}, + date = {2025-01-01}, + publisher = {GitHub}, + journal = {GitHub repository}, + howpublished = {\url{https://github.com/pythonarcade/arcade}}, + commit = {} +} ``` ## Contact the Maintainers @@ -65,4 +65,4 @@ Preview the next release at https://api.arcade.academy/en/development/. The best way to contact and chat with the maintainers is on the [Arcade Discord Server][]. -paul@cravenfamily.com + diff --git a/license.rst b/license.rst index d70898a896..baf695fd28 100644 --- a/license.rst +++ b/license.rst @@ -1,7 +1,7 @@ License ======= -Copyright (c) 2022 Paul Vincent Craven +Copyright (c) 2025 Paul Vincent Craven The Arcade library is licensed under the `MIT License`_. From 0bcef98020a672166b6b54ce469c915897ff959d Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Mon, 9 Jun 2025 14:26:04 +0200 Subject: [PATCH 199/279] Update CHANGELOG for 3.3 (#2720) --- CHANGELOG.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a61c66357..0a4eff7fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,22 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## Unreleased +## 3.3 (Unreleased) - Fixed an issue causing a crash when closing the window -- Added `Window.close` (bool) attribute indicating if the window is closed +- Added `Window.closed` (bool) attribute indicating if the window is closed +- Fixed an issue where `on_draw` could be dispatched after the window was closed +- Added `PymunkPhysicsEngine.update_sprite` for manually updating a sprite's shape + to synchronize sprite hit boxes with the physics engine +- Fixed an issue causing `on_mouse_leave` to be called from disabled `Section`s +- Various documentation fixes and improvements +- Scene + - `Scene.add_sprite` now returns the added sprite + - `Scene.add_sprite_list` now returns the added sprite list + - `Scene.add_sprite_before` now returns the added sprite list + - `Scene.move_sprite_list_before` now returns the moved sprite list + - `Scene.remove_sprite_list_by_index` now returns the removed sprite list + - `Scene.remove_sprite_list_by_name` now returns the removed sprite list - GUI - Fix `UILabel` with enabled multiline sometimes cut off text - Improved `UIWidget` usability for resizing and positioning: @@ -14,6 +26,13 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Added property setters for `center_x` and `center_y` - Added property setters for `left`, `right`, `top`, and `bottom` - Users can now set widget position and size more intuitively without needing to access the `rect` property +- Rendering: + - The `arcade.gl` package was restructured to be more modular in preparation for + other backends such as WebGL and WebGPU + - Rewrote many shader programs to not use geometry shaders, which are not supported in WebGL + and some other rendering backends + - Fixed a few instances og exceptions not being raised properly in edge cases + - `SpriteList` now has multiple rendering systems supporting both WebGL and Desktop GL ## Version 3.2 From 4ed79ee7e94aa05affd527cfdf3617e026009b56 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Mon, 9 Jun 2025 14:46:45 +0200 Subject: [PATCH 200/279] Update CHANGELOG.md (#2721) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4eff7fc2..8a2807b6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,10 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Rewrote many shader programs to not use geometry shaders, which are not supported in WebGL and some other rendering backends - Fixed a few instances og exceptions not being raised properly in edge cases - - `SpriteList` now has multiple rendering systems supporting both WebGL and Desktop GL + - **BREAKING CHANGE**: `SpriteList` now has multiple rendering systems supporting both WebGL and Desktop GL. + If you have customized spritelist rendering you now need to modify the `SpriteListData` instance + on the spritelist accessed through `SpriteList.data`. This instance holds all the GPU-related + resources for the spritelist such as buffers, textures, geometry and shader program. ## Version 3.2 From 4d8c653a0ecb4b09002083165267940d1d0b4042 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Mon, 9 Jun 2025 08:30:48 -0500 Subject: [PATCH 201/279] Prep for version update (#2722) Co-authored-by: Paul V Craven --- CHANGELOG.md | 2 +- arcade/VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a2807b6b2..b06b3d7ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## 3.3 (Unreleased) +## 3.3.0 - Fixed an issue causing a crash when closing the window - Added `Window.closed` (bool) attribute indicating if the window is closed diff --git a/arcade/VERSION b/arcade/VERSION index a4f52a5dbb..0fa4ae4890 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.2.0 \ No newline at end of file +3.3.0 \ No newline at end of file From 993a2b6b762652bf691715fe7018aaed869358cf Mon Sep 17 00:00:00 2001 From: Alan Joshi George Date: Tue, 10 Jun 2025 17:35:17 +0530 Subject: [PATCH 202/279] Add timing output to subprocess commands in make.py --- make.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/make.py b/make.py index 463de5daf0..d268e41b94 100755 --- a/make.py +++ b/make.py @@ -19,6 +19,7 @@ from shutil import rmtree, which from typing import Union from collections.abc import Generator +import time PathLike = Union[Path, str, bytes] @@ -144,13 +145,15 @@ def run(args: str | list[str], cd: PathLike | None = None) -> None: """ cmd = " ".join(args) print(">> Running command:", cmd) + start_time = time.time() if cd is not None: with cd_context(_resolve(cd, strict=True)): result = subprocess.run(args) else: result = subprocess.run(args) - - print(">> Command finished:", cmd, "\n") + elapsed_time = time.time() - start_time + minutes, seconds = divmod(elapsed_time, 60) + print(f">> Command finished ({int(minutes)}m {int(seconds)}s): {cmd} \n") # TODO: Should we exit here? Or continue to let other commands run also? if result.returncode != 0: From d25be0dc31a3783eb839cb46750d8c3f3cc1fe99 Mon Sep 17 00:00:00 2001 From: Alan Joshi George Date: Sun, 15 Jun 2025 13:41:39 +0530 Subject: [PATCH 203/279] update time reporting for subprocess commands --- make.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/make.py b/make.py index d268e41b94..e6ae8a354e 100755 --- a/make.py +++ b/make.py @@ -145,15 +145,24 @@ def run(args: str | list[str], cd: PathLike | None = None) -> None: """ cmd = " ".join(args) print(">> Running command:", cmd) - start_time = time.time() + start_time = time.perf_counter() if cd is not None: with cd_context(_resolve(cd, strict=True)): result = subprocess.run(args) else: result = subprocess.run(args) - elapsed_time = time.time() - start_time - minutes, seconds = divmod(elapsed_time, 60) - print(f">> Command finished ({int(minutes)}m {int(seconds)}s): {cmd} \n") + + elapsed_time = time.perf_counter() - start_time + h, rem = divmod(elapsed_time, 3600) + m, s = divmod(rem, 60) + + time_str = " ".join(part for part in [ + f"{int(h)}h" if h >= 1 else "", + f"{int(m)}m" if m >= 1 or h >= 1 else "", + f"{int(s)}s" + ] if part) + + print(f">> Command finished ({time_str}): {cmd} \n") # TODO: Should we exit here? Or continue to let other commands run also? if result.returncode != 0: From ca8492f3ff18e503ea1fe0620e0018a46950ebf0 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Mon, 16 Jun 2025 20:24:10 +0200 Subject: [PATCH 204/279] remove todo, which does not improve code --- arcade/gui/widgets/slider.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index b17847acd0..12e632b648 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -115,8 +115,6 @@ def _apply_step(self, value: float): return value def _set_value(self, value: float): - # TODO changing the value itself should trigger `on_change` event - # current problem is, that the property does not pass the old value to listeners if value < self.min_value: value = self.min_value elif value > self.max_value: From 63151819d5a2dbdd6067ce45f13c9e8c2cd0225b Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Mon, 16 Jun 2025 20:32:43 +0200 Subject: [PATCH 205/279] sort import --- arcade/gui/property.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/gui/property.py b/arcade/gui/property.py index b96710ac46..2bcd5496fc 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -2,7 +2,7 @@ import sys import traceback from collections.abc import Callable -from contextlib import suppress, contextmanager +from contextlib import contextmanager, suppress from typing import Any, Generic, TypeVar, cast from weakref import WeakKeyDictionary, ref From 2250b5509f46bbcf805d77911f7c9a0f3b81169e Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Wed, 18 Jun 2025 00:12:50 +0200 Subject: [PATCH 206/279] expose label visible in text --- arcade/text.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/arcade/text.py b/arcade/text.py index 3ceec48bfb..803219352d 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -633,6 +633,24 @@ def multiline(self) -> bool: def multiline(self, multiline: bool): self.label.multiline = multiline + @property + def visible(self) -> bool: + """ + Whether the text is visible or not. + + This is a property of the underlying pyglet.Label. + """ + return self.label.visible + + @visible.setter + def visible(self, visible: bool): + """ + Set the visibility of the text. + + This is a property of the underlying pyglet.Label. + """ + self.label.visible = visible + def draw(self) -> None: """ Draw the label to the screen at its current ``x`` and ``y`` position. From 94df796d0874970c07417f10c6cf00a4454b7aee Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Mon, 23 Jun 2025 21:03:15 +0200 Subject: [PATCH 207/279] NinePatch should rebuild after atlas resize/rebuild (#2736) * NinePatch should rebuild after atlas resize/rebuild * test atlas version * Update CHANGELOG --- CHANGELOG.md | 8 ++++++++ arcade/gui/nine_patch.py | 14 +++++++++----- arcade/texture_atlas/atlas_default.py | 4 ++++ arcade/texture_atlas/base.py | 12 ++++++++++++ tests/unit/atlas/test_basics.py | 5 +++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e962c3622..8bb1487c5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. +## 3.3.1 + +- Fixed an issue causing NinePatch to not render correctly +- TextureAtlas now as a `version` attribute that is incremented when the + atlas is resized or rebuilt. This way it's easy to track when texture coordinates + has changed. +- Added `Text.visible` (bool) property to control the visibility of text objects. + ## 3.3.0 - Fixed an issue causing a crash when closing the window diff --git a/arcade/gui/nine_patch.py b/arcade/gui/nine_patch.py index 424ac3fa98..7fd4a96b68 100644 --- a/arcade/gui/nine_patch.py +++ b/arcade/gui/nine_patch.py @@ -78,7 +78,7 @@ def __init__( self._initialized = False self._texture = texture self._custom_atlas = atlas - self._geometry_cache: tuple[int, int, int, int, Rect] | None = None + self._geometry_cache: tuple[int, int, int, int, int, Rect] | None = None # pixel texture co-ordinate start and end of central box. self._left = left @@ -325,16 +325,20 @@ def _init_deferred(self): # References for the texture self._atlas = self._custom_atlas or self._ctx.default_atlas self._add_to_atlas(self.texture) - - # NOTE: Important to create geometry after the texture is added to the atlas - # self._create_geometry(LBWH(0, 0, self.width, self.height)) self._initialized = True def _create_geometry(self, rect: Rect): """Create vertices for the 9-patch texture.""" # NOTE: This was ported from glsl geometry shader to python # Simulate old uniforms - cache_key = (self._left, self._right, self._bottom, self._top, rect) + cache_key = ( + self._atlas.version, + self._left, + self._right, + self._bottom, + self._top, + rect, + ) if cache_key == self._geometry_cache: return self._geometry_cache = cache_key diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index 52ec23823e..e804083231 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -112,6 +112,7 @@ def __init__( self._ctx = ctx or get_window().ctx self._max_size = self._ctx.info.MAX_VIEWPORT_DIMS self._size: tuple[int, int] = size + self._version = 0 self._allocator = Allocator(*self._size) self._auto_resize = auto_resize self._capacity = capacity @@ -736,6 +737,7 @@ def resize(self, size: tuple[int, int], force=False) -> None: vertices=UV_TEXTURE_WIDTH * self._capacity * 6, ) + self._version += 1 # duration = time.perf_counter() - resize_start # LOG.info("[%s] Atlas resize took %s seconds", id(self), duration) @@ -769,6 +771,8 @@ def rebuild(self) -> None: for texture in sorted(textures, key=lambda x: x.image.size[1]): self._add(texture, create_finalizer=False) + self._version += 1 + def use_uv_texture(self, unit: int = 0) -> None: """ Bind the texture coordinate texture to a channel. diff --git a/arcade/texture_atlas/base.py b/arcade/texture_atlas/base.py index 1a089f83da..1ec5142b5f 100644 --- a/arcade/texture_atlas/base.py +++ b/arcade/texture_atlas/base.py @@ -64,6 +64,7 @@ def __init__(self, ctx: ArcadeContext | None): self._ctx = ctx or arcade.get_window().ctx self._size: tuple[int, int] = 0, 0 self._layers: int = 1 + self._version = 0 @property def ctx(self) -> ArcadeContext: @@ -85,6 +86,17 @@ def texture(self) -> Texture2D: """The OpenGL texture for this atlas.""" return self._texture + @property + def version(self) -> int: + """ + The version of the atlas. + + This is incremented every time the atlas is rebuilt or resized. + It can be used to check if the atlas has changed since last + time it was used. + """ + return self._version + @property def width(self) -> int: """Hight of the atlas in pixels.""" diff --git a/tests/unit/atlas/test_basics.py b/tests/unit/atlas/test_basics.py index 018ab9e09b..7cfcf0bea8 100644 --- a/tests/unit/atlas/test_basics.py +++ b/tests/unit/atlas/test_basics.py @@ -137,7 +137,12 @@ def buf_check(atlas): assert len(atlas._texture_uvs._data.tobytes()) == len(atlas._texture_uvs.texture.read()) buf_check(atlas) + version = atlas.version atlas.resize((200, 200)) + assert atlas.version != version buf_check(atlas) + + version = atlas.version atlas.rebuild() + assert atlas.version != version buf_check(atlas) From b4c2757b3b6bddd10527c3cb22c23e5410f0a390 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Mon, 23 Jun 2025 21:13:53 +0200 Subject: [PATCH 208/279] ruff format --- make.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/make.py b/make.py index e6ae8a354e..e98ff13517 100755 --- a/make.py +++ b/make.py @@ -151,16 +151,20 @@ def run(args: str | list[str], cd: PathLike | None = None) -> None: result = subprocess.run(args) else: result = subprocess.run(args) - + elapsed_time = time.perf_counter() - start_time h, rem = divmod(elapsed_time, 3600) m, s = divmod(rem, 60) - - time_str = " ".join(part for part in [ - f"{int(h)}h" if h >= 1 else "", - f"{int(m)}m" if m >= 1 or h >= 1 else "", - f"{int(s)}s" - ] if part) + + time_str = " ".join( + part + for part in [ + f"{int(h)}h" if h >= 1 else "", + f"{int(m)}m" if m >= 1 or h >= 1 else "", + f"{int(s)}s", + ] + if part + ) print(f">> Command finished ({time_str}): {cmd} \n") From c68bac66f863d0f85b58b0c1e1229567f69cd679 Mon Sep 17 00:00:00 2001 From: Einar Forselv Date: Mon, 23 Jun 2025 21:31:06 +0200 Subject: [PATCH 209/279] Don't draw lines and point with zero elements (#2737) --- CHANGELOG.md | 2 ++ arcade/draw/line.py | 2 ++ arcade/draw/point.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb1487c5a..4b4eb477a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. atlas is resized or rebuilt. This way it's easy to track when texture coordinates has changed. - Added `Text.visible` (bool) property to control the visibility of text objects. +- Fixed an issue causing points and lines to draw random primitives when + passing in an empty list. ## 3.3.0 diff --git a/arcade/draw/line.py b/arcade/draw/line.py index daf5c244be..07e3ed5452 100644 --- a/arcade/draw/line.py +++ b/arcade/draw/line.py @@ -112,6 +112,8 @@ def draw_lines(point_list: Point2List, color: RGBOrA255, line_width: float = 1) line_pos_array = array.array("f", (v for point in point_list for v in point)) num_points = len(point_list) + if num_points == 0: + return # Grow buffer until large enough to hold all our data goal_buffer_size = num_points * 3 * 4 diff --git a/arcade/draw/point.py b/arcade/draw/point.py index 9898f66527..d5312e3f02 100644 --- a/arcade/draw/point.py +++ b/arcade/draw/point.py @@ -64,6 +64,8 @@ def draw_points(point_list: Point2List, color: RGBOrA255, size: float = 1.0) -> # Get # of points and translate Python tuples to a C-style array num_points = len(point_list) + if num_points == 0: + return point_array = array.array("f", (v for point in point_list for v in point)) # Resize buffer From 355016f81d10cb11813e703440101aa4c67f8237 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:52:01 -0400 Subject: [PATCH 210/279] Fix line highlights in sprite_move_animation.rst (#2738) --- doc/example_code/sprite_move_animation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/example_code/sprite_move_animation.rst b/doc/example_code/sprite_move_animation.rst index aff7165ddb..0c40f225c6 100644 --- a/doc/example_code/sprite_move_animation.rst +++ b/doc/example_code/sprite_move_animation.rst @@ -13,4 +13,4 @@ Move with a Sprite Animation .. literalinclude:: ../../arcade/examples/sprite_move_animation.py :caption: sprite_move_animation.py :linenos: - :emphasize-lines: 22-28, 31-38, 47-76, 78-97, 184-185 + :emphasize-lines: 22-28, 31-39, 48-67, 77-106 From 677015f4db3556981960dda1e5c324a58114e24a Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Tue, 24 Jun 2025 01:29:10 -0400 Subject: [PATCH 211/279] Add a line of highlight to sprite_move_animation.rst (#2739) --- doc/example_code/sprite_move_animation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/example_code/sprite_move_animation.rst b/doc/example_code/sprite_move_animation.rst index 0c40f225c6..091f1bc442 100644 --- a/doc/example_code/sprite_move_animation.rst +++ b/doc/example_code/sprite_move_animation.rst @@ -13,4 +13,4 @@ Move with a Sprite Animation .. literalinclude:: ../../arcade/examples/sprite_move_animation.py :caption: sprite_move_animation.py :linenos: - :emphasize-lines: 22-28, 31-39, 48-67, 77-106 + :emphasize-lines: 22-28, 31-39, 48-67, 77-106, 178-179 From 2bf15638dcbed0817bee09bf41c87c65e612f667 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 24 Jun 2025 22:50:59 +0200 Subject: [PATCH 212/279] workaround for non deactivated caret caused by consumed mouse events --- arcade/gui/property.py | 6 +++-- arcade/gui/ui_manager.py | 19 +++++++++++++++ arcade/gui/widgets/__init__.py | 27 ++++++++++++++++++++- arcade/gui/widgets/text.py | 43 ++++++++++++++++++++++++--------- tests/unit/gui/test_property.py | 14 +++++++++++ 5 files changed, 95 insertions(+), 14 deletions(-) diff --git a/arcade/gui/property.py b/arcade/gui/property.py index 2bcd5496fc..e8ad3e8914 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -230,8 +230,10 @@ class MyObject: """ t = type(instance) prop = getattr(t, property) - if isinstance(prop, Property): - prop.bind(instance, callback) + if not isinstance(prop, Property): + raise ValueError(f"{t.__name__}.{property} is not an arcade.gui.Property") + + prop.bind(instance, callback) def unbind(instance, property: str, callback): diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 59be4e19ad..2fcc29f1e3 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -99,6 +99,12 @@ def on_draw(): """Experimental feature to pixelate the UI, all textures will be rendered pixelated, which will mostly influence scaled background images. This property has to be set right after the UIManager is created.""" + _active_widget: UIWidget | None = None + """The currently active widget. Any widget, which consumes mouse press or release events + should set itself as active widget. + UIManager ensures that only one widget can be active at a time, + which can be used by widgets like text fields to detect when they are disabled, + without relying on unconsumed mouse press or release events.""" DEFAULT_LAYER = 0 OVERLAY_LAYER = 10 @@ -518,6 +524,19 @@ def rect(self) -> Rect: """The rect of the UIManager, which is the window size.""" return LBWH(0, 0, *self.window.get_size()) + def _set_active_widget(self, widget: UIWidget | None): + if self._active_widget == widget: + return + + if self._active_widget: + print(f"Deactivating widget {self._active_widget.__class__.__name__}") + self._active_widget._active = False + + self._active_widget = widget + if self._active_widget: + print(f"Activating widget {self._active_widget.__class__.__name__}") + self._active_widget._active = True + def debug(self): """Walks through all widgets of a UIManager and prints out layout information.""" for index, layer in self.children.items(): diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index dfe53fdb85..c2b3f584f2 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -94,6 +94,8 @@ class UIWidget(EventDispatcher, ABC): This is not part of the public API and subject to change. UILabel have a strong background if set. """ + _active = Property[bool](False) + """If True, the widget is active""" def __init__( self, @@ -167,6 +169,27 @@ def add(self, child: W, **kwargs) -> W: return child + # TODO "focus" would be more intuative but clashes with the UIFocusGroups :/ + # maybe the two systems should be merged? + def _grap_active(self): + """Sets itself as the single active widget in the UIManager.""" + from arcade.gui.ui_manager import UIManager + + ui_manager: UIManager | None = self.get_ui_manager() + if ui_manager: + ui_manager._set_active_widget(self) + + def _release_active(self): + """Make this widget inactive in the UIManager.""" + from arcade.gui.ui_manager import UIManager + + if not self._active: + return + + ui_manager: UIManager | None = self.get_ui_manager() + if ui_manager and ui_manager._active_widget is self: + ui_manager._set_active_widget(None) + def remove(self, child: UIWidget) -> dict | None: """Removes a child from the UIManager which was directly added to it. This will not remove widgets which are added to a child of UIManager. @@ -694,6 +717,7 @@ def on_event(self, event: UIEvent) -> bool | None: and event.button in self.interaction_buttons ): self.pressed = True + self._grap_active() # make this the active widget return EVENT_HANDLED if ( @@ -705,6 +729,7 @@ def on_event(self, event: UIEvent) -> bool | None: if self.rect.point_in_rect(event.pos): if not self.disabled: # Dispatch new on_click event, source is this widget itself + self._grap_active() # make this the active widget self.dispatch_event( "on_click", UIOnClickEvent( @@ -715,7 +740,7 @@ def on_event(self, event: UIEvent) -> bool | None: modifiers=event.modifiers, ), ) - return EVENT_HANDLED + return EVENT_HANDLED # TODO should we return the result from on_click? return EVENT_UNHANDLED diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index fd8d877d78..3d3ae5b74a 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -16,6 +16,7 @@ UIMouseDragEvent, UIMouseEvent, UIMousePressEvent, + UIMouseReleaseEvent, UIMouseScrollEvent, UIOnChangeEvent, UIOnClickEvent, @@ -544,7 +545,6 @@ def __init__( **kwargs, ) - self._active = False self._text_color = Color.from_iterable(text_color) self.doc: AbstractDocument = pyglet.text.decode_text(text) @@ -574,10 +574,16 @@ def __init__( bind(self, "pressed", self._apply_style) bind(self, "invalid", self._apply_style) bind(self, "disabled", self._apply_style) + bind(self, "_active", self._on_active_changed) # initial style application self._apply_style() + def _on_active_changed(self): + """Handle the active state change of the input text field to care about loosing active state.""" + if not self._active: + self.deactivate() + def _apply_style(self): style = self.get_current_style() @@ -630,12 +636,25 @@ def on_event(self, event: UIEvent) -> bool | None: Text input is only active when the user clicks on the input field.""" # If active check to deactivate - if self._active and isinstance(event, UIMousePressEvent): - if self.rect.point_in_rect(event.pos): - x = int(event.x - self.left - self.LAYOUT_OFFSET) - y = int(event.y - self.bottom) - self.caret.on_mouse_press(x, y, event.button, event.modifiers) - else: + if self._active and isinstance(event, UIMouseEvent): + event_in_rect = self.rect.point_in_rect(event.pos) + + # mouse press + if isinstance(event, UIMousePressEvent): + # inside the input field + if event_in_rect: + x = int(event.x - self.left - self.LAYOUT_OFFSET) + y = int(event.y - self.bottom) + self.caret.on_mouse_press(x, y, event.button, event.modifiers) + else: + # outside the input field + self.deactivate() + # return unhandled to allow other widgets to activate + return EVENT_UNHANDLED + + # mouse release outside the input field, + # which could be a click on another widget, which handles the press event + if isinstance(event, UIMouseReleaseEvent) and not event_in_rect: self.deactivate() # return unhandled to allow other widgets to activate return EVENT_UNHANDLED @@ -683,7 +702,7 @@ def activate(self): if self._active: return - self._active = True + self._grap_active() # will set _active to True self.trigger_full_render() self.caret.on_activate() self.caret.position = len(self.doc.text) @@ -691,10 +710,12 @@ def activate(self): def deactivate(self): """Programmatically deactivate the text input field.""" - if not self._active: - return + if self._active: + print("Release active text input field") + self._release_active() # will set _active to False + else: + print("Text input field is not active, cannot deactivate") - self._active = False self.trigger_full_render() self.caret.on_deactivate() diff --git a/tests/unit/gui/test_property.py b/tests/unit/gui/test_property.py index 9c783e06a1..b3d25b57c6 100644 --- a/tests/unit/gui/test_property.py +++ b/tests/unit/gui/test_property.py @@ -1,5 +1,7 @@ import gc +import pytest + from arcade.gui.property import Property, bind, unbind @@ -241,3 +243,15 @@ def callback(*args, **kwargs): del callback assert len(MyObject.name.obs[obj]._listeners) == 1 + + +def test_bind_raise_if_attr_not_a_ui_property(): + class BadObject: + @property + def name(self): + return + + obj = BadObject() + + with pytest.raises(ValueError): + bind(obj, "name", lambda *args: None) From 0f2cbe30bc377af37d7bbe12e18e7204d1fb5f2a Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 24 Jun 2025 22:58:48 +0200 Subject: [PATCH 213/279] Update changelog --- CHANGELOG.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4eb477a1..9d4d936378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Added `Text.visible` (bool) property to control the visibility of text objects. - Fixed an issue causing points and lines to draw random primitives when passing in an empty list. +- GUI + - Fix caret did not deactivate because of consumed mouse events. [2725](https://github.com/pythonarcade/arcade/issues/2725) + - Property listener can now receive: + - no args + - instance + - instance, value + - instance, value, old value + > Listener accepting `*args` receive `instance, value` like in previous versions. ## 3.3.0 @@ -36,12 +44,6 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Added property setters for `center_x` and `center_y` - Added property setters for `left`, `right`, `top`, and `bottom` - Users can now set widget position and size more intuitively without needing to access the `rect` property - - Property listener can now receive: - - no args - - instance - - instance, value - - instance, value, old value - > Listener accepting `*args` receive `instance, value` like in previous versions. - Rendering: - The `arcade.gl` package was restructured to be more modular in preparation for From 4ca85d99dec067c16b9236cfd051c060d8d858d1 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 24 Jun 2025 23:01:22 +0200 Subject: [PATCH 214/279] Cleanup --- arcade/gui/widgets/__init__.py | 6 +----- arcade/gui/widgets/text.py | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index c2b3f584f2..39dae93160 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -173,16 +173,12 @@ def add(self, child: W, **kwargs) -> W: # maybe the two systems should be merged? def _grap_active(self): """Sets itself as the single active widget in the UIManager.""" - from arcade.gui.ui_manager import UIManager - ui_manager: UIManager | None = self.get_ui_manager() if ui_manager: ui_manager._set_active_widget(self) def _release_active(self): """Make this widget inactive in the UIManager.""" - from arcade.gui.ui_manager import UIManager - if not self._active: return @@ -740,7 +736,7 @@ def on_event(self, event: UIEvent) -> bool | None: modifiers=event.modifiers, ), ) - return EVENT_HANDLED # TODO should we return the result from on_click? + return EVENT_HANDLED # TODO should we return the result from on_click? return EVENT_UNHANDLED diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 3d3ae5b74a..3123669d58 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -580,7 +580,8 @@ def __init__( self._apply_style() def _on_active_changed(self): - """Handle the active state change of the input text field to care about loosing active state.""" + """Handle the active state change of the input + text field to care about loosing active state.""" if not self._active: self.deactivate() From 1ef0a604f62d498a097044ae871145329f8bcd1e Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Wed, 25 Jun 2025 09:11:42 -0500 Subject: [PATCH 215/279] Update version (#2741) Co-authored-by: Paul V Craven --- arcade/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/VERSION b/arcade/VERSION index 0fa4ae4890..712bd5a680 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.3.0 \ No newline at end of file +3.3.1 \ No newline at end of file From d04f102896697a79718fe28de556ed99664f757e Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 27 Jun 2025 11:44:49 +0200 Subject: [PATCH 216/279] Fix bind to non existing property --- arcade/gui/experimental/scroll_area.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index fbe715cd0e..e8818976c1 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -48,8 +48,7 @@ def __init__(self, scroll_area: UIScrollArea, vertical: bool = True): bind(self, "_dragging", self.trigger_render) bind(scroll_area, "scroll_x", self.trigger_full_render) bind(scroll_area, "scroll_y", self.trigger_full_render) - bind(scroll_area, "content_height", self.trigger_full_render) - bind(scroll_area, "content_width", self.trigger_full_render) + bind(scroll_area, "rect", self.trigger_full_render) def on_event(self, event: UIEvent) -> bool | None: # check if we are scrollable From 7fd36a8f142cf1e26b46dfd2cb6942d07489a53d Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Fri, 27 Jun 2025 11:46:22 +0200 Subject: [PATCH 217/279] Update Changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d4d936378..f93e464507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. +## Unreleased + +- GUI + - Fix UIScrollBar creation + + ## 3.3.1 - Fixed an issue causing NinePatch to not render correctly From 9d720e5fe43860b2a3c4220a914ca96225aed430 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 1 Jul 2025 15:27:29 +0200 Subject: [PATCH 218/279] remove debug output --- arcade/examples/gui/6_size_hints.py | 1 - 1 file changed, 1 deletion(-) diff --git a/arcade/examples/gui/6_size_hints.py b/arcade/examples/gui/6_size_hints.py index 8801eb8470..2d3959a0c3 100644 --- a/arcade/examples/gui/6_size_hints.py +++ b/arcade/examples/gui/6_size_hints.py @@ -135,7 +135,6 @@ def on_change(event: UIOnChangeEvent): content_anchor.add(UISpace(height=20)) self.ui.execute_layout() - self.ui.debug() def main(): From ec4abf4dcc8de4de4da19d67a4430ed6f1f03252 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 1 Jul 2025 15:47:04 +0200 Subject: [PATCH 219/279] remove debug output in UIManager --- arcade/gui/ui_manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 2fcc29f1e3..3a5707d47d 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -529,12 +529,10 @@ def _set_active_widget(self, widget: UIWidget | None): return if self._active_widget: - print(f"Deactivating widget {self._active_widget.__class__.__name__}") self._active_widget._active = False self._active_widget = widget if self._active_widget: - print(f"Activating widget {self._active_widget.__class__.__name__}") self._active_widget._active = True def debug(self): From 9afc0ee44158aa825a3b354f056226054554ba6d Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 1 Jul 2025 15:47:43 +0200 Subject: [PATCH 220/279] Bind class methods within a widget to avoid memory leak --- arcade/gui/experimental/focus.py | 6 +- arcade/gui/experimental/scroll_area.py | 14 +-- arcade/gui/property.py | 89 ++++++++++++------- arcade/gui/widgets/__init__.py | 86 ++++++++++++++---- arcade/gui/widgets/buttons.py | 2 +- arcade/gui/widgets/image.py | 6 +- arcade/gui/widgets/layout.py | 54 +++++------ arcade/gui/widgets/slider.py | 10 +-- arcade/gui/widgets/text.py | 24 ++--- arcade/gui/widgets/toggle.py | 4 +- tests/unit/gui/test_layouting_anchorlayout.py | 7 +- tests/unit/gui/test_layouting_boxlayout.py | 50 +++++++---- tests/unit/gui/test_property.py | 38 ++++++++ tests/unit/gui/test_uilabel.py | 6 +- tests/unit/gui/test_widget_tree.py | 50 ++++++++++- 15 files changed, 313 insertions(+), 133 deletions(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index e806150120..a41cd158d5 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -71,9 +71,9 @@ class UIFocusMixin(UIWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - bind(self, "_debug", self.trigger_full_render) - bind(self, "_focused_widget", self.trigger_full_render) - bind(self, "_focusable_widgets", self.trigger_full_render) + bind(self, "_debug", UIFocusMixin.trigger_full_render) + bind(self, "_focused_widget", UIFocusMixin.trigger_full_render) + bind(self, "_focusable_widgets", UIFocusMixin.trigger_full_render) def on_event(self, event: UIEvent) -> bool | None: # pass events to children first, including controller events diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index e8818976c1..5b8ad10617 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -44,11 +44,11 @@ def __init__(self, scroll_area: UIScrollArea, vertical: bool = True): self.with_border(color=arcade.uicolor.GRAY_CONCRETE) self.vertical = vertical - bind(self, "_thumb_hover", self.trigger_render) - bind(self, "_dragging", self.trigger_render) - bind(scroll_area, "scroll_x", self.trigger_full_render) - bind(scroll_area, "scroll_y", self.trigger_full_render) - bind(scroll_area, "rect", self.trigger_full_render) + bind(self, "_thumb_hover", UIScrollBar.trigger_render) + bind(self, "_dragging", UIScrollBar.trigger_render) + bind(scroll_area, "scroll_x", UIScrollBar.trigger_full_render) + bind(scroll_area, "scroll_y", UIScrollBar.trigger_full_render) + bind(scroll_area, "rect", UIScrollBar.trigger_full_render) def on_event(self, event: UIEvent) -> bool | None: # check if we are scrollable @@ -234,8 +234,8 @@ def __init__( size=canvas_size, ) - bind(self, "scroll_x", self.trigger_full_render) - bind(self, "scroll_y", self.trigger_full_render) + bind(self, "scroll_x", UIScrollArea.trigger_full_render) + bind(self, "scroll_y", UIScrollArea.trigger_full_render) def add(self, child: W, **kwargs) -> W: """Add a child to the widget.""" diff --git a/arcade/gui/property.py b/arcade/gui/property.py index e8ad3e8914..400536f9b8 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -3,6 +3,7 @@ import traceback from collections.abc import Callable from contextlib import contextmanager, suppress +from enum import Enum from typing import Any, Generic, TypeVar, cast from weakref import WeakKeyDictionary, ref @@ -18,6 +19,41 @@ AnyListener = NoArgListener | InstanceListener | InstanceValueListener | InstanceNewOldListener +class _ListenerType(Enum): + """Enum to represent the type of listener""" + + NO_ARG = 0 + INSTANCE = 1 + INSTANCE_VALUE = 2 + INSTANCE_NEW_OLD = 3 + + @staticmethod + def detect_callback_type(callback: AnyListener) -> "_ListenerType": + """Normalizes the callback so every callback can be invoked with the same signature.""" + signature = inspect.signature(callback) + + # first detect the old *args default listener signatures + with suppress(TypeError): + signature.bind(..., ...) + return _ListenerType.INSTANCE_VALUE + + # check for the most common signature + with suppress(TypeError): + signature.bind() + return _ListenerType.NO_ARG + + # check for the other + with suppress(TypeError): + signature.bind(..., ..., ...) + return _ListenerType.INSTANCE_NEW_OLD + + with suppress(TypeError): + signature.bind(...) + return _ListenerType.INSTANCE + + raise TypeError("Callback is not callable") + + class _Obs(Generic[P]): """ Internal holder for Property value and change listeners @@ -29,14 +65,14 @@ def __init__(self, value: P): self.value = value # This will keep any added listener even if it is not referenced anymore # and would be garbage collected - self._listeners: dict[AnyListener, InstanceNewOldListener] = dict() + self._listeners: dict[AnyListener, _ListenerType] = dict() def add( self, callback: AnyListener, ): """Add a callback to the list of listeners""" - self._listeners[callback] = _Obs._normalize_callback(callback) + self._listeners[callback] = _ListenerType.detect_callback_type(callback) def remove(self, callback): """Remove a callback from the list of listeners""" @@ -44,31 +80,9 @@ def remove(self, callback): del self._listeners[callback] @property - def listeners(self) -> list[InstanceNewOldListener]: - return list(self._listeners.values()) - - @staticmethod - def _normalize_callback(callback) -> InstanceNewOldListener: - """Normalizes the callback so every callback can be invoked with the same signature.""" - signature = inspect.signature(callback) - - with suppress(TypeError): - signature.bind(1, 1) - return lambda instance, new, old: callback(instance, new) - - with suppress(TypeError): - signature.bind(1, 1, 1) - return lambda instance, new, old: callback(instance, new, old) - - with suppress(TypeError): - signature.bind(1) - return lambda instance, new, old: callback(instance) - - with suppress(TypeError): - signature.bind() - return lambda instance, new, old: callback() - - raise TypeError("Callback is not callable") + def listeners(self) -> list[tuple[AnyListener, _ListenerType]]: + """Returns a list of all listeners and type, both weak and strong.""" + return list(self._listeners.items()) class Property(Generic[P]): @@ -147,9 +161,16 @@ def dispatch(self, instance, value, old_value): """ obs = self._get_obs(instance) - for listener in obs.listeners: + for listener, _listener_type in obs.listeners: try: - listener(instance, value, old_value) + if _listener_type == _ListenerType.NO_ARG: + listener() # type: ignore[call-arg] + elif _listener_type == _ListenerType.INSTANCE: + listener(instance) # type: ignore[call-arg] + elif _listener_type == _ListenerType.INSTANCE_VALUE: + listener(instance, value) # type: ignore[call-arg] + elif _listener_type == _ListenerType.INSTANCE_NEW_OLD: + listener(instance, value, old_value) # type: ignore[call-arg] except Exception: print( f"Change listener for {instance}.{self.name} = {value} raised an exception!", @@ -157,7 +178,7 @@ def dispatch(self, instance, value, old_value): ) traceback.print_exc() - def bind(self, instance, callback): + def bind(self, instance: Any, callback: AnyListener): """Binds a function to the change event of the property. A reference to the function will be kept. @@ -200,7 +221,7 @@ def __set__(self, instance, value: P): self.set(instance, value) -def bind(instance, property: str, callback): +def bind(instance, property: str, callback: AnyListener): """Bind a function to the change event of the property. A reference to the function will be kept, so that it will be still @@ -220,6 +241,11 @@ class MyObject: my_obj.name = "Hans" # > Value of <__main__.MyObject ...> changed to Hans + Binding to a method of the Property owner itself can cause a memory leak, because the + owner is strongly referenced. Instead, bind the class method, which will be invoked with + the instance as first parameter. + + Args: instance: Instance owning the property property: Name of the property @@ -228,6 +254,7 @@ class MyObject: Returns: None """ + # TODO rename property to property_name for arcade 4.0 (just to be sure) t = type(instance) prop = getattr(t, property) if not isinstance(prop, Property): diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 39dae93160..3d752dd39b 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -1,10 +1,12 @@ from __future__ import annotations +import weakref from abc import ABC from collections.abc import Iterable from enum import IntEnum from types import EllipsisType -from typing import TYPE_CHECKING, NamedTuple, TypeVar +from typing import Any, Generic, TYPE_CHECKING, NamedTuple, TypeVar, overload +from weakref import WeakKeyDictionary from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher from pyglet.math import Vec2 @@ -31,6 +33,7 @@ from arcade.gui.ui_manager import UIManager W = TypeVar("W", bound="UIWidget") +P = TypeVar("P") class FocusMode(IntEnum): @@ -51,6 +54,51 @@ class _ChildEntry(NamedTuple): data: dict +class WeakRef(Generic[P]): + """A weak reference to a UIWidget parent, which is used to prevent memory leaks.""" + + __slots__ = ("name", "obs") + name: str + """Attribute name of the property""" + obs: WeakKeyDictionary[Any, weakref.ref[P]] + """Weak dictionary to hold the values""" + + def __init__(self): + self.obs = WeakKeyDictionary() + + def get(self, instance: Any) -> P | None: + """Get value for owner instance""" + # If the value is not set, return None + value = self.obs.get(instance) + return value() if value else None + + def set(self, instance, value: P | None): + """Set value for owner instance""" + # Store a weak reference to the value + if value is None: + self.obs.pop(instance, None) + else: + self.obs[instance] = weakref.ref(value) + + def __set_name__(self, owner, name): + self.name = name + + @overload + def __get__(self, instance: None, instance_type) -> Self: ... + + @overload + def __get__(self, instance: Any, instance_type) -> P | None: ... + + def __get__(self, instance: Any | None, instance_type) -> Self | P | None: + """Get the value for the owner instance, or None if not set.""" + if instance is None: + return self + return self.get(instance) + + def __set__(self, instance, value: P | None): + self.set(instance, value) + + @copy_dunders_unimplemented class UIWidget(EventDispatcher, ABC): """The :class:`UIWidget` class is the base class required for creating widgets. @@ -71,6 +119,9 @@ class UIWidget(EventDispatcher, ABC): size_hint_max: max width and height in pixel """ + parent: WeakRef[UIManager | UIWidget | None] = WeakRef() + """A weak reference to the parent UIManager or UIWidget, + which does not prevent garbage collection of the parent.""" rect = Property(LBWH(0, 0, 1, 1)) visible = Property(True) focused = Property(False) @@ -113,7 +164,6 @@ def __init__( ): self._requires_render = True self.rect = LBWH(x, y, width, height) - self.parent: UIManager | UIWidget | None = None # Size hints are properties that can be used by layouts self.size_hint = size_hint @@ -126,21 +176,21 @@ def __init__( for child in children: self.add(child) - bind(self, "rect", self.trigger_full_render) - bind(self, "focused", self.trigger_full_render) + bind(self, "rect", UIWidget.trigger_full_render) + bind(self, "focused", UIWidget.trigger_full_render) bind( - self, "visible", self.trigger_full_render + self, "visible", UIWidget.trigger_full_render ) # TODO maybe trigger_parent_render would be enough - bind(self, "_children", self.trigger_render) - bind(self, "_border_width", self.trigger_render) - bind(self, "_border_color", self.trigger_render) - bind(self, "_bg_color", self.trigger_render) - bind(self, "_bg_tex", self.trigger_render) - bind(self, "_padding_top", self.trigger_render) - bind(self, "_padding_right", self.trigger_render) - bind(self, "_padding_bottom", self.trigger_render) - bind(self, "_padding_left", self.trigger_render) - bind(self, "_strong_background", self.trigger_render) + bind(self, "_children", UIWidget.trigger_render) + bind(self, "_border_width", UIWidget.trigger_render) + bind(self, "_border_color", UIWidget.trigger_render) + bind(self, "_bg_color", UIWidget.trigger_render) + bind(self, "_bg_tex", UIWidget.trigger_render) + bind(self, "_padding_top", UIWidget.trigger_render) + bind(self, "_padding_right", UIWidget.trigger_render) + bind(self, "_padding_bottom", UIWidget.trigger_render) + bind(self, "_padding_left", UIWidget.trigger_render) + bind(self, "_strong_background", UIWidget.trigger_render) def add(self, child: W, **kwargs) -> W: """Add a widget as a child. @@ -692,9 +742,9 @@ def __init__( self.interaction_buttons = interaction_buttons - bind(self, "pressed", self.trigger_render) - bind(self, "hovered", self.trigger_render) - bind(self, "disabled", self.trigger_render) + bind(self, "pressed", UIInteractiveWidget.trigger_render) + bind(self, "hovered", UIInteractiveWidget.trigger_render) + bind(self, "disabled", UIInteractiveWidget.trigger_render) def on_event(self, event: UIEvent) -> bool | None: """Handles mouse events and triggers on_click event if the widget is clicked. diff --git a/arcade/gui/widgets/buttons.py b/arcade/gui/widgets/buttons.py index adc0919e74..df216326f5 100644 --- a/arcade/gui/widgets/buttons.py +++ b/arcade/gui/widgets/buttons.py @@ -134,7 +134,7 @@ def __init__( if texture_disabled: self._textures["disabled"] = texture_disabled - bind(self, "_textures", self.trigger_render) + bind(self, "_textures", UITextureButton.trigger_render) # prepare label with default style _style = self.get_current_style() diff --git a/arcade/gui/widgets/image.py b/arcade/gui/widgets/image.py index f732753168..149a327244 100644 --- a/arcade/gui/widgets/image.py +++ b/arcade/gui/widgets/image.py @@ -55,9 +55,9 @@ def __init__( height=height if height else texture.height, **kwargs, ) - bind(self, "texture", self.trigger_render) - bind(self, "alpha", self.trigger_full_render) - bind(self, "angle", self.trigger_full_render) + bind(self, "texture", UIImage.trigger_render) + bind(self, "alpha", UIImage.trigger_full_render) + bind(self, "angle", UIImage.trigger_full_render) @override def do_render(self, surface: Surface): diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index 386532d707..2c17cf29a9 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -270,13 +270,13 @@ def __init__( self._size_hint_requires_update = True - bind(self, "_children", self._update_size_hints) - bind(self, "_border_width", self._update_size_hints) + bind(self, "_children", UIBoxLayout._trigger_size_hint_update) + bind(self, "_border_width", UIBoxLayout._trigger_size_hint_update) - bind(self, "_padding_left", self._update_size_hints) - bind(self, "_padding_right", self._update_size_hints) - bind(self, "_padding_top", self._update_size_hints) - bind(self, "_padding_bottom", self._update_size_hints) + bind(self, "_padding_left", UIBoxLayout._trigger_size_hint_update) + bind(self, "_padding_right", UIBoxLayout._trigger_size_hint_update) + bind(self, "_padding_top", UIBoxLayout._trigger_size_hint_update) + bind(self, "_padding_bottom", UIBoxLayout._trigger_size_hint_update) self._update_size_hints() @@ -288,11 +288,11 @@ def add(self, child: W, **kwargs) -> W: child: The widget to add to the layout. """ # subscribe to child's changes, which might affect the own size hint - bind(child, "_children", self._trigger_size_hint_update) - bind(child, "rect", self._trigger_size_hint_update) - bind(child, "size_hint", self._trigger_size_hint_update) - bind(child, "size_hint_min", self._trigger_size_hint_update) - bind(child, "size_hint_max", self._trigger_size_hint_update) + bind(child, "_children", UIBoxLayout._trigger_size_hint_update) + bind(child, "rect", UIBoxLayout._trigger_size_hint_update) + bind(child, "size_hint", UIBoxLayout._trigger_size_hint_update) + bind(child, "size_hint_min", UIBoxLayout._trigger_size_hint_update) + bind(child, "size_hint_max", UIBoxLayout._trigger_size_hint_update) return super().add(child, **kwargs) @@ -300,11 +300,11 @@ def add(self, child: W, **kwargs) -> W: def remove(self, child: UIWidget): """Remove a child from the layout.""" # unsubscribe from child's changes - unbind(child, "_children", self._trigger_size_hint_update) - unbind(child, "rect", self._trigger_size_hint_update) - unbind(child, "size_hint", self._trigger_size_hint_update) - unbind(child, "size_hint_min", self._trigger_size_hint_update) - unbind(child, "size_hint_max", self._trigger_size_hint_update) + unbind(child, "_children", UIBoxLayout._trigger_size_hint_update) + unbind(child, "rect", UIBoxLayout._trigger_size_hint_update) + unbind(child, "size_hint", UIBoxLayout._trigger_size_hint_update) + unbind(child, "size_hint_min", UIBoxLayout._trigger_size_hint_update) + unbind(child, "size_hint_max", UIBoxLayout._trigger_size_hint_update) return super().remove(child) @@ -514,13 +514,13 @@ def __init__( self.align_horizontal = align_horizontal self.align_vertical = align_vertical - bind(self, "_children", self._trigger_size_hint_update) - bind(self, "_border_width", self._trigger_size_hint_update) + bind(self, "_children", UIGridLayout._trigger_size_hint_update) + bind(self, "_border_width", UIGridLayout._trigger_size_hint_update) - bind(self, "_padding_left", self._trigger_size_hint_update) - bind(self, "_padding_right", self._trigger_size_hint_update) - bind(self, "_padding_top", self._trigger_size_hint_update) - bind(self, "_padding_bottom", self._trigger_size_hint_update) + bind(self, "_padding_left", UIGridLayout._trigger_size_hint_update) + bind(self, "_padding_right", UIGridLayout._trigger_size_hint_update) + bind(self, "_padding_top", UIGridLayout._trigger_size_hint_update) + bind(self, "_padding_bottom", UIGridLayout._trigger_size_hint_update) # initially update size hints # TODO is this required? @@ -547,11 +547,11 @@ def add( row_span: Number of rows the widget will stretch for. """ # subscribe to child's changes, which might affect the own size hint - bind(child, "_children", self._trigger_size_hint_update) - bind(child, "rect", self._trigger_size_hint_update) - bind(child, "size_hint", self._trigger_size_hint_update) - bind(child, "size_hint_min", self._trigger_size_hint_update) - bind(child, "size_hint_max", self._trigger_size_hint_update) + bind(child, "_children", UIGridLayout._trigger_size_hint_update) + bind(child, "rect", UIGridLayout._trigger_size_hint_update) + bind(child, "size_hint", UIGridLayout._trigger_size_hint_update) + bind(child, "size_hint_min", UIGridLayout._trigger_size_hint_update) + bind(child, "size_hint_max", UIGridLayout._trigger_size_hint_update) return super().add( child, diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index 12e632b648..9d2ccb545b 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -91,11 +91,11 @@ def __init__( self._cursor_width = self.height // 3 # trigger render on value changes - bind(self, "value", self.trigger_full_render) - bind(self, "value", self._ensure_step) - bind(self, "hovered", self.trigger_render) - bind(self, "pressed", self.trigger_render) - bind(self, "disabled", self.trigger_render) + bind(self, "value", UIBaseSlider.trigger_full_render) + bind(self, "value", UIBaseSlider._ensure_step) + bind(self, "hovered", UIBaseSlider.trigger_render) + bind(self, "pressed", UIBaseSlider.trigger_render) + bind(self, "disabled", UIBaseSlider.trigger_render) self.register_event_type("on_change") diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 3123669d58..e4e8d6db8a 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -148,14 +148,14 @@ def __init__( if height: self._label.height = int(height) - bind(self, "rect", self._update_label) + bind(self, "rect", UILabel._update_label) # update size hint when border or padding changes - bind(self, "_border_width", self._update_size_hint_min) - bind(self, "_padding_left", self._update_size_hint_min) - bind(self, "_padding_right", self._update_size_hint_min) - bind(self, "_padding_top", self._update_size_hint_min) - bind(self, "_padding_bottom", self._update_size_hint_min) + bind(self, "_border_width", UILabel._update_size_hint_min) + bind(self, "_padding_left", UILabel._update_size_hint_min) + bind(self, "_padding_right", UILabel._update_size_hint_min) + bind(self, "_padding_top", UILabel._update_size_hint_min) + bind(self, "_padding_bottom", UILabel._update_size_hint_min) self._update_size_hint_min() @@ -570,11 +570,11 @@ def __init__( self.register_event_type("on_change") - bind(self, "hovered", self._apply_style) - bind(self, "pressed", self._apply_style) - bind(self, "invalid", self._apply_style) - bind(self, "disabled", self._apply_style) - bind(self, "_active", self._on_active_changed) + bind(self, "hovered", UIInputText._apply_style) + bind(self, "pressed", UIInputText._apply_style) + bind(self, "invalid", UIInputText._apply_style) + bind(self, "disabled", UIInputText._apply_style) + bind(self, "_active", UIInputText._on_active_changed) # initial style application self._apply_style() @@ -859,7 +859,7 @@ def __init__( multiline=multiline, ) - # bind(self, "rect", self._update_layout) + bind(self, "rect", self._update_layout) def fit_content(self): """Set the width and height of the text area to contain the whole text.""" diff --git a/arcade/gui/widgets/toggle.py b/arcade/gui/widgets/toggle.py index 5e1786efe2..bdc288d92a 100644 --- a/arcade/gui/widgets/toggle.py +++ b/arcade/gui/widgets/toggle.py @@ -82,8 +82,8 @@ def __init__( self.value = value self.register_event_type("on_change") - bind(self, "value", self.trigger_render) - bind(self, "value", self._dispatch_on_change_event) + bind(self, "value", UITextureToggle.trigger_render) + bind(self, "value", UITextureToggle._dispatch_on_change_event) super().__init__( x=x, diff --git a/tests/unit/gui/test_layouting_anchorlayout.py b/tests/unit/gui/test_layouting_anchorlayout.py index 4714f82123..0948dab833 100644 --- a/tests/unit/gui/test_layouting_anchorlayout.py +++ b/tests/unit/gui/test_layouting_anchorlayout.py @@ -51,8 +51,9 @@ def test_place_widget_relative_to_own_content_rect(window): assert dummy.top == 378 -def test_place_box_layout(window): - subject = UIAnchorLayout(width=500, height=500) +def test_place_box_layout(window, ui): + subject = UIAnchorLayout(width=500, height=500, size_hint=None) + ui.add(subject) box = UIBoxLayout() box.add(UIDummy(width=100, height=100)) @@ -60,7 +61,7 @@ def test_place_box_layout(window): subject.add(child=box, anchor_x="center_x", align_y=-20, anchor_y="top") - subject._do_layout() + ui.execute_layout() assert subject.rect == LBWH(0, 0, 500, 500) assert box.rect == LBWH(200, 280, 100, 200) diff --git a/tests/unit/gui/test_layouting_boxlayout.py b/tests/unit/gui/test_layouting_boxlayout.py index 377823fb31..20aadc3d24 100644 --- a/tests/unit/gui/test_layouting_boxlayout.py +++ b/tests/unit/gui/test_layouting_boxlayout.py @@ -26,8 +26,9 @@ def test_do_layout_vertical_with_initial_children(window): assert element_2.left == 100 -def test_do_layout_vertical_add_children(window): - group = UIBoxLayout(vertical=True) +def test_do_layout_vertical_add_children(window, ui): + group = UIBoxLayout(vertical=True, size_hint=None) + ui.add(group) element_1 = UIDummy() element_2 = UIDummy() @@ -36,7 +37,7 @@ def test_do_layout_vertical_add_children(window): group.add(element_2) group.rect = LBWH(100, 200, *group.size_hint_min) - group.do_layout() + ui.execute_layout() assert element_1.top == 400 assert element_1.bottom == 300 @@ -47,17 +48,18 @@ def test_do_layout_vertical_add_children(window): assert element_2.left == 100 -def test_do_layout_vertical_add_child_with_initial_children(window): +def test_do_layout_vertical_add_child_with_initial_children(window, ui): element_1 = UIDummy() element_2 = UIDummy() element_3 = UIDummy() - group = UIBoxLayout(vertical=True, children=[element_1, element_2]) + group = UIBoxLayout(vertical=True, children=[element_1, element_2], size_hint=None) + ui.add(group) group.add(element_3) group.rect = LBWH(100, 200, *group.size_hint_min) - group.do_layout() + ui.execute_layout() assert element_1.top == 500 assert element_1.bottom == 400 @@ -146,8 +148,9 @@ def test_do_layout_horizontal_with_initial_children(window): assert element_2.top == 300 -def test_do_layout_horizontal_add_children(window): - group = UIBoxLayout(vertical=False) +def test_do_layout_horizontal_add_children(window, ui): + group = UIBoxLayout(vertical=False, size_hint=None) + ui.add(group) element_1 = UIDummy() element_2 = UIDummy() @@ -156,7 +159,7 @@ def test_do_layout_horizontal_add_children(window): group.add(element_2) group.rect = LBWH(100, 200, *group.size_hint_min) - group.do_layout() + ui.execute_layout() assert element_1.left == 100 assert element_1.right == 200 @@ -191,16 +194,17 @@ def test_do_layout_horizontal_add_child_with_initial_children(window): assert element_3.top == 300 -def test_horizontal_group_keep_left_alignment_while_adding_children(window): +def test_horizontal_group_keep_left_alignment_while_adding_children(window, ui): element_1 = UIDummy() element_2 = UIDummy() element_3 = UIDummy() - group = UIBoxLayout(vertical=False, children=[element_1, element_2]) + group = UIBoxLayout(vertical=False, children=[element_1, element_2], size_hint=None) + ui.add(group) group.add(element_3) group.rect = LBWH(100, 200, *group.size_hint_min) - group.do_layout() + ui.execute_layout() assert group.left == 100 assert group.top == 300 @@ -259,31 +263,41 @@ def test_do_layout_horizontal_space_between(window): assert element_2.left == 210 -def test_size_hint_min_contains_children_vertically(window): - box = UIBoxLayout() +def test_size_hint_min_contains_children_vertically(window, ui): + box = UIBoxLayout(size_hint=None) + ui.add(box) box.add(UIDummy(width=100, height=100)) box.add(UIDummy(width=100, height=100)) + ui.execute_layout() + assert box.size_hint_min == (100, 200) -def test_size_hint_min_contains_children_horizontal(window): - box = UIBoxLayout(vertical=False) +def test_size_hint_min_contains_children_horizontal(window, ui): + box = UIBoxLayout(vertical=False, size_hint=None) + ui.add(box) + ui.add(box) box.add(UIDummy(width=100, height=100)) box.add(UIDummy(width=100, height=100)) + ui.execute_layout() + assert box.size_hint_min == (200, 100) -def test_size_hint_contains_border_and_padding(window): - box = UIBoxLayout() +def test_size_hint_contains_border_and_padding(window, ui): + box = UIBoxLayout(size_hint=None) + ui.add(box) box.with_border(width=3) box.with_padding(top=10, right=20, bottom=30, left=40) box.add(UIDummy(width=100, height=100)) box.add(UIDummy(width=100, height=100)) + ui.execute_layout() + assert box.size_hint_min == (100 + 2 * 3 + 20 + 40, 200 + 2 * 3 + 10 + 30) diff --git a/tests/unit/gui/test_property.py b/tests/unit/gui/test_property.py index b3d25b57c6..8b95e37a44 100644 --- a/tests/unit/gui/test_property.py +++ b/tests/unit/gui/test_property.py @@ -213,6 +213,44 @@ def test_gc_entries_are_collected(): assert len(MyObject.name.obs) == 0 +def test_obj_collected_when_using_class_method(): + class ObserverAndObject(Observer, MyObject): + pass + + obj = ObserverAndObject() + bind(obj, "name", ObserverAndObject.call) + + # Keeps referenced objects + gc.collect() + assert len(MyObject.name.obs) == 1 + + # delete ref and trigger gc + del obj + gc.collect() + + # No leftovers + assert len(MyObject.name.obs) == 0 + + +def test_gc_bound_methods_strongly_referenced(): + class ObserverAndObject(Observer, MyObject): + pass + + obj = ObserverAndObject() + bind(obj, "name", obj.call) + + # Keeps referenced objects + gc.collect() + assert len(ObserverAndObject.name.obs) == 1 + + # delete ref and trigger gc + del obj + gc.collect() + + # No leftovers + assert len(ObserverAndObject.name.obs) == 1 + + def test_gc_keeps_bound_methods(): observer = Observer() obj = MyObject() diff --git a/tests/unit/gui/test_uilabel.py b/tests/unit/gui/test_uilabel.py index a78828d166..7413deb1c9 100644 --- a/tests/unit/gui/test_uilabel.py +++ b/tests/unit/gui/test_uilabel.py @@ -79,9 +79,10 @@ def test_change_text_triggers_full_render_without_background(window): should fit the size to the text. This is not natively supported by either arcade.Text or pyglet.Label. Because text length variates between different os, we can only test boundaries, which indicate a proper implementation. """ + mock = Mock() label = UILabel(text="First Text") - label.parent = Mock() + label.parent = mock label.text = "Second Text" label.parent.trigger_render.assert_called_once() @@ -93,9 +94,10 @@ def test_change_text_triggers_render_with_background(window): should fit the size to the text. This is not natively supported by either arcade.Text or pyglet.Label. Because text length variates between different os, we can only test boundaries, which indicate a proper implementation. """ + mock = Mock() label = UILabel(text="First Text").with_background(color=Color(255, 255, 255, 255)) - label.parent = Mock() + label.parent = mock label.text = "Second Text" label.parent.trigger_render.assert_not_called() diff --git a/tests/unit/gui/test_widget_tree.py b/tests/unit/gui/test_widget_tree.py index 13b39fe78f..fee72f39c8 100644 --- a/tests/unit/gui/test_widget_tree.py +++ b/tests/unit/gui/test_widget_tree.py @@ -1,4 +1,6 @@ -from arcade.gui import UIEvent +import gc + +from arcade.gui import UIEvent, UIWidget from arcade.gui.widgets import UIDummy @@ -103,3 +105,49 @@ def test_iterate_widget_children(window): # THEN assert list(parent) == [child1, child2] + + +def test_chained_widgets_are_collected_by_gc(): + """ + Test that chained widgets are collected by garbage collector. + This is to ensure that there are no memory leaks when widgets are + added and removed in a chain. + """ + + def objs_in_memory(obj_type): + """Check if an object of a specific type is in memory.""" + return len([obj for obj in gc.get_objects() if isinstance(obj, obj_type)]) + + gc.collect() + start_count = objs_in_memory(UIWidget) + + root = UIWidget() + root.add(UIWidget()) + + # children are not collected until the parent is deleted + gc.collect() + assert objs_in_memory(UIWidget) == start_count + 2 + + del root + gc.collect() + + if objs_in_memory(UIWidget) > start_count: + print("Render object graph...") + import objgraph + + objgraph.show_chain( + objgraph.find_backref_chain( + [obj for obj in gc.get_objects() if isinstance(obj, UIWidget)][1], + objgraph.is_proper_module, + ), + # filename="chain.png", + ) + + # print("Render backrefs...") + # objgraph.show_backrefs( + # [[obj for obj in gc.get_objects() if isinstance(obj, UIWidget)][1]], + # max_depth=15, + # # filename="sample-graph.png", + # ) + + assert objs_in_memory(UIWidget) == start_count From 8be95712859a010c4360e4a5bead2f417b1760ca Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 3 Jul 2025 21:10:12 +0200 Subject: [PATCH 221/279] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f93e464507..fd65bcd627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - GUI - Fix UIScrollBar creation + - Fix memory leak: widgets were not garbage collected ## 3.3.1 From 9ac2efc907c26c09bbee5dad9b960c324842e356 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 3 Jul 2025 23:06:50 +0200 Subject: [PATCH 222/279] gui: Fix issues with binding class methods on children --- arcade/gui/property.py | 61 +++++++++++++++++++++++++++++++-- arcade/gui/widgets/layout.py | 30 ++++++++-------- tests/unit/gui/test_property.py | 34 +++++++++++++++++- 3 files changed, 106 insertions(+), 19 deletions(-) diff --git a/arcade/gui/property.py b/arcade/gui/property.py index 400536f9b8..d2d7498cf8 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -1,9 +1,12 @@ import inspect import sys import traceback +import warnings +import weakref from collections.abc import Callable from contextlib import contextmanager, suppress from enum import Enum +from inspect import ismethod from typing import Any, Generic, TypeVar, cast from weakref import WeakKeyDictionary, ref @@ -221,7 +224,52 @@ def __set__(self, instance, value: P): self.set(instance, value) -def bind(instance, property: str, callback: AnyListener): +class _WeakCallback: + """Wrapper for weakly referencing a callback function. + + Which allows to bind methods of the instance itself without + causing memory leaks. + + Also supports to be stored in a dict or set, because it implements + __hash__ and __eq__ methods to match the original function. + """ + + def __init__(self, func): + self._func_type = _ListenerType.detect_callback_type(func) # type: ignore[assignment] + self._hash = hash(func) + + if inspect.ismethod(func): + self._func = weakref.WeakMethod(func) + else: + self._func = weakref.ref(func) + + def __call__(self, instance, new_value, old_value): + func = self._func() + if func is None: + warnings.warn("WeakCallable was called without a callable object.") + + if self._func_type == _ListenerType.NO_ARG: + return func() + elif self._func_type == _ListenerType.INSTANCE: + return func(instance) + elif self._func_type == _ListenerType.INSTANCE_VALUE: + return func(instance, new_value) + elif self._func_type == _ListenerType.INSTANCE_NEW_OLD: + return func(instance, new_value, old_value) + + else: + raise TypeError(f"Unsupported callback type: {self._func_type}") + + def __hash__(self): + return self._hash + + def __eq__(self, other): + if ismethod(other): + return self._hash == hash(other) + return False + + +def bind(instance, property: str, callback: AnyListener, weak=False): """Bind a function to the change event of the property. A reference to the function will be kept, so that it will be still @@ -243,17 +291,24 @@ class MyObject: Binding to a method of the Property owner itself can cause a memory leak, because the owner is strongly referenced. Instead, bind the class method, which will be invoked with - the instance as first parameter. - + the instance as first parameter. `bind(instance, "property_name", Instance.method)`. + Or use the `weak` parameter to bind the method weakly `bind(instance, "property_name", instance.method, weak=True)` Args: instance: Instance owning the property property: Name of the property callback: Function to call + weak: If True, the callback will be weakly referenced. + This is useful for methods of the instance itself to avoid memory leaks. Returns: None """ + + if weak: + # If weak is True, we use a _WeakCallable to avoid strong references + callback = _WeakCallback(callback) # type: ignore[assignment] + # TODO rename property to property_name for arcade 4.0 (just to be sure) t = type(instance) prop = getattr(t, property) diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index 2c17cf29a9..410f85159c 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -288,11 +288,11 @@ def add(self, child: W, **kwargs) -> W: child: The widget to add to the layout. """ # subscribe to child's changes, which might affect the own size hint - bind(child, "_children", UIBoxLayout._trigger_size_hint_update) - bind(child, "rect", UIBoxLayout._trigger_size_hint_update) - bind(child, "size_hint", UIBoxLayout._trigger_size_hint_update) - bind(child, "size_hint_min", UIBoxLayout._trigger_size_hint_update) - bind(child, "size_hint_max", UIBoxLayout._trigger_size_hint_update) + bind(child, "_children", self._trigger_size_hint_update, weak=True) + bind(child, "rect", self._trigger_size_hint_update, weak=True) + bind(child, "size_hint", self._trigger_size_hint_update, weak=True) + bind(child, "size_hint_min", self._trigger_size_hint_update, weak=True) + bind(child, "size_hint_max", self._trigger_size_hint_update, weak=True) return super().add(child, **kwargs) @@ -300,11 +300,11 @@ def add(self, child: W, **kwargs) -> W: def remove(self, child: UIWidget): """Remove a child from the layout.""" # unsubscribe from child's changes - unbind(child, "_children", UIBoxLayout._trigger_size_hint_update) - unbind(child, "rect", UIBoxLayout._trigger_size_hint_update) - unbind(child, "size_hint", UIBoxLayout._trigger_size_hint_update) - unbind(child, "size_hint_min", UIBoxLayout._trigger_size_hint_update) - unbind(child, "size_hint_max", UIBoxLayout._trigger_size_hint_update) + unbind(child, "_children", self._trigger_size_hint_update) + unbind(child, "rect", self._trigger_size_hint_update) + unbind(child, "size_hint", self._trigger_size_hint_update) + unbind(child, "size_hint_min", self._trigger_size_hint_update) + unbind(child, "size_hint_max", self._trigger_size_hint_update) return super().remove(child) @@ -547,11 +547,11 @@ def add( row_span: Number of rows the widget will stretch for. """ # subscribe to child's changes, which might affect the own size hint - bind(child, "_children", UIGridLayout._trigger_size_hint_update) - bind(child, "rect", UIGridLayout._trigger_size_hint_update) - bind(child, "size_hint", UIGridLayout._trigger_size_hint_update) - bind(child, "size_hint_min", UIGridLayout._trigger_size_hint_update) - bind(child, "size_hint_max", UIGridLayout._trigger_size_hint_update) + bind(child, "_children", self._trigger_size_hint_update, weak=True) + bind(child, "rect", self._trigger_size_hint_update, weak=True) + bind(child, "size_hint", self._trigger_size_hint_update, weak=True) + bind(child, "size_hint_min", self._trigger_size_hint_update, weak=True) + bind(child, "size_hint_max", self._trigger_size_hint_update, weak=True) return super().add( child, diff --git a/tests/unit/gui/test_property.py b/tests/unit/gui/test_property.py index 8b95e37a44..d8c2c565aa 100644 --- a/tests/unit/gui/test_property.py +++ b/tests/unit/gui/test_property.py @@ -144,12 +144,44 @@ def test_bind_callback_with_star_args(): observer.call_args = None -def test_unbind_callback(): +def test_unbind_function_callback(): + called = False + def callback(*args): + nonlocal called + called = True + + my_obj = MyObject() + bind(my_obj, "name", callback) + + # WHEN + unbind(my_obj, "name", callback) + my_obj.name = "New Name" + + assert not called + +def test_unbind_method_callback(): observer = Observer() my_obj = MyObject() bind(my_obj, "name", observer.call) + gc.collect() + + # WHEN + unbind(my_obj, "name", observer.call) + my_obj.name = "New Name" + + assert not observer.called + + +def test_unbind_weak_method_callback(): + observer = Observer() + + my_obj = MyObject() + bind(my_obj, "name", observer.call, weak=True) + + gc.collect() + # WHEN unbind(my_obj, "name", observer.call) my_obj.name = "New Name" From 07431b2d8e68d7c78e7de74e90d513b2945c044f Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 3 Jul 2025 23:18:16 +0200 Subject: [PATCH 223/279] gui: fix lint --- arcade/gui/property.py | 5 ++++- tests/unit/gui/test_property.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/arcade/gui/property.py b/arcade/gui/property.py index d2d7498cf8..b446a34404 100644 --- a/arcade/gui/property.py +++ b/arcade/gui/property.py @@ -85,6 +85,8 @@ def remove(self, callback): @property def listeners(self) -> list[tuple[AnyListener, _ListenerType]]: """Returns a list of all listeners and type, both weak and strong.""" + # todo returning a iterator would be more efficient, but might also break + # improve ~0.01 sec return list(self._listeners.items()) @@ -292,7 +294,8 @@ class MyObject: Binding to a method of the Property owner itself can cause a memory leak, because the owner is strongly referenced. Instead, bind the class method, which will be invoked with the instance as first parameter. `bind(instance, "property_name", Instance.method)`. - Or use the `weak` parameter to bind the method weakly `bind(instance, "property_name", instance.method, weak=True)` + Or use the `weak` parameter to bind the method weakly + bind(instance, "property_name", instance.method, weak=True)`. Args: instance: Instance owning the property diff --git a/tests/unit/gui/test_property.py b/tests/unit/gui/test_property.py index d8c2c565aa..86f5305d1b 100644 --- a/tests/unit/gui/test_property.py +++ b/tests/unit/gui/test_property.py @@ -146,6 +146,7 @@ def test_bind_callback_with_star_args(): def test_unbind_function_callback(): called = False + def callback(*args): nonlocal called called = True @@ -158,7 +159,8 @@ def callback(*args): my_obj.name = "New Name" assert not called - + + def test_unbind_method_callback(): observer = Observer() From 611376219fbd397078451c3b8930950ecb77390b Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 3 Jul 2025 20:07:31 -0500 Subject: [PATCH 224/279] Update version (#2749) --- CHANGELOG.md | 3 +-- arcade/VERSION | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd65bcd627..110de9c292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,12 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## Unreleased +## 3.3.2 - GUI - Fix UIScrollBar creation - Fix memory leak: widgets were not garbage collected - ## 3.3.1 - Fixed an issue causing NinePatch to not render correctly diff --git a/arcade/VERSION b/arcade/VERSION index 712bd5a680..5436ea06e3 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.3.1 \ No newline at end of file +3.3.2 \ No newline at end of file From 9c90129600c9252fb534ea15751397ce19a3c324 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Sat, 5 Jul 2025 22:41:00 +0200 Subject: [PATCH 225/279] Gui/fix caret misplaced (#2750) * gui: fix caret offset after UIInputText is resized * gui: cleanup test execution --- CHANGELOG.md | 5 ++++ arcade/gui/widgets/text.py | 3 +++ tests/unit/gui/test_widget_tree.py | 38 ++++++++++++++++-------------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 110de9c292..eaa0ec647d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. +## Unreleased + +- GUI + - Fix a bug, where the caret of UIInputText was misplaced after resizing the widget + ## 3.3.2 - GUI diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index e4e8d6db8a..a9b50cbee8 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -735,6 +735,9 @@ def _update_layout(self): layout.y = 0 layout.end_update() + # manually update caret position + self.caret.on_layout_update() + @property def text(self): """Text of the input field.""" diff --git a/tests/unit/gui/test_widget_tree.py b/tests/unit/gui/test_widget_tree.py index fee72f39c8..51d12a0bfe 100644 --- a/tests/unit/gui/test_widget_tree.py +++ b/tests/unit/gui/test_widget_tree.py @@ -131,23 +131,25 @@ def objs_in_memory(obj_type): del root gc.collect() - if objs_in_memory(UIWidget) > start_count: - print("Render object graph...") - import objgraph - - objgraph.show_chain( - objgraph.find_backref_chain( - [obj for obj in gc.get_objects() if isinstance(obj, UIWidget)][1], - objgraph.is_proper_module, - ), - # filename="chain.png", - ) - - # print("Render backrefs...") - # objgraph.show_backrefs( - # [[obj for obj in gc.get_objects() if isinstance(obj, UIWidget)][1]], - # max_depth=15, - # # filename="sample-graph.png", - # ) + # This might help, if the test fails ;) + # requires `objgraph` + # if objs_in_memory(UIWidget) > start_count: + # print("Render object graph...") + # import objgraph + # + # objgraph.show_chain( + # objgraph.find_backref_chain( + # [obj for obj in gc.get_objects() if isinstance(obj, UIWidget)][1], + # objgraph.is_proper_module, + # ), + # # filename="chain.png", + # ) + + # print("Render backrefs...") + # objgraph.show_backrefs( + # [[obj for obj in gc.get_objects() if isinstance(obj, UIWidget)][1]], + # max_depth=15, + # # filename="sample-graph.png", + # ) assert objs_in_memory(UIWidget) == start_count From f5b6228f2b84a5f799b0f2fbe3a58c44f834a745 Mon Sep 17 00:00:00 2001 From: "A. J. Andrews" <86714785+DragonMoffon@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:34:24 +1200 Subject: [PATCH 226/279] fixing the positioning algorithm in `Camera2D.match_target` for #2558 (#2646) * fixing the positioning algorithm in `Camera2D.match_target` for #2558 * vec2 whoops --- arcade/camera/camera_2d.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index aa6b4a25d9..52e8096aa2 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -331,6 +331,8 @@ def equalise(self) -> None: x, y = self._projection_data.rect.x, self._projection_data.rect.y self._projection_data.rect = XYWH(x, y, self.viewport_width, self.viewport_height) + equalize = equalise + def match_window( self, viewport: bool = True, @@ -349,8 +351,8 @@ def match_window( On by default scissor: Flag whether to also equalize the scissor box to the viewport. On by default - position: Flag whether to also center the camera to the viewport. - Off by default + position: Flag whether to position the camera so that (0.0, 0.0) is in + the bottom-left aspect: The ratio between width and height that the viewport should be constrained to. If unset then the viewport just matches the window size. The aspect ratio describes how much larger the width should be @@ -383,8 +385,8 @@ def match_target( match the render target. The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. - position: Flag whether to also center the camera to the value. - Off by default + position: Flag whether to position the camera so that (0.0, 0.0) is in + the bottom-left aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. @@ -425,8 +427,8 @@ def update_values( projection: Flag whether to equalize the size of the projection to match the value. The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. - position: Flag whether to also center the camera to the value. - Off by default + position: Flag whether to position the camera so that (0.0, 0.0) is in + the bottom-left aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. @@ -452,7 +454,7 @@ def update_values( self.scissor = value if position: - self.position = value.center + self.position = Vec2(-self._projection_data.left, -self._projection_data.bottom) def aabb(self) -> Rect: """ From b084ae3d7f3ab96753ae08d67d76cfad8fc068b4 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 29 Jul 2025 19:56:18 +0200 Subject: [PATCH 227/279] gui: use incremental layout in UITextArea (#2756) --- CHANGELOG.md | 1 + arcade/examples/gui/1_layouts.py | 3 ++- arcade/gui/experimental/scroll_area.py | 6 +++--- arcade/gui/widgets/text.py | 5 +++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa0ec647d..ba77e10b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - GUI - Fix a bug, where the caret of UIInputText was misplaced after resizing the widget + - Use incremental layout for UIScrollArea to improve performance of changing text ## 3.3.2 diff --git a/arcade/examples/gui/1_layouts.py b/arcade/examples/gui/1_layouts.py index 51afa9beab..f7d4bc8e29 100644 --- a/arcade/examples/gui/1_layouts.py +++ b/arcade/examples/gui/1_layouts.py @@ -14,6 +14,7 @@ from datetime import datetime import arcade +import arcade.gui from arcade.gui import UIAnchorLayout, UIImage, UITextArea arcade.resources.load_kenney_fonts() @@ -186,5 +187,5 @@ def main(): window.run() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index 5b8ad10617..bd7db75412 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -46,9 +46,9 @@ def __init__(self, scroll_area: UIScrollArea, vertical: bool = True): bind(self, "_thumb_hover", UIScrollBar.trigger_render) bind(self, "_dragging", UIScrollBar.trigger_render) - bind(scroll_area, "scroll_x", UIScrollBar.trigger_full_render) - bind(scroll_area, "scroll_y", UIScrollBar.trigger_full_render) - bind(scroll_area, "rect", UIScrollBar.trigger_full_render) + bind(scroll_area, "scroll_x", self.trigger_full_render, weak=True) + bind(scroll_area, "scroll_y", self.trigger_full_render, weak=True) + bind(scroll_area, "rect", self.trigger_full_render, weak=True) def on_event(self, event: UIEvent) -> bool | None: # check if we are scrollable diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index a9b50cbee8..224de9a7b1 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -855,14 +855,14 @@ def __init__( ), ) - self.layout = pyglet.text.layout.ScrollableTextLayout( + self.layout = pyglet.text.layout.IncrementalTextLayout( self.doc, width=int(self.content_width), height=int(self.content_height), multiline=multiline, ) - bind(self, "rect", self._update_layout) + bind(self, "rect", UITextArea._update_layout) def fit_content(self): """Set the width and height of the text area to contain the whole text.""" @@ -894,6 +894,7 @@ def _update_layout(self): layout.begin_update() layout.width = content_width layout.height = content_height + layout.y = 0 # reset y position to 0 (Required by IncrementalTextLayout) layout.end_update() @override From 61248f2b5d381eb08bfc69c6df3bbb470c7f9c01 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Wed, 30 Jul 2025 00:52:57 +0200 Subject: [PATCH 228/279] Gui/controller focus improvements (#2757) * controller window skip initial events * gui: move focus interaction into widget code * gui: add escape handler to close example * gui: make use of focus group to switch between input fields * gui: remove debug prints * gui: fix flickering of UIInputText when focused and space pressed * gui: update UIFocusGroup docs --- CHANGELOG.md | 1 + arcade/examples/gui/exp_controller_support.py | 9 +- .../gui/exp_controller_support_grid.py | 8 +- arcade/examples/gui/exp_hidden_password.py | 33 +++--- arcade/experimental/controller_window.py | 3 +- arcade/gui/experimental/focus.py | 106 +++--------------- arcade/gui/widgets/__init__.py | 49 ++++++++ arcade/gui/widgets/slider.py | 27 ++++- arcade/gui/widgets/text.py | 17 ++- 9 files changed, 133 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba77e10b1a..97d8ef61b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - GUI - Fix a bug, where the caret of UIInputText was misplaced after resizing the widget - Use incremental layout for UIScrollArea to improve performance of changing text + - Refactored and improved focus handling ## 3.3.2 diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py index 40dd979722..26decc42fa 100644 --- a/arcade/examples/gui/exp_controller_support.py +++ b/arcade/examples/gui/exp_controller_support.py @@ -9,7 +9,6 @@ python -m arcade.examples.gui.exp_controller_support """ - import arcade from arcade import Texture from arcade.experimental.controller_window import ControllerWindow, ControllerView @@ -143,6 +142,7 @@ def __init__(self): root.add(UIFlatButton(text="Close")).on_click = self.close self.detect_focusable_widgets() + self.set_focus() def on_event(self, event): if super().on_event(event): @@ -190,6 +190,13 @@ def on_button_click(self, event: UIOnClickEvent): print("Button clicked") self.root.add(ControllerModal()) + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + # make the example close with the escape key + if symbol == arcade.key.ESCAPE: + self.window.close() + return True + return super().on_key_press(symbol, modifiers) + if __name__ == "__main__": window = ControllerWindow(title="Controller UI Example") diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py index 51f1dd0772..9ea4cad632 100644 --- a/arcade/examples/gui/exp_controller_support_grid.py +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -9,7 +9,6 @@ python -m arcade.examples.gui.exp_controller_support_grid """ - import arcade from arcade.examples.gui.exp_controller_support import ControllerIndicator from arcade.experimental.controller_window import ControllerView, ControllerWindow @@ -88,6 +87,13 @@ def __init__(self): self.root.detect_focusable_widgets() + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + # make the example close with the escape key + if symbol == arcade.key.ESCAPE: + self.window.close() + return True + return super().on_key_press(symbol, modifiers) + if __name__ == "__main__": window = ControllerWindow(title="Controller UI Example") diff --git a/arcade/examples/gui/exp_hidden_password.py b/arcade/examples/gui/exp_hidden_password.py index 8c83ffeff5..9cc981e823 100644 --- a/arcade/examples/gui/exp_hidden_password.py +++ b/arcade/examples/gui/exp_hidden_password.py @@ -13,10 +13,12 @@ """ import arcade +from arcade.experimental.controller_window import ControllerWindow from arcade.gui import UIInputText, UIOnClickEvent, UIView +from arcade.gui.experimental.focus import UIFocusGroup from arcade.gui.experimental.password_input import UIPasswordInput from arcade.gui.widgets.buttons import UIFlatButton -from arcade.gui.widgets.layout import UIGridLayout, UIAnchorLayout +from arcade.gui.widgets.layout import UIGridLayout from arcade.gui.widgets.text import UILabel from arcade import resources @@ -80,32 +82,25 @@ def __init__(self): column_span=2, ) - anchor = UIAnchorLayout() # to center grid on screen + anchor = UIFocusGroup() # to center grid on screen anchor.add(grid) self.add_widget(anchor) # activate username input field - self.username_input.activate() + anchor.detect_focusable_widgets() + anchor.set_focus() def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + # make the example close with the escape key + if symbol == arcade.key.ESCAPE: + self.window.close() + return True + # if username field active, switch fields with enter - if self.username_input.active: - if symbol == arcade.key.TAB: - self.username_input.deactivate() - self.password_input.activate() - return True - elif symbol == arcade.key.ENTER: + elif self.username_input.active or self.password_input.active: + if symbol == arcade.key.ENTER: self.username_input.deactivate() - self.on_login(None) - return True - # if password field active, login with enter - elif self.password_input.active: - if symbol == arcade.key.TAB: - self.username_input.activate() - self.password_input.deactivate() - return True - elif symbol == arcade.key.ENTER: self.password_input.deactivate() self.on_login(None) return True @@ -118,7 +113,7 @@ def on_login(self, event: UIOnClickEvent | None): def main(): - window = arcade.Window(title="GUI Example: Hidden Password") + window = ControllerWindow(title="GUI Example: Hidden Password") window.show_view(MyView()) window.run() diff --git a/arcade/experimental/controller_window.py b/arcade/experimental/controller_window.py index 2e6d9e2cba..933146ee25 100644 --- a/arcade/experimental/controller_window.py +++ b/arcade/experimental/controller_window.py @@ -29,12 +29,11 @@ def __init__(self, window: arcade.Window): self.on_connect(controller) def on_connect(self, controller: Controller): - controller.push_handlers(self) - try: controller.open() except Exception as e: warnings.warn(f"Failed to open controller {controller}: {e}") + controller.push_handlers(self) self.window.dispatch_event("on_connect", controller) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index a41cd158d5..333f461a57 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -5,23 +5,16 @@ from pyglet.math import Vec2 import arcade -from arcade import MOUSE_BUTTON_LEFT from arcade.gui.events import ( - UIControllerButtonPressEvent, - UIControllerButtonReleaseEvent, UIControllerDpadEvent, UIControllerEvent, UIEvent, UIKeyPressEvent, - UIKeyReleaseEvent, - UIMousePressEvent, - UIMouseReleaseEvent, ) from arcade.gui.property import ListProperty, Property, bind from arcade.gui.surface import Surface -from arcade.gui.widgets import FocusMode, UIInteractiveWidget, UIWidget +from arcade.gui.widgets import FocusMode, UIWidget from arcade.gui.widgets.layout import UIAnchorLayout -from arcade.gui.widgets.slider import UIBaseSlider class UIFocusable(UIWidget): @@ -100,54 +93,22 @@ def on_event(self, event: UIEvent) -> bool | None: return EVENT_HANDLED - elif event.symbol == arcade.key.SPACE: - self._start_interaction() + elif isinstance(event, UIControllerDpadEvent): + # switch focus + if event.vector.x == 1: + self.focus_right() return EVENT_HANDLED - elif isinstance(event, UIKeyReleaseEvent): - if event.symbol == arcade.key.SPACE: - self._end_interaction() + elif event.vector.y == 1: + self.focus_up() return EVENT_HANDLED - elif isinstance(event, UIControllerDpadEvent): - if self._interacting: - # TODO this should be handled in the slider! - # pass dpad events to the interacting widget - if event.vector.x == 1 and isinstance(self._interacting, UIBaseSlider): - self._interacting.norm_value += 0.1 - return EVENT_HANDLED - - elif event.vector.x == -1 and isinstance(self._interacting, UIBaseSlider): - self._interacting.norm_value -= 0.1 - return EVENT_HANDLED - + elif event.vector.x == -1: + self.focus_left() return EVENT_HANDLED - else: - # switch focus - if event.vector.x == 1: - self.focus_right() - return EVENT_HANDLED - - elif event.vector.y == 1: - self.focus_up() - return EVENT_HANDLED - - elif event.vector.x == -1: - self.focus_left() - return EVENT_HANDLED - - elif event.vector.y == -1: - self.focus_down() - return EVENT_HANDLED - - elif isinstance(event, UIControllerButtonPressEvent): - if event.button == "a": - self._start_interaction() - return EVENT_HANDLED - elif isinstance(event, UIControllerButtonReleaseEvent): - if event.button == "a": - self._end_interaction() + elif event.vector.y == -1: + self.focus_down() return EVENT_HANDLED return EVENT_UNHANDLED @@ -278,48 +239,6 @@ def focus_previous(self): # automatically wrap around via index -1 self.set_focus(self._focusable_widgets[focused_index]) - def _start_interaction(self): - # TODO this should be handled in the widget - - widget = self.focused_widget - - if isinstance(widget, UIInteractiveWidget): - widget.dispatch_ui_event( - UIMousePressEvent( - source=self, - x=int(widget.rect.center_x), - y=int(widget.rect.center_y), - button=MOUSE_BUTTON_LEFT, - modifiers=0, - ) - ) - self._interacting = widget - else: - print("Cannot interact widget") - - def _end_interaction(self): - widget = self.focused_widget - - if isinstance(widget, UIInteractiveWidget): - if isinstance(self._interacting, UIBaseSlider): - # if slider, release outside the slider - x = self._interacting.rect.left - 1 - y = self._interacting.rect.bottom - 1 - else: - x = widget.rect.center_x - y = widget.rect.center_y - - self._interacting = None - widget.dispatch_ui_event( - UIMouseReleaseEvent( - source=self, - x=int(x), - y=int(y), - button=MOUSE_BUTTON_LEFT, - modifiers=0, - ) - ) - def _do_render(self, surface: Surface, force=False) -> bool: rendered = super()._do_render(surface, force) @@ -373,4 +292,5 @@ def is_focusable(widget): class UIFocusGroup(UIFocusMixin, UIAnchorLayout): - pass + """This will be removed in the future. + UIFocusMixin is planned to be integrated into UILayout.""" diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 3d752dd39b..734b3e43e0 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -22,6 +22,10 @@ UIMouseReleaseEvent, UIOnClickEvent, UIOnUpdateEvent, + UIControllerButtonPressEvent, + UIControllerButtonReleaseEvent, + UIKeyPressEvent, + UIKeyReleaseEvent, ) from arcade.gui.nine_patch import NinePatchTexture from arcade.gui.property import ListProperty, Property, bind @@ -745,6 +749,13 @@ def __init__( bind(self, "pressed", UIInteractiveWidget.trigger_render) bind(self, "hovered", UIInteractiveWidget.trigger_render) bind(self, "disabled", UIInteractiveWidget.trigger_render) + bind(self, "focused", UIInteractiveWidget._on_focus_change) + + def _on_focus_change(self): + """If focus lost, release active state""" + if self.pressed and not self.focused: + self.pressed = False + self._release_active() def on_event(self, event: UIEvent) -> bool | None: """Handles mouse events and triggers on_click event if the widget is clicked. @@ -754,6 +765,7 @@ def on_event(self, event: UIEvent) -> bool | None: if super().on_event(event): return EVENT_HANDLED + # mouse event handling if isinstance(event, UIMouseMovementEvent): self.hovered = self.rect.point_in_rect(event.pos) @@ -788,6 +800,43 @@ def on_event(self, event: UIEvent) -> bool | None: ) return EVENT_HANDLED # TODO should we return the result from on_click? + # focus related events + if self.focused: + if isinstance(event, UIKeyPressEvent) and event.symbol == arcade.key.SPACE: + self.pressed = True + self._grap_active() # make this the active widget + return EVENT_HANDLED + + if isinstance(event, UIControllerButtonPressEvent) and event.button in ("a",): + self.pressed = True + self._grap_active() # make this the active widget + return EVENT_HANDLED + + if self.pressed: + keyboard_interaction = ( + isinstance(event, UIKeyReleaseEvent) and event.symbol == arcade.key.SPACE + ) + controller_interaction = isinstance( + event, UIControllerButtonReleaseEvent + ) and event.button in ("a",) + + if keyboard_interaction or controller_interaction: + self.pressed = False + if not self.disabled: + # Dispatch new on_click event, source is this widget itself + self._grap_active() + self.dispatch_event( + "on_click", + UIOnClickEvent( # simulate mouse click + source=self, + x=int(self.center_x), + y=int(self.center_y), + button=self.interaction_buttons[0], + modifiers=0, + ), + ) + return EVENT_HANDLED # TODO should we return the result from on_click? + return EVENT_UNHANDLED def on_click(self, event: UIOnClickEvent): diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index 9d2ccb545b..45c44ffe55 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -18,7 +18,11 @@ UIMouseDragEvent, UIOnClickEvent, ) -from arcade.gui.events import UIOnChangeEvent +from arcade.gui.events import ( + UIControllerButtonReleaseEvent, + UIControllerDpadEvent, + UIOnChangeEvent, +) from arcade.gui.property import Property, bind from arcade.gui.style import UIStyleBase, UIStyledWidget from arcade.types import RGBA255 @@ -223,6 +227,15 @@ def on_event(self, event: UIEvent) -> bool | None: if self.disabled: return EVENT_UNHANDLED + # handle UIControllerButtonEvent events, + # before UIInteractiveWidgets dispatches an on_click event + if self.focused and isinstance(event, UIControllerButtonReleaseEvent): + if event.button == "a": + self.pressed = False + self._release_active() + + return EVENT_HANDLED + if super().on_event(event): return EVENT_HANDLED @@ -232,6 +245,18 @@ def on_event(self, event: UIEvent) -> bool | None: return EVENT_HANDLED + if self.pressed and isinstance(event, UIControllerDpadEvent): + # pass dpad events to the interacting widget + if event.vector.x == 1: + self.norm_value += 0.1 + return EVENT_HANDLED + + elif event.vector.x == -1: + self.norm_value -= 0.1 + return EVENT_HANDLED + + return EVENT_HANDLED + return EVENT_UNHANDLED @override diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 224de9a7b1..c19e198be1 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -13,6 +13,7 @@ from arcade import uicolor from arcade.gui.events import ( UIEvent, + UIKeyEvent, UIMouseDragEvent, UIMouseEvent, UIMousePressEvent, @@ -574,11 +575,18 @@ def __init__( bind(self, "pressed", UIInputText._apply_style) bind(self, "invalid", UIInputText._apply_style) bind(self, "disabled", UIInputText._apply_style) + bind(self, "focused", UIInputText._on_focus_change) bind(self, "_active", UIInputText._on_active_changed) # initial style application self._apply_style() + def _on_focus_change(self): + if self.focused: + self.activate() + elif self.active: + self.deactivate() + def _on_active_changed(self): """Handle the active state change of the input text field to care about loosing active state.""" @@ -663,6 +671,12 @@ def on_event(self, event: UIEvent) -> bool | None: # If active pass all non press events to caret if self._active: old_text = self.text + + if self.focused and isinstance(event, UIKeyEvent) and event.symbol == arcade.key.SPACE: + # if widget is focused, we consume the space key + # to prevent flickering of the focus + return EVENT_HANDLED + # Act on events if active if isinstance(event, UITextInputEvent): self.caret.on_text(event.text) @@ -712,10 +726,7 @@ def deactivate(self): """Programmatically deactivate the text input field.""" if self._active: - print("Release active text input field") self._release_active() # will set _active to False - else: - print("Text input field is not active, cannot deactivate") self.trigger_full_render() self.caret.on_deactivate() From 3ce025ab76032bb9af3dd62bdab6427fc1e5025b Mon Sep 17 00:00:00 2001 From: DigiDuncan Date: Wed, 13 Aug 2025 17:55:31 -0400 Subject: [PATCH 229/279] Add .rect to Text (#2759) * add .rect to Text * added comment from @pushfoo --- arcade/text.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/arcade/text.py b/arcade/text.py index 803219352d..60154c90c4 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -13,6 +13,7 @@ from arcade.resources import resolve from arcade.texture_atlas import TextureAtlasBase from arcade.types import Color, Point, RGBOrA255 +from arcade.types.rect import LRBT, Rect __all__ = ["load_font", "Text", "create_text_sprite", "draw_text"] @@ -578,6 +579,18 @@ def bottom(self) -> float: """Pixel location of the bottom content border.""" return self.label.bottom + @property + def rect(self) -> Rect: + """Rect representing the bounds of the text. + + .. tip:: Don't worry about `width` being `None`. + + Although a label can be created with a `width=None`: + * The underlying :py:mod:`pyglet` label will have bounding dimensions + * This rect is for on-screen click and layout purposes, not maximum possible width + """ + return LRBT(self.left, self.right, self.bottom, self.top) + @property def content_size(self) -> tuple[int, int]: """Get the pixel width and height of the text contents.""" From 539dd7e05d45cfdd2d0f210e63707edcf7e7ba1a Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Thu, 21 Aug 2025 21:05:53 +0200 Subject: [PATCH 230/279] UIBoxLayout ignores widgets with `visible=None` (#2761) * UIBoxLayout ignores widgets with `visible=None` --- CHANGELOG.md | 1 + arcade/context.py | 4 ++-- arcade/gui/widgets/__init__.py | 4 +++- arcade/gui/widgets/layout.py | 14 ++++++++++---- tests/unit/gui/test_layouting_boxlayout.py | 22 ++++++++++++++++++++++ 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d8ef61b5..72a58dab7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fix a bug, where the caret of UIInputText was misplaced after resizing the widget - Use incremental layout for UIScrollArea to improve performance of changing text - Refactored and improved focus handling + - UIBoxLayout ignores widgets with `visible=None` ## 3.3.2 diff --git a/arcade/context.py b/arcade/context.py index b727fa1795..52e0cedae5 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -330,8 +330,8 @@ def bind_window_block(self) -> None: gl.GL_UNIFORM_BUFFER, 0, self._window_block.buffer.id, - 0, - 128, # 32 x 32bit floats (two mat4) + 0, # type: ignore + 128, # 32 x 32bit floats (two mat4) # type: ignore ) @property diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 734b3e43e0..f275509e22 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -127,7 +127,9 @@ class UIWidget(EventDispatcher, ABC): """A weak reference to the parent UIManager or UIWidget, which does not prevent garbage collection of the parent.""" rect = Property(LBWH(0, 0, 1, 1)) - visible = Property(True) + visible = Property[bool | None](True) + """If True, the widget is visible and will be rendered. If None, + the widget should also be skipped by layouts.""" focused = Property(False) focus_mode: FocusMode = FocusMode.NONE diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index 410f85159c..bdf2512178 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -315,7 +315,9 @@ def _update_size_hints(self): self._size_hint_requires_update = False required_space_between = max(0, len(self.children) - 1) * self._space_between - min_child_sizes = [UILayout.min_size_of(child) for child in self.children] + min_child_sizes = [ + UILayout.min_size_of(child) for child in self.children if child.visible is not None + ] if len(self.children) == 0: width = 0 @@ -360,10 +362,14 @@ def do_layout(self): if not self.children: return + children_to_render = [ + (child, data) for child, data in self._children if child.visible is not None + ] + # main axis constraints = [ _C.from_widget_height(child) if self.vertical else _C.from_widget_width(child) - for child, _ in self._children + for child, _ in children_to_render ] available_space = ( @@ -374,14 +380,14 @@ def do_layout(self): # orthogonal axis constraints = [ _C.from_widget_width(child) if self.vertical else _C.from_widget_height(child) - for child, _ in self._children + for child, _ in children_to_render ] orthogonal_sizes = _box_orthogonal_algorithm( constraints, self.content_width if self.vertical else self.content_height ) for (child, data), main_size, ortho_size in zip( - self._children, main_sizes, orthogonal_sizes + children_to_render, main_sizes, orthogonal_sizes ): # apply calculated sizes, condition regarding existing size_hint # are already covered in calculation input diff --git a/tests/unit/gui/test_layouting_boxlayout.py b/tests/unit/gui/test_layouting_boxlayout.py index 20aadc3d24..894d24155e 100644 --- a/tests/unit/gui/test_layouting_boxlayout.py +++ b/tests/unit/gui/test_layouting_boxlayout.py @@ -128,6 +128,28 @@ def test_do_layout_vertical_space_between(window): assert element_2.top == 300 +def test_do_layout_ignores_child_with_visible_None(window): + # add two 100x100 Dummy widgets + element_1 = UIDummy() + element_1.visible = None + element_2 = UIDummy() + + group = UIBoxLayout(vertical=True, children=[element_1, element_2]) + assert group.size_hint_min == (100, 100) + group.rect = LBWH(100, 200, 100, 100) + + group._do_layout() + + # element 1 in initial position + assert element_1.bottom == 0 + assert element_1.left == 0 + + # element 2 fills the group + assert element_2.top == 300 + assert element_2.bottom == 200 + assert element_2.left == 100 + + # Horizontal def test_do_layout_horizontal_with_initial_children(window): # add two 100x100 Dummy widgets From d1d3824f1e4d3582a277120d3995e66fd480b622 Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Sun, 7 Sep 2025 13:22:10 -0400 Subject: [PATCH 231/279] Add GL backends to pyinstaller hidden imports (#2764) * Add GL backends to pyinstaller hidden imports * Update changelog --- CHANGELOG.md | 4 ++++ arcade/__pyinstaller/hook-arcade.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a58dab7b..87146a7413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased +- PyInstaller + - Fixed an issue where imports for backends for the `arcade.gl` package could not be discovered by PyInstaller. + Since 3.3.0 users have needed to add these hidden imports via the pyinstaller CLI in order for Arcade to work. + - GUI - Fix a bug, where the caret of UIInputText was misplaced after resizing the widget - Use incremental layout for UIScrollArea to improve performance of changing text diff --git a/arcade/__pyinstaller/hook-arcade.py b/arcade/__pyinstaller/hook-arcade.py index 76f89a4932..ef5ab231f1 100644 --- a/arcade/__pyinstaller/hook-arcade.py +++ b/arcade/__pyinstaller/hook-arcade.py @@ -22,6 +22,8 @@ "Only Linux, Mac, and Windows are supported." ) +hiddenimports = ["arcade.gl.backends.opengl.provider", "arcade.gl.backends.opengl"] + datas = [] binaries = [] From 7f4ae4937a0c7ea8d4df3944c6d070c543bc3817 Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Sun, 7 Sep 2025 13:33:42 -0400 Subject: [PATCH 232/279] Add method 0 for auto-select to check_for_collision_with_lists (#2762) * Add method 0 for auto-select to check_for_collision_with_lists * Update changelog * Reference PR --- CHANGELOG.md | 6 ++++++ arcade/sprite_list/collision.py | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87146a7413..d7dfe4f269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased +- Fixes a bug with the `check_for_collision_with_lists` function. This function is intended to mimic the functionality of + `check_for_collision_with_list` but allow passing multiple lists and looping the same behavior. The `lists` function however + handled the collision method differently. Which resulted in only spatial hash being used if it was available, or GPU collision. + It would never fallback to the pure CPU brute force approach, which is the best option for spritelists which don't have spatial hash + and less than 1,500 sprites. Certain games may see a substantial performance improvement from this change. See [2762](https://github.com/pythonarcade/arcade/pull/2762) + - PyInstaller - Fixed an issue where imports for backends for the `arcade.gl` package could not be discovered by PyInstaller. Since 3.3.0 users have needed to add these hidden imports via the pyinstaller CLI in order for Arcade to work. diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 48f8323e18..09e8459ebb 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -196,7 +196,7 @@ def check_for_collision_with_list( def check_for_collision_with_lists( sprite: BasicSprite, sprite_lists: Iterable[SpriteSequence[SpriteType]], - method=1, + method=0, ) -> list[SpriteType]: """ Check for a collision between a Sprite, and a list of SpriteLists. @@ -207,8 +207,16 @@ def check_for_collision_with_lists( sprite_lists: SpriteLists to check against method: - Collision check method. 1 is Spatial Hashing if available, - 2 is GPU based, 3 is slow CPU-bound check-everything. Defaults to 1. + Collision check method. Defaults to 0. + + - 0: auto-select. (spatial if available, GPU if 1500+ sprites, else simple) + - 1: Spatial Hashing if available, + - 2: GPU based + - 3: Simple check-everything. + + Note that while the GPU method is very fast when you cannot use spatial hashing, + it's also very slow if you are calling this function many times per frame. + What method is the most appropriate depends entirely on your use case. Returns: List of sprites colliding, or an empty list. @@ -224,9 +232,10 @@ def check_for_collision_with_lists( sprites_to_check: Iterable[SpriteType] for sprite_list in sprite_lists: - if sprite_list.spatial_hash is not None and method == 1: + # Spatial + if sprite_list.spatial_hash is not None and (method == 1 or method == 0): sprites_to_check = sprite_list.spatial_hash.get_sprites_near_sprite(sprite) - elif method == 3: + elif method == 3 or (method == 0 and len(sprite_list) <= 1500): sprites_to_check = sprite_list else: # GPU transform From 6cbd908b99b0eccb7f2eb6f6fbcb60b6cf093a8d Mon Sep 17 00:00:00 2001 From: "Yohan Thi." <46330750+TNTY100@users.noreply.github.com> Date: Thu, 11 Sep 2025 00:33:45 -0400 Subject: [PATCH 233/279] Added center_x and center_y to SpriteCircle's constructor (#2766) --- arcade/sprite/colored.py | 16 ++++++++++++++-- tests/unit/sprite/test_sprite_colored.py | 4 +++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/arcade/sprite/colored.py b/arcade/sprite/colored.py index c8e3e7f54f..b348f05506 100644 --- a/arcade/sprite/colored.py +++ b/arcade/sprite/colored.py @@ -139,12 +139,24 @@ class SpriteCircle(Sprite): soft: If ``True``, the circle will fade from an opaque center to transparent edges. + center_x: + Initial x position of the sprite + center_y: + Initial y position of the sprite """ # Local weak cache for textures to avoid creating multiple instances with the same configuration _texture_cache: WeakValueDictionary[tuple[int, RGBA255, bool], Texture] = WeakValueDictionary() - def __init__(self, radius: int, color: RGBA255, soft: bool = False, **kwargs): + def __init__( + self, + radius: int, + color: RGBA255, + soft: bool = False, + center_x: float = 0, + center_y: float = 0, + **kwargs, + ): radius = int(radius) diameter = radius * 2 @@ -168,5 +180,5 @@ def __init__(self, radius: int, color: RGBA255, soft: bool = False, **kwargs): self.__class__._texture_cache[cache_key] = texture # apply results to the new sprite - super().__init__(texture) + super().__init__(texture, center_x=center_x, center_y=center_y) self.color = Color.from_iterable(color) diff --git a/tests/unit/sprite/test_sprite_colored.py b/tests/unit/sprite/test_sprite_colored.py index 0470683118..138668c888 100644 --- a/tests/unit/sprite/test_sprite_colored.py +++ b/tests/unit/sprite/test_sprite_colored.py @@ -7,9 +7,11 @@ def test_sprite_circle_props(): """Test basic properties of SpriteCircle""" - sprite = arcade.SpriteCircle(50, arcade.color.RED) + sprite = arcade.SpriteCircle(50, arcade.color.RED, center_x=5, center_y=7) assert sprite.color == arcade.color.RED assert sprite.size == (100, 100) + assert sprite.center_x == 5 + assert sprite.center_y == 7 def test_sprite_circle_texture_cache(): From cdf329b737724807e43149c1d880c25accbeef7f Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Sat, 13 Sep 2025 17:59:12 -0400 Subject: [PATCH 234/279] Temp fix texture atlas issues from mypy 1.18.1 (#2768) --- arcade/texture_atlas/atlas_default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index e804083231..9bca6a531f 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -375,7 +375,7 @@ def finalizer_callback(atlas_name, hash): texture.image_data.hash, ) # Don't bother removing texture on program exit - finalizer_ref.atexit = False + finalizer_ref.atexit = False # type: ignore self._finalizers_created += 1 self._textures_added += 1 From c110a4de6eda5bdeb74e2c6e8172888044b3acb8 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:11:38 -0400 Subject: [PATCH 235/279] Update examples to use SpriteCircle's new center_* keyword arguments (#2767) * Use SpriteCircle's center_x and center_y in snow example * Update conway_alpha example with center_* and min * Use center_x and center_y arguments for sprites * Account for grid cells that are short and wide via min * Update easing_example_1 with center_* * Use center_x and center_y arguments * Use shared kwargs to shorten code --- arcade/examples/conway_alpha.py | 75 ++++++++++++++++++----------- arcade/examples/easing_example_1.py | 45 +++++++++-------- arcade/examples/snow.py | 26 +++++----- 3 files changed, 86 insertions(+), 60 deletions(-) diff --git a/arcade/examples/conway_alpha.py b/arcade/examples/conway_alpha.py index 5123cca7c5..d077eb638f 100644 --- a/arcade/examples/conway_alpha.py +++ b/arcade/examples/conway_alpha.py @@ -8,7 +8,9 @@ typing: python -m arcade.examples.conway_alpha """ + import arcade +from arcade import SpriteCircle, SpriteList import random # Set how many rows and columns we will have @@ -35,34 +37,47 @@ ALPHA_OFF = 0 -def create_grids(): +def create_grids( + cell_size: tuple[int, int] = (CELL_WIDTH, CELL_HEIGHT), cell_margin: int = CELL_MARGIN +): """ Create a 2D and 1D grid of sprites. We use the 1D SpriteList for drawing, and the 2D list for accessing via grid. Both lists point to the same set of sprites. """ # One dimensional list of all sprites in the two-dimensional sprite list - grid_sprites_one_dim = arcade.SpriteList() + grid_sprites_one_dim: SpriteList[SpriteCircle] = SpriteList() # This will be a two-dimensional grid of sprites to mirror the two # dimensional grid of numbers. This points to the SAME sprites that are # in grid_sprite_list, just in a 2d manner. - grid_sprites_two_dim = [] + grid_sprites_two_dim: list[list[SpriteCircle]] = [] + + # Calculate values we'll re-use below + cell_width, cell_height = cell_size + half_width = cell_width // 2 + half_height = cell_height // 2 + + x_step = cell_width + cell_margin + y_step = cell_height + cell_margin + + center_offset_x = half_width + cell_margin + center_offset_y = half_height + cell_margin + + # Fit sprites into the cell size + radius = min(half_width, half_height) # Create a list of sprites to represent each grid location for row in range(ROW_COUNT): grid_sprites_two_dim.append([]) for column in range(COLUMN_COUNT): + # Position the sprite + x = column * x_step + center_offset_x + y = row * y_step + center_offset_y # Make the sprite as a soft circle - sprite = arcade.SpriteCircle(CELL_WIDTH // 2, ALIVE_COLOR, soft=True) - - # Position the sprite - x = column * (CELL_WIDTH + CELL_MARGIN) + (CELL_WIDTH / 2 + CELL_MARGIN) - y = row * (CELL_HEIGHT + CELL_MARGIN) + (CELL_HEIGHT / 2 + CELL_MARGIN) - sprite.center_x = x - sprite.center_y = y + sprite = SpriteCircle(radius, ALIVE_COLOR, True, center_x=x, center_y=y) # Add the sprite to both lists grid_sprites_one_dim.append(sprite) @@ -72,7 +87,7 @@ def create_grids(): def randomize_grid(grid: arcade.SpriteList): - """ Randomize the grid to alive/dead """ + """Randomize the grid to alive/dead""" for cell in grid: pick = random.randrange(2) if pick: @@ -106,24 +121,24 @@ def __init__(self): randomize_grid(self.layers_grid_sprites_one_dim[0]) def reset(self): - """ Reset the grid """ + """Reset the grid""" randomize_grid(self.layers_grid_sprites_one_dim[0]) def on_draw(self): - """ Render the screen. """ + """Render the screen.""" # Clear all pixels in the window self.clear() self.layers_grid_sprites_one_dim[0].draw() def on_key_press(self, symbol: int, modifiers: int): - """ Handle key press events """ + """Handle key press events""" if symbol == arcade.key.SPACE: self.reset() elif symbol == arcade.key.ESCAPE: self.window.close() def on_update(self, delta_time: float): - """ Update the grid """ + """Update the grid""" # Flip layers if self.cur_layer == 0: @@ -140,31 +155,37 @@ def on_update(self, delta_time: float): for column in range(COLUMN_COUNT): live_neighbors = 0 # -1 -1 - if row > 0 and column > 0 \ - and layer1[row - 1][column - 1].alpha == ALPHA_ON: + if row > 0 and column > 0 and layer1[row - 1][column - 1].alpha == ALPHA_ON: live_neighbors += 1 # -1 0 if row > 0 and layer1[row - 1][column].alpha == ALPHA_ON: live_neighbors += 1 # -1 +1 - if row > 0 and column < COLUMN_COUNT - 1\ - and layer1[row - 1][column + 1].alpha == ALPHA_ON: + if ( + row > 0 + and column < COLUMN_COUNT - 1 + and layer1[row - 1][column + 1].alpha == ALPHA_ON + ): live_neighbors += 1 # 0 +1 - if column < COLUMN_COUNT - 1 \ - and layer1[row][column + 1].alpha == ALPHA_ON: + if column < COLUMN_COUNT - 1 and layer1[row][column + 1].alpha == ALPHA_ON: live_neighbors += 1 # +1 +1 - if row < ROW_COUNT - 1 \ - and column < COLUMN_COUNT - 1 \ - and layer1[row + 1][column + 1].alpha == ALPHA_ON: + if ( + row < ROW_COUNT - 1 + and column < COLUMN_COUNT - 1 + and layer1[row + 1][column + 1].alpha == ALPHA_ON + ): live_neighbors += 1 # +1 0 if row < ROW_COUNT - 1 and layer1[row + 1][column].alpha == ALPHA_ON: live_neighbors += 1 # +1 -1 - if row < ROW_COUNT - 1 and column > 0 \ - and layer1[row + 1][column - 1].alpha == ALPHA_ON: + if ( + row < ROW_COUNT - 1 + and column > 0 + and layer1[row + 1][column - 1].alpha == ALPHA_ON + ): live_neighbors += 1 # 0 -1 if column > 0 and layer1[row][column - 1].alpha == ALPHA_ON: @@ -194,7 +215,7 @@ def on_update(self, delta_time: float): def main(): - """ Main function """ + """Main function""" # Create a window class. This is what actually shows up on screen window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) window.center_window() diff --git a/arcade/examples/easing_example_1.py b/arcade/examples/easing_example_1.py index 26c9e7eaaf..032d1d1021 100644 --- a/arcade/examples/easing_example_1.py +++ b/arcade/examples/easing_example_1.py @@ -10,6 +10,7 @@ If Python and Arcade are installed, this example can be run from the command line with: python -m arcade.examples.easing_example_1 """ + import arcade from arcade import easing from arcade.types import Color @@ -34,13 +35,13 @@ class EasingCircle(arcade.SpriteCircle): - """ Player class """ + """Player class""" - def __init__(self, radius, color): - """ Set up the player """ + def __init__(self, radius, color, center_x: float = 0, center_y: float = 0): + """Set up the player""" # Call the parent init - super().__init__(radius, color) + super().__init__(radius, color, center_x=center_x, center_y=center_y) self.easing_x_data = None self.easing_y_data = None @@ -52,10 +53,12 @@ def update(self, delta_time: float = 1 / 60): x = X_START if self.center_x < WINDOW_WIDTH / 2: x = X_END - ex, ey = easing.ease_position(self.position, - (x, self.center_y), - rate=180, - ease_function=self.easing_x_data.ease_function) + ex, ey = easing.ease_position( + self.position, + (x, self.center_y), + rate=180, + ease_function=self.easing_x_data.ease_function, + ) self.easing_x_data = ex if self.easing_y_data is not None: @@ -65,10 +68,10 @@ def update(self, delta_time: float = 1 / 60): class GameView(arcade.View): - """ Main application class. """ + """Main application class.""" def __init__(self): - """ Initializer """ + """Initializer""" # Call the parent class initializer super().__init__() @@ -81,15 +84,16 @@ def __init__(self): self.lines = None def setup(self): - """ Set up the game and initialize the variables. """ + """Set up the game and initialize the variables.""" # Sprite lists self.ball_list = arcade.SpriteList() self.lines = arcade.shape_list.ShapeElementList() + color = Color.from_hex_string(BALL_COLOR) + shared_ball_kwargs = dict(radius=BALL_RADIUS, color=color) def create_ball(ball_y, ease_function): - ball = EasingCircle(BALL_RADIUS, Color.from_hex_string(BALL_COLOR)) - ball.position = X_START, ball_y + ball = EasingCircle(**shared_ball_kwargs, center_x=X_START, center_y=ball_y) p1 = ball.position p2 = (X_END, ball_y) ex, ey = easing.ease_position(p1, p2, time=TIME, ease_function=ease_function) @@ -100,9 +104,12 @@ def create_ball(ball_y, ease_function): def create_line(line_y): line = arcade.shape_list.create_line( - X_START, line_y - BALL_RADIUS - LINE_WIDTH, - X_END, line_y - BALL_RADIUS, - line_color, line_width=LINE_WIDTH, + X_START, + line_y - BALL_RADIUS - LINE_WIDTH, + X_END, + line_y - BALL_RADIUS, + line_color, + line_width=LINE_WIDTH, ) return line @@ -161,7 +168,7 @@ def add_item(item_y, ease_function, text): add_item(y, easing.ease_in_out_sin, "Ease in out sin") def on_draw(self): - """ Render the screen. """ + """Render the screen.""" # This command has to happen before we start drawing self.clear() @@ -175,7 +182,7 @@ def on_draw(self): text.draw() def on_update(self, delta_time): - """ Movement and game logic """ + """Movement and game logic""" # Call update on all sprites (The sprites don't do much in this # example though.) @@ -183,7 +190,7 @@ def on_update(self, delta_time): def main(): - """ Main function """ + """Main function""" # Create a window class. This is what actually shows up on screen window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) diff --git a/arcade/examples/snow.py b/arcade/examples/snow.py index f151c2e350..35163073e1 100644 --- a/arcade/examples/snow.py +++ b/arcade/examples/snow.py @@ -25,8 +25,8 @@ class Snowflake(arcade.SpriteCircle): Based on drawing filled-circles. """ - def __init__(self, size, speed, drift): - super().__init__(size, arcade.color.WHITE) + def __init__(self, size, speed, drift, center_x: float = 0, center_y: float = 0): + super().__init__(size, arcade.color.WHITE, center_x=center_x, center_y=center_y) self.speed = speed self.drift = drift @@ -37,7 +37,7 @@ def reset_pos(self): random.randrange(WINDOW_HEIGHT, WINDOW_HEIGHT + 100), ) - def update(self, delta_time: float = 1/60) -> None: + def update(self, delta_time: float = 1 / 60) -> None: self.center_y -= self.speed * delta_time # Check if snowflake has fallen below screen @@ -50,10 +50,10 @@ def update(self, delta_time: float = 1/60) -> None: class GameView(arcade.View): - """ Main application class. """ + """Main application class.""" def __init__(self): - """ Initializer """ + """Initializer""" # Calls "__init__" of parent class (arcade.Window) to setup screen super().__init__() @@ -66,24 +66,22 @@ def __init__(self): self.background_color = arcade.color.BLACK def start_snowfall(self): - """ Set up snowfall and initialize variables. """ + """Set up snowfall and initialize variables.""" for i in range(SNOWFLAKE_COUNT): # Create snowflake instance snowflake = Snowflake( size=random.randrange(1, 4), speed=random.randrange(20, 40), drift=random.uniform(math.pi, math.pi * 2), - ) - # Randomly position snowflake - snowflake.position = ( - random.randrange(WINDOW_WIDTH), - random.randrange(WINDOW_HEIGHT + 200), + # Randomly position snowflake + center_x=random.randrange(WINDOW_WIDTH), + center_y=random.randrange(WINDOW_HEIGHT + 200), ) # Add snowflake to snowflake list self.snowflake_list.append(snowflake) def on_draw(self): - """ Render the screen. """ + """Render the screen.""" # Clear the screen to the background color self.clear() @@ -91,13 +89,13 @@ def on_draw(self): self.snowflake_list.draw() def on_update(self, delta_time): - """ All the logic to move, and the game logic goes here. """ + """All the logic to move, and the game logic goes here.""" # Call update on all the snowflakes self.snowflake_list.update(delta_time) def main(): - """ Main function """ + """Main function""" # Create a window class. This is what actually shows up on screen window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) From e00e5e7912661f0cf2b64ca1002045e67b49f0d8 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:23:25 -0400 Subject: [PATCH 236/279] Pin click version to fix typer breaking (#2774) --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b840af03f0..3f1c0535bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,11 +54,12 @@ dev = [ "pytest-cov", "pytest-mock", "coverage", - "coveralls", # Do we really need this? + "coveralls", # Do we really need this? "ruff", "mypy", "pyright==1.1.387", - "typer[all]==0.12.5", # Needed for make.py + "click==8.1.7", # Temp fix until we bump typer + "typer==0.12.5", # Needed for make.py "wheel", ] # Testing only From a6c30b22f17822960813dd77d4eefff2e5321198 Mon Sep 17 00:00:00 2001 From: Adithya Date: Thu, 2 Oct 2025 21:57:15 +0530 Subject: [PATCH 237/279] Fix typos in Sound API documentation #2769 (#2772) --- arcade/sound.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/arcade/sound.py b/arcade/sound.py index 5eac3d2cdd..c371c11a01 100644 --- a/arcade/sound.py +++ b/arcade/sound.py @@ -94,7 +94,7 @@ def play( .. important:: A :py:class:`Sound` with ``streaming=True`` loses features! Neither ``loop`` nor simultaneous playbacks will work. See - :py;class:`Sound` and :ref:`sound-loading-modes`. + :py:class:`Sound` and :ref:`sound-loading-modes`. Args: volume: Volume (``0.0`` is silent, ``1.0`` is loudest). @@ -220,7 +220,7 @@ def load_sound(path: str | Path, streaming: bool = False) -> Sound: .. important:: A :py:class:`Sound` with ``streaming=True`` loses features! Neither ``loop`` nor simultaneous playbacks will work. See - :py;class:`Sound` and :ref:`sound-loading-modes`. + :py:class:`Sound` and :ref:`sound-loading-modes`. Args: path: a path which may be prefixed with a @@ -230,7 +230,7 @@ def load_sound(path: str | Path, streaming: bool = False) -> Sound: save memory, ``False`` for short sounds to speed playback. Returns: - A :ref:playable ` instance of a + A :ref:`playable ` instance of a :py:class:`Sound` object. """ # Initialize the audio driver if it hasn't been already. @@ -264,7 +264,7 @@ def play_sound( .. important:: A :py:class:`Sound` with ``streaming=True`` loses features! Neither ``loop`` nor simultaneous playbacks will work. See - :py;class:`Sound` and :ref:`sound-loading-modes`. + :py:class:`Sound` and :ref:`sound-loading-modes`. The output and return value depend on whether playback succeeded: .. # Note: substitutions don't really work inside tables, so the From e55f2b73ca2f84b7a458d650eb79d1b7481492ae Mon Sep 17 00:00:00 2001 From: Vincent Poulailleau Date: Mon, 6 Oct 2025 19:43:32 +0200 Subject: [PATCH 238/279] typo in about/for_academia.rst (#2776) --- doc/about/for_academia.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/about/for_academia.rst b/doc/about/for_academia.rst index 30966de79e..afdd1ec20d 100644 --- a/doc/about/for_academia.rst +++ b/doc/about/for_academia.rst @@ -120,7 +120,7 @@ applies: SBC Purchasing Rules of Thumb ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. note:: These rules help **non-experts** steer toward Arcade-copatible devices. +.. note:: These rules help **non-experts** steer toward Arcade-compatible devices. You can find more in-depth descriptions of the required OpenGL ES versions and more under the :ref:`sbc_requirements` heading. From 34b50dea834bda81033a1cf9e82a8a7a97f74e46 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:15:52 -0400 Subject: [PATCH 239/279] Enable Python 3.14 by updating to pillow 11.3.X and adding 3.14 to CI (#2777) * Add Python 3.14 to self-hosted CI runner workflow * Bump pillow to 11.3.0 to try beta Python 3.14 support --- .github/workflows/selfhosted_runner.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/selfhosted_runner.yml b/.github/workflows/selfhosted_runner.yml index c0caacd8b7..f8597f6a05 100644 --- a/.github/workflows/selfhosted_runner.yml +++ b/.github/workflows/selfhosted_runner.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] architecture: ['x64'] steps: diff --git a/pyproject.toml b/pyproject.toml index 3f1c0535bc..00b2e66183 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ ] dependencies = [ "pyglet~=2.1.5", - "pillow~=11.0.0", + "pillow~=11.3.0", "pymunk~=6.9.0", "pytiled-parser~=2.2.9", ] From 3308bd97230ebbfa8796b97f2b8d313c135f6991 Mon Sep 17 00:00:00 2001 From: Miles Curry <2590700+MiCurry@users.noreply.github.com> Date: Wed, 8 Oct 2025 22:36:42 -0600 Subject: [PATCH 240/279] Check if Controller device is open before opening (#2779) Before this commit, the `__init__` of the `InputManager` could fail if a controller had already been open. This could occur if an `InputManager` was instantiated twice. This commit closes issue #2778. --- arcade/future/input/manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/arcade/future/input/manager.py b/arcade/future/input/manager.py index a530907be1..4d996b4aa7 100644 --- a/arcade/future/input/manager.py +++ b/arcade/future/input/manager.py @@ -114,7 +114,9 @@ def __init__( self.controller_deadzone = controller_deadzone if controller: self.controller = controller - self.controller.open() + if not self.controller.device.is_open: + self.controller.open() + self.controller.push_handlers( self.on_button_press, self.on_button_release, From 937c572608aead7d8da23f1837c0c0b6d4807044 Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Thu, 9 Oct 2025 00:57:45 -0400 Subject: [PATCH 241/279] Add Python 3.14 to pyproject classifiers (#2780) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 00b2e66183..9e74a561e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Libraries :: Python Modules", ] From e55fa7bb9b53189e452595c39258085318595caf Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Thu, 9 Oct 2025 00:58:53 -0400 Subject: [PATCH 242/279] Update changelog (#2781) --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7dfe4f269..349f707a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,21 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased +- Support for Python 3.14 - Fixes a bug with the `check_for_collision_with_lists` function. This function is intended to mimic the functionality of `check_for_collision_with_list` but allow passing multiple lists and looping the same behavior. The `lists` function however handled the collision method differently. Which resulted in only spatial hash being used if it was available, or GPU collision. It would never fallback to the pure CPU brute force approach, which is the best option for spritelists which don't have spatial hash and less than 1,500 sprites. Certain games may see a substantial performance improvement from this change. See [2762](https://github.com/pythonarcade/arcade/pull/2762) +- Added `center_x` and `center_y` arguments to `arcade.SpriteCircle`. See [2766](https://github.com/pythonarcade/arcade/pull/2766) +- Added a `rect` property to `arcade.Text` objects which will return an `arcade.Rect` based on the `left`, `right`, `bottom`, and `top` values of the Text object. See [2759](https://github.com/pythonarcade/arcade/pull/2759) + +- Camera + - Fixes the position flag in `Camera2D.match_window` to so (0, 0) as the bottom left, instead of matching the center. See [2646](https://github.com/pythonarcade/arcade/pull/2646) - PyInstaller - Fixed an issue where imports for backends for the `arcade.gl` package could not be discovered by PyInstaller. - Since 3.3.0 users have needed to add these hidden imports via the pyinstaller CLI in order for Arcade to work. + Since 3.3.0 users have needed to add these hidden imports via the pyinstaller CLI in order for Arcade to work. See [2764](https://github.com/pythonarcade/arcade/pull/2764) - GUI - Fix a bug, where the caret of UIInputText was misplaced after resizing the widget From 33e5960bc8cec85b80fdda6fb41f81b9c7e01074 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 9 Oct 2025 10:43:08 -0500 Subject: [PATCH 243/279] Update version (#2783) * Update version * Update version --------- Co-authored-by: Paul V Craven --- CHANGELOG.md | 5 +++-- arcade/VERSION | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 349f707a7e..9350a9ce3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## Unreleased +## 3.3.3 - Support for Python 3.14 - Fixes a bug with the `check_for_collision_with_lists` function. This function is intended to mimic the functionality of @@ -19,7 +19,8 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - PyInstaller - Fixed an issue where imports for backends for the `arcade.gl` package could not be discovered by PyInstaller. - Since 3.3.0 users have needed to add these hidden imports via the pyinstaller CLI in order for Arcade to work. See [2764](https://github.com/pythonarcade/arcade/pull/2764) + Since 3.3.0 users have needed to add these hidden imports via the pyinstaller CLI in order for Arcade to work. + See [2764](https://github.com/pythonarcade/arcade/pull/2764) - GUI - Fix a bug, where the caret of UIInputText was misplaced after resizing the widget diff --git a/arcade/VERSION b/arcade/VERSION index 5436ea06e3..3f09e91095 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.3.2 \ No newline at end of file +3.3.3 \ No newline at end of file From d45476a6704b81df6079df101404720c4d96f9b8 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:12:33 -0400 Subject: [PATCH 244/279] Add NoArcadeWindowError subclassing RuntimeError (#2784) * Add NoArcadeWindowError and have_window function * Add NoArcadeWindowError subclassing RuntimeError * Make arcade.window_commands.get_window() raise NoArcadeWindowError when no window exists * Add arcade.window_commands.have_window() function * Improve documentation of get_window() function * Attempt to make ruff happy * Remove have_window() since the name / location are controversial * Add window_exists function * Fix doc naming --------- Co-authored-by: Darren Eberly --- arcade/__init__.py | 2 ++ arcade/exceptions.py | 10 ++++++++++ arcade/window_commands.py | 31 +++++++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/arcade/__init__.py b/arcade/__init__.py index a5152680d6..968e311bf8 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -90,6 +90,7 @@ def configure_logging(level: int | None = None): from .window_commands import start_render from .window_commands import unschedule from .window_commands import schedule_once +from .window_commands import window_exists from .sections import Section, SectionManager @@ -359,6 +360,7 @@ def configure_logging(level: int | None = None): "create_text_sprite", "clear_timings", "get_window", + "window_exists", "get_fps", "has_line_of_sight", "load_animated_gif", diff --git a/arcade/exceptions.py b/arcade/exceptions.py index a3b651823f..7ba2c8ed17 100644 --- a/arcade/exceptions.py +++ b/arcade/exceptions.py @@ -7,6 +7,7 @@ from typing import TypeVar __all__ = [ + "NoArcadeWindowError", "OutsideRangeError", "IntOutsideRangeError", "FloatOutsideRangeError", @@ -23,6 +24,15 @@ _CT = TypeVar("_CT") # Comparable type, ie supports the <= operator +class NoArcadeWindowError(RuntimeError): + """No valid Arcade window exists. + + It may be handled as a :py:class:`RuntimeError`. + """ + + ... + + class OutsideRangeError(ValueError): """ Raised when a value is outside and expected range diff --git a/arcade/window_commands.py b/arcade/window_commands.py index 99d2f41c05..13dc5c5533 100644 --- a/arcade/window_commands.py +++ b/arcade/window_commands.py @@ -14,6 +14,7 @@ import pyglet +from arcade.exceptions import NoArcadeWindowError from arcade.types import RGBA255, Color if TYPE_CHECKING: @@ -26,6 +27,7 @@ "get_display_size", "get_window", "set_window", + "window_exists", "close_window", "run", "exit", @@ -55,13 +57,19 @@ def get_display_size(screen_id: int = 0) -> tuple[int, int]: def get_window() -> Window: - """ - Return a handle to the current window. + """Return a handle to the current window. + + If no window exists, it will raise an exception you can + handle as a :py:class:`RuntimeError`. Use :py:func:`window_exists` + to prevent raising an exception. - :return: Handle to the current window. + Raises: + :py:class:`~arcade.exceptions.NoArcadeWindowError` when no window exists. """ if _window is None: - raise RuntimeError("No window is active. It has not been created yet, or it was closed.") + raise NoArcadeWindowError( + "No window is active. It has not been created yet, or it was closed." + ) return _window @@ -77,6 +85,21 @@ def set_window(window: Window | None) -> None: _window = window +def window_exists() -> bool: + """ + Returns True or False based on wether there is currently a Window. + + Returns: + Boolean for if a window exists. + """ + try: + get_window() + except NoArcadeWindowError: + return False + + return True + + def close_window() -> None: """ Closes the current window, and then runs garbage collection. The garbage collection From fe9b113b535e9f47b73ce99c15f0e4f5b7a3036f Mon Sep 17 00:00:00 2001 From: Vincent Poulailleau Date: Wed, 5 Nov 2025 18:31:31 +0100 Subject: [PATCH 245/279] Update captions for framebuffer tutorial steps (#2788) --- doc/tutorials/framebuffer/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/tutorials/framebuffer/index.rst b/doc/tutorials/framebuffer/index.rst index 757c5d1a97..f54c108d9c 100644 --- a/doc/tutorials/framebuffer/index.rst +++ b/doc/tutorials/framebuffer/index.rst @@ -16,11 +16,11 @@ Then create a simple program with a frame buffer: Now, color everything that doesn't have an alpha of zero as green: .. literalinclude:: step_02.py - :caption: Pass-through frame buffer + :caption: Green where alpha is not zero in the FBO :linenos: Something about passing uniform data to the shader: .. literalinclude:: step_03.py - :caption: Pass-through frame buffer + :caption: Passing uniform data to the shader :linenos: From 6496a3e8e7645a358457a6dc2b375160d5327275 Mon Sep 17 00:00:00 2001 From: Paul <36696816+pushfoo@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:55:34 -0500 Subject: [PATCH 246/279] Bump to Pillow 12.0.0 for full Python 3.14 support (#2792) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9e74a561e8..9671ae48dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ ] dependencies = [ "pyglet~=2.1.5", - "pillow~=11.3.0", + "pillow~=12.0.0", "pymunk~=6.9.0", "pytiled-parser~=2.2.9", ] From 225a1919c971fe38848713fc1fb6baf04e2cf83f Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Fri, 7 Nov 2025 21:07:57 -0500 Subject: [PATCH 247/279] Changelog Updates (#2793) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9350a9ce3a..76220abe57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. +## Unreleased + +- Upgraded Pillow to 12.0.0 for Python 3.14 support. +- Adds a new `arcade.NoAracdeWindowError` exception type. This is raised when certain window operations are performed and there is no valid Arcade window found. Previously where this error would be raised, we raised a standard `RuntimeError`, this made it harder to properly catch and act accordingly. This new exception subclasses `RuntimeError`, so you can still catch this error the same way as before. The `arcade.get_window()` function will now raise this if there is no window. +- Along with the new exception type, is a new `arcade.windows_exists()` function which will return True or False based on if there is currently an active window. + ## 3.3.3 - Support for Python 3.14 From 9df5ffb7f14512cb7832292a757fa180db8c804f Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Wed, 26 Nov 2025 16:20:37 +0100 Subject: [PATCH 248/279] Gui/fixes (#2795) * Fix UIManager to correctly handle size hint of (0, 0) * Refactor UIScrollArea to allow multiple children and detect UIScrollArea in UIDropdown * Update CHANGELOG.md to reflect UIScrollArea enhancements and UIDropdown positioning fix --- CHANGELOG.md | 4 ++++ arcade/gui/experimental/scroll_area.py | 3 --- arcade/gui/ui_manager.py | 4 ++-- arcade/gui/widgets/dropdown.py | 21 +++++++++++++++------ tests/unit/gui/test_uimanager_layouting.py | 5 +++++ 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76220abe57..8b5c84811a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Upgraded Pillow to 12.0.0 for Python 3.14 support. - Adds a new `arcade.NoAracdeWindowError` exception type. This is raised when certain window operations are performed and there is no valid Arcade window found. Previously where this error would be raised, we raised a standard `RuntimeError`, this made it harder to properly catch and act accordingly. This new exception subclasses `RuntimeError`, so you can still catch this error the same way as before. The `arcade.get_window()` function will now raise this if there is no window. - Along with the new exception type, is a new `arcade.windows_exists()` function which will return True or False based on if there is currently an active window. +- GUI + - `UIManager` did not apply size hint of (0,0). Mainly an issue with `UIBoxLayout`. + - Allow multiple children in `UIScrollArea`. + - Fix `UIDropdown Overlay` positioning within a `UIScrollArea`. ## 3.3.3 diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index bd7db75412..3bee0c13e9 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -239,9 +239,6 @@ def __init__( def add(self, child: W, **kwargs) -> W: """Add a child to the widget.""" - if self._children: - raise ValueError("UIScrollArea can only have one child") - super().add(child, **kwargs) self.trigger_full_render() diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 3a5707d47d..f7b6500d5f 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -246,8 +246,8 @@ def _do_layout(self): # actual layout if child.size_hint: sh_x, sh_y = child.size_hint - nw = surface_width * sh_x if sh_x else None - nh = surface_height * sh_y if sh_y else None + nw = surface_width * sh_x if sh_x is not None else None + nh = surface_height * sh_y if sh_y is not None else None child.rect = child.rect.resize(nw, nh, anchor=AnchorPoint.BOTTOM_LEFT) if child.size_hint_min: diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index afa75c495a..43e88358a9 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -6,6 +6,7 @@ from arcade import uicolor from arcade.gui import UIEvent, UIMousePressEvent from arcade.gui.events import UIControllerButtonPressEvent, UIOnChangeEvent, UIOnClickEvent +from arcade.gui.experimental import UIScrollArea from arcade.gui.experimental.focus import UIFocusMixin from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UILayout, UIWidget @@ -21,7 +22,7 @@ class _UIDropdownOverlay(UIFocusMixin, UIBoxLayout): # TODO move also options logic to this class - def show(self, manager: UIManager): + def show(self, manager: UIManager | UIScrollArea): manager.add(self, layer=UIManager.OVERLAY_LAYER) def hide(self): @@ -197,11 +198,19 @@ def _update_options(self): self._overlay.detect_focusable_widgets() def _show_overlay(self): - manager = self.get_ui_manager() - if manager is None: - raise Exception("UIDropdown could not find UIManager in its parents.") - - self._overlay.show(manager) + # traverse parents until UIManager or UIScrollArea is found + parent = self.parent + while parent is not None: + if isinstance(parent, UIManager): + break + if isinstance(parent, UIScrollArea): + break + parent = parent.parent + + if parent is None: + raise Exception("UIDropdown could not find a valid parent for the overlay.") + + self._overlay.show(parent) def _on_button_click(self, _: UIOnClickEvent): self._show_overlay() diff --git a/tests/unit/gui/test_uimanager_layouting.py b/tests/unit/gui/test_uimanager_layouting.py index 1f6487867b..f9f29ee409 100644 --- a/tests/unit/gui/test_uimanager_layouting.py +++ b/tests/unit/gui/test_uimanager_layouting.py @@ -25,9 +25,13 @@ def test_supports_size_hint(window): widget3 = UIDummy() widget3.size_hint = (1, None) + widget4 = UIDummy() + widget4.size_hint = (0, 0) + manager.add(widget1) manager.add(widget2) manager.add(widget3) + manager.add(widget4) with sized(window, 200, 300): manager.draw() @@ -35,6 +39,7 @@ def test_supports_size_hint(window): assert widget1.size == Vec2(200, 300) assert widget2.size == Vec2(100, 75) assert widget3.size == Vec2(200, 100) + assert widget4.size == Vec2(0, 0) def test_supports_size_hint_min(window): From 6efb32cc6268597ef4b4e9e64c59817ecb6f6a64 Mon Sep 17 00:00:00 2001 From: Miles Curry <2590700+MiCurry@users.noreply.github.com> Date: Sat, 13 Dec 2025 18:16:01 -0700 Subject: [PATCH 249/279] Fix broken link in procedural caves BSP (#2798) It appears rougelikedevelopment.org is no more. Perhaps it is now rougebasin.com? Regardless, this replaces the dead link with a good one. --- arcade/examples/procedural_caves_bsp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/examples/procedural_caves_bsp.py b/arcade/examples/procedural_caves_bsp.py index fe6d05c196..17974acbd2 100644 --- a/arcade/examples/procedural_caves_bsp.py +++ b/arcade/examples/procedural_caves_bsp.py @@ -3,7 +3,7 @@ Binary Space Partitioning (BSP) For more information, see: -https://roguebasin.roguelikedevelopment.org/index.php?title=Basic_BSP_Dungeon_generation +https://www.roguebasin.com/index.php?title=Basic_BSP_Dungeon_generation https://github.com/DanaL/RLDungeonGenerator If Python and Arcade are installed, this example can be run from the command line with: From 4b9d5157faf3a91293bf48398c5a4e1d493f6a18 Mon Sep 17 00:00:00 2001 From: "A. J. Andrews" <86714785+DragonMoffon@users.noreply.github.com> Date: Sat, 20 Dec 2025 21:40:51 +1300 Subject: [PATCH 250/279] Camera2d requested additions (#2796) * make camera2D position setter use `pos` not `_pos` and add x, y properties * Add `move_to` method with optional duration as requested by Eruvanos * add `Camera2D.move_by` method to avoid position access costs * add `Camera2D.drag_by` method to allow for accurate dragging * linting and formatting passs * Sphyinxify docs * code block in correct format * better position doc string as part of #2558 * formatting pass for new docstring * arcade uses american spelling much to my dismay * also remove alias of British spelling * improve camera init position logic and add aspect argument to init * formatting pass * additionally exception when aspect == 0 in update values * aspect ratio unit tests * fix viewport not respecting aspect ratio and redundant rect reaction found this issue with unit tests hurra * unit test formating --- arcade/camera/camera_2d.py | 188 ++++++++++++++++++++++++++--- tests/unit/camera/test_camera2d.py | 20 +++ 2 files changed, 191 insertions(+), 17 deletions(-) diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index 52e8096aa2..d337c4e943 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -2,7 +2,7 @@ from collections.abc import Generator from contextlib import contextmanager -from math import atan2, cos, degrees, radians, sin +from math import atan2, cos, degrees, pow, radians, sin from typing import TYPE_CHECKING from pyglet.math import Vec2, Vec3 @@ -60,7 +60,11 @@ class Camera2D: If the viewport is not 1:1 with the projection then positions in world space won't match pixels on screen. position: - The 2D position of the camera in the XY plane. + The 2D position of the camera. + + This is in world space, so the same as :py:class:`Sprite` and draw commands. + The default projection is a :py:func:`XYWH` rect positioned at (0, 0) so the + position of the camera is the center of the viewport. up: A 2D vector which describes which direction is up (defines the +Y-axis of the camera space). @@ -75,6 +79,11 @@ class Camera2D: The near clipping plane of the camera. far: The far clipping plane of the camera. + aspect: The ratio between width and height that the viewport should + be constrained to. If unset then the viewport just matches the given + size. The aspect ratio describes how much larger the width should be + compared to the height. i.e. for an aspect ratio of ``4:3`` you should + input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. scissor: A ``Rect`` which will crop the camera's output to this area on screen. Unlike the viewport this has no influence on the visuals rendered with @@ -96,6 +105,7 @@ def __init__( near: float = DEFAULT_NEAR_ORTHO, far: float = DEFAULT_FAR, *, + aspect: float | None = None, scissor: Rect | None = None, render_target: Framebuffer | None = None, window: Window | None = None, @@ -111,7 +121,20 @@ def __init__( # but we need to have some form of default size. render_target = render_target or self._window.ctx.screen viewport = viewport or LBWH(*render_target.viewport) - width, height = viewport.size + + if aspect is None: + width, height = viewport.size + elif aspect == 0.0: + raise ZeroProjectionDimension( + "aspect ratio is 0 which will cause invalid viewport dimensions." + ) + elif viewport.height * aspect < viewport.width: + width = viewport.height * aspect + height = viewport.height + else: + width = viewport.width + height = viewport.width / aspect + viewport = XYWH(viewport.x, viewport.y, width, height) half_width = width / 2 half_height = height / 2 @@ -136,8 +159,10 @@ def __init__( f"projection depth is 0 due to equal {near=} and {far=} values" ) - pos_x = position[0] if position is not None else half_width - pos_y = position[1] if position is not None else half_height + # By using -left and -bottom this ensures that (0.0, 0.0) is always + # in the bottom left corner of the viewport + pos_x = position[0] if position is not None else -left + pos_y = position[1] if position is not None else -bottom self._camera_data = CameraData( position=(pos_x, pos_y, 0.0), up=(up[0], up[1], 0.0), @@ -148,7 +173,7 @@ def __init__( left=left, right=right, top=top, bottom=bottom, near=near, far=far ) - self.viewport: Rect = viewport or LRBT(0, 0, width, height) + self.viewport: Rect = viewport """ A rect which describes how the final projection should be mapped from unit-space. defaults to the size of the render_target or window @@ -322,7 +347,7 @@ def unproject(self, screen_coordinate: Point) -> Vec3: _view = generate_view_matrix(self.view_data) return unproject_orthographic(screen_coordinate, self.viewport.lbwh_int, _view, _projection) - def equalise(self) -> None: + def equalize(self) -> None: """ Forces the projection to match the size of the viewport. When matching the projection to the viewport the method keeps @@ -331,8 +356,6 @@ def equalise(self) -> None: x, y = self._projection_data.rect.x, self._projection_data.rect.y self._projection_data.rect = XYWH(x, y, self.viewport_width, self.viewport_height) - equalize = equalise - def match_window( self, viewport: bool = True, @@ -352,7 +375,7 @@ def match_window( scissor: Flag whether to also equalize the scissor box to the viewport. On by default position: Flag whether to position the camera so that (0.0, 0.0) is in - the bottom-left + the bottom-left of the viewport aspect: The ratio between width and height that the viewport should be constrained to. If unset then the viewport just matches the window size. The aspect ratio describes how much larger the width should be @@ -386,7 +409,7 @@ def match_target( The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to position the camera so that (0.0, 0.0) is in - the bottom-left + the bottom-left of the viewport aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. @@ -428,14 +451,18 @@ def update_values( The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to position the camera so that (0.0, 0.0) is in - the bottom-left + the bottom-left of the viewport aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. If unset then the value will not be updated. """ if aspect is not None: - if value.height * aspect < value.width: + if aspect == 0.0: + raise ZeroProjectionDimension( + "aspect ratio is 0 which will cause invalid viewport dimensions." + ) + elif value.height * aspect < value.width: w = value.height * aspect h = value.height else: @@ -454,7 +481,11 @@ def update_values( self.scissor = value if position: - self.position = Vec2(-self._projection_data.left, -self._projection_data.bottom) + self._camera_data.position = ( + -self._projection_data.left, + -self._projection_data.bottom, + self._camera_data.position[2], + ) def aabb(self) -> Rect: """ @@ -512,6 +543,103 @@ def point_in_view(self, point: Point2) -> bool: return abs(dot_x) <= h_width and abs(dot_y) <= h_height + def move_to(self, position: Point2, *, duration: float | None = None) -> Point2: + """ + Move the camera to the provided position. + If duration is None this is the same as setting camera.position. + duration makes it easy to move the camera smoothly over time. + + When duration is not None it uses :py:func:`arcade.math.smerp` method + to smoothly move to the target position. This means duration does NOT + equal the fraction to move. To make the motion frame rate independant + use ``duration = dt * T`` where ``T`` is the number of seconds to move + half the distance to the target position. + + Args: + position: x, y position in world space to move too + duration: The number of frames it takes to approximately move half-way + to the target position + + Returns: + The actual position the camera was set too. + """ + if duration is None: + x, y = position + self._camera_data.position = (x, y, self._camera_data.position[2]) + return position + + x1, y1, z1 = self._camera_data.position + x2, y2 = position + d = pow(2, -duration) + x = x1 + (x2 - x1) * d + y = y1 + (y2 - y1) * d + + self._camera_data.position = (x, y, z1) + return x, y + + def move_by(self, change: Point2) -> Point2: + """ + Move the camera in world space along the XY axes by the provided change. + If you want to drag the camera with a mouse :py:func:`camera2D.drag_by` + is the method to use. + + Args: + change: amount to move XY position in world space + + Returns: + final XY position of the camera + """ + pos = self._camera_data.position + new = pos[0] + change[0], pos[1] + change[1] + self._camera_data.position = new[0], new[1], pos[2] + return new + + def drag_by(self, change: Point2) -> Point2: + """ + Move the camera in world space by an amount in screen space. + This is a utility method to make it easy to drag the camera correctly. + normally zooming in/out, rotating the camera, and using a non 1:1 projection + causes the mouse dragging to desync with the camera motion. It automatically + negates the change so the change represents the amount the camera appears + to move. This is because moving the camera left makes everything appear to + move right. So a user moving the mouse right expects the camera to move + left. + + The simplest use case is with the Window/View's :py:func:`on_mouse_drag` + .. code-block:: python + + def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): + self.camera.drag_by((dx, dy)) + + .. warning:: This method is more expensive than :py:func:`Camera2D.move_by` so + use only when needed. If your camera is 1:1 with the screen and you + only zoom in and out you can get away with + ``camera2D.move_by(-change / camera.zoom)``. + + .. warning:: This method must assume that viewport has the same pixel scale as the + window. If you are doing some form of upscaling you will have to scale + the mouse dx and dy by the difference in pixel scale. + + Args: + change: The number of pixels to move the camera by + + Returns: + The final position of the camera. + """ + + # Early exit to avoid expensive matrix generation + if change[0] == 0.0 and change[1] == 0.0: + return self._camera_data.position[0], self._camera_data.position[1] + + x0, y0, _ = self.unproject((0, 0)) + xc, yc, _ = self.unproject(change) + + dx, dy = xc - x0, yc - y0 + pos = self._camera_data.position + new = pos[0] - dx, pos[1] - dy + self._camera_data.position = new[0], new[1], pos[2] + return new + @property def view_data(self) -> CameraData: """The view data for the camera. @@ -547,17 +675,43 @@ def projection_data(self) -> OrthographicProjectionData: @property def position(self) -> Vec2: - """The 2D world position of the camera along the X and Y axes.""" + """ + The 2D position of the camera. + + This is in world space, so the same as :py:class:`Sprite` and draw commands. + The default projection is a :py:func:`XYWH` rect positioned at (0, 0) so the + position of the camera is the center of the viewport. + """ return Vec2(self._camera_data.position[0], self._camera_data.position[1]) # Setter with different signature will cause mypy issues # https://github.com/python/mypy/issues/3004 @position.setter - def position(self, _pos: Point) -> None: - x, y, *_z = _pos + def position(self, pos: Point) -> None: + x, y, *_z = pos z = self._camera_data.position[2] if not _z else _z[0] self._camera_data.position = (x, y, z) + @property + def x(self) -> float: + """The 2D world position of the camera along the X axis""" + return self._camera_data.position[0] + + @x.setter + def x(self, x: float) -> None: + pos = self._camera_data.position + self._camera_data.position = (x, pos[1], pos[2]) + + @property + def y(self) -> float: + """The 2D world position of the camera along the Y axis""" + return self._camera_data.position[1] + + @y.setter + def y(self, y: float) -> None: + pos = self._camera_data.position + self._camera_data.position = (pos[0], y, pos[2]) + @property def projection(self) -> Rect: """Get/set the left, right, bottom, and top projection values. diff --git a/tests/unit/camera/test_camera2d.py b/tests/unit/camera/test_camera2d.py index 0760405075..5ab9ad752e 100644 --- a/tests/unit/camera/test_camera2d.py +++ b/tests/unit/camera/test_camera2d.py @@ -82,6 +82,20 @@ def test_camera2d_init_inheritance_safety(window: Window, camera_class): assert isinstance(subclassed, Camera2DSub1) +ASPECT_RATIOS = (1.0, 4.0 / 3.0, 16.0 / 9.0, 16.0 / 10.0) + + +def test_camera2d_init_aspect_equal_0_raises_zeroprojectiondimension(window: Window): + with pytest.raises(ZeroProjectionDimension): + camera = Camera2D(aspect=0.0) + + +@pytest.mark.parametrize("aspect", ASPECT_RATIOS) +def test_camera2d_init_respects_aspect_ratio(window: Window, aspect): + ortho_camera = Camera2D(aspect=aspect) + assert ortho_camera.viewport_width / ortho_camera.viewport_height == pytest.approx(aspect) + + RENDER_TARGET_SIZES = [ (800, 600), # Normal window size (1280, 720), # Bigger @@ -105,6 +119,9 @@ def test_camera2d_init_uses_render_target_size(window: Window, width, height): assert ortho_camera.viewport_bottom == 0 assert ortho_camera.viewport_top == height + assert ortho_camera.position.x == width / 2.0 + assert ortho_camera.position.y == height / 2.0 + @pytest.mark.parametrize("width, height", RENDER_TARGET_SIZES) def test_camera2d_from_camera_data_uses_render_target_size(window: Window, width, height): @@ -122,6 +139,9 @@ def test_camera2d_from_camera_data_uses_render_target_size(window: Window, width assert ortho_camera.viewport_bottom == 0 assert ortho_camera.viewport_top == height + assert ortho_camera.position.x == width / 2.0 + assert ortho_camera.position.y == height / 2.0 + def test_move_camera_and_project(window: Window): camera = Camera2D() From f3de4d9cbdfc535f1b0a791da3445b019f86e56e Mon Sep 17 00:00:00 2001 From: Miles Curry <2590700+MiCurry@users.noreply.github.com> Date: Tue, 23 Dec 2025 06:24:53 -0700 Subject: [PATCH 251/279] Raise TypeError if Viewport is not a Rect and make Viewport (#2790) * Make viewport a property/setter and raise TypeError if not a Rect This commit makes viewport a property of Camera2D and adds a setter for it. In the setter, and in __init__, we check that Viewport is a Rect and raise a TypeError if it is not. Without this check, an error will be raised after either calling `Camera2D.equalise()` or in `Camera2D.use()`, which may confuse users as to why the error is occurring. Backtrace when calling `Camera2D.equalise()` with a non-rect viewport: ``` File "E:\Projects\SpaceGame\SpaceGame\gamemodes\basegame.py", line 128, in setup_two_player_cameras player_one_camera.equalise() File "E:\Programs\python-arcade\arcade\camera\camera_2d.py", line 336, in equalise self._projection_data.rect = XYWH(x, y, self.viewport_width, self.viewport_height) ^^^^^^^^^^^^^^^^^^^ File "E:\Programs\python-arcade\arcade\camera\camera_2d.py", line 751, in viewport_width return int(self._viewport.width) ``` Backtrace when calling `Camera2D.use()` with a non-rect viewport: ``` File "E:\Projects\SpaceGame\SpaceGame\gamemodes\pvp.py", line 139, in on_draw self.cameras[player].use() File "E:\Programs\python-arcade\arcade\camera\camera_2d.py", line 271, in use self._window.ctx.viewport = self._viewport.lbwh_int ``` * Refactor viewport assignment and type check in camera_2d.py --------- Co-authored-by: Maic Siemering --- arcade/camera/camera_2d.py | 55 +++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index d337c4e943..e55a603985 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -122,6 +122,9 @@ def __init__( render_target = render_target or self._window.ctx.screen viewport = viewport or LBWH(*render_target.viewport) + if not isinstance(viewport, Rect): + raise TypeError("viewport must be a Rect type,use arcade.LBWH or arcade.types.Viewport") + if aspect is None: width, height = viewport.size elif aspect == 0.0: @@ -135,6 +138,7 @@ def __init__( width = viewport.width height = viewport.width / aspect viewport = XYWH(viewport.x, viewport.y, width, height) + half_width = width / 2 half_height = height / 2 @@ -173,7 +177,8 @@ def __init__( left=left, right=right, top=top, bottom=bottom, near=near, far=far ) - self.viewport: Rect = viewport + self.viewport = viewport + """ A rect which describes how the final projection should be mapped from unit-space. defaults to the size of the render_target or window @@ -289,7 +294,7 @@ def use(self) -> None: _projection = generate_orthographic_matrix(self.projection_data, self.zoom) _view = generate_view_matrix(self.view_data) - self._window.ctx.viewport = self.viewport.lbwh_int + self._window.ctx.viewport = self._viewport.lbwh_int self._window.ctx.scissor = None if not self.scissor else self.scissor.lbwh_int self._window.projection = _projection self._window.view = _view @@ -322,7 +327,7 @@ def project(self, world_coordinate: Point) -> Vec2: return project_orthographic( world_coordinate, - self.viewport.lbwh_int, + self._viewport.lbwh_int, _view, _projection, ) @@ -345,7 +350,9 @@ def unproject(self, screen_coordinate: Point) -> Vec3: _projection = generate_orthographic_matrix(self.projection_data, self.zoom) _view = generate_view_matrix(self.view_data) - return unproject_orthographic(screen_coordinate, self.viewport.lbwh_int, _view, _projection) + return unproject_orthographic( + screen_coordinate, self._viewport.lbwh_int, _view, _projection + ) def equalize(self) -> None: """ @@ -470,8 +477,7 @@ def update_values( h = value.width / aspect value = XYWH(value.x, value.y, w, h) - if viewport: - self.viewport = value + self.viewport = value if projection: x, y = self._projection_data.rect.x, self._projection_data.rect.y @@ -498,7 +504,7 @@ def aabb(self) -> Rect: ux, uy, *_ = up rx, ry = uy, -ux # up x Z' - l, r, b, t = self.viewport.lrbt + l, r, b, t = self._viewport.lrbt x, y = self.position x_points = ( @@ -881,17 +887,28 @@ def projection_far(self) -> float: def projection_far(self, new_far: float) -> None: self._projection_data.far = new_far + @property + def viewport(self) -> Rect: + return self._viewport + + @viewport.setter + def viewport(self, viewport: Rect) -> None: + if not isinstance(viewport, Rect): + raise TypeError("viewport must be a Rect type,use arcade.LBWH or arcade.types.Viewport") + + self._viewport = viewport + @property def viewport_width(self) -> int: """ The width of the viewport. Defines the number of pixels drawn too horizontally. """ - return int(self.viewport.width) + return int(self._viewport.width) @viewport_width.setter def viewport_width(self, new_width: int) -> None: - self.viewport = self.viewport.resize(new_width, anchor=Vec2(0.0, 0.0)) + self._viewport = self._viewport.resize(new_width, anchor=Vec2(0.0, 0.0)) @property def viewport_height(self) -> int: @@ -899,18 +916,18 @@ def viewport_height(self) -> int: The height of the viewport. Defines the number of pixels drawn too vertically. """ - return int(self.viewport.height) + return int(self._viewport.height) @viewport_height.setter def viewport_height(self, new_height: int) -> None: - self.viewport = self.viewport.resize(height=new_height, anchor=Vec2(0.0, 0.0)) + self._viewport = self._viewport.resize(height=new_height, anchor=Vec2(0.0, 0.0)) @property def viewport_left(self) -> int: """ The left most pixel drawn to on the X axis. """ - return int(self.viewport.left) + return int(self._viewport.left) @viewport_left.setter def viewport_left(self, new_left: int) -> None: @@ -918,14 +935,14 @@ def viewport_left(self, new_left: int) -> None: Set the left most pixel drawn to. This moves the position of the viewport, and does not change the size. """ - self.viewport = self.viewport.align_left(new_left) + self._viewport = self._viewport.align_left(new_left) @property def viewport_right(self) -> int: """ The right most pixel drawn to on the X axis. """ - return int(self.viewport.right) + return int(self._viewport.right) @viewport_right.setter def viewport_right(self, new_right: int) -> None: @@ -933,14 +950,14 @@ def viewport_right(self, new_right: int) -> None: Set the right most pixel drawn to. This moves the position of the viewport, and does not change the size. """ - self.viewport = self.viewport.align_right(new_right) + self._viewport = self._viewport.align_right(new_right) @property def viewport_bottom(self) -> int: """ The bottom most pixel drawn to on the Y axis. """ - return int(self.viewport.bottom) + return int(self._viewport.bottom) @viewport_bottom.setter def viewport_bottom(self, new_bottom: int) -> None: @@ -948,14 +965,14 @@ def viewport_bottom(self, new_bottom: int) -> None: Set the bottom most pixel drawn to. This moves the position of the viewport, and does not change the size. """ - self.viewport = self.viewport.align_bottom(new_bottom) + self._viewport = self._viewport.align_bottom(new_bottom) @property def viewport_top(self) -> int: """ The top most pixel drawn to on the Y axis. """ - return int(self.viewport.top) + return int(self._viewport.top) @viewport_top.setter def viewport_top(self, new_top: int) -> None: @@ -963,7 +980,7 @@ def viewport_top(self, new_top: int) -> None: Set the top most pixel drawn to. This moves the position of the viewport, and does not change the size. """ - self.viewport = self.viewport.align_top(new_top) + self._viewport = self._viewport.align_top(new_top) @property def up(self) -> Vec2: From 3f7e02dd7feeebcf84ce3b790500ac27c50559fd Mon Sep 17 00:00:00 2001 From: DigiDuncan Date: Wed, 24 Dec 2025 19:07:28 -0500 Subject: [PATCH 252/279] Easing Into It: The `anim` module (#2799) * new anim folder, new easings * deprecate easing.py by deleting it it doesn't look like it's used anywhere internally * explain constants * remove obsolete examples * Get doc building * minor improvements * just define the methods on the class * Fix sphinx conf to render docstrings * Fix inclusion of items in doc * Fix doc build after adding to the quickindex file. * Convert EasingFunction into a typing.Protocol so it gets picked up by doc build + explain why * Correct Sphinx style issues and broken cross-references * Explain how of pyglet.math Matrix types won't work with easing (matmul) * Add an __all__ to arcade.anim.easing * ./make.py format * Remove unused typing.Callable import * Another ruff formatter run * Rename perc to norm as discussed * Rename `Animatable` to `Interpolatable`, as per @pushfoo --------- Co-authored-by: pushfoo <36696816+pushfoo@users.noreply.github.com> --- arcade/anim/__init__.py | 3 + arcade/anim/easing.py | 421 ++++++++++++++++++++++++++ arcade/easing.py | 275 ----------------- arcade/examples/easing_example_1.py | 209 ------------- arcade/examples/easing_example_2.py | 178 ----------- doc/api_docs/arcade.rst | 2 +- doc/example_code/easing_example_1.rst | 17 -- doc/example_code/easing_example_2.rst | 17 -- doc/example_code/index.rst | 11 +- util/update_quick_index.py | 2 +- 10 files changed, 428 insertions(+), 707 deletions(-) create mode 100644 arcade/anim/__init__.py create mode 100644 arcade/anim/easing.py delete mode 100644 arcade/easing.py delete mode 100644 arcade/examples/easing_example_1.py delete mode 100644 arcade/examples/easing_example_2.py delete mode 100644 doc/example_code/easing_example_1.rst delete mode 100644 doc/example_code/easing_example_2.rst diff --git a/arcade/anim/__init__.py b/arcade/anim/__init__.py new file mode 100644 index 0000000000..e93a038cb1 --- /dev/null +++ b/arcade/anim/__init__.py @@ -0,0 +1,3 @@ +from arcade.anim.easing import ease, Easing, lerp, norm + +__all__ = ["ease", "Easing", "lerp", "norm"] diff --git a/arcade/anim/easing.py b/arcade/anim/easing.py new file mode 100644 index 0000000000..a3a12ddcea --- /dev/null +++ b/arcade/anim/easing.py @@ -0,0 +1,421 @@ +"""Core easing annotations and helper functions.""" + +from math import cos, pi, sin, sqrt, tau +from typing import Protocol, TypeVar + +T = TypeVar("T") + + +# This needs to be a Protocol rather than an annotation +# due to our build configuration being set to pick up +# classes but not type annotations. +class EasingFunction(Protocol): + """Any :py:func:`callable` object which maps linear completion to a curve. + + .. tip:: See :py:class:`Easing` for the most common easings. + + Pass them to :py:func:`.ease` via the ``func`` + keyword argument. + + If the built-in easing curves are not enough, you can define + your own. Functions should match this pattern: + + .. code-block:: python + + def f(t: float) -> t: + ... + + For advanced users, any object with a matching :py:meth:`~object.__call__` + method can be passed as an easing function. + """ + + def __call__(self, __t: float) -> float: ... + + +class Interpolatable(Protocol): + """Matches types with support for the following operations: + + .. list-table:: + :header-rows: 1 + + * - Method + - Summary + + * - :py:meth:`~object.__mul__` + - Multiplication by a scalar + + * - :py:meth:`~object.__add__` + - Addition + + * - :py:meth:`~object.__sub__` + - Subtraction + + .. important:: The :py:mod:`pyglet.math` matrix types are currently unsupported. + + Although vector types work, matrix multiplication is + subtly different. It uses a separate :py:meth:`~object.__matmul__` + operator for multiplication. + """ + + def __mul__(self: T, other: T | float, /) -> T: ... + + def __add__(self: T, other: T | float, /) -> T: ... + + def __sub__(self: T, other: T | float, /) -> T: ... + + +A = TypeVar("A", bound=Interpolatable) + +# === BEGIN EASING FUNCTIONS === + +# CONSTANTS USED FOR EASING EQUATIONS +# *: The constants C2, C3, N1, and D1 don't have clean analogies, +# so remain unnamed. +TEN_PERCENT_BOUNCE = 1.70158 +C2 = TEN_PERCENT_BOUNCE * 1.525 +C3 = TEN_PERCENT_BOUNCE + 1 +TAU_ON_THREE = tau / 3 +TAU_ON_FOUR_AND_A_HALF = tau / 4.5 +N1 = 7.5625 +D1 = 2.75 + + +class Easing: + """Built-in easing functions as static methods. + + Each takes the following form: + + .. code-block:: python + + def f(t: float) -> float: + ... + + Pass them into :py:func:`.ease` via the ``func`` keyword + argument: + + .. code-block:: python + + from arcade.anim import ease, Easing + + value = ease( + 1.0, 2.0, + 2.0, 3.0, + 2.4, + func=Easing.SINE_IN) + + """ + + # This is a bucket of staticmethods because typing. + # Enum hates this, and they can't be classmethods. + # That's why their capitalized, it's meant to be an Enum-like + # Sorry that this looks strange! -- DigiDuncan + + @staticmethod + def LINEAR(t: float) -> float: + """Essentially the 'null' case for easing. Does no easing.""" + return t + + @staticmethod + def SINE_IN(t: float) -> float: + """http://easings.net/#easeInSine""" + return 1 - cos((t * pi / 2)) + + @staticmethod + def SINE_OUT(t: float) -> float: + """http://easings.net/#easeOutSine""" + return sin((t * pi) / 2) + + @staticmethod + def SINE(t: float) -> float: + """http://easings.net/#easeInOutSine""" + return -(cos(t * pi) - 1) / 2 + + @staticmethod + def QUAD_IN(t: float) -> float: + """http://easings.net/#easeInQuad""" + return t * t + + @staticmethod + def QUAD_OUT(t: float) -> float: + """http://easings.net/#easeOutQuad""" + return 1 - (1 - t) * (1 - t) + + @staticmethod + def QUAD(t: float) -> float: + """http://easings.net/#easeInOutQuad""" + if t < 0.5: + return 2 * t * t + else: + return 1 - pow(-2 * t + 2, 2) / 2 + + @staticmethod + def CUBIC_IN(t: float) -> float: + """http://easings.net/#easeInCubic""" + return t * t * t + + @staticmethod + def CUBIC_OUT(t: float) -> float: + """http://easings.net/#easeOutCubic""" + return 1 - pow(1 - t, 3) + + @staticmethod + def CUBIC(t: float) -> float: + """http://easings.net/#easeInOutCubic""" + if t < 0.5: + return 4 * t * t * t + else: + return 1 - pow(-2 * t + 2, 3) / 2 + + @staticmethod + def QUART_IN(t: float) -> float: + """http://easings.net/#easeInQuart""" + return t * t * t * t + + @staticmethod + def QUART_OUT(t: float) -> float: + """http://easings.net/#easeOutQuart""" + return 1 - pow(1 - t, 4) + + @staticmethod + def QUART(t: float) -> float: + """http://easings.net/#easeInOutQuart""" + if t < 0.5: + return 8 * t * t * t * t + else: + return 1 - pow(-2 * t + 2, 4) / 2 + + @staticmethod + def QUINT_IN(t: float) -> float: + """http://easings.net/#easeInQint""" + return t * t * t * t * t + + @staticmethod + def QUINT_OUT(t: float) -> float: + """http://easings.net/#easeOutQint""" + return 1 - pow(1 - t, 5) + + @staticmethod + def QUINT(t: float) -> float: + """http://easings.net/#easeInOutQint""" + if t < 0.5: + return 16 * t * t * t * t * t + else: + return 1 - pow(-2 * t + 2, 5) / 2 + + @staticmethod + def EXPO_IN(t: float) -> float: + """http://easings.net/#easeInExpo""" + if t == 0: + return 0 + return pow(2, 10 * t - 10) + + @staticmethod + def EXPO_OUT(t: float) -> float: + """http://easings.net/#easeOutExpo""" + if t == 1: + return 1 + return 1 - pow(2, -10 * t) + + @staticmethod + def EXPO(t: float) -> float: + """http://easings.net/#easeInOutExpo""" + if t == 0 or t == 1: + return t + elif t < 0.5: + return pow(2, 20 * t - 10) / 2 + else: + return (2 - pow(2, -20 * t + 10)) / 2 + + @staticmethod + def CIRC_IN(t: float) -> float: + """http://easings.net/#easeInCirc""" + return 1 - sqrt(1 - pow(t, 2)) + + @staticmethod + def CIRC_OUT(t: float) -> float: + """http://easings.net/#easeOutCirc""" + return sqrt(1 - pow(t - 1, 2)) + + @staticmethod + def CIRC(t: float) -> float: + """http://easings.net/#easeInOutCirc""" + if t < 0.5: + return (1 - sqrt(1 - pow(2 * t, 2))) / 2 + else: + return (sqrt(1 - pow(-2 * t + 2, 2)) + 1) / 2 + + @staticmethod + def BACK_IN(t: float) -> float: + """http://easings.net/#easeInBack""" + return (C3 * t * t * t) - (TEN_PERCENT_BOUNCE * t * t) + + @staticmethod + def BACK_OUT(t: float) -> float: + """http://easings.net/#easeOutBack""" + return 1 + C3 + pow(t - 1, 3) + TEN_PERCENT_BOUNCE * pow(t - 1, 2) + + @staticmethod + def BACK(t: float) -> float: + """http://easings.net/#easeInOutBack""" + if t < 0.5: + return (pow(2 * t, 2) * ((C2 + 1) * 2 * t - C2)) / 2 + else: + return (pow(2 * t - 2, 2) * ((C2 + 1) * (t * 2 - 2) + C2) + 2) / 2 + + @staticmethod + def ELASTIC_IN(t: float) -> float: + """http://easings.net/#easeInElastic""" + if t == 0 or t == 1: + return t + return -pow(2, 10 * t - 10) * sin((t * 10 - 10.75) * TAU_ON_THREE) + + @staticmethod + def ELASTIC_OUT(t: float) -> float: + """http://easings.net/#easeOutElastic""" + if t == 0 or t == 1: + return t + return pow(2, -10 * t) * sin((t * 10 - 0.75) * TAU_ON_THREE) + 1 + + @staticmethod + def ELASTIC(t: float) -> float: + """http://easings.net/#easeInOutElastic""" + if t == 0 or t == 1: + return t + if t < 0.5: + return -(pow(2, 20 * t - 10) * sin((20 * t - 11.125) * TAU_ON_FOUR_AND_A_HALF)) / 2 + else: + return (pow(2, -20 * t + 10) * sin((20 * t - 11.125) * TAU_ON_FOUR_AND_A_HALF)) / 2 + 1 + + @staticmethod + def BOUNCE_IN(t: float) -> float: + """http://easings.net/#easeInBounce""" + return 1 - (Easing.BOUNCE_OUT(1 - t)) + + @staticmethod + def BOUNCE_OUT(t: float) -> float: + """http://easings.net/#easeOutBounce""" + if t < 1 / D1: + return N1 * t * t + elif t < 2 / D1: + return N1 * ((t - 1.5) / D1) * (t - 1.5) + 0.75 + elif t < 2.5 / D1: + return N1 * ((t - 2.25) / D1) * (t - 2.25) + 0.9375 + else: + return N1 * ((t - 2.625) / D1) * (t - 2.625) + 0.984375 + + @staticmethod + def BOUNCE(t: float) -> float: + """http://easings.net/#easeInOutBounce""" + if t < 0.5: + return (1 - Easing.BOUNCE_OUT(1 - 2 * t)) / 2 + else: + return (1 + Easing.BOUNCE_OUT(2 * t - 1)) / 2 + + # Aliases to match easing.net names + SINE_IN_OUT = SINE + QUAD_IN_OUT = QUAD + CUBIC_IN_OUT = CUBIC + QUART_IN_OUT = QUART + QUINT_IN_OUT = QUINT + EXPO_IN_OUT = EXPO + CIRC_IN_OUT = CIRC + BACK_IN_OUT = BACK + ELASTIC_IN_OUT = ELASTIC + BOUNCE_IN_OUT = BOUNCE + + +# === END EASING FUNCTIONS === + + +def _clamp(x: float, low: float, high: float) -> float: + return high if x > high else max(x, low) + + +def norm(x: float, start: float, end: float) -> float: + """Convert ``x`` to a progress ratio from ``start`` to ``end``. + + The result will be a value normalized to between ``0.0`` + and ``1.0`` if ``x`` is between ``start`` and ``end`. It + is not clamped, so the result may be less than ``0.0`` or + ``greater than ``1.0``. + + Arguments: + x: A value between ``start`` and ``end``. + start: The start of the range. + end: The end of the range. + + Returns: + A range completion progress as a :py:class:`float`. + """ + return (x - start) / (end - start) + + +def lerp(progress: float, minimum: A, maximum: A) -> A: + """Get ``progress`` of the way from``minimum`` to ``maximum``. + + Arguments: + progress: How far from ``minimum`` to ``maximum`` to go + from ``0.0`` to ``1.0``. + minimum: The start value along the path. + maximum: The maximum value along the path. + + Returns: + A value ``progress`` of the way from ``minimum`` to ``maximum``. + """ + return minimum + ((maximum - minimum) * progress) + + +def ease( + minimum: A, + maximum: A, + start: float, + end: float, + t: float, + func: EasingFunction = Easing.LINEAR, + clamped: bool = True, +) -> A: + """Ease a value according to a curve function passed as ``func``. + + Override the default easing curve by passing any :py:class:`.Easing` + or :py:class:`.EasingFunction` of your choice. + + The ``maximum`` and ``minimum`` must be of compatible types. + For example, these can include: + + .. list-table:: + :header-rows: 1 + + * - Type + - Value Example + - Explanation + + * - :py:class:`float` + - ``0.5`` + - Numbers such as volume or brightness. + + * - :py:class:`~pyglet.math.Vec2` + - ``Vec2(500.0, 200.0)`` + - A :py:mod:`pyglet.math` vector representing position. + + Arguments: + minimum: any math-like object (a position, scale, value...); the "start position." + maximum: any math-like object (a position, scale, value...); the "end position." + start: a :py:class:`float` defining where progression begins, the "start time." + end: a :py:class:`float` defining where progression ends, the "end time." + t: a :py:class:`float` defining the current progression, the "current time." + func: Defaults to :py:attr:`Easing.LINEAR`, but you can pass an + :py:class:`Easing` or :py:class:`.EasingFunction` of your choice. + clamped: Whether the value will be clamped to ``minimum`` and ``maximum``. + + Returns: + An eased value for the given time ``t``. + + """ + p = norm(t, start, end) + if clamped: + p = _clamp(p, 0.0, 1.0) + new_p = func(p) + return lerp(new_p, minimum, maximum) + + +__all__ = ["Interpolatable", "Easing", "EasingFunction", "ease", "norm", "lerp"] diff --git a/arcade/easing.py b/arcade/easing.py deleted file mode 100644 index aa2a2ddf5b..0000000000 --- a/arcade/easing.py +++ /dev/null @@ -1,275 +0,0 @@ -""" -Functions used to support easing -""" - -from collections.abc import Callable -from dataclasses import dataclass -from math import cos, pi, sin - -from .math import get_distance - - -@dataclass -class EasingData: - """ - Data class for holding information about easing. - """ - - start_period: float - cur_period: float - end_period: float - start_value: float - end_value: float - ease_function: Callable - - def reset(self) -> None: - """ - Reset the easing data to its initial state. - """ - self.cur_period = self.start_period - - -def linear(percent: float) -> float: - """ - Function for linear easing. - """ - return percent - - -def _flip(percent: float) -> float: - return 1.0 - percent - - -def smoothstep(percent: float) -> float: - """ - Function for smoothstep easing. - """ - return percent**2 * (3.0 - 2.0 * percent) - - -def ease_in(percent: float) -> float: - """ - Function for quadratic ease-in easing. - """ - return percent**2 - - -def ease_out(percent: float) -> float: - """ - Function for quadratic ease-out easing. - """ - return _flip(_flip(percent) * _flip(percent)) - - -def ease_in_out(percent: float) -> float: - """ - Function for quadratic easing in and out. - """ - - return 2 * percent**2 if percent < 0.5 else 1 - (-2 * percent + 2) ** 2 / 2 - - -def ease_out_elastic(percent: float) -> float: - """ - Function for elastic ease-out easing. - """ - c4 = 2 * pi / 3 - result = 0.0 - if percent == 1: - result = 1 - elif percent > 0: - result = (2 ** (-10 * percent)) * sin((percent * 10 - 0.75) * c4) + 1 - return result - - -def ease_out_bounce(percent: float) -> float: - """ - Function for a bouncy ease-out easing. - """ - n1 = 7.5625 - d1 = 2.75 - - if percent < 1 / d1: - return n1 * percent * percent - elif percent < 2 / d1: - percent_modified = percent - 1.5 / d1 - return n1 * percent_modified * percent_modified + 0.75 - elif percent < 2.5 / d1: - percent_modified = percent - 2.25 / d1 - return n1 * percent_modified * percent_modified + 0.9375 - else: - percent_modified = percent - 2.625 / d1 - return n1 * percent_modified * percent_modified + 0.984375 - - -def ease_in_back(percent: float) -> float: - """ - Function for ease_in easing which moves back before moving forward. - """ - c1 = 1.70158 - c3 = c1 + 1 - - return c3 * percent * percent * percent - c1 * percent * percent - - -def ease_out_back(percent: float) -> float: - """ - Function for ease_out easing which moves back before moving forward. - """ - c1 = 1.70158 - c3 = c1 + 1 - - return 1 + c3 * pow(percent - 1, 3) + c1 * pow(percent - 1, 2) - - -def ease_in_sin(percent: float) -> float: - """ - Function for ease_in easing using a sin wave - """ - return 1 - cos((percent * pi) / 2) - - -def ease_out_sin(percent: float) -> float: - """ - Function for ease_out easing using a sin wave - """ - return sin((percent * pi) / 2) - - -def ease_in_out_sin(percent: float) -> float: - """ - Function for easing in and out using a sin wave - """ - return -cos(percent * pi) * 0.5 + 0.5 - - -def easing(percent: float, easing_data: EasingData) -> float: - """ - Function for calculating return value for easing, given percent and easing data. - """ - return easing_data.start_value + ( - easing_data.end_value - easing_data.start_value - ) * easing_data.ease_function(percent) - - -def ease_angle( - start_angle: float, - end_angle: float, - *, - time=None, - rate=None, - ease_function: Callable = linear, -) -> EasingData | None: - """ - Set up easing for angles. - """ - while start_angle - end_angle > 180: - end_angle += 360 - - while start_angle - end_angle < -180: - end_angle -= 360 - - diff = abs(start_angle - end_angle) - if diff == 0: - return None - - if rate is not None: - time = diff / rate - - if time is None: - raise ValueError("Either the 'time' or the 'rate' parameter needs to be set.") - - easing_data = EasingData( - start_value=start_angle, - end_value=end_angle, - start_period=0, - cur_period=0, - end_period=time, - ease_function=ease_function, - ) - return easing_data - - -def ease_angle_update(easing_data: EasingData, delta_time: float) -> tuple[bool, float]: - """ - Update angle easing. - """ - done = False - easing_data.cur_period += delta_time - easing_data.cur_period = min(easing_data.cur_period, easing_data.end_period) - percent = easing_data.cur_period / easing_data.end_period - - angle = easing(percent, easing_data) - - if percent >= 1.0: - done = True - - while angle > 360: - angle -= 360 - - while angle < 0: - angle += 360 - - return done, angle - - -def ease_value( - start_value: float, end_value: float, *, time=None, rate=None, ease_function=linear -) -> EasingData: - """ - Get an easing value - """ - if rate is not None: - diff = abs(start_value - end_value) - time = diff / rate - - if time is None: - raise ValueError("Either the 'time' or the 'rate' parameter needs to be set.") - - easing_data = EasingData( - start_value=start_value, - end_value=end_value, - start_period=0, - cur_period=0, - end_period=time, - ease_function=ease_function, - ) - return easing_data - - -def ease_position( - start_position, end_position, *, time=None, rate=None, ease_function=linear -) -> tuple[EasingData, EasingData]: - """ - Get an easing position - """ - distance = get_distance(start_position[0], start_position[1], end_position[0], end_position[1]) - - if rate is not None: - time = distance / rate - - easing_data_x = ease_value( - start_position[0], end_position[0], time=time, ease_function=ease_function - ) - easing_data_y = ease_value( - start_position[1], end_position[1], time=time, ease_function=ease_function - ) - - return easing_data_x, easing_data_y - - -def ease_update(easing_data: EasingData, delta_time: float) -> tuple[bool, float]: - """ - Update easing between two values/ - """ - easing_data.cur_period += delta_time - easing_data.cur_period = min(easing_data.cur_period, easing_data.end_period) - if easing_data.end_period == 0: - percent = 1.0 - value = easing_data.end_value - else: - percent = easing_data.cur_period / easing_data.end_period - value = easing(percent, easing_data) - - done = percent >= 1.0 - return done, value diff --git a/arcade/examples/easing_example_1.py b/arcade/examples/easing_example_1.py deleted file mode 100644 index 032d1d1021..0000000000 --- a/arcade/examples/easing_example_1.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -Example showing how to use the easing functions for position. - -See: -https://easings.net/ -...for a great guide on the theory behind how easings can work. - -See example 2 for how to use easings for angles. - -If Python and Arcade are installed, this example can be run from the command line with: -python -m arcade.examples.easing_example_1 -""" - -import arcade -from arcade import easing -from arcade.types import Color - -SPRITE_SCALING = 0.5 - -WINDOW_WIDTH = 1280 -WINDOW_HEIGHT = 720 -WINDOW_TITLE = "Easing Example" - -BACKGROUND_COLOR = "#F5D167" -TEXT_COLOR = "#4B1DF2" -BALL_COLOR = "#42B5EB" -LINE_COLOR = "#45E6D0" -LINE_WIDTH = 3 - -X_START = 40 -X_END = 1200 -Y_INTERVAL = 60 -BALL_RADIUS = 13 -TIME = 3.0 - - -class EasingCircle(arcade.SpriteCircle): - """Player class""" - - def __init__(self, radius, color, center_x: float = 0, center_y: float = 0): - """Set up the player""" - - # Call the parent init - super().__init__(radius, color, center_x=center_x, center_y=center_y) - - self.easing_x_data = None - self.easing_y_data = None - - def update(self, delta_time: float = 1 / 60): - if self.easing_x_data is not None: - done, self.center_x = easing.ease_update(self.easing_x_data, delta_time) - if done: - x = X_START - if self.center_x < WINDOW_WIDTH / 2: - x = X_END - ex, ey = easing.ease_position( - self.position, - (x, self.center_y), - rate=180, - ease_function=self.easing_x_data.ease_function, - ) - self.easing_x_data = ex - - if self.easing_y_data is not None: - done, self.center_y = easing.ease_update(self.easing_y_data, delta_time) - if done: - self.easing_y_data = None - - -class GameView(arcade.View): - """Main application class.""" - - def __init__(self): - """Initializer""" - - # Call the parent class initializer - super().__init__() - - # Set the background color - self.background_color = Color.from_hex_string(BACKGROUND_COLOR) - - self.ball_list = None - self.text_list = [] - self.lines = None - - def setup(self): - """Set up the game and initialize the variables.""" - - # Sprite lists - self.ball_list = arcade.SpriteList() - self.lines = arcade.shape_list.ShapeElementList() - color = Color.from_hex_string(BALL_COLOR) - shared_ball_kwargs = dict(radius=BALL_RADIUS, color=color) - - def create_ball(ball_y, ease_function): - ball = EasingCircle(**shared_ball_kwargs, center_x=X_START, center_y=ball_y) - p1 = ball.position - p2 = (X_END, ball_y) - ex, ey = easing.ease_position(p1, p2, time=TIME, ease_function=ease_function) - ball.ease_function = ease_function - ball.easing_x_data = ex - ball.easing_y_data = ey - return ball - - def create_line(line_y): - line = arcade.shape_list.create_line( - X_START, - line_y - BALL_RADIUS - LINE_WIDTH, - X_END, - line_y - BALL_RADIUS, - line_color, - line_width=LINE_WIDTH, - ) - return line - - def create_text(text_string): - text = arcade.Text( - text_string, - x=X_START, - y=y - BALL_RADIUS, - color=text_color, - font_size=24, - ) - return text - - def add_item(item_y, ease_function, text): - ball = create_ball(item_y, ease_function) - self.ball_list.append(ball) - text = create_text(text) - self.text_list.append(text) - line = create_line(item_y) - self.lines.append(line) - - text_color = Color.from_hex_string(TEXT_COLOR) - line_color = Color.from_hex_string(LINE_COLOR) - - y = Y_INTERVAL - add_item(y, easing.linear, "Linear") - - y += Y_INTERVAL - add_item(y, easing.ease_out, "Ease out") - - y += Y_INTERVAL - add_item(y, easing.ease_in, "Ease in") - - y += Y_INTERVAL - add_item(y, easing.smoothstep, "Smoothstep") - - y += Y_INTERVAL - add_item(y, easing.ease_in_out, "Ease in/out") - - y += Y_INTERVAL - add_item(y, easing.ease_out_elastic, "Ease out elastic") - - y += Y_INTERVAL - add_item(y, easing.ease_in_back, "Ease in back") - - y += Y_INTERVAL - add_item(y, easing.ease_out_back, "Ease out back") - - y += Y_INTERVAL - add_item(y, easing.ease_in_sin, "Ease in sin") - - y += Y_INTERVAL - add_item(y, easing.ease_out_sin, "Ease out sin") - - y += Y_INTERVAL - add_item(y, easing.ease_in_out_sin, "Ease in out sin") - - def on_draw(self): - """Render the screen.""" - - # This command has to happen before we start drawing - self.clear() - - self.lines.draw() - - # Draw all the sprites. - self.ball_list.draw() - - for text in self.text_list: - text.draw() - - def on_update(self, delta_time): - """Movement and game logic""" - - # Call update on all sprites (The sprites don't do much in this - # example though.) - self.ball_list.update(delta_time) - - -def main(): - """Main function""" - # Create a window class. This is what actually shows up on screen - window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) - - # Create and setup the GameView - game = GameView() - game.setup() - - # Show GameView on screen - window.show_view(game) - - # Start the arcade game loop - arcade.run() - - -if __name__ == "__main__": - main() diff --git a/arcade/examples/easing_example_2.py b/arcade/examples/easing_example_2.py deleted file mode 100644 index 1467b71e1c..0000000000 --- a/arcade/examples/easing_example_2.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -Example showing how to use the easing functions for position. -Example showing how to use easing for angles. - -See: -https://easings.net/ -...for a great guide on the theory behind how easings can work. - -If Python and Arcade are installed, this example can be run from the command line with: -python -m arcade.examples.easing_example_2 -""" - -import arcade -from arcade import easing - -SPRITE_SCALING = 1.0 - -WINDOW_WIDTH = 1280 -WINDOW_HEIGHT = 720 -WINDOW_TITLE = "Easing Example" - - -class Player(arcade.Sprite): - """Player class""" - - def __init__(self, image, scale): - """Set up the player""" - - # Call the parent init - super().__init__(image, scale=scale) - - self.easing_angle_data = None - self.easing_x_data = None - self.easing_y_data = None - - def update(self, delta_time: float = 1 / 60): - if self.easing_angle_data is not None: - done, self.angle = easing.ease_angle_update(self.easing_angle_data, delta_time) - if done: - self.easing_angle_data = None - - if self.easing_x_data is not None: - done, self.center_x = easing.ease_update(self.easing_x_data, delta_time) - if done: - self.easing_x_data = None - - if self.easing_y_data is not None: - done, self.center_y = easing.ease_update(self.easing_y_data, delta_time) - if done: - self.easing_y_data = None - - -class GameView(arcade.View): - """Main application class.""" - - def __init__(self): - """Initializer""" - - # Call the parent class initializer - super().__init__() - - # Set up the player info - self.player_list = arcade.SpriteList() - - # Load the player texture. The ship points up by default. We need it to point right. - # That's why we rotate it 90 degrees clockwise. - texture = arcade.load_texture(":resources:images/space_shooter/playerShip1_orange.png") - texture = texture.rotate_90() - - # Set up the player - self.player_sprite = Player(texture, SPRITE_SCALING) - self.player_sprite.angle = 0 - self.player_sprite.center_x = WINDOW_WIDTH / 2 - self.player_sprite.center_y = WINDOW_HEIGHT / 2 - self.player_list.append(self.player_sprite) - - # Set the background color - self.background_color = arcade.color.BLACK - self.text = "Move the mouse and press 1-9 to apply an easing function." - - def on_draw(self): - """Render the screen.""" - - # This command has to happen before we start drawing - self.clear() - - # Draw all the sprites. - self.player_list.draw() - - arcade.draw_text(self.text, 15, 15, arcade.color.WHITE, 24) - - def on_update(self, delta_time): - """Movement and game logic""" - - # Call update on all sprites (The sprites don't do much in this - # example though.) - self.player_list.update(delta_time) - - def on_key_press(self, key, modifiers): - x = self.window.mouse["x"] - y = self.window.mouse["y"] - - if key == arcade.key.KEY_1: - angle = arcade.math.get_angle_degrees( - x1=self.player_sprite.position[0], y1=self.player_sprite.position[1], x2=x, y2=y - ) - self.player_sprite.angle = angle - self.text = "Instant angle change" - if key in [arcade.key.KEY_2, arcade.key.KEY_3, arcade.key.KEY_4, arcade.key.KEY_5]: - p1 = self.player_sprite.position - p2 = (x, y) - end_angle = arcade.math.get_angle_degrees(p1[0], p1[1], p2[0], p2[1]) - start_angle = self.player_sprite.angle - if key == arcade.key.KEY_2: - ease_function = easing.linear - self.text = "Linear easing - angle" - elif key == arcade.key.KEY_3: - ease_function = easing.ease_in - self.text = "Ease in - angle" - elif key == arcade.key.KEY_4: - ease_function = easing.ease_out - self.text = "Ease out - angle" - elif key == arcade.key.KEY_5: - ease_function = easing.smoothstep - self.text = "Smoothstep - angle" - else: - raise ValueError("?") - - self.player_sprite.easing_angle_data = easing.ease_angle( - start_angle, end_angle, rate=180, ease_function=ease_function - ) - - if key in [arcade.key.KEY_6, arcade.key.KEY_7, arcade.key.KEY_8, arcade.key.KEY_9]: - p1 = self.player_sprite.position - p2 = (x, y) - if key == arcade.key.KEY_6: - ease_function = easing.linear - self.text = "Linear easing - position" - elif key == arcade.key.KEY_7: - ease_function = easing.ease_in - self.text = "Ease in - position" - elif key == arcade.key.KEY_8: - ease_function = easing.ease_out - self.text = "Ease out - position" - elif key == arcade.key.KEY_9: - ease_function = easing.smoothstep - self.text = "Smoothstep - position" - else: - raise ValueError("?") - - ex, ey = easing.ease_position(p1, p2, rate=180, ease_function=ease_function) - self.player_sprite.easing_x_data = ex - self.player_sprite.easing_y_data = ey - - def on_mouse_press(self, x: float, y: float, button: int, modifiers: int): - angle = arcade.math.get_angle_degrees( - x1=self.player_sprite.position[0], y1=self.player_sprite.position[1], x2=x, y2=y - ) - self.player_sprite.angle = angle - - -def main(): - """ Main function """ - # Create a window class. This is what actually shows up on screen - window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) - - # Create the GameView - game = GameView() - - # Show GameView on screen - window.show_view(game) - - # Start the arcade game loop - arcade.run() - - -if __name__ == "__main__": - main() diff --git a/doc/api_docs/arcade.rst b/doc/api_docs/arcade.rst index a541e0500f..c259a54b1e 100644 --- a/doc/api_docs/arcade.rst +++ b/doc/api_docs/arcade.rst @@ -40,7 +40,7 @@ for the Python Arcade library. See also: api/path_finding api/isometric api/earclip - api/easing + api/anim api/open_gl api/math gl/index diff --git a/doc/example_code/easing_example_1.rst b/doc/example_code/easing_example_1.rst deleted file mode 100644 index aa74129a42..0000000000 --- a/doc/example_code/easing_example_1.rst +++ /dev/null @@ -1,17 +0,0 @@ -:orphan: - -.. _easing_example_1: - -Easing Example 1 -================ - -.. image:: images/easing_example_1.png - :width: 600px - :align: center - :alt: Easing Example - -Source ------- -.. literalinclude:: ../../arcade/examples/easing_example_1.py - :caption: easing_example.py - :linenos: diff --git a/doc/example_code/easing_example_2.rst b/doc/example_code/easing_example_2.rst deleted file mode 100644 index 76e2f582c7..0000000000 --- a/doc/example_code/easing_example_2.rst +++ /dev/null @@ -1,17 +0,0 @@ -:orphan: - -.. _easing_example_2: - -Easing Example 2 -================ - -.. image:: images/easing_example_2.png - :width: 600px - :align: center - :alt: Easing Example - -Source ------- -.. literalinclude:: ../../arcade/examples/easing_example_2.py - :caption: easing_example.py - :linenos: diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index 5d5359e8e7..ccb6508da7 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -228,17 +228,10 @@ Non-Player Movement Easing ^^^^^^ -.. figure:: images/thumbs/easing_example_1.png - :figwidth: 170px - :target: easing_example_1.html - - :ref:`easing_example_1` +.. note:: Easing is a work in progress refactor. -.. figure:: images/thumbs/easing_example_2.png - :figwidth: 170px - :target: easing_example_2.html + Please see :py:mod:`arcade.anim`. - :ref:`easing_example_2` Calculating a Path ^^^^^^^^^^^^^^^^^^ diff --git a/util/update_quick_index.py b/util/update_quick_index.py index e6729b1af1..b4fca31cc2 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -188,7 +188,7 @@ "title": "Isometric Map (incomplete)", "use_declarations_in": ["arcade.isometric"], }, - "easing.rst": {"title": "Easing", "use_declarations_in": ["arcade.easing"]}, + "anim.rst": {"title": "Easing", "use_declarations_in": ["arcade.anim", "arcade.anim.easing"]}, "utility.rst": { "title": "Misc Utility Functions", "use_declarations_in": ["arcade", "arcade.__main__", "arcade.utils"], From 91020872d4fc932386f128aaef42b4ad22c08a9a Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Thu, 25 Dec 2025 02:48:26 -0500 Subject: [PATCH 253/279] Initial 4.0 Transition and Pyglet 3 Upgrade (#2801) --- .github/workflows/code_quality.yml | 77 ++++ .github/workflows/docs.yml | 35 ++ .github/workflows/selfhosted_runner.yml | 70 --- .github/workflows/test.yml | 131 ++---- .github/workflows/verify_types.yml | 31 -- .gitignore | 5 +- CHANGELOG.md | 23 +- arcade/VERSION | 2 +- arcade/__init__.py | 18 +- arcade/application.py | 101 +++-- arcade/context.py | 127 +++--- arcade/examples/dual_stick_shooter.py | 20 +- arcade/examples/follow_path.py | 2 +- arcade/examples/gl/__init__.py | 0 arcade/examples/gl/tessellation.py | 4 +- arcade/examples/gl/texture_compression.py | 2 +- arcade/examples/gui/__init__.py | 0 .../examples/gui/exp_controller_inventory.py | 4 +- arcade/examples/perf_test/__init__.py | 0 arcade/examples/platform_tutorial/__init__.py | 0 arcade/examples/slime_invaders.py | 4 +- arcade/examples/snow.py | 2 +- arcade/examples/sprite_bullets.py | 2 +- arcade/examples/sprite_change_coins.py | 2 +- arcade/examples/sprite_collect_coins.py | 2 +- .../sprite_collect_coins_background.py | 2 +- .../sprite_collect_coins_diff_levels.py | 2 +- .../sprite_collect_coins_move_bouncing.py | 2 +- .../sprite_collect_coins_move_circle.py | 2 +- .../sprite_collect_coins_move_down.py | 2 +- arcade/examples/sprite_collect_rotating.py | 2 +- arcade/examples/sprite_explosion_bitmapped.py | 2 +- arcade/examples/sprite_explosion_particles.py | 2 +- arcade/examples/sprite_follow_simple.py | 2 +- arcade/examples/sprite_follow_simple_2.py | 2 +- arcade/examples/sprite_properties.py | 2 +- .../view_instructions_and_game_over.py | 4 +- .../input_manager_example.py | 9 +- arcade/experimental/shapes_buffered_2_glow.py | 2 +- arcade/future/__init__.py | 3 +- arcade/future/video/video_player.py | 6 +- arcade/future/video/video_record_cv2.py | 2 +- arcade/gl/backends/opengl/buffer.py | 13 +- arcade/gl/backends/opengl/compute_shader.py | 5 +- arcade/gl/backends/opengl/context.py | 54 ++- arcade/gl/backends/opengl/framebuffer.py | 19 +- arcade/gl/backends/opengl/glsl.py | 2 +- arcade/gl/backends/opengl/program.py | 5 +- arcade/gl/backends/opengl/query.py | 5 +- arcade/gl/backends/opengl/sampler.py | 2 +- arcade/gl/backends/opengl/texture.py | 5 +- arcade/gl/backends/opengl/texture_array.py | 5 +- arcade/gl/backends/opengl/uniform.py | 2 +- arcade/gl/backends/opengl/vertex_array.py | 10 +- arcade/gl/backends/webgl/__init__.py | 0 arcade/gl/backends/webgl/buffer.py | 128 ++++++ arcade/gl/backends/webgl/context.py | 412 ++++++++++++++++++ arcade/gl/backends/webgl/framebuffer.py | 303 +++++++++++++ arcade/gl/backends/webgl/glsl.py | 162 +++++++ arcade/gl/backends/webgl/program.py | 358 +++++++++++++++ arcade/gl/backends/webgl/provider.py | 14 + arcade/gl/backends/webgl/query.py | 75 ++++ arcade/gl/backends/webgl/sampler.py | 135 ++++++ arcade/gl/backends/webgl/texture.py | 382 ++++++++++++++++ arcade/gl/backends/webgl/texture_array.py | 327 ++++++++++++++ arcade/gl/backends/webgl/uniform.py | 201 +++++++++ arcade/gl/backends/webgl/utils.py | 32 ++ arcade/gl/backends/webgl/vertex_array.py | 272 ++++++++++++ arcade/gl/buffer.py | 3 + arcade/gl/context.py | 2 +- arcade/gl/enums.py | 120 +++++ arcade/gl/texture_array.py | 5 +- arcade/gl/vertex_array.py | 75 ++-- arcade/hitbox/__init__.py | 10 +- arcade/{future => }/input/README.md | 0 arcade/{future => }/input/__init__.py | 0 arcade/{future => }/input/input_mapping.py | 4 +- arcade/{future => }/input/inputs.py | 2 +- arcade/{future => }/input/manager.py | 8 +- arcade/{future => }/input/raw_dicts.py | 0 arcade/perf_graph.py | 4 +- arcade/pymunk_physics_engine.py | 31 +- .../shaders/atlas/resize_simple_vs.glsl | 3 +- arcade/shape_list.py | 30 +- arcade/sound.py | 27 +- arcade/sprite_list/collision.py | 17 +- arcade/sprite_list/sprite_list.py | 64 ++- arcade/text.py | 82 +--- arcade/texture_atlas/atlas_default.py | 8 +- arcade/utils.py | 2 + doc/tutorials/views/01_views.py | 2 +- doc/tutorials/views/02_views.py | 2 +- doc/tutorials/views/03_views.py | 2 +- doc/tutorials/views/04_views.py | 2 +- doc/tutorials/views/index.rst | 4 +- index.html | 28 ++ make.py | 10 + pyproject.toml | 7 +- tests/conftest.py | 4 +- .../sprite_collision_inspector.py | 4 +- tests/unit/atlas/test_basics.py | 2 +- tests/unit/atlas/test_rebuild_resize.py | 2 +- tests/unit/gl/backends/gl/test_gl_program.py | 2 +- tests/unit/gl/test_gl_types.py | 2 +- .../unit/shape_list/test_buffered_drawing.py | 2 +- tests/unit/test_example_docstrings.py | 2 + tests/unit/window/test_window.py | 2 +- util/update_quick_index.py | 8 - webplayground/README.md | 23 + webplayground/example.tpl | 31 ++ webplayground/index.tpl | 16 + webplayground/server.py | 72 +++ 112 files changed, 3736 insertions(+), 630 deletions(-) create mode 100644 .github/workflows/code_quality.yml create mode 100644 .github/workflows/docs.yml delete mode 100644 .github/workflows/selfhosted_runner.yml delete mode 100644 .github/workflows/verify_types.yml create mode 100644 arcade/examples/gl/__init__.py create mode 100644 arcade/examples/gui/__init__.py create mode 100644 arcade/examples/perf_test/__init__.py create mode 100644 arcade/examples/platform_tutorial/__init__.py rename arcade/{future/input => experimental}/input_manager_example.py (96%) create mode 100644 arcade/gl/backends/webgl/__init__.py create mode 100644 arcade/gl/backends/webgl/buffer.py create mode 100644 arcade/gl/backends/webgl/context.py create mode 100644 arcade/gl/backends/webgl/framebuffer.py create mode 100644 arcade/gl/backends/webgl/glsl.py create mode 100644 arcade/gl/backends/webgl/program.py create mode 100644 arcade/gl/backends/webgl/provider.py create mode 100644 arcade/gl/backends/webgl/query.py create mode 100644 arcade/gl/backends/webgl/sampler.py create mode 100644 arcade/gl/backends/webgl/texture.py create mode 100644 arcade/gl/backends/webgl/texture_array.py create mode 100644 arcade/gl/backends/webgl/uniform.py create mode 100644 arcade/gl/backends/webgl/utils.py create mode 100644 arcade/gl/backends/webgl/vertex_array.py rename arcade/{future => }/input/README.md (100%) rename arcade/{future => }/input/__init__.py (100%) rename arcade/{future => }/input/input_mapping.py (96%) rename arcade/{future => }/input/inputs.py (99%) rename arcade/{future => }/input/manager.py (98%) rename arcade/{future => }/input/raw_dicts.py (100%) create mode 100644 index.html create mode 100644 webplayground/README.md create mode 100644 webplayground/example.tpl create mode 100644 webplayground/index.tpl create mode 100644 webplayground/server.py diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml new file mode 100644 index 0000000000..77b1cff767 --- /dev/null +++ b/.github/workflows/code_quality.yml @@ -0,0 +1,77 @@ +# This does code inspection and checks to make sure building of docs works + +name: Code Quality + +on: + push: + branches: [development, maintenance] + pull_request: + branches: [development, maintenance] + workflow_dispatch: + +jobs: + + build: + name: Code Inspections + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Sync UV project + run: uv sync + + - name: Formatting (Ruff) + if: success() + continue-on-error: true + run: uv run make.py format --check + + - name: Linting (Ruff) + if: success() + continue-on-error: true + run: uv run make.py ruff-check + + - name: Type Checking (MyPy) + if: success() + continue-on-error: true + run: uv run make.py mypy + + - name: Type Checking (Pyright) + if: success() + continue-on-error: true + run: uv run make.py pyright + + verifytypes: + name: Verify Types + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Sync UV project + run: uv sync + + - name: Pyright Type Completeness + # Suppress exit code because we do not expect to reach 100% type completeness any time soon + run: uv run pyright --verifytypes arcade || true + diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..fe60bc4499 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,35 @@ +# Builds the doc in PRs + +name: Docs Build + +on: + push: + branches: [development, maintenance] + pull_request: + branches: [development, maintenance] + workflow_dispatch: + +jobs: + + build: + name: Build Documentation + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Sync UV Project + run: uv sync + + - name: build-docs + run: uv run make.py docs-full diff --git a/.github/workflows/selfhosted_runner.yml b/.github/workflows/selfhosted_runner.yml deleted file mode 100644 index f8597f6a05..0000000000 --- a/.github/workflows/selfhosted_runner.yml +++ /dev/null @@ -1,70 +0,0 @@ -# This is our full unit tests -# Self-hosted, run on an old notebook -name: Unit testing - -on: - push: - branches: [development, maintenance] - pull_request: - branches: [development, maintenance] - workflow_dispatch: - -jobs: - - build: - name: Unit tests - runs-on: self-hosted - - strategy: - matrix: - os: [ubuntu-latest] - python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] - architecture: ['x64'] - - steps: - - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies part 1 - run: | - rm -rf .venv - python -m venv .venv - source .venv/bin/activate - pip install --upgrade pip - python -m pip install -U pip wheel setuptools - - name: Install dependencies part 2 - run: | - source .venv/bin/activate - python -m pip install -I -e .[testing_libraries] - - name: Test with pytest - run: | - source .venv/bin/activate - which python - python -c "import pyglet; print('pyglet version', pyglet.__version__)" - python -c "import PIL; print('Pillow version', PIL.__version__)" - pytest --maxfail=10 - - # Prepare the Pull Request Payload artifact. If this fails, we - # we fail silently using the `continue-on-error` option. It's - # nice if this succeeds, but if it fails for any reason, it - # does not mean that our main workflow has failed. - - name: Prepare Pull Request Payload artifact - id: prepare-artifact - if: always() && github.event_name == 'pull_request' - continue-on-error: true - run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json - - # This only makes sense if the previous step succeeded. To - # get the original outcome of the previous step before the - # `continue-on-error` conclusion is applied, we use the - # `.outcome` value. This step also fails silently. - - name: Upload a Build Artifact - if: always() && steps.prepare-artifact.outcome == 'success' - continue-on-error: true - uses: actions/upload-artifact@v4 - with: - name: pull-request-payload - path: pull_request_payload.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b89cde3c1..a9ab9e844a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,4 @@ -# This does code inspection and checks to make sure building of docs works - -name: GitHub based tests +name: PyTest on: push: @@ -11,117 +9,38 @@ on: jobs: - build: - name: Code inspections - runs-on: ${{ matrix.os }} - + linux: + runs-on: ubuntu-latest strategy: matrix: - os: [ubuntu-latest] - python-version: ['3.12'] - architecture: ['x64'] - + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + + name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v4 - - name: setup + - uses: actions/checkout@v5 + + # xvfb is used to run "headless" by providing a virtual X server + # ffmpeg is used for handling mp3 files in some of our tests + - name: Install xvfb and ffmpeg + run: sudo apt-get install xvfb ffmpeg + + - name: Setup Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.architecture }} - - name: dependencies - run: | - python -m pip install -U pip wheel setuptools - - name: wheel - id: wheel - run: | - python -m pip install -e .[dev] - - name: "code-inspection: formatting" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py format --check - - name: "code-inspection: ruff-check" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py ruff-check - - name: "code-inspection: mypy" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py mypy - - name: "code-inspection: pyright" - if: ${{ (success() || failure()) && steps.wheel.outcome == 'success' }} - run: | - python ./make.py pyright - # Prepare the Pull Request Payload artifact. If this fails, - # we fail silently using the `continue-on-error` option. It's - # nice if this succeeds, but if it fails for any reason, it - # does not mean that our lint-test checks failed. - - name: Prepare Pull Request Payload artifact - id: prepare-artifact - if: always() && github.event_name == 'pull_request' - continue-on-error: true - run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json - - # This only makes sense if the previous step succeeded. To - # get the original outcome of the previous step before the - # `continue-on-error` conclusion is applied, we use the - # `.outcome` value. This step also fails silently. - - name: Upload a Build Artifact - if: always() && steps.prepare-artifact.outcome == 'success' - continue-on-error: true - uses: actions/upload-artifact@v4 + - name: Install UV + uses: astral-sh/setup-uv@v7 with: - name: pull-request-payload - path: pull_request_payload.json - - builddoc: - - name: Documentation build test - runs-on: ${{ matrix.os }} + enable-cache: true - strategy: - matrix: - os: [ubuntu-latest] - # python-version in must be kept in sync with .readthedocs.yaml - python-version: ['3.10'] # July 2024 | Match our contributor dev version; see pyproject.toml - architecture: ['x64'] + - name: Sync UV project + run: uv sync - steps: - - uses: actions/checkout@v4 - - name: setup - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.architecture }} - - - name: dependencies - run: | - python -m pip install -U pip wheel setuptools - - name: wheel - id: wheel - run: | - python -m pip install -e .[dev] - - name: build-docs + - name: Run tests + env: + PYGLET_BACKEND: opengl + REPL_ID: hello # Arcade checks for this to disable anti-aliasing run: | - sphinx-build doc build -W - # Prepare the Pull Request Payload artifact. If this fails, - # we fail silently using the `continue-on-error` option. It's - # nice if this succeeds, but if it fails for any reason, it - # does not mean that our lint-test checks failed. - - name: Prepare Pull Request Payload artifact - id: prepare-artifact - if: always() && github.event_name == 'pull_request' - continue-on-error: true - run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json - - # This only makes sense if the previous step succeeded. To - # get the original outcome of the previous step before the - # `continue-on-error` conclusion is applied, we use the - # `.outcome` value. This step also fails silently. - - name: Upload a Build Artifact - if: always() && steps.prepare-artifact.outcome == 'success' - continue-on-error: true - uses: actions/upload-artifact@v4 - with: - name: pull-request-payload - path: pull_request_payload.json + xvfb-run --auto-servernum uv run arcade + xvfb-run --auto-servernum uv run pytest --maxfail=10 \ No newline at end of file diff --git a/.github/workflows/verify_types.yml b/.github/workflows/verify_types.yml deleted file mode 100644 index 1e037c82e6..0000000000 --- a/.github/workflows/verify_types.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Type completeness report - -on: - push: - branches: [development, maintenance] - pull_request: - branches: [development, maintenance] - workflow_dispatch: - -jobs: - - verifytypes: - name: Verify types - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: setup - uses: actions/setup-python@v5 - with: - python-version: 3.11 - architecture: x64 - - - name: Install - id: install - run: | - python -m pip install .[dev] - - name: "code-inspection: pyright --verifytypes" - # Suppress exit code because we do not expect to reach 100% type completeness any time soon - run: | - python -m pyright --verifytypes arcade || true diff --git a/.gitignore b/.gitignore index 8d2b38dd22..f97fc04787 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,7 @@ temp/ # pending: Sphinx 8.1.4 + deps are verified as working with Arcade # see util/sphinx_static_file_temp_fix.py -.ENABLE_DEVMACHINE_SPHINX_STATIC_FIX \ No newline at end of file +.ENABLE_DEVMACHINE_SPHINX_STATIC_FIX + +webplayground/**/*.whl +webplayground/**/*.zip \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5c84811a..6c9e349f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,15 +3,28 @@ You can grab pre-release versions from PyPi. See the available versions from the Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. -## Unreleased +## 4.0.0.dev1 + +### New Features +- Support for running with Pyodide in web browsers. +- New `anim` module. Currently contains new easing/lerp utilities. + +### Breaking Changes +- `arcade.easing` has been removed, and replaced by the new `arcade.anim.easing` module. +- `arcade.future.input` package has been moved to the top level `arcade.input`. + +### GUI +- `UIManager` did not apply size hint of (0,0). Mainly an issue with `UIBoxLayout`. +- Allow multiple children in `UIScrollArea`. +- Fix `UIDropdown Overlay` positioning within a `UIScrollArea`. + +### Misc Changes - Upgraded Pillow to 12.0.0 for Python 3.14 support. - Adds a new `arcade.NoAracdeWindowError` exception type. This is raised when certain window operations are performed and there is no valid Arcade window found. Previously where this error would be raised, we raised a standard `RuntimeError`, this made it harder to properly catch and act accordingly. This new exception subclasses `RuntimeError`, so you can still catch this error the same way as before. The `arcade.get_window()` function will now raise this if there is no window. - Along with the new exception type, is a new `arcade.windows_exists()` function which will return True or False based on if there is currently an active window. -- GUI - - `UIManager` did not apply size hint of (0,0). Mainly an issue with `UIBoxLayout`. - - Allow multiple children in `UIScrollArea`. - - Fix `UIDropdown Overlay` positioning within a `UIScrollArea`. + + ## 3.3.3 diff --git a/arcade/VERSION b/arcade/VERSION index 3f09e91095..bcadb75013 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.3.3 \ No newline at end of file +4.0.0.dev1 \ No newline at end of file diff --git a/arcade/__init__.py b/arcade/__init__.py index 968e311bf8..e10cf5a079 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -59,7 +59,7 @@ def configure_logging(level: int | None = None): # Enable HiDPI support using stretch mode if os.environ.get("ARCADE_TEST"): - pyglet.options.dpi_scaling = "real" + pyglet.options.dpi_scaling = "platform" else: pyglet.options.dpi_scaling = "stretch" @@ -68,13 +68,6 @@ def configure_logging(level: int | None = None): if headless: pyglet.options.headless = headless - -# from arcade import utils -# Disable shadow window on macs and in headless mode. -# if sys.platform == "darwin" or os.environ.get("ARCADE_HEADLESS") or utils.is_raspberry_pi(): -# NOTE: We always disable shadow window now to have consistent behavior across platforms. -pyglet.options.shadow_window = False - # Imports from modules that don't do anything circular # Complex imports with potential circularity @@ -199,9 +192,10 @@ def configure_logging(level: int | None = None): from .tilemap import load_tilemap from .tilemap import TileMap -from .pymunk_physics_engine import PymunkPhysicsEngine -from .pymunk_physics_engine import PymunkPhysicsObject -from .pymunk_physics_engine import PymunkException +if sys.platform != "emscripten": + from .pymunk_physics_engine import PymunkPhysicsEngine + from .pymunk_physics_engine import PymunkPhysicsObject + from .pymunk_physics_engine import PymunkException from .version import VERSION @@ -238,6 +232,7 @@ def configure_logging(level: int | None = None): from arcade import math as math from arcade import shape_list as shape_list from arcade import hitbox as hitbox +from arcade import input as input from arcade import experimental as experimental from arcade.types import rect @@ -388,6 +383,7 @@ def configure_logging(level: int | None = None): "get_default_texture", "get_default_image", "hitbox", + "input", "experimental", "rect", "color", diff --git a/arcade/application.py b/arcade/application.py index 759e7a7c3b..2a883df819 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -12,7 +12,13 @@ from typing import TYPE_CHECKING import pyglet -import pyglet.gl as gl + +from arcade.utils import is_pyodide + +if is_pyodide(): + pyglet.options.backend = "webgl" + +import pyglet.config import pyglet.window.mouse from pyglet.display.base import Screen, ScreenMode from pyglet.event import EVENT_HANDLE_STATE, EVENT_UNHANDLED @@ -24,7 +30,7 @@ from arcade.context import ArcadeContext from arcade.gl.provider import get_arcade_context, set_provider from arcade.types import LBWH, Color, Rect, RGBANormalized, RGBOrA255 -from arcade.utils import is_pyodide, is_raspberry_pi +from arcade.utils import is_raspberry_pi from arcade.window_commands import get_display_size, set_window if TYPE_CHECKING: @@ -173,6 +179,7 @@ def __init__( gl_api = "webgl" if gl_api == "webgl": + pyglet.options.backend = "webgl" desired_gl_provider = "webgl" # Detect Raspberry Pi and switch to OpenGL ES 3.1 @@ -187,15 +194,34 @@ def __init__( config = None # Attempt to make window with antialiasing - if antialiasing: - try: - config = gl.Config( + if gl_api == "opengl" or gl_api == "opengles": + if antialiasing: + try: + config = pyglet.config.OpenGLConfig( + major_version=gl_version[0], + minor_version=gl_version[1], + opengl_api=gl_api.replace("open", ""), # type: ignore # pending: upstream fix + double_buffer=True, + sample_buffers=1, + samples=samples, + depth_size=24, + stencil_size=8, + red_size=8, + green_size=8, + blue_size=8, + alpha_size=8, + ) + except RuntimeError: + LOG.warning("Skipping antialiasing due missing hardware/driver support") + config = None + antialiasing = False + # If we still don't have a config + if not config: + config = pyglet.config.OpenGLConfig( major_version=gl_version[0], minor_version=gl_version[1], opengl_api=gl_api.replace("open", ""), # type: ignore # pending: upstream fix double_buffer=True, - sample_buffers=1, - samples=samples, depth_size=24, stencil_size=8, red_size=8, @@ -203,35 +229,14 @@ def __init__( blue_size=8, alpha_size=8, ) - display = pyglet.display.get_display() - screen = screen or display.get_default_screen() - if screen: - config = screen.get_best_config(config) - except pyglet.window.NoSuchConfigException: - LOG.warning("Skipping antialiasing due missing hardware/driver support") - config = None - antialiasing = False - # If we still don't have a config - if not config: - config = gl.Config( - major_version=gl_version[0], - minor_version=gl_version[1], - opengl_api=gl_api.replace("open", ""), # type: ignore # pending: upstream fix - double_buffer=True, - depth_size=24, - stencil_size=8, - red_size=8, - green_size=8, - blue_size=8, - alpha_size=8, - ) try: + # This type ignore is here because somehow Pyright thinks this is an Emscripten window super().__init__( width=width, height=height, caption=title, resizable=resizable, - config=config, + config=config, # type: ignore vsync=vsync, visible=visible, style=style, @@ -245,11 +250,15 @@ def __init__( "Unable to create an OpenGL 3.3+ context. " "Check to make sure your system supports OpenGL 3.3 or higher." ) - if antialiasing: - try: - gl.glEnable(gl.GL_MULTISAMPLE_ARB) - except gl.GLException: - LOG.warning("Warning: Anti-aliasing not supported on this computer.") + if gl_api == "opengl" or gl_api == "opengles": + if antialiasing: + import pyglet.graphics.api.gl as gl + import pyglet.graphics.api.gl.lib as gllib + + try: + gl.glEnable(gl.GL_MULTISAMPLE_ARB) + except gllib.GLException: + LOG.warning("Warning: Anti-aliasing not supported on this computer.") _setup_clock() _setup_fixed_clock(fixed_rate) @@ -348,8 +357,10 @@ def current_view(self) -> View | None: """ return self._current_view + # TODO: This is overriding the ctx function from Pyglet's BaseWindow which returns the + # SurfaceContext class from pyglet. We should probably rename this. @property - def ctx(self) -> ArcadeContext: + def ctx(self) -> ArcadeContext: # type: ignore """ The OpenGL context for this window. @@ -759,7 +770,7 @@ def on_mouse_scroll( """ return EVENT_UNHANDLED - def set_mouse_visible(self, visible: bool = True) -> None: + def set_mouse_cursor_visible(self, visible: bool = True) -> None: """ Set whether to show the system's cursor while over the window @@ -790,7 +801,7 @@ def set_mouse_visible(self, visible: bool = True) -> None: Args: visible: Whether to hide the system mouse cursor """ - super().set_mouse_visible(visible) + super().set_mouse_cursor_visible(visible) def on_action(self, action_name: str, state) -> None: """ @@ -846,6 +857,12 @@ def on_key_release(self, symbol: int, modifiers: int) -> EVENT_HANDLE_STATE: """ return EVENT_UNHANDLED + def before_draw(self) -> None: + """ + New event in base pyglet window. This is current unused in Arcade. + """ + pass + def on_draw(self) -> EVENT_HANDLE_STATE: """ Override this function to add your custom drawing code. @@ -1129,17 +1146,17 @@ def set_vsync(self, vsync: bool) -> None: """Set if we sync our draws to the monitors vertical sync rate.""" super().set_vsync(vsync) - def set_mouse_platform_visible(self, platform_visible=None) -> None: + def set_mouse_cursor_platform_visible(self, platform_visible=None) -> None: """ .. warning:: You are probably looking for - :meth:`~.Window.set_mouse_visible`! + :meth:`~.Window.set_mouse_cursor_visible`! This is a lower level function inherited from the pyglet window. For more information on what this means, see the documentation - for :py:meth:`pyglet.window.Window.set_mouse_platform_visible`. + for :py:meth:`pyglet.window.Window.set_mouse_cursor_platform_visible`. """ - super().set_mouse_platform_visible(platform_visible) + super().set_mouse_cursor_platform_visible(platform_visible) def set_exclusive_mouse(self, exclusive=True) -> None: """Capture the mouse.""" diff --git a/arcade/context.py b/arcade/context.py index 52e0cedae5..2cbe21a85f 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -10,17 +10,17 @@ import pyglet from PIL import Image -from pyglet import gl -from pyglet.graphics.shader import UniformBufferObject from pyglet.math import Mat4 import arcade from arcade.camera import Projector from arcade.camera.default import DefaultProjector from arcade.gl import BufferDescription, Context +from arcade.gl.buffer import Buffer from arcade.gl.compute_shader import ComputeShader from arcade.gl.framebuffer import Framebuffer from arcade.gl.program import Program +from arcade.gl.query import Query from arcade.gl.texture import Texture2D from arcade.gl.vertex_array import Geometry from arcade.texture_atlas import DefaultTextureAtlas, TextureAtlasBase @@ -56,10 +56,10 @@ def __init__( gc_mode: str = "context_gc", gl_api: str = "gl", ) -> None: - super().__init__(window, gc_mode=gc_mode, gl_api=gl_api) - # Set up a default orthogonal projection for sprites and shapes - self._window_block: UniformBufferObject = window.ubo + # Mypy can't figure out the dynamic creation of the matrices in Pyglet + # They are created based on the active backend. + self._window_block = window._matrices.ubo # type: ignore self.bind_window_block() self.blend_func = self.BLEND_DEFAULT @@ -84,21 +84,26 @@ def __init__( vertex_shader=":system:shaders/shape_element_list_vs.glsl", fragment_shader=":system:shaders/shape_element_list_fs.glsl", ) - self.sprite_list_program_no_cull: Program = self.load_program( - vertex_shader=":system:shaders/sprites/sprite_list_geometry_vs.glsl", - geometry_shader=":system:shaders/sprites/sprite_list_geometry_no_cull_geo.glsl", - fragment_shader=":system:shaders/sprites/sprite_list_geometry_fs.glsl", - ) - self.sprite_list_program_no_cull["sprite_texture"] = 0 - self.sprite_list_program_no_cull["uv_texture"] = 1 - self.sprite_list_program_cull: Program = self.load_program( - vertex_shader=":system:shaders/sprites/sprite_list_geometry_vs.glsl", - geometry_shader=":system:shaders/sprites/sprite_list_geometry_cull_geo.glsl", - fragment_shader=":system:shaders/sprites/sprite_list_geometry_fs.glsl", - ) - self.sprite_list_program_cull["sprite_texture"] = 0 - self.sprite_list_program_cull["uv_texture"] = 1 + if gl_api != "webgl": + self.sprite_list_program_no_cull: Program = self.load_program( + vertex_shader=":system:shaders/sprites/sprite_list_geometry_vs.glsl", + geometry_shader=":system:shaders/sprites/sprite_list_geometry_no_cull_geo.glsl", + fragment_shader=":system:shaders/sprites/sprite_list_geometry_fs.glsl", + ) + self.sprite_list_program_no_cull["sprite_texture"] = 0 + self.sprite_list_program_no_cull["uv_texture"] = 1 + + self.sprite_list_program_cull: Program = self.load_program( + vertex_shader=":system:shaders/sprites/sprite_list_geometry_vs.glsl", + geometry_shader=":system:shaders/sprites/sprite_list_geometry_cull_geo.glsl", + fragment_shader=":system:shaders/sprites/sprite_list_geometry_fs.glsl", + ) + self.sprite_list_program_cull["sprite_texture"] = 0 + self.sprite_list_program_cull["uv_texture"] = 1 + else: + self.sprite_list_program_no_cull = None # type: ignore + self.sprite_list_program_cull = None # type: ignore self.sprite_list_program_no_geo = self.load_program( vertex_shader=":system:shaders/sprites/sprite_list_simple_vs.glsl", @@ -114,14 +119,18 @@ def __init__( self.sprite_list_program_no_geo["index_data"] = 6 # Geo shader single sprite program - self.sprite_program_single = self.load_program( - vertex_shader=":system:shaders/sprites/sprite_single_vs.glsl", - geometry_shader=":system:shaders/sprites/sprite_list_geometry_no_cull_geo.glsl", - fragment_shader=":system:shaders/sprites/sprite_list_geometry_fs.glsl", - ) - self.sprite_program_single["sprite_texture"] = 0 - self.sprite_program_single["uv_texture"] = 1 - self.sprite_program_single["spritelist_color"] = 1.0, 1.0, 1.0, 1.0 + if gl_api != "webgl": + self.sprite_program_single = self.load_program( + vertex_shader=":system:shaders/sprites/sprite_single_vs.glsl", + geometry_shader=":system:shaders/sprites/sprite_list_geometry_no_cull_geo.glsl", + fragment_shader=":system:shaders/sprites/sprite_list_geometry_fs.glsl", + ) + self.sprite_program_single["sprite_texture"] = 0 + self.sprite_program_single["uv_texture"] = 1 + self.sprite_program_single["spritelist_color"] = 1.0, 1.0, 1.0, 1.0 + else: + self.sprite_program_single = None # type: ignore + # Non-geometry shader single sprite program self.sprite_program_single_simple = self.load_program( vertex_shader=":system:shaders/sprites/sprite_single_simple_vs.glsl", @@ -180,28 +189,34 @@ def __init__( fragment_shader=":system:shaders/atlas/resize_simple_fs.glsl", ) self.atlas_resize_program["atlas_old"] = 0 # Configure texture channels - self.atlas_resize_program["atlas_new"] = 1 - self.atlas_resize_program["texcoords_old"] = 2 - self.atlas_resize_program["texcoords_new"] = 3 - - # NOTE: These should not be created when WebGL is used - # SpriteList collision resources - # Buffer version of the collision detection program. - self.collision_detection_program = self.load_program( - vertex_shader=":system:shaders/collision/col_trans_vs.glsl", - geometry_shader=":system:shaders/collision/col_trans_gs.glsl", - ) - # Texture version of the collision detection program. - self.collision_detection_program_simple = self.load_program( - vertex_shader=":system:shaders/collision/col_tex_trans_vs.glsl", - geometry_shader=":system:shaders/collision/col_tex_trans_gs.glsl", - ) - self.collision_detection_program_simple["pos_angle_data"] = 0 - self.collision_detection_program_simple["size_data"] = 1 - self.collision_detection_program_simple["index_data"] = 2 + self.atlas_resize_program["texcoords_old"] = 1 + self.atlas_resize_program["texcoords_new"] = 2 + + if gl_api != "webgl": + # SpriteList collision resources + # Buffer version of the collision detection program. + self.collision_detection_program: Program | None = self.load_program( + vertex_shader=":system:shaders/collision/col_trans_vs.glsl", + geometry_shader=":system:shaders/collision/col_trans_gs.glsl", + ) + # Texture version of the collision detection program. + self.collision_detection_program_simple: Program | None = self.load_program( + vertex_shader=":system:shaders/collision/col_tex_trans_vs.glsl", + geometry_shader=":system:shaders/collision/col_tex_trans_gs.glsl", + ) + self.collision_detection_program_simple["pos_angle_data"] = 0 + self.collision_detection_program_simple["size_data"] = 1 + self.collision_detection_program_simple["index_data"] = 2 - self.collision_buffer = self.buffer(reserve=1024 * 4) - self.collision_query = self.query(samples=False, time=False, primitives=True) + self.collision_buffer: Buffer | None = self.buffer(reserve=1024 * 4) + self.collision_query: Query | None = self.query( + samples=False, time=False, primitives=True + ) + else: + self.collision_detection_program = None + self.collision_detection_program_simple = None + self.collision_buffer = None + self.collision_query = None # General Utility @@ -251,7 +266,10 @@ def __init__( ["in_vert"], ), BufferDescription( - self.shape_line_buffer_pos, "4f", ["in_instance_pos"], instanced=True + self.shape_line_buffer_pos, + "4f", + ["in_instance_pos"], + instanced=True, ), ], mode=self.TRIANGLE_STRIP, @@ -300,7 +318,8 @@ def __init__( self.label_cache: dict[str, arcade.Text] = {} # self.active_program = None - self.point_size = 1.0 + if gl_api != "webgl": + self.point_size = 1.0 def reset(self) -> None: """ @@ -326,12 +345,8 @@ def bind_window_block(self) -> None: This should always be bound to index 0 so all shaders have access to them. """ - gl.glBindBufferRange( - gl.GL_UNIFORM_BUFFER, - 0, - self._window_block.buffer.id, - 0, # type: ignore - 128, # 32 x 32bit floats (two mat4) # type: ignore + raise NotImplementedError( + "The currently selected GL backend does not implement ArcadeContext.bind_window_block" ) @property diff --git a/arcade/examples/dual_stick_shooter.py b/arcade/examples/dual_stick_shooter.py index d8f7711efe..7f7b2a7954 100644 --- a/arcade/examples/dual_stick_shooter.py +++ b/arcade/examples/dual_stick_shooter.py @@ -36,11 +36,9 @@ def dump_obj(obj): def dump_controller(controller): print(f"========== {controller}") - print(f"Left X {controller.leftx}") - print(f"Left Y {controller.lefty}") + print(f"Left X,Y {controller.leftstick[0]},{controller.leftstick[1]}") print(f"Left Trigger {controller.lefttrigger}") - print(f"Right X {controller.rightx}") - print(f"Right Y {controller.righty}") + print(f"Right X,Y {controller.rightstick[0]},{controller.rightstick[1]}") print(f"Right Trigger {controller.righttrigger}") print("========== Extra controller") dump_obj(controller) @@ -58,11 +56,11 @@ def dump_controller_state(ticks, controller): num_fmts = ["{:5.2f}"] * 6 fmt_str += " ".join(num_fmts) print(fmt_str.format(ticks, - controller.leftx, - controller.lefty, + controller.leftstick[0], + controller.leftstick[1], controller.lefttrigger, - controller.rightx, - controller.righty, + controller.rightstick[0], + controller.rightstick[0], controller.righttrigger, )) @@ -202,8 +200,10 @@ def on_update(self, delta_time): if self.controller: # Controller input - movement + left_position = self.controller.leftstick + right_position = self.controller.rightstick move_x, move_y, move_angle = get_stick_position( - self.controller.leftx, self.controller.lefty + left_position[0], left_position[1] ) if move_angle: self.player.change_x = move_x * MOVEMENT_SPEED @@ -215,7 +215,7 @@ def on_update(self, delta_time): # Controller input - shooting shoot_x, shoot_y, shoot_angle = get_stick_position( - self.controller.rightx, self.controller.righty + right_position[0], right_position[1] ) if shoot_angle: self.spawn_bullet(shoot_angle) diff --git a/arcade/examples/follow_path.py b/arcade/examples/follow_path.py index 79c5e24ae0..11c4ff916c 100644 --- a/arcade/examples/follow_path.py +++ b/arcade/examples/follow_path.py @@ -93,7 +93,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/gl/__init__.py b/arcade/examples/gl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/examples/gl/tessellation.py b/arcade/examples/gl/tessellation.py index 8a3efb3ee2..6cd25f7e7d 100644 --- a/arcade/examples/gl/tessellation.py +++ b/arcade/examples/gl/tessellation.py @@ -10,7 +10,7 @@ import arcade from arcade.gl import BufferDescription -import pyglet.gl +from pyglet.graphics.api.gl import GL_PATCHES WINDOW_WIDTH = 1280 WINDOW_HEIGHT = 720 @@ -107,7 +107,7 @@ def __init__(self, width, height, title): def on_draw(self): self.clear() self.program["time"] = self.time - self.geometry.render(self.program, mode=pyglet.gl.GL_PATCHES) + self.geometry.render(self.program, mode=GL_PATCHES) if __name__ == "__main__": diff --git a/arcade/examples/gl/texture_compression.py b/arcade/examples/gl/texture_compression.py index 7f31b311b1..aa0a3af132 100644 --- a/arcade/examples/gl/texture_compression.py +++ b/arcade/examples/gl/texture_compression.py @@ -12,7 +12,7 @@ import PIL.Image import arcade import arcade.gl -from pyglet import gl +from pyglet.graphics.api import gl class CompressedTextures(arcade.Window): diff --git a/arcade/examples/gui/__init__.py b/arcade/examples/gui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/examples/gui/exp_controller_inventory.py b/arcade/examples/gui/exp_controller_inventory.py index ffe99f02b0..5359b8746e 100644 --- a/arcade/examples/gui/exp_controller_inventory.py +++ b/arcade/examples/gui/exp_controller_inventory.py @@ -412,8 +412,8 @@ def on_draw_before_ui(self): if __name__ == "__main__": # pixelate the font - pyglet.font.base.Font.texture_min_filter = GL_NEAREST - pyglet.font.base.Font.texture_mag_filter = GL_NEAREST + pyglet.font.base.Font.texture_min_filter = GL_NEAREST # type: ignore + pyglet.font.base.Font.texture_mag_filter = GL_NEAREST # type: ignore load_kenney_fonts() diff --git a/arcade/examples/perf_test/__init__.py b/arcade/examples/perf_test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/examples/platform_tutorial/__init__.py b/arcade/examples/platform_tutorial/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/examples/slime_invaders.py b/arcade/examples/slime_invaders.py index f4ef41d7e7..1c7280b730 100644 --- a/arcade/examples/slime_invaders.py +++ b/arcade/examples/slime_invaders.py @@ -74,7 +74,7 @@ def __init__(self): self.enemy_change_x = -ENEMY_SPEED # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Load sounds. Sounds from kenney.nl self.gun_sound = arcade.load_sound(":resources:sounds/hurt5.wav") @@ -196,7 +196,7 @@ def on_draw(self): # Draw game over if the game state is such if self.game_state == GAME_OVER: self.game_over_text.draw() - self.window.set_mouse_visible(True) + self.window.set_mouse_cursor_visible(True) def on_key_press(self, key, modifiers): if key == arcade.key.ESCAPE: diff --git a/arcade/examples/snow.py b/arcade/examples/snow.py index 35163073e1..e1585a7de4 100644 --- a/arcade/examples/snow.py +++ b/arcade/examples/snow.py @@ -60,7 +60,7 @@ def __init__(self): self.snowflake_list = arcade.SpriteList() # Don't show the mouse pointer - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.BLACK diff --git a/arcade/examples/sprite_bullets.py b/arcade/examples/sprite_bullets.py index c8652d3f9b..837f61c545 100644 --- a/arcade/examples/sprite_bullets.py +++ b/arcade/examples/sprite_bullets.py @@ -41,7 +41,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Load sounds. Sounds from kenney.nl self.gun_sound = arcade.load_sound(":resources:sounds/hurt5.wav") diff --git a/arcade/examples/sprite_change_coins.py b/arcade/examples/sprite_change_coins.py index 742d3f2b37..b1cc6c94b6 100644 --- a/arcade/examples/sprite_change_coins.py +++ b/arcade/examples/sprite_change_coins.py @@ -77,7 +77,7 @@ def setup(self): self.coin_list.append(coin) # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins.py b/arcade/examples/sprite_collect_coins.py index 41c2cb220a..45fe45955e 100644 --- a/arcade/examples/sprite_collect_coins.py +++ b/arcade/examples/sprite_collect_coins.py @@ -42,7 +42,7 @@ def __init__(self): self.score_display = None # Hide the mouse cursor while it's over the window - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_background.py b/arcade/examples/sprite_collect_coins_background.py index ff41d33f19..4c30da178f 100644 --- a/arcade/examples/sprite_collect_coins_background.py +++ b/arcade/examples/sprite_collect_coins_background.py @@ -47,7 +47,7 @@ def __init__(self): self.score_text = arcade.Text("Score: 0", 10, 20, arcade.color.WHITE, 14) # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_diff_levels.py b/arcade/examples/sprite_collect_coins_diff_levels.py index 503499d1cf..209af44dfa 100644 --- a/arcade/examples/sprite_collect_coins_diff_levels.py +++ b/arcade/examples/sprite_collect_coins_diff_levels.py @@ -74,7 +74,7 @@ def __init__(self): self.level = 1 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_move_bouncing.py b/arcade/examples/sprite_collect_coins_move_bouncing.py index 256d5ec26b..90513f88e2 100644 --- a/arcade/examples/sprite_collect_coins_move_bouncing.py +++ b/arcade/examples/sprite_collect_coins_move_bouncing.py @@ -67,7 +67,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_move_circle.py b/arcade/examples/sprite_collect_coins_move_circle.py index 4b05dff104..0dbcc1d48f 100644 --- a/arcade/examples/sprite_collect_coins_move_circle.py +++ b/arcade/examples/sprite_collect_coins_move_circle.py @@ -106,7 +106,7 @@ def setup(self): self.coin_list.append(coin) # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Set the background color self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_coins_move_down.py b/arcade/examples/sprite_collect_coins_move_down.py index 15b93fc3ab..c9b1f033f5 100644 --- a/arcade/examples/sprite_collect_coins_move_down.py +++ b/arcade/examples/sprite_collect_coins_move_down.py @@ -64,7 +64,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_collect_rotating.py b/arcade/examples/sprite_collect_rotating.py index 28b37ebb12..674c01c094 100644 --- a/arcade/examples/sprite_collect_rotating.py +++ b/arcade/examples/sprite_collect_rotating.py @@ -46,7 +46,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_explosion_bitmapped.py b/arcade/examples/sprite_explosion_bitmapped.py index 04b278a0da..1d04e5c8c6 100644 --- a/arcade/examples/sprite_explosion_bitmapped.py +++ b/arcade/examples/sprite_explosion_bitmapped.py @@ -77,7 +77,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Pre-load the animation frames. We don't do this in the __init__ # of the explosion sprite because it diff --git a/arcade/examples/sprite_explosion_particles.py b/arcade/examples/sprite_explosion_particles.py index 5b12d7f3a6..c33cfa18ed 100644 --- a/arcade/examples/sprite_explosion_particles.py +++ b/arcade/examples/sprite_explosion_particles.py @@ -163,7 +163,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) # Load sounds. Sounds from kenney.nl self.gun_sound = arcade.sound.load_sound(":resources:sounds/laser2.wav") diff --git a/arcade/examples/sprite_follow_simple.py b/arcade/examples/sprite_follow_simple.py index 8dcaa20b48..325b404115 100644 --- a/arcade/examples/sprite_follow_simple.py +++ b/arcade/examples/sprite_follow_simple.py @@ -68,7 +68,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_follow_simple_2.py b/arcade/examples/sprite_follow_simple_2.py index 2e08bbedff..29b4d7e266 100644 --- a/arcade/examples/sprite_follow_simple_2.py +++ b/arcade/examples/sprite_follow_simple_2.py @@ -87,7 +87,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/sprite_properties.py b/arcade/examples/sprite_properties.py index 90a4d67f08..cbcc30c2a7 100644 --- a/arcade/examples/sprite_properties.py +++ b/arcade/examples/sprite_properties.py @@ -44,7 +44,7 @@ def __init__(self): self.trigger_sprite = None # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/arcade/examples/view_instructions_and_game_over.py b/arcade/examples/view_instructions_and_game_over.py index bcebdd0b2b..4eeb1fcd44 100644 --- a/arcade/examples/view_instructions_and_game_over.py +++ b/arcade/examples/view_instructions_and_game_over.py @@ -97,7 +97,7 @@ def on_show_view(self): self.window.background_color = arcade.color.AMAZON # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) def on_draw(self): self.clear() @@ -134,7 +134,7 @@ def on_update(self, delta_time): if len(self.coin_list) == 0: game_over_view = GameOverView() game_over_view.time_taken = self.time_taken - self.window.set_mouse_visible(True) + self.window.set_mouse_cursor_visible(True) self.window.show_view(game_over_view) def on_mouse_motion(self, x, y, _dx, _dy): diff --git a/arcade/future/input/input_manager_example.py b/arcade/experimental/input_manager_example.py similarity index 96% rename from arcade/future/input/input_manager_example.py rename to arcade/experimental/input_manager_example.py index 56bc942ee7..34fd31b36c 100644 --- a/arcade/future/input/input_manager_example.py +++ b/arcade/experimental/input_manager_example.py @@ -1,4 +1,11 @@ # type: ignore +""" +Example for handling input using the Arcade InputManager + +If Python and Arcade are installed, this example can be run from the command line with: +python -m arcade.examples.input_manager +""" + import random from collections.abc import Sequence @@ -6,7 +13,7 @@ from pyglet.input import Controller import arcade -from arcade.future.input import ActionState, ControllerAxes, ControllerButtons, InputManager, Keys +from arcade.input import ActionState, ControllerAxes, ControllerButtons, InputManager, Keys WINDOW_WIDTH = 1280 WINDOW_HEIGHT = 720 diff --git a/arcade/experimental/shapes_buffered_2_glow.py b/arcade/experimental/shapes_buffered_2_glow.py index 2abc284e8b..5cfa525df2 100644 --- a/arcade/experimental/shapes_buffered_2_glow.py +++ b/arcade/experimental/shapes_buffered_2_glow.py @@ -9,7 +9,7 @@ import random -from pyglet import gl +from pyglet.graphics.api import gl import arcade from arcade.experimental import postprocessing diff --git a/arcade/future/__init__.py b/arcade/future/__init__.py index 1f42c56507..735aa6c81c 100644 --- a/arcade/future/__init__.py +++ b/arcade/future/__init__.py @@ -1,9 +1,8 @@ from . import video from . import light -from . import input from . import background from . import splash from .texture_render_target import RenderTargetTexture -__all__ = ["video", "light", "input", "background", "RenderTargetTexture", "splash"] +__all__ = ["video", "light", "background", "RenderTargetTexture", "splash"] diff --git a/arcade/future/video/video_player.py b/arcade/future/video/video_player.py index 4962011ae6..8c61d896b8 100644 --- a/arcade/future/video/video_player.py +++ b/arcade/future/video/video_player.py @@ -23,7 +23,7 @@ class VideoPlayer: """ def __init__(self, path: str | Path, loop: bool = False): - self.player = pyglet.media.Player() + self.player = pyglet.media.VideoPlayer() self.player.loop = loop self.player.queue(pyglet.media.load(str(arcade.resources.resolve(path)))) self.player.play() @@ -77,9 +77,9 @@ def get_video_size(self) -> tuple[int, int]: width = video_format.width height = video_format.height if video_format.sample_aspect > 1: - width *= video_format.sample_aspect + width = int(width * video_format.sample_aspect) elif video_format.sample_aspect < 1: - height /= video_format.sample_aspect + height = int(height / video_format.sample_aspect) return width, height diff --git a/arcade/future/video/video_record_cv2.py b/arcade/future/video/video_record_cv2.py index 9dd7089f62..3265bc7ab7 100644 --- a/arcade/future/video/video_record_cv2.py +++ b/arcade/future/video/video_record_cv2.py @@ -21,7 +21,7 @@ import cv2 # type: ignore import numpy # type: ignore -import pyglet.gl as gl +import pyglet.graphics.api.gl as gl import arcade diff --git a/arcade/gl/backends/opengl/buffer.py b/arcade/gl/backends/opengl/buffer.py index 0fc7d40ef2..e6245c7485 100644 --- a/arcade/gl/backends/opengl/buffer.py +++ b/arcade/gl/backends/opengl/buffer.py @@ -4,9 +4,10 @@ from ctypes import byref, string_at from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl -from arcade.gl.buffer import Buffer +from arcade.gl.buffer import Buffer, _usages from arcade.types import BufferProtocol from .utils import data_to_ctypes @@ -14,12 +15,6 @@ if TYPE_CHECKING: from arcade.gl import Context -_usages = { - "static": gl.GL_STATIC_DRAW, - "dynamic": gl.GL_DYNAMIC_DRAW, - "stream": gl.GL_STREAM_DRAW, -} - class OpenGLBuffer(Buffer): """OpenGL buffer object. Buffers store byte data and upload it @@ -120,7 +115,7 @@ def delete_glo(ctx: Context, glo: gl.GLuint): The OpenGL buffer id """ # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: + if core.current_context is None: return if glo.value != 0: diff --git a/arcade/gl/backends/opengl/compute_shader.py b/arcade/gl/backends/opengl/compute_shader.py index 67a1e8543c..f3c88a4ea3 100644 --- a/arcade/gl/backends/opengl/compute_shader.py +++ b/arcade/gl/backends/opengl/compute_shader.py @@ -14,7 +14,8 @@ ) from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.compute_shader import ComputeShader @@ -188,7 +189,7 @@ def delete_glo(ctx, prog_id): """ # Check to see if the context was already cleaned up from program # shut down. If so, we don't need to delete the shaders. - if gl.current_context is None: + if core.current_context is None: return gl.glDeleteProgram(prog_id) diff --git a/arcade/gl/backends/opengl/context.py b/arcade/gl/backends/opengl/context.py index 24f34e47a3..ca110c5d8b 100644 --- a/arcade/gl/backends/opengl/context.py +++ b/arcade/gl/backends/opengl/context.py @@ -2,7 +2,7 @@ from typing import Dict, Iterable, List, Sequence, Tuple import pyglet -from pyglet import gl +from pyglet.graphics.api import gl from arcade.context import ArcadeContext from arcade.gl import enums @@ -29,7 +29,10 @@ class OpenGLContext(Context): _valid_apis = ("opengl", "opengles") def __init__( - self, window: pyglet.window.Window, gc_mode: str = "context_gc", gl_api: str = "opengl" + self, + window: pyglet.window.Window, + gc_mode: str = "context_gc", + gl_api: str = "opengl", ): super().__init__(window, gc_mode) @@ -44,6 +47,12 @@ def __init__( self._gl_version = (self._info.MAJOR_VERSION, self._info.MINOR_VERSION) + # This can't be set in the abstract context because not all backends + # support primitive restart, and the getter in those backends will raise + # a NotImplementedError. So we need to do this specifically on the + # backends that support it + self.primitive_restart_index = self._primitive_restart_index + # Hardcoded states # This should always be enabled # gl.glEnable(gl.GL_TEXTURE_CUBE_MAP_SEAMLESS) @@ -57,7 +66,9 @@ def __init__( # Assumed to be supported in gles self._ext_separate_shader_objects_enabled = True if self.gl_api == "opengl": - have_ext = gl.gl_info.have_extension("GL_ARB_separate_shader_objects") + have_ext = self.window.context.get_info().have_extension( + "GL_ARB_separate_shader_objects" + ) # type: ignore This is guaranteed to be an OpenGLSurfaceContext self._ext_separate_shader_objects_enabled = self.gl_version >= (4, 1) or have_ext # We enable scissor testing by default. @@ -78,7 +89,7 @@ def gl_version(self) -> Tuple[int, int]: @Context.extensions.getter def extensions(self) -> set[str]: - return gl.gl_info.get_extensions() + return self.window.context.get_info().extensions # type: ignore @property def error(self) -> str | None: @@ -213,7 +224,11 @@ def _create_default_framebuffer(self) -> OpenGLDefaultFrameBuffer: return OpenGLDefaultFrameBuffer(self) def buffer( - self, *, data: BufferProtocol | None = None, reserve: int = 0, usage: str = "static" + self, + *, + data: BufferProtocol | None = None, + reserve: int = 0, + usage: str = "static", ) -> OpenGLBuffer: return OpenGLBuffer(self, data, reserve=reserve, usage=usage) @@ -264,10 +279,10 @@ def program( return OpenGLProgram( self, vertex_shader=source_vs.get_source(defines=defines), - fragment_shader=source_fs.get_source(defines=defines) if source_fs else None, - geometry_shader=source_geo.get_source(defines=defines) if source_geo else None, - tess_control_shader=source_tc.get_source(defines=defines) if source_tc else None, - tess_evaluation_shader=source_te.get_source(defines=defines) if source_te else None, + fragment_shader=(source_fs.get_source(defines=defines) if source_fs else None), + geometry_shader=(source_geo.get_source(defines=defines) if source_geo else None), + tess_control_shader=(source_tc.get_source(defines=defines) if source_tc else None), + tess_evaluation_shader=(source_te.get_source(defines=defines) if source_te else None), varyings=out_attributes, varyings_capture_mode=varyings_capture_mode, ) @@ -288,7 +303,7 @@ def geometry( ) def compute_shader(self, *, source: str, common: Iterable[str] = ()) -> OpenGLComputeShader: - src = ShaderSource(self, source, common, pyglet.gl.GL_COMPUTE_SHADER) + src = ShaderSource(self, source, common, gl.GL_COMPUTE_SHADER) return OpenGLComputeShader(self, src.get_source()) def texture( @@ -337,7 +352,9 @@ def framebuffer( depth_attachment: OpenGLTexture2D | None = None, ) -> OpenGLFramebuffer: return OpenGLFramebuffer( - self, color_attachments=color_attachments or [], depth_attachment=depth_attachment + self, + color_attachments=color_attachments or [], + depth_attachment=depth_attachment, ) def copy_framebuffer( @@ -416,6 +433,15 @@ def __init__(self, *args, **kwargs): OpenGLContext.__init__(self, *args, **kwargs) ArcadeContext.__init__(self, *args, **kwargs) + def bind_window_block(self): + gl.glBindBufferRange( + gl.GL_UNIFORM_BUFFER, + 0, + self._window_block.buffer.id, + 0, # type: ignore + 128, # 32 x 32bit floats (two mat4) # type: ignore + ) + class OpenGLInfo(Info): """OpenGL info and capabilities""" @@ -491,7 +517,7 @@ def get_int_tuple(self, enum, length: int): values = (c_int * length)() gl.glGetIntegerv(enum, values) return tuple(values) - except pyglet.gl.lib.GLException: + except gl.lib.GLException: return tuple([0] * length) def get(self, enum, default=0) -> int: @@ -521,7 +547,7 @@ def get_float(self, enum, default=0.0) -> float: value = c_float() gl.glGetFloatv(enum, value) return value.value - except pyglet.gl.lib.GLException: + except gl.GLException: return default def get_str(self, enum) -> str: @@ -533,5 +559,5 @@ def get_str(self, enum) -> str: """ try: return cast(gl.glGetString(enum), c_char_p).value.decode() # type: ignore - except pyglet.gl.lib.GLException: + except gl.GLException: return "Unknown" diff --git a/arcade/gl/backends/opengl/framebuffer.py b/arcade/gl/backends/opengl/framebuffer.py index 419cd2f86f..eb9fac5a6f 100644 --- a/arcade/gl/backends/opengl/framebuffer.py +++ b/arcade/gl/backends/opengl/framebuffer.py @@ -4,7 +4,8 @@ from ctypes import Array, c_int, c_uint, string_at from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.framebuffer import DefaultFrameBuffer, Framebuffer from arcade.gl.types import pixel_formats @@ -208,12 +209,22 @@ def clear( if len(color) == 3: clear_color = color[0] / 255, color[1] / 255, color[2] / 255, 1.0 elif len(color) == 4: - clear_color = color[0] / 255, color[1] / 255, color[2] / 255, color[3] / 255 + clear_color = ( + color[0] / 255, + color[1] / 255, + color[2] / 255, + color[3] / 255, + ) else: raise ValueError("Color should be a 3 or 4 component tuple") elif color_normalized is not None: if len(color_normalized) == 3: - clear_color = color_normalized[0], color_normalized[1], color_normalized[2], 1.0 + clear_color = ( + color_normalized[0], + color_normalized[1], + color_normalized[2], + 1.0, + ) elif len(color_normalized) == 4: clear_color = color_normalized else: @@ -304,7 +315,7 @@ def delete_glo(ctx, framebuffer_id): framebuffer_id: Framebuffer id destroy (glo) """ - if gl.current_context is None: + if core.current_context is None: return gl.glDeleteFramebuffers(1, framebuffer_id) diff --git a/arcade/gl/backends/opengl/glsl.py b/arcade/gl/backends/opengl/glsl.py index 2abcf7650f..2827f6b683 100644 --- a/arcade/gl/backends/opengl/glsl.py +++ b/arcade/gl/backends/opengl/glsl.py @@ -1,7 +1,7 @@ import re from typing import TYPE_CHECKING, Iterable -from pyglet import gl +from pyglet.graphics.api import gl if TYPE_CHECKING: from .context import Context as ArcadeGlContext diff --git a/arcade/gl/backends/opengl/program.py b/arcade/gl/backends/opengl/program.py index 61b08cfd93..cde9cead01 100644 --- a/arcade/gl/backends/opengl/program.py +++ b/arcade/gl/backends/opengl/program.py @@ -15,7 +15,8 @@ ) from typing import TYPE_CHECKING, Any, Iterable -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.exceptions import ShaderException from arcade.gl.program import Program @@ -256,7 +257,7 @@ def delete_glo(ctx, prog_id): """ # Check to see if the context was already cleaned up from program # shut down. If so, we don't need to delete the shaders. - if gl.current_context is None: + if core.current_context is None: return gl.glDeleteProgram(prog_id) diff --git a/arcade/gl/backends/opengl/query.py b/arcade/gl/backends/opengl/query.py index 76b66b5470..2aa5236521 100644 --- a/arcade/gl/backends/opengl/query.py +++ b/arcade/gl/backends/opengl/query.py @@ -3,7 +3,8 @@ import weakref from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.query import Query @@ -118,7 +119,7 @@ def delete_glo(ctx, glos) -> None: This is automatically called when the object is garbage collected. """ - if gl.current_context is None: + if core.current_context is None: return for glo in glos: diff --git a/arcade/gl/backends/opengl/sampler.py b/arcade/gl/backends/opengl/sampler.py index 538c71767f..4f59a72325 100644 --- a/arcade/gl/backends/opengl/sampler.py +++ b/arcade/gl/backends/opengl/sampler.py @@ -4,7 +4,7 @@ from ctypes import byref, c_uint32 from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics.api import gl from arcade.gl.sampler import Sampler from arcade.gl.types import PyGLuint, compare_funcs diff --git a/arcade/gl/backends/opengl/texture.py b/arcade/gl/backends/opengl/texture.py index 13f668e91b..0de0e65a91 100644 --- a/arcade/gl/backends/opengl/texture.py +++ b/arcade/gl/backends/opengl/texture.py @@ -4,7 +4,8 @@ from ctypes import byref, string_at from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.texture import Texture2D from arcade.gl.types import ( @@ -658,7 +659,7 @@ def delete_glo(ctx: "Context", glo: gl.GLuint): glo: The OpenGL texture id """ # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: + if core.current_context is None: return if glo.value != 0: diff --git a/arcade/gl/backends/opengl/texture_array.py b/arcade/gl/backends/opengl/texture_array.py index 8b1456989d..153cc3a884 100644 --- a/arcade/gl/backends/opengl/texture_array.py +++ b/arcade/gl/backends/opengl/texture_array.py @@ -4,7 +4,8 @@ from ctypes import byref, string_at from typing import TYPE_CHECKING -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.texture_array import TextureArray from arcade.gl.types import ( @@ -602,7 +603,7 @@ def delete_glo(ctx: "Context", glo: gl.GLuint): glo: The OpenGL texture id """ # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: + if core.current_context is None: return if glo.value != 0: diff --git a/arcade/gl/backends/opengl/uniform.py b/arcade/gl/backends/opengl/uniform.py index f664bc320d..55059cded7 100644 --- a/arcade/gl/backends/opengl/uniform.py +++ b/arcade/gl/backends/opengl/uniform.py @@ -2,7 +2,7 @@ from ctypes import POINTER, c_double, c_float, c_int, c_uint, cast from typing import Callable -from pyglet import gl +from pyglet.graphics.api import gl from arcade.gl.exceptions import ShaderException diff --git a/arcade/gl/backends/opengl/vertex_array.py b/arcade/gl/backends/opengl/vertex_array.py index 27293978e0..dfc2ea0862 100644 --- a/arcade/gl/backends/opengl/vertex_array.py +++ b/arcade/gl/backends/opengl/vertex_array.py @@ -4,7 +4,8 @@ from ctypes import byref, c_void_p from typing import TYPE_CHECKING, Sequence -from pyglet import gl +from pyglet.graphics import core +from pyglet.graphics.api import gl from arcade.gl.types import BufferDescription, GLenumLike, GLuintLike, gl_name from arcade.gl.vertex_array import Geometry, VertexArray @@ -97,7 +98,7 @@ def delete_glo(ctx: Context, glo: gl.GLuint) -> None: This is automatically called when this object is garbage collected. """ # If we have no context, then we are shutting down, so skip this - if gl.current_context is None: + if core.current_context is None: return if glo.value != 0: @@ -107,7 +108,10 @@ def delete_glo(ctx: Context, glo: gl.GLuint) -> None: ctx.stats.decr("vertex_array") def _build( - self, program: Program, content: Sequence[BufferDescription], index_buffer: Buffer | None + self, + program: Program, + content: Sequence[BufferDescription], + index_buffer: Buffer | None, ) -> None: """ Build a vertex array compatible with the program passed in. diff --git a/arcade/gl/backends/webgl/__init__.py b/arcade/gl/backends/webgl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/arcade/gl/backends/webgl/buffer.py b/arcade/gl/backends/webgl/buffer.py new file mode 100644 index 0000000000..574ad92e87 --- /dev/null +++ b/arcade/gl/backends/webgl/buffer.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import js # type: ignore + +from arcade.gl import enums +from arcade.gl.buffer import Buffer, _usages +from arcade.types import BufferProtocol + +from .utils import data_to_memoryview + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLBuffer as JSWebGLBuffer + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLBuffer(Buffer): + __slots__ = "_glo", "_usage" + + def __init__( + self, + ctx: WebGLContext, + data: BufferProtocol | None = None, + reserve: int = 0, + usage: str = "static", + ): + super().__init__(ctx) + self._ctx: WebGLContext = ctx + self._usage = _usages[usage] + self._glo: JSWebGLBuffer | None = ctx._gl.createBuffer() + + if self._glo is None: + raise RuntimeError("Cannot create Buffer object.") + + ctx._gl.bindBuffer(enums.ARRAY_BUFFER, self._glo) + + if data is not None and len(data) > 0: # type: ignore + self._size, data = data_to_memoryview(data) + js_array_buffer = js.ArrayBuffer.new(self._size) + js_array_buffer.assign(data) + ctx._gl.bufferData(enums.ARRAY_BUFFER, js_array_buffer, self._usage) + elif reserve > 0: + self._size = reserve + # WebGL allows passing an integer size instead of a memoryview + # to populate the buffer with zero bytes. We have to provide the bytes + # ourselves in OpenGL + ctx._gl.bufferData(enums.ARRAY_BUFFER, self._size, self._usage) + else: + raise ValueError("Buffer takes byte data or number of reserved bytes") + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLBuffer.delete_glo, self.ctx, self._glo) # type: ignore + + def __repr__(self): + return f"" + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + @property + def glo(self) -> JSWebGLBuffer | None: + return self._glo + + def delete(self) -> None: + WebGLBuffer.delete_glo(self._ctx, self._glo) # type: ignore + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLBuffer | None): + if glo is not None: + ctx._gl.deleteBuffer(glo) + + ctx.stats.decr("buffer") + + def read(self, size: int = -1, offset: int = 0) -> bytes: + # framebuffer has kind of an example to do this but it's with typed arrays + # need to figure out how to read to a generic ArrayBuffer and get a memoryview from that + # for generic buffers since we have no idea what the data type might be + raise NotImplementedError("Not done yet") + + def write(self, data: BufferProtocol, offset: int = 0): + self._ctx._gl.bindBuffer(enums.ARRAY_BUFFER, self._glo) + size, data = data_to_memoryview(data) + js_array_buffer = js.ArrayBuffer.new(size) + js_array_buffer.assign(data) + # Ensure we don't write outside the buffer + size = min(size, self._size - offset) + if size < 0: + raise ValueError("Attempting to write negative number bytes to buffer") + self._ctx._gl.bufferSubData(enums.ARRAY_BUFFER, offset, js_array_buffer) + + def copy_from_buffer(self, source: WebGLBuffer, size=-1, offset=0, source_offset=0): + if size == -1: + size = source.size + + if size + source_offset > source.size: + raise ValueError("Attempting to read outside the source buffer") + + if size + offset > self._size: + raise ValueError("Attempting to write outside the buffer") + + self._ctx._gl.bindBuffer(enums.COPY_READ_BUFFER, source.glo) + self._ctx._gl.bindBuffer(enums.COPY_WRITE_BUFFER, self._glo) + self._ctx._gl.copyBufferSubData( + enums.COPY_READ_BUFFER, enums.COPY_WRITE_BUFFER, source_offset, offset, size + ) + + def orphan(self, size: int = -1, double: bool = False): + if size > 0: + self._size = size + elif double is True: + self._size *= 2 + + self._ctx._gl.bindBuffer(enums.ARRAY_BUFFER, self._glo) + self._ctx._gl.bufferData(enums.ARRAY_BUFFER, self._size, self._usage) + + def bind_to_uniform_block(self, binding: int = 0, offset: int = 0, size: int = -1): + if size < 0: + size = self.size + + self._ctx._gl.bindBufferRange(enums.UNIFORM_BUFFER, binding, self._glo, offset, size) + + def bind_to_storage_buffer(self, *, binding=0, offset=0, size=-1): + raise NotImplementedError("bind_to_storage_buffer is not suppported with WebGL") diff --git a/arcade/gl/backends/webgl/context.py b/arcade/gl/backends/webgl/context.py new file mode 100644 index 0000000000..4ed4bd6429 --- /dev/null +++ b/arcade/gl/backends/webgl/context.py @@ -0,0 +1,412 @@ +from typing import TYPE_CHECKING, Dict, Iterable, List, Sequence, Tuple + +import pyglet +import pyglet.graphics.api + +from arcade.context import ArcadeContext +from arcade.gl import enums +from arcade.gl.context import Context, Info +from arcade.gl.types import BufferDescription +from arcade.types import BufferProtocol + +from .buffer import WebGLBuffer +from .framebuffer import WebGLDefaultFrameBuffer, WebGLFramebuffer +from .glsl import ShaderSource +from .program import WebGLProgram +from .query import WebGLQuery +from .sampler import WebGLSampler +from .texture import WebGLTexture2D +from .texture_array import WebGLTextureArray +from .vertex_array import WebGLGeometry, WebGLVertexArray # noqa: F401 + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGL2RenderingContext + + +class WebGLContext(Context): + gl_api: str = "webgl" + + def __init__( + self, + window: pyglet.window.Window, + gc_mode: str = "context_gc", + gl_api: str = "webgl", # type: ignore + ): + if gl_api != "webgl": + raise ValueError("Tried to create a WebGLContext with an incompatible api selected.") + + self.gl_api = gl_api + self._gl: WebGL2RenderingContext = pyglet.graphics.api.core.current_context.gl + + anistropy_ext = self._gl.getExtension("EXT_texture_filter_anisotropic") + texture_float_linear_ext = self._gl.getExtension("OES_texture_float_linear") + + unsupported_extensions = [] + if not anistropy_ext: + unsupported_extensions.append("EXT_texture_filter_anisotropic") + if not texture_float_linear_ext: + unsupported_extensions.append("OES_texture_float_linear") + + if unsupported_extensions: + raise RuntimeError( + f"Tried to create a WebGL context with missing extensions: {unsupported_extensions}" + ) + + super().__init__(window, gc_mode, gl_api) + + self._gl.enable(enums.SCISSOR_TEST) + + self._build_uniform_setters() + + def _build_uniform_setters(self): + self._uniform_setters = { + # Integers + enums.INT: (int, self._gl.uniform1i, 1, 1), + enums.INT_VEC2: (int, self._gl.uniform2iv, 2, 1), + enums.INT_VEC3: (int, self._gl.uniform3iv, 3, 1), + enums.INT_VEC4: (int, self._gl.uniform4iv, 4, 1), + # Unsigned Integers + enums.UNSIGNED_INT: (int, self._gl.uniform1ui, 1, 1), + enums.UNSIGNED_INT_VEC2: (int, self._gl.uniform2ui, 2, 1), + enums.UNSIGNED_INT_VEC3: (int, self._gl.uniform3ui, 3, 1), + enums.UNSIGNED_INT_VEC4: (int, self._gl.uniform4ui, 4, 1), + # Bools + enums.BOOL: (bool, self._gl.uniform1i, 1, 1), + enums.BOOL_VEC2: (bool, self._gl.uniform2iv, 2, 1), + enums.BOOL_VEC3: (bool, self._gl.uniform3iv, 3, 1), + enums.BOOL_VEC4: (bool, self._gl.uniform4iv, 4, 1), + # Floats + enums.FLOAT: (float, self._gl.uniform1f, 1, 1), + enums.FLOAT_VEC2: (float, self._gl.uniform2fv, 2, 1), + enums.FLOAT_VEC3: (float, self._gl.uniform3fv, 3, 1), + enums.FLOAT_VEC4: (float, self._gl.uniform4fv, 4, 1), + # Matrices + enums.FLOAT_MAT2: (float, self._gl.uniformMatrix2fv, 4, 1), + enums.FLOAT_MAT3: (float, self._gl.uniformMatrix3fv, 9, 1), + enums.FLOAT_MAT4: (float, self._gl.uniformMatrix4fv, 16, 1), + # 2D Samplers + enums.SAMPLER_2D: (int, self._gl.uniform1i, 1, 1), + enums.INT_SAMPLER_2D: (int, self._gl.uniform1i, 1, 1), + enums.UNSIGNED_INT_SAMPLER_2D: (int, self._gl.uniform1i, 1, 1), + # Array + enums.SAMPLER_2D_ARRAY: ( + int, + self._gl.uniform1iv, + self._gl.uniform1iv, + 1, + 1, + ), + } + + @Context.extensions.getter + def extensions(self) -> set[str]: + return self.window.context.get_info().extensions # type: ignore + + @property + def error(self) -> str | None: + err = self._gl.getError() + if err == enums.NO_ERROR: + return None + + return self._errors.get(err, "UNKNOWN_ERROR") + + def enable(self, *flags: int): + self._flags.update(flags) + + for flag in flags: + self._gl.enable(flag) + + def enable_only(self, *args: int): + self._flags = set(args) + + if self.BLEND in self._flags: + self._gl.enable(self.BLEND) + else: + self._gl.disable(self.BLEND) + + if self.DEPTH_TEST in self._flags: + self._gl.enable(self.DEPTH_TEST) + else: + self._gl.disable(self.DEPTH_TEST) + + if self.CULL_FACE in self._flags: + self._gl.enable(self.CULL_FACE) + else: + self._gl.disable(self.CULL_FACE) + + def disable(self, *args): + self._flags -= set(args) + + for flag in args: + self._gl.disable(flag) + + @Context.blend_func.setter + def blend_func(self, value: Tuple[int, int] | Tuple[int, int, int, int]): + self._blend_func = value + if len(value) == 2: + self._gl.blendFunc(*value) + elif len(value) == 4: + self._gl.blendFuncSeparate(*value) + else: + ValueError("blend_func takes a tuple of 2 or 4 values") + + @property + def front_face(self) -> str: + value = self._gl.getParameter(enums.FRONT_FACE) + return "cw" if value == enums.CW else "ccw" + + @front_face.setter + def front_face(self, value: str): + if value not in ["cw", "ccw"]: + raise ValueError("front_face must be 'cw' or 'ccw'") + self._gl.frontFace(enums.CW if value == "cw" else enums.CCW) + + @property + def cull_face(self) -> str: + value = self._gl.getParameter(enums.CULL_FACE_MODE) + return self._cull_face_options_reverse[value] + + @cull_face.setter + def cull_face(self, value): + if value not in self._cull_face_options: + raise ValueError("cull_face must be", list(self._cull_face_options.keys())) + + self._gl.cullFace(self._cull_face_options[value]) + + @Context.wireframe.setter + def wireframe(self, value: bool): + raise NotImplementedError("wireframe is not supported with WebGL") + + @property + def patch_vertices(self) -> int: + raise NotImplementedError("patch_vertices is not supported with WebGL") + + @patch_vertices.setter + def patch_vertices(self, value: int): + raise NotImplementedError("patch_vertices is not supported with WebGL") + + @Context.point_size.setter + def point_size(self, value: float): + raise NotImplementedError("point_size is not supported with WebGL") + + @Context.primitive_restart_index.setter + def primitive_restart_index(self, value: int): + raise NotImplementedError("primitive_restart_index is not supported with WebGL") + + def finish(self) -> None: + self._gl.finish() + + def flush(self) -> None: + self._gl.flush() + + def _create_default_framebuffer(self) -> WebGLDefaultFrameBuffer: + return WebGLDefaultFrameBuffer(self) + + def buffer( + self, *, data: BufferProtocol | None = None, reserve: int = 0, usage: str = "static" + ) -> WebGLBuffer: + return WebGLBuffer(self, data, reserve=reserve, usage=usage) + + def program( + self, + *, + vertex_shader: str, + fragment_shader: str | None = None, + geometry_shader: str | None = None, + tess_control_shader: str | None = None, + tess_evaluation_shader: str | None = None, + common: List[str] | None = None, + defines: Dict[str, str] | None = None, + varyings: Sequence[str] | None = None, + varyings_capture_mode: str = "interleaved", + ): + if geometry_shader is not None: + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + if tess_control_shader is not None: + raise NotImplementedError("Tessellation Shaders not supported with WebGL") + + if tess_evaluation_shader is not None: + raise NotImplementedError("Tessellation Shaders not supported with WebGL") + + source_vs = ShaderSource(self, vertex_shader, common, enums.VERTEX_SHADER) + source_fs = ( + ShaderSource(self, fragment_shader, common, enums.FRAGMENT_SHADER) + if fragment_shader + else None + ) + + out_attributes = list(varyings) if varyings is not None else [] + if not source_fs and not out_attributes: + out_attributes = source_vs.out_attributes + + return WebGLProgram( + self, + vertex_shader=source_vs.get_source(defines=defines), + fragment_shader=source_fs.get_source(defines=defines) if source_fs else None, + geometry_shader=None, + tess_control_shader=None, + tess_evaluation_shader=None, + varyings=out_attributes, + varyings_capture_mode=varyings_capture_mode, + ) + + def geometry( + self, + content: Sequence[BufferDescription] | None = None, + index_buffer: WebGLBuffer | None = None, + mode: int | None = None, + index_element_size: int = 4, + ) -> WebGLGeometry: + return WebGLGeometry( + self, + content, + index_buffer=index_buffer, + mode=mode, + index_element_size=index_element_size, + ) + + def compute_shader(self, *, source: str, common: Iterable[str] = ()) -> None: + raise NotImplementedError("compute_shader is not supported with WebGL") + + def texture( + self, + size: Tuple[int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + filter: Tuple[int, int] | None = None, + samples: int = 0, + immutable: bool = False, + internal_format: int | None = None, + compressed: bool = False, + compressed_data: bool = False, + ) -> WebGLTexture2D: + return WebGLTexture2D( + self, + size, + components=components, + data=data, + dtype=dtype, + wrap_x=wrap_x, + wrap_y=wrap_y, + filter=filter, + samples=samples, + immutable=immutable, + internal_format=internal_format, + compressed=compressed, + compressed_data=compressed_data, + ) + + def depth_texture( + self, size: Tuple[int, int], *, data: BufferProtocol | None = None + ) -> WebGLTexture2D: + return WebGLTexture2D(self, size, data=data, depth=True) + + def framebuffer( + self, + *, + color_attachments: WebGLTexture2D | List[WebGLTexture2D] | None = None, + depth_attachment: WebGLTexture2D | None = None, + ) -> WebGLFramebuffer: + return WebGLFramebuffer( + self, color_attachments=color_attachments or [], depth_attachment=depth_attachment + ) + + def copy_framebuffer( + self, + src: WebGLFramebuffer, + dst: WebGLFramebuffer, + src_attachment_index: int = 0, + depth: bool = True, + ): + self._gl.bindFramebuffer(enums.READ_FRAMEBUFFER, src.glo) + self._gl.bindFramebuffer(enums.DRAW_FRAMEBUFFER, dst.glo) + + self._gl.readBuffer(enums.COLOR_ATTACHMENT0 + src_attachment_index) + if dst.is_default: + self._gl.drawBuffers([enums.BACK]) + else: + self._gl.drawBuffers([enums.COLOR_ATTACHMENT0]) + + self._gl.blitFramebuffer( + 0, + 0, + src.width, + src.height, + 0, + 0, + src.width, + src.height, + enums.COLOR_BUFFER_BIT | enums.DEPTH_BUFFER_BIT, + enums.NEAREST, + ) + + self._gl.readBuffer(enums.COLOR_ATTACHMENT0) + + def sampler(self, texture: WebGLTexture2D): + return WebGLSampler(self, texture) + + def texture_array( + self, + size: Tuple[int, int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + filter: Tuple[int, int] | None = None, + ) -> WebGLTextureArray: + return WebGLTextureArray( + self, + size, + components=components, + dtype=dtype, + data=data, + wrap_x=wrap_x, + wrap_y=wrap_y, + filter=filter, + ) + + def query(self, *, samples=True, time=False, primitives=True): + return WebGLQuery(self, samples=samples, time=time, primitives=primitives) + + +class WebGLArcadeContext(ArcadeContext, WebGLContext): + def __init__(self, *args, **kwargs): + WebGLContext.__init__(self, *args, **kwargs) + ArcadeContext.__init__(self, *args, **kwargs) + + def bind_window_block(self): + self._gl.bindBufferRange( + enums.UNIFORM_BUFFER, + 0, + self._window_block.buffer.id, + 0, + 128, + ) + + +class WebGLInfo(Info): + def __init__(self, ctx: WebGLContext): + super().__init__(ctx) + self._ctx = ctx + + def get_int_tuple(self, enum, length: int): + # TODO: this might not work + values = self._ctx._gl.getParameter(enum) + return tuple(values) + + def get(self, enum, default=0): + value = self._ctx._gl.getParameter(enum) + return value or default + + def get_float(self, enum, default=0.0): + return self.get(enum, default) # type: ignore + + def get_str(self, enum): + return self.get(enum) diff --git a/arcade/gl/backends/webgl/framebuffer.py b/arcade/gl/backends/webgl/framebuffer.py new file mode 100644 index 0000000000..a5bf4a18cf --- /dev/null +++ b/arcade/gl/backends/webgl/framebuffer.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +import js # type: ignore + +from arcade.gl import enums +from arcade.gl.framebuffer import DefaultFrameBuffer, Framebuffer +from arcade.gl.types import pixel_formats +from arcade.types import RGBOrA255, RGBOrANormalized + +from .texture import WebGLTexture2D + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLFramebuffer as JSWebGLFramebuffer + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLFramebuffer(Framebuffer): + __slots__ = "_glo" + + def __init__( + self, + ctx: WebGLContext, + *, + color_attachments: WebGLTexture2D | list[WebGLTexture2D], + depth_attachment: WebGLTexture2D | None = None, + ): + super().__init__( + ctx, + color_attachments=color_attachments, + depth_attachment=depth_attachment, # type: ignore + ) + self._ctx = ctx + + self._glo = self._ctx._gl.createFramebuffer() + self._ctx._gl.bindFramebuffer(enums.FRAMEBUFFER, self._glo) + + for i, tex in enumerate(self._color_attachments): + self._ctx._gl.framebufferTexture2D( + enums.FRAMEBUFFER, + enums.COLOR_ATTACHMENT0 + i, + tex._target, # type: ignore + tex.glo, # type: ignore + 0, + ) + + if self.depth_attachment: + self._ctx._gl.framebufferTexture2D( + enums.FRAMEBUFFER, + enums.DEPTH_ATTACHMENT, + self.depth_attachment._target, # type: ignore + self.depth_attachment.glo, # type: ignore + 0, + ) + + self._check_completeness(ctx) + + self._draw_buffers = [ + enums.COLOR_ATTACHMENT0 + i for i, _ in enumerate(self._color_attachments) + ] + + # Restore the original framebuffer to avoid confusion + self._ctx.active_framebuffer.use(force=True) + + if self._ctx.gc_mode == "auto" and not self.is_default: + weakref.finalize(self, WebGLFramebuffer.delete_glo, ctx, self._glo) + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and not self.is_default and self._glo is not None: + self._ctx.objects.append(self) + + @property + def glo(self) -> JSWebGLFramebuffer | None: + return self._glo + + @Framebuffer.viewport.setter + def viewport(self, value: tuple[int, int, int, int]): + if not isinstance(value, tuple) or len(value) != 4: + raise ValueError("viewport should be a 4-component tuple") + + self._viewport = value + + # If the framebuffer is active we need to set the viewport now + # Otherwise it will be set when it is activated + if self._ctx.active_framebuffer == self: + self._ctx._gl.viewport(*self._viewport) + if self._scissor is None: + self._ctx._gl.scissor(*self._viewport) + else: + self._ctx._gl.scissor(*self._scissor) + + @Framebuffer.scissor.setter + def scissor(self, value): + self._scissor = value + + if self._scissor is None: + if self._ctx.active_framebuffer == self: + self._ctx._gl.scissor(*self._viewport) + else: + if self._ctx.active_framebuffer == self: + self._ctx._gl.scissor(*self._scissor) + + @Framebuffer.depth_mask.setter + def depth_mask(self, value: bool): + self._depth_mask = value + if self._ctx.active_framebuffer == self: + self._ctx._gl.depthMask(self._depth_mask) + + def _use(self, *, force: bool = False): + if self._ctx.active_framebuffer == self and not force: + return + + self._ctx._gl.bindFramebuffer(enums.FRAMEBUFFER, self._glo) + + if self._draw_buffers: + self._ctx._gl.drawBuffers(self._draw_buffers) + + self._ctx._gl.depthMask(self._depth_mask) + self._ctx._gl.viewport(*self._viewport) + if self._scissor is not None: + self._ctx._gl.scissor(*self._scissor) + else: + self._ctx._gl.scissor(*self._viewport) + + def clear( + self, + *, + color: RGBOrA255 | None = None, + color_normalized: RGBOrANormalized | None = None, + depth: float = 1.0, + viewport: tuple[int, int, int, int] | None = None, + ): + with self.activate(): + scissor_values = self._scissor + + if viewport: + self.scissor = viewport + else: + self.scissor = None + + clear_color = 0.0, 0.0, 0.0, 0.0 + if color is not None: + if len(color) == 3: + clear_color = color[0] / 255, color[1] / 255, color[2] / 255, 1.0 + elif len(color) == 4: + clear_color = color[0] / 255, color[1] / 255, color[2] / 255, color[3] / 255 + else: + raise ValueError("Color should be a 3 or 4 component tuple") + elif color_normalized is not None: + if len(color_normalized) == 3: + clear_color = color_normalized[0], color_normalized[1], color_normalized[2], 1.0 + elif len(color_normalized) == 4: + clear_color = color_normalized + else: + raise ValueError("Color should be a 3 or 4 component tuple") + + self._ctx._gl.clearColor(*clear_color) + + if self.depth_attachment: + self._ctx._gl.clearDepth(depth) + self._ctx._gl.clear(enums.COLOR_BUFFER_BIT | enums.DEPTH_BUFFER_BIT) + else: + self._ctx._gl.clear(enums.COLOR_BUFFER_BIT) + + self.scissor = scissor_values + + def read(self, *, viewport=None, components=3, attachment=0, dtype="f1") -> bytes: + try: + frmt = pixel_formats[dtype] + base_format = frmt[0][components] + pixel_type = frmt[2] + component_size = frmt[3] + except Exception: + raise ValueError(f"Invalid dtype '{dtype}'") + + with self.activate(): + if not self.is_default: + self._ctx._gl.readBuffer(enums.COLOR_ATTACHMENT0 + attachment) + + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + + if viewport: + x, y, width, height = viewport + else: + x, y, width, height = 0, 0, *self.size + + array_size = components * component_size * width * height + if pixel_type == enums.UNSIGNED_BYTE: + js_array_buffer = js.Uint8Array(array_size) + elif pixel_type == enums.UNSIGNED_SHORT: + js_array_buffer = js.Uint16Array(array_size) + elif pixel_type == enums.FLOAT: + js_array_buffer = js.Float32Array(array_size) + else: + raise ValueError(f"Unsupported pixel type {pixel_type} in framebuffer.read") + self._ctx._gl.readPixels(x, y, width, height, base_format, pixel_type, js_array_buffer) + + if not self.is_default: + self._ctx._gl.readBuffer(enums.COLOR_ATTACHMENT0) + + # TODO: Is this right or does this need something more for conversion to bytes? + return js_array_buffer + + def delete(self): + WebGLFramebuffer.delete_glo(self._ctx, self._glo) + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLFramebuffer | None): + if glo is not None: + ctx._gl.deleteFramebuffer(glo) + + ctx.stats.decr("framebuffer") + + @staticmethod + def _check_completeness(ctx: WebGLContext) -> None: + # See completeness rules : https://www.khronos.org/opengl/wiki/Framebuffer_Object + states = { + enums.FRAMEBUFFER_UNSUPPORTED: "Framebuffer unsupported. Try another format.", + enums.FRAMEBUFFER_INCOMPLETE_ATTACHMENT: "Framebuffer incomplete attachment.", + enums.FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: "Framebuffer missing attachment.", + enums.FRAMEBUFFER_INCOMPLETE_DIMENSIONS: "Framebuffer unsupported dimension.", + enums.FRAMEBUFFER_INCOMPLETE_MULTISAMPLE: "Framebuffer unsupported multisample.", + enums.FRAMEBUFFER_COMPLETE: "Framebuffer is complete.", + } + + status = ctx._gl.checkFramebufferStatus(enums.FRAMEBUFFER) + if status != enums.FRAMEBUFFER_COMPLETE: + raise ValueError( + "Framebuffer is incomplete. {}".format(states.get(status, "Unknown error")) + ) + + def __repr__(self): + return "".format(self._glo) + + +class WebGLDefaultFrameBuffer(DefaultFrameBuffer, WebGLFramebuffer): # type: ignore + is_default = True + + def __init__(self, ctx: WebGLContext): + super().__init__(ctx) + self._ctx = ctx + + x, y, width, height = self._ctx._gl.getParameter(enums.SCISSOR_BOX) + + self._viewport = x, y, width, height + self._scissor = None + self._width = width + self._height = height + + self._glo = None + + @DefaultFrameBuffer.viewport.setter + def viewport(self, value: tuple[int, int, int, int]): + # This is the exact same as the WebGLFramebuffer setter + # WebGL backend doesn't need to handle pixel scaling for the + # default framebuffer like desktop does, the browser does that + # for us. However we need a separate implementation for the + # function because of ABC + if not isinstance(value, tuple) or len(value) != 4: + raise ValueError("viewport shouldbe a 4-component tuple") + + ratio = self.ctx.window.get_pixel_ratio() + self._viewport = ( + int(value[0] * ratio), + int(value[1] * ratio), + int(value[2] * ratio), + int(value[3] * ratio), + ) + + if self._ctx.active_framebuffer == self: + self._ctx._gl.viewport(*self._viewport) + if self._scissor is None: + self._ctx._gl.scissor(*self._viewport) + else: + self._ctx._gl.scissor(*self._scissor) + + @DefaultFrameBuffer.scissor.setter + def scissor(self, value): + # This is the exact same as the WebGLFramebuffer setter + # WebGL backend doesn't need to handle pixel scaling for the + # default framebuffer like desktop does, the browser does that + # for us. However we need a separate implementation for the + # function because of ABC + if value is None: + self._scissor = None + if self._ctx.active_framebuffer == self: + self._ctx._gl.scissor(*self._viewport) + else: + ratio = self.ctx.window.get_pixel_ratio() + self._scissor = ( + int(value[0] * ratio), + int(value[1] * ratio), + int(value[2] * ratio), + int(value[3] * ratio), + ) + + if self._ctx.active_framebuffer == self: + self._ctx._gl.scissor(*self._scissor) diff --git a/arcade/gl/backends/webgl/glsl.py b/arcade/gl/backends/webgl/glsl.py new file mode 100644 index 0000000000..b6c43de2f5 --- /dev/null +++ b/arcade/gl/backends/webgl/glsl.py @@ -0,0 +1,162 @@ +import re +from typing import TYPE_CHECKING, Iterable + +if TYPE_CHECKING: + from .context import Context as ArcadeGlContext + +from arcade.gl import enums +from arcade.gl.exceptions import ShaderException +from arcade.gl.types import SHADER_TYPE_NAMES + + +class ShaderSource: + """ + GLSL source container for making source parsing simpler. + + We support locating out attributes, applying ``#defines`` values + and injecting common source. + + .. note::: + We do assume the source is neat enough to be parsed + this way and don't contain several statements on one line. + + Args: + ctx: + The context this framebuffer belongs to + source: + The GLSL source code + common: + Common source code to inject + source_type: + The shader type + """ + + def __init__( + self, + ctx: "ArcadeGlContext", + source: str, + common: Iterable[str] | None, + source_type: int, + ): + self._source = source.strip() + self._type = source_type + self._lines = self._source.split("\n") if source else [] + self._out_attributes: list[str] = [] + + if not self._lines: + raise ValueError("Shader source is empty") + + self._version = self._find_glsl_version() + + self._lines[0] = "#version 300 es" + self._lines.insert(1, "precision mediump float;") + + # TODO: Does this also need done for GLES and we just haven't encountered the problem yet? + self._lines.insert(1, "precision mediump isampler2D;") + + self._version = self._find_glsl_version() + + # Inject common source + self.inject_common_sources(common) + + if self._type in [enums.VERTEX_SHADER, enums.GEOMETRY_SHADER]: + self._parse_out_attributes() + + @property + def version(self) -> int: + """The glsl version""" + return self._version + + @property + def out_attributes(self) -> list[str]: + """The out attributes for this program""" + return self._out_attributes + + def inject_common_sources(self, common: Iterable[str] | None) -> None: + """ + Inject common source code into the shader source. + + Args: + common: + A list of common source code strings to inject + """ + if not common: + return + + # Find the main function + for line_number, line in enumerate(self._lines): + if "main()" in line: + break + else: + raise ShaderException("No main() function found when injecting common source") + + # Insert all common sources + for source in common: + lines = source.split("\n") + self._lines = self._lines[:line_number] + lines + self._lines[line_number:] + + def get_source(self, *, defines: dict[str, str] | None = None) -> str: + """Return the shader source + + Args: + defines: Defines to replace in the source. + """ + if not defines: + return "\n".join(self._lines) + + lines = ShaderSource.apply_defines(self._lines, defines) + return "\n".join(lines) + + def _find_glsl_version(self) -> int: + if self._lines[0].strip().startswith("#version"): + try: + return int(self._lines[0].split()[1]) + except Exception: + pass + + source = "\n".join(f"{str(i + 1).zfill(3)}: {line} " for i, line in enumerate(self._lines)) + + raise ShaderException( + ( + "Cannot find #version in shader source. " + "Please provide at least a #version 330 statement in the beginning of the shader.\n" + f"---- [{SHADER_TYPE_NAMES[self._type]}] ---\n" + f"{source}" + ) + ) + + @staticmethod + def apply_defines(lines: list[str], defines: dict[str, str]) -> list[str]: + """Locate and apply #define values + + Args: + lines: + List of source lines + defines: + dict with ``name: value`` pairs. + """ + for nr, line in enumerate(lines): + line = line.strip() + if line.startswith("#define"): + try: + name = line.split()[1] + value = defines.get(name, None) + if value is None: + continue + + lines[nr] = "#define {} {}".format(name, str(value)) + except IndexError: + pass + + return lines + + def _parse_out_attributes(self): + """ + Locates out attributes so we don't have to manually supply them. + + Note that this currently doesn't work for structs. + """ + for line in self._lines: + res = re.match(r"(layout(.+)\))?(\s+)?(out)(\s+)(\w+)(\s+)(\w+)", line.strip()) + if res: + self._out_attributes.append(res.groups()[-1]) diff --git a/arcade/gl/backends/webgl/program.py b/arcade/gl/backends/webgl/program.py new file mode 100644 index 0000000000..14f0c3754b --- /dev/null +++ b/arcade/gl/backends/webgl/program.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, Any, Iterable, cast + +from arcade.gl import enums +from arcade.gl.exceptions import ShaderException +from arcade.gl.program import Program +from arcade.gl.types import SHADER_TYPE_NAMES, AttribFormat, GLTypes + +from .uniform import Uniform, UniformBlock + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLProgram as JSWebGLProgram + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLProgram(Program): + _valid_capture_modes = ("interleaved", "separate") + + def __init__( + self, + ctx: WebGLContext, + *, + vertex_shader: str, + fragment_shader: str | None = None, + geometry_shader: str | None = None, + tess_control_shader: str | None = None, + tess_evaluation_shader: str | None = None, + varyings: list[str] | None = None, + varyings_capture_mode: str = "interleaved", + ): + if geometry_shader is not None: + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + if tess_control_shader is not None: + raise NotImplementedError("Tessellation Shaders not supported with WebGL") + + if tess_evaluation_shader is not None: + raise NotImplementedError("Tessellation Shaders not supported with WebGL") + + super().__init__(ctx) + self._ctx = ctx + + glo = self._ctx._gl.createProgram() + assert glo is not None, "Failed to create GL program" + self._glo: JSWebGLProgram = glo + + self._varyings = varyings or [] + self._varyings_capture_mode = varyings_capture_mode.strip().lower() + self._geometry_info = (0, 0, 0) + self._attributes = [] + self._uniforms: dict[str, Uniform | UniformBlock] = {} + + if self._varyings_capture_mode not in self._valid_capture_modes: + raise ValueError( + f"Invalid Capture Mode: {self._varyings_capture_mode}. " + f"Valid Modes are: {self._valid_capture_modes}." + ) + + shaders: list[tuple[str, int]] = [(vertex_shader, enums.VERTEX_SHADER)] + if fragment_shader: + shaders.append((fragment_shader, enums.FRAGMENT_SHADER)) + + # TODO: Do we need to inject a dummy fragment shader for transforms like OpenGL ES? + + compiled_shaders = [] + for shader_code, shader_type in shaders: + shader = WebGLProgram.compile_shader(self._ctx, shader_code, shader_type) + self._ctx._gl.attachShader(self._glo, shader) + compiled_shaders.append(shader) + + if not fragment_shader: + self._configure_varyings() + + WebGLProgram.link(self._ctx, self._glo) + + for shader in compiled_shaders: + self._ctx._gl.deleteShader(shader) + self._ctx._gl.detachShader(self._glo, shader) + + self._introspect_attributes() + self._introspect_uniforms() + self._introspect_uniform_blocks() + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLProgram.delete_glo, self._ctx, self._glo) + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + @property + def ctx(self) -> WebGLContext: + """The context this program belongs to.""" + return self._ctx + + @property + def glo(self) -> JSWebGLProgram | None: + """The OpenGL resource id for this program.""" + return self._glo + + @property + def attributes(self) -> Iterable[AttribFormat]: + """List of attribute information.""" + return self._attributes + + @property + def varyings(self) -> list[str]: + """Out attributes names used in transform feedback.""" + return self._varyings + + @property + def out_attributes(self) -> list[str]: + """ + Out attributes names used in transform feedback. + + Alias for `varyings`. + """ + return self._varyings + + @property + def varyings_capture_mode(self) -> str: + """ + Get the capture more for transform feedback (single, multiple). + + This is a read only property since capture mode + can only be set before the program is linked. + """ + return self._varyings_capture_mode + + @property + def geometry_input(self) -> int: + """ + The geometry shader's input primitive type. + + This an be compared with ``GL_TRIANGLES``, ``GL_POINTS`` etc. + and is queried when the program is created. + """ + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + @property + def geometry_output(self) -> int: + """The geometry shader's output primitive type. + + This an be compared with ``GL_TRIANGLES``, ``GL_POINTS`` etc. + and is queried when the program is created. + """ + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + @property + def geometry_vertices(self) -> int: + """ + The maximum number of vertices that can be emitted. + This is queried when the program is created. + """ + raise NotImplementedError("Geometry Shaders not supported with WebGL") + + def delete(self): + """ + Destroy the underlying OpenGL resource. + + Don't use this unless you know exactly what you are doing. + """ + WebGLProgram.delete_glo(self._ctx, self._glo) + self._glo = None # type: ignore + + @staticmethod + def delete_glo(ctx: WebGLContext, program: JSWebGLProgram | None): + ctx._gl.deleteProgram(program) + ctx.stats.decr("program") + + def __getitem__(self, item) -> Uniform | UniformBlock: + """Get a uniform or uniform block""" + try: + uniform = self._uniforms[item] + except KeyError: + raise KeyError(f"Uniform with the name `{item}` was not found.") + + return uniform.getter() + + def __setitem__(self, key, value): + """ + Set a uniform value. + + Example:: + + program['color'] = 1.0, 1.0, 1.0, 1.0 + program['mvp'] = projection @ view @ model + + Args: + key: + The uniform name + value: + The uniform value + """ + try: + uniform = self._uniforms[key] + except KeyError: + raise KeyError(f"Uniform with the name `{key}` was not found.") + + uniform.setter(value) + + def set_uniform_safe(self, name: str, value: Any): + """ + Safely set a uniform catching KeyError. + + Args: + name: + The uniform name + value: + The uniform value + """ + try: + self[name] = value + except KeyError: + pass + + def set_uniform_array_safe(self, name: str, value: list[Any]): + """ + Safely set a uniform array. + + Arrays can be shortened by the glsl compiler not all elements are determined + to be in use. This function checks the length of the actual array and sets a + subset of the values if needed. If the uniform don't exist no action will be + done. + + Args: + name: + Name of uniform + value: + List of values + """ + if name not in self._uniforms: + return + + uniform = cast(Uniform, self._uniforms[name]) + _len = uniform._array_length * uniform._components + if _len == 1: + self.set_uniform_safe(name, value[0]) + else: + self.set_uniform_safe(name, value[:_len]) + + def use(self): + self._ctx._gl.useProgram(self._glo) + + def _configure_varyings(self): + if not self._varyings: + return + + mode = ( + enums.INTERLEAVED_ATTRIBS + if self._varyings_capture_mode == "interleaved" + else enums.SEPARATE_ATTRIBS + ) + + self._ctx._gl.transformFeedbackVaryings( + self._glo, # type: ignore this is guaranteed to not be None at this point + self._varyings, + mode, + ) + + def _introspect_attributes(self): + num_attrs = self._ctx._gl.getProgramParameter(self._glo, enums.ACTIVE_ATTRIBUTES) + + # TODO: Do we need to instrospect the varyings? The OpenGL backend doesn't + # num_varyings = self._ctx._gl.getProgramParameter( + # self._glo, + # enums.TRANSFORM_FEEDBACK_VARYINGS + # ) + + for i in range(num_attrs): + info = self._ctx._gl.getActiveAttrib(self._glo, i) + location = self._ctx._gl.getAttribLocation(self._glo, info.name) + type_info = GLTypes.get(info.type) + self._attributes.append( + AttribFormat( + info.name, + type_info.gl_type, + type_info.components, + type_info.gl_size, + location=location, + ) + ) + + self.attribute_key = ":".join( + f"{attr.name}[{attr.gl_type}/{attr.components}]" for attr in self._attributes + ) + + def _introspect_uniforms(self): + active_uniforms = self._ctx._gl.getProgramParameter(self._glo, enums.ACTIVE_UNIFORMS) + + for i in range(active_uniforms): + name, type, size = self._query_uniform(i) + location = self._ctx._gl.getUniformLocation(self._glo, name) + + if location == -1: + continue + + name = name.replace("[0]", "") + self._uniforms[name] = Uniform(self._ctx, self._glo, location, name, type, size) + + def _introspect_uniform_blocks(self): + active_uniform_blocks = self._ctx._gl.getProgramParameter( + self._glo, enums.ACTIVE_UNIFORM_BLOCKS + ) + + for location in range(active_uniform_blocks): + index, size, name = self._query_uniform_block(location) + block = UniformBlock(self._ctx, self._glo, index, size, name) + self._uniforms[name] = block + + def _query_uniform(self, location: int) -> tuple[str, int, int]: + info = self._ctx._gl.getActiveUniform(self._glo, location) + return info.name, info.type, info.size + + def _query_uniform_block(self, location: int) -> tuple[int, int, str]: + name = self._ctx._gl.getActiveUniformBlockName(self._glo, location) + index = self._ctx._gl.getActiveUniformBlockParameter( + self._glo, location, enums.UNIFORM_BLOCK_BINDING + ) + size = self._ctx._gl.getActiveUniformBlockParameter( + self._glo, location, enums.UNIFORM_BLOCK_DATA_SIZE + ) + return index, size, name + + @staticmethod + def compile_shader(ctx: WebGLContext, source: str, shader_type: int): + shader = ctx._gl.createShader(shader_type) + assert shader is not None, "Failed to WebGL Shader Object" + ctx._gl.shaderSource(shader, source) + ctx._gl.compileShader(shader) + compile_result = ctx._gl.getShaderParameter(shader, enums.COMPILE_STATUS) + if not compile_result: + msg = ctx._gl.getShaderInfoLog(shader) + raise ShaderException( + ( + f"Error compiling {SHADER_TYPE_NAMES[shader_type]} " + f"{compile_result}): {msg}\n" + f"---- [{SHADER_TYPE_NAMES[shader_type]}] ---\n" + ) + + "\n".join( + f"{str(i + 1).zfill(3)}: {line} " for i, line in enumerate(source.split("\n")) + ) + ) + return shader + + @staticmethod + def link(ctx: WebGLContext, glo: JSWebGLProgram): + ctx._gl.linkProgram(glo) + status = ctx._gl.getProgramParameter(glo, enums.LINK_STATUS) + if not status: + log = ctx._gl.getProgramInfoLog(glo) + raise ShaderException("Program link error: {}".format(log)) + + def __repr__(self): + return "".format(self._glo) diff --git a/arcade/gl/backends/webgl/provider.py b/arcade/gl/backends/webgl/provider.py new file mode 100644 index 0000000000..59c3864899 --- /dev/null +++ b/arcade/gl/backends/webgl/provider.py @@ -0,0 +1,14 @@ +from arcade.gl.provider import BaseProvider + +from .context import WebGLArcadeContext, WebGLContext, WebGLInfo + + +class Provider(BaseProvider): + def create_context(self, *args, **kwargs): + return WebGLContext(*args, **kwargs) + + def create_info(self, ctx): + return WebGLInfo(ctx) # type: ignore + + def create_arcade_context(self, *args, **kwargs): + return WebGLArcadeContext(*args, **kwargs) diff --git a/arcade/gl/backends/webgl/query.py b/arcade/gl/backends/webgl/query.py new file mode 100644 index 0000000000..3da0cab9f9 --- /dev/null +++ b/arcade/gl/backends/webgl/query.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +from arcade.gl import enums +from arcade.gl.query import Query + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLQuery as JSWebGLQuery + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLQuery(Query): + __slots__ = ( + "_glo_samples_passed", + "_glo_time_elapsed", + "_glo_primitives_generated", + ) + + def __init__(self, ctx: WebGLContext, samples=True, time=False, primitives=True): + super().__init__(ctx, samples, time, primitives) + self._ctx = ctx + + if time: + raise NotImplementedError("Time queries are not supported with WebGL") + + glos = [] + + self._glo_samples_passed = None + if self._samples_enabled: + self._glo_samples_passed = self._ctx._gl.createQuery() + glos.append(self._glo_samples_passed) + + self._glo_primitives_generated = None + if self._primitives_enabled: + self._glo_primitives_generated = self._ctx._gl.createQuery() + glos.append(self._glo_primitives_generated) + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLQuery.delete_glo, self._ctx, glos) + + def __enter__(self): + if self._samples_enabled: + self._ctx._gl.beginQuery(enums.ANY_SAMPLES_PASSED, self._glo_samples_passed) # type: ignore + if self._primitives_enabled: + self._ctx._gl.beginQuery( + enums.TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, + self._glo_primitives_generated, # type: ignore + ) + + def __exit__(self): + if self._samples_enabled: + self._ctx._gl.endQuery(enums.ANY_SAMPLES_PASSED) + self._samples = self._ctx._gl.getQueryParameter( + self._glo_samples_passed, # type: ignore + enums.QUERY_RESULT, + ) + if self._primitives_enabled: + self._ctx._gl.endQuery(enums.TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN) + self._primitives = self._ctx._gl.getQueryParameter( + self._glo_primitives_generated, # type: ignore + enums.QUERY_RESULT, + ) + + def delete(self): + WebGLQuery.delete_glo(self._ctx, [self._glo_samples_passed, self._glo_primitives_generated]) + + @staticmethod + def delete_glo(ctx: WebGLContext, glos: list[JSWebGLQuery | None]): + for glo in glos: + ctx._gl.deleteQuery(glo) + + ctx.stats.decr("query") diff --git a/arcade/gl/backends/webgl/sampler.py b/arcade/gl/backends/webgl/sampler.py new file mode 100644 index 0000000000..4f9ad1f8ff --- /dev/null +++ b/arcade/gl/backends/webgl/sampler.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +from arcade.gl import enums +from arcade.gl.sampler import Sampler +from arcade.gl.types import compare_funcs + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLSampler as JSWebGLSampler + + from arcade.gl.backends.webgl.context import WebGLContext + from arcade.gl.backends.webgl.texture import WebGLTexture2D + + +class WebGLSampler(Sampler): + def __init__( + self, + ctx: WebGLContext, + texture: WebGLTexture2D, + *, + filter: tuple[int, int] | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + ): + super().__init__(ctx, texture, filter=filter, wrap_x=wrap_x, wrap_y=wrap_y) + self._ctx = ctx + + self._glo = self._ctx._gl.createSampler() + + if "f" in self.texture._dtype: + self._filter = enums.LINEAR, enums.LINEAR + else: + self._filter = enums.NEAREST, enums.NEAREST + + self._wrap_x = enums.REPEAT + self._wrap_y = enums.REPEAT + + if self.texture._samples == 0: + self.filter = filter or self._filter + self.wrap_x = wrap_x or self._wrap_x + self.wrap_y = wrap_y or self._wrap_y + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLSampler.delete_glo, self._ctx, self._glo) + + @property + def glo(self) -> JSWebGLSampler | None: + return self._glo + + def use(self, unit: int): + self._ctx._gl.bindSampler(unit, self._glo) + + def clear(self, unit: int): + self._ctx._gl.bindSampler(unit, None) + + @Sampler.filter.setter + def filter(self, value: tuple[int, int]): + if not isinstance(value, tuple) or not len(value) == 2: + raise ValueError("Texture filter must be a 2 component tuple (min, mag)") + + self._filter = value + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_MIN_FILTER, + self._filter[0], + ) + self._ctx._gl.samplerParameterf( + self._glo, # type: ignore + enums.TEXTURE_MAG_FILTER, + self._filter[1], + ) + + @Sampler.wrap_x.setter + def wrap_x(self, value: int): + self._wrap_x = value + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_WRAP_S, + value, + ) + + @Sampler.wrap_y.setter + def wrap_y(self, value: int): + self._wrap_y = value + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_WRAP_T, + value, + ) + + @Sampler.anisotropy.setter + def anisotropy(self, value): + self._anistropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) + self._ctx._gl.samplerParameterf( + self._glo, # type: ignore + enums.TEXTURE_MAX_ANISOTROPY_EXT, + self._anisotropy, + ) + + @Sampler.compare_func.setter + def compare_func(self, value: str | None): + if not self.texture._depth: + raise ValueError("Depth comparison function can only be set on depth textures") + + if not isinstance(value, str) and value is not None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + func = compare_funcs.get(value, None) + if func is None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + self._compare_func = value + if value is None: + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_COMPARE_MODE, + enums.NONE, + ) + else: + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_COMPARE_MODE, + enums.COMPARE_REF_TO_TEXTURE, + ) + self._ctx._gl.samplerParameteri( + self._glo, # type: ignore + enums.TEXTURE_COMPARE_FUNC, + func, + ) + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLSampler | None) -> None: + ctx._gl.deleteSampler(glo) diff --git a/arcade/gl/backends/webgl/texture.py b/arcade/gl/backends/webgl/texture.py new file mode 100644 index 0000000000..82080b3474 --- /dev/null +++ b/arcade/gl/backends/webgl/texture.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, Optional + +from pyodide.ffi import to_js + +from arcade.gl import enums +from arcade.gl.texture import Texture2D +from arcade.gl.types import BufferOrBufferProtocol, compare_funcs, pixel_formats +from arcade.types import BufferProtocol + +from .buffer import Buffer +from .utils import data_to_memoryview + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLTexture + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLTexture2D(Texture2D): + __slots__ = ( + "_glo", + "_target", + ) + + def __init__( + self, + ctx: WebGLContext, + size: tuple[int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + filter: tuple[int, int] | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + depth=False, + samples: int = 0, + immutable: bool = False, + internal_format: int | None = None, + compressed: bool = False, + compressed_data: bool = False, + ): + if samples > 0: + raise NotImplementedError("Multisample Textures are unsupported with WebGL") + + super().__init__( + ctx, + size, + components=components, + dtype=dtype, + data=data, + filter=filter, + wrap_x=wrap_x, + wrap_y=wrap_y, + depth=depth, + samples=samples, + immutable=immutable, + internal_format=internal_format, + compressed=compressed, + compressed_data=compressed_data, + ) + self._ctx = ctx + + if "f" in self._dtype: + self._filter = enums.LINEAR, enums.LINEAR + else: + self._filter = enums.NEAREST, enums.NEAREST + + self._target = enums.TEXTURE_2D + + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._glo = self._ctx._gl.createTexture() + if self._glo is None: + raise RuntimeError("Cannot create Texture. WebGL failed to generate a texture") + + self._ctx._gl.bindTexture(self._target, self._glo) + self._texture_2d(data) + + self.filter = filter = self._filter + self.wrap_x = wrap_x or self._wrap_x + self.wrap_y = wrap_y or self._wrap_y + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLTexture2D.delete_glo, self._ctx, self._glo) + + def resize(self, size: tuple[int, int]): + if self._immutable: + raise ValueError("Immutable textures cannot be resized") + + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + + self._width, self._height = size + + self._texture_2d(None) + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + def _texture_2d(self, data): + try: + format_info = pixel_formats[self._dtype] + except KeyError: + raise ValueError( + f"dtype '{self._dtype}' not supported. Supported types are: " + f"{tuple(pixel_formats.keys())}" + ) + _format, _internal_format, self._type, self._component_size = format_info + if data is not None: + byte_length, data = data_to_memoryview(data) + self._validate_data_size(data, byte_length, self._width, self._height) + + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, self._alignment) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, self._alignment) + + if self._depth: + self._ctx._gl.texImage2D( + self._target, + 0, + enums.DEPTH_COMPONENT24, + self._width, + self._height, + 0, + enums.DEPTH_COMPONENT, # type: ignore python doesn't have arg based function signatures + enums.UNSIGNED_INT, + data, + ) + self.compare_func = "<=" + else: + self._format = _format[self._components] + if self._internal_format is None: + self._internal_format = _internal_format[self._components] + + if self._immutable: + self._ctx._gl.texStorage2D( + self._target, + 1, + self._internal_format, + self._width, + self._height, + ) + if data: + self.write(data) + else: + if self._compressed_data is True: + self._ctx._gl.compressedTexImage2D( + self._target, 0, self._internal_format, self._width, self._height, 0, data + ) + else: + self._ctx._gl.texImage2D( + self._target, + 0, + self._internal_format, + self._width, + self._height, + 0, + self._format, # type: ignore + self._type, + data, + ) + + @property + def ctx(self) -> WebGLContext: + return self._ctx + + @property + def glo(self) -> Optional[WebGLTexture]: + return self._glo + + @property + def compressed(self) -> bool: + return self._compressed + + @property + def width(self) -> int: + """The width of the texture in pixels""" + return self._width + + @property + def height(self) -> int: + """The height of the texture in pixels""" + return self._height + + @property + def dtype(self) -> str: + """The data type of each component""" + return self._dtype + + @property + def size(self) -> tuple[int, int]: + """The size of the texture as a tuple""" + return self._width, self._height + + @property + def samples(self) -> int: + """Number of samples if multisampling is enabled (read only)""" + return self._samples + + @property + def byte_size(self) -> int: + """The byte size of the texture.""" + return pixel_formats[self._dtype][3] * self._components * self.width * self.height + + @property + def components(self) -> int: + """Number of components in the texture""" + return self._components + + @property + def component_size(self) -> int: + """Size in bytes of each component""" + return self._component_size + + @property + def depth(self) -> bool: + """If this is a depth texture.""" + return self._depth + + @property + def immutable(self) -> bool: + """Does this texture have immutable storage?""" + return self._immutable + + @property + def swizzle(self) -> str: + raise NotImplementedError("Texture Swizzle is not supported with WebGL") + + @swizzle.setter + def swizzle(self, value: str): + raise NotImplementedError("Texture Swizzle is not supported with WebGL") + + @Texture2D.filter.setter + def filter(self, value: tuple[int, int]): + if not isinstance(value, tuple) or not len(value) == 2: + raise ValueError("Texture filter must be a 2 component tuple (min, mag)") + + self._filter = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MIN_FILTER, self._filter[0]) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MAG_FILTER, self._filter[1]) + + @Texture2D.wrap_x.setter + def wrap_x(self, value: int): + self._wrap_x = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_WRAP_S, value) + + @Texture2D.wrap_y.setter + def wrap_y(self, value: int): + self._wrap_y = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_WRAP_T, value) + + @Texture2D.anisotropy.setter + def anisotropy(self, value): + # Technically anisotropy needs EXT_texture_filter_anisotropic but it's universally supported + self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameterf( + self._target, enums.TEXTURE_MAX_ANISOTROPY_EXT, self._anisotropy + ) + + @Texture2D.compare_func.setter + def compare_func(self, value: str | None): + if not self._depth: + raise ValueError("Depth comparison function can only be set on depth textures") + + if not isinstance(value, str) and value is not None: + raise ValueError(f"Value must a string of: {compare_funcs.keys()}") + + func = compare_funcs.get(value, None) + if func is None: + raise ValueError(f"Value must a string of: {compare_funcs.keys()}") + + self._compare_func = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + if value is None: + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_COMPARE_MODE, enums.NONE) + else: + self._ctx._gl.texParameteri( + self._target, enums.TEXTURE_COMPARE_MODE, enums.COMPARE_REF_TO_TEXTURE + ) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_COMPARE_FUNC, func) + + def read(self, level: int = 0, alignment: int = 1) -> bytes: + # WebGL has no getTexImage, so attach this to a framebuffer and read from that + fbo = self._ctx.framebuffer(color_attachments=[self]) + return fbo.read(components=self._components, dtype=self._dtype) + + def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> None: + x, y, w, h = 0, 0, self._width, self._height + if viewport: + if len(viewport) == 2: + ( + w, + h, + ) = viewport + elif len(viewport) == 4: + x, y, w, h = viewport + else: + raise ValueError("Viewport must be of length 2 or 4") + + if isinstance(data, Buffer): + # type ignore here because + self._ctx._gl.bindBuffer(enums.PIXEL_UNPACK_BUFFER, data.glo) # type: ignore + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + self._ctx._gl.texSubImage2D( + self._target, level, x, y, w, h, self._format, self._type, 0 + ) # type: ignore + self._ctx._gl.bindBuffer(enums.PIXEL_UNPACK_BUFFER, None) + else: + byte_size, data = data_to_memoryview(data) + self._validate_data_size(data, byte_size, w, h) + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + # TODO: Does this to_js call create a memory leak? Need to investigate this more + # https://pyodide.org/en/stable/usage/type-conversions.html#type-translations-pyproxy-to-js + self._ctx._gl.texSubImage2D( + self._target, level, x, y, w, h, self._format, self._type, to_js(data), 0 + ) # type: ignore + + def _validate_data_size(self, byte_data, byte_size, width, height) -> None: + if self._compressed is True: + return + + expected_size = width * height * self._component_size * self._components + if byte_size != expected_size: + raise ValueError( + f"Data size {len(byte_data)} does not match expected size {expected_size}" + ) + byte_length = len(byte_data) if isinstance(byte_data, bytes) else byte_data.nbytes + if byte_length != byte_size: + raise ValueError( + f"Data size {len(byte_data)} does not match reported size {expected_size}" + ) + + def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(enums.TEXTURE_2D, self._glo) + self._ctx._gl.texParameteri(enums.TEXTURE_2D, enums.TEXTURE_BASE_LEVEL, base) + self._ctx._gl.texParameteri(enums.TEXTURE_2D, enums.TEXTURE_MAX_LEVEL, max_level) + self._ctx._gl.generateMipmap(enums.TEXTURE_2D) + + def delete(self): + WebGLTexture2D.delete_glo(self._ctx, self._glo) + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: WebGLTexture | None): + if glo is not None: + ctx._gl.deleteTexture(glo) + + ctx.stats.decr("texture") + + def use(self, unit: int = 0) -> None: + self._ctx._gl.activeTexture(enums.TEXTURE0 + unit) + self._ctx._gl.bindTexture(self._target, self._glo) + + def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: int = 0): + raise NotImplementedError("bind_to_image not supported with WebGL") + + def get_handle(self, resident: bool = True) -> int: + raise NotImplementedError("get_handle is not supported with WebGL") + + def __repr__(self) -> str: + return "".format( + self._glo, self._width, self._height, self._components + ) diff --git a/arcade/gl/backends/webgl/texture_array.py b/arcade/gl/backends/webgl/texture_array.py new file mode 100644 index 0000000000..ceb20638d2 --- /dev/null +++ b/arcade/gl/backends/webgl/texture_array.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING + +from pyodide.ffi import to_js + +from arcade.gl import enums +from arcade.gl.texture_array import TextureArray +from arcade.gl.types import BufferOrBufferProtocol, compare_funcs, pixel_formats +from arcade.types import BufferProtocol + +from .buffer import Buffer +from .utils import data_to_memoryview + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLTexture as JSWebGLTexture + + from arcade.gl.backends.webgl.context import WebGLContext + + +class WebGLTextureArray(TextureArray): + __slots__ = ( + "_glo", + "_target", + ) + + def __init__( + self, + ctx: WebGLContext, + size: tuple[int, int, int], + *, + components: int = 4, + dtype: str = "f1", + data: BufferProtocol | None = None, + filter: tuple[int, int] | None = None, + wrap_x: int | None = None, + wrap_y: int | None = None, + depth=False, + samples: int = 0, + immutable: bool = False, + internal_format: int | None = None, + compressed: bool = False, + compressed_data: bool = False, + ): + if samples > 0: + raise NotImplementedError("Multisample Textures are unsupported with WebGL") + + super().__init__( + ctx, + size, + components=components, + dtype=dtype, + data=data, + filter=filter, + wrap_x=wrap_x, + wrap_y=wrap_y, + depth=depth, + samples=samples, + immutable=immutable, + internal_format=internal_format, + compressed=compressed, + compressed_data=compressed_data, + ) + self._ctx = ctx + + if "f" in self._dtype: + self._filter = enums.LINEAR, enums.LINEAR + else: + self._filter = enums.NEAREST, enums.NEAREST + + self._wrap_x = enums.REPEAT + self._wrap_y = enums.REPEAT + + self._target = enums.TEXTURE_2D_ARRAY + + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._glo = self._ctx._gl.createTexture() + if self._glo is None: + raise RuntimeError("Cannot create TextureArray. WebGL failed to generate a texture") + + self._ctx._gl.bindTexture(self._target, self._glo) + self._texture_2d_array(data) + + self.filter = filter = self._filter + self.wrap_x = wrap_x or self._wrap_x + self.wrap_y = wrap_y or self._wrap_y + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLTextureArray.delete_glo, self._ctx, self._glo) + + def resize(self, size: tuple[int, int]): + if self._immutable: + raise ValueError("Immutable textures cannot be resized") + + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + + self._width, self._height = size + + self._texture_2d_array(None) + + def __del__(self): + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + def _texture_2d_array(self, data): + try: + format_info = pixel_formats[self._dtype] + except KeyError: + raise ValueError( + f"dype '{self._dtype}' not support. Supported types are : " + f"{tuple(pixel_formats.keys())}" + ) + _format, _internal_format, self._type, self._component_size = format_info + if data is not None: + byte_length, data = data_to_memoryview(data) + self._validate_data_size(data, byte_length, self._width, self._height, self._layers) + + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, self._alignment) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, self._alignment) + + if self._depth: + self._ctx._gl.texImage3D( + self._target, + 0, + enums.DEPTH_COMPONENT24, + self._width, + self._height, + self._layers, + 0, + enums.DEPTH_COMPONENT, + enums.UNSIGNED_INT, + data, + ) + self.compare_func = "<=" + else: + self._format = _format[self._components] + if self._internal_format is None: + self._internal_format = _internal_format[self._components] + + if self._immutable: + self._ctx._gl.texStorage3D( + self._target, + 1, + self._internal_format, + self._width, + self._height, + self._layers, + ) + if data: + self.write(data) + else: + if self._compressed_data is True: + self._ctx._gl.compressedTexImage3D( + self._target, + 0, + self._internal_format, + self._width, + self._height, + self._layers, + 0, + len(data), + data, + ) + else: + self._ctx._gl.texImage3D( + self._target, + 0, + self._internal_format, + self._width, + self._height, + self._layers, + 0, + self._format, + self._type, + data, + ) + + @property + def glo(self) -> JSWebGLTexture | None: + return self._glo + + @property + def swizzle(self) -> str: + raise NotImplementedError("Texture Swizzle is not support with WebGL") + + @swizzle.setter + def swizzle(self, value: str): + raise NotImplementedError("Texture Swizzle is not supported with WebGL") + + @TextureArray.filter.setter + def filter(self, value: tuple[int, int]): + if not isinstance(value, tuple) or not len(value) == 2: + raise ValueError("Texture filter must be a 2 component tuple (min, mag)") + + self._filter = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MIN_FILTER, self._filter[0]) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MAG_FILTER, self._filter[1]) + + @TextureArray.wrap_x.setter + def wrap_x(self, value: int): + self._wrap_x = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_WRAP_T, value) + + @TextureArray.wrap_y.setter + def wrap_y(self, value: int): + self._wrap_y = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_WRAP_S, value) + + @TextureArray.anisotropy.setter + def anisotropy(self, value): + self._anisotropy = max(1.0, min(value, self._ctx.info.MAX_TEXTURE_MAX_ANISOTROPY)) + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameterf( + self._target, enums.TEXTURE_MAX_ANISOTROPY_EXT, self._anisotropy + ) + + @TextureArray.compare_func.setter + def compare_func(self, value: str | None): + if not self._depth: + raise ValueError("Depth comparison function can only be set on depth textures") + + if not isinstance(value, str) and value is not None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + func = compare_funcs.get(value, None) + if func is None: + raise ValueError(f"value must be as string: {compare_funcs.keys()}") + + self._compare_func = value + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + if value is None: + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_COMPARE_MODE, enums.NONE) + else: + self._ctx._gl.texParameteri( + self._target, enums.TEXTURE_COMPARE_MODE, enums.COMPARE_REF_TO_TEXTURE + ) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_COMPARE_FUNC, func) + + def read(self, level: int = 0, alignment: int = 1) -> bytes: + # FIXME: Check if we can attach a layer to framebuffer for reading. OpenGL ES has same + # problems in the OpenGL backend. + raise NotImplementedError("Reading texture array data not supported with WebGL") + + def write(self, data: BufferOrBufferProtocol, level: int = 0, viewport=None) -> None: + x, y, l, w, h = 0, 0, 0, self._width, self._height + if viewport: + if len(viewport) == 5: + x, y, l, w, h = viewport + else: + raise ValueError("Viewport must be of length 5") + + if isinstance(data, Buffer): + self._ctx._gl.bindBuffer(enums.PIXEL_UNPACK_BUFFER, data.glo) # type: ignore + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + self._ctx._gl.texSubImage3D( + self._target, level, x, y, l, w, h, 1, self._format, self._type, 0 + ) + self._ctx._gl.bindBuffer(enums.PIXEL_UNPACK_BUFFER, None) + else: + byte_size, data = data_to_memoryview(data) + self._validate_data_size(data, byte_size, w, h, 1) + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.pixelStorei(enums.PACK_ALIGNMENT, 1) + self._ctx._gl.pixelStorei(enums.UNPACK_ALIGNMENT, 1) + self._ctx._gl.texSubImage3D( + self._target, level, x, y, l, w, h, 1, self._format, self._type, to_js(data), 0 + ) + + def _validate_data_size(self, byte_data, byte_size, width, height) -> None: + if self._compressed is True: + return + + expected_size = width * height * self._component_size * self._components + if byte_size != expected_size: + raise ValueError( + f"Data size {len(byte_data)} does not match expected size {expected_size}" + ) + byte_length = len(byte_data) if isinstance(byte_data, bytes) else byte_data.nbytes + if byte_length != byte_size: + raise ValueError( + f"Data size {len(byte_data)} does not match reported size {expected_size}" + ) + + def build_mipmaps(self, base: int = 0, max_level: int = 1000) -> None: + self._ctx._gl.activeTexture(enums.TEXTURE0 + self._ctx.default_texture_unit) + self._ctx._gl.bindTexture(self._target, self._glo) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_BASE_LEVEL, base) + self._ctx._gl.texParameteri(self._target, enums.TEXTURE_MAX_LEVEL, max_level) + self._ctx._gl.generateMipmap(self._target) + + def delete(self): + self.delete_glo(self._ctx, self._glo) + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLTexture | None): + ctx._gl.deleteTexture(glo) + ctx.stats.decr("texture") + + def use(self, unit: int = 0) -> None: + self._ctx._gl.activeTexture(enums.TEXTURE0 + unit) + self._ctx._gl.bindTexture(self._target, self._glo) + + def bind_to_image(self, unit: int, read: bool = True, write: bool = True, level: int = 0): + raise NotImplementedError("bind_to_image not supported with WebGL") + + def get_handle(self, resident: bool = True) -> int: + raise NotImplementedError("get_handle is not supported with WebGL") + + def __repr__(self) -> str: + return "".format( + self._glo, self._width, self._layers, self._height, self._components + ) diff --git a/arcade/gl/backends/webgl/uniform.py b/arcade/gl/backends/webgl/uniform.py new file mode 100644 index 0000000000..34741557af --- /dev/null +++ b/arcade/gl/backends/webgl/uniform.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + +from arcade.gl import enums +from arcade.gl.exceptions import ShaderException + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLProgram as JSWebGLProgram + + from arcade.gl.backends.webgl.context import WebGLContext + + +class Uniform: + """ + A Program uniform + + Args: + ctx: + The context + program_id: + The program id to which this uniform belongs + location: + The uniform location + name: + The uniform name + data_type: + The data type of the uniform + array_length: + The array length of the uniform + """ + + __slots__ = ( + "_program", + "_location", + "_name", + "_data_type", + "_array_length", + "_components", + "getter", + "setter", + "_ctx", + ) + + def __init__( + self, ctx: WebGLContext, program: JSWebGLProgram, location, name, data_type, array_length + ): + self._ctx = ctx + self._program = program + self._location = location + self._name = name + self._data_type = data_type + # Array length of the uniform (1 if no array) + self._array_length = array_length + # Number of components (including per array entry) + self._components = 0 + self.getter: Callable + """The getter function configured for this uniform""" + self.setter: Callable + """The setter function configured for this uniform""" + self._setup_getters_and_setters() + + @property + def location(self) -> int: + """The location of the uniform in the program""" + return self._location + + @property + def name(self) -> str: + """Name of the uniform""" + return self._name + + @property + def array_length(self) -> int: + """Length of the uniform array. If not an array 1 will be returned""" + return self._array_length + + @property + def components(self) -> int: + """ + How many components for the uniform. + + A vec4 will for example have 4 components. + """ + return self._components + + def _setup_getters_and_setters(self): + """Maps the right getter and setter functions for this uniform""" + try: + gl_type, gl_setter, length, count = self._ctx._uniform_setters[self._data_type] + self._components = length + except KeyError: + raise ShaderException(f"Unsupported Uniform type: {self._data_type}") + + is_matrix = self._data_type in ( + enums.FLOAT_MAT2, + enums.FLOAT_MAT3, + enums.FLOAT_MAT4, + ) + + self.setter = Uniform._create_setter_func( + self._ctx, + self._program, + self._location, + gl_setter, + is_matrix, + ) + + @classmethod + def _create_setter_func( + cls, + ctx: WebGLContext, + program: JSWebGLProgram, + location, + gl_setter, + is_matrix, + ): + """Create setters for OpenGL data.""" + # Matrix uniforms + if is_matrix: + + def setter_func(value): # type: ignore #conditional function variants must have identical signature + """Set OpenGL matrix uniform data.""" + ctx._gl.useProgram(program) + gl_setter(location, False, value) + + # Single value and multi componentuniforms + else: + + def setter_func(value): # type: ignore #conditional function variants must have identical signature + """Set OpenGL uniform data value.""" + ctx._gl.useProgram(program) + gl_setter(location, value) + + return setter_func + + def __repr__(self) -> str: + return f"" + + +class UniformBlock: + """ + Wrapper for a uniform block in shaders. + + Args: + glo: + The OpenGL object handle + index: + The index of the uniform block + size: + The size of the uniform block + name: + The name of the uniform + """ + + __slots__ = ("_ctx", "glo", "index", "size", "name") + + def __init__(self, ctx: WebGLContext, glo, index: int, size: int, name: str): + self._ctx = ctx + self.glo = glo + """The OpenGL object handle""" + + self.index = index + """The index of the uniform block""" + + self.size = size + """The size of the uniform block""" + + self.name = name + """The name of the uniform block""" + + @property + def binding(self) -> int: + """Get or set the binding index for this uniform block""" + return self._ctx._gl.getActiveUniformBlockParameter( + self.glo, self.index, enums.UNIFORM_BLOCK_BINDING + ) + + @binding.setter + def binding(self, binding: int): + self._ctx._gl.uniformBlockBinding(self.glo, self.index, binding) + + def getter(self): + """ + The getter function for this uniform block. + + Returns self. + """ + return self + + def setter(self, value: int): + """ + The setter function for this uniform block. + + Args: + value: The binding index to set. + """ + self.binding = value + + def __str__(self) -> str: + return f"" diff --git a/arcade/gl/backends/webgl/utils.py b/arcade/gl/backends/webgl/utils.py new file mode 100644 index 0000000000..334e8682f9 --- /dev/null +++ b/arcade/gl/backends/webgl/utils.py @@ -0,0 +1,32 @@ +""" +Various utility functions for the gl module. +""" + +from array import array +from typing import Any, Union + + +def data_to_memoryview(data: Any) -> tuple[int, Union[bytes, memoryview]]: + """ + Attempts to convert the data to a memoryview if needed + + - bytes will be returned as is + - Tuples will be converted to array + - Other types will be converted directly to memoryview + + Args: + data: The data to convert to ctypes. + Returns: + A tuple containing the size of the data in bytes + and the data object optionally converted to a memoryview. + """ + if isinstance(data, bytes): + return len(data), data + else: + if isinstance(data, tuple): + data = array("f", data) + try: + m_view = memoryview(data) + return m_view.nbytes, m_view + except Exception as ex: + raise TypeError(f"Failed to convert data to memoryview: {ex}") diff --git a/arcade/gl/backends/webgl/vertex_array.py b/arcade/gl/backends/webgl/vertex_array.py new file mode 100644 index 0000000000..680c37dc9e --- /dev/null +++ b/arcade/gl/backends/webgl/vertex_array.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, Sequence + +from arcade.gl import enums +from arcade.gl.types import BufferDescription, gl_name +from arcade.gl.vertex_array import Geometry, VertexArray + +from .buffer import WebGLBuffer +from .program import WebGLProgram + +if TYPE_CHECKING: + from pyglet.graphics.api.webgl.webgl_js import WebGLVertexArrayObject as JSWebGLVertexArray + + from arcade.gl.backends.webgl.context import WebGLContext + +index_types = [None, enums.UNSIGNED_BYTE, enums.UNSIGNED_SHORT, None, enums.UNSIGNED_INT] + + +class WebGLVertexArray(VertexArray): + __slots__ = ( + "_glo", + "_index_element_type", + ) + + def __init__( + self, + ctx: WebGLContext, + program: WebGLProgram, + content: Sequence[BufferDescription], + index_buffer: WebGLBuffer | None = None, + index_element_size: int = 4, + ): + super().__init__(ctx, program, content, index_buffer, index_element_size) + self._ctx = ctx + + glo = self._ctx._gl.createVertexArray() + assert glo is not None, "Failed to create WebGL VertexArray object" + self._glo = glo + + self._index_element_type = index_types[index_element_size] + + self._build(program, content, index_buffer) + + if self._ctx.gc_mode == "auto": + weakref.finalize(self, WebGLVertexArray.delete_glo, self._ctx, self._glo) + + def __repr__(self) -> str: + return f"" + + def __del__(self) -> None: + # Intercept garbage collection if we are using Context.gc() + if self._ctx.gc_mode == "context_gc" and self._glo is not None: + self._ctx.objects.append(self) + + def delete(self) -> None: + WebGLVertexArray.delete_glo(self._ctx, self._glo) + self._glo = None + + @staticmethod + def delete_glo(ctx: WebGLContext, glo: JSWebGLVertexArray | None): + if glo is not None: + ctx._gl.deleteVertexArray(glo) + + ctx.stats.decr("vertex_array") + + def _build( + self, + program: WebGLProgram, + content: Sequence[BufferDescription], + index_buffer: WebGLBuffer | None, + ) -> None: + self._ctx._gl.bindVertexArray(self._glo) + + if index_buffer is not None: + self._ctx._gl.bindBuffer(enums.ELEMENT_ARRAY_BUFFER, index_buffer._glo) + + descr_attribs = {attr.name: (descr, attr) for descr in content for attr in descr.formats} + + for _, prog_attr in enumerate(program.attributes): + if prog_attr.name is not None and prog_attr.name.startswith("gl_"): + continue + try: + buff_descr, attr_descr = descr_attribs[prog_attr.name] + except KeyError: + raise ValueError( + ( + f"Program needs attribute '{prog_attr.name}', but is not present in buffer " + f"description. Buffer descriptions: {content}" + ) + ) + + if prog_attr.components != attr_descr.components: + raise ValueError( + ( + f"Program attribute '{prog_attr.name}' has {prog_attr.components} " + f"components while the buffer description has {attr_descr.components} " + " components. " + ) + ) + + self._ctx._gl.enableVertexAttribArray(prog_attr.location) + self._ctx._gl.bindBuffer(enums.ARRAY_BUFFER, buff_descr.buffer.glo) # type: ignore + + normalized = True if attr_descr.name in buff_descr.normalized else False + + float_types = (enums.FLOAT, enums.HALF_FLOAT) + int_types = ( + enums.INT, + enums.UNSIGNED_INT, + enums.SHORT, + enums.UNSIGNED_SHORT, + enums.BYTE, + enums.UNSIGNED_BYTE, + ) + attrib_type = attr_descr.gl_type + if attrib_type in int_types and buff_descr.normalized: + attrib_type = prog_attr.gl_type + + if attrib_type != prog_attr.gl_type: + raise ValueError( + ( + f"Program attribute '{prog_attr.name}' has type " + f"{gl_name(prog_attr.gl_type)}" + f"while the buffer description has type {gl_name(attr_descr.gl_type)}. " + ) + ) + + if attrib_type in float_types or attrib_type in int_types: + self._ctx._gl.vertexAttribPointer( + prog_attr.location, + attr_descr.components, + attr_descr.gl_type, + normalized, + buff_descr.stride, + attr_descr.offset, + ) + else: + raise ValueError(f"Unsupported attribute type: {attr_descr.gl_type}") + + if buff_descr.instanced: + self._ctx._gl.vertexAttribDivisor(prog_attr.location, 1) + + def render(self, mode: int, first: int = 0, vertices: int = 0, instances: int = 1) -> None: + self._ctx._gl.bindVertexArray(self._glo) + if self._ibo is not None: + self._ctx._gl.bindBuffer(enums.ELEMENT_ARRAY_BUFFER, self._ibo.glo) # type: ignore + self._ctx._gl.drawElementsInstanced( + mode, + vertices, + self._index_element_type, + first * self._index_element_size, + instances, + ) + else: + self._ctx._gl.drawArraysInstanced(mode, first, vertices, instances) + + def render_indirect(self, buffer: WebGLBuffer, mode: int, count, first, stride) -> None: + raise NotImplementedError("Indrect Rendering not supported with WebGL") + + def transform_interleaved( + self, + buffer: WebGLBuffer, + mode: int, + output_mode: int, + first: int = 0, + vertices: int = 0, + instances: int = 1, + buffer_offset=0, + ) -> None: + if vertices < 0: + raise ValueError(f"Cannot determine the number of verticies: {vertices}") + + if buffer_offset >= buffer.size: + raise ValueError("buffer_offset at end or past the buffer size") + + self._ctx._gl.bindVertexArray(self._glo) + self._ctx._gl.enable(enums.RASTERIZER_DISCARD) + + if buffer_offset > 0: + self._ctx._gl.bindBufferRange( + enums.TRANSFORM_FEEDBACK_BUFFER, + 0, + buffer.glo, + buffer_offset, + buffer.size - buffer_offset, + ) + else: + self._ctx._gl.bindBufferBase(enums.TRANSFORM_FEEDBACK_BUFFER, 0, buffer.glo) + + self._ctx._gl.beginTransformFeedback(output_mode) + + if self._ibo is not None: + count = self._ibo.size // 4 + self._ctx._gl.drawElementsInstanced( + mode, vertices or count, enums.UNSIGNED_INT, 0, instances + ) + else: + self._ctx._gl.drawArraysInstanced(mode, first, vertices, instances) + + self._ctx._gl.endTransformFeedback() + self._ctx._gl.disable(enums.RASTERIZER_DISCARD) + + def transform_separate( + self, + buffers: list[WebGLBuffer], + mode: int, + output_mode: int, + first: int = 0, + vertices: int = 0, + instances: int = 1, + buffer_offset=0, + ) -> None: + if vertices < 0: + raise ValueError(f"Cannot determine the number of vertices: {vertices}") + + size = min(buf.size for buf in buffers) + if buffer_offset >= size: + raise ValueError("buffer_offset at end or past the buffer size") + + self._ctx._gl.bindVertexArray(self._glo) + self._ctx._gl.enable(enums.RASTERIZER_DISCARD) + + if buffer_offset > 0: + for index, buffer in enumerate(buffers): + self._ctx._gl.bindBufferRange( + enums.TRANSFORM_FEEDBACK_BUFFER, + index, + buffer.glo, + buffer_offset, + buffer.size - buffer_offset, + ) + else: + for index, buffer in enumerate(buffers): + self._ctx._gl.bindBufferBase(enums.TRANSFORM_FEEDBACK_BUFFER, index, buffer.glo) + + self._ctx._gl.beginTransformFeedback(output_mode) + + if self._ibo is not None: + count = self._ibo.size // 4 + self._ctx._gl.drawElementsInstanced( + mode, vertices or count, enums.UNSIGNED_INT, 0, instances + ) + else: + self._ctx._gl.drawArraysInstanced(mode, first, vertices, instances) + + self._ctx._gl.endTransformFeedback() + self._ctx._gl.disable(enums.RASTERIZER_DISCARD) + + +class WebGLGeometry(Geometry): + def __init__( + self, + ctx: WebGLContext, + content: Sequence[BufferDescription] | None, + index_buffer: WebGLBuffer | None = None, + mode: int | None = None, + index_element_size: int = 4, + ) -> None: + super().__init__(ctx, content, index_buffer, mode, index_element_size) + + def _generate_vao(self, program: WebGLProgram) -> WebGLVertexArray: + vao = WebGLVertexArray( + self._ctx, # type: ignore + program, + self._content, + index_buffer=self._index_buffer, # type: ignore + index_element_size=self._index_element_size, + ) + self._vao_cache[program.attribute_key] = vao + return vao diff --git a/arcade/gl/buffer.py b/arcade/gl/buffer.py index 294c3730b9..56e55ca4ac 100644 --- a/arcade/gl/buffer.py +++ b/arcade/gl/buffer.py @@ -3,11 +3,14 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from arcade.gl import enums from arcade.types import BufferProtocol if TYPE_CHECKING: from arcade.gl import Context +_usages = {"static": enums.STATIC_DRAW, "dynamic": enums.DYNAMIC_DRAW, "stream": enums.STREAM_DRAW} + class Buffer(ABC): """OpenGL buffer object. Buffers store byte data and upload it diff --git a/arcade/gl/context.py b/arcade/gl/context.py index ded2574d1a..7ba7188e21 100644 --- a/arcade/gl/context.py +++ b/arcade/gl/context.py @@ -207,6 +207,7 @@ def __init__( gc_mode: str = "context_gc", gl_api: str = "gl", # This is ignored here, but used in implementation classes ): + self._gl_api = gl_api self._window_ref = weakref.ref(window) self._info = get_provider().create_info(self) @@ -224,7 +225,6 @@ def __init__( self._stats: ContextStats = ContextStats(warn_threshold=1000) self._primitive_restart_index = -1 - self.primitive_restart_index = self._primitive_restart_index # States self._blend_func: Tuple[int, int] | Tuple[int, int, int, int] = self.BLEND_DEFAULT diff --git a/arcade/gl/enums.py b/arcade/gl/enums.py index 4b410dd7df..1482f1be54 100644 --- a/arcade/gl/enums.py +++ b/arcade/gl/enums.py @@ -63,6 +63,7 @@ BLEND = 0x0BE2 DEPTH_TEST = 0x0B71 CULL_FACE = 0x0B44 +SCISSOR_TEST = 0x0C11 # Texture min/mag filters NEAREST = 0x2600 @@ -72,6 +73,59 @@ NEAREST_MIPMAP_LINEAR = 0x2702 LINEAR_MIPMAP_LINEAR = 0x2703 +# Textures +TEXTURE_2D = 0x0DE1 +TEXTURE_2D_ARRAY = 0x8C1A +ACTIVE_TEXTURE = 0x84E0 +TEXTURE0 = 0x84C0 +TEXTURE1 = 0x84C1 +TEXTURE2 = 0x84C2 +TEXTURE3 = 0x84C3 +TEXTURE4 = 0x84C4 +TEXTURE5 = 0x84C5 +TEXTURE6 = 0x84C6 +TEXTURE7 = 0x84C7 +TEXTURE8 = 0x84C8 +TEXTURE9 = 0x84C9 +TEXTURE10 = 0x84CA +TEXTURE11 = 0x84CB +TEXTURE12 = 0x84CC +TEXTURE13 = 0x84CD +TEXTURE14 = 0x84CE +TEXTURE15 = 0x84CF +TEXTURE16 = 0x84D0 +TEXTURE17 = 0x84D1 +TEXTURE18 = 0x84D2 +TEXTURE19 = 0x84D3 +TEXTURE20 = 0x84D4 +TEXTURE21 = 0x84D5 +TEXTURE22 = 0x84D6 +TEXTURE23 = 0x84D7 +TEXTURE24 = 0x84D8 +TEXTURE25 = 0x84D9 +TEXTURE26 = 0x84DA +TEXTURE27 = 0x84DB +TEXTURE28 = 0x84DC +TEXTURE29 = 0x84DD +TEXTURE30 = 0x84DE +TEXTURE31 = 0x84DF +UNPACK_ALIGNMENT = 0x0CF5 +PACK_ALIGNMENT = 0x0D05 +DEPTH_COMPONENT = 0x1902 +DEPTH_COMPONENT16 = 0x81A5 +DEPTH_COMPONENT24 = 0x81A6 +DEPTH_COMPONENT32F = 0x8CAC +TEXTURE_MAG_FILTER = 0x2800 +TEXTURE_MIN_FILTER = 0x2801 +TEXTURE_WRAP_S = 0x2802 +TEXTURE_WRAP_T = 0x2803 +TEXTURE_MAX_ANISOTROPY_EXT = 0x84FE # WebGL Specific for texture anisotropy extension +TEXTURE_COMPARE_MODE = 0x884C +TEXTURE_COMPARE_FUNC = 0x884D +COMPARE_REF_TO_TEXTURE = 0x884E +TEXTURE_BASE_LEVEL = 0x813C +TEXTURE_MAX_LEVEL = 0x813D + # Texture wrapping REPEAT = 0x2901 CLAMP_TO_EDGE = 0x812F @@ -236,3 +290,69 @@ GEOMETRY_SHADER = 0x8DD9 # Not supported in WebGL TESS_CONTROL_SHADER = 0x8E88 # Not supported in WebGL TESS_EVALUATION_SHADER = 0x8E87 # Not supported in WebGL + +# Get Parameters +FRONT_FACE = 0x0B46 +CW = 0x0900 +CCW = 0x0901 +CULL_FACE_MODE = 0x0B45 +SCISSOR_BOX = 0x0C10 + +# Buffers +STATIC_DRAW = 0x88E4 +STREAM_DRAW = 0x88E0 +DYNAMIC_DRAW = 0x88E8 +ARRAY_BUFFER = 0x8892 +ELEMENT_ARRAY_BUFFER = 0x8893 +COPY_READ_BUFFER = 0x8F36 +COPY_WRITE_BUFFER = 0x8F37 +UNIFORM_BUFFER = 0x8A11 +PIXEL_UNPACK_BUFFER = 0x88EC + +# Framebuffers +FRAMEBUFFER = 0x8D40 +COLOR_ATTACHMENT0 = 0x8CE0 +DEPTH_ATTACHMENT = 0x8D00 +FRAMEBUFFER_UNSUPPORTED = 0x8CDD +FRAMEBUFFER_INCOMPLETE_ATTACHMENT = 0x8CD6 +FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT = 0x8CD7 +FRAMEBUFFER_INCOMPLETE_DIMENSIONS = 0x8CD9 +FRAMEBUFFER_INCOMPLETE_MULTISAMPLE = 0x8D56 +FRAMEBUFFER_COMPLETE = 0x8CD5 +READ_FRAMEBUFFER = 0x8CA8 +DRAW_FRAMEBUFFER = 0x8CA9 + +# Clear Bits +DEPTH_BUFFER_BIT = 0x00000100 +STENCIL_BUFFER_BIT = 0x0000400 +COLOR_BUFFER_BIT = 0x00004000 + +# Samplers +SAMPLER_2D = 0x8B5E +INT_SAMPLER_2D = 0x8DCA +UNSIGNED_INT_SAMPLER_2D = 0x8DD2 +SAMPLER_2D_ARRAY = 0x8DC1 + +# Shader Parameters +COMPILE_STATUS = 0x8B81 +LINK_STATUS = 0x8B82 + +# Misc +UNIFORM_BLOCK_BINDING = 0x8A3F +INTERLEAVED_ATTRIBS = 0x8C8C +SEPARATE_ATTRIBS = 0x8C8D +ACTIVE_ATTRIBUTES = 0x8B89 +TRANSFORM_FEEDBACK_VARYINGS = 0x8C83 +ACTIVE_UNIFORMS = 0x8B86 +ACTIVE_UNIFORM_BLOCKS = 0x8A36 +UNIFORM_BLOCK_DATA_SIZE = 0x8A40 +RASTERIZER_DISCARD = 0x8C89 +TRANSFORM_FEEDBACK_BUFFER = 0x8C8E + +# Queries +CURRENT_QUERY = 0x8865 +QUERY_RESULT = 0x8866 +QUERY_RESULT_AVAILABLE = 0x8867 +ANY_SAMPLES_PASSED = 0x8C2F +ANY_SAMPLES_PASSED_CONSERVATIVE = 0x8D6A +TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN = 0x8C88 diff --git a/arcade/gl/texture_array.py b/arcade/gl/texture_array.py index 369ee617d6..345f732d22 100644 --- a/arcade/gl/texture_array.py +++ b/arcade/gl/texture_array.py @@ -4,10 +4,7 @@ from typing import TYPE_CHECKING from ..types import BufferProtocol -from .types import ( - BufferOrBufferProtocol, - pixel_formats, -) +from .types import BufferOrBufferProtocol, pixel_formats if TYPE_CHECKING: # handle import cycle caused by type hinting from arcade.gl import Context diff --git a/arcade/gl/vertex_array.py b/arcade/gl/vertex_array.py index 5a8a764c97..9c7310c709 100644 --- a/arcade/gl/vertex_array.py +++ b/arcade/gl/vertex_array.py @@ -355,41 +355,46 @@ def render( # If we have a geometry shader we need to sanity check that # the primitive mode is supported - if program.geometry_vertices > 0: - if program.geometry_input == self._ctx.POINTS: - mode = program.geometry_input - if program.geometry_input == self._ctx.LINES: - if mode not in [ - self._ctx.LINES, - self._ctx.LINE_STRIP, - self._ctx.LINE_LOOP, - self._ctx.LINES_ADJACENCY, - ]: - raise ValueError( - "Geometry shader expects LINES, LINE_STRIP, LINE_LOOP " - " or LINES_ADJACENCY as input" - ) - if program.geometry_input == self._ctx.LINES_ADJACENCY: - if mode not in [self._ctx.LINES_ADJACENCY, self._ctx.LINE_STRIP_ADJACENCY]: - raise ValueError( - "Geometry shader expects LINES_ADJACENCY or LINE_STRIP_ADJACENCY as input" - ) - if program.geometry_input == self._ctx.TRIANGLES: - if mode not in [ - self._ctx.TRIANGLES, - self._ctx.TRIANGLE_STRIP, - self._ctx.TRIANGLE_FAN, - ]: - raise ValueError( - "Geometry shader expects GL_TRIANGLES, GL_TRIANGLE_STRIP " - "or GL_TRIANGLE_FAN as input" - ) - if program.geometry_input == self._ctx.TRIANGLES_ADJACENCY: - if mode not in [self._ctx.TRIANGLES_ADJACENCY, self._ctx.TRIANGLE_STRIP_ADJACENCY]: - raise ValueError( - "Geometry shader expects GL_TRIANGLES_ADJACENCY or " - "GL_TRIANGLE_STRIP_ADJACENCY as input" - ) + if self._ctx._gl_api != "webgl": + if program.geometry_vertices > 0: + if program.geometry_input == self._ctx.POINTS: + mode = program.geometry_input + if program.geometry_input == self._ctx.LINES: + if mode not in [ + self._ctx.LINES, + self._ctx.LINE_STRIP, + self._ctx.LINE_LOOP, + self._ctx.LINES_ADJACENCY, + ]: + raise ValueError( + "Geometry shader expects LINES, LINE_STRIP, LINE_LOOP " + " or LINES_ADJACENCY as input" + ) + if program.geometry_input == self._ctx.LINES_ADJACENCY: + if mode not in [self._ctx.LINES_ADJACENCY, self._ctx.LINE_STRIP_ADJACENCY]: + raise ValueError( + "Geometry shader expects LINES_ADJACENCY or LINE_STRIP_ADJACENCY " + "as input" + ) + if program.geometry_input == self._ctx.TRIANGLES: + if mode not in [ + self._ctx.TRIANGLES, + self._ctx.TRIANGLE_STRIP, + self._ctx.TRIANGLE_FAN, + ]: + raise ValueError( + "Geometry shader expects GL_TRIANGLES, GL_TRIANGLE_STRIP " + "or GL_TRIANGLE_FAN as input" + ) + if program.geometry_input == self._ctx.TRIANGLES_ADJACENCY: + if mode not in [ + self._ctx.TRIANGLES_ADJACENCY, + self._ctx.TRIANGLE_STRIP_ADJACENCY, + ]: + raise ValueError( + "Geometry shader expects GL_TRIANGLES_ADJACENCY or " + "GL_TRIANGLE_STRIP_ADJACENCY as input" + ) vao.render( mode=mode, diff --git a/arcade/hitbox/__init__.py b/arcade/hitbox/__init__.py index becf40679d..d8881c4bff 100644 --- a/arcade/hitbox/__init__.py +++ b/arcade/hitbox/__init__.py @@ -1,16 +1,22 @@ from PIL.Image import Image from arcade.types import Point2List +from arcade.utils import is_pyodide from .base import HitBox, HitBoxAlgorithm, RotatableHitBox from .bounding_box import BoundingHitBoxAlgorithm -from .pymunk import PymunkHitBoxAlgorithm + from .simple import SimpleHitBoxAlgorithm #: The simple hit box algorithm. algo_simple = SimpleHitBoxAlgorithm() #: The detailed hit box algorithm. -algo_detailed = PymunkHitBoxAlgorithm() + +if not is_pyodide(): + from .pymunk import PymunkHitBoxAlgorithm + + algo_detailed = PymunkHitBoxAlgorithm() + #: The bounding box hit box algorithm. algo_bounding_box = BoundingHitBoxAlgorithm() #: The default hit box algorithm. diff --git a/arcade/future/input/README.md b/arcade/input/README.md similarity index 100% rename from arcade/future/input/README.md rename to arcade/input/README.md diff --git a/arcade/future/input/__init__.py b/arcade/input/__init__.py similarity index 100% rename from arcade/future/input/__init__.py rename to arcade/input/__init__.py diff --git a/arcade/future/input/input_mapping.py b/arcade/input/input_mapping.py similarity index 96% rename from arcade/future/input/input_mapping.py rename to arcade/input/input_mapping.py index 3198db7401..d030ad7ce6 100644 --- a/arcade/future/input/input_mapping.py +++ b/arcade/input/input_mapping.py @@ -1,8 +1,8 @@ # type: ignore from __future__ import annotations -from arcade.future.input import inputs -from arcade.future.input.raw_dicts import RawAction, RawActionMapping, RawAxis, RawAxisMapping +from arcade.input import inputs +from arcade.input.raw_dicts import RawAction, RawActionMapping, RawAxis, RawAxisMapping class Action: diff --git a/arcade/future/input/inputs.py b/arcade/input/inputs.py similarity index 99% rename from arcade/future/input/inputs.py rename to arcade/input/inputs.py index 1371e750bb..25111c8f7f 100644 --- a/arcade/future/input/inputs.py +++ b/arcade/input/inputs.py @@ -8,7 +8,7 @@ from enum import Enum, auto from sys import platform -from arcade.future.input.raw_dicts import RawBindBase +from arcade.input.raw_dicts import RawBindBase class InputType(Enum): diff --git a/arcade/future/input/manager.py b/arcade/input/manager.py similarity index 98% rename from arcade/future/input/manager.py rename to arcade/input/manager.py index 4d996b4aa7..ccad0484d7 100644 --- a/arcade/future/input/manager.py +++ b/arcade/input/manager.py @@ -10,8 +10,8 @@ from typing_extensions import TypedDict import arcade -from arcade.future.input import inputs -from arcade.future.input.input_mapping import ( +from arcade.input import inputs +from arcade.input.input_mapping import ( Action, ActionMapping, Axis, @@ -19,8 +19,8 @@ serialize_action, serialize_axis, ) -from arcade.future.input.inputs import InputEnum, InputType -from arcade.future.input.raw_dicts import RawAction, RawAxis +from arcade.input.inputs import InputEnum, InputType +from arcade.input.raw_dicts import RawAction, RawAxis from arcade.types import OneOrIterableOf from arcade.utils import grow_sequence diff --git a/arcade/future/input/raw_dicts.py b/arcade/input/raw_dicts.py similarity index 100% rename from arcade/future/input/raw_dicts.py rename to arcade/input/raw_dicts.py diff --git a/arcade/perf_graph.py b/arcade/perf_graph.py index 1809d31f8f..b274a31190 100644 --- a/arcade/perf_graph.py +++ b/arcade/perf_graph.py @@ -1,7 +1,9 @@ import random import pyglet.clock -from pyglet.graphics import Batch + +# Pyright can't figure out the dynamic import for the backends in Pyglet +from pyglet.graphics import Batch # type: ignore from pyglet.shapes import Line import arcade diff --git a/arcade/pymunk_physics_engine.py b/arcade/pymunk_physics_engine.py index 04e0e721d4..7dde352585 100644 --- a/arcade/pymunk_physics_engine.py +++ b/arcade/pymunk_physics_engine.py @@ -5,6 +5,7 @@ import logging import math from collections.abc import Callable +from typing import Any import pymunk from pyglet.math import Vec2 @@ -568,37 +569,29 @@ def add_collision_handler( self.collision_types.append(second_type) second_type_id = self.collision_types.index(second_type) - def _f1(arbiter, space, data): + def _f1(arbiter: pymunk.Arbiter, space: pymunk.Space, data: Any) -> None: sprite_a, sprite_b = self.get_sprites_from_arbiter(arbiter) should_process_collision = False if sprite_a is not None and sprite_b is not None and begin_handler is not None: should_process_collision = begin_handler(sprite_a, sprite_b, arbiter, space, data) - return should_process_collision + arbiter.process_collision = should_process_collision - def _f2(arbiter, space, data): + def _f2(arbiter: pymunk.Arbiter, space: pymunk.Space, data: Any) -> None: sprite_a, sprite_b = self.get_sprites_from_arbiter(arbiter) if sprite_a is not None and sprite_b is not None and post_handler is not None: post_handler(sprite_a, sprite_b, arbiter, space, data) - def _f3(arbiter, space, data): + def _f3(arbiter: pymunk.Arbiter, space: pymunk.Space, data: Any) -> None: sprite_a, sprite_b = self.get_sprites_from_arbiter(arbiter) - if pre_handler is not None: - return pre_handler(sprite_a, sprite_b, arbiter, space, data) + if sprite_a is not None and sprite_b is not None and pre_handler is not None: + arbiter.process_collision = pre_handler(sprite_a, sprite_b, arbiter, space, data) - def _f4(arbiter, space, data): + def _f4(arbiter: pymunk.Arbiter, space: pymunk.Space, data: Any) -> None: sprite_a, sprite_b = self.get_sprites_from_arbiter(arbiter) if separate_handler: separate_handler(sprite_a, sprite_b, arbiter, space, data) - h = self.space.add_collision_handler(first_type_id, second_type_id) - if begin_handler: - h.begin = _f1 - if post_handler: - h.post_solve = _f2 - if pre_handler: - h.pre_solve = _f3 - if separate_handler: - h.separate = _f4 + self.space.on_collision(first_type_id, second_type_id, _f1, _f3, _f2, _f4) def update_sprite(self, sprite: Sprite) -> None: """ @@ -783,7 +776,7 @@ def check_grounding(self, sprite: Sprite) -> dict: """ grounding = { "normal": pymunk.Vec2d.zero(), - "penetration": pymunk.Vec2d.zero(), + "penetration": 0.0, "impulse": pymunk.Vec2d.zero(), "position": pymunk.Vec2d.zero(), "body": None, @@ -813,7 +806,9 @@ def f(arbiter: pymunk.Arbiter): ): grounding["normal"] = n grounding["penetration"] = -arbiter.contact_point_set.points[0].distance - grounding["body"] = arbiter.shapes[1].body + # Mypy is making bad inferences about what this is based on the other elements + # and this doesn't particularly feel worth a TypedDict + grounding["body"] = arbiter.shapes[1].body # type: ignore grounding["impulse"] = arbiter.total_impulse grounding["position"] = arbiter.contact_point_set.points[0].point_b diff --git a/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl b/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl index 4daecf6e66..662f37829f 100644 --- a/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl +++ b/arcade/resources/system/shaders/atlas/resize_simple_vs.glsl @@ -8,20 +8,19 @@ // Old and new texture coordinates uniform sampler2D atlas_old; -uniform sampler2D atlas_new; uniform sampler2D texcoords_old; uniform sampler2D texcoords_new; uniform mat4 projection; uniform float border; +uniform vec2 size_new; out vec2 uv; void main() { // Get the texture sizes ivec2 size_old = textureSize(atlas_old, 0).xy; - ivec2 size_new = textureSize(atlas_new, 0).xy; // Read texture coordinates from UV texture here int texture_id = gl_VertexID / 6; diff --git a/arcade/shape_list.py b/arcade/shape_list.py index 08d1437531..b6c784e509 100644 --- a/arcade/shape_list.py +++ b/arcade/shape_list.py @@ -17,10 +17,8 @@ cast, ) -import pyglet.gl as gl - from arcade import ArcadeContext, get_points_for_thick_line, get_window -from arcade.gl import Buffer, BufferDescription, Geometry, Program +from arcade.gl import Buffer, BufferDescription, Geometry, Program, enums from arcade.math import rotate_point from arcade.types import RGBA255, Color, Point, PointList from arcade.utils import copy_dunders_unimplemented @@ -72,7 +70,7 @@ def __init__( colors: Sequence[RGBA255], # vao: Geometry, # vbo: Buffer, - mode: int = gl.GL_TRIANGLES, + mode: int = enums.TRIANGLES, program: Program | None = None, ) -> None: self.ctx = get_window().ctx @@ -197,7 +195,7 @@ def create_line_strip(point_list: PointList, color: RGBA255, line_width: float = line_width: Width of the line """ if line_width == 1: - return create_line_generic(point_list, color, gl.GL_LINE_STRIP) + return create_line_generic(point_list, color, enums.LINE_STRIP) triangle_point_list: list[Point] = [] new_color_list: list[RGBA255] = [] @@ -245,7 +243,7 @@ def create_lines( point_list: A list of points that make up the shape. color: A color such as a :py:class:`~arcade.types.Color` """ - return create_line_generic(point_list, color, gl.GL_LINES) + return create_line_generic(point_list, color, enums.LINES) def create_lines_with_colors( @@ -263,7 +261,7 @@ def create_lines_with_colors( line_width: Width of the line """ if line_width == 1: - return create_line_generic_with_colors(point_list, color_list, gl.GL_LINES) + return create_line_generic_with_colors(point_list, color_list, enums.LINES) triangle_point_list: list[Point] = [] new_color_list: list[RGBA255] = [] @@ -308,7 +306,7 @@ def create_polygon(point_list: PointList, color: RGBA255) -> Shape: itertools.zip_longest(point_list[:half], reversed(point_list[half:])) ) point_list = [p for p in interleaved if p is not None] - return create_line_generic(point_list, color, gl.GL_TRIANGLE_STRIP) + return create_line_generic(point_list, color, enums.TRIANGLE_STRIP) def create_rectangle_filled( @@ -511,7 +509,7 @@ def create_rectangle( border_width = 1 - shape_mode = gl.GL_TRIANGLE_STRIP + shape_mode = enums.TRIANGLE_STRIP return create_line_generic(data, color, shape_mode) @@ -531,7 +529,7 @@ def create_rectangle_filled_with_colors(point_list, color_list) -> Shape: point_list: List of points to create the rectangle from color_list: List of colors to create the rectangle from """ - shape_mode = gl.GL_TRIANGLE_STRIP + shape_mode = enums.TRIANGLE_STRIP new_point_list = [point_list[0], point_list[1], point_list[3], point_list[2]] new_color_list = [color_list[0], color_list[1], color_list[3], color_list[2]] return create_line_generic_with_colors(new_point_list, new_color_list, shape_mode) @@ -553,7 +551,7 @@ def create_rectangles_filled_with_colors(point_list, color_list: Sequence[RGBA25 point_list: List of points to create the rectangles from color_list: List of colors to create the rectangles from """ - shape_mode = gl.GL_TRIANGLES + shape_mode = enums.TRIANGLES new_point_list: list[Point] = [] new_color_list: list[RGBA255] = [] for i in range(0, len(point_list), 4): @@ -590,7 +588,7 @@ def create_triangles_filled_with_colors( :py:class:`~arcade.types.Color` instance or a 4-length RGBA :py:class:`tuple`. """ - shape_mode = gl.GL_TRIANGLES + shape_mode = enums.TRIANGLES return create_line_generic_with_colors(point_list, color_sequence, shape_mode) @@ -618,7 +616,7 @@ def create_triangles_strip_filled_with_colors( :py:class:`~arcade.types.Color` instance or a 4-length RGBA :py:class:`tuple`. """ - shape_mode = gl.GL_TRIANGLE_STRIP + shape_mode = enums.TRIANGLE_STRIP return create_line_generic_with_colors(point_list, color_sequence, shape_mode) @@ -762,10 +760,10 @@ def create_ellipse( itertools.zip_longest(point_list[:half], reversed(point_list[half:])) ) point_list = [p for p in interleaved if p is not None] - shape_mode = gl.GL_TRIANGLE_STRIP + shape_mode = enums.TRIANGLE_STRIP else: point_list.append(point_list[0]) - shape_mode = gl.GL_LINE_STRIP + shape_mode = enums.LINE_STRIP return create_line_generic(point_list, color, shape_mode) @@ -818,7 +816,7 @@ def create_ellipse_filled_with_colors( point_list.append(point_list[1]) color_list = [inside_color] + [outside_color] * (num_segments + 1) - return create_line_generic_with_colors(point_list, color_list, gl.GL_TRIANGLE_FAN) + return create_line_generic_with_colors(point_list, color_list, enums.TRIANGLE_FAN) TShape = TypeVar("TShape", bound=Shape) diff --git a/arcade/sound.py b/arcade/sound.py index c371c11a01..e401ba4928 100644 --- a/arcade/sound.py +++ b/arcade/sound.py @@ -9,9 +9,14 @@ from pyglet.media import Source from arcade.resources import resolve +from arcade.utils import is_pyodide if os.environ.get("ARCADE_SOUND_BACKENDS"): pyglet.options.audio = tuple(v.strip() for v in os.environ["ARCADE_SOUND_BACKENDS"].split(",")) +elif is_pyodide(): + # Pyglet will also detect Pyodide and auto select the driver for it + # but the driver tuple needs to be empty for that to happen + pyglet.options.audio = () else: pyglet.options.audio = ("openal", "xaudio2", "directsound", "pulse", "silent") @@ -88,7 +93,7 @@ def play( pan: float = 0.0, loop: bool = False, speed: float = 1.0, - ) -> media.Player: + ) -> media.AudioPlayer: """Try to play this :py:class:`Sound` and return a |pyglet Player|. .. important:: A :py:class:`Sound` with ``streaming=True`` loses features! @@ -113,7 +118,7 @@ def play( " If you need more use a Static source." ) - player: media.Player = media.Player() + player: media.AudioPlayer = media.AudioPlayer() player.volume = volume player.position = ( pan, @@ -145,7 +150,7 @@ def _on_player_eos(): player.on_player_eos = _on_player_eos # type: ignore return player - def stop(self, player: media.Player) -> None: + def stop(self, player: media.AudioPlayer) -> None: """Stop and :py:meth:`~pyglet.media.player.Player.delete` ``player``. All references to it in the internal table for @@ -165,12 +170,12 @@ def get_length(self) -> float: # We validate that duration is known when loading the source return self.source.duration # type: ignore - def is_complete(self, player: media.Player) -> bool: + def is_complete(self, player: media.AudioPlayer) -> bool: """``True`` if the sound is done playing.""" # We validate that duration is known when loading the source return player.time >= self.source.duration # type: ignore - def is_playing(self, player: media.Player) -> bool: + def is_playing(self, player: media.AudioPlayer) -> bool: """``True`` if ``player`` is currently playing, otherwise ``False``. Args: @@ -182,7 +187,7 @@ def is_playing(self, player: media.Player) -> bool: """ return player.playing - def get_volume(self, player: media.Player) -> float: + def get_volume(self, player: media.AudioPlayer) -> float: """Get the current volume. Args: @@ -193,7 +198,7 @@ def get_volume(self, player: media.Player) -> float: """ return player.volume # type: ignore # pending https://github.com/pyglet/pyglet/issues/847 - def set_volume(self, volume: float, player: media.Player) -> None: + def set_volume(self, volume: float, player: media.AudioPlayer) -> None: """Set the volume of a sound as it is playing. Args: @@ -203,7 +208,7 @@ def set_volume(self, volume: float, player: media.Player) -> None: """ player.volume = volume - def get_stream_position(self, player: media.Player) -> float: + def get_stream_position(self, player: media.AudioPlayer) -> float: """Return where we are in the stream. This will reset back to zero when it is done playing. @@ -254,7 +259,7 @@ def play_sound( pan: float = 0.0, loop: bool = False, speed: float = 1.0, -) -> media.Player | None: +) -> media.AudioPlayer | None: """Try to play the ``sound`` and return a |pyglet Player|. The ``sound`` must be a loaded :py:class:`Sound` object. If you @@ -322,7 +327,7 @@ def play_sound( return None -def stop_sound(player: media.Player) -> None: +def stop_sound(player: media.AudioPlayer) -> None: """Stop and delete a |pyglet Player| which is currently playing. Args: @@ -330,7 +335,7 @@ def stop_sound(player: media.Player) -> None: or :py:meth:`Sound.play`. """ - if not isinstance(player, media.Player): + if not isinstance(player, media.AudioPlayer): raise TypeError( "stop_sound takes a media player object returned from the play_sound() command, not a " "loaded Sound object." diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 09e8459ebb..3734b51e3e 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -8,6 +8,7 @@ from arcade.sprite import BasicSprite, SpriteType from arcade.types import Point from arcade.types.rect import Rect +from arcade.window_commands import get_window from .sprite_list import SpriteSequence @@ -174,10 +175,14 @@ def check_for_collision_with_list( # Spatial if sprite_list.spatial_hash is not None and (method == 1 or method == 0): sprites_to_check = sprite_list.spatial_hash.get_sprites_near_sprite(sprite) - elif method == 3 or (method == 0 and len(sprite_list) <= 1500): + elif ( + method == 3 + or (method == 0 and len(sprite_list) <= 1500) + or get_window().ctx._gl_api == "webgl" + ): sprites_to_check = sprite_list else: - # GPU transform + # GPU transform - Not on WebGL sprites_to_check = _get_nearby_sprites(sprite, sprite_list) return [ @@ -235,10 +240,14 @@ def check_for_collision_with_lists( # Spatial if sprite_list.spatial_hash is not None and (method == 1 or method == 0): sprites_to_check = sprite_list.spatial_hash.get_sprites_near_sprite(sprite) - elif method == 3 or (method == 0 and len(sprite_list) <= 1500): + elif ( + method == 3 + or (method == 0 and len(sprite_list) <= 1500) + or get_window().ctx._gl_api == "webgl" + ): sprites_to_check = sprite_list else: - # GPU transform + # GPU transform - Not on WebGL sprites_to_check = _get_nearby_sprites(sprite, sprite_list) for sprite2 in sprites_to_check: diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 1eadcad1c8..d24641dea6 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -321,13 +321,15 @@ def _init_deferred(self) -> None: if not self._atlas: self._atlas = self.ctx.default_atlas - # NOTE: Instantiate the appropriate spritelist data class here - # Desktop GL (with geo shader) - self._data = SpriteListBufferData(self.ctx, capacity=self._buf_capacity, atlas=self._atlas) - # WebGL (without geo shader) - # self._data = SpriteListTextureData( - # self.ctx, capacity=self._buf_capacity, atlas=self._atlas - # ) + if self.ctx._gl_api == "webgl": + self._data = SpriteListTextureData( + self.ctx, capacity=self._buf_capacity, atlas=self._atlas + ) + else: + self._data = SpriteListBufferData( + self.ctx, capacity=self._buf_capacity, atlas=self._atlas + ) + self._initialized = True # Load all the textures and write texture coordinates into buffers. @@ -1683,21 +1685,30 @@ def get_nearby_sprite_indices(self, pos: Point, size: Point, length: int) -> lis A list of indices of nearby sprites. """ ctx = self.ctx - ctx.collision_detection_program["check_pos"] = pos - ctx.collision_detection_program["check_size"] = size + if ctx._gl_api == "webgl": + raise RuntimeError("GPU Collision is not supported on WebGL Backends") + + # All of these type ignores are because of GPU collision not being supported on WebGL + # Unfortuantely the type checkers don't have a sane way of understanding that, and it's + # not worth run-time checking all of these things, because they are guaranteed based on + # active GL api of the context. Pyright actually does seem to be able to figure it out + # but mypy does not + + ctx.collision_detection_program["check_pos"] = pos # type: ignore + ctx.collision_detection_program["check_size"] = size # type: ignore buffer = ctx.collision_buffer - with ctx.collision_query: - self._geometry.transform( # type: ignore - ctx.collision_detection_program, - buffer, + with ctx.collision_query: # type: ignore + self._geometry.transform( + ctx.collision_detection_program, # type: ignore + buffer, # type: ignore vertices=length, ) # Store the number of sprites emitted - emit_count = ctx.collision_query.primitives_generated + emit_count = ctx.collision_query.primitives_generated # type: ignore if emit_count == 0: return [] - return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] + return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] # type: ignore class SpriteListTextureData(SpriteListData): @@ -1884,23 +1895,32 @@ def get_nearby_sprite_indices(self, pos: Point, size: Point, length: int) -> lis A list of indices of nearby sprites. """ ctx = self.ctx + if ctx._gl_api == "webgl": + raise RuntimeError("GPU Collision is not supported on WebGL Backends") + + # All of these type ignores are because of GPU collision not being supported on WebGL + # Unfortuantely the type checkers don't have a sane way of understanding that, and it's + # not worth run-time checking all of these things, because they are guaranteed based on + # active GL api of the context. Pyright actually does seem to be able to figure it out + # but mypy does not + buffer = ctx.collision_buffer program = ctx.collision_detection_program_simple - program["check_pos"] = pos - program["check_size"] = size + program["check_pos"] = pos # type: ignore + program["check_size"] = size # type: ignore self._storage_pos_angle.use(0) self._storage_size.use(1) self._storage_index.use(2) - with ctx.collision_query: + with ctx.collision_query: # type: ignore ctx.geometry_empty.transform( - program, - buffer, + program, # type: ignore + buffer, # type: ignore vertices=length, ) - emit_count = ctx.collision_query.primitives_generated + emit_count = ctx.collision_query.primitives_generated # type: ignore # print(f"Collision query emitted {emit_count} sprites") if emit_count == 0: return [] - return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] + return [i for i in struct.unpack(f"{emit_count}i", buffer.read(size=emit_count * 4))] # type: ignore diff --git a/arcade/text.py b/arcade/text.py index 60154c90c4..d9bdbd46a3 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -2,11 +2,15 @@ Drawing text with pyglet label """ -from ctypes import c_int, c_ubyte from pathlib import Path from typing import Any import pyglet +from pyglet.enums import Style, Weight + +# Pyright can't figure out the dynamic backend imports in pyglet.graphics +# right now. Maybe can fix in future Pyglet version +from pyglet.graphics import Batch, Group # type: ignore import arcade from arcade.exceptions import PerformanceWarning, warning @@ -18,62 +22,6 @@ __all__ = ["load_font", "Text", "create_text_sprite", "draw_text"] -class _ArcadeTextLayoutGroup(pyglet.text.layout.TextLayoutGroup): - """Create a text layout rendering group. - - Overrides pyglet blending handling to allow for additive blending. - Furthermore, it resets the blend function to the previous state. - """ - - _prev_blend: bool - _prev_blend_func: tuple[int, int, int, int] - - def set_state(self) -> None: - self.program.use() - self.program["scissor"] = False - - pyglet.gl.glActiveTexture(pyglet.gl.GL_TEXTURE0) - pyglet.gl.glBindTexture(self.texture.target, self.texture.id) - - blend = c_ubyte() - pyglet.gl.glGetBooleanv(pyglet.gl.GL_BLEND, blend) - self._prev_blend = bool(blend.value) - - src_rgb = c_int() - dst_rgb = c_int() - src_alpha = c_int() - dst_alpha = c_int() - pyglet.gl.glGetIntegerv(pyglet.gl.GL_BLEND_SRC_RGB, src_rgb) - pyglet.gl.glGetIntegerv(pyglet.gl.GL_BLEND_DST_RGB, dst_rgb) - pyglet.gl.glGetIntegerv(pyglet.gl.GL_BLEND_SRC_ALPHA, src_alpha) - pyglet.gl.glGetIntegerv(pyglet.gl.GL_BLEND_DST_ALPHA, dst_alpha) - - self._prev_blend_func = (src_rgb.value, dst_rgb.value, src_alpha.value, dst_alpha.value) - - pyglet.gl.glEnable(pyglet.gl.GL_BLEND) - pyglet.gl.glBlendFuncSeparate( - pyglet.gl.GL_SRC_ALPHA, - pyglet.gl.GL_ONE_MINUS_SRC_ALPHA, - pyglet.gl.GL_ONE, - pyglet.gl.GL_ONE, - ) - - def unset_state(self) -> None: - if not self._prev_blend: - pyglet.gl.glDisable(pyglet.gl.GL_BLEND) - - pyglet.gl.glBlendFuncSeparate( - self._prev_blend_func[0], - self._prev_blend_func[1], - self._prev_blend_func[2], - self._prev_blend_func[3], - ) - self.program.stop() - - -pyglet.text.layout.TextLayout.group_class = _ArcadeTextLayoutGroup - - def load_font(path: str | Path) -> None: """ Load fonts in a file (usually .ttf) adding them to a global font registry. @@ -272,8 +220,8 @@ def __init__( anchor_y: str = "baseline", multiline: bool = False, rotation: float = 0, - batch: pyglet.graphics.Batch | None = None, - group: pyglet.graphics.Group | None = None, + batch: Batch | None = None, + group: Group | None = None, z: float = 0, **kwargs, ): @@ -286,8 +234,8 @@ def __init__( width=width, align=align, font_name=font_name, - weight=pyglet.text.Weight.BOLD if bold else pyglet.text.Weight.NORMAL, - italic=italic, + weight=Weight.BOLD if bold else Weight.NORMAL, + style=Style.ITALIC if italic else Style.NORMAL, anchor_x=anchor_x, anchor_y=anchor_y, multiline=multiline, @@ -353,7 +301,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.label.end_update() @property - def batch(self) -> pyglet.graphics.Batch | None: + def batch(self) -> Batch | None: """The batch this text is in, if any. Can be unset by setting to ``None``. @@ -361,11 +309,11 @@ def batch(self) -> pyglet.graphics.Batch | None: return self.label.batch @batch.setter - def batch(self, batch: pyglet.graphics.Batch): + def batch(self, batch: Batch): self.label.batch = batch @property - def group(self) -> pyglet.graphics.Group | None: + def group(self) -> Group | None: """ The specific group in a batch the text should belong to. @@ -376,7 +324,7 @@ def group(self) -> pyglet.graphics.Group | None: return self.label.group @group.setter - def group(self, group: pyglet.graphics.Group): + def group(self, group: Group): self.label.group = group @property @@ -622,11 +570,11 @@ def bold(self) -> bool | str: * ``"light"`` """ - return self.label.weight == pyglet.text.Weight.BOLD + return self.label.weight == Weight.BOLD @bold.setter def bold(self, bold: bool | str): - self.label.weight = pyglet.text.Weight.BOLD if bold else pyglet.text.Weight.NORMAL + self.label.weight = Weight.BOLD if bold else Weight.NORMAL @property def italic(self) -> bool | str: diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index 9bca6a531f..3820798be5 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -15,7 +15,7 @@ import PIL.Image from PIL import Image, ImageDraw from PIL.Image import Resampling -from pyglet.image.atlas import ( +from pyglet.graphics.atlas import ( Allocator, AllocatorException, ) @@ -712,10 +712,10 @@ def resize(self, size: tuple[int, int], force=False) -> None: # Bind textures for atlas copy shader atlas_texture_old.use(0) - self._texture.use(1) - image_uvs_old.texture.use(2) - self._image_uvs.texture.use(3) + image_uvs_old.texture.use(1) + self._image_uvs.texture.use(2) self._ctx.atlas_resize_program["border"] = float(self._border) + self._ctx.atlas_resize_program["size_new"] = size self._ctx.atlas_resize_program["projection"] = Mat4.orthogonal_projection( 0, self.width, diff --git a/arcade/utils.py b/arcade/utils.py index 7be6757ae1..ec77699658 100644 --- a/arcade/utils.py +++ b/arcade/utils.py @@ -256,6 +256,8 @@ def __deepcopy__(self, memo): # noqa def is_pyodide() -> bool: + if sys.platform == "emscripten": + return True return False diff --git a/doc/tutorials/views/01_views.py b/doc/tutorials/views/01_views.py index a1b12cdddc..8ff15556a5 100644 --- a/doc/tutorials/views/01_views.py +++ b/doc/tutorials/views/01_views.py @@ -29,7 +29,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.set_mouse_visible(False) + self.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/views/02_views.py b/doc/tutorials/views/02_views.py index b189a8f81c..07d83798c1 100644 --- a/doc/tutorials/views/02_views.py +++ b/doc/tutorials/views/02_views.py @@ -29,7 +29,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.window.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/views/03_views.py b/doc/tutorials/views/03_views.py index 96573e2a49..1226b87d67 100644 --- a/doc/tutorials/views/03_views.py +++ b/doc/tutorials/views/03_views.py @@ -55,7 +55,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.window.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/views/04_views.py b/doc/tutorials/views/04_views.py index bea6f38dab..2fb2435702 100644 --- a/doc/tutorials/views/04_views.py +++ b/doc/tutorials/views/04_views.py @@ -96,7 +96,7 @@ def __init__(self): self.score = 0 # Don't show the mouse cursor - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) self.background_color = arcade.color.AMAZON diff --git a/doc/tutorials/views/index.rst b/doc/tutorials/views/index.rst index 3cb47bf2c9..b3d5477861 100644 --- a/doc/tutorials/views/index.rst +++ b/doc/tutorials/views/index.rst @@ -71,13 +71,13 @@ class. Change: .. code-block:: python - self.set_mouse_visible(False) + self.set_mouse_cursor_visible(False) to: .. code-block:: python - self.window.set_mouse_visible(False) + self.window.set_mouse_cursor_visible(False) Now in the ``main`` function, instead of just creating a window, we'll create a window, a view, and then show that view. diff --git a/index.html b/index.html new file mode 100644 index 0000000000..02a2256e42 --- /dev/null +++ b/index.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/make.py b/make.py index e98ff13517..6dba3e3a11 100755 --- a/make.py +++ b/make.py @@ -207,6 +207,16 @@ def serve(): ) +@app.command(rich_help_panel="Docs") +def docs_full(): + """ + Build the documentation fully and error on warnings. This is what is checked in CI. + """ + run_doc([SPHINX_BUILD, DOC_DIR, "build", "-W"]) + print() + print("Build finished") + + @app.command(rich_help_panel="Docs") def linkcheck(): """ diff --git a/pyproject.toml b/pyproject.toml index 9671ae48dd..ec18226f4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,9 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "pyglet~=2.1.5", + "pyglet==3.0.dev1", "pillow~=12.0.0", - "pymunk~=6.9.0", + "pymunk~=7.2.0", "pytiled-parser~=2.2.9", ] dynamic = ["version"] @@ -35,7 +35,7 @@ Issues = "https://github.com/pythonarcade/arcade/issues" Source = "https://github.com/pythonarcade/arcade" Book = "https://learn.arcade.academy" -[project.optional-dependencies] +[dependency-groups] # Used for dev work dev = [ "sphinx==8.1.3", # April 2024 | Updated 2024-07-15, 7.4+ is broken with sphinx-autobuild @@ -62,6 +62,7 @@ dev = [ "click==8.1.7", # Temp fix until we bump typer "typer==0.12.5", # Needed for make.py "wheel", + "bottle" # Used for web testing playground ] # Testing only testing_libraries = ["pytest", "pytest-mock", "pytest-cov", "pyyaml==6.0.1"] diff --git a/tests/conftest.py b/tests/conftest.py index 513f5c9baf..effb265533 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -339,8 +339,8 @@ def get_framebuffer_size(self): def get_pixel_ratio(self): return self.window.get_pixel_ratio() - def set_mouse_visible(self, visible): - self.window.set_mouse_visible(visible) + def set_mouse_cursor_visible(self, visible): + self.window.set_mouse_cursor_visible(visible) def center_window(self): self.window.center_window() diff --git a/tests/manual_smoke/sprite_collision_inspector.py b/tests/manual_smoke/sprite_collision_inspector.py index b38b725c39..efcaa5fb2f 100644 --- a/tests/manual_smoke/sprite_collision_inspector.py +++ b/tests/manual_smoke/sprite_collision_inspector.py @@ -180,7 +180,7 @@ def __init__(self, width: int = 1280, height: int = 720, grid_tile_px: int = 100 # self.spritelist.append(sprite) self.build_sprite_grid(8, 12, self.grid_tile_px, Vec2(50, 50)) self.background_color = arcade.color.DARK_GRAY - self.set_mouse_visible(False) + self.set_mouse_cursor_visible(False) self.cursor = 0, 0 self.from_mouse = True self.on_widget = False @@ -206,7 +206,7 @@ def on_update(self, dt: float = 1 / 60): on_widget = bool(len(widgets)) if self.on_widget != on_widget: - self.set_mouse_visible(on_widget) + self.set_mouse_cursor_visible(on_widget) self.on_widget = on_widget def on_draw(self): diff --git a/tests/unit/atlas/test_basics.py b/tests/unit/atlas/test_basics.py index 7cfcf0bea8..8aa71c5a98 100644 --- a/tests/unit/atlas/test_basics.py +++ b/tests/unit/atlas/test_basics.py @@ -1,6 +1,6 @@ import PIL.Image import pytest -from pyglet.image.atlas import AllocatorException +from pyglet.graphics.atlas import AllocatorException import arcade from arcade import DefaultTextureAtlas, load_texture from arcade.gl import Texture2D, Framebuffer diff --git a/tests/unit/atlas/test_rebuild_resize.py b/tests/unit/atlas/test_rebuild_resize.py index 0b381c6911..dc044cfcb1 100644 --- a/tests/unit/atlas/test_rebuild_resize.py +++ b/tests/unit/atlas/test_rebuild_resize.py @@ -1,6 +1,6 @@ import PIL.Image import pytest -from pyglet.image.atlas import AllocatorException +from pyglet.graphics.atlas import AllocatorException import arcade from arcade import DefaultTextureAtlas, load_texture diff --git a/tests/unit/gl/backends/gl/test_gl_program.py b/tests/unit/gl/backends/gl/test_gl_program.py index 5a7971e2ce..bca04fc51e 100644 --- a/tests/unit/gl/backends/gl/test_gl_program.py +++ b/tests/unit/gl/backends/gl/test_gl_program.py @@ -1,7 +1,7 @@ import struct import pytest import arcade -from pyglet import gl +from pyglet.graphics.api import gl from pyglet.math import Mat4, Mat3 from arcade.gl import ShaderException from arcade.gl.backends.opengl.uniform import UniformBlock diff --git a/tests/unit/gl/test_gl_types.py b/tests/unit/gl/test_gl_types.py index b4da8851cd..2748daab84 100644 --- a/tests/unit/gl/test_gl_types.py +++ b/tests/unit/gl/test_gl_types.py @@ -1,5 +1,5 @@ import pytest -from pyglet import gl +from pyglet.graphics.api import gl from arcade.gl import types diff --git a/tests/unit/shape_list/test_buffered_drawing.py b/tests/unit/shape_list/test_buffered_drawing.py index 715b95f258..8771187ede 100644 --- a/tests/unit/shape_list/test_buffered_drawing.py +++ b/tests/unit/shape_list/test_buffered_drawing.py @@ -15,7 +15,7 @@ create_line_generic, create_line_strip, ) -import pyglet.gl as gl +import pyglet.graphics.api.gl as gl SCREEN_WIDTH = 800 SCREEN_HEIGHT = 600 diff --git a/tests/unit/test_example_docstrings.py b/tests/unit/test_example_docstrings.py index f70bb76c1e..71c99630c3 100644 --- a/tests/unit/test_example_docstrings.py +++ b/tests/unit/test_example_docstrings.py @@ -62,6 +62,8 @@ def check_submodules(parent_module_absolute_name: str) -> None: # Check all modules nested immediately inside it on the file system for finder, child_module_name, is_pkg in pkgutil.iter_modules(parent_module_file_path): + if is_pkg: + continue child_module_file_path = Path(finder.path) / f"{child_module_name}.py" child_module_absolute_name = f"{parent_module_absolute_name}.{child_module_name}" diff --git a/tests/unit/window/test_window.py b/tests/unit/window/test_window.py index 1ba75f4dc6..02d69fcb0e 100644 --- a/tests/unit/window/test_window.py +++ b/tests/unit/window/test_window.py @@ -31,7 +31,7 @@ def test_window(window: arcade.Window): w.background_color = 255, 255, 255, 255 assert w.background_color == (255, 255, 255, 255) - w.set_mouse_visible(True) + w.set_mouse_cursor_visible(True) w.set_size(width, height) v = window.ctx.viewport diff --git a/util/update_quick_index.py b/util/update_quick_index.py index b4fca31cc2..534e0aacef 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -269,14 +269,6 @@ "future.rst": { "title": "Future Features", "use_declarations_in": [ - "arcade.future.texture_render_target", - "arcade.future.input.inputs", - "arcade.future.input.manager", - "arcade.future.input.input_mapping", - "arcade.future.input.raw_dicts", - "arcade.future.background.background_texture", - "arcade.future.background.background", - "arcade.future.background.groups", "arcade.future.light.lights", "arcade.future.video.video_player", ], diff --git a/webplayground/README.md b/webplayground/README.md new file mode 100644 index 0000000000..df6b4b0df5 --- /dev/null +++ b/webplayground/README.md @@ -0,0 +1,23 @@ +# Arcade Web Testing +This directory contains a utility for early testing of Arcade in web browsers. + +An http server is provided with the `server.py` file. This file can be run with `python server.py` and will serve a local HTTP server on port 8000. + +The index page will provide a list of all Arcade examples. This is generated dynamically on the fly when the page is loaded, and will show all examples in the `arcade.examples` package. This generates links which can be followed to open any example in the browser. + +There are some pre-requesites to running this server. It assumes that you have the `development` branch of Pyglet +checked out and in a folder named `pyglet` directly next to your Arcade repo directory. You will also need to have +the `build` and `flit` packages from PyPi installed. These are used by Pyglet and Arcade to build wheel files, +but are not generally installed for local development. + +Assuming you have Pyglet ready to go, you can then start the server. It will build wheels for both Pyglet and Arcade, and copy them +into this directory. This means that if you make any, you will need to restart this server in order to build new wheels. + +## How does this work? + +The web server itself is built with a nice little HTTP server library named [Bottle](https://github.com/bottlepy/bottle). We need to run an HTTP server locally +to load anything into WASM in the browser, it will not work if we just server it files directly due to browser security constraints. For the Arcade examples specifically, +we are taking advantage of the fact that the example code is packaged directly inside of Arcade to enable executing them in the browser. + +If we need to add extra code that is not part of the Arcade package, that will require extension of this server to handle packaging it properly for loading into WASM, and then +serving that package. \ No newline at end of file diff --git a/webplayground/example.tpl b/webplayground/example.tpl new file mode 100644 index 0000000000..1288c96c7e --- /dev/null +++ b/webplayground/example.tpl @@ -0,0 +1,31 @@ + + + + + % title = name.split(".")[-1] + {{title}} + + + + + + + + \ No newline at end of file diff --git a/webplayground/index.tpl b/webplayground/index.tpl new file mode 100644 index 0000000000..4c5a8e11b5 --- /dev/null +++ b/webplayground/index.tpl @@ -0,0 +1,16 @@ + + + + + Arcade Examples + + + +

    _@4IRZS7d5;cqD}$splyJqsDKj( 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 z zXp=Uh3Tz)rIggi?_>pom;~o<3e|5i0K=s{U1oxqs9UsnOq_Bos_{|efj3cK_Ws}Vj z_y4x2Cb=1q>-2TsZN-P15+)F-y2oRHU!e!l3r@h8HqJWNJQaK|=` z3Y?^)<-KdFLLPaqyr(YeP50X@%kqw7eqGqQZYVDQ@(PSMJ7eL|(_-!+lb9&%3OA(f zr3J9k~VnZB+gt~(#E7E~q6vnu)#r1hQemUHUEI$U7$Lk7?0na7?f z^U(8VpHavf3&ivzN=T>2zdJeCej{#_2TqMRer}bsU4G%~y`bJ*y=_w5zGhoIU zr?DAqQN)%ZsU3hOU;FreB41`b6^GOLVy8wWV7MWyo6eR&f)k3w2-CNT*JuOD@SrwM~49*KJxc3}a+V*uWE? zn?(vcaTeEbU2@4|yh{=M!stLc-DAaxPUR8>+a{iEZ`2pRPG=iLzH~>&4OpbDwuyWB zOqX0~fzNbxCtep;Jq8@a*ruwx%G%Q$#H}ZT?%2LhcXZoY;+rz`bP`>14Z4Qw@@(jy zh773>V`VsMi9#MVxsNiW+=MT@W?QQZcyXmx-hSy-f0c>lAwPM-%UBtb&ax;nR4(Rg z>O@-^lE(O!40YWX=Xf$irqa@qAu_jo+&FWP(F~z1yJyeDy+l~u-(FytY zt8U_K$gt>y<(TCL82d=|4RE)Q7|g0WKj> zkCby$hRKiRqYP_)M(JULAN5r@S#H!3-Okksm(*#bHZ*0}#M#mbk`);`3D8Afnhe&n z4A7f0=2006$Dj+mv>TLbt6r%SypygLKjgI>tVf33U=x>hxuJ)-`~g%sajF~qoKV;0 zt-Om21siyW0G9G;C416jIp;Pkk$2?{||S??>sOIzfGt?tPMBX(yny zoeb^Ab7@5_hjC@7k$_N5+VZwC$W_s!Ved_b!WpB72^(P)qeAM$J;8}AB}Lh`hfRFZ z2@Wqd&7yo6bBw#%w*r(1h&|_n`##dzjpZ!u*Xu zq1+CHtw6d??k(8S7I($0rP*z?$(1bxX@yN?n_vsRhmgskAz=g424KA4E1_*?0J4_d zUFY|9#YVo0dkpL(yd?Z8j)!#>G^DVEB+=JjU+Xu&^_T8$cDQFk%V82J-xL{wbl(v_ z7`o?)6A=v%9h9x_JMc2ej+c3c9waiZA#dXS+#Eh28ZrL0w=U!`@qC5C-s@ErUia#j1_FX zw4l>;>afN{^&@ejz%KJBu+>pdVlbmF{nBC(Ue(r$znY|HT!WviTj0>xlzfhnVGAcX z@^lhm`(GK>jbsZp@lGHroYcF9JG#1(V!rs{u@SQh)kNx zJZfFZ!JI%%3*oj;DMMh4xYtWx)n<7*i9zs%Pj5QPvK3#t%tKv~H}o*mdf$+tZIk6R zN`?rw@Z$uhrHAN5aSgAQ{_v8}lwq@vbl;cM6^pm=JRb=Rd%k&Ai7Kw@|?eb%1 zll`03OInCeI%K3ypE@pmrq_^wtdxn*3EQ@*z$Q9Rs33tJm}6zw0$X6$a!Fc>41o