Files
Flora/ui/menu.py
T

2477 lines
123 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)))