nodes/player_hud.py¶

Part of Dungeon Explorer.

  1"""Player HUD: HP/XP bars (rounded), minimap, level label, hotbar with drawn icons."""
  2
  3from events import HotbarSlotClicked, HotbarSlotLongPressed
  4from scripts.dungeon_generator import TILE_SIZE
  5from scripts.fog_of_war import FogOfWar
  6
  7from simvx.core import Node2D
  8
  9# HUD constants
 10BAR_W = 200
 11BAR_H = 16
 12MINIMAP_SIZE = 140
 13MINIMAP_SCALE = 2
 14
 15
 16class PlayerHUD(Node2D):
 17    """HUD overlay drawn in screen space."""
 18
 19    # ``on_draw`` reads non-Property live state every frame: the player's
 20    # position (the minimap dot tracks it continuously), HP/XP/gold/level, and
 21    # timer-driven fades (combo, quest slide-in, save flash) plus tree.screen_size.
 22    # Pinning every change site is impractical and the minimap dot is genuinely
 23    # per-frame, so ``dynamic`` is the honest, correct declaration here.
 24    dynamic = True
 25
 26    def __init__(self, **kwargs):
 27        super().__init__(name="HUD", **kwargs)
 28        self._player = None
 29        self._dungeon_data = None
 30        self._fog = None
 31        self._inventory = None
 32        self._quest_mgr = None
 33        self._skill_tree = None
 34        self._game_mgr = None
 35        self._screen_w = 1280
 36        self._screen_h = 720
 37        self._save_timer = 0.0
 38        self._hud_scale = 1.0
 39        self._stats_collapsed = False
 40        # Quest complete notification queue
 41        self._quest_notifications: list[tuple[str, float]] = []  # (text, remaining_time)
 42        # Combo visual feedback
 43        self._combo_display_timer = 0.0
 44        self._combo_display_count = 0
 45        self._combo_shatter_timer = 0.0
 46        # Hotbar interactions are surfaced as :class:`HotbarSlotClicked` and
 47        # :class:`HotbarSlotLongPressed` events on ``self.tree.events``.
 48        self._hotbar_press_slot: int | None = None
 49        self._hotbar_press_timer = 0.0
 50
 51        # Minimap is split into two retained-aware sibling children so the
 52        # ~2500-rect explored-tile background is cached and re-emitted only when
 53        # fog reveals a new tile (``_MinimapBackground``), while only the
 54        # genuinely per-frame player dot (``_MinimapForeground``) redraws each
 55        # frame. Both are children of the (always-visited) PlayerHUD; ``z_index``
 56        # orders the dot (1) above the background (0) within the HUD's draw band.
 57        self._minimap_bg = _MinimapBackground()
 58        self._minimap_fg = _MinimapForeground()
 59        self.add_child(self._minimap_bg)
 60        self.add_child(self._minimap_fg)
 61
 62    def setup(self, player, dungeon_data=None, fog: FogOfWar | None = None, screen_size=(1280, 720), inventory=None):
 63        self._player = player
 64        self._dungeon_data = dungeon_data
 65        self._fog = fog
 66        self._screen_w, self._screen_h = screen_size
 67        if inventory is not None:
 68            self._inventory = inventory
 69        # Propagate to the minimap children. A ``None`` dungeon/fog (town) clears
 70        # the background so it draws nothing; a real dungeon re-arms it and the
 71        # ``configure`` call queues the one background redraw for the new level.
 72        self._minimap_fg.configure(player, dungeon_data, self._screen_w)
 73        self._minimap_bg.configure(dungeon_data, fog, self._screen_w)
 74
 75    def notify_fog_changed(self):
 76        """Re-emit the cached minimap background after fog reveals new tiles."""
 77        self._minimap_bg.queue_redraw()
 78
 79    def show_save_indicator(self):
 80        """Flash 'SAVED' in the corner for 2 seconds."""
 81        self._save_timer = 2.0
 82
 83    def set_quest_manager(self, mgr):
 84        self._quest_mgr = mgr
 85
 86    def set_skill_tree(self, tree):
 87        self._skill_tree = tree
 88
 89    def set_game_manager(self, mgr):
 90        self._game_mgr = mgr
 91
 92    def notify_quest_complete(self, quest_name: str):
 93        """Queue a quest-complete slide-in notification."""
 94        self._quest_notifications.append((f"Quest Complete: {quest_name}", 3.0))
 95
 96    def update_combo(self, count: int):
 97        """Update combo visual feedback."""
 98        prev = self._combo_display_count
 99        self._combo_display_count = count
100        if count >= 2:
101            self._combo_display_timer = 2.0
102        if count == 0 and prev >= 2:
103            self._combo_shatter_timer = 0.5
104
105    def on_update(self, dt: float):
106        # The minimap dot tracks the player every frame. Mark it render-dirty
107        # rather than declaring it ``dynamic``: the render-dirty path (the same
108        # one the background uses) uploads a freshly-configured node's geometry
109        # immediately, whereas a ``dynamic`` node added mid-retention is not
110        # patched until the next full re-collect (dot invisible after a load).
111        self._minimap_fg.queue_redraw()
112        if self._save_timer > 0:
113            self._save_timer -= dt
114        if self._combo_display_timer > 0:
115            self._combo_display_timer -= dt
116        if self._combo_shatter_timer > 0:
117            self._combo_shatter_timer -= dt
118        if self._quest_notifications:
119            self._quest_notifications = [(text, t - dt) for text, t in self._quest_notifications if t - dt > 0]
120
121    def check_hotbar_click(self, mx: float, my: float) -> int | None:
122        """Hit-test against the 4 hotbar slots. Returns slot index or None."""
123        if not self._skill_tree:
124            return None
125        cx = self._screen_w / 2
126        slot_size = 40
127        pad = 5
128        total_w = 4 * (slot_size + pad)
129        sx = cx - total_w / 2
130        sy = self._screen_h - slot_size - 12
131        for i in range(4):
132            x = sx + i * (slot_size + pad)
133            if x <= mx <= x + slot_size and sy <= my <= sy + slot_size:
134                return i
135        return None
136
137    def handle_hotbar_input(self, dt: float):
138        """Process hotbar mouse interaction (call from main game loop)."""
139        from simvx.core import Input, MouseButton
140
141        if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
142            mx, my = Input.mouse_position
143            slot = self.check_hotbar_click(mx, my)
144            if slot is not None:
145                self._hotbar_press_slot = slot
146                self._hotbar_press_timer = 0.0
147                return
148        if self._hotbar_press_slot is not None:
149            self._hotbar_press_timer += dt
150            if not Input.is_mouse_button_pressed(MouseButton.LEFT):
151                if self._hotbar_press_timer > 0.5:
152                    self._fire_long_press(self._hotbar_press_slot)
153                else:
154                    self._fire_click(self._hotbar_press_slot)
155                self._hotbar_press_slot = None
156            elif self._hotbar_press_timer > 0.5:
157                self._fire_long_press(self._hotbar_press_slot)
158                self._hotbar_press_slot = None
159        # Right-click opens assignment
160        if Input.is_mouse_button_just_pressed(MouseButton.RIGHT):
161            mx, my = Input.mouse_position
162            slot = self.check_hotbar_click(mx, my)
163            if slot is not None:
164                self._fire_long_press(slot)
165
166    def _fire_click(self, slot: int) -> None:
167        if self.tree is not None:
168            self.tree.events.publish(HotbarSlotClicked(slot=slot))
169
170    def _fire_long_press(self, slot: int) -> None:
171        if self.tree is not None:
172            self.tree.events.publish(HotbarSlotLongPressed(slot=slot))
173
174    def on_draw(self, renderer):
175        if self.tree:
176            sw, sh = self.tree.screen_size
177            if sw != self._screen_w:
178                # The minimap geometry is screen-relative (``mx`` hugs the right
179                # edge), so a resize must re-emit the cached background.
180                self._minimap_bg.set_screen_w(sw)
181            self._minimap_fg._screen_w = sw
182            self._screen_w, self._screen_h = sw, sh
183        if self._player is None:
184            return
185
186        p = self._player
187        # HP bar (top left) -- rounded style with segment marks
188        self._draw_rounded_bar(renderer, 10, 10, p.hp, p.max_hp, (0.8, 0.1, 0.1, 0.9), "HP", segments=5)
189        # XP bar (below HP) -- rounded
190        self._draw_rounded_bar(renderer, 10, 32, p.xp, p.xp_to_next, (0.2, 0.5, 0.9, 0.9), "XP", segments=4)
191        # Level
192        renderer.draw_text(f"Lv {p.level}", (10, 54), scale=1.2, colour=(1.0, 1.0, 1.0))
193        # Gold
194        renderer.draw_text(f"Gold: {p.gold}", (10, 72), scale=1, colour=(1.0, 0.85, 0.2))
195
196        # Collapsible stats section
197        y = 90
198        if not self._stats_collapsed:
199            renderer.draw_text(
200                f"STR: {p.strength}  DEX: {p.dexterity}  VIT: {p.vitality}", (10, y), scale=0.8, colour=(0.7, 0.7, 0.7)
201            )
202            y += 14
203            if self._inventory:
204                weapon = self._inventory.get_equipped("weapon")
205                if weapon:
206                    renderer.draw_text(f"Weapon: {weapon.display_name}", (10, y), scale=0.8, colour=weapon.colour)
207                    y += 14
208
209        # Level-up notification
210        if p.stat_points > 0:
211            renderer.draw_text(f"LEVEL UP! Press L ({p.stat_points} pts)", (10, y), scale=1.0, colour=(1.0, 0.9, 0.3))
212            y += 16
213
214        # Combo visual feedback (scaling font, colour gradient, shatter on break)
215        if self._combo_display_count >= 2 and self._combo_display_timer > 0:
216            combo = self._combo_display_count
217            combo_scale = min(2.5, 1.0 + combo * 0.15)
218            if combo < 5:
219                cc = (1.0, 0.7, 0.0, 1.0)
220            elif combo < 10:
221                cc = (1.0, 0.4, 0.0, 1.0)
222            else:
223                cc = (1.0, 0.15, 0.0, 1.0)
224            renderer.draw_text(f"x{combo} COMBO!", (10, y), scale=combo_scale, colour=cc)
225            y += int(16 * combo_scale)
226        if self._combo_shatter_timer > 0:
227            alpha = self._combo_shatter_timer / 0.5
228            renderer.draw_text("COMBO BREAK!", (10, y), scale=1.2, colour=(0.8, 0.2, 0.2, alpha))
229            y += 16
230
231        # Compact quest tracker
232        if self._quest_mgr and self._quest_mgr.active:
233            qy = max(y, 140)
234            renderer.draw_text("Quests:", (10, qy), scale=0.8, colour=(0.8, 0.7, 0.3))
235            for quest in self._quest_mgr.active[:2]:
236                qy += 14
237                done = quest.is_complete
238                c = (0.3, 1.0, 0.3) if done else (0.6, 0.6, 0.6)
239                # Compact: name + progress bar
240                renderer.draw_text(f"  {quest.name}", (10, qy), scale=0.7, colour=c)
241                qy += 12
242                # Mini progress bar
243                bar_w = 100
244                ratio = min(1.0, quest.progress / max(1, quest.count))
245                renderer.draw_rect((14, qy), (bar_w, 4), colour=(0.2, 0.2, 0.2, 0.6), filled=True)
246                bar_c = (0.3, 0.8, 0.3, 0.8) if done else (0.4, 0.5, 0.7, 0.8)
247                renderer.draw_rect((14, qy), (int(bar_w * ratio), 4), colour=bar_c, filled=True)
248                renderer.draw_text(
249                    f"{quest.progress}/{quest.count}", (bar_w + 18, qy - 4), scale=0.6, colour=(0.5, 0.5, 0.5)
250                )
251                qy += 6
252
253        # Minimap (top right) is drawn by the ``_MinimapBackground`` /
254        # ``_MinimapForeground`` child nodes, not here.
255
256        # Ability hotbar (upgraded)
257        if self._skill_tree:
258            self._draw_hotbar(renderer)
259
260        # Quest complete notifications (slide-in from right, gold text, 3s fade)
261        notif_y = 200
262        for text, remaining in self._quest_notifications:
263            if remaining > 2.7:
264                slide = (3.0 - remaining) / 0.3
265                nx = self._screen_w - int(slide * 300)
266            else:
267                nx = self._screen_w - 300
268            alpha = min(1.0, remaining / 0.5) if remaining < 0.5 else 1.0
269            renderer.draw_rect((nx - 5, notif_y - 2), (310, 22), colour=(0.1, 0.08, 0.02, alpha * 0.7), filled=True)
270            renderer.draw_text(text, (nx, notif_y), scale=1.1, colour=(1.0, 0.85, 0.2, alpha))
271            notif_y += 28
272
273        # Save indicator
274        if self._save_timer > 0:
275            alpha = min(1.0, self._save_timer / 0.5)
276            renderer.draw_text(
277                "SAVED", (self._screen_w - 80, self._screen_h - 30), scale=1.0, colour=(0.3, 1.0, 0.3, alpha)
278            )
279
280    def _draw_hotbar(self, renderer):
281        cx = self._screen_w / 2
282        slot_size = 40
283        pad = 5
284        total_w = 4 * (slot_size + pad)
285        sx = cx - total_w / 2
286        sy = self._screen_h - slot_size - 12
287
288        for i in range(4):
289            x = sx + i * (slot_size + pad)
290            entry = self._skill_tree.hotbar[i]
291
292            if entry and entry.startswith("item:"):
293                # Item slot
294                template_key = entry[5:]
295                label = {
296                    "potion": "Potn",
297                    "fire_scroll": "Fire",
298                    "town_portal_scroll": "TPrt",
299                }.get(template_key, template_key[:4].title())
300                renderer.draw_rect((x, sy), (slot_size, slot_size), colour=(0.15, 0.18, 0.15, 0.9), filled=True)
301                border_c = (0.3, 0.6, 0.3, 0.8)
302                renderer.draw_rect((x, sy), (slot_size, 1), colour=border_c, filled=True)
303                renderer.draw_rect((x, sy + slot_size - 1), (slot_size, 1), colour=border_c, filled=True)
304                renderer.draw_rect((x, sy), (1, slot_size), colour=border_c, filled=True)
305                renderer.draw_rect((x + slot_size - 1, sy), (1, slot_size), colour=border_c, filled=True)
306                # Potion icon
307                icx, icy = x + slot_size / 2, sy + slot_size / 2 - 4
308                renderer.draw_rect((icx - 3, icy - 6), (6, 5), colour=(0.5, 0.5, 0.6, 0.9), filled=True)
309                renderer.draw_rect((icx - 5, icy - 1), (10, 9), colour=(0.3, 0.8, 0.3, 0.9), filled=True)
310                renderer.draw_text(label, (x + 2, sy + slot_size - 14), scale=0.6, colour=(0.5, 0.8, 0.5))
311                # Show stack count from inventory
312                if self._inventory:
313                    count = sum(
314                        it.stack_count
315                        for idx in range(self._inventory.capacity)
316                        if (it := self._inventory.get(idx)) and it.is_consumable and it.template_key == template_key
317                    )
318                    if count > 0:
319                        renderer.draw_text(str(count), (x + slot_size - 14, sy + 2), scale=0.65, colour=(1.0, 1.0, 1.0))
320            elif entry:
321                skill_id = entry
322                skill = self._skill_tree.get_skill(skill_id)
323                if skill:
324                    ready = self._skill_tree.is_ready(skill_id)
325                    cd = self._skill_tree.get_cooldown(skill_id)
326                    max_cd = skill.get("cooldown", 1)
327
328                    # Background
329                    bg = (0.15, 0.15, 0.22, 0.9) if ready else (0.1, 0.1, 0.12, 0.9)
330                    renderer.draw_rect((x, sy), (slot_size, slot_size), colour=bg, filled=True)
331
332                    # Border (glow when ready)
333                    if ready:
334                        border_c = (0.4, 0.5, 0.7, 0.8)
335                    else:
336                        border_c = (0.2, 0.2, 0.25, 0.6)
337                    renderer.draw_rect((x, sy), (slot_size, 1), colour=border_c, filled=True)
338                    renderer.draw_rect((x, sy + slot_size - 1), (slot_size, 1), colour=border_c, filled=True)
339                    renderer.draw_rect((x, sy), (1, slot_size), colour=border_c, filled=True)
340                    renderer.draw_rect((x + slot_size - 1, sy), (1, slot_size), colour=border_c, filled=True)
341
342                    # Drawn icon
343                    branch = skill.get("branch", "mage")
344                    icon_c = (0.8, 0.8, 0.9, 1.0) if ready else (0.3, 0.3, 0.4, 0.5)
345                    icx = x + slot_size / 2
346                    icy = sy + slot_size / 2 - 4
347                    _draw_hotbar_icon(renderer, icx, icy, branch, ready, icon_c)
348
349                    # Skill name abbreviation
350                    abbr = skill["name"][:4]
351                    text_c = (0.7, 0.7, 0.8, 1.0) if ready else (0.35, 0.35, 0.4, 0.5)
352                    renderer.draw_text(abbr, (x + 2, sy + slot_size - 14), scale=0.65, colour=text_c)
353
354                    # Clock-wipe cooldown overlay
355                    if cd > 0:
356                        fill = cd / max(0.01, max_cd)
357                        fill_h = int(slot_size * fill)
358                        renderer.draw_rect(
359                            (x, sy + slot_size - fill_h),
360                            (slot_size, fill_h),
361                            colour=(0.05, 0.05, 0.08, 0.7),
362                            filled=True,
363                        )
364
365                    # Activation flash (when cooldown just started)
366                    if cd > 0 and cd > max_cd - 0.2:
367                        flash_a = (max_cd - cd) / 0.2
368                        renderer.draw_rect(
369                            (x, sy), (slot_size, slot_size), colour=(1.0, 1.0, 1.0, 0.3 * flash_a), filled=True
370                        )
371            else:
372                # Empty slot -- dotted border
373                renderer.draw_rect((x, sy), (slot_size, slot_size), colour=(0.1, 0.1, 0.12, 0.5), filled=True)
374                dash_len = 4
375                gap = 6
376                for d in range(0, slot_size, dash_len + gap):
377                    dc = (0.25, 0.25, 0.3, 0.4)
378                    renderer.draw_rect((x + d, sy), (min(dash_len, slot_size - d), 1), colour=dc, filled=True)
379                    renderer.draw_rect(
380                        (x + d, sy + slot_size - 1), (min(dash_len, slot_size - d), 1), colour=dc, filled=True
381                    )
382                    renderer.draw_rect((x, sy + d), (1, min(dash_len, slot_size - d)), colour=dc, filled=True)
383                    renderer.draw_rect(
384                        (x + slot_size - 1, sy + d), (1, min(dash_len, slot_size - d)), colour=dc, filled=True
385                    )
386
387            # Key label
388            renderer.draw_text(str(i + 1), (x + slot_size - 10, sy - 12), scale=0.7, colour=(0.5, 0.5, 0.5))
389
390    def _draw_rounded_bar(self, renderer, x, y, current, maximum, colour, label, segments=0):
391        """Draw a bar with rounded-feel ends and optional segment marks."""
392        renderer.draw_rect((x, y), (BAR_W, BAR_H), colour=(0.12, 0.12, 0.12, 0.8), filled=True)
393        renderer.draw_rect((x, y), (BAR_W, 1), colour=(0.25, 0.25, 0.28, 0.6), filled=True)
394        renderer.draw_rect((x, y + BAR_H - 1), (BAR_W, 1), colour=(0.08, 0.08, 0.08, 0.8), filled=True)
395
396        ratio = max(0, min(1, current / max(1, maximum)))
397        fill_w = int(BAR_W * ratio)
398        if fill_w > 0:
399            renderer.draw_rect((x, y + 1), (fill_w, BAR_H - 2), colour=colour, filled=True)
400            r, g, b, a = colour
401            renderer.draw_rect(
402                (x, y + 1),
403                (fill_w, 2),
404                colour=(min(1, r + 0.2), min(1, g + 0.2), min(1, b + 0.2), a * 0.4),
405                filled=True,
406            )
407
408        if segments > 1:
409            for s in range(1, segments):
410                sx = x + int(BAR_W * s / segments)
411                renderer.draw_rect((sx, y), (1, BAR_H), colour=(0.0, 0.0, 0.0, 0.3), filled=True)
412
413        renderer.draw_text(f"{label}: {int(current)}/{int(maximum)}", (x + 4, y + 2), scale=0.9, colour=(1.0, 1.0, 1.0))
414
415
416def _draw_hotbar_icon(renderer, cx: float, cy: float, branch: str, ready: bool, colour: tuple):
417    """Draw a hotbar skill icon based on branch."""
418    r, g, b = colour[0], colour[1], colour[2]
419    a = colour[3] if len(colour) > 3 else 1.0
420    if branch == "warrior":
421        renderer.draw_rect((cx - 1, cy - 6), (2, 12), colour=(r, g, b, a), filled=True)
422        renderer.draw_rect((cx - 4, cy - 1), (8, 2), colour=(r * 0.7, g * 0.7, b * 0.7, a), filled=True)
423    elif branch == "ranger":
424        renderer.draw_rect((cx - 1, cy - 5), (2, 10), colour=(r, g, b, a), filled=True)
425        renderer.fill_triangle(cx, cy - 7, cx - 3, cy - 3, cx + 3, cy - 3, colour=(r, g, b, a))
426    else:
427        renderer.draw_circle((cx, cy), 4, colour=(r, g, b, a), filled=True)
428        if ready:
429            renderer.draw_circle((cx, cy), 2, colour=(1.0, 1.0, 1.0, 0.3), filled=True)
430
431
432class _MinimapBackground(Node2D):
433    """Static minimap: frame, explored-tile loop, and reveal-gated markers.
434
435    NOT ``dynamic``. The ~2500-rect explored-tile fill only changes when fog
436    reveals a new tile, so this node is retained and re-emits exactly once per
437    reveal (``PlayerHUD.notify_fog_changed`` -> ``queue_redraw``), on level load
438    (``configure``), and on resize (``set_screen_w``) -- never per frame.
439    """
440
441    dynamic = False
442
443    def __init__(self, **kwargs):
444        super().__init__(name="MinimapBg", z_index=0, **kwargs)  # below the player dot
445        self._dungeon_data = None
446        self._fog = None
447        self._screen_w = 1280
448
449    def configure(self, dungeon_data, fog, screen_w):
450        """(Re)arm for a new dungeon, or clear (``None``) when back in town."""
451        self._dungeon_data = dungeon_data
452        self._fog = fog
453        self._screen_w = screen_w
454        self.queue_redraw()
455
456    def set_screen_w(self, screen_w):
457        self._screen_w = screen_w
458        self.queue_redraw()
459
460    def on_draw(self, renderer):
461        data = self._dungeon_data
462        fog = self._fog
463        if not (data and fog):
464            return
465
466        mx = self._screen_w - MINIMAP_SIZE - 10
467        my = 10
468        renderer.draw_rect(
469            (mx - 3, my - 3), (MINIMAP_SIZE + 6, MINIMAP_SIZE + 6), colour=(0.25, 0.22, 0.18, 0.8), filled=True
470        )
471        renderer.draw_rect(
472            (mx - 2, my - 2), (MINIMAP_SIZE + 4, MINIMAP_SIZE + 4), colour=(0.0, 0.0, 0.0, 0.7), filled=True
473        )
474
475        scale_x = MINIMAP_SIZE / max(1, data.width)
476        scale_y = MINIMAP_SIZE / max(1, data.height)
477
478        for gy in range(data.height):
479            for gx in range(data.width):
480                state = fog.get_state(gx, gy)
481                if state == 0:
482                    continue
483                cell = data.grid[gy][gx]
484                if cell == -1:
485                    continue
486
487                px = mx + gx * scale_x
488                py = my + gy * scale_y
489                w = max(1, scale_x)
490                h = max(1, scale_y)
491
492                alpha = 0.3 if state == 1 else 0.8
493
494                if data.is_walkable(gx, gy):
495                    renderer.draw_rect((px, py), (w, h), colour=(0.5, 0.5, 0.5, alpha), filled=True)
496                else:
497                    renderer.draw_rect((px, py), (w, h), colour=(0.3, 0.25, 0.2, alpha), filled=True)
498
499        ex, ey = data.entrance
500        epx = mx + ex * scale_x
501        epy = my + ey * scale_y
502        renderer.draw_rect((epx - 2, epy - 2), (4, 4), colour=(0.2, 0.9, 0.3, 0.9), filled=True)
503
504        xx, xy = data.exit
505        if fog.get_state(xx, xy) >= 1:  # Only show exit on minimap once explored
506            xpx = mx + xx * scale_x
507            xpy = my + xy * scale_y
508            renderer.draw_rect((xpx - 2, xpy - 2), (4, 4), colour=(0.9, 0.2, 0.15, 0.9), filled=True)
509
510        for obj in data.special_objects:
511            if obj.get("type") == "chest":
512                ogx, ogy = obj.get("gx", 0), obj.get("gy", 0)
513                if fog.get_state(ogx, ogy) >= 1:
514                    opx = mx + ogx * scale_x
515                    opy = my + ogy * scale_y
516                    renderer.draw_rect((opx - 1, opy - 1), (3, 3), colour=(1.0, 0.85, 0.2, 0.8), filled=True)
517
518        for obj in data.special_objects:
519            if obj.get("type") == "waypoint":
520                ogx, ogy = obj.get("gx", 0), obj.get("gy", 0)
521                if fog.get_state(ogx, ogy) >= 1:
522                    opx = mx + ogx * scale_x
523                    opy = my + ogy * scale_y
524                    renderer.draw_rect((opx - 2, opy - 2), (4, 4), colour=(0.3, 0.8, 1.0, 0.8), filled=True)
525
526        renderer.draw_text("N", (mx + MINIMAP_SIZE // 2 - 3, my - 12), scale=0.6, colour=(0.7, 0.7, 0.7))
527        renderer.draw_text("S", (mx + MINIMAP_SIZE // 2 - 3, my + MINIMAP_SIZE + 3), scale=0.6, colour=(0.7, 0.7, 0.7))
528        renderer.draw_text("W", (mx - 12, my + MINIMAP_SIZE // 2 - 4), scale=0.6, colour=(0.7, 0.7, 0.7))
529        renderer.draw_text("E", (mx + MINIMAP_SIZE + 4, my + MINIMAP_SIZE // 2 - 4), scale=0.6, colour=(0.7, 0.7, 0.7))
530
531
532class _MinimapForeground(Node2D):
533    """The moving player dot only. NOT ``dynamic``: ``PlayerHUD`` marks it
534    render-dirty every frame via ``queue_redraw`` (so a just-configured dot's
535    geometry uploads at once). ``z_index = 1`` puts it on top of the background."""
536
537    def __init__(self, **kwargs):
538        super().__init__(name="MinimapFg", z_index=1, **kwargs)  # above the explored tiles
539        self._player = None
540        self._dungeon_data = None
541        self._screen_w = 1280
542
543    def configure(self, player, dungeon_data, screen_w):
544        self._player = player
545        self._dungeon_data = dungeon_data
546        self._screen_w = screen_w
547
548    def on_draw(self, renderer):
549        # Always emit exactly ONE rect so the dynamic op count is stable: a
550        # variable (0<->1) count makes the retained pipeline add/drop the item row
551        # without re-uploading its geometry until the next full re-collect, which
552        # leaves the dot invisible for a stationary player just after a level load.
553        # When not yet configured (town / pre-setup) the dot is parked off-screen.
554        player = self._player
555        data = self._dungeon_data
556        if player is not None and data is not None:
557            mx = self._screen_w - MINIMAP_SIZE - 10
558            my = 10
559            scale_x = MINIMAP_SIZE / max(1, data.width)
560            scale_y = MINIMAP_SIZE / max(1, data.height)
561            ppx = mx + (player.position.x / TILE_SIZE) * scale_x
562            ppy = my + (player.position.y / TILE_SIZE) * scale_y
563        else:
564            ppx = ppy = -1000.0
565        renderer.draw_rect((ppx - 1, ppy - 1), (3, 3), colour=(0.2, 0.8, 1.0, 1.0), filled=True)