mini-arcade-core 1.1.1__py3-none-any.whl → 1.2.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 (57) hide show
  1. mini_arcade_core/__init__.py +14 -42
  2. mini_arcade_core/backend/__init__.py +1 -2
  3. mini_arcade_core/backend/backend.py +182 -184
  4. mini_arcade_core/backend/types.py +5 -1
  5. mini_arcade_core/engine/commands.py +8 -8
  6. mini_arcade_core/engine/game.py +54 -354
  7. mini_arcade_core/engine/game_config.py +40 -0
  8. mini_arcade_core/engine/gameplay_settings.py +24 -0
  9. mini_arcade_core/engine/loop/config.py +20 -0
  10. mini_arcade_core/engine/loop/hooks.py +77 -0
  11. mini_arcade_core/engine/loop/runner.py +272 -0
  12. mini_arcade_core/engine/loop/state.py +32 -0
  13. mini_arcade_core/engine/managers.py +24 -0
  14. mini_arcade_core/engine/render/context.py +0 -2
  15. mini_arcade_core/engine/render/effects/base.py +2 -2
  16. mini_arcade_core/engine/render/effects/crt.py +4 -4
  17. mini_arcade_core/engine/render/effects/registry.py +1 -1
  18. mini_arcade_core/engine/render/effects/vignette.py +8 -8
  19. mini_arcade_core/engine/render/passes/begin_frame.py +1 -1
  20. mini_arcade_core/engine/render/passes/end_frame.py +1 -1
  21. mini_arcade_core/engine/render/passes/postfx.py +1 -1
  22. mini_arcade_core/engine/render/passes/ui.py +1 -1
  23. mini_arcade_core/engine/render/passes/world.py +6 -6
  24. mini_arcade_core/engine/render/pipeline.py +7 -6
  25. mini_arcade_core/engine/render/viewport.py +10 -4
  26. mini_arcade_core/engine/scenes/models.py +54 -0
  27. mini_arcade_core/engine/scenes/scene_manager.py +213 -0
  28. mini_arcade_core/runtime/audio/audio_adapter.py +4 -3
  29. mini_arcade_core/runtime/audio/audio_port.py +0 -4
  30. mini_arcade_core/runtime/capture/capture_adapter.py +13 -6
  31. mini_arcade_core/runtime/capture/capture_port.py +0 -4
  32. mini_arcade_core/runtime/context.py +8 -6
  33. mini_arcade_core/runtime/scene/scene_query_adapter.py +31 -0
  34. mini_arcade_core/runtime/scene/scene_query_port.py +38 -0
  35. mini_arcade_core/runtime/services.py +3 -2
  36. mini_arcade_core/runtime/window/window_adapter.py +43 -41
  37. mini_arcade_core/runtime/window/window_port.py +3 -17
  38. mini_arcade_core/scenes/debug_overlay.py +5 -4
  39. mini_arcade_core/scenes/registry.py +11 -1
  40. mini_arcade_core/scenes/sim_scene.py +14 -14
  41. mini_arcade_core/ui/menu.py +54 -16
  42. mini_arcade_core/utils/__init__.py +2 -1
  43. mini_arcade_core/utils/logging.py +47 -18
  44. mini_arcade_core/utils/profiler.py +283 -0
  45. {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.0.dist-info}/METADATA +1 -1
  46. mini_arcade_core-1.2.0.dist-info/RECORD +92 -0
  47. {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.0.dist-info}/WHEEL +1 -1
  48. mini_arcade_core/managers/inputs.py +0 -284
  49. mini_arcade_core/runtime/scene/scene_adapter.py +0 -125
  50. mini_arcade_core/runtime/scene/scene_port.py +0 -170
  51. mini_arcade_core/sim/protocols.py +0 -41
  52. mini_arcade_core/sim/runner.py +0 -222
  53. mini_arcade_core-1.1.1.dist-info/RECORD +0 -85
  54. /mini_arcade_core/{managers → engine}/cheats.py +0 -0
  55. /mini_arcade_core/{managers → engine/loop}/__init__.py +0 -0
  56. /mini_arcade_core/{sim → engine/scenes}/__init__.py +0 -0
  57. {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -4,11 +4,12 @@ Debug overlay scene that displays FPS, window size, and scene stack information.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ from mini_arcade_core.backend.backend import Backend
7
8
  from mini_arcade_core.engine.render.packet import RenderPacket
8
9
  from mini_arcade_core.runtime.context import RuntimeContext
9
10
  from mini_arcade_core.runtime.input_frame import InputFrame
10
11
  from mini_arcade_core.scenes.autoreg import register_scene
11
- from mini_arcade_core.sim.protocols import SimScene
12
+ from mini_arcade_core.scenes.sim_scene import SimScene
12
13
 
13
14
 
14
15
  @register_scene("debug_overlay")
@@ -55,14 +56,14 @@ class DebugOverlayScene(SimScene):
55
56
  for e in stack:
56
57
  lines.append(f" - {e.scene_id} overlay={e.is_overlay}")
57
58
 
58
- def draw(backend):
59
+ def draw(backend: Backend):
59
60
  # translucent background panel
60
- backend.draw_rect(
61
+ backend.render.draw_rect(
61
62
  8, 8, 360, 18 * (len(lines) + 1), color=(0, 0, 0, 0.65)
62
63
  )
63
64
  y = 14
64
65
  for line in lines:
65
- backend.draw_text(
66
+ backend.text.draw(
66
67
  16, y, line, color=(255, 255, 255), font_size=14
67
68
  )
68
69
  y += 18
@@ -15,7 +15,7 @@ from mini_arcade_core.runtime.context import RuntimeContext
15
15
  from .autoreg import snapshot
16
16
 
17
17
  if TYPE_CHECKING:
18
- from mini_arcade_core.sim.protocols import SimScene
18
+ from mini_arcade_core.scenes.sim_scene import SimScene
19
19
 
20
20
 
21
21
  class SceneFactory(Protocol):
@@ -34,6 +34,16 @@ class SceneRegistry:
34
34
 
35
35
  _factories: Dict[str, SceneFactory]
36
36
 
37
+ @property
38
+ def listed_scene_ids(self) -> list[str]:
39
+ """
40
+ Get a list of all registered scene IDs.
41
+
42
+ :return: A list of registered scene IDs.
43
+ :rtype: list[str]
44
+ """
45
+ return list(self._factories.keys())
46
+
37
47
  def register(self, scene_id: str, factory: SceneFactory):
38
48
  """
39
49
  Register a scene factory under a given scene ID.
@@ -5,15 +5,23 @@ Defines the SimScene protocol for simulation scenes.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from typing import Protocol, runtime_checkable
8
+ from dataclasses import dataclass
9
9
 
10
10
  from mini_arcade_core.engine.render.packet import RenderPacket
11
+ from mini_arcade_core.runtime.context import RuntimeContext
11
12
  from mini_arcade_core.runtime.input_frame import InputFrame
12
13
 
13
14
 
14
- @runtime_checkable
15
- class SimScene(Protocol):
16
- """ "Protocol for a simulation scene in the mini arcade core."""
15
+ @dataclass
16
+ class SimScene:
17
+ """
18
+ Simulation-first scene protocol.
19
+ tick() advances the simulation and returns a RenderPacket for this scene.
20
+
21
+ :ivar context: RuntimeContext for this scene.
22
+ """
23
+
24
+ context: RuntimeContext
17
25
 
18
26
  def on_enter(self):
19
27
  """Called when the scene is entered."""
@@ -21,9 +29,9 @@ class SimScene(Protocol):
21
29
  def on_exit(self):
22
30
  """Called when the scene is exited."""
23
31
 
24
- def tick(self, input_frame: InputFrame, dt: float):
32
+ def tick(self, input_frame: InputFrame, dt: float) -> RenderPacket:
25
33
  """
26
- Advance the simulation by one tick.
34
+ Advance the simulation by dt seconds, processing input_frame.
27
35
 
28
36
  :param input_frame: Current input frame.
29
37
  :type input_frame: InputFrame
@@ -31,11 +39,3 @@ class SimScene(Protocol):
31
39
  :param dt: Delta time since last tick.
32
40
  :type dt: float
33
41
  """
34
-
35
- def build_render_packet(self) -> RenderPacket:
36
- """
37
- Build the render packet for the current scene state.
38
-
39
- :return: RenderPacket instance.
40
- :rtype: RenderPacket
41
- """
@@ -15,8 +15,8 @@ from mini_arcade_core.engine.commands import Command, CommandQueue, QuitCommand
15
15
  from mini_arcade_core.engine.render.packet import RenderPacket
16
16
  from mini_arcade_core.runtime.context import RuntimeContext
17
17
  from mini_arcade_core.runtime.input_frame import InputFrame
18
+ from mini_arcade_core.scenes.sim_scene import SimScene
18
19
  from mini_arcade_core.scenes.systems.system_pipeline import SystemPipeline
19
- from mini_arcade_core.sim.protocols import SimScene
20
20
  from mini_arcade_core.spaces.d2.geometry2d import Size2D
21
21
 
22
22
 
@@ -271,12 +271,15 @@ class Menu:
271
271
 
272
272
  # 0) Solid background (for main menus)
273
273
  if self.style.background_color is not None:
274
- surface.draw_rect(0, 0, vw, vh, color=self.style.background_color)
274
+ surface.render.draw_rect(
275
+ 0, 0, vw, vh, color=self.style.background_color
276
+ )
275
277
 
276
278
  # 1) Overlay (for pause, etc.)
277
279
  if self.style.overlay_color is not None:
278
- surface.draw_rect(0, 0, vw, vh, color=self.style.overlay_color)
279
-
280
+ surface.render.draw_rect(
281
+ 0, 0, vw, vh, color=self.style.overlay_color
282
+ )
280
283
  # 2) Compute menu content bounds (panel area)
281
284
  content_w, content_h, title_h = self._measure_content(surface)
282
285
 
@@ -292,7 +295,7 @@ class Menu:
292
295
 
293
296
  # 3) Panel (optional)
294
297
  if self.style.panel_color is not None:
295
- surface.draw_rect(
298
+ surface.render.draw_rect(
296
299
  x0, y0, panel_w, panel_h, color=self.style.panel_color
297
300
  )
298
301
 
@@ -357,7 +360,7 @@ class Menu:
357
360
  else:
358
361
  max_label_w = 0
359
362
  for it in self.items:
360
- w, _ = surface.measure_text(
363
+ w, _ = surface.text.measure(
361
364
  it.label, font_size=self.style.item_font_size
362
365
  )
363
366
  max_label_w = max(max_label_w, w)
@@ -384,18 +387,22 @@ class Menu:
384
387
  )
385
388
 
386
389
  # Border rect
387
- surface.draw_rect(x - 4, y - 4, bw + 8, bh + 8, color=border)
390
+ surface.render.draw_rect(
391
+ x - 4, y - 4, bw + 8, bh + 8, color=border
392
+ )
388
393
  # Fill rect
389
- surface.draw_rect(x, y, bw, bh, color=self.style.button_fill)
394
+ surface.render.draw_rect(
395
+ x, y, bw, bh, color=self.style.button_fill
396
+ )
390
397
 
391
398
  # Label color
392
399
  text_color = self.style.selected if selected else self.style.normal
393
- tw, th = surface.measure_text(
400
+ tw, th = surface.text.measure(
394
401
  item.label, font_size=self.style.item_font_size
395
402
  )
396
403
  tx = x + (bw - tw) // 2
397
404
  ty = y + (bh - th) // 2
398
- surface.draw_text(
405
+ surface.text.draw(
399
406
  tx,
400
407
  ty,
401
408
  item.label,
@@ -412,7 +419,7 @@ class Menu:
412
419
 
413
420
  # Title
414
421
  if self.title:
415
- tw, th = surface.measure_text(
422
+ tw, th = surface.text.measure(
416
423
  self.title, font_size=self.style.title_font_size
417
424
  )
418
425
  max_w = max(max_w, tw)
@@ -433,7 +440,7 @@ class Menu:
433
440
  else:
434
441
  max_label_w = 0
435
442
  for it in self.items:
436
- w, _ = surface.measure_text(
443
+ w, _ = surface.text.measure(
437
444
  it.label, font_size=self.style.item_font_size
438
445
  )
439
446
  max_label_w = max(max_label_w, w)
@@ -446,7 +453,7 @@ class Menu:
446
453
  items_h = len(self.items) * bh + (len(self.items) - 1) * gap
447
454
  else:
448
455
  for it in self.items:
449
- w, _ = surface.measure_text(
456
+ w, _ = surface.text.measure(
450
457
  it.label, font_size=self.style.item_font_size
451
458
  )
452
459
  max_w = max(max_w, w)
@@ -480,13 +487,33 @@ class Menu:
480
487
  color: Color,
481
488
  font_size: int | None = None,
482
489
  ):
483
- w, _ = surface.measure_text(text, font_size=font_size)
484
- surface.draw_text(
490
+ w, _ = surface.text.measure(text, font_size=font_size)
491
+ surface.text.draw(
485
492
  x_center - (w // 2), y, text, color=color, font_size=font_size
486
493
  )
487
494
 
488
495
  # pylint: enable=too-many-arguments
489
496
 
497
+ def set_viewport(self, viewport: Size2D):
498
+ """
499
+ Set the viewport size for the menu.
500
+
501
+ :param viewport: New viewport size.
502
+ :type viewport: Size2D
503
+ """
504
+ if self.viewport is None:
505
+ self.viewport = viewport
506
+ return
507
+
508
+ old_w, old_h = self.viewport
509
+ new_w, new_h = viewport
510
+ self.viewport = viewport
511
+
512
+ # If the viewport changed (especially shrinking), allow layout to shrink too.
513
+ if (new_w < old_w) or (new_h < old_h):
514
+ self._max_content_w_seen = 0
515
+ self._max_button_w_seen = 0
516
+
490
517
 
491
518
  # pylint: enable=too-many-instance-attributes
492
519
 
@@ -722,7 +749,7 @@ class BaseMenuScene(SimScene):
722
749
  def on_enter(self):
723
750
  self.menu = Menu(
724
751
  self._build_display_items(),
725
- viewport=self.context.services.window.get_virtual_size(),
752
+ viewport=self.menu_viewport(),
726
753
  title=self.menu_title,
727
754
  style=self.menu_style(),
728
755
  )
@@ -738,6 +765,7 @@ class BaseMenuScene(SimScene):
738
765
  )
739
766
 
740
767
  def tick(self, input_frame: InputFrame, dt: float) -> RenderPacket:
768
+ self.menu.set_viewport(self.menu_viewport())
741
769
  items = self.menu_items()
742
770
  if not items:
743
771
  return RenderPacket()
@@ -761,6 +789,16 @@ class BaseMenuScene(SimScene):
761
789
  # always return packet from pipeline
762
790
  return ctx.packet or RenderPacket()
763
791
 
792
+ def menu_viewport(self) -> Size2D:
793
+ """
794
+ Get the viewport size for the menu.
795
+
796
+ :return: The Size2D representing the menu viewport.
797
+ :rtype: Size2D
798
+ """
799
+ # default: virtual space (fits your UI layout)
800
+ return self.context.services.window.get_virtual_size()
801
+
764
802
  def _build_display_items(self) -> list[MenuItem]:
765
803
  """
766
804
  Resolve dynamic labels (label_fn(ctx)) into the label field the Menu draws.
@@ -6,5 +6,6 @@ from __future__ import annotations
6
6
 
7
7
  from .deprecated_decorator import deprecated
8
8
  from .logging import logger
9
+ from .profiler import FrameTimer
9
10
 
10
- __all__ = ["logger", "deprecated"]
11
+ __all__ = ["logger", "deprecated", "FrameTimer"]
@@ -82,6 +82,29 @@ LOGGER_FORMAT = (
82
82
  )
83
83
 
84
84
 
85
+ class OnlyPerf(logging.Filter):
86
+ """Performance logger filter to include only perf logs."""
87
+
88
+ def filter(self, record: logging.LogRecord) -> bool:
89
+ return record.name.startswith("mini-arcade-core.perf")
90
+
91
+
92
+ class ExcludePerf(logging.Filter):
93
+ """Performance logger filter to exclude perf logs."""
94
+
95
+ def filter(self, record: logging.LogRecord) -> bool:
96
+ return not record.name.startswith("mini-arcade-core.perf")
97
+
98
+
99
+ class PerfFormatter(logging.Formatter):
100
+ """Formatter for performance logs."""
101
+
102
+ def format(self, record: logging.LogRecord) -> str:
103
+ # No global level color wrap; let the message carry its own ANSI
104
+ ts = self.formatTime(record, "%Y-%m-%d %H:%M:%S")
105
+ return f"{ts} [{record.levelname:<8}] [mini-arcade-core]\nprofiler:\n{record.getMessage()}"
106
+
107
+
85
108
  def _enable_windows_ansi():
86
109
  """
87
110
  Best-effort enable ANSI escape sequences on Windows terminals.
@@ -141,27 +164,33 @@ def configure_logging(level: int = logging.DEBUG):
141
164
  # Avoid duplicate handlers if reloaded/imported multiple times
142
165
  # We tag our handler so we can find it reliably.
143
166
  handler_tag = "_mini_arcade_core_console_handler"
167
+ perf_tag = "_mini_arcade_core_perf_handler"
144
168
 
169
+ # --- main console handler (your current one) ---
170
+ main_console = None
145
171
  for h in list(root.handlers):
146
172
  if getattr(h, handler_tag, False):
147
- # Already configured
148
- return
149
-
150
- console = logging.StreamHandler(stream=sys.stdout)
151
- setattr(console, handler_tag, True)
152
-
153
- console.setFormatter(ConsoleColorFormatter(LOGGER_FORMAT))
154
- console.addFilter(EnsureClassName())
155
-
156
- # Important: don’t leave any basicConfig handlers around if someone called it earlier
157
- # We remove only the plain StreamHandlers that don't have our tag.
158
- for h in list(root.handlers):
159
- if isinstance(h, logging.StreamHandler) and not getattr(
160
- h, handler_tag, False
161
- ):
162
- root.removeHandler(h)
163
-
164
- root.addHandler(console)
173
+ main_console = h
174
+ break
175
+
176
+ if main_console is None:
177
+ main_console = logging.StreamHandler(stream=sys.stdout)
178
+ setattr(main_console, handler_tag, True)
179
+ main_console.setFormatter(ConsoleColorFormatter(LOGGER_FORMAT))
180
+ main_console.addFilter(EnsureClassName())
181
+ root.addHandler(main_console)
182
+
183
+ # EXCLUDE perf logs from main handler (prevents duplicates)
184
+ main_console.addFilter(ExcludePerf())
185
+
186
+ # --- perf handler (new) ---
187
+ already = any(getattr(h, perf_tag, False) for h in root.handlers)
188
+ if not already:
189
+ perf_console = logging.StreamHandler(stream=sys.stdout)
190
+ setattr(perf_console, perf_tag, True)
191
+ perf_console.setFormatter(PerfFormatter())
192
+ perf_console.addFilter(OnlyPerf())
193
+ root.addHandler(perf_console)
165
194
 
166
195
 
167
196
  configure_logging()
@@ -0,0 +1,283 @@
1
+ """
2
+ Game core module defining the Game class and configuration.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import enum
8
+ import logging
9
+ from dataclasses import dataclass, field
10
+ from time import perf_counter
11
+ from typing import Dict, Iterable, Mapping
12
+
13
+ perf_logger = logging.getLogger("mini-arcade-core.perf")
14
+
15
+
16
+ class Ansi(enum.Enum):
17
+ """
18
+ ANSI escape codes for terminal text formatting.
19
+
20
+ cvar RESET (str): Reset all formatting.
21
+ cvar BOLD (str): Bold text.
22
+ cvar DIM (str): Dim text.
23
+ cvar RED (str): Red text.
24
+ cvar GREEN (str): Green text.
25
+ cvar YELLOW (str): Yellow text.
26
+ cvar CYAN (str): Cyan text.
27
+ cvar MAGENTA (str): Magenta text.
28
+ cvar WHITE (str): White text.
29
+ """
30
+
31
+ RESET = "\033[0m"
32
+ BOLD = "\033[1m"
33
+ DIM = "\033[2m"
34
+
35
+ RED = "\033[91m"
36
+ GREEN = "\033[92m"
37
+ YELLOW = "\033[93m"
38
+ CYAN = "\033[96m"
39
+ MAGENTA = "\033[95m"
40
+ WHITE = "\033[97m"
41
+
42
+
43
+ def _c(text: str, *codes: str) -> str:
44
+ """Convenience function to wrap text with ANSI codes."""
45
+ return "".join(codes) + text + Ansi.RESET.value
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class FrameTimingReport:
50
+ """
51
+ Report of frame timing data.
52
+
53
+ :ivar frame_index (int): Index of the frame.
54
+ :ivar diffs_ms (Dict[str, float]): Dictionary of time differences in milliseconds.
55
+ :ivar total_ms (float): Total time in milliseconds.
56
+ :ivar budget_ms (float): Frame budget in milliseconds.
57
+ """
58
+
59
+ frame_index: int
60
+ diffs_ms: Dict[str, float]
61
+ total_ms: float
62
+ budget_ms: float
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class FrameTimingFormatter:
67
+ """
68
+ Formats a FrameTimingReport into a colored, multi-line table string.
69
+ Keeps FrameTimer lean and avoids pylint complexity in the timer itself.
70
+
71
+ :ivar target_fps (int): Target frames per second for budget calculation.
72
+ :ivar top_n (int): Number of top time-consuming segments to display.
73
+ :ivar min_ms (float): Minimum time in milliseconds to include in the top list.
74
+ :ivar phases (tuple[tuple[str, str], ...]): Tuples of (display name, mark key)
75
+ for table columns.
76
+ """
77
+
78
+ target_fps: int = 60
79
+ top_n: int = 6
80
+ min_ms: float = 0.05
81
+
82
+ # These are the “headline” segments you want as columns.
83
+ phases: tuple[tuple[str, str], ...] = (
84
+ ("events", "frame_start->events_polled"),
85
+ ("input", "events_polled->input_built"),
86
+ ("tick", "tick_start->tick_end"),
87
+ ("render", "render_start->render_done"),
88
+ ("sleep", "sleep_start->sleep_end"),
89
+ )
90
+
91
+ def make_report(
92
+ self, frame_index: int, diffs_ms: Dict[str, float]
93
+ ) -> FrameTimingReport:
94
+ """
95
+ Create a FrameTimingReport from the given diffs.
96
+
97
+ :param frame_index: Index of the frame.
98
+ :type frame_index: int
99
+ :param diffs_ms: Dictionary of time differences in milliseconds.
100
+ :type diffs_ms: Dict[str, float]
101
+ :return: FrameTimingReport instance.
102
+ :rtype: FrameTimingReport
103
+ """
104
+ total = sum(diffs_ms.values()) if diffs_ms else 0.0
105
+ budget = (1000.0 / self.target_fps) if self.target_fps > 0 else 0.0
106
+ return FrameTimingReport(
107
+ frame_index=frame_index,
108
+ diffs_ms=diffs_ms,
109
+ total_ms=total,
110
+ budget_ms=budget,
111
+ )
112
+
113
+ def format(self, report: FrameTimingReport) -> str:
114
+ """
115
+ Format the FrameTimingReport into a colored string.
116
+
117
+ :param report: FrameTimingReport instance.
118
+ :type report: FrameTimingReport
119
+ :return: Formatted string.
120
+ :rtype: str
121
+ """
122
+ header = self._format_header(report)
123
+ table = self._format_table(report.diffs_ms)
124
+ top = self._format_top(report.diffs_ms)
125
+ return f"{header}\n{table}\n{top}\n"
126
+
127
+ def _format_header(self, report: FrameTimingReport) -> str:
128
+ over = report.budget_ms > 0 and report.total_ms > report.budget_ms
129
+ status = (
130
+ _c("OVER", Ansi.BOLD.value, Ansi.RED.value)
131
+ if over
132
+ else _c("OK", Ansi.BOLD.value, Ansi.GREEN.value)
133
+ )
134
+
135
+ frame = _c(
136
+ f"[Frame {report.frame_index}]", Ansi.BOLD.value, Ansi.WHITE.value
137
+ )
138
+ total = _c(
139
+ f"{report.total_ms:.2f}ms", Ansi.BOLD.value, Ansi.WHITE.value
140
+ )
141
+ budget = _c(f"{report.budget_ms:.2f}ms", Ansi.DIM.value)
142
+ dim = Ansi.DIM.value
143
+
144
+ return (
145
+ f"{frame} {_c('total', dim)}={total} "
146
+ f"{_c('budget', dim)}={budget} {_c('status', dim)}={status}"
147
+ )
148
+
149
+ def _format_table(self, diffs: Mapping[str, float]) -> str:
150
+ # Header line
151
+ headers = [name for name, _ in self.phases]
152
+ line_h = self._pipe_row((_c(h, Ansi.DIM.value) for h in headers))
153
+
154
+ # Values line
155
+ values = [diffs.get(key, 0.0) for _, key in self.phases]
156
+ line_v = self._pipe_row(self._color_values(values))
157
+
158
+ return f"{line_h}\n{line_v}"
159
+
160
+ def _color_values(self, values: Iterable[float]) -> list[str]:
161
+ # Keep coloring policy centralized and easy to tweak.
162
+ # events/input: cyan, tick: yellow, render: magenta, sleep: green
163
+ colors = [
164
+ Ansi.CYAN.value,
165
+ Ansi.CYAN.value,
166
+ Ansi.YELLOW.value,
167
+ Ansi.MAGENTA.value,
168
+ Ansi.GREEN.value,
169
+ ]
170
+ out: list[str] = []
171
+ for v, col in zip(values, colors):
172
+ out.append(_c(f"{v:6.2f}", col))
173
+ return out
174
+
175
+ def _format_top(self, diffs: Mapping[str, float]) -> str:
176
+ dim = Ansi.DIM.value
177
+ items = [
178
+ (k, float(v)) for k, v in diffs.items() if float(v) >= self.min_ms
179
+ ]
180
+ items.sort(key=lambda kv: kv[1], reverse=True)
181
+ items = items[: self.top_n]
182
+
183
+ if not items:
184
+ return f"{_c('top:', dim)} (none >= {self.min_ms:.2f}ms)"
185
+
186
+ top_str = ", ".join(f"{k}:{v:.2f}ms" for k, v in items)
187
+ return f"{_c('top:', dim)} {top_str}"
188
+
189
+ @staticmethod
190
+ def _pipe_row(cells: Iterable[str]) -> str:
191
+ # Keeps lines short and avoids long f-strings.
192
+ return " | ".join(cells)
193
+
194
+
195
+ @dataclass
196
+ class FrameTimerConfig:
197
+ """
198
+ Configuration for FrameTimer.
199
+
200
+ :ivar enabled (bool): Whether timing is enabled.
201
+ :ivar report_every (int): Number of frames between reports.
202
+ """
203
+
204
+ enabled: bool = False
205
+ report_every: int = 60
206
+
207
+
208
+ @dataclass
209
+ class FrameTimer:
210
+ """
211
+ Simple frame timer for marking and reporting time intervals.
212
+
213
+ :ivar config (FrameTimerConfig): Configuration for the timer.
214
+ :ivar formatter (FrameTimingFormatter): Formatter for timing reports.
215
+ :ivar marks (Dict[str, float]): Recorded time marks.
216
+ """
217
+
218
+ config: FrameTimerConfig = field(default_factory=FrameTimerConfig)
219
+ formatter: FrameTimingFormatter = field(
220
+ default_factory=FrameTimingFormatter
221
+ )
222
+ marks: Dict[str, float] = field(default_factory=dict)
223
+
224
+ def clear(self):
225
+ """Clear all recorded marks."""
226
+ if not self.config.enabled:
227
+ return
228
+ self.marks.clear()
229
+
230
+ def mark(self, name: str):
231
+ """
232
+ Record a time mark with the given name.
233
+
234
+ :param name: Name of the mark.
235
+ :type name: str
236
+ """
237
+ if not self.config.enabled:
238
+ return
239
+ self.marks[name] = perf_counter()
240
+
241
+ def report_ms(self) -> Dict[str, float]:
242
+ """
243
+ Returns diffs between consecutive marks in insertion order.
244
+
245
+ :return: Dictionary mapping "start->end" to time difference in milliseconds.
246
+ :rtype: Dict[str, float]
247
+ """
248
+ if not self.config.enabled:
249
+ return {}
250
+ keys = list(self.marks.keys())
251
+ out: Dict[str, float] = {}
252
+ for a, b in zip(keys, keys[1:]):
253
+ out[f"{a}->{b}"] = (self.marks[b] - self.marks[a]) * 1000.0
254
+ return out
255
+
256
+ def should_report(self, frame_index: int) -> bool:
257
+ """
258
+ Determine if a report should be emitted for the given frame index.
259
+
260
+ :param frame_index: Current frame index.
261
+ :type frame_index: int
262
+ :return: True if a report should be emitted, False otherwise.
263
+ :rtype: bool
264
+ """
265
+ return (
266
+ self.config.enabled
267
+ and self.config.report_every > 0
268
+ and frame_index > 0
269
+ and (frame_index % self.config.report_every == 0)
270
+ )
271
+
272
+ def emit(self, frame_index: int):
273
+ """
274
+ Emit a timing report to the performance logger.
275
+
276
+ :param frame_index: Current frame index.
277
+ :type frame_index: int
278
+ """
279
+ if not self.config.enabled:
280
+ return
281
+ diffs = self.report_ms()
282
+ report = self.formatter.make_report(frame_index, diffs)
283
+ perf_logger.info(self.formatter.format(report))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-arcade-core
3
- Version: 1.1.1
3
+ Version: 1.2.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