Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,6 @@ __marimo__/

# Agents
.agents/

# Compiled engine C
engine/c/build/
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
include engine/py.typed
recursive-include engine/tcl *.tcl
recursive-include engine/nut *.nut
recursive-include engine/c *.c *.h
16 changes: 16 additions & 0 deletions engine/c/mathutil.c
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions engine/c/mathutil.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (C) Natuworkguy
// See the LICENSE file for GPLv3

double clamp(double value, double low, double high);
21 changes: 15 additions & 6 deletions engine/core/animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@
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:
"""
Expand Down Expand Up @@ -43,7 +49,7 @@ def __init__(self, anim_path: str, loop: bool = True) -> None:
self.frames = []
self.loop: bool = loop

self._starts: list[float] = []
self._starts: array[float] = array("d")
self._duration: float = 0.0
self._started_at: int = 0

Expand All @@ -58,6 +64,8 @@ def set_image(self, anim_path: str, loop: Optional[bool] = None) -> None:

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
Expand All @@ -77,12 +85,13 @@ def set_image(self, anim_path: str, loop: Optional[bool] = None) -> None:
if loop is not None:
self.loop = loop

self._starts = []
self._duration = 0.0
delays = [delay for _, delay in loaded]
timings = array(
"d", [float(t) for t in nut_call_function("frame_starts", delays, len(delays))]
)

for _, delay in loaded:
self._starts.append(self._duration)
self._duration += max(0.0, delay)
self._starts = timings[:-1]
self._duration = timings[-1]

self._started_at = pygame.time.get_ticks()

Expand Down
8 changes: 4 additions & 4 deletions engine/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@

from typing import TYPE_CHECKING

from ..loaders.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[float, float]:
Expand All @@ -36,7 +36,7 @@ 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 Squirrel*
*Implemented in C*

Args:
value (float): The number to limit.
Expand All @@ -47,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))
197 changes: 197 additions & 0 deletions engine/loaders/c_loader.py
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 22 additions & 0 deletions engine/nut/anim.nut
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 0 additions & 14 deletions engine/nut/math.nut

This file was deleted.

3 changes: 2 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ ty

# Typeshed packages
types-colorama
types-pyinstaller
types-pyinstaller
types-cffi
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pygame-ce
colorama
pyinstaller
squirrel-lang
squirrel-lang
cffi