Initial commit: Plants turn-based battle game

This commit is contained in:
2026-08-28 14:26:07 +02:00
commit 6840b715de
158 changed files with 13352 additions and 0 deletions
+285
View File
@@ -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