diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0372651 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf + +*.absp text linguist-language=JSON linguist-detectable=true diff=json diff --git a/.github/workflows/pr-target-guard.yml b/.github/workflows/pr-target-guard.yml index 0b5eaf7..359c825 100644 --- a/.github/workflows/pr-target-guard.yml +++ b/.github/workflows/pr-target-guard.yml @@ -6,7 +6,7 @@ on: permissions: contents: read - pull-requests: read + pull-requests: write issues: write jobs: @@ -38,7 +38,7 @@ jobs: - name: Comment warning on PR if: steps.check.outputs.WARN == 'true' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/release-version-guard.yml b/.github/workflows/release-version-guard.yml new file mode 100644 index 0000000..84504a6 --- /dev/null +++ b/.github/workflows/release-version-guard.yml @@ -0,0 +1,110 @@ +name: Release Version Guard + +on: + pull_request: + types: [opened, reopened, synchronize, edited, labeled, unlabeled] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + check-version-bump: + name: Check Version Bump + runs-on: ubuntu-latest + if: github.event.pull_request.base.ref == 'main' + + steps: + - name: Determine release label + id: label + run: | + HAS_RELEASE_LABEL=false + + for label in ${{ join(github.event.pull_request.labels.*.name, ' ') }}; do + if [ "$label" = "release" ]; then + HAS_RELEASE_LABEL=true + fi + done + + echo "Has release label: $HAS_RELEASE_LABEL" + echo "release=$HAS_RELEASE_LABEL" >> "$GITHUB_OUTPUT" + + - name: Checkout code + if: steps.label.outputs.release == 'true' + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Compare versions + if: steps.label.outputs.release == 'true' + id: compare + run: | + get_version() { + git show "$1:engine/version.py" \ + | sed -n 's/^__version__[[:space:]]*=[[:space:]]*["'"'"']\(.*\)["'"'"'].*/\1/p' + } + + OLD_VERSION=$(get_version "${{ github.event.pull_request.base.sha }}") + NEW_VERSION=$(get_version HEAD) + + echo "Base (main) version: '$OLD_VERSION'" + echo "PR version: '$NEW_VERSION'" + + if [ -z "$NEW_VERSION" ]; then + echo "Could not read __version__ from engine/version.py" + exit 1 + fi + + if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then + echo "bumped=false" >> "$GITHUB_OUTPUT" + else + echo "bumped=true" >> "$GITHUB_OUTPUT" + fi + + echo "old_version=$OLD_VERSION" >> "$GITHUB_OUTPUT" + + - name: Request changes if version not bumped + if: steps.label.outputs.release == 'true' && steps.compare.outputs.bumped == 'false' + uses: actions/github-script@v8 + with: + script: | + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + event: "REQUEST_CHANGES", + body: "@${{ github.event.pull_request.user.login }} This **release** PR does not bump `engine/version.py` (still `${{ steps.compare.outputs.old_version }}`). Update `__version__` before merging." + }); + + - name: Dismiss prior request-changes review once bumped + if: steps.label.outputs.release == 'true' && steps.compare.outputs.bumped == 'true' + uses: actions/github-script@v8 + with: + script: | + const pull_number = context.payload.pull_request.number; + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number, + }); + for (const review of reviews) { + if ( + review.user.login === "github-actions[bot]" && + review.state === "CHANGES_REQUESTED" + ) { + await github.rest.pulls.dismissReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number, + review_id: review.id, + message: "`engine/version.py` bumped — clearing the version guard.", + }); + } + } + + - name: Fail if version not bumped + if: steps.label.outputs.release == 'true' && steps.compare.outputs.bumped == 'false' + run: | + echo "Release PR must bump engine/version.py before merging." + exit 1 diff --git a/AGENTS.md b/AGENTS.md index a436b65..fb40327 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,6 @@ -See ABS Engine's [AI Policy](CONTRIBUTING.md#ai-policy) for more information on +ABS Engine has a strict AI policy. + +See [the AI Policy](CONTRIBUTING.md#ai-policy) for more information on how AI may be used. diff --git a/docs/scripting.md b/docs/scripting.md index a695bc7..c73219d 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -88,6 +88,7 @@ The `engine.core.Entity` class has the following properties: | `id` | Unique entity UUID | `str` | | `get_colliding_entities` | Return a list of colliding entities | `Callable[[], list[Entity]]` | | `destroy` | Destroy the entity | `Callable[[], None]` | +| `visible` | Weather to draw the entity or not | `bool` | ## Script Functions diff --git a/engine/__init__.py b/engine/__init__.py index a06b83c..d6beb19 100644 --- a/engine/__init__.py +++ b/engine/__init__.py @@ -4,3 +4,7 @@ """ Core engine package """ + +import colorama + +colorama.just_fix_windows_console() diff --git a/engine/core/__init__.py b/engine/core/__init__.py index 0b5ca1f..cd7a6ba 100644 --- a/engine/core/__init__.py +++ b/engine/core/__init__.py @@ -14,6 +14,7 @@ import tkinter.messagebox import uuid import os +import colorama from typing import Optional, Any, Union @@ -24,6 +25,7 @@ print( f"ABS Engine v{version} (Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}, pygame {pygame.ver})" + "\n" ) @@ -55,6 +57,8 @@ def __init__( image (Optional[str]): Path to optional image file. Defaults to None. """ + self.visible = True + self.x: int = x self.y: int = y self.width: int = width @@ -139,6 +143,16 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} at {hex(id(self))} with id {self.id}>" + def __del__(self) -> None: + """ + Destructor for the entity. + """ + + try: + self.destroy() + except ValueError: + logger("Failed to destroy entity", status=LoggerStatus.WARNING) + def _collides_with(self, other: "Entity") -> bool: """ Check if this entity collides with another entity using AABB collision detection. @@ -161,6 +175,18 @@ def _setparent(self, parent: "Scene") -> None: """ self.parent = parent + def center(self, pos: tuple[int, int]) -> None: + """ + Center the entity on a position. + + Args: + pos (tuple[int, int]): The (x, y) point to center the entity on. + """ + + self.x = pos[0] - self.width // 2 + self.y = pos[1] - self.height // 2 + self._update_rect() + def init(self) -> None: """ Call the init function in the script file if it exists. @@ -215,10 +241,11 @@ def draw(self, surface: pygame.Surface) -> None: surface (pygame.Surface): The surface to draw the entity on. """ - if self.image is not None: - self.image.draw(surface, self.rect) - else: - pygame.draw.rect(surface, self.color, self.rect) + if self.visible: + if self.image is not None: + self.image.draw(surface, self.rect) + else: + pygame.draw.rect(surface, self.color, self.rect) def get_colliding_entities(self) -> list["Entity"]: """ @@ -235,14 +262,17 @@ def get_colliding_entities(self) -> list["Entity"]: def destroy(self) -> None: """ - Destroy this entity + Destroy this entity. + + Raises: + ValueError: If the entity cannot be removed from its parent. """ if self.parent is not None: try: self.parent.remove(self) - except ValueError: - logger("Invalid target for destruction", status=LoggerStatus.WARNING) + except ValueError as e: + raise ValueError("Invalid target for destruction") from e self.parent = None @@ -398,6 +428,9 @@ def __init__( self.screen: pygame.Surface = pygame.display.set_mode(self.wsize, display_flags) pygame.display.set_caption(title) + if sys.stdout.isatty(): + print(colorama.ansi.set_title(title), end="") + self.set_icon(icon_path) pygame.mouse.set_visible(cursor_visible) diff --git a/engine/core/text.py b/engine/core/text.py index de990aa..cd129c9 100644 --- a/engine/core/text.py +++ b/engine/core/text.py @@ -74,11 +74,20 @@ def _update_text_surface(self) -> None: text, color, bgcolor, antialias, and position. """ - self.text_surface = self.font.render( - self.text, antialias=self.antialias, color=self.color, bgcolor=self.bgcolor - ) + self.text_surface = self.font.render(self.text, self.antialias, self.color, self.bgcolor) self.text_rect = self.text_surface.get_rect(x=self.x, y=self.y) + def center(self, pos: tuple[int, int]) -> None: + """ + Center the text on a position and rebuild its rendered surface. + + Args: + pos (tuple[int, int]): The (x, y) point to center the text on. + """ + + super().center(pos) + self._update_text_surface() + def draw(self, surface: pygame.Surface) -> None: """ Draw the rendered text onto the given surface. @@ -87,7 +96,8 @@ def draw(self, surface: pygame.Surface) -> None: surface (pygame.Surface): The surface to draw the text on. """ - if self.dynamic: - self._update_text_surface() + if self.visible: + if self.dynamic: + self._update_text_surface() - surface.blit(self.text_surface, self.text_rect) + surface.blit(self.text_surface, self.text_rect) diff --git a/engine/core/types.py b/engine/core/types.py index 49ece87..281b591 100644 --- a/engine/core/types.py +++ b/engine/core/types.py @@ -7,11 +7,13 @@ import pygame -from typing import Protocol +from typing import Union, Protocol from .image import EntityImage -RGBType = tuple[int, int, int] +RGBType = Union[ + tuple[int, int, int], pygame.Color # Add pygame.Color for type checkers +] EntityImageType = EntityImage diff --git a/engine/logger.py b/engine/logger.py index d447805..8028cae 100644 --- a/engine/logger.py +++ b/engine/logger.py @@ -6,6 +6,7 @@ """ import inspect +import sys from enum import Enum from typing import Any @@ -52,10 +53,17 @@ def logger(message: str, *, status: Status = Status.INFO) -> None: """ source = _get_caller_module().upper() + is_tty: bool = sys.stdout.isatty() - if status == Status.CRITICAL: - print(Fore.RED, end="") - elif status == Status.WARNING: - print(Fore.YELLOW, end="") + if is_tty: + if status == Status.CRITICAL: + print(Fore.RED, end="") + elif status == Status.WARNING: + print(Fore.YELLOW, end="") - print(f"({status.value}) {source}: {message}{Style.RESET_ALL}") + print(f"({status.value}) {source}: {message}", end="") + + if is_tty: + print(Style.RESET_ALL, end="") + + print()