Initial commit: Plants turn-based battle game
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# runtime save data
|
||||||
|
data/
|
||||||
@@ -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
|
||||||
@@ -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"))
|
||||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 32 KiB |