mini-arcade-core 0.9.1__tar.gz → 0.9.3__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.
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/PKG-INFO +1 -1
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/pyproject.toml +1 -1
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/__init__.py +8 -2
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/backend.py +12 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/game.py +20 -5
- mini_arcade_core-0.9.3/src/mini_arcade_core/registry.py +62 -0
- mini_arcade_core-0.9.3/src/mini_arcade_core/ui/menu.py +371 -0
- mini_arcade_core-0.9.1/src/mini_arcade_core/ui/menu.py +0 -135
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/LICENSE +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/README.md +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/boundaries2d.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/collision2d.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/entity.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/geometry2d.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/keymaps/__init__.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/keymaps/sdl.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/keys.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/kinematics2d.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/physics2d.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/scene.py +0 -0
- {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/ui/__init__.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mini-arcade-core"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.3"
|
|
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" },
|
|
@@ -22,12 +22,17 @@ from .geometry2d import Bounds2D, Position2D, Size2D
|
|
|
22
22
|
from .keys import Key, keymap
|
|
23
23
|
from .kinematics2d import KinematicData
|
|
24
24
|
from .physics2d import Velocity2D
|
|
25
|
+
from .registry import SceneRegistry
|
|
25
26
|
from .scene import Scene
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
def run_game(
|
|
31
|
+
def run_game(
|
|
32
|
+
initial_scene_cls: type[Scene],
|
|
33
|
+
config: GameConfig | None = None,
|
|
34
|
+
registry: SceneRegistry | None = None,
|
|
35
|
+
):
|
|
31
36
|
"""
|
|
32
37
|
Convenience helper to bootstrap and run a game with a single scene.
|
|
33
38
|
|
|
@@ -44,7 +49,7 @@ def run_game(initial_scene_cls: type[Scene], config: GameConfig | None = None):
|
|
|
44
49
|
raise ValueError(
|
|
45
50
|
"GameConfig.backend must be set to a Backend instance"
|
|
46
51
|
)
|
|
47
|
-
game = Game(cfg)
|
|
52
|
+
game = Game(cfg, registry=registry)
|
|
48
53
|
scene = initial_scene_cls(game)
|
|
49
54
|
game.run(scene)
|
|
50
55
|
|
|
@@ -72,6 +77,7 @@ __all__ = [
|
|
|
72
77
|
"RectKinematic",
|
|
73
78
|
"Key",
|
|
74
79
|
"keymap",
|
|
80
|
+
"SceneRegistry",
|
|
75
81
|
]
|
|
76
82
|
|
|
77
83
|
PACKAGE_NAME = "mini-arcade-core" # or whatever is in your pyproject.toml
|
|
@@ -214,6 +214,18 @@ class Backend(Protocol):
|
|
|
214
214
|
:type color: Color
|
|
215
215
|
"""
|
|
216
216
|
|
|
217
|
+
def measure_text(self, text: str) -> tuple[int, int]:
|
|
218
|
+
"""
|
|
219
|
+
Measure the width and height of the given text string in pixels.
|
|
220
|
+
|
|
221
|
+
:param text: The text string to measure.
|
|
222
|
+
:type text: str
|
|
223
|
+
|
|
224
|
+
:return: A tuple (width, height) in pixels.
|
|
225
|
+
:rtype: tuple[int, int]
|
|
226
|
+
"""
|
|
227
|
+
raise NotImplementedError
|
|
228
|
+
|
|
217
229
|
def capture_frame(self, path: str | None = None) -> bytes | None:
|
|
218
230
|
"""
|
|
219
231
|
Capture the current frame.
|
|
@@ -9,15 +9,18 @@ from dataclasses import dataclass
|
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from time import perf_counter, sleep
|
|
12
|
-
from typing import TYPE_CHECKING
|
|
12
|
+
from typing import TYPE_CHECKING, Union
|
|
13
13
|
|
|
14
14
|
from PIL import Image # type: ignore[import]
|
|
15
15
|
|
|
16
16
|
from .backend import Backend
|
|
17
|
+
from .registry import SceneRegistry
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING: # avoid runtime circular import
|
|
19
20
|
from .scene import Scene
|
|
20
21
|
|
|
22
|
+
SceneOrId = Union["Scene", str]
|
|
23
|
+
|
|
21
24
|
|
|
22
25
|
@dataclass
|
|
23
26
|
class GameConfig:
|
|
@@ -49,7 +52,9 @@ class _StackEntry:
|
|
|
49
52
|
class Game:
|
|
50
53
|
"""Core game object responsible for managing the main loop and active scene."""
|
|
51
54
|
|
|
52
|
-
def __init__(
|
|
55
|
+
def __init__(
|
|
56
|
+
self, config: GameConfig, registry: SceneRegistry | None = None
|
|
57
|
+
):
|
|
53
58
|
"""
|
|
54
59
|
:param config: Game configuration options.
|
|
55
60
|
:type config: GameConfig
|
|
@@ -65,6 +70,7 @@ class Game:
|
|
|
65
70
|
"GameConfig.backend must be set to a Backend instance"
|
|
66
71
|
)
|
|
67
72
|
self.backend: Backend = config.backend
|
|
73
|
+
self.registry = registry or SceneRegistry(_factories={})
|
|
68
74
|
self._scene_stack: list[_StackEntry] = []
|
|
69
75
|
|
|
70
76
|
def current_scene(self) -> "Scene | None":
|
|
@@ -76,7 +82,7 @@ class Game:
|
|
|
76
82
|
"""
|
|
77
83
|
return self._scene_stack[-1].scene if self._scene_stack else None
|
|
78
84
|
|
|
79
|
-
def change_scene(self, scene:
|
|
85
|
+
def change_scene(self, scene: SceneOrId):
|
|
80
86
|
"""
|
|
81
87
|
Swap the active scene. Concrete implementations should call
|
|
82
88
|
``on_exit``/``on_enter`` appropriately.
|
|
@@ -84,6 +90,8 @@ class Game:
|
|
|
84
90
|
:param scene: The new scene to activate.
|
|
85
91
|
:type scene: Scene
|
|
86
92
|
"""
|
|
93
|
+
scene = self._resolve_scene(scene)
|
|
94
|
+
|
|
87
95
|
while self._scene_stack:
|
|
88
96
|
entry = self._scene_stack.pop()
|
|
89
97
|
entry.scene.on_exit()
|
|
@@ -91,11 +99,13 @@ class Game:
|
|
|
91
99
|
self._scene_stack.append(_StackEntry(scene=scene, as_overlay=False))
|
|
92
100
|
scene.on_enter()
|
|
93
101
|
|
|
94
|
-
def push_scene(self, scene:
|
|
102
|
+
def push_scene(self, scene: SceneOrId, as_overlay: bool = False):
|
|
95
103
|
"""
|
|
96
104
|
Push a scene on top of the current one.
|
|
97
105
|
If as_overlay=True, underlying scene(s) may still be drawn but never updated.
|
|
98
106
|
"""
|
|
107
|
+
scene = self._resolve_scene(scene)
|
|
108
|
+
|
|
99
109
|
top = self.current_scene()
|
|
100
110
|
if top is not None:
|
|
101
111
|
top.on_pause()
|
|
@@ -142,7 +152,7 @@ class Game:
|
|
|
142
152
|
"""Request that the main loop stops."""
|
|
143
153
|
self._running = False
|
|
144
154
|
|
|
145
|
-
def run(self, initial_scene:
|
|
155
|
+
def run(self, initial_scene: SceneOrId):
|
|
146
156
|
"""
|
|
147
157
|
Run the main loop starting with the given scene.
|
|
148
158
|
|
|
@@ -241,3 +251,8 @@ class Game:
|
|
|
241
251
|
self._convert_bmp_to_image(bmp_path, str(out_path))
|
|
242
252
|
return str(out_path)
|
|
243
253
|
return None
|
|
254
|
+
|
|
255
|
+
def _resolve_scene(self, scene: SceneOrId) -> "Scene":
|
|
256
|
+
if isinstance(scene, str):
|
|
257
|
+
return self.registry.create(scene, self)
|
|
258
|
+
return scene
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scene registry for mini arcade core.
|
|
3
|
+
Allows registering and creating scenes by string IDs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Dict, Protocol
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from mini_arcade_core.game import Game
|
|
13
|
+
from mini_arcade_core.scene import Scene
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SceneFactory(Protocol):
|
|
17
|
+
"""
|
|
18
|
+
Protocol for scene factory callables.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __call__(self, game: "Game") -> "Scene": ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SceneRegistry:
|
|
26
|
+
"""
|
|
27
|
+
Registry for scene factories, allowing registration and creation of scenes by string IDs.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_factories: Dict[str, SceneFactory]
|
|
31
|
+
|
|
32
|
+
def register(self, scene_id: str, factory: SceneFactory) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Register a scene factory under a given scene ID.
|
|
35
|
+
|
|
36
|
+
:param scene_id: The string ID for the scene.
|
|
37
|
+
:type scene_id: str
|
|
38
|
+
|
|
39
|
+
:param factory: A callable that creates a Scene instance.
|
|
40
|
+
:type factory: SceneFactory
|
|
41
|
+
"""
|
|
42
|
+
self._factories[scene_id] = factory
|
|
43
|
+
|
|
44
|
+
def create(self, scene_id: str, game: "Game") -> "Scene":
|
|
45
|
+
"""
|
|
46
|
+
Create a scene instance using the registered factory for the given scene ID.
|
|
47
|
+
|
|
48
|
+
:param scene_id: The string ID of the scene to create.
|
|
49
|
+
:type scene_id: str
|
|
50
|
+
|
|
51
|
+
:param game: The Game instance to pass to the scene factory.
|
|
52
|
+
:type game: Game
|
|
53
|
+
|
|
54
|
+
:return: A new Scene instance.
|
|
55
|
+
:rtype: Scene
|
|
56
|
+
|
|
57
|
+
:raises KeyError: If no factory is registered for the given scene ID.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
return self._factories[scene_id](game)
|
|
61
|
+
except KeyError as e:
|
|
62
|
+
raise KeyError(f"Unknown scene_id={scene_id!r}") from e
|
|
@@ -0,0 +1,371 @@
|
|
|
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
|
+
from mini_arcade_core.geometry2d import Size2D
|
|
12
|
+
|
|
13
|
+
MenuAction = Callable[[], None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class MenuItem:
|
|
18
|
+
"""
|
|
19
|
+
Represents a single item in a menu.
|
|
20
|
+
|
|
21
|
+
:ivar label (str): The text label of the menu item.
|
|
22
|
+
:ivar on_select (MenuAction): The action to perform when the item is selected.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
label: str
|
|
26
|
+
on_select: MenuAction
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Justification: Data container for styling options needs
|
|
30
|
+
# some attributes.
|
|
31
|
+
# pylint: disable=too-many-instance-attributes
|
|
32
|
+
@dataclass
|
|
33
|
+
class MenuStyle:
|
|
34
|
+
"""
|
|
35
|
+
Styling options for the Menu.
|
|
36
|
+
|
|
37
|
+
:ivar normal (Color): Color for unselected items.
|
|
38
|
+
:ivar selected (Color): Color for the selected item.
|
|
39
|
+
:ivar line_height (int): Vertical spacing between items.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
normal: Color = (220, 220, 220)
|
|
43
|
+
selected: Color = (255, 255, 0)
|
|
44
|
+
|
|
45
|
+
# Layout
|
|
46
|
+
line_height: int = 28
|
|
47
|
+
title_color: Color = (255, 255, 255)
|
|
48
|
+
title_spacing: int = 18
|
|
49
|
+
|
|
50
|
+
# Scene background (solid)
|
|
51
|
+
background_color: Color | None = None # e.g. BACKGROUND
|
|
52
|
+
|
|
53
|
+
# Optional full-screen overlay (dim)
|
|
54
|
+
overlay_color: Color | None = None # e.g. (0,0,0,0.5) for pause
|
|
55
|
+
|
|
56
|
+
# Panel behind content (optional)
|
|
57
|
+
panel_color: Color | None = None
|
|
58
|
+
panel_padding_x: int = 24
|
|
59
|
+
panel_padding_y: int = 18
|
|
60
|
+
|
|
61
|
+
# Button rendering (optional)
|
|
62
|
+
button_enabled: bool = False
|
|
63
|
+
button_fill: Color = (30, 30, 30, 1.0)
|
|
64
|
+
button_border: Color = (120, 120, 120, 1.0)
|
|
65
|
+
button_selected_border: Color = (255, 255, 0, 1.0)
|
|
66
|
+
button_width: int | None = (
|
|
67
|
+
None # if None -> auto-fit to longest label + padding
|
|
68
|
+
)
|
|
69
|
+
button_height: int = 40
|
|
70
|
+
button_gap: int = 20
|
|
71
|
+
button_padding_x: int = 20 # used for auto-fit + text centering
|
|
72
|
+
|
|
73
|
+
# Hint footer (optional)
|
|
74
|
+
hint: str | None = None
|
|
75
|
+
hint_color: Color = (200, 200, 200)
|
|
76
|
+
hint_margin_bottom: int = 50
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# pylint: enable=too-many-instance-attributes
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Menu:
|
|
83
|
+
"""A simple text-based menu system."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
items: Sequence[MenuItem],
|
|
88
|
+
*,
|
|
89
|
+
viewport: Size2D | None = None,
|
|
90
|
+
title: str | None = None,
|
|
91
|
+
style: MenuStyle | None = None,
|
|
92
|
+
):
|
|
93
|
+
"""
|
|
94
|
+
:param items: Sequence of MenuItem instances to display.
|
|
95
|
+
type items: Sequence[MenuItem]
|
|
96
|
+
|
|
97
|
+
:param x: X coordinate for the menu's top-left corner.
|
|
98
|
+
:param y: Y coordinate for the menu's top-left corner.
|
|
99
|
+
|
|
100
|
+
:param style: Optional MenuStyle for customizing appearance.
|
|
101
|
+
:type style: MenuStyle | None
|
|
102
|
+
"""
|
|
103
|
+
self.items = list(items)
|
|
104
|
+
self.viewport = viewport
|
|
105
|
+
self.title = title
|
|
106
|
+
self.style = style or MenuStyle()
|
|
107
|
+
self.selected_index = 0
|
|
108
|
+
|
|
109
|
+
def move_up(self):
|
|
110
|
+
"""Move the selection up by one item, wrapping around if necessary."""
|
|
111
|
+
if self.items:
|
|
112
|
+
self.selected_index = (self.selected_index - 1) % len(self.items)
|
|
113
|
+
|
|
114
|
+
def move_down(self):
|
|
115
|
+
"""Move the selection down by one item, wrapping around if necessary."""
|
|
116
|
+
if self.items:
|
|
117
|
+
self.selected_index = (self.selected_index + 1) % len(self.items)
|
|
118
|
+
|
|
119
|
+
def select(self):
|
|
120
|
+
"""Select the currently highlighted item, invoking its action."""
|
|
121
|
+
if self.items:
|
|
122
|
+
self.items[self.selected_index].on_select()
|
|
123
|
+
|
|
124
|
+
def handle_event(
|
|
125
|
+
self,
|
|
126
|
+
event: Event,
|
|
127
|
+
*,
|
|
128
|
+
up_key: int,
|
|
129
|
+
down_key: int,
|
|
130
|
+
select_key: int,
|
|
131
|
+
):
|
|
132
|
+
"""
|
|
133
|
+
Handle an input event to navigate the menu.
|
|
134
|
+
|
|
135
|
+
:param event: The input event to handle.
|
|
136
|
+
:type event: Event
|
|
137
|
+
|
|
138
|
+
:param up_key: Key code for moving selection up.
|
|
139
|
+
type up_key: int
|
|
140
|
+
|
|
141
|
+
:param down_key: Key code for moving selection down.
|
|
142
|
+
:type down_key: int
|
|
143
|
+
|
|
144
|
+
:param select_key: Key code for selecting the current item.
|
|
145
|
+
:type select_key: int
|
|
146
|
+
"""
|
|
147
|
+
if event.type != EventType.KEYDOWN or event.key is None:
|
|
148
|
+
return
|
|
149
|
+
if event.key == up_key:
|
|
150
|
+
self.move_up()
|
|
151
|
+
elif event.key == down_key:
|
|
152
|
+
self.move_down()
|
|
153
|
+
elif event.key == select_key:
|
|
154
|
+
self.select()
|
|
155
|
+
|
|
156
|
+
def _measure_content(self, surface: Backend) -> tuple[int, int, int]:
|
|
157
|
+
"""
|
|
158
|
+
Returns (content_width, content_height, title_height)
|
|
159
|
+
where content is items-only (no padding).
|
|
160
|
+
"""
|
|
161
|
+
if not self.items and not self.title:
|
|
162
|
+
return 0, 0, 0
|
|
163
|
+
|
|
164
|
+
max_w = 0
|
|
165
|
+
title_h = 0
|
|
166
|
+
|
|
167
|
+
if self.title:
|
|
168
|
+
tw, th = surface.measure_text(self.title)
|
|
169
|
+
max_w = max(max_w, tw)
|
|
170
|
+
title_h = th
|
|
171
|
+
|
|
172
|
+
# Items
|
|
173
|
+
for it in self.items:
|
|
174
|
+
w, _ = surface.measure_text(it.label)
|
|
175
|
+
max_w = max(max_w, w)
|
|
176
|
+
|
|
177
|
+
items_h = len(self.items) * self.style.line_height
|
|
178
|
+
|
|
179
|
+
# Total content height includes title block if present
|
|
180
|
+
content_h = items_h
|
|
181
|
+
if self.title:
|
|
182
|
+
content_h += title_h + self.style.title_spacing
|
|
183
|
+
|
|
184
|
+
return max_w, content_h, title_h
|
|
185
|
+
|
|
186
|
+
def draw(self, surface: Backend):
|
|
187
|
+
"""
|
|
188
|
+
Draw the menu onto the given backend surface.
|
|
189
|
+
|
|
190
|
+
:param surface: The backend surface to draw on.
|
|
191
|
+
:type surface: Backend
|
|
192
|
+
"""
|
|
193
|
+
if self.viewport is None:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"Menu requires viewport=Size2D for centering/layout"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
vw, vh = self.viewport.width, self.viewport.height
|
|
199
|
+
|
|
200
|
+
# 0) Solid background (for main menus)
|
|
201
|
+
if self.style.background_color is not None:
|
|
202
|
+
surface.draw_rect(0, 0, vw, vh, color=self.style.background_color)
|
|
203
|
+
|
|
204
|
+
# 1) Overlay (for pause, etc.)
|
|
205
|
+
if self.style.overlay_color is not None:
|
|
206
|
+
surface.draw_rect(0, 0, vw, vh, color=self.style.overlay_color)
|
|
207
|
+
|
|
208
|
+
# 2) Compute menu content bounds (panel area)
|
|
209
|
+
content_w, content_h, title_h = self._measure_content(surface)
|
|
210
|
+
|
|
211
|
+
pad_x, pad_y = self.style.panel_padding_x, self.style.panel_padding_y
|
|
212
|
+
panel_w = content_w + pad_x * 2
|
|
213
|
+
panel_h = content_h + pad_y * 2
|
|
214
|
+
|
|
215
|
+
x0 = (vw - panel_w) // 2
|
|
216
|
+
y0 = (vh - panel_h) // 2
|
|
217
|
+
|
|
218
|
+
# Optional vertical offset if you add it later:
|
|
219
|
+
# y0 += self.style.center_offset_y
|
|
220
|
+
|
|
221
|
+
# 3) Panel (optional)
|
|
222
|
+
if self.style.panel_color is not None:
|
|
223
|
+
surface.draw_rect(
|
|
224
|
+
x0, y0, panel_w, panel_h, color=self.style.panel_color
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# 4) Draw title + items
|
|
228
|
+
cursor_y = y0 + pad_y
|
|
229
|
+
x_center = x0 + (panel_w // 2)
|
|
230
|
+
|
|
231
|
+
if self.title:
|
|
232
|
+
self._draw_text_center_x(
|
|
233
|
+
surface,
|
|
234
|
+
x_center,
|
|
235
|
+
cursor_y,
|
|
236
|
+
self.title,
|
|
237
|
+
color=self.style.title_color,
|
|
238
|
+
)
|
|
239
|
+
cursor_y += title_h + self.style.title_spacing
|
|
240
|
+
|
|
241
|
+
if self.style.button_enabled:
|
|
242
|
+
self._draw_buttons(surface, x_center, cursor_y)
|
|
243
|
+
else:
|
|
244
|
+
self._draw_text_items(surface, x_center, cursor_y)
|
|
245
|
+
|
|
246
|
+
# 5) Hint footer (optional)
|
|
247
|
+
if self.style.hint:
|
|
248
|
+
self._draw_text_center_x(
|
|
249
|
+
surface,
|
|
250
|
+
vw // 2,
|
|
251
|
+
vh - self.style.hint_margin_bottom,
|
|
252
|
+
self.style.hint,
|
|
253
|
+
color=self.style.hint_color,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _draw_text_items(
|
|
257
|
+
self, surface: Backend, x_center: int, cursor_y: int
|
|
258
|
+
) -> None:
|
|
259
|
+
for i, item in enumerate(self.items):
|
|
260
|
+
color = (
|
|
261
|
+
self.style.selected
|
|
262
|
+
if i == self.selected_index
|
|
263
|
+
else self.style.normal
|
|
264
|
+
)
|
|
265
|
+
self._draw_text_center_x(
|
|
266
|
+
surface,
|
|
267
|
+
x_center,
|
|
268
|
+
cursor_y + i * self.style.line_height,
|
|
269
|
+
item.label,
|
|
270
|
+
color=color,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Justification: Local variables for layout calculations
|
|
274
|
+
# pylint: disable=too-many-locals
|
|
275
|
+
def _draw_buttons(
|
|
276
|
+
self, surface: Backend, x_center: int, cursor_y: int
|
|
277
|
+
) -> None:
|
|
278
|
+
# Determine button width: fixed or auto-fit
|
|
279
|
+
if self.style.button_width is not None:
|
|
280
|
+
bw = self.style.button_width
|
|
281
|
+
else:
|
|
282
|
+
max_label_w = 0
|
|
283
|
+
for it in self.items:
|
|
284
|
+
w, _ = surface.measure_text(it.label)
|
|
285
|
+
max_label_w = max(max_label_w, w)
|
|
286
|
+
bw = max_label_w + self.style.button_padding_x * 2
|
|
287
|
+
|
|
288
|
+
bh = self.style.button_height
|
|
289
|
+
gap = self.style.button_gap
|
|
290
|
+
|
|
291
|
+
# We treat cursor_y as “top of first button”
|
|
292
|
+
for i, item in enumerate(self.items):
|
|
293
|
+
y = cursor_y + i * (bh + gap)
|
|
294
|
+
x = x_center - bw // 2
|
|
295
|
+
|
|
296
|
+
selected = i == self.selected_index
|
|
297
|
+
border = (
|
|
298
|
+
self.style.button_selected_border
|
|
299
|
+
if selected
|
|
300
|
+
else self.style.button_border
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Border rect
|
|
304
|
+
surface.draw_rect(x - 4, y - 4, bw + 8, bh + 8, color=border)
|
|
305
|
+
# Fill rect
|
|
306
|
+
surface.draw_rect(x, y, bw, bh, color=self.style.button_fill)
|
|
307
|
+
|
|
308
|
+
# Label color
|
|
309
|
+
text_color = self.style.selected if selected else self.style.normal
|
|
310
|
+
tw, th = surface.measure_text(item.label)
|
|
311
|
+
tx = x + (bw - tw) // 2
|
|
312
|
+
ty = y + (bh - th) // 2
|
|
313
|
+
surface.draw_text(tx, ty, item.label, color=text_color)
|
|
314
|
+
|
|
315
|
+
# pylint: enable=too-many-locals
|
|
316
|
+
|
|
317
|
+
def _measure_content(self, surface: Backend) -> tuple[int, int, int]:
|
|
318
|
+
# If button mode: content height differs (button_height + gaps)
|
|
319
|
+
max_w = 0
|
|
320
|
+
title_h = 0
|
|
321
|
+
|
|
322
|
+
if self.title:
|
|
323
|
+
tw, th = surface.measure_text(self.title)
|
|
324
|
+
max_w = max(max_w, tw)
|
|
325
|
+
title_h = th
|
|
326
|
+
|
|
327
|
+
if not self.items:
|
|
328
|
+
content_h = 0
|
|
329
|
+
if self.title:
|
|
330
|
+
content_h = title_h
|
|
331
|
+
return max_w, content_h, title_h
|
|
332
|
+
|
|
333
|
+
if self.style.button_enabled:
|
|
334
|
+
# width: either fixed or auto-fit
|
|
335
|
+
if self.style.button_width is not None:
|
|
336
|
+
items_w = self.style.button_width
|
|
337
|
+
else:
|
|
338
|
+
max_label_w = 0
|
|
339
|
+
for it in self.items:
|
|
340
|
+
w, _ = surface.measure_text(it.label)
|
|
341
|
+
max_label_w = max(max_label_w, w)
|
|
342
|
+
items_w = max_label_w + self.style.button_padding_x * 2
|
|
343
|
+
|
|
344
|
+
max_w = max(max_w, items_w)
|
|
345
|
+
|
|
346
|
+
bh = self.style.button_height
|
|
347
|
+
gap = self.style.button_gap
|
|
348
|
+
items_h = len(self.items) * bh + (len(self.items) - 1) * gap
|
|
349
|
+
else:
|
|
350
|
+
for it in self.items:
|
|
351
|
+
w, _ = surface.measure_text(it.label)
|
|
352
|
+
max_w = max(max_w, w)
|
|
353
|
+
items_h = len(self.items) * self.style.line_height
|
|
354
|
+
|
|
355
|
+
content_h = items_h
|
|
356
|
+
if self.title:
|
|
357
|
+
content_h += title_h + self.style.title_spacing
|
|
358
|
+
|
|
359
|
+
return max_w, content_h, title_h
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _draw_text_center_x(
|
|
363
|
+
surface: Backend,
|
|
364
|
+
x_center: int,
|
|
365
|
+
y: int,
|
|
366
|
+
text: str,
|
|
367
|
+
*,
|
|
368
|
+
color: Color,
|
|
369
|
+
) -> None:
|
|
370
|
+
w, _ = surface.measure_text(text)
|
|
371
|
+
surface.draw_text(x_center - (w // 2), y, text, color=color)
|
|
@@ -1,135 +0,0 @@
|
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|