Files
Lena feb4500c6c small update
boss prototype, auto planter, visual reworks, bug fixes
2026-08-29 10:45:34 +02:00

358 lines
13 KiB
Python

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"]
# Floor enemies scale up faster and hit harder / are beefier than player units
# of the same level (a flat boost on top of the leveled stats).
FLOOR_ENEMY_LEVEL_STEP = 4 # +1 enemy level every 4 floors (was every 5)
FLOOR_ENEMY_ATK_MULT = 1.20 # +20% attack (more damage)
FLOOR_ENEMY_HP_MULT = 1.25 # +25% max health (tankier)
def floor_enemy_count(floor: int) -> int:
"""Number of enemies on a battle floor: 1 on floors 1-3, 2 on 4-9, 3 on
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. Boss floors field the boss alone."""
from . import bosses
boss = bosses.boss_for_floor(floor)
if boss:
return [boss]
count = floor_enemy_count(floor)
# Lead with an attacker; the rest cycle through the trio so each floor gets
# a deterministic mix of roles (with repeats) built from the critters.
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 get a fixed type
derived from the floor (no rerolling — the same floor always fields the same
team, and duplicate types within a team are allowed); plants keep their own
fixed type."""
from . import bosses
types = []
for i, n in enumerate(names):
if bosses.is_boss(n):
types.append(bosses.boss_type(n))
elif n in VARIABLE_TYPE_UNITS:
if floor <= 3:
types.append(TYPE_BEATS.get(player_type, "garden"))
else:
types.append(ALL_TYPES[(floor * 7 + i * i) % len(ALL_TYPES)])
else:
types.append(POOL[n]["type"])
return types
def floor_enemy_level(floor: int) -> int:
"""Enemy level for a floor (ramps one level every FLOOR_ENEMY_LEVEL_STEP
floors, steeper than the old every-5 curve)."""
return 1 + (floor - 1) // FLOOR_ENEMY_LEVEL_STEP
def make_floor_enemies(floor: int, player_type: str) -> List[Unit]:
"""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). Floor enemies also get a
flat attack/health boost so they out-stat player units of the same level.
Boss floors build the boss instead."""
from . import bosses
if names and bosses.is_boss(names[0]):
return [bosses.make_boss(names[0], floor)]
level = floor_enemy_level(floor)
stage = max(1, min(5, 1 + (floor - 1) // 20))
units = [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t)
for n, t in zip(names, types)]
for u in units:
u.attack = int(u.attack * FLOOR_ENEMY_ATK_MULT)
u.max_hp = int(u.max_hp * FLOOR_ENEMY_HP_MULT)
u.hp = u.max_hp
return units
def difficulty_pool(difficulty: int) -> List[str]:
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())