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.
- mini_arcade_core/__init__.py +14 -42
- mini_arcade_core/backend/__init__.py +1 -2
- mini_arcade_core/backend/backend.py +182 -184
- mini_arcade_core/backend/types.py +5 -1
- mini_arcade_core/engine/commands.py +8 -8
- mini_arcade_core/engine/game.py +54 -354
- mini_arcade_core/engine/game_config.py +40 -0
- mini_arcade_core/engine/gameplay_settings.py +24 -0
- mini_arcade_core/engine/loop/config.py +20 -0
- mini_arcade_core/engine/loop/hooks.py +77 -0
- mini_arcade_core/engine/loop/runner.py +272 -0
- mini_arcade_core/engine/loop/state.py +32 -0
- mini_arcade_core/engine/managers.py +24 -0
- mini_arcade_core/engine/render/context.py +0 -2
- mini_arcade_core/engine/render/effects/base.py +2 -2
- mini_arcade_core/engine/render/effects/crt.py +4 -4
- mini_arcade_core/engine/render/effects/registry.py +1 -1
- mini_arcade_core/engine/render/effects/vignette.py +8 -8
- mini_arcade_core/engine/render/passes/begin_frame.py +1 -1
- mini_arcade_core/engine/render/passes/end_frame.py +1 -1
- mini_arcade_core/engine/render/passes/postfx.py +1 -1
- mini_arcade_core/engine/render/passes/ui.py +1 -1
- mini_arcade_core/engine/render/passes/world.py +6 -6
- mini_arcade_core/engine/render/pipeline.py +7 -6
- mini_arcade_core/engine/render/viewport.py +10 -4
- mini_arcade_core/engine/scenes/models.py +54 -0
- mini_arcade_core/engine/scenes/scene_manager.py +213 -0
- mini_arcade_core/runtime/audio/audio_adapter.py +4 -3
- mini_arcade_core/runtime/audio/audio_port.py +0 -4
- mini_arcade_core/runtime/capture/capture_adapter.py +13 -6
- mini_arcade_core/runtime/capture/capture_port.py +0 -4
- mini_arcade_core/runtime/context.py +8 -6
- mini_arcade_core/runtime/scene/scene_query_adapter.py +31 -0
- mini_arcade_core/runtime/scene/scene_query_port.py +38 -0
- mini_arcade_core/runtime/services.py +3 -2
- mini_arcade_core/runtime/window/window_adapter.py +43 -41
- mini_arcade_core/runtime/window/window_port.py +3 -17
- mini_arcade_core/scenes/debug_overlay.py +5 -4
- mini_arcade_core/scenes/registry.py +11 -1
- mini_arcade_core/scenes/sim_scene.py +14 -14
- mini_arcade_core/ui/menu.py +54 -16
- mini_arcade_core/utils/__init__.py +2 -1
- mini_arcade_core/utils/logging.py +47 -18
- mini_arcade_core/utils/profiler.py +283 -0
- {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.0.dist-info}/METADATA +1 -1
- mini_arcade_core-1.2.0.dist-info/RECORD +92 -0
- {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.0.dist-info}/WHEEL +1 -1
- mini_arcade_core/managers/inputs.py +0 -284
- mini_arcade_core/runtime/scene/scene_adapter.py +0 -125
- mini_arcade_core/runtime/scene/scene_port.py +0 -170
- mini_arcade_core/sim/protocols.py +0 -41
- mini_arcade_core/sim/runner.py +0 -222
- mini_arcade_core-1.1.1.dist-info/RECORD +0 -85
- /mini_arcade_core/{managers → engine}/cheats.py +0 -0
- /mini_arcade_core/{managers → engine/loop}/__init__.py +0 -0
- /mini_arcade_core/{sim → engine/scenes}/__init__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
@
|
|
15
|
-
class SimScene
|
|
16
|
-
"""
|
|
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
|
|
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
|
-
"""
|
mini_arcade_core/ui/menu.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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.
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
484
|
-
surface.
|
|
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.
|
|
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.
|
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
#
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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))
|