diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml new file mode 100644 index 0000000000..f853b24ac5 --- /dev/null +++ b/.github/workflows/code_quality.yml @@ -0,0 +1,81 @@ +# 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: + + lints: + 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 + + - 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: + - 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: 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/push_build_to_prod_pypi.yml b/.github/workflows/push_build_to_prod_pypi.yml deleted file mode 100644 index 7091b7d873..0000000000 --- a/.github/workflows/push_build_to_prod_pypi.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Distribute build to PyPi Production - -on: - workflow_dispatch: - inputs: - tag: - description: 'Tag to deploy' - required: true - type: string - -jobs: - - # --- Deploy to pypi - deploy-to-pypi-prod: - - runs-on: ubuntu-latest - environment: deploy-pypi-prod - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-tags: 'true' - ref: ${{ github.event.inputs.tag }} - - name: Set up 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 - 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 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/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..5aa296d18d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +on: + push: + tags: + - '*' + +name: Create a Release + +jobs: + run: + runs-on: ubuntu-latest + environment: + name: release + permissions: + id-token: write + contents: read + 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: Build Project + run: uv build + + - name: Publish to PyPi + run: uv publish + + - name: Generate Release Notes + run: | + 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/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/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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b89cde3c1..110abf5401 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,41 @@ 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/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 - with: - name: pull-request-payload - path: pull_request_payload.json + - uses: actions/checkout@v5 - builddoc: + - name: Update apt + run: sudo apt-get update - name: Documentation build test - runs-on: ${{ matrix.os }} + # 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 - 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'] - - steps: - - uses: actions/checkout@v4 - - name: setup + - 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: build-docs - 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 + - name: Install UV + uses: astral-sh/setup-uv@v7 with: - name: pull-request-payload - path: pull_request_payload.json + enable-cache: true + + - name: Sync UV project + run: uv sync + + - name: Run tests + env: + PYGLET_BACKEND: opengl + REPL_ID: hello # Arcade checks for this to disable anti-aliasing + run: | + 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..5aac1e8aed 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9350a9ce3a..a25b09b89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,52 @@ 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 + +### New Features +- GUI: `UIDropdown` now supports scrolling when options exceed the menu height. New parameters: `max_height`, `invert_scroll`, `scroll_speed`, and `show_scroll_bar`. + +### Breaking Change +- Tilemap: Sprites of an object tile layer will now apply visibility of the object. + +## 4.0.0.dev3 + +### Fixes +- Removes an unnecessary dependency on NumPy which caused breakage in web browsers. + +## 4.0.0.dev2 + +### Fixes +- Fixes to camera module handling framebuffer changes. See [2802](https://github.com/pythonarcade/arcade/pull/2802) +- Small fixes to new easing functions. See [2810](https://github.com/pythonarcade/arcade/pull/2810) + +### Breaking Changes +- Updated pyglet to 3.0.dev2 +- Small changes to the new input package between 4.0.0.dev1 and dev2. Namely `ControllerAxes` renamed/split to `ControllerSticks` and `ControllerTriggers`. There are more underlying changes which shouldn't impact the public API of InputManager. + +## 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. + + + ## 3.3.3 - Support for Python 3.14 diff --git a/arcade/VERSION b/arcade/VERSION index 3f09e91095..5bf18217b2 100644 --- a/arcade/VERSION +++ b/arcade/VERSION @@ -1 +1 @@ -3.3.3 \ No newline at end of file +4.0.0.dev3 diff --git a/arcade/__init__.py b/arcade/__init__.py index a5152680d6..342ed7216d 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 @@ -90,6 +83,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 @@ -151,6 +145,17 @@ def configure_logging(level: int | None = None): from .controller import ControllerManager from .controller import get_controllers +from .input import ActionState +from .input import ControllerButtons +from .input import ControllerSticks +from .input import ControllerTriggers +from .input import InputManager +from .input import Keys +from .input import MouseAxes +from .input import MouseButtons +from .input import PSControllerButtons +from .input import XBoxControllerButtons + from .sound import Sound from .sound import load_sound from .sound import play_sound @@ -198,9 +203,12 @@ 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 +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 @@ -237,6 +245,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 @@ -251,6 +260,16 @@ def configure_logging(level: int | None = None): ) __all__ = [ + "ActionState", + "ControllerButtons", + "ControllerSticks", + "ControllerTriggers", + "InputManager", + "Keys", + "MouseAxes", + "MouseButtons", + "PSControllerButtons", + "XBoxControllerButtons", "AStarBarrierList", "AnimatedWalkingSprite", "TextureAnimationSprite", @@ -359,6 +378,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", @@ -386,6 +406,7 @@ def configure_logging(level: int | None = None): "get_default_texture", "get_default_image", "hitbox", + "input", "experimental", "rect", "color", diff --git a/arcade/__main__.py b/arcade/__main__.py index 1a318f0781..a2baf83815 100644 --- a/arcade/__main__.py +++ b/arcade/__main__.py @@ -1,4 +1,4 @@ -from arcade.management import show_info +from arcade.cli import run_arcade_cli if __name__ == "__main__": - show_info() + run_arcade_cli() 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..5f361b133f --- /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) ** 2 + 0.75 + elif t < 2.5 / D1: + return N1 * (t - 2.25 / D1) ** 2 + 0.9375 + else: + return N1 * (t - 2.625 / D1) ** 2 + 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/application.py b/arcade/application.py index 759e7a7c3b..7f7c46e639 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: @@ -135,6 +141,12 @@ class Window(pyglet.window.Window): enable_polling: Enabled input polling capability. This makes the :py:attr:`keyboard` and :py:attr:`mouse` attributes available for use. + file_drops: + Should the window listen for file drops? If True, the window will dispatch + ``on_file_drop`` events when files are dropped onto the window. + **kwargs: + Further keyword arguments are passed to the pyglet window constructor. + This can be used to set advanced options that aren't explicitly handled by Arcade. Raises: NoOpenGLException: If the system does not support OpenGL requested OpenGL version. @@ -162,6 +174,8 @@ def __init__( draw_rate: float = 1 / 60, fixed_rate: float = 1.0 / 60.0, fixed_frame_cap: int | None = None, + file_drops: bool = False, + **kwargs, ) -> None: # In certain environments we can't have antialiasing/MSAA enabled. # Detect replit environment @@ -173,6 +187,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 +202,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,38 +237,19 @@ 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, + file_drops=file_drops, + **kwargs, ) # pending: weird import tricks resolved self.register_event_type("on_update") @@ -245,11 +260,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 +367,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. @@ -669,7 +690,7 @@ def on_mouse_press(self, x: int, y: int, button: int, modifiers: int) -> EVENT_H modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) - active during this event. See :ref:`keyboard_modifiers`. + active during this event. See :ref:`pg_simple_input_keyboard_modifiers`. """ pass @@ -694,7 +715,7 @@ def on_mouse_drag( Which button is pressed modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) - active during this event. See :ref:`keyboard_modifiers`. + active during this event. See :ref:`pg_simple_input_keyboard_modifiers`. """ return self.on_mouse_motion(x, y, dx, dy) @@ -719,7 +740,7 @@ def on_mouse_release(self, x: int, y: int, button: int, modifiers: int) -> EVENT - ``arcade.MOUSE_BUTTON_MIDDLE`` modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) - active during this event. See :ref:`keyboard_modifiers`. + active during this event. See :ref:`pg_simple_input_keyboard_modifiers`. """ return EVENT_UNHANDLED @@ -759,7 +780,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 +811,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: """ @@ -820,7 +841,7 @@ def on_key_press(self, symbol: int, modifiers: int) -> EVENT_HANDLE_STATE: Key that was just pushed down modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) - active during this event. See :ref:`keyboard_modifiers`. + active during this event. See :ref:`pg_simple_input_keyboard_modifiers`. """ return EVENT_UNHANDLED @@ -842,10 +863,16 @@ def on_key_release(self, symbol: int, modifiers: int) -> EVENT_HANDLE_STATE: symbol (int): Key that was released modifiers (int): Bitwise 'and' of all modifiers (shift, ctrl, num lock) active during this event. - See :ref:`keyboard_modifiers`. + See :ref:`pg_simple_input_keyboard_modifiers`. """ 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 +1156,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.""" @@ -1423,7 +1450,7 @@ def on_mouse_press(self, x: int, y: int, button: int, modifiers: int) -> bool | modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) - active during this event. See :ref:`keyboard_modifiers`. + active during this event. See :ref:`pg_simple_input_keyboard_modifiers`. """ pass @@ -1448,7 +1475,7 @@ def on_mouse_drag( Which button is pressed _modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) - active during this event. See :ref:`keyboard_modifiers`. + active during this event. See :ref:`pg_simple_input_keyboard_modifiers`. """ self.on_mouse_motion(x, y, dx, dy) return False @@ -1475,7 +1502,7 @@ def on_mouse_release(self, x: int, y: int, button: int, modifiers: int) -> bool modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) - active during this event. See :ref:`keyboard_modifiers`. + active during this event. See :ref:`pg_simple_input_keyboard_modifiers`. """ pass @@ -1530,7 +1557,7 @@ def on_key_press(self, symbol: int, modifiers: int) -> bool | None: Key that was just pushed down modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) active - during this event. See :ref:`keyboard_modifiers`. + during this event. See :ref:`pg_simple_input_keyboard_modifiers`. """ return False @@ -1553,7 +1580,7 @@ def on_key_release(self, symbol: int, modifiers: int) -> bool | None: Key that was released modifiers: Bitwise 'and' of all modifiers (shift, ctrl, num lock) active - during this event. See :ref:`keyboard_modifiers`. + during this event. See :ref:`pg_simple_input_keyboard_modifiers`. """ return False diff --git a/arcade/camera/__init__.py b/arcade/camera/__init__.py index 902e8e4027..5a7ad024ab 100644 --- a/arcade/camera/__init__.py +++ b/arcade/camera/__init__.py @@ -23,6 +23,7 @@ from arcade.camera.orthographic import OrthographicProjector from arcade.camera.perspective import PerspectiveProjector +from arcade.camera.viewport import ViewportProjector from arcade.camera.camera_2d import Camera2D @@ -33,6 +34,7 @@ "Projection", "Projector", "CameraData", + "ViewportProjector", "generate_view_matrix", "OrthographicProjectionData", "generate_orthographic_matrix", diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index 52e8096aa2..e55a603985 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,24 @@ 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 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: + 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 +163,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 +177,8 @@ 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 = 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 @@ -264,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 @@ -297,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, ) @@ -320,9 +350,11 @@ 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 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 +363,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 +382,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 +416,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 +458,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: @@ -443,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 @@ -454,7 +487,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: """ @@ -467,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 = ( @@ -512,6 +549,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 +681,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. @@ -727,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: @@ -745,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: @@ -764,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: @@ -779,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: @@ -794,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: @@ -809,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: diff --git a/arcade/camera/default.py b/arcade/camera/default.py index dcb94d4b9f..b643995544 100644 --- a/arcade/camera/default.py +++ b/arcade/camera/default.py @@ -7,64 +7,125 @@ from pyglet.math import Mat4, Vec2, Vec3 from typing_extensions import Self -from arcade.types import LBWH, Point, Rect +from arcade.camera.data_types import DEFAULT_FAR, DEFAULT_NEAR_ORTHO +from arcade.types import Point from arcade.window_commands import get_window if TYPE_CHECKING: from arcade.context import ArcadeContext -__all__ = ["ViewportProjector", "DefaultProjector"] +__all__ = () -class ViewportProjector: +class DefaultProjector: """ - A simple Projector which does not rely on any camera PoDs. + An extremely limited projector which lacks any kind of control. This is only + here to act as the default camera used internally by Arcade. There should be + no instance where a developer would want to use this class. - Does not have a way of moving, rotating, or zooming the camera. - perfect for something like UI or for mapping to an offscreen framebuffer. + The job of the default projector is to ensure that when no other Projector + (Camera2D, OthrographicProjector, PerspectiveProjector, etc) is in use the + projection and view matrices are correct such at (0.0, 0.0) is in the bottom + left corner of the viewport and that one pixel equals one 'unit'. Args: - viewport: The viewport to project to. - context: The window context 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 context. """ - def __init__( - self, - viewport: Rect | None = None, - *, - context: ArcadeContext | None = None, - ): + def __init__(self, *, context: ArcadeContext | None = None): self._ctx: ArcadeContext = context or get_window().ctx - self._viewport: Rect = viewport or LBWH(*self._ctx.viewport) - self._projection_matrix: Mat4 = Mat4.orthogonal_projection( - 0.0, self._viewport.width, 0.0, self._viewport.height, -100, 100 - ) + self._viewport: tuple[int, int, int, int] | None = None + self._scissor: tuple[int, int, int, int] | None = None + self._matrix: Mat4 | None = None - @property - def viewport(self) -> Rect: + def update_viewport(self): """ - The viewport use to derive projection and view matrix. + Called when the ArcadeContext's viewport or active + framebuffer has been set. It only actually updates + the viewport if no other camera is active. Also + setting the viewport to match the size of the active + framebuffer sets the viewport to None. """ + + # If another camera is active then the viewport was probably set + # by camera.use() + if self._ctx.current_camera != self: + return + + if ( + self._ctx.viewport[2] != self._ctx.fbo.width + or self._ctx.viewport[3] != self._ctx.fbo.height + ): + self.viewport = self._ctx.viewport + else: + self.viewport = None + + self.use() + + @property + def viewport(self) -> tuple[int, int, int, int] | None: return self._viewport @viewport.setter - def viewport(self, viewport: Rect) -> None: + def viewport(self, viewport: tuple[int, int, int, int] | None) -> None: + if viewport == self._viewport: + return self._viewport = viewport - self._projection_matrix = Mat4.orthogonal_projection( - 0, viewport.width, 0, viewport.height, -100, 100 + self._matrix = Mat4.orthogonal_projection( + 0, self.width, 0, self.height, DEFAULT_NEAR_ORTHO, DEFAULT_FAR ) + @viewport.deleter + def viewport(self): + self.viewport = None + + @property + def scissor(self) -> tuple[int, int, int, int] | None: + return self._scissor + + @scissor.setter + def scissor(self, scissor: tuple[int, int, int, int] | None) -> None: + self._scissor = scissor + + @scissor.deleter + def scissor(self) -> None: + self._scissor = None + + @property + def width(self) -> int: + if self._viewport is not None: + return int(self._viewport[2]) + return self._ctx.fbo.width + + @property + def height(self) -> int: + if self._viewport is not None: + return int(self._viewport[3]) + return self._ctx.fbo.height + + def get_current_viewport(self) -> tuple[int, int, int, int]: + if self._viewport is not None: + return self._viewport + return (0, 0, self._ctx.fbo.width, self._ctx.fbo.height) + def use(self) -> None: """ - Set the window's projection and view matrix. - Also sets the projector as the windows current camera. + Set the window's Projection and View matrices. """ - self._ctx.current_camera = self - self._ctx.viewport = self.viewport.lbwh_int # get the integer 4-tuple LBWH + viewport = self.get_current_viewport() + + self._ctx.current_camera = self + if self._ctx.viewport != viewport: + self._ctx.active_framebuffer.viewport = viewport + self._ctx.scissor = None if self._scissor is None else self._scissor self._ctx.view_matrix = Mat4() - self._ctx.projection_matrix = self._projection_matrix + if self._matrix is None: + self._matrix = Mat4.orthogonal_projection( + 0, viewport[2], 0, viewport[3], DEFAULT_NEAR_ORTHO, DEFAULT_FAR + ) + self._ctx.projection_matrix = self._matrix @contextmanager def activate(self) -> Generator[Self, None, None]: @@ -73,12 +134,20 @@ def activate(self) -> Generator[Self, None, None]: usable with the 'with' block. e.g. 'with ViewportProjector.activate() as cam: ...' """ - previous = self._ctx.current_camera + previous_projector = self._ctx.current_camera + previous_view = self._ctx.view_matrix + previous_projection = self._ctx.projection_matrix + previous_scissor = self._ctx.scissor + previous_viewport = self._ctx.viewport try: self.use() yield self finally: - previous.use() + self._ctx.viewport = previous_viewport + self._ctx.scissor = previous_scissor + self._ctx.projection_matrix = previous_projection + self._ctx.view_matrix = previous_view + self._ctx.current_camera = previous_projector def project(self, world_coordinate: Point) -> Vec2: """ @@ -97,46 +166,3 @@ def unproject(self, screen_coordinate: Point) -> Vec3: z = 0.0 if not _z else _z[0] return Vec3(x, y, z) - - -# As this class is only supposed to be used internally -# I wanted to place an _ in front, but the linting complains -# about it being a protected class. -class DefaultProjector(ViewportProjector): - """ - An extremely limited projector which lacks any kind of control. This is only - here to act as the default camera used internally by Arcade. There should be - no instance where a developer would want to use this class. - - Args: - context: The window context to bind the camera to. Defaults to the currently active window. - """ - - def __init__(self, *, context: ArcadeContext | None = None): - super().__init__(context=context) - - def use(self) -> None: - """ - Set the window's Projection and View matrices. - - cache's the window viewport to determine the projection matrix. - """ - - viewport = self.viewport.lbwh_int - # If the viewport is correct and the default camera is in use, - # then don't waste time resetting the view and projection matrices - if self._ctx.viewport == viewport and self._ctx.current_camera == self: - return - - # If the viewport has changed while the default camera is active then the - # default needs to update itself. - # If it was another camera's viewport being used the default camera should not update. - if self._ctx.viewport != viewport and self._ctx.current_camera == self: - self.viewport = LBWH(*self._ctx.viewport) - else: - self._ctx.viewport = viewport - - self._ctx.current_camera = self - - self._ctx.view_matrix = Mat4() - self._ctx.projection_matrix = self._projection_matrix diff --git a/arcade/camera/viewport.py b/arcade/camera/viewport.py new file mode 100644 index 0000000000..0f0498bacc --- /dev/null +++ b/arcade/camera/viewport.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from pyglet.math import Mat4, Vec2, Vec3 +from typing_extensions import Self + +from arcade.camera.data_types import DEFAULT_FAR, DEFAULT_NEAR_ORTHO +from arcade.types import LBWH, Point, Rect +from arcade.window_commands import get_window + +if TYPE_CHECKING: + from arcade.context import ArcadeContext + +__all__ = ["ViewportProjector"] + + +class ViewportProjector: + """ + A simple Projector which does not rely on any camera PoDs. + + Does not have a way of moving, rotating, or zooming the camera. + perfect for something like UI or for mapping to an offscreen framebuffer. + + Args: + viewport: The viewport to project to. + context: The window context to bind the camera to. Defaults to the currently active window. + """ + + def __init__( + self, + viewport: Rect | None = None, + *, + context: ArcadeContext | None = None, + ): + self._ctx: ArcadeContext = context or get_window().ctx + self._viewport: Rect = viewport or LBWH(*self._ctx.viewport) + self._projection_matrix: Mat4 = Mat4.orthogonal_projection( + 0.0, self._viewport.width, 0.0, self._viewport.height, DEFAULT_NEAR_ORTHO, DEFAULT_FAR + ) + + @property + def viewport(self) -> Rect: + """ + The viewport use to derive projection and view matrix. + """ + return self._viewport + + @viewport.setter + def viewport(self, viewport: Rect) -> None: + self._viewport = viewport + self._projection_matrix = Mat4.orthogonal_projection( + 0, viewport.width, 0, viewport.height, DEFAULT_NEAR_ORTHO, DEFAULT_FAR + ) + + def use(self) -> None: + """ + Set the window's projection and view matrix. + Also sets the projector as the windows current camera. + """ + self._ctx.current_camera = self + + if self.viewport: + self._ctx.viewport = self.viewport.lbwh_int + + self._ctx.view_matrix = Mat4() + self._ctx.projection_matrix = self._projection_matrix + + @contextmanager + def activate(self) -> Generator[Self, None, None]: + """ + The context manager version of the use method. + + usable with the 'with' block. e.g. 'with ViewportProjector.activate() as cam: ...' + """ + previous = self._ctx.current_camera + previous_viewport = self._ctx.viewport + try: + self.use() + yield self + finally: + self._ctx.viewport = previous_viewport + previous.use() + + def project(self, world_coordinate: Point) -> Vec2: + """ + Take a Vec2 or Vec3 of coordinates and return the related screen coordinate + """ + x, y, *z = world_coordinate + return Vec2(x, y) + + def unproject(self, screen_coordinate: Point) -> Vec3: + """ + Map the screen pos to screen_coordinates. + + Due to the nature of viewport projector this does not do anything. + """ + x, y, *_z = screen_coordinate + z = 0.0 if not _z else _z[0] + + return Vec3(x, y, z) diff --git a/arcade/cli/__init__.py b/arcade/cli/__init__.py new file mode 100644 index 0000000000..217c3738e4 --- /dev/null +++ b/arcade/cli/__init__.py @@ -0,0 +1 @@ +from .cli import run_arcade_cli diff --git a/arcade/cli/cli.py b/arcade/cli/cli.py new file mode 100644 index 0000000000..7b80d8204c --- /dev/null +++ b/arcade/cli/cli.py @@ -0,0 +1,57 @@ +import argparse +import sys +from typing import Type + +from .commands import BaseCommand, InfoCommand + + +class CLI: + def __init__(self): + self.commands: dict[str, BaseCommand] = {} + self.prog: str = "arcade" + self.description: str = "Arcade Game Library CLI" + + def register_command(self, command_class: Type[BaseCommand]) -> None: + command = command_class() # type: ignore BaseCommand has different constructor than it's implementations + self.commands[command.name] = command + + def create_parser(self) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=self.prog, + description=self.description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + for command_name, command in self.commands.items(): + command_parser = subparsers.add_parser( + command_name, + help=command.help, + description=command.description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + command.add_arguments(command_parser) + + return parser + + def run(self) -> int: + parser = self.create_parser() + args = parser.parse_args() + + if args.command is None: + parser.print_help() + return 0 + + try: + command = self.commands[args.command] + return command.handle(args) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def run_arcade_cli(): + cli = CLI() + cli.register_command(InfoCommand) + return cli.run() diff --git a/arcade/cli/commands/__init__.py b/arcade/cli/commands/__init__.py new file mode 100644 index 0000000000..773d1c01cc --- /dev/null +++ b/arcade/cli/commands/__init__.py @@ -0,0 +1,4 @@ +from .base import BaseCommand +from .info import InfoCommand + +__all__ = ["BaseCommand", "InfoCommand"] diff --git a/arcade/cli/commands/base.py b/arcade/cli/commands/base.py new file mode 100644 index 0000000000..da06cc64c3 --- /dev/null +++ b/arcade/cli/commands/base.py @@ -0,0 +1,21 @@ +import argparse +from abc import ABC, abstractmethod + + +class BaseCommand(ABC): + name: str + description: str + help: str + + def __init__(self, name: str, description: str, help: str) -> None: + self.name = name + self.description = description + self.help = help + + @abstractmethod + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + pass + + @abstractmethod + def handle(self, args: argparse.Namespace) -> int: + pass diff --git a/arcade/cli/commands/info.py b/arcade/cli/commands/info.py new file mode 100644 index 0000000000..ba1328d61d --- /dev/null +++ b/arcade/cli/commands/info.py @@ -0,0 +1,36 @@ +import argparse +import sys + +import PIL +import pyglet + +import arcade + +from .base import BaseCommand + + +class InfoCommand(BaseCommand): + def __init__(self): + super().__init__( + name="info", + description="Print Arcade and System Information", + help="Print information about the installed Arcade version and system specifications", + ) + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + pass + + def handle(self, args: argparse.Namespace) -> int: + window = arcade.Window(visible=False) + version_str = f"Arcade {arcade.__version__}" + print() + print(version_str) + print("-" * len(version_str)) + print("vendor:", window.ctx.info.VENDOR) + print("device:", window.ctx.info.RENDERER) + print("renderer:", window.ctx.info.CTX_INFO) # type: ignore + print("python:", sys.version) + print("platform:", sys.platform) + print("pyglet version:", pyglet.version) + print("PIL version:", PIL.__version__) + return 1 diff --git a/arcade/context.py b/arcade/context.py index 52e0cedae5..c84c0f1ce1 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: """ @@ -315,6 +334,8 @@ def reset(self) -> None: self.projection_matrix = Mat4.orthogonal_projection( 0, self.window.width, 0, self.window.height, -100, 100 ) + self._default_camera: DefaultProjector = DefaultProjector(context=self) + self.current_camera = self._default_camera self.enable_only(self.BLEND) self.blend_func = self.BLEND_DEFAULT self.point_size = 1.0 @@ -326,12 +347,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 @@ -357,6 +374,15 @@ def default_atlas(self) -> TextureAtlasBase: return self._atlas + @property + def active_framebuffer(self): + return self._active_framebuffer + + @active_framebuffer.setter + def active_framebuffer(self, framebuffer: Framebuffer): + self._active_framebuffer = framebuffer + self._default_camera.update_viewport() + @property def viewport(self) -> tuple[int, int, int, int]: """ @@ -378,8 +404,7 @@ def viewport(self) -> tuple[int, int, int, int]: @viewport.setter def viewport(self, value: tuple[int, int, int, int]): self.active_framebuffer.viewport = value - if self._default_camera == self.current_camera: - self._default_camera.use() + self._default_camera.update_viewport() @property def projection_matrix(self) -> Mat4: 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/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/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/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/numpy_sprite.py b/arcade/examples/gl/numpy_sprite.py new file mode 100644 index 0000000000..c7d960285b --- /dev/null +++ b/arcade/examples/gl/numpy_sprite.py @@ -0,0 +1,270 @@ +""" +Render sprites that are backed by Numpy arrays of RGBA data + +If Python and Arcade are installed, this example can be run from the command line with: +python -m arcade.examples.gl.numpy_sprite +""" + +from array import array +from typing import Optional + +import arcade +import numpy as np + +from arcade.gl import BufferDescription, enums + +VERTEX_SHADER_SRC = """ +#version 330 + +// Input from buffers +in vec2 in_position; +in vec2 in_size; + +// Outputs to geometry shader +out vec2 position; +out vec2 size; + +void main() { + position = in_position; + size = in_size; +} +""" + +FRAGMENT_SHADER_SRC = """ +#version 330 + +uniform sampler2D sprite_texture; + +in vec2 uv; + +out vec4 fragColor; + +void main() { + fragColor = texture(sprite_texture, uv); +} +""" + +GEOMETRY_SHADER_SRC = """ +#version 330 + +// Configure inputs and outputs for the geometry shader +// We are taking single points form the vertex shader per invocation +// and emitting 4 new vertices creating a quad/sprites +layout (points) in; +layout (triangle_strip, max_vertices = 4) out; + +// A uniform buffer that will automagically contain arcade's projection matrix +uniform WindowBlock { + mat4 projection; + mat4 view; +} window; + +// Receive the outputs from the vertex shader. +// Since geometry shader can take multiple values from a vertex +// shader we need to define the inputs as arrays. +// We're only getting one vertex at the time in this example, +// but we make an unsized array leaving the rest up to the shader compiler. +in vec2 position[]; +in vec2 size[]; + +// Texture coordinate to fragment shader +out vec2 uv; + +void main() { + // Create some more convenient variables for the input + vec2 center = position[0]; + vec2 hsize = size[0] / 2.0; + + // Emit a triangle strip of 4 vertices making a triangle. + // The fragment shader will then fill these triangles in the next stage. + + mat4 mvp = window.projection * window.view; + + // Upper left + gl_Position = mvp * vec4(vec2(-hsize.x, hsize.y) + center, 0.0, 1.0); + uv = vec2(0, 1); + EmitVertex(); + + // lower left + gl_Position = mvp * vec4(vec2(-hsize.x, -hsize.y) + center, 0.0, 1.0); + uv = vec2(0, 0); + EmitVertex(); + + // upper right + gl_Position = mvp * vec4(vec2(hsize.x, hsize.y) + center, 0.0, 1.0); + uv = vec2(1, 1); + EmitVertex(); + + // lower right + gl_Position = mvp * vec4(vec2(hsize.x, -hsize.y) + center, 0.0, 1.0); + uv = vec2(1, 0); + EmitVertex(); + + EndPrimitive(); +} +""" +class NumpySprite: + def __init__( + self, + ctx: arcade.ArcadeContext, + center_x: float, + center_y: float, + texture_width: int, + texture_height: int, + width: int = 0, + height: int = 0, + data: Optional[np.ndarray] = None, + filter: int = enums.NEAREST, + ): + self.ctx = ctx + + self._position = (center_x, center_y) + self._texture_width = texture_width + self._texture_height = texture_height + + if not width: + width = self._texture_width + + if not height: + height = self._texture_height + + self._width = width + self._height = height + + self._position_changed = False + self._size_changed = False + + self._program = self.ctx.program( + vertex_shader=VERTEX_SHADER_SRC, + fragment_shader=FRAGMENT_SHADER_SRC, + geometry_shader=GEOMETRY_SHADER_SRC + ) + self._program["sprite_texture"] = 0 + + self._texture = self.ctx.texture( + (self._texture_width, self._texture_height), + filter=(filter, filter) + ) + + if data is None: + data = np.zeros( + (self._texture_width, self._texture_height, 4), dtype=np.uint8 + ) + + self._texture.write(data) + + self._vertex_buffer = self.ctx.buffer( + data=array( + "f", + ( + self._position[0], + self._position[1], + self._width, + self._height + ) + ) + ) + + self._geometry = self.ctx.geometry( + content=[ + BufferDescription( + self._vertex_buffer, "2f 2f", ["in_position", "in_size"] + ) + ] + ) + + @property + def position(self) -> tuple[float, float]: + return self._position + + @position.setter + def position(self, new_value: tuple[float, float]): + if new_value[0] != self._position[0] or new_value[1] != self._position[1]: + self._position = new_value + self._position_changed = True + + @property + def width(self) -> int: + return self._width + + @width.setter + def width(self, new_value: int): + if new_value != self._width: + self._width = new_value + self._size_changed = True + + @property + def height(self) -> int: + return self._height + + @height.setter + def height(self, new_value: int): + if new_value != self._height: + self._height = new_value + self._size_changed = True + + def _write_buffers_to_gpu(self): + if self._size_changed or self._position_changed: + self._vertex_buffer.write( + data=array( + "f", + ( + self._position[0], + self._position[1], + self._width, + self._height, + ), + ) + ) + self._size_changed = False + self._position_changed = False + + def draw(self): + self._write_buffers_to_gpu() + self.ctx.enable(self.ctx.BLEND) + + self._texture.use(unit=0) + self._geometry.render(self._program) + + def write(self, data: np.ndarray): + self._texture.write(data) # type: ignore + + +class NumpyWindow(arcade.Window): + + def __init__(self): + super().__init__(800, 600, "Numpy Sprite Example") + arcade.set_background_color(arcade.color.BLACK) + + self.sprite_one_np = np.full((32, 32, 4), (255, 0, 0, 255), dtype=np.uint8) + self.sprite_one = NumpySprite( + self.ctx, 200, 300, 32, 32, 200, 200, self.sprite_one_np + ) + + self.sprite_two_np = np.full((32, 32, 4), (0, 0, 255, 255), dtype=np.uint8) + self.sprite_two = NumpySprite( + self.ctx, 500, 400, 32, 32, 200, 200, self.sprite_two_np + ) + + self._time = 0.0 + + def on_draw(self): + self.clear() + self.sprite_one.draw() + self.sprite_two.draw() + + def on_update(self, delta_time: float): + self._time += delta_time + + g = int((np.sin(self._time * 2.0) * 0.5 + 0.5) * 255) + + self.sprite_one_np[:, :, 1] = g + self.sprite_one.write(self.sprite_one_np) + + self.sprite_two_np[:, :, 1] = g + self.sprite_two.write(self.sprite_two_np) + + +if __name__ == "__main__": + NumpyWindow() + arcade.run() \ No newline at end of file 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/2_widgets.py b/arcade/examples/gui/2_widgets.py index 74223dc0c3..00706d1f77 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -395,7 +395,8 @@ def _show_interactive_widgets(self): dropdown_row.add( UIDropdown( default="Option 1", - options=["Option 1", "Option 2", "Option 3"], + options=[f"Option {i}" for i in range(1, 16)], + show_scroll_bar=True, ) ) 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/hex_map.py b/arcade/examples/hex_map.py new file mode 100644 index 0000000000..2b7b4305b8 --- /dev/null +++ b/arcade/examples/hex_map.py @@ -0,0 +1,195 @@ +""" +Hex Map Example + +If Python and Arcade are installed, this example can be run from the command line with: +python -m arcade.examples.hex_map +""" + +import math +from operator import add + +from pyglet.math import Vec2 + +import arcade +from arcade import hexagon + +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 720 +WINDOW_TITLE = "Hex Map" + + +class GameView(arcade.View): + """ + Main application class. + """ + + def __init__(self): + super().__init__() + + # Variable to hold our Tiled Map + self.tile_map: arcade.TileMap + + # Replacing all of our SpriteLists with a Scene variable + self.scene: arcade.Scene + + # A variable to store our camera object + self.camera: arcade.camera.Camera2D + + # A variable to store our gui camera object + self.gui_camera: arcade.camera.Camera2D + + # Initialize the mouse_pan variable + self.mouse_pan = False + + def reset(self): + """Reset the game to the initial state.""" + # Do changes needed to restart the game here + + # Tiled always uses pointy orientations + orientation = hexagon.pointy_orientation + # + hex_size_x = 120 / math.sqrt(3) + hex_size_y = 140 / 2 + map_origin = Vec2(0, 0) + + hex_layout = hexagon.Layout( + orientation=orientation, + size=Vec2(hex_size_x, hex_size_y), + origin=map_origin, + ) + + # Load our TileMap + self.tile_map = arcade.load_tilemap( + ":resources:tiled_maps/hex_map.tmj", + hex_layout=hex_layout, + use_spatial_hash=True, + ) + + # Create our Scene Based on the TileMap + self.scene = arcade.Scene.from_tilemap(self.tile_map) # type: ignore[arg-type] + + # Initialize our camera, setting a viewport the size of our window. + self.camera = arcade.camera.Camera2D() + self.camera.zoom = 0.5 + + # Initialize our gui camera, initial settings are the same as our world camera. + self.gui_camera = arcade.camera.Camera2D() + + # Set the background color to a nice red + self.background_color = arcade.color.BLACK + + def on_draw(self): + """ + Render the screen. + """ + + # This command should happen before we start drawing. It will clear + # the screen to the background color, and erase what we drew last frame. + self.clear() + + with self.camera.activate(): + self.scene.draw() + + # Call draw() on all your sprite lists below + + def on_update(self, delta_time): + """ + All the logic to move, and the game logic goes here. + Normally, you'll call update() on the sprite lists that + need it. + """ + pass + + def on_key_press(self, key, key_modifiers): + """ + Called whenever a key on the keyboard is pressed. + + For a full list of keys, see: + https://api.arcade.academy/en/latest/arcade.key.html + """ + pass + + def on_key_release(self, key, key_modifiers): + """ + Called whenever the user lets off a previously pressed key. + """ + pass + + def on_mouse_motion(self, x, y, delta_x, delta_y): + """ + Called whenever the mouse moves. + """ + if self.mouse_pan: + # If the middle mouse button is pressed, we want to pan the camera + # by the amount of pixels the mouse moved, divided by the zoom level + # to keep the panning speed consistent regardless of zoom level. + # The camera position is updated by adding the delta_x and delta_y + # values to the current camera position, divided by the zoom level. + # This is done using the add function from the operator module to + # add the delta_x and delta_y values to the current camera position. + self.camera.position = tuple( + map( + add, + self.camera.position, + (-delta_x * 1 / self.camera.zoom, -delta_y * 1 / self.camera.zoom), + ) + ) + return + + def on_mouse_press(self, x, y, button, key_modifiers): + """ + Called when the user presses a mouse button. + """ + if button == arcade.MOUSE_BUTTON_MIDDLE: + self.mouse_pan = True + return + + def on_mouse_release(self, x, y, button, key_modifiers): + """ + Called when a user releases a mouse button. + """ + if button == arcade.MOUSE_BUTTON_MIDDLE: + self.mouse_pan = False + return + + def on_mouse_scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + """Called whenever the mouse scrolls.""" + # If the mouse wheel is scrolled, we want to zoom the camera in or out + # by the amount of scroll_y. The zoom level is adjusted by adding the + # scroll_y value multiplied by a zoom factor (0.1 in this case) to the + # current zoom level. This allows for smooth zooming in and out of the + # camera view. + + self.camera.zoom += scroll_y * 0.1 + + # The zoom level is clamped to a minimum of 0.1 to prevent the camera + # from zooming out too far. + if self.camera.zoom < 0.1: + self.camera.zoom = 0.1 + + # The zoom level is clamped to a maximum of 10 to prevent the camera + # from zooming in too far. + if self.camera.zoom > 2: + self.camera.zoom = 2 + + +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() + + # Show GameView on screen + window.show_view(game) + + # Reset the game to the initial state + game.reset() + + # Start the arcade game loop + arcade.run() + + +if __name__ == "__main__": + main() 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/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: 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_move_input_manager.py b/arcade/examples/sprite_move_input_manager.py new file mode 100644 index 0000000000..94b2ad15b1 --- /dev/null +++ b/arcade/examples/sprite_move_input_manager.py @@ -0,0 +1,156 @@ +""" +Move Sprite With Keyboard or Controller using InputManager + +Simple program to show moving a sprite with the keyboard or controller via InputManager. +This is similar to the behavior in sprite_move_controller and sprite_move_keyboard +but combining the devices using Arcade's advanced input system. + +Artwork from https://kenney.nl + +If Python and Arcade are installed, this example can be run from the command line with: +python -m arcade.examples.sprite_move_input_manager +""" + +import arcade + +SPRITE_SCALING = 0.5 + +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 720 +WINDOW_TITLE = "Move Sprite with InputManager Example" + +MOVEMENT_SPEED = 5 + + +class Player(arcade.Sprite): + + def update(self, delta_time: float = 1/60): + """ Move the player """ + # Move player. + # Remove these lines if physics engine is moving player. + self.center_x += self.change_x + self.center_y += self.change_y + + # Check for out-of-bounds + if self.left < 0: + self.left = 0 + elif self.right > WINDOW_WIDTH - 1: + self.right = WINDOW_WIDTH - 1 + + if self.bottom < 0: + self.bottom = 0 + elif self.top > WINDOW_HEIGHT - 1: + self.top = WINDOW_HEIGHT - 1 + + +class GameView(arcade.View): + """ + Main application class. + """ + + def __init__(self): + """ + Initializer + """ + + # Call the parent class initializer + super().__init__() + + # Set our controller to None, this will get changed if we find a connected controller + controller = None + + # Ask arcade for a list of connected controllers + controllers = arcade.get_controllers() + if controllers: + # Just use the first one in the list for now + controller = controllers[0] + + # Create a new InputManager, and assign our controller to it(if we have one) + self.input_manager = arcade.InputManager(controller) + + # Add a new horizontal movement axis to the input manager. + # Also assign the LEFT/RIGHT arrow keys and left thumbstick to it + self.input_manager.new_axis("MoveHorizontal") + self.input_manager.add_axis_input_combined( + "MoveHorizontal", + arcade.Keys.RIGHT, + arcade.Keys.LEFT + ) + self.input_manager.add_axis_input("MoveHorizontal", arcade.ControllerSticks.LEFT_STICK_X) + + # Same thing for vertical movement axis + self.input_manager.new_axis("MoveVertical") + self.input_manager.add_axis_input_combined( + "MoveVertical", + arcade.Keys.UP, + arcade.Keys.DOWN + ) + self.input_manager.add_axis_input("MoveVertical", arcade.ControllerSticks.LEFT_STICK_Y) + + # Variables that will hold sprite lists + self.player_list = None + + # Set up the player info + self.player_sprite = None + + # Set the background color + self.background_color = arcade.color.AMAZON + + def setup(self): + """ Set up the game and initialize the variables. """ + + # Sprite lists + self.player_list = arcade.SpriteList() + + # Set up the player + self.player_sprite = Player( + ":resources:images/animated_characters/female_person/femalePerson_idle.png", + scale=SPRITE_SCALING, + ) + self.player_sprite.center_x = 50 + self.player_sprite.center_y = 50 + self.player_list.append(self.player_sprite) + + def on_draw(self): + """ Render the screen. """ + + # Clear the screen + self.clear() + + # Draw all the sprites. + self.player_list.draw() + + def on_update(self, delta_time): + """ Movement and game logic """ + + # Update the input manager so it has the latest values from our devices + self.input_manager.update() + + # Apply the input axes to the player + self.player_sprite.change_x = self.input_manager.axis("MoveHorizontal") * MOVEMENT_SPEED + self.player_sprite.change_y = self.input_manager.axis("MoveVertical") * MOVEMENT_SPEED + + # Call update to move the sprite + # If using a physics engine, call update player to rely on physics engine + # for movement, and call physics engine here. + self.player_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/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/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/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..dab2fc70a6 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""" @@ -429,6 +455,8 @@ def __init__(self, ctx): self.MAJOR_VERSION = self.get(gl.GL_MAJOR_VERSION) """Major version number of the OpenGL API supported by the current context.""" + self.CTX_INFO = f"opengl {self.MAJOR_VERSION}.{self.MINOR_VERSION}" + self.MAX_COLOR_TEXTURE_SAMPLES = self.get(gl.GL_MAX_COLOR_TEXTURE_SAMPLES) """Maximum number of samples in a color multisample texture""" @@ -491,7 +519,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 +549,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 +561,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..7e39ec9ba6 --- /dev/null +++ b/arcade/gl/backends/webgl/context.py @@ -0,0 +1,413 @@ +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 + self.CTX_INFO = "webgl" + + 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..a4d30975b3 --- /dev/null +++ b/arcade/gl/backends/webgl/framebuffer.py @@ -0,0 +1,285 @@ +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 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 + # function because of ABC + if not isinstance(value, tuple) or len(value) != 4: + raise ValueError("viewport shouldbe a 4-component tuple") + + self._viewport = value + + 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): + if value is None: + self._scissor = None + if self._ctx.active_framebuffer == self: + self._ctx._gl.scissor(*self._viewport) + else: + self._scissor = value + 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..41131dc67d 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) @@ -220,11 +221,10 @@ def __init__( # 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._active_framebuffer: Framebuffer = self._screen 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 @@ -327,7 +327,18 @@ def fbo(self) -> Framebuffer: """ Get the currently active framebuffer (read only). """ - return self.active_framebuffer + return self._active_framebuffer + + @property + def active_framebuffer(self) -> Framebuffer: + """ + Get the currently active framebuffer. + """ + return self._active_framebuffer + + @active_framebuffer.setter + def active_framebuffer(self, framebuffer: Framebuffer) -> None: + self._active_framebuffer = framebuffer def gc(self) -> int: """ 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/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/__init__.py b/arcade/gui/widgets/__init__.py index f275509e22..7f1c9a8034 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings import weakref from abc import ABC from collections.abc import Iterable @@ -1026,6 +1027,38 @@ def do_layout(self): frame, this will happen automatically if the position or size of this widget changed. """ + def _warn_if_size_hint_overrides_fixed_size(self, width, height, size_hint) -> None: + """Warn when a fixed width/height is given but the size_hint will override it. + + Layouts have non-None size_hint by default, which causes the parent layout to + resize them, overriding any fixed width/height given by the developer. + + Args: + width: The width argument passed to __init__, or ``...`` if + width was not explicitly provided. + height: The height argument passed to __init__, or ``...`` if + height was not explicitly provided. + size_hint: The size_hint argument passed to __init__. + """ + class_name = type(self).__name__ + sh_w = size_hint[0] if size_hint is not None else None + sh_h = size_hint[1] if size_hint is not None else None + + if width is not ... and sh_w is not None: + warnings.warn( + f"{class_name} was given a fixed width, but size_hint_x is {sh_w!r}. " + f"The size_hint will override the fixed width. " + f"Set size_hint=(None, ...) to use a fixed width.", + stacklevel=3, + ) + if height is not ... and sh_h is not None: + warnings.warn( + f"{class_name} was given a fixed height, but size_hint_y is {sh_h!r}. " + f"The size_hint will override the fixed height. " + f"Set size_hint=(..., None) to use a fixed height.", + stacklevel=3, + ) + class UISpace(UIWidget): """Widget reserving space, can also have a background color. diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index afa75c495a..9cb59c2881 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -6,7 +6,9 @@ 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.experimental.scroll_area import UIScrollBar from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UILayout, UIWidget from arcade.gui.widgets.buttons import UIFlatButton @@ -14,14 +16,57 @@ class _UIDropdownOverlay(UIFocusMixin, UIBoxLayout): - """Represents the dropdown options overlay. + """Represents the dropdown options overlay with scroll support. - Currently only handles closing the overlay when clicked outside of the options. + Contains a UIScrollArea with the option buttons and a UIScrollBar + for navigating when options exceed the maximum height. """ - # TODO move also options logic to this class + SCROLL_BAR_WIDTH = 15 - def show(self, manager: UIManager): + def __init__( + self, + max_height: float = 200, + invert_scroll: bool = False, + scroll_speed: float = 15.0, + show_scroll_bar: bool = False, + ): + # Horizontal layout: [scroll_area | scroll_bar] + # size_hint=None prevents UIManager from overriding the rect + # that UIDropdown.do_layout explicitly sets. + super().__init__(vertical=False, align="top", size_hint=None) + self._max_height = max_height + self._show_scroll_bar = show_scroll_bar + + self._options_layout = UIBoxLayout(size_hint=(1, 0)) + self._scroll_area = UIScrollArea( + width=100, + height=100, + canvas_size=(100, 100), + size_hint=(1, 1), + ) + self._scroll_area.invert_scroll = invert_scroll + self._scroll_area.scroll_speed = scroll_speed + self._scroll_area.add(self._options_layout) + + super().add(self._scroll_area) + + if show_scroll_bar: + self._scroll_bar = UIScrollBar(self._scroll_area, vertical=True) + self._scroll_bar.size_hint = (None, 1) + self._scroll_bar.rect = self._scroll_bar.rect.resize(width=self.SCROLL_BAR_WIDTH) + super().add(self._scroll_bar) + + def add_option(self, widget: UIWidget) -> UIWidget: + """Add an option widget to the options layout.""" + return self._options_layout.add(widget) + + def clear_options(self): + """Clear all options and reset scroll position.""" + self._options_layout.clear() + self._scroll_area.scroll_y = 0 + + def show(self, manager: UIManager | UIScrollArea): manager.add(self, layer=UIManager.OVERLAY_LAYER) def hide(self): @@ -66,6 +111,10 @@ 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. + max_height: Maximum height of the dropdown menu before scrolling is enabled. + invert_scroll: Invert the scroll direction of the dropdown menu. + scroll_speed: Speed of scrolling in the dropdown menu. + show_scroll_bar: Show a scroll bar in the dropdown menu. 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. @@ -119,6 +168,10 @@ def __init__( height: float = 30, default: str | None = None, options: list[str | None] | None = None, + max_height: float = 200, + invert_scroll: bool = False, + scroll_speed: float = 15.0, + show_scroll_bar: bool = False, primary_style=None, dropdown_style=None, active_style=None, @@ -149,7 +202,12 @@ def __init__( ) self._default_button.on_click = self._on_button_click # type: ignore - self._overlay = _UIDropdownOverlay() + self._overlay = _UIDropdownOverlay( + max_height=max_height, + invert_scroll=invert_scroll, + scroll_speed=scroll_speed, + show_scroll_bar=show_scroll_bar, + ) self._update_options() # add children after super class setup @@ -175,16 +233,16 @@ def value(self, value: str | None): def _update_options(self): # generate options - self._overlay.clear() + self._overlay.clear_options() for option in self._options: if option is None: # None = UIDropdown.DIVIDER, required by pyright - self._overlay.add( + self._overlay.add_option( UIWidget(width=self.width, height=2).with_background(color=arcade.color.GRAY) ) continue else: - button = self._overlay.add( + button = self._overlay.add_option( UIFlatButton( text=option, width=self.width, @@ -197,11 +255,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.") + # 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 - self._overlay.show(manager) + 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() @@ -216,13 +282,23 @@ def do_layout(self): but is required for the dropdown.""" self._default_button.rect = self.rect - # resize layout to contain widgets - overlay = self._overlay - rect = overlay.rect - if overlay.size_hint_min is not None: - rect = rect.resize(*overlay.size_hint_min) + # Calculate total options height + total_h = 0 + for option in self._options: + total_h += 2 if option is None else self.height - self._overlay.rect = rect.align_top(self.bottom - 2).align_left(self._default_button.left) + # Cap at max_height + overlay = self._overlay + visible_h = min(total_h, overlay._max_height) if total_h > 0 else self.height + scroll_bar_w = _UIDropdownOverlay.SCROLL_BAR_WIDTH if overlay._show_scroll_bar else 0 + overlay_w = self.width + scroll_bar_w + + overlay.rect = ( + overlay.rect + .resize(overlay_w, visible_h) + .align_top(self.bottom - 2) + .align_left(self._default_button.left) + ) def on_change(self, event: UIOnChangeEvent): """To be implemented by the user, triggered when the current selected value diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index bdf2512178..ffd010689d 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -4,6 +4,7 @@ from collections.abc import Iterable from dataclasses import dataclass from typing import Literal, TypeVar +from types import EllipsisType from typing_extensions import override @@ -73,19 +74,20 @@ def __init__( *, x: float = 0, y: float = 0, - width: float = 1, - height: float = 1, + width: float | EllipsisType = ..., + height: float | EllipsisType = ..., children: Iterable[UIWidget] = tuple(), size_hint=(1, 1), size_hint_min=None, size_hint_max=None, **kwargs, ): + self._warn_if_size_hint_overrides_fixed_size(width, height, size_hint) super().__init__( x=x, y=y, - width=width, - height=height, + width=1 if width is ... else width, + height=1 if height is ... else height, children=children, size_hint=size_hint, size_hint_min=size_hint_min, @@ -239,10 +241,10 @@ class UIBoxLayout(UILayout): def __init__( self, *, - x=0, - y=0, - width=1, - height=1, + x: float = 0, + y: float = 0, + width: float | EllipsisType = ..., + height: float | EllipsisType = ..., vertical=True, align="center", children: Iterable[UIWidget] = tuple(), @@ -252,11 +254,12 @@ def __init__( style=None, **kwargs, ): + self._warn_if_size_hint_overrides_fixed_size(width, height, size_hint) super().__init__( x=x, y=y, - width=width, - height=height, + width=1 if width is ... else width, + height=1 if height is ... else height, children=children, size_hint=size_hint, size_hint_max=size_hint_max, @@ -485,10 +488,10 @@ class UIGridLayout(UILayout): def __init__( self, *, - x=0, - y=0, - width=1, - height=1, + x: float = 0, + y: float = 0, + width: float | EllipsisType = ..., + height: float | EllipsisType = ..., align_horizontal="center", align_vertical="center", children: Iterable[UIWidget] = tuple(), @@ -500,11 +503,12 @@ def __init__( row_count: int = 1, **kwargs, ): + self._warn_if_size_hint_overrides_fixed_size(width, height, size_hint) super().__init__( x=x, y=y, - width=width, - height=height, + width=1 if width is ... else width, + height=1 if height is ... else height, children=children, size_hint=size_hint, size_hint_max=size_hint_max, diff --git a/arcade/hexagon.py b/arcade/hexagon.py new file mode 100644 index 0000000000..318a85927e --- /dev/null +++ b/arcade/hexagon.py @@ -0,0 +1,347 @@ +"""Hexagon utilities. + +This module started as the Python implementation of the hexagon utilities +from Red Blob Games. + +See: https://www.redblobgames.com/grids/hexagons/ + +CC0 -- No Rights Reserved +""" + +import math +from dataclasses import dataclass +from math import isclose +from typing import Literal, NamedTuple, cast + +from pyglet.math import Vec2 + +_EVEN: Literal[1] = 1 +_ODD: Literal[-1] = -1 + +offset_system = Literal["odd-r", "even-r", "odd-q", "even-q"] + + +class _Orientation(NamedTuple): + """Helper class to store forward and inverse matrix for hexagon conversion. + + Also stores the start angle for hexagon corners. + """ + + f0: float + f1: float + f2: float + f3: float + b0: float + b1: float + b2: float + b3: float + start_angle: float + + +pointy_orientation = _Orientation( + math.sqrt(3.0), + math.sqrt(3.0) / 2.0, + 0.0, + 3.0 / 2.0, + math.sqrt(3.0) / 3.0, + -1.0 / 3.0, + 0.0, + 2.0 / 3.0, + 0.5, +) +flat_orientation = _Orientation( + 3.0 / 2.0, + 0.0, + math.sqrt(3.0) / 2.0, + math.sqrt(3.0), + 2.0 / 3.0, + 0.0, + -1.0 / 3.0, + math.sqrt(3.0) / 3.0, + 0.0, +) + + +class Layout(NamedTuple): + """Helper class to store hexagon layout information.""" + + orientation: _Orientation + size: Vec2 + origin: Vec2 + + +# TODO: should this be a np.array? +# TODO: should this be in rust? +# TODO: should this be cached/memoized? +# TODO: benchmark +@dataclass(frozen=True) +class HexTile: + """A hexagonal tile in cube coordinates. + + For an introduction to hexagonal grids and cube coordinates, see: + https://www.redblobgames.com/grids/hexagons/ + """ + + q: float + r: float + s: float + + def __post_init__(self) -> None: + """Create a hexagon in cube coordinates.""" + cube_sum = self.q + self.r + self.s + assert isclose(0, cube_sum, abs_tol=1e-14), f"q + r + s must be 0, is {cube_sum}" + + def __eq__(self, other: object) -> bool: + """Check if two hexagons are equal.""" + result = self.q == other.q and self.r == other.r and self.s == other.s # type: ignore[attr-defined] + assert isinstance(result, bool) + return result + + def __add__(self, other: "HexTile") -> "HexTile": + """Add two hexagons.""" + return HexTile(self.q + other.q, self.r + other.r, self.s + other.s) + + def __sub__(self, other: "HexTile") -> "HexTile": + """Subtract two hexagons.""" + return HexTile(self.q - other.q, self.r - other.r, self.s - other.s) + + def __mul__(self, k: int) -> "HexTile": + """Multiply a hexagon by a scalar.""" + return HexTile(self.q * k, self.r * k, self.s * k) + + def __neg__(self) -> "HexTile": + """Negate a hexagon.""" + return HexTile(-self.q, -self.r, -self.s) + + def __round__(self) -> "HexTile": + """Round a hexagon.""" + qi = round(self.q) + ri = round(self.r) + si = round(self.s) + q_diff = abs(qi - self.q) + r_diff = abs(ri - self.r) + s_diff = abs(si - self.s) + if q_diff > r_diff and q_diff > s_diff: + qi = -ri - si + elif r_diff > s_diff: + ri = -qi - si + else: + si = -qi - ri + return HexTile(qi, ri, si) + + def rotate_left(self) -> "HexTile": + """Rotate a hexagon to the left.""" + return HexTile(-self.s, -self.q, -self.r) + + def rotate_right(self) -> "HexTile": + """Rotate a hexagon to the right.""" + return HexTile(-self.r, -self.s, -self.q) + + @staticmethod + def direction(direction: int) -> "HexTile": + """Return a relative hexagon in a given direction.""" + hex_directions = [ + HexTile(1, 0, -1), + HexTile(1, -1, 0), + HexTile(0, -1, 1), + HexTile(-1, 0, 1), + HexTile(-1, 1, 0), + HexTile(0, 1, -1), + ] + return hex_directions[direction] + + def neighbor(self, direction: int) -> "HexTile": + """Return the neighbor in a given direction.""" + return self + self.direction(direction) + + def neighbors(self) -> list["HexTile"]: + """Return the neighbors of a hexagon.""" + return [self.neighbor(i) for i in range(6)] + + def diagonal_neighbor(self, direction: int) -> "HexTile": + """Return the diagonal neighbor in a given direction.""" + hex_diagonals = [ + HexTile(2, -1, -1), + HexTile(1, -2, 1), + HexTile(-1, -1, 2), + HexTile(-2, 1, 1), + HexTile(-1, 2, -1), + HexTile(1, 1, -2), + ] + return self + hex_diagonals[direction] + + def length(self) -> int: + """Return the length of a hexagon.""" + return int((abs(self.q) + abs(self.r) + abs(self.s)) // 2) + + def distance_to(self, other: "HexTile") -> float: + """Return the distance between self and another HexTile.""" + return (self - other).length() + + def line_to(self, other: "HexTile") -> list["HexTile"]: + """Return a list of hexagons between self and another HexTile.""" + return line(self, other) + + def lerp_between(self, other: "HexTile", t: float) -> "HexTile": + """Perform a linear interpolation between self and another HexTile.""" + return lerp(self, other, t) + + def to_pixel(self, layout: Layout) -> Vec2: + """Convert a hexagon to pixel coordinates.""" + return hextile_to_pixel(self, layout) + + def to_offset(self, system: offset_system) -> "OffsetCoord": + """Convert a hexagon to offset coordinates.""" + if system == "odd-r": + return roffset_from_cube(self, _ODD) + if system == "even-r": + return roffset_from_cube(self, _EVEN) + if system == "odd-q": + return qoffset_from_cube(self, _ODD) + if system == "even-q": + return qoffset_from_cube(self, _EVEN) + + msg = "system must be odd-r, even-r, odd-q, or even-q" + raise ValueError(msg) + + +def lerp(a: HexTile, b: HexTile, t: float) -> HexTile: + """Perform a linear interpolation between two hexagons.""" + return HexTile( + a.q * (1.0 - t) + b.q * t, + a.r * (1.0 - t) + b.r * t, + a.s * (1.0 - t) + b.s * t, + ) + + +def distance(a: HexTile, b: HexTile) -> int: + """Return the distance between two hexagons.""" + return (a - b).length() + + +def line(a: HexTile, b: HexTile) -> list[HexTile]: + """Return a list of hexagons between two hexagons.""" + n = distance(a, b) + # epsilon to nudge points by to falling on an edge + a_nudge = HexTile(a.q + 1e-06, a.r + 1e-06, a.s - 2e-06) + b_nudge = HexTile(b.q + 1e-06, b.r + 1e-06, b.s - 2e-06) + step = 1.0 / max(n, 1) + return [round(lerp(a_nudge, b_nudge, step * i)) for i in range(n + 1)] + + +def hextile_to_pixel(h: HexTile, layout: Layout) -> Vec2: + """Convert axial hexagon coordinates to pixel coordinates.""" + M = layout.orientation # noqa: N806 + size = layout.size + origin = layout.origin + x = (M.f0 * h.q + M.f1 * h.r) * size.x + y = (M.f2 * h.q + M.f3 * h.r) * size.y + return Vec2(x + origin.x, y + origin.y) + + +def pixel_to_hextile( + p: Vec2, + layout: Layout, +) -> HexTile: + """Convert pixel coordinates to cubic hexagon coordinates.""" + M = layout.orientation # noqa: N806 + size = layout.size + origin = layout.origin + pt = Vec2((p.x - origin.x) / size.x, (p.y - origin.y) / size.y) + q = M.b0 * pt.x + M.b1 * pt.y + r = M.b2 * pt.x + M.b3 * pt.y + return HexTile(q, r, -q - r) + + +def hextile_corner_offset(corner: int, layout: Layout) -> Vec2: + """Return the offset of a hexagon corner.""" + # Hexagons have 6 corners + assert 0 <= corner < 6 # noqa: PLR2004 + M = layout.orientation # noqa: N806 + size = layout.size + angle = 2.0 * math.pi * (M.start_angle - corner) / 6.0 + return Vec2(size.x * math.cos(angle), size.y * math.sin(angle)) + + +hextile_corners = tuple[Vec2, Vec2, Vec2, Vec2, Vec2, Vec2] + + +def polygon_corners(h: HexTile, layout: Layout) -> hextile_corners: + """Return the corners of a hexagon in a list of pixels.""" + corners = [] + center = hextile_to_pixel(h, layout) + for i in range(6): + offset = hextile_corner_offset(i, layout) + corners.append(Vec2(center.x + offset.x, center.y + offset.y)) + result = tuple(corners) + # Hexagons have 6 corners + assert len(result) == 6 # noqa: PLR2004 + return cast("hextile_corners", result) + + +@dataclass(frozen=True) +class OffsetCoord: + """Offset coordinates.""" + + col: float + row: float + + def to_cube(self, system: offset_system) -> HexTile: + """Convert offset coordinates to cube coordinates.""" + if system == "odd-r": + return roffset_to_cube(self, _ODD) + if system == "even-r": + return roffset_to_cube(self, _EVEN) + if system == "odd-q": + return qoffset_to_cube(self, _ODD) + if system == "even-q": + return qoffset_to_cube(self, _EVEN) + + msg = "system must be EVEN (+1) or ODD (-1)" + raise ValueError(msg) + + +def qoffset_from_cube(h: HexTile, offset: Literal[-1, 1]) -> OffsetCoord: + """Convert a hexagon in cube coordinates to q offset coordinates.""" + if offset not in (_EVEN, _ODD): + msg = "offset must be EVEN (+1) or ODD (-1)" + raise ValueError(msg) + + col = h.q + row = h.r + (h.q + offset * (h.q & 1)) // 2 # type: ignore[operator] + return OffsetCoord(col, row) + + +def qoffset_to_cube(h: OffsetCoord, offset: Literal[-1, 1]) -> HexTile: + """Convert a hexagon in q offset coordinates to cube coordinates.""" + if offset not in (_EVEN, _ODD): + msg = "offset must be EVEN (+1) or ODD (-1)" + raise ValueError(msg) + + q = h.col + r = h.row - (h.col + offset * (h.col & 1)) // 2 # type: ignore[operator] + s = -q - r + return HexTile(q, r, s) + + +def roffset_from_cube(h: HexTile, offset: Literal[-1, 1]) -> OffsetCoord: + """Convert a hexagon in cube coordinates to r offset coordinates.""" + if offset not in (_EVEN, _ODD): + msg = "offset must be EVEN (+1) or ODD (-1)" + raise ValueError(msg) + + col = h.q + (h.r + offset * (h.r & 1)) // 2 # type: ignore[operator] + row = h.r + return OffsetCoord(col, row) + + +def roffset_to_cube(h: OffsetCoord, offset: Literal[-1, 1]) -> HexTile: + """Convert a hexagon in r offset coordinates to cube coordinates.""" + if offset not in (_EVEN, _ODD): + msg = "offset must be EVEN (+1) or ODD (-1)" + raise ValueError(msg) + + q = h.col - (h.row + offset * (h.row & 1)) // 2 # type: ignore[operator] + r = h.row + s = -q - r + return HexTile(q, r, s) diff --git a/arcade/hitbox/__init__.py b/arcade/hitbox/__init__.py index becf40679d..01086e7a9b 100644 --- a/arcade/hitbox/__init__.py +++ b/arcade/hitbox/__init__.py @@ -4,13 +4,21 @@ 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() + +#: 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() #: 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 84% rename from arcade/future/input/__init__.py rename to arcade/input/__init__.py index d4c1a1cf63..8b4e086c8f 100644 --- a/arcade/future/input/__init__.py +++ b/arcade/input/__init__.py @@ -2,8 +2,9 @@ # type: ignore from .inputs import ( - ControllerAxes, ControllerButtons, + ControllerSticks, + ControllerTriggers, XBoxControllerButtons, PSControllerButtons, Keys, @@ -14,8 +15,9 @@ from .input_mapping import Action, ActionMapping, Axis, AxisMapping __all__ = [ - "ControllerAxes", "ControllerButtons", + "ControllerSticks", + "ControllerTriggers", "XBoxControllerButtons", "PSControllerButtons", "Keys", 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 92% rename from arcade/future/input/inputs.py rename to arcade/input/inputs.py index 1371e750bb..a2022f0bb7 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): @@ -16,7 +16,8 @@ class InputType(Enum): MOUSE_BUTTON = 1 MOUSE_AXIS = 2 CONTROLLER_BUTTON = 3 - CONTROLLER_AXIS = 4 + CONTROLLER_AXIS_SINGLE = 4 + CONTROLLER_AXIS_DOUBLE = 5 class InputEnum(Enum): @@ -39,19 +40,14 @@ def _generate_next_value_(name, *_): return name -class ControllerAxes(StrEnum): - LEFT_STICK_X = "leftx" - LEFT_STICK_POSITIVE_X = "leftxpositive" - LEFT_STICK_NEGATIVE_X = "leftxnegative" - LEFT_STICK_Y = "lefty" - LEFT_STICK_POSITIVE_Y = "leftypositive" - LEFT_STICK_NEGATIVE_Y = "leftynegative" - RIGHT_STICK_X = "rightx" - RIGHT_STICK_POSITIVE_X = "rightxpositive" - RIGHT_STICK_NEGATIVE_X = "rightxnegative" - RIGHT_STICK_Y = "righty" - RIGHT_STICK_POSITIVE_Y = "rightypositive" - RIGHT_STICK_NEGATIVE_Y = "rightynegative" +class ControllerSticks(StrEnum): + LEFT_STICK_X = "leftstickx" + LEFT_STICK_Y = "leftsticky" + RIGHT_STICK_X = "rightstickx" + RIGHT_STICK_Y = "rightsticky" + + +class ControllerTriggers(StrEnum): LEFT_TRIGGER = "lefttrigger" RIGHT_TRIGGER = "righttrigger" @@ -366,7 +362,8 @@ class MouseButtons(InputEnum): MouseButtons: InputType.MOUSE_BUTTON, MouseAxes: InputType.MOUSE_AXIS, ControllerButtons: InputType.CONTROLLER_BUTTON, - ControllerAxes: InputType.CONTROLLER_AXIS, + ControllerTriggers: InputType.CONTROLLER_AXIS_SINGLE, + ControllerSticks: InputType.CONTROLLER_AXIS_DOUBLE, } INPUT_TYPE_TO_CLASS: dict[InputType, type[InputEnum]] = { @@ -374,7 +371,8 @@ class MouseButtons(InputEnum): InputType.MOUSE_BUTTON: MouseButtons, InputType.MOUSE_AXIS: MouseAxes, InputType.CONTROLLER_BUTTON: ControllerButtons, - InputType.CONTROLLER_AXIS: ControllerAxes, + InputType.CONTROLLER_AXIS_SINGLE: ControllerTriggers, + InputType.CONTROLLER_AXIS_DOUBLE: ControllerSticks, } diff --git a/arcade/future/input/manager.py b/arcade/input/manager.py similarity index 60% rename from arcade/future/input/manager.py rename to arcade/input/manager.py index 4d996b4aa7..ba25a95908 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 @@ -65,6 +65,29 @@ class InputDevice(Enum): class InputManager: + """ + The InputManager is responsible for managing input for a given device, this can be the keyboard/mouse or a controller. + + In general, you can share one InputManager for one controller and the keyboard/mouse, there are even utilities to handle + automatically switching between them as the active device. However if you intend to have multiple controllers connected + to your game, each controller should have it's own InputManager. + + For runnable examples of how to use this, please see Arcdade's + :ref:`built-in InputManager examples `. + + Args: + controller: + Either a Pyglet Controller object or None if you only want to use the keyboard/mouse. + allow_keyboard: + Whether to allow keyboard input, defaults to True, can be changed safely after initialization. + action_handlers: + Either one or a collection of functions that will be called for every action that is triggered. + :py:meth:`InputManager.subscribe_to_action` may be preferred to subscribe to individual actions instead. + controller_deadzone: + The deadzone for controller input, defaults to 0.1. If changes to axis values are within this + range from the underlying hardware, they will be ignored. + """ + def __init__( self, controller: Controller | None = None, @@ -120,13 +143,29 @@ def __init__( self.controller.push_handlers( self.on_button_press, self.on_button_release, - self.on_stick_motion, + self.on_leftstick_motion, + self.on_rightstick_motion, self.on_dpad_motion, - self.on_trigger_motion, + self.on_lefttrigger_motion, + self.on_righttrigger_motion, ) self.active_device = InputDevice.CONTROLLER def serialize(self) -> RawInputManager: + """ + Serializes the current state of the InputManager into a RawInputManager dictionary which can easily be saved to JSON. + + This does not include current values of inputs, but rather the structure of the InputManager. Including: + - Actions: All registered actions + - Axes: All registered axis inputs + - Current Mappings: All current mappings of underlying inputs to actions/axis + + The output dictionary of this function can be passed to :meth:`arcade.InputManager.parse` to create a new InputManager + from a serialized one. + + Returns: + A RawInputManager dictionary representing the current state of the InputManager. + """ raw_actions = [] for action in self.actions.values(): raw_actions.append(serialize_action(action)) @@ -141,6 +180,13 @@ def serialize(self) -> RawInputManager: @classmethod def parse(cls, raw: RawInputManager) -> InputManager: + """ + Create a new InputManager from a serialized dictionary. Can be used in combination with the :meth:`arcade.InputManager.serialize` to + save/load input configurations. + + Returns: + A new InputManager with the state defined in the provided RawInputManager dictionary. + """ final = cls(controller_deadzone=raw["controller_deadzone"]) for raw_action in raw["actions"]: @@ -169,6 +215,16 @@ def parse(cls, raw: RawInputManager) -> InputManager: return final def copy_existing(self, existing: InputManager): + """ + Copies the state of another InputManager into this one. Note that this does not create a new InputManager, but modifies the one on which it is called. + + This does not copy current input values, just the structure/mappings of the InputManager. + + If you want to create a new InputManager from an existing one, use :meth:`arcade.InputManager.from_existing` + + Args: + existing: The InputManager to copy from. + """ self.actions = existing.actions.copy() self.keys_to_actions = existing.keys_to_actions.copy() self.controller_buttons_to_actions = existing.controller_buttons_to_actions.copy() @@ -185,41 +241,67 @@ def from_existing( existing: InputManager, controller: pyglet.input.Controller | None = None, ) -> InputManager: + """ + Create a new InputManager from an existing one. This does not copy current input values, just the structure/mappings of the InputManager. + + If you want to create a new InputManager from a serialized dictionary, use :meth:`arcade.InputManager.parse` + + Args: + existing: The InputManager to copy from. + controller: The controller to use for this InputManager. If None, no Controller will be bound. + + Returns: + A new InputManager with the state defined in the provided existing InputManager. + """ new = cls( allow_keyboard=existing.allow_keyboard, controller=controller, controller_deadzone=existing.controller_deadzone, ) new.copy_existing(existing) - new.actions = existing.actions.copy() return new def bind_controller(self, controller: Controller): + """ + Bind a controller to this InputManager. If a controller is already bound, it will be unbound first. + + Upon binding a controller it will be set as the active device. + + Args: + controller: The controller to bind to this InputManager. + """ if self.controller: - self.controller.remove_handlers() + self.unbind_controller() self.controller = controller self.controller.open() self.controller.push_handlers( self.on_button_press, self.on_button_release, - self.on_stick_motion, + self.on_leftstick_motion, + self.on_rightstick_motion, self.on_dpad_motion, - self.on_trigger_motion, + self.on_lefttrigger_motion, + self.on_righttrigger_motion, ) self.active_device = InputDevice.CONTROLLER def unbind_controller(self): + """ + Unbind the currently bound controller from this InputManager. + """ if not self.controller: return self.controller.remove_handlers( self.on_button_press, self.on_button_release, - self.on_stick_motion, + self.on_leftstick_motion, + self.on_rightstick_motion, self.on_dpad_motion, - self.on_trigger_motion, + self.on_lefttrigger_motion, + self.on_righttrigger_motion, ) self.controller.close() self.controller = None @@ -229,6 +311,11 @@ def unbind_controller(self): @property def allow_keyboard(self): + """ + Whether the keyboard is allowed for this InputManager. This also effects mouse input. + + If this is false then all keyboard and mouse input will be ignored regardless of if there are mappings for them. + """ return self._allow_keyboard @allow_keyboard.setter @@ -251,14 +338,27 @@ def new_action( self, name: str, ): + """ + Create a new action with the given name. If an action with the same name already exists, this will do nothing. + + Args: + name: The name of the action to create. + """ + if name in self.actions: + return + action = Action(name) self.actions[name] = action def remove_action(self, name: str): - self.clear_action_input(name) - + """ + Remove the specified action. If the action does not exist, this will do nothing. All registered inputs for the action will be removed. + Args: + name: The name of the action to remove. + """ to_remove = self.actions.get(name, None) if to_remove: + self.clear_action_input(name) del self.actions[name] def add_action_input( @@ -269,6 +369,16 @@ def add_action_input( mod_ctrl: bool = False, mod_alt: bool = False, ): + """ + Register an input to an action. + + Args: + action: The action to register the input for + input: The input to register + mod_shift: The input will only trigger if the Shift keyboard key is also held + mod_ctrl: The input will only trigger if the Control keyboard key is also held + mod_alt: The input will only trigger if the Alt keyboard key is also held + """ mapping = ActionMapping(input, mod_shift, mod_ctrl, mod_alt) self.actions[action].add_mapping(mapping) @@ -285,12 +395,21 @@ def add_action_input( if input.value not in self.mouse_buttons_to_actions: self.mouse_buttons_to_actions[input.value] = set() self.mouse_buttons_to_actions[input.value].add(action) - elif mapping._input_type == InputType.CONTROLLER_AXIS: + elif ( + mapping._input_type == InputType.CONTROLLER_AXIS_SINGLE + or mapping._input_type == InputType.CONTROLLER_AXIS_DOUBLE + ): if input.value not in self.controller_axes_to_actions: self.controller_axes_to_actions[input.value] = set() self.controller_axes_to_actions[input.value].add(action) def clear_action_input(self, action: str): + """ + Clears all registered inputs for a given action. + + Args: + action: The name of the action to clear. + """ self.actions[action]._mappings.clear() _clean_dicts( action, @@ -301,14 +420,38 @@ def clear_action_input(self, action: str): ) def register_action_handler(self, handler: OneOrIterableOf[Callable[[str, ActionState], Any]]): + """ + Register a callback function for all actions from this InputManager. + + The callback function should accept a String with the name of the Action, and an ActionState. + This callback will receive all action events, regardless of if :meth:`arcade.InputManager.subscribe_to_action` has been used as well. + + Args: + handler: The callback function to register. + """ grow_sequence(self.on_action_listeners, handler, append_if=callable) def subscribe_to_action(self, name: str, subscriber: Callable[[ActionState], Any]): + """ + Subscribe a callback to given action. + + The callback function should accept an ActionState parameter. + + Args: + name: The name of the action to subscribe to. + subscriber: The callback function which will be called. + """ old = self.action_subscribers.get(name, set()) old.add(subscriber) self.action_subscribers[name] = old def new_axis(self, name: str): + """ + Create a new axis with the given name. + + Args: + name: The name of the axis + """ if name in self.axes: raise AttributeError(f"Tried to create Axis with duplicate name: {name}") @@ -317,6 +460,14 @@ def new_axis(self, name: str): self.axes_state[name] = 0.0 def add_axis_input(self, axis: str, input: InputEnum, scale: float = 1.0): + """ + Register an input to an axis. + + Args: + axis: The axis to register the input for + input: The input to register + scale: The value to multiply the input by, for non analog inputs the scale value is used literally. + """ mapping = AxisMapping(input, scale) self.axes[axis].add_mapping(mapping) @@ -328,26 +479,69 @@ def add_axis_input(self, axis: str, input: InputEnum, scale: float = 1.0): if input.value not in self.controller_buttons_to_axes: self.controller_buttons_to_axes[input.value] = set() self.controller_buttons_to_axes[input.value].add(axis) - elif mapping._input_type == InputType.CONTROLLER_AXIS: + elif ( + mapping._input_type == InputType.CONTROLLER_AXIS_SINGLE + or mapping._input_type == InputType.CONTROLLER_AXIS_DOUBLE + ): if input.value not in self.controller_analog_to_axes: self.controller_analog_to_axes[input.value] = set() self.controller_analog_to_axes[input.value].add(axis) + def add_axis_input_combined( + self, axis: str, positive: InputEnum, negative: InputEnum, scale: float = 1.0 + ): + """ + This is a helper function that wraps :meth:`arcade.InputManager.add_axis_input` to add two inputs + with a positive and negative scale. + + For example, you can do: + add_axis_input_combined("MoveHorizontal", arcade.Keys.RIGHT, arcade.Keys.LEFT, 1.0) + instead of: + add_axis_input("MoveHorizontal", arcade.Keys.RIGHT, 1.0) + add_axis_input("MoveHorizontal", arcade.Keys.LEFT, -1.0) + + Args: + axis: The axis name to register the input for + positive: The input that will correspond to the positive side of the axis + negative: The input that will correspond to the negative side of the axis + scale: The value to multiply the input by, for non analog inputs the scale value is used literally. + """ + self.add_axis_input(axis, positive, scale) + self.add_axis_input(axis, negative, -scale) + def clear_axis_input(self, axis: str): + """ + Clear all registered inputs for the given axis. + + Args: + axis: The axis to clear + """ self.axes[axis]._mappings.clear() _clean_dicts( axis, self.keys_to_axes, self.controller_analog_to_axes, self.controller_buttons_to_axes ) - def remove_axis(self, name: str): - self.clear_axis_input(name) + def remove_axis(self, axis: str): + """ + Completely remove an axis from the manager. This will also clear the registered inputs for that axis. - to_remove = self.axes.get(name, None) + Args: + axis: The axis to remove + """ + self.clear_axis_input(axis) + + to_remove = self.axes.get(axis, None) if to_remove: - del self.axes[name] - del self.axes_state[name] + del self.axes[axis] + del self.axes_state[axis] def axis(self, name: str) -> float: + """ + Get the current value of a given axis. + + Args: + name: The axis to get the value of + """ return self.axes_state[name] def dispatch_action(self, action: str, state: ActionState): @@ -439,77 +633,63 @@ def on_button_release(self, controller: Controller, button_name: str): for action_name in buttons_to_actions: self.dispatch_action(action_name, ActionState.RELEASED) - def on_stick_motion(self, controller: Controller, name: str, motion: pyglet.math.Vec2): + def handle_stick_motion(self, stick: str, motion: pyglet.math.Vec2): x_value, y_value = motion.x, motion.y - if name == "leftx": - self.window.dispatch_event( - "on_stick_motion", - self.controller, - "leftxpositive" if x_value > 0 else "leftxnegative", - x_value, - y_value, - ) - elif name == "lefty": - self.window.dispatch_event( - "on_stick_motion", - self.controller, - "leftypositive" if y_value > 0 else "leftynegative", - x_value, - y_value, - ) - elif name == "rightx": - self.window.dispatch_event( - "on_stick_motion", - self.controller, - "rightxpositive" if x_value > 0 else "rightxpositive", - x_value, - y_value, - ) - elif name == "righty": - self.window.dispatch_event( - "on_stick_motion", - self.controller, - "rightypositive" if y_value > 0 else "rightynegative", - x_value, - y_value, - ) - axes_to_actions = self.controller_axes_to_actions.get(name, set()) + has_x = x_value > self.controller_deadzone or x_value < -self.controller_deadzone + has_y = y_value > self.controller_deadzone or y_value < -self.controller_deadzone - if ( - x_value > self.controller_deadzone - or x_value < -self.controller_deadzone - or y_value > self.controller_deadzone - or y_value < -self.controller_deadzone - ): - self.active_device = InputDevice.CONTROLLER + if has_x: + axes_to_actions = self.controller_axes_to_actions.get(f"{stick}stickx", set()) + for action_name in axes_to_actions: + self.dispatch_action(action_name, ActionState.PRESSED) + if has_y: + axes_to_actions = self.controller_axes_to_actions.get(f"{stick}sticky", set()) for action_name in axes_to_actions: self.dispatch_action(action_name, ActionState.PRESSED) - return + def on_leftstick_motion(self, controller: Controller, motion: pyglet.math.Vec2): + self.handle_stick_motion("left", motion) - for action_name in axes_to_actions: - self.dispatch_action(action_name, ActionState.RELEASED) + def on_rightstick_motion(self, controller: Controller, motion: pyglet.math.Vec2): + self.handle_stick_motion("right", motion) def on_dpad_motion(self, controller: Controller, motion: pyglet.math.Vec2): self.active_device = InputDevice.CONTROLLER - def on_trigger_motion(self, controller: Controller, trigger_name: str, value: float): + def handle_trigger_motion(self, trigger_name: str, value: float): self.active_device = InputDevice.CONTROLLER + def on_lefttrigger_motion(self, controller: Controller, value: float): + self.handle_trigger_motion("left", value) + + def on_righttrigger_motion(self, controller: Controller, value: float): + self.handle_trigger_motion("right", value) + def update(self): + """ + Updates axis inputs, all axis values will remain unchanged unless this function is called, usually during on_update. + """ for name in self.axes.keys(): self.axes_state[name] = 0 if self.controller and self.active_device == InputDevice.CONTROLLER: for name, axis in self.axes.items(): for mapping in tuple(axis._mappings): - if mapping._input_type == InputType.CONTROLLER_AXIS: + if mapping._input_type == InputType.CONTROLLER_AXIS_SINGLE: scale = mapping._scale input = getattr(self.controller, mapping._input.value) # type: ignore if input > self.controller_deadzone or input < -self.controller_deadzone: self.axes_state[name] = input * scale + if mapping._input_type == InputType.CONTROLLER_AXIS_DOUBLE: + scale = mapping._scale + direction = mapping._input.value[-1].lower() + input = getattr( + getattr(self.controller, mapping._input.value[:-1]), direction + ) + if input > self.controller_deadzone or input < -self.controller_deadzone: + self.axes_state[name] = input * scale if mapping._input_type == InputType.CONTROLLER_BUTTON: if getattr(self.controller, mapping._input.value): # type: ignore self.axes_state[name] = mapping._scale 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/management/__init__.py b/arcade/management/__init__.py deleted file mode 100644 index 1b098fcb3c..0000000000 --- a/arcade/management/__init__.py +++ /dev/null @@ -1,48 +0,0 @@ -from pathlib import Path -import shutil -import sys -import pyglet -import arcade -import PIL - -EXAMPLE_PATH = Path(__file__).parent.parent.resolve() / "examples" - - -def execute_from_command_line(): - if len(sys.argv) == 1: - show_info() - return - - command = sys.argv[1] - if command == "startproject": - start_project(sys.argv[2]) - else: - print("Unsupported command") - - -def show_info(): - window = arcade.Window() - version_str = f"Arcade {arcade.__version__}" - print() - print(version_str) - print("-" * len(version_str)) - print("vendor:", window.ctx.info.VENDOR) - print("renderer:", window.ctx.info.RENDERER) - # 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) - print("PIL version:", PIL.__version__) - - -def start_project(path_str: str): - path = Path(path_str) - if path.exists(): - print("File already exists") - - shutil.copy(EXAMPLE_PATH / "starting_template.py", path) - print("Created", path) 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..7fae8a926e 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: """ @@ -709,6 +702,40 @@ def apply_force(self, sprite: Sprite, force: tuple[float, float]): ) physics_object.body.apply_force_at_local_point(force, (0, 0)) + def apply_torque(self, sprite: Sprite, torque: float): + """ + Apply torque to a Sprite. + + Args: + sprite: + The sprite to apply the force to. + torque: + The torque to apply to the sprite. + """ + physics_object = self.sprites[sprite] + if physics_object.body is None: + raise PymunkException( + "Tried to apply a torque, but this physics object has no 'body' set." + ) + physics_object.body.torque = torque + + def set_angular_velocity(self, sprite: Sprite, velocity: float): + """ + Set velocity to a Sprite. + + Args: + sprite: + The sprite to set the angular velocity of. + velocity: + The velocity to set. + """ + physics_object = self.sprites[sprite] + if physics_object.body is None: + raise PymunkException( + "Tried to set velocity, but this physics object has no 'body' set." + ) + physics_object.body.angular_velocity = velocity + def set_horizontal_velocity(self, sprite: Sprite, velocity: float) -> None: """ Set a sprite's velocity. @@ -783,7 +810,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 +840,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/assets/images/spritesheets/hex_tilesheet.png b/arcade/resources/assets/images/spritesheets/hex_tilesheet.png new file mode 100644 index 0000000000..28cf56785c Binary files /dev/null and b/arcade/resources/assets/images/spritesheets/hex_tilesheet.png differ diff --git a/arcade/resources/assets/tiled_maps/hex_map.tmj b/arcade/resources/assets/tiled_maps/hex_map.tmj new file mode 100644 index 0000000000..0cec4af117 --- /dev/null +++ b/arcade/resources/assets/tiled_maps/hex_map.tmj @@ -0,0 +1,85 @@ +{ "compressionlevel":-1, + "height":20, + "hexsidelength":70, + "infinite":false, + "layers":[ + { + "data":[3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, + 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, + 3, 3, 3, 2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 2, 2, 2, 3, 3, 3, + 3, 3, 3, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 5, 5, 5, 2, 2, 2, 3, 3, 3, + 3, 3, 3, 2, 2, 2, 2, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 2, 2, 2, 3, 3, + 3, 3, 3, 2, 2, 2, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 4, 5, 5, 2, 2, 2, 3, 3, 3, + 3, 3, 3, 2, 2, 2, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 4, 4, 5, 5, 2, 2, 2, 3, 3, + 3, 3, 3, 2, 2, 2, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 1, 4, 4, 5, 2, 2, 2, 3, 3, 3, + 3, 3, 3, 2, 2, 2, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 4, 4, 5, 5, 2, 2, 2, 3, 3, + 3, 3, 3, 2, 2, 2, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 4, 4, 5, 5, 5, 2, 2, 2, 3, 3, + 3, 3, 3, 2, 2, 2, 5, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 2, 2, 2, 3, 3, + 3, 3, 2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 5, 5, 5, 5, 2, 2, 2, 2, 3, 3, + 3, 3, 3, 2, 2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 2, 2, 2, 2, 3, 3, + 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 5, 5, 2, 2, 2, 2, 2, 3, 3, 3, + 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, + 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3], + "height":20, + "id":1, + "name":"TILE", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":30, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 0, 0, 0, 0, 12, 12, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 7, 0, 0, 9, 12, 12, 12, 0, 16, 0, 6, 14, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 10, 10, 10, 0, 0, 0, 9, 0, 0, 33, 0, 0, 0, 0, 19, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 10, 0, 0, 0, 0, 0, 21, 0, 0, 11, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 10, 10, 7, 0, 0, 0, 0, 34, 34, 0, 0, 0, 29, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 34, 34, 34, 34, 34, 34, 34, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 34, 34, 34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":20, + "id":2, + "name":"POI", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":30, + "x":0, + "y":0 + }], + "nextlayerid":3, + "nextobjectid":1, + "orientation":"hexagonal", + "renderorder":"right-down", + "staggeraxis":"y", + "staggerindex":"odd", + "tiledversion":"1.11.2", + "tileheight":140, + "tilesets":[ + { + "firstgid":1, + "source":"hex_tilesheet.tsj" + }], + "tilewidth":120, + "type":"map", + "version":"1.11", + "width":30 +} \ No newline at end of file diff --git a/arcade/resources/assets/tiled_maps/hex_tilesheet.tsj b/arcade/resources/assets/tiled_maps/hex_tilesheet.tsj new file mode 100644 index 0000000000..0351002747 --- /dev/null +++ b/arcade/resources/assets/tiled_maps/hex_tilesheet.tsj @@ -0,0 +1,91 @@ +{ "columns":5, + "image":"..\/images\/spritesheets\/hex_tilesheet.png", + "imageheight":994, + "imagewidth":610, + "margin":1, + "name":"tilesheet", + "spacing":2, + "tilecount":35, + "tiledversion":"1.11.2", + "tileheight":140, + "tiles":[ + { + "id":0, + "type":"cement" + }, + { + "id":1, + "type":"desert" + }, + { + "id":2, + "type":"wasteland" + }, + { + "id":3, + "type":"grass" + }, + { + "id":4, + "type":"dirt" + }, + { + "id":5, + "type":"yellow_warehouse" + }, + { + "id":6, + "type":"mine" + }, + { + "id":8, + "type":"barracks" + }, + { + "id":9, + "type":"mountain" + }, + { + "id":10, + "type":"gas station" + }, + { + "id":11, + "type":"farm" + }, + { + "id":13, + "type":"house_1" + }, + { + "id":15, + "type":"corner store" + }, + { + "id":18, + "type":"house_2" + }, + { + "id":20, + "type":"camp" + }, + { + "id":23, + "type":"house_3" + }, + { + "id":28, + "type":"house_4" + }, + { + "id":32, + "type":"construction" + }, + { + "id":33, + "type":"forest" + }], + "tilewidth":120, + "type":"tileset", + "version":"1.10" +} \ No newline at end of file diff --git a/arcade/resources/assets/tiled_maps/test_objects.json b/arcade/resources/assets/tiled_maps/test_objects.json index b64ec9db0c..fdfb4ed3db 100644 --- a/arcade/resources/assets/tiled_maps/test_objects.json +++ b/arcade/resources/assets/tiled_maps/test_objects.json @@ -171,7 +171,7 @@ "name":"crate1", "rotation":-45, "type":"crate", - "visible":true, + "visible":false, "width":400, "x":1500, "y":1800 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/tilemap/tilemap.py b/arcade/tilemap/tilemap.py index 3828c6c9c8..f1415ae86c 100644 --- a/arcade/tilemap/tilemap.py +++ b/arcade/tilemap/tilemap.py @@ -29,6 +29,7 @@ TextureAnimationSprite, TextureKeyframe, get_window, + hexagon, ) from arcade.hitbox import HitBoxAlgorithm, RotatableHitBox from arcade.types import RGBA255 @@ -153,6 +154,10 @@ class TileMap: SpriteLists will be created lazily. texture_cache_manager: The texture cache manager to use for loading textures. + hex_layout: + The hex layout to use for the map. If not supplied, the map will be + treated as a square map. If supplied, the map will be treated as a hexagonal map. + The ``layer_options`` parameter can be used to specify per layer arguments. The available options for this are: @@ -234,6 +239,7 @@ def __init__( texture_atlas: TextureAtlasBase | None = None, lazy: bool = False, texture_cache_manager: arcade.TextureCacheManager | None = None, + hex_layout: hexagon.Layout | None = None, ) -> None: if not map_file and not tiled_map: raise AttributeError( @@ -261,6 +267,8 @@ def __init__( except RuntimeError: pass + self.hex_layout = hex_layout + self._lazy = lazy self.texture_cache_manager = texture_cache_manager or arcade.texture.default_texture_cache @@ -771,13 +779,79 @@ def _process_tile_layer( atlas=texture_atlas, lazy=self._lazy, ) + + if self.hex_layout is None: + map_array = layer.data + if TYPE_CHECKING: + # Can never be None because we already detect and reject infinite maps + assert map_array + + # Loop through the layer and add in the list + for row_index, row in enumerate(map_array): + for column_index, item in enumerate(row): + # Check for an empty tile + if item == 0: + continue + + 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})." + ) + + my_sprite = self._create_sprite_from_tile( + tile, + scaling=scaling, + hit_box_algorithm=hit_box_algorithm, + custom_class=custom_class, + custom_class_args=custom_class_args, + ) + + if my_sprite is None: + print( + f"Warning: Could not create sprite number {item} " + f"in layer '{layer.name}' {tile.image}" + ) + else: + my_sprite.center_x = ( + column_index * (self.tiled_map.tile_size[0] * scaling) + + my_sprite.width / 2 + ) + offset[0] + my_sprite.center_y = ( + (self.tiled_map.map_size.height - row_index - 1) + * (self.tiled_map.tile_size[1] * scaling) + + my_sprite.height / 2 + ) + offset[1] + + # Tint + if layer.tint_color: + my_sprite.color = ArcadeColor.from_iterable(layer.tint_color) + + # Opacity + opacity = layer.opacity + if opacity: + my_sprite.alpha = int(opacity * 255) + + sprite_list.visible = layer.visible + sprite_list.append(my_sprite) + + if layer.properties: + sprite_list.properties = layer.properties + + return sprite_list + + # Hexagonal map map_array = layer.data if TYPE_CHECKING: # Can never be None because we already detect and reject infinite maps assert map_array + # FIXME: get tile size from tileset + # Loop through the layer and add in the list - for row_index, row in enumerate(map_array): + for row_index, row in enumerate(reversed(map_array)): for column_index, item in enumerate(row): # Check for an empty tile if item == 0: @@ -785,11 +859,12 @@ def _process_tile_layer( tile = self._get_tile_by_gid(item) if tile is None: - raise ValueError( + msg = ( 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})." ) + raise ValueError(msg) my_sprite = self._create_sprite_from_tile( tile, @@ -805,14 +880,17 @@ def _process_tile_layer( f"in layer '{layer.name}' {tile.image}" ) else: - my_sprite.center_x = ( - column_index * (self.tiled_map.tile_size[0] * scaling) + my_sprite.width / 2 - ) + offset[0] - my_sprite.center_y = ( - (self.tiled_map.map_size.height - row_index - 1) - * (self.tiled_map.tile_size[1] * scaling) - + my_sprite.height / 2 - ) + offset[1] + # FIXME: handle map scaling + # Convert from odd-r offset to cube coordinates + offset_coord = hexagon.OffsetCoord(column_index, row_index) + hex_ = offset_coord.to_cube("even-r") + + # Convert hex position to pixel position + pixel_pos = hex_.to_pixel(self.hex_layout) + # FIXME: why is the y position negative? + pixel_pos = hexagon.Vec2(pixel_pos.x, pixel_pos.y) + my_sprite.center_x = pixel_pos.x + my_sprite.center_y = pixel_pos.y # Tint if layer.tint_color: @@ -896,6 +974,7 @@ def _process_object_layer( my_sprite.position = (x + rotated_center_x, y + rotated_center_y) my_sprite.angle = angle_degrees + my_sprite.visible = cur_object.visible if layer.tint_color: my_sprite.color = ArcadeColor.from_iterable(layer.tint_color) @@ -1032,6 +1111,7 @@ def load_tilemap( offset: Vec2 = Vec2(0, 0), texture_atlas: DefaultTextureAtlas | None = None, lazy: bool = False, + hex_layout: hexagon.Layout | None = None, ) -> TileMap: """ Given a .json map file, loads in and returns a `TileMap` object. @@ -1065,6 +1145,9 @@ def load_tilemap( If not supplied the global default atlas will be used. lazy: SpriteLists will be created lazily. + hex_layout: + The hex layout to use for the map. If not supplied, the map will be + treated as a square map. If supplied, the map will be treated as a hexagonal map. """ return TileMap( map_file=map_file, @@ -1075,4 +1158,5 @@ def load_tilemap( offset=offset, texture_atlas=texture_atlas, lazy=lazy, + hex_layout=hex_layout, ) 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/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 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/conf.py b/doc/conf.py index 21558432d2..d8dd628153 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -212,7 +212,7 @@ def run_util(filename, run_name="__main__", init_globals=None): # General information about the project. project = "Python Arcade Library" -copyright = "2025, Paul Vincent Craven" +copyright = "2026, Paul Vincent Craven" author = "Paul Vincent Craven" # The version info for the project you're documenting, acts as replacement for 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/doc/index.rst b/doc/index.rst index bac6112449..3754f22ddd 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -121,7 +121,7 @@ help improve Arcade. :caption: Manual programming_guide/sprites/index - programming_guide/keyboard + programming_guide/input/index programming_guide/sound programming_guide/textures programming_guide/event_loop diff --git a/doc/programming_guide/input/advanced_input.rst b/doc/programming_guide/input/advanced_input.rst new file mode 100644 index 0000000000..eecd149e46 --- /dev/null +++ b/doc/programming_guide/input/advanced_input.rst @@ -0,0 +1,138 @@ +.. _pg_advanced_input: + +Advanced Input +============== + +Advanced Input in Arcade is handled through the use of an :class:`arcade.InputManager` + +Key Concepts +------------ + +Actions +^^^^^^^ + +Actions are essentially named actions which can have inputs mapped to them. For example, you might have a ``Jump`` action +with the Spacebar and the bottom controller face button mapped to it. You can then subscribe a callback to this action, which +will be hit whenever the action is triggered, regardless of the underlying input source. + +Axis Inputs +^^^^^^^^^^^ + +Axis Inputs are named inputs similar to actions, but are used for generally analog inputs or more "constant" inputs. These are +intended to be polled for their state, rather than being notified via a callback. Generally these inputs would be used to map onto +analog devices such as thumbsticks, or triggers on controllers, however as we will demonstrate later you can also use buttons or keyboard +input to control these. These inputs generally make it simple to handle something like movement with either keyboard input or a controller. + +A Small Example +--------------- + +Create an InputManager +^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + input_manager = arcade.InputManager() + + input_manager.new_action("Jump") + input_manager.add_action_input("Jump", arcade.Keys.SPACE) + input_manager.add_action_input("Jump", arcade.ControllerButtons.BOTTOM_FACE) + + input_manager.new_axis("Move") + input_manager.add_axis_input("Move", arcade.Keys.LEFT, scale=-1.0) + input_manager.add_axis_input("Move", arcade.Keys.RIGHT, scale=1.0) + input_manager.add_axis_input("Move", arcade.ControllerAxes.LEFT_STICK_X, scale=1.0) + +The above block of code demonstrates how you would create an :class:`arcade.InputManager` and create an action for jumping, and +an axis for moving. You'll notice for the movement axis, we assign the left and right keyboard keys with a different scale, but for the +controller input, we only define a positive scale value. This is because a controller feeds us analog input that might range anywhere from +-1.0 to 1.0. When the input is a controller axis, Arcade will multiply the input against the specified scale value, so in this example +using a scale of 1.0 means we get the exact value from the controller. + +However when we assign keyboard keys, or buttons of any kind to an axis, all we know from the underlying input is wether that key/button is +pressed or not, but there is no value to multiply against a scale. In the case of a key/button being added to an axis input, Arcade will +use the scale specified as the value for the axis. + +Handling the Jump Action +^^^^^^^^^^^^^^^^^^^^^^^^ + +For handling actions from our InputManager, we have two options: + +- The global :meth:`arcade.Window.on_action` method which can be added to any :class:`arcade.Window`, :class:`arcade.View`, or :class:`arcade.Section` and will receive notification of all actions. +- A callback function registered to our ``Jump`` action. + +The global :meth:`arcade.Window.on_action` approach: + +.. code-block:: python + + def on_action(self, action: str, state: arcade.ActionState): + if (action == "Jump"): + do_player_jump() + +.. note:: + + If you want to have the ``on_action`` function be on a class other than the Window, View, or Section. You can use :meth:`arcade.InputManager.register_action_handler` to + explicitly register the function to the InputManager. However if the function is on the Window, View, or Section it will receive the actions automatically. + +The callback function approach: + +.. code-block:: python + + def handle_jump(state: arcade.ActionState): + do_player_jump() + + input_manager.subscribe_to_action("Jump", handle_jump) + +Handling the Move Axis +^^^^^^^^^^^^^^^^^^^^^^ + +For handling axis inputs, it is important that we make sure the input manager is being updated. You will need to manually do this as part of your :meth:`arcade.Window.on_update` +function, or via somewhere else that is called every update. + +.. code-block:: python + + input_manager.update() + +When the InputManager is updated, it will update the values of every Axis input within it. You can then poll it simply by using the :meth:`arcade.InputManager.axis` function. +Below is an example of getting the axis value and one way you might use it to move a player. + +.. code-block:: + + player.change_x = input_manager.axis("Move") * PLAYER_MOVEMENT_SPEED + +Active Device Switching +----------------------- + +One question you might have had, is that if we are handling inputs on the "Move" axis for both the keyboard and a controller, which devices input will be used? +The answer depends on a couple different factors. + +It is possible to have never bound a controller to the InputManager, in which case the controller inputs will be ignored. If there is no controller bound, and the ``allow_keyboard`` option +of the InputManager has been set to false, then all Axis values will just return 0, and no actions will ever be triggered. + +However in the scenario that ``allow_keyboard`` is true, and we have a controller bound, the InputManager has somewhat intelligent active device switching which will prioritize the last device that was used. +For example if the controller is currently active, and the user pressed a key on their keyboard, Arcade will switch the active device to the keyboard, so the controller input will be ignored for axis inputs. +If the player then presses a button on their controller, or moves a stick out of the deadzone, then it will switch back to the controller as the active device and ignore keyboard inputs. + +Controller Binding and Multiple Players +--------------------------------------- + +One thing we haven't covered yet, is how the InputManager actually gets a controller bound to it. To keep the InputManager flexible, it does not do this automatically on it's own, and it is up to you to provide +a :class:`pyglet.input.Controller` object to it. See the full examples below for more code on how to create a new Controller. + +Once you have a controller object, you can either bind it to an InputManager during creation by passing it to the ``controller`` argument. Or you can use the :meth:`arcade.InputManager.bind_controller` function after +the InputManager has been created. If you want to unbind the controller, you can use :meth:`arcade.InputManager.unbind_controller`. + +If your game is intended to support multiple players via multiple controllers. The general idea is that you would have one InputManager per controller/player. A common approach to this would be to construct one InputManager +with all of your desired actions/axis inputs, and then create a new one using the :meth:`arcade.InputManager.from_existing` function, as shown below. This function will copy all of the actions/axis from the specified +InputManager into the new one, but ignore the controller binding, allowing you to bind a different controller to the newly created manager. + +.. code-block:: python + + # Not real code, see Pyglet input docs for more on Controller management + controller_one = Controller() + controller_two = Controller() + + input_manager_one = arcade.InputManager(controller_one) + input_manager_one.new_action("Jump") + input_manager_one.add_action_input("Jump", arcade.Keys.SPACE) + + input_manager_two = arcade.InputManager.from_existing(input_manager_one, controller_two) \ No newline at end of file diff --git a/doc/programming_guide/input/index.rst b/doc/programming_guide/input/index.rst new file mode 100644 index 0000000000..47f33cf943 --- /dev/null +++ b/doc/programming_guide/input/index.rst @@ -0,0 +1,25 @@ +.. _pg_input: + +Input Handling +============== + +Arcade has a number of different options for handling input, but they fall into two main categories: + +:ref:`The Simple Way `: This is what you will generally see used in most of Arcade's example code. This way +of working is mostly directly talking with the underlying windowing library, and can work fine for keyboard/mouse +but starts to require a lot of manual work when you want more complex systems, especially when making extensive use +of controllers or mixing different types of input devices(like supporting both keyboard/mouse and controllers). + +:ref:`The Advanced Way `: This is where the advanced input system comes in. The advanced input system provides a very rigidly defined but much more +capable interface for handling input. This system allows defining custom actions, which can be linked to multiple different +input sources(for example a keypress or a button on a controller can trigger the same action). It also supports things like +joystick input from controllers, has utilities for switching between input devices, and more. While this system is more +capable, it has more boilerplate to get started with, and is less flexible than the simple one if you want to build your own +system for input on top of something. + + +.. toctree:: + :maxdepth: 1 + + simple_input + advanced_input \ No newline at end of file diff --git a/doc/programming_guide/keyboard.rst b/doc/programming_guide/input/simple_input.rst similarity index 54% rename from doc/programming_guide/keyboard.rst rename to doc/programming_guide/input/simple_input.rst index 0231b8f26d..3f711dfe36 100644 --- a/doc/programming_guide/keyboard.rst +++ b/doc/programming_guide/input/simple_input.rst @@ -1,27 +1,49 @@ -Keyboard -======== +.. _pg_simple_input: + +Simple Input +============ + +This section will cover simple input handling in Arcade, which consists of just keyboard/mouse devices. + +There are two possible approaches to this to be aware of: + +* Event Based +* Polling + +These two approaches work somewhat different, and will require different levels of code on your end to handle them. +However these approaches are not mutually exclusive, you can use both at the same time for different purposes where +one might be preferable to the other. + +Event Based +----------- -.. _keyboard_events: +With the event based approach, your game will register handlers that Arcade will call whenever an input happens. -Events ------- +For example, when you press the ``A`` button on your keyboard, Arcade would send an event to any registered handlers +for key press events. And then similarly when the key is released, Arcade will send an event to handlers registered for +key release events. -What is a keyboard event? -^^^^^^^^^^^^^^^^^^^^^^^^^ +Using this system if you want to know if a button is currently held down, it would be up to your application to track the +state of the ``A`` key, changing it when your handlers receive a call for press and release events. -Keyboard events are Arcade's representation of physical keyboard interactions. +Polling +------- -For example, if your keyboard is working correctly and you type the letter A -into the window of a running Arcade game, it will see two separate events: +In contrast to the event based approach where Arcade notifies your application of input events, polling is the opposite way +around. With polling you can ask Arcade at any point in time what the state of a given key or mouse button is. This can be +useful when you want to modify some action that is being taken based on if a certain key is currently pressed or not, but +if you rely exclusively on polling, you may not always respond immediately to input actions if you don't poll often enough. -#. a key press event with the key code for ``A`` -#. a key release event with the key code for ``A`` +Whereas with the event based approach, Arcade will trigger your handlers immediately when the event happens. -.. _keyboard_event_handlers: +Keyboard +-------- + +.. _pg_simple_input_keyboard: How do I handle keyboard events? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -You must implement key event handlers. These functions are called whenever a +You must implement key event handler functions. These functions are called whenever a key event is detected: * :meth:`arcade.Window.on_key_press` @@ -31,7 +53,7 @@ You need to implement your own versions of the above methods on your subclass of :class:`arcade.Window`. The :ref:`arcade.key ` module contains constants for specific keys. -For runnable examples, see the following: +For runnable examples, see the following, and look for the ``on_key_press`` and ``on_key_release`` functions: * :ref:`sprite_move_keyboard` * :ref:`sprite_move_keyboard_better` @@ -40,10 +62,23 @@ For runnable examples, see the following: .. note:: If you are using :class:`Views `, you can also implement key event handler methods on them. -.. _keyboard_modifiers: +How do I poll for keyboard state? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +If you need to ask Arcade what the state of a given key is, you can do so through your :class:`arcade.Window` class. + +.. code-block:: python + + import arcade + + window = arcade.get_window() + a_key_pressed = window.keyboard[arcade.keys.A] + if a_key_pressed: + print("The A key is pressed") Modifiers ---------- +^^^^^^^^^ + +.. _pg_simple_input_keyboard_modifiers: What is a modifier? ^^^^^^^^^^^^^^^^^^^ @@ -69,7 +104,7 @@ How do I use modifiers? As long as you don't need to distinguish between the left and right versions of modifiers keys, you can rely on the ``modifiers`` argument of :ref:`key event -handlers `. +handlers `. For every key event, the current state of all modifiers is passed to the handler method through the ``modifiers`` argument as a single integer. For each @@ -113,4 +148,4 @@ specific modifier keys are currently pressed! Instead, you have to use specific key codes for left and right versions from :ref:`arcade.key ` to :ref:`track press and release events -`. +`. diff --git a/doc/tutorials/compiling_with_nuitka/index.rst b/doc/tutorials/compiling_with_nuitka/index.rst index 05ad5f1679..4dd2bb526a 100644 --- a/doc/tutorials/compiling_with_nuitka/index.rst +++ b/doc/tutorials/compiling_with_nuitka/index.rst @@ -59,6 +59,12 @@ Converting that code to a standalone executable is as easy as: python -m nuitka 17_views.py --standalone --enable-plugin=numpy +.. note:: + + Experimental features of Arcade will not be included in the build + unless you explicitly include them using + ``--include-module=arcade.experimental``. + Now sit back and relax. Might as well go and grab a cup of coffee since compilation takes time, sometimes maybe up to 2 hours, depending on your machine's specs. After the process is finished, two new folders named ``17_views.py.dist`` and 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: diff --git a/doc/tutorials/hex_map/index.rst b/doc/tutorials/hex_map/index.rst new file mode 100644 index 0000000000..d28d2c1110 --- /dev/null +++ b/doc/tutorials/hex_map/index.rst @@ -0,0 +1,267 @@ +.. _hex_map_tutorial: + +Working with Hexagonal Tilemaps +=============================== + +This tutorial covers how to load and display hexagonal tilemaps in Arcade using +the `Tiled`_ map editor and Arcade's :mod:`arcade.hexagon` module. + +You don't need to understand all the math behind hexagonal grids to follow this +tutorial, but if you're building something with hexes, +`Red Blob Games' guide to hexagonal grids `_ +is an invaluable resource for understanding the coordinate systems, algorithms, and +geometry involved. + +.. _Tiled: https://www.mapeditor.org/ + +Hexagonal Coordinate Systems +----------------------------- + +Hexagonal grids use a different coordinate system than square grids. Arcade's +:class:`~arcade.hexagon.HexTile` class uses **cube coordinates** (q, r, s) where +``q + r + s = 0`` always holds true. This constraint means you only need two of the +three values to uniquely identify a hex, but keeping all three makes the math simpler. + +.. code-block:: python + + from arcade.hexagon import HexTile + + # Create a hex tile at the origin + origin = HexTile(0, 0, 0) + + # A neighbor one step in the q direction + neighbor = HexTile(1, -1, 0) + + # Distance between two hexes + dist = origin.distance_to(neighbor) # 1 + +Cube coordinates are great for math, but map editors like Tiled use **offset +coordinates** (column, row). Arcade handles the conversion automatically when +loading maps, but you can also convert manually: + +.. code-block:: python + + from arcade.hexagon import HexTile, OffsetCoord + + # Convert from offset to cube + offset = OffsetCoord(col=3, row=2) + cube = offset.to_cube("even-r") + + # Convert back + offset_again = cube.to_offset("even-r") + +The offset system must match your map's configuration. Tiled hex maps typically +use ``"even-r"`` (staggered rows, even rows shifted right). + +Hex Layout +---------- + +A :class:`~arcade.hexagon.Layout` defines how hex coordinates map to pixel positions +on screen. It contains three pieces of information: + +1. **Orientation** -- pointy-top or flat-top hexagons +2. **Size** -- the size of each hexagon in pixels +3. **Origin** -- the pixel position of hex (0, 0, 0) + +.. code-block:: python + + from pyglet.math import Vec2 + from arcade.hexagon import Layout, pointy_orientation, flat_orientation + + # Pointy-top hexagons (what Tiled uses) + layout = Layout( + orientation=pointy_orientation, + size=Vec2(40.0, 40.0), + origin=Vec2(0.0, 0.0), + ) + +.. note:: + + Tiled always uses **pointy-top** orientation for hexagonal maps. + Use ``pointy_orientation`` when loading Tiled hex maps. + +Computing the Hex Size from Tiled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``size`` in a Layout is **not** the same as the tile width/height in Tiled. +For pointy-top hexagons, the conversion is: + +.. code-block:: python + + import math + + # From your Tiled map properties + tile_width = 120 # tilewidth in the .tmj file + tile_height = 140 # tileheight in the .tmj file + + hex_size_x = tile_width / math.sqrt(3) + hex_size_y = tile_height / 2 + + layout = Layout( + orientation=pointy_orientation, + size=Vec2(hex_size_x, hex_size_y), + origin=Vec2(0, 0), + ) + +These values come from the relationship between a hexagon's size and its bounding +rectangle. See the `Red Blob Games size reference +`_ for the full +derivation. + +Loading a Hex Map from Tiled +----------------------------- + +Loading a hexagonal tilemap works just like loading a regular tilemap, with one +extra parameter: ``hex_layout``. This tells Arcade how to convert hex coordinates +to pixel positions. + +Here is a complete example: + +.. code-block:: python + + import math + from pyglet.math import Vec2 + + import arcade + from arcade import hexagon + + WINDOW_WIDTH = 1280 + WINDOW_HEIGHT = 720 + + + class GameView(arcade.View): + + def __init__(self): + super().__init__() + self.tile_map: arcade.TileMap + self.scene: arcade.Scene + + def reset(self): + # Tiled always uses pointy-top orientation + orientation = hexagon.pointy_orientation + + # Calculate hex size from the tile dimensions in your .tmj file + hex_size_x = 120 / math.sqrt(3) + hex_size_y = 140 / 2 + + hex_layout = hexagon.Layout( + orientation=orientation, + size=Vec2(hex_size_x, hex_size_y), + origin=Vec2(0, 0), + ) + + # Load the hex tilemap -- note the hex_layout parameter + self.tile_map = arcade.load_tilemap( + ":resources:tiled_maps/hex_map.tmj", + hex_layout=hex_layout, + use_spatial_hash=True, + ) + + # Create a Scene from the tilemap, just like a square map + self.scene = arcade.Scene.from_tilemap(self.tile_map) + + self.background_color = arcade.color.BLACK + + def on_draw(self): + self.clear() + self.scene.draw() + + + def main(): + window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, "Hex Map") + game = GameView() + window.show_view(game) + game.reset() + arcade.run() + + + if __name__ == "__main__": + main() + +The key difference from a regular tilemap is the ``hex_layout`` parameter passed to +:func:`arcade.load_tilemap`. Without it, Arcade treats the map as a square grid. +With it, Arcade uses hex coordinate math to position each tile correctly. + +Everything else -- creating a Scene, drawing, using layers -- works the same as +with square tilemaps. See :ref:`platformer_part_twelve` for the general tilemap +loading tutorial. + +Working with Hex Tiles in Code +------------------------------- + +The :class:`~arcade.hexagon.HexTile` class provides several useful operations: + +**Neighbors and Distance** + +.. code-block:: python + + tile = HexTile(2, -1, -1) + + # Get all 6 neighbors + neighbors = tile.neighbors() + + # Get a specific neighbor by direction (0-5) + north = tile.neighbor(0) + + # Distance between two hexes + dist = tile.distance_to(HexTile(0, 0, 0)) # 2 + +**Rotation** + +.. code-block:: python + + tile = HexTile(1, -3, 2) + + rotated_right = tile.rotate_right() # HexTile(3, -2, -1) + rotated_left = tile.rotate_left() # HexTile(-2, -1, 3) + +**Line Drawing** + +.. code-block:: python + + from arcade.hexagon import line + + # Get all hexes on a line between two points + path = line(HexTile(0, 0, 0), HexTile(3, -3, 0)) + +**Pixel Conversion** + +.. code-block:: python + + from arcade.hexagon import hextile_to_pixel, pixel_to_hextile + + # Convert a hex to pixel coordinates + pixel_pos = hextile_to_pixel(tile, layout) + + # Convert a mouse click back to hex coordinates + hex_tile = pixel_to_hextile(Vec2(mouse_x, mouse_y), layout) + snapped = round(hex_tile) # Round to nearest hex + +Creating a Hex Map in Tiled +----------------------------- + +To create your own hexagonal map in Tiled: + +1. Open Tiled and select **File > New > New Map** +2. Set **Map Orientation** to ``Hexagonal`` +3. Set **Tile Render Order** to ``Right Down`` +4. Set **Stagger Axis** to ``Y`` and **Stagger Index** to ``Odd`` +5. Set your desired **Tile Width** and **Tile Height** +6. Set the **Hex Side Length** (this controls how "tall" the flat edges are) +7. Create your tileset and start painting + +When loading the map in Arcade, make sure your Layout's ``size`` values match +the tile dimensions as shown in the `Computing the Hex Size from Tiled`_ section +above. + +Full Example +------------ + +For a complete working example with camera controls (panning and zooming), see +the built-in hex map example: + +.. code-block:: bash + + python -m arcade.examples.hex_map + +Source: ``arcade/examples/hex_map.py`` diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst index d18c5d035a..f5889f7da1 100644 --- a/doc/tutorials/index.rst +++ b/doc/tutorials/index.rst @@ -18,4 +18,5 @@ Various tutorials of varying difficulty and complexity. compiling_with_nuitka/index shader_tutorials menu/index + hex_map/index framebuffer/index diff --git a/doc/tutorials/menu/index.rst b/doc/tutorials/menu/index.rst index f90a307d60..b8a36a64cc 100644 --- a/doc/tutorials/menu/index.rst +++ b/doc/tutorials/menu/index.rst @@ -295,6 +295,11 @@ Adding it to the widget layout. :caption: Adding dropdown to the layout :lines: 242 +If a dropdown has many options, it will automatically scroll when the list +exceeds the ``max_height`` (default 200px). You can also enable a visible +scroll bar with ``show_scroll_bar=True``, control the scroll direction with +``invert_scroll``, and adjust the scroll speed with ``scroll_speed``. + Adding a Slider ~~~~~~~~~~~~~~~ diff --git a/doc/tutorials/menu/menu_05.py b/doc/tutorials/menu/menu_05.py index d703ee0efc..a81890554b 100644 --- a/doc/tutorials/menu/menu_05.py +++ b/doc/tutorials/menu/menu_05.py @@ -119,7 +119,20 @@ def on_click_volume_button(event): "Volume Menu", "How do you like your volume?", "Enable Sound", - ["Play: Rock", "Play: Punk", "Play: Pop"], + [ + "Play: Rock", + "Play: Punk", + "Play: Pop", + "Play: Jazz", + "Play: Blues", + "Play: Classical", + "Play: Country", + "Play: Electronic", + "Play: Hip Hop", + "Play: Metal", + "Play: R&B", + "Play: Reggae", + ], "Adjust Volume", ) self.manager.add(volume_menu, layer=1) @@ -130,7 +143,20 @@ def on_click_options_button(event): "Funny Menu", "Too much fun here", "Fun?", - ["Make Fun", "Enjoy Fun", "Like Fun"], + [ + "Make Fun", + "Enjoy Fun", + "Like Fun", + "Share Fun", + "Spread Fun", + "Find Fun", + "Create Fun", + "Discover Fun", + "Embrace Fun", + "Celebrate Fun", + "Inspire Fun", + "Maximize Fun", + ], "Adjust Fun", ) self.manager.add(options_menu, layer=1) @@ -216,8 +242,13 @@ def __init__( toggle_group.add(toggle_label) # Create dropdown with a specified default. + # When many options are provided, the dropdown automatically scrolls. dropdown = arcade.gui.UIDropdown( - default=dropdown_options[0], options=dropdown_options, height=20, width=250 + default=dropdown_options[0], + options=dropdown_options, + height=20, + width=250, + show_scroll_bar=True, ) slider_label = arcade.gui.UILabel(text=slider_label) 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 9e74a561e8..7db78ad5f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,10 +20,9 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "pyglet~=2.1.5", - "pillow~=11.3.0", - "pymunk~=6.9.0", + "pillow>=11.3.0", "pytiled-parser~=2.2.9", + "pyglet==3.0.dev2", ] dynamic = ["version"] @@ -35,7 +34,10 @@ Issues = "https://github.com/pythonarcade/arcade/issues" Source = "https://github.com/pythonarcade/arcade" Book = "https://learn.arcade.academy" -[project.optional-dependencies] +[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,12 +64,15 @@ 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 + {include-group = "extras"} ] + # Testing only testing_libraries = ["pytest", "pytest-mock", "pytest-cov", "pyyaml==6.0.1"] [project.scripts] -arcade = "arcade.management:execute_from_command_line" +arcade = "arcade.cli:run_arcade_cli" [project.entry-points.pyinstaller40] hook-dirs = "arcade.__pyinstaller:get_hook_dirs" 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/integration/examples/test_examples.py b/tests/integration/examples/test_examples.py index f440805a79..dbfc709395 100644 --- a/tests/integration/examples/test_examples.py +++ b/tests/integration/examples/test_examples.py @@ -38,6 +38,7 @@ "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. + "numpy_sprite", # Needs numpy installed as a dependency ] 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/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() diff --git a/tests/unit/camera/test_viewport_projector.py b/tests/unit/camera/test_viewport_projector.py index f99098ff1a..56fe0fa48f 100644 --- a/tests/unit/camera/test_viewport_projector.py +++ b/tests/unit/camera/test_viewport_projector.py @@ -8,13 +8,13 @@ @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() + cam = camera.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() + cam = camera.ViewportProjector() x, y, *z = wrld_pos assert cam.unproject(wrld_pos) == Vec3(x, y, 0.0 if not z else z[0]) @@ -24,7 +24,7 @@ def test_viewport_projector_unproject(window: Window, wrld_pos: Point): "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() + cam = camera.ViewportProjector() assert cam.viewport.lbwh_int == window.ctx.viewport cam.viewport = viewport assert cam.viewport == viewport 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/gui/test_layout_size_hint_warning.py b/tests/unit/gui/test_layout_size_hint_warning.py new file mode 100644 index 0000000000..a0e5c29103 --- /dev/null +++ b/tests/unit/gui/test_layout_size_hint_warning.py @@ -0,0 +1,111 @@ +"""Tests that layouts warn when explicit width/height conflicts with active size_hint.""" +import warnings + +import pytest + +from arcade.gui import UIBoxLayout +from arcade.gui.widgets.layout import UIAnchorLayout, UIGridLayout + + +def test_anchor_layout_warns_when_width_given_with_default_size_hint(window): + """UIAnchorLayout should warn when width is given but size_hint_x is active.""" + with pytest.warns(UserWarning, match="size_hint_x"): + UIAnchorLayout(width=500) + + +def test_anchor_layout_warns_when_height_given_with_default_size_hint(window): + """UIAnchorLayout should warn when height is given but size_hint_y is active.""" + with pytest.warns(UserWarning, match="size_hint_y"): + UIAnchorLayout(height=500) + + +def test_anchor_layout_no_warning_when_size_hint_none(window): + """UIAnchorLayout should not warn when size_hint=None is explicitly set.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + UIAnchorLayout(width=500, height=500, size_hint=None) + + +def test_anchor_layout_no_warning_when_no_explicit_size(window): + """UIAnchorLayout should not warn when width/height are not explicitly given.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + UIAnchorLayout(size_hint=(1, 1)) + + +def test_anchor_layout_no_warning_when_size_hint_x_none(window): + """UIAnchorLayout should not warn for width when size_hint_x is None.""" + # No width warning expected (only height warning) + with pytest.warns(UserWarning, match="size_hint_y"): + UIAnchorLayout(width=500, height=500, size_hint=(None, 1)) + + +def test_anchor_layout_no_warning_when_size_hint_y_none(window): + """UIAnchorLayout should not warn for height when size_hint_y is None.""" + # No height warning expected (only width warning) + with pytest.warns(UserWarning, match="size_hint_x"): + UIAnchorLayout(width=500, height=500, size_hint=(1, None)) + + +def test_box_layout_warns_when_width_given_with_default_size_hint(window): + """UIBoxLayout should warn when width is given but size_hint_x is active.""" + with pytest.warns(UserWarning, match="size_hint_x"): + UIBoxLayout(width=200) + + +def test_box_layout_warns_when_height_given_with_default_size_hint(window): + """UIBoxLayout should warn when height is given but size_hint_y is active.""" + with pytest.warns(UserWarning, match="size_hint_y"): + UIBoxLayout(height=200) + + +def test_box_layout_no_warning_when_size_hint_none(window): + """UIBoxLayout should not warn when size_hint=None is explicitly set.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + UIBoxLayout(width=200, height=200, size_hint=None) + + +def test_box_layout_no_warning_when_no_explicit_size(window): + """UIBoxLayout should not warn when width/height are not explicitly given.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + UIBoxLayout(size_hint=(0, 0)) + + +def test_grid_layout_warns_when_width_given_with_default_size_hint(window): + """UIGridLayout should warn when width is given but size_hint_x is active.""" + with pytest.warns(UserWarning, match="size_hint_x"): + UIGridLayout(width=200) + + +def test_grid_layout_warns_when_height_given_with_default_size_hint(window): + """UIGridLayout should warn when height is given but size_hint_y is active.""" + with pytest.warns(UserWarning, match="size_hint_y"): + UIGridLayout(height=200) + + +def test_grid_layout_no_warning_when_size_hint_none(window): + """UIGridLayout should not warn when size_hint=None is explicitly set.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + UIGridLayout(width=200, height=200, size_hint=None) + + +def test_grid_layout_no_warning_when_no_explicit_size(window): + """UIGridLayout should not warn when width/height are not explicitly given.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + UIGridLayout(size_hint=(0, 0)) + + +def test_warning_message_includes_class_name(window): + """Warning should include the layout class name for clear identification.""" + with pytest.warns(UserWarning, match="UIBoxLayout"): + UIBoxLayout(width=200) + + with pytest.warns(UserWarning, match="UIAnchorLayout"): + UIAnchorLayout(width=200) + + with pytest.warns(UserWarning, match="UIGridLayout"): + UIGridLayout(width=200) diff --git a/tests/unit/gui/test_layouting_anchorlayout.py b/tests/unit/gui/test_layouting_anchorlayout.py index 0948dab833..b6fbc5b8d3 100644 --- a/tests/unit/gui/test_layouting_anchorlayout.py +++ b/tests/unit/gui/test_layouting_anchorlayout.py @@ -8,7 +8,7 @@ def test_place_widget(window): dummy = UIDummy(width=100, height=200) - subject = UIAnchorLayout(x=0, y=0, width=500, height=500) + subject = UIAnchorLayout(x=0, y=0, width=500, height=500, size_hint=None) subject.add( dummy, @@ -30,7 +30,7 @@ def test_place_widget_relative_to_own_content_rect(window): dummy = UIDummy(width=100, height=200) subject = ( - UIAnchorLayout(x=0, y=0, width=500, height=500) + UIAnchorLayout(x=0, y=0, width=500, height=500, size_hint=None) .with_border(width=2) .with_padding(left=50, top=100) ) @@ -68,7 +68,7 @@ def test_place_box_layout(window, ui): def test_grow_child_half(window): - subject = UIAnchorLayout(width=400, height=400) + subject = UIAnchorLayout(width=400, height=400, size_hint=None) dummy = subject.add(UIDummy(width=100, height=100, size_hint=(0.5, 0.5))) subject._do_layout() @@ -78,7 +78,7 @@ def test_grow_child_half(window): def test_grow_child_full_width(window): - subject = UIAnchorLayout(width=400, height=400) + subject = UIAnchorLayout(width=400, height=400, size_hint=None) dummy = subject.add(UIDummy(width=100, height=100, size_hint=(1, 0.5))) subject._do_layout() @@ -88,7 +88,7 @@ def test_grow_child_full_width(window): def test_grow_child_full_height(window): - subject = UIAnchorLayout(width=400, height=400) + subject = UIAnchorLayout(width=400, height=400, size_hint=None) dummy = subject.add(UIDummy(width=100, height=100, size_hint=(0.5, 1))) subject._do_layout() @@ -98,7 +98,7 @@ def test_grow_child_full_height(window): def test_grow_child_to_max_size(window): - subject = UIAnchorLayout(width=400, height=400) + subject = UIAnchorLayout(width=400, height=400, size_hint=None) dummy = subject.add(UIDummy(width=100, height=100, size_hint=(1, 1), size_hint_max=(200, 150))) subject._do_layout() @@ -108,7 +108,7 @@ def test_grow_child_to_max_size(window): def test_shrink_child_to_min_size(window): - subject = UIAnchorLayout(width=400, height=400) + subject = UIAnchorLayout(width=400, height=400, size_hint=None) dummy = subject.add( UIDummy(width=100, height=100, size_hint=(0.1, 0.1), size_hint_min=(200, 150)) ) @@ -121,7 +121,7 @@ def test_shrink_child_to_min_size(window): def test_children_can_grow_out_of_bounce(window): """This tests behavior, which is used for scrolling.""" - subject = UIAnchorLayout(width=400, height=400) + subject = UIAnchorLayout(width=400, height=400, size_hint=None) dummy = subject.add(UIDummy(width=100, height=100, size_hint=(2, 2))) subject._do_layout() @@ -132,7 +132,7 @@ def test_children_can_grow_out_of_bounce(window): def test_children_limited_to_layout_size_when_enforced(window): """This tests behavior, which is used for scrolling.""" - subject = UIAnchorLayout(width=400, height=400) + subject = UIAnchorLayout(width=400, height=400, size_hint=None) subject._restrict_child_size = True dummy = subject.add(UIDummy(width=100, height=100, size_hint=(2, 2))) @@ -143,7 +143,7 @@ def test_children_limited_to_layout_size_when_enforced(window): def test_only_adjust_size_if_size_hint_is_given_for_dimension(window): - subject = UIAnchorLayout(width=400, height=400) + subject = UIAnchorLayout(width=400, height=400, size_hint=None) dummy = subject.add( UIDummy(width=100, height=100, size_hint=(2, None), size_hint_min=(None, 200)) ) diff --git a/tests/unit/gui/test_layouting_boxlayout.py b/tests/unit/gui/test_layouting_boxlayout.py index 894d24155e..7a366bf5c5 100644 --- a/tests/unit/gui/test_layouting_boxlayout.py +++ b/tests/unit/gui/test_layouting_boxlayout.py @@ -324,7 +324,7 @@ def test_size_hint_contains_border_and_padding(window, ui): def test_vertical_resize_child_according_size_hint_full(window): - box = UIBoxLayout(width=200, height=200, vertical=True) + box = UIBoxLayout(width=200, height=200, vertical=True, size_hint=None) dummy_1 = box.add(UIDummy(width=100, height=100, size_hint=(1, 1))) box._do_layout() @@ -334,7 +334,7 @@ def test_vertical_resize_child_according_size_hint_full(window): def test_vertical_resize_child_according_size_hint_half(window): - box = UIBoxLayout(width=200, height=200, vertical=True) + box = UIBoxLayout(width=200, height=200, vertical=True, size_hint=None) dummy_1 = box.add(UIDummy(width=100, height=100, size_hint=(0.5, 0.5))) box._do_layout() @@ -344,7 +344,7 @@ def test_vertical_resize_child_according_size_hint_half(window): def test_vertical_resize_children_according_size_hint(window): - box = UIBoxLayout(width=300, height=400, vertical=True) + box = UIBoxLayout(width=300, height=400, vertical=True, size_hint=None) dummy_1 = box.add(UIDummy(size_hint_min=(100, 100), size_hint=(1, 1))) dummy_2 = box.add(UIDummy(size_hint_min=(100, 100), size_hint=(0.5, 0.5))) @@ -356,7 +356,7 @@ def test_vertical_resize_children_according_size_hint(window): def test_vertical_ignores_size_hint_none(window): - box = UIBoxLayout(width=300, height=400, vertical=True) + box = UIBoxLayout(width=300, height=400, vertical=True, size_hint=None) dummy_1 = box.add(UIDummy(width=100, height=100, size_hint=(1, None))) dummy_2 = box.add(UIDummy(width=100, height=100, size_hint=(None, 1))) @@ -368,7 +368,7 @@ def test_vertical_ignores_size_hint_none(window): def test_vertical_fit_content(window): - box = UIBoxLayout(width=100, height=100, vertical=True) + box = UIBoxLayout(width=100, height=100, vertical=True, size_hint=None) _ = box.add(UIDummy(width=100, height=50)) _ = box.add(UIDummy(width=20, height=100)) @@ -378,7 +378,7 @@ def test_vertical_fit_content(window): def test_horizontal_resize_child_according_size_hint_full(window): - box = UIBoxLayout(width=200, height=200, vertical=False) + box = UIBoxLayout(width=200, height=200, vertical=False, size_hint=None) dummy_1 = box.add(UIDummy(width=100, height=100, size_hint=(1, 1))) box._do_layout() @@ -388,7 +388,7 @@ def test_horizontal_resize_child_according_size_hint_full(window): def test_horizontal_resize_child_according_size_hint_half(window): - box = UIBoxLayout(width=200, height=200, vertical=False) + box = UIBoxLayout(width=200, height=200, vertical=False, size_hint=None) dummy_1 = box.add(UIDummy(width=100, height=100, size_hint=(0.5, 0.5))) box._do_layout() @@ -398,7 +398,7 @@ def test_horizontal_resize_child_according_size_hint_half(window): def test_horizontal_resize_children_according_size_hint(window): - box = UIBoxLayout(width=300, height=400, vertical=False) + box = UIBoxLayout(width=300, height=400, vertical=False, size_hint=None) dummy_1 = box.add(UIDummy(size_hint_min=(100, 100), size_hint=(1, 1))) dummy_2 = box.add(UIDummy(size_hint_min=(100, 100), size_hint=(0.5, 0.5))) @@ -410,7 +410,7 @@ def test_horizontal_resize_children_according_size_hint(window): def test_horizontal_ignores_size_hint_none(window): - box = UIBoxLayout(width=300, height=400, vertical=False) + box = UIBoxLayout(width=300, height=400, vertical=False, size_hint=None) dummy_1 = box.add(UIDummy(width=100, height=100, size_hint=(1, None))) dummy_2 = box.add(UIDummy(width=100, height=100, size_hint=(None, 1))) @@ -422,7 +422,7 @@ def test_horizontal_ignores_size_hint_none(window): def test_horizontal_fit_content(window): - box = UIBoxLayout(width=100, height=100, vertical=False) + box = UIBoxLayout(width=100, height=100, vertical=False, size_hint=None) _ = box.add(UIDummy(width=100, height=50)) _ = box.add(UIDummy(width=20, height=100)) diff --git a/tests/unit/gui/test_uidropdown.py b/tests/unit/gui/test_uidropdown.py new file mode 100644 index 0000000000..22af8dd79c --- /dev/null +++ b/tests/unit/gui/test_uidropdown.py @@ -0,0 +1,197 @@ +from arcade.gui import UIAnchorLayout +from arcade.gui.widgets.dropdown import UIDropdown + +from . import record_ui_events + + +def test_dropdown_initial_value(ui): + dropdown = UIDropdown(default="Apple", options=["Apple", "Banana", "Cherry"]) + ui.add(UIAnchorLayout()).add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + assert dropdown.value == "Apple" + + +def test_dropdown_no_default_value(ui): + dropdown = UIDropdown(options=["Apple", "Banana", "Cherry"]) + ui.add(UIAnchorLayout()).add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + assert dropdown.value is None + + +def test_dropdown_select_option_via_click(ui): + dropdown = UIDropdown( + default="Apple", options=["Apple", "Banana", "Cherry"], width=200, height=30 + ) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Click the dropdown button to open the overlay + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + # The overlay should now be visible โ€” click the second option ("Banana") + # Options are stacked below the button, each with height=30 + # First option starts at dropdown.bottom - 2, second option is 30px below that + option_x = dropdown.left + dropdown.width / 2 + option_y = dropdown.bottom - 2 - 15 # center of first option + second_option_y = option_y - 30 # center of second option + ui.click(option_x, second_option_y) + + assert dropdown.value == "Banana" + + +def test_dropdown_dispatches_on_change_event(ui): + dropdown = UIDropdown( + default="Apple", options=["Apple", "Banana", "Cherry"], width=200, height=30 + ) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + with record_ui_events(dropdown, "on_change") as events: + # Open and click first option (same as current โ€” "Apple") + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + # Click "Banana" (second option) + option_x = dropdown.left + dropdown.width / 2 + second_option_y = dropdown.bottom - 2 - 15 - 30 + ui.click(option_x, second_option_y) + + assert len(events) == 1 + assert events[0].old_value == "Apple" + assert events[0].new_value == "Banana" + + +def test_dropdown_closes_on_click_outside(ui): + dropdown = UIDropdown( + default="Apple", options=["Apple", "Banana", "Cherry"], width=200, height=30 + ) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Open the dropdown + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + # Overlay should be shown (added to manager) + assert dropdown._overlay.parent is not None + + # Click far outside + ui.click(10, 10) + + # Overlay should be hidden (removed from manager) + assert dropdown._overlay.parent is None + + +def test_dropdown_overlay_height_capped_at_max_height(ui): + options = [f"Option {i}" for i in range(20)] + dropdown = UIDropdown(options=options, width=200, height=30, max_height=150) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Total options height would be 20 * 30 = 600, but should be capped at 150 + # Open the dropdown to trigger overlay layout + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + assert dropdown._overlay.height <= 150 + + +def test_dropdown_scroll_changes_visible_options(ui): + options = [f"Option {i}" for i in range(20)] + dropdown = UIDropdown(options=options, width=200, height=30, max_height=150) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Open the dropdown + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + scroll_area = dropdown._overlay._scroll_area + initial_scroll = scroll_area.scroll_y + + # Scroll within the overlay + overlay_cx, overlay_cy = dropdown._overlay.rect.center + ui.on_mouse_scroll(overlay_cx, overlay_cy, 0, 5) + + assert scroll_area.scroll_y != initial_scroll + + +def test_dropdown_with_scroll_bar(ui): + options = [f"Option {i}" for i in range(20)] + dropdown = UIDropdown(options=options, width=200, height=30, show_scroll_bar=True) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + assert dropdown._overlay._show_scroll_bar is True + + +def test_dropdown_without_scroll_bar(ui): + options = [f"Option {i}" for i in range(20)] + dropdown = UIDropdown(options=options, width=200, height=30, show_scroll_bar=False) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + assert dropdown._overlay._show_scroll_bar is False + + +def test_dropdown_value_setter_updates_button_text(ui): + dropdown = UIDropdown( + default="Apple", options=["Apple", "Banana", "Cherry"], width=200, height=30 + ) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + dropdown.value = "Cherry" + + assert dropdown.value == "Cherry" + assert dropdown._default_button.text == "Cherry" + + +def test_dropdown_few_options_no_scrolling(ui): + dropdown = UIDropdown(options=["Apple", "Banana", "Cherry"], width=200, height=30, max_height=200) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Open the dropdown + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + # Total height is 3 * 30 = 90, well under max_height=200 + assert dropdown._overlay.height == 90 + + # Scrolling should have no effect (content fits) + scroll_area = dropdown._overlay._scroll_area + overlay_cx, overlay_cy = dropdown._overlay.rect.center + ui.on_mouse_scroll(overlay_cx, overlay_cy, 0, 5) + assert scroll_area.scroll_y == 0 + + +def test_dropdown_with_divider(ui): + options = ["Apple", UIDropdown.DIVIDER, "Banana", "Cherry"] + dropdown = UIDropdown(default="Apple", options=options, width=200, height=30) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Should not crash; divider is a 2px separator + assert dropdown.value == "Apple" + # Options layout should have 4 children (3 buttons + 1 divider widget) + assert len(dropdown._overlay._options_layout.children) == 4 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): 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/test_hexagon.py b/tests/unit/test_hexagon.py new file mode 100644 index 0000000000..ca19893b68 --- /dev/null +++ b/tests/unit/test_hexagon.py @@ -0,0 +1,167 @@ +from arcade.hexagon import ( + HexTile, + Layout, + OffsetCoord, + flat_orientation, + hextile_to_pixel, + line, + pixel_to_hextile, + pointy_orientation, +) +from pyglet.math import Vec2 + +# TODO: grab the rest of the tests from my main machine + + +def equal_offset_coord(name, a, b): + assert a.col == b.col and a.row == b.row + + +def equal_doubled_coord(name, a, b): + assert a.col == b.col and a.row == b.row + + +def test_hex_equality(): + assert HexTile(3, 4, -7) == HexTile(3, 4, -7) + assert HexTile(3, 4, -7) != HexTile(3, 3, -6) + assert HexTile(3, 4, -7) != HexTile(0, 0, 0) + assert HexTile(3, 4, -7) != HexTile(4, -7, 3) + + +def test_hex_pixel_roundtrip(): + flat = Layout(flat_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0)) + pointy = Layout(pointy_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0)) + + h = HexTile(3, 4, -7) + assert h == round(pixel_to_hextile(hextile_to_pixel(h, flat), flat)) + assert h == round(pixel_to_hextile(hextile_to_pixel(h, pointy), pointy)) + + +def test_list_of_hexes(): + assert [ + HexTile(0, 0, 0), + HexTile(0, -1, 1), + HexTile(0, -2, 2), + ] == [ + HexTile(0, 0, 0), + HexTile(0, -1, 1), + HexTile(0, -2, 2), + ] + + assert [HexTile(0, 0, 0), HexTile(0, -1, 1)] != [HexTile(0, 0, 0)] + + assert [HexTile(0, 0, 0), HexTile(0, -1, 1)] != [HexTile(0, -1, 1)] + + assert [HexTile(0, 0, 0), HexTile(0, -1, 1)] != [HexTile(0, -1, 1), HexTile(0, 0, 0)] + + assert HexTile(0, 0, 0) in [HexTile(0, 0, 0), HexTile(0, -1, 1)] + + assert HexTile(0, 0, 0) not in [HexTile(0, -1, 1), HexTile(0, -2, 2)] + + +def test_hex_arithmetic(): + assert HexTile(4, -10, 6) == HexTile(1, -3, 2) + HexTile(3, -7, 4) + assert HexTile(-2, 4, -2) == HexTile(1, -3, 2) - HexTile(3, -7, 4) + + +def test_hex_direction(): + assert HexTile(0, -1, 1) == HexTile.direction(2) + + +def test_hex_neighbor(): + assert HexTile(1, -3, 2) == HexTile(1, -2, 1).neighbor(2) + + +def test_hex_diagonal(): + assert HexTile(-1, -1, 2) == HexTile(1, -2, 1).diagonal_neighbor(3) + + +def test_hex_distance(): + assert 7 == HexTile(3, -7, 4).distance_to(HexTile(0, 0, 0)) + + +def test_hex_rotate_right(): + assert HexTile(1, -3, 2).rotate_right() == HexTile(3, -2, -1) + + +def test_hex_rotate_left(): + assert HexTile(1, -3, 2).rotate_left() == HexTile(-2, -1, 3) + + +def test_hex_round(): + a = HexTile(0.0, 0.0, 0.0) + b = HexTile(1.0, -1.0, 0.0) + c = HexTile(0.0, -1.0, 1.0) + assert HexTile(5, -10, 5) == round(HexTile(0.0, 0.0, 0.0).lerp_between(HexTile(10.0, -20.0, 10.0), 0.5)) + assert round(a) == round(a.lerp_between(b, 0.499)) + assert round(b) == round(a.lerp_between(b, 0.501)) + + assert round(a) == round( + HexTile( + a.q * 0.4 + b.q * 0.3 + c.q * 0.3, + a.r * 0.4 + b.r * 0.3 + c.r * 0.3, + a.s * 0.4 + b.s * 0.3 + c.s * 0.3, + ) + ) + + assert round(c) == round( + HexTile( + a.q * 0.3 + b.q * 0.3 + c.q * 0.4, + a.r * 0.3 + b.r * 0.3 + c.r * 0.4, + a.s * 0.3 + b.s * 0.3 + c.s * 0.4, + ) + ) + + +def test_hex_line_draw(): + assert [ + HexTile(0, 0, 0), + HexTile(0, -1, 1), + HexTile(0, -2, 2), + HexTile(1, -3, 2), + HexTile(1, -4, 3), + HexTile(1, -5, 4), + ] == line(HexTile(0, 0, 0), HexTile(1, -5, 4)) + + +def test_layout(): + h = HexTile(3, 4, -7) + flat = Layout(flat_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0)) + + assert h == round(pixel_to_hextile(hextile_to_pixel(h, flat), flat)) + + pointy = Layout(pointy_orientation, Vec2(10.0, 15.0), Vec2(35.0, 71.0)) + assert h == round(pixel_to_hextile(hextile_to_pixel(h, pointy), pointy)) + + +def test_offset_roundtrip(): + a = HexTile(3, 4, -7) + b = OffsetCoord(1, -3) + + assert a == a.to_offset("even-q").to_cube("even-q") + + assert b == b.to_cube("even-q").to_offset("even-q") + + assert a == a.to_offset("odd-q").to_cube("odd-q") + + assert b == b.to_cube("odd-q").to_offset("odd-q") + + assert a == a.to_offset("even-r").to_cube("even-r") + + assert b == b.to_cube("even-r").to_offset("even-r") + + assert a == a.to_offset("odd-r").to_cube("odd-r") + + assert b == b.to_cube("odd-r").to_offset("odd-r") + + +def test_offset_from_cube(): + assert OffsetCoord(1, 3) == HexTile(1, 2, -3).to_offset("even-q") + + assert OffsetCoord(1, 2) == HexTile(1, 2, -3).to_offset("odd-q") + + +def test_offset_to_cube(): + assert HexTile(1, 2, -3) == OffsetCoord(1, 3).to_cube("even-q") + + assert HexTile(1, 2, -3) == OffsetCoord(1, 2).to_cube("odd-q") diff --git a/tests/unit/tilemap/test_tilemap_objects.py b/tests/unit/tilemap/test_tilemap_objects.py index 648a08c343..73a31231df 100644 --- a/tests/unit/tilemap/test_tilemap_objects.py +++ b/tests/unit/tilemap/test_tilemap_objects.py @@ -23,11 +23,12 @@ def test_one(): sprite_1 = tile_list[0] assert sprite_1 is not None # - # Test width, height and angle + # Test width, height, angle and visibility # assert sprite_1.width == 400 assert sprite_1.height == 1000 assert sprite_1.angle == -45 + assert sprite_1.visible == False # # Test type and name properties 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 e6729b1af1..534e0aacef 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"], @@ -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..1491686e64 --- /dev/null +++ b/webplayground/README.md @@ -0,0 +1,35 @@ +# 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. + +## Testing Local Scripts + +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, 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 +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..1c686f9e61 --- /dev/null +++ b/webplayground/example.tpl @@ -0,0 +1,30 @@ + + + + + % 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..66e42fd740 --- /dev/null +++ b/webplayground/index.tpl @@ -0,0 +1,39 @@ + + + + + Arcade Examples + + + + +

Arcade Examples

+ ๐Ÿงช Test Local Scripts +

Built-in Examples:

+
    + % for item in examples: +
  • {{item}}
  • + % end +
+ + + \ No newline at end of file diff --git a/webplayground/local.tpl b/webplayground/local.tpl new file mode 100644 index 0000000000..395ee0596e --- /dev/null +++ b/webplayground/local.tpl @@ -0,0 +1,75 @@ + + + + + Local Scripts + + + + +

Local Test Scripts

+ +
+

How to use:

+

Place your Python scripts in the webplayground/local_scripts/ directory.

+

Scripts should have a main() function that will be called when loaded.

+

No need to restart the server - just refresh this page to see new scripts!

+
+ + % if scripts: +

Available Scripts:

+
    + % for script in scripts: +
  • {{script}}
  • + % end +
+ % else: +

No local scripts found. Add .py files to the local_scripts/ directory.

+ % end + + โ† Back to Examples + + + + diff --git a/webplayground/local_run.tpl b/webplayground/local_run.tpl new file mode 100644 index 0000000000..5081809a8e --- /dev/null +++ b/webplayground/local_run.tpl @@ -0,0 +1,80 @@ + + + + + {{script_name}} + + + + + +
Loading...
+ + + + + diff --git a/webplayground/local_scripts/README.md b/webplayground/local_scripts/README.md new file mode 100644 index 0000000000..0c998b4498 --- /dev/null +++ b/webplayground/local_scripts/README.md @@ -0,0 +1,59 @@ +# Local Scripts Directory + +This directory is for testing your own Arcade scripts in the web environment without restarting the server. + +## How to Use + +1. Place your Python script (`.py` file) in this directory +2. Write your script using the standard `if __name__ == "__main__":` pattern (see example below) +3. Navigate to `http://localhost:8000/local` in your browser to see the list of available scripts +4. Click on any script to run it in the browser +5. Edit your script and refresh the browser page to see changes - **no server restart needed!** + - Scripts are never cached, so refreshing always loads the latest version + - No need for hard refresh (Ctrl+F5) - a simple refresh is enough + +## Example Script Structure + +```python +import arcade + + +class MyWindow(arcade.Window): + def __init__(self): + super().__init__(800, 600, "My Test") + + def on_draw(self): + self.clear() + # Your drawing code here + arcade.draw_text( + "Hello World!", + self.width // 2, + self.height // 2, + arcade.color.WHITE, + font_size=24, + anchor_x="center" + ) + + +# Standard Python entry point - this will run when the script is loaded +if __name__ == "__main__": + window = MyWindow() + arcade.run() +``` + +**Note:** The `if __name__ == "__main__":` block is important! Your script's code will execute when loaded in the browser, just like running it normally with Python. + +## Tips + +- Scripts are loaded dynamically, so you can add, remove, or edit them while the server is running +- Just refresh the `/local` page to see newly added scripts +- Use the browser console (F12) to see any Python errors or debug output +- The example_test.py file shows a simple working example + +## What Gets Loaded + +- All `.py` files in this directory will appear in the local scripts list +- The web interface will load the Arcade wheel and execute your script +- When your script runs, the `if __name__ == "__main__":` block executes, just like running Python locally +- Pyodide environment is used to run Python in the browser + diff --git a/webplayground/local_scripts/example_test.py b/webplayground/local_scripts/example_test.py new file mode 100644 index 0000000000..a2a70650b9 --- /dev/null +++ b/webplayground/local_scripts/example_test.py @@ -0,0 +1,43 @@ +""" +Example local test script for the Arcade webplayground. + +Place this file (or your own scripts) in the webplayground/local_scripts/ directory +to test them in the browser without restarting the server. + +Your script should use the standard Python pattern with if __name__ == "__main__": +""" + +import arcade + + +class MyWindow(arcade.Window): + def __init__(self): + super().__init__(800, 600, "Local Test Script") + self.background_color = arcade.color.AMAZON + + def on_draw(self): + self.clear() + arcade.draw_text( + "Hello from local script!", + self.width // 2, + self.height // 2, + arcade.color.WHITE, + font_size=30, + anchor_x="center", + anchor_y="center" + ) + arcade.draw_text( + "Edit this file and refresh to see changes", + self.width // 2, + self.height // 2 - 50, + arcade.color.WHITE, + font_size=16, + anchor_x="center", + anchor_y="center" + ) + + +if __name__ == "__main__": + window = MyWindow() + arcade.run() + diff --git a/webplayground/server.py b/webplayground/server.py new file mode 100644 index 0000000000..efe3dd6fd7 --- /dev/null +++ b/webplayground/server.py @@ -0,0 +1,112 @@ +#! /usr/bin/env python + +import importlib +import os +import pkgutil +import shutil +import subprocess +import sys +from pathlib import Path + +import bottle +from bottle import route, run, static_file, template # type: ignore + +from arcade import examples + +# Disable template caching for development +bottle.TEMPLATES.clear() +bottle.debug(True) + +here = Path(__file__).parent.resolve() + +path_arcade = Path("../") +arcade_wheel_filename = "arcade-4.0.0.dev3-py3-none-any.whl" +path_arcade_wheel = path_arcade / "dist" / arcade_wheel_filename + +# Directory for local test scripts +local_scripts_dir = here / "local_scripts" +local_scripts_dir.mkdir(exist_ok=True) + + +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(r"/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, + ) + + +@route("/local") +def local_index(): + """List available local scripts""" + local_scripts = [] + if local_scripts_dir.exists(): + for script in local_scripts_dir.glob("*.py"): + local_scripts.append(script.stem) + return template("local.tpl", scripts=local_scripts) + + +@route("/local/") +def local_script(script_name): + """Run a local script""" + return template( + "local_run.tpl", + script_name=script_name, + arcade_wheel=arcade_wheel_filename, + ) + + +@route("/local_scripts/") +def serve_local_script(filename): + """Serve local script files with no-cache headers""" + from bottle import response + + response.set_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + response.set_header("Pragma", "no-cache") + response.set_header("Expires", "0") + return static_file(filename, root=local_scripts_dir) + + +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(["uv", "build"]) + os.chdir(here) + shutil.copy(path_arcade_wheel, f"./{arcade_wheel_filename}") + + run(host="localhost", port=8000) + + +if __name__ == "__main__": + main()