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