diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..24251cd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.py] +max_line_length = 100 + +[*.{nut,tcl}] +max_line_length = 100 + +[*.{yml,yaml,json,toml}] +indent_size = 2 + +[*.md] +max_line_length = 81 +trim_trailing_whitespace = false + +[*.{cfg,ini,flake8}] +indent_size = 4 diff --git a/.flake8 b/.flake8 index 534ba8a..4054c1e 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,5 @@ [flake8] ignore = E501, W503 exclude = - .venv \ No newline at end of file + .venv + .env diff --git a/.gitattributes b/.gitattributes index 0372651..b4f5827 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,12 @@ * text=auto eol=lf *.absp text linguist-language=JSON linguist-detectable=true diff=json + +.github export-ignore +.vscode export-ignore +.editorconfig export-ignore +.flake8 export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.markdownlint-cli2.yaml export-ignore +AGENTS.md export-ignore diff --git a/.gitignore b/.gitignore index 747d3c8..5384ee8 100644 --- a/.gitignore +++ b/.gitignore @@ -190,7 +190,9 @@ cython_debug/ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder -.vscode/ +# (shared workspace config is un-ignored below) +.vscode/* +!.vscode/extensions.json # Ruff stuff: .ruff_cache/ @@ -213,7 +215,5 @@ __marimo__/ # Agents .agents/ -### ABS Specific ### - -# ABS cache files -abs_cache/ +# Compiled engine C +engine/c/build/ diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 3418d2c..f547e3b 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -1,2 +1,9 @@ ignores: - "LICENSE" + - ".venv/**" + +globs: + - "**/*.{md,markdown}" + +config: + MD013: false diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..ceb8450 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,17 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + + "charliermarsh.ruff", + "astral-sh.ty", + "ms-python.mypy-type-checker", + "ms-python.flake8", + "nwgh.bandit", + "kennethlove.interrogate", + "DavidAnson.vscode-markdownlint", + + "marcinbar.vscode-squirrel", + "rashwell.tcl" + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1e986f..ce15d72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ Contributions of all types are welcome, including: Please read this document before submitting changes. -> [!Note] +> [!NOTE] > All contributions should target the `dev` branch. Changes are reviewed and > tested there before being merged into `main` for releases. @@ -98,6 +98,7 @@ All contributions should prioritize: provided they do not produce large or significant changes. - **Trivial Edits Allowed:** Very small, obvious changes such as typo fixes, whitespace cleanup, or minor wording updates are permitted. +- **Commit messages allowed**: Generated commit messages are allowed as long as they are an accurate description of the change being made. - **Human Responsibility:** All contributions must be reviewed, tested, and approved by a human author. @@ -115,7 +116,7 @@ All contributions should prioritize: - **Trivial Changes Only:** Very small edits are allowed. -This AI Policy is derived from [Gravel's AI Policy](https://github.com/Pacsfury/Gravel-Launcher/blob/main/AI-POLICY.md). +This AI Policy is derived from [Gravel's AI Policy](https://github.com/Pacsfury/Gravel-Launcher/blob/ed0301ba7aa82342ac937cf1d149d68d00008724/AI-POLICY.md). --- diff --git a/MANIFEST.in b/MANIFEST.in index fd8505b..c9d8030 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include engine/py.typed recursive-include engine/tcl *.tcl recursive-include engine/nut *.nut +recursive-include engine/c *.c *.h diff --git a/docs/README.md b/docs/README.md index 5ae0b3c..d4041ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,3 +3,9 @@ This folder contains the documentation for ABS Engine. Documentation may include guides, tutorials, references, examples, and other resources related to using and developing games with ABS Engine. + +> [!TIP] +> If you're new to ABS Engine, check out: +> +> * [Scripting Docs](scripting.md) +> * [Build Tools Docs](using_build_tools.md) diff --git a/docs/accessing_the_console.md b/docs/accessing_the_console.md index 8cc0b33..68f38a8 100644 --- a/docs/accessing_the_console.md +++ b/docs/accessing_the_console.md @@ -14,13 +14,13 @@ python3 run.pyw Make sure you run it with the `python3` or `py` command. Directly using the file path will not show the console. -Bad Example: +Don't do this: ```powershell & "C:/Users/User/ABS-Engine/run.pyw" ``` -*Good* Example: +Do this: ```powershell py C:/Users/User/ABS-Engine/run.pyw diff --git a/docs/debugging_games.md b/docs/debugging_games.md index 61f9682..9288b36 100644 --- a/docs/debugging_games.md +++ b/docs/debugging_games.md @@ -54,13 +54,13 @@ Here are the main parts of the message: ``` **Type**: The severity of the message (can be "INFO", "WARNING", or "CRITICAL") -**Source**: Shows which module the message originated from. In this example, the message came from `engine/core.py`. +**Source**: Shows which module the message originated from. In this example, the message came from `engine/core/__init__.py`. **Message**: The message being printed Example of a critical error message: ```text -(CRITICAL) ENGINE.GUI: Initialized game +(CRITICAL) ENGINE.GUI: Could not load icon image. | | | | |______ |_______ | |Source| |Message| diff --git a/docs/errors.md b/docs/errors.md index 6ef397b..1463cc4 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -4,8 +4,7 @@ This document describes ABS Engine's error module (`engine.core.errors`). ## Overview -`engine.core.errors` is not only for fatal, unrecoverable errors. It also will -define precise, engine-specific exception types for situations that +`engine.core.errors` is not only for fatal, unrecoverable errors. It will also define precise, engine-specific exception types for situations that could otherwise be mistaken for an ordinary, expected error (e.g. a generic `ValueError` or `KeyError`) but that the user of the engine should actually be catching and handling deliberately. Naming these @@ -41,7 +40,7 @@ def __init__(self, message: str) -> Never: ... from engine.core.errors import ABSFatalError if renderer_context_lost: - ABSFatalError("Lost graphics context, cannot continue") + raise ABSFatalError("Lost graphics context, cannot continue") ``` Do **not** wrap this in a `try`/`except` expecting to recover. It's diff --git a/docs/game_file_structure.md b/docs/game_file_structure.md index 9a05aa3..ad5e81c 100644 --- a/docs/game_file_structure.md +++ b/docs/game_file_structure.md @@ -18,7 +18,7 @@ MyGame ``` Do not create game.absp, as it is generated by ABS Engine. -It contains the project name, game settings, and entity names, +It contains the project name, game settings, entity names, properties, and data. ## Game Data Structure diff --git a/docs/music.md b/docs/music.md new file mode 100644 index 0000000..4865b0a --- /dev/null +++ b/docs/music.md @@ -0,0 +1,78 @@ +# Music + +Every [`Game`](using_the_engine_api.md) owns a small music mixer as +`game.music`. It plays one background track at a time, which is what +background music usually is: a single looping track that changes when the +scene or the mood does. + +From a [script](scripting.md), reach it through the entity's scene: + +```python +def init(entity): + game = entity.parent.game + game.music.play("assets/theme.ogg") +``` + +> [!TIP] +> Music is not entity-specific, so start it from your main script. +> See the tip about main scripts in the [Main Scripts section of the scripting docs](scripting.md#main-scripts). + +Using the engine API directly: + +```python +from engine.core import Game + + +game = Game(GP_BASE_PATH=".") + +game.music.set_volume(0.5) +game.music.play("data/audio/theme.ogg") + +game.run() +``` + +## Methods + +| Method | Description | +| --- | --- | +| `play(track, *, loops=-1, fade_ms=0)` | Start `track`, replacing whatever was playing. `track` is a path relative to the project root. `loops` is how many extra times to repeat it, `-1` forever. `fade_ms` fades the track in. | +| `stop(*, fade_ms=0)` | Stop the current track and forget it. `fade_ms` fades it out instead of cutting it. | +| `pause()` | Hold the current track where it is. | +| `resume()` | Carry on with a paused track. | +| `set_volume(volume)` | Set how loud music plays, from `0.0` to `1.0`. Values outside that range are clamped. Applies to later tracks too. | +| `get_volume()` | Return the volume music is set to play at, from `0.0` to `1.0`. | +| `is_playing()` | Return whether a track is audible right now. A paused track is not playing. | + +## Properties + +| Property | Description | Type | +| --- | --- | --- | +| `track` | Path of the loaded track, or `None` when nothing is loaded | `Optional[str]` | +| `available` | Whether an audio device was opened | `bool` | +| `base_path` | Project root that `track` paths are given relative to | `str` | + +## Formats + +Playable formats come from pygame, which uses SDL_mixer. OGG and WAV are the +safe choices; MP3 support depends on how the player's SDL_mixer was built. +Prefer OGG for music, since it is compressed and always supported. + +## Machines Without Audio + +Some machines have no working audio device: a headless build server, a +container, or a desktop with sound disabled. Rather than crash a game over +it, the mixer logs a warning at startup and sets `available` to `False`. +Every method stays safe to call, and none of them do anything: + +```python +def init(entity): + music = entity.parent.game.music + + music.play("data/audio/theme.ogg") # Fine. Silent, but fine. + + if not music.available: + entity.parent.game.gamedata["subtitles"] = True +``` + +A missing or unreadable track file is handled the same way: the load is +logged as a warning and whatever was already playing keeps playing. diff --git a/docs/scenes.md b/docs/scenes.md index 938d58f..25ae8a6 100644 --- a/docs/scenes.md +++ b/docs/scenes.md @@ -12,6 +12,7 @@ Scenes are managed by `engine.core.Game`, and each scene is represented by Every `Game` has: - `game.scenes`: A list of all scenes in the game. +- `game.music`: See [this article](music.md) - `game.current_scene`: The index of the scene currently being shown. - `game.add_scene()`: A method that creates a new scene and returns its index. - `game.switch_scene(scene_index)`: A method that changes the active scene. diff --git a/docs/scripting.md b/docs/scripting.md index c73219d..aa3cc9d 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -5,6 +5,12 @@ With scripting, you can move and manipulate objects, create animations, and much In other words, scripting allows you to make entities in your game _do things_. Here's how to get started: +> [!TIP] +> If you're planning on using the engine API, see: +> +> * [Using the Engine API Directly](using_the_engine_api.md) +> * [OSE Docs](ose.md) + To create a script file, first follow the [recommended file structure](game_file_structure.md). The `assets/` folder is not needed for this tutorial. @@ -23,7 +29,7 @@ Then, launch ABS Engine and follow these steps: 6. Click "Save", then "Save Project" 7. Verify that `game.absp` was saved in your project folder ->[!Tip] +> [!TIP] > Name your scripts after the entity they're attached to. > This makes it easy to identify them later. @@ -41,18 +47,6 @@ def update(entity, dt): Go back to ABS Engine and click "Run". You should see a white square moving continuously from left to right. ->[!Tip] -> Because the engine requires a script to -> be attached to an entity in your game, -> put everything in the game that is not -> entity-specific (e.g. playing background -> music, etc.) in the script attached to -> the player entity (The entity that the -> player controls). This is called a main -> script. If your game does not have a player -> entity, place a new entity off screen that -> has the main script attached to it. - Let's break down what this code does. In a script file, three functions are commonly defined: @@ -63,7 +57,9 @@ event(entity: Entity, event: pygame.event.Event) -> None ``` `init()` - Called once when the game starts + `update()` - Called every frame (multiple times per second) + `event()` - Called when a pygame event is triggered ## Entity Properties @@ -82,7 +78,7 @@ The `engine.core.Entity` class has the following properties: | `width` | Width in pixels | `float` | | `height` | Height in pixels | `float` | | `color` | RGB color value | `tuple[int, int, int]` | -| `rect` | Pygame rect object on screen | `pygame.Rect` | +| `rect` | Pygame rect object on screen | `pygame.FRect` | | `scriptfile` | Path to the attached script | `str` | | `image` | Image attached to entity | `EntityImage` or `None` | | `id` | Unique entity UUID | `str` | @@ -94,9 +90,9 @@ The `engine.core.Entity` class has the following properties: The `init()`, `update()`, and `event()` functions are callback functions that ABS Engine calls at specific times: -- `init(entity: Entity) -> None` - Called when the game starts -- `update(entity: Entity, dt: float) -> None` - Called every frame -- `event(entity: Entity, event: pygame.event.Event) -> None` - Called when an a pygame event occurs +* `init(entity: Entity) -> None` - Called when the game starts +* `update(entity: Entity, dt: float) -> None` - Called every frame +* `event(entity: Entity, event: pygame.event.Event) -> None` - Called when an a pygame event occurs Here's an example script that creates a simple game with player movement. The game uses a top-down perspective with a player-controlled square @@ -151,3 +147,17 @@ def event(entity, event): > If the game window freezes or shows a black screen at startup, > check that your script file has no syntax errors. It is completely normal for a game to crash if there are code errors. > See [Debugging Games](debugging_games.md) for help diagnosing issues. + +## Main Scripts + +> [!TIP] +> Because the engine requires a script to +> be attached to an entity in your game, +> put everything in the game that is not +> entity-specific (e.g. playing background +> music, etc.) in the script attached to +> the player entity (The entity that the +> player controls). This is called a main +> script. If your game does not have a player +> entity, place a new entity off screen that +> has the main script attached to it. diff --git a/docs/using_build_tools.md b/docs/using_build_tools.md index 5a4432d..4c59348 100644 --- a/docs/using_build_tools.md +++ b/docs/using_build_tools.md @@ -9,7 +9,7 @@ Click "Build Game", then "Yes". ABS Engine will now compile the game and all of its dependencies to the folder that contains the project file, using Pyinstaller under the hood. A "Building Game" window stays open with a progress -bar while this happens, and only closes once Pyinstaller has +bar and log while this happens, and only closes once Pyinstaller has actually finished. When you run the new `run.py` file in that folder, diff --git a/docs/using_the_engine_api.md b/docs/using_the_engine_api.md index 4ce2062..a1c18c8 100644 --- a/docs/using_the_engine_api.md +++ b/docs/using_the_engine_api.md @@ -11,6 +11,31 @@ This is useful for prototypes, procedurally generated games, or tooling built on top of ABS Engine, where driving everything from code is more convenient than maintaining a project file. +## Installing the Engine + +When the editor builds a project it copies the `engine/` package into the +game's own directory, so a generated game finds the engine sitting next to +it. Nothing does that for you if you aren't using the editor, and doing it by +hand is awkward to keep in sync. Install ABS Engine as a dependency instead: +put it on the first line of your game's `requirements.txt`, above whatever +else your game needs. + +```requirements +git+https://github.com/Natuworkguy/ABS-Engine.git +``` + +Then set your game up the same way as any other Python project: + +```bash +pip install -r requirements.txt +``` + +>[!IMPORTANT] +> Installed as a dependency, the package is imported as `abs_engine` +> (`from abs_engine.core import Entity, Game`). The plain `engine` name used +> in the examples below is the copy the editor drops next to a generated +> game. + ## A Minimal Game ```python diff --git a/engine/__main__.py b/engine/__main__.py index 99b35a7..99f68af 100644 --- a/engine/__main__.py +++ b/engine/__main__.py @@ -7,7 +7,10 @@ from .logger import Status, logger +package = __package__ or "engine" + logger( - "The engine module cannot be run directly to launch the GUI. You might be trying to run engine.gui.", + f"The {package} module cannot be run directly to launch the GUI. " + f"You might be trying to run {package}.gui.", status=Status.CRITICAL, ) diff --git a/engine/build_tools.py b/engine/build_tools.py index 63c02b6..3ae36c7 100644 --- a/engine/build_tools.py +++ b/engine/build_tools.py @@ -26,10 +26,27 @@ class _QueueWriter: """A writable stream that forwards each written line to a Queue.""" def __init__(self, log_queue: "Queue[Optional[str]]") -> None: + """ + Wrap a queue in a writable stream. + + Args: + log_queue (Queue[Optional[str]]): Queue that receives each completed line. + """ + self._queue = log_queue self._buffer = "" def write(self, text: str) -> int: + """ + Buffer text, forwarding each line to the queue once it is complete. + + Args: + text (str): Text written to the stream. + + Returns: + int: Number of characters written, as a writable stream is expected to report. + """ + self._buffer += text while "\n" in self._buffer: @@ -39,21 +56,53 @@ def write(self, text: str) -> int: return len(text) def flush(self) -> None: - pass + """ + Do nothing. Lines reach the queue as soon as they complete, so nothing + is ever held back waiting to be flushed. + """ def _clear_readonly(func: Any, path: Any, exc: BaseException) -> None: + """ + Clear a path's read-only bit and retry the removal that failed on it. + + Passed to shutil.rmtree as its error handler. This is what lets a previous + build be deleted on Windows, where PyInstaller leaves read-only files behind. + + Args: + func (Any): The removal function that failed, called again once the + permission has been changed. + path (Any): Path that could not be removed. + exc (BaseException): Exception func raised. Unused, but part of the + handler signature rmtree calls back with. + """ + os.chmod(path, stat.S_IWRITE) func(path) def _remove_previous_build(path: Path, retries: int = 5, delay: float = 0.5) -> None: + """ + Delete a previous build directory, waiting out any lock still held on it. + + A build that has only just finished can keep files open for a moment, so a + PermissionError is retried rather than treated as fatal straight away. + + Args: + path (Path): Build directory to remove. A missing path is ignored. + retries (int): How many removal attempts to make. Defaults to 5. + delay (float): Seconds to wait between attempts. Defaults to 0.5. + + Raises: + PermissionError: If the directory is still locked after the final attempt. + """ + if not path.exists(): return for attempt in range(retries): try: - shutil.rmtree(path, onexc=_clear_readonly) # ty: ignore[unknown-argument] + shutil.rmtree(path, onexc=_clear_readonly) # pyright: ignore[reportCallIssue] # ty: ignore[unknown-argument] return except PermissionError: if attempt == retries - 1: @@ -62,9 +111,19 @@ def _remove_previous_build(path: Path, retries: int = 5, delay: float = 0.5) -> def _build_pyinstaller(name: str, directory: Path, log_queue: "Queue[Optional[str]]") -> None: - # Redirect output before importing PyInstaller: its logging setup binds a - # handler to sys.stderr at import time, so the import must happen after - # the streams are replaced for build output to reach the log_queue. + """ + Run PyInstaller over a prepared project directory, reporting progress. + + Meant to run in its own process, since it replaces the process-wide output + streams. A None item is put on the queue once the build ends, however it + ends, so a reader knows no more output is coming. + + Args: + name (str): Name to give the built executable. + directory (Path): Project directory holding game.absp and run.py. + log_queue (Queue[Optional[str]]): Queue that receives PyInstaller's output. + """ + sys.stdout = _QueueWriter(log_queue) sys.stderr = _QueueWriter(log_queue) @@ -86,6 +145,8 @@ def _build_pyinstaller(name: str, directory: Path, log_queue: "Queue[Optional[st "--specpath", str(directory), f"--add-data={directory / 'game.absp'!s}{os.pathsep}.", + f"--add-data={directory / 'engine' / 'nut'}{os.pathsep}engine/nut/", + f"--add-data={directory / 'engine' / 'tcl'}{os.pathsep}engine/tcl/", ] if (directory / "scripts").exists(): diff --git a/engine/c/mathutil.c b/engine/c/mathutil.c new file mode 100644 index 0000000..7db687a --- /dev/null +++ b/engine/c/mathutil.c @@ -0,0 +1,16 @@ +// Copyright (C) Natuworkguy +// See the LICENSE file for GPLv3 + +#include "mathutil.h" + +double clamp(double value, double low, double high) { + if (value < low) { + return low; + } + + if (value > high) { + return high; + } + + return value; +} diff --git a/engine/c/mathutil.h b/engine/c/mathutil.h new file mode 100644 index 0000000..85b401b --- /dev/null +++ b/engine/c/mathutil.h @@ -0,0 +1,4 @@ +// Copyright (C) Natuworkguy +// See the LICENSE file for GPLv3 + +double clamp(double value, double low, double high); diff --git a/engine/core/__init__.py b/engine/core/__init__.py index 5a390c5..5a3fc37 100644 --- a/engine/core/__init__.py +++ b/engine/core/__init__.py @@ -13,7 +13,6 @@ import pygame import importlib.util import sys -import tkinter.messagebox import uuid import os import colorama @@ -22,9 +21,11 @@ from ..logger import logger, Status as LoggerStatus from .image import EntityImage +from .animation import EntityAnim +from .music import Music from .errors import ABSFatalError from .utils import clamp -from .types import RGBType, EntityImageType +from .types import RGBType, EntityMediaType from ..version import __version__ as version print( @@ -40,10 +41,10 @@ class Entity: def __init__( self, - x: int = 0, - y: int = 0, - width: int = 50, - height: int = 50, + x: float = 0.0, + y: float = 0.0, + width: float = 50.0, + height: float = 50.0, color: RGBType = (255, 255, 255), scriptfile: Optional[str] = None, image: Optional[str] = None, @@ -52,10 +53,10 @@ def __init__( Initialize an entity Args: - x (int): X position. Defaults to 0. - y (int): Y position. Defaults to 0. - width (int): Width of the entity. Defaults to 50. - height (int): Height of the entity. Defaults to 50. + x (float): X position. Defaults to 0. + y (float): Y position. Defaults to 0. + width (float): Width of the entity. Defaults to 50. + height (float): Height of the entity. Defaults to 50. color (RGBType): RGB color value. Defaults to (255, 255, 255). scriptfile (Optional[str]): Path to optional script file. Defaults to None. image (Optional[str]): Path to optional image file. Defaults to None. @@ -63,17 +64,17 @@ def __init__( self.visible = True - self.x: int = x - self.y: int = y - self.width: int = width - self.height: int = height + self.x: float = x + self.y: float = y + self.width: float = width + self.height: float = height self.color: RGBType = ( int(clamp(color[0], 0, 255)), int(clamp(color[1], 0, 255)), int(clamp(color[2], 0, 255)), ) - self.rect: pygame.Rect = pygame.Rect(self.x, self.y, self.width, self.height) + self.rect: pygame.FRect = pygame.FRect(self.x, self.y, self.width, self.height) self.id: str = str(uuid.uuid4()) self.parent: Optional["Scene"] = None @@ -88,19 +89,27 @@ def __init__( self.did_init: bool = False - self.image: Optional[EntityImageType] = None + self.image: Optional[EntityMediaType] = None if image is not None: try: - self.image = EntityImage(image) - except pygame.error as e: - logger(f"Failed to load image '{image}': {str(e)}", status=LoggerStatus.WARNING) - except FileNotFoundError as e: - tkinter.messagebox.showerror("File not found", str(e)) + if image.lower().endswith((".png", ".jpg", ".jpeg", ".bmp")): + self.image = EntityImage(image) + else: + self.image = EntityAnim(image) + except (pygame.error, FileNotFoundError) as e: + logger( + f"Failed to load image or animation '{image}': {str(e)}", + status=LoggerStatus.WARNING, + ) if scriptfile is not None: esfid = f"esf-{self.id}" + script_dir = str(Path(scriptfile).resolve().parent) + if script_dir not in sys.path: + sys.path.append(script_dir) + spec: Optional[ModuleSpec] = importlib.util.spec_from_file_location(esfid, scriptfile) if spec: @@ -111,12 +120,12 @@ def __init__( try: spec.loader.exec_module(self.scriptfile_module) except FileNotFoundError: - tkinter.messagebox.showerror( - "Error", + logger( f'Script file "{scriptfile}" not found. Please ensure the file exists and try again.', + status=LoggerStatus.CRITICAL, ) except ImportError as e: - tkinter.messagebox.showerror("Error", f"Error when loading script: {e}") + logger(f"Error when loading script: {e}", status=LoggerStatus.CRITICAL) if self.scriptfile_module is not None: if self.scriptfile is not None: @@ -149,7 +158,9 @@ def __repr__(self) -> str: str: Debug representation of the entity. """ - return f"<{self.__class__.__name__} at {hex(id(self))} with id {self.id}>" + addr: str = "0x" + hex(id(self))[2:].upper() + + return f"<{self.__class__.__name__} at {addr} with id {self.id}>" def __del__(self) -> None: """ @@ -183,12 +194,12 @@ def _setparent(self, parent: "Scene") -> None: """ self.parent = parent - def center(self, pos: tuple[int, int]) -> None: + def center(self, pos: tuple[float, float]) -> None: """ Center the entity on a position. Args: - pos (tuple[int, int]): The (x, y) point to center the entity on. + pos (tuple[float, float]): The (x, y) point to center the entity on. """ self.x = pos[0] - self.width // 2 @@ -204,6 +215,7 @@ def init(self) -> None: if self.scriptfile_module is not None: if self.scriptfile_funcs["init"]: self.scriptfile_module.init(self) + self._update_rect() self.did_init = True def _update_rect(self) -> None: @@ -332,7 +344,8 @@ def __init__(self, *, parent: "Game") -> None: def _get_colliding_entities(self, entity: Entity) -> list[Entity]: """ - Internal collision query used by Entity.get_colliding_entities(). + Internal collision query used by + :meth:`~engine.core.Entity.get_colliding_entities`. Args: entity (Entity): Entity to evaluate collisions for. @@ -424,8 +437,8 @@ def __init__( self, title: str = "Game", /, - width: int = 800, - height: int = 600, + width: float = 800, + height: float = 600, *, GP_BASE_PATH: str, cursor_visible: bool = True, @@ -438,8 +451,8 @@ def __init__( Args: title (str): Window title. Defaults to "Game". - width (int): Window width in pixels. Defaults to 800. - height (int): Window height in pixels. Defaults to 600. + width (float): Window width in pixels. Defaults to 800. + height (float): Window height in pixels. Defaults to 600. cursor_visible (bool): Whether the mouse cursor is visible. Defaults to True. fullscreen (bool): Whether to start in fullscreen mode. Defaults to False. icon_path (str | Path | None): Path to window icon image. Defaults to None. @@ -457,8 +470,9 @@ def __init__( pygame.init() self.GP_BASE_PATH: str = GP_BASE_PATH + self.music: Music = Music(GP_BASE_PATH) display_flags: int = pygame.FULLSCREEN if fullscreen else 0 - self.wsize: tuple[int, int] = (width, height) + self.wsize: tuple[float, float] = (width, height) if width < 0 or height < 0: raise ABSFatalError("Window width and height must be positive") diff --git a/engine/core/animation.py b/engine/core/animation.py new file mode 100644 index 0000000..7acd0ff --- /dev/null +++ b/engine/core/animation.py @@ -0,0 +1,169 @@ +# Copyright (C) Natuworkguy +# See the LICENSE file for GPLv3 + +""" +Animation handling utilities for engine entities. +""" + +import bisect +import pygame + +from array import array + +from typing import Optional, Union + +from ..loaders.nut_loader import nut_source, nut_call_function + +nut_source("anim.nut") + + +class EntityAnim: + """ + Manage an animation for an :class:`~engine.core.Entity`. + + Stands in for :class:`~engine.core.image.EntityImage` + when an entity should show a moving image (GIF or WebP) + instead of a still one. It offers the same three methods, so an + entity holding one needs no special handling, and frames advance off the + clock on their own: drawing it is all the caller has to do. The animation + loops for as long as it keeps being drawn, at the pace the file asks for. + + Pass ``loop=False`` for a one-shot animation, such as a jump: it plays + through once, then holds its last frame and reports ``finished``, so the + caller can swap in another animation or leave the pose standing. Calling + ``restart`` plays it again from the top, which is how the same jump is + re-triggered each time the entity leaves the ground. + """ + + frames: list[pygame.Surface] + + def __init__(self, anim_path: str, loop: bool = True) -> None: + """ + Initialize the EntityAnim by loading the animation at ``anim_path``. + + Args: + anim_path (str): The path to the animation file. + loop (bool): Whether the animation repeats. Defaults to True. + """ + + self.frames = [] + self.loop: bool = loop + + self._starts: array[float] = array("d") + self._duration: float = 0.0 + self._started_at: int = 0 + + self._scaled: Optional[pygame.Surface] = None + self._scaled_key: Optional[tuple[int, float, float]] = None + + self.set_image(anim_path) + + def set_image(self, anim_path: str, loop: Optional[bool] = None) -> None: + """ + Load ``anim_path`` and store it as an animation. + + The animation starts over from its first frame. + + Frame timings are worked out in Squirrel, in engine/nut/anim.nut + + Args: + anim_path (str): The path to the animation file. + loop (Optional[bool]): Whether the animation repeats. Keeps the + current setting when None. Defaults to None. + """ + + assert pygame.get_init(), ( # nosec B101 + "EntityAnim: pygame must be initialized before loading animations" + ) + + loaded: list[tuple[pygame.Surface, float]] = pygame.image.load_animation(anim_path) + + assert loaded, f'EntityAnim: "{anim_path}" holds no frames' # nosec B101 + + self.frames = [frame.convert_alpha() for frame, _ in loaded] + + if loop is not None: + self.loop = loop + + delays = [delay for _, delay in loaded] + timings = array( + "d", [float(t) for t in nut_call_function("frame_starts", delays, len(delays))] + ) + + self._starts = timings[:-1] + self._duration = timings[-1] + + self._started_at = pygame.time.get_ticks() + + self._scaled = None + self._scaled_key = None + + def restart(self) -> None: + """ + Play the animation again from its first frame. + + This is what re-triggers a one-shot animation: call it on every jump + rather than reloading the file each time. + """ + + self._started_at = pygame.time.get_ticks() + + @property + def finished(self) -> bool: + """ + Whether a one-shot animation has already played through its last frame. + + A looping animation never finishes, so this is always False for one. + + Returns: + bool: True once a non-looping animation has run its course. + """ + + if self.loop: + return False + + if self._duration <= 0.0: + return True + + return (pygame.time.get_ticks() - self._started_at) >= self._duration + + def _current_index(self) -> int: + """ + Work out which frame is due, from how long the animation has been running. + + Returns: + int: Index into ``frames`` of the frame to show right now. + """ + + if self._duration <= 0.0: + return 0 + + elapsed: float = float(pygame.time.get_ticks() - self._started_at) + + if not self.loop: + if elapsed >= self._duration: + return len(self.frames) - 1 + else: + elapsed %= self._duration + + return bisect.bisect_right(self._starts, elapsed) - 1 + + def draw(self, surface: pygame.Surface, rect: Union[pygame.Rect, pygame.FRect]) -> None: + """ + Draw the current frame of the animation scaled to ``rect`` onto ``surface``. + + Args: + surface (pygame.Surface): surface to draw onto + rect (pygame.Rect | pygame.FRect): rect to scale image to + """ + + assert self.frames, "EntityAnim.frames was not initialized" # nosec B101 + + index: int = self._current_index() + key: tuple[int, float, float] = (index, rect.width, rect.height) + + if key != self._scaled_key or self._scaled is None: + self._scaled = pygame.transform.scale(self.frames[index], (rect.width, rect.height)) + self._scaled_key = key + + surface.blit(self._scaled, (rect.x, rect.y)) diff --git a/engine/core/errors.py b/engine/core/errors.py index 99fecb4..3001ac5 100644 --- a/engine/core/errors.py +++ b/engine/core/errors.py @@ -1,3 +1,6 @@ +# Copyright (C) Natuworkguy +# See the LICENSE file for GPLv3 + """ABS Engine's error module.""" import faulthandler @@ -42,9 +45,9 @@ def __init__(self, message: str) -> Never: frame: FrameType = sys._getframe(1) - print(file=sys.stderr) + eprint() dis.disassemble(frame.f_code, frame.f_lasti, file=sys.stderr) - print(file=sys.stderr) + eprint() faulthandler.enable() os.abort() diff --git a/engine/core/image.py b/engine/core/image.py index 05f86f4..466bcef 100644 --- a/engine/core/image.py +++ b/engine/core/image.py @@ -7,19 +7,20 @@ import pygame -from typing import Optional +from typing import Optional, Union class EntityImage: """ - Manage a pygame image surface for an entity. + Manage a pygame image surface for an :class:`~engine.core.Entity`. """ surface: Optional[pygame.Surface] def __init__(self, image_path: str) -> None: """ - Initialize the EntityImage by loading the image at ``image_path``. + Initialize the :class:`~engine.core.image.EntityImage` by loading the + image at ``image_path``. Args: image_path (str): The path to the image file. @@ -43,13 +44,13 @@ def set_image(self, image_path: str) -> None: self.surface = pygame.image.load(image_path).convert_alpha() - def draw(self, surface: pygame.Surface, rect: pygame.Rect) -> None: + def draw(self, surface: pygame.Surface, rect: Union[pygame.Rect, pygame.FRect]) -> None: """ Draw the image scaled to ``rect`` onto ``surface``. Args: surface (pygame.Surface): surface to draw onto - rect (pygame.Rect): rect to scale image to + rect (pygame.Rect | pygame.FRect): rect to scale image to """ assert self.surface is not None, "EntityImage.surface was not initialized" # nosec B101 diff --git a/engine/core/music.py b/engine/core/music.py new file mode 100644 index 0000000..fca1f9a --- /dev/null +++ b/engine/core/music.py @@ -0,0 +1,148 @@ +# Copyright (C) Natuworkguy +# See the LICENSE file for GPLv3 + +""" +Background music playback for the engine. +""" + +import os + +import pygame + +from typing import Optional + +from ..logger import logger, Status as LoggerStatus + + +class Music: + """ + Plays one background track at a time. + + Every game owns one of these as ``game.music``. A machine without a working + audio device leaves it unavailable rather than failing: nothing plays, but + the game still runs and every method here stays safe to call. + """ + + def __init__(self, base_path: str) -> None: + """ + Open the mixer, if this machine has an audio device to open. + + Args: + base_path (str): Project root that track paths are given relative to. + """ + + self.base_path: str = base_path + self.available: bool = True + + self.track: Optional[str] = None + + self._volume: float = 1.0 + self._paused: bool = False + + try: + if pygame.mixer.get_init() is None: + pygame.mixer.init() + except pygame.error as e: + self.available = False + logger(f"No audio device available, music is off: {e}", status=LoggerStatus.WARNING) + + def play(self, track: str, *, loops: int = -1, fade_ms: int = 0) -> None: + """ + Start a track, replacing whatever was playing. + + Args: + track (str): Path to an audio file, relative to the project root. + loops (int): Extra times to repeat it. -1 repeats forever. Defaults to -1. + fade_ms (int): Milliseconds to fade in over. Defaults to 0. + """ + + if not self.available: + return + + try: + pygame.mixer.music.load(os.path.join(self.base_path, track)) + except (pygame.error, FileNotFoundError) as e: + logger(f'Could not load music "{track}": {e}', status=LoggerStatus.WARNING) + return + + self.track = track + self._paused = False + + pygame.mixer.music.set_volume(self._volume) + pygame.mixer.music.play(loops, fade_ms=fade_ms) + + def stop(self, *, fade_ms: int = 0) -> None: + """ + Stop the current track and forget it. + + Args: + fade_ms (int): Milliseconds to fade out over. Defaults to 0. + """ + + if not self.available: + return + + if fade_ms > 0: + pygame.mixer.music.fadeout(fade_ms) + else: + pygame.mixer.music.stop() + + self.track = None + self._paused = False + + def pause(self) -> None: + """ + Hold the current track where it is. Resuming picks it up from there. + """ + + if not self.available or self._paused: + return + + pygame.mixer.music.pause() + self._paused = True + + def resume(self) -> None: + """ + Carry on with a paused track. + """ + + if not self.available or not self._paused: + return + + pygame.mixer.music.unpause() + self._paused = False + + def set_volume(self, volume: float) -> None: + """ + Set how loud music plays, now and for tracks played later. + + Args: + volume (float): Loudness from 0.0 to 1.0. Values outside are clamped. + """ + + self._volume = max(0.0, min(1.0, float(volume))) + + if self.available: + pygame.mixer.music.set_volume(self._volume) + + def get_volume(self) -> float: + """ + Get how loud music is set to play. + + Returns: + float: Loudness from 0.0 to 1.0. + """ + + return self._volume + + def is_playing(self) -> bool: + """ + Check whether a track is audible right now. + + A paused track is not playing, though the mixer still holds it. + + Returns: + bool: True if a track is currently being heard. + """ + + return self.available and not self._paused and pygame.mixer.music.get_busy() diff --git a/engine/core/ose.py b/engine/core/ose.py index a2ee1b3..a4e18e4 100644 --- a/engine/core/ose.py +++ b/engine/core/ose.py @@ -13,11 +13,11 @@ def _script_defines(scriptobj: EntityScriptType, name: str) -> bool: """ - Check whether scriptobj itself defines `name`, rather than inheriting - it from Entity. Since script classes are commonly subclasses of Entity - (for typing convenience), a plain hasattr() check would also match - Entity's own init/update/event, causing infinite recursion when they - are dispatched. + Check whether scriptobj itself defines ``name``, rather than inheriting + it from :class:`~engine.core.Entity`. Since script classes are commonly + subclasses of :class:`~engine.core.Entity` (for typing convenience), a + plain hasattr() check would also match its own init/update/event, + causing infinite recursion when they are dispatched. Args: @@ -25,7 +25,7 @@ def _script_defines(scriptobj: EntityScriptType, name: str) -> bool: name (str): The name of the method to check for. Returns: - bool: True if scriptobj defines `name`, False otherwise. + bool: True if scriptobj defines ``name``, False otherwise. """ func: Optional[Callable] = getattr(scriptobj, name, None) @@ -39,16 +39,20 @@ class ObjectScriptEntity(Entity): def __new__(cls, *args: Any, scriptobj: EntityScriptType, **kwargs: Any) -> Any: """ - Create an Entity configured to dispatch lifecycle calls to ``scriptobj``. + Create an :class:`~engine.core.Entity` configured to dispatch lifecycle + calls to ``scriptobj``. Args: - *args (Any): Positional arguments forwarded to Entity. + *args (Any): Positional arguments forwarded to + Entity. scriptobj (EntityScriptType): Object script providing optional init, update, and event methods. - **kwargs (Any): Keyword arguments forwarded to Entity. + **kwargs (Any): Keyword arguments forwarded to + Entity. Returns: - Any: Entity instance with object-script dispatch metadata attached. + Any: :class:`~engine.core.Entity` instance with object-script + dispatch metadata attached. """ kwargs["scriptfile"] = None diff --git a/engine/core/text.py b/engine/core/text.py index cd129c9..d17f813 100644 --- a/engine/core/text.py +++ b/engine/core/text.py @@ -20,9 +20,9 @@ class Text(Entity): def __init__( self, - x: int = 0, - y: int = 0, text: str = "", + x: float = 0, + y: float = 0, size: int = 50, font: Optional[str] = None, color: RGBType = (255, 255, 255), @@ -36,9 +36,9 @@ def __init__( Text entities behave like any other entity, but they render a string of text instead of an image or colored box. Args: - x (int): X position. Defaults to 0. - y (int): Y position. Defaults to 0. text (str): The string to render. Defaults to "". + x (float): X position. Defaults to 0. + y (float): Y position. Defaults to 0. size (int): Font size in points. Defaults to 50. font (Optional[str]): Path to a font file. Defaults to None (pygame's default font). If the file cannot be found, Text falls back to @@ -51,9 +51,9 @@ def __init__( to avoid the per-frame re-render cost. Defaults to True. """ - self.x: int = x - self.y: int = y self.text: str = text + self.x: float = x + self.y: float = y self.color: RGBType = color self.bgcolor: RGBType = bgcolor self.antialias: bool = antialias @@ -66,7 +66,9 @@ def __init__( self._update_text_surface() - super().__init__(x, y, width=self.text_rect.width, height=self.text_rect.height) + super().__init__( + x, y, width=self.text_rect.width, height=self.text_rect.height, color=self.color + ) def _update_text_surface(self) -> None: """ @@ -75,14 +77,14 @@ def _update_text_surface(self) -> None: """ 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) + self.text_rect = self.text_surface.get_frect(x=self.x, y=self.y) - def center(self, pos: tuple[int, int]) -> None: + def center(self, pos: tuple[float, float]) -> 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. + pos (tuple[float, float]): The (x, y) point to center the text on. """ super().center(pos) diff --git a/engine/core/types.py b/engine/core/types.py index 281b591..1a53f0d 100644 --- a/engine/core/types.py +++ b/engine/core/types.py @@ -10,11 +10,12 @@ from typing import Union, Protocol from .image import EntityImage +from .animation import EntityAnim RGBType = Union[ tuple[int, int, int], pygame.Color # Add pygame.Color for type checkers ] -EntityImageType = EntityImage +EntityMediaType = Union[EntityImage, EntityAnim] class EntityScript(Protocol): diff --git a/engine/core/utils.py b/engine/core/utils.py index 7257283..1756611 100644 --- a/engine/core/utils.py +++ b/engine/core/utils.py @@ -7,15 +7,15 @@ from typing import TYPE_CHECKING -from ..nut_loader import nut_source, nut_call_function +from ..loaders.c_loader import c_source if TYPE_CHECKING: from . import Game -nut_source("math.nut") +_clamp = c_source("mathutil.c").clamp -def get_center(game: "Game") -> tuple[int, int]: +def get_center(game: "Game") -> tuple[float, float]: """ Get the center of the game window. @@ -23,7 +23,7 @@ def get_center(game: "Game") -> tuple[int, int]: game (Game): The game instance. Returns: - tuple[int, int]: The (x, y) coordinates of the center of the game window. + tuple[float, float]: The (x, y) coordinates of the center of the game window. """ return (game.wsize[0] // 2, game.wsize[1] // 2) @@ -36,6 +36,8 @@ def clamp(value: float, low: float, high: float) -> float: Useful for holding an entity on screen, or keeping a color channel between 0 and 255. + *Implemented in C* + Args: value (float): The number to limit. low (float): Smallest value allowed. @@ -45,4 +47,4 @@ def clamp(value: float, low: float, high: float) -> float: float: The number, or low or high if it fell outside them. """ - return float(nut_call_function("clamp", float(value), float(low), float(high))) + return float(_clamp(value, low, high)) diff --git a/engine/gui/__init__.py b/engine/gui/__init__.py index 784bbe2..162cf8d 100644 --- a/engine/gui/__init__.py +++ b/engine/gui/__init__.py @@ -7,6 +7,7 @@ import queue from functools import partial +from multiprocessing import get_context import tkinter as tk from tkinter import DISABLED, NORMAL, ttk @@ -25,7 +26,7 @@ from ..core import Game as CoreGame, Entity from ..logger import logger, Status as LoggerStatus from ..build_tools import build -from ..tcl_loader import tcl_source +from ..loaders.tcl_loader import tcl_source from .tooltip import Tooltip as _Tooltip from pathlib import Path @@ -51,7 +52,6 @@ class Editor: entity_data: Optional[tk.Text] def __init__(self) -> None: - self.core_game: Optional[CoreGame] = None self.view_popup = None self.game_settings_popup: Optional[tk.Toplevel] = None self.entity_data = None @@ -464,6 +464,7 @@ def view_entity(self, entity_list: tk.Listbox) -> None: "scriptfile": [("Python scripts", "*.py"), ("All files", "*.*")], "image": [ ("Images", "*.png *.jpg *.jpeg *.gif *.bmp"), + ("Animations", "*.gif *.webp"), ("All files", "*.*"), ], } @@ -609,21 +610,30 @@ def save_edits() -> None: updates = {} + # A blank field means "use the default", and a key this entity does + # not carry is exactly what every reader falls back to a default on. + cleared = [] + try: for name, obj in field_objs.items(): - value = obj.get() + value = obj.get().strip() - if value.strip() == "": + if value == "": + cleared.append(name) continue updates[name] = fields[name](value) color_values = [c.get().strip() for c in color_objs] if any(color_values): - parsed_color = tuple(int(c) for c in color_values) + # A blank component falls back to its own default rather + # than dragging the whole color back to white. + parsed_color = tuple(int(c) if c else 255 for c in color_values) if any(component < 0 or component > 255 for component in parsed_color): raise ValueError("Color values must be between 0 and 255") updates["color"] = parsed_color + else: + cleared.append("color") except ValueError as e: messagebox.showerror( "Error", @@ -631,6 +641,9 @@ def save_edits() -> None: ) return + for name in cleared: + self.entities[selected_item].pop(name, None) + self.entities[selected_item].update(updates) self.view_popup.destroy() @@ -714,43 +727,23 @@ def save_name(self, name: str) -> None: messagebox.showinfo("Info", f"Project name set to: {self.project_name}") def run_game(self, is_editor: bool = True) -> None: - self.core_game = CoreGame( - self.project_name, - width=self.game_dimensions[0], - height=self.game_dimensions[1], - cursor_visible=self.cursor_visible, - fullscreen=self.fullscreen, - IS_EDITOR=is_editor, - GP_BASE_PATH=GP_BASE_PATH, + process = get_context("spawn").Process( + target=_run_game, + args=( + { + "name": self.project_name, + "dimensions": self.game_dimensions, + "cursor_visible": self.cursor_visible, + "fullscreen": self.fullscreen, + }, + self.entities, + GP_BASE_PATH, + is_editor, + ), ) - for _entity_name, entity_data in self.entities.items(): - scriptfile = game_path(entity_data.get("scriptfile", None)) - image_path = entity_data.get("image") - - if image_path: - image = game_path(image_path) - else: - image = None - - entity = Entity( - x=entity_data.get("x", 0), - y=entity_data.get("y", 0), - width=entity_data.get("width", 50), - height=entity_data.get("height", 50), - color=tuple(entity_data.get("color", (255, 255, 255))), - scriptfile=scriptfile, - image=image, - ) - self.core_game.add_to_current_scene(entity) - - def run_core_game() -> None: - if self.core_game is None: - return - - self.core_game.run() - - run_core_game() + process.start() + process.join() def run(self) -> None: self.root.mainloop() @@ -760,6 +753,49 @@ def quit(self) -> None: sys.exit() +def _run_game(settings: dict, entities: dict, base_path: str, is_editor: bool) -> None: + """ + Run a game to completion. The editor spawns this as a process of its own. + + Args: + settings (dict): Window title and display settings for this run. + entities (dict): Entity data keyed by name, as the editor stores it. + base_path (str): Project root the entities' paths are relative to. + is_editor (bool): Whether the game should behave as an editor preview. + """ + + global GP_BASE_PATH + + GP_BASE_PATH = base_path + sys.dont_write_bytecode = True + + core_game = CoreGame( + settings["name"], + width=settings["dimensions"][0], + height=settings["dimensions"][1], + cursor_visible=settings["cursor_visible"], + fullscreen=settings["fullscreen"], + IS_EDITOR=is_editor, + GP_BASE_PATH=base_path, + ) + + for _entity_name, entity_data in entities.items(): + image_path = entity_data.get("image") + + entity = Entity( + x=entity_data.get("x", 0), + y=entity_data.get("y", 0), + width=entity_data.get("width", 50), + height=entity_data.get("height", 50), + color=tuple(entity_data.get("color", (255, 255, 255))), + scriptfile=game_path(entity_data.get("scriptfile", None)), + image=game_path(image_path) if image_path else None, + ) + core_game.add_to_current_scene(entity) + + core_game.run() + + def run() -> None: editor = Editor() editor.run() diff --git a/engine/gui/__main__.py b/engine/gui/__main__.py index e326ed8..24bb68d 100644 --- a/engine/gui/__main__.py +++ b/engine/gui/__main__.py @@ -1,3 +1,10 @@ +# Copyright (C) Natuworkguy +# See the LICENSE file for GPLv3 + +""" +Run the engine GUI +""" + from . import run if __name__ == "__main__": diff --git a/engine/loaders/__init__.py b/engine/loaders/__init__.py new file mode 100644 index 0000000..7d76085 --- /dev/null +++ b/engine/loaders/__init__.py @@ -0,0 +1,11 @@ +# Copyright (C) Natuworkguy +# See the LICENSE file for GPLv3 + +""" +Loaders for the scripting languages the engine embeds. +""" + +from typing import Final +from pathlib import Path + +_ENGINE_DIR: Final[Path] = Path(__file__).parent.parent diff --git a/engine/loaders/c_loader.py b/engine/loaders/c_loader.py new file mode 100644 index 0000000..825e13a --- /dev/null +++ b/engine/loaders/c_loader.py @@ -0,0 +1,197 @@ +# Copyright (C) Natuworkguy +# See the LICENSE file for GPLv3 + +""" +C integration utilities for the engine. +""" + +import importlib +import sys + +import cffi + +from functools import cache +from types import ModuleType +from typing import Final, Any +from pathlib import Path + +from ..logger import logger, Status +from . import _ENGINE_DIR + +C_DIR: Final[Path] = _ENGINE_DIR / "c" +BUILD_DIR: Final[Path] = C_DIR / "build" + +if not C_DIR.exists() or not C_DIR.is_dir(): + logger("Could not find engine/c/ directory.", status=Status.CRITICAL) + sys.exit(1) + + +class CModule: + """ + A compiled C file, with the functions it exposes reachable as attributes. + """ + + ffi: cffi.FFI + lib: Any + + def __init__(self, source_name: str, module: ModuleType) -> None: + """ + Wrap the module cffi compiled for a C file. + + Args: + source_name (str): file in engine/c/ the module was compiled from + module (ModuleType): Module cffi produced for that file. + """ + + self.source_name = source_name + self.ffi = module.ffi + self.lib = module.lib + + def __getattr__(self, name: str) -> Any: + """ + Get a function or constant from the compiled C. + + Args: + name (str): Name the C file's header declares. + + Returns: + Any: The function or constant it names. + + Raises: + AttributeError: If the header declares no such name. + """ + + try: + return getattr(self.lib, name) + except AttributeError: + raise AttributeError(f"Could not find {name} in {self.source_name}.") from None + + def __repr__(self) -> str: + """ + Return a developer-friendly representation of the compiled C file. + + Returns: + str: Debug representation of the module. + """ + + return f"<{self.__class__.__name__} of {self.source_name}>" + + +def _is_built(module_name: str, *sources: Path) -> bool: + """ + Check whether a compiled module is already present and up to date. + + Args: + module_name (str): Name of the compiled module. + *sources (Path): Files the module was compiled from. + + Returns: + bool: True if the module exists and is newer than every source. + """ + + newest = max(source.stat().st_mtime for source in sources) + + return any( + built.suffix in {".so", ".pyd"} and built.stat().st_mtime >= newest + for built in BUILD_DIR.glob(f"{module_name}.*") + ) + + +def _build(module_name: str, source_path: Path, header_path: Path) -> None: + """ + Compile a C file into an extension module under engine/c/build/ + + Args: + module_name (str): Name to give the compiled module. + source_path (Path): C file to compile. + header_path (Path): Header declaring what the C file exposes. + """ + + ffibuilder = cffi.FFI() + + ffibuilder.cdef(header_path.read_text(encoding="utf-8")) + ffibuilder.set_source( + module_name, + source_path.read_text(encoding="utf-8"), + include_dirs=[str(C_DIR)], + libraries=[] if sys.platform == "win32" else ["m"], + ) + + ffibuilder.compile(tmpdir=str(BUILD_DIR)) + + +@cache +def c_source(source_name: str) -> CModule: + """ + Compile and load a C file from engine/c/ + + The file needs a header of the same name holding its prototypes. cffi reads + that header to learn what Python may call, so it holds declarations only, + with no includes and no include guards. + + Compiling happens once per file, and only when the C is newer than the last + build, so later calls return the same already built module. + + Args: + source_name (str): file in engine/c/ to compile + + Returns: + CModule: The compiled C file, with its functions as attributes. + + Raises: + FileNotFoundError: If no such source or header exists under engine/c/. + IsADirectoryError: If the path names a directory rather than a file. + ModuleNotFoundError: If the compiled module cannot be imported, which + usually means engine/c/build/ holds a module built by a different + Python than the one running now. + """ + + source_path = C_DIR / source_name + + if not source_path.exists(): + raise FileNotFoundError(f"Could not find C file {source_path}.") + + if source_path.is_dir(): + raise IsADirectoryError(f"{source_path}: Invalid script path (Is a directory)") + + header_path = source_path.with_suffix(".h") + + if not header_path.exists(): + raise FileNotFoundError(f"Could not find C header {header_path}.") + + module_name = f"_{source_path.stem}_cffi" + + if not _is_built(module_name, source_path, header_path): + _build(module_name, source_path, header_path) + + if str(BUILD_DIR) not in sys.path: + sys.path.insert(0, str(BUILD_DIR)) + + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as e: + raise ModuleNotFoundError( + f"Compiled {source_name}, but {module_name} could not be imported from " + f"{BUILD_DIR}. Delete that directory to build it again." + ) from e + + return CModule(source_name, module) + + +def c_call_function(source_name: str, function_name: str, *args: Any) -> Any: + """ + Call a C function from a file in engine/c/ with arguments and return result + + Example: + result = c_call_function("geometry.c", "distance", 0.0, 0.0, 3.0, 4.0) + + Args: + source_name (str): file in engine/c/ holding the function + function_name (str): function to call + *args (Any): Arguments passed to the C function. + + Returns: + Any: Result of the function + """ + + return getattr(c_source(source_name), function_name)(*args) diff --git a/engine/nut_loader.py b/engine/loaders/nut_loader.py similarity index 77% rename from engine/nut_loader.py rename to engine/loaders/nut_loader.py index ecf9c16..cea9478 100644 --- a/engine/nut_loader.py +++ b/engine/loaders/nut_loader.py @@ -5,7 +5,6 @@ Squirrel integration utilities for the engine. """ -import os import sys import squirrel @@ -14,9 +13,10 @@ from typing import Final, Any from pathlib import Path -from .logger import logger, Status +from ..logger import logger, Status +from . import _ENGINE_DIR -NUT_DIR: Final[Path] = Path(__file__).parent / "nut" +NUT_DIR: Final[Path] = _ENGINE_DIR / "nut" if not NUT_DIR.exists() or not NUT_DIR.is_dir(): logger("Could not find engine/nut/ directory.", status=Status.CRITICAL) @@ -47,13 +47,19 @@ def nut_source(script_name: str) -> Any: Returns: Any: Value the script returns, or None if it returns nothing. + + Raises: + FileNotFoundError: If no such script exists under engine/nut/. + IsADirectoryError: If the path names a directory rather than a file. """ script_path = NUT_DIR / script_name - if not os.path.exists(script_path) or not os.path.isfile(script_path): - logger(f"Could not find Squirrel file {script_path}.", status=Status.CRITICAL) - sys.exit(1) + if not script_path.exists(): + raise FileNotFoundError(f"Could not find Squirrel file {script_path}.") + + if script_path.is_dir(): + raise IsADirectoryError(f"{script_path}: Invalid script path (Is a directory)") return nut_eval(script_path.read_text(encoding="utf-8")) diff --git a/engine/tcl_loader.py b/engine/loaders/tcl_loader.py similarity index 53% rename from engine/tcl_loader.py rename to engine/loaders/tcl_loader.py index cec4811..7bdcc1e 100644 --- a/engine/tcl_loader.py +++ b/engine/loaders/tcl_loader.py @@ -5,7 +5,6 @@ Tcl integration utilities for the engine. """ -import os import sys import tkinter as tk @@ -13,9 +12,10 @@ from typing import Final from pathlib import Path -from .logger import logger, Status +from ..logger import logger, Status +from . import _ENGINE_DIR -TCL_DIR: Final[Path] = Path(__file__).parent / "tcl" +TCL_DIR: Final[Path] = _ENGINE_DIR / "tcl" if not TCL_DIR.exists() or not TCL_DIR.is_dir(): logger("Could not find engine/tcl/ directory.", status=Status.CRITICAL) @@ -29,12 +29,18 @@ def tcl_source(script_name: str, root: tk.Tk) -> None: Args: script_name (str): file in engine/tcl/ to source from root (tk.Tk): Tk instance + + Raises: + FileNotFoundError: If no such script exists under engine/tcl/. + IsADirectoryError: If the path names a directory rather than a file. """ - script_path = str(TCL_DIR / script_name) + script_path = TCL_DIR / script_name + + if not script_path.exists(): + raise FileNotFoundError(f"Could not find Tcl file {script_path}.") - if not os.path.exists(script_path) or not os.path.isfile(script_path): - logger(f"Could not find Tcl file {script_path}.", status=Status.CRITICAL) - sys.exit(1) + if script_path.is_dir(): + raise IsADirectoryError(f"{script_path}: Invalid script path (Is a directory)") root.tk.call("source", script_path) diff --git a/engine/nut/anim.nut b/engine/nut/anim.nut new file mode 100644 index 0000000..bf12b62 --- /dev/null +++ b/engine/nut/anim.nut @@ -0,0 +1,22 @@ +// Copyright (C) Natuworkguy +// See the LICENSE file for GPLv3 + +function frame_starts(delays, count) { + local starts = [] + local total = 0.0 + + for (local i = 0; i < count; i += 1) { + local delay = delays[i] + + if (delay < 0.0) { + delay = 0.0 + } + + starts.append(total) + total = total + delay + } + + starts.append(total) + + return starts +} diff --git a/engine/nut/math.nut b/engine/nut/math.nut deleted file mode 100644 index d785dc4..0000000 --- a/engine/nut/math.nut +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (C) Natuworkguy -// See the LICENSE file for GPLv3 - -function clamp(value, low, high) { - if (value < low) { - return low - } - - if (value > high) { - return high - } - - return value -} diff --git a/engine/saveload.py b/engine/saveload.py index 463c8a1..8bad0ce 100644 --- a/engine/saveload.py +++ b/engine/saveload.py @@ -86,7 +86,7 @@ def save_project(engine: Any, dir_str: Optional[str] = None) -> Optional[str]: with open(dir / ".gitattributes", "w") as f: f.write( """ - *.absp text linguist-language=JSON linguist-detectable=true diff=json +*.absp text linguist-language=JSON linguist-detectable=true diff=json """.strip() ) diff --git a/engine/tcl/theme.tcl b/engine/tcl/theme.tcl index 0ee7ecf..690c38d 100644 --- a/engine/tcl/theme.tcl +++ b/engine/tcl/theme.tcl @@ -1,3 +1,6 @@ +# Copyright (C) Natuworkguy +# See the LICENSE file for GPLv3 + set font [dict create] dict set font default [list "Segoe UI" 10] diff --git a/engine/version.py b/engine/version.py index a34b2f6..1cc82e6 100644 --- a/engine/version.py +++ b/engine/version.py @@ -1 +1 @@ -__version__ = "0.4.7" +__version__ = "0.5.7" diff --git a/pyproject.toml b/pyproject.toml index 0173564..66eff43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=69", "wheel"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -8,16 +8,18 @@ dynamic = ["version", "dependencies", "optional-dependencies"] description = "2D Game engine made by Natuworkguy for public use." readme = "README.md" requires-python = ">=3.11" -license = { text = "GPL-3.0-only" } +license = "GPL-3.0-only" +license-files = ["LICENSE"] authors = [ { name = "Nathan C." } ] [tool.setuptools] include-package-data = true +package-dir = { abs_engine = "engine" } [tool.setuptools.dynamic] -version = { attr = "engine.version.__version__" } +version = { attr = "abs_engine.version.__version__" } [tool.setuptools.dynamic.dependencies] file = ["requirements.txt"] @@ -25,20 +27,41 @@ file = ["requirements.txt"] [tool.setuptools.dynamic.optional-dependencies] dev = { file = ["requirements-dev.txt"] } -[tool.setuptools.packages.find] -include = ["engine*"] - [tool.setuptools.package-data] -engine = ["py.typed"] +abs_engine = ["py.typed"] [tool.mypy] +check_untyped_defs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +disallow_untyped_calls = true +disallow_subclassing_any = true +warn_unused_ignores = true +warn_unused_configs = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true + # squirrel-lang ships __init__.pyi but no py.typed marker, so PEP 561 forbids # mypy from using its inline stubs. [[tool.mypy.overrides]] module = ["squirrel"] ignore_missing_imports = true +[tool.pyright] +exclude = [ + # == Default Excludes === + "**/.*", + "**/node_modules", + + ".venv", + "build", + "dist", + "**/__pycache__" +] + [tool.ruff] line-length = 100 target-version = "py311" diff --git a/requirements-dev.txt b/requirements-dev.txt index 613d4f9..3ea1754 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,4 +9,5 @@ ty # Typeshed packages types-colorama -types-pyinstaller \ No newline at end of file +types-pyinstaller +types-cffi diff --git a/requirements.txt b/requirements.txt index f84b6fd..e2a18ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ pygame-ce colorama pyinstaller -squirrel-lang \ No newline at end of file +squirrel-lang +cffi