diff --git a/README.md b/README.md index 9370165..6fa3c40 100644 --- a/README.md +++ b/README.md @@ -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 starts small and ramps up. diff --git a/assets/sprites/daisy.png b/assets/sprites/daisy.png deleted file mode 100644 index 55c76e1..0000000 Binary files a/assets/sprites/daisy.png and /dev/null differ diff --git a/assets/sprites/fairy.png b/assets/sprites/fairy.png deleted file mode 100644 index 2e38944..0000000 Binary files a/assets/sprites/fairy.png and /dev/null differ diff --git a/assets/sprites/gnome.png b/assets/sprites/gnome.png deleted file mode 100644 index 44a762d..0000000 Binary files a/assets/sprites/gnome.png and /dev/null differ diff --git a/assets/sprites/poppy.png b/assets/sprites/poppy.png deleted file mode 100644 index 5bf83ae..0000000 Binary files a/assets/sprites/poppy.png and /dev/null differ diff --git a/assets/sprites/snowdrop.png b/assets/sprites/snowdrop.png deleted file mode 100644 index cc085fd..0000000 Binary files a/assets/sprites/snowdrop.png and /dev/null differ diff --git a/engine/battle.py b/engine/battle.py index 2674095..7ba64c4 100644 --- a/engine/battle.py +++ b/engine/battle.py @@ -9,6 +9,7 @@ from .skills import ( 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_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, TARGET_ALL_ALLIES, TARGET_ALL_ENEMIES, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY, TARGET_SELF, TARGET_TWO_ENEMIES, Skill, roll_crit, @@ -135,6 +136,7 @@ class Battle: def _crit(self, unit: Unit) -> float: pct = unit.crit * 100 - self._status_value(unit, S_CRIT_DOWN) + pct += self._rune_pct(unit, "crit") * 100 return max(0.0, pct / 100) # --- tank protection ---------------------------------------------- @@ -278,6 +280,15 @@ class Battle: del unit.status[key] else: 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 ------------------------------------------------ def _tick_dot(self, unit: Unit) -> None: @@ -371,13 +382,20 @@ class Battle: blue = self._rune_pct(target, "shield") if 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: src = f" from {source.name}" if source else "" self.log.append(f"{target.name} gains {amount} shield{src}") if self._action is not None: 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: """Shield amounts scale with the caster's Attack so they keep up with growing health pools (like the Sunflower passive).""" @@ -396,7 +414,7 @@ class Battle: if weaken: mult *= 1 - weaken / 100.0 # 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 if any(u.passive == "tulip_equinox" for u in team): mult *= 1.1 @@ -507,10 +525,25 @@ class Battle: def _absorb(self, attacker: Optional[Unit], target: Unit, dmg: int) -> int: self._consume_hits_statuses(target) 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 >= 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.events.append({"type": "shield", "target": target, "amount": dmg}) if target.passive == "camelia_frigid": @@ -674,6 +707,8 @@ class Battle: if skill.requires_form and skill.requires_form != user.status.get("form"): return [] 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)) if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES): return self.foes_of(user) @@ -755,6 +790,10 @@ class Battle: return False if skill.cooldown_left > 0: 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"): return False 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": skill.power += self._status_value(user, S_LYING_WAIT) 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): targets = self.foes_of(user) if skill.target == TARGET_TWO_ENEMIES: @@ -931,9 +972,12 @@ class Battle: else: self._apply_wilt_damage(target, int(dealt * 0.3)) if skill.mechanic == "heal_random_ally": - allies = self.allies_of(user) - if allies: - self._heal(user, random.choice(allies), 40) + # Only pick from allies that are missing HP, so the heal always + # lands on someone it can actually help (a random full-HP ally + # 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": self._strip_buffs(target) if skill.mechanic == "tether_lowest": @@ -942,6 +986,12 @@ class Battle: low = min(allies, key=lambda a: a.hp) low.status[S_TETHER] = (1, 1) 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: for k in (S_ATTACK_UP, S_DEFENSE_UP, S_SPEED_UP, S_SHIELD): diff --git a/engine/bosses.py b/engine/bosses.py new file mode 100644 index 0000000..9b2c938 --- /dev/null +++ b/engine/bosses.py @@ -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 \ No newline at end of file diff --git a/engine/collection.py b/engine/collection.py index 6ad102d..c0bb8a8 100644 --- a/engine/collection.py +++ b/engine/collection.py @@ -49,6 +49,8 @@ def init_starter_pinwheel(ptype: str) -> bool: "fertilizer_accrued_at": 0.0, "auto_farmer": False, "auto_farmer_active": False, + "auto_planter": False, + "auto_planter_active": False, "coins": 0, "coins_accrued_at": 0.0, "water_used": 0, @@ -59,6 +61,7 @@ def init_starter_pinwheel(ptype: str) -> bool: "open_history": [], "lesser_opened": 0, "floor": 1, + "bosses_fought": [], "soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []}, } save(data) @@ -80,6 +83,8 @@ def _default() -> dict: "fertilizer_accrued_at": 0.0, "auto_farmer": False, "auto_farmer_active": False, + "auto_planter": False, + "auto_planter_active": False, "coins": 0, "coins_accrued_at": 0.0, "water_used": 0, @@ -91,6 +96,7 @@ def _default() -> dict: "lesser_opened": 0, "soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []}, "floor": 1, + "bosses_fought": [], } @@ -128,6 +134,9 @@ def load() -> dict: data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) data.setdefault("floor", 1) 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 # color names (blue/red/white/yellow) instead of the current gems. 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: prog = data["progress"][n] 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) - gain = (xp + bonus) // 3 if (xp + bonus) > 0 else 0 + # all XP gains are cut to a sixth (half of the old third) so leveling + # takes twice as long; water (a direct +1 level) is unaffected + gain = (xp + bonus) // 6 if (xp + bonus) > 0 else 0 prog["xp"] += gain while prog["level"] < MAX_LEVEL and 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_PER_HOUR = 1 # test build: earn 1 fertilizer every real hour AUTO_FARMER_COST = 100 # coins (WIP value) +AUTO_PLANTER_COST = 200 # coins: plants retrieved seeds automatically def add_fertilizer(amount: int) -> None: @@ -404,6 +415,37 @@ def buy_auto_farmer() -> str: 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): """Buy a random rune of `tier` (1 = common types, 2 = brown) for coins. 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) +# --- 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: """Permanent rune slots for `name` (1 + evolutions ever achieved).""" data = data or load() @@ -544,20 +610,20 @@ def free_runes(data=None) -> list: def reroll_rune(rune_id: int, sacrificed_ids) -> str: """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 - removed from their plants. Returns '' on success or a reason string.""" + above its max roll), sacrificing runes.RUNE_SACRIFICE_COST runes. Equipped + sacrifices are removed from their plants. Returns '' on success or a reason.""" data = load() rune_list = data.get("runes", []) target = next((r for r in rune_list if r["id"] == rune_id), None) if not target: 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) if current >= max_pct: return "Already max roll" sac_set = set(sacrificed_ids) - if len(sac_set) != 10: - return "Pick exactly 10 runes to sacrifice" + if len(sac_set) != runes.RUNE_SACRIFICE_COST: + return f"Pick exactly {runes.RUNE_SACRIFICE_COST} runes to sacrifice" if rune_id in sac_set: return "Cannot sacrifice the rune being upgraded" by_id = {r["id"]: r for r in rune_list} @@ -629,20 +695,6 @@ def add_tower_packs(count: int) -> None: 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: """Evolve at the milestone level by consuming duplicates.""" data = load() @@ -750,7 +802,7 @@ def add_lesser_pack() -> 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() packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES}) 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 +# 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: """Total number of battle wins the player has recorded.""" return load().get("wins", 0) @@ -1189,6 +1268,23 @@ def auto_farm() -> str: 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: """Harvest a grown plot, gaining a copy (duplicate) of the plant.""" data = load() diff --git a/engine/plants.py b/engine/plants.py index 4f36782..3af56f9 100644 --- a/engine/plants.py +++ b/engine/plants.py @@ -281,7 +281,7 @@ POOL: Dict[str, dict] = { ]), "Butterfly": dict(role=ROLE_SUPPORT, type=TYPE_GARDEN, stats=(30, 40, 26, 40), 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)"), _sk("Cocoon", power=25, kind="shield", target=TARGET_ALLY, cd=2, desc="Shield an ally (scales with Attack)"), diff --git a/engine/relics.py b/engine/relics.py index 1dd13cf..8c5d410 100644 --- a/engine/relics.py +++ b/engine/relics.py @@ -5,9 +5,9 @@ from typing import Callable, Iterable, List, Optional from . import runes 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_SPEED_UP, S_VULNERABLE, + S_SPEED_UP, S_VULNERABLE, SHIELD_DURATION, ) @@ -70,7 +70,7 @@ def _crit(pct): def _shield(pct): def apply(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 diff --git a/engine/roster.py b/engine/roster.py index 554d6f2..c70355a 100644 --- a/engine/roster.py +++ b/engine/roster.py @@ -20,6 +20,12 @@ FLOOR_RARE_PLANTS = ["Marigold", "Daffodil", "Peony", "Primrose", # Low-rarity tank plants used as enemy tanks on later floors. 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: """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 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 - 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) # 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. @@ -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]: """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 - type; plants keep their own fixed type.""" - import random + always the type the player is strong against; other critters get a fixed type + derived from the floor (no rerolling — the same floor always fields the same + team, and duplicate types within a team are allowed); plants keep their own + fixed type.""" + from . import bosses types = [] 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: types.append(TYPE_BEATS.get(player_type, "garden")) else: - types.append(random.choice(ALL_TYPES)) + types.append(ALL_TYPES[(floor * 7 + i * i) % len(ALL_TYPES)]) else: types.append(POOL[n]["type"]) return types def floor_enemy_level(floor: int) -> int: - """Enemy level for a floor (ramps toward ~20 at floor 100).""" - return 1 + (floor - 1) // 5 + """Enemy level for a floor (ramps one level every FLOOR_ENEMY_LEVEL_STEP + 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]: @@ -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]: """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) stage = max(1, min(5, 1 + (floor - 1) // 20)) - return [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t) - for n, t in zip(names, types)] + units = [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t) + 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]: diff --git a/engine/runes.py b/engine/runes.py index 667c966..ca994ae 100644 --- a/engine/runes.py +++ b/engine/runes.py @@ -3,18 +3,19 @@ from __future__ import annotations import random # 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 = { "sapphire": "Sapphire Rune", "ruby": "Ruby Rune", "amethyst": "Amethyst Rune", "jade": "Jade 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") -TIER2_COLORS = ("brown",) +TIER2_COLORS = ("brown", "pearl") RUNE_TIERS = {c: 1 for c in TIER1_COLORS} RUNE_TIERS.update({c: 2 for c in TIER2_COLORS}) @@ -25,6 +26,7 @@ RUNE_ICON_FILES = { "jade": "jade_rune", "amethyst": "amethyst_rune", "brown": "rune_brown", + "pearl": "pearl_rune", } # 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 # 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 = { "ruby": "damage", # +damage dealt "sapphire": "shield", # +shielding "jade": "healing", # +healing received "amethyst": "damage_taken", # -damage taken "brown": "wilt", # damage dealt also Wils the target + "pearl": "crit", # +critical hit chance } PCT_MIN = 25 PCT_MAX = 35 BROWN_PCT_MIN = 5 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 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 T1_RUNE_COST = 100 T2_RUNE_COST = 150 -# brown drops 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} +# 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, "pearl": 0.75} _EFFECT_TEXTS = { "ruby": "+{pct}% damage dealt", "sapphire": "+{pct}% shielding", "jade": "+{pct}% healing", "amethyst": "-{pct}% damage taken", "brown": "deals {pct}% of damage as Wilt", + "pearl": "+{pct}% critical chance", } RUNE_SUMMARIES = { "ruby": "Increases damage dealt", @@ -75,6 +85,7 @@ RUNE_SUMMARIES = { "jade": "Increases healing received", "amethyst": "Reduces damage taken", "brown": "Deals a portion of damage as Wilt", + "pearl": "Increases critical hit chance", } MAX_RUNE_SLOTS = 3 @@ -91,12 +102,23 @@ def random_color() -> str: 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": 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) +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: return RUNE_NAMES.get(color, "Rune") @@ -106,11 +128,11 @@ def tier(color: str) -> int: def roll_ratio(rune) -> float: - """How close a rune's roll is to its own maximum (0..1). Brown's 5-15 range - is compared to its 15 cap, so a maxed brown ranks equally with a maxed - yellow even though 15 < 35.""" - max_pct = BROWN_PCT_MAX if rune.get("color") == "brown" else PCT_MAX - return rune.get("pct", 0) / max_pct + """How close a rune's roll is to its own maximum (0..1). Each color is + compared to its own cap, so a maxed brown or pearl ranks equally with a + maxed ruby even though 15 < 35.""" + cap = max_pct(rune.get("color")) + return rune.get("pct", 0) / cap def effect_text(color: str, pct: int) -> str: @@ -124,6 +146,7 @@ _CARD_TEXTS = { "jade": "+{pct}% heal", "amethyst": "-{pct}% taken", "brown": "Wilt {pct}%", + "pearl": "+{pct}% crit", } @@ -149,9 +172,11 @@ def summary(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": 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}%" diff --git a/engine/skills.py b/engine/skills.py index 3ed33ee..cb06915 100644 --- a/engine/skills.py +++ b/engine/skills.py @@ -110,6 +110,7 @@ S_WILT = "wilt" S_DOT = "dot" S_HOT = "hot" S_SHIELD = "shield" +SHIELD_DURATION = 3 # turns a shield lasts before decaying (per application, doesn't refresh) S_ICICLES = "icicles" S_SNOWFLAKES = "snowflakes" S_ZINNIA_WEAKEN = "zinnia_weaken" diff --git a/ideas/test_ignore_me.txt b/ideas/test_ignore_me.txt new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py index 743ab15..f812dee 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ from collections import Counter from assets import icon -from engine import collection, runes +from engine import bosses, collection, runes from engine.battle import Battle 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 @@ -24,6 +24,7 @@ def _pack_reward_items(pack_types): }) return items +#test_ignore def _rune_reward_item(rune): """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): """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 - 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() wins = collection.record_win() if wins % 5 == 0: @@ -61,6 +63,12 @@ def _battle_win_reward(floor): rune = collection.roll_battle_rune() if 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 @@ -255,7 +263,10 @@ def main(): floor = collection.floor_progress() enemy_names = floor_enemy_names(floor, player_type) 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 ts = TeamSelect(initial_enemies=enemy_names, difficulty=floor, enemy_types=enemy_types, no_roll=True, @@ -269,7 +280,7 @@ def main(): allies, enemies = result 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) enemy_units = build_floor_enemies(enemy_names, enemy_types, floor) battle = Battle(players, enemy_units) diff --git a/tests/test_battle.py b/tests/test_battle.py index 2abb199..b0eaa1a 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -155,6 +155,28 @@ class ShieldWiltDotTest(unittest.TestCase): self.assertEqual(t.hp, 100) 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): 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) @@ -194,6 +216,45 @@ class ShieldWiltDotTest(unittest.TestCase): self.assertEqual(t.hp, 410) 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): def test_sleep_skips_until_two_hits(self): @@ -487,7 +548,7 @@ class MechanicsTest(unittest.TestCase): enemy.status["wilt"] = (30, 3) enemy.status["dot"] = [(10, 2)] 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) def test_zinnia_targets_most_effect_rich_enemy(self): @@ -743,5 +804,48 @@ class TowerInjectionTest(unittest.TestCase): 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__": unittest.main() \ No newline at end of file diff --git a/tests/test_bosses.py b/tests/test_bosses.py new file mode 100644 index 0000000..7de290f --- /dev/null +++ b/tests/test_bosses.py @@ -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() \ No newline at end of file diff --git a/tests/test_relics.py b/tests/test_relics.py index dce12f8..ac7230a 100644 --- a/tests/test_relics.py +++ b/tests/test_relics.py @@ -103,7 +103,8 @@ class RelicApplyTest(unittest.TestCase): def test_shield_relic(self): b = _battle("iron_bark") 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): base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1) @@ -156,7 +157,8 @@ class RelicApplyTest(unittest.TestCase): def test_multiple_relics_stack(self): b = _battle("iron_bark", "morning_dew", "chilling_gust") 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)) for u in b.enemies: self.assertEqual(u.status["speed_down"], (8, 1)) diff --git a/tests/test_runes.py b/tests/test_runes.py index 65e347b..9301c0a 100644 --- a/tests/test_runes.py +++ b/tests/test_runes.py @@ -131,9 +131,9 @@ class RuneTest(unittest.TestCase): self.assertIn(r2["id"], free) self.assertNotIn(r1["id"], free) - def _ten_runes(self, data): + def _sac_runes(self, data): ids = [] - for _ in range(10): + for _ in range(runes.RUNE_SACRIFICE_COST): ids.append(collection.add_rune("sapphire", data=data, save_now=False)["id"]) return ids @@ -141,20 +141,20 @@ class RuneTest(unittest.TestCase): r = collection.add_rune("ruby") data = collection.load() data["runes"][0]["pct"] = 25 - sac = self._ten_runes(data) + sac = self._sac_runes(data) collection.save(data) err = collection.reroll_rune(r["id"], sac) self.assertEqual(err, "") new_pct = collection.load()["runes"][0]["pct"] self.assertGreaterEqual(new_pct, 26) # at least 1% better 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): r = collection.add_rune("brown") data = collection.load() data["runes"][0]["pct"] = 5 - sac = self._ten_runes(data) + sac = self._sac_runes(data) collection.save(data) err = collection.reroll_rune(r["id"], sac) self.assertEqual(err, "") @@ -166,27 +166,27 @@ class RuneTest(unittest.TestCase): r = collection.add_rune("ruby") data = collection.load() data["runes"][0]["pct"] = 35 - sac = self._ten_runes(data) + sac = self._sac_runes(data) collection.save(data) err = collection.reroll_rune(r["id"], sac) 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") data = collection.load() data["runes"][0]["pct"] = 25 - sac = self._ten_runes(data) + sac = self._sac_runes(data) collection.save(data) - self.assertEqual(collection.reroll_rune(r["id"], sac[:9]), - "Pick exactly 10 runes to sacrifice") + self.assertEqual(collection.reroll_rune(r["id"], sac[:runes.RUNE_SACRIFICE_COST - 1]), + f"Pick exactly {runes.RUNE_SACRIFICE_COST} runes to sacrifice") def test_reroll_rejects_target_in_sacrifices(self): r = collection.add_rune("ruby") data = collection.load() data["runes"][0]["pct"] = 25 - sac = self._ten_runes(data) - sac[0] = r["id"] # 10 unique runes, but includes the upgrade target + sac = self._sac_runes(data) + sac[0] = r["id"] # unique runes, but includes the upgrade target collection.save(data) self.assertEqual(collection.reroll_rune(r["id"], sac), "Cannot sacrifice the rune being upgraded") @@ -196,7 +196,7 @@ class RuneTest(unittest.TestCase): r2 = collection.add_rune("sapphire") data = collection.load() data["runes"][0]["pct"] = 25 - sac = self._ten_runes(data) + sac = self._sac_runes(data) sac[0] = r2["id"] # sacrifice the equipped rune collection.save(data) 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(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): 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.start() 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): heal = Skill("Mend", power=40, kind="heal", target="ally") @@ -412,6 +422,25 @@ class RuneBattleEffectTest(unittest.TestCase): b.use_skill(a, hit, t) 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__": unittest.main() \ No newline at end of file diff --git a/tests/test_soil.py b/tests/test_soil.py index 8df3f38..5cc9d3b 100644 --- a/tests/test_soil.py +++ b/tests/test_soil.py @@ -219,6 +219,8 @@ class AutoFarmerTest(unittest.TestCase): data["fertilizer"] = 10 data["coins"] = 100 data["auto_farmer"] = False + data["auto_planter"] = False + data["auto_planter_active"] = False collection.save(data) def _stalled_plot(self): @@ -308,6 +310,100 @@ class AutoFarmerTest(unittest.TestCase): self.assertEqual(len(state["seeds"]), 1) # seed not planted 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__": unittest.main() \ No newline at end of file diff --git a/ui/extract.py b/ui/extract.py index f955461..e53f824 100644 --- a/ui/extract.py +++ b/ui/extract.py @@ -27,7 +27,7 @@ class ExtractionScreen: pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 30) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) diff --git a/ui/main.py b/ui/main.py index 041127e..d015913 100644 --- a/ui/main.py +++ b/ui/main.py @@ -4,10 +4,11 @@ from assets import icon, load_sprite from engine import collection, runes from engine.battle import Battle from engine.dominance import estimate_dominance, resolve_instantly -from engine.plants import PASSIVES, RARITY_COLORS, RARITY_LABELS, plant_rarity +from engine.growth import EVOLVE_COST, EVOLVE_STAGE_AT_LEVEL, can_evolve +from engine.plants import PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS, plant_rarity from engine.relics import RELIC_POOL from engine.skills import EFF_DOT, EFF_HOT, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY -from engine.types import type_multiplier +from engine.types import TYPE_BEATS, type_multiplier from engine.unit import ROLE_NAMES, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER from ui.menu import _bracket_text, _draw_evo_badge from ui.relics_overview import RelicsOverview @@ -19,8 +20,8 @@ TYPE_COLORS = { "radiant": (245, 200, 90), "nocturnal": (150, 120, 220), "ice": (130, 200, 235), - "garden": (120, 200, 120), - "wildflower": (235, 130, 170), + "garden": (235, 130, 170), + "wildflower": (120, 200, 120), } WIN_W = 1040 WIN_H = 720 @@ -64,13 +65,13 @@ class Game: self.stage = stage self.relics = list(relics) if tower and stage: - caption = f"Plants — Tower — Floor {stage}" + caption = f"Flora — Tower — Floor {stage}" elif stage: - caption = f"Plants — Floor {stage}" + caption = f"Flora — Floor {stage}" elif tower: - caption = "Plants — Tower Battle" + caption = "Flora — Tower Battle" else: - caption = "Plants — 4v4 Turn Battle (prototype)" + caption = "Flora — 4v4 Turn Battle (prototype)" pygame.display.set_caption(caption) self.clock = pygame.time.Clock() self.battle = battle @@ -108,6 +109,12 @@ class Game: self._dt = 0.0 self.back_rect = pygame.Rect(12, 12, 90, 32) self.auto_btn = pygame.Rect(112, 12, 90, 32) + # "Back" confirmation modal + transient tank-redirect notice. + self.confirm_back = False + self.confirm_ok_rect = pygame.Rect(0, 0, 0, 0) + self.confirm_no_rect = pygame.Rect(0, 0, 0, 0) + self.tank_notice = "" + self.tank_notice_until = 0 # Auto-battle unlocks once the player has cleared floor 20 in the # normal stage battles (floor_progress counts the next unbeaten floor). self.auto_unlocked = collection.floor_progress() > 20 @@ -206,6 +213,13 @@ class Game: running = False elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: pos = to_logical(event.pos, self.scale) + if self.confirm_back: + if self.confirm_ok_rect.collidepoint(pos): + self.confirm_back = False + self.battle.winner = TEAM_ENEMY # insta-lose + elif self.confirm_no_rect.collidepoint(pos): + self.confirm_back = False + continue if self.auto_unlocked and self.auto_btn.collidepoint(pos): self.auto_battle = not self.auto_battle continue @@ -219,8 +233,11 @@ class Game: self.reward_acknowledged = True continue if self.back_rect.collidepoint(pos): - outcome = "back" - running = False + if self.battle.winner is None: + self.confirm_back = True + else: + outcome = "back" + running = False else: self._handle_click(pos) elif event.type == pygame.MOUSEMOTION: @@ -294,18 +311,27 @@ class Game: if rect.collidepoint(pos): if sk.cooldown_left > 0: return + if cur.acted and not sk.extra_action: + return # already acted this turn; only extra-action skills remain if oblivion and sk is not weakest: return if sk.target in (TARGET_ENEMY, TARGET_ALLY, TARGET_ANY): - valid = b.valid_skill_targets(cur, sk) - if len(valid) == 1: - # Only one thing it can hit (e.g. last enemy alive, or a - # tank blocking everything): use it straight away. - self.pending_skill = None - self._cast(sk, valid[0]) + if self.pending_skill is sk: + # Clicking the selected skill AGAIN casts it (even if only + # one target is available). With several valid targets the + # player still needs to click one. + valid = b.valid_skill_targets(cur, sk) + if len(valid) == 1: + self.pending_skill = None + self._cast(sk, valid[0]) + else: + self.pending_skill = sk else: - self.pending_skill = sk if self.pending_skill is not sk else None + # First click only SELECTS the skill (it must be clicked + # again, or a target clicked, to cast). + self.pending_skill = sk else: + # No target needed (self / AoE): cast right away. self.pending_skill = None self._cast(sk, None) return @@ -319,8 +345,12 @@ class Game: if self.pending_skill: target = self._resolve_target(unit, self.pending_skill) if target: + if self._was_tank_redirect(unit, self.pending_skill, target): + self._set_tank_notice() self._cast(self.pending_skill, target) - self.pending_skill = None + self.pending_skill = None + # Invalid target (e.g. clicking an enemy with an ally heal/shield + # selected): do nothing and KEEP the skill selected. return if unit.team != TEAM_PLAYER: best = next((s for s in sorted(cur.skills, key=lambda s: s.priority) @@ -328,8 +358,19 @@ class Game: if best: target = self._resolve_target(unit, best) if target: + if self._was_tank_redirect(unit, best, target): + self._set_tank_notice() self._cast(best, target) + def _was_tank_redirect(self, unit, skill, target): + """True when `unit` (a backline enemy) was redirected to a tank.""" + return (skill.target == "enemy" and unit.team != TEAM_PLAYER + and unit.alive and unit.role != "tank" and unit is not target) + + def _set_tank_notice(self): + self.tank_notice = "A tank is protecting that unit — attack the tank first!" + self.tank_notice_until = pygame.time.get_ticks() + 3000 + def _resolve_target(self, unit, skill): b = self.battle if unit in b.valid_skill_targets(b.current, skill): @@ -380,6 +421,8 @@ class Game: def _update(self, dt): b = self.battle + if self.tank_notice and pygame.time.get_ticks() >= self.tank_notice_until: + self.tank_notice = "" self._consume_events() self._advance_hit_queue(dt) if b.winner is not None: @@ -603,9 +646,9 @@ class Game: elif self.stage: title_text = f"Floor {self.stage}" elif self.tower: - title_text = "Plants — Tower Battle" + title_text = "Flora — Tower Battle" else: - title_text = "Plants — 4v4 Turn Battle" + title_text = "Flora — 4v4 Turn Battle" title = self.font.render(title_text, True, TEXT) self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 24))) pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) @@ -636,6 +679,75 @@ class Game: self._draw_reward_popup() if self.relic_overview is not None: self.relic_overview.draw(self.mouse_pos) + if self.tank_notice and pygame.time.get_ticks() < self.tank_notice_until: + self._draw_tank_notice() + if self.confirm_back: + self._draw_confirm_back() + + def _draw_tank_notice(self): + t = self.small.render(self.tank_notice, True, (255, 210, 90)) + bg = pygame.Surface((t.get_width() + 24, t.get_height() + 10), pygame.SRCALPHA) + bg.fill((20, 24, 20, 210)) + rect = t.get_rect(center=(WIN_W // 2, 96)) + self.screen.blit(bg, (rect.x - 12, rect.y - 5)) + self.screen.blit(t, rect) + + def _draw_confirm_back(self): + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 140)) + self.screen.blit(overlay, (0, 0)) + w, h = 460, 170 + panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h) + pygame.draw.rect(self.screen, (32, 38, 32), panel, border_radius=12) + pygame.draw.rect(self.screen, (150, 70, 70), panel, 2, border_radius=12) + title = self.bold.render("Leave the battle?", True, TEXT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + msg = self.small.render("You will lose this battle.", True, MUTED) + self.screen.blit(msg, msg.get_rect(center=(panel.centerx, panel.y + 58))) + self.confirm_ok_rect = pygame.Rect(panel.centerx - 120, panel.bottom - 54, 100, 36) + self.confirm_no_rect = pygame.Rect(panel.centerx + 20, panel.bottom - 54, 100, 36) + pygame.draw.rect(self.screen, (150, 70, 70), self.confirm_ok_rect, border_radius=8) + ok = self.bold.render("Leave", True, TEXT) + self.screen.blit(ok, ok.get_rect(center=self.confirm_ok_rect.center)) + pygame.draw.rect(self.screen, BTN, self.confirm_no_rect, border_radius=8) + no = self.bold.render("Cancel", True, TEXT) + self.screen.blit(no, no.get_rect(center=self.confirm_no_rect.center)) + + def _defeat_tips(self): + """Contextual tips for the defeat screen, based on the player's state + and the units they just brought into the lost battle.""" + tips = [] + if len(collection.owned()) < len(POOL) and sum(collection.pack_counts().values()) > 0: + tips.append("Try to open packs to gain more allies!") + if collection.available_water() >= 1: + tips.append("You can use water to level up your plants and critters.") + if collection.free_runes(): + tips.append("You can use runes to improve your fighting abilities.") + used = list(self.battle.players) + enemy_types = [e.plant_type for e in self.battle.enemies] + for u in used: + prog = collection.get_progress(u.name) + if can_evolve(prog["level"], prog["stage"], prog.get("duplicates", 0)): + tips.append("Try evolving your plants for better stats.") + break + for u in used: + for etype in enemy_types: + if TYPE_BEATS.get(etype) == u.plant_type: + tips.append(f"Make sure to use the types to your advantage! " + f"{u.plant_type} is weak against {etype}.") + break + else: + continue + break + for u in used: + prog = collection.get_progress(u.name) + target = prog["stage"] + 1 + if (target in EVOLVE_STAGE_AT_LEVEL + and prog["level"] >= EVOLVE_STAGE_AT_LEVEL[target] + and prog.get("duplicates", 0) < EVOLVE_COST[target]): + tips.append("You can plant seeds from your plants to gain more of them.") + break + return tips def _draw_reward_popup(self): b = self.battle @@ -656,8 +768,11 @@ class Game: kind = it.get("kind", "") # Display scaled to match the rest of the UI (aspect preserved). img = fit_surface(img, 60 if kind == "rune" else 80) - tiles.append((img, label, img.get_width(), img.get_height() + 20, - kind == "rune")) + # Tile width must also fit the label, or a rune + pack in the + # same row overlap (the rune's label spills into the pack tile). + label_w = self.bold.size(label)[0] + tiles.append((img, label, max(img.get_width(), label_w), + img.get_height() + 20, kind == "rune")) else: tiles.append((None, label, max(90, self.bold.size(label)[0] + 20), 30, False)) @@ -698,7 +813,9 @@ class Game: if target >= max(img.get_width(), img.get_height()): continue img2 = fit_surface(img, target) - tiles[i] = (img2, t[1], img2.get_width(), img2.get_height() + 20, t[4]) + label_w = self.bold.size(t[1])[0] + tiles[i] = (img2, t[1], max(img2.get_width(), label_w), + img2.get_height() + 20, t[4]) changed = True if not changed: break @@ -706,6 +823,14 @@ class Game: panel_w = max(440, max_row_w + pad * 2) panel_h = head_h + tiles_h + 46 + # Defeat tips: contextual advice shown in the popup's text box. + tip_lines = [] + if b.winner != TEAM_PLAYER: + for tip in self._defeat_tips(): + tip_lines.extend(wrap_text(tip, self.small, panel_w - pad * 2)) + tips_h = len(tip_lines) * 20 + (14 if tip_lines else 0) + panel_h += tips_h + panel = pygame.Rect((WIN_W - panel_w) // 2, (WIN_H - panel_h) // 2, panel_w, panel_h) pygame.draw.rect(self.screen, (32, 38, 32), panel, border_radius=12) border = (150, 190, 150) if b.winner == TEAM_PLAYER else (200, 90, 90) @@ -722,7 +847,8 @@ class Game: x = panel.centerx - total // 2 for img, label, tw, th, _ in r: if img is not None: - self.screen.blit(img, (x, y)) + self.screen.blit(img, img.get_rect(center=(x + tw // 2, + y + img.get_height() // 2))) lr = self.bold.render(label, True, HINT) self.screen.blit(lr, lr.get_rect(center=(x + tw // 2, y + img.get_height() + 10))) else: @@ -732,6 +858,12 @@ class Game: self.screen.blit(t, t.get_rect(center=box.center)) x += tw + gap y += row_h[i] + 14 + if tip_lines: + ty = panel.y + head_h + tiles_h + 10 + for line in tip_lines: + t = self.small.render(line, True, (215, 220, 215)) + self.screen.blit(t, (panel.x + pad, ty)) + ty += 20 hint = self.tiny.render("Click to continue", True, (150, 155, 150)) self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 16))) @@ -778,6 +910,8 @@ class Game: parts.append(f"Heal {sum(a for a, _ in stacks)}/t") if "form" in unit.status: parts.append(unit.status["form"].capitalize()) + if unit.status.get("boss_stacks", 0): + parts.append(f"Power {unit.status['boss_stacks']}/3") return " | ".join(parts) def _draw_cards(self): @@ -804,10 +938,11 @@ class Game: bar_color = (80, 220, 90) if pct > 0.5 else (220, 180, 40) if pct > 0.25 else (220, 60, 60) fill = int(bar_w * pct) pygame.draw.rect(self.screen, bar_color, (hp_x, rect.y + 7, fill, 10)) - shield = unit.status.get("shield", 0) + shield = sum(amount for amount, _ in unit.status.get("shield", [])) if shield > 0 and fill > 0: + # The shield bar shows the FULL shield value, independent of the + # unit's current HP (a low-HP unit still shows its whole shield). sh_w = int(bar_w * min(1.0, shield / unit.max_hp)) - sh_w = min(sh_w, fill) if sh_w > 0: tint = pygame.Surface((sh_w, 10), pygame.SRCALPHA) tint.fill((120, 185, 240, 140)) @@ -1046,7 +1181,7 @@ class Game: if ready: oblivion_skill = min(ready, key=lambda s: s.power) for i, (sk, rect) in enumerate(self.skill_rects): - ready = sk.cooldown_left <= 0 + ready = sk.cooldown_left <= 0 and (not unit.acted or sk.extra_action) blocked = oblivion and sk is not oblivion_skill if sk is self.pending_skill: bg = (120, 120, 40) diff --git a/ui/menu.py b/ui/menu.py index 31b4b4f..42df3d9 100644 --- a/ui/menu.py +++ b/ui/menu.py @@ -4,7 +4,7 @@ import pygame from assets import icon as icon_path from assets import load_sprite -from engine import collection +from engine import bosses, collection from engine import runes 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, @@ -34,8 +34,8 @@ TYPE_COLORS = { "radiant": (245, 200, 90), "nocturnal": (150, 120, 220), "ice": (130, 200, 235), - "garden": (120, 200, 120), - "wildflower": (235, 130, 170), + "garden": (235, 130, 170), + "wildflower": (120, 200, 120), } def _draw_star(screen, center, radius, color): import math @@ -84,7 +84,7 @@ class Menu: pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 36) 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.runes_rect = pygame.Rect(480, 352, 170, 30) 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_info_rect = pygame.Rect(WIN_W - 100, 16, 84, 34) self.runes_deconstruct = False @@ -153,6 +154,11 @@ class Menu: self.redeem_rect = pygame.Rect(WIN_W - 250, 50, 234, 30) self.history_rect = pygame.Rect(WIN_W - 250, 84, 234, 26) 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.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)} @@ -178,8 +184,9 @@ class Menu: self.coll_sort = None self.collection_tab = "plants" self.collection_tab_btns = { - "plants": pygame.Rect(WIN_W // 2 - 170, 100, 150, 30), - "runes": pygame.Rect(WIN_W // 2 + 20, 100, 150, 30), + "plants": pygame.Rect(WIN_W // 2 - 250, 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_color = None @@ -206,27 +213,18 @@ class Menu: "level": pygame.Rect(767, 50, 56, 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_fert_info = False self.water_info = False self.soil_notice = "" 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() def _reinit(self): pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 36) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) @@ -293,16 +291,16 @@ class Menu: "Plants": pygame.Rect(x0, 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. sq, sq_gap = 104, 16 ry = WIN_H - 72 - 2 * sq - sq_gap - 10 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), } 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) def _seed_pack_sprite(self, pt, size=110): @@ -356,6 +354,7 @@ class Menu: collection.accrue_coins() collection.accrue_fertilizer() collection.auto_farm() + collection.auto_plant() self._passive_check_at = pygame.time.get_ticks() + 1000 if self.page != self._prev_page: self.grid_scroll = 0 @@ -399,14 +398,16 @@ class Menu: if self.type_info is not None: self.type_info = None return None - if self.convert_mode: - for pt, rect in self.convert_pack_btns.items(): + if self.shop_mode: + 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 collection.convert_water_to_pack(pt): - self.owned = collection.owned() - self.convert_mode = False + err = collection.buy_pack(pt) + self.shop_msg = err or f"Bought a {pt.capitalize()} pack!" + self.shop_msg_until = pygame.time.get_ticks() + 1800 return None - self.convert_mode = False + self.shop_mode = False return None if self.page == "seeds": if self.history_open: @@ -421,6 +422,9 @@ class Menu: if self.redeem_rect.collidepoint(pos): collection.redeem_tower_pack() return None + if self.shop_rect.collidepoint(pos): + self.shop_mode = True + return None for pt, (card, btn) in self.pack_rects.items(): if btn.move(0, -self.seeds_scroll).collidepoint(pos): result = collection.open_pack(pt) @@ -453,6 +457,8 @@ class Menu: if rect.collidepoint(pos): self.collection_tab = key return None + if self.collection_tab == "bosses": + return None # boss cards are informational only if self.collection_tab == "runes": if self._click_coll_rune_filter(pos): return None @@ -488,9 +494,6 @@ class Menu: self.water_mode = not self.water_mode return None 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): self.water_mode = False return None @@ -592,6 +595,16 @@ class Menu: "activated" if active else "deactivated") self.soil_notice_until = pygame.time.get_ticks() + 1800 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: if rect.collidepoint(pos): collection.plant_seed(idx) @@ -727,8 +740,8 @@ class Menu: if self.type_info is not None: draw_type_popup(self.screen, WIN_W, WIN_H, self.type_info, self.bold, self.small, self.tiny) - if self.convert_mode: - self._draw_convert_pack() + if self.shop_mode: + self._draw_shop() if self.tower_info: self._draw_tower_info() if self.soil_msg: @@ -796,7 +809,7 @@ class Menu: def _draw_menu(self): 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))) y = 130 for line in OVERVIEW: @@ -819,7 +832,7 @@ class Menu: else: t = self.bold.render(label, True, TEXT) 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() for label, rect in self.side_buttons.items(): hover = rect.collidepoint(mouse) @@ -857,8 +870,8 @@ class Menu: n = collection.SOIL_GATE_PLANTS - len(collection.owned()) hint = self.tiny.render(f"Own {collection.SOIL_GATE_PLANTS} plants to unlock Soil" f" ({n} to go)", True, (110, 115, 110)) - self.screen.blit(hint, hint.get_rect(midright=(self.side_buttons["Soil"].right, - self.side_buttons["Soil"].top - 6))) + self.screen.blit(hint, hint.get_rect(midleft=(self.side_buttons["Soil"].left, + self.side_buttons["Soil"].top - 6))) def _draw_battle_setup(self): self._draw_page("Select Floors", @@ -972,6 +985,12 @@ class Menu: pygame.draw.rect(self.screen, (150, 190, 150), self.history_rect, 1, border_radius=6) ht = self.tiny.render("History (last 10)", True, TEXT) 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 old_clip = self.screen.get_clip() self.screen.set_clip(self.seeds_scroll_area) @@ -1143,7 +1162,7 @@ class Menu: self.screen.blit(rp, (panel.right - 50, y)) low, high = runes.per_rune_chance(runes.PACK_DROP_CHANCE) 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) self.screen.blit(sub, (panel.x + 150, y + 22)) if pt in ALL_TYPES: @@ -1152,26 +1171,54 @@ class Menu: close = self.tiny.render("Click anywhere to close", True, MUTED) 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.fill((0, 0, 0, 160)) 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) pygame.draw.rect(self.screen, PANEL, panel, border_radius=12) - pygame.draw.rect(self.screen, (60, 70, 60), panel, 2, border_radius=12) - title = self.bold.render("Convert 5 water → a pack of your choice", True, TEXT) - self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 28))) + pygame.draw.rect(self.screen, ACCENT, panel, 2, border_radius=12) + title = self.bold.render("Pack Shop", True, ACCENT) + 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 - 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) - bg = (90, 120, 90) if hover else BTN - pygame.draw.rect(self.screen, bg, rect, border_radius=6) - pygame.draw.rect(self.screen, (150, 190, 150), rect, 1, border_radius=6) - tcol = TYPE_COLORS.get(pt, TEXT) - draw_type_icon(self.screen, pt, rect.center, self.tiny, tcol, 26, center=True) - hint = self.tiny.render("Click elsewhere to cancel", True, MUTED) - self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 18))) + afford = coins >= cost + pygame.draw.rect(self.screen, (90, 120, 90) if (hover and afford) else (BTN if afford else (45, 50, 45)), + rect, border_radius=8) + pygame.draw.rect(self.screen, (150, 190, 150) if afford else (70, 80, 70), + rect, 1, border_radius=8) + spr = self._seed_pack_sprite(pt, 48) + 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): 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", True, TEXT if can_afford else MUTED) 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: nt = self.bold.render(self.soil_notice, True, (235, 130, 130)) 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) if self.water_mode: 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, (150, 190, 150), self.water_exit_btn, 2, border_radius=6) et = self.tiny.render("Exit water mode", True, TEXT) @@ -1574,10 +1633,12 @@ class Menu: bcol = DANGER if self.runes_deconstruct_msg else ACCENT if self.runes_target is None: 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: 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" b = self.tiny.render(btxt, True, bcol) self.screen.blit(b, b.get_rect(center=(WIN_W // 2, 124))) @@ -1609,7 +1670,7 @@ class Menu: if ic: self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 30))) 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_target = self.runes_deconstruct and self.runes_target \ and rune["id"] == self.runes_target["id"] @@ -1841,7 +1902,7 @@ class Menu: if not rune: return 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: self.runes_deconstruct_msg = f"{rune['name']} is already at max roll" return @@ -1857,13 +1918,14 @@ class Menu: return if rune_id in self.runes_sacrifice: self.runes_sacrifice.remove(rune_id) - elif len(self.runes_sacrifice) >= 10: - self.runes_deconstruct_msg = "Already picked 10 runes" + elif len(self.runes_sacrifice) >= runes.RUNE_SACRIFICE_COST: + self.runes_deconstruct_msg = \ + f"Already picked {runes.RUNE_SACRIFICE_COST} runes" return else: self.runes_sacrifice.append(rune_id) self.runes_deconstruct_msg = "" - if len(self.runes_sacrifice) == 10: + if len(self.runes_sacrifice) == runes.RUNE_SACRIFICE_COST: self._open_runes_confirm() return @@ -1884,7 +1946,7 @@ class Menu: overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) overlay.fill((0, 0, 0, 160)) 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) pygame.draw.rect(self.screen, PANEL, panel, 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) rune = self.runes_confirm["rune"] 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) title = self.bold.render("Reroll rune?", True, TEXT) 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) 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))) @@ -1979,14 +2042,73 @@ class Menu: rect, border_radius=6) pygame.draw.rect(self.screen, (150, 190, 150) if active else (70, 80, 70), 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)) if self.collection_tab == "runes": self._draw_collection_runes() + elif self.collection_tab == "bosses": + self._draw_collection_bosses() else: self._draw_grid(self.names, reveal_all=False, show_evo=False, 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): mouse = self.mouse_pos self._draw_coll_rune_filters() @@ -2108,8 +2230,7 @@ class Menu: self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 78))) eff = self.small.render(runes.summary(color), True, TEXT) 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), - True, MUTED) + dmg = self.tiny.render(runes.effect_text(color, runes.max_pct(color)), True, MUTED) 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)}", True, HINT) @@ -2124,8 +2245,7 @@ class Menu: if self.coll_rune_sort_best: 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} - colors.sort(key=lambda c: best[c] / (runes.BROWN_PCT_MAX if c == "brown" else runes.PCT_MAX), - reverse=True) + colors.sort(key=lambda c: best[c] / runes.max_pct(c), reverse=True) return colors def _draw_detail(self): @@ -2390,12 +2510,15 @@ class Menu: sprite_rect = pygame.Rect(rect.centerx - 44, rect.y + 12, 88, 88) if spr: ic = fit_surface(spr, 88) - self.screen.blit(ic, ic.get_rect(center=sprite_rect.center)) - if spr and not found: - 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) + if found: + self.screen.blit(ic, ic.get_rect(center=sprite_rect.center)) + 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.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) name_txt = "???" if not found else name t = self.tiny.render(name_txt, True, TEXT if found else MUTED) self.screen.blit(t, t.get_rect(center=(rect.centerx, rect.y + 110))) diff --git a/ui/picker.py b/ui/picker.py index 1991b07..9d66416 100644 --- a/ui/picker.py +++ b/ui/picker.py @@ -2,10 +2,11 @@ import pygame from assets import icon as icon_path from assets import load_sprite -from engine import collection +from engine import bosses, collection from engine.growth import compute_stats 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.types import ALL_TYPES 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.runes import draw_rune_chips @@ -17,8 +18,8 @@ TYPE_COLORS = { "radiant": (245, 200, 90), "nocturnal": (150, 120, 220), "ice": (130, 200, 235), - "garden": (120, 200, 120), - "wildflower": (235, 130, 170), + "garden": (235, 130, 170), + "wildflower": (120, 200, 120), } BG = (24, 30, 24) @@ -44,7 +45,7 @@ class TeamSelect: pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 30) 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()), key=lambda n: (n not in self.favs, n)) 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.role_icons = {} self.allies = [] @@ -82,6 +85,12 @@ class TeamSelect: self._layout_enemy_cards() self._layout_enemy_strip() 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.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) @@ -157,13 +166,28 @@ class TeamSelect: 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)] + 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): 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 x0 = 290 y0 = 170 - for i, name in enumerate(self.pool_names): + for i, name in enumerate(shown): x = x0 + (i % cols) * (cw + gap) y = y0 + (i // cols) * (ch + gap) 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, cols * cw + (cols - 1) * gap + 8, 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 = getattr(self, "pool_scroll", 0) self.pool_scroll = max(0, min(self.pool_scroll, self.pool_scroll_max)) @@ -190,6 +214,10 @@ class TeamSelect: if not self.detail_open: self.pool_scroll = max(0, min(self.pool_scroll_max, 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: pos = to_logical(event.pos, self.scale) if self.detail_open: @@ -269,12 +297,55 @@ class TeamSelect: if rect.collidepoint(pos) and i < len(self.enemies): self._open_detail(self.enemies[i], self.enemies) 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(): if rect.move(0, -self.pool_scroll).collidepoint(pos): if name in self.allies: self.allies.remove(name) # click a selected plant to unselect it 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 @@ -334,8 +405,12 @@ class TeamSelect: return tag def _draw_role_type_tag(self, name, pos, font, center=False, ptype=None): - role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"]) - rs = font.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT)) + 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"]) + rs = font.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT)) sep = font.render(" · ", True, MUTED) ptype = ptype or collection.unit_type(name) tic = load_type_icon(ptype, 16) if ptype else None @@ -381,7 +456,7 @@ class TeamSelect: 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) 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)}", True, MUTED) self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 216))) @@ -414,6 +489,7 @@ class TeamSelect: self._draw_enemy_strip() if self.show_slots: self._draw_slots(self.ally_rects, self.allies, "Your Allies") + self._draw_pool_filters() self._draw_pool() self._draw_tower_enemies() for i, rect in enumerate(self.preset_rects): @@ -495,6 +571,69 @@ class TeamSelect: draw_rune_chips(self.screen, self._rune_icon, rune_colors, 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): old_clip = self.screen.get_clip() self.screen.set_clip(self.pool_area) @@ -574,7 +713,11 @@ class TeamSelect: name = self.detail_name if name is None: 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.fill((0, 0, 0)) shade.set_alpha(120) @@ -592,8 +735,11 @@ class TeamSelect: 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) self.screen.blit(t, (self.detail_rect.x + 160, self.detail_rect.y + 34)) - rar = plant_rarity(name) - rt = self.tiny.render(RARITY_LABELS.get(rar, rar), True, RARITY_COLORS.get(rar, TEXT)) + if is_boss_unit: + rt = self.tiny.render("Boss", True, (235, 200, 120)) + else: + rar = plant_rarity(name) + 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)) enemy_type = None if self.detail_is_enemy: @@ -604,19 +750,34 @@ class TeamSelect: name, (self.detail_rect.x + 160, self.detail_rect.y + 62), self.small, 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) 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)) atk, spd, dfn, hp = compute_stats(spec["stats"], prog["level"], prog["stage"]) 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) 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) self.screen.blit(st, (self.detail_rect.x + 160, self.detail_rect.y + 174)) 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", "") if p: pname, pdesc = PASSIVES.get(p, (p, "")) diff --git a/ui/presets.py b/ui/presets.py index b03cfc0..ee75f62 100644 --- a/ui/presets.py +++ b/ui/presets.py @@ -18,7 +18,7 @@ class PresetScreen: pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 30) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) @@ -67,7 +67,7 @@ class PresetScreen: pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 30) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) diff --git a/ui/relic_choice.py b/ui/relic_choice.py index 6ac2f29..40b900d 100644 --- a/ui/relic_choice.py +++ b/ui/relic_choice.py @@ -22,7 +22,7 @@ class RelicChoiceScreen: pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 36) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) diff --git a/ui/runes.py b/ui/runes.py index 520aef2..adca149 100644 --- a/ui/runes.py +++ b/ui/runes.py @@ -36,7 +36,7 @@ class RuneScreen: self.plant = plant self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 30) 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.fill((0, 0, 0, 160)) 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) pygame.draw.rect(self.screen, PANEL, panel, border_radius=12) pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12) diff --git a/ui/starter.py b/ui/starter.py index 4c338cf..b5e1d5b 100644 --- a/ui/starter.py +++ b/ui/starter.py @@ -25,8 +25,8 @@ TYPE_COLORS = { "radiant": (245, 200, 90), "nocturnal": (150, 120, 220), "ice": (130, 200, 235), - "garden": (120, 200, 120), - "wildflower": (235, 130, 170), + "garden": (235, 130, 170), + "wildflower": (120, 200, 120), } @@ -35,7 +35,7 @@ class StarterScreen: pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 30) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) diff --git a/ui/tower.py b/ui/tower.py index b9b04fd..d1c9f5a 100644 --- a/ui/tower.py +++ b/ui/tower.py @@ -21,7 +21,7 @@ class ConfirmScreen: pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 30) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) @@ -81,7 +81,7 @@ class TowerScreen: pygame.init() self.window, self.scale = make_window(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.font = pygame.font.SysFont("segoeui", 36) self.bold = pygame.font.SysFont("segoeui", 20, bold=True) diff --git a/ui/typepopup.py b/ui/typepopup.py index 0ec6de9..a3b7084 100644 --- a/ui/typepopup.py +++ b/ui/typepopup.py @@ -7,8 +7,8 @@ TYPE_COLORS = { "radiant": (245, 200, 90), "nocturnal": (150, 120, 220), "ice": (130, 200, 235), - "garden": (120, 200, 120), - "wildflower": (235, 130, 170), + "garden": (235, 130, 170), + "wildflower": (120, 200, 120), } STRONG = (120, 210, 120) WEAK = (225, 130, 130)