gamekit 0.1.0__py3-none-any.whl

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/__init__.py 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
+ ]
gamekit/ecs.py ADDED
@@ -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
gamekit/grid.py ADDED
@@ -0,0 +1,130 @@
1
+ """Tile grids with walls, neighbour lookup, flood fill, and A* pathfinding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ from collections import deque
7
+ from itertools import count
8
+ from typing import Iterable, Iterator
9
+
10
+ from .vec import CARDINALS, Vec2
11
+
12
+
13
+ class Grid:
14
+ """A rectangular grid of cells; each cell is either open or a wall.
15
+
16
+ Coordinates are ``Vec2(x, y)`` with ``0 <= x < width`` and ``0 <= y < height``.
17
+ Movement is 4-directional.
18
+ """
19
+
20
+ def __init__(self, width: int, height: int, walls: Iterable[Vec2] = ()) -> None:
21
+ if width <= 0 or height <= 0:
22
+ raise ValueError("grid dimensions must be positive")
23
+ self.width = width
24
+ self.height = height
25
+ self._walls: set[Vec2] = set()
26
+ for w in walls:
27
+ self.set_wall(w)
28
+
29
+ @classmethod
30
+ def from_ascii(cls, text: str, wall: str = "#") -> "Grid":
31
+ """Build a grid from lines of text where ``wall`` characters are walls.
32
+
33
+ Leading/trailing blank lines are ignored; short lines are padded as open.
34
+ """
35
+ lines = [ln for ln in text.strip("\n").splitlines()]
36
+ if not lines:
37
+ raise ValueError("empty map")
38
+ width = max(len(ln) for ln in lines)
39
+ walls = [
40
+ Vec2(x, y)
41
+ for y, ln in enumerate(lines)
42
+ for x, ch in enumerate(ln)
43
+ if ch == wall
44
+ ]
45
+ return cls(width, len(lines), walls)
46
+
47
+ def in_bounds(self, p: Vec2) -> bool:
48
+ """True if ``p`` lies inside the grid."""
49
+ return 0 <= p.x < self.width and 0 <= p.y < self.height
50
+
51
+ def is_wall(self, p: Vec2) -> bool:
52
+ """True if ``p`` is a wall. Out-of-bounds cells count as walls."""
53
+ return not self.in_bounds(p) or p in self._walls
54
+
55
+ def passable(self, p: Vec2) -> bool:
56
+ """True if ``p`` is in bounds and not a wall."""
57
+ return not self.is_wall(p)
58
+
59
+ def set_wall(self, p: Vec2, wall: bool = True) -> None:
60
+ """Make ``p`` a wall (or open it with ``wall=False``)."""
61
+ if not self.in_bounds(p):
62
+ raise IndexError(f"{p} is out of bounds")
63
+ if wall:
64
+ self._walls.add(p)
65
+ else:
66
+ self._walls.discard(p)
67
+
68
+ def cells(self) -> Iterator[Vec2]:
69
+ """Iterate every cell in row-major order."""
70
+ for y in range(self.height):
71
+ for x in range(self.width):
72
+ yield Vec2(x, y)
73
+
74
+ def neighbors(self, p: Vec2) -> list[Vec2]:
75
+ """Passable 4-connected neighbours of ``p``."""
76
+ return [q for q in (p + d for d in CARDINALS) if self.passable(q)]
77
+
78
+ def reachable(self, start: Vec2) -> set[Vec2]:
79
+ """All cells reachable from ``start`` (flood fill). Empty if start is a wall."""
80
+ if not self.passable(start):
81
+ return set()
82
+ seen = {start}
83
+ queue = deque([start])
84
+ while queue:
85
+ for q in self.neighbors(queue.popleft()):
86
+ if q not in seen:
87
+ seen.add(q)
88
+ queue.append(q)
89
+ return seen
90
+
91
+ def find_path(self, start: Vec2, goal: Vec2) -> list[Vec2] | None:
92
+ """Shortest 4-directional path from ``start`` to ``goal`` using A*.
93
+
94
+ Returns the list of cells including both endpoints, ``[start]`` when
95
+ ``start == goal``, or ``None`` if no path exists or an endpoint is blocked.
96
+ """
97
+ if not (self.passable(start) and self.passable(goal)):
98
+ return None
99
+ tie = count() # stable tie-breaker so heap never compares Vec2s
100
+ frontier: list[tuple[int, int, Vec2]] = [(0, next(tie), start)]
101
+ came_from: dict[Vec2, Vec2 | None] = {start: None}
102
+ cost: dict[Vec2, int] = {start: 0}
103
+ while frontier:
104
+ _, _, cur = heapq.heappop(frontier)
105
+ if cur == goal:
106
+ path = [cur]
107
+ while (prev := came_from[path[-1]]) is not None:
108
+ path.append(prev)
109
+ return path[::-1]
110
+ for nxt in self.neighbors(cur):
111
+ new_cost = cost[cur] + 1
112
+ if new_cost < cost.get(nxt, new_cost + 1):
113
+ cost[nxt] = new_cost
114
+ came_from[nxt] = cur
115
+ prio = new_cost + int(nxt.manhattan(goal))
116
+ heapq.heappush(frontier, (prio, next(tie), nxt))
117
+ return None
118
+
119
+ def render(self, marks: dict[Vec2, str] | None = None,
120
+ wall: str = "#", floor: str = ".") -> str:
121
+ """Render the grid as text; ``marks`` overrides characters at given cells."""
122
+ marks = marks or {}
123
+ rows = []
124
+ for y in range(self.height):
125
+ row = []
126
+ for x in range(self.width):
127
+ p = Vec2(x, y)
128
+ row.append(marks.get(p, wall if p in self._walls else floor))
129
+ rows.append("".join(row))
130
+ return "\n".join(rows)
gamekit/input.py ADDED
@@ -0,0 +1,72 @@
1
+ """Device-agnostic input mapping: bind raw keys to named actions.
2
+
3
+ Feed raw key events (any hashable: strings, key codes) from whatever backend
4
+ you use, then query actions. Call :meth:`InputMap.end_frame` once per frame so
5
+ "just pressed" / "just released" edges are tracked correctly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from typing import Hashable, Iterable
12
+
13
+
14
+ class InputMap:
15
+ """Maps raw keys to actions and tracks held / just-pressed / just-released state."""
16
+
17
+ def __init__(self, bindings: dict[str, Iterable[Hashable]] | None = None) -> None:
18
+ self._bindings: dict[str, set[Hashable]] = defaultdict(set)
19
+ self._held: set[Hashable] = set()
20
+ self._pressed: set[Hashable] = set()
21
+ self._released: set[Hashable] = set()
22
+ for action, keys in (bindings or {}).items():
23
+ for key in keys:
24
+ self.bind(action, key)
25
+
26
+ def bind(self, action: str, key: Hashable) -> None:
27
+ """Bind ``key`` to ``action``. A key may drive several actions."""
28
+ self._bindings[action].add(key)
29
+
30
+ def unbind(self, action: str, key: Hashable | None = None) -> None:
31
+ """Remove one binding, or all bindings for ``action`` if ``key`` is None."""
32
+ if key is None:
33
+ self._bindings.pop(action, None)
34
+ else:
35
+ self._bindings[action].discard(key)
36
+
37
+ def keys_for(self, action: str) -> frozenset[Hashable]:
38
+ """Keys currently bound to ``action``."""
39
+ return frozenset(self._bindings.get(action, ()))
40
+
41
+ def press(self, key: Hashable) -> None:
42
+ """Record a key-down event."""
43
+ if key not in self._held:
44
+ self._held.add(key)
45
+ self._pressed.add(key)
46
+
47
+ def release(self, key: Hashable) -> None:
48
+ """Record a key-up event."""
49
+ if key in self._held:
50
+ self._held.discard(key)
51
+ self._released.add(key)
52
+
53
+ def is_held(self, action: str) -> bool:
54
+ """True while any key bound to ``action`` is down."""
55
+ return not self._held.isdisjoint(self._bindings.get(action, ()))
56
+
57
+ def just_pressed(self, action: str) -> bool:
58
+ """True on the frame a key bound to ``action`` went down."""
59
+ return not self._pressed.isdisjoint(self._bindings.get(action, ()))
60
+
61
+ def just_released(self, action: str) -> bool:
62
+ """True on the frame a key bound to ``action`` went up."""
63
+ return not self._released.isdisjoint(self._bindings.get(action, ()))
64
+
65
+ def axis(self, negative: str, positive: str) -> int:
66
+ """-1, 0 or +1 from two opposing held actions (e.g. ``"left"``, ``"right"``)."""
67
+ return int(self.is_held(positive)) - int(self.is_held(negative))
68
+
69
+ def end_frame(self) -> None:
70
+ """Clear per-frame edge state. Call once after game logic each frame."""
71
+ self._pressed.clear()
72
+ self._released.clear()
gamekit/loop.py ADDED
@@ -0,0 +1,160 @@
1
+ """Headless fixed-timestep game loop and countdown timers.
2
+
3
+ The loop never sleeps or reads the wall clock unless you ask it to, which makes
4
+ simulations deterministic and easy to test: feed it frame times with
5
+ :meth:`GameLoop.advance`, or run a set number of ticks with :meth:`GameLoop.run`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Callable
12
+
13
+ UpdateFn = Callable[[float], None]
14
+
15
+
16
+ class Timer:
17
+ """A countdown timer that fires a callback when it elapses.
18
+
19
+ With ``repeat=True`` it re-arms itself and can fire several times in one
20
+ large :meth:`tick` (catch-up), keeping long-run timing exact.
21
+ """
22
+
23
+ def __init__(self, duration: float, callback: Callable[[], None] | None = None,
24
+ repeat: bool = False) -> None:
25
+ if duration <= 0:
26
+ raise ValueError("duration must be positive")
27
+ self.duration = duration
28
+ self.callback = callback
29
+ self.repeat = repeat
30
+ self.remaining = duration
31
+ self.finished = False
32
+
33
+ def tick(self, dt: float) -> int:
34
+ """Advance by ``dt`` seconds; return how many times the timer fired."""
35
+ if self.finished:
36
+ return 0
37
+ self.remaining -= dt
38
+ fired = 0
39
+ while self.remaining <= 1e-12:
40
+ fired += 1
41
+ if self.callback is not None:
42
+ self.callback()
43
+ if not self.repeat:
44
+ self.finished = True
45
+ self.remaining = 0.0
46
+ break
47
+ self.remaining += self.duration
48
+ return fired
49
+
50
+ def reset(self) -> None:
51
+ """Restart the countdown from the full duration."""
52
+ self.remaining = self.duration
53
+ self.finished = False
54
+
55
+ @property
56
+ def progress(self) -> float:
57
+ """Fraction of the current cycle elapsed, in ``[0, 1]``."""
58
+ return 1.0 - max(self.remaining, 0.0) / self.duration
59
+
60
+
61
+ class GameLoop:
62
+ """Fixed-timestep loop: real elapsed time is accumulated and consumed in
63
+ equal ``1 / tick_rate`` steps, so game logic is frame-rate independent.
64
+
65
+ ``update(dt)`` is called once per fixed step. Timers added with
66
+ :meth:`every` / :meth:`after` are ticked after ``update`` on each step.
67
+ """
68
+
69
+ def __init__(self, update: UpdateFn, tick_rate: float = 60.0,
70
+ max_steps_per_advance: int = 8) -> None:
71
+ if tick_rate <= 0:
72
+ raise ValueError("tick_rate must be positive")
73
+ self.update = update
74
+ self.dt = 1.0 / tick_rate
75
+ self.max_steps_per_advance = max_steps_per_advance
76
+ self.ticks = 0
77
+ self.time = 0.0
78
+ self.running = False
79
+ self._accumulator = 0.0
80
+ self._timers: list[Timer] = []
81
+
82
+ @property
83
+ def alpha(self) -> float:
84
+ """Leftover fraction of a step, in ``[0, 1)``, for render interpolation."""
85
+ return self._accumulator / self.dt
86
+
87
+ def every(self, seconds: float, callback: Callable[[], None]) -> Timer:
88
+ """Call ``callback`` every ``seconds`` of simulated time."""
89
+ t = Timer(seconds, callback, repeat=True)
90
+ self._timers.append(t)
91
+ return t
92
+
93
+ def after(self, seconds: float, callback: Callable[[], None]) -> Timer:
94
+ """Call ``callback`` once after ``seconds`` of simulated time."""
95
+ t = Timer(seconds, callback)
96
+ self._timers.append(t)
97
+ return t
98
+
99
+ def step(self) -> None:
100
+ """Run exactly one fixed step: ``update``, advance the clock, then timers.
101
+
102
+ Timer callbacks therefore observe the post-step ``ticks`` / ``time``.
103
+ """
104
+ self.update(self.dt)
105
+ self.ticks += 1
106
+ self.time = self.ticks * self.dt
107
+ for t in list(self._timers):
108
+ t.tick(self.dt)
109
+ self._timers = [t for t in self._timers if not t.finished]
110
+
111
+ def advance(self, elapsed: float) -> int:
112
+ """Feed ``elapsed`` real seconds; run as many fixed steps as fit.
113
+
114
+ At most ``max_steps_per_advance`` steps run per call (the rest of the
115
+ backlog is dropped) to avoid a "spiral of death". Returns steps run.
116
+ """
117
+ if elapsed < 0:
118
+ raise ValueError("elapsed must be non-negative")
119
+ self._accumulator += elapsed
120
+ steps = 0
121
+ while self._accumulator + 1e-12 >= self.dt:
122
+ if steps >= self.max_steps_per_advance:
123
+ self._accumulator = 0.0
124
+ break
125
+ self.step()
126
+ self._accumulator -= self.dt
127
+ steps += 1
128
+ self._accumulator = max(self._accumulator, 0.0)
129
+ return steps
130
+
131
+ def run(self, ticks: int) -> None:
132
+ """Run ``ticks`` fixed steps immediately (headless / tests)."""
133
+ self.running = True
134
+ for _ in range(ticks):
135
+ if not self.running:
136
+ break
137
+ self.step()
138
+ self.running = False
139
+
140
+ def run_realtime(self, duration: float | None = None,
141
+ clock: Callable[[], float] = time.perf_counter,
142
+ sleep: Callable[[float], None] = time.sleep) -> None:
143
+ """Run against a real clock until :meth:`stop` or ``duration`` seconds.
144
+
145
+ ``clock`` and ``sleep`` are injectable for testing.
146
+ """
147
+ self.running = True
148
+ start = last = clock()
149
+ while self.running:
150
+ now = clock()
151
+ self.advance(now - last)
152
+ last = now
153
+ if duration is not None and now - start >= duration:
154
+ break
155
+ sleep(max(0.0, self.dt - self._accumulator))
156
+ self.running = False
157
+
158
+ def stop(self) -> None:
159
+ """Ask :meth:`run` / :meth:`run_realtime` to exit after the current step."""
160
+ self.running = False
gamekit/py.typed ADDED
File without changes
gamekit/vec.py ADDED
@@ -0,0 +1,58 @@
1
+ """Immutable 2D integer/float vector used for positions and directions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Iterator, Union
8
+
9
+ Number = Union[int, float]
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Vec2:
14
+ """An immutable 2D vector.
15
+
16
+ Works with ints (grid coordinates) or floats (continuous positions).
17
+ Hashable, so it can be used as a dict key or set member.
18
+ """
19
+
20
+ x: Number = 0
21
+ y: Number = 0
22
+
23
+ def __add__(self, other: "Vec2") -> "Vec2":
24
+ return Vec2(self.x + other.x, self.y + other.y)
25
+
26
+ def __sub__(self, other: "Vec2") -> "Vec2":
27
+ return Vec2(self.x - other.x, self.y - other.y)
28
+
29
+ def __mul__(self, k: Number) -> "Vec2":
30
+ return Vec2(self.x * k, self.y * k)
31
+
32
+ __rmul__ = __mul__
33
+
34
+ def __neg__(self) -> "Vec2":
35
+ return Vec2(-self.x, -self.y)
36
+
37
+ def __iter__(self) -> Iterator[Number]:
38
+ yield self.x
39
+ yield self.y
40
+
41
+ def length(self) -> float:
42
+ """Euclidean length."""
43
+ return math.hypot(self.x, self.y)
44
+
45
+ def manhattan(self, other: "Vec2") -> Number:
46
+ """Manhattan (taxicab) distance to ``other``."""
47
+ return abs(self.x - other.x) + abs(self.y - other.y)
48
+
49
+ def normalized(self) -> "Vec2":
50
+ """Unit vector in the same direction; the zero vector stays zero."""
51
+ n = self.length()
52
+ if n == 0:
53
+ return Vec2(0.0, 0.0)
54
+ return Vec2(self.x / n, self.y / n)
55
+
56
+
57
+ #: The four cardinal unit directions (right, left, down, up), y grows downward.
58
+ CARDINALS: tuple[Vec2, ...] = (Vec2(1, 0), Vec2(-1, 0), Vec2(0, 1), Vec2(0, -1))
@@ -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
@@ -0,0 +1,12 @@
1
+ gamekit/__init__.py,sha256=LTTugKX1V-4G28N9YdsSh0v6eemh4Z1ffuO5QDMBdas,674
2
+ gamekit/ecs.py,sha256=xqRTQt1aiTpL3asFRGF4B_CubMdt1f0ApBDyPO_TLs0,4844
3
+ gamekit/grid.py,sha256=pz2PvYPIr_TP8wjnWK8osfcpJpa3hmDxKcjI4UlmXug,4874
4
+ gamekit/input.py,sha256=k-sCGOnsjThuVP7f1igPhOQ7hhWQx7ekV0RDkW_UznI,2884
5
+ gamekit/loop.py,sha256=TEooWdqqI8kwW00wtFuhXXsS5jCyhvcQQizXhG954Dk,5663
6
+ gamekit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ gamekit/vec.py,sha256=CU-maOWjhaMeJYFIpFI4bRYO1gAm870hO1ndxfHByMk,1639
8
+ gamekit-0.1.0.dist-info/licenses/LICENSE,sha256=pAfYREEW9GAy7cnK20OXjDn7ofJahYny9GCuIZQTDAA,1061
9
+ gamekit-0.1.0.dist-info/METADATA,sha256=u7_CXCE8PHeayvIgDDRAEngK2mZXmAOmcBi8QqV64VY,7563
10
+ gamekit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ gamekit-0.1.0.dist-info/top_level.txt,sha256=B7Oj60S5AlHspUApEv0EDMOTYSRmi3YCAyPhHBYEPd8,8
12
+ gamekit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ gamekit