1192 lines
51 KiB
Python
1192 lines
51 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.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 |