|
| 1 | +"""The pygame window: main loop, tab and layout switching, letterboxed scaling. |
| 2 | +
|
| 3 | +Layouts draw on a fixed body (CANVAS_W x CANVAS_H) under a status row; the window scales both to |
| 4 | +fit and never shrinks below them. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +from pathlib import Path |
| 10 | + |
| 11 | +import pygame |
| 12 | + |
| 13 | +from . import panels |
| 14 | +from . import widgets as W |
| 15 | +from .assets import AssetLibrary |
| 16 | +from .calibration import Calibration |
| 17 | +from .layouts import AIModLayout, QuestLayout, QuestPrepLayout, VillageLayout |
| 18 | +from .layouts.base import Layout, Reader, Writer |
| 19 | +from .reader import QUEST_MAP |
| 20 | +from .state import Context, GameSnapshot |
| 21 | +from .theme import CANVAS_H, CANVAS_W, STATUS_H, C, font |
| 22 | + |
| 23 | +WINDOW_TITLE = "MHFU Live HUD" |
| 24 | +GAMEPLAY = {Context.VILLAGE, Context.QUEST} |
| 25 | + |
| 26 | +# LIVE follows the game context, QUEST_PREP stages spawn edits, AI_MOD inspects the AI cells |
| 27 | +TAB_LIVE = "live" |
| 28 | +TAB_QUEST_PREP = "quest_prep" |
| 29 | +TAB_AI_MOD = "ai_mod" |
| 30 | +TABS = (TAB_LIVE, TAB_QUEST_PREP, TAB_AI_MOD) |
| 31 | + |
| 32 | +_HELP = [ |
| 33 | + ("F1", "toggle this help"), |
| 34 | + ("F2 / F3 / F4", "layout: auto / force village / force quest"), |
| 35 | + ("F11", "toggle fullscreen"), |
| 36 | + ("ESC", "close overlay, or quit"), |
| 37 | + ("TAB", "cycle tabs LIVE -> QUEST-PREP -> AI-MOD"), |
| 38 | + ("", ""), |
| 39 | + ("B", "toggle bag panel (quest)"), |
| 40 | + ("] / [", "select next / previous monster (quest)"), |
| 41 | + ("ENTER", "open / close monster detail page"), |
| 42 | + ("TAB", "in monster detail: cycle STATS -> AI"), |
| 43 | + ("M", "cycle map image (quest)"), |
| 44 | + ("", ""), |
| 45 | + ("arrows", "navigate quest-prep panels + rows"), |
| 46 | + ("+ / -", "adjust selected staged edit"), |
| 47 | + ("T", "staged edit kind: size / species / HP"), |
| 48 | + ("ENTER", "stage / toggle edit"), |
| 49 | + ("X / DEL", "remove staged edit"), |
| 50 | + ("M", "master switch for writing the edits"), |
| 51 | + ("", ""), |
| 52 | + ("] / [", "AI-MOD: next / previous monster"), |
| 53 | + ("up / down", "AI-MOD: scroll the cells"), |
| 54 | + ("", ""), |
| 55 | + ("C", "toggle calibration mode"), |
| 56 | + ("+ / -", "calib: map scale"), |
| 57 | + (", / .", "calib: map rotation"), |
| 58 | + ("T", "calib: player-centered / fixed mode"), |
| 59 | + ("arrows", "calib: nudge map origin or village marker"), |
| 60 | + ("S", "calib: save calibration.json"), |
| 61 | +] |
| 62 | + |
| 63 | + |
| 64 | +class HUDApp: |
| 65 | + def __init__( |
| 66 | + self, |
| 67 | + reader: Reader, |
| 68 | + calib: Calibration | None = None, |
| 69 | + *, |
| 70 | + writer: Writer | None = None, |
| 71 | + assets: Path | None = None, |
| 72 | + fullscreen: bool = False, |
| 73 | + ) -> None: |
| 74 | + """`writer` None is `--read-only`: QUEST_PREP's edits are off.""" |
| 75 | + self.reader = reader |
| 76 | + self.writer = writer |
| 77 | + # not pygame.init(): its joystick subsystem races PPSSPP for the gamepad on macOS |
| 78 | + pygame.display.init() |
| 79 | + pygame.font.init() |
| 80 | + pygame.display.set_caption(WINDOW_TITLE) |
| 81 | + self._windowed_size = (CANVAS_W, STATUS_H + CANVAS_H) |
| 82 | + self.fullscreen = fullscreen |
| 83 | + self.screen = self._make_window(self._windowed_size, fullscreen) |
| 84 | + self.canvas = pygame.Surface((CANVAS_W, STATUS_H + CANVAS_H)).convert() |
| 85 | + self.body = self.canvas.subsurface((0, STATUS_H, CANVAS_W, CANVAS_H)) |
| 86 | + """What the layouts draw on.""" |
| 87 | + self.clock = pygame.time.Clock() |
| 88 | + self.assets = AssetLibrary(assets) |
| 89 | + self.calib = calib or Calibration() |
| 90 | + self.layouts: dict[Context, Layout] = { |
| 91 | + Context.VILLAGE: VillageLayout(self.assets, self.calib), |
| 92 | + Context.QUEST: QuestLayout(self.assets, self.calib, reader=reader), |
| 93 | + } |
| 94 | + self.quest_prep = QuestPrepLayout(self.assets, self.calib, writer=writer) |
| 95 | + self.ai_mod = AIModLayout(self.assets, self.calib) |
| 96 | + self.forced: Context | None = None |
| 97 | + self.tab = TAB_LIVE |
| 98 | + self._tab_user_pinned = False |
| 99 | + self._last_quest_prep_eligible = False |
| 100 | + self.show_help = False |
| 101 | + self.running = True |
| 102 | + self._blit_off = (0, 0) |
| 103 | + self._blit_scale = 1.0 |
| 104 | + |
| 105 | + # --- window --- |
| 106 | + |
| 107 | + def _make_window(self, size: tuple[int, int], fullscreen: bool) -> pygame.Surface: |
| 108 | + if fullscreen: |
| 109 | + return pygame.display.set_mode((0, 0), pygame.FULLSCREEN) |
| 110 | + w, h = max(CANVAS_W, size[0]), max(STATUS_H + CANVAS_H, size[1]) |
| 111 | + return pygame.display.set_mode((w, h), pygame.RESIZABLE) |
| 112 | + |
| 113 | + def _toggle_fullscreen(self) -> None: |
| 114 | + self.fullscreen = not self.fullscreen |
| 115 | + self.screen = self._make_window(self._windowed_size, self.fullscreen) |
| 116 | + |
| 117 | + # --- loop --- |
| 118 | + |
| 119 | + def run(self) -> None: |
| 120 | + try: |
| 121 | + while self.running: |
| 122 | + snap = self.reader.snapshot |
| 123 | + self._absorb(snap) |
| 124 | + self._handle_events(snap) |
| 125 | + self._render(snap) |
| 126 | + self.clock.tick(60) |
| 127 | + finally: |
| 128 | + pygame.quit() |
| 129 | + |
| 130 | + def shoot(self, out: Path) -> list[Path]: |
| 131 | + """Render every tab once to `out/<tab>.png`.""" |
| 132 | + out.mkdir(parents=True, exist_ok=True) |
| 133 | + snap = self.reader.snapshot |
| 134 | + paths = [] |
| 135 | + for tab in TABS: |
| 136 | + self.tab = tab |
| 137 | + self._render(snap) |
| 138 | + path = out / f"{tab}.png" |
| 139 | + pygame.image.save(self.canvas, str(path)) |
| 140 | + paths.append(path) |
| 141 | + return paths |
| 142 | + |
| 143 | + def _absorb(self, snap: GameSnapshot) -> None: |
| 144 | + """Keep the area_index pairs the reader learnt in the user's calibration.""" |
| 145 | + if snap.learnt_sections and self.calib.learn(QUEST_MAP, snap.learnt_sections): |
| 146 | + self.calib.save() |
| 147 | + |
| 148 | + def _context(self, snap: GameSnapshot) -> Context: |
| 149 | + return self.forced if self.forced is not None else snap.context |
| 150 | + |
| 151 | + def _handle_events(self, snap: GameSnapshot) -> None: |
| 152 | + self._maybe_auto_switch_tab(snap) |
| 153 | + layout = self._active_layout(snap) |
| 154 | + for ev in pygame.event.get(): |
| 155 | + if ev.type == pygame.QUIT: |
| 156 | + self.running = False |
| 157 | + elif ev.type == pygame.VIDEORESIZE and not self.fullscreen: |
| 158 | + self._windowed_size = (ev.w, ev.h) |
| 159 | + self.screen = self._make_window(self._windowed_size, False) |
| 160 | + elif ev.type == pygame.MOUSEBUTTONDOWN and ev.button == 1: |
| 161 | + if layout is not None: |
| 162 | + layout.handle_click(self._to_canvas(ev.pos), snap) |
| 163 | + elif ev.type == pygame.MOUSEMOTION: |
| 164 | + if layout is not None: |
| 165 | + layout.handle_motion(self._to_canvas(ev.pos), snap) |
| 166 | + elif ev.type == pygame.KEYDOWN: |
| 167 | + self._handle_key(ev.key, snap, layout) |
| 168 | + |
| 169 | + def _quest_prep_eligible(self, snap: GameSnapshot) -> bool: |
| 170 | + """Outside a quest with an area index set: the engine prefetches the next quest's.""" |
| 171 | + return snap.context != Context.QUEST and snap.area_index not in (0, -1) |
| 172 | + |
| 173 | + def _maybe_auto_switch_tab(self, snap: GameSnapshot) -> None: |
| 174 | + """Flip to QUEST_PREP when a quest is queued and back when it lapses, unless the user |
| 175 | + picked a tab; entering the quest clears that pick.""" |
| 176 | + eligible = self._quest_prep_eligible(snap) |
| 177 | + if snap.context == Context.QUEST: |
| 178 | + self._tab_user_pinned = False |
| 179 | + if not self._tab_user_pinned: |
| 180 | + if eligible and not self._last_quest_prep_eligible: |
| 181 | + self.tab = TAB_QUEST_PREP |
| 182 | + elif not eligible and self._last_quest_prep_eligible: |
| 183 | + self.tab = TAB_LIVE |
| 184 | + self._last_quest_prep_eligible = eligible |
| 185 | + |
| 186 | + def _active_layout(self, snap: GameSnapshot) -> Layout | None: |
| 187 | + """The layout that takes keys and clicks: the tab's, or LIVE's for the context.""" |
| 188 | + if self.tab == TAB_AI_MOD: |
| 189 | + return self.ai_mod |
| 190 | + if self.tab == TAB_QUEST_PREP: |
| 191 | + return self.quest_prep |
| 192 | + return self.layouts.get(self._context(snap)) |
| 193 | + |
| 194 | + def _handle_key(self, key: int, snap: GameSnapshot, layout: Layout | None) -> None: |
| 195 | + if key == pygame.K_F1: |
| 196 | + self.show_help = not self.show_help |
| 197 | + elif key == pygame.K_F2: |
| 198 | + self.forced = None |
| 199 | + elif key == pygame.K_F3: |
| 200 | + self.forced = Context.VILLAGE |
| 201 | + elif key == pygame.K_F4: |
| 202 | + self.forced = Context.QUEST |
| 203 | + elif key == pygame.K_F11: |
| 204 | + self._toggle_fullscreen() |
| 205 | + elif self.show_help and key == pygame.K_ESCAPE: |
| 206 | + self.show_help = False |
| 207 | + # the layout sees TAB first: the quest detail page cycles its sub-pages with it |
| 208 | + elif layout is not None and layout.handle_key(key, snap): |
| 209 | + return |
| 210 | + elif key == pygame.K_TAB: |
| 211 | + i = TABS.index(self.tab) if self.tab in TABS else 0 |
| 212 | + self.tab = TABS[(i + 1) % len(TABS)] |
| 213 | + self._tab_user_pinned = True |
| 214 | + elif key == pygame.K_ESCAPE: |
| 215 | + self.running = False |
| 216 | + |
| 217 | + # --- render --- |
| 218 | + |
| 219 | + def _render(self, snap: GameSnapshot) -> None: |
| 220 | + ctx = self._context(snap) |
| 221 | + if self.tab == TAB_AI_MOD: |
| 222 | + self.ai_mod.render(self.body, snap) |
| 223 | + elif self.tab == TAB_QUEST_PREP: |
| 224 | + self.quest_prep.render(self.body, snap) |
| 225 | + else: |
| 226 | + layout = self.layouts.get(ctx) |
| 227 | + if layout is not None and ( |
| 228 | + self.forced is not None or (snap.connected and ctx in GAMEPLAY) |
| 229 | + ): |
| 230 | + layout.render(self.body, snap) |
| 231 | + else: |
| 232 | + panels.draw_status_screen(self.body, snap) |
| 233 | + self._status_row(snap, ctx) |
| 234 | + if self.show_help: |
| 235 | + self._draw_help() |
| 236 | + self._present() |
| 237 | + |
| 238 | + def _status_row(self, snap: GameSnapshot, ctx: Context) -> None: |
| 239 | + """Connection, context, tab and rates; a badge while edits would be written.""" |
| 240 | + self.canvas.fill(C.BG, (0, 0, CANVAS_W, STATUS_H)) |
| 241 | + mid = STATUS_H // 2 |
| 242 | + pygame.draw.circle(self.canvas, C.OK if snap.connected else C.ERR, (14, mid), 5) |
| 243 | + tab = {TAB_AI_MOD: "[AI-MOD]", TAB_QUEST_PREP: "[PREP]"}.get(self.tab, "[LIVE]") |
| 244 | + parts = [ctx.value.upper(), tab] |
| 245 | + if self.forced is not None: |
| 246 | + parts.append("[FORCED]") |
| 247 | + parts += [f"{self.clock.get_fps():.0f}fps", f"poll {snap.poll_latency_ms:.0f}ms"] |
| 248 | + W.text(self.canvas, " · ".join(parts), (26, mid - 8), size=12, color=C.TEXT_DIM) |
| 249 | + help_at = (CANVAS_W - 10, mid - 8) |
| 250 | + right = W.text(self.canvas, "F1 help", help_at, 12, C.TEXT_DIM, align="right") |
| 251 | + status = self.writer.status if self.writer else None |
| 252 | + if status is not None and status.active: |
| 253 | + n = sum(1 for st in status.staged if st.edit.enabled) |
| 254 | + label = f"WRITES ON · {n} edit{'s' * (n != 1)}" |
| 255 | + w, h = font(12, True).size(label) |
| 256 | + badge = pygame.Rect(0, 1, w + 12, STATUS_H - 2) |
| 257 | + badge.right = right.x - 14 |
| 258 | + pygame.draw.rect(self.canvas, C.WARN, badge, border_radius=4) |
| 259 | + at = (badge.centerx, badge.y + (badge.h - h) // 2) |
| 260 | + W.text(self.canvas, label, at, 12, C.BG, bold=True, align="center", shadow=False) |
| 261 | + |
| 262 | + def _draw_help(self) -> None: |
| 263 | + veil = pygame.Surface((CANVAS_W, CANVAS_H), pygame.SRCALPHA) |
| 264 | + veil.fill((0, 0, 0, 200)) |
| 265 | + self.body.blit(veil, (0, 0)) |
| 266 | + box = pygame.Rect(CANVAS_W // 2 - 300, 40, 600, 464) |
| 267 | + W.panel(self.body, box, fill=C.PANEL_HI, border=C.ACCENT) |
| 268 | + W.text( |
| 269 | + self.body, |
| 270 | + "MHFU LIVE HUD — CONTROLS", |
| 271 | + (box.x + 20, box.y + 16), |
| 272 | + size=18, |
| 273 | + color=C.ACCENT, |
| 274 | + bold=True, |
| 275 | + ) |
| 276 | + y = box.y + 48 |
| 277 | + for keys, desc in _HELP: |
| 278 | + if keys: |
| 279 | + W.text(self.body, keys, (box.x + 24, y), size=13, color=C.SELECT, bold=True) |
| 280 | + W.text(self.body, desc, (box.x + 180, y), size=13, color=C.TEXT) |
| 281 | + y += 14 if keys else 6 |
| 282 | + W.text( |
| 283 | + self.body, |
| 284 | + "only QUEST-PREP's staged edits write game memory (none with --read-only)", |
| 285 | + (box.x + 20, box.bottom - 24), |
| 286 | + size=11, |
| 287 | + color=C.TEXT_FAINT, |
| 288 | + ) |
| 289 | + |
| 290 | + def _present(self) -> None: |
| 291 | + ww, wh = self.screen.get_size() |
| 292 | + cw, ch = self.canvas.get_size() |
| 293 | + self._blit_scale = min(ww / cw, wh / ch) |
| 294 | + sw, sh = int(cw * self._blit_scale), int(ch * self._blit_scale) |
| 295 | + self._blit_off = ((ww - sw) // 2, (wh - sh) // 2) |
| 296 | + self.screen.fill((0, 0, 0)) |
| 297 | + self.screen.blit(pygame.transform.smoothscale(self.canvas, (sw, sh)), self._blit_off) |
| 298 | + pygame.display.flip() |
| 299 | + |
| 300 | + def _to_canvas(self, pos: tuple[int, int]) -> tuple[float, float]: |
| 301 | + """A window position in the layouts' coordinates.""" |
| 302 | + ox, oy = self._blit_off |
| 303 | + s = self._blit_scale or 1.0 |
| 304 | + return (pos[0] - ox) / s, (pos[1] - oy) / s - STATUS_H |
0 commit comments