import pygame from assets import icon as icon_path from engine import collection from engine.plants import POOL, RARITY_COLORS, plant_rarity from engine import runes from ui.scaling import fit_surface, load_rune_icon, make_window, request_quit, to_logical WIN_W, WIN_H = 1040, 720 BG = (24, 30, 24) TEXT = (230, 235, 230) MUTED = (150, 155, 150) HINT = (255, 220, 120) PANEL = (32, 38, 32) BTN = (60, 90, 60) BTN_HOVER = (85, 125, 85) ACCENT = (120, 220, 190) DANGER = (150, 70, 70) SLOT_BORDER = (90, 110, 90) SLOT_SIZE = 64 def draw_rune_chips(screen, get_icon, colors, x, y, size=14, gap=2, max_chips=3): """Draw small rune icons for the given colors starting at (x, y). `get_icon(color)` returns a surface (already sized) or None. Used to show equipped runes at a glance.""" for i, color in enumerate(colors[:max_chips]): ic = get_icon(color) if ic: screen.blit(ic, (x + i * (size + gap), y)) class RuneScreen: def __init__(self, plant: str): pygame.init() self.plant = plant self.window, self.scale = make_window(WIN_W, WIN_H) self.screen = pygame.Surface((WIN_W, WIN_H)) pygame.display.set_caption(f"Plants — Runes · {plant}") self.clock = pygame.time.Clock() self.font = pygame.font.SysFont("segoeui", 30) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.small = pygame.font.SysFont("segoeui", 16) self.tiny = pygame.font.SysFont("segoeui", 13) self.back_rect = pygame.Rect(16, 16, 90, 34) self.info_rect = pygame.Rect(WIN_W - 100, 16, 84, 34) self.mouse_pos = (0, 0) self.selected = None # rune id selected from the inventory self.confirm = None # {"kind","slot","rune",...} prompt self.confirm_ok = pygame.Rect(0, 0, 0, 0) self.confirm_no = pygame.Rect(0, 0, 0, 0) self.info = False # rune-effects info popup self.equip_msg = "" # transient message (e.g. rune-type limit) self.equip_msg_until = 0 self.filter_tier = 0 # 0 all, 1 tier 1, 2 tier 2 self.filter_color = None # None all, else a rune color self.sort_best = False # sort by proportional roll self.filter_rects = [] # (kind, value, rect) self.sort_rect = pygame.Rect(WIN_W - 170, 284, 150, 24) self.slot_rects = [] # (slot_index, rect) self.inv_rects = [] # (rune_id, rect) self.scroll = 0 self.scroll_max = 0 self._icons = {} def _icon_for(self, color): if color not in self._icons: self._icons[color] = load_rune_icon(color, 40) return self._icons[color] def run(self): running = True while running: self.clock.tick(60) self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale) for event in pygame.event.get(): if event.type == pygame.QUIT: request_quit() running = False elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: pos = to_logical(event.pos, self.scale) if self.back_rect.collidepoint(pos): running = False continue if self.info: self.info = False continue if self.info_rect.collidepoint(pos): self.info = True continue if self.confirm is not None: if self.confirm_ok.collidepoint(pos): if self.confirm["kind"] == "move": collection.equip_rune(self.plant, self.confirm["slot"], self.confirm["rune"]["id"]) self.selected = None else: collection.unequip_rune(self.plant, self.confirm["slot"]) self.confirm = None elif self.confirm_no.collidepoint(pos): self.confirm = None continue self._click(pos) elif event.type == pygame.MOUSEWHEEL: self.scroll = max(0, min(self.scroll_max, self.scroll - event.y * 44)) self._draw() pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) pygame.display.flip() def _click(self, pos): if self._click_filter(pos): return equipped = collection.equipped_runes(self.plant) inventory = collection.rune_inventory() by_id = {r["id"]: r for r in inventory} for slot, rect in self.slot_rects: if rect.collidepoint(pos): if self.selected is not None: rune = by_id.get(self.selected) owner = collection.rune_location(self.selected) if owner and owner != self.plant: self.confirm = {"kind": "move", "slot": slot, "rune": rune, "from": owner} else: reason = collection.equip_rune(self.plant, slot, self.selected) self.equip_msg = reason or "" self.equip_msg_until = (pygame.time.get_ticks() + 2000) if reason else 0 self.selected = None elif slot < len(equipped) and equipped[slot] is not None: self.confirm = {"kind": "remove", "slot": slot, "rune": by_id.get(equipped[slot])} return for rune_id, rect in self.inv_rects: if rect.collidepoint(pos): self.selected = None if self.selected == rune_id else rune_id return def _click_filter(self, pos): for kind, val, rect in self.filter_rects: if rect.collidepoint(pos): if kind == "tier": self.filter_tier = val elif kind == "color": self.filter_color = val return True if self.sort_rect.collidepoint(pos): self.sort_best = not self.sort_best return True return False def _displayed_runes(self, inventory): shown = [r for r in inventory if (self.filter_tier == 0 or runes.tier(r["color"]) == self.filter_tier) and (self.filter_color is None or r["color"] == self.filter_color)] if self.sort_best: shown.sort(key=runes.roll_ratio, reverse=True) return shown def _small_icon(self, color): key = ("small", color) if key not in self._icons: ic = self._icon_for(color) self._icons[key] = fit_surface(ic, 22) if ic else None return self._icons[key] def _draw_filters(self): self.filter_rects = [] y, h = 284, 24 tl = self.tiny.render("Tier:", True, MUTED) self.screen.blit(tl, (60, y + 5)) x = 92 for val, label in ((0, "All"), (1, "T1"), (2, "T2")): active = self.filter_tier == val rect = pygame.Rect(x, y, 30, h) pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), rect, border_radius=4) pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), rect, 1, border_radius=4) t = self.tiny.render(label, True, TEXT if active else MUTED) self.screen.blit(t, t.get_rect(center=rect.center)) self.filter_rects.append(("tier", val, rect)) x += 36 ty = self.tiny.render("Type:", True, MUTED) self.screen.blit(ty, (210, y + 5)) x = 244 all_rect = pygame.Rect(x, y, 36, h) active = self.filter_color is None pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), all_rect, border_radius=4) pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), all_rect, 1, border_radius=4) t = self.tiny.render("All", True, TEXT if active else MUTED) self.screen.blit(t, t.get_rect(center=all_rect.center)) self.filter_rects.append(("color", None, all_rect)) x += 42 for color in runes.RUNE_COLORS: rect = pygame.Rect(x, y, 24, h) active = self.filter_color == color pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4) pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), rect, 1 if active else 2, border_radius=4) sw = self._small_icon(color) if sw: self.screen.blit(sw, sw.get_rect(center=rect.center)) self.filter_rects.append(("color", color, rect)) x += 28 active = self.sort_best pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), self.sort_rect, border_radius=4) pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), self.sort_rect, 1, border_radius=4) sl = self.tiny.render("Sort: best roll" if active else "Sort: default", True, TEXT if active else MUTED) self.screen.blit(sl, sl.get_rect(center=self.sort_rect.center)) def _draw(self): self.slot_rects = [] self.inv_rects = [] self.screen.fill(BG) pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) bt = self.small.render("Back", True, TEXT) self.screen.blit(bt, bt.get_rect(center=self.back_rect.center)) ihov = self.info_rect.collidepoint(self.mouse_pos) pygame.draw.rect(self.screen, (90, 120, 90) if (self.info or ihov) else BTN, self.info_rect, border_radius=6) il = self.tiny.render("Rune info", True, TEXT) self.screen.blit(il, il.get_rect(center=self.info_rect.center)) spec = POOL[self.plant] rar = plant_rarity(self.plant) title = self.font.render(self.plant, True, RARITY_COLORS.get(rar, TEXT)) self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 44))) sub = self.small.render(f"Rune slots — {spec['role']}", True, MUTED) self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 76))) slots = collection.rune_slots(self.plant) equipped = collection.equipped_runes(self.plant) inventory = collection.rune_inventory() by_id = {r["id"]: r for r in inventory} # --- slots ----------------------------------------------------- st = self.bold.render("Rune slots", True, HINT) self.screen.blit(st, (60, 110)) locked = self.tiny.render( "Evolve to unlock more slots (permanent)", True, MUTED) self.screen.blit(locked, (200, 116)) total_w = slots * SLOT_SIZE + (slots - 1) * 20 x0 = (WIN_W - total_w) // 2 y = 140 for i in range(slots): rect = pygame.Rect(x0 + i * (SLOT_SIZE + 20), y, SLOT_SIZE, SLOT_SIZE) pygame.draw.rect(self.screen, PANEL, rect, border_radius=10) pygame.draw.rect(self.screen, SLOT_BORDER, rect, 2, border_radius=10) rune = by_id.get(equipped[i]) if i < len(equipped) else None if rune: ic = self._icon_for(rune["color"]) if ic: self.screen.blit(ic, ic.get_rect(center=rect.center)) else: num = self.bold.render(str(i + 1), True, MUTED) num.set_alpha(70) # faint hint that the slot is empty self.screen.blit(num, num.get_rect(center=rect.center)) lbl = self.tiny.render(f"Slot {i + 1}", True, MUTED) self.screen.blit(lbl, lbl.get_rect(center=(rect.centerx, rect.bottom + 14))) self.slot_rects.append((i, rect)) # --- equipped-effects summary (right of the slots) ------------ equipped_runes = [by_id[e] for e in equipped if e in by_id] line_h = 18 panel = pygame.Rect(x0 + total_w + 40, y, 240, 26 + line_h * max(1, len(equipped_runes)) + 8) pygame.draw.rect(self.screen, PANEL, panel, border_radius=8) pygame.draw.rect(self.screen, (60, 70, 60), panel, 1, border_radius=8) t = self.bold.render("Equipped effects", True, HINT) self.screen.blit(t, (panel.x + 8, panel.y + 6)) py = panel.y + 28 if equipped_runes: for rune in equipped_runes: eff = self.tiny.render( runes.effect_text(rune["color"], rune.get("pct", 30)), True, TEXT) self.screen.blit(eff, (panel.x + 8, py)) py += line_h else: e = self.tiny.render("No runes equipped", True, MUTED) self.screen.blit(e, (panel.x + 8, py)) if pygame.time.get_ticks() < self.equip_msg_until: b = self.tiny.render(self.equip_msg, True, DANGER) self.screen.blit(b, b.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44))) elif self.selected is not None: sel = by_id.get(self.selected) if sel: owner = collection.rune_location(self.selected) if owner and owner != self.plant: hint = self.bold.render( f"Click a slot to move {sel['name']} from {owner}", True, ACCENT) else: hint = self.bold.render( f"Click a slot to equip {sel['name']}", True, ACCENT) self.screen.blit(hint, hint.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44))) elif slots == 1: h = self.tiny.render("Click a rune below, then click a slot to equip it.", True, MUTED) self.screen.blit(h, h.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44))) # --- inventory ------------------------------------------------- it = self.bold.render("Runes you own", True, HINT) self.screen.blit(it, (60, 260)) dr = self.tiny.render( f"Drop rates — {runes.BATTLE_DROP_CHANCE * 100:.0f}% for any rune " f"from floor battles · {runes.PACK_DROP_CHANCE * 100:.0f}% for any " f"rune from packs", True, MUTED) self.screen.blit(dr, dr.get_rect(midright=(WIN_W - 60, 270))) self._draw_filters() display = self._displayed_runes(inventory) list_y = 318 self.scroll_max = max(0, len(display) * 46 - (WIN_H - list_y - 20)) self.scroll = max(0, min(self.scroll, self.scroll_max)) old_clip = self.screen.get_clip() self.screen.set_clip(pygame.Rect(0, list_y, WIN_W, WIN_H - list_y - 20)) ry = list_y - self.scroll for rune in display: rect = pygame.Rect(60, ry, 780, 40) pygame.draw.rect(self.screen, PANEL, rect, border_radius=6) if rune["id"] == self.selected: pygame.draw.rect(self.screen, ACCENT, rect, 2, border_radius=6) else: pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=6) ic = self._icon_for(rune["color"]) if ic: self.screen.blit(ic, (rect.x + 8, rect.y + 4)) owner = collection.rune_location(rune["id"]) on_this = owner == self.plant name = self.small.render(rune["name"], True, TEXT if not on_this else MUTED) self.screen.blit(name, (rect.x + 58, rect.y + 11)) eff = self.tiny.render(runes.effect_text(rune["color"], rune.get("pct", 30)), True, MUTED) self.screen.blit(eff, (rect.x + 58 + name.get_width() + 10, rect.y + 14)) if on_this: tag = self.tiny.render("equipped", True, MUTED) self.screen.blit(tag, (rect.right - 70, rect.y + 13)) elif owner: tag = self.tiny.render(f"Equipped in: {owner}", True, MUTED) self.screen.blit(tag, tag.get_rect(midright=(rect.right - 14, rect.centery))) self.inv_rects.append((rune["id"], rect)) ry += 46 self.screen.set_clip(old_clip) if not inventory: empty = self.small.render( "No runes yet — win floor battles or open seed packs to find some.", True, MUTED) self.screen.blit(empty, (60, list_y + 10)) elif not display: empty = self.small.render("No runes match the current filter.", True, MUTED) self.screen.blit(empty, (60, list_y + 10)) if self.scroll_max > 0: hint = "▲" if self.scroll > 0 else "" if self.scroll < self.scroll_max: hint += "▼" ht = self.tiny.render(hint, True, MUTED) self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 18))) if self.confirm is not None: self._draw_confirm() if self.info: self._draw_info() def _draw_info(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 160)) self.screen.blit(overlay, (0, 0)) w, h = 560, 400 panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h) pygame.draw.rect(self.screen, PANEL, panel, border_radius=12) pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12) title = self.bold.render("Rune effects", True, HINT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) y = panel.y + 62 for color in runes.RUNE_COLORS: ic = self._icon_for(color) if ic: self.screen.blit(ic, (panel.x + 40, y)) nm = self.bold.render(runes.name(color), True, TEXT) self.screen.blit(nm, (panel.x + 92, y)) eff = self.small.render(runes.summary(color), True, MUTED) self.screen.blit(eff, (panel.x + 92, y + 24)) rg = self.tiny.render( f"Tier {runes.tier(color)} · Rolls {runes.roll_range(color)}", True, ACCENT) self.screen.blit(rg, rg.get_rect(topright=(panel.right - 16, y + 10))) y += 58 note = self.tiny.render( "Runes of the same effect stack additively; a plant can equip at " "most %d runes of the same type." % runes.MAX_RUNES_PER_TYPE, True, MUTED) self.screen.blit(note, note.get_rect(center=(panel.centerx, panel.bottom - 24))) close = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 8))) def _draw_confirm(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) w, h = 480, 190 panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h) pygame.draw.rect(self.screen, PANEL, panel, border_radius=12) pygame.draw.rect(self.screen, DANGER, panel, 2, border_radius=12) rune = self.confirm["rune"] name = rune["name"] if rune else "Rune" if self.confirm["kind"] == "move": title = self.bold.render("Move rune?", True, TEXT) msg = self.small.render( f"Remove {name} from {self.confirm['from']} to slot it here?", True, TEXT) ok_label = "Move" else: slot = self.confirm["slot"] + 1 title = self.bold.render("Remove rune?", True, TEXT) msg = self.small.render(f"Unequip {name} from slot {slot}?", True, TEXT) ok_label = "Remove" self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 34))) self.screen.blit(msg, msg.get_rect(center=(panel.centerx, panel.y + 66))) self.confirm_ok = pygame.Rect(panel.centerx - 110, panel.y + 110, 100, 36) self.confirm_no = pygame.Rect(panel.centerx + 10, panel.y + 110, 100, 36) pygame.draw.rect(self.screen, DANGER, self.confirm_ok, border_radius=8) ok = self.bold.render(ok_label, True, TEXT) self.screen.blit(ok, ok.get_rect(center=self.confirm_ok.center)) pygame.draw.rect(self.screen, BTN, self.confirm_no, border_radius=8) no = self.bold.render("Cancel", True, TEXT) self.screen.blit(no, no.get_rect(center=self.confirm_no.center))