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))