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)