mini-arcade-core 0.9.9__py3-none-any.whl → 1.0.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.
Files changed (74) hide show
  1. mini_arcade_core/__init__.py +44 -80
  2. mini_arcade_core/backend/__init__.py +0 -5
  3. mini_arcade_core/backend/backend.py +9 -0
  4. mini_arcade_core/backend/events.py +1 -1
  5. mini_arcade_core/{keymaps/sdl.py → backend/sdl_map.py} +1 -1
  6. mini_arcade_core/bus.py +57 -0
  7. mini_arcade_core/engine/__init__.py +0 -0
  8. mini_arcade_core/engine/commands.py +169 -0
  9. mini_arcade_core/engine/game.py +354 -0
  10. mini_arcade_core/engine/render/__init__.py +0 -0
  11. mini_arcade_core/engine/render/packet.py +56 -0
  12. mini_arcade_core/engine/render/pipeline.py +39 -0
  13. mini_arcade_core/managers/__init__.py +0 -14
  14. mini_arcade_core/managers/cheats.py +186 -0
  15. mini_arcade_core/managers/inputs.py +286 -0
  16. mini_arcade_core/runtime/__init__.py +0 -0
  17. mini_arcade_core/runtime/audio/__init__.py +0 -0
  18. mini_arcade_core/runtime/audio/audio_adapter.py +13 -0
  19. mini_arcade_core/runtime/audio/audio_port.py +17 -0
  20. mini_arcade_core/runtime/capture/__init__.py +0 -0
  21. mini_arcade_core/runtime/capture/capture_adapter.py +143 -0
  22. mini_arcade_core/runtime/capture/capture_port.py +32 -0
  23. mini_arcade_core/runtime/context.py +53 -0
  24. mini_arcade_core/runtime/file/__init__.py +0 -0
  25. mini_arcade_core/runtime/file/file_adapter.py +20 -0
  26. mini_arcade_core/runtime/file/file_port.py +31 -0
  27. mini_arcade_core/runtime/input/__init__.py +0 -0
  28. mini_arcade_core/runtime/input/input_adapter.py +49 -0
  29. mini_arcade_core/runtime/input/input_port.py +31 -0
  30. mini_arcade_core/runtime/input_frame.py +71 -0
  31. mini_arcade_core/runtime/scene/__init__.py +0 -0
  32. mini_arcade_core/runtime/scene/scene_adapter.py +97 -0
  33. mini_arcade_core/runtime/scene/scene_port.py +149 -0
  34. mini_arcade_core/runtime/services.py +35 -0
  35. mini_arcade_core/runtime/window/__init__.py +0 -0
  36. mini_arcade_core/runtime/window/window_adapter.py +26 -0
  37. mini_arcade_core/runtime/window/window_port.py +47 -0
  38. mini_arcade_core/scenes/__init__.py +0 -12
  39. mini_arcade_core/scenes/autoreg.py +1 -1
  40. mini_arcade_core/scenes/registry.py +21 -19
  41. mini_arcade_core/scenes/sim_scene.py +41 -0
  42. mini_arcade_core/scenes/systems/__init__.py +0 -0
  43. mini_arcade_core/scenes/systems/base_system.py +40 -0
  44. mini_arcade_core/scenes/systems/system_pipeline.py +57 -0
  45. mini_arcade_core/sim/__init__.py +0 -0
  46. mini_arcade_core/sim/protocols.py +41 -0
  47. mini_arcade_core/sim/runner.py +222 -0
  48. mini_arcade_core/spaces/__init__.py +0 -0
  49. mini_arcade_core/spaces/d2/__init__.py +0 -0
  50. mini_arcade_core/{two_d → spaces/d2}/collision2d.py +25 -28
  51. mini_arcade_core/{two_d → spaces/d2}/geometry2d.py +18 -0
  52. mini_arcade_core/{two_d → spaces/d2}/kinematics2d.py +5 -8
  53. mini_arcade_core/{two_d → spaces/d2}/physics2d.py +9 -0
  54. mini_arcade_core/ui/__init__.py +0 -14
  55. mini_arcade_core/ui/menu.py +415 -56
  56. mini_arcade_core/utils/__init__.py +10 -0
  57. mini_arcade_core/utils/deprecated_decorator.py +45 -0
  58. mini_arcade_core/utils/logging.py +174 -0
  59. {mini_arcade_core-0.9.9.dist-info → mini_arcade_core-1.0.0.dist-info}/METADATA +1 -1
  60. mini_arcade_core-1.0.0.dist-info/RECORD +65 -0
  61. {mini_arcade_core-0.9.9.dist-info → mini_arcade_core-1.0.0.dist-info}/WHEEL +1 -1
  62. mini_arcade_core/cheats.py +0 -235
  63. mini_arcade_core/entity.py +0 -71
  64. mini_arcade_core/game.py +0 -287
  65. mini_arcade_core/keymaps/__init__.py +0 -15
  66. mini_arcade_core/managers/base.py +0 -91
  67. mini_arcade_core/managers/entity_manager.py +0 -38
  68. mini_arcade_core/managers/overlay_manager.py +0 -33
  69. mini_arcade_core/scenes/scene.py +0 -93
  70. mini_arcade_core/two_d/__init__.py +0 -30
  71. mini_arcade_core-0.9.9.dist-info/RECORD +0 -31
  72. /mini_arcade_core/{keymaps → backend}/keys.py +0 -0
  73. /mini_arcade_core/{two_d → spaces/d2}/boundaries2d.py +0 -0
  74. {mini_arcade_core-0.9.9.dist-info → mini_arcade_core-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,12 +5,19 @@ Menu system for mini arcade core.
5
5
  from __future__ import annotations
6
6
 
7
7
  from dataclasses import dataclass
8
- from typing import Callable, Sequence
8
+ from typing import Callable, Optional, Sequence
9
9
 
10
- from mini_arcade_core.backend import Backend, Color, Event, EventType
11
- from mini_arcade_core.two_d import Size2D
12
-
13
- MenuAction = Callable[[], None]
10
+ from mini_arcade_core.backend import Backend
11
+ from mini_arcade_core.backend.events import Event, EventType
12
+ from mini_arcade_core.backend.keys import Key
13
+ from mini_arcade_core.backend.types import Color
14
+ from mini_arcade_core.engine.commands import Command, CommandQueue, QuitCommand
15
+ from mini_arcade_core.engine.render.packet import RenderPacket
16
+ from mini_arcade_core.runtime.context import RuntimeContext
17
+ from mini_arcade_core.runtime.input_frame import InputFrame
18
+ from mini_arcade_core.scenes.systems.system_pipeline import SystemPipeline
19
+ from mini_arcade_core.sim.protocols import SimScene
20
+ from mini_arcade_core.spaces.d2.geometry2d import Size2D
14
21
 
15
22
 
16
23
  @dataclass(frozen=True)
@@ -19,11 +26,25 @@ class MenuItem:
19
26
  Represents a single item in a menu.
20
27
 
21
28
  :ivar label (str): The text label of the menu item.
22
- :ivar on_select (MenuAction): The action to perform when the item is selected.
29
+ :ivar on_select (BaseCommand): The action to perform when the item is selected.
23
30
  """
24
31
 
32
+ id: str
25
33
  label: str
26
- on_select: MenuAction
34
+ command_factory: Callable[[], Command]
35
+ label_fn: Optional[Callable[[object], str]] = None
36
+
37
+ def resolved_label(self, ctx: object) -> str:
38
+ """
39
+ Get the resolved label for this menu item.
40
+
41
+ :param ctx: The current ctx instance.
42
+ :type ctx: object
43
+
44
+ :return: The resolved label string.
45
+ :rtype: str
46
+ """
47
+ return self.label_fn(ctx) if self.label_fn else self.label
27
48
 
28
49
 
29
50
  # Justification: Data container for styling options needs
@@ -104,12 +125,11 @@ class MenuStyle:
104
125
  item_font_size = 24
105
126
 
106
127
 
107
- # pylint: enable=too-many-instance-attributes
108
-
109
-
110
128
  class Menu:
111
129
  """A simple text-based menu system."""
112
130
 
131
+ # Justification: Multiple attributes for menu state
132
+ # pylint: disable=too-many-arguments
113
133
  def __init__(
114
134
  self,
115
135
  items: Sequence[MenuItem],
@@ -117,6 +137,7 @@ class Menu:
117
137
  viewport: Size2D | None = None,
118
138
  title: str | None = None,
119
139
  style: MenuStyle | None = None,
140
+ on_select: Optional[Callable[[MenuItem], None]] = None,
120
141
  ):
121
142
  """
122
143
  :param items: Sequence of MenuItem instances to display.
@@ -136,6 +157,42 @@ class Menu:
136
157
  self.title = title
137
158
  self.style = style or MenuStyle()
138
159
  self.selected_index = 0
160
+ self._on_select = on_select
161
+ self._max_content_w_seen = 0
162
+ self._max_button_w_seen = 0
163
+ self.stable_width = True
164
+
165
+ # pylint: enable=too-many-arguments
166
+
167
+ def set_items(self, items: Sequence[MenuItem]):
168
+ """Set the menu items.
169
+ :param items: Sequence of new MenuItem instances.
170
+ :type items: Sequence[MenuItem]
171
+ """
172
+ self.items = list(items)
173
+
174
+ def set_selected_index(self, index: int):
175
+ """Set the selected index of the menu.
176
+ :param index: New selected index.
177
+ :type index: int
178
+ """
179
+ if 0 <= index < len(self.items):
180
+ self.selected_index = index
181
+
182
+ def set_labels(self, labels: Sequence[str]):
183
+ """Set the labels of the menu items.
184
+ :param labels: Sequence of new labels for the menu items.
185
+ :type labels: Sequence[str]
186
+ """
187
+ for index, label in enumerate(labels):
188
+ if index < len(self.items):
189
+ item = self.items[index]
190
+ self.items[index] = MenuItem(
191
+ id=item.id,
192
+ label=label,
193
+ command_factory=item.command_factory,
194
+ label_fn=item.label_fn,
195
+ )
139
196
 
140
197
  def move_up(self):
141
198
  """Move the selection up by one item, wrapping around if necessary."""
@@ -149,8 +206,11 @@ class Menu:
149
206
 
150
207
  def select(self):
151
208
  """Select the currently highlighted item, invoking its action."""
152
- if self.items:
153
- self.items[self.selected_index].on_select()
209
+ if not self.items:
210
+ return
211
+ item = self.items[self.selected_index]
212
+ if self._on_select is not None:
213
+ self._on_select(item)
154
214
 
155
215
  def handle_event(
156
216
  self,
@@ -159,7 +219,7 @@ class Menu:
159
219
  up_key: int,
160
220
  down_key: int,
161
221
  select_key: int,
162
- ):
222
+ ) -> bool:
163
223
  """
164
224
  Handle an input event to navigate the menu.
165
225
 
@@ -176,43 +236,19 @@ class Menu:
176
236
  :type select_key: int
177
237
  """
178
238
  if event.type != EventType.KEYDOWN or event.key is None:
179
- return
239
+ return False
240
+
180
241
  if event.key == up_key:
181
242
  self.move_up()
182
- elif event.key == down_key:
243
+ return True
244
+ if event.key == down_key:
183
245
  self.move_down()
184
- elif event.key == select_key:
246
+ return True
247
+ if event.key == select_key:
185
248
  self.select()
249
+ return True
186
250
 
187
- def _measure_content(self, surface: Backend) -> tuple[int, int, int]:
188
- # Returns (content_width, content_height, title_height)
189
- # where content is items-only (no padding)
190
- if not self.items and not self.title:
191
- return 0, 0, 0
192
-
193
- max_w = 0
194
- title_h = 0
195
-
196
- if self.title:
197
- tw, th = surface.measure_text(
198
- self.title, self.style.title_font_size
199
- )
200
- max_w = max(max_w, tw)
201
- title_h = th
202
-
203
- # Items
204
- for it in self.items:
205
- w, _ = surface.measure_text(it.label, self.style.item_font_size)
206
- max_w = max(max_w, w)
207
-
208
- items_h = len(self.items) * self.style.line_height
209
-
210
- # Total content height includes title block if present
211
- content_h = items_h
212
- if self.title:
213
- content_h += title_h + self.style.title_spacing
214
-
215
- return max_w, content_h, title_h
251
+ return False
216
252
 
217
253
  def draw(self, surface: Backend):
218
254
  """
@@ -226,7 +262,7 @@ class Menu:
226
262
  "Menu requires viewport=Size2D for centering/layout"
227
263
  )
228
264
 
229
- vw, vh = self.viewport.width, self.viewport.height
265
+ vw, vh = self.viewport
230
266
 
231
267
  # 0) Solid background (for main menus)
232
268
  if self.style.background_color is not None:
@@ -315,10 +351,17 @@ class Menu:
315
351
  else:
316
352
  max_label_w = 0
317
353
  for it in self.items:
318
- w, _ = surface.measure_text(it.label)
354
+ w, _ = surface.measure_text(
355
+ it.label, font_size=self.style.item_font_size
356
+ )
319
357
  max_label_w = max(max_label_w, w)
320
358
  bw = max_label_w + self.style.button_padding_x * 2
321
359
 
360
+ # ✅ Sticky button width (never shrink)
361
+ if self.stable_width:
362
+ self._max_button_w_seen = max(self._max_button_w_seen, bw)
363
+ bw = self._max_button_w_seen
364
+
322
365
  bh = self.style.button_height
323
366
  gap = self.style.button_gap
324
367
 
@@ -341,10 +384,18 @@ class Menu:
341
384
 
342
385
  # Label color
343
386
  text_color = self.style.selected if selected else self.style.normal
344
- tw, th = surface.measure_text(item.label)
387
+ tw, th = surface.measure_text(
388
+ item.label, font_size=self.style.item_font_size
389
+ )
345
390
  tx = x + (bw - tw) // 2
346
391
  ty = y + (bh - th) // 2
347
- surface.draw_text(tx, ty, item.label, color=text_color)
392
+ surface.draw_text(
393
+ tx,
394
+ ty,
395
+ item.label,
396
+ color=text_color,
397
+ font_size=self.style.item_font_size,
398
+ )
348
399
 
349
400
  # pylint: enable=too-many-locals
350
401
 
@@ -353,25 +404,32 @@ class Menu:
353
404
  max_w = 0
354
405
  title_h = 0
355
406
 
407
+ # Title
356
408
  if self.title:
357
- tw, th = surface.measure_text(self.title)
409
+ tw, th = surface.measure_text(
410
+ self.title, font_size=self.style.title_font_size
411
+ )
358
412
  max_w = max(max_w, tw)
359
413
  title_h = th
360
414
 
361
415
  if not self.items:
362
- content_h = 0
363
- if self.title:
364
- content_h = title_h
416
+ content_h = title_h if self.title else 0
417
+ # Apply stable width even for empty items
418
+ if self.stable_width:
419
+ self._max_content_w_seen = max(self._max_content_w_seen, max_w)
420
+ max_w = self._max_content_w_seen
365
421
  return max_w, content_h, title_h
366
422
 
367
423
  if self.style.button_enabled:
368
- # width: either fixed or auto-fit
424
+ # Width: fixed or auto-fit by longest label
369
425
  if self.style.button_width is not None:
370
426
  items_w = self.style.button_width
371
427
  else:
372
428
  max_label_w = 0
373
429
  for it in self.items:
374
- w, _ = surface.measure_text(it.label)
430
+ w, _ = surface.measure_text(
431
+ it.label, font_size=self.style.item_font_size
432
+ )
375
433
  max_label_w = max(max_label_w, w)
376
434
  items_w = max_label_w + self.style.button_padding_x * 2
377
435
 
@@ -382,7 +440,9 @@ class Menu:
382
440
  items_h = len(self.items) * bh + (len(self.items) - 1) * gap
383
441
  else:
384
442
  for it in self.items:
385
- w, _ = surface.measure_text(it.label)
443
+ w, _ = surface.measure_text(
444
+ it.label, font_size=self.style.item_font_size
445
+ )
386
446
  max_w = max(max_w, w)
387
447
  items_h = len(self.items) * self.style.line_height
388
448
 
@@ -394,6 +454,11 @@ class Menu:
394
454
  + self.style.title_margin_bottom
395
455
  )
396
456
 
457
+ # ✅ Sticky width (never shrink)
458
+ if self.stable_width:
459
+ self._max_content_w_seen = max(self._max_content_w_seen, max_w)
460
+ max_w = self._max_content_w_seen
461
+
397
462
  return max_w, content_h, title_h
398
463
 
399
464
  # Justification: Many arguments for text drawing utility
@@ -414,3 +479,297 @@ class Menu:
414
479
  )
415
480
 
416
481
  # pylint: enable=too-many-arguments
482
+
483
+
484
+ # pylint: enable=too-many-instance-attributes
485
+
486
+
487
+ @dataclass
488
+ class MenuModel:
489
+ """
490
+ Data model for menu scenes.
491
+
492
+ :ivar selected (int): Currently selected menu item index.
493
+ :ivar move_cooldown (float): Cooldown time between menu moves.
494
+ :ivar _cooldown_timer (float): Internal timer for move cooldown.
495
+ """
496
+
497
+ selected: int = 0
498
+ move_cooldown: float = 0.12
499
+ _cooldown_timer: float = 0.0
500
+
501
+ def step_timer(self, dt: float):
502
+ """
503
+ Step the internal cooldown timer.
504
+
505
+ :param dt: Delta time since last update.
506
+ :type dt: float
507
+ """
508
+ if self._cooldown_timer > 0:
509
+ self._cooldown_timer = max(0.0, self._cooldown_timer - dt)
510
+
511
+ def can_move(self) -> bool:
512
+ """
513
+ Check if the menu can move selection (cooldown elapsed).
514
+
515
+ :return: True if movement is allowed, False otherwise.
516
+ :rtype: bool
517
+ """
518
+ return self._cooldown_timer <= 0.0
519
+
520
+ def consume_move(self):
521
+ """Consume a move action and reset the cooldown timer."""
522
+ self._cooldown_timer = self.move_cooldown
523
+
524
+
525
+ # TODO: Solve too-many-instance-attributes warning later
526
+ # Justification: Context for menu tick needs multiple attributes.
527
+ # pylint: disable=too-many-instance-attributes
528
+ @dataclass
529
+ class MenuTickContext:
530
+ """
531
+ Context for a single tick of the menu scene.
532
+
533
+ :ivar input_frame (InputFrame): The current input frame.
534
+ :ivar dt (float): Delta time since last tick.
535
+ :ivar menu (Menu): The Menu instance.
536
+ :ivar model (MenuModel): The MenuModel instance.
537
+ :ivar commands (CommandQueue): The command queue for pushing commands.
538
+ :ivar intent (MenuIntent | None): The current menu intent.
539
+ :ivar quit_cmd_factory (callable | None): Factory for quit command.
540
+ :ivar packet (RenderPacket | None): The resulting render packet.
541
+ """
542
+
543
+ input_frame: InputFrame
544
+ dt: float
545
+
546
+ menu: "Menu"
547
+ model: "MenuModel"
548
+ commands: CommandQueue
549
+
550
+ # computed data that systems can write into
551
+ intent: "MenuIntent | None" = None
552
+
553
+ # scene hook: a callable to produce quit cmd (per scene override)
554
+ quit_cmd_factory: callable | None = None
555
+
556
+ # final output
557
+ packet: RenderPacket | None = None
558
+
559
+
560
+ # pylint: enable=too-many-instance-attributes
561
+
562
+
563
+ @dataclass(frozen=True)
564
+ class MenuIntent:
565
+ """
566
+ Represents the user's intent in the menu for the current tick.
567
+
568
+ :ivar move_up (bool): Whether the user intends to move up.
569
+ :ivar move_down (bool): Whether the user intends to move down.
570
+ :ivar select (bool): Whether the user intends to select the current item.
571
+ :ivar quit (bool): Whether the user intends to quit the menu.
572
+ """
573
+
574
+ move_up: bool = False
575
+ move_down: bool = False
576
+ select: bool = False
577
+ quit: bool = False
578
+
579
+
580
+ @dataclass
581
+ class MenuInputSystem:
582
+ """Converts InputFrame -> MenuIntent."""
583
+
584
+ name: str = "menu_input"
585
+ order: int = 10
586
+
587
+ def step(self, ctx: MenuTickContext):
588
+ """Step the input system to extract menu intent."""
589
+ pressed = ctx.input_frame.keys_pressed
590
+ ctx.intent = MenuIntent(
591
+ move_up=Key.UP in pressed,
592
+ move_down=Key.DOWN in pressed,
593
+ select=(Key.ENTER in pressed) or (Key.SPACE in pressed),
594
+ quit=Key.ESCAPE in pressed,
595
+ )
596
+
597
+
598
+ @dataclass
599
+ class MenuNavigationSystem:
600
+ """Menu navigation system."""
601
+
602
+ name: str = "menu_nav"
603
+ order: int = 20
604
+
605
+ def step(self, ctx: MenuTickContext):
606
+ """Update menu selection based on intent."""
607
+ intent = ctx.intent
608
+ if intent is None:
609
+ return
610
+
611
+ ctx.model.step_timer(ctx.dt)
612
+
613
+ if not ctx.model.can_move():
614
+ return
615
+
616
+ if intent.move_up:
617
+ ctx.menu.move_up()
618
+ ctx.model.selected = ctx.menu.selected_index
619
+ ctx.model.consume_move()
620
+ return
621
+
622
+ if intent.move_down:
623
+ ctx.menu.move_down()
624
+ ctx.model.selected = ctx.menu.selected_index
625
+ ctx.model.consume_move()
626
+
627
+
628
+ @dataclass
629
+ class MenuActionSystem:
630
+ """Menu action execution system."""
631
+
632
+ name: str = "menu_actions"
633
+ order: int = 30
634
+
635
+ def step(self, ctx: MenuTickContext):
636
+ """Execute actions based on menu intent."""
637
+ intent = ctx.intent
638
+ if intent is None:
639
+ return
640
+
641
+ if intent.select and ctx.menu.items:
642
+ item = ctx.menu.items[ctx.menu.selected_index]
643
+ ctx.commands.push(item.command_factory())
644
+
645
+ if intent.quit and ctx.quit_cmd_factory is not None:
646
+ cmd = ctx.quit_cmd_factory()
647
+ if cmd is not None:
648
+ ctx.commands.push(cmd)
649
+
650
+
651
+ @dataclass
652
+ class MenuRenderSystem:
653
+ """Menu rendering system."""
654
+
655
+ name: str = "menu_render"
656
+ order: int = 100
657
+
658
+ def step(self, ctx: MenuTickContext):
659
+ """Set the render packet to draw the menu."""
660
+ ctx.packet = RenderPacket.from_ops([ctx.menu.draw])
661
+
662
+
663
+ class BaseMenuScene(SimScene):
664
+ """
665
+ Base scene class for menu-based scenes.
666
+
667
+ :ivar model (MenuModel): The data model for the menu scene.
668
+ """
669
+
670
+ menu: Menu
671
+ systems: SystemPipeline[MenuTickContext]
672
+
673
+ def __init__(self, ctx: RuntimeContext):
674
+ super().__init__(ctx)
675
+ self.model = MenuModel()
676
+
677
+ # ---- hooks (same spirit as before) ----
678
+
679
+ @property
680
+ def menu_title(self) -> str | None:
681
+ """
682
+ Get the title of the menu.
683
+
684
+ :return: The menu title string, or None for no title.
685
+ :rtype: str | None
686
+ """
687
+ return None
688
+
689
+ def menu_style(self) -> MenuStyle:
690
+ """
691
+ Get the style configuration for the menu.
692
+
693
+ :return: The MenuStyle instance for styling the menu.
694
+ :rtype: MenuStyle
695
+ """
696
+ return MenuStyle()
697
+
698
+ def menu_items(self) -> list[MenuItem]:
699
+ """
700
+ Get the list of menu items for the menu.
701
+
702
+ :return: List of MenuItem instances for the menu.
703
+ :rtype: list[MenuItem]
704
+ """
705
+ raise NotImplementedError
706
+
707
+ def quit_command(self):
708
+ """
709
+ Get the command to execute when quitting the menu.
710
+
711
+ :return: The command to execute on quit.
712
+ :rtype: Command
713
+ """
714
+ # default behavior: quit game
715
+ return QuitCommand()
716
+
717
+ def on_enter(self):
718
+ self.menu = Menu(
719
+ self._build_display_items(),
720
+ viewport=self.context.services.window.size,
721
+ title=self.menu_title,
722
+ style=self.menu_style(),
723
+ )
724
+ self.menu.selected_index = self.model.selected
725
+ self.systems = SystemPipeline()
726
+ self.systems.extend(
727
+ [
728
+ MenuInputSystem(),
729
+ MenuNavigationSystem(),
730
+ MenuActionSystem(),
731
+ MenuRenderSystem(),
732
+ ]
733
+ )
734
+
735
+ def tick(self, input_frame: InputFrame, dt: float) -> RenderPacket:
736
+ items = self.menu_items()
737
+ if not items:
738
+ return RenderPacket()
739
+
740
+ self.model.step_timer(dt)
741
+
742
+ self.menu.set_items(self._build_display_items())
743
+ self.menu.set_selected_index(self.model.selected)
744
+
745
+ ctx = MenuTickContext(
746
+ input_frame=input_frame,
747
+ dt=dt,
748
+ menu=self.menu,
749
+ model=self.model,
750
+ commands=self.context.command_queue,
751
+ quit_cmd_factory=self.quit_command,
752
+ )
753
+
754
+ self.systems.step(ctx)
755
+
756
+ # always return packet from pipeline
757
+ return ctx.packet or RenderPacket()
758
+
759
+ def _build_display_items(self) -> list[MenuItem]:
760
+ """
761
+ Resolve dynamic labels (label_fn(ctx)) into the label field the Menu draws.
762
+ Keeps command_factory intact.
763
+ """
764
+ src = self.menu_items()
765
+ out: list[MenuItem] = []
766
+ for it in src:
767
+ out.append(
768
+ MenuItem(
769
+ id=it.id,
770
+ label=it.resolved_label(self.context),
771
+ command_factory=it.command_factory,
772
+ label_fn=it.label_fn,
773
+ )
774
+ )
775
+ return out
@@ -0,0 +1,10 @@
1
+ """
2
+ Mini Arcade Core Utilities Package
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from .deprecated_decorator import deprecated
8
+ from .logging import logger
9
+
10
+ __all__ = ["logger", "deprecated"]
@@ -0,0 +1,45 @@
1
+ """
2
+ Deprecated utilities for the mini-arcade-core package.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import functools
8
+
9
+ from mini_arcade_core.utils.logging import logger
10
+
11
+
12
+ def deprecated(
13
+ reason: str | None = None,
14
+ version: str | None = None,
15
+ alternative: str | None = None,
16
+ ):
17
+ """
18
+ Mark a function as deprecated.
19
+
20
+ :param reason: Optional reason for deprecation
21
+ :type reason: str | None
22
+
23
+ :param version: Optional version when it will be removed
24
+ :type version: str | None
25
+
26
+ :param alternative: Optional alternative function to use
27
+ :type alternative: str | None
28
+ """
29
+
30
+ def decorator(func):
31
+ @functools.wraps(func)
32
+ def wrapper(*args, **kwargs):
33
+ message = f"The function {func.__name__} is deprecated"
34
+ if version:
35
+ message += f" and will be removed in version {version}"
36
+ if reason:
37
+ message += f". {reason}"
38
+ if alternative:
39
+ message += f" Use {alternative} instead."
40
+ logger.warning(message)
41
+ return func(*args, **kwargs)
42
+
43
+ return wrapper
44
+
45
+ return decorator