mini-arcade-core 0.3.1__tar.gz → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-arcade-core
3
- Version: 0.3.1
3
+ Version: 0.8.0
4
4
  Summary: Tiny scene-based game loop core for small arcade games.
5
5
  License: Copyright (c) 2025 Santiago Rincón
6
6
 
@@ -34,6 +34,7 @@ Provides-Extra: dev
34
34
  Requires-Dist: black (>=24.10,<25.0) ; extra == "dev"
35
35
  Requires-Dist: isort (>=5.13,<6.0) ; extra == "dev"
36
36
  Requires-Dist: mypy (>=1.5,<2.0) ; extra == "dev"
37
+ Requires-Dist: pillow (<12)
37
38
  Requires-Dist: pylint (>=3.3,<4.0) ; extra == "dev"
38
39
  Requires-Dist: pytest (>=8.3,<9.0) ; extra == "dev"
39
40
  Requires-Dist: pytest-cov (>=6.0,<7.0) ; extra == "dev"
@@ -122,21 +123,21 @@ Represents one state of your game (menu, gameplay, pause, etc.):
122
123
  from mini_arcade_core import Scene, Game
123
124
 
124
125
  class MyScene(Scene):
125
- def on_enter(self) -> None:
126
+ def on_enter(self):
126
127
  print("Scene entered")
127
128
 
128
- def on_exit(self) -> None:
129
+ def on_exit(self):
129
130
  print("Scene exited")
130
131
 
131
- def handle_event(self, event: object) -> None:
132
+ def handle_event(self, event: object):
132
133
  # Handle input / events from your backend
133
134
  pass
134
135
 
135
- def update(self, dt: float) -> None:
136
+ def update(self, dt: float):
136
137
  # Game logic
137
138
  pass
138
139
 
139
- def draw(self, surface: object) -> None:
140
+ def draw(self, surface: object):
140
141
  # Rendering via your backend
141
142
  pass
142
143
  ```
@@ -149,17 +150,17 @@ Lightweight game object primitives:
149
150
  from mini_arcade_core import Entity, SpriteEntity
150
151
 
151
152
  class Ball(Entity):
152
- def __init__(self) -> None:
153
+ def __init__(self):
153
154
  self.x = 100.0
154
155
  self.y = 100.0
155
156
  self.vx = 200.0
156
157
  self.vy = 150.0
157
158
 
158
- def update(self, dt: float) -> None:
159
+ def update(self, dt: float):
159
160
  self.x += self.vx * dt
160
161
  self.y += self.vy * dt
161
162
 
162
- def draw(self, surface: object) -> None:
163
+ def draw(self, surface: object):
163
164
  # Use your backend to draw the ball on `surface`
164
165
  pass
165
166
 
@@ -181,7 +182,7 @@ from mini_arcade_core import Game, GameConfig, Scene
181
182
 
182
183
 
183
184
  class PygameGame(Game):
184
- def __init__(self, config: GameConfig) -> None:
185
+ def __init__(self, config: GameConfig):
185
186
  super().__init__(config)
186
187
  pygame.init()
187
188
  self._screen = pygame.display.set_mode(
@@ -190,13 +191,13 @@ class PygameGame(Game):
190
191
  pygame.display.set_caption(config.title)
191
192
  self._clock = pygame.time.Clock()
192
193
 
193
- def change_scene(self, scene: Scene) -> None:
194
+ def change_scene(self, scene: Scene):
194
195
  if self._current_scene is not None:
195
196
  self._current_scene.on_exit()
196
197
  self._current_scene = scene
197
198
  self._current_scene.on_enter()
198
199
 
199
- def run(self, initial_scene: Scene) -> None:
200
+ def run(self, initial_scene: Scene):
200
201
  self.change_scene(initial_scene)
201
202
  self._running = True
202
203
 
@@ -219,7 +220,7 @@ class PygameGame(Game):
219
220
 
220
221
 
221
222
  class PongScene(Scene):
222
- def __init__(self, game: Game) -> None:
223
+ def __init__(self, game: Game):
223
224
  super().__init__(game)
224
225
  self.x = 100.0
225
226
  self.y = 100.0
@@ -227,17 +228,17 @@ class PongScene(Scene):
227
228
  self.vy = 150.0
228
229
  self.radius = 10
229
230
 
230
- def on_enter(self) -> None:
231
+ def on_enter(self):
231
232
  print("Pong started")
232
233
 
233
- def on_exit(self) -> None:
234
+ def on_exit(self):
234
235
  print("Pong finished")
235
236
 
236
- def handle_event(self, event: object) -> None:
237
+ def handle_event(self, event: object):
237
238
  # no input yet
238
239
  pass
239
240
 
240
- def update(self, dt: float) -> None:
241
+ def update(self, dt: float):
241
242
  self.x += self.vx * dt
242
243
  self.y += self.vy * dt
243
244
 
@@ -249,7 +250,7 @@ class PongScene(Scene):
249
250
  if self.y < self.radius or self.y > height - self.radius:
250
251
  self.vy *= -1
251
252
 
252
- def draw(self, surface: pygame.Surface) -> None: # type: ignore[override]
253
+ def draw(self, surface: pygame.Surface): # type: ignore[override]
253
254
  pygame.draw.circle(
254
255
  surface, (255, 255, 255), (int(self.x), int(self.y)), self.radius
255
256
  )
@@ -81,21 +81,21 @@ Represents one state of your game (menu, gameplay, pause, etc.):
81
81
  from mini_arcade_core import Scene, Game
82
82
 
83
83
  class MyScene(Scene):
84
- def on_enter(self) -> None:
84
+ def on_enter(self):
85
85
  print("Scene entered")
86
86
 
87
- def on_exit(self) -> None:
87
+ def on_exit(self):
88
88
  print("Scene exited")
89
89
 
90
- def handle_event(self, event: object) -> None:
90
+ def handle_event(self, event: object):
91
91
  # Handle input / events from your backend
92
92
  pass
93
93
 
94
- def update(self, dt: float) -> None:
94
+ def update(self, dt: float):
95
95
  # Game logic
96
96
  pass
97
97
 
98
- def draw(self, surface: object) -> None:
98
+ def draw(self, surface: object):
99
99
  # Rendering via your backend
100
100
  pass
101
101
  ```
@@ -108,17 +108,17 @@ Lightweight game object primitives:
108
108
  from mini_arcade_core import Entity, SpriteEntity
109
109
 
110
110
  class Ball(Entity):
111
- def __init__(self) -> None:
111
+ def __init__(self):
112
112
  self.x = 100.0
113
113
  self.y = 100.0
114
114
  self.vx = 200.0
115
115
  self.vy = 150.0
116
116
 
117
- def update(self, dt: float) -> None:
117
+ def update(self, dt: float):
118
118
  self.x += self.vx * dt
119
119
  self.y += self.vy * dt
120
120
 
121
- def draw(self, surface: object) -> None:
121
+ def draw(self, surface: object):
122
122
  # Use your backend to draw the ball on `surface`
123
123
  pass
124
124
 
@@ -140,7 +140,7 @@ from mini_arcade_core import Game, GameConfig, Scene
140
140
 
141
141
 
142
142
  class PygameGame(Game):
143
- def __init__(self, config: GameConfig) -> None:
143
+ def __init__(self, config: GameConfig):
144
144
  super().__init__(config)
145
145
  pygame.init()
146
146
  self._screen = pygame.display.set_mode(
@@ -149,13 +149,13 @@ class PygameGame(Game):
149
149
  pygame.display.set_caption(config.title)
150
150
  self._clock = pygame.time.Clock()
151
151
 
152
- def change_scene(self, scene: Scene) -> None:
152
+ def change_scene(self, scene: Scene):
153
153
  if self._current_scene is not None:
154
154
  self._current_scene.on_exit()
155
155
  self._current_scene = scene
156
156
  self._current_scene.on_enter()
157
157
 
158
- def run(self, initial_scene: Scene) -> None:
158
+ def run(self, initial_scene: Scene):
159
159
  self.change_scene(initial_scene)
160
160
  self._running = True
161
161
 
@@ -178,7 +178,7 @@ class PygameGame(Game):
178
178
 
179
179
 
180
180
  class PongScene(Scene):
181
- def __init__(self, game: Game) -> None:
181
+ def __init__(self, game: Game):
182
182
  super().__init__(game)
183
183
  self.x = 100.0
184
184
  self.y = 100.0
@@ -186,17 +186,17 @@ class PongScene(Scene):
186
186
  self.vy = 150.0
187
187
  self.radius = 10
188
188
 
189
- def on_enter(self) -> None:
189
+ def on_enter(self):
190
190
  print("Pong started")
191
191
 
192
- def on_exit(self) -> None:
192
+ def on_exit(self):
193
193
  print("Pong finished")
194
194
 
195
- def handle_event(self, event: object) -> None:
195
+ def handle_event(self, event: object):
196
196
  # no input yet
197
197
  pass
198
198
 
199
- def update(self, dt: float) -> None:
199
+ def update(self, dt: float):
200
200
  self.x += self.vx * dt
201
201
  self.y += self.vy * dt
202
202
 
@@ -208,7 +208,7 @@ class PongScene(Scene):
208
208
  if self.y < self.radius or self.y > height - self.radius:
209
209
  self.vy *= -1
210
210
 
211
- def draw(self, surface: pygame.Surface) -> None: # type: ignore[override]
211
+ def draw(self, surface: pygame.Surface): # type: ignore[override]
212
212
  pygame.draw.circle(
213
213
  surface, (255, 255, 255), (int(self.x), int(self.y)), self.radius
214
214
  )
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "mini-arcade-core"
7
- version = "0.3.1"
7
+ version = "0.8.0"
8
8
  description = "Tiny scene-based game loop core for small arcade games."
9
9
  authors = [
10
10
  { name = "Santiago Rincon", email = "rincores@gmail.com" },
@@ -12,7 +12,7 @@ authors = [
12
12
  readme = "README.md"
13
13
  license = { file = "LICENSE" }
14
14
  requires-python = ">=3.9,<3.12"
15
- dependencies = []
15
+ dependencies = ["pillow (<12)"]
16
16
 
17
17
  [project.optional-dependencies]
18
18
  dev = [
@@ -57,6 +57,8 @@ strict = false
57
57
  disable = [
58
58
  # Justification: Allow classes with few public methods for simplicity.
59
59
  "too-few-public-methods",
60
+ # Justification: f-strings are preferred for logging.
61
+ "logging-fstring-interpolation",
60
62
  # Justification: TODOs are acceptable during development.
61
63
  "fixme",
62
64
  ]
@@ -0,0 +1,100 @@
1
+ """
2
+ Entry point for the mini_arcade_core package.
3
+ Provides access to core classes and a convenience function to run a game.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ from .backend import Backend, Event, EventType
12
+ from .boundaries2d import (
13
+ RectKinematic,
14
+ RectSprite,
15
+ VerticalBounce,
16
+ VerticalWrap,
17
+ )
18
+ from .collision2d import RectCollider
19
+ from .entity import Entity, KinematicEntity, SpriteEntity
20
+ from .game import Game, GameConfig
21
+ from .geometry2d import Bounds2D, Position2D, Size2D
22
+ from .kinematics2d import KinematicData
23
+ from .physics2d import Velocity2D
24
+ from .scene import Scene
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def run_game(initial_scene_cls: type[Scene], config: GameConfig | None = None):
30
+ """
31
+ Convenience helper to bootstrap and run a game with a single scene.
32
+
33
+ :param initial_scene_cls: The Scene subclass to instantiate as the initial scene.
34
+ :type initial_scene_cls: type[Scene]
35
+
36
+ :param config: Optional GameConfig to customize game settings.
37
+ :type config: GameConfig | None
38
+
39
+ :raises ValueError: If the provided config does not have a valid Backend.
40
+ """
41
+ cfg = config or GameConfig()
42
+ if config.backend is None:
43
+ raise ValueError(
44
+ "GameConfig.backend must be set to a Backend instance"
45
+ )
46
+ game = Game(cfg)
47
+ scene = initial_scene_cls(game)
48
+ game.run(scene)
49
+
50
+
51
+ __all__ = [
52
+ "Game",
53
+ "GameConfig",
54
+ "Scene",
55
+ "Entity",
56
+ "SpriteEntity",
57
+ "run_game",
58
+ "Backend",
59
+ "Event",
60
+ "EventType",
61
+ "Velocity2D",
62
+ "Position2D",
63
+ "Size2D",
64
+ "KinematicEntity",
65
+ "KinematicData",
66
+ "RectCollider",
67
+ "VerticalBounce",
68
+ "Bounds2D",
69
+ "VerticalWrap",
70
+ "RectSprite",
71
+ "RectKinematic",
72
+ ]
73
+
74
+ PACKAGE_NAME = "mini-arcade-core" # or whatever is in your pyproject.toml
75
+
76
+
77
+ def get_version() -> str:
78
+ """
79
+ Return the installed package version.
80
+
81
+ This is a thin helper around importlib.metadata.version so games can do:
82
+
83
+ from mini_arcade_core import get_version
84
+ print(get_version())
85
+
86
+ :return: The version string of the installed package.
87
+ :rtype: str
88
+
89
+ :raises PackageNotFoundError: If the package is not installed.
90
+ """
91
+ try:
92
+ return version(PACKAGE_NAME)
93
+ except PackageNotFoundError: # if running from source / editable
94
+ logger.warning(
95
+ f"Package '{PACKAGE_NAME}' not found. Returning default version '0.0.0'."
96
+ )
97
+ return "0.0.0"
98
+
99
+
100
+ __version__ = get_version()
@@ -0,0 +1,173 @@
1
+ """
2
+ Backend interface for rendering and input.
3
+ This is the only part of the code that talks to SDL/pygame directly.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from enum import Enum, auto
10
+ from typing import Iterable, Protocol, Tuple, Union
11
+
12
+ Color = Union[Tuple[int, int, int], Tuple[int, int, int, int]]
13
+
14
+
15
+ class EventType(Enum):
16
+ """
17
+ High-level event types understood by the core.
18
+
19
+ :cvar UNKNOWN: Unknown/unhandled event.
20
+ :cvar QUIT: User requested to quit the game.
21
+ :cvar KEYDOWN: A key was pressed.
22
+ :cvar KEYUP: A key was released.
23
+ """
24
+
25
+ UNKNOWN = auto()
26
+ QUIT = auto()
27
+ KEYDOWN = auto()
28
+ KEYUP = auto()
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Event:
33
+ """
34
+ Core event type.
35
+
36
+ For now we only care about:
37
+ - type: what happened
38
+ - key: integer key code (e.g. ESC = 27), or None if not applicable
39
+
40
+ :ivar type (EventType): The type of event.
41
+ :ivar key (int | None): The key code associated with the event, if any.
42
+ """
43
+
44
+ type: EventType
45
+ key: int | None = None
46
+
47
+
48
+ class Backend(Protocol):
49
+ """
50
+ Interface that any rendering/input backend must implement.
51
+
52
+ mini-arcade-core only talks to this protocol, never to SDL/pygame directly.
53
+ """
54
+
55
+ def init(self, width: int, height: int, title: str):
56
+ """
57
+ Initialize the backend and open a window.
58
+ Should be called once before the main loop.
59
+
60
+ :param width: Width of the window in pixels.
61
+ :type width: int
62
+
63
+ :param height: Height of the window in pixels.
64
+ :type height: int
65
+
66
+ :param title: Title of the window.
67
+ :type title: str
68
+ """
69
+
70
+ def poll_events(self) -> Iterable[Event]:
71
+ """
72
+ Return all pending events since last call.
73
+ Concrete backends will translate their native events into core Event objects.
74
+
75
+ :return: An iterable of Event objects.
76
+ :rtype: Iterable[Event]
77
+ """
78
+
79
+ def set_clear_color(self, r: int, g: int, b: int):
80
+ """
81
+ Set the background/clear color used by begin_frame.
82
+
83
+ :param r: Red component (0-255).
84
+ :type r: int
85
+
86
+ :param g: Green component (0-255).
87
+ :type g: int
88
+
89
+ :param b: Blue component (0-255).
90
+ :type b: int
91
+ """
92
+
93
+ def begin_frame(self):
94
+ """
95
+ Prepare for drawing a new frame (e.g. clear screen).
96
+ """
97
+
98
+ def end_frame(self):
99
+ """
100
+ Present the frame to the user (swap buffers).
101
+ """
102
+
103
+ # Justification: Simple drawing API for now
104
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
105
+ def draw_rect(
106
+ self,
107
+ x: int,
108
+ y: int,
109
+ w: int,
110
+ h: int,
111
+ color: Color = (255, 255, 255),
112
+ ):
113
+ """
114
+ Draw a filled rectangle in some default color.
115
+ We'll keep this minimal for now; later we can extend with colors/sprites.
116
+
117
+ :param x: X position of the rectangle's top-left corner.
118
+ :type x: int
119
+
120
+ :param y: Y position of the rectangle's top-left corner.
121
+ :type y: int
122
+
123
+ :param w: Width of the rectangle.
124
+ :type w: int
125
+
126
+ :param h: Height of the rectangle.
127
+ :type h: int
128
+
129
+ :param color: RGB color tuple.
130
+ :type color: Color
131
+ """
132
+
133
+ # pylint: enable=too-many-arguments,too-many-positional-arguments
134
+
135
+ def draw_text(
136
+ self,
137
+ x: int,
138
+ y: int,
139
+ text: str,
140
+ color: Color = (255, 255, 255),
141
+ ):
142
+ """
143
+ Draw text at the given position in a default font and color.
144
+
145
+ Backends may ignore advanced styling for now; this is just to render
146
+ simple labels like menu items, scores, etc.
147
+
148
+ :param x: X position of the text's top-left corner.
149
+ :type x: int
150
+
151
+ :param y: Y position of the text's top-left corner.
152
+ :type y: int
153
+
154
+ :param text: The text string to draw.
155
+ :type text: str
156
+
157
+ :param color: RGB color tuple.
158
+ :type color: Color
159
+ """
160
+
161
+ def capture_frame(self, path: str | None = None) -> bytes | None:
162
+ """
163
+ Capture the current frame.
164
+ If `path` is provided, save to that file (e.g. PNG).
165
+ Returns raw bytes (PNG) or None if unsupported.
166
+
167
+ :param path: Optional file path to save the screenshot.
168
+ :type path: str | None
169
+
170
+ :return: Raw image bytes if no path given, else None.
171
+ :rtype: bytes | None
172
+ """
173
+ raise NotImplementedError
@@ -0,0 +1,107 @@
1
+ """
2
+ Boundary behaviors for 2D rectangular objects.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Protocol
9
+
10
+ from .geometry2d import Bounds2D, Position2D, Size2D
11
+ from .physics2d import Velocity2D
12
+
13
+
14
+ class RectKinematic(Protocol):
15
+ """
16
+ Minimal contract for something that can bounce:
17
+ - position: Position2D
18
+ - size: Size2D
19
+ - velocity: Velocity2D
20
+
21
+ :ivar position (Position2D): Top-left position of the object.
22
+ :ivar size (Size2D): Size of the object.
23
+ :ivar velocity (Velocity2D): Velocity of the object.
24
+ """
25
+
26
+ position: Position2D
27
+ size: Size2D
28
+ velocity: Velocity2D
29
+
30
+
31
+ @dataclass
32
+ class VerticalBounce:
33
+ """
34
+ Bounce an object off the top/bottom bounds by inverting vy
35
+ and clamping its position inside the bounds.
36
+
37
+ :ivar bounds (Bounds2D): The boundary rectangle.
38
+ """
39
+
40
+ bounds: Bounds2D
41
+
42
+ def apply(self, obj: RectKinematic):
43
+ """
44
+ Apply vertical bounce to the given object.
45
+
46
+ :param obj: The object to apply the bounce to.
47
+ :type obj: RectKinematic
48
+ """
49
+ top = self.bounds.top
50
+ bottom = self.bounds.bottom
51
+
52
+ # Top collision
53
+ if obj.position.y <= top:
54
+ obj.position.y = top
55
+ obj.velocity.vy *= -1
56
+
57
+ # Bottom collision
58
+ if obj.position.y + obj.size.height >= bottom:
59
+ obj.position.y = bottom - obj.size.height
60
+ obj.velocity.vy *= -1
61
+
62
+
63
+ class RectSprite(Protocol):
64
+ """
65
+ Minimal contract for something that can wrap:
66
+ - position: Position2D
67
+ - size: Size2D
68
+ (no velocity required)
69
+
70
+ :ivar position (Position2D): Top-left position of the object.
71
+ :ivar size (Size2D): Size of the object.
72
+ """
73
+
74
+ position: Position2D
75
+ size: Size2D
76
+
77
+
78
+ @dataclass
79
+ class VerticalWrap:
80
+ """
81
+ Wrap an object top <-> bottom.
82
+
83
+ If it leaves above the top, it reappears at the bottom.
84
+ If it leaves below the bottom, it reappears at the top.
85
+
86
+ :ivar bounds (Bounds2D): The boundary rectangle.
87
+ """
88
+
89
+ bounds: Bounds2D
90
+
91
+ def apply(self, obj: RectSprite):
92
+ """
93
+ Apply vertical wrap to the given object.
94
+
95
+ :param obj: The object to apply the wrap to.
96
+ :type obj: RectSprite
97
+ """
98
+ top = self.bounds.top
99
+ bottom = self.bounds.bottom
100
+
101
+ # Completely above top → appear at bottom
102
+ if obj.position.y + obj.size.height < top:
103
+ obj.position.y = bottom
104
+
105
+ # Completely below bottom → appear at top
106
+ elif obj.position.y > bottom:
107
+ obj.position.y = top - obj.size.height