keelpy 0.7.0__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.
Files changed (79) hide show
  1. keelpy-0.7.0/PKG-INFO +18 -0
  2. keelpy-0.7.0/README.md +211 -0
  3. keelpy-0.7.0/keel/__init__.py +406 -0
  4. keelpy-0.7.0/keel/assets/__init__.py +68 -0
  5. keelpy-0.7.0/keel/assets/hot_reload.py +141 -0
  6. keelpy-0.7.0/keel/assets/loaders/__init__.py +5 -0
  7. keelpy-0.7.0/keel/assets/loaders/json_loader.py +11 -0
  8. keelpy-0.7.0/keel/assets/loaders/texture_loader.py +27 -0
  9. keelpy-0.7.0/keel/assets/registry.py +162 -0
  10. keelpy-0.7.0/keel/assets/scene.py +193 -0
  11. keelpy-0.7.0/keel/audio/__init__.py +110 -0
  12. keelpy-0.7.0/keel/audio/audio_engine.py +428 -0
  13. keelpy-0.7.0/keel/audio/components.py +27 -0
  14. keelpy-0.7.0/keel/cli/__init__.py +6 -0
  15. keelpy-0.7.0/keel/cli/commands.py +191 -0
  16. keelpy-0.7.0/keel/cli/templates.py +69 -0
  17. keelpy-0.7.0/keel/components/__init__.py +8 -0
  18. keelpy-0.7.0/keel/components/mesh_renderer.py +14 -0
  19. keelpy-0.7.0/keel/components/sprite.py +18 -0
  20. keelpy-0.7.0/keel/components/text_label.py +29 -0
  21. keelpy-0.7.0/keel/components/transform2d.py +14 -0
  22. keelpy-0.7.0/keel/components/transform3d.py +19 -0
  23. keelpy-0.7.0/keel/core/__init__.py +32 -0
  24. keelpy-0.7.0/keel/core/archetype.py +227 -0
  25. keelpy-0.7.0/keel/core/events.py +49 -0
  26. keelpy-0.7.0/keel/core/query.py +155 -0
  27. keelpy-0.7.0/keel/core/scheduler.py +133 -0
  28. keelpy-0.7.0/keel/core/world.py +374 -0
  29. keelpy-0.7.0/keel/gamepad.py +125 -0
  30. keelpy-0.7.0/keel/input.py +243 -0
  31. keelpy-0.7.0/keel/loop.py +123 -0
  32. keelpy-0.7.0/keel/physics/__init__.py +114 -0
  33. keelpy-0.7.0/keel/physics/components2d.py +68 -0
  34. keelpy-0.7.0/keel/physics/components3d.py +61 -0
  35. keelpy-0.7.0/keel/physics/physics2d.py +432 -0
  36. keelpy-0.7.0/keel/physics/physics3d.py +599 -0
  37. keelpy-0.7.0/keel/py.typed +0 -0
  38. keelpy-0.7.0/keel/renderer/__init__.py +184 -0
  39. keelpy-0.7.0/keel/renderer/batch2d.py +183 -0
  40. keelpy-0.7.0/keel/renderer/camera2d.py +79 -0
  41. keelpy-0.7.0/keel/renderer/shader.py +122 -0
  42. keelpy-0.7.0/keel/renderer/texture.py +118 -0
  43. keelpy-0.7.0/keel/renderer/tilemap.py +156 -0
  44. keelpy-0.7.0/keel/renderer3d/__init__.py +397 -0
  45. keelpy-0.7.0/keel/renderer3d/camera3d.py +111 -0
  46. keelpy-0.7.0/keel/renderer3d/frustum.py +82 -0
  47. keelpy-0.7.0/keel/renderer3d/lighting.py +50 -0
  48. keelpy-0.7.0/keel/renderer3d/material.py +55 -0
  49. keelpy-0.7.0/keel/renderer3d/mesh.py +294 -0
  50. keelpy-0.7.0/keel/renderer3d/shader3d.py +166 -0
  51. keelpy-0.7.0/keel/renderer3d/transform3d.py +134 -0
  52. keelpy-0.7.0/keel/text/__init__.py +132 -0
  53. keelpy-0.7.0/keel/text/builtin_font.py +6016 -0
  54. keelpy-0.7.0/keel/text/font.py +384 -0
  55. keelpy-0.7.0/keel/text/shader_text.py +40 -0
  56. keelpy-0.7.0/keel/text/text_renderer.py +341 -0
  57. keelpy-0.7.0/keel/tools/__init__.py +17 -0
  58. keelpy-0.7.0/keel/tools/debug_draw.py +288 -0
  59. keelpy-0.7.0/keel/tools/inspector.py +453 -0
  60. keelpy-0.7.0/keel/tools/profiler.py +89 -0
  61. keelpy-0.7.0/keel/window.py +137 -0
  62. keelpy-0.7.0/keelpy.egg-info/PKG-INFO +18 -0
  63. keelpy-0.7.0/keelpy.egg-info/SOURCES.txt +77 -0
  64. keelpy-0.7.0/keelpy.egg-info/dependency_links.txt +1 -0
  65. keelpy-0.7.0/keelpy.egg-info/entry_points.txt +2 -0
  66. keelpy-0.7.0/keelpy.egg-info/requires.txt +15 -0
  67. keelpy-0.7.0/keelpy.egg-info/top_level.txt +1 -0
  68. keelpy-0.7.0/pyproject.toml +38 -0
  69. keelpy-0.7.0/setup.cfg +4 -0
  70. keelpy-0.7.0/tests/test_assets.py +572 -0
  71. keelpy-0.7.0/tests/test_audio.py +494 -0
  72. keelpy-0.7.0/tests/test_ecs_core.py +639 -0
  73. keelpy-0.7.0/tests/test_gamepad.py +374 -0
  74. keelpy-0.7.0/tests/test_physics.py +800 -0
  75. keelpy-0.7.0/tests/test_renderer2d.py +586 -0
  76. keelpy-0.7.0/tests/test_renderer3d.py +582 -0
  77. keelpy-0.7.0/tests/test_text.py +466 -0
  78. keelpy-0.7.0/tests/test_tooling.py +613 -0
  79. keelpy-0.7.0/tests/test_window_loop.py +533 -0
keelpy-0.7.0/PKG-INFO ADDED
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: keelpy
3
+ Version: 0.7.0
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.7.0/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 keel
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,406 @@
1
+ """Keel — a Python game engine. Top-level public surface."""
2
+ from __future__ import annotations
3
+
4
+ __version__ = "0.1.0"
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 Collider2D as Collider2D
43
+ from .physics import Collider3D as Collider3D
44
+ from .physics import CollisionEvent2D as CollisionEvent2D
45
+ from .physics import CollisionEvent3D as CollisionEvent3D
46
+ from .physics import Physics2D as Physics2D
47
+ from .physics import Physics3D as Physics3D
48
+ from .physics import RigidBody2D as RigidBody2D
49
+ from .physics import RigidBody3D as RigidBody3D
50
+ from .physics import setup_physics_2d as setup_physics_2d
51
+ from .physics import setup_physics_3d as setup_physics_3d
52
+
53
+ from .core import CommandBuffer as CommandBuffer
54
+ from .core import NULL_ENTITY as NULL_ENTITY
55
+ from .core import Optional as Optional
56
+ from .core import Phase as Phase
57
+ from .core import QueryResult as QueryResult
58
+ from .core import Without as Without
59
+ from .core import World as World
60
+ from .core import component as component
61
+ from .core import event as event
62
+ from .core.scheduler import Scheduler as Scheduler
63
+
64
+ from .gamepad import GamepadState as GamepadState
65
+ from .gamepad import setup_gamepad as setup_gamepad
66
+
67
+ from .input import GamepadAxisEvent as GamepadAxisEvent
68
+ from .input import GamepadButtonEvent as GamepadButtonEvent
69
+ from .input import InputState as InputState
70
+ from .input import KeyEvent as KeyEvent
71
+ from .input import MouseButtonEvent as MouseButtonEvent
72
+ from .input import MouseMoveEvent as MouseMoveEvent
73
+ from .input import MouseScrollEvent as MouseScrollEvent
74
+ from .input import WindowResizeEvent as WindowResizeEvent
75
+ from .input import make_callbacks as make_callbacks
76
+ from .input import wire_callbacks as wire_callbacks
77
+
78
+ from .loop import FIXED_DT as FIXED_DT
79
+ from .loop import FixedStepDriver as FixedStepDriver
80
+ from .loop import RenderState as RenderState
81
+ from .loop import run_loop as run_loop
82
+
83
+ from .renderer import Renderer2DSetup as Renderer2DSetup
84
+ from .renderer import Tilemap as Tilemap
85
+ from .renderer import TilemapSetup as TilemapSetup
86
+ from .renderer import setup_renderer_2d as setup_renderer_2d
87
+ from .renderer import setup_tilemap as setup_tilemap
88
+ from .renderer.camera2d import Camera2D as Camera2D
89
+
90
+ from .renderer3d import Camera3D as Camera3D
91
+ from .renderer3d import DirectionalLight as DirectionalLight
92
+ from .renderer3d import PointLight as PointLight
93
+ from .renderer3d import Renderer3D as Renderer3D
94
+ from .renderer3d import Renderer3DSetup as Renderer3DSetup
95
+ from .renderer3d import setup_renderer_3d as setup_renderer_3d
96
+
97
+ from .text import BUILTIN_FONT as BUILTIN_FONT
98
+ from .text import Font as Font
99
+ from .text import TextSetup as TextSetup
100
+ from .text import clear_text as clear_text
101
+ from .text import get_text as get_text
102
+ from .text import load_font as load_font
103
+ from .text import set_label_visible as set_label_visible
104
+ from .text import set_text as set_text
105
+ from .text import setup_text as setup_text
106
+
107
+ from .tools import setup_debug_draw as setup_debug_draw
108
+ from .tools import setup_inspector as setup_inspector
109
+ from .tools import setup_profiler as setup_profiler
110
+
111
+ from .window import Window as Window
112
+ from .window import glfw_initialized as glfw_initialized
113
+ from .window import shutdown_glfw as shutdown_glfw
114
+
115
+
116
+ # --- GLFW action constants -------------------------------------------------
117
+
118
+ PRESS: int = _glfw.PRESS
119
+ RELEASE: int = _glfw.RELEASE
120
+ REPEAT: int = _glfw.REPEAT
121
+
122
+ # --- Mouse buttons ---------------------------------------------------------
123
+
124
+ MOUSE_BUTTON_LEFT: int = _glfw.MOUSE_BUTTON_LEFT
125
+ MOUSE_BUTTON_RIGHT: int = _glfw.MOUSE_BUTTON_RIGHT
126
+ MOUSE_BUTTON_MIDDLE: int = _glfw.MOUSE_BUTTON_MIDDLE
127
+ MOUSE_BUTTON_4: int = _glfw.MOUSE_BUTTON_4
128
+ MOUSE_BUTTON_5: int = _glfw.MOUSE_BUTTON_5
129
+ MOUSE_BUTTON_6: int = _glfw.MOUSE_BUTTON_6
130
+ MOUSE_BUTTON_7: int = _glfw.MOUSE_BUTTON_7
131
+ MOUSE_BUTTON_8: int = _glfw.MOUSE_BUTTON_8
132
+
133
+ # --- Gamepad buttons + axes (GLFW aliases) --------------------------------
134
+
135
+ GAMEPAD_BUTTON_A: int = _glfw.GAMEPAD_BUTTON_A
136
+ GAMEPAD_BUTTON_B: int = _glfw.GAMEPAD_BUTTON_B
137
+ GAMEPAD_BUTTON_X: int = _glfw.GAMEPAD_BUTTON_X
138
+ GAMEPAD_BUTTON_Y: int = _glfw.GAMEPAD_BUTTON_Y
139
+ GAMEPAD_BUTTON_LEFT_BUMPER: int = _glfw.GAMEPAD_BUTTON_LEFT_BUMPER
140
+ GAMEPAD_BUTTON_RIGHT_BUMPER: int = _glfw.GAMEPAD_BUTTON_RIGHT_BUMPER
141
+ GAMEPAD_BUTTON_BACK: int = _glfw.GAMEPAD_BUTTON_BACK
142
+ GAMEPAD_BUTTON_START: int = _glfw.GAMEPAD_BUTTON_START
143
+ GAMEPAD_BUTTON_GUIDE: int = _glfw.GAMEPAD_BUTTON_GUIDE
144
+ GAMEPAD_BUTTON_LEFT_THUMB: int = _glfw.GAMEPAD_BUTTON_LEFT_THUMB
145
+ GAMEPAD_BUTTON_RIGHT_THUMB: int = _glfw.GAMEPAD_BUTTON_RIGHT_THUMB
146
+ GAMEPAD_BUTTON_DPAD_UP: int = _glfw.GAMEPAD_BUTTON_DPAD_UP
147
+ GAMEPAD_BUTTON_DPAD_RIGHT: int = _glfw.GAMEPAD_BUTTON_DPAD_RIGHT
148
+ GAMEPAD_BUTTON_DPAD_DOWN: int = _glfw.GAMEPAD_BUTTON_DPAD_DOWN
149
+ GAMEPAD_BUTTON_DPAD_LEFT: int = _glfw.GAMEPAD_BUTTON_DPAD_LEFT
150
+
151
+ GAMEPAD_AXIS_LEFT_X: int = _glfw.GAMEPAD_AXIS_LEFT_X
152
+ GAMEPAD_AXIS_LEFT_Y: int = _glfw.GAMEPAD_AXIS_LEFT_Y
153
+ GAMEPAD_AXIS_RIGHT_X: int = _glfw.GAMEPAD_AXIS_RIGHT_X
154
+ GAMEPAD_AXIS_RIGHT_Y: int = _glfw.GAMEPAD_AXIS_RIGHT_Y
155
+ GAMEPAD_AXIS_LEFT_TRIGGER: int = _glfw.GAMEPAD_AXIS_LEFT_TRIGGER
156
+ GAMEPAD_AXIS_RIGHT_TRIGGER: int = _glfw.GAMEPAD_AXIS_RIGHT_TRIGGER
157
+
158
+ _GAMEPAD_NAMES: list[str] = [
159
+ "GAMEPAD_BUTTON_A", "GAMEPAD_BUTTON_B", "GAMEPAD_BUTTON_X", "GAMEPAD_BUTTON_Y",
160
+ "GAMEPAD_BUTTON_LEFT_BUMPER", "GAMEPAD_BUTTON_RIGHT_BUMPER",
161
+ "GAMEPAD_BUTTON_BACK", "GAMEPAD_BUTTON_START", "GAMEPAD_BUTTON_GUIDE",
162
+ "GAMEPAD_BUTTON_LEFT_THUMB", "GAMEPAD_BUTTON_RIGHT_THUMB",
163
+ "GAMEPAD_BUTTON_DPAD_UP", "GAMEPAD_BUTTON_DPAD_RIGHT",
164
+ "GAMEPAD_BUTTON_DPAD_DOWN", "GAMEPAD_BUTTON_DPAD_LEFT",
165
+ "GAMEPAD_AXIS_LEFT_X", "GAMEPAD_AXIS_LEFT_Y",
166
+ "GAMEPAD_AXIS_RIGHT_X", "GAMEPAD_AXIS_RIGHT_Y",
167
+ "GAMEPAD_AXIS_LEFT_TRIGGER", "GAMEPAD_AXIS_RIGHT_TRIGGER",
168
+ ]
169
+
170
+ # --- Re-export every glfw.KEY_* constant under the keel.* namespace --------
171
+
172
+ _KEY_NAMES: list[str] = []
173
+ for _name in dir(_glfw):
174
+ if _name.startswith("KEY_") and _name.isupper():
175
+ globals()[_name] = getattr(_glfw, _name)
176
+ _KEY_NAMES.append(_name)
177
+ del _name
178
+
179
+
180
+ class App:
181
+ """Top-level entry point: window + world + scheduler + input wiring."""
182
+
183
+ def __init__(
184
+ self,
185
+ title: str = "Keel",
186
+ width: int = 800,
187
+ height: int = 600,
188
+ vsync: bool = True,
189
+ ) -> None:
190
+ self.world: World = World()
191
+ self.window: Window = Window(title, width, height, vsync)
192
+ self.input: InputState = InputState()
193
+ # Expose the InputState as a world resource so the run_loop (and any
194
+ # system using resource injection) can call input.begin_frame() to
195
+ # drive edge-detected key/button helpers.
196
+ self.world.insert_resource(self.input, type_=InputState)
197
+ # One scheduler per app: share World.scheduler so @app.system(...) and
198
+ # @world.system(...) target the same registry. Previously App owned a
199
+ # separate Scheduler() that the loop drove, while @world.system fired
200
+ # only when world.tick() was invoked manually — a silent footgun.
201
+ self._scheduler: Scheduler = self.world.scheduler
202
+ self._callbacks_keepalive = wire_callbacks(
203
+ self.window._glfw_window, self.world, self.input, window_obj=self.window
204
+ )
205
+ self._shutdown_hooks: list = []
206
+ self._has_run: bool = False
207
+
208
+ @property
209
+ def ctx(self) -> moderngl.Context:
210
+ """The single ModernGL context owned by the window — pass to renderer systems."""
211
+ return self.window.ctx
212
+
213
+ @property
214
+ def scheduler(self) -> Scheduler:
215
+ """The scheduler driven by App.run."""
216
+ return self._scheduler
217
+
218
+ def system(self, phase: Phase):
219
+ """Decorator: register a system function in the given phase."""
220
+ def decorator(fn):
221
+ self._scheduler.register(phase, fn)
222
+ return fn
223
+ return decorator
224
+
225
+ def insert_resource(self, resource, *, type_=None) -> None:
226
+ """Forward to world.insert_resource for ergonomic top-level access."""
227
+ self.world.insert_resource(resource, type_=type_)
228
+
229
+ def setup_assets(self, watch_dirs: list[str] | None = None) -> AssetRegistry:
230
+ """Create / return the AssetRegistry, register default loaders + watcher."""
231
+ return _setup_assets(self, watch_dirs)
232
+
233
+ def add_shutdown_hook(self, hook) -> None:
234
+ """Register a callable to run when App.run() exits (after the loop, before GLFW shutdown)."""
235
+ self._shutdown_hooks.append(hook)
236
+
237
+ def _run_shutdown_hooks(self) -> None:
238
+ """Invoke every registered shutdown hook, swallowing per-hook exceptions."""
239
+ for hook in self._shutdown_hooks:
240
+ try:
241
+ hook()
242
+ except Exception:
243
+ pass
244
+
245
+ def run(self) -> None:
246
+ """Block until the window closes, then run shutdown hooks and terminate GLFW.
247
+
248
+ Single-shot: calling run() again after the loop has exited (the window
249
+ was closed, an exception propagated, etc.) raises RuntimeError. Build
250
+ a new App instead.
251
+ """
252
+ if self._has_run:
253
+ raise RuntimeError(
254
+ "App.run() has already been called — the window and GLFW "
255
+ "context were torn down on exit. Build a new App() instance."
256
+ )
257
+ self._has_run = True
258
+ try:
259
+ run_loop(self.window, self.world, self._scheduler)
260
+ finally:
261
+ self._run_shutdown_hooks()
262
+ self.window.destroy()
263
+ shutdown_glfw()
264
+
265
+
266
+ class DevTools:
267
+ """One-call developer tooling bundle: profiler, inspector, and (if physics is set up) debug draw."""
268
+
269
+ def __init__(self, app: "App") -> None:
270
+ from .tools.debug_draw import setup_debug_draw
271
+ from .tools.inspector import setup_inspector
272
+ from .tools.profiler import setup_profiler
273
+
274
+ self.app = app
275
+ self.profiler = setup_profiler(app)
276
+
277
+ # Register debug_draw BEFORE the inspector so its POST_RENDER system
278
+ # runs first; the inspector's ImGui submission then draws on top of
279
+ # the GL line overlay rather than under it.
280
+ try:
281
+ from .physics import Physics2D as _Physics2D
282
+ has_2d = app.world.has_resource(_Physics2D)
283
+ except ImportError: # pragma: no cover - physics is a hard dep but be safe
284
+ has_2d = False
285
+
286
+ if has_2d:
287
+ self.debug_draw = setup_debug_draw(app)
288
+ else:
289
+ self.debug_draw = None
290
+
291
+ self.inspector = setup_inspector(app)
292
+
293
+
294
+ def dev_tools(app: "App") -> DevTools:
295
+ """Convenience: build (or return the cached) DevTools bundle for `app`."""
296
+ existing = getattr(app, "_keel_dev_tools", None)
297
+ if existing is not None:
298
+ return existing
299
+ tools = DevTools(app)
300
+ setattr(app, "_keel_dev_tools", tools)
301
+ return tools
302
+
303
+
304
+ __all__ = [
305
+ "App",
306
+ "AssetHandle",
307
+ "AssetNotFoundError",
308
+ "AssetRegistry",
309
+ "AudioEngine",
310
+ "AudioSetup",
311
+ "AudioSource",
312
+ "BUILTIN_FONT",
313
+ "Camera2D",
314
+ "Camera3D",
315
+ "Collider2D",
316
+ "Collider3D",
317
+ "CollisionEvent2D",
318
+ "CollisionEvent3D",
319
+ "CommandBuffer",
320
+ "DevTools",
321
+ "DirectionalLight",
322
+ "FIXED_DT",
323
+ "FileWatcher",
324
+ "FixedStepDriver",
325
+ "Font",
326
+ "GamepadAxisEvent",
327
+ "GamepadButtonEvent",
328
+ "GamepadState",
329
+ "InputState",
330
+ "InvalidHandleError",
331
+ "KeyEvent",
332
+ "MouseButtonEvent",
333
+ "MouseMoveEvent",
334
+ "MeshRenderer",
335
+ "MouseScrollEvent",
336
+ "NULL_ENTITY",
337
+ "NoLoaderError",
338
+ "Optional",
339
+ "Phase",
340
+ "Physics2D",
341
+ "Physics3D",
342
+ "PointLight",
343
+ "PRESS",
344
+ "QueryResult",
345
+ "RELEASE",
346
+ "REPEAT",
347
+ "Renderer2DSetup",
348
+ "Renderer3D",
349
+ "Renderer3DSetup",
350
+ "RenderState",
351
+ "RigidBody2D",
352
+ "RigidBody3D",
353
+ "Scene",
354
+ "SceneVersionError",
355
+ "Scheduler",
356
+ "SoundHandle",
357
+ "Sprite",
358
+ "TextLabel",
359
+ "TextSetup",
360
+ "Tilemap",
361
+ "TilemapSetup",
362
+ "Transform2D",
363
+ "Transform3D",
364
+ "Window",
365
+ "WindowResizeEvent",
366
+ "Without",
367
+ "World",
368
+ "clear_text",
369
+ "dev_tools",
370
+ "get_text",
371
+ "load_font",
372
+ "play_music",
373
+ "play_sound",
374
+ "set_label_visible",
375
+ "set_text",
376
+ "set_volume",
377
+ "setup_assets",
378
+ "setup_audio",
379
+ "setup_debug_draw",
380
+ "setup_gamepad",
381
+ "setup_inspector",
382
+ "setup_physics_2d",
383
+ "setup_physics_3d",
384
+ "setup_profiler",
385
+ "setup_renderer_2d",
386
+ "setup_renderer_3d",
387
+ "setup_text",
388
+ "setup_tilemap",
389
+ "stop_music",
390
+ "stop_sound",
391
+ "MOUSE_BUTTON_LEFT",
392
+ "MOUSE_BUTTON_RIGHT",
393
+ "MOUSE_BUTTON_MIDDLE",
394
+ "MOUSE_BUTTON_4",
395
+ "MOUSE_BUTTON_5",
396
+ "MOUSE_BUTTON_6",
397
+ "MOUSE_BUTTON_7",
398
+ "MOUSE_BUTTON_8",
399
+ "component",
400
+ "event",
401
+ "glfw_initialized",
402
+ "make_callbacks",
403
+ "run_loop",
404
+ "shutdown_glfw",
405
+ "wire_callbacks",
406
+ ] + _KEY_NAMES + _GAMEPAD_NAMES