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)