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.
- mini_arcade_core/__init__.py +14 -42
- mini_arcade_core/backend/__init__.py +1 -2
- mini_arcade_core/backend/backend.py +185 -154
- mini_arcade_core/backend/types.py +5 -1
- mini_arcade_core/engine/commands.py +26 -7
- mini_arcade_core/engine/game.py +79 -319
- 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 +88 -0
- mini_arcade_core/engine/render/effects/crt.py +68 -0
- mini_arcade_core/engine/render/effects/registry.py +50 -0
- mini_arcade_core/engine/render/effects/vignette.py +79 -0
- 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 +25 -4
- 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/__init__.py +0 -0
- 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.0.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.0.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.0.dist-info/RECORD +0 -80
- /mini_arcade_core/{managers → engine}/cheats.py +0 -0
- /mini_arcade_core/{managers → engine/loop}/__init__.py +0 -0
- /mini_arcade_core/{sim → engine/render/effects}/__init__.py +0 -0
- {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()
|
|
@@ -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.
|
|
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[
|
|
28
|
+
self, backend: Backend, ctx: RenderContext, packets: list[FramePacket]
|
|
25
29
|
):
|
|
26
30
|
"""Run the post-processing effects render pass."""
|
|
27
|
-
#
|
|
28
|
-
|
|
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)
|