keelpy 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keelpy-0.1.4/PKG-INFO +18 -0
- keelpy-0.1.4/README.md +211 -0
- keelpy-0.1.4/keel/__init__.py +412 -0
- keelpy-0.1.4/keel/assets/__init__.py +68 -0
- keelpy-0.1.4/keel/assets/hot_reload.py +141 -0
- keelpy-0.1.4/keel/assets/loaders/__init__.py +5 -0
- keelpy-0.1.4/keel/assets/loaders/json_loader.py +11 -0
- keelpy-0.1.4/keel/assets/loaders/texture_loader.py +27 -0
- keelpy-0.1.4/keel/assets/registry.py +162 -0
- keelpy-0.1.4/keel/assets/scene.py +193 -0
- keelpy-0.1.4/keel/audio/__init__.py +110 -0
- keelpy-0.1.4/keel/audio/audio_engine.py +428 -0
- keelpy-0.1.4/keel/audio/components.py +27 -0
- keelpy-0.1.4/keel/cli/__init__.py +6 -0
- keelpy-0.1.4/keel/cli/commands.py +191 -0
- keelpy-0.1.4/keel/cli/templates.py +69 -0
- keelpy-0.1.4/keel/components/__init__.py +8 -0
- keelpy-0.1.4/keel/components/mesh_renderer.py +14 -0
- keelpy-0.1.4/keel/components/sprite.py +18 -0
- keelpy-0.1.4/keel/components/text_label.py +29 -0
- keelpy-0.1.4/keel/components/transform2d.py +14 -0
- keelpy-0.1.4/keel/components/transform3d.py +19 -0
- keelpy-0.1.4/keel/core/__init__.py +32 -0
- keelpy-0.1.4/keel/core/archetype.py +227 -0
- keelpy-0.1.4/keel/core/events.py +49 -0
- keelpy-0.1.4/keel/core/query.py +155 -0
- keelpy-0.1.4/keel/core/scheduler.py +133 -0
- keelpy-0.1.4/keel/core/world.py +374 -0
- keelpy-0.1.4/keel/gamepad.py +125 -0
- keelpy-0.1.4/keel/input.py +243 -0
- keelpy-0.1.4/keel/loop.py +123 -0
- keelpy-0.1.4/keel/physics/__init__.py +118 -0
- keelpy-0.1.4/keel/physics/components2d.py +85 -0
- keelpy-0.1.4/keel/physics/components3d.py +80 -0
- keelpy-0.1.4/keel/physics/enums.py +39 -0
- keelpy-0.1.4/keel/physics/physics2d.py +432 -0
- keelpy-0.1.4/keel/physics/physics3d.py +599 -0
- keelpy-0.1.4/keel/py.typed +0 -0
- keelpy-0.1.4/keel/renderer/__init__.py +184 -0
- keelpy-0.1.4/keel/renderer/batch2d.py +183 -0
- keelpy-0.1.4/keel/renderer/camera2d.py +79 -0
- keelpy-0.1.4/keel/renderer/shader.py +122 -0
- keelpy-0.1.4/keel/renderer/texture.py +118 -0
- keelpy-0.1.4/keel/renderer/tilemap.py +156 -0
- keelpy-0.1.4/keel/renderer3d/__init__.py +397 -0
- keelpy-0.1.4/keel/renderer3d/camera3d.py +111 -0
- keelpy-0.1.4/keel/renderer3d/frustum.py +82 -0
- keelpy-0.1.4/keel/renderer3d/lighting.py +50 -0
- keelpy-0.1.4/keel/renderer3d/material.py +55 -0
- keelpy-0.1.4/keel/renderer3d/mesh.py +294 -0
- keelpy-0.1.4/keel/renderer3d/shader3d.py +166 -0
- keelpy-0.1.4/keel/renderer3d/transform3d.py +134 -0
- keelpy-0.1.4/keel/text/__init__.py +132 -0
- keelpy-0.1.4/keel/text/builtin_font.py +6016 -0
- keelpy-0.1.4/keel/text/font.py +384 -0
- keelpy-0.1.4/keel/text/shader_text.py +40 -0
- keelpy-0.1.4/keel/text/text_renderer.py +341 -0
- keelpy-0.1.4/keel/tools/__init__.py +17 -0
- keelpy-0.1.4/keel/tools/debug_draw.py +288 -0
- keelpy-0.1.4/keel/tools/inspector.py +453 -0
- keelpy-0.1.4/keel/tools/profiler.py +89 -0
- keelpy-0.1.4/keel/window.py +137 -0
- keelpy-0.1.4/keelpy.egg-info/PKG-INFO +18 -0
- keelpy-0.1.4/keelpy.egg-info/SOURCES.txt +78 -0
- keelpy-0.1.4/keelpy.egg-info/dependency_links.txt +1 -0
- keelpy-0.1.4/keelpy.egg-info/entry_points.txt +2 -0
- keelpy-0.1.4/keelpy.egg-info/requires.txt +15 -0
- keelpy-0.1.4/keelpy.egg-info/top_level.txt +1 -0
- keelpy-0.1.4/pyproject.toml +38 -0
- keelpy-0.1.4/setup.cfg +4 -0
- keelpy-0.1.4/tests/test_assets.py +572 -0
- keelpy-0.1.4/tests/test_audio.py +494 -0
- keelpy-0.1.4/tests/test_ecs_core.py +639 -0
- keelpy-0.1.4/tests/test_gamepad.py +374 -0
- keelpy-0.1.4/tests/test_physics.py +853 -0
- keelpy-0.1.4/tests/test_renderer2d.py +586 -0
- keelpy-0.1.4/tests/test_renderer3d.py +582 -0
- keelpy-0.1.4/tests/test_text.py +466 -0
- keelpy-0.1.4/tests/test_tooling.py +613 -0
- keelpy-0.1.4/tests/test_window_loop.py +533 -0
keelpy-0.1.4/PKG-INFO
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keelpy
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Keel — a Python ECS game engine
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: numpy>=1.24
|
|
7
|
+
Requires-Dist: moderngl>=5.10
|
|
8
|
+
Requires-Dist: glfw>=2.6
|
|
9
|
+
Requires-Dist: Pillow>=10.0
|
|
10
|
+
Requires-Dist: watchdog>=3.0
|
|
11
|
+
Requires-Dist: pymunk>=7.0
|
|
12
|
+
Requires-Dist: pybullet>=3.2
|
|
13
|
+
Requires-Dist: freetype-py>=2.3
|
|
14
|
+
Requires-Dist: miniaudio>=1.59
|
|
15
|
+
Provides-Extra: tools
|
|
16
|
+
Requires-Dist: imgui-bundle>=1.5; extra == "tools"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
keelpy-0.1.4/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Keel
|
|
2
|
+
|
|
3
|
+
*The backbone of your game.*
|
|
4
|
+
|
|
5
|
+
A Python game engine with archetype ECS, built on ModernGL and GLFW.
|
|
6
|
+
|
|
7
|
+
## Why Keel
|
|
8
|
+
|
|
9
|
+
Pygame is old, single-threaded, and bound to CPU blits. Panda3D is a Python wrapper around a C++ engine, with the cognitive load that implies. Other Python options stop at hobby scope or no longer maintain a release. None of them provide a modern, data-oriented ECS as the core data model. Keel is for developers who want to stay in Python and write structured game code on top of a real archetype-based ECS. The tradeoff is honest: Python has interpreter overhead, so Keel pushes hot paths into numpy and C extensions (ModernGL, pymunk, pybullet) and exposes the rest as plain Python.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
### ECS
|
|
14
|
+
|
|
15
|
+
- Archetype storage with one numpy structured array per component type per archetype.
|
|
16
|
+
- Struct-of-arrays layout, so iteration is a numpy slice rather than a Python loop.
|
|
17
|
+
- Query DSL with `world.query(A, B, Without[C], Optional[D])`.
|
|
18
|
+
- Command buffer for deferred structural changes (spawn, despawn, add, remove).
|
|
19
|
+
- Typed event bus (`world.emit(...)`, `world.read_events(EventType)`), cleared at the start of each frame.
|
|
20
|
+
- Resource injection into systems via parameter type annotations.
|
|
21
|
+
|
|
22
|
+
### 2D rendering
|
|
23
|
+
|
|
24
|
+
- Instanced sprite batcher: one draw call per texture group.
|
|
25
|
+
- Texture atlas with up to 16 texture units.
|
|
26
|
+
- Orthographic camera with translation, rotation, zoom.
|
|
27
|
+
<!-- Tilemap class exists in keel.renderer.tilemap but has no setup helper
|
|
28
|
+
and is not exported from the top-level package, so it's roadmap, not
|
|
29
|
+
a feature. -->
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### 3D rendering
|
|
33
|
+
|
|
34
|
+
- OBJ loader (positions, normals, UVs, n-gon triangulation, missing-normal fallback).
|
|
35
|
+
- PBR-lite material (albedo, roughness, metallic, emissive scalars).
|
|
36
|
+
- Directional light plus up to 8 point lights, sorted nearest-first.
|
|
37
|
+
- Sphere-based frustum culling using Gribb/Hartmann plane extraction.
|
|
38
|
+
- Transform3D hierarchy with parent chains and cycle detection.
|
|
39
|
+
- Cube, plane, and UV sphere primitive generators.
|
|
40
|
+
|
|
41
|
+
### Physics
|
|
42
|
+
|
|
43
|
+
- 2D bridge to pymunk: rigid bodies, shapes (circle, box, segment), collision events, segment-query raycast.
|
|
44
|
+
- 3D bridge to pybullet (DIRECT mode only, never GUI): rigid bodies, sphere/box/capsule shapes, contact events, ray tests.
|
|
45
|
+
- Both bridges run at `Phase.POST_UPDATE`. ECS data is the source of truth on the way in; physics owns the result on the way out.
|
|
46
|
+
|
|
47
|
+
> **Collision events and body types:** `CollisionEvent2D` and `CollisionEvent3D` only fire when at least one body is **dynamic** (`body_type=0`). Two kinematic or two static bodies that overlap will not emit events — this is pymunk/Bullet behavior, not a Keel bug. Make at least one side dynamic if you need a collision to be detected. Keel prints a one-time `UserWarning` the first time a second kinematic body joins `Physics2D` to flag the trap early.
|
|
48
|
+
|
|
49
|
+
### Assets
|
|
50
|
+
|
|
51
|
+
- Handle-based `AssetRegistry` with extension-dispatched loaders.
|
|
52
|
+
- Built-in loaders for JSON and image formats (PNG, JPG, BMP, TGA).
|
|
53
|
+
- Hot reload via watchdog: file change is queued on the watchdog thread and drained on the main thread inside a PRE_UPDATE system, so GL re-uploads stay on the right thread.
|
|
54
|
+
- Scene save/load to JSON, atomic write (`.tmp` + `os.replace`), versioned schema.
|
|
55
|
+
|
|
56
|
+
### Tooling
|
|
57
|
+
|
|
58
|
+
- ImGui world inspector (F1).
|
|
59
|
+
- Per-system frame profiler overlay (F2).
|
|
60
|
+
- 2D physics debug draw (F3).
|
|
61
|
+
- CLI: `keel new`, `keel run`, `keel build`.
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- Python 3.11 or newer.
|
|
66
|
+
- A GPU and driver supporting OpenGL 3.3 Core.
|
|
67
|
+
- Pip dependencies: `moderngl`, `pyglfw`, `PyGLM`, `numpy`, `Pillow`, `pymunk`, `pybullet`, `watchdog`, `imgui-bundle`.
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
Keel is not yet on PyPI. Install from source:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
git clone https://github.com/VKSFY/keel
|
|
75
|
+
cd keel
|
|
76
|
+
pip install -e .
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Once it is published, the supported install will be:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install keelpy
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Quickstart
|
|
86
|
+
|
|
87
|
+
The fastest way to start a project is the CLI scaffold:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
keel new mygame
|
|
91
|
+
cd mygame
|
|
92
|
+
keel run
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`keel new` creates the project tree, and `keel run` watches every `.py` file in the directory and restarts the process on save.
|
|
96
|
+
|
|
97
|
+
A minimal working example, a ball that falls under gravity and bounces on a floor, looks like this:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import keel
|
|
101
|
+
from keel.renderer import setup_renderer_2d
|
|
102
|
+
from keel.physics import setup_physics_2d
|
|
103
|
+
|
|
104
|
+
app = keel.App(title="Bouncing Ball", width=800, height=600)
|
|
105
|
+
setup_renderer_2d(app)
|
|
106
|
+
setup_physics_2d(app, gravity_y=-980.0)
|
|
107
|
+
|
|
108
|
+
tools = keel.dev_tools(app)
|
|
109
|
+
tools.debug_draw.set_visible(True)
|
|
110
|
+
|
|
111
|
+
# Static floor.
|
|
112
|
+
app.world.spawn(
|
|
113
|
+
keel.Transform2D(x=400.0, y=50.0),
|
|
114
|
+
keel.RigidBody2D(body_type=1), # 1 = static
|
|
115
|
+
keel.Collider2D(shape_type=1, width=600.0, height=20.0, elasticity=0.6),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Dynamic ball.
|
|
119
|
+
app.world.spawn(
|
|
120
|
+
keel.Transform2D(x=400.0, y=500.0),
|
|
121
|
+
keel.RigidBody2D(mass=1.0),
|
|
122
|
+
keel.Collider2D(shape_type=0, radius=20.0, elasticity=0.75),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@app.system(keel.Phase.UPDATE)
|
|
126
|
+
def log_bounces(world, dt):
|
|
127
|
+
for event in world.read_events(keel.CollisionEvent2D):
|
|
128
|
+
if event.impulse > 100.0:
|
|
129
|
+
print(f"bounce: impulse={event.impulse:.0f}")
|
|
130
|
+
|
|
131
|
+
app.run()
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
That is a complete program. Save it as `main.py` and run it with `python main.py` or `keel run`. Press F1 to open the world inspector, F2 for the profiler overlay, and F3 to toggle the debug draw of the physics shapes.
|
|
135
|
+
|
|
136
|
+
## ECS concepts
|
|
137
|
+
|
|
138
|
+
Components are plain dataclasses, decorated with `@keel.component`. Field types map to numpy dtypes when possible (`float` to `float64`, `int` to `int64`, `bool` to `bool_`). Components with non-numpy fields fall back to a Python list column. Systems are plain functions registered with `@app.system(phase)`. The first two parameters are always `(world, dt)`. Any further parameters annotated with a registered resource type are injected by the scheduler.
|
|
139
|
+
|
|
140
|
+
Queries return per-archetype numpy array views. Mutations write through to the underlying storage in place:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
for pos, vel in world.query(Position, Velocity):
|
|
144
|
+
pos['x'] += vel['x'] * dt
|
|
145
|
+
pos['y'] += vel['y'] * dt
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
That loop runs once per matching archetype, not once per entity. Each iteration is a vectorized numpy operation over the entire archetype's rows.
|
|
149
|
+
|
|
150
|
+
Structural changes are deferred. Calling `world.spawn`, `world.despawn`, `world.add_component`, or `world.remove_component` queues a command in the buffer. Nothing moves between archetypes until `world.flush()` runs, which the main loop calls at the end of every frame. This keeps query iteration stable for the entire frame: you can spawn entities from inside a system without invalidating the views you are iterating.
|
|
151
|
+
|
|
152
|
+
## Project structure
|
|
153
|
+
|
|
154
|
+
`keel new mygame` produces:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
mygame/
|
|
158
|
+
├── main.py
|
|
159
|
+
├── pyproject.toml
|
|
160
|
+
├── README.md
|
|
161
|
+
├── assets/
|
|
162
|
+
│ └── .gitkeep
|
|
163
|
+
└── scenes/
|
|
164
|
+
└── .gitkeep
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`assets/` is monitored by the asset hot reload (textures, JSON data, anything `setup_assets` knows how to load). `scenes/` is the conventional home for `Scene.save` JSON output.
|
|
168
|
+
|
|
169
|
+
## Developer tools
|
|
170
|
+
|
|
171
|
+
### World inspector (F1)
|
|
172
|
+
|
|
173
|
+
`WorldInspector` opens an ImGui window listing every archetype and its entities. Each row expands to show component field values, sourced live from the structured arrays. There is a filter box: typing `Sprite` narrows the list to archetypes that contain a `Sprite` component. Useful for verifying that a system actually wrote what you think it wrote.
|
|
174
|
+
|
|
175
|
+
### Profiler overlay (F2)
|
|
176
|
+
|
|
177
|
+
`FrameProfiler` wraps every scheduler-invoked system in `time.perf_counter` markers. The overlay (top right) lists each system with its rolling 60-frame average in milliseconds and a unit-scaled bar. min, max, and last-sample stats are also tracked and available via `profiler.get_stats()` for programmatic use.
|
|
178
|
+
|
|
179
|
+
### Debug draw (F3)
|
|
180
|
+
|
|
181
|
+
`DebugDraw2D` walks every `Transform2D + Collider2D + RigidBody2D` entity and draws the collider outline as GL line segments: 32-segment circles, 4-segment rectangles, single segments. Lines are grouped by color (green for dynamic, gray for static, blue for kinematic, yellow for sensor) so the whole overlay is one draw call per color. The shader is a 2-uniform line program (`u_camera`, `u_color`).
|
|
182
|
+
|
|
183
|
+
### Enable everything
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
tools = keel.dev_tools(app)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
That call sets up the profiler, the inspector, and (if `setup_physics_2d` has already been called on this app) the debug draw. F1, F2, and F3 use edge-detected polling via `app.input.is_key_down`, checked once per sim tick. `KeyEvent`s are not used here because input events can be dropped on visual frames where no sim tick fires.
|
|
190
|
+
|
|
191
|
+
## Architecture overview
|
|
192
|
+
|
|
193
|
+
`App` owns one `World` (the ECS), one `Scheduler` (phase-ordered system runner), one `Window` (GLFW + ModernGL context), and one `InputState`. The fixed-timestep loop drives the scheduler at 60 Hz simulation, render-once-per-visual-frame. Renderers are plain systems registered at `Phase.RENDER`: they read components through `world.query` and issue draw calls. Physics bridges run at `Phase.POST_UPDATE`, sync ECS state into the engine, step, and write results back into Transform components. Assets and scenes go through their own resources but never own simulation state. Every layer talks to the next through public ECS APIs only, so adding or replacing a layer does not require changes to the others.
|
|
194
|
+
|
|
195
|
+
## Roadmap
|
|
196
|
+
|
|
197
|
+
- [ ] Tilemap (`keel.renderer.tilemap.Tilemap` exists but has no `setup_tilemap` helper or top-level export yet)
|
|
198
|
+
- [ ] Text rendering
|
|
199
|
+
- [ ] Skeletal animation
|
|
200
|
+
- [ ] Parallel system execution
|
|
201
|
+
- [ ] WASM export via Pyodide
|
|
202
|
+
- [ ] Audio
|
|
203
|
+
- [ ] Visual scene editor
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT.
|
|
208
|
+
|
|
209
|
+
## Contributing
|
|
210
|
+
|
|
211
|
+
Pull requests are welcome. Run `pytest` before submitting; the suite is fast and covers every phase. There is no formal contribution guide yet, so open an issue if you want to discuss a larger change before writing it.
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Keel — a Python game engine. Top-level public surface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
__version__ = "0.1.3"
|
|
5
|
+
|
|
6
|
+
import glfw as _glfw
|
|
7
|
+
import moderngl
|
|
8
|
+
|
|
9
|
+
# Re-exports use the `from X import Y as Y` alias form so Pylance treats them
|
|
10
|
+
# as intentional public re-exports rather than internal side imports — this is
|
|
11
|
+
# what makes `from keel import setup_renderer_2d` autocomplete correctly in
|
|
12
|
+
# editors that use Pyright (VSCode/Cursor) for static analysis.
|
|
13
|
+
from .assets import AssetHandle as AssetHandle
|
|
14
|
+
from .assets import AssetNotFoundError as AssetNotFoundError
|
|
15
|
+
from .assets import AssetRegistry as AssetRegistry
|
|
16
|
+
from .assets import FileWatcher as FileWatcher
|
|
17
|
+
from .assets import InvalidHandleError as InvalidHandleError
|
|
18
|
+
from .assets import NoLoaderError as NoLoaderError
|
|
19
|
+
from .assets import Scene as Scene
|
|
20
|
+
from .assets import SceneVersionError as SceneVersionError
|
|
21
|
+
from .assets import setup_assets as setup_assets
|
|
22
|
+
|
|
23
|
+
from .audio import AudioEngine as AudioEngine
|
|
24
|
+
from .audio import AudioSetup as AudioSetup
|
|
25
|
+
from .audio import AudioSource as AudioSource
|
|
26
|
+
from .audio import SoundHandle as SoundHandle
|
|
27
|
+
from .audio import play_music as play_music
|
|
28
|
+
from .audio import play_sound as play_sound
|
|
29
|
+
from .audio import set_volume as set_volume
|
|
30
|
+
from .audio import setup_audio as setup_audio
|
|
31
|
+
from .audio import stop_music as stop_music
|
|
32
|
+
from .audio import stop_sound as stop_sound
|
|
33
|
+
|
|
34
|
+
_setup_assets = setup_assets
|
|
35
|
+
|
|
36
|
+
from .components import MeshRenderer as MeshRenderer
|
|
37
|
+
from .components import Sprite as Sprite
|
|
38
|
+
from .components import TextLabel as TextLabel
|
|
39
|
+
from .components import Transform2D as Transform2D
|
|
40
|
+
from .components import Transform3D as Transform3D
|
|
41
|
+
|
|
42
|
+
from .physics import BodyType as BodyType
|
|
43
|
+
from .physics import Collider2D as Collider2D
|
|
44
|
+
from .physics import Collider3D as Collider3D
|
|
45
|
+
from .physics import CollisionEvent2D as CollisionEvent2D
|
|
46
|
+
from .physics import CollisionEvent3D as CollisionEvent3D
|
|
47
|
+
from .physics import Physics2D as Physics2D
|
|
48
|
+
from .physics import Physics3D as Physics3D
|
|
49
|
+
from .physics import RigidBody2D as RigidBody2D
|
|
50
|
+
from .physics import RigidBody3D as RigidBody3D
|
|
51
|
+
from .physics import ShapeType2D as ShapeType2D
|
|
52
|
+
from .physics import ShapeType3D as ShapeType3D
|
|
53
|
+
from .physics import setup_physics_2d as setup_physics_2d
|
|
54
|
+
from .physics import setup_physics_3d as setup_physics_3d
|
|
55
|
+
|
|
56
|
+
from .core import CommandBuffer as CommandBuffer
|
|
57
|
+
from .core import NULL_ENTITY as NULL_ENTITY
|
|
58
|
+
from .core import Optional as Optional
|
|
59
|
+
from .core import Phase as Phase
|
|
60
|
+
from .core import QueryResult as QueryResult
|
|
61
|
+
from .core import Without as Without
|
|
62
|
+
from .core import World as World
|
|
63
|
+
from .core import component as component
|
|
64
|
+
from .core import event as event
|
|
65
|
+
from .core.scheduler import Scheduler as Scheduler
|
|
66
|
+
|
|
67
|
+
from .gamepad import GamepadState as GamepadState
|
|
68
|
+
from .gamepad import setup_gamepad as setup_gamepad
|
|
69
|
+
|
|
70
|
+
from .input import GamepadAxisEvent as GamepadAxisEvent
|
|
71
|
+
from .input import GamepadButtonEvent as GamepadButtonEvent
|
|
72
|
+
from .input import InputState as InputState
|
|
73
|
+
from .input import KeyEvent as KeyEvent
|
|
74
|
+
from .input import MouseButtonEvent as MouseButtonEvent
|
|
75
|
+
from .input import MouseMoveEvent as MouseMoveEvent
|
|
76
|
+
from .input import MouseScrollEvent as MouseScrollEvent
|
|
77
|
+
from .input import WindowResizeEvent as WindowResizeEvent
|
|
78
|
+
from .input import make_callbacks as make_callbacks
|
|
79
|
+
from .input import wire_callbacks as wire_callbacks
|
|
80
|
+
|
|
81
|
+
from .loop import FIXED_DT as FIXED_DT
|
|
82
|
+
from .loop import FixedStepDriver as FixedStepDriver
|
|
83
|
+
from .loop import RenderState as RenderState
|
|
84
|
+
from .loop import run_loop as run_loop
|
|
85
|
+
|
|
86
|
+
from .renderer import Renderer2DSetup as Renderer2DSetup
|
|
87
|
+
from .renderer import Tilemap as Tilemap
|
|
88
|
+
from .renderer import TilemapSetup as TilemapSetup
|
|
89
|
+
from .renderer import setup_renderer_2d as setup_renderer_2d
|
|
90
|
+
from .renderer import setup_tilemap as setup_tilemap
|
|
91
|
+
from .renderer.camera2d import Camera2D as Camera2D
|
|
92
|
+
|
|
93
|
+
from .renderer3d import Camera3D as Camera3D
|
|
94
|
+
from .renderer3d import DirectionalLight as DirectionalLight
|
|
95
|
+
from .renderer3d import PointLight as PointLight
|
|
96
|
+
from .renderer3d import Renderer3D as Renderer3D
|
|
97
|
+
from .renderer3d import Renderer3DSetup as Renderer3DSetup
|
|
98
|
+
from .renderer3d import setup_renderer_3d as setup_renderer_3d
|
|
99
|
+
|
|
100
|
+
from .text import BUILTIN_FONT as BUILTIN_FONT
|
|
101
|
+
from .text import Font as Font
|
|
102
|
+
from .text import TextSetup as TextSetup
|
|
103
|
+
from .text import clear_text as clear_text
|
|
104
|
+
from .text import get_text as get_text
|
|
105
|
+
from .text import load_font as load_font
|
|
106
|
+
from .text import set_label_visible as set_label_visible
|
|
107
|
+
from .text import set_text as set_text
|
|
108
|
+
from .text import setup_text as setup_text
|
|
109
|
+
|
|
110
|
+
from .tools import setup_debug_draw as setup_debug_draw
|
|
111
|
+
from .tools import setup_inspector as setup_inspector
|
|
112
|
+
from .tools import setup_profiler as setup_profiler
|
|
113
|
+
|
|
114
|
+
from .window import Window as Window
|
|
115
|
+
from .window import glfw_initialized as glfw_initialized
|
|
116
|
+
from .window import shutdown_glfw as shutdown_glfw
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- GLFW action constants -------------------------------------------------
|
|
120
|
+
|
|
121
|
+
PRESS: int = _glfw.PRESS
|
|
122
|
+
RELEASE: int = _glfw.RELEASE
|
|
123
|
+
REPEAT: int = _glfw.REPEAT
|
|
124
|
+
|
|
125
|
+
# --- Mouse buttons ---------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
MOUSE_BUTTON_LEFT: int = _glfw.MOUSE_BUTTON_LEFT
|
|
128
|
+
MOUSE_BUTTON_RIGHT: int = _glfw.MOUSE_BUTTON_RIGHT
|
|
129
|
+
MOUSE_BUTTON_MIDDLE: int = _glfw.MOUSE_BUTTON_MIDDLE
|
|
130
|
+
MOUSE_BUTTON_4: int = _glfw.MOUSE_BUTTON_4
|
|
131
|
+
MOUSE_BUTTON_5: int = _glfw.MOUSE_BUTTON_5
|
|
132
|
+
MOUSE_BUTTON_6: int = _glfw.MOUSE_BUTTON_6
|
|
133
|
+
MOUSE_BUTTON_7: int = _glfw.MOUSE_BUTTON_7
|
|
134
|
+
MOUSE_BUTTON_8: int = _glfw.MOUSE_BUTTON_8
|
|
135
|
+
|
|
136
|
+
# --- Gamepad buttons + axes (GLFW aliases) --------------------------------
|
|
137
|
+
|
|
138
|
+
GAMEPAD_BUTTON_A: int = _glfw.GAMEPAD_BUTTON_A
|
|
139
|
+
GAMEPAD_BUTTON_B: int = _glfw.GAMEPAD_BUTTON_B
|
|
140
|
+
GAMEPAD_BUTTON_X: int = _glfw.GAMEPAD_BUTTON_X
|
|
141
|
+
GAMEPAD_BUTTON_Y: int = _glfw.GAMEPAD_BUTTON_Y
|
|
142
|
+
GAMEPAD_BUTTON_LEFT_BUMPER: int = _glfw.GAMEPAD_BUTTON_LEFT_BUMPER
|
|
143
|
+
GAMEPAD_BUTTON_RIGHT_BUMPER: int = _glfw.GAMEPAD_BUTTON_RIGHT_BUMPER
|
|
144
|
+
GAMEPAD_BUTTON_BACK: int = _glfw.GAMEPAD_BUTTON_BACK
|
|
145
|
+
GAMEPAD_BUTTON_START: int = _glfw.GAMEPAD_BUTTON_START
|
|
146
|
+
GAMEPAD_BUTTON_GUIDE: int = _glfw.GAMEPAD_BUTTON_GUIDE
|
|
147
|
+
GAMEPAD_BUTTON_LEFT_THUMB: int = _glfw.GAMEPAD_BUTTON_LEFT_THUMB
|
|
148
|
+
GAMEPAD_BUTTON_RIGHT_THUMB: int = _glfw.GAMEPAD_BUTTON_RIGHT_THUMB
|
|
149
|
+
GAMEPAD_BUTTON_DPAD_UP: int = _glfw.GAMEPAD_BUTTON_DPAD_UP
|
|
150
|
+
GAMEPAD_BUTTON_DPAD_RIGHT: int = _glfw.GAMEPAD_BUTTON_DPAD_RIGHT
|
|
151
|
+
GAMEPAD_BUTTON_DPAD_DOWN: int = _glfw.GAMEPAD_BUTTON_DPAD_DOWN
|
|
152
|
+
GAMEPAD_BUTTON_DPAD_LEFT: int = _glfw.GAMEPAD_BUTTON_DPAD_LEFT
|
|
153
|
+
|
|
154
|
+
GAMEPAD_AXIS_LEFT_X: int = _glfw.GAMEPAD_AXIS_LEFT_X
|
|
155
|
+
GAMEPAD_AXIS_LEFT_Y: int = _glfw.GAMEPAD_AXIS_LEFT_Y
|
|
156
|
+
GAMEPAD_AXIS_RIGHT_X: int = _glfw.GAMEPAD_AXIS_RIGHT_X
|
|
157
|
+
GAMEPAD_AXIS_RIGHT_Y: int = _glfw.GAMEPAD_AXIS_RIGHT_Y
|
|
158
|
+
GAMEPAD_AXIS_LEFT_TRIGGER: int = _glfw.GAMEPAD_AXIS_LEFT_TRIGGER
|
|
159
|
+
GAMEPAD_AXIS_RIGHT_TRIGGER: int = _glfw.GAMEPAD_AXIS_RIGHT_TRIGGER
|
|
160
|
+
|
|
161
|
+
_GAMEPAD_NAMES: list[str] = [
|
|
162
|
+
"GAMEPAD_BUTTON_A", "GAMEPAD_BUTTON_B", "GAMEPAD_BUTTON_X", "GAMEPAD_BUTTON_Y",
|
|
163
|
+
"GAMEPAD_BUTTON_LEFT_BUMPER", "GAMEPAD_BUTTON_RIGHT_BUMPER",
|
|
164
|
+
"GAMEPAD_BUTTON_BACK", "GAMEPAD_BUTTON_START", "GAMEPAD_BUTTON_GUIDE",
|
|
165
|
+
"GAMEPAD_BUTTON_LEFT_THUMB", "GAMEPAD_BUTTON_RIGHT_THUMB",
|
|
166
|
+
"GAMEPAD_BUTTON_DPAD_UP", "GAMEPAD_BUTTON_DPAD_RIGHT",
|
|
167
|
+
"GAMEPAD_BUTTON_DPAD_DOWN", "GAMEPAD_BUTTON_DPAD_LEFT",
|
|
168
|
+
"GAMEPAD_AXIS_LEFT_X", "GAMEPAD_AXIS_LEFT_Y",
|
|
169
|
+
"GAMEPAD_AXIS_RIGHT_X", "GAMEPAD_AXIS_RIGHT_Y",
|
|
170
|
+
"GAMEPAD_AXIS_LEFT_TRIGGER", "GAMEPAD_AXIS_RIGHT_TRIGGER",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
# --- Re-export every glfw.KEY_* constant under the keel.* namespace --------
|
|
174
|
+
|
|
175
|
+
_KEY_NAMES: list[str] = []
|
|
176
|
+
for _name in dir(_glfw):
|
|
177
|
+
if _name.startswith("KEY_") and _name.isupper():
|
|
178
|
+
globals()[_name] = getattr(_glfw, _name)
|
|
179
|
+
_KEY_NAMES.append(_name)
|
|
180
|
+
del _name
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class App:
|
|
184
|
+
"""Top-level entry point: window + world + scheduler + input wiring."""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
title: str = "Keel",
|
|
189
|
+
width: int = 800,
|
|
190
|
+
height: int = 600,
|
|
191
|
+
vsync: bool = True,
|
|
192
|
+
) -> None:
|
|
193
|
+
self.world: World = World()
|
|
194
|
+
self.window: Window = Window(title, width, height, vsync)
|
|
195
|
+
self.input: InputState = InputState()
|
|
196
|
+
# Expose the InputState as a world resource so the run_loop (and any
|
|
197
|
+
# system using resource injection) can call input.begin_frame() to
|
|
198
|
+
# drive edge-detected key/button helpers.
|
|
199
|
+
self.world.insert_resource(self.input, type_=InputState)
|
|
200
|
+
# One scheduler per app: share World.scheduler so @app.system(...) and
|
|
201
|
+
# @world.system(...) target the same registry. Previously App owned a
|
|
202
|
+
# separate Scheduler() that the loop drove, while @world.system fired
|
|
203
|
+
# only when world.tick() was invoked manually — a silent footgun.
|
|
204
|
+
self._scheduler: Scheduler = self.world.scheduler
|
|
205
|
+
self._callbacks_keepalive = wire_callbacks(
|
|
206
|
+
self.window._glfw_window, self.world, self.input, window_obj=self.window
|
|
207
|
+
)
|
|
208
|
+
self._shutdown_hooks: list = []
|
|
209
|
+
self._has_run: bool = False
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def ctx(self) -> moderngl.Context:
|
|
213
|
+
"""The single ModernGL context owned by the window — pass to renderer systems."""
|
|
214
|
+
return self.window.ctx
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def scheduler(self) -> Scheduler:
|
|
218
|
+
"""The scheduler driven by App.run."""
|
|
219
|
+
return self._scheduler
|
|
220
|
+
|
|
221
|
+
def system(self, phase: Phase):
|
|
222
|
+
"""Decorator: register a system function in the given phase."""
|
|
223
|
+
def decorator(fn):
|
|
224
|
+
self._scheduler.register(phase, fn)
|
|
225
|
+
return fn
|
|
226
|
+
return decorator
|
|
227
|
+
|
|
228
|
+
def insert_resource(self, resource, *, type_=None) -> None:
|
|
229
|
+
"""Forward to world.insert_resource for ergonomic top-level access."""
|
|
230
|
+
self.world.insert_resource(resource, type_=type_)
|
|
231
|
+
|
|
232
|
+
def setup_assets(self, watch_dirs: list[str] | None = None) -> AssetRegistry:
|
|
233
|
+
"""Create / return the AssetRegistry, register default loaders + watcher."""
|
|
234
|
+
return _setup_assets(self, watch_dirs)
|
|
235
|
+
|
|
236
|
+
def add_shutdown_hook(self, hook) -> None:
|
|
237
|
+
"""Register a callable to run when App.run() exits (after the loop, before GLFW shutdown)."""
|
|
238
|
+
self._shutdown_hooks.append(hook)
|
|
239
|
+
|
|
240
|
+
def _run_shutdown_hooks(self) -> None:
|
|
241
|
+
"""Invoke every registered shutdown hook, swallowing per-hook exceptions."""
|
|
242
|
+
for hook in self._shutdown_hooks:
|
|
243
|
+
try:
|
|
244
|
+
hook()
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
def run(self) -> None:
|
|
249
|
+
"""Block until the window closes, then run shutdown hooks and terminate GLFW.
|
|
250
|
+
|
|
251
|
+
Single-shot: calling run() again after the loop has exited (the window
|
|
252
|
+
was closed, an exception propagated, etc.) raises RuntimeError. Build
|
|
253
|
+
a new App instead.
|
|
254
|
+
"""
|
|
255
|
+
if self._has_run:
|
|
256
|
+
raise RuntimeError(
|
|
257
|
+
"App.run() has already been called — the window and GLFW "
|
|
258
|
+
"context were torn down on exit. Build a new App() instance."
|
|
259
|
+
)
|
|
260
|
+
self._has_run = True
|
|
261
|
+
try:
|
|
262
|
+
run_loop(self.window, self.world, self._scheduler)
|
|
263
|
+
finally:
|
|
264
|
+
self._run_shutdown_hooks()
|
|
265
|
+
self.window.destroy()
|
|
266
|
+
shutdown_glfw()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class DevTools:
|
|
270
|
+
"""One-call developer tooling bundle: profiler, inspector, and (if physics is set up) debug draw."""
|
|
271
|
+
|
|
272
|
+
def __init__(self, app: "App") -> None:
|
|
273
|
+
from .tools.debug_draw import setup_debug_draw
|
|
274
|
+
from .tools.inspector import setup_inspector
|
|
275
|
+
from .tools.profiler import setup_profiler
|
|
276
|
+
|
|
277
|
+
self.app = app
|
|
278
|
+
self.profiler = setup_profiler(app)
|
|
279
|
+
|
|
280
|
+
# Register debug_draw BEFORE the inspector so its POST_RENDER system
|
|
281
|
+
# runs first; the inspector's ImGui submission then draws on top of
|
|
282
|
+
# the GL line overlay rather than under it.
|
|
283
|
+
try:
|
|
284
|
+
from .physics import Physics2D as _Physics2D
|
|
285
|
+
has_2d = app.world.has_resource(_Physics2D)
|
|
286
|
+
except ImportError: # pragma: no cover - physics is a hard dep but be safe
|
|
287
|
+
has_2d = False
|
|
288
|
+
|
|
289
|
+
if has_2d:
|
|
290
|
+
self.debug_draw = setup_debug_draw(app)
|
|
291
|
+
else:
|
|
292
|
+
self.debug_draw = None
|
|
293
|
+
|
|
294
|
+
self.inspector = setup_inspector(app)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def dev_tools(app: "App") -> DevTools:
|
|
298
|
+
"""Convenience: build (or return the cached) DevTools bundle for `app`."""
|
|
299
|
+
existing = getattr(app, "_keel_dev_tools", None)
|
|
300
|
+
if existing is not None:
|
|
301
|
+
return existing
|
|
302
|
+
tools = DevTools(app)
|
|
303
|
+
setattr(app, "_keel_dev_tools", tools)
|
|
304
|
+
return tools
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
__all__ = [
|
|
308
|
+
"App",
|
|
309
|
+
"AssetHandle",
|
|
310
|
+
"AssetNotFoundError",
|
|
311
|
+
"AssetRegistry",
|
|
312
|
+
"AudioEngine",
|
|
313
|
+
"AudioSetup",
|
|
314
|
+
"AudioSource",
|
|
315
|
+
"BodyType",
|
|
316
|
+
"BUILTIN_FONT",
|
|
317
|
+
"Camera2D",
|
|
318
|
+
"Camera3D",
|
|
319
|
+
"Collider2D",
|
|
320
|
+
"Collider3D",
|
|
321
|
+
"CollisionEvent2D",
|
|
322
|
+
"CollisionEvent3D",
|
|
323
|
+
"CommandBuffer",
|
|
324
|
+
"DevTools",
|
|
325
|
+
"DirectionalLight",
|
|
326
|
+
"FIXED_DT",
|
|
327
|
+
"FileWatcher",
|
|
328
|
+
"FixedStepDriver",
|
|
329
|
+
"Font",
|
|
330
|
+
"GamepadAxisEvent",
|
|
331
|
+
"GamepadButtonEvent",
|
|
332
|
+
"GamepadState",
|
|
333
|
+
"InputState",
|
|
334
|
+
"InvalidHandleError",
|
|
335
|
+
"KeyEvent",
|
|
336
|
+
"MouseButtonEvent",
|
|
337
|
+
"MouseMoveEvent",
|
|
338
|
+
"MeshRenderer",
|
|
339
|
+
"MouseScrollEvent",
|
|
340
|
+
"NULL_ENTITY",
|
|
341
|
+
"NoLoaderError",
|
|
342
|
+
"Optional",
|
|
343
|
+
"Phase",
|
|
344
|
+
"Physics2D",
|
|
345
|
+
"Physics3D",
|
|
346
|
+
"PointLight",
|
|
347
|
+
"PRESS",
|
|
348
|
+
"QueryResult",
|
|
349
|
+
"RELEASE",
|
|
350
|
+
"REPEAT",
|
|
351
|
+
"Renderer2DSetup",
|
|
352
|
+
"Renderer3D",
|
|
353
|
+
"Renderer3DSetup",
|
|
354
|
+
"RenderState",
|
|
355
|
+
"RigidBody2D",
|
|
356
|
+
"RigidBody3D",
|
|
357
|
+
"Scene",
|
|
358
|
+
"SceneVersionError",
|
|
359
|
+
"Scheduler",
|
|
360
|
+
"ShapeType2D",
|
|
361
|
+
"ShapeType3D",
|
|
362
|
+
"SoundHandle",
|
|
363
|
+
"Sprite",
|
|
364
|
+
"TextLabel",
|
|
365
|
+
"TextSetup",
|
|
366
|
+
"Tilemap",
|
|
367
|
+
"TilemapSetup",
|
|
368
|
+
"Transform2D",
|
|
369
|
+
"Transform3D",
|
|
370
|
+
"Window",
|
|
371
|
+
"WindowResizeEvent",
|
|
372
|
+
"Without",
|
|
373
|
+
"World",
|
|
374
|
+
"clear_text",
|
|
375
|
+
"dev_tools",
|
|
376
|
+
"get_text",
|
|
377
|
+
"load_font",
|
|
378
|
+
"play_music",
|
|
379
|
+
"play_sound",
|
|
380
|
+
"set_label_visible",
|
|
381
|
+
"set_text",
|
|
382
|
+
"set_volume",
|
|
383
|
+
"setup_assets",
|
|
384
|
+
"setup_audio",
|
|
385
|
+
"setup_debug_draw",
|
|
386
|
+
"setup_gamepad",
|
|
387
|
+
"setup_inspector",
|
|
388
|
+
"setup_physics_2d",
|
|
389
|
+
"setup_physics_3d",
|
|
390
|
+
"setup_profiler",
|
|
391
|
+
"setup_renderer_2d",
|
|
392
|
+
"setup_renderer_3d",
|
|
393
|
+
"setup_text",
|
|
394
|
+
"setup_tilemap",
|
|
395
|
+
"stop_music",
|
|
396
|
+
"stop_sound",
|
|
397
|
+
"MOUSE_BUTTON_LEFT",
|
|
398
|
+
"MOUSE_BUTTON_RIGHT",
|
|
399
|
+
"MOUSE_BUTTON_MIDDLE",
|
|
400
|
+
"MOUSE_BUTTON_4",
|
|
401
|
+
"MOUSE_BUTTON_5",
|
|
402
|
+
"MOUSE_BUTTON_6",
|
|
403
|
+
"MOUSE_BUTTON_7",
|
|
404
|
+
"MOUSE_BUTTON_8",
|
|
405
|
+
"component",
|
|
406
|
+
"event",
|
|
407
|
+
"glfw_initialized",
|
|
408
|
+
"make_callbacks",
|
|
409
|
+
"run_loop",
|
|
410
|
+
"shutdown_glfw",
|
|
411
|
+
"wire_callbacks",
|
|
412
|
+
] + _KEY_NAMES + _GAMEPAD_NAMES
|