diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 38ad5cb1..c025e115 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -16,37 +16,27 @@ env: git-depth: 0 # Depth to search for tags. jobs: - black: + ruff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Install Black - run: pip install black - - name: Run Black - run: black --check --diff examples/ scripts/ tcod/ tests/ *.py - - isort: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Install isort - run: pip install isort - - name: isort - uses: liskin/gh-problem-matcher-wrap@v2 - with: - linters: isort - run: isort scripts/ tcod/ tests/ examples/ --check --diff + - uses: actions/checkout@v4 + - name: Install Ruff + run: pip install ruff + - name: Ruff Check + run: ruff check . --fix-only --exit-non-zero-on-fix --output-format=github + - name: Ruff Format + run: ruff format . --check mypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - name: Install typing dependencies run: pip install mypy pytest -r requirements.txt - name: Mypy - uses: liskin/gh-problem-matcher-wrap@v2 + uses: liskin/gh-problem-matcher-wrap@v3 with: linters: mypy run: mypy --show-column-numbers @@ -58,7 +48,7 @@ jobs: run: sudo apt-get update - name: Install APT dependencies run: sudo apt-get install libsdl2-dev - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -67,29 +57,29 @@ jobs: run: pip install build - name: Build source distribution run: python -m build --sdist - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: sdist path: dist/tcod-*.tar.gz retention-days: 7 - + compression-level: 0 # This makes sure that the latest versions of the SDL headers parse correctly. parse_sdl: - needs: [black, isort, mypy] + needs: [ruff, mypy] runs-on: ${{ matrix.os }} strategy: matrix: os: ["windows-latest", "macos-latest"] - sdl-version: ["2.0.14", "2.0.16"] + sdl-version: ["2.0.14", "2.0.16", "2.30.0"] fail-fast: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install build dependencies @@ -100,31 +90,31 @@ jobs: SDL_VERSION: ${{ matrix.sdl-version }} build: - needs: [black, isort, mypy] + needs: [ruff, mypy] runs-on: ${{ matrix.os }} strategy: matrix: os: ["ubuntu-latest", "windows-latest"] - python-version: ["3.7", "3.8", "3.9", "pypy-3.7"] + python-version: ["3.10", "pypy-3.10"] architecture: ["x64"] include: - os: "windows-latest" - python-version: "3.7" + python-version: "3.10" architecture: "x86" - os: "windows-latest" - python-version: "pypy-3.7" + python-version: "pypy-3.10" architecture: "x86" fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: | git submodule update --init --recursive --depth 1 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.architecture }} @@ -155,23 +145,26 @@ jobs: - name: Xvfb logs if: runner.os != 'Windows' run: cat /tmp/xvfb.log - - uses: codecov/codecov-action@v3 - - uses: actions/upload-artifact@v3 + - uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + - uses: actions/upload-artifact@v4 if: runner.os == 'Windows' with: - name: wheels-windows + name: wheels-windows-${{ matrix.architecture }}-${{ matrix.python-version }} path: dist/*.whl retention-days: 7 + compression-level: 0 test-docs: - needs: [black, isort, mypy] + needs: [ruff, mypy] runs-on: ubuntu-latest steps: - name: Install APT dependencies run: | sudo apt-get update sudo apt-get install libsdl2-dev - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -188,61 +181,61 @@ jobs: working-directory: docs run: python -m sphinx -T -E -W --keep-going . _build/html - isolated: # Test installing the package from source. - needs: [black, isort, mypy, sdist] + tox: + needs: [ruff] runs-on: ${{ matrix.os }} strategy: matrix: os: ["ubuntu-latest", "windows-latest"] steps: + - uses: actions/checkout@v4 + with: + fetch-depth: ${{ env.git-depth }} + - name: Checkout submodules + run: git submodule update --init --depth 1 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.x + python-version: "3.x" - name: Install Python dependencies run: | - python -m pip install --upgrade pip - pip install wheel + python -m pip install --upgrade pip tox - name: Install APT dependencies if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install libsdl2-dev - - uses: actions/download-artifact@v3 - with: - name: sdist - - name: Build package in isolation + - name: Run tox run: | - pip install tcod-*.tar.gz - - name: Confirm package import - run: | - python -c "import tcod.context" + tox -vv linux-wheels: - needs: [black, isort, mypy] - runs-on: "ubuntu-latest" + needs: [ruff, mypy] + runs-on: ${{ matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-latest'}} strategy: matrix: arch: ["x86_64", "aarch64"] - build: ["cp37-manylinux*", "pp37-manylinux*"] + build: ["cp310-manylinux*", "pp310-manylinux*"] + env: + BUILD_DESC: "" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: ${{ env.git-depth }} - name: Set up QEMU if: ${{ matrix.arch == 'aarch64' }} - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Checkout submodules run: | git submodule update --init --recursive --depth 1 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install Python dependencies run: | python -m pip install --upgrade pip - pip install cibuildwheel==2.3.1 + pip install cibuildwheel==2.23.3 - name: Build wheels run: | python -m cibuildwheel --output-dir wheelhouse @@ -260,33 +253,44 @@ jobs: CIBW_TEST_COMMAND: python -c "import tcod.context" # Skip test on emulated architectures CIBW_TEST_SKIP: "*_aarch64" + - name: Remove asterisk from label + run: | + BUILD_DESC=${{ matrix.build }} + BUILD_DESC=${BUILD_DESC//\*} + echo BUILD_DESC=${BUILD_DESC} >> $GITHUB_ENV - name: Archive wheel - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: wheels-linux + name: wheels-linux-${{ matrix.arch }}-${{ env.BUILD_DESC }} path: wheelhouse/*.whl retention-days: 7 + compression-level: 0 build-macos: - needs: [black, isort, mypy] - runs-on: "macos-11" + needs: [ruff, mypy] + runs-on: "macos-14" strategy: fail-fast: true matrix: - python: ["cp38-*_universal2", "cp38-*_x86_64", "cp38-*_arm64", "pp37-*"] + python: ["cp310-*_universal2", "pp310-*"] + env: + PYTHON_DESC: "" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" - name: Install Python dependencies - run: pip3 install -r requirements.txt + run: pip install -r requirements.txt - name: Prepare package # Downloads SDL2 for the later step. - run: python3 build_sdl.py + run: python build_sdl.py - name: Build wheels - uses: pypa/cibuildwheel@v2.12.3 + uses: pypa/cibuildwheel@v2.23.3 env: CIBW_BUILD: ${{ matrix.python }} CIBW_ARCHS_MACOS: x86_64 arm64 universal2 @@ -294,39 +298,39 @@ jobs: CIBW_BEFORE_TEST: pip install numpy CIBW_TEST_COMMAND: python -c "import tcod.context" CIBW_TEST_SKIP: "pp* *-macosx_arm64 *-macosx_universal2:arm64" + MACOSX_DEPLOYMENT_TARGET: "10.11" + - name: Remove asterisk from label + run: | + PYTHON_DESC=${{ matrix.python }} + PYTHON_DESC=${PYTHON_DESC//\*/X} + echo PYTHON_DESC=${PYTHON_DESC} >> $GITHUB_ENV - name: Archive wheel - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: wheels-macos + name: wheels-macos-${{ env.PYTHON_DESC }} path: wheelhouse/*.whl retention-days: 7 + compression-level: 0 publish: - needs: [sdist, build, build-macos, linux-wheels] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - environment: - name: release - url: https://pypi.org/p/tcod - permissions: - id-token: write - steps: - - uses: actions/download-artifact@v3 + needs: [sdist, build, build-macos, linux-wheels] + runs-on: ubuntu-latest + if: github.ref_type == 'tag' + environment: + name: pypi + url: https://pypi.org/project/tcod/${{ github.ref_name }} + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 with: name: sdist path: dist/ - - uses: actions/download-artifact@v3 - with: - name: wheels-windows - path: dist/ - - uses: actions/download-artifact@v3 - with: - name: wheels-macos - path: dist/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: - name: wheels-linux + pattern: wheels-* path: dist/ + merge-multiple: true - uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 95cf02d3..2171ddbf 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -13,7 +13,7 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Generate body run: | scripts/get_release_description.py | tee release_body.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 54a9a88e..f7d3a915 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,10 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks +ci: + autoupdate_schedule: quarterly repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -14,13 +16,9 @@ repos: - id: debug-statements - id: fix-byte-order-marker - id: detect-private-key - - repo: https://github.com/psf/black - rev: 23.3.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.11 hooks: - - id: black - - repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort -default_language_version: - python: python3.11 + - id: ruff + args: [--fix-only, --exit-non-zero-on-fix] + - id: ruff-format diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 390d6115..81bb11ae 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -20,8 +20,7 @@ sphinx: fail_on_warning: true # If using Sphinx, optionally build your docs in additional formats such as PDF -# formats: -# - pdf +formats: all # Optionally declare the Python requirements required to build your docs python: diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d589600c..15ad54f7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,13 +6,13 @@ "austin.code-gnu-global", "editorconfig.editorconfig", "ms-python.python", - "ms-python.black-formatter", "ms-python.vscode-pylance", "ms-vscode.cpptools", "redhat.vscode-yaml", "streetsidesoftware.code-spell-checker", "tamasfe.even-better-toml", "xaver.clang-format", + "charliermarsh.ruff" ], // List of extensions recommended by VS Code that should not be recommended for users of this workspace. "unwantedRecommendations": [] diff --git a/.vscode/launch.json b/.vscode/launch.json index 0f237387..a4d5d9e7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,24 +6,24 @@ "configurations": [ { "name": "Python: Current File", - "type": "python", + "type": "debugpy", "request": "launch", "program": "${file}", - "console": "internalConsole", + "console": "integratedTerminal", }, { // Run the Python samples. // tcod will be built and installed in editalbe mode. "name": "Python: Python samples", - "type": "python", + "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/examples/samples_tcod.py", "cwd": "${workspaceFolder}/examples", - "console": "internalConsole", + "console": "integratedTerminal", }, { "name": "Python: Run tests", - "type": "python", + "type": "debugpy", "request": "launch", "module": "pytest", "preLaunchTask": "develop python-tcod", @@ -31,7 +31,7 @@ { "name": "Documentation: Launch Chrome", "request": "launch", - "type": "pwa-chrome", + "type": "chrome", "url": "file://${workspaceFolder}/docs/_build/html/index.html", "webRoot": "${workspaceFolder}", "preLaunchTask": "build documentation", @@ -39,7 +39,7 @@ { "name": "Documentation: Launch Edge", "request": "launch", - "type": "pwa-msedge", + "type": "msedge", "url": "file://${workspaceFolder}/docs/_build/html/index.html", "webRoot": "${workspaceFolder}", "preLaunchTask": "build documentation", diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a02db40..e3434adc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,24 +5,19 @@ ], "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll": true, - "source.organizeImports": false + "source.fixAll": "always", + "source.organizeImports": "never" }, + "cmake.configureOnOpen": false, "files.trimFinalNewlines": true, "files.insertFinalNewline": true, "files.trimTrailingWhitespace": true, - "python.linting.enabled": true, - "python.linting.flake8Enabled": false, - "python.linting.mypyEnabled": true, - "python.linting.mypyArgs": [ - "--follow-imports=silent", - "--show-column-numbers" - ], - "python.formatting.provider": "none", "files.associations": { "*.spec": "python", }, + "mypy-type-checker.importStrategy": "fromEnvironment", "cSpell.words": [ + "aarch", "ADDA", "ADDALPHA", "addressof", @@ -34,6 +29,7 @@ "ALTERASE", "arange", "ARCHS", + "arctan", "asarray", "ascontiguousarray", "astar", @@ -51,6 +47,7 @@ "autofunction", "autogenerated", "automodule", + "autoupdate", "backlinks", "bdist", "Benesch", @@ -82,6 +79,7 @@ "CFLAGS", "CHARMAP", "Chebyshev", + "choicelist", "cibuildwheel", "CIBW", "CLEARAGAIN", @@ -95,6 +93,7 @@ "Coef", "COLCTRL", "COMPILEDVERSION", + "condlist", "consolas", "contextdata", "CONTROLLERAXISMOTION", @@ -127,6 +126,7 @@ "DHLINE", "DISPLAYSWITCH", "dlopen", + "docstrings", "documentclass", "Doryen", "DPAD", @@ -136,9 +136,11 @@ "DTEEW", "dtype", "dtypes", + "dunder", "DVLINE", "elif", "endianness", + "epel", "epub", "EQUALSAS", "errorvf", @@ -148,6 +150,7 @@ "ffade", "fgcolor", "fheight", + "Flecs", "flto", "fmean", "fontx", @@ -160,6 +163,7 @@ "GAMECONTROLLER", "gamepad", "genindex", + "getbbox", "GFORCE", "GLES", "globaltoc", @@ -167,6 +171,7 @@ "greyscale", "groupwise", "guass", + "hasattr", "heapify", "heightmap", "heightmaps", @@ -186,11 +191,14 @@ "iinfo", "IJKL", "imageio", + "imread", "INCOL", "INROW", + "interactable", "intersphinx", "isinstance", "isort", + "issubdtype", "itemsize", "itleref", "ivar", @@ -331,6 +339,7 @@ "printn", "PRINTSCREEN", "propname", + "pushdown", "pycall", "pycparser", "pyinstaller", @@ -339,11 +348,13 @@ "pypiwin", "pypy", "pytest", + "PYTHONHASHSEED", "PYTHONOPTIMIZE", "Pyup", "quickstart", "QUOTEDBL", "RALT", + "randint", "randomizer", "rbutton", "RCTRL", @@ -399,6 +410,7 @@ "sourcelink", "sphinxstrong", "sphinxtitleref", + "staticmethod", "stdeb", "struct", "structs", @@ -483,7 +495,7 @@ "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" + "editor.defaultFormatter": "charliermarsh.ruff" }, "cSpell.enableFiletypes": [ "github-actions-workflow" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fac3613..0ce0d62c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1271 +1,1859 @@ # Changelog + Changes relevant to the users of python-tcod are documented here. This project adheres to [Semantic Versioning](https://semver.org/) since version `2.0.0`. ## [Unreleased] +### Removed + +- Support dropped for Python 3.8 and 3.9. + +## [18.1.0] - 2025-05-05 + +### Added + +- `tcod.path.path2d` to compute paths for the most basic cases. + +### Fixed + +- `tcod.noise.grid` would raise `TypeError` when given a plain integer for scale. + +## [18.0.0] - 2025-04-08 + +### Changed + +- `Console.print` now accepts `height` and `width` keywords and has renamed `string` to `text`. +- Text printed with `Console.print` using right-alignment has been shifted to the left by 1-tile. + +### Deprecated + +- In general the `fg`, `bg`, and `bg_blend` keywords are too hard to keep track of as positional arguments so they must be replaced with keyword arguments instead. +- `Console.print`: deprecated `string`, `fg`, `bg`, and `bg_blend` being given as positional arguments. + The `string` parameter has been renamed to `text`. +- `Console.print_box` has been replaced by `Console.print`. +- `Console.draw_frame`: deprecated `clear`, `fg`, `bg`, and `bg_blend` being given as positional arguments. +- `Console.draw_rect`: deprecated `fg`, `bg`, and `bg_blend` being given as positional arguments. +- The `EventDispatch` class is now deprecated. + This class was made before Python supported protocols and structural pattern matching, + now the class serves little purpose and its usage can create a minor technical burden. + +## [17.1.0] - 2025-03-29 + +### Added + +- SDL renderer primitive drawing methods now support sequences of tuples. + +### Fixed + +- `tcod.sdl.Renderer.draw_lines` type hint was too narrow. +- Fixed crash in `tcod.sdl.Renderer.geometry`. + +## [17.0.0] - 2025-03-28 + +### Changed + +- `EventDispatch`'s on event methods are now defined as positional parameters, so renaming the `event` parameter is now valid in subclasses. + +### Deprecated + +- Keyboard bitmask modifiers `tcod.event.KMOD_*` have been replaced by `tcod.event.Modifier`. + +### Fixed + +- Suppressed internal `mouse.tile_motion` deprecation warning. +- Fixed SDL renderer primitive drawing methods. #159 + +## [16.2.3] - 2024-07-16 + +### Fixed + +- Fixed access violation when events are polled before SDL is initialized. +- Fixed access violation when libtcod images fail to load. +- Verify input files exist when calling `libtcodpy.parser_run`, `libtcodpy.namegen_parse`, `tcod.image.load`. + +## [16.2.2] - 2024-01-16 + +### Fixed + +- Ignore the locale when encoding file paths outside of Windows. +- Fix performance when calling joystick functions. + +## [16.2.1] - 2023-09-24 + +### Fixed + +- Fixed errors loading files on Windows where their paths are non-ASCII and the locale is not UTF-8. + +## [16.2.0] - 2023-09-20 + +### Changed + +- Renamed `gauss` methods to fix typos. + +## [16.1.1] - 2023-07-10 + +### Changed + +- Added an empty `__slots__` to `EventDispatch`. +- Bundle `SDL 2.28.1` on Windows and MacOS. + +### Fixed + +- Fixed "SDL failed to get a vertex buffer for this Direct3D 9 rendering batch!" + https://github.com/libtcod/python-tcod/issues/131 + +### Removed + +- Dropped support for Python 3.7. + ## [16.1.0] - 2023-06-23 + ### Added + - Added the enums `tcod.event.MouseButton` and `tcod.event.MouseButtonMask`. ### Changed + - Using `libtcod 1.24.0`. ### Deprecated + - Mouse button and mask constants have been replaced by enums. ### Fixed + - `WindowResized` literal annotations were in the wrong case. ## [16.0.3] - 2023-06-04 + ### Changed + - Enabled logging for libtcod and SDL. ### Deprecated + - Deprecated using `tcod` as an implicit alias for `libtcodpy`. You should use `from tcod import libtcodpy` if you want to access this module. - Deprecated constants being held directly in `tcod`, get these from `tcod.libtcodpy` instead. - Deprecated `tcod.Console` which should be accessed from `tcod.console.Console` instead. ## [16.0.2] - 2023-06-02 + ### Fixed + - Joystick/controller device events would raise `RuntimeError` when accessed after removal. ## [16.0.1] - 2023-05-28 + ### Fixed + - `AudioDevice.stopped` was inverted. - Fixed the audio mixer stop and fadeout methods. - Exceptions raised in the audio mixer callback no longer cause a messy crash, they now go to `sys.unraisablehook`. ## [16.0.0] - 2023-05-27 + ### Added + - Added PathLike support to more libtcodpy functions. - New `tcod.sdl.mouse.show` function for querying or setting mouse visibility. -- New class method `tcod.image.Image.from_file` to load images with. This replaces `tcod.image_load`. +- New class method `tcod.image.Image.from_file` to load images with. This replaces `tcod.image_load`. - `tcod.sdl.audio.AudioDevice` is now a context manager. ### Changed + - SDL audio conversion will now pass unconvertible floating types as float32 instead of raising. ### Deprecated + - Deprecated the libtcodpy functions for images and noise generators. ### Removed + - `tcod.console_set_custom_font` can no longer take bytes as the file path. ### Fixed + - Fix `tcod.sdl.mouse.warp_in_window` function. - Fix `TypeError: '_AudioCallbackUserdata' object is not callable` when using an SDL audio device callback. [#128](https://github.com/libtcod/python-tcod/issues/128) ## [15.0.3] - 2023-05-25 + ### Deprecated -- Deprecated all libtcod color constants. Replace these with your own manually defined colors. + +- Deprecated all libtcod color constants. Replace these with your own manually defined colors. Using a color will tell you the color values of the deprecated color in the warning. -- Deprecated older scancode and keysym constants. These were replaced with the Scancode and KeySym enums. +- Deprecated older scancode and keysym constants. These were replaced with the Scancode and KeySym enums. ### Fixed + - DLL loader could fail to load `SDL2.dll` when other tcod namespace packages were installed. ## [15.0.1] - 2023-03-30 + ### Added + - Added support for `tcod.sdl` namespace packages. ### Fixed -- ``Renderer.read_pixels`` method was completely broken. + +- `Renderer.read_pixels` method was completely broken. ## [15.0.0] - 2023-01-04 + ### Changed + - Modified the letter case of window event types to match their type annotations. - This may cause regressions. Run Mypy to check for ``[comparison-overlap]`` errors. -- Mouse event attributes have been changed ``.pixel -> .position`` and ``.pixel_motion -> .motion``. + This may cause regressions. Run Mypy to check for `[comparison-overlap]` errors. +- Mouse event attributes have been changed `.pixel -> .position` and `.pixel_motion -> .motion`. - `Context.convert_event` now returns copies of events with mouse coordinates converted into tile positions. ### Deprecated + - Mouse event pixel and tile attributes have been deprecated. ## [14.0.0] - 2022-12-09 + ### Added + - Added explicit support for namespace packages. ### Changed + - Using `libtcod 1.23.1`. - Bundle `SDL 2.26.0` on Windows and MacOS. - Code Page 437: Character 0x7F is now assigned to 0x2302 (HOUSE). -- Forced all renderers to ``RENDERER_SDL2`` to fix rare graphical artifacts with OpenGL. +- Forced all renderers to `RENDERER_SDL2` to fix rare graphical artifacts with OpenGL. ### Deprecated + - The `renderer` parameter of new contexts is now deprecated. ## [13.8.1] - 2022-09-23 + ### Fixed + - `EventDispatch` was missing new event names. ## [13.8.0] - 2022-09-22 + ### Added + - Ported SDL2 joystick handing as `tcod.sdl.joystick`. - New joystick related events. ### Changed + - Using `libtcod 1.22.3`. - Bundle `SDL 2.24.0` on Windows and MacOS. ### Deprecated + - Renderers other than `tcod.RENDERER_SDL2` are now discouraged. ### Fixed + - Fixed double present bug in non-context flush functions. This was affecting performance and also caused a screen flicker whenever the global fade color was active. - Fixed the parsing of SDL 2.24.0 headers on Windows. ## [13.7.0] - 2022-08-07 + ### Added + - You can new use `SDLConsoleRender.atlas` to access the `SDLTilesetAtlas` used to create it. [#121](https://github.com/libtcod/python-tcod/issues/121) ### Fixed -- Fixed the parsing of SDL 2.0.22 headers. Specifically `SDL_FLT_EPSILON`. + +- Fixed the parsing of SDL 2.0.22 headers. Specifically `SDL_FLT_EPSILON`. ## [13.6.2] - 2022-05-02 + ### Fixed + - SDL renderers were ignoring tiles where only the background red channel was changed. ## [13.6.1] - 2022-03-29 + ### Changed + - The SDL2 renderer has had a major performance update when compiled with SDL 2.0.18. - SDL2 is now the default renderer to avoid rare issues with the OpenGL 2 renderer. ## [13.6.0] - 2022-02-19 + ### Added -- `BasicMixer` and `Channel` classes added to `tcod.sdl.audio`. These handle simple audio mixing. + +- `BasicMixer` and `Channel` classes added to `tcod.sdl.audio`. These handle simple audio mixing. - `AudioDevice.convert` added to handle simple conversions to the active devices format. - `tcod.sdl.audio.convert_audio` added to handle any other conversions needed. ## [13.5.0] - 2022-02-11 + ### Added -- `tcod.sdl.audio`, a new module exposing SDL audio devices. This does not include an audio mixer yet. + +- `tcod.sdl.audio`, a new module exposing SDL audio devices. This does not include an audio mixer yet. - `tcod.sdl.mouse`, for SDL mouse and cursor handing. - `Context.sdl_atlas`, which provides the relevant `SDLTilesetAtlas` when one is being used by the context. - Several missing features were added to `tcod.sdl.render`. - `Window.mouse_rect` added to SDL windows to set the mouse confinement area. + ### Changed + - `Texture.access` and `Texture.blend_mode` properties now return enum instances. You can still set `blend_mode` with `int` but Mypy will complain. ## [13.4.0] - 2022-02-04 + ### Added + - Adds `sdl_window` and `sdl_renderer` properties to tcod contexts. - Adds `tcod.event.add_watch` and `tcod.event.remove_watch` to handle SDL events via callback. - Adds the `tcod.sdl.video` module to handle SDL windows. - Adds the `tcod.sdl.render` module to handle SDL renderers. - Adds the `tcod.render` module which gives more control over the rendering of consoles and tilesets. + ### Fixed + - Fixed handling of non-Path PathLike parameters and filepath encodings. ## [13.3.0] - 2022-01-07 + ### Added + - New experimental renderer `tcod.context.RENDERER_XTERM`. + ### Changed + - Using `libtcod 1.20.1`. + ### Fixed + - Functions accepting `Path`-like parameters now accept the more correct `os.PathLike` type. - BDF files with blank lines no longer fail to load with an "Unknown keyword" error. ## [13.2.0] - 2021-12-24 + ### Added + - New `console` parameter in `tcod.context.new` which sets parameters from an existing Console. ### Changed + - Using `libtcod 1.20.0`. ### Fixed + - Fixed segfault when an OpenGL2 context fails to load. - Gaussian number generation no longer affects the results of unrelated RNG's. - Gaussian number generation is now reentrant and thread-safe. - Fixed potential crash in PNG image loading. ## [13.1.0] - 2021-10-22 + ### Added + - Added the `tcod.tileset.procedural_block_elements` function. ### Removed + - Python 3.6 is no longer supported. ## [13.0.0] - 2021-09-20 + ### Changed + - Console print and drawing functions now always use absolute coordinates for negative numbers. ## [12.7.3] - 2021-08-13 + ### Deprecated + - `tcod.console_is_key_pressed` was replaced with `tcod.event.get_keyboard_state`. - `tcod.console_from_file` is deprecated. - The `.asc` and `.apf` formats are no longer actively supported. ### Fixed + - Fixed the parsing of SDL 2.0.16 headers. ## [12.7.2] - 2021-07-01 + ### Fixed -- *Scancode* and *KeySym* enums no longer crash when SDL returns an unexpected value. + +- _Scancode_ and _KeySym_ enums no longer crash when SDL returns an unexpected value. ## [12.7.1] - 2021-06-30 + ### Added + - Started uploading wheels for ARM64 macOS. ## [12.7.0] - 2021-06-29 + ### Added -- *tcod.image* and *tcod.tileset* now support *pathlib*. + +- _tcod.image_ and _tcod.tileset_ now support _pathlib_. ### Fixed + - Wheels for 32-bit Windows now deploy again. ## [12.6.2] - 2021-06-15 + ### Fixed + - Git is no longer required to install from source. ## [12.6.1] - 2021-06-09 + ### Fixed + - Fixed version mismatch when building from sources. ## [12.6.0] - 2021-06-09 + ### Added -- Added the *decoration* parameter to *Console.draw_frame*. - You may use this parameter to designate custom glyphs as the frame border. + +- Added the _decoration_ parameter to _Console.draw_frame_. + You may use this parameter to designate custom glyphs as the frame border. ### Deprecated + - The handling of negative indexes given to console drawing and printing - functions will be changed to be used as absolute coordinates in the future. + functions will be changed to be used as absolute coordinates in the future. ## [12.5.1] - 2021-05-30 + ### Fixed + - The setup script should no longer fail silently when cffi is unavailable. ## [12.5.0] - 2021-05-21 + ### Changed + - `KeyboardEvent`'s '`scancode`, `sym`, and `mod` attributes now use their respective enums. ## [12.4.0] - 2021-05-21 + ### Added + - Added modernized REXPaint saving/loading functions. - - `tcod.console.load_xp` - - `tcod.console.save_xp` + - `tcod.console.load_xp` + - `tcod.console.save_xp` ### Changed + - Using `libtcod 1.18.1`. - `tcod.event.KeySym` and `tcod.event.Scancode` can now be hashed. ## [12.3.2] - 2021-05-15 + ### Changed + - Using `libtcod 1.17.1`. ### Fixed + - Fixed regression with loading PNG images. ## [12.3.1] - 2021-05-13 + ### Fixed + - Fix Windows deployment. ## [12.3.0] - 2021-05-13 + ### Added + - New keyboard enums: - - `tcod.event.KeySym` - - `tcod.event.Scancode` - - `tcod.event.Modifier` + - `tcod.event.KeySym` + - `tcod.event.Scancode` + - `tcod.event.Modifier` - New functions: - - `tcod.event.get_keyboard_state` - - `tcod.event.get_modifier_state` + - `tcod.event.get_keyboard_state` + - `tcod.event.get_modifier_state` - Added `tcod.console.rgb_graphic` and `tcod.console.rgba_graphic` dtypes. - Another name for the Console array attributes: `Console.rgb` and `Console.rgba`. ### Changed + - Using `libtcod 1.17.0`. ### Deprecated + - `Console_tiles_rgb` is being renamed to `Console.rgb`. - `Console_tiles` being renamed to `Console.rgba`. ### Fixed + - Contexts now give a more useful error when pickled. - Fixed regressions with `tcod.console_print_frame` and `Console.print_frame` - when given empty strings as the banner. + when given empty strings as the banner. ## [12.2.0] - 2021-04-09 + ### Added + - Added `tcod.noise.Algorithm` and `tcod.noise.Implementation` enums. - Added `tcod.noise.grid` helper function. ### Deprecated + - The non-enum noise implementation names have been deprecated. ### Fixed + - Indexing Noise classes now works with the FBM implementation. ## [12.1.0] - 2021-04-01 + ### Added + - Added package-level PyInstaller hook. ### Changed + - Using `libtcod 1.16.7`. - `tcod.path.dijkstra2d` now returns the output and accepts an `out` parameter. ### Deprecated -- In the future `tcod.path.dijkstra2d` will no longer modify the input by default. Until then an `out` parameter must be given. + +- In the future `tcod.path.dijkstra2d` will no longer modify the input by default. Until then an `out` parameter must be given. ### Fixed + - Fixed crashes from loading tilesets with non-square tile sizes. - Tilesets with a size of 0 should no longer crash when used. - Prevent division by zero from recommended-console-size functions. ## [12.0.0] - 2021-03-05 + ### Added + - Now includes PyInstaller hooks within the package itself. ### Deprecated + - The Random class will now warn if the seed it's given will not used - deterministically. It will no longer accept non-integer seeds in the future. + deterministically. It will no longer accept non-integer seeds in the future. ### Changed + - Now bundles SDL 2.0.14 for MacOS. - `tcod.event` can now detect and will warn about uninitialized tile - attributes on mouse events. + attributes on mouse events. ### Removed + - Python 3.5 is no longer supported. - The `tdl` module has been dropped. ## [11.19.3] - 2021-01-07 + ### Fixed + - Some wheels had broken version metadata. ## [11.19.2] - 2020-12-30 + ### Changed + - Now bundles SDL 2.0.10 for MacOS and SDL 2.0.14 for Windows. ### Fixed + - MacOS wheels were failing to bundle dependencies for SDL2. ## [11.19.1] - 2020-12-29 + ### Fixed + - MacOS wheels failed to deploy for the previous version. ## [11.19.0] - 2020-12-29 + ### Added + - Added the important `order` parameter to `Context.new_console`. ## [11.18.3] - 2020-12-28 + ### Changed + - Now bundles SDL 2.0.14 for Windows/MacOS. ### Deprecated + - Support for Python 3.5 will be dropped. - `tcod.console_load_xp` has been deprecated, `tcod.console_from_xp` can load - these files without modifying an existing console. + these files without modifying an existing console. ### Fixed + - `tcod.console_from_xp` now has better error handling (instead of crashing.) - Can now compile with SDL 2.0.14 headers. ## [11.18.2] - 2020-12-03 + ### Fixed + - Fixed missing `tcod.FOV_SYMMETRIC_SHADOWCAST` constant. -- Fixed regression in `tcod.sys_get_current_resolution` behavior. This - function now returns the monitor resolution as was previously expected. +- Fixed regression in `tcod.sys_get_current_resolution` behavior. This + function now returns the monitor resolution as was previously expected. ## [11.18.1] - 2020-11-30 + ### Fixed + - Code points from the Private Use Area will now print correctly. ## [11.18.0] - 2020-11-13 + ### Added + - New context method `Context.new_console`. ### Changed + - Using `libtcod 1.16.0-alpha.15`. ## [11.17.0] - 2020-10-30 + ### Added + - New FOV implementation: `tcod.FOV_SYMMETRIC_SHADOWCAST`. ### Changed + - Using `libtcod 1.16.0-alpha.14`. ## [11.16.1] - 2020-10-28 + ### Deprecated + - Changed context deprecations to PendingDeprecationWarning to reduce mass - panic from tutorial followers. + panic from tutorial followers. ### Fixed + - Fixed garbled titles and crashing on some platforms. ## [11.16.0] - 2020-10-23 + ### Added + - Added `tcod.context.new` function. - Contexts now support a CLI. - You can now provide the window x,y position when making contexts. - `tcod.noise.Noise` instances can now be indexed to generate noise maps. ### Changed + - Using `libtcod 1.16.0-alpha.13`. - The OpenGL 2 renderer can now use `SDL_HINT_RENDER_SCALE_QUALITY` to - determine the tileset upscaling filter. + determine the tileset upscaling filter. - Improved performance of the FOV_BASIC algorithm. ### Deprecated + - `tcod.context.new_window` and `tcod.context.new_terminal` have been replaced - by `tcod.context.new`. + by `tcod.context.new`. ### Fixed + - Pathfinders will now work with boolean arrays. - Console blits now ignore alpha compositing which would result in division by - zero. + zero. - `tcod.console_is_key_pressed` should work even if libtcod events are ignored. - The `TCOD_RENDERER` and `TCOD_VSYNC` environment variables should work now. - `FOV_PERMISSIVE` algorithm is now reentrant. ## [11.15.3] - 2020-07-30 + ### Fixed + - `tcod.tileset.Tileset.remap`, codepoint and index were swapped. ## [11.15.2] - 2020-07-27 + ### Fixed + - `tcod.path.dijkstra2d`, fixed corrupted output with int8 arrays. ## [11.15.1] - 2020-07-26 + ### Changed + - `tcod.event.EventDispatch` now uses the absolute names for event type hints - so that IDE's can better auto-complete method overrides. + so that IDE's can better auto-complete method overrides. ### Fixed + - Fixed libtcodpy heightmap data alignment issues on non-square maps. ## [11.15.0] - 2020-06-29 + ### Added + - `tcod.path.SimpleGraph` for pathfinding on simple 2D arrays. ### Changed + - `tcod.path.CustomGraph` now accepts an `order` parameter. ## [11.14.0] - 2020-06-23 + ### Added + - New `tcod.los` module for NumPy-based line-of-sight algorithms. - Includes `tcod.los.bresenham`. + Includes `tcod.los.bresenham`. ### Deprecated + - `tcod.line_where` and `tcod.line_iter` have been deprecated. ## [11.13.6] - 2020-06-19 + ### Deprecated + - `console_init_root` and `console_set_custom_font` have been replaced by the - modern API. + modern API. - All functions which handle SDL windows without a context are deprecated. - All functions which modify a globally active tileset are deprecated. - `tcod.map.Map` is deprecated, NumPy arrays should be passed to functions - directly instead of through this class. + directly instead of through this class. ## [11.13.5] - 2020-06-15 + ### Fixed + - Install requirements will no longer try to downgrade `cffi`. ## [11.13.4] - 2020-06-15 ## [11.13.3] - 2020-06-13 + ### Fixed + - `cffi` requirement has been updated to version `1.13.0`. - The older versions raise TypeError's. + The older versions raise TypeError's. ## [11.13.2] - 2020-06-12 + ### Fixed + - SDL related errors during package installation are now more readable. ## [11.13.1] - 2020-05-30 + ### Fixed + - `tcod.event.EventDispatch`: `ev_*` methods now allow `Optional[T]` return - types. + types. ## [11.13.0] - 2020-05-22 + ### Added + - `tcod.path`: New `Pathfinder` and `CustomGraph` classes. ### Changed + - Added `edge_map` parameter to `tcod.path.dijkstra2d` and - `tcod.path.hillclimb2d`. + `tcod.path.hillclimb2d`. ### Fixed + - tcod.console_init_root` and context initializing functions were not - raising exceptions on failure. + raising exceptions on failure. ## [11.12.1] - 2020-05-02 + ### Fixed + - Prevent adding non-existent 2nd halves to potential double-wide charterers. ## [11.12.0] - 2020-04-30 + ### Added -- Added `tcod.context` module. You now have more options for making libtcod - controlled contexts. + +- Added `tcod.context` module. You now have more options for making libtcod + controlled contexts. - `tcod.tileset.load_tilesheet`: Load a simple tilesheet as a Tileset. - `Tileset.remap`: Reassign codepoints to tiles on a Tileset. - `tcod.tileset.CHARMAP_CP437`: Character mapping for `load_tilesheet`. - `tcod.tileset.CHARMAP_TCOD`: Older libtcod layout. ### Changed + - `EventDispatch.dispatch` can now return the values returned by the `ev_*` - methods. The class is now generic to support type checking these values. + methods. The class is now generic to support type checking these values. - Event mouse coordinates are now strictly int types. - Submodules are now implicitly imported. ## [11.11.4] - 2020-04-26 + ### Changed + - Using `libtcod 1.16.0-alpha.10`. ### Fixed + - Fixed characters being dropped when color codes were used. ## [11.11.3] - 2020-04-24 + ### Changed + - Using `libtcod 1.16.0-alpha.9`. ### Fixed + - `FOV_DIAMOND` and `FOV_RESTRICTIVE` algorithms are now reentrant. - [libtcod#48](https://github.com/libtcod/libtcod/pull/48) + [libtcod#48](https://github.com/libtcod/libtcod/pull/48) - The `TCOD_VSYNC` environment variable was being ignored. ## [11.11.2] - 2020-04-22 ## [11.11.1] - 2020-04-03 + ### Changed + - Using `libtcod 1.16.0-alpha.8`. ### Fixed + - Changing the active tileset now redraws tiles correctly on the next frame. ## [11.11.0] - 2020-04-02 + ### Added + - Added `Console.close` as a more obvious way to close the active window of a - root console. + root console. ### Changed + - GCC is no longer needed to compile the library on Windows. - Using `libtcod 1.16.0-alpha.7`. - `tcod.console_flush` will now accept an RGB tuple as a `clear_color`. ### Fixed + - Changing the active tileset will now properly show it on the next render. ## [11.10.0] - 2020-03-26 + ### Added + - Added `tcod.tileset.load_bdf`, you can now load BDF fonts. - `tcod.tileset.set_default` and `tcod.tileset.get_default` are now stable. ### Changed + - Using `libtcod 1.16.0-alpha.6`. ### Deprecated + - The `snap_to_integer` parameter in `tcod.console_flush` has been deprecated - since it can cause minor scaling issues which don't exist when using - `integer_scaling` instead. + since it can cause minor scaling issues which don't exist when using + `integer_scaling` instead. ## [11.9.2] - 2020-03-17 + ### Fixed + - Fixed segfault after the Tileset returned by `tcod.tileset.get_default` goes - out of scope. + out of scope. ## [11.9.1] - 2020-02-28 + ### Changed + - Using `libtcod 1.16.0-alpha.5`. - Mouse tile coordinates are now always zero before the first call to - `tcod.console_flush`. + `tcod.console_flush`. ## [11.9.0] - 2020-02-22 + ### Added + - New method `Tileset.render` renders an RGBA NumPy array from a tileset and - a console. + a console. ## [11.8.2] - 2020-02-22 + ### Fixed + - Prevent KeyError when representing unusual keyboard symbol constants. ## [11.8.1] - 2020-02-22 + ### Changed + - Using `libtcod 1.16.0-alpha.4`. ### Fixed + - Mouse tile coordinates are now correct on any resized window. ## [11.8.0] - 2020-02-21 + ### Added + - Added `tcod.console.recommended_size` for when you want to change your main - console size at runtime. + console size at runtime. - Added `Console.tiles_rgb` as a replacement for `Console.tiles2`. ### Changed + - Using `libtcod 1.16.0-alpha.3`. - Added parameters to `tcod.console_flush`, you can now manually provide a - console and adjust how it is presented. + console and adjust how it is presented. ### Deprecated + - `Console.tiles2` is deprecated in favour of `Console.tiles_rgb`. - `Console.buffer` is now deprecated in favour of `Console.tiles`, instead of - the other way around. + the other way around. ### Fixed + - Fixed keyboard state and mouse state functions losing state when events were - flushed. + flushed. ## [11.7.2] - 2020-02-16 + ### Fixed + - Fixed regression in `tcod.console_clear`. ## [11.7.1] - 2020-02-16 + ### Fixed + - Fixed regression in `Console.draw_frame`. - The wavelet noise generator now excludes -1.0f and 1.0f as return values. - Fixed console fading color regression. ## [11.7.0] - 2020-02-14 + ### Changed + - Using `libtcod 1.16.0-alpha.2`. - When a renderer fails to load it will now fallback to a different one. - The order is: OPENGL2 -> OPENGL -> SDL2. + The order is: OPENGL2 -> OPENGL -> SDL2. - The default renderer is now SDL2. - The SDL and OPENGL renderers are no longer deprecated, but they now point to - slightly different backward compatible implementations. + slightly different backward compatible implementations. ### Deprecated + - The use of `libtcod.cfg` and `terminal.png` is deprecated. ### Fixed + - `tcod.sys_update_char` now works with the newer renderers. - Fixed buffer overflow in name generator. - `tcod.image_from_console` now works with the newer renderers. - New renderers now auto-load fonts from `libtcod.cfg` or `terminal.png`. ## [11.6.0] - 2019-12-05 + ### Changed + - Console blit operations now perform per-cell alpha transparency. ## [11.5.1] - 2019-11-23 + ### Fixed + - Python 3.8 wheels failed to deploy. ## [11.5.0] - 2019-11-22 + ### Changed + - Quarter block elements are now rendered using Unicode instead of a custom - encoding. + encoding. ### Fixed + - `OPENGL` and `GLSL` renderers were not properly clearing space characters. ## [11.4.1] - 2019-10-15 + ### Added + - Uploaded Python 3.8 wheels to PyPI. ## [11.4.0] - 2019-09-20 + ### Added + - Added `__array_interface__` to the Image class. - Added `Console.draw_semigraphics` as a replacement for blit_2x functions. - `draw_semigraphics` can handle array-like objects. + `draw_semigraphics` can handle array-like objects. - `Image.from_array` class method creates an Image from an array-like object. - `tcod.image.load` loads a PNG file as an RGBA array. ### Changed + - `Console.tiles` is now named `Console.buffer`. ## [11.3.0] - 2019-09-06 + ### Added + - New attribute `Console.tiles2` is similar to `Console.tiles` but without an - alpha channel. + alpha channel. ## [11.2.2] - 2019-08-25 + ### Fixed + - Fixed a regression preventing PyInstaller distributions from loading SDL2. ## [11.2.1] - 2019-08-25 ## [11.2.0] - 2019-08-24 + ### Added + - `tcod.path.dijkstra2d`: Computes Dijkstra from an arbitrary initial state. - `tcod.path.hillclimb2d`: Returns a path from a distance array. - `tcod.path.maxarray`: Creates arrays filled with maximum finite values. ### Fixed + - Changing the tiles of an active tileset on OPENGL2 will no longer leave - temporary artifact tiles. + temporary artifact tiles. - It's now harder to accidentally import tcod's internal modules. ## [11.1.2] - 2019-08-02 + ### Changed + - Now bundles SDL 2.0.10 for Windows/MacOS. ### Fixed + - Can now parse SDL 2.0.10 headers during installation without crashing. ## [11.1.1] - 2019-08-01 + ### Deprecated + - Using an out-of-bounds index for field-of-view operations now raises a - warning, which will later become an error. + warning, which will later become an error. ### Fixed + - Changing the tiles of an active tileset will now work correctly. ## [11.1.0] - 2019-07-05 + ### Added + - You can now set the `TCOD_RENDERER` and `TCOD_VSYNC` environment variables to - force specific options to be used. - Example: ``TCOD_RENDERER=sdl2 TCOD_VSYNC=1`` + force specific options to be used. + Example: `TCOD_RENDERER=sdl2 TCOD_VSYNC=1` ### Changed + - `tcod.sys_set_renderer` now raises an exception if it fails. ### Fixed + - `tcod.console_map_ascii_code_to_font` functions will now work when called - before `tcod.console_init_root`. + before `tcod.console_init_root`. ## [11.0.2] - 2019-06-21 + ### Changed + - You no longer need OpenGL to build python-tcod. ## [11.0.1] - 2019-06-21 + ### Changed + - Better runtime checks for Windows dependencies should now give distinct - errors depending on if the issue is SDL2 or missing redistributables. + errors depending on if the issue is SDL2 or missing redistributables. ### Fixed + - Changed NumPy type hints from `np.array` to `np.ndarray` which should - resolve issues. + resolve issues. ## [11.0.0] - 2019-06-14 + ### Changed + - `tcod.map.compute_fov` now takes a 2-item tuple instead of separate `x` and - `y` parameters. This causes less confusion over how axes are aligned. + `y` parameters. This causes less confusion over how axes are aligned. ## [10.1.1] - 2019-06-02 + ### Changed + - Better string representations for `tcod.event.Event` subclasses. ### Fixed + - Fixed regressions in text alignment for non-rectangle print functions. ## [10.1.0] - 2019-05-24 + ### Added + - `tcod.console_init_root` now has an optional `vsync` parameter. ## [10.0.5] - 2019-05-17 + ### Fixed + - Fixed shader compilation issues in the OPENGL2 renderer. - Fallback fonts should fail less on Linux. ## [10.0.4] - 2019-05-17 + ### Changed + - Now depends on cffi 0.12 or later. ### Fixed + - `tcod.console_init_root` and `tcod.console_set_custom_font` will raise - exceptions instead of terminating. + exceptions instead of terminating. - Fixed issues preventing `tcod.event` from working on 32-bit Windows. ## [10.0.3] - 2019-05-10 + ### Fixed + - Corrected bounding box issues with the `Console.print_box` method. ## [10.0.2] - 2019-04-26 + ### Fixed + - Resolved Color warnings when importing tcod. - When compiling, fixed a name conflict with endianness macros on FreeBSD. ## [10.0.1] - 2019-04-19 + ### Fixed + - Fixed horizontal alignment for TrueType fonts. - Fixed taking screenshots with the older SDL renderer. ## [10.0.0] - 2019-03-29 + ### Added + - New `Console.tiles` array attribute. + ### Changed + - `Console.DTYPE` changed to add alpha to its color types. + ### Fixed + - Console printing was ignoring color codes at the beginning of a string. ## [9.3.0] - 2019-03-15 + ### Added + - The SDL2/OPENGL2 renderers can potentially use a fall-back font when none - are provided. + are provided. - New function `tcod.event.get_mouse_state`. - New function `tcod.map.compute_fov` lets you get a visibility array directly - from a transparency array. + from a transparency array. + ### Deprecated + - The following functions and classes have been deprecated. - - `tcod.Key` - - `tcod.Mouse` - - `tcod.mouse_get_status` - - `tcod.console_is_window_closed` - - `tcod.console_check_for_keypress` - - `tcod.console_wait_for_keypress` - - `tcod.console_delete` - - `tcod.sys_check_for_event` - - `tcod.sys_wait_for_event` + - `tcod.Key` + - `tcod.Mouse` + - `tcod.mouse_get_status` + - `tcod.console_is_window_closed` + - `tcod.console_check_for_keypress` + - `tcod.console_wait_for_keypress` + - `tcod.console_delete` + - `tcod.sys_check_for_event` + - `tcod.sys_wait_for_event` - The SDL, OPENGL, and GLSL renderers have been deprecated. - Many libtcodpy functions have been marked with PendingDeprecationWarning's. + ### Fixed + - To be more compatible with libtcodpy `tcod.console_init_root` will default - to the SDL render, but will raise warnings when an old renderer is used. + to the SDL render, but will raise warnings when an old renderer is used. ## [9.2.5] - 2019-03-04 + ### Fixed + - Fixed `tcod.namegen_generate_custom`. ## [9.2.4] - 2019-03-02 + ### Fixed + - The `tcod` package is has been marked as typed and will now work with MyPy. ## [9.2.3] - 2019-03-01 + ### Deprecated + - The behavior for negative indexes on the new print functions may change in - the future. + the future. - Methods and functionality preventing `tcod.Color` from behaving like a tuple - have been deprecated. + have been deprecated. ## [9.2.2] - 2019-02-26 + ### Fixed + - `Console.print_box` wasn't setting the background color by default. ## [9.2.1] - 2019-02-25 + ### Fixed + - `tcod.sys_get_char_size` fixed on the new renderers. ## [9.2.0] - 2019-02-24 + ### Added + - New `tcod.console.get_height_rect` function, which can be used to get the - height of a print call without an existing console. + height of a print call without an existing console. - New `tcod.tileset` module, with a `set_truetype_font` function. + ### Fixed + - The new print methods now handle alignment according to how they were - documented. + documented. - `SDL2` and `OPENGL2` now support screenshots. - Windows and MacOS builds now restrict exported SDL2 symbols to only - SDL 2.0.5; This will avoid hard to debug import errors when the wrong - version of SDL is dynamically linked. + SDL 2.0.5; This will avoid hard to debug import errors when the wrong + version of SDL is dynamically linked. - The root console now starts with a white foreground. ## [9.1.0] - 2019-02-23 + ### Added + - Added the `tcod.random.MULTIPLY_WITH_CARRY` constant. + ### Changed + - The overhead for warnings has been reduced when running Python with the - optimize `-O` flag. + optimize `-O` flag. - `tcod.random.Random` now provides a default algorithm. ## [9.0.0] - 2019-02-17 + ### Changed + - New console methods now default to an `fg` and `bg` of None instead of - white-on-black. + white-on-black. ## [8.5.0] - 2019-02-15 + ### Added + - `tcod.console.Console` now supports `str` and `repr`. - Added new Console methods which are independent from the console defaults. - You can now give an array when initializing a `tcod.console.Console` - instance. + instance. - `Console.clear` can now take `ch`, `fg`, and `bg` parameters. + ### Changed + - Updated libtcod to 1.10.6 - Printing generates more compact layouts. + ### Deprecated + - Most libtcodpy console functions have been replaced by the tcod.console - module. -- Deprecated the `set_key_color` functions. You can pass key colors to - `Console.blit` instead. + module. +- Deprecated the `set_key_color` functions. You can pass key colors to + `Console.blit` instead. - `Console.clear` should be given the colors to clear with as parameters, - rather than by using `default_fg` or `default_bg`. + rather than by using `default_fg` or `default_bg`. - Most functions which depend on console default values have been deprecated. - The new deprecation warnings will give details on how to make default values - explicit. + The new deprecation warnings will give details on how to make default values + explicit. + ### Fixed + - `tcod.console.Console.blit` was ignoring the key color set by - `Console.set_key_color`. + `Console.set_key_color`. - The `SDL2` and `OPENGL2` renders can now large numbers of tiles. ## [8.4.3] - 2019-02-06 + ### Changed + - Updated libtcod to 1.10.5 - The SDL2/OPENGL2 renderers will now auto-detect a custom fonts key-color. ## [8.4.2] - 2019-02-05 + ### Deprecated + - The tdl module has been deprecated. - The libtcodpy parser functions have been deprecated. + ### Fixed + - `tcod.image_is_pixel_transparent` and `tcod.image_get_alpha` now return - values. + values. - `Console.print_frame` was clearing tiles outside if its bounds. - The `FONT_LAYOUT_CP437` layout was incorrect. ## [8.4.1] - 2019-02-01 + ### Fixed + - Window event types were not upper-case. - Fixed regression where libtcodpy mouse wheel events unset mouse coordinates. ## [8.4.0] - 2019-01-31 + ### Added + - Added tcod.event module, based off of the sdlevent.py shim. + ### Changed + - Updated libtcod to 1.10.3 + ### Fixed + - Fixed libtcodpy `struct_add_value_list` function. - Use correct math for tile-based delta in mouse events. - New renderers now support tile-based mouse coordinates. - SDL2 renderer will now properly refresh after the window is resized. ## [8.3.2] - 2018-12-28 + ### Fixed + - Fixed rare access violations for some functions which took strings as - parameters, such as `tcod.console_init_root`. + parameters, such as `tcod.console_init_root`. ## [8.3.1] - 2018-12-28 + ### Fixed + - libtcodpy key and mouse functions will no longer accept the wrong types. - The `new_struct` method was not being called for libtcodpy's custom parsers. ## [8.3.0] - 2018-12-08 + ### Added + - Added BSP traversal methods in tcod.bsp for parity with libtcodpy. + ### Deprecated + - Already deprecated bsp functions are now even more deprecated. ## [8.2.0] - 2018-11-27 + ### Added + - New layout `tcod.FONT_LAYOUT_CP437`. + ### Changed + - Updated libtcod to 1.10.2 - `tcod.console_print_frame` and `Console.print_frame` now support Unicode - strings. + strings. + ### Deprecated + - Deprecated using bytes strings for all printing functions. + ### Fixed + - Console objects are now initialized with spaces. This fixes some blit - operations. + operations. - Unicode code-points above U+FFFF will now work on all platforms. ## [8.1.1] - 2018-11-16 + ### Fixed + - Printing a frame with an empty string no longer displays a title bar. ## [8.1.0] - 2018-11-15 + ### Changed + - Heightmap functions now support 'F_CONTIGUOUS' arrays. - `tcod.heightmap_new` now has an `order` parameter. - Updated SDL to 2.0.9 + ### Deprecated + - Deprecated heightmap functions which sample noise grids, this can be done - using the `Noise.sample_ogrid` method. + using the `Noise.sample_ogrid` method. ## [8.0.0] - 2018-11-02 + ### Changed + - The default renderer can now be anything if not set manually. - Better error message for when a font file isn't found. ## [7.0.1] - 2018-10-27 + ### Fixed + - Building from source was failing because `console_2tris.glsl*` was missing - from source distributions. + from source distributions. ## [7.0.0] - 2018-10-25 + ### Added + - New `RENDERER_SDL2` and `RENDERER_OPENGL2` renderers. + ### Changed + - Updated libtcod to 1.9.0 - Now requires SDL 2.0.5, which is not trivially installable on - Ubuntu 16.04 LTS. + Ubuntu 16.04 LTS. + ### Removed + - Dropped support for Python versions before 3.5 - Dropped support for MacOS versions before 10.9 Mavericks. ## [6.0.7] - 2018-10-24 + ### Fixed + - The root console no longer loses track of buffers and console defaults on a - renderer change. + renderer change. ## [6.0.6] - 2018-10-01 + ### Fixed + - Replaced missing wheels for older and 32-bit versions of MacOS. ## [6.0.5] - 2018-09-28 + ### Fixed + - Resolved CDefError error during source installs. ## [6.0.4] - 2018-09-11 + ### Fixed + - tcod.Key right-hand modifiers are now set independently at initialization, - instead of mirroring the left-hand modifier value. + instead of mirroring the left-hand modifier value. ## [6.0.3] - 2018-09-05 + ### Fixed + - tcod.Key and tcod.Mouse no longer ignore initiation parameters. ## [6.0.2] - 2018-08-28 + ### Fixed + - Fixed color constants missing at build-time. ## [6.0.1] - 2018-08-24 + ### Fixed + - Source distributions were missing C++ source files. ## [6.0.0] - 2018-08-23 + ### Changed + - Project renamed to tcod on PyPI. + ### Deprecated + - Passing bytes strings to libtcodpy print functions is deprecated. + ### Fixed + - Fixed libtcodpy print functions not accepting bytes strings. - libtcod constants are now generated at build-time fixing static analysis - tools. + tools. ## [5.0.1] - 2018-07-08 + ### Fixed + - tdl.event no longer crashes with StopIteration on Python 3.7 ## [5.0.0] - 2018-07-05 + ### Changed + - tcod.path: all classes now use `shape` instead of `width` and `height`. - tcod.path now respects NumPy array shape, instead of assuming that arrays - need to be transposed from C memory order. From now on `x` and `y` mean - 1st and 2nd axis. This doesn't affect non-NumPy code. + need to be transposed from C memory order. From now on `x` and `y` mean + 1st and 2nd axis. This doesn't affect non-NumPy code. - tcod.path now has full support of non-contiguous memory. ## [4.6.1] - 2018-06-30 + ### Added + - New function `tcod.line_where` for indexing NumPy arrays using a Bresenham - line. + line. + ### Deprecated + - Python 2.7 support will be dropped in the near future. ## [4.5.2] - 2018-06-29 + ### Added + - New wheels for Python3.7 on Windows. + ### Fixed + - Arrays from `tcod.heightmap_new` are now properly zeroed out. ## [4.5.1] - 2018-06-23 + ### Deprecated + - Deprecated all libtcodpy map functions. + ### Fixed + - `tcod.map_copy` could break the `tcod.map.Map` class. - `tcod.map_clear` `transparent` and `walkable` parameters were reversed. - When multiple SDL2 headers were installed, the wrong ones would be used when - the library is built. + the library is built. - Fails to build via pip unless Numpy is installed first. ## [4.5.0] - 2018-06-12 + ### Changed + - Updated libtcod to v1.7.0 - Updated SDL to v2.0.8 - Error messages when failing to create an SDL window should be a less vague. - You no longer need to initialize libtcod before you can print to an - off-screen console. + off-screen console. + ### Fixed + - Avoid crashes if the root console has a character code higher than expected. + ### Removed + - No more debug output when loading fonts. ## [4.4.0] - 2018-05-02 + ### Added -- Added the libtcodpy module as an alias for tcod. Actual use of it is - deprecated, it exists primarily for backward compatibility. + +- Added the libtcodpy module as an alias for tcod. Actual use of it is + deprecated, it exists primarily for backward compatibility. - Adding missing libtcodpy functions `console_has_mouse_focus` and - `console_is_active`. + `console_is_active`. + ### Changed + - Updated libtcod to v1.6.6 ## [4.3.2] - 2018-03-18 + ### Deprecated + - Deprecated the use of falsy console parameters with libtcodpy functions. + ### Fixed + - Fixed libtcodpy image functions not supporting falsy console parameters. - Fixed tdl `Window.get_char` method. (Kaczor2704) ## [4.3.1] - 2018-03-07 + ### Fixed + - Fixed cffi.api.FFIError "unsupported expression: expected a simple numeric - constant" error when building on platforms with an older cffi module and - newer SDL headers. + constant" error when building on platforms with an older cffi module and + newer SDL headers. - tcod/tdl Map and Console objects were not saving stride data when pickled. ## [4.3.0] - 2018-02-01 + ### Added + - You can now set the numpy memory order on tcod.console.Console, - tcod.map.Map, and tdl.map.Map objects well as from the - tcod.console_init_root function. + tcod.map.Map, and tdl.map.Map objects well as from the + tcod.console_init_root function. + ### Changed + - The `console_init_root` `title` parameter is now optional. + ### Fixed + - OpenGL renderer alpha blending is now consistent with all other render - modes. + modes. ## [4.2.3] - 2018-01-06 + ### Fixed + - Fixed setup.py regression that could prevent building outside of the git - repository. + repository. ## [4.2.2] - 2018-01-06 + ### Fixed + - The Windows dynamic linker will now prefer the bundled version of SDL. - This fixes: - "ImportError: DLL load failed: The specified procedure could not be found." + This fixes: + "ImportError: DLL load failed: The specified procedure could not be found." - `key.c` is no longer set when `key.vk == KEY_TEXT`, this fixes a regression - which was causing events to be heard twice in the libtcod/Python tutorial. + which was causing events to be heard twice in the libtcod/Python tutorial. ## [4.2.0] - 2018-01-02 + ### Changed + - Updated libtcod backend to v1.6.4 - Updated SDL to v2.0.7 for Windows/MacOS. + ### Removed + - Source distributions no longer include tests, examples, or fonts. - [Find these on GitHub.](https://github.com/libtcod/python-tcod) + [Find these on GitHub.](https://github.com/libtcod/python-tcod) + ### Fixed + - Fixed "final link failed: Nonrepresentable section on output" error - when compiling for Linux. + when compiling for Linux. - `tcod.console_init_root` defaults to the SDL renderer, other renderers - cause issues with mouse movement events. + cause issues with mouse movement events. ## [4.1.1] - 2017-11-02 + ### Fixed + - Fixed `ConsoleBuffer.blit` regression. - Console defaults corrected, the root console's blend mode and alignment is - the default value for newly made Console's. + the default value for newly made Console's. - You can give a byte string as a filename to load parsers. ## [4.1.0] - 2017-07-19 + ### Added + - tdl Map class can now be pickled. + ### Changed + - Added protection to the `transparent`, `walkable`, and `fov` - attributes in tcod and tdl Map classes, to prevent them from being - accidentally overridden. + attributes in tcod and tdl Map classes, to prevent them from being + accidentally overridden. - tcod and tdl Map classes now use numpy arrays as their attributes. ## [4.0.1] - 2017-07-12 + ### Fixed + - tdl: Fixed NameError in `set_fps`. ## [4.0.0] - 2017-07-08 + ### Changed + - tcod.bsp: `BSP.split_recursive` parameter `random` is now `seed`. - tcod.console: `Console.blit` parameters have been rearranged. - Most of the parameters are now optional. + Most of the parameters are now optional. - tcod.noise: `Noise.__init__` parameter `rand` is now named `seed`. - tdl: Changed `set_fps` parameter name to `fps`. + ### Fixed + - tcod.bsp: Corrected spelling of max_vertical_ratio. ## [3.2.0] - 2017-07-04 + ### Changed + - Merged libtcod-cffi dependency with TDL. + ### Fixed + - Fixed boolean related crashes with Key 'text' events. -- tdl.noise: Fixed crash when given a negative seed. As well as cases - where an instance could lose its seed being pickled. +- tdl.noise: Fixed crash when given a negative seed. As well as cases + where an instance could lose its seed being pickled. ## [3.1.0] - 2017-05-28 + ### Added + - You can now pass tdl Console instances as parameters to libtcod-cffi - functions expecting a tcod Console. + functions expecting a tcod Console. + ### Changed + - Dependencies updated: `libtcod-cffi>=2.5.0,<3` - The `Console.tcod_console` attribute is being renamed to - `Console.console_c`. + `Console.console_c`. + ### Deprecated + - The tdl.noise and tdl.map modules will be deprecated in the future. + ### Fixed + - Resolved crash-on-exit issues for Windows platforms. ## [3.0.2] - 2017-04-13 + ### Changed + - Dependencies updated: `libtcod-cffi>=2.4.3,<3` - You can now create Console instances before a call to `tdl.init`. + ### Removed + - Dropped support for Python 3.3 + ### Fixed + - Resolved issues with MacOS builds. - 'OpenGL' and 'GLSL' renderers work again. ## [3.0.1] - 2017-03-22 + ### Changed + - `KeyEvent`'s with `text` now have all their modifier keys set to False. + ### Fixed + - Undefined behavior in text events caused crashes on 32-bit builds. ## [3.0.0] - 2017-03-21 + ### Added + - `KeyEvent` supports libtcod text and meta keys. + ### Changed + - `KeyEvent` parameters have been moved. - This version requires `libtcod-cffi>=2.3.0`. + ### Deprecated + - `KeyEvent` camel capped attribute names are deprecated. + ### Fixed + - Crashes with key-codes undefined by libtcod. - `tdl.map` typedef issues with libtcod-cffi. - ## [2.0.1] - 2017-02-22 + ### Fixed + - `tdl.init` renderer was defaulted to OpenGL which is not supported in the - current version of libtcod. + current version of libtcod. ## [2.0.0] - 2017-02-15 + ### Changed + - Dependencies updated, tdl now requires libtcod-cffi 2.x.x - Some event behaviors have changed with SDL2, event keys might be different - than what you expect. + than what you expect. + ### Removed + - Key repeat functions were removed from SDL2. - `set_key_repeat` is now stubbed, and does nothing. + `set_key_repeat` is now stubbed, and does nothing. ## [1.6.0] - 2016-11-18 + - Console.blit methods can now take fg_alpha and bg_alpha parameters. ## [1.5.3] - 2016-06-04 + - set_font no longer crashes when loading a file without the implied font size in its name ## [1.5.2] - 2016-03-11 + - Fixed non-square Map instances ## [1.5.1] - 2015-12-20 + - Fixed errors with Unicode and non-Unicode literals on Python 2 - Fixed attribute error in compute_fov ## [1.5.0] - 2015-07-13 + - python-tdl distributions are now universal builds - New Map class - map.bresenham now returns a list - This release will require libtcod-cffi v0.2.3 or later ## [1.4.0] - 2015-06-22 + - The DLL's have been moved into another library which you can find at https://github.com/HexDecimal/libtcod-cffi You can use this library to have some raw access to libtcod if you want. Plus it can be used alongside TDL. - The libtcod console objects in Console instances have been made public. -- Added tdl.event.wait function. This function can called with a timeout and +- Added tdl.event.wait function. This function can called with a timeout and can automatically call tdl.flush. ## [1.3.1] - 2015-06-19 + - Fixed pathfinding regressions. ## [1.3.0] - 2015-06-19 -- Updated backend to use python-cffi instead of ctypes. This gives decent + +- Updated backend to use python-cffi instead of ctypes. This gives decent boost to speed in CPython and a drastic to boost in speed in PyPy. ## [1.2.0] - 2015-06-06 -- The set_colors method now changes the default colors used by the draw_* - methods. You can use Python's Ellipsis to explicitly select default colors + +- The set*colors method now changes the default colors used by the draw*\* + methods. You can use Python's Ellipsis to explicitly select default colors this way. - Functions and Methods renamed to match Python's style-guide PEP 8, the old function names still exist and are depreciated. - The fgcolor and bgcolor parameters have been shortened to fg and bg. ## [1.1.7] - 2015-03-19 + - Noise generator now seeds properly. - The OS event queue will now be handled during a call to tdl.flush. This prevents a common newbie programmer hang where events are handled @@ -1273,11 +1861,13 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - Fixed a major bug that would cause a crash in later versions of Python 3 ## [1.1.6] - 2014-06-27 + - Fixed a race condition when importing on some platforms. - Fixed a type issue with quickFOV on Linux. - Added a bresenham function to the tdl.map module. ## [1.1.5] - 2013-11-10 + - A for loop can iterate over all coordinates of a Console. - drawStr can be configured to scroll or raise an error. - You can now configure or disable key repeating with tdl.event.setKeyRepeat @@ -1285,6 +1875,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - setColors method fixed. ## [1.1.4] - 2013-03-06 + - Merged the Typewriter and MetaConsole classes, You now have a virtual cursor with Console and Window objects. - Fixed the clear method on the Window class. @@ -1295,11 +1886,13 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - Fixed event.keyWait, and now converts window closed events into Alt+F4. ## [1.1.3] - 2012-12-17 + - Some of the setFont parameters were incorrectly labeled and documented. - setFont can auto-detect tilesets if the font sizes are in the filenames. - Added some X11 unicode tilesets, including Unifont. ## [1.1.2] - 2012-12-13 + - Window title now defaults to the running scripts filename. - Fixed incorrect deltaTime for App.update - App will no longer call tdl.flush on its own, you'll need to call this @@ -1308,6 +1901,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - clear method now defaults to black on black. ## [1.1.1] - 2012-12-05 + - Map submodule added with AStar class and quickFOV function. - New Typewriter class. - Most console functions can use Python-style negative indexes now. @@ -1315,6 +1909,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - Rectangle geometry is less strict. ## [1.1.0] - 2012-10-04 + - KeyEvent.keyname is now KeyEvent.key - MouseButtonEvent.button now behaves like KeyEvent.keyname does. - event.App class added. @@ -1322,20 +1917,24 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - KeyEvent.ctrl is now KeyEvent.control ## [1.0.8] - 2010-04-07 + - No longer works in Python 2.5 but now works in 3.x and has been partly tested. - Many bug fixes. ## [1.0.5] - 2010-04-06 + - Got rid of setuptools dependency, this will make it much more compatible with Python 3.x - Fixed a typo with the MacOS library import. ## [1.0.4] - 2010-04-06 -- All constant colors (C_*) have been removed, they may be put back in later. + +- All constant colors (C\_\*) have been removed, they may be put back in later. - Made some type assertion failures show the value they received to help in - general debugging. Still working on it. + general debugging. Still working on it. - Added MacOS and 64-bit Linux support. ## [1.0.0] - 2009-01-31 + - First public release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4f7d5ea..846d492c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ pre-commit install ## Building python-tcod To work with the tcod source, your environment must be set up to build -Python C extensions. You'll also need `cpp` installed for +Python C extensions. You'll also need `cpp` installed for use with pycparser. ### Windows diff --git a/LICENSE.txt b/LICENSE.txt index d91bf759..ed980457 100755 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2009-2023, Kyle Benesch and the python-tcod contributors. +Copyright (c) 2009-2025, Kyle Benesch and the python-tcod contributors. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.rst b/README.rst index f84370ee..515c90a7 100755 --- a/README.rst +++ b/README.rst @@ -46,7 +46,7 @@ For the most part it's just:: ============== Requirements ============== -* Python 3.7+ +* Python 3.10+ * Windows, Linux, or MacOS X 10.9+. * On Linux, requires libsdl2 (2.0.10+). diff --git a/build_libtcod.py b/build_libtcod.py index b912ecc1..974904cc 100755 --- a/build_libtcod.py +++ b/build_libtcod.py @@ -1,5 +1,6 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Parse and compile libtcod and SDL sources for CFFI.""" + from __future__ import annotations import contextlib @@ -8,16 +9,19 @@ import platform import re import sys +from collections.abc import Iterable, Iterator from pathlib import Path -from typing import Any, Iterable, Iterator +from typing import Any, ClassVar from cffi import FFI +# ruff: noqa: T201 + sys.path.append(str(Path(__file__).parent)) # Allow importing local modules. -import build_sdl # noqa: E402 +import build_sdl -Py_LIMITED_API = 0x03060000 +Py_LIMITED_API = 0x03100000 HEADER_PARSE_PATHS = ("tcod/", "libtcod/src/libtcod/") HEADER_PARSE_EXCLUDES = ("gl2_ext_.h", "renderer_gl_internal.h", "event.h") @@ -45,18 +49,18 @@ class ParsedHeader: """ # Class dictionary of all parsed headers. - all_headers: dict[Path, ParsedHeader] = {} + all_headers: ClassVar[dict[Path, ParsedHeader]] = {} def __init__(self, path: Path) -> None: """Initialize and organize a header file.""" - self.path = path = path.resolve(True) + self.path = path = path.resolve(strict=True) directory = path.parent depends = set() header = self.path.read_text(encoding="utf-8") header = RE_COMMENT.sub("", header) header = RE_CPLUSPLUS.sub("", header) for dependency in RE_INCLUDE.findall(header): - depends.add((directory / str(dependency)).resolve(True)) + depends.add((directory / str(dependency)).resolve(strict=True)) header = RE_PREPROCESSOR.sub("", header) header = RE_TAGS.sub("", header) header = RE_VAFUNC.sub("", header) @@ -90,7 +94,7 @@ def walk_includes(directory: str) -> Iterator[ParsedHeader]: if file in HEADER_PARSE_EXCLUDES: continue if file.endswith(".h"): - yield ParsedHeader(Path(path, file).resolve(True)) + yield ParsedHeader(Path(path, file).resolve(strict=True)) def resolve_dependencies( @@ -232,6 +236,7 @@ def walk_sources(directory: str) -> Iterator[str]: This module is auto-generated by `build_libtcod.py`. """ + from tcod.color import Color ''' @@ -365,7 +370,7 @@ def write_library_constants() -> None: f.write(f"{name[5:]} = {color!r}\n") all_names_merged = ",\n ".join(f'"{name}"' for name in all_names) - f.write(f"\n__all__ = [\n {all_names_merged},\n]\n") + f.write(f"\n__all__ = [ # noqa: RUF022\n {all_names_merged},\n]\n") update_module_all(Path("tcod/libtcodpy.py"), all_names_merged) with Path("tcod/event_constants.py").open("w", encoding="utf-8") as f: @@ -378,12 +383,12 @@ def write_library_constants() -> None: f.write(f"""{parse_sdl_attrs("SDLK", None)[0]}\n""") f.write("\n# --- SDL keyboard modifiers ---\n") - f.write("{}\n_REVERSE_MOD_TABLE = {}\n".format(*parse_sdl_attrs("KMOD", all_names))) + f.write("{}\n_REVERSE_MOD_TABLE = {}\n".format(*parse_sdl_attrs("KMOD", None))) f.write("\n# --- SDL wheel ---\n") f.write("{}\n_REVERSE_WHEEL_TABLE = {}\n".format(*parse_sdl_attrs("SDL_MOUSEWHEEL", all_names))) all_names_merged = ",\n ".join(f'"{name}"' for name in all_names) - f.write(f"\n__all__ = [\n {all_names_merged},\n]\n") + f.write(f"\n__all__ = [ # noqa: RUF022\n {all_names_merged},\n]\n") event_py = Path("tcod/event.py").read_text(encoding="utf-8") diff --git a/build_sdl.py b/build_sdl.py index ef3dd4be..e395c4d4 100755 --- a/build_sdl.py +++ b/build_sdl.py @@ -1,5 +1,6 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Build script to parse SDL headers and generate CFFI bindings.""" + from __future__ import annotations import io @@ -13,10 +14,11 @@ from pathlib import Path from typing import Any -import pcpp # type: ignore +import pcpp # type: ignore[import-untyped] import requests -# ruff: noqa: S603, S607 # This script calls a lot of programs. +# This script calls a lot of programs. +# ruff: noqa: S603, S607, T201 BIT_SIZE, LINKAGE = platform.architecture() @@ -25,13 +27,13 @@ # The SDL2 version to parse and export symbols from. SDL2_PARSE_VERSION = os.environ.get("SDL_VERSION", "2.0.20") # The SDL2 version to include in binary distributions. -SDL2_BUNDLE_VERSION = os.environ.get("SDL_VERSION", "2.26.0") +SDL2_BUNDLE_VERSION = os.environ.get("SDL_VERSION", "2.28.1") # Used to remove excessive newlines in debug outputs. RE_NEWLINES = re.compile(r"\n\n+") # Functions using va_list need to be culled. -RE_VAFUNC = re.compile(r"^.*?\([^()]*va_list[^()]*\);$", re.MULTILINE) +RE_VAFUNC = re.compile(r"^.*?\([^()]*va_list[^()]*\)\s*;\s*$", re.MULTILINE) # Static inline functions need to be culled. RE_INLINE = re.compile(r"^static inline.*?^}$", re.MULTILINE | re.DOTALL) # Most SDL_PIXELFORMAT names need their values scrubbed. @@ -58,6 +60,9 @@ "SDL_INLINE", "SDL_FORCE_INLINE", "SDL_FALLTHROUGH", + "SDL_HAS_FALLTHROUGH", + "SDL_NO_THREAD_SAFETY_ANALYSIS", + "SDL_SCOPED_CAPABILITY", # Might show up in parsing and not in source. "SDL_ANDROID_EXTERNAL_STORAGE_READ", "SDL_ANDROID_EXTERNAL_STORAGE_WRITE", @@ -136,7 +141,7 @@ def unpack_sdl2(version: str) -> Path: return sdl2_path -class SDLParser(pcpp.Preprocessor): # type: ignore +class SDLParser(pcpp.Preprocessor): # type: ignore[misc] """A modified preprocessor to output code in a format for CFFI.""" def __init__(self) -> None: @@ -154,7 +159,7 @@ def get_output(self) -> str: buffer.write(f"#define {name} ...\n") return buffer.getvalue() - def on_include_not_found(self, is_malformed: bool, is_system_include: bool, curdir: str, includepath: str) -> None: + def on_include_not_found(self, is_malformed: bool, is_system_include: bool, curdir: str, includepath: str) -> None: # noqa: ARG002, FBT001 """Remove bad includes such as stddef.h and stdarg.h.""" raise pcpp.OutputDirective(pcpp.Action.IgnoreAndRemove) @@ -182,7 +187,11 @@ def _should_track_define(self, tokens: list[Any]) -> bool: ) def on_directive_handle( - self, directive: Any, tokens: list[Any], if_passthru: bool, preceding_tokens: list[Any] # noqa: ANN401 + self, + directive: Any, # noqa: ANN401 + tokens: list[Any], + if_passthru: bool, # noqa: FBT001 + preceding_tokens: list[Any], ) -> Any: # noqa: ANN401 """Catch and store definitions.""" if directive.value == "define" and self._should_track_define(tokens): @@ -195,7 +204,7 @@ def on_directive_handle( check_sdl_version() -if sys.platform in ["win32", "darwin"]: +if sys.platform == "win32" or sys.platform == "darwin": SDL2_PARSE_PATH = unpack_sdl2(SDL2_PARSE_VERSION) SDL2_BUNDLE_PATH = unpack_sdl2(SDL2_BUNDLE_VERSION) diff --git a/docs/conf.py b/docs/conf.py index 2fece512..0fda406a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,6 +25,10 @@ sys.path.insert(0, str(Path("..").resolve(strict=True))) +THIS_DIR = Path(__file__).parent + +# ruff: noqa: ERA001 + # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. @@ -61,7 +65,7 @@ # General information about the project. project = "python-tcod" -copyright = "2009-2023, Kyle Benesch" +copyright = "2009-2025, Kyle Benesch" author = "Kyle Benesch" # The version info for the project you're documenting, acts as replacement for @@ -70,14 +74,14 @@ # # The full version, including alpha/beta/rc tags. git_describe = subprocess.run( - ["git", "describe", "--abbrev=0"], # noqa: S603, S607 + ["git", "describe", "--abbrev=0"], # noqa: S607 stdout=subprocess.PIPE, text=True, check=True, ) release = git_describe.stdout.strip() assert release -print("release version: %r" % release) +print(f"release version: {release!r}") # The short X.Y version. match_version = re.match(r"([0-9]+\.[0-9]+).*?", release) @@ -103,7 +107,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "/epilog.rst"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "epilog.rst", "prolog.rst"] # The reST default role (used for this markup: `text`) to use for all # documents. @@ -378,13 +382,14 @@ napoleon_use_param = True napoleon_use_rtype = True -rst_prolog = ".. include:: /prolog.rst" # Added to the beginning of every source file. -rst_epilog = ".. include:: /epilog.rst" # Added to the end of every source file. +rst_prolog = (THIS_DIR / "prolog.rst").read_text(encoding="utf-8") # Added to the beginning of every source file. +rst_epilog = (THIS_DIR / "epilog.rst").read_text(encoding="utf-8") # Added to the end of every source file. # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), "numpy": ("https://numpy.org/doc/stable/", None), + "tcod-ecs": ("https://python-tcod-ecs.readthedocs.io/en/latest/", None), } os.environ["READTHEDOCS"] = "True" diff --git a/docs/epilog.rst b/docs/epilog.rst index e69de29b..c3ee806c 100644 --- a/docs/epilog.rst +++ b/docs/epilog.rst @@ -0,0 +1,3 @@ + +.. _tcod-ecs: https://github.com/HexDecimal/python-tcod-ecs +.. _Flecs: https://github.com/SanderMertens/flecs diff --git a/docs/index.rst b/docs/index.rst index 50a87f4a..afc353a7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,10 +22,9 @@ Contents: .. toctree:: :maxdepth: 2 - :caption: Tutorial - :glob: + :caption: How To - tutorial/part-* + tutorial/index .. toctree:: :maxdepth: 2 diff --git a/docs/tcod/getting-started.rst b/docs/tcod/getting-started.rst index c7634149..add5d9f0 100644 --- a/docs/tcod/getting-started.rst +++ b/docs/tcod/getting-started.rst @@ -19,9 +19,9 @@ console will be stretched to fit the window. You can add arguments to :any:`Context.present` to fix the aspect ratio or only scale the console by integer increments. -Example:: +.. code-block:: python - #!/usr/bin/env python3 + #!/usr/bin/env python # Make sure 'dejavu10x10_gs_tc.png' is in the same directory as this script. import tcod.console import tcod.context @@ -38,14 +38,14 @@ Example:: "dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD, ) # Create the main console. - console = tcod.console.Console(WIDTH, HEIGHT, order="F") + console = tcod.console.Console(WIDTH, HEIGHT) # Create a window based on this console and tileset. with tcod.context.new( # New window for a console of size columns×rows. columns=console.width, rows=console.height, tileset=tileset, ) as context: while True: # Main loop, runs until SystemExit is raised. console.clear() - console.print(x=0, y=0, string="Hello World!") + console.print(x=0, y=0, text="Hello World!") context.present(console) # Show the console. # This event loop will wait until at least one event is processed before exiting. @@ -53,8 +53,9 @@ Example:: for event in tcod.event.wait(): context.convert_event(event) # Sets tile coordinates for mouse events. print(event) # Print event names and attributes. - if isinstance(event, tcod.event.Quit): - raise SystemExit() + match event: + case tcod.event.Quit(): + raise SystemExit # The window will be closed after the above with-block exits. @@ -87,9 +88,9 @@ You can call :any:`Context.new_console` every frame or only when the window is resized. This example creates a new console every frame instead of clearing the console every frame and replacing it only on resizing the window. -Example:: +.. code-block:: python - #!/usr/bin/env python3 + #!/usr/bin/env python import tcod.context import tcod.event @@ -103,17 +104,18 @@ Example:: width=WIDTH, height=HEIGHT, sdl_window_flags=FLAGS ) as context: while True: - console = context.new_console(order="F") # Console size based on window resolution and tile size. + console = context.new_console() # Console size based on window resolution and tile size. console.print(0, 0, "Hello World") context.present(console, integer_scaling=True) for event in tcod.event.wait(): - context.convert_event(event) # Sets tile coordinates for mouse events. + event = context.convert_event(event) # Sets tile coordinates for mouse events. print(event) # Print event names and attributes. - if isinstance(event, tcod.event.Quit): - raise SystemExit() - elif isinstance(event, tcod.event.WindowResized) and event.type == "WindowSizeChanged": - pass # The next call to context.new_console may return a different size. + match event: + case tcod.event.Quit(): + raise SystemExit + case tcod.event.WindowResized(type="WindowSizeChanged"): + pass # The next call to context.new_console may return a different size. if __name__ == "__main__": diff --git a/docs/tutorial/index.rst b/docs/tutorial/index.rst new file mode 100644 index 00000000..b43a8971 --- /dev/null +++ b/docs/tutorial/index.rst @@ -0,0 +1,16 @@ +Tutorial +############################################################################## + +.. include:: notice.rst + +.. note:: + This a Python tutorial reliant on a Modern ECS implementation. + In this case `tcod-ecs`_ will be used. + Most other Python ECS libraries do not support entity relationships and arbitrary tags required by this tutorial. + If you wish to use this tutorial with another language you may need a Modern ECS implementation on par with `Flecs`_. + +.. toctree:: + :maxdepth: 1 + :glob: + + part-* diff --git a/docs/tutorial/notice.rst b/docs/tutorial/notice.rst index 47bd3d6b..707baf09 100644 --- a/docs/tutorial/notice.rst +++ b/docs/tutorial/notice.rst @@ -1,6 +1,6 @@ .. note:: This tutorial is still a work-in-progress. `The resources being used are tracked here `_. - Feel free to discuss this tutorial on the `Github Discussions`_ forum. + Feel free to discuss this tutorial or share your progress on the `Github Discussions`_ forum. .. _Github Discussions: https://github.com/libtcod/python-tcod/discussions diff --git a/docs/tutorial/part-00.rst b/docs/tutorial/part-00.rst index cb97db3f..a00a7fe9 100644 --- a/docs/tutorial/part-00.rst +++ b/docs/tutorial/part-00.rst @@ -23,9 +23,13 @@ First script First start with a modern top-level script. Create a script in the project root folder called ``main.py`` which checks :python:`if __name__ == "__main__":` and calls a ``main`` function. +Any modern script using type-hinting will also have :python:`from __future__ import annotations` near the top. .. code-block:: python + from __future__ import annotations + + def main() -> None: print("Hello World!") diff --git a/docs/tutorial/part-01.rst b/docs/tutorial/part-01.rst index fac8d4e6..b18b7531 100644 --- a/docs/tutorial/part-01.rst +++ b/docs/tutorial/part-01.rst @@ -5,13 +5,21 @@ Part 1 - Moving a player around the screen .. include:: notice.rst +In part 1 you will become familiar with the initialization, rendering, and event system of tcod. +This will be done as a series of small implementations. +It is recommend to save your progress after each section is finished and tested. + Initial script ============================================================================== First start with a modern top-level script. +You should have ``main.py`` script from :ref:`part-0`: .. code-block:: python + from __future__ import annotations + + def main() -> None: ... @@ -25,18 +33,26 @@ Loading a tileset and opening a window ============================================================================== From here it is time to setup a ``tcod`` program. -Download `Alloy_curses_12x12.png `_ and place this file in your projects ``data/`` directory. +Download `Alloy_curses_12x12.png `_ [#tileset]_ and place this file in your projects ``data/`` directory. This tileset is from the `Dwarf Fortress tileset repository `_. These kinds of tilesets are always loaded with :python:`columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437`. +Use the string :python:`"data/Alloy_curses_12x12.png"` to refer to the path of the tileset. [#why_not_pathlib]_ + +Load the tileset with :any:`tcod.tileset.load_tilesheet`. +Pass the tileset to :any:`tcod.tileset.procedural_block_elements` which will fill in most `Block Elements `_ missing from `Code Page 437 `_. +Then pass the tileset to :any:`tcod.context.new`, you only need to provide the ``tileset`` parameter. + +:any:`tcod.context.new` returns a :any:`Context` which will be used with Python's :python:`with` statement. +We want to keep the name of the context, so use the syntax: :python:`with tcod.context.new(tileset=tileset) as context:`. +The new block can't be empty, so add :python:`pass` to the with statement body. -Load the tileset with :any:`tcod.tileset.load_tilesheet` and then pass it to :any:`tcod.context.new`. -These functions are part of modules which have not been imported yet, so new imports need to be added. -:any:`tcod.context.new` returns a :any:`Context` which is used with the ``with`` statement. +These functions are part of modules which have not been imported yet, so new imports for ``tcod.context`` and ``tcod.tileset`` must be added to the top of the script. .. code-block:: python - :emphasize-lines: 2,3,8-12 + :emphasize-lines: 3,4,8-14 + + from __future__ import annotations - ... import tcod.context # Add these imports import tcod.tileset @@ -46,14 +62,18 @@ These functions are part of modules which have not been imported yet, so new imp tileset = tcod.tileset.load_tilesheet( "data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437 ) + tcod.tileset.procedural_block_elements(tileset=tileset) with tcod.context.new(tileset=tileset) as context: pass # The window will stay open for the duration of this block - ... + + + if __name__ == "__main__": + main() If an import fails that means you do not have ``tcod`` installed on the Python environment you just used to run the script. If you use an IDE then make sure the Python environment it is using is correct and then run :shell:`pip install tcod` from the shell terminal within that IDE. -If you run this script now then a window will open and then immediately close. +There is no game loop, so if you run this script now then a window will open and then immediately close. If that happens without seeing a traceback in your terminal then the script is correct. Configuring an event loop @@ -62,7 +82,7 @@ Configuring an event loop The next step is to keep the window open until the user closes it. Since nothing is displayed yet a :any:`Console` should be created with :python:`"Hello World"` printed to it. -The size of the console can be used as a reference to create the context by adding the console to :any:`tcod.context.new`. +The size of the console can be used as a reference to create the context by adding the console to :any:`tcod.context.new`. [#init_context]_ Begin the main game loop with a :python:`while True:` statement. @@ -71,15 +91,20 @@ Do this first in the game loop before handing events. Events are checked by iterating over all pending events with :any:`tcod.event.wait`. Use the code :python:`for event in tcod.event.wait():` to begin handing events. -Test if an event is for closing the window with :python:`if isinstance(event, tcod.event.Quit):`. -If this is True then you should exit the function with :python:`raise SystemExit`. + +In the event loop start with the line :python:`print(event)` so that all events can be viewed from the program output. +Then test if an event is for closing the window with :python:`if isinstance(event, tcod.event.Quit):`. +If this is True then you should exit the function with :python:`raise SystemExit`. [#why_raise]_ .. code-block:: python - :emphasize-lines: 2,3,11-18 + :emphasize-lines: 3,5,15-23 + + from __future__ import annotations - ... import tcod.console + import tcod.context import tcod.event + import tcod.tileset def main() -> None: @@ -87,18 +112,24 @@ If this is True then you should exit the function with :python:`raise SystemExit tileset = tcod.tileset.load_tilesheet( "data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437 ) + tcod.tileset.procedural_block_elements(tileset=tileset) console = tcod.console.Console(80, 50) console.print(0, 0, "Hello World") # Test text by printing "Hello World" to the console with tcod.context.new(console=console, tileset=tileset) as context: while True: # Main loop context.present(console) # Render the console to the window and show it for event in tcod.event.wait(): # Event loop, blocks until pending events exist + print(event) if isinstance(event, tcod.event.Quit): - raise SystemExit() - ... + raise SystemExit + + + if __name__ == "__main__": + main() If you run this then you get a window saying :python:`"Hello World"`. The window can be resized and the console will be stretched to fit the new resolution. +When you do anything such as press a key or interact with the window the event for that action will be printed to the program output. An example game state ============================================================================== @@ -109,7 +140,7 @@ The next step is to change state based on user input. Like ``tcod`` you'll need to install ``attrs`` with Pip, such as with :shell:`pip install attrs`. Start by adding an ``attrs`` class called ``ExampleState``. -This a normal class with the :python:`@attrs.define(eq=False)` decorator added. +This a normal class with the :python:`@attrs.define()` decorator added. This class should hold coordinates for the player. It should also have a ``on_draw`` method which takes :any:`tcod.console.Console` as a parameter and marks the player position on it. @@ -120,12 +151,18 @@ The parameters for ``on_draw`` are ``self`` because this is an instance method a Call this method using the players current coordinates and the :python:`"@"` character. .. code-block:: python + :emphasize-lines: 3,10-21 + + from __future__ import annotations - ... import attrs + import tcod.console + import tcod.context + import tcod.event + import tcod.tileset - @attrs.define(eq=False) + @attrs.define() class ExampleState: """Example state with a hard-coded player position.""" @@ -137,6 +174,7 @@ Call this method using the players current coordinates and the :python:`"@"` cha def on_draw(self, console: tcod.console.Console) -> None: """Draw the player glyph.""" console.print(self.player_x, self.player_y, "@") + ... Now remove the :python:`console.print(0, 0, "Hello World")` line from ``main``. @@ -165,9 +203,13 @@ Modify the drawing routine so that the console is cleared, then passed to :pytho state.on_draw(console) # Draw the current state context.present(console) # Display the console on the window for event in tcod.event.wait(): + print(event) if isinstance(event, tcod.event.Quit): - raise SystemExit() - ... + raise SystemExit + + + if __name__ == "__main__": + main() Now if you run the script you'll see ``@``. @@ -182,20 +224,42 @@ Begin matching with :python:`match event:`. The equivalent to :python:`if isinstance(event, tcod.event.Quit):` is :python:`case tcod.event.Quit():`. Keyboard keys can be checked with :python:`case tcod.event.KeyDown(sym=tcod.event.KeySym.LEFT):`. Make a case for each arrow key: ``LEFT`` ``RIGHT`` ``UP`` ``DOWN`` and move the player in the direction of that key. +Since events are printed you can check the :any:`KeySym` of a key by pressing that key and looking at the printed output. See :any:`KeySym` for a list of all keys. +Finally replace the event handling code in ``main`` to defer to the states ``on_event`` method. +The full script so far is: + .. code-block:: python + :emphasize-lines: 23-35,53 - ... - @attrs.define(eq=False) + from __future__ import annotations + + import attrs + import tcod.console + import tcod.context + import tcod.event + import tcod.tileset + + + @attrs.define() class ExampleState: - ... + """Example state with a hard-coded player position.""" + + player_x: int + """Player X position, left-most position is zero.""" + player_y: int + """Player Y position, top-most position is zero.""" + + def on_draw(self, console: tcod.console.Console) -> None: + """Draw the player glyph.""" + console.print(self.player_x, self.player_y, "@") def on_event(self, event: tcod.event.Event) -> None: """Move the player on events and handle exiting. Movement is hard-coded.""" match event: case tcod.event.Quit(): - raise SystemExit() + raise SystemExit case tcod.event.KeyDown(sym=tcod.event.KeySym.LEFT): self.player_x -= 1 case tcod.event.KeyDown(sym=tcod.event.KeySym.RIGHT): @@ -204,16 +268,15 @@ See :any:`KeySym` for a list of all keys. self.player_y -= 1 case tcod.event.KeyDown(sym=tcod.event.KeySym.DOWN): self.player_y += 1 - ... -Now replace the event handling code in ``main`` to defer to the states ``on_event`` method. - -.. code-block:: python - :emphasize-lines: 11 - ... def main() -> None: - ... + """Run ExampleState.""" + tileset = tcod.tileset.load_tilesheet( + "data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437 + ) + tcod.tileset.procedural_block_elements(tileset=tileset) + console = tcod.console.Console(80, 50) state = ExampleState(player_x=console.width // 2, player_y=console.height // 2) with tcod.context.new(console=console, tileset=tileset) as context: while True: @@ -221,9 +284,29 @@ Now replace the event handling code in ``main`` to defer to the states ``on_even state.on_draw(console) context.present(console) for event in tcod.event.wait(): + print(event) state.on_event(event) # Pass events to the state - ... + + + if __name__ == "__main__": + main() Now when you run this script you have a player character you can move around with the arrow keys before closing the window. You can review the part-1 source code `here `_. + +.. rubric:: Footnotes + +.. [#tileset] The choice of tileset came down to what looked nice while also being square. + Other options such as using a BDF font were considered, but in the end this tutorial won't go too much into Unicode. + +.. [#why_not_pathlib] + :any:`pathlib` is not used because this example is too simple for that. + The working directory will always be the project root folder for the entire tutorial, including distributions. + :any:`pathlib` will be used later for saved games and configuration directories, and not for static data. + +.. [#init_context] This tutorial follows the setup for a fixed-size console. + The alternatives shown in :ref:`getting-started` are outside the scope of this tutorial. + +.. [#why_raise] You could use :python:`return` here to exit the ``main`` function and end the program, but :python:`raise SystemExit` is used because it will close the program from anywhere. + :python:`raise SystemExit` is also more useful to teach than :any:`sys.exit`. diff --git a/docs/tutorial/part-02.rst b/docs/tutorial/part-02.rst new file mode 100644 index 00000000..293ad055 --- /dev/null +++ b/docs/tutorial/part-02.rst @@ -0,0 +1,590 @@ +.. _part-2: + +Part 2 - Entities +############################################################################## + +.. include:: notice.rst + +In part 2 entities will be added and a new state will be created to handle them. +This part will also begin to split logic into multiple Python modules using a namespace called ``game``. + +Entities will be handled with an ECS implementation, in this case: `tcod-ecs`_. +``tcod-ecs`` is a standalone package and is installed separately from ``tcod``. +Use :shell:`pip install tcod-ecs` to install this package. + +Namespace package +============================================================================== + +Create a new folder called ``game`` and inside the folder create a new python file named ``__init__.py``. +``game/__init__.py`` only needs a docstring describing that it is a namespace package: + +.. code-block:: python + + """Game namespace package.""" + +This package will be used to organize new modules. + +Organizing globals +============================================================================== + +There are a few variables which will need to be accessible from multiple modules. +Any global variables which might be assigned from other modules will need to a tracked and handled with care. + +Create a new module: ``g.py`` [#g]_. +This module is exceptional and will be placed at the top-level instead of in the ``game`` folder. + +In ``g.py`` import ``tcod.context`` and ``tcod.ecs``. + +``context`` from ``main.py`` will now be annotated in ``g.py`` by adding the line :python:`context: tcod.context.Context` by itself. +Notice that is this only a type-hinted name and nothing is assigned to it. +This means that type-checking will assume the variable always exists but using it before it is assigned will crash at run-time. + +``main.py`` should add :python:`import g` and replace the variables named ``context`` with ``g.context``. + +Then add the :python:`world: tcod.ecs.Registry` global to hold the ECS scope. + +It is important to document all variables placed in this module with docstrings. + +.. code-block:: python + + """This module stores globally mutable variables used by this program.""" + + from __future__ import annotations + + import tcod.context + import tcod.ecs + + context: tcod.context.Context + """The window managed by tcod.""" + + world: tcod.ecs.Registry + """The active ECS registry and current session.""" + +Ideally you should not overuse this module for too many things. +When a variable can either be taken as a function parameter or accessed as a global then passing as a parameter is always preferable. + +ECS tags +============================================================================== + +Create ``game/tags.py``. +This will hold some sentinel values to be used as tags for ``tcod-ecs``. +These tags can be anything that's both unique and unchanging, in this case Python strings are used. + +For example :python:`IsPlayer: Final = "IsPlayer"` will tag an object as being controlled by the player. +The name is ``IsPlayer`` and string is the same as the name. +The ``Final`` annotation clarifies that this a constant. +Sentinel values for ``tcod-ecs`` are named like classes, similar to names like :python:`None` or :python:`False`. + +Repeat this for ``IsActor`` and ``IsItem`` tags. +The ``game/tags.py`` module should look like this: + +.. code-block:: python + + """Collection of common tags.""" + + from __future__ import annotations + + from typing import Final + + IsPlayer: Final = "IsPlayer" + """Entity is the player.""" + + IsActor: Final = "IsActor" + """Entity is an actor.""" + + IsItem: Final = "IsItem" + """Entity is an item.""" + +ECS components +============================================================================== + +Next is a new ``game/components.py`` module. +This will hold the components for the graphics and position of entities. + +Start by adding an import for ``attrs``. +The ability to easily design small classes which are frozen/immutable is important for working with ``tcod-ecs``. + +The first component will be a ``Position`` class. +This class will be decorated with :python:`@attrs.define(frozen=True)`. +For attributes this class will have :python:`x: int` and :python:`y: int`. + +It will be common to add vectors to a ``Position`` with code such as :python:`new_pos: Position = Position(0, 0) + (0, 1)`. +Create the dunder method :python:`def __add__(self, direction: tuple[int, int]) -> Self:` to allow this syntax. +Unpack the input with :python:`x, y = direction`. +:python:`self.__class__` is the current class so :python:`self.__class__(self.x + x, self.y + y)` will create a new instance with the direction added to the previous values. + +The new class will look like this: + +.. code-block:: python + + @attrs.define(frozen=True) + class Position: + """An entities position.""" + + x: int + y: int + + def __add__(self, direction: tuple[int, int]) -> Self: + """Add a vector to this position.""" + x, y = direction + return self.__class__(self.x + x, self.y + y) + +Because ``Position`` is immutable, ``tcod-ecs`` is able to reliably track changes to this component. +Normally you can only query entities by which components they have. +A callback can be registered with ``tcod-ecs`` to mirror component values as tags. +This allows querying an entity by its exact position. + +Add :python:`import tcod.ecs.callbacks` and :python:`from tcod.ecs import Entity`. +Then create the new function :python:`def on_position_changed(entity: Entity, old: Position | None, new: Position | None) -> None:` decorated with :python:`@tcod.ecs.callbacks.register_component_changed(component=Position)`. +This function is called when the ``Position`` component is either added, removed, or modified by assignment. +The goal of this function is to mirror the current position to the :class:`set`-like attribute ``entity.tags``. + +:python:`if old == new:` then a position was assigned its own value or an equivalent value. +The cost of discarding and adding the same value can sometimes be high so this case should be guarded and ignored. +:python:`if old is not None:` then the value tracked by ``entity.tags`` is outdated and must be removed. +:python:`if new is not None:` then ``new`` is the up-to-date value to be tracked by ``entity.tags``. + +The function should look like this: + +.. code-block:: python + + @tcod.ecs.callbacks.register_component_changed(component=Position) + def on_position_changed(entity: Entity, old: Position | None, new: Position | None) -> None: + """Mirror position components as a tag.""" + if old == new: # New position is equivalent to its previous value + return # Ignore and return + if old is not None: # Position component removed or changed + entity.tags.discard(old) # Remove old position from tags + if new is not None: # Position component added or changed + entity.tags.add(new) # Add new position to tags + +Next is the ``Graphic`` component. +This will have the attributes :python:`ch: int = ord("!")` and :python:`fg: tuple[int, int, int] = (255, 255, 255)`. +By default all new components should be marked as frozen. + +.. code-block:: python + + @attrs.define(frozen=True) + class Graphic: + """An entities icon and color.""" + + ch: int = ord("!") + fg: tuple[int, int, int] = (255, 255, 255) + +One last component: ``Gold``. +Define this as :python:`Gold: Final = ("Gold", int)`. +``(name, type)`` is tcod-ecs specific syntax to handle multiple components sharing the same type. + +.. code-block:: python + + Gold: Final = ("Gold", int) + """Amount of gold.""" + +That was the last component. +The ``game/components.py`` module should look like this: + +.. code-block:: python + + """Collection of common components.""" + + from __future__ import annotations + + from typing import Final, Self + + import attrs + import tcod.ecs.callbacks + from tcod.ecs import Entity + + + @attrs.define(frozen=True) + class Position: + """An entities position.""" + + x: int + y: int + + def __add__(self, direction: tuple[int, int]) -> Self: + """Add a vector to this position.""" + x, y = direction + return self.__class__(self.x + x, self.y + y) + + + @tcod.ecs.callbacks.register_component_changed(component=Position) + def on_position_changed(entity: Entity, old: Position | None, new: Position | None) -> None: + """Mirror position components as a tag.""" + if old == new: + return + if old is not None: + entity.tags.discard(old) + if new is not None: + entity.tags.add(new) + + + @attrs.define(frozen=True) + class Graphic: + """An entities icon and color.""" + + ch: int = ord("!") + fg: tuple[int, int, int] = (255, 255, 255) + + + Gold: Final = ("Gold", int) + """Amount of gold.""" + +ECS entities and registry +============================================================================== + +Now it is time to create entities. +To do that you need to create the ECS registry. + +Make a new script called ``game/world_tools.py``. +This module will be used to create the ECS registry. + +Random numbers from :mod:`random` will be used. +In this case we want to use ``Random`` as a component so add :python:`from random import Random`. +Get the registry with :python:`from tcod.ecs import Registry`. +Collect all our components and tags with :python:`from game.components import Gold, Graphic, Position` and :python:`from game.tags import IsActor, IsItem, IsPlayer`. + +This module will have one function: :python:`def new_world() -> Registry:`. +Think of the ECS registry as containing the world since this is how it will be used. +Start this function with :python:`world = Registry()`. + +Entities are referenced with the syntax :python:`world[unique_id]`. +If the same ``unique_id`` is used then you will access the same entity. +:python:`new_entity = world[object()]` is the syntax to spawn new entities because ``object()`` is always unique. +Whenever a global entity is needed then :python:`world[None]` will be used. + +Create an instance of :python:`Random()` and assign it to both :python:`world[None].components[Random]` and ``rng``. +This can done on one line with :python:`rng = world[None].components[Random] = Random()`. + +Next create the player entity with :python:`player = world[object()]`. +Assign the following components to the new player entity: :python:`player.components[Position] = Position(5, 5)`, :python:`player.components[Graphic] = Graphic(ord("@"))`, and :python:`player.components[Gold] = 0`. +Then update the players tags with :python:`player.tags |= {IsPlayer, IsActor}`. + +To add some variety we will scatter gold randomly across the world. +Start a for-loop with :python:`for _ in range(10):` then create a ``gold`` entity in this loop. + +The ``Random`` instance ``rng`` has access to functions from Python's random module such as :any:`random.randint`. +Set ``Position`` to :python:`Position(rng.randint(0, 20), rng.randint(0, 20))`. +Set ``Graphic`` to :python:`Graphic(ord("$"), fg=(255, 255, 0))`. +Set ``Gold`` to :python:`rng.randint(1, 10)`. +Then add ``IsItem`` as a tag. + +Once the for-loop exits then :python:`return world`. +Make sure :python:`return` has the correct indentation and is not part of the for-loop or else you will only spawn one gold. + +``game/world_tools.py`` should look like this: + +.. code-block:: python + + """Functions for working with worlds.""" + + from __future__ import annotations + + from random import Random + + from tcod.ecs import Registry + + from game.components import Gold, Graphic, Position + from game.tags import IsActor, IsItem, IsPlayer + + + def new_world() -> Registry: + """Return a freshly generated world.""" + world = Registry() + + rng = world[None].components[Random] = Random() + + player = world[object()] + player.components[Position] = Position(5, 5) + player.components[Graphic] = Graphic(ord("@")) + player.components[Gold] = 0 + player.tags |= {IsPlayer, IsActor} + + for _ in range(10): + gold = world[object()] + gold.components[Position] = Position(rng.randint(0, 20), rng.randint(0, 20)) + gold.components[Graphic] = Graphic(ord("$"), fg=(255, 255, 0)) + gold.components[Gold] = rng.randint(1, 10) + gold.tags |= {IsItem} + + return world + +New InGame state +============================================================================== + +Now there is a new ECS world but the example state does not know how to render it. +A new state needs to be made which is aware of the new entities. + +Before adding a new state it is time to add a more complete set of directional keys. +Create a new module called ``game/constants.py``. +Keys will be mapped to direction using a dictionary which can be reused anytime we want to know how a key translates to a direction. +Use :python:`from tcod.event import KeySym` to make ``KeySym`` enums easier to write. + +``game/constants.py`` should look like this: + +.. code-block:: python + + """Global constants are stored here.""" + + from __future__ import annotations + + from typing import Final + + from tcod.event import KeySym + + DIRECTION_KEYS: Final = { + # Arrow keys + KeySym.LEFT: (-1, 0), + KeySym.RIGHT: (1, 0), + KeySym.UP: (0, -1), + KeySym.DOWN: (0, 1), + # Arrow key diagonals + KeySym.HOME: (-1, -1), + KeySym.END: (-1, 1), + KeySym.PAGEUP: (1, -1), + KeySym.PAGEDOWN: (1, 1), + # Keypad + KeySym.KP_4: (-1, 0), + KeySym.KP_6: (1, 0), + KeySym.KP_8: (0, -1), + KeySym.KP_2: (0, 1), + KeySym.KP_7: (-1, -1), + KeySym.KP_1: (-1, 1), + KeySym.KP_9: (1, -1), + KeySym.KP_3: (1, 1), + # VI keys + KeySym.h: (-1, 0), + KeySym.l: (1, 0), + KeySym.k: (0, -1), + KeySym.j: (0, 1), + KeySym.y: (-1, -1), + KeySym.b: (-1, 1), + KeySym.u: (1, -1), + KeySym.n: (1, 1), + } + +Create a new module called ``game/states.py``. +``states`` is for derived classes, ``state`` is for the abstract class. +New states will be created in this module and this module will be allowed to import many first party modules without issues. + +Create a new :python:`class InGame:` decorated with :python:`@attrs.define()`. +States will always use ``g.world`` to access the ECS registry. + +.. code-block:: python + + @attrs.define() + class InGame: + """Primary in-game state.""" + ... + +Create an ``on_event`` and ``on_draw`` method matching the ``ExampleState`` class. +Copying ``ExampleState`` and modifying it should be enough since this wil replace ``ExampleState``. + +Now to do an tcod-ecs query to fetch the player entity. +In tcod-ecs queries most often start with :python:`g.world.Q.all_of(components=[], tags=[])`. +Which components and tags are asked for will narrow down the returned set of entities to only those matching the requirements. +The query to fetch player entities is :python:`g.world.Q.all_of(tags=[IsPlayer])`. +We expect only one player so the result will be unpacked into a single name: :python:`(player,) = g.world.Q.all_of(tags=[IsPlayer])`. + +Next is to handle the event. +Handling :python:`case tcod.event.Quit():` is the same as before: :python:`raise SystemExit`. + +The case for direction keys will now be done in a single case: :python:`case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS:`. +``sym=sym`` assigns from the event attribute to a local name. +The left side is the ``event.sym`` attribute and right side is the local name ``sym`` being assigned to. +The case also has a condition which must pass for this branch to be taken and in this case we ensure that only keys from the ``DIRECTION_KEYS`` dictionary are valid ``sym``'s. + +Inside this branch moving the player is simple. +Access the ``(x, y)`` vector with :python:`DIRECTION_KEYS[sym]` and use ``+=`` to add it to the players current ``Position`` component. +This triggers the earlier written ``__add__`` dunder method and ``on_position_changed`` callback. + +Now that the player has moved it would be a good time to interact with the gold entities. +The query to see if the player has stepped on gold is to check for whichever entities have a ``Gold`` component, an ``IsItem`` tag, and the players current position as a tag. +The query for this is :python:`g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]):`. + +We will iterate over whatever matches this query using a :python:`for gold in ...:` loop. +Add the entities ``Gold`` component to the players similar component. +Keep in mind that ``Gold`` is treated like an ``int`` so its usage is predictable. + +Format the added and total of gold using a Python f-string_: :python:`text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g"`. +Store ``text`` globally in the ECS registry with :python:`g.world[None].components[("Text", str)] = text`. +This is done as two lines to avoid creating a line with an excessive length. + +Then use :python:`gold.clear()` at the end to remove all components and tags from the gold entity which will effectively delete it. + +.. code-block:: python + + ... + def on_event(self, event: tcod.event.Event) -> None: + """Handle events for the in-game state.""" + (player,) = g.world.Q.all_of(tags=[IsPlayer]) + match event: + case tcod.event.Quit(): + raise SystemExit + case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS: + player.components[Position] += DIRECTION_KEYS[sym] + # Auto pickup gold + for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]): + player.components[Gold] += gold.components[Gold] + text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g" + g.world[None].components[str] = text + gold.clear() + ... + +Now start with the ``on_draw`` method. +Any entity with both a ``Position`` and a ``Graphic`` is drawable. +Iterate over these entities with :python:`for entity in g.world.Q.all_of(components=[Position, Graphic]):`. +Accessing components can be slow in a loop, so assign components to local names before using them (:python:`pos = entity.components[Position]` and :python:`graphic = entity.components[Graphic]`). + +Check if a components position is in the bounds of the console. +:python:`0 <= pos.x < console.width and 0 <= pos.y < console.height` tells if the position is in bounds. +Instead of nesting this method further, this check should be a guard using :python:`if not (...):` and :python:`continue`. + +Draw the graphic by assigning it to the consoles Numpy array directly with :python:`console.rgb[["ch", "fg"]][pos.y, pos.x] = graphic.ch, graphic.fg`. +``console.rgb`` is a ``ch,fg,bg`` array and :python:`[["ch", "fg"]]` narrows it down to only ``ch,fg``. +The array is in C row-major memory order so you access it with yx (or ij) ordering. + +That ends the entity rendering loop. +Next is to print the ``("Text", str)`` component if it exists. +A normal access will raise ``KeyError`` if the component is accessed before being assigned. +This case will be handled by the ``.get`` method of the ``Entity.components`` attribute. +:python:`g.world[None].components.get(("Text", str))` will return :python:`None` instead of raising ``KeyError``. +Assigning this result to ``text`` and then checking :python:`if text:` will ensure that ``text`` within the branch is not None and that the string is not empty. +We will not use ``text`` outside of the branch, so an assignment expression can be used here to check and assign the name at the same time with :python:`if text := g.world[None].components.get(("Text", str)):`. + +In this branch you will print ``text`` to the bottom of the console with a white foreground and black background. +The call to do this is :python:`console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0))`. + +.. code-block:: python + + ... + def on_draw(self, console: tcod.console.Console) -> None: + """Draw the standard screen.""" + for entity in g.world.Q.all_of(components=[Position, Graphic]): + pos = entity.components[Position] + if not (0 <= pos.x < console.width and 0 <= pos.y < console.height): + continue + graphic = entity.components[Graphic] + console.rgb[["ch", "fg"]][pos.y, pos.x] = graphic.ch, graphic.fg + + if text := g.world[None].components.get(("Text", str)): + console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0)) + +Verify the indentation of the ``if`` branch is correct. +It should be at the same level as the ``for`` loop and not inside of it. + +``game/states.py`` should now look like this: + +.. code-block:: python + + """A collection of game states.""" + + from __future__ import annotations + + import attrs + import tcod.console + import tcod.event + + import g + from game.components import Gold, Graphic, Position + from game.constants import DIRECTION_KEYS + from game.tags import IsItem, IsPlayer + + + @attrs.define() + class InGame: + """Primary in-game state.""" + + def on_event(self, event: tcod.event.Event) -> None: + """Handle events for the in-game state.""" + (player,) = g.world.Q.all_of(tags=[IsPlayer]) + match event: + case tcod.event.Quit(): + raise SystemExit + case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS: + player.components[Position] += DIRECTION_KEYS[sym] + # Auto pickup gold + for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]): + player.components[Gold] += gold.components[Gold] + text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g" + g.world[None].components[("Text", str)] = text + gold.clear() + + def on_draw(self, console: tcod.console.Console) -> None: + """Draw the standard screen.""" + for entity in g.world.Q.all_of(components=[Position, Graphic]): + pos = entity.components[Position] + if not (0 <= pos.x < console.width and 0 <= pos.y < console.height): + continue + graphic = entity.components[Graphic] + console.rgb[["ch", "fg"]][pos.y, pos.x] = graphic.ch, graphic.fg + + if text := g.world[None].components.get(("Text", str)): + console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0)) + +Main script update +============================================================================== + +Back to ``main.py``. +At this point you should know to import the modules needed. + +The ``ExampleState`` class is obsolete and will be removed. +``state`` will be created with :python:`game.states.InGame()` instead. + +If you have not replaced ``context`` with ``g.context`` yet then do it now. + +Add :python:`g.world = game.world_tools.new_world()` before the main loop. + +``main.py`` will look like this: + +.. code-block:: python + :emphasize-lines: 10-12,22-24,28 + + #!/usr/bin/env python3 + """Main entry-point module. This script is used to start the program.""" + + from __future__ import annotations + + import tcod.console + import tcod.context + import tcod.event + import tcod.tileset + + import g + import game.states + import game.world_tools + + + def main() -> None: + """Entry point function.""" + tileset = tcod.tileset.load_tilesheet( + "data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437 + ) + tcod.tileset.procedural_block_elements(tileset=tileset) + console = tcod.console.Console(80, 50) + state = game.states.InGame() + g.world = game.world_tools.new_world() + with tcod.context.new(console=console, tileset=tileset) as g.context: + while True: # Main loop + console.clear() # Clear the console before any drawing + state.on_draw(console) # Draw the current state + g.context.present(console) # Render the console to the window and show it + for event in tcod.event.wait(): # Event loop, blocks until pending events exist + print(event) + state.on_event(event) # Dispatch events to the state + + + if __name__ == "__main__": + main() + +Now you can play a simple game where you wander around collecting gold. + +You can review the part-2 source code `here `_. + +.. rubric:: Footnotes + +.. [#g] ``global``, ``globals``, and ``glob`` were already taken by keywords, built-ins, and the standard library. + The alternatives are to either put this in the ``game`` namespace or to add an underscore such as ``globals_.py``. + +.. _f-string: https://docs.python.org/3/tutorial/inputoutput.html#formatted-string-literals diff --git a/docs/tutorial/part-03.rst b/docs/tutorial/part-03.rst new file mode 100644 index 00000000..528a39ae --- /dev/null +++ b/docs/tutorial/part-03.rst @@ -0,0 +1,459 @@ +.. _part-3: + +Part 3 - UI State +############################################################################## + +.. include:: notice.rst + +.. warning:: + + **This part is still a draft and is being worked on. + Sections here will be incorrect as these examples were hastily moved from an earlier part.** + + +State protocol +============================================================================== + +To have more states than ``ExampleState`` one must use an abstract type which can be used to refer to any state. +In this case a `Protocol`_ will be used, called ``State``. + +Create a new module: ``game/state.py``. +In this module add the class :python:`class State(Protocol):`. +``Protocol`` is from Python's ``typing`` module. +``State`` should have the ``on_event`` and ``on_draw`` methods from ``ExampleState`` but these methods will be empty other than the docstrings describing what they are for. +These methods refer to types from ``tcod`` and those types will need to be imported. +``State`` should also have :python:`__slots__ = ()` [#slots]_ in case the class is used for a subclass. + +Now add a few small classes using :python:`@attrs.define()`: +A ``Push`` class with a :python:`state: State` attribute. +A ``Pop`` class with no attributes. +A ``Reset`` class with a :python:`state: State` attribute. + +Then add a :python:`StateResult: TypeAlias = "Push | Pop | Reset | None"`. +This is a type which combines all of the previous classes. + +Edit ``State``'s ``on_event`` method to return ``StateResult``. + +``game/state.py`` should look like this: + +.. code-block:: python + + """Base classes for states.""" + + from __future__ import annotations + + from typing import Protocol, TypeAlias + + import attrs + import tcod.console + import tcod.event + + + class State(Protocol): + """An abstract game state.""" + + __slots__ = () + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Called on events.""" + + def on_draw(self, console: tcod.console.Console) -> None: + """Called when the state is being drawn.""" + + + @attrs.define() + class Push: + """Push a new state on top of the stack.""" + + state: State + + + @attrs.define() + class Pop: + """Remove the current state from the stack.""" + + + @attrs.define() + class Reset: + """Replace the entire stack with a new state.""" + + state: State + + + StateResult: TypeAlias = "Push | Pop | Reset | None" + """Union of state results.""" + +The ``InGame`` class does not need to be updated since it is already a structural subtype of ``State``. +Note that subclasses of ``State`` will never be in same module as ``State``, this will be the same for all abstract classes. + +New globals +============================================================================== + +A new global will be added: :python:`states: list[game.state.State] = []`. +States are implemented as a list/stack to support `pushdown automata `_. +Representing states as a stack makes it easier to implement popup windows, sub-menus, and other prompts. + +The ``console`` variable from ``main.py`` will be moved to ``g.py``. +Add :python:`console: tcod.console.Console` and replace all references to ``console`` in ``main.py`` with ``g.console``. + +.. code-block:: python + :emphasize-lines: 9,17-21 + + """This module stores globally mutable variables used by this program.""" + + from __future__ import annotations + + import tcod.console + import tcod.context + import tcod.ecs + + import game.state + + context: tcod.context.Context + """The window managed by tcod.""" + + world: tcod.ecs.Registry + """The active ECS registry and current session.""" + + states: list[game.state.State] = [] + """A stack of states with the last item being the active state.""" + + console: tcod.console.Console + """The current main console.""" + + +State functions +============================================================================== + +Create a new module: ``game/state_tools.py``. +This module will handle events and rendering of the global state. + +In this module add the function :python:`def main_draw() -> None:`. +This will hold the "clear, draw, present" logic from the ``main`` function which will be moved to this function. +Render the active state with :python:`g.states[-1].on_draw(g.console)`. +If ``g.states`` is empty then this function should immediately :python:`return` instead of doing anything. +Empty containers in Python are :python:`False` when checked for truthiness. + +Next is to handle the ``StateResult`` type. +Start by adding the :python:`def apply_state_result(result: StateResult) -> None:` function. +This function will :python:`match result:` to decide on what to do. + +:python:`case Push(state=state):` should append ``state`` to ``g.states``. + +:python:`case Pop():` should simply call :python:`g.states.pop()`. + +:python:`case Reset(state=state):` should call :python:`apply_state_result(Pop())` until ``g.state`` is empty then call :python:`apply_state_result(Push(state))`. + +:python:`case None:` should be handled by explicitly ignoring it. + +:python:`case _:` handles anything else and should invoke :python:`raise TypeError(result)` since no other types are expected. + +Now the function :python:`def main_loop() -> None:` is created. +The :python:`while` loop from ``main`` will be moved to this function. +The while loop will be replaced by :python:`while g.states:` so that this function will exit if no state exists. +Drawing will be replaced by a call to ``main_draw``. +Events with mouse coordinates should be converted to tiles using :python:`tile_event = g.context.convert_event(event)` before being passed to a state. +:python:`apply_state_result(g.states[-1].on_event(tile_event))` will pass the event and handle the return result at the same time. +``g.states`` must be checked to be non-empty inside the event handing for-loop because ``apply_state_result`` could cause ``g.states`` to become empty. + +Next is the utility function :python:`def get_previous_state(state: State) -> State | None:`. +Get the index of ``state`` in ``g.states`` by identity [#identity]_ using :python:`current_index = next(index for index, value in enumerate(g.states) if value is state)`. +Return the previous state if :python:`current_index > 0` or else return None using :python:`return g.states[current_index - 1] if current_index > 0 else None`. + +Next is :python:`def draw_previous_state(state: State, console: tcod.console.Console, dim: bool = True) -> None:`. +Call ``get_previous_state`` to get the previous state and return early if the result is :python:`None`. +Then call the previous states :python:`State.on_draw` method as normal. +Afterwards test :python:`dim and state is g.states[-1]` to see if the console should be dimmed. +If it should be dimmed then reduce the color values of the console with :python:`console.rgb["fg"] //= 4` and :python:`console.rgb["bg"] //= 4`. +This is used to indicate that any graphics behind the active state are non-interactable. + + +.. code-block:: python + + """State handling functions.""" + + from __future__ import annotations + + import tcod.console + + import g + from game.state import Pop, Push, Reset, StateResult + + + def main_draw() -> None: + """Render and present the active state.""" + if not g.states: + return + g.console.clear() + g.states[-1].on_draw(g.console) + g.context.present(g.console) + + + def apply_state_result(result: StateResult) -> None: + """Apply a StateResult to `g.states`.""" + match result: + case Push(state=state): + g.states.append(state) + case Pop(): + g.states.pop() + case Reset(state=state): + while g.states: + apply_state_result(Pop()) + apply_state_result(Push(state)) + case None: + pass + case _: + raise TypeError(result) + + + def main_loop() -> None: + """Run the active state forever.""" + while g.states: + main_draw() + for event in tcod.event.wait(): + tile_event = g.context.convert_event(event) + if g.states: + apply_state_result(g.states[-1].on_event(tile_event)) + + + def get_previous_state(state: State) -> State | None: + """Return the state before `state` in the stack if it exists.""" + current_index = next(index for index, value in enumerate(g.states) if value is state) + return g.states[current_index - 1] if current_index > 0 else None + + + def draw_previous_state(state: State, console: tcod.console.Console, dim: bool = True) -> None: + """Draw previous states, optionally dimming all but the active state.""" + prev_state = get_previous_state(state) + if prev_state is None: + return + prev_state.on_draw(console) + if dim and state is g.states[-1]: + console.rgb["fg"] //= 4 + console.rgb["bg"] //= 4 + +Menus +============================================================================== + +.. code-block:: python + + """Menu UI classes.""" + + from __future__ import annotations + + from collections.abc import Callable + from typing import Protocol + + import attrs + import tcod.console + import tcod.event + from tcod.event import KeySym + + import game.state_tools + from game.constants import DIRECTION_KEYS + from game.state import Pop, State, StateResult + + + class MenuItem(Protocol): + """Menu item protocol.""" + + __slots__ = () + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Handle events passed to the menu item.""" + + def on_draw(self, console: tcod.console.Console, x: int, y: int, highlight: bool) -> None: + """Draw is item at the given position.""" + + + @attrs.define() + class SelectItem(MenuItem): + """Clickable menu item.""" + + label: str + callback: Callable[[], StateResult] + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Handle events selecting this item.""" + match event: + case tcod.event.KeyDown(sym=sym) if sym in {KeySym.RETURN, KeySym.RETURN2, KeySym.KP_ENTER}: + return self.callback() + case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.LEFT): + return self.callback() + case _: + return None + + def on_draw(self, console: tcod.console.Console, x: int, y: int, highlight: bool) -> None: + """Render this items label.""" + console.print(x, y, self.label, fg=(255, 255, 255), bg=(64, 64, 64) if highlight else (0, 0, 0)) + + + @attrs.define() + class ListMenu(State): + """Simple list menu state.""" + + items: tuple[MenuItem, ...] + selected: int | None = 0 + x: int = 0 + y: int = 0 + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Handle events for menus.""" + match event: + case tcod.event.Quit(): + raise SystemExit + case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS: + dx, dy = DIRECTION_KEYS[sym] + if dx != 0 or dy == 0: + return self.activate_selected(event) + if self.selected is not None: + self.selected += dy + self.selected %= len(self.items) + else: + self.selected = 0 if dy == 1 else len(self.items) - 1 + return None + case tcod.event.MouseMotion(position=(_, y)): + y -= self.y + self.selected = y if 0 <= y < len(self.items) else None + return None + case tcod.event.KeyDown(sym=KeySym.ESCAPE): + return self.on_cancel() + case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.RIGHT): + return self.on_cancel() + case _: + return self.activate_selected(event) + + def activate_selected(self, event: tcod.event.Event) -> StateResult: + """Call the selected menu items callback.""" + if self.selected is not None: + return self.items[self.selected].on_event(event) + return None + + def on_cancel(self) -> StateResult: + """Handle escape or right click being pressed on menus.""" + return Pop() + + def on_draw(self, console: tcod.console.Console) -> None: + """Render the menu.""" + game.state_tools.draw_previous_state(self, console) + for i, item in enumerate(self.items): + item.on_draw(console, x=self.x, y=self.y + i, highlight=i == self.selected) + +Update states +============================================================================== + +.. code-block:: python + + class MainMenu(game.menus.ListMenu): + """Main/escape menu.""" + + __slots__ = () + + def __init__(self) -> None: + """Initialize the main menu.""" + items = [ + game.menus.SelectItem("New game", self.new_game), + game.menus.SelectItem("Quit", self.quit), + ] + if hasattr(g, "world"): + items.insert(0, game.menus.SelectItem("Continue", self.continue_)) + + super().__init__( + items=tuple(items), + selected=0, + x=5, + y=5, + ) + + @staticmethod + def continue_() -> StateResult: + """Return to the game.""" + return Reset(InGame()) + + @staticmethod + def new_game() -> StateResult: + """Begin a new game.""" + g.world = game.world_tools.new_world() + return Reset(InGame()) + + @staticmethod + def quit() -> StateResult: + """Close the program.""" + raise SystemExit + +.. code-block:: python + :emphasize-lines: 2,5,19-23 + + @attrs.define() + class InGame(State): + """Primary in-game state.""" + + def on_event(self, event: tcod.event.Event) -> StateResult: + """Handle events for the in-game state.""" + (player,) = g.world.Q.all_of(tags=[IsPlayer]) + match event: + case tcod.event.Quit(): + raise SystemExit + case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS: + player.components[Position] += DIRECTION_KEYS[sym] + # Auto pickup gold + for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]): + player.components[Gold] += gold.components[Gold] + text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g" + g.world[None].components[("Text", str)] = text + gold.clear() + return None + case tcod.event.KeyDown(sym=KeySym.ESCAPE): + return Push(MainMenu()) + case _: + return None + + ... + +Update main.py +============================================================================== + +Now ``main.py`` can be edited to use the global variables and the new game loop. + +Add :python:`import g` and :python:`import game.state_tools`. +Replace references to ``console`` with ``g.console``. + +States are initialed by assigning a list with the initial state to ``g.states``. +The previous game loop is replaced by a call to :python:`game.state_tools.main_loop()`. + +.. code-block:: python + :emphasize-lines: 3-4,13-16 + + ... + + import g + import game.state_tools + import game.states + + def main() -> None: + """Entry point function.""" + tileset = tcod.tileset.load_tilesheet( + "data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437 + ) + tcod.tileset.procedural_block_elements(tileset=tileset) + g.console = tcod.console.Console(80, 50) + g.states = [game.states.MainMenu()] + with tcod.context.new(console=g.console, tileset=tileset) as g.context: + game.state_tools.main_loop() + ... + +After this you can test the game. +There should be no visible differences from before. + +You can review the part-3 source code `here `_. + +.. rubric:: Footnotes + +.. [#slots] This is done to prevent subclasses from requiring a ``__dict__`` attribute. + See :any:`slots` for a detailed explanation of what they are. + +.. [#identity] See :any:`is`. + Since ``State`` classes use ``attrs`` they might compare equal when they're not the same object. + This means :python:`list.index` won't work for this case. + +.. _Protocol: https://mypy.readthedocs.io/en/stable/protocols.html diff --git a/examples/README.md b/examples/README.md index 0594efe1..8f13fa79 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ This directory contains a few example scripts for using python-tcod. -`samples_tcod.py` is the mail example which uses most of the newer API. This +`samples_tcod.py` is the mail example which uses most of the newer API. This can be compared to `samples_libtcodpy.py` which mostly uses deprecated functions from the old API. @@ -8,5 +8,5 @@ Examples in the `distribution/` folder show how to distribute projects made using python-tcod. Examples in the `experimental/` folder show off features that might later be -added the python-tcod API. You can use those features by copying those modules +added the python-tcod API. You can use those features by copying those modules into your own project. diff --git a/examples/audio_tone.py b/examples/audio_tone.py new file mode 100755 index 00000000..22f21133 --- /dev/null +++ b/examples/audio_tone.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +"""Shows how to use tcod.sdl.audio to play a custom-made audio stream. + +Opens an audio device using SDL and plays a square wave for 1 second. +""" + +import math +import time +from typing import Any + +import attrs +import numpy as np +from numpy.typing import NDArray +from scipy import signal # type: ignore + +import tcod.sdl.audio + +VOLUME = 10 ** (-12 / 10) # -12dB, square waves can be loud + + +@attrs.define +class PullWave: + """Square wave stream generator for an SDL audio device in pull mode.""" + + time: float = 0.0 + + def __call__(self, device: tcod.sdl.audio.AudioDevice, stream: NDArray[Any]) -> None: + """Stream a square wave to SDL on demand. + + This function must run faster than the stream duration. + Numpy is used to keep performance within these limits. + """ + sample_rate = device.frequency + n_samples = device.buffer_samples + duration = n_samples / sample_rate + print(f"{duration=} {self.time=}") + + t = np.linspace(self.time, self.time + duration, n_samples, endpoint=False) + self.time += duration + wave = signal.square(t * (math.tau * 440)).astype(np.float32) + wave *= VOLUME + + stream[:] = device.convert(wave) + + +if __name__ == "__main__": + with tcod.sdl.audio.open(callback=PullWave()) as device: + print(device) + time.sleep(1) diff --git a/examples/cavegen.py b/examples/cavegen.py index eedf0575..8b95d3fc 100755 --- a/examples/cavegen.py +++ b/examples/cavegen.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """A basic cellular automata cave generation example using SciPy. http://www.roguebasin.com/index.php?title=Cellular_Automata_Method_for_Generating_Random_Cave-Like_Levels @@ -6,6 +6,7 @@ This will print the result to the console, so be sure to run this from the command line. """ + from typing import Any import numpy as np diff --git a/examples/distribution/PyInstaller/main.py b/examples/distribution/PyInstaller/main.py index e1c6090d..ce500635 100755 --- a/examples/distribution/PyInstaller/main.py +++ b/examples/distribution/PyInstaller/main.py @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # To the extent possible under law, the libtcod maintainers have waived all # copyright and related or neighboring rights for the "hello world" PyInstaller # example script. This work is published from: United States. # https://creativecommons.org/publicdomain/zero/1.0/ """PyInstaller main script example.""" -import sys + from pathlib import Path import tcod.console @@ -14,8 +14,8 @@ WIDTH, HEIGHT = 80, 60 -# The base directory, this is sys._MEIPASS when in one-file mode. -BASE_DIR = Path(getattr(sys, "_MEIPASS", ".")) +BASE_DIR = Path(__file__).parent +"""The directory of this script.""" FONT_PATH = BASE_DIR / "data/terminal8x8_gs_ro.png" @@ -30,7 +30,7 @@ def main() -> None: context.present(console) for event in tcod.event.wait(): if isinstance(event, tcod.event.Quit): - raise SystemExit() + raise SystemExit if __name__ == "__main__": diff --git a/examples/distribution/PyInstaller/requirements.txt b/examples/distribution/PyInstaller/requirements.txt index f28443fd..477fbbdc 100644 --- a/examples/distribution/PyInstaller/requirements.txt +++ b/examples/distribution/PyInstaller/requirements.txt @@ -1,3 +1,3 @@ -tcod==12.2.0 -pyinstaller==4.3 +tcod==16.2.3 +pyinstaller==6.9.0 pypiwin32; sys_platform=="win32" diff --git a/examples/distribution/cx_Freeze/main.py b/examples/distribution/cx_Freeze/main.py index 8f608af7..569dddad 100755 --- a/examples/distribution/cx_Freeze/main.py +++ b/examples/distribution/cx_Freeze/main.py @@ -1,5 +1,6 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """cx_Freeze main script example.""" + import tcod.console import tcod.context import tcod.event @@ -19,7 +20,7 @@ def main() -> None: context.present(console) for event in tcod.event.wait(): if isinstance(event, tcod.event.Quit): - raise SystemExit() + raise SystemExit if __name__ == "__main__": diff --git a/examples/distribution/cx_Freeze/setup.py b/examples/distribution/cx_Freeze/setup.py index 446a0f42..50a588db 100755 --- a/examples/distribution/cx_Freeze/setup.py +++ b/examples/distribution/cx_Freeze/setup.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python import sys from cx_Freeze import Executable, setup # type: ignore diff --git a/examples/eventget.py b/examples/eventget.py index 0895e90f..dad8649d 100755 --- a/examples/eventget.py +++ b/examples/eventget.py @@ -1,10 +1,9 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # To the extent possible under law, the libtcod maintainers have waived all # copyright and related or neighboring rights for this example. This work is # published from: United States. # https://creativecommons.org/publicdomain/zero/1.0/ """An demonstration of event handling using the tcod.event module.""" -from typing import List, Set import tcod.context import tcod.event @@ -16,11 +15,11 @@ def main() -> None: """Example program for tcod.event.""" - event_log: List[str] = [] + event_log: list[str] = [] motion_desc = "" tcod.sdl.joystick.init() - controllers: Set[tcod.sdl.joystick.GameController] = set() - joysticks: Set[tcod.sdl.joystick.Joystick] = set() + controllers: set[tcod.sdl.joystick.GameController] = set() + joysticks: set[tcod.sdl.joystick.Joystick] = set() with tcod.context.new(width=WIDTH, height=HEIGHT) as context: console = context.new_console() @@ -40,7 +39,7 @@ def main() -> None: context.convert_event(event) # Set tile coordinates for event. print(repr(event)) if isinstance(event, tcod.event.Quit): - raise SystemExit() + raise SystemExit if isinstance(event, tcod.event.WindowResized) and event.type == "WindowSizeChanged": console = context.new_console() if isinstance(event, tcod.event.ControllerDevice): diff --git a/examples/framerate.py b/examples/framerate.py index 3a9e9f81..cc1195de 100755 --- a/examples/framerate.py +++ b/examples/framerate.py @@ -1,13 +1,13 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # To the extent possible under law, the libtcod maintainers have waived all # copyright and related or neighboring rights for this example. This work is # published from: United States. # https://creativecommons.org/publicdomain/zero/1.0/ """A system to control time since the original libtcod tools are deprecated.""" + import statistics import time from collections import deque -from typing import Deque, Optional import tcod @@ -24,11 +24,11 @@ class Clock: def __init__(self) -> None: """Initialize this object with empty data.""" self.last_time = time.perf_counter() # Last time this was synced. - self.time_samples: Deque[float] = deque() # Delta time samples. + self.time_samples: deque[float] = deque() # Delta time samples. self.max_samples = 64 # Number of fps samples to log. Can be changed. self.drift_time = 0.0 # Tracks how much the last frame was overshot. - def sync(self, fps: Optional[float] = None) -> float: + def sync(self, fps: float | None = None) -> float: """Sync to a given framerate and return the delta time. `fps` is the desired framerate in frames-per-second. If None is given @@ -137,7 +137,7 @@ def main() -> None: for event in tcod.event.get(): context.convert_event(event) # Set tile coordinates for event. if isinstance(event, tcod.event.Quit): - raise SystemExit() + raise SystemExit if isinstance(event, tcod.event.MouseWheel): desired_fps = max(1, desired_fps + event.y) diff --git a/examples/samples_libtcodpy.py b/examples/samples_libtcodpy.py index ab68eb91..9e0cb41d 100755 --- a/examples/samples_libtcodpy.py +++ b/examples/samples_libtcodpy.py @@ -220,7 +220,7 @@ def render_colors(first, key, mouse): SAMPLE_SCREEN_HEIGHT - 1, libtcod.BKGND_MULTIPLY, libtcod.CENTER, - "The Doryen library uses 24 bits " "colors, for both background and " "foreground.", + "The Doryen library uses 24 bits colors, for both background and foreground.", ) if key.c == ord("f"): @@ -270,10 +270,7 @@ def render_offscreen(first, key, mouse): SAMPLE_SCREEN_HEIGHT // 2, libtcod.BKGND_NONE, libtcod.CENTER, - b"You can render to an offscreen " - b"console and blit in on another " - b"one, simulating alpha " - b"transparency.", + b"You can render to an offscreen console and blit in on another one, simulating alpha transparency.", ) if first: libtcod.sys_set_fps(30) diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index 43b53d92..6c0e6a1c 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -1,29 +1,42 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """This code demonstrates various usages of python-tcod.""" + # To the extent possible under law, the libtcod maintainers have waived all # copyright and related or neighboring rights to these samples. # https://creativecommons.org/publicdomain/zero/1.0/ from __future__ import annotations import copy +import importlib.metadata import math import random import sys import time import warnings from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np -from numpy.typing import NDArray +import tcod.bsp import tcod.cffi +import tcod.console +import tcod.context import tcod.event +import tcod.image +import tcod.los +import tcod.map import tcod.noise +import tcod.path import tcod.render import tcod.sdl.mouse import tcod.sdl.render +import tcod.tileset from tcod import libtcodpy +from tcod.sdl.video import WindowFlags + +if TYPE_CHECKING: + from numpy.typing import NDArray # ruff: noqa: S311 @@ -57,6 +70,12 @@ cur_sample = 0 # Current selected sample. frame_times = [time.perf_counter()] frame_length = [0.0] +START_TIME = time.perf_counter() + + +def _get_elapsed_time() -> float: + """Return time passed since the start of the program.""" + return time.perf_counter() - START_TIME class Sample(tcod.event.EventDispatch[None]): @@ -79,25 +98,22 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: cur_sample = (cur_sample - 1) % len(SAMPLES) SAMPLES[cur_sample].on_enter() draw_samples_menu() - elif event.sym == tcod.event.KeySym.RETURN and event.mod & tcod.event.KMOD_LALT: - libtcodpy.console_set_fullscreen(not libtcodpy.console_is_fullscreen()) - elif event.sym == tcod.event.KeySym.PRINTSCREEN or event.sym == tcod.event.KeySym.p: + elif event.sym == tcod.event.KeySym.RETURN and event.mod & tcod.event.Modifier.ALT: + sdl_window = context.sdl_window + if sdl_window: + sdl_window.fullscreen = False if sdl_window.fullscreen else WindowFlags.FULLSCREEN_DESKTOP + elif event.sym in (tcod.event.KeySym.PRINTSCREEN, tcod.event.KeySym.p): print("screenshot") - if event.mod & tcod.event.KMOD_LALT: + if event.mod & tcod.event.Modifier.ALT: libtcodpy.console_save_apf(root_console, "samples.apf") print("apf") else: libtcodpy.sys_save_screenshot() print("png") - elif event.sym == tcod.event.KeySym.ESCAPE: - raise SystemExit() elif event.sym in RENDERER_KEYS: # Swap the active context for one with a different renderer. init_context(RENDERER_KEYS[event.sym]) - def ev_quit(self, event: tcod.event.Quit) -> None: - raise SystemExit() - class TrueColorSample(Sample): def __init__(self) -> None: @@ -116,7 +132,7 @@ def on_draw(self) -> None: self.slide_corner_colors() self.interpolate_corner_colors() self.darken_background_characters() - self.randomize_sample_conole() + self.randomize_sample_console() self.print_banner() def slide_corner_colors(self) -> None: @@ -141,7 +157,7 @@ def darken_background_characters(self) -> None: sample_console.fg[:] = sample_console.bg[:] sample_console.fg[:] //= 2 - def randomize_sample_conole(self) -> None: + def randomize_sample_console(self) -> None: # randomize sample console characters sample_console.ch[:] = np.random.randint( low=ord("a"), @@ -157,11 +173,11 @@ def print_banner(self) -> None: y=5, width=sample_console.width - 2, height=sample_console.height - 1, - string="The Doryen library uses 24 bits colors, for both " "background and foreground.", + string="The Doryen library uses 24 bits colors, for both background and foreground.", fg=WHITE, bg=GREY, - bg_blend=tcod.BKGND_MULTIPLY, - alignment=tcod.CENTER, + bg_blend=libtcodpy.BKGND_MULTIPLY, + alignment=libtcodpy.CENTER, ) @@ -182,7 +198,7 @@ def __init__(self) -> None: sample_console.width // 2, sample_console.height // 2, "Offscreen console", - False, + clear=False, fg=WHITE, bg=BLACK, ) @@ -192,20 +208,20 @@ def __init__(self) -> None: 2, sample_console.width // 2 - 2, sample_console.height // 2, - "You can render to an offscreen console and blit in on another " "one, simulating alpha transparency.", + "You can render to an offscreen console and blit in on another one, simulating alpha transparency.", fg=WHITE, bg=None, alignment=libtcodpy.CENTER, ) def on_enter(self) -> None: - self.counter = time.perf_counter() + self.counter = _get_elapsed_time() # get a "screenshot" of the current sample screen sample_console.blit(dest=self.screenshot) def on_draw(self) -> None: - if time.perf_counter() - self.counter >= 1: - self.counter = time.perf_counter() + if _get_elapsed_time() - self.counter >= 1: + self.counter = _get_elapsed_time() self.x += self.x_dir self.y += self.y_dir if self.x == sample_console.width / 2 + 5: @@ -231,7 +247,7 @@ def on_draw(self) -> None: class LineDrawingSample(Sample): - FLAG_NAMES = [ + FLAG_NAMES = ( "BKGND_NONE", "BKGND_SET", "BKGND_MULTIPLY", @@ -245,7 +261,7 @@ class LineDrawingSample(Sample): "BKGND_BURN", "BKGND_OVERLAY", "BKGND_ALPHA", - ] + ) def __init__(self) -> None: self.name = "Line drawing" @@ -297,18 +313,18 @@ def on_draw(self) -> None: # in python the easiest way is to use the line iterator for x, y in tcod.los.bresenham((xo, yo), (xd, yd)).tolist(): if 0 <= x < sample_console.width and 0 <= y < sample_console.height: - tcod.console_set_char_background(sample_console, x, y, LIGHT_BLUE, self.bk_flag) + libtcodpy.console_set_char_background(sample_console, x, y, LIGHT_BLUE, self.bk_flag) sample_console.print( 2, 2, - "%s (ENTER to change)" % self.FLAG_NAMES[self.bk_flag & 0xFF], + f"{self.FLAG_NAMES[self.bk_flag & 0xFF]} (ENTER to change)", fg=WHITE, bg=None, ) class NoiseSample(Sample): - NOISE_OPTIONS = [ # (name, algorithm, implementation) + NOISE_OPTIONS = ( # (name, algorithm, implementation) ( "perlin noise", tcod.noise.Algorithm.PERLIN, @@ -354,7 +370,7 @@ class NoiseSample(Sample): tcod.noise.Algorithm.WAVELET, tcod.noise.Implementation.TURBULENCE, ), - ] + ) def __init__(self) -> None: self.name = "Noise" @@ -388,8 +404,8 @@ def get_noise(self) -> tcod.noise.Noise: ) def on_draw(self) -> None: - self.dx = time.perf_counter() * 0.25 - self.dy = time.perf_counter() * 0.25 + self.dx = _get_elapsed_time() * 0.25 + self.dy = _get_elapsed_time() * 0.25 for y in range(2 * sample_console.height): for x in range(2 * sample_console.width): f = [ @@ -404,7 +420,7 @@ def on_draw(self) -> None: rect_h = 13 if self.implementation == tcod.noise.Implementation.SIMPLE: rect_h = 10 - sample_console.draw_semigraphics(self.img) + sample_console.draw_semigraphics(np.asarray(self.img)) sample_console.draw_rect( 2, 2, @@ -420,31 +436,31 @@ def on_draw(self) -> None: ) for cur_func in range(len(self.NOISE_OPTIONS)): - text = "%i : %s" % (cur_func + 1, self.NOISE_OPTIONS[cur_func][0]) + text = f"{cur_func + 1} : {self.NOISE_OPTIONS[cur_func][0]}" if cur_func == self.func: sample_console.print(2, 2 + cur_func, text, fg=WHITE, bg=LIGHT_BLUE) else: sample_console.print(2, 2 + cur_func, text, fg=GREY, bg=None) - sample_console.print(2, 11, "Y/H : zoom (%2.1f)" % self.zoom, fg=WHITE, bg=None) + sample_console.print(2, 11, f"Y/H : zoom ({self.zoom:2.1f})", fg=WHITE, bg=None) if self.implementation != tcod.noise.Implementation.SIMPLE: sample_console.print( 2, 12, - "E/D : hurst (%2.1f)" % self.hurst, + f"E/D : hurst ({self.hurst:2.1f})", fg=WHITE, bg=None, ) sample_console.print( 2, 13, - "R/F : lacunarity (%2.1f)" % self.lacunarity, + f"R/F : lacunarity ({self.lacunarity:2.1f})", fg=WHITE, bg=None, ) sample_console.print( 2, 14, - "T/G : octaves (%2.1f)" % self.octaves, + f"T/G : octaves ({self.octaves:2.1f})", fg=WHITE, bg=None, ) @@ -487,7 +503,7 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: DARK_GROUND = (50, 50, 150) LIGHT_GROUND = (200, 180, 50) -SAMPLE_MAP_ = [ +SAMPLE_MAP_ = ( "##############################################", "####################### #################", "##################### # ###############", @@ -508,11 +524,11 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: "######## # #### ##### #####", "######## ##### ####################", "##############################################", -] +) -SAMPLE_MAP: NDArray[Any] = np.array([list(line) for line in SAMPLE_MAP_]).transpose() +SAMPLE_MAP: NDArray[Any] = np.array([[ord(c) for c in line] for line in SAMPLE_MAP_]).transpose() -FOV_ALGO_NAMES = [ +FOV_ALGO_NAMES = ( "BASIC ", "DIAMOND ", "SHADOW ", @@ -527,7 +543,7 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: "PERMISSIVE8", "RESTRICTIVE", "SYMMETRIC_SHADOWCAST", -] +) TORCH_RADIUS = 10 SQUARED_TORCH_RADIUS = TORCH_RADIUS * TORCH_RADIUS @@ -547,26 +563,23 @@ def __init__(self) -> None: map_shape = (SAMPLE_SCREEN_WIDTH, SAMPLE_SCREEN_HEIGHT) self.walkable: NDArray[np.bool_] = np.zeros(map_shape, dtype=bool, order="F") - self.walkable[:] = SAMPLE_MAP[:] == " " + self.walkable[:] = SAMPLE_MAP[:] == ord(" ") self.transparent: NDArray[np.bool_] = np.zeros(map_shape, dtype=bool, order="F") - self.transparent[:] = self.walkable[:] | (SAMPLE_MAP == "=") + self.transparent[:] = self.walkable[:] | (SAMPLE_MAP[:] == ord("=")) # Lit background colors for the map. self.light_map_bg: NDArray[np.uint8] = np.full(SAMPLE_MAP.shape, LIGHT_GROUND, dtype="3B") - self.light_map_bg[SAMPLE_MAP[:] == "#"] = LIGHT_WALL + self.light_map_bg[SAMPLE_MAP[:] == ord("#")] = LIGHT_WALL # Dark background colors for the map. self.dark_map_bg: NDArray[np.uint8] = np.full(SAMPLE_MAP.shape, DARK_GROUND, dtype="3B") - self.dark_map_bg[SAMPLE_MAP[:] == "#"] = DARK_WALL + self.dark_map_bg[SAMPLE_MAP[:] == ord("#")] = DARK_WALL def draw_ui(self) -> None: sample_console.print( 1, 1, - "IJKL : move around\n" - "T : torch fx {}\n" - "W : light walls {}\n" - "+-: algo {}".format( + "IJKL : move around\nT : torch fx {}\nW : light walls {}\n+-: algo {}".format( "on " if self.torch else "off", "on " if self.light_walls else "off", FOV_ALGO_NAMES[self.algo_num], @@ -581,8 +594,8 @@ def on_draw(self) -> None: self.draw_ui() sample_console.print(self.player_x, self.player_y, "@") # Draw windows. - sample_console.tiles_rgb["ch"][SAMPLE_MAP == "="] = 0x2550 # BOX DRAWINGS DOUBLE HORIZONTAL - sample_console.tiles_rgb["fg"][SAMPLE_MAP == "="] = BLACK + sample_console.rgb["ch"][SAMPLE_MAP[:] == ord("=")] = 0x2550 # BOX DRAWINGS DOUBLE HORIZONTAL + sample_console.rgb["fg"][SAMPLE_MAP[:] == ord("=")] = BLACK # Get a 2D boolean array of visible cells. fov = tcod.map.compute_fov( @@ -595,7 +608,7 @@ def on_draw(self) -> None: if self.torch: # Derive the touch from noise based on the current time. - torch_t = time.perf_counter() * 5 + torch_t = _get_elapsed_time() * 5 # Randomize the light position between -1.5 and 1.5 torch_x = self.player_x + self.noise.get_point(torch_t) * 1.5 torch_y = self.player_y + self.noise.get_point(torch_t + 11) * 1.5 @@ -625,18 +638,22 @@ def on_draw(self) -> None: dark_bg: NDArray[np.float16] = self.dark_map_bg.astype(np.float16) # Linear interpolation between colors. - sample_console.tiles_rgb["bg"] = dark_bg + (light_bg - dark_bg) * light[..., np.newaxis] + sample_console.rgb["bg"] = dark_bg + (light_bg - dark_bg) * light[..., np.newaxis] else: - sample_console.bg[...] = np.where(fov[:, :, np.newaxis], self.light_map_bg, self.dark_map_bg) + sample_console.bg[...] = np.select( + condlist=[fov[:, :, np.newaxis]], + choicelist=[self.light_map_bg], + default=self.dark_map_bg, + ) def ev_keydown(self, event: tcod.event.KeyDown) -> None: - MOVE_KEYS = { + MOVE_KEYS = { # noqa: N806 tcod.event.KeySym.i: (0, -1), tcod.event.KeySym.j: (-1, 0), tcod.event.KeySym.k: (0, 1), tcod.event.KeySym.l: (1, 0), } - FOV_SELECT_KEYS = { + FOV_SELECT_KEYS = { # noqa: N806 tcod.event.KeySym.MINUS: -1, tcod.event.KeySym.EQUALS: 1, tcod.event.KeySym.KP_MINUS: -1, @@ -660,176 +677,89 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: class PathfindingSample(Sample): def __init__(self) -> None: + """Initialize this sample.""" self.name = "Path finding" - self.px = 20 - self.py = 10 - self.dx = 24 - self.dy = 1 - self.dijkstra_dist = 0.0 + self.player_x = 20 + self.player_y = 10 + self.dest_x = 24 + self.dest_y = 1 self.using_astar = True - self.recalculate = False self.busy = 0.0 - self.oldchar = " " + self.cost = SAMPLE_MAP.T[:] == ord(" ") + self.graph = tcod.path.SimpleGraph(cost=self.cost, cardinal=70, diagonal=99) + self.pathfinder = tcod.path.Pathfinder(graph=self.graph) - self.map = tcod.map.Map(SAMPLE_SCREEN_WIDTH, SAMPLE_SCREEN_HEIGHT) - for y in range(SAMPLE_SCREEN_HEIGHT): - for x in range(SAMPLE_SCREEN_WIDTH): - if SAMPLE_MAP[x, y] == " ": - # ground - self.map.walkable[y, x] = True - self.map.transparent[y, x] = True - elif SAMPLE_MAP[x, y] == "=": - # window - self.map.walkable[y, x] = False - self.map.transparent[y, x] = True - self.path = tcod.path.AStar(self.map) - self.dijkstra = tcod.path.Dijkstra(self.map) + self.background_console = tcod.console.Console(SAMPLE_SCREEN_WIDTH, SAMPLE_SCREEN_HEIGHT) + + # draw the dungeon + self.background_console.rgb["fg"] = BLACK + self.background_console.rgb["bg"] = DARK_GROUND + self.background_console.rgb["bg"][SAMPLE_MAP.T[:] == ord("#")] = DARK_WALL + self.background_console.rgb["ch"][SAMPLE_MAP.T[:] == ord("=")] = ord("═") def on_enter(self) -> None: - # we draw the foreground only the first time. - # during the player movement, only the @ is redrawn. - # the rest impacts only the background color - # draw the help text & player @ - sample_console.clear() - sample_console.ch[self.dx, self.dy] = ord("+") - sample_console.fg[self.dx, self.dy] = WHITE - sample_console.ch[self.px, self.py] = ord("@") - sample_console.fg[self.px, self.py] = WHITE - sample_console.print( - 1, - 1, - "IJKL / mouse :\nmove destination\nTAB : A*/dijkstra", - fg=WHITE, - bg=None, - ) - sample_console.print(1, 4, "Using : A*", fg=WHITE, bg=None) - # draw windows - for y in range(SAMPLE_SCREEN_HEIGHT): - for x in range(SAMPLE_SCREEN_WIDTH): - if SAMPLE_MAP[x, y] == "=": - libtcodpy.console_put_char(sample_console, x, y, libtcodpy.CHAR_DHLINE, libtcodpy.BKGND_NONE) - self.recalculate = True + """Do nothing.""" def on_draw(self) -> None: - if self.recalculate: - if self.using_astar: - libtcodpy.path_compute(self.path, self.px, self.py, self.dx, self.dy) - else: - self.dijkstra_dist = 0.0 - # compute dijkstra grid (distance from px,py) - libtcodpy.dijkstra_compute(self.dijkstra, self.px, self.py) - # get the maximum distance (needed for rendering) - for y in range(SAMPLE_SCREEN_HEIGHT): - for x in range(SAMPLE_SCREEN_WIDTH): - d = libtcodpy.dijkstra_get_distance(self.dijkstra, x, y) - if d > self.dijkstra_dist: - self.dijkstra_dist = d - # compute path from px,py to dx,dy - libtcodpy.dijkstra_path_set(self.dijkstra, self.dx, self.dy) - self.recalculate = False - self.busy = 0.2 + """Recompute and render pathfinding.""" + self.pathfinder = tcod.path.Pathfinder(graph=self.graph) + # self.pathfinder.clear() # Known issues, needs fixing # noqa: ERA001 + self.pathfinder.add_root((self.player_y, self.player_x)) + # draw the dungeon - for y in range(SAMPLE_SCREEN_HEIGHT): - for x in range(SAMPLE_SCREEN_WIDTH): - if SAMPLE_MAP[x, y] == "#": - libtcodpy.console_set_char_background(sample_console, x, y, DARK_WALL, libtcodpy.BKGND_SET) - else: - libtcodpy.console_set_char_background(sample_console, x, y, DARK_GROUND, libtcodpy.BKGND_SET) + self.background_console.blit(dest=sample_console) + + sample_console.print(self.dest_x, self.dest_y, "+", fg=WHITE) + sample_console.print(self.player_x, self.player_y, "@", fg=WHITE) + sample_console.print(1, 1, "IJKL / mouse :\nmove destination\nTAB : A*/dijkstra", fg=WHITE, bg=None) + sample_console.print(1, 4, "Using : A*", fg=WHITE, bg=None) + + if not self.using_astar: + self.pathfinder.resolve(goal=None) + reachable = self.pathfinder.distance != np.iinfo(self.pathfinder.distance.dtype).max + + # draw distance from player + dijkstra_max_dist = float(self.pathfinder.distance[reachable].max()) + np.array(self.pathfinder.distance, copy=True, dtype=np.float32) + interpolate = self.pathfinder.distance[reachable] * 0.9 / dijkstra_max_dist + color_delta = (np.array(DARK_GROUND) - np.array(LIGHT_GROUND)).astype(np.float32) + sample_console.rgb.T["bg"][reachable] = np.array(LIGHT_GROUND) + interpolate[:, np.newaxis] * color_delta + # draw the path - if self.using_astar: - for i in range(libtcodpy.path_size(self.path)): - x, y = libtcodpy.path_get(self.path, i) - libtcodpy.console_set_char_background(sample_console, x, y, LIGHT_GROUND, libtcodpy.BKGND_SET) - else: - for y in range(SAMPLE_SCREEN_HEIGHT): - for x in range(SAMPLE_SCREEN_WIDTH): - if SAMPLE_MAP[x, y] != "#": - libtcodpy.console_set_char_background( - sample_console, - x, - y, - libtcodpy.color_lerp( # type: ignore - LIGHT_GROUND, - DARK_GROUND, - 0.9 * libtcodpy.dijkstra_get_distance(self.dijkstra, x, y) / self.dijkstra_dist, - ), - libtcodpy.BKGND_SET, - ) - for i in range(libtcodpy.dijkstra_size(self.dijkstra)): - x, y = libtcodpy.dijkstra_get(self.dijkstra, i) - libtcodpy.console_set_char_background(sample_console, x, y, LIGHT_GROUND, libtcodpy.BKGND_SET) + path = self.pathfinder.path_to((self.dest_y, self.dest_x))[1:, ::-1] + sample_console.rgb["bg"][tuple(path.T)] = LIGHT_GROUND # move the creature self.busy -= frame_length[-1] if self.busy <= 0.0: self.busy = 0.2 - if self.using_astar: - if not libtcodpy.path_is_empty(self.path): - libtcodpy.console_put_char(sample_console, self.px, self.py, " ", libtcodpy.BKGND_NONE) - self.px, self.py = libtcodpy.path_walk(self.path, True) # type: ignore - libtcodpy.console_put_char(sample_console, self.px, self.py, "@", libtcodpy.BKGND_NONE) - else: - if not libtcodpy.dijkstra_is_empty(self.dijkstra): - libtcodpy.console_put_char(sample_console, self.px, self.py, " ", libtcodpy.BKGND_NONE) - self.px, self.py = libtcodpy.dijkstra_path_walk(self.dijkstra) # type: ignore - libtcodpy.console_put_char(sample_console, self.px, self.py, "@", libtcodpy.BKGND_NONE) - self.recalculate = True + if len(path): + self.player_x = int(path.item(0, 0)) + self.player_y = int(path.item(0, 1)) def ev_keydown(self, event: tcod.event.KeyDown) -> None: - if event.sym == tcod.event.KeySym.i and self.dy > 0: - # destination move north - libtcodpy.console_put_char(sample_console, self.dx, self.dy, self.oldchar, libtcodpy.BKGND_NONE) - self.dy -= 1 - self.oldchar = sample_console.ch[self.dx, self.dy] - libtcodpy.console_put_char(sample_console, self.dx, self.dy, "+", libtcodpy.BKGND_NONE) - if SAMPLE_MAP[self.dx, self.dy] == " ": - self.recalculate = True - elif event.sym == tcod.event.KeySym.k and self.dy < SAMPLE_SCREEN_HEIGHT - 1: - # destination move south - libtcodpy.console_put_char(sample_console, self.dx, self.dy, self.oldchar, libtcodpy.BKGND_NONE) - self.dy += 1 - self.oldchar = sample_console.ch[self.dx, self.dy] - libtcodpy.console_put_char(sample_console, self.dx, self.dy, "+", libtcodpy.BKGND_NONE) - if SAMPLE_MAP[self.dx, self.dy] == " ": - self.recalculate = True - elif event.sym == tcod.event.KeySym.j and self.dx > 0: - # destination move west - libtcodpy.console_put_char(sample_console, self.dx, self.dy, self.oldchar, libtcodpy.BKGND_NONE) - self.dx -= 1 - self.oldchar = sample_console.ch[self.dx, self.dy] - libtcodpy.console_put_char(sample_console, self.dx, self.dy, "+", libtcodpy.BKGND_NONE) - if SAMPLE_MAP[self.dx, self.dy] == " ": - self.recalculate = True - elif event.sym == tcod.event.KeySym.l and self.dx < SAMPLE_SCREEN_WIDTH - 1: - # destination move east - libtcodpy.console_put_char(sample_console, self.dx, self.dy, self.oldchar, libtcodpy.BKGND_NONE) - self.dx += 1 - self.oldchar = sample_console.ch[self.dx, self.dy] - libtcodpy.console_put_char(sample_console, self.dx, self.dy, "+", libtcodpy.BKGND_NONE) - if SAMPLE_MAP[self.dx, self.dy] == " ": - self.recalculate = True + """Handle movement and UI.""" + if event.sym == tcod.event.KeySym.i and self.dest_y > 0: # destination move north + self.dest_y -= 1 + elif event.sym == tcod.event.KeySym.k and self.dest_y < SAMPLE_SCREEN_HEIGHT - 1: # destination move south + self.dest_y += 1 + elif event.sym == tcod.event.KeySym.j and self.dest_x > 0: # destination move west + self.dest_x -= 1 + elif event.sym == tcod.event.KeySym.l and self.dest_x < SAMPLE_SCREEN_WIDTH - 1: # destination move east + self.dest_x += 1 elif event.sym == tcod.event.KeySym.TAB: self.using_astar = not self.using_astar - if self.using_astar: - libtcodpy.console_print(sample_console, 1, 4, "Using : A* ") - else: - libtcodpy.console_print(sample_console, 1, 4, "Using : Dijkstra") - self.recalculate = True else: super().ev_keydown(event) def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: + """Move destination via mouseover.""" mx = event.tile.x - SAMPLE_SCREEN_X my = event.tile.y - SAMPLE_SCREEN_Y - if 0 <= mx < SAMPLE_SCREEN_WIDTH and 0 <= my < SAMPLE_SCREEN_HEIGHT and (self.dx != mx or self.dy != my): - libtcodpy.console_put_char(sample_console, self.dx, self.dy, self.oldchar, libtcodpy.BKGND_NONE) - self.dx = mx - self.dy = my - self.oldchar = sample_console.ch[self.dx, self.dy] - libtcodpy.console_put_char(sample_console, self.dx, self.dy, "+", libtcodpy.BKGND_NONE) - if SAMPLE_MAP[self.dx, self.dy] == " ": - self.recalculate = True + if 0 <= mx < SAMPLE_SCREEN_WIDTH and 0 <= my < SAMPLE_SCREEN_HEIGHT: + self.dest_x = mx + self.dest_y = my ############################################# @@ -980,9 +910,9 @@ def on_draw(self) -> None: 1, "ENTER : rebuild bsp\n" "SPACE : rebuild dungeon\n" - "+-: bsp depth %d\n" - "*/: room size %d\n" - "1 : random room size %s" % (bsp_depth, bsp_min_room_size, rooms), + f"+-: bsp depth {bsp_depth}\n" + f"*/: room size {bsp_min_room_size}\n" + f"1 : random room size {rooms}", fg=WHITE, bg=None, ) @@ -990,7 +920,7 @@ def on_draw(self) -> None: walls = "OFF" if bsp_room_walls: walls = "ON" - sample_console.print(1, 6, "2 : room walls %s" % walls, fg=WHITE, bg=None) + sample_console.print(1, 6, f"2 : room walls {walls}", fg=WHITE, bg=None) # render the level for y in range(SAMPLE_SCREEN_HEIGHT): for x in range(SAMPLE_SCREEN_WIDTH): @@ -1041,7 +971,7 @@ def on_draw(self) -> None: y = sample_console.height / 2 scalex = 0.2 + 1.8 * (1.0 + math.cos(time.time() / 2)) / 2.0 scaley = scalex - angle = time.perf_counter() + angle = _get_elapsed_time() if int(time.time()) % 2: # split the color channels of circle.png # the red channel @@ -1099,23 +1029,12 @@ def on_draw(self) -> None: sample_console.print( 1, 1, - "Pixel position : %4dx%4d\n" - "Tile position : %4dx%4d\n" - "Tile movement : %4dx%4d\n" - "Left button : %s\n" - "Right button : %s\n" - "Middle button : %s\n" - % ( - self.motion.position.x, - self.motion.position.y, - self.motion.tile.x, - self.motion.tile.y, - self.motion.tile_motion.x, - self.motion.tile_motion.y, - ("OFF", "ON")[self.mouse_left], - ("OFF", "ON")[self.mouse_right], - ("OFF", "ON")[self.mouse_middle], - ), + f"Pixel position : {self.motion.position.x:4d}x{self.motion.position.y:4d}\n" + f"Tile position : {self.motion.tile.x:4d}x{self.motion.tile.y:4d}\n" + f"Tile movement : {self.motion.tile_motion.x:4d}x{self.motion.tile_motion.y:4d}\n" + f"Left button : {'ON' if self.mouse_left else 'OFF'}\n" + f"Right button : {'ON' if self.mouse_right else 'OFF'}\n" + f"Middle button : {'ON' if self.mouse_middle else 'OFF'}\n", fg=LIGHT_YELLOW, bg=None, ) @@ -1140,7 +1059,7 @@ class NameGeneratorSample(Sample): def __init__(self) -> None: self.name = "Name generator" - self.curset = 0 + self.current_set = 0 self.delay = 0.0 self.names: list[str] = [] self.sets: list[str] = [] @@ -1160,13 +1079,13 @@ def on_draw(self) -> None: sample_console.print( 1, 1, - "%s\n\n+ : next generator\n- : prev generator" % self.sets[self.curset], + f"{self.sets[self.current_set]}\n\n+ : next generator\n- : prev generator", fg=WHITE, bg=None, ) for i in range(len(self.names)): sample_console.print( - SAMPLE_SCREEN_WIDTH - 2, + SAMPLE_SCREEN_WIDTH - 1, 2 + i, self.names[i], fg=WHITE, @@ -1176,18 +1095,18 @@ def on_draw(self) -> None: self.delay += frame_length[-1] if self.delay > 0.5: self.delay -= 0.5 - self.names.append(libtcodpy.namegen_generate(self.sets[self.curset])) + self.names.append(libtcodpy.namegen_generate(self.sets[self.current_set])) def ev_keydown(self, event: tcod.event.KeyDown) -> None: if event.sym == tcod.event.KeySym.EQUALS: - self.curset += 1 + self.current_set += 1 self.names.append("======") elif event.sym == tcod.event.KeySym.MINUS: - self.curset -= 1 + self.current_set -= 1 self.names.append("======") else: super().ev_keydown(event) - self.curset %= len(self.sets) + self.current_set %= len(self.sets) ############################################# @@ -1213,8 +1132,8 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: # the coordinates of all tiles in the screen, as numpy arrays. # example: (4x3 pixels screen) -# xc = [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]] -# yc = [[1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3]] +# xc = [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]] # noqa: ERA001 +# yc = [[1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3]] # noqa: ERA001 if numpy_available: (xc, yc) = np.meshgrid(range(SCREEN_W), range(SCREEN_H)) # translate coordinates of all pixels to center @@ -1290,14 +1209,14 @@ def on_draw(self) -> None: # new pixels are based on absolute elapsed time int_abs_t = int(self.abs_t) - texture = np.roll(texture, -int_t, 1) + texture = np.roll(texture, -int_t, 1) # type: ignore[assignment] # replace new stretch of texture with new values for v in range(RES_V - int_t, RES_V): - for u in range(0, RES_U): + for u in range(RES_U): tex_v = (v + int_abs_t) / float(RES_V) - texture[u, v] = tcod.noise_get_fbm(noise2d, [u / float(RES_U), tex_v], 32.0) + tcod.noise_get_fbm( - noise2d, [1 - u / float(RES_U), tex_v], 32.0 - ) + texture[u, v] = libtcodpy.noise_get_fbm( + noise2d, [u / float(RES_U), tex_v], 32.0 + ) + libtcodpy.noise_get_fbm(noise2d, [1 - u / float(RES_U), tex_v], 32.0) # squared distance from center, # clipped to sensible minimum and maximum values @@ -1315,9 +1234,9 @@ def on_draw(self) -> None: brightness = texture[uu.astype(int), vv.astype(int)] / 4.0 + 0.5 # use the brightness map to compose the final color of the tunnel - R = brightness * self.tex_r - G = brightness * self.tex_g - B = brightness * self.tex_b + rr = brightness * self.tex_r + gg = brightness * self.tex_g + bb = brightness * self.tex_b # create new light source if random.random() <= time_delta * LIGHTS_CHANCE and len(self.lights) < MAX_LIGHTS: @@ -1325,9 +1244,9 @@ def on_draw(self) -> None: y = random.uniform(-0.5, 0.5) strength = random.uniform(MIN_LIGHT_STRENGTH, 1.0) - color = tcod.Color(0, 0, 0) # create bright colors with random hue + color = libtcodpy.Color(0, 0, 0) # create bright colors with random hue hue = random.uniform(0, 360) - tcod.color_set_hsv(color, hue, 0.5, strength) + libtcodpy.color_set_hsv(color, hue, 0.5, strength) self.lights.append(Light(x, y, TEX_STRETCH, color.r, color.g, color.b, strength)) # eliminate lights that are going to be out of view @@ -1347,17 +1266,17 @@ def on_draw(self) -> None: brightness = light_brightness / ((xc - xl) ** 2 + (yc - yl) ** 2) # make all pixels shine around this light - R += brightness * light.r - G += brightness * light.g - B += brightness * light.b + rr += brightness * light.r + gg += brightness * light.g + bb += brightness * light.b # truncate values - R = R.clip(0, 255) - G = G.clip(0, 255) - B = B.clip(0, 255) + rr = rr.clip(0, 255) + gg = gg.clip(0, 255) + bb = bb.clip(0, 255) # fill the screen with these background colors - sample_console.bg.transpose(2, 1, 0)[...] = (R, G, B) + sample_console.bg.transpose(2, 1, 0)[...] = (rr, gg, bb) ############################################# @@ -1403,15 +1322,13 @@ def init_context(renderer: int) -> None: global context, console_render, sample_minimap if "context" in globals(): context.close() - libtcod_version = "%i.%i.%i" % ( - tcod.cffi.lib.TCOD_MAJOR_VERSION, - tcod.cffi.lib.TCOD_MINOR_VERSION, - tcod.cffi.lib.TCOD_PATCHLEVEL, + libtcod_version = ( + f"{tcod.cffi.lib.TCOD_MAJOR_VERSION}.{tcod.cffi.lib.TCOD_MINOR_VERSION}.{tcod.cffi.lib.TCOD_PATCHLEVEL}" ) context = tcod.context.new( columns=root_console.width, rows=root_console.height, - title=f"python-tcod samples (python-tcod {tcod.__version__}, libtcod {libtcod_version})", + title=f"""python-tcod samples (python-tcod {importlib.metadata.version("tcod")}, libtcod {libtcod_version})""", vsync=False, # VSync turned off since this is for benchmarking. tileset=tileset, ) @@ -1449,6 +1366,9 @@ def main() -> None: sample_console.blit(root_console, SAMPLE_SCREEN_X, SAMPLE_SCREEN_Y) draw_stats() if context.sdl_renderer: + # Clear the screen to ensure no garbage data outside of the logical area is displayed + context.sdl_renderer.draw_color = (0, 0, 0, 255) + context.sdl_renderer.clear() # SDL renderer support, upload the sample console background to a minimap texture. sample_minimap.update(sample_console.rgb.T["bg"]) # Render the root_console normally, this is the drawing step of context.present without presenting. @@ -1503,7 +1423,9 @@ def handle_events() -> None: SAMPLES[cur_sample].dispatch(event) if isinstance(event, tcod.event.Quit): - raise SystemExit() + raise SystemExit + if isinstance(event, tcod.event.KeyDown) and event.sym == tcod.event.KeySym.ESCAPE: + raise SystemExit def draw_samples_menu() -> None: @@ -1517,7 +1439,7 @@ def draw_samples_menu() -> None: root_console.print( 2, 46 - (len(SAMPLES) - i), - " %s" % sample.name.ljust(19), + f" {sample.name.ljust(19)}", fg, bg, alignment=libtcodpy.LEFT, @@ -1530,16 +1452,16 @@ def draw_stats() -> None: except ZeroDivisionError: fps = 0 root_console.print( - 79, + root_console.width, 46, - "last frame :%5.1f ms (%4d fps)" % (frame_length[-1] * 1000.0, fps), + f"last frame :{frame_length[-1] * 1000.0:5.1f} ms ({int(fps):4d} fps)", fg=GREY, alignment=libtcodpy.RIGHT, ) root_console.print( - 79, + root_console.width, 47, - "elapsed : %8d ms %5.2fs" % (time.perf_counter() * 1000, time.perf_counter()), + f"elapsed : {int(_get_elapsed_time() * 1000):8d} ms {_get_elapsed_time():5.2f}s", fg=GREY, alignment=libtcodpy.RIGHT, ) diff --git a/examples/sdl-hello-world.py b/examples/sdl-hello-world.py index b9c07547..67b0fa01 100644 --- a/examples/sdl-hello-world.py +++ b/examples/sdl-hello-world.py @@ -1,8 +1,9 @@ """Hello world using tcod's SDL API and using Pillow for the TTF rendering.""" + from pathlib import Path import numpy as np -from PIL import Image, ImageDraw, ImageFont # type: ignore # pip install Pillow +from PIL import Image, ImageDraw, ImageFont # pip install Pillow import tcod.event import tcod.sdl.render @@ -14,8 +15,9 @@ def render_text(renderer: tcod.sdl.render.Renderer, text: str) -> tcod.sdl.render.Texture: """Render text, upload it to VRAM, then return it as an SDL Texture.""" - # Use Pillow normally to render the font. This code is standard. - width, height = font.getsize(text) + # Use Pillow to render the font. + _left, _top, right, bottom = font.getbbox(text) + width, height = int(right), int(bottom) image = Image.new("RGBA", (width, height)) draw = ImageDraw.Draw(image) draw.text((0, 0), text, font=font) @@ -41,7 +43,7 @@ def main() -> None: renderer.present() for event in tcod.event.get(): if isinstance(event, tcod.event.Quit): - raise SystemExit() + raise SystemExit if __name__ == "__main__": diff --git a/examples/termbox/README.md b/examples/termbox/README.md index 3ec77f56..36f13c20 100644 --- a/examples/termbox/README.md +++ b/examples/termbox/README.md @@ -4,7 +4,6 @@ The code here are modified files from [termbox repository](https://github.com/nsf/termbox/), so please consult it for the license and other info. - The code consists of two part - `termbox.py` module with API, translation of official binding form the description below into `tld`: @@ -14,7 +13,6 @@ And the example `termboxtest.py` which is copied verbatim from: https://github.com/nsf/termbox/blob/b20c0a11/test_termboxmodule.py - ### API Mapping Notes Notes taken while mapping the Termbox class: @@ -41,19 +39,19 @@ Notes taken while mapping the Termbox class: - init... - tdl doesn't allow to resize window (or rather libtcod) - tb works in existing terminal window and queries it rather than making own + init... + tdl doesn't allow to resize window (or rather libtcod) + tb works in existing terminal window and queries it rather than making own - colors... - tdl uses RGB values - tb uses it own constants + colors... + tdl uses RGB values + tb uses it own constants - event... - tb returns event one by one - tdl return an event iterator + event... + tb returns event one by one + tdl return an event iterator - tb Event tdl Event - .type .type - EVENT_KEY KEYDOWN + tb Event tdl Event + .type .type + EVENT_KEY KEYDOWN diff --git a/examples/termbox/termbox.py b/examples/termbox/termbox.py index 26ab2d7a..4634c990 100755 --- a/examples/termbox/termbox.py +++ b/examples/termbox/termbox.py @@ -15,6 +15,8 @@ [ ] not all keys/events are mapped """ +# ruff: noqa + class TermboxException(Exception): def __init__(self, msg) -> None: @@ -264,7 +266,7 @@ def peek_event(self, timeout=0): else: uch = None """ - pass # return (e.type, uch, e.key, e.mod, e.w, e.h, e.x, e.y) + # return (e.type, uch, e.key, e.mod, e.w, e.h, e.x, e.y) def poll_event(self): """Wait for an event and return it. diff --git a/examples/termbox/termboxtest.py b/examples/termbox/termboxtest.py index c0d57065..696be1ce 100755 --- a/examples/termbox/termboxtest.py +++ b/examples/termbox/termboxtest.py @@ -1,7 +1,9 @@ -#!/usr/bin/python +#!/usr/bin/env python import termbox +# ruff: noqa + spaceord = ord(" ") diff --git a/examples/thread_jobs.py b/examples/thread_jobs.py index d6fa54fd..9f6cf6c1 100755 --- a/examples/thread_jobs.py +++ b/examples/thread_jobs.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # To the extent possible under law, the libtcod maintainers have waived all # copyright and related or neighboring rights for this example. This work is # published from: United States. @@ -13,12 +13,13 @@ Typically the field-of-view tasks run good but not great, and the path-finding tasks run poorly. """ + import concurrent.futures import multiprocessing import platform import sys import timeit -from typing import Callable, List, Tuple +from collections.abc import Callable import tcod.map @@ -36,35 +37,35 @@ def test_fov(map_: tcod.map.Map) -> tcod.map.Map: return map_ -def test_fov_single(maps: List[tcod.map.Map]) -> None: +def test_fov_single(maps: list[tcod.map.Map]) -> None: for map_ in maps: test_fov(map_) -def test_fov_threads(executor: concurrent.futures.Executor, maps: List[tcod.map.Map]) -> None: +def test_fov_threads(executor: concurrent.futures.Executor, maps: list[tcod.map.Map]) -> None: for _result in executor.map(test_fov, maps): pass -def test_astar(map_: tcod.map.Map) -> List[Tuple[int, int]]: +def test_astar(map_: tcod.map.Map) -> list[tuple[int, int]]: astar = tcod.path.AStar(map_) return astar.get_path(0, 0, MAP_WIDTH - 1, MAP_HEIGHT - 1) -def test_astar_single(maps: List[tcod.map.Map]) -> None: +def test_astar_single(maps: list[tcod.map.Map]) -> None: for map_ in maps: test_astar(map_) -def test_astar_threads(executor: concurrent.futures.Executor, maps: List[tcod.map.Map]) -> None: +def test_astar_threads(executor: concurrent.futures.Executor, maps: list[tcod.map.Map]) -> None: for _result in executor.map(test_astar, maps): pass def run_test( - maps: List[tcod.map.Map], - single_func: Callable[[List[tcod.map.Map]], None], - multi_func: Callable[[concurrent.futures.Executor, List[tcod.map.Map]], None], + maps: list[tcod.map.Map], + single_func: Callable[[list[tcod.map.Map]], None], + multi_func: Callable[[concurrent.futures.Executor, list[tcod.map.Map]], None], ) -> None: """Run a function designed for a single thread and compare it to a threaded version. @@ -76,7 +77,7 @@ def run_test( for i in range(1, THREADS + 1): executor = concurrent.futures.ThreadPoolExecutor(i) multi_time = min(timeit.repeat(lambda: multi_func(executor, maps), number=1, repeat=REPEAT)) - print(f"{i} threads: {multi_time * 1000:.2f}ms, " f"{single_time / (multi_time * i) * 100:.2f}% efficiency") + print(f"{i} threads: {multi_time * 1000:.2f}ms, {single_time / (multi_time * i) * 100:.2f}% efficiency") def main() -> None: @@ -88,13 +89,10 @@ def main() -> None: print(f"Python {sys.version}\n{platform.platform()}\n{platform.processor()}") - print(f"\nComputing field-of-view for " f"{len(maps)} empty {MAP_WIDTH}x{MAP_HEIGHT} maps.") + print(f"\nComputing field-of-view for {len(maps)} empty {MAP_WIDTH}x{MAP_HEIGHT} maps.") run_test(maps, test_fov_single, test_fov_threads) - print( - f"\nComputing AStar from corner to corner {len(maps)} times " - f"on separate empty {MAP_WIDTH}x{MAP_HEIGHT} maps." - ) + print(f"\nComputing AStar from corner to corner {len(maps)} times on separate empty {MAP_WIDTH}x{MAP_HEIGHT} maps.") run_test(maps, test_astar_single, test_astar_threads) diff --git a/examples/ttf.py b/examples/ttf.py index da412b4b..fb1e35f6 100755 --- a/examples/ttf.py +++ b/examples/ttf.py @@ -1,28 +1,31 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """A TrueType Font example using the FreeType library. You will need to get this external library from PyPI: pip install freetype-py """ + # To the extent possible under law, the libtcod maintainers have waived all # copyright and related or neighboring rights to this example script. # https://creativecommons.org/publicdomain/zero/1.0/ -from typing import Tuple +from typing import TYPE_CHECKING import freetype # type: ignore # pip install freetype-py import numpy as np -from numpy.typing import NDArray import tcod.console import tcod.context import tcod.event import tcod.tileset +if TYPE_CHECKING: + from numpy.typing import NDArray + FONT = "VeraMono.ttf" -def load_ttf(path: str, size: Tuple[int, int]) -> tcod.tileset.Tileset: +def load_ttf(path: str, size: tuple[int, int]) -> tcod.tileset.Tileset: """Load a TTF file and return a tcod Tileset. `path` is the file path to the font, this can be any font supported by the @@ -80,7 +83,7 @@ def main() -> None: context.present(console, integer_scaling=True) for event in tcod.event.wait(): if isinstance(event, tcod.event.Quit): - raise SystemExit() + raise SystemExit if isinstance(event, tcod.event.WindowResized) and event.type == "WindowSizeChanged": # Resize the Tileset to match the new screen size. context.change_tileset( diff --git a/libtcodpy.py b/libtcodpy.py index 7947f394..76317e99 100644 --- a/libtcodpy.py +++ b/libtcodpy.py @@ -1,4 +1,5 @@ """Deprecated module alias for tcod.libtcodpy, use 'import tcod as libtcodpy' instead.""" + import warnings from tcod.libtcodpy import * # noqa: F403 diff --git a/pyproject.toml b/pyproject.toml index 2fbc6ebb..eac81af3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,10 @@ [build-system] requires = [ - # Newer versions of setuptools break editable installs + # setuptools >=64.0.0 might break editable installs # https://github.com/pypa/setuptools/issues/3548 - "setuptools >=61.0.0, <64.0.0", + "setuptools >=77.0.3", "setuptools_scm[toml]>=6.2", + "packaging>=24.2", "wheel>=0.37.1", "cffi>=1.15", "pycparser>=2.14", @@ -18,12 +19,17 @@ dynamic = ["version"] description = "The official Python port of libtcod." authors = [{ name = "Kyle Benesch", email = "4b796c65+tcod@gmail.com" }] readme = "README.rst" -requires-python = ">=3.7" -license = { text = "Simplified BSD License" } +requires-python = ">=3.10" +license = "BSD-2-Clause" +license-files = [ + "LICENSE.txt", + "libtcod/LICENSE.txt", + "libtcod/LIBTCOD-CREDITS.txt", +] dependencies = [ "cffi>=1.15", 'numpy>=1.21.4; implementation_name != "pypy"', - "typing_extensions", + "typing_extensions>=4.12.2", ] keywords = [ "roguelike", @@ -39,17 +45,15 @@ classifiers = [ "Environment :: MacOS X", "Environment :: X11 Applications", "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", "Natural Language :: English", "Operating System :: POSIX", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Games/Entertainment", @@ -70,21 +74,11 @@ Tracker = "https://github.com/libtcod/python-tcod/issues" Forum = "https://github.com/libtcod/python-tcod/discussions" [tool.distutils.bdist_wheel] -py-limited-api = "cp37" +py-limited-api = "cp310" [tool.setuptools_scm] write_to = "tcod/version.py" -[tool.black] -line-length = 120 -target-version = ["py37"] - -[tool.isort] -profile = "black" -py_version = "37" -skip_gitignore = true -line_length = 120 - [tool.pytest.ini_options] minversion = "6.0" required_plugins = ["pytest-cov", "pytest-benchmark"] @@ -99,15 +93,14 @@ addopts = [ log_file_level = "DEBUG" faulthandler_timeout = 5 filterwarnings = [ - "ignore::DeprecationWarning:tcod.libtcodpy", - "ignore::PendingDeprecationWarning:tcod.libtcodpy", + "ignore:This function may be deprecated in the future:PendingDeprecationWarning", "ignore:This class may perform poorly and is no longer needed.::tcod.map", "ignore:'import tcod as libtcodpy' is preferred.", ] [tool.mypy] files = ["."] -python_version = 3.9 +python_version = "3.10" warn_unused_configs = true show_error_codes = true disallow_subclassing_any = true @@ -146,51 +139,36 @@ module = "tcod._libtcod" ignore_missing_imports = true [tool.ruff] -# https://beta.ruff.rs/docs/rules/ -select = [ - "C90", # mccabe - "E", # pycodestyle - "W", # pycodestyle - "F", # Pyflakes - "I", # isort - "UP", # pyupgrade - "YTT", # flake8-2020 - "ANN", # flake8-annotations - "S", # flake8-bandit - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "DTZ", # flake8-datetimez - "EM", # flake8-errmsg - "EXE", # flake8-executable - "RET", # flake8-return - "ICN", # flake8-import-conventions - "PIE", # flake8-pie - "PT", # flake8-pytest-style - "SIM", # flake8-simplify - "PTH", # flake8-use-pathlib - "PL", # Pylint - "TRY", # tryceratops - "RUF", # NumPy-specific rules - "G", # flake8-logging-format - "D", # pydocstyle -] +extend-exclude = ["libtcod"] # Ignore submodule +line-length = 120 + +[tool.ruff.lint] # https://docs.astral.sh/ruff/rules/ +select = ["ALL"] ignore = [ - "E501", # line-too-long - "S101", # assert - "S301", # suspicious-pickle-usage - "S311", # suspicious-non-cryptographic-random-usage - "ANN101", # missing-type-self - "ANN102", # missing-type-cls + "COM", # flake8-commas "D203", # one-blank-line-before-class "D204", # one-blank-line-after-class "D213", # multi-line-summary-second-line "D407", # dashed-underline-after-section "D408", # section-underline-after-name "D409", # section-underline-matches-section-length + "D206", # indent-with-spaces + "E501", # line-too-long + "PYI064", # redundant-final-literal + "S101", # assert + "S301", # suspicious-pickle-usage + "S311", # suspicious-non-cryptographic-random-usage + "SLF001", # private-member-access + "W191", # tab-indentation +] +[tool.ruff.lint.per-file-ignores] +"**/{tests}/*" = [ + "D103", # undocumented-public-function +] +"**/{tests,docs,examples,scripts}/*" = [ + "D103", # undocumented-public-function + "T201", # print ] -extend-exclude = ["libtcod"] # Ignore submodule -line-length = 120 -[tool.ruff.pydocstyle] -# Use Google-style docstrings. +[tool.ruff.lint.pydocstyle] # https://docs.astral.sh/ruff/settings/#lintpydocstyle convention = "google" diff --git a/requirements.txt b/requirements.txt index ee1ea8a0..06667de0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,12 @@ +attrs>=23.1.0 cffi>=1.15 numpy>=1.21.4 pycparser>=2.14 requests>=2.28.1 -setuptools==65.5.1 +setuptools>=80.8.0 types-cffi types-requests +types-Pillow types-setuptools types-tabulate typing_extensions diff --git a/scripts/generate_charmap_table.py b/scripts/generate_charmap_table.py index 0591cb9b..2a7814b7 100755 --- a/scripts/generate_charmap_table.py +++ b/scripts/generate_charmap_table.py @@ -1,21 +1,22 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """This script is used to generate the tables for `charmap-reference.rst`. Uses the tabulate module from PyPI. """ + from __future__ import annotations import argparse import unicodedata -from typing import Iterable, Iterator +from collections.abc import Iterable, Iterator from tabulate import tabulate import tcod.tileset -def get_charmaps() -> Iterator[str]: - """Return an iterator of the current character maps from tcod.tilest.""" +def get_character_maps() -> Iterator[str]: + """Return an iterator of the current character maps from tcod.tileset.""" for name in dir(tcod.tileset): if name.startswith("CHARMAP_"): yield name[len("CHARMAP_") :].lower() @@ -60,7 +61,7 @@ def main() -> None: parser.add_argument( "charmap", action="store", - choices=list(get_charmaps()), + choices=list(get_character_maps()), type=str, help="which character map to generate a table from", ) diff --git a/scripts/get_release_description.py b/scripts/get_release_description.py index 7d47d9f9..889feced 100755 --- a/scripts/get_release_description.py +++ b/scripts/get_release_description.py @@ -1,5 +1,6 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Print the description used for GitHub Releases.""" + from __future__ import annotations import re diff --git a/scripts/tag_release.py b/scripts/tag_release.py index 70b77fb7..066eaeda 100755 --- a/scripts/tag_release.py +++ b/scripts/tag_release.py @@ -1,5 +1,6 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Automate tagged releases of this project.""" + from __future__ import annotations import argparse @@ -34,16 +35,14 @@ def parse_changelog(args: argparse.Namespace) -> tuple[str, str]: ) assert match header, changes, tail = match.groups() - tagged = "\n## [{}] - {}\n{}".format( - args.tag, - datetime.date.today().isoformat(), # Local timezone is fine, probably. # noqa: DTZ011 - changes, - ) + + iso_date = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat() + tagged = f"\n## [{args.tag}] - {iso_date}\n{changes}" if args.verbose: print("--- Tagged section:") print(tagged) - return "".join((header, tagged, tail)), changes + return f"{header}{tagged}{tail}", changes def replace_unreleased_tags(tag: str, dry_run: bool) -> None: diff --git a/setup.py b/setup.py index dc2139e7..a98b64b3 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Python-tcod setup script.""" + from __future__ import annotations import platform @@ -9,6 +10,8 @@ from setuptools import setup +# ruff: noqa: T201 + SDL_VERSION_NEEDED = (2, 0, 5) SETUP_DIR = Path(__file__).parent # setup.py current directory @@ -16,7 +19,7 @@ def get_package_data() -> list[str]: """Get data files which will be included in the main tcod/ directory.""" - BIT_SIZE, _ = platform.architecture() + bit_size, _ = platform.architecture() files = [ "py.typed", "lib/LIBTCOD-CREDITS.txt", @@ -24,7 +27,7 @@ def get_package_data() -> list[str]: "lib/README-SDL.txt", ] if "win32" in sys.platform: - if BIT_SIZE == "32bit": + if bit_size == "32bit": files += ["x86/SDL2.dll"] else: files += ["x64/SDL2.dll"] @@ -39,7 +42,7 @@ def check_sdl_version() -> None: return needed_version = "{}.{}.{}".format(*SDL_VERSION_NEEDED) try: - sdl_version_str = subprocess.check_output(["sdl2-config", "--version"], universal_newlines=True).strip() + sdl_version_str = subprocess.check_output(["sdl2-config", "--version"], universal_newlines=True).strip() # noqa: S603, S607 except FileNotFoundError as exc: msg = ( f"libsdl2-dev or equivalent must be installed on your system and must be at least version {needed_version}." diff --git a/tcod/__init__.py b/tcod/__init__.py index b8ea0d25..2db18f54 100644 --- a/tcod/__init__.py +++ b/tcod/__init__.py @@ -6,34 +6,35 @@ Read the documentation online: https://python-tcod.readthedocs.io/en/latest/ """ + from __future__ import annotations from pkgutil import extend_path __path__ = extend_path(__path__, __name__) -from tcod import bsp, color, console, context, event, image, los, map, noise, path, random, tileset +from tcod import bsp, color, console, context, event, image, los, map, noise, path, random, tileset # noqa: A004 from tcod.cffi import __sdl_version__, ffi, lib from tcod.tcod import __getattr__ # noqa: F401 from tcod.version import __version__ __all__ = [ - "__version__", + "Console", "__sdl_version__", - "lib", - "ffi", + "__version__", "bsp", "color", "console", "context", "event", - "tileset", + "ffi", "image", + "lib", "los", "map", "noise", "path", "random", "tileset", - "Console", + "tileset", ] diff --git a/tcod/__pyinstaller/__init__.py b/tcod/__pyinstaller/__init__.py index 7224bb10..2b67c1c0 100644 --- a/tcod/__pyinstaller/__init__.py +++ b/tcod/__pyinstaller/__init__.py @@ -1,4 +1,5 @@ """PyInstaller entry point for tcod.""" + from __future__ import annotations from pathlib import Path diff --git a/tcod/__pyinstaller/hook-tcod.py b/tcod/__pyinstaller/hook-tcod.py index 9790d583..b0cabf38 100644 --- a/tcod/__pyinstaller/hook-tcod.py +++ b/tcod/__pyinstaller/hook-tcod.py @@ -5,6 +5,7 @@ If this hook is ever modified then the contributed hook needs to be removed from: https://github.com/pyinstaller/pyinstaller-hooks-contrib """ + from PyInstaller.utils.hooks import collect_dynamic_libs # type: ignore hiddenimports = ["_cffi_backend"] diff --git a/tcod/_internal.py b/tcod/_internal.py index cd92a45c..3391a602 100644 --- a/tcod/_internal.py +++ b/tcod/_internal.py @@ -1,52 +1,58 @@ """Internal helper functions used by the rest of the library.""" + from __future__ import annotations -import functools +import locale +import sys import warnings -from types import TracebackType -from typing import Any, AnyStr, Callable, NoReturn, SupportsInt, TypeVar, cast +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, AnyStr, Literal, NoReturn, SupportsInt, TypeVar -import numpy as np -from numpy.typing import ArrayLike, NDArray -from typing_extensions import Literal +from typing_extensions import LiteralString, deprecated from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from pathlib import Path + from types import TracebackType + + FuncType = Callable[..., Any] F = TypeVar("F", bound=FuncType) T = TypeVar("T") -def deprecate(message: str, category: type[Warning] = DeprecationWarning, stacklevel: int = 0) -> Callable[[F], F]: - """Return a decorator which adds a warning to functions.""" +def _deprecate_passthrough( + message: str, # noqa: ARG001 + /, + *, + category: type[Warning] = DeprecationWarning, # noqa: ARG001 + stacklevel: int = 0, # noqa: ARG001 +) -> Callable[[F], F]: + """Return a decorator which skips wrapping a warning onto functions. This is used for non-debug runs.""" def decorator(func: F) -> F: - if not __debug__: - return func + return func - @functools.wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 - warnings.warn(message, category, stacklevel=stacklevel + 2) - return func(*args, **kwargs) + return decorator - return cast(F, wrapper) - return decorator +deprecate = deprecated if __debug__ or TYPE_CHECKING else _deprecate_passthrough def pending_deprecate( - message: str = "This function may be deprecated in the future." + message: LiteralString = "This function may be deprecated in the future." " Consider raising an issue on GitHub if you need this feature.", category: type[Warning] = PendingDeprecationWarning, stacklevel: int = 0, ) -> Callable[[F], F]: """Like deprecate, but the default parameters are filled out for a generic pending deprecation warning.""" - return deprecate(message, category, stacklevel) + return deprecate(message, category=category, stacklevel=stacklevel) def verify_order(order: Literal["C", "F"]) -> Literal["C", "F"]: """Verify and return a Numpy order string.""" - order = order.upper() # type: ignore + order = order.upper() # type: ignore[assignment] if order not in ("C", "F"): msg = f"order must be 'C' or 'F', not {order!r}" raise TypeError(msg) @@ -86,7 +92,7 @@ def _check_warn(error: int, stacklevel: int = 2) -> int: def _unpack_char_p(char_p: Any) -> str: # noqa: ANN401 if char_p == ffi.NULL: return "" - return ffi.string(char_p).decode() # type: ignore + return str(ffi.string(char_p), encoding="utf-8") def _int(int_or_str: SupportsInt | str | bytes) -> int: @@ -112,7 +118,7 @@ def _unicode(string: AnyStr, stacklevel: int = 2) -> str: stacklevel=stacklevel + 1, ) return string.decode("latin-1") - return string + return str(string) def _fmt(string: str, stacklevel: int = 2) -> bytes: @@ -126,6 +132,18 @@ def _fmt(string: str, stacklevel: int = 2) -> bytes: return string.encode("utf-8").replace(b"%", b"%%") +def _path_encode(path: Path) -> bytes: + """Return a bytes file path for the current locale when on Windows, uses fsdecode for other platforms.""" + if sys.platform != "win32": + return bytes(path) # Sane and expected behavior for converting Path into bytes + try: + return str(path).encode(locale.getlocale()[1] or "utf-8") # Stay classy, Windows + except UnicodeEncodeError as exc: + if sys.version_info >= (3, 11): + exc.add_note("""Consider calling 'locale.setlocale(locale.LC_CTYPES, ".UTF8")' to support Unicode paths.""") + raise + + class _PropagateException: """Context manager designed to propagate exceptions outside of a cffi callback context. @@ -154,7 +172,7 @@ def __enter__(self) -> Callable[[Any], None]: return self.propagate def __exit__( - self, type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None + self, _type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None ) -> None: """If we're holding on to an exception, raise it now. @@ -183,18 +201,17 @@ def _get_cdata_from_args(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 def __hash__(self) -> int: return hash(self.cdata) - def __eq__(self, other: Any) -> Any: - try: - return self.cdata == other.cdata - except AttributeError: + def __eq__(self, other: object) -> bool: + if not isinstance(other, _CDataWrapper): return NotImplemented + return bool(self.cdata == other.cdata) - def __getattr__(self, attr: str) -> Any: + def __getattr__(self, attr: str) -> Any: # noqa: ANN401 if "cdata" in self.__dict__: return getattr(self.__dict__["cdata"], attr) raise AttributeError(attr) - def __setattr__(self, attr: str, value: Any) -> None: + def __setattr__(self, attr: str, value: Any) -> None: # noqa: ANN401 if hasattr(self, "cdata") and hasattr(self.cdata, attr): setattr(self.cdata, attr, value) else: @@ -216,42 +233,3 @@ def _console(console: Any) -> Any: # noqa: ANN401 stacklevel=3, ) return ffi.NULL - - -class TempImage: - """An Image-like container for NumPy arrays.""" - - def __init__(self, array: ArrayLike) -> None: - """Initialize an image from the given array. May copy or reference the array.""" - self._array: NDArray[np.uint8] = np.ascontiguousarray(array, dtype=np.uint8) - height, width, depth = self._array.shape - if depth != 3: - msg = f"Array must have RGB channels. Shape is: {self._array.shape!r}" - raise TypeError(msg) - self._buffer = ffi.from_buffer("TCOD_color_t[]", self._array) - self._mipmaps = ffi.new( - "struct TCOD_mipmap_*", - { - "width": width, - "height": height, - "fwidth": width, - "fheight": height, - "buf": self._buffer, - "dirty": True, - }, - ) - self.image_c = ffi.new( - "TCOD_Image*", - { - "nb_mipmaps": 1, - "mipmaps": self._mipmaps, - "has_key_color": False, - }, - ) - - -def _as_image(image: Any) -> TempImage: - """Convert this input into an Image-like object.""" - if hasattr(image, "image_c"): - return image # type: ignore - return TempImage(image) diff --git a/tcod/bsp.py b/tcod/bsp.py index a53b8f67..f4d7204d 100644 --- a/tcod/bsp.py +++ b/tcod/bsp.py @@ -24,14 +24,19 @@ else: print('Dig a room for %s.' % node) """ + from __future__ import annotations -from typing import Any, Iterator +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any + +from typing_extensions import deprecated -import tcod.random -from tcod._internal import deprecate from tcod.cffi import ffi, lib +if TYPE_CHECKING: + import tcod.random + class BSP: """A binary space partitioning tree which can be used for simple dungeon generation. @@ -71,7 +76,7 @@ def __init__(self, x: int, y: int, width: int, height: int) -> None: self.children: tuple[()] | tuple[BSP, BSP] = () @property - @deprecate("This attribute has been renamed to `width`.", FutureWarning) + @deprecated("This attribute has been renamed to `width`.", category=FutureWarning) def w(self) -> int: # noqa: D102 return self.width @@ -80,7 +85,7 @@ def w(self, value: int) -> None: self.width = value @property - @deprecate("This attribute has been renamed to `height`.", FutureWarning) + @deprecated("This attribute has been renamed to `height`.", category=FutureWarning) def h(self) -> int: # noqa: D102 return self.height @@ -88,7 +93,7 @@ def h(self) -> int: # noqa: D102 def h(self, value: int) -> None: self.height = value - def _as_cdata(self) -> Any: + def _as_cdata(self) -> Any: # noqa: ANN401 cdata = ffi.gc( lib.TCOD_bsp_new_with_size(self.x, self.y, self.width, self.height), lib.TCOD_bsp_delete, @@ -100,22 +105,11 @@ def __repr__(self) -> str: """Provide a useful readout when printed.""" status = "leaf" if self.children: - status = "split at position=%i,horizontal=%r" % ( - self.position, - self.horizontal, - ) - - return "<%s(x=%i,y=%i,width=%i,height=%i) level=%i %s>" % ( - self.__class__.__name__, - self.x, - self.y, - self.width, - self.height, - self.level, - status, - ) + status = f"split at position={self.position},horizontal={self.horizontal!r}" + + return f"<{self.__class__.__name__}(x={self.x},y={self.y},width={self.width},height={self.height}) level={self.level} {status}>" - def _unpack_bsp_tree(self, cdata: Any) -> None: + def _unpack_bsp_tree(self, cdata: Any) -> None: # noqa: ANN401 self.x = cdata.x self.y = cdata.y self.width = cdata.w @@ -176,7 +170,7 @@ def split_recursive( # noqa: PLR0913 ) self._unpack_bsp_tree(cdata) - @deprecate("Use pre_order method instead of walk.") + @deprecated("Use pre_order method instead of walk.") def walk(self) -> Iterator[BSP]: """Iterate over this BSP's hierarchy in pre order. @@ -234,13 +228,13 @@ def inverted_level_order(self) -> Iterator[BSP]: .. versionadded:: 8.3 """ levels: list[list[BSP]] = [] - next = [self] - while next: - levels.append(next) - level = next - next = [] + next_: list[BSP] = [self] + while next_: + levels.append(next_) + level = next_ + next_ = [] for node in level: - next.extend(node.children) + next_.extend(node.children) while levels: yield from levels.pop() diff --git a/tcod/cffi.py b/tcod/cffi.py index d733abea..d1c93025 100644 --- a/tcod/cffi.py +++ b/tcod/cffi.py @@ -1,4 +1,5 @@ """This module handles loading of the libtcod cffi API.""" + from __future__ import annotations import logging @@ -58,13 +59,13 @@ def get_sdl_version() -> str: verify_dependencies() -from tcod._libtcod import ffi, lib # noqa +from tcod._libtcod import ffi, lib # noqa: E402 __sdl_version__ = get_sdl_version() -@ffi.def_extern() # type: ignore -def _libtcod_log_watcher(message: Any, userdata: None) -> None: # noqa: ANN401 +@ffi.def_extern() # type: ignore[misc] +def _libtcod_log_watcher(message: Any, _userdata: None) -> None: # noqa: ANN401 text = str(ffi.string(message.message), encoding="utf-8") source = str(ffi.string(message.source), encoding="utf-8") level = int(message.level) @@ -75,4 +76,4 @@ def _libtcod_log_watcher(message: Any, userdata: None) -> None: # noqa: ANN401 lib.TCOD_set_log_callback(lib._libtcod_log_watcher, ffi.NULL) lib.TCOD_set_log_level(0) -__all__ = ["ffi", "lib", "__sdl_version__"] +__all__ = ["__sdl_version__", "ffi", "lib"] diff --git a/tcod/color.py b/tcod/color.py index dfd97b8c..61f18b49 100644 --- a/tcod/color.py +++ b/tcod/color.py @@ -1,14 +1,15 @@ """Old libtcod color management.""" + from __future__ import annotations import warnings -from typing import Any, List +from typing import Any from tcod._internal import deprecate from tcod.cffi import lib -class Color(List[int]): +class Color(list[int]): """Old-style libtcodpy color class. Args: @@ -17,7 +18,7 @@ class Color(List[int]): b (int): Blue value, from 0 to 255. """ - def __init__(self, r: int = 0, g: int = 0, b: int = 0) -> None: + def __init__(self, r: int = 0, g: int = 0, b: int = 0) -> None: # noqa: D107 list.__setitem__(self, slice(None), (r & 0xFF, g & 0xFF, b & 0xFF)) @property @@ -30,7 +31,7 @@ def r(self) -> int: return int(self[0]) @r.setter - @deprecate("Setting color attributes has been deprecated.", FutureWarning) + @deprecate("Setting color attributes has been deprecated.", category=FutureWarning) def r(self, value: int) -> None: self[0] = value & 0xFF @@ -44,7 +45,7 @@ def g(self) -> int: return int(self[1]) @g.setter - @deprecate("Setting color attributes has been deprecated.", FutureWarning) + @deprecate("Setting color attributes has been deprecated.", category=FutureWarning) def g(self, value: int) -> None: self[1] = value & 0xFF @@ -58,7 +59,7 @@ def b(self) -> int: return int(self[2]) @b.setter - @deprecate("Setting color attributes has been deprecated.", FutureWarning) + @deprecate("Setting color attributes has been deprecated.", category=FutureWarning) def b(self, value: int) -> None: self[2] = value & 0xFF @@ -81,14 +82,14 @@ def __getitem__(self, index: Any) -> Any: # noqa: ANN401 return super().__getitem__("rgb".index(index)) return super().__getitem__(index) - @deprecate("This class will not be mutable in the future.", FutureWarning) - def __setitem__(self, index: Any, value: Any) -> None: # noqa: ANN401 + @deprecate("This class will not be mutable in the future.", category=FutureWarning) + def __setitem__(self, index: Any, value: Any) -> None: # noqa: ANN401, D105 if isinstance(index, str): super().__setitem__("rgb".index(index), value) else: super().__setitem__(index, value) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: """Compare equality between colors. Also compares with standard sequences such as 3-item tuples or lists. @@ -98,8 +99,8 @@ def __eq__(self, other: Any) -> bool: except TypeError: return False - @deprecate("Use NumPy instead for color math operations.", FutureWarning) - def __add__(self, other: Any) -> Color: # type: ignore[override] + @deprecate("Use NumPy instead for color math operations.", category=FutureWarning) + def __add__(self, other: object) -> Color: # type: ignore[override] """Add two colors together. .. deprecated:: 9.2 @@ -107,8 +108,8 @@ def __add__(self, other: Any) -> Color: # type: ignore[override] """ return Color._new_from_cdata(lib.TCOD_color_add(self, other)) - @deprecate("Use NumPy instead for color math operations.", FutureWarning) - def __sub__(self, other: Any) -> Color: + @deprecate("Use NumPy instead for color math operations.", category=FutureWarning) + def __sub__(self, other: object) -> Color: """Subtract one color from another. .. deprecated:: 9.2 @@ -116,8 +117,8 @@ def __sub__(self, other: Any) -> Color: """ return Color._new_from_cdata(lib.TCOD_color_subtract(self, other)) - @deprecate("Use NumPy instead for color math operations.", FutureWarning) - def __mul__(self, other: Any) -> Color: + @deprecate("Use NumPy instead for color math operations.", category=FutureWarning) + def __mul__(self, other: object) -> Color: """Multiply with a scaler or another color. .. deprecated:: 9.2 diff --git a/tcod/console.py b/tcod/console.py index 36cc0aba..ca9d4253 100644 --- a/tcod/console.py +++ b/tcod/console.py @@ -1,23 +1,31 @@ -"""Libtcod consoles are a strictly tile-based representation of text and color. +"""Libtcod tile-based Consoles and printing functions. + +Libtcod consoles are a strictly tile-based representation of colored glyphs/tiles. To render a console you need a tileset and a window to render to. See :ref:`getting-started` for info on how to set those up. """ + from __future__ import annotations import warnings -from os import PathLike +from collections.abc import Iterable from pathlib import Path -from typing import Any, Iterable +from typing import TYPE_CHECKING, Any, Literal, overload import numpy as np -from numpy.typing import NDArray -from typing_extensions import Literal +from typing_extensions import Self, deprecated import tcod._internal import tcod.constants -from tcod._internal import _check, deprecate +import tcod.image +from tcod._internal import _check, _path_encode from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from os import PathLike + + from numpy.typing import ArrayLike, NDArray + def _fmt(string: str) -> bytes: """Return a string that escapes 'C printf' side effects.""" @@ -26,7 +34,7 @@ def _fmt(string: str) -> bytes: _root_console = None -rgba_graphic = np.dtype([("ch", np.intc), ("fg", "4B"), ("bg", "4B")]) +rgba_graphic: np.dtype[Any] = np.dtype([("ch", np.intc), ("fg", "4B"), ("bg", "4B")]) """A NumPy :any:`dtype` compatible with :any:`Console.rgba`. This dtype is: ``np.dtype([("ch", np.intc), ("fg", "4B"), ("bg", "4B")])`` @@ -34,7 +42,7 @@ def _fmt(string: str) -> bytes: .. versionadded:: 12.3 """ -rgb_graphic = np.dtype([("ch", np.intc), ("fg", "3B"), ("bg", "3B")]) +rgb_graphic: np.dtype[Any] = np.dtype([("ch", np.intc), ("fg", "3B"), ("bg", "3B")]) """A NumPy :any:`dtype` compatible with :any:`Console.rgb`. This dtype is: ``np.dtype([("ch", np.intc), ("fg", "3B"), ("bg", "3B")])`` @@ -101,7 +109,7 @@ class Console: DTYPE = rgba_graphic # A structured array type with the added "fg_rgb" and "bg_rgb" fields. - _DTYPE_RGB = np.dtype( + _DTYPE_RGB: np.dtype[Any] = np.dtype( { "names": ["ch", "fg", "bg"], "formats": [np.int32, "3u1", "3u1"], @@ -117,8 +125,9 @@ def __init__( order: Literal["C", "F"] = "C", buffer: NDArray[Any] | None = None, ) -> None: + """Initialize the console.""" self._key_color: tuple[int, int, int] | None = None - self._order = tcod._internal.verify_order(order) + self._order: Literal["C", "F"] = tcod._internal.verify_order(order) if buffer is not None: if self._order == "F": buffer = buffer.transpose() @@ -166,7 +175,7 @@ def _get_root(cls, order: Literal["C", "F"] | None = None) -> Console: This function will also update an already active root console. """ - global _root_console + global _root_console # noqa: PLW0603 if _root_console is None: _root_console = object.__new__(cls) self: Console = _root_console @@ -178,7 +187,7 @@ def _get_root(cls, order: Literal["C", "F"] | None = None) -> Console: def _init_setup_console_data(self, order: Literal["C", "F"] = "C") -> None: """Setup numpy arrays over libtcod data buffers.""" - global _root_console + global _root_console # noqa: PLW0603 self._key_color = None if self.console_c == ffi.NULL: _root_console = self @@ -186,7 +195,7 @@ def _init_setup_console_data(self, order: Literal["C", "F"] = "C") -> None: else: self._console_data = ffi.cast("struct TCOD_Console*", self.console_c) - self._tiles: NDArray[Any] = np.frombuffer( # type: ignore + self._tiles = np.frombuffer( ffi.buffer(self._console_data.tiles[0 : self.width * self.height]), dtype=self.DTYPE, ).reshape((self.height, self.width)) @@ -196,12 +205,12 @@ def _init_setup_console_data(self, order: Literal["C", "F"] = "C") -> None: @property def width(self) -> int: """The width of this Console.""" - return lib.TCOD_console_get_width(self.console_c) # type: ignore + return int(lib.TCOD_console_get_width(self.console_c)) @property def height(self) -> int: """The height of this Console.""" - return lib.TCOD_console_get_height(self.console_c) # type: ignore + return int(lib.TCOD_console_get_height(self.console_c)) @property def bg(self) -> NDArray[np.uint8]: @@ -244,7 +253,7 @@ def ch(self) -> NDArray[np.intc]: return self._tiles["ch"].T if self._order == "F" else self._tiles["ch"] @property - @deprecate("This attribute has been renamed to `rgba`.", category=FutureWarning) + @deprecated("This attribute has been renamed to `rgba`.", category=FutureWarning) def tiles(self) -> NDArray[Any]: """An array of this consoles raw tile data. @@ -260,7 +269,7 @@ def tiles(self) -> NDArray[Any]: return self.rgba @property - @deprecate("This attribute has been renamed to `rgba`.", category=FutureWarning) + @deprecated("This attribute has been renamed to `rgba`.", category=FutureWarning) def buffer(self) -> NDArray[Any]: """An array of this consoles raw tile data. @@ -272,7 +281,7 @@ def buffer(self) -> NDArray[Any]: return self.rgba @property - @deprecate("This attribute has been renamed to `rgb`.", category=FutureWarning) + @deprecated("This attribute has been renamed to `rgb`.", category=FutureWarning) def tiles_rgb(self) -> NDArray[Any]: """An array of this consoles data without the alpha channel. @@ -284,7 +293,7 @@ def tiles_rgb(self) -> NDArray[Any]: return self.rgb @property - @deprecate("This attribute has been renamed to `rgb`.", category=FutureWarning) + @deprecated("This attribute has been renamed to `rgb`.", category=FutureWarning) def tiles2(self) -> NDArray[Any]: """This name is deprecated in favour of :any:`rgb`. @@ -310,8 +319,8 @@ def rgba(self) -> NDArray[Any]: ... (*WHITE, 255), ... (*BLACK, 255), ... ) - >>> con.rgba[0, 0] - (88, [255, 255, 255, 255], [ 0, 0, 0, 255]) + >>> print(f"{con.rgba[0, 0]=}") + con.rgba[0, 0]=...(88, [255, 255, 255, 255], [ 0, 0, 0, 255])... .. versionadded:: 12.3 """ @@ -327,59 +336,119 @@ def rgb(self) -> NDArray[Any]: The :any:`rgb_graphic` dtype can be used to make arrays compatible with this attribute that are independent of a :any:`Console`. + Example: + >>> tile_graphics = np.array( # Tile graphics lookup table + ... [ # (Unicode, foreground, background) + ... (ord("."), (255, 255, 255), (0, 0, 0)), # Tile 0 + ... (ord("#"), (255, 255, 255), (0, 0, 0)), # Tile 1 + ... (ord("^"), (255, 255, 255), (0, 0, 0)), # Tile 2 + ... (ord("~"), (255, 255, 255), (0, 0, 0)), # Tile 3 + ... ], + ... dtype=tcod.console.rgb_graphic, + ... ) + >>> console = tcod.console.Console(6, 5) + >>> console.rgb[:] = tile_graphics[ # Convert 2D array of indexes to tile graphics + ... [ + ... [1, 1, 1, 1, 1, 1], + ... [1, 0, 2, 0, 0, 1], + ... [1, 0, 0, 3, 3, 1], + ... [1, 0, 0, 3, 3, 1], + ... [1, 1, 1, 1, 1, 1], + ... ], + ... ] + >>> print(console) + <###### + #.^..# + #..~~# + #..~~# + ######> + Example: >>> con = tcod.console.Console(10, 2) >>> BLUE, YELLOW, BLACK = (0, 0, 255), (255, 255, 0), (0, 0, 0) >>> con.rgb[0, 0] = ord("@"), YELLOW, BLACK - >>> con.rgb[0, 0] - (64, [255, 255, 0], [0, 0, 0]) + >>> print(f"{con.rgb[0, 0]=}") + con.rgb[0, 0]=...(64, [255, 255, 0], [0, 0, 0])... >>> con.rgb["bg"] = BLUE - >>> con.rgb[0, 0] - (64, [255, 255, 0], [ 0, 0, 255]) + >>> print(f"{con.rgb[0, 0]=}") + con.rgb[0, 0]=...(64, [255, 255, 0], [ 0, 0, 255])... .. versionadded:: 12.3 """ return self.rgba.view(self._DTYPE_RGB) - @property + _DEPRECATE_CONSOLE_DEFAULTS_MSG = """Console defaults have been deprecated. +Consider one of the following: + + # Set parameters once then pass them as kwargs + DEFAULT_COLOR = {"bg": (0, 0, 127), "fg": (127, 127, 255)} + console.print(x, y, string, **DEFAULT_COLOR) + + # Clear the console to a color and then skip setting colors on printing/drawing + console.clear(fg=(127, 127, 255), bg=(0, 0, 127)) + console.print(x, y, string, fg=None) +""" + + @property # Getters used internally, so only deprecate the setters. def default_bg(self) -> tuple[int, int, int]: - """Tuple[int, int, int]: The default background color.""" + """Tuple[int, int, int]: The default background color. + + .. deprecated:: 8.5 + These should not be used. Prefer passing defaults as kwargs. + + .. code-block:: + + DEFAULT_COLOR = {"bg": (0, 0, 127), "fg": (127, 127, 255)} + console.print(x, y, string, **DEFAULT_COLOR) + """ color = self._console_data.back return color.r, color.g, color.b @default_bg.setter - @deprecate("Console defaults have been deprecated.", category=FutureWarning) + @deprecated(_DEPRECATE_CONSOLE_DEFAULTS_MSG, category=FutureWarning) def default_bg(self, color: tuple[int, int, int]) -> None: self._console_data.back = color @property def default_fg(self) -> tuple[int, int, int]: - """Tuple[int, int, int]: The default foreground color.""" + """Tuple[int, int, int]: The default foreground color. + + .. deprecated:: 8.5 + These should not be used. Prefer passing defaults as kwargs. + """ color = self._console_data.fore return color.r, color.g, color.b @default_fg.setter - @deprecate("Console defaults have been deprecated.", category=FutureWarning) + @deprecated(_DEPRECATE_CONSOLE_DEFAULTS_MSG, category=FutureWarning) def default_fg(self, color: tuple[int, int, int]) -> None: self._console_data.fore = color @property def default_bg_blend(self) -> int: - """int: The default blending mode.""" - return self._console_data.bkgnd_flag # type: ignore + """int: The default blending mode. + + .. deprecated:: 8.5 + These should not be used. Prefer passing defaults as kwargs. + """ + return int(self._console_data.bkgnd_flag) @default_bg_blend.setter - @deprecate("Console defaults have been deprecated.", category=FutureWarning) + @deprecated(_DEPRECATE_CONSOLE_DEFAULTS_MSG, category=FutureWarning) def default_bg_blend(self, value: int) -> None: self._console_data.bkgnd_flag = value @property def default_alignment(self) -> int: - """int: The default text alignment.""" - return self._console_data.alignment # type: ignore + """int: The default text alignment. + + .. deprecated:: 8.5 + These should not be used. Prefer passing defaults as kwargs. + """ + return int(self._console_data.alignment) @default_alignment.setter - @deprecate("Console defaults have been deprecated.", category=FutureWarning) + @deprecated(_DEPRECATE_CONSOLE_DEFAULTS_MSG, category=FutureWarning) def default_alignment(self, value: int) -> None: self._console_data.alignment = value @@ -387,15 +456,15 @@ def __clear_warning(self, name: str, value: tuple[int, int, int]) -> None: """Raise a warning for bad default values during calls to clear.""" warnings.warn( f"Clearing with the console default values is deprecated.\nAdd {name}={value!r} to this call.", - DeprecationWarning, + FutureWarning, stacklevel=3, ) def clear( self, ch: int = 0x20, - fg: tuple[int, int, int] = ..., # type: ignore - bg: tuple[int, int, int] = ..., # type: ignore + fg: tuple[int, int, int] = ..., # type: ignore[assignment] + bg: tuple[int, int, int] = ..., # type: ignore[assignment] ) -> None: """Reset all values in this console to a single value. @@ -414,11 +483,11 @@ def clear( Added the `ch`, `fg`, and `bg` parameters. Non-white-on-black default values are deprecated. """ - if fg is ...: # type: ignore + if fg is ...: # type: ignore[comparison-overlap] fg = self.default_fg if fg != (255, 255, 255): self.__clear_warning("fg", fg) - if bg is ...: # type: ignore + if bg is ...: # type: ignore[comparison-overlap] bg = self.default_bg if bg != (0, 0, 0): self.__clear_warning("bg", bg) @@ -441,24 +510,28 @@ def put_char( """ lib.TCOD_console_put_char(self.console_c, x, y, ch, bg_blend) - __ALIGNMENT_LOOKUP = {0: "tcod.LEFT", 1: "tcod.RIGHT", 2: "tcod.CENTER"} - - __BG_BLEND_LOOKUP = { - 0: "tcod.BKGND_NONE", - 1: "tcod.BKGND_SET", - 2: "tcod.BKGND_MULTIPLY", - 3: "tcod.BKGND_LIGHTEN", - 4: "tcod.BKGND_DARKEN", - 5: "tcod.BKGND_SCREEN", - 6: "tcod.BKGND_COLOR_DODGE", - 7: "tcod.BKGND_COLOR_BURN", - 8: "tcod.BKGND_ADD", - 9: "tcod.BKGND_ADDA", - 10: "tcod.BKGND_BURN", - 11: "tcod.BKGND_OVERLAY", - 12: "tcod.BKGND_ALPH", - 13: "tcod.BKGND_DEFAULT", - } + __ALIGNMENT_LOOKUP = ( + "tcod.LEFT", + "tcod.RIGHT", + "tcod.CENTER", + ) + + __BG_BLEND_LOOKUP = ( + "tcod.BKGND_NONE", + "tcod.BKGND_SET", + "tcod.BKGND_MULTIPLY", + "tcod.BKGND_LIGHTEN", + "tcod.BKGND_DARKEN", + "tcod.BKGND_SCREEN", + "tcod.BKGND_COLOR_DODGE", + "tcod.BKGND_COLOR_BURN", + "tcod.BKGND_ADD", + "tcod.BKGND_ADDA", + "tcod.BKGND_BURN", + "tcod.BKGND_OVERLAY", + "tcod.BKGND_ALPH", + "tcod.BKGND_DEFAULT", + ) def __deprecate_defaults( # noqa: C901, PLR0912 self, @@ -511,6 +584,7 @@ def __deprecate_defaults( # noqa: C901, PLR0912 stacklevel=3, ) + @deprecated("Switch to using keywords and then replace with 'console.print(...)'") def print_( self, x: int, @@ -538,7 +612,8 @@ def print_( alignment = self.default_alignment if alignment is None else alignment lib.TCOD_console_printf_ex(self.console_c, x, y, bg_blend, alignment, _fmt(string)) - def print_rect( + @deprecated("Switch to using keywords and then replace with 'console.print(...)'") + def print_rect( # noqa: PLR0913 self, x: int, y: int, @@ -604,13 +679,14 @@ def get_height_rect(self, x: int, y: int, width: int, height: int, string: str) string_ = string.encode("utf-8") return int(lib.TCOD_console_get_height_rect_n(self.console_c, x, y, width, height, len(string_), string_)) - def rect( + @deprecated("""Replace with 'console.draw_rect(x, y, width, height, ch=..., fg=..., bg=..., bg_blend=...)'""") + def rect( # noqa: PLR0913 self, x: int, y: int, width: int, height: int, - clear: bool, + clear: bool, # noqa: FBT001 bg_blend: int = tcod.constants.BKGND_DEFAULT, ) -> None: """Draw a the background color on a rect optionally clearing the text. @@ -636,6 +712,9 @@ def rect( self.__deprecate_defaults("draw_rect", bg_blend, clear=bool(clear)) lib.TCOD_console_rect(self.console_c, x, y, width, height, clear, bg_blend) + @deprecated( + """Replace with 'console.draw_rect(x, y, width=width, height=1, ch=ord("─"), fg=..., bg=..., bg_blend=...)'""" + ) def hline( self, x: int, @@ -645,7 +724,7 @@ def hline( ) -> None: """Draw a horizontal line on the console. - This always uses ord('─'), the horizontal line character. + This always uses ord("─"), the horizontal line character. Args: x (int): The x coordinate from the left. @@ -663,6 +742,9 @@ def hline( self.__deprecate_defaults("draw_rect", bg_blend) lib.TCOD_console_hline(self.console_c, x, y, width, bg_blend) + @deprecated( + """Replace with 'console.draw_rect(x, y, width=1, height=height, ch=ord("│"), fg=..., bg=..., bg_blend=...)'""" + ) def vline( self, x: int, @@ -672,7 +754,7 @@ def vline( ) -> None: """Draw a vertical line on the console. - This always uses ord('│'), the vertical line character. + This always uses ord("│"), the vertical line character. Args: x (int): The x coordinate from the left. @@ -690,14 +772,15 @@ def vline( self.__deprecate_defaults("draw_rect", bg_blend) lib.TCOD_console_vline(self.console_c, x, y, height, bg_blend) - def print_frame( + @deprecated("Replace with 'console.draw_frame(...)', use a separate print call for frame titles") + def print_frame( # noqa: PLR0913 self, x: int, y: int, width: int, height: int, string: str = "", - clear: bool = True, + clear: bool = True, # noqa: FBT001, FBT002 bg_blend: int = tcod.constants.BKGND_DEFAULT, ) -> None: """Draw a framed rectangle with optional text. @@ -729,10 +812,10 @@ def print_frame( explicit. """ self.__deprecate_defaults("draw_frame", bg_blend) - string = _fmt(string) if string else ffi.NULL - _check(lib.TCOD_console_printf_frame(self.console_c, x, y, width, height, clear, bg_blend, string)) + string_: Any = _fmt(string) if string else ffi.NULL + _check(lib.TCOD_console_printf_frame(self.console_c, x, y, width, height, clear, bg_blend, string_)) - def blit( + def blit( # noqa: PLR0913 self, dest: Console, dest_x: int = 0, @@ -778,11 +861,11 @@ def blit( # The old syntax is easy to detect and correct. if hasattr(src_y, "console_c"): (src_x, src_y, width, height, dest, dest_x, dest_y) = ( - dest, # type: ignore + dest, # type: ignore[assignment] dest_x, dest_y, src_x, - src_y, # type: ignore + src_y, # type: ignore[assignment] width, height, ) @@ -822,7 +905,7 @@ def blit( bg_alpha, ) - @deprecate("Pass the key color to Console.blit instead of calling this function.") + @deprecated("Pass the key color to Console.blit instead of calling this function.") def set_key_color(self, color: tuple[int, int, int] | None) -> None: """Set a consoles blit transparent color. @@ -834,7 +917,7 @@ def set_key_color(self, color: tuple[int, int, int] | None) -> None: """ self._key_color = color - def __enter__(self) -> Console: + def __enter__(self) -> Self: """Return this console in a managed context. When the root console is used as a context, the graphical window will @@ -865,7 +948,7 @@ def close(self) -> None: raise NotImplementedError(msg) lib.TCOD_console_delete(self.console_c) - def __exit__(self, *args: Any) -> None: + def __exit__(self, *_: object) -> None: """Close the graphical window on exit. Some tcod functions may have undefined behavior after this point. @@ -880,6 +963,7 @@ def __bool__(self) -> bool: return bool(self.console_c != ffi.NULL) def __getstate__(self) -> dict[str, Any]: + """Support serialization via :mod:`pickle`.""" state = self.__dict__.copy() del state["console_c"] state["_console_data"] = { @@ -895,6 +979,7 @@ def __getstate__(self) -> dict[str, Any]: return state def __setstate__(self, state: dict[str, Any]) -> None: + """Support serialization via :mod:`pickle`.""" self._key_color = None if "_tiles" not in state: tiles: NDArray[Any] = np.ndarray((self.height, self.width), dtype=self.DTYPE) @@ -914,67 +999,173 @@ def __setstate__(self, state: dict[str, Any]) -> None: def __repr__(self) -> str: """Return a string representation of this console.""" - return "tcod.console.Console(width=%i, height=%i, " "order=%r,buffer=\n%r)" % ( - self.width, - self.height, - self._order, - self.rgba, - ) + return f"tcod.console.Console(width={self.width}, height={self.height}, order={self._order!r},buffer=\n{self.rgba!r})" def __str__(self) -> str: """Return a simplified representation of this consoles contents.""" - return "<%s>" % "\n ".join("".join(chr(c) for c in line) for line in self._tiles["ch"]) + return "<{}>".format("\n ".join("".join(chr(c) for c in line) for line in self._tiles["ch"])) + @overload def print( self, x: int, y: int, - string: str, + text: str, + *, + width: int | None = None, + height: int | None = None, fg: tuple[int, int, int] | None = None, bg: tuple[int, int, int] | None = None, bg_blend: int = tcod.constants.BKGND_SET, alignment: int = tcod.constants.LEFT, - ) -> None: - r"""Print a string on a console with manual line breaks. + ) -> int: ... - `x` and `y` are the starting tile, with ``0,0`` as the upper-left - corner of the console. + @overload + @deprecated( + "Replace text, fg, bg, bg_blend, and alignment with keyword arguments." + "\n'string' keyword should be renamed to `text`" + ) + def print( + self, + x: int, + y: int, + text: str, + fg: tuple[int, int, int] | None = None, + bg: tuple[int, int, int] | None = None, + bg_blend: int = tcod.constants.BKGND_SET, + alignment: int = tcod.constants.LEFT, + *, + string: str = "", + ) -> int: ... - `string` is a Unicode string which may include color control - characters. Strings which are too long will be truncated until the - next newline character ``"\n"``. + def print( # noqa: PLR0913 + self, + x: int, + y: int, + text: str = "", + fg: tuple[int, int, int] | None = None, + bg: tuple[int, int, int] | None = None, + bg_blend: int = tcod.constants.BKGND_SET, + alignment: int = tcod.constants.LEFT, + *, + width: int | None = None, + height: int | None = None, + string: str = "", + ) -> int: + r"""Print a string of Unicode text on this console. - `fg` and `bg` are the foreground text color and background tile color - respectfully. This is a 3-item tuple with (r, g, b) color values from - 0 to 255. These parameters can also be set to `None` to leave the - colors unchanged. + Prefer using keywords for this method call to avoid ambiguous parameters. - `bg_blend` is the blend type used by libtcod. + Args: + x: Starting X coordinate, with the left-most tile as zero. + y: Starting Y coordinate, with the top-most tile as zero. + text: A Unicode string which may include color control characters. + width: Width in tiles to constrain the printing region. + If a `width` is given then `text` will have automatic word wrapping applied to it. + A `width` of `None` means `text` will only have manual line breaks. + height: Height in tiles to constrain the printing region. + fg: RGB tuple to use as the foreground color, or `None` to leave the foreground unchanged. + Tuple values should be 0-255. + Must be given as a keyword argument. + bg: RGB tuple to use as the background color, or `None` to leave the foreground unchanged. + Tuple values should be 0-255. + Must be given as a keyword argument. + bg_blend: Background blend type used by libtcod. + Typically starts with `libtcodpy.BKGND_*`. + Must be given as a keyword argument. + alignment: One of `libtcodpy.LEFT`, `libtcodpy.CENTER`, or `libtcodpy.RIGHT` + Must be given as a keyword argument. + string: Older deprecated name of the `text` parameter. - `alignment` can be `tcod.LEFT`, `tcod.CENTER`, or `tcod.RIGHT`. + Returns: + The height of `text` in lines via word wrapping and line breaks. + + Example:: + + >>> from tcod import libtcodpy + >>> console = tcod.console.Console(20, 1) + >>> console.clear(ch=ord('·')) + >>> console.print(x=0, y=0, text="left") + 1 + >>> console.print(x=console.width, y=0, text="right", alignment=libtcodpy.RIGHT) + 1 + >>> console.print(x=10, y=0, text="center", alignment=libtcodpy.CENTER) + 1 + >>> print(console) + + + >>> console = tcod.console.Console(20, 4) + >>> console.clear(ch=ord('·')) + >>> console.print(x=1, y=1, text="words within bounds", width=8) + 3 + >>> print(console) + <···················· + ·words·············· + ·within············· + ·bounds·············> + >>> WHITE = (255, 255, 255) + >>> BLACK = (0, 0, 0) + >>> console.print(x=0, y=0, text="Black text on white background", fg=BLACK, bg=WHITE) + 1 .. versionadded:: 8.5 .. versionchanged:: 9.0 + `fg` and `bg` now default to `None` instead of white-on-black. .. versionchanged:: 13.0 + `x` and `y` are now always used as an absolute position for negative values. + + .. versionchanged:: 18.0 + + Deprecated giving `string`, `fg`, `bg`, and `bg_blend` as positional arguments. + + Added `text` parameter to replace `string`. + + Added `width` and `height` keyword parameters to bind text to a rectangle and replace other print functions. + Right-aligned text with `width=None` now treats the `x` coordinate as a past-the-end index, this will shift + the text of older calls to the left by 1 tile. + + Now returns the number of lines printed via word wrapping, + same as previous print functions bound to rectangles. """ - string_ = string.encode("utf-8") - lib.TCOD_console_printn( - self.console_c, - x, - y, - len(string_), - string_, - (fg,) if fg is not None else ffi.NULL, - (bg,) if bg is not None else ffi.NULL, - bg_blend, - alignment, + if width is not None and width <= 0: + return 0 + if width is None and alignment == tcod.constants.LEFT: # Fix alignment + width = 0x100000 + if width is None and alignment == tcod.constants.CENTER: # Fix center alignment + x -= 0x100000 + width = 0x200000 + if width is None and alignment == tcod.constants.RIGHT: # Fix right alignment + x -= 0x100000 + width = 0x100000 + rgb_fg = ffi.new("TCOD_ColorRGB*", fg) if fg is not None else ffi.NULL + rgb_bg = ffi.new("TCOD_ColorRGB*", bg) if bg is not None else ffi.NULL + utf8 = (string or text).encode("utf-8") + return _check( + int( + lib.TCOD_printn_rgb( + self.console_c, + { + "x": x, + "y": y, + "width": width or 0, + "height": height or 0, + "fg": rgb_fg, + "bg": rgb_bg, + "flag": bg_blend, + "alignment": alignment, + }, + len(utf8), + utf8, + ) + ) ) - def print_box( + @deprecated("Switch to using keywords and then replace with 'console.print(...)'") + def print_box( # noqa: PLR0913 self, x: int, y: int, @@ -1033,17 +1224,49 @@ def print_box( ) ) + @overload def draw_frame( self, x: int, y: int, width: int, height: int, - title: str = "", + *, clear: bool = True, fg: tuple[int, int, int] | None = None, bg: tuple[int, int, int] | None = None, bg_blend: int = tcod.constants.BKGND_SET, + decoration: str | tuple[int, int, int, int, int, int, int, int, int] = "┌─┐│ │└─┘", + ) -> None: ... + + @overload + @deprecated("Parameters clear, fg, bg, bg_blend should be keyword arguments. Remove title parameter") + def draw_frame( + self, + x: int, + y: int, + width: int, + height: int, + title: str = "", + clear: bool = True, # noqa: FBT001, FBT002 + fg: tuple[int, int, int] | None = None, + bg: tuple[int, int, int] | None = None, + bg_blend: int = tcod.constants.BKGND_SET, + *, + decoration: str | tuple[int, int, int, int, int, int, int, int, int] = "┌─┐│ │└─┘", + ) -> None: ... + + def draw_frame( # noqa: PLR0913 + self, + x: int, + y: int, + width: int, + height: int, + title: str = "", + clear: bool = True, # noqa: FBT001, FBT002 + fg: tuple[int, int, int] | None = None, + bg: tuple[int, int, int] | None = None, + bg_blend: int = tcod.constants.BKGND_SET, *, decoration: str | tuple[int, int, int, int, int, int, int, int, int] = "┌─┐│ │└─┘", ) -> None: @@ -1061,13 +1284,16 @@ def draw_frame( border with :any:`Console.print_box` using your own style. If `clear` is True than the region inside of the frame will be cleared. + Must be given as a keyword argument. `fg` and `bg` are the foreground and background colors for the frame border. This is a 3-item tuple with (r, g, b) color values from 0 to 255. These parameters can also be set to `None` to leave the colors unchanged. + Must be given as a keyword argument. `bg_blend` is the blend type used by libtcod. + Must be given as a keyword argument. `decoration` is a sequence of glyphs to use for rendering the borders. This a str or tuple of int's with 9 items with the items arranged in @@ -1087,17 +1313,22 @@ def draw_frame( .. versionchanged:: 13.0 `x` and `y` are now always used as an absolute position for negative values. + .. versionchanged:: 18.0 + Deprecated `clear`, `fg`, `bg`, and `bg_blend` being given as positional arguments. + These should be keyword arguments only. + Example:: + >>> from tcod import libtcodpy >>> console = tcod.console.Console(12, 6) >>> console.draw_frame(x=0, y=0, width=3, height=3) >>> console.draw_frame(x=3, y=0, width=3, height=3, decoration="╔═╗║ ║╚═╝") >>> console.draw_frame(x=6, y=0, width=3, height=3, decoration="123456789") >>> console.draw_frame(x=9, y=0, width=3, height=3, decoration="/-\\| |\\-/") >>> console.draw_frame(x=0, y=3, width=12, height=3) - >>> console.print_box(x=0, y=3, width=12, height=1, string=" Title ", alignment=tcod.CENTER) + >>> console.print(x=0, y=3, width=12, height=1, string=" Title ", alignment=libtcodpy.CENTER) 1 - >>> console.print_box(x=0, y=5, width=12, height=1, string="┤Lower├", alignment=tcod.CENTER) + >>> console.print(x=0, y=5, width=12, height=1, string="┤Lower├", alignment=libtcodpy.CENTER) 1 >>> print(console) <┌─┐╔═╗123/-\ @@ -1132,7 +1363,7 @@ def draw_frame( ) return decoration_ = [ord(c) for c in decoration] if isinstance(decoration, str) else decoration - if len(decoration_) != 9: + if len(decoration_) != 9: # noqa: PLR2004 msg = f"Decoration must have a length of 9 (len(decoration) is {len(decoration_)}.)" raise TypeError(msg) _check( @@ -1150,7 +1381,35 @@ def draw_frame( ) ) + @overload def draw_rect( + self, + x: int, + y: int, + width: int, + height: int, + *, + ch: int, + fg: tuple[int, int, int] | None = None, + bg: tuple[int, int, int] | None = None, + bg_blend: int = tcod.constants.BKGND_SET, + ) -> None: ... + + @overload + @deprecated("Parameters ch, fg, bg, bg_blend should be keyword arguments") + def draw_rect( + self, + x: int, + y: int, + width: int, + height: int, + ch: int, + fg: tuple[int, int, int] | None = None, + bg: tuple[int, int, int] | None = None, + bg_blend: int = tcod.constants.BKGND_SET, + ) -> None: ... + + def draw_rect( # noqa: PLR0913 self, x: int, y: int, @@ -1168,15 +1427,16 @@ def draw_rect( `width` and `height` determine the size of the rectangle. - `ch` is a Unicode integer. You can use 0 to leave the current - characters unchanged. + `ch` is a Unicode integer. You can use 0 to leave the current characters unchanged. + Must be given as a keyword argument. - `fg` and `bg` are the foreground text color and background tile color - respectfully. This is a 3-item tuple with (r, g, b) color values from - 0 to 255. These parameters can also be set to `None` to leave the - colors unchanged. + `fg` and `bg` are the foreground text color and background tile color respectfully. + This is a 3-item tuple with (r, g, b) color values from 0 to 255. + These parameters can also be set to `None` to leave the colors unchanged. + Must be given as a keyword argument. `bg_blend` is the blend type used by libtcod. + Must be given as a keyword argument. .. versionadded:: 8.5 @@ -1185,6 +1445,10 @@ def draw_rect( .. versionchanged:: 13.0 `x` and `y` are now always used as an absolute position for negative values. + + .. versionchanged:: 18.0 + Deprecated `ch`, `fg`, `bg`, and `bg_blend` being given as positional arguments. + These should be keyword arguments only. """ lib.TCOD_console_draw_rect_rgb( self.console_c, @@ -1198,7 +1462,7 @@ def draw_rect( bg_blend, ) - def draw_semigraphics(self, pixels: Any, x: int = 0, y: int = 0) -> None: + def draw_semigraphics(self, pixels: ArrayLike | tcod.image.Image, x: int = 0, y: int = 0) -> None: """Draw a block of 2x2 semi-graphics into this console. `pixels` is an Image or an array-like object. It will be down-sampled @@ -1209,7 +1473,7 @@ def draw_semigraphics(self, pixels: Any, x: int = 0, y: int = 0) -> None: .. versionadded:: 11.4 """ - image = tcod._internal._as_image(pixels) + image = tcod.image._as_image(pixels) lib.TCOD_image_blit_2x(image.image_c, self.console_c, x, y, 0, 0, -1, -1) @@ -1226,7 +1490,7 @@ def get_height_rect(width: int, string: str) -> int: return int(lib.TCOD_console_get_height_rect_wn(width, len(string_), string_)) -@deprecate("This function does not support contexts.", category=FutureWarning) +@deprecated("This function does not support contexts.", category=FutureWarning) def recommended_size() -> tuple[int, int]: """Return the recommended size of a console for the current active window. @@ -1295,9 +1559,9 @@ def load_xp(path: str | PathLike[str], order: Literal["C", "F"] = "C") -> tuple[ console.rgba[is_transparent] = (ord(" "), (0,), (0,)) """ path = Path(path).resolve(strict=True) - layers = _check(tcod.lib.TCOD_load_xp(bytes(path), 0, ffi.NULL)) + layers = _check(tcod.lib.TCOD_load_xp(_path_encode(path), 0, ffi.NULL)) consoles = ffi.new("TCOD_Console*[]", layers) - _check(tcod.lib.TCOD_load_xp(bytes(path), layers, consoles)) + _check(tcod.lib.TCOD_load_xp(_path_encode(path), layers, consoles)) return tuple(Console._from_cdata(console_p, order=order) for console_p in consoles) @@ -1358,7 +1622,7 @@ def save_xp( tcod.lib.TCOD_save_xp( len(consoles_c), consoles_c, - bytes(path), + _path_encode(path), compress_level, ) ) diff --git a/tcod/constants.py b/tcod/constants.py index f0a596dc..ebaa4f4b 100644 --- a/tcod/constants.py +++ b/tcod/constants.py @@ -2,6 +2,7 @@ This module is auto-generated by `build_libtcod.py`. """ + from tcod.color import Color FOV_BASIC = 0 @@ -502,7 +503,7 @@ white = Color(255, 255, 255) yellow = Color(255, 255, 0) -__all__ = [ +__all__ = [ # noqa: RUF022 "FOV_BASIC", "FOV_DIAMOND", "FOV_PERMISSIVE_0", diff --git a/tcod/context.py b/tcod/context.py index 4b2d33e3..f9d562ee 100644 --- a/tcod/context.py +++ b/tcod/context.py @@ -22,16 +22,18 @@ .. versionadded:: 11.12 """ + from __future__ import annotations import copy import pickle import sys import warnings +from collections.abc import Iterable from pathlib import Path -from typing import Any, Iterable, NoReturn, TypeVar +from typing import Any, Literal, NoReturn, TypeVar -from typing_extensions import Literal +from typing_extensions import Self, deprecated import tcod.console import tcod.event @@ -39,28 +41,28 @@ import tcod.sdl.render import tcod.sdl.video import tcod.tileset -from tcod._internal import _check, _check_warn, pending_deprecate +from tcod._internal import _check, _check_warn from tcod.cffi import ffi, lib __all__ = ( - "Context", - "new", - "new_window", - "new_terminal", - "SDL_WINDOW_FULLSCREEN", - "SDL_WINDOW_FULLSCREEN_DESKTOP", - "SDL_WINDOW_HIDDEN", - "SDL_WINDOW_BORDERLESS", - "SDL_WINDOW_RESIZABLE", - "SDL_WINDOW_MINIMIZED", - "SDL_WINDOW_MAXIMIZED", - "SDL_WINDOW_INPUT_GRABBED", - "SDL_WINDOW_ALLOW_HIGHDPI", "RENDERER_OPENGL", "RENDERER_OPENGL2", "RENDERER_SDL", "RENDERER_SDL2", "RENDERER_XTERM", + "SDL_WINDOW_ALLOW_HIGHDPI", + "SDL_WINDOW_BORDERLESS", + "SDL_WINDOW_FULLSCREEN", + "SDL_WINDOW_FULLSCREEN_DESKTOP", + "SDL_WINDOW_HIDDEN", + "SDL_WINDOW_INPUT_GRABBED", + "SDL_WINDOW_MAXIMIZED", + "SDL_WINDOW_MINIMIZED", + "SDL_WINDOW_RESIZABLE", + "Context", + "new", + "new_terminal", + "new_window", ) _Event = TypeVar("_Event", bound=tcod.event.Event) @@ -124,12 +126,12 @@ """ -def _handle_tileset(tileset: tcod.tileset.Tileset | None) -> Any: +def _handle_tileset(tileset: tcod.tileset.Tileset | None) -> Any: # noqa: ANN401 """Get the TCOD_Tileset pointer from a Tileset or return a NULL pointer.""" return tileset._tileset_p if tileset else ffi.NULL -def _handle_title(title: str | None) -> Any: +def _handle_title(title: str | None) -> Any: # noqa: ANN401 """Return title as a CFFI string. If title is None then return a decent default title is returned. @@ -145,12 +147,12 @@ class Context: Use :any:`tcod.context.new` to create a new context. """ - def __init__(self, context_p: Any) -> None: + def __init__(self, context_p: Any) -> None: # noqa: ANN401 """Create a context from a cffi pointer.""" self._context_p = context_p @classmethod - def _claim(cls, context_p: Any) -> Context: + def _claim(cls, context_p: Any) -> Context: # noqa: ANN401 """Return a new instance wrapping a context pointer.""" return cls(ffi.gc(context_p, lib.TCOD_context_delete)) @@ -163,7 +165,7 @@ def _p(self) -> Any: # noqa: ANN401 msg = "This context has been closed can no longer be used." raise RuntimeError(msg) from None - def __enter__(self) -> Context: + def __enter__(self) -> Self: """Enter this context which will close on exiting.""" return self @@ -176,7 +178,7 @@ def close(self) -> None: ffi.release(self._context_p) del self._context_p - def __exit__(self, *args: Any) -> None: + def __exit__(self, *_: object) -> None: """Automatically close on the context on exit.""" self.close() @@ -264,7 +266,7 @@ def convert_event(self, event: _Event) -> _Event: event.position[0] - event.motion[0], event.position[1] - event.motion[1], ) - event_copy.motion = event.tile_motion = tcod.event.Point( + event_copy.motion = event._tile_motion = tcod.event.Point( event._tile[0] - prev_tile[0], event._tile[1] - prev_tile[1] ) return event_copy @@ -343,7 +345,8 @@ def new_console( scale = max(1, scale + event.y) """ if magnification < 0: - raise ValueError("Magnification must be greater than zero. (Got %f)" % magnification) + msg = f"Magnification must be greater than zero. (Got {magnification:f})" + raise ValueError(msg) size = ffi.new("int[2]") _check(lib.TCOD_context_recommended_console_size(self._p, magnification, size, size + 1)) width, height = max(min_columns, size[0]), max(min_rows, size[1]) @@ -366,7 +369,7 @@ def renderer_type(self) -> int: return _check(lib.TCOD_context_get_renderer_type(self._p)) @property - def sdl_window_p(self) -> Any: + def sdl_window_p(self) -> Any: # noqa: ANN401 '''A cffi `SDL_Window*` pointer. This pointer might be NULL. This pointer will become invalid if the context is closed or goes out @@ -445,8 +448,8 @@ def __reduce__(self) -> NoReturn: raise pickle.PicklingError(msg) -@ffi.def_extern() # type: ignore -def _pycall_cli_output(catch_reference: Any, output: Any) -> None: +@ffi.def_extern() # type: ignore[misc] +def _pycall_cli_output(catch_reference: Any, output: Any) -> None: # noqa: ANN401 """Callback for the libtcod context CLI. Catches the CLI output. @@ -455,7 +458,7 @@ def _pycall_cli_output(catch_reference: Any, output: Any) -> None: catch.append(ffi.string(output).decode("utf-8")) -def new( +def new( # noqa: PLR0913 *, x: int | None = None, y: int | None = None, @@ -572,8 +575,8 @@ def new( return Context._claim(context_pp[0]) -@pending_deprecate("Call tcod.context.new with width and height as keyword parameters.") -def new_window( +@deprecated("Call tcod.context.new with width and height as keyword parameters.") +def new_window( # noqa: PLR0913 width: int, height: int, *, @@ -599,8 +602,8 @@ def new_window( ) -@pending_deprecate("Call tcod.context.new with columns and rows as keyword parameters.") -def new_terminal( +@deprecated("Call tcod.context.new with columns and rows as keyword parameters.") +def new_terminal( # noqa: PLR0913 columns: int, rows: int, *, diff --git a/tcod/event.py b/tcod/event.py index d808436b..25abfb7f 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -79,23 +79,28 @@ .. versionadded:: 8.4 """ + from __future__ import annotations import enum import warnings -from typing import Any, Callable, Generic, Iterator, Mapping, NamedTuple, TypeVar +from collections.abc import Callable, Iterator, Mapping +from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeVar import numpy as np -from numpy.typing import NDArray -from typing_extensions import Final, Literal +from typing_extensions import deprecated +import tcod.event import tcod.event_constants import tcod.sdl.joystick +import tcod.sdl.sys from tcod.cffi import ffi, lib from tcod.event_constants import * # noqa: F403 -from tcod.event_constants import KMOD_ALT, KMOD_CTRL, KMOD_GUI, KMOD_SHIFT from tcod.sdl.joystick import _HAT_DIRECTIONS +if TYPE_CHECKING: + from numpy.typing import NDArray + T = TypeVar("T") @@ -172,16 +177,11 @@ def _verify_tile_coordinates(xy: Point | None) -> Point: return Point(0, 0) -_is_sdl_video_initialized = False - - def _init_sdl_video() -> None: """Keyboard layout stuff needs SDL to be initialized first.""" - global _is_sdl_video_initialized - if _is_sdl_video_initialized: + if lib.SDL_WasInit(lib.SDL_INIT_VIDEO): return lib.SDL_InitSubSystem(lib.SDL_INIT_VIDEO) - _is_sdl_video_initialized = True class Modifier(enum.IntFlag): @@ -302,7 +302,7 @@ def __init__(self, type: str | None = None) -> None: @classmethod def from_sdl_event(cls, sdl_event: Any) -> Event: """Return a class instance from a python-cffi 'SDL_Event*' pointer.""" - raise NotImplementedError() + raise NotImplementedError def __str__(self) -> str: return f"" @@ -444,12 +444,7 @@ def tile(self, xy: tuple[int, int]) -> None: self._tile = Point(*xy) def __repr__(self) -> str: - return ("tcod.event.{}(position={!r}, tile={!r}, state={})").format( - self.__class__.__name__, - tuple(self.position), - tuple(self.tile), - MouseButtonMask(self.state), - ) + return f"tcod.event.{self.__class__.__name__}(position={tuple(self.position)!r}, tile={tuple(self.tile)!r}, state={MouseButtonMask(self.state)})" def __str__(self) -> str: return ("<%s, position=(x=%i, y=%i), tile=(x=%i, y=%i), state=%s>") % ( @@ -494,7 +489,7 @@ def __init__( ) -> None: super().__init__(position, tile, state) self.motion = Point(*motion) - self.__tile_motion = Point(*tile_motion) if tile_motion is not None else None + self._tile_motion = Point(*tile_motion) if tile_motion is not None else None @property def pixel_motion(self) -> Point: @@ -522,7 +517,7 @@ def tile_motion(self) -> Point: DeprecationWarning, stacklevel=2, ) - return _verify_tile_coordinates(self.__tile_motion) + return _verify_tile_coordinates(self._tile_motion) @tile_motion.setter def tile_motion(self, xy: tuple[int, int]) -> None: @@ -532,7 +527,7 @@ def tile_motion(self, xy: tuple[int, int]) -> None: DeprecationWarning, stacklevel=2, ) - self.__tile_motion = Point(*xy) + self._tile_motion = Point(*xy) @classmethod def from_sdl_event(cls, sdl_event: Any) -> MouseMotion: @@ -554,14 +549,7 @@ def from_sdl_event(cls, sdl_event: Any) -> MouseMotion: return self def __repr__(self) -> str: - return ("tcod.event.{}(position={!r}, motion={!r}, tile={!r}, tile_motion={!r}, state={!r})").format( - self.__class__.__name__, - tuple(self.position), - tuple(self.motion), - tuple(self.tile), - tuple(self.tile_motion), - MouseButtonMask(self.state), - ) + return f"tcod.event.{self.__class__.__name__}(position={tuple(self.position)!r}, motion={tuple(self.motion)!r}, tile={tuple(self.tile)!r}, tile_motion={tuple(self.tile_motion)!r}, state={MouseButtonMask(self.state)!r})" def __str__(self) -> str: return ("<%s, motion=(x=%i, y=%i), tile_motion=(x=%i, y=%i)>") % ( @@ -621,12 +609,7 @@ def from_sdl_event(cls, sdl_event: Any) -> Any: return self def __repr__(self) -> str: - return "tcod.event.{}(position={!r}, tile={!r}, button={!r})".format( - self.__class__.__name__, - tuple(self.position), - tuple(self.tile), - MouseButton(self.button), - ) + return f"tcod.event.{self.__class__.__name__}(position={tuple(self.position)!r}, tile={tuple(self.tile)!r}, button={MouseButton(self.button)!r})" def __str__(self) -> str: return " WindowEvent | Undefined: def __repr__(self) -> str: return f"tcod.event.{self.__class__.__name__}(type={self.type!r})" - __WINDOW_TYPES = { + __WINDOW_TYPES: Final = { lib.SDL_WINDOWEVENT_SHOWN: "WindowShown", lib.SDL_WINDOWEVENT_HIDDEN: "WindowHidden", lib.SDL_WINDOWEVENT_EXPOSED: "WindowExposed", @@ -796,12 +779,7 @@ def __init__(self, x: int, y: int) -> None: self.y = y def __repr__(self) -> str: - return "tcod.event.{}(type={!r}, x={!r}, y={!r})".format( - self.__class__.__name__, - self.type, - self.x, - self.y, - ) + return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, x={self.x!r}, y={self.y!r})" def __str__(self) -> str: return "<{}, x={!r}, y={!r})".format( @@ -828,12 +806,7 @@ def __init__(self, type: str, width: int, height: int) -> None: self.height = height def __repr__(self) -> str: - return "tcod.event.{}(type={!r}, width={!r}, height={!r})".format( - self.__class__.__name__, - self.type, - self.width, - self.height, - ) + return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, width={self.width!r}, height={self.height!r})" def __str__(self) -> str: return "<{}, width={!r}, height={!r})".format( @@ -861,7 +834,7 @@ def joystick(self) -> tcod.sdl.joystick.Joystick: return tcod.sdl.joystick.Joystick._from_instance_id(self.which) def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}" f"(type={self.type!r}, which={self.which})" + return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, which={self.which})" def __str__(self) -> str: prefix = super().__str__().strip("<>") @@ -964,9 +937,7 @@ def from_sdl_event(cls, sdl_event: Any) -> JoystickHat: return cls("JOYHATMOTION", sdl_event.jhat.which, *_HAT_DIRECTIONS[sdl_event.jhat.hat]) def __repr__(self) -> str: - return ( - f"tcod.event.{self.__class__.__name__}" f"(type={self.type!r}, which={self.which}, x={self.x}, y={self.y})" - ) + return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, which={self.which}, x={self.x}, y={self.y})" def __str__(self) -> str: prefix = super().__str__().strip("<>") @@ -1007,7 +978,7 @@ def from_sdl_event(cls, sdl_event: Any) -> JoystickButton: return cls(type, sdl_event.jbutton.which, sdl_event.jbutton.button) def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}" f"(type={self.type!r}, which={self.which}, button={self.button})" + return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, which={self.which}, button={self.button})" def __str__(self) -> str: prefix = super().__str__().strip("<>") @@ -1062,7 +1033,7 @@ def controller(self) -> tcod.sdl.joystick.GameController: return tcod.sdl.joystick.GameController._from_instance_id(self.which) def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}" f"(type={self.type!r}, which={self.which})" + return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, which={self.which})" def __str__(self) -> str: prefix = super().__str__().strip("<>") @@ -1222,6 +1193,13 @@ def get() -> Iterator[Any]: It is also safe to call this function inside of a loop that is already handling events (the event iterator is reentrant.) """ + if not lib.SDL_WasInit(tcod.sdl.sys.Subsystem.EVENTS): + warnings.warn( + "Events polled before SDL was initialized.", + RuntimeWarning, + stacklevel=1, + ) + return sdl_event = ffi.new("SDL_Event*") while lib.SDL_PollEvent(sdl_event): if sdl_event.type in _SDL_TO_CLASS_TABLE: @@ -1261,6 +1239,10 @@ def wait(timeout: float | None = None) -> Iterator[Any]: return get() +@deprecated( + "Event dispatch should be handled via a single custom method in a Protocol instead of this class.", + category=DeprecationWarning, +) class EventDispatch(Generic[T]): '''Dispatches events to methods depending on the events type attribute. @@ -1271,6 +1253,10 @@ class EventDispatch(Generic[T]): This is now a generic class. The type hints at the return value of :any:`dispatch` and the `ev_*` methods. + .. deprecated:: 18.0 + Event dispatch should be handled via a single custom method in a Protocol instead of this class. + Note that events can and should be handled using Python's `match` statement. + Example:: import tcod @@ -1365,6 +1351,8 @@ def cmd_quit(self) -> None: state.dispatch(event) ''' + __slots__ = () + def dispatch(self, event: Any) -> T | None: """Send an event to an `ev_*` method. @@ -1399,160 +1387,160 @@ def event_wait(self, timeout: float | None) -> None: wait(timeout) self.event_get() - def ev_quit(self, event: tcod.event.Quit) -> T | None: + def ev_quit(self, event: tcod.event.Quit, /) -> T | None: """Called when the termination of the program is requested.""" - def ev_keydown(self, event: tcod.event.KeyDown) -> T | None: + def ev_keydown(self, event: tcod.event.KeyDown, /) -> T | None: """Called when a keyboard key is pressed or repeated.""" - def ev_keyup(self, event: tcod.event.KeyUp) -> T | None: + def ev_keyup(self, event: tcod.event.KeyUp, /) -> T | None: """Called when a keyboard key is released.""" - def ev_mousemotion(self, event: tcod.event.MouseMotion) -> T | None: + def ev_mousemotion(self, event: tcod.event.MouseMotion, /) -> T | None: """Called when the mouse is moved.""" - def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> T | None: + def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown, /) -> T | None: """Called when a mouse button is pressed.""" - def ev_mousebuttonup(self, event: tcod.event.MouseButtonUp) -> T | None: + def ev_mousebuttonup(self, event: tcod.event.MouseButtonUp, /) -> T | None: """Called when a mouse button is released.""" - def ev_mousewheel(self, event: tcod.event.MouseWheel) -> T | None: + def ev_mousewheel(self, event: tcod.event.MouseWheel, /) -> T | None: """Called when the mouse wheel is scrolled.""" - def ev_textinput(self, event: tcod.event.TextInput) -> T | None: + def ev_textinput(self, event: tcod.event.TextInput, /) -> T | None: """Called to handle Unicode input.""" - def ev_windowshown(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowshown(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window is shown.""" - def ev_windowhidden(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowhidden(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window is hidden.""" - def ev_windowexposed(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowexposed(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when a window is exposed, and needs to be refreshed. This usually means a call to :any:`libtcodpy.console_flush` is necessary. """ - def ev_windowmoved(self, event: tcod.event.WindowMoved) -> T | None: + def ev_windowmoved(self, event: tcod.event.WindowMoved, /) -> T | None: """Called when the window is moved.""" - def ev_windowresized(self, event: tcod.event.WindowResized) -> T | None: + def ev_windowresized(self, event: tcod.event.WindowResized, /) -> T | None: """Called when the window is resized.""" - def ev_windowsizechanged(self, event: tcod.event.WindowResized) -> T | None: + def ev_windowsizechanged(self, event: tcod.event.WindowResized, /) -> T | None: """Called when the system or user changes the size of the window.""" - def ev_windowminimized(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowminimized(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window is minimized.""" - def ev_windowmaximized(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowmaximized(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window is maximized.""" - def ev_windowrestored(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowrestored(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window is restored.""" - def ev_windowenter(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowenter(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window gains mouse focus.""" - def ev_windowleave(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowleave(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window loses mouse focus.""" - def ev_windowfocusgained(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowfocusgained(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window gains keyboard focus.""" - def ev_windowfocuslost(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowfocuslost(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window loses keyboard focus.""" - def ev_windowclose(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowclose(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window manager requests the window to be closed.""" - def ev_windowtakefocus(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowtakefocus(self, event: tcod.event.WindowEvent, /) -> T | None: pass - def ev_windowhittest(self, event: tcod.event.WindowEvent) -> T | None: + def ev_windowhittest(self, event: tcod.event.WindowEvent, /) -> T | None: pass - def ev_joyaxismotion(self, event: tcod.event.JoystickAxis) -> T | None: + def ev_joyaxismotion(self, event: tcod.event.JoystickAxis, /) -> T | None: """Called when a joystick analog is moved. .. versionadded:: 13.8 """ - def ev_joyballmotion(self, event: tcod.event.JoystickBall) -> T | None: + def ev_joyballmotion(self, event: tcod.event.JoystickBall, /) -> T | None: """Called when a joystick ball is moved. .. versionadded:: 13.8 """ - def ev_joyhatmotion(self, event: tcod.event.JoystickHat) -> T | None: + def ev_joyhatmotion(self, event: tcod.event.JoystickHat, /) -> T | None: """Called when a joystick hat is moved. .. versionadded:: 13.8 """ - def ev_joybuttondown(self, event: tcod.event.JoystickButton) -> T | None: + def ev_joybuttondown(self, event: tcod.event.JoystickButton, /) -> T | None: """Called when a joystick button is pressed. .. versionadded:: 13.8 """ - def ev_joybuttonup(self, event: tcod.event.JoystickButton) -> T | None: + def ev_joybuttonup(self, event: tcod.event.JoystickButton, /) -> T | None: """Called when a joystick button is released. .. versionadded:: 13.8 """ - def ev_joydeviceadded(self, event: tcod.event.JoystickDevice) -> T | None: + def ev_joydeviceadded(self, event: tcod.event.JoystickDevice, /) -> T | None: """Called when a joystick is added. .. versionadded:: 13.8 """ - def ev_joydeviceremoved(self, event: tcod.event.JoystickDevice) -> T | None: + def ev_joydeviceremoved(self, event: tcod.event.JoystickDevice, /) -> T | None: """Called when a joystick is removed. .. versionadded:: 13.8 """ - def ev_controlleraxismotion(self, event: tcod.event.ControllerAxis) -> T | None: + def ev_controlleraxismotion(self, event: tcod.event.ControllerAxis, /) -> T | None: """Called when a controller analog is moved. .. versionadded:: 13.8 """ - def ev_controllerbuttondown(self, event: tcod.event.ControllerButton) -> T | None: + def ev_controllerbuttondown(self, event: tcod.event.ControllerButton, /) -> T | None: """Called when a controller button is pressed. .. versionadded:: 13.8 """ - def ev_controllerbuttonup(self, event: tcod.event.ControllerButton) -> T | None: + def ev_controllerbuttonup(self, event: tcod.event.ControllerButton, /) -> T | None: """Called when a controller button is released. .. versionadded:: 13.8 """ - def ev_controllerdeviceadded(self, event: tcod.event.ControllerDevice) -> T | None: + def ev_controllerdeviceadded(self, event: tcod.event.ControllerDevice, /) -> T | None: """Called when a standard controller is added. .. versionadded:: 13.8 """ - def ev_controllerdeviceremoved(self, event: tcod.event.ControllerDevice) -> T | None: + def ev_controllerdeviceremoved(self, event: tcod.event.ControllerDevice, /) -> T | None: """Called when a standard controller is removed. .. versionadded:: 13.8 """ - def ev_controllerdeviceremapped(self, event: tcod.event.ControllerDevice) -> T | None: + def ev_controllerdeviceremapped(self, event: tcod.event.ControllerDevice, /) -> T | None: """Called when a standard controller is remapped. .. versionadded:: 13.8 """ - def ev_(self, event: Any) -> T | None: + def ev_(self, event: Any, /) -> T | None: pass @@ -1569,7 +1557,7 @@ def get_mouse_state() -> MouseState: return MouseState((xy[0], xy[1]), (int(tile[0]), int(tile[1])), buttons) -@ffi.def_extern() # type: ignore +@ffi.def_extern() # type: ignore[misc] def _sdl_event_watcher(userdata: Any, sdl_event: Any) -> int: callback: Callable[[Event], None] = ffi.from_handle(userdata) callback(_parse_event(sdl_event)) @@ -1608,7 +1596,9 @@ def handle_events(event: tcod.event.Event) -> None: .. versionadded:: 13.4 """ if callback in _event_watch_handles: - warnings.warn(f"{callback} is already an active event watcher, nothing was added.", RuntimeWarning) + warnings.warn( + f"{callback} is already an active event watcher, nothing was added.", RuntimeWarning, stacklevel=2 + ) return callback handle = _event_watch_handles[callback] = ffi.new_handle(callback) lib.SDL_AddEventWatch(lib._sdl_event_watcher, handle) @@ -1625,7 +1615,7 @@ def remove_watch(callback: Callable[[Event], None]) -> None: .. versionadded:: 13.4 """ if callback not in _event_watch_handles: - warnings.warn(f"{callback} is not an active event watcher, nothing was removed.", RuntimeWarning) + warnings.warn(f"{callback} is not an active event watcher, nothing was removed.", RuntimeWarning, stacklevel=2) return handle = _event_watch_handles[callback] lib.SDL_DelEventWatch(lib._sdl_event_watcher, handle) @@ -2210,7 +2200,7 @@ def _missing_(cls, value: object) -> Scancode | None: result._value_ = value return result - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: if isinstance(other, KeySym): msg = "Scancode and KeySym enums can not be compared directly. Convert one or the other to the same type." raise TypeError(msg) @@ -2764,7 +2754,7 @@ def _missing_(cls, value: object) -> KeySym | None: result._value_ = value return result - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: if isinstance(other, Scancode): msg = "Scancode and KeySym enums can not be compared directly. Convert one or the other to the same type." raise TypeError(msg) @@ -2826,10 +2816,18 @@ def __getattr__(name: str) -> int: FutureWarning, stacklevel=2, ) + elif name.startswith("KMOD_"): + modifier = name[5:] + warnings.warn( + "Key modifiers have been replaced with the Modifier IntFlag.\n" + f"`tcod.event.{modifier}` should be replaced with `tcod.event.Modifier.{modifier}`", + FutureWarning, + stacklevel=2, + ) return value -__all__ = [ # noqa: F405 +__all__ = [ # noqa: F405 RUF022 "Modifier", "Point", "BUTTON_LEFT", @@ -2878,23 +2876,6 @@ def __getattr__(name: str) -> int: "Scancode", "KeySym", # --- From event_constants.py --- - "KMOD_NONE", - "KMOD_LSHIFT", - "KMOD_RSHIFT", - "KMOD_SHIFT", - "KMOD_LCTRL", - "KMOD_RCTRL", - "KMOD_CTRL", - "KMOD_LALT", - "KMOD_RALT", - "KMOD_ALT", - "KMOD_LGUI", - "KMOD_RGUI", - "KMOD_GUI", - "KMOD_NUM", - "KMOD_CAPS", - "KMOD_MODE", - "KMOD_RESERVED", "MOUSEWHEEL_NORMAL", "MOUSEWHEEL_FLIPPED", "MOUSEWHEEL", diff --git a/tcod/event_constants.py b/tcod/event_constants.py index d10eeb74..6f012293 100644 --- a/tcod/event_constants.py +++ b/tcod/event_constants.py @@ -539,24 +539,7 @@ 1027: "MOUSEWHEEL", } -__all__ = [ - "KMOD_NONE", - "KMOD_LSHIFT", - "KMOD_RSHIFT", - "KMOD_SHIFT", - "KMOD_LCTRL", - "KMOD_RCTRL", - "KMOD_CTRL", - "KMOD_LALT", - "KMOD_RALT", - "KMOD_ALT", - "KMOD_LGUI", - "KMOD_RGUI", - "KMOD_GUI", - "KMOD_NUM", - "KMOD_CAPS", - "KMOD_MODE", - "KMOD_SCROLL", +__all__ = [ # noqa: RUF022 "MOUSEWHEEL_NORMAL", "MOUSEWHEEL_FLIPPED", "MOUSEWHEEL", diff --git a/tcod/image.py b/tcod/image.py index b404361a..6b4a1083 100644 --- a/tcod/image.py +++ b/tcod/image.py @@ -8,19 +8,25 @@ The best it can do with consoles is convert an image into semigraphics which can be shown on non-emulated terminals. For true pixel-based rendering you'll want to access the SDL rendering port at :any:`tcod.sdl.render`. """ + from __future__ import annotations -from os import PathLike from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np -from numpy.typing import ArrayLike, NDArray +from typing_extensions import deprecated -import tcod.console -from tcod._internal import _console, deprecate +from tcod._internal import _console, _path_encode from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from os import PathLike + + from numpy.typing import ArrayLike, NDArray + + import tcod.console + class Image: """A libtcod image. @@ -38,10 +44,16 @@ def __init__(self, width: int, height: int) -> None: """Initialize a blank image.""" self.width, self.height = width, height self.image_c = ffi.gc(lib.TCOD_image_new(width, height), lib.TCOD_image_delete) + if self.image_c == ffi.NULL: + msg = "Failed to allocate image." + raise MemoryError(msg) @classmethod - def _from_cdata(cls, cdata: Any) -> Image: + def _from_cdata(cls, cdata: Any) -> Image: # noqa: ANN401 self: Image = object.__new__(cls) + if cdata == ffi.NULL: + msg = "Pointer must not be NULL." + raise RuntimeError(msg) self.image_c = cdata self.width, self.height = self._get_size() return self @@ -72,7 +84,7 @@ def from_file(cls, path: str | PathLike[str]) -> Image: .. versionadded:: 16.0 """ path = Path(path).resolve(strict=True) - return cls._from_cdata(ffi.gc(lib.TCOD_image_load(bytes(path)), lib.TCOD_image_delete)) + return cls._from_cdata(ffi.gc(lib.TCOD_image_load(_path_encode(path)), lib.TCOD_image_delete)) def clear(self, color: tuple[int, int, int]) -> None: """Fill this entire Image with color. @@ -133,7 +145,7 @@ def get_alpha(self, x: int, y: int) -> int: int: The alpha value of the pixel. With 0 being fully transparent and 255 being fully opaque. """ - return lib.TCOD_image_get_alpha(self.image_c, x, y) # type: ignore + return int(lib.TCOD_image_get_alpha(self.image_c, x, y)) def refresh_console(self, console: tcod.console.Console) -> None: """Update an Image created with :any:`libtcodpy.image_from_console`. @@ -207,7 +219,7 @@ def put_pixel(self, x: int, y: int, color: tuple[int, int, int]) -> None: """ lib.TCOD_image_put_pixel(self.image_c, x, y, color) - def blit( + def blit( # noqa: PLR0913 self, console: tcod.console.Console, x: float, @@ -242,7 +254,7 @@ def blit( angle, ) - def blit_rect( + def blit_rect( # noqa: PLR0913 self, console: tcod.console.Console, x: int, @@ -263,7 +275,7 @@ def blit_rect( """ lib.TCOD_image_blit_rect(self.image_c, _console(console), x, y, width, height, bg_blend) - def blit_2x( + def blit_2x( # noqa: PLR0913 self, console: tcod.console.Console, dest_x: int, @@ -306,7 +318,7 @@ def save_as(self, filename: str | PathLike[str]) -> None: .. versionchanged:: 16.0 Added PathLike support. """ - lib.TCOD_image_save(self.image_c, bytes(Path(filename))) + lib.TCOD_image_save(self.image_c, _path_encode(Path(filename))) @property def __array_interface__(self) -> dict[str, Any]: @@ -339,21 +351,9 @@ def __array_interface__(self) -> dict[str, Any]: } -def _get_format_name(format: int) -> str: - """Return the SDL_PIXELFORMAT_X name for this format, if possible.""" - for attr in dir(lib): - if not attr.startswith("SDL_PIXELFORMAT"): - continue - if getattr(lib, attr) != format: - continue - return attr - return str(format) - - -@deprecate( +@deprecated( "This function may be removed in the future." " It's recommended to load images with a more complete image library such as python-Pillow or python-imageio.", - category=PendingDeprecationWarning, ) def load(filename: str | PathLike[str]) -> NDArray[np.uint8]: """Load a PNG file as an RGBA array. @@ -364,10 +364,11 @@ def load(filename: str | PathLike[str]) -> NDArray[np.uint8]: .. versionadded:: 11.4 """ - image = Image._from_cdata(ffi.gc(lib.TCOD_image_load(bytes(Path(filename))), lib.TCOD_image_delete)) + filename = Path(filename).resolve(strict=True) + image = Image._from_cdata(ffi.gc(lib.TCOD_image_load(_path_encode(filename)), lib.TCOD_image_delete)) array: NDArray[np.uint8] = np.asarray(image, dtype=np.uint8) height, width, depth = array.shape - if depth == 3: + if depth == 3: # noqa: PLR2004 array = np.concatenate( ( array, @@ -376,3 +377,42 @@ def load(filename: str | PathLike[str]) -> NDArray[np.uint8]: axis=2, ) return array + + +class _TempImage: + """An Image-like container for NumPy arrays.""" + + def __init__(self, array: ArrayLike) -> None: + """Initialize an image from the given array. May copy or reference the array.""" + self._array: NDArray[np.uint8] = np.ascontiguousarray(array, dtype=np.uint8) + height, width, depth = self._array.shape + if depth != 3: # noqa: PLR2004 + msg = f"Array must have RGB channels. Shape is: {self._array.shape!r}" + raise TypeError(msg) + self._buffer = ffi.from_buffer("TCOD_color_t[]", self._array) + self._mipmaps = ffi.new( + "struct TCOD_mipmap_*", + { + "width": width, + "height": height, + "fwidth": width, + "fheight": height, + "buf": self._buffer, + "dirty": True, + }, + ) + self.image_c = ffi.new( + "TCOD_Image*", + { + "nb_mipmaps": 1, + "mipmaps": self._mipmaps, + "has_key_color": False, + }, + ) + + +def _as_image(image: ArrayLike | Image | _TempImage) -> _TempImage | Image: + """Convert this input into an Image-like object.""" + if isinstance(image, (Image, _TempImage)): + return image + return _TempImage(image) diff --git a/tcod/libtcodpy.py b/tcod/libtcodpy.py index 88086c1c..517f3377 100644 --- a/tcod/libtcodpy.py +++ b/tcod/libtcodpy.py @@ -1,17 +1,17 @@ """This module handles backward compatibility with the ctypes libtcodpy module.""" + from __future__ import annotations import atexit import sys import threading import warnings -from os import PathLike +from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence from pathlib import Path -from typing import Any, Callable, Hashable, Iterable, Iterator, Sequence +from typing import TYPE_CHECKING, Any, Literal import numpy as np -from numpy.typing import NDArray -from typing_extensions import Literal +from typing_extensions import deprecated import tcod.bsp import tcod.console @@ -31,6 +31,7 @@ _console, _fmt, _int, + _path_encode, _PropagateException, _unicode, _unpack_char_p, @@ -52,7 +53,13 @@ NOISE_DEFAULT, ) -# ruff: noqa: ANN401 PLR0913 # Functions are too deprecated to make changes. +if TYPE_CHECKING: + from os import PathLike + + from numpy.typing import NDArray + +# Functions are too deprecated to make changes. +# ruff: noqa: ANN401 PLR0913 D102 D103 D105 D107 Bsp = tcod.bsp.BSP @@ -74,6 +81,7 @@ def BKGND_ADDALPHA(a: int) -> int: return BKGND_ADDA | (int(a * 255) << 8) +@deprecated("Console array attributes perform better than this class.") class ConsoleBuffer: """Simple console that allows direct (fast) access to cells. Simplifies use of the "fill" functions. @@ -108,11 +116,6 @@ def __init__( Values to fill the buffer are optional, defaults to black with no characters. """ - warnings.warn( - "Console array attributes perform better than this class.", - DeprecationWarning, - stacklevel=2, - ) self.width = width self.height = height self.clear(back_r, back_g, back_b, fore_r, fore_g, fore_b, char) @@ -236,8 +239,8 @@ def set( def blit( self, dest: tcod.console.Console, - fill_fore: bool = True, - fill_back: bool = True, + fill_fore: bool = True, # noqa: FBT001, FBT002 + fill_back: bool = True, # noqa: FBT001, FBT002 ) -> None: """Use libtcod's "fill" functions to write the buffer to a console. @@ -268,6 +271,7 @@ def blit( dest.ch.ravel()[:] = self.char +@deprecated("Using this class is not recommended.") class Dice(_CDataWrapper): """A libtcod dice object. @@ -283,11 +287,6 @@ class Dice(_CDataWrapper): """ def __init__(self, *args: Any, **kwargs: Any) -> None: - warnings.warn( - "Using this class is not recommended.", - DeprecationWarning, - stacklevel=2, - ) super().__init__(*args, **kwargs) if self.cdata == ffi.NULL: self._init(*args, **kwargs) @@ -314,26 +313,15 @@ def nb_dices(self, value: int) -> None: self.nb_rolls = value def __str__(self) -> str: - add = "+(%s)" % self.addsub if self.addsub != 0 else "" - return "%id%ix%s%s" % ( - self.nb_dices, - self.nb_faces, - self.multiplier, - add, - ) + add = f"+({self.addsub})" if self.addsub != 0 else "" + return f"{self.nb_dices}d{self.nb_faces}x{self.multiplier}{add}" def __repr__(self) -> str: - return "{}(nb_dices={!r},nb_faces={!r},multiplier={!r},addsub={!r})".format( - self.__class__.__name__, - self.nb_dices, - self.nb_faces, - self.multiplier, - self.addsub, - ) + return f"{self.__class__.__name__}(nb_dices={self.nb_dices!r},nb_faces={self.nb_faces!r},multiplier={self.multiplier!r},addsub={self.addsub!r})" # reverse lookup table for KEY_X attributes, used by Key.__repr__ -_LOOKUP_VK = {value: "KEY_%s" % key[6:] for key, value in lib.__dict__.items() if key.startswith("TCODK")} +_LOOKUP_VK = {value: f"KEY_{key[6:]}" for key, value in lib.__dict__.items() if key.startswith("TCODK")} class Key(_CDataWrapper): @@ -358,6 +346,8 @@ class Key(_CDataWrapper): Use events from the :any:`tcod.event` module instead. """ + cdata: Any + _BOOL_ATTRIBUTES = ( "lalt", "lctrl", @@ -419,11 +409,11 @@ def __setattr__(self, attr: str, value: Any) -> None: def __repr__(self) -> str: """Return a representation of this Key object.""" params = [] - params.append(f"pressed={self.pressed!r}, vk=tcod.{_LOOKUP_VK[self.vk]}") + params.append(f"pressed={self.pressed!r}, vk=libtcodpy.{_LOOKUP_VK[self.vk]}") if self.c: - params.append("c=ord(%r)" % chr(self.c)) + params.append(f"c=ord({chr(self.c)!r})") if self.text: - params.append("text=%r" % self.text) + params.append(f"text={self.text!r}") for attr in [ "shift", "lalt", @@ -435,7 +425,7 @@ def __repr__(self) -> str: ]: if getattr(self, attr): params.append(f"{attr}={getattr(self, attr)!r}") - return "tcod.Key(%s)" % ", ".join(params) + return "libtcodpy.Key({})".format(", ".join(params)) @property def key_p(self) -> Any: @@ -513,14 +503,14 @@ def __repr__(self) -> str: ]: if getattr(self, attr): params.append(f"{attr}={getattr(self, attr)!r}") - return "tcod.Mouse(%s)" % ", ".join(params) + return "libtcodpy.Mouse({})".format(", ".join(params)) @property def mouse_p(self) -> Any: return self.cdata -@deprecate("Call tcod.bsp.BSP(x, y, width, height) instead.", FutureWarning) +@deprecate("Call tcod.bsp.BSP(x, y, width, height) instead.", category=FutureWarning) def bsp_new_with_size(x: int, y: int, w: int, h: int) -> tcod.bsp.BSP: """Create a new BSP instance with the given rectangle. @@ -539,7 +529,7 @@ def bsp_new_with_size(x: int, y: int, w: int, h: int) -> tcod.bsp.BSP: return Bsp(x, y, w, h) -@deprecate("Call node.split_once instead.", FutureWarning) +@deprecate("Call node.split_once instead.", category=FutureWarning) def bsp_split_once(node: tcod.bsp.BSP, horizontal: bool, position: int) -> None: """Deprecated function. @@ -549,7 +539,7 @@ def bsp_split_once(node: tcod.bsp.BSP, horizontal: bool, position: int) -> None: node.split_once(horizontal, position) -@deprecate("Call node.split_recursive instead.", FutureWarning) +@deprecate("Call node.split_recursive instead.", category=FutureWarning) def bsp_split_recursive( node: tcod.bsp.BSP, randomizer: tcod.random.Random | None, @@ -567,7 +557,7 @@ def bsp_split_recursive( node.split_recursive(nb, minHSize, minVSize, maxHRatio, maxVRatio, randomizer) -@deprecate("Assign values via attribute instead.", FutureWarning) +@deprecate("Assign values via attribute instead.", category=FutureWarning) def bsp_resize(node: tcod.bsp.BSP, x: int, y: int, w: int, h: int) -> None: """Deprecated function. @@ -600,7 +590,7 @@ def bsp_right(node: tcod.bsp.BSP) -> tcod.bsp.BSP | None: return None if not node.children else node.children[1] -@deprecate("Get the parent with 'node.parent' instead.", FutureWarning) +@deprecate("Get the parent with 'node.parent' instead.", category=FutureWarning) def bsp_father(node: tcod.bsp.BSP) -> tcod.bsp.BSP | None: """Deprecated function. @@ -610,7 +600,7 @@ def bsp_father(node: tcod.bsp.BSP) -> tcod.bsp.BSP | None: return node.parent -@deprecate("Check for children with 'bool(node.children)' instead.", FutureWarning) +@deprecate("Check for children with 'bool(node.children)' instead.", category=FutureWarning) def bsp_is_leaf(node: tcod.bsp.BSP) -> bool: """Deprecated function. @@ -620,7 +610,7 @@ def bsp_is_leaf(node: tcod.bsp.BSP) -> bool: return not node.children -@deprecate("Use 'node.contains' instead.", FutureWarning) +@deprecate("Use 'node.contains' instead.", category=FutureWarning) def bsp_contains(node: tcod.bsp.BSP, cx: int, cy: int) -> bool: """Deprecated function. @@ -630,7 +620,7 @@ def bsp_contains(node: tcod.bsp.BSP, cx: int, cy: int) -> bool: return node.contains(cx, cy) -@deprecate("Use 'node.find_node' instead.", FutureWarning) +@deprecate("Use 'node.find_node' instead.", category=FutureWarning) def bsp_find_node(node: tcod.bsp.BSP, cx: int, cy: int) -> tcod.bsp.BSP | None: """Deprecated function. @@ -706,7 +696,7 @@ def bsp_traverse_level_order( _bsp_traverse(node.level_order(), callback, userData) -@deprecate("Iterate over nodes using " "'for n in node.inverted_level_order():' instead.") +@deprecate("Iterate over nodes using 'for n in node.inverted_level_order():' instead.") def bsp_traverse_inverted_level_order( node: tcod.bsp.BSP, callback: Callable[[tcod.bsp.BSP, Any], None], @@ -734,7 +724,7 @@ def bsp_remove_sons(node: tcod.bsp.BSP) -> None: node.children = () -@deprecate("libtcod objects are deleted automatically.", FutureWarning) +@deprecate("libtcod objects are deleted automatically.", category=FutureWarning) def bsp_delete(node: tcod.bsp.BSP) -> None: """Exists for backward compatibility. Does nothing. @@ -837,7 +827,8 @@ def color_gen_map(colors: Iterable[tuple[int, int, int]], indexes: Iterable[int] List[Color]: A list of Color instances. Example: - >>> tcod.color_gen_map([(0, 0, 0), (255, 128, 0)], [0, 5]) + >>> from tcod import libtcodpy + >>> libtcodpy.color_gen_map([(0, 0, 0), (255, 128, 0)], [0, 5]) [Color(0, 0, 0), Color(51, 25, 0), Color(102, 51, 0), \ Color(153, 76, 0), Color(204, 102, 0), Color(255, 128, 0)] """ @@ -990,7 +981,7 @@ def console_set_custom_font( Added PathLike support. `fontFile` no longer takes bytes. """ fontFile = Path(fontFile).resolve(strict=True) - _check(lib.TCOD_console_set_custom_font(bytes(fontFile), flags, nb_char_horiz, nb_char_vertic)) + _check(lib.TCOD_console_set_custom_font(_path_encode(fontFile), flags, nb_char_horiz, nb_char_vertic)) @deprecate("Check `con.width` instead.") @@ -1224,7 +1215,7 @@ def console_flush( DeprecationWarning, stacklevel=2, ) - if len(clear_color) == 3: + if len(clear_color) == 3: # noqa: PLR2004 clear_color = clear_color[0], clear_color[1], clear_color[2], 255 options = { "keep_aspect": keep_aspect, @@ -1475,6 +1466,9 @@ def console_print_ex( con (Console): Any Console instance. x (int): Character x position from the left. y (int): Character y position from the top. + flag: Blending mode to use. + alignment: The libtcod alignment constant. + fmt: A unicode or bytes string, optionally using color codes. .. deprecated:: 8.5 Use :any:`Console.print_` instead. @@ -1617,8 +1611,8 @@ def console_print_frame( .. deprecated:: 8.5 Use :any:`Console.print_frame` instead. """ - fmt = _fmt(fmt) if fmt else ffi.NULL - _check(lib.TCOD_console_printf_frame(_console(con), x, y, w, h, clear, flag, fmt)) + fmt_: Any = _fmt(fmt) if fmt else ffi.NULL + _check(lib.TCOD_console_printf_frame(_console(con), x, y, w, h, clear, flag, fmt_)) @pending_deprecate() @@ -1688,7 +1682,7 @@ def console_get_char(con: tcod.console.Console, x: int, y: int) -> int: return lib.TCOD_console_get_char(_console(con), x, y) # type: ignore -@deprecate("This function is not supported if contexts are being used.", FutureWarning) +@deprecate("This function is not supported if contexts are being used.", category=FutureWarning) def console_set_fade(fade: int, fadingColor: tuple[int, int, int]) -> None: """Deprecated function. @@ -1698,7 +1692,7 @@ def console_set_fade(fade: int, fadingColor: tuple[int, int, int]) -> None: lib.TCOD_console_set_fade(fade, fadingColor) -@deprecate("This function is not supported if contexts are being used.", FutureWarning) +@deprecate("This function is not supported if contexts are being used.", category=FutureWarning) def console_get_fade() -> int: """Deprecated function. @@ -1708,7 +1702,7 @@ def console_get_fade() -> int: return int(lib.TCOD_console_get_fade()) -@deprecate("This function is not supported if contexts are being used.", FutureWarning) +@deprecate("This function is not supported if contexts are being used.", category=FutureWarning) def console_get_fading_color() -> Color: """Deprecated function. @@ -1724,8 +1718,7 @@ def console_wait_for_keypress(flush: bool) -> Key: """Block until the user presses a key, then returns a new Key. Args: - flush bool: If True then the event queue is cleared before waiting - for the next event. + flush: If True then the event queue is cleared before waiting for the next event. Returns: Key: A new Key instance. @@ -1762,7 +1755,7 @@ def console_check_for_keypress(flags: int = KEY_RELEASED) -> Key: return key -@deprecate("Use tcod.event.get_keyboard_state to see if a key is held.", FutureWarning) +@deprecate("Use tcod.event.get_keyboard_state to see if a key is held.", category=FutureWarning) def console_is_key_pressed(key: int) -> bool: """Return True if a key is held. @@ -1805,7 +1798,7 @@ def console_from_file(filename: str | PathLike[str]) -> tcod.console.Console: Added PathLike support. """ filename = Path(filename).resolve(strict=True) - return tcod.console.Console._from_cdata(_check_p(lib.TCOD_console_from_file(bytes(filename)))) + return tcod.console.Console._from_cdata(_check_p(lib.TCOD_console_from_file(_path_encode(filename)))) @deprecate("Call the `Console.blit` method instead.") @@ -1870,7 +1863,7 @@ def console_delete(con: tcod.console.Console) -> None: ) else: warnings.warn( - "You no longer need to make this call, " "Console's are deleted when they go out of scope.", + "You no longer need to make this call, Console's are deleted when they go out of scope.", DeprecationWarning, stacklevel=2, ) @@ -1984,7 +1977,7 @@ def console_load_asc(con: tcod.console.Console, filename: str | PathLike[str]) - Added PathLike support. """ filename = Path(filename).resolve(strict=True) - return bool(lib.TCOD_console_load_asc(_console(con), bytes(filename))) + return bool(lib.TCOD_console_load_asc(_console(con), _path_encode(filename))) @deprecate("This format is not actively supported") @@ -1997,7 +1990,7 @@ def console_save_asc(con: tcod.console.Console, filename: str | PathLike[str]) - .. versionchanged:: 16.0 Added PathLike support. """ - return bool(lib.TCOD_console_save_asc(_console(con), bytes(Path(filename)))) + return bool(lib.TCOD_console_save_asc(_console(con), _path_encode(Path(filename)))) @deprecate("This format is not actively supported") @@ -2011,7 +2004,7 @@ def console_load_apf(con: tcod.console.Console, filename: str | PathLike[str]) - Added PathLike support. """ filename = Path(filename).resolve(strict=True) - return bool(lib.TCOD_console_load_apf(_console(con), bytes(filename))) + return bool(lib.TCOD_console_load_apf(_console(con), _path_encode(filename))) @deprecate("This format is not actively supported") @@ -2024,7 +2017,7 @@ def console_save_apf(con: tcod.console.Console, filename: str | PathLike[str]) - .. versionchanged:: 16.0 Added PathLike support. """ - return bool(lib.TCOD_console_save_apf(_console(con), bytes(Path(filename)))) + return bool(lib.TCOD_console_save_apf(_console(con), _path_encode(Path(filename)))) @deprecate("Use tcod.console.load_xp to load this file.") @@ -2039,7 +2032,7 @@ def console_load_xp(con: tcod.console.Console, filename: str | PathLike[str]) -> Added PathLike support. """ filename = Path(filename).resolve(strict=True) - return bool(lib.TCOD_console_load_xp(_console(con), bytes(filename))) + return bool(lib.TCOD_console_load_xp(_console(con), _path_encode(filename))) @deprecate("Use tcod.console.save_xp to save this console.") @@ -2049,7 +2042,7 @@ def console_save_xp(con: tcod.console.Console, filename: str | PathLike[str], co .. versionchanged:: 16.0 Added PathLike support. """ - return bool(lib.TCOD_console_save_xp(_console(con), bytes(Path(filename)), compress_level)) + return bool(lib.TCOD_console_save_xp(_console(con), _path_encode(Path(filename)), compress_level)) @deprecate("Use tcod.console.load_xp to load this file.") @@ -2060,7 +2053,7 @@ def console_from_xp(filename: str | PathLike[str]) -> tcod.console.Console: Added PathLike support. """ filename = Path(filename).resolve(strict=True) - return tcod.console.Console._from_cdata(_check_p(lib.TCOD_console_from_xp(bytes(filename)))) + return tcod.console.Console._from_cdata(_check_p(lib.TCOD_console_from_xp(_path_encode(filename)))) @deprecate("Use tcod.console.load_xp to load this file.") @@ -2073,7 +2066,7 @@ def console_list_load_xp( Added PathLike support. """ filename = Path(filename).resolve(strict=True) - tcod_list = lib.TCOD_console_list_from_xp(bytes(filename)) + tcod_list = lib.TCOD_console_list_from_xp(_path_encode(filename)) if tcod_list == ffi.NULL: return None try: @@ -2101,7 +2094,7 @@ def console_list_save_xp( try: for console in console_list: lib.TCOD_list_push(tcod_list, _console(console)) - return bool(lib.TCOD_console_list_save_xp(tcod_list, bytes(Path(filename)), compress_level)) + return bool(lib.TCOD_console_list_save_xp(tcod_list, _path_encode(Path(filename)), compress_level)) finally: lib.TCOD_list_delete(tcod_list) @@ -2134,8 +2127,8 @@ def path_new_using_function( Args: w (int): Clipping width. h (int): Clipping height. - func (Callable[[int, int, int, int, Any], float]): - userData (Any): + func: Callback function with the format: `f(origin_x, origin_y, dest_x, dest_y, userData) -> float` + userData (Any): An object passed to the callback. dcost (float): A multiplier for the cost of diagonal movement. Can be set to 0 to disable diagonal movement. @@ -2362,7 +2355,8 @@ def _heightmap_cdata(array: NDArray[np.float32]) -> ffi.CData: msg = "array must be a contiguous segment." raise ValueError(msg) if array.dtype != np.float32: - raise ValueError("array dtype must be float32, not %r" % array.dtype) + msg = f"array dtype must be float32, not {array.dtype!r}" + raise ValueError(msg) height, width = array.shape pointer = ffi.from_buffer("float *", array) return ffi.new("TCOD_heightmap_t *", (width, height, pointer)) @@ -2389,11 +2383,10 @@ def heightmap_new(w: int, h: int, order: str = "C") -> NDArray[np.float32]: """ if order == "C": return np.zeros((h, w), np.float32, order="C") - elif order == "F": + if order == "F": return np.zeros((w, h), np.float32, order="F") - else: - msg = "Invalid order parameter, should be 'C' or 'F'." - raise ValueError(msg) + msg = "Invalid order parameter, should be 'C' or 'F'." + raise ValueError(msg) @deprecate("Assign to heightmaps as a NumPy array instead.") @@ -2405,7 +2398,7 @@ def heightmap_set_value(hm: NDArray[np.float32], x: int, y: int, value: float) - """ if hm.flags["C_CONTIGUOUS"]: warnings.warn( - "Assign to this heightmap with hm[i,j] = value\n" "consider using order='F'", + "Assign to this heightmap with hm[i,j] = value\nconsider using order='F'", DeprecationWarning, stacklevel=2, ) @@ -2483,6 +2476,7 @@ def heightmap_copy(hm1: NDArray[np.float32], hm2: NDArray[np.float32]) -> None: """Copy the heightmap ``hm1`` to ``hm2``. Args: + hm: A numpy.ndarray formatted for heightmap functions. hm1 (numpy.ndarray): The source heightmap. hm2 (numpy.ndarray): The destination heightmap. @@ -2497,6 +2491,7 @@ def heightmap_normalize(hm: NDArray[np.float32], mi: float = 0.0, ma: float = 1. """Normalize heightmap values between ``mi`` and ``ma``. Args: + hm: A numpy.ndarray formatted for heightmap functions. mi (float): The lowest value after normalization. ma (float): The highest value after normalization. """ @@ -2672,13 +2667,13 @@ def heightmap_kernel_transform( Example: >>> import numpy as np + >>> from tcod import libtcodpy >>> heightmap = np.zeros((3, 3), dtype=np.float32) >>> heightmap[:,1] = 1 >>> dx = [-1, 1, 0] >>> dy = [0, 0, 0] >>> weight = [0.33, 0.33, 0.33] - >>> tcod.heightmap_kernel_transform(heightmap, 3, dx, dy, weight, - ... 0.0, 1.0) + >>> libtcodpy.heightmap_kernel_transform(heightmap, 3, dx, dy, weight, 0.0, 1.0) """ c_dx = ffi.new("int[]", dx) c_dy = ffi.new("int[]", dy) @@ -2852,21 +2847,20 @@ def heightmap_get_value(hm: NDArray[np.float32], x: int, y: int) -> float: """ if hm.flags["C_CONTIGUOUS"]: warnings.warn( - "Get a value from this heightmap with hm[i,j]\n" "consider using order='F'", + "Get a value from this heightmap with hm[i,j]\nconsider using order='F'", DeprecationWarning, stacklevel=2, ) return hm[y, x] # type: ignore - elif hm.flags["F_CONTIGUOUS"]: + if hm.flags["F_CONTIGUOUS"]: warnings.warn( "Get a value from this heightmap with hm[x,y]", DeprecationWarning, stacklevel=2, ) return hm[x, y] # type: ignore - else: - msg = "This array is not contiguous." - raise ValueError(msg) + msg = "This array is not contiguous." + raise ValueError(msg) @pending_deprecate() @@ -2914,7 +2908,7 @@ def heightmap_get_normal(hm: NDArray[np.float32], x: float, y: float, waterLevel """ cn = ffi.new("float[3]") lib.TCOD_heightmap_get_normal(_heightmap_cdata(hm), x, y, cn, waterLevel) - return tuple(cn) # type: ignore + return tuple(cn) @deprecate("This function is deprecated, see documentation.") @@ -2942,7 +2936,7 @@ def heightmap_has_land_on_border(hm: NDArray[np.float32], waterlevel: float) -> Args: hm (numpy.ndarray): A numpy.ndarray formatted for heightmap functions. - waterLevel (float): The water level to use. + waterlevel (float): The water level to use. Returns: bool: True if the map edges are below ``waterlevel``, otherwise False. @@ -2980,52 +2974,52 @@ def heightmap_delete(hm: Any) -> None: """ -@deprecate("Use `tcod.image.Image(width, height)` instead.", FutureWarning) +@deprecate("Use `tcod.image.Image(width, height)` instead.", category=FutureWarning) def image_new(width: int, height: int) -> tcod.image.Image: return tcod.image.Image(width, height) -@deprecate("Use the `image.clear()` method instead.", FutureWarning) +@deprecate("Use the `image.clear()` method instead.", category=FutureWarning) def image_clear(image: tcod.image.Image, col: tuple[int, int, int]) -> None: image.clear(col) -@deprecate("Use the `image.invert()` method instead.", FutureWarning) +@deprecate("Use the `image.invert()` method instead.", category=FutureWarning) def image_invert(image: tcod.image.Image) -> None: image.invert() -@deprecate("Use the `image.hflip()` method instead.", FutureWarning) +@deprecate("Use the `image.hflip()` method instead.", category=FutureWarning) def image_hflip(image: tcod.image.Image) -> None: image.hflip() -@deprecate("Use the `image.rotate90(n)` method instead.", FutureWarning) +@deprecate("Use the `image.rotate90(n)` method instead.", category=FutureWarning) def image_rotate90(image: tcod.image.Image, num: int = 1) -> None: image.rotate90(num) -@deprecate("Use the `image.vflip()` method instead.", FutureWarning) +@deprecate("Use the `image.vflip()` method instead.", category=FutureWarning) def image_vflip(image: tcod.image.Image) -> None: image.vflip() -@deprecate("Use the `image.scale(new_width, new_height)` method instead.", FutureWarning) +@deprecate("Use the `image.scale(new_width, new_height)` method instead.", category=FutureWarning) def image_scale(image: tcod.image.Image, neww: int, newh: int) -> None: image.scale(neww, newh) -@deprecate("Use the `image.image_set_key_color(rgb)` method instead.", FutureWarning) +@deprecate("Use the `image.image_set_key_color(rgb)` method instead.", category=FutureWarning) def image_set_key_color(image: tcod.image.Image, col: tuple[int, int, int]) -> None: image.set_key_color(col) -@deprecate("Use `np.asarray(image)[y, x, 3]` instead.", FutureWarning) +@deprecate("Use `np.asarray(image)[y, x, 3]` instead.", category=FutureWarning) def image_get_alpha(image: tcod.image.Image, x: int, y: int) -> int: return image.get_alpha(x, y) -@deprecate("Use the Numpy array interface to check alpha or color keys.", FutureWarning) +@deprecate("Use the Numpy array interface to check alpha or color keys.", category=FutureWarning) def image_is_pixel_transparent(image: tcod.image.Image, x: int, y: int) -> bool: return bool(lib.TCOD_image_is_pixel_transparent(image.image_c, x, y)) @@ -3033,7 +3027,7 @@ def image_is_pixel_transparent(image: tcod.image.Image, x: int, y: int) -> bool: @deprecate( "Call the classmethod `tcod.image.Image.from_file` instead to load images." "\nIt's recommended to load images with a more complete image library such as python-Pillow or python-imageio.", - FutureWarning, + category=FutureWarning, ) def image_load(filename: str | PathLike[str]) -> tcod.image.Image: """Load an image file into an Image instance and return it. @@ -3050,7 +3044,7 @@ def image_load(filename: str | PathLike[str]) -> tcod.image.Image: return tcod.image.Image.from_file(filename) -@deprecate("Use `Tileset.render` instead of this function.", FutureWarning) +@deprecate("Use `Tileset.render` instead of this function.", category=FutureWarning) def image_from_console(console: tcod.console.Console) -> tcod.image.Image: """Return an Image with a Consoles pixel data. @@ -3070,7 +3064,7 @@ def image_from_console(console: tcod.console.Console) -> tcod.image.Image: ) -@deprecate("Use `Tileset.render` instead of this function.", FutureWarning) +@deprecate("Use `Tileset.render` instead of this function.", category=FutureWarning) def image_refresh_console(image: tcod.image.Image, console: tcod.console.Console) -> None: """Update an image made with :any:`image_from_console`. @@ -3080,27 +3074,27 @@ def image_refresh_console(image: tcod.image.Image, console: tcod.console.Console image.refresh_console(console) -@deprecate("Access an images size with `image.width` or `image.height`.", FutureWarning) +@deprecate("Access an images size with `image.width` or `image.height`.", category=FutureWarning) def image_get_size(image: tcod.image.Image) -> tuple[int, int]: return image.width, image.height -@deprecate("Use `np.asarray(image)[y, x, :3]` instead.", FutureWarning) +@deprecate("Use `np.asarray(image)[y, x, :3]` instead.", category=FutureWarning) def image_get_pixel(image: tcod.image.Image, x: int, y: int) -> tuple[int, int, int]: return image.get_pixel(x, y) -@deprecate("Use the `image.get_mipmap_pixel(...)` method instead.", FutureWarning) +@deprecate("Use the `image.get_mipmap_pixel(...)` method instead.", category=FutureWarning) def image_get_mipmap_pixel(image: tcod.image.Image, x0: float, y0: float, x1: float, y1: float) -> tuple[int, int, int]: return image.get_mipmap_pixel(x0, y0, x1, y1) -@deprecate("Use `np.asarray(image)[y, x, :3] = rgb` instead.", FutureWarning) +@deprecate("Use `np.asarray(image)[y, x, :3] = rgb` instead.", category=FutureWarning) def image_put_pixel(image: tcod.image.Image, x: int, y: int, col: tuple[int, int, int]) -> None: image.put_pixel(x, y, col) -@deprecate("Use the `image.blit(...)` method instead.", FutureWarning) +@deprecate("Use the `image.blit(...)` method instead.", category=FutureWarning) def image_blit( image: tcod.image.Image, console: tcod.console.Console, @@ -3114,7 +3108,7 @@ def image_blit( image.blit(console, x, y, bkgnd_flag, scalex, scaley, angle) -@deprecate("Use the `image.blit_rect(...)` method instead.", FutureWarning) +@deprecate("Use the `image.blit_rect(...)` method instead.", category=FutureWarning) def image_blit_rect( image: tcod.image.Image, console: tcod.console.Console, @@ -3127,7 +3121,7 @@ def image_blit_rect( image.blit_rect(console, x, y, w, h, bkgnd_flag) -@deprecate("Use `Console.draw_semigraphics(image, ...)` instead.", FutureWarning) +@deprecate("Use `Console.draw_semigraphics(image, ...)` instead.", category=FutureWarning) def image_blit_2x( image: tcod.image.Image, console: tcod.console.Console, @@ -3141,12 +3135,12 @@ def image_blit_2x( image.blit_2x(console, dx, dy, sx, sy, w, h) -@deprecate("Use the `image.save_as` method instead.", FutureWarning) +@deprecate("Use the `image.save_as` method instead.", category=FutureWarning) def image_save(image: tcod.image.Image, filename: str | PathLike[str]) -> None: image.save_as(filename) -@deprecate("libtcod objects are deleted automatically.", FutureWarning) +@deprecate("libtcod objects are deleted automatically.", category=FutureWarning) def image_delete(image: tcod.image.Image) -> None: """Does nothing. libtcod objects are managed by Python's garbage collector. @@ -3154,7 +3148,7 @@ def image_delete(image: tcod.image.Image) -> None: """ -@deprecate("Use tcod.los.bresenham instead.", FutureWarning) +@deprecate("Use tcod.los.bresenham instead.", category=FutureWarning) def line_init(xo: int, yo: int, xd: int, yd: int) -> None: """Initialize a line whose points will be returned by `line_step`. @@ -3174,7 +3168,7 @@ def line_init(xo: int, yo: int, xd: int, yd: int) -> None: lib.TCOD_line_init(xo, yo, xd, yd) -@deprecate("Use tcod.los.bresenham instead.", FutureWarning) +@deprecate("Use tcod.los.bresenham instead.", category=FutureWarning) def line_step() -> tuple[int, int] | tuple[None, None]: """After calling line_init returns (x, y) points of the line. @@ -3196,7 +3190,7 @@ def line_step() -> tuple[int, int] | tuple[None, None]: return None, None -@deprecate("Use tcod.los.bresenham instead.", FutureWarning) +@deprecate("Use tcod.los.bresenham instead.", category=FutureWarning) def line(xo: int, yo: int, xd: int, yd: int, py_callback: Callable[[int, int], bool]) -> bool: """Iterate over a line using a callback function. @@ -3228,7 +3222,7 @@ def line(xo: int, yo: int, xd: int, yd: int, py_callback: Callable[[int, int], b return False -@deprecate("This function has been replaced by tcod.los.bresenham.", FutureWarning) +@deprecate("This function has been replaced by tcod.los.bresenham.", category=FutureWarning) def line_iter(xo: int, yo: int, xd: int, yd: int) -> Iterator[tuple[int, int]]: """Returns an Iterable over a Bresenham line. @@ -3255,7 +3249,7 @@ def line_iter(xo: int, yo: int, xd: int, yd: int) -> Iterator[tuple[int, int]]: yield (x[0], y[0]) -@deprecate("This function has been replaced by tcod.los.bresenham.", FutureWarning) +@deprecate("This function has been replaced by tcod.los.bresenham.", category=FutureWarning) def line_where(x1: int, y1: int, x2: int, y2: int, inclusive: bool = True) -> tuple[NDArray[np.intc], NDArray[np.intc]]: """Return a NumPy index array following a Bresenham line. @@ -3273,7 +3267,7 @@ def line_where(x1: int, y1: int, x2: int, y2: int, inclusive: bool = True) -> tu return i, j -@deprecate("Call tcod.map.Map(width, height) instead.", FutureWarning) +@deprecate("Call tcod.map.Map(width, height) instead.", category=FutureWarning) def map_new(w: int, h: int) -> tcod.map.Map: """Return a :any:`tcod.map.Map` with a width and height. @@ -3284,7 +3278,7 @@ def map_new(w: int, h: int) -> tcod.map.Map: return tcod.map.Map(w, h) -@deprecate("Use Python's standard copy module instead.", FutureWarning) +@deprecate("Use Python's standard copy module instead.", category=FutureWarning) def map_copy(source: tcod.map.Map, dest: tcod.map.Map) -> None: """Copy map data from `source` to `dest`. @@ -3297,7 +3291,7 @@ def map_copy(source: tcod.map.Map, dest: tcod.map.Map) -> None: dest._Map__buffer[:] = source._Map__buffer[:] # type: ignore -@deprecate("Set properties using the m.transparent and m.walkable arrays.", FutureWarning) +@deprecate("Set properties using the m.transparent and m.walkable arrays.", category=FutureWarning) def map_set_properties(m: tcod.map.Map, x: int, y: int, isTrans: bool, isWalk: bool) -> None: """Set the properties of a single cell. @@ -3310,7 +3304,7 @@ def map_set_properties(m: tcod.map.Map, x: int, y: int, isTrans: bool, isWalk: b lib.TCOD_map_set_properties(m.map_c, x, y, isTrans, isWalk) -@deprecate("Clear maps using NumPy broadcast rules instead.", FutureWarning) +@deprecate("Clear maps using NumPy broadcast rules instead.", category=FutureWarning) def map_clear(m: tcod.map.Map, transparent: bool = False, walkable: bool = False) -> None: """Change all map cells to a specific value. @@ -3322,7 +3316,7 @@ def map_clear(m: tcod.map.Map, transparent: bool = False, walkable: bool = False m.walkable[:] = walkable -@deprecate("Call the map.compute_fov method instead.", FutureWarning) +@deprecate("Call the map.compute_fov method instead.", category=FutureWarning) def map_compute_fov( m: tcod.map.Map, x: int, @@ -3339,7 +3333,7 @@ def map_compute_fov( m.compute_fov(x, y, radius, light_walls, algo) -@deprecate("Use map.fov to check for this property.", FutureWarning) +@deprecate("Use map.fov to check for this property.", category=FutureWarning) def map_is_in_fov(m: tcod.map.Map, x: int, y: int) -> bool: """Return True if the cell at x,y is lit by the last field-of-view algorithm. @@ -3351,7 +3345,7 @@ def map_is_in_fov(m: tcod.map.Map, x: int, y: int) -> bool: return bool(lib.TCOD_map_is_in_fov(m.map_c, x, y)) -@deprecate("Use map.transparent to check for this property.", FutureWarning) +@deprecate("Use map.transparent to check for this property.", category=FutureWarning) def map_is_transparent(m: tcod.map.Map, x: int, y: int) -> bool: """Return True is a map cell is transparent. @@ -3363,7 +3357,7 @@ def map_is_transparent(m: tcod.map.Map, x: int, y: int) -> bool: return bool(lib.TCOD_map_is_transparent(m.map_c, x, y)) -@deprecate("Use map.walkable to check for this property.", FutureWarning) +@deprecate("Use map.walkable to check for this property.", category=FutureWarning) def map_is_walkable(m: tcod.map.Map, x: int, y: int) -> bool: """Return True is a map cell is walkable. @@ -3375,7 +3369,7 @@ def map_is_walkable(m: tcod.map.Map, x: int, y: int) -> bool: return bool(lib.TCOD_map_is_walkable(m.map_c, x, y)) -@deprecate("libtcod objects are deleted automatically.", FutureWarning) +@deprecate("libtcod objects are deleted automatically.", category=FutureWarning) def map_delete(m: tcod.map.Map) -> None: """Does nothing. libtcod objects are managed by Python's garbage collector. @@ -3383,7 +3377,7 @@ def map_delete(m: tcod.map.Map) -> None: """ -@deprecate("Check the map.width attribute instead.", FutureWarning) +@deprecate("Check the map.width attribute instead.", category=FutureWarning) def map_get_width(map: tcod.map.Map) -> int: """Return the width of a map. @@ -3393,7 +3387,7 @@ def map_get_width(map: tcod.map.Map) -> int: return map.width -@deprecate("Check the map.height attribute instead.", FutureWarning) +@deprecate("Check the map.height attribute instead.", category=FutureWarning) def map_get_height(map: tcod.map.Map) -> int: """Return the height of a map. @@ -3403,7 +3397,7 @@ def map_get_height(map: tcod.map.Map) -> int: return map.height -@deprecate("Use `tcod.sdl.mouse.show(visible)` instead.", FutureWarning) +@deprecate("Use `tcod.sdl.mouse.show(visible)` instead.", category=FutureWarning) def mouse_show_cursor(visible: bool) -> None: """Change the visibility of the mouse cursor. @@ -3413,7 +3407,7 @@ def mouse_show_cursor(visible: bool) -> None: lib.TCOD_mouse_show_cursor(visible) -@deprecate("Use `is_visible = tcod.sdl.mouse.show()` instead.", FutureWarning) +@deprecate("Use `is_visible = tcod.sdl.mouse.show()` instead.", category=FutureWarning) def mouse_is_cursor_visible() -> bool: """Return True if the mouse cursor is visible. @@ -3423,19 +3417,19 @@ def mouse_is_cursor_visible() -> bool: return bool(lib.TCOD_mouse_is_cursor_visible()) -@deprecate("Use `tcod.sdl.mouse.warp_in_window` instead.", FutureWarning) +@deprecate("Use `tcod.sdl.mouse.warp_in_window` instead.", category=FutureWarning) def mouse_move(x: int, y: int) -> None: lib.TCOD_mouse_move(x, y) -@deprecate("Use tcod.event.get_mouse_state() instead.", FutureWarning) +@deprecate("Use tcod.event.get_mouse_state() instead.", category=FutureWarning) def mouse_get_status() -> Mouse: return Mouse(lib.TCOD_mouse_get_status()) @pending_deprecate() def namegen_parse(filename: str | PathLike[str], random: tcod.random.Random | None = None) -> None: - lib.TCOD_namegen_parse(bytes(Path(filename)), random or ffi.NULL) + lib.TCOD_namegen_parse(_path_encode(Path(filename).resolve(strict=True)), random or ffi.NULL) @pending_deprecate() @@ -3465,7 +3459,7 @@ def namegen_destroy() -> None: lib.TCOD_namegen_destroy() -@deprecate("Use `tcod.noise.Noise(dimensions, hurst=, lacunarity=)` instead.", FutureWarning) +@deprecate("Use `tcod.noise.Noise(dimensions, hurst=, lacunarity=)` instead.", category=FutureWarning) def noise_new( dim: int, h: float = NOISE_DEFAULT_HURST, @@ -3486,17 +3480,18 @@ def noise_new( return tcod.noise.Noise(dim, hurst=h, lacunarity=l, seed=random) -@deprecate("Use `noise.algorithm = x` instead.", FutureWarning) +@deprecate("Use `noise.algorithm = x` instead.", category=FutureWarning) def noise_set_type(n: tcod.noise.Noise, typ: int) -> None: """Set a Noise objects default noise algorithm. Args: + n: Noise object. typ (int): Any NOISE_* constant. """ n.algorithm = typ -@deprecate("Use `value = noise[x]` instead.", FutureWarning) +@deprecate("Use `value = noise[x]` instead.", category=FutureWarning) def noise_get(n: tcod.noise.Noise, f: Sequence[float], typ: int = NOISE_DEFAULT) -> float: """Return the noise value sampled from the ``f`` coordinate. @@ -3516,7 +3511,7 @@ def noise_get(n: tcod.noise.Noise, f: Sequence[float], typ: int = NOISE_DEFAULT) return float(lib.TCOD_noise_get_ex(n.noise_c, ffi.new("float[4]", f), typ)) -@deprecate("Configure a Noise instance for FBM and then sample it like normal.", FutureWarning) +@deprecate("Configure a Noise instance for FBM and then sample it like normal.", category=FutureWarning) def noise_get_fbm( n: tcod.noise.Noise, f: Sequence[float], @@ -3529,7 +3524,7 @@ def noise_get_fbm( n (Noise): A Noise instance. f (Sequence[float]): The point to sample the noise from. typ (int): The noise algorithm to use. - octaves (float): The level of level. Should be more than 1. + oc (float): The level of level. Should be more than 1. Returns: float: The sampled noise value. @@ -3537,7 +3532,7 @@ def noise_get_fbm( return float(lib.TCOD_noise_get_fbm_ex(n.noise_c, ffi.new("float[4]", f), oc, typ)) -@deprecate("Configure a Noise instance for FBM and then sample it like normal.", FutureWarning) +@deprecate("Configure a Noise instance for FBM and then sample it like normal.", category=FutureWarning) def noise_get_turbulence( n: tcod.noise.Noise, f: Sequence[float], @@ -3550,7 +3545,7 @@ def noise_get_turbulence( n (Noise): A Noise instance. f (Sequence[float]): The point to sample the noise from. typ (int): The noise algorithm to use. - octaves (float): The level of level. Should be more than 1. + oc (float): The level of level. Should be more than 1. Returns: float: The sampled noise value. @@ -3558,7 +3553,7 @@ def noise_get_turbulence( return float(lib.TCOD_noise_get_turbulence_ex(n.noise_c, ffi.new("float[4]", f), oc, typ)) -@deprecate("libtcod objects are deleted automatically.", FutureWarning) +@deprecate("libtcod objects are deleted automatically.", category=FutureWarning) def noise_delete(n: tcod.noise.Noise) -> None: # type (Any) -> None """Does nothing. libtcod objects are managed by Python's garbage collector. @@ -3567,26 +3562,26 @@ def noise_delete(n: tcod.noise.Noise) -> None: """ -def _unpack_union(type_: int, union: Any) -> Any: +def _unpack_union(type_: int, union: Any) -> Any: # noqa: PLR0911 """Unpack items from parser new_property (value_converter).""" if type_ == lib.TCOD_TYPE_BOOL: return bool(union.b) - elif type_ == lib.TCOD_TYPE_CHAR: + if type_ == lib.TCOD_TYPE_CHAR: return union.c.decode("latin-1") - elif type_ == lib.TCOD_TYPE_INT: + if type_ == lib.TCOD_TYPE_INT: return union.i - elif type_ == lib.TCOD_TYPE_FLOAT: + if type_ == lib.TCOD_TYPE_FLOAT: return union.f - elif type_ == lib.TCOD_TYPE_STRING or lib.TCOD_TYPE_VALUELIST15 >= type_ >= lib.TCOD_TYPE_VALUELIST00: + if type_ == lib.TCOD_TYPE_STRING or lib.TCOD_TYPE_VALUELIST15 >= type_ >= lib.TCOD_TYPE_VALUELIST00: return _unpack_char_p(union.s) - elif type_ == lib.TCOD_TYPE_COLOR: + if type_ == lib.TCOD_TYPE_COLOR: return Color._new_from_cdata(union.col) - elif type_ == lib.TCOD_TYPE_DICE: + if type_ == lib.TCOD_TYPE_DICE: return Dice(union.dice) - elif type_ & lib.TCOD_TYPE_LIST: + if type_ & lib.TCOD_TYPE_LIST: return _convert_TCODList(union.list, type_ & 0xFF) - else: - raise RuntimeError("Unknown libtcod type: %i" % type_) + msg = f"Unknown libtcod type: {type_}" + raise RuntimeError(msg) def _convert_TCODList(c_list: Any, type_: int) -> Any: @@ -3609,36 +3604,37 @@ def parser_new_struct(parser: Any, name: str) -> Any: _parser_listener: Any = None -@ffi.def_extern() # type: ignore +@ffi.def_extern() # type: ignore[misc] def _pycall_parser_new_struct(struct: Any, name: str) -> Any: return _parser_listener.new_struct(struct, _unpack_char_p(name)) -@ffi.def_extern() # type: ignore +@ffi.def_extern() # type: ignore[misc] def _pycall_parser_new_flag(name: str) -> Any: return _parser_listener.new_flag(_unpack_char_p(name)) -@ffi.def_extern() # type: ignore +@ffi.def_extern() # type: ignore[misc] def _pycall_parser_new_property(propname: Any, type: Any, value: Any) -> Any: return _parser_listener.new_property(_unpack_char_p(propname), type, _unpack_union(type, value)) -@ffi.def_extern() # type: ignore +@ffi.def_extern() # type: ignore[misc] def _pycall_parser_end_struct(struct: Any, name: Any) -> Any: return _parser_listener.end_struct(struct, _unpack_char_p(name)) -@ffi.def_extern() # type: ignore +@ffi.def_extern() # type: ignore[misc] def _pycall_parser_error(msg: Any) -> None: _parser_listener.error(_unpack_char_p(msg)) @deprecate("Parser functions have been deprecated.") def parser_run(parser: Any, filename: str | PathLike[str], listener: Any = None) -> None: - global _parser_listener + global _parser_listener # noqa: PLW0603 + filename = Path(filename).resolve(strict=True) if not listener: - lib.TCOD_parser_run(parser, bytes(Path(filename)), ffi.NULL) + lib.TCOD_parser_run(parser, _path_encode(filename), ffi.NULL) return propagate_manager = _PropagateException() @@ -3657,7 +3653,7 @@ def parser_run(parser: Any, filename: str | PathLike[str], listener: Any = None) with _parser_callback_lock: _parser_listener = listener with propagate_manager: - lib.TCOD_parser_run(parser, bytes(Path(filename)), c_listener) + lib.TCOD_parser_run(parser, _path_encode(filename), c_listener) @deprecate("libtcod objects are deleted automatically.") @@ -3779,8 +3775,8 @@ def random_get_int(rnd: tcod.random.Random | None, mi: int, ma: int) -> int: Args: rnd (Optional[Random]): A Random instance, or None to use the default. - low (int): The lower bound of the random range, inclusive. - high (int): The upper bound of the random range, inclusive. + mi (int): The lower bound of the random range, inclusive. + ma (int): The upper bound of the random range, inclusive. Returns: int: A random integer in the range ``mi`` <= n <= ``ma``. @@ -3796,8 +3792,8 @@ def random_get_float(rnd: tcod.random.Random | None, mi: float, ma: float) -> fl Args: rnd (Optional[Random]): A Random instance, or None to use the default. - low (float): The lower bound of the random range, inclusive. - high (float): The upper bound of the random range, inclusive. + mi (float): The lower bound of the random range, inclusive. + ma (float): The upper bound of the random range, inclusive. Returns: float: A random double precision float @@ -3825,8 +3821,8 @@ def random_get_int_mean(rnd: tcod.random.Random | None, mi: int, ma: int, mean: Args: rnd (Optional[Random]): A Random instance, or None to use the default. - low (int): The lower bound of the random range, inclusive. - high (int): The upper bound of the random range, inclusive. + mi (int): The lower bound of the random range, inclusive. + ma (int): The upper bound of the random range, inclusive. mean (int): The mean return value. Returns: @@ -3843,8 +3839,8 @@ def random_get_float_mean(rnd: tcod.random.Random | None, mi: float, ma: float, Args: rnd (Optional[Random]): A Random instance, or None to use the default. - low (float): The lower bound of the random range, inclusive. - high (float): The upper bound of the random range, inclusive. + mi (float): The lower bound of the random range, inclusive. + ma (float): The upper bound of the random range, inclusive. mean (float): The mean return value. Returns: @@ -4069,7 +4065,7 @@ def sys_save_screenshot(name: str | PathLike[str] | None = None) -> None: screenshot000.png, screenshot001.png, etc. Whichever is available first. Args: - file Optional[AnyStr]: File path to save screenshot. + name: File path to save screenshot. .. deprecated:: 11.13 This function is not supported by contexts. @@ -4078,7 +4074,7 @@ def sys_save_screenshot(name: str | PathLike[str] | None = None) -> None: .. versionchanged:: 16.0 Added PathLike support. """ - lib.TCOD_sys_save_screenshot(bytes(Path(name)) if name is not None else ffi.NULL) + lib.TCOD_sys_save_screenshot(_path_encode(Path(name)) if name is not None else ffi.NULL) # custom fullscreen resolution @@ -4177,8 +4173,7 @@ def sys_register_SDL_renderer(callback: Callable[[Any], None]) -> None: The callback is called on every call to :any:`libtcodpy.console_flush`. Args: - callback Callable[[CData], None]: - A function which takes a single argument. + callback: A function which takes a single argument. .. deprecated:: 11.13 This function is not supported by contexts. @@ -4287,7 +4282,7 @@ def __getattr__(name: str) -> Color: raise AttributeError(msg) from None -__all__ = [ # noqa: F405 +__all__ = [ # noqa: F405 RUF022 "Color", "Bsp", "NB_FOV_ALGORITHMS", diff --git a/tcod/los.py b/tcod/los.py index d6cd1362..f09b073c 100644 --- a/tcod/los.py +++ b/tcod/los.py @@ -1,13 +1,16 @@ """This modules holds functions for NumPy-based line of sight algorithms.""" + from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np -from numpy.typing import NDArray from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from numpy.typing import NDArray + def bresenham(start: tuple[int, int], end: tuple[int, int]) -> NDArray[np.intc]: """Return a thin Bresenham line as a NumPy array of shape (length, 2). diff --git a/tcod/map.py b/tcod/map.py index 820c981d..45b4561a 100644 --- a/tcod/map.py +++ b/tcod/map.py @@ -1,17 +1,19 @@ """libtcod map attributes and field-of-view functions.""" + from __future__ import annotations import warnings -from typing import Any +from typing import TYPE_CHECKING, Any, Literal import numpy as np -from numpy.typing import ArrayLike, NDArray -from typing_extensions import Literal import tcod._internal import tcod.constants from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from numpy.typing import ArrayLike, NDArray + class Map: """A map containing libtcod attributes. @@ -60,7 +62,7 @@ class Map: [ True, True, True], [False, True, True], [False, False, True]]...) - >>> m.fov[3,1] + >>> m.fov.item(3, 1) False .. deprecated:: 11.13 @@ -75,6 +77,7 @@ def __init__( height: int, order: Literal["C", "F"] = "C", ) -> None: + """Initialize the map.""" warnings.warn( "This class may perform poorly and is no longer needed.", DeprecationWarning, @@ -87,7 +90,7 @@ def __init__( self.__buffer: NDArray[np.bool_] = np.zeros((height, width, 3), dtype=np.bool_) self.map_c = self.__as_cdata() - def __as_cdata(self) -> Any: + def __as_cdata(self) -> Any: # noqa: ANN401 return ffi.new( "struct TCOD_Map*", ( @@ -137,8 +140,8 @@ def compute_fov( """ if not (0 <= x < self.width and 0 <= y < self.height): warnings.warn( - "Index ({}, {}) is outside of this maps shape ({}, {})." - "\nThis will raise an error in future versions.".format(x, y, self.width, self.height), + f"Index ({x}, {y}) is outside of this maps shape ({self.width}, {self.height})." + "\nThis will raise an error in future versions.", RuntimeWarning, stacklevel=2, ) @@ -232,15 +235,16 @@ def compute_fov( conditions. """ transparency = np.asarray(transparency) - if len(transparency.shape) != 2: - raise TypeError("transparency must be an array of 2 dimensions" " (shape is %r)" % transparency.shape) + if len(transparency.shape) != 2: # noqa: PLR2004 + msg = f"transparency must be an array of 2 dimensions (shape is {transparency.shape!r})" + raise TypeError(msg) if isinstance(pov, int): msg = "The tcod.map.compute_fov function has changed. The `x` and `y` parameters should now be given as a single tuple." raise TypeError(msg) if not (0 <= pov[0] < transparency.shape[0] and 0 <= pov[1] < transparency.shape[1]): warnings.warn( - "Given pov index {!r} is outside the array of shape {!r}." - "\nThis will raise an error in future versions.".format(pov, transparency.shape), + f"Given pov index {pov!r} is outside the array of shape {transparency.shape!r}." + "\nThis will raise an error in future versions.", RuntimeWarning, stacklevel=2, ) @@ -257,6 +261,6 @@ def compute_fov( ffi.from_buffer("struct TCOD_MapCell*", map_buffer), ), ) - map_buffer["transparent"] = transparency + map_buffer["transparent"] = transparency # type: ignore[call-overload] lib.TCOD_map_compute_fov(map_cdata, pov[1], pov[0], radius, light_walls, algorithm) - return map_buffer["fov"] # type: ignore + return map_buffer["fov"] # type: ignore[no-any-return,call-overload] diff --git a/tcod/noise.py b/tcod/noise.py index 92b7636e..23d84691 100644 --- a/tcod/noise.py +++ b/tcod/noise.py @@ -31,20 +31,23 @@ [ 76, 54, 85, 144, 164], [ 63, 94, 159, 209, 203]], dtype=uint8) """ + from __future__ import annotations import enum import warnings -from typing import Any, Sequence +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Literal import numpy as np -from numpy.typing import ArrayLike, NDArray -from typing_extensions import Literal import tcod.constants import tcod.random from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from numpy.typing import ArrayLike, NDArray + class Algorithm(enum.IntEnum): """Libtcod noise algorithms. @@ -90,7 +93,7 @@ def __repr__(self) -> str: def __getattr__(name: str) -> Implementation: if name in Implementation.__members__: warnings.warn( - f"'tcod.noise.{name}' is deprecated," f" use 'tcod.noise.Implementation.{name}' instead.", + f"'tcod.noise.{name}' is deprecated, use 'tcod.noise.Implementation.{name}' instead.", FutureWarning, stacklevel=2, ) @@ -122,7 +125,7 @@ class Noise: noise_c (CData): A cffi pointer to a TCOD_noise_t object. """ - def __init__( + def __init__( # noqa: PLR0913 self, dimensions: int, algorithm: int = Algorithm.SIMPLEX, @@ -132,7 +135,8 @@ def __init__( octaves: float = 4, seed: int | tcod.random.Random | None = None, ) -> None: - if not 0 < dimensions <= 4: + """Initialize and seed the noise object.""" + if not 0 < dimensions <= 4: # noqa: PLR2004 msg = f"dimensions must be in range 0 < n <= 4, got {dimensions}" raise ValueError(msg) self._seed = seed @@ -190,7 +194,7 @@ def implementation(self) -> int: @implementation.setter def implementation(self, value: int) -> None: - if not 0 <= value < 3: + if not 0 <= value < 3: # noqa: PLR2004 msg = f"{value!r} is not a valid implementation. " raise ValueError(msg) self._tdl_noise_c.implementation = value @@ -232,10 +236,9 @@ def __getitem__(self, indexes: Any) -> NDArray[np.float32]: if not isinstance(indexes, tuple): indexes = (indexes,) if len(indexes) > self.dimensions: - raise IndexError( - "This noise generator has %i dimensions, but was indexed with %i." % (self.dimensions, len(indexes)) - ) - indexes = np.broadcast_arrays(*indexes) + msg = f"This noise generator has {self.dimensions} dimensions, but was indexed with {len(indexes)}." + raise IndexError(msg) + indexes = list(np.broadcast_arrays(*indexes)) c_input = [ffi.NULL, ffi.NULL, ffi.NULL, ffi.NULL] for i, index in enumerate(indexes): if index.dtype.type == np.object_: @@ -272,7 +275,8 @@ def __getitem__(self, indexes: Any) -> NDArray[np.float32]: ffi.from_buffer("float*", out), ) else: - raise TypeError("Unexpected %r" % self.implementation) + msg = f"Unexpected {self.implementation!r}" + raise TypeError(msg) return out @@ -336,7 +340,7 @@ def sample_ogrid(self, ogrid: Sequence[ArrayLike]) -> NDArray[np.float32]: def __getstate__(self) -> dict[str, Any]: state = self.__dict__.copy() - if self.dimensions < 4 and self.noise_c.waveletTileData == ffi.NULL: + if self.dimensions < 4 and self.noise_c.waveletTileData == ffi.NULL: # noqa: PLR2004 # Trigger a side effect of wavelet, so that copies will be synced. saved_algo = self.algorithm self.algorithm = tcod.constants.NOISE_WAVELET @@ -385,7 +389,7 @@ def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) return None - def _setstate_old(self, state: Any) -> None: + def _setstate_old(self, state: tuple[Any, ...]) -> None: self._random = state[0] self.noise_c = ffi.new("struct TCOD_Noise*") self.noise_c.ndim = state[3] @@ -407,7 +411,7 @@ def grid( scale: tuple[float, ...] | float, origin: tuple[int, ...] | None = None, indexing: Literal["ij", "xy"] = "xy", -) -> tuple[NDArray[Any], ...]: +) -> tuple[NDArray[np.number], ...]: """Generate a mesh-grid of sample points to use with noise sampling. Args: @@ -445,7 +449,7 @@ def grid( .. versionadded:: 12.2 """ - if isinstance(scale, float): + if isinstance(scale, (int, float)): scale = (scale,) * len(shape) if origin is None: origin = (0,) * len(shape) @@ -455,5 +459,7 @@ def grid( if len(shape) != len(origin): msg = "shape must have the same length as origin" raise TypeError(msg) - indexes = (np.arange(i_shape) * i_scale + i_origin for i_shape, i_scale, i_origin in zip(shape, scale, origin)) + indexes = ( + np.arange(i_shape) * i_scale + i_origin for i_shape, i_scale, i_origin in zip(shape, scale, origin, strict=True) + ) return tuple(np.meshgrid(*indexes, copy=False, sparse=True, indexing=indexing)) diff --git a/tcod/path.py b/tcod/path.py index 84d54138..92980b85 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -15,44 +15,49 @@ All path-finding functions now respect the NumPy array shape (if a NumPy array is used.) """ + from __future__ import annotations import functools import itertools import warnings -from typing import Any, Callable +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any, Final, Literal import numpy as np -from numpy.typing import ArrayLike, NDArray -from typing_extensions import Literal +from typing_extensions import Self +import tcod.map from tcod._internal import _check from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from numpy.typing import ArrayLike, DTypeLike, NDArray + -@ffi.def_extern() # type: ignore -def _pycall_path_old(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: - """Libtcodpy style callback, needs to preserve the old userData issue.""" - func, userData = ffi.from_handle(handle) - return func(x1, y1, x2, y2, userData) # type: ignore +@ffi.def_extern() # type: ignore[misc] +def _pycall_path_old(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: # noqa: ANN401 + """Libtcodpy style callback, needs to preserve the old userdata issue.""" + func, userdata = ffi.from_handle(handle) + return func(x1, y1, x2, y2, userdata) # type: ignore[no-any-return] -@ffi.def_extern() # type: ignore -def _pycall_path_simple(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: +@ffi.def_extern() # type: ignore[misc] +def _pycall_path_simple(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: # noqa: ANN401 """Does less and should run faster, just calls the handle function.""" - return ffi.from_handle(handle)(x1, y1, x2, y2) # type: ignore + return ffi.from_handle(handle)(x1, y1, x2, y2) # type: ignore[no-any-return] -@ffi.def_extern() # type: ignore -def _pycall_path_swap_src_dest(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: +@ffi.def_extern() # type: ignore[misc] +def _pycall_path_swap_src_dest(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: # noqa: ANN401 """A TDL function dest comes first to match up with a dest only call.""" - return ffi.from_handle(handle)(x2, y2, x1, y1) # type: ignore + return ffi.from_handle(handle)(x2, y2, x1, y1) # type: ignore[no-any-return] -@ffi.def_extern() # type: ignore -def _pycall_path_dest_only(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: +@ffi.def_extern() # type: ignore[misc] +def _pycall_path_dest_only(_x1: int, _y1: int, x2: int, y2: int, handle: Any) -> float: # noqa: ANN401 """A TDL function which samples the dest coordinate only.""" - return ffi.from_handle(handle)(x2, y2) # type: ignore + return ffi.from_handle(handle)(x2, y2) # type: ignore[no-any-return] def _get_path_cost_func( @@ -60,8 +65,8 @@ def _get_path_cost_func( ) -> Callable[[int, int, int, int, Any], float]: """Return a properly cast PathCostArray callback.""" if not ffi: - return lambda x1, y1, x2, y2, _: 0 - return ffi.cast("TCOD_path_func_t", ffi.addressof(lib, name)) # type: ignore + return lambda _x1, _y1, _x2, _y2, _: 0 + return ffi.cast("TCOD_path_func_t", ffi.addressof(lib, name)) # type: ignore[no-any-return] class _EdgeCostFunc: @@ -74,7 +79,7 @@ class _EdgeCostFunc: _CALLBACK_P = lib._pycall_path_old - def __init__(self, userdata: Any, shape: tuple[int, int]) -> None: + def __init__(self, userdata: object, shape: tuple[int, int]) -> None: self._userdata = userdata self.shape = shape @@ -83,11 +88,7 @@ def get_tcod_path_ffi(self) -> tuple[Any, Any, tuple[int, int]]: return self._CALLBACK_P, ffi.new_handle(self._userdata), self.shape def __repr__(self) -> str: - return "{}({!r}, shape={!r})".format( - self.__class__.__name__, - self._userdata, - self.shape, - ) + return f"{self.__class__.__name__}({self._userdata!r}, shape={self.shape!r})" class EdgeCostCallback(_EdgeCostFunc): @@ -114,14 +115,14 @@ def __init__( super().__init__(callback, shape) -class NodeCostArray(np.ndarray): # type: ignore +class NodeCostArray(np.ndarray): # type: ignore[type-arg] """Calculate cost from a numpy array of nodes. `array` is a NumPy array holding the path-cost of each node. A cost of 0 means the node is blocking. """ - _C_ARRAY_CALLBACKS = { + _C_ARRAY_CALLBACKS: Final = { np.float32: ("float*", _get_path_cost_func("PathCostArrayFloat32")), np.bool_: ("int8_t*", _get_path_cost_func("PathCostArrayInt8")), np.int8: ("int8_t*", _get_path_cost_func("PathCostArrayInt8")), @@ -132,16 +133,15 @@ class NodeCostArray(np.ndarray): # type: ignore np.uint32: ("uint32_t*", _get_path_cost_func("PathCostArrayUInt32")), } - def __new__(cls, array: ArrayLike) -> NodeCostArray: + def __new__(cls, array: ArrayLike) -> Self: """Validate a numpy array and setup a C callback.""" - self = np.asarray(array).view(cls) - return self + return np.asarray(array).view(cls) def __repr__(self) -> str: return f"{self.__class__.__name__}({repr(self.view(np.ndarray))!r})" def get_tcod_path_ffi(self) -> tuple[Any, Any, tuple[int, int]]: - if len(self.shape) != 2: + if len(self.shape) != 2: # noqa: PLR2004 msg = f"Array must have a 2d shape, shape is {self.shape!r}" raise ValueError(msg) if self.dtype.type not in self._C_ARRAY_CALLBACKS: @@ -159,13 +159,13 @@ def get_tcod_path_ffi(self) -> tuple[Any, Any, tuple[int, int]]: class _PathFinder: """A class sharing methods used by AStar and Dijkstra.""" - def __init__(self, cost: Any, diagonal: float = 1.41) -> None: + def __init__(self, cost: tcod.map.Map | ArrayLike | _EdgeCostFunc, diagonal: float = 1.41) -> None: self.cost = cost self.diagonal = diagonal self._path_c: Any = None self._callback = self._userdata = None - if hasattr(self.cost, "map_c"): + if isinstance(self.cost, tcod.map.Map): self.shape = self.cost.width, self.cost.height self._path_c = ffi.gc( self._path_new_using_map(self.cost.map_c, diagonal), @@ -173,9 +173,9 @@ def __init__(self, cost: Any, diagonal: float = 1.41) -> None: ) return - if not hasattr(self.cost, "get_tcod_path_ffi"): + if not isinstance(self.cost, _EdgeCostFunc): assert not callable(self.cost), ( - "Any callback alone is missing shape information. " "Wrap your callback in tcod.path.EdgeCostCallback" + "Any callback alone is missing shape information. Wrap your callback in tcod.path.EdgeCostCallback" ) self.cost = NodeCostArray(self.cost) @@ -198,7 +198,7 @@ def __init__(self, cost: Any, diagonal: float = 1.41) -> None: def __repr__(self) -> str: return f"{self.__class__.__name__}(cost={self.cost!r}, diagonal={self.diagonal!r})" - def __getstate__(self) -> Any: + def __getstate__(self) -> dict[str, Any]: state = self.__dict__.copy() del state["_path_c"] del state["shape"] @@ -206,9 +206,9 @@ def __getstate__(self) -> Any: del state["_userdata"] return state - def __setstate__(self, state: Any) -> None: + def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) - self.__init__(self.cost, self.diagonal) # type: ignore + _PathFinder.__init__(self, self.cost, self.diagonal) _path_new_using_map = lib.TCOD_path_new_using_map _path_new_using_function = lib.TCOD_path_new_using_function @@ -241,7 +241,7 @@ def get_path(self, start_x: int, start_y: int, goal_x: int, goal_y: int) -> list path = [] x = ffi.new("int[2]") y = x + 1 - while lib.TCOD_path_walk(self._path_c, x, y, False): + while lib.TCOD_path_walk(self._path_c, x, y, False): # noqa: FBT003 path.append((x[0], y[0])) return path @@ -290,7 +290,7 @@ def get_path(self, x: int, y: int) -> list[tuple[int, int]]: def maxarray( shape: tuple[int, ...], - dtype: Any = np.int32, + dtype: DTypeLike = np.int32, order: Literal["C", "F"] = "C", ) -> NDArray[Any]: """Return a new array filled with the maximum finite value for `dtype`. @@ -325,7 +325,7 @@ def _export_dict(array: NDArray[Any]) -> dict[str, Any]: } -def _export(array: NDArray[Any]) -> Any: +def _export(array: NDArray[np.integer]) -> Any: # noqa: ANN401 """Convert a NumPy array into a cffi object.""" return ffi.new("struct NArray*", _export_dict(array)) @@ -333,8 +333,9 @@ def _export(array: NDArray[Any]) -> Any: def _compile_cost_edges(edge_map: ArrayLike) -> tuple[NDArray[np.intc], int]: """Return an edge_cost array using an integer map.""" edge_map = np.array(edge_map, copy=True) - if edge_map.ndim != 2: - raise ValueError("edge_map must be 2 dimensional. (Got %i)" % edge_map.ndim) + if edge_map.ndim != 2: # noqa: PLR2004 + msg = f"edge_map must be 2 dimensional. (Got {edge_map.ndim})" + raise ValueError(msg) edge_center = edge_map.shape[0] // 2, edge_map.shape[1] // 2 edge_map[edge_center] = 0 edge_map[edge_map < 0] = 0 @@ -348,15 +349,15 @@ def _compile_cost_edges(edge_map: ArrayLike) -> tuple[NDArray[np.intc], int]: return c_edges, len(edge_array) -def dijkstra2d( +def dijkstra2d( # noqa: PLR0913 distance: ArrayLike, cost: ArrayLike, cardinal: int | None = None, diagonal: int | None = None, *, - edge_map: Any = None, - out: np.ndarray | None = ..., # type: ignore -) -> NDArray[Any]: + edge_map: ArrayLike | None = None, + out: NDArray[np.integer] | None = ..., # type: ignore[assignment, unused-ignore] +) -> NDArray[np.integer]: """Return the computed distance of all nodes on a 2D Dijkstra grid. `distance` is an input array of node distances. Is this often an @@ -527,8 +528,8 @@ def hillclimb2d( cardinal: bool | None = None, diagonal: bool | None = None, *, - edge_map: Any = None, -) -> NDArray[Any]: + edge_map: ArrayLike | None = None, +) -> NDArray[np.intc]: """Return a path on a grid from `start` to the lowest point. `distance` should be a fully computed distance array. This kind of array @@ -581,7 +582,7 @@ def hillclimb2d( return path -def _world_array(shape: tuple[int, ...], dtype: Any = np.int32) -> NDArray[Any]: +def _world_array(shape: tuple[int, ...], dtype: DTypeLike = np.int32) -> NDArray[Any]: """Return an array where ``ij == arr[ij]``.""" return np.ascontiguousarray( np.transpose( @@ -595,7 +596,7 @@ def _world_array(shape: tuple[int, ...], dtype: Any = np.int32) -> NDArray[Any]: ) -def _as_hashable(obj: np.ndarray[Any, Any] | None) -> Any | None: +def _as_hashable(obj: np.ndarray[Any, Any] | None) -> object | None: """Return NumPy arrays as a more hashable form.""" if obj is None: return obj @@ -665,12 +666,13 @@ class CustomGraph: """ def __init__(self, shape: tuple[int, ...], *, order: str = "C") -> None: + """Initialize the custom graph.""" self._shape = self._shape_c = tuple(shape) self._ndim = len(self._shape) self._order = order if self._order == "F": self._shape_c = self._shape_c[::-1] - if not 0 < self._ndim <= 4: + if not 0 < self._ndim <= 4: # noqa: PLR2004 msg = "Graph dimensions must be 1 <= n <= 4." raise TypeError(msg) self._graph: dict[tuple[Any, ...], dict[str, Any]] = {} @@ -751,7 +753,8 @@ def add_edge( edge_dir = tuple(edge_dir) cost = np.asarray(cost) if len(edge_dir) != self._ndim: - raise TypeError("edge_dir must have exactly %i items, got %r" % (self._ndim, edge_dir)) + msg = f"edge_dir must have exactly {self._ndim} items, got {edge_dir!r}" + raise TypeError(msg) if edge_cost <= 0: msg = f"edge_cost must be greater than zero, got {edge_cost!r}" raise ValueError(msg) @@ -885,7 +888,8 @@ def add_edges( if edge_map.ndim < self._ndim: edge_map = np.asarray(edge_map[(np.newaxis,) * (self._ndim - edge_map.ndim)]) if edge_map.ndim != self._ndim: - raise TypeError("edge_map must must match graph dimensions (%i). (Got %i)" % (self.ndim, edge_map.ndim)) + msg = f"edge_map must must match graph dimensions ({self.ndim}). (Got {edge_map.ndim})" + raise TypeError(msg) if self._order == "F": # edge_map needs to be converted into C. # The other parameters are converted by the add_edge method. @@ -897,9 +901,13 @@ def add_edges( edge_costs = edge_map[edge_nz] edge_array = np.transpose(edge_nz) edge_array -= edge_center - for edge, edge_cost in zip(edge_array, edge_costs): - edge = tuple(edge) - self.add_edge(edge, edge_cost, cost=cost, condition=condition) + for edge, edge_cost in zip(edge_array, edge_costs, strict=True): + self.add_edge( + tuple(edge.tolist()), # type: ignore[arg-type] + edge_cost, + cost=cost, + condition=condition, + ) def set_heuristic(self, *, cardinal: int = 0, diagonal: int = 0, z: int = 0, w: int = 0) -> None: """Set a pathfinder heuristic so that pathfinding can done with A*. @@ -961,7 +969,7 @@ def set_heuristic(self, *, cardinal: int = 0, diagonal: int = 0, z: int = 0, w: raise ValueError(msg) self._heuristic = (cardinal, diagonal, z, w) - def _compile_rules(self) -> Any: + def _compile_rules(self) -> Any: # noqa: ANN401 """Compile this graph into a C struct array.""" if not self._edge_rules_p: self._edge_rules_keep_alive = [] @@ -1032,8 +1040,9 @@ class SimpleGraph: """ def __init__(self, *, cost: ArrayLike, cardinal: int, diagonal: int, greed: int = 1) -> None: + """Initialize the graph.""" cost = np.asarray(cost) - if cost.ndim != 2: + if cost.ndim != 2: # noqa: PLR2004 msg = f"The cost array must e 2 dimensional, array of shape {cost.shape!r} given." raise TypeError(msg) if greed <= 0: @@ -1091,6 +1100,7 @@ class Pathfinder: """ def __init__(self, graph: CustomGraph | SimpleGraph) -> None: + """Initialize the pathfinder from a graph.""" self._graph = graph self._order = graph._order self._frontier_p = ffi.gc(lib.TCOD_frontier_new(self._graph._ndim), lib.TCOD_frontier_delete) @@ -1145,7 +1155,7 @@ def traversal(self) -> NDArray[Any]: >>> i, j = (3, 3) # Starting index. >>> path = [(i, j)] # List of nodes from the start to the root. >>> while not (pf.traversal[i, j] == (i, j)).all(): - ... i, j = pf.traversal[i, j] + ... i, j = pf.traversal[i, j].tolist() ... path.append((i, j)) >>> path # Slower. [(3, 3), (2, 2), (1, 1), (0, 0)] @@ -1186,7 +1196,8 @@ def add_root(self, index: tuple[int, ...], value: int = 0) -> None: if self._order == "F": # Convert to ij indexing order. index = index[::-1] if len(index) != self._distance.ndim: - raise TypeError("Index must be %i items, got %r" % (self._distance.ndim, index)) + msg = f"Index must be {self._distance.ndim} items, got {index!r}" + raise TypeError(msg) self._distance[index] = value self._update_heuristic(None) lib.TCOD_frontier_push(self._frontier_p, index, value, value) @@ -1273,17 +1284,18 @@ def resolve(self, goal: tuple[int, ...] | None = None) -> None: if goal is not None: goal = tuple(goal) # Check for bad input. if len(goal) != self._distance.ndim: - raise TypeError("Goal must be %i items, got %r" % (self._distance.ndim, goal)) + msg = f"Goal must be {self._distance.ndim} items, got {goal!r}" + raise TypeError(msg) if self._order == "F": # Goal is now ij indexed for the rest of this function. goal = goal[::-1] - if self._distance[goal] != np.iinfo(self._distance.dtype).max: + if self._distance[goal] != np.iinfo(self._distance.dtype).max: # noqa: SIM102 if not lib.frontier_has_index(self._frontier_p, goal): return self._update_heuristic(goal) self._graph._resolve(self) - def path_from(self, index: tuple[int, ...]) -> NDArray[Any]: + def path_from(self, index: tuple[int, ...]) -> NDArray[np.intc]: """Return the shortest path from `index` to the nearest root. The returned array is of shape `(length, ndim)` where `length` is the @@ -1320,7 +1332,8 @@ def path_from(self, index: tuple[int, ...]) -> NDArray[Any]: """ index = tuple(index) # Check for bad input. if len(index) != self._graph._ndim: - raise TypeError("Index must be %i items, got %r" % (self._distance.ndim, index)) + msg = f"Index must be {self._distance.ndim} items, got {index!r}" + raise TypeError(msg) self.resolve(index) if self._order == "F": # Convert to ij indexing order. index = index[::-1] @@ -1336,7 +1349,7 @@ def path_from(self, index: tuple[int, ...]) -> NDArray[Any]: ) return path[:, ::-1] if self._order == "F" else path - def path_to(self, index: tuple[int, ...]) -> NDArray[Any]: + def path_to(self, index: tuple[int, ...]) -> NDArray[np.intc]: """Return the shortest path from the nearest root to `index`. See :any:`path_from`. @@ -1363,3 +1376,146 @@ def path_to(self, index: tuple[int, ...]) -> NDArray[Any]: [] """ return self.path_from(index)[::-1] + + +def path2d( # noqa: C901, PLR0912, PLR0913 + cost: ArrayLike, + *, + start_points: Sequence[tuple[int, int]], + end_points: Sequence[tuple[int, int]], + cardinal: int, + diagonal: int | None = None, + check_bounds: bool = True, +) -> NDArray[np.intc]: + """Return a path between `start_points` and `end_points`. + + If `start_points` or `end_points` has only one item then this is equivalent to A*. + Otherwise it is equivalent to Dijkstra. + + If multiple `start_points` or `end_points` are given then the single shortest path between them is returned. + + Points placed on nodes with a cost of 0 are treated as always reachable from adjacent nodes. + + Args: + cost: A 2D array of integers with the cost of each node. + start_points: A sequence of one or more starting points indexing `cost`. + end_points: A sequence of one or more ending points indexing `cost`. + cardinal: The relative cost to move a cardinal direction. + diagonal: The relative cost to move a diagonal direction. + `None` or `0` will disable diagonal movement. + check_bounds: If `False` then out-of-bounds points are silently ignored. + If `True` (default) then out-of-bounds points raise :any:`IndexError`. + + Returns: + A `(length, 2)` array of indexes of the path including the start and end points. + If there is no path then an array with zero items will be returned. + + Example:: + + # Note: coordinates in this example are (i, j), or (y, x) + >>> cost = np.array([ + ... [1, 0, 1, 1, 1, 0, 1], + ... [1, 0, 1, 1, 1, 0, 1], + ... [1, 0, 1, 0, 1, 0, 1], + ... [1, 1, 1, 1, 1, 0, 1], + ... ]) + + # Endpoints are reachable even when endpoints are on blocked nodes + >>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(2, 3)], cardinal=70, diagonal=99) + array([[0, 0], + [1, 0], + [2, 0], + [3, 1], + [2, 2], + [2, 3]], dtype=int...) + + # Unreachable endpoints return a zero length array + >>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(3, 6)], cardinal=70, diagonal=99) + array([], shape=(0, 2), dtype=int...) + >>> tcod.path.path2d(cost, start_points=[(0, 0), (3, 0)], end_points=[(0, 6), (3, 6)], cardinal=70, diagonal=99) + array([], shape=(0, 2), dtype=int...) + >>> tcod.path.path2d(cost, start_points=[], end_points=[], cardinal=70, diagonal=99) + array([], shape=(0, 2), dtype=int...) + + # Overlapping endpoints return a single step + >>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(0, 0)], cardinal=70, diagonal=99) + array([[0, 0]], dtype=int32) + + # Multiple endpoints return the shortest path + >>> tcod.path.path2d( + ... cost, start_points=[(0, 0)], end_points=[(1, 3), (3, 3), (2, 2), (2, 4)], cardinal=70, diagonal=99) + array([[0, 0], + [1, 0], + [2, 0], + [3, 1], + [2, 2]], dtype=int...) + >>> tcod.path.path2d( + ... cost, start_points=[(0, 0), (0, 2)], end_points=[(1, 3), (3, 3), (2, 2), (2, 4)], cardinal=70, diagonal=99) + array([[0, 2], + [1, 3]], dtype=int...) + >>> tcod.path.path2d(cost, start_points=[(0, 0), (0, 2)], end_points=[(3, 2)], cardinal=1) + array([[0, 2], + [1, 2], + [2, 2], + [3, 2]], dtype=int...) + + # Checking for out-of-bounds points may be toggled + >>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(-1, -1), (3, 1)], cardinal=1) + Traceback (most recent call last): + ... + IndexError: End point (-1, -1) is out-of-bounds of cost shape (4, 7) + >>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(-1, -1), (3, 1)], cardinal=1, check_bounds=False) + array([[0, 0], + [1, 0], + [2, 0], + [3, 0], + [3, 1]], dtype=int...) + + .. versionadded:: 18.1 + """ + cost = np.copy(cost) # Copy array to later modify nodes to be always reachable + + # Check bounds of endpoints + if check_bounds: + for points, name in [(start_points, "start"), (end_points, "end")]: + for i, j in points: + if not (0 <= i < cost.shape[0] and 0 <= j < cost.shape[1]): + msg = f"{name.capitalize()} point {(i, j)!r} is out-of-bounds of cost shape {cost.shape!r}" + raise IndexError(msg) + else: + start_points = [(i, j) for i, j in start_points if 0 <= i < cost.shape[0] and 0 <= j < cost.shape[1]] + end_points = [(i, j) for i, j in end_points if 0 <= i < cost.shape[0] and 0 <= j < cost.shape[1]] + + if not start_points or not end_points: + return np.zeros((0, 2), dtype=np.intc) # Missing endpoints + + # Check if endpoints can be manipulated to use A* for a one-to-many computation + reversed_path = False + if len(end_points) == 1 and len(start_points) > 1: + # Swap endpoints to ensure single start point as the A* goal + reversed_path = True + start_points, end_points = end_points, start_points + + for ij in start_points: + cost[ij] = 1 # Enforce reachability of endpoint + + graph = SimpleGraph(cost=cost, cardinal=cardinal, diagonal=diagonal or 0) + pf = Pathfinder(graph) + for ij in end_points: + pf.add_root(ij) + + if len(start_points) == 1: # Compute A* from possibly multiple roots to one goal + out = pf.path_from(start_points[0]) + if pf.distance[start_points[0]] == np.iinfo(pf.distance.dtype).max: + return np.zeros((0, 2), dtype=np.intc) # Unreachable endpoint + if reversed_path: + out = out[::-1] + return out + + # Crude Dijkstra implementation until issues with Pathfinder are fixed + pf.resolve(None) + best_distance, best_ij = min((pf.distance[ij], ij) for ij in start_points) + if best_distance == np.iinfo(pf.distance.dtype).max: + return np.zeros((0, 2), dtype=np.intc) # All endpoints unreachable + + return hillclimb2d(pf.distance, best_ij, cardinal=bool(cardinal), diagonal=bool(diagonal)) diff --git a/tcod/random.py b/tcod/random.py index b574cab8..6d332b86 100644 --- a/tcod/random.py +++ b/tcod/random.py @@ -6,12 +6,16 @@ However, you will need to use these generators to get deterministic results from the :any:`Noise` and :any:`BSP` classes. """ + from __future__ import annotations import os import random import warnings -from typing import Any, Hashable +from collections.abc import Hashable +from typing import Any + +from typing_extensions import deprecated import tcod.constants from tcod.cffi import ffi, lib @@ -80,7 +84,7 @@ def __init__( ) @classmethod - def _new_from_cdata(cls, cdata: Any) -> Random: + def _new_from_cdata(cls, cdata: Any) -> Random: # noqa: ANN401 """Return a new instance encapsulating this cdata.""" self: Random = object.__new__(cls) self.random_c = cdata @@ -110,7 +114,7 @@ def uniform(self, low: float, high: float) -> float: """ return float(lib.TCOD_random_get_double(self.random_c, low, high)) - def guass(self, mu: float, sigma: float) -> float: + def gauss(self, mu: float, sigma: float) -> float: """Return a random number using Gaussian distribution. Args: @@ -119,10 +123,17 @@ def guass(self, mu: float, sigma: float) -> float: Returns: float: A random float. + + .. versionchanged:: 16.2 + Renamed from `guass` to `gauss`. """ return float(lib.TCOD_random_get_gaussian_double(self.random_c, mu, sigma)) - def inverse_guass(self, mu: float, sigma: float) -> float: + @deprecated("This is a typo, rename this to 'gauss'", category=FutureWarning) + def guass(self, mu: float, sigma: float) -> float: # noqa: D102 + return self.gauss(mu, sigma) + + def inverse_gauss(self, mu: float, sigma: float) -> float: """Return a random Gaussian number using the Box-Muller transform. Args: @@ -131,10 +142,17 @@ def inverse_guass(self, mu: float, sigma: float) -> float: Returns: float: A random float. + + .. versionchanged:: 16.2 + Renamed from `inverse_guass` to `inverse_gauss`. """ return float(lib.TCOD_random_get_gaussian_double_inv(self.random_c, mu, sigma)) - def __getstate__(self) -> Any: + @deprecated("This is a typo, rename this to 'inverse_gauss'", category=FutureWarning) + def inverse_guass(self, mu: float, sigma: float) -> float: # noqa: D102 + return self.inverse_gauss(mu, sigma) + + def __getstate__(self) -> dict[str, Any]: """Pack the self.random_c attribute into a portable state.""" state = self.__dict__.copy() state["random_c"] = { @@ -150,7 +168,7 @@ def __getstate__(self) -> Any: } return state - def __setstate__(self, state: Any) -> None: + def __setstate__(self, state: dict[str, Any]) -> None: """Create a new cdata object with the stored parameters.""" if "algo" in state["random_c"]: # Handle old/deprecated format. Covert to libtcod's new union type. diff --git a/tcod/render.py b/tcod/render.py index d01d8129..09d3a137 100644 --- a/tcod/render.py +++ b/tcod/render.py @@ -29,9 +29,7 @@ from __future__ import annotations -from typing import Any - -from typing_extensions import Final +from typing import Any, Final import tcod.console import tcod.sdl.render @@ -44,6 +42,7 @@ class SDLTilesetAtlas: """Prepares a tileset for rendering using SDL.""" def __init__(self, renderer: tcod.sdl.render.Renderer, tileset: tcod.tileset.Tileset) -> None: + """Initialize the tileset atlas.""" self._renderer = renderer self.tileset: Final[tcod.tileset.Tileset] = tileset """The tileset used to create this SDLTilesetAtlas.""" @@ -52,7 +51,7 @@ def __init__(self, renderer: tcod.sdl.render.Renderer, tileset: tcod.tileset.Til ) @classmethod - def _from_ref(cls, renderer_p: Any, atlas_p: Any) -> SDLTilesetAtlas: + def _from_ref(cls, renderer_p: Any, atlas_p: Any) -> SDLTilesetAtlas: # noqa: ANN401 self = object.__new__(cls) # Ignore Final reassignment type errors since this is an alternative constructor. # This could be a sign that the current constructor was badly implemented. @@ -66,6 +65,7 @@ class SDLConsoleRender: """Holds an internal cache console and texture which are used to optimized console rendering.""" def __init__(self, atlas: SDLTilesetAtlas) -> None: + """Initialize the console renderer.""" self.atlas: Final[SDLTilesetAtlas] = atlas """The SDLTilesetAtlas used to create this SDLConsoleRender. diff --git a/tcod/sdl/__init__.py b/tcod/sdl/__init__.py index 8133948b..22e82845 100644 --- a/tcod/sdl/__init__.py +++ b/tcod/sdl/__init__.py @@ -1,4 +1,5 @@ """tcod.sdl package.""" + from pkgutil import extend_path __path__ = extend_path(__path__, __name__) diff --git a/tcod/sdl/_internal.py b/tcod/sdl/_internal.py index 00dc868c..23b41632 100644 --- a/tcod/sdl/_internal.py +++ b/tcod/sdl/_internal.py @@ -1,14 +1,18 @@ """tcod.sdl private functions.""" + from __future__ import annotations import logging import sys as _sys +from collections.abc import Callable from dataclasses import dataclass -from types import TracebackType -from typing import Any, Callable, TypeVar +from typing import TYPE_CHECKING, Any, NoReturn, TypeVar from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from types import TracebackType + T = TypeVar("T") logger = logging.getLogger("tcod.sdl") @@ -57,13 +61,11 @@ def __exit__( ) -> bool: if exc_type is None: return False - if _sys.version_info < (3, 8): - return False - _sys.unraisablehook(_UnraisableHookArgs(exc_type, value, traceback, None, self.obj)) # type: ignore[arg-type] + _sys.unraisablehook(_UnraisableHookArgs(exc_type, value, traceback, None, self.obj)) return True -@ffi.def_extern() # type: ignore +@ffi.def_extern() # type: ignore[misc] def _sdl_log_output_function(_userdata: None, category: int, priority: int, message_p: Any) -> None: # noqa: ANN401 """Pass logs sent by SDL to Python's logging system.""" message = str(ffi.string(message_p), encoding="utf-8") @@ -82,7 +84,7 @@ def _check(result: int) -> int: return result -def _check_p(result: Any) -> Any: +def _check_p(result: Any) -> Any: # noqa: ANN401 """Check if an SDL function returned NULL, and raise an exception if it did.""" if not result: raise RuntimeError(_get_error()) @@ -113,11 +115,11 @@ def _required_version(required: tuple[int, int, int]) -> Callable[[T], T]: if required <= _compiled_version(): return lambda x: x - def replacement(*_args: Any, **_kwargs: Any) -> Any: + def replacement(*_args: object, **_kwargs: object) -> NoReturn: msg = f"This feature requires SDL version {required}, but tcod was compiled with version {_compiled_version()}" raise RuntimeError(msg) - return lambda x: replacement # type: ignore[return-value] + return lambda _: replacement # type: ignore[return-value] lib.SDL_LogSetOutputFunction(lib._sdl_log_output_function, ffi.NULL) diff --git a/tcod/sdl/audio.py b/tcod/sdl/audio.py index 8b9addab..711d3fc4 100644 --- a/tcod/sdl/audio.py +++ b/tcod/sdl/audio.py @@ -41,23 +41,28 @@ .. versionadded:: 13.5 """ + from __future__ import annotations import enum import sys import threading import time -from types import TracebackType -from typing import Any, Callable, Hashable, Iterator +from collections.abc import Callable, Hashable, Iterator +from typing import TYPE_CHECKING, Any, Final, Literal import numpy as np -from numpy.typing import ArrayLike, DTypeLike, NDArray -from typing_extensions import Final, Literal, Self +from typing_extensions import Self import tcod.sdl.sys from tcod.cffi import ffi, lib from tcod.sdl._internal import _check, _get_error, _ProtectedContext +if TYPE_CHECKING: + from types import TracebackType + + from numpy.typing import ArrayLike, DTypeLike, NDArray + def _get_format(format: DTypeLike) -> int: """Return a SDL_AudioFormat bit-field from a NumPy dtype.""" @@ -136,7 +141,7 @@ def convert_audio( in_array: NDArray[Any] = np.asarray(in_sound) if len(in_array.shape) == 1: in_array = in_array[:, np.newaxis] - if len(in_array.shape) != 2: + if len(in_array.shape) != 2: # noqa: PLR2004 msg = f"Expected a 1 or 2 ndim input, got {in_array.shape} instead." raise TypeError(msg) cvt = ffi.new("SDL_AudioCVT*") @@ -191,7 +196,7 @@ def __init__( self, device_id: int, capture: bool, - spec: Any, # SDL_AudioSpec* + spec: Any, # SDL_AudioSpec* # noqa: ANN401 ) -> None: assert device_id >= 0 assert ffi.typeof(spec) is ffi.typeof("SDL_AudioSpec*") @@ -284,7 +289,7 @@ def _convert_array(self, samples_: ArrayLike) -> NDArray[Any]: if isinstance(samples_, np.ndarray): samples_ = self._verify_array_format(samples_) samples: NDArray[Any] = np.asarray(samples_, dtype=self.format) - if len(samples.shape) < 2: + if len(samples.shape) < 2: # noqa: PLR2004 samples = samples[:, np.newaxis] return np.ascontiguousarray(np.broadcast_to(samples, (samples.shape[0], self.channels)), dtype=self.format) @@ -560,7 +565,7 @@ class _AudioCallbackUserdata: device: AudioDevice -@ffi.def_extern() # type: ignore +@ffi.def_extern() # type: ignore[misc] def _sdl_audio_callback(userdata: Any, stream: Any, length: int) -> None: # noqa: ANN401 """Handle audio device callbacks.""" data: _AudioCallbackUserdata = ffi.from_handle(userdata) @@ -605,7 +610,7 @@ class AllowedChanges(enum.IntFlag): """""" -def open( +def open( # noqa: PLR0913 name: str | None = None, capture: bool = False, *, diff --git a/tcod/sdl/joystick.py b/tcod/sdl/joystick.py index 8ab8637f..9015605c 100644 --- a/tcod/sdl/joystick.py +++ b/tcod/sdl/joystick.py @@ -2,14 +2,13 @@ .. versionadded:: 13.8 """ + from __future__ import annotations import enum -from typing import Any, ClassVar +from typing import Any, ClassVar, Final, Literal from weakref import WeakValueDictionary -from typing_extensions import Final, Literal - import tcod.sdl.sys from tcod.cffi import ffi, lib from tcod.sdl._internal import _check, _check_p @@ -126,7 +125,7 @@ class Joystick: _by_instance_id: ClassVar[WeakValueDictionary[int, Joystick]] = WeakValueDictionary() """Currently opened joysticks.""" - def __init__(self, sdl_joystick_p: Any) -> None: + def __init__(self, sdl_joystick_p: Any) -> None: # noqa: ANN401 self.sdl_joystick_p: Final = sdl_joystick_p """The CFFI pointer to an SDL_Joystick struct.""" self.axes: Final[int] = _check(lib.SDL_JoystickNumAxes(self.sdl_joystick_p)) @@ -200,7 +199,7 @@ class GameController: _by_instance_id: ClassVar[WeakValueDictionary[int, GameController]] = WeakValueDictionary() """Currently opened controllers.""" - def __init__(self, sdl_controller_p: Any) -> None: + def __init__(self, sdl_controller_p: Any) -> None: # noqa: ANN401 self.sdl_controller_p: Final = sdl_controller_p self.joystick: Final = Joystick(lib.SDL_GameControllerGetJoystick(self.sdl_controller_p)) """The :any:`Joystick` associated with this controller.""" @@ -363,7 +362,10 @@ def _touchpad(self) -> bool: def init() -> None: """Initialize SDL's joystick and game controller subsystems.""" - tcod.sdl.sys.init(tcod.sdl.sys.Subsystem.JOYSTICK | tcod.sdl.sys.Subsystem.GAMECONTROLLER) + CONTROLLER_SYSTEMS = tcod.sdl.sys.Subsystem.JOYSTICK | tcod.sdl.sys.Subsystem.GAMECONTROLLER + if tcod.sdl.sys.Subsystem(lib.SDL_WasInit(CONTROLLER_SYSTEMS)) == CONTROLLER_SYSTEMS: + return # Already initialized + tcod.sdl.sys.init(CONTROLLER_SYSTEMS) def _get_number() -> int: diff --git a/tcod/sdl/mouse.py b/tcod/sdl/mouse.py index 6a93955a..de481427 100644 --- a/tcod/sdl/mouse.py +++ b/tcod/sdl/mouse.py @@ -6,24 +6,27 @@ .. versionadded:: 13.5 """ + from __future__ import annotations import enum -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np -from numpy.typing import ArrayLike, NDArray import tcod.event import tcod.sdl.video from tcod.cffi import ffi, lib from tcod.sdl._internal import _check, _check_p +if TYPE_CHECKING: + from numpy.typing import ArrayLike, NDArray + class Cursor: """A cursor icon for use with :any:`set_cursor`.""" - def __init__(self, sdl_cursor_p: Any) -> None: + def __init__(self, sdl_cursor_p: Any) -> None: # noqa: ANN401 if ffi.typeof(sdl_cursor_p) is not ffi.typeof("struct SDL_Cursor*"): msg = f"Expected a {ffi.typeof('struct SDL_Cursor*')} type (was {ffi.typeof(sdl_cursor_p)})." raise TypeError(msg) @@ -32,7 +35,7 @@ def __init__(self, sdl_cursor_p: Any) -> None: raise TypeError(msg) self.p = sdl_cursor_p - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: return bool(self.p == getattr(other, "p", None)) @classmethod @@ -82,7 +85,7 @@ def new_cursor(data: NDArray[np.bool_], mask: NDArray[np.bool_], hot_xy: tuple[i :any:`set_cursor` https://wiki.libsdl.org/SDL_CreateCursor """ - if len(data.shape) != 2: + if len(data.shape) != 2: # noqa: PLR2004 msg = "Data and mask arrays must be 2D." raise TypeError(msg) if data.shape != mask.shape: diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index b5489f18..83606a03 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -2,19 +2,22 @@ .. versionadded:: 13.4 """ + from __future__ import annotations import enum -from typing import Any +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Final, Literal import numpy as np -from numpy.typing import NDArray -from typing_extensions import Final, Literal import tcod.sdl.video from tcod.cffi import ffi, lib from tcod.sdl._internal import _check, _check_p, _required_version +if TYPE_CHECKING: + from numpy.typing import NDArray + class TextureAccess(enum.IntEnum): """Determines how a texture is expected to be used.""" @@ -115,7 +118,7 @@ class BlendMode(enum.IntEnum): """""" -def compose_blend_mode( +def compose_blend_mode( # noqa: PLR0913 source_color_factor: BlendFactor, dest_color_factor: BlendFactor, color_operation: BlendOperation, @@ -239,7 +242,7 @@ def __init__(self, renderer: Renderer) -> None: def __enter__(self) -> None: pass - def __exit__(self, *_: Any) -> None: + def __exit__(self, *_: object) -> None: _check(lib.SDL_SetRenderTarget(self.renderer.p, self.old_texture_p)) @@ -262,7 +265,7 @@ def __eq__(self, other: object) -> bool: return bool(self.p == other.p) return NotImplemented - def copy( + def copy( # noqa: PLR0913 self, texture: Texture, source: tuple[float, float, float, float] | None = None, @@ -334,11 +337,11 @@ def upload_texture(self, pixels: NDArray[Any], *, format: int | None = None, acc See :any:`TextureAccess` for more options. """ if format is None: - assert len(pixels.shape) == 3 + assert len(pixels.shape) == 3 # noqa: PLR2004 assert pixels.dtype == np.uint8 - if pixels.shape[2] == 4: + if pixels.shape[2] == 4: # noqa: PLR2004 format = int(lib.SDL_PIXELFORMAT_RGBA32) - elif pixels.shape[2] == 3: + elif pixels.shape[2] == 3: # noqa: PLR2004 format = int(lib.SDL_PIXELFORMAT_RGB24) else: msg = f"Can't determine the format required for an array of shape {pixels.shape}." @@ -360,7 +363,7 @@ def draw_color(self) -> tuple[int, int, int, int]: """ rgba = ffi.new("uint8_t[4]") _check(lib.SDL_GetRenderDrawColor(self.p, rgba, rgba + 1, rgba + 2, rgba + 3)) - return tuple(rgba) # type: ignore[return-value] + return tuple(rgba) @draw_color.setter def draw_color(self, rgba: tuple[int, int, int, int]) -> None: @@ -580,106 +583,127 @@ def draw_point(self, xy: tuple[float, float]) -> None: .. versionadded:: 13.5 """ - _check(lib.SDL_RenderDrawPointF(self.p, (xy,))) + x, y = xy + _check(lib.SDL_RenderDrawPointF(self.p, x, y)) def draw_line(self, start: tuple[float, float], end: tuple[float, float]) -> None: """Draw a single line. .. versionadded:: 13.5 """ - _check(lib.SDL_RenderDrawLineF(self.p, *start, *end)) + x1, y1 = start + x2, y2 = end + _check(lib.SDL_RenderDrawLineF(self.p, x1, y1, x2, y2)) - def fill_rects(self, rects: NDArray[np.intc | np.float32]) -> None: + @staticmethod + def _convert_array( + array: NDArray[np.number] | Sequence[Sequence[float]], item_length: int + ) -> NDArray[np.intc] | NDArray[np.float32]: + """Convert ndarray for a SDL function expecting a C contiguous array of either intc or float32. + + Array shape is enforced to be (n, item_length) + """ + if getattr(array, "dtype", None) in (np.intc, np.int8, np.int16, np.int32, np.uint8, np.uint16): + out = np.ascontiguousarray(array, np.intc) + else: + out = np.ascontiguousarray(array, np.float32) + if len(out.shape) != 2: # noqa: PLR2004 + msg = f"Array must have 2 axes, but shape is {out.shape!r}" + raise TypeError(msg) + if out.shape[1] != item_length: + msg = f"Array shape[1] must be {item_length}, but shape is {out.shape!r}" + raise TypeError(msg) + return out + + def fill_rects(self, rects: NDArray[np.number] | Sequence[tuple[float, float, float, float]]) -> None: """Fill multiple rectangles from an array. + Args: + rects: A sequence or array of (x, y, width, height) rectangles. + .. versionadded:: 13.5 """ - assert len(rects.shape) == 2 - assert rects.shape[1] == 4 - rects = np.ascontiguousarray(rects) + rects = self._convert_array(rects, item_length=4) if rects.dtype == np.intc: _check(lib.SDL_RenderFillRects(self.p, tcod.ffi.from_buffer("SDL_Rect*", rects), rects.shape[0])) - elif rects.dtype == np.float32: - _check(lib.SDL_RenderFillRectsF(self.p, tcod.ffi.from_buffer("SDL_FRect*", rects), rects.shape[0])) - else: - msg = f"Array must be an np.intc or np.float32 type, got {rects.dtype}." - raise TypeError(msg) + return + _check(lib.SDL_RenderFillRectsF(self.p, tcod.ffi.from_buffer("SDL_FRect*", rects), rects.shape[0])) - def draw_rects(self, rects: NDArray[np.intc | np.float32]) -> None: + def draw_rects(self, rects: NDArray[np.number] | Sequence[tuple[float, float, float, float]]) -> None: """Draw multiple outlined rectangles from an array. + Args: + rects: A sequence or array of (x, y, width, height) rectangles. + .. versionadded:: 13.5 """ - assert len(rects.shape) == 2 - assert rects.shape[1] == 4 - rects = np.ascontiguousarray(rects) + rects = self._convert_array(rects, item_length=4) + assert len(rects.shape) == 2 # noqa: PLR2004 + assert rects.shape[1] == 4 # noqa: PLR2004 if rects.dtype == np.intc: _check(lib.SDL_RenderDrawRects(self.p, tcod.ffi.from_buffer("SDL_Rect*", rects), rects.shape[0])) - elif rects.dtype == np.float32: - _check(lib.SDL_RenderDrawRectsF(self.p, tcod.ffi.from_buffer("SDL_FRect*", rects), rects.shape[0])) - else: - msg = f"Array must be an np.intc or np.float32 type, got {rects.dtype}." - raise TypeError(msg) + return + _check(lib.SDL_RenderDrawRectsF(self.p, tcod.ffi.from_buffer("SDL_FRect*", rects), rects.shape[0])) - def draw_points(self, points: NDArray[np.intc | np.float32]) -> None: + def draw_points(self, points: NDArray[np.number] | Sequence[tuple[float, float]]) -> None: """Draw an array of points. + Args: + points: A sequence or array of (x, y) points. + .. versionadded:: 13.5 """ - assert len(points.shape) == 2 - assert points.shape[1] == 2 - points = np.ascontiguousarray(points) + points = self._convert_array(points, item_length=2) if points.dtype == np.intc: - _check(lib.SDL_RenderDrawRects(self.p, tcod.ffi.from_buffer("SDL_Point*", points), points.shape[0])) - elif points.dtype == np.float32: - _check(lib.SDL_RenderDrawRectsF(self.p, tcod.ffi.from_buffer("SDL_FPoint*", points), points.shape[0])) - else: - msg = f"Array must be an np.intc or np.float32 type, got {points.dtype}." - raise TypeError(msg) + _check(lib.SDL_RenderDrawPoints(self.p, tcod.ffi.from_buffer("SDL_Point*", points), points.shape[0])) + return + _check(lib.SDL_RenderDrawPointsF(self.p, tcod.ffi.from_buffer("SDL_FPoint*", points), points.shape[0])) - def draw_lines(self, points: NDArray[np.intc | np.float32]) -> None: + def draw_lines(self, points: NDArray[np.number] | Sequence[tuple[float, float]]) -> None: """Draw a connected series of lines from an array. + Args: + points: A sequence or array of (x, y) points. + .. versionadded:: 13.5 """ - assert len(points.shape) == 2 - assert points.shape[1] == 2 - points = np.ascontiguousarray(points) + points = self._convert_array(points, item_length=2) if points.dtype == np.intc: - _check(lib.SDL_RenderDrawRects(self.p, tcod.ffi.from_buffer("SDL_Point*", points), points.shape[0] - 1)) - elif points.dtype == np.float32: - _check(lib.SDL_RenderDrawRectsF(self.p, tcod.ffi.from_buffer("SDL_FPoint*", points), points.shape[0] - 1)) - else: - msg = f"Array must be an np.intc or np.float32 type, got {points.dtype}." - raise TypeError(msg) + _check(lib.SDL_RenderDrawLines(self.p, tcod.ffi.from_buffer("SDL_Point*", points), points.shape[0] - 1)) + return + _check(lib.SDL_RenderDrawLinesF(self.p, tcod.ffi.from_buffer("SDL_FPoint*", points), points.shape[0] - 1)) @_required_version((2, 0, 18)) def geometry( self, texture: Texture | None, - xy: NDArray[np.float32], - color: NDArray[np.uint8], - uv: NDArray[np.float32], + xy: NDArray[np.float32] | Sequence[tuple[float, float]], + color: NDArray[np.uint8] | Sequence[tuple[int, int, int, int]], + uv: NDArray[np.float32] | Sequence[tuple[float, float]], indices: NDArray[np.uint8 | np.uint16 | np.uint32] | None = None, ) -> None: """Render triangles from texture and vertex data. + Args: + texture: The SDL texture to render from. + xy: A sequence of (x, y) points to buffer. + color: A sequence of (r, g, b, a) colors to buffer. + uv: A sequence of (x, y) coordinates to buffer. + indices: A sequence of indexes referring to the buffered data, every 3 indexes is a triangle to render. + .. versionadded:: 13.5 """ - assert xy.dtype == np.float32 - assert len(xy.shape) == 2 - assert xy.shape[1] == 2 - assert xy[0].flags.c_contiguous - - assert color.dtype == np.uint8 - assert len(color.shape) == 2 - assert color.shape[1] == 4 - assert color[0].flags.c_contiguous - - assert uv.dtype == np.float32 - assert len(uv.shape) == 2 - assert uv.shape[1] == 2 - assert uv[0].flags.c_contiguous + xy = np.ascontiguousarray(xy, np.float32) + assert len(xy.shape) == 2 # noqa: PLR2004 + assert xy.shape[1] == 2 # noqa: PLR2004 + + color = np.ascontiguousarray(color, np.uint8) + assert len(color.shape) == 2 # noqa: PLR2004 + assert color.shape[1] == 4 # noqa: PLR2004 + + uv = np.ascontiguousarray(uv, np.float32) + assert len(uv.shape) == 2 # noqa: PLR2004 + assert uv.shape[1] == 2 # noqa: PLR2004 if indices is not None: assert indices.dtype.type in (np.uint8, np.uint16, np.uint32, np.int8, np.int16, np.int32) indices = np.ascontiguousarray(indices) @@ -691,7 +715,7 @@ def geometry( texture.p if texture else ffi.NULL, ffi.cast("float*", xy.ctypes.data), xy.strides[0], - ffi.cast("uint8_t*", color.ctypes.data), + ffi.cast("SDL_Color*", color.ctypes.data), color.strides[0], ffi.cast("float*", uv.ctypes.data), uv.strides[0], diff --git a/tcod/sdl/sys.py b/tcod/sdl/sys.py index b2a71a66..7e7b2d88 100644 --- a/tcod/sdl/sys.py +++ b/tcod/sdl/sys.py @@ -2,7 +2,8 @@ import enum import warnings -from typing import Any + +from typing_extensions import Self from tcod.cffi import ffi, lib from tcod.sdl._internal import _check, _get_error @@ -41,10 +42,10 @@ def close(self) -> None: def __del__(self) -> None: self.close() - def __enter__(self) -> _ScopeInit: + def __enter__(self) -> Self: return self - def __exit__(self, *args: Any) -> None: + def __exit__(self, *_: object) -> None: self.close() diff --git a/tcod/sdl/video.py b/tcod/sdl/video.py index 64b8cf20..b140aa17 100644 --- a/tcod/sdl/video.py +++ b/tcod/sdl/video.py @@ -6,24 +6,27 @@ .. versionadded:: 13.4 """ + from __future__ import annotations import enum import sys -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np -from numpy.typing import ArrayLike, NDArray from tcod.cffi import ffi, lib from tcod.sdl._internal import _check, _check_p, _required_version, _version_at_least +if TYPE_CHECKING: + from numpy.typing import ArrayLike, NDArray + __all__ = ( - "WindowFlags", "FlashOperation", "Window", - "new_window", + "WindowFlags", "get_grabbed_window", + "new_window", "screen_saver_allowed", ) @@ -97,10 +100,10 @@ class _TempSurface: def __init__(self, pixels: ArrayLike) -> None: self._array: NDArray[np.uint8] = np.ascontiguousarray(pixels, dtype=np.uint8) - if len(self._array.shape) != 3: + if len(self._array.shape) != 3: # noqa: PLR2004 msg = f"NumPy shape must be 3D [y, x, ch] (got {self._array.shape})" raise TypeError(msg) - if not (3 <= self._array.shape[2] <= 4): + if not (3 <= self._array.shape[2] <= 4): # noqa: PLR2004 msg = f"NumPy array must have RGB or RGBA channels. (got {self._array.shape})" raise TypeError(msg) self.p = ffi.gc( @@ -113,7 +116,7 @@ def __init__(self, pixels: ArrayLike) -> None: 0x000000FF, 0x0000FF00, 0x00FF0000, - 0xFF000000 if self._array.shape[2] == 4 else 0, + 0xFF000000 if self._array.shape[2] == 4 else 0, # noqa: PLR2004 ), lib.SDL_FreeSurface, ) @@ -122,7 +125,7 @@ def __init__(self, pixels: ArrayLike) -> None: class Window: """An SDL2 Window object.""" - def __init__(self, sdl_window_p: Any) -> None: + def __init__(self, sdl_window_p: Any) -> None: # noqa: ANN401 if ffi.typeof(sdl_window_p) is not ffi.typeof("struct SDL_Window*"): msg = "sdl_window_p must be {!r} type (was {!r}).".format( ffi.typeof("struct SDL_Window*"), ffi.typeof(sdl_window_p) @@ -133,7 +136,9 @@ def __init__(self, sdl_window_p: Any) -> None: raise TypeError(msg) self.p = sdl_window_p - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: + if not isinstance(other, Window): + return NotImplemented return bool(self.p == other.p) def set_icon(self, pixels: ArrayLike) -> None: @@ -334,7 +339,7 @@ def hide(self) -> None: lib.SDL_HideWindow(self.p) -def new_window( +def new_window( # noqa: PLR0913 width: int, height: int, *, diff --git a/tcod/tcod.py b/tcod/tcod.py index 6fab4ff4..39240194 100644 --- a/tcod/tcod.py +++ b/tcod/tcod.py @@ -6,6 +6,7 @@ Read the documentation online: https://python-tcod.readthedocs.io/en/latest/ """ + from __future__ import annotations import warnings @@ -21,7 +22,7 @@ image, libtcodpy, los, - map, + map, # noqa: A004 noise, path, random, @@ -78,7 +79,6 @@ def __getattr__(name: str, stacklevel: int = 1) -> Any: # noqa: ANN401 "console", "context", "event", - "tileset", "image", "los", "map", @@ -86,4 +86,5 @@ def __getattr__(name: str, stacklevel: int = 1) -> Any: # noqa: ANN401 "path", "random", "tileset", + "tileset", ] diff --git a/tcod/tileset.py b/tcod/tileset.py index b53b0149..b953cab3 100644 --- a/tcod/tileset.py +++ b/tcod/tileset.py @@ -1,5 +1,7 @@ """Tileset and font related functions. +If you want to load a tileset from a common tileset image then you only need :any:`tcod.tileset.load_tilesheet`. + Tilesets can be loaded as a whole from tile-sheets or True-Type fonts, or they can be put together from multiple tile images by loading them separately using :any:`Tileset.set_tile`. @@ -10,20 +12,27 @@ `_ while continuing to use python-tcod's pathfinding and field-of-view algorithms. """ + from __future__ import annotations import itertools -from os import PathLike +from collections.abc import Iterable from pathlib import Path -from typing import Any, Iterable +from typing import TYPE_CHECKING, Any import numpy as np -from numpy.typing import ArrayLike, NDArray +from typing_extensions import deprecated -import tcod.console -from tcod._internal import _check, _console, _raise_tcod_error, deprecate +from tcod._internal import _check, _check_p, _console, _path_encode, _raise_tcod_error from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from os import PathLike + + from numpy.typing import ArrayLike, NDArray + + import tcod.console + class Tileset: """A collection of graphical tiles. @@ -32,13 +41,16 @@ class Tileset: """ def __init__(self, tile_width: int, tile_height: int) -> None: - self._tileset_p = ffi.gc( - lib.TCOD_tileset_new(tile_width, tile_height), - lib.TCOD_tileset_delete, + """Initialize the tileset.""" + self._tileset_p = _check_p( + ffi.gc( + lib.TCOD_tileset_new(tile_width, tile_height), + lib.TCOD_tileset_delete, + ) ) @classmethod - def _claim(cls, cdata: Any) -> Tileset: + def _claim(cls, cdata: Any) -> Tileset: # noqa: ANN401 """Return a new Tileset that owns the provided TCOD_Tileset* object.""" self = object.__new__(cls) if cdata == ffi.NULL: @@ -48,7 +60,7 @@ def _claim(cls, cdata: Any) -> Tileset: return self @classmethod - def _from_ref(cls, tileset_p: Any) -> Tileset: + def _from_ref(cls, tileset_p: Any) -> Tileset: # noqa: ANN401 self = object.__new__(cls) self._tileset_p = tileset_p return self @@ -115,20 +127,20 @@ def set_tile(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8]) -> None: # Normal usage when a tile already has its own alpha channel. # The loaded tile must be the correct shape for the tileset you assign it to. # The tile is assigned to a private use area and will not conflict with any exiting codepoint. - tileset.set_tile(0x100000, imageio.load("rgba_tile.png")) + tileset.set_tile(0x100000, imageio.imread("rgba_tile.png")) # Load a greyscale tile. - tileset.set_tile(0x100001, imageio.load("greyscale_tile.png"), pilmode="L") + tileset.set_tile(0x100001, imageio.imread("greyscale_tile.png"), mode="L") # If you are stuck with an RGB array then you can use the red channel as the input: `rgb[:, :, 0]` # Loads an RGB sprite without a background. - tileset.set_tile(0x100002, imageio.load("rgb_no_background.png", pilmode="RGBA")) + tileset.set_tile(0x100002, imageio.imread("rgb_no_background.png", mode="RGBA")) # If you're stuck with an RGB array then you can pad the channel axis with an alpha of 255: # rgba = np.pad(rgb, pad_width=((0, 0), (0, 0), (0, 1)), constant_values=255) # Loads an RGB sprite with a key color background. KEY_COLOR = np.asarray((255, 0, 255), dtype=np.uint8) - sprite_rgb = imageio.load("rgb_tile.png") + sprite_rgb = imageio.imread("rgb_tile.png") # Compare the RGB colors to KEY_COLOR, compress full matches to a 2D mask. sprite_mask = (sprite_rgb != KEY_COLOR).all(axis=2) # Generate the alpha array, with 255 as the foreground and 0 as the background. @@ -147,7 +159,7 @@ def set_tile(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8]) -> None: required = (*self.tile_shape, 4) if tile.shape != required: note = "" - if len(tile.shape) == 3 and tile.shape[2] == 3: + if len(tile.shape) == 3 and tile.shape[2] == 3: # noqa: PLR2004 note = ( "\nNote: An RGB array is too ambiguous," " an alpha channel must be added to this array to divide the background/foreground areas." @@ -218,10 +230,11 @@ def remap(self, codepoint: int, x: int, y: int = 0) -> None: """ tile_i = x + y * self._tileset_p.virtual_columns if not (0 <= tile_i < self._tileset_p.tiles_count): - raise IndexError( - "Tile %i is non-existent and can't be assigned." - " (Tileset has %i tiles.)" % (tile_i, self._tileset_p.tiles_count) + msg = ( + f"Tile {tile_i} is non-existent and can't be assigned." + f" (Tileset has {self._tileset_p.tiles_count} tiles.)" ) + raise IndexError(msg) _check( lib.TCOD_tileset_assign_tile( self._tileset_p, @@ -231,7 +244,7 @@ def remap(self, codepoint: int, x: int, y: int = 0) -> None: ) -@deprecate("Using the default tileset is deprecated.") +@deprecated("Using the default tileset is deprecated.") def get_default() -> Tileset: """Return a reference to the default Tileset. @@ -244,7 +257,7 @@ def get_default() -> Tileset: return Tileset._claim(lib.TCOD_get_default_tileset()) -@deprecate("Using the default tileset is deprecated.") +@deprecated("Using the default tileset is deprecated.") def set_default(tileset: Tileset) -> None: """Set the default tileset. @@ -268,13 +281,13 @@ def load_truetype_font(path: str | PathLike[str], tile_width: int, tile_height: This function is provisional. The API may change. """ path = Path(path).resolve(strict=True) - cdata = lib.TCOD_load_truetype_font_(bytes(path), tile_width, tile_height) + cdata = lib.TCOD_load_truetype_font_(_path_encode(path), tile_width, tile_height) if not cdata: raise RuntimeError(ffi.string(lib.TCOD_get_error())) return Tileset._claim(cdata) -@deprecate("Accessing the default tileset is deprecated.") +@deprecated("Accessing the default tileset is deprecated.") def set_truetype_font(path: str | PathLike[str], tile_width: int, tile_height: int) -> None: """Set the default tileset from a `.ttf` or `.otf` file. @@ -296,7 +309,7 @@ def set_truetype_font(path: str | PathLike[str], tile_width: int, tile_height: i Use :any:`load_truetype_font` instead. """ path = Path(path).resolve(strict=True) - if lib.TCOD_tileset_load_truetype_(bytes(path), tile_width, tile_height): + if lib.TCOD_tileset_load_truetype_(_path_encode(path), tile_width, tile_height): raise RuntimeError(ffi.string(lib.TCOD_get_error())) @@ -314,7 +327,7 @@ def load_bdf(path: str | PathLike[str]) -> Tileset: .. versionadded:: 11.10 """ path = Path(path).resolve(strict=True) - cdata = lib.TCOD_load_bdf(bytes(path)) + cdata = lib.TCOD_load_bdf(_path_encode(path)) if not cdata: raise RuntimeError(ffi.string(lib.TCOD_get_error()).decode()) return Tileset._claim(cdata) @@ -337,13 +350,31 @@ def load_tilesheet(path: str | PathLike[str], columns: int, rows: int, charmap: If `None` is used then no tiles will be mapped, you will need to use :any:`Tileset.remap` to assign codepoints to this Tileset. + Image alpha and key colors are handled automatically. + For example any tileset from the `Dwarf Fortress tileset repository `_ + will load correctly with the following example: + + Example:: + + from pathlib import Path + + import tcod.tileset + + THIS_DIR = Path(__file__, "..") # Directory of this script file + FONT = THIS_DIR / "assets/tileset.png" # Replace with any tileset from the DF tileset repository + + # Will raise FileNotFoundError if the font is missing! + tileset = tcod.tileset.load_tilesheet(FONT, 16, 16, tcod.tileset.CHARMAP_CP437) + + The tileset return value is usually passed to the `tileset` parameter of :any:`tcod.context.new`. + .. versionadded:: 11.12 """ path = Path(path).resolve(strict=True) mapping = [] if charmap is not None: mapping = list(itertools.islice(charmap, columns * rows)) - cdata = lib.TCOD_tileset_load(bytes(path), columns, rows, len(mapping), mapping) + cdata = lib.TCOD_tileset_load(_path_encode(path), columns, rows, len(mapping), mapping) if not cdata: _raise_tcod_error() return Tileset._claim(cdata) @@ -365,6 +396,7 @@ def procedural_block_elements(*, tileset: Tileset) -> None: Example:: + >>> import tcod.tileset >>> tileset = tcod.tileset.Tileset(8, 8) >>> tcod.tileset.procedural_block_elements(tileset=tileset) >>> tileset.get_tile(0x259E)[:, :, 3] # "▞" Quadrant upper right and lower left. diff --git a/tests/conftest.py b/tests/conftest.py index 163d0918..182cb6d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,15 @@ """Test directory configuration.""" + +from __future__ import annotations + import random import warnings -from typing import Callable, Iterator, Union +from collections.abc import Callable, Iterator import pytest import tcod +from tcod import libtcodpy # ruff: noqa: D103 @@ -14,6 +18,15 @@ def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption("--no-window", action="store_true", help="Skip tests which need a rendering context.") +@pytest.fixture +def uses_window(request: pytest.FixtureRequest) -> Iterator[None]: + """Marks tests which require a rendering context.""" + if request.config.getoption("--no-window"): + pytest.skip("This test needs a rendering context.") + yield None + return + + @pytest.fixture(scope="session", params=["SDL", "SDL2"]) def session_console(request: pytest.FixtureRequest) -> Iterator[tcod.console.Console]: if request.config.getoption("--no-window"): @@ -23,41 +36,41 @@ def session_console(request: pytest.FixtureRequest) -> Iterator[tcod.console.Con HEIGHT = 10 TITLE = "libtcod-cffi tests" FULLSCREEN = False - RENDERER = getattr(tcod, "RENDERER_" + request.param) + RENDERER = getattr(libtcodpy, "RENDERER_" + request.param) - tcod.console_set_custom_font(FONT_FILE) - with tcod.console_init_root(WIDTH, HEIGHT, TITLE, FULLSCREEN, RENDERER, vsync=False) as con: + libtcodpy.console_set_custom_font(FONT_FILE) + with libtcodpy.console_init_root(WIDTH, HEIGHT, TITLE, FULLSCREEN, RENDERER, vsync=False) as con: yield con -@pytest.fixture() +@pytest.fixture def console(session_console: tcod.console.Console) -> tcod.console.Console: console = session_console - tcod.console_flush() + libtcodpy.console_flush() with warnings.catch_warnings(): warnings.simplefilter("ignore") console.default_fg = (255, 255, 255) console.default_bg = (0, 0, 0) - console.default_bg_blend = tcod.BKGND_SET - console.default_alignment = tcod.LEFT + console.default_bg_blend = libtcodpy.BKGND_SET + console.default_alignment = libtcodpy.LEFT console.clear() return console -@pytest.fixture() +@pytest.fixture def offscreen(console: tcod.console.Console) -> tcod.console.Console: """Return an off-screen console with the same size as the root console.""" return tcod.console.Console(console.width, console.height) -@pytest.fixture() -def fg() -> tcod.Color: - return tcod.Color(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) +@pytest.fixture +def fg() -> libtcodpy.Color: + return libtcodpy.Color(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) -@pytest.fixture() -def bg() -> tcod.Color: - return tcod.Color(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) +@pytest.fixture +def bg() -> libtcodpy.Color: + return libtcodpy.Color(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) def ch_ascii_int() -> int: @@ -84,6 +97,6 @@ def ch_latin1_str() -> str: "latin1_str", ] ) -def ch(request: pytest.FixtureRequest) -> Callable[[], Union[int, str]]: +def ch(request: pytest.FixtureRequest) -> Callable[[], int | str]: """Test with multiple types of ascii/latin1 characters.""" - return globals()["ch_%s" % request.param]() # type: ignore + return globals()[f"ch_{request.param}"]() # type: ignore[no-any-return] diff --git a/tests/test_console.py b/tests/test_console.py index f8fa5d91..4b8f8435 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -1,4 +1,5 @@ """Tests for tcod.console.""" + import pickle from pathlib import Path @@ -64,21 +65,28 @@ def test_console_defaults() -> None: @pytest.mark.filterwarnings("ignore:Parameter names have been moved around,") -@pytest.mark.filterwarnings("ignore:Pass the key color to Console.blit instea") +@pytest.mark.filterwarnings("ignore:Pass the key color to Console.blit instead") @pytest.mark.filterwarnings("ignore:.*default values have been deprecated") def test_console_methods() -> None: console = tcod.console.Console(width=12, height=10) console.put_char(0, 0, ord("@")) - console.print_(0, 0, "Test") - console.print_rect(0, 0, 2, 8, "a b c d e f") + with pytest.deprecated_call(): + console.print_(0, 0, "Test") + with pytest.deprecated_call(): + console.print_rect(0, 0, 2, 8, "a b c d e f") console.get_height_rect(0, 0, 2, 8, "a b c d e f") - console.rect(0, 0, 2, 2, True) - console.hline(0, 1, 10) - console.vline(1, 0, 10) - console.print_frame(0, 0, 8, 8, "Frame") - console.blit(0, 0, 0, 0, console, 0, 0) # type: ignore - console.blit(0, 0, 0, 0, console, 0, 0, key_color=(0, 0, 0)) # type: ignore - console.set_key_color((254, 0, 254)) + with pytest.deprecated_call(): + console.rect(0, 0, 2, 2, True) + with pytest.deprecated_call(): + console.hline(0, 1, 10) + with pytest.deprecated_call(): + console.vline(1, 0, 10) + with pytest.deprecated_call(): + console.print_frame(0, 0, 8, 8, "Frame") + console.blit(0, 0, 0, 0, console, 0, 0) # type: ignore[arg-type] + console.blit(0, 0, 0, 0, console, 0, 0, key_color=(0, 0, 0)) # type: ignore[arg-type] + with pytest.deprecated_call(): + console.set_key_color((254, 0, 254)) def test_console_pickle() -> None: @@ -103,7 +111,7 @@ def test_console_pickle_fortran() -> None: def test_console_repr() -> None: from numpy import array # noqa: F401 # Used for eval - eval(repr(tcod.console.Console(10, 2))) + eval(repr(tcod.console.Console(10, 2))) # noqa: S307 def test_console_str() -> None: @@ -111,7 +119,7 @@ def test_console_str() -> None: console.ch[:] = ord(".") with pytest.warns(): console.print_(0, 0, "Test") - assert str(console) == ("") + assert str(console) == ("") def test_console_fortran_buffer() -> None: @@ -149,7 +157,7 @@ def test_rexpaint(tmp_path: Path) -> None: assert consoles[0].rgb.shape == loaded[0].rgb.shape assert consoles[1].rgb.shape == loaded[1].rgb.shape with pytest.raises(FileNotFoundError): - tcod.console.load_xp(tmp_path / "non_existant") + tcod.console.load_xp(tmp_path / "non_existent") def test_draw_frame() -> None: diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py index 502d9ea9..82001d47 100644 --- a/tests/test_deprecated.py +++ b/tests/test_deprecated.py @@ -1,4 +1,5 @@ """Test deprecated features.""" + from __future__ import annotations import numpy as np @@ -8,6 +9,7 @@ import tcod.constants import tcod.event import tcod.libtcodpy +import tcod.random with pytest.warns(): import libtcodpy @@ -49,7 +51,22 @@ def test_deprecate_mouse_constants() -> None: _ = tcod.event.BUTTON_LMASK +def test_deprecate_kmod_constants() -> None: + with pytest.warns(FutureWarning, match=r"Modifier.LSHIFT"): + _ = tcod.event.KMOD_LSHIFT + with pytest.warns(FutureWarning, match=r"Modifier.GUI"): + _ = tcod.event.KMOD_GUI + + def test_line_where() -> None: with pytest.warns(): where = tcod.libtcodpy.line_where(1, 0, 3, 4) np.testing.assert_array_equal(where, [[1, 1, 2, 2, 3], [0, 1, 2, 3, 4]]) + + +def test_gauss_typo() -> None: + rng = tcod.random.Random() + with pytest.warns(FutureWarning, match=r"gauss"): + rng.guass(1, 1) + with pytest.warns(FutureWarning, match=r"inverse_gauss"): + rng.inverse_guass(1, 1) diff --git a/tests/test_libtcodpy.py b/tests/test_libtcodpy.py index 9eb151bb..7ace644a 100644 --- a/tests/test_libtcodpy.py +++ b/tests/test_libtcodpy.py @@ -1,6 +1,8 @@ """Tests for the libtcodpy API.""" + +from collections.abc import Callable, Iterator from pathlib import Path -from typing import Any, Callable, Iterator, List, Optional, Tuple, Union +from typing import Any import numpy as np import pytest @@ -36,9 +38,9 @@ def assert_char( # noqa: PLR0913 console: tcod.console.Console, x: int, y: int, - ch: Optional[Union[str, int]] = None, - fg: Optional[Tuple[int, int, int]] = None, - bg: Optional[Tuple[int, int, int]] = None, + ch: str | int | None = None, + fg: tuple[int, int, int] | None = None, + bg: tuple[int, int, int] | None = None, ) -> None: if ch is not None: if isinstance(ch, str): @@ -51,7 +53,7 @@ def assert_char( # noqa: PLR0913 @pytest.mark.filterwarnings("ignore") -def test_console_defaults(console: tcod.console.Console, fg: Tuple[int, int, int], bg: Tuple[int, int, int]) -> None: +def test_console_defaults(console: tcod.console.Console, fg: tuple[int, int, int], bg: tuple[int, int, int]) -> None: libtcodpy.console_set_default_foreground(console, fg) libtcodpy.console_set_default_background(console, bg) libtcodpy.console_clear(console) @@ -59,13 +61,13 @@ def test_console_defaults(console: tcod.console.Console, fg: Tuple[int, int, int @pytest.mark.filterwarnings("ignore") -def test_console_set_char_background(console: tcod.console.Console, bg: Tuple[int, int, int]) -> None: +def test_console_set_char_background(console: tcod.console.Console, bg: tuple[int, int, int]) -> None: libtcodpy.console_set_char_background(console, 0, 0, bg, libtcodpy.BKGND_SET) assert_char(console, 0, 0, bg=bg) @pytest.mark.filterwarnings("ignore") -def test_console_set_char_foreground(console: tcod.console.Console, fg: Tuple[int, int, int]) -> None: +def test_console_set_char_foreground(console: tcod.console.Console, fg: tuple[int, int, int]) -> None: libtcodpy.console_set_char_foreground(console, 0, 0, fg) assert_char(console, 0, 0, fg=fg) @@ -84,14 +86,14 @@ def test_console_put_char(console: tcod.console.Console, ch: int) -> None: @pytest.mark.filterwarnings("ignore") def console_put_char_ex( - console: tcod.console.Console, ch: int, fg: Tuple[int, int, int], bg: Tuple[int, int, int] + console: tcod.console.Console, ch: int, fg: tuple[int, int, int], bg: tuple[int, int, int] ) -> None: libtcodpy.console_put_char_ex(console, 0, 0, ch, fg, bg) assert_char(console, 0, 0, ch=ch, fg=fg, bg=bg) @pytest.mark.filterwarnings("ignore") -def test_console_printing(console: tcod.console.Console, fg: Tuple[int, int, int], bg: Tuple[int, int, int]) -> None: +def test_console_printing(console: tcod.console.Console, fg: tuple[int, int, int], bg: tuple[int, int, int]) -> None: libtcodpy.console_set_background_flag(console, libtcodpy.BKGND_SET) assert libtcodpy.console_get_background_flag(console) == libtcodpy.BKGND_SET @@ -181,8 +183,8 @@ def test_console_rexpaint_save_load( console: tcod.console.Console, tmp_path: Path, ch: int, - fg: Tuple[int, int, int], - bg: Tuple[int, int, int], + fg: tuple[int, int, int], + bg: tuple[int, int, int], ) -> None: libtcodpy.console_print(console, 0, 0, "test") libtcodpy.console_put_char_ex(console, 1, 1, ch, fg, bg) @@ -205,7 +207,7 @@ def test_console_rexpaint_list_save_load(console: tcod.console.Console, tmp_path assert libtcodpy.console_list_save_xp([con1, con2], xp_file, 1) loaded_consoles = libtcodpy.console_list_load_xp(xp_file) assert loaded_consoles - for a, b in zip([con1, con2], loaded_consoles): + for a, b in zip([con1, con2], loaded_consoles, strict=True): assertConsolesEqual(a, b) libtcodpy.console_delete(a) libtcodpy.console_delete(b) @@ -259,9 +261,9 @@ def test_console_fill_numpy(console: tcod.console.Console) -> None: for y in range(height): fill[y, :] = y % 256 - libtcodpy.console_fill_background(console, fill, fill, fill) # type: ignore - libtcodpy.console_fill_foreground(console, fill, fill, fill) # type: ignore - libtcodpy.console_fill_char(console, fill) # type: ignore + libtcodpy.console_fill_background(console, fill, fill, fill) # type: ignore[arg-type] + libtcodpy.console_fill_foreground(console, fill, fill, fill) # type: ignore[arg-type] + libtcodpy.console_fill_char(console, fill) # type: ignore[arg-type] # verify fill bg: NDArray[np.intc] = np.zeros((height, width), dtype=np.intc) @@ -272,10 +274,10 @@ def test_console_fill_numpy(console: tcod.console.Console) -> None: bg[y, x] = libtcodpy.console_get_char_background(console, x, y)[0] fg[y, x] = libtcodpy.console_get_char_foreground(console, x, y)[0] ch[y, x] = libtcodpy.console_get_char(console, x, y) - fill = fill.tolist() - assert fill == bg.tolist() - assert fill == fg.tolist() - assert fill == ch.tolist() + fill_ = fill.tolist() + assert fill_ == bg.tolist() + assert fill_ == fg.tolist() + assert fill_ == ch.tolist() @pytest.mark.filterwarnings("ignore") @@ -404,7 +406,7 @@ def test_line_step() -> None: def test_line() -> None: """Tests normal use, lazy evaluation, and error propagation.""" # test normal results - test_result: List[Tuple[int, int]] = [] + test_result: list[tuple[int, int]] = [] def line_test(x: int, y: int) -> bool: test_result.append((x, y)) @@ -432,7 +434,6 @@ def test_line_iter() -> None: @pytest.mark.filterwarnings("ignore") def test_bsp() -> None: - """Commented out statements work in libtcod-cffi.""" bsp = libtcodpy.bsp_new_with_size(0, 0, 64, 64) repr(bsp) # test __repr__ on leaf libtcodpy.bsp_resize(bsp, 0, 0, 32, 32) @@ -448,15 +449,15 @@ def test_bsp() -> None: bsp.level = bsp.level # cover functions on leaf - # self.assertFalse(libtcodpy.bsp_left(bsp)) - # self.assertFalse(libtcodpy.bsp_right(bsp)) - # self.assertFalse(libtcodpy.bsp_father(bsp)) + assert not libtcodpy.bsp_left(bsp) + assert not libtcodpy.bsp_right(bsp) + assert not libtcodpy.bsp_father(bsp) assert libtcodpy.bsp_is_leaf(bsp) assert libtcodpy.bsp_contains(bsp, 1, 1) - # self.assertFalse(libtcodpy.bsp_contains(bsp, -1, -1)) - # self.assertEqual(libtcodpy.bsp_find_node(bsp, 1, 1), bsp) - # self.assertFalse(libtcodpy.bsp_find_node(bsp, -1, -1)) + assert not libtcodpy.bsp_contains(bsp, -1, -1) + assert libtcodpy.bsp_find_node(bsp, 1, 1) == bsp + assert not libtcodpy.bsp_find_node(bsp, -1, -1) libtcodpy.bsp_split_once(bsp, False, 4) repr(bsp) # test __repr__ with parent @@ -466,10 +467,10 @@ def test_bsp() -> None: # cover functions on parent assert libtcodpy.bsp_left(bsp) assert libtcodpy.bsp_right(bsp) - # self.assertFalse(libtcodpy.bsp_father(bsp)) + assert not libtcodpy.bsp_father(bsp) assert not libtcodpy.bsp_is_leaf(bsp) - # self.assertEqual(libtcodpy.bsp_father(libtcodpy.bsp_left(bsp)), bsp) - # self.assertEqual(libtcodpy.bsp_father(libtcodpy.bsp_right(bsp)), bsp) + assert libtcodpy.bsp_father(libtcodpy.bsp_left(bsp)) == bsp # type: ignore[arg-type] + assert libtcodpy.bsp_father(libtcodpy.bsp_right(bsp)) == bsp # type: ignore[arg-type] libtcodpy.bsp_split_recursive(bsp, None, 4, 2, 2, 1.0, 1.0) @@ -518,7 +519,7 @@ def test_color() -> None: color_a["b"] = color_a["b"] assert list(color_a) == [0, 3, 2] - assert color_a == color_a + assert color_a == color_a # noqa: PLR0124 color_b = libtcodpy.Color(255, 255, 255) assert color_a != color_b @@ -532,7 +533,7 @@ def test_color() -> None: def test_color_repr() -> None: Color = libtcodpy.Color col = Color(0, 1, 2) - assert eval(repr(col)) == col + assert eval(repr(col)) == col # noqa: S307 @pytest.mark.filterwarnings("ignore") @@ -659,7 +660,7 @@ def test_heightmap() -> None: POINTS_AC = POINT_A + POINT_C # invalid path -@pytest.fixture() +@pytest.fixture def map_() -> Iterator[tcod.map.Map]: map_ = tcod.map.Map(MAP_WIDTH, MAP_HEIGHT) map_.walkable[...] = map_.transparent[...] = MAP[...] == " " @@ -667,12 +668,10 @@ def map_() -> Iterator[tcod.map.Map]: libtcodpy.map_delete(map_) -@pytest.fixture() +@pytest.fixture def path_callback(map_: tcod.map.Map) -> Callable[[int, int, int, int, None], bool]: def callback(ox: int, oy: int, dx: int, dy: int, user_data: None) -> bool: - if map_.walkable[dy, dx]: - return True - return False + return bool(map_.walkable[dy, dx]) return callback @@ -699,8 +698,8 @@ def test_astar(map_: tcod.map.Map) -> None: assert libtcodpy.path_size(astar) > 0 assert not libtcodpy.path_is_empty(astar) - x: Optional[int] - y: Optional[int] + x: int | None + y: int | None for i in range(libtcodpy.path_size(astar)): x, y = libtcodpy.path_get(astar, i) @@ -737,8 +736,8 @@ def test_dijkstra(map_: tcod.map.Map) -> None: libtcodpy.dijkstra_reverse(path) - x: Optional[int] - y: Optional[int] + x: int | None + y: int | None for i in range(libtcodpy.dijkstra_size(path)): x, y = libtcodpy.dijkstra_get(path, i) diff --git a/tests/test_noise.py b/tests/test_noise.py index 0f64d5fe..80023f5f 100644 --- a/tests/test_noise.py +++ b/tests/test_noise.py @@ -1,4 +1,5 @@ """Tests for the tcod.noise module.""" + import copy import pickle @@ -6,6 +7,7 @@ import pytest import tcod.noise +import tcod.random # ruff: noqa: D103 @@ -99,3 +101,7 @@ def test_noise_copy() -> None: assert repr(noise3) == repr(pickle.loads(pickle.dumps(noise3))) noise4 = tcod.noise.Noise(2, seed=42) assert repr(noise4) == repr(pickle.loads(pickle.dumps(noise4))) + + +def test_noise_grid() -> None: + tcod.noise.grid((2, 2), scale=2) # Check int scale diff --git a/tests/test_parser.py b/tests/test_parser.py index ce200166..5494b786 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,4 +1,5 @@ """Test old libtcodpy parser.""" + from pathlib import Path from typing import Any @@ -61,7 +62,7 @@ def new_property(self, name: str, typ: int, value: Any) -> bool: # noqa: ANN401 type_names = ["NONE", "BOOL", "CHAR", "INT", "FLOAT", "STRING", "COLOR", "DICE"] type_name = type_names[typ & 0xFF] if typ & libtcod.TYPE_LIST: - type_name = "LIST<%s>" % type_name + type_name = f"LIST<{type_name}>" print("new property named ", name, " type ", type_name, " value ", value) return True diff --git a/tests/test_random.py b/tests/test_random.py index e6b64e1e..d20045cc 100644 --- a/tests/test_random.py +++ b/tests/test_random.py @@ -1,8 +1,11 @@ """Test random number generators.""" + import copy import pickle from pathlib import Path +import pytest + import tcod.random # ruff: noqa: D103 @@ -14,8 +17,10 @@ def test_tcod_random() -> None: rand = tcod.random.Random(tcod.random.COMPLEMENTARY_MULTIPLY_WITH_CARRY) assert 0 <= rand.randint(0, 100) <= 100 # noqa: PLR2004 assert 0 <= rand.uniform(0, 100) <= 100 # noqa: PLR2004 - rand.guass(0, 1) - rand.inverse_guass(0, 1) + with pytest.warns(FutureWarning, match=r"typo"): + rand.guass(0, 1) + with pytest.warns(FutureWarning, match=r"typo"): + rand.inverse_guass(0, 1) def test_tcod_random_copy() -> None: diff --git a/tests/test_sdl.py b/tests/test_sdl.py index fa2ac29a..e29554df 100644 --- a/tests/test_sdl.py +++ b/tests/test_sdl.py @@ -1,4 +1,5 @@ """Test SDL specific features.""" + import sys import numpy as np @@ -11,7 +12,7 @@ # ruff: noqa: D103 -def test_sdl_window() -> None: +def test_sdl_window(uses_window: None) -> None: assert tcod.sdl.video.get_grabbed_window() is None window = tcod.sdl.video.new_window(1, 1) window.raise_window() @@ -23,7 +24,7 @@ def test_sdl_window() -> None: assert window.title == sys.argv[0] window.title = "Title" assert window.title == "Title" - assert window.opacity == 1.0 # noqa: PLR2004 + assert window.opacity == 1.0 window.position = window.position window.fullscreen = window.fullscreen window.resizable = window.resizable @@ -47,17 +48,19 @@ def test_sdl_window_bad_types() -> None: tcod.sdl.video.Window(tcod.ffi.new("SDL_Rect*")) -def test_sdl_screen_saver() -> None: +def test_sdl_screen_saver(uses_window: None) -> None: tcod.sdl.sys.init() assert tcod.sdl.video.screen_saver_allowed(False) is False assert tcod.sdl.video.screen_saver_allowed(True) is True assert tcod.sdl.video.screen_saver_allowed() is True -def test_sdl_render() -> None: +def test_sdl_render(uses_window: None) -> None: window = tcod.sdl.video.new_window(1, 1) render = tcod.sdl.render.new_renderer(window, software=True, vsync=False, target_textures=True) + render.clear() render.present() + render.clear() rgb = render.upload_texture(np.zeros((8, 8, 3), np.uint8)) assert (rgb.width, rgb.height) == (8, 8) assert rgb.access == tcod.sdl.render.TextureAccess.STATIC @@ -75,6 +78,35 @@ def test_sdl_render() -> None: assert (render.read_pixels(format="RGB") == (0, 0, 0)).all() assert render.read_pixels(rect=(1, 2, 3, 4)).shape == (4, 3, 4) + render.draw_point((0, 0)) + render.draw_line((0, 0), (1, 1)) + render.draw_rect((0, 0, 1, 1)) + render.fill_rect((0, 0, 1, 1)) + + render.draw_points([(0, 0)]) + render.draw_lines([(0, 0), (1, 1)]) + render.draw_rects([(0, 0, 1, 1)]) + render.fill_rects([(0, 0, 1, 1)]) + + for dtype in (np.intc, np.int8, np.uint8, np.float32, np.float16): + render.draw_points(np.ones((3, 2), dtype=dtype)) + render.draw_lines(np.ones((3, 2), dtype=dtype)) + render.draw_rects(np.ones((3, 4), dtype=dtype)) + render.fill_rects(np.ones((3, 4), dtype=dtype)) + + with pytest.raises(TypeError, match=r"shape\[1\] must be 2"): + render.draw_points(np.ones((3, 1), dtype=dtype)) + with pytest.raises(TypeError, match=r"must have 2 axes"): + render.draw_points(np.ones((3,), dtype=dtype)) + + render.geometry( + None, + np.zeros((1, 2), np.float32), + np.zeros((1, 4), np.uint8), + np.zeros((1, 2), np.float32), + np.zeros((3,), np.uint8), + ) + def test_sdl_render_bad_types() -> None: with pytest.raises(TypeError): diff --git a/tests/test_sdl_audio.py b/tests/test_sdl_audio.py index 6441bda1..f7e55093 100644 --- a/tests/test_sdl_audio.py +++ b/tests/test_sdl_audio.py @@ -1,4 +1,5 @@ """Test tcod.sdl.audio module.""" + import contextlib import sys import time diff --git a/tests/test_tcod.py b/tests/test_tcod.py index 33e50195..8b910c3a 100644 --- a/tests/test_tcod.py +++ b/tests/test_tcod.py @@ -1,4 +1,5 @@ """Tests for newer tcod API.""" + import copy import pickle from typing import Any, NoReturn @@ -9,11 +10,12 @@ import tcod import tcod.console +from tcod import libtcodpy # ruff: noqa: D103 -def raise_Exception(*args: object) -> NoReturn: +def raise_Exception(*_args: object) -> NoReturn: raise RuntimeError("testing exception") # noqa: TRY003, EM101 @@ -34,7 +36,7 @@ def test_tcod_bsp() -> None: assert not bsp.children with pytest.raises(RuntimeError): - tcod.bsp_traverse_pre_order(bsp, raise_Exception) + libtcodpy.bsp_traverse_pre_order(bsp, raise_Exception) bsp.split_recursive(3, 4, 4, 1, 1) for node in bsp.walk(): @@ -65,11 +67,11 @@ def test_tcod_map_set_bits() -> None: assert not map_.fov[:].any() map_.transparent[1, 0] = True - assert tcod.map_is_transparent(map_, 0, 1) + assert libtcodpy.map_is_transparent(map_, 0, 1) map_.walkable[1, 0] = True - assert tcod.map_is_walkable(map_, 0, 1) + assert libtcodpy.map_is_walkable(map_, 0, 1) map_.fov[1, 0] = True - assert tcod.map_is_in_fov(map_, 0, 1) + assert libtcodpy.map_is_in_fov(map_, 0, 1) @pytest.mark.filterwarnings("ignore:This class may perform poorly") @@ -114,7 +116,7 @@ def test_color_class() -> None: assert tcod.white - tcod.white == tcod.black assert tcod.black + (2, 2, 2) - (1, 1, 1) == (1, 1, 1) # noqa: RUF005 - color = tcod.Color() + color = libtcodpy.Color() color.r = 1 color.g = 2 color.b = 3 @@ -155,21 +157,21 @@ def test_path_callback() -> None: def test_key_repr() -> None: - Key = tcod.Key + Key = libtcodpy.Key key = Key(vk=1, c=2, shift=True) assert key.vk == 1 assert key.c == 2 # noqa: PLR2004 assert key.shift - key_copy = eval(repr(key)) + key_copy = eval(repr(key)) # noqa: S307 assert key.vk == key_copy.vk assert key.c == key_copy.c assert key.shift == key_copy.shift def test_mouse_repr() -> None: - Mouse = tcod.Mouse + Mouse = libtcodpy.Mouse mouse = Mouse(x=1, lbutton=True) - mouse_copy = eval(repr(mouse)) + mouse_copy = eval(repr(mouse)) # noqa: S307 assert mouse.x == mouse_copy.x assert mouse.lbutton == mouse_copy.lbutton @@ -186,11 +188,11 @@ def test_recommended_size(console: tcod.console.Console) -> None: @pytest.mark.filterwarnings("ignore") -def test_context() -> None: - with tcod.context.new_window(32, 32, renderer=tcod.RENDERER_SDL2): +def test_context(uses_window: None) -> None: + with tcod.context.new_window(32, 32, renderer=libtcodpy.RENDERER_SDL2): pass WIDTH, HEIGHT = 16, 4 - with tcod.context.new_terminal(columns=WIDTH, rows=HEIGHT, renderer=tcod.RENDERER_SDL2) as context: + with tcod.context.new_terminal(columns=WIDTH, rows=HEIGHT, renderer=libtcodpy.RENDERER_SDL2) as context: console = tcod.console.Console(*context.recommended_console_size()) context.present(console) assert context.sdl_window_p is not None diff --git a/tests/test_tileset.py b/tests/test_tileset.py index aba88b98..dd272fe7 100644 --- a/tests/test_tileset.py +++ b/tests/test_tileset.py @@ -1,4 +1,5 @@ """Test for tcod.tileset module.""" + import tcod.tileset # ruff: noqa: D103 diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..784e5f57 --- /dev/null +++ b/tox.ini @@ -0,0 +1,17 @@ +[tox] +isolated_build = True +env_list = + py3 +minversion = 4.4.11 + +[testenv] +description = run the tests with pytest +package = wheel +wheel_build_env = .pkg +deps = + pytest>=6 + pytest-cov + pytest-benchmark + pytest-timeout +commands = + pytest --no-window {tty:--color=yes} {posargs}