ludos 0.1.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.
- ludos-0.1.0/.github/workflows/publish.yml +27 -0
- ludos-0.1.0/.gitignore +20 -0
- ludos-0.1.0/LICENSE +21 -0
- ludos-0.1.0/PKG-INFO +85 -0
- ludos-0.1.0/README.md +59 -0
- ludos-0.1.0/docs/guide.md +491 -0
- ludos-0.1.0/pyproject.toml +46 -0
- ludos-0.1.0/src/ludos/__init__.py +43 -0
- ludos-0.1.0/src/ludos/display/__init__.py +0 -0
- ludos-0.1.0/src/ludos/display/window.py +58 -0
- ludos-0.1.0/src/ludos/engine.py +143 -0
- ludos-0.1.0/src/ludos/errors.py +25 -0
- ludos-0.1.0/src/ludos/input/__init__.py +0 -0
- ludos-0.1.0/src/ludos/input/bindings.py +62 -0
- ludos-0.1.0/src/ludos/input/events.py +34 -0
- ludos-0.1.0/src/ludos/input/handler.py +85 -0
- ludos-0.1.0/src/ludos/scenes/__init__.py +0 -0
- ludos-0.1.0/src/ludos/scenes/base.py +34 -0
- ludos-0.1.0/src/ludos/scenes/manager.py +62 -0
- ludos-0.1.0/src/ludos/scenes/menu.py +120 -0
- ludos-0.1.0/src/ludos/state/__init__.py +0 -0
- ludos-0.1.0/src/ludos/state/base.py +22 -0
- ludos-0.1.0/src/ludos/state/manager.py +50 -0
- ludos-0.1.0/tests/__init__.py +0 -0
- ludos-0.1.0/tests/conftest.py +38 -0
- ludos-0.1.0/tests/test_display/__init__.py +0 -0
- ludos-0.1.0/tests/test_display/test_window.py +69 -0
- ludos-0.1.0/tests/test_docs_sync.py +266 -0
- ludos-0.1.0/tests/test_engine.py +195 -0
- ludos-0.1.0/tests/test_errors.py +29 -0
- ludos-0.1.0/tests/test_input/__init__.py +0 -0
- ludos-0.1.0/tests/test_input/test_bindings.py +72 -0
- ludos-0.1.0/tests/test_input/test_events.py +39 -0
- ludos-0.1.0/tests/test_input/test_handler.py +109 -0
- ludos-0.1.0/tests/test_scenes/__init__.py +0 -0
- ludos-0.1.0/tests/test_scenes/test_base.py +58 -0
- ludos-0.1.0/tests/test_scenes/test_manager.py +93 -0
- ludos-0.1.0/tests/test_scenes/test_menu.py +111 -0
- ludos-0.1.0/tests/test_state/__init__.py +0 -0
- ludos-0.1.0/tests/test_state/test_base.py +40 -0
- ludos-0.1.0/tests/test_state/test_manager.py +59 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
- run: uv sync --all-extras
|
|
15
|
+
- run: uv run pytest
|
|
16
|
+
|
|
17
|
+
publish:
|
|
18
|
+
needs: test
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
environment: pypi
|
|
21
|
+
permissions:
|
|
22
|
+
id-token: write
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: astral-sh/setup-uv@v5
|
|
26
|
+
- run: uv build
|
|
27
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
ludos-0.1.0/.gitignore
ADDED
ludos-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Clarke-Lauer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ludos-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ludos
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pygame abstraction framework for rapid game development
|
|
5
|
+
Project-URL: Homepage, https://github.com/mclarkelauer/ludos
|
|
6
|
+
Project-URL: Repository, https://github.com/mclarkelauer/ludos
|
|
7
|
+
Project-URL: Issues, https://github.com/mclarkelauer/ludos/issues
|
|
8
|
+
Author-email: Matt Clarke-Lauer <mdlauer@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Games/Entertainment
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: pygame
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: pygame-ce>=2.5.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Ludos
|
|
28
|
+
|
|
29
|
+
Pygame-ce abstraction framework that eliminates boilerplate for rapid game development. Clean separation between input, state, and rendering with extensibility at every layer.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install ludos
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
import pygame
|
|
42
|
+
from ludos import (
|
|
43
|
+
BaseGameState, BaseScene, EngineConfig, GameEngine,
|
|
44
|
+
KeyBindings, SceneManager, StateManager,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class MyState(BaseGameState):
|
|
49
|
+
score: int = 0
|
|
50
|
+
|
|
51
|
+
class PlayScene(BaseScene):
|
|
52
|
+
def handle_input(self, events, state_manager):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def update(self, dt, state_manager):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def render(self, surface, state_manager):
|
|
59
|
+
surface.fill((0, 0, 0))
|
|
60
|
+
|
|
61
|
+
config = EngineConfig(title="My Game", width=800, height=600)
|
|
62
|
+
state_manager = StateManager(MyState())
|
|
63
|
+
scene_manager = SceneManager()
|
|
64
|
+
scene_manager.push(PlayScene())
|
|
65
|
+
|
|
66
|
+
engine = GameEngine(config)
|
|
67
|
+
engine.run(scene_manager, state_manager)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **State management** — Subclass `BaseGameState` as a dataclass, mutate through `StateManager.update()`
|
|
73
|
+
- **Scene stack** — Push/pop/replace scenes with `on_enter`/`on_exit` lifecycle hooks
|
|
74
|
+
- **Input mapping** — Map raw pygame keys to semantic actions via `KeyBindings`
|
|
75
|
+
- **Display wrapper** — `Window` handles pygame display init and surface management
|
|
76
|
+
- **Built-in menu** — `MenuScene` with configurable `MenuConfig` for quick prototyping
|
|
77
|
+
- **Error hierarchy** — All errors inherit `LudosError` for unified catching
|
|
78
|
+
|
|
79
|
+
## Documentation
|
|
80
|
+
|
|
81
|
+
See the [user guide](docs/guide.md) for full documentation.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
ludos-0.1.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Ludos
|
|
2
|
+
|
|
3
|
+
Pygame-ce abstraction framework that eliminates boilerplate for rapid game development. Clean separation between input, state, and rendering with extensibility at every layer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ludos
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
import pygame
|
|
16
|
+
from ludos import (
|
|
17
|
+
BaseGameState, BaseScene, EngineConfig, GameEngine,
|
|
18
|
+
KeyBindings, SceneManager, StateManager,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class MyState(BaseGameState):
|
|
23
|
+
score: int = 0
|
|
24
|
+
|
|
25
|
+
class PlayScene(BaseScene):
|
|
26
|
+
def handle_input(self, events, state_manager):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def update(self, dt, state_manager):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def render(self, surface, state_manager):
|
|
33
|
+
surface.fill((0, 0, 0))
|
|
34
|
+
|
|
35
|
+
config = EngineConfig(title="My Game", width=800, height=600)
|
|
36
|
+
state_manager = StateManager(MyState())
|
|
37
|
+
scene_manager = SceneManager()
|
|
38
|
+
scene_manager.push(PlayScene())
|
|
39
|
+
|
|
40
|
+
engine = GameEngine(config)
|
|
41
|
+
engine.run(scene_manager, state_manager)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **State management** — Subclass `BaseGameState` as a dataclass, mutate through `StateManager.update()`
|
|
47
|
+
- **Scene stack** — Push/pop/replace scenes with `on_enter`/`on_exit` lifecycle hooks
|
|
48
|
+
- **Input mapping** — Map raw pygame keys to semantic actions via `KeyBindings`
|
|
49
|
+
- **Display wrapper** — `Window` handles pygame display init and surface management
|
|
50
|
+
- **Built-in menu** — `MenuScene` with configurable `MenuConfig` for quick prototyping
|
|
51
|
+
- **Error hierarchy** — All errors inherit `LudosError` for unified catching
|
|
52
|
+
|
|
53
|
+
## Documentation
|
|
54
|
+
|
|
55
|
+
See the [user guide](docs/guide.md) for full documentation.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
# Ludos User Guide
|
|
2
|
+
|
|
3
|
+
Ludos is a pygame-ce abstraction framework that eliminates boilerplate and lets you build games fast. You define your state, scenes, and input bindings — Ludos handles the game loop, event conversion, and display management.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Clone and install with uv
|
|
9
|
+
uv sync --all-extras
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
**Requirements**: Python >= 3.12, pygame-ce >= 2.5.0
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
Here is the smallest possible Ludos program — a window with a menu:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from ludos import GameEngine, EngineConfig, MenuScene, MenuItem, MenuConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def on_start():
|
|
23
|
+
print("Starting!")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def on_quit():
|
|
27
|
+
print("Goodbye!")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
engine = GameEngine(
|
|
31
|
+
config=EngineConfig(title="My Game", width=800, height=600, fps=60),
|
|
32
|
+
initial_scene=MenuScene(
|
|
33
|
+
items=[
|
|
34
|
+
MenuItem("Start Game", on_start),
|
|
35
|
+
MenuItem("Quit", on_quit),
|
|
36
|
+
],
|
|
37
|
+
config=MenuConfig(title="Main Menu"),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
engine.run()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This opens an 800x600 window with a navigable menu. Arrow keys move the selection, Enter confirms. The window closes when you press the X button.
|
|
44
|
+
|
|
45
|
+
## Core Concepts
|
|
46
|
+
|
|
47
|
+
Ludos's architecture separates concerns into four systems:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
GameEngine (composition root)
|
|
51
|
+
├── Window — pygame display surface
|
|
52
|
+
├── InputHandler — polls events, resolves semantic actions
|
|
53
|
+
│ └── KeyBindings — key/button → action mapping
|
|
54
|
+
├── SceneManager — stack-based scene switching
|
|
55
|
+
│ └── BaseScene — your game screens (ABC)
|
|
56
|
+
└── StateManager[T] — generic over your state dataclass
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The game loop runs **Input → Update → Render** every frame with delta time.
|
|
60
|
+
|
|
61
|
+
## State
|
|
62
|
+
|
|
63
|
+
### Defining State
|
|
64
|
+
|
|
65
|
+
Extend `BaseGameState` with a `@dataclass`. Your fields sit alongside the built-in ones:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from dataclasses import dataclass
|
|
69
|
+
from ludos import BaseGameState
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class MyState(BaseGameState):
|
|
74
|
+
score: int = 0
|
|
75
|
+
player_x: float = 400.0
|
|
76
|
+
player_y: float = 300.0
|
|
77
|
+
lives: int = 3
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Built-in fields** (managed by the engine):
|
|
81
|
+
|
|
82
|
+
| Field | Type | Default | Description |
|
|
83
|
+
|-------|------|---------|-------------|
|
|
84
|
+
| `is_running` | `bool` | `True` | Set to `False` to stop the engine |
|
|
85
|
+
| `frame_count` | `int` | `0` | Incremented each frame by the engine |
|
|
86
|
+
| `elapsed_time` | `float` | `0.0` | Total seconds elapsed |
|
|
87
|
+
| `metadata` | `dict[str, Any]` | `{}` | Arbitrary storage for your own use |
|
|
88
|
+
|
|
89
|
+
### Mutating State
|
|
90
|
+
|
|
91
|
+
State is managed through `StateManager[T]`. Mutations use an explicit mutator pattern:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# Inside a scene's handle_input or update:
|
|
95
|
+
engine.state_manager.update(lambda s: setattr(s, "score", s.score + 10))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The `StateManager` tracks whether state has changed via a `dirty` flag, which the engine resets after each render frame with `mark_clean()`.
|
|
99
|
+
|
|
100
|
+
### StateManager API
|
|
101
|
+
|
|
102
|
+
| Member | Type | Description |
|
|
103
|
+
|--------|------|-------------|
|
|
104
|
+
| `state` | property → `T` | The current state instance |
|
|
105
|
+
| `dirty` | property → `bool` | `True` if state was mutated since last `mark_clean()` |
|
|
106
|
+
| `update(mutator)` | method | Apply a `Callable[[T], None]` to mutate state |
|
|
107
|
+
| `mark_clean()` | method | Reset the dirty flag |
|
|
108
|
+
|
|
109
|
+
If the mutator raises, it is wrapped in a `StateError`.
|
|
110
|
+
|
|
111
|
+
## Input
|
|
112
|
+
|
|
113
|
+
### InputEvent
|
|
114
|
+
|
|
115
|
+
Raw pygame events are converted to `InputEvent` — a frozen dataclass:
|
|
116
|
+
|
|
117
|
+
| Field | Type | Description |
|
|
118
|
+
|-------|------|-------------|
|
|
119
|
+
| `type` | `InputType` | Event category (see below) |
|
|
120
|
+
| `key` | `int \| None` | Pygame key constant (keyboard events) |
|
|
121
|
+
| `button` | `int \| None` | Mouse button number (mouse events) |
|
|
122
|
+
| `pos` | `tuple[int, int] \| None` | Mouse position (mouse events) |
|
|
123
|
+
| `action` | `str \| None` | Semantic action resolved from KeyBindings |
|
|
124
|
+
|
|
125
|
+
### InputType
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from ludos import InputType
|
|
129
|
+
|
|
130
|
+
InputType.KEY_DOWN # Key pressed
|
|
131
|
+
InputType.KEY_UP # Key released
|
|
132
|
+
InputType.MOUSE_DOWN # Mouse button pressed
|
|
133
|
+
InputType.MOUSE_UP # Mouse button released
|
|
134
|
+
InputType.MOUSE_MOTION # Mouse moved
|
|
135
|
+
InputType.QUIT # Window close
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### KeyBindings
|
|
139
|
+
|
|
140
|
+
Maps raw pygame keys/buttons to semantic action strings. The engine uses these to populate `InputEvent.action`.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from ludos import KeyBindings
|
|
144
|
+
import pygame
|
|
145
|
+
|
|
146
|
+
# Start from defaults or empty
|
|
147
|
+
bindings = KeyBindings.defaults()
|
|
148
|
+
|
|
149
|
+
# Add/override bindings
|
|
150
|
+
bindings.bind_key(pygame.K_w, "move_up")
|
|
151
|
+
bindings.bind_key(pygame.K_s, "move_down")
|
|
152
|
+
bindings.bind_mouse(2, "middle_click")
|
|
153
|
+
|
|
154
|
+
# Remove a binding
|
|
155
|
+
bindings.unbind_key(pygame.K_SPACE)
|
|
156
|
+
|
|
157
|
+
# Look up
|
|
158
|
+
bindings.get_key_action(pygame.K_w) # "move_up"
|
|
159
|
+
bindings.get_mouse_action(1) # "click"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Default bindings** (`KeyBindings.defaults()`):
|
|
163
|
+
|
|
164
|
+
| Key/Button | Action |
|
|
165
|
+
|------------|--------|
|
|
166
|
+
| `K_UP` | `"move_up"` |
|
|
167
|
+
| `K_DOWN` | `"move_down"` |
|
|
168
|
+
| `K_LEFT` | `"move_left"` |
|
|
169
|
+
| `K_RIGHT` | `"move_right"` |
|
|
170
|
+
| `K_RETURN` | `"confirm"` |
|
|
171
|
+
| `K_ESCAPE` | `"cancel"` |
|
|
172
|
+
| `K_SPACE` | `"action"` |
|
|
173
|
+
| Mouse button 1 | `"click"` |
|
|
174
|
+
| Mouse button 3 | `"right_click"` |
|
|
175
|
+
|
|
176
|
+
### InputHandler
|
|
177
|
+
|
|
178
|
+
Polls pygame events, converts them to `InputEvent`, and dispatches registered callbacks.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from ludos import InputHandler, KeyBindings
|
|
182
|
+
|
|
183
|
+
handler = InputHandler(KeyBindings.defaults())
|
|
184
|
+
|
|
185
|
+
# Register callbacks for semantic actions
|
|
186
|
+
handler.on("confirm", lambda event: print("Confirmed!"))
|
|
187
|
+
handler.on("cancel", lambda event: print("Cancelled!"))
|
|
188
|
+
|
|
189
|
+
# Unregister
|
|
190
|
+
handler.off("confirm", my_callback)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
You typically don't call `handler.poll()` yourself — the engine does it. But in your scenes, you receive the already-converted `InputEvent` objects.
|
|
194
|
+
|
|
195
|
+
## Scenes
|
|
196
|
+
|
|
197
|
+
### BaseScene
|
|
198
|
+
|
|
199
|
+
All game screens extend `BaseScene` and implement three required methods:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
import pygame
|
|
203
|
+
from ludos import BaseScene, BaseGameState, InputEvent, InputType
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class GameplayScene(BaseScene):
|
|
207
|
+
def handle_input(self, event: InputEvent, state: BaseGameState) -> None:
|
|
208
|
+
"""Called once per input event per frame."""
|
|
209
|
+
if event.action == "move_up":
|
|
210
|
+
state.metadata["player_y"] = state.metadata.get("player_y", 300) - 5
|
|
211
|
+
|
|
212
|
+
def update(self, dt: float, state: BaseGameState) -> None:
|
|
213
|
+
"""Called once per frame. dt = seconds since last frame."""
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
def render(self, surface: pygame.Surface, state: BaseGameState) -> None:
|
|
217
|
+
"""Draw to the surface each frame."""
|
|
218
|
+
surface.fill((30, 30, 60))
|
|
219
|
+
|
|
220
|
+
# Optional lifecycle hooks:
|
|
221
|
+
def on_enter(self, state: BaseGameState) -> None:
|
|
222
|
+
"""Called when this scene becomes active (pushed or uncovered)."""
|
|
223
|
+
print("Entered gameplay!")
|
|
224
|
+
|
|
225
|
+
def on_exit(self, state: BaseGameState) -> None:
|
|
226
|
+
"""Called when this scene is deactivated (popped or covered)."""
|
|
227
|
+
print("Left gameplay!")
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### SceneManager
|
|
231
|
+
|
|
232
|
+
Scenes live on a stack. The topmost scene is the **active** scene that receives input, updates, and renders.
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
from ludos import SceneManager, BaseGameState
|
|
236
|
+
|
|
237
|
+
manager = SceneManager()
|
|
238
|
+
state = BaseGameState()
|
|
239
|
+
|
|
240
|
+
manager.push(gameplay_scene, state) # gameplay is active, on_enter called
|
|
241
|
+
manager.push(pause_scene, state) # gameplay.on_exit → pause.on_enter
|
|
242
|
+
manager.pop(state) # pause.on_exit → gameplay.on_enter
|
|
243
|
+
manager.replace(menu_scene, state) # gameplay.on_exit → menu.on_enter
|
|
244
|
+
|
|
245
|
+
manager.active # The topmost scene (or None)
|
|
246
|
+
manager.depth # Number of scenes on stack
|
|
247
|
+
manager.clear(state) # Pop all scenes, calling on_exit for each
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Lifecycle flow**:
|
|
251
|
+
- `push(scene)`: old scene's `on_exit` → new scene's `on_enter`
|
|
252
|
+
- `pop()`: active scene's `on_exit` → uncovered scene's `on_enter`
|
|
253
|
+
- `replace(scene)`: old scene's `on_exit` → new scene's `on_enter`
|
|
254
|
+
- `clear()`: all scenes get `on_exit` called, stack empties
|
|
255
|
+
|
|
256
|
+
### MenuScene
|
|
257
|
+
|
|
258
|
+
A ready-made scene with keyboard-navigable menu items:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from ludos import MenuScene, MenuItem, MenuConfig
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def start_game():
|
|
265
|
+
print("Starting!")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
menu = MenuScene(
|
|
269
|
+
items=[
|
|
270
|
+
MenuItem("New Game", start_game),
|
|
271
|
+
MenuItem("Options", lambda: print("Options")),
|
|
272
|
+
MenuItem("Quit", lambda: print("Quit")),
|
|
273
|
+
],
|
|
274
|
+
config=MenuConfig(
|
|
275
|
+
title="My Game",
|
|
276
|
+
font_size=36,
|
|
277
|
+
font_name=None, # None = pygame default font
|
|
278
|
+
text_color=(255, 255, 255),
|
|
279
|
+
highlight_color=(255, 255, 0),
|
|
280
|
+
bg_color=(0, 0, 0),
|
|
281
|
+
title_font_size=48,
|
|
282
|
+
item_spacing=10,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Navigation uses the `"move_up"`, `"move_down"`, and `"confirm"` actions from your key bindings. Selection wraps around.
|
|
288
|
+
|
|
289
|
+
**MenuConfig fields**:
|
|
290
|
+
|
|
291
|
+
| Field | Type | Default |
|
|
292
|
+
|-------|------|---------|
|
|
293
|
+
| `font_size` | `int` | `36` |
|
|
294
|
+
| `font_name` | `str \| None` | `None` (system default) |
|
|
295
|
+
| `text_color` | `tuple[int, int, int]` | `(255, 255, 255)` |
|
|
296
|
+
| `highlight_color` | `tuple[int, int, int]` | `(255, 255, 0)` |
|
|
297
|
+
| `bg_color` | `tuple[int, int, int]` | `(0, 0, 0)` |
|
|
298
|
+
| `title` | `str` | `""` |
|
|
299
|
+
| `title_font_size` | `int` | `48` |
|
|
300
|
+
| `item_spacing` | `int` | `10` |
|
|
301
|
+
|
|
302
|
+
## Engine
|
|
303
|
+
|
|
304
|
+
### EngineConfig
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from ludos import EngineConfig
|
|
308
|
+
|
|
309
|
+
config = EngineConfig(
|
|
310
|
+
width=800, # Window width (default: 800)
|
|
311
|
+
height=600, # Window height (default: 600)
|
|
312
|
+
title="Ludos", # Window title (default: "Ludos")
|
|
313
|
+
fps=60, # Target frames per second (default: 60)
|
|
314
|
+
bg_color=(0, 0, 0), # Background clear color (default: black)
|
|
315
|
+
display_flags=0, # Pygame display flags (default: 0)
|
|
316
|
+
)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### GameEngine
|
|
320
|
+
|
|
321
|
+
The composition root that ties everything together:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
from ludos import GameEngine, EngineConfig, KeyBindings
|
|
325
|
+
|
|
326
|
+
engine = GameEngine(
|
|
327
|
+
config=EngineConfig(title="My Game"),
|
|
328
|
+
initial_state=MyState(),
|
|
329
|
+
initial_scene=my_scene,
|
|
330
|
+
bindings=KeyBindings.defaults(), # optional, defaults are used if omitted
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Access subsystems
|
|
334
|
+
engine.state_manager # StateManager[T]
|
|
335
|
+
engine.scene_manager # SceneManager
|
|
336
|
+
engine.input_handler # InputHandler
|
|
337
|
+
engine.window # Window (None until run() is called)
|
|
338
|
+
|
|
339
|
+
# Run the game
|
|
340
|
+
engine.run() # Blocks until game ends
|
|
341
|
+
|
|
342
|
+
# Stop from inside a scene or callback
|
|
343
|
+
engine.stop() # Sets is_running = False, loop exits after current frame
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**Game loop order** (each frame):
|
|
347
|
+
1. Tick clock, compute delta time
|
|
348
|
+
2. Increment `frame_count` and `elapsed_time`
|
|
349
|
+
3. **Input**: poll events, dispatch to active scene's `handle_input`
|
|
350
|
+
4. `QUIT` events automatically call `engine.stop()`
|
|
351
|
+
5. **Update**: call active scene's `update(dt, state)`
|
|
352
|
+
6. **Render**: clear window, call active scene's `render(surface, state)`, flip display
|
|
353
|
+
7. Mark state clean
|
|
354
|
+
|
|
355
|
+
## Error Handling
|
|
356
|
+
|
|
357
|
+
All framework errors inherit from `LudosError`:
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
from ludos import LudosError
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
engine.run()
|
|
364
|
+
except LudosError as e:
|
|
365
|
+
print(f"Framework error: {e}")
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
| Error | Raised when |
|
|
369
|
+
|-------|-------------|
|
|
370
|
+
| `LudosError` | Base class for all errors |
|
|
371
|
+
| `InitializationError` | Pygame init or window creation fails |
|
|
372
|
+
| `StateError` | State mutator raises or invalid state passed |
|
|
373
|
+
| `SceneError` | Invalid scene operation (pop empty stack, push non-scene) |
|
|
374
|
+
| `InputError` | Invalid key binding (empty action string) |
|
|
375
|
+
| `RenderError` | Display flip fails |
|
|
376
|
+
|
|
377
|
+
## Complete Example
|
|
378
|
+
|
|
379
|
+
A game with a main menu that transitions to a gameplay scene:
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
from dataclasses import dataclass
|
|
383
|
+
|
|
384
|
+
import pygame
|
|
385
|
+
|
|
386
|
+
from ludos import (
|
|
387
|
+
BaseGameState,
|
|
388
|
+
BaseScene,
|
|
389
|
+
EngineConfig,
|
|
390
|
+
GameEngine,
|
|
391
|
+
InputEvent,
|
|
392
|
+
KeyBindings,
|
|
393
|
+
MenuConfig,
|
|
394
|
+
MenuItem,
|
|
395
|
+
MenuScene,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@dataclass
|
|
400
|
+
class MyState(BaseGameState):
|
|
401
|
+
score: int = 0
|
|
402
|
+
player_x: float = 400.0
|
|
403
|
+
player_y: float = 300.0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class GameplayScene(BaseScene):
|
|
407
|
+
def __init__(self, engine: GameEngine) -> None:
|
|
408
|
+
self._engine = engine
|
|
409
|
+
|
|
410
|
+
def handle_input(self, event: InputEvent, state: BaseGameState) -> None:
|
|
411
|
+
if event.action == "cancel":
|
|
412
|
+
# Return to menu
|
|
413
|
+
self._engine.scene_manager.pop(state)
|
|
414
|
+
elif event.action == "move_up":
|
|
415
|
+
state.metadata["player_y"] = state.metadata.get("player_y", 300.0) - 5
|
|
416
|
+
elif event.action == "move_down":
|
|
417
|
+
state.metadata["player_y"] = state.metadata.get("player_y", 300.0) + 5
|
|
418
|
+
elif event.action == "move_left":
|
|
419
|
+
state.metadata["player_x"] = state.metadata.get("player_x", 400.0) - 5
|
|
420
|
+
elif event.action == "move_right":
|
|
421
|
+
state.metadata["player_x"] = state.metadata.get("player_x", 400.0) + 5
|
|
422
|
+
|
|
423
|
+
def update(self, dt: float, state: BaseGameState) -> None:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
def render(self, surface: pygame.Surface, state: BaseGameState) -> None:
|
|
427
|
+
surface.fill((20, 20, 40))
|
|
428
|
+
x = int(state.metadata.get("player_x", 400.0))
|
|
429
|
+
y = int(state.metadata.get("player_y", 300.0))
|
|
430
|
+
pygame.draw.circle(surface, (0, 200, 255), (x, y), 20)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def main():
|
|
434
|
+
bindings = KeyBindings.defaults()
|
|
435
|
+
bindings.bind_key(pygame.K_w, "move_up")
|
|
436
|
+
bindings.bind_key(pygame.K_s, "move_down")
|
|
437
|
+
bindings.bind_key(pygame.K_a, "move_left")
|
|
438
|
+
bindings.bind_key(pygame.K_d, "move_right")
|
|
439
|
+
|
|
440
|
+
engine = GameEngine(
|
|
441
|
+
config=EngineConfig(title="My Game", width=800, height=600),
|
|
442
|
+
initial_state=MyState(),
|
|
443
|
+
bindings=bindings,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
gameplay = GameplayScene(engine)
|
|
447
|
+
|
|
448
|
+
menu = MenuScene(
|
|
449
|
+
items=[
|
|
450
|
+
MenuItem("Play", lambda: engine.scene_manager.push(gameplay, engine.state_manager.state)),
|
|
451
|
+
MenuItem("Quit", engine.stop),
|
|
452
|
+
],
|
|
453
|
+
config=MenuConfig(title="My Game"),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
engine._initial_scene = menu
|
|
457
|
+
engine.run()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__":
|
|
461
|
+
main()
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
## Testing Your Game
|
|
465
|
+
|
|
466
|
+
Ludos is designed for testability. Mock pygame to test scenes without a display:
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
from unittest.mock import MagicMock
|
|
470
|
+
|
|
471
|
+
from ludos import InputEvent, InputType
|
|
472
|
+
from your_game import GameplayScene, MyState
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_move_up():
|
|
476
|
+
scene = GameplayScene(engine=MagicMock())
|
|
477
|
+
state = MyState()
|
|
478
|
+
state.metadata["player_y"] = 300.0
|
|
479
|
+
|
|
480
|
+
event = InputEvent(type=InputType.KEY_DOWN, action="move_up")
|
|
481
|
+
scene.handle_input(event, state)
|
|
482
|
+
|
|
483
|
+
assert state.metadata["player_y"] == 295.0
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Run tests with:
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
uv run pytest
|
|
490
|
+
uv run pytest --cov=ludos
|
|
491
|
+
```
|