gamekit 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.
- gamekit-0.1.0/LICENSE +21 -0
- gamekit-0.1.0/PKG-INFO +197 -0
- gamekit-0.1.0/README.md +173 -0
- gamekit-0.1.0/pyproject.toml +34 -0
- gamekit-0.1.0/setup.cfg +4 -0
- gamekit-0.1.0/src/gamekit/__init__.py +29 -0
- gamekit-0.1.0/src/gamekit/ecs.py +128 -0
- gamekit-0.1.0/src/gamekit/grid.py +130 -0
- gamekit-0.1.0/src/gamekit/input.py +72 -0
- gamekit-0.1.0/src/gamekit/loop.py +160 -0
- gamekit-0.1.0/src/gamekit/py.typed +0 -0
- gamekit-0.1.0/src/gamekit/vec.py +58 -0
- gamekit-0.1.0/src/gamekit.egg-info/PKG-INFO +197 -0
- gamekit-0.1.0/src/gamekit.egg-info/SOURCES.txt +17 -0
- gamekit-0.1.0/src/gamekit.egg-info/dependency_links.txt +1 -0
- gamekit-0.1.0/src/gamekit.egg-info/top_level.txt +1 -0
- gamekit-0.1.0/tests/test_ecs.py +117 -0
- gamekit-0.1.0/tests/test_loop_input.py +138 -0
- gamekit-0.1.0/tests/test_vec_grid.py +110 -0
gamekit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nehz
|
|
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.
|
gamekit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gamekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny, dependency-free, headless toolkit for building games: ECS, grids and A* pathfinding, a fixed-timestep loop, timers, and input mapping.
|
|
5
|
+
Author: nehz
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: game,gamedev,ecs,pathfinding,game-loop,roguelike
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Games/Entertainment
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# gamekit
|
|
26
|
+
|
|
27
|
+
**The game-logic half of a game engine, with no window required.**
|
|
28
|
+
|
|
29
|
+
gamekit is a small, pure-Python toolkit for the parts of a game that aren't
|
|
30
|
+
graphics: an entity-component-system, tile grids with A* pathfinding, a
|
|
31
|
+
fixed-timestep game loop with timers, and action-based input mapping. It has
|
|
32
|
+
zero dependencies and never opens a display, so your game logic runs the same
|
|
33
|
+
in a terminal, a test suite, a server, or under whatever renderer you bolt on
|
|
34
|
+
later (curses, pygame, a web front end...).
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **ECS** (`World`): integer entity ids, components are plain objects (dataclasses
|
|
39
|
+
work well), typed `query()` over component combinations, ordered systems, and
|
|
40
|
+
deferred `destroy()` so it's safe to remove entities while iterating.
|
|
41
|
+
- **Grids & pathfinding** (`Grid`): build maps from ASCII art, 4-way neighbours,
|
|
42
|
+
flood-fill reachability, A* shortest paths, and text rendering with overlays.
|
|
43
|
+
- **Fixed-timestep loop** (`GameLoop`): frame-rate-independent updates, an
|
|
44
|
+
accumulator with a "spiral of death" cap, render interpolation `alpha`,
|
|
45
|
+
headless `run(ticks)` for tests, and `run_realtime()` with an injectable clock.
|
|
46
|
+
- **Timers** (`Timer`, `GameLoop.every/after`): one-shot and repeating timers in
|
|
47
|
+
simulated time, with exact catch-up when a big step overshoots.
|
|
48
|
+
- **Input mapping** (`InputMap`): bind any hashable key to named actions; query
|
|
49
|
+
held / just-pressed / just-released and two-action axes. Backend-agnostic.
|
|
50
|
+
- **`Vec2`**: immutable, hashable 2D vector usable as a dict key.
|
|
51
|
+
- Fully type-hinted (`py.typed`), standard library only, Python 3.10+.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
From a checkout:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
59
|
+
pip install -e .
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quickstart
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from dataclasses import dataclass
|
|
66
|
+
from gamekit import GameLoop, Grid, Vec2, World
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Pos:
|
|
70
|
+
at: Vec2
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Seeker:
|
|
74
|
+
goal: Vec2
|
|
75
|
+
|
|
76
|
+
grid = Grid.from_ascii("""
|
|
77
|
+
#######
|
|
78
|
+
#.....#
|
|
79
|
+
#.###.#
|
|
80
|
+
#.....#
|
|
81
|
+
#######
|
|
82
|
+
""")
|
|
83
|
+
|
|
84
|
+
world = World()
|
|
85
|
+
world.spawn(Pos(Vec2(1, 1)), Seeker(goal=Vec2(5, 3)))
|
|
86
|
+
|
|
87
|
+
@world.add_system
|
|
88
|
+
def seek(world: World, dt: float) -> None:
|
|
89
|
+
for eid, pos, seeker in world.query(Pos, Seeker):
|
|
90
|
+
path = grid.find_path(pos.at, seeker.goal)
|
|
91
|
+
if path and len(path) > 1:
|
|
92
|
+
pos.at = path[1] # one step per tick
|
|
93
|
+
else:
|
|
94
|
+
world.destroy(eid) # arrived (removed at end of step)
|
|
95
|
+
|
|
96
|
+
loop = GameLoop(world.step, tick_rate=10)
|
|
97
|
+
loop.every(0.5, lambda: print(f"t={loop.time:.1f}s entities={len(world)}"))
|
|
98
|
+
loop.run(10)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A complete example, a tiny terminal chase game that uses every module, lives in
|
|
102
|
+
[`examples/chase.py`](examples/chase.py):
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
python3 examples/chase.py # coin-collecting player escapes the ghost
|
|
106
|
+
python3 examples/chase.py --idle # player stands still; the ghost catches it
|
|
107
|
+
python3 examples/chase.py --every 1 --delay 0.1 # watch it frame by frame
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## API overview
|
|
111
|
+
|
|
112
|
+
Everything below is importable from the top-level `gamekit` package.
|
|
113
|
+
|
|
114
|
+
### `Vec2(x=0, y=0)` (frozen dataclass)
|
|
115
|
+
|
|
116
|
+
| Member | Description |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `+`, `-`, `* k`, `k *`, unary `-` | Vector arithmetic |
|
|
119
|
+
| `iter(v)` | Unpack: `x, y = v` |
|
|
120
|
+
| `length() -> float` | Euclidean length |
|
|
121
|
+
| `manhattan(other)` | Taxicab distance |
|
|
122
|
+
| `normalized() -> Vec2` | Unit vector (zero stays zero) |
|
|
123
|
+
|
|
124
|
+
`CARDINALS`: tuple of the four unit directions `(1,0), (-1,0), (0,1), (0,-1)`; y grows downward.
|
|
125
|
+
|
|
126
|
+
### `World()`, the ECS (`Entity` is an alias for `int`)
|
|
127
|
+
|
|
128
|
+
| Method | Description |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `spawn(*components) -> Entity` | Create an entity |
|
|
131
|
+
| `destroy(entity)` | Mark for removal; applied at the end of `step()` or by `flush()` |
|
|
132
|
+
| `flush()` | Apply pending destroys now |
|
|
133
|
+
| `alive(entity) -> bool` | Exists and is not pending destruction |
|
|
134
|
+
| `len(world)` | Number of live entities |
|
|
135
|
+
| `add(entity, component)` | Attach/replace a component (keyed by its type) |
|
|
136
|
+
| `remove(entity, ctype)` | Detach a component type (no-op if absent) |
|
|
137
|
+
| `get(entity, ctype)` | Component, or `KeyError` |
|
|
138
|
+
| `try_get(entity, ctype)` | Component or `None` |
|
|
139
|
+
| `has(entity, *ctypes) -> bool` | Has every listed type |
|
|
140
|
+
| `query(*ctypes)` | Yields `(entity, comp1, comp2, ...)` in creation order |
|
|
141
|
+
| `add_system(fn)` | Register `fn(world, dt)`; usable as a decorator |
|
|
142
|
+
| `step(dt)` | Run all systems in order, then flush destroys |
|
|
143
|
+
|
|
144
|
+
### `Grid(width, height, walls=())`
|
|
145
|
+
|
|
146
|
+
| Method | Description |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `Grid.from_ascii(text, wall="#")` | Build from ASCII art |
|
|
149
|
+
| `width`, `height` | Dimensions |
|
|
150
|
+
| `in_bounds(p)`, `is_wall(p)`, `passable(p)` | Cell tests (out of bounds counts as wall) |
|
|
151
|
+
| `set_wall(p, wall=True)` | Add/remove a wall (`IndexError` if out of bounds) |
|
|
152
|
+
| `cells()` | All cells, row-major |
|
|
153
|
+
| `neighbors(p) -> list[Vec2]` | Passable 4-connected neighbours |
|
|
154
|
+
| `reachable(start) -> set[Vec2]` | Flood fill |
|
|
155
|
+
| `find_path(start, goal) -> list[Vec2] \| None` | A* shortest path including both endpoints |
|
|
156
|
+
| `render(marks=None, wall="#", floor=".") -> str` | Text rendering with per-cell overrides |
|
|
157
|
+
|
|
158
|
+
### `GameLoop(update, tick_rate=60.0, max_steps_per_advance=8)`
|
|
159
|
+
|
|
160
|
+
| Member | Description |
|
|
161
|
+
| --- | --- |
|
|
162
|
+
| `update(dt)` | Your callback, called once per fixed step |
|
|
163
|
+
| `dt`, `ticks`, `time`, `running` | Step size, steps run, simulated seconds, run state |
|
|
164
|
+
| `alpha` | Leftover fraction of a step, for render interpolation |
|
|
165
|
+
| `step()` | Run exactly one step: `update`, advance `ticks`/`time`, then timers |
|
|
166
|
+
| `advance(elapsed) -> int` | Feed real elapsed seconds; returns steps run |
|
|
167
|
+
| `run(ticks)` | Run N steps immediately (headless) |
|
|
168
|
+
| `run_realtime(duration=None, clock=time.perf_counter, sleep=time.sleep)` | Real-time loop |
|
|
169
|
+
| `stop()` | Exit `run`/`run_realtime` after the current step |
|
|
170
|
+
| `every(seconds, cb) -> Timer` / `after(seconds, cb) -> Timer` | Schedule in simulated time |
|
|
171
|
+
|
|
172
|
+
### `Timer(duration, callback=None, repeat=False)`
|
|
173
|
+
|
|
174
|
+
`tick(dt) -> int` (times fired), `reset()`, `progress` (0..1), `remaining`, `finished`.
|
|
175
|
+
|
|
176
|
+
### `InputMap(bindings=None)`
|
|
177
|
+
|
|
178
|
+
`bindings` is `{"action": [key, ...]}`; keys are any hashable.
|
|
179
|
+
|
|
180
|
+
| Method | Description |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| `bind(action, key)` / `unbind(action, key=None)` | Edit bindings |
|
|
183
|
+
| `keys_for(action) -> frozenset` | Keys bound to an action |
|
|
184
|
+
| `press(key)` / `release(key)` | Feed raw events from your backend |
|
|
185
|
+
| `is_held(action)`, `just_pressed(action)`, `just_released(action)` | Action state |
|
|
186
|
+
| `axis(negative, positive) -> int` | -1, 0 or +1 |
|
|
187
|
+
| `end_frame()` | Clear edge state; call once per frame |
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
python3 -m unittest discover -s tests -t . # or: python3 -m pytest
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT
|
gamekit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# gamekit
|
|
2
|
+
|
|
3
|
+
**The game-logic half of a game engine, with no window required.**
|
|
4
|
+
|
|
5
|
+
gamekit is a small, pure-Python toolkit for the parts of a game that aren't
|
|
6
|
+
graphics: an entity-component-system, tile grids with A* pathfinding, a
|
|
7
|
+
fixed-timestep game loop with timers, and action-based input mapping. It has
|
|
8
|
+
zero dependencies and never opens a display, so your game logic runs the same
|
|
9
|
+
in a terminal, a test suite, a server, or under whatever renderer you bolt on
|
|
10
|
+
later (curses, pygame, a web front end...).
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **ECS** (`World`): integer entity ids, components are plain objects (dataclasses
|
|
15
|
+
work well), typed `query()` over component combinations, ordered systems, and
|
|
16
|
+
deferred `destroy()` so it's safe to remove entities while iterating.
|
|
17
|
+
- **Grids & pathfinding** (`Grid`): build maps from ASCII art, 4-way neighbours,
|
|
18
|
+
flood-fill reachability, A* shortest paths, and text rendering with overlays.
|
|
19
|
+
- **Fixed-timestep loop** (`GameLoop`): frame-rate-independent updates, an
|
|
20
|
+
accumulator with a "spiral of death" cap, render interpolation `alpha`,
|
|
21
|
+
headless `run(ticks)` for tests, and `run_realtime()` with an injectable clock.
|
|
22
|
+
- **Timers** (`Timer`, `GameLoop.every/after`): one-shot and repeating timers in
|
|
23
|
+
simulated time, with exact catch-up when a big step overshoots.
|
|
24
|
+
- **Input mapping** (`InputMap`): bind any hashable key to named actions; query
|
|
25
|
+
held / just-pressed / just-released and two-action axes. Backend-agnostic.
|
|
26
|
+
- **`Vec2`**: immutable, hashable 2D vector usable as a dict key.
|
|
27
|
+
- Fully type-hinted (`py.typed`), standard library only, Python 3.10+.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
From a checkout:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
35
|
+
pip install -e .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from gamekit import GameLoop, Grid, Vec2, World
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Pos:
|
|
46
|
+
at: Vec2
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Seeker:
|
|
50
|
+
goal: Vec2
|
|
51
|
+
|
|
52
|
+
grid = Grid.from_ascii("""
|
|
53
|
+
#######
|
|
54
|
+
#.....#
|
|
55
|
+
#.###.#
|
|
56
|
+
#.....#
|
|
57
|
+
#######
|
|
58
|
+
""")
|
|
59
|
+
|
|
60
|
+
world = World()
|
|
61
|
+
world.spawn(Pos(Vec2(1, 1)), Seeker(goal=Vec2(5, 3)))
|
|
62
|
+
|
|
63
|
+
@world.add_system
|
|
64
|
+
def seek(world: World, dt: float) -> None:
|
|
65
|
+
for eid, pos, seeker in world.query(Pos, Seeker):
|
|
66
|
+
path = grid.find_path(pos.at, seeker.goal)
|
|
67
|
+
if path and len(path) > 1:
|
|
68
|
+
pos.at = path[1] # one step per tick
|
|
69
|
+
else:
|
|
70
|
+
world.destroy(eid) # arrived (removed at end of step)
|
|
71
|
+
|
|
72
|
+
loop = GameLoop(world.step, tick_rate=10)
|
|
73
|
+
loop.every(0.5, lambda: print(f"t={loop.time:.1f}s entities={len(world)}"))
|
|
74
|
+
loop.run(10)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A complete example, a tiny terminal chase game that uses every module, lives in
|
|
78
|
+
[`examples/chase.py`](examples/chase.py):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python3 examples/chase.py # coin-collecting player escapes the ghost
|
|
82
|
+
python3 examples/chase.py --idle # player stands still; the ghost catches it
|
|
83
|
+
python3 examples/chase.py --every 1 --delay 0.1 # watch it frame by frame
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## API overview
|
|
87
|
+
|
|
88
|
+
Everything below is importable from the top-level `gamekit` package.
|
|
89
|
+
|
|
90
|
+
### `Vec2(x=0, y=0)` (frozen dataclass)
|
|
91
|
+
|
|
92
|
+
| Member | Description |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `+`, `-`, `* k`, `k *`, unary `-` | Vector arithmetic |
|
|
95
|
+
| `iter(v)` | Unpack: `x, y = v` |
|
|
96
|
+
| `length() -> float` | Euclidean length |
|
|
97
|
+
| `manhattan(other)` | Taxicab distance |
|
|
98
|
+
| `normalized() -> Vec2` | Unit vector (zero stays zero) |
|
|
99
|
+
|
|
100
|
+
`CARDINALS`: tuple of the four unit directions `(1,0), (-1,0), (0,1), (0,-1)`; y grows downward.
|
|
101
|
+
|
|
102
|
+
### `World()`, the ECS (`Entity` is an alias for `int`)
|
|
103
|
+
|
|
104
|
+
| Method | Description |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `spawn(*components) -> Entity` | Create an entity |
|
|
107
|
+
| `destroy(entity)` | Mark for removal; applied at the end of `step()` or by `flush()` |
|
|
108
|
+
| `flush()` | Apply pending destroys now |
|
|
109
|
+
| `alive(entity) -> bool` | Exists and is not pending destruction |
|
|
110
|
+
| `len(world)` | Number of live entities |
|
|
111
|
+
| `add(entity, component)` | Attach/replace a component (keyed by its type) |
|
|
112
|
+
| `remove(entity, ctype)` | Detach a component type (no-op if absent) |
|
|
113
|
+
| `get(entity, ctype)` | Component, or `KeyError` |
|
|
114
|
+
| `try_get(entity, ctype)` | Component or `None` |
|
|
115
|
+
| `has(entity, *ctypes) -> bool` | Has every listed type |
|
|
116
|
+
| `query(*ctypes)` | Yields `(entity, comp1, comp2, ...)` in creation order |
|
|
117
|
+
| `add_system(fn)` | Register `fn(world, dt)`; usable as a decorator |
|
|
118
|
+
| `step(dt)` | Run all systems in order, then flush destroys |
|
|
119
|
+
|
|
120
|
+
### `Grid(width, height, walls=())`
|
|
121
|
+
|
|
122
|
+
| Method | Description |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `Grid.from_ascii(text, wall="#")` | Build from ASCII art |
|
|
125
|
+
| `width`, `height` | Dimensions |
|
|
126
|
+
| `in_bounds(p)`, `is_wall(p)`, `passable(p)` | Cell tests (out of bounds counts as wall) |
|
|
127
|
+
| `set_wall(p, wall=True)` | Add/remove a wall (`IndexError` if out of bounds) |
|
|
128
|
+
| `cells()` | All cells, row-major |
|
|
129
|
+
| `neighbors(p) -> list[Vec2]` | Passable 4-connected neighbours |
|
|
130
|
+
| `reachable(start) -> set[Vec2]` | Flood fill |
|
|
131
|
+
| `find_path(start, goal) -> list[Vec2] \| None` | A* shortest path including both endpoints |
|
|
132
|
+
| `render(marks=None, wall="#", floor=".") -> str` | Text rendering with per-cell overrides |
|
|
133
|
+
|
|
134
|
+
### `GameLoop(update, tick_rate=60.0, max_steps_per_advance=8)`
|
|
135
|
+
|
|
136
|
+
| Member | Description |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `update(dt)` | Your callback, called once per fixed step |
|
|
139
|
+
| `dt`, `ticks`, `time`, `running` | Step size, steps run, simulated seconds, run state |
|
|
140
|
+
| `alpha` | Leftover fraction of a step, for render interpolation |
|
|
141
|
+
| `step()` | Run exactly one step: `update`, advance `ticks`/`time`, then timers |
|
|
142
|
+
| `advance(elapsed) -> int` | Feed real elapsed seconds; returns steps run |
|
|
143
|
+
| `run(ticks)` | Run N steps immediately (headless) |
|
|
144
|
+
| `run_realtime(duration=None, clock=time.perf_counter, sleep=time.sleep)` | Real-time loop |
|
|
145
|
+
| `stop()` | Exit `run`/`run_realtime` after the current step |
|
|
146
|
+
| `every(seconds, cb) -> Timer` / `after(seconds, cb) -> Timer` | Schedule in simulated time |
|
|
147
|
+
|
|
148
|
+
### `Timer(duration, callback=None, repeat=False)`
|
|
149
|
+
|
|
150
|
+
`tick(dt) -> int` (times fired), `reset()`, `progress` (0..1), `remaining`, `finished`.
|
|
151
|
+
|
|
152
|
+
### `InputMap(bindings=None)`
|
|
153
|
+
|
|
154
|
+
`bindings` is `{"action": [key, ...]}`; keys are any hashable.
|
|
155
|
+
|
|
156
|
+
| Method | Description |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| `bind(action, key)` / `unbind(action, key=None)` | Edit bindings |
|
|
159
|
+
| `keys_for(action) -> frozenset` | Keys bound to an action |
|
|
160
|
+
| `press(key)` / `release(key)` | Feed raw events from your backend |
|
|
161
|
+
| `is_held(action)`, `just_pressed(action)`, `just_released(action)` | Action state |
|
|
162
|
+
| `axis(negative, positive) -> int` | -1, 0 or +1 |
|
|
163
|
+
| `end_frame()` | Clear edge state; call once per frame |
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
python3 -m unittest discover -s tests -t . # or: python3 -m pytest
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gamekit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A tiny, dependency-free, headless toolkit for building games: ECS, grids and A* pathfinding, a fixed-timestep loop, timers, and input mapping."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "nehz" }]
|
|
13
|
+
keywords = ["game", "gamedev", "ecs", "pathfinding", "game-loop", "roguelike"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Games/Entertainment",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-data]
|
|
34
|
+
gamekit = ["py.typed"]
|
gamekit-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""gamekit: a small, dependency-free, headless toolkit for building games.
|
|
2
|
+
|
|
3
|
+
Modules:
|
|
4
|
+
vec -- immutable :class:`Vec2`
|
|
5
|
+
ecs -- entity-component-system :class:`World`
|
|
6
|
+
grid -- tile :class:`Grid` with flood fill and A* pathfinding
|
|
7
|
+
loop -- fixed-timestep :class:`GameLoop` and :class:`Timer`
|
|
8
|
+
input -- action-based :class:`InputMap`
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .ecs import Entity, World
|
|
12
|
+
from .grid import Grid
|
|
13
|
+
from .input import InputMap
|
|
14
|
+
from .loop import GameLoop, Timer
|
|
15
|
+
from .vec import CARDINALS, Vec2
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CARDINALS",
|
|
21
|
+
"Entity",
|
|
22
|
+
"GameLoop",
|
|
23
|
+
"Grid",
|
|
24
|
+
"InputMap",
|
|
25
|
+
"Timer",
|
|
26
|
+
"Vec2",
|
|
27
|
+
"World",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""A minimal entity-component-system.
|
|
2
|
+
|
|
3
|
+
Entities are integer ids. Components are arbitrary Python objects stored by
|
|
4
|
+
their type, so each entity holds at most one component of a given type.
|
|
5
|
+
Systems are callables ``system(world, dt)`` run in registration order.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from itertools import count
|
|
11
|
+
from typing import Any, Callable, Iterator, TypeVar, overload
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
A = TypeVar("A")
|
|
15
|
+
B = TypeVar("B")
|
|
16
|
+
C = TypeVar("C")
|
|
17
|
+
|
|
18
|
+
Entity = int
|
|
19
|
+
System = Callable[["World", float], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class World:
|
|
23
|
+
"""Container for entities, their components, and the systems that act on them."""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._ids = count(1)
|
|
27
|
+
self._entities: dict[Entity, dict[type, Any]] = {}
|
|
28
|
+
self._systems: list[System] = []
|
|
29
|
+
self._pending_destroy: set[Entity] = set()
|
|
30
|
+
|
|
31
|
+
# -- entities -----------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def spawn(self, *components: Any) -> Entity:
|
|
34
|
+
"""Create a new entity with the given components and return its id."""
|
|
35
|
+
eid = next(self._ids)
|
|
36
|
+
self._entities[eid] = {}
|
|
37
|
+
for comp in components:
|
|
38
|
+
self.add(eid, comp)
|
|
39
|
+
return eid
|
|
40
|
+
|
|
41
|
+
def destroy(self, entity: Entity) -> None:
|
|
42
|
+
"""Mark ``entity`` for removal at the end of the current :meth:`step`.
|
|
43
|
+
|
|
44
|
+
Outside of a step, call :meth:`flush` (or the next ``step``) to apply it.
|
|
45
|
+
Deferring keeps iteration inside systems safe.
|
|
46
|
+
"""
|
|
47
|
+
if entity in self._entities:
|
|
48
|
+
self._pending_destroy.add(entity)
|
|
49
|
+
|
|
50
|
+
def flush(self) -> None:
|
|
51
|
+
"""Apply pending destroys immediately."""
|
|
52
|
+
for eid in self._pending_destroy:
|
|
53
|
+
self._entities.pop(eid, None)
|
|
54
|
+
self._pending_destroy.clear()
|
|
55
|
+
|
|
56
|
+
def alive(self, entity: Entity) -> bool:
|
|
57
|
+
"""True if ``entity`` exists and is not pending destruction."""
|
|
58
|
+
return entity in self._entities and entity not in self._pending_destroy
|
|
59
|
+
|
|
60
|
+
def __len__(self) -> int:
|
|
61
|
+
return sum(1 for e in self._entities if e not in self._pending_destroy)
|
|
62
|
+
|
|
63
|
+
# -- components ---------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def add(self, entity: Entity, component: Any) -> None:
|
|
66
|
+
"""Attach ``component`` to ``entity``, replacing any of the same type."""
|
|
67
|
+
self._require(entity)[type(component)] = component
|
|
68
|
+
|
|
69
|
+
def remove(self, entity: Entity, ctype: type) -> None:
|
|
70
|
+
"""Detach the component of type ``ctype``; no-op if absent."""
|
|
71
|
+
self._require(entity).pop(ctype, None)
|
|
72
|
+
|
|
73
|
+
def get(self, entity: Entity, ctype: type[T]) -> T:
|
|
74
|
+
"""Return the ``ctype`` component of ``entity``; raise ``KeyError`` if absent."""
|
|
75
|
+
return self._require(entity)[ctype]
|
|
76
|
+
|
|
77
|
+
def try_get(self, entity: Entity, ctype: type[T]) -> T | None:
|
|
78
|
+
"""Return the ``ctype`` component of ``entity`` or ``None``."""
|
|
79
|
+
return self._require(entity).get(ctype)
|
|
80
|
+
|
|
81
|
+
def has(self, entity: Entity, *ctypes: type) -> bool:
|
|
82
|
+
"""True if ``entity`` has every component type in ``ctypes``."""
|
|
83
|
+
comps = self._entities.get(entity)
|
|
84
|
+
return comps is not None and all(c in comps for c in ctypes)
|
|
85
|
+
|
|
86
|
+
@overload
|
|
87
|
+
def query(self, a: type[A], /) -> Iterator[tuple[Entity, A]]: ...
|
|
88
|
+
@overload
|
|
89
|
+
def query(self, a: type[A], b: type[B], /) -> Iterator[tuple[Entity, A, B]]: ...
|
|
90
|
+
@overload
|
|
91
|
+
def query(
|
|
92
|
+
self, a: type[A], b: type[B], c: type[C], /
|
|
93
|
+
) -> Iterator[tuple[Entity, A, B, C]]: ...
|
|
94
|
+
@overload
|
|
95
|
+
def query(self, *ctypes: type) -> Iterator[tuple[Any, ...]]: ...
|
|
96
|
+
|
|
97
|
+
def query(self, *ctypes: type) -> Iterator[tuple[Any, ...]]:
|
|
98
|
+
"""Yield ``(entity, comp1, comp2, ...)`` for live entities having all ``ctypes``.
|
|
99
|
+
|
|
100
|
+
Entities are yielded in creation order. The entity set is snapshotted,
|
|
101
|
+
so spawning/destroying while iterating is safe.
|
|
102
|
+
"""
|
|
103
|
+
if not ctypes:
|
|
104
|
+
raise ValueError("query() needs at least one component type")
|
|
105
|
+
for eid, comps in list(self._entities.items()):
|
|
106
|
+
if eid in self._pending_destroy:
|
|
107
|
+
continue
|
|
108
|
+
if all(c in comps for c in ctypes):
|
|
109
|
+
yield (eid, *(comps[c] for c in ctypes))
|
|
110
|
+
|
|
111
|
+
# -- systems ------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def add_system(self, system: System) -> System:
|
|
114
|
+
"""Register a system; returns it so this can be used as a decorator."""
|
|
115
|
+
self._systems.append(system)
|
|
116
|
+
return system
|
|
117
|
+
|
|
118
|
+
def step(self, dt: float) -> None:
|
|
119
|
+
"""Run every system once with timestep ``dt``, then flush destroys."""
|
|
120
|
+
for system in self._systems:
|
|
121
|
+
system(self, dt)
|
|
122
|
+
self.flush()
|
|
123
|
+
|
|
124
|
+
def _require(self, entity: Entity) -> dict[type, Any]:
|
|
125
|
+
try:
|
|
126
|
+
return self._entities[entity]
|
|
127
|
+
except KeyError:
|
|
128
|
+
raise KeyError(f"no such entity: {entity}") from None
|