mini-arcade-core 0.7.5__tar.gz → 0.8.1__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.7.5
3
+ Version: 0.8.1
4
4
  Summary: Tiny scene-based game loop core for small arcade games.
5
5
  License: Copyright (c) 2025 Santiago Rincón
6
6
 
@@ -123,21 +123,21 @@ Represents one state of your game (menu, gameplay, pause, etc.):
123
123
  from mini_arcade_core import Scene, Game
124
124
 
125
125
  class MyScene(Scene):
126
- def on_enter(self) -> None:
126
+ def on_enter(self):
127
127
  print("Scene entered")
128
128
 
129
- def on_exit(self) -> None:
129
+ def on_exit(self):
130
130
  print("Scene exited")
131
131
 
132
- def handle_event(self, event: object) -> None:
132
+ def handle_event(self, event: object):
133
133
  # Handle input / events from your backend
134
134
  pass
135
135
 
136
- def update(self, dt: float) -> None:
136
+ def update(self, dt: float):
137
137
  # Game logic
138
138
  pass
139
139
 
140
- def draw(self, surface: object) -> None:
140
+ def draw(self, surface: object):
141
141
  # Rendering via your backend
142
142
  pass
143
143
  ```
@@ -150,17 +150,17 @@ Lightweight game object primitives:
150
150
  from mini_arcade_core import Entity, SpriteEntity
151
151
 
152
152
  class Ball(Entity):
153
- def __init__(self) -> None:
153
+ def __init__(self):
154
154
  self.x = 100.0
155
155
  self.y = 100.0
156
156
  self.vx = 200.0
157
157
  self.vy = 150.0
158
158
 
159
- def update(self, dt: float) -> None:
159
+ def update(self, dt: float):
160
160
  self.x += self.vx * dt
161
161
  self.y += self.vy * dt
162
162
 
163
- def draw(self, surface: object) -> None:
163
+ def draw(self, surface: object):
164
164
  # Use your backend to draw the ball on `surface`
165
165
  pass
166
166
 
@@ -182,7 +182,7 @@ from mini_arcade_core import Game, GameConfig, Scene
182
182
 
183
183
 
184
184
  class PygameGame(Game):
185
- def __init__(self, config: GameConfig) -> None:
185
+ def __init__(self, config: GameConfig):
186
186
  super().__init__(config)
187
187
  pygame.init()
188
188
  self._screen = pygame.display.set_mode(
@@ -191,13 +191,13 @@ class PygameGame(Game):
191
191
  pygame.display.set_caption(config.title)
192
192
  self._clock = pygame.time.Clock()
193
193
 
194
- def change_scene(self, scene: Scene) -> None:
194
+ def change_scene(self, scene: Scene):
195
195
  if self._current_scene is not None:
196
196
  self._current_scene.on_exit()
197
197
  self._current_scene = scene
198
198
  self._current_scene.on_enter()
199
199
 
200
- def run(self, initial_scene: Scene) -> None:
200
+ def run(self, initial_scene: Scene):
201
201
  self.change_scene(initial_scene)
202
202
  self._running = True
203
203
 
@@ -220,7 +220,7 @@ class PygameGame(Game):
220
220
 
221
221
 
222
222
  class PongScene(Scene):
223
- def __init__(self, game: Game) -> None:
223
+ def __init__(self, game: Game):
224
224
  super().__init__(game)
225
225
  self.x = 100.0
226
226
  self.y = 100.0
@@ -228,17 +228,17 @@ class PongScene(Scene):
228
228
  self.vy = 150.0
229
229
  self.radius = 10
230
230
 
231
- def on_enter(self) -> None:
231
+ def on_enter(self):
232
232
  print("Pong started")
233
233
 
234
- def on_exit(self) -> None:
234
+ def on_exit(self):
235
235
  print("Pong finished")
236
236
 
237
- def handle_event(self, event: object) -> None:
237
+ def handle_event(self, event: object):
238
238
  # no input yet
239
239
  pass
240
240
 
241
- def update(self, dt: float) -> None:
241
+ def update(self, dt: float):
242
242
  self.x += self.vx * dt
243
243
  self.y += self.vy * dt
244
244
 
@@ -250,7 +250,7 @@ class PongScene(Scene):
250
250
  if self.y < self.radius or self.y > height - self.radius:
251
251
  self.vy *= -1
252
252
 
253
- def draw(self, surface: pygame.Surface) -> None: # type: ignore[override]
253
+ def draw(self, surface: pygame.Surface): # type: ignore[override]
254
254
  pygame.draw.circle(
255
255
  surface, (255, 255, 255), (int(self.x), int(self.y)), self.radius
256
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.7.5"
7
+ version = "0.8.1"
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" },
@@ -9,8 +9,18 @@ import logging
9
9
  from importlib.metadata import PackageNotFoundError, version
10
10
 
11
11
  from .backend import Backend, Event, EventType
12
- from .entity import Entity, SpriteEntity
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
13
20
  from .game import Game, GameConfig
21
+ from .geometry2d import Bounds2D, Position2D, Size2D
22
+ from .kinematics2d import KinematicData
23
+ from .physics2d import Velocity2D
14
24
  from .scene import Scene
15
25
 
16
26
  logger = logging.getLogger(__name__)
@@ -25,8 +35,15 @@ def run_game(initial_scene_cls: type[Scene], config: GameConfig | None = None):
25
35
 
26
36
  :param config: Optional GameConfig to customize game settings.
27
37
  :type config: GameConfig | None
38
+
39
+ :raises ValueError: If the provided config does not have a valid Backend.
28
40
  """
29
- game = Game(config or GameConfig())
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)
30
47
  scene = initial_scene_cls(game)
31
48
  game.run(scene)
32
49
 
@@ -41,6 +58,17 @@ __all__ = [
41
58
  "Backend",
42
59
  "Event",
43
60
  "EventType",
61
+ "Velocity2D",
62
+ "Position2D",
63
+ "Size2D",
64
+ "KinematicEntity",
65
+ "KinematicData",
66
+ "RectCollider",
67
+ "VerticalBounce",
68
+ "Bounds2D",
69
+ "VerticalWrap",
70
+ "RectSprite",
71
+ "RectKinematic",
44
72
  ]
45
73
 
46
74
  PACKAGE_NAME = "mini-arcade-core" # or whatever is in your pyproject.toml
@@ -7,7 +7,9 @@ from __future__ import annotations
7
7
 
8
8
  from dataclasses import dataclass
9
9
  from enum import Enum, auto
10
- from typing import Iterable, Protocol
10
+ from typing import Iterable, Protocol, Tuple, Union
11
+
12
+ Color = Union[Tuple[int, int, int], Tuple[int, int, int, int]]
11
13
 
12
14
 
13
15
  class EventType(Enum):
@@ -50,7 +52,7 @@ class Backend(Protocol):
50
52
  mini-arcade-core only talks to this protocol, never to SDL/pygame directly.
51
53
  """
52
54
 
53
- def init(self, width: int, height: int, title: str) -> None:
55
+ def init(self, width: int, height: int, title: str):
54
56
  """
55
57
  Initialize the backend and open a window.
56
58
  Should be called once before the main loop.
@@ -74,7 +76,7 @@ class Backend(Protocol):
74
76
  :rtype: Iterable[Event]
75
77
  """
76
78
 
77
- def set_clear_color(self, r: int, g: int, b: int) -> None:
79
+ def set_clear_color(self, r: int, g: int, b: int):
78
80
  """
79
81
  Set the background/clear color used by begin_frame.
80
82
 
@@ -88,12 +90,12 @@ class Backend(Protocol):
88
90
  :type b: int
89
91
  """
90
92
 
91
- def begin_frame(self) -> None:
93
+ def begin_frame(self):
92
94
  """
93
95
  Prepare for drawing a new frame (e.g. clear screen).
94
96
  """
95
97
 
96
- def end_frame(self) -> None:
98
+ def end_frame(self):
97
99
  """
98
100
  Present the frame to the user (swap buffers).
99
101
  """
@@ -106,8 +108,8 @@ class Backend(Protocol):
106
108
  y: int,
107
109
  w: int,
108
110
  h: int,
109
- color: tuple[int, int, int] = (255, 255, 255),
110
- ) -> None:
111
+ color: Color = (255, 255, 255),
112
+ ):
111
113
  """
112
114
  Draw a filled rectangle in some default color.
113
115
  We'll keep this minimal for now; later we can extend with colors/sprites.
@@ -125,7 +127,7 @@ class Backend(Protocol):
125
127
  :type h: int
126
128
 
127
129
  :param color: RGB color tuple.
128
- :type color: tuple[int, int, int]
130
+ :type color: Color
129
131
  """
130
132
 
131
133
  # pylint: enable=too-many-arguments,too-many-positional-arguments
@@ -135,8 +137,8 @@ class Backend(Protocol):
135
137
  x: int,
136
138
  y: int,
137
139
  text: str,
138
- color: tuple[int, int, int] = (255, 255, 255),
139
- ) -> None:
140
+ color: Color = (255, 255, 255),
141
+ ):
140
142
  """
141
143
  Draw text at the given position in a default font and color.
142
144
 
@@ -153,7 +155,7 @@ class Backend(Protocol):
153
155
  :type text: str
154
156
 
155
157
  :param color: RGB color tuple.
156
- :type color: tuple[int, int, int]
158
+ :type color: Color
157
159
  """
158
160
 
159
161
  def capture_frame(self, path: str | None = None) -> bytes | None:
@@ -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
@@ -0,0 +1,71 @@
1
+ """
2
+ 2D collision detection helpers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+ from .geometry2d import Position2D, Size2D
10
+
11
+
12
+ def _rects_intersect(
13
+ pos_a: Position2D,
14
+ size_a: Size2D,
15
+ pos_b: Position2D,
16
+ size_b: Size2D,
17
+ ) -> bool:
18
+ """
19
+ Low-level AABB check. Internal helper.
20
+
21
+ :param pos_a: Top-left position of rectangle A.
22
+ :type pos_a: Position2D
23
+
24
+ :param size_a: Size of rectangle A.
25
+ :type size_a: Size2D
26
+
27
+ :param pos_b: Top-left position of rectangle B.
28
+ :type pos_b: Position2D
29
+
30
+ :param size_b: Size of rectangle B.
31
+ :type size_b: Size2D
32
+
33
+ :return: True if the rectangles intersect.
34
+ :rtype: bool
35
+ """
36
+ return not (
37
+ pos_a.x + size_a.width < pos_b.x
38
+ or pos_a.x > pos_b.x + size_b.width
39
+ or pos_a.y + size_a.height < pos_b.y
40
+ or pos_a.y > pos_b.y + size_b.height
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class RectCollider:
46
+ """
47
+ OOP collision helper that wraps a Position2D + Size2D pair.
48
+
49
+ It does NOT own the data – it just points to them. If the
50
+ entity moves (position changes), the collider “sees” it.
51
+
52
+ :ivar position (Position2D): Top-left position of the rectangle.
53
+ :ivar size (Size2D): Size of the rectangle.
54
+ """
55
+
56
+ position: Position2D
57
+ size: Size2D
58
+
59
+ def intersects(self, other: "RectCollider") -> bool:
60
+ """
61
+ High-level OOP method to check collision with another collider.
62
+
63
+ ;param other: The other rectangle collider.
64
+ :type other: RectCollider
65
+
66
+ :return: True if the rectangles intersect.
67
+ :rtype: bool
68
+ """
69
+ return _rects_intersect(
70
+ self.position, self.size, other.position, other.size
71
+ )
@@ -0,0 +1,68 @@
1
+ """
2
+ Entity base classes for mini_arcade_core.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from .collision2d import RectCollider
10
+ from .geometry2d import Position2D, Size2D
11
+ from .kinematics2d import KinematicData
12
+
13
+
14
+ class Entity:
15
+ """Entity base class for game objects."""
16
+
17
+ def update(self, dt: float):
18
+ """
19
+ Advance the entity state by ``dt`` seconds.
20
+
21
+ :param dt: Time delta in seconds.
22
+ :type dt: float
23
+ """
24
+
25
+ def draw(self, surface: Any):
26
+ """
27
+ Render the entity to the given surface.
28
+
29
+ :param surface: The surface to draw on.
30
+ :type surface: Any
31
+ """
32
+
33
+
34
+ class SpriteEntity(Entity):
35
+ """Entity with position and size."""
36
+
37
+ def __init__(self, position: Position2D, size: Size2D):
38
+ """
39
+ :param position: Top-left position of the entity.
40
+ :type position: Position2D
41
+
42
+ :param size: Size of the entity.
43
+ :type size: Size2D
44
+ """
45
+ self.position = Position2D(float(position.x), float(position.y))
46
+ self.size = Size2D(int(size.width), int(size.height))
47
+ self.collider = RectCollider(self.position, self.size)
48
+
49
+
50
+ class KinematicEntity(SpriteEntity):
51
+ """SpriteEntity with velocity-based movement."""
52
+
53
+ def __init__(self, kinematic_data: KinematicData):
54
+ """
55
+ :param kinematic_data: Kinematic data for the entity.
56
+ :type kinematic_data: KinematicData
57
+ """
58
+ super().__init__(
59
+ position=kinematic_data.position,
60
+ size=kinematic_data.size,
61
+ )
62
+
63
+ self.velocity = kinematic_data.velocity
64
+
65
+ def update(self, dt: float):
66
+ self.position.x, self.position.y = self.velocity.advance(
67
+ self.position.x, self.position.y, dt
68
+ )
@@ -29,7 +29,7 @@ class GameConfig:
29
29
  :ivar title: Title of the game window.
30
30
  :ivar fps: Target frames per second.
31
31
  :ivar background_color: RGB background color.
32
- :ivar backend: Optional backend class to use for rendering and input.
32
+ :ivar backend: Optional Backend instance to use for rendering and input.
33
33
  """
34
34
 
35
35
  width: int = 800
@@ -40,6 +40,12 @@ class GameConfig:
40
40
  backend: Backend | None = None
41
41
 
42
42
 
43
+ @dataclass
44
+ class _StackEntry:
45
+ scene: "Scene"
46
+ as_overlay: bool = False
47
+
48
+
43
49
  class Game:
44
50
  """Core game object responsible for managing the main loop and active scene."""
45
51
 
@@ -47,17 +53,28 @@ class Game:
47
53
  """
48
54
  :param config: Game configuration options.
49
55
  :type config: GameConfig
56
+
57
+ :raises ValueError: If the provided config does not have a valid Backend.
50
58
  """
51
59
  self.config = config
52
60
  self._current_scene: Scene | None = None
53
61
  self._running: bool = False
54
- self.backend: Backend | None = config.backend
55
62
 
56
63
  if config.backend is None:
57
64
  raise ValueError(
58
65
  "GameConfig.backend must be set to a Backend instance"
59
66
  )
60
67
  self.backend: Backend = config.backend
68
+ self._scene_stack: list[_StackEntry] = []
69
+
70
+ def current_scene(self) -> "Scene | None":
71
+ """
72
+ Get the currently active scene.
73
+
74
+ :return: The active Scene instance, or None if no scene is active.
75
+ :rtype: Scene | None
76
+ """
77
+ return self._scene_stack[-1].scene if self._scene_stack else None
61
78
 
62
79
  def change_scene(self, scene: Scene):
63
80
  """
@@ -67,10 +84,59 @@ class Game:
67
84
  :param scene: The new scene to activate.
68
85
  :type scene: Scene
69
86
  """
70
- if self._current_scene is not None:
71
- self._current_scene.on_exit()
72
- self._current_scene = scene
73
- self._current_scene.on_enter()
87
+ while self._scene_stack:
88
+ entry = self._scene_stack.pop()
89
+ entry.scene.on_exit()
90
+
91
+ self._scene_stack.append(_StackEntry(scene=scene, as_overlay=False))
92
+ scene.on_enter()
93
+
94
+ def push_scene(self, scene: "Scene", as_overlay: bool = False):
95
+ """
96
+ Push a scene on top of the current one.
97
+ If as_overlay=True, underlying scene(s) may still be drawn but never updated.
98
+ """
99
+ top = self.current_scene()
100
+ if top is not None:
101
+ top.on_pause()
102
+
103
+ self._scene_stack.append(
104
+ _StackEntry(scene=scene, as_overlay=as_overlay)
105
+ )
106
+ scene.on_enter()
107
+
108
+ def pop_scene(self) -> "Scene | None":
109
+ """Pop the top scene. If stack becomes empty, quit."""
110
+ if not self._scene_stack:
111
+ return None
112
+
113
+ popped = self._scene_stack.pop()
114
+ popped.scene.on_exit()
115
+
116
+ top = self.current_scene()
117
+ if top is None:
118
+ self.quit()
119
+ return popped.scene
120
+
121
+ top.on_resume()
122
+ return popped.scene
123
+
124
+ def _visible_stack(self) -> list["Scene"]:
125
+ """
126
+ Return the list of scenes that should be drawn (base + overlays).
127
+ We draw from the top-most non-overlay scene upward.
128
+ """
129
+ if not self._scene_stack:
130
+ return []
131
+
132
+ # find top-most base scene (as_overlay=False)
133
+ base_idx = 0
134
+ for i in range(len(self._scene_stack) - 1, -1, -1):
135
+ if not self._scene_stack[i].as_overlay:
136
+ base_idx = i
137
+ break
138
+
139
+ return [e.scene for e in self._scene_stack[base_idx:]]
74
140
 
75
141
  def quit(self):
76
142
  """Request that the main loop stops."""
@@ -103,24 +169,27 @@ class Game:
103
169
  dt = now - last_time
104
170
  last_time = now
105
171
 
106
- scene = self._current_scene
107
- if scene is None:
172
+ top = self.current_scene()
173
+ if top is None:
108
174
  break
109
175
 
110
176
  for ev in backend.poll_events():
111
- scene.handle_event(ev)
177
+ top.handle_event(ev)
112
178
 
113
- scene.update(dt)
179
+ top.update(dt)
114
180
 
115
181
  backend.begin_frame()
116
- scene.draw(backend)
182
+ for scene in self._visible_stack():
183
+ scene.draw(backend)
117
184
  backend.end_frame()
118
185
 
119
186
  if target_dt > 0 and dt < target_dt:
120
187
  sleep(target_dt - dt)
121
188
 
122
- if self._current_scene is not None:
123
- self._current_scene.on_exit()
189
+ # exit remaining scenes
190
+ while self._scene_stack:
191
+ entry = self._scene_stack.pop()
192
+ entry.scene.on_exit()
124
193
 
125
194
  @staticmethod
126
195
  def _convert_bmp_to_image(bmp_path: str, out_path: str) -> bool:
@@ -0,0 +1,69 @@
1
+ """
2
+ 2D geometry data structures.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class Position2D:
12
+ """
13
+ Simple 2D position.
14
+
15
+ :ivar x (float): X coordinate.
16
+ :ivar y (float): Y coordinate.
17
+ """
18
+
19
+ x: float
20
+ y: float
21
+
22
+
23
+ @dataclass
24
+ class Size2D:
25
+ """
26
+ Simple 2D size.
27
+
28
+ :ivar width (int): Width.
29
+ :ivar height (int): Height.
30
+ """
31
+
32
+ width: int
33
+ height: int
34
+
35
+
36
+ @dataclass
37
+ class Bounds2D:
38
+ """
39
+ Axis-aligned rectangular bounds in world space.
40
+ (left, top) .. (right, bottom)
41
+
42
+ :ivar left (float): Left boundary.
43
+ :ivar top (float): Top boundary.
44
+ :ivar right (float): Right boundary.
45
+ :ivar bottom (float): Bottom boundary.
46
+ """
47
+
48
+ left: float
49
+ top: float
50
+ right: float
51
+ bottom: float
52
+
53
+ @classmethod
54
+ def from_size(cls, size: "Size2D") -> "Bounds2D":
55
+ """
56
+ Convenience factory for screen/world bounds starting at (0, 0).
57
+
58
+ :param size: Size2D defining the bounds.
59
+ :type size: Size2D
60
+
61
+ :return: Bounds2D from (0,0) to (size.width, size.height).
62
+ :rtype: Bounds2D
63
+ """
64
+ return cls(
65
+ left=0.0,
66
+ top=0.0,
67
+ right=float(size.width),
68
+ bottom=float(size.height),
69
+ )
@@ -0,0 +1,78 @@
1
+ """
2
+ Kinematic helpers for mini_arcade_core (position + size + velocity).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ from .backend import Color
11
+ from .geometry2d import Position2D, Size2D
12
+ from .physics2d import Velocity2D
13
+
14
+
15
+ @dataclass
16
+ class KinematicData:
17
+ """
18
+ Simple data structure to hold position, size, and velocity.
19
+
20
+ :ivar position (Position2D): Top-left position of the object.
21
+ :ivar size (Size2D): Size of the object.
22
+ :ivar velocity (Velocity2D): Velocity of the object.
23
+ :ivar color (Optional[Color]): Optional color for rendering.
24
+ """
25
+
26
+ position: Position2D
27
+ size: Size2D
28
+ velocity: Velocity2D
29
+ color: Optional[Color] = None # future use
30
+
31
+ # Justification: Convenience factory with many params.
32
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
33
+ @classmethod
34
+ def rect(
35
+ cls,
36
+ x: float,
37
+ y: float,
38
+ width: int,
39
+ height: int,
40
+ vx: float = 0.0,
41
+ vy: float = 0.0,
42
+ color: Optional[Color] = None,
43
+ ) -> "KinematicData":
44
+ """
45
+ Convenience factory for rectangular kinematic data.
46
+
47
+ :param x: Top-left X position.
48
+ :type x: float
49
+
50
+ :param y: Top-left Y position.
51
+ :type y: float
52
+
53
+ :param width: Width of the object.
54
+ :type width: int
55
+
56
+ :param height: Height of the object.
57
+ :type height: int
58
+
59
+ :param vx: Velocity in the X direction.
60
+ :type vx: float
61
+
62
+ :param vy: Velocity in the Y direction.
63
+ :type vy: float
64
+
65
+ :param color: Optional color for rendering.
66
+ :type color: Optional[Color]
67
+
68
+ :return: KinematicData instance with the specified parameters.
69
+ :rtype: KinematicData
70
+ """
71
+ return cls(
72
+ position=Position2D(float(x), float(y)),
73
+ size=Size2D(int(width), int(height)),
74
+ velocity=Velocity2D(float(vx), float(vy)),
75
+ color=color,
76
+ )
77
+
78
+ # pylint: enable=too-many-arguments,too-many-positional-arguments
@@ -0,0 +1,73 @@
1
+ """
2
+ Simple 2D physics utilities.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class Velocity2D:
12
+ """
13
+ Simple 2D velocity vector.
14
+
15
+ :ivar vx (float): Velocity in the X direction.
16
+ :ivar vy (float): Velocity in the Y direction.
17
+ """
18
+
19
+ vx: float = 0.0
20
+ vy: float = 0.0
21
+
22
+ def advance(self, x: float, y: float, dt: float) -> tuple[float, float]:
23
+ """Return new (x, y) after dt seconds."""
24
+ return x + self.vx * dt, y + self.vy * dt
25
+
26
+ def stop(self):
27
+ """Stop movement in both axes."""
28
+ self.vx = 0.0
29
+ self.vy = 0.0
30
+
31
+ def stop_x(self):
32
+ """Stop horizontal movement."""
33
+ self.vx = 0.0
34
+
35
+ def stop_y(self):
36
+ """Stop vertical movement."""
37
+ self.vy = 0.0
38
+
39
+ def move_up(self, speed: float):
40
+ """
41
+ Set vertical velocity upwards (negative Y).
42
+
43
+ :param speed: Speed to set.
44
+ :type speed: float
45
+ """
46
+ self.vy = -abs(speed)
47
+
48
+ def move_down(self, speed: float):
49
+ """
50
+ Set vertical velocity downwards (positive Y).
51
+
52
+ :param speed: Speed to set.
53
+ :type speed: float
54
+ """
55
+ self.vy = abs(speed)
56
+
57
+ def move_left(self, speed: float):
58
+ """
59
+ Set horizontal velocity to the left (negative X)."
60
+
61
+ :param speed: Speed to set.
62
+ :type speed: float
63
+ """
64
+ self.vx = -abs(speed)
65
+
66
+ def move_right(self, speed: float):
67
+ """
68
+ Set horizontal velocity to the right (positive X).
69
+
70
+ :param speed: Speed to set.
71
+ :type speed: float
72
+ """
73
+ self.vx = abs(speed)
@@ -7,9 +7,10 @@ from __future__ import annotations
7
7
  from abc import ABC, abstractmethod
8
8
  from typing import Callable, List
9
9
 
10
- from mini_arcade_core.backend import Backend
11
-
10
+ from .backend import Backend, Event
11
+ from .entity import Entity
12
12
  from .game import Game
13
+ from .geometry2d import Size2D
13
14
 
14
15
  OverlayFunc = Callable[[Backend], None]
15
16
 
@@ -23,10 +24,55 @@ class Scene(ABC):
23
24
  :type game: Game
24
25
  """
25
26
  self.game = game
27
+ self.entities: List[Entity] = []
28
+ self.size: Size2D = Size2D(game.config.width, game.config.height)
26
29
  # overlays drawn on top of the scene
27
30
  self._overlays: List[OverlayFunc] = []
28
31
 
29
- def add_overlay(self, overlay: OverlayFunc) -> None:
32
+ def add_entity(self, *entities: Entity):
33
+ """
34
+ Register one or more entities in this scene.
35
+
36
+ :param entities: One or more Entity instances to add.
37
+ :type entities: Entity
38
+ """
39
+ self.entities.extend(entities)
40
+
41
+ def remove_entity(self, entity: Entity):
42
+ """
43
+ Unregister a single entity, if present.
44
+
45
+ :param entity: The Entity instance to remove.
46
+ :type entity: Entity
47
+ """
48
+ if entity in self.entities:
49
+ self.entities.remove(entity)
50
+
51
+ def clear_entities(self):
52
+ """Remove all entities from the scene."""
53
+ self.entities.clear()
54
+
55
+ def update_entities(self, dt: float):
56
+ """
57
+ Default update loop for all entities.
58
+
59
+ :param dt: Time delta in seconds.
60
+ :type dt: float
61
+ """
62
+ for ent in self.entities:
63
+ ent.update(dt)
64
+
65
+ def draw_entities(self, surface: Backend):
66
+ """
67
+ Default draw loop for all entities.
68
+
69
+ :param surface: The backend surface to draw on.
70
+ :type surface: Backend
71
+ """
72
+ for ent in self.entities:
73
+ ent.draw(surface)
74
+
75
+ def add_overlay(self, overlay: OverlayFunc):
30
76
  """
31
77
  Register an overlay (drawn every frame, after entities).
32
78
 
@@ -35,7 +81,7 @@ class Scene(ABC):
35
81
  """
36
82
  self._overlays.append(overlay)
37
83
 
38
- def remove_overlay(self, overlay: OverlayFunc) -> None:
84
+ def remove_overlay(self, overlay: OverlayFunc):
39
85
  """
40
86
  Unregister a previously added overlay.
41
87
 
@@ -45,11 +91,11 @@ class Scene(ABC):
45
91
  if overlay in self._overlays:
46
92
  self._overlays.remove(overlay)
47
93
 
48
- def clear_overlays(self) -> None:
94
+ def clear_overlays(self):
49
95
  """Clear all registered overlays."""
50
96
  self._overlays.clear()
51
97
 
52
- def draw_overlays(self, surface: Backend) -> None:
98
+ def draw_overlays(self, surface: Backend):
53
99
  """
54
100
  Call all overlays. Scenes should call this at the end of draw().
55
101
 
@@ -68,12 +114,12 @@ class Scene(ABC):
68
114
  """Called when the scene is replaced."""
69
115
 
70
116
  @abstractmethod
71
- def handle_event(self, event: object):
117
+ def handle_event(self, event: Event):
72
118
  """
73
119
  Handle input / events (e.g. pygame.Event).
74
120
 
75
121
  :param event: The event to handle.
76
- :type event: object
122
+ :type event: Event
77
123
  """
78
124
 
79
125
  @abstractmethod
@@ -86,10 +132,16 @@ class Scene(ABC):
86
132
  """
87
133
 
88
134
  @abstractmethod
89
- def draw(self, surface: object):
135
+ def draw(self, surface: Backend):
90
136
  """
91
137
  Render to the main surface.
92
138
 
93
139
  :param surface: The backend surface to draw on.
94
- :type surface: object
140
+ :type surface: Backend
95
141
  """
142
+
143
+ def on_pause(self):
144
+ """Called when the game is paused."""
145
+
146
+ def on_resume(self):
147
+ """Called when the game is resumed."""
@@ -0,0 +1,135 @@
1
+ """
2
+ Menu system for mini arcade core.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Callable, Sequence
9
+
10
+ from mini_arcade_core.backend import Backend, Color, Event, EventType
11
+
12
+ MenuAction = Callable[[], None]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class MenuItem:
17
+ """
18
+ Represents a single item in a menu.
19
+
20
+ :ivar label (str): The text label of the menu item.
21
+ :ivar on_select (MenuAction): The action to perform when the item is selected.
22
+ """
23
+
24
+ label: str
25
+ on_select: MenuAction
26
+
27
+
28
+ @dataclass
29
+ class MenuStyle:
30
+ """
31
+ Styling options for the Menu.
32
+
33
+ :ivar normal (Color): Color for unselected items.
34
+ :ivar selected (Color): Color for the selected item.
35
+ :ivar line_height (int): Vertical spacing between items.
36
+ """
37
+
38
+ normal: Color = (220, 220, 220)
39
+ selected: Color = (255, 255, 0)
40
+ line_height: int = 28
41
+
42
+
43
+ class Menu:
44
+ """A simple text-based menu system."""
45
+
46
+ def __init__(
47
+ self,
48
+ items: Sequence[MenuItem],
49
+ *,
50
+ x: int = 40,
51
+ y: int = 40,
52
+ style: MenuStyle | None = None,
53
+ ):
54
+ """
55
+ :param items: Sequence of MenuItem instances to display.
56
+ type items: Sequence[MenuItem]
57
+
58
+ :param x: X coordinate for the menu's top-left corner.
59
+ :param y: Y coordinate for the menu's top-left corner.
60
+
61
+ :param style: Optional MenuStyle for customizing appearance.
62
+ :type style: MenuStyle | None
63
+ """
64
+ self.items = list(items)
65
+ self.x = x
66
+ self.y = y
67
+ self.style = style or MenuStyle()
68
+ self.selected_index = 0
69
+
70
+ def move_up(self):
71
+ """Move the selection up by one item, wrapping around if necessary."""
72
+ if self.items:
73
+ self.selected_index = (self.selected_index - 1) % len(self.items)
74
+
75
+ def move_down(self):
76
+ """Move the selection down by one item, wrapping around if necessary."""
77
+ if self.items:
78
+ self.selected_index = (self.selected_index + 1) % len(self.items)
79
+
80
+ def select(self):
81
+ """Select the currently highlighted item, invoking its action."""
82
+ if self.items:
83
+ self.items[self.selected_index].on_select()
84
+
85
+ def handle_event(
86
+ self,
87
+ event: Event,
88
+ *,
89
+ up_key: int,
90
+ down_key: int,
91
+ select_key: int,
92
+ ):
93
+ """
94
+ Handle an input event to navigate the menu.
95
+
96
+ :param event: The input event to handle.
97
+ :type event: Event
98
+
99
+ :param up_key: Key code for moving selection up.
100
+ type up_key: int
101
+
102
+ :param down_key: Key code for moving selection down.
103
+ :type down_key: int
104
+
105
+ :param select_key: Key code for selecting the current item.
106
+ :type select_key: int
107
+ """
108
+ if event.type != EventType.KEYDOWN or event.key is None:
109
+ return
110
+ if event.key == up_key:
111
+ self.move_up()
112
+ elif event.key == down_key:
113
+ self.move_down()
114
+ elif event.key == select_key:
115
+ self.select()
116
+
117
+ def draw(self, surface: Backend):
118
+ """
119
+ Draw the menu onto the given backend surface.
120
+
121
+ :param surface: The backend surface to draw on.
122
+ :type surface: Backend
123
+ """
124
+ for i, item in enumerate(self.items):
125
+ color = (
126
+ self.style.selected
127
+ if i == self.selected_index
128
+ else self.style.normal
129
+ )
130
+ surface.draw_text(
131
+ self.x,
132
+ self.y + i * self.style.line_height,
133
+ item.label,
134
+ color=color,
135
+ )
@@ -1,51 +0,0 @@
1
- """
2
- Entity base classes for mini_arcade_core.
3
- """
4
-
5
- from __future__ import annotations
6
-
7
- from typing import Any
8
-
9
-
10
- class Entity:
11
- """Entity base class for game objects."""
12
-
13
- def update(self, dt: float):
14
- """
15
- Advance the entity state by ``dt`` seconds.
16
-
17
- :param dt: Time delta in seconds.
18
- :type dt: float
19
- """
20
-
21
- def draw(self, surface: Any):
22
- """
23
- Render the entity to the given surface.
24
-
25
- :param surface: The surface to draw on.
26
- :type surface: Any
27
- """
28
-
29
-
30
- class SpriteEntity(Entity):
31
- """Entity with position and size."""
32
-
33
- def __init__(self, x: float, y: float, width: int, height: int):
34
- """
35
- :param x: X position.
36
- :type x: float
37
-
38
- :param y: Y position.
39
- :type y: float
40
-
41
- :param width: Width of the entity.
42
- :type width: int
43
-
44
- :param height: Height of the entity.
45
- :type height: int
46
- """
47
- self.x = x
48
- self.y = y
49
- self.width = width
50
- self.height = height
51
- # TODO: velocity, color, etc.