commit 6840b715de6e46836329a397fac79f76738f01b8 Author: Halica Date: Fri Aug 28 14:26:07 2026 +0200 Initial commit: Plants turn-based battle game diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91a7105 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +.DS_Store + +# runtime save data +data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9370165 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# Plants — Turn-Based Battle Game + +A turn-based battle game in Python. Normal battles are 4v4; the Tower mode +starts small and ramps up. + +## Run it + +``` +pip install -r requirements.txt +python main.py +``` + +You land on the **main menu**: +- **Seeds** — seed pack opening; the main way to get/enhance plants. +- **Plants** — the plants you own, with their **level/stage**; open a plant to see its + stats and **Water** (+1 level) or **Evolve** it + (at milestone levels, consuming duplicates). +- **Battle** — pick a difficulty stage (1–5, gated by enemy rarity), then the + enemy team is rolled so you know the matchup, and you pick your 4 allies and + fight. Higher stages grant more packs on a win; any loss grants a lesser pack. +- **Tower** — a 20-stage gauntlet. Enemies start with 1 common plant and get + stronger (more enemies, higher rarity, higher level) each stage; your team is + fresh every fight. After each win you **choose one of three roguelike + blessings** (shields, stat boosts, enemy debuffs, heal-over-time…) that last + for the rest of the run — the core of the tower's roguelike loop. + Stages 1–5 pay 1 water each, stages 6–20 pay scaling + **Tower packs** (high-rarity odds), which you can open or redeem for 20 water. +- **Collection** — plants you've discovered (they're added when you battle them); + undiscovered plants show as blacked-out silhouettes. Saved in `data/collection.json`. +- **Soil** — retrieve a **seed** from any owned plant (once per cooldown, from + its detail page), plant it, then grow a copy in stages. Each stage fills over + time; **fertilizing** (1 fertilizer per stage) advances it. Higher rarities + have more stages; harvesting grants a duplicate of that plant. You start with + **1 plot**; extra plots unlock for escalating fertilizer (10, then 20, 30…, + up to 5 total). Soil unlocks once you own **8 distinct plants**. + +## Rarities + +Every plant has a **rarity** tag (common / rare / epic / legendary) — +purely cosmetic, only used to weight seed-pack drops. A **victory** grants +seed packs equal to the difficulty stage (1–5) **plus fertilizer equal to the +floor** (1–5); a **defeat** grants 1 +**lesser pack** (worse rarity odds, no type weighting). Type packs roll the +rarity first (matching the general pack) and then pick a plant of that rarity +from the type when possible; rarer plants are rarer pulls. + +## Watering, fertilizer & Evolution + +Two resources: +- **Water** — the scarce, valuable currency. **Watering** gives an instant +1 + level by spending 1 water, and 5 water can be converted into a seed pack. + Water comes mainly from Tower rewards (stage 1–5 wins pay 1 water, Tower packs + redeem for 10). +- **Fertilizer** — the common currency earned from **battle wins**. It's used to + fertilize growing Soil plots (each fertilizer advances a plot one stage). + +- **XP** is earned from battles (participation + win bonus); allies auto-level up. + Higher difficulty stages pay more XP (scales with the stage number), and Tower + wins pay XP too. The lowest-level plant on your team earns bonus XP based on + the level gap (catch-up). +- **Evolution** unlocks at milestone levels (5 → stage 2, 10 → stage 3) and + consumes duplicates. Stages multiply base stats before the level curve. +- Enemies in battle are scaled to roughly your team's level. +- Stat growth: attack/defense +6%, health +8%, speed +4% per level (see `engine/growth.py`). + +## Roguelike blessings (Tower) + +After every Tower win you're offered **3 random blessings** and keep one (or +skip). Blessings stack for the rest of the run and apply at the **start of each +battle**. They come in three rarity tiers: + +- **Common** — small shields/stat boosts and start-of-battle buffs (e.g. "team + starts with a shield for 10% of max health"). +- **Rare** — bigger boosts, heal-over-time, stronger healing, enemy debuffs + (Speed/Attack Down). +- **Epic** — strong shields, "+15% attack", "+20% max health", enemies start + Vulnerable. +- **Legendary** — rare picks with big payoff: block the first damage taken, + a 40% shield, a one-time revive, or a large HP/attack combo. + +Blessings are offered from a **rarity-weighted** pool, so legendary relics show +up rarely. + +Blessings are defined declaratively in `engine/relics.py` (`RELIC_POOL`); each +relic is a `Relic` with an `apply(battle)` function that runs once per battle in +`Battle.start()`. They're tracked per-run (not saved). An **"All blessings" +overview** (`ui/relics_overview.py`) shows the whole pool grouped by rarity with +the ones you've collected highlighted in gold; click any card for a closer look. +It opens from the blessing-choice screen and by clicking the "Blessings:" line +on the Tower battle screen. + +## Plant types & type advantages + +Five types with a rock-paper-scissors cycle: + +**Radiant > Nocturnal > Ice > Garden > Wildflowers > Radiant** + +- Hitting a type you're strong against deals **+30%** damage. +- Hitting a type that's strong against you deals **−30%**. +- Applies only to damage — heals and shields are unaffected. + +## Stats & damage + +- **Health** — the damage pool. +- **Attack / Defense** — damage = skill power + attack − defense (min 1). +- **Speed** — an **initiative timeline**: each plant acts based on `100 / speed` + interval, so faster plants act *more often* and interleave with slower ones + (e.g. a 110-speed enemy goes 110 → 75 → 110 → 50…). The blue bar under each + unit's health shows how close it is to its next turn. Speed buffs/debuffs + change the interval, and "reduce turn progress" effects literally push a + plant's next turn back. +- **Crit** — base 10%, doubles damage. + +## Debuffs + +- **Wilt** — damage over 3 turns (total = % of the triggering hit's damage); + reapplying resets it to 3 turns. +- **Shield** — temporary HP that absorbs damage; cannot be healed. +- **Damage-over-time** (Dark Corrosion, Chill) — deals its listed damage **every + turn** while active; Dark Corrosion stacks. +- **Sleep** — skips turns until the target has taken damage twice. +- **Vulnerable X** — +50% damage for the next X hits. +- **Intoxicate / Oblivion** — an Intoxicated plant's attacks splash half their + damage onto one of its own allies; an Oblivious plant may only use its weakest + skill (both last their stated duration, e.g. 1-2 turns). +- Plus speed/crit/attack/defense down, Madness, Tether, and more. + +## The plant pool (27) + +All defined declaratively in `engine/plants.py` so new plants are easy to add: + +- **Radiant:** Zinnia, Sunflower, Daisy, Marigold, Daffodil +- **Garden:** Rose, Peony, Magnolia, Hydrangea, Sweet Pea, Chrysanthemum +- **Wildflower:** Lily, Hibiscus, Poppy, Tulip, Lotus, Dandelion +- **Nocturnal:** Nightshade, Moonflower, Orchid, Lavender, Venus Flytrap +- **Ice:** Snowdrop, Primrose, Winter Jasmine, Hellebore, Camelia + +Each has a role (Attacker / Support / Tank), a unique passive, three skills +with cooldowns, and base stats. Tanks protect their team from single-target +attacks (AOE still hits everyone). + +## Architecture + +``` +engine/ Pure-Python battle logic (types, skills, effects, plants, battle). + No pygame — reusable for a web UI or Flet later. +ui/ pygame frontend: team picker + battle screen. +tests/ unittest coverage of the engine. +``` + +Adding a plant later = add one entry to `POOL` in `engine/plants.py`. + +## Tests + +``` +python -m unittest discover -s tests +``` + +## Roadmap ideas + +- Balance pass (sim currently favors the player's chosen teams) +- More team strategies, saving team compositions +- Web UI via Flask, or a real mobile app via Flet \ No newline at end of file diff --git a/assets/__init__.py b/assets/__init__.py new file mode 100644 index 0000000..27efa86 --- /dev/null +++ b/assets/__init__.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os + +import pygame + +_ASSETS = os.path.dirname(os.path.abspath(__file__)) + + +def sprite(name: str, variant: str = "") -> str: + """Path to a unit sprite, e.g. sprite('sweet pea') -> assets/sprites/sweet_pea.png. + With a variant ('full' or 'head') looks under the split folders, e.g. + sprite('daisy', 'head') -> assets/sprites/plant_head/daisy_head.png.""" + base = name.lower().replace(' ', '_') + if variant: + return os.path.join(_ASSETS, "sprites", f"plant_{variant}", f"{base}_{variant}.png") + return os.path.join(_ASSETS, "sprites", f"{base}.png") + + +def load_sprite(name: str, variant: str = "") -> "pygame.Surface": + """Load a unit sprite, trying the split variant first ('full'/'head') and + falling back to the legacy single sprite when the variant file is missing. + Returns a surface or None.""" + def _exists(nm): + p = sprite(nm) + if os.path.exists(p): + return p + return None + try: + if variant: + path = sprite(name, variant) + if not os.path.exists(path): + path = _exists(name) + if path is None: + return None + else: + path = _exists(name) + if path is None: + return None + return pygame.image.load(path).convert_alpha() + except (pygame.error, OSError): + return None + + +def icon(category: str, name: str) -> str: + """Path to an icon under a category folder, e.g. icon('currency_icons', 'coin') + -> assets/icons/currency_icons/coin.png""" + return os.path.join(_ASSETS, "icons", category, f"{name}.png") + + +def type_icon_path(ptype: str) -> str: + """Path to a plant type icon, e.g. type_icon_path('radiant') -> assets/icons/types/radiant_icon.png""" + return icon("types", f"{ptype}_icon") + + +def plot_icon_path(name: str) -> str: + """Path to a soil plot sprite, e.g. plot_icon_path('soil_barren') -> assets/icons/plot/soil_barren.png""" + return icon("plot", name) + + +def rune_icon_path(color: str) -> str: + """Path to a rune sprite by color, e.g. rune_icon_path('red') -> assets/icons/runes/ruby_rune.png""" + from engine import runes + return icon("runes", runes.RUNE_ICON_FILES.get(color, "rune_brown")) \ No newline at end of file diff --git a/assets/icons/attacker_icon.png b/assets/icons/attacker_icon.png new file mode 100644 index 0000000..90e8795 Binary files /dev/null and b/assets/icons/attacker_icon.png differ diff --git a/assets/icons/battle.png b/assets/icons/battle.png new file mode 100644 index 0000000..f3197a7 Binary files /dev/null and b/assets/icons/battle.png differ diff --git a/assets/icons/coin.png b/assets/icons/coin.png new file mode 100644 index 0000000..f6307e9 Binary files /dev/null and b/assets/icons/coin.png differ diff --git a/assets/icons/collection.png b/assets/icons/collection.png new file mode 100644 index 0000000..dcc1084 Binary files /dev/null and b/assets/icons/collection.png differ diff --git a/assets/icons/currency_icons/coin.png b/assets/icons/currency_icons/coin.png new file mode 100644 index 0000000..f6307e9 Binary files /dev/null and b/assets/icons/currency_icons/coin.png differ diff --git a/assets/icons/currency_icons/fertilizer.png b/assets/icons/currency_icons/fertilizer.png new file mode 100644 index 0000000..7362024 Binary files /dev/null and b/assets/icons/currency_icons/fertilizer.png differ diff --git a/assets/icons/currency_icons/water_drop.png b/assets/icons/currency_icons/water_drop.png new file mode 100644 index 0000000..44e6716 Binary files /dev/null and b/assets/icons/currency_icons/water_drop.png differ diff --git a/assets/icons/fertilizer.png b/assets/icons/fertilizer.png new file mode 100644 index 0000000..7362024 Binary files /dev/null and b/assets/icons/fertilizer.png differ diff --git a/assets/icons/menu_icons/battle.png b/assets/icons/menu_icons/battle.png new file mode 100644 index 0000000..f3197a7 Binary files /dev/null and b/assets/icons/menu_icons/battle.png differ diff --git a/assets/icons/menu_icons/collection.png b/assets/icons/menu_icons/collection.png new file mode 100644 index 0000000..dcc1084 Binary files /dev/null and b/assets/icons/menu_icons/collection.png differ diff --git a/assets/icons/menu_icons/plants.png b/assets/icons/menu_icons/plants.png new file mode 100644 index 0000000..8cef15c Binary files /dev/null and b/assets/icons/menu_icons/plants.png differ diff --git a/assets/icons/menu_icons/runes.png b/assets/icons/menu_icons/runes.png new file mode 100644 index 0000000..84f4cb2 Binary files /dev/null and b/assets/icons/menu_icons/runes.png differ diff --git a/assets/icons/menu_icons/seed_pack.png b/assets/icons/menu_icons/seed_pack.png new file mode 100644 index 0000000..80e6dd1 Binary files /dev/null and b/assets/icons/menu_icons/seed_pack.png differ diff --git a/assets/icons/menu_icons/soil.png b/assets/icons/menu_icons/soil.png new file mode 100644 index 0000000..4d4ed91 Binary files /dev/null and b/assets/icons/menu_icons/soil.png differ diff --git a/assets/icons/menu_icons/tower.png b/assets/icons/menu_icons/tower.png new file mode 100644 index 0000000..4aeab99 Binary files /dev/null and b/assets/icons/menu_icons/tower.png differ diff --git a/assets/icons/old/coin_old.png b/assets/icons/old/coin_old.png new file mode 100644 index 0000000..c31a300 Binary files /dev/null and b/assets/icons/old/coin_old.png differ diff --git a/assets/icons/old/fertilizer_old.png b/assets/icons/old/fertilizer_old.png new file mode 100644 index 0000000..b875cd7 Binary files /dev/null and b/assets/icons/old/fertilizer_old.png differ diff --git a/assets/icons/old/garden_icon_old.png b/assets/icons/old/garden_icon_old.png new file mode 100644 index 0000000..2193d84 Binary files /dev/null and b/assets/icons/old/garden_icon_old.png differ diff --git a/assets/icons/old/nocturnal_icon.png b/assets/icons/old/nocturnal_icon.png new file mode 100644 index 0000000..afa0e27 Binary files /dev/null and b/assets/icons/old/nocturnal_icon.png differ diff --git a/assets/icons/old/rune_blue.png b/assets/icons/old/rune_blue.png new file mode 100644 index 0000000..1ec9bef Binary files /dev/null and b/assets/icons/old/rune_blue.png differ diff --git a/assets/icons/old/rune_blue_unused.png b/assets/icons/old/rune_blue_unused.png new file mode 100644 index 0000000..43d5a34 Binary files /dev/null and b/assets/icons/old/rune_blue_unused.png differ diff --git a/assets/icons/old/rune_red.png b/assets/icons/old/rune_red.png new file mode 100644 index 0000000..83230a7 Binary files /dev/null and b/assets/icons/old/rune_red.png differ diff --git a/assets/icons/old/rune_white.png b/assets/icons/old/rune_white.png new file mode 100644 index 0000000..f60ea65 Binary files /dev/null and b/assets/icons/old/rune_white.png differ diff --git a/assets/icons/old/rune_yellow.png b/assets/icons/old/rune_yellow.png new file mode 100644 index 0000000..5732b38 Binary files /dev/null and b/assets/icons/old/rune_yellow.png differ diff --git a/assets/icons/old/soil_barren_old.png b/assets/icons/old/soil_barren_old.png new file mode 100644 index 0000000..9109ea5 Binary files /dev/null and b/assets/icons/old/soil_barren_old.png differ diff --git a/assets/icons/old/soil_growth_old.png b/assets/icons/old/soil_growth_old.png new file mode 100644 index 0000000..6c8f78f Binary files /dev/null and b/assets/icons/old/soil_growth_old.png differ diff --git a/assets/icons/old/soil_old.png b/assets/icons/old/soil_old.png new file mode 100644 index 0000000..8b99365 Binary files /dev/null and b/assets/icons/old/soil_old.png differ diff --git a/assets/icons/old/soil_ready_old.png b/assets/icons/old/soil_ready_old.png new file mode 100644 index 0000000..7aaef7c Binary files /dev/null and b/assets/icons/old/soil_ready_old.png differ diff --git a/assets/icons/old/soil_seed1_old.png b/assets/icons/old/soil_seed1_old.png new file mode 100644 index 0000000..72bcbe2 Binary files /dev/null and b/assets/icons/old/soil_seed1_old.png differ diff --git a/assets/icons/old/soil_seed2_old.png b/assets/icons/old/soil_seed2_old.png new file mode 100644 index 0000000..2d5df28 Binary files /dev/null and b/assets/icons/old/soil_seed2_old.png differ diff --git a/assets/icons/old/tank_icon.png b/assets/icons/old/tank_icon.png new file mode 100644 index 0000000..97543e0 Binary files /dev/null and b/assets/icons/old/tank_icon.png differ diff --git a/assets/icons/old/water_droplet.png b/assets/icons/old/water_droplet.png new file mode 100644 index 0000000..5155033 Binary files /dev/null and b/assets/icons/old/water_droplet.png differ diff --git a/assets/icons/plants.png b/assets/icons/plants.png new file mode 100644 index 0000000..8cef15c Binary files /dev/null and b/assets/icons/plants.png differ diff --git a/assets/icons/plot/soil_barren.png b/assets/icons/plot/soil_barren.png new file mode 100644 index 0000000..2f36c04 Binary files /dev/null and b/assets/icons/plot/soil_barren.png differ diff --git a/assets/icons/plot/soil_growth.png b/assets/icons/plot/soil_growth.png new file mode 100644 index 0000000..21ef46d Binary files /dev/null and b/assets/icons/plot/soil_growth.png differ diff --git a/assets/icons/plot/soil_ready.png b/assets/icons/plot/soil_ready.png new file mode 100644 index 0000000..959daa4 Binary files /dev/null and b/assets/icons/plot/soil_ready.png differ diff --git a/assets/icons/plot/soil_seed1.png b/assets/icons/plot/soil_seed1.png new file mode 100644 index 0000000..99444ec Binary files /dev/null and b/assets/icons/plot/soil_seed1.png differ diff --git a/assets/icons/plot/soil_seed2.png b/assets/icons/plot/soil_seed2.png new file mode 100644 index 0000000..7b977c9 Binary files /dev/null and b/assets/icons/plot/soil_seed2.png differ diff --git a/assets/icons/role_icons/attacker_icon.png b/assets/icons/role_icons/attacker_icon.png new file mode 100644 index 0000000..90e8795 Binary files /dev/null and b/assets/icons/role_icons/attacker_icon.png differ diff --git a/assets/icons/role_icons/supporter_icon.png b/assets/icons/role_icons/supporter_icon.png new file mode 100644 index 0000000..03c4c21 Binary files /dev/null and b/assets/icons/role_icons/supporter_icon.png differ diff --git a/assets/icons/role_icons/tank_icon.png b/assets/icons/role_icons/tank_icon.png new file mode 100644 index 0000000..cc97e61 Binary files /dev/null and b/assets/icons/role_icons/tank_icon.png differ diff --git a/assets/icons/rune_blue.png b/assets/icons/rune_blue.png new file mode 100644 index 0000000..1ec9bef Binary files /dev/null and b/assets/icons/rune_blue.png differ diff --git a/assets/icons/rune_blue_unused.png b/assets/icons/rune_blue_unused.png new file mode 100644 index 0000000..43d5a34 Binary files /dev/null and b/assets/icons/rune_blue_unused.png differ diff --git a/assets/icons/rune_brown.png b/assets/icons/rune_brown.png new file mode 100644 index 0000000..026e7f6 Binary files /dev/null and b/assets/icons/rune_brown.png differ diff --git a/assets/icons/rune_red.png b/assets/icons/rune_red.png new file mode 100644 index 0000000..83230a7 Binary files /dev/null and b/assets/icons/rune_red.png differ diff --git a/assets/icons/rune_white.png b/assets/icons/rune_white.png new file mode 100644 index 0000000..f60ea65 Binary files /dev/null and b/assets/icons/rune_white.png differ diff --git a/assets/icons/rune_yellow.png b/assets/icons/rune_yellow.png new file mode 100644 index 0000000..5732b38 Binary files /dev/null and b/assets/icons/rune_yellow.png differ diff --git a/assets/icons/runes.png b/assets/icons/runes.png new file mode 100644 index 0000000..84f4cb2 Binary files /dev/null and b/assets/icons/runes.png differ diff --git a/assets/icons/runes/amethyst_rune.png b/assets/icons/runes/amethyst_rune.png new file mode 100644 index 0000000..ede95f1 Binary files /dev/null and b/assets/icons/runes/amethyst_rune.png differ diff --git a/assets/icons/runes/jade_rune.png b/assets/icons/runes/jade_rune.png new file mode 100644 index 0000000..724fd34 Binary files /dev/null and b/assets/icons/runes/jade_rune.png differ diff --git a/assets/icons/runes/pearl_rune.png b/assets/icons/runes/pearl_rune.png new file mode 100644 index 0000000..71f0caf Binary files /dev/null and b/assets/icons/runes/pearl_rune.png differ diff --git a/assets/icons/runes/ruby_rune.png b/assets/icons/runes/ruby_rune.png new file mode 100644 index 0000000..11191bc Binary files /dev/null and b/assets/icons/runes/ruby_rune.png differ diff --git a/assets/icons/runes/rune_brown.png b/assets/icons/runes/rune_brown.png new file mode 100644 index 0000000..026e7f6 Binary files /dev/null and b/assets/icons/runes/rune_brown.png differ diff --git a/assets/icons/runes/sapphire_rune.png b/assets/icons/runes/sapphire_rune.png new file mode 100644 index 0000000..71f4876 Binary files /dev/null and b/assets/icons/runes/sapphire_rune.png differ diff --git a/assets/icons/seed_pack.png b/assets/icons/seed_pack.png new file mode 100644 index 0000000..80e6dd1 Binary files /dev/null and b/assets/icons/seed_pack.png differ diff --git a/assets/icons/seed_packs/closed/garden_seedpack.png b/assets/icons/seed_packs/closed/garden_seedpack.png new file mode 100644 index 0000000..7718ec0 Binary files /dev/null and b/assets/icons/seed_packs/closed/garden_seedpack.png differ diff --git a/assets/icons/seed_packs/closed/general_seedpack.png b/assets/icons/seed_packs/closed/general_seedpack.png new file mode 100644 index 0000000..1bcdb37 Binary files /dev/null and b/assets/icons/seed_packs/closed/general_seedpack.png differ diff --git a/assets/icons/seed_packs/closed/ice_seedpack.png b/assets/icons/seed_packs/closed/ice_seedpack.png new file mode 100644 index 0000000..707a9f0 Binary files /dev/null and b/assets/icons/seed_packs/closed/ice_seedpack.png differ diff --git a/assets/icons/seed_packs/closed/lesser_seedpack.png b/assets/icons/seed_packs/closed/lesser_seedpack.png new file mode 100644 index 0000000..8dc85f8 Binary files /dev/null and b/assets/icons/seed_packs/closed/lesser_seedpack.png differ diff --git a/assets/icons/seed_packs/closed/nocturnal_seedpack.png b/assets/icons/seed_packs/closed/nocturnal_seedpack.png new file mode 100644 index 0000000..36eba23 Binary files /dev/null and b/assets/icons/seed_packs/closed/nocturnal_seedpack.png differ diff --git a/assets/icons/seed_packs/closed/radiant_seedpack.png b/assets/icons/seed_packs/closed/radiant_seedpack.png new file mode 100644 index 0000000..2f4e734 Binary files /dev/null and b/assets/icons/seed_packs/closed/radiant_seedpack.png differ diff --git a/assets/icons/seed_packs/closed/tower_seedpack.png b/assets/icons/seed_packs/closed/tower_seedpack.png new file mode 100644 index 0000000..8dce62f Binary files /dev/null and b/assets/icons/seed_packs/closed/tower_seedpack.png differ diff --git a/assets/icons/seed_packs/closed/wildflower_seedpack.png b/assets/icons/seed_packs/closed/wildflower_seedpack.png new file mode 100644 index 0000000..8708478 Binary files /dev/null and b/assets/icons/seed_packs/closed/wildflower_seedpack.png differ diff --git a/assets/icons/soil.png b/assets/icons/soil.png new file mode 100644 index 0000000..8b99365 Binary files /dev/null and b/assets/icons/soil.png differ diff --git a/assets/icons/supporter_icon.png b/assets/icons/supporter_icon.png new file mode 100644 index 0000000..03c4c21 Binary files /dev/null and b/assets/icons/supporter_icon.png differ diff --git a/assets/icons/tank_icon.png b/assets/icons/tank_icon.png new file mode 100644 index 0000000..cc97e61 Binary files /dev/null and b/assets/icons/tank_icon.png differ diff --git a/assets/icons/tower.png b/assets/icons/tower.png new file mode 100644 index 0000000..4aeab99 Binary files /dev/null and b/assets/icons/tower.png differ diff --git a/assets/icons/types/garden_icon.png b/assets/icons/types/garden_icon.png new file mode 100644 index 0000000..b35df82 Binary files /dev/null and b/assets/icons/types/garden_icon.png differ diff --git a/assets/icons/types/ice_icon.png b/assets/icons/types/ice_icon.png new file mode 100644 index 0000000..a222024 Binary files /dev/null and b/assets/icons/types/ice_icon.png differ diff --git a/assets/icons/types/nocturnal_icon.png b/assets/icons/types/nocturnal_icon.png new file mode 100644 index 0000000..7580760 Binary files /dev/null and b/assets/icons/types/nocturnal_icon.png differ diff --git a/assets/icons/types/radiant_icon.png b/assets/icons/types/radiant_icon.png new file mode 100644 index 0000000..0154d0c Binary files /dev/null and b/assets/icons/types/radiant_icon.png differ diff --git a/assets/icons/types/wildflower_icon.png b/assets/icons/types/wildflower_icon.png new file mode 100644 index 0000000..ca63875 Binary files /dev/null and b/assets/icons/types/wildflower_icon.png differ diff --git a/assets/icons/water_drop.png b/assets/icons/water_drop.png new file mode 100644 index 0000000..44e6716 Binary files /dev/null and b/assets/icons/water_drop.png differ diff --git a/assets/sprites/butterfly.png b/assets/sprites/butterfly.png new file mode 100644 index 0000000..c19a95f Binary files /dev/null and b/assets/sprites/butterfly.png differ diff --git a/assets/sprites/camelia.png b/assets/sprites/camelia.png new file mode 100644 index 0000000..368759d Binary files /dev/null and b/assets/sprites/camelia.png differ diff --git a/assets/sprites/chrysanthemum.png b/assets/sprites/chrysanthemum.png new file mode 100644 index 0000000..3c0c5f9 Binary files /dev/null and b/assets/sprites/chrysanthemum.png differ diff --git a/assets/sprites/daffodil.png b/assets/sprites/daffodil.png new file mode 100644 index 0000000..ec0a64c Binary files /dev/null and b/assets/sprites/daffodil.png differ diff --git a/assets/sprites/daisy.png b/assets/sprites/daisy.png new file mode 100644 index 0000000..55c76e1 Binary files /dev/null and b/assets/sprites/daisy.png differ diff --git a/assets/sprites/dandelion.png b/assets/sprites/dandelion.png new file mode 100644 index 0000000..75bb754 Binary files /dev/null and b/assets/sprites/dandelion.png differ diff --git a/assets/sprites/fairy.png b/assets/sprites/fairy.png new file mode 100644 index 0000000..2e38944 Binary files /dev/null and b/assets/sprites/fairy.png differ diff --git a/assets/sprites/gnome.png b/assets/sprites/gnome.png new file mode 100644 index 0000000..44a762d Binary files /dev/null and b/assets/sprites/gnome.png differ diff --git a/assets/sprites/hellebore.png b/assets/sprites/hellebore.png new file mode 100644 index 0000000..62c4257 Binary files /dev/null and b/assets/sprites/hellebore.png differ diff --git a/assets/sprites/hibiscus.png b/assets/sprites/hibiscus.png new file mode 100644 index 0000000..1496f28 Binary files /dev/null and b/assets/sprites/hibiscus.png differ diff --git a/assets/sprites/hydrangea.png b/assets/sprites/hydrangea.png new file mode 100644 index 0000000..c0e3490 Binary files /dev/null and b/assets/sprites/hydrangea.png differ diff --git a/assets/sprites/lavender.png b/assets/sprites/lavender.png new file mode 100644 index 0000000..4c33b6c Binary files /dev/null and b/assets/sprites/lavender.png differ diff --git a/assets/sprites/lily.png b/assets/sprites/lily.png new file mode 100644 index 0000000..d5dc827 Binary files /dev/null and b/assets/sprites/lily.png differ diff --git a/assets/sprites/lotus.png b/assets/sprites/lotus.png new file mode 100644 index 0000000..7490a22 Binary files /dev/null and b/assets/sprites/lotus.png differ diff --git a/assets/sprites/magnolia.png b/assets/sprites/magnolia.png new file mode 100644 index 0000000..cf0e91d Binary files /dev/null and b/assets/sprites/magnolia.png differ diff --git a/assets/sprites/marigold.png b/assets/sprites/marigold.png new file mode 100644 index 0000000..6817ec2 Binary files /dev/null and b/assets/sprites/marigold.png differ diff --git a/assets/sprites/moonflower.png b/assets/sprites/moonflower.png new file mode 100644 index 0000000..aa859c5 Binary files /dev/null and b/assets/sprites/moonflower.png differ diff --git a/assets/sprites/nightshade.png b/assets/sprites/nightshade.png new file mode 100644 index 0000000..31d3620 Binary files /dev/null and b/assets/sprites/nightshade.png differ diff --git a/assets/sprites/old/daisy.png b/assets/sprites/old/daisy.png new file mode 100644 index 0000000..55c76e1 Binary files /dev/null and b/assets/sprites/old/daisy.png differ diff --git a/assets/sprites/old/fairy.png b/assets/sprites/old/fairy.png new file mode 100644 index 0000000..2e38944 Binary files /dev/null and b/assets/sprites/old/fairy.png differ diff --git a/assets/sprites/old/gnome.png b/assets/sprites/old/gnome.png new file mode 100644 index 0000000..44a762d Binary files /dev/null and b/assets/sprites/old/gnome.png differ diff --git a/assets/sprites/old/poppy.png b/assets/sprites/old/poppy.png new file mode 100644 index 0000000..5bf83ae Binary files /dev/null and b/assets/sprites/old/poppy.png differ diff --git a/assets/sprites/old/snowdrop.png b/assets/sprites/old/snowdrop.png new file mode 100644 index 0000000..cc085fd Binary files /dev/null and b/assets/sprites/old/snowdrop.png differ diff --git a/assets/sprites/old/stones.png b/assets/sprites/old/stones.png new file mode 100644 index 0000000..2564427 Binary files /dev/null and b/assets/sprites/old/stones.png differ diff --git a/assets/sprites/orchid.png b/assets/sprites/orchid.png new file mode 100644 index 0000000..18b1516 Binary files /dev/null and b/assets/sprites/orchid.png differ diff --git a/assets/sprites/peony.png b/assets/sprites/peony.png new file mode 100644 index 0000000..2f94e13 Binary files /dev/null and b/assets/sprites/peony.png differ diff --git a/assets/sprites/pinwheel.png b/assets/sprites/pinwheel.png new file mode 100644 index 0000000..d6beafc Binary files /dev/null and b/assets/sprites/pinwheel.png differ diff --git a/assets/sprites/plant_full/daisy_full.png b/assets/sprites/plant_full/daisy_full.png new file mode 100644 index 0000000..c29f7fe Binary files /dev/null and b/assets/sprites/plant_full/daisy_full.png differ diff --git a/assets/sprites/plant_full/poppy_full.png b/assets/sprites/plant_full/poppy_full.png new file mode 100644 index 0000000..b7c4088 Binary files /dev/null and b/assets/sprites/plant_full/poppy_full.png differ diff --git a/assets/sprites/plant_full/snowdrop_full.png b/assets/sprites/plant_full/snowdrop_full.png new file mode 100644 index 0000000..3390eb4 Binary files /dev/null and b/assets/sprites/plant_full/snowdrop_full.png differ diff --git a/assets/sprites/plant_head/daisy_head.png b/assets/sprites/plant_head/daisy_head.png new file mode 100644 index 0000000..10d3954 Binary files /dev/null and b/assets/sprites/plant_head/daisy_head.png differ diff --git a/assets/sprites/plant_head/poppy_head.png b/assets/sprites/plant_head/poppy_head.png new file mode 100644 index 0000000..ee3b5a0 Binary files /dev/null and b/assets/sprites/plant_head/poppy_head.png differ diff --git a/assets/sprites/plant_head/snowdrop_head.png b/assets/sprites/plant_head/snowdrop_head.png new file mode 100644 index 0000000..55d206a Binary files /dev/null and b/assets/sprites/plant_head/snowdrop_head.png differ diff --git a/assets/sprites/poppy.png b/assets/sprites/poppy.png new file mode 100644 index 0000000..5bf83ae Binary files /dev/null and b/assets/sprites/poppy.png differ diff --git a/assets/sprites/primrose.png b/assets/sprites/primrose.png new file mode 100644 index 0000000..ea2c3ce Binary files /dev/null and b/assets/sprites/primrose.png differ diff --git a/assets/sprites/rose.png b/assets/sprites/rose.png new file mode 100644 index 0000000..860e8e4 Binary files /dev/null and b/assets/sprites/rose.png differ diff --git a/assets/sprites/snowdrop.png b/assets/sprites/snowdrop.png new file mode 100644 index 0000000..cc085fd Binary files /dev/null and b/assets/sprites/snowdrop.png differ diff --git a/assets/sprites/stones.png b/assets/sprites/stones.png new file mode 100644 index 0000000..2564427 Binary files /dev/null and b/assets/sprites/stones.png differ diff --git a/assets/sprites/sunflower.png b/assets/sprites/sunflower.png new file mode 100644 index 0000000..aeb2590 Binary files /dev/null and b/assets/sprites/sunflower.png differ diff --git a/assets/sprites/sweet_pea.png b/assets/sprites/sweet_pea.png new file mode 100644 index 0000000..fd403ae Binary files /dev/null and b/assets/sprites/sweet_pea.png differ diff --git a/assets/sprites/toadstool.png b/assets/sprites/toadstool.png new file mode 100644 index 0000000..c31c279 Binary files /dev/null and b/assets/sprites/toadstool.png differ diff --git a/assets/sprites/tulip.png b/assets/sprites/tulip.png new file mode 100644 index 0000000..a696d11 Binary files /dev/null and b/assets/sprites/tulip.png differ diff --git a/assets/sprites/venus_flytrap.png b/assets/sprites/venus_flytrap.png new file mode 100644 index 0000000..6513661 Binary files /dev/null and b/assets/sprites/venus_flytrap.png differ diff --git a/assets/sprites/winter_jasmine.png b/assets/sprites/winter_jasmine.png new file mode 100644 index 0000000..25868e8 Binary files /dev/null and b/assets/sprites/winter_jasmine.png differ diff --git a/assets/sprites/zinnia.png b/assets/sprites/zinnia.png new file mode 100644 index 0000000..9f8986e Binary files /dev/null and b/assets/sprites/zinnia.png differ diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/engine/battle.py b/engine/battle.py new file mode 100644 index 0000000..2674095 --- /dev/null +++ b/engine/battle.py @@ -0,0 +1,1189 @@ +from __future__ import annotations + +import random +from typing import List, Optional + +from .skills import ( + S_ABSORB, S_ATTACKED_THIS_TURN, S_ATTACK_DOWN, S_ATTACK_UP, + S_CRIT_DOWN, S_DEFENSE_DOWN, S_DEFENSE_UP, S_DENTING, S_DOT, S_HEAL_BOOST, + 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, + 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, +) +from .types import type_multiplier +from .unit import ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER, Unit +from .relics import by_key +from . import runes + +CRIT_MULT = 2.0 +VULNERABLE_MULT = 1.5 +WILT_TURNS = 3 +AOE_MULT = 0.75 # area-effect skills deal less damage per target +DEF_K = 75 # defense reduces damage by defense/(defense+DEF_K), so it always matters + +_DEBUFF_KEYS = (S_SPEED_DOWN, S_CRIT_DOWN, S_ATTACK_DOWN, S_DEFENSE_DOWN, + S_HEAL_REDUCTION, S_VULNERABLE, S_SLEEP, + S_INTOXICATE, S_OBLIVION, S_TETHER, S_MADNESS, S_DOT, S_WILT, + "stun") + +# Random stat debuff applied by Nightshade's Dark Corrosion (value, turns=2). +_CORROSION_DEBUFFS = ( + (S_SPEED_DOWN, 3), + (S_CRIT_DOWN, 10), + (S_ATTACK_DOWN, 10), + (S_DEFENSE_DOWN, 20), + (S_HEAL_REDUCTION, 30), +) + +_STATUS_NAMES = { + S_SLEEP: "Sleep", + S_VULNERABLE: "Vulnerable", + S_SPEED_DOWN: "Speed Down", + S_CRIT_DOWN: "Crit Down", + S_ATTACK_DOWN: "Attack Down", + S_DEFENSE_DOWN: "Defense Down", + S_HEAL_REDUCTION: "Healing Reduction", + S_INTOXICATE: "Intoxicate", + S_OBLIVION: "Oblivion", + S_TETHER: "Tether", + S_MADNESS: "Madness", + S_DENTING: "Denting", + S_ATTACK_UP: "Attack Up", + S_DEFENSE_UP: "Defense Up", + S_SPEED_UP: "Speed Up", + S_ICICLES: "Icicle", + S_SNOWFLAKES: "Snowflakes", + "stun": "Stun", +} + + +class Battle: + def __init__(self, player_units: List[Unit], enemy_units: List[Unit], + relics=()) -> None: + self.players = list(player_units) + self.enemies = list(enemy_units) + self.relics = list(relics) + self.units: List[Unit] = self.players + self.enemies + for i, u in enumerate(self.units): + u.sort_order = i + self.log: List[str] = [] + self.winner: Optional[int] = None + self.time: float = 0.0 + self.turn: int = 0 + self.current: Optional[Unit] = None + self.lotus_used: set = set() # teams whose Rebirth has already fired + self.relic_used: set = set() # teams whose one-shot relics (Second Wind) have fired + self._sweetpea_tethered: set = set() # allies tethered at Sweet Pea's turn start + self._hit_damage = {} + self._action: Optional[dict] = None + self.events: List[dict] = [] # transient UI events, cleared by the frontend + + # --- helpers ------------------------------------------------------- + def alive_players(self) -> List[Unit]: + return [u for u in self.players if u.alive] + + def alive_enemies(self) -> List[Unit]: + return [u for u in self.enemies if u.alive] + + def foes_of(self, unit: Unit) -> List[Unit]: + return self.alive_enemies() if unit.team == TEAM_PLAYER else self.alive_players() + + def allies_of(self, unit: Unit) -> List[Unit]: + return self.alive_players() if unit.team == TEAM_PLAYER else self.alive_enemies() + + @staticmethod + def _status_value(unit: Unit, key: str) -> int: + v = unit.status.get(key) + if v is None: + return 0 + return v[0] if isinstance(v, tuple) else v + + def _conditional_boost(self, unit: Unit) -> bool: + frac = unit.passive_value or 0.5 + return any(e.alive and e.hp < e.max_hp * frac for e in self.foes_of(unit)) + + def _soporific_active(self, unit: Unit) -> bool: + return any(e.alive and e.status.get(S_SLEEP, 0) > 0 for e in self.foes_of(unit)) + + def _attack(self, unit: Unit) -> int: + a = unit.attack + self._status_value(unit, S_ATTACK_UP) - self._status_value(unit, S_ATTACK_DOWN) + if unit.passive in ("orchid_epiphytic", "lily_passionate") and self._conditional_boost(unit): + a += 10 + if unit.passive == "lavender_soporific" and self._soporific_active(unit): + a += 10 + return max(0, a) + + def _speed(self, unit: Unit) -> int: + s = unit.speed + self._status_value(unit, S_SPEED_UP) - self._status_value(unit, S_SPEED_DOWN) + if unit.passive in ("orchid_epiphytic", "lily_passionate") and self._conditional_boost(unit): + s += 5 + if unit.passive == "lavender_soporific" and self._soporific_active(unit): + s += 5 + return max(1, s) + + def _defense(self, unit: Unit, ignore_pct: float = 0.0) -> int: + d = unit.defense + self._status_value(unit, S_DEFENSE_UP) + d = int(d * (1 + 0.03 * unit.status.get("def_stack", 0))) + down = self._status_value(unit, S_DEFENSE_DOWN) + if down: + d = int(d * (1 - down / 100.0)) + d = int(d * (1 - ignore_pct)) + return max(0, d) + + def _crit(self, unit: Unit) -> float: + pct = unit.crit * 100 - self._status_value(unit, S_CRIT_DOWN) + return max(0.0, pct / 100) + + # --- tank protection ---------------------------------------------- + def tanks_on_team(self, team: int) -> List[Unit]: + pool = self.players if team == TEAM_PLAYER else self.enemies + return [u for u in pool if u.alive and u.role == ROLE_TANK] + + def protectors_of(self, unit: Unit) -> List[Unit]: + return self.tanks_on_team(unit.team) + + def is_protected(self, unit: Unit) -> bool: + return unit.alive and unit.role != ROLE_TANK and bool(self.tanks_on_team(unit.team)) + + def _targetable(self, foes: List[Unit], bypass_tanks: bool = False) -> List[Unit]: + if bypass_tanks: + return foes + tanks = [u for u in foes if u.alive and u.role == ROLE_TANK] + return tanks if tanks else foes + + def _bypass_tanks(self, user: Unit, skill: Skill) -> bool: + if user.passive != "primrose_coldhearted": + return False + return (not user.first_skill_used) or skill.name == "Frostbite" + + # --- turn system (initiative timeline) ---------------------------- + def _interval(self, unit: Unit) -> float: + return 100.0 / max(1, self._speed(unit)) + + def _next_unit(self) -> Optional[Unit]: + alive = [u for u in self.units if u.alive] + if not alive: + return None + return min(alive, key=lambda u: (u.next_at, -self._speed(u), u.sort_order)) + + def progress(self, unit: Unit) -> float: + """0..1 how far a unit is from acting (drives the UI bar).""" + if not unit.alive: + return 0.0 + inter = self._interval(unit) + return max(0.0, min(1.0, 1 - (unit.next_at - self.time) / inter)) + + def start(self) -> Unit: + self.time = 0.0 + self.turn = 0 + self.log.append("Battle started!") + # Relics and start-of-battle passives must apply before intervals are + # computed, so speed changes actually affect the opening turn order. + for key in self.relics: + relic = by_key(key) + if relic is not None: + relic.apply(self) + for u in self.units: + if u.passive == "snowdrop_winter": + for foe in (self.enemies if u.team == TEAM_PLAYER else self.players): + if foe.alive: + foe.status[S_SPEED_DOWN] = (3, 3) + self.log.append(f"{u.name}'s Winter Breeze slows the enemy team!") + if u.passive == "jasmine_janus": + for ally in (self.players if u.team == TEAM_PLAYER else self.enemies): + if ally.alive: + ally.status[S_PREVENT_FIRST_DAMAGE] = 1 + self.log.append(f"{u.name}'s Janus Bloom protects her allies from the first hit!") + for u in self.units: + u.next_at = self._interval(u) + self.current = self._next_unit() + self.time = self.current.next_at + return self._advance() + + def next_turn(self) -> Optional[Unit]: + if self.winner is not None: + return self.current + cur = self.current + if cur is not None and cur.alive: + self._end_turn_status(cur) + if cur.passive == "sweetpea_scent": + # Clear the allies that were tethered at the START of Sweet Pea's + # turn (they've now served a full turn). Tethers she applies this + # very turn survive so her next attack can heal them. + for a in self.allies_of(cur): + if id(a) in self._sweetpea_tethered: + a.status.pop(S_TETHER, None) + self._sweetpea_tethered.clear() + cur.next_at += self._interval(cur) + nxt = self._next_unit() + if nxt is None: + return None + self.current = nxt + self.time = nxt.next_at + return self._advance() + + def _advance(self) -> Unit: + self.turn += 1 + unit = self.current + unit.reset_turn_flags() + for sk in unit.skills: + if sk.cooldown_left > 0: + sk.cooldown_left -= 1 + self._tick_dot(unit) + self._tick_wilt(unit) + self._tick_hot(unit) + self._tick_seed(unit) + if self.winner is not None: + return unit + if not unit.alive: + return self.next_turn() + if unit.passive == "sunflower_incandescent": + allies = self.allies_of(unit) + if allies: + low = min(allies, key=lambda a: a.hp) + if low.hp < low.max_hp: + amount = max(1, int(unit.attack * (unit.passive_value or 0.7))) + self._heal(unit, low, amount) + if unit.passive == "marigold_golden": + unit.status[S_ATTACKED_THIS_TURN] = 1 # first hit this turn is reduced + if unit.passive == "moonflower_night": + for e in self.foes_of(unit): + self._deal_damage(unit, e, 8 + 4 * self.turn, crit=False, flat=True) + if unit.passive == "hydrangea_ph": + unit.status["form"] = "alkaline" if unit.status.get("form", "acidic") == "acidic" else "acidic" + if unit.passive == "sweetpea_scent": + # Snapshot which allies are already tethered this turn, so they can + # be cleared when her turn ends (after her attack healed them). + self._sweetpea_tethered = {id(a) for a in self.allies_of(unit) + if isinstance(a.status.get(S_TETHER), tuple)} + if unit.status.get(S_SLEEP, 0) > 0 or unit.status.get("stun", 0) > 0: + self.log.append(f"{unit.name} is asleep/stunned and cannot take a turn") + if unit.status.get("stun", 0) > 0: + unit.status.pop("stun", None) + self.log.append(f" {unit.name} shakes off the stun!") + return self.next_turn() + return unit + + def _end_turn_status(self, unit: Unit) -> None: + for key in (S_SPEED_DOWN, S_CRIT_DOWN, S_ATTACK_DOWN, S_DEFENSE_DOWN, + S_HEAL_REDUCTION, S_ATTACK_UP, S_DEFENSE_UP, + S_SPEED_UP, S_INTOXICATE, S_OBLIVION, S_ABSORB, S_LYING_WAIT): + if key in unit.status: + value, dur = unit.status[key] + dur -= 1 + if dur <= 0: + del unit.status[key] + else: + unit.status[key] = (value, dur) + + # --- status ticking ------------------------------------------------ + def _tick_dot(self, unit: Unit) -> None: + stacks = unit.status.get(S_DOT, []) + if not stacks: + return + remaining = [] + for dmg, turns in stacks: + self.log.append(f"{unit.name} suffers {dmg} from a damage-over-time effect") + self._deal_damage(None, unit, dmg, crit=False, flat=True) + if self.winner is not None: + return + if turns > 1: + remaining.append((dmg, turns - 1)) + if remaining: + unit.status[S_DOT] = remaining + else: + unit.status.pop(S_DOT, None) + + def _tick_wilt(self, unit: Unit) -> None: + v = unit.status.get(S_WILT) + if not v: + return + total, turns = v + tick = max(1, total // turns) + self.log.append(f"{unit.name} wilts for {tick}") + self._deal_damage(None, unit, tick, crit=False, flat=True) + if self.winner is not None: + return + remaining = total - tick + if turns <= 1 or remaining <= 0: + unit.status.pop(S_WILT, None) + else: + unit.status[S_WILT] = (remaining, turns - 1) + + def _tick_hot(self, unit: Unit) -> None: + stacks = unit.status.get(S_HOT, []) + if not stacks: + return + remaining = [] + for amount, turns in stacks: + self._heal(None, unit, amount) + if turns > 1: + remaining.append((amount, turns - 1)) + if remaining: + unit.status[S_HOT] = remaining + else: + unit.status.pop(S_HOT, None) + + def _tick_seed(self, unit: Unit) -> None: + v = unit.status.get(S_SEED) + if not v: + return + power, turns = v + if turns <= 0: + self.log.append(f"A seed erupts on {unit.name}!") + self._deal_damage(None, unit, power, crit=False, flat=True) + unit.status.pop(S_SEED, None) + else: + unit.status[S_SEED] = (power, turns - 1) + + # --- heal / shield ------------------------------------------------- + def _heal(self, source: Optional[Unit], target: Unit, amount: int) -> int: + if not target.alive: + return 0 + if isinstance(target.status.get(S_TETHER), tuple) and target.status[S_TETHER][0] > 0: + amount = int(amount * 1.3) + if target.status.get(S_HEAL_REDUCTION, (0, 0))[0] if isinstance(target.status.get(S_HEAL_REDUCTION), tuple) else False: + amount = int(amount * (1 - target.status[S_HEAL_REDUCTION][0] / 100.0)) + if target.status.get(S_HEAL_BOOST, 0): + amount = int(amount * (1 + target.status[S_HEAL_BOOST] / 100.0)) + yellow = self._rune_pct(target, "healing") + if yellow: + amount = int(amount * (1 + yellow)) + healed = min(target.max_hp - target.hp, amount) + if healed > 0: + target.hp += healed + if self._action is None: + src = f" from {source.name}" if source else "" + self.log.append(f"{target.name} recovers {healed} HP{src}") + if self._action is not None: + self._action["heal"] += healed + self.events.append({"type": "heal", "source": source, "target": target, "amount": healed}) + if source and source.passive == "daisy_gentle": + self._daisy_gentle(source, target) + return healed + + def _shield(self, target: Unit, amount: int, source: Optional[Unit] = None) -> None: + if not target.alive: + return + blue = self._rune_pct(target, "shield") + if blue: + amount = int(amount * (1 + blue)) + target.status[S_SHIELD] = target.status.get(S_SHIELD, 0) + amount + 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_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).""" + return int(base * (1 + (user.attack if user else 0) / 200)) + + # --- damage -------------------------------------------------------- + def _damage_multipliers(self, attacker: Optional[Unit], target: Unit) -> float: + mult = 1.0 + if attacker is None: + return mult + mult *= type_multiplier(attacker.plant_type, target.plant_type) + if target.status.get(S_DENTING, 0): + mult *= 1 + 0.03 * target.status[S_DENTING] + # zinnia_solar: enemies Zinnia has hit deal less damage + weaken = attacker.status.get(S_ZINNIA_WEAKEN, 0) + 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: + team = self.players if attacker.team == TEAM_PLAYER else self.enemies + if any(u.passive == "tulip_equinox" for u in team): + mult *= 1.1 + # hellebore_toxin: madness modifiers + if attacker.status.get(S_MADNESS, 0): + mult *= 1.5 if attacker.team == TEAM_PLAYER else 1.3 + if target.status.get(S_MADNESS, 0): + mult *= 1.3 if target.team == TEAM_PLAYER else 1.5 + if isinstance(target.status.get(S_TETHER), tuple) and target.status[S_TETHER][0] > 0: + mult *= 0.9 + if attacker.passive == "hibiscus_intensifying" and target.max_hp > attacker.max_hp: + mult *= 1.25 + # runes + red = self._rune_pct(attacker, "damage") + if red: + mult *= 1 + red + white = self._rune_pct(target, "damage_taken") + if white: + mult *= max(0.0, 1 - white) + return mult + + def _rune_pct(self, unit: Unit, effect: str) -> float: + """Total effect percentage (as a fraction) from the unit's equipped + runes matching `effect`. Runes stack additively.""" + if not unit.runes: + return 0.0 + return sum(pct for eff, pct in unit.runes if eff == effect) + + def _deal_damage(self, attacker: Optional[Unit], target: Unit, power: int, + crit: bool = False, hits: int = 1, ignore_def_pct: float = 0.0, + flat: bool = False, label: str = "", + mult: float = 1.0) -> int: + total = 0 + janus_blocked = False + for _ in range(max(1, hits)): + if not target.alive: + break + if flat: + dmg = power + else: + atk = self._attack(attacker) if attacker else 0 + offense = power + int(atk * 0.5) + dfn = self._defense(target, ignore_def_pct) + dmg = int(offense * (1 - dfn / (dfn + DEF_K))) + dmg = max(1, dmg) + dmg = int(dmg * self._damage_multipliers(attacker, target)) + dmg += 5 * self._status_value(attacker, S_SNOWFLAKES) if attacker else 0 + if target.status.get(S_VULNERABLE, 0) > 0: + dmg = int(dmg * VULNERABLE_MULT) + if crit: + dmg = int(dmg * CRIT_MULT) + dmg = self._apply_damage_taken_mods(attacker, target, dmg) + dmg = int(dmg * mult) + if dmg <= 0: + continue + if target.status.get(S_PREVENT_FIRST_DAMAGE, 0): + target.status.pop(S_PREVENT_FIRST_DAMAGE, None) + # Winter Jasmine's Janus Bloom (or the Protective Aura blessing) + # softens the first hit: it deals 80% less damage instead of + # being fully blocked. + team = self.players if target.team == TEAM_PLAYER else self.enemies + if any(u.passive == "jasmine_janus" for u in team): + self.log.append(f" {target.name}'s Janus Bloom softens the hit by 80%!") + else: + self.log.append(f" {target.name} is protected (takes 20% of the hit)!") + dmg = max(1, int(dmg * 0.2)) + self._hit_damage[id(target)] = dmg + janus_blocked = True + split = 0 + if attacker and self._status_value(attacker, S_INTOXICATE) > 0: + split = dmg // 2 # Intoxicate: half splashes onto the attacker's own ally + actual = self._absorb(attacker, target, dmg - split) + total += actual + if actual > 0: + if self._action is None: + suffix = f" (from {label})" if label else "" + self.log.append(f" {target.name} takes {actual} damage{suffix}") + self.events.append({"type": "damage", "source": attacker, "target": target, "amount": actual}) + self._after_hit(attacker, target, actual) + if split > 0: + teammates = [u for u in self.units if u.alive and u.team == attacker.team and u is not attacker] + if teammates: + other = random.choice(teammates) + splashed = self._absorb(attacker, other, split) + total += splashed + if splashed > 0: + if self._action is None: + self.log.append(f" {other.name} takes {splashed} from Intoxicate!") + self.events.append({"type": "damage", "source": attacker, "target": other, "amount": splashed}) + if self.winner is not None: + break + if attacker is not None and total > 0: + wilt_pct = self._rune_pct(attacker, "wilt") + if wilt_pct: + self._apply_wilt_damage(target, int(total * wilt_pct)) + if total > 0 or not janus_blocked: + self._hit_damage[id(target)] = total + return total + + def _apply_damage_taken_mods(self, attacker: Optional[Unit], target: Unit, dmg: int) -> int: + if target.status.get(S_ATTACKED_THIS_TURN, 0): + dmg = int(dmg * 0.7) # marigold: first hit each turn is reduced + target.status.pop(S_ATTACKED_THIS_TURN, None) + if isinstance(target.status.get(S_ABSORB), tuple) and target.status[S_ABSORB][0] > 0: + dmg = int(dmg * 0.8) + return max(0, dmg) + + 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) + if shield > 0: + if shield >= dmg: + target.status[S_SHIELD] = shield - dmg + self.log.append(f" {target.name}'s shield absorbs {dmg}") + self.events.append({"type": "shield", "target": target, "amount": dmg}) + if target.passive == "camelia_frigid": + target.status[S_ICICLES] = target.status.get(S_ICICLES, 0) + 2 + if reflect and attacker: + self._deal_damage(None, attacker, 40, crit=False, flat=True) + if target.status.get("vigil", 0): + self._vigil_counter(attacker, target) + return 0 + dmg -= shield + self.log.append(f" {target.name}'s shield shatters!") + self.events.append({"type": "shield", "target": target, "amount": shield}) + target.status.pop(S_SHIELD, None) + if reflect and attacker: + self._deal_damage(None, attacker, 40, crit=False, flat=True) + target.hp -= dmg + if target.passive == "camelia_frigid": + target.status[S_ICICLES] = target.status.get(S_ICICLES, 0) + 1 + if target.status.get("vigil", 0): + self._vigil_counter(attacker, target) + self._passive_on_taken(target, dmg, attacker) + if target.hp <= 0: + self._kill(target, attacker) + return dmg + + def _vigil_counter(self, attacker: Optional[Unit], target: Unit) -> None: + if attacker and target.status.get("vigil_source"): + self._deal_damage(target.status["vigil_source"], attacker, 45, crit=False) + target.status.pop("vigil", None) + target.status.pop("vigil_source", None) + + def _passive_on_taken(self, target: Unit, dmg: int, attacker: Optional[Unit] = None) -> None: + if target.passive == "daffodil_toxic" and attacker and attacker.alive: + if attacker.status.get(S_WILT): + remaining = attacker.status[S_WILT][0] + self._heal(target, target, int(remaining * 0.5)) + self._note(f" {target.name}'s Toxic Sap drains {attacker.name}'s Wilt!") + else: + self._apply_wilt_damage(attacker, int(dmg * 0.5)) + elif target.passive == "rose_briars" and attacker and attacker.alive: + self._apply_wilt_damage(attacker, int(dmg * 0.4)) + if target.passive == "chrys_longevity": + target.status["def_stack"] = target.status.get("def_stack", 0) + 1 + if target.passive == "dandelion_cover" and not target.status.get("cover_used", False): + if target.hp > 0 and target.hp < target.max_hp * 0.4: + target.status["cover_used"] = True + self._shield(target, int(target.max_hp * 0.5)) + self.log.append(f" {target.name} is shielded by Cover!") + + def _after_hit(self, attacker: Optional[Unit], target: Unit, actual: int) -> None: + if attacker is None: + return + if attacker.passive == "zinnia_solar": + stacks = min(30, target.status.get(S_ZINNIA_WEAKEN, 0) + 1) + target.status[S_ZINNIA_WEAKEN] = stacks + if attacker.passive == "flytrap_feed": + self._heal(attacker, attacker, int(actual * 0.2)) + if isinstance(target.status.get(S_LYING_WAIT), tuple) and target.status[S_LYING_WAIT][0] > 0: + v = target.status[S_LYING_WAIT] + target.status[S_LYING_WAIT] = (v[0] + 10, v[1]) + if attacker.passive == "poppy_hypnotic" and roll_crit(0.10): + target.status[S_INTOXICATE] = (1, 1) + target.status[S_OBLIVION] = (1, 1) + self.log.append(f" {target.name} is Intoxicated and under Oblivion!") + if attacker.passive == "sweetpea_scent": + for a in self.allies_of(attacker): + if isinstance(a.status.get(S_TETHER), tuple) and a.status[S_TETHER][0] > 0: + self._heal(attacker, a, int(actual * 0.5)) + + def _consume_hits_statuses(self, target: Unit) -> None: + if target.status.get(S_SLEEP, 0) > 0: + target.status[S_SLEEP] -= 1 + if target.status[S_SLEEP] <= 0: + del target.status[S_SLEEP] + self.log.append(f" {target.name} wakes up!") + if target.status.get(S_VULNERABLE, 0) > 0: + target.status[S_VULNERABLE] -= 1 + if target.status[S_VULNERABLE] <= 0: + del target.status[S_VULNERABLE] + self.log.append(f" {target.name} is no longer vulnerable") + + def _kill(self, unit: Unit, attacker: Optional[Unit] = None) -> None: + unit.alive = False + unit.hp = 0 + unit.status.pop(S_SHIELD, None) # dead units keep no shield bar + self.log.append(f"{unit.name} is defeated!") + self.events.append({"type": "kill", "target": unit}) + if unit.team not in self.lotus_used: + team = self.players if unit.team == TEAM_PLAYER else self.enemies + lotus = next((p for p in team if p.passive == "lotus_rebirth" and p.alive), None) + if lotus: + self.lotus_used.add(unit.team) + unit.alive = True + unit.hp = int(unit.max_hp * 0.5) + unit.status = {} + self.log.append(f"{lotus.name}'s Rebirth revives {unit.name} at 50% HP!") + return + if unit.team not in self.relic_used and "second_wind" in self.relics: + self.relic_used.add(unit.team) + unit.alive = True + unit.hp = int(unit.max_hp * 0.5) + unit.status = {} + self.log.append(f"{unit.name} is saved by Second Wind at 50% HP!") + return + if attacker is not None: + team = self.players if unit.team == TEAM_PLAYER else self.enemies + for p in team: + if p.passive == "snowdrop_winter" and p.alive: + self._add_dot(attacker, 45, 2) + self.log.append(f"{p.name}'s Winter Breeze Chills {attacker.name}!") + if unit.status.get(S_DOT): + team = self.players if unit.team == TEAM_PLAYER else self.enemies + others = [u for u in team if u.alive and u is not unit] + if others: + spread_to = min(others, key=lambda u: u.hp) + for dmg, turns in unit.status[S_DOT]: + self._add_dot(spread_to, dmg, turns) + self._apply_corrosion_debuff(spread_to) + self.log.append(f"{unit.name}'s corruption spreads to {spread_to.name}!") + if not self.alive_enemies(): + self.winner = TEAM_PLAYER + self.log.append("VICTORY! All enemies defeated.") + elif not self.alive_players(): + self.winner = TEAM_ENEMY + self.log.append("DEFEAT... All allies have fallen.") + + # --- status helpers ------------------------------------------------ + def _apply_wilt_damage(self, target: Unit, total: int) -> None: + if total <= 0: + return + turns = WILT_TURNS - 1 if target.passive == "magnolia_nobility" else WILT_TURNS + cur = target.status.get(S_WILT) + if cur: + target.status[S_WILT] = (cur[0] + total, turns) + else: + target.status[S_WILT] = (total, turns) + self._note(f"{target.name} is afflicted with Wilt ({total} over {turns} turns)") + + def _add_dot(self, target: Unit, dmg: int, turns: int) -> None: + if target.passive == "magnolia_nobility": + turns = max(1, turns - 1) + stacks = target.status.get(S_DOT, []) + stacks.append((dmg, turns)) + target.status[S_DOT] = stacks + self._note(f"{target.name} is afflicted ({dmg}/turn for {turns} turns)") + + def _apply_corrosion_debuff(self, target: Unit) -> None: + key, value = random.choice(_CORROSION_DEBUFFS) + target.status[key] = (value, 2) + name = _STATUS_NAMES.get(key, key.replace("_", " ").title()) + self._note(f"{target.name} is {name.lower()} (-{value}) for 2 turns") + + def _remove_debuffs(self, unit: Unit) -> None: + for k in _DEBUFF_KEYS: + unit.status.pop(k, None) + + # --- actions ------------------------------------------------------- + def valid_skill_targets(self, user: Unit, skill: Skill) -> List[Unit]: + if not user.alive or skill.cooldown_left > 0: + return [] + if skill.requires_form and skill.requires_form != user.status.get("form"): + return [] + if skill.target == TARGET_ENEMY: + 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) + if skill.target == TARGET_ALLY: + return self.allies_of(user) + if skill.target == TARGET_ALL_ALLIES: + return self.allies_of(user) + if skill.target == TARGET_SELF: + return [user] + if skill.target == TARGET_ANY: + return self.alive_players() + self.alive_enemies() + return [] + + def _effective_hits(self, unit: Unit, skill: Skill, target: Optional[Unit]) -> int: + hits = skill.hits + if skill.mechanic == "hits_per_effect" and target is not None: + hits += len([k for k in target.status if k != "form"]) + elif skill.mechanic == "hits_per_self_effect": + hits += len([k for k in unit.status if k != "form"]) + return hits + + def _skill_score(self, unit: Unit, skill: Skill) -> int: + """Selection score for a ready skill (lower = better). Skills whose hit + count scales with effects (hits_per_effect / hits_per_self_effect) fold + their effective hit count in, so the AI picks whichever attacks more; + everything else keeps its static priority.""" + if skill.mechanic == "hits_per_self_effect": + hits = self._effective_hits(unit, skill, None) + return skill.priority - hits + if skill.mechanic == "hits_per_effect": + targets = self.valid_skill_targets(unit, skill) + if targets: + hits = max(self._effective_hits(unit, skill, t) for t in targets) + else: + hits = skill.hits + return skill.priority - hits + return skill.priority + + def best_skill(self, unit: Unit) -> Optional[Skill]: + ready = [s for s in unit.skills + if s.cooldown_left <= 0 and (not s.requires_form or s.requires_form == unit.status.get("form"))] + if not ready: + return None + if isinstance(unit.status.get(S_OBLIVION), tuple) and unit.status[S_OBLIVION][0] > 0: + return min(ready, key=lambda s: s.power) + allies_full = all(a.hp >= a.max_hp for a in self.allies_of(unit)) + + def heal_only(s: Skill) -> bool: + # Only instant heals are "wasted" at full HP. HoT skills apply over + # time, so they're fine to pick even when everyone is full. + if s.kind != "heal": + return False + for eff in s.effects: + if eff.kind in (EFF_WILT, EFF_DOT): + return False + return True + + def wasted(s: Skill) -> bool: + if heal_only(s) and allies_full: + return True + if s.mechanic == "puff_up": + # self heal + cleanse: pointless if full HP and nothing to cleanse + return unit.hp >= unit.max_hp and not any(k in unit.status for k in _DEBUFF_KEYS) + return False + + candidates = [s for s in ready if not wasted(s)] + if not candidates: + candidates = ready + # Cleanse (puff_up) is a priority when the unit is debuffed: it heals + # AND removes the debuffs, so prefer it over an equally-ready skill. + if any(k in unit.status for k in _DEBUFF_KEYS): + cleanse = next((s for s in candidates if s.mechanic == "puff_up"), None) + if cleanse is not None: + return cleanse + return min(candidates, key=lambda s: self._skill_score(unit, s)) + + def use_skill(self, user: Unit, skill: Skill, target: Optional[Unit] = None) -> bool: + if user is not self.current or not user.alive: + return False + if skill.cooldown_left > 0: + return False + 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: + ready = [s for s in user.skills if s.cooldown_left <= 0] + if ready and min(ready, key=lambda s: s.power) is not skill: + return False # Oblivion: only the weakest skill may be used + if skill.target == TARGET_ALL_ENEMIES: + target = None + elif skill.target == TARGET_ALL_ALLIES: + target = None + elif skill.target == TARGET_SELF: + target = user + elif skill.target == TARGET_TWO_ENEMIES: + target = None + elif target is None or target not in self.valid_skill_targets(user, skill): + return False + if not skill.extra_action: + user.acted = True + skill.cooldown_left = skill.cooldown + self._hit_damage = {} + self._action = {"heal": 0, "shield": 0, "effects": []} + log_index = len(self.log) + self._use_skill_primary(user, skill, target) + self._use_skill_effects(user, skill, target) + self._log_action(user, skill, target, log_index) + self._action = None + if skill.target == TARGET_ENEMY or skill.target == TARGET_ALLY or skill.target == TARGET_SELF: + user.first_skill_used = True + return True + + def _log_action(self, user: Unit, skill: Skill, target: Optional[Unit], index: int) -> None: + dmg = sum(self._hit_damage.values()) + heal = self._action["heal"] if self._action else 0 + shield = self._action["shield"] if self._action else 0 + effects = self._action["effects"] if self._action else [] + + # dedupe effects by name, keeping the longest duration + best = {} + for name, dur, hits in effects: + if name not in best or dur > best[name][1]: + best[name] = (name, dur, hits) + effects = list(best.values()) + + if skill.target == TARGET_ALL_ENEMIES or skill.target == TARGET_TWO_ENEMIES: + tdesc = "all enemies" + elif skill.target == TARGET_ALL_ALLIES: + tdesc = "all allies" + elif skill.target == TARGET_SELF: + tdesc = "itself" + else: + tdesc = target.name if target else "the target" + + results = [] + if dmg > 0: + results.append(f"for {dmg} damage") + if heal > 0: + results.append(f"for {heal} health") + if shield > 0: + results.append(f"for {shield} shield") + + if results: + verb = "attacks" + if heal > 0 and dmg == 0 and shield == 0: + verb = "heals" + elif shield > 0 and dmg == 0 and heal == 0: + verb = "shields" + elif dmg > 0 and heal > 0 and shield == 0: + verb = "attacks and heals" + line = f"{user.name} {verb} {tdesc} " + " and ".join(results) + else: + line = f"{user.name} uses {skill.name} on {tdesc}!" + + if effects: + parts = [] + for name, dur, hits in effects: + if dur: + unit_word = "hit(s)" if hits else "turn(s)" + parts.append(f"{name} for {dur} {unit_word}") + else: + parts.append(name) + line += " (and applied " + ", ".join(parts) + ")" + + self.log.insert(index, {"text": line, "team": user.team}) + + def _resolve_power(self, user: Unit, skill: Skill, target: Unit) -> int: + power = skill.power + if skill.mechanic == "wilt_boost" and target.status.get(S_WILT): + power = skill.power_alt + elif skill.mechanic == "low_hp_boost" and target.hp < target.max_hp * 0.4: + power = skill.power_alt + elif skill.mechanic == "high_hp_boost" and target.hp > target.max_hp * 0.8: + power = skill.power_alt + elif skill.mechanic == "thresh_boost" and (target.hp > target.max_hp * 0.8 or target.hp < target.max_hp * 0.2): + power = skill.power_alt + return power + + def _use_skill_primary(self, user: Unit, skill: Skill, target: Optional[Unit]) -> None: + ignore_def = 0.0 + if user.passive == "peony_robust": + ignore_def = 0.2 + if skill.mechanic == "ignore_defense": + ignore_def = min(1.0, ignore_def + (skill.power_alt or 0.3)) + hits = skill.hits + if skill.mechanic == "hits_per_effect" and target is not None: + hits += len([k for k in target.status if k != "form"]) + elif skill.mechanic == "hits_per_self_effect": + hits += len([k for k in user.status if k != "form"]) + + if skill.kind == "heal": + for t in ([target] if target else self.allies_of(user)): + if skill.mechanic == "full_heal": + amount = t.max_hp + else: + # heals keep pace with damage and growing health pools: + # base + half the caster's Attack + amount = skill.power + int(self._attack(user) * 0.5) + if skill.mechanic == "pacify" and t.hp < t.max_hp * 0.4: + t.status.pop(S_MADNESS, None) + self.log.append(f" {t.name}'s Madness is soothed away!") + self._heal(user, t, amount) + return + if skill.kind == "shield": + for t in ([target] if target else self.allies_of(user)): + self._shield(t, self._shield_power(user, skill.power), source=user) + return + if skill.kind == "utility": + self._utility_skill(user, skill, target) + return + + if skill.mechanic == "ramping": + skill.power += 5 + if skill.mechanic == "moon_double": + hits = 2 if roll_crit(min(0.9, 0.2 + 0.1 * self.turn)) else 1 + 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.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES): + targets = self.foes_of(user) + if skill.target == TARGET_TWO_ENEMIES: + targets = random.sample(targets, min(2, len(targets))) + for t in targets: + power = self._resolve_power(user, skill, t) + power_bonus + self._deal_damage(user, t, power, crit=roll_crit(self._crit(user)), + hits=hits, ignore_def_pct=ignore_def, + label=f"{user.name}'s {skill.name}", mult=AOE_MULT) + if skill.mechanic == "strip_buffs_aoe": + self._strip_buffs(t) + if skill.mechanic == "delay_all": + t.next_at += self._interval(t) * 0.2 + if self._action is None: + self.log.append(f" {t.name} is delayed!") + return + t = target + power = self._resolve_power(user, skill, t) + power_bonus + self._deal_damage(user, t, power, crit=roll_crit(self._crit(user)), + hits=hits, ignore_def_pct=ignore_def, + label=f"{user.name}'s {skill.name}") + self._after_primary_damage(user, skill, t) + + def _after_primary_damage(self, user: Unit, skill: Skill, target: Unit) -> None: + dealt = self._hit_damage.get(id(target), 0) + if skill.mechanic == "consume_wilt" and target.status.get(S_WILT): + self._heal(user, user, dealt) + elif skill.mechanic == "spore_siphon": + pct = (skill.power_alt or 25) / 100.0 + self._heal(user, user, int(dealt * pct)) + elif skill.mechanic == "shield_lifesteal": + pct = (skill.power_alt or 15) / 100.0 + self._shield(user, int(dealt * pct), source=user) + if skill.mechanic == "ph_cloud": + if user.status.get("form") == "alkaline": + for a in self.allies_of(user): + self._heal(user, a, int(dealt * 0.3)) + 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) + if skill.mechanic == "strip_buffs": + self._strip_buffs(target) + if skill.mechanic == "tether_lowest": + allies = [a for a in self.allies_of(user) if a is not user] + if allies: + 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}") + + def _strip_buffs(self, unit: Unit) -> None: + for k in (S_ATTACK_UP, S_DEFENSE_UP, S_SPEED_UP, S_SHIELD): + if unit.status.pop(k, None) is not None: + self.log.append(f" {unit.name} loses a buff ({k})") + break + + def _use_skill_effects(self, user: Unit, skill: Skill, target: Optional[Unit]) -> None: + if skill.mechanic == "crimson_brew": + user.status[S_ATTACK_UP] = (10, 2) + if self._action is not None: + self._action["effects"].append(("Attack up", 2, False)) + if not skill.effects: + return + if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES): + targets = self.foes_of(user) + elif skill.target == TARGET_ALL_ALLIES: + targets = self.allies_of(user) + elif target is not None: + targets = [target] + else: + targets = self.foes_of(user) + for eff in skill.effects: + for t in targets: + if not t.alive: + continue + if eff.kind == EFF_SHIELD: + self._shield(t, self._shield_power(user, eff.value), source=user) + elif eff.kind == EFF_WILT: + self._apply_wilt_damage(t, int(self._hit_damage.get(id(t), skill.power) * eff.value / 100.0)) + if self._action is not None: + self._action["effects"].append(("Wilt", WILT_TURNS, False)) + elif eff.kind == EFF_DOT: + self._add_dot(t, eff.value, eff.duration) + if user.passive == "nightshade_corruption": + self._apply_corrosion_debuff(t) + if self._action is not None: + self._action["effects"].append(("Damage over time", eff.duration, False)) + elif eff.kind == EFF_HOT: + stacks = t.status.get(S_HOT, []) + stacks.append((eff.value, eff.duration)) + t.status[S_HOT] = stacks + if self._action is not None: + self._action["effects"].append(("Heal over time", eff.duration, False)) + elif eff.kind == EFF_HEAL: + self._heal(user, t, eff.value + int(self._attack(user) * 0.5)) + elif eff.kind == EFF_STATUS: + self._apply_status_effect(user, t, eff) + + def _apply_status_effect(self, user: Unit, target: Unit, eff) -> None: + key, value, duration = eff.key, eff.value, eff.duration + name = _STATUS_NAMES.get(key, key.replace("_", " ").title()) + dur, hits = 0, False + if key in (S_SLEEP, S_VULNERABLE): + dur, hits = value, True + elif duration > 0: + dur = duration + + detail = "" + if key == S_SLEEP: + target.status[S_SLEEP] = value + detail = f"{target.name} falls asleep!" + elif key == S_VULNERABLE: + target.status[S_VULNERABLE] = target.status.get(S_VULNERABLE, 0) + value + detail = f"{target.name} is vulnerable for {target.status[S_VULNERABLE]} hit(s)" + elif key == S_MADNESS: + target.status[S_MADNESS] = target.status.get(S_MADNESS, 0) + value + detail = f"{target.name} now has Madness ({target.status[S_MADNESS]})" + dur, hits = 0, False + elif key == S_DENTING: + target.status[S_DENTING] = target.status.get(S_DENTING, 0) + value + detail = f"{target.name} permanently takes {value}% more damage" + dur, hits = 0, False + elif key == S_ICICLES: + target.status[S_ICICLES] = target.status.get(S_ICICLES, 0) + value + elif key == S_SNOWFLAKES: + target.status[S_SNOWFLAKES] = target.status.get(S_SNOWFLAKES, 0) + value + detail = f"{target.name} gathers snowflakes (+{value} hit damage)" + dur, hits = 0, False + elif key == "stun": + if target.passive == "magnolia_nobility": + detail = f"{target.name} is immune to stuns!" + else: + target.status["stun"] = value + detail = f"{target.name} is stunned!" + else: + target.status[key] = (value, duration) + detail = f"{target.name} gains {key.replace('_', ' ')} ({value})" + + if self._action is not None: + self._action["effects"].append((name, dur, hits)) + elif detail: + self.log.append(detail) + + def _utility_skill(self, user: Unit, skill: Skill, target: Optional[Unit]) -> None: + if skill.mechanic == "reset_cooldowns": + t = target or user + for s in t.skills: + if s is not skill: # the skill being cast keeps its own cooldown + s.cooldown_left = 0 + self._note(f"{t.name}'s cooldowns are reset!") + elif skill.mechanic == "extend_timers": + for u in self.allies_of(user) + self.foes_of(user): + for k, v in list(u.status.items()): + if isinstance(v, tuple): + u.status[k] = (v[0], v[1] + 1) + self._note("Alkaline Blossom extends all buffs and debuffs!") + elif skill.mechanic == "clear_debuffs_allies": + for a in self.allies_of(user): + self._remove_debuffs(a) + self._note("All allies are purified!") + elif skill.mechanic == "shatter_icicles": + n = user.status.get(S_ICICLES, 0) + user.status[S_ICICLES] = 0 + for e in self.foes_of(user): + self._deal_damage(user, e, skill.power * max(1, n), crit=roll_crit(self._crit(user)), + mult=AOE_MULT) + elif skill.mechanic == "snowflakes": + for a in self.allies_of(user): + a.status[S_SNOWFLAKES] = a.status.get(S_SNOWFLAKES, 0) + 10 + self._note("All allies gain +10 damage on hits!") + elif skill.mechanic == "perma_hp_def": + for a in self.allies_of(user): + bonus = int(a.max_hp * 0.15) + a.max_hp += bonus + a.hp += bonus + a.defense = int(a.defense * 1.10) + self._note("Golden Dynasty empowers all allies!") + elif skill.mechanic == "lying_wait": + user.status[S_LYING_WAIT] = (0, 1) + self._note(f"{user.name} lies in wait...") + elif skill.mechanic == "vigil": + t = target if target is not None else user + self._remove_debuffs(t) + self._shield(t, self._shield_power(user, 60)) + t.status["vigil"] = 1 + t.status["vigil_source"] = user + self._note(f"{t.name} is cleansed, shielded, and readies a counter!") + elif skill.mechanic == "sow": + target.status[S_SEED] = (115, 2) + self._note(f"{target.name} is seeded... it will erupt in 2 turns!") + elif skill.mechanic == "puff_up": + self._heal(user, user, int(user.max_hp * 0.3)) + self._remove_debuffs(user) + self._note(f"{user.name} puffs up and cleanses itself!") + elif skill.mechanic == "absorb": + user.status[S_ABSORB] = (20, 1) + self._note(f"{user.name} hardens, taking 20% less damage!") + + def _note(self, msg: str) -> None: + """Log a detail line, but not while resolving a skill action (those go + into the one-line action summary instead).""" + if self._action is None: + self.log.append(msg) + + def _daisy_gentle(self, source: Unit, target: Unit) -> None: + for k in _DEBUFF_KEYS: + if target.status.pop(k, None) is not None: + self.log.append(f" Gentle Encouragement removes a debuff from {target.name}") + break + target.status[S_SPEED_UP] = (3, 1) + + # --- AI ------------------------------------------------------------ + def enemy_ai(self, unit: Unit) -> bool: + """Returns True if the enemy used a skill (extra-action skills leave + the unit unacted so the caller may let it act again).""" + if unit is not self.current or unit.team != TEAM_ENEMY or not unit.alive: + return False + foes = self.foes_of(unit) + if not foes: + return False + if isinstance(unit.status.get(S_OBLIVION), tuple) and unit.status[S_OBLIVION][0] > 0: + ready = [s for s in unit.skills if s.cooldown_left <= 0] + if not ready: + return False + skill = min(ready, key=lambda s: s.power) + target = min(foes, key=lambda t: t.hp) + return self.use_skill(unit, skill, target) + + ready = sorted((s for s in unit.skills + if s.cooldown_left <= 0 + and (not s.requires_form or s.requires_form == unit.status.get("form"))), + key=lambda s: self._skill_score(unit, s)) + for skill in ready: + if skill.kind in ("heal", "shield"): + low = [a for a in self.allies_of(unit) if a.hp < a.max_hp * 0.7] + if skill.kind == "shield" and skill.target != TARGET_SELF: + target = self._shield_target(unit) + if target: + return self.use_skill(unit, skill, target) + continue + if low: + return self.use_skill(unit, skill, min(low, key=lambda a: a.hp)) + continue + if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES): + if len(foes) >= 2: + return self.use_skill(unit, skill, None) + continue + if skill.target in (TARGET_ALLY, TARGET_ALL_ALLIES): + allies = self.allies_of(unit) + if not allies: + continue + if skill.mechanic == "reset_cooldowns": + if not any(x.cooldown_left > 0 for a in allies for x in a.skills): + continue + target = max(allies, key=lambda a: sum( + x.cooldown_left for x in a.skills if x.cooldown_left > 0)) + return self.use_skill(unit, skill, target) + return self.use_skill(unit, skill, min(allies, key=lambda a: a.hp)) + if skill.target == TARGET_SELF: + return self.use_skill(unit, skill, None) + if skill.target == TARGET_ANY: + targets = self.valid_skill_targets(unit, skill) + if targets: + return self.use_skill(unit, skill, min(targets, key=lambda t: t.hp)) + continue + if skill.target == TARGET_ENEMY: + targets = self.valid_skill_targets(unit, skill) + if targets: + target = self._best_target(unit, skill, targets) + return self.use_skill(unit, skill, target) + return False + + def _best_target(self, unit: Unit, skill: Skill, targets: List[Unit]) -> Unit: + """Pick the best target for `skill`: for effect-scaling hits, prefer the + enemy carrying the most effects (ties -> lowest HP); otherwise lowest HP.""" + if skill.mechanic == "hits_per_effect": + return max(targets, key=lambda t: (len([k for k in t.status if k != "form"]), -t.hp)) + return min(targets, key=lambda t: t.hp) + + def _shield_target(self, unit: Unit) -> Optional[Unit]: + """Pick the best ally to shield. Prefer the tank when no ally has taken + damage yet (pre-emptive shield), and also when the tank is the lowest-HP + ally while everyone else is fine.""" + allies = [a for a in self.allies_of(unit) if a.alive] + if not allies: + return None + tanks = [a for a in allies if a.role == ROLE_TANK] + if tanks: + if all(a.hp >= a.max_hp for a in allies): + return tanks[0] # nobody damaged yet -> shield a tank + lowest = min(allies, key=lambda a: a.hp) + if lowest.role == ROLE_TANK: + return lowest # tank is the most injured -> shield it + return min(allies, key=lambda a: a.hp) diff --git a/engine/collection.py b/engine/collection.py new file mode 100644 index 0000000..6ad102d --- /dev/null +++ b/engine/collection.py @@ -0,0 +1,1212 @@ +from __future__ import annotations + +import json +import pathlib +import random + +from .growth import MAX_LEVEL, EVOLVE_COST, EVOLVE_STAGE_AT_LEVEL, can_evolve, evolve_cost, xp_to_next +from .plants import POOL, VARIABLE_TYPE_UNITS, plant_rarity +from .types import ALL_TYPES +from . import runes +from .seeds import PACK_TYPES, REWARD_PACK_TYPES, open_pack as roll_pack +from .skills import skill_from_dict, skill_to_dict + +_ROOT = pathlib.Path(__file__).resolve().parent.parent +_DATA_DIR = _ROOT / "data" +_DATA_FILE = _DATA_DIR / "collection.json" + +DEFAULT_OWNED = ["Lavender", "Marigold", "Sunflower", "Zinnia"] +CATCH_UP_PER_GAP = 20 # bonus XP the lowest-level plant earns per level of gap +WIN_FERTILIZER = 5 # fertilizer granted into the pool for a won battle + +STARTER_TYPES = ALL_TYPES + +# The very first lesser packs the player opens are scripted (in this order) so +# every save starts the critter trio off the same way; later packs are random. +LESSER_STARTER_SEQUENCE = ["Pinwheel", "Toadstool", "Pinwheel", "Butterfly"] + + +def save_exists() -> bool: + """True once a starter pinwheel (or any save) has been created.""" + return _DATA_FILE.exists() + + +def init_starter_pinwheel(ptype: str) -> bool: + """Create a fresh save starting with a single Pinwheel of the chosen type.""" + if ptype not in ALL_TYPES: + return False + data = { + "owned": ["Pinwheel"], + "discovered": ["Pinwheel"], + "progress": {"Pinwheel": {"level": 1, "stage": 1, "xp": 0, "duplicates": 0, + "watered_at": 0, "max_stage": 1, "equipped_runes": [], + "type": ptype}}, + "packs": {t: 0 for t in PACK_TYPES}, + "battles": 0, + "wins": 0, + "water": 0, + "fertilizer": 0, + "fertilizer_accrued_at": 0.0, + "auto_farmer": False, + "auto_farmer_active": False, + "coins": 0, + "coins_accrued_at": 0.0, + "water_used": 0, + "favorites": [], + "presets": [], + "tower_best": 0, + "extracted": [], + "open_history": [], + "lesser_opened": 0, + "floor": 1, + "soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []}, + } + save(data) + return True + + +def _default() -> dict: + return { + "owned": list(DEFAULT_OWNED), + "discovered": list(DEFAULT_OWNED), + "progress": {n: {"level": 1, "stage": 1, "xp": 0, "duplicates": 2, "watered_at": 0, + "max_stage": 1, "equipped_runes": []} + for n in DEFAULT_OWNED}, + "packs": {t: (2 if t == "general" else 0) for t in PACK_TYPES}, + "battles": 0, + "wins": 0, + "water": 0, + "fertilizer": 0, + "fertilizer_accrued_at": 0.0, + "auto_farmer": False, + "auto_farmer_active": False, + "coins": 0, + "coins_accrued_at": 0.0, + "water_used": 0, + "favorites": [], + "presets": [], + "tower_best": 0, + "extracted": [], + "open_history": [], + "lesser_opened": 0, + "soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []}, + "floor": 1, + } + + +def _fresh_progress() -> dict: + return {"level": 1, "stage": 1, "xp": 0, "duplicates": 0, "watered_at": 0, + "max_stage": 1, "equipped_runes": []} + + +def load() -> dict: + if _DATA_FILE.exists(): + try: + data = json.loads(_DATA_FILE.read_text(encoding="utf-8")) + if isinstance(data, list): # legacy: discovered only + data = {"owned": list(DEFAULT_OWNED), "discovered": data, "progress": {}} + data.setdefault("owned", list(DEFAULT_OWNED)) + data.setdefault("discovered", []) + data.setdefault("progress", {}) + data.setdefault("packs", {t: 0 for t in PACK_TYPES}) + data["packs"] = {t: data["packs"].get(t, 0) for t in PACK_TYPES} + data.setdefault("battles", 0) + data.setdefault("water", 0) + data.setdefault("fertilizer", 0) + data.setdefault("fertilizer_accrued_at", 0.0) + data.setdefault("auto_farmer", False) + data.setdefault("auto_farmer_active", False) + data.setdefault("coins", 0) + data.setdefault("coins_accrued_at", 0.0) + data.setdefault("water_used", 0) + data.setdefault("favorites", []) + data.setdefault("presets", []) + data.setdefault("tower_best", 0) + data.setdefault("extracted", []) + data.setdefault("open_history", []) + data.setdefault("lesser_opened", 0) + data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) + data.setdefault("floor", 1) + data.setdefault("wins", 0) + # 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", []): + r["color"] = runes.canonical_color(r.get("color", "")) + if r["color"] not in runes.RUNE_EFFECTS: + r["color"] = runes.random_color() + r["name"] = runes.name(r["color"]) + r.setdefault("pct", runes.roll_pct(r["color"])) + return data + except (ValueError, OSError): + pass + return _default() + + +def save(data: dict) -> None: + _DATA_DIR.mkdir(exist_ok=True) + _DATA_FILE.write_text(json.dumps(data), encoding="utf-8") + + +def owned() -> set: + return set(load()["owned"]) + + +MAX_FLOOR = 100 + + +def floor_progress() -> int: + """The player's current battle floor (1..100 for now).""" + return max(1, min(MAX_FLOOR, load().get("floor", 1))) + + +def advance_floor() -> int: + """Advance the player's floor by one (capped at MAX_FLOOR).""" + data = load() + data["floor"] = min(MAX_FLOOR, data.get("floor", 1) + 1) + save(data) + return data["floor"] + + +def unit_type(name: str, data=None): + """The elemental type of a unit. For variable-type critters (Pinwheel/ + Butterfly/Toadstool) this is the stored per-unit type (None until the + player picks one after their first evolution); otherwise the spec's fixed type.""" + if name in VARIABLE_TYPE_UNITS: + data = data or load() + stored = data["progress"].get(name, {}).get("type") + return stored if stored else None + return POOL[name]["type"] + + +def set_unit_type(name: str, ptype: str) -> str: + """Set the elemental type of a variable-type critter once it has evolved at + least once (stage 2). Returns '' or a reason.""" + if name not in VARIABLE_TYPE_UNITS: + return "Not a variable-type unit" + if ptype not in ALL_TYPES: + return "Unknown type" + data = load() + prog = data["progress"].get(name) + if not prog: + return "Not owned" + if prog.get("stage", 1) < 2: + return "Evolve once to choose a type" + prog["type"] = ptype + save(data) + return "" + + +def discovered() -> set: + return set(load()["discovered"]) + + +def get_progress(name: str, data=None) -> dict: + data = data or load() + prog = data["progress"].get(name) + if not prog: + return dict(_fresh_progress()) + return dict(prog) + + +def _put_progress(data, name, prog) -> None: + data.setdefault("progress", {})[name] = prog + + +def mark_found(names) -> set: + data = load() + data["discovered"] = sorted(set(data["discovered"]) | set(names)) + save(data) + return set(data["discovered"]) + + +def mark_owned(names) -> set: + data = load() + data["owned"] = sorted(set(data["owned"]) | set(names)) + data["discovered"] = sorted(set(data["discovered"]) | set(names)) + for n in names: + data["progress"].setdefault(n, _fresh_progress()) + save(data) + return set(data["owned"]) + + +def grant_xp(names, xp: int, count_battle: bool = True, fertilizer: int = None) -> dict: + """Add battle XP; auto-levels (may level several times). The lowest-level + plant on the team earns bonus XP based on the level gap (catch-up). + `count_battle` controls whether this battle counts toward the fertilizer + allowance (only wins should); `fertilizer` overrides the win amount.""" + data = load() + if count_battle: + data["battles"] = data.get("battles", 0) + 1 + amount = WIN_FERTILIZER if fertilizer is None else fertilizer + data["fertilizer"] = min(FERTILIZER_CAP, data.get("fertilizer", 0) + amount) + levels = {} + for n in names: + prog = data["progress"].get(n) + if not prog: + prog = _fresh_progress() + data["progress"][n] = prog + levels[n] = prog["level"] + low = min(levels.values()) if levels else 1 + high = max(levels.values()) if levels else 1 + gap = high - low + 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 + prog["xp"] += gain + while prog["level"] < MAX_LEVEL and prog["xp"] >= xp_to_next(prog["level"]): + prog["xp"] -= xp_to_next(prog["level"]) + prog["level"] += 1 + save(data) + return {n: dict(data["progress"][n]) for n in names if n in data["progress"]} + + +def can_water(name, data=None) -> str: + """Why this plant can't be watered, or '' if it can (water pool available).""" + data = data or load() + prog = data["progress"].get(name) + if not prog: + return "Not owned" + if prog["level"] >= MAX_LEVEL: + return "Max level" + if data.get("water", 0) > 0: + return "" + return "No water" + + +def water(name) -> bool: + """Spend 1 water from the shared pool to level a plant.""" + data = load() + prog = data["progress"].get(name) + if not prog: + return False + if prog["level"] >= MAX_LEVEL: + return False + if data.get("water", 0) <= 0: + return False + data["water"] -= 1 + prog["level"] += 1 + save(data) + return True + + +def available_water() -> int: + """How much water the player has in their pool.""" + return load().get("water", 0) + + +def smart_water_candidate(name: str, data=None) -> bool: + """True if the plant already has the duplicates for its next evolution and + is only missing the level milestone — watering it up is a smart move.""" + data = data or load() + prog = data["progress"].get(name) + if not prog: + return False + if prog["level"] >= MAX_LEVEL: + return False + target = prog["stage"] + 1 + if target not in EVOLVE_STAGE_AT_LEVEL: + return False + if prog["level"] >= EVOLVE_STAGE_AT_LEVEL[target]: + return False + return prog["duplicates"] >= EVOLVE_COST[target] + + +def redeem_tower_pack() -> bool: + """Redeem one Tower pack for 10 water.""" + data = load() + packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES}) + if packs.get("tower", 0) <= 0: + return False + packs["tower"] -= 1 + data["water"] = data.get("water", 0) + 10 + save(data) + return True + + +def add_water(amount: int) -> None: + data = load() + data["water"] = data.get("water", 0) + amount + save(data) + + +def available_fertilizer() -> int: + return load().get("fertilizer", 0) + + +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) + + +def add_fertilizer(amount: int) -> None: + data = load() + data["fertilizer"] = min(FERTILIZER_CAP, data.get("fertilizer", 0) + amount) + save(data) + + +def accrue_fertilizer() -> int: + """Grant the fertilizer earned since the last check (1 per hour, test build), + never exceeding FERTILIZER_CAP. Partial hours carry over. Returns how many + fertilizer were granted.""" + import time as _time + data = load() + now = _time.time() + last = data.get("fertilizer_accrued_at", 0.0) + if last <= 0: + data["fertilizer_accrued_at"] = now + save(data) + return 0 + gained = int((now - last) // 3600) * FERTILIZER_PER_HOUR + if gained > 0: + current = data.get("fertilizer", 0) + new_total = min(FERTILIZER_CAP, current + gained) + granted = new_total - current + data["fertilizer"] = new_total + if granted > 0: + data["fertilizer_accrued_at"] = last + granted * 3600 + else: + data["fertilizer_accrued_at"] = now + save(data) + return granted + return 0 + + +def auto_farmer_owned() -> bool: + return bool(load().get("auto_farmer", False)) + + +def auto_farmer_active() -> bool: + """Whether the owned auto farmer is currently toggled on.""" + return bool(load().get("auto_farmer_active", False)) + + +def toggle_auto_farmer() -> bool: + """Turn the owned auto farmer on/off. Returns the new active state.""" + data = load() + data["auto_farmer_active"] = not data.get("auto_farmer_active", False) + save(data) + return data["auto_farmer_active"] + + +def buy_auto_farmer() -> str: + """Buy the auto farmer for AUTO_FARMER_COST coins. Returns '' or a reason.""" + data = load() + if data.get("auto_farmer", False): + return "Already owned" + if data.get("coins", 0) < AUTO_FARMER_COST: + return "Not enough coins" + data["coins"] -= AUTO_FARMER_COST + data["auto_farmer"] = True + data["auto_farmer_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.""" + cost = runes.T1_RUNE_COST if tier == 1 else runes.T2_RUNE_COST + data = load() + if data.get("coins", 0) < cost: + return "Not enough coins" + data["coins"] -= cost + color = runes.random_color() + while runes.tier(color) != tier: + color = runes.random_color() + rune = add_rune(color=color, data=data, save_now=False) + save(data) + return rune + + +# --- coins -------------------------------------------------------- +COINS_PER_MINUTE = 1 # test build: earn 1 coin every real minute +COIN_CAP = 200 # coins stop accruing once this cap is reached + + +def available_coins() -> int: + return load().get("coins", 0) + + +def add_coins(amount: int) -> None: + data = load() + data["coins"] = min(COIN_CAP, data.get("coins", 0) + amount) + save(data) + + +def accrue_coins() -> int: + """Grant the coins earned since the last check (1 per minute, test build), + never exceeding COIN_CAP. Partial minutes carry over. Returns how many + coins were granted.""" + import time as _time + data = load() + now = _time.time() + last = data.get("coins_accrued_at", 0.0) + if last <= 0: + data["coins_accrued_at"] = now + save(data) + return 0 + gained = int((now - last) // 60) * COINS_PER_MINUTE + if gained > 0: + current = data.get("coins", 0) + new_total = min(COIN_CAP, current + gained) + granted = new_total - current + data["coins"] = new_total + if granted > 0: + data["coins_accrued_at"] = last + granted * 60 + else: + # at the cap: forfeit the elapsed time so it isn't banked + data["coins_accrued_at"] = now + save(data) + return granted + return 0 + + +# --- runes --------------------------------------------------------- +def rune_inventory() -> list: + """Every rune the player owns (not yet equipped).""" + return list(load().get("runes", [])) + + +def add_rune(color=None, data=None, save_now=True) -> dict: + """Add a rune to the inventory; returns the rune dict. When `data` is given + (already loaded save), it is modified in place; `save_now` controls whether + it is persisted (callers inside a larger save flow pass save_now=False).""" + if data is None: + data = load() + color = runes.canonical_color(color or runes.random_color()) + rune_list = data.setdefault("runes", []) + rune_id = max([r["id"] for r in rune_list], default=0) + 1 + rune = {"id": rune_id, "color": color, "name": runes.name(color), + "pct": runes.roll_pct(color)} + rune_list.append(rune) + if save_now: + save(data) + return rune + + +def roll_battle_rune(): + """Rare rune drop from a normal floor battle win. Returns a rune dict or + None if it didn't drop this time.""" + if random.random() >= runes.BATTLE_DROP_CHANCE: + return None + return add_rune() + + +def roll_pack_rune(data=None): + """Rare additional rune drop when opening a seed pack. Returns a rune dict + or None. Modifies `data` in place without saving (the caller saves).""" + if random.random() >= runes.PACK_DROP_CHANCE: + return None + return add_rune(data=data, save_now=False) + + +def rune_slots(name, data=None) -> int: + """Permanent rune slots for `name` (1 + evolutions ever achieved).""" + data = data or load() + prog = data["progress"].get(name, {}) + return runes.slots_for_max_stage(prog.get("max_stage", 1)) + + +def equipped_runes(name, data=None) -> list: + """The equipped rune ids for `name`, one entry per unlocked slot (None = empty).""" + data = data or load() + prog = data["progress"].get(name, {}) + return list(prog.get("equipped_runes", [])) + + +def equipped_rune_colors(name, data=None) -> list: + """Colors of the runes equipped on `name`, in slot order.""" + data = data or load() + by_id = {r["id"]: r for r in data.get("runes", [])} + return [by_id[e]["color"] for e in equipped_runes(name, data) if e in by_id] + + +def rune_location(rune_id: int, data=None) -> str: + """The plant name that currently has `rune_id` equipped, or None. A rune can + only be equipped on one plant at a time.""" + data = data or load() + for name, prog in data["progress"].items(): + if rune_id in prog.get("equipped_runes", []): + return name + return None + + +def free_runes(data=None) -> list: + """Runes the player owns that aren't equipped on any plant.""" + data = data or load() + equipped = set() + for prog in data["progress"].values(): + equipped.update(prog.get("equipped_runes", [])) + return [r for r in data.get("runes", []) if r["id"] not in equipped] + + +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.""" + 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 + 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 rune_id in sac_set: + return "Cannot sacrifice the rune being upgraded" + by_id = {r["id"]: r for r in rune_list} + if any(i not in by_id for i in sac_set): + return "No such rune" + target["pct"] = random.randint(current + 1, max_pct) + data["runes"] = [r for r in rune_list if r["id"] not in sac_set] + # unequip the sacrificed runes from any plant + for prog in data["progress"].values(): + equipped = prog.get("equipped_runes", []) + if any(r in sac_set for r in equipped): + prog["equipped_runes"] = [None if r in sac_set else r for r in equipped] + save(data) + return "" + + +def equip_rune(name: str, slot: int, rune_id: int) -> str: + """Equip rune `rune_id` into `slot` (0-based) on `name`. Returns '' on + success or a reason string. A rune can only occupy one slot anywhere, and a + plant can equip at most runes.MAX_RUNES_PER_TYPE runes of the same type.""" + data = load() + prog = data["progress"].get(name) + if not prog: + return "Not owned" + slots = runes.slots_for_max_stage(prog.get("max_stage", 1)) + if not (0 <= slot < slots): + return "Slot locked" + rune = next((r for r in data.get("runes", []) if r["id"] == rune_id), None) + if not rune: + return "No such rune" + # free the rune from every plant's slots (global uniqueness) + for other_name, other_prog in data["progress"].items(): + equipped = other_prog.get("equipped_runes", []) + if rune_id in equipped: + other_prog["equipped_runes"] = [None if r == rune_id else r for r in equipped] + equipped = list(prog.get("equipped_runes", [])) + while len(equipped) < slots: + equipped.append(None) + by_id = {r["id"]: r for r in data.get("runes", [])} + same = sum(1 for e in equipped if e is not None and e in by_id + and by_id[e]["color"] == rune["color"]) + if same >= runes.MAX_RUNES_PER_TYPE: + return f"Max {runes.MAX_RUNES_PER_TYPE} {rune['color']} runes per plant" + equipped[slot] = rune_id + prog["equipped_runes"] = equipped + save(data) + return "" + + +def unequip_rune(name: str, slot: int) -> bool: + """Clear the rune equipped in `slot` on `name`.""" + data = load() + prog = data["progress"].get(name) + if not prog: + return False + equipped = list(prog.get("equipped_runes", [])) + if not (0 <= slot < len(equipped)): + return False + equipped[slot] = None + prog["equipped_runes"] = equipped + save(data) + return True + + +def add_tower_packs(count: int) -> None: + data = load() + packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES}) + packs["tower"] = packs.get("tower", 0) + count + 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() + prog = data["progress"].get(name) + if not prog: + return False + if not can_evolve(prog["level"], prog["stage"], prog["duplicates"]): + return False + prog["stage"] += 1 + prog["max_stage"] = max(prog.get("max_stage", 1), prog["stage"]) + prog["duplicates"] -= evolve_cost(prog["stage"] - 1) + save(data) + return True + + +# --- favorites ------------------------------------------------------ +def favorites() -> set: + return set(load().get("favorites", [])) + + +def is_favorite(name) -> bool: + return name in load().get("favorites", []) + + +def toggle_favorite(name) -> bool: + """Toggle a plant's favorite state; returns its new state.""" + data = load() + favs = data.setdefault("favorites", []) + if name in favs: + favs.remove(name) + state = False + else: + favs.append(name) + state = True + save(data) + return state + + +# --- team presets ---------------------------------------------------- +def get_presets() -> list: + presets = load().get("presets", []) + while len(presets) < 4: + presets.append(None) + return presets + + +def save_preset(slot: int, names) -> None: + data = load() + presets = list(data.get("presets", [])) + while len(presets) < 4: + presets.append(None) + presets[slot] = list(names) + data["presets"] = presets + save(data) + + +def clear_preset(slot: int) -> None: + data = load() + presets = list(data.get("presets", [])) + while len(presets) < 4: + presets.append(None) + presets[slot] = None + data["presets"] = presets + save(data) + + +def get_tower_best() -> int: + return load().get("tower_best", 0) + + +def set_tower_best(stage: int) -> None: + data = load() + if stage > data.get("tower_best", 0): + data["tower_best"] = stage + save(data) + + +# --- seed packs ---------------------------------------------------- +def pack_counts() -> dict: + data = load() + return dict(data.get("packs", {})) + + +def add_random_packs(count: int = 3) -> list: + """Grant `count` random seed packs (the reward for winning a battle).""" + import random as _random + data = load() + packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES}) + granted = [] + for _ in range(count): + pt = _random.choice(REWARD_PACK_TYPES) + packs[pt] = packs.get(pt, 0) + 1 + granted.append(pt) + save(data) + return granted + + +def add_lesser_pack() -> list: + """Grant one lesser seed pack.""" + data = load() + packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES}) + packs["lesser"] = packs.get("lesser", 0) + 1 + save(data) + return ["lesser"] + + +def add_pack(pack_type: str, count: int = 1) -> list: + """Grant `count` packs of a specific type. Returns the granted type list.""" + data = load() + packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES}) + packs[pack_type] = packs.get(pack_type, 0) + count + save(data) + return [pack_type] * count + + +def battle_wins() -> int: + """Total number of battle wins the player has recorded.""" + return load().get("wins", 0) + + +def record_win() -> int: + """Increment and return the battle-win counter.""" + data = load() + data["wins"] = data.get("wins", 0) + 1 + save(data) + return data["wins"] + + +def grant_fifth_win_bonus() -> list: + """Grant the every-5th-win bonus: one general or type-boosted pack.""" + import random as _random + pt = _random.choice(["general"] + list(ALL_TYPES)) + return add_pack(pt) + + +def _lesser_pack_plant(data: dict) -> str: + """Plant for a lesser pack. The first few lesser packs a save opens follow a + fixed starter sequence so every run starts with the same critters; after + that the pack rolls normally.""" + opened = data.get("lesser_opened", 0) + if opened < len(LESSER_STARTER_SEQUENCE): + plant = LESSER_STARTER_SEQUENCE[opened] + else: + plant = roll_pack("lesser") + data["lesser_opened"] = opened + 1 + return plant + + +def open_pack(pack_type: str): + """Open one pack; returns (plant_name, is_new, bonus) or None if none left. + `bonus` is a rune dict on a rare additional drop, otherwise None. The result + is recorded in the last-10 open history.""" + data = load() + packs = data.get("packs", {}) + if packs.get(pack_type, 0) <= 0: + return None + packs[pack_type] -= 1 + plant = _lesser_pack_plant(data) if pack_type == "lesser" else roll_pack(pack_type) + if plant in data["owned"]: + prog = data["progress"].setdefault(plant, _fresh_progress()) + prog["duplicates"] = prog.get("duplicates", 0) + 1 + is_new = False + else: + data["owned"].append(plant) + data["owned"].sort() + data["discovered"].append(plant) + data["discovered"] = sorted(set(data["discovered"])) + prog = data["progress"].setdefault(plant, _fresh_progress()) + is_new = True + bonus = roll_pack_rune(data) + history = data.setdefault("open_history", []) + history.append({ + "plant": plant, + "is_new": is_new, + "rune": bonus["name"] if bonus else None, + }) + del history[:-10] + save(data) + return plant, is_new, bonus + + +def open_history() -> list: + """The last 10 opened packs (each a {'plant','is_new','rune'} record).""" + return list(load().get("open_history", [])) + + +def open_packs(pack_type: str, count: int) -> list: + """Open up to `count` packs of one type; returns a list of + (plant, is_new, bonus) tuples for each pack actually opened.""" + results = [] + for _ in range(count): + r = open_pack(pack_type) + if not r: + break + results.append(r) + return results + + +# --- ability extraction / injection ------------------------------------- +EXTRACT_GATE = 10 # tower stages cleared (best) to unlock extraction +FULL_LEVEL = MAX_LEVEL # 20 +FULL_STAGE = 3 # 2 evolutions + +# abilities whose effects are tied to a specific plant identity/passive +NON_EXTRACTABLE_MECHANICS = { + "extend_timers", "lying_wait", "sow", "shatter_icicles", + "snowflakes", "perma_hp_def", "vigil", +} +# name-based blocklist for skills whose effect depends on the plant's passive +NON_EXTRACTABLE_SKILL_NAMES = {"Madness"} # Hellebore: relies on the Toxin passive + + +def extracted_skills() -> list: + """Available extracted abilities (each is single-use).""" + return list(load().get("extracted", [])) + + +def injection_of(name: str): + """The injected-ability record for a plant, or None.""" + prog = load()["progress"].get(name) + if not prog: + return None + return prog.get("injected") or None + + +def tower_max_stage() -> int: + """Highest tower stage the player currently qualifies for, gated by account + milestones (any owned plant, whether or not it's brought along): + stages 1-5 free, 6-10 need an evolved plant, 11-15 need a max-level plant, + 16-20 need a max-level plant plus any injected plant.""" + data = load() + owned = data.get("owned", []) + progress = data.get("progress", {}) + has_evolved = any(progress.get(n, {}).get("stage", 1) >= 2 for n in owned) + has_max = any(progress.get(n, {}).get("level", 1) >= MAX_LEVEL for n in owned) + has_injected = any(injection_of(n) for n in owned) + if has_max and has_injected: + return 20 + if has_max: + return 15 + if has_evolved: + return 10 + return 5 + + +def extraction_blocked_reason(name: str, index: int, sk) -> str: + """Why this skill of `name` can't be extracted, or '' if it can.""" + if index == 0: + return "basic ability" + if sk.mechanic in NON_EXTRACTABLE_MECHANICS or sk.name in NON_EXTRACTABLE_SKILL_NAMES: + return "tied to the plant's passive" + if sk.requires_form: + return "depends on the plant's form" + return "" + + +def extractable_skills(name: str) -> list: + """[(index, Skill)] of a plant's abilities that can be extracted.""" + spec = POOL[name] + out = [] + for i, sk in enumerate(spec["skills"]): + if extraction_blocked_reason(name, i, sk) == "": + out.append((i, sk)) + return out + + +def can_extract(name: str) -> str: + """Why extraction is blocked for `name`, or '' if it can be extracted.""" + prog = load()["progress"].get(name) + if not prog: + return "Not owned" + if prog["level"] < FULL_LEVEL: + return f"Needs Lv {FULL_LEVEL}" + if prog["stage"] < FULL_STAGE: + return "Needs 2 evolutions" + if not extractable_skills(name): + return "No extractable abilities" + return "" + + +def extract_skill(name: str, index: int) -> bool: + """Extract ability `index` from a fully leveled/evolved plant. The plant + resets to Lv 1 / Stage 1; the ability is stored for one injection.""" + data = load() + prog = data["progress"].get(name) + if not prog or prog["level"] < FULL_LEVEL or prog["stage"] < FULL_STAGE: + return False + spec = POOL[name] + if not (0 <= index < len(spec["skills"])): + return False + sk = spec["skills"][index] + if index == 0 or sk.mechanic in NON_EXTRACTABLE_MECHANICS \ + or sk.name in NON_EXTRACTABLE_SKILL_NAMES or sk.requires_form: + return False + data.setdefault("extracted", []).append({ + "source": name, + "role": spec["role"], + "skill_name": sk.name, + "skill": skill_to_dict(sk), + }) + prog["level"] = 1 + prog["stage"] = 1 + prog["xp"] = 0 + prog["duplicates"] = 0 + prog.pop("injected", None) + save(data) + return True + + +def can_inject(target: str, record: dict) -> str: + """Why injecting `record` into `target` is blocked, or '' if allowed.""" + if not record: + return "No extracted ability" + prog = load()["progress"].get(target) + if not prog: + return "Not owned" + if prog.get("injected"): + return "Already has an injected ability" + if POOL[target]["role"] != record["role"]: + return "Wrong role" + return "" + + +def injection_slot_reason(target: str, record: dict, slot: int) -> str: + """Why injecting `record` into `target` at `slot` is blocked, or '' if ok.""" + if slot >= len(POOL[target]["skills"]): + return "No skill in that slot" + if POOL[target]["skills"][slot].name == record["skill"]["name"]: + return "same ability" + return "" + + +def inject_skill(extracted_index: int, target: str, slot: int) -> bool: + """Inject extracted[extracted_index] into `target`, replacing its slot + (1 or 2 = 2nd/3rd skill). Consumes the extracted ability. Never replaces + a skill with the same ability.""" + data = load() + extracted = data.get("extracted", []) + if not (0 <= extracted_index < len(extracted)): + return False + rec = extracted[extracted_index] + prog = data["progress"].get(target) + if not prog or prog.get("injected"): + return False + if POOL[target]["role"] != rec["role"]: + return False + if slot not in (1, 2) or slot >= len(POOL[target]["skills"]): + return False + if injection_slot_reason(target, rec, slot): + return False + prog["injected"] = {"index": slot, "source": rec["source"], "skill": rec["skill"]} + del extracted[extracted_index] + save(data) + return True + + +def remove_injection(name: str) -> bool: + """Destroy a plant's injected ability (reverts it to base skills).""" + data = load() + prog = data["progress"].get(name) + if not prog or not prog.get("injected"): + return False + prog.pop("injected", None) + save(data) + return True + + +# --- Soil / seed collection ----------------------------------------- +import time as _time + +# Retrieve a seed no more often than this (test build: 1 minute; real: 24h). +SEED_CD_SECONDS = 60 +SEED_BAG_MAX = 8 +SOIL_PLOTS_MAX = 4 +SOIL_GATE_PLANTS = 8 # distinct plants owned to unlock the Soil feature +# (coins, fertilizer) needed to unlock each plot slot beyond the first. +PLOT_UNLOCK_COSTS = { + 2: (10, 5), + 3: (50, 20), + 4: (100, 50), +} + + +def soil_unlocked() -> bool: + """Whether the Soil feature is available (own at least SOIL_GATE_PLANTS).""" + return len(owned()) >= SOIL_GATE_PLANTS + +# growth stages per rarity (higher rarity = more stages) +GROWTH_STAGES = {"common": 2, "rare": 3, "super_rare": 4, "super_super_rare": 5, "ultra_rare": 6} +# seconds to fill a single stage +STAGE_SECONDS = 30 + + +def _plot_view(p: dict, now: float) -> dict: + total = GROWTH_STAGES.get(plant_rarity(p["plant"]), 2) + stage = p.get("stage", 0) + started = p.get("stage_started_at", p.get("planted_at", now)) + elapsed = now - started + stage_progress = min(1.0, elapsed / STAGE_SECONDS) + full = elapsed >= STAGE_SECONDS + grown = full and stage >= total - 1 + return { + "plant": p["plant"], + "stage": stage, + "total_stages": total, + "stage_progress": stage_progress, + "grown": grown, + "stalled": full and not grown, # stage full, needs water to advance + "water": p.get("water", 0), + "seconds_left": max(0, STAGE_SECONDS - elapsed), + } + + +def soil_state() -> dict: + """Snapshot of the soil: seed cooldown, retrieved seeds, and planted plots + with their current stage / growth progress.""" + data = load() + soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) + now = _time.time() + cd_left = max(0.0, soil.get("seed_cd_until", 0.0) - now) + seeds = list(soil.get("seeds", [])) + plots = [_plot_view(p, now) for p in soil.get("plots", [])] + unlocked = unlocked_plots(data, soil) + return { + "cd_left": cd_left, + "seeds": seeds, + "plots": plots, + "plots_unlocked": unlocked, + "plots_max": SOIL_PLOTS_MAX, + "next_plot_cost": plot_unlock_cost(unlocked + 1) if unlocked < SOIL_PLOTS_MAX else (0, 0), + } + + +def retrieve_seed(name: str) -> str: + """Retrieve a seed of `name`. Returns "" on success or a reason string. + Critters (pinwheels/butterflies/toadstools) don't drop seeds.""" + if name in VARIABLE_TYPE_UNITS: + return "Critters don't drop seeds" + data = load() + soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) + if name not in data["owned"]: + return "Not owned" + if _time.time() < soil.get("seed_cd_until", 0.0): + return "On cooldown" + if len(soil.get("seeds", [])) >= SEED_BAG_MAX: + return "Seed bag full" + soil.setdefault("seeds", []).append({"plant": name}) + soil["seed_cd_until"] = _time.time() + SEED_CD_SECONDS + save(data) + return "" + + +def plant_seed(index: int) -> str: + """Plant the retrieved seed at `index` in the soil. Returns "" or a reason.""" + data = load() + soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) + seeds = soil.get("seeds", []) + if not (0 <= index < len(seeds)): + return "No such seed" + if len(soil.get("plots", [])) >= unlocked_plots(data, soil): + return "No free plots" + seed = seeds.pop(index) + soil.setdefault("plots", []).append({ + "plant": seed["plant"], "stage_started_at": _time.time(), "stage": 0, "water": 0}) + save(data) + return "" + + +def unlocked_plots(data: dict = None, soil: dict = None) -> int: + """How many plot slots are currently unlocked (starts at 1, max 5).""" + if soil is None: + data = data or load() + soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) + return max(1, min(SOIL_PLOTS_MAX, soil.get("plots_unlocked", 1))) + + +def plot_unlock_cost(next_slot: int): + """(coins, fertilizer) needed to unlock the Nth plot slot (slot 1 is free).""" + return PLOT_UNLOCK_COSTS.get(next_slot, (0, 0)) + + +def unlock_plot() -> str: + """Spend coins + fertilizer to unlock the next plot slot. Returns "" or a reason.""" + data = load() + soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) + unlocked = unlocked_plots(data, soil) + if unlocked >= SOIL_PLOTS_MAX: + return "Max plots" + coins, fert = plot_unlock_cost(unlocked + 1) + if data.get("coins", 0) < coins: + return "Not enough coins" + if data.get("fertilizer", 0) < fert: + return "Not enough fertilizer" + data["coins"] -= coins + data["fertilizer"] -= fert + soil["plots_unlocked"] = unlocked + 1 + save(data) + return "" + + +def water_plot(index: int) -> str: + """Fertilize a stalled plot, spending 1 fertilizer to advance to the next + growth stage. The current stage must be full before it can be fertilized.""" + data = load() + soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) + plots = soil.get("plots", []) + if not (0 <= index < len(plots)): + return "No such plot" + if data.get("fertilizer", 0) <= 0: + return "No fertilizer" + now = _time.time() + view = _plot_view(plots[index], now) + if view["grown"]: + return "Already grown" + if not view["stalled"]: + return "Not ready yet" + p = plots[index] + p["stage"] = view["stage"] + 1 + p["stage_started_at"] = now + p["water"] = p.get("water", 0) + 1 + data["fertilizer"] -= 1 + save(data) + return "" + + +def auto_farm() -> str: + """If the player owns an auto farmer that's toggled on and a plot is + stalled, spend 1 fertilizer to advance it. Returns '' on success or when + idle, else a reason. The auto farmer never plants seeds or harvests.""" + data = load() + if not data.get("auto_farmer", False): + return "No auto farmer" + if not data.get("auto_farmer_active", False): + return "Auto farmer off" + if data.get("fertilizer", 0) <= 0: + return "No fertilizer" + soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) + plots = soil.get("plots", []) + now = _time.time() + for i, p in enumerate(plots): + view = _plot_view(p, now) + if view["stalled"] and not view["grown"]: + return water_plot(i) + return "" + + +def harvest_plot(index: int) -> str: + """Harvest a grown plot, gaining a copy (duplicate) of the plant.""" + data = load() + soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []}) + plots = soil.get("plots", []) + if not (0 <= index < len(plots)): + return "No such plot" + if not _plot_view(plots[index], _time.time())["grown"]: + return "Not grown yet" + plant = plots[index]["plant"] + if plant in data["owned"]: + prog = data["progress"].setdefault(plant, _fresh_progress()) + prog["duplicates"] = prog.get("duplicates", 0) + 1 + else: + data["owned"].append(plant) + data["owned"].sort() + data["discovered"] = sorted(set(data["discovered"]) | {plant}) + data["progress"].setdefault(plant, _fresh_progress()) + plots.pop(index) + save(data) + return "" \ No newline at end of file diff --git a/engine/dominance.py b/engine/dominance.py new file mode 100644 index 0000000..6f480e1 --- /dev/null +++ b/engine/dominance.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import copy +from typing import Iterable, List, Optional + +from .battle import Battle +from .unit import TEAM_ENEMY, TEAM_PLAYER, Unit + + +def _ai_act(battle: Battle, unit: Unit) -> bool: + """Let `unit` take an action using the engine's AI (mirrors the player's + auto-battle behaviour). Returns True if it used a skill.""" + if unit.team == TEAM_ENEMY: + return battle.enemy_ai(unit) + s = battle.best_skill(unit) + if s is None: + return False + foes = battle.foes_of(unit) + if s.kind == "heal": + low = [a for a in battle.allies_of(unit) if a.hp < a.max_hp] + return battle.use_skill(unit, s, min(low, key=lambda a: a.hp) if low else None) + if s.kind == "shield": + t = unit if s.target == "self" else min(battle.allies_of(unit), key=lambda a: a.hp) + return battle.use_skill(unit, s, t) + if s.target in ("all_enemies", "two_enemies"): + return battle.use_skill(unit, s, None) + if s.target == "any": + tg = battle.valid_skill_targets(unit, s) + return battle.use_skill(unit, s, min(tg, key=lambda t: t.hp) if tg else None) + if s.kind == "utility" and s.mechanic == "reset_cooldowns": + al = battle.allies_of(unit) + if al: + target = max(al, key=lambda a: sum(x.cooldown_left for x in a.skills)) + return battle.use_skill(unit, s, target) + return False + if s.target in ("ally", "all_allies"): + al = battle.allies_of(unit) + return battle.use_skill(unit, s, min(al, key=lambda a: a.hp) if al else None) + tg = battle.valid_skill_targets(unit, s) + if not tg: + return False + if s.mechanic == "hits_per_effect": + t = max(tg, key=lambda t: (len([k for k in t.status if k != "form"]), -t.hp)) + else: + t = min(tg, key=lambda t: t.hp) + return battle.use_skill(unit, s, t) + + +def play_to_end(battle: Battle) -> Optional[int]: + """Play `battle` to completion with the AI controlling both sides. + Returns the winning team (TEAM_PLAYER / TEAM_ENEMY).""" + battle.start() + guard = 0 + while battle.winner is None and battle.current is not None and guard < 600: + guard += 1 + unit = battle.current + if not unit.acted: + used = _ai_act(battle, unit) + if not used: + battle.next_turn() + continue + if battle.current is unit and unit.acted: + battle.next_turn() + return battle.winner + + +def _surviving_hp_fraction(battle: Battle) -> float: + if not battle.players: + return 0.0 + return sum(u.hp / u.max_hp for u in battle.players) / len(battle.players) + + +def estimate_dominance(player_units: List[Unit], enemy_units: List[Unit], + relics: Iterable[str] = (), runs: int = 25, + win_thresh: float = 0.99, margin_thresh: float = 0.6) -> bool: + """Monte-Carlo estimate of whether the player team crushes the enemy team. + + Returns True only for confident blowouts: the player wins almost every run + AND ends with most of the team at high health. Used to decide whether a + "Finish battle" button is safe to show (it's hidden the moment the matchup + is remotely close).""" + wins = 0 + margin = 0.0 + for i in range(runs): + b = Battle(copy.deepcopy(player_units), copy.deepcopy(enemy_units), + relics=list(relics)) + winner = play_to_end(b) + if winner == TEAM_PLAYER: + wins += 1 + margin += _surviving_hp_fraction(b) + # early exit: even if we win every remaining run we can't hit the threshold + if wins + (runs - i - 1) < runs * win_thresh: + return False + if runs == 0: + return False + return wins / runs >= win_thresh and margin / max(1, wins) >= margin_thresh + + +def resolve_instantly(player_units: List[Unit], enemy_units: List[Unit], + relics: Iterable[str] = ()) -> Optional[int]: + """Run a single real simulation to completion and return its outcome. This + is what the 'Finish battle' button applies, so the result is genuine, not + an assumed win.""" + b = Battle(copy.deepcopy(player_units), copy.deepcopy(enemy_units), + relics=list(relics)) + return play_to_end(b) \ No newline at end of file diff --git a/engine/growth.py b/engine/growth.py new file mode 100644 index 0000000..9d8c029 --- /dev/null +++ b/engine/growth.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +MAX_LEVEL = 50 +STAGE_MULT = [1.0, 1.25, 1.55, 1.9, 2.3] +GROWTH = {"attack": 0.06, "defense": 0.06, "health": 0.05, "speed": 0.04} +EVOLVE_STAGE_AT_LEVEL = {2: 10, 3: 20, 4: 30, 5: 40} # evolve to stage 2/3/4/5 at these levels +EVOLVE_COST = {2: 2, 3: 4, 4: 6, 5: 8} # duplicates consumed to evolve + + +def compute_stats(base_stats, level: int = 1, stage: int = 1): + """base_stats = (attack, speed, defense, health); returns leveled stats.""" + mult = STAGE_MULT[max(0, min(len(STAGE_MULT) - 1, stage - 1))] + atk, spd, dfn, hp = base_stats + + def scale(v, growth): + return int(v * mult * (1 + growth * (level - 1))) + + return (scale(atk, GROWTH["attack"]), + scale(spd, GROWTH["speed"]), + scale(dfn, GROWTH["defense"]), + scale(hp, GROWTH["health"])) + + +def xp_to_next(level: int) -> int: + return 40 * level + + +def stage_for_level(level: int) -> int: + stage = 1 + for s in (2, 3, 4, 5): + if level >= EVOLVE_STAGE_AT_LEVEL[s]: + stage = s + return stage + + +def can_evolve(level: int, stage: int, duplicates: int) -> bool: + target = stage + 1 + if target not in EVOLVE_STAGE_AT_LEVEL: + return False + return level >= EVOLVE_STAGE_AT_LEVEL[target] and duplicates >= EVOLVE_COST[target] + + +def evolve_cost(stage: int) -> int: + return EVOLVE_COST.get(stage + 1, 0) if stage + 1 in EVOLVE_COST else 0 \ No newline at end of file diff --git a/engine/plants.py b/engine/plants.py new file mode 100644 index 0000000..4f36782 --- /dev/null +++ b/engine/plants.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +import copy +from typing import Dict + +from .growth import compute_stats +from .skills import ( + S_ATTACK_DOWN, S_ATTACK_UP, S_CRIT_DOWN, S_DEFENSE_DOWN, + S_DEFENSE_UP, S_DENTING, S_HEAL_REDUCTION, S_ICICLES, S_INTOXICATE, S_MADNESS, + S_OBLIVION, S_SLEEP, S_SNOWFLAKES, S_SPEED_DOWN, S_TETHER, S_VULNERABLE, + EFF_DOT, EFF_HEAL, EFF_HOT, EFF_SHIELD, EFF_STATUS, EFF_WILT, Effect, Skill, + TARGET_ALL_ALLIES, TARGET_ALL_ENEMIES, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY, + TARGET_SELF, TARGET_TWO_ENEMIES, +) +from .types import TYPE_GARDEN, TYPE_ICE, TYPE_NOCTURNAL, TYPE_RADIANT, TYPE_WILDFLOWER +from .unit import ROLE_ATTACKER, ROLE_SUPPORT, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER, Unit + + +def _sk(name, power=0, target=TARGET_ENEMY, cd=0, hits=1, kind="damage", + effects=None, desc="", priority=4, mechanic="", power_alt=0, + extra_action=False, form=""): + return Skill(name=name, power=power, cooldown=cd, target=target, kind=kind, + hits=hits, effects=effects or [], description=desc, priority=priority, + mechanic=mechanic, power_alt=power_alt, extra_action=extra_action, + requires_form=form) + + +def _st(key, value, dur=0): + return Effect(EFF_STATUS, value=value, duration=dur, key=key) + + +def _wilt(pct): + return Effect(EFF_WILT, value=pct) + + +def _dot(dmg, turns): + return Effect(EFF_DOT, value=dmg, duration=turns) + + +def _hot(amount, turns): + return Effect(EFF_HOT, value=amount, duration=turns) + + +def _shield(value): + return Effect(EFF_SHIELD, value=value) + + +POOL: Dict[str, dict] = { + "Zinnia": dict(role=ROLE_ATTACKER, type=TYPE_RADIANT, stats=(63, 68, 51, 48), + passive="zinnia_solar", skills=[ + _sk("Blinding Slash", 20, hits=2, desc="20 dmg, 2 hits"), + _sk("Glitter", 10, hits=5, cd=3, mechanic="hits_per_effect", priority=1, + desc="10 dmg, 5 hits, +1 per effect on target"), + _sk("Glimmer", 10, hits=5, cd=3, mechanic="hits_per_self_effect", priority=2, + desc="10 dmg, 5 hits, +1 per effect on self"), + ]), + "Sunflower": dict(role=ROLE_SUPPORT, type=TYPE_RADIANT, stats=(57, 68, 52, 46), + passive="sunflower_incandescent", passive_value=0.7, skills=[ + _sk("Beam", 50, desc="50 dmg"), + _sk("Face The Sun", kind="utility", target=TARGET_ALL_ALLIES, cd=3, priority=1, + effects=[_hot(35, 3)], desc="Heal all allies 35/turn for 3 turns"), + _sk("Daybreak", kind="utility", target=TARGET_ALLY, cd=3, + mechanic="reset_cooldowns", priority=2, desc="Reset an ally's cooldowns"), + ]), + "Daisy": dict(role=ROLE_SUPPORT, type=TYPE_RADIANT, stats=(57, 72, 48, 42), + passive="daisy_gentle", skills=[ + _sk("Flutter", 40, mechanic="heal_random_ally", desc="40 dmg, heals a random ally"), + _sk("Sunkissed Glow", power=50, kind="heal", target=TARGET_ALL_ALLIES, cd=2, priority=1, + desc="Heal all allies for 50"), + _sk("Motivate", kind="heal", target=TARGET_ALLY, cd=4, mechanic="full_heal", + priority=2, desc="Heal an ally to full health"), + ]), + "Marigold": dict(role=ROLE_TANK, type=TYPE_RADIANT, stats=(43, 50, 68, 73), + passive="marigold_golden", skills=[ + _sk("Rustle", 25, desc="25 dmg"), + _sk("Puff Up", kind="utility", target=TARGET_SELF, cd=3, mechanic="puff_up", + priority=1, desc="Heal 30% max HP, cleanse self"), + _sk("Absorb", kind="utility", target=TARGET_SELF, cd=3, mechanic="absorb", + priority=2, desc="Take 20% less damage until next turn"), + ]), + "Daffodil": dict(role=ROLE_TANK, type=TYPE_RADIANT, stats=(55, 47, 64, 70), + passive="daffodil_toxic", skills=[ + _sk("Consume", 45, mechanic="consume_wilt", + desc="45 dmg, heal for damage dealt if target Wilted"), + _sk("Poison Ravish", 50, target=TARGET_ALL_ENEMIES, cd=3, mechanic="wilt_boost", + power_alt=75, priority=1, desc="50/75 dmg to all, more if Wilted"), + _sk("Deteriorate", 50, target=TARGET_ALL_ENEMIES, cd=3, effects=[_wilt(50)], + priority=2, desc="50 dmg to all, Wilt for 50% of damage"), + ]), + "Rose": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(74, 66, 52, 49), + passive="rose_briars", skills=[ + _sk("Thorned Strike", 60, effects=[_wilt(20)], desc="60 dmg, Wilt 20%"), + _sk("Petal Kiss", 90, cd=3, mechanic="ignore_defense", power_alt=0.3, priority=1, + desc="90 dmg, ignores 30% defense"), + _sk("Brambles", 75, target=TARGET_ALL_ENEMIES, cd=3, + effects=[_wilt(15), _st(S_HEAL_REDUCTION, 40, 2)], + priority=2, desc="75 dmg to all, Wilt 15%, -40% healing 2 turns"), + ]), + "Peony": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(77, 58, 61, 61), + passive="peony_robust", skills=[ + _sk("Petal Punch", 55, desc="55 dmg"), + _sk("Snap", 75, cd=3, mechanic="ignore_defense", power_alt=0.15, priority=1, + effects=[_st(S_DEFENSE_DOWN, 20, 3)], desc="75 dmg, -20% defense 3 turns"), + _sk("Denting Blows", 65, target=TARGET_TWO_ENEMIES, cd=3, effects=[_st(S_DENTING, 1)], + priority=2, desc="65 dmg to 2, permanent +3% damage taken (stacks)"), + ]), + "Magnolia": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(73, 64, 55, 58), + passive="magnolia_nobility", skills=[ + _sk("Royal Smack", 50, effects=[_st(S_DEFENSE_DOWN, 20, 1)], desc="50 dmg, -20% defense 1 turn"), + _sk("Crown", 55, cd=3, mechanic="strip_buffs", priority=1, desc="55 dmg, removes target buffs"), + _sk("Regal Order", 90, cd=3, mechanic="shield_lifesteal", power_alt=15, priority=2, + effects=[_st("stun", 1)], desc="90 dmg, stun 1 turn, shield self 15% of damage"), + ]), + "Hydrangea": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(68, 70, 52, 54), + passive="hydrangea_ph", skills=[ + _sk("Cloud", 45, mechanic="ph_cloud", + desc="45 dmg; Acidic: Wilt 30%, Alkaline: heal allies 30%"), + _sk("Alkaline Blossom", kind="utility", target=TARGET_SELF, cd=3, form="alkaline", + mechanic="extend_timers", priority=1, extra_action=True, + desc="Extend buffs/debuffs by 1 turn (extra action)"), + _sk("Acidic Blossom", 80, target=TARGET_ALL_ENEMIES, cd=3, form="acidic", + effects=[_wilt(40)], priority=2, extra_action=True, + desc="80 dmg to all, Wilt 40% (extra action)"), + ]), + "Sweet Pea": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(71, 71, 36, 45), + passive="sweetpea_scent", skills=[ + _sk("Vine Whip", 40, mechanic="tether_lowest", + desc="40 dmg, tethers lowest-HP ally for 1 turn"), + _sk("Sugar Bloom", 70, target=TARGET_ALL_ENEMIES, cd=3, priority=1, + desc="70 dmg to all enemies"), + _sk("Fragrant Bond", kind="utility", target=TARGET_ALL_ALLIES, cd=3, + effects=[_st(S_TETHER, 1, 1)], priority=2, + desc="Tether all allies; tethered allies heal from Sweet Pea's damage"), + ]), + "Chrysanthemum": dict(role=ROLE_TANK, type=TYPE_GARDEN, stats=(38, 41, 70, 76), + passive="chrys_longevity", skills=[ + _sk("Imperial Smite", 45, mechanic="def_bonus", desc="45 dmg + bonus from defense"), + _sk("Autumn Shield", power=50, kind="shield", target=TARGET_ALL_ALLIES, cd=2, priority=1, + effects=[_st(S_DEFENSE_UP, 8, 1)], desc="Shield all allies (scales with Attack), +defense 1 turn"), + _sk("Golden Dynasty", kind="utility", target=TARGET_ALL_ALLIES, cd=4, + mechanic="perma_hp_def", priority=2, + desc="Permanently +15% HP and +10% defense for all allies"), + ]), + "Lily": dict(role=ROLE_ATTACKER, type=TYPE_WILDFLOWER, stats=(79, 65, 41, 36), + passive="lily_passionate", passive_value=0.5, skills=[ + _sk("Flora Blast", 30, target=TARGET_ALL_ENEMIES, desc="30 dmg to all enemies"), + _sk("Fervent Blossom", 50, target=TARGET_ALL_ENEMIES, cd=2, mechanic="low_hp_boost", + power_alt=80, priority=1, desc="50/80 dmg to all, more under 40% HP"), + _sk("Whirlwind", 95, target=TARGET_ALL_ENEMIES, cd=4, mechanic="high_hp_boost", + power_alt=120, priority=2, desc="95/120 dmg to all, more above 80% HP"), + ]), + "Hibiscus": dict(role=ROLE_ATTACKER, type=TYPE_WILDFLOWER, stats=(72, 67, 49, 55), + passive="hibiscus_intensifying", skills=[ + _sk("Tropical Burst", 50, desc="50 dmg"), + _sk("Crimson Strike", 70, cd=3, mechanic="thresh_boost", power_alt=90, priority=1, + desc="70/90 dmg, more if target is high or low HP"), + _sk("Crimson Brew", 65, target=TARGET_ALL_ENEMIES, cd=3, mechanic="crimson_brew", priority=2, + desc="65 dmg to all, +attack to self for 2 turns"), + ]), + "Poppy": dict(role=ROLE_ATTACKER, type=TYPE_WILDFLOWER, stats=(76, 63, 59, 55), + passive="poppy_hypnotic", skills=[ + _sk("Scarlet Lash", 55, desc="55 dmg"), + _sk("Satin Smite", 70, cd=3, effects=[_st(S_INTOXICATE, 1, 2)], priority=1, + desc="70 dmg, Intoxicate 2 turns"), + _sk("Narcosis", 70, target=TARGET_TWO_ENEMIES, cd=3, + effects=[_st(S_CRIT_DOWN, 20, 2), _st(S_OBLIVION, 1, 2)], priority=2, + desc="70 dmg to 2, -20% crit chance and Oblivion 2 turns"), + ]), + "Tulip": dict(role=ROLE_ATTACKER, type=TYPE_WILDFLOWER, stats=(69, 65, 53, 57), + passive="tulip_equinox", skills=[ + _sk("Bulb Blast", 40, target=TARGET_TWO_ENEMIES, desc="40 dmg to 2 enemies"), + _sk("Spring Rain", 55, target=TARGET_ALL_ENEMIES, cd=3, + effects=[_st(S_ATTACK_DOWN, 10, 2)], priority=1, desc="55 dmg to all, -attack 2 turns"), + _sk("Vibrant Bells", power=80, kind="shield", target=TARGET_ALL_ALLIES, cd=3, priority=2, + effects=[_st("bulb_reflect", 1)], desc="Shield all allies (scales with Attack); reflects Bulb Blast"), + ]), + "Lotus": dict(role=ROLE_SUPPORT, type=TYPE_WILDFLOWER, stats=(59, 75, 44, 53), + passive="lotus_rebirth", skills=[ + _sk("Revitalize", power=40, kind="heal", target=TARGET_ALLY, mechanic="scale_heal", + desc="Heal an ally (scales with Attack)"), + _sk("Mud Resilience", kind="utility", target=TARGET_ALL_ALLIES, cd=2, + effects=[_st(S_ATTACK_UP, 10, 2)], priority=1, desc="+attack for all allies 2 turns"), + _sk("Purify", kind="utility", target=TARGET_ALL_ALLIES, cd=4, + mechanic="clear_debuffs_allies", priority=2, extra_action=True, + desc="Remove all debuffs from all allies (extra action)"), + ]), + "Dandelion": dict(role=ROLE_SUPPORT, type=TYPE_WILDFLOWER, stats=(62, 74, 45, 37), + passive="dandelion_cover", skills=[ + _sk("Drifting Seed", 40, effects=[_st(S_ATTACK_DOWN, 10, 1)], desc="40 dmg, -attack 1 turn"), + _sk("Bloom", power=90, kind="shield", target=TARGET_ALLY, cd=2, priority=1, desc="Shield an ally (scales with Attack)"), + _sk("Make A Wish", power=15, kind="shield", target=TARGET_ALL_ALLIES, cd=4, + effects=[_hot(20, 3)], priority=2, + desc="Shield all allies (scales with Attack), heal 20/turn for 3 turns"), + ]), + "Nightshade": dict(role=ROLE_ATTACKER, type=TYPE_NOCTURNAL, stats=(76, 69, 45, 53), + passive="nightshade_corruption", skills=[ + _sk("Astra", 60, desc="60 dmg"), + _sk("Falling Star", 25, hits=4, cd=3, priority=1, desc="25 dmg, 4 hits"), + _sk("Dark Corrosion", kind="utility", target=TARGET_ALL_ENEMIES, cd=3, + effects=[_dot(70, 3)], priority=2, desc="70 dmg/turn to all for 3 turns (stacks)"), + ]), + "Moonflower": dict(role=ROLE_ATTACKER, type=TYPE_NOCTURNAL, stats=(77, 66, 54, 43), + passive="moonflower_night", skills=[ + _sk("Lunar Beam", 45, mechanic="ramping", desc="45 dmg, +5 every use"), + _sk("Night Fog", kind="utility", target=TARGET_ALL_ENEMIES, cd=3, + effects=[_st(S_HEAL_REDUCTION, 30, 2), _st(S_VULNERABLE, 1)], priority=1, + desc="-healing 2 turns, Vulnerable 1 on all"), + _sk("Glimmering Moon", 65, target=TARGET_ALL_ENEMIES, cd=3, + mechanic="moon_double", priority=2, desc="65 dmg to all, chance to hit twice"), + ]), + "Orchid": dict(role=ROLE_ATTACKER, type=TYPE_NOCTURNAL, stats=(74, 68, 47, 64), + passive="orchid_epiphytic", passive_value=0.4, skills=[ + _sk("Branch", 60, desc="60 dmg"), + _sk("Sow", kind="utility", target=TARGET_ENEMY, cd=3, mechanic="sow", priority=1, + desc="Plant a seed that erupts for 115 dmg in 2 turns"), + _sk("Sweeping Elegance", 70, target=TARGET_ALL_ENEMIES, cd=3, + mechanic="strip_buffs_aoe", priority=2, desc="70 dmg to all, removes a buff each"), + ]), + "Lavender": dict(role=ROLE_ATTACKER, type=TYPE_NOCTURNAL, stats=(72, 77, 36, 43), + passive="lavender_soporific", skills=[ + _sk("Tranquilize", 30, effects=[_st(S_SPEED_DOWN, 3, 1), _st(S_VULNERABLE, 1)], + desc="30 dmg, -speed 1 turn, Vulnerable 1"), + _sk("Soothing Lullaby", 50, target=TARGET_ALL_ENEMIES, cd=2, + effects=[_st(S_CRIT_DOWN, 10, 2)], priority=1, desc="50 dmg to all, -10% crit 2 turns"), + _sk("Sedate", 60, cd=4, effects=[_st(S_SLEEP, 2)], priority=2, desc="60 dmg, Sleep"), + ]), + "Venus Flytrap": dict(role=ROLE_TANK, type=TYPE_NOCTURNAL, stats=(55, 44, 73, 68), + passive="flytrap_feed", skills=[ + _sk("Devour", 45, desc="45 dmg"), + _sk("Lying In Wait", kind="utility", target=TARGET_SELF, cd=3, + mechanic="lying_wait", priority=1, desc="Enemy attacks charge Dissolve"), + _sk("Dissolve", 25, hits=2, cd=3, mechanic="dissolve", priority=2, + desc="25 dmg twice, +10 per charge"), + ]), + "Snowdrop": dict(role=ROLE_ATTACKER, type=TYPE_ICE, stats=(73, 78, 52, 38), + passive="snowdrop_winter", skills=[ + _sk("Snow Pierce", 50, effects=[_st(S_DEFENSE_DOWN, 20, 1)], desc="50 dmg, -20% defense 1 turn"), + _sk("Chill", kind="utility", target=TARGET_ENEMY, cd=2, effects=[_dot(45, 2)], + priority=1, desc="45 dmg/turn for 2 turns"), + _sk("Whiteout", 10, target=TARGET_ALL_ENEMIES, cd=4, mechanic="delay_all", + priority=2, desc="10 dmg to all, delays their next turns"), + ]), + "Primrose": dict(role=ROLE_ATTACKER, type=TYPE_ICE, stats=(77, 73, 36, 42), + passive="primrose_coldhearted", skills=[ + _sk("Swipe", 40, desc="40 dmg"), + _sk("Frostbite", 75, cd=3, effects=[_st(S_SPEED_DOWN, 3, 2), _st(S_HEAL_REDUCTION, 50, 2)], + priority=1, desc="75 dmg, -speed and -50% healing 2 turns"), + _sk("Icy Glare", 60, cd=3, effects=[_wilt(40)], priority=2, desc="60 dmg, Wilt 40%"), + ]), + "Winter Jasmine": dict(role=ROLE_ATTACKER, type=TYPE_ICE, stats=(67, 73, 38, 55), + passive="jasmine_janus", skills=[ + _sk("Solstice Spark", 45, desc="45 dmg"), + _sk("Thaw", kind="utility", target=TARGET_ALLY, cd=3, mechanic="vigil", priority=1, + desc="Remove debuffs, shield (scales with Attack); when hit, counter Solstice Spark"), + _sk("Catching Snowflakes", kind="utility", target=TARGET_ALL_ALLIES, cd=3, + mechanic="snowflakes", priority=2, extra_action=True, + desc="All allies' hits +10 (stacks). Extra action"), + ]), + "Hellebore": dict(role=ROLE_SUPPORT, type=TYPE_ICE, stats=(76, 71, 33, 48), + passive="hellebore_toxin", skills=[ + _sk("Pacify", power=45, kind="heal", target=TARGET_ALLY, mechanic="pacify", + desc="Heal an ally; removes Madness if below 40% HP"), + _sk("Madness", kind="utility", target=TARGET_ANY, cd=3, effects=[_st(S_MADNESS, 1)], + priority=1, desc="Apply Madness to a plant"), + _sk("Gradual Annihilation", 40, cd=3, effects=[_wilt(200)], + priority=2, desc="40 dmg, Wilt for 200% of damage"), + ]), + "Camelia": dict(role=ROLE_TANK, type=TYPE_ICE, stats=(60, 49, 68, 71), + passive="camelia_frigid", skills=[ + _sk("Hail", 55, effects=[_st(S_ICICLES, 1)], desc="55 dmg, gain 1 Icicle"), + _sk("Igloo", power=70, kind="shield", target=TARGET_SELF, cd=2, priority=1, + desc="Shield self (scales with Attack); gain 2 Icicles per hit while shielded"), + _sk("Blizzard", kind="utility", target=TARGET_ALL_ENEMIES, cd=4, + mechanic="shatter_icicles", priority=2, desc="Shatter Icicles to damage all"), + ]), + # Common critters: each exists in all five types (the sprite is shared). + # They're always weaker than real plants and carry no passive. + "Pinwheel": dict(role=ROLE_ATTACKER, type=TYPE_GARDEN, stats=(30, 35, 28, 42), + skills=[ + _sk("Spin Slash", 25, desc="25 dmg to one enemy"), + ]), + "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, + 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)"), + ]), + "Toadstool": dict(role=ROLE_TANK, type=TYPE_GARDEN, stats=(30, 30, 42, 54), + skills=[ + _sk("Spore Bash", 22, mechanic="spore_siphon", power_alt=25, + desc="22 dmg, heal self for 25% of damage dealt"), + ]), + # __MORE_PLANTS__ +} + + +HP_MULT = 2.5 # balance: defense is now a % reduction, so HP needs less inflation + + +RARITY = { + # common critters + "Pinwheel": "common", "Butterfly": "common", "Toadstool": "common", + # super super rare (was legendary) + "Sunflower": "super_super_rare", "Hydrangea": "super_super_rare", "Poppy": "super_super_rare", + "Lavender": "super_super_rare", "Snowdrop": "super_super_rare", "Hellebore": "super_super_rare", + "Zinnia": "super_super_rare", + # super rare (was epic) + "Daisy": "super_rare", "Magnolia": "super_rare", "Sweet Pea": "super_rare", + "Chrysanthemum": "super_rare", "Lotus": "super_rare", "Nightshade": "super_rare", + "Moonflower": "super_rare", "Winter Jasmine": "super_rare", "Camelia": "super_rare", "Tulip": "super_rare", + "Rose": "super_rare", "Lily": "super_rare", "Orchid": "super_rare", + # rare (was common + rare) + "Hibiscus": "rare", "Dandelion": "rare", "Venus Flytrap": "rare", + "Marigold": "rare", "Daffodil": "rare", "Peony": "rare", "Primrose": "rare", +} + +# Plant rarities. "common" (Golems) and "ultra_rare" (Ubers/Fruits) have no plants yet. +RARITY_ORDER = { + "common": 0, "rare": 1, "super_rare": 2, "super_super_rare": 3, "ultra_rare": 4, +} +RARITY_LABELS = { + "common": "Common", + "rare": "Rare", + "super_rare": "Super Rare", + "super_super_rare": "Super Super Rare", + "ultra_rare": "Ultra Rare", +} + +RARITY_COLORS = { + "common": (215, 215, 220), # White + "rare": (110, 170, 235), # Blue + "super_rare": (170, 120, 220), # Purple + "super_super_rare": (245, 180, 70), # Gold + "ultra_rare": (235, 75, 75), # Red + "epic": (170, 120, 220), # relics (legacy) + "legendary": (245, 180, 70), # relics (legacy) +} + +# Critters that share one sprite across all five types; the type is assigned +# per unit (and shown with type icons in the collection). +VARIABLE_TYPE_UNITS = ("Pinwheel", "Butterfly", "Toadstool") + +PASSIVES = { + "zinnia_solar": ("Solar Decay", "Enemies Zinnia attacks deal 1% less damage per hit, up to 30%."), + "sunflower_incandescent": ("Incandescent", "Heal the ally with the lowest health 70% of Attack each turn."), + "daisy_gentle": ("Gentle Encouragement", "Healing an ally removes a debuff and boosts their speed."), + "marigold_golden": ("Golden Gleam", "The first hit each turn is reduced by 30%."), + "daffodil_toxic": ("Toxic Sap", "When attacked, Wilt the attacker for 50%; drain their Wilt to heal instead."), + "rose_briars": ("Briars", "Reflect 40% of damage taken back to the attacker as Wilt."), + "peony_robust": ("Robust", "Attacks ignore 20% of defense."), + "magnolia_nobility": ("Nobility", "Immune to stuns; periodic debuffs last 1 turn less."), + "hydrangea_ph": ("pH Shift", "Switches between Acidic and Alkaline each turn."), + "sweetpea_scent": ("Sweet Scent", "Tethered allies take 10% less damage and heal 30% more."), + "chrys_longevity": ("Longevity", "Gain 3% defense when hit."), + "lily_passionate": ("Passionate", "+Attack and +Speed while enemies are below 50% health."), + "hibiscus_intensifying": ("Intensifying", "Deal more damage to enemies with more max health."), + "poppy_hypnotic": ("Hypnotic", "10% chance per attack to Intoxicate (1 turn) and Oblivion (1 turn) an enemy. Intoxicate: half the victim's damage splashes onto one of their own allies. Oblivion: they may only use their weakest skill."), + "tulip_equinox": ("Verdant Equinox", "Allies deal 10% more damage while shielded."), + "lotus_rebirth": ("Rebirth", "Revive the first fallen ally at 50% health."), + "dandelion_cover": ("Cover", "The first time an ally drops below 40% HP, shield them for 50% of max HP."), + "nightshade_corruption": ("Corruption", "When an enemy dies with Dark Corrosion, it spreads to the next most vulnerable enemy. Dark Corrosion also applies a random stat debuff to the target for 2 turns."), + "moonflower_night": ("Everlasting Night", "Damage all enemies each turn, growing stronger; Glimmering Moon's double-hit chance rises 10% each turn."), + "orchid_epiphytic": ("Epiphytic", "+Speed and +Attack while enemies are below 40% health."), + "lavender_soporific": ("Soporific", "+Speed and +Attack while an enemy is asleep."), + "flytrap_feed": ("Feed", "Heal for 20% of damage dealt."), + "snowdrop_winter": ("Winter Breeze", "Slow all enemies at start; Chill the attacker of a fallen ally."), + "primrose_coldhearted": ("Coldhearted", "The first skill bypasses tanks; afterwards only Frostbite does."), + "jasmine_janus": ("Janus Bloom", "Reduce the first damage each ally takes by 80%."), + "hellebore_toxin": ("Toxin", "Madness allies deal +50% and take +30%; enemies deal +30% and take +50%."), + "camelia_frigid": ("Frigid", "Gain an Icicle when hit."), +} + + +def plant_rarity(name: str) -> str: + return RARITY.get(name, "common") + + +def _apply_injection(name: str, team: int, skills): + """Replace a plant's skill with its persisted injected ability, if any. + Only the player's own plants carry injections (enemies never inherit them). + Returns (injected_index, injected_name) or (-1, "").""" + if team != TEAM_PLAYER: + return -1, "" + from . import collection # lazy: collection imports plants at module level + from .skills import skill_from_dict + inj = collection.injection_of(name) + idx = inj.get("index", 1) if inj else -1 + if inj and 0 < idx < len(skills): + skills[idx] = skill_from_dict(inj["skill"]) + return idx, inj["skill"]["name"] + return -1, "" + + +def make(name: str, team: int, ptype: str = None) -> Unit: + spec = POOL[name] + atk, spd, dfn, hp = spec["stats"] + skills = [copy.deepcopy(s) for s in spec["skills"]] + inj_index, inj_name = _apply_injection(name, team, skills) + # Display order = list order (basic -> 2nd -> 3rd). Selection priority is + # position-based: the LAST skill is the strongest, so it is preselected first. + for i, s in enumerate(skills): + s.priority = len(skills) - i + unit_type = ptype if ptype is not None or name in VARIABLE_TYPE_UNITS else spec["type"] + unit = Unit(name, team, unit_type, max_hp=int(hp * HP_MULT), attack=atk, speed=spd, defense=dfn, + skills=skills, + role=spec["role"], passive=spec.get("passive", ""), + passive_value=spec.get("passive_value", 0)) + unit.injected_index = inj_index + unit.injected_name = inj_name + _apply_runes(name, team, unit) + return unit + + +def _apply_runes(name: str, team: int, unit: Unit) -> None: + """Load the player's equipped runes for `name` onto the unit as + (effect, pct) pairs. Only the player's own plants carry runes (enemies never + inherit them).""" + if team != TEAM_PLAYER: + return + from . import collection # lazy: collection imports plants at module level + from . import runes + equipped = collection.equipped_runes(name) + if not equipped: + return + by_id = {r["id"]: r for r in collection.rune_inventory()} + effects = [] + for rune_id in equipped: + rune = by_id.get(rune_id) + if rune and rune["color"] in runes.RUNE_EFFECTS: + effects.append((runes.effect_for(rune["color"]), rune.get("pct", 30) / 100.0)) + unit.runes = effects + + +def make_leveled(name: str, team: int, level: int = 1, stage: int = 1, ptype: str = None) -> Unit: + u = make(name, team, ptype) + if level > 1 or stage > 1: + atk, spd, dfn, hp = compute_stats(spec := POOL[name]["stats"], level, stage) + u.attack = atk + u.speed = spd + u.defense = dfn + u.max_hp = int(hp * HP_MULT) + u.hp = u.max_hp + u.level = level + u.stage = stage + return u + + +def build_pool() -> Dict[str, Unit]: + return {name: make(name, TEAM_PLAYER) for name in POOL} \ No newline at end of file diff --git a/engine/relics.py b/engine/relics.py new file mode 100644 index 0000000..1dd13cf --- /dev/null +++ b/engine/relics.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import random +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_HEAL_BOOST, S_HOT, S_PREVENT_FIRST_DAMAGE, S_SHIELD, S_SNOWFLAKES, S_SPEED_DOWN, + S_SPEED_UP, S_VULNERABLE, +) + + +class Relic: + """A roguelike blessing collected during a Tower run. `apply` runs once at + the start of each battle for the rest of the run. Rune-reward relics + (`rune_reward` set) have no battle effect and instead grant runes when the + run ends; they may be picked more than once.""" + + __slots__ = ("key", "name", "desc", "rarity", "apply", "rune_reward") + + def __init__(self, key: str, name: str, desc: str, rarity: str, + apply: Callable[["Battle"], None], rune_reward: str = "") -> None: + self.key = key + self.name = name + self.desc = desc + self.rarity = rarity + self.apply = apply + self.rune_reward = rune_reward + + def __repr__(self) -> str: + return self.name + + +# --- apply helpers ------------------------------------------------------ + +def _players(battle): + return [u for u in battle.players if u.alive] + + +def _pct_stat(attr, pct): + def apply(battle): + for u in _players(battle): + setattr(u, attr, int(getattr(u, attr) * (1 + pct))) + return apply + + +def _add_stat(attr, amount): + def apply(battle): + for u in _players(battle): + setattr(u, attr, getattr(u, attr) + amount) + return apply + + +def _pct_hp(pct): + def apply(battle): + for u in _players(battle): + u.max_hp = int(u.max_hp * (1 + pct)) + u.hp = u.max_hp + return apply + + +def _crit(pct): + def apply(battle): + for u in _players(battle): + u.crit = min(1.0, u.crit + pct) + return apply + + +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) + return apply + + +def _buff(key, value, duration): + def apply(battle): + for u in _players(battle): + u.status[key] = (value, duration) + return apply + + +def _enemy_debuff(key, value, duration): + def apply(battle): + for u in battle.enemies: + if u.alive: + u.status[key] = (value, duration) + return apply + + +def _heal_boost(pct): + def apply(battle): + for u in _players(battle): + u.status[S_HEAL_BOOST] = pct + return apply + + +def _hot(amount, turns): + def apply(battle): + for u in _players(battle): + u.status[S_HOT] = [(amount, turns)] + return apply + + +def _snowflakes(value): + def apply(battle): + for u in _players(battle): + u.status[S_SNOWFLAKES] = u.status.get(S_SNOWFLAKES, 0) + value + return apply + + +def _combine(*apps): + def apply(battle): + for fn in apps: + fn(battle) + return apply + + +def _prevent_first_damage(): + def apply(battle): + for u in _players(battle): + u.status[S_PREVENT_FIRST_DAMAGE] = 1 + return apply + + +def _enemy_vulnerable(hits): + def apply(battle): + for u in battle.enemies: + if u.alive: + u.status[S_VULNERABLE] = u.status.get(S_VULNERABLE, 0) + hits + return apply + + +# --- the relic pool ----------------------------------------------------- + +RELIC_POOL: List[Relic] = [ + # common + Relic("iron_bark", "Iron Bark", + "Your team starts the battle with a shield for 10% of their max health.", + "common", _shield(0.10)), + Relic("vitality", "Vitality", + "+12% max health for your team.", + "common", _pct_hp(0.12)), + Relic("granite", "Granite Roots", + "+10% defense for your team.", + "common", _pct_stat("defense", 0.10)), + Relic("sunlit", "Sunlit Growth", + "+8% attack for your team.", + "common", _pct_stat("attack", 0.08)), + Relic("swift", "Swift Petals", + "+5 speed for your team.", + "common", _add_stat("speed", 5)), + Relic("sharp_eye", "Sharp Vision", + "+5% critical chance for your team.", + "common", _crit(0.05)), + Relic("morning_dew", "Morning Dew", + "Your team starts the battle with Attack Up for 1 turn.", + "common", _buff(S_ATTACK_UP, 10, 1)), + Relic("dawn_aura", "Dawn Aura", + "Your team starts the battle with Defense Up for 1 turn.", + "common", _buff(S_DEFENSE_UP, 8, 1)), + Relic("regrowth", "Regrowth", + "Your team starts the battle with a heal-over-time (20 HP/turn for 3 turns).", + "common", _hot(20, 3)), + # rare + Relic("living_spring", "Living Spring", + "Your team's heals are 15% stronger.", + "rare", _heal_boost(15)), + Relic("chilling_gust", "Chilling Gust", + "Enemies start the battle with Speed Down for 1 turn.", + "rare", _enemy_debuff(S_SPEED_DOWN, 8, 1)), + Relic("weakening_fog", "Weakening Fog", + "Enemies start the battle with Attack Down for 1 turn.", + "rare", _enemy_debuff(S_ATTACK_DOWN, 10, 1)), + Relic("frost_blades", "Frosted Blades", + "Your team starts the battle with +5 bonus hit damage.", + "rare", _snowflakes(5)), + Relic("gale_wind", "Gale Wind", + "+10 speed for your team.", + "rare", _add_stat("speed", 10)), + Relic("rune_cache", "Rune Cache", + "No battle effect. When the tower run ends, gain a random Tier 1 rune. " + "Can be picked more than once.", + "rare", lambda battle: None, rune_reward="tier1"), + # epic + Relic("thicket", "Thick Thicket", + "Your team starts the battle with a shield for 25% of their max health.", + "epic", _shield(0.25)), + Relic("rusting_rain", "Rusting Rain", + "Enemies start the battle with Defense Down for 1 turn.", + "epic", _enemy_debuff(S_DEFENSE_DOWN, 20, 1)), + Relic("overpower", "Overpower", + "+15% attack for your team.", + "epic", _pct_stat("attack", 0.15)), + Relic("apex", "Apex Growth", + "+20% max health for your team.", + "epic", _pct_hp(0.20)), + Relic("fearless", "Fearless", + "+10% critical chance for your team.", + "epic", _crit(0.10)), + Relic("expose", "Expose", + "Enemies start the battle Vulnerable for 2 hits.", + "epic", _enemy_vulnerable(2)), + Relic("rune_crypt", "Rune Crypt", + "No battle effect. When the tower run ends, gain a Tier 2 rune (brown). " + "Can be picked more than once.", + "epic", lambda battle: None, rune_reward="brown"), + # legendary + Relic("protective_aura", "Protective Aura", + "Each ally takes 80% less damage from the first hit they receive in a battle.", + "legendary", _prevent_first_damage()), + Relic("aegis", "Aegis", + "Allies start the battle with a shield for 40% of their max health.", + "legendary", _shield(0.40)), + Relic("overgrowth", "Overgrowth", + "Allies start the battle with +20% max health and +10% attack.", + "legendary", _combine(_pct_hp(0.20), _pct_stat("attack", 0.10))), + Relic("second_wind", "Second Wind", + "The first time an ally would fall in a battle, they survive at 50% HP instead.", + "legendary", lambda battle: None), +] + +RELIC_BY_KEY = {r.key: r for r in RELIC_POOL} + +# Relative draw weight per rarity tier; legendary relics should show up rarely. +RELIC_RARITY_WEIGHT = {"common": 100, "rare": 60, "epic": 35, "legendary": 12} + + +def _tier_weights(stage): + """Draw weights for a tower stage. Better tiers get rarer at low stages and + more common at high stages, but are never impossible.""" + frac = 0.0 + if stage: + frac = min(1.0, max(0.0, (stage - 1) / 19.0)) # 0 at stage 1, 1 at stage 20 + common = 100 + rare = 60 * (0.9 + 0.2 * frac) + epic = 35 * (0.7 + 0.6 * frac) + legendary = 12 * (0.5 + 1.0 * frac) + return {"common": common, "rare": rare, "epic": epic, "legendary": legendary} + + +def by_key(key: str) -> Optional[Relic]: + return RELIC_BY_KEY.get(key) + + +def rune_reward_count(relic_keys: Iterable[str]) -> int: + """How many runes the picked rune-reward relics will grant when the run ends.""" + return sum(1 for k in relic_keys if getattr(by_key(k), "rune_reward", "")) + + +def grant_rune_rewards(relic_keys: Iterable[str]) -> list: + """Grant the runes promised by any rune-reward relics collected during a + tower run (called when the run ends). Returns the granted runes.""" + from . import collection # lazy: collection doesn't import relics + granted = [] + for key in relic_keys: + relic = by_key(key) + reward = getattr(relic, "rune_reward", "") + if reward == "tier1": + granted.append(collection.add_rune(random.choice(runes.TIER1_COLORS))) + elif reward == "brown": + granted.append(collection.add_rune("brown")) + return granted + + +def roll_relics(n: int = 3, exclude: Iterable[str] = (), stage: Optional[int] = None) -> List[Relic]: + """Roll `n` distinct relics, excluding already-owned keys. Draws are weighted + by rarity, scaled by the tower stage so better tiers appear less in the early + stages (but are never impossible). Rune-reward relics are never excluded, so + they can be picked more than once in a run.""" + ex = set(exclude) + pool = [r for r in RELIC_POOL if (r.key not in ex) or r.rune_reward] + if not pool: + return [] + weights_by_tier = _tier_weights(stage) + weights = [weights_by_tier.get(r.rarity, 50) for r in pool] + chosen = [] + while len(chosen) < n and pool: + pick = random.choices(pool, weights=weights, k=1)[0] + chosen.append(pick) + idx = pool.index(pick) + pool.pop(idx) + weights.pop(idx) + return chosen \ No newline at end of file diff --git a/engine/roster.py b/engine/roster.py new file mode 100644 index 0000000..554d6f2 --- /dev/null +++ b/engine/roster.py @@ -0,0 +1,333 @@ +from typing import List + +from . import collection +from .plants import POOL, VARIABLE_TYPE_UNITS, make_leveled, plant_rarity +from .types import ALL_TYPES, TYPE_BEATS +from .unit import ROLE_ATTACKER, ROLE_SUPPORT, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER, Unit + + +DIFFICULTY_RARITIES = { + 1: ("rare",), + 2: ("rare", "super_rare"), + 3: ("rare", "super_rare"), + 4: ("super_rare", "super_super_rare"), + 5: ("super_super_rare",), +} + +# Low-rarity plants that join floor enemies at higher floors (deterministic pick). +FLOOR_RARE_PLANTS = ["Marigold", "Daffodil", "Peony", "Primrose", + "Hibiscus", "Dandelion", "Venus Flytrap"] +# Low-rarity tank plants used as enemy tanks on later floors. +FLOOR_TANK_PLANTS = ["Marigold", "Daffodil", "Venus Flytrap"] + + +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 + 10-15, then a full 4-enemy team from floor 16 on.""" + if floor <= 3: + return 1 + if floor <= 9: + return 2 + if floor <= 15: + return 3 + return 4 + + +def floor_enemy_names(floor: int, player_type: str) -> List[str]: + """Deterministic enemy names for a battle floor. Floors 1-3 are a single + pinwheel; the team grows from there — 3+ enemies from floor 10 and always + 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.""" + 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. + names = ["Pinwheel"] + cycle = ("Butterfly", "Toadstool", "Pinwheel", "Toadstool", + "Butterfly", "Pinwheel", "Butterfly", "Toadstool") + for i in range(1, count): + names.append(cycle[(floor + i) % len(cycle)]) + # High floors mix in plants, replacing critter slots (4-enemy cap kept). + if floor >= 40 and count >= 3: + names[-1] = FLOOR_TANK_PLANTS[(floor // 2) % len(FLOOR_TANK_PLANTS)] + if floor >= 45 and count >= 4: + names[-2] = FLOOR_RARE_PLANTS[(floor * 7) % len(FLOOR_RARE_PLANTS)] + return names + + +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 + types = [] + for i, n in enumerate(names): + if n in VARIABLE_TYPE_UNITS: + if floor <= 3: + types.append(TYPE_BEATS.get(player_type, "garden")) + else: + types.append(random.choice(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 + + +def make_floor_enemies(floor: int, player_type: str) -> List[Unit]: + """Build the enemy unit team for a floor, with level/stage scaling and a + fresh roll of critter types.""" + names = floor_enemy_names(floor, player_type) + types = roll_enemy_types(names, player_type, floor) + return build_floor_enemies(names, types, floor) + + +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).""" + 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)] + + +def difficulty_pool(difficulty: int) -> List[str]: + rarities = DIFFICULTY_RARITIES.get(difficulty, DIFFICULTY_RARITIES[1]) + return [n for n in POOL if plant_rarity(n) in rarities] + + +TOWER_STAGES = 20 +TOWER_INJECT_FLOOR = 10 +TOWER_INJECT_CHANCE_MIN = 0.20 +TOWER_INJECT_CHANCE_MAX = 0.60 + + +def tower_inject_chance(stage: int) -> float: + """Chance (0..1) that a tower enemy at `stage` carries an injected ability.""" + if stage < TOWER_INJECT_FLOOR: + return 0.0 + frac = min(1.0, (stage - TOWER_INJECT_FLOOR) / (TOWER_STAGES - TOWER_INJECT_FLOOR)) + return TOWER_INJECT_CHANCE_MIN + frac * (TOWER_INJECT_CHANCE_MAX - TOWER_INJECT_CHANCE_MIN) + + +def roll_enemy_injection(name: str) -> dict: + """Roll an injected ability for an enemy plant, or None. It comes from a + same-role plant's extractable skills; legendary plants only pull from other + legendary plants so they stay strong. Never replaces a slot with the same + ability it already has.""" + import random + + from .skills import skill_to_dict + my_rarity = plant_rarity(name) + my_role = POOL[name]["role"] + base = POOL[name]["skills"] + candidates = [] + for other, spec in POOL.items(): + if other == name: + continue + if spec["role"] != my_role: + continue + if my_rarity == "super_super_rare" and plant_rarity(other) != "super_super_rare": + continue + for _, sk in collection.extractable_skills(other): + candidates.append(sk) + if not candidates: + return None + pairs = [] + for sk in candidates: + for slot in (1, 2): + if slot < len(base) and base[slot].name != sk.name: + pairs.append((sk, slot)) + if not pairs: + return None + sk, slot = random.choice(pairs) + return {"index": slot, "source": name, "skill": skill_to_dict(sk)} + + +def make_tower_enemies(names: List[str], level: int, stage: int) -> List[Unit]: + """Build a tower enemy team; each enemy independently rolls a chance to + carry one injected ability (floors 10+) and gains runes at higher stages.""" + import random + + from . import runes + from .skills import skill_from_dict + units = [] + estage = tower_enemy_stage(stage) + for n in names: + u = make_leveled(n, TEAM_ENEMY, level, estage) + if random.random() < tower_inject_chance(stage): + inj = roll_enemy_injection(n) + if inj and inj["index"] < len(u.skills): + u.skills[inj["index"]] = skill_from_dict(inj["skill"]) + u.injected_index = inj["index"] + u.injected_name = inj["skill"]["name"] + u.runes = runes.roll_enemy_runes(stage) + units.append(u) + return units + + +def roll_tower_enemy_team(stage: int, level: int, exclude=()) -> tuple: + """Roll a full tower enemy team once, returning (names, injections, runes). + The recipe is cached per stage so a retry rebuilds the exact same team + instead of rerolling it.""" + import random + + from . import runes + names = roll_tower_enemies(stage, exclude=exclude) + injections = [] + for n in names: + inj = None + if random.random() < tower_inject_chance(stage): + inj = roll_enemy_injection(n) + injections.append(inj) + enemy_runes = [runes.roll_enemy_runes(stage) for _ in names] + return names, injections, enemy_runes + + +def build_tower_enemy_team(names: List[str], injections, enemy_runes, + level: int, stage: int) -> List[Unit]: + """Rebuild tower enemy units from a previously rolled team recipe.""" + from .skills import skill_from_dict + units = [] + estage = tower_enemy_stage(stage) + for n, inj, rns in zip(names, injections, enemy_runes or []): + u = make_leveled(n, TEAM_ENEMY, level, estage) + if inj and inj["index"] < len(u.skills): + u.skills[inj["index"]] = skill_from_dict(inj["skill"]) + u.injected_index = inj["index"] + u.injected_name = inj["skill"]["name"] + u.runes = list(rns) if rns else [] + units.append(u) + return units + + +def tower_pool(stage: int) -> tuple: + """Rarities available at a tower stage (stage 1 = rares only, ramping up).""" + if stage <= 1: + return ("rare",) + if stage <= 3: + return ("rare", "super_rare") + if stage <= 5: + return ("rare", "super_rare") + if stage <= 8: + return ("super_rare",) + if stage <= 12: + return ("super_rare", "super_super_rare") + return ("super_super_rare",) + + +def tower_enemy_stage(stage: int) -> int: + """Evolution stage for tower enemies: they evolve as the tower ramps, which + multiplies their base stats (1.0 / 1.25 / 1.55).""" + if stage >= 11: + return 3 + if stage >= 5: + return 2 + return 1 + + +def tower_enemy_level(player_level: int, stage: int) -> int: + return max(1, min(33, player_level + (stage - 1) * 3 // 4)) + + +def roll_tower_enemies(stage: int, exclude=()) -> List[str]: + """Roll the tower enemy team for a stage. Count grows 1 -> 4; rarity ramps. + Tanks are scarce at high stages (there are no super-super-rare tanks), so the + tank slot is filled from the super-rare pool when the stage pool has none.""" + import random + pool = [n for n in POOL + if plant_rarity(n) in tower_pool(stage) and n not in exclude] + count = 1 if stage == 1 else min(4, (stage + 3) // 2) + if count == 1: + return [random.choice(pool)] + + def _by(role, source): + return [n for n in source if POOL[n]["role"] == role] + + tank_pool = _by(ROLE_TANK, pool) + if not tank_pool: + tank_pool = [n for n in POOL + if POOL[n]["role"] == ROLE_TANK and plant_rarity(n) == "super_rare" + and n not in exclude] + + chosen = [] + for role, source in ((ROLE_TANK, tank_pool), (ROLE_SUPPORT, pool), + (ROLE_ATTACKER, pool), (ROLE_ATTACKER, pool)): + if len(chosen) >= count: + break + cands = [n for n in _by(role, source) if n not in chosen] + if cands: + chosen.append(random.choice(cands)) + remaining = [n for n in pool if n not in chosen] + random.shuffle(remaining) + return (chosen + remaining)[:count] + + +def tower_reward(stage: int): + """Reward for defeating a tower stage: (water, tower_packs).""" + if stage <= 5: + return 1, 0 + return 0, 1 + (stage - 6) // 5 + + +def roll_enemies(difficulty: int, exclude=()) -> List[str]: + """Roll a 4-plant enemy team for a difficulty stage, aiming for + 1 tank + 1 support + 2 attackers. Falls back gracefully when a role + isn't present in the pool (e.g. no legendary tanks on stage 5).""" + import random + pool = [n for n in difficulty_pool(difficulty) if n not in exclude] + if len(pool) < 4: # safety fallback: never fewer than a full team + pool = [n for n in POOL if n not in exclude] + + def _by_role(role): + return [n for n in pool if POOL[n]["role"] == role] + + chosen = [] + for role in (ROLE_TANK, ROLE_SUPPORT, ROLE_ATTACKER, ROLE_ATTACKER): + candidates = [n for n in _by_role(role) if n not in chosen] + if candidates: + chosen.append(random.choice(candidates)) + remaining = [n for n in pool if n not in chosen] + random.shuffle(remaining) + return (chosen + remaining)[:4] + + +def pick_team(names: List[str], team: int) -> List[Unit]: + units = [] + for n in names: + prog = collection.get_progress(n) + units.append(make_leveled(n, team, prog["level"], prog["stage"], + ptype=collection.unit_type(n))) + return units + + +def make_enemy_team(names: List[str], level: int = 1, stage: int = 1) -> List[Unit]: + return [make_leveled(n, TEAM_ENEMY, level, stage) for n in names] + + +def player_team_level(names: List[str]) -> int: + levels = [collection.get_progress(n)["level"] for n in names] + return max(1, round(sum(levels) / max(1, len(levels)))) + + +def player_team_stage(names: List[str]) -> int: + stages = [collection.get_progress(n)["stage"] for n in names] + return max(1, round(sum(stages) / max(1, len(stages)))) + + +DEFAULT_PLAYERS = ["Lavender", "Marigold", "Sunflower", "Zinnia"] +DEFAULT_ENEMIES = ["Rose", "Tulip", "Camelia", "Hellebore"] + + +def make_roster(): + """Deterministic level-1 rosters (used by tests; independent of save data).""" + return ([make_leveled(n, TEAM_PLAYER, 1, 1) for n in DEFAULT_PLAYERS], + [make_leveled(n, TEAM_ENEMY, 1, 1) for n in DEFAULT_ENEMIES]) + + +def all_names() -> List[str]: + return list(POOL.keys()) \ No newline at end of file diff --git a/engine/runes.py b/engine/runes.py new file mode 100644 index 0000000..667c966 --- /dev/null +++ b/engine/runes.py @@ -0,0 +1,192 @@ +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_NAMES = { + "sapphire": "Sapphire Rune", + "ruby": "Ruby Rune", + "amethyst": "Amethyst Rune", + "jade": "Jade Rune", + "brown": "Brown Rune", +} + +# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown is Tier 2. +TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade") +TIER2_COLORS = ("brown",) +RUNE_TIERS = {c: 1 for c in TIER1_COLORS} +RUNE_TIERS.update({c: 2 for c in TIER2_COLORS}) + +# Sprite file (without extension) per rune color. +RUNE_ICON_FILES = { + "sapphire": "sapphire_rune", + "ruby": "ruby_rune", + "jade": "jade_rune", + "amethyst": "amethyst_rune", + "brown": "rune_brown", +} + +# Old color names kept in earlier saves, mapped to the current gem names. +RUNE_COLOR_ALIASES = { + "blue": "sapphire", + "red": "ruby", + "white": "amethyst", + "yellow": "jade", +} + + +def canonical_color(color: str) -> str: + """Map an old rune color name (blue/red/white/yellow) to the current gem.""" + return RUNE_COLOR_ALIASES.get(color, color) + + +# 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. +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 +} +PCT_MIN = 25 +PCT_MAX = 35 +BROWN_PCT_MIN = 5 +BROWN_PCT_MAX = 15 +# a plant can't stack more than this many runes of the same type +MAX_RUNES_PER_TYPE = 2 +# 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} +_EFFECT_TEXTS = { + "ruby": "+{pct}% damage dealt", + "sapphire": "+{pct}% shielding", + "jade": "+{pct}% healing", + "amethyst": "-{pct}% damage taken", + "brown": "deals {pct}% of damage as Wilt", +} +RUNE_SUMMARIES = { + "ruby": "Increases damage dealt", + "sapphire": "Increases shielding", + "jade": "Increases healing received", + "amethyst": "Reduces damage taken", + "brown": "Deals a portion of damage as Wilt", +} + +MAX_RUNE_SLOTS = 3 + +# Drop chances (test build) +BATTLE_DROP_CHANCE = 0.15 # rare rune from a normal floor battle win +PACK_DROP_CHANCE = 0.05 # rare additional rune from a seed pack + + +def random_color() -> str: + """Roll a rune color; brown is weighted rarer than the others.""" + weights = [RUNE_WEIGHTS.get(c, 1.0) for c in RUNE_COLORS] + return random.choices(RUNE_COLORS, weights=weights, k=1)[0] + + +def roll_pct(color: str = None) -> int: + """Roll a rune's stat percentage. Brown rolls 5-15; the rest 25-35.""" + if color == "brown": + return random.randint(BROWN_PCT_MIN, BROWN_PCT_MAX) + return random.randint(PCT_MIN, PCT_MAX) + + +def name(color: str) -> str: + return RUNE_NAMES.get(color, "Rune") + + +def tier(color: str) -> int: + return RUNE_TIERS.get(color, 1) + + +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 + + +def effect_text(color: str, pct: int) -> str: + """Human-readable effect for a rune of `color` with rolled stat `pct`.""" + return _EFFECT_TEXTS.get(color, "").format(pct=pct) + + +_CARD_TEXTS = { + "ruby": "+{pct}% dmg", + "sapphire": "+{pct}% shield", + "jade": "+{pct}% heal", + "amethyst": "-{pct}% taken", + "brown": "Wilt {pct}%", +} + + +def card_text(color: str, pct: int) -> str: + """Compact effect label that fits a small card (e.g. 'Wilt 12%').""" + return _CARD_TEXTS.get(color, "").format(pct=pct) + + +def effect_for(color: str) -> str: + return RUNE_EFFECTS.get(color, "") + + +_EFFECT_COLORS = {v: k for k, v in RUNE_EFFECTS.items()} + + +def color_for_effect(effect: str) -> str: + """The rune colour whose effect is `effect` (for showing enemy runes).""" + return _EFFECT_COLORS.get(effect, "") + + +def summary(color: str) -> str: + return RUNE_SUMMARIES.get(color, "") + + +def roll_range(color: str) -> str: + """The stat range a rune of `color` rolls between (brown is lower).""" + if color == "brown": + return f"{BROWN_PCT_MIN}–{BROWN_PCT_MAX}%" + return f"{PCT_MIN}–{PCT_MAX}%" + + +def slots_for_max_stage(max_stage: int) -> int: + """Permanent rune slots unlocked by the highest evolution stage a plant has + ever reached (stage 1 -> 1 slot, stage 2 -> 2, stage 3 -> 3).""" + return min(MAX_RUNE_SLOTS, max(1, max_stage)) + + +def enemy_rune_count(stage: int) -> int: + """How many runes a tower enemy carries at `stage` (more as the tower ramps).""" + if stage < 3: + return 0 + return min(3, 1 + (stage - 3) // 5) + + +def roll_enemy_runes(stage: int) -> list: + """Roll the runes a single tower enemy carries at `stage`. Returns a list of + (effect_key, pct_fraction) pairs ready for `Unit.runes`. At most + MAX_RUNES_PER_TYPE runes of the same type are rolled (same rule as players).""" + count = enemy_rune_count(stage) + runes_list = [] + counts = {} + while len(runes_list) < count: + color = random_color() + if counts.get(color, 0) >= MAX_RUNES_PER_TYPE: + continue # type already at the cap; roll another colour + counts[color] = counts.get(color, 0) + 1 + runes_list.append((effect_for(color), roll_pct(color) / 100.0)) + return runes_list + + +def per_rune_chance(source_chance: float) -> tuple: + """Effective drop chance (low, high) for a specific rune colour from a drop + source, where brown's lower weight makes it the low end.""" + weights = [RUNE_WEIGHTS.get(c, 1.0) for c in RUNE_COLORS] + total = sum(weights) + return source_chance * min(weights) / total, source_chance * max(weights) / total \ No newline at end of file diff --git a/engine/seeds.py b/engine/seeds.py new file mode 100644 index 0000000..37a76a3 --- /dev/null +++ b/engine/seeds.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import random +from collections import Counter + +from .plants import POOL, plant_rarity +from .types import ALL_TYPES + +PACK_TYPES = ("lesser", "tower", "general") + ALL_TYPES # lesser, tower, general + one per type +REWARD_PACK_TYPES = ("general",) + ALL_TYPES # good packs only (win rewards) +RARITY_WEIGHTS = {"common": 50, "rare": 35, "super_rare": 5, "super_super_rare": 2, "ultra_rare": 1} +LESSER_RARITY_WEIGHTS = {"common": 70, "rare": 25, "super_rare": 2, "super_super_rare": 0, "ultra_rare": 0} +TOWER_RARITY_WEIGHTS = {"common": 2, "rare": 15, "super_rare": 25, "super_super_rare": 15, "ultra_rare": 5} + + +def _weighted(names, weights_map=None) -> str: + weights_map = weights_map or RARITY_WEIGHTS + weights = [weights_map.get(plant_rarity(n), 20) for n in names] + return random.choices(names, weights=weights, k=1)[0] + + +def open_pack(pack_type: str) -> str: + """Return a random plant name for the given pack type.""" + if pack_type == "lesser": + return _weighted(list(POOL.keys()), LESSER_RARITY_WEIGHTS) + if pack_type == "tower": + return _weighted(list(POOL.keys()), TOWER_RARITY_WEIGHTS) + if pack_type == "general": + return _weighted(list(POOL.keys())) + if pack_type in ALL_TYPES: + # type pack: roll the rarity first (with the general ladder), then pick a + # plant of that rarity from the type; fall back to the whole pool only if + # that rarity isn't present in the type. + dist = _rarity_odds(list(POOL.keys()), RARITY_WEIGHTS) + rarity = random.choices(list(dist.keys()), + weights=[dist[r] for r in dist], k=1)[0] + candidates = [n for n in POOL + if POOL[n]["type"] == pack_type and plant_rarity(n) == rarity] + if not candidates: + candidates = [n for n in POOL if plant_rarity(n) == rarity] + return random.choice(candidates) + return _weighted(list(POOL.keys())) + + +def _rarity_odds(pool, weights_map) -> dict: + """Chance (0..1) of each rarity when rolling from `pool` with `weights_map`.""" + counts = Counter(plant_rarity(n) for n in pool) + total = sum(counts.get(r, 0) * w for r, w in weights_map.items()) + if total <= 0: + return {r: 0.0 for r in weights_map} + return {r: counts.get(r, 0) * weights_map[r] / total for r in weights_map} + + +def pack_odds(pack_type: str) -> dict: + """Chance (0..1) of each rarity for the given pack type.""" + whole = list(POOL.keys()) + if pack_type == "lesser": + return _rarity_odds(whole, LESSER_RARITY_WEIGHTS) + if pack_type == "tower": + return _rarity_odds(whole, TOWER_RARITY_WEIGHTS) + if pack_type == "general": + return _rarity_odds(whole, RARITY_WEIGHTS) + # type packs roll the rarity first with the general ladder, so their rarity + # odds match the general pack (the plant then comes from the type when possible). + return _rarity_odds(whole, RARITY_WEIGHTS) \ No newline at end of file diff --git a/engine/skills.py b/engine/skills.py new file mode 100644 index 0000000..3ed33ee --- /dev/null +++ b/engine/skills.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from typing import List + +# Target kinds +TARGET_ENEMY = "enemy" +TARGET_ALL_ENEMIES = "all_enemies" +TARGET_TWO_ENEMIES = "two_enemies" +TARGET_ALLY = "ally" +TARGET_ALL_ALLIES = "all_allies" +TARGET_SELF = "self" +TARGET_ANY = "any" + +# Effect kinds +EFF_SHIELD = "shield" +EFF_WILT = "wilt" # value = % of the skill's damage dealt, over 3 turns +EFF_DOT = "dot" # value = damage per turn, duration = turns +EFF_HOT = "hot" # value = heal per turn, duration = turns +EFF_STATUS = "status" # applies status[key] with value/duration +EFF_HEAL = "heal" # immediate heal + + +@dataclass +class Effect: + kind: str + value: int = 0 + duration: int = 0 + key: str = "" + + +@dataclass +class Skill: + name: str + power: int = 0 + cooldown: int = 0 + target: str = TARGET_ENEMY + kind: str = "damage" # damage | heal | shield | utility + hits: int = 1 + effects: List[Effect] = field(default_factory=list) + description: str = "" + priority: int = 100 # lower = "best" (preselected / AI preference) + mechanic: str = "" # special behaviour id handled by the engine + extra_action: bool = False + requires_form: str = "" # hydrangea: "acidic" | "alkaline" + power_alt: int = 0 # conditional power (e.g. [50/75]) + cooldown_left: int = field(init=False, default=0) + + +def roll_crit(chance: float) -> bool: + return random.random() < chance + + +def skill_to_dict(sk: "Skill") -> dict: + """Serialize a Skill (minus runtime cooldown) for persistence.""" + return { + "name": sk.name, + "power": sk.power, + "cooldown": sk.cooldown, + "target": sk.target, + "kind": sk.kind, + "hits": sk.hits, + "effects": [{"kind": e.kind, "value": e.value, "duration": e.duration, "key": e.key} + for e in sk.effects], + "description": sk.description, + "mechanic": sk.mechanic, + "extra_action": sk.extra_action, + "requires_form": sk.requires_form, + "power_alt": sk.power_alt, + } + + +def skill_from_dict(d: dict) -> "Skill": + """Recreate a Skill from a skill_to_dict payload.""" + return Skill( + name=d["name"], + power=d.get("power", 0), + cooldown=d.get("cooldown", 0), + target=d.get("target", TARGET_ENEMY), + kind=d.get("kind", "damage"), + hits=d.get("hits", 1), + effects=[Effect(**e) for e in d.get("effects", [])], + description=d.get("description", ""), + mechanic=d.get("mechanic", ""), + extra_action=d.get("extra_action", False), + requires_form=d.get("requires_form", ""), + power_alt=d.get("power_alt", 0), + ) + + +# --- shared status keys ------------------------------------------------- +S_SPEED_DOWN = "speed_down" +S_CRIT_DOWN = "crit_down" +S_ATTACK_DOWN = "attack_down" +S_DEFENSE_DOWN = "defense_down" +S_HEAL_REDUCTION = "healing_reduction" +S_HEAL_BOOST = "healing_boost" +S_SLEEP = "sleep" +S_VULNERABLE = "vulnerable" +S_INTOXICATE = "intoxicate" +S_OBLIVION = "oblivion" +S_TETHER = "tether" +S_MADNESS = "madness" +S_DENTING = "denting" +S_ATTACK_UP = "attack_up" +S_DEFENSE_UP = "defense_up" +S_SPEED_UP = "speed_up" +S_WILT = "wilt" +S_DOT = "dot" +S_HOT = "hot" +S_SHIELD = "shield" +S_ICICLES = "icicles" +S_SNOWFLAKES = "snowflakes" +S_ZINNIA_WEAKEN = "zinnia_weaken" +S_ABSORB = "absorb" +S_PREVENT_FIRST_DAMAGE = "prevent_first_damage" +S_LYING_WAIT = "lying_wait" +S_SEED = "seed" # orchid: erupts later +S_ATTACKED_THIS_TURN = "attacked_this_turn" # marigold: first-hit guard \ No newline at end of file diff --git a/engine/types.py b/engine/types.py new file mode 100644 index 0000000..f98cd7b --- /dev/null +++ b/engine/types.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +TYPE_RADIANT = "radiant" +TYPE_NOCTURNAL = "nocturnal" +TYPE_ICE = "ice" +TYPE_GARDEN = "garden" +TYPE_WILDFLOWER = "wildflower" + +ALL_TYPES = (TYPE_RADIANT, TYPE_NOCTURNAL, TYPE_ICE, TYPE_GARDEN, TYPE_WILDFLOWER) + +# type_beats[X] = the type that X is strong against +TYPE_BEATS = { + TYPE_RADIANT: TYPE_NOCTURNAL, + TYPE_NOCTURNAL: TYPE_ICE, + TYPE_ICE: TYPE_GARDEN, + TYPE_GARDEN: TYPE_WILDFLOWER, + TYPE_WILDFLOWER: TYPE_RADIANT, +} + +TYPE_ADVANTAGE_MULT = 1.4 # hitting a type you're strong against +TYPE_DISADVANTAGE_MULT = 0.7 # hitting a type that's strong against you + + +def type_multiplier(attacker_type: str, defender_type: str) -> float: + """Damage multiplier between two plant types. Only affects damage.""" + if TYPE_BEATS.get(attacker_type) == defender_type: + return TYPE_ADVANTAGE_MULT + if TYPE_BEATS.get(defender_type) == attacker_type: + return TYPE_DISADVANTAGE_MULT + return 1.0 + + +def type_relations(ptype: str): + """Return (strong_against, weak_against) for a type.""" + strong = TYPE_BEATS.get(ptype) + weak = next((k for k, v in TYPE_BEATS.items() if v == ptype), None) + return strong, weak \ No newline at end of file diff --git a/engine/unit.py b/engine/unit.py new file mode 100644 index 0000000..bacd3b0 --- /dev/null +++ b/engine/unit.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List + +from .skills import S_ATTACKED_THIS_TURN, Skill + +TEAM_PLAYER = 0 +TEAM_ENEMY = 1 + +ROLE_ATTACKER = "attacker" +ROLE_SUPPORT = "support" +ROLE_TANK = "tank" +ROLES = (ROLE_ATTACKER, ROLE_SUPPORT, ROLE_TANK) + +# Shared presentation for roles: human name, accent color, and icon file. +ROLE_NAMES = { + ROLE_ATTACKER: "Attacker", + ROLE_SUPPORT: "Support", + ROLE_TANK: "Tank", +} +ROLE_ICONS = { + ROLE_ATTACKER: "attacker_icon", + ROLE_SUPPORT: "supporter_icon", + ROLE_TANK: "tank_icon", +} +ROLE_COLORS = { + ROLE_ATTACKER: (235, 130, 90), + ROLE_SUPPORT: (120, 155, 235), + ROLE_TANK: (235, 200, 90), +} + + +@dataclass +class Unit: + name: str + team: int + plant_type: str + max_hp: int + attack: int + speed: int + defense: int + skills: List[Skill] + role: str = "" + crit: float = 0.10 + passive: str = "" + passive_value: int = 0 + sort_order: int = 0 + runes: List = field(default_factory=list) # (effect_key, pct_fraction) per equipped rune + + hp: int = field(init=False) + alive: bool = field(init=False) + status: dict = field(default_factory=dict) + acted: bool = field(default=False) + next_at: float = field(default=0.0) + level: int = field(default=1) + stage: int = field(default=1) + first_skill_used: bool = field(default=False) # primrose + used_once: bool = field(default=False) # generic "first time" flags + injected_index: int = field(default=-1, init=False) + injected_name: str = field(default="", init=False) + + def __post_init__(self) -> None: + self.hp = self.max_hp + self.alive = True + self.status = {} + + def reset_turn_flags(self) -> None: + self.acted = False + self.status.pop(S_ATTACKED_THIS_TURN, None) + + def __repr__(self) -> str: + return self.name \ No newline at end of file diff --git a/ideas/floors.txt b/ideas/floors.txt new file mode 100644 index 0000000..d14cacc --- /dev/null +++ b/ideas/floors.txt @@ -0,0 +1,112 @@ +Plant Battle - Floor Guide (floors 1-100) + +Enemy level = 1 + (floor - 1) // 5 +Enemy count: 1 on floors 1-3, 2 on 4-9, 3 on 10-15, 4 from floor 16. +Floors 1-3 fight a single Pinwheel of the type the player is strong +against (the sheet assumes a Radiant player, so that Pinwheel is Nocturnal). +Critter types (Pinwheel/Butterfly/Toadstool) roll randomly per battle in-game; +this sheet shows one deterministic roll per floor. Duplicate critters and +duplicate types are allowed for enemies. Tank plants join from floor 40, +low-rarity plants from floor 45 (they replace critter slots, never exceeding +4 enemies). + +Floor 1 Lv 1 Pinwheel (nocturnal) +Floor 2 Lv 1 Pinwheel (nocturnal) +Floor 3 Lv 1 Pinwheel (nocturnal) +Floor 4 Lv 1 Pinwheel (nocturnal) | Pinwheel (ice) +Floor 5 Lv 1 Pinwheel (wildflower) | Butterfly (ice) +Floor 6 Lv 2 Pinwheel (wildflower) | Toadstool (radiant) +Floor 7 Lv 2 Pinwheel (ice) | Butterfly (nocturnal) +Floor 8 Lv 2 Pinwheel (nocturnal) | Toadstool (ice) +Floor 9 Lv 2 Pinwheel (garden) | Pinwheel (wildflower) +Floor 10 Lv 2 Pinwheel (wildflower) | Toadstool (radiant) | Butterfly (garden) +Floor 11 Lv 3 Pinwheel (garden) | Butterfly (wildflower) | Pinwheel (garden) +Floor 12 Lv 3 Pinwheel (garden) | Pinwheel (ice) | Butterfly (wildflower) +Floor 13 Lv 3 Pinwheel (ice) | Butterfly (ice) | Toadstool (nocturnal) +Floor 14 Lv 3 Pinwheel (radiant) | Toadstool (wildflower) | Butterfly (wildflower) +Floor 15 Lv 3 Pinwheel (nocturnal) | Butterfly (radiant) | Toadstool (wildflower) +Floor 16 Lv 4 Pinwheel (ice) | Toadstool (garden) | Pinwheel (garden) | Toadstool (ice) +Floor 17 Lv 4 Pinwheel (wildflower) | Pinwheel (garden) | Toadstool (ice) | Butterfly (ice) +Floor 18 Lv 4 Pinwheel (nocturnal) | Toadstool (radiant) | Butterfly (garden) | Pinwheel (ice) +Floor 19 Lv 4 Pinwheel (radiant) | Butterfly (wildflower) | Pinwheel (radiant) | Butterfly (wildflower) +Floor 20 Lv 4 Pinwheel (nocturnal) | Pinwheel (ice) | Butterfly (radiant) | Toadstool (ice) +Floor 21 Lv 5 Pinwheel (nocturnal) | Butterfly (garden) | Toadstool (garden) | Butterfly (ice) +Floor 22 Lv 5 Pinwheel (nocturnal) | Toadstool (nocturnal) | Butterfly (radiant) | Toadstool (wildflower) +Floor 23 Lv 5 Pinwheel (ice) | Butterfly (radiant) | Toadstool (radiant) | Pinwheel (wildflower) +Floor 24 Lv 5 Pinwheel (garden) | Toadstool (wildflower) | Pinwheel (nocturnal) | Toadstool (nocturnal) +Floor 25 Lv 5 Pinwheel (garden) | Pinwheel (radiant) | Toadstool (nocturnal) | Butterfly (ice) +Floor 26 Lv 6 Pinwheel (nocturnal) | Toadstool (nocturnal) | Butterfly (garden) | Pinwheel (wildflower) +Floor 27 Lv 6 Pinwheel (garden) | Butterfly (ice) | Pinwheel (ice) | Butterfly (nocturnal) +Floor 28 Lv 6 Pinwheel (radiant) | Pinwheel (nocturnal) | Butterfly (wildflower) | Toadstool (wildflower) +Floor 29 Lv 6 Pinwheel (wildflower) | Butterfly (radiant) | Toadstool (ice) | Butterfly (wildflower) +Floor 30 Lv 6 Pinwheel (wildflower) | Toadstool (ice) | Butterfly (wildflower) | Toadstool (radiant) +Floor 31 Lv 7 Pinwheel (radiant) | Butterfly (garden) | Toadstool (radiant) | Pinwheel (garden) +Floor 32 Lv 7 Pinwheel (radiant) | Toadstool (nocturnal) | Pinwheel (nocturnal) | Toadstool (ice) +Floor 33 Lv 7 Pinwheel (wildflower) | Pinwheel (nocturnal) | Toadstool (nocturnal) | Butterfly (ice) +Floor 34 Lv 7 Pinwheel (wildflower) | Toadstool (ice) | Butterfly (wildflower) | Pinwheel (radiant) +Floor 35 Lv 7 Pinwheel (wildflower) | Butterfly (ice) | Pinwheel (nocturnal) | Butterfly (ice) +Floor 36 Lv 8 Pinwheel (ice) | Pinwheel (radiant) | Butterfly (radiant) | Toadstool (ice) +Floor 37 Lv 8 Pinwheel (wildflower) | Butterfly (radiant) | Toadstool (wildflower) | Butterfly (wildflower) +Floor 38 Lv 8 Pinwheel (garden) | Toadstool (garden) | Butterfly (radiant) | Toadstool (radiant) +Floor 39 Lv 8 Pinwheel (nocturnal) | Butterfly (ice) | Toadstool (garden) | Pinwheel (radiant) +Floor 40 Lv 8 Pinwheel (garden) | Toadstool (wildflower) | Pinwheel (wildflower) | Venus Flytrap (nocturnal) +Floor 41 Lv 9 Pinwheel (garden) | Pinwheel (ice) | Toadstool (nocturnal) | Venus Flytrap (nocturnal) +Floor 42 Lv 9 Pinwheel (radiant) | Toadstool (radiant) | Butterfly (ice) | Marigold (radiant) +Floor 43 Lv 9 Pinwheel (radiant) | Butterfly (ice) | Pinwheel (nocturnal) | Marigold (radiant) +Floor 44 Lv 9 Pinwheel (garden) | Pinwheel (wildflower) | Butterfly (wildflower) | Daffodil (radiant) +Floor 45 Lv 9 Pinwheel (ice) | Butterfly (garden) | Marigold (radiant) | Daffodil (radiant) +Floor 46 Lv 10 Pinwheel (radiant) | Toadstool (garden) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 47 Lv 10 Pinwheel (ice) | Butterfly (radiant) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 48 Lv 10 Pinwheel (wildflower) | Toadstool (ice) | Marigold (radiant) | Marigold (radiant) +Floor 49 Lv 10 Pinwheel (radiant) | Pinwheel (ice) | Marigold (radiant) | Marigold (radiant) +Floor 50 Lv 10 Pinwheel (garden) | Toadstool (ice) | Marigold (radiant) | Daffodil (radiant) +Floor 51 Lv 11 Pinwheel (nocturnal) | Butterfly (wildflower) | Marigold (radiant) | Daffodil (radiant) +Floor 52 Lv 11 Pinwheel (ice) | Pinwheel (radiant) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 53 Lv 11 Pinwheel (wildflower) | Butterfly (nocturnal) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 54 Lv 11 Pinwheel (nocturnal) | Toadstool (garden) | Marigold (radiant) | Marigold (radiant) +Floor 55 Lv 11 Pinwheel (radiant) | Butterfly (nocturnal) | Marigold (radiant) | Marigold (radiant) +Floor 56 Lv 12 Pinwheel (wildflower) | Toadstool (radiant) | Marigold (radiant) | Daffodil (radiant) +Floor 57 Lv 12 Pinwheel (radiant) | Pinwheel (ice) | Marigold (radiant) | Daffodil (radiant) +Floor 58 Lv 12 Pinwheel (wildflower) | Toadstool (nocturnal) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 59 Lv 12 Pinwheel (nocturnal) | Butterfly (radiant) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 60 Lv 12 Pinwheel (ice) | Pinwheel (ice) | Marigold (radiant) | Marigold (radiant) +Floor 61 Lv 13 Pinwheel (garden) | Butterfly (nocturnal) | Marigold (radiant) | Marigold (radiant) +Floor 62 Lv 13 Pinwheel (wildflower) | Toadstool (nocturnal) | Marigold (radiant) | Daffodil (radiant) +Floor 63 Lv 13 Pinwheel (garden) | Butterfly (garden) | Marigold (radiant) | Daffodil (radiant) +Floor 64 Lv 13 Pinwheel (garden) | Toadstool (radiant) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 65 Lv 13 Pinwheel (garden) | Pinwheel (ice) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 66 Lv 14 Pinwheel (radiant) | Toadstool (ice) | Marigold (radiant) | Marigold (radiant) +Floor 67 Lv 14 Pinwheel (radiant) | Butterfly (radiant) | Marigold (radiant) | Marigold (radiant) +Floor 68 Lv 14 Pinwheel (garden) | Pinwheel (wildflower) | Marigold (radiant) | Daffodil (radiant) +Floor 69 Lv 14 Pinwheel (radiant) | Butterfly (radiant) | Marigold (radiant) | Daffodil (radiant) +Floor 70 Lv 14 Pinwheel (radiant) | Toadstool (ice) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 71 Lv 15 Pinwheel (ice) | Butterfly (wildflower) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 72 Lv 15 Pinwheel (radiant) | Toadstool (wildflower) | Marigold (radiant) | Marigold (radiant) +Floor 73 Lv 15 Pinwheel (ice) | Pinwheel (radiant) | Marigold (radiant) | Marigold (radiant) +Floor 74 Lv 15 Pinwheel (wildflower) | Toadstool (wildflower) | Marigold (radiant) | Daffodil (radiant) +Floor 75 Lv 15 Pinwheel (garden) | Butterfly (wildflower) | Marigold (radiant) | Daffodil (radiant) +Floor 76 Lv 16 Pinwheel (ice) | Pinwheel (garden) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 77 Lv 16 Pinwheel (ice) | Butterfly (ice) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 78 Lv 16 Pinwheel (nocturnal) | Toadstool (radiant) | Marigold (radiant) | Marigold (radiant) +Floor 79 Lv 16 Pinwheel (nocturnal) | Butterfly (garden) | Marigold (radiant) | Marigold (radiant) +Floor 80 Lv 16 Pinwheel (ice) | Toadstool (garden) | Marigold (radiant) | Daffodil (radiant) +Floor 81 Lv 17 Pinwheel (wildflower) | Pinwheel (garden) | Marigold (radiant) | Daffodil (radiant) +Floor 82 Lv 17 Pinwheel (nocturnal) | Toadstool (garden) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 83 Lv 17 Pinwheel (garden) | Butterfly (garden) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 84 Lv 17 Pinwheel (ice) | Pinwheel (radiant) | Marigold (radiant) | Marigold (radiant) +Floor 85 Lv 17 Pinwheel (nocturnal) | Butterfly (wildflower) | Marigold (radiant) | Marigold (radiant) +Floor 86 Lv 18 Pinwheel (radiant) | Toadstool (wildflower) | Marigold (radiant) | Daffodil (radiant) +Floor 87 Lv 18 Pinwheel (nocturnal) | Butterfly (nocturnal) | Marigold (radiant) | Daffodil (radiant) +Floor 88 Lv 18 Pinwheel (garden) | Toadstool (nocturnal) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 89 Lv 18 Pinwheel (radiant) | Pinwheel (wildflower) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 90 Lv 18 Pinwheel (nocturnal) | Toadstool (radiant) | Marigold (radiant) | Marigold (radiant) +Floor 91 Lv 19 Pinwheel (radiant) | Butterfly (wildflower) | Marigold (radiant) | Marigold (radiant) +Floor 92 Lv 19 Pinwheel (garden) | Pinwheel (wildflower) | Marigold (radiant) | Daffodil (radiant) +Floor 93 Lv 19 Pinwheel (garden) | Butterfly (wildflower) | Marigold (radiant) | Daffodil (radiant) +Floor 94 Lv 19 Pinwheel (wildflower) | Toadstool (nocturnal) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 95 Lv 19 Pinwheel (wildflower) | Butterfly (wildflower) | Marigold (radiant) | Venus Flytrap (nocturnal) +Floor 96 Lv 20 Pinwheel (ice) | Toadstool (ice) | Marigold (radiant) | Marigold (radiant) +Floor 97 Lv 20 Pinwheel (nocturnal) | Pinwheel (garden) | Marigold (radiant) | Marigold (radiant) +Floor 98 Lv 20 Pinwheel (ice) | Toadstool (wildflower) | Marigold (radiant) | Daffodil (radiant) +Floor 99 Lv 20 Pinwheel (garden) | Butterfly (garden) | Marigold (radiant) | Daffodil (radiant) +Floor 100 Lv 20 Pinwheel (nocturnal) | Pinwheel (garden) | Marigold (radiant) | Venus Flytrap (nocturnal) diff --git a/ideas/plants.txt b/ideas/plants.txt new file mode 100644 index 0000000..6b11d3b --- /dev/null +++ b/ideas/plants.txt @@ -0,0 +1,375 @@ +Name: Zinnia / Attacker +Type: Radiant +Passive — Solar Decay +The enemies Zinnia attacks deal 1% less damage with each hit from Zinnia. Up to 30% total damage reduction. + +Blinding Slash [ 20 ]: Attack an enemy. Hits 2 times. +Glitter [ 10 ]: Attack an enemy. Hits 5 times. Extra hit for every effect on the target. Cooldown: 3 +Glimmer [ 10 ]: Attack an enemy. Hits 5 times. Extra hit for every effect on Zinnia. Cooldown: 3 + +Base Stats +Attack — 63 / Speed — 68 / Defense — 51 / Health — 48 + +--- + +Name: Sunflower / Support +Type: Radiant +Passive — Incandescent [40] +Heal the ally with the lowest health each turn. + +Beam [ 50 ]: Attack an enemy. +Face The Sun [ 35 ]: Heal all allies over 3 turns. Cooldown: 3 +Daybreak: Reset an ally’s cooldowns. Cooldown: 3 + +Base Stats +Attack — 57 / Speed — 68 / Defense — 52 / Health — 46 + +--- + +Name: Daisy / Support +Type: Radiant +Passive — Gentle Encouragement +Healing an ally removes a debuff and increases their speed for 1 turn. + +Flutter [ 40 ]: Attack an enemy. Heals a random ally. +Sunkissed Glow [ 50 ]: Heal all allies. Cooldown: 2 +Motivate: Heal an ally back to 100% full health immediately. Cooldown: 4 + +Base Stats +Attack — 57 / Speed — 72 / Defense — 48 / Health — 42 + +--- + +Name: Marigold / Tank +Type: Radiant +Passive — Golden Gleam +Reduces damage from the first hit received every turn by 30%. + +Rustle [ 25 ]: Attack an enemy. +Puff Up: Heal for 30% of max health. Remove all debuffs from self. Cooldown: 3 +Absorb: Until next turn, damage taken is reduced by another 20%. Cooldown: 3 + +Base Stats +Attack — 43 / Speed — 50 / Defense — 68 / Health — 73 + +--- + +Name: Daffodil / Tank +Type: Radiant +Passive — Toxic Sap +When attacked, inflict Wilt at 50% effectiveness. If the attacker already has Wilt, heal for 50% of remaining Wilt damage instead. + +Consume [ 45 ]: Attack an enemy. If they have Wilt, heal for 100% of damage dealt. +Poison Ravish [ 50/75 ]: Attack all enemies. Damage increased if Wilt is on the enemy. Cooldown: 3 +Deteriorate [ 50 ]: Attack all enemies. Apply a Wilt for 50% of damage dealt. Cooldown: 3 + +Base Stats +Attack — 55 / Speed — 47 / Defense — 64 / Health — 70 + +--- + +Name: Rose / Attacker +Type: Garden +Passive — Briars +Reflect 40% of all damage taken back to the attacker in the form of Wilt. + +Thorned Strike [ 60 ]: Attack an enemy. Apply Wilt for 20% of damage dealt. +Petal Kiss [ 90 ]: Attack an enemy. Ignores 30% of defense. Cooldown: 3 +Brambles [ 75 ]: Attack all enemies. Apply Wilt for 15% of damage dealt. Reduce their healing by 40% for 2 turns. Cooldown: 3 + +Base Stats +Attack — 74 / Speed — 66 / Defense — 52 / Health — 49 + +---- + +Name: Peony /Attacker +Type: Garden +Passive — Robust +Attacks ignore 20% of defense. + +Petal Punch [ 55 ]: Attack an enemy. +Snap [ 75 ]: Attack an enemy. Ignore additional 15% of defense. Reduce enemy defense by 20% for 3 turns. Cooldown: 3 +Denting Blows [ 65 ]: Attack two enemies. Apply Denting Blows, making them permanently take 3% more damage. This effect stacks. Cooldown: 3 + +Base Stats +Attack — 77 / Speed — 58 / Defense — 61 / Health — 61 + +--- + +Name: Magnolia / Attacker +Type: Garden +Passive — Nobility +Immune to stuns. Periodic debuffs last 1 turn less. + +Royal Smack [ 50 ]: Attack an enemy. Reduce their defense by 20% for 1 turn. +Crown [ 55 ]: Attack an enemy. Ignore buffs. Cooldown: 3 +Regal Order [ 90 ]: Attack an enemy. Stun them for 1 turn. Shield self for 15% of damage dealt. Cooldown: 3 + +Base Stats +Attack — 73 / Speed — 64 / Defense — 55 / Health — 58 + +--- + +Name: Hydrangea / Attacker +Type: Garden +Passive — pH Shift +Start off the battle in Blue (Acidic), then switch from Pink (Alkaline) and Blue every turn. + +Cloud [ 45 ]: Attack an enemy. If Blue, apply Wilt for 30% of damage dealt. If Pink, heal all allies for 30% of damage dealt. +Alkaline Blossom: Extend the duration of all buffs on allies and all debuffs on enemies by 1 turn. Extra action. Only usable when Alkaline. Cooldown: 3 +Acidic Blossom [ 80 ]: Attack all enemies. Apply a Wilt for 40% of damage dealt. Extra action. Only usable when Acidic. Cooldown: 3 + +Base Stats +Attack — 68 / Speed — 70 / Defense — 52 / Health — 54 + +--- + +Name: Sweet Pea / Attacker +Type: Garden +Passive — Sweet Scent +Tethered allies take 10% less damage and receive 30% more healing and shielding. + +Vine Whip [ 40 ]: Attack an enemy. Tether the ally with the lowest health for 1 turn. +Sugar Bloom [ 70 ]: Attack all enemies. Cooldown: 3 +Fragrant Bond: Tether all allies for 1 turn. When Tethered, an ally is healed by 50% of damage dealt by Sweet Pea. Cooldown: 3 + +Base Stats +Attack — 71 / Speed — 71 / Defense — 36 / Health — 45 + +--- + +Name: Crysanthemum / Tank +Type: Garden +Passive — Longevity +When hit, gain 3% defense. + +Imperial Smite [ 45 ]: Attack an enemy. Deal bonus damage based on defense. +Autumn Shield [ 50 ]: Shield all allies and increase all allies’ defense for 1 turn. Cooldown: 2 +Golden Dynasty: Increase all allies’ Health by 15% and defense by 10% permanently for the rest of the battle. Cooldown: 4 + +Base Stats +Attack — 38 / Speed — 41 / Defense — 70 / Health — 76 + +--- + +Name: Lily / Attacker +Type: Wildflower +Passive — Passionate +Gains attack up and speed up when enemies are below 50% health. Overkill from Fervent Blossom and Motivate is spread equally amongst remaining enemies. + +Flora Blast [ 30 ]: Attack all enemies. +Fervent Blossom [ 50/80 ]: Attack all enemies. Deal increased damage to enemies below 40% health. Cooldown: 2 +Whirlwind [ 95/120 ]: Attack all enemies. Deal increased damage to enemies above 80% health. Cooldown: 4 + +Base Stats +Attack — 79 / Speed — 65 / Defense — 41 / Health — 36 + +--- + +Name: Hibiscus / Attacker +Type: Wildflower +Passive — Intensifying +Deals increased damage to enemies if they have more max health than you. + +Tropical Burst [ 50 ]: Attack an enemy. +Crimson Strike [ 70/90 ]: Attack an enemy. Deal increased damage if they are above 80% or below 20% health. Cooldown: 3 +Crimson Brew [ 65 ]: Increase your attack for 2 turns. Attack all enemies. Cooldown: 3 + +Base Stats +Attack — 72 / Speed — 67 / Defense — 49 / Health — 55 + +--- + +Name: Poppy / Attacker +Type: Wildflower +Passive — Hypnotic +10% chance to apply Intoxicate and Oblivion to an enemy every attack. Intoxicate: enemy attacks are split in half, the first half they attack their target but the remaining half they attack a teammate. Oblivion: target uses weakest base attack, even if a stronger ability is available. + +Scarlet Lash [ 55 ]: Attack an enemy. +Satin Smite [ 70 ]: Attack an enemy. Apply Intoxicate for 2 turns. Cooldown: 3 +Narcosis [ 70 ]: Attack 2 enemies. Reduce their crit chance for 2 turns and apply Oblivion for 2 turns. Cooldown: 3 + +Base Stats +Attack — 76 / Speed — 63 / Defense — 59 / Health — 55 + +--- + +Name: Tulip / Attacker +Type: Wildflower +Passive — Verdant Equinox +Allies deal 10% more damage when having a shield. + +Bulb Blast [ 40 ]: Attack two random enemies. +Spring Rain [ 55 ]: Attack all enemies. Reduce their attack for 2 turns. Cooldown: 3 +Vibrant Bells [ 80 ]: Shield all allies. Until their shield runs out, when that ally is attacked, activate Bulb Blast. Cooldown: 3 + +Base Stats +Attack — 69 / Speed — 65 / Defense — 53 / Health — 57 + +--- + +Name: Lotus / Support +Type: Wildflower +Passive — Rebirth +The first time an ally dies in battle, revive them and set them at 50% health. + +Revitalize [ 40 ]: Heal an ally. +Mud Resilience: Increase all allies’ attack for 2 turns. Cooldown: 2 +Purify: Remove all debuffs from all allies. Extra action. Cooldown: 4 + +Base Stats +Attack — 59 / Speed — 75 / Defense — 44 / Health — 53 + +--- + +Name: Dandelion / Support +Type: Wildflower +Passive — Cover +The first time an ally drops below 40% health, shield them for 50% of max health. + +Drifting Seed [ 40 ]: Attack an enemy. Reduce their attack for 1 turn. +Bloom [ 90 ]: Shield an ally. Cooldown: 2 +Make A Wish [ 15 ]: Shield all allies and heal them over 3 of your turns. Cooldown: 4 + +Base Stats +Attack — 62 / Speed — 74 / Defense — 45 / Health — 37 + +--- + +Name: Nightshade / Attacker +Type: Nocturnal +Passive — Corruption +When an enemy dies with Dark Corrosion, it spreads to the next most vulnerable enemy. Dark Corrosion also applies a random stat debuff to the target for 2 turns. + +Astra [ 60 ]: Attack an enemy. +Falling Star [ 25 ]: Attack an enemy. Hits 4 times. Cooldown: 3 +Dark Corrosion [ 70 ]: Apply a debuff to all enemies that deals damage over 3 turns. This effect can stack. Cooldown: 3 + +Base Stats +Attack — 76 / Speed — 69 / Defense — 45 / Health — 53 + +--- + +Name: Moonflower / Attacker +Type: Nocturnal +Passive — Everlasting Night +At the start of your turn, deal damage to all enemies. Damage increases every turn. The chance of Glimmering Moon hitting twice also increases by 10% every turn. + +Lunar Beam [ 45 ]: Attack an enemy. Damage increased with every hit. +Night Fog: Reduce all enemies’ healing and apply Vulnerable 1. Cooldown: 3 +Glimmering Moon [ 65 ]: Attack all enemies. 20% chance to hit twice. Cooldown: 3 + +Base Stats +Attack — 77 / Speed — 66 / Defense — 54 / Health — 43 + +--- + +Name: Orchid / Attacker +Type: Nocturnal +Passive — Epiphytic +Gain increased speed and attack when enemies are below 40% health. + +Branch [ 60 ]: Attack an enemy. +Sow [ 115 ]: Plant a seed in an enemy that will erupt for the ability damage 2 turns later. Cooldown: 3 +Sweeping Elegance [ 70 ]: Attack all enemies. Removes a buff from each enemy. Cooldown: 3 + +Base Stats +Attack — 74 / Speed — 68 / Defense — 47 / Health — 64 + +--- + +Name: Lavender / Attacker +Type: Nocturnal +Passive — Soporific +Gain increased speed and attack when enemies are in Sleep. Sleep: the target cannot take a turn until they have taken damage twice. Lavender's attacks have a 10% chance to put the target to sleep. + +Tranquilize [ 30 ]: Attack an enemy. Reduce their speed for 1 turn and apply Vulnerable 1. +Soothing Lullaby [ 50 ]: Attack all enemies. Reduce all enemies’ crit chance by 10% for 2 turns. Cooldown: 2 +Sedate [ 60 ]: Attack an enemy. Put them to Sleep. Cooldown: 4 + +Base Stats +Attack — 72 / Speed — 77 / Defense — 36 / Health — 43 + +--- + +Name: Venus Flytrap / Tank +Type: Nocturnal +Passive — Feed +Heal for 20% of damage dealt. + +Devour [ 45 ]: Attack an enemy. +Lying In Wait: Until the next turn, enemy attacks increase the damage of Dissolve by 10. Cooldown: 3 +Dissolve [ 25 ]: Attack an enemy. Hits twice. Cooldown: 3 + +Base Stats +Attack — 55 / Speed — 44 / Defense — 73 / Health — 68 + +--- + +Name: Snowdrop / Attacker +Type: Ice +Passive — Winter Breeze +At the start of battle, reduce all enemies' speed for 3 turns. The first time an ally is killed, apply Chill to the attacker. + +Snow Pierce [ 50 ]: Attack an enemy. Reduce their defense by 20% for 1 turn. +Chill [ 45 ]: Apply a debuff to an enemy that deals damage over 2 turns. Cooldown: 2 +Whiteout [ 10 ]: Attack all enemies and reduce their turn progress by 20%. Cooldown: 4 + +Base Stats +Attack — 73 / Speed — 78 / Defense — 52 / Health — 38 + +--- + +Name: Primrose / Attacker +Type: Ice +Passive — Coldhearted +The first time a skill is used, it can bypass all tanks. After that, only Frostbite will always bypass tanks. + +Swipe [ 40 ]: Attack an enemy. +Frostbite [ 75 ]: Attack an enemy. Reduce their speed and halve their healing received for 2 turns. Cooldown: 3 +Icy Glare [ 60 ]: Attack an enemy. Apply a Wilt for 40% of damage dealt. Cooldown: 3 + +Base Stats +Attack — 77 / Speed — 73 / Defense — 36 / Health — 42 + +--- + +Name: Winter Jasmine / Attacker +Type: Ice +Passive — Janus Bloom +The first damage each ally takes is reduced by 80%. + +Solstice Spark [ 45 ]: Attack an enemy. +Thaw [ 60 ]: Remove a debuff from an ally and apply a shield. For the duration of 1 turn: When that ally takes damage, activate Solstice Spark. Cooldown: 3 +Catching Snowflakes: Add additional 10 damage to all allies’ hits. This effect can stack. Extra action. Cooldown: 3 + +Base Stats +Attack — 67 / Speed — 73 / Defense — 38 / Health — 55 + +--- + +Name: Hellebore / Support +Type: Ice +Passive — Toxin +Allies with Madness deal 50% increased damage and take 30% increased damage. Enemies with Madness deal 30% increased damage and take 50% increased damage. + +Pacify [ 45 ]: Heal an ally. If that ally is below 40% health before the heal, remove Madness. +Gradual Annihilation [ 40 ]: Attack an enemy. Apply Wilt for 200% of damage dealt. Cooldown: 3 +Madness: Apply Madness to either an ally or an enemy. This effect can stack. Cooldown: 3 + +Base Stats +Attack — 76 / Speed — 71 / Defense — 33 / Health — 48 + +--- + +Name: Camellia / Tank +Type: Ice +Passive — Frigid +When hit, gain an Icicle. + +Hail [ 55 ]: Attack an enemy. Gain 1 Icicle. +Igloo [ 70 ]: Shield self. Until the shield is broken, gain 2 Icicles per hit instead of 1. Cooldown: 2 +Blizzard [ 25 ]: Attack all enemies. Shatter all Icicles on self. Initial damage multiplied with every Icicle shattered. Cooldown: 4 + +Base Stats +Attack — 60 / Speed — 49 / Defense — 68 / Health — 71 diff --git a/main.py b/main.py new file mode 100644 index 0000000..743ab15 --- /dev/null +++ b/main.py @@ -0,0 +1,304 @@ +from collections import Counter + +from assets import icon +from engine import 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 +from engine.unit import TEAM_ENEMY, TEAM_PLAYER + + +def _pack_reward_items(pack_types): + """Reward entries (label + pack sprite path) from a list of pack type names, + e.g. ['wildflower', 'wildflower'] -> [{'label': '2 Wildflower packs', ...}].""" + counts = Counter(pack_types) + items = [] + for pt in ("general", "lesser", "tower", "radiant", "nocturnal", "ice", "garden", "wildflower"): + n = counts.get(pt, 0) + if n: + name = "Wildflower" if pt == "wildflower" else pt.capitalize() + items.append({ + "label": f"{n} {name} pack{'s' if n > 1 else ''}", + "kind": "pack", + "img": icon("seed_packs/closed", f"{pt}_seedpack"), + }) + return items + + +def _rune_reward_item(rune): + """Reward entry for a rune drop (label + rune sprite path).""" + return { + "label": rune["name"], + "kind": "rune", + "img": icon("runes", runes.RUNE_ICON_FILES.get(rune["color"], "rune_brown")), + } + + +def _reward_labels(items): + """Flatten reward entries (dicts or plain strings) into label text.""" + return [it.get("label", "") if isinstance(it, dict) else it for it in items] + + +def _totals_txt(total_water, total_packs, run_relics): + """Tower run totals line, including pending rune rewards from rune relics.""" + parts = [f"{total_water} water", f"{total_packs} tower pack(s)"] + n = rune_reward_count(run_relics) + if n: + parts.append(f"{n} rune{'s' if n != 1 else ''}") + return ", ".join(parts) + + +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.""" + collection.advance_floor() + wins = collection.record_win() + if wins % 5 == 0: + reward = _pack_reward_items(collection.grant_fifth_win_bonus()) + else: + reward = _pack_reward_items(collection.add_pack("lesser")) + rune = collection.roll_battle_rune() + if rune: + reward.append(_rune_reward_item(rune)) + return reward + + +def run_tower(): + import ui.scaling as scaling + from ui.picker import TeamSelect + roster_pick = TeamSelect(tower_mode=True, pick_goal=8, min_goal=1, show_slots=False) + roster_res = roster_pick.run() + if scaling.QUIT_REQUESTED: + return "quit" + if roster_res is None or roster_res == "back" or not roster_res[0]: + return "back" # back to the battle/tower selection screen + roster = list(roster_res[0]) + collection.mark_owned(roster) + from engine.roster import ( + build_tower_enemy_team, roll_tower_enemy_team, + tower_enemy_level, tower_reward, + ) + level = player_team_level(roster) + # Gate the tower to the highest stage the account qualifies for; clearing + # that stage finishes the run (the tower can't be pushed past the gate). + from engine.collection import tower_max_stage + max_stage = tower_max_stage() + + def build_team(enemies=()): + el = tower_enemy_level(level, stage) + ts = TeamSelect(tower_mode=True, pick_goal=4, restrict_to=roster, + initial_enemies=list(enemies), + enemy_levels=[el] * len(enemies)) + res = ts.run() + if scaling.QUIT_REQUESTED: + return "quit" + if res is None or res == "back": + return None + return list(res[0]) + + active = build_team() + if active == "quit": + return "quit" + if active is None: + return "back" # back to the battle/tower selection screen + run_relics = [] + total_water = 0 + total_packs = 0 + stage = 1 + rolled_stage = -1 + enemy_recipe = None + while stage <= max_stage: + water, packs = tower_reward(stage) + reward_display = [] + if water: + reward_display.append({"label": f"{water} water", "kind": "text"}) + if packs: + reward_display.append({"label": f"{packs} tower pack{'s' if packs > 1 else ''}", + "kind": "pack", + "img": icon("seed_packs/closed", "tower_seedpack")}) + if stage != rolled_stage: + # Roll the enemy team once per stage; retrying a lost stage (or + # swapping teams) rebuilds the exact same enemies instead of rerolling. + enemy_recipe = roll_tower_enemy_team(stage, tower_enemy_level(level, stage)) + rolled_stage = stage + enemy_names, injections, enemy_runes = enemy_recipe + enemy_units = build_tower_enemy_team(enemy_names, injections, enemy_runes, + tower_enemy_level(level, stage), stage) + players = pick_team(active, TEAM_PLAYER) # fresh units: cooldowns/status reset each battle + battle = Battle(players, enemy_units, relics=run_relics) + from ui.main import Game + Game(battle, on_win=lambda d=reward_display: d, on_lose=lambda: [], tower=True, + relics=[RELIC_BY_KEY[k] for k in run_relics if k in RELIC_BY_KEY], + stage=stage).run() + if scaling.QUIT_REQUESTED: + return "quit" + totals_txt = _totals_txt(total_water, total_packs, run_relics) + from ui.tower import ConfirmScreen, TowerScreen + if battle.winner != TEAM_PLAYER: + while True: + choice = TowerScreen(stage, max_stage, "", totals_txt, mode="lost", + enemies=enemy_names).run() + if scaling.QUIT_REQUESTED: + return "quit" + if choice == "retry": + break # re-fight this stage + if choice == "change": + active = build_team(enemies=enemy_names) + if active == "quit": + return "quit" + if active is None: + # back in the team picker: ask before leaving the tower + if ConfirmScreen( + "Leave the tower?", + "You'll keep the rewards you've earned so far.").run(): + TowerScreen(stage, max_stage, "", totals_txt, + mode="summary").run() + if scaling.QUIT_REQUESTED: + return "quit" + grant_rune_rewards(run_relics) + return "back" + continue # stay: show the lost screen again + break # team changed, re-fight this stage + TowerScreen(stage, max_stage, "", totals_txt, mode="summary").run() + if scaling.QUIT_REQUESTED: + return "quit" + grant_rune_rewards(run_relics) + return "back" # gave up -> back to the selection screen + continue + if water: + collection.add_water(water) + if packs: + collection.add_tower_packs(packs) + collection.grant_xp(active, 11 * stage, count_battle=False) + total_water += water + total_packs += packs + collection.set_tower_best(stage) + totals_txt = _totals_txt(total_water, total_packs, run_relics) + if stage >= max_stage: + TowerScreen(max_stage, max_stage, "", totals_txt, mode="cleared").run() + if scaling.QUIT_REQUESTED: + return "quit" + break # cleared the highest available tower stage + from ui.relic_choice import RelicChoiceScreen + offered = roll_relics(3, exclude=run_relics, stage=stage) + if offered: + picked = RelicChoiceScreen(offered, stage, owned=run_relics).run() + if scaling.QUIT_REQUESTED: + return "quit" + if picked is not None: + run_relics.append(picked.key) + reward_txt = ", ".join(_reward_labels(reward_display)) if reward_display else "—" + while True: + choice = TowerScreen(stage, max_stage, reward_txt, totals_txt, + mode="battle").run() + if scaling.QUIT_REQUESTED: + return "quit" + if choice == "exit": + TowerScreen(stage, max_stage, "", totals_txt, mode="summary").run() + if scaling.QUIT_REQUESTED: + return "quit" + grant_rune_rewards(run_relics) + return "back" + if choice == "adjust": + active = build_team() + if active == "quit": + return "quit" + if active is None: + # back in the team picker: ask before leaving the tower + if ConfirmScreen( + "Leave the tower?", + "You'll keep the rewards you've earned so far.").run(): + TowerScreen(stage, max_stage, "", totals_txt, + mode="summary").run() + if scaling.QUIT_REQUESTED: + return "quit" + grant_rune_rewards(run_relics) + return "back" + continue # stay: show the stage screen again + continue # re-fight this stage with the new team + break # "next": advance to the next stage + stage += 1 + grant_rune_rewards(run_relics) + return "back" # tower loop ended (cleared all available stages) -> selection screen + + +def main(): + import pygame + import ui.scaling as scaling + try: + if not collection.save_exists(): + from ui.starter import StarterScreen + if not StarterScreen().run(): + return # closed the starter window without picking a pack + next_page = "menu" + while True: + from ui.menu import Menu + choice = Menu(page=next_page).run() + if scaling.QUIT_REQUESTED or choice is None: + return + next_page = "menu" + if choice == "tower": + result = run_tower() + if scaling.QUIT_REQUESTED or result == "quit": + return + if result == "back": + next_page = "battle_setup" + continue + floor = int(choice) + player_type = collection.unit_type("Pinwheel") if "Pinwheel" in collection.owned() \ + else collection.unit_type(next(iter(collection.owned() or ["Pinwheel"]))) + from engine.roster import (build_floor_enemies, floor_enemy_level, + floor_enemy_names, roll_enemy_types) + + while True: + 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) + from ui.picker import TeamSelect + ts = TeamSelect(initial_enemies=enemy_names, difficulty=floor, + enemy_types=enemy_types, no_roll=True, + enemy_levels=enemy_levels) + result = ts.run() + if scaling.QUIT_REQUESTED or result is None: + return + if result == "back": + next_page = "battle_setup" + break # back to the battle/tower selection screen + + allies, enemies = result + collection.mark_owned(allies) + collection.mark_found(enemies) + players = pick_team(allies, TEAM_PLAYER) + enemy_units = build_floor_enemies(enemy_names, enemy_types, floor) + battle = Battle(players, enemy_units) + + from ui.main import Game + outcome = Game(battle, + on_win=lambda f=floor: _battle_win_reward(f), + on_lose=lambda: [], stage=floor).run() + if scaling.QUIT_REQUESTED: + return + + won = battle.winner == TEAM_PLAYER + # Losses give nothing; wins scale XP gently with the floor. + xp = 40 + 15 * floor if won else 0 + collection.grant_xp(allies, xp, count_battle=won, + fertilizer=floor if won else 0) + + if outcome == "back": + next_page = "battle_setup" + break # back to the battle/tower selection screen + if outcome == "rematch": + # A win already advanced the floor (in _battle_win_reward), so + # the next loop iteration fights the new unbeaten floor. + # A loss retries the same floor. + continue + return + finally: + pygame.quit() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f08b912 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pygame>=2.6 \ No newline at end of file diff --git a/tests/test_battle.py b/tests/test_battle.py new file mode 100644 index 0000000..2abb199 --- /dev/null +++ b/tests/test_battle.py @@ -0,0 +1,747 @@ +import atexit +import os +import pathlib +import tempfile +import unittest + +# make()/pick_team() read the save file (e.g. injected abilities, levels), so +# point the engine at a fresh empty save to keep battle tests hermetic. +import engine.collection as collection + +_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() + +from engine.battle import Battle +from engine.plants import make, make_leveled +from engine.roster import all_names, pick_team +from engine.skills import ( + S_DOT, S_INTOXICATE, S_SHIELD, S_SLEEP, S_VULNERABLE, S_WILT, + EFF_STATUS, TARGET_ENEMY, + Effect, Skill, roll_crit, +) +from engine.types import TYPE_ADVANTAGE_MULT, TYPE_DISADVANTAGE_MULT, type_multiplier +from engine.unit import ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER, Unit + + +def make_unit(name, team, ptype, atk=50, spd=5, dfn=20, hp=100, skills=None, crit=0.0, role="", passive=""): + return Unit(name, team, ptype, max_hp=hp, attack=atk, speed=spd, defense=dfn, + skills=skills or [], crit=crit, role=role, passive=passive) + + +class TypeAdvantageTest(unittest.TestCase): + def test_advantage_cycle(self): + self.assertEqual(type_multiplier("radiant", "nocturnal"), TYPE_ADVANTAGE_MULT) + self.assertEqual(type_multiplier("nocturnal", "ice"), TYPE_ADVANTAGE_MULT) + self.assertEqual(type_multiplier("ice", "garden"), TYPE_ADVANTAGE_MULT) + self.assertEqual(type_multiplier("garden", "wildflower"), TYPE_ADVANTAGE_MULT) + self.assertEqual(type_multiplier("wildflower", "radiant"), TYPE_ADVANTAGE_MULT) + self.assertEqual(type_multiplier("nocturnal", "radiant"), TYPE_DISADVANTAGE_MULT) + self.assertEqual(type_multiplier("radiant", "radiant"), 1.0) + + def test_damage_gets_type_bonus(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[hit]) + t = make_unit("T", TEAM_ENEMY, "nocturnal", dfn=20, hp=100) + b = Battle([a], [t]) + b.start() + b.use_skill(a, hit, t) + base = int((10 + 25) * (1 - 20 / (20 + 75))) + self.assertEqual(t.hp, 100 - int(base * TYPE_ADVANTAGE_MULT)) + + def test_damage_gets_type_penalty(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + a = make_unit("A", TEAM_PLAYER, "nocturnal", atk=50, skills=[hit]) + t = make_unit("T", TEAM_ENEMY, "radiant", dfn=20, hp=100) + b = Battle([a], [t]) + b.start() + b.use_skill(a, hit, t) + base = int((10 + 25) * (1 - 20 / (20 + 75))) + self.assertEqual(t.hp, 100 - int(base * TYPE_DISADVANTAGE_MULT)) + + def test_heal_unaffected_by_type(self): + mend = Skill("Mend", power=40, kind="heal", target="ally") + a = make_unit("A", TEAM_PLAYER, "nocturnal", atk=50, skills=[mend]) + ally = make_unit("Ally", TEAM_PLAYER, "radiant", hp=100) + ally.hp = 30 + t = make_unit("T", TEAM_ENEMY, "radiant", dfn=20) + b = Battle([a, ally], [t]) + b.start() + b.use_skill(a, mend, ally) + # heals scale with half the caster's Attack, but are unaffected by type + self.assertEqual(ally.hp, 30 + 40 + int(50 * 0.5)) + + +class DamageTest(unittest.TestCase): + def test_attack_defense_formula(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[hit]) + t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=100) + b = Battle([a], [t]) + b.start() + b.use_skill(a, hit, t) + self.assertEqual(t.hp, 100 - int(35 * (1 - 20 / 95))) + + def test_higher_defense_means_less_damage(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[hit]) + low = make_unit("Low", TEAM_ENEMY, "garden", dfn=20, hp=500) + high = make_unit("High", TEAM_ENEMY, "garden", dfn=70, hp=500) + b1 = Battle([a], [low]) + b1.start() + b1.use_skill(a, hit, low) + b2 = Battle([a], [high]) + b2.start() + b2.use_skill(a, hit, high) + self.assertGreater(500 - low.hp, 500 - high.hp) + + def test_min_one_damage(self): + hit = Skill("Hit", power=5, target=TARGET_ENEMY) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=10, skills=[hit]) + t = make_unit("T", TEAM_ENEMY, "garden", dfn=100, hp=100) + b = Battle([a], [t]) + b.start() + b.use_skill(a, hit, t) + self.assertLess(t.hp, 100) + + def test_crit_doubles(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, crit=1.0, skills=[hit]) + t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=100) + b = Battle([a], [t]) + b.start() + b.use_skill(a, hit, t) + base = int(35 * (1 - 20 / 95)) + self.assertEqual(t.hp, 100 - base * 2) + + def test_vulnerable_boosts(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + mark = Skill("Mark", power=10, target=TARGET_ENEMY, + effects=[Effect(EFF_STATUS, value=1, key=S_VULNERABLE)]) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[mark, hit]) + t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500) + b = Battle([a], [t]) + b.start() + base = int(35 * (1 - 20 / 95)) + b.use_skill(a, mark, t) + self.assertEqual(t.hp, 500 - base) + b.next_turn() + b.next_turn() + b.use_skill(a, hit, t) + self.assertEqual(t.hp, 500 - base - int(base * 1.5)) + self.assertNotIn(S_VULNERABLE, t.status) + + +class ShieldWiltDotTest(unittest.TestCase): + def test_shield_absorbs_and_ignores_heal(self): + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[Skill("Hit", 60, target=TARGET_ENEMY)]) + t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=100) + b = Battle([a], [t]) + b.start() + b._shield(t, 30) + b.use_skill(a, a.skills[0], t) + base = int((60 + 25) * (1 - 20 / 95)) + self.assertEqual(t.hp, 100 - (base - 30)) + b._heal(None, t, 50) + self.assertEqual(t.hp, 100) + self.assertNotIn(S_SHIELD, t.status) + + 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) + b = Battle([a], [t]) + b.start() + b._apply_wilt_damage(t, 30) + self.assertEqual(t.status[S_WILT][0], 30) + b.next_turn() # t turn -> tick 10 + self.assertEqual(t.hp, 490) + b.next_turn() # a round 2 + b.next_turn() # t round 2 -> tick 10 + self.assertEqual(t.hp, 480) + b.next_turn() + b.next_turn() # t round 3 -> tick 10, wilt ends + self.assertEqual(t.hp, 470) + self.assertNotIn(S_WILT, t.status) + + def test_wilt_reapply_resets_duration(self): + t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=1, skills=[Skill("H", 1, target=TARGET_ENEMY)]) + b = Battle([a], [t]) + b.start() + b._apply_wilt_damage(t, 30) + b._apply_wilt_damage(t, 30) + self.assertEqual(t.status[S_WILT], (60, 3)) + + def test_dot_deals_listed_damage_per_turn(self): + a = make_unit("A", TEAM_PLAYER, "radiant", atk=1, skills=[Skill("H", 1, target=TARGET_ENEMY)]) + t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5) + b = Battle([a], [t]) + b.start() + b._add_dot(t, 45, 2) + b.next_turn() # t turn -> 45 + self.assertEqual(t.hp, 455) + b.next_turn() + b.next_turn() # t turn -> 45, dot ends + self.assertEqual(t.hp, 410) + self.assertNotIn(S_DOT, t.status) + + +class MechanicsTest(unittest.TestCase): + def test_sleep_skips_until_two_hits(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + sedate = Skill("Sedate", power=60, target=TARGET_ENEMY, + effects=[Effect(EFF_STATUS, value=2, key=S_SLEEP)]) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[sedate, hit]) + t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5) + b = Battle([a], [t]) + b.start() + b.use_skill(a, sedate, t) + b.next_turn() + self.assertIs(b.current, a) + b.use_skill(a, hit, t) + self.assertEqual(t.status[S_SLEEP], 1) + b.next_turn() + self.assertIs(b.current, a) + b.use_skill(a, hit, t) + self.assertNotIn(S_SLEEP, t.status) + b.next_turn() + self.assertIs(b.current, t) + + def test_intoxicate_splashes_to_attackers_own_ally(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, spd=5, skills=[hit]) + p2 = make_unit("P2", TEAM_PLAYER, "garden", hp=200, spd=4) + e = make_unit("E", TEAM_ENEMY, "garden", hp=500, spd=3, dfn=0) + b = Battle([a, p2], [e]) + b.start() + a.status[S_INTOXICATE] = (1, 1) + before = p2.hp + b.use_skill(a, a.skills[0], e) + self.assertLess(p2.hp, before) + + def test_lotus_revitalize_scales_with_attack(self): + lotus = make("Lotus", TEAM_PLAYER) + ally = make_unit("Ally", TEAM_PLAYER, "garden", hp=500, spd=5) + e = make_unit("E", TEAM_ENEMY, "garden", hp=500, spd=5) + b = Battle([lotus, ally], [e]) + b.start() + ally.hp = 100 + lotus.attack = 300 + heal = lotus.skills[0] + b.use_skill(lotus, heal, ally) + self.assertGreater(ally.hp, 100 + heal.power) + + def test_flat_heal_now_scales_with_attack(self): + mend = Skill("Mend", power=40, kind="heal", target="ally") + a = make_unit("A", TEAM_PLAYER, "nocturnal", atk=80, skills=[mend]) + ally = make_unit("Ally", TEAM_PLAYER, "radiant", hp=1000) + ally.hp = 100 + t = make_unit("T", TEAM_ENEMY, "radiant", dfn=20) + b = Battle([a, ally], [t]) + b.start() + b.use_skill(a, mend, ally) + self.assertEqual(ally.hp, 100 + 40 + int(80 * 0.5)) + + def test_tank_protection(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + cactus = make_unit("Cactus", TEAM_ENEMY, "garden", dfn=30, hp=100, role=ROLE_TANK) + fungus = make_unit("Fungus", TEAM_ENEMY, "garden", dfn=10, hp=100) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[hit]) + b = Battle([a], [cactus, fungus]) + b.start() + self.assertTrue(b.is_protected(fungus)) + self.assertNotIn(fungus, b.valid_skill_targets(a, hit)) + self.assertIn(cactus, b.valid_skill_targets(a, hit)) + + def test_cooldown(self): + boom = Skill("Boom", power=10, cooldown=2, target=TARGET_ENEMY) + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[boom]) + t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5) + b = Battle([a], [t]) + b.start() + b.use_skill(a, boom, t) + self.assertEqual(boom.cooldown_left, 2) + b.next_turn() + b.next_turn() + self.assertEqual(boom.cooldown_left, 1) + b.next_turn() + b.next_turn() + self.assertEqual(boom.cooldown_left, 0) + + def test_enemy_aoe_hits_players_not_own_allies(self): + aoe = Skill("Burst", power=30, target="all_enemies") + e1 = make_unit("E1", TEAM_ENEMY, "ice", atk=50, skills=[aoe]) + e2 = make_unit("E2", TEAM_ENEMY, "ice", atk=50, hp=200, spd=1) + p1 = make_unit("P1", TEAM_PLAYER, "garden", dfn=20, hp=200, spd=9) + p2 = make_unit("P2", TEAM_PLAYER, "garden", dfn=20, hp=200, spd=8) + b = Battle([p1, p2], [e1, e2]) + b.start() + while b.current is not e1: + b.next_turn() + b.use_skill(e1, aoe, None) + self.assertLess(p1.hp, 200) # players damaged + self.assertLess(p2.hp, 200) + self.assertEqual(e2.hp, 200) # own ally untouched + + def test_daffodil_toxic_sap_wilts_attacker(self): + from engine.plants import make + from engine.unit import ROLE_TANK + daff = make("Daffodil", TEAM_PLAYER) + attacker = make("Rose", TEAM_ENEMY) + hit = Skill("Hit", power=30, target=TARGET_ENEMY) + attacker.skills = [hit] + b = Battle([daff], [attacker]) + b.start() + while b.current is not attacker: + b.next_turn() + self.assertNotIn(S_WILT, daff.status) # Daffodil itself must not be wilted + b.use_skill(attacker, hit, daff) + self.assertIn(S_WILT, attacker.status) # the ATTACKER gets the Wilt + self.assertGreater(attacker.status[S_WILT][0], 0) + + def test_daffodil_toxic_sap_drains_existing_wilt(self): + from engine.plants import make + daff = make("Daffodil", TEAM_PLAYER) + attacker = make_unit("Weak", TEAM_ENEMY, "garden", atk=0, spd=9, dfn=20, + skills=[Skill("Hit", power=5, target=TARGET_ENEMY)]) + b = Battle([daff], [attacker]) + b.start() + while b.current is not attacker: + b.next_turn() + b._apply_wilt_damage(attacker, 30) + daff.hp = 50 + b.use_skill(attacker, attacker.skills[0], daff) + self.assertGreater(daff.hp, 50) # healed by draining the attacker's Wilt + + def test_rose_briars_wilts_attacker(self): + from engine.plants import make + rose = make("Rose", TEAM_PLAYER) + attacker = make("Zinnia", TEAM_ENEMY) + hit = Skill("Hit", power=30, target=TARGET_ENEMY) + attacker.skills = [hit] + b = Battle([rose], [attacker]) + b.start() + while b.current is not attacker: + b.next_turn() + self.assertNotIn(S_WILT, rose.status) + b.use_skill(attacker, hit, rose) + self.assertIn(S_WILT, attacker.status) + + def test_zinnia_solar_reduces_weakened_enemy_damage(self): + target = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5, + skills=[Skill("Hit", power=10, target=TARGET_ENEMY)]) + victim = make_unit("V", TEAM_PLAYER, "garden", dfn=20, hp=500, spd=9) + b = Battle([victim], [target]) + b.start() + while b.current is not target: + b.next_turn() + target.status["zinnia_weaken"] = 30 + before = victim.hp + b.use_skill(target, target.skills[0], victim) + base = int((10 + 25) * (1 - 20 / 95)) + self.assertEqual(before - victim.hp, int(base * 0.7)) + + def test_marigold_reduces_first_hit_each_turn(self): + from engine.plants import make + marigold = make("Marigold", TEAM_PLAYER) + attacker = make_unit("A", TEAM_ENEMY, "garden", atk=0, spd=1, dfn=20, + skills=[Skill("Hit", power=40, target=TARGET_ENEMY)]) + b = Battle([marigold], [attacker]) + b.start() + while b.current is not attacker: + b.next_turn() + base = int((40 + 0) * (1 - marigold.defense / (marigold.defense + 75))) + first = marigold.hp + b.use_skill(attacker, attacker.skills[0], marigold) + self.assertEqual(first - marigold.hp, int(base * 0.7)) # first hit reduced + first = marigold.hp + b.next_turn() # marigold turn resets flag + while b.current is not attacker: + b.next_turn() + b.use_skill(attacker, attacker.skills[0], marigold) + self.assertEqual(first - marigold.hp, int(base * 0.7)) # still reduced next turn + + def test_magnolia_reduces_periodic_debuff_duration(self): + from engine.plants import make + magnolia = make("Magnolia", TEAM_PLAYER) + b = Battle([magnolia], [make_unit("E", TEAM_ENEMY, "garden", spd=1, dfn=20)]) + b.start() + b._apply_wilt_damage(magnolia, 30) + self.assertEqual(magnolia.status[S_WILT][1], 2) # 3 -> 2 turns + b._add_dot(magnolia, 45, 2) + self.assertEqual(magnolia.status[S_DOT][0][1], 1) # 2 -> 1 turn + + def test_hydrangea_cloud_wilts_only_when_acidic(self): + from engine.plants import make + hyd = make("Hydrangea", TEAM_PLAYER) + cloud = next(s for s in hyd.skills if s.name == "Cloud") + enemy = make_unit("E", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=1) + b = Battle([hyd], [enemy]) + b.start() + while b.current is not hyd: + b.next_turn() + hyd.status["form"] = "acidic" + b.use_skill(hyd, cloud, enemy) + self.assertIn(S_WILT, enemy.status) + b.next_turn() + b.next_turn() + while b.current is not hyd: + b.next_turn() + hyd.status["form"] = "alkaline" + enemy.status.pop(S_WILT, None) + b.use_skill(hyd, cloud, enemy) + self.assertNotIn(S_WILT, enemy.status) + + def test_tulip_equinox_boosts_allies_with_shield(self): + tulip = make_unit("Tulip", TEAM_PLAYER, "garden", atk=1, spd=1, dfn=20, passive="tulip_equinox") + ally = make_unit("Ally", TEAM_PLAYER, "radiant", atk=50, spd=9, dfn=20, + skills=[Skill("Hit", power=10, target=TARGET_ENEMY)]) + enemy = make_unit("E", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5) + b = Battle([tulip, ally], [enemy]) + b.start() + while b.current is not ally: + b.next_turn() + b._shield(ally, 20) + before = enemy.hp + b.use_skill(ally, ally.skills[0], enemy) + base = int((10 + 25) * (1 - 20 / 95)) + self.assertEqual(before - enemy.hp, int(base * 1.1)) + + def test_nightshade_corruption_spreads_dot_on_death(self): + from engine.plants import make + from engine.unit import TEAM_ENEMY + e1 = make_unit("E1", TEAM_ENEMY, "nocturnal", dfn=10, hp=100, spd=1) + e2 = make_unit("E2", TEAM_ENEMY, "nocturnal", dfn=10, hp=100, spd=1) + killer = make_unit("K", TEAM_PLAYER, "radiant", atk=99, spd=9, + skills=[Skill("Hit", power=200, target=TARGET_ENEMY)]) + b = Battle([killer], [e1, e2]) + b.start() + while b.current is not killer: + b.next_turn() + b._add_dot(e1, 40, 3) + b.use_skill(killer, killer.skills[0], e1) + self.assertFalse(e1.alive) + self.assertIn(S_DOT, e2.status) # corrosion spread to the other enemy + + def test_orchid_sow_seed_deals_full_delayed_damage(self): + from engine.plants import make + orchid = make("Orchid", TEAM_ENEMY) + orchid.speed = 12 + target = make_unit("T", TEAM_PLAYER, "garden", dfn=50, hp=500, spd=10) + sow = next(s for s in orchid.skills if s.name == "Sow") + b = Battle([target], [orchid]) + b.start() + while b.current is not orchid: + b.next_turn() + b.use_skill(orchid, sow, target) + self.assertEqual(target.status["seed"][0], 115) + hp0 = target.hp + self.assertEqual(target.hp, hp0) # no immediate damage + lav_turns = 0 + erupted = False + for _ in range(40): + b.next_turn() + if b.current is target: + lav_turns += 1 + if target.hp < hp0: + erupted = True + break + self.assertTrue(erupted) + self.assertEqual(lav_turns, 3) # two turns pass, erupts on the third + self.assertEqual(hp0 - target.hp, 115) # full flat damage, ignores defense + self.assertNotIn("seed", target.status) + + def test_zinnia_picks_skill_with_more_effects(self): + from engine.plants import make + zinnia = make("Zinnia", TEAM_PLAYER) + enemy = make_unit("E", TEAM_ENEMY, "nocturnal", dfn=20, hp=500, spd=1) + b = Battle([zinnia], [enemy]) + b.start() + glitter = next(s for s in zinnia.skills if s.name == "Glitter") + glimmer = next(s for s in zinnia.skills if s.name == "Glimmer") + self.assertEqual(glitter.hits, 5) + self.assertEqual(glimmer.hits, 5) + # enemy carries more effects -> target-scaling Glitter wins + enemy.status["wilt"] = (30, 3) + enemy.status["dot"] = [(10, 2)] + zinnia.status.clear() + self.assertIs(b.best_skill(zinnia), glitter) + # Zinnia carries more effects on herself -> Glimmer wins + enemy.status.clear() + zinnia.status["attack_up"] = (10, 2) + zinnia.status["speed_up"] = (5, 2) + self.assertIs(b.best_skill(zinnia), glimmer) + # no effects anywhere -> Glimmer (lower static priority) wins the tie + zinnia.status.clear() + self.assertIs(b.best_skill(zinnia), glimmer) + # a heavily-buffed enemy still out-hits Zinnia -> Glitter again + enemy.status["wilt"] = (30, 3) + enemy.status["dot"] = [(10, 2)] + enemy.status["vulnerable"] = 1 + zinnia.status["shield"] = 10 # one self effect only + self.assertIs(b.best_skill(zinnia), glitter) + + def test_zinnia_targets_most_effect_rich_enemy(self): + from engine.plants import make + zinnia = make("Zinnia", TEAM_ENEMY) + zinnia.speed = 80 + e1 = make_unit("Plain", TEAM_PLAYER, "garden", dfn=10, hp=400, spd=1) + e2 = make_unit("Buffed", TEAM_PLAYER, "garden", dfn=10, hp=400, spd=1) + e2.status["wilt"] = (30, 3) + e2.status["dot"] = [(10, 2)] + b = Battle([e1, e2], [zinnia]) + b.start() + while b.current is not zinnia: + b.next_turn() + # enemy_ai picks Glitter (most hits) and targets the effect-rich enemy + self.assertTrue(b.enemy_ai(zinnia)) + self.assertLess(e2.hp, 400) + self.assertEqual(e1.hp, 400) + + def test_shield_prefers_tank_when_nobody_damaged(self): + from engine.plants import make + from engine.unit import ROLE_TANK + shield = Skill("Shield", power=40, kind="shield", target="ally") + tank = make_leveled("Marigold", TEAM_PLAYER, 1, 1) # role tank + squishy = make("Lavender", TEAM_PLAYER) + ally = make("Rose", TEAM_PLAYER) + ally.skills = [shield] + enemy = make_leveled("Camelia", TEAM_ENEMY, 1, 1) + b = Battle([tank, squishy, ally], [enemy]) + b.start() + # nobody damaged yet -> tank is preferred + self.assertIs(b._shield_target(ally), tank) + # tank is lowest HP -> tank still preferred + tank.hp = 1 + squishy.hp = int(squishy.max_hp * 0.8) + self.assertIs(b._shield_target(ally), tank) + # squishy lower than a healthy tank -> squishy + tank.hp = tank.max_hp + squishy.hp = int(squishy.max_hp * 0.3) + self.assertIs(b._shield_target(ally), squishy) + + def test_best_skill_priority(self): + lav = make("Lavender", TEAM_PLAYER) + by_name = {s.name: s for s in lav.skills} + from engine.battle import Battle as B + from engine.plants import make as mk + b = B([lav], [mk("Camelia", TEAM_ENEMY)]) + self.assertIs(b.best_skill(lav), by_name["Sedate"]) + by_name["Sedate"].cooldown_left = 4 + self.assertIs(b.best_skill(lav), by_name["Soothing Lullaby"]) + by_name["Soothing Lullaby"].cooldown_left = 2 + self.assertIs(b.best_skill(lav), by_name["Tranquilize"]) + + def test_pure_heal_not_preselected_when_allies_full(self): + from engine.roster import pick_team + sun = make("Sunflower", TEAM_PLAYER) + b = Battle([sun], pick_team(["Rose"], TEAM_ENEMY)) + b.start() + by = {s.name: s for s in sun.skills} + self.assertIsNot(b.best_skill(sun), by["Face The Sun"]) + by["Beam"].cooldown_left = 1 + by["Daybreak"].cooldown_left = 1 + self.assertIs(b.best_skill(sun), by["Face The Sun"]) # only option left -> fallback + sun.hp = 20 + self.assertIs(b.best_skill(sun), by["Face The Sun"]) # ally hurt -> candidate again + + def test_pool_consistency(self): + from engine.plants import HP_MULT, POOL + for name in all_names(): + spec = POOL[name] + self.assertIn(spec["type"], ("radiant", "nocturnal", "ice", "garden", "wildflower")) + self.assertIn(spec["role"], ("attacker", "support", "tank")) + self.assertTrue(spec["skills"]) + u = make(name, TEAM_PLAYER) + self.assertEqual(u.max_hp, int(spec["stats"][3] * HP_MULT)) + + +class SpeedTimelineTest(unittest.TestCase): + def test_fast_unit_acts_more_often(self): + fast = make_unit("Fast", TEAM_ENEMY, "garden", spd=110, skills=[Skill("H", 1, target=TARGET_ENEMY)]) + mid = make_unit("Mid", TEAM_ENEMY, "garden", spd=75, skills=[Skill("H", 1, target=TARGET_ENEMY)]) + slows = [make_unit(f"S{i}", TEAM_ENEMY, "garden", spd=50, skills=[Skill("H", 1, target=TARGET_ENEMY)]) for i in range(3)] + b = Battle([], [fast, mid] + slows) + b.start() + order = [] + for _ in range(6): + order.append(b.current.name) + b.next_turn() + self.assertEqual(order, ["Fast", "Mid", "Fast", "S0", "S1", "S2"]) + + def test_progress_bar_is_full_for_current(self): + fast = make_unit("Fast", TEAM_ENEMY, "garden", spd=110, skills=[Skill("H", 1, target=TARGET_ENEMY)]) + slow = make_unit("Slow", TEAM_ENEMY, "garden", spd=50, skills=[Skill("H", 1, target=TARGET_ENEMY)]) + b = Battle([], [fast, slow]) + b.start() + self.assertIs(b.current, fast) + self.assertAlmostEqual(b.progress(fast), 1.0) + self.assertLess(b.progress(slow), 1.0) + + def test_delay_pushes_turns_back(self): + a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, spd=100, + skills=[Skill("Delay", 5, target="all_enemies", mechanic="delay_all")]) + t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=50) + b = Battle([a], [t]) + b.start() + before = t.next_at + b.use_skill(a, a.skills[0], None) + self.assertGreater(t.next_at, before) + + +class BattleCompleteTest(unittest.TestCase): + def test_default_battle_completes(self): + from engine.roster import make_roster + players, enemies = make_roster() + b = Battle(players, enemies) + b.start() + guard = 0 + while b.winner is None and b.current is not None and guard < 500: + guard += 1 + u = b.current + if u.team == TEAM_ENEMY: + b.enemy_ai(u) + else: + s = b.best_skill(u) + if s: + foes = b.foes_of(u) + if s.kind == "heal": + low = [a for a in b.allies_of(u) if a.hp < a.max_hp] + if low: + b.use_skill(u, s, min(low, key=lambda a: a.hp)) + elif s.kind == "shield": + b.use_skill(u, s, u if s.target == "self" else min(b.allies_of(u), key=lambda a: a.hp)) + elif s.target in ("all_enemies", "two_enemies"): + b.use_skill(u, s, None) + elif s.target == "any" and foes: + b.use_skill(u, s, foes[0]) + elif s.target in ("ally", "all_allies"): + allies = b.allies_of(u) + if allies: + b.use_skill(u, s, min(allies, key=lambda a: a.hp)) + elif foes: + b.use_skill(u, s, min(foes, key=lambda t: t.hp)) + b.next_turn() + self.assertIn(b.winner, (TEAM_PLAYER, TEAM_ENEMY)) + + def test_random_teams_battle_completes(self): + import random + names = list(all_names()) + for _ in range(5): + random.seed(_) + allies = random.sample(names, 4) + enemies = random.sample(names, 4) + b = Battle([make_leveled(n, TEAM_PLAYER, 1, 1) for n in allies], + [make_leveled(n, TEAM_ENEMY, 1, 1) for n in enemies]) + b.start() + guard = 0 + while b.winner is None and b.current is not None and guard < 400: + guard += 1 + u = b.current + if u.team == TEAM_ENEMY: + b.enemy_ai(u) + else: + s = b.best_skill(u) + if s: + foes = b.foes_of(u) + if s.kind == "heal": + low = [a for a in b.allies_of(u) if a.hp < a.max_hp] + if low: + b.use_skill(u, s, min(low, key=lambda a: a.hp)) + elif s.kind == "shield": + b.use_skill(u, s, u if s.target == "self" else min(b.allies_of(u), key=lambda a: a.hp)) + elif s.target in ("all_enemies", "two_enemies"): + b.use_skill(u, s, None) + elif s.target == "any" and foes: + b.use_skill(u, s, foes[0]) + elif s.target in ("ally", "all_allies"): + allies = b.allies_of(u) + if allies: + b.use_skill(u, s, min(allies, key=lambda a: a.hp)) + elif foes: + b.use_skill(u, s, min(foes, key=lambda t: t.hp)) + b.next_turn() + self.assertIn(b.winner, (TEAM_PLAYER, TEAM_ENEMY)) + + +class TowerInjectionTest(unittest.TestCase): + def _skill_sources(self): + from engine.plants import POOL, RARITY + src = {} + for n in POOL: + for sk in POOL[n]["skills"]: + src.setdefault(sk.name, set()).add((POOL[n]["role"], RARITY[n])) + return src + + def test_tower_retry_rebuilds_same_team(self): + from engine.roster import build_tower_enemy_team, roll_tower_enemy_team + for stage, level in ((5, 8), (12, 10), (20, 15)): + names, injections, runes = roll_tower_enemy_team(stage, level) + team_a = build_tower_enemy_team(names, injections, runes, level, stage) + team_b = build_tower_enemy_team(names, injections, runes, level, stage) + self.assertEqual([u.name for u in team_a], names) + self.assertEqual([u.name for u in team_b], names) + for a, b in zip(team_a, team_b): + self.assertEqual(a.injected_name, b.injected_name) + self.assertEqual(a.runes, b.runes) + self.assertEqual([s.name for s in a.skills], + [s.name for s in b.skills]) + + def test_tower_enemies_gain_runes_at_higher_stages(self): + from engine.roster import make_tower_enemies + early = make_tower_enemies(["Zinnia"], 10, 2) + late = make_tower_enemies(["Zinnia"], 20, 15) + self.assertEqual(early[0].runes, []) # stage 2: no runes yet + self.assertTrue(late[0].runes) # stage 15: runes equipped + + def test_tower_inject_chance(self): + from engine.roster import tower_inject_chance + self.assertEqual(tower_inject_chance(5), 0.0) + self.assertGreater(tower_inject_chance(10), 0.0) + self.assertLessEqual(tower_inject_chance(20), 0.6) + + def test_legendary_enemies_pull_only_from_legendaries(self): + from engine.roster import roll_enemy_injection + src = self._skill_sources() + for _ in range(40): + inj = roll_enemy_injection("Zinnia") # super-super-rare attacker + if inj: + for role, rar in src[inj["skill"]["name"]]: + self.assertEqual((role, rar), ("attacker", "super_super_rare")) + + def test_epic_tank_pulls_from_any_tank(self): + from engine.roster import roll_enemy_injection + src = self._skill_sources() + for _ in range(40): + inj = roll_enemy_injection("Camelia") # epic tank + if inj: + roles = {role for role, _ in src[inj["skill"]["name"]]} + self.assertEqual(roles, {"tank"}) + + def test_tower_enemies_have_no_injection_below_floor(self): + from engine.roster import make_tower_enemies + units = make_tower_enemies(["Zinnia", "Rose"], 1, 5) + self.assertTrue(all(u.injected_index == -1 for u in units)) + + def test_enemy_injection_never_replaces_same_ability(self): + from engine.roster import roll_enemy_injection + from engine.plants import POOL + for name in ["Zinnia", "Camelia", "Sunflower", "Hellebore", "Marigold"]: + for _ in range(40): + inj = roll_enemy_injection(name) + if inj: + base = POOL[name]["skills"][inj["index"]] + self.assertNotEqual(base.name, inj["skill"]["name"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_coins.py b/tests/test_coins.py new file mode 100644 index 0000000..b0090a3 --- /dev/null +++ b/tests/test_coins.py @@ -0,0 +1,57 @@ +import os +import pathlib +import tempfile +import time +import unittest + +import engine.collection as collection + + +class CoinTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._orig = collection._DATA_FILE + fd, cls._path = tempfile.mkstemp(suffix=".json") + os.close(fd) + collection._DATA_FILE = pathlib.Path(cls._path) + + @classmethod + def tearDownClass(cls): + collection._DATA_FILE = cls._orig + if os.path.exists(cls._path): + os.unlink(cls._path) + + def setUp(self): + collection.init_starter_pinwheel("radiant") + + def _backdate(self, seconds, coins=0): + data = collection.load() + data["coins"] = coins + data["coins_accrued_at"] = time.time() - seconds + collection.save(data) + + def test_accrue_never_exceeds_cap(self): + self._backdate(600, coins=195) + self.assertEqual(collection.accrue_coins(), 5) # only up to the cap + self.assertEqual(collection.available_coins(), collection.COIN_CAP) + + def test_no_generation_while_at_cap(self): + self._backdate(120, coins=collection.COIN_CAP) + self.assertEqual(collection.accrue_coins(), 0) + self.assertEqual(collection.available_coins(), collection.COIN_CAP) + + def test_generation_resumes_after_spending(self): + self._backdate(61, coins=150) + self.assertEqual(collection.accrue_coins(), 1) + self.assertEqual(collection.available_coins(), 151) + + def test_add_coins_respects_cap(self): + data = collection.load() + data["coins"] = collection.COIN_CAP - 1 + collection.save(data) + collection.add_coins(10) + self.assertEqual(collection.available_coins(), collection.COIN_CAP) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_dominance.py b/tests/test_dominance.py new file mode 100644 index 0000000..819d88a --- /dev/null +++ b/tests/test_dominance.py @@ -0,0 +1,75 @@ +import atexit +import os +import pathlib +import tempfile +import unittest + +# Point the engine at a fresh empty save to keep these tests hermetic. +import engine.collection as collection + +_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() + + +from engine.battle import Battle +from engine.dominance import estimate_dominance, play_to_end, resolve_instantly +from engine.plants import make_leveled +from engine.roster import DEFAULT_ENEMIES, DEFAULT_PLAYERS +from engine.unit import TEAM_ENEMY, TEAM_PLAYER + + +def _team(names, team, level=1, stage=1): + return [make_leveled(n, team, level, stage) for n in names] + + +class PlayToEndTest(unittest.TestCase): + def test_returns_winner(self): + b = Battle(_team(DEFAULT_PLAYERS, TEAM_PLAYER), + _team(DEFAULT_ENEMIES, TEAM_ENEMY)) + winner = play_to_end(b) + self.assertIn(winner, (TEAM_PLAYER, TEAM_ENEMY)) + + def test_returns_winner_with_relics(self): + b = Battle(_team(DEFAULT_PLAYERS, TEAM_PLAYER), + _team(DEFAULT_ENEMIES, TEAM_ENEMY), + relics=["iron_bark", "living_spring", "chilling_gust"]) + winner = play_to_end(b) + self.assertIn(winner, (TEAM_PLAYER, TEAM_ENEMY)) + + def test_resolve_instantly_matches_a_played_outcome(self): + players = _team(DEFAULT_PLAYERS, TEAM_PLAYER) + enemies = _team(DEFAULT_ENEMIES, TEAM_ENEMY) + winner = resolve_instantly(players, enemies) + # re-running fresh units gives the same winner (deterministic roll-free path) + self.assertIn(winner, (TEAM_PLAYER, TEAM_ENEMY)) + + +class DominanceTest(unittest.TestCase): + def test_overwhelming_team_is_dominant(self): + players = _team(DEFAULT_PLAYERS, TEAM_PLAYER, level=20, stage=3) + enemies = _team(["Rose"], TEAM_ENEMY, level=1, stage=1) + self.assertTrue(estimate_dominance(players, enemies, runs=15)) + + def test_even_match_is_not_dominant(self): + players = _team(DEFAULT_PLAYERS, TEAM_PLAYER, level=1, stage=1) + enemies = _team(DEFAULT_ENEMIES, TEAM_ENEMY, level=1, stage=1) + self.assertFalse(estimate_dominance(players, enemies, runs=15)) + + def test_much_weaker_team_is_not_dominant(self): + players = _team(["Marigold"], TEAM_PLAYER, level=1, stage=1) + enemies = _team(DEFAULT_ENEMIES, TEAM_ENEMY, level=10, stage=3) + self.assertFalse(estimate_dominance(players, enemies, runs=15)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_extraction.py b/tests/test_extraction.py new file mode 100644 index 0000000..ce2df44 --- /dev/null +++ b/tests/test_extraction.py @@ -0,0 +1,230 @@ +import os +import pathlib +import tempfile +import unittest + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +import engine.collection as collection +from engine.plants import POOL, make +from engine.unit import TEAM_PLAYER + + +class ExtractionTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._orig = collection._DATA_FILE + fd, cls._path = tempfile.mkstemp(suffix=".json") + os.close(fd) + collection._DATA_FILE = pathlib.Path(cls._path) + + @classmethod + def tearDownClass(cls): + collection._DATA_FILE = cls._orig + if os.path.exists(cls._path): + os.unlink(cls._path) + + def setUp(self): + data = collection.load() + data["owned"] = ["Lavender", "Zinnia", "Marigold", "Sunflower", "Hydrangea", "Hellebore"] + for n in data["owned"]: + data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0, + "duplicates": 0, "watered_at": 0}) + data["extracted"] = [] + for n in data["owned"]: + data["progress"][n].pop("injected", None) + collection.save(data) + + def _maxed(self, name): + data = collection.load() + data["progress"][name]["level"] = 50 + data["progress"][name]["stage"] = 3 + collection.save(data) + + def test_extraction_requires_maxed(self): + self.assertNotEqual(collection.can_extract("Lavender"), "") + self._maxed("Lavender") + self.assertEqual(collection.can_extract("Lavender"), "") + + def test_extract_excludes_basic_and_blocks_inject_of_self_type(self): + self._maxed("Lavender") + idxs = [i for i, _ in collection.extractable_skills("Lavender")] + self.assertTrue(all(i > 0 for i in idxs)) + + def test_hydrangea_has_no_extractable_abilities(self): + self._maxed("Hydrangea") + self.assertEqual(collection.extractable_skills("Hydrangea"), []) + self.assertNotEqual(collection.can_extract("Hydrangea"), "") + + def test_extract_resets_plant_and_stores_ability(self): + self._maxed("Lavender") + idx, sk = collection.extractable_skills("Lavender")[0] + self.assertTrue(collection.extract_skill("Lavender", idx)) + prog = collection.load()["progress"]["Lavender"] + self.assertEqual(prog["level"], 1) + self.assertEqual(prog["stage"], 1) + ext = collection.extracted_skills() + self.assertEqual(len(ext), 1) + self.assertEqual(ext[0]["skill_name"], sk.name) + self.assertEqual(ext[0]["role"], POOL["Lavender"]["role"]) + # extraction again is blocked (no longer maxed) + self.assertNotEqual(collection.can_extract("Lavender"), "") + + def test_inject_requires_same_role_and_is_single_use(self): + self._maxed("Lavender") + idx, _ = collection.extractable_skills("Lavender")[0] + collection.extract_skill("Lavender", idx) + rec = collection.extracted_skills()[0] + self.assertNotEqual(collection.can_inject("Marigold", rec), "") # tank + self.assertEqual(collection.can_inject("Zinnia", rec), "") # attacker + self.assertTrue(collection.inject_skill(0, "Zinnia", 1)) + self.assertEqual(collection.extracted_skills(), []) # consumed + # one injected ability per plant + self.assertNotEqual(collection.can_inject("Zinnia", rec), "") + self.assertFalse(collection.inject_skill(0, "Zinnia", 2)) + + def test_unit_uses_injected_skill_and_remove_reverts(self): + self._maxed("Lavender") + idx, sk = collection.extractable_skills("Lavender")[0] + collection.extract_skill("Lavender", idx) + collection.inject_skill(0, "Zinnia", 1) + unit = make("Zinnia", TEAM_PLAYER) + self.assertEqual(unit.skills[1].name, sk.name) + self.assertEqual(unit.skills[0].name, POOL["Zinnia"]["skills"][0].name) + self.assertTrue(collection.remove_injection("Zinnia")) + unit = make("Zinnia", TEAM_PLAYER) + self.assertEqual(unit.skills[1].name, POOL["Zinnia"]["skills"][1].name) + + def test_madness_is_not_extractable(self): + self._maxed("Hellebore") + names = [sk.name for _, sk in collection.extractable_skills("Hellebore")] + self.assertNotIn("Madness", names) + idx = POOL["Hellebore"]["skills"].index(next(s for s in POOL["Hellebore"]["skills"] + if s.name == "Madness")) + self.assertFalse(collection.extract_skill("Hellebore", idx)) + + def test_inject_cannot_replace_same_ability(self): + from engine.skills import skill_to_dict + data = collection.load() + data["extracted"] = [{ + "source": "Lavender", "role": "attacker", + "skill_name": "Glitter", "skill": skill_to_dict(POOL["Zinnia"]["skills"][1]), + }] + collection.save(data) + rec = collection.extracted_skills()[0] + # Zinnia's 2nd skill IS Glitter -> cannot replace it with Glitter + self.assertNotEqual(collection.injection_slot_reason("Zinnia", rec, 1), "") + self.assertFalse(collection.inject_skill(0, "Zinnia", 1)) + # its 3rd skill (Glimmer) is fine + self.assertEqual(collection.injection_slot_reason("Zinnia", rec, 2), "") + self.assertTrue(collection.inject_skill(0, "Zinnia", 2)) + + +class FertilizerRewardTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._orig = collection._DATA_FILE + fd, cls._path = tempfile.mkstemp(suffix=".json") + os.close(fd) + collection._DATA_FILE = pathlib.Path(cls._path) + + @classmethod + def tearDownClass(cls): + collection._DATA_FILE = cls._orig + if os.path.exists(cls._path): + os.unlink(cls._path) + + def test_win_grants_fertilizer_not_water(self): + data = collection.load() + data["owned"] = ["Lavender", "Marigold", "Sunflower", "Zinnia"] + for n in data["owned"]: + data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0, + "duplicates": 0, "watered_at": 0}) + data["water"] = 0 + data["fertilizer"] = 0 + collection.save(data) + collection.grant_xp(["Lavender"], 40, count_battle=True, fertilizer=3) + self.assertEqual(collection.available_fertilizer(), 3) + self.assertEqual(collection.available_water(), 0) + + def test_win_fertilizer_scales_with_floor(self): + data = collection.load() + data["owned"] = ["Lavender", "Marigold", "Sunflower", "Zinnia"] + for n in data["owned"]: + data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0, + "duplicates": 0, "watered_at": 0}) + data["fertilizer"] = 0 + collection.save(data) + # floor 1 -> 1 fertilizer, floor 5 -> 5 + collection.grant_xp(["Lavender"], 40, count_battle=True, fertilizer=1) + collection.grant_xp(["Lavender"], 40, count_battle=True, fertilizer=5) + self.assertEqual(collection.available_fertilizer(), 6) + + def test_water_still_levels_plants(self): + data = collection.load() + data["owned"] = ["Lavender", "Marigold", "Sunflower", "Zinnia"] + for n in data["owned"]: + data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0, + "duplicates": 0, "watered_at": 0}) + data["water"] = 3 + collection.save(data) + collection.water("Lavender") + self.assertEqual(collection.get_progress("Lavender")["level"], 2) + self.assertEqual(collection.available_water(), 2) + + def test_smart_water_candidate(self): + data = collection.load() + data["owned"] = ["Lavender", "Marigold", "Sunflower", "Zinnia"] + for n in data["owned"]: + data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0, + "duplicates": 0, "watered_at": 0}) + data["progress"]["Marigold"]["duplicates"] = 5 # >= 2 needed for stage 2 + collection.save(data) + self.assertTrue(collection.smart_water_candidate("Marigold")) + data = collection.load() + data["progress"]["Marigold"]["level"] = 10 # already at the milestone + collection.save(data) + self.assertFalse(collection.smart_water_candidate("Marigold")) + + +class StarterPackTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._orig = collection._DATA_FILE + fd, cls._path = tempfile.mkstemp(suffix=".json") + os.close(fd) + collection._DATA_FILE = pathlib.Path(cls._path) + + @classmethod + def tearDownClass(cls): + collection._DATA_FILE = cls._orig + if os.path.exists(cls._path): + os.unlink(cls._path) + + def setUp(self): + if collection._DATA_FILE.exists(): + collection._DATA_FILE.unlink() + + def test_init_starter_pinwheel(self): + self.assertFalse(collection.save_exists()) + self.assertFalse(collection.init_starter_pinwheel("nope")) + self.assertTrue(collection.init_starter_pinwheel("radiant")) + self.assertTrue(collection.save_exists()) + data = collection.load() + self.assertEqual(set(data["owned"]), {"Pinwheel"}) + self.assertEqual(data["progress"]["Pinwheel"]["type"], "radiant") + self.assertEqual(data["progress"]["Pinwheel"]["level"], 1) + self.assertEqual(data["progress"]["Pinwheel"]["stage"], 1) + self.assertEqual(data["progress"]["Pinwheel"]["duplicates"], 0) + + def test_every_type_can_start(self): + from engine.types import ALL_TYPES + for pt in ALL_TYPES: + if collection._DATA_FILE.exists(): + collection._DATA_FILE.unlink() + self.assertTrue(collection.init_starter_pinwheel(pt)) + self.assertEqual(collection.load()["progress"]["Pinwheel"]["type"], pt) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_relics.py b/tests/test_relics.py new file mode 100644 index 0000000..dce12f8 --- /dev/null +++ b/tests/test_relics.py @@ -0,0 +1,365 @@ +import atexit +import os +import pathlib +import random +import tempfile +import unittest +from unittest import mock + +# make()/pick_team() read the save file, so point the engine at a fresh empty +# save to keep relic tests hermetic (same trick as test_battle.py). +import engine.collection as collection + +_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() + + +from engine.battle import Battle +from engine.plants import make, make_leveled +from engine import runes +from engine.relics import RELIC_POOL, by_key, grant_rune_rewards, roll_relics, rune_reward_count +from engine.roster import DEFAULT_ENEMIES, DEFAULT_PLAYERS +from engine.skills import S_ATTACK_UP, S_DEFENSE_UP, S_HOT, S_SHIELD, S_SNOWFLAKES, S_VULNERABLE +from engine.unit import TEAM_ENEMY, TEAM_PLAYER + + +def _battle(*relic_keys): + players = [make_leveled(n, TEAM_PLAYER, 1, 1) for n in DEFAULT_PLAYERS] + enemies = [make_leveled(n, TEAM_ENEMY, 1, 1) for n in DEFAULT_ENEMIES] + b = Battle(players, enemies, relics=relic_keys) + b.start() + return b + + +class RelicRollTest(unittest.TestCase): + def test_roll_returns_distinct_relics(self): + for _ in range(20): + rolled = roll_relics(3) + self.assertLessEqual(len(rolled), 3) + self.assertEqual(len({r.key for r in rolled}), len(rolled)) + + def test_roll_excludes_owned(self): + rolled = roll_relics(3, exclude=["iron_bark", "vitality", "granite"]) + self.assertTrue(all(r.key not in ("iron_bark", "vitality", "granite") for r in rolled)) + + def test_by_key_roundtrip(self): + for r in RELIC_POOL: + self.assertIs(by_key(r.key), r) + + def test_roll_with_all_excluded_keeps_rune_rewards(self): + rolled = roll_relics(3, exclude=[r.key for r in RELIC_POOL]) + self.assertTrue(rolled) + self.assertTrue(all(r.rune_reward for r in rolled)) # rune rewards always available + + def test_rune_reward_relics_are_rare_and_epic(self): + self.assertEqual(by_key("rune_cache").rarity, "rare") + self.assertEqual(by_key("rune_crypt").rarity, "epic") + + def test_rune_reward_relics_ignore_exclusion(self): + # a picked rune-reward relic can be rolled again in the same run + + def fake_choices(pool, weights=None, k=1): + pick = by_key("rune_cache") + return [pick if pick in pool else pool[0]] + + with mock.patch.object(random, "choices", side_effect=fake_choices): + rolled = roll_relics(3, exclude=["rune_cache"]) + self.assertIn("rune_cache", [r.key for r in rolled]) + + def test_grant_rune_rewards_tier1(self): + granted = grant_rune_rewards(["rune_cache"]) + self.assertEqual(len(granted), 1) + self.assertIn(granted[0]["color"], runes.TIER1_COLORS) + + def test_grant_rune_rewards_brown(self): + granted = grant_rune_rewards(["rune_crypt"]) + self.assertEqual(len(granted), 1) + self.assertEqual(granted[0]["color"], "brown") + + def test_grant_rune_rewards_counts(self): + granted = grant_rune_rewards(["rune_cache", "rune_crypt", "rune_cache"]) + self.assertEqual(len(granted), 3) + colors = [g["color"] for g in granted] + self.assertEqual(colors.count("brown"), 1) + self.assertEqual(sum(1 for c in colors if c in runes.TIER1_COLORS), 2) + + def test_rune_reward_count(self): + self.assertEqual(rune_reward_count(["rune_cache", "iron_bark", "rune_crypt"]), 2) + self.assertEqual(rune_reward_count(["iron_bark", "vitality"]), 0) + self.assertEqual(rune_reward_count([]), 0) + + +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)) + + def test_pct_hp_relic(self): + base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1) + b = _battle("vitality") + u = next(p for p in b.players if p.name == "Zinnia") + self.assertGreaterEqual(u.max_hp, int(base.max_hp * 1.12) - 1) + self.assertEqual(u.hp, u.max_hp) # healed to full + + def test_attack_pct_relic(self): + base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1) + b = _battle("sunlit") + u = next(p for p in b.players if p.name == "Zinnia") + self.assertEqual(u.attack, int(base.attack * 1.08)) + + def test_start_buff_relic(self): + b = _battle("morning_dew") + for u in b.players: + self.assertEqual(u.status[S_ATTACK_UP], (10, 1)) # lasts 1 turn + + def test_heal_boost_relic(self): + b = _battle("living_spring") + target = b.players[0] + target.hp = target.max_hp - 100 # room for a full heal + self.assertEqual(target.status["healing_boost"], 15) + healed = b._heal(None, target, 50) + self.assertEqual(healed, int(50 * 1.15)) + self.assertEqual(target.hp, target.max_hp - 100 + int(50 * 1.15)) + + def test_hot_relic(self): + b = _battle("regrowth") + for u in b.players: + self.assertIn(S_HOT, u.status) + self.assertEqual(u.status[S_HOT][0][0], 20) # 20 HP/turn (may have ticked once) + + def test_snowflakes_relic(self): + b = _battle("frost_blades") + for u in b.players: + self.assertEqual(u.status.get(S_SNOWFLAKES, 0), 5) + + def test_enemy_debuff_relic(self): + b = _battle("chilling_gust") + for u in b.enemies: + self.assertEqual(u.status["speed_down"], (8, 1)) # lasts 1 turn + + def test_enemy_vulnerable_relic(self): + b = _battle("expose") + for u in b.enemies: + self.assertEqual(u.status.get(S_VULNERABLE, 0), 2) + + 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) + self.assertEqual(u.status[S_ATTACK_UP], (10, 1)) + for u in b.enemies: + self.assertEqual(u.status["speed_down"], (8, 1)) + + def test_janus_bloom_reduces_first_hit_by_80_percent(self): + from engine.plants import make + from engine.skills import S_PREVENT_FIRST_DAMAGE + # baseline: full damage with protection status removed (no crits, so the + # two battles below are directly comparable) + target = make("Winter Jasmine", TEAM_PLAYER) + attacker = make("Rose", TEAM_ENEMY) + attacker.crit = 0.0 + b = Battle([target], [attacker]) + b.start() + while b.current is not attacker: + b.next_turn() + target.status.pop(S_PREVENT_FIRST_DAMAGE, None) + thorn = next(s for s in attacker.skills if s.kind == "damage") + hp0 = target.hp + b.use_skill(attacker, thorn, target) + full = hp0 - target.hp + self.assertGreater(full, 0) + # protected: same matchup, keep the Janus protection on the first hit + target2 = make("Winter Jasmine", TEAM_PLAYER) + attacker2 = make("Rose", TEAM_ENEMY) + attacker2.crit = 0.0 + b2 = Battle([target2], [attacker2]) + b2.start() + while b2.current is not attacker2: + b2.next_turn() + hp0 = target2.hp + b2.use_skill(attacker2, next(s for s in attacker2.skills if s.kind == "damage"), + target2) + softened = hp0 - target2.hp + self.assertLess(softened, full) # reduced + self.assertAlmostEqual(softened / full, 0.2, delta=0.06) # ~20% remains + + def test_protective_aura_log_is_not_janus(self): + # The blessing grants the same protection as Winter Jasmine's passive, + # but must not claim Janus Bloom in the log when no jasmine is present. + from engine.plants import make + target = make("Lavender", TEAM_PLAYER) # no tank on this team + attacker = make("Rose", TEAM_ENEMY) + b = Battle([target], [attacker], relics=["protective_aura"]) + b.start() + guard = 0 + while b.current is not attacker and guard < 50: + b.next_turn() + guard += 1 + thorn = next(s for s in attacker.skills if s.kind == "damage") + b.use_skill(attacker, thorn, target) + self.assertFalse(any("Janus Bloom" in (e if isinstance(e, str) else e["text"]) + for e in b.log)) + # first hit is reduced by 80%, not fully blocked + self.assertLess(target.hp, target.max_hp) + self.assertGreater(target.hp, target.max_hp * 0.5) + + def test_second_wind_revives_once(self): + from engine.plants import make + target = make("Lavender", TEAM_PLAYER) + attacker = make("Rose", TEAM_ENEMY) + b = Battle([target], [attacker], relics=["second_wind"]) + b.start() + guard = 0 + while b.current is not attacker and guard < 50: + b.next_turn() + guard += 1 + thorn = next(s for s in attacker.skills if s.kind == "damage") + target.hp = 1 + b.use_skill(attacker, thorn, target) + self.assertTrue(target.alive) # saved by Second Wind + self.assertLessEqual(target.hp, int(target.max_hp * 0.5)) + # only once: killing it again keeps it dead + target.hp = 1 + b._kill(target, attacker) + self.assertFalse(target.alive) + + def test_legendary_relics(self): + from engine.relics import RELIC_POOL + legendary = [r for r in RELIC_POOL if r.rarity == "legendary"] + self.assertGreaterEqual(len(legendary), 4) + self.assertEqual(by_key("protective_aura").rarity, "legendary") + self.assertIsNotNone(by_key("aegis")) + self.assertIsNotNone(by_key("overgrowth")) + self.assertIsNotNone(by_key("second_wind")) + + def test_roll_weights_toward_common(self): + import random + random.seed(1234) + counts = {"common": 0, "rare": 0, "epic": 0, "legendary": 0} + for _ in range(600): + for r in roll_relics(3): + counts[r.rarity] += 1 + self.assertGreater(counts["common"], counts["legendary"]) + self.assertGreater(counts["rare"], counts["legendary"]) + self.assertGreater(counts["epic"], counts["legendary"]) + + def test_low_stage_suppresses_high_tiers(self): + from engine.relics import _tier_weights + low = _tier_weights(1) + high = _tier_weights(20) + self.assertGreater(low["common"] / low["legendary"], + high["common"] / high["legendary"]) + self.assertGreater(low["common"] / low["epic"], + high["common"] / high["epic"]) + # high tiers are never impossible + self.assertGreater(low["legendary"], 0) + self.assertGreater(low["epic"], 0) + + def test_roll_honors_stage(self): + from engine.relics import _tier_weights + import random + random.seed(7) + def rarity_counts(stage): + counts = {"common": 0, "rare": 0, "epic": 0, "legendary": 0} + for _ in range(400): + for r in roll_relics(3, stage=stage): + counts[r.rarity] += 1 + return counts + c1 = rarity_counts(1) + c20 = rarity_counts(20) + # higher stages draw notably more legendary/epic relics + self.assertGreater(c20["legendary"], c1["legendary"]) + self.assertGreater(c20["epic"], c1["epic"]) + + def test_battle_with_relics_completes(self): + keys = ["iron_bark", "sunlit", "chilling_gust", "living_spring", "protective_aura"] + b = _battle(*keys) + guard = 0 + while b.winner is None and b.current is not None and guard < 500: + guard += 1 + u = b.current + if u.team == TEAM_ENEMY: + b.enemy_ai(u) + else: + s = b.best_skill(u) + if s: + foes = b.foes_of(u) + if s.kind == "heal": + low = [a for a in b.allies_of(u) if a.hp < a.max_hp] + if low: + b.use_skill(u, s, min(low, key=lambda a: a.hp)) + elif s.kind == "shield": + b.use_skill(u, s, u if s.target == "self" else min(b.allies_of(u), key=lambda a: a.hp)) + elif s.target in ("all_enemies", "two_enemies"): + b.use_skill(u, s, None) + elif s.target == "any" and foes: + b.use_skill(u, s, foes[0]) + elif s.target in ("ally", "all_allies"): + allies = b.allies_of(u) + if allies: + b.use_skill(u, s, min(allies, key=lambda a: a.hp)) + elif foes: + b.use_skill(u, s, min(foes, key=lambda t: t.hp)) + b.next_turn() + self.assertIn(b.winner, (TEAM_PLAYER, TEAM_ENEMY)) + + +class TowerMaxStageTest(unittest.TestCase): + def _set(self, owned, levels=None, stages=None, injected=None): + """Set owned plants + optional level/stage/injected per plant.""" + levels = levels or {} + stages = stages or {} + injected = injected or {} + data = collection.load() + data["owned"] = list(owned) + for n in data["progress"]: + data["progress"][n].pop("injected", None) + for n in owned: + prog = data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0, + "duplicates": 0, "watered_at": 0}) + prog["level"] = levels.get(n, 1) + prog["stage"] = stages.get(n, 1) + if n in injected: + prog["injected"] = injected[n] + collection.save(data) + + def test_fresh_account_caps_at_5(self): + self._set(["Lavender", "Marigold"]) + from engine.collection import tower_max_stage + self.assertEqual(tower_max_stage(), 5) + + def test_evolved_plant_unlocks_10(self): + self._set(["Lavender", "Marigold"], stages={"Lavender": 2}) + from engine.collection import tower_max_stage + self.assertEqual(tower_max_stage(), 10) + + def test_max_level_unlocks_15(self): + self._set(["Lavender"], levels={"Lavender": 50}, stages={"Lavender": 3}) + from engine.collection import tower_max_stage + self.assertEqual(tower_max_stage(), 15) + + def test_max_level_plus_injection_unlocks_20(self): + self._set(["Lavender"], levels={"Lavender": 50}, stages={"Lavender": 3}, + injected={"Lavender": {"index": 1, "skill": {}}}) + from engine.collection import tower_max_stage + self.assertEqual(tower_max_stage(), 20) + + def test_injection_alone_does_not_unlock(self): + self._set(["Lavender"], injected={"Lavender": {"index": 1, "skill": {}}}) + from engine.collection import tower_max_stage + self.assertEqual(tower_max_stage(), 5) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_runes.py b/tests/test_runes.py new file mode 100644 index 0000000..65e347b --- /dev/null +++ b/tests/test_runes.py @@ -0,0 +1,417 @@ +import os +import pathlib +import tempfile +import unittest +from unittest import mock + +import engine.collection as collection +from engine import runes +from engine.battle import Battle +from engine.skills import Skill, TARGET_ENEMY +from engine.unit import TEAM_ENEMY, TEAM_PLAYER, Unit + + +def _force_random(value): + """Force the random.random() used by the collection module to return `value`.""" + return mock.patch.object(collection, "random", + mock.Mock(random=mock.Mock(return_value=value))) + + +class RuneTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._orig = collection._DATA_FILE + fd, cls._path = tempfile.mkstemp(suffix=".json") + os.close(fd) + collection._DATA_FILE = pathlib.Path(cls._path) + + @classmethod + def tearDownClass(cls): + collection._DATA_FILE = cls._orig + if os.path.exists(cls._path): + os.unlink(cls._path) + + def setUp(self): + collection.init_starter_pinwheel("radiant") + data = collection.load() + data["runes"] = [] + for n in ("Winter Jasmine", "Primrose"): + if n not in data["owned"]: + data["owned"].append(n) + data["owned"].sort() + for n in data["owned"]: + data["progress"][n] = {"level": 1, "stage": 1, "xp": 0, "duplicates": 0, + "watered_at": 0, "max_stage": 1, "equipped_runes": []} + collection.save(data) + self.plant = "Winter Jasmine" + + def _evolve_to(self, name, stage, level, duplicates): + data = collection.load() + prog = data["progress"][name] + prog["level"] = level + prog["stage"] = stage + prog["duplicates"] = duplicates + collection.save(data) + return collection.evolve(name) + + # --- slots ------------------------------------------------------ + def test_starts_with_one_slot(self): + self.assertEqual(collection.rune_slots(self.plant), 1) + + def test_evolve_unlocks_second_slot(self): + self.assertTrue(self._evolve_to(self.plant, 1, 10, 2)) + self.assertEqual(collection.rune_slots(self.plant), 2) + + def test_evolve_unlocks_third_slot(self): + self.assertTrue(self._evolve_to(self.plant, 2, 20, 4)) + self.assertEqual(collection.rune_slots(self.plant), 3) + + def test_slots_persist_through_extraction_reset(self): + # evolve to stage 2 (2 slots), then reset like extract_skill does + self.assertTrue(self._evolve_to(self.plant, 1, 10, 2)) + data = collection.load() + prog = data["progress"][self.plant] + prog["level"] = 1 + prog["stage"] = 1 + prog["xp"] = 0 + prog["duplicates"] = 0 + collection.save(data) + self.assertEqual(collection.rune_slots(self.plant), 2) + + def test_slots_cap_at_three(self): + self.assertTrue(self._evolve_to(self.plant, 2, 20, 4)) + self.assertEqual(collection.rune_slots(self.plant), 3) + + # --- inventory & equip ------------------------------------------ + def test_add_rune_and_inventory(self): + r = collection.add_rune("sapphire") + self.assertEqual(r["color"], "sapphire") + self.assertEqual(r["name"], "Sapphire Rune") + self.assertGreaterEqual(r["pct"], 25) + self.assertLessEqual(r["pct"], 35) + self.assertEqual(collection.rune_inventory(), [r]) + + def test_add_rune_random_color(self): + r = collection.add_rune() + self.assertIn(r["color"], ("sapphire", "ruby", "amethyst", "jade", "brown")) + if r["color"] == "brown": + self.assertGreaterEqual(r["pct"], 5) + self.assertLessEqual(r["pct"], 15) + else: + self.assertGreaterEqual(r["pct"], 25) + self.assertLessEqual(r["pct"], 35) + + def test_brown_rune_rolls_low_pct(self): + r = collection.add_rune("brown") + self.assertGreaterEqual(r["pct"], 5) + self.assertLessEqual(r["pct"], 15) + + def test_tiers(self): + self.assertEqual(runes.tier("brown"), 2) + for c in ("sapphire", "ruby", "amethyst", "jade"): + self.assertEqual(runes.tier(c), 1) + self.assertIn(c, runes.TIER1_COLORS) + + def test_roll_ratio_is_proportional(self): + # a maxed brown equals a maxed yellow despite 15 < 35 + self.assertEqual(runes.roll_ratio({"color": "brown", "pct": 15}), + runes.roll_ratio({"color": "jade", "pct": 35})) + # mid rolls compare against their own cap + self.assertGreater(runes.roll_ratio({"color": "jade", "pct": 30}), + runes.roll_ratio({"color": "brown", "pct": 10})) + self.assertLess(runes.roll_ratio({"color": "brown", "pct": 5}), + runes.roll_ratio({"color": "jade", "pct": 35})) + + def test_free_runes_excludes_equipped(self): + r1 = collection.add_rune("ruby") + r2 = collection.add_rune("sapphire") + self._evolve_to(self.plant, 1, 10, 2) + collection.equip_rune(self.plant, 0, r1["id"]) + free = [r["id"] for r in collection.free_runes()] + self.assertIn(r2["id"], free) + self.assertNotIn(r1["id"], free) + + def _ten_runes(self, data): + ids = [] + for _ in range(10): + ids.append(collection.add_rune("sapphire", data=data, save_now=False)["id"]) + return ids + + def test_reroll_rune_improves_pct(self): + r = collection.add_rune("ruby") + data = collection.load() + data["runes"][0]["pct"] = 25 + sac = self._ten_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 + + 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) + collection.save(data) + err = collection.reroll_rune(r["id"], sac) + self.assertEqual(err, "") + new_pct = collection.load()["runes"][0]["pct"] + self.assertGreaterEqual(new_pct, 6) + self.assertLessEqual(new_pct, 15) + + def test_reroll_max_rune_rejected(self): + r = collection.add_rune("ruby") + data = collection.load() + data["runes"][0]["pct"] = 35 + sac = self._ten_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 + + def test_reroll_requires_exactly_ten(self): + r = collection.add_rune("ruby") + data = collection.load() + data["runes"][0]["pct"] = 25 + sac = self._ten_runes(data) + collection.save(data) + self.assertEqual(collection.reroll_rune(r["id"], sac[:9]), + "Pick exactly 10 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 + collection.save(data) + self.assertEqual(collection.reroll_rune(r["id"], sac), + "Cannot sacrifice the rune being upgraded") + + def test_reroll_unequips_sacrificed_runes(self): + r1 = collection.add_rune("ruby") + r2 = collection.add_rune("sapphire") + data = collection.load() + data["runes"][0]["pct"] = 25 + sac = self._ten_runes(data) + sac[0] = r2["id"] # sacrifice the equipped rune + collection.save(data) + self._evolve_to(self.plant, 1, 10, 2) + collection.equip_rune(self.plant, 0, r2["id"]) + err = collection.reroll_rune(r1["id"], sac) + self.assertEqual(err, "") + self.assertEqual(collection.equipped_runes(self.plant), [None, None]) + self.assertNotIn(r2["id"], [x["id"] for x in collection.load()["runes"]]) + + def test_equip_and_unequip(self): + self._evolve_to(self.plant, 1, 10, 2) # 2 slots + r1 = collection.add_rune("sapphire") + r2 = collection.add_rune("ruby") + self.assertEqual(collection.equip_rune(self.plant, 0, r1["id"]), "") + self.assertEqual(collection.equipped_runes(self.plant), [r1["id"], None]) + # a rune can only occupy one slot; equipping elsewhere moves it + self.assertEqual(collection.equip_rune(self.plant, 1, r1["id"]), "") + self.assertEqual(collection.equipped_runes(self.plant), [None, r1["id"]]) + # fill the other slot too + self.assertEqual(collection.equip_rune(self.plant, 0, r2["id"]), "") + self.assertEqual(sorted(collection.equipped_runes(self.plant)), + sorted([r1["id"], r2["id"]])) + # bad slot / unknown rune are rejected + self.assertNotEqual(collection.equip_rune(self.plant, 5, r1["id"]), "") + self.assertNotEqual(collection.equip_rune(self.plant, 0, 9999), "") + # unequip + self.assertTrue(collection.unequip_rune(self.plant, 0)) + self.assertEqual(collection.equipped_runes(self.plant), [None, r1["id"]]) + self.assertFalse(collection.unequip_rune(self.plant, 5)) + + def test_equip_respects_slot_count(self): + r = collection.add_rune("sapphire") + # only 1 slot until evolved + self.assertNotEqual(collection.equip_rune(self.plant, 1, r["id"]), "") + self.assertEqual(collection.equip_rune(self.plant, 0, r["id"]), "") + + def test_rune_can_only_be_equipped_on_one_plant(self): + other = "Primrose" + r = collection.add_rune("ruby") + self._evolve_to(self.plant, 1, 10, 2) + self._evolve_to(other, 1, 10, 2) + self.assertEqual(collection.equip_rune(self.plant, 0, r["id"]), "") + self.assertEqual(collection.rune_location(r["id"]), self.plant) + # equipping the same rune on another plant moves it there + self.assertEqual(collection.equip_rune(other, 0, r["id"]), "") + self.assertEqual(collection.rune_location(r["id"]), other) + self.assertEqual(collection.equipped_runes(self.plant), [None, None]) + + def test_max_same_type_runes_per_plant(self): + self._evolve_to(self.plant, 2, 20, 4) # 3 slots + ws = [collection.add_rune("amethyst") for _ in range(3)] + self.assertEqual(collection.equip_rune(self.plant, 0, ws[0]["id"]), "") + self.assertEqual(collection.equip_rune(self.plant, 1, ws[1]["id"]), "") + # a third white rune is blocked + self.assertNotEqual(collection.equip_rune(self.plant, 2, ws[2]["id"]), "") + # a different colour still fits + red = collection.add_rune("ruby") + self.assertEqual(collection.equip_rune(self.plant, 2, red["id"]), "") + + def test_enemy_runes_cap_same_type(self): + # three white rolls: the 3rd is rejected in favour of another colour + with mock.patch.object(runes, "random_color", + side_effect=["amethyst", "amethyst", "amethyst", "ruby"]): + rolled = runes.roll_enemy_runes(15) + self.assertEqual(len(rolled), 3) + effects = [e for e, _ in rolled] + self.assertEqual(effects.count("damage_taken"), 2) # at most 2 white + self.assertEqual(effects.count("damage"), 1) + + # --- drops ------------------------------------------------------ + def test_battle_drop_chance(self): + with _force_random(0.0): + self.assertIsNotNone(collection.roll_battle_rune()) + with _force_random(0.99): + self.assertIsNone(collection.roll_battle_rune()) + + def test_pack_drop_chance(self): + data = collection.load() + data["packs"]["general"] = 2 + collection.save(data) + with _force_random(0.0): + plant, is_new, bonus = collection.open_pack("general") + self.assertIsNotNone(bonus) + self.assertIn("color", bonus) + with _force_random(0.99): + plant, is_new, bonus = collection.open_pack("general") + self.assertIsNone(bonus) + + def test_pack_still_returns_plant_without_rune(self): + data = collection.load() + data["packs"]["general"] = 1 + collection.save(data) + with _force_random(0.99): + result = collection.open_pack("general") + self.assertIsNotNone(result) + self.assertEqual(len(result), 3) + + def test_open_pack_history_keeps_last_ten(self): + data = collection.load() + data["packs"]["general"] = 12 + collection.save(data) + for _ in range(12): + collection.open_pack("general") + history = collection.open_history() + self.assertEqual(len(history), 10) + for rec in history: + self.assertIn("plant", rec) + self.assertIn("is_new", rec) + self.assertIn("rune", rec) + + def test_buy_rune_t1(self): + data = collection.load() + data["coins"] = 200 + collection.save(data) + before = len(collection.rune_inventory()) + r = collection.buy_rune(1) + self.assertIsInstance(r, dict) + self.assertEqual(runes.tier(r["color"]), 1) + self.assertEqual(collection.available_coins(), 100) + self.assertEqual(len(collection.rune_inventory()), before + 1) + + def test_buy_rune_t2(self): + data = collection.load() + data["coins"] = 200 + collection.save(data) + before = len(collection.rune_inventory()) + r = collection.buy_rune(2) + self.assertIsInstance(r, dict) + self.assertEqual(r["color"], "brown") + self.assertEqual(collection.available_coins(), 50) + self.assertEqual(len(collection.rune_inventory()), before + 1) + + def test_buy_rune_insufficient_coins(self): + data = collection.load() + data["coins"] = 50 + collection.save(data) + before = len(collection.rune_inventory()) + self.assertEqual(collection.buy_rune(1), "Not enough coins") + self.assertEqual(len(collection.rune_inventory()), before) + self.assertEqual(collection.available_coins(), 50) + + +class RuneBattleEffectTest(unittest.TestCase): + def _unit(self, name="A", team=TEAM_PLAYER, ptype="radiant", atk=50, spd=5, + dfn=20, hp=100, skills=None, runes=None): + return Unit(name, team, ptype, max_hp=hp, attack=atk, speed=spd, defense=dfn, + skills=skills or [Skill("Hit", 10, target=TARGET_ENEMY)], + crit=0.0, runes=runes or []) + + def _damage_test(self, attacker_runes=(), target_runes=()): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + a = self._unit(atk=50, skills=[hit], runes=list(attacker_runes)) + t = self._unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, runes=list(target_runes)) + b = Battle([a], [t]) + b.start() + b.use_skill(a, hit, t) + base = int((10 + 25) * (1 - 20 / 95)) + return 500 - t.hp, base + + def test_red_rune_boosts_damage(self): + dealt, base = self._damage_test(attacker_runes=(("damage", 0.30),)) + self.assertEqual(dealt, int(base * 1.3)) + + def test_red_runes_stack_by_rolled_pct(self): + dealt, base = self._damage_test(attacker_runes=(("damage", 0.25), ("damage", 0.35))) + self.assertEqual(dealt, int(base * 1.6)) + + def test_white_rune_reduces_damage_taken(self): + dealt, base = self._damage_test(target_runes=(("damage_taken", 0.30),)) + self.assertEqual(dealt, int(base * 0.7)) + + def test_red_and_white_combine(self): + dealt, base = self._damage_test(attacker_runes=(("damage", 0.30),), + target_runes=(("damage_taken", 0.30),)) + self.assertEqual(dealt, int(base * 1.3 * 0.7)) + + def test_blue_rune_boosts_shields(self): + t = self._unit("T", TEAM_ENEMY, "garden", hp=500, runes=[("shield", 0.30)]) + b = Battle([self._unit()], [t]) + b.start() + b._shield(t, 100) + self.assertEqual(t.status["shield"], 130) + + def test_yellow_rune_boosts_healing(self): + heal = Skill("Mend", power=40, kind="heal", target="ally") + a = self._unit(atk=50, skills=[heal]) + ally = self._unit("Ally", TEAM_PLAYER, "radiant", hp=1000, runes=[("healing", 0.30)]) + ally.hp = 100 + enemy = self._unit("E", TEAM_ENEMY, "garden") + b = Battle([a, ally], [enemy]) + b.start() + b.use_skill(a, heal, ally) + self.assertEqual(ally.hp, 100 + int((40 + 25) * 1.3)) + + def test_brown_rune_applies_wilt_from_damage(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + a = self._unit(atk=50, skills=[hit], runes=[("wilt", 0.06)]) + t = self._unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500) + b = Battle([a], [t]) + b.start() + b.use_skill(a, hit, t) + base = int((10 + 25) * (1 - 20 / 95)) + self.assertEqual(500 - t.hp, base) + # 6% of the 100 damage dealt lands as Wilt + self.assertEqual(t.status["wilt"][0], int(base * 0.06)) + + def test_brown_rune_wilt_ticks_over_three_turns(self): + hit = Skill("Hit", power=10, target=TARGET_ENEMY) + a = self._unit(atk=50, spd=9, skills=[hit], runes=[("wilt", 0.06)]) + t = self._unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=1) + b = Battle([a], [t]) + b.start() + b.use_skill(a, hit, t) + self.assertEqual(t.status["wilt"][1], 3) # lasts 3 turns + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_soil.py b/tests/test_soil.py new file mode 100644 index 0000000..8df3f38 --- /dev/null +++ b/tests/test_soil.py @@ -0,0 +1,313 @@ +import atexit +import os +import pathlib +import tempfile +import time +import unittest + +# Point the engine at a fresh empty save to keep these tests hermetic. +import engine.collection as collection + +_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 SoilTest(unittest.TestCase): + def setUp(self): + data = collection.load() + data["owned"] = ["Rose", "Lavender", "Zinnia"] + data["soil"] = {"seed_cd_until": 0.0, "seeds": [], "plots": []} + data["water"] = 10 + data["fertilizer"] = 10 + collection.save(data) + + def test_retrieve_seed(self): + self.assertEqual(collection.retrieve_seed("Rose"), "") + state = collection.soil_state() + self.assertEqual(len(state["seeds"]), 1) + self.assertEqual(state["seeds"][0]["plant"], "Rose") + self.assertGreater(state["cd_left"], 0) # cooldown set + + def test_retrieve_respects_cooldown(self): + collection.retrieve_seed("Rose") + self.assertNotEqual(collection.retrieve_seed("Lavender"), "") + state = collection.soil_state() + self.assertEqual(len(state["seeds"]), 1) + + def test_retrieve_requires_owned(self): + self.assertNotEqual(collection.retrieve_seed("NotAPlant"), "") + data = collection.load() + data["discovered"] = ["Marigold"] + collection.save(data) + self.assertNotEqual(collection.retrieve_seed("Marigold"), "") + + def test_plant_creates_stage_zero_plot(self): + collection.retrieve_seed("Rose") + self.assertEqual(collection.plant_seed(0), "") + state = collection.soil_state() + self.assertEqual(len(state["plots"]), 1) + self.assertEqual(state["seeds"], []) + plot = state["plots"][0] + self.assertEqual(plot["stage"], 0) + self.assertFalse(plot["grown"]) + self.assertFalse(plot["stalled"]) # stage not yet full + + def test_stage_fills_over_time(self): + data = collection.load() + data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}] + collection.save(data) + state = collection.soil_state() + plot = state["plots"][0] + self.assertEqual(plot["stage_progress"], 1.0) + self.assertTrue(plot["stalled"]) # stage full -> needs water + self.assertFalse(plot["grown"]) # but not the last stage + + def test_water_requires_stalled(self): + collection.retrieve_seed("Rose") + collection.plant_seed(0) + self.assertNotEqual(collection.water_plot(0), "") # not stalled yet + + def test_water_advances_stage(self): + data = collection.load() + data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}] + collection.save(data) + self.assertEqual(collection.water_plot(0), "") + self.assertEqual(collection.available_fertilizer(), 9) + state = collection.soil_state() + self.assertEqual(state["plots"][0]["stage"], 1) + self.assertEqual(state["plots"][0]["water"], 1) + self.assertFalse(state["plots"][0]["stalled"]) # new stage just started + + def test_water_requires_fertilizer_pool(self): + data = collection.load() + data["fertilizer"] = 0 + data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}] + collection.save(data) + self.assertNotEqual(collection.water_plot(0), "") + + def test_harvest_requires_grown(self): + collection.retrieve_seed("Rose") + collection.plant_seed(0) + self.assertNotEqual(collection.harvest_plot(0), "") # not grown + + def test_grow_all_stages_then_harvest(self): + # Rose is super-rare -> 4 stages. Plant with an old stage_started_at so each + # stage is instantly full, then water through stages 0..2. + data = collection.load() + data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}] + collection.save(data) + total = collection.GROWTH_STAGES["super_rare"] + for expected in range(total - 1): + self.assertNotEqual(collection.harvest_plot(0), "") # not grown mid-way + self.assertEqual(collection.water_plot(0), "", f"water to advance to {expected + 1}") + # each watered stage is instantly full again (stage_started_at=now) + data = collection.load() + data["soil"]["plots"][0]["stage_started_at"] = 0 + collection.save(data) + state = collection.soil_state() + self.assertTrue(state["plots"][0]["grown"]) + dup_before = collection.get_progress("Rose")["duplicates"] + self.assertEqual(collection.harvest_plot(0), "") + state = collection.soil_state() + self.assertEqual(state["plots"], []) + self.assertEqual(collection.get_progress("Rose")["duplicates"], dup_before + 1) + + def test_higher_rarity_more_stages(self): + self.assertGreater(collection.GROWTH_STAGES["super_super_rare"], + collection.GROWTH_STAGES["super_rare"]) + self.assertGreater(collection.GROWTH_STAGES["super_rare"], + collection.GROWTH_STAGES["rare"]) + self.assertGreater(collection.GROWTH_STAGES["rare"], + collection.GROWTH_STAGES["common"]) + + def test_default_one_plot_unlocked(self): + state = collection.soil_state() + self.assertEqual(state["plots_unlocked"], 1) + self.assertEqual(state["plots_max"], collection.SOIL_PLOTS_MAX) + + def test_soil_gated_by_owned_plants(self): + data = collection.load() + data["owned"] = ["Rose", "Lavender"] + collection.save(data) + self.assertFalse(collection.soil_unlocked()) + data = collection.load() + data["owned"] = [f"Plant{i}" for i in range(collection.SOIL_GATE_PLANTS)] + for n in data["owned"]: + data["progress"].setdefault(n, {"level": 1, "stage": 1, "xp": 0, + "duplicates": 0, "watered_at": 0}) + collection.save(data) + self.assertTrue(collection.soil_unlocked()) + + def test_plot_unlock_costs_escalate(self): + self.assertEqual(collection.plot_unlock_cost(2), (10, 5)) + self.assertEqual(collection.plot_unlock_cost(3), (50, 20)) + self.assertEqual(collection.plot_unlock_cost(4), (100, 50)) + self.assertEqual(collection.plot_unlock_cost(5), (0, 0)) + + def test_plant_limited_to_unlocked_plots(self): + data = collection.load() + data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}] + collection.save(data) + # 1 unlocked, 1 planted -> planting another seed fails + collection.retrieve_seed("Lavender") + self.assertEqual(len(collection.soil_state()["seeds"]), 1) + self.assertNotEqual(collection.plant_seed(0), "") + + def test_unlock_plot_spends_coins_and_fertilizer(self): + data = collection.load() + data["coins"] = 10 + data["fertilizer"] = 5 + collection.save(data) + self.assertEqual(collection.unlock_plot(), "") + self.assertEqual(collection.soil_state()["plots_unlocked"], 2) + self.assertEqual(collection.available_coins(), 0) + self.assertEqual(collection.available_fertilizer(), 0) + # too little fertilizer now + self.assertNotEqual(collection.unlock_plot(), "") + + def test_unlock_plot_requires_coins(self): + data = collection.load() + data["coins"] = 0 + data["fertilizer"] = 50 + collection.save(data) + self.assertNotEqual(collection.unlock_plot(), "") + + def test_unlock_plot_requires_fertilizer(self): + data = collection.load() + data["coins"] = 50 + data["fertilizer"] = 4 + collection.save(data) + self.assertNotEqual(collection.unlock_plot(), "") + + def test_unlock_plot_max_four(self): + data = collection.load() + data["coins"] = 500 + data["fertilizer"] = 500 + collection.save(data) + for _ in range(3): + self.assertEqual(collection.unlock_plot(), "") + self.assertEqual(collection.soil_state()["plots_unlocked"], collection.SOIL_PLOTS_MAX) + self.assertNotEqual(collection.unlock_plot(), "") # capped + + def test_after_unlock_extra_plot_usable(self): + data = collection.load() + data["coins"] = 10 + data["fertilizer"] = 5 + data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}] + collection.save(data) + collection.unlock_plot() # now 2 unlocked, 1 used + collection.retrieve_seed("Lavender") + self.assertEqual(collection.plant_seed(0), "") + self.assertEqual(len(collection.soil_state()["plots"]), 2) + + +class AutoFarmerTest(unittest.TestCase): + def setUp(self): + data = collection.load() + data["owned"] = ["Rose", "Lavender", "Zinnia"] + data["soil"] = {"seed_cd_until": 0.0, "seeds": [], "plots": []} + data["fertilizer"] = 10 + data["coins"] = 100 + data["auto_farmer"] = False + collection.save(data) + + def _stalled_plot(self): + data = collection.load() + data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}] + collection.save(data) + + def test_accrue_fertilizer_grants_one_per_hour(self): + data = collection.load() + data["fertilizer_accrued_at"] = time.time() - 7200 # 2 hours ago + data["fertilizer"] = 0 + collection.save(data) + self.assertEqual(collection.accrue_fertilizer(), 2) + self.assertEqual(collection.available_fertilizer(), 2) + + def test_accrue_fertilizer_caps(self): + data = collection.load() + data["fertilizer_accrued_at"] = time.time() - 7200 + data["fertilizer"] = 99 + collection.save(data) + self.assertEqual(collection.accrue_fertilizer(), 1) + self.assertEqual(collection.available_fertilizer(), 100) + + def test_add_fertilizer_caps(self): + data = collection.load() + data["fertilizer"] = 99 + collection.save(data) + collection.add_fertilizer(10) + self.assertEqual(collection.available_fertilizer(), 100) + + def test_buy_auto_farmer(self): + self.assertEqual(collection.buy_auto_farmer(), "") + self.assertTrue(collection.auto_farmer_owned()) + self.assertEqual(collection.available_coins(), 0) + self.assertNotEqual(collection.buy_auto_farmer(), "") # already owned + + def test_buy_auto_farmer_requires_coins(self): + data = collection.load() + data["coins"] = 50 + collection.save(data) + self.assertNotEqual(collection.buy_auto_farmer(), "") + self.assertFalse(collection.auto_farmer_owned()) + + def test_buy_activates_farmer(self): + self.assertEqual(collection.buy_auto_farmer(), "") + self.assertTrue(collection.auto_farmer_active()) + + def test_toggle_auto_farmer(self): + collection.buy_auto_farmer() + self.assertTrue(collection.auto_farmer_active()) + self.assertFalse(collection.toggle_auto_farmer()) # turned off + self.assertFalse(collection.auto_farmer_active()) + self.assertTrue(collection.toggle_auto_farmer()) # back on + self.assertTrue(collection.auto_farmer_owned()) # still owns it + + def test_auto_farm_respects_toggle(self): + self._stalled_plot() + collection.buy_auto_farmer() + collection.toggle_auto_farmer() # turn it off + self.assertEqual(collection.auto_farm(), "Auto farmer off") + self.assertEqual(collection.available_fertilizer(), 10) # nothing spent + self.assertEqual(collection.soil_state()["plots"][0]["stage"], 0) + + def test_auto_farm_fertilizes_stalled_plot(self): + self._stalled_plot() + self.assertEqual(collection.buy_auto_farmer(), "") + self.assertEqual(collection.auto_farm(), "") + self.assertEqual(collection.available_fertilizer(), 9) + self.assertEqual(collection.soil_state()["plots"][0]["stage"], 1) + + def test_auto_farm_requires_farmer(self): + self._stalled_plot() + self.assertEqual(collection.auto_farm(), "No auto farmer") + + def test_auto_farm_does_not_plant_or_harvest(self): + data = collection.load() + data["soil"]["plots"] = [ + {"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}, # stalled + {"plant": "Rose", "stage_started_at": 0, "stage": 3, "water": 0}, # grown (epic) + ] + data["soil"]["seeds"] = [{"plant": "Lavender"}] + collection.save(data) + self.assertEqual(collection.buy_auto_farmer(), "") + self.assertEqual(collection.auto_farm(), "") + state = collection.soil_state() + self.assertEqual(len(state["plots"]), 2) # grown plot not harvested + self.assertEqual(len(state["seeds"]), 1) # seed not planted + self.assertEqual(state["plots"][0]["stage"], 1) # stalled plot advanced + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/extract.py b/ui/extract.py new file mode 100644 index 0000000..f955461 --- /dev/null +++ b/ui/extract.py @@ -0,0 +1,315 @@ +import pygame + +from engine import collection +from engine.growth import MAX_LEVEL +from engine.plants import POOL, RARITY_COLORS, plant_rarity +from engine.skills import skill_from_dict +from engine.unit import ROLE_NAMES +from ui.menu import _bracket_text +from ui.scaling import make_window, request_quit, to_logical + +WIN_W, WIN_H = 1040, 720 +BG = (24, 30, 24) +TEXT = (230, 235, 230) +MUTED = (150, 155, 150) +HINT = (255, 220, 120) +PANEL = (32, 38, 32) +BTN = (60, 90, 60) +BTN_HOVER = (85, 125, 85) +ACCENT = (120, 220, 190) +DANGER = (150, 70, 70) + +ORDINALS = {1: "2nd", 2: "3rd"} + + +class ExtractionScreen: + def __init__(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 — Ability Extraction") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 30) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.back_rect = pygame.Rect(16, 16, 90, 34) + self.step_rect = pygame.Rect(116, 16, 120, 34) + self.tab_rects = { + "extract": pygame.Rect(WIN_W // 2 - 210, 78, 200, 36), + "inject": pygame.Rect(WIN_W // 2 + 10, 78, 200, 36), + } + self.tab = "extract" + self.phase = "list" # extract: list -> skills ; inject: ability -> plant -> slot + self.source = None # selected source plant (extract) + self.ability = None # selected extracted-ability index (inject) + self.target = None # selected target plant (inject) + self.item_rects = [] # (key, rect) for the current phase list + self.confirm = None # dict, or None + self.confirm_ok = pygame.Rect(0, 0, 0, 0) + self.confirm_no = pygame.Rect(0, 0, 0, 0) + self.mouse_pos = (0, 0) + + # ---- helpers ------------------------------------------------------- + def _deep(self): + if self.tab == "extract": + return self.phase == "skills" + return self.phase in ("plant", "slot") + + def _reset(self, tab): + self.tab = tab + self.phase = "list" if tab == "extract" else "ability" + self.source = None + self.ability = None + self.target = None + self.confirm = None + + # ---- click --------------------------------------------------------- + def _handle(self, key): + if self.tab == "extract": + if self.phase == "list": + self.source = key + self.phase = "skills" + elif self.phase == "skills": + self.confirm = {"kind": "extract", "name": self.source, "index": key} + else: + if self.phase == "ability": + self.ability = key + self.phase = "plant" + elif self.phase == "plant": + self.target = key + self.phase = "slot" + elif self.phase == "slot": + self.confirm = {"kind": "inject", "ability": self.ability, + "target": self.target, "slot": key} + + def _confirm_ok(self): + c = self.confirm + if c["kind"] == "extract": + if collection.extract_skill(c["name"], c["index"]): + self.phase = "list" + self.source = None + else: + if collection.inject_skill(c["ability"], c["target"], c["slot"]): + self.phase = "ability" + self.ability = None + self.target = None + self.confirm = None + + def run(self): + running = True + while running: + self.clock.tick(60) + self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + pos = to_logical(event.pos, self.scale) + if self.confirm is not None: + if self.confirm_ok.collidepoint(pos): + self._confirm_ok() + elif self.confirm_no.collidepoint(pos): + self.confirm = None + continue + if self.back_rect.collidepoint(pos): + running = False + continue + if self.step_rect.collidepoint(pos) and self._deep(): + self._step_back() + continue + if self.tab_rects["extract"].collidepoint(pos): + if self.tab != "extract": + self._reset("extract") + continue + if self.tab_rects["inject"].collidepoint(pos): + if self.tab != "inject": + self._reset("inject") + continue + for key, rect in self.item_rects: + if rect.collidepoint(pos): + self._handle(key) + break + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + + def _step_back(self): + if self.tab == "extract": + self.phase = "list" + self.source = None + else: + if self.phase == "slot": + self.phase = "plant" + self.target = None + elif self.phase == "plant": + self.phase = "ability" + self.ability = None + + # ---- drawing ------------------------------------------------------- + def _draw(self): + self.item_rects = [] + self.screen.fill(BG) + pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) + t = self.small.render("Back", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.back_rect.center)) + if self._deep(): + pygame.draw.rect(self.screen, BTN, self.step_rect, border_radius=6) + st = self.small.render("← Back", True, TEXT) + self.screen.blit(st, st.get_rect(center=self.step_rect.center)) + title = self.font.render("Ability Extraction", True, HINT) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 38))) + sub = self.small.render("Sacrifice a maxed plant to copy an ability; inject it into a same-role plant.", + True, MUTED) + self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 68))) + for name, rect in self.tab_rects.items(): + active = self.tab == name + pygame.draw.rect(self.screen, BTN_HOVER if active else BTN, rect, border_radius=8) + pygame.draw.rect(self.screen, (150, 190, 150) if active else (70, 80, 70), + rect, 2 if active else 1, border_radius=8) + tt = self.small.render(name.capitalize(), True, TEXT) + self.screen.blit(tt, tt.get_rect(center=rect.center)) + if self.confirm is not None: + self._draw_confirm() + elif self.tab == "extract": + self._draw_extract() + else: + self._draw_inject() + + # ---- extract tab --------------------------------------------------- + def _draw_extract(self): + if self.phase == "list": + sources = sorted(n for n in collection.owned() if collection.can_extract(n) == "") + if not sources: + t = self.small.render( + f"No plant is ready yet. A plant needs Lv {MAX_LEVEL} and 2 evolutions.", True, MUTED) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 220))) + return + rows = [] + for n in sources: + spec = POOL[n] + rows.append((n, n, RARITY_COLORS.get(plant_rarity(n), TEXT), + f"{ROLE_NAMES.get(spec['role'], spec['role'])} · Lv {MAX_LEVEL} · 2 evolutions")) + self._draw_items(rows, "Plants ready to extract from:") + else: + self._draw_extract_skills() + + def _draw_extract_skills(self): + rows = [] + for i, sk in enumerate(POOL[self.source]["skills"]): + reason = collection.extraction_blocked_reason(self.source, i, sk) + if reason: + rows.append((i, sk.name, MUTED, f"Not extractable — {reason}", False)) + else: + rows.append((i, sk.name, ACCENT, _skill_sub(sk))) + self._draw_items(rows, f"Extract from {self.source}:") + + # ---- inject tab ---------------------------------------------------- + def _draw_inject(self): + if self.phase == "ability": + ext = collection.extracted_skills() + if not ext: + t = self.small.render("No extracted abilities yet — extract one first.", True, MUTED) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 220))) + return + rows = [(i, r["skill_name"], ACCENT, + f"from {r['source']} · {ROLE_NAMES.get(r['role'], r['role'])}") + for i, r in enumerate(ext)] + self._draw_items(rows, "Pick an extracted ability:") + elif self.phase == "plant": + rec = collection.extracted_skills()[self.ability] + targets = sorted(n for n in collection.owned() if collection.can_inject(n, rec) == "") + if not targets: + t = self.small.render("No eligible plants (same role, no injection).", True, MUTED) + self.screen.blit(t, (60, 150)) + return + rows = [(n, n, RARITY_COLORS.get(plant_rarity(n), TEXT), + ROLE_NAMES.get(POOL[n]["role"], POOL[n]["role"])) for n in targets] + self._draw_items(rows, f"Inject {rec['skill_name']} into:") + else: + self._draw_inject_slots() + + def _draw_inject_slots(self): + rec = collection.extracted_skills()[self.ability] + spec = POOL[self.target] + rows = [] + for slot in (1, 2): + if slot >= len(spec["skills"]): + continue + sk = spec["skills"][slot] + reason = collection.injection_slot_reason(self.target, rec, slot) + if reason: + rows.append((slot, f"Replace {ORDINALS[slot]} skill ({sk.name})", MUTED, + f"Not allowed — {reason}", False)) + else: + rows.append((slot, f"Replace {ORDINALS[slot]} skill ({sk.name})", HINT, + f"CD {sk.cooldown} · {sk.description}")) + self._draw_items(rows, f"Inject {rec['skill_name']} into {self.target} — replace:") + + # ---- shared list --------------------------------------------------- + def _draw_items(self, rows, header): + h = self.bold.render(header, True, MUTED) + self.screen.blit(h, (60, 118)) + y = 146 + for row in rows: + key, label, color, sub = row[0], row[1], row[2], row[3] + enabled = row[4] if len(row) > 4 else True + rect = pygame.Rect(60, y, 560, 46) + if enabled: + self.item_rects.append((key, rect)) + hover = enabled and rect.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, BTN_HOVER if hover else PANEL, rect, border_radius=6) + pygame.draw.rect(self.screen, (70, 90, 70) if hover else (60, 70, 60), + rect, 2 if hover else 1, border_radius=6) + lt = self.bold.render(label, True, color) + self.screen.blit(lt, (rect.x + 12, rect.y + 5)) + if sub: + st = self.tiny.render(sub, True, MUTED) + self.screen.blit(st, (rect.x + 12, rect.y + 26)) + y += 54 + + # ---- confirm overlay ----------------------------------------------- + def _draw_confirm(self): + shade = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + shade.fill((0, 0, 0, 170)) + self.screen.blit(shade, (0, 0)) + panel = pygame.Rect(WIN_W // 2 - 250, 200, 500, 200) + pygame.draw.rect(self.screen, PANEL, panel, border_radius=12) + pygame.draw.rect(self.screen, (90, 110, 90), panel, 2, border_radius=12) + c = self.confirm + if c["kind"] == "extract": + sk = POOL[c["name"]]["skills"][c["index"]] + title = f"Extract {sk.name}?" + body = [f"from {c['name']} — this resets it to Lv 1 / Stage 1.", + "The ability is stored for one injection."] + else: + rec = collection.extracted_skills()[c["ability"]] + spec = POOL[c["target"]] + title = f"Inject {rec['skill_name']} into {c['target']}?" + body = [f"Replaces the {ORDINALS[c['slot']]} skill ({spec['skills'][c['slot']].name}).", + "Consumes the extracted ability.", + "Removing it later destroys the ability."] + tt = self.bold.render(title, True, HINT) + self.screen.blit(tt, tt.get_rect(center=(panel.centerx, panel.y + 40))) + yy = panel.y + 76 + for line in body: + b = self.small.render(line, True, TEXT) + self.screen.blit(b, b.get_rect(center=(panel.centerx, yy))) + yy += 28 + self.confirm_ok = pygame.Rect(panel.centerx - 145, panel.bottom - 52, 135, 38) + self.confirm_no = pygame.Rect(panel.centerx + 10, panel.bottom - 52, 135, 38) + pygame.draw.rect(self.screen, BTN_HOVER, self.confirm_ok, border_radius=8) + ot = self.bold.render("Confirm", True, TEXT) + self.screen.blit(ot, ot.get_rect(center=self.confirm_ok.center)) + pygame.draw.rect(self.screen, DANGER, self.confirm_no, border_radius=8) + nt = self.bold.render("Cancel", True, TEXT) + self.screen.blit(nt, nt.get_rect(center=self.confirm_no.center)) + + +def _skill_sub(sk) -> str: + bracket = _bracket_text(sk) + head = f"CD {sk.cooldown}" + if bracket: + head += f" [{bracket}]" + return f"{head} · {sk.description}" \ No newline at end of file diff --git a/ui/main.py b/ui/main.py new file mode 100644 index 0000000..041127e --- /dev/null +++ b/ui/main.py @@ -0,0 +1,1192 @@ +import pygame + +from assets import icon, load_sprite +from engine import collection, runes +from engine.battle import Battle +from engine.dominance import estimate_dominance, resolve_instantly +from engine.plants import PASSIVES, RARITY_COLORS, RARITY_LABELS, plant_rarity +from engine.relics import RELIC_POOL +from engine.skills import EFF_DOT, EFF_HOT, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY +from engine.types import type_multiplier +from engine.unit import ROLE_NAMES, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER +from ui.menu import _bracket_text, _draw_evo_badge +from ui.relics_overview import RelicsOverview +from ui.runes import draw_rune_chips +from ui.scaling import (fit_surface, load_rune_icon, load_type_icon, make_window, + request_quit, to_logical, wrap_text) + +TYPE_COLORS = { + "radiant": (245, 200, 90), + "nocturnal": (150, 120, 220), + "ice": (130, 200, 235), + "garden": (120, 200, 120), + "wildflower": (235, 130, 170), +} +WIN_W = 1040 +WIN_H = 720 +BG = (24, 30, 24) +CARD_BG = (36, 44, 36) +ALLIED = (80, 190, 90) +ENEMY = (215, 90, 90) +TEXT = (230, 235, 230) +HINT = (255, 220, 120) +MUTED = (150, 155, 150) +BTN = (60, 90, 60) +BTN_HOVER = (85, 125, 85) +BAR_BG = (28, 32, 28) +LOG_BG = (20, 22, 20) +SEL = (255, 255, 120) +TARGET = (120, 220, 120) +READY = (70, 130, 70) +LOCKED = (46, 50, 46) +ACCENT = (120, 220, 190) + +CARD_W = 400 +CARD_H = 96 +CARD_GAP = 8 +CARD_TOP = 44 +SIDE_X = (24, WIN_W - CARD_W - 24) +PORTRAIT = 84 + +SKILL_Y = 468 +SKILL_H = 84 +SKILL_BTN_H = 44 +LOG_Y = 560 +LOG_H = 74 + + +class Game: + def __init__(self, battle: Battle, on_win=None, on_lose=None, tower=False, relics=(), stage=0): + pygame.init() + self.window, self.scale = make_window(WIN_W, WIN_H) + self.screen = pygame.Surface((WIN_W, WIN_H)) + self.tower = tower + self.stage = stage + self.relics = list(relics) + if tower and stage: + caption = f"Plants — Tower — Floor {stage}" + elif stage: + caption = f"Plants — Floor {stage}" + elif tower: + caption = "Plants — Tower Battle" + else: + caption = "Plants — 4v4 Turn Battle (prototype)" + pygame.display.set_caption(caption) + self.clock = pygame.time.Clock() + self.battle = battle + self.on_win = on_win + self.on_lose = on_lose + self.reward = None + self.reward_rolled = False + self.reward_acknowledged = False + self.font = pygame.font.SysFont("segoeui", 24) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 15) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.tank_icon = pygame.transform.smoothscale( + pygame.image.load(icon("role_icons", "tank_icon")).convert_alpha(), (18, 18) + ) + self.rune_icons = {} + self._reward_sprites = {} + self.sprites = {u.sort_order: self._load_sprite(u) for u in self.battle.units} + self.pending_skill = None + self.info_unit = None + self.info_rects = {} + self.auto_end = 0.0 + self.ai_delay = 900 + self.ai_timer = self.ai_delay + self.skill_rects = [] + self._cards = [] + self._layout_cards() + self.card_rects = {u.sort_order: rect for u, rect in self._cards} + self.floaters = [] + self.flashes = [] + self.beams = [] + self.hit_queue = [] + self._hit_offsets = {} + self.flash_surf = pygame.Surface((CARD_W, CARD_H)) + self._dt = 0.0 + self.back_rect = pygame.Rect(12, 12, 90, 32) + self.auto_btn = pygame.Rect(112, 12, 90, 32) + # Auto-battle unlocks once the player has cleared floor 20 in the + # normal stage battles (floor_progress counts the next unbeaten floor). + self.auto_unlocked = collection.floor_progress() > 20 + self.finish_rect = pygame.Rect(WIN_W - 132, 12, 120, 32) + self.can_finish = False + self.auto_battle = False + self.log_scroll = 0 + self.mouse_pos = (0, 0) + self.log_rect = pygame.Rect(16, LOG_Y, WIN_W - 32, LOG_H) + self._last_log_len = 0 + self.relic_overview = None + self.blessings_rect = pygame.Rect(16, LOG_Y, WIN_W - 32, 18) if (tower and relics) else None + + def _layout_cards(self): + self._cards = [] + for x, units in ((SIDE_X[0], self.battle.players), (SIDE_X[1], self.battle.enemies)): + for i, u in enumerate(units): + y = CARD_TOP + i * (CARD_H + CARD_GAP) + self._cards.append((u, pygame.Rect(x, y, CARD_W, CARD_H))) + + def _card_at(self, pos): + for unit, rect in self._cards: + if rect.collidepoint(pos): + return unit + return None + + def _load_sprite(self, unit): + img = load_sprite(unit.name, "head") + if img: + return fit_surface(img, PORTRAIT) + return None + + def _rune_icon(self, color): + if color not in self.rune_icons: + self.rune_icons[color] = load_rune_icon(color, 14) + return self.rune_icons[color] + + def _reward_sprite(self, path): + """Load a reward sprite (seed pack / rune) at its native resolution, + cached. Returns a surface or None. Sprites are never scaled.""" + if path in self._reward_sprites: + return self._reward_sprites[path] + try: + img = pygame.image.load(path).convert_alpha() + except (pygame.error, OSError): + img = None + self._reward_sprites[path] = img + return img + + def _reward_labels(self): + """Flatten reward items (dicts or plain strings) into label text.""" + out = [] + for it in (self.reward or []): + if isinstance(it, str): + out.append(it) + else: + out.append(it.get("label", "")) + return out + + def _layout_skills(self): + unit = self.battle.current + self.skill_rects = [] + self.injected_index = -1 + if unit is None or unit.team != TEAM_PLAYER: + return + self.injected_index = unit.injected_index + n = len(unit.skills) + avail = WIN_W - 32 + gap = 8 + w = (avail - gap * (n - 1)) // n + x = 16 + for sk in unit.skills: + self.skill_rects.append((sk, pygame.Rect(x, SKILL_Y, w, SKILL_BTN_H))) + x += w + gap + + # --- main loop ----------------------------------------------------- + def run(self): + import copy + # Snapshot fresh units so a "Finish battle" resolves the true full-battle + # outcome (matching the dominance estimate) rather than the mid-battle state. + self._finish_players = copy.deepcopy(self.battle.players) + self._finish_enemies = copy.deepcopy(self.battle.enemies) + self._dominance = estimate_dominance( + self.battle.players, self.battle.enemies, self.battle.relics) + self.battle.start() + self._preselect() + running = True + outcome = None + end_timer = None + while running: + dt = self.clock.tick(60) + self._dt = dt + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + pos = to_logical(event.pos, self.scale) + if self.auto_unlocked and self.auto_btn.collidepoint(pos): + self.auto_battle = not self.auto_battle + continue + if (self._dominance and self.battle.turn >= 3 + and collection.get_tower_best() >= 5 + and self.battle.winner is None + and self.finish_rect.collidepoint(pos)): + self._finish_battle() + continue + if self.battle.winner is not None and self.reward_rolled and not self.reward_acknowledged: + self.reward_acknowledged = True + continue + if self.back_rect.collidepoint(pos): + outcome = "back" + running = False + else: + self._handle_click(pos) + elif event.type == pygame.MOUSEMOTION: + self.mouse_pos = to_logical(event.pos, self.scale) + elif event.type == pygame.MOUSEWHEEL: + if self.relic_overview is not None: + self.relic_overview.handle_wheel(event.y) + elif self.log_rect.collidepoint(self.mouse_pos): + self.log_scroll = max(0, self.log_scroll + event.y) + self._update(dt) + if self.battle.winner is not None and self.reward_acknowledged: + if end_timer is None: + end_timer = 0.0 + end_timer += dt + if end_timer >= 1500: + outcome = "rematch" + running = False + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + return outcome + + def _preselect(self): + b = self.battle + cur = b.current + if cur and cur.team == TEAM_PLAYER and not cur.acted: + self.pending_skill = b.best_skill(cur) + else: + self.pending_skill = None + + # --- input --------------------------------------------------------- + def _handle_click(self, pos): + b = self.battle + if self.relic_overview is not None: + if self.relic_overview.handle_click(pos) == "close": + self.relic_overview = None + return + if b.winner is not None: + return + if self.blessings_rect and self.blessings_rect.collidepoint(pos): + self.relic_overview = RelicsOverview( + self.screen, self.bold, self.small, self.tiny, + RELIC_POOL, [r.key for r in self.relics]) + return + if self.info_unit is not None: + self.info_unit = None # any click closes the info popup + return + for sort, rect in self.info_rects.items(): + if rect.collidepoint(pos): + unit = next((u for u in b.units if u.sort_order == sort), None) + if unit is not None: + self.info_unit = unit + return + if pos[1] >= SKILL_Y - 4: + self._handle_skill_click(pos) + return + if b.current and b.current.team == TEAM_PLAYER: + self._handle_card_click(pos) + + def _handle_skill_click(self, pos): + b = self.battle + cur = b.current + if cur is None or cur.team != TEAM_PLAYER: + return + oblivion = isinstance(cur.status.get("oblivion"), tuple) and cur.status["oblivion"][0] > 0 + weakest = None + if oblivion: + ready = [s for s in cur.skills if s.cooldown_left <= 0] + weakest = min(ready, key=lambda s: s.power) if ready else None + for sk, rect in self.skill_rects: + if rect.collidepoint(pos): + if sk.cooldown_left > 0: + return + if oblivion and sk is not weakest: + return + if sk.target in (TARGET_ENEMY, TARGET_ALLY, TARGET_ANY): + valid = b.valid_skill_targets(cur, sk) + if len(valid) == 1: + # Only one thing it can hit (e.g. last enemy alive, or a + # tank blocking everything): use it straight away. + self.pending_skill = None + self._cast(sk, valid[0]) + else: + self.pending_skill = sk if self.pending_skill is not sk else None + else: + self.pending_skill = None + self._cast(sk, None) + return + + def _handle_card_click(self, pos): + b = self.battle + unit = self._card_at(pos) + if unit is None: + return + cur = b.current + if self.pending_skill: + target = self._resolve_target(unit, self.pending_skill) + if target: + self._cast(self.pending_skill, target) + self.pending_skill = None + return + if unit.team != TEAM_PLAYER: + best = next((s for s in sorted(cur.skills, key=lambda s: s.priority) + if s.cooldown_left <= 0 and s.kind == "damage"), None) + if best: + target = self._resolve_target(unit, best) + if target: + self._cast(best, target) + + def _resolve_target(self, unit, skill): + b = self.battle + if unit in b.valid_skill_targets(b.current, skill): + return unit + if skill.target == "enemy": + tanks = b.protectors_of(unit) + if tanks: + return tanks[0] + return None + + def _cast(self, skill, target): + b = self.battle + if b.use_skill(b.current, skill, target): + self.auto_end = 600 + + def _finish_battle(self): + """Instantly resolve a clearly-dominant battle by simulating it to its + true outcome (option 1): the result is genuine, not an assumed win.""" + b = self.battle + winner = resolve_instantly(self._finish_players, self._finish_enemies, b.relics) + b.winner = winner + # Mark the losing side dead with empty bars (mirrors _kill). + for u in b.players: + if winner == TEAM_PLAYER: + u.alive = True + else: + u.alive = False + u.hp = 0 + u.status.pop("shield", None) + for u in b.enemies: + if winner == TEAM_ENEMY: + u.alive = True + else: + u.alive = False + u.hp = 0 + u.status.pop("shield", None) + if winner == TEAM_PLAYER: + b.log.append("Battle finished instantly — your team overwhelms the enemy!") + else: + b.log.append("Battle finished instantly — the enemy prevails.") + self.reward_rolled = False + + def _end_player_turn(self): + self.battle.next_turn() + self.auto_end = 0.0 + self.ai_timer = self.ai_delay + self._preselect() + + def _update(self, dt): + b = self.battle + self._consume_events() + self._advance_hit_queue(dt) + if b.winner is not None: + if not self.reward_rolled: + if b.winner == TEAM_PLAYER and self.on_win: + self.reward = self.on_win() + elif b.winner == TEAM_ENEMY and self.on_lose: + self.reward = self.on_lose() + self.reward_rolled = True + return + if self.auto_end > 0: + self.auto_end -= dt + if self.auto_end <= 0 and b.current and b.current.team == TEAM_PLAYER and b.current.acted: + self._end_player_turn() + if b.current and b.current.team == TEAM_ENEMY: + self.ai_timer -= dt + if self.ai_timer <= 0: + if not b.current.acted: + used = b.enemy_ai(b.current) + self.ai_timer = self.ai_delay + if not used: + b.next_turn() + self._preselect() + if b.current.acted: + b.next_turn() + self._preselect() + elif b.current and b.current.team == TEAM_PLAYER and self.auto_battle: + self.ai_timer -= dt + if self.ai_timer <= 0: + if not b.current.acted: + used = self._player_ai(b.current) + self.ai_timer = self.ai_delay + if not used: + b.next_turn() + self._preselect() + if b.current.acted: + b.next_turn() + self._preselect() + + def _player_ai(self, unit) -> bool: + """Auto-play the player's unit like the enemy AI. Returns True if it acted.""" + if unit is not self.battle.current or unit.team != TEAM_PLAYER or not unit.alive: + return False + b = self.battle + foes = b.foes_of(unit) + # if only one enemy remains, prefer a guaranteed-kill damaging skill over a heal + if len(foes) == 1: + target = foes[0] + for s in unit.skills: + if s.cooldown_left > 0 or s.kind != "damage": + continue + if target not in b.valid_skill_targets(unit, s): + continue + if self._estimate_damage(unit, s, target) >= target.hp: + return b.use_skill(unit, s, target) + s = b.best_skill(unit) + if not s: + return False + if s.kind == "heal": + low = [a for a in b.allies_of(unit) if a.hp < a.max_hp] + return b.use_skill(unit, s, min(low, key=lambda a: a.hp) if low else None) + if s.kind == "shield": + t = unit if s.target == "self" else b._shield_target(unit) + if t is None: + t = unit + return b.use_skill(unit, s, t) + if s.target in ("all_enemies", "two_enemies"): + return b.use_skill(unit, s, None) + if s.target == "any": + tg = b.valid_skill_targets(unit, s) + return b.use_skill(unit, s, min(tg, key=lambda t: t.hp) if tg else None) + if s.kind == "utility" and s.mechanic == "reset_cooldowns": + al = b.allies_of(unit) + if al: + target = max(al, key=lambda a: sum( + x.cooldown_left for x in a.skills if x.cooldown_left > 0)) + return b.use_skill(unit, s, target) + return False + if s.target in ("ally", "all_allies"): + al = b.allies_of(unit) + return b.use_skill(unit, s, min(al, key=lambda a: a.hp) if al else None) + tg = b.valid_skill_targets(unit, s) + if not tg: + return False + if s.mechanic == "hits_per_effect": + t = max(tg, key=lambda t: (len([k for k in t.status if k != "form"]), -t.hp)) + else: + t = min(tg, key=lambda t: t.hp) + return b.use_skill(unit, s, t) + + def _estimate_damage(self, unit, skill, target) -> int: + """Conservative (no-crit) damage estimate for a skill against a target.""" + from engine.types import type_multiplier + power = skill.power + atk = self.battle._attack(unit) + offense = power + int(atk * 0.5) + dfn = self.battle._defense(target) + dmg = int(offense * (1 - dfn / (dfn + 75))) + dmg = max(1, int(dmg * type_multiplier(unit.plant_type, target.plant_type))) + if skill.target in ("all_enemies", "two_enemies"): + dmg = int(dmg * 0.75) + return dmg + + # --- drawing ------------------------------------------------------- + def _consume_events(self): + b = self.battle + if not b.events: + return + for ev in b.events: + if ev.get("type") == "damage": + self.hit_queue.append({"event": ev, "timer": len(self.hit_queue) * 110}) + else: + self._handle_event(ev) + b.events.clear() + + def _advance_hit_queue(self, dt): + if not self.hit_queue: + return + for h in self.hit_queue: + h["timer"] -= dt + due = [h for h in self.hit_queue if h["timer"] <= 0] + self.hit_queue = [h for h in self.hit_queue if h["timer"] > 0] + for h in due: + self._handle_event(h["event"]) + + def _handle_event(self, ev): + kind = ev.get("type") + if kind == "damage": + source, target = ev.get("source"), ev.get("target") + if source and target and source is not target: + self._spawn_beam(source, target, (255, 235, 120)) + if target: + idx = self._hit_offsets.get(target.sort_order, 0) + self._hit_offsets[target.sort_order] = idx + 1 + offset = (idx % 3) * -16 + self._spawn_floater(target, f"-{ev['amount']}", (255, 235, 235), center=True, offset=offset) + self._spawn_flash(target, (255, 80, 80)) + if source: + self._spawn_flash(source, (255, 255, 255)) + elif kind == "heal": + source, target = ev.get("source"), ev.get("target") + if source and target and source is not target: + self._spawn_beam(source, target, (120, 255, 150)) + if target: + self._spawn_floater(target, f"+{ev['amount']}", (120, 255, 150)) + self._spawn_flash(target, (90, 255, 120)) + elif kind == "shield": + if ev.get("target"): + self._spawn_floater(ev["target"], "shield", (150, 210, 255)) + self._spawn_flash(ev["target"], (120, 190, 255)) + elif kind == "kill": + if ev.get("target"): + self._spawn_floater(ev["target"], "DEFEATED", (255, 120, 120)) + self._spawn_flash(ev["target"], (255, 255, 255)) + + def _spawn_beam(self, source, target, color): + a = self.card_rects.get(source.sort_order) + b = self.card_rects.get(target.sort_order) + if a and b: + self.beams.append({"start": a.center, "end": b.center, "color": color, + "age": 0.0, "lifetime": 350}) + + def _spawn_floater(self, unit, text, color, center=False, offset=0): + rect = self.card_rects.get(unit.sort_order) + if rect: + y = (rect.centery if center else rect.top - 8) + offset + self.floaters.append({"x": rect.centerx, "y": y, + "text": text, "color": color, "age": 0.0, "lifetime": 1000}) + + def _spawn_flash(self, unit, color): + rect = self.card_rects.get(unit.sort_order) + if rect: + self.flashes.append({"rect": rect, "color": color, "age": 0.0, "lifetime": 500}) + + def _draw_effects(self): + keep = [] + for f in self.floaters: + f["age"] += self._dt + if f["age"] >= f["lifetime"]: + continue + prog = f["age"] / f["lifetime"] + txt = self.bold.render(f["text"], True, f["color"]) + txt.set_alpha(int(255 * (1 - prog))) + lift = int(prog * 26) + self.screen.blit(txt, (f["x"] - txt.get_width() // 2, f["y"] - lift)) + keep.append(f) + self.floaters = keep + + keep = [] + for fl in self.flashes: + fl["age"] += self._dt + if fl["age"] >= fl["lifetime"]: + continue + alpha = int(200 * (1 - fl["age"] / fl["lifetime"])) + self.flash_surf.fill(fl["color"]) + self.flash_surf.set_alpha(alpha) + self.screen.blit(self.flash_surf, fl["rect"].topleft) + keep.append(fl) + self.flashes = keep + + keep = [] + for bm in self.beams: + bm["age"] += self._dt + if bm["age"] >= bm["lifetime"]: + continue + prog = bm["age"] / bm["lifetime"] + alpha = int(255 * (1 - prog)) + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + pygame.draw.line(overlay, (*bm["color"], alpha), bm["start"], bm["end"], 5) + pygame.draw.circle(overlay, (*bm["color"], alpha), bm["end"], 8) + self.screen.blit(overlay, (0, 0)) + keep.append(bm) + self.beams = keep + + def _draw(self): + if self.battle.winner is not None: + self.info_unit = None + self.screen.fill(BG) + if self.tower and self.stage: + title_text = f"Tower — Floor {self.stage}" + elif self.stage: + title_text = f"Floor {self.stage}" + elif self.tower: + title_text = "Plants — Tower Battle" + else: + title_text = "Plants — 4v4 Turn Battle" + title = self.font.render(title_text, True, TEXT) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 24))) + pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) + t = self.small.render("Back", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.back_rect.center)) + if self.auto_unlocked: + abg = BTN_HOVER if self.auto_battle else BTN + pygame.draw.rect(self.screen, abg, self.auto_btn, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150) if self.auto_battle else (90, 110, 90), + self.auto_btn, 2, border_radius=6) + t = self.small.render("Auto: ON" if self.auto_battle else "Auto", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.auto_btn.center)) + if (self._dominance and self.battle.turn >= 3 and collection.get_tower_best() >= 5 + and self.battle.winner is None): + fbg = BTN_HOVER if self.finish_rect.collidepoint(self.mouse_pos) else BTN + pygame.draw.rect(self.screen, fbg, self.finish_rect, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150), self.finish_rect, 2, border_radius=6) + ft = self.small.render("Finish battle", True, TEXT) + self.screen.blit(ft, ft.get_rect(center=self.finish_rect.center)) + self._draw_cards() + self._layout_skills() + self._draw_skill_bar() + self._draw_log() + self._draw_effects() + if self.info_unit is not None: + self._draw_unit_info() + if self.battle.winner is not None and self.reward_rolled and not self.reward_acknowledged: + self._draw_reward_popup() + if self.relic_overview is not None: + self.relic_overview.draw(self.mouse_pos) + + def _draw_reward_popup(self): + b = self.battle + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 170)) + self.screen.blit(overlay, (0, 0)) + # Build tiles: (img_or_None, label, w, h, shrinkable). Pack sprites are + # drawn at native size; rune sprites are too, but shrink (aspect kept) + # only as a last resort so a rare 2-pack + rune reward still fits. + tiles = [] + for it in (self.reward or []): + if isinstance(it, str): + tiles.append((None, it, self.bold.size(it)[0] + 20, 30, False)) + continue + label = it.get("label", "") + img = self._reward_sprite(it.get("img")) if it.get("img") else None + if img: + kind = it.get("kind", "") + # Display scaled to match the rest of the UI (aspect preserved). + img = fit_surface(img, 60 if kind == "rune" else 80) + tiles.append((img, label, img.get_width(), img.get_height() + 20, + kind == "rune")) + else: + tiles.append((None, label, max(90, self.bold.size(label)[0] + 20), 30, False)) + + gap = 14 + pad = 24 + avail_w = WIN_W - 48 + + def _layout(tiles): + rows, row, row_w = [], [], 0 + for t in tiles: + tw = t[2] + if row and row_w + gap + tw > avail_w: + rows.append(row) + row, row_w = [], 0 + row.append(t) + row_w += (gap if row_w else 0) + tw + if row: + rows.append(row) + row_h = [max(t[3] for t in r) for r in rows] if rows else [] + max_row_w = max((sum(t[2] for t in r) + gap * (len(r) - 1)) for r in rows) if rows else 0 + head_h = 96 if tiles else 36 + tiles_h = sum(row_h) + 14 * (len(row_h) - 1) + return rows, row_h, max_row_w, head_h, tiles_h + + rows, row_h, max_row_w, head_h, tiles_h = _layout(tiles) + panel_w = max(440, max_row_w + pad * 2) + panel_h = head_h + tiles_h + 46 + # If the panel doesn't fit the screen (e.g. several packs at once), + # shrink every sprite proportionally (aspect preserved, never distorted) + # until it fits. A single reward stays at native resolution. + while (panel_w > WIN_W or panel_h > WIN_H) and any(t[0] is not None for t in tiles): + changed = False + for i, t in enumerate(tiles): + img = t[0] + if img is None: + continue + target = max(24, int(max(img.get_width(), img.get_height()) * 0.85)) + if target >= max(img.get_width(), img.get_height()): + continue + img2 = fit_surface(img, target) + tiles[i] = (img2, t[1], img2.get_width(), img2.get_height() + 20, t[4]) + changed = True + if not changed: + break + rows, row_h, max_row_w, head_h, tiles_h = _layout(tiles) + panel_w = max(440, max_row_w + pad * 2) + panel_h = head_h + tiles_h + 46 + + panel = pygame.Rect((WIN_W - panel_w) // 2, (WIN_H - panel_h) // 2, panel_w, panel_h) + pygame.draw.rect(self.screen, (32, 38, 32), panel, border_radius=12) + border = (150, 190, 150) if b.winner == TEAM_PLAYER else (200, 90, 90) + pygame.draw.rect(self.screen, border, panel, 2, border_radius=12) + title = self.font.render("VICTORY!" if b.winner == TEAM_PLAYER else "DEFEAT...", True, HINT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + if tiles: + verb = "won" if b.winner == TEAM_PLAYER else "earned" + head = self.bold.render(f"You {verb}:", True, TEXT) + self.screen.blit(head, head.get_rect(center=(panel.centerx, panel.y + 62))) + y = panel.y + head_h + for i, r in enumerate(rows): + total = sum(t[2] for t in r) + gap * (len(r) - 1) + x = panel.centerx - total // 2 + for img, label, tw, th, _ in r: + if img is not None: + self.screen.blit(img, (x, y)) + lr = self.bold.render(label, True, HINT) + self.screen.blit(lr, lr.get_rect(center=(x + tw // 2, y + img.get_height() + 10))) + else: + box = pygame.Rect(x, y, tw, 30) + pygame.draw.rect(self.screen, (45, 52, 45), box, border_radius=6) + t = self.bold.render(label, True, TEXT) + self.screen.blit(t, t.get_rect(center=box.center)) + x += tw + gap + y += row_h[i] + 14 + hint = self.tiny.render("Click to continue", True, (150, 155, 150)) + self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 16))) + + def _status_text(self, unit): + parts = [] + if unit.status.get("sleep", 0) > 0: + parts.append("Sleeping") + if unit.status.get("stun", 0) > 0: + parts.append("Stunned") + if "vulnerable" in unit.status: + parts.append(f"Vulnerable {unit.status['vulnerable']}") + if "wilt" in unit.status: + total, turns = unit.status["wilt"] + parts.append(f"Wilt {total}") + if "dot" in unit.status: + stacks = unit.status["dot"] + parts.append(f"DoT {sum(d for d, _ in stacks)}/t") + if "speed_down" in unit.status: + parts.append("Spd down") + if "crit_down" in unit.status: + parts.append("Crit down") + if "attack_down" in unit.status: + parts.append("Atk down") + if "defense_down" in unit.status: + parts.append("Def down") + if "intoxicate" in unit.status: + parts.append("Intoxicated") + if "oblivion" in unit.status: + parts.append("Oblivion") + if "madness" in unit.status: + parts.append(f"Madness {unit.status['madness']}") + if "tether" in unit.status: + parts.append("Tethered") + if "attack_up" in unit.status: + parts.append("Atk+") + if "defense_up" in unit.status: + parts.append("Def+") + if "speed_up" in unit.status: + parts.append("Spd+") + if unit.status.get("snowflakes", 0): + parts.append(f"Hit+{unit.status['snowflakes']}") + if "hot" in unit.status: + stacks = unit.status["hot"] + parts.append(f"Heal {sum(a for a, _ in stacks)}/t") + if "form" in unit.status: + parts.append(unit.status["form"].capitalize()) + return " | ".join(parts) + + def _draw_cards(self): + b = self.battle + valid = [] + if self.pending_skill and b.current: + valid = b.valid_skill_targets(b.current, self.pending_skill) + self.info_rects = {} + for unit, rect in self._cards: + team_color = ALLIED if unit.team == TEAM_PLAYER else ENEMY + bg = CARD_BG if unit.alive else (28, 28, 28) + pygame.draw.rect(self.screen, bg, rect, border_radius=8) + if unit in valid: + pygame.draw.rect(self.screen, TARGET, rect, 3, border_radius=8) + elif unit is b.current: + pygame.draw.rect(self.screen, SEL, rect, 3, border_radius=8) + else: + pygame.draw.rect(self.screen, team_color, rect, 2, border_radius=8) + + bar_w = CARD_W - 130 + hp_x = rect.x + 10 + pct = max(0.0, unit.hp / unit.max_hp) + pygame.draw.rect(self.screen, (60, 20, 20), (hp_x, rect.y + 7, bar_w, 10)) + bar_color = (80, 220, 90) if pct > 0.5 else (220, 180, 40) if pct > 0.25 else (220, 60, 60) + fill = int(bar_w * pct) + pygame.draw.rect(self.screen, bar_color, (hp_x, rect.y + 7, fill, 10)) + shield = unit.status.get("shield", 0) + if shield > 0 and fill > 0: + sh_w = int(bar_w * min(1.0, shield / unit.max_hp)) + sh_w = min(sh_w, fill) + if sh_w > 0: + tint = pygame.Surface((sh_w, 10), pygame.SRCALPHA) + tint.fill((120, 185, 240, 140)) + self.screen.blit(tint, (hp_x, rect.y + 7)) + + x_right = rect.x + CARD_W - 12 + hp_text = self.small.render(f"{unit.hp}/{unit.max_hp}", True, TEXT) + sh_text = self.tiny.render(f" shield {shield}", True, (150, 210, 255)) if shield > 0 else None + total_w = hp_text.get_width() + (sh_text.get_width() if sh_text else 0) + self.screen.blit(hp_text, (x_right - total_w, rect.y + 3)) + if sh_text: + self.screen.blit(sh_text, (x_right - total_w + hp_text.get_width(), rect.y + 5)) + + init_pct = b.progress(unit) + pygame.draw.rect(self.screen, (28, 38, 48), (hp_x, rect.y + 20, bar_w, 5)) + pygame.draw.rect(self.screen, (120, 200, 240), (hp_x, rect.y + 20, int(bar_w * init_pct), 5)) + + portrait = self.sprites.get(unit.sort_order) + if portrait is not None: + self.screen.blit(portrait, (rect.x + 10, rect.y + 28)) + _draw_evo_badge(self.screen, self.tiny, (rect.x + CARD_W - 80, rect.y + 12), unit.stage - 1) + + tx = rect.x + 104 + name_color = TEXT if unit.alive else (130, 130, 130) + name = self.bold.render(unit.name, True, name_color) + self.screen.blit(name, (tx, rect.y + 30)) + + tcol = TYPE_COLORS.get(unit.plant_type, TEXT) + tic = load_type_icon(unit.plant_type, 14) if unit.plant_type else None + if tic: + self.screen.blit(tic, tic.get_rect(midleft=(tx + name.get_width() + 8, rect.y + 39))) + icon_x = tx + name.get_width() + 8 + tic.get_width() + 8 + elif unit.plant_type: + ttype = self.small.render(unit.plant_type, True, tcol) + self.screen.blit(ttype, (tx + name.get_width() + 8, rect.y + 32)) + icon_x = tx + name.get_width() + 8 + ttype.get_width() + 8 + else: + icon_x = tx + name.get_width() + 8 + if unit.alive and unit.role == ROLE_TANK and b.tanks_on_team(unit.team): + self.screen.blit(self.tank_icon, (icon_x, rect.y + 30)) + + if unit.alive and unit.team != TEAM_PLAYER and b.current and b.current.team == TEAM_PLAYER and b.current is not unit: + mult = type_multiplier(b.current.plant_type, unit.plant_type) + if mult != 1.0: + badge = self.small.render(f"{mult:.1f}x", True, + (120, 220, 130) if mult > 1 else (235, 130, 130)) + self.screen.blit(badge, (rect.x + CARD_W - 62, rect.y + 26)) + + if unit.alive: + statuses = self._status_text(unit) + if statuses: + self.screen.blit(self.small.render(statuses, True, (255, 160, 120)), + (tx, rect.y + 56)) + rune_colors = [runes.color_for_effect(e) for e, _ in unit.runes] + rune_colors = [c for c in rune_colors if c] + if rune_colors: + draw_rune_chips(self.screen, self._rune_icon, rune_colors, + tx, rect.y + 74) + else: + dead = self.small.render("DEFEATED", True, (150, 150, 150)) + self.screen.blit(dead, (tx, rect.y + 56)) + + irect = pygame.Rect(rect.right - 28, rect.bottom - 28, 20, 20) + self.info_rects[unit.sort_order] = irect + ihov = irect.collidepoint(self.mouse_pos) + pygame.draw.circle(self.screen, (120, 140, 120) if ihov else (70, 82, 70), irect.center, 10) + pygame.draw.circle(self.screen, (175, 195, 175), irect.center, 10, 1) + itxt = self.tiny.render("i", True, TEXT) + self.screen.blit(itxt, itxt.get_rect(center=irect.center)) + lv = self.tiny.render(f"Lv {unit.level}", True, (175, 195, 175)) + self.screen.blit(lv, lv.get_rect(midright=(irect.left - 5, irect.centery))) + + def _draw_unit_info(self): + u = self.info_unit + if u is None: + return + shade = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + shade.fill((0, 0, 0, 150)) + self.screen.blit(shade, (0, 0)) + w, h = 480, 560 + panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h) + pygame.draw.rect(self.screen, (30, 36, 30), panel, border_radius=10) + pygame.draw.rect(self.screen, (90, 110, 90), panel, 2, border_radius=10) + spr = self.sprites.get(u.sort_order) + if spr: + ic = fit_surface(spr, 96) + self.screen.blit(ic, ic.get_rect(center=(panel.x + 68, panel.y + 72))) + rar = plant_rarity(u.name) + name = self.bold.render(u.name, True, RARITY_COLORS.get(rar, TEXT)) + self.screen.blit(name, (panel.x + 130, panel.y + 34)) + rt = self.tiny.render(RARITY_LABELS.get(rar, rar), True, RARITY_COLORS.get(rar, TEXT)) + self.screen.blit(rt, (panel.x + 130 + name.get_width() + 8, panel.y + 38)) + rn = self.small.render(ROLE_NAMES.get(u.role, u.role), True, MUTED) + self.screen.blit(rn, (panel.x + 130, panel.y + 66)) + tic = load_type_icon(u.plant_type, 14) if u.plant_type else None + if tic: + self.screen.blit(tic, tic.get_rect(midleft=(panel.x + 130 + rn.get_width() + 8, panel.y + 74))) + elif u.plant_type: + sep2 = self.small.render(" · ", True, MUTED) + ts = self.small.render(u.plant_type, True, TYPE_COLORS.get(u.plant_type, MUTED)) + self.screen.blit(sep2, (panel.x + 130 + rn.get_width(), panel.y + 66)) + self.screen.blit(ts, (panel.x + 130 + rn.get_width() + sep2.get_width(), panel.y + 66)) + + runes_list = u.runes + if runes_list: + ry = panel.y + 88 + for eff, pct in runes_list[:3]: + color = runes.color_for_effect(eff) + ic = self._rune_icon(color) + if ic: + self.screen.blit(ic, (panel.x + 130, ry)) + txt = self.tiny.render( + runes.effect_text(color, int(round(pct * 100))), True, TEXT) + self.screen.blit(txt, (panel.x + 148, ry + 1)) + ry += 14 + + b = self.battle + atk, dfn, spd = b._attack(u), b._defense(u), b._speed(u) + self._draw_stat_line(panel.x + 20, panel.y + 132, + [("Attack", atk, u.attack), + ("Defense", dfn, u.defense), + ("Speed", spd, u.speed)]) + y2 = panel.y + 154 + hp_txt = self.small.render(f"HP {u.hp}/{u.max_hp}", True, TEXT) + self.screen.blit(hp_txt, (panel.x + 20, y2)) + x2 = panel.x + 20 + hp_txt.get_width() + 18 + crit_pct = int(round(b._crit(u) * 100)) + base_pct = int(round(u.crit * 100)) + ctxt = self.small.render(f"Crit {crit_pct}%", True, TEXT) + self.screen.blit(ctxt, (x2, y2)) + x2 += ctxt.get_width() + if crit_pct != base_pct: + base_t = self.tiny.render(f" (base {base_pct}%)", True, MUTED) + self.screen.blit(base_t, (x2, y2 + 3)) + + inj = collection.injection_of(u.name) + inj_idx = u.injected_index if u.injected_index >= 0 else (inj.get("index", -1) if inj else -1) + y = panel.y + 184 + if u.passive: + pname, pdesc = PASSIVES.get(u.passive, (u.passive, "")) + pt = self.bold.render(f"Passive — {pname}", True, HINT) + self.screen.blit(pt, (panel.x + 20, y)) + lines = wrap_text(" " + pdesc, self.small, panel.w - 40) + for i, ln in enumerate(lines): + self.screen.blit(self.small.render(ln, True, TEXT), + (panel.x + 20, y + 24 + i * 20)) + y += 26 + len(lines) * 20 + 2 + for i, sk in enumerate(u.skills): + bracket = _bracket_text(sk) + label = sk.name + if bracket: + label += f" [{bracket}]" + if sk.cooldown: + if sk.cooldown_left > 0: + note = f"{sk.cooldown_left} turn{'s' if sk.cooldown_left > 1 else ''} left" + else: + note = "ready" + label += f" CD {sk.cooldown} ({note})" + is_inj = i == inj_idx + st = self.bold.render(label, True, ACCENT if is_inj else HINT) + self.screen.blit(st, (panel.x + 20, y)) + if is_inj: + tag = self.tiny.render("injected", True, ACCENT) + self.screen.blit(tag, (panel.x + 20 + st.get_width() + 8, y + 4)) + d = self.small.render(" " + sk.description, True, ACCENT if is_inj else TEXT) + self.screen.blit(d, (panel.x + 20, y + 24)) + y += 54 + + y += 4 + st_head = self.bold.render("Current status", True, HINT) + self.screen.blit(st_head, (panel.x + 20, y)) + y += 26 + statuses = self._status_text(u) + if statuses: + for ln in self._wrap_text(statuses, self.small, panel.w - 40)[:5]: + self.screen.blit(self.small.render(ln, True, (255, 160, 120)), + (panel.x + 20, y)) + y += 20 + else: + self.screen.blit(self.small.render("No active buffs or debuffs", True, MUTED), + (panel.x + 20, y)) + ch = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(ch, ch.get_rect(center=(panel.centerx, panel.bottom - 18))) + + @staticmethod + def _wrap_text(text, font, width): + words = text.split() + lines = [] + cur = "" + for w in words: + if font.size(cur + " " + w)[0] > width and cur: + lines.append(cur) + cur = w + else: + cur = (cur + " " + w).strip() + if cur: + lines.append(cur) + return lines + + def _draw_stat_line(self, x, y, tokens): + """Draw 'Label value' tokens, appending a muted '(base N)' when the + current value differs from the base (buffs/debuffs in effect).""" + x_pos = x + for label, cur, base in tokens: + txt = self.small.render(f"{label} {cur}", True, TEXT) + self.screen.blit(txt, (x_pos, y)) + x_pos += txt.get_width() + if cur != base: + btxt = self.tiny.render(f" (base {base})", True, MUTED) + self.screen.blit(btxt, (x_pos, y + 3)) + x_pos += btxt.get_width() + x_pos += 18 + + def _draw_skill_bar(self): + b = self.battle + pygame.draw.rect(self.screen, BAR_BG, (16, SKILL_Y - 4, WIN_W - 32, SKILL_H + 8), border_radius=8) + mouse = self.mouse_pos + if b.winner is not None: + msg = "VICTORY!" if b.winner == TEAM_PLAYER else "DEFEAT..." + t = self.font.render(msg, True, HINT) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, SKILL_Y + SKILL_H // 2 - 12))) + if self.reward: + verb = "won" if b.winner == TEAM_PLAYER else "earned" + pt = self.tiny.render(f"You {verb}: {', '.join(self._reward_labels())}!", True, TEXT) + self.screen.blit(pt, pt.get_rect(center=(WIN_W // 2, SKILL_Y + SKILL_H // 2 + 14))) + return + if b.current is None or b.current.team != TEAM_PLAYER: + t = self.small.render("Enemy turn...", True, (220, 160, 160)) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, SKILL_Y + SKILL_H // 2))) + return + unit = b.current + oblivion = isinstance(unit.status.get("oblivion"), tuple) and unit.status["oblivion"][0] > 0 + oblivion_skill = None + if oblivion: + ready = [s for s in unit.skills if s.cooldown_left <= 0] + if ready: + oblivion_skill = min(ready, key=lambda s: s.power) + for i, (sk, rect) in enumerate(self.skill_rects): + ready = sk.cooldown_left <= 0 + blocked = oblivion and sk is not oblivion_skill + if sk is self.pending_skill: + bg = (120, 120, 40) + elif ready and not blocked: + bg = BTN if not rect.collidepoint(mouse) else BTN_HOVER + else: + bg = LOCKED + pygame.draw.rect(self.screen, bg, rect, border_radius=8) + if sk is self.pending_skill: + border = SEL + elif blocked: + border = (90, 90, 90) + elif i == self.injected_index: + border = ACCENT + elif ready: + border = (150, 190, 150) + else: + border = (70, 74, 70) + pygame.draw.rect(self.screen, border, rect, 2, border_radius=8) + name_color = ACCENT if i == self.injected_index and not blocked else TEXT + name = self.small.render(sk.name, True, name_color) + self.screen.blit(name, name.get_rect(midtop=(rect.centerx, rect.y + 6))) + if sk.kind == "damage": + sub = f"{sk.power} dmg" + elif sk.kind == "heal": + sub = "full heal" if sk.mechanic == "full_heal" else f"heal {sk.power}" + elif sk.kind == "shield": + sub = f"shield {sk.power}" + else: + sub = None + if sub is None: + for eff in sk.effects: + if eff.kind == EFF_HOT: + sub = f"heal {eff.value}/t" + break + if eff.kind == EFF_DOT: + sub = f"dmg {eff.value}/t" + break + else: + sub = "utility" + if sk.target == "all_enemies": + sub += " · all" + elif sk.target == "two_enemies": + sub += " · 2" + elif sk.target == "all_allies": + sub += " · allies" + elif sk.target == "any": + sub += " · any" + if blocked: + sub += " · Oblivion" + elif ready: + sub += " · Ready" + else: + sub += f" · CD {sk.cooldown_left}" + sub_surf = self.tiny.render(sub, True, (150, 150, 150) if blocked else (HINT if ready else (140, 140, 140))) + self.screen.blit(sub_surf, sub_surf.get_rect(midtop=(rect.centerx, rect.y + 28))) + + hovered = next((sk for sk, rect in self.skill_rects if rect.collidepoint(mouse)), None) + desc_skill = hovered or self.pending_skill + if desc_skill is not None: + desc = f"{desc_skill.name}: {desc_skill.description}" + color = HINT + elif self.pending_skill: + desc = "Choose a target (green cards)" + color = HINT + else: + pname = PASSIVES.get(unit.passive, (unit.passive or "", ""))[0] + pfx = f" · {pname}" if pname else "" + desc = f"{unit.name}{pfx}'s turn — pick a skill, or click an enemy to attack" + color = TEXT + if oblivion: + desc = "Oblivion: only the weakest skill can be used!" + color = (235, 130, 130) + t = self.small.render(desc, True, color) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, SKILL_Y + 62))) + + def _draw_log(self): + b = self.battle + pygame.draw.rect(self.screen, LOG_BG, self.log_rect) + header = self.relics and self.tower + visible = 3 if header else 4 + y = LOG_Y + 6 + if header: + names = ", ".join(r.name for r in self.relics) + rt = self.tiny.render(f"Blessings: {names} (click to inspect)", True, (245, 200, 90)) + self.screen.blit(rt, (24, y)) + y += 17 + # keep the view anchored on the same entries when new ones arrive + delta = len(b.log) - self._last_log_len + if delta > 0 and self.log_scroll > 0: + self.log_scroll += delta + self._last_log_len = len(b.log) + self.log_scroll = min(self.log_scroll, max(0, len(b.log) - visible)) + end = len(b.log) - self.log_scroll + start = max(0, end - visible) + injected = self._injected_skill_names() + for entry in b.log[start:end]: + if isinstance(entry, dict): + text = entry["text"] + base = (120, 220, 130) if entry["team"] == TEAM_PLAYER else (235, 130, 130) + else: + text = entry + base = (170, 175, 170) + self._blit_log(text, (24, y), base, injected) + y += 17 + if self.log_scroll > 0: + t = self.tiny.render("scroll up for older", True, (140, 150, 160)) + self.screen.blit(t, (WIN_W - 32 - t.get_width(), LOG_Y + LOG_H - 16)) + + def _injected_skill_names(self) -> set: + """Skill names that are injected on any unit in this battle.""" + names = set() + for u in self.battle.units: + if u.injected_name: + names.add(u.injected_name) + return names + + def _blit_log(self, text, pos, base, injected): + x = pos[0] + for segment, is_inj in self._split_injected(text, injected): + surf = self.small.render(segment, True, ACCENT if is_inj else base) + self.screen.blit(surf, (x, pos[1])) + x += surf.get_width() + + def _split_injected(self, text, injected): + spans = [] + for name in injected: + idx = text.find(name) + if idx >= 0: + spans.append((idx, idx + len(name))) + if not spans: + return [(text, False)] + spans.sort() + parts = [] + pos = 0 + for s, e in spans: + if s > pos: + parts.append((text[pos:s], False)) + parts.append((text[s:e], True)) + pos = e + if pos < len(text): + parts.append((text[pos:], False)) + return parts \ No newline at end of file diff --git a/ui/menu.py b/ui/menu.py new file mode 100644 index 0000000..31b4b4f --- /dev/null +++ b/ui/menu.py @@ -0,0 +1,2477 @@ +import time + +import pygame + +from assets import icon as icon_path +from assets import load_sprite +from engine import 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, + VARIABLE_TYPE_UNITS, plant_rarity) +from engine.roster import DIFFICULTY_RARITIES +from engine.seeds import PACK_TYPES, REWARD_PACK_TYPES, pack_odds +from engine.skills import EFF_DOT, EFF_HOT, skill_from_dict +from engine.types import ALL_TYPES, type_relations +from engine.unit import ROLE_ATTACKER, ROLE_ICONS, ROLE_NAMES, ROLE_SUPPORT, ROLE_TANK +from ui import scaling +from ui.scaling import (draw_type_icon, fit_surface, load_plot_icon, load_rune_icon, + load_type_icon, make_window, request_quit, to_logical, wrap_text) +from ui.typepopup import draw_type_popup + +WIN_W, WIN_H = 1040, 720 +BG = (24, 30, 24) +TEXT = (230, 235, 230) +MUTED = (150, 155, 150) +HINT = (255, 220, 120) +BTN = (60, 90, 60) +BTN_HOVER = (85, 125, 85) +PANEL = (32, 38, 32) +ACCENT = (120, 220, 190) +DANGER = (150, 70, 70) + +TYPE_COLORS = { + "radiant": (245, 200, 90), + "nocturnal": (150, 120, 220), + "ice": (130, 200, 235), + "garden": (120, 200, 120), + "wildflower": (235, 130, 170), +} +def _draw_star(screen, center, radius, color): + import math + pts = [] + for i in range(10): + ang = -math.pi / 2 + i * math.pi / 5 + r = radius if i % 2 == 0 else radius * 0.45 + pts.append((center[0] + r * math.cos(ang), center[1] + r * math.sin(ang))) + pygame.draw.polygon(screen, color, pts) + + +def _draw_evo_badge(screen, font, center, evo_count): + """Badge showing how many times a plant has been evolved (0/1/2).""" + if evo_count > 0: + bg, border, num = (205, 165, 45), (255, 220, 130), (25, 20, 5) + else: + bg, border, num = (78, 84, 78), (115, 120, 115), (195, 195, 195) + pygame.draw.circle(screen, bg, center, 10) + pygame.draw.circle(screen, border, center, 10, 2) + t = font.render(str(evo_count), True, num) + screen.blit(t, t.get_rect(center=center)) + +OVERVIEW = [ + "A turn-based battle game: 4v4 battles plus a 20-stage Tower.", + "Pick a difficulty stage, field 4 plants, and fight using", + "plant types with type advantages, cooldowns, and debuffs.", + "Open seed packs to earn new plants and duplicates.", + "Water and evolve your plants to make them stronger.", +] + + +def _bracket_text(sk): + """The '[value]' shown for a skill, or None if the skill has no such number.""" + if sk.kind in ("damage", "heal", "shield"): + if sk.mechanic == "full_heal": + return None + return str(sk.power) + for eff in sk.effects: + if eff.kind in (EFF_HOT, EFF_DOT): + return str(eff.value) + return None + + +class Menu: + def __init__(self, page="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") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 36) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.page = page + self.names = list(POOL.keys()) + self.sprites = {n: self._load(n) for n in self.names} + self.water_icon = self._load_icon("currency_icons", "water_drop", 16) + self.fert_icon = self._load_icon("currency_icons", "fertilizer", 16) + self.coin_icon = self._load_icon("currency_icons", "coin", 16) + self._load_main_icons() + self._passive_check_at = 0 + self.rune_icons = {} + self.role_icons = {} + self.owned = collection.owned() + self.discovered = collection.discovered() + self.back_rect = pygame.Rect(16, 16, 90, 34) + self.detail_name = None + self.detail_from = "plants" + self.detail_list = [] + self.detail_index = 0 + self.detail_left = pygame.Rect(18, 300, 36, 48) + self.detail_right = pygame.Rect(WIN_W - 54, 300, 36, 48) + self.grid_hit = [] + self.grid_scroll = 0 + self.grid_scroll_max = 0 + self._prev_page = None + self.water_rect = pygame.Rect(250, 318, 165, 30) + self.evolve_rect = pygame.Rect(425, 318, 165, 30) + 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.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 + self.runes_deconstruct_msg = "" + self.runes_target = None # rune picked to upgrade + self.runes_sacrifice = [] # rune ids picked to sacrifice + self.runes_info = False + self.runes_filter_tier = 0 # 0 all, 1 tier 1, 2 tier 2 + self.runes_filter_color = None + self.runes_sort_best = False + self.runes_filter_rects = [] + self.runes_sort_rect = pygame.Rect(WIN_W - 170, 96, 150, 24) + self.runes_shop_rect = pygame.Rect(WIN_W - 226, 54, 210, 28) + self.runes_shop = False # rune shop window open + self.runes_shop_buy_t1_rect = pygame.Rect(0, 0, 0, 0) + self.runes_shop_buy_t2_rect = pygame.Rect(0, 0, 0, 0) + self.runes_buy_popup = None # rune dict for the "bought" popup + self.runes_buy_msg = "" + self.runes_buy_msg_until = 0 + self.runes_confirm = None + self.runes_confirm_ok = pygame.Rect(0, 0, 0, 0) + self.runes_confirm_no = pygame.Rect(0, 0, 0, 0) + self.runes_result = None # {"name","color","before","after"} reroll popup + self.rune_card_rects = [] + self.last_open = None + self.pack_rects = {} + self.pack5_rects = {} + self.info_rects = {} + self.seed_pack_sprites = {} + self.seeds_scroll = 0 + self.seeds_scroll_max = 0 + self.info_pack = None + 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._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)} + self.floor_bands = { + 1: "Floors 1-100", + 2: "Floors 101-200", + 3: "Floors 201-300", + 4: "Floors 301-400", + 5: "Floors 401-500", + } + self.type_info = None + self.detail_type_rect = None + self.type_tag_rects = {} + self.favs = collection.favorites() + self.star_rect = pygame.Rect(WIN_W - 56, 118, 40, 40) + self.water_mode = False + self.water_mode_btn = pygame.Rect(222, 58, 160, 34) + self.abilities_btn = pygame.Rect(16, 58, 200, 34) + self.abilities_msg = "" + self.abilities_msg_until = 0 + self.inject_remove_rect = pygame.Rect(WIN_W - 160, 208, 144, 26) + self.water_exit_btn = pygame.Rect(WIN_W // 2 - 100, WIN_H - 56, 200, 36) + 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), + } + self.coll_rune_tier = 0 + self.coll_rune_color = None + self.coll_rune_sort_best = False + self.coll_rune_sort_rect = pygame.Rect(WIN_W - 170, 96, 150, 24) + self.coll_rune_filter_rects = [] + self.coll_rune_cards = [] + self.coll_rune_detail = None + self.tower_rect = pygame.Rect(WIN_W // 2 - 130, WIN_H - 92, 260, 58) + self.tower_info_rect = pygame.Rect(WIN_W // 2 + 140, WIN_H - 88, 24, 24) + self.tower_info = False + self.presets_btn = pygame.Rect(WIN_W - 260, 52, 244, 30) + self.coll_sort_btns = { + "rarity": pygame.Rect(598, 50, 66, 28), + "type": pygame.Rect(670, 50, 62, 28), + "role": pygame.Rect(738, 50, 62, 28), + "found": pygame.Rect(806, 50, 78, 28), + } + self.plants_sort = None + self.plants_sort_btns = { + "rarity": pygame.Rect(585, 50, 58, 28), + "type": pygame.Rect(647, 50, 56, 28), + "role": pygame.Rect(707, 50, 56, 28), + "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") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 36) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.sprites = {n: self._load(n) for n in self.names} + self.water_icon = self._load_icon("currency_icons", "water_drop", 16) + self.fert_icon = self._load_icon("currency_icons", "fertilizer", 16) + self.coin_icon = self._load_icon("currency_icons", "coin", 16) + self._load_main_icons() + self.rune_icons = {} + self.seed_pack_sprites = {} + self.seeds_scroll = getattr(self, "seeds_scroll", 0) + + def _load(self, name): + return load_sprite(name, "full") + + def _load_icon(self, category, name, size): + try: + img = pygame.image.load(icon_path(category, name)).convert_alpha() + return fit_surface(img, size) + except (pygame.error, OSError): + return None + + def _load_main_icons(self): + self.main_icons = { + "Plants": self._load_icon("menu_icons", "plants", 36), + "Battle": self._load_icon("menu_icons", "battle", 36), + "Runes": self._load_icon("menu_icons", "runes", 56), + "Seeds": self._load_icon("menu_icons", "seed_pack", 56), + "Soil": self._load_icon("menu_icons", "soil", 56), + "Collection": self._load_icon("menu_icons", "collection", 56), + } + self.tower_icon = self._load_icon("menu_icons", "tower", 28) + + def _rune_icon(self, color, size=16): + key = (color, size) + if key not in self.rune_icons: + self.rune_icons[key] = load_rune_icon(color, size) + return self.rune_icons[key] + + def _role_icon(self, role, size=16): + key = (role, size) + if key not in self.role_icons: + name = ROLE_ICONS.get(role) + self.role_icons[key] = self._load_icon("role_icons", name, size) if name else None + return self.role_icons[key] + + def _fit_text(self, text, font, max_w): + """Truncate `text` to fit within `max_w` pixels, appending an ellipsis.""" + if font.size(text)[0] <= max_w: + return text + while text and font.size(text + "...")[0] > max_w: + text = text[:-1] + return (text + "...") if text else "" + + def _layout_buttons(self): + # Bottom row: Plants and Battle side by side. + bw, bh, gap = 200, 54, 20 + total = 2 * bw + gap + x0 = (WIN_W - total) // 2 + y = WIN_H - 72 + self.buttons = { + "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. + # 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), + "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["Collection"] = pygame.Rect(rx, ry + sq + sq_gap, sq, sq) + + def _seed_pack_sprite(self, pt, size=110): + """Seed pack sprite scaled to fit a `size` box (aspect preserved), cached.""" + key = (pt, size) + if key not in self.seed_pack_sprites: + try: + img = pygame.image.load( + icon_path("seed_packs/closed", f"{pt}_seedpack")).convert_alpha() + img = fit_surface(img, size) + except (pygame.error, OSError): + img = None + self.seed_pack_sprites[key] = img + return self.seed_pack_sprites[key] + + def _layout_packs(self): + cw, ch, gap = 210, 200, 14 + cols = 4 + total = cols * cw + (cols - 1) * gap + x0 = (WIN_W - total) // 2 + y0 = 120 + for i, pt in enumerate(PACK_TYPES): + x = x0 + (i % cols) * (cw + gap) + y = y0 + (i // cols) * (ch + gap) + card = pygame.Rect(x, y, cw, ch) + btn = pygame.Rect(x + 8, y + ch - 28, 62, 24) + btn5 = pygame.Rect(x + 74, y + ch - 28, 62, 24) + self.pack_rects[pt] = (card, btn) + self.pack5_rects[pt] = btn5 + self.info_rects[pt] = pygame.Rect(x + cw - 26, y + 8, 18, 18) + rows = (len(PACK_TYPES) + cols - 1) // cols + grid_h = rows * (ch + gap) - gap + content_h = grid_h + 16 + 110 + self.seeds_grid_y0 = y0 + self.seeds_grid_h = grid_h + self.seeds_scroll_area = pygame.Rect(0, 96, WIN_W, WIN_H - 96 - 8) + self.seeds_scroll_max = max(0, content_h - self.seeds_scroll_area.h) + self.seeds_scroll = getattr(self, "seeds_scroll", 0) + self.seeds_scroll = max(0, min(self.seeds_scroll, self.seeds_scroll_max)) + + def run(self): + running = True + result = None + while running: + if scaling.QUIT_REQUESTED: + running = False + break + self.clock.tick(60) + self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale) + if pygame.time.get_ticks() >= self._passive_check_at: + collection.accrue_coins() + collection.accrue_fertilizer() + collection.auto_farm() + self._passive_check_at = pygame.time.get_ticks() + 1000 + if self.page != self._prev_page: + self.grid_scroll = 0 + self.seeds_scroll = 0 + if self.page != "runes": + self.runes_deconstruct = False + self.runes_deconstruct_msg = "" + self.runes_target = None + self.runes_sacrifice = [] + self.runes_info = False + self.runes_confirm = None + self.runes_result = None + self.runes_buy_popup = None + self.runes_buy_msg = "" + self._prev_page = self.page + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + choice = self._click(to_logical(event.pos, self.scale)) + if isinstance(choice, int) or choice == "tower": + result = choice + running = False + elif event.type == pygame.MOUSEWHEEL: + if self.page == "seeds": + self.seeds_scroll = max(0, min(self.seeds_scroll_max, + self.seeds_scroll - event.y * 60)) + elif self.page in ("plants", "collection", "runes"): + self.grid_scroll = max(0, min(self.grid_scroll_max, + self.grid_scroll - event.y * 50)) + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + return result + + def _click(self, pos): + if self.info_pack is not None: + self.info_pack = None + return None + 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 rect.collidepoint(pos): + if collection.convert_water_to_pack(pt): + self.owned = collection.owned() + self.convert_mode = False + return None + self.convert_mode = False + return None + if self.page == "seeds": + if self.history_open: + self.history_open = False + return None + if self.back_rect.collidepoint(pos): + self.page = "menu" + return None + if self.history_rect.collidepoint(pos): + self.history_open = True + return None + if self.redeem_rect.collidepoint(pos): + collection.redeem_tower_pack() + 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) + if result: + self.last_open = result + self.owned = collection.owned() + self.discovered = collection.discovered() + return None + if self.pack5_rects[pt].move(0, -self.seeds_scroll).collidepoint(pos): + if collection.pack_counts().get(pt, 0) >= 5: + results = collection.open_packs(pt, 5) + if results: + self.last_open = results + self.owned = collection.owned() + self.discovered = collection.discovered() + return None + if self.info_rects[pt].move(0, -self.seeds_scroll).collidepoint(pos): + self.info_pack = pt + return None + return None + if self.page in ("plants", "collection"): + if self.back_rect.collidepoint(pos): + self.page = "menu" + return None + if self.page == "collection": + if self.coll_rune_detail is not None: + self.coll_rune_detail = None + return None + for key, rect in self.collection_tab_btns.items(): + if rect.collidepoint(pos): + self.collection_tab = key + return None + if self.collection_tab == "runes": + if self._click_coll_rune_filter(pos): + return None + owned_colors = {r["color"] for r in collection.rune_inventory()} + for color, rect in self.coll_rune_cards: + if rect.collidepoint(pos) and color in owned_colors: + self.coll_rune_detail = color + return None + return None + if self.page == "plants": + if self.water_info: + self.water_info = False + return None + for key, rect in self.plants_sort_btns.items(): + if rect.collidepoint(pos): + self.plants_sort = None if self.plants_sort == key else key + return None + if self.water_info_rect.collidepoint(pos): + self.water_info = True + return None + if self.abilities_btn.collidepoint(pos): + if collection.get_tower_best() >= collection.EXTRACT_GATE: + from ui.extract import ExtractionScreen + ExtractionScreen().run() + self._reinit() # the extract screen quit pygame + self.owned = collection.owned() + self.favs = collection.favorites() + else: + self.abilities_msg = f"Reach Tower stage {collection.EXTRACT_GATE} to unlock" + self.abilities_msg_until = pygame.time.get_ticks() + 2000 + return None + if self.water_mode_btn.collidepoint(pos): + 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 + for name, rect in self.grid_hit: + if rect.collidepoint(pos): + collection.water(name) + return None + return None + for name, rect in self.type_tag_rects.items(): + if rect.collidepoint(pos) and name not in VARIABLE_TYPE_UNITS: + self.type_info = POOL[name]["type"] + return None + for name, rect in self.grid_hit: + if rect.collidepoint(pos): + self.detail_name = name + self.detail_from = self.page + self.detail_list = [n for n, _ in self.grid_hit] + self.detail_index = self.detail_list.index(name) + self.page = "plant_detail" + return None + return None + if self.page == "plant_detail": + if self.star_rect.collidepoint(pos) and self.detail_name: + collection.toggle_favorite(self.detail_name) + self.favs = collection.favorites() + return None + if self.detail_type_rect and self.detail_type_rect.collidepoint(pos) and self.detail_name \ + and self.detail_name not in VARIABLE_TYPE_UNITS: + self.type_info = POOL[self.detail_name]["type"] + return None + for pt, rect in self.type_choice_rects.items(): + if rect.collidepoint(pos) and self.detail_name: + collection.set_unit_type(self.detail_name, pt) + return None + if self.back_rect.collidepoint(pos): + self.page = self.detail_from + elif self.detail_left.collidepoint(pos): + self._nav_detail(-1) + elif self.detail_right.collidepoint(pos): + self._nav_detail(1) + elif self.water_rect.collidepoint(pos) and self.detail_name and self.detail_from != "collection": + collection.water(self.detail_name) + elif self.evolve_rect.collidepoint(pos) and self.detail_name and self.detail_from != "collection": + collection.evolve(self.detail_name) + elif self.seed_rect.collidepoint(pos) and self.detail_name and self.detail_from != "collection": + collection.retrieve_seed(self.detail_name) + elif (self.runes_rect.collidepoint(pos) and self.detail_name + and self.detail_from != "collection"): + from ui.runes import RuneScreen + RuneScreen(self.detail_name).run() + if not scaling.QUIT_REQUESTED: + self._reinit() # RuneScreen re-created the window + elif (self.inject_remove_rect.collidepoint(pos) and self.detail_name + and self.detail_from != "collection" and collection.injection_of(self.detail_name)): + collection.remove_injection(self.detail_name) + return None + if self.page == "battle_setup": + if self.tower_info: + self.tower_info = False + return None + if self.back_rect.collidepoint(pos): + self.page = "menu" + return None + if self.tower_info_rect.collidepoint(pos): + self.tower_info = not self.tower_info + return None + if self.presets_btn.collidepoint(pos): + from ui.presets import PresetScreen + PresetScreen().run() + self._reinit() # PresetScreen quit pygame + return None + if self.tower_rect.collidepoint(pos): + return "tower" + for band, rect in self.floor_rects.items(): + if rect.collidepoint(pos) and band == 1 \ + and collection.floor_progress() <= collection.MAX_FLOOR: + return collection.floor_progress() + return None + if self.page == "soil": + if self.soil_msg is not None: + self.soil_msg = None # click closes the harvest popup + return None + if self.soil_fert_info: + self.soil_fert_info = False + return None + if self.soil_fert_info_rect.collidepoint(pos): + self.soil_fert_info = True + return None + if self.back_rect.collidepoint(pos): + self.page = "menu" + return None + if self.auto_farmer_rect.collidepoint(pos): + if not collection.auto_farmer_owned(): + reason = collection.buy_auto_farmer() + self.soil_notice = reason or "Auto farmer bought!" + else: + active = collection.toggle_auto_farmer() + self.soil_notice = "Auto farmer " + ( + "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) + return None + for idx, rect in self.soil_water_rects: + if rect.collidepoint(pos): + collection.water_plot(idx) + return None + for idx, rect in self.soil_harvest_rects: + if rect.collidepoint(pos): + state = collection.soil_state() + if 0 <= idx < len(state["plots"]): + plant = state["plots"][idx]["plant"] + if collection.harvest_plot(idx) == "": + self.owned = collection.owned() + self.soil_msg = { + "plant": plant, + "duplicates": collection.get_progress(plant)["duplicates"], + } + return None + for idx, rect in self.soil_unlock_rects: + if rect.collidepoint(pos): + reason = collection.unlock_plot() + if reason: + self.soil_notice = reason + self.soil_notice_until = pygame.time.get_ticks() + 1800 + return None + return None + if self.page == "runes": + if self.runes_buy_popup is not None: + self.runes_buy_popup = None + return None + if self.runes_shop: + if self.runes_shop_buy_t1_rect.collidepoint(pos): + self._buy_rune(1) + elif self.runes_shop_buy_t2_rect.collidepoint(pos): + self._buy_rune(2) + else: + self.runes_shop = False + return None + if self.runes_result is not None: + self.runes_result = None + return None + if self.runes_info: + self.runes_info = False + return None + if self.runes_confirm is not None: + if self.runes_confirm_ok.collidepoint(pos): + rune_id = self.runes_confirm["rune"]["id"] + before = self.runes_confirm["rune"].get("pct", 0) + err = collection.reroll_rune(rune_id, self.runes_confirm["sacrifice"]) + self.runes_deconstruct_msg = err or "" + if not err: + after = next((r["pct"] for r in collection.rune_inventory() + if r["id"] == rune_id), before) + self.runes_result = { + "name": self.runes_confirm["rune"]["name"], + "color": self.runes_confirm["rune"]["color"], + "before": before, + "after": after, + } + self.runes_deconstruct = False + self.runes_target = None + self.runes_sacrifice = [] + self.runes_confirm = None + elif self.runes_confirm_no.collidepoint(pos): + self.runes_confirm = None + return None + if self.back_rect.collidepoint(pos): + self.page = "menu" + return None + if self.runes_info_rect.collidepoint(pos): + self.runes_info = True + return None + if self.runes_deconstruct_rect.collidepoint(pos): + self.runes_deconstruct = not self.runes_deconstruct + self.runes_deconstruct_msg = "" + self.runes_target = None + self.runes_sacrifice = [] + return None + if self.runes_shop_rect.collidepoint(pos): + self.runes_shop = True + self.runes_info = False + self.runes_buy_msg = "" + return None + if self._click_runes_filter(pos): + return None + if self.runes_deconstruct: + self._click_runes_deconstruct(pos) + return None + if self.page != "menu": + if self.back_rect.collidepoint(pos): + self.page = "menu" + return None + for label, rect in self.buttons.items(): + if rect.collidepoint(pos): + if label == "Battle": + self.page = "battle_setup" + return None + self.page = label.lower() + return None + for label, rect in self.side_buttons.items(): + if rect.collidepoint(pos): + if label == "Soil" and not collection.soil_unlocked(): + self.soil_msg = None + return None + self.page = label.lower() + return None + return None + + def _draw(self): + if self.water_mode and (self.page != "plants" or collection.available_water() <= 0): + self.water_mode = False + self.screen.fill(BG) + if self.page == "menu": + self._draw_menu() + elif self.page == "battle_setup": + self._draw_battle_setup() + elif self.page == "seeds": + self._draw_seeds() + elif self.page == "plant_detail": + self._draw_detail() + elif self.page == "plants": + self._draw_plants() + elif self.page == "soil": + self._draw_soil() + elif self.page == "runes": + self._draw_runes() + else: + self._draw_collection() + if self.info_pack is not None: + self._draw_pack_info() + 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.tower_info: + self._draw_tower_info() + if self.soil_msg: + self._draw_soil_msg() + if self.soil_fert_info: + self._draw_fert_info() + if self.water_info: + self._draw_water_info() + + def _nav_detail(self, step): + if not self.detail_list: + return + if not (0 <= self.detail_index < len(self.detail_list)): + self.detail_index = 0 + self.detail_index = (self.detail_index + step) % len(self.detail_list) + self.detail_name = self.detail_list[self.detail_index] + self.type_info = None + self.coll_rune_detail = None + self.type_choice_rects = {} + + def _draw_resources(self): + """Small boxes along the top of the start screen showing the current + coin, water, and fertilizer balances. The coin box shows its cap and a + countdown to the next coin until the cap is reached.""" + data = collection.load() + coins = data.get("coins", 0) + items = [ + ("Coins", coins, self.coin_icon), + ("Water", data.get("water", 0), self.water_icon), + ("Fertilizer", data.get("fertilizer", 0), self.fert_icon), + ] + bw, bh, gap = 144, 44, 14 + total = len(items) * bw + (len(items) - 1) * gap + x0 = (WIN_W - total) // 2 + y = 16 + for i, (label, val, icon) in enumerate(items): + rect = pygame.Rect(x0 + i * (bw + gap), y, bw, bh) + pygame.draw.rect(self.screen, PANEL, rect, border_radius=8) + pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=8) + if icon: + self.screen.blit(icon, (rect.x + 12, rect.centery - 8)) + if label == "Coins": + txt = self.small.render(f"{coins}/{collection.COIN_CAP}", True, TEXT) + elif label == "Fertilizer": + txt = self.small.render(f"{val}/{collection.FERTILIZER_CAP}", True, TEXT) + else: + txt = self.small.render(str(val), True, TEXT) + self.screen.blit(txt, (rect.x + 36, rect.centery - txt.get_height() // 2)) + if coins < collection.COIN_CAP: + last = data.get("coins_accrued_at", 0.0) + if last > 0: + secs = max(1, int(60 - (time.time() - last) % 60)) + nt = self.tiny.render(f"Next in {secs // 60}:{secs % 60:02d}", True, MUTED) + self.screen.blit(nt, nt.get_rect(midtop=(x0 + bw // 2, y + bh + 2))) + fert = data.get("fertilizer", 0) + if fert < collection.FERTILIZER_CAP: + last = data.get("fertilizer_accrued_at", 0.0) + if last > 0: + secs = max(1, int(3600 - (time.time() - last) % 3600)) + hh, rem = divmod(secs, 3600) + mm, ss = divmod(rem, 60) + nt = self.tiny.render(f"Next in {hh:02d}:{mm:02d}:{ss:02d}", True, MUTED) + fert_x = x0 + 2 * (bw + gap) + bw // 2 + self.screen.blit(nt, nt.get_rect(midtop=(fert_x, y + bh + 2))) + + def _draw_menu(self): + self._draw_resources() + title = self.font.render("Plants", True, TEXT) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 86))) + y = 130 + for line in OVERVIEW: + t = self.small.render(line, True, MUTED) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, y))) + y += 24 + mouse = self.mouse_pos + # bottom row: Plants + Battle + for label, rect in self.buttons.items(): + hover = rect.collidepoint(mouse) + if label == "Battle": + bg = (90, 140, 90) if hover else BTN_HOVER + else: + bg = BTN_HOVER if hover else BTN + pygame.draw.rect(self.screen, bg, rect, border_radius=12) + pygame.draw.rect(self.screen, (150, 190, 150), rect, 2, border_radius=12) + ic = self.main_icons.get(label) + if ic: + self.screen.blit(ic, ic.get_rect(center=rect.center)) + 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) + soil_locked = not collection.soil_unlocked() + for label, rect in self.side_buttons.items(): + hover = rect.collidepoint(mouse) + is_soil = label == "Soil" + if is_soil and soil_locked: + bg = (45, 50, 45) + border = (70, 80, 70) + else: + bg = BTN_HOVER if hover else BTN + border = (150, 190, 150) + pygame.draw.rect(self.screen, bg, rect, border_radius=18) + pygame.draw.rect(self.screen, border, rect, 2, border_radius=18) + ic = self.main_icons.get(label) + if ic: + ipos = ic.get_rect(center=rect.center) + if is_soil and soil_locked: + # Dim only the icon's opaque pixels (silhouette) so the + # transparent background stays clear. + shade = pygame.Surface(ic.get_size(), pygame.SRCALPHA) + shade.fill((0, 0, 0, 150)) + shade.blit(ic, (0, 0), special_flags=pygame.BLEND_RGBA_MIN) + self.screen.blit(ic, ipos) + self.screen.blit(shade, ipos) + lt = self.bold.render("Soil", True, (235, 100, 100)) + self.screen.blit(lt, lt.get_rect(center=rect.center)) + else: + self.screen.blit(ic, ipos) + else: + t = self.bold.render(label, True, TEXT) + self.screen.blit(t, t.get_rect(center=rect.center)) + if is_soil: + self.soil_btn_locked = soil_locked + if soil_locked: + self.soil_btn_locked = True + 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))) + + def _draw_battle_setup(self): + self._draw_page("Select Floors", + "Pick a floor range to fight in, or take on the Tower") + mouse = self.mouse_pos + current = collection.floor_progress() + for band, rect in self.floor_rects.items(): + hover = rect.collidepoint(mouse) + playable = band == 1 and current <= collection.MAX_FLOOR + bg = (90, 120, 90) if (hover and playable) else BTN + pygame.draw.rect(self.screen, bg, rect, border_radius=8) + pygame.draw.rect(self.screen, (150, 190, 150) if playable else (70, 80, 70), + rect, 2, border_radius=8) + label = self.floor_bands[band] + t = self.bold.render(label, True, TEXT if playable else MUTED) + self.screen.blit(t, (rect.x + 16, rect.y + 8)) + if band == 1: + sub = self.tiny.render(f"Your floor: {current}/{collection.MAX_FLOOR}" + f" · Win: packs + floor up", True, MUTED) + else: + sub = self.tiny.render("Coming soon", True, (110, 115, 110)) + self.screen.blit(sub, (rect.x + 16, rect.y + 32)) + tower_hover = self.tower_rect.collidepoint(mouse) + pygame.draw.rect(self.screen, (90, 120, 90) if tower_hover else BTN, + self.tower_rect, border_radius=8) + pygame.draw.rect(self.screen, (245, 200, 90), self.tower_rect, 2, border_radius=8) + tt = self.bold.render("Tower — 20 stages", True, TEXT) + tic = self.tower_icon + row1_y = self.tower_rect.y + 16 + if tic: + total_w = tic.get_width() + 6 + tt.get_width() + tx = self.tower_rect.centerx - total_w // 2 + self.screen.blit(tic, tic.get_rect(midleft=(tx, row1_y + tt.get_height() // 2))) + self.screen.blit(tt, (tx + tic.get_width() + 6, row1_y)) + else: + self.screen.blit(tt, tt.get_rect(center=(self.tower_rect.centerx, row1_y + tt.get_height() // 2))) + from engine.collection import tower_max_stage + mst = tower_max_stage() + ts = self.tiny.render(f"Stage {mst} open · Water + packs · Best {collection.get_tower_best()}", + True, MUTED) + self.screen.blit(ts, ts.get_rect(center=(self.tower_rect.centerx, self.tower_rect.y + 40))) + ihov = self.tower_info_rect.collidepoint(mouse) + pygame.draw.circle(self.screen, (115, 135, 115) if ihov else (70, 82, 70), + self.tower_info_rect.center, 12) + pygame.draw.circle(self.screen, (175, 195, 175), self.tower_info_rect.center, 12, 1) + itxt = self.tiny.render("i", True, TEXT) + self.screen.blit(itxt, itxt.get_rect(center=self.tower_info_rect.center)) + pygame.draw.rect(self.screen, BTN, self.presets_btn, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150), self.presets_btn, 1, border_radius=6) + ptxt = self.tiny.render("Team presets (P1–P4)", True, TEXT) + self.screen.blit(ptxt, ptxt.get_rect(center=self.presets_btn.center)) + + def _draw_tower_info(self): + from engine.collection import tower_max_stage + mst = tower_max_stage() + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 160)) + self.screen.blit(overlay, (0, 0)) + w, h = 440, 360 + 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, (245, 200, 90), panel, 2, border_radius=12) + title = self.bold.render("Tower access", True, HINT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + sub = self.tiny.render(f"You can currently reach stage {mst}", True, MUTED) + self.screen.blit(sub, sub.get_rect(center=(panel.centerx, panel.y + 56))) + rows = [ + ("1 - 5", "Always available"), + ("6 - 10", "Own at least one evolved plant"), + ("11 - 15", "Own at least one max-level plant"), + ("16 - 20", "Own a max-level plant and any injected plant"), + ] + y = panel.y + 96 + for band, req in rows: + rar = HINT if mst >= int(band.split(" - ")[1]) else MUTED + b = self.bold.render(band, True, rar) + self.screen.blit(b, (panel.x + 34, y)) + r = self.small.render(req, True, TEXT if rar == HINT else MUTED) + self.screen.blit(r, (panel.x + 130, y)) + y += 34 + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 20))) + + def _draw_page(self, title_text, subtitle): + t = self.font.render(title_text, True, TEXT) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 52))) + t = self.small.render(subtitle, True, MUTED) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 84))) + pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) + t = self.small.render("Back", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.back_rect.center)) + + def _draw_seeds(self): + self._draw_page("Seeds", "Win packs scale with difficulty · Tower packs have high rarity") + packs = collection.pack_counts() + tcol_lesser = (150, 150, 150) + tcol_tower = (245, 200, 90) + tpacks = packs.get("tower", 0) + can_redeem = tpacks > 0 + pygame.draw.rect(self.screen, BTN_HOVER if can_redeem else (45, 50, 45), + self.redeem_rect, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150) if can_redeem else (70, 80, 70), + self.redeem_rect, 1, border_radius=6) + rtxt = f"Redeem tower pack (+10 water) · {tpacks}" if can_redeem else "No tower packs" + r = self.tiny.render(rtxt, True, TEXT if can_redeem else MUTED) + self.screen.blit(r, r.get_rect(center=self.redeem_rect.center)) + mouse = self.mouse_pos + h_hover = self.history_rect.collidepoint(mouse) + pygame.draw.rect(self.screen, (90, 120, 90) if h_hover else BTN, + self.history_rect, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150), self.history_rect, 1, border_radius=6) + ht = self.tiny.render("History (last 10)", True, TEXT) + self.screen.blit(ht, ht.get_rect(center=self.history_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) + for pt, (card, btn) in self.pack_rects.items(): + rect = card.move(0, -self.seeds_scroll) + if not rect.colliderect(self.seeds_scroll_area): + continue + pygame.draw.rect(self.screen, PANEL, rect, border_radius=8) + pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=8) + spr = self._seed_pack_sprite(pt) + if spr: + self.screen.blit(spr, spr.get_rect(center=(rect.centerx, rect.y + 112))) + if pt == "tower": + tcol = tcol_tower + elif pt == "lesser": + tcol = tcol_lesser + else: + tcol = TYPE_COLORS.get(pt, TEXT) + t = self.bold.render(pt.capitalize(), True, tcol) + self.screen.blit(t, (rect.x + 8, rect.y + 8)) + cnt = packs.get(pt, 0) + c = self.small.render(f"x{cnt}", True, HINT) + self.screen.blit(c, (rect.right - 40, rect.y + 8)) + if pt == "lesser": + sub = "Any type, worse odds" + elif pt == "tower": + sub = "Any type, high rarity" + elif pt == "general": + sub = "Any plant type" + else: + sub = f"{pt} type boosted" + s = self.tiny.render(sub, True, MUTED) + self.screen.blit(s, (rect.x + 8, rect.y + 30)) + info = self.info_rects[pt].move(0, -self.seeds_scroll) + ihov = info.collidepoint(mouse) + icol = (115, 135, 115) if ihov else (70, 82, 70) + pygame.draw.circle(self.screen, icol, info.center, 9) + pygame.draw.circle(self.screen, (175, 195, 175), info.center, 9, 1) + itxt = self.tiny.render("i", True, TEXT) + self.screen.blit(itxt, itxt.get_rect(center=info.center)) + b = btn.move(0, -self.seeds_scroll) + active = cnt > 0 + pygame.draw.rect(self.screen, BTN_HOVER if active else (45, 50, 45), b, border_radius=6) + t = self.tiny.render("Open", True, TEXT if active else MUTED) + self.screen.blit(t, t.get_rect(center=b.center)) + b5 = self.pack5_rects[pt].move(0, -self.seeds_scroll) + active5 = cnt >= 5 + pygame.draw.rect(self.screen, (100, 100, 60) if active5 else (45, 50, 45), b5, border_radius=6) + t5 = self.tiny.render("Open x5", True, TEXT if active5 else MUTED) + self.screen.blit(t5, t5.get_rect(center=b5.center)) + self.screen.set_clip(old_clip) + if self.seeds_scroll_max > 0: + hint = ("▲" if self.seeds_scroll > 0 else "") + ( + "▼" if self.seeds_scroll < self.seeds_scroll_max else "") + if hint: + ht = self.tiny.render(hint, True, MUTED) + self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 8))) + # "Last opened" result sits just below the pack grid. + self._draw_last_open(self.seeds_grid_y0 + self.seeds_grid_h + 22 - self.seeds_scroll) + if self.history_open: + self._draw_pack_history() + + def _draw_last_open(self, y): + t = self.small.render("Last opened:", True, MUTED) + self.screen.blit(t, (60, y)) + if self.last_open: + batch = self.last_open if isinstance(self.last_open, list) else [self.last_open] + x = 60 + for plant, is_new, bonus in batch: + spr = self.sprites.get(plant) + if spr: + ic = fit_surface(spr, 40) + self.screen.blit(ic, ic.get_rect(center=(x + 20, y + 28))) + rcol = RARITY_COLORS.get(plant_rarity(plant), TEXT) + nm = self.tiny.render(plant, True, rcol) + self.screen.blit(nm, (x, y + 52)) + if is_new: + nt = self.tiny.render("NEW", True, HINT) + self.screen.blit(nt, (x + 44, y + 10)) + x += 132 + total = len(batch) + n_new = sum(1 for r in batch if r[1]) + if total == 1: + status = "NEW plant!" if n_new else "Duplicate (+1)" + else: + status = f"Opened {total} — {n_new} new, {total - n_new} duplicate(s)" + col = HINT if n_new else MUTED + st = self.small.render(status, True, col) + self.screen.blit(st, (60, y + 90)) + rune_bonus = [r[2] for r in batch if r[2] is not None] + if rune_bonus: + rx = 60 + for r in rune_bonus: + ic = self._rune_icon(r["color"]) + if ic: + self.screen.blit(ic, (rx, y + 111)) + rn = self.tiny.render(r["name"], True, ACCENT) + self.screen.blit(rn, (rx + 20, y + 113)) + rx += 20 + rn.get_width() + 12 + + def _draw_pack_history(self): + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 160)) + self.screen.blit(overlay, (0, 0)) + w, h = 620, 430 + 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) + title = self.bold.render("Last 10 packs opened", True, HINT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + history = collection.open_history() + if not history: + t = self.small.render("No packs opened yet.", True, MUTED) + self.screen.blit(t, t.get_rect(center=(panel.centerx, panel.y + 100))) + else: + y = panel.y + 64 + for rec in reversed(history[-10:]): + rect = pygame.Rect(panel.x + 20, y, panel.w - 40, 30) + pygame.draw.rect(self.screen, (40, 48, 40), rect, border_radius=6) + rcol = RARITY_COLORS.get(plant_rarity(rec["plant"]), TEXT) + nm = self.small.render(rec["plant"], True, rcol) + self.screen.blit(nm, (rect.x + 10, rect.y + 6)) + if rec["is_new"]: + nt = self.tiny.render("NEW", True, HINT) + self.screen.blit(nt, (rect.x + 10 + nm.get_width() + 8, rect.y + 8)) + if rec["rune"]: + rt = self.tiny.render(f"+ {rec['rune']}", True, ACCENT) + self.screen.blit(rt, rt.get_rect(midright=(rect.right - 10, rect.centery))) + y += 36 + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 22))) + + def _draw_pack_info(self): + pt = self.info_pack + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 160)) + self.screen.blit(overlay, (0, 0)) + w, h = 360, 360 + 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) + tcol = TYPE_COLORS.get(pt, (150, 150, 150) if pt == "lesser" else TEXT) + title = self.bold.render(f"{pt.capitalize()} pack odds", True, tcol) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 34))) + odds = pack_odds(pt) + y = panel.y + 78 + for rar in ("common", "rare", "super_rare", "super_super_rare", "ultra_rare"): + pct = odds.get(rar, 0.0) * 100.0 + col = RARITY_COLORS.get(rar, TEXT) + name = self.small.render(RARITY_LABELS.get(rar, rar), True, col) + self.screen.blit(name, (panel.x + 40, y)) + bar_w = int(150 * pct / 100.0) + if bar_w > 0: + pygame.draw.rect(self.screen, col, (panel.x + 150, y + 5, bar_w, 10), border_radius=3) + pct_s = self.small.render(f"{pct:.1f}%", True, TEXT) + self.screen.blit(pct_s, (panel.right - 50, y)) + y += 40 + # runes + pygame.draw.line(self.screen, (60, 70, 60), + (panel.x + 30, y + 2), (panel.right - 30, y + 2)) + y += 14 + rn = self.small.render("Runes", True, HINT) + self.screen.blit(rn, (panel.x + 40, y)) + rpct = runes.PACK_DROP_CHANCE * 100.0 + bar_w = int(150 * rpct / 100.0) + if bar_w > 0: + pygame.draw.rect(self.screen, HINT, (panel.x + 150, y + 5, bar_w, 10), border_radius=3) + rp = self.small.render(f"{rpct:.1f}%", True, TEXT) + 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", + True, MUTED) + self.screen.blit(sub, (panel.x + 150, y + 22)) + if pt in ALL_TYPES: + note = self.tiny.render(f"Plants are drawn from the {pt} type when possible", True, MUTED) + self.screen.blit(note, note.get_rect(center=(panel.centerx, panel.bottom - 46))) + 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): + 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 + 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))) + mouse = self.mouse_pos + for pt, rect in self.convert_pack_btns.items(): + 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))) + + def _draw_soil(self): + self._draw_page("Soil", "Retrieve seeds from plants, plant them, and grow copies.") + state = collection.soil_state() + fertilizer = collection.available_fertilizer() + cd = int(state["cd_left"]) + if cd > 0: + status = f"Next seed in {cd}s" + else: + status = "Seed ready — open a plant and hit 'Retrieve Seed'" + seeds_x = 60 + plots_x = 343 + st = self.small.render(status, True, HINT if cd == 0 else MUTED) + self.screen.blit(st, (seeds_x, 120)) + wt = self.small.render(f"Fertilizer: {fertilizer}/{collection.FERTILIZER_CAP}", True, TEXT) + self.screen.blit(wt, (WIN_W - 180, 120)) + if self.fert_icon: + self.screen.blit(self.fert_icon, (WIN_W - 180 + wt.get_width() + 4, 122)) + self.soil_fert_info_rect = pygame.Rect(WIN_W - 40, 116, 22, 22) + ihov = self.soil_fert_info_rect.collidepoint(self.mouse_pos) + pygame.draw.circle(self.screen, (115, 135, 115) if ihov else (70, 82, 70), + self.soil_fert_info_rect.center, 11) + pygame.draw.circle(self.screen, (175, 195, 175), self.soil_fert_info_rect.center, 11, 1) + itxt = self.tiny.render("i", True, TEXT) + self.screen.blit(itxt, itxt.get_rect(center=self.soil_fert_info_rect.center)) + af_owned = collection.auto_farmer_owned() + af_active = collection.auto_farmer_active() + self.auto_farmer_rect = pygame.Rect(WIN_W - 270, 148, 250, 28) + af_hover = self.auto_farmer_rect.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, + (90, 120, 90) if (af_active or af_hover) else BTN, + self.auto_farmer_rect, border_radius=6) + pygame.draw.rect(self.screen, + (150, 190, 150) if af_active else (70, 80, 70), + self.auto_farmer_rect, 1, border_radius=6) + if af_owned: + af_txt = self.tiny.render( + "Auto Farmer: active" if af_active else "Auto Farmer: off", + True, ACCENT if af_active else MUTED) + else: + can_afford = collection.available_coins() >= collection.AUTO_FARMER_COST + af_txt = self.tiny.render( + 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)) + 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))) + + # --- retrieved seeds (unplanted) --- + t = self.bold.render("Seeds retrieved", True, HINT) + self.screen.blit(t, (seeds_x, 160)) + self.soil_seed_rects = [] + y = 190 + if not state["seeds"]: + self.screen.blit(self.small.render("No seeds yet.", True, MUTED), (seeds_x + 10, y)) + for idx, s in enumerate(state["seeds"]): + rect = pygame.Rect(seeds_x, y, 220, 34) + pygame.draw.rect(self.screen, (42, 50, 42), rect, border_radius=6) + pygame.draw.rect(self.screen, (90, 110, 90), rect, 1, border_radius=6) + rar = plant_rarity(s["plant"]) + self.screen.blit(self.small.render(s["plant"], True, RARITY_COLORS.get(rar, TEXT)), + (rect.x + 8, rect.y + 7)) + btn = pygame.Rect(rect.right - 90, rect.y + 4, 82, 26) + pygame.draw.rect(self.screen, BTN_HOVER, btn, border_radius=6) + self.screen.blit(self.tiny.render("Plant", True, TEXT), + self.tiny.render("Plant", True, TEXT).get_rect(center=btn.center)) + self.soil_seed_rects.append((idx, btn)) + y += 40 + + # --- plots --- + self._draw_soil_plots(state, fertilizer) + + def _plot_sprite_name(self, p): + if p["grown"]: + return "soil_ready" + if p["stage"] == 0: + return "soil_seed1" + if p["stage"] == 1: + return "soil_seed2" + return "soil_growth" + + def _draw_soil_plots(self, state, fertilizer): + plots_x = (WIN_W - (2 * 170 + 14)) // 2 + t = self.bold.render(f"Growing ({state['plots_unlocked']}/{state['plots_max']} plots)", + True, HINT) + self.screen.blit(t, (plots_x, 160)) + self.soil_water_rects = [] + self.soil_harvest_rects = [] + self.soil_unlock_rects = [] + tile_w, tile_h, gap = 170, 180, 14 + cols = 2 + y0 = 190 + unlocked = state["plots_unlocked"] + for slot in range(state["plots_max"]): + tx = plots_x + (slot % cols) * (tile_w + gap) + ty = y0 + (slot // cols) * (tile_h + gap) + rect = pygame.Rect(tx, ty, tile_w, tile_h) + if slot < len(state["plots"]): + self._draw_plot_tile(rect, state["plots"][slot], fertilizer, slot) + elif slot < unlocked: + pygame.draw.rect(self.screen, (30, 36, 30), rect, border_radius=10) + pygame.draw.rect(self.screen, (70, 80, 70), rect, 1, border_radius=10) + ic = load_plot_icon("soil_barren", 96) + if ic: + self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 60))) + self.screen.blit(self.small.render("Empty plot", True, MUTED), + (rect.x + 8, rect.y + 132)) + else: + pygame.draw.rect(self.screen, (24, 28, 24), rect, border_radius=10) + pygame.draw.rect(self.screen, (50, 55, 50), rect, 1, border_radius=10) + ic = load_plot_icon("soil_barren", 96) + if ic: + ipos = ic.get_rect(center=(rect.centerx, rect.y + 48)) + dim = ic.copy() + dim.set_alpha(70) + self.screen.blit(dim, ipos) + self.screen.blit(self.small.render("Locked plot", True, (100, 105, 100)), + (rect.x + 8, rect.y + 102)) + next_buy = slot == unlocked + c_coins, c_fert = collection.plot_unlock_cost(slot + 1) + can = next_buy and collection.available_coins() >= c_coins and fertilizer >= c_fert + cx = rect.x + 8 + if self.coin_icon: + self.screen.blit(self.coin_icon, (cx, rect.y + 131)) + cx += 18 + cxt = self.tiny.render(f"{c_coins}", True, HINT if can else (110, 115, 110)) + self.screen.blit(cxt, (cx, rect.y + 130)) + cx += cxt.get_width() + 10 + if self.fert_icon: + self.screen.blit(self.fert_icon, (cx, rect.y + 131)) + cx += 18 + fxt = self.tiny.render(f"{c_fert}", True, HINT if can else (110, 115, 110)) + self.screen.blit(fxt, (cx, rect.y + 130)) + ubtn = pygame.Rect(rect.centerx - 40, rect.bottom - 30, 80, 24) + pygame.draw.rect(self.screen, BTN_HOVER if can else (45, 50, 45), + ubtn, border_radius=6) + self.screen.blit(self.tiny.render("Unlock", True, TEXT if can else MUTED), + self.tiny.render("Unlock", True, TEXT if can else MUTED).get_rect(center=ubtn.center)) + if next_buy: + self.soil_unlock_rects.append((slot, ubtn)) + + def _draw_plot_tile(self, rect, p, fertilizer, slot): + pygame.draw.rect(self.screen, (34, 42, 34), rect, border_radius=10) + pygame.draw.rect(self.screen, (90, 110, 90), rect, 1, border_radius=10) + rar = plant_rarity(p["plant"]) + ic = load_plot_icon(self._plot_sprite_name(p), 96) + if ic: + self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 60))) + self.screen.blit(self.small.render(p["plant"], True, RARITY_COLORS.get(rar, TEXT)), + (rect.x + 8, rect.y + 114)) + if p["grown"]: + self.screen.blit(self.tiny.render("GROWN!", True, (120, 220, 120)), + (rect.x + 8, rect.y + 128)) + else: + self.screen.blit(self.tiny.render(f"stage {p['stage'] + 1}/{p['total_stages']}" + f" · fertilized {p['water']}x", True, MUTED), + (rect.x + 8, rect.y + 128)) + if p["grown"]: + hbtn = pygame.Rect(rect.centerx - 40, rect.bottom - 34, 80, 26) + pygame.draw.rect(self.screen, (90, 140, 90), hbtn, border_radius=6) + self.screen.blit(self.tiny.render("Harvest", True, TEXT), + self.tiny.render("Harvest", True, TEXT).get_rect(center=hbtn.center)) + self.soil_harvest_rects.append((slot, hbtn)) + elif p["stalled"]: + wbtn = pygame.Rect(rect.centerx - 40, rect.bottom - 34, 80, 26) + pygame.draw.rect(self.screen, BTN_HOVER if fertilizer > 0 else (45, 50, 45), + wbtn, border_radius=6) + self.screen.blit(self.tiny.render("Fertilize", True, TEXT if fertilizer > 0 else MUTED), + self.tiny.render("Fertilize", True, TEXT if fertilizer > 0 else MUTED).get_rect(center=wbtn.center)) + self.soil_water_rects.append((slot, wbtn)) + else: + secs = int(p["seconds_left"]) + st = self.tiny.render(f"{secs}s", True, MUTED) + self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.bottom - 20))) + + def _draw_fert_info(self): + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + self.screen.blit(overlay, (0, 0)) + w, h = 400, 190 + 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) + title = self.bold.render("Fertilizer", True, HINT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + lines = [ + "Used to advance growing Soil plots by one stage.", + "", + "Earned by winning battles — higher floor = more", + "(floor 1 gives 1, floor 5 gives 5).", + ] + y = panel.y + 66 + for ln in lines: + if not ln: + y += 10 + continue + t = self.small.render(ln, True, TEXT) + self.screen.blit(t, t.get_rect(center=(panel.centerx, y))) + y += 24 + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 20))) + + def _draw_water_info(self): + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + self.screen.blit(overlay, (0, 0)) + w, h = 420, 240 + 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) + title = self.bold.render("Water", True, HINT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + lines = [ + "The scarce, valuable currency.", + "", + "Spent to level up plants (+1 level per water)", + "and to buy seed packs (5 water each).", + "", + "Earned from Tower rewards — stage 1-5 wins pay", + "1 water, and Tower packs redeem for 10.", + ] + y = panel.y + 62 + for ln in lines: + if not ln: + y += 10 + continue + t = self.small.render(ln, True, TEXT) + self.screen.blit(t, t.get_rect(center=(panel.centerx, y))) + y += 22 + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 18))) + + def _draw_soil_msg(self): + msg = self.soil_msg + if not msg: + return + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + self.screen.blit(overlay, (0, 0)) + w, h = 360, 200 + 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) + title = self.bold.render("Harvest!", True, (120, 220, 120)) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + plant = msg["plant"] + rar = plant_rarity(plant) + spr = self.sprites.get(plant) + if spr: + ic = fit_surface(spr, 64) + self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 84))) + name = self.bold.render(plant, True, RARITY_COLORS.get(rar, TEXT)) + self.screen.blit(name, name.get_rect(center=(panel.centerx, panel.y + 124))) + dup = self.small.render(f"You gained a copy — {msg['duplicates']} duplicates", True, TEXT) + self.screen.blit(dup, dup.get_rect(center=(panel.centerx, panel.y + 152))) + hint = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 18))) + + def _draw_plants(self): + data = collection.load() + if self.water_mode: + self._draw_page("Plants", "Water mode — gold ring = ready to evolve once leveled") + else: + self._draw_page("Plants", "Plants you own") + water = data.get("water", 0) + wm_hover = self.water_mode_btn.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, (90, 120, 90) if wm_hover or self.water_mode else BTN, + self.water_mode_btn, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150) if self.water_mode else (70, 80, 70), + self.water_mode_btn, 1, border_radius=6) + wtxt = " (exit)" if self.water_mode else "" + wv = self.tiny.render(f"{water}{wtxt}", True, TEXT) + total_w = (16 + 2) + wv.get_width() + x = self.water_mode_btn.centerx - total_w // 2 + if self.water_icon: + self.screen.blit(self.water_icon, (x, self.water_mode_btn.centery - 8)) + x += 18 + self.screen.blit(wv, (x, self.water_mode_btn.centery - wv.get_height() // 2)) + # water info circle + self.water_info_rect = pygame.Rect(self.water_mode_btn.right - 26, + self.water_mode_btn.centery - 11, 22, 22) + ihov = self.water_info_rect.collidepoint(self.mouse_pos) + pygame.draw.circle(self.screen, (115, 135, 115) if ihov else (70, 82, 70), + self.water_info_rect.center, 11) + pygame.draw.circle(self.screen, (175, 195, 175), self.water_info_rect.center, 11, 1) + itxt = self.tiny.render("i", True, TEXT) + self.screen.blit(itxt, itxt.get_rect(center=self.water_info_rect.center)) + unlocked = data.get("tower_best", 0) >= collection.EXTRACT_GATE + ab_hover = self.abilities_btn.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, (90, 120, 90) if (ab_hover and unlocked) else (BTN if unlocked else (45, 50, 45)), + self.abilities_btn, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150) if unlocked else (70, 80, 70), + self.abilities_btn, 1, border_radius=6) + alabel = "Abilities" if unlocked else f"Abilities (Tower {collection.EXTRACT_GATE})" + t = self.tiny.render(alabel, True, TEXT if unlocked else MUTED) + self.screen.blit(t, t.get_rect(center=self.abilities_btn.center)) + if pygame.time.get_ticks() < self.abilities_msg_until: + mt = self.tiny.render(self.abilities_msg, True, HINT) + self.screen.blit(mt, (self.abilities_btn.x, self.abilities_btn.bottom + 4)) + owned = self._sort_plants([n for n in self.names if n in self.owned]) + if not owned: + t = self.small.render("You don't own any plants yet — battle to discover them.", True, MUTED) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 200))) + return + mouse = self.mouse_pos + labels = {"rarity": "Rarity", "type": "Type", "role": "Role", + "level": "Level", "stage": "Stage"} + for key, rect in self.plants_sort_btns.items(): + active = self.plants_sort == key + hover = rect.collidepoint(mouse) + pygame.draw.rect(self.screen, (90, 120, 90) if hover or active else BTN, + 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(labels[key], True, TEXT) + self.screen.blit(t, t.get_rect(center=rect.center)) + 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) + self.screen.blit(et, et.get_rect(center=self.water_exit_btn.center)) + + def _sort_collection(self, names): + s = self.coll_sort + if s == "rarity": + order = {"common": 0, "rare": 1, "super_rare": 2, "super_super_rare": 3, "ultra_rare": 4} + return sorted(names, key=lambda n: (order.get(plant_rarity(n), 0), n)) + if s == "type": + return sorted(names, key=lambda n: (POOL[n]["type"], n)) + if s == "role": + order = {"attacker": 0, "support": 1, "tank": 2} + return sorted(names, key=lambda n: (order.get(POOL[n]["role"], 3), n)) + if s == "found": + return sorted(names, key=lambda n: (n not in self.owned, n)) + return list(names) + + def _sort_plants(self, names): + s = self.plants_sort + if s == "rarity": + order = {"common": 0, "rare": 1, "super_rare": 2, "super_super_rare": 3, "ultra_rare": 4} + return sorted(names, key=lambda n: (order.get(plant_rarity(n), 0), n)) + if s == "type": + return sorted(names, key=lambda n: (POOL[n]["type"], n)) + if s == "role": + order = {"attacker": 0, "support": 1, "tank": 2} + return sorted(names, key=lambda n: (order.get(POOL[n]["role"], 3), n)) + if s == "level": + return sorted(names, key=lambda n: (-collection.get_progress(n)["level"], n)) + if s == "stage": + return sorted(names, key=lambda n: (-collection.get_progress(n)["stage"], + -collection.get_progress(n)["duplicates"], + -collection.get_progress(n)["level"], n)) + return sorted(names, key=lambda n: (n not in self.favs, n)) + + def _draw_runes(self): + self._draw_page("Runes", "Every rune you own — equipped runes show their plant") + dhov = self.runes_deconstruct_rect.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, + (90, 120, 90) if (self.runes_deconstruct or dhov) else BTN, + self.runes_deconstruct_rect, border_radius=6) + dlbl = "Cancel" if self.runes_deconstruct else "Deconstruct" + dt = self.tiny.render(dlbl, True, TEXT) + self.screen.blit(dt, dt.get_rect(center=self.runes_deconstruct_rect.center)) + ihov = self.runes_info_rect.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, (90, 120, 90) if (self.runes_info or ihov) else BTN, + self.runes_info_rect, border_radius=6) + il = self.tiny.render("Rune info", True, TEXT) + self.screen.blit(il, il.get_rect(center=self.runes_info_rect.center)) + + # rune shop button + shov = self.runes_shop_rect.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, (90, 120, 90) if shov else BTN, + self.runes_shop_rect, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150), self.runes_shop_rect, 1, border_radius=6) + sh = self.tiny.render("Rune shop", True, TEXT) + self.screen.blit(sh, sh.get_rect(center=self.runes_shop_rect.center)) + + inventory = collection.rune_inventory() + self._draw_runes_filters() + if not inventory: + t = self.small.render( + "No runes yet — win floor battles or open seed packs to find some.", + True, MUTED) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 200))) + return + display = self._runes_displayed(inventory) + top = 140 + if self.runes_deconstruct: + 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" + else: + btxt = self.runes_deconstruct_msg or \ + f"Pick 10 runes to sacrifice ({len(self.runes_sacrifice)}/10) — " \ + "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))) + elif self.runes_buy_msg and pygame.time.get_ticks() < self.runes_buy_msg_until: + b = self.tiny.render(self.runes_buy_msg, True, DANGER) + self.screen.blit(b, b.get_rect(center=(WIN_W // 2, 124))) + cols, cw, ch, gap = 6, 132, 122, 10 + width = min(cols, len(display)) if display else 1 + x0 = (WIN_W - (width * cw + (width - 1) * gap)) // 2 + rows = (len(display) + width - 1) // width if display else 0 + bottom = top + rows * (ch + gap) - gap + self.grid_scroll = max(0, min(self.grid_scroll, max(0, bottom - (WIN_H - 62)))) + self.grid_scroll_max = max(0, bottom - (WIN_H - 62)) + scroll = self.grid_scroll + old_clip = self.screen.get_clip() + self.screen.set_clip(pygame.Rect(0, 138, WIN_W, WIN_H - 138 - 44)) + self.rune_card_rects = [] + if not display: + t = self.small.render("No runes match the current filter.", True, MUTED) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, top + 40))) + for i, rune in enumerate(display): + x = x0 + (i % width) * (cw + gap) + y = top + (i // width) * (ch + gap) - scroll + rect = pygame.Rect(x, y, cw, ch) + self.rune_card_rects.append((rune["id"], rect)) + pygame.draw.rect(self.screen, PANEL, rect, border_radius=8) + pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=8) + ic = self._rune_icon(rune["color"], 40) + 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 + is_max = rune.get("pct", 0) >= max_pct + is_target = self.runes_deconstruct and self.runes_target \ + and rune["id"] == self.runes_target["id"] + is_sac = self.runes_deconstruct and self.runes_target \ + and rune["id"] in self.runes_sacrifice + if is_target: + pygame.draw.rect(self.screen, HINT, rect, 3, border_radius=8) + elif is_sac: + pygame.draw.rect(self.screen, ACCENT, rect, 3, border_radius=8) + name_col = MUTED if (self.runes_deconstruct and self.runes_target is None + and is_max) else TEXT + nm = self.tiny.render(rune["name"], True, name_col) + self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 62))) + eff = self.tiny.render(runes.card_text(rune["color"], rune.get("pct", 30)), + True, MUTED) + self.screen.blit(eff, eff.get_rect(center=(rect.centerx, rect.y + 78))) + if is_target: + st_text = "Upgrade target" + elif is_sac: + st_text = f"Sacrifice #{self.runes_sacrifice.index(rune['id']) + 1}" + elif self.runes_deconstruct and self.runes_target is None and is_max: + st_text = "MAX" + elif owner: + st_text = f"Equipped in: {owner}" + else: + st_text = "Free" + st = self.tiny.render(self._fit_text(st_text, self.tiny, cw - 8), + True, HINT if owner and not (is_target or is_sac) + else (ACCENT if is_sac else + (HINT if is_target else MUTED))) + self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 96))) + self.screen.set_clip(old_clip) + if self.grid_scroll_max > 0: + hint = "▲" if scroll > 0 else "" + if scroll < self.grid_scroll_max: + hint += "▼" + if hint: + ht = self.tiny.render(hint, True, MUTED) + self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 44))) + if self.runes_info: + self._draw_runes_info() + if self.runes_confirm is not None: + self._draw_runes_confirm() + if self.runes_result is not None: + self._draw_runes_result() + if self.runes_shop: + self._draw_runes_shop() + if self.runes_buy_popup is not None: + self._draw_runes_bought() + + def _draw_runes_shop(self): + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + self.screen.blit(overlay, (0, 0)) + w, h = 560, 380 + 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, ACCENT, panel, 2, border_radius=12) + title = self.bold.render("Rune 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} / {collection.COIN_CAP}", 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 + 44)) + self.screen.blit(bal, (bx + 20, panel.y + 44)) + if self.runes_buy_msg and pygame.time.get_ticks() < self.runes_buy_msg_until: + mt = self.tiny.render(self.runes_buy_msg, True, DANGER) + self.screen.blit(mt, mt.get_rect(center=(panel.centerx, panel.y + 66))) + cycle = pygame.time.get_ticks() // 1600 + t1_color = runes.TIER1_COLORS[cycle % len(runes.TIER1_COLORS)] + t2_color = runes.TIER2_COLORS[cycle % len(runes.TIER2_COLORS)] + self._draw_shop_option(panel, 0, 1, t1_color, runes.T1_RUNE_COST) + self._draw_shop_option(panel, 1, 2, t2_color, runes.T2_RUNE_COST) + hint = self.tiny.render("Click outside to close", True, MUTED) + self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 14))) + + def _draw_shop_option(self, panel, index, tier, color, cost): + card_w = (panel.w - 96) // 2 + card = pygame.Rect(panel.x + 36 + index * (card_w + 24), panel.y + 78, + card_w, 262) + pygame.draw.rect(self.screen, (24, 30, 24), card, border_radius=10) + pygame.draw.rect(self.screen, (70, 80, 70), card, 1, border_radius=10) + tl = self.bold.render(f"Tier {tier}", True, ACCENT) + self.screen.blit(tl, tl.get_rect(center=(card.centerx, card.y + 20))) + colors = runes.TIER1_COLORS if tier == 1 else runes.TIER2_COLORS + total = len(colors) * 14 + (len(colors) - 1) * 4 + dx = card.centerx - total // 2 + for c in colors: + sw = self._rune_icon(c, 14) + if sw: + self.screen.blit(sw, (dx, card.y + 36)) + dx += 18 + ic = self._rune_icon(color, 72) + if ic: + self.screen.blit(ic, ic.get_rect(center=(card.centerx, card.y + 90))) + nm = self.small.render(runes.name(color), True, TEXT) + self.screen.blit(nm, nm.get_rect(center=(card.centerx, card.y + 132))) + sm = self.tiny.render(runes.summary(color), True, MUTED) + self.screen.blit(sm, sm.get_rect(center=(card.centerx, card.y + 154))) + rng = self.tiny.render(f"rolls {runes.roll_range(color)}", True, MUTED) + self.screen.blit(rng, rng.get_rect(center=(card.centerx, card.y + 172))) + afford = collection.available_coins() >= cost + btn = pygame.Rect(card.x + 16, card.bottom - 48, card.w - 32, 32) + hover = btn.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, + (90, 120, 90) if (hover and afford) else (BTN if afford else (45, 50, 45)), + btn, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150) if afford else (70, 80, 70), + btn, 1, border_radius=6) + lbl = self.tiny.render(f"Buy — {cost}", True, TEXT if afford else MUTED) + total_w = (16 + 4) + lbl.get_width() + bx = btn.centerx - total_w // 2 + if self.coin_icon: + self.screen.blit(self.coin_icon, (bx, btn.centery - 8)) + self.screen.blit(lbl, (bx + 20, btn.centery - lbl.get_height() // 2)) + if tier == 1: + self.runes_shop_buy_t1_rect = btn + else: + self.runes_shop_buy_t2_rect = btn + + def _draw_runes_bought(self): + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + self.screen.blit(overlay, (0, 0)) + w, h = 400, 190 + 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, ACCENT, panel, 2, border_radius=12) + title = self.bold.render("Rune purchased!", True, ACCENT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + rune = self.runes_buy_popup + ic = self._rune_icon(rune["color"], 40) + if ic: + self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 74))) + line = self.small.render( + f"{rune['name']} · {runes.card_text(rune['color'], rune['pct'])}", True, TEXT) + self.screen.blit(line, line.get_rect(center=(panel.centerx, panel.y + 126))) + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 22))) + + def _runes_displayed(self, inventory): + shown = [r for r in inventory + if (self.runes_filter_tier == 0 or runes.tier(r["color"]) == self.runes_filter_tier) + and (self.runes_filter_color is None or r["color"] == self.runes_filter_color)] + if self.runes_sort_best: + shown.sort(key=runes.roll_ratio, reverse=True) + return shown + + def _click_runes_filter(self, pos): + for kind, val, rect in self.runes_filter_rects: + if rect.collidepoint(pos): + if kind == "tier": + self.runes_filter_tier = val + elif kind == "color": + self.runes_filter_color = val + return True + if self.runes_sort_rect.collidepoint(pos): + self.runes_sort_best = not self.runes_sort_best + return True + return False + + def _draw_runes_filters(self): + self.runes_filter_rects = [] + y, h = 96, 24 + tl = self.tiny.render("Tier:", True, MUTED) + self.screen.blit(tl, (60, y + 5)) + x = 92 + for val, label in ((0, "All"), (1, "T1"), (2, "T2")): + active = self.runes_filter_tier == val + rect = pygame.Rect(x, y, 30, h) + pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), + rect, border_radius=4) + pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), + rect, 1, border_radius=4) + t = self.tiny.render(label, True, TEXT if active else MUTED) + self.screen.blit(t, t.get_rect(center=rect.center)) + self.runes_filter_rects.append(("tier", val, rect)) + x += 36 + ty = self.tiny.render("Type:", True, MUTED) + self.screen.blit(ty, (210, y + 5)) + x = 244 + all_rect = pygame.Rect(x, y, 36, h) + active = self.runes_filter_color 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.runes_filter_rects.append(("color", None, all_rect)) + x += 42 + for color in runes.RUNE_COLORS: + rect = pygame.Rect(x, y, 24, h) + active = self.runes_filter_color == color + pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4) + pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), + rect, 1, border_radius=4) + sw = self._rune_icon(color, 22) + if sw: + self.screen.blit(sw, sw.get_rect(center=rect.center)) + self.runes_filter_rects.append(("color", color, rect)) + x += 28 + active = self.runes_sort_best + pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), + self.runes_sort_rect, border_radius=4) + pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), + self.runes_sort_rect, 1, border_radius=4) + sl = self.tiny.render("Sort: best roll" if active else "Sort: default", + True, TEXT if active else MUTED) + self.screen.blit(sl, sl.get_rect(center=self.runes_sort_rect.center)) + + def _buy_rune(self, tier): + result = collection.buy_rune(tier) + if isinstance(result, dict): + self.runes_buy_popup = result + self.runes_buy_msg = "" + else: + self.runes_buy_msg = result + self.runes_buy_msg_until = pygame.time.get_ticks() + 1800 + + def _click_runes_deconstruct(self, pos): + inventory = collection.rune_inventory() + by_id = {r["id"]: r for r in inventory} + for rune_id, rect in self.rune_card_rects: + if rect.collidepoint(pos): + rune = by_id.get(rune_id) + if not rune: + return + if self.runes_target is None: + max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX + if rune.get("pct", 0) >= max_pct: + self.runes_deconstruct_msg = f"{rune['name']} is already at max roll" + return + self.runes_target = rune + self.runes_sacrifice = [] + self.runes_deconstruct_msg = "" + else: + if rune_id == self.runes_target["id"]: + # click the target again to change it + self.runes_target = None + self.runes_sacrifice = [] + self.runes_deconstruct_msg = "" + 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" + return + else: + self.runes_sacrifice.append(rune_id) + self.runes_deconstruct_msg = "" + if len(self.runes_sacrifice) == 10: + self._open_runes_confirm() + return + + def _open_runes_confirm(self): + equipped = [] + for sid in self.runes_sacrifice: + owner = collection.rune_location(sid) + if owner: + equipped.append((sid, owner)) + self.runes_confirm = { + "kind": "deconstruct", + "rune": self.runes_target, + "sacrifice": list(self.runes_sacrifice), + "equipped": equipped, + } + + def _draw_runes_info(self): + 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 + 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) + title = self.bold.render("Rune effects", True, HINT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + y = panel.y + 62 + for color in runes.RUNE_COLORS: + ic = self._rune_icon(color, 40) + if ic: + self.screen.blit(ic, (panel.x + 40, y)) + nm = self.bold.render(runes.name(color), True, TEXT) + self.screen.blit(nm, (panel.x + 92, y)) + eff = self.small.render(runes.summary(color), True, MUTED) + self.screen.blit(eff, (panel.x + 92, y + 24)) + rg = self.tiny.render( + f"Tier {runes.tier(color)} · Rolls {runes.roll_range(color)}", + True, ACCENT) + self.screen.blit(rg, rg.get_rect(topright=(panel.right - 16, y + 10))) + y += 58 + note = self.tiny.render( + "Runes of the same effect stack additively; a plant can equip at " + "most %d runes of the same type." % runes.MAX_RUNES_PER_TYPE, + True, MUTED) + self.screen.blit(note, note.get_rect(center=(panel.centerx, panel.bottom - 24))) + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 8))) + + def _draw_runes_result(self): + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + self.screen.blit(overlay, (0, 0)) + w, h = 420, 200 + 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, ACCENT, panel, 2, border_radius=12) + title = self.bold.render("Rune improved!", True, ACCENT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + res = self.runes_result + ic = self._rune_icon(res["color"], 40) + if ic: + self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 72))) + line = self.small.render( + f"{res['name']}: {res['before']}% -> {res['after']}%", True, TEXT) + self.screen.blit(line, line.get_rect(center=(panel.centerx, panel.y + 122))) + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 22))) + + def _draw_runes_confirm(self): + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + self.screen.blit(overlay, (0, 0)) + equipped = self.runes_confirm.get("equipped", []) + w, h = 520, 210 if equipped else 190 + 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, 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 + 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}%?", + 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))) + if equipped: + plants = ", ".join(sorted({p for _, p in equipped})) + warn = self.tiny.render( + f"{len(equipped)} equipped rune(s) on {plants} will be removed", + True, DANGER) + self.screen.blit(warn, warn.get_rect(center=(panel.centerx, panel.y + 84))) + btn_y = panel.y + 120 + else: + btn_y = panel.y + 108 + self.runes_confirm_ok = pygame.Rect(panel.centerx - 110, btn_y, 100, 36) + self.runes_confirm_no = pygame.Rect(panel.centerx + 10, btn_y, 100, 36) + pygame.draw.rect(self.screen, DANGER, self.runes_confirm_ok, border_radius=8) + ok = self.bold.render("Reroll", True, TEXT) + self.screen.blit(ok, ok.get_rect(center=self.runes_confirm_ok.center)) + pygame.draw.rect(self.screen, BTN, self.runes_confirm_no, border_radius=8) + no = self.bold.render("Cancel", True, TEXT) + self.screen.blit(no, no.get_rect(center=self.runes_confirm_no.center)) + + def _draw_collection(self): + self._draw_page("Collection", f"{len(self.owned)} / {len(self.names)} plants owned") + mouse = self.mouse_pos + for key, rect in self.collection_tab_btns.items(): + active = self.collection_tab == key + hover = rect.collidepoint(mouse) + pygame.draw.rect(self.screen, (90, 120, 90) if (hover or active) else BTN, + 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) + self.screen.blit(t, t.get_rect(center=rect.center)) + if self.collection_tab == "runes": + self._draw_collection_runes() + else: + self._draw_grid(self.names, reveal_all=False, show_evo=False, + reveal_owned=True, y0=144) + + def _draw_collection_runes(self): + mouse = self.mouse_pos + self._draw_coll_rune_filters() + colors = self._collection_rune_colors() + owned_colors = {r["color"] for r in collection.rune_inventory()} + cw, ch, gap = 170, 190, 16 + cols = 5 + total = cols * cw + (cols - 1) * gap + x0 = (WIN_W - total) // 2 + y0 = 184 + self.coll_rune_cards = [] + for i, color in enumerate(colors): + x = x0 + (i % cols) * (cw + gap) + y = y0 + (i // cols) * (ch + gap) + rect = pygame.Rect(x, y, cw, ch) + self.coll_rune_cards.append((color, rect)) + obtained = color in owned_colors + pygame.draw.rect(self.screen, PANEL, rect, border_radius=8) + pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=8) + ic = self._rune_icon(color, 64) + if ic: + ipos = ic.get_rect(center=(rect.centerx, rect.y + 50)) + if not obtained: + dim = ic.copy() + dim.set_alpha(70) + self.screen.blit(dim, ipos) + else: + self.screen.blit(ic, ipos) + nm = self.small.render(runes.name(color), True, TEXT if obtained else MUTED) + self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 88))) + if obtained: + eff = self.tiny.render(runes.summary(color), True, MUTED) + self.screen.blit(eff, eff.get_rect(center=(rect.centerx, rect.y + 108))) + if not obtained: + self.screen.blit(self.tiny.render("Not obtained", True, (110, 115, 110)), + self.tiny.render("Not obtained", True, (110, 115, 110)).get_rect(center=(rect.centerx, rect.y + 166))) + else: + t1 = self.tiny.render(f"Tier {runes.tier(color)} · max {runes.roll_range(color)}", True, HINT) + self.screen.blit(t1, t1.get_rect(center=(rect.centerx, rect.y + 166))) + if self.coll_rune_detail is not None: + self._draw_coll_rune_detail() + + def _draw_coll_rune_filters(self): + self.coll_rune_filter_rects = [] + y, h = 140, 24 + tl = self.tiny.render("Tier:", True, MUTED) + self.screen.blit(tl, (60, y + 5)) + x = 92 + for val, label in ((0, "All"), (1, "T1"), (2, "T2")): + active = self.coll_rune_tier == val + rect = pygame.Rect(x, y, 30, h) + pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), + rect, border_radius=4) + pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), + rect, 1, border_radius=4) + t = self.tiny.render(label, True, TEXT if active else MUTED) + self.screen.blit(t, t.get_rect(center=rect.center)) + self.coll_rune_filter_rects.append(("tier", val, rect)) + x += 36 + ty = self.tiny.render("Type:", True, MUTED) + self.screen.blit(ty, (210, y + 5)) + x = 244 + all_rect = pygame.Rect(x, y, 36, h) + active = self.coll_rune_color 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.coll_rune_filter_rects.append(("color", None, all_rect)) + x += 42 + for color in runes.RUNE_COLORS: + rect = pygame.Rect(x, y, 24, h) + active = self.coll_rune_color == color + pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4) + pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), + rect, 1, border_radius=4) + sw = self._rune_icon(color, 22) + if sw: + self.screen.blit(sw, sw.get_rect(center=rect.center)) + self.coll_rune_filter_rects.append(("color", color, rect)) + x += 28 + active = self.coll_rune_sort_best + pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), + self.coll_rune_sort_rect, border_radius=4) + pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), + self.coll_rune_sort_rect, 1, border_radius=4) + sl = self.tiny.render("Sort: best roll" if active else "Sort: default", + True, TEXT if active else MUTED) + self.screen.blit(sl, sl.get_rect(center=self.coll_rune_sort_rect.center)) + + def _click_coll_rune_filter(self, pos): + for kind, val, rect in self.coll_rune_filter_rects: + if rect.collidepoint(pos): + if kind == "tier": + self.coll_rune_tier = val + elif kind == "color": + self.coll_rune_color = val + return True + if self.coll_rune_sort_rect.collidepoint(pos): + self.coll_rune_sort_best = not self.coll_rune_sort_best + return True + return False + + def _draw_coll_rune_detail(self): + color = self.coll_rune_detail + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + self.screen.blit(overlay, (0, 0)) + w, h = 360, 240 + 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, ACCENT, panel, 2, border_radius=12) + title = self.bold.render(runes.name(color), True, TEXT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + ic = self._rune_icon(color, 64) + if ic: + 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) + 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) + self.screen.blit(t1, t1.get_rect(center=(panel.centerx, panel.y + 178))) + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 18))) + + def _collection_rune_colors(self): + colors = [c for c in runes.RUNE_COLORS + if (self.coll_rune_tier == 0 or runes.tier(c) == self.coll_rune_tier) + and (self.coll_rune_color is None or c == self.coll_rune_color)] + 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) + return colors + + def _draw_detail(self): + name = self.detail_name + pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) + t = self.small.render("Back", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.back_rect.center)) + + pygame.draw.rect(self.screen, BTN, self.detail_left, border_radius=6) + t = self.bold.render("<", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.detail_left.center)) + pygame.draw.rect(self.screen, BTN, self.detail_right, border_radius=6) + t = self.bold.render(">", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.detail_right.center)) + if name in self.detail_list: + pos = self.tiny.render(f"{self.detail_list.index(name) + 1}/{len(self.detail_list)}", True, MUTED) + self.screen.blit(pos, pos.get_rect(topright=(WIN_W - 70, 24))) + + if name is None or name not in POOL: + return + spec = POOL[name] + found = name in self.owned if self.detail_from == "collection" else True + + sprite_rect = pygame.Rect(70, 110, 150, 150) + spr = self.sprites.get(name) + if spr: + ic = fit_surface(spr, 150) + 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, 2) + + name_txt = "???" if not found else name + t = self.font.render(name_txt, True, TEXT if found else MUTED) + self.screen.blit(t, (250, 120)) + if found: + rar = plant_rarity(name) + rt = self.small.render(RARITY_LABELS.get(rar, rar), True, RARITY_COLORS.get(rar, TEXT)) + self.screen.blit(rt, (250 + t.get_width() + 12, 126)) + if name in self.owned: + fav = collection.is_favorite(name) + pygame.draw.rect(self.screen, (70, 80, 70) if fav else (50, 58, 50), + self.star_rect, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150) if fav else (70, 80, 70), + self.star_rect, 1, border_radius=6) + _draw_star(self.screen, self.star_rect.center, 13, HINT if fav else MUTED) + + if not found: + t = self.small.render("Not discovered yet.", True, MUTED) + self.screen.blit(t, (250, 170)) + t = self.small.render("Battle this plant to reveal its details.", True, MUTED) + self.screen.blit(t, (250, 196)) + return + + role_name = ROLE_NAMES.get(spec["role"], spec["role"]) + ric = self._role_icon(spec["role"], 16) + role_surf = self.small.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT)) + sep_surf = self.small.render(" · ", True, MUTED) + icon_w = 16 + 6 if ric else 0 + if name in VARIABLE_TYPE_UNITS: + tics = [load_type_icon(t, 16) for t in ALL_TYPES] + type_w = sum((ic.get_width() + 3) if ic else self.small.size(t)[0] + 3 for ic, t in zip(tics, ALL_TYPES)) + else: + tic = load_type_icon(spec["type"], 18) + type_w = (tic.get_width() + 4) if tic else self.small.size(spec["type"])[0] + total_w = icon_w + role_surf.get_width() + sep_surf.get_width() + type_w + h = role_surf.get_height() + pill = pygame.Rect(250 - 8, 168 - 4, total_w + 16, h + 8) + pygame.draw.rect(self.screen, (52, 60, 52), pill, border_radius=5) + x = 250 + if ric: + self.screen.blit(ric, ric.get_rect(midleft=(x, 168 + h // 2))) + x += icon_w + self.screen.blit(role_surf, (x, 168)) + x += role_surf.get_width() + self.screen.blit(sep_surf, (x, 168)) + x += sep_surf.get_width() + if name in VARIABLE_TYPE_UNITS: + for tic, t in zip(tics, ALL_TYPES): + if tic: + self.screen.blit(tic, tic.get_rect(midleft=(x, 168 + h // 2))) + x += tic.get_width() + 3 + else: + ts = self.small.render(t, True, TYPE_COLORS.get(t, TEXT)) + self.screen.blit(ts, (x, 168)) + x += self.small.size(t)[0] + 3 + else: + tic = load_type_icon(spec["type"], 18) + if tic: + self.screen.blit(tic, tic.get_rect(midleft=(x, 168 + h // 2))) + else: + ts = self.small.render(spec["type"], True, TYPE_COLORS.get(spec["type"], TEXT)) + self.screen.blit(ts, (x, 168)) + self.detail_type_rect = pill + self.type_choice_rects = {} + if name in VARIABLE_TYPE_UNITS and self.detail_from != "collection" and name in self.owned: + prog = collection.get_progress(name) + evolved = prog["stage"] >= 2 + current = collection.unit_type(name) + tl = self.small.render("Type:", True, MUTED) + self.screen.blit(tl, (250, 194)) + x = 250 + tl.get_width() + 8 + for pt in ALL_TYPES: + rect = pygame.Rect(x, 192, 28, 28) + active = current == pt + bg = (90, 120, 90) if (evolved and active) else (45, 50, 45) + border = HINT if (evolved and active) else (70, 80, 70) + pygame.draw.rect(self.screen, bg, rect, border_radius=5) + pygame.draw.rect(self.screen, border, rect, 1, border_radius=5) + ic = load_type_icon(pt, 22) + if ic: + self.screen.blit(ic, ic.get_rect(center=rect.center)) + if evolved: + self.type_choice_rects[pt] = rect + x += 34 + if not evolved: + note = self.tiny.render("Evolve once to choose a type", True, MUTED) + self.screen.blit(note, (x + 4, 199)) + if name in self.owned and self.detail_from != "collection": + _draw_evo_badge(self.screen, self.tiny, (WIN_W - 56, 175), collection.get_progress(name)["stage"] - 1) + + show_owned = self.detail_from != "collection" + if show_owned and name in self.owned: + prog = collection.get_progress(name) + lv = self.small.render(f"Level {prog['level']} · Stage {prog['stage']} · {prog['duplicates']} dup", True, HINT) + self.screen.blit(lv, (250, 226)) + if collection.injection_of(name): + pygame.draw.rect(self.screen, DANGER, self.inject_remove_rect, border_radius=6) + it = self.tiny.render("Remove injection", True, TEXT) + self.screen.blit(it, it.get_rect(center=self.inject_remove_rect.center)) + + if prog["level"] < MAX_LEVEL: + xp_next = xp_to_next(prog["level"]) + frac = min(1.0, prog["xp"] / xp_next) + bar = pygame.Rect(250, 250, 330, 12) + pygame.draw.rect(self.screen, (45, 50, 45), bar, border_radius=6) + if frac > 0: + pygame.draw.rect(self.screen, (95, 145, 205), + (bar.x, bar.y, int(bar.w * frac), bar.h), border_radius=6) + xpt = self.tiny.render(f"XP {prog['xp']}/{xp_next}", True, TEXT) + self.screen.blit(xpt, (bar.right + 8, bar.y - 2)) + else: + xpt = self.small.render("Max level", True, MUTED) + self.screen.blit(xpt, (250, 246)) + + atk, spd, dfn, hp = compute_stats(spec["stats"], prog["level"], prog["stage"]) + crit = spec.get("crit", 0.10) + st = self.small.render(f"Attack {atk} Speed {spd}", True, TEXT) + self.screen.blit(st, (250, 276)) + st = self.small.render(f"Defense {dfn} Health {int(hp * HP_MULT)} Crit {int(crit * 100)}%", True, TEXT) + self.screen.blit(st, (250, 300)) + + water_reason = collection.can_water(name) + water_ok = water_reason == "" + pygame.draw.rect(self.screen, BTN_HOVER if water_ok else (45, 50, 45), self.water_rect, border_radius=6) + wtxt = "Water (+1 level)" if water_ok else f"Water ({water_reason})" + t = self.tiny.render(wtxt, True, TEXT if water_ok else MUTED) + self.screen.blit(t, t.get_rect(center=self.water_rect.center)) + + ev_ok = can_evolve(prog["level"], prog["stage"], prog["duplicates"]) + pygame.draw.rect(self.screen, BTN_HOVER if ev_ok else (45, 50, 45), self.evolve_rect, border_radius=6) + if ev_ok: + etxt = "Evolve!" + elif prog["stage"] >= 3: + etxt = "Max evolved" + else: + cost = evolve_cost(prog["stage"]) + etxt = f"Evolve (Lv milestone + {cost} dup)" if cost else "Evolve (unlocked later)" + t = self.tiny.render(etxt, True, TEXT if ev_ok else MUTED) + self.screen.blit(t, t.get_rect(center=self.evolve_rect.center)) + + seed_state = collection.soil_state() + seed_ok = name not in VARIABLE_TYPE_UNITS and seed_state["cd_left"] <= 0 + pygame.draw.rect(self.screen, BTN_HOVER if seed_ok else (45, 50, 45), + self.seed_rect, border_radius=6) + if name in VARIABLE_TYPE_UNITS: + stxt = "No seed" + elif seed_ok: + stxt = "Retrieve Seed" + else: + stxt = f"Seed in {int(seed_state['cd_left'])}s" + st = self.tiny.render(stxt, True, TEXT if seed_ok else MUTED) + self.screen.blit(st, st.get_rect(center=self.seed_rect.center)) + + equipped = sum(1 for r in collection.equipped_runes(name) if r is not None) + slots = collection.rune_slots(name) + pygame.draw.rect(self.screen, BTN_HOVER, self.runes_rect, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150), self.runes_rect, 1, border_radius=6) + rt = self.tiny.render(f"Runes ({equipped}/{slots})", True, TEXT) + self.screen.blit(rt, rt.get_rect(center=self.runes_rect.center)) + skills_y = 392 + else: + atk, spd, dfn, hp = spec["stats"] + crit = spec.get("crit", 0.10) + st = self.small.render(f"Attack {atk} Speed {spd}", True, TEXT) + self.screen.blit(st, (250, 226)) + st = self.small.render(f"Defense {dfn} Health {int(hp * HP_MULT)} Crit {int(crit * 100)}%", True, TEXT) + self.screen.blit(st, (250, 250)) + skills_y = 290 + + p = spec.get("passive", "") + if p: + pname, pdesc = PASSIVES.get(p, (p, "")) + pt = self.bold.render(f"Passive — {pname}", True, HINT) + self.screen.blit(pt, (60, skills_y)) + lines = wrap_text(" " + pdesc, self.small, WIN_W - 120) + for i, ln in enumerate(lines): + self.screen.blit(self.small.render(ln, True, TEXT), + (60, skills_y + 24 + i * 20)) + skills_y += 26 + len(lines) * 20 + 4 + + y = skills_y + inj = collection.injection_of(name) if show_owned else None + for i, sk in enumerate(spec["skills"]): + is_inj = bool(inj) and i == inj.get("index") + if is_inj: + sk = skill_from_dict(inj["skill"]) + bracket = _bracket_text(sk) + if bracket: + label = f"{sk.name} [{bracket}] CD {sk.cooldown}" + else: + label = f"{sk.name} CD {sk.cooldown}" + color = ACCENT if is_inj else HINT + t = self.bold.render(label, True, color) + self.screen.blit(t, (60, y)) + if is_inj: + tag = self.tiny.render("injected", True, ACCENT) + self.screen.blit(tag, (60 + t.get_width() + 10, y + 2)) + d = self.small.render(" " + sk.description, True, ACCENT if is_inj else TEXT) + self.screen.blit(d, (60, y + 24)) + y += 60 + + def _draw_grid(self, names, reveal_all=False, level_mode=False, show_evo=True, reveal_owned=False, + data=None, y0=96): + data = data or collection.load() + cols, cw, ch, gap = 4, 210, 150, 14 + width = min(cols, len(names)) + x0 = (WIN_W - (width * cw + (width - 1) * gap)) // 2 + rows = (len(names) + width - 1) // width + bottom = y0 + rows * (ch + gap) - gap + self.grid_scroll = max(0, min(self.grid_scroll, max(0, bottom - (WIN_H - 62)))) + self.grid_scroll_max = max(0, bottom - (WIN_H - 62)) + scroll = self.grid_scroll + self.grid_hit = [] + self.type_tag_rects = {} + old_clip = self.screen.get_clip() + self.screen.set_clip(pygame.Rect(0, 90, WIN_W, WIN_H - 90 - 46)) + for i, name in enumerate(names): + x = x0 + (i % width) * (cw + gap) + y = y0 + (i // width) * (ch + gap) - scroll + rect = pygame.Rect(x, y, cw, ch) + self.grid_hit.append((name, rect)) + if reveal_owned: + found = name in self.owned + else: + found = reveal_all or name in self.discovered + pygame.draw.rect(self.screen, PANEL, rect, border_radius=6) + pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=6) + spr = self.sprites.get(name) + 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) + 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))) + if found: + if level_mode: + lv = collection.get_progress(name, data)["level"] + lvt = self.tiny.render(f"Lv {lv}", True, HINT) + self.screen.blit(lvt, lvt.get_rect(center=(rect.centerx, rect.y + 130))) + can = collection.can_water(name, data) == "" + smart = collection.smart_water_candidate(name, data) + if smart: + pygame.draw.rect(self.screen, (245, 200, 90), rect, 3, border_radius=6) + elif can: + pygame.draw.rect(self.screen, (110, 190, 110), rect, 2, border_radius=6) + else: + pygame.draw.rect(self.screen, (70, 78, 70), rect, 1, border_radius=6) + else: + role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"]) + ric = self._role_icon(POOL[name]["role"], 12) + rt = self.tiny.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT)) + sep = self.tiny.render(" · ", True, MUTED) + role_icon_w = 12 + 3 if ric else 0 + if name in VARIABLE_TYPE_UNITS: + tics = [(load_type_icon(t, 12), t) for t in ALL_TYPES] + type_w = sum((ic.get_width() + 2) if ic else self.tiny.size(t)[0] + 2 + for ic, t in tics) + else: + tic = load_type_icon(POOL[name]["type"], 14) + type_w = (tic.get_width() + 2) if tic else self.tiny.size(POOL[name]["type"])[0] + total_w = role_icon_w + rt.get_width() + sep.get_width() + type_w + row_y = rect.y + 130 + rt_rect = rt.get_rect(topleft=(rect.centerx - total_w // 2 + role_icon_w, row_y)) + sep_rect = sep.get_rect(midleft=(rt_rect.right, rt_rect.centery)) + if ric: + self.screen.blit(ric, ric.get_rect(midright=(rt_rect.left - 3, rt_rect.centery))) + self.screen.blit(rt, rt_rect) + self.screen.blit(sep, sep_rect) + tx = sep_rect.right + 2 + if name in VARIABLE_TYPE_UNITS: + first_tr = last_tr = None + for tic, t in tics: + if tic: + tr = tic.get_rect(midleft=(tx, sep_rect.centery)) + self.screen.blit(tic, tr) + else: + ty = self.tiny.render(t, True, MUTED) + tr = ty.get_rect(midleft=(tx, sep_rect.centery)) + self.screen.blit(ty, tr) + first_tr = first_tr or tr + last_tr = tr + tx += tr.width + 2 + self.type_tag_rects[name] = first_tr.union(last_tr).inflate(6, 4) + else: + tic = load_type_icon(POOL[name]["type"], 14) + if tic: + tr = tic.get_rect(midleft=(tx, sep_rect.centery)) + self.screen.blit(tic, tr) + else: + ty = self.tiny.render(POOL[name]["type"], True, + TYPE_COLORS.get(POOL[name]["type"], MUTED)) + tr = ty.get_rect(midleft=(tx, sep_rect.centery)) + self.screen.blit(ty, tr) + self.type_tag_rects[name] = tr.inflate(6, 4) + if name in self.owned and show_evo: + evo = collection.get_progress(name, data)["stage"] - 1 + _draw_evo_badge(self.screen, self.tiny, (rect.x + 22, rect.y + 28), evo) + if name in self.favs: + _draw_star(self.screen, (rect.x + 18, rect.y + 12), 8, HINT) + if name in self.owned and not level_mode: + lv = self.tiny.render(f"Lv {collection.get_progress(name, data)['level']}", True, HINT) + self.screen.blit(lv, (rect.right - 34, rect.y + 8)) + self.screen.set_clip(old_clip) + if self.grid_scroll_max > 0: + hint = "▲" if scroll > 0 else "" + if scroll < self.grid_scroll_max: + hint += "▼" + if hint: + ht = self.tiny.render(hint, True, MUTED) + self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 46))) \ No newline at end of file diff --git a/ui/picker.py b/ui/picker.py new file mode 100644 index 0000000..1991b07 --- /dev/null +++ b/ui/picker.py @@ -0,0 +1,656 @@ +import pygame + +from assets import icon as icon_path +from assets import load_sprite +from engine import 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.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 +from ui.scaling import (fit_surface, load_rune_icon, load_type_icon, make_window, + request_quit, to_logical, wrap_text) +from ui.typepopup import draw_type_popup + +TYPE_COLORS = { + "radiant": (245, 200, 90), + "nocturnal": (150, 120, 220), + "ice": (130, 200, 235), + "garden": (120, 200, 120), + "wildflower": (235, 130, 170), +} + +BG = (24, 30, 24) +TEXT = (230, 235, 230) +MUTED = (150, 155, 150) +SLOT = (42, 50, 42) +SLOT_EMPTY = (34, 40, 34) +HIGHLIGHT = (255, 220, 120) +HINT = (255, 220, 120) +BTN = (60, 90, 60) +BTN_HOVER = (85, 125, 85) + +WIN_W, WIN_H = 1040, 720 +SLOT_W, SLOT_H = 220, 52 +SLOT_GAP = 8 + + +class TeamSelect: + def __init__(self, initial_enemies=None, difficulty=1, tower_mode=False, + pick_goal=4, min_goal=1, restrict_to=None, show_slots=True, + header=None, start_label=None, enemy_types=None, no_roll=False, + enemy_levels=None): + 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") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 30) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.difficulty = difficulty + self.tower_mode = tower_mode + self.header = header + self.start_label = start_label + self.pick_goal = pick_goal + self.min_goal = min_goal + self.show_slots = show_slots + self.enemy_types = list(enemy_types) if enemy_types else [] + self.enemy_levels = list(enemy_levels) if enemy_levels else [] + self.no_roll = no_roll + self.names = list(POOL.keys()) + self.favs = collection.favorites() + if restrict_to is not None: + self.pool_names = sorted(restrict_to, key=lambda n: (n not in self.favs, n)) + else: + 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} + self.rune_icons = {} + self.role_icons = {} + self.allies = [] + if self.tower_mode: + self.phase = 2 + self.enemies = list(initial_enemies) if initial_enemies else [] + else: + self.phase = 1 + self.enemies = list(initial_enemies) if initial_enemies else [] + if not self.enemies: + self._roll_enemies() + self._layout_enemy_cards() + self._layout_enemy_strip() + self._layout_slots() + 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) + self.start_rect = pygame.Rect(WIN_W // 2 - 90, WIN_H - 60, 180, 40) + self.preset_rects = [pygame.Rect(WIN_W - 254 + i * 62, 16, 56, 28) for i in range(4)] + self.back_rect = pygame.Rect(12, 12, 90, 32) + self.detail_open = False + self.detail_name = None + self.detail_list = [] + self.detail_index = 0 + self.detail_rect = pygame.Rect(WIN_W // 2 - 280, 120, 560, 480) + self.detail_left = pygame.Rect(self.detail_rect.left - 44, self.detail_rect.centery - 24, 36, 48) + self.detail_right = pygame.Rect(self.detail_rect.right + 8, self.detail_rect.centery - 24, 36, 48) + self.detail_close = pygame.Rect(self.detail_rect.right - 44, self.detail_rect.top + 8, 36, 28) + self.detail_add = pygame.Rect(self.detail_rect.centerx - 70, self.detail_rect.bottom - 44, 140, 32) + self.type_info = None + self.type_tags = [] + self.detail_type_rect = None + + def _role_icon(self, role, size=14): + key = (role, size) + if key not in self.role_icons: + name = ROLE_ICONS.get(role) + if name: + try: + img = pygame.image.load(icon_path("role_icons", name)).convert_alpha() + self.role_icons[key] = fit_surface(img, size) + except (pygame.error, OSError): + self.role_icons[key] = None + else: + self.role_icons[key] = None + return self.role_icons[key] + + def _load(self, name): + return load_sprite(name, "full") + + def _rune_icon(self, color): + if color not in self.rune_icons: + self.rune_icons[color] = load_rune_icon(color, 14) + return self.rune_icons[color] + + def _roll_enemies(self): + self.enemies = roll_enemies(self.difficulty, exclude=tuple(self.allies)) + + def _enemy_level(self, idx): + """Displayed level of enemy `idx` (explicit list, else the floor default).""" + if idx < len(self.enemy_levels): + return self.enemy_levels[idx] + return floor_enemy_level(self.difficulty) + + def _draw_enemy_level(self, tag, idx): + """'Lv N' label just to the right of an enemy's role/type tag.""" + lv = self.tiny.render(f"Lv {self._enemy_level(idx)}", True, HINT) + self.screen.blit(lv, (tag.right + 5, tag.y + 1)) + + def _layout_enemy_cards(self): + cw, ch, gap = 210, 250, 18 + total = 4 * cw + 3 * gap + x0 = (WIN_W - total) // 2 + self.enemy_card_rects = [pygame.Rect(x0 + i * (cw + gap), 96, cw, ch) for i in range(4)] + + def _layout_enemy_strip(self): + cw, ch, gap = 220, 84, 16 + total = 4 * cw + 3 * gap + x0 = (WIN_W - total) // 2 + self.enemy_strip_rects = [pygame.Rect(x0 + i * (cw + gap), 56, cw, ch) for i in range(4)] + + def _layout_slots(self): + n = self.pick_goal + if n > 4: + sl_h, gap = 40, 4 + else: + 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 _layout_pool(self): + self.pool_rects = {} + cols = min(4, max(1, len(self.pool_names))) + cw, ch, gap = 170, 64, 10 + x0 = 290 + y0 = 170 + for i, name in enumerate(self.pool_names): + x = x0 + (i % cols) * (cw + gap) + y = y0 + (i // cols) * (ch + gap) + self.pool_rects[name] = pygame.Rect(x, y, cw, ch) + # pool scroll area: above the bottom buttons (WIN_H - 100) + 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 + 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)) + + def run(self): + running = True + result = None + while running: + self.clock.tick(60) + self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEWHEEL: + 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 == 1: + pos = to_logical(event.pos, self.scale) + if self.detail_open: + self._click_detail(pos) + elif self.back_rect.collidepoint(pos): + result = "back" + running = False + else: + choice = self._click(pos) + if choice == "start": + result = (list(self.allies), list(self.enemies)) + running = False + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + return result + + def _open_detail(self, name, namelist): + self.detail_list = list(namelist) + if name not in self.detail_list: + self.detail_list.append(name) + self.detail_name = name + self.detail_index = self.detail_list.index(name) + self.detail_is_enemy = (namelist is self.enemies) + self.detail_open = True + + def _nav_detail(self, step): + if not self.detail_list: + return + if not (0 <= self.detail_index < len(self.detail_list)): + self.detail_index = 0 + self.detail_index = (self.detail_index + step) % len(self.detail_list) + self.detail_name = self.detail_list[self.detail_index] + self.type_info = None + + def _click(self, pos): + if self.type_info is not None: + self.type_info = None + return None + for rect, ptype in self.type_tags: + if rect.collidepoint(pos): + self.type_info = ptype + return None + if self.phase == 1: + if not self.no_roll and self.roll_rect.collidepoint(pos): + self._roll_enemies() + elif self.next_rect.collidepoint(pos): + self.phase = 2 + else: + for i, rect in enumerate(self.enemy_card_rects): + if rect.collidepoint(pos) and i < len(self.enemies): + self._open_detail(self.enemies[i], self.enemies) + return None + if self.start_rect.collidepoint(pos) and len(self.allies) >= self.min_goal: + return "start" + for i, rect in enumerate(self.preset_rects): + if rect.collidepoint(pos): + preset = collection.get_presets()[i] + if preset: + plants = [n for n in preset if n in self.pool_names] + if self.pick_goal == 8: + # tower roster: presets merge into the roster, re-click to unload + if plants and all(n in self.allies for n in plants): + self.allies = [n for n in self.allies if n not in plants] + else: + for n in plants: + if n not in self.allies and len(self.allies) < self.pick_goal: + self.allies.append(n) + else: + self.allies = plants + return None + for i, rect in enumerate(self.ally_rects): + if rect.collidepoint(pos) and i < len(self.allies): + self.allies.pop(i) + return None + for i, rect in enumerate(self.enemy_strip_rects): + if rect.collidepoint(pos) and i < len(self.enemies): + self._open_detail(self.enemies[i], self.enemies) + 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) + return None + return None + + def _click_detail(self, pos): + if self.type_info is not None: + self.type_info = None + return + if self.detail_type_rect and self.detail_type_rect.collidepoint(pos) and self.detail_name: + if self.detail_is_enemy: + idx = self.detail_list.index(self.detail_name) + ptype = self.enemy_types[idx] if idx < len(self.enemy_types) else None + else: + ptype = collection.unit_type(self.detail_name) + if ptype: + self.type_info = ptype + return + if self.detail_close.collidepoint(pos): + self.detail_open = False + return + if self.detail_left.collidepoint(pos): + self._nav_detail(-1) + return + if self.detail_right.collidepoint(pos): + self._nav_detail(1) + return + if self.detail_add.collidepoint(pos) and not self.detail_is_enemy: + name = self.detail_name + if (self.phase == 2 and name in self.pool_names + and name not in self.allies and len(self.allies) < self.pick_goal): + self.allies.append(name) + self.detail_open = False + + def _draw(self): + self.type_tags = [] + self.detail_type_rect = None + self.screen.fill(BG) + pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) + t = self.small.render("Back", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.back_rect.center)) + if self.phase == 1: + self._draw_reveal() + else: + self._draw_pick() + if self.detail_open: + self._draw_detail_panel() + 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) + + def _draw_type_tag(self, label, ptype, pos, font, center=False): + surf = font.render(label, True, TYPE_COLORS.get(ptype, TEXT)) + rect = surf.get_rect(center=pos) if center else surf.get_rect(topleft=pos) + tag = rect.inflate(12, 6) + pygame.draw.rect(self.screen, (52, 60, 52), tag, border_radius=5) + self.screen.blit(surf, rect) + self.type_tags.append((tag, ptype)) + 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)) + sep = font.render(" · ", True, MUTED) + ptype = ptype or collection.unit_type(name) + tic = load_type_icon(ptype, 16) if ptype else None + if tic: + type_w = tic.get_width() + 4 + type_text = None + else: + shown = ptype if ptype else "No type" + type_text = font.render(shown, True, TYPE_COLORS.get(ptype, MUTED) if ptype else MUTED) + type_w = type_text.get_width() + total_w = rs.get_width() + sep.get_width() + type_w + h = rs.get_height() + if center: + x, y = pos[0] - total_w // 2, pos[1] - h // 2 + else: + x, y = pos + tag = pygame.Rect(x - 6, y - 3, total_w + 12, h + 6) + pygame.draw.rect(self.screen, (52, 60, 52), tag, border_radius=5) + self.screen.blit(rs, (x, y)) + self.screen.blit(sep, (x + rs.get_width(), y)) + ix = x + rs.get_width() + sep.get_width() + if tic: + self.screen.blit(tic, tic.get_rect(midleft=(ix, y + h // 2))) + else: + self.screen.blit(type_text, (ix, y)) + return tag + + def _draw_reveal(self): + t = self.font.render("Enemy team", True, TEXT) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 40))) + t = self.small.render(f"Floor {self.difficulty} — this is who you'll fight. Click a plant for details.", True, MUTED) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 74))) + for i, name in enumerate(self.enemies): + rect = self.enemy_card_rects[i] + pygame.draw.rect(self.screen, (36, 44, 36), rect, border_radius=8) + pygame.draw.rect(self.screen, (200, 90, 90), rect, 2, border_radius=8) + spr = self.sprites.get(name) + if spr: + ic = fit_surface(spr, 120) + self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 60))) + t = self.bold.render(name, True, TEXT) + self.screen.blit(t, t.get_rect(center=(rect.centerx, rect.y + 140))) + 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"] + 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))) + mouse = self.mouse_pos + btns = ([(self.next_rect, "Continue")] if self.no_roll + else [(self.roll_rect, "Roll"), (self.next_rect, "Continue")]) + for rect, label in btns: + pygame.draw.rect(self.screen, BTN_HOVER if rect.collidepoint(mouse) else BTN, rect, border_radius=8) + t = self.small.render(label, True, TEXT) + self.screen.blit(t, t.get_rect(center=rect.center)) + + def _draw_pick(self): + if self.tower_mode: + if self.header: + t = self.font.render(self.header, True, HIGHLIGHT) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 26))) + note = self.small.render(f"Pick {self.min_goal}-{self.pick_goal} plants from your collection.", True, MUTED) + elif self.pick_goal == 8: + t = self.font.render("Tower — pick your roster (1-8 plants)", True, HIGHLIGHT) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 26))) + note = self.small.render("4 fight each stage, you can swap from the bench between battles.", True, MUTED) + else: + t = self.font.render("Tower — pick your team (1-4 of your roster)", True, HIGHLIGHT) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 26))) + note = self.small.render("Enemies get stronger each stage (up to 20).", True, MUTED) + self.screen.blit(note, note.get_rect(center=(WIN_W // 2, 58))) + else: + t = self.font.render("Pick your allies", True, TEXT) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 26))) + self._draw_enemy_strip() + if self.show_slots: + self._draw_slots(self.ally_rects, self.allies, "Your Allies") + self._draw_pool() + self._draw_tower_enemies() + for i, rect in enumerate(self.preset_rects): + preset = collection.get_presets()[i] + has = bool(preset) + loaded = False + if self.pick_goal == 8 and preset: + plants = [n for n in preset if n in self.pool_names] + loaded = bool(plants) and all(n in self.allies for n in plants) + if self.pick_goal == 8: + bg = (BTN_HOVER if loaded else BTN) if has else (45, 50, 45) + border = HIGHLIGHT if loaded else ((150, 190, 150) if has else (70, 80, 70)) + pygame.draw.rect(self.screen, bg, rect, border_radius=6) + pygame.draw.rect(self.screen, border, rect, 2 if loaded else 1, border_radius=6) + else: + pygame.draw.rect(self.screen, BTN if has else (45, 50, 45), rect, border_radius=6) + pygame.draw.rect(self.screen, (150, 190, 150) if has else (70, 80, 70), + rect, 1, border_radius=6) + label = f"P{i + 1}" if not preset else f"P{i + 1}·{len(preset)}" + t = self.tiny.render(label, True, TEXT if preset else MUTED) + self.screen.blit(t, t.get_rect(center=rect.center)) + mouse = self.mouse_pos + ready = len(self.allies) >= self.min_goal + pygame.draw.rect(self.screen, BTN_HOVER if ready else (45, 50, 45), self.start_rect, border_radius=8) + if self.tower_mode: + if self.start_label: + label = self.start_label + elif self.pick_goal == 8: + label = "Start Tower" + else: + label = f"Fight ({len(self.allies)} picked)" + else: + label = "Start Battle" + t = self.small.render(label, True, TEXT) + self.screen.blit(t, t.get_rect(center=self.start_rect.center)) + + def _draw_enemy_strip(self): + for i, name in enumerate(self.enemies): + rect = self.enemy_strip_rects[i] + pygame.draw.rect(self.screen, (40, 40, 34), rect, border_radius=6) + pygame.draw.rect(self.screen, (200, 90, 90), rect, 2, border_radius=6) + spr = self.sprites.get(name) + if spr: + ic = fit_surface(spr, 56) + self.screen.blit(ic, ic.get_rect(center=(rect.x + 34, rect.y + 42))) + t = self.small.render(name, True, TEXT) + self.screen.blit(t, (rect.x + 66, rect.y + 12)) + tag = self._draw_role_type_tag(name, (rect.x + 66, rect.y + 34), self.tiny, + ptype=self.enemy_types[i] if i < len(self.enemy_types) else None) + self._draw_enemy_level(tag, i) + hint = self.tiny.render("click for details", True, (120, 130, 120)) + self.screen.blit(hint, (rect.right - 96, rect.y + 66)) + + def _draw_slots(self, rects, chosen, title): + t = self.bold.render(title, True, MUTED) + self.screen.blit(t, (rects[0].x, rects[0].y - 26)) + for i, rect in enumerate(rects): + pygame.draw.rect(self.screen, SLOT if i < len(chosen) else SLOT_EMPTY, rect, border_radius=6) + pygame.draw.rect(self.screen, (70, 80, 70), rect, 1, border_radius=6) + if i < len(chosen): + self._draw_mini(rect, chosen[i]) + elif i == len(chosen): + t = self.small.render("+", True, MUTED) + self.screen.blit(t, t.get_rect(center=rect.center)) + + def _draw_mini(self, rect, name): + spr = self.sprites.get(name) + x = rect.x + 6 + if spr: + ic = fit_surface(spr, 56) + self.screen.blit(ic, ic.get_rect(midleft=(x, rect.centery))) + t = self.small.render(name, True, TEXT) + self.screen.blit(t, (x + 62, rect.y + 8)) + self._draw_role_type_tag(name, (x + 62, rect.y + 30), self.tiny) + _draw_evo_badge(self.screen, self.tiny, (rect.right - 24, rect.y + 16), collection.get_progress(name)["stage"] - 1) + rune_colors = collection.equipped_rune_colors(name) + if rune_colors: + chip_w = len(rune_colors) * 14 + (len(rune_colors) - 1) * 2 + draw_rune_chips(self.screen, self._rune_icon, rune_colors, + rect.right - 40 - chip_w, rect.y + 32) + + def _draw_pool(self): + old_clip = self.screen.get_clip() + self.screen.set_clip(self.pool_area) + for name, rect in self.pool_rects.items(): + draw_rect = rect.move(0, -self.pool_scroll) + if not draw_rect.colliderect(self.pool_area): + continue + chosen = name in self.allies + bg = (50, 60, 50) if chosen else (36, 42, 36) + pygame.draw.rect(self.screen, bg, draw_rect, border_radius=6) + border = HIGHLIGHT if chosen else (60, 70, 60) + pygame.draw.rect(self.screen, border, draw_rect, 2 if chosen else 1, border_radius=6) + spr = self.sprites.get(name) + if spr: + ic = fit_surface(spr, 40) + self.screen.blit(ic, ic.get_rect(center=(draw_rect.x + 24, draw_rect.y + 24))) + t = self.tiny.render(name, True, TEXT) + self.screen.blit(t, (draw_rect.x + 50, draw_rect.y + 6)) + role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"]) + ric = self._role_icon(POOL[name]["role"], 14) + rs = self.tiny.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), MUTED)) + sep = self.tiny.render(" · ", True, MUTED) + ptype = collection.unit_type(name) + tic = load_type_icon(ptype, 14) if ptype else None + r_x = draw_rect.x + 50 + if ric: + self.screen.blit(ric, ric.get_rect(midleft=(r_x, draw_rect.y + 28 + 7))) + r_x += 17 + self.screen.blit(rs, (r_x, draw_rect.y + 28)) + r_x += rs.get_width() + self.screen.blit(sep, (r_x, draw_rect.y + 28)) + r_x += sep.get_width() + if tic: + self.screen.blit(tic, tic.get_rect(midleft=(r_x, draw_rect.y + 35))) + else: + shown = ptype if ptype else "No type" + ts = self.tiny.render(shown, True, TYPE_COLORS.get(ptype, MUTED) if ptype else MUTED) + self.screen.blit(ts, (r_x, draw_rect.y + 28)) + prog = collection.get_progress(name) + _draw_evo_badge(self.screen, self.tiny, (draw_rect.x + 14, draw_rect.y + 54), prog["stage"] - 1) + lv = self.tiny.render(f"Lv {prog['level']}", True, HINT) + self.screen.blit(lv, (draw_rect.x + 50, draw_rect.y + 46)) + rune_colors = collection.equipped_rune_colors(name) + if rune_colors: + draw_rune_chips(self.screen, self._rune_icon, rune_colors, + draw_rect.x + 104, draw_rect.y + 48) + self.screen.set_clip(old_clip) + if self.pool_scroll_max > 0: + hint = ("▲" if self.pool_scroll > 0 else "") + ("▼" if self.pool_scroll < self.pool_scroll_max else "") + if hint: + ht = self.tiny.render(hint, True, MUTED) + self.screen.blit(ht, ht.get_rect(center=(self.pool_area.centerx, self.pool_area.bottom + 6))) + + def _draw_tower_enemies(self): + if not self.tower_mode or not self.enemies: + return + # below the roster pool (which ends around y=308 for an 8-plant roster) + y = 340 + header = self.bold.render("Enemies this stage:", True, (200, 90, 90)) + self.screen.blit(header, (290, y)) + y += 30 + for i, name in enumerate(self.enemies): + rect = pygame.Rect(290, y, 240, 54) + pygame.draw.rect(self.screen, (40, 40, 34), rect, border_radius=6) + pygame.draw.rect(self.screen, (200, 90, 90), rect, 2, border_radius=6) + spr = self.sprites.get(name) + if spr: + ic = fit_surface(spr, 34) + self.screen.blit(ic, ic.get_rect(center=(rect.x + 23, rect.y + 27))) + t = self.tiny.render(name, True, TEXT) + self.screen.blit(t, (rect.x + 46, rect.y + 8)) + tag = self._draw_role_type_tag(name, (rect.x + 46, rect.y + 30), self.tiny) + self._draw_enemy_level(tag, i) + y += 60 + + def _draw_detail_panel(self): + name = self.detail_name + if name is None: + return + spec = POOL[name] + shade = pygame.Surface((WIN_W, WIN_H)) + shade.fill((0, 0, 0)) + shade.set_alpha(120) + self.screen.blit(shade, (0, 0)) + pygame.draw.rect(self.screen, (30, 36, 30), self.detail_rect, border_radius=10) + pygame.draw.rect(self.screen, (90, 110, 90), self.detail_rect, 2, border_radius=10) + + pygame.draw.rect(self.screen, (140, 70, 70), self.detail_close, border_radius=6) + t = self.small.render("X", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.detail_close.center)) + + spr = self.sprites.get(name) + if spr: + ic = fit_surface(spr, 120) + 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)) + 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: + idx = self.detail_list.index(name) + if idx < len(self.enemy_types): + enemy_type = self.enemy_types[idx] + self.detail_type_rect = self._draw_role_type_tag( + 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(): + 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"] + 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 + p = spec.get("passive", "") + if p: + pname, pdesc = PASSIVES.get(p, (p, "")) + self.screen.blit(self.bold.render(f"Passive — {pname}", True, HINT), + (self.detail_rect.x + 24, y)) + lines = wrap_text(" " + pdesc, 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 spec["skills"]: + bracket = _bracket_text(sk) + label = f"{sk.name} [{bracket}] CD {sk.cooldown}" if bracket else f"{sk.name} CD {sk.cooldown}" + 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 + 22)) + y += 50 + + if self.phase == 2 and not self.detail_is_enemy and name in self.pool_names: + addable = name not in self.allies and len(self.allies) < self.pick_goal + label = "In Team" if name in self.allies else ("Add to Team" if addable else "Team full") + pygame.draw.rect(self.screen, BTN_HOVER if addable else (45, 50, 45), + self.detail_add, border_radius=6) + t = self.tiny.render(label, True, TEXT if addable else MUTED) + self.screen.blit(t, t.get_rect(center=self.detail_add.center)) + + pygame.draw.rect(self.screen, BTN, self.detail_left, border_radius=6) + t = self.bold.render("<", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.detail_left.center)) + pygame.draw.rect(self.screen, BTN, self.detail_right, border_radius=6) + t = self.bold.render(">", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.detail_right.center)) + idx = self.detail_list.index(name) if name in self.detail_list else 0 + pos = self.tiny.render(f"{idx + 1}/{len(self.detail_list)}", True, MUTED) + self.screen.blit(pos, pos.get_rect(center=(self.detail_rect.centerx, self.detail_rect.bottom - 18))) + + # __MORE__ \ No newline at end of file diff --git a/ui/presets.py b/ui/presets.py new file mode 100644 index 0000000..b03cfc0 --- /dev/null +++ b/ui/presets.py @@ -0,0 +1,104 @@ +import pygame + +from engine import collection +from ui.scaling import make_window, request_quit, to_logical + +WIN_W, WIN_H = 1040, 720 +BG = (24, 30, 24) +TEXT = (230, 235, 230) +MUTED = (150, 155, 150) +HINT = (255, 220, 120) +PANEL = (32, 38, 32) +BTN = (60, 90, 60) +BTN_HOVER = (85, 125, 85) + + +class PresetScreen: + def __init__(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 — Team Presets") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 30) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.back_rect = pygame.Rect(16, 16, 90, 34) + self.slots = [] + for i in range(4): + card = pygame.Rect(WIN_W // 2 - 240, 130 + i * 92, 480, 76) + set_btn = pygame.Rect(card.right - 180, card.y + 24, 80, 28) + clear_btn = pygame.Rect(card.right - 90, card.y + 24, 80, 28) + self.slots.append((card, set_btn, clear_btn)) + + def run(self): + running = True + while running: + self.clock.tick(60) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + pos = to_logical(event.pos, self.scale) + if self.back_rect.collidepoint(pos): + running = False + continue + for i, (card, set_btn, clear_btn) in enumerate(self.slots): + if set_btn.collidepoint(pos): + self._set_slot(i) + elif clear_btn.collidepoint(pos): + collection.clear_preset(i) + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + + def _set_slot(self, i): + from ui.picker import TeamSelect + ts = TeamSelect(tower_mode=True, pick_goal=4, header="Pick a team", + start_label="Save") + res = ts.run() + self._reinit() # the nested picker quit pygame + if res and res != "back" and res[0]: + collection.save_preset(i, res[0]) + + 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 — Team Presets") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 30) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + + def _draw(self): + self.screen.fill(BG) + title = self.font.render("Team Presets", True, TEXT) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 52))) + sub = self.small.render("Set up to 4 teams; load them with the P1–P4 buttons in battle.", True, MUTED) + self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 88))) + pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) + t = self.small.render("Back", True, TEXT) + self.screen.blit(t, t.get_rect(center=self.back_rect.center)) + presets = collection.get_presets() + for i, (card, set_btn, clear_btn) in enumerate(self.slots): + pygame.draw.rect(self.screen, PANEL, card, border_radius=8) + pygame.draw.rect(self.screen, (60, 70, 60), card, 1, border_radius=8) + head = self.bold.render(f"Preset {i + 1}", True, HINT) + self.screen.blit(head, (card.x + 16, card.y + 10)) + team = presets[i] if i < len(presets) else None + if team: + names = " · ".join(team) + tt = self.tiny.render(names, True, TEXT) + else: + tt = self.tiny.render("Empty", True, MUTED) + self.screen.blit(tt, (card.x + 16, card.y + 42)) + pygame.draw.rect(self.screen, BTN_HOVER, set_btn, border_radius=6) + s = self.tiny.render("Set", True, TEXT) + self.screen.blit(s, s.get_rect(center=set_btn.center)) + pygame.draw.rect(self.screen, (150, 70, 70), clear_btn, border_radius=6) + c = self.tiny.render("Clear", True, TEXT) + self.screen.blit(c, c.get_rect(center=clear_btn.center)) \ No newline at end of file diff --git a/ui/relic_choice.py b/ui/relic_choice.py new file mode 100644 index 0000000..6ac2f29 --- /dev/null +++ b/ui/relic_choice.py @@ -0,0 +1,130 @@ +import pygame + +from engine.plants import RARITY_COLORS +from engine.relics import RELIC_POOL +from ui.relics_overview import RelicsOverview +from ui.scaling import make_window, request_quit, to_logical + +WIN_W, WIN_H = 1040, 720 +BG = (24, 30, 24) +TEXT = (230, 235, 230) +MUTED = (150, 155, 150) +HINT = (255, 220, 120) +CARD = (36, 44, 36) +BORDER = (90, 120, 90) +SKIP = (60, 60, 60) + + +class RelicChoiceScreen: + """Pick one of the offered roguelike blessings (or skip).""" + + def __init__(self, relics, stage, owned=()): + 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") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 36) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.relics = relics + self.stage = stage + self.owned = list(owned) + self.overview = None + self.cards = [] + cw, ch = 250, 300 + gap = 26 + total = len(relics) * cw + (len(relics) - 1) * gap + x0 = (WIN_W - total) // 2 + y = 190 + for i, r in enumerate(relics): + self.cards.append((r, pygame.Rect(x0 + i * (cw + gap), y, cw, ch))) + self.skip_rect = pygame.Rect(WIN_W // 2 - 80, WIN_H - 70, 160, 40) + self.all_btn = pygame.Rect(24, WIN_H - 70, 160, 40) + + def run(self): + running = True + result = None + while running: + self.clock.tick(60) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEWHEEL: + if self.overview is not None: + self.overview.handle_wheel(event.y) + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + pos = to_logical(event.pos, self.scale) + if self.overview is not None: + if self.overview.handle_click(pos) == "close": + self.overview = None + continue + for r, rect in self.cards: + if rect.collidepoint(pos): + result = r + running = False + break + else: + if self.skip_rect.collidepoint(pos): + result = None + running = False + elif self.all_btn.collidepoint(pos): + self.overview = RelicsOverview( + self.screen, self.bold, self.small, self.tiny, + RELIC_POOL, self.owned) + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + return result + + def _wrap(self, text, font, width): + words = text.split() + lines = [] + cur = "" + for w in words: + if font.size(cur + " " + w)[0] > width and cur: + lines.append(cur) + cur = w + else: + cur = (cur + " " + w).strip() + if cur: + lines.append(cur) + return lines + + def _draw(self): + self.screen.fill(BG) + if self.overview is not None: + self.overview.draw(pygame.mouse.get_pos()) + return + t = self.font.render("Choose a blessing", True, HINT) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 60))) + st = self.small.render( + f"Stage {self.stage} defeated — pick a relic for the rest of the tower.", + True, MUTED) + self.screen.blit(st, st.get_rect(center=(WIN_W // 2, 110))) + mouse = pygame.mouse.get_pos() + for r, rect in self.cards: + hover = rect.collidepoint(mouse) + pygame.draw.rect(self.screen, CARD, rect, border_radius=10) + pygame.draw.rect(self.screen, HINT if hover else BORDER, rect, 2, border_radius=10) + name = self.bold.render(r.name, True, HINT) + self.screen.blit(name, name.get_rect(midtop=(rect.centerx, rect.y + 16))) + rar = self.small.render(r.rarity.capitalize(), True, RARITY_COLORS.get(r.rarity, MUTED)) + self.screen.blit(rar, rar.get_rect(midtop=(rect.centerx, rect.y + 50))) + y = rect.y + 92 + for ln in self._wrap(r.desc, self.small, rect.w - 24)[:7]: + lr = self.small.render(ln, True, TEXT) + self.screen.blit(lr, lr.get_rect(midtop=(rect.centerx, y))) + y += 24 + pygame.draw.rect(self.screen, SKIP, self.skip_rect, border_radius=8) + pygame.draw.rect(self.screen, (150, 150, 150), self.skip_rect, 2, border_radius=8) + sk = self.small.render("Skip", True, TEXT) + self.screen.blit(sk, sk.get_rect(center=self.skip_rect.center)) + pygame.draw.rect(self.screen, (70, 90, 70), self.all_btn, border_radius=8) + pygame.draw.rect(self.screen, BORDER, self.all_btn, 2, border_radius=8) + ab = self.small.render("All blessings", True, TEXT) + self.screen.blit(ab, ab.get_rect(center=self.all_btn.center)) + hint = self.tiny.render("Blessings persist for the rest of this tower run", True, MUTED) + self.screen.blit(hint, hint.get_rect(center=(WIN_W // 2, WIN_H - 20))) \ No newline at end of file diff --git a/ui/relics_overview.py b/ui/relics_overview.py new file mode 100644 index 0000000..79e6fe9 --- /dev/null +++ b/ui/relics_overview.py @@ -0,0 +1,178 @@ +import pygame + +from engine.plants import RARITY_COLORS +from engine.relics import RELIC_POOL + +BG = (24, 30, 24) +TEXT = (230, 235, 230) +MUTED = (150, 155, 150) +HINT = (255, 220, 120) +PANEL = (30, 36, 30) +PANEL_BORDER = (150, 190, 150) +CARD = (36, 44, 36) +CARD_OWNED = (46, 54, 42) +GOLD = (245, 200, 90) +OWNED = (110, 220, 255) # bright cyan: distinct from the legendary gold border + +TIER_ORDER = ("common", "rare", "epic", "legendary") +TIER_LABELS = {"common": "Common", "rare": "Rare", "epic": "Epic", "legendary": "Legendary"} + + +def _wrap(text, font, width): + words = text.split() + lines = [] + cur = "" + for w in words: + if font.size(cur + " " + w)[0] > width and cur: + lines.append(cur) + cur = w + else: + cur = (cur + " " + w).strip() + if cur: + lines.append(cur) + return lines + + +class RelicsOverview: + """Full-screen overlay listing every blessing, grouped by rarity, with the + ones collected this run highlighted. Click a relic to inspect it up close. + Host screens draw this onto their own surface (no pygame display ownership), + so it can open mid-battle without tearing down the window.""" + + def __init__(self, screen, bold, small, tiny, pool=None, owned_keys=()): + self.screen = screen + self.bold = bold + self.small = small + self.tiny = tiny + self.pool = list(pool if pool is not None else RELIC_POOL) + self.owned = set(owned_keys) + self.scroll = 0 + self.detail = None + w, h = screen.get_size() + pw = min(760, w - 60) + ph = min(500, h - 140) + self.panel = pygame.Rect((w - pw) // 2, (h - ph) // 2, pw, ph) + pad = 24 + self.cards_top = self.panel.y + 78 + self.inner = pygame.Rect(self.panel.x + pad, self.cards_top, + self.panel.w - 2 * pad, + self.panel.bottom - 20 - self.cards_top) + self.close_rect = pygame.Rect(self.panel.right - 74, self.panel.y + 14, 58, 28) + rank = {t: i for i, t in enumerate(TIER_ORDER)} + self.pool.sort(key=lambda r: (rank.get(r.rarity, 9), r.name.lower())) + cols = 3 + gap = 14 + cw = (self.inner.w - gap * (cols - 1)) // cols + ch = 118 + self.cards = [] + x, y = self.inner.x, self.inner.y + for i, r in enumerate(self.pool): + self.cards.append((r, pygame.Rect(x, y, cw, ch))) + if (i + 1) % cols == 0: + x = self.inner.x + y += ch + gap + else: + x += cw + gap + self.content_h = y - self.inner.y if self.cards else 0 + self.scroll_max = max(0, self.content_h - self.inner.h) + + def handle_wheel(self, y): + if self.detail is not None: + return + self.scroll = max(0, min(self.scroll_max, self.scroll - y * 40)) + + def handle_click(self, pos): + if self.detail is not None: + self.detail = None + return None + if self.close_rect.collidepoint(pos): + return "close" + if not self.inner.collidepoint(pos): + return "close" if not self.panel.collidepoint(pos) else None + for r, rect in self.cards: + draw_rect = rect.move(0, -self.scroll) + if draw_rect.collidepoint(pos) and draw_rect.colliderect(self.inner): + self.detail = r + return None + return None + + def _draw_card(self, r, rect): + owned = r.key in self.owned + bg = CARD_OWNED if owned else CARD + border = OWNED if owned else RARITY_COLORS.get(r.rarity, (150, 150, 150)) + pygame.draw.rect(self.screen, bg, rect, border_radius=8) + pygame.draw.rect(self.screen, border, rect, 3 if owned else 1, border_radius=8) + name = self.small.render(r.name, True, RARITY_COLORS.get(r.rarity, TEXT)) + self.screen.blit(name, (rect.x + 10, rect.y + 8)) + if owned: + tag = self.tiny.render("OWNED", True, OWNED) + self.screen.blit(tag, (rect.right - tag.get_width() - 8, rect.y + 10)) + y = rect.y + 34 + for ln in _wrap(r.desc, self.tiny, rect.w - 20)[:3]: + self.screen.blit(self.tiny.render(ln, True, MUTED), (rect.x + 10, y)) + y += 16 + tip = self.tiny.render(f"{TIER_LABELS.get(r.rarity, r.rarity)}" + + (" · click to inspect" if owned else " · not owned"), + True, OWNED if owned else (110, 120, 110)) + self.screen.blit(tip, (rect.x + 10, rect.bottom - 18)) + + def _draw_detail(self): + r = self.detail + if r is None: + return + shade = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) + shade.fill((0, 0, 0, 130)) + self.screen.blit(shade, (0, 0)) + w, h = 440, 230 + panel = pygame.Rect((self.screen.get_width() - w) // 2, + (self.screen.get_height() - h) // 2, w, h) + pygame.draw.rect(self.screen, (36, 44, 36), panel, border_radius=10) + pygame.draw.rect(self.screen, RARITY_COLORS.get(r.rarity, PANEL_BORDER), + panel, 2, border_radius=10) + name = self.bold.render(r.name, True, RARITY_COLORS.get(r.rarity, TEXT)) + self.screen.blit(name, name.get_rect(midtop=(panel.centerx, panel.y + 22))) + rar = self.small.render(TIER_LABELS.get(r.rarity, r.rarity).capitalize(), True, MUTED) + self.screen.blit(rar, rar.get_rect(midtop=(panel.centerx, panel.y + 54))) + y = panel.y + 92 + for ln in _wrap(r.desc, self.small, panel.w - 48)[:5]: + line = self.small.render(ln, True, TEXT) + self.screen.blit(line, line.get_rect(midtop=(panel.centerx, y))) + y += 26 + owned = r.key in self.owned + st = self.tiny.render("OWNED this run" if owned else "Not owned yet", True, + OWNED if owned else MUTED) + self.screen.blit(st, st.get_rect(midtop=(panel.centerx, y + 4))) + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 20))) + + def draw(self, mouse): + overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) + self.screen.blit(overlay, (0, 0)) + pygame.draw.rect(self.screen, PANEL, self.panel, border_radius=12) + pygame.draw.rect(self.screen, PANEL_BORDER, self.panel, 2, border_radius=12) + title = self.bold.render("All blessings", True, HINT) + self.screen.blit(title, title.get_rect(midtop=(self.panel.centerx, self.panel.y + 14))) + sub = self.tiny.render( + f"{len(self.owned)} owned this run · cyan border = collected · click a card to inspect", + True, MUTED) + self.screen.blit(sub, sub.get_rect(midtop=(self.panel.centerx, self.panel.y + 44))) + pygame.draw.rect(self.screen, (90, 110, 90), self.close_rect, border_radius=6) + pygame.draw.rect(self.screen, PANEL_BORDER, self.close_rect, 1, border_radius=6) + c = self.small.render("Close", True, TEXT) + self.screen.blit(c, c.get_rect(center=self.close_rect.center)) + old = self.screen.get_clip() + self.screen.set_clip(self.inner) + for r, rect in self.cards: + draw_rect = rect.move(0, -self.scroll) + if not draw_rect.colliderect(self.inner): + continue + self._draw_card(r, draw_rect) + self.screen.set_clip(old) + if self.scroll_max > 0: + hint = ("▲" if self.scroll > 0 else "") + ("▼" if self.scroll < self.scroll_max else "") + if hint: + ht = self.tiny.render(hint, True, MUTED) + self.screen.blit(ht, ht.get_rect(midbottom=(self.inner.centerx, self.inner.bottom))) + if self.detail is not None: + self._draw_detail() \ No newline at end of file diff --git a/ui/runes.py b/ui/runes.py new file mode 100644 index 0000000..520aef2 --- /dev/null +++ b/ui/runes.py @@ -0,0 +1,437 @@ +import pygame + +from assets import icon as icon_path +from engine import collection +from engine.plants import POOL, RARITY_COLORS, plant_rarity +from engine import runes +from ui.scaling import fit_surface, load_rune_icon, make_window, request_quit, to_logical + +WIN_W, WIN_H = 1040, 720 +BG = (24, 30, 24) +TEXT = (230, 235, 230) +MUTED = (150, 155, 150) +HINT = (255, 220, 120) +PANEL = (32, 38, 32) +BTN = (60, 90, 60) +BTN_HOVER = (85, 125, 85) +ACCENT = (120, 220, 190) +DANGER = (150, 70, 70) +SLOT_BORDER = (90, 110, 90) + +SLOT_SIZE = 64 + + +def draw_rune_chips(screen, get_icon, colors, x, y, size=14, gap=2, max_chips=3): + """Draw small rune icons for the given colors starting at (x, y). `get_icon(color)` + returns a surface (already sized) or None. Used to show equipped runes at a glance.""" + for i, color in enumerate(colors[:max_chips]): + ic = get_icon(color) + if ic: + screen.blit(ic, (x + i * (size + gap), y)) + + +class RuneScreen: + def __init__(self, plant: str): + pygame.init() + 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}") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 30) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.back_rect = pygame.Rect(16, 16, 90, 34) + self.info_rect = pygame.Rect(WIN_W - 100, 16, 84, 34) + self.mouse_pos = (0, 0) + self.selected = None # rune id selected from the inventory + self.confirm = None # {"kind","slot","rune",...} prompt + self.confirm_ok = pygame.Rect(0, 0, 0, 0) + self.confirm_no = pygame.Rect(0, 0, 0, 0) + self.info = False # rune-effects info popup + self.equip_msg = "" # transient message (e.g. rune-type limit) + self.equip_msg_until = 0 + self.filter_tier = 0 # 0 all, 1 tier 1, 2 tier 2 + self.filter_color = None # None all, else a rune color + self.sort_best = False # sort by proportional roll + self.filter_rects = [] # (kind, value, rect) + self.sort_rect = pygame.Rect(WIN_W - 170, 284, 150, 24) + self.slot_rects = [] # (slot_index, rect) + self.inv_rects = [] # (rune_id, rect) + self.scroll = 0 + self.scroll_max = 0 + self._icons = {} + + def _icon_for(self, color): + if color not in self._icons: + self._icons[color] = load_rune_icon(color, 40) + return self._icons[color] + + def run(self): + running = True + while running: + self.clock.tick(60) + self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + pos = to_logical(event.pos, self.scale) + if self.back_rect.collidepoint(pos): + running = False + continue + if self.info: + self.info = False + continue + if self.info_rect.collidepoint(pos): + self.info = True + continue + if self.confirm is not None: + if self.confirm_ok.collidepoint(pos): + if self.confirm["kind"] == "move": + collection.equip_rune(self.plant, self.confirm["slot"], + self.confirm["rune"]["id"]) + self.selected = None + else: + collection.unequip_rune(self.plant, self.confirm["slot"]) + self.confirm = None + elif self.confirm_no.collidepoint(pos): + self.confirm = None + continue + self._click(pos) + elif event.type == pygame.MOUSEWHEEL: + self.scroll = max(0, min(self.scroll_max, self.scroll - event.y * 44)) + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + + def _click(self, pos): + if self._click_filter(pos): + return + equipped = collection.equipped_runes(self.plant) + inventory = collection.rune_inventory() + by_id = {r["id"]: r for r in inventory} + for slot, rect in self.slot_rects: + if rect.collidepoint(pos): + if self.selected is not None: + rune = by_id.get(self.selected) + owner = collection.rune_location(self.selected) + if owner and owner != self.plant: + self.confirm = {"kind": "move", "slot": slot, + "rune": rune, "from": owner} + else: + reason = collection.equip_rune(self.plant, slot, self.selected) + self.equip_msg = reason or "" + self.equip_msg_until = (pygame.time.get_ticks() + 2000) if reason else 0 + self.selected = None + elif slot < len(equipped) and equipped[slot] is not None: + self.confirm = {"kind": "remove", "slot": slot, + "rune": by_id.get(equipped[slot])} + return + for rune_id, rect in self.inv_rects: + if rect.collidepoint(pos): + self.selected = None if self.selected == rune_id else rune_id + return + + def _click_filter(self, pos): + for kind, val, rect in self.filter_rects: + if rect.collidepoint(pos): + if kind == "tier": + self.filter_tier = val + elif kind == "color": + self.filter_color = val + return True + if self.sort_rect.collidepoint(pos): + self.sort_best = not self.sort_best + return True + return False + + def _displayed_runes(self, inventory): + shown = [r for r in inventory + if (self.filter_tier == 0 or runes.tier(r["color"]) == self.filter_tier) + and (self.filter_color is None or r["color"] == self.filter_color)] + if self.sort_best: + shown.sort(key=runes.roll_ratio, reverse=True) + return shown + + def _small_icon(self, color): + key = ("small", color) + if key not in self._icons: + ic = self._icon_for(color) + self._icons[key] = fit_surface(ic, 22) if ic else None + return self._icons[key] + + def _draw_filters(self): + self.filter_rects = [] + y, h = 284, 24 + tl = self.tiny.render("Tier:", True, MUTED) + self.screen.blit(tl, (60, y + 5)) + x = 92 + for val, label in ((0, "All"), (1, "T1"), (2, "T2")): + active = self.filter_tier == val + rect = pygame.Rect(x, y, 30, h) + pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), + rect, border_radius=4) + pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), + rect, 1, border_radius=4) + t = self.tiny.render(label, True, TEXT if active else MUTED) + self.screen.blit(t, t.get_rect(center=rect.center)) + self.filter_rects.append(("tier", val, rect)) + x += 36 + ty = self.tiny.render("Type:", True, MUTED) + self.screen.blit(ty, (210, y + 5)) + x = 244 + all_rect = pygame.Rect(x, y, 36, h) + active = self.filter_color 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.filter_rects.append(("color", None, all_rect)) + x += 42 + for color in runes.RUNE_COLORS: + rect = pygame.Rect(x, y, 24, h) + active = self.filter_color == color + pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4) + pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), + rect, 1 if active else 2, border_radius=4) + sw = self._small_icon(color) + if sw: + self.screen.blit(sw, sw.get_rect(center=rect.center)) + self.filter_rects.append(("color", color, rect)) + x += 28 + active = self.sort_best + pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45), + self.sort_rect, border_radius=4) + pygame.draw.rect(self.screen, HINT if active else (70, 80, 70), + self.sort_rect, 1, border_radius=4) + sl = self.tiny.render("Sort: best roll" if active else "Sort: default", + True, TEXT if active else MUTED) + self.screen.blit(sl, sl.get_rect(center=self.sort_rect.center)) + + def _draw(self): + self.slot_rects = [] + self.inv_rects = [] + self.screen.fill(BG) + + pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6) + bt = self.small.render("Back", True, TEXT) + self.screen.blit(bt, bt.get_rect(center=self.back_rect.center)) + + ihov = self.info_rect.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, (90, 120, 90) if (self.info or ihov) else BTN, + self.info_rect, border_radius=6) + il = self.tiny.render("Rune info", True, TEXT) + self.screen.blit(il, il.get_rect(center=self.info_rect.center)) + + spec = POOL[self.plant] + rar = plant_rarity(self.plant) + title = self.font.render(self.plant, True, RARITY_COLORS.get(rar, TEXT)) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 44))) + sub = self.small.render(f"Rune slots — {spec['role']}", True, MUTED) + self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 76))) + + slots = collection.rune_slots(self.plant) + equipped = collection.equipped_runes(self.plant) + inventory = collection.rune_inventory() + by_id = {r["id"]: r for r in inventory} + + # --- slots ----------------------------------------------------- + st = self.bold.render("Rune slots", True, HINT) + self.screen.blit(st, (60, 110)) + locked = self.tiny.render( + "Evolve to unlock more slots (permanent)", True, MUTED) + self.screen.blit(locked, (200, 116)) + + total_w = slots * SLOT_SIZE + (slots - 1) * 20 + x0 = (WIN_W - total_w) // 2 + y = 140 + for i in range(slots): + rect = pygame.Rect(x0 + i * (SLOT_SIZE + 20), y, SLOT_SIZE, SLOT_SIZE) + pygame.draw.rect(self.screen, PANEL, rect, border_radius=10) + pygame.draw.rect(self.screen, SLOT_BORDER, rect, 2, border_radius=10) + rune = by_id.get(equipped[i]) if i < len(equipped) else None + if rune: + ic = self._icon_for(rune["color"]) + if ic: + self.screen.blit(ic, ic.get_rect(center=rect.center)) + else: + num = self.bold.render(str(i + 1), True, MUTED) + num.set_alpha(70) # faint hint that the slot is empty + self.screen.blit(num, num.get_rect(center=rect.center)) + lbl = self.tiny.render(f"Slot {i + 1}", True, MUTED) + self.screen.blit(lbl, lbl.get_rect(center=(rect.centerx, rect.bottom + 14))) + self.slot_rects.append((i, rect)) + + # --- equipped-effects summary (right of the slots) ------------ + equipped_runes = [by_id[e] for e in equipped if e in by_id] + line_h = 18 + panel = pygame.Rect(x0 + total_w + 40, y, 240, + 26 + line_h * max(1, len(equipped_runes)) + 8) + pygame.draw.rect(self.screen, PANEL, panel, border_radius=8) + pygame.draw.rect(self.screen, (60, 70, 60), panel, 1, border_radius=8) + t = self.bold.render("Equipped effects", True, HINT) + self.screen.blit(t, (panel.x + 8, panel.y + 6)) + py = panel.y + 28 + if equipped_runes: + for rune in equipped_runes: + eff = self.tiny.render( + runes.effect_text(rune["color"], rune.get("pct", 30)), True, TEXT) + self.screen.blit(eff, (panel.x + 8, py)) + py += line_h + else: + e = self.tiny.render("No runes equipped", True, MUTED) + self.screen.blit(e, (panel.x + 8, py)) + + if pygame.time.get_ticks() < self.equip_msg_until: + b = self.tiny.render(self.equip_msg, True, DANGER) + self.screen.blit(b, b.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44))) + elif self.selected is not None: + sel = by_id.get(self.selected) + if sel: + owner = collection.rune_location(self.selected) + if owner and owner != self.plant: + hint = self.bold.render( + f"Click a slot to move {sel['name']} from {owner}", True, ACCENT) + else: + hint = self.bold.render( + f"Click a slot to equip {sel['name']}", True, ACCENT) + self.screen.blit(hint, hint.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44))) + elif slots == 1: + h = self.tiny.render("Click a rune below, then click a slot to equip it.", + True, MUTED) + self.screen.blit(h, h.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44))) + + # --- inventory ------------------------------------------------- + it = self.bold.render("Runes you own", True, HINT) + self.screen.blit(it, (60, 260)) + dr = self.tiny.render( + f"Drop rates — {runes.BATTLE_DROP_CHANCE * 100:.0f}% for any rune " + f"from floor battles · {runes.PACK_DROP_CHANCE * 100:.0f}% for any " + f"rune from packs", + True, MUTED) + self.screen.blit(dr, dr.get_rect(midright=(WIN_W - 60, 270))) + self._draw_filters() + display = self._displayed_runes(inventory) + list_y = 318 + self.scroll_max = max(0, len(display) * 46 - (WIN_H - list_y - 20)) + self.scroll = max(0, min(self.scroll, self.scroll_max)) + + old_clip = self.screen.get_clip() + self.screen.set_clip(pygame.Rect(0, list_y, WIN_W, WIN_H - list_y - 20)) + ry = list_y - self.scroll + for rune in display: + rect = pygame.Rect(60, ry, 780, 40) + pygame.draw.rect(self.screen, PANEL, rect, border_radius=6) + if rune["id"] == self.selected: + pygame.draw.rect(self.screen, ACCENT, rect, 2, border_radius=6) + else: + pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=6) + ic = self._icon_for(rune["color"]) + if ic: + self.screen.blit(ic, (rect.x + 8, rect.y + 4)) + owner = collection.rune_location(rune["id"]) + on_this = owner == self.plant + name = self.small.render(rune["name"], True, + TEXT if not on_this else MUTED) + self.screen.blit(name, (rect.x + 58, rect.y + 11)) + eff = self.tiny.render(runes.effect_text(rune["color"], rune.get("pct", 30)), + True, MUTED) + self.screen.blit(eff, (rect.x + 58 + name.get_width() + 10, rect.y + 14)) + if on_this: + tag = self.tiny.render("equipped", True, MUTED) + self.screen.blit(tag, (rect.right - 70, rect.y + 13)) + elif owner: + tag = self.tiny.render(f"Equipped in: {owner}", True, MUTED) + self.screen.blit(tag, tag.get_rect(midright=(rect.right - 14, rect.centery))) + self.inv_rects.append((rune["id"], rect)) + ry += 46 + self.screen.set_clip(old_clip) + + if not inventory: + empty = self.small.render( + "No runes yet — win floor battles or open seed packs to find some.", + True, MUTED) + self.screen.blit(empty, (60, list_y + 10)) + elif not display: + empty = self.small.render("No runes match the current filter.", + True, MUTED) + self.screen.blit(empty, (60, list_y + 10)) + if self.scroll_max > 0: + hint = "▲" if self.scroll > 0 else "" + if self.scroll < self.scroll_max: + hint += "▼" + ht = self.tiny.render(hint, True, MUTED) + self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 18))) + + if self.confirm is not None: + self._draw_confirm() + if self.info: + self._draw_info() + + def _draw_info(self): + 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 + 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) + title = self.bold.render("Rune effects", True, HINT) + self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + y = panel.y + 62 + for color in runes.RUNE_COLORS: + ic = self._icon_for(color) + if ic: + self.screen.blit(ic, (panel.x + 40, y)) + nm = self.bold.render(runes.name(color), True, TEXT) + self.screen.blit(nm, (panel.x + 92, y)) + eff = self.small.render(runes.summary(color), True, MUTED) + self.screen.blit(eff, (panel.x + 92, y + 24)) + rg = self.tiny.render( + f"Tier {runes.tier(color)} · Rolls {runes.roll_range(color)}", + True, ACCENT) + self.screen.blit(rg, rg.get_rect(topright=(panel.right - 16, y + 10))) + y += 58 + note = self.tiny.render( + "Runes of the same effect stack additively; a plant can equip at " + "most %d runes of the same type." % runes.MAX_RUNES_PER_TYPE, + True, MUTED) + self.screen.blit(note, note.get_rect(center=(panel.centerx, panel.bottom - 24))) + close = self.tiny.render("Click anywhere to close", True, MUTED) + self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 8))) + + def _draw_confirm(self): + overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + self.screen.blit(overlay, (0, 0)) + w, h = 480, 190 + 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, DANGER, panel, 2, border_radius=12) + rune = self.confirm["rune"] + name = rune["name"] if rune else "Rune" + if self.confirm["kind"] == "move": + title = self.bold.render("Move rune?", True, TEXT) + msg = self.small.render( + f"Remove {name} from {self.confirm['from']} to slot it here?", True, TEXT) + ok_label = "Move" + else: + slot = self.confirm["slot"] + 1 + title = self.bold.render("Remove rune?", True, TEXT) + msg = self.small.render(f"Unequip {name} from slot {slot}?", True, TEXT) + ok_label = "Remove" + 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 + 66))) + self.confirm_ok = pygame.Rect(panel.centerx - 110, panel.y + 110, 100, 36) + self.confirm_no = pygame.Rect(panel.centerx + 10, panel.y + 110, 100, 36) + pygame.draw.rect(self.screen, DANGER, self.confirm_ok, border_radius=8) + ok = self.bold.render(ok_label, True, TEXT) + self.screen.blit(ok, ok.get_rect(center=self.confirm_ok.center)) + pygame.draw.rect(self.screen, BTN, self.confirm_no, border_radius=8) + no = self.bold.render("Cancel", True, TEXT) + self.screen.blit(no, no.get_rect(center=self.confirm_no.center)) \ No newline at end of file diff --git a/ui/scaling.py b/ui/scaling.py new file mode 100644 index 0000000..2e063a3 --- /dev/null +++ b/ui/scaling.py @@ -0,0 +1,150 @@ +import sys +import ctypes +from ctypes import wintypes + +import pygame + +from assets import plot_icon_path, rune_icon_path, type_icon_path + +# Set when the user closes the window (X button). Screens keep running loops, +# but the top-level driver (main.py) checks this after every screen and bails +# out of the whole app so closing never lands you on another menu. +QUIT_REQUESTED = False +_desktop = None + + +def request_quit(): + global QUIT_REQUESTED + QUIT_REQUESTED = True + + +def _work_area(): + """Usable desktop area (excludes the Windows taskbar), or None if unavailable.""" + if sys.platform != "win32": + return None + try: + rect = wintypes.RECT() + ctypes.windll.user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(rect), 0) # SPI_GETWORKAREA + return rect.right - rect.left, rect.bottom - rect.top + except Exception: + return None + + +def _desktop_size(): + """The actual monitor resolution, cached after the first call. On macOS, + pygame.display.Info() reports the *current window* size, so using it here + makes each make_window() treat the last window as the available space and + the window shrinks on every screen transition. get_desktop_sizes() is + window-independent.""" + global _desktop + if _desktop is None: + sizes = pygame.display.get_desktop_sizes() + if sizes: + _desktop = sizes[0] + else: + info = pygame.display.Info() + _desktop = (info.current_w, info.current_h) + return _desktop + + +def make_window(logical_w: int, logical_h: int): + """Create a window sized to fit the usable desktop (downscales only, keeps + aspect ratio). Returns (window_surface, scale_factor). Render at the logical + size and smoothscale into the window each frame; divide mouse coordinates by + `scale` to get logical coordinates.""" + pygame.display.init() + area = _work_area() or _desktop_size() + avail_w = max(1, area[0] - 24) # window border safety + avail_h = max(1, area[1] - 48) # title bar safety + scale = min(1.0, avail_w / logical_w, avail_h / logical_h) + scale = max(0.2, scale) + size = (int(logical_w * scale), int(logical_h * scale)) + window = pygame.display.set_mode(size) + return window, scale + + +def to_logical(pos, scale: float): + return (int(pos[0] / scale), int(pos[1] / scale)) + + +def fit_surface(img, size): + """Scale `img` to fit within a `size`x`size` box, preserving aspect ratio. + Square sources become `size`x`size`; non-square sources shrink the smaller + axis so nothing is stretched or squished (e.g. a portrait water drop).""" + w, h = img.get_size() + scale = size / max(w, h) + nw = max(1, int(round(w * scale))) + nh = max(1, int(round(h * scale))) + return pygame.transform.smoothscale(img, (nw, nh)) + + +_TYPE_ICON_CACHE = {} +_PLOT_ICON_CACHE = {} +_RUNE_ICON_CACHE = {} + + +def load_type_icon(ptype: str, size: int): + """Plant type icon scaled to fit a `size` box (aspect preserved), cached.""" + key = (ptype, size) + if key not in _TYPE_ICON_CACHE: + try: + img = pygame.image.load(type_icon_path(ptype)).convert_alpha() + _TYPE_ICON_CACHE[key] = fit_surface(img, size) + except (pygame.error, OSError): + _TYPE_ICON_CACHE[key] = None + return _TYPE_ICON_CACHE[key] + + +def load_plot_icon(name: str, size: int): + """Soil plot sprite scaled to fit a `size` box (aspect preserved), cached.""" + key = (name, size) + if key not in _PLOT_ICON_CACHE: + try: + img = pygame.image.load(plot_icon_path(name)).convert_alpha() + _PLOT_ICON_CACHE[key] = fit_surface(img, size) + except (pygame.error, OSError): + _PLOT_ICON_CACHE[key] = None + return _PLOT_ICON_CACHE[key] + + +def load_rune_icon(color: str, size: int): + """Rune sprite scaled to fit a `size` box (aspect preserved), cached. Falls + back to the brown rune sprite if a color's icon is missing.""" + key = (color, size) + if key not in _RUNE_ICON_CACHE: + try: + img = pygame.image.load(rune_icon_path(color)).convert_alpha() + _RUNE_ICON_CACHE[key] = fit_surface(img, size) + except (pygame.error, OSError): + _RUNE_ICON_CACHE[key] = None + return _RUNE_ICON_CACHE[key] + + +def draw_type_icon(screen, ptype, pos, font, color, size, center=False): + """Draw a plant type icon (or the type name as fallback text) at `pos`, + returning its rect. Uses midleft alignment unless `center` is True.""" + ic = load_type_icon(ptype, size) + if ic is None: + surf = font.render(ptype, True, color) + rect = surf.get_rect(center=pos) if center else surf.get_rect(midleft=pos) + screen.blit(surf, rect) + return rect + rect = ic.get_rect(center=pos) if center else ic.get_rect(midleft=pos) + screen.blit(ic, rect) + return rect + + +def wrap_text(text, font, width): + """Split `text` into lines that each fit within `width` pixels.""" + words = text.split() + lines = [] + cur = "" + for w in words: + if font.size(cur + " " + w)[0] > width and cur: + lines.append(cur) + cur = w + else: + cur = (cur + " " + w).strip() + if cur: + lines.append(cur) + return lines \ No newline at end of file diff --git a/ui/starter.py b/ui/starter.py new file mode 100644 index 0000000..4c338cf --- /dev/null +++ b/ui/starter.py @@ -0,0 +1,104 @@ +import pygame + +from assets import load_sprite +from engine import collection +from engine.types import ALL_TYPES +from ui.scaling import fit_surface, load_type_icon, make_window, request_quit, to_logical + +WIN_W, WIN_H = 1040, 720 +BG = (24, 30, 24) +TEXT = (230, 235, 230) +MUTED = (150, 155, 150) +HINT = (255, 220, 120) +PANEL = (32, 38, 32) +BTN = (60, 90, 60) +BTN_HOVER = (85, 125, 85) + +TYPE_NAMES = { + "radiant": "Radiant", + "nocturnal": "Nocturnal", + "ice": "Ice", + "garden": "Garden", + "wildflower": "Wildflower", +} +TYPE_COLORS = { + "radiant": (245, 200, 90), + "nocturnal": (150, 120, 220), + "ice": (130, 200, 235), + "garden": (120, 200, 120), + "wildflower": (235, 130, 170), +} + + +class StarterScreen: + def __init__(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 — 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) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.type_rects = {} + cw, ch, gap = 180, 200, 12 + total = len(ALL_TYPES) * cw + (len(ALL_TYPES) - 1) * gap + x0 = (WIN_W - total) // 2 + y0 = 120 + for i, pt in enumerate(ALL_TYPES): + x = x0 + i * (cw + gap) + self.type_rects[pt] = pygame.Rect(x, y0, cw, ch) + self.pinwheel_sprite = self._load("Pinwheel") + self.mouse_pos = (0, 0) + + def _load(self, name): + return load_sprite(name, "full") + + def run(self): + chosen = False + running = True + while running: + self.clock.tick(60) + self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + pos = to_logical(event.pos, self.scale) + for pt, rect in self.type_rects.items(): + if rect.collidepoint(pos): + collection.init_starter_pinwheel(pt) + chosen = True + running = False + break + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + return chosen + + def _draw(self): + self.screen.fill(BG) + title = self.font.render("Choose your starting pinwheel", True, HINT) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 52))) + sub = self.small.render("You start with a single pinwheel of the chosen type. " + "Battle to win packs and grow your garden.", + True, MUTED) + self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 88))) + for pt, rect in self.type_rects.items(): + hover = rect.collidepoint(self.mouse_pos) + pygame.draw.rect(self.screen, BTN_HOVER if hover else PANEL, rect, border_radius=10) + pygame.draw.rect(self.screen, (110, 150, 110) if hover else (70, 84, 70), + rect, 2, border_radius=10) + if self.pinwheel_sprite: + ic = fit_surface(self.pinwheel_sprite, 96) + self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 66))) + tcol = TYPE_COLORS.get(pt, TEXT) + nm = self.bold.render(TYPE_NAMES.get(pt, pt), True, tcol) + self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 130))) + tic = load_type_icon(pt, 28) + if tic: + self.screen.blit(tic, tic.get_rect(center=(rect.centerx, rect.y + 160))) + hint = self.tiny.render("Click to start", True, MUTED) + self.screen.blit(hint, hint.get_rect(center=(rect.centerx, rect.bottom - 14))) \ No newline at end of file diff --git a/ui/tower.py b/ui/tower.py new file mode 100644 index 0000000..b9b04fd --- /dev/null +++ b/ui/tower.py @@ -0,0 +1,204 @@ +import pygame + +from engine.plants import RARITY_COLORS, plant_rarity +from ui.scaling import make_window, request_quit, to_logical + +WIN_W, WIN_H = 1040, 720 +BG = (24, 30, 24) +TEXT = (230, 235, 230) +MUTED = (150, 155, 150) +HINT = (255, 220, 120) +BTN = (60, 90, 60) +BTN_HOVER = (85, 125, 85) +DANGER = (150, 70, 70) +DANGER_HOVER = (180, 90, 90) + + +class ConfirmScreen: + """A simple yes/no popup (e.g. "really leave the tower?").""" + + def __init__(self, title, message, yes_label="Leave", no_label="Stay"): + 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") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 30) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.title = title + self.message = message + self.yes_label = yes_label + self.no_label = no_label + self.yes_rect = pygame.Rect(WIN_W // 2 + 16, WIN_H - 96, 180, 44) + self.no_rect = pygame.Rect(WIN_W // 2 - 196, WIN_H - 96, 180, 44) + + def run(self): + running = True + result = False + while running: + self.clock.tick(60) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + pos = to_logical(event.pos, self.scale) + if self.yes_rect.collidepoint(pos): + result = True + running = False + elif self.no_rect.collidepoint(pos): + result = False + running = False + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + return result + + def _draw(self): + self.screen.fill(BG) + title = self.font.render(self.title, True, HINT) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 100))) + for i, line in enumerate(self.message.split("\n")): + t = self.small.render(line, True, TEXT) + self.screen.blit(t, t.get_rect(center=(WIN_W // 2, 150 + i * 26))) + self._button(self.no_rect, self.no_label, BTN, BTN_HOVER) + self._button(self.yes_rect, self.yes_label, DANGER, DANGER_HOVER) + + def _button(self, rect, label, base, hover): + mouse = pygame.mouse.get_pos() + bg = hover if rect.collidepoint(mouse) else base + pygame.draw.rect(self.screen, bg, rect, border_radius=8) + pygame.draw.rect(self.screen, (150, 190, 150), rect, 2, border_radius=8) + t = self.bold.render(label, True, TEXT) + self.screen.blit(t, t.get_rect(center=rect.center)) + + +class TowerScreen: + def __init__(self, stage, total_stages, reward_text, totals_text, mode="battle", + enemies=()): + 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") + self.clock = pygame.time.Clock() + self.font = pygame.font.SysFont("segoeui", 36) + self.bold = pygame.font.SysFont("segoeui", 20, bold=True) + self.small = pygame.font.SysFont("segoeui", 16) + self.tiny = pygame.font.SysFont("segoeui", 13) + self.stage = stage + self.total = total_stages + self.reward_text = reward_text + self.totals_text = totals_text + self.mode = mode # battle | lost | cleared | summary + self.enemies = list(enemies) + self.left_rect = pygame.Rect(WIN_W // 2 - 320, WIN_H - 90, 200, 44) + self.mid_rect = pygame.Rect(WIN_W // 2 - 100, WIN_H - 90, 200, 44) + self.right_rect = pygame.Rect(WIN_W // 2 + 120, WIN_H - 90, 200, 44) + + def run(self): + running = True + result = "exit" + while running: + self.clock.tick(60) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + request_quit() + running = False + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + pos = to_logical(event.pos, self.scale) + if self.mode == "battle": + if self.left_rect.collidepoint(pos): + result = "exit" + running = False + elif self.mid_rect.collidepoint(pos): + result = "adjust" + running = False + elif self.right_rect.collidepoint(pos): + result = "next" + running = False + elif self.mode == "lost": + if self.left_rect.collidepoint(pos): + result = "retry" + running = False + elif self.mid_rect.collidepoint(pos): + result = "change" + running = False + elif self.right_rect.collidepoint(pos): + result = "giveup" + running = False + else: + result = "exit" + running = False + self._draw() + pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window) + pygame.display.flip() + return result + + def _draw(self): + self.screen.fill(BG) + if self.mode == "battle": + self._draw_battle() + elif self.mode == "lost": + self._draw_lost() + else: + self._draw_summary() + + def _button(self, rect, label, base, hover): + mouse = pygame.mouse.get_pos() + bg = hover if rect.collidepoint(mouse) else base + pygame.draw.rect(self.screen, bg, rect, border_radius=8) + pygame.draw.rect(self.screen, (150, 190, 150), rect, 2, border_radius=8) + t = self.bold.render(label, True, TEXT) + self.screen.blit(t, t.get_rect(center=rect.center)) + + def _draw_battle(self): + title = self.font.render("Tower", True, HINT) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 70))) + st = self.bold.render(f"Stage {self.stage} defeated!", True, TEXT) + self.screen.blit(st, st.get_rect(center=(WIN_W // 2, 150))) + info = self.small.render(f"Next: Stage {self.stage + 1} / {self.total}", True, MUTED) + self.screen.blit(info, info.get_rect(center=(WIN_W // 2, 190))) + rw = self.small.render(f"Stage reward: {self.reward_text}", True, TEXT) + self.screen.blit(rw, rw.get_rect(center=(WIN_W // 2, 240))) + tt = self.small.render(f"Total so far: {self.totals_text}", True, MUTED) + self.screen.blit(tt, tt.get_rect(center=(WIN_W // 2, 270))) + self._button(self.left_rect, "Exit tower", BTN, BTN_HOVER) + self._button(self.mid_rect, "Adjust team", BTN, BTN_HOVER) + self._button(self.right_rect, "Next stage", BTN, BTN_HOVER) + + def _draw_lost(self): + title = self.font.render("Tower ended", True, HINT) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 70))) + sub = self.bold.render(f"Defeated at stage {self.stage}", True, TEXT) + self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 130))) + rw = self.small.render(f"Rewards earned: {self.totals_text}", True, TEXT) + self.screen.blit(rw, rw.get_rect(center=(WIN_W // 2, 180))) + if self.enemies: + eh = self.bold.render("Enemy team this stage:", True, HINT) + self.screen.blit(eh, eh.get_rect(midtop=(WIN_W // 2, 224))) + total_w = sum(self.small.size(n)[0] for n in self.enemies) + 14 * (len(self.enemies) - 1) + x = (WIN_W - total_w) // 2 + for name in self.enemies: + rar = plant_rarity(name) + surf = self.small.render(name, True, RARITY_COLORS.get(rar, TEXT)) + self.screen.blit(surf, (x, 254)) + x += surf.get_width() + 14 + self._button(self.left_rect, "Retry", BTN, BTN_HOVER) + self._button(self.mid_rect, "Change team", BTN, BTN_HOVER) + self._button(self.right_rect, "Give up", DANGER, DANGER_HOVER) + + def _draw_summary(self): + if self.mode == "cleared": + title = self.font.render("Tower cleared!", True, HINT) + sub = self.bold.render(f"Cleared all {self.total} stages!", True, TEXT) + else: + title = self.font.render("Tower ended", True, HINT) + sub = self.bold.render(f"Reached stage {self.stage}", True, TEXT) + self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 90))) + self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 170))) + rw = self.small.render(f"Rewards earned: {self.totals_text}", True, TEXT) + self.screen.blit(rw, rw.get_rect(center=(WIN_W // 2, 230))) + hint = self.tiny.render("Click anywhere to return to the menu", True, MUTED) + self.screen.blit(hint, hint.get_rect(center=(WIN_W // 2, 280))) \ No newline at end of file diff --git a/ui/typepopup.py b/ui/typepopup.py new file mode 100644 index 0000000..0ec6de9 --- /dev/null +++ b/ui/typepopup.py @@ -0,0 +1,70 @@ +import pygame + +from engine.types import TYPE_BEATS, type_relations +from ui.scaling import draw_type_icon, load_type_icon + +TYPE_COLORS = { + "radiant": (245, 200, 90), + "nocturnal": (150, 120, 220), + "ice": (130, 200, 235), + "garden": (120, 200, 120), + "wildflower": (235, 130, 170), +} +STRONG = (120, 210, 120) +WEAK = (225, 130, 130) +MUTED = (150, 155, 150) +TEXT = (230, 235, 230) + + +def _cycle_types() -> list: + order = [] + cur = next(iter(TYPE_BEATS)) + for _ in range(len(TYPE_BEATS)): + order.append(cur) + cur = TYPE_BEATS[cur] + return order + + +def draw_type_popup(screen, w, h, ptype, bold, small, tiny): + """Draw a modal popup explaining a type's strong/weak matchups.""" + overlay = pygame.Surface((w, h), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + screen.blit(overlay, (0, 0)) + pw, ph = 340, 200 + panel = pygame.Rect((w - pw) // 2, (h - ph) // 2, pw, ph) + pygame.draw.rect(screen, (32, 38, 32), panel, border_radius=12) + pygame.draw.rect(screen, (70, 90, 70), panel, 2, border_radius=12) + tcol = TYPE_COLORS.get(ptype, TEXT) + title = bold.render(ptype.capitalize(), True, tcol) + screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30))) + + strong, weak = type_relations(ptype) + y = panel.y + 62 + if strong: + screen.blit(small.render("Strong against", True, MUTED), (panel.x + 36, y)) + draw_type_icon(screen, strong, (panel.x + 36, y + 34), small, STRONG, 22) + if weak: + screen.blit(small.render("Weak against", True, MUTED), (panel.right - 140, y)) + draw_type_icon(screen, weak, (panel.right - 140, y + 34), small, WEAK, 22) + + cyc_y = panel.bottom - 44 + segs = [] + for t in _cycle_types(): + ic = load_type_icon(t, 16) + if ic: + segs.append((ic, ic.get_width())) + else: + txt = tiny.render(t.capitalize(), True, MUTED) + segs.append((txt, txt.get_width())) + gap = tiny.size(">")[0] + 6 + total_w = sum(s[1] for s in segs) + (len(segs) - 1) * gap + x = panel.centerx - total_w // 2 + for i, (surf, w) in enumerate(segs): + screen.blit(surf, surf.get_rect(midleft=(x, cyc_y))) + x += w + if i < len(segs) - 1: + gt = tiny.render(">", True, MUTED) + screen.blit(gt, (x + 2, cyc_y - gt.get_height() // 2)) + x += gap + hint = tiny.render("Click anywhere to close", True, MUTED) + screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 22))) \ No newline at end of file