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
+304
View File
@@ -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()