mini-arcade-core 1.1.1__py3-none-any.whl → 1.2.1__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 (58) 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 +53 -31
  31. mini_arcade_core/runtime/capture/capture_port.py +0 -4
  32. mini_arcade_core/runtime/capture/capture_worker.py +174 -0
  33. mini_arcade_core/runtime/context.py +8 -6
  34. mini_arcade_core/runtime/scene/scene_query_adapter.py +31 -0
  35. mini_arcade_core/runtime/scene/scene_query_port.py +38 -0
  36. mini_arcade_core/runtime/services.py +3 -2
  37. mini_arcade_core/runtime/window/window_adapter.py +43 -41
  38. mini_arcade_core/runtime/window/window_port.py +3 -17
  39. mini_arcade_core/scenes/debug_overlay.py +5 -4
  40. mini_arcade_core/scenes/registry.py +11 -1
  41. mini_arcade_core/scenes/sim_scene.py +14 -14
  42. mini_arcade_core/ui/menu.py +54 -16
  43. mini_arcade_core/utils/__init__.py +2 -1
  44. mini_arcade_core/utils/logging.py +47 -18
  45. mini_arcade_core/utils/profiler.py +283 -0
  46. {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.1.dist-info}/METADATA +1 -1
  47. mini_arcade_core-1.2.1.dist-info/RECORD +93 -0
  48. {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.1.dist-info}/WHEEL +1 -1
  49. mini_arcade_core/managers/inputs.py +0 -284
  50. mini_arcade_core/runtime/scene/scene_adapter.py +0 -125
  51. mini_arcade_core/runtime/scene/scene_port.py +0 -170
  52. mini_arcade_core/sim/protocols.py +0 -41
  53. mini_arcade_core/sim/runner.py +0 -222
  54. mini_arcade_core-1.1.1.dist-info/RECORD +0 -85
  55. /mini_arcade_core/{managers → engine}/cheats.py +0 -0
  56. /mini_arcade_core/{managers → engine/loop}/__init__.py +0 -0
  57. /mini_arcade_core/{sim → engine/scenes}/__init__.py +0 -0
  58. {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,31 @@
1
+ """
2
+ Scene query adapter implementation.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Sequence
9
+
10
+ from mini_arcade_core.engine.scenes.models import SceneEntry
11
+ from mini_arcade_core.engine.scenes.scene_manager import SceneAdapter
12
+ from mini_arcade_core.runtime.scene.scene_query_port import SceneQueryPort
13
+
14
+
15
+ @dataclass
16
+ class SceneQueryAdapter(SceneQueryPort):
17
+ """Adapter that exposes a read-only view of the SceneAdapter manager."""
18
+
19
+ _scenes: SceneAdapter
20
+
21
+ def visible_entries(self) -> Sequence[SceneEntry]:
22
+ return list(self._scenes.visible_entries())
23
+
24
+ def input_entry(self) -> SceneEntry | None:
25
+ return self._scenes.input_entry()
26
+
27
+ def stack_summary(self) -> list[str]:
28
+ out: list[str] = []
29
+ for e in self._scenes.visible_entries():
30
+ out.append(f"{e.scene_id} overlay={e.is_overlay}")
31
+ return out
@@ -0,0 +1,38 @@
1
+ """
2
+ Scene query port protocol.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Protocol, Sequence, runtime_checkable
8
+
9
+ from mini_arcade_core.engine.scenes.models import SceneEntry
10
+
11
+
12
+ @runtime_checkable
13
+ class SceneQueryPort(Protocol):
14
+ """Read-only queries over the engine scene stack."""
15
+
16
+ def visible_entries(self) -> Sequence[SceneEntry]:
17
+ """
18
+ Scenes that should be rendered (policy-aware).
19
+
20
+ :return: Sequence of SceneEntry instances that are visible.
21
+ :rtype: Sequence[SceneEntry]
22
+ """
23
+
24
+ def input_entry(self) -> SceneEntry | None:
25
+ """
26
+ The scene that currently receives input (top-most eligible).
27
+
28
+ :return: SceneEntry that receives input, or None if stack is empty.
29
+ :rtype: SceneEntry | None
30
+ """
31
+
32
+ def stack_summary(self) -> list[str]:
33
+ """
34
+ Convenience: human-readable stack lines for debug overlays.
35
+
36
+ :return: List of strings summarizing the scene stack.
37
+ :rtype: list[str]
38
+ """
@@ -11,7 +11,7 @@ from mini_arcade_core.runtime.capture.capture_port import CapturePort
11
11
  from mini_arcade_core.runtime.file.file_port import FilePort
12
12
  from mini_arcade_core.runtime.input.input_port import InputPort
13
13
  from mini_arcade_core.runtime.render.render_port import RenderServicePort
14
- from mini_arcade_core.runtime.scene.scene_port import ScenePort
14
+ from mini_arcade_core.runtime.scene.scene_query_port import SceneQueryPort
15
15
  from mini_arcade_core.runtime.window.window_port import WindowPort
16
16
 
17
17
 
@@ -26,12 +26,13 @@ class RuntimeServices:
26
26
  :ivar files (FilePort): File service port.
27
27
  :ivar capture (CapturePort): Capture service port.
28
28
  :ivar input (InputPort): Input handling service port.
29
+ :ivar render (RenderServicePort): Rendering service port.
29
30
  """
30
31
 
31
32
  window: WindowPort
32
- scenes: ScenePort
33
33
  audio: AudioPort
34
34
  files: FilePort
35
35
  capture: CapturePort
36
36
  input: InputPort
37
37
  render: RenderServicePort
38
+ scenes: SceneQueryPort
@@ -4,14 +4,14 @@ Module providing runtime adapters for window and scene management.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from venv import logger
8
-
7
+ from mini_arcade_core.backend import Backend
9
8
  from mini_arcade_core.engine.render.viewport import (
10
9
  Viewport,
11
10
  ViewportMode,
12
11
  ViewportState,
13
12
  )
14
13
  from mini_arcade_core.runtime.window.window_port import WindowPort
14
+ from mini_arcade_core.utils import logger
15
15
 
16
16
 
17
17
  class WindowAdapter(WindowPort):
@@ -19,71 +19,73 @@ class WindowAdapter(WindowPort):
19
19
  Manages multiple game windows (not implemented).
20
20
  """
21
21
 
22
- def __init__(self, backend, window_settings):
23
- self.backend = backend
24
- self.window_settings = window_settings
25
-
26
- self._initialized = False
22
+ _drawable_size: tuple[int, int]
27
23
 
28
- # Default: virtual resolution == initial window resolution.
29
- # You can override via set_virtual_resolution().
30
- self._viewport = Viewport(
31
- window_settings.width,
32
- window_settings.height,
33
- mode=ViewportMode.FIT,
34
- )
35
-
36
- # Cached current window size
37
- self.size = (window_settings.width, window_settings.height)
24
+ def __init__(self, backend: Backend):
25
+ self.backend = backend
38
26
 
39
- def set_window_size(self, width, height):
40
- width = int(width)
41
- height = int(height)
42
- self.size = (width, height)
27
+ self.backend.init()
43
28
 
44
- self.window_settings.width = width
45
- self.window_settings.height = height
29
+ w, h = self.backend.window.size()
30
+ self.size = (w, h)
31
+ self._initialized = True
46
32
 
47
- if not self._initialized:
48
- self.backend.init(self.window_settings)
49
- self._initialized = True
50
- else:
51
- self.backend.resize_window(width, height)
33
+ self._viewport = Viewport(w, h, mode=ViewportMode.FIT)
34
+ self._viewport.resize(w, h)
35
+ self._apply_viewport_transform()
52
36
 
53
- self._viewport.resize(width, height)
37
+ def _apply_viewport_transform(self):
38
+ s = self._viewport.state
39
+ # This is the missing link in the new backend design:
40
+ self.backend.set_viewport_transform(s.offset_x, s.offset_y, s.scale)
54
41
 
55
42
  def set_virtual_resolution(self, width: int, height: int):
56
43
  self._viewport.set_virtual_resolution(int(width), int(height))
57
- # re-apply using current window size
58
- w, h = self.size
44
+ w, h = self.backend.window.size()
45
+ self.size = (w, h)
59
46
  self._viewport.resize(w, h)
47
+ self._apply_viewport_transform()
60
48
 
61
49
  def set_viewport_mode(self, mode: ViewportMode):
62
50
  self._viewport.set_mode(mode)
51
+ # mode change affects scale/offset
52
+ if self._viewport.state is not None:
53
+ self._apply_viewport_transform()
63
54
 
64
55
  def get_viewport(self) -> ViewportState:
65
56
  return self._viewport.state
66
57
 
67
58
  def screen_to_virtual(self, x: float, y: float) -> tuple[float, float]:
68
- return self._viewport.screen_to_virtual(x, y)
59
+ logical_w, logical_h = self.size
60
+ drawable_w, drawable_h = self._drawable_size
61
+
62
+ sx = drawable_w / logical_w if logical_w else 1.0
63
+ sy = drawable_h / logical_h if logical_h else 1.0
64
+
65
+ return self._viewport.screen_to_virtual(x * sx, y * sy)
69
66
 
70
67
  def set_title(self, title):
71
- self.backend.set_window_title(title)
68
+ self.backend.window.set_title(title)
72
69
 
73
70
  def set_clear_color(self, r, g, b):
74
- self.backend.set_clear_color(r, g, b)
71
+ self.backend.render.set_clear_color(r, g, b)
75
72
 
76
73
  def on_window_resized(self, width: int, height: int):
77
74
  logger.debug(f"Window resized event: {width}x{height}")
78
- width = int(width)
79
- height = int(height)
80
75
 
81
- # Update cached size, but DO NOT call backend.resize_window here.
82
- self.size = (width, height)
83
- self.window_settings.width = width
84
- self.window_settings.height = height
76
+ # logical
77
+ logical_w, logical_h = int(width), int(height)
78
+
79
+ # drawable (pixel)
80
+ drawable_w, drawable_h = self.backend.window.drawable_size()
81
+
82
+ # store both if useful
83
+ self.size = (logical_w, logical_h)
84
+ self._drawable_size = (drawable_w, drawable_h)
85
85
 
86
- self._viewport.resize(width, height)
86
+ # viewport should match what renderer draws to:
87
+ self._viewport.resize(drawable_w, drawable_h)
88
+ self._apply_viewport_transform()
87
89
 
88
90
  def get_virtual_size(self) -> tuple[int, int]:
89
91
  s = self.get_viewport()
@@ -4,28 +4,14 @@ Service interfaces for runtime components.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from mini_arcade_core.backend import Backend, WindowSettings
7
+ from typing import Protocol
8
+
8
9
  from mini_arcade_core.engine.render.viewport import ViewportMode, ViewportState
9
10
 
10
11
 
11
- class WindowPort:
12
+ class WindowPort(Protocol):
12
13
  """Interface for window-related operations."""
13
14
 
14
- backend: Backend
15
- size: tuple[int, int]
16
- window_settings_cls: WindowSettings
17
-
18
- def set_window_size(self, width: int, height: int):
19
- """
20
- Set the size of the window.
21
-
22
- :param width: Width in pixels.
23
- :type width: int
24
-
25
- :param height: Height in pixels.
26
- :type height: int
27
- """
28
-
29
15
  def set_viewport_mode(self, mode: ViewportMode):
30
16
  """
31
17
  Set the viewport mode for rendering.
@@ -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()