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, Mouse
140
141        if Input.is_mouse_button_just_pressed(Mouse.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(Mouse.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(Mouse.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        sw, sh = self.viewport_size
176        if sw != self._screen_w:
177            # The minimap geometry is screen-relative (``mx`` hugs the right
178            # edge), so a resize must re-emit the cached background.
179            self._minimap_bg.set_screen_w(sw)
180        self._minimap_fg._screen_w = sw
181        self._screen_w, self._screen_h = sw, sh
182        if self._player is None:
183            return
184
185        p = self._player
186        # HP bar (top left) -- rounded style with segment marks
187        self._draw_rounded_bar(renderer, 10, 10, p.hp, p.max_hp, (0.8, 0.1, 0.1, 0.9), "HP", segments=5)
188        # XP bar (below HP) -- rounded
189        self._draw_rounded_bar(renderer, 10, 32, p.xp, p.xp_to_next, (0.2, 0.5, 0.9, 0.9), "XP", segments=4)
190        # Level
191        renderer.text(f"Lv {p.level}", (10, 54), fill=(1.0, 1.0, 1.0), scale=1.2)
192        # Gold
193        renderer.text(f"Gold: {p.gold}", (10, 72), fill=(1.0, 0.85, 0.2), scale=1)
194
195        # Collapsible stats section
196        y = 90
197        if not self._stats_collapsed:
198            renderer.text(
199                f"STR: {p.strength}  DEX: {p.dexterity}  VIT: {p.vitality}", (10, y), fill=(0.7, 0.7, 0.7), scale=0.8
200            )
201            y += 14
202            if self._inventory:
203                weapon = self._inventory.get_equipped("weapon")
204                if weapon:
205                    renderer.text(f"Weapon: {weapon.display_name}", (10, y), fill=weapon.colour, scale=0.8)
206                    y += 14
207
208        # Level-up notification
209        if p.stat_points > 0:
210            renderer.text(f"LEVEL UP! Press L ({p.stat_points} pts)", (10, y), fill=(1.0, 0.9, 0.3), scale=1.0)
211            y += 16
212
213        # Combo visual feedback (scaling font, colour gradient, shatter on break)
214        if self._combo_display_count >= 2 and self._combo_display_timer > 0:
215            combo = self._combo_display_count
216            combo_scale = min(2.5, 1.0 + combo * 0.15)
217            if combo < 5:
218                cc = (1.0, 0.7, 0.0, 1.0)
219            elif combo < 10:
220                cc = (1.0, 0.4, 0.0, 1.0)
221            else:
222                cc = (1.0, 0.15, 0.0, 1.0)
223            renderer.text(f"x{combo} COMBO!", (10, y), fill=cc, scale=combo_scale)
224            y += int(16 * combo_scale)
225        if self._combo_shatter_timer > 0:
226            alpha = self._combo_shatter_timer / 0.5
227            renderer.text("COMBO BREAK!", (10, y), fill=(0.8, 0.2, 0.2, alpha), scale=1.2)
228            y += 16
229
230        # Compact quest tracker
231        if self._quest_mgr and self._quest_mgr.active:
232            qy = max(y, 140)
233            renderer.text("Quests:", (10, qy), fill=(0.8, 0.7, 0.3), scale=0.8)
234            for quest in self._quest_mgr.active[:2]:
235                qy += 14
236                done = quest.is_complete
237                c = (0.3, 1.0, 0.3) if done else (0.6, 0.6, 0.6)
238                # Compact: name + progress bar
239                renderer.text(f"  {quest.name}", (10, qy), fill=c, scale=0.7)
240                qy += 12
241                # Mini progress bar
242                bar_w = 100
243                ratio = min(1.0, quest.progress / max(1, quest.count))
244                renderer.rect((14, qy), (bar_w, 4), fill=(0.2, 0.2, 0.2, 0.6))
245                bar_c = (0.3, 0.8, 0.3, 0.8) if done else (0.4, 0.5, 0.7, 0.8)
246                renderer.rect((14, qy), (int(bar_w * ratio), 4), fill=bar_c)
247                renderer.text(f"{quest.progress}/{quest.count}", (bar_w + 18, qy - 4), fill=(0.5, 0.5, 0.5), scale=0.6)
248                qy += 6
249
250        # Minimap (top right) is drawn by the ``_MinimapBackground`` /
251        # ``_MinimapForeground`` child nodes, not here.
252
253        # Ability hotbar (upgraded)
254        if self._skill_tree:
255            self._draw_hotbar(renderer)
256
257        # Quest complete notifications (slide-in from right, gold text, 3s fade)
258        notif_y = 200
259        for text, remaining in self._quest_notifications:
260            if remaining > 2.7:
261                slide = (3.0 - remaining) / 0.3
262                nx = self._screen_w - int(slide * 300)
263            else:
264                nx = self._screen_w - 300
265            alpha = min(1.0, remaining / 0.5) if remaining < 0.5 else 1.0
266            renderer.rect((nx - 5, notif_y - 2), (310, 22), fill=(0.1, 0.08, 0.02, alpha * 0.7))
267            renderer.text(text, (nx, notif_y), fill=(1.0, 0.85, 0.2, alpha), scale=1.1)
268            notif_y += 28
269
270        # Save indicator
271        if self._save_timer > 0:
272            alpha = min(1.0, self._save_timer / 0.5)
273            renderer.text("SAVED", (self._screen_w - 80, self._screen_h - 30), fill=(0.3, 1.0, 0.3, alpha), scale=1.0)
274
275    def _draw_hotbar(self, renderer):
276        cx = self._screen_w / 2
277        slot_size = 40
278        pad = 5
279        total_w = 4 * (slot_size + pad)
280        sx = cx - total_w / 2
281        sy = self._screen_h - slot_size - 12
282
283        for i in range(4):
284            x = sx + i * (slot_size + pad)
285            entry = self._skill_tree.hotbar[i]
286
287            if entry and entry.startswith("item:"):
288                # Item slot
289                template_key = entry[5:]
290                label = {
291                    "potion": "Potn",
292                    "fire_scroll": "Fire",
293                    "town_portal_scroll": "TPrt",
294                }.get(template_key, template_key[:4].title())
295                renderer.rect((x, sy), (slot_size, slot_size), fill=(0.15, 0.18, 0.15, 0.9))
296                border_c = (0.3, 0.6, 0.3, 0.8)
297                renderer.rect((x, sy), (slot_size, 1), fill=border_c)
298                renderer.rect((x, sy + slot_size - 1), (slot_size, 1), fill=border_c)
299                renderer.rect((x, sy), (1, slot_size), fill=border_c)
300                renderer.rect((x + slot_size - 1, sy), (1, slot_size), fill=border_c)
301                # Potion icon
302                icx, icy = x + slot_size / 2, sy + slot_size / 2 - 4
303                renderer.rect((icx - 3, icy - 6), (6, 5), fill=(0.5, 0.5, 0.6, 0.9))
304                renderer.rect((icx - 5, icy - 1), (10, 9), fill=(0.3, 0.8, 0.3, 0.9))
305                renderer.text(label, (x + 2, sy + slot_size - 14), fill=(0.5, 0.8, 0.5), scale=0.6)
306                # Show stack count from inventory
307                if self._inventory:
308                    count = sum(
309                        it.stack_count
310                        for idx in range(self._inventory.capacity)
311                        if (it := self._inventory.get(idx)) and it.is_consumable and it.template_key == template_key
312                    )
313                    if count > 0:
314                        renderer.text(str(count), (x + slot_size - 14, sy + 2), fill=(1.0, 1.0, 1.0), scale=0.65)
315            elif entry:
316                skill_id = entry
317                skill = self._skill_tree.get_skill(skill_id)
318                if skill:
319                    ready = self._skill_tree.is_ready(skill_id)
320                    cd = self._skill_tree.get_cooldown(skill_id)
321                    max_cd = skill.get("cooldown", 1)
322
323                    # Background
324                    bg = (0.15, 0.15, 0.22, 0.9) if ready else (0.1, 0.1, 0.12, 0.9)
325                    renderer.rect((x, sy), (slot_size, slot_size), fill=bg)
326
327                    # Border (glow when ready)
328                    if ready:
329                        border_c = (0.4, 0.5, 0.7, 0.8)
330                    else:
331                        border_c = (0.2, 0.2, 0.25, 0.6)
332                    renderer.rect((x, sy), (slot_size, 1), fill=border_c)
333                    renderer.rect((x, sy + slot_size - 1), (slot_size, 1), fill=border_c)
334                    renderer.rect((x, sy), (1, slot_size), fill=border_c)
335                    renderer.rect((x + slot_size - 1, sy), (1, slot_size), fill=border_c)
336
337                    # Drawn icon
338                    branch = skill.get("branch", "mage")
339                    icon_c = (0.8, 0.8, 0.9, 1.0) if ready else (0.3, 0.3, 0.4, 0.5)
340                    icx = x + slot_size / 2
341                    icy = sy + slot_size / 2 - 4
342                    _draw_hotbar_icon(renderer, icx, icy, branch, ready, icon_c)
343
344                    # Skill name abbreviation
345                    abbr = skill["name"][:4]
346                    text_c = (0.7, 0.7, 0.8, 1.0) if ready else (0.35, 0.35, 0.4, 0.5)
347                    renderer.text(abbr, (x + 2, sy + slot_size - 14), fill=text_c, scale=0.65)
348
349                    # Clock-wipe cooldown overlay
350                    if cd > 0:
351                        fill = cd / max(0.01, max_cd)
352                        fill_h = int(slot_size * fill)
353                        renderer.rect((x, sy + slot_size - fill_h), (slot_size, fill_h), fill=(0.05, 0.05, 0.08, 0.7))
354
355                    # Activation flash (when cooldown just started)
356                    if cd > 0 and cd > max_cd - 0.2:
357                        flash_a = (max_cd - cd) / 0.2
358                        renderer.rect((x, sy), (slot_size, slot_size), fill=(1.0, 1.0, 1.0, 0.3 * flash_a))
359            else:
360                # Empty slot -- dotted border
361                renderer.rect((x, sy), (slot_size, slot_size), fill=(0.1, 0.1, 0.12, 0.5))
362                dash_len = 4
363                gap = 6
364                for d in range(0, slot_size, dash_len + gap):
365                    dc = (0.25, 0.25, 0.3, 0.4)
366                    renderer.rect((x + d, sy), (min(dash_len, slot_size - d), 1), fill=dc)
367                    renderer.rect((x + d, sy + slot_size - 1), (min(dash_len, slot_size - d), 1), fill=dc)
368                    renderer.rect((x, sy + d), (1, min(dash_len, slot_size - d)), fill=dc)
369                    renderer.rect((x + slot_size - 1, sy + d), (1, min(dash_len, slot_size - d)), fill=dc)
370
371            # Key label
372            renderer.text(str(i + 1), (x + slot_size - 10, sy - 12), fill=(0.5, 0.5, 0.5), scale=0.7)
373
374    def _draw_rounded_bar(self, renderer, x, y, current, maximum, colour, label, segments=0):
375        """Draw a bar with rounded-feel ends and optional segment marks."""
376        renderer.rect((x, y), (BAR_W, BAR_H), fill=(0.12, 0.12, 0.12, 0.8))
377        renderer.rect((x, y), (BAR_W, 1), fill=(0.25, 0.25, 0.28, 0.6))
378        renderer.rect((x, y + BAR_H - 1), (BAR_W, 1), fill=(0.08, 0.08, 0.08, 0.8))
379
380        ratio = max(0, min(1, current / max(1, maximum)))
381        fill_w = int(BAR_W * ratio)
382        if fill_w > 0:
383            renderer.rect((x, y + 1), (fill_w, BAR_H - 2), fill=colour)
384            r, g, b, a = colour
385            renderer.rect((x, y + 1), (fill_w, 2), fill=(min(1, r + 0.2), min(1, g + 0.2), min(1, b + 0.2), a * 0.4))
386
387        if segments > 1:
388            for s in range(1, segments):
389                sx = x + int(BAR_W * s / segments)
390                renderer.rect((sx, y), (1, BAR_H), fill=(0.0, 0.0, 0.0, 0.3))
391
392        renderer.text(f"{label}: {int(current)}/{int(maximum)}", (x + 4, y + 2), fill=(1.0, 1.0, 1.0), scale=0.9)
393
394
395def _draw_hotbar_icon(renderer, cx: float, cy: float, branch: str, ready: bool, colour: tuple):
396    """Draw a hotbar skill icon based on branch."""
397    r, g, b = colour[0], colour[1], colour[2]
398    a = colour[3] if len(colour) > 3 else 1.0
399    if branch == "warrior":
400        renderer.rect((cx - 1, cy - 6), (2, 12), fill=(r, g, b, a))
401        renderer.rect((cx - 4, cy - 1), (8, 2), fill=(r * 0.7, g * 0.7, b * 0.7, a))
402    elif branch == "ranger":
403        renderer.rect((cx - 1, cy - 5), (2, 10), fill=(r, g, b, a))
404        renderer.polygon([(cx, cy - 7), (cx - 3, cy - 3), (cx + 3, cy - 3)], fill=(r, g, b, a))
405    else:
406        renderer.circle((cx, cy), 4, fill=(r, g, b, a))
407        if ready:
408            renderer.circle((cx, cy), 2, fill=(1.0, 1.0, 1.0, 0.3))
409
410
411class _MinimapBackground(Node2D):
412    """Static minimap: frame, explored-tile loop, and reveal-gated markers.
413
414    NOT ``dynamic``. The ~2500-rect explored-tile fill only changes when fog
415    reveals a new tile, so this node is retained and re-emits exactly once per
416    reveal (``PlayerHUD.notify_fog_changed`` -> ``queue_redraw``), on level load
417    (``configure``), and on resize (``set_screen_w``) -- never per frame.
418    """
419
420    dynamic = False
421
422    def __init__(self, **kwargs):
423        super().__init__(name="MinimapBg", z_index=0, **kwargs)  # below the player dot
424        self._dungeon_data = None
425        self._fog = None
426        self._screen_w = 1280
427
428    def configure(self, dungeon_data, fog, screen_w):
429        """(Re)arm for a new dungeon, or clear (``None``) when back in town."""
430        self._dungeon_data = dungeon_data
431        self._fog = fog
432        self._screen_w = screen_w
433        self.queue_redraw()
434
435    def set_screen_w(self, screen_w):
436        self._screen_w = screen_w
437        self.queue_redraw()
438
439    def on_draw(self, renderer):
440        data = self._dungeon_data
441        fog = self._fog
442        if not (data and fog):
443            return
444
445        mx = self._screen_w - MINIMAP_SIZE - 10
446        my = 10
447        renderer.rect((mx - 3, my - 3), (MINIMAP_SIZE + 6, MINIMAP_SIZE + 6), fill=(0.25, 0.22, 0.18, 0.8))
448        renderer.rect((mx - 2, my - 2), (MINIMAP_SIZE + 4, MINIMAP_SIZE + 4), fill=(0.0, 0.0, 0.0, 0.7))
449
450        scale_x = MINIMAP_SIZE / max(1, data.width)
451        scale_y = MINIMAP_SIZE / max(1, data.height)
452
453        for gy in range(data.height):
454            for gx in range(data.width):
455                state = fog.get_state(gx, gy)
456                if state == 0:
457                    continue
458                cell = data.grid[gy][gx]
459                if cell == -1:
460                    continue
461
462                px = mx + gx * scale_x
463                py = my + gy * scale_y
464                w = max(1, scale_x)
465                h = max(1, scale_y)
466
467                alpha = 0.3 if state == 1 else 0.8
468
469                if data.is_walkable(gx, gy):
470                    renderer.rect((px, py), (w, h), fill=(0.5, 0.5, 0.5, alpha))
471                else:
472                    renderer.rect((px, py), (w, h), fill=(0.3, 0.25, 0.2, alpha))
473
474        ex, ey = data.entrance
475        epx = mx + ex * scale_x
476        epy = my + ey * scale_y
477        renderer.rect((epx - 2, epy - 2), (4, 4), fill=(0.2, 0.9, 0.3, 0.9))
478
479        xx, xy = data.exit
480        if fog.get_state(xx, xy) >= 1:  # Only show exit on minimap once explored
481            xpx = mx + xx * scale_x
482            xpy = my + xy * scale_y
483            renderer.rect((xpx - 2, xpy - 2), (4, 4), fill=(0.9, 0.2, 0.15, 0.9))
484
485        for obj in data.special_objects:
486            if obj.get("type") == "chest":
487                ogx, ogy = obj.get("gx", 0), obj.get("gy", 0)
488                if fog.get_state(ogx, ogy) >= 1:
489                    opx = mx + ogx * scale_x
490                    opy = my + ogy * scale_y
491                    renderer.rect((opx - 1, opy - 1), (3, 3), fill=(1.0, 0.85, 0.2, 0.8))
492
493        for obj in data.special_objects:
494            if obj.get("type") == "waypoint":
495                ogx, ogy = obj.get("gx", 0), obj.get("gy", 0)
496                if fog.get_state(ogx, ogy) >= 1:
497                    opx = mx + ogx * scale_x
498                    opy = my + ogy * scale_y
499                    renderer.rect((opx - 2, opy - 2), (4, 4), fill=(0.3, 0.8, 1.0, 0.8))
500
501        renderer.text("N", (mx + MINIMAP_SIZE // 2 - 3, my - 12), fill=(0.7, 0.7, 0.7), scale=0.6)
502        renderer.text("S", (mx + MINIMAP_SIZE // 2 - 3, my + MINIMAP_SIZE + 3), fill=(0.7, 0.7, 0.7), scale=0.6)
503        renderer.text("W", (mx - 12, my + MINIMAP_SIZE // 2 - 4), fill=(0.7, 0.7, 0.7), scale=0.6)
504        renderer.text("E", (mx + MINIMAP_SIZE + 4, my + MINIMAP_SIZE // 2 - 4), fill=(0.7, 0.7, 0.7), scale=0.6)
505
506
507class _MinimapForeground(Node2D):
508    """The moving player dot only. NOT ``dynamic``: ``PlayerHUD`` marks it
509    render-dirty every frame via ``queue_redraw`` (so a just-configured dot's
510    geometry uploads at once). ``z_index = 1`` puts it on top of the background."""
511
512    def __init__(self, **kwargs):
513        super().__init__(name="MinimapFg", z_index=1, **kwargs)  # above the explored tiles
514        self._player = None
515        self._dungeon_data = None
516        self._screen_w = 1280
517
518    def configure(self, player, dungeon_data, screen_w):
519        self._player = player
520        self._dungeon_data = dungeon_data
521        self._screen_w = screen_w
522
523    def on_draw(self, renderer):
524        # Always emit exactly ONE rect so the dynamic op count is stable: a
525        # variable (0<->1) count makes the retained pipeline add/drop the item row
526        # without re-uploading its geometry until the next full re-collect, which
527        # leaves the dot invisible for a stationary player just after a level load.
528        # When not yet configured (town / pre-setup) the dot is parked off-screen.
529        player = self._player
530        data = self._dungeon_data
531        if player is not None and data is not None:
532            mx = self._screen_w - MINIMAP_SIZE - 10
533            my = 10
534            scale_x = MINIMAP_SIZE / max(1, data.width)
535            scale_y = MINIMAP_SIZE / max(1, data.height)
536            ppx = mx + (player.position.x / TILE_SIZE) * scale_x
537            ppy = my + (player.position.y / TILE_SIZE) * scale_y
538        else:
539            ppx = ppy = -1000.0
540        renderer.rect((ppx - 1, ppy - 1), (3, 3), fill=(0.2, 0.8, 1.0, 1.0))