small update
boss prototype, auto planter, visual reworks, bug fixes
This commit is contained in:
+162
-27
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user