import time import pygame from assets import icon as icon_path from assets import load_sprite from engine import collection from engine import runes from engine.growth import MAX_LEVEL, can_evolve, compute_stats, evolve_cost, xp_to_next from engine.plants import (HP_MULT, PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS, VARIABLE_TYPE_UNITS, plant_rarity) from engine.roster import DIFFICULTY_RARITIES from engine.seeds import PACK_TYPES, REWARD_PACK_TYPES, pack_odds from engine.skills import EFF_DOT, EFF_HOT, skill_from_dict from engine.types import ALL_TYPES, type_relations from engine.unit import ROLE_ATTACKER, ROLE_ICONS, ROLE_NAMES, ROLE_SUPPORT, ROLE_TANK from ui import scaling from ui.scaling import (draw_type_icon, fit_surface, load_plot_icon, load_rune_icon, load_type_icon, make_window, request_quit, to_logical, wrap_text) from ui.typepopup import draw_type_popup WIN_W, WIN_H = 1040, 720 BG = (24, 30, 24) TEXT = (230, 235, 230) MUTED = (150, 155, 150) HINT = (255, 220, 120) BTN = (60, 90, 60) BTN_HOVER = (85, 125, 85) PANEL = (32, 38, 32) ACCENT = (120, 220, 190) DANGER = (150, 70, 70) TYPE_COLORS = { "radiant": (245, 200, 90), "nocturnal": (150, 120, 220), "ice": (130, 200, 235), "garden": (120, 200, 120), "wildflower": (235, 130, 170), } def _draw_star(screen, center, radius, color): import math pts = [] for i in range(10): ang = -math.pi / 2 + i * math.pi / 5 r = radius if i % 2 == 0 else radius * 0.45 pts.append((center[0] + r * math.cos(ang), center[1] + r * math.sin(ang))) pygame.draw.polygon(screen, color, pts) def _draw_evo_badge(screen, font, center, evo_count): """Badge showing how many times a plant has been evolved (0/1/2).""" if evo_count > 0: bg, border, num = (205, 165, 45), (255, 220, 130), (25, 20, 5) else: bg, border, num = (78, 84, 78), (115, 120, 115), (195, 195, 195) pygame.draw.circle(screen, bg, center, 10) pygame.draw.circle(screen, border, center, 10, 2) t = font.render(str(evo_count), True, num) screen.blit(t, t.get_rect(center=center)) OVERVIEW = [ "A turn-based battle game: 4v4 battles plus a 20-stage Tower.", "Pick a difficulty stage, field 4 plants, and fight using", "plant types with type advantages, cooldowns, and debuffs.", "Open seed packs to earn new plants and duplicates.", "Water and evolve your plants to make them stronger.", ] def _bracket_text(sk): """The '[value]' shown for a skill, or None if the skill has no such number.""" if sk.kind in ("damage", "heal", "shield"): if sk.mechanic == "full_heal": return None return str(sk.power) for eff in sk.effects: if eff.kind in (EFF_HOT, EFF_DOT): return str(eff.value) return None class Menu: def __init__(self, page="menu"): pygame.init() self.window, self.scale = make_window(WIN_W, WIN_H) self.screen = pygame.Surface((WIN_W, WIN_H)) pygame.display.set_caption("Plants") self.clock = pygame.time.Clock() self.font = pygame.font.SysFont("segoeui", 36) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.small = pygame.font.SysFont("segoeui", 16) self.tiny = pygame.font.SysFont("segoeui", 13) self.page = page self.names = list(POOL.keys()) self.sprites = {n: self._load(n) for n in self.names} self.water_icon = self._load_icon("currency_icons", "water_drop", 16) self.fert_icon = self._load_icon("currency_icons", "fertilizer", 16) self.coin_icon = self._load_icon("currency_icons", "coin", 16) self._load_main_icons() self._passive_check_at = 0 self.rune_icons = {} self.role_icons = {} self.owned = collection.owned() self.discovered = collection.discovered() self.back_rect = pygame.Rect(16, 16, 90, 34) self.detail_name = None self.detail_from = "plants" self.detail_list = [] self.detail_index = 0 self.detail_left = pygame.Rect(18, 300, 36, 48) self.detail_right = pygame.Rect(WIN_W - 54, 300, 36, 48) self.grid_hit = [] self.grid_scroll = 0 self.grid_scroll_max = 0 self._prev_page = None self.water_rect = pygame.Rect(250, 318, 165, 30) self.evolve_rect = pygame.Rect(425, 318, 165, 30) self.seed_rect = pygame.Rect(250, 352, 220, 30) self.runes_rect = pygame.Rect(480, 352, 170, 30) self.auto_farmer_rect = pygame.Rect(0, 0, 0, 0) self.runes_deconstruct_rect = pygame.Rect(WIN_W - 220, 16, 110, 34) self.runes_info_rect = pygame.Rect(WIN_W - 100, 16, 84, 34) self.runes_deconstruct = False self.runes_deconstruct_msg = "" self.runes_target = None # rune picked to upgrade self.runes_sacrifice = [] # rune ids picked to sacrifice self.runes_info = False self.runes_filter_tier = 0 # 0 all, 1 tier 1, 2 tier 2 self.runes_filter_color = None self.runes_sort_best = False self.runes_filter_rects = [] self.runes_sort_rect = pygame.Rect(WIN_W - 170, 96, 150, 24) self.runes_shop_rect = pygame.Rect(WIN_W - 226, 54, 210, 28) self.runes_shop = False # rune shop window open self.runes_shop_buy_t1_rect = pygame.Rect(0, 0, 0, 0) self.runes_shop_buy_t2_rect = pygame.Rect(0, 0, 0, 0) self.runes_buy_popup = None # rune dict for the "bought" popup self.runes_buy_msg = "" self.runes_buy_msg_until = 0 self.runes_confirm = None self.runes_confirm_ok = pygame.Rect(0, 0, 0, 0) self.runes_confirm_no = pygame.Rect(0, 0, 0, 0) self.runes_result = None # {"name","color","before","after"} reroll popup self.rune_card_rects = [] self.last_open = None self.pack_rects = {} self.pack5_rects = {} self.info_rects = {} self.seed_pack_sprites = {} self.seeds_scroll = 0 self.seeds_scroll_max = 0 self.info_pack = None self.redeem_rect = pygame.Rect(WIN_W - 250, 50, 234, 30) self.history_rect = pygame.Rect(WIN_W - 250, 84, 234, 26) self.history_open = False self._layout_packs() self.diff_rects = {i: pygame.Rect(220, 130 + (i - 1) * 74, 460, 58) for i in range(1, 6)} self.floor_rects = {i: pygame.Rect(220, 130 + (i - 1) * 74, 460, 58) for i in range(1, 6)} self.floor_bands = { 1: "Floors 1-100", 2: "Floors 101-200", 3: "Floors 201-300", 4: "Floors 301-400", 5: "Floors 401-500", } self.type_info = None self.detail_type_rect = None self.type_tag_rects = {} self.favs = collection.favorites() self.star_rect = pygame.Rect(WIN_W - 56, 118, 40, 40) self.water_mode = False self.water_mode_btn = pygame.Rect(222, 58, 160, 34) self.abilities_btn = pygame.Rect(16, 58, 200, 34) self.abilities_msg = "" self.abilities_msg_until = 0 self.inject_remove_rect = pygame.Rect(WIN_W - 160, 208, 144, 26) self.water_exit_btn = pygame.Rect(WIN_W // 2 - 100, WIN_H - 56, 200, 36) self.coll_sort = None self.collection_tab = "plants" self.collection_tab_btns = { "plants": pygame.Rect(WIN_W // 2 - 170, 100, 150, 30), "runes": pygame.Rect(WIN_W // 2 + 20, 100, 150, 30), } self.coll_rune_tier = 0 self.coll_rune_color = None self.coll_rune_sort_best = False self.coll_rune_sort_rect = pygame.Rect(WIN_W - 170, 96, 150, 24) self.coll_rune_filter_rects = [] self.coll_rune_cards = [] self.coll_rune_detail = None self.tower_rect = pygame.Rect(WIN_W // 2 - 130, WIN_H - 92, 260, 58) self.tower_info_rect = pygame.Rect(WIN_W // 2 + 140, WIN_H - 88, 24, 24) self.tower_info = False self.presets_btn = pygame.Rect(WIN_W - 260, 52, 244, 30) self.coll_sort_btns = { "rarity": pygame.Rect(598, 50, 66, 28), "type": pygame.Rect(670, 50, 62, 28), "role": pygame.Rect(738, 50, 62, 28), "found": pygame.Rect(806, 50, 78, 28), } self.plants_sort = None self.plants_sort_btns = { "rarity": pygame.Rect(585, 50, 58, 28), "type": pygame.Rect(647, 50, 56, 28), "role": pygame.Rect(707, 50, 56, 28), "level": pygame.Rect(767, 50, 56, 28), "stage": pygame.Rect(827, 50, 58, 28), } self.convert_mode = False self.convert_rect = pygame.Rect(WIN_W // 2 - 330, WIN_H - 56, 210, 36) self.soil_msg = None # dict describing a soil popup (harvest result) self.soil_fert_info = False self.water_info = False self.soil_notice = "" self.soil_notice_until = 0 self.convert_pack_btns = {} for i, pt in enumerate(REWARD_PACK_TYPES): col = i % 3 row = i // 3 x = WIN_W // 2 - 165 + col * 115 y = WIN_H // 2 - 25 + row * 52 self.convert_pack_btns[pt] = pygame.Rect(x, y, 105, 42) self._layout_buttons() def _reinit(self): pygame.init() self.window, self.scale = make_window(WIN_W, WIN_H) self.screen = pygame.Surface((WIN_W, WIN_H)) pygame.display.set_caption("Plants") self.clock = pygame.time.Clock() self.font = pygame.font.SysFont("segoeui", 36) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.small = pygame.font.SysFont("segoeui", 16) self.tiny = pygame.font.SysFont("segoeui", 13) self.sprites = {n: self._load(n) for n in self.names} self.water_icon = self._load_icon("currency_icons", "water_drop", 16) self.fert_icon = self._load_icon("currency_icons", "fertilizer", 16) self.coin_icon = self._load_icon("currency_icons", "coin", 16) self._load_main_icons() self.rune_icons = {} self.seed_pack_sprites = {} self.seeds_scroll = getattr(self, "seeds_scroll", 0) def _load(self, name): return load_sprite(name, "full") def _load_icon(self, category, name, size): try: img = pygame.image.load(icon_path(category, name)).convert_alpha() return fit_surface(img, size) except (pygame.error, OSError): return None def _load_main_icons(self): self.main_icons = { "Plants": self._load_icon("menu_icons", "plants", 36), "Battle": self._load_icon("menu_icons", "battle", 36), "Runes": self._load_icon("menu_icons", "runes", 56), "Seeds": self._load_icon("menu_icons", "seed_pack", 56), "Soil": self._load_icon("menu_icons", "soil", 56), "Collection": self._load_icon("menu_icons", "collection", 56), } self.tower_icon = self._load_icon("menu_icons", "tower", 28) def _rune_icon(self, color, size=16): key = (color, size) if key not in self.rune_icons: self.rune_icons[key] = load_rune_icon(color, size) return self.rune_icons[key] def _role_icon(self, role, size=16): key = (role, size) if key not in self.role_icons: name = ROLE_ICONS.get(role) self.role_icons[key] = self._load_icon("role_icons", name, size) if name else None return self.role_icons[key] def _fit_text(self, text, font, max_w): """Truncate `text` to fit within `max_w` pixels, appending an ellipsis.""" if font.size(text)[0] <= max_w: return text while text and font.size(text + "...")[0] > max_w: text = text[:-1] return (text + "...") if text else "" def _layout_buttons(self): # Bottom row: Plants and Battle side by side. bw, bh, gap = 200, 54, 20 total = 2 * bw + gap x0 = (WIN_W - total) // 2 y = WIN_H - 72 self.buttons = { "Plants": pygame.Rect(x0, y, bw, bh), "Battle": pygame.Rect(x0 + bw + gap, y, bw, bh), } # Side buttons (square-ish): Seeds & Runes left, Soil & Collection right. # Fitted into the lower half, keeping clear of the bottom row. sq, sq_gap = 104, 16 ry = WIN_H - 72 - 2 * sq - sq_gap - 10 self.side_buttons = { "Runes": pygame.Rect(34, ry, sq, sq), "Seeds": pygame.Rect(34, ry + sq + sq_gap, sq, sq), } rx = WIN_W - 34 - sq self.side_buttons["Soil"] = pygame.Rect(rx, ry, sq, sq) self.side_buttons["Collection"] = pygame.Rect(rx, ry + sq + sq_gap, sq, sq) def _seed_pack_sprite(self, pt, size=110): """Seed pack sprite scaled to fit a `size` box (aspect preserved), cached.""" key = (pt, size) if key not in self.seed_pack_sprites: try: img = pygame.image.load( icon_path("seed_packs/closed", f"{pt}_seedpack")).convert_alpha() img = fit_surface(img, size) except (pygame.error, OSError): img = None self.seed_pack_sprites[key] = img return self.seed_pack_sprites[key] def _layout_packs(self): cw, ch, gap = 210, 200, 14 cols = 4 total = cols * cw + (cols - 1) * gap x0 = (WIN_W - total) // 2 y0 = 120 for i, pt in enumerate(PACK_TYPES): x = x0 + (i % cols) * (cw + gap) y = y0 + (i // cols) * (ch + gap) card = pygame.Rect(x, y, cw, ch) btn = pygame.Rect(x + 8, y + ch - 28, 62, 24) btn5 = pygame.Rect(x + 74, y + ch - 28, 62, 24) self.pack_rects[pt] = (card, btn) self.pack5_rects[pt] = btn5 self.info_rects[pt] = pygame.Rect(x + cw - 26, y + 8, 18, 18) rows = (len(PACK_TYPES) + cols - 1) // cols grid_h = rows * (ch + gap) - gap content_h = grid_h + 16 + 110 self.seeds_grid_y0 = y0 self.seeds_grid_h = grid_h self.seeds_scroll_area = pygame.Rect(0, 96, WIN_W, WIN_H - 96 - 8) self.seeds_scroll_max = max(0, content_h - self.seeds_scroll_area.h) self.seeds_scroll = getattr(self, "seeds_scroll", 0) self.seeds_scroll = max(0, min(self.seeds_scroll, self.seeds_scroll_max)) def run(self): running = True result = None while running: if scaling.QUIT_REQUESTED: running = False break self.clock.tick(60) self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale) if pygame.time.get_ticks() >= self._passive_check_at: collection.accrue_coins() collection.accrue_fertilizer() collection.auto_farm() self._passive_check_at = pygame.time.get_ticks() + 1000 if self.page != self._prev_page: self.grid_scroll = 0 self.seeds_scroll = 0 if self.page != "runes": self.runes_deconstruct = False self.runes_deconstruct_msg = "" self.runes_target = None self.runes_sacrifice = [] self.runes_info = False self.runes_confirm = None self.runes_result = None self.runes_buy_popup = None self.runes_buy_msg = "" self._prev_page = self.page for event in pygame.event.get(): if event.type == pygame.QUIT: request_quit() running = False elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: choice = self._click(to_logical(event.pos, self.scale)) if isinstance(choice, int) or choice == "tower": result = choice running = False elif event.type == pygame.MOUSEWHEEL: if self.page == "seeds": self.seeds_scroll = max(0, min(self.seeds_scroll_max, self.seeds_scroll - event.y * 60)) elif self.page in ("plants", "collection", "runes"): self.grid_scroll = max(0, min(self.grid_scroll_max, self.grid_scroll - event.y * 50)) self._draw() pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) pygame.display.flip() return result def _click(self, pos): if self.info_pack is not None: self.info_pack = None return None if self.type_info is not None: self.type_info = None return None if self.convert_mode: for pt, rect in self.convert_pack_btns.items(): if rect.collidepoint(pos): if collection.convert_water_to_pack(pt): self.owned = collection.owned() self.convert_mode = False return None self.convert_mode = False return None if self.page == "seeds": if self.history_open: self.history_open = False return None if self.back_rect.collidepoint(pos): self.page = "menu" return None if self.history_rect.collidepoint(pos): self.history_open = True return None if self.redeem_rect.collidepoint(pos): collection.redeem_tower_pack() return None for pt, (card, btn) in self.pack_rects.items(): if btn.move(0, -self.seeds_scroll).collidepoint(pos): result = collection.open_pack(pt) if result: self.last_open = result self.owned = collection.owned() self.discovered = collection.discovered() return None if self.pack5_rects[pt].move(0, -self.seeds_scroll).collidepoint(pos): if collection.pack_counts().get(pt, 0) >= 5: results = collection.open_packs(pt, 5) if results: self.last_open = results self.owned = collection.owned() self.discovered = collection.discovered() return None if self.info_rects[pt].move(0, -self.seeds_scroll).collidepoint(pos): self.info_pack = pt return None return None if self.page in ("plants", "collection"): if self.back_rect.collidepoint(pos): self.page = "menu" return None if self.page == "collection": if self.coll_rune_detail is not None: self.coll_rune_detail = None return None for key, rect in self.collection_tab_btns.items(): if rect.collidepoint(pos): self.collection_tab = key return None if self.collection_tab == "runes": if self._click_coll_rune_filter(pos): return None owned_colors = {r["color"] for r in collection.rune_inventory()} for color, rect in self.coll_rune_cards: if rect.collidepoint(pos) and color in owned_colors: self.coll_rune_detail = color return None return None if self.page == "plants": if self.water_info: self.water_info = False return None for key, rect in self.plants_sort_btns.items(): if rect.collidepoint(pos): self.plants_sort = None if self.plants_sort == key else key return None if self.water_info_rect.collidepoint(pos): self.water_info = True return None if self.abilities_btn.collidepoint(pos): if collection.get_tower_best() >= collection.EXTRACT_GATE: from ui.extract import ExtractionScreen ExtractionScreen().run() self._reinit() # the extract screen quit pygame self.owned = collection.owned() self.favs = collection.favorites() else: self.abilities_msg = f"Reach Tower stage {collection.EXTRACT_GATE} to unlock" self.abilities_msg_until = pygame.time.get_ticks() + 2000 return None if self.water_mode_btn.collidepoint(pos): self.water_mode = not self.water_mode return None if self.water_mode: if self.convert_rect.collidepoint(pos) and collection.available_water() >= 5: self.convert_mode = True return None if self.water_exit_btn.collidepoint(pos): self.water_mode = False return None for name, rect in self.grid_hit: if rect.collidepoint(pos): collection.water(name) return None return None for name, rect in self.type_tag_rects.items(): if rect.collidepoint(pos) and name not in VARIABLE_TYPE_UNITS: self.type_info = POOL[name]["type"] return None for name, rect in self.grid_hit: if rect.collidepoint(pos): self.detail_name = name self.detail_from = self.page self.detail_list = [n for n, _ in self.grid_hit] self.detail_index = self.detail_list.index(name) self.page = "plant_detail" return None return None if self.page == "plant_detail": if self.star_rect.collidepoint(pos) and self.detail_name: collection.toggle_favorite(self.detail_name) self.favs = collection.favorites() return None if self.detail_type_rect and self.detail_type_rect.collidepoint(pos) and self.detail_name \ and self.detail_name not in VARIABLE_TYPE_UNITS: self.type_info = POOL[self.detail_name]["type"] return None for pt, rect in self.type_choice_rects.items(): if rect.collidepoint(pos) and self.detail_name: collection.set_unit_type(self.detail_name, pt) return None if self.back_rect.collidepoint(pos): self.page = self.detail_from elif self.detail_left.collidepoint(pos): self._nav_detail(-1) elif self.detail_right.collidepoint(pos): self._nav_detail(1) elif self.water_rect.collidepoint(pos) and self.detail_name and self.detail_from != "collection": collection.water(self.detail_name) elif self.evolve_rect.collidepoint(pos) and self.detail_name and self.detail_from != "collection": collection.evolve(self.detail_name) elif self.seed_rect.collidepoint(pos) and self.detail_name and self.detail_from != "collection": collection.retrieve_seed(self.detail_name) elif (self.runes_rect.collidepoint(pos) and self.detail_name and self.detail_from != "collection"): from ui.runes import RuneScreen RuneScreen(self.detail_name).run() if not scaling.QUIT_REQUESTED: self._reinit() # RuneScreen re-created the window elif (self.inject_remove_rect.collidepoint(pos) and self.detail_name and self.detail_from != "collection" and collection.injection_of(self.detail_name)): collection.remove_injection(self.detail_name) return None if self.page == "battle_setup": if self.tower_info: self.tower_info = False return None if self.back_rect.collidepoint(pos): self.page = "menu" return None if self.tower_info_rect.collidepoint(pos): self.tower_info = not self.tower_info return None if self.presets_btn.collidepoint(pos): from ui.presets import PresetScreen PresetScreen().run() self._reinit() # PresetScreen quit pygame return None if self.tower_rect.collidepoint(pos): return "tower" for band, rect in self.floor_rects.items(): if rect.collidepoint(pos) and band == 1 \ and collection.floor_progress() <= collection.MAX_FLOOR: return collection.floor_progress() return None if self.page == "soil": if self.soil_msg is not None: self.soil_msg = None # click closes the harvest popup return None if self.soil_fert_info: self.soil_fert_info = False return None if self.soil_fert_info_rect.collidepoint(pos): self.soil_fert_info = True return None if self.back_rect.collidepoint(pos): self.page = "menu" return None if self.auto_farmer_rect.collidepoint(pos): if not collection.auto_farmer_owned(): reason = collection.buy_auto_farmer() self.soil_notice = reason or "Auto farmer bought!" else: active = collection.toggle_auto_farmer() self.soil_notice = "Auto farmer " + ( "activated" if active else "deactivated") self.soil_notice_until = pygame.time.get_ticks() + 1800 return None for idx, rect in self.soil_seed_rects: if rect.collidepoint(pos): collection.plant_seed(idx) return None for idx, rect in self.soil_water_rects: if rect.collidepoint(pos): collection.water_plot(idx) return None for idx, rect in self.soil_harvest_rects: if rect.collidepoint(pos): state = collection.soil_state() if 0 <= idx < len(state["plots"]): plant = state["plots"][idx]["plant"] if collection.harvest_plot(idx) == "": self.owned = collection.owned() self.soil_msg = { "plant": plant, "duplicates": collection.get_progress(plant)["duplicates"], } return None for idx, rect in self.soil_unlock_rects: if rect.collidepoint(pos): reason = collection.unlock_plot() if reason: self.soil_notice = reason self.soil_notice_until = pygame.time.get_ticks() + 1800 return None return None if self.page == "runes": if self.runes_buy_popup is not None: self.runes_buy_popup = None return None if self.runes_shop: if self.runes_shop_buy_t1_rect.collidepoint(pos): self._buy_rune(1) elif self.runes_shop_buy_t2_rect.collidepoint(pos): self._buy_rune(2) else: self.runes_shop = False return None if self.runes_result is not None: self.runes_result = None return None if self.runes_info: self.runes_info = False return None if self.runes_confirm is not None: if self.runes_confirm_ok.collidepoint(pos): rune_id = self.runes_confirm["rune"]["id"] before = self.runes_confirm["rune"].get("pct", 0) err = collection.reroll_rune(rune_id, self.runes_confirm["sacrifice"]) self.runes_deconstruct_msg = err or "" if not err: after = next((r["pct"] for r in collection.rune_inventory() if r["id"] == rune_id), before) self.runes_result = { "name": self.runes_confirm["rune"]["name"], "color": self.runes_confirm["rune"]["color"], "before": before, "after": after, } self.runes_deconstruct = False self.runes_target = None self.runes_sacrifice = [] self.runes_confirm = None elif self.runes_confirm_no.collidepoint(pos): self.runes_confirm = None return None if self.back_rect.collidepoint(pos): self.page = "menu" return None if self.runes_info_rect.collidepoint(pos): self.runes_info = True return None if self.runes_deconstruct_rect.collidepoint(pos): self.runes_deconstruct = not self.runes_deconstruct self.runes_deconstruct_msg = "" self.runes_target = None self.runes_sacrifice = [] return None if self.runes_shop_rect.collidepoint(pos): self.runes_shop = True self.runes_info = False self.runes_buy_msg = "" return None if self._click_runes_filter(pos): return None if self.runes_deconstruct: self._click_runes_deconstruct(pos) return None if self.page != "menu": if self.back_rect.collidepoint(pos): self.page = "menu" return None for label, rect in self.buttons.items(): if rect.collidepoint(pos): if label == "Battle": self.page = "battle_setup" return None self.page = label.lower() return None for label, rect in self.side_buttons.items(): if rect.collidepoint(pos): if label == "Soil" and not collection.soil_unlocked(): self.soil_msg = None return None self.page = label.lower() return None return None def _draw(self): if self.water_mode and (self.page != "plants" or collection.available_water() <= 0): self.water_mode = False self.screen.fill(BG) if self.page == "menu": self._draw_menu() elif self.page == "battle_setup": self._draw_battle_setup() elif self.page == "seeds": self._draw_seeds() elif self.page == "plant_detail": self._draw_detail() elif self.page == "plants": self._draw_plants() elif self.page == "soil": self._draw_soil() elif self.page == "runes": self._draw_runes() else: self._draw_collection() if self.info_pack is not None: self._draw_pack_info() if self.type_info is not None: draw_type_popup(self.screen, WIN_W, WIN_H, self.type_info, self.bold, self.small, self.tiny) if self.convert_mode: self._draw_convert_pack() if self.tower_info: self._draw_tower_info() if self.soil_msg: self._draw_soil_msg() if self.soil_fert_info: self._draw_fert_info() if self.water_info: self._draw_water_info() def _nav_detail(self, step): if not self.detail_list: return if not (0 <= self.detail_index < len(self.detail_list)): self.detail_index = 0 self.detail_index = (self.detail_index + step) % len(self.detail_list) self.detail_name = self.detail_list[self.detail_index] self.type_info = None self.coll_rune_detail = None self.type_choice_rects = {} def _draw_resources(self): """Small boxes along the top of the start screen showing the current coin, water, and fertilizer balances. The coin box shows its cap and a countdown to the next coin until the cap is reached.""" data = collection.load() coins = data.get("coins", 0) items = [ ("Coins", coins, self.coin_icon), ("Water", data.get("water", 0), self.water_icon), ("Fertilizer", data.get("fertilizer", 0), self.fert_icon), ] bw, bh, gap = 144, 44, 14 total = len(items) * bw + (len(items) - 1) * gap x0 = (WIN_W - total) // 2 y = 16 for i, (label, val, icon) in enumerate(items): rect = pygame.Rect(x0 + i * (bw + gap), y, bw, bh) pygame.draw.rect(self.screen, PANEL, rect, border_radius=8) pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=8) if icon: self.screen.blit(icon, (rect.x + 12, rect.centery - 8)) if label == "Coins": txt = self.small.render(f"{coins}/{collection.COIN_CAP}", True, TEXT) elif label == "Fertilizer": txt = self.small.render(f"{val}/{collection.FERTILIZER_CAP}", True, TEXT) else: txt = self.small.render(str(val), True, TEXT) self.screen.blit(txt, (rect.x + 36, rect.centery - txt.get_height() // 2)) if coins < collection.COIN_CAP: last = data.get("coins_accrued_at", 0.0) if last > 0: secs = max(1, int(60 - (time.time() - last) % 60)) nt = self.tiny.render(f"Next in {secs // 60}:{secs % 60:02d}", True, MUTED) self.screen.blit(nt, nt.get_rect(midtop=(x0 + bw // 2, y + bh + 2))) fert = data.get("fertilizer", 0) if fert < collection.FERTILIZER_CAP: last = data.get("fertilizer_accrued_at", 0.0) if last > 0: secs = max(1, int(3600 - (time.time() - last) % 3600)) hh, rem = divmod(secs, 3600) mm, ss = divmod(rem, 60) nt = self.tiny.render(f"Next in {hh:02d}:{mm:02d}:{ss:02d}", True, MUTED) fert_x = x0 + 2 * (bw + gap) + bw // 2 self.screen.blit(nt, nt.get_rect(midtop=(fert_x, y + bh + 2))) def _draw_menu(self): self._draw_resources() title = self.font.render("Plants", True, TEXT) self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 86))) y = 130 for line in OVERVIEW: t = self.small.render(line, True, MUTED) self.screen.blit(t, t.get_rect(center=(WIN_W // 2, y))) y += 24 mouse = self.mouse_pos # bottom row: Plants + Battle for label, rect in self.buttons.items(): hover = rect.collidepoint(mouse) if label == "Battle": bg = (90, 140, 90) if hover else BTN_HOVER else: bg = BTN_HOVER if hover else BTN pygame.draw.rect(self.screen, bg, rect, border_radius=12) pygame.draw.rect(self.screen, (150, 190, 150), rect, 2, border_radius=12) ic = self.main_icons.get(label) if ic: self.screen.blit(ic, ic.get_rect(center=rect.center)) else: t = self.bold.render(label, True, TEXT) self.screen.blit(t, t.get_rect(center=rect.center)) # side buttons: Seeds (left), Soil & Collection (right) soil_locked = not collection.soil_unlocked() for label, rect in self.side_buttons.items(): hover = rect.collidepoint(mouse) is_soil = label == "Soil" if is_soil and soil_locked: bg = (45, 50, 45) border = (70, 80, 70) else: bg = BTN_HOVER if hover else BTN border = (150, 190, 150) pygame.draw.rect(self.screen, bg, rect, border_radius=18) pygame.draw.rect(self.screen, border, rect, 2, border_radius=18) ic = self.main_icons.get(label) if ic: ipos = ic.get_rect(center=rect.center) if is_soil and soil_locked: # Dim only the icon's opaque pixels (silhouette) so the # transparent background stays clear. shade = pygame.Surface(ic.get_size(), pygame.SRCALPHA) shade.fill((0, 0, 0, 150)) shade.blit(ic, (0, 0), special_flags=pygame.BLEND_RGBA_MIN) self.screen.blit(ic, ipos) self.screen.blit(shade, ipos) lt = self.bold.render("Soil", True, (235, 100, 100)) self.screen.blit(lt, lt.get_rect(center=rect.center)) else: self.screen.blit(ic, ipos) else: t = self.bold.render(label, True, TEXT) self.screen.blit(t, t.get_rect(center=rect.center)) if is_soil: self.soil_btn_locked = soil_locked if soil_locked: self.soil_btn_locked = True n = collection.SOIL_GATE_PLANTS - len(collection.owned()) hint = self.tiny.render(f"Own {collection.SOIL_GATE_PLANTS} plants to unlock Soil" f" ({n} to go)", True, (110, 115, 110)) self.screen.blit(hint, hint.get_rect(midright=(self.side_buttons["Soil"].right, self.side_buttons["Soil"].top - 6))) def _draw_battle_setup(self): self._draw_page("Select Floors", "Pick a floor range to fight in, or take on the Tower") mouse = self.mouse_pos current = collection.floor_progress() for band, rect in self.floor_rects.items(): hover = rect.collidepoint(mouse) playable = band == 1 and current <= collection.MAX_FLOOR bg = (90, 120, 90) if (hover and playable) else BTN pygame.draw.rect(self.screen, bg, rect, border_radius=8) pygame.draw.rect(self.screen, (150, 190, 150) if playable else (70, 80, 70), rect, 2, border_radius=8) label = self.floor_bands[band] t = self.bold.render(label, True, TEXT if playable else MUTED) self.screen.blit(t, (rect.x + 16, rect.y + 8)) if band == 1: sub = self.tiny.render(f"Your floor: {current}/{collection.MAX_FLOOR}" f" · Win: packs + floor up", True, MUTED) else: sub = self.tiny.render("Coming soon", True, (110, 115, 110)) self.screen.blit(sub, (rect.x + 16, rect.y + 32)) tower_hover = self.tower_rect.collidepoint(mouse) pygame.draw.rect(self.screen, (90, 120, 90) if tower_hover else BTN, self.tower_rect, border_radius=8) pygame.draw.rect(self.screen, (245, 200, 90), self.tower_rect, 2, border_radius=8) tt = self.bold.render("Tower — 20 stages", True, TEXT) tic = self.tower_icon row1_y = self.tower_rect.y + 16 if tic: total_w = tic.get_width() + 6 + tt.get_width() tx = self.tower_rect.centerx - total_w // 2 self.screen.blit(tic, tic.get_rect(midleft=(tx, row1_y + tt.get_height() // 2))) self.screen.blit(tt, (tx + tic.get_width() + 6, row1_y)) else: self.screen.blit(tt, tt.get_rect(center=(self.tower_rect.centerx, row1_y + tt.get_height() // 2))) from engine.collection import tower_max_stage mst = tower_max_stage() ts = self.tiny.render(f"Stage {mst} open · Water + packs · Best {collection.get_tower_best()}", True, MUTED) self.screen.blit(ts, ts.get_rect(center=(self.tower_rect.centerx, self.tower_rect.y + 40))) ihov = self.tower_info_rect.collidepoint(mouse) pygame.draw.circle(self.screen, (115, 135, 115) if ihov else (70, 82, 70), self.tower_info_rect.center, 12) pygame.draw.circle(self.screen, (175, 195, 175), self.tower_info_rect.center, 12, 1) itxt = self.tiny.render("i", True, TEXT) self.screen.blit(itxt, itxt.get_rect(center=self.tower_info_rect.center)) pygame.draw.rect(self.screen, BTN, self.presets_btn, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150), self.presets_btn, 1, border_radius=6) ptxt = self.tiny.render("Team presets (P1–P4)", True, TEXT) self.screen.blit(ptxt, ptxt.get_rect(center=self.presets_btn.center)) def _draw_tower_info(self): from engine.collection import tower_max_stage mst = tower_max_stage() overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 160)) self.screen.blit(overlay, (0, 0)) w, h = 440, 360 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, (245, 200, 90), panel, 2, border_radius=12) title = self.bold.render("Tower access", True, HINT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) sub = self.tiny.render(f"You can currently reach stage {mst}", True, MUTED) self.screen.blit(sub, sub.get_rect(center=(panel.centerx, panel.y + 56))) rows = [ ("1 - 5", "Always available"), ("6 - 10", "Own at least one evolved plant"), ("11 - 15", "Own at least one max-level plant"), ("16 - 20", "Own a max-level plant and any injected plant"), ] y = panel.y + 96 for band, req in rows: rar = HINT if mst >= int(band.split(" - ")[1]) else MUTED b = self.bold.render(band, True, rar) self.screen.blit(b, (panel.x + 34, y)) r = self.small.render(req, True, TEXT if rar == HINT else MUTED) self.screen.blit(r, (panel.x + 130, y)) y += 34 close = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 20))) def _draw_page(self, title_text, subtitle): t = self.font.render(title_text, True, TEXT) self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 52))) t = self.small.render(subtitle, True, MUTED) self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 84))) pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) t = self.small.render("Back", True, TEXT) self.screen.blit(t, t.get_rect(center=self.back_rect.center)) def _draw_seeds(self): self._draw_page("Seeds", "Win packs scale with difficulty · Tower packs have high rarity") packs = collection.pack_counts() tcol_lesser = (150, 150, 150) tcol_tower = (245, 200, 90) tpacks = packs.get("tower", 0) can_redeem = tpacks > 0 pygame.draw.rect(self.screen, BTN_HOVER if can_redeem else (45, 50, 45), self.redeem_rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if can_redeem else (70, 80, 70), self.redeem_rect, 1, border_radius=6) rtxt = f"Redeem tower pack (+10 water) · {tpacks}" if can_redeem else "No tower packs" r = self.tiny.render(rtxt, True, TEXT if can_redeem else MUTED) self.screen.blit(r, r.get_rect(center=self.redeem_rect.center)) mouse = self.mouse_pos h_hover = self.history_rect.collidepoint(mouse) pygame.draw.rect(self.screen, (90, 120, 90) if h_hover else BTN, self.history_rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150), self.history_rect, 1, border_radius=6) ht = self.tiny.render("History (last 10)", True, TEXT) self.screen.blit(ht, ht.get_rect(center=self.history_rect.center)) # scrollable grid of packs, each showing its sprite at native size old_clip = self.screen.get_clip() self.screen.set_clip(self.seeds_scroll_area) for pt, (card, btn) in self.pack_rects.items(): rect = card.move(0, -self.seeds_scroll) if not rect.colliderect(self.seeds_scroll_area): continue pygame.draw.rect(self.screen, PANEL, rect, border_radius=8) pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=8) spr = self._seed_pack_sprite(pt) if spr: self.screen.blit(spr, spr.get_rect(center=(rect.centerx, rect.y + 112))) if pt == "tower": tcol = tcol_tower elif pt == "lesser": tcol = tcol_lesser else: tcol = TYPE_COLORS.get(pt, TEXT) t = self.bold.render(pt.capitalize(), True, tcol) self.screen.blit(t, (rect.x + 8, rect.y + 8)) cnt = packs.get(pt, 0) c = self.small.render(f"x{cnt}", True, HINT) self.screen.blit(c, (rect.right - 40, rect.y + 8)) if pt == "lesser": sub = "Any type, worse odds" elif pt == "tower": sub = "Any type, high rarity" elif pt == "general": sub = "Any plant type" else: sub = f"{pt} type boosted" s = self.tiny.render(sub, True, MUTED) self.screen.blit(s, (rect.x + 8, rect.y + 30)) info = self.info_rects[pt].move(0, -self.seeds_scroll) ihov = info.collidepoint(mouse) icol = (115, 135, 115) if ihov else (70, 82, 70) pygame.draw.circle(self.screen, icol, info.center, 9) pygame.draw.circle(self.screen, (175, 195, 175), info.center, 9, 1) itxt = self.tiny.render("i", True, TEXT) self.screen.blit(itxt, itxt.get_rect(center=info.center)) b = btn.move(0, -self.seeds_scroll) active = cnt > 0 pygame.draw.rect(self.screen, BTN_HOVER if active else (45, 50, 45), b, border_radius=6) t = self.tiny.render("Open", True, TEXT if active else MUTED) self.screen.blit(t, t.get_rect(center=b.center)) b5 = self.pack5_rects[pt].move(0, -self.seeds_scroll) active5 = cnt >= 5 pygame.draw.rect(self.screen, (100, 100, 60) if active5 else (45, 50, 45), b5, border_radius=6) t5 = self.tiny.render("Open x5", True, TEXT if active5 else MUTED) self.screen.blit(t5, t5.get_rect(center=b5.center)) self.screen.set_clip(old_clip) if self.seeds_scroll_max > 0: hint = ("▲" if self.seeds_scroll > 0 else "") + ( "▼" if self.seeds_scroll < self.seeds_scroll_max else "") if hint: ht = self.tiny.render(hint, True, MUTED) self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 8))) # "Last opened" result sits just below the pack grid. self._draw_last_open(self.seeds_grid_y0 + self.seeds_grid_h + 22 - self.seeds_scroll) if self.history_open: self._draw_pack_history() def _draw_last_open(self, y): t = self.small.render("Last opened:", True, MUTED) self.screen.blit(t, (60, y)) if self.last_open: batch = self.last_open if isinstance(self.last_open, list) else [self.last_open] x = 60 for plant, is_new, bonus in batch: spr = self.sprites.get(plant) if spr: ic = fit_surface(spr, 40) self.screen.blit(ic, ic.get_rect(center=(x + 20, y + 28))) rcol = RARITY_COLORS.get(plant_rarity(plant), TEXT) nm = self.tiny.render(plant, True, rcol) self.screen.blit(nm, (x, y + 52)) if is_new: nt = self.tiny.render("NEW", True, HINT) self.screen.blit(nt, (x + 44, y + 10)) x += 132 total = len(batch) n_new = sum(1 for r in batch if r[1]) if total == 1: status = "NEW plant!" if n_new else "Duplicate (+1)" else: status = f"Opened {total} — {n_new} new, {total - n_new} duplicate(s)" col = HINT if n_new else MUTED st = self.small.render(status, True, col) self.screen.blit(st, (60, y + 90)) rune_bonus = [r[2] for r in batch if r[2] is not None] if rune_bonus: rx = 60 for r in rune_bonus: ic = self._rune_icon(r["color"]) if ic: self.screen.blit(ic, (rx, y + 111)) rn = self.tiny.render(r["name"], True, ACCENT) self.screen.blit(rn, (rx + 20, y + 113)) rx += 20 + rn.get_width() + 12 def _draw_pack_history(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 160)) self.screen.blit(overlay, (0, 0)) w, h = 620, 430 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("Last 10 packs opened", True, HINT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) history = collection.open_history() if not history: t = self.small.render("No packs opened yet.", True, MUTED) self.screen.blit(t, t.get_rect(center=(panel.centerx, panel.y + 100))) else: y = panel.y + 64 for rec in reversed(history[-10:]): rect = pygame.Rect(panel.x + 20, y, panel.w - 40, 30) pygame.draw.rect(self.screen, (40, 48, 40), rect, border_radius=6) rcol = RARITY_COLORS.get(plant_rarity(rec["plant"]), TEXT) nm = self.small.render(rec["plant"], True, rcol) self.screen.blit(nm, (rect.x + 10, rect.y + 6)) if rec["is_new"]: nt = self.tiny.render("NEW", True, HINT) self.screen.blit(nt, (rect.x + 10 + nm.get_width() + 8, rect.y + 8)) if rec["rune"]: rt = self.tiny.render(f"+ {rec['rune']}", True, ACCENT) self.screen.blit(rt, rt.get_rect(midright=(rect.right - 10, rect.centery))) y += 36 close = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 22))) def _draw_pack_info(self): pt = self.info_pack overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 160)) self.screen.blit(overlay, (0, 0)) w, h = 360, 360 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, (60, 70, 60), panel, 2, border_radius=12) tcol = TYPE_COLORS.get(pt, (150, 150, 150) if pt == "lesser" else TEXT) title = self.bold.render(f"{pt.capitalize()} pack odds", True, tcol) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 34))) odds = pack_odds(pt) y = panel.y + 78 for rar in ("common", "rare", "super_rare", "super_super_rare", "ultra_rare"): pct = odds.get(rar, 0.0) * 100.0 col = RARITY_COLORS.get(rar, TEXT) name = self.small.render(RARITY_LABELS.get(rar, rar), True, col) self.screen.blit(name, (panel.x + 40, y)) bar_w = int(150 * pct / 100.0) if bar_w > 0: pygame.draw.rect(self.screen, col, (panel.x + 150, y + 5, bar_w, 10), border_radius=3) pct_s = self.small.render(f"{pct:.1f}%", True, TEXT) self.screen.blit(pct_s, (panel.right - 50, y)) y += 40 # runes pygame.draw.line(self.screen, (60, 70, 60), (panel.x + 30, y + 2), (panel.right - 30, y + 2)) y += 14 rn = self.small.render("Runes", True, HINT) self.screen.blit(rn, (panel.x + 40, y)) rpct = runes.PACK_DROP_CHANCE * 100.0 bar_w = int(150 * rpct / 100.0) if bar_w > 0: pygame.draw.rect(self.screen, HINT, (panel.x + 150, y + 5, bar_w, 10), border_radius=3) rp = self.small.render(f"{rpct:.1f}%", True, TEXT) self.screen.blit(rp, (panel.right - 50, y)) low, high = runes.per_rune_chance(runes.PACK_DROP_CHANCE) sub = self.tiny.render( f"{rpct:.0f}% for any rune — brown is rarer than the others", True, MUTED) self.screen.blit(sub, (panel.x + 150, y + 22)) if pt in ALL_TYPES: note = self.tiny.render(f"Plants are drawn from the {pt} type when possible", True, MUTED) self.screen.blit(note, note.get_rect(center=(panel.centerx, panel.bottom - 46))) close = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 24))) def _draw_convert_pack(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 160)) self.screen.blit(overlay, (0, 0)) w, h = 380, 210 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, (60, 70, 60), panel, 2, border_radius=12) title = self.bold.render("Convert 5 water → a pack of your choice", True, TEXT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 28))) mouse = self.mouse_pos for pt, rect in self.convert_pack_btns.items(): hover = rect.collidepoint(mouse) bg = (90, 120, 90) if hover else BTN pygame.draw.rect(self.screen, bg, rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150), rect, 1, border_radius=6) tcol = TYPE_COLORS.get(pt, TEXT) draw_type_icon(self.screen, pt, rect.center, self.tiny, tcol, 26, center=True) hint = self.tiny.render("Click elsewhere to cancel", True, MUTED) self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 18))) def _draw_soil(self): self._draw_page("Soil", "Retrieve seeds from plants, plant them, and grow copies.") state = collection.soil_state() fertilizer = collection.available_fertilizer() cd = int(state["cd_left"]) if cd > 0: status = f"Next seed in {cd}s" else: status = "Seed ready — open a plant and hit 'Retrieve Seed'" seeds_x = 60 plots_x = 343 st = self.small.render(status, True, HINT if cd == 0 else MUTED) self.screen.blit(st, (seeds_x, 120)) wt = self.small.render(f"Fertilizer: {fertilizer}/{collection.FERTILIZER_CAP}", True, TEXT) self.screen.blit(wt, (WIN_W - 180, 120)) if self.fert_icon: self.screen.blit(self.fert_icon, (WIN_W - 180 + wt.get_width() + 4, 122)) self.soil_fert_info_rect = pygame.Rect(WIN_W - 40, 116, 22, 22) ihov = self.soil_fert_info_rect.collidepoint(self.mouse_pos) pygame.draw.circle(self.screen, (115, 135, 115) if ihov else (70, 82, 70), self.soil_fert_info_rect.center, 11) pygame.draw.circle(self.screen, (175, 195, 175), self.soil_fert_info_rect.center, 11, 1) itxt = self.tiny.render("i", True, TEXT) self.screen.blit(itxt, itxt.get_rect(center=self.soil_fert_info_rect.center)) af_owned = collection.auto_farmer_owned() af_active = collection.auto_farmer_active() self.auto_farmer_rect = pygame.Rect(WIN_W - 270, 148, 250, 28) af_hover = self.auto_farmer_rect.collidepoint(self.mouse_pos) pygame.draw.rect(self.screen, (90, 120, 90) if (af_active or af_hover) else BTN, self.auto_farmer_rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if af_active else (70, 80, 70), self.auto_farmer_rect, 1, border_radius=6) if af_owned: af_txt = self.tiny.render( "Auto Farmer: active" if af_active else "Auto Farmer: off", True, ACCENT if af_active else MUTED) else: can_afford = collection.available_coins() >= collection.AUTO_FARMER_COST af_txt = self.tiny.render( f"Buy Auto Farmer — {collection.AUTO_FARMER_COST} coins", True, TEXT if can_afford else MUTED) self.screen.blit(af_txt, af_txt.get_rect(center=self.auto_farmer_rect.center)) if self.soil_notice and pygame.time.get_ticks() < self.soil_notice_until: nt = self.bold.render(self.soil_notice, True, (235, 130, 130)) self.screen.blit(nt, nt.get_rect(center=(WIN_W // 2, 148))) # --- retrieved seeds (unplanted) --- t = self.bold.render("Seeds retrieved", True, HINT) self.screen.blit(t, (seeds_x, 160)) self.soil_seed_rects = [] y = 190 if not state["seeds"]: self.screen.blit(self.small.render("No seeds yet.", True, MUTED), (seeds_x + 10, y)) for idx, s in enumerate(state["seeds"]): rect = pygame.Rect(seeds_x, y, 220, 34) pygame.draw.rect(self.screen, (42, 50, 42), rect, border_radius=6) pygame.draw.rect(self.screen, (90, 110, 90), rect, 1, border_radius=6) rar = plant_rarity(s["plant"]) self.screen.blit(self.small.render(s["plant"], True, RARITY_COLORS.get(rar, TEXT)), (rect.x + 8, rect.y + 7)) btn = pygame.Rect(rect.right - 90, rect.y + 4, 82, 26) pygame.draw.rect(self.screen, BTN_HOVER, btn, border_radius=6) self.screen.blit(self.tiny.render("Plant", True, TEXT), self.tiny.render("Plant", True, TEXT).get_rect(center=btn.center)) self.soil_seed_rects.append((idx, btn)) y += 40 # --- plots --- self._draw_soil_plots(state, fertilizer) def _plot_sprite_name(self, p): if p["grown"]: return "soil_ready" if p["stage"] == 0: return "soil_seed1" if p["stage"] == 1: return "soil_seed2" return "soil_growth" def _draw_soil_plots(self, state, fertilizer): plots_x = (WIN_W - (2 * 170 + 14)) // 2 t = self.bold.render(f"Growing ({state['plots_unlocked']}/{state['plots_max']} plots)", True, HINT) self.screen.blit(t, (plots_x, 160)) self.soil_water_rects = [] self.soil_harvest_rects = [] self.soil_unlock_rects = [] tile_w, tile_h, gap = 170, 180, 14 cols = 2 y0 = 190 unlocked = state["plots_unlocked"] for slot in range(state["plots_max"]): tx = plots_x + (slot % cols) * (tile_w + gap) ty = y0 + (slot // cols) * (tile_h + gap) rect = pygame.Rect(tx, ty, tile_w, tile_h) if slot < len(state["plots"]): self._draw_plot_tile(rect, state["plots"][slot], fertilizer, slot) elif slot < unlocked: pygame.draw.rect(self.screen, (30, 36, 30), rect, border_radius=10) pygame.draw.rect(self.screen, (70, 80, 70), rect, 1, border_radius=10) ic = load_plot_icon("soil_barren", 96) if ic: self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 60))) self.screen.blit(self.small.render("Empty plot", True, MUTED), (rect.x + 8, rect.y + 132)) else: pygame.draw.rect(self.screen, (24, 28, 24), rect, border_radius=10) pygame.draw.rect(self.screen, (50, 55, 50), rect, 1, border_radius=10) ic = load_plot_icon("soil_barren", 96) if ic: ipos = ic.get_rect(center=(rect.centerx, rect.y + 48)) dim = ic.copy() dim.set_alpha(70) self.screen.blit(dim, ipos) self.screen.blit(self.small.render("Locked plot", True, (100, 105, 100)), (rect.x + 8, rect.y + 102)) next_buy = slot == unlocked c_coins, c_fert = collection.plot_unlock_cost(slot + 1) can = next_buy and collection.available_coins() >= c_coins and fertilizer >= c_fert cx = rect.x + 8 if self.coin_icon: self.screen.blit(self.coin_icon, (cx, rect.y + 131)) cx += 18 cxt = self.tiny.render(f"{c_coins}", True, HINT if can else (110, 115, 110)) self.screen.blit(cxt, (cx, rect.y + 130)) cx += cxt.get_width() + 10 if self.fert_icon: self.screen.blit(self.fert_icon, (cx, rect.y + 131)) cx += 18 fxt = self.tiny.render(f"{c_fert}", True, HINT if can else (110, 115, 110)) self.screen.blit(fxt, (cx, rect.y + 130)) ubtn = pygame.Rect(rect.centerx - 40, rect.bottom - 30, 80, 24) pygame.draw.rect(self.screen, BTN_HOVER if can else (45, 50, 45), ubtn, border_radius=6) self.screen.blit(self.tiny.render("Unlock", True, TEXT if can else MUTED), self.tiny.render("Unlock", True, TEXT if can else MUTED).get_rect(center=ubtn.center)) if next_buy: self.soil_unlock_rects.append((slot, ubtn)) def _draw_plot_tile(self, rect, p, fertilizer, slot): pygame.draw.rect(self.screen, (34, 42, 34), rect, border_radius=10) pygame.draw.rect(self.screen, (90, 110, 90), rect, 1, border_radius=10) rar = plant_rarity(p["plant"]) ic = load_plot_icon(self._plot_sprite_name(p), 96) if ic: self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 60))) self.screen.blit(self.small.render(p["plant"], True, RARITY_COLORS.get(rar, TEXT)), (rect.x + 8, rect.y + 114)) if p["grown"]: self.screen.blit(self.tiny.render("GROWN!", True, (120, 220, 120)), (rect.x + 8, rect.y + 128)) else: self.screen.blit(self.tiny.render(f"stage {p['stage'] + 1}/{p['total_stages']}" f" · fertilized {p['water']}x", True, MUTED), (rect.x + 8, rect.y + 128)) if p["grown"]: hbtn = pygame.Rect(rect.centerx - 40, rect.bottom - 34, 80, 26) pygame.draw.rect(self.screen, (90, 140, 90), hbtn, border_radius=6) self.screen.blit(self.tiny.render("Harvest", True, TEXT), self.tiny.render("Harvest", True, TEXT).get_rect(center=hbtn.center)) self.soil_harvest_rects.append((slot, hbtn)) elif p["stalled"]: wbtn = pygame.Rect(rect.centerx - 40, rect.bottom - 34, 80, 26) pygame.draw.rect(self.screen, BTN_HOVER if fertilizer > 0 else (45, 50, 45), wbtn, border_radius=6) self.screen.blit(self.tiny.render("Fertilize", True, TEXT if fertilizer > 0 else MUTED), self.tiny.render("Fertilize", True, TEXT if fertilizer > 0 else MUTED).get_rect(center=wbtn.center)) self.soil_water_rects.append((slot, wbtn)) else: secs = int(p["seconds_left"]) st = self.tiny.render(f"{secs}s", True, MUTED) self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.bottom - 20))) def _draw_fert_info(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) w, h = 400, 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, (150, 190, 150), panel, 2, border_radius=12) title = self.bold.render("Fertilizer", True, HINT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) lines = [ "Used to advance growing Soil plots by one stage.", "", "Earned by winning battles — higher floor = more", "(floor 1 gives 1, floor 5 gives 5).", ] y = panel.y + 66 for ln in lines: if not ln: y += 10 continue t = self.small.render(ln, True, TEXT) self.screen.blit(t, t.get_rect(center=(panel.centerx, y))) y += 24 close = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 20))) def _draw_water_info(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) w, h = 420, 240 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("Water", True, HINT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) lines = [ "The scarce, valuable currency.", "", "Spent to level up plants (+1 level per water)", "and to buy seed packs (5 water each).", "", "Earned from Tower rewards — stage 1-5 wins pay", "1 water, and Tower packs redeem for 10.", ] y = panel.y + 62 for ln in lines: if not ln: y += 10 continue t = self.small.render(ln, True, TEXT) self.screen.blit(t, t.get_rect(center=(panel.centerx, y))) y += 22 close = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 18))) def _draw_soil_msg(self): msg = self.soil_msg if not msg: return overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) w, h = 360, 200 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("Harvest!", True, (120, 220, 120)) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) plant = msg["plant"] rar = plant_rarity(plant) spr = self.sprites.get(plant) if spr: ic = fit_surface(spr, 64) self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 84))) name = self.bold.render(plant, True, RARITY_COLORS.get(rar, TEXT)) self.screen.blit(name, name.get_rect(center=(panel.centerx, panel.y + 124))) dup = self.small.render(f"You gained a copy — {msg['duplicates']} duplicates", True, TEXT) self.screen.blit(dup, dup.get_rect(center=(panel.centerx, panel.y + 152))) hint = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 18))) def _draw_plants(self): data = collection.load() if self.water_mode: self._draw_page("Plants", "Water mode — gold ring = ready to evolve once leveled") else: self._draw_page("Plants", "Plants you own") water = data.get("water", 0) wm_hover = self.water_mode_btn.collidepoint(self.mouse_pos) pygame.draw.rect(self.screen, (90, 120, 90) if wm_hover or self.water_mode else BTN, self.water_mode_btn, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if self.water_mode else (70, 80, 70), self.water_mode_btn, 1, border_radius=6) wtxt = " (exit)" if self.water_mode else "" wv = self.tiny.render(f"{water}{wtxt}", True, TEXT) total_w = (16 + 2) + wv.get_width() x = self.water_mode_btn.centerx - total_w // 2 if self.water_icon: self.screen.blit(self.water_icon, (x, self.water_mode_btn.centery - 8)) x += 18 self.screen.blit(wv, (x, self.water_mode_btn.centery - wv.get_height() // 2)) # water info circle self.water_info_rect = pygame.Rect(self.water_mode_btn.right - 26, self.water_mode_btn.centery - 11, 22, 22) ihov = self.water_info_rect.collidepoint(self.mouse_pos) pygame.draw.circle(self.screen, (115, 135, 115) if ihov else (70, 82, 70), self.water_info_rect.center, 11) pygame.draw.circle(self.screen, (175, 195, 175), self.water_info_rect.center, 11, 1) itxt = self.tiny.render("i", True, TEXT) self.screen.blit(itxt, itxt.get_rect(center=self.water_info_rect.center)) unlocked = data.get("tower_best", 0) >= collection.EXTRACT_GATE ab_hover = self.abilities_btn.collidepoint(self.mouse_pos) pygame.draw.rect(self.screen, (90, 120, 90) if (ab_hover and unlocked) else (BTN if unlocked else (45, 50, 45)), self.abilities_btn, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if unlocked else (70, 80, 70), self.abilities_btn, 1, border_radius=6) alabel = "Abilities" if unlocked else f"Abilities (Tower {collection.EXTRACT_GATE})" t = self.tiny.render(alabel, True, TEXT if unlocked else MUTED) self.screen.blit(t, t.get_rect(center=self.abilities_btn.center)) if pygame.time.get_ticks() < self.abilities_msg_until: mt = self.tiny.render(self.abilities_msg, True, HINT) self.screen.blit(mt, (self.abilities_btn.x, self.abilities_btn.bottom + 4)) owned = self._sort_plants([n for n in self.names if n in self.owned]) if not owned: t = self.small.render("You don't own any plants yet — battle to discover them.", True, MUTED) self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 200))) return mouse = self.mouse_pos labels = {"rarity": "Rarity", "type": "Type", "role": "Role", "level": "Level", "stage": "Stage"} for key, rect in self.plants_sort_btns.items(): active = self.plants_sort == key hover = rect.collidepoint(mouse) pygame.draw.rect(self.screen, (90, 120, 90) if hover or active else BTN, rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if active else (70, 80, 70), rect, 1, border_radius=6) t = self.tiny.render(labels[key], True, TEXT) self.screen.blit(t, t.get_rect(center=rect.center)) self._draw_grid(owned, reveal_all=True, level_mode=self.water_mode, data=data) if self.water_mode: water = collection.available_water() can_convert = water >= 5 pygame.draw.rect(self.screen, BTN_HOVER if can_convert else (45, 50, 45), self.convert_rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if can_convert else (70, 80, 70), self.convert_rect, 1, border_radius=6) ct = self.tiny.render(f"Convert 5 water → pack" + ("" if can_convert else " (need 5)"), True, TEXT if can_convert else MUTED) self.screen.blit(ct, ct.get_rect(center=self.convert_rect.center)) pygame.draw.rect(self.screen, BTN_HOVER, self.water_exit_btn, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150), self.water_exit_btn, 2, border_radius=6) et = self.tiny.render("Exit water mode", True, TEXT) self.screen.blit(et, et.get_rect(center=self.water_exit_btn.center)) def _sort_collection(self, names): s = self.coll_sort if s == "rarity": order = {"common": 0, "rare": 1, "super_rare": 2, "super_super_rare": 3, "ultra_rare": 4} return sorted(names, key=lambda n: (order.get(plant_rarity(n), 0), n)) if s == "type": return sorted(names, key=lambda n: (POOL[n]["type"], n)) if s == "role": order = {"attacker": 0, "support": 1, "tank": 2} return sorted(names, key=lambda n: (order.get(POOL[n]["role"], 3), n)) if s == "found": return sorted(names, key=lambda n: (n not in self.owned, n)) return list(names) def _sort_plants(self, names): s = self.plants_sort if s == "rarity": order = {"common": 0, "rare": 1, "super_rare": 2, "super_super_rare": 3, "ultra_rare": 4} return sorted(names, key=lambda n: (order.get(plant_rarity(n), 0), n)) if s == "type": return sorted(names, key=lambda n: (POOL[n]["type"], n)) if s == "role": order = {"attacker": 0, "support": 1, "tank": 2} return sorted(names, key=lambda n: (order.get(POOL[n]["role"], 3), n)) if s == "level": return sorted(names, key=lambda n: (-collection.get_progress(n)["level"], n)) if s == "stage": return sorted(names, key=lambda n: (-collection.get_progress(n)["stage"], -collection.get_progress(n)["duplicates"], -collection.get_progress(n)["level"], n)) return sorted(names, key=lambda n: (n not in self.favs, n)) def _draw_runes(self): self._draw_page("Runes", "Every rune you own — equipped runes show their plant") dhov = self.runes_deconstruct_rect.collidepoint(self.mouse_pos) pygame.draw.rect(self.screen, (90, 120, 90) if (self.runes_deconstruct or dhov) else BTN, self.runes_deconstruct_rect, border_radius=6) dlbl = "Cancel" if self.runes_deconstruct else "Deconstruct" dt = self.tiny.render(dlbl, True, TEXT) self.screen.blit(dt, dt.get_rect(center=self.runes_deconstruct_rect.center)) ihov = self.runes_info_rect.collidepoint(self.mouse_pos) pygame.draw.rect(self.screen, (90, 120, 90) if (self.runes_info or ihov) else BTN, self.runes_info_rect, border_radius=6) il = self.tiny.render("Rune info", True, TEXT) self.screen.blit(il, il.get_rect(center=self.runes_info_rect.center)) # rune shop button shov = self.runes_shop_rect.collidepoint(self.mouse_pos) pygame.draw.rect(self.screen, (90, 120, 90) if shov else BTN, self.runes_shop_rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150), self.runes_shop_rect, 1, border_radius=6) sh = self.tiny.render("Rune shop", True, TEXT) self.screen.blit(sh, sh.get_rect(center=self.runes_shop_rect.center)) inventory = collection.rune_inventory() self._draw_runes_filters() if not inventory: t = self.small.render( "No runes yet — win floor battles or open seed packs to find some.", True, MUTED) self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 200))) return display = self._runes_displayed(inventory) top = 140 if self.runes_deconstruct: bcol = DANGER if self.runes_deconstruct_msg else ACCENT if self.runes_target is None: btxt = self.runes_deconstruct_msg or \ "Deconstruct — pick a rune to improve, then sacrifice 10 runes" else: btxt = self.runes_deconstruct_msg or \ f"Pick 10 runes to sacrifice ({len(self.runes_sacrifice)}/10) — " \ "click the target again to change it" b = self.tiny.render(btxt, True, bcol) self.screen.blit(b, b.get_rect(center=(WIN_W // 2, 124))) elif self.runes_buy_msg and pygame.time.get_ticks() < self.runes_buy_msg_until: b = self.tiny.render(self.runes_buy_msg, True, DANGER) self.screen.blit(b, b.get_rect(center=(WIN_W // 2, 124))) cols, cw, ch, gap = 6, 132, 122, 10 width = min(cols, len(display)) if display else 1 x0 = (WIN_W - (width * cw + (width - 1) * gap)) // 2 rows = (len(display) + width - 1) // width if display else 0 bottom = top + rows * (ch + gap) - gap self.grid_scroll = max(0, min(self.grid_scroll, max(0, bottom - (WIN_H - 62)))) self.grid_scroll_max = max(0, bottom - (WIN_H - 62)) scroll = self.grid_scroll old_clip = self.screen.get_clip() self.screen.set_clip(pygame.Rect(0, 138, WIN_W, WIN_H - 138 - 44)) self.rune_card_rects = [] if not display: t = self.small.render("No runes match the current filter.", True, MUTED) self.screen.blit(t, t.get_rect(center=(WIN_W // 2, top + 40))) for i, rune in enumerate(display): x = x0 + (i % width) * (cw + gap) y = top + (i // width) * (ch + gap) - scroll rect = pygame.Rect(x, y, cw, ch) self.rune_card_rects.append((rune["id"], rect)) pygame.draw.rect(self.screen, PANEL, rect, border_radius=8) pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=8) ic = self._rune_icon(rune["color"], 40) if ic: self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 30))) owner = collection.rune_location(rune["id"]) max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX is_max = rune.get("pct", 0) >= max_pct is_target = self.runes_deconstruct and self.runes_target \ and rune["id"] == self.runes_target["id"] is_sac = self.runes_deconstruct and self.runes_target \ and rune["id"] in self.runes_sacrifice if is_target: pygame.draw.rect(self.screen, HINT, rect, 3, border_radius=8) elif is_sac: pygame.draw.rect(self.screen, ACCENT, rect, 3, border_radius=8) name_col = MUTED if (self.runes_deconstruct and self.runes_target is None and is_max) else TEXT nm = self.tiny.render(rune["name"], True, name_col) self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 62))) eff = self.tiny.render(runes.card_text(rune["color"], rune.get("pct", 30)), True, MUTED) self.screen.blit(eff, eff.get_rect(center=(rect.centerx, rect.y + 78))) if is_target: st_text = "Upgrade target" elif is_sac: st_text = f"Sacrifice #{self.runes_sacrifice.index(rune['id']) + 1}" elif self.runes_deconstruct and self.runes_target is None and is_max: st_text = "MAX" elif owner: st_text = f"Equipped in: {owner}" else: st_text = "Free" st = self.tiny.render(self._fit_text(st_text, self.tiny, cw - 8), True, HINT if owner and not (is_target or is_sac) else (ACCENT if is_sac else (HINT if is_target else MUTED))) self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 96))) self.screen.set_clip(old_clip) if self.grid_scroll_max > 0: hint = "▲" if scroll > 0 else "" if scroll < self.grid_scroll_max: hint += "▼" if hint: ht = self.tiny.render(hint, True, MUTED) self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 44))) if self.runes_info: self._draw_runes_info() if self.runes_confirm is not None: self._draw_runes_confirm() if self.runes_result is not None: self._draw_runes_result() if self.runes_shop: self._draw_runes_shop() if self.runes_buy_popup is not None: self._draw_runes_bought() def _draw_runes_shop(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) w, h = 560, 380 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, ACCENT, panel, 2, border_radius=12) title = self.bold.render("Rune Shop", True, ACCENT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 26))) coins = collection.available_coins() bal = self.small.render(f"{coins} / {collection.COIN_CAP}", True, TEXT) total_w = (16 + 4) + bal.get_width() bx = panel.centerx - total_w // 2 if self.coin_icon: self.screen.blit(self.coin_icon, (bx, panel.y + 44)) self.screen.blit(bal, (bx + 20, panel.y + 44)) if self.runes_buy_msg and pygame.time.get_ticks() < self.runes_buy_msg_until: mt = self.tiny.render(self.runes_buy_msg, True, DANGER) self.screen.blit(mt, mt.get_rect(center=(panel.centerx, panel.y + 66))) cycle = pygame.time.get_ticks() // 1600 t1_color = runes.TIER1_COLORS[cycle % len(runes.TIER1_COLORS)] t2_color = runes.TIER2_COLORS[cycle % len(runes.TIER2_COLORS)] self._draw_shop_option(panel, 0, 1, t1_color, runes.T1_RUNE_COST) self._draw_shop_option(panel, 1, 2, t2_color, runes.T2_RUNE_COST) hint = self.tiny.render("Click outside to close", True, MUTED) self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 14))) def _draw_shop_option(self, panel, index, tier, color, cost): card_w = (panel.w - 96) // 2 card = pygame.Rect(panel.x + 36 + index * (card_w + 24), panel.y + 78, card_w, 262) pygame.draw.rect(self.screen, (24, 30, 24), card, border_radius=10) pygame.draw.rect(self.screen, (70, 80, 70), card, 1, border_radius=10) tl = self.bold.render(f"Tier {tier}", True, ACCENT) self.screen.blit(tl, tl.get_rect(center=(card.centerx, card.y + 20))) colors = runes.TIER1_COLORS if tier == 1 else runes.TIER2_COLORS total = len(colors) * 14 + (len(colors) - 1) * 4 dx = card.centerx - total // 2 for c in colors: sw = self._rune_icon(c, 14) if sw: self.screen.blit(sw, (dx, card.y + 36)) dx += 18 ic = self._rune_icon(color, 72) if ic: self.screen.blit(ic, ic.get_rect(center=(card.centerx, card.y + 90))) nm = self.small.render(runes.name(color), True, TEXT) self.screen.blit(nm, nm.get_rect(center=(card.centerx, card.y + 132))) sm = self.tiny.render(runes.summary(color), True, MUTED) self.screen.blit(sm, sm.get_rect(center=(card.centerx, card.y + 154))) rng = self.tiny.render(f"rolls {runes.roll_range(color)}", True, MUTED) self.screen.blit(rng, rng.get_rect(center=(card.centerx, card.y + 172))) afford = collection.available_coins() >= cost btn = pygame.Rect(card.x + 16, card.bottom - 48, card.w - 32, 32) hover = btn.collidepoint(self.mouse_pos) pygame.draw.rect(self.screen, (90, 120, 90) if (hover and afford) else (BTN if afford else (45, 50, 45)), btn, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if afford else (70, 80, 70), btn, 1, border_radius=6) lbl = self.tiny.render(f"Buy — {cost}", True, TEXT if afford else MUTED) total_w = (16 + 4) + lbl.get_width() bx = btn.centerx - total_w // 2 if self.coin_icon: self.screen.blit(self.coin_icon, (bx, btn.centery - 8)) self.screen.blit(lbl, (bx + 20, btn.centery - lbl.get_height() // 2)) if tier == 1: self.runes_shop_buy_t1_rect = btn else: self.runes_shop_buy_t2_rect = btn def _draw_runes_bought(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) w, h = 400, 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, ACCENT, panel, 2, border_radius=12) title = self.bold.render("Rune purchased!", True, ACCENT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) rune = self.runes_buy_popup ic = self._rune_icon(rune["color"], 40) if ic: self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 74))) line = self.small.render( f"{rune['name']} · {runes.card_text(rune['color'], rune['pct'])}", True, TEXT) self.screen.blit(line, line.get_rect(center=(panel.centerx, panel.y + 126))) close = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 22))) def _runes_displayed(self, inventory): shown = [r for r in inventory if (self.runes_filter_tier == 0 or runes.tier(r["color"]) == self.runes_filter_tier) and (self.runes_filter_color is None or r["color"] == self.runes_filter_color)] if self.runes_sort_best: shown.sort(key=runes.roll_ratio, reverse=True) return shown def _click_runes_filter(self, pos): for kind, val, rect in self.runes_filter_rects: if rect.collidepoint(pos): if kind == "tier": self.runes_filter_tier = val elif kind == "color": self.runes_filter_color = val return True if self.runes_sort_rect.collidepoint(pos): self.runes_sort_best = not self.runes_sort_best return True return False def _draw_runes_filters(self): self.runes_filter_rects = [] y, h = 96, 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.runes_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.runes_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.runes_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.runes_filter_rects.append(("color", None, all_rect)) x += 42 for color in runes.RUNE_COLORS: rect = pygame.Rect(x, y, 24, h) active = self.runes_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, border_radius=4) sw = self._rune_icon(color, 22) if sw: self.screen.blit(sw, sw.get_rect(center=rect.center)) self.runes_filter_rects.append(("color", color, rect)) x += 28 active = self.runes_sort_best pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), self.runes_sort_rect, border_radius=4) pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), self.runes_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.runes_sort_rect.center)) def _buy_rune(self, tier): result = collection.buy_rune(tier) if isinstance(result, dict): self.runes_buy_popup = result self.runes_buy_msg = "" else: self.runes_buy_msg = result self.runes_buy_msg_until = pygame.time.get_ticks() + 1800 def _click_runes_deconstruct(self, pos): inventory = collection.rune_inventory() by_id = {r["id"]: r for r in inventory} for rune_id, rect in self.rune_card_rects: if rect.collidepoint(pos): rune = by_id.get(rune_id) if not rune: return if self.runes_target is None: max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX if rune.get("pct", 0) >= max_pct: self.runes_deconstruct_msg = f"{rune['name']} is already at max roll" return self.runes_target = rune self.runes_sacrifice = [] self.runes_deconstruct_msg = "" else: if rune_id == self.runes_target["id"]: # click the target again to change it self.runes_target = None self.runes_sacrifice = [] self.runes_deconstruct_msg = "" return if rune_id in self.runes_sacrifice: self.runes_sacrifice.remove(rune_id) elif len(self.runes_sacrifice) >= 10: self.runes_deconstruct_msg = "Already picked 10 runes" return else: self.runes_sacrifice.append(rune_id) self.runes_deconstruct_msg = "" if len(self.runes_sacrifice) == 10: self._open_runes_confirm() return def _open_runes_confirm(self): equipped = [] for sid in self.runes_sacrifice: owner = collection.rune_location(sid) if owner: equipped.append((sid, owner)) self.runes_confirm = { "kind": "deconstruct", "rune": self.runes_target, "sacrifice": list(self.runes_sacrifice), "equipped": equipped, } def _draw_runes_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._rune_icon(color, 40) 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_runes_result(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) w, h = 420, 200 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, ACCENT, panel, 2, border_radius=12) title = self.bold.render("Rune improved!", True, ACCENT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) res = self.runes_result ic = self._rune_icon(res["color"], 40) if ic: self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 72))) line = self.small.render( f"{res['name']}: {res['before']}% -> {res['after']}%", True, TEXT) self.screen.blit(line, line.get_rect(center=(panel.centerx, panel.y + 122))) close = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 22))) def _draw_runes_confirm(self): overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) equipped = self.runes_confirm.get("equipped", []) w, h = 520, 210 if equipped else 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.runes_confirm["rune"] name = rune["name"] max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX current = rune.get("pct", 0) title = self.bold.render("Reroll rune?", True, TEXT) msg = self.small.render( f"Sacrifice 10 runes to improve {name} to {current + 1}–{max_pct}%?", True, TEXT) 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 + 60))) if equipped: plants = ", ".join(sorted({p for _, p in equipped})) warn = self.tiny.render( f"{len(equipped)} equipped rune(s) on {plants} will be removed", True, DANGER) self.screen.blit(warn, warn.get_rect(center=(panel.centerx, panel.y + 84))) btn_y = panel.y + 120 else: btn_y = panel.y + 108 self.runes_confirm_ok = pygame.Rect(panel.centerx - 110, btn_y, 100, 36) self.runes_confirm_no = pygame.Rect(panel.centerx + 10, btn_y, 100, 36) pygame.draw.rect(self.screen, DANGER, self.runes_confirm_ok, border_radius=8) ok = self.bold.render("Reroll", True, TEXT) self.screen.blit(ok, ok.get_rect(center=self.runes_confirm_ok.center)) pygame.draw.rect(self.screen, BTN, self.runes_confirm_no, border_radius=8) no = self.bold.render("Cancel", True, TEXT) self.screen.blit(no, no.get_rect(center=self.runes_confirm_no.center)) def _draw_collection(self): self._draw_page("Collection", f"{len(self.owned)} / {len(self.names)} plants owned") mouse = self.mouse_pos for key, rect in self.collection_tab_btns.items(): active = self.collection_tab == key hover = rect.collidepoint(mouse) pygame.draw.rect(self.screen, (90, 120, 90) if (hover or active) else BTN, rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if active else (70, 80, 70), rect, 1, border_radius=6) t = self.tiny.render("Plants" if key == "plants" else "Runes", True, TEXT) self.screen.blit(t, t.get_rect(center=rect.center)) if self.collection_tab == "runes": self._draw_collection_runes() else: self._draw_grid(self.names, reveal_all=False, show_evo=False, reveal_owned=True, y0=144) def _draw_collection_runes(self): mouse = self.mouse_pos self._draw_coll_rune_filters() colors = self._collection_rune_colors() owned_colors = {r["color"] for r in collection.rune_inventory()} cw, ch, gap = 170, 190, 16 cols = 5 total = cols * cw + (cols - 1) * gap x0 = (WIN_W - total) // 2 y0 = 184 self.coll_rune_cards = [] for i, color in enumerate(colors): x = x0 + (i % cols) * (cw + gap) y = y0 + (i // cols) * (ch + gap) rect = pygame.Rect(x, y, cw, ch) self.coll_rune_cards.append((color, rect)) obtained = color in owned_colors pygame.draw.rect(self.screen, PANEL, rect, border_radius=8) pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=8) ic = self._rune_icon(color, 64) if ic: ipos = ic.get_rect(center=(rect.centerx, rect.y + 50)) if not obtained: dim = ic.copy() dim.set_alpha(70) self.screen.blit(dim, ipos) else: self.screen.blit(ic, ipos) nm = self.small.render(runes.name(color), True, TEXT if obtained else MUTED) self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 88))) if obtained: eff = self.tiny.render(runes.summary(color), True, MUTED) self.screen.blit(eff, eff.get_rect(center=(rect.centerx, rect.y + 108))) if not obtained: self.screen.blit(self.tiny.render("Not obtained", True, (110, 115, 110)), self.tiny.render("Not obtained", True, (110, 115, 110)).get_rect(center=(rect.centerx, rect.y + 166))) else: t1 = self.tiny.render(f"Tier {runes.tier(color)} · max {runes.roll_range(color)}", True, HINT) self.screen.blit(t1, t1.get_rect(center=(rect.centerx, rect.y + 166))) if self.coll_rune_detail is not None: self._draw_coll_rune_detail() def _draw_coll_rune_filters(self): self.coll_rune_filter_rects = [] y, h = 140, 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.coll_rune_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.coll_rune_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.coll_rune_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.coll_rune_filter_rects.append(("color", None, all_rect)) x += 42 for color in runes.RUNE_COLORS: rect = pygame.Rect(x, y, 24, h) active = self.coll_rune_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, border_radius=4) sw = self._rune_icon(color, 22) if sw: self.screen.blit(sw, sw.get_rect(center=rect.center)) self.coll_rune_filter_rects.append(("color", color, rect)) x += 28 active = self.coll_rune_sort_best pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), self.coll_rune_sort_rect, border_radius=4) pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), self.coll_rune_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.coll_rune_sort_rect.center)) def _click_coll_rune_filter(self, pos): for kind, val, rect in self.coll_rune_filter_rects: if rect.collidepoint(pos): if kind == "tier": self.coll_rune_tier = val elif kind == "color": self.coll_rune_color = val return True if self.coll_rune_sort_rect.collidepoint(pos): self.coll_rune_sort_best = not self.coll_rune_sort_best return True return False def _draw_coll_rune_detail(self): color = self.coll_rune_detail overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) w, h = 360, 240 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, ACCENT, panel, 2, border_radius=12) title = self.bold.render(runes.name(color), True, TEXT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) ic = self._rune_icon(color, 64) if ic: self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 78))) eff = self.small.render(runes.summary(color), True, TEXT) self.screen.blit(eff, eff.get_rect(center=(panel.centerx, panel.y + 122))) dmg = self.tiny.render(runes.effect_text(color, runes.PCT_MAX if color != "brown" else runes.BROWN_PCT_MAX), True, MUTED) self.screen.blit(dmg, dmg.get_rect(center=(panel.centerx, panel.y + 146))) t1 = self.small.render(f"Tier {runes.tier(color)} · Max roll {runes.roll_range(color)}", True, HINT) self.screen.blit(t1, t1.get_rect(center=(panel.centerx, panel.y + 178))) close = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 18))) def _collection_rune_colors(self): colors = [c for c in runes.RUNE_COLORS if (self.coll_rune_tier == 0 or runes.tier(c) == self.coll_rune_tier) and (self.coll_rune_color is None or c == self.coll_rune_color)] if self.coll_rune_sort_best: inv = collection.rune_inventory() best = {c: max((r.get("pct", 0) for r in inv if r["color"] == c), default=0) for c in colors} colors.sort(key=lambda c: best[c] / (runes.BROWN_PCT_MAX if c == "brown" else runes.PCT_MAX), reverse=True) return colors def _draw_detail(self): name = self.detail_name pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) t = self.small.render("Back", True, TEXT) self.screen.blit(t, t.get_rect(center=self.back_rect.center)) pygame.draw.rect(self.screen, BTN, self.detail_left, border_radius=6) t = self.bold.render("<", True, TEXT) self.screen.blit(t, t.get_rect(center=self.detail_left.center)) pygame.draw.rect(self.screen, BTN, self.detail_right, border_radius=6) t = self.bold.render(">", True, TEXT) self.screen.blit(t, t.get_rect(center=self.detail_right.center)) if name in self.detail_list: pos = self.tiny.render(f"{self.detail_list.index(name) + 1}/{len(self.detail_list)}", True, MUTED) self.screen.blit(pos, pos.get_rect(topright=(WIN_W - 70, 24))) if name is None or name not in POOL: return spec = POOL[name] found = name in self.owned if self.detail_from == "collection" else True sprite_rect = pygame.Rect(70, 110, 150, 150) spr = self.sprites.get(name) if spr: ic = fit_surface(spr, 150) self.screen.blit(ic, ic.get_rect(center=sprite_rect.center)) if spr and not found: dim = ic.copy() dim.set_alpha(70) self.screen.blit(dim, dim.get_rect(center=sprite_rect.center)) pygame.draw.rect(self.screen, (70, 78, 70), sprite_rect, 2) name_txt = "???" if not found else name t = self.font.render(name_txt, True, TEXT if found else MUTED) self.screen.blit(t, (250, 120)) if found: rar = plant_rarity(name) rt = self.small.render(RARITY_LABELS.get(rar, rar), True, RARITY_COLORS.get(rar, TEXT)) self.screen.blit(rt, (250 + t.get_width() + 12, 126)) if name in self.owned: fav = collection.is_favorite(name) pygame.draw.rect(self.screen, (70, 80, 70) if fav else (50, 58, 50), self.star_rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if fav else (70, 80, 70), self.star_rect, 1, border_radius=6) _draw_star(self.screen, self.star_rect.center, 13, HINT if fav else MUTED) if not found: t = self.small.render("Not discovered yet.", True, MUTED) self.screen.blit(t, (250, 170)) t = self.small.render("Battle this plant to reveal its details.", True, MUTED) self.screen.blit(t, (250, 196)) return role_name = ROLE_NAMES.get(spec["role"], spec["role"]) ric = self._role_icon(spec["role"], 16) role_surf = self.small.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT)) sep_surf = self.small.render(" · ", True, MUTED) icon_w = 16 + 6 if ric else 0 if name in VARIABLE_TYPE_UNITS: tics = [load_type_icon(t, 16) for t in ALL_TYPES] type_w = sum((ic.get_width() + 3) if ic else self.small.size(t)[0] + 3 for ic, t in zip(tics, ALL_TYPES)) else: tic = load_type_icon(spec["type"], 18) type_w = (tic.get_width() + 4) if tic else self.small.size(spec["type"])[0] total_w = icon_w + role_surf.get_width() + sep_surf.get_width() + type_w h = role_surf.get_height() pill = pygame.Rect(250 - 8, 168 - 4, total_w + 16, h + 8) pygame.draw.rect(self.screen, (52, 60, 52), pill, border_radius=5) x = 250 if ric: self.screen.blit(ric, ric.get_rect(midleft=(x, 168 + h // 2))) x += icon_w self.screen.blit(role_surf, (x, 168)) x += role_surf.get_width() self.screen.blit(sep_surf, (x, 168)) x += sep_surf.get_width() if name in VARIABLE_TYPE_UNITS: for tic, t in zip(tics, ALL_TYPES): if tic: self.screen.blit(tic, tic.get_rect(midleft=(x, 168 + h // 2))) x += tic.get_width() + 3 else: ts = self.small.render(t, True, TYPE_COLORS.get(t, TEXT)) self.screen.blit(ts, (x, 168)) x += self.small.size(t)[0] + 3 else: tic = load_type_icon(spec["type"], 18) if tic: self.screen.blit(tic, tic.get_rect(midleft=(x, 168 + h // 2))) else: ts = self.small.render(spec["type"], True, TYPE_COLORS.get(spec["type"], TEXT)) self.screen.blit(ts, (x, 168)) self.detail_type_rect = pill self.type_choice_rects = {} if name in VARIABLE_TYPE_UNITS and self.detail_from != "collection" and name in self.owned: prog = collection.get_progress(name) evolved = prog["stage"] >= 2 current = collection.unit_type(name) tl = self.small.render("Type:", True, MUTED) self.screen.blit(tl, (250, 194)) x = 250 + tl.get_width() + 8 for pt in ALL_TYPES: rect = pygame.Rect(x, 192, 28, 28) active = current == pt bg = (90, 120, 90) if (evolved and active) else (45, 50, 45) border = HINT if (evolved and active) else (70, 80, 70) pygame.draw.rect(self.screen, bg, rect, border_radius=5) pygame.draw.rect(self.screen, border, rect, 1, border_radius=5) ic = load_type_icon(pt, 22) if ic: self.screen.blit(ic, ic.get_rect(center=rect.center)) if evolved: self.type_choice_rects[pt] = rect x += 34 if not evolved: note = self.tiny.render("Evolve once to choose a type", True, MUTED) self.screen.blit(note, (x + 4, 199)) if name in self.owned and self.detail_from != "collection": _draw_evo_badge(self.screen, self.tiny, (WIN_W - 56, 175), collection.get_progress(name)["stage"] - 1) show_owned = self.detail_from != "collection" if show_owned and name in self.owned: prog = collection.get_progress(name) lv = self.small.render(f"Level {prog['level']} · Stage {prog['stage']} · {prog['duplicates']} dup", True, HINT) self.screen.blit(lv, (250, 226)) if collection.injection_of(name): pygame.draw.rect(self.screen, DANGER, self.inject_remove_rect, border_radius=6) it = self.tiny.render("Remove injection", True, TEXT) self.screen.blit(it, it.get_rect(center=self.inject_remove_rect.center)) if prog["level"] < MAX_LEVEL: xp_next = xp_to_next(prog["level"]) frac = min(1.0, prog["xp"] / xp_next) bar = pygame.Rect(250, 250, 330, 12) pygame.draw.rect(self.screen, (45, 50, 45), bar, border_radius=6) if frac > 0: pygame.draw.rect(self.screen, (95, 145, 205), (bar.x, bar.y, int(bar.w * frac), bar.h), border_radius=6) xpt = self.tiny.render(f"XP {prog['xp']}/{xp_next}", True, TEXT) self.screen.blit(xpt, (bar.right + 8, bar.y - 2)) else: xpt = self.small.render("Max level", True, MUTED) self.screen.blit(xpt, (250, 246)) atk, spd, dfn, hp = compute_stats(spec["stats"], prog["level"], prog["stage"]) crit = spec.get("crit", 0.10) st = self.small.render(f"Attack {atk} Speed {spd}", True, TEXT) self.screen.blit(st, (250, 276)) st = self.small.render(f"Defense {dfn} Health {int(hp * HP_MULT)} Crit {int(crit * 100)}%", True, TEXT) self.screen.blit(st, (250, 300)) water_reason = collection.can_water(name) water_ok = water_reason == "" pygame.draw.rect(self.screen, BTN_HOVER if water_ok else (45, 50, 45), self.water_rect, border_radius=6) wtxt = "Water (+1 level)" if water_ok else f"Water ({water_reason})" t = self.tiny.render(wtxt, True, TEXT if water_ok else MUTED) self.screen.blit(t, t.get_rect(center=self.water_rect.center)) ev_ok = can_evolve(prog["level"], prog["stage"], prog["duplicates"]) pygame.draw.rect(self.screen, BTN_HOVER if ev_ok else (45, 50, 45), self.evolve_rect, border_radius=6) if ev_ok: etxt = "Evolve!" elif prog["stage"] >= 3: etxt = "Max evolved" else: cost = evolve_cost(prog["stage"]) etxt = f"Evolve (Lv milestone + {cost} dup)" if cost else "Evolve (unlocked later)" t = self.tiny.render(etxt, True, TEXT if ev_ok else MUTED) self.screen.blit(t, t.get_rect(center=self.evolve_rect.center)) seed_state = collection.soil_state() seed_ok = name not in VARIABLE_TYPE_UNITS and seed_state["cd_left"] <= 0 pygame.draw.rect(self.screen, BTN_HOVER if seed_ok else (45, 50, 45), self.seed_rect, border_radius=6) if name in VARIABLE_TYPE_UNITS: stxt = "No seed" elif seed_ok: stxt = "Retrieve Seed" else: stxt = f"Seed in {int(seed_state['cd_left'])}s" st = self.tiny.render(stxt, True, TEXT if seed_ok else MUTED) self.screen.blit(st, st.get_rect(center=self.seed_rect.center)) equipped = sum(1 for r in collection.equipped_runes(name) if r is not None) slots = collection.rune_slots(name) pygame.draw.rect(self.screen, BTN_HOVER, self.runes_rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150), self.runes_rect, 1, border_radius=6) rt = self.tiny.render(f"Runes ({equipped}/{slots})", True, TEXT) self.screen.blit(rt, rt.get_rect(center=self.runes_rect.center)) skills_y = 392 else: atk, spd, dfn, hp = spec["stats"] crit = spec.get("crit", 0.10) st = self.small.render(f"Attack {atk} Speed {spd}", True, TEXT) self.screen.blit(st, (250, 226)) st = self.small.render(f"Defense {dfn} Health {int(hp * HP_MULT)} Crit {int(crit * 100)}%", True, TEXT) self.screen.blit(st, (250, 250)) skills_y = 290 p = spec.get("passive", "") if p: pname, pdesc = PASSIVES.get(p, (p, "")) pt = self.bold.render(f"Passive — {pname}", True, HINT) self.screen.blit(pt, (60, skills_y)) lines = wrap_text(" " + pdesc, self.small, WIN_W - 120) for i, ln in enumerate(lines): self.screen.blit(self.small.render(ln, True, TEXT), (60, skills_y + 24 + i * 20)) skills_y += 26 + len(lines) * 20 + 4 y = skills_y inj = collection.injection_of(name) if show_owned else None for i, sk in enumerate(spec["skills"]): is_inj = bool(inj) and i == inj.get("index") if is_inj: sk = skill_from_dict(inj["skill"]) bracket = _bracket_text(sk) if bracket: label = f"{sk.name} [{bracket}] CD {sk.cooldown}" else: label = f"{sk.name} CD {sk.cooldown}" color = ACCENT if is_inj else HINT t = self.bold.render(label, True, color) self.screen.blit(t, (60, y)) if is_inj: tag = self.tiny.render("injected", True, ACCENT) self.screen.blit(tag, (60 + t.get_width() + 10, y + 2)) d = self.small.render(" " + sk.description, True, ACCENT if is_inj else TEXT) self.screen.blit(d, (60, y + 24)) y += 60 def _draw_grid(self, names, reveal_all=False, level_mode=False, show_evo=True, reveal_owned=False, data=None, y0=96): data = data or collection.load() cols, cw, ch, gap = 4, 210, 150, 14 width = min(cols, len(names)) x0 = (WIN_W - (width * cw + (width - 1) * gap)) // 2 rows = (len(names) + width - 1) // width bottom = y0 + rows * (ch + gap) - gap self.grid_scroll = max(0, min(self.grid_scroll, max(0, bottom - (WIN_H - 62)))) self.grid_scroll_max = max(0, bottom - (WIN_H - 62)) scroll = self.grid_scroll self.grid_hit = [] self.type_tag_rects = {} old_clip = self.screen.get_clip() self.screen.set_clip(pygame.Rect(0, 90, WIN_W, WIN_H - 90 - 46)) for i, name in enumerate(names): x = x0 + (i % width) * (cw + gap) y = y0 + (i // width) * (ch + gap) - scroll rect = pygame.Rect(x, y, cw, ch) self.grid_hit.append((name, rect)) if reveal_owned: found = name in self.owned else: found = reveal_all or name in self.discovered pygame.draw.rect(self.screen, PANEL, rect, border_radius=6) pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=6) spr = self.sprites.get(name) sprite_rect = pygame.Rect(rect.centerx - 44, rect.y + 12, 88, 88) if spr: ic = fit_surface(spr, 88) self.screen.blit(ic, ic.get_rect(center=sprite_rect.center)) if spr and not found: dim = ic.copy() dim.set_alpha(70) self.screen.blit(dim, dim.get_rect(center=sprite_rect.center)) pygame.draw.rect(self.screen, (70, 78, 70), sprite_rect, 1) name_txt = "???" if not found else name t = self.tiny.render(name_txt, True, TEXT if found else MUTED) self.screen.blit(t, t.get_rect(center=(rect.centerx, rect.y + 110))) if found: if level_mode: lv = collection.get_progress(name, data)["level"] lvt = self.tiny.render(f"Lv {lv}", True, HINT) self.screen.blit(lvt, lvt.get_rect(center=(rect.centerx, rect.y + 130))) can = collection.can_water(name, data) == "" smart = collection.smart_water_candidate(name, data) if smart: pygame.draw.rect(self.screen, (245, 200, 90), rect, 3, border_radius=6) elif can: pygame.draw.rect(self.screen, (110, 190, 110), rect, 2, border_radius=6) else: pygame.draw.rect(self.screen, (70, 78, 70), rect, 1, border_radius=6) else: role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"]) ric = self._role_icon(POOL[name]["role"], 12) rt = self.tiny.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT)) sep = self.tiny.render(" · ", True, MUTED) role_icon_w = 12 + 3 if ric else 0 if name in VARIABLE_TYPE_UNITS: tics = [(load_type_icon(t, 12), t) for t in ALL_TYPES] type_w = sum((ic.get_width() + 2) if ic else self.tiny.size(t)[0] + 2 for ic, t in tics) else: tic = load_type_icon(POOL[name]["type"], 14) type_w = (tic.get_width() + 2) if tic else self.tiny.size(POOL[name]["type"])[0] total_w = role_icon_w + rt.get_width() + sep.get_width() + type_w row_y = rect.y + 130 rt_rect = rt.get_rect(topleft=(rect.centerx - total_w // 2 + role_icon_w, row_y)) sep_rect = sep.get_rect(midleft=(rt_rect.right, rt_rect.centery)) if ric: self.screen.blit(ric, ric.get_rect(midright=(rt_rect.left - 3, rt_rect.centery))) self.screen.blit(rt, rt_rect) self.screen.blit(sep, sep_rect) tx = sep_rect.right + 2 if name in VARIABLE_TYPE_UNITS: first_tr = last_tr = None for tic, t in tics: if tic: tr = tic.get_rect(midleft=(tx, sep_rect.centery)) self.screen.blit(tic, tr) else: ty = self.tiny.render(t, True, MUTED) tr = ty.get_rect(midleft=(tx, sep_rect.centery)) self.screen.blit(ty, tr) first_tr = first_tr or tr last_tr = tr tx += tr.width + 2 self.type_tag_rects[name] = first_tr.union(last_tr).inflate(6, 4) else: tic = load_type_icon(POOL[name]["type"], 14) if tic: tr = tic.get_rect(midleft=(tx, sep_rect.centery)) self.screen.blit(tic, tr) else: ty = self.tiny.render(POOL[name]["type"], True, TYPE_COLORS.get(POOL[name]["type"], MUTED)) tr = ty.get_rect(midleft=(tx, sep_rect.centery)) self.screen.blit(ty, tr) self.type_tag_rects[name] = tr.inflate(6, 4) if name in self.owned and show_evo: evo = collection.get_progress(name, data)["stage"] - 1 _draw_evo_badge(self.screen, self.tiny, (rect.x + 22, rect.y + 28), evo) if name in self.favs: _draw_star(self.screen, (rect.x + 18, rect.y + 12), 8, HINT) if name in self.owned and not level_mode: lv = self.tiny.render(f"Lv {collection.get_progress(name, data)['level']}", True, HINT) self.screen.blit(lv, (rect.right - 34, rect.y + 8)) self.screen.set_clip(old_clip) if self.grid_scroll_max > 0: hint = "▲" if scroll > 0 else "" if scroll < self.grid_scroll_max: hint += "▼" if hint: ht = self.tiny.render(hint, True, MUTED) self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 46)))