small update

boss prototype, auto planter, visual reworks, bug fixes
This commit is contained in:
2026-08-29 10:45:34 +02:00
parent 6840b715de
commit feb4500c6c
31 changed files with 1293 additions and 213 deletions
+162 -27
View File
@@ -4,10 +4,11 @@ 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.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_multiplier
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
@@ -19,8 +20,8 @@ TYPE_COLORS = {
"radiant": (245, 200, 90),
"nocturnal": (150, 120, 220),
"ice": (130, 200, 235),
"garden": (120, 200, 120),
"wildflower": (235, 130, 170),
"garden": (235, 130, 170),
"wildflower": (120, 200, 120),
}
WIN_W = 1040
WIN_H = 720
@@ -64,13 +65,13 @@ class Game:
self.stage = stage
self.relics = list(relics)
if tower and stage:
caption = f"Plants — Tower — Floor {stage}"
caption = f"Flora — Tower — Floor {stage}"
elif stage:
caption = f"Plants — Floor {stage}"
caption = f"Flora — Floor {stage}"
elif tower:
caption = "Plants — Tower Battle"
caption = "Flora — Tower Battle"
else:
caption = "Plants — 4v4 Turn Battle (prototype)"
caption = "Flora — 4v4 Turn Battle (prototype)"
pygame.display.set_caption(caption)
self.clock = pygame.time.Clock()
self.battle = battle
@@ -108,6 +109,12 @@ class Game:
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
@@ -206,6 +213,13 @@ class Game:
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
@@ -219,8 +233,11 @@ class Game:
self.reward_acknowledged = True
continue
if self.back_rect.collidepoint(pos):
outcome = "back"
running = False
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:
@@ -294,18 +311,27 @@ class Game:
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):
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])
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:
self.pending_skill = sk if self.pending_skill is not sk else None
# 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
@@ -319,8 +345,12 @@ class Game:
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
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)
@@ -328,8 +358,19 @@ class Game:
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):
@@ -380,6 +421,8 @@ class Game:
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:
@@ -603,9 +646,9 @@ class Game:
elif self.stage:
title_text = f"Floor {self.stage}"
elif self.tower:
title_text = "Plants — Tower Battle"
title_text = "Flora — Tower Battle"
else:
title_text = "Plants — 4v4 Turn Battle"
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)
@@ -636,6 +679,75 @@ class Game:
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
@@ -656,8 +768,11 @@ class Game:
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"))
# 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))
@@ -698,7 +813,9 @@ class Game:
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])
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
@@ -706,6 +823,14 @@ class Game:
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)
@@ -722,7 +847,8 @@ class Game:
x = panel.centerx - total // 2
for img, label, tw, th, _ in r:
if img is not None:
self.screen.blit(img, (x, y))
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:
@@ -732,6 +858,12 @@ class Game:
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)))
@@ -778,6 +910,8 @@ class Game:
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):
@@ -804,10 +938,11 @@ class Game:
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)
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))
sh_w = min(sh_w, fill)
if sh_w > 0:
tint = pygame.Surface((sh_w, 10), pygame.SRCALPHA)
tint.fill((120, 185, 240, 140))
@@ -1046,7 +1181,7 @@ class Game:
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
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)