mini-arcade-core 1.0.2__tar.gz → 1.1.1__tar.gz

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 (86) hide show
  1. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/PKG-INFO +1 -1
  2. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/pyproject.toml +1 -1
  3. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/backend/backend.py +33 -0
  4. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/engine/commands.py +25 -2
  5. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/engine/game.py +88 -8
  6. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/context.py +48 -0
  7. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/effects/base.py +88 -0
  8. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/effects/crt.py +68 -0
  9. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/effects/registry.py +50 -0
  10. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/effects/vignette.py +79 -0
  11. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/frame_packet.py +26 -0
  12. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/passes/base.py +37 -0
  13. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/passes/begin_frame.py +27 -0
  14. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/passes/end_frame.py +28 -0
  15. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/passes/lighting.py +28 -0
  16. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/passes/postfx.py +49 -0
  17. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/passes/ui.py +41 -0
  18. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/passes/world.py +55 -0
  19. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/pipeline.py +112 -0
  20. mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/render_service.py +22 -0
  21. mini_arcade_core-1.1.1/src/mini_arcade_core/runtime/render/render_port.py +22 -0
  22. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/scene/scene_adapter.py +1 -1
  23. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/scene/scene_port.py +1 -1
  24. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/services.py +2 -0
  25. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/scenes/debug_overlay.py +4 -1
  26. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/scenes/registry.py +1 -2
  27. mini_arcade_core-1.1.1/src/mini_arcade_core/spaces/__init__.py +0 -0
  28. mini_arcade_core-1.1.1/src/mini_arcade_core/spaces/d2/__init__.py +0 -0
  29. mini_arcade_core-1.1.1/src/mini_arcade_core/ui/__init__.py +0 -0
  30. mini_arcade_core-1.0.2/src/mini_arcade_core/engine/render/pipeline.py +0 -63
  31. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/LICENSE +0 -0
  32. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/README.md +0 -0
  33. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/__init__.py +0 -0
  34. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/backend/__init__.py +0 -0
  35. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/backend/events.py +0 -0
  36. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/backend/keys.py +0 -0
  37. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/backend/sdl_map.py +0 -0
  38. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/backend/types.py +0 -0
  39. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/bus.py +0 -0
  40. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/engine/__init__.py +0 -0
  41. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/engine/render/__init__.py +0 -0
  42. {mini_arcade_core-1.0.2/src/mini_arcade_core/managers → mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/effects}/__init__.py +0 -0
  43. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/engine/render/packet.py +0 -0
  44. {mini_arcade_core-1.0.2/src/mini_arcade_core/runtime → mini_arcade_core-1.1.1/src/mini_arcade_core/engine/render/passes}/__init__.py +0 -0
  45. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/engine/render/viewport.py +0 -0
  46. {mini_arcade_core-1.0.2/src/mini_arcade_core/runtime/audio → mini_arcade_core-1.1.1/src/mini_arcade_core/managers}/__init__.py +0 -0
  47. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/managers/cheats.py +0 -0
  48. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/managers/inputs.py +0 -0
  49. {mini_arcade_core-1.0.2/src/mini_arcade_core/runtime/capture → mini_arcade_core-1.1.1/src/mini_arcade_core/runtime}/__init__.py +0 -0
  50. {mini_arcade_core-1.0.2/src/mini_arcade_core/runtime/file → mini_arcade_core-1.1.1/src/mini_arcade_core/runtime/audio}/__init__.py +0 -0
  51. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/audio/audio_adapter.py +0 -0
  52. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/audio/audio_port.py +0 -0
  53. {mini_arcade_core-1.0.2/src/mini_arcade_core/runtime/input → mini_arcade_core-1.1.1/src/mini_arcade_core/runtime/capture}/__init__.py +0 -0
  54. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/capture/capture_adapter.py +0 -0
  55. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/capture/capture_port.py +0 -0
  56. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/context.py +0 -0
  57. {mini_arcade_core-1.0.2/src/mini_arcade_core/runtime/scene → mini_arcade_core-1.1.1/src/mini_arcade_core/runtime/file}/__init__.py +0 -0
  58. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/file/file_adapter.py +0 -0
  59. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/file/file_port.py +0 -0
  60. {mini_arcade_core-1.0.2/src/mini_arcade_core/runtime/window → mini_arcade_core-1.1.1/src/mini_arcade_core/runtime/input}/__init__.py +0 -0
  61. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/input/input_adapter.py +0 -0
  62. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/input/input_port.py +0 -0
  63. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/input_frame.py +0 -0
  64. {mini_arcade_core-1.0.2/src/mini_arcade_core/scenes → mini_arcade_core-1.1.1/src/mini_arcade_core/runtime/render}/__init__.py +0 -0
  65. {mini_arcade_core-1.0.2/src/mini_arcade_core/scenes/systems → mini_arcade_core-1.1.1/src/mini_arcade_core/runtime/scene}/__init__.py +0 -0
  66. {mini_arcade_core-1.0.2/src/mini_arcade_core/sim → mini_arcade_core-1.1.1/src/mini_arcade_core/runtime/window}/__init__.py +0 -0
  67. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/window/window_adapter.py +0 -0
  68. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/runtime/window/window_port.py +0 -0
  69. {mini_arcade_core-1.0.2/src/mini_arcade_core/spaces → mini_arcade_core-1.1.1/src/mini_arcade_core/scenes}/__init__.py +0 -0
  70. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/scenes/autoreg.py +0 -0
  71. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/scenes/sim_scene.py +0 -0
  72. {mini_arcade_core-1.0.2/src/mini_arcade_core/spaces/d2 → mini_arcade_core-1.1.1/src/mini_arcade_core/scenes/systems}/__init__.py +0 -0
  73. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/scenes/systems/base_system.py +0 -0
  74. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/scenes/systems/system_pipeline.py +0 -0
  75. {mini_arcade_core-1.0.2/src/mini_arcade_core/ui → mini_arcade_core-1.1.1/src/mini_arcade_core/sim}/__init__.py +0 -0
  76. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/sim/protocols.py +0 -0
  77. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/sim/runner.py +0 -0
  78. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/spaces/d2/boundaries2d.py +0 -0
  79. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/spaces/d2/collision2d.py +0 -0
  80. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/spaces/d2/geometry2d.py +0 -0
  81. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/spaces/d2/kinematics2d.py +0 -0
  82. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/spaces/d2/physics2d.py +0 -0
  83. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/ui/menu.py +0 -0
  84. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/utils/__init__.py +0 -0
  85. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/utils/deprecated_decorator.py +0 -0
  86. {mini_arcade_core-1.0.2 → mini_arcade_core-1.1.1}/src/mini_arcade_core/utils/logging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-arcade-core
3
- Version: 1.0.2
3
+ Version: 1.1.1
4
4
  Summary: Tiny scene-based game loop core for small arcade games.
5
5
  License: Copyright (c) 2025 Santiago Rincón
6
6
 
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "mini-arcade-core"
7
- version = "1.0.2"
7
+ version = "1.1.1"
8
8
  description = "Tiny scene-based game loop core for small arcade games."
9
9
  authors = [
10
10
  { name = "Santiago Rincon", email = "rincores@gmail.com" },
@@ -289,3 +289,36 @@ class Backend(Protocol):
289
289
  def clear_clip_rect(self):
290
290
  """Clear any clipping rectangle."""
291
291
  raise NotImplementedError
292
+
293
+ # Justification: Simple drawing API for now
294
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
295
+ def draw_line(
296
+ self,
297
+ x1: int,
298
+ y1: int,
299
+ x2: int,
300
+ y2: int,
301
+ color: tuple[int, ...] = (255, 255, 255),
302
+ ):
303
+ """
304
+ Draw a line between two points in some default color.
305
+
306
+ :param x1: X position of the start point.
307
+ :type x1: int
308
+
309
+ :param y1: Y position of the start point.
310
+ :type y1: int
311
+
312
+ :param x2: X position of the end point.
313
+ :type x2: int
314
+
315
+ :param y2: Y position of the end point.
316
+ :type y2: int
317
+
318
+ :param color: RGB color tuple.
319
+ :type color: tuple[int, ...]
320
+ """
321
+ raise NotImplementedError
322
+
323
+
324
+ # pylint: enable=too-many-arguments,too-many-positional-arguments
@@ -173,11 +173,15 @@ class ChangeSceneCommand(Command):
173
173
 
174
174
  @dataclass(frozen=True)
175
175
  class ToggleDebugOverlayCommand(Command):
176
- """Toggle the debug overlay scene."""
176
+ """
177
+ Toggle the debug overlay scene.
178
+
179
+ :cvar DEBUG_OVERLAY_ID: str: Identifier for the debug overlay scene.
180
+ """
177
181
 
178
182
  DEBUG_OVERLAY_ID = "debug_overlay"
179
183
 
180
- def execute(self, context: CommandContext) -> None:
184
+ def execute(self, context: CommandContext):
181
185
  scenes = context.services.scenes
182
186
  if scenes.has_scene(self.DEBUG_OVERLAY_ID):
183
187
  scenes.remove_scene(self.DEBUG_OVERLAY_ID)
@@ -193,3 +197,22 @@ class ToggleDebugOverlayCommand(Command):
193
197
  receives_input=False,
194
198
  ),
195
199
  )
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class ToggleEffectCommand(Command):
204
+ """
205
+ Toggle a post-processing effect on or off.
206
+
207
+ :ivar effect_id (str): Identifier of the effect to toggle.
208
+ """
209
+
210
+ effect_id: str
211
+
212
+ def execute(self, context: CommandContext) -> None:
213
+ # effects live in context.meta OR in a dedicated service/settings.
214
+ # v1 simplest: stash stack into context.settings or context.services.render
215
+ stack = getattr(context.settings, "effects_stack", None)
216
+ if stack is None:
217
+ return
218
+ stack.toggle(self.effect_id)
@@ -16,9 +16,20 @@ from mini_arcade_core.engine.commands import (
16
16
  CommandQueue,
17
17
  QuitCommand,
18
18
  ToggleDebugOverlayCommand,
19
+ ToggleEffectCommand,
19
20
  )
21
+ from mini_arcade_core.engine.render.context import RenderContext
22
+ from mini_arcade_core.engine.render.effects.base import (
23
+ EffectParams,
24
+ EffectStack,
25
+ )
26
+ from mini_arcade_core.engine.render.effects.crt import CRTEffect
27
+ from mini_arcade_core.engine.render.effects.registry import EffectRegistry
28
+ from mini_arcade_core.engine.render.effects.vignette import VignetteNoiseEffect
29
+ from mini_arcade_core.engine.render.frame_packet import FramePacket
20
30
  from mini_arcade_core.engine.render.packet import RenderPacket
21
31
  from mini_arcade_core.engine.render.pipeline import RenderPipeline
32
+ from mini_arcade_core.engine.render.render_service import RenderService
22
33
  from mini_arcade_core.managers.cheats import CheatManager
23
34
  from mini_arcade_core.runtime.audio.audio_adapter import SDLAudioAdapter
24
35
  from mini_arcade_core.runtime.capture.capture_adapter import CaptureAdapter
@@ -49,6 +60,19 @@ class WindowConfig:
49
60
  title: str
50
61
 
51
62
 
63
+ @dataclass
64
+ class PostFXConfig:
65
+ """
66
+ Configuration for post-processing effects.
67
+
68
+ :ivar enabled (bool): Whether post effects are enabled by default.
69
+ :ivar active (list[str]): List of active effect IDs by default.
70
+ """
71
+
72
+ enabled: bool = True
73
+ active: list[str] = field(default_factory=list)
74
+
75
+
52
76
  @dataclass
53
77
  class GameConfig:
54
78
  """
@@ -62,6 +86,7 @@ class GameConfig:
62
86
  window: WindowConfig | None = None
63
87
  fps: int = 60
64
88
  backend: Backend | None = None
89
+ postfx: PostFXConfig = field(default_factory=PostFXConfig)
65
90
 
66
91
 
67
92
  Difficulty = Literal["easy", "normal", "hard", "insane"]
@@ -76,6 +101,7 @@ class GameSettings:
76
101
  """
77
102
 
78
103
  difficulty: Difficulty = "normal"
104
+ effects_stack: EffectStack | None = None
79
105
 
80
106
 
81
107
  def _neutral_input(frame_index: int, dt: float) -> InputFrame:
@@ -188,6 +214,7 @@ class Game:
188
214
  files=LocalFilesAdapter(),
189
215
  capture=CaptureAdapter(self.backend),
190
216
  input=InputAdapter(),
217
+ render=RenderService(),
191
218
  )
192
219
 
193
220
  self.command_queue = CommandQueue()
@@ -200,6 +227,9 @@ class Game:
200
227
  # TODO: Fix too-many-statements and too-many-locals warnings
201
228
  # Justification: Main game loop with multiple responsibilities.
202
229
  # pylint: disable=too-many-statements,too-many-locals
230
+ # TODO: Fix too-many-branches warning
231
+ # Justification: Complex control flow in main loop.
232
+ # pylint: disable=too-many-branches
203
233
  def run(self, initial_scene_id: str):
204
234
  """
205
235
  Run the main loop starting with the given scene.
@@ -218,6 +248,26 @@ class Game:
218
248
 
219
249
  pipeline = RenderPipeline()
220
250
 
251
+ effects_registry = EffectRegistry()
252
+ effects_registry.register(CRTEffect())
253
+ effects_registry.register(VignetteNoiseEffect())
254
+
255
+ effects_stack = EffectStack(
256
+ enabled=self.config.postfx.enabled,
257
+ active=list(self.config.postfx.active),
258
+ params={
259
+ "crt": EffectParams(intensity=0.35, wobble_speed=1.0),
260
+ "vignette_noise": EffectParams(
261
+ intensity=0.25, wobble_speed=1.0
262
+ ),
263
+ },
264
+ )
265
+ self.settings.effects_stack = effects_stack
266
+
267
+ for p in pipeline.passes:
268
+ if getattr(p, "name", "") == "PostFXPass":
269
+ p.registry = effects_registry
270
+
221
271
  self._running = True
222
272
  target_dt = 1.0 / self.config.fps if self.config.fps > 0 else 0.0
223
273
  last_time = perf_counter()
@@ -234,6 +284,7 @@ class Game:
234
284
  # & self.services.scenes.input_entry
235
285
  # Justification: These methods are expected to return values.
236
286
  # pylint: disable=assignment-from-no-return
287
+ time_s = 0.0
237
288
 
238
289
  while self._running:
239
290
  timer.clear()
@@ -251,8 +302,17 @@ class Game:
251
302
  logger.debug(f"Window resized event: {w}x{h}")
252
303
  self.services.window.on_window_resized(w, h)
253
304
  # if F1 pressed, toggle debug overlay
254
- if e.type == EventType.KEYDOWN and e.key == Key.F1:
255
- self.command_queue.push(ToggleDebugOverlayCommand())
305
+ if e.type == EventType.KEYDOWN:
306
+ if e.key == Key.F1:
307
+ self.command_queue.push(ToggleDebugOverlayCommand())
308
+ elif e.key == Key.F2:
309
+ self.command_queue.push(ToggleEffectCommand("crt"))
310
+ elif e.key == Key.F3:
311
+ self.command_queue.push(
312
+ ToggleEffectCommand("vignette_noise")
313
+ )
314
+ elif e.key == Key.F4:
315
+ effects_stack.enabled = not effects_stack.enabled
256
316
  timer.mark("events_polled")
257
317
 
258
318
  input_frame = self.services.input.build(events, frame_index, dt)
@@ -304,23 +364,43 @@ class Game:
304
364
  cmd.execute(command_context)
305
365
  timer.mark("cmd_exec_end")
306
366
 
367
+ # ---------------- TO REPLACE WITH RENDERING PIPELINE ----------------
307
368
  timer.mark("render_start")
308
- backend.begin_frame()
309
- timer.mark("begin_frame_done")
310
369
 
311
370
  vp = self.services.window.get_viewport()
371
+
372
+ # gather visible packets
373
+ frame_packets: list[RenderPacket] = []
312
374
  for entry in self.services.scenes.visible_entries():
313
375
  scene = entry.scene
314
376
  packet = packet_cache.get(id(scene))
315
377
  if packet is None:
316
- # bootstrap (first frame visible but not updated)
317
378
  packet = scene.tick(_neutral_input(frame_index, 0.0), 0.0)
318
379
  packet_cache[id(scene)] = packet
380
+ frame_packets.append(
381
+ FramePacket(
382
+ scene_id=entry.scene_id,
383
+ is_overlay=entry.is_overlay,
384
+ packet=packet,
385
+ )
386
+ )
387
+
388
+ render_ctx = RenderContext(
389
+ viewport=vp,
390
+ debug_overlay=getattr(self.settings, "debug_overlay", False),
391
+ frame_ms=dt * 1000.0,
392
+ )
393
+ time_s += dt
394
+ render_ctx.meta["frame_index"] = frame_index
395
+ render_ctx.meta["time_s"] = time_s
396
+ render_ctx.meta["effects_stack"] = effects_stack
319
397
 
320
- pipeline.draw_packet(backend, packet, vp)
398
+ self.services.render.last_frame_ms = render_ctx.frame_ms
399
+ self.services.render.last_stats = render_ctx.stats
400
+ pipeline.render_frame(backend, render_ctx, frame_packets)
321
401
 
322
- timer.mark("draw_done")
323
- backend.end_frame()
402
+ timer.mark("render_done")
403
+ # ---------------- END RENDERING PIPELINE ----------------------------
324
404
  timer.mark("end_frame_done")
325
405
 
326
406
  timer.mark("sleep_start")
@@ -0,0 +1,48 @@
1
+ """
2
+ Render context and stats for a single frame render.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from mini_arcade_core.engine.render.viewport import ViewportState
11
+
12
+
13
+ @dataclass
14
+ class RenderStats:
15
+ """
16
+ Statistics about the rendering process for a single frame.
17
+
18
+ :ivar packets (int): Number of render packets processed.
19
+ :ivar ops (int): Number of rendering operations executed.
20
+ :ivar draw_groups (int): Number of draw groups processed.
21
+ :ivar renderables (int): Number of renderable objects processed.
22
+ :ivar draw_groups (int): Number of draw groups processed.
23
+ """
24
+
25
+ packets: int = 0
26
+ ops: int = 0
27
+ draw_groups: int = 0 # approx ok
28
+ renderables: int = 0
29
+ draw_groups: int = 0
30
+
31
+
32
+ @dataclass
33
+ class RenderContext:
34
+ """
35
+ Context for rendering a single frame.
36
+
37
+ :ivar viewport: ViewportState: Current viewport state.
38
+ :ivar debug_overlay: bool: Whether to render debug overlays.
39
+ :ivar frame_ms: float: Time taken to render the frame in milliseconds.
40
+ :ivar stats: RenderStats: Statistics about the rendering process.
41
+ :ivar meta: dict[str, Any]: Additional metadata for rendering.
42
+ """
43
+
44
+ viewport: ViewportState
45
+ debug_overlay: bool = False
46
+ frame_ms: float = 0.0
47
+ stats: RenderStats = field(default_factory=RenderStats)
48
+ meta: dict[str, Any] = field(default_factory=dict)
@@ -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) -> None:
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) -> None:
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) -> None:
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.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.draw_line(
65
+ x0 + shift, y, x0 + w + shift, y, color=line_color
66
+ )
67
+
68
+ backend.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) -> None:
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) -> None:
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.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.draw_rect(x0, y0, w, t, color=color)
60
+ # bottom
61
+ backend.draw_rect(x0, y0 + h - t, w, t, color=color)
62
+ # left
63
+ backend.draw_rect(x0, y0, t, h, color=color)
64
+ # right
65
+ backend.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.draw_rect(px, py, 1, 1, color=(255, 255, 255, a))
78
+
79
+ backend.clear_clip_rect()
@@ -0,0 +1,26 @@
1
+ """
2
+ Frame packet for rendering.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+ from mini_arcade_core.engine.render.packet import RenderPacket
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class FramePacket:
14
+ """
15
+ A packet representing a frame to be rendered, associated with a specific scene
16
+ and indicating whether it is an overlay.
17
+
18
+
19
+ :ivar scene_id (str): Identifier of the scene.
20
+ :ivar is_overlay (bool): Whether the frame is an overlay.
21
+ :ivar packet (RenderPacket): The render packet containing rendering operations.
22
+ """
23
+
24
+ scene_id: str
25
+ is_overlay: bool
26
+ packet: RenderPacket
@@ -0,0 +1,37 @@
1
+ """
2
+ Render pass base protocol.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Protocol
8
+
9
+ from mini_arcade_core.backend import Backend
10
+ from mini_arcade_core.engine.render.context import RenderContext
11
+ from mini_arcade_core.engine.render.packet import RenderPacket
12
+
13
+
14
+ class RenderPass(Protocol):
15
+ """
16
+ Render pass protocol.
17
+
18
+ :ivar name: str: Name of the render pass.
19
+ """
20
+
21
+ name: str
22
+
23
+ def run(
24
+ self, backend: Backend, ctx: RenderContext, packets: list[RenderPacket]
25
+ ):
26
+ """
27
+ Run the render pass.
28
+
29
+ :param backend: Backend: The rendering backend.
30
+ :type backend: Backend
31
+
32
+ :param ctx: RenderContext: The rendering context.
33
+ :type ctx: RenderContext
34
+
35
+ :param packets: list[RenderPacket]: List of render packets to process.
36
+ :type packets: list[RenderPacket]
37
+ """