Initial commit: Plants turn-based battle game

This commit is contained in:
2026-08-28 14:26:07 +02:00
commit 6840b715de
158 changed files with 13352 additions and 0 deletions
View File
+315
View File
@@ -0,0 +1,315 @@
import pygame
from engine import collection
from engine.growth import MAX_LEVEL
from engine.plants import POOL, RARITY_COLORS, plant_rarity
from engine.skills import skill_from_dict
from engine.unit import ROLE_NAMES
from ui.menu import _bracket_text
from ui.scaling import make_window, request_quit, to_logical
WIN_W, WIN_H = 1040, 720
BG = (24, 30, 24)
TEXT = (230, 235, 230)
MUTED = (150, 155, 150)
HINT = (255, 220, 120)
PANEL = (32, 38, 32)
BTN = (60, 90, 60)
BTN_HOVER = (85, 125, 85)
ACCENT = (120, 220, 190)
DANGER = (150, 70, 70)
ORDINALS = {1: "2nd", 2: "3rd"}
class ExtractionScreen:
def __init__(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 — Ability Extraction")
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
self.small = pygame.font.SysFont("segoeui", 16)
self.tiny = pygame.font.SysFont("segoeui", 13)
self.back_rect = pygame.Rect(16, 16, 90, 34)
self.step_rect = pygame.Rect(116, 16, 120, 34)
self.tab_rects = {
"extract": pygame.Rect(WIN_W // 2 - 210, 78, 200, 36),
"inject": pygame.Rect(WIN_W // 2 + 10, 78, 200, 36),
}
self.tab = "extract"
self.phase = "list" # extract: list -> skills ; inject: ability -> plant -> slot
self.source = None # selected source plant (extract)
self.ability = None # selected extracted-ability index (inject)
self.target = None # selected target plant (inject)
self.item_rects = [] # (key, rect) for the current phase list
self.confirm = None # dict, or None
self.confirm_ok = pygame.Rect(0, 0, 0, 0)
self.confirm_no = pygame.Rect(0, 0, 0, 0)
self.mouse_pos = (0, 0)
# ---- helpers -------------------------------------------------------
def _deep(self):
if self.tab == "extract":
return self.phase == "skills"
return self.phase in ("plant", "slot")
def _reset(self, tab):
self.tab = tab
self.phase = "list" if tab == "extract" else "ability"
self.source = None
self.ability = None
self.target = None
self.confirm = None
# ---- click ---------------------------------------------------------
def _handle(self, key):
if self.tab == "extract":
if self.phase == "list":
self.source = key
self.phase = "skills"
elif self.phase == "skills":
self.confirm = {"kind": "extract", "name": self.source, "index": key}
else:
if self.phase == "ability":
self.ability = key
self.phase = "plant"
elif self.phase == "plant":
self.target = key
self.phase = "slot"
elif self.phase == "slot":
self.confirm = {"kind": "inject", "ability": self.ability,
"target": self.target, "slot": key}
def _confirm_ok(self):
c = self.confirm
if c["kind"] == "extract":
if collection.extract_skill(c["name"], c["index"]):
self.phase = "list"
self.source = None
else:
if collection.inject_skill(c["ability"], c["target"], c["slot"]):
self.phase = "ability"
self.ability = None
self.target = None
self.confirm = None
def run(self):
running = True
while running:
self.clock.tick(60)
self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale)
for event in pygame.event.get():
if event.type == pygame.QUIT:
request_quit()
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale)
if self.confirm is not None:
if self.confirm_ok.collidepoint(pos):
self._confirm_ok()
elif self.confirm_no.collidepoint(pos):
self.confirm = None
continue
if self.back_rect.collidepoint(pos):
running = False
continue
if self.step_rect.collidepoint(pos) and self._deep():
self._step_back()
continue
if self.tab_rects["extract"].collidepoint(pos):
if self.tab != "extract":
self._reset("extract")
continue
if self.tab_rects["inject"].collidepoint(pos):
if self.tab != "inject":
self._reset("inject")
continue
for key, rect in self.item_rects:
if rect.collidepoint(pos):
self._handle(key)
break
self._draw()
pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window)
pygame.display.flip()
def _step_back(self):
if self.tab == "extract":
self.phase = "list"
self.source = None
else:
if self.phase == "slot":
self.phase = "plant"
self.target = None
elif self.phase == "plant":
self.phase = "ability"
self.ability = None
# ---- drawing -------------------------------------------------------
def _draw(self):
self.item_rects = []
self.screen.fill(BG)
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))
if self._deep():
pygame.draw.rect(self.screen, BTN, self.step_rect, border_radius=6)
st = self.small.render("← Back", True, TEXT)
self.screen.blit(st, st.get_rect(center=self.step_rect.center))
title = self.font.render("Ability Extraction", True, HINT)
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 38)))
sub = self.small.render("Sacrifice a maxed plant to copy an ability; inject it into a same-role plant.",
True, MUTED)
self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 68)))
for name, rect in self.tab_rects.items():
active = self.tab == name
pygame.draw.rect(self.screen, BTN_HOVER if active else BTN, rect, border_radius=8)
pygame.draw.rect(self.screen, (150, 190, 150) if active else (70, 80, 70),
rect, 2 if active else 1, border_radius=8)
tt = self.small.render(name.capitalize(), True, TEXT)
self.screen.blit(tt, tt.get_rect(center=rect.center))
if self.confirm is not None:
self._draw_confirm()
elif self.tab == "extract":
self._draw_extract()
else:
self._draw_inject()
# ---- extract tab ---------------------------------------------------
def _draw_extract(self):
if self.phase == "list":
sources = sorted(n for n in collection.owned() if collection.can_extract(n) == "")
if not sources:
t = self.small.render(
f"No plant is ready yet. A plant needs Lv {MAX_LEVEL} and 2 evolutions.", True, MUTED)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 220)))
return
rows = []
for n in sources:
spec = POOL[n]
rows.append((n, n, RARITY_COLORS.get(plant_rarity(n), TEXT),
f"{ROLE_NAMES.get(spec['role'], spec['role'])} · Lv {MAX_LEVEL} · 2 evolutions"))
self._draw_items(rows, "Plants ready to extract from:")
else:
self._draw_extract_skills()
def _draw_extract_skills(self):
rows = []
for i, sk in enumerate(POOL[self.source]["skills"]):
reason = collection.extraction_blocked_reason(self.source, i, sk)
if reason:
rows.append((i, sk.name, MUTED, f"Not extractable — {reason}", False))
else:
rows.append((i, sk.name, ACCENT, _skill_sub(sk)))
self._draw_items(rows, f"Extract from {self.source}:")
# ---- inject tab ----------------------------------------------------
def _draw_inject(self):
if self.phase == "ability":
ext = collection.extracted_skills()
if not ext:
t = self.small.render("No extracted abilities yet — extract one first.", True, MUTED)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 220)))
return
rows = [(i, r["skill_name"], ACCENT,
f"from {r['source']} · {ROLE_NAMES.get(r['role'], r['role'])}")
for i, r in enumerate(ext)]
self._draw_items(rows, "Pick an extracted ability:")
elif self.phase == "plant":
rec = collection.extracted_skills()[self.ability]
targets = sorted(n for n in collection.owned() if collection.can_inject(n, rec) == "")
if not targets:
t = self.small.render("No eligible plants (same role, no injection).", True, MUTED)
self.screen.blit(t, (60, 150))
return
rows = [(n, n, RARITY_COLORS.get(plant_rarity(n), TEXT),
ROLE_NAMES.get(POOL[n]["role"], POOL[n]["role"])) for n in targets]
self._draw_items(rows, f"Inject {rec['skill_name']} into:")
else:
self._draw_inject_slots()
def _draw_inject_slots(self):
rec = collection.extracted_skills()[self.ability]
spec = POOL[self.target]
rows = []
for slot in (1, 2):
if slot >= len(spec["skills"]):
continue
sk = spec["skills"][slot]
reason = collection.injection_slot_reason(self.target, rec, slot)
if reason:
rows.append((slot, f"Replace {ORDINALS[slot]} skill ({sk.name})", MUTED,
f"Not allowed — {reason}", False))
else:
rows.append((slot, f"Replace {ORDINALS[slot]} skill ({sk.name})", HINT,
f"CD {sk.cooldown} · {sk.description}"))
self._draw_items(rows, f"Inject {rec['skill_name']} into {self.target} — replace:")
# ---- shared list ---------------------------------------------------
def _draw_items(self, rows, header):
h = self.bold.render(header, True, MUTED)
self.screen.blit(h, (60, 118))
y = 146
for row in rows:
key, label, color, sub = row[0], row[1], row[2], row[3]
enabled = row[4] if len(row) > 4 else True
rect = pygame.Rect(60, y, 560, 46)
if enabled:
self.item_rects.append((key, rect))
hover = enabled and rect.collidepoint(self.mouse_pos)
pygame.draw.rect(self.screen, BTN_HOVER if hover else PANEL, rect, border_radius=6)
pygame.draw.rect(self.screen, (70, 90, 70) if hover else (60, 70, 60),
rect, 2 if hover else 1, border_radius=6)
lt = self.bold.render(label, True, color)
self.screen.blit(lt, (rect.x + 12, rect.y + 5))
if sub:
st = self.tiny.render(sub, True, MUTED)
self.screen.blit(st, (rect.x + 12, rect.y + 26))
y += 54
# ---- confirm overlay -----------------------------------------------
def _draw_confirm(self):
shade = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
shade.fill((0, 0, 0, 170))
self.screen.blit(shade, (0, 0))
panel = pygame.Rect(WIN_W // 2 - 250, 200, 500, 200)
pygame.draw.rect(self.screen, PANEL, panel, border_radius=12)
pygame.draw.rect(self.screen, (90, 110, 90), panel, 2, border_radius=12)
c = self.confirm
if c["kind"] == "extract":
sk = POOL[c["name"]]["skills"][c["index"]]
title = f"Extract {sk.name}?"
body = [f"from {c['name']} — this resets it to Lv 1 / Stage 1.",
"The ability is stored for one injection."]
else:
rec = collection.extracted_skills()[c["ability"]]
spec = POOL[c["target"]]
title = f"Inject {rec['skill_name']} into {c['target']}?"
body = [f"Replaces the {ORDINALS[c['slot']]} skill ({spec['skills'][c['slot']].name}).",
"Consumes the extracted ability.",
"Removing it later destroys the ability."]
tt = self.bold.render(title, True, HINT)
self.screen.blit(tt, tt.get_rect(center=(panel.centerx, panel.y + 40)))
yy = panel.y + 76
for line in body:
b = self.small.render(line, True, TEXT)
self.screen.blit(b, b.get_rect(center=(panel.centerx, yy)))
yy += 28
self.confirm_ok = pygame.Rect(panel.centerx - 145, panel.bottom - 52, 135, 38)
self.confirm_no = pygame.Rect(panel.centerx + 10, panel.bottom - 52, 135, 38)
pygame.draw.rect(self.screen, BTN_HOVER, self.confirm_ok, border_radius=8)
ot = self.bold.render("Confirm", True, TEXT)
self.screen.blit(ot, ot.get_rect(center=self.confirm_ok.center))
pygame.draw.rect(self.screen, DANGER, self.confirm_no, border_radius=8)
nt = self.bold.render("Cancel", True, TEXT)
self.screen.blit(nt, nt.get_rect(center=self.confirm_no.center))
def _skill_sub(sk) -> str:
bracket = _bracket_text(sk)
head = f"CD {sk.cooldown}"
if bracket:
head += f" [{bracket}]"
return f"{head} · {sk.description}"
+1192
View File
File diff suppressed because it is too large Load Diff
+2477
View File
File diff suppressed because it is too large Load Diff
+656
View File
@@ -0,0 +1,656 @@
import pygame
from assets import icon as icon_path
from assets import load_sprite
from engine import collection
from engine.growth import compute_stats
from engine.plants import HP_MULT, PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS, plant_rarity
from engine.roster import floor_enemy_level, roll_enemies
from engine.unit import ROLE_ATTACKER, ROLE_ICONS, ROLE_NAMES, ROLE_SUPPORT, ROLE_TANK
from ui.menu import _bracket_text, _draw_evo_badge
from ui.runes import draw_rune_chips
from ui.scaling import (fit_surface, load_rune_icon, load_type_icon, make_window,
request_quit, to_logical, wrap_text)
from ui.typepopup import draw_type_popup
TYPE_COLORS = {
"radiant": (245, 200, 90),
"nocturnal": (150, 120, 220),
"ice": (130, 200, 235),
"garden": (120, 200, 120),
"wildflower": (235, 130, 170),
}
BG = (24, 30, 24)
TEXT = (230, 235, 230)
MUTED = (150, 155, 150)
SLOT = (42, 50, 42)
SLOT_EMPTY = (34, 40, 34)
HIGHLIGHT = (255, 220, 120)
HINT = (255, 220, 120)
BTN = (60, 90, 60)
BTN_HOVER = (85, 125, 85)
WIN_W, WIN_H = 1040, 720
SLOT_W, SLOT_H = 220, 52
SLOT_GAP = 8
class TeamSelect:
def __init__(self, initial_enemies=None, difficulty=1, tower_mode=False,
pick_goal=4, min_goal=1, restrict_to=None, show_slots=True,
header=None, start_label=None, enemy_types=None, no_roll=False,
enemy_levels=None):
pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption(header or "Plants — Tower")
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
self.small = pygame.font.SysFont("segoeui", 16)
self.tiny = pygame.font.SysFont("segoeui", 13)
self.difficulty = difficulty
self.tower_mode = tower_mode
self.header = header
self.start_label = start_label
self.pick_goal = pick_goal
self.min_goal = min_goal
self.show_slots = show_slots
self.enemy_types = list(enemy_types) if enemy_types else []
self.enemy_levels = list(enemy_levels) if enemy_levels else []
self.no_roll = no_roll
self.names = list(POOL.keys())
self.favs = collection.favorites()
if restrict_to is not None:
self.pool_names = sorted(restrict_to, key=lambda n: (n not in self.favs, n))
else:
self.pool_names = sorted((n for n in self.names if n in collection.owned()),
key=lambda n: (n not in self.favs, n))
self.sprites = {n: self._load(n) for n in self.names}
self.rune_icons = {}
self.role_icons = {}
self.allies = []
if self.tower_mode:
self.phase = 2
self.enemies = list(initial_enemies) if initial_enemies else []
else:
self.phase = 1
self.enemies = list(initial_enemies) if initial_enemies else []
if not self.enemies:
self._roll_enemies()
self._layout_enemy_cards()
self._layout_enemy_strip()
self._layout_slots()
self._layout_pool()
self.roll_rect = pygame.Rect(WIN_W // 2 - 260, WIN_H - 60, 170, 40)
self.next_rect = pygame.Rect(WIN_W // 2 + 90, WIN_H - 60, 180, 40)
self.start_rect = pygame.Rect(WIN_W // 2 - 90, WIN_H - 60, 180, 40)
self.preset_rects = [pygame.Rect(WIN_W - 254 + i * 62, 16, 56, 28) for i in range(4)]
self.back_rect = pygame.Rect(12, 12, 90, 32)
self.detail_open = False
self.detail_name = None
self.detail_list = []
self.detail_index = 0
self.detail_rect = pygame.Rect(WIN_W // 2 - 280, 120, 560, 480)
self.detail_left = pygame.Rect(self.detail_rect.left - 44, self.detail_rect.centery - 24, 36, 48)
self.detail_right = pygame.Rect(self.detail_rect.right + 8, self.detail_rect.centery - 24, 36, 48)
self.detail_close = pygame.Rect(self.detail_rect.right - 44, self.detail_rect.top + 8, 36, 28)
self.detail_add = pygame.Rect(self.detail_rect.centerx - 70, self.detail_rect.bottom - 44, 140, 32)
self.type_info = None
self.type_tags = []
self.detail_type_rect = None
def _role_icon(self, role, size=14):
key = (role, size)
if key not in self.role_icons:
name = ROLE_ICONS.get(role)
if name:
try:
img = pygame.image.load(icon_path("role_icons", name)).convert_alpha()
self.role_icons[key] = fit_surface(img, size)
except (pygame.error, OSError):
self.role_icons[key] = None
else:
self.role_icons[key] = None
return self.role_icons[key]
def _load(self, name):
return load_sprite(name, "full")
def _rune_icon(self, color):
if color not in self.rune_icons:
self.rune_icons[color] = load_rune_icon(color, 14)
return self.rune_icons[color]
def _roll_enemies(self):
self.enemies = roll_enemies(self.difficulty, exclude=tuple(self.allies))
def _enemy_level(self, idx):
"""Displayed level of enemy `idx` (explicit list, else the floor default)."""
if idx < len(self.enemy_levels):
return self.enemy_levels[idx]
return floor_enemy_level(self.difficulty)
def _draw_enemy_level(self, tag, idx):
"""'Lv N' label just to the right of an enemy's role/type tag."""
lv = self.tiny.render(f"Lv {self._enemy_level(idx)}", True, HINT)
self.screen.blit(lv, (tag.right + 5, tag.y + 1))
def _layout_enemy_cards(self):
cw, ch, gap = 210, 250, 18
total = 4 * cw + 3 * gap
x0 = (WIN_W - total) // 2
self.enemy_card_rects = [pygame.Rect(x0 + i * (cw + gap), 96, cw, ch) for i in range(4)]
def _layout_enemy_strip(self):
cw, ch, gap = 220, 84, 16
total = 4 * cw + 3 * gap
x0 = (WIN_W - total) // 2
self.enemy_strip_rects = [pygame.Rect(x0 + i * (cw + gap), 56, cw, ch) for i in range(4)]
def _layout_slots(self):
n = self.pick_goal
if n > 4:
sl_h, gap = 40, 4
else:
sl_h, gap = SLOT_H, SLOT_GAP
self.ally_rects = [pygame.Rect(24, 170 + i * (sl_h + gap), SLOT_W, sl_h) for i in range(n)]
def _layout_pool(self):
self.pool_rects = {}
cols = min(4, max(1, len(self.pool_names)))
cw, ch, gap = 170, 64, 10
x0 = 290
y0 = 170
for i, name in enumerate(self.pool_names):
x = x0 + (i % cols) * (cw + gap)
y = y0 + (i // cols) * (ch + gap)
self.pool_rects[name] = pygame.Rect(x, y, cw, ch)
# pool scroll area: above the bottom buttons (WIN_H - 100)
self.pool_area = pygame.Rect(x0 - 4, y0 - 4,
cols * cw + (cols - 1) * gap + 8,
WIN_H - y0 - 100)
rows = (len(self.pool_names) + cols - 1) // cols
self.pool_scroll_max = max(0, rows * (ch + gap) - gap - self.pool_area.h)
self.pool_scroll = getattr(self, "pool_scroll", 0)
self.pool_scroll = max(0, min(self.pool_scroll, self.pool_scroll_max))
def run(self):
running = True
result = None
while running:
self.clock.tick(60)
self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale)
for event in pygame.event.get():
if event.type == pygame.QUIT:
request_quit()
running = False
elif event.type == pygame.MOUSEWHEEL:
if not self.detail_open:
self.pool_scroll = max(0, min(self.pool_scroll_max,
self.pool_scroll - event.y * 40))
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale)
if self.detail_open:
self._click_detail(pos)
elif self.back_rect.collidepoint(pos):
result = "back"
running = False
else:
choice = self._click(pos)
if choice == "start":
result = (list(self.allies), list(self.enemies))
running = False
self._draw()
pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window)
pygame.display.flip()
return result
def _open_detail(self, name, namelist):
self.detail_list = list(namelist)
if name not in self.detail_list:
self.detail_list.append(name)
self.detail_name = name
self.detail_index = self.detail_list.index(name)
self.detail_is_enemy = (namelist is self.enemies)
self.detail_open = True
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
def _click(self, pos):
if self.type_info is not None:
self.type_info = None
return None
for rect, ptype in self.type_tags:
if rect.collidepoint(pos):
self.type_info = ptype
return None
if self.phase == 1:
if not self.no_roll and self.roll_rect.collidepoint(pos):
self._roll_enemies()
elif self.next_rect.collidepoint(pos):
self.phase = 2
else:
for i, rect in enumerate(self.enemy_card_rects):
if rect.collidepoint(pos) and i < len(self.enemies):
self._open_detail(self.enemies[i], self.enemies)
return None
if self.start_rect.collidepoint(pos) and len(self.allies) >= self.min_goal:
return "start"
for i, rect in enumerate(self.preset_rects):
if rect.collidepoint(pos):
preset = collection.get_presets()[i]
if preset:
plants = [n for n in preset if n in self.pool_names]
if self.pick_goal == 8:
# tower roster: presets merge into the roster, re-click to unload
if plants and all(n in self.allies for n in plants):
self.allies = [n for n in self.allies if n not in plants]
else:
for n in plants:
if n not in self.allies and len(self.allies) < self.pick_goal:
self.allies.append(n)
else:
self.allies = plants
return None
for i, rect in enumerate(self.ally_rects):
if rect.collidepoint(pos) and i < len(self.allies):
self.allies.pop(i)
return None
for i, rect in enumerate(self.enemy_strip_rects):
if rect.collidepoint(pos) and i < len(self.enemies):
self._open_detail(self.enemies[i], self.enemies)
return None
for name, rect in self.pool_rects.items():
if rect.move(0, -self.pool_scroll).collidepoint(pos):
if name in self.allies:
self.allies.remove(name) # click a selected plant to unselect it
else:
self._open_detail(name, self.pool_names)
return None
return None
def _click_detail(self, pos):
if self.type_info is not None:
self.type_info = None
return
if self.detail_type_rect and self.detail_type_rect.collidepoint(pos) and self.detail_name:
if self.detail_is_enemy:
idx = self.detail_list.index(self.detail_name)
ptype = self.enemy_types[idx] if idx < len(self.enemy_types) else None
else:
ptype = collection.unit_type(self.detail_name)
if ptype:
self.type_info = ptype
return
if self.detail_close.collidepoint(pos):
self.detail_open = False
return
if self.detail_left.collidepoint(pos):
self._nav_detail(-1)
return
if self.detail_right.collidepoint(pos):
self._nav_detail(1)
return
if self.detail_add.collidepoint(pos) and not self.detail_is_enemy:
name = self.detail_name
if (self.phase == 2 and name in self.pool_names
and name not in self.allies and len(self.allies) < self.pick_goal):
self.allies.append(name)
self.detail_open = False
def _draw(self):
self.type_tags = []
self.detail_type_rect = None
self.screen.fill(BG)
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))
if self.phase == 1:
self._draw_reveal()
else:
self._draw_pick()
if self.detail_open:
self._draw_detail_panel()
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)
def _draw_type_tag(self, label, ptype, pos, font, center=False):
surf = font.render(label, True, TYPE_COLORS.get(ptype, TEXT))
rect = surf.get_rect(center=pos) if center else surf.get_rect(topleft=pos)
tag = rect.inflate(12, 6)
pygame.draw.rect(self.screen, (52, 60, 52), tag, border_radius=5)
self.screen.blit(surf, rect)
self.type_tags.append((tag, ptype))
return tag
def _draw_role_type_tag(self, name, pos, font, center=False, ptype=None):
role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"])
rs = font.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT))
sep = font.render(" · ", True, MUTED)
ptype = ptype or collection.unit_type(name)
tic = load_type_icon(ptype, 16) if ptype else None
if tic:
type_w = tic.get_width() + 4
type_text = None
else:
shown = ptype if ptype else "No type"
type_text = font.render(shown, True, TYPE_COLORS.get(ptype, MUTED) if ptype else MUTED)
type_w = type_text.get_width()
total_w = rs.get_width() + sep.get_width() + type_w
h = rs.get_height()
if center:
x, y = pos[0] - total_w // 2, pos[1] - h // 2
else:
x, y = pos
tag = pygame.Rect(x - 6, y - 3, total_w + 12, h + 6)
pygame.draw.rect(self.screen, (52, 60, 52), tag, border_radius=5)
self.screen.blit(rs, (x, y))
self.screen.blit(sep, (x + rs.get_width(), y))
ix = x + rs.get_width() + sep.get_width()
if tic:
self.screen.blit(tic, tic.get_rect(midleft=(ix, y + h // 2)))
else:
self.screen.blit(type_text, (ix, y))
return tag
def _draw_reveal(self):
t = self.font.render("Enemy team", True, TEXT)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 40)))
t = self.small.render(f"Floor {self.difficulty} — this is who you'll fight. Click a plant for details.", True, MUTED)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 74)))
for i, name in enumerate(self.enemies):
rect = self.enemy_card_rects[i]
pygame.draw.rect(self.screen, (36, 44, 36), rect, border_radius=8)
pygame.draw.rect(self.screen, (200, 90, 90), rect, 2, border_radius=8)
spr = self.sprites.get(name)
if spr:
ic = fit_surface(spr, 120)
self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 60)))
t = self.bold.render(name, True, TEXT)
self.screen.blit(t, t.get_rect(center=(rect.centerx, rect.y + 140)))
tag = self._draw_role_type_tag(name, (rect.centerx, rect.y + 178), self.small, center=True,
ptype=self.enemy_types[i] if i < len(self.enemy_types) else None)
self._draw_enemy_level(tag, i)
stats = POOL[name]["stats"]
st = self.tiny.render(f"Atk {stats[0]} Spd {stats[1]} Def {stats[2]} HP {int(stats[3] * HP_MULT)}",
True, MUTED)
self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 216)))
mouse = self.mouse_pos
btns = ([(self.next_rect, "Continue")] if self.no_roll
else [(self.roll_rect, "Roll"), (self.next_rect, "Continue")])
for rect, label in btns:
pygame.draw.rect(self.screen, BTN_HOVER if rect.collidepoint(mouse) else BTN, rect, border_radius=8)
t = self.small.render(label, True, TEXT)
self.screen.blit(t, t.get_rect(center=rect.center))
def _draw_pick(self):
if self.tower_mode:
if self.header:
t = self.font.render(self.header, True, HIGHLIGHT)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 26)))
note = self.small.render(f"Pick {self.min_goal}-{self.pick_goal} plants from your collection.", True, MUTED)
elif self.pick_goal == 8:
t = self.font.render("Tower — pick your roster (1-8 plants)", True, HIGHLIGHT)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 26)))
note = self.small.render("4 fight each stage, you can swap from the bench between battles.", True, MUTED)
else:
t = self.font.render("Tower — pick your team (1-4 of your roster)", True, HIGHLIGHT)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 26)))
note = self.small.render("Enemies get stronger each stage (up to 20).", True, MUTED)
self.screen.blit(note, note.get_rect(center=(WIN_W // 2, 58)))
else:
t = self.font.render("Pick your allies", True, TEXT)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 26)))
self._draw_enemy_strip()
if self.show_slots:
self._draw_slots(self.ally_rects, self.allies, "Your Allies")
self._draw_pool()
self._draw_tower_enemies()
for i, rect in enumerate(self.preset_rects):
preset = collection.get_presets()[i]
has = bool(preset)
loaded = False
if self.pick_goal == 8 and preset:
plants = [n for n in preset if n in self.pool_names]
loaded = bool(plants) and all(n in self.allies for n in plants)
if self.pick_goal == 8:
bg = (BTN_HOVER if loaded else BTN) if has else (45, 50, 45)
border = HIGHLIGHT if loaded else ((150, 190, 150) if has else (70, 80, 70))
pygame.draw.rect(self.screen, bg, rect, border_radius=6)
pygame.draw.rect(self.screen, border, rect, 2 if loaded else 1, border_radius=6)
else:
pygame.draw.rect(self.screen, BTN if has else (45, 50, 45), rect, border_radius=6)
pygame.draw.rect(self.screen, (150, 190, 150) if has else (70, 80, 70),
rect, 1, border_radius=6)
label = f"P{i + 1}" if not preset else f"P{i + 1}·{len(preset)}"
t = self.tiny.render(label, True, TEXT if preset else MUTED)
self.screen.blit(t, t.get_rect(center=rect.center))
mouse = self.mouse_pos
ready = len(self.allies) >= self.min_goal
pygame.draw.rect(self.screen, BTN_HOVER if ready else (45, 50, 45), self.start_rect, border_radius=8)
if self.tower_mode:
if self.start_label:
label = self.start_label
elif self.pick_goal == 8:
label = "Start Tower"
else:
label = f"Fight ({len(self.allies)} picked)"
else:
label = "Start Battle"
t = self.small.render(label, True, TEXT)
self.screen.blit(t, t.get_rect(center=self.start_rect.center))
def _draw_enemy_strip(self):
for i, name in enumerate(self.enemies):
rect = self.enemy_strip_rects[i]
pygame.draw.rect(self.screen, (40, 40, 34), rect, border_radius=6)
pygame.draw.rect(self.screen, (200, 90, 90), rect, 2, border_radius=6)
spr = self.sprites.get(name)
if spr:
ic = fit_surface(spr, 56)
self.screen.blit(ic, ic.get_rect(center=(rect.x + 34, rect.y + 42)))
t = self.small.render(name, True, TEXT)
self.screen.blit(t, (rect.x + 66, rect.y + 12))
tag = self._draw_role_type_tag(name, (rect.x + 66, rect.y + 34), self.tiny,
ptype=self.enemy_types[i] if i < len(self.enemy_types) else None)
self._draw_enemy_level(tag, i)
hint = self.tiny.render("click for details", True, (120, 130, 120))
self.screen.blit(hint, (rect.right - 96, rect.y + 66))
def _draw_slots(self, rects, chosen, title):
t = self.bold.render(title, True, MUTED)
self.screen.blit(t, (rects[0].x, rects[0].y - 26))
for i, rect in enumerate(rects):
pygame.draw.rect(self.screen, SLOT if i < len(chosen) else SLOT_EMPTY, rect, border_radius=6)
pygame.draw.rect(self.screen, (70, 80, 70), rect, 1, border_radius=6)
if i < len(chosen):
self._draw_mini(rect, chosen[i])
elif i == len(chosen):
t = self.small.render("+", True, MUTED)
self.screen.blit(t, t.get_rect(center=rect.center))
def _draw_mini(self, rect, name):
spr = self.sprites.get(name)
x = rect.x + 6
if spr:
ic = fit_surface(spr, 56)
self.screen.blit(ic, ic.get_rect(midleft=(x, rect.centery)))
t = self.small.render(name, True, TEXT)
self.screen.blit(t, (x + 62, rect.y + 8))
self._draw_role_type_tag(name, (x + 62, rect.y + 30), self.tiny)
_draw_evo_badge(self.screen, self.tiny, (rect.right - 24, rect.y + 16), collection.get_progress(name)["stage"] - 1)
rune_colors = collection.equipped_rune_colors(name)
if rune_colors:
chip_w = len(rune_colors) * 14 + (len(rune_colors) - 1) * 2
draw_rune_chips(self.screen, self._rune_icon, rune_colors,
rect.right - 40 - chip_w, rect.y + 32)
def _draw_pool(self):
old_clip = self.screen.get_clip()
self.screen.set_clip(self.pool_area)
for name, rect in self.pool_rects.items():
draw_rect = rect.move(0, -self.pool_scroll)
if not draw_rect.colliderect(self.pool_area):
continue
chosen = name in self.allies
bg = (50, 60, 50) if chosen else (36, 42, 36)
pygame.draw.rect(self.screen, bg, draw_rect, border_radius=6)
border = HIGHLIGHT if chosen else (60, 70, 60)
pygame.draw.rect(self.screen, border, draw_rect, 2 if chosen else 1, border_radius=6)
spr = self.sprites.get(name)
if spr:
ic = fit_surface(spr, 40)
self.screen.blit(ic, ic.get_rect(center=(draw_rect.x + 24, draw_rect.y + 24)))
t = self.tiny.render(name, True, TEXT)
self.screen.blit(t, (draw_rect.x + 50, draw_rect.y + 6))
role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"])
ric = self._role_icon(POOL[name]["role"], 14)
rs = self.tiny.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), MUTED))
sep = self.tiny.render(" · ", True, MUTED)
ptype = collection.unit_type(name)
tic = load_type_icon(ptype, 14) if ptype else None
r_x = draw_rect.x + 50
if ric:
self.screen.blit(ric, ric.get_rect(midleft=(r_x, draw_rect.y + 28 + 7)))
r_x += 17
self.screen.blit(rs, (r_x, draw_rect.y + 28))
r_x += rs.get_width()
self.screen.blit(sep, (r_x, draw_rect.y + 28))
r_x += sep.get_width()
if tic:
self.screen.blit(tic, tic.get_rect(midleft=(r_x, draw_rect.y + 35)))
else:
shown = ptype if ptype else "No type"
ts = self.tiny.render(shown, True, TYPE_COLORS.get(ptype, MUTED) if ptype else MUTED)
self.screen.blit(ts, (r_x, draw_rect.y + 28))
prog = collection.get_progress(name)
_draw_evo_badge(self.screen, self.tiny, (draw_rect.x + 14, draw_rect.y + 54), prog["stage"] - 1)
lv = self.tiny.render(f"Lv {prog['level']}", True, HINT)
self.screen.blit(lv, (draw_rect.x + 50, draw_rect.y + 46))
rune_colors = collection.equipped_rune_colors(name)
if rune_colors:
draw_rune_chips(self.screen, self._rune_icon, rune_colors,
draw_rect.x + 104, draw_rect.y + 48)
self.screen.set_clip(old_clip)
if self.pool_scroll_max > 0:
hint = ("▲" if self.pool_scroll > 0 else "") + ("▼" if self.pool_scroll < self.pool_scroll_max else "")
if hint:
ht = self.tiny.render(hint, True, MUTED)
self.screen.blit(ht, ht.get_rect(center=(self.pool_area.centerx, self.pool_area.bottom + 6)))
def _draw_tower_enemies(self):
if not self.tower_mode or not self.enemies:
return
# below the roster pool (which ends around y=308 for an 8-plant roster)
y = 340
header = self.bold.render("Enemies this stage:", True, (200, 90, 90))
self.screen.blit(header, (290, y))
y += 30
for i, name in enumerate(self.enemies):
rect = pygame.Rect(290, y, 240, 54)
pygame.draw.rect(self.screen, (40, 40, 34), rect, border_radius=6)
pygame.draw.rect(self.screen, (200, 90, 90), rect, 2, border_radius=6)
spr = self.sprites.get(name)
if spr:
ic = fit_surface(spr, 34)
self.screen.blit(ic, ic.get_rect(center=(rect.x + 23, rect.y + 27)))
t = self.tiny.render(name, True, TEXT)
self.screen.blit(t, (rect.x + 46, rect.y + 8))
tag = self._draw_role_type_tag(name, (rect.x + 46, rect.y + 30), self.tiny)
self._draw_enemy_level(tag, i)
y += 60
def _draw_detail_panel(self):
name = self.detail_name
if name is None:
return
spec = POOL[name]
shade = pygame.Surface((WIN_W, WIN_H))
shade.fill((0, 0, 0))
shade.set_alpha(120)
self.screen.blit(shade, (0, 0))
pygame.draw.rect(self.screen, (30, 36, 30), self.detail_rect, border_radius=10)
pygame.draw.rect(self.screen, (90, 110, 90), self.detail_rect, 2, border_radius=10)
pygame.draw.rect(self.screen, (140, 70, 70), self.detail_close, border_radius=6)
t = self.small.render("X", True, TEXT)
self.screen.blit(t, t.get_rect(center=self.detail_close.center))
spr = self.sprites.get(name)
if spr:
ic = fit_surface(spr, 120)
self.screen.blit(ic, ic.get_rect(center=(self.detail_rect.x + 84, self.detail_rect.y + 100)))
t = self.bold.render(name, True, TEXT)
self.screen.blit(t, (self.detail_rect.x + 160, self.detail_rect.y + 34))
rar = plant_rarity(name)
rt = self.tiny.render(RARITY_LABELS.get(rar, rar), True, RARITY_COLORS.get(rar, TEXT))
self.screen.blit(rt, (self.detail_rect.x + 160 + t.get_width() + 8, self.detail_rect.y + 38))
enemy_type = None
if self.detail_is_enemy:
idx = self.detail_list.index(name)
if idx < len(self.enemy_types):
enemy_type = self.enemy_types[idx]
self.detail_type_rect = self._draw_role_type_tag(
name, (self.detail_rect.x + 160, self.detail_rect.y + 62), self.small,
ptype=enemy_type)
if not self.detail_is_enemy and name in collection.owned():
prog = collection.get_progress(name)
lv = self.tiny.render(f"Level {prog['level']} · Stage {prog['stage']}", True, HINT)
self.screen.blit(lv, (self.detail_rect.x + 160, self.detail_rect.y + 120))
atk, spd, dfn, hp = compute_stats(spec["stats"], prog["level"], prog["stage"])
else:
atk, spd, dfn, hp = spec["stats"]
st = self.small.render(f"Atk {atk} Spd {spd}", True, TEXT)
self.screen.blit(st, (self.detail_rect.x + 160, self.detail_rect.y + 148))
st = self.small.render(f"Def {dfn} HP {int(hp * HP_MULT)}", True, TEXT)
self.screen.blit(st, (self.detail_rect.x + 160, self.detail_rect.y + 174))
y = self.detail_rect.y + 210
p = spec.get("passive", "")
if p:
pname, pdesc = PASSIVES.get(p, (p, ""))
self.screen.blit(self.bold.render(f"Passive — {pname}", True, HINT),
(self.detail_rect.x + 24, y))
lines = wrap_text(" " + pdesc, self.small, self.detail_rect.w - 48)
for i, ln in enumerate(lines):
self.screen.blit(self.small.render(ln, True, TEXT),
(self.detail_rect.x + 24, y + 22 + i * 20))
y += 26 + len(lines) * 20 + 2
for sk in spec["skills"]:
bracket = _bracket_text(sk)
label = f"{sk.name} [{bracket}] CD {sk.cooldown}" if bracket else f"{sk.name} CD {sk.cooldown}"
self.screen.blit(self.bold.render(label, True, HINT), (self.detail_rect.x + 24, y))
d = self.small.render(" " + sk.description, True, TEXT)
self.screen.blit(d, (self.detail_rect.x + 24, y + 22))
y += 50
if self.phase == 2 and not self.detail_is_enemy and name in self.pool_names:
addable = name not in self.allies and len(self.allies) < self.pick_goal
label = "In Team" if name in self.allies else ("Add to Team" if addable else "Team full")
pygame.draw.rect(self.screen, BTN_HOVER if addable else (45, 50, 45),
self.detail_add, border_radius=6)
t = self.tiny.render(label, True, TEXT if addable else MUTED)
self.screen.blit(t, t.get_rect(center=self.detail_add.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))
idx = self.detail_list.index(name) if name in self.detail_list else 0
pos = self.tiny.render(f"{idx + 1}/{len(self.detail_list)}", True, MUTED)
self.screen.blit(pos, pos.get_rect(center=(self.detail_rect.centerx, self.detail_rect.bottom - 18)))
# __MORE__
+104
View File
@@ -0,0 +1,104 @@
import pygame
from engine import collection
from ui.scaling import make_window, request_quit, to_logical
WIN_W, WIN_H = 1040, 720
BG = (24, 30, 24)
TEXT = (230, 235, 230)
MUTED = (150, 155, 150)
HINT = (255, 220, 120)
PANEL = (32, 38, 32)
BTN = (60, 90, 60)
BTN_HOVER = (85, 125, 85)
class PresetScreen:
def __init__(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 — Team Presets")
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
self.small = pygame.font.SysFont("segoeui", 16)
self.tiny = pygame.font.SysFont("segoeui", 13)
self.back_rect = pygame.Rect(16, 16, 90, 34)
self.slots = []
for i in range(4):
card = pygame.Rect(WIN_W // 2 - 240, 130 + i * 92, 480, 76)
set_btn = pygame.Rect(card.right - 180, card.y + 24, 80, 28)
clear_btn = pygame.Rect(card.right - 90, card.y + 24, 80, 28)
self.slots.append((card, set_btn, clear_btn))
def run(self):
running = True
while running:
self.clock.tick(60)
for event in pygame.event.get():
if event.type == pygame.QUIT:
request_quit()
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale)
if self.back_rect.collidepoint(pos):
running = False
continue
for i, (card, set_btn, clear_btn) in enumerate(self.slots):
if set_btn.collidepoint(pos):
self._set_slot(i)
elif clear_btn.collidepoint(pos):
collection.clear_preset(i)
self._draw()
pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window)
pygame.display.flip()
def _set_slot(self, i):
from ui.picker import TeamSelect
ts = TeamSelect(tower_mode=True, pick_goal=4, header="Pick a team",
start_label="Save")
res = ts.run()
self._reinit() # the nested picker quit pygame
if res and res != "back" and res[0]:
collection.save_preset(i, res[0])
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 — Team Presets")
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
self.small = pygame.font.SysFont("segoeui", 16)
self.tiny = pygame.font.SysFont("segoeui", 13)
def _draw(self):
self.screen.fill(BG)
title = self.font.render("Team Presets", True, TEXT)
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 52)))
sub = self.small.render("Set up to 4 teams; load them with the P1–P4 buttons in battle.", True, MUTED)
self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 88)))
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))
presets = collection.get_presets()
for i, (card, set_btn, clear_btn) in enumerate(self.slots):
pygame.draw.rect(self.screen, PANEL, card, border_radius=8)
pygame.draw.rect(self.screen, (60, 70, 60), card, 1, border_radius=8)
head = self.bold.render(f"Preset {i + 1}", True, HINT)
self.screen.blit(head, (card.x + 16, card.y + 10))
team = presets[i] if i < len(presets) else None
if team:
names = " · ".join(team)
tt = self.tiny.render(names, True, TEXT)
else:
tt = self.tiny.render("Empty", True, MUTED)
self.screen.blit(tt, (card.x + 16, card.y + 42))
pygame.draw.rect(self.screen, BTN_HOVER, set_btn, border_radius=6)
s = self.tiny.render("Set", True, TEXT)
self.screen.blit(s, s.get_rect(center=set_btn.center))
pygame.draw.rect(self.screen, (150, 70, 70), clear_btn, border_radius=6)
c = self.tiny.render("Clear", True, TEXT)
self.screen.blit(c, c.get_rect(center=clear_btn.center))
+130
View File
@@ -0,0 +1,130 @@
import pygame
from engine.plants import RARITY_COLORS
from engine.relics import RELIC_POOL
from ui.relics_overview import RelicsOverview
from ui.scaling import make_window, request_quit, to_logical
WIN_W, WIN_H = 1040, 720
BG = (24, 30, 24)
TEXT = (230, 235, 230)
MUTED = (150, 155, 150)
HINT = (255, 220, 120)
CARD = (36, 44, 36)
BORDER = (90, 120, 90)
SKIP = (60, 60, 60)
class RelicChoiceScreen:
"""Pick one of the offered roguelike blessings (or skip)."""
def __init__(self, relics, stage, owned=()):
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 — Blessing")
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.relics = relics
self.stage = stage
self.owned = list(owned)
self.overview = None
self.cards = []
cw, ch = 250, 300
gap = 26
total = len(relics) * cw + (len(relics) - 1) * gap
x0 = (WIN_W - total) // 2
y = 190
for i, r in enumerate(relics):
self.cards.append((r, pygame.Rect(x0 + i * (cw + gap), y, cw, ch)))
self.skip_rect = pygame.Rect(WIN_W // 2 - 80, WIN_H - 70, 160, 40)
self.all_btn = pygame.Rect(24, WIN_H - 70, 160, 40)
def run(self):
running = True
result = None
while running:
self.clock.tick(60)
for event in pygame.event.get():
if event.type == pygame.QUIT:
request_quit()
running = False
elif event.type == pygame.MOUSEWHEEL:
if self.overview is not None:
self.overview.handle_wheel(event.y)
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale)
if self.overview is not None:
if self.overview.handle_click(pos) == "close":
self.overview = None
continue
for r, rect in self.cards:
if rect.collidepoint(pos):
result = r
running = False
break
else:
if self.skip_rect.collidepoint(pos):
result = None
running = False
elif self.all_btn.collidepoint(pos):
self.overview = RelicsOverview(
self.screen, self.bold, self.small, self.tiny,
RELIC_POOL, self.owned)
self._draw()
pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window)
pygame.display.flip()
return result
def _wrap(self, text, font, width):
words = text.split()
lines = []
cur = ""
for w in words:
if font.size(cur + " " + w)[0] > width and cur:
lines.append(cur)
cur = w
else:
cur = (cur + " " + w).strip()
if cur:
lines.append(cur)
return lines
def _draw(self):
self.screen.fill(BG)
if self.overview is not None:
self.overview.draw(pygame.mouse.get_pos())
return
t = self.font.render("Choose a blessing", True, HINT)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 60)))
st = self.small.render(
f"Stage {self.stage} defeated — pick a relic for the rest of the tower.",
True, MUTED)
self.screen.blit(st, st.get_rect(center=(WIN_W // 2, 110)))
mouse = pygame.mouse.get_pos()
for r, rect in self.cards:
hover = rect.collidepoint(mouse)
pygame.draw.rect(self.screen, CARD, rect, border_radius=10)
pygame.draw.rect(self.screen, HINT if hover else BORDER, rect, 2, border_radius=10)
name = self.bold.render(r.name, True, HINT)
self.screen.blit(name, name.get_rect(midtop=(rect.centerx, rect.y + 16)))
rar = self.small.render(r.rarity.capitalize(), True, RARITY_COLORS.get(r.rarity, MUTED))
self.screen.blit(rar, rar.get_rect(midtop=(rect.centerx, rect.y + 50)))
y = rect.y + 92
for ln in self._wrap(r.desc, self.small, rect.w - 24)[:7]:
lr = self.small.render(ln, True, TEXT)
self.screen.blit(lr, lr.get_rect(midtop=(rect.centerx, y)))
y += 24
pygame.draw.rect(self.screen, SKIP, self.skip_rect, border_radius=8)
pygame.draw.rect(self.screen, (150, 150, 150), self.skip_rect, 2, border_radius=8)
sk = self.small.render("Skip", True, TEXT)
self.screen.blit(sk, sk.get_rect(center=self.skip_rect.center))
pygame.draw.rect(self.screen, (70, 90, 70), self.all_btn, border_radius=8)
pygame.draw.rect(self.screen, BORDER, self.all_btn, 2, border_radius=8)
ab = self.small.render("All blessings", True, TEXT)
self.screen.blit(ab, ab.get_rect(center=self.all_btn.center))
hint = self.tiny.render("Blessings persist for the rest of this tower run", True, MUTED)
self.screen.blit(hint, hint.get_rect(center=(WIN_W // 2, WIN_H - 20)))
+178
View File
@@ -0,0 +1,178 @@
import pygame
from engine.plants import RARITY_COLORS
from engine.relics import RELIC_POOL
BG = (24, 30, 24)
TEXT = (230, 235, 230)
MUTED = (150, 155, 150)
HINT = (255, 220, 120)
PANEL = (30, 36, 30)
PANEL_BORDER = (150, 190, 150)
CARD = (36, 44, 36)
CARD_OWNED = (46, 54, 42)
GOLD = (245, 200, 90)
OWNED = (110, 220, 255) # bright cyan: distinct from the legendary gold border
TIER_ORDER = ("common", "rare", "epic", "legendary")
TIER_LABELS = {"common": "Common", "rare": "Rare", "epic": "Epic", "legendary": "Legendary"}
def _wrap(text, font, width):
words = text.split()
lines = []
cur = ""
for w in words:
if font.size(cur + " " + w)[0] > width and cur:
lines.append(cur)
cur = w
else:
cur = (cur + " " + w).strip()
if cur:
lines.append(cur)
return lines
class RelicsOverview:
"""Full-screen overlay listing every blessing, grouped by rarity, with the
ones collected this run highlighted. Click a relic to inspect it up close.
Host screens draw this onto their own surface (no pygame display ownership),
so it can open mid-battle without tearing down the window."""
def __init__(self, screen, bold, small, tiny, pool=None, owned_keys=()):
self.screen = screen
self.bold = bold
self.small = small
self.tiny = tiny
self.pool = list(pool if pool is not None else RELIC_POOL)
self.owned = set(owned_keys)
self.scroll = 0
self.detail = None
w, h = screen.get_size()
pw = min(760, w - 60)
ph = min(500, h - 140)
self.panel = pygame.Rect((w - pw) // 2, (h - ph) // 2, pw, ph)
pad = 24
self.cards_top = self.panel.y + 78
self.inner = pygame.Rect(self.panel.x + pad, self.cards_top,
self.panel.w - 2 * pad,
self.panel.bottom - 20 - self.cards_top)
self.close_rect = pygame.Rect(self.panel.right - 74, self.panel.y + 14, 58, 28)
rank = {t: i for i, t in enumerate(TIER_ORDER)}
self.pool.sort(key=lambda r: (rank.get(r.rarity, 9), r.name.lower()))
cols = 3
gap = 14
cw = (self.inner.w - gap * (cols - 1)) // cols
ch = 118
self.cards = []
x, y = self.inner.x, self.inner.y
for i, r in enumerate(self.pool):
self.cards.append((r, pygame.Rect(x, y, cw, ch)))
if (i + 1) % cols == 0:
x = self.inner.x
y += ch + gap
else:
x += cw + gap
self.content_h = y - self.inner.y if self.cards else 0
self.scroll_max = max(0, self.content_h - self.inner.h)
def handle_wheel(self, y):
if self.detail is not None:
return
self.scroll = max(0, min(self.scroll_max, self.scroll - y * 40))
def handle_click(self, pos):
if self.detail is not None:
self.detail = None
return None
if self.close_rect.collidepoint(pos):
return "close"
if not self.inner.collidepoint(pos):
return "close" if not self.panel.collidepoint(pos) else None
for r, rect in self.cards:
draw_rect = rect.move(0, -self.scroll)
if draw_rect.collidepoint(pos) and draw_rect.colliderect(self.inner):
self.detail = r
return None
return None
def _draw_card(self, r, rect):
owned = r.key in self.owned
bg = CARD_OWNED if owned else CARD
border = OWNED if owned else RARITY_COLORS.get(r.rarity, (150, 150, 150))
pygame.draw.rect(self.screen, bg, rect, border_radius=8)
pygame.draw.rect(self.screen, border, rect, 3 if owned else 1, border_radius=8)
name = self.small.render(r.name, True, RARITY_COLORS.get(r.rarity, TEXT))
self.screen.blit(name, (rect.x + 10, rect.y + 8))
if owned:
tag = self.tiny.render("OWNED", True, OWNED)
self.screen.blit(tag, (rect.right - tag.get_width() - 8, rect.y + 10))
y = rect.y + 34
for ln in _wrap(r.desc, self.tiny, rect.w - 20)[:3]:
self.screen.blit(self.tiny.render(ln, True, MUTED), (rect.x + 10, y))
y += 16
tip = self.tiny.render(f"{TIER_LABELS.get(r.rarity, r.rarity)}" +
(" · click to inspect" if owned else " · not owned"),
True, OWNED if owned else (110, 120, 110))
self.screen.blit(tip, (rect.x + 10, rect.bottom - 18))
def _draw_detail(self):
r = self.detail
if r is None:
return
shade = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
shade.fill((0, 0, 0, 130))
self.screen.blit(shade, (0, 0))
w, h = 440, 230
panel = pygame.Rect((self.screen.get_width() - w) // 2,
(self.screen.get_height() - h) // 2, w, h)
pygame.draw.rect(self.screen, (36, 44, 36), panel, border_radius=10)
pygame.draw.rect(self.screen, RARITY_COLORS.get(r.rarity, PANEL_BORDER),
panel, 2, border_radius=10)
name = self.bold.render(r.name, True, RARITY_COLORS.get(r.rarity, TEXT))
self.screen.blit(name, name.get_rect(midtop=(panel.centerx, panel.y + 22)))
rar = self.small.render(TIER_LABELS.get(r.rarity, r.rarity).capitalize(), True, MUTED)
self.screen.blit(rar, rar.get_rect(midtop=(panel.centerx, panel.y + 54)))
y = panel.y + 92
for ln in _wrap(r.desc, self.small, panel.w - 48)[:5]:
line = self.small.render(ln, True, TEXT)
self.screen.blit(line, line.get_rect(midtop=(panel.centerx, y)))
y += 26
owned = r.key in self.owned
st = self.tiny.render("OWNED this run" if owned else "Not owned yet", True,
OWNED if owned else MUTED)
self.screen.blit(st, st.get_rect(midtop=(panel.centerx, y + 4)))
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(self, mouse):
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 180))
self.screen.blit(overlay, (0, 0))
pygame.draw.rect(self.screen, PANEL, self.panel, border_radius=12)
pygame.draw.rect(self.screen, PANEL_BORDER, self.panel, 2, border_radius=12)
title = self.bold.render("All blessings", True, HINT)
self.screen.blit(title, title.get_rect(midtop=(self.panel.centerx, self.panel.y + 14)))
sub = self.tiny.render(
f"{len(self.owned)} owned this run · cyan border = collected · click a card to inspect",
True, MUTED)
self.screen.blit(sub, sub.get_rect(midtop=(self.panel.centerx, self.panel.y + 44)))
pygame.draw.rect(self.screen, (90, 110, 90), self.close_rect, border_radius=6)
pygame.draw.rect(self.screen, PANEL_BORDER, self.close_rect, 1, border_radius=6)
c = self.small.render("Close", True, TEXT)
self.screen.blit(c, c.get_rect(center=self.close_rect.center))
old = self.screen.get_clip()
self.screen.set_clip(self.inner)
for r, rect in self.cards:
draw_rect = rect.move(0, -self.scroll)
if not draw_rect.colliderect(self.inner):
continue
self._draw_card(r, draw_rect)
self.screen.set_clip(old)
if self.scroll_max > 0:
hint = ("▲" if self.scroll > 0 else "") + ("▼" if self.scroll < self.scroll_max else "")
if hint:
ht = self.tiny.render(hint, True, MUTED)
self.screen.blit(ht, ht.get_rect(midbottom=(self.inner.centerx, self.inner.bottom)))
if self.detail is not None:
self._draw_detail()
+437
View File
@@ -0,0 +1,437 @@
import pygame
from assets import icon as icon_path
from engine import collection
from engine.plants import POOL, RARITY_COLORS, plant_rarity
from engine import runes
from ui.scaling import fit_surface, load_rune_icon, make_window, request_quit, to_logical
WIN_W, WIN_H = 1040, 720
BG = (24, 30, 24)
TEXT = (230, 235, 230)
MUTED = (150, 155, 150)
HINT = (255, 220, 120)
PANEL = (32, 38, 32)
BTN = (60, 90, 60)
BTN_HOVER = (85, 125, 85)
ACCENT = (120, 220, 190)
DANGER = (150, 70, 70)
SLOT_BORDER = (90, 110, 90)
SLOT_SIZE = 64
def draw_rune_chips(screen, get_icon, colors, x, y, size=14, gap=2, max_chips=3):
"""Draw small rune icons for the given colors starting at (x, y). `get_icon(color)`
returns a surface (already sized) or None. Used to show equipped runes at a glance."""
for i, color in enumerate(colors[:max_chips]):
ic = get_icon(color)
if ic:
screen.blit(ic, (x + i * (size + gap), y))
class RuneScreen:
def __init__(self, plant: str):
pygame.init()
self.plant = plant
self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption(f"Plants — Runes · {plant}")
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
self.small = pygame.font.SysFont("segoeui", 16)
self.tiny = pygame.font.SysFont("segoeui", 13)
self.back_rect = pygame.Rect(16, 16, 90, 34)
self.info_rect = pygame.Rect(WIN_W - 100, 16, 84, 34)
self.mouse_pos = (0, 0)
self.selected = None # rune id selected from the inventory
self.confirm = None # {"kind","slot","rune",...} prompt
self.confirm_ok = pygame.Rect(0, 0, 0, 0)
self.confirm_no = pygame.Rect(0, 0, 0, 0)
self.info = False # rune-effects info popup
self.equip_msg = "" # transient message (e.g. rune-type limit)
self.equip_msg_until = 0
self.filter_tier = 0 # 0 all, 1 tier 1, 2 tier 2
self.filter_color = None # None all, else a rune color
self.sort_best = False # sort by proportional roll
self.filter_rects = [] # (kind, value, rect)
self.sort_rect = pygame.Rect(WIN_W - 170, 284, 150, 24)
self.slot_rects = [] # (slot_index, rect)
self.inv_rects = [] # (rune_id, rect)
self.scroll = 0
self.scroll_max = 0
self._icons = {}
def _icon_for(self, color):
if color not in self._icons:
self._icons[color] = load_rune_icon(color, 40)
return self._icons[color]
def run(self):
running = True
while running:
self.clock.tick(60)
self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale)
for event in pygame.event.get():
if event.type == pygame.QUIT:
request_quit()
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale)
if self.back_rect.collidepoint(pos):
running = False
continue
if self.info:
self.info = False
continue
if self.info_rect.collidepoint(pos):
self.info = True
continue
if self.confirm is not None:
if self.confirm_ok.collidepoint(pos):
if self.confirm["kind"] == "move":
collection.equip_rune(self.plant, self.confirm["slot"],
self.confirm["rune"]["id"])
self.selected = None
else:
collection.unequip_rune(self.plant, self.confirm["slot"])
self.confirm = None
elif self.confirm_no.collidepoint(pos):
self.confirm = None
continue
self._click(pos)
elif event.type == pygame.MOUSEWHEEL:
self.scroll = max(0, min(self.scroll_max, self.scroll - event.y * 44))
self._draw()
pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window)
pygame.display.flip()
def _click(self, pos):
if self._click_filter(pos):
return
equipped = collection.equipped_runes(self.plant)
inventory = collection.rune_inventory()
by_id = {r["id"]: r for r in inventory}
for slot, rect in self.slot_rects:
if rect.collidepoint(pos):
if self.selected is not None:
rune = by_id.get(self.selected)
owner = collection.rune_location(self.selected)
if owner and owner != self.plant:
self.confirm = {"kind": "move", "slot": slot,
"rune": rune, "from": owner}
else:
reason = collection.equip_rune(self.plant, slot, self.selected)
self.equip_msg = reason or ""
self.equip_msg_until = (pygame.time.get_ticks() + 2000) if reason else 0
self.selected = None
elif slot < len(equipped) and equipped[slot] is not None:
self.confirm = {"kind": "remove", "slot": slot,
"rune": by_id.get(equipped[slot])}
return
for rune_id, rect in self.inv_rects:
if rect.collidepoint(pos):
self.selected = None if self.selected == rune_id else rune_id
return
def _click_filter(self, pos):
for kind, val, rect in self.filter_rects:
if rect.collidepoint(pos):
if kind == "tier":
self.filter_tier = val
elif kind == "color":
self.filter_color = val
return True
if self.sort_rect.collidepoint(pos):
self.sort_best = not self.sort_best
return True
return False
def _displayed_runes(self, inventory):
shown = [r for r in inventory
if (self.filter_tier == 0 or runes.tier(r["color"]) == self.filter_tier)
and (self.filter_color is None or r["color"] == self.filter_color)]
if self.sort_best:
shown.sort(key=runes.roll_ratio, reverse=True)
return shown
def _small_icon(self, color):
key = ("small", color)
if key not in self._icons:
ic = self._icon_for(color)
self._icons[key] = fit_surface(ic, 22) if ic else None
return self._icons[key]
def _draw_filters(self):
self.filter_rects = []
y, h = 284, 24
tl = self.tiny.render("Tier:", True, MUTED)
self.screen.blit(tl, (60, y + 5))
x = 92
for val, label in ((0, "All"), (1, "T1"), (2, "T2")):
active = self.filter_tier == val
rect = pygame.Rect(x, y, 30, h)
pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45),
rect, border_radius=4)
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
rect, 1, border_radius=4)
t = self.tiny.render(label, True, TEXT if active else MUTED)
self.screen.blit(t, t.get_rect(center=rect.center))
self.filter_rects.append(("tier", val, rect))
x += 36
ty = self.tiny.render("Type:", True, MUTED)
self.screen.blit(ty, (210, y + 5))
x = 244
all_rect = pygame.Rect(x, y, 36, h)
active = self.filter_color is None
pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45),
all_rect, border_radius=4)
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
all_rect, 1, border_radius=4)
t = self.tiny.render("All", True, TEXT if active else MUTED)
self.screen.blit(t, t.get_rect(center=all_rect.center))
self.filter_rects.append(("color", None, all_rect))
x += 42
for color in runes.RUNE_COLORS:
rect = pygame.Rect(x, y, 24, h)
active = self.filter_color == color
pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4)
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
rect, 1 if active else 2, border_radius=4)
sw = self._small_icon(color)
if sw:
self.screen.blit(sw, sw.get_rect(center=rect.center))
self.filter_rects.append(("color", color, rect))
x += 28
active = self.sort_best
pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45),
self.sort_rect, border_radius=4)
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
self.sort_rect, 1, border_radius=4)
sl = self.tiny.render("Sort: best roll" if active else "Sort: default",
True, TEXT if active else MUTED)
self.screen.blit(sl, sl.get_rect(center=self.sort_rect.center))
def _draw(self):
self.slot_rects = []
self.inv_rects = []
self.screen.fill(BG)
pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6)
bt = self.small.render("Back", True, TEXT)
self.screen.blit(bt, bt.get_rect(center=self.back_rect.center))
ihov = self.info_rect.collidepoint(self.mouse_pos)
pygame.draw.rect(self.screen, (90, 120, 90) if (self.info or ihov) else BTN,
self.info_rect, border_radius=6)
il = self.tiny.render("Rune info", True, TEXT)
self.screen.blit(il, il.get_rect(center=self.info_rect.center))
spec = POOL[self.plant]
rar = plant_rarity(self.plant)
title = self.font.render(self.plant, True, RARITY_COLORS.get(rar, TEXT))
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 44)))
sub = self.small.render(f"Rune slots — {spec['role']}", True, MUTED)
self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 76)))
slots = collection.rune_slots(self.plant)
equipped = collection.equipped_runes(self.plant)
inventory = collection.rune_inventory()
by_id = {r["id"]: r for r in inventory}
# --- slots -----------------------------------------------------
st = self.bold.render("Rune slots", True, HINT)
self.screen.blit(st, (60, 110))
locked = self.tiny.render(
"Evolve to unlock more slots (permanent)", True, MUTED)
self.screen.blit(locked, (200, 116))
total_w = slots * SLOT_SIZE + (slots - 1) * 20
x0 = (WIN_W - total_w) // 2
y = 140
for i in range(slots):
rect = pygame.Rect(x0 + i * (SLOT_SIZE + 20), y, SLOT_SIZE, SLOT_SIZE)
pygame.draw.rect(self.screen, PANEL, rect, border_radius=10)
pygame.draw.rect(self.screen, SLOT_BORDER, rect, 2, border_radius=10)
rune = by_id.get(equipped[i]) if i < len(equipped) else None
if rune:
ic = self._icon_for(rune["color"])
if ic:
self.screen.blit(ic, ic.get_rect(center=rect.center))
else:
num = self.bold.render(str(i + 1), True, MUTED)
num.set_alpha(70) # faint hint that the slot is empty
self.screen.blit(num, num.get_rect(center=rect.center))
lbl = self.tiny.render(f"Slot {i + 1}", True, MUTED)
self.screen.blit(lbl, lbl.get_rect(center=(rect.centerx, rect.bottom + 14)))
self.slot_rects.append((i, rect))
# --- equipped-effects summary (right of the slots) ------------
equipped_runes = [by_id[e] for e in equipped if e in by_id]
line_h = 18
panel = pygame.Rect(x0 + total_w + 40, y, 240,
26 + line_h * max(1, len(equipped_runes)) + 8)
pygame.draw.rect(self.screen, PANEL, panel, border_radius=8)
pygame.draw.rect(self.screen, (60, 70, 60), panel, 1, border_radius=8)
t = self.bold.render("Equipped effects", True, HINT)
self.screen.blit(t, (panel.x + 8, panel.y + 6))
py = panel.y + 28
if equipped_runes:
for rune in equipped_runes:
eff = self.tiny.render(
runes.effect_text(rune["color"], rune.get("pct", 30)), True, TEXT)
self.screen.blit(eff, (panel.x + 8, py))
py += line_h
else:
e = self.tiny.render("No runes equipped", True, MUTED)
self.screen.blit(e, (panel.x + 8, py))
if pygame.time.get_ticks() < self.equip_msg_until:
b = self.tiny.render(self.equip_msg, True, DANGER)
self.screen.blit(b, b.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44)))
elif self.selected is not None:
sel = by_id.get(self.selected)
if sel:
owner = collection.rune_location(self.selected)
if owner and owner != self.plant:
hint = self.bold.render(
f"Click a slot to move {sel['name']} from {owner}", True, ACCENT)
else:
hint = self.bold.render(
f"Click a slot to equip {sel['name']}", True, ACCENT)
self.screen.blit(hint, hint.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44)))
elif slots == 1:
h = self.tiny.render("Click a rune below, then click a slot to equip it.",
True, MUTED)
self.screen.blit(h, h.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44)))
# --- inventory -------------------------------------------------
it = self.bold.render("Runes you own", True, HINT)
self.screen.blit(it, (60, 260))
dr = self.tiny.render(
f"Drop rates — {runes.BATTLE_DROP_CHANCE * 100:.0f}% for any rune "
f"from floor battles · {runes.PACK_DROP_CHANCE * 100:.0f}% for any "
f"rune from packs",
True, MUTED)
self.screen.blit(dr, dr.get_rect(midright=(WIN_W - 60, 270)))
self._draw_filters()
display = self._displayed_runes(inventory)
list_y = 318
self.scroll_max = max(0, len(display) * 46 - (WIN_H - list_y - 20))
self.scroll = max(0, min(self.scroll, self.scroll_max))
old_clip = self.screen.get_clip()
self.screen.set_clip(pygame.Rect(0, list_y, WIN_W, WIN_H - list_y - 20))
ry = list_y - self.scroll
for rune in display:
rect = pygame.Rect(60, ry, 780, 40)
pygame.draw.rect(self.screen, PANEL, rect, border_radius=6)
if rune["id"] == self.selected:
pygame.draw.rect(self.screen, ACCENT, rect, 2, border_radius=6)
else:
pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=6)
ic = self._icon_for(rune["color"])
if ic:
self.screen.blit(ic, (rect.x + 8, rect.y + 4))
owner = collection.rune_location(rune["id"])
on_this = owner == self.plant
name = self.small.render(rune["name"], True,
TEXT if not on_this else MUTED)
self.screen.blit(name, (rect.x + 58, rect.y + 11))
eff = self.tiny.render(runes.effect_text(rune["color"], rune.get("pct", 30)),
True, MUTED)
self.screen.blit(eff, (rect.x + 58 + name.get_width() + 10, rect.y + 14))
if on_this:
tag = self.tiny.render("equipped", True, MUTED)
self.screen.blit(tag, (rect.right - 70, rect.y + 13))
elif owner:
tag = self.tiny.render(f"Equipped in: {owner}", True, MUTED)
self.screen.blit(tag, tag.get_rect(midright=(rect.right - 14, rect.centery)))
self.inv_rects.append((rune["id"], rect))
ry += 46
self.screen.set_clip(old_clip)
if not inventory:
empty = self.small.render(
"No runes yet — win floor battles or open seed packs to find some.",
True, MUTED)
self.screen.blit(empty, (60, list_y + 10))
elif not display:
empty = self.small.render("No runes match the current filter.",
True, MUTED)
self.screen.blit(empty, (60, list_y + 10))
if self.scroll_max > 0:
hint = "▲" if self.scroll > 0 else ""
if self.scroll < self.scroll_max:
hint += "▼"
ht = self.tiny.render(hint, True, MUTED)
self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 18)))
if self.confirm is not None:
self._draw_confirm()
if self.info:
self._draw_info()
def _draw_info(self):
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 160))
self.screen.blit(overlay, (0, 0))
w, h = 560, 400
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
pygame.draw.rect(self.screen, PANEL, panel, border_radius=12)
pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12)
title = self.bold.render("Rune effects", True, HINT)
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30)))
y = panel.y + 62
for color in runes.RUNE_COLORS:
ic = self._icon_for(color)
if ic:
self.screen.blit(ic, (panel.x + 40, y))
nm = self.bold.render(runes.name(color), True, TEXT)
self.screen.blit(nm, (panel.x + 92, y))
eff = self.small.render(runes.summary(color), True, MUTED)
self.screen.blit(eff, (panel.x + 92, y + 24))
rg = self.tiny.render(
f"Tier {runes.tier(color)} · Rolls {runes.roll_range(color)}",
True, ACCENT)
self.screen.blit(rg, rg.get_rect(topright=(panel.right - 16, y + 10)))
y += 58
note = self.tiny.render(
"Runes of the same effect stack additively; a plant can equip at "
"most %d runes of the same type." % runes.MAX_RUNES_PER_TYPE,
True, MUTED)
self.screen.blit(note, note.get_rect(center=(panel.centerx, panel.bottom - 24)))
close = self.tiny.render("Click anywhere to close", True, MUTED)
self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 8)))
def _draw_confirm(self):
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 150))
self.screen.blit(overlay, (0, 0))
w, h = 480, 190
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
pygame.draw.rect(self.screen, PANEL, panel, border_radius=12)
pygame.draw.rect(self.screen, DANGER, panel, 2, border_radius=12)
rune = self.confirm["rune"]
name = rune["name"] if rune else "Rune"
if self.confirm["kind"] == "move":
title = self.bold.render("Move rune?", True, TEXT)
msg = self.small.render(
f"Remove {name} from {self.confirm['from']} to slot it here?", True, TEXT)
ok_label = "Move"
else:
slot = self.confirm["slot"] + 1
title = self.bold.render("Remove rune?", True, TEXT)
msg = self.small.render(f"Unequip {name} from slot {slot}?", True, TEXT)
ok_label = "Remove"
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 34)))
self.screen.blit(msg, msg.get_rect(center=(panel.centerx, panel.y + 66)))
self.confirm_ok = pygame.Rect(panel.centerx - 110, panel.y + 110, 100, 36)
self.confirm_no = pygame.Rect(panel.centerx + 10, panel.y + 110, 100, 36)
pygame.draw.rect(self.screen, DANGER, self.confirm_ok, border_radius=8)
ok = self.bold.render(ok_label, True, TEXT)
self.screen.blit(ok, ok.get_rect(center=self.confirm_ok.center))
pygame.draw.rect(self.screen, BTN, self.confirm_no, border_radius=8)
no = self.bold.render("Cancel", True, TEXT)
self.screen.blit(no, no.get_rect(center=self.confirm_no.center))
+150
View File
@@ -0,0 +1,150 @@
import sys
import ctypes
from ctypes import wintypes
import pygame
from assets import plot_icon_path, rune_icon_path, type_icon_path
# Set when the user closes the window (X button). Screens keep running loops,
# but the top-level driver (main.py) checks this after every screen and bails
# out of the whole app so closing never lands you on another menu.
QUIT_REQUESTED = False
_desktop = None
def request_quit():
global QUIT_REQUESTED
QUIT_REQUESTED = True
def _work_area():
"""Usable desktop area (excludes the Windows taskbar), or None if unavailable."""
if sys.platform != "win32":
return None
try:
rect = wintypes.RECT()
ctypes.windll.user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(rect), 0) # SPI_GETWORKAREA
return rect.right - rect.left, rect.bottom - rect.top
except Exception:
return None
def _desktop_size():
"""The actual monitor resolution, cached after the first call. On macOS,
pygame.display.Info() reports the *current window* size, so using it here
makes each make_window() treat the last window as the available space and
the window shrinks on every screen transition. get_desktop_sizes() is
window-independent."""
global _desktop
if _desktop is None:
sizes = pygame.display.get_desktop_sizes()
if sizes:
_desktop = sizes[0]
else:
info = pygame.display.Info()
_desktop = (info.current_w, info.current_h)
return _desktop
def make_window(logical_w: int, logical_h: int):
"""Create a window sized to fit the usable desktop (downscales only, keeps
aspect ratio). Returns (window_surface, scale_factor). Render at the logical
size and smoothscale into the window each frame; divide mouse coordinates by
`scale` to get logical coordinates."""
pygame.display.init()
area = _work_area() or _desktop_size()
avail_w = max(1, area[0] - 24) # window border safety
avail_h = max(1, area[1] - 48) # title bar safety
scale = min(1.0, avail_w / logical_w, avail_h / logical_h)
scale = max(0.2, scale)
size = (int(logical_w * scale), int(logical_h * scale))
window = pygame.display.set_mode(size)
return window, scale
def to_logical(pos, scale: float):
return (int(pos[0] / scale), int(pos[1] / scale))
def fit_surface(img, size):
"""Scale `img` to fit within a `size`x`size` box, preserving aspect ratio.
Square sources become `size`x`size`; non-square sources shrink the smaller
axis so nothing is stretched or squished (e.g. a portrait water drop)."""
w, h = img.get_size()
scale = size / max(w, h)
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
return pygame.transform.smoothscale(img, (nw, nh))
_TYPE_ICON_CACHE = {}
_PLOT_ICON_CACHE = {}
_RUNE_ICON_CACHE = {}
def load_type_icon(ptype: str, size: int):
"""Plant type icon scaled to fit a `size` box (aspect preserved), cached."""
key = (ptype, size)
if key not in _TYPE_ICON_CACHE:
try:
img = pygame.image.load(type_icon_path(ptype)).convert_alpha()
_TYPE_ICON_CACHE[key] = fit_surface(img, size)
except (pygame.error, OSError):
_TYPE_ICON_CACHE[key] = None
return _TYPE_ICON_CACHE[key]
def load_plot_icon(name: str, size: int):
"""Soil plot sprite scaled to fit a `size` box (aspect preserved), cached."""
key = (name, size)
if key not in _PLOT_ICON_CACHE:
try:
img = pygame.image.load(plot_icon_path(name)).convert_alpha()
_PLOT_ICON_CACHE[key] = fit_surface(img, size)
except (pygame.error, OSError):
_PLOT_ICON_CACHE[key] = None
return _PLOT_ICON_CACHE[key]
def load_rune_icon(color: str, size: int):
"""Rune sprite scaled to fit a `size` box (aspect preserved), cached. Falls
back to the brown rune sprite if a color's icon is missing."""
key = (color, size)
if key not in _RUNE_ICON_CACHE:
try:
img = pygame.image.load(rune_icon_path(color)).convert_alpha()
_RUNE_ICON_CACHE[key] = fit_surface(img, size)
except (pygame.error, OSError):
_RUNE_ICON_CACHE[key] = None
return _RUNE_ICON_CACHE[key]
def draw_type_icon(screen, ptype, pos, font, color, size, center=False):
"""Draw a plant type icon (or the type name as fallback text) at `pos`,
returning its rect. Uses midleft alignment unless `center` is True."""
ic = load_type_icon(ptype, size)
if ic is None:
surf = font.render(ptype, True, color)
rect = surf.get_rect(center=pos) if center else surf.get_rect(midleft=pos)
screen.blit(surf, rect)
return rect
rect = ic.get_rect(center=pos) if center else ic.get_rect(midleft=pos)
screen.blit(ic, rect)
return rect
def wrap_text(text, font, width):
"""Split `text` into lines that each fit within `width` pixels."""
words = text.split()
lines = []
cur = ""
for w in words:
if font.size(cur + " " + w)[0] > width and cur:
lines.append(cur)
cur = w
else:
cur = (cur + " " + w).strip()
if cur:
lines.append(cur)
return lines
+104
View File
@@ -0,0 +1,104 @@
import pygame
from assets import load_sprite
from engine import collection
from engine.types import ALL_TYPES
from ui.scaling import fit_surface, load_type_icon, make_window, request_quit, to_logical
WIN_W, WIN_H = 1040, 720
BG = (24, 30, 24)
TEXT = (230, 235, 230)
MUTED = (150, 155, 150)
HINT = (255, 220, 120)
PANEL = (32, 38, 32)
BTN = (60, 90, 60)
BTN_HOVER = (85, 125, 85)
TYPE_NAMES = {
"radiant": "Radiant",
"nocturnal": "Nocturnal",
"ice": "Ice",
"garden": "Garden",
"wildflower": "Wildflower",
}
TYPE_COLORS = {
"radiant": (245, 200, 90),
"nocturnal": (150, 120, 220),
"ice": (130, 200, 235),
"garden": (120, 200, 120),
"wildflower": (235, 130, 170),
}
class StarterScreen:
def __init__(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 — Choose your starting pinwheel")
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
self.small = pygame.font.SysFont("segoeui", 16)
self.tiny = pygame.font.SysFont("segoeui", 13)
self.type_rects = {}
cw, ch, gap = 180, 200, 12
total = len(ALL_TYPES) * cw + (len(ALL_TYPES) - 1) * gap
x0 = (WIN_W - total) // 2
y0 = 120
for i, pt in enumerate(ALL_TYPES):
x = x0 + i * (cw + gap)
self.type_rects[pt] = pygame.Rect(x, y0, cw, ch)
self.pinwheel_sprite = self._load("Pinwheel")
self.mouse_pos = (0, 0)
def _load(self, name):
return load_sprite(name, "full")
def run(self):
chosen = False
running = True
while running:
self.clock.tick(60)
self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale)
for event in pygame.event.get():
if event.type == pygame.QUIT:
request_quit()
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale)
for pt, rect in self.type_rects.items():
if rect.collidepoint(pos):
collection.init_starter_pinwheel(pt)
chosen = True
running = False
break
self._draw()
pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window)
pygame.display.flip()
return chosen
def _draw(self):
self.screen.fill(BG)
title = self.font.render("Choose your starting pinwheel", True, HINT)
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 52)))
sub = self.small.render("You start with a single pinwheel of the chosen type. "
"Battle to win packs and grow your garden.",
True, MUTED)
self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 88)))
for pt, rect in self.type_rects.items():
hover = rect.collidepoint(self.mouse_pos)
pygame.draw.rect(self.screen, BTN_HOVER if hover else PANEL, rect, border_radius=10)
pygame.draw.rect(self.screen, (110, 150, 110) if hover else (70, 84, 70),
rect, 2, border_radius=10)
if self.pinwheel_sprite:
ic = fit_surface(self.pinwheel_sprite, 96)
self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 66)))
tcol = TYPE_COLORS.get(pt, TEXT)
nm = self.bold.render(TYPE_NAMES.get(pt, pt), True, tcol)
self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 130)))
tic = load_type_icon(pt, 28)
if tic:
self.screen.blit(tic, tic.get_rect(center=(rect.centerx, rect.y + 160)))
hint = self.tiny.render("Click to start", True, MUTED)
self.screen.blit(hint, hint.get_rect(center=(rect.centerx, rect.bottom - 14)))
+204
View File
@@ -0,0 +1,204 @@
import pygame
from engine.plants import RARITY_COLORS, plant_rarity
from ui.scaling import make_window, request_quit, to_logical
WIN_W, WIN_H = 1040, 720
BG = (24, 30, 24)
TEXT = (230, 235, 230)
MUTED = (150, 155, 150)
HINT = (255, 220, 120)
BTN = (60, 90, 60)
BTN_HOVER = (85, 125, 85)
DANGER = (150, 70, 70)
DANGER_HOVER = (180, 90, 90)
class ConfirmScreen:
"""A simple yes/no popup (e.g. "really leave the tower?")."""
def __init__(self, title, message, yes_label="Leave", no_label="Stay"):
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 — Tower")
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
self.small = pygame.font.SysFont("segoeui", 16)
self.tiny = pygame.font.SysFont("segoeui", 13)
self.title = title
self.message = message
self.yes_label = yes_label
self.no_label = no_label
self.yes_rect = pygame.Rect(WIN_W // 2 + 16, WIN_H - 96, 180, 44)
self.no_rect = pygame.Rect(WIN_W // 2 - 196, WIN_H - 96, 180, 44)
def run(self):
running = True
result = False
while running:
self.clock.tick(60)
for event in pygame.event.get():
if event.type == pygame.QUIT:
request_quit()
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale)
if self.yes_rect.collidepoint(pos):
result = True
running = False
elif self.no_rect.collidepoint(pos):
result = False
running = False
self._draw()
pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window)
pygame.display.flip()
return result
def _draw(self):
self.screen.fill(BG)
title = self.font.render(self.title, True, HINT)
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 100)))
for i, line in enumerate(self.message.split("\n")):
t = self.small.render(line, True, TEXT)
self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 150 + i * 26)))
self._button(self.no_rect, self.no_label, BTN, BTN_HOVER)
self._button(self.yes_rect, self.yes_label, DANGER, DANGER_HOVER)
def _button(self, rect, label, base, hover):
mouse = pygame.mouse.get_pos()
bg = hover if rect.collidepoint(mouse) else base
pygame.draw.rect(self.screen, bg, rect, border_radius=8)
pygame.draw.rect(self.screen, (150, 190, 150), rect, 2, border_radius=8)
t = self.bold.render(label, True, TEXT)
self.screen.blit(t, t.get_rect(center=rect.center))
class TowerScreen:
def __init__(self, stage, total_stages, reward_text, totals_text, mode="battle",
enemies=()):
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 — Tower")
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.stage = stage
self.total = total_stages
self.reward_text = reward_text
self.totals_text = totals_text
self.mode = mode # battle | lost | cleared | summary
self.enemies = list(enemies)
self.left_rect = pygame.Rect(WIN_W // 2 - 320, WIN_H - 90, 200, 44)
self.mid_rect = pygame.Rect(WIN_W // 2 - 100, WIN_H - 90, 200, 44)
self.right_rect = pygame.Rect(WIN_W // 2 + 120, WIN_H - 90, 200, 44)
def run(self):
running = True
result = "exit"
while running:
self.clock.tick(60)
for event in pygame.event.get():
if event.type == pygame.QUIT:
request_quit()
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale)
if self.mode == "battle":
if self.left_rect.collidepoint(pos):
result = "exit"
running = False
elif self.mid_rect.collidepoint(pos):
result = "adjust"
running = False
elif self.right_rect.collidepoint(pos):
result = "next"
running = False
elif self.mode == "lost":
if self.left_rect.collidepoint(pos):
result = "retry"
running = False
elif self.mid_rect.collidepoint(pos):
result = "change"
running = False
elif self.right_rect.collidepoint(pos):
result = "giveup"
running = False
else:
result = "exit"
running = False
self._draw()
pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window)
pygame.display.flip()
return result
def _draw(self):
self.screen.fill(BG)
if self.mode == "battle":
self._draw_battle()
elif self.mode == "lost":
self._draw_lost()
else:
self._draw_summary()
def _button(self, rect, label, base, hover):
mouse = pygame.mouse.get_pos()
bg = hover if rect.collidepoint(mouse) else base
pygame.draw.rect(self.screen, bg, rect, border_radius=8)
pygame.draw.rect(self.screen, (150, 190, 150), rect, 2, border_radius=8)
t = self.bold.render(label, True, TEXT)
self.screen.blit(t, t.get_rect(center=rect.center))
def _draw_battle(self):
title = self.font.render("Tower", True, HINT)
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 70)))
st = self.bold.render(f"Stage {self.stage} defeated!", True, TEXT)
self.screen.blit(st, st.get_rect(center=(WIN_W // 2, 150)))
info = self.small.render(f"Next: Stage {self.stage + 1} / {self.total}", True, MUTED)
self.screen.blit(info, info.get_rect(center=(WIN_W // 2, 190)))
rw = self.small.render(f"Stage reward: {self.reward_text}", True, TEXT)
self.screen.blit(rw, rw.get_rect(center=(WIN_W // 2, 240)))
tt = self.small.render(f"Total so far: {self.totals_text}", True, MUTED)
self.screen.blit(tt, tt.get_rect(center=(WIN_W // 2, 270)))
self._button(self.left_rect, "Exit tower", BTN, BTN_HOVER)
self._button(self.mid_rect, "Adjust team", BTN, BTN_HOVER)
self._button(self.right_rect, "Next stage", BTN, BTN_HOVER)
def _draw_lost(self):
title = self.font.render("Tower ended", True, HINT)
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 70)))
sub = self.bold.render(f"Defeated at stage {self.stage}", True, TEXT)
self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 130)))
rw = self.small.render(f"Rewards earned: {self.totals_text}", True, TEXT)
self.screen.blit(rw, rw.get_rect(center=(WIN_W // 2, 180)))
if self.enemies:
eh = self.bold.render("Enemy team this stage:", True, HINT)
self.screen.blit(eh, eh.get_rect(midtop=(WIN_W // 2, 224)))
total_w = sum(self.small.size(n)[0] for n in self.enemies) + 14 * (len(self.enemies) - 1)
x = (WIN_W - total_w) // 2
for name in self.enemies:
rar = plant_rarity(name)
surf = self.small.render(name, True, RARITY_COLORS.get(rar, TEXT))
self.screen.blit(surf, (x, 254))
x += surf.get_width() + 14
self._button(self.left_rect, "Retry", BTN, BTN_HOVER)
self._button(self.mid_rect, "Change team", BTN, BTN_HOVER)
self._button(self.right_rect, "Give up", DANGER, DANGER_HOVER)
def _draw_summary(self):
if self.mode == "cleared":
title = self.font.render("Tower cleared!", True, HINT)
sub = self.bold.render(f"Cleared all {self.total} stages!", True, TEXT)
else:
title = self.font.render("Tower ended", True, HINT)
sub = self.bold.render(f"Reached stage {self.stage}", True, TEXT)
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 90)))
self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 170)))
rw = self.small.render(f"Rewards earned: {self.totals_text}", True, TEXT)
self.screen.blit(rw, rw.get_rect(center=(WIN_W // 2, 230)))
hint = self.tiny.render("Click anywhere to return to the menu", True, MUTED)
self.screen.blit(hint, hint.get_rect(center=(WIN_W // 2, 280)))
+70
View File
@@ -0,0 +1,70 @@
import pygame
from engine.types import TYPE_BEATS, type_relations
from ui.scaling import draw_type_icon, load_type_icon
TYPE_COLORS = {
"radiant": (245, 200, 90),
"nocturnal": (150, 120, 220),
"ice": (130, 200, 235),
"garden": (120, 200, 120),
"wildflower": (235, 130, 170),
}
STRONG = (120, 210, 120)
WEAK = (225, 130, 130)
MUTED = (150, 155, 150)
TEXT = (230, 235, 230)
def _cycle_types() -> list:
order = []
cur = next(iter(TYPE_BEATS))
for _ in range(len(TYPE_BEATS)):
order.append(cur)
cur = TYPE_BEATS[cur]
return order
def draw_type_popup(screen, w, h, ptype, bold, small, tiny):
"""Draw a modal popup explaining a type's strong/weak matchups."""
overlay = pygame.Surface((w, h), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 150))
screen.blit(overlay, (0, 0))
pw, ph = 340, 200
panel = pygame.Rect((w - pw) // 2, (h - ph) // 2, pw, ph)
pygame.draw.rect(screen, (32, 38, 32), panel, border_radius=12)
pygame.draw.rect(screen, (70, 90, 70), panel, 2, border_radius=12)
tcol = TYPE_COLORS.get(ptype, TEXT)
title = bold.render(ptype.capitalize(), True, tcol)
screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30)))
strong, weak = type_relations(ptype)
y = panel.y + 62
if strong:
screen.blit(small.render("Strong against", True, MUTED), (panel.x + 36, y))
draw_type_icon(screen, strong, (panel.x + 36, y + 34), small, STRONG, 22)
if weak:
screen.blit(small.render("Weak against", True, MUTED), (panel.right - 140, y))
draw_type_icon(screen, weak, (panel.right - 140, y + 34), small, WEAK, 22)
cyc_y = panel.bottom - 44
segs = []
for t in _cycle_types():
ic = load_type_icon(t, 16)
if ic:
segs.append((ic, ic.get_width()))
else:
txt = tiny.render(t.capitalize(), True, MUTED)
segs.append((txt, txt.get_width()))
gap = tiny.size(">")[0] + 6
total_w = sum(s[1] for s in segs) + (len(segs) - 1) * gap
x = panel.centerx - total_w // 2
for i, (surf, w) in enumerate(segs):
screen.blit(surf, surf.get_rect(midleft=(x, cyc_y)))
x += w
if i < len(segs) - 1:
gt = tiny.render(">", True, MUTED)
screen.blit(gt, (x + 2, cyc_y - gt.get_height() // 2))
x += gap
hint = tiny.render("Click anywhere to close", True, MUTED)
screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 22)))