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