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)