mini-arcade-core 1.1.0__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 (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 +185 -154
  4. mini_arcade_core/backend/types.py +5 -1
  5. mini_arcade_core/engine/commands.py +26 -7
  6. mini_arcade_core/engine/game.py +79 -319
  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 +88 -0
  16. mini_arcade_core/engine/render/effects/crt.py +68 -0
  17. mini_arcade_core/engine/render/effects/registry.py +50 -0
  18. mini_arcade_core/engine/render/effects/vignette.py +79 -0
  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 +25 -4
  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/__init__.py +0 -0
  27. mini_arcade_core/engine/scenes/models.py +54 -0
  28. mini_arcade_core/engine/scenes/scene_manager.py +213 -0
  29. mini_arcade_core/runtime/audio/audio_adapter.py +4 -3
  30. mini_arcade_core/runtime/audio/audio_port.py +0 -4
  31. mini_arcade_core/runtime/capture/capture_adapter.py +13 -6
  32. mini_arcade_core/runtime/capture/capture_port.py +0 -4
  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.0.dist-info → mini_arcade_core-1.2.0.dist-info}/METADATA +1 -1
  47. mini_arcade_core-1.2.0.dist-info/RECORD +92 -0
  48. {mini_arcade_core-1.1.0.dist-info → mini_arcade_core-1.2.0.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.0.dist-info/RECORD +0 -80
  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/render/effects}/__init__.py +0 -0
  58. {mini_arcade_core-1.1.0.dist-info → mini_arcade_core-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,272 @@
1
+ """
2
+ Game core module defining the Game class and configuration.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from time import sleep
8
+ from typing import TYPE_CHECKING
9
+
10
+ from mini_arcade_core.engine.commands import CommandContext, QuitCommand
11
+ from mini_arcade_core.engine.loop.config import RunnerConfig
12
+ from mini_arcade_core.engine.loop.hooks import LoopHooks
13
+ from mini_arcade_core.engine.loop.state import FrameState
14
+ from mini_arcade_core.engine.render.context import RenderContext
15
+ from mini_arcade_core.engine.render.effects.base import EffectStack
16
+ from mini_arcade_core.engine.render.frame_packet import FramePacket
17
+ from mini_arcade_core.engine.render.packet import RenderPacket
18
+ from mini_arcade_core.engine.render.pipeline import RenderPipeline
19
+ from mini_arcade_core.runtime.input_frame import InputFrame
20
+ from mini_arcade_core.utils import FrameTimer
21
+
22
+ if TYPE_CHECKING:
23
+ from mini_arcade_core.engine.game import Game
24
+
25
+
26
+ def _neutral_input(frame_index: int, dt: float) -> InputFrame:
27
+ """Create a neutral InputFrame with no input events."""
28
+ return InputFrame(frame_index=frame_index, dt=dt)
29
+
30
+
31
+ # Justification: This class has many attributes for managing the loop.
32
+ # pylint: disable=too-many-instance-attributes
33
+ class EngineRunner:
34
+ """
35
+ Core engine runner responsible for the main loop execution.
36
+
37
+ :param game: The Game instance to run.
38
+ :type game: Game
39
+ :param pipeline: The RenderPipeline to use for rendering.
40
+ :type pipeline: RenderPipeline
41
+ :param effects_stack: The EffectStack for post-processing effects.
42
+ :type effects_stack: EffectStack
43
+ :param hooks: Optional LoopHooks for custom event handling.
44
+ :type hooks: LoopHooks | None
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ game: "Game",
50
+ *,
51
+ pipeline: RenderPipeline,
52
+ effects_stack: EffectStack,
53
+ hooks: LoopHooks | None = None,
54
+ ):
55
+ self.game = game
56
+ self.backend = game.backend
57
+ self.services = game.services
58
+ self.managers = game.managers
59
+
60
+ self.pipeline = pipeline
61
+ self.effects_stack = effects_stack
62
+ self.hooks = hooks
63
+
64
+ self._running = False
65
+ self._packet_cache: dict[int, RenderPacket] = {}
66
+
67
+ def stop(self):
68
+ """Stop the engine runner loop."""
69
+ self._running = False
70
+
71
+ def run(self, *, cfg: RunnerConfig, timer: FrameTimer | None = None):
72
+ """
73
+ Run the main loop with the given configuration.
74
+
75
+ :param cfg: RunnerConfig instance.
76
+ :type cfg: RunnerConfig
77
+ :param timer: Optional FrameTimer for profiling.
78
+ :type timer: FrameTimer | None
79
+ """
80
+ self._running = True
81
+ frame = FrameState()
82
+
83
+ target_dt = 1.0 / cfg.fps if cfg.fps > 0 else 0.0
84
+
85
+ while self._running and self.game.running:
86
+ if (
87
+ cfg.max_frames is not None
88
+ and frame.frame_index >= cfg.max_frames
89
+ ):
90
+ break
91
+
92
+ if timer:
93
+ timer.clear()
94
+ timer.mark("frame_start")
95
+
96
+ frame.step_time()
97
+
98
+ events = self._poll_events(timer)
99
+ self._handle_events(events)
100
+
101
+ input_frame = self._build_input(events, frame=frame, timer=timer)
102
+ if self._should_quit(input_frame):
103
+ break
104
+
105
+ input_entry = self._input_entry()
106
+ if input_entry is None:
107
+ break
108
+
109
+ self._tick_scenes(
110
+ input_entry, input_frame, frame=frame, timer=timer
111
+ )
112
+ ctx = self._build_command_context(timer)
113
+ self._process_cheats(input_frame, ctx, timer)
114
+ self._execute_commands(ctx, timer)
115
+
116
+ self._render_frame(frame, timer)
117
+
118
+ self._sleep(target_dt, frame.dt, timer)
119
+
120
+ if timer and timer.should_report(frame.frame_index):
121
+ timer.emit(frame.frame_index)
122
+
123
+ frame.frame_index += 1
124
+
125
+ self.managers.scenes.clean()
126
+
127
+ def _poll_events(self, timer: FrameTimer | None):
128
+ # Poll input events from the backend.
129
+ events = list(self.backend.input.poll())
130
+ if timer:
131
+ timer.mark("events_polled")
132
+ return events
133
+
134
+ def _handle_events(self, events):
135
+ # Handle polled events via hooks if available.
136
+ if self.hooks:
137
+ self.hooks.on_events(events)
138
+
139
+ def _build_input(
140
+ self, events, *, frame: FrameState, timer: FrameTimer | None
141
+ ):
142
+ # Build InputFrame from events.
143
+ input_frame = self.services.input.build(
144
+ events, frame.frame_index, frame.dt
145
+ )
146
+ if timer:
147
+ timer.mark("input_built")
148
+ if input_frame.quit:
149
+ self.managers.command_queue.push(QuitCommand())
150
+ return input_frame
151
+
152
+ def _should_quit(self, input_frame: InputFrame) -> bool:
153
+ # Determine if the game should quit based on input.
154
+ return bool(input_frame.quit)
155
+
156
+ def _input_entry(self):
157
+ # Get the current input-focused scene entry.
158
+ return self.managers.scenes.input_entry()
159
+
160
+ def _tick_scenes(
161
+ self,
162
+ input_entry,
163
+ input_frame: InputFrame,
164
+ *,
165
+ frame: FrameState,
166
+ timer: FrameTimer | None,
167
+ ):
168
+ # Tick/update all scenes according to their policies.
169
+ if timer:
170
+ timer.mark("tick_start")
171
+ for entry in self.managers.scenes.update_entries():
172
+ effective_input = (
173
+ input_frame
174
+ if entry is input_entry
175
+ else _neutral_input(frame.frame_index, frame.dt)
176
+ )
177
+ packet = entry.scene.tick(effective_input, frame.dt)
178
+ self._packet_cache[id(entry.scene)] = packet
179
+ if timer:
180
+ timer.mark("tick_end")
181
+
182
+ def _build_command_context(
183
+ self, timer: FrameTimer | None
184
+ ) -> CommandContext:
185
+ # Build the command execution context.
186
+ if timer:
187
+ timer.mark("command_ctx_start")
188
+ ctx = CommandContext(
189
+ services=self.services,
190
+ managers=self.managers,
191
+ settings=self.game.settings,
192
+ world=self.game.resolve_world(),
193
+ )
194
+ if timer:
195
+ timer.mark("command_ctx_end")
196
+ return ctx
197
+
198
+ def _process_cheats(
199
+ self,
200
+ input_frame: InputFrame,
201
+ ctx: CommandContext,
202
+ timer: FrameTimer | None,
203
+ ):
204
+ # Process cheat codes based on the input frame.
205
+ if timer:
206
+ timer.mark("cheats_start")
207
+ self.managers.cheats.process_frame(
208
+ input_frame, context=ctx, queue=self.managers.command_queue
209
+ )
210
+ if timer:
211
+ timer.mark("cheats_end")
212
+
213
+ def _execute_commands(self, ctx: CommandContext, timer: FrameTimer | None):
214
+ # Execute all queued commands.
215
+ if timer:
216
+ timer.mark("cmd_exec_start")
217
+ for cmd in self.managers.command_queue.drain():
218
+ cmd.execute(ctx)
219
+ if timer:
220
+ timer.mark("cmd_exec_end")
221
+
222
+ def _render_frame(self, frame: FrameState, timer: FrameTimer | None):
223
+ # Render the current frame using the render pipeline.
224
+ if timer:
225
+ timer.mark("render_start")
226
+
227
+ vp = self.services.window.get_viewport()
228
+
229
+ frame_packets: list[FramePacket] = []
230
+ for entry in self.managers.scenes.visible_entries():
231
+ scene = entry.scene
232
+ packet = self._packet_cache.get(id(scene))
233
+ if packet is None:
234
+ packet = scene.tick(
235
+ _neutral_input(frame.frame_index, 0.0), 0.0
236
+ )
237
+ self._packet_cache[id(scene)] = packet
238
+
239
+ frame_packets.append(
240
+ FramePacket(
241
+ scene_id=entry.scene_id,
242
+ is_overlay=entry.is_overlay,
243
+ packet=packet,
244
+ )
245
+ )
246
+
247
+ render_ctx = RenderContext(
248
+ viewport=vp,
249
+ debug_overlay=getattr(self.game.settings, "debug_overlay", False),
250
+ frame_ms=frame.dt * 1000.0,
251
+ )
252
+ render_ctx.meta["frame_index"] = frame.frame_index
253
+ render_ctx.meta["time_s"] = frame.time_s
254
+ render_ctx.meta["effects_stack"] = self.effects_stack
255
+
256
+ self.services.render.last_frame_ms = render_ctx.frame_ms
257
+ self.services.render.last_stats = render_ctx.stats
258
+
259
+ self.pipeline.render_frame(self.backend, render_ctx, frame_packets)
260
+
261
+ if timer:
262
+ timer.mark("render_done")
263
+ timer.mark("end_frame_done")
264
+
265
+ def _sleep(self, target_dt: float, dt: float, timer: FrameTimer | None):
266
+ # Sleep to maintain target frame rate if necessary.
267
+ if timer:
268
+ timer.mark("sleep_start")
269
+ if target_dt > 0 and dt < target_dt:
270
+ sleep(target_dt - dt)
271
+ if timer:
272
+ timer.mark("sleep_end")
@@ -0,0 +1,32 @@
1
+ """
2
+ Game core module defining the Game class and configuration.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from time import perf_counter
9
+
10
+
11
+ @dataclass
12
+ class FrameState:
13
+ """
14
+ State of the current frame in the main loop.
15
+
16
+ :ivar frame_index (int): The current frame index.
17
+ :ivar last_time (float): The timestamp of the last frame.
18
+ :ivar time_s (float): The total elapsed time in seconds.
19
+ :ivar dt (float): The delta time since the last frame in seconds.
20
+ """
21
+
22
+ frame_index: int = 0
23
+ last_time: float = field(default_factory=perf_counter)
24
+ time_s: float = 0.0
25
+ dt: float = 0.0
26
+
27
+ def step_time(self):
28
+ """Step the time forward by calculating dt."""
29
+ now = perf_counter()
30
+ self.dt = now - self.last_time
31
+ self.last_time = now
32
+ self.time_s += self.dt
@@ -0,0 +1,24 @@
1
+ """
2
+ Game core module defining the Game class and configuration.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+ from mini_arcade_core.engine.cheats import CheatManager
10
+ from mini_arcade_core.engine.commands import CommandQueue
11
+ from mini_arcade_core.engine.scenes.scene_manager import SceneAdapter
12
+
13
+
14
+ @dataclass
15
+ class EngineManagers:
16
+ """
17
+ Container for various game managers.
18
+
19
+ :ivar cheats (CheatManager): Manager for handling cheat codes.
20
+ """
21
+
22
+ cheats: CheatManager = field(default_factory=CheatManager)
23
+ command_queue: CommandQueue = field(default_factory=CommandQueue)
24
+ scenes: SceneAdapter | None = None
@@ -19,14 +19,12 @@ class RenderStats:
19
19
  :ivar ops (int): Number of rendering operations executed.
20
20
  :ivar draw_groups (int): Number of draw groups processed.
21
21
  :ivar renderables (int): Number of renderable objects processed.
22
- :ivar draw_groups (int): Number of draw groups processed.
23
22
  """
24
23
 
25
24
  packets: int = 0
26
25
  ops: int = 0
27
26
  draw_groups: int = 0 # approx ok
28
27
  renderables: int = 0
29
- draw_groups: int = 0
30
28
 
31
29
 
32
30
  @dataclass
@@ -0,0 +1,88 @@
1
+ """
2
+ Screen-space post effects base classes and protocols.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Protocol, runtime_checkable
9
+
10
+ from mini_arcade_core.backend import Backend
11
+ from mini_arcade_core.engine.render.context import RenderContext
12
+
13
+
14
+ @runtime_checkable
15
+ class Effect(Protocol):
16
+ """
17
+ Screen-space post effect.
18
+
19
+ IMPORTANT: Effects should draw ONLY using ctx.viewport (screen-space),
20
+ and must not assume anything about world-space transforms.
21
+ """
22
+
23
+ effect_id: str
24
+
25
+ def apply(self, backend: Backend, ctx: RenderContext):
26
+ """
27
+ Apply the effect to the current framebuffer.
28
+
29
+ :param backend: Backend to use for rendering.
30
+ :type backend: Backend
31
+
32
+ :param ctx: Render context with viewport info.
33
+ :type ctx: RenderContext
34
+ """
35
+
36
+
37
+ @dataclass
38
+ class EffectParams:
39
+ """
40
+ Shared params (Material-ish controls) for v1.
41
+
42
+ :ivar intensity (float): Effect intensity.
43
+ :ivar wobble_speed (float): Speed factor for animated distortion.
44
+ :ivar tint (tuple[int, int, int, int] | None): Optional RGBA tint.
45
+ """
46
+
47
+ intensity: float = 1.0
48
+ wobble_speed: float = 1.0
49
+ tint: tuple[int, int, int, int] | None = None
50
+
51
+
52
+ @dataclass
53
+ class EffectStack:
54
+ """
55
+ Runtime state: what effects are enabled + their params.
56
+
57
+ Zero-overhead path:
58
+ - if enabled=False OR active is empty => PostFXPass returns immediately.
59
+
60
+ :ivar enabled (bool): Master toggle for post effects.
61
+ :ivar active (list[str]): List of active effect IDs.
62
+ :ivar params (dict[str, EffectParams]): Per-effect parameters.
63
+ """
64
+
65
+ enabled: bool = False
66
+ active: list[str] = field(default_factory=list)
67
+ params: dict[str, EffectParams] = field(default_factory=dict)
68
+
69
+ def is_active(self) -> bool:
70
+ """
71
+ Check if any effects are active.
72
+
73
+ :return: True if effects are enabled and at least one is active.
74
+ :rtype: bool
75
+ """
76
+ return self.enabled and bool(self.active)
77
+
78
+ def toggle(self, effect_id: str):
79
+ """
80
+ Toggle an effect on/off.
81
+
82
+ :param effect_id: ID of the effect to toggle.
83
+ :type effect_id: str
84
+ """
85
+ if effect_id in self.active:
86
+ self.active.remove(effect_id)
87
+ else:
88
+ self.active.append(effect_id)
@@ -0,0 +1,68 @@
1
+ """
2
+ CRT screen-space post effect.
3
+ """
4
+
5
+ # Justification: PoC code for v1.
6
+ # pylint: disable=duplicate-code
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from math import sin
12
+
13
+ from mini_arcade_core.backend import Backend
14
+ from mini_arcade_core.engine.render.context import RenderContext
15
+ from mini_arcade_core.engine.render.effects.base import EffectParams
16
+
17
+
18
+ @dataclass
19
+ class CRTEffect:
20
+ """
21
+ CRT screen-space post effect.
22
+ Simulates CRT scanlines with optional wobble.
23
+ """
24
+
25
+ effect_id: str = "crt"
26
+
27
+ # Justification: This is PoC code for v1.
28
+ # pylint: disable=too-many-locals
29
+ def apply(self, backend: Backend, ctx: RenderContext):
30
+ """Apply the CRT effect to the current render context."""
31
+ vp = ctx.viewport
32
+ x0, y0 = vp.offset_x, vp.offset_y
33
+ w, h = vp.viewport_w, vp.viewport_h
34
+
35
+ stack = ctx.meta.get("effects_stack")
36
+ params: EffectParams = (
37
+ stack.params.get(self.effect_id, EffectParams())
38
+ if stack
39
+ else EffectParams()
40
+ )
41
+
42
+ intensity = max(0.0, min(1.0, params.intensity))
43
+ if intensity <= 0.0:
44
+ return
45
+
46
+ # Use a time value from ctx.meta (added in Game.run below)
47
+ t = float(ctx.meta.get("time_s", 0.0))
48
+ wobble = float(params.wobble_speed)
49
+
50
+ # Clip to viewport so it works with all viewport modes/resolutions
51
+ backend.render.set_clip_rect(x0, y0, w, h)
52
+
53
+ # Scanlines: draw every N lines with low alpha
54
+ # Note: assumes Backend supports alpha in color tuples.
55
+ spacing = 2 # tweakable
56
+ base_alpha = 120 # int(40 * intensity) # subtle
57
+ line_color = (255, 255, 255, base_alpha)
58
+
59
+ # "Wobble": tiny horizontal shift that animates over time
60
+ # Keep it tiny to avoid looking like a bug.
61
+ for y in range(y0, y0 + h, spacing):
62
+ # shift in pixels, -2..2-ish
63
+ shift = int(2.0 * intensity * sin((y * 0.05) + (t * wobble)))
64
+ backend.render.draw_line(
65
+ x0 + shift, y, x0 + w + shift, y, color=line_color
66
+ )
67
+
68
+ backend.render.clear_clip_rect()
@@ -0,0 +1,50 @@
1
+ """
2
+ Screen-space post effects registry.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+ from mini_arcade_core.engine.render.effects.base import Effect
10
+
11
+
12
+ @dataclass
13
+ class EffectRegistry:
14
+ """
15
+ Registry of available screen-space post effects.
16
+
17
+ :ivar _effects: dict[str, Effect]: Internal mapping of effect IDs to effects.
18
+ """
19
+
20
+ _effects: dict[str, Effect] = field(default_factory=dict)
21
+
22
+ def register(self, effect: Effect):
23
+ """
24
+ Register a new effect in the registry.
25
+
26
+ :param effect: Effect to register.
27
+ :type effect: Effect
28
+ """
29
+ self._effects[effect.effect_id] = effect
30
+
31
+ def get(self, effect_id: str) -> Effect | None:
32
+ """
33
+ Get an effect by its ID.
34
+
35
+ :param effect_id: ID of the effect to retrieve.
36
+ :type effect_id: str
37
+
38
+ :return: Effect instance or None if not found.
39
+ :rtype: Effect | None
40
+ """
41
+ return self._effects.get(effect_id)
42
+
43
+ def all_ids(self) -> list[str]:
44
+ """
45
+ Get a list of all registered effect IDs.
46
+
47
+ :return: List of effect IDs.
48
+ :rtype: list[str]
49
+ """
50
+ return list(self._effects.keys())
@@ -0,0 +1,79 @@
1
+ """
2
+ Vignette noise screen-space post effect.
3
+ """
4
+
5
+ # Justification: PoC code for v1.
6
+ # pylint: disable=duplicate-code
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ from dataclasses import dataclass
12
+
13
+ from mini_arcade_core.backend import Backend
14
+ from mini_arcade_core.engine.render.context import RenderContext
15
+ from mini_arcade_core.engine.render.effects.base import EffectParams
16
+
17
+
18
+ @dataclass
19
+ class VignetteNoiseEffect:
20
+ """
21
+ Vignette + noise screen-space post effect.
22
+ Simulates a vignette effect with added noise/grain.
23
+ """
24
+
25
+ effect_id: str = "vignette_noise"
26
+
27
+ # Justification: This is PoC code for v1.
28
+ # pylint: disable=too-many-locals
29
+ def apply(self, backend: Backend, ctx: RenderContext):
30
+ """Apply the Vignette + Noise effect to the current render context."""
31
+ vp = ctx.viewport
32
+ x0, y0 = vp.offset_x, vp.offset_y
33
+ w, h = vp.viewport_w, vp.viewport_h
34
+
35
+ stack = ctx.meta.get("effects_stack")
36
+ params: EffectParams = (
37
+ stack.params.get(self.effect_id, EffectParams())
38
+ if stack
39
+ else EffectParams()
40
+ )
41
+
42
+ intensity = max(0.0, min(1.0, params.intensity))
43
+ if intensity <= 0.0:
44
+ return
45
+
46
+ backend.render.set_clip_rect(x0, y0, w, h)
47
+
48
+ # Vignette approximation: draw edge rectangles with increasing alpha.
49
+ # Not a true radial gradient, but good enough for v1.
50
+ steps = 10
51
+ max_alpha = int(110 * intensity) # subtle
52
+ for i in range(steps):
53
+ # thickness grows inward
54
+ t = i + 1
55
+ alpha = int(max_alpha * (t / steps))
56
+ color = (0, 0, 0, alpha)
57
+
58
+ # top
59
+ backend.render.draw_rect(x0, y0, w, t, color=color)
60
+ # bottom
61
+ backend.render.draw_rect(x0, y0 + h - t, w, t, color=color)
62
+ # left
63
+ backend.render.draw_rect(x0, y0, t, h, color=color)
64
+ # right
65
+ backend.render.draw_rect(x0 + w - t, y0, t, h, color=color)
66
+
67
+ # Noise: sprinkle a few pixels (or tiny 1x1 rects).
68
+ # Use deterministic-ish seed per frame so it doesn't “swim” too much.
69
+ frame = int(ctx.meta.get("frame_index", 0))
70
+ random.seed(frame * 1337)
71
+
72
+ dots = int(200 * intensity) # tweak
73
+ for _ in range(dots):
74
+ px = x0 + random.randint(0, max(0, w - 1))
75
+ py = y0 + random.randint(0, max(0, h - 1))
76
+ a = random.randint(10, int(50 * intensity) + 10)
77
+ backend.render.draw_rect(px, py, 1, 1, color=(255, 255, 255, a))
78
+
79
+ backend.render.clear_clip_rect()
@@ -24,4 +24,4 @@ class BeginFramePass:
24
24
  self, backend: Backend, ctx: RenderContext, packets: list[RenderPacket]
25
25
  ):
26
26
  """Run the begin frame pass."""
27
- backend.begin_frame()
27
+ backend.render.begin_frame()
@@ -25,4 +25,4 @@ class EndFramePass:
25
25
  ):
26
26
  """Run the end frame pass."""
27
27
  # Signal the end of the frame to the backend
28
- backend.end_frame()
28
+ backend.render.end_frame()
@@ -2,11 +2,14 @@
2
2
  Post-processing effects render pass implementation.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  from dataclasses import dataclass
6
8
 
7
9
  from mini_arcade_core.backend import Backend
8
10
  from mini_arcade_core.engine.render.context import RenderContext
9
- from mini_arcade_core.engine.render.packet import RenderPacket
11
+ from mini_arcade_core.engine.render.effects.registry import EffectRegistry
12
+ from mini_arcade_core.engine.render.frame_packet import FramePacket
10
13
 
11
14
 
12
15
  @dataclass
@@ -17,12 +20,30 @@ class PostFXPass:
17
20
  """
18
21
 
19
22
  name: str = "PostFXPass"
23
+ registry: EffectRegistry | None = None
20
24
 
21
25
  # Justification: No implementation yet
22
26
  # pylint: disable=unused-argument
23
27
  def run(
24
- self, backend: Backend, ctx: RenderContext, packets: list[RenderPacket]
28
+ self, backend: Backend, ctx: RenderContext, packets: list[FramePacket]
25
29
  ):
26
30
  """Run the post-processing effects render pass."""
27
- # hook/no-op for now (CRT later)
28
- return
31
+ # Zero overhead path (no effects configured)
32
+ stack = ctx.meta.get("effects_stack")
33
+ if stack is None or not stack.is_active():
34
+ return
35
+
36
+ # Screen space: no transforms
37
+ backend.clear_viewport_transform()
38
+ backend.render.clear_clip_rect()
39
+
40
+ reg = self.registry
41
+ if reg is None:
42
+ # no registry => nothing to do
43
+ return
44
+
45
+ for effect_id in list(stack.active):
46
+ effect = reg.get(effect_id)
47
+ if effect is None:
48
+ continue
49
+ effect.apply(backend, ctx)