nodes/settings_ui.pyΒΆ
Part of Dungeon Explorer.
1"""Settings menu: audio, display, gameplay options as a Control overlay."""
2
3import math
4
5from simvx.core import Control, Property
6from simvx.core.input import MouseButton
7from simvx.core.ui.enums import AnchorPreset
8
9from ._back_button import back_button_hit, check_menu_click, draw_back_button
10
11
12# Global settings singleton
13class GameSettings:
14 """Game-wide settings storage."""
15
16 def __init__(self):
17 self.music_volume: float = 1.0
18 self.sfx_volume: float = 1.0
19 self.screen_shake: bool = True
20 self.camera_zoom: float = 2.5
21 self.hud_scale: float = 1.0
22 self.difficulty: str = "normal"
23 self.fullscreen: bool = False
24 self.control_mode: str = "keyboard"
25
26 def difficulty_damage_mult(self) -> float:
27 return {"easy": 0.7, "normal": 1.0, "hard": 1.5}.get(self.difficulty, 1.0)
28
29 def difficulty_xp_mult(self) -> float:
30 return {"easy": 1.3, "normal": 1.0, "hard": 0.8}.get(self.difficulty, 1.0)
31
32
33game_settings = GameSettings()
34
35DIFFICULTY_OPTIONS = ["easy", "normal", "hard"]
36CONTROL_MODE_OPTIONS = ["keyboard", "click_to_path", "virtual_gamepad"]
37
38
39class SettingsUI(Control):
40 """Settings popup with audio, display, and gameplay options."""
41
42 visible = Property(
43 False,
44 coerce=bool,
45 hint="Whether this node and its subtree are drawn",
46 on_change="_on_visible_changed",
47 )
48
49 # on_draw pulses the selected-row highlight off self._timer each frame ->
50 # retained 2D must re-run it each frame.
51 dynamic = True
52
53 ITEMS = [
54 ("Music Volume", "slider", "music_volume"),
55 ("SFX Volume", "slider", "sfx_volume"),
56 ("Screen Shake", "toggle", "screen_shake"),
57 ("Camera Zoom", "slider", "camera_zoom"),
58 ("HUD Scale", "slider", "hud_scale"),
59 ("Difficulty", "cycle", "difficulty"),
60 ("Fullscreen", "toggle", "fullscreen"),
61 ("Control Mode", "cycle", "control_mode"),
62 ]
63
64 def __init__(self, settings: GameSettings | None = None, on_close_cb=None, **kwargs):
65 super().__init__(name="SettingsUI", **kwargs)
66 self.set_anchor_preset(AnchorPreset.FULL_RECT)
67
68 self._settings = settings or game_settings
69 self._cursor = 0
70 self._on_close_cb = on_close_cb
71 self._timer = 0.0
72
73 self.cancel_requested.connect(self._on_close_signal)
74
75 @property
76 def settings(self) -> GameSettings:
77 return self._settings
78
79 def show(self):
80 self._cursor = 0
81 self._timer = 0.0
82 self.show_overlay("blocking", dismiss=True)
83
84 def _on_close_signal(self):
85 # Esc / outside-click closes; fire the close callback once.
86 if self._on_close_cb:
87 self._on_close_cb()
88
89 # -- Frame update --
90
91 def on_update(self, dt: float):
92 self._timer += dt
93
94 # -- Input --
95
96 def _on_gui_input(self, event):
97 if not self.visible:
98 return
99 if event.button == MouseButton.LEFT and event.pressed:
100 self._handle_click(float(event.position[0]), float(event.position[1]), event)
101 return
102 if event.key and event.pressed:
103 if event.key in ("w", "up"):
104 self._cursor = max(0, self._cursor - 1)
105 event.handled = True
106 elif event.key in ("s", "down"):
107 self._cursor = min(len(self.ITEMS) - 1, self._cursor + 1)
108 event.handled = True
109 elif event.key in ("a", "left"):
110 self._adjust(-1)
111 event.handled = True
112 elif event.key in ("d", "right"):
113 self._adjust(1)
114 event.handled = True
115 elif event.key in ("space", "enter", "return", "e"):
116 self._adjust(1)
117 event.handled = True
118
119 def _handle_click(self, mx: float, my: float, event):
120 if back_button_hit(mx, my):
121 if self._on_close_cb:
122 self._on_close_cb()
123 self.close_overlay()
124 event.handled = True
125 return
126 sw, sh = self._screen_size()
127 panel_w, panel_h = self._panel_size()
128 px = (sw - panel_w) / 2
129 py = (sh - panel_h) / 2
130 oy = py + 60
131 idx = check_menu_click(mx, my, len(self.ITEMS), px + 10, oy, panel_w - 20, 32, 44)
132 if idx is not None:
133 self._cursor = idx
134 vx = px + panel_w - 150
135 if mx < vx:
136 self._adjust(1)
137 else:
138 _, kind, key = self.ITEMS[idx]
139 if kind == "slider":
140 bar_x = vx
141 bar_w = 100
142 ratio = max(0.0, min(1.0, (mx - bar_x) / bar_w))
143 s = self._settings
144 if key == "camera_zoom":
145 s.camera_zoom = 1.0 + ratio * 3.0
146 elif key == "hud_scale":
147 s.hud_scale = 0.5 + ratio * 1.5
148 else:
149 setattr(s, key, round(ratio, 2))
150 else:
151 self._adjust(1)
152 event.handled = True
153
154 def _adjust(self, direction: int):
155 _, kind, key = self.ITEMS[self._cursor]
156 s = self._settings
157 if kind == "slider":
158 val = getattr(s, key)
159 if key == "camera_zoom":
160 step = 0.5
161 val = max(1.0, min(4.0, val + direction * step))
162 elif key == "hud_scale":
163 step = 0.25
164 val = max(0.5, min(2.0, val + direction * step))
165 else:
166 step = 0.1
167 val = max(0.0, min(1.0, round(val + direction * step, 2)))
168 setattr(s, key, val)
169 elif kind == "toggle":
170 setattr(s, key, not getattr(s, key))
171 elif kind == "cycle":
172 if key == "difficulty":
173 idx = DIFFICULTY_OPTIONS.index(s.difficulty)
174 idx = (idx + direction) % len(DIFFICULTY_OPTIONS)
175 s.difficulty = DIFFICULTY_OPTIONS[idx]
176 elif key == "control_mode":
177 idx = CONTROL_MODE_OPTIONS.index(s.control_mode)
178 idx = (idx + direction) % len(CONTROL_MODE_OPTIONS)
179 s.control_mode = CONTROL_MODE_OPTIONS[idx]
180
181 # -- Layout --
182
183 def _panel_size(self):
184 return 420, 60 + len(self.ITEMS) * 44 + 35
185
186 def _screen_size(self) -> tuple[float, float]:
187 if self.tree is not None:
188 sw, sh = self.tree.screen_size
189 return float(sw), float(sh)
190 return 1280.0, 720.0
191
192 # -- Rendering --
193
194 def on_draw(self, renderer):
195 if not self.visible:
196 return
197 sw, sh = self._screen_size()
198 renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.7), filled=True)
199
200 panel_w, panel_h = self._panel_size()
201 px = (sw - panel_w) / 2
202 py = (sh - panel_h) / 2
203
204 renderer.draw_rect((px, py), (panel_w, panel_h), colour=(0.1, 0.1, 0.14, 0.95), filled=True)
205 renderer.draw_rect((px, py), (panel_w, 3), colour=(0.5, 0.5, 0.6, 0.8), filled=True)
206
207 cx = sw / 2
208 renderer.draw_text("SETTINGS", (cx - 45, py + 15), scale=2.0, colour=(1.0, 1.0, 1.0, 1.0))
209
210 oy = py + 60
211 for i, (label, kind, key) in enumerate(self.ITEMS):
212 y = oy + i * 44
213 selected = i == self._cursor
214 if selected:
215 sel_pulse = 0.22 + 0.04 * math.sin(self._timer * 4.0)
216 renderer.draw_rect(
217 (px + 10, y - 4),
218 (panel_w - 20, 36),
219 colour=(sel_pulse, sel_pulse, sel_pulse + 0.08, 0.8),
220 filled=True,
221 )
222 label_c = (1.0, 0.9, 0.3, 1.0) if selected else (0.7, 0.7, 0.7, 1.0)
223 renderer.draw_text(label, (px + 25, y + 6), scale=1.1, colour=label_c)
224
225 val = getattr(self._settings, key)
226 vx = px + panel_w - 150
227 if kind == "slider":
228 bar_x = vx
229 bar_w = 100
230 bar_y = y + 12
231 renderer.draw_rect((bar_x, bar_y), (bar_w, 6), colour=(0.25, 0.25, 0.3, 1.0), filled=True)
232 if key == "camera_zoom":
233 fill = (val - 1.0) / 3.0
234 elif key == "hud_scale":
235 fill = (val - 0.5) / 1.5
236 else:
237 fill = val
238 fill = max(0.0, min(1.0, fill))
239 renderer.draw_rect((bar_x, bar_y), (int(bar_w * fill), 6), colour=(0.5, 0.7, 1.0, 1.0), filled=True)
240 if key in ("camera_zoom", "hud_scale"):
241 val_text = f"{val:.1f}x"
242 else:
243 val_text = f"{int(val * 100)}%"
244 renderer.draw_text(val_text, (bar_x + bar_w + 8, y + 6), scale=0.9, colour=(0.8, 0.8, 0.8, 1.0))
245 elif kind == "toggle":
246 text = "ON" if val else "OFF"
247 c = (0.3, 1.0, 0.3, 1.0) if val else (0.6, 0.3, 0.3, 1.0)
248 renderer.draw_text(text, (vx + 30, y + 6), scale=1.1, colour=c)
249 elif kind == "cycle":
250 display = val.replace("_", " ").title() if isinstance(val, str) else str(val)
251 renderer.draw_text(f"< {display} >", (vx + 10, y + 6), scale=1.1, colour=(0.8, 0.8, 0.8, 1.0))
252
253 renderer.draw_text(
254 "Arrows: Navigate/Adjust Esc: Back", (px + 20, py + panel_h - 25), scale=0.8, colour=(0.4, 0.4, 0.4, 1.0)
255 )
256 draw_back_button(renderer)