Files
Flora/ui/main.py
T
Lena feb4500c6c small update
boss prototype, auto planter, visual reworks, bug fixes
2026-08-29 10:45:34 +02:00

1327 lines
58 KiB
Python

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.growth import EVOLVE_COST, EVOLVE_STAGE_AT_LEVEL, can_evolve
from engine.plants import PASSIVES, POOL, 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_BEATS, 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": (235, 130, 170),
"wildflower": (120, 200, 120),
}
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"Flora — Tower — Floor {stage}"
elif stage:
caption = f"Flora — Floor {stage}"
elif tower:
caption = "Flora — Tower Battle"
else:
caption = "Flora — 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)
# "Back" confirmation modal + transient tank-redirect notice.
self.confirm_back = False
self.confirm_ok_rect = pygame.Rect(0, 0, 0, 0)
self.confirm_no_rect = pygame.Rect(0, 0, 0, 0)
self.tank_notice = ""
self.tank_notice_until = 0
# 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.confirm_back:
if self.confirm_ok_rect.collidepoint(pos):
self.confirm_back = False
self.battle.winner = TEAM_ENEMY # insta-lose
elif self.confirm_no_rect.collidepoint(pos):
self.confirm_back = False
continue
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):
if self.battle.winner is None:
self.confirm_back = True
else:
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 cur.acted and not sk.extra_action:
return # already acted this turn; only extra-action skills remain
if oblivion and sk is not weakest:
return
if sk.target in (TARGET_ENEMY, TARGET_ALLY, TARGET_ANY):
if self.pending_skill is sk:
# Clicking the selected skill AGAIN casts it (even if only
# one target is available). With several valid targets the
# player still needs to click one.
valid = b.valid_skill_targets(cur, sk)
if len(valid) == 1:
self.pending_skill = None
self._cast(sk, valid[0])
else:
self.pending_skill = sk
else:
# First click only SELECTS the skill (it must be clicked
# again, or a target clicked, to cast).
self.pending_skill = sk
else:
# No target needed (self / AoE): cast right away.
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:
if self._was_tank_redirect(unit, self.pending_skill, target):
self._set_tank_notice()
self._cast(self.pending_skill, target)
self.pending_skill = None
# Invalid target (e.g. clicking an enemy with an ally heal/shield
# selected): do nothing and KEEP the skill selected.
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:
if self._was_tank_redirect(unit, best, target):
self._set_tank_notice()
self._cast(best, target)
def _was_tank_redirect(self, unit, skill, target):
"""True when `unit` (a backline enemy) was redirected to a tank."""
return (skill.target == "enemy" and unit.team != TEAM_PLAYER
and unit.alive and unit.role != "tank" and unit is not target)
def _set_tank_notice(self):
self.tank_notice = "A tank is protecting that unit — attack the tank first!"
self.tank_notice_until = pygame.time.get_ticks() + 3000
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
if self.tank_notice and pygame.time.get_ticks() >= self.tank_notice_until:
self.tank_notice = ""
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 = "Flora — Tower Battle"
else:
title_text = "Flora — 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)
if self.tank_notice and pygame.time.get_ticks() < self.tank_notice_until:
self._draw_tank_notice()
if self.confirm_back:
self._draw_confirm_back()
def _draw_tank_notice(self):
t = self.small.render(self.tank_notice, True, (255, 210, 90))
bg = pygame.Surface((t.get_width() + 24, t.get_height() + 10), pygame.SRCALPHA)
bg.fill((20, 24, 20, 210))
rect = t.get_rect(center=(WIN_W // 2, 96))
self.screen.blit(bg, (rect.x - 12, rect.y - 5))
self.screen.blit(t, rect)
def _draw_confirm_back(self):
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 140))
self.screen.blit(overlay, (0, 0))
w, h = 460, 170
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
pygame.draw.rect(self.screen, (32, 38, 32), panel, border_radius=12)
pygame.draw.rect(self.screen, (150, 70, 70), panel, 2, border_radius=12)
title = self.bold.render("Leave the battle?", True, TEXT)
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30)))
msg = self.small.render("You will lose this battle.", True, MUTED)
self.screen.blit(msg, msg.get_rect(center=(panel.centerx, panel.y + 58)))
self.confirm_ok_rect = pygame.Rect(panel.centerx - 120, panel.bottom - 54, 100, 36)
self.confirm_no_rect = pygame.Rect(panel.centerx + 20, panel.bottom - 54, 100, 36)
pygame.draw.rect(self.screen, (150, 70, 70), self.confirm_ok_rect, border_radius=8)
ok = self.bold.render("Leave", True, TEXT)
self.screen.blit(ok, ok.get_rect(center=self.confirm_ok_rect.center))
pygame.draw.rect(self.screen, BTN, self.confirm_no_rect, border_radius=8)
no = self.bold.render("Cancel", True, TEXT)
self.screen.blit(no, no.get_rect(center=self.confirm_no_rect.center))
def _defeat_tips(self):
"""Contextual tips for the defeat screen, based on the player's state
and the units they just brought into the lost battle."""
tips = []
if len(collection.owned()) < len(POOL) and sum(collection.pack_counts().values()) > 0:
tips.append("Try to open packs to gain more allies!")
if collection.available_water() >= 1:
tips.append("You can use water to level up your plants and critters.")
if collection.free_runes():
tips.append("You can use runes to improve your fighting abilities.")
used = list(self.battle.players)
enemy_types = [e.plant_type for e in self.battle.enemies]
for u in used:
prog = collection.get_progress(u.name)
if can_evolve(prog["level"], prog["stage"], prog.get("duplicates", 0)):
tips.append("Try evolving your plants for better stats.")
break
for u in used:
for etype in enemy_types:
if TYPE_BEATS.get(etype) == u.plant_type:
tips.append(f"Make sure to use the types to your advantage! "
f"{u.plant_type} is weak against {etype}.")
break
else:
continue
break
for u in used:
prog = collection.get_progress(u.name)
target = prog["stage"] + 1
if (target in EVOLVE_STAGE_AT_LEVEL
and prog["level"] >= EVOLVE_STAGE_AT_LEVEL[target]
and prog.get("duplicates", 0) < EVOLVE_COST[target]):
tips.append("You can plant seeds from your plants to gain more of them.")
break
return tips
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)
# Tile width must also fit the label, or a rune + pack in the
# same row overlap (the rune's label spills into the pack tile).
label_w = self.bold.size(label)[0]
tiles.append((img, label, max(img.get_width(), label_w),
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)
label_w = self.bold.size(t[1])[0]
tiles[i] = (img2, t[1], max(img2.get_width(), label_w),
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
# Defeat tips: contextual advice shown in the popup's text box.
tip_lines = []
if b.winner != TEAM_PLAYER:
for tip in self._defeat_tips():
tip_lines.extend(wrap_text(tip, self.small, panel_w - pad * 2))
tips_h = len(tip_lines) * 20 + (14 if tip_lines else 0)
panel_h += tips_h
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, img.get_rect(center=(x + tw // 2,
y + img.get_height() // 2)))
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
if tip_lines:
ty = panel.y + head_h + tiles_h + 10
for line in tip_lines:
t = self.small.render(line, True, (215, 220, 215))
self.screen.blit(t, (panel.x + pad, ty))
ty += 20
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())
if unit.status.get("boss_stacks", 0):
parts.append(f"Power {unit.status['boss_stacks']}/3")
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 = sum(amount for amount, _ in unit.status.get("shield", []))
if shield > 0 and fill > 0:
# The shield bar shows the FULL shield value, independent of the
# unit's current HP (a low-HP unit still shows its whole shield).
sh_w = int(bar_w * min(1.0, shield / unit.max_hp))
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 and (not unit.acted or sk.extra_action)
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