Metadata-Version: 2.4
Name: sky-engine
Version: 0.0.2
Summary: A wrapper around Pygame that makes it easier to use.
License-Expression: MIT
License-File: LICENSE
Keywords: pygame,game-development,game-engine
Author: iNCOHESiONS
Author-email: incohesions@gmail.com
Requires-Python: >=3.14
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: Implementation :: CPython
Requires-Dist: pygame-ce (>=2.5.3,<3.0.0)
Requires-Dist: pywin32 (>=311,<312) ; sys_platform == "win32"
Requires-Dist: screeninfo (>=0.8.1,<0.9.0)
Requires-Dist: singleton-decorator (==1.0.0)
Requires-Dist: watchdog (==6.0.0)
Project-URL: Homepage, https://github.com/incohesions/sky-engine
Project-URL: Repository, https://github.com/incohesions/sky-engine
Description-Content-Type: text/markdown

# Sky Engine

<p align="center">
  <img src="https://img.shields.io/badge/Python-3.14-blue?logo=python" alt="Python 3.14" />
</p>

Makes `pygame` (or rather, [pygame-ce](https://github.com/pygame-community/pygame-ce), more specifically) less painful to use. More like a wrapper than an engine. Fully typed with [basedpyright](https://github.com/DetachHead/basedpyright).

> Theoretically cross-platform, but mostly tested on Windows. May have some window manager weirdness on Linux, specifically when it comes to fullscreening.

## Quick Start

Due to the engine's many defaults, only 2 lines of code are required to get started. This opens an 800x600 window, centered on the main monitor, with a black background:

```python
from sky import App

App().mainloop()
```

To modify the app's defaults, including the default window's properties, one may use the `spec` argument:
```python
from sky import App, AppSpec, Vector2, WindowSpec
from sky.colors import CRIMSON

app = App(
    spec=AppSpec(
        window_spec=WindowSpec(title="My Window", size=Vector2(400, 400), fill=CRIMSON)
    )
)

app.mainloop()
```

For a headless `App`, one may simply set `window_spec` to None, or use the `AppSpec.headless()` classmethod.

`Sky` provides users many `Hook`s that may contain callbacks to be executed whenever the `Hook` is triggered. They can be used as decorators, which makes for particularly elegant code:

```python
from pygame import draw

from sky import App, WindowSpec
from sky.colors import ALICE_BLUE, CRIMSON

app = App(spec=WindowSpec(fill=CRIMSON))


@app.on_setup
def setup() -> None:
    print("This will run as the app starts.")


@app.pre_update
def pre_update() -> None:
    print("This will run every frame.")


@app.window.on_render
def on_render1() -> None:
    print(
        "This will also run every frame, but is tied to a certain Window. Use this for rendering!"
    )


@app.on_render
def on_render2() -> None:
    print("Alternatively, use the alias `app.on_render`.")
    draw.aacircle(app.window.surface, ALICE_BLUE, app.window.center, 32)


@app.on_cleanup
def cleanup() -> None:
    print("This will run as soon as the app finishes running.")


app.mainloop()
```

> Note: although `Sky` itself doesn't favor any particular module or form of rendering, we will use `pygame.draw` for examples, as it comes bundled with `pygame`. For examples that perform hardware rendering using other libraries, see the [examples](https://github.com/INCOHESIONS/sky-engine/tree/main/examples) folder.

`Hook`s may also have their execution cancelled. Example:

```python
from sky import App, Hook
from sky.utils import discard

app = App()

some_event = Hook(cancellable=True)


@some_event
def some_event1() -> None:
    print("This will print.")
    some_event.cancel()


@some_event
def some_event2() -> None:
    print("This will not print.")


app.on_setup += lambda: discard(some_event.invoke())

app.mainloop()
```

Combining `Hook`s, `Spec`s, and rendering, we can create two windows with differently colored backgrounds that render differently colored circles to their surfaces:

```python
from pygame import draw

from sky import App, AppSpec, Color, Window, WindowSpec
from sky.colors import CRIMSON, DODGER_BLUE
from sky.utils import discard

app = App(spec=AppSpec.headless())  # no default window since we'll add our own

window1 = app.windowing.add_window(spec=WindowSpec(title="Window 1", fill=CRIMSON))
window2 = app.windowing.add_window(spec=WindowSpec(title="Window 2", fill=DODGER_BLUE))


def render_to(window: Window, color: Color) -> None:
    window.on_render += lambda: discard(
        draw.aacircle(window.surface, color, window.center, 32)
    )


render_to(window1, DODGER_BLUE)
render_to(window2, CRIMSON)


app.mainloop()
```

To allow for interactions by grabbing user input, users may utilize the `mouse` and `keyboard` services. With them, we can render a circle to the screen that moves according to the player's WASD input, and changes size with the right and left mouse buttons:

```python
from pygame import draw

from sky import App, Key, MouseButton, WindowSpec
from sky.colors import ALICE_BLUE, CRIMSON

app = App(spec=WindowSpec(fill=CRIMSON))

pos = app.window.center
speed = 2
radius = 32


@app.on_render
def render() -> None:
    global pos
    pos += app.keyboard.get_movement_2d((Key.a, Key.d), (Key.w, Key.s)) * speed
    draw.aacircle(app.window.surface, ALICE_BLUE, pos, radius)


@app.mouse.on_mouse_button_downed
def change_radius(button: MouseButton) -> None:
    global radius
    radius += -1 if button == MouseButton.right else 1


app.mainloop()
```

This isn't the only way to grab input, however. One may also check for a key's or button's state every frame, using the `State` checking methods `is_downed`, `is_pressed` and `is_released`. Here's the example shown above, but using those functions instead:

```python
from pygame import draw

from sky import App, MouseButton, State, WindowSpec, Key
from sky.colors import ALICE_BLUE, CRIMSON

app = App(spec=WindowSpec(fill=CRIMSON))

pos = app.window.center
speed = 2
radius = 32


@app.on_render
def render() -> None:
    global pos, radius

    if app.mouse.any(State.downed):
        radius += -1 if app.mouse.is_downed(MouseButton.right) else 1

    pos += app.keyboard.get_movement_2d((Key.a, Key.d), (Key.w, Key.s)) * speed
    draw.aacircle(app.window.surface, ALICE_BLUE, pos, radius)


app.mainloop()
```

Using globals for everything, like we did with `pos`, `speed` and `radius`, is bad practice. Using `Component`s, the engine's fundamental object type, used to represent anything in a game, we can once again rewrite the example above, packaging those values into a single object:

```python
from dataclasses import dataclass, field
from typing import override

from pygame import draw

from sky import App, Component, Vector2, WindowSpec
from sky.colors import ALICE_BLUE, CRIMSON

app = App(spec=WindowSpec(fill=CRIMSON))


@dataclass
class Player(Component):
    pos: Vector2 = field(default_factory=lambda: app.window.center)
    speed: float = 2
    radius: int = 32

    @override
    def update(self) -> None:
        self.pos += app.keyboard.get_movement_2d(("a", "d"), ("w", "s")) * self.speed

        if app.mouse.any("downed"):
            self.radius += -1 if app.mouse.is_downed("right") else 1

        draw.aacircle(app.window.surface, ALICE_BLUE, self.pos, self.radius)


app.add_component(Player)
app.mainloop()
```

> Every method that accepts a `Key`, `MouseButton` or `State` also accepts a `str` version of those values. As such, `app.mouse.is_downed(MouseButton.right)` and `app.mouse.is_downed("right")` are the same. Methods that accept `Key` and `MouseButton` also accept `int`s, as pygame has constants that represent every key. With that, `app.keyboard.is_downed(Key.a)`, `app.keyboard.is_downed("a")` and `app.keyboard.is_downed(pygame.K_a)` are all equivalent.

Since `Player` has defaults for all of its constructor parameters, we may pass the type directly into `add_component`, letting the app instance it for us. Alternatively, if one is building a singleplayer game, or has some sort of "game controller" class that contains shared logic or data, they may use the `@app.singleton_component` decorator, making the class declaration look like this:

```python
@app.singleton_component  # has to come before @dataclass
@dataclass
class Player(Component):
    ...
```

The decorator immediately instances the class, and adds it to the app. It also makes the decorated class a singleton, and as such any subsequent instantiations will always refer to the same object:

```python
assert Player() is Player()  # passes
```

The engine supports hot reloading for `Component`s, meaning they can have their attributes changed and updated during runtime. To enable hot reloading, one must first add the `HotReload` module to their `App`'s spec:

```python
from sky.modules import HotReload

app = App(spec=AppSpec(modules=[HotReload]))
```

Then, to mark a `Component` as hot reloadable, simply use the `hot_reloadable` subclass argument:

```python
class Player(Component, hot_reloadable=True): ...
```

Alternatively, one may use the `hot_reloadable` class decorator.
```python
@hot_reloadable
class Player(Component): ...
```

> Note that hot reloading simply changes a `Component`'s internal type reference (`__class__`), meaning it changes its methods, descriptors and inner classes, but it does not change its attributes (`__dict__`). As such, members set in `__init__` or `start` will not be updated unless those methods are executed again, which they normally won't be. With that in mind, to use hot reloading for prototyping, one must type values directly into arguments. For example, when writing something like `draw.aacircle(app.window.surface, self.color, self.position, self.radius)`, one should simply write `BLACK` directly into the color parameter, as opposed to modifying `self.color` in the initialization function, as that function won't be called again after the `Component`'s initialization.

Notably, these examples use `app.mouse` and `app.keyboard`, which are `InputManager`s included by default in every window. `InputManager`s run every frame, grabbing input from a given window, using the `Windowing` `Service`. `Service`s are objects that handle a certain portion of functionality for the engine, also updating their data every frame. By default, the engine offers 4 `Service`s:
- `Events` (handles `pygame` events)
- `Windowing` (handles windowing)
- `Chrono` (handles time-related data)
- `Executor` (handles coroutines)

> Every window handles its own inputs, and as such has their own instance of a given `InputManager`, normally `Keyboard` and `Mouse`, which are included by default. Accessing `app.keyboard`, for instance, returns a reference to the main window's keyboard input manager, serving as a shorthand for `app.windowing.main_window.keyboard`.

Users may add their own `Service`s by subclassing the `Service` class, and using the `add_service` method:
```python
from typing import override

from sky import App, Service

app = App()


class SomeService(Service):
    @override
    def update(self) -> None:
        print("Runs every frame!")


app.add_service(SomeService())
app.mainloop()
```

So far, we've used methods that run either at the start, or at every frame. But many games require more granular control over timing, using delays, loops and animations. `Coroutine`s are the engine's way of handling such tasks.

```python
from sky import App, Coroutine, Color
from sky.colors import CRIMSON, DODGER_BLUE
from sky.utils import animate

app = App()


@app.on_setup
def lerp_color() -> Coroutine:
    for t in animate(duration=3, step=lambda: app.chrono.deltatime):
        app.window.fill_color = Color(CRIMSON.lerp(DODGER_BLUE, t))
        yield None  # same as WaitForFrames(1)


app.mainloop()
```

> This feature based on `Unity`'s coroutines. See their [documentation](https://docs.unity3d.com/6000.2/Documentation/Manual/Coroutines.html) for their version of the feature, done in `C#`.

`Hook`s can automatically detect `Coroutine`s, calling `app.executor.start_coroutine` when triggered instead of simply calling the decorated generator function.

Earlier, we called `add_component` directly on our `App` instance. Doing this actually calls `add_component` on the most recently added `Scene`, the engine's way of organizing many components into separate collections for easier management. Multiple `Scene`s may be loaded at once, as games usually contain portions that act differently from others, but run in parallel, such as the level and user interface.

In our case, the most recently added `Scene` is simply the default scene, as we haven't added any others. Here's an example that does not create a default scene, and instead adds two scenes, with each rendering a differently colored circle:

```python
from dataclasses import dataclass
from typing import override

from pygame import draw

from sky import App, AppSpec, Color, Component, Scene, Vector2
from sky.colors import BLUE, RED

app = App(spec=AppSpec.sceneless())  # no default scene since we'll add our own


@dataclass
class Circle(Component):
    pos: Vector2
    color: Color

    @override
    def update(self) -> None:
        draw.aacircle(app.window.surface, self.color, self.pos, 50)


app.load_scene(
    red_scene := Scene.from_components(
        [Circle(app.window.center + Vector2(100, 0), BLUE)]
    )
)
app.load_scene(
    blue_scene := Scene.from_components(
        [Circle(app.window.center - Vector2(100, 0), RED)]
    )
)

app.keyboard.add_keybindings(
    a=lambda: app.toggle_scene(blue_scene), b=lambda: app.toggle_scene(red_scene)
)

app.mainloop()
```

Yet another way of handling user input is using `Keybinding`s. Their constructor provides exact control over the binding, accepting multiple keys with possibly differing activation `State`s to allow for complex key combinations. A simpler way of adding keybindings, however, is using the `Keybinding.make` method, that simply takes a key and an action as arguments. `add_keybindings` is a method that uses `**kwargs` to create a mapping of key to action, simplifying the process further.

> This README covers most of the engine's main features, but one may dig through the source code and extra examples to learn more. Do note that this project is in heavy active development and breaking changes occur constantly, so don't use it for anything serious.

