import pygame from assets import icon, load_sprite from engine import collection, runes from engine.battle import Battle from engine.dominance import estimate_dominance, resolve_instantly from engine.plants import PASSIVES, RARITY_COLORS, RARITY_LABELS, plant_rarity from engine.relics import RELIC_POOL from engine.skills import EFF_DOT, EFF_HOT, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY from engine.types import type_multiplier from engine.unit import ROLE_NAMES, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER from ui.menu import _bracket_text, _draw_evo_badge from ui.relics_overview import RelicsOverview 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) TYPE_COLORS = { "radiant": (245, 200, 90), "nocturnal": (150, 120, 220), "ice": (130, 200, 235), "garden": (120, 200, 120), "wildflower": (235, 130, 170), } WIN_W = 1040 WIN_H = 720 BG = (24, 30, 24) CARD_BG = (36, 44, 36) ALLIED = (80, 190, 90) ENEMY = (215, 90, 90) TEXT = (230, 235, 230) HINT = (255, 220, 120) MUTED = (150, 155, 150) BTN = (60, 90, 60) BTN_HOVER = (85, 125, 85) BAR_BG = (28, 32, 28) LOG_BG = (20, 22, 20) SEL = (255, 255, 120) TARGET = (120, 220, 120) READY = (70, 130, 70) LOCKED = (46, 50, 46) ACCENT = (120, 220, 190) CARD_W = 400 CARD_H = 96 CARD_GAP = 8 CARD_TOP = 44 SIDE_X = (24, WIN_W - CARD_W - 24) PORTRAIT = 84 SKILL_Y = 468 SKILL_H = 84 SKILL_BTN_H = 44 LOG_Y = 560 LOG_H = 74 class Game: def __init__(self, battle: Battle, on_win=None, on_lose=None, tower=False, relics=(), stage=0): pygame.init() self.window, self.scale = make_window(WIN_W, WIN_H) self.screen = pygame.Surface((WIN_W, WIN_H)) self.tower = tower self.stage = stage self.relics = list(relics) if tower and stage: caption = f"Plants — Tower — Floor {stage}" elif stage: caption = f"Plants — Floor {stage}" elif tower: caption = "Plants — Tower Battle" else: caption = "Plants — 4v4 Turn Battle (prototype)" pygame.display.set_caption(caption) self.clock = pygame.time.Clock() self.battle = battle self.on_win = on_win self.on_lose = on_lose self.reward = None self.reward_rolled = False self.reward_acknowledged = False self.font = pygame.font.SysFont("segoeui", 24) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.small = pygame.font.SysFont("segoeui", 15) self.tiny = pygame.font.SysFont("segoeui", 13) self.tank_icon = pygame.transform.smoothscale( pygame.image.load(icon("role_icons", "tank_icon")).convert_alpha(), (18, 18) ) self.rune_icons = {} self._reward_sprites = {} self.sprites = {u.sort_order: self._load_sprite(u) for u in self.battle.units} self.pending_skill = None self.info_unit = None self.info_rects = {} self.auto_end = 0.0 self.ai_delay = 900 self.ai_timer = self.ai_delay self.skill_rects = [] self._cards = [] self._layout_cards() self.card_rects = {u.sort_order: rect for u, rect in self._cards} self.floaters = [] self.flashes = [] self.beams = [] self.hit_queue = [] self._hit_offsets = {} self.flash_surf = pygame.Surface((CARD_W, CARD_H)) self._dt = 0.0 self.back_rect = pygame.Rect(12, 12, 90, 32) self.auto_btn = pygame.Rect(112, 12, 90, 32) # Auto-battle unlocks once the player has cleared floor 20 in the # normal stage battles (floor_progress counts the next unbeaten floor). self.auto_unlocked = collection.floor_progress() > 20 self.finish_rect = pygame.Rect(WIN_W - 132, 12, 120, 32) self.can_finish = False self.auto_battle = False self.log_scroll = 0 self.mouse_pos = (0, 0) self.log_rect = pygame.Rect(16, LOG_Y, WIN_W - 32, LOG_H) self._last_log_len = 0 self.relic_overview = None self.blessings_rect = pygame.Rect(16, LOG_Y, WIN_W - 32, 18) if (tower and relics) else None def _layout_cards(self): self._cards = [] for x, units in ((SIDE_X[0], self.battle.players), (SIDE_X[1], self.battle.enemies)): for i, u in enumerate(units): y = CARD_TOP + i * (CARD_H + CARD_GAP) self._cards.append((u, pygame.Rect(x, y, CARD_W, CARD_H))) def _card_at(self, pos): for unit, rect in self._cards: if rect.collidepoint(pos): return unit return None def _load_sprite(self, unit): img = load_sprite(unit.name, "head") if img: return fit_surface(img, PORTRAIT) return None 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 _reward_sprite(self, path): """Load a reward sprite (seed pack / rune) at its native resolution, cached. Returns a surface or None. Sprites are never scaled.""" if path in self._reward_sprites: return self._reward_sprites[path] try: img = pygame.image.load(path).convert_alpha() except (pygame.error, OSError): img = None self._reward_sprites[path] = img return img def _reward_labels(self): """Flatten reward items (dicts or plain strings) into label text.""" out = [] for it in (self.reward or []): if isinstance(it, str): out.append(it) else: out.append(it.get("label", "")) return out def _layout_skills(self): unit = self.battle.current self.skill_rects = [] self.injected_index = -1 if unit is None or unit.team != TEAM_PLAYER: return self.injected_index = unit.injected_index n = len(unit.skills) avail = WIN_W - 32 gap = 8 w = (avail - gap * (n - 1)) // n x = 16 for sk in unit.skills: self.skill_rects.append((sk, pygame.Rect(x, SKILL_Y, w, SKILL_BTN_H))) x += w + gap # --- main loop ----------------------------------------------------- def run(self): import copy # Snapshot fresh units so a "Finish battle" resolves the true full-battle # outcome (matching the dominance estimate) rather than the mid-battle state. self._finish_players = copy.deepcopy(self.battle.players) self._finish_enemies = copy.deepcopy(self.battle.enemies) self._dominance = estimate_dominance( self.battle.players, self.battle.enemies, self.battle.relics) self.battle.start() self._preselect() running = True outcome = None end_timer = None while running: dt = self.clock.tick(60) self._dt = dt 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.auto_unlocked and self.auto_btn.collidepoint(pos): self.auto_battle = not self.auto_battle continue if (self._dominance and self.battle.turn >= 3 and collection.get_tower_best() >= 5 and self.battle.winner is None and self.finish_rect.collidepoint(pos)): self._finish_battle() continue if self.battle.winner is not None and self.reward_rolled and not self.reward_acknowledged: self.reward_acknowledged = True continue if self.back_rect.collidepoint(pos): outcome = "back" running = False else: self._handle_click(pos) elif event.type == pygame.MOUSEMOTION: self.mouse_pos = to_logical(event.pos, self.scale) elif event.type == pygame.MOUSEWHEEL: if self.relic_overview is not None: self.relic_overview.handle_wheel(event.y) elif self.log_rect.collidepoint(self.mouse_pos): self.log_scroll = max(0, self.log_scroll + event.y) self._update(dt) if self.battle.winner is not None and self.reward_acknowledged: if end_timer is None: end_timer = 0.0 end_timer += dt if end_timer >= 1500: outcome = "rematch" running = False self._draw() pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) pygame.display.flip() return outcome def _preselect(self): b = self.battle cur = b.current if cur and cur.team == TEAM_PLAYER and not cur.acted: self.pending_skill = b.best_skill(cur) else: self.pending_skill = None # --- input --------------------------------------------------------- def _handle_click(self, pos): b = self.battle if self.relic_overview is not None: if self.relic_overview.handle_click(pos) == "close": self.relic_overview = None return if b.winner is not None: return if self.blessings_rect and self.blessings_rect.collidepoint(pos): self.relic_overview = RelicsOverview( self.screen, self.bold, self.small, self.tiny, RELIC_POOL, [r.key for r in self.relics]) return if self.info_unit is not None: self.info_unit = None # any click closes the info popup return for sort, rect in self.info_rects.items(): if rect.collidepoint(pos): unit = next((u for u in b.units if u.sort_order == sort), None) if unit is not None: self.info_unit = unit return if pos[1] >= SKILL_Y - 4: self._handle_skill_click(pos) return if b.current and b.current.team == TEAM_PLAYER: self._handle_card_click(pos) def _handle_skill_click(self, pos): b = self.battle cur = b.current if cur is None or cur.team != TEAM_PLAYER: return oblivion = isinstance(cur.status.get("oblivion"), tuple) and cur.status["oblivion"][0] > 0 weakest = None if oblivion: ready = [s for s in cur.skills if s.cooldown_left <= 0] weakest = min(ready, key=lambda s: s.power) if ready else None for sk, rect in self.skill_rects: if rect.collidepoint(pos): if sk.cooldown_left > 0: return if oblivion and sk is not weakest: return if sk.target in (TARGET_ENEMY, TARGET_ALLY, TARGET_ANY): valid = b.valid_skill_targets(cur, sk) if len(valid) == 1: # Only one thing it can hit (e.g. last enemy alive, or a # tank blocking everything): use it straight away. self.pending_skill = None self._cast(sk, valid[0]) else: self.pending_skill = sk if self.pending_skill is not sk else None else: self.pending_skill = None self._cast(sk, None) return def _handle_card_click(self, pos): b = self.battle unit = self._card_at(pos) if unit is None: return cur = b.current if self.pending_skill: target = self._resolve_target(unit, self.pending_skill) if target: self._cast(self.pending_skill, target) self.pending_skill = None return if unit.team != TEAM_PLAYER: best = next((s for s in sorted(cur.skills, key=lambda s: s.priority) if s.cooldown_left <= 0 and s.kind == "damage"), None) if best: target = self._resolve_target(unit, best) if target: self._cast(best, target) def _resolve_target(self, unit, skill): b = self.battle if unit in b.valid_skill_targets(b.current, skill): return unit if skill.target == "enemy": tanks = b.protectors_of(unit) if tanks: return tanks[0] return None def _cast(self, skill, target): b = self.battle if b.use_skill(b.current, skill, target): self.auto_end = 600 def _finish_battle(self): """Instantly resolve a clearly-dominant battle by simulating it to its true outcome (option 1): the result is genuine, not an assumed win.""" b = self.battle winner = resolve_instantly(self._finish_players, self._finish_enemies, b.relics) b.winner = winner # Mark the losing side dead with empty bars (mirrors _kill). for u in b.players: if winner == TEAM_PLAYER: u.alive = True else: u.alive = False u.hp = 0 u.status.pop("shield", None) for u in b.enemies: if winner == TEAM_ENEMY: u.alive = True else: u.alive = False u.hp = 0 u.status.pop("shield", None) if winner == TEAM_PLAYER: b.log.append("Battle finished instantly — your team overwhelms the enemy!") else: b.log.append("Battle finished instantly — the enemy prevails.") self.reward_rolled = False def _end_player_turn(self): self.battle.next_turn() self.auto_end = 0.0 self.ai_timer = self.ai_delay self._preselect() def _update(self, dt): b = self.battle self._consume_events() self._advance_hit_queue(dt) if b.winner is not None: if not self.reward_rolled: if b.winner == TEAM_PLAYER and self.on_win: self.reward = self.on_win() elif b.winner == TEAM_ENEMY and self.on_lose: self.reward = self.on_lose() self.reward_rolled = True return if self.auto_end > 0: self.auto_end -= dt if self.auto_end <= 0 and b.current and b.current.team == TEAM_PLAYER and b.current.acted: self._end_player_turn() if b.current and b.current.team == TEAM_ENEMY: self.ai_timer -= dt if self.ai_timer <= 0: if not b.current.acted: used = b.enemy_ai(b.current) self.ai_timer = self.ai_delay if not used: b.next_turn() self._preselect() if b.current.acted: b.next_turn() self._preselect() elif b.current and b.current.team == TEAM_PLAYER and self.auto_battle: self.ai_timer -= dt if self.ai_timer <= 0: if not b.current.acted: used = self._player_ai(b.current) self.ai_timer = self.ai_delay if not used: b.next_turn() self._preselect() if b.current.acted: b.next_turn() self._preselect() def _player_ai(self, unit) -> bool: """Auto-play the player's unit like the enemy AI. Returns True if it acted.""" if unit is not self.battle.current or unit.team != TEAM_PLAYER or not unit.alive: return False b = self.battle foes = b.foes_of(unit) # if only one enemy remains, prefer a guaranteed-kill damaging skill over a heal if len(foes) == 1: target = foes[0] for s in unit.skills: if s.cooldown_left > 0 or s.kind != "damage": continue if target not in b.valid_skill_targets(unit, s): continue if self._estimate_damage(unit, s, target) >= target.hp: return b.use_skill(unit, s, target) s = b.best_skill(unit) if not s: return False if s.kind == "heal": low = [a for a in b.allies_of(unit) if a.hp < a.max_hp] return b.use_skill(unit, s, min(low, key=lambda a: a.hp) if low else None) if s.kind == "shield": t = unit if s.target == "self" else b._shield_target(unit) if t is None: t = unit return b.use_skill(unit, s, t) if s.target in ("all_enemies", "two_enemies"): return b.use_skill(unit, s, None) if s.target == "any": tg = b.valid_skill_targets(unit, s) return b.use_skill(unit, s, min(tg, key=lambda t: t.hp) if tg else None) if s.kind == "utility" and s.mechanic == "reset_cooldowns": al = b.allies_of(unit) if al: target = max(al, key=lambda a: sum( x.cooldown_left for x in a.skills if x.cooldown_left > 0)) return b.use_skill(unit, s, target) return False if s.target in ("ally", "all_allies"): al = b.allies_of(unit) return b.use_skill(unit, s, min(al, key=lambda a: a.hp) if al else None) tg = b.valid_skill_targets(unit, s) if not tg: return False if s.mechanic == "hits_per_effect": t = max(tg, key=lambda t: (len([k for k in t.status if k != "form"]), -t.hp)) else: t = min(tg, key=lambda t: t.hp) return b.use_skill(unit, s, t) def _estimate_damage(self, unit, skill, target) -> int: """Conservative (no-crit) damage estimate for a skill against a target.""" from engine.types import type_multiplier power = skill.power atk = self.battle._attack(unit) offense = power + int(atk * 0.5) dfn = self.battle._defense(target) dmg = int(offense * (1 - dfn / (dfn + 75))) dmg = max(1, int(dmg * type_multiplier(unit.plant_type, target.plant_type))) if skill.target in ("all_enemies", "two_enemies"): dmg = int(dmg * 0.75) return dmg # --- drawing ------------------------------------------------------- def _consume_events(self): b = self.battle if not b.events: return for ev in b.events: if ev.get("type") == "damage": self.hit_queue.append({"event": ev, "timer": len(self.hit_queue) * 110}) else: self._handle_event(ev) b.events.clear() def _advance_hit_queue(self, dt): if not self.hit_queue: return for h in self.hit_queue: h["timer"] -= dt due = [h for h in self.hit_queue if h["timer"] <= 0] self.hit_queue = [h for h in self.hit_queue if h["timer"] > 0] for h in due: self._handle_event(h["event"]) def _handle_event(self, ev): kind = ev.get("type") if kind == "damage": source, target = ev.get("source"), ev.get("target") if source and target and source is not target: self._spawn_beam(source, target, (255, 235, 120)) if target: idx = self._hit_offsets.get(target.sort_order, 0) self._hit_offsets[target.sort_order] = idx + 1 offset = (idx % 3) * -16 self._spawn_floater(target, f"-{ev['amount']}", (255, 235, 235), center=True, offset=offset) self._spawn_flash(target, (255, 80, 80)) if source: self._spawn_flash(source, (255, 255, 255)) elif kind == "heal": source, target = ev.get("source"), ev.get("target") if source and target and source is not target: self._spawn_beam(source, target, (120, 255, 150)) if target: self._spawn_floater(target, f"+{ev['amount']}", (120, 255, 150)) self._spawn_flash(target, (90, 255, 120)) elif kind == "shield": if ev.get("target"): self._spawn_floater(ev["target"], "shield", (150, 210, 255)) self._spawn_flash(ev["target"], (120, 190, 255)) elif kind == "kill": if ev.get("target"): self._spawn_floater(ev["target"], "DEFEATED", (255, 120, 120)) self._spawn_flash(ev["target"], (255, 255, 255)) def _spawn_beam(self, source, target, color): a = self.card_rects.get(source.sort_order) b = self.card_rects.get(target.sort_order) if a and b: self.beams.append({"start": a.center, "end": b.center, "color": color, "age": 0.0, "lifetime": 350}) def _spawn_floater(self, unit, text, color, center=False, offset=0): rect = self.card_rects.get(unit.sort_order) if rect: y = (rect.centery if center else rect.top - 8) + offset self.floaters.append({"x": rect.centerx, "y": y, "text": text, "color": color, "age": 0.0, "lifetime": 1000}) def _spawn_flash(self, unit, color): rect = self.card_rects.get(unit.sort_order) if rect: self.flashes.append({"rect": rect, "color": color, "age": 0.0, "lifetime": 500}) def _draw_effects(self): keep = [] for f in self.floaters: f["age"] += self._dt if f["age"] >= f["lifetime"]: continue prog = f["age"] / f["lifetime"] txt = self.bold.render(f["text"], True, f["color"]) txt.set_alpha(int(255 * (1 - prog))) lift = int(prog * 26) self.screen.blit(txt, (f["x"] - txt.get_width() // 2, f["y"] - lift)) keep.append(f) self.floaters = keep keep = [] for fl in self.flashes: fl["age"] += self._dt if fl["age"] >= fl["lifetime"]: continue alpha = int(200 * (1 - fl["age"] / fl["lifetime"])) self.flash_surf.fill(fl["color"]) self.flash_surf.set_alpha(alpha) self.screen.blit(self.flash_surf, fl["rect"].topleft) keep.append(fl) self.flashes = keep keep = [] for bm in self.beams: bm["age"] += self._dt if bm["age"] >= bm["lifetime"]: continue prog = bm["age"] / bm["lifetime"] alpha = int(255 * (1 - prog)) overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) pygame.draw.line(overlay, (*bm["color"], alpha), bm["start"], bm["end"], 5) pygame.draw.circle(overlay, (*bm["color"], alpha), bm["end"], 8) self.screen.blit(overlay, (0, 0)) keep.append(bm) self.beams = keep def _draw(self): if self.battle.winner is not None: self.info_unit = None self.screen.fill(BG) if self.tower and self.stage: title_text = f"Tower — Floor {self.stage}" elif self.stage: title_text = f"Floor {self.stage}" elif self.tower: title_text = "Plants — Tower Battle" else: title_text = "Plants — 4v4 Turn Battle" title = self.font.render(title_text, True, TEXT) self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 24))) 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.auto_unlocked: abg = BTN_HOVER if self.auto_battle else BTN pygame.draw.rect(self.screen, abg, self.auto_btn, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if self.auto_battle else (90, 110, 90), self.auto_btn, 2, border_radius=6) t = self.small.render("Auto: ON" if self.auto_battle else "Auto", True, TEXT) self.screen.blit(t, t.get_rect(center=self.auto_btn.center)) if (self._dominance and self.battle.turn >= 3 and collection.get_tower_best() >= 5 and self.battle.winner is None): fbg = BTN_HOVER if self.finish_rect.collidepoint(self.mouse_pos) else BTN pygame.draw.rect(self.screen, fbg, self.finish_rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150), self.finish_rect, 2, border_radius=6) ft = self.small.render("Finish battle", True, TEXT) self.screen.blit(ft, ft.get_rect(center=self.finish_rect.center)) self._draw_cards() self._layout_skills() self._draw_skill_bar() self._draw_log() self._draw_effects() if self.info_unit is not None: self._draw_unit_info() if self.battle.winner is not None and self.reward_rolled and not self.reward_acknowledged: self._draw_reward_popup() if self.relic_overview is not None: self.relic_overview.draw(self.mouse_pos) def _draw_reward_popup(self): b = self.battle overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 170)) self.screen.blit(overlay, (0, 0)) # Build tiles: (img_or_None, label, w, h, shrinkable). Pack sprites are # drawn at native size; rune sprites are too, but shrink (aspect kept) # only as a last resort so a rare 2-pack + rune reward still fits. tiles = [] for it in (self.reward or []): if isinstance(it, str): tiles.append((None, it, self.bold.size(it)[0] + 20, 30, False)) continue label = it.get("label", "") img = self._reward_sprite(it.get("img")) if it.get("img") else None if img: kind = it.get("kind", "") # Display scaled to match the rest of the UI (aspect preserved). img = fit_surface(img, 60 if kind == "rune" else 80) tiles.append((img, label, img.get_width(), img.get_height() + 20, kind == "rune")) else: tiles.append((None, label, max(90, self.bold.size(label)[0] + 20), 30, False)) gap = 14 pad = 24 avail_w = WIN_W - 48 def _layout(tiles): rows, row, row_w = [], [], 0 for t in tiles: tw = t[2] if row and row_w + gap + tw > avail_w: rows.append(row) row, row_w = [], 0 row.append(t) row_w += (gap if row_w else 0) + tw if row: rows.append(row) row_h = [max(t[3] for t in r) for r in rows] if rows else [] max_row_w = max((sum(t[2] for t in r) + gap * (len(r) - 1)) for r in rows) if rows else 0 head_h = 96 if tiles else 36 tiles_h = sum(row_h) + 14 * (len(row_h) - 1) return rows, row_h, max_row_w, head_h, tiles_h rows, row_h, max_row_w, head_h, tiles_h = _layout(tiles) panel_w = max(440, max_row_w + pad * 2) panel_h = head_h + tiles_h + 46 # If the panel doesn't fit the screen (e.g. several packs at once), # shrink every sprite proportionally (aspect preserved, never distorted) # until it fits. A single reward stays at native resolution. while (panel_w > WIN_W or panel_h > WIN_H) and any(t[0] is not None for t in tiles): changed = False for i, t in enumerate(tiles): img = t[0] if img is None: continue target = max(24, int(max(img.get_width(), img.get_height()) * 0.85)) if target >= max(img.get_width(), img.get_height()): continue img2 = fit_surface(img, target) tiles[i] = (img2, t[1], img2.get_width(), img2.get_height() + 20, t[4]) changed = True if not changed: break rows, row_h, max_row_w, head_h, tiles_h = _layout(tiles) panel_w = max(440, max_row_w + pad * 2) panel_h = head_h + tiles_h + 46 panel = pygame.Rect((WIN_W - panel_w) // 2, (WIN_H - panel_h) // 2, panel_w, panel_h) pygame.draw.rect(self.screen, (32, 38, 32), panel, border_radius=12) border = (150, 190, 150) if b.winner == TEAM_PLAYER else (200, 90, 90) pygame.draw.rect(self.screen, border, panel, 2, border_radius=12) title = self.font.render("VICTORY!" if b.winner == TEAM_PLAYER else "DEFEAT...", True, HINT) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) if tiles: verb = "won" if b.winner == TEAM_PLAYER else "earned" head = self.bold.render(f"You {verb}:", True, TEXT) self.screen.blit(head, head.get_rect(center=(panel.centerx, panel.y + 62))) y = panel.y + head_h for i, r in enumerate(rows): total = sum(t[2] for t in r) + gap * (len(r) - 1) x = panel.centerx - total // 2 for img, label, tw, th, _ in r: if img is not None: self.screen.blit(img, (x, y)) lr = self.bold.render(label, True, HINT) self.screen.blit(lr, lr.get_rect(center=(x + tw // 2, y + img.get_height() + 10))) else: box = pygame.Rect(x, y, tw, 30) pygame.draw.rect(self.screen, (45, 52, 45), box, border_radius=6) t = self.bold.render(label, True, TEXT) self.screen.blit(t, t.get_rect(center=box.center)) x += tw + gap y += row_h[i] + 14 hint = self.tiny.render("Click to continue", True, (150, 155, 150)) self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 16))) def _status_text(self, unit): parts = [] if unit.status.get("sleep", 0) > 0: parts.append("Sleeping") if unit.status.get("stun", 0) > 0: parts.append("Stunned") if "vulnerable" in unit.status: parts.append(f"Vulnerable {unit.status['vulnerable']}") if "wilt" in unit.status: total, turns = unit.status["wilt"] parts.append(f"Wilt {total}") if "dot" in unit.status: stacks = unit.status["dot"] parts.append(f"DoT {sum(d for d, _ in stacks)}/t") if "speed_down" in unit.status: parts.append("Spd down") if "crit_down" in unit.status: parts.append("Crit down") if "attack_down" in unit.status: parts.append("Atk down") if "defense_down" in unit.status: parts.append("Def down") if "intoxicate" in unit.status: parts.append("Intoxicated") if "oblivion" in unit.status: parts.append("Oblivion") if "madness" in unit.status: parts.append(f"Madness {unit.status['madness']}") if "tether" in unit.status: parts.append("Tethered") if "attack_up" in unit.status: parts.append("Atk+") if "defense_up" in unit.status: parts.append("Def+") if "speed_up" in unit.status: parts.append("Spd+") if unit.status.get("snowflakes", 0): parts.append(f"Hit+{unit.status['snowflakes']}") if "hot" in unit.status: stacks = unit.status["hot"] parts.append(f"Heal {sum(a for a, _ in stacks)}/t") if "form" in unit.status: parts.append(unit.status["form"].capitalize()) return " | ".join(parts) def _draw_cards(self): b = self.battle valid = [] if self.pending_skill and b.current: valid = b.valid_skill_targets(b.current, self.pending_skill) self.info_rects = {} for unit, rect in self._cards: team_color = ALLIED if unit.team == TEAM_PLAYER else ENEMY bg = CARD_BG if unit.alive else (28, 28, 28) pygame.draw.rect(self.screen, bg, rect, border_radius=8) if unit in valid: pygame.draw.rect(self.screen, TARGET, rect, 3, border_radius=8) elif unit is b.current: pygame.draw.rect(self.screen, SEL, rect, 3, border_radius=8) else: pygame.draw.rect(self.screen, team_color, rect, 2, border_radius=8) bar_w = CARD_W - 130 hp_x = rect.x + 10 pct = max(0.0, unit.hp / unit.max_hp) pygame.draw.rect(self.screen, (60, 20, 20), (hp_x, rect.y + 7, bar_w, 10)) bar_color = (80, 220, 90) if pct > 0.5 else (220, 180, 40) if pct > 0.25 else (220, 60, 60) fill = int(bar_w * pct) pygame.draw.rect(self.screen, bar_color, (hp_x, rect.y + 7, fill, 10)) shield = unit.status.get("shield", 0) if shield > 0 and fill > 0: sh_w = int(bar_w * min(1.0, shield / unit.max_hp)) sh_w = min(sh_w, fill) if sh_w > 0: tint = pygame.Surface((sh_w, 10), pygame.SRCALPHA) tint.fill((120, 185, 240, 140)) self.screen.blit(tint, (hp_x, rect.y + 7)) x_right = rect.x + CARD_W - 12 hp_text = self.small.render(f"{unit.hp}/{unit.max_hp}", True, TEXT) sh_text = self.tiny.render(f" shield {shield}", True, (150, 210, 255)) if shield > 0 else None total_w = hp_text.get_width() + (sh_text.get_width() if sh_text else 0) self.screen.blit(hp_text, (x_right - total_w, rect.y + 3)) if sh_text: self.screen.blit(sh_text, (x_right - total_w + hp_text.get_width(), rect.y + 5)) init_pct = b.progress(unit) pygame.draw.rect(self.screen, (28, 38, 48), (hp_x, rect.y + 20, bar_w, 5)) pygame.draw.rect(self.screen, (120, 200, 240), (hp_x, rect.y + 20, int(bar_w * init_pct), 5)) portrait = self.sprites.get(unit.sort_order) if portrait is not None: self.screen.blit(portrait, (rect.x + 10, rect.y + 28)) _draw_evo_badge(self.screen, self.tiny, (rect.x + CARD_W - 80, rect.y + 12), unit.stage - 1) tx = rect.x + 104 name_color = TEXT if unit.alive else (130, 130, 130) name = self.bold.render(unit.name, True, name_color) self.screen.blit(name, (tx, rect.y + 30)) tcol = TYPE_COLORS.get(unit.plant_type, TEXT) tic = load_type_icon(unit.plant_type, 14) if unit.plant_type else None if tic: self.screen.blit(tic, tic.get_rect(midleft=(tx + name.get_width() + 8, rect.y + 39))) icon_x = tx + name.get_width() + 8 + tic.get_width() + 8 elif unit.plant_type: ttype = self.small.render(unit.plant_type, True, tcol) self.screen.blit(ttype, (tx + name.get_width() + 8, rect.y + 32)) icon_x = tx + name.get_width() + 8 + ttype.get_width() + 8 else: icon_x = tx + name.get_width() + 8 if unit.alive and unit.role == ROLE_TANK and b.tanks_on_team(unit.team): self.screen.blit(self.tank_icon, (icon_x, rect.y + 30)) if unit.alive and unit.team != TEAM_PLAYER and b.current and b.current.team == TEAM_PLAYER and b.current is not unit: mult = type_multiplier(b.current.plant_type, unit.plant_type) if mult != 1.0: badge = self.small.render(f"{mult:.1f}x", True, (120, 220, 130) if mult > 1 else (235, 130, 130)) self.screen.blit(badge, (rect.x + CARD_W - 62, rect.y + 26)) if unit.alive: statuses = self._status_text(unit) if statuses: self.screen.blit(self.small.render(statuses, True, (255, 160, 120)), (tx, rect.y + 56)) rune_colors = [runes.color_for_effect(e) for e, _ in unit.runes] rune_colors = [c for c in rune_colors if c] if rune_colors: draw_rune_chips(self.screen, self._rune_icon, rune_colors, tx, rect.y + 74) else: dead = self.small.render("DEFEATED", True, (150, 150, 150)) self.screen.blit(dead, (tx, rect.y + 56)) irect = pygame.Rect(rect.right - 28, rect.bottom - 28, 20, 20) self.info_rects[unit.sort_order] = irect ihov = irect.collidepoint(self.mouse_pos) pygame.draw.circle(self.screen, (120, 140, 120) if ihov else (70, 82, 70), irect.center, 10) pygame.draw.circle(self.screen, (175, 195, 175), irect.center, 10, 1) itxt = self.tiny.render("i", True, TEXT) self.screen.blit(itxt, itxt.get_rect(center=irect.center)) lv = self.tiny.render(f"Lv {unit.level}", True, (175, 195, 175)) self.screen.blit(lv, lv.get_rect(midright=(irect.left - 5, irect.centery))) def _draw_unit_info(self): u = self.info_unit if u is None: return shade = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) shade.fill((0, 0, 0, 150)) self.screen.blit(shade, (0, 0)) w, h = 480, 560 panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h) pygame.draw.rect(self.screen, (30, 36, 30), panel, border_radius=10) pygame.draw.rect(self.screen, (90, 110, 90), panel, 2, border_radius=10) spr = self.sprites.get(u.sort_order) if spr: ic = fit_surface(spr, 96) self.screen.blit(ic, ic.get_rect(center=(panel.x + 68, panel.y + 72))) rar = plant_rarity(u.name) name = self.bold.render(u.name, True, RARITY_COLORS.get(rar, TEXT)) self.screen.blit(name, (panel.x + 130, panel.y + 34)) rt = self.tiny.render(RARITY_LABELS.get(rar, rar), True, RARITY_COLORS.get(rar, TEXT)) self.screen.blit(rt, (panel.x + 130 + name.get_width() + 8, panel.y + 38)) rn = self.small.render(ROLE_NAMES.get(u.role, u.role), True, MUTED) self.screen.blit(rn, (panel.x + 130, panel.y + 66)) tic = load_type_icon(u.plant_type, 14) if u.plant_type else None if tic: self.screen.blit(tic, tic.get_rect(midleft=(panel.x + 130 + rn.get_width() + 8, panel.y + 74))) elif u.plant_type: sep2 = self.small.render(" · ", True, MUTED) ts = self.small.render(u.plant_type, True, TYPE_COLORS.get(u.plant_type, MUTED)) self.screen.blit(sep2, (panel.x + 130 + rn.get_width(), panel.y + 66)) self.screen.blit(ts, (panel.x + 130 + rn.get_width() + sep2.get_width(), panel.y + 66)) runes_list = u.runes if runes_list: ry = panel.y + 88 for eff, pct in runes_list[:3]: color = runes.color_for_effect(eff) ic = self._rune_icon(color) if ic: self.screen.blit(ic, (panel.x + 130, ry)) txt = self.tiny.render( runes.effect_text(color, int(round(pct * 100))), True, TEXT) self.screen.blit(txt, (panel.x + 148, ry + 1)) ry += 14 b = self.battle atk, dfn, spd = b._attack(u), b._defense(u), b._speed(u) self._draw_stat_line(panel.x + 20, panel.y + 132, [("Attack", atk, u.attack), ("Defense", dfn, u.defense), ("Speed", spd, u.speed)]) y2 = panel.y + 154 hp_txt = self.small.render(f"HP {u.hp}/{u.max_hp}", True, TEXT) self.screen.blit(hp_txt, (panel.x + 20, y2)) x2 = panel.x + 20 + hp_txt.get_width() + 18 crit_pct = int(round(b._crit(u) * 100)) base_pct = int(round(u.crit * 100)) ctxt = self.small.render(f"Crit {crit_pct}%", True, TEXT) self.screen.blit(ctxt, (x2, y2)) x2 += ctxt.get_width() if crit_pct != base_pct: base_t = self.tiny.render(f" (base {base_pct}%)", True, MUTED) self.screen.blit(base_t, (x2, y2 + 3)) inj = collection.injection_of(u.name) inj_idx = u.injected_index if u.injected_index >= 0 else (inj.get("index", -1) if inj else -1) y = panel.y + 184 if u.passive: pname, pdesc = PASSIVES.get(u.passive, (u.passive, "")) pt = self.bold.render(f"Passive — {pname}", True, HINT) self.screen.blit(pt, (panel.x + 20, y)) lines = wrap_text(" " + pdesc, self.small, panel.w - 40) for i, ln in enumerate(lines): self.screen.blit(self.small.render(ln, True, TEXT), (panel.x + 20, y + 24 + i * 20)) y += 26 + len(lines) * 20 + 2 for i, sk in enumerate(u.skills): bracket = _bracket_text(sk) label = sk.name if bracket: label += f" [{bracket}]" if sk.cooldown: if sk.cooldown_left > 0: note = f"{sk.cooldown_left} turn{'s' if sk.cooldown_left > 1 else ''} left" else: note = "ready" label += f" CD {sk.cooldown} ({note})" is_inj = i == inj_idx st = self.bold.render(label, True, ACCENT if is_inj else HINT) self.screen.blit(st, (panel.x + 20, y)) if is_inj: tag = self.tiny.render("injected", True, ACCENT) self.screen.blit(tag, (panel.x + 20 + st.get_width() + 8, y + 4)) d = self.small.render(" " + sk.description, True, ACCENT if is_inj else TEXT) self.screen.blit(d, (panel.x + 20, y + 24)) y += 54 y += 4 st_head = self.bold.render("Current status", True, HINT) self.screen.blit(st_head, (panel.x + 20, y)) y += 26 statuses = self._status_text(u) if statuses: for ln in self._wrap_text(statuses, self.small, panel.w - 40)[:5]: self.screen.blit(self.small.render(ln, True, (255, 160, 120)), (panel.x + 20, y)) y += 20 else: self.screen.blit(self.small.render("No active buffs or debuffs", True, MUTED), (panel.x + 20, y)) ch = self.tiny.render("Click anywhere to close", True, MUTED) self.screen.blit(ch, ch.get_rect(center=(panel.centerx, panel.bottom - 18))) @staticmethod def _wrap_text(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_stat_line(self, x, y, tokens): """Draw 'Label value' tokens, appending a muted '(base N)' when the current value differs from the base (buffs/debuffs in effect).""" x_pos = x for label, cur, base in tokens: txt = self.small.render(f"{label} {cur}", True, TEXT) self.screen.blit(txt, (x_pos, y)) x_pos += txt.get_width() if cur != base: btxt = self.tiny.render(f" (base {base})", True, MUTED) self.screen.blit(btxt, (x_pos, y + 3)) x_pos += btxt.get_width() x_pos += 18 def _draw_skill_bar(self): b = self.battle pygame.draw.rect(self.screen, BAR_BG, (16, SKILL_Y - 4, WIN_W - 32, SKILL_H + 8), border_radius=8) mouse = self.mouse_pos if b.winner is not None: msg = "VICTORY!" if b.winner == TEAM_PLAYER else "DEFEAT..." t = self.font.render(msg, True, HINT) self.screen.blit(t, t.get_rect(center=(WIN_W // 2, SKILL_Y + SKILL_H // 2 - 12))) if self.reward: verb = "won" if b.winner == TEAM_PLAYER else "earned" pt = self.tiny.render(f"You {verb}: {', '.join(self._reward_labels())}!", True, TEXT) self.screen.blit(pt, pt.get_rect(center=(WIN_W // 2, SKILL_Y + SKILL_H // 2 + 14))) return if b.current is None or b.current.team != TEAM_PLAYER: t = self.small.render("Enemy turn...", True, (220, 160, 160)) self.screen.blit(t, t.get_rect(center=(WIN_W // 2, SKILL_Y + SKILL_H // 2))) return unit = b.current oblivion = isinstance(unit.status.get("oblivion"), tuple) and unit.status["oblivion"][0] > 0 oblivion_skill = None if oblivion: ready = [s for s in unit.skills if s.cooldown_left <= 0] if ready: oblivion_skill = min(ready, key=lambda s: s.power) for i, (sk, rect) in enumerate(self.skill_rects): ready = sk.cooldown_left <= 0 blocked = oblivion and sk is not oblivion_skill if sk is self.pending_skill: bg = (120, 120, 40) elif ready and not blocked: bg = BTN if not rect.collidepoint(mouse) else BTN_HOVER else: bg = LOCKED pygame.draw.rect(self.screen, bg, rect, border_radius=8) if sk is self.pending_skill: border = SEL elif blocked: border = (90, 90, 90) elif i == self.injected_index: border = ACCENT elif ready: border = (150, 190, 150) else: border = (70, 74, 70) pygame.draw.rect(self.screen, border, rect, 2, border_radius=8) name_color = ACCENT if i == self.injected_index and not blocked else TEXT name = self.small.render(sk.name, True, name_color) self.screen.blit(name, name.get_rect(midtop=(rect.centerx, rect.y + 6))) if sk.kind == "damage": sub = f"{sk.power} dmg" elif sk.kind == "heal": sub = "full heal" if sk.mechanic == "full_heal" else f"heal {sk.power}" elif sk.kind == "shield": sub = f"shield {sk.power}" else: sub = None if sub is None: for eff in sk.effects: if eff.kind == EFF_HOT: sub = f"heal {eff.value}/t" break if eff.kind == EFF_DOT: sub = f"dmg {eff.value}/t" break else: sub = "utility" if sk.target == "all_enemies": sub += " · all" elif sk.target == "two_enemies": sub += " · 2" elif sk.target == "all_allies": sub += " · allies" elif sk.target == "any": sub += " · any" if blocked: sub += " · Oblivion" elif ready: sub += " · Ready" else: sub += f" · CD {sk.cooldown_left}" sub_surf = self.tiny.render(sub, True, (150, 150, 150) if blocked else (HINT if ready else (140, 140, 140))) self.screen.blit(sub_surf, sub_surf.get_rect(midtop=(rect.centerx, rect.y + 28))) hovered = next((sk for sk, rect in self.skill_rects if rect.collidepoint(mouse)), None) desc_skill = hovered or self.pending_skill if desc_skill is not None: desc = f"{desc_skill.name}: {desc_skill.description}" color = HINT elif self.pending_skill: desc = "Choose a target (green cards)" color = HINT else: pname = PASSIVES.get(unit.passive, (unit.passive or "", ""))[0] pfx = f" · {pname}" if pname else "" desc = f"{unit.name}{pfx}'s turn — pick a skill, or click an enemy to attack" color = TEXT if oblivion: desc = "Oblivion: only the weakest skill can be used!" color = (235, 130, 130) t = self.small.render(desc, True, color) self.screen.blit(t, t.get_rect(center=(WIN_W // 2, SKILL_Y + 62))) def _draw_log(self): b = self.battle pygame.draw.rect(self.screen, LOG_BG, self.log_rect) header = self.relics and self.tower visible = 3 if header else 4 y = LOG_Y + 6 if header: names = ", ".join(r.name for r in self.relics) rt = self.tiny.render(f"Blessings: {names} (click to inspect)", True, (245, 200, 90)) self.screen.blit(rt, (24, y)) y += 17 # keep the view anchored on the same entries when new ones arrive delta = len(b.log) - self._last_log_len if delta > 0 and self.log_scroll > 0: self.log_scroll += delta self._last_log_len = len(b.log) self.log_scroll = min(self.log_scroll, max(0, len(b.log) - visible)) end = len(b.log) - self.log_scroll start = max(0, end - visible) injected = self._injected_skill_names() for entry in b.log[start:end]: if isinstance(entry, dict): text = entry["text"] base = (120, 220, 130) if entry["team"] == TEAM_PLAYER else (235, 130, 130) else: text = entry base = (170, 175, 170) self._blit_log(text, (24, y), base, injected) y += 17 if self.log_scroll > 0: t = self.tiny.render("scroll up for older", True, (140, 150, 160)) self.screen.blit(t, (WIN_W - 32 - t.get_width(), LOG_Y + LOG_H - 16)) def _injected_skill_names(self) -> set: """Skill names that are injected on any unit in this battle.""" names = set() for u in self.battle.units: if u.injected_name: names.add(u.injected_name) return names def _blit_log(self, text, pos, base, injected): x = pos[0] for segment, is_inj in self._split_injected(text, injected): surf = self.small.render(segment, True, ACCENT if is_inj else base) self.screen.blit(surf, (x, pos[1])) x += surf.get_width() def _split_injected(self, text, injected): spans = [] for name in injected: idx = text.find(name) if idx >= 0: spans.append((idx, idx + len(name))) if not spans: return [(text, False)] spans.sort() parts = [] pos = 0 for s, e in spans: if s > pos: parts.append((text[pos:s], False)) parts.append((text[s:e], True)) pos = e if pos < len(text): parts.append((text[pos:], False)) return parts