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
+1 -1
View File
@@ -1,4 +1,4 @@
# Plants — Turn-Based Battle Game # Flora — Turn-Based Battle Game
A turn-based battle game in Python. Normal battles are 4v4; the Tower mode A turn-based battle game in Python. Normal battles are 4v4; the Tower mode
starts small and ramps up. starts small and ramps up.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

+57 -7
View File
@@ -9,6 +9,7 @@ from .skills import (
S_HEAL_REDUCTION, S_HOT, S_ICICLES, S_INTOXICATE, S_LYING_WAIT, S_MADNESS, S_OBLIVION, S_HEAL_REDUCTION, S_HOT, S_ICICLES, S_INTOXICATE, S_LYING_WAIT, S_MADNESS, S_OBLIVION,
S_PREVENT_FIRST_DAMAGE, S_SEED, S_SHIELD, S_SLEEP, S_SNOWFLAKES, S_SPEED_DOWN, S_PREVENT_FIRST_DAMAGE, S_SEED, S_SHIELD, S_SLEEP, S_SNOWFLAKES, S_SPEED_DOWN,
S_SPEED_UP, S_TETHER, S_VULNERABLE, S_WILT, S_ZINNIA_WEAKEN, S_SPEED_UP, S_TETHER, S_VULNERABLE, S_WILT, S_ZINNIA_WEAKEN,
SHIELD_DURATION,
EFF_DOT, EFF_HEAL, EFF_HOT, EFF_SHIELD, EFF_STATUS, EFF_WILT, EFF_DOT, EFF_HEAL, EFF_HOT, EFF_SHIELD, EFF_STATUS, EFF_WILT,
TARGET_ALL_ALLIES, TARGET_ALL_ENEMIES, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY, TARGET_ALL_ALLIES, TARGET_ALL_ENEMIES, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY,
TARGET_SELF, TARGET_TWO_ENEMIES, Skill, roll_crit, TARGET_SELF, TARGET_TWO_ENEMIES, Skill, roll_crit,
@@ -135,6 +136,7 @@ class Battle:
def _crit(self, unit: Unit) -> float: def _crit(self, unit: Unit) -> float:
pct = unit.crit * 100 - self._status_value(unit, S_CRIT_DOWN) pct = unit.crit * 100 - self._status_value(unit, S_CRIT_DOWN)
pct += self._rune_pct(unit, "crit") * 100
return max(0.0, pct / 100) return max(0.0, pct / 100)
# --- tank protection ---------------------------------------------- # --- tank protection ----------------------------------------------
@@ -278,6 +280,15 @@ class Battle:
del unit.status[key] del unit.status[key]
else: else:
unit.status[key] = (value, dur) unit.status[key] = (value, dur)
# Shields decay like any buff: each application counts down SHIELD_DURATION
# turns from when it was applied, independently of newer shields.
shields = unit.status.get(S_SHIELD)
if shields:
remaining = [(amount, turns - 1) for amount, turns in shields if turns > 1]
if remaining:
unit.status[S_SHIELD] = remaining
else:
unit.status.pop(S_SHIELD, None)
# --- status ticking ------------------------------------------------ # --- status ticking ------------------------------------------------
def _tick_dot(self, unit: Unit) -> None: def _tick_dot(self, unit: Unit) -> None:
@@ -371,13 +382,20 @@ class Battle:
blue = self._rune_pct(target, "shield") blue = self._rune_pct(target, "shield")
if blue: if blue:
amount = int(amount * (1 + blue)) amount = int(amount * (1 + blue))
target.status[S_SHIELD] = target.status.get(S_SHIELD, 0) + amount # Each application has its own decay timer; stacking does NOT refresh
# shields already up (they still decay SHIELD_DURATION turns after being
# applied).
target.status.setdefault(S_SHIELD, []).append((amount, SHIELD_DURATION))
if self._action is None: if self._action is None:
src = f" from {source.name}" if source else "" src = f" from {source.name}" if source else ""
self.log.append(f"{target.name} gains {amount} shield{src}") self.log.append(f"{target.name} gains {amount} shield{src}")
if self._action is not None: if self._action is not None:
self._action["shield"] += amount self._action["shield"] += amount
def _shield_total(self, unit: Unit) -> int:
"""Total shield remaining across all applications."""
return sum(amount for amount, _ in unit.status.get(S_SHIELD, []))
def _shield_power(self, user: Unit, base: int) -> int: def _shield_power(self, user: Unit, base: int) -> int:
"""Shield amounts scale with the caster's Attack so they keep up with """Shield amounts scale with the caster's Attack so they keep up with
growing health pools (like the Sunflower passive).""" growing health pools (like the Sunflower passive)."""
@@ -396,7 +414,7 @@ class Battle:
if weaken: if weaken:
mult *= 1 - weaken / 100.0 mult *= 1 - weaken / 100.0
# tulip_equinox: any ally of Tulip with a shield deals +10% # tulip_equinox: any ally of Tulip with a shield deals +10%
if attacker.status.get(S_SHIELD, 0) > 0: if self._shield_total(attacker) > 0:
team = self.players if attacker.team == TEAM_PLAYER else self.enemies team = self.players if attacker.team == TEAM_PLAYER else self.enemies
if any(u.passive == "tulip_equinox" for u in team): if any(u.passive == "tulip_equinox" for u in team):
mult *= 1.1 mult *= 1.1
@@ -507,10 +525,25 @@ class Battle:
def _absorb(self, attacker: Optional[Unit], target: Unit, dmg: int) -> int: def _absorb(self, attacker: Optional[Unit], target: Unit, dmg: int) -> int:
self._consume_hits_statuses(target) self._consume_hits_statuses(target)
reflect = isinstance(target.status.get("bulb_reflect"), tuple) and target.status["bulb_reflect"][0] > 0 reflect = isinstance(target.status.get("bulb_reflect"), tuple) and target.status["bulb_reflect"][0] > 0
shield = target.status.get(S_SHIELD, 0) shield_list = target.status.get(S_SHIELD, [])
shield = sum(amount for amount, _ in shield_list)
if shield > 0: if shield > 0:
if shield >= dmg: if shield >= dmg:
target.status[S_SHIELD] = shield - dmg # Consume the damage across applications (oldest first).
remaining = dmg
new_shields = []
for amount, turns in shield_list:
if remaining <= 0:
new_shields.append((amount, turns))
elif amount > remaining:
new_shields.append((amount - remaining, turns))
remaining = 0
else:
remaining -= amount
if new_shields:
target.status[S_SHIELD] = new_shields
else:
target.status.pop(S_SHIELD, None)
self.log.append(f" {target.name}'s shield absorbs {dmg}") self.log.append(f" {target.name}'s shield absorbs {dmg}")
self.events.append({"type": "shield", "target": target, "amount": dmg}) self.events.append({"type": "shield", "target": target, "amount": dmg})
if target.passive == "camelia_frigid": if target.passive == "camelia_frigid":
@@ -674,6 +707,8 @@ class Battle:
if skill.requires_form and skill.requires_form != user.status.get("form"): if skill.requires_form and skill.requires_form != user.status.get("form"):
return [] return []
if skill.target == TARGET_ENEMY: if skill.target == TARGET_ENEMY:
if skill.mechanic == "boss_landslide" and user.status.get("boss_stacks", 0) < 3:
return [] # Landslide only becomes available at 3 stacks
return self._targetable(self.foes_of(user), self._bypass_tanks(user, skill)) return self._targetable(self.foes_of(user), self._bypass_tanks(user, skill))
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES): if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
return self.foes_of(user) return self.foes_of(user)
@@ -755,6 +790,10 @@ class Battle:
return False return False
if skill.cooldown_left > 0: if skill.cooldown_left > 0:
return False return False
if skill.mechanic == "boss_landslide" and user.status.get("boss_stacks", 0) < 3:
return False
if user.acted and not skill.extra_action:
return False # a unit acts once per turn (extra-action skills still allowed)
if skill.requires_form and skill.requires_form != user.status.get("form"): if skill.requires_form and skill.requires_form != user.status.get("form"):
return False return False
if isinstance(user.status.get(S_OBLIVION), tuple) and user.status[S_OBLIVION][0] > 0: if isinstance(user.status.get(S_OBLIVION), tuple) and user.status[S_OBLIVION][0] > 0:
@@ -891,6 +930,8 @@ class Battle:
if skill.mechanic == "dissolve": if skill.mechanic == "dissolve":
skill.power += self._status_value(user, S_LYING_WAIT) skill.power += self._status_value(user, S_LYING_WAIT)
power_bonus = int(self._defense(user) * 0.5) if skill.mechanic == "def_bonus" else 0 power_bonus = int(self._defense(user) * 0.5) if skill.mechanic == "def_bonus" else 0
if skill.mechanic == "boss_landslide":
power_bonus += 55 * user.status.get("boss_stacks", 0)
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES): if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
targets = self.foes_of(user) targets = self.foes_of(user)
if skill.target == TARGET_TWO_ENEMIES: if skill.target == TARGET_TWO_ENEMIES:
@@ -931,9 +972,12 @@ class Battle:
else: else:
self._apply_wilt_damage(target, int(dealt * 0.3)) self._apply_wilt_damage(target, int(dealt * 0.3))
if skill.mechanic == "heal_random_ally": if skill.mechanic == "heal_random_ally":
allies = self.allies_of(user) # Only pick from allies that are missing HP, so the heal always
if allies: # lands on someone it can actually help (a random full-HP ally
self._heal(user, random.choice(allies), 40) # would silently heal for 0).
injured = [a for a in self.allies_of(user) if a.hp < a.max_hp]
if injured:
self._heal(user, random.choice(injured), skill.power)
if skill.mechanic == "strip_buffs": if skill.mechanic == "strip_buffs":
self._strip_buffs(target) self._strip_buffs(target)
if skill.mechanic == "tether_lowest": if skill.mechanic == "tether_lowest":
@@ -942,6 +986,12 @@ class Battle:
low = min(allies, key=lambda a: a.hp) low = min(allies, key=lambda a: a.hp)
low.status[S_TETHER] = (1, 1) low.status[S_TETHER] = (1, 1)
self.log.append(f" {low.name} is tethered to {user.name}") self.log.append(f" {low.name} is tethered to {user.name}")
if skill.mechanic == "boss_stack":
user.status["boss_stacks"] = min(3, user.status.get("boss_stacks", 0) + 1)
self.log.append(f" {user.name}'s power stacks ({user.status['boss_stacks']}/3).")
if skill.mechanic == "boss_landslide":
user.status["boss_stacks"] = 0
self.log.append(f" {user.name} unleashes Landslide and releases all stacks!")
def _strip_buffs(self, unit: Unit) -> None: def _strip_buffs(self, unit: Unit) -> None:
for k in (S_ATTACK_UP, S_DEFENSE_UP, S_SPEED_UP, S_SHIELD): for k in (S_ATTACK_UP, S_DEFENSE_UP, S_SPEED_UP, S_SHIELD):
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from .plants import HP_MULT
from .skills import Skill, TARGET_ENEMY
from .types import TYPE_GARDEN
from .unit import TEAM_ENEMY, Unit
# A boss appears every BOSS_FLOOR_STEP floors (20, 40, 60, ...). Bosses are
# fought alone (1 vs the player's team) and have far bigger stats than normal
# floor enemies.
BOSS_FLOOR_STEP = 20
# Chance (0..1) a defeated boss drops one of its signature runes — much higher
# than the ~5-15% rune drop chance anywhere else.
BOSS_RUNE_DROP_CHANCE = 0.80
BOSS_SPECS = {
"Stones": {
"type": TYPE_GARDEN,
# (attack, speed, defense, health) — medium attack, very low speed,
# medium defense, high health (before HP_MULT).
"stats": (110, 30, 90, 380),
"sprite": "stones",
"drop_runes": ("ruby", "amethyst"),
"desc": "A slow, rocky guardian. Each Pebble Throw stacks its power "
"(up to 3); at 3 stacks it unleashes Landslide for huge damage.",
},
}
def is_boss(name: str) -> bool:
return name in BOSS_SPECS
def boss_for_floor(floor: int):
"""The boss that appears on `floor`, or None if it's a normal floor."""
if floor > 0 and floor % BOSS_FLOOR_STEP == 0:
return "Stones"
return None
def boss_type(name: str) -> str:
return BOSS_SPECS[name]["type"]
def boss_stats(name: str) -> tuple:
return BOSS_SPECS[name]["stats"]
def boss_sprite(name: str) -> str:
return BOSS_SPECS[name]["sprite"]
def boss_drop_runes(name: str) -> tuple:
return BOSS_SPECS[name]["drop_runes"]
def boss_floor_level(floor: int) -> int:
"""Display level for a boss at `floor` (scales with each boss appearance)."""
return 1 + floor // BOSS_FLOOR_STEP
def make_boss(name: str, floor: int) -> Unit:
"""Build the boss unit for `floor`. Boss stats are already far above normal
enemies and grow a little with each appearance so they stay threatening."""
spec = BOSS_SPECS[name]
atk, spd, dfn, hp = spec["stats"]
lvl = boss_floor_level(floor)
mult = 1 + 0.06 * (lvl - 1)
atk = int(atk * mult)
dfn = int(dfn * mult)
hp = int(hp * mult)
pebble = Skill("Pebble Throw", power=60, target=TARGET_ENEMY, cooldown=0,
mechanic="boss_stack", priority=2,
description="Medium damage; stacks the boss's power (up to 3).")
landslide = Skill("Landslide", power=140, target=TARGET_ENEMY, cooldown=0,
mechanic="boss_landslide", priority=1,
description="Huge damage, releases all stacks. Needs 3 stacks.")
u = Unit(name, TEAM_ENEMY, spec["type"], max_hp=int(hp * HP_MULT),
attack=atk, speed=spd, defense=dfn, skills=[pebble, landslide],
role="tank", crit=0.05)
u.hp = u.max_hp
return u
+118 -22
View File
@@ -49,6 +49,8 @@ def init_starter_pinwheel(ptype: str) -> bool:
"fertilizer_accrued_at": 0.0, "fertilizer_accrued_at": 0.0,
"auto_farmer": False, "auto_farmer": False,
"auto_farmer_active": False, "auto_farmer_active": False,
"auto_planter": False,
"auto_planter_active": False,
"coins": 0, "coins": 0,
"coins_accrued_at": 0.0, "coins_accrued_at": 0.0,
"water_used": 0, "water_used": 0,
@@ -59,6 +61,7 @@ def init_starter_pinwheel(ptype: str) -> bool:
"open_history": [], "open_history": [],
"lesser_opened": 0, "lesser_opened": 0,
"floor": 1, "floor": 1,
"bosses_fought": [],
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []}, "soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
} }
save(data) save(data)
@@ -80,6 +83,8 @@ def _default() -> dict:
"fertilizer_accrued_at": 0.0, "fertilizer_accrued_at": 0.0,
"auto_farmer": False, "auto_farmer": False,
"auto_farmer_active": False, "auto_farmer_active": False,
"auto_planter": False,
"auto_planter_active": False,
"coins": 0, "coins": 0,
"coins_accrued_at": 0.0, "coins_accrued_at": 0.0,
"water_used": 0, "water_used": 0,
@@ -91,6 +96,7 @@ def _default() -> dict:
"lesser_opened": 0, "lesser_opened": 0,
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []}, "soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
"floor": 1, "floor": 1,
"bosses_fought": [],
} }
@@ -128,6 +134,9 @@ def load() -> dict:
data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
data.setdefault("floor", 1) data.setdefault("floor", 1)
data.setdefault("wins", 0) data.setdefault("wins", 0)
data.setdefault("auto_planter", False)
data.setdefault("auto_planter_active", False)
data.setdefault("bosses_fought", [])
# normalize runes: older saves lack the per-rune stat roll, and use the old # normalize runes: older saves lack the per-rune stat roll, and use the old
# color names (blue/red/white/yellow) instead of the current gems. # color names (blue/red/white/yellow) instead of the current gems.
for r in data.setdefault("runes", []): for r in data.setdefault("runes", []):
@@ -252,8 +261,9 @@ def grant_xp(names, xp: int, count_battle: bool = True, fertilizer: int = None)
for n in names: for n in names:
prog = data["progress"][n] prog = data["progress"][n]
bonus = gap * CATCH_UP_PER_GAP if prog["level"] == low else 0 bonus = gap * CATCH_UP_PER_GAP if prog["level"] == low else 0
# all XP gains are cut by 2/3 (only a third is awarded) # all XP gains are cut to a sixth (half of the old third) so leveling
gain = (xp + bonus) // 3 if (xp + bonus) > 0 else 0 # takes twice as long; water (a direct +1 level) is unaffected
gain = (xp + bonus) // 6 if (xp + bonus) > 0 else 0
prog["xp"] += gain prog["xp"] += gain
while prog["level"] < MAX_LEVEL and prog["xp"] >= xp_to_next(prog["level"]): while prog["level"] < MAX_LEVEL and prog["xp"] >= xp_to_next(prog["level"]):
prog["xp"] -= xp_to_next(prog["level"]) prog["xp"] -= xp_to_next(prog["level"])
@@ -338,6 +348,7 @@ def available_fertilizer() -> int:
FERTILIZER_CAP = 100 # fertilizer stops accruing once this cap is reached FERTILIZER_CAP = 100 # fertilizer stops accruing once this cap is reached
FERTILIZER_PER_HOUR = 1 # test build: earn 1 fertilizer every real hour FERTILIZER_PER_HOUR = 1 # test build: earn 1 fertilizer every real hour
AUTO_FARMER_COST = 100 # coins (WIP value) AUTO_FARMER_COST = 100 # coins (WIP value)
AUTO_PLANTER_COST = 200 # coins: plants retrieved seeds automatically
def add_fertilizer(amount: int) -> None: def add_fertilizer(amount: int) -> None:
@@ -404,6 +415,37 @@ def buy_auto_farmer() -> str:
return "" return ""
def auto_planter_owned() -> bool:
return bool(load().get("auto_planter", False))
def auto_planter_active() -> bool:
"""Whether the owned auto planter is currently toggled on."""
return bool(load().get("auto_planter_active", False))
def toggle_auto_planter() -> bool:
"""Turn the owned auto planter on/off. Returns the new active state."""
data = load()
data["auto_planter_active"] = not data.get("auto_planter_active", False)
save(data)
return data["auto_planter_active"]
def buy_auto_planter() -> str:
"""Buy the auto planter for AUTO_PLANTER_COST coins. Returns '' or a reason."""
data = load()
if data.get("auto_planter", False):
return "Already owned"
if data.get("coins", 0) < AUTO_PLANTER_COST:
return "Not enough coins"
data["coins"] -= AUTO_PLANTER_COST
data["auto_planter"] = True
data["auto_planter_active"] = True
save(data)
return ""
def buy_rune(tier: int): def buy_rune(tier: int):
"""Buy a random rune of `tier` (1 = common types, 2 = brown) for coins. """Buy a random rune of `tier` (1 = common types, 2 = brown) for coins.
Returns the rune dict on success or a reason string.""" Returns the rune dict on success or a reason string."""
@@ -502,6 +544,30 @@ def roll_pack_rune(data=None):
return add_rune(data=data, save_now=False) return add_rune(data=data, save_now=False)
# --- bosses ---------------------------------------------------------
def bosses_fought() -> set:
"""Boss names the player has defeated at least once."""
return set(load().get("bosses_fought", []))
def mark_boss_fought(name: str) -> None:
data = load()
fought = data.setdefault("bosses_fought", [])
if name not in fought:
fought.append(name)
save(data)
def boss_rune_drop(name: str):
"""Roll a boss's signature rune drop. Much higher chance than normal rune
drops. Returns a rune dict or None."""
from . import bosses
if random.random() >= bosses.BOSS_RUNE_DROP_CHANCE:
return None
color = random.choice(bosses.boss_drop_runes(name))
return add_rune(color)
def rune_slots(name, data=None) -> int: def rune_slots(name, data=None) -> int:
"""Permanent rune slots for `name` (1 + evolutions ever achieved).""" """Permanent rune slots for `name` (1 + evolutions ever achieved)."""
data = data or load() data = data or load()
@@ -544,20 +610,20 @@ def free_runes(data=None) -> list:
def reroll_rune(rune_id: int, sacrificed_ids) -> str: def reroll_rune(rune_id: int, sacrificed_ids) -> str:
"""Reroll `rune_id`'s stat to a higher value (at least 1% better, never """Reroll `rune_id`'s stat to a higher value (at least 1% better, never
above its max roll), sacrificing the given 10 runes. Equipped sacrifices are above its max roll), sacrificing runes.RUNE_SACRIFICE_COST runes. Equipped
removed from their plants. Returns '' on success or a reason string.""" sacrifices are removed from their plants. Returns '' on success or a reason."""
data = load() data = load()
rune_list = data.get("runes", []) rune_list = data.get("runes", [])
target = next((r for r in rune_list if r["id"] == rune_id), None) target = next((r for r in rune_list if r["id"] == rune_id), None)
if not target: if not target:
return "No such rune" return "No such rune"
max_pct = runes.BROWN_PCT_MAX if target["color"] == "brown" else runes.PCT_MAX max_pct = runes.max_pct(target["color"])
current = target.get("pct", 0) current = target.get("pct", 0)
if current >= max_pct: if current >= max_pct:
return "Already max roll" return "Already max roll"
sac_set = set(sacrificed_ids) sac_set = set(sacrificed_ids)
if len(sac_set) != 10: if len(sac_set) != runes.RUNE_SACRIFICE_COST:
return "Pick exactly 10 runes to sacrifice" return f"Pick exactly {runes.RUNE_SACRIFICE_COST} runes to sacrifice"
if rune_id in sac_set: if rune_id in sac_set:
return "Cannot sacrifice the rune being upgraded" return "Cannot sacrifice the rune being upgraded"
by_id = {r["id"]: r for r in rune_list} by_id = {r["id"]: r for r in rune_list}
@@ -629,20 +695,6 @@ def add_tower_packs(count: int) -> None:
save(data) save(data)
def convert_water_to_pack(pack_type: str) -> bool:
"""Spend 5 water from the pool to gain one pack of the chosen type."""
data = load()
if pack_type not in REWARD_PACK_TYPES:
return False
if data.get("water", 0) < 5:
return False
data["water"] -= 5
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
packs[pack_type] = packs.get(pack_type, 0) + 1
save(data)
return True
def evolve(name) -> bool: def evolve(name) -> bool:
"""Evolve at the milestone level by consuming duplicates.""" """Evolve at the milestone level by consuming duplicates."""
data = load() data = load()
@@ -750,7 +802,7 @@ def add_lesser_pack() -> list:
def add_pack(pack_type: str, count: int = 1) -> list: def add_pack(pack_type: str, count: int = 1) -> list:
"""Grant `count` packs of a specific type. Returns the granted type list.""" """Add `count` seed packs of `pack_type`."""
data = load() data = load()
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES}) packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
packs[pack_type] = packs.get(pack_type, 0) + count packs[pack_type] = packs.get(pack_type, 0) + count
@@ -758,6 +810,33 @@ def add_pack(pack_type: str, count: int = 1) -> list:
return [pack_type] * count return [pack_type] * count
# Pack shop (seed packs page): coins buy packs. Tower packs can't be bought.
PACK_COSTS = {
"lesser": 50,
"general": 100,
"radiant": 100,
"nocturnal": 100,
"ice": 100,
"garden": 100,
"wildflower": 100,
}
def buy_pack(pack_type: str) -> str:
"""Buy a pack of `pack_type` with coins. Returns '' on success or a reason."""
cost = PACK_COSTS.get(pack_type)
if cost is None:
return "That pack can't be bought"
data = load()
if data.get("coins", 0) < cost:
return "Not enough coins"
data["coins"] -= cost
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
packs[pack_type] = packs.get(pack_type, 0) + 1
save(data)
return ""
def battle_wins() -> int: def battle_wins() -> int:
"""Total number of battle wins the player has recorded.""" """Total number of battle wins the player has recorded."""
return load().get("wins", 0) return load().get("wins", 0)
@@ -1189,6 +1268,23 @@ def auto_farm() -> str:
return "" return ""
def auto_plant() -> str:
"""If the player owns an auto planter that's toggled on, automatically plant
a retrieved seed into the first empty plot (if any seeds are available).
Returns '' on success or when idle, else a reason."""
data = load()
if not data.get("auto_planter", False):
return "No auto planter"
if not data.get("auto_planter_active", False):
return "Auto planter off"
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
if not soil.get("seeds", []):
return "" # nothing to plant
if len(soil.get("plots", [])) >= unlocked_plots(data, soil):
return "" # no empty plot
return plant_seed(0)
def harvest_plot(index: int) -> str: def harvest_plot(index: int) -> str:
"""Harvest a grown plot, gaining a copy (duplicate) of the plant.""" """Harvest a grown plot, gaining a copy (duplicate) of the plant."""
data = load() data = load()
+1 -1
View File
@@ -281,7 +281,7 @@ POOL: Dict[str, dict] = {
]), ]),
"Butterfly": dict(role=ROLE_SUPPORT, type=TYPE_GARDEN, stats=(30, 40, 26, 40), "Butterfly": dict(role=ROLE_SUPPORT, type=TYPE_GARDEN, stats=(30, 40, 26, 40),
skills=[ skills=[
_sk("Nectar", power=20, kind="heal", target=TARGET_ALLY, cd=2, _sk("Nectar", power=15, kind="heal", target=TARGET_ALLY, cd=2,
desc="Heal an ally (scales with Attack)"), desc="Heal an ally (scales with Attack)"),
_sk("Cocoon", power=25, kind="shield", target=TARGET_ALLY, cd=2, _sk("Cocoon", power=25, kind="shield", target=TARGET_ALLY, cd=2,
desc="Shield an ally (scales with Attack)"), desc="Shield an ally (scales with Attack)"),
+3 -3
View File
@@ -5,9 +5,9 @@ from typing import Callable, Iterable, List, Optional
from . import runes from . import runes
from .skills import ( from .skills import (
S_ATTACK_DOWN, S_ATTACK_UP, S_DEFENSE_DOWN, S_DEFENSE_UP, S_ATTACK_DOWN, S_ATTACK_UP, S_CRIT_DOWN, S_DEFENSE_DOWN, S_DEFENSE_UP,
S_HEAL_BOOST, S_HOT, S_PREVENT_FIRST_DAMAGE, S_SHIELD, S_SNOWFLAKES, S_SPEED_DOWN, S_HEAL_BOOST, S_HOT, S_PREVENT_FIRST_DAMAGE, S_SHIELD, S_SNOWFLAKES, S_SPEED_DOWN,
S_SPEED_UP, S_VULNERABLE, S_SPEED_UP, S_VULNERABLE, SHIELD_DURATION,
) )
@@ -70,7 +70,7 @@ def _crit(pct):
def _shield(pct): def _shield(pct):
def apply(battle): def apply(battle):
for u in _players(battle): for u in _players(battle):
u.status[S_SHIELD] = u.status.get(S_SHIELD, 0) + int(u.max_hp * pct) u.status.setdefault(S_SHIELD, []).append((int(u.max_hp * pct), SHIELD_DURATION))
return apply return apply
+35 -10
View File
@@ -20,6 +20,12 @@ FLOOR_RARE_PLANTS = ["Marigold", "Daffodil", "Peony", "Primrose",
# Low-rarity tank plants used as enemy tanks on later floors. # Low-rarity tank plants used as enemy tanks on later floors.
FLOOR_TANK_PLANTS = ["Marigold", "Daffodil", "Venus Flytrap"] FLOOR_TANK_PLANTS = ["Marigold", "Daffodil", "Venus Flytrap"]
# Floor enemies scale up faster and hit harder / are beefier than player units
# of the same level (a flat boost on top of the leveled stats).
FLOOR_ENEMY_LEVEL_STEP = 4 # +1 enemy level every 4 floors (was every 5)
FLOOR_ENEMY_ATK_MULT = 1.20 # +20% attack (more damage)
FLOOR_ENEMY_HP_MULT = 1.25 # +25% max health (tankier)
def floor_enemy_count(floor: int) -> int: def floor_enemy_count(floor: int) -> int:
"""Number of enemies on a battle floor: 1 on floors 1-3, 2 on 4-9, 3 on """Number of enemies on a battle floor: 1 on floors 1-3, 2 on 4-9, 3 on
@@ -39,7 +45,11 @@ def floor_enemy_names(floor: int, player_type: str) -> List[str]:
4 from floor 16. Teams mix pinwheels, butterflies and toadstools, and 4 from floor 16. Teams mix pinwheels, butterflies and toadstools, and
duplicates are allowed (e.g. two pinwheels). Tank plants join from floor duplicates are allowed (e.g. two pinwheels). Tank plants join from floor
40 and low-rarity plants from floor 45, replacing critter slots so a team 40 and low-rarity plants from floor 45, replacing critter slots so a team
never exceeds 4 enemies.""" never exceeds 4 enemies. Boss floors field the boss alone."""
from . import bosses
boss = bosses.boss_for_floor(floor)
if boss:
return [boss]
count = floor_enemy_count(floor) count = floor_enemy_count(floor)
# Lead with an attacker; the rest cycle through the trio so each floor gets # Lead with an attacker; the rest cycle through the trio so each floor gets
# a deterministic mix of roles (with repeats) built from the critters. # a deterministic mix of roles (with repeats) built from the critters.
@@ -58,24 +68,29 @@ def floor_enemy_names(floor: int, player_type: str) -> List[str]:
def roll_enemy_types(names: List[str], player_type: str, floor: int) -> List[str]: def roll_enemy_types(names: List[str], player_type: str, floor: int) -> List[str]:
"""Types for a floor's enemies (parallel to `names`). The floor 1-3 pinwheel is """Types for a floor's enemies (parallel to `names`). The floor 1-3 pinwheel is
always the type the player is strong against; other critters roll a random always the type the player is strong against; other critters get a fixed type
type; plants keep their own fixed type.""" derived from the floor (no rerolling — the same floor always fields the same
import random team, and duplicate types within a team are allowed); plants keep their own
fixed type."""
from . import bosses
types = [] types = []
for i, n in enumerate(names): for i, n in enumerate(names):
if n in VARIABLE_TYPE_UNITS: if bosses.is_boss(n):
types.append(bosses.boss_type(n))
elif n in VARIABLE_TYPE_UNITS:
if floor <= 3: if floor <= 3:
types.append(TYPE_BEATS.get(player_type, "garden")) types.append(TYPE_BEATS.get(player_type, "garden"))
else: else:
types.append(random.choice(ALL_TYPES)) types.append(ALL_TYPES[(floor * 7 + i * i) % len(ALL_TYPES)])
else: else:
types.append(POOL[n]["type"]) types.append(POOL[n]["type"])
return types return types
def floor_enemy_level(floor: int) -> int: def floor_enemy_level(floor: int) -> int:
"""Enemy level for a floor (ramps toward ~20 at floor 100).""" """Enemy level for a floor (ramps one level every FLOOR_ENEMY_LEVEL_STEP
return 1 + (floor - 1) // 5 floors, steeper than the old every-5 curve)."""
return 1 + (floor - 1) // FLOOR_ENEMY_LEVEL_STEP
def make_floor_enemies(floor: int, player_type: str) -> List[Unit]: def make_floor_enemies(floor: int, player_type: str) -> List[Unit]:
@@ -88,11 +103,21 @@ def make_floor_enemies(floor: int, player_type: str) -> List[Unit]:
def build_floor_enemies(names: List[str], types: List[str], floor: int) -> List[Unit]: def build_floor_enemies(names: List[str], types: List[str], floor: int) -> List[Unit]:
"""Build enemy units from an already-rolled name/type list (so the picker and """Build enemy units from an already-rolled name/type list (so the picker and
the actual battle always show the same enemies).""" the actual battle always show the same enemies). Floor enemies also get a
flat attack/health boost so they out-stat player units of the same level.
Boss floors build the boss instead."""
from . import bosses
if names and bosses.is_boss(names[0]):
return [bosses.make_boss(names[0], floor)]
level = floor_enemy_level(floor) level = floor_enemy_level(floor)
stage = max(1, min(5, 1 + (floor - 1) // 20)) stage = max(1, min(5, 1 + (floor - 1) // 20))
return [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t) units = [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t)
for n, t in zip(names, types)] for n, t in zip(names, types)]
for u in units:
u.attack = int(u.attack * FLOOR_ENEMY_ATK_MULT)
u.max_hp = int(u.max_hp * FLOOR_ENEMY_HP_MULT)
u.hp = u.max_hp
return units
def difficulty_pool(difficulty: int) -> List[str]: def difficulty_pool(difficulty: int) -> List[str]:
+38 -13
View File
@@ -3,18 +3,19 @@ from __future__ import annotations
import random import random
# Rune kinds (gems). Each rune is an item equipped into a plant's slots. # Rune kinds (gems). Each rune is an item equipped into a plant's slots.
RUNE_COLORS = ("sapphire", "ruby", "amethyst", "jade", "brown") RUNE_COLORS = ("sapphire", "ruby", "amethyst", "jade", "brown", "pearl")
RUNE_NAMES = { RUNE_NAMES = {
"sapphire": "Sapphire Rune", "sapphire": "Sapphire Rune",
"ruby": "Ruby Rune", "ruby": "Ruby Rune",
"amethyst": "Amethyst Rune", "amethyst": "Amethyst Rune",
"jade": "Jade Rune", "jade": "Jade Rune",
"brown": "Brown Rune", "brown": "Brown Rune",
"pearl": "Pearl Rune",
} }
# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown is Tier 2. # Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown & pearl Tier 2.
TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade") TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade")
TIER2_COLORS = ("brown",) TIER2_COLORS = ("brown", "pearl")
RUNE_TIERS = {c: 1 for c in TIER1_COLORS} RUNE_TIERS = {c: 1 for c in TIER1_COLORS}
RUNE_TIERS.update({c: 2 for c in TIER2_COLORS}) RUNE_TIERS.update({c: 2 for c in TIER2_COLORS})
@@ -25,6 +26,7 @@ RUNE_ICON_FILES = {
"jade": "jade_rune", "jade": "jade_rune",
"amethyst": "amethyst_rune", "amethyst": "amethyst_rune",
"brown": "rune_brown", "brown": "rune_brown",
"pearl": "pearl_rune",
} }
# Old color names kept in earlier saves, mapped to the current gem names. # Old color names kept in earlier saves, mapped to the current gem names.
@@ -43,31 +45,39 @@ def canonical_color(color: str) -> str:
# Primitive effects: each rune rolls its own stat and stacks additively with # Primitive effects: each rune rolls its own stat and stacks additively with
# other equipped runes of the same effect. Brown is rarer and rolls lower, but # other equipped runes of the same effect. Brown is rarer and rolls lower, but
# turns part of the damage this plant deals into a Wilt. # turns part of the damage this plant deals into a Wilt. Pearl rolls a chunk of
# critical hit chance.
RUNE_EFFECTS = { RUNE_EFFECTS = {
"ruby": "damage", # +damage dealt "ruby": "damage", # +damage dealt
"sapphire": "shield", # +shielding "sapphire": "shield", # +shielding
"jade": "healing", # +healing received "jade": "healing", # +healing received
"amethyst": "damage_taken", # -damage taken "amethyst": "damage_taken", # -damage taken
"brown": "wilt", # damage dealt also Wils the target "brown": "wilt", # damage dealt also Wils the target
"pearl": "crit", # +critical hit chance
} }
PCT_MIN = 25 PCT_MIN = 25
PCT_MAX = 35 PCT_MAX = 35
BROWN_PCT_MIN = 5 BROWN_PCT_MIN = 5
BROWN_PCT_MAX = 15 BROWN_PCT_MAX = 15
PEARL_PCT_MIN = 20
PEARL_PCT_MAX = 40
# a plant can't stack more than this many runes of the same type # a plant can't stack more than this many runes of the same type
MAX_RUNES_PER_TYPE = 2 MAX_RUNES_PER_TYPE = 2
# runes sacrificed to reroll one rune to a higher stat
RUNE_SACRIFICE_COST = 3
# coin costs to buy a random rune of a tier in the runes shop # coin costs to buy a random rune of a tier in the runes shop
T1_RUNE_COST = 100 T1_RUNE_COST = 100
T2_RUNE_COST = 150 T2_RUNE_COST = 150
# brown drops less often than the other runes (relative weights) # brown & pearl drop less often than the other runes (relative weights)
RUNE_WEIGHTS = {"sapphire": 1.0, "ruby": 1.0, "amethyst": 1.0, "jade": 1.0, "brown": 0.75} RUNE_WEIGHTS = {"sapphire": 1.0, "ruby": 1.0, "amethyst": 1.0, "jade": 1.0,
"brown": 0.75, "pearl": 0.75}
_EFFECT_TEXTS = { _EFFECT_TEXTS = {
"ruby": "+{pct}% damage dealt", "ruby": "+{pct}% damage dealt",
"sapphire": "+{pct}% shielding", "sapphire": "+{pct}% shielding",
"jade": "+{pct}% healing", "jade": "+{pct}% healing",
"amethyst": "-{pct}% damage taken", "amethyst": "-{pct}% damage taken",
"brown": "deals {pct}% of damage as Wilt", "brown": "deals {pct}% of damage as Wilt",
"pearl": "+{pct}% critical chance",
} }
RUNE_SUMMARIES = { RUNE_SUMMARIES = {
"ruby": "Increases damage dealt", "ruby": "Increases damage dealt",
@@ -75,6 +85,7 @@ RUNE_SUMMARIES = {
"jade": "Increases healing received", "jade": "Increases healing received",
"amethyst": "Reduces damage taken", "amethyst": "Reduces damage taken",
"brown": "Deals a portion of damage as Wilt", "brown": "Deals a portion of damage as Wilt",
"pearl": "Increases critical hit chance",
} }
MAX_RUNE_SLOTS = 3 MAX_RUNE_SLOTS = 3
@@ -91,12 +102,23 @@ def random_color() -> str:
def roll_pct(color: str = None) -> int: def roll_pct(color: str = None) -> int:
"""Roll a rune's stat percentage. Brown rolls 5-15; the rest 25-35.""" """Roll a rune's stat percentage. Brown rolls 5-15, pearl 20-40, the rest 25-35."""
if color == "brown": if color == "brown":
return random.randint(BROWN_PCT_MIN, BROWN_PCT_MAX) return random.randint(BROWN_PCT_MIN, BROWN_PCT_MAX)
if color == "pearl":
return random.randint(PEARL_PCT_MIN, PEARL_PCT_MAX)
return random.randint(PCT_MIN, PCT_MAX) return random.randint(PCT_MIN, PCT_MAX)
def max_pct(color: str) -> int:
"""The highest stat a rune of `color` can roll (brown/pearl have their own caps)."""
if color == "brown":
return BROWN_PCT_MAX
if color == "pearl":
return PEARL_PCT_MAX
return PCT_MAX
def name(color: str) -> str: def name(color: str) -> str:
return RUNE_NAMES.get(color, "Rune") return RUNE_NAMES.get(color, "Rune")
@@ -106,11 +128,11 @@ def tier(color: str) -> int:
def roll_ratio(rune) -> float: def roll_ratio(rune) -> float:
"""How close a rune's roll is to its own maximum (0..1). Brown's 5-15 range """How close a rune's roll is to its own maximum (0..1). Each color is
is compared to its 15 cap, so a maxed brown ranks equally with a maxed compared to its own cap, so a maxed brown or pearl ranks equally with a
yellow even though 15 < 35.""" maxed ruby even though 15 < 35."""
max_pct = BROWN_PCT_MAX if rune.get("color") == "brown" else PCT_MAX cap = max_pct(rune.get("color"))
return rune.get("pct", 0) / max_pct return rune.get("pct", 0) / cap
def effect_text(color: str, pct: int) -> str: def effect_text(color: str, pct: int) -> str:
@@ -124,6 +146,7 @@ _CARD_TEXTS = {
"jade": "+{pct}% heal", "jade": "+{pct}% heal",
"amethyst": "-{pct}% taken", "amethyst": "-{pct}% taken",
"brown": "Wilt {pct}%", "brown": "Wilt {pct}%",
"pearl": "+{pct}% crit",
} }
@@ -149,9 +172,11 @@ def summary(color: str) -> str:
def roll_range(color: str) -> str: def roll_range(color: str) -> str:
"""The stat range a rune of `color` rolls between (brown is lower).""" """The stat range a rune of `color` rolls between (brown/pearl are lower)."""
if color == "brown": if color == "brown":
return f"{BROWN_PCT_MIN}–{BROWN_PCT_MAX}%" return f"{BROWN_PCT_MIN}–{BROWN_PCT_MAX}%"
if color == "pearl":
return f"{PEARL_PCT_MIN}–{PEARL_PCT_MAX}%"
return f"{PCT_MIN}–{PCT_MAX}%" return f"{PCT_MIN}–{PCT_MAX}%"
+1
View File
@@ -110,6 +110,7 @@ S_WILT = "wilt"
S_DOT = "dot" S_DOT = "dot"
S_HOT = "hot" S_HOT = "hot"
S_SHIELD = "shield" S_SHIELD = "shield"
SHIELD_DURATION = 3 # turns a shield lasts before decaying (per application, doesn't refresh)
S_ICICLES = "icicles" S_ICICLES = "icicles"
S_SNOWFLAKES = "snowflakes" S_SNOWFLAKES = "snowflakes"
S_ZINNIA_WEAKEN = "zinnia_weaken" S_ZINNIA_WEAKEN = "zinnia_weaken"
View File
+15 -4
View File
@@ -1,7 +1,7 @@
from collections import Counter from collections import Counter
from assets import icon from assets import icon
from engine import collection, runes from engine import bosses, collection, runes
from engine.battle import Battle from engine.battle import Battle
from engine.relics import RELIC_BY_KEY, grant_rune_rewards, roll_relics, rune_reward_count from engine.relics import RELIC_BY_KEY, grant_rune_rewards, roll_relics, rune_reward_count
from engine.roster import make_enemy_team, pick_team, player_team_level, player_team_stage from engine.roster import make_enemy_team, pick_team, player_team_level, player_team_stage
@@ -24,6 +24,7 @@ def _pack_reward_items(pack_types):
}) })
return items return items
#test_ignore
def _rune_reward_item(rune): def _rune_reward_item(rune):
"""Reward entry for a rune drop (label + rune sprite path).""" """Reward entry for a rune drop (label + rune sprite path)."""
@@ -51,7 +52,8 @@ def _totals_txt(total_water, total_packs, run_relics):
def _battle_win_reward(floor): def _battle_win_reward(floor):
"""Grant and describe a floor-battle win: a lesser pack, or on every 5th win """Grant and describe a floor-battle win: a lesser pack, or on every 5th win
(5, 10, 15, ...) a better general/type-boosted pack instead. Advances the (5, 10, 15, ...) a better general/type-boosted pack instead. Advances the
floor. Runes can drop on any win regardless.""" floor. Runes can drop on any win regardless. Boss floors additionally grant
a boss-specific rune drop at a much higher chance and mark the boss fought."""
collection.advance_floor() collection.advance_floor()
wins = collection.record_win() wins = collection.record_win()
if wins % 5 == 0: if wins % 5 == 0:
@@ -61,6 +63,12 @@ def _battle_win_reward(floor):
rune = collection.roll_battle_rune() rune = collection.roll_battle_rune()
if rune: if rune:
reward.append(_rune_reward_item(rune)) reward.append(_rune_reward_item(rune))
boss = bosses.boss_for_floor(floor)
if boss:
collection.mark_boss_fought(boss)
bdrop = collection.boss_rune_drop(boss)
if bdrop:
reward.append(_rune_reward_item(bdrop))
return reward return reward
@@ -255,7 +263,10 @@ def main():
floor = collection.floor_progress() floor = collection.floor_progress()
enemy_names = floor_enemy_names(floor, player_type) enemy_names = floor_enemy_names(floor, player_type)
enemy_types = roll_enemy_types(enemy_names, player_type, floor) enemy_types = roll_enemy_types(enemy_names, player_type, floor)
enemy_levels = [floor_enemy_level(floor)] * len(enemy_names) enemy_levels = [
bosses.boss_floor_level(floor) if bosses.is_boss(n) else floor_enemy_level(floor)
for n in enemy_names
]
from ui.picker import TeamSelect from ui.picker import TeamSelect
ts = TeamSelect(initial_enemies=enemy_names, difficulty=floor, ts = TeamSelect(initial_enemies=enemy_names, difficulty=floor,
enemy_types=enemy_types, no_roll=True, enemy_types=enemy_types, no_roll=True,
@@ -269,7 +280,7 @@ def main():
allies, enemies = result allies, enemies = result
collection.mark_owned(allies) collection.mark_owned(allies)
collection.mark_found(enemies) collection.mark_found([n for n in enemies if not bosses.is_boss(n)])
players = pick_team(allies, TEAM_PLAYER) players = pick_team(allies, TEAM_PLAYER)
enemy_units = build_floor_enemies(enemy_names, enemy_types, floor) enemy_units = build_floor_enemies(enemy_names, enemy_types, floor)
battle = Battle(players, enemy_units) battle = Battle(players, enemy_units)
+105 -1
View File
@@ -155,6 +155,28 @@ class ShieldWiltDotTest(unittest.TestCase):
self.assertEqual(t.hp, 100) self.assertEqual(t.hp, 100)
self.assertNotIn(S_SHIELD, t.status) self.assertNotIn(S_SHIELD, t.status)
def test_heal_random_ally_prefers_injured_ally(self):
flutter = Skill("Flutter", power=40, target=TARGET_ENEMY, mechanic="heal_random_ally")
daisy = make_unit("Daisy", TEAM_PLAYER, "radiant", atk=50, skills=[flutter])
full = make_unit("Full", TEAM_PLAYER, "radiant", hp=100)
hurt = make_unit("Hurt", TEAM_PLAYER, "radiant", hp=100)
hurt.hp = 50
enemy = make_unit("E", TEAM_ENEMY, "garden", hp=500)
b = Battle([daisy, full, hurt], [enemy])
b.start()
b.use_skill(daisy, flutter, enemy)
self.assertEqual(hurt.hp, 90) # the injured ally got healed
self.assertEqual(full.hp, 100) # full-HP ally untouched
def test_heal_random_ally_noop_when_all_full(self):
flutter = Skill("Flutter", power=40, target=TARGET_ENEMY, mechanic="heal_random_ally")
daisy = make_unit("Daisy", TEAM_PLAYER, "radiant", atk=50, skills=[flutter])
enemy = make_unit("E", TEAM_ENEMY, "garden", hp=500)
b = Battle([daisy], [enemy])
b.start()
b.use_skill(daisy, flutter, enemy)
self.assertFalse(any(isinstance(l, dict) and "recovers" in l["text"] for l in b.log)) # nothing healed
def test_wilt_ticks_over_three_turns(self): def test_wilt_ticks_over_three_turns(self):
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[Skill("Hit", 10, target=TARGET_ENEMY)]) a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[Skill("Hit", 10, target=TARGET_ENEMY)])
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5) t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5)
@@ -194,6 +216,45 @@ class ShieldWiltDotTest(unittest.TestCase):
self.assertEqual(t.hp, 410) self.assertEqual(t.hp, 410)
self.assertNotIn(S_DOT, t.status) self.assertNotIn(S_DOT, t.status)
def test_shield_decays_after_3_turns(self):
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
b.start()
b._shield(t, 30)
self.assertEqual(b._shield_total(t), 30)
b._end_turn_status(t)
self.assertEqual(b._shield_total(t), 30) # 3 -> 2
b._end_turn_status(t)
self.assertEqual(b._shield_total(t), 30) # 2 -> 1
b._end_turn_status(t)
self.assertEqual(b._shield_total(t), 0) # gone after the 3rd turn
self.assertNotIn(S_SHIELD, t.status)
def test_stacked_shields_decay_independently(self):
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
b.start()
b._shield(t, 30)
b._end_turn_status(t) # first: 3 -> 2
b._shield(t, 40) # stack a new one (doesn't refresh the old)
b._end_turn_status(t) # first: 2 -> 1, second: 3 -> 2
b._end_turn_status(t) # first expires, second: 2 -> 1
self.assertEqual(b._shield_total(t), 40) # only the newer 40 remains
b._end_turn_status(t) # second expires too
self.assertEqual(b._shield_total(t), 0)
def test_shield_absorption_consumes_applications_in_order(self):
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
b.start()
b._shield(t, 30)
b._shield(t, 40)
b._absorb(None, t, 50) # 30 from the old shield, 20 from the new one
self.assertEqual(b._shield_total(t), 20)
b._absorb(None, t, 20) # finishes the new shield
self.assertEqual(b._shield_total(t), 0)
self.assertEqual(t.hp, 500)
class MechanicsTest(unittest.TestCase): class MechanicsTest(unittest.TestCase):
def test_sleep_skips_until_two_hits(self): def test_sleep_skips_until_two_hits(self):
@@ -487,7 +548,7 @@ class MechanicsTest(unittest.TestCase):
enemy.status["wilt"] = (30, 3) enemy.status["wilt"] = (30, 3)
enemy.status["dot"] = [(10, 2)] enemy.status["dot"] = [(10, 2)]
enemy.status["vulnerable"] = 1 enemy.status["vulnerable"] = 1
zinnia.status["shield"] = 10 # one self effect only zinnia.status["shield"] = [(10, 3)] # one self effect only
self.assertIs(b.best_skill(zinnia), glitter) self.assertIs(b.best_skill(zinnia), glitter)
def test_zinnia_targets_most_effect_rich_enemy(self): def test_zinnia_targets_most_effect_rich_enemy(self):
@@ -743,5 +804,48 @@ class TowerInjectionTest(unittest.TestCase):
self.assertNotEqual(base.name, inj["skill"]["name"]) self.assertNotEqual(base.name, inj["skill"]["name"])
class TurnActionLimitTest(unittest.TestCase):
"""A unit must act only once per turn (spam-clicking must not multi-cast)."""
def _battle(self, skills):
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=skills)
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=1000)
b = Battle([a], [t])
b.start()
return b, a, t
def test_unit_cannot_use_two_skills_in_one_turn(self):
hit1 = Skill("Hit1", power=10, target=TARGET_ENEMY)
hit2 = Skill("Hit2", power=10, target=TARGET_ENEMY)
b, a, t = self._battle([hit1, hit2])
self.assertTrue(b.use_skill(a, hit1, t))
self.assertFalse(b.use_skill(a, hit2, t)) # already acted
self.assertTrue(a.acted)
def test_same_skill_cannot_be_spammed(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY, cooldown=0)
b, a, t = self._battle([hit])
self.assertTrue(b.use_skill(a, hit, t))
self.assertFalse(b.use_skill(a, hit, t)) # acted flag blocks the re-cast
def test_extra_action_skill_still_usable_after_acting(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
extra = Skill("Extra", power=5, target=TARGET_ENEMY, extra_action=True)
b, a, t = self._battle([hit, extra])
self.assertTrue(b.use_skill(a, hit, t))
self.assertTrue(b.use_skill(a, extra, t)) # extra action allowed after acting
self.assertTrue(a.acted)
def test_no_extra_actions_after_act_limit_exhausted(self):
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
extra = Skill("Extra", power=5, target=TARGET_ENEMY, extra_action=True)
extra2 = Skill("Extra2", power=5, target=TARGET_ENEMY, extra_action=True)
b, a, t = self._battle([hit, extra, extra2])
self.assertTrue(b.use_skill(a, hit, t))
self.assertTrue(b.use_skill(a, extra, t))
self.assertTrue(b.use_skill(a, extra2, t))
self.assertFalse(b.use_skill(a, hit, t)) # acted + cooldown: still blocked
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+139
View File
@@ -0,0 +1,139 @@
import atexit
import os
import pathlib
import tempfile
import unittest
# Point the engine at a fresh empty save to keep collection-dependent tests hermetic.
import engine.collection as collection
from engine import bosses
from engine.battle import Battle
from engine.plants import make_leveled
from engine.unit import TEAM_ENEMY, TEAM_PLAYER
_fd, _tmp_path = tempfile.mkstemp(suffix=".json")
os.close(_fd)
_TMP = pathlib.Path(_tmp_path)
_ORIG_DATA_FILE = collection._DATA_FILE
collection._DATA_FILE = _TMP
@atexit.register
def _restore_collection():
collection._DATA_FILE = _ORIG_DATA_FILE
if _TMP.exists():
_TMP.unlink()
class BossSchedulingTest(unittest.TestCase):
def test_boss_appears_on_multiples_of_20(self):
self.assertEqual(bosses.boss_for_floor(20), "Stones")
self.assertEqual(bosses.boss_for_floor(40), "Stones")
self.assertEqual(bosses.boss_for_floor(100), "Stones")
self.assertIsNone(bosses.boss_for_floor(19))
self.assertIsNone(bosses.boss_for_floor(1))
def test_boss_stats_are_extreme(self):
u = bosses.make_boss("Stones", 20)
# very low speed, medium defense, high health, medium attack
self.assertLessEqual(u.speed, 40)
self.assertGreaterEqual(u.max_hp, 900)
self.assertGreaterEqual(u.defense, 80)
self.assertTrue(60 <= u.attack <= 200)
class BossMechanicTest(unittest.TestCase):
def _battle(self):
a = make_leveled("Rose", TEAM_PLAYER, 20, 1)
a.max_hp = 5000
a.hp = 5000
u = bosses.make_boss("Stones", 20)
b = Battle([a], [u])
b.start()
return b, a, u
def test_landslide_blocked_until_three_stacks(self):
b, a, u = self._battle()
b.current = u
pebble, landslide = u.skills
u.acted = False
self.assertFalse(b.use_skill(u, landslide, a)) # 0 stacks: blocked
self.assertEqual(b.valid_skill_targets(u, landslide), [])
for _ in range(3):
u.acted = False
self.assertTrue(b.use_skill(u, pebble, a))
self.assertEqual(u.status["boss_stacks"], 3)
u.acted = False
self.assertTrue(b.use_skill(u, landslide, a)) # now available
self.assertEqual(u.status["boss_stacks"], 0) # released
def test_boss_stacks_cap_at_three(self):
b, a, u = self._battle()
b.current = u
pebble, _ = u.skills
for _ in range(6):
u.acted = False
self.assertTrue(b.use_skill(u, pebble, a))
self.assertEqual(u.status["boss_stacks"], 3) # never exceeds 3
def test_boss_ai_pebbles_then_landslides_then_repeats(self):
b, a, u = self._battle()
moves = []
guard = 0
while len(moves) < 7 and guard < 120 and b.winner is None:
guard += 1
if b.current is u and not u.acted:
before = u.status.get("boss_stacks", 0)
used = b.enemy_ai(u)
after = u.status.get("boss_stacks", 0)
if used:
moves.append("Pebble" if after > before else
("Landslide" if before >= 3 and after == 0 else "?"))
b.next_turn()
self.assertEqual(moves[:4], ["Pebble", "Pebble", "Pebble", "Landslide"])
self.assertEqual(moves[4:], ["Pebble", "Pebble", "Pebble"])
def test_landslide_hits_harder_than_pebble(self):
b, a, u = self._battle()
b.current = u
pebble, landslide = u.skills
for _ in range(3):
u.acted = False
b.use_skill(u, pebble, a)
u.acted = False
pebble_dmg = b._hit_damage.get(id(a), 0)
a.hp = a.max_hp # reset before landslide
u.status["boss_stacks"] = 3
b.use_skill(u, landslide, a)
landslide_dmg = b._hit_damage.get(id(a), 0)
self.assertGreater(landslide_dmg, pebble_dmg * 2)
class BossRewardTest(unittest.TestCase):
def setUp(self):
data = collection.load()
data["owned"] = ["Rose"]
data["bosses_fought"] = []
data["runes"] = []
collection.save(data)
def test_mark_boss_fought(self):
self.assertEqual(collection.bosses_fought(), set())
collection.mark_boss_fought("Stones")
self.assertEqual(collection.bosses_fought(), {"Stones"})
def test_boss_rune_drop_is_high_chance_and_select(self):
colors = set()
total = 0
for _ in range(300):
r = collection.boss_rune_drop("Stones")
if r:
total += 1
self.assertIn(r["color"], ("ruby", "amethyst"))
colors.add(r["color"])
self.assertGreaterEqual(total, 150) # ~80% chance
self.assertEqual(colors, {"ruby", "amethyst"})
if __name__ == "__main__":
unittest.main()
+4 -2
View File
@@ -103,7 +103,8 @@ class RelicApplyTest(unittest.TestCase):
def test_shield_relic(self): def test_shield_relic(self):
b = _battle("iron_bark") b = _battle("iron_bark")
for u in b.players: for u in b.players:
self.assertGreaterEqual(u.status.get(S_SHIELD, 0), int(u.max_hp * 0.09)) total = sum(amount for amount, _ in u.status.get(S_SHIELD, []))
self.assertGreaterEqual(total, int(u.max_hp * 0.09))
def test_pct_hp_relic(self): def test_pct_hp_relic(self):
base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1) base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1)
@@ -156,7 +157,8 @@ class RelicApplyTest(unittest.TestCase):
def test_multiple_relics_stack(self): def test_multiple_relics_stack(self):
b = _battle("iron_bark", "morning_dew", "chilling_gust") b = _battle("iron_bark", "morning_dew", "chilling_gust")
for u in b.players: for u in b.players:
self.assertGreaterEqual(u.status.get(S_SHIELD, 0), 1) total = sum(amount for amount, _ in u.status.get(S_SHIELD, []))
self.assertGreaterEqual(total, 1)
self.assertEqual(u.status[S_ATTACK_UP], (10, 1)) self.assertEqual(u.status[S_ATTACK_UP], (10, 1))
for u in b.enemies: for u in b.enemies:
self.assertEqual(u.status["speed_down"], (8, 1)) self.assertEqual(u.status["speed_down"], (8, 1))
+44 -15
View File
@@ -131,9 +131,9 @@ class RuneTest(unittest.TestCase):
self.assertIn(r2["id"], free) self.assertIn(r2["id"], free)
self.assertNotIn(r1["id"], free) self.assertNotIn(r1["id"], free)
def _ten_runes(self, data): def _sac_runes(self, data):
ids = [] ids = []
for _ in range(10): for _ in range(runes.RUNE_SACRIFICE_COST):
ids.append(collection.add_rune("sapphire", data=data, save_now=False)["id"]) ids.append(collection.add_rune("sapphire", data=data, save_now=False)["id"])
return ids return ids
@@ -141,20 +141,20 @@ class RuneTest(unittest.TestCase):
r = collection.add_rune("ruby") r = collection.add_rune("ruby")
data = collection.load() data = collection.load()
data["runes"][0]["pct"] = 25 data["runes"][0]["pct"] = 25
sac = self._ten_runes(data) sac = self._sac_runes(data)
collection.save(data) collection.save(data)
err = collection.reroll_rune(r["id"], sac) err = collection.reroll_rune(r["id"], sac)
self.assertEqual(err, "") self.assertEqual(err, "")
new_pct = collection.load()["runes"][0]["pct"] new_pct = collection.load()["runes"][0]["pct"]
self.assertGreaterEqual(new_pct, 26) # at least 1% better self.assertGreaterEqual(new_pct, 26) # at least 1% better
self.assertLessEqual(new_pct, 35) self.assertLessEqual(new_pct, 35)
self.assertEqual(len(collection.load()["runes"]), 1) # 10 consumed self.assertEqual(len(collection.load()["runes"]), 1) # cost consumed
def test_reroll_brown_caps_at_15(self): def test_reroll_brown_caps_at_15(self):
r = collection.add_rune("brown") r = collection.add_rune("brown")
data = collection.load() data = collection.load()
data["runes"][0]["pct"] = 5 data["runes"][0]["pct"] = 5
sac = self._ten_runes(data) sac = self._sac_runes(data)
collection.save(data) collection.save(data)
err = collection.reroll_rune(r["id"], sac) err = collection.reroll_rune(r["id"], sac)
self.assertEqual(err, "") self.assertEqual(err, "")
@@ -166,27 +166,27 @@ class RuneTest(unittest.TestCase):
r = collection.add_rune("ruby") r = collection.add_rune("ruby")
data = collection.load() data = collection.load()
data["runes"][0]["pct"] = 35 data["runes"][0]["pct"] = 35
sac = self._ten_runes(data) sac = self._sac_runes(data)
collection.save(data) collection.save(data)
err = collection.reroll_rune(r["id"], sac) err = collection.reroll_rune(r["id"], sac)
self.assertEqual(err, "Already max roll") self.assertEqual(err, "Already max roll")
self.assertEqual(len(collection.load()["runes"]), 11) # nothing consumed self.assertEqual(len(collection.load()["runes"]), 1 + runes.RUNE_SACRIFICE_COST) # nothing consumed
def test_reroll_requires_exactly_ten(self): def test_reroll_requires_exact_sacrifice_count(self):
r = collection.add_rune("ruby") r = collection.add_rune("ruby")
data = collection.load() data = collection.load()
data["runes"][0]["pct"] = 25 data["runes"][0]["pct"] = 25
sac = self._ten_runes(data) sac = self._sac_runes(data)
collection.save(data) collection.save(data)
self.assertEqual(collection.reroll_rune(r["id"], sac[:9]), self.assertEqual(collection.reroll_rune(r["id"], sac[:runes.RUNE_SACRIFICE_COST - 1]),
"Pick exactly 10 runes to sacrifice") f"Pick exactly {runes.RUNE_SACRIFICE_COST} runes to sacrifice")
def test_reroll_rejects_target_in_sacrifices(self): def test_reroll_rejects_target_in_sacrifices(self):
r = collection.add_rune("ruby") r = collection.add_rune("ruby")
data = collection.load() data = collection.load()
data["runes"][0]["pct"] = 25 data["runes"][0]["pct"] = 25
sac = self._ten_runes(data) sac = self._sac_runes(data)
sac[0] = r["id"] # 10 unique runes, but includes the upgrade target sac[0] = r["id"] # unique runes, but includes the upgrade target
collection.save(data) collection.save(data)
self.assertEqual(collection.reroll_rune(r["id"], sac), self.assertEqual(collection.reroll_rune(r["id"], sac),
"Cannot sacrifice the rune being upgraded") "Cannot sacrifice the rune being upgraded")
@@ -196,7 +196,7 @@ class RuneTest(unittest.TestCase):
r2 = collection.add_rune("sapphire") r2 = collection.add_rune("sapphire")
data = collection.load() data = collection.load()
data["runes"][0]["pct"] = 25 data["runes"][0]["pct"] = 25
sac = self._ten_runes(data) sac = self._sac_runes(data)
sac[0] = r2["id"] # sacrifice the equipped rune sac[0] = r2["id"] # sacrifice the equipped rune
collection.save(data) collection.save(data)
self._evolve_to(self.plant, 1, 10, 2) self._evolve_to(self.plant, 1, 10, 2)
@@ -338,6 +338,16 @@ class RuneTest(unittest.TestCase):
self.assertEqual(len(collection.rune_inventory()), before) self.assertEqual(len(collection.rune_inventory()), before)
self.assertEqual(collection.available_coins(), 50) self.assertEqual(collection.available_coins(), 50)
def test_pearl_is_t2_crit_rune(self):
self.assertEqual(runes.tier("pearl"), 2)
self.assertEqual(runes.effect_for("pearl"), "crit")
self.assertEqual(runes.max_pct("pearl"), 40)
self.assertEqual(runes.roll_range("pearl"), "20–40%")
for _ in range(50):
self.assertGreaterEqual(runes.roll_pct("pearl"), 20)
self.assertLessEqual(runes.roll_pct("pearl"), 40)
self.assertEqual(runes.name("pearl"), "Pearl Rune")
class RuneBattleEffectTest(unittest.TestCase): class RuneBattleEffectTest(unittest.TestCase):
def _unit(self, name="A", team=TEAM_PLAYER, ptype="radiant", atk=50, spd=5, def _unit(self, name="A", team=TEAM_PLAYER, ptype="radiant", atk=50, spd=5,
@@ -378,7 +388,7 @@ class RuneBattleEffectTest(unittest.TestCase):
b = Battle([self._unit()], [t]) b = Battle([self._unit()], [t])
b.start() b.start()
b._shield(t, 100) b._shield(t, 100)
self.assertEqual(t.status["shield"], 130) self.assertEqual(sum(a for a, _ in t.status["shield"]), 130)
def test_yellow_rune_boosts_healing(self): def test_yellow_rune_boosts_healing(self):
heal = Skill("Mend", power=40, kind="heal", target="ally") heal = Skill("Mend", power=40, kind="heal", target="ally")
@@ -412,6 +422,25 @@ class RuneBattleEffectTest(unittest.TestCase):
b.use_skill(a, hit, t) b.use_skill(a, hit, t)
self.assertEqual(t.status["wilt"][1], 3) # lasts 3 turns self.assertEqual(t.status["wilt"][1], 3) # lasts 3 turns
def test_pearl_rune_raises_crit_chance(self):
a = self._unit(runes=[("crit", 0.30)])
b = Battle([a], [self._unit("T", TEAM_ENEMY, "garden")])
b.start()
self.assertAlmostEqual(b._crit(a), 0.30)
def test_pearl_runes_stack_additively(self):
a = self._unit(runes=[("crit", 0.20), ("crit", 0.20)])
b = Battle([a], [self._unit("T", TEAM_ENEMY, "garden")])
b.start()
self.assertAlmostEqual(b._crit(a), 0.40)
def test_pearl_rune_adds_on_top_of_base_crit(self):
a = self._unit(runes=[("crit", 0.25)])
a.crit = 0.10
b = Battle([a], [self._unit("T", TEAM_ENEMY, "garden")])
b.start()
self.assertAlmostEqual(b._crit(a), 0.35)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+96
View File
@@ -219,6 +219,8 @@ class AutoFarmerTest(unittest.TestCase):
data["fertilizer"] = 10 data["fertilizer"] = 10
data["coins"] = 100 data["coins"] = 100
data["auto_farmer"] = False data["auto_farmer"] = False
data["auto_planter"] = False
data["auto_planter_active"] = False
collection.save(data) collection.save(data)
def _stalled_plot(self): def _stalled_plot(self):
@@ -308,6 +310,100 @@ class AutoFarmerTest(unittest.TestCase):
self.assertEqual(len(state["seeds"]), 1) # seed not planted self.assertEqual(len(state["seeds"]), 1) # seed not planted
self.assertEqual(state["plots"][0]["stage"], 1) # stalled plot advanced self.assertEqual(state["plots"][0]["stage"], 1) # stalled plot advanced
def test_buy_auto_planter(self):
data = collection.load()
data["coins"] = 300
collection.save(data)
self.assertEqual(collection.buy_auto_planter(), "")
self.assertTrue(collection.auto_planter_owned())
self.assertTrue(collection.auto_planter_active())
self.assertEqual(collection.available_coins(), 100)
self.assertNotEqual(collection.buy_auto_planter(), "") # already owned
def test_buy_auto_planter_requires_coins(self):
data = collection.load()
data["coins"] = 50
collection.save(data)
self.assertNotEqual(collection.buy_auto_planter(), "")
self.assertFalse(collection.auto_planter_owned())
def test_toggle_auto_planter(self):
data = collection.load()
data["coins"] = 300
collection.save(data)
collection.buy_auto_planter()
self.assertFalse(collection.toggle_auto_planter()) # turned off
self.assertFalse(collection.auto_planter_active())
self.assertTrue(collection.toggle_auto_planter()) # back on
def test_auto_plant_plants_seed_into_empty_plot(self):
data = collection.load()
data["coins"] = 300
data["soil"]["seeds"] = [{"plant": "Rose"}, {"plant": "Lavender"}]
collection.save(data)
self.assertEqual(collection.buy_auto_planter(), "")
self.assertEqual(collection.auto_plant(), "")
state = collection.soil_state()
self.assertEqual(len(state["plots"]), 1)
self.assertEqual(state["plots"][0]["plant"], "Rose") # first seed planted
self.assertEqual(len(state["seeds"]), 1)
def test_auto_plant_respects_toggle_and_requirements(self):
data = collection.load()
data["coins"] = 300
data["soil"]["seeds"] = [{"plant": "Rose"}]
collection.save(data)
self.assertEqual(collection.auto_plant(), "No auto planter") # not owned
collection.buy_auto_planter()
collection.toggle_auto_planter() # turn off
self.assertEqual(collection.auto_plant(), "Auto planter off")
collection.toggle_auto_planter()
data = collection.load()
data["soil"]["seeds"] = []
collection.save(data)
self.assertEqual(collection.auto_plant(), "") # nothing to plant -> idle
self.assertEqual(len(collection.soil_state()["plots"]), 0)
def test_auto_plant_wont_overfill_plots(self):
data = collection.load()
data["coins"] = 300
data["soil"]["seeds"] = [{"plant": "Rose"}, {"plant": "Lavender"}]
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
collection.save(data)
self.assertEqual(collection.buy_auto_planter(), "")
self.assertEqual(collection.auto_plant(), "") # no free plot -> idle
self.assertEqual(len(collection.soil_state()["plots"]), 1)
class PackShopTest(unittest.TestCase):
def setUp(self):
data = collection.load()
data["coins"] = 500
data["packs"] = {t: 0 for t in collection.PACK_TYPES}
collection.save(data)
def test_buy_lesser_and_general(self):
self.assertEqual(collection.buy_pack("lesser"), "")
self.assertEqual(collection.buy_pack("general"), "")
self.assertEqual(collection.buy_pack("garden"), "")
self.assertEqual(collection.available_coins(), 500 - 50 - 100 - 100)
packs = collection.pack_counts()
self.assertEqual(packs["lesser"], 1)
self.assertEqual(packs["general"], 1)
self.assertEqual(packs["garden"], 1)
def test_tower_pack_cannot_be_bought(self):
self.assertEqual(collection.buy_pack("tower"), "That pack can't be bought")
self.assertEqual(collection.pack_counts().get("tower", 0), 0)
self.assertEqual(collection.available_coins(), 500)
def test_buy_pack_requires_coins(self):
data = collection.load()
data["coins"] = 30
collection.save(data)
self.assertEqual(collection.buy_pack("lesser"), "Not enough coins")
self.assertEqual(collection.pack_counts().get("lesser", 0), 0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+1 -1
View File
@@ -27,7 +27,7 @@ class ExtractionScreen:
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption("Plants — Ability Extraction") pygame.display.set_caption("Flora — Ability Extraction")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30) self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
+155 -20
View File
@@ -4,10 +4,11 @@ from assets import icon, load_sprite
from engine import collection, runes from engine import collection, runes
from engine.battle import Battle from engine.battle import Battle
from engine.dominance import estimate_dominance, resolve_instantly 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.relics import RELIC_POOL
from engine.skills import EFF_DOT, EFF_HOT, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY 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 engine.unit import ROLE_NAMES, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER
from ui.menu import _bracket_text, _draw_evo_badge from ui.menu import _bracket_text, _draw_evo_badge
from ui.relics_overview import RelicsOverview from ui.relics_overview import RelicsOverview
@@ -19,8 +20,8 @@ TYPE_COLORS = {
"radiant": (245, 200, 90), "radiant": (245, 200, 90),
"nocturnal": (150, 120, 220), "nocturnal": (150, 120, 220),
"ice": (130, 200, 235), "ice": (130, 200, 235),
"garden": (120, 200, 120), "garden": (235, 130, 170),
"wildflower": (235, 130, 170), "wildflower": (120, 200, 120),
} }
WIN_W = 1040 WIN_W = 1040
WIN_H = 720 WIN_H = 720
@@ -64,13 +65,13 @@ class Game:
self.stage = stage self.stage = stage
self.relics = list(relics) self.relics = list(relics)
if tower and stage: if tower and stage:
caption = f"Plants — Tower — Floor {stage}" caption = f"Flora — Tower — Floor {stage}"
elif stage: elif stage:
caption = f"Plants — Floor {stage}" caption = f"Flora — Floor {stage}"
elif tower: elif tower:
caption = "Plants — Tower Battle" caption = "Flora — Tower Battle"
else: else:
caption = "Plants — 4v4 Turn Battle (prototype)" caption = "Flora — 4v4 Turn Battle (prototype)"
pygame.display.set_caption(caption) pygame.display.set_caption(caption)
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.battle = battle self.battle = battle
@@ -108,6 +109,12 @@ class Game:
self._dt = 0.0 self._dt = 0.0
self.back_rect = pygame.Rect(12, 12, 90, 32) self.back_rect = pygame.Rect(12, 12, 90, 32)
self.auto_btn = pygame.Rect(112, 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 # Auto-battle unlocks once the player has cleared floor 20 in the
# normal stage battles (floor_progress counts the next unbeaten floor). # normal stage battles (floor_progress counts the next unbeaten floor).
self.auto_unlocked = collection.floor_progress() > 20 self.auto_unlocked = collection.floor_progress() > 20
@@ -206,6 +213,13 @@ class Game:
running = False running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale) 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): if self.auto_unlocked and self.auto_btn.collidepoint(pos):
self.auto_battle = not self.auto_battle self.auto_battle = not self.auto_battle
continue continue
@@ -219,6 +233,9 @@ class Game:
self.reward_acknowledged = True self.reward_acknowledged = True
continue continue
if self.back_rect.collidepoint(pos): if self.back_rect.collidepoint(pos):
if self.battle.winner is None:
self.confirm_back = True
else:
outcome = "back" outcome = "back"
running = False running = False
else: else:
@@ -294,18 +311,27 @@ class Game:
if rect.collidepoint(pos): if rect.collidepoint(pos):
if sk.cooldown_left > 0: if sk.cooldown_left > 0:
return 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: if oblivion and sk is not weakest:
return return
if sk.target in (TARGET_ENEMY, TARGET_ALLY, TARGET_ANY): 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) valid = b.valid_skill_targets(cur, sk)
if len(valid) == 1: 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.pending_skill = None
self._cast(sk, valid[0]) self._cast(sk, valid[0])
else: else:
self.pending_skill = sk if self.pending_skill is not sk else None self.pending_skill = sk
else: 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.pending_skill = None
self._cast(sk, None) self._cast(sk, None)
return return
@@ -319,8 +345,12 @@ class Game:
if self.pending_skill: if self.pending_skill:
target = self._resolve_target(unit, self.pending_skill) target = self._resolve_target(unit, self.pending_skill)
if target: if target:
if self._was_tank_redirect(unit, self.pending_skill, target):
self._set_tank_notice()
self._cast(self.pending_skill, target) 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 return
if unit.team != TEAM_PLAYER: if unit.team != TEAM_PLAYER:
best = next((s for s in sorted(cur.skills, key=lambda s: s.priority) best = next((s for s in sorted(cur.skills, key=lambda s: s.priority)
@@ -328,8 +358,19 @@ class Game:
if best: if best:
target = self._resolve_target(unit, best) target = self._resolve_target(unit, best)
if target: if target:
if self._was_tank_redirect(unit, best, target):
self._set_tank_notice()
self._cast(best, target) 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): def _resolve_target(self, unit, skill):
b = self.battle b = self.battle
if unit in b.valid_skill_targets(b.current, skill): if unit in b.valid_skill_targets(b.current, skill):
@@ -380,6 +421,8 @@ class Game:
def _update(self, dt): def _update(self, dt):
b = self.battle b = self.battle
if self.tank_notice and pygame.time.get_ticks() >= self.tank_notice_until:
self.tank_notice = ""
self._consume_events() self._consume_events()
self._advance_hit_queue(dt) self._advance_hit_queue(dt)
if b.winner is not None: if b.winner is not None:
@@ -603,9 +646,9 @@ class Game:
elif self.stage: elif self.stage:
title_text = f"Floor {self.stage}" title_text = f"Floor {self.stage}"
elif self.tower: elif self.tower:
title_text = "Plants — Tower Battle" title_text = "Flora — Tower Battle"
else: else:
title_text = "Plants — 4v4 Turn Battle" title_text = "Flora — 4v4 Turn Battle"
title = self.font.render(title_text, True, TEXT) title = self.font.render(title_text, True, TEXT)
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 24))) self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 24)))
pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6)
@@ -636,6 +679,75 @@ class Game:
self._draw_reward_popup() self._draw_reward_popup()
if self.relic_overview is not None: if self.relic_overview is not None:
self.relic_overview.draw(self.mouse_pos) 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): def _draw_reward_popup(self):
b = self.battle b = self.battle
@@ -656,8 +768,11 @@ class Game:
kind = it.get("kind", "") kind = it.get("kind", "")
# Display scaled to match the rest of the UI (aspect preserved). # Display scaled to match the rest of the UI (aspect preserved).
img = fit_surface(img, 60 if kind == "rune" else 80) img = fit_surface(img, 60 if kind == "rune" else 80)
tiles.append((img, label, img.get_width(), img.get_height() + 20, # Tile width must also fit the label, or a rune + pack in the
kind == "rune")) # 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: else:
tiles.append((None, label, max(90, self.bold.size(label)[0] + 20), 30, False)) 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()): if target >= max(img.get_width(), img.get_height()):
continue continue
img2 = fit_surface(img, target) 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 changed = True
if not changed: if not changed:
break break
@@ -706,6 +823,14 @@ class Game:
panel_w = max(440, max_row_w + pad * 2) panel_w = max(440, max_row_w + pad * 2)
panel_h = head_h + tiles_h + 46 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) 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) 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) border = (150, 190, 150) if b.winner == TEAM_PLAYER else (200, 90, 90)
@@ -722,7 +847,8 @@ class Game:
x = panel.centerx - total // 2 x = panel.centerx - total // 2
for img, label, tw, th, _ in r: for img, label, tw, th, _ in r:
if img is not None: 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) lr = self.bold.render(label, True, HINT)
self.screen.blit(lr, lr.get_rect(center=(x + tw // 2, y + img.get_height() + 10))) self.screen.blit(lr, lr.get_rect(center=(x + tw // 2, y + img.get_height() + 10)))
else: else:
@@ -732,6 +858,12 @@ class Game:
self.screen.blit(t, t.get_rect(center=box.center)) self.screen.blit(t, t.get_rect(center=box.center))
x += tw + gap x += tw + gap
y += row_h[i] + 14 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)) hint = self.tiny.render("Click to continue", True, (150, 155, 150))
self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 16))) 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") parts.append(f"Heal {sum(a for a, _ in stacks)}/t")
if "form" in unit.status: if "form" in unit.status:
parts.append(unit.status["form"].capitalize()) 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) return " | ".join(parts)
def _draw_cards(self): 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) 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) fill = int(bar_w * pct)
pygame.draw.rect(self.screen, bar_color, (hp_x, rect.y + 7, fill, 10)) 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: 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 = int(bar_w * min(1.0, shield / unit.max_hp))
sh_w = min(sh_w, fill)
if sh_w > 0: if sh_w > 0:
tint = pygame.Surface((sh_w, 10), pygame.SRCALPHA) tint = pygame.Surface((sh_w, 10), pygame.SRCALPHA)
tint.fill((120, 185, 240, 140)) tint.fill((120, 185, 240, 140))
@@ -1046,7 +1181,7 @@ class Game:
if ready: if ready:
oblivion_skill = min(ready, key=lambda s: s.power) oblivion_skill = min(ready, key=lambda s: s.power)
for i, (sk, rect) in enumerate(self.skill_rects): 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 blocked = oblivion and sk is not oblivion_skill
if sk is self.pending_skill: if sk is self.pending_skill:
bg = (120, 120, 40) bg = (120, 120, 40)
+194 -71
View File
@@ -4,7 +4,7 @@ import pygame
from assets import icon as icon_path from assets import icon as icon_path
from assets import load_sprite from assets import load_sprite
from engine import collection from engine import bosses, collection
from engine import runes from engine import runes
from engine.growth import MAX_LEVEL, can_evolve, compute_stats, evolve_cost, xp_to_next from engine.growth import MAX_LEVEL, can_evolve, compute_stats, evolve_cost, xp_to_next
from engine.plants import (HP_MULT, PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS, from engine.plants import (HP_MULT, PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS,
@@ -34,8 +34,8 @@ TYPE_COLORS = {
"radiant": (245, 200, 90), "radiant": (245, 200, 90),
"nocturnal": (150, 120, 220), "nocturnal": (150, 120, 220),
"ice": (130, 200, 235), "ice": (130, 200, 235),
"garden": (120, 200, 120), "garden": (235, 130, 170),
"wildflower": (235, 130, 170), "wildflower": (120, 200, 120),
} }
def _draw_star(screen, center, radius, color): def _draw_star(screen, center, radius, color):
import math import math
@@ -84,7 +84,7 @@ class Menu:
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption("Plants") pygame.display.set_caption("Flora")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 36) self.font = pygame.font.SysFont("segoeui", 36)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
@@ -118,6 +118,7 @@ class Menu:
self.seed_rect = pygame.Rect(250, 352, 220, 30) self.seed_rect = pygame.Rect(250, 352, 220, 30)
self.runes_rect = pygame.Rect(480, 352, 170, 30) self.runes_rect = pygame.Rect(480, 352, 170, 30)
self.auto_farmer_rect = pygame.Rect(0, 0, 0, 0) self.auto_farmer_rect = pygame.Rect(0, 0, 0, 0)
self.auto_planter_rect = pygame.Rect(0, 0, 0, 0)
self.runes_deconstruct_rect = pygame.Rect(WIN_W - 220, 16, 110, 34) self.runes_deconstruct_rect = pygame.Rect(WIN_W - 220, 16, 110, 34)
self.runes_info_rect = pygame.Rect(WIN_W - 100, 16, 84, 34) self.runes_info_rect = pygame.Rect(WIN_W - 100, 16, 84, 34)
self.runes_deconstruct = False self.runes_deconstruct = False
@@ -153,6 +154,11 @@ class Menu:
self.redeem_rect = pygame.Rect(WIN_W - 250, 50, 234, 30) self.redeem_rect = pygame.Rect(WIN_W - 250, 50, 234, 30)
self.history_rect = pygame.Rect(WIN_W - 250, 84, 234, 26) self.history_rect = pygame.Rect(WIN_W - 250, 84, 234, 26)
self.history_open = False self.history_open = False
self.shop_mode = False
self.shop_rect = pygame.Rect(WIN_W - 250, 114, 234, 28)
self.shop_btns = {}
self.shop_msg = ""
self.shop_msg_until = 0
self._layout_packs() self._layout_packs()
self.diff_rects = {i: pygame.Rect(220, 130 + (i - 1) * 74, 460, 58) for i in range(1, 6)} self.diff_rects = {i: pygame.Rect(220, 130 + (i - 1) * 74, 460, 58) for i in range(1, 6)}
self.floor_rects = {i: pygame.Rect(220, 130 + (i - 1) * 74, 460, 58) for i in range(1, 6)} self.floor_rects = {i: pygame.Rect(220, 130 + (i - 1) * 74, 460, 58) for i in range(1, 6)}
@@ -178,8 +184,9 @@ class Menu:
self.coll_sort = None self.coll_sort = None
self.collection_tab = "plants" self.collection_tab = "plants"
self.collection_tab_btns = { self.collection_tab_btns = {
"plants": pygame.Rect(WIN_W // 2 - 170, 100, 150, 30), "plants": pygame.Rect(WIN_W // 2 - 250, 100, 150, 30),
"runes": pygame.Rect(WIN_W // 2 + 20, 100, 150, 30), "bosses": pygame.Rect(WIN_W // 2 - 70, 100, 150, 30),
"runes": pygame.Rect(WIN_W // 2 + 110, 100, 150, 30),
} }
self.coll_rune_tier = 0 self.coll_rune_tier = 0
self.coll_rune_color = None self.coll_rune_color = None
@@ -206,27 +213,18 @@ class Menu:
"level": pygame.Rect(767, 50, 56, 28), "level": pygame.Rect(767, 50, 56, 28),
"stage": pygame.Rect(827, 50, 58, 28), "stage": pygame.Rect(827, 50, 58, 28),
} }
self.convert_mode = False
self.convert_rect = pygame.Rect(WIN_W // 2 - 330, WIN_H - 56, 210, 36)
self.soil_msg = None # dict describing a soil popup (harvest result) self.soil_msg = None # dict describing a soil popup (harvest result)
self.soil_fert_info = False self.soil_fert_info = False
self.water_info = False self.water_info = False
self.soil_notice = "" self.soil_notice = ""
self.soil_notice_until = 0 self.soil_notice_until = 0
self.convert_pack_btns = {}
for i, pt in enumerate(REWARD_PACK_TYPES):
col = i % 3
row = i // 3
x = WIN_W // 2 - 165 + col * 115
y = WIN_H // 2 - 25 + row * 52
self.convert_pack_btns[pt] = pygame.Rect(x, y, 105, 42)
self._layout_buttons() self._layout_buttons()
def _reinit(self): def _reinit(self):
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption("Plants") pygame.display.set_caption("Flora")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 36) self.font = pygame.font.SysFont("segoeui", 36)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
@@ -293,16 +291,16 @@ class Menu:
"Plants": pygame.Rect(x0, y, bw, bh), "Plants": pygame.Rect(x0, y, bw, bh),
"Battle": pygame.Rect(x0 + bw + gap, y, bw, bh), "Battle": pygame.Rect(x0 + bw + gap, y, bw, bh),
} }
# Side buttons (square-ish): Seeds & Runes left, Soil & Collection right. # Side buttons (square-ish): Soil & Seeds left, Runes & Collection right.
# Fitted into the lower half, keeping clear of the bottom row. # Fitted into the lower half, keeping clear of the bottom row.
sq, sq_gap = 104, 16 sq, sq_gap = 104, 16
ry = WIN_H - 72 - 2 * sq - sq_gap - 10 ry = WIN_H - 72 - 2 * sq - sq_gap - 10
self.side_buttons = { self.side_buttons = {
"Runes": pygame.Rect(34, ry, sq, sq), "Soil": pygame.Rect(34, ry, sq, sq),
"Seeds": pygame.Rect(34, ry + sq + sq_gap, sq, sq), "Seeds": pygame.Rect(34, ry + sq + sq_gap, sq, sq),
} }
rx = WIN_W - 34 - sq rx = WIN_W - 34 - sq
self.side_buttons["Soil"] = pygame.Rect(rx, ry, sq, sq) self.side_buttons["Runes"] = pygame.Rect(rx, ry, sq, sq)
self.side_buttons["Collection"] = pygame.Rect(rx, ry + sq + sq_gap, sq, sq) self.side_buttons["Collection"] = pygame.Rect(rx, ry + sq + sq_gap, sq, sq)
def _seed_pack_sprite(self, pt, size=110): def _seed_pack_sprite(self, pt, size=110):
@@ -356,6 +354,7 @@ class Menu:
collection.accrue_coins() collection.accrue_coins()
collection.accrue_fertilizer() collection.accrue_fertilizer()
collection.auto_farm() collection.auto_farm()
collection.auto_plant()
self._passive_check_at = pygame.time.get_ticks() + 1000 self._passive_check_at = pygame.time.get_ticks() + 1000
if self.page != self._prev_page: if self.page != self._prev_page:
self.grid_scroll = 0 self.grid_scroll = 0
@@ -399,14 +398,16 @@ class Menu:
if self.type_info is not None: if self.type_info is not None:
self.type_info = None self.type_info = None
return None return None
if self.convert_mode: if self.shop_mode:
for pt, rect in self.convert_pack_btns.items(): if self.shop_msg and pygame.time.get_ticks() < self.shop_msg_until:
return None # a "bought" notice is showing; ignore clicks briefly
for pt, rect in self.shop_btns.items():
if rect.collidepoint(pos): if rect.collidepoint(pos):
if collection.convert_water_to_pack(pt): err = collection.buy_pack(pt)
self.owned = collection.owned() self.shop_msg = err or f"Bought a {pt.capitalize()} pack!"
self.convert_mode = False self.shop_msg_until = pygame.time.get_ticks() + 1800
return None return None
self.convert_mode = False self.shop_mode = False
return None return None
if self.page == "seeds": if self.page == "seeds":
if self.history_open: if self.history_open:
@@ -421,6 +422,9 @@ class Menu:
if self.redeem_rect.collidepoint(pos): if self.redeem_rect.collidepoint(pos):
collection.redeem_tower_pack() collection.redeem_tower_pack()
return None return None
if self.shop_rect.collidepoint(pos):
self.shop_mode = True
return None
for pt, (card, btn) in self.pack_rects.items(): for pt, (card, btn) in self.pack_rects.items():
if btn.move(0, -self.seeds_scroll).collidepoint(pos): if btn.move(0, -self.seeds_scroll).collidepoint(pos):
result = collection.open_pack(pt) result = collection.open_pack(pt)
@@ -453,6 +457,8 @@ class Menu:
if rect.collidepoint(pos): if rect.collidepoint(pos):
self.collection_tab = key self.collection_tab = key
return None return None
if self.collection_tab == "bosses":
return None # boss cards are informational only
if self.collection_tab == "runes": if self.collection_tab == "runes":
if self._click_coll_rune_filter(pos): if self._click_coll_rune_filter(pos):
return None return None
@@ -488,9 +494,6 @@ class Menu:
self.water_mode = not self.water_mode self.water_mode = not self.water_mode
return None return None
if self.water_mode: if self.water_mode:
if self.convert_rect.collidepoint(pos) and collection.available_water() >= 5:
self.convert_mode = True
return None
if self.water_exit_btn.collidepoint(pos): if self.water_exit_btn.collidepoint(pos):
self.water_mode = False self.water_mode = False
return None return None
@@ -592,6 +595,16 @@ class Menu:
"activated" if active else "deactivated") "activated" if active else "deactivated")
self.soil_notice_until = pygame.time.get_ticks() + 1800 self.soil_notice_until = pygame.time.get_ticks() + 1800
return None return None
if self.auto_planter_rect.collidepoint(pos):
if not collection.auto_planter_owned():
reason = collection.buy_auto_planter()
self.soil_notice = reason or "Auto planter bought!"
else:
active = collection.toggle_auto_planter()
self.soil_notice = "Auto planter " + (
"activated" if active else "deactivated")
self.soil_notice_until = pygame.time.get_ticks() + 1800
return None
for idx, rect in self.soil_seed_rects: for idx, rect in self.soil_seed_rects:
if rect.collidepoint(pos): if rect.collidepoint(pos):
collection.plant_seed(idx) collection.plant_seed(idx)
@@ -727,8 +740,8 @@ class Menu:
if self.type_info is not None: if self.type_info is not None:
draw_type_popup(self.screen, WIN_W, WIN_H, self.type_info, draw_type_popup(self.screen, WIN_W, WIN_H, self.type_info,
self.bold, self.small, self.tiny) self.bold, self.small, self.tiny)
if self.convert_mode: if self.shop_mode:
self._draw_convert_pack() self._draw_shop()
if self.tower_info: if self.tower_info:
self._draw_tower_info() self._draw_tower_info()
if self.soil_msg: if self.soil_msg:
@@ -796,7 +809,7 @@ class Menu:
def _draw_menu(self): def _draw_menu(self):
self._draw_resources() self._draw_resources()
title = self.font.render("Plants", True, TEXT) title = self.font.render("Flora", True, TEXT)
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 86))) self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 86)))
y = 130 y = 130
for line in OVERVIEW: for line in OVERVIEW:
@@ -819,7 +832,7 @@ class Menu:
else: else:
t = self.bold.render(label, True, TEXT) t = self.bold.render(label, True, TEXT)
self.screen.blit(t, t.get_rect(center=rect.center)) self.screen.blit(t, t.get_rect(center=rect.center))
# side buttons: Seeds (left), Soil & Collection (right) # side buttons: Soil & Seeds (left), Runes & Collection (right)
soil_locked = not collection.soil_unlocked() soil_locked = not collection.soil_unlocked()
for label, rect in self.side_buttons.items(): for label, rect in self.side_buttons.items():
hover = rect.collidepoint(mouse) hover = rect.collidepoint(mouse)
@@ -857,7 +870,7 @@ class Menu:
n = collection.SOIL_GATE_PLANTS - len(collection.owned()) n = collection.SOIL_GATE_PLANTS - len(collection.owned())
hint = self.tiny.render(f"Own {collection.SOIL_GATE_PLANTS} plants to unlock Soil" hint = self.tiny.render(f"Own {collection.SOIL_GATE_PLANTS} plants to unlock Soil"
f" ({n} to go)", True, (110, 115, 110)) f" ({n} to go)", True, (110, 115, 110))
self.screen.blit(hint, hint.get_rect(midright=(self.side_buttons["Soil"].right, self.screen.blit(hint, hint.get_rect(midleft=(self.side_buttons["Soil"].left,
self.side_buttons["Soil"].top - 6))) self.side_buttons["Soil"].top - 6)))
def _draw_battle_setup(self): def _draw_battle_setup(self):
@@ -972,6 +985,12 @@ class Menu:
pygame.draw.rect(self.screen, (150, 190, 150), self.history_rect, 1, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150), self.history_rect, 1, border_radius=6)
ht = self.tiny.render("History (last 10)", True, TEXT) ht = self.tiny.render("History (last 10)", True, TEXT)
self.screen.blit(ht, ht.get_rect(center=self.history_rect.center)) self.screen.blit(ht, ht.get_rect(center=self.history_rect.center))
shov = self.shop_rect.collidepoint(mouse)
pygame.draw.rect(self.screen, (90, 120, 90) if shov else BTN,
self.shop_rect, border_radius=6)
pygame.draw.rect(self.screen, (150, 190, 150), self.shop_rect, 1, border_radius=6)
sh = self.tiny.render("Pack Shop", True, TEXT)
self.screen.blit(sh, sh.get_rect(center=self.shop_rect.center))
# scrollable grid of packs, each showing its sprite at native size # scrollable grid of packs, each showing its sprite at native size
old_clip = self.screen.get_clip() old_clip = self.screen.get_clip()
self.screen.set_clip(self.seeds_scroll_area) self.screen.set_clip(self.seeds_scroll_area)
@@ -1143,7 +1162,7 @@ class Menu:
self.screen.blit(rp, (panel.right - 50, y)) self.screen.blit(rp, (panel.right - 50, y))
low, high = runes.per_rune_chance(runes.PACK_DROP_CHANCE) low, high = runes.per_rune_chance(runes.PACK_DROP_CHANCE)
sub = self.tiny.render( sub = self.tiny.render(
f"{rpct:.0f}% for any rune — brown is rarer than the others", f"{rpct:.0f}% for any rune — brown & pearl are rarer than the others",
True, MUTED) True, MUTED)
self.screen.blit(sub, (panel.x + 150, y + 22)) self.screen.blit(sub, (panel.x + 150, y + 22))
if pt in ALL_TYPES: if pt in ALL_TYPES:
@@ -1152,26 +1171,54 @@ class Menu:
close = self.tiny.render("Click anywhere to close", True, MUTED) close = self.tiny.render("Click anywhere to close", True, MUTED)
self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 24))) self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 24)))
def _draw_convert_pack(self): def _draw_shop(self):
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 160)) overlay.fill((0, 0, 0, 160))
self.screen.blit(overlay, (0, 0)) self.screen.blit(overlay, (0, 0))
w, h = 380, 210 w, h = 640, 400
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h) panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
pygame.draw.rect(self.screen, PANEL, panel, border_radius=12) pygame.draw.rect(self.screen, PANEL, panel, border_radius=12)
pygame.draw.rect(self.screen, (60, 70, 60), panel, 2, border_radius=12) pygame.draw.rect(self.screen, ACCENT, panel, 2, border_radius=12)
title = self.bold.render("Convert 5 water → a pack of your choice", True, TEXT) title = self.bold.render("Pack Shop", True, ACCENT)
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 28))) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 26)))
coins = collection.available_coins()
bal = self.small.render(f"Coins: {coins}", True, TEXT)
total_w = (16 + 4) + bal.get_width()
bx = panel.centerx - total_w // 2
if self.coin_icon:
self.screen.blit(self.coin_icon, (bx, panel.y + 52))
self.screen.blit(bal, (bx + 20, panel.y + 52))
buyable = ("lesser", "general", "radiant", "nocturnal", "ice", "garden", "wildflower")
cols = 4
cw, chh, gap = 132, 118, 12
x0 = panel.x + 24
y0 = panel.y + 96
self.shop_btns = {}
mouse = self.mouse_pos mouse = self.mouse_pos
for pt, rect in self.convert_pack_btns.items(): for i, pt in enumerate(buyable):
x = x0 + (i % cols) * (cw + gap)
y = y0 + (i // cols) * (chh + gap)
rect = pygame.Rect(x, y, cw, chh)
cost = collection.PACK_COSTS[pt]
hover = rect.collidepoint(mouse) hover = rect.collidepoint(mouse)
bg = (90, 120, 90) if hover else BTN afford = coins >= cost
pygame.draw.rect(self.screen, bg, rect, border_radius=6) pygame.draw.rect(self.screen, (90, 120, 90) if (hover and afford) else (BTN if afford else (45, 50, 45)),
pygame.draw.rect(self.screen, (150, 190, 150), rect, 1, border_radius=6) rect, border_radius=8)
tcol = TYPE_COLORS.get(pt, TEXT) pygame.draw.rect(self.screen, (150, 190, 150) if afford else (70, 80, 70),
draw_type_icon(self.screen, pt, rect.center, self.tiny, tcol, 26, center=True) rect, 1, border_radius=8)
hint = self.tiny.render("Click elsewhere to cancel", True, MUTED) spr = self._seed_pack_sprite(pt, 48)
self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 18))) if spr:
self.screen.blit(spr, spr.get_rect(center=(rect.centerx, rect.y + 30)))
nm = self.tiny.render(pt.capitalize(), True, TEXT if afford else MUTED)
self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 60)))
pr = self.tiny.render(f"{cost} coins", True, HINT if afford else MUTED)
self.screen.blit(pr, pr.get_rect(center=(rect.centerx, rect.y + 86)))
self.shop_btns[pt] = rect
note = self.tiny.render("Tower packs can't be bought.", True, MUTED)
self.screen.blit(note, note.get_rect(center=(panel.centerx, panel.bottom - 18)))
if self.shop_msg and pygame.time.get_ticks() < self.shop_msg_until:
mt = self.small.render(self.shop_msg, True, DANGER if "Not enough" in self.shop_msg else (120, 220, 120))
self.screen.blit(mt, mt.get_rect(center=(panel.centerx, panel.y + 356)))
def _draw_soil(self): def _draw_soil(self):
self._draw_page("Soil", "Retrieve seeds from plants, plant them, and grow copies.") self._draw_page("Soil", "Retrieve seeds from plants, plant them, and grow copies.")
@@ -1217,6 +1264,26 @@ class Menu:
f"Buy Auto Farmer — {collection.AUTO_FARMER_COST} coins", f"Buy Auto Farmer — {collection.AUTO_FARMER_COST} coins",
True, TEXT if can_afford else MUTED) True, TEXT if can_afford else MUTED)
self.screen.blit(af_txt, af_txt.get_rect(center=self.auto_farmer_rect.center)) self.screen.blit(af_txt, af_txt.get_rect(center=self.auto_farmer_rect.center))
ap_owned = collection.auto_planter_owned()
ap_active = collection.auto_planter_active()
self.auto_planter_rect = pygame.Rect(WIN_W - 270, 180, 250, 28)
ap_hover = self.auto_planter_rect.collidepoint(self.mouse_pos)
pygame.draw.rect(self.screen,
(90, 120, 90) if (ap_active or ap_hover) else BTN,
self.auto_planter_rect, border_radius=6)
pygame.draw.rect(self.screen,
(150, 190, 150) if ap_active else (70, 80, 70),
self.auto_planter_rect, 1, border_radius=6)
if ap_owned:
ap_txt = self.tiny.render(
"Auto Planter: active" if ap_active else "Auto Planter: off",
True, ACCENT if ap_active else MUTED)
else:
can_afford = collection.available_coins() >= collection.AUTO_PLANTER_COST
ap_txt = self.tiny.render(
f"Buy Auto Planter — {collection.AUTO_PLANTER_COST} coins",
True, TEXT if can_afford else MUTED)
self.screen.blit(ap_txt, ap_txt.get_rect(center=self.auto_planter_rect.center))
if self.soil_notice and pygame.time.get_ticks() < self.soil_notice_until: if self.soil_notice and pygame.time.get_ticks() < self.soil_notice_until:
nt = self.bold.render(self.soil_notice, True, (235, 130, 130)) nt = self.bold.render(self.soil_notice, True, (235, 130, 130))
self.screen.blit(nt, nt.get_rect(center=(WIN_W // 2, 148))) self.screen.blit(nt, nt.get_rect(center=(WIN_W // 2, 148)))
@@ -1492,14 +1559,6 @@ class Menu:
self._draw_grid(owned, reveal_all=True, level_mode=self.water_mode, data=data) self._draw_grid(owned, reveal_all=True, level_mode=self.water_mode, data=data)
if self.water_mode: if self.water_mode:
water = collection.available_water() water = collection.available_water()
can_convert = water >= 5
pygame.draw.rect(self.screen, BTN_HOVER if can_convert else (45, 50, 45),
self.convert_rect, border_radius=6)
pygame.draw.rect(self.screen, (150, 190, 150) if can_convert else (70, 80, 70),
self.convert_rect, 1, border_radius=6)
ct = self.tiny.render(f"Convert 5 water → pack" + ("" if can_convert else " (need 5)"),
True, TEXT if can_convert else MUTED)
self.screen.blit(ct, ct.get_rect(center=self.convert_rect.center))
pygame.draw.rect(self.screen, BTN_HOVER, self.water_exit_btn, border_radius=6) pygame.draw.rect(self.screen, BTN_HOVER, self.water_exit_btn, border_radius=6)
pygame.draw.rect(self.screen, (150, 190, 150), self.water_exit_btn, 2, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150), self.water_exit_btn, 2, border_radius=6)
et = self.tiny.render("Exit water mode", True, TEXT) et = self.tiny.render("Exit water mode", True, TEXT)
@@ -1574,10 +1633,12 @@ class Menu:
bcol = DANGER if self.runes_deconstruct_msg else ACCENT bcol = DANGER if self.runes_deconstruct_msg else ACCENT
if self.runes_target is None: if self.runes_target is None:
btxt = self.runes_deconstruct_msg or \ btxt = self.runes_deconstruct_msg or \
"Deconstruct — pick a rune to improve, then sacrifice 10 runes" f"Deconstruct — pick a rune to improve, then sacrifice " \
f"{runes.RUNE_SACRIFICE_COST} runes"
else: else:
btxt = self.runes_deconstruct_msg or \ btxt = self.runes_deconstruct_msg or \
f"Pick 10 runes to sacrifice ({len(self.runes_sacrifice)}/10) — " \ f"Pick {runes.RUNE_SACRIFICE_COST} runes to sacrifice " \
f"({len(self.runes_sacrifice)}/{runes.RUNE_SACRIFICE_COST}) — " \
"click the target again to change it" "click the target again to change it"
b = self.tiny.render(btxt, True, bcol) b = self.tiny.render(btxt, True, bcol)
self.screen.blit(b, b.get_rect(center=(WIN_W // 2, 124))) self.screen.blit(b, b.get_rect(center=(WIN_W // 2, 124)))
@@ -1609,7 +1670,7 @@ class Menu:
if ic: if ic:
self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 30))) self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 30)))
owner = collection.rune_location(rune["id"]) owner = collection.rune_location(rune["id"])
max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX max_pct = runes.max_pct(rune["color"])
is_max = rune.get("pct", 0) >= max_pct is_max = rune.get("pct", 0) >= max_pct
is_target = self.runes_deconstruct and self.runes_target \ is_target = self.runes_deconstruct and self.runes_target \
and rune["id"] == self.runes_target["id"] and rune["id"] == self.runes_target["id"]
@@ -1841,7 +1902,7 @@ class Menu:
if not rune: if not rune:
return return
if self.runes_target is None: if self.runes_target is None:
max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX max_pct = runes.max_pct(rune["color"])
if rune.get("pct", 0) >= max_pct: if rune.get("pct", 0) >= max_pct:
self.runes_deconstruct_msg = f"{rune['name']} is already at max roll" self.runes_deconstruct_msg = f"{rune['name']} is already at max roll"
return return
@@ -1857,13 +1918,14 @@ class Menu:
return return
if rune_id in self.runes_sacrifice: if rune_id in self.runes_sacrifice:
self.runes_sacrifice.remove(rune_id) self.runes_sacrifice.remove(rune_id)
elif len(self.runes_sacrifice) >= 10: elif len(self.runes_sacrifice) >= runes.RUNE_SACRIFICE_COST:
self.runes_deconstruct_msg = "Already picked 10 runes" self.runes_deconstruct_msg = \
f"Already picked {runes.RUNE_SACRIFICE_COST} runes"
return return
else: else:
self.runes_sacrifice.append(rune_id) self.runes_sacrifice.append(rune_id)
self.runes_deconstruct_msg = "" self.runes_deconstruct_msg = ""
if len(self.runes_sacrifice) == 10: if len(self.runes_sacrifice) == runes.RUNE_SACRIFICE_COST:
self._open_runes_confirm() self._open_runes_confirm()
return return
@@ -1884,7 +1946,7 @@ class Menu:
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 160)) overlay.fill((0, 0, 0, 160))
self.screen.blit(overlay, (0, 0)) self.screen.blit(overlay, (0, 0))
w, h = 560, 400 w, h = 560, 450
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h) panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
pygame.draw.rect(self.screen, PANEL, panel, border_radius=12) pygame.draw.rect(self.screen, PANEL, panel, border_radius=12)
pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12) pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12)
@@ -1943,11 +2005,12 @@ class Menu:
pygame.draw.rect(self.screen, DANGER, panel, 2, border_radius=12) pygame.draw.rect(self.screen, DANGER, panel, 2, border_radius=12)
rune = self.runes_confirm["rune"] rune = self.runes_confirm["rune"]
name = rune["name"] name = rune["name"]
max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX max_pct = runes.max_pct(rune["color"])
current = rune.get("pct", 0) current = rune.get("pct", 0)
title = self.bold.render("Reroll rune?", True, TEXT) title = self.bold.render("Reroll rune?", True, TEXT)
msg = self.small.render( msg = self.small.render(
f"Sacrifice 10 runes to improve {name} to {current + 1}–{max_pct}%?", f"Sacrifice {runes.RUNE_SACRIFICE_COST} runes to improve {name} to "
f"{current + 1}–{max_pct}%?",
True, TEXT) True, TEXT)
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 34))) self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 34)))
self.screen.blit(msg, msg.get_rect(center=(panel.centerx, panel.y + 60))) self.screen.blit(msg, msg.get_rect(center=(panel.centerx, panel.y + 60)))
@@ -1979,14 +2042,73 @@ class Menu:
rect, border_radius=6) rect, border_radius=6)
pygame.draw.rect(self.screen, (150, 190, 150) if active else (70, 80, 70), pygame.draw.rect(self.screen, (150, 190, 150) if active else (70, 80, 70),
rect, 1, border_radius=6) rect, 1, border_radius=6)
t = self.tiny.render("Plants" if key == "plants" else "Runes", True, TEXT) t = self.tiny.render(
"Plants" if key == "plants" else "Bosses" if key == "bosses" else "Runes",
True, TEXT)
self.screen.blit(t, t.get_rect(center=rect.center)) self.screen.blit(t, t.get_rect(center=rect.center))
if self.collection_tab == "runes": if self.collection_tab == "runes":
self._draw_collection_runes() self._draw_collection_runes()
elif self.collection_tab == "bosses":
self._draw_collection_bosses()
else: else:
self._draw_grid(self.names, reveal_all=False, show_evo=False, self._draw_grid(self.names, reveal_all=False, show_evo=False,
reveal_owned=True, y0=144) reveal_owned=True, y0=144)
def _draw_collection_bosses(self):
mouse = self.mouse_pos
fought = collection.bosses_fought()
cw, ch, gap = 230, 260, 20
cols = 3
total = cols * cw + (cols - 1) * gap
x0 = (WIN_W - total) // 2
y0 = 150
self.boss_cards = []
for i, (name, spec) in enumerate(bosses.BOSS_SPECS.items()):
x = x0 + (i % cols) * (cw + gap)
y = y0 + (i // cols) * (ch + gap)
rect = pygame.Rect(x, y, cw, ch)
self.boss_cards.append((name, rect))
known = name in fought
pygame.draw.rect(self.screen, PANEL, rect, border_radius=8)
pygame.draw.rect(self.screen, (150, 120, 80) if known else (60, 70, 60),
rect, 1, border_radius=8)
spr = self._boss_sprite(name)
sprite_rect = pygame.Rect(rect.centerx - 60, rect.y + 12, 120, 120)
if spr:
ic = fit_surface(spr, 120)
if known:
self.screen.blit(ic, ic.get_rect(center=sprite_rect.center))
else:
dim = ic.copy()
dim.set_alpha(70)
self.screen.blit(dim, dim.get_rect(center=sprite_rect.center))
pygame.draw.rect(self.screen, (70, 78, 70), sprite_rect, 1)
nm = self.small.render(name if known else "???", True, TEXT if known else MUTED)
self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 150)))
if known:
atk, spd, dfn, hp = spec["stats"]
st = self.tiny.render(f"Atk {atk} Spd {spd} Def {dfn} HP {int(hp * HP_MULT)}",
True, MUTED)
self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 176)))
lines = wrap_text(spec["desc"], self.tiny, cw - 24)
ty = rect.y + 198
for ln in lines[:3]:
self.screen.blit(self.tiny.render(ln, True, (170, 175, 170)),
(rect.x + 12, ty))
ty += 16
drops = "Drops: " + ", ".join(spec["drop_runes"])
dr = self.tiny.render(drops, True, (235, 200, 120))
self.screen.blit(dr, dr.get_rect(center=(rect.centerx, rect.bottom - 20)))
else:
t = self.tiny.render("Not yet fought", True, (110, 115, 110))
self.screen.blit(t, t.get_rect(center=(rect.centerx, rect.y + 180)))
def _boss_sprite(self, name):
key = f"boss_{name}"
if key not in self.seed_pack_sprites:
self.seed_pack_sprites[key] = load_sprite(bosses.boss_sprite(name))
return self.seed_pack_sprites[key]
def _draw_collection_runes(self): def _draw_collection_runes(self):
mouse = self.mouse_pos mouse = self.mouse_pos
self._draw_coll_rune_filters() self._draw_coll_rune_filters()
@@ -2108,8 +2230,7 @@ class Menu:
self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 78))) self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 78)))
eff = self.small.render(runes.summary(color), True, TEXT) eff = self.small.render(runes.summary(color), True, TEXT)
self.screen.blit(eff, eff.get_rect(center=(panel.centerx, panel.y + 122))) self.screen.blit(eff, eff.get_rect(center=(panel.centerx, panel.y + 122)))
dmg = self.tiny.render(runes.effect_text(color, runes.PCT_MAX if color != "brown" else runes.BROWN_PCT_MAX), dmg = self.tiny.render(runes.effect_text(color, runes.max_pct(color)), True, MUTED)
True, MUTED)
self.screen.blit(dmg, dmg.get_rect(center=(panel.centerx, panel.y + 146))) self.screen.blit(dmg, dmg.get_rect(center=(panel.centerx, panel.y + 146)))
t1 = self.small.render(f"Tier {runes.tier(color)} · Max roll {runes.roll_range(color)}", t1 = self.small.render(f"Tier {runes.tier(color)} · Max roll {runes.roll_range(color)}",
True, HINT) True, HINT)
@@ -2124,8 +2245,7 @@ class Menu:
if self.coll_rune_sort_best: if self.coll_rune_sort_best:
inv = collection.rune_inventory() inv = collection.rune_inventory()
best = {c: max((r.get("pct", 0) for r in inv if r["color"] == c), default=0) for c in colors} best = {c: max((r.get("pct", 0) for r in inv if r["color"] == c), default=0) for c in colors}
colors.sort(key=lambda c: best[c] / (runes.BROWN_PCT_MAX if c == "brown" else runes.PCT_MAX), colors.sort(key=lambda c: best[c] / runes.max_pct(c), reverse=True)
reverse=True)
return colors return colors
def _draw_detail(self): def _draw_detail(self):
@@ -2390,8 +2510,11 @@ class Menu:
sprite_rect = pygame.Rect(rect.centerx - 44, rect.y + 12, 88, 88) sprite_rect = pygame.Rect(rect.centerx - 44, rect.y + 12, 88, 88)
if spr: if spr:
ic = fit_surface(spr, 88) ic = fit_surface(spr, 88)
if found:
self.screen.blit(ic, ic.get_rect(center=sprite_rect.center)) self.screen.blit(ic, ic.get_rect(center=sprite_rect.center))
if spr and not found: else:
# Dim only the sprite (matches the runes tab): blitting the
# full icon first then the dim copy would cancel out.
dim = ic.copy() dim = ic.copy()
dim.set_alpha(70) dim.set_alpha(70)
self.screen.blit(dim, dim.get_rect(center=sprite_rect.center)) self.screen.blit(dim, dim.get_rect(center=sprite_rect.center))
+173 -12
View File
@@ -2,10 +2,11 @@ import pygame
from assets import icon as icon_path from assets import icon as icon_path
from assets import load_sprite from assets import load_sprite
from engine import collection from engine import bosses, collection
from engine.growth import compute_stats from engine.growth import compute_stats
from engine.plants import HP_MULT, PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS, plant_rarity from engine.plants import HP_MULT, PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS, plant_rarity
from engine.roster import floor_enemy_level, roll_enemies from engine.roster import floor_enemy_level, roll_enemies
from engine.types import ALL_TYPES
from engine.unit import ROLE_ATTACKER, ROLE_ICONS, ROLE_NAMES, ROLE_SUPPORT, ROLE_TANK from engine.unit import ROLE_ATTACKER, ROLE_ICONS, ROLE_NAMES, ROLE_SUPPORT, ROLE_TANK
from ui.menu import _bracket_text, _draw_evo_badge from ui.menu import _bracket_text, _draw_evo_badge
from ui.runes import draw_rune_chips from ui.runes import draw_rune_chips
@@ -17,8 +18,8 @@ TYPE_COLORS = {
"radiant": (245, 200, 90), "radiant": (245, 200, 90),
"nocturnal": (150, 120, 220), "nocturnal": (150, 120, 220),
"ice": (130, 200, 235), "ice": (130, 200, 235),
"garden": (120, 200, 120), "garden": (235, 130, 170),
"wildflower": (235, 130, 170), "wildflower": (120, 200, 120),
} }
BG = (24, 30, 24) BG = (24, 30, 24)
@@ -44,7 +45,7 @@ class TeamSelect:
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption(header or "Plants — Tower") pygame.display.set_caption(header or "Flora — Tower")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30) self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
@@ -68,6 +69,8 @@ class TeamSelect:
self.pool_names = sorted((n for n in self.names if n in collection.owned()), self.pool_names = sorted((n for n in self.names if n in collection.owned()),
key=lambda n: (n not in self.favs, n)) key=lambda n: (n not in self.favs, n))
self.sprites = {n: self._load(n) for n in self.names} self.sprites = {n: self._load(n) for n in self.names}
for bname in bosses.BOSS_SPECS:
self.sprites[bname] = self._load(bosses.boss_sprite(bname))
self.rune_icons = {} self.rune_icons = {}
self.role_icons = {} self.role_icons = {}
self.allies = [] self.allies = []
@@ -82,6 +85,12 @@ class TeamSelect:
self._layout_enemy_cards() self._layout_enemy_cards()
self._layout_enemy_strip() self._layout_enemy_strip()
self._layout_slots() self._layout_slots()
self.pool_filter = None # optional elemental type filter for the ally pool
self.pool_exclude_type = None # right-click: show everything BUT this type
self.pool_type_rects = []
self.pool_role = None # optional role filter for the ally pool
self.pool_exclude_role = None # right-click: show everything BUT this role
self.pool_role_rects = []
self._layout_pool() self._layout_pool()
self.roll_rect = pygame.Rect(WIN_W // 2 - 260, WIN_H - 60, 170, 40) self.roll_rect = pygame.Rect(WIN_W // 2 - 260, WIN_H - 60, 170, 40)
self.next_rect = pygame.Rect(WIN_W // 2 + 90, WIN_H - 60, 180, 40) self.next_rect = pygame.Rect(WIN_W // 2 + 90, WIN_H - 60, 180, 40)
@@ -157,13 +166,28 @@ class TeamSelect:
sl_h, gap = SLOT_H, SLOT_GAP sl_h, gap = SLOT_H, SLOT_GAP
self.ally_rects = [pygame.Rect(24, 170 + i * (sl_h + gap), SLOT_W, sl_h) for i in range(n)] self.ally_rects = [pygame.Rect(24, 170 + i * (sl_h + gap), SLOT_W, sl_h) for i in range(n)]
def _display_pool(self):
"""The ally pool, filtered by element type and/or role when active.
Left-click filters include a type/role; right-click excludes it."""
result = self.pool_names
if self.pool_filter is not None:
result = [n for n in result if POOL[n]["type"] == self.pool_filter]
elif self.pool_exclude_type is not None:
result = [n for n in result if POOL[n]["type"] != self.pool_exclude_type]
if self.pool_role is not None:
result = [n for n in result if POOL[n]["role"] == self.pool_role]
elif self.pool_exclude_role is not None:
result = [n for n in result if POOL[n]["role"] != self.pool_exclude_role]
return result
def _layout_pool(self): def _layout_pool(self):
self.pool_rects = {} self.pool_rects = {}
cols = min(4, max(1, len(self.pool_names))) shown = self._display_pool()
cols = min(4, max(1, len(shown)))
cw, ch, gap = 170, 64, 10 cw, ch, gap = 170, 64, 10
x0 = 290 x0 = 290
y0 = 170 y0 = 170
for i, name in enumerate(self.pool_names): for i, name in enumerate(shown):
x = x0 + (i % cols) * (cw + gap) x = x0 + (i % cols) * (cw + gap)
y = y0 + (i // cols) * (ch + gap) y = y0 + (i // cols) * (ch + gap)
self.pool_rects[name] = pygame.Rect(x, y, cw, ch) self.pool_rects[name] = pygame.Rect(x, y, cw, ch)
@@ -171,7 +195,7 @@ class TeamSelect:
self.pool_area = pygame.Rect(x0 - 4, y0 - 4, self.pool_area = pygame.Rect(x0 - 4, y0 - 4,
cols * cw + (cols - 1) * gap + 8, cols * cw + (cols - 1) * gap + 8,
WIN_H - y0 - 100) WIN_H - y0 - 100)
rows = (len(self.pool_names) + cols - 1) // cols rows = (len(shown) + cols - 1) // cols
self.pool_scroll_max = max(0, rows * (ch + gap) - gap - self.pool_area.h) self.pool_scroll_max = max(0, rows * (ch + gap) - gap - self.pool_area.h)
self.pool_scroll = getattr(self, "pool_scroll", 0) self.pool_scroll = getattr(self, "pool_scroll", 0)
self.pool_scroll = max(0, min(self.pool_scroll, self.pool_scroll_max)) self.pool_scroll = max(0, min(self.pool_scroll, self.pool_scroll_max))
@@ -190,6 +214,10 @@ class TeamSelect:
if not self.detail_open: if not self.detail_open:
self.pool_scroll = max(0, min(self.pool_scroll_max, self.pool_scroll = max(0, min(self.pool_scroll_max,
self.pool_scroll - event.y * 40)) self.pool_scroll - event.y * 40))
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
pos = to_logical(event.pos, self.scale)
if not self.detail_open:
self._click_right(pos)
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
pos = to_logical(event.pos, self.scale) pos = to_logical(event.pos, self.scale)
if self.detail_open: if self.detail_open:
@@ -269,12 +297,55 @@ class TeamSelect:
if rect.collidepoint(pos) and i < len(self.enemies): if rect.collidepoint(pos) and i < len(self.enemies):
self._open_detail(self.enemies[i], self.enemies) self._open_detail(self.enemies[i], self.enemies)
return None return None
for ptype, rect in self.pool_type_rects:
if rect.collidepoint(pos):
self.pool_filter = ptype # None = "All", else the chosen type
self.pool_exclude_type = None
self.pool_scroll = 0
self._layout_pool()
return None
for role, rect in self.pool_role_rects:
if rect.collidepoint(pos):
self.pool_role = role # None = "All", else the chosen role
self.pool_exclude_role = None
self.pool_scroll = 0
self._layout_pool()
return None
for name, rect in self.pool_rects.items(): for name, rect in self.pool_rects.items():
if rect.move(0, -self.pool_scroll).collidepoint(pos): if rect.move(0, -self.pool_scroll).collidepoint(pos):
if name in self.allies: if name in self.allies:
self.allies.remove(name) # click a selected plant to unselect it self.allies.remove(name) # click a selected plant to unselect it
else: else:
self._open_detail(name, self.pool_names) self._open_detail(name, self._display_pool())
return None
return None
def _click_right(self, pos):
"""Right-click on a filter icon EXCLUDES that type/role (everything but it)."""
if self.type_info is not None:
self.type_info = None
return None
for ptype, rect in self.pool_type_rects:
if rect.collidepoint(pos):
if ptype is None:
self.pool_filter = None
self.pool_exclude_type = None
else:
self.pool_exclude_type = ptype
self.pool_filter = None
self.pool_scroll = 0
self._layout_pool()
return None
for role, rect in self.pool_role_rects:
if rect.collidepoint(pos):
if role is None:
self.pool_role = None
self.pool_exclude_role = None
else:
self.pool_exclude_role = role
self.pool_role = None
self.pool_scroll = 0
self._layout_pool()
return None return None
return None return None
@@ -334,6 +405,10 @@ class TeamSelect:
return tag return tag
def _draw_role_type_tag(self, name, pos, font, center=False, ptype=None): def _draw_role_type_tag(self, name, pos, font, center=False, ptype=None):
if bosses.is_boss(name):
role_name = "Boss"
rs = font.render(role_name, True, (235, 200, 120))
else:
role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"]) role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"])
rs = font.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT)) rs = font.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT))
sep = font.render(" · ", True, MUTED) sep = font.render(" · ", True, MUTED)
@@ -381,7 +456,7 @@ class TeamSelect:
tag = self._draw_role_type_tag(name, (rect.centerx, rect.y + 178), self.small, center=True, tag = self._draw_role_type_tag(name, (rect.centerx, rect.y + 178), self.small, center=True,
ptype=self.enemy_types[i] if i < len(self.enemy_types) else None) ptype=self.enemy_types[i] if i < len(self.enemy_types) else None)
self._draw_enemy_level(tag, i) self._draw_enemy_level(tag, i)
stats = POOL[name]["stats"] stats = bosses.boss_stats(name) if bosses.is_boss(name) else POOL[name]["stats"]
st = self.tiny.render(f"Atk {stats[0]} Spd {stats[1]} Def {stats[2]} HP {int(stats[3] * HP_MULT)}", st = self.tiny.render(f"Atk {stats[0]} Spd {stats[1]} Def {stats[2]} HP {int(stats[3] * HP_MULT)}",
True, MUTED) True, MUTED)
self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 216))) self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 216)))
@@ -414,6 +489,7 @@ class TeamSelect:
self._draw_enemy_strip() self._draw_enemy_strip()
if self.show_slots: if self.show_slots:
self._draw_slots(self.ally_rects, self.allies, "Your Allies") self._draw_slots(self.ally_rects, self.allies, "Your Allies")
self._draw_pool_filters()
self._draw_pool() self._draw_pool()
self._draw_tower_enemies() self._draw_tower_enemies()
for i, rect in enumerate(self.preset_rects): for i, rect in enumerate(self.preset_rects):
@@ -495,6 +571,69 @@ class TeamSelect:
draw_rune_chips(self.screen, self._rune_icon, rune_colors, draw_rune_chips(self.screen, self._rune_icon, rune_colors,
rect.right - 40 - chip_w, rect.y + 32) rect.right - 40 - chip_w, rect.y + 32)
def _draw_pool_filters(self):
"""Type + role filter row above the ally pool: 'All' plus one icon per
element and per role. Left-click highlights green (include only), and
right-click highlights red (exclude that type/role)."""
self.pool_type_rects = []
self.pool_role_rects = []
y, h = 142, 24
INC = (120, 220, 120)
EXC = (235, 110, 110)
tl = self.tiny.render("Type:", True, MUTED)
self.screen.blit(tl, (290, y + 5))
x = 330
all_rect = pygame.Rect(x, y, 36, h)
active = self.pool_filter is None and self.pool_exclude_type is None
pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45),
all_rect, border_radius=4)
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
all_rect, 1, border_radius=4)
t = self.tiny.render("All", True, TEXT if active else MUTED)
self.screen.blit(t, t.get_rect(center=all_rect.center))
self.pool_type_rects.append((None, all_rect))
x += 42
for ptype in ALL_TYPES:
rect = pygame.Rect(x, y, 24, h)
inc = self.pool_filter == ptype
exc = self.pool_exclude_type == ptype
pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4)
border = INC if inc else EXC if exc else (70, 80, 70)
pygame.draw.rect(self.screen, border, rect, 2 if (inc or exc) else 1,
border_radius=4)
tic = load_type_icon(ptype, 22)
if tic:
self.screen.blit(tic, tic.get_rect(center=rect.center))
self.pool_type_rects.append((ptype, rect))
x += 28
x += 36 # a little space between the type and role filters
rl = self.tiny.render("Role:", True, MUTED)
self.screen.blit(rl, (x, y + 5))
x += 42
all_role = pygame.Rect(x, y, 36, h)
active = self.pool_role is None and self.pool_exclude_role is None
pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45),
all_role, border_radius=4)
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
all_role, 1, border_radius=4)
t = self.tiny.render("All", True, TEXT if active else MUTED)
self.screen.blit(t, t.get_rect(center=all_role.center))
self.pool_role_rects.append((None, all_role))
x += 42
for role in (ROLE_ATTACKER, ROLE_SUPPORT, ROLE_TANK):
rect = pygame.Rect(x, y, 24, h)
inc = self.pool_role == role
exc = self.pool_exclude_role == role
pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4)
border = INC if inc else EXC if exc else (70, 80, 70)
pygame.draw.rect(self.screen, border, rect, 2 if (inc or exc) else 1,
border_radius=4)
ric = self._role_icon(role, 22)
if ric:
self.screen.blit(ric, ric.get_rect(center=rect.center))
self.pool_role_rects.append((role, rect))
x += 28
def _draw_pool(self): def _draw_pool(self):
old_clip = self.screen.get_clip() old_clip = self.screen.get_clip()
self.screen.set_clip(self.pool_area) self.screen.set_clip(self.pool_area)
@@ -574,7 +713,11 @@ class TeamSelect:
name = self.detail_name name = self.detail_name
if name is None: if name is None:
return return
spec = POOL[name] is_boss_unit = bosses.is_boss(name)
spec = None if is_boss_unit else POOL[name]
boss_stats_t = bosses.boss_stats(name) if is_boss_unit else None
boss_skills = bosses.make_boss(name, 20).skills if is_boss_unit else None
boss_desc = bosses.BOSS_SPECS[name]["desc"] if is_boss_unit else None
shade = pygame.Surface((WIN_W, WIN_H)) shade = pygame.Surface((WIN_W, WIN_H))
shade.fill((0, 0, 0)) shade.fill((0, 0, 0))
shade.set_alpha(120) shade.set_alpha(120)
@@ -592,6 +735,9 @@ class TeamSelect:
self.screen.blit(ic, ic.get_rect(center=(self.detail_rect.x + 84, self.detail_rect.y + 100))) self.screen.blit(ic, ic.get_rect(center=(self.detail_rect.x + 84, self.detail_rect.y + 100)))
t = self.bold.render(name, True, TEXT) t = self.bold.render(name, True, TEXT)
self.screen.blit(t, (self.detail_rect.x + 160, self.detail_rect.y + 34)) self.screen.blit(t, (self.detail_rect.x + 160, self.detail_rect.y + 34))
if is_boss_unit:
rt = self.tiny.render("Boss", True, (235, 200, 120))
else:
rar = plant_rarity(name) rar = plant_rarity(name)
rt = self.tiny.render(RARITY_LABELS.get(rar, rar), True, RARITY_COLORS.get(rar, TEXT)) rt = self.tiny.render(RARITY_LABELS.get(rar, rar), True, RARITY_COLORS.get(rar, TEXT))
self.screen.blit(rt, (self.detail_rect.x + 160 + t.get_width() + 8, self.detail_rect.y + 38)) self.screen.blit(rt, (self.detail_rect.x + 160 + t.get_width() + 8, self.detail_rect.y + 38))
@@ -604,19 +750,34 @@ class TeamSelect:
name, (self.detail_rect.x + 160, self.detail_rect.y + 62), self.small, name, (self.detail_rect.x + 160, self.detail_rect.y + 62), self.small,
ptype=enemy_type) ptype=enemy_type)
if not self.detail_is_enemy and name in collection.owned(): if not self.detail_is_enemy and not is_boss_unit and name in collection.owned():
prog = collection.get_progress(name) prog = collection.get_progress(name)
lv = self.tiny.render(f"Level {prog['level']} · Stage {prog['stage']}", True, HINT) lv = self.tiny.render(f"Level {prog['level']} · Stage {prog['stage']}", True, HINT)
self.screen.blit(lv, (self.detail_rect.x + 160, self.detail_rect.y + 120)) self.screen.blit(lv, (self.detail_rect.x + 160, self.detail_rect.y + 120))
atk, spd, dfn, hp = compute_stats(spec["stats"], prog["level"], prog["stage"]) atk, spd, dfn, hp = compute_stats(spec["stats"], prog["level"], prog["stage"])
else: else:
atk, spd, dfn, hp = spec["stats"] atk, spd, dfn, hp = boss_stats_t if is_boss_unit else spec["stats"]
st = self.small.render(f"Atk {atk} Spd {spd}", True, TEXT) st = self.small.render(f"Atk {atk} Spd {spd}", True, TEXT)
self.screen.blit(st, (self.detail_rect.x + 160, self.detail_rect.y + 148)) self.screen.blit(st, (self.detail_rect.x + 160, self.detail_rect.y + 148))
st = self.small.render(f"Def {dfn} HP {int(hp * HP_MULT)}", True, TEXT) st = self.small.render(f"Def {dfn} HP {int(hp * HP_MULT)}", True, TEXT)
self.screen.blit(st, (self.detail_rect.x + 160, self.detail_rect.y + 174)) self.screen.blit(st, (self.detail_rect.x + 160, self.detail_rect.y + 174))
y = self.detail_rect.y + 210 y = self.detail_rect.y + 210
if is_boss_unit:
self.screen.blit(self.bold.render("Boss", True, HINT), (self.detail_rect.x + 24, y))
lines = wrap_text(" " + boss_desc, self.small, self.detail_rect.w - 48)
for i, ln in enumerate(lines):
self.screen.blit(self.small.render(ln, True, TEXT),
(self.detail_rect.x + 24, y + 22 + i * 20))
y += 26 + len(lines) * 20 + 2
for sk in boss_skills:
bracket = _bracket_text(sk)
label = f"{sk.name} [{bracket}]" if bracket else sk.name
self.screen.blit(self.bold.render(label, True, HINT), (self.detail_rect.x + 24, y))
d = self.small.render(" " + sk.description, True, TEXT)
self.screen.blit(d, (self.detail_rect.x + 24, y + 20))
y += 42
return
p = spec.get("passive", "") p = spec.get("passive", "")
if p: if p:
pname, pdesc = PASSIVES.get(p, (p, "")) pname, pdesc = PASSIVES.get(p, (p, ""))
+2 -2
View File
@@ -18,7 +18,7 @@ class PresetScreen:
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption("Plants — Team Presets") pygame.display.set_caption("Flora — Team Presets")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30) self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
@@ -67,7 +67,7 @@ class PresetScreen:
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption("Plants — Team Presets") pygame.display.set_caption("Flora — Team Presets")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30) self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
+1 -1
View File
@@ -22,7 +22,7 @@ class RelicChoiceScreen:
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption("Plants — Blessing") pygame.display.set_caption("Flora — Blessing")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 36) self.font = pygame.font.SysFont("segoeui", 36)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
+2 -2
View File
@@ -36,7 +36,7 @@ class RuneScreen:
self.plant = plant self.plant = plant
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption(f"Plants — Runes · {plant}") pygame.display.set_caption(f"Flora — Runes · {plant}")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30) self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
@@ -377,7 +377,7 @@ class RuneScreen:
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 160)) overlay.fill((0, 0, 0, 160))
self.screen.blit(overlay, (0, 0)) self.screen.blit(overlay, (0, 0))
w, h = 560, 400 w, h = 560, 450
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h) panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
pygame.draw.rect(self.screen, PANEL, panel, border_radius=12) pygame.draw.rect(self.screen, PANEL, panel, border_radius=12)
pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12) pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12)
+3 -3
View File
@@ -25,8 +25,8 @@ TYPE_COLORS = {
"radiant": (245, 200, 90), "radiant": (245, 200, 90),
"nocturnal": (150, 120, 220), "nocturnal": (150, 120, 220),
"ice": (130, 200, 235), "ice": (130, 200, 235),
"garden": (120, 200, 120), "garden": (235, 130, 170),
"wildflower": (235, 130, 170), "wildflower": (120, 200, 120),
} }
@@ -35,7 +35,7 @@ class StarterScreen:
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption("Plants — Choose your starting pinwheel") pygame.display.set_caption("Flora — Choose your starting pinwheel")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30) self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
+2 -2
View File
@@ -21,7 +21,7 @@ class ConfirmScreen:
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption("Plants — Tower") pygame.display.set_caption("Flora — Tower")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 30) self.font = pygame.font.SysFont("segoeui", 30)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
@@ -81,7 +81,7 @@ class TowerScreen:
pygame.init() pygame.init()
self.window, self.scale = make_window(WIN_W, WIN_H) self.window, self.scale = make_window(WIN_W, WIN_H)
self.screen = pygame.Surface((WIN_W, WIN_H)) self.screen = pygame.Surface((WIN_W, WIN_H))
pygame.display.set_caption("Plants — Tower") pygame.display.set_caption("Flora — Tower")
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("segoeui", 36) self.font = pygame.font.SysFont("segoeui", 36)
self.bold = pygame.font.SysFont("segoeui", 20, bold=True) self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
+2 -2
View File
@@ -7,8 +7,8 @@ TYPE_COLORS = {
"radiant": (245, 200, 90), "radiant": (245, 200, 90),
"nocturnal": (150, 120, 220), "nocturnal": (150, 120, 220),
"ice": (130, 200, 235), "ice": (130, 200, 235),
"garden": (120, 200, 120), "garden": (235, 130, 170),
"wildflower": (235, 130, 170), "wildflower": (120, 200, 120),
} }
STRONG = (120, 210, 120) STRONG = (120, 210, 120)
WEAK = (225, 130, 130) WEAK = (225, 130, 130)