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 +29 -0
- gamekit/ecs.py +128 -0
- gamekit/grid.py +130 -0
- gamekit/input.py +72 -0
- gamekit/loop.py +160 -0
- gamekit/py.typed +0 -0
- gamekit/vec.py +58 -0
- gamekit-0.1.0.dist-info/METADATA +197 -0
- gamekit-0.1.0.dist-info/RECORD +12 -0
- gamekit-0.1.0.dist-info/WHEEL +5 -0
- gamekit-0.1.0.dist-info/licenses/LICENSE +21 -0
- gamekit-0.1.0.dist-info/top_level.txt +1 -0
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,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
|