small update
boss prototype, auto planter, visual reworks, bug fixes
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Plants — Turn-Based Battle Game
|
# Flora — Turn-Based Battle Game
|
||||||
|
|
||||||
A turn-based battle game in Python. Normal battles are 4v4; the Tower mode
|
A turn-based battle game in Python. Normal battles are 4v4; the Tower mode
|
||||||
starts small and ramps up.
|
starts small and ramps up.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
+57
-7
@@ -9,6 +9,7 @@ from .skills import (
|
|||||||
S_HEAL_REDUCTION, S_HOT, S_ICICLES, S_INTOXICATE, S_LYING_WAIT, S_MADNESS, S_OBLIVION,
|
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_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,
|
S_SPEED_UP, S_TETHER, S_VULNERABLE, S_WILT, S_ZINNIA_WEAKEN,
|
||||||
|
SHIELD_DURATION,
|
||||||
EFF_DOT, EFF_HEAL, EFF_HOT, EFF_SHIELD, EFF_STATUS, EFF_WILT,
|
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_ALL_ALLIES, TARGET_ALL_ENEMIES, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY,
|
||||||
TARGET_SELF, TARGET_TWO_ENEMIES, Skill, roll_crit,
|
TARGET_SELF, TARGET_TWO_ENEMIES, Skill, roll_crit,
|
||||||
@@ -135,6 +136,7 @@ class Battle:
|
|||||||
|
|
||||||
def _crit(self, unit: Unit) -> float:
|
def _crit(self, unit: Unit) -> float:
|
||||||
pct = unit.crit * 100 - self._status_value(unit, S_CRIT_DOWN)
|
pct = unit.crit * 100 - self._status_value(unit, S_CRIT_DOWN)
|
||||||
|
pct += self._rune_pct(unit, "crit") * 100
|
||||||
return max(0.0, pct / 100)
|
return max(0.0, pct / 100)
|
||||||
|
|
||||||
# --- tank protection ----------------------------------------------
|
# --- tank protection ----------------------------------------------
|
||||||
@@ -278,6 +280,15 @@ class Battle:
|
|||||||
del unit.status[key]
|
del unit.status[key]
|
||||||
else:
|
else:
|
||||||
unit.status[key] = (value, dur)
|
unit.status[key] = (value, dur)
|
||||||
|
# Shields decay like any buff: each application counts down SHIELD_DURATION
|
||||||
|
# turns from when it was applied, independently of newer shields.
|
||||||
|
shields = unit.status.get(S_SHIELD)
|
||||||
|
if shields:
|
||||||
|
remaining = [(amount, turns - 1) for amount, turns in shields if turns > 1]
|
||||||
|
if remaining:
|
||||||
|
unit.status[S_SHIELD] = remaining
|
||||||
|
else:
|
||||||
|
unit.status.pop(S_SHIELD, None)
|
||||||
|
|
||||||
# --- status ticking ------------------------------------------------
|
# --- status ticking ------------------------------------------------
|
||||||
def _tick_dot(self, unit: Unit) -> None:
|
def _tick_dot(self, unit: Unit) -> None:
|
||||||
@@ -371,13 +382,20 @@ class Battle:
|
|||||||
blue = self._rune_pct(target, "shield")
|
blue = self._rune_pct(target, "shield")
|
||||||
if blue:
|
if blue:
|
||||||
amount = int(amount * (1 + blue))
|
amount = int(amount * (1 + blue))
|
||||||
target.status[S_SHIELD] = target.status.get(S_SHIELD, 0) + amount
|
# Each application has its own decay timer; stacking does NOT refresh
|
||||||
|
# shields already up (they still decay SHIELD_DURATION turns after being
|
||||||
|
# applied).
|
||||||
|
target.status.setdefault(S_SHIELD, []).append((amount, SHIELD_DURATION))
|
||||||
if self._action is None:
|
if self._action is None:
|
||||||
src = f" from {source.name}" if source else ""
|
src = f" from {source.name}" if source else ""
|
||||||
self.log.append(f"{target.name} gains {amount} shield{src}")
|
self.log.append(f"{target.name} gains {amount} shield{src}")
|
||||||
if self._action is not None:
|
if self._action is not None:
|
||||||
self._action["shield"] += amount
|
self._action["shield"] += amount
|
||||||
|
|
||||||
|
def _shield_total(self, unit: Unit) -> int:
|
||||||
|
"""Total shield remaining across all applications."""
|
||||||
|
return sum(amount for amount, _ in unit.status.get(S_SHIELD, []))
|
||||||
|
|
||||||
def _shield_power(self, user: Unit, base: int) -> int:
|
def _shield_power(self, user: Unit, base: int) -> int:
|
||||||
"""Shield amounts scale with the caster's Attack so they keep up with
|
"""Shield amounts scale with the caster's Attack so they keep up with
|
||||||
growing health pools (like the Sunflower passive)."""
|
growing health pools (like the Sunflower passive)."""
|
||||||
@@ -396,7 +414,7 @@ class Battle:
|
|||||||
if weaken:
|
if weaken:
|
||||||
mult *= 1 - weaken / 100.0
|
mult *= 1 - weaken / 100.0
|
||||||
# tulip_equinox: any ally of Tulip with a shield deals +10%
|
# tulip_equinox: any ally of Tulip with a shield deals +10%
|
||||||
if attacker.status.get(S_SHIELD, 0) > 0:
|
if self._shield_total(attacker) > 0:
|
||||||
team = self.players if attacker.team == TEAM_PLAYER else self.enemies
|
team = self.players if attacker.team == TEAM_PLAYER else self.enemies
|
||||||
if any(u.passive == "tulip_equinox" for u in team):
|
if any(u.passive == "tulip_equinox" for u in team):
|
||||||
mult *= 1.1
|
mult *= 1.1
|
||||||
@@ -507,10 +525,25 @@ class Battle:
|
|||||||
def _absorb(self, attacker: Optional[Unit], target: Unit, dmg: int) -> int:
|
def _absorb(self, attacker: Optional[Unit], target: Unit, dmg: int) -> int:
|
||||||
self._consume_hits_statuses(target)
|
self._consume_hits_statuses(target)
|
||||||
reflect = isinstance(target.status.get("bulb_reflect"), tuple) and target.status["bulb_reflect"][0] > 0
|
reflect = isinstance(target.status.get("bulb_reflect"), tuple) and target.status["bulb_reflect"][0] > 0
|
||||||
shield = target.status.get(S_SHIELD, 0)
|
shield_list = target.status.get(S_SHIELD, [])
|
||||||
|
shield = sum(amount for amount, _ in shield_list)
|
||||||
if shield > 0:
|
if shield > 0:
|
||||||
if shield >= dmg:
|
if shield >= dmg:
|
||||||
target.status[S_SHIELD] = shield - dmg
|
# Consume the damage across applications (oldest first).
|
||||||
|
remaining = dmg
|
||||||
|
new_shields = []
|
||||||
|
for amount, turns in shield_list:
|
||||||
|
if remaining <= 0:
|
||||||
|
new_shields.append((amount, turns))
|
||||||
|
elif amount > remaining:
|
||||||
|
new_shields.append((amount - remaining, turns))
|
||||||
|
remaining = 0
|
||||||
|
else:
|
||||||
|
remaining -= amount
|
||||||
|
if new_shields:
|
||||||
|
target.status[S_SHIELD] = new_shields
|
||||||
|
else:
|
||||||
|
target.status.pop(S_SHIELD, None)
|
||||||
self.log.append(f" {target.name}'s shield absorbs {dmg}")
|
self.log.append(f" {target.name}'s shield absorbs {dmg}")
|
||||||
self.events.append({"type": "shield", "target": target, "amount": dmg})
|
self.events.append({"type": "shield", "target": target, "amount": dmg})
|
||||||
if target.passive == "camelia_frigid":
|
if target.passive == "camelia_frigid":
|
||||||
@@ -674,6 +707,8 @@ class Battle:
|
|||||||
if skill.requires_form and skill.requires_form != user.status.get("form"):
|
if skill.requires_form and skill.requires_form != user.status.get("form"):
|
||||||
return []
|
return []
|
||||||
if skill.target == TARGET_ENEMY:
|
if skill.target == TARGET_ENEMY:
|
||||||
|
if skill.mechanic == "boss_landslide" and user.status.get("boss_stacks", 0) < 3:
|
||||||
|
return [] # Landslide only becomes available at 3 stacks
|
||||||
return self._targetable(self.foes_of(user), self._bypass_tanks(user, skill))
|
return self._targetable(self.foes_of(user), self._bypass_tanks(user, skill))
|
||||||
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
|
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
|
||||||
return self.foes_of(user)
|
return self.foes_of(user)
|
||||||
@@ -755,6 +790,10 @@ class Battle:
|
|||||||
return False
|
return False
|
||||||
if skill.cooldown_left > 0:
|
if skill.cooldown_left > 0:
|
||||||
return False
|
return False
|
||||||
|
if skill.mechanic == "boss_landslide" and user.status.get("boss_stacks", 0) < 3:
|
||||||
|
return False
|
||||||
|
if user.acted and not skill.extra_action:
|
||||||
|
return False # a unit acts once per turn (extra-action skills still allowed)
|
||||||
if skill.requires_form and skill.requires_form != user.status.get("form"):
|
if skill.requires_form and skill.requires_form != user.status.get("form"):
|
||||||
return False
|
return False
|
||||||
if isinstance(user.status.get(S_OBLIVION), tuple) and user.status[S_OBLIVION][0] > 0:
|
if isinstance(user.status.get(S_OBLIVION), tuple) and user.status[S_OBLIVION][0] > 0:
|
||||||
@@ -891,6 +930,8 @@ class Battle:
|
|||||||
if skill.mechanic == "dissolve":
|
if skill.mechanic == "dissolve":
|
||||||
skill.power += self._status_value(user, S_LYING_WAIT)
|
skill.power += self._status_value(user, S_LYING_WAIT)
|
||||||
power_bonus = int(self._defense(user) * 0.5) if skill.mechanic == "def_bonus" else 0
|
power_bonus = int(self._defense(user) * 0.5) if skill.mechanic == "def_bonus" else 0
|
||||||
|
if skill.mechanic == "boss_landslide":
|
||||||
|
power_bonus += 55 * user.status.get("boss_stacks", 0)
|
||||||
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
|
if skill.target in (TARGET_ALL_ENEMIES, TARGET_TWO_ENEMIES):
|
||||||
targets = self.foes_of(user)
|
targets = self.foes_of(user)
|
||||||
if skill.target == TARGET_TWO_ENEMIES:
|
if skill.target == TARGET_TWO_ENEMIES:
|
||||||
@@ -931,9 +972,12 @@ class Battle:
|
|||||||
else:
|
else:
|
||||||
self._apply_wilt_damage(target, int(dealt * 0.3))
|
self._apply_wilt_damage(target, int(dealt * 0.3))
|
||||||
if skill.mechanic == "heal_random_ally":
|
if skill.mechanic == "heal_random_ally":
|
||||||
allies = self.allies_of(user)
|
# Only pick from allies that are missing HP, so the heal always
|
||||||
if allies:
|
# lands on someone it can actually help (a random full-HP ally
|
||||||
self._heal(user, random.choice(allies), 40)
|
# would silently heal for 0).
|
||||||
|
injured = [a for a in self.allies_of(user) if a.hp < a.max_hp]
|
||||||
|
if injured:
|
||||||
|
self._heal(user, random.choice(injured), skill.power)
|
||||||
if skill.mechanic == "strip_buffs":
|
if skill.mechanic == "strip_buffs":
|
||||||
self._strip_buffs(target)
|
self._strip_buffs(target)
|
||||||
if skill.mechanic == "tether_lowest":
|
if skill.mechanic == "tether_lowest":
|
||||||
@@ -942,6 +986,12 @@ class Battle:
|
|||||||
low = min(allies, key=lambda a: a.hp)
|
low = min(allies, key=lambda a: a.hp)
|
||||||
low.status[S_TETHER] = (1, 1)
|
low.status[S_TETHER] = (1, 1)
|
||||||
self.log.append(f" {low.name} is tethered to {user.name}")
|
self.log.append(f" {low.name} is tethered to {user.name}")
|
||||||
|
if skill.mechanic == "boss_stack":
|
||||||
|
user.status["boss_stacks"] = min(3, user.status.get("boss_stacks", 0) + 1)
|
||||||
|
self.log.append(f" {user.name}'s power stacks ({user.status['boss_stacks']}/3).")
|
||||||
|
if skill.mechanic == "boss_landslide":
|
||||||
|
user.status["boss_stacks"] = 0
|
||||||
|
self.log.append(f" {user.name} unleashes Landslide and releases all stacks!")
|
||||||
|
|
||||||
def _strip_buffs(self, unit: Unit) -> None:
|
def _strip_buffs(self, unit: Unit) -> None:
|
||||||
for k in (S_ATTACK_UP, S_DEFENSE_UP, S_SPEED_UP, S_SHIELD):
|
for k in (S_ATTACK_UP, S_DEFENSE_UP, S_SPEED_UP, S_SHIELD):
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .plants import HP_MULT
|
||||||
|
from .skills import Skill, TARGET_ENEMY
|
||||||
|
from .types import TYPE_GARDEN
|
||||||
|
from .unit import TEAM_ENEMY, Unit
|
||||||
|
|
||||||
|
# A boss appears every BOSS_FLOOR_STEP floors (20, 40, 60, ...). Bosses are
|
||||||
|
# fought alone (1 vs the player's team) and have far bigger stats than normal
|
||||||
|
# floor enemies.
|
||||||
|
BOSS_FLOOR_STEP = 20
|
||||||
|
|
||||||
|
# Chance (0..1) a defeated boss drops one of its signature runes — much higher
|
||||||
|
# than the ~5-15% rune drop chance anywhere else.
|
||||||
|
BOSS_RUNE_DROP_CHANCE = 0.80
|
||||||
|
|
||||||
|
BOSS_SPECS = {
|
||||||
|
"Stones": {
|
||||||
|
"type": TYPE_GARDEN,
|
||||||
|
# (attack, speed, defense, health) — medium attack, very low speed,
|
||||||
|
# medium defense, high health (before HP_MULT).
|
||||||
|
"stats": (110, 30, 90, 380),
|
||||||
|
"sprite": "stones",
|
||||||
|
"drop_runes": ("ruby", "amethyst"),
|
||||||
|
"desc": "A slow, rocky guardian. Each Pebble Throw stacks its power "
|
||||||
|
"(up to 3); at 3 stacks it unleashes Landslide for huge damage.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_boss(name: str) -> bool:
|
||||||
|
return name in BOSS_SPECS
|
||||||
|
|
||||||
|
|
||||||
|
def boss_for_floor(floor: int):
|
||||||
|
"""The boss that appears on `floor`, or None if it's a normal floor."""
|
||||||
|
if floor > 0 and floor % BOSS_FLOOR_STEP == 0:
|
||||||
|
return "Stones"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def boss_type(name: str) -> str:
|
||||||
|
return BOSS_SPECS[name]["type"]
|
||||||
|
|
||||||
|
|
||||||
|
def boss_stats(name: str) -> tuple:
|
||||||
|
return BOSS_SPECS[name]["stats"]
|
||||||
|
|
||||||
|
|
||||||
|
def boss_sprite(name: str) -> str:
|
||||||
|
return BOSS_SPECS[name]["sprite"]
|
||||||
|
|
||||||
|
|
||||||
|
def boss_drop_runes(name: str) -> tuple:
|
||||||
|
return BOSS_SPECS[name]["drop_runes"]
|
||||||
|
|
||||||
|
|
||||||
|
def boss_floor_level(floor: int) -> int:
|
||||||
|
"""Display level for a boss at `floor` (scales with each boss appearance)."""
|
||||||
|
return 1 + floor // BOSS_FLOOR_STEP
|
||||||
|
|
||||||
|
|
||||||
|
def make_boss(name: str, floor: int) -> Unit:
|
||||||
|
"""Build the boss unit for `floor`. Boss stats are already far above normal
|
||||||
|
enemies and grow a little with each appearance so they stay threatening."""
|
||||||
|
spec = BOSS_SPECS[name]
|
||||||
|
atk, spd, dfn, hp = spec["stats"]
|
||||||
|
lvl = boss_floor_level(floor)
|
||||||
|
mult = 1 + 0.06 * (lvl - 1)
|
||||||
|
atk = int(atk * mult)
|
||||||
|
dfn = int(dfn * mult)
|
||||||
|
hp = int(hp * mult)
|
||||||
|
pebble = Skill("Pebble Throw", power=60, target=TARGET_ENEMY, cooldown=0,
|
||||||
|
mechanic="boss_stack", priority=2,
|
||||||
|
description="Medium damage; stacks the boss's power (up to 3).")
|
||||||
|
landslide = Skill("Landslide", power=140, target=TARGET_ENEMY, cooldown=0,
|
||||||
|
mechanic="boss_landslide", priority=1,
|
||||||
|
description="Huge damage, releases all stacks. Needs 3 stacks.")
|
||||||
|
u = Unit(name, TEAM_ENEMY, spec["type"], max_hp=int(hp * HP_MULT),
|
||||||
|
attack=atk, speed=spd, defense=dfn, skills=[pebble, landslide],
|
||||||
|
role="tank", crit=0.05)
|
||||||
|
u.hp = u.max_hp
|
||||||
|
return u
|
||||||
+118
-22
@@ -49,6 +49,8 @@ def init_starter_pinwheel(ptype: str) -> bool:
|
|||||||
"fertilizer_accrued_at": 0.0,
|
"fertilizer_accrued_at": 0.0,
|
||||||
"auto_farmer": False,
|
"auto_farmer": False,
|
||||||
"auto_farmer_active": False,
|
"auto_farmer_active": False,
|
||||||
|
"auto_planter": False,
|
||||||
|
"auto_planter_active": False,
|
||||||
"coins": 0,
|
"coins": 0,
|
||||||
"coins_accrued_at": 0.0,
|
"coins_accrued_at": 0.0,
|
||||||
"water_used": 0,
|
"water_used": 0,
|
||||||
@@ -59,6 +61,7 @@ def init_starter_pinwheel(ptype: str) -> bool:
|
|||||||
"open_history": [],
|
"open_history": [],
|
||||||
"lesser_opened": 0,
|
"lesser_opened": 0,
|
||||||
"floor": 1,
|
"floor": 1,
|
||||||
|
"bosses_fought": [],
|
||||||
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
|
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
|
||||||
}
|
}
|
||||||
save(data)
|
save(data)
|
||||||
@@ -80,6 +83,8 @@ def _default() -> dict:
|
|||||||
"fertilizer_accrued_at": 0.0,
|
"fertilizer_accrued_at": 0.0,
|
||||||
"auto_farmer": False,
|
"auto_farmer": False,
|
||||||
"auto_farmer_active": False,
|
"auto_farmer_active": False,
|
||||||
|
"auto_planter": False,
|
||||||
|
"auto_planter_active": False,
|
||||||
"coins": 0,
|
"coins": 0,
|
||||||
"coins_accrued_at": 0.0,
|
"coins_accrued_at": 0.0,
|
||||||
"water_used": 0,
|
"water_used": 0,
|
||||||
@@ -91,6 +96,7 @@ def _default() -> dict:
|
|||||||
"lesser_opened": 0,
|
"lesser_opened": 0,
|
||||||
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
|
"soil": {"seed_cd_until": 0.0, "seeds": [], "plots": []},
|
||||||
"floor": 1,
|
"floor": 1,
|
||||||
|
"bosses_fought": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +134,9 @@ def load() -> dict:
|
|||||||
data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
|
data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
|
||||||
data.setdefault("floor", 1)
|
data.setdefault("floor", 1)
|
||||||
data.setdefault("wins", 0)
|
data.setdefault("wins", 0)
|
||||||
|
data.setdefault("auto_planter", False)
|
||||||
|
data.setdefault("auto_planter_active", False)
|
||||||
|
data.setdefault("bosses_fought", [])
|
||||||
# normalize runes: older saves lack the per-rune stat roll, and use the old
|
# 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.
|
# color names (blue/red/white/yellow) instead of the current gems.
|
||||||
for r in data.setdefault("runes", []):
|
for r in data.setdefault("runes", []):
|
||||||
@@ -252,8 +261,9 @@ def grant_xp(names, xp: int, count_battle: bool = True, fertilizer: int = None)
|
|||||||
for n in names:
|
for n in names:
|
||||||
prog = data["progress"][n]
|
prog = data["progress"][n]
|
||||||
bonus = gap * CATCH_UP_PER_GAP if prog["level"] == low else 0
|
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)
|
# all XP gains are cut to a sixth (half of the old third) so leveling
|
||||||
gain = (xp + bonus) // 3 if (xp + bonus) > 0 else 0
|
# takes twice as long; water (a direct +1 level) is unaffected
|
||||||
|
gain = (xp + bonus) // 6 if (xp + bonus) > 0 else 0
|
||||||
prog["xp"] += gain
|
prog["xp"] += gain
|
||||||
while prog["level"] < MAX_LEVEL and prog["xp"] >= xp_to_next(prog["level"]):
|
while prog["level"] < MAX_LEVEL and prog["xp"] >= xp_to_next(prog["level"]):
|
||||||
prog["xp"] -= xp_to_next(prog["level"])
|
prog["xp"] -= xp_to_next(prog["level"])
|
||||||
@@ -338,6 +348,7 @@ def available_fertilizer() -> int:
|
|||||||
FERTILIZER_CAP = 100 # fertilizer stops accruing once this cap is reached
|
FERTILIZER_CAP = 100 # fertilizer stops accruing once this cap is reached
|
||||||
FERTILIZER_PER_HOUR = 1 # test build: earn 1 fertilizer every real hour
|
FERTILIZER_PER_HOUR = 1 # test build: earn 1 fertilizer every real hour
|
||||||
AUTO_FARMER_COST = 100 # coins (WIP value)
|
AUTO_FARMER_COST = 100 # coins (WIP value)
|
||||||
|
AUTO_PLANTER_COST = 200 # coins: plants retrieved seeds automatically
|
||||||
|
|
||||||
|
|
||||||
def add_fertilizer(amount: int) -> None:
|
def add_fertilizer(amount: int) -> None:
|
||||||
@@ -404,6 +415,37 @@ def buy_auto_farmer() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def auto_planter_owned() -> bool:
|
||||||
|
return bool(load().get("auto_planter", False))
|
||||||
|
|
||||||
|
|
||||||
|
def auto_planter_active() -> bool:
|
||||||
|
"""Whether the owned auto planter is currently toggled on."""
|
||||||
|
return bool(load().get("auto_planter_active", False))
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_auto_planter() -> bool:
|
||||||
|
"""Turn the owned auto planter on/off. Returns the new active state."""
|
||||||
|
data = load()
|
||||||
|
data["auto_planter_active"] = not data.get("auto_planter_active", False)
|
||||||
|
save(data)
|
||||||
|
return data["auto_planter_active"]
|
||||||
|
|
||||||
|
|
||||||
|
def buy_auto_planter() -> str:
|
||||||
|
"""Buy the auto planter for AUTO_PLANTER_COST coins. Returns '' or a reason."""
|
||||||
|
data = load()
|
||||||
|
if data.get("auto_planter", False):
|
||||||
|
return "Already owned"
|
||||||
|
if data.get("coins", 0) < AUTO_PLANTER_COST:
|
||||||
|
return "Not enough coins"
|
||||||
|
data["coins"] -= AUTO_PLANTER_COST
|
||||||
|
data["auto_planter"] = True
|
||||||
|
data["auto_planter_active"] = True
|
||||||
|
save(data)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def buy_rune(tier: int):
|
def buy_rune(tier: int):
|
||||||
"""Buy a random rune of `tier` (1 = common types, 2 = brown) for coins.
|
"""Buy a random rune of `tier` (1 = common types, 2 = brown) for coins.
|
||||||
Returns the rune dict on success or a reason string."""
|
Returns the rune dict on success or a reason string."""
|
||||||
@@ -502,6 +544,30 @@ def roll_pack_rune(data=None):
|
|||||||
return add_rune(data=data, save_now=False)
|
return add_rune(data=data, save_now=False)
|
||||||
|
|
||||||
|
|
||||||
|
# --- bosses ---------------------------------------------------------
|
||||||
|
def bosses_fought() -> set:
|
||||||
|
"""Boss names the player has defeated at least once."""
|
||||||
|
return set(load().get("bosses_fought", []))
|
||||||
|
|
||||||
|
|
||||||
|
def mark_boss_fought(name: str) -> None:
|
||||||
|
data = load()
|
||||||
|
fought = data.setdefault("bosses_fought", [])
|
||||||
|
if name not in fought:
|
||||||
|
fought.append(name)
|
||||||
|
save(data)
|
||||||
|
|
||||||
|
|
||||||
|
def boss_rune_drop(name: str):
|
||||||
|
"""Roll a boss's signature rune drop. Much higher chance than normal rune
|
||||||
|
drops. Returns a rune dict or None."""
|
||||||
|
from . import bosses
|
||||||
|
if random.random() >= bosses.BOSS_RUNE_DROP_CHANCE:
|
||||||
|
return None
|
||||||
|
color = random.choice(bosses.boss_drop_runes(name))
|
||||||
|
return add_rune(color)
|
||||||
|
|
||||||
|
|
||||||
def rune_slots(name, data=None) -> int:
|
def rune_slots(name, data=None) -> int:
|
||||||
"""Permanent rune slots for `name` (1 + evolutions ever achieved)."""
|
"""Permanent rune slots for `name` (1 + evolutions ever achieved)."""
|
||||||
data = data or load()
|
data = data or load()
|
||||||
@@ -544,20 +610,20 @@ def free_runes(data=None) -> list:
|
|||||||
|
|
||||||
def reroll_rune(rune_id: int, sacrificed_ids) -> str:
|
def reroll_rune(rune_id: int, sacrificed_ids) -> str:
|
||||||
"""Reroll `rune_id`'s stat to a higher value (at least 1% better, never
|
"""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
|
above its max roll), sacrificing runes.RUNE_SACRIFICE_COST runes. Equipped
|
||||||
removed from their plants. Returns '' on success or a reason string."""
|
sacrifices are removed from their plants. Returns '' on success or a reason."""
|
||||||
data = load()
|
data = load()
|
||||||
rune_list = data.get("runes", [])
|
rune_list = data.get("runes", [])
|
||||||
target = next((r for r in rune_list if r["id"] == rune_id), None)
|
target = next((r for r in rune_list if r["id"] == rune_id), None)
|
||||||
if not target:
|
if not target:
|
||||||
return "No such rune"
|
return "No such rune"
|
||||||
max_pct = runes.BROWN_PCT_MAX if target["color"] == "brown" else runes.PCT_MAX
|
max_pct = runes.max_pct(target["color"])
|
||||||
current = target.get("pct", 0)
|
current = target.get("pct", 0)
|
||||||
if current >= max_pct:
|
if current >= max_pct:
|
||||||
return "Already max roll"
|
return "Already max roll"
|
||||||
sac_set = set(sacrificed_ids)
|
sac_set = set(sacrificed_ids)
|
||||||
if len(sac_set) != 10:
|
if len(sac_set) != runes.RUNE_SACRIFICE_COST:
|
||||||
return "Pick exactly 10 runes to sacrifice"
|
return f"Pick exactly {runes.RUNE_SACRIFICE_COST} runes to sacrifice"
|
||||||
if rune_id in sac_set:
|
if rune_id in sac_set:
|
||||||
return "Cannot sacrifice the rune being upgraded"
|
return "Cannot sacrifice the rune being upgraded"
|
||||||
by_id = {r["id"]: r for r in rune_list}
|
by_id = {r["id"]: r for r in rune_list}
|
||||||
@@ -629,20 +695,6 @@ def add_tower_packs(count: int) -> None:
|
|||||||
save(data)
|
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:
|
def evolve(name) -> bool:
|
||||||
"""Evolve at the milestone level by consuming duplicates."""
|
"""Evolve at the milestone level by consuming duplicates."""
|
||||||
data = load()
|
data = load()
|
||||||
@@ -750,7 +802,7 @@ def add_lesser_pack() -> list:
|
|||||||
|
|
||||||
|
|
||||||
def add_pack(pack_type: str, count: int = 1) -> list:
|
def add_pack(pack_type: str, count: int = 1) -> list:
|
||||||
"""Grant `count` packs of a specific type. Returns the granted type list."""
|
"""Add `count` seed packs of `pack_type`."""
|
||||||
data = load()
|
data = load()
|
||||||
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
|
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
|
||||||
packs[pack_type] = packs.get(pack_type, 0) + count
|
packs[pack_type] = packs.get(pack_type, 0) + count
|
||||||
@@ -758,6 +810,33 @@ def add_pack(pack_type: str, count: int = 1) -> list:
|
|||||||
return [pack_type] * count
|
return [pack_type] * count
|
||||||
|
|
||||||
|
|
||||||
|
# Pack shop (seed packs page): coins buy packs. Tower packs can't be bought.
|
||||||
|
PACK_COSTS = {
|
||||||
|
"lesser": 50,
|
||||||
|
"general": 100,
|
||||||
|
"radiant": 100,
|
||||||
|
"nocturnal": 100,
|
||||||
|
"ice": 100,
|
||||||
|
"garden": 100,
|
||||||
|
"wildflower": 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def buy_pack(pack_type: str) -> str:
|
||||||
|
"""Buy a pack of `pack_type` with coins. Returns '' on success or a reason."""
|
||||||
|
cost = PACK_COSTS.get(pack_type)
|
||||||
|
if cost is None:
|
||||||
|
return "That pack can't be bought"
|
||||||
|
data = load()
|
||||||
|
if data.get("coins", 0) < cost:
|
||||||
|
return "Not enough coins"
|
||||||
|
data["coins"] -= cost
|
||||||
|
packs = data.setdefault("packs", {t: 0 for t in PACK_TYPES})
|
||||||
|
packs[pack_type] = packs.get(pack_type, 0) + 1
|
||||||
|
save(data)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def battle_wins() -> int:
|
def battle_wins() -> int:
|
||||||
"""Total number of battle wins the player has recorded."""
|
"""Total number of battle wins the player has recorded."""
|
||||||
return load().get("wins", 0)
|
return load().get("wins", 0)
|
||||||
@@ -1189,6 +1268,23 @@ def auto_farm() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def auto_plant() -> str:
|
||||||
|
"""If the player owns an auto planter that's toggled on, automatically plant
|
||||||
|
a retrieved seed into the first empty plot (if any seeds are available).
|
||||||
|
Returns '' on success or when idle, else a reason."""
|
||||||
|
data = load()
|
||||||
|
if not data.get("auto_planter", False):
|
||||||
|
return "No auto planter"
|
||||||
|
if not data.get("auto_planter_active", False):
|
||||||
|
return "Auto planter off"
|
||||||
|
soil = data.setdefault("soil", {"seed_cd_until": 0.0, "seeds": [], "plots": []})
|
||||||
|
if not soil.get("seeds", []):
|
||||||
|
return "" # nothing to plant
|
||||||
|
if len(soil.get("plots", [])) >= unlocked_plots(data, soil):
|
||||||
|
return "" # no empty plot
|
||||||
|
return plant_seed(0)
|
||||||
|
|
||||||
|
|
||||||
def harvest_plot(index: int) -> str:
|
def harvest_plot(index: int) -> str:
|
||||||
"""Harvest a grown plot, gaining a copy (duplicate) of the plant."""
|
"""Harvest a grown plot, gaining a copy (duplicate) of the plant."""
|
||||||
data = load()
|
data = load()
|
||||||
|
|||||||
+1
-1
@@ -281,7 +281,7 @@ POOL: Dict[str, dict] = {
|
|||||||
]),
|
]),
|
||||||
"Butterfly": dict(role=ROLE_SUPPORT, type=TYPE_GARDEN, stats=(30, 40, 26, 40),
|
"Butterfly": dict(role=ROLE_SUPPORT, type=TYPE_GARDEN, stats=(30, 40, 26, 40),
|
||||||
skills=[
|
skills=[
|
||||||
_sk("Nectar", power=20, kind="heal", target=TARGET_ALLY, cd=2,
|
_sk("Nectar", power=15, kind="heal", target=TARGET_ALLY, cd=2,
|
||||||
desc="Heal an ally (scales with Attack)"),
|
desc="Heal an ally (scales with Attack)"),
|
||||||
_sk("Cocoon", power=25, kind="shield", target=TARGET_ALLY, cd=2,
|
_sk("Cocoon", power=25, kind="shield", target=TARGET_ALLY, cd=2,
|
||||||
desc="Shield an ally (scales with Attack)"),
|
desc="Shield an ally (scales with Attack)"),
|
||||||
|
|||||||
+3
-3
@@ -5,9 +5,9 @@ from typing import Callable, Iterable, List, Optional
|
|||||||
|
|
||||||
from . import runes
|
from . import runes
|
||||||
from .skills import (
|
from .skills import (
|
||||||
S_ATTACK_DOWN, S_ATTACK_UP, S_DEFENSE_DOWN, S_DEFENSE_UP,
|
S_ATTACK_DOWN, S_ATTACK_UP, S_CRIT_DOWN, S_DEFENSE_DOWN, S_DEFENSE_UP,
|
||||||
S_HEAL_BOOST, S_HOT, S_PREVENT_FIRST_DAMAGE, S_SHIELD, S_SNOWFLAKES, S_SPEED_DOWN,
|
S_HEAL_BOOST, S_HOT, S_PREVENT_FIRST_DAMAGE, S_SHIELD, S_SNOWFLAKES, S_SPEED_DOWN,
|
||||||
S_SPEED_UP, S_VULNERABLE,
|
S_SPEED_UP, S_VULNERABLE, SHIELD_DURATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ def _crit(pct):
|
|||||||
def _shield(pct):
|
def _shield(pct):
|
||||||
def apply(battle):
|
def apply(battle):
|
||||||
for u in _players(battle):
|
for u in _players(battle):
|
||||||
u.status[S_SHIELD] = u.status.get(S_SHIELD, 0) + int(u.max_hp * pct)
|
u.status.setdefault(S_SHIELD, []).append((int(u.max_hp * pct), SHIELD_DURATION))
|
||||||
return apply
|
return apply
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+35
-10
@@ -20,6 +20,12 @@ FLOOR_RARE_PLANTS = ["Marigold", "Daffodil", "Peony", "Primrose",
|
|||||||
# Low-rarity tank plants used as enemy tanks on later floors.
|
# Low-rarity tank plants used as enemy tanks on later floors.
|
||||||
FLOOR_TANK_PLANTS = ["Marigold", "Daffodil", "Venus Flytrap"]
|
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:
|
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
|
"""Number of enemies on a battle floor: 1 on floors 1-3, 2 on 4-9, 3 on
|
||||||
@@ -39,7 +45,11 @@ def floor_enemy_names(floor: int, player_type: str) -> List[str]:
|
|||||||
4 from floor 16. Teams mix pinwheels, butterflies and toadstools, and
|
4 from floor 16. Teams mix pinwheels, butterflies and toadstools, and
|
||||||
duplicates are allowed (e.g. two pinwheels). Tank plants join from floor
|
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
|
40 and low-rarity plants from floor 45, replacing critter slots so a team
|
||||||
never exceeds 4 enemies."""
|
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)
|
count = floor_enemy_count(floor)
|
||||||
# Lead with an attacker; the rest cycle through the trio so each floor gets
|
# 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.
|
# a deterministic mix of roles (with repeats) built from the critters.
|
||||||
@@ -58,24 +68,29 @@ def floor_enemy_names(floor: int, player_type: str) -> List[str]:
|
|||||||
|
|
||||||
def roll_enemy_types(names: List[str], player_type: str, floor: int) -> List[str]:
|
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
|
"""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
|
always the type the player is strong against; other critters get a fixed type
|
||||||
type; plants keep their own fixed type."""
|
derived from the floor (no rerolling — the same floor always fields the same
|
||||||
import random
|
team, and duplicate types within a team are allowed); plants keep their own
|
||||||
|
fixed type."""
|
||||||
|
from . import bosses
|
||||||
types = []
|
types = []
|
||||||
for i, n in enumerate(names):
|
for i, n in enumerate(names):
|
||||||
if n in VARIABLE_TYPE_UNITS:
|
if bosses.is_boss(n):
|
||||||
|
types.append(bosses.boss_type(n))
|
||||||
|
elif n in VARIABLE_TYPE_UNITS:
|
||||||
if floor <= 3:
|
if floor <= 3:
|
||||||
types.append(TYPE_BEATS.get(player_type, "garden"))
|
types.append(TYPE_BEATS.get(player_type, "garden"))
|
||||||
else:
|
else:
|
||||||
types.append(random.choice(ALL_TYPES))
|
types.append(ALL_TYPES[(floor * 7 + i * i) % len(ALL_TYPES)])
|
||||||
else:
|
else:
|
||||||
types.append(POOL[n]["type"])
|
types.append(POOL[n]["type"])
|
||||||
return types
|
return types
|
||||||
|
|
||||||
|
|
||||||
def floor_enemy_level(floor: int) -> int:
|
def floor_enemy_level(floor: int) -> int:
|
||||||
"""Enemy level for a floor (ramps toward ~20 at floor 100)."""
|
"""Enemy level for a floor (ramps one level every FLOOR_ENEMY_LEVEL_STEP
|
||||||
return 1 + (floor - 1) // 5
|
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]:
|
def make_floor_enemies(floor: int, player_type: str) -> List[Unit]:
|
||||||
@@ -88,11 +103,21 @@ def make_floor_enemies(floor: int, player_type: str) -> List[Unit]:
|
|||||||
|
|
||||||
def build_floor_enemies(names: List[str], types: List[str], floor: int) -> List[Unit]:
|
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
|
"""Build enemy units from an already-rolled name/type list (so the picker and
|
||||||
the actual battle always show the same enemies)."""
|
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)
|
level = floor_enemy_level(floor)
|
||||||
stage = max(1, min(5, 1 + (floor - 1) // 20))
|
stage = max(1, min(5, 1 + (floor - 1) // 20))
|
||||||
return [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t)
|
units = [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t)
|
||||||
for n, t in zip(names, types)]
|
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]:
|
def difficulty_pool(difficulty: int) -> List[str]:
|
||||||
|
|||||||
+38
-13
@@ -3,18 +3,19 @@ from __future__ import annotations
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
# Rune kinds (gems). Each rune is an item equipped into a plant's slots.
|
# Rune kinds (gems). Each rune is an item equipped into a plant's slots.
|
||||||
RUNE_COLORS = ("sapphire", "ruby", "amethyst", "jade", "brown")
|
RUNE_COLORS = ("sapphire", "ruby", "amethyst", "jade", "brown", "pearl")
|
||||||
RUNE_NAMES = {
|
RUNE_NAMES = {
|
||||||
"sapphire": "Sapphire Rune",
|
"sapphire": "Sapphire Rune",
|
||||||
"ruby": "Ruby Rune",
|
"ruby": "Ruby Rune",
|
||||||
"amethyst": "Amethyst Rune",
|
"amethyst": "Amethyst Rune",
|
||||||
"jade": "Jade Rune",
|
"jade": "Jade Rune",
|
||||||
"brown": "Brown Rune",
|
"brown": "Brown Rune",
|
||||||
|
"pearl": "Pearl Rune",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown is Tier 2.
|
# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown & pearl Tier 2.
|
||||||
TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade")
|
TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade")
|
||||||
TIER2_COLORS = ("brown",)
|
TIER2_COLORS = ("brown", "pearl")
|
||||||
RUNE_TIERS = {c: 1 for c in TIER1_COLORS}
|
RUNE_TIERS = {c: 1 for c in TIER1_COLORS}
|
||||||
RUNE_TIERS.update({c: 2 for c in TIER2_COLORS})
|
RUNE_TIERS.update({c: 2 for c in TIER2_COLORS})
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ RUNE_ICON_FILES = {
|
|||||||
"jade": "jade_rune",
|
"jade": "jade_rune",
|
||||||
"amethyst": "amethyst_rune",
|
"amethyst": "amethyst_rune",
|
||||||
"brown": "rune_brown",
|
"brown": "rune_brown",
|
||||||
|
"pearl": "pearl_rune",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Old color names kept in earlier saves, mapped to the current gem names.
|
# Old color names kept in earlier saves, mapped to the current gem names.
|
||||||
@@ -43,31 +45,39 @@ def canonical_color(color: str) -> str:
|
|||||||
|
|
||||||
# Primitive effects: each rune rolls its own stat and stacks additively with
|
# 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
|
# 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.
|
# turns part of the damage this plant deals into a Wilt. Pearl rolls a chunk of
|
||||||
|
# critical hit chance.
|
||||||
RUNE_EFFECTS = {
|
RUNE_EFFECTS = {
|
||||||
"ruby": "damage", # +damage dealt
|
"ruby": "damage", # +damage dealt
|
||||||
"sapphire": "shield", # +shielding
|
"sapphire": "shield", # +shielding
|
||||||
"jade": "healing", # +healing received
|
"jade": "healing", # +healing received
|
||||||
"amethyst": "damage_taken", # -damage taken
|
"amethyst": "damage_taken", # -damage taken
|
||||||
"brown": "wilt", # damage dealt also Wils the target
|
"brown": "wilt", # damage dealt also Wils the target
|
||||||
|
"pearl": "crit", # +critical hit chance
|
||||||
}
|
}
|
||||||
PCT_MIN = 25
|
PCT_MIN = 25
|
||||||
PCT_MAX = 35
|
PCT_MAX = 35
|
||||||
BROWN_PCT_MIN = 5
|
BROWN_PCT_MIN = 5
|
||||||
BROWN_PCT_MAX = 15
|
BROWN_PCT_MAX = 15
|
||||||
|
PEARL_PCT_MIN = 20
|
||||||
|
PEARL_PCT_MAX = 40
|
||||||
# a plant can't stack more than this many runes of the same type
|
# a plant can't stack more than this many runes of the same type
|
||||||
MAX_RUNES_PER_TYPE = 2
|
MAX_RUNES_PER_TYPE = 2
|
||||||
|
# runes sacrificed to reroll one rune to a higher stat
|
||||||
|
RUNE_SACRIFICE_COST = 3
|
||||||
# coin costs to buy a random rune of a tier in the runes shop
|
# coin costs to buy a random rune of a tier in the runes shop
|
||||||
T1_RUNE_COST = 100
|
T1_RUNE_COST = 100
|
||||||
T2_RUNE_COST = 150
|
T2_RUNE_COST = 150
|
||||||
# brown drops less often than the other runes (relative weights)
|
# brown & pearl drop 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}
|
RUNE_WEIGHTS = {"sapphire": 1.0, "ruby": 1.0, "amethyst": 1.0, "jade": 1.0,
|
||||||
|
"brown": 0.75, "pearl": 0.75}
|
||||||
_EFFECT_TEXTS = {
|
_EFFECT_TEXTS = {
|
||||||
"ruby": "+{pct}% damage dealt",
|
"ruby": "+{pct}% damage dealt",
|
||||||
"sapphire": "+{pct}% shielding",
|
"sapphire": "+{pct}% shielding",
|
||||||
"jade": "+{pct}% healing",
|
"jade": "+{pct}% healing",
|
||||||
"amethyst": "-{pct}% damage taken",
|
"amethyst": "-{pct}% damage taken",
|
||||||
"brown": "deals {pct}% of damage as Wilt",
|
"brown": "deals {pct}% of damage as Wilt",
|
||||||
|
"pearl": "+{pct}% critical chance",
|
||||||
}
|
}
|
||||||
RUNE_SUMMARIES = {
|
RUNE_SUMMARIES = {
|
||||||
"ruby": "Increases damage dealt",
|
"ruby": "Increases damage dealt",
|
||||||
@@ -75,6 +85,7 @@ RUNE_SUMMARIES = {
|
|||||||
"jade": "Increases healing received",
|
"jade": "Increases healing received",
|
||||||
"amethyst": "Reduces damage taken",
|
"amethyst": "Reduces damage taken",
|
||||||
"brown": "Deals a portion of damage as Wilt",
|
"brown": "Deals a portion of damage as Wilt",
|
||||||
|
"pearl": "Increases critical hit chance",
|
||||||
}
|
}
|
||||||
|
|
||||||
MAX_RUNE_SLOTS = 3
|
MAX_RUNE_SLOTS = 3
|
||||||
@@ -91,12 +102,23 @@ def random_color() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def roll_pct(color: str = None) -> int:
|
def roll_pct(color: str = None) -> int:
|
||||||
"""Roll a rune's stat percentage. Brown rolls 5-15; the rest 25-35."""
|
"""Roll a rune's stat percentage. Brown rolls 5-15, pearl 20-40, the rest 25-35."""
|
||||||
if color == "brown":
|
if color == "brown":
|
||||||
return random.randint(BROWN_PCT_MIN, BROWN_PCT_MAX)
|
return random.randint(BROWN_PCT_MIN, BROWN_PCT_MAX)
|
||||||
|
if color == "pearl":
|
||||||
|
return random.randint(PEARL_PCT_MIN, PEARL_PCT_MAX)
|
||||||
return random.randint(PCT_MIN, PCT_MAX)
|
return random.randint(PCT_MIN, PCT_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
def max_pct(color: str) -> int:
|
||||||
|
"""The highest stat a rune of `color` can roll (brown/pearl have their own caps)."""
|
||||||
|
if color == "brown":
|
||||||
|
return BROWN_PCT_MAX
|
||||||
|
if color == "pearl":
|
||||||
|
return PEARL_PCT_MAX
|
||||||
|
return PCT_MAX
|
||||||
|
|
||||||
|
|
||||||
def name(color: str) -> str:
|
def name(color: str) -> str:
|
||||||
return RUNE_NAMES.get(color, "Rune")
|
return RUNE_NAMES.get(color, "Rune")
|
||||||
|
|
||||||
@@ -106,11 +128,11 @@ def tier(color: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def roll_ratio(rune) -> float:
|
def roll_ratio(rune) -> float:
|
||||||
"""How close a rune's roll is to its own maximum (0..1). Brown's 5-15 range
|
"""How close a rune's roll is to its own maximum (0..1). Each color is
|
||||||
is compared to its 15 cap, so a maxed brown ranks equally with a maxed
|
compared to its own cap, so a maxed brown or pearl ranks equally with a
|
||||||
yellow even though 15 < 35."""
|
maxed ruby even though 15 < 35."""
|
||||||
max_pct = BROWN_PCT_MAX if rune.get("color") == "brown" else PCT_MAX
|
cap = max_pct(rune.get("color"))
|
||||||
return rune.get("pct", 0) / max_pct
|
return rune.get("pct", 0) / cap
|
||||||
|
|
||||||
|
|
||||||
def effect_text(color: str, pct: int) -> str:
|
def effect_text(color: str, pct: int) -> str:
|
||||||
@@ -124,6 +146,7 @@ _CARD_TEXTS = {
|
|||||||
"jade": "+{pct}% heal",
|
"jade": "+{pct}% heal",
|
||||||
"amethyst": "-{pct}% taken",
|
"amethyst": "-{pct}% taken",
|
||||||
"brown": "Wilt {pct}%",
|
"brown": "Wilt {pct}%",
|
||||||
|
"pearl": "+{pct}% crit",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -149,9 +172,11 @@ def summary(color: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def roll_range(color: str) -> str:
|
def roll_range(color: str) -> str:
|
||||||
"""The stat range a rune of `color` rolls between (brown is lower)."""
|
"""The stat range a rune of `color` rolls between (brown/pearl are lower)."""
|
||||||
if color == "brown":
|
if color == "brown":
|
||||||
return f"{BROWN_PCT_MIN}–{BROWN_PCT_MAX}%"
|
return f"{BROWN_PCT_MIN}–{BROWN_PCT_MAX}%"
|
||||||
|
if color == "pearl":
|
||||||
|
return f"{PEARL_PCT_MIN}–{PEARL_PCT_MAX}%"
|
||||||
return f"{PCT_MIN}–{PCT_MAX}%"
|
return f"{PCT_MIN}–{PCT_MAX}%"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ S_WILT = "wilt"
|
|||||||
S_DOT = "dot"
|
S_DOT = "dot"
|
||||||
S_HOT = "hot"
|
S_HOT = "hot"
|
||||||
S_SHIELD = "shield"
|
S_SHIELD = "shield"
|
||||||
|
SHIELD_DURATION = 3 # turns a shield lasts before decaying (per application, doesn't refresh)
|
||||||
S_ICICLES = "icicles"
|
S_ICICLES = "icicles"
|
||||||
S_SNOWFLAKES = "snowflakes"
|
S_SNOWFLAKES = "snowflakes"
|
||||||
S_ZINNIA_WEAKEN = "zinnia_weaken"
|
S_ZINNIA_WEAKEN = "zinnia_weaken"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from assets import icon
|
from assets import icon
|
||||||
from engine import collection, runes
|
from engine import bosses, collection, runes
|
||||||
from engine.battle import Battle
|
from engine.battle import Battle
|
||||||
from engine.relics import RELIC_BY_KEY, grant_rune_rewards, roll_relics, rune_reward_count
|
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.roster import make_enemy_team, pick_team, player_team_level, player_team_stage
|
||||||
@@ -24,6 +24,7 @@ def _pack_reward_items(pack_types):
|
|||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
#test_ignore
|
||||||
|
|
||||||
def _rune_reward_item(rune):
|
def _rune_reward_item(rune):
|
||||||
"""Reward entry for a rune drop (label + rune sprite path)."""
|
"""Reward entry for a rune drop (label + rune sprite path)."""
|
||||||
@@ -51,7 +52,8 @@ def _totals_txt(total_water, total_packs, run_relics):
|
|||||||
def _battle_win_reward(floor):
|
def _battle_win_reward(floor):
|
||||||
"""Grant and describe a floor-battle win: a lesser pack, or on every 5th win
|
"""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
|
(5, 10, 15, ...) a better general/type-boosted pack instead. Advances the
|
||||||
floor. Runes can drop on any win regardless."""
|
floor. Runes can drop on any win regardless. Boss floors additionally grant
|
||||||
|
a boss-specific rune drop at a much higher chance and mark the boss fought."""
|
||||||
collection.advance_floor()
|
collection.advance_floor()
|
||||||
wins = collection.record_win()
|
wins = collection.record_win()
|
||||||
if wins % 5 == 0:
|
if wins % 5 == 0:
|
||||||
@@ -61,6 +63,12 @@ def _battle_win_reward(floor):
|
|||||||
rune = collection.roll_battle_rune()
|
rune = collection.roll_battle_rune()
|
||||||
if rune:
|
if rune:
|
||||||
reward.append(_rune_reward_item(rune))
|
reward.append(_rune_reward_item(rune))
|
||||||
|
boss = bosses.boss_for_floor(floor)
|
||||||
|
if boss:
|
||||||
|
collection.mark_boss_fought(boss)
|
||||||
|
bdrop = collection.boss_rune_drop(boss)
|
||||||
|
if bdrop:
|
||||||
|
reward.append(_rune_reward_item(bdrop))
|
||||||
return reward
|
return reward
|
||||||
|
|
||||||
|
|
||||||
@@ -255,7 +263,10 @@ def main():
|
|||||||
floor = collection.floor_progress()
|
floor = collection.floor_progress()
|
||||||
enemy_names = floor_enemy_names(floor, player_type)
|
enemy_names = floor_enemy_names(floor, player_type)
|
||||||
enemy_types = roll_enemy_types(enemy_names, player_type, floor)
|
enemy_types = roll_enemy_types(enemy_names, player_type, floor)
|
||||||
enemy_levels = [floor_enemy_level(floor)] * len(enemy_names)
|
enemy_levels = [
|
||||||
|
bosses.boss_floor_level(floor) if bosses.is_boss(n) else floor_enemy_level(floor)
|
||||||
|
for n in enemy_names
|
||||||
|
]
|
||||||
from ui.picker import TeamSelect
|
from ui.picker import TeamSelect
|
||||||
ts = TeamSelect(initial_enemies=enemy_names, difficulty=floor,
|
ts = TeamSelect(initial_enemies=enemy_names, difficulty=floor,
|
||||||
enemy_types=enemy_types, no_roll=True,
|
enemy_types=enemy_types, no_roll=True,
|
||||||
@@ -269,7 +280,7 @@ def main():
|
|||||||
|
|
||||||
allies, enemies = result
|
allies, enemies = result
|
||||||
collection.mark_owned(allies)
|
collection.mark_owned(allies)
|
||||||
collection.mark_found(enemies)
|
collection.mark_found([n for n in enemies if not bosses.is_boss(n)])
|
||||||
players = pick_team(allies, TEAM_PLAYER)
|
players = pick_team(allies, TEAM_PLAYER)
|
||||||
enemy_units = build_floor_enemies(enemy_names, enemy_types, floor)
|
enemy_units = build_floor_enemies(enemy_names, enemy_types, floor)
|
||||||
battle = Battle(players, enemy_units)
|
battle = Battle(players, enemy_units)
|
||||||
|
|||||||
+105
-1
@@ -155,6 +155,28 @@ class ShieldWiltDotTest(unittest.TestCase):
|
|||||||
self.assertEqual(t.hp, 100)
|
self.assertEqual(t.hp, 100)
|
||||||
self.assertNotIn(S_SHIELD, t.status)
|
self.assertNotIn(S_SHIELD, t.status)
|
||||||
|
|
||||||
|
def test_heal_random_ally_prefers_injured_ally(self):
|
||||||
|
flutter = Skill("Flutter", power=40, target=TARGET_ENEMY, mechanic="heal_random_ally")
|
||||||
|
daisy = make_unit("Daisy", TEAM_PLAYER, "radiant", atk=50, skills=[flutter])
|
||||||
|
full = make_unit("Full", TEAM_PLAYER, "radiant", hp=100)
|
||||||
|
hurt = make_unit("Hurt", TEAM_PLAYER, "radiant", hp=100)
|
||||||
|
hurt.hp = 50
|
||||||
|
enemy = make_unit("E", TEAM_ENEMY, "garden", hp=500)
|
||||||
|
b = Battle([daisy, full, hurt], [enemy])
|
||||||
|
b.start()
|
||||||
|
b.use_skill(daisy, flutter, enemy)
|
||||||
|
self.assertEqual(hurt.hp, 90) # the injured ally got healed
|
||||||
|
self.assertEqual(full.hp, 100) # full-HP ally untouched
|
||||||
|
|
||||||
|
def test_heal_random_ally_noop_when_all_full(self):
|
||||||
|
flutter = Skill("Flutter", power=40, target=TARGET_ENEMY, mechanic="heal_random_ally")
|
||||||
|
daisy = make_unit("Daisy", TEAM_PLAYER, "radiant", atk=50, skills=[flutter])
|
||||||
|
enemy = make_unit("E", TEAM_ENEMY, "garden", hp=500)
|
||||||
|
b = Battle([daisy], [enemy])
|
||||||
|
b.start()
|
||||||
|
b.use_skill(daisy, flutter, enemy)
|
||||||
|
self.assertFalse(any(isinstance(l, dict) and "recovers" in l["text"] for l in b.log)) # nothing healed
|
||||||
|
|
||||||
def test_wilt_ticks_over_three_turns(self):
|
def test_wilt_ticks_over_three_turns(self):
|
||||||
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=[Skill("Hit", 10, target=TARGET_ENEMY)])
|
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)
|
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=500, spd=5)
|
||||||
@@ -194,6 +216,45 @@ class ShieldWiltDotTest(unittest.TestCase):
|
|||||||
self.assertEqual(t.hp, 410)
|
self.assertEqual(t.hp, 410)
|
||||||
self.assertNotIn(S_DOT, t.status)
|
self.assertNotIn(S_DOT, t.status)
|
||||||
|
|
||||||
|
def test_shield_decays_after_3_turns(self):
|
||||||
|
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
|
||||||
|
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
|
||||||
|
b.start()
|
||||||
|
b._shield(t, 30)
|
||||||
|
self.assertEqual(b._shield_total(t), 30)
|
||||||
|
b._end_turn_status(t)
|
||||||
|
self.assertEqual(b._shield_total(t), 30) # 3 -> 2
|
||||||
|
b._end_turn_status(t)
|
||||||
|
self.assertEqual(b._shield_total(t), 30) # 2 -> 1
|
||||||
|
b._end_turn_status(t)
|
||||||
|
self.assertEqual(b._shield_total(t), 0) # gone after the 3rd turn
|
||||||
|
self.assertNotIn(S_SHIELD, t.status)
|
||||||
|
|
||||||
|
def test_stacked_shields_decay_independently(self):
|
||||||
|
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
|
||||||
|
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
|
||||||
|
b.start()
|
||||||
|
b._shield(t, 30)
|
||||||
|
b._end_turn_status(t) # first: 3 -> 2
|
||||||
|
b._shield(t, 40) # stack a new one (doesn't refresh the old)
|
||||||
|
b._end_turn_status(t) # first: 2 -> 1, second: 3 -> 2
|
||||||
|
b._end_turn_status(t) # first expires, second: 2 -> 1
|
||||||
|
self.assertEqual(b._shield_total(t), 40) # only the newer 40 remains
|
||||||
|
b._end_turn_status(t) # second expires too
|
||||||
|
self.assertEqual(b._shield_total(t), 0)
|
||||||
|
|
||||||
|
def test_shield_absorption_consumes_applications_in_order(self):
|
||||||
|
t = make_unit("T", TEAM_ENEMY, "garden", hp=500)
|
||||||
|
b = Battle([make_unit("A", TEAM_PLAYER, "radiant")], [t])
|
||||||
|
b.start()
|
||||||
|
b._shield(t, 30)
|
||||||
|
b._shield(t, 40)
|
||||||
|
b._absorb(None, t, 50) # 30 from the old shield, 20 from the new one
|
||||||
|
self.assertEqual(b._shield_total(t), 20)
|
||||||
|
b._absorb(None, t, 20) # finishes the new shield
|
||||||
|
self.assertEqual(b._shield_total(t), 0)
|
||||||
|
self.assertEqual(t.hp, 500)
|
||||||
|
|
||||||
|
|
||||||
class MechanicsTest(unittest.TestCase):
|
class MechanicsTest(unittest.TestCase):
|
||||||
def test_sleep_skips_until_two_hits(self):
|
def test_sleep_skips_until_two_hits(self):
|
||||||
@@ -487,7 +548,7 @@ class MechanicsTest(unittest.TestCase):
|
|||||||
enemy.status["wilt"] = (30, 3)
|
enemy.status["wilt"] = (30, 3)
|
||||||
enemy.status["dot"] = [(10, 2)]
|
enemy.status["dot"] = [(10, 2)]
|
||||||
enemy.status["vulnerable"] = 1
|
enemy.status["vulnerable"] = 1
|
||||||
zinnia.status["shield"] = 10 # one self effect only
|
zinnia.status["shield"] = [(10, 3)] # one self effect only
|
||||||
self.assertIs(b.best_skill(zinnia), glitter)
|
self.assertIs(b.best_skill(zinnia), glitter)
|
||||||
|
|
||||||
def test_zinnia_targets_most_effect_rich_enemy(self):
|
def test_zinnia_targets_most_effect_rich_enemy(self):
|
||||||
@@ -743,5 +804,48 @@ class TowerInjectionTest(unittest.TestCase):
|
|||||||
self.assertNotEqual(base.name, inj["skill"]["name"])
|
self.assertNotEqual(base.name, inj["skill"]["name"])
|
||||||
|
|
||||||
|
|
||||||
|
class TurnActionLimitTest(unittest.TestCase):
|
||||||
|
"""A unit must act only once per turn (spam-clicking must not multi-cast)."""
|
||||||
|
|
||||||
|
def _battle(self, skills):
|
||||||
|
a = make_unit("A", TEAM_PLAYER, "radiant", atk=50, skills=skills)
|
||||||
|
t = make_unit("T", TEAM_ENEMY, "garden", dfn=20, hp=1000)
|
||||||
|
b = Battle([a], [t])
|
||||||
|
b.start()
|
||||||
|
return b, a, t
|
||||||
|
|
||||||
|
def test_unit_cannot_use_two_skills_in_one_turn(self):
|
||||||
|
hit1 = Skill("Hit1", power=10, target=TARGET_ENEMY)
|
||||||
|
hit2 = Skill("Hit2", power=10, target=TARGET_ENEMY)
|
||||||
|
b, a, t = self._battle([hit1, hit2])
|
||||||
|
self.assertTrue(b.use_skill(a, hit1, t))
|
||||||
|
self.assertFalse(b.use_skill(a, hit2, t)) # already acted
|
||||||
|
self.assertTrue(a.acted)
|
||||||
|
|
||||||
|
def test_same_skill_cannot_be_spammed(self):
|
||||||
|
hit = Skill("Hit", power=10, target=TARGET_ENEMY, cooldown=0)
|
||||||
|
b, a, t = self._battle([hit])
|
||||||
|
self.assertTrue(b.use_skill(a, hit, t))
|
||||||
|
self.assertFalse(b.use_skill(a, hit, t)) # acted flag blocks the re-cast
|
||||||
|
|
||||||
|
def test_extra_action_skill_still_usable_after_acting(self):
|
||||||
|
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
|
||||||
|
extra = Skill("Extra", power=5, target=TARGET_ENEMY, extra_action=True)
|
||||||
|
b, a, t = self._battle([hit, extra])
|
||||||
|
self.assertTrue(b.use_skill(a, hit, t))
|
||||||
|
self.assertTrue(b.use_skill(a, extra, t)) # extra action allowed after acting
|
||||||
|
self.assertTrue(a.acted)
|
||||||
|
|
||||||
|
def test_no_extra_actions_after_act_limit_exhausted(self):
|
||||||
|
hit = Skill("Hit", power=10, target=TARGET_ENEMY)
|
||||||
|
extra = Skill("Extra", power=5, target=TARGET_ENEMY, extra_action=True)
|
||||||
|
extra2 = Skill("Extra2", power=5, target=TARGET_ENEMY, extra_action=True)
|
||||||
|
b, a, t = self._battle([hit, extra, extra2])
|
||||||
|
self.assertTrue(b.use_skill(a, hit, t))
|
||||||
|
self.assertTrue(b.use_skill(a, extra, t))
|
||||||
|
self.assertTrue(b.use_skill(a, extra2, t))
|
||||||
|
self.assertFalse(b.use_skill(a, hit, t)) # acted + cooldown: still blocked
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import atexit
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# Point the engine at a fresh empty save to keep collection-dependent tests hermetic.
|
||||||
|
import engine.collection as collection
|
||||||
|
from engine import bosses
|
||||||
|
from engine.battle import Battle
|
||||||
|
from engine.plants import make_leveled
|
||||||
|
from engine.unit import TEAM_ENEMY, TEAM_PLAYER
|
||||||
|
|
||||||
|
_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 BossSchedulingTest(unittest.TestCase):
|
||||||
|
def test_boss_appears_on_multiples_of_20(self):
|
||||||
|
self.assertEqual(bosses.boss_for_floor(20), "Stones")
|
||||||
|
self.assertEqual(bosses.boss_for_floor(40), "Stones")
|
||||||
|
self.assertEqual(bosses.boss_for_floor(100), "Stones")
|
||||||
|
self.assertIsNone(bosses.boss_for_floor(19))
|
||||||
|
self.assertIsNone(bosses.boss_for_floor(1))
|
||||||
|
|
||||||
|
def test_boss_stats_are_extreme(self):
|
||||||
|
u = bosses.make_boss("Stones", 20)
|
||||||
|
# very low speed, medium defense, high health, medium attack
|
||||||
|
self.assertLessEqual(u.speed, 40)
|
||||||
|
self.assertGreaterEqual(u.max_hp, 900)
|
||||||
|
self.assertGreaterEqual(u.defense, 80)
|
||||||
|
self.assertTrue(60 <= u.attack <= 200)
|
||||||
|
|
||||||
|
|
||||||
|
class BossMechanicTest(unittest.TestCase):
|
||||||
|
def _battle(self):
|
||||||
|
a = make_leveled("Rose", TEAM_PLAYER, 20, 1)
|
||||||
|
a.max_hp = 5000
|
||||||
|
a.hp = 5000
|
||||||
|
u = bosses.make_boss("Stones", 20)
|
||||||
|
b = Battle([a], [u])
|
||||||
|
b.start()
|
||||||
|
return b, a, u
|
||||||
|
|
||||||
|
def test_landslide_blocked_until_three_stacks(self):
|
||||||
|
b, a, u = self._battle()
|
||||||
|
b.current = u
|
||||||
|
pebble, landslide = u.skills
|
||||||
|
u.acted = False
|
||||||
|
self.assertFalse(b.use_skill(u, landslide, a)) # 0 stacks: blocked
|
||||||
|
self.assertEqual(b.valid_skill_targets(u, landslide), [])
|
||||||
|
for _ in range(3):
|
||||||
|
u.acted = False
|
||||||
|
self.assertTrue(b.use_skill(u, pebble, a))
|
||||||
|
self.assertEqual(u.status["boss_stacks"], 3)
|
||||||
|
u.acted = False
|
||||||
|
self.assertTrue(b.use_skill(u, landslide, a)) # now available
|
||||||
|
self.assertEqual(u.status["boss_stacks"], 0) # released
|
||||||
|
|
||||||
|
def test_boss_stacks_cap_at_three(self):
|
||||||
|
b, a, u = self._battle()
|
||||||
|
b.current = u
|
||||||
|
pebble, _ = u.skills
|
||||||
|
for _ in range(6):
|
||||||
|
u.acted = False
|
||||||
|
self.assertTrue(b.use_skill(u, pebble, a))
|
||||||
|
self.assertEqual(u.status["boss_stacks"], 3) # never exceeds 3
|
||||||
|
|
||||||
|
def test_boss_ai_pebbles_then_landslides_then_repeats(self):
|
||||||
|
b, a, u = self._battle()
|
||||||
|
moves = []
|
||||||
|
guard = 0
|
||||||
|
while len(moves) < 7 and guard < 120 and b.winner is None:
|
||||||
|
guard += 1
|
||||||
|
if b.current is u and not u.acted:
|
||||||
|
before = u.status.get("boss_stacks", 0)
|
||||||
|
used = b.enemy_ai(u)
|
||||||
|
after = u.status.get("boss_stacks", 0)
|
||||||
|
if used:
|
||||||
|
moves.append("Pebble" if after > before else
|
||||||
|
("Landslide" if before >= 3 and after == 0 else "?"))
|
||||||
|
b.next_turn()
|
||||||
|
self.assertEqual(moves[:4], ["Pebble", "Pebble", "Pebble", "Landslide"])
|
||||||
|
self.assertEqual(moves[4:], ["Pebble", "Pebble", "Pebble"])
|
||||||
|
|
||||||
|
def test_landslide_hits_harder_than_pebble(self):
|
||||||
|
b, a, u = self._battle()
|
||||||
|
b.current = u
|
||||||
|
pebble, landslide = u.skills
|
||||||
|
for _ in range(3):
|
||||||
|
u.acted = False
|
||||||
|
b.use_skill(u, pebble, a)
|
||||||
|
u.acted = False
|
||||||
|
pebble_dmg = b._hit_damage.get(id(a), 0)
|
||||||
|
a.hp = a.max_hp # reset before landslide
|
||||||
|
u.status["boss_stacks"] = 3
|
||||||
|
b.use_skill(u, landslide, a)
|
||||||
|
landslide_dmg = b._hit_damage.get(id(a), 0)
|
||||||
|
self.assertGreater(landslide_dmg, pebble_dmg * 2)
|
||||||
|
|
||||||
|
|
||||||
|
class BossRewardTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
data = collection.load()
|
||||||
|
data["owned"] = ["Rose"]
|
||||||
|
data["bosses_fought"] = []
|
||||||
|
data["runes"] = []
|
||||||
|
collection.save(data)
|
||||||
|
|
||||||
|
def test_mark_boss_fought(self):
|
||||||
|
self.assertEqual(collection.bosses_fought(), set())
|
||||||
|
collection.mark_boss_fought("Stones")
|
||||||
|
self.assertEqual(collection.bosses_fought(), {"Stones"})
|
||||||
|
|
||||||
|
def test_boss_rune_drop_is_high_chance_and_select(self):
|
||||||
|
colors = set()
|
||||||
|
total = 0
|
||||||
|
for _ in range(300):
|
||||||
|
r = collection.boss_rune_drop("Stones")
|
||||||
|
if r:
|
||||||
|
total += 1
|
||||||
|
self.assertIn(r["color"], ("ruby", "amethyst"))
|
||||||
|
colors.add(r["color"])
|
||||||
|
self.assertGreaterEqual(total, 150) # ~80% chance
|
||||||
|
self.assertEqual(colors, {"ruby", "amethyst"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -103,7 +103,8 @@ class RelicApplyTest(unittest.TestCase):
|
|||||||
def test_shield_relic(self):
|
def test_shield_relic(self):
|
||||||
b = _battle("iron_bark")
|
b = _battle("iron_bark")
|
||||||
for u in b.players:
|
for u in b.players:
|
||||||
self.assertGreaterEqual(u.status.get(S_SHIELD, 0), int(u.max_hp * 0.09))
|
total = sum(amount for amount, _ in u.status.get(S_SHIELD, []))
|
||||||
|
self.assertGreaterEqual(total, int(u.max_hp * 0.09))
|
||||||
|
|
||||||
def test_pct_hp_relic(self):
|
def test_pct_hp_relic(self):
|
||||||
base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1)
|
base = make_leveled("Zinnia", TEAM_PLAYER, 1, 1)
|
||||||
@@ -156,7 +157,8 @@ class RelicApplyTest(unittest.TestCase):
|
|||||||
def test_multiple_relics_stack(self):
|
def test_multiple_relics_stack(self):
|
||||||
b = _battle("iron_bark", "morning_dew", "chilling_gust")
|
b = _battle("iron_bark", "morning_dew", "chilling_gust")
|
||||||
for u in b.players:
|
for u in b.players:
|
||||||
self.assertGreaterEqual(u.status.get(S_SHIELD, 0), 1)
|
total = sum(amount for amount, _ in u.status.get(S_SHIELD, []))
|
||||||
|
self.assertGreaterEqual(total, 1)
|
||||||
self.assertEqual(u.status[S_ATTACK_UP], (10, 1))
|
self.assertEqual(u.status[S_ATTACK_UP], (10, 1))
|
||||||
for u in b.enemies:
|
for u in b.enemies:
|
||||||
self.assertEqual(u.status["speed_down"], (8, 1))
|
self.assertEqual(u.status["speed_down"], (8, 1))
|
||||||
|
|||||||
+44
-15
@@ -131,9 +131,9 @@ class RuneTest(unittest.TestCase):
|
|||||||
self.assertIn(r2["id"], free)
|
self.assertIn(r2["id"], free)
|
||||||
self.assertNotIn(r1["id"], free)
|
self.assertNotIn(r1["id"], free)
|
||||||
|
|
||||||
def _ten_runes(self, data):
|
def _sac_runes(self, data):
|
||||||
ids = []
|
ids = []
|
||||||
for _ in range(10):
|
for _ in range(runes.RUNE_SACRIFICE_COST):
|
||||||
ids.append(collection.add_rune("sapphire", data=data, save_now=False)["id"])
|
ids.append(collection.add_rune("sapphire", data=data, save_now=False)["id"])
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
@@ -141,20 +141,20 @@ class RuneTest(unittest.TestCase):
|
|||||||
r = collection.add_rune("ruby")
|
r = collection.add_rune("ruby")
|
||||||
data = collection.load()
|
data = collection.load()
|
||||||
data["runes"][0]["pct"] = 25
|
data["runes"][0]["pct"] = 25
|
||||||
sac = self._ten_runes(data)
|
sac = self._sac_runes(data)
|
||||||
collection.save(data)
|
collection.save(data)
|
||||||
err = collection.reroll_rune(r["id"], sac)
|
err = collection.reroll_rune(r["id"], sac)
|
||||||
self.assertEqual(err, "")
|
self.assertEqual(err, "")
|
||||||
new_pct = collection.load()["runes"][0]["pct"]
|
new_pct = collection.load()["runes"][0]["pct"]
|
||||||
self.assertGreaterEqual(new_pct, 26) # at least 1% better
|
self.assertGreaterEqual(new_pct, 26) # at least 1% better
|
||||||
self.assertLessEqual(new_pct, 35)
|
self.assertLessEqual(new_pct, 35)
|
||||||
self.assertEqual(len(collection.load()["runes"]), 1) # 10 consumed
|
self.assertEqual(len(collection.load()["runes"]), 1) # cost consumed
|
||||||
|
|
||||||
def test_reroll_brown_caps_at_15(self):
|
def test_reroll_brown_caps_at_15(self):
|
||||||
r = collection.add_rune("brown")
|
r = collection.add_rune("brown")
|
||||||
data = collection.load()
|
data = collection.load()
|
||||||
data["runes"][0]["pct"] = 5
|
data["runes"][0]["pct"] = 5
|
||||||
sac = self._ten_runes(data)
|
sac = self._sac_runes(data)
|
||||||
collection.save(data)
|
collection.save(data)
|
||||||
err = collection.reroll_rune(r["id"], sac)
|
err = collection.reroll_rune(r["id"], sac)
|
||||||
self.assertEqual(err, "")
|
self.assertEqual(err, "")
|
||||||
@@ -166,27 +166,27 @@ class RuneTest(unittest.TestCase):
|
|||||||
r = collection.add_rune("ruby")
|
r = collection.add_rune("ruby")
|
||||||
data = collection.load()
|
data = collection.load()
|
||||||
data["runes"][0]["pct"] = 35
|
data["runes"][0]["pct"] = 35
|
||||||
sac = self._ten_runes(data)
|
sac = self._sac_runes(data)
|
||||||
collection.save(data)
|
collection.save(data)
|
||||||
err = collection.reroll_rune(r["id"], sac)
|
err = collection.reroll_rune(r["id"], sac)
|
||||||
self.assertEqual(err, "Already max roll")
|
self.assertEqual(err, "Already max roll")
|
||||||
self.assertEqual(len(collection.load()["runes"]), 11) # nothing consumed
|
self.assertEqual(len(collection.load()["runes"]), 1 + runes.RUNE_SACRIFICE_COST) # nothing consumed
|
||||||
|
|
||||||
def test_reroll_requires_exactly_ten(self):
|
def test_reroll_requires_exact_sacrifice_count(self):
|
||||||
r = collection.add_rune("ruby")
|
r = collection.add_rune("ruby")
|
||||||
data = collection.load()
|
data = collection.load()
|
||||||
data["runes"][0]["pct"] = 25
|
data["runes"][0]["pct"] = 25
|
||||||
sac = self._ten_runes(data)
|
sac = self._sac_runes(data)
|
||||||
collection.save(data)
|
collection.save(data)
|
||||||
self.assertEqual(collection.reroll_rune(r["id"], sac[:9]),
|
self.assertEqual(collection.reroll_rune(r["id"], sac[:runes.RUNE_SACRIFICE_COST - 1]),
|
||||||
"Pick exactly 10 runes to sacrifice")
|
f"Pick exactly {runes.RUNE_SACRIFICE_COST} runes to sacrifice")
|
||||||
|
|
||||||
def test_reroll_rejects_target_in_sacrifices(self):
|
def test_reroll_rejects_target_in_sacrifices(self):
|
||||||
r = collection.add_rune("ruby")
|
r = collection.add_rune("ruby")
|
||||||
data = collection.load()
|
data = collection.load()
|
||||||
data["runes"][0]["pct"] = 25
|
data["runes"][0]["pct"] = 25
|
||||||
sac = self._ten_runes(data)
|
sac = self._sac_runes(data)
|
||||||
sac[0] = r["id"] # 10 unique runes, but includes the upgrade target
|
sac[0] = r["id"] # unique runes, but includes the upgrade target
|
||||||
collection.save(data)
|
collection.save(data)
|
||||||
self.assertEqual(collection.reroll_rune(r["id"], sac),
|
self.assertEqual(collection.reroll_rune(r["id"], sac),
|
||||||
"Cannot sacrifice the rune being upgraded")
|
"Cannot sacrifice the rune being upgraded")
|
||||||
@@ -196,7 +196,7 @@ class RuneTest(unittest.TestCase):
|
|||||||
r2 = collection.add_rune("sapphire")
|
r2 = collection.add_rune("sapphire")
|
||||||
data = collection.load()
|
data = collection.load()
|
||||||
data["runes"][0]["pct"] = 25
|
data["runes"][0]["pct"] = 25
|
||||||
sac = self._ten_runes(data)
|
sac = self._sac_runes(data)
|
||||||
sac[0] = r2["id"] # sacrifice the equipped rune
|
sac[0] = r2["id"] # sacrifice the equipped rune
|
||||||
collection.save(data)
|
collection.save(data)
|
||||||
self._evolve_to(self.plant, 1, 10, 2)
|
self._evolve_to(self.plant, 1, 10, 2)
|
||||||
@@ -338,6 +338,16 @@ class RuneTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(collection.rune_inventory()), before)
|
self.assertEqual(len(collection.rune_inventory()), before)
|
||||||
self.assertEqual(collection.available_coins(), 50)
|
self.assertEqual(collection.available_coins(), 50)
|
||||||
|
|
||||||
|
def test_pearl_is_t2_crit_rune(self):
|
||||||
|
self.assertEqual(runes.tier("pearl"), 2)
|
||||||
|
self.assertEqual(runes.effect_for("pearl"), "crit")
|
||||||
|
self.assertEqual(runes.max_pct("pearl"), 40)
|
||||||
|
self.assertEqual(runes.roll_range("pearl"), "20–40%")
|
||||||
|
for _ in range(50):
|
||||||
|
self.assertGreaterEqual(runes.roll_pct("pearl"), 20)
|
||||||
|
self.assertLessEqual(runes.roll_pct("pearl"), 40)
|
||||||
|
self.assertEqual(runes.name("pearl"), "Pearl Rune")
|
||||||
|
|
||||||
|
|
||||||
class RuneBattleEffectTest(unittest.TestCase):
|
class RuneBattleEffectTest(unittest.TestCase):
|
||||||
def _unit(self, name="A", team=TEAM_PLAYER, ptype="radiant", atk=50, spd=5,
|
def _unit(self, name="A", team=TEAM_PLAYER, ptype="radiant", atk=50, spd=5,
|
||||||
@@ -378,7 +388,7 @@ class RuneBattleEffectTest(unittest.TestCase):
|
|||||||
b = Battle([self._unit()], [t])
|
b = Battle([self._unit()], [t])
|
||||||
b.start()
|
b.start()
|
||||||
b._shield(t, 100)
|
b._shield(t, 100)
|
||||||
self.assertEqual(t.status["shield"], 130)
|
self.assertEqual(sum(a for a, _ in t.status["shield"]), 130)
|
||||||
|
|
||||||
def test_yellow_rune_boosts_healing(self):
|
def test_yellow_rune_boosts_healing(self):
|
||||||
heal = Skill("Mend", power=40, kind="heal", target="ally")
|
heal = Skill("Mend", power=40, kind="heal", target="ally")
|
||||||
@@ -412,6 +422,25 @@ class RuneBattleEffectTest(unittest.TestCase):
|
|||||||
b.use_skill(a, hit, t)
|
b.use_skill(a, hit, t)
|
||||||
self.assertEqual(t.status["wilt"][1], 3) # lasts 3 turns
|
self.assertEqual(t.status["wilt"][1], 3) # lasts 3 turns
|
||||||
|
|
||||||
|
def test_pearl_rune_raises_crit_chance(self):
|
||||||
|
a = self._unit(runes=[("crit", 0.30)])
|
||||||
|
b = Battle([a], [self._unit("T", TEAM_ENEMY, "garden")])
|
||||||
|
b.start()
|
||||||
|
self.assertAlmostEqual(b._crit(a), 0.30)
|
||||||
|
|
||||||
|
def test_pearl_runes_stack_additively(self):
|
||||||
|
a = self._unit(runes=[("crit", 0.20), ("crit", 0.20)])
|
||||||
|
b = Battle([a], [self._unit("T", TEAM_ENEMY, "garden")])
|
||||||
|
b.start()
|
||||||
|
self.assertAlmostEqual(b._crit(a), 0.40)
|
||||||
|
|
||||||
|
def test_pearl_rune_adds_on_top_of_base_crit(self):
|
||||||
|
a = self._unit(runes=[("crit", 0.25)])
|
||||||
|
a.crit = 0.10
|
||||||
|
b = Battle([a], [self._unit("T", TEAM_ENEMY, "garden")])
|
||||||
|
b.start()
|
||||||
|
self.assertAlmostEqual(b._crit(a), 0.35)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -219,6 +219,8 @@ class AutoFarmerTest(unittest.TestCase):
|
|||||||
data["fertilizer"] = 10
|
data["fertilizer"] = 10
|
||||||
data["coins"] = 100
|
data["coins"] = 100
|
||||||
data["auto_farmer"] = False
|
data["auto_farmer"] = False
|
||||||
|
data["auto_planter"] = False
|
||||||
|
data["auto_planter_active"] = False
|
||||||
collection.save(data)
|
collection.save(data)
|
||||||
|
|
||||||
def _stalled_plot(self):
|
def _stalled_plot(self):
|
||||||
@@ -308,6 +310,100 @@ class AutoFarmerTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(state["seeds"]), 1) # seed not planted
|
self.assertEqual(len(state["seeds"]), 1) # seed not planted
|
||||||
self.assertEqual(state["plots"][0]["stage"], 1) # stalled plot advanced
|
self.assertEqual(state["plots"][0]["stage"], 1) # stalled plot advanced
|
||||||
|
|
||||||
|
def test_buy_auto_planter(self):
|
||||||
|
data = collection.load()
|
||||||
|
data["coins"] = 300
|
||||||
|
collection.save(data)
|
||||||
|
self.assertEqual(collection.buy_auto_planter(), "")
|
||||||
|
self.assertTrue(collection.auto_planter_owned())
|
||||||
|
self.assertTrue(collection.auto_planter_active())
|
||||||
|
self.assertEqual(collection.available_coins(), 100)
|
||||||
|
self.assertNotEqual(collection.buy_auto_planter(), "") # already owned
|
||||||
|
|
||||||
|
def test_buy_auto_planter_requires_coins(self):
|
||||||
|
data = collection.load()
|
||||||
|
data["coins"] = 50
|
||||||
|
collection.save(data)
|
||||||
|
self.assertNotEqual(collection.buy_auto_planter(), "")
|
||||||
|
self.assertFalse(collection.auto_planter_owned())
|
||||||
|
|
||||||
|
def test_toggle_auto_planter(self):
|
||||||
|
data = collection.load()
|
||||||
|
data["coins"] = 300
|
||||||
|
collection.save(data)
|
||||||
|
collection.buy_auto_planter()
|
||||||
|
self.assertFalse(collection.toggle_auto_planter()) # turned off
|
||||||
|
self.assertFalse(collection.auto_planter_active())
|
||||||
|
self.assertTrue(collection.toggle_auto_planter()) # back on
|
||||||
|
|
||||||
|
def test_auto_plant_plants_seed_into_empty_plot(self):
|
||||||
|
data = collection.load()
|
||||||
|
data["coins"] = 300
|
||||||
|
data["soil"]["seeds"] = [{"plant": "Rose"}, {"plant": "Lavender"}]
|
||||||
|
collection.save(data)
|
||||||
|
self.assertEqual(collection.buy_auto_planter(), "")
|
||||||
|
self.assertEqual(collection.auto_plant(), "")
|
||||||
|
state = collection.soil_state()
|
||||||
|
self.assertEqual(len(state["plots"]), 1)
|
||||||
|
self.assertEqual(state["plots"][0]["plant"], "Rose") # first seed planted
|
||||||
|
self.assertEqual(len(state["seeds"]), 1)
|
||||||
|
|
||||||
|
def test_auto_plant_respects_toggle_and_requirements(self):
|
||||||
|
data = collection.load()
|
||||||
|
data["coins"] = 300
|
||||||
|
data["soil"]["seeds"] = [{"plant": "Rose"}]
|
||||||
|
collection.save(data)
|
||||||
|
self.assertEqual(collection.auto_plant(), "No auto planter") # not owned
|
||||||
|
collection.buy_auto_planter()
|
||||||
|
collection.toggle_auto_planter() # turn off
|
||||||
|
self.assertEqual(collection.auto_plant(), "Auto planter off")
|
||||||
|
collection.toggle_auto_planter()
|
||||||
|
data = collection.load()
|
||||||
|
data["soil"]["seeds"] = []
|
||||||
|
collection.save(data)
|
||||||
|
self.assertEqual(collection.auto_plant(), "") # nothing to plant -> idle
|
||||||
|
self.assertEqual(len(collection.soil_state()["plots"]), 0)
|
||||||
|
|
||||||
|
def test_auto_plant_wont_overfill_plots(self):
|
||||||
|
data = collection.load()
|
||||||
|
data["coins"] = 300
|
||||||
|
data["soil"]["seeds"] = [{"plant": "Rose"}, {"plant": "Lavender"}]
|
||||||
|
data["soil"]["plots"] = [{"plant": "Rose", "stage_started_at": 0, "stage": 0, "water": 0}]
|
||||||
|
collection.save(data)
|
||||||
|
self.assertEqual(collection.buy_auto_planter(), "")
|
||||||
|
self.assertEqual(collection.auto_plant(), "") # no free plot -> idle
|
||||||
|
self.assertEqual(len(collection.soil_state()["plots"]), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class PackShopTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
data = collection.load()
|
||||||
|
data["coins"] = 500
|
||||||
|
data["packs"] = {t: 0 for t in collection.PACK_TYPES}
|
||||||
|
collection.save(data)
|
||||||
|
|
||||||
|
def test_buy_lesser_and_general(self):
|
||||||
|
self.assertEqual(collection.buy_pack("lesser"), "")
|
||||||
|
self.assertEqual(collection.buy_pack("general"), "")
|
||||||
|
self.assertEqual(collection.buy_pack("garden"), "")
|
||||||
|
self.assertEqual(collection.available_coins(), 500 - 50 - 100 - 100)
|
||||||
|
packs = collection.pack_counts()
|
||||||
|
self.assertEqual(packs["lesser"], 1)
|
||||||
|
self.assertEqual(packs["general"], 1)
|
||||||
|
self.assertEqual(packs["garden"], 1)
|
||||||
|
|
||||||
|
def test_tower_pack_cannot_be_bought(self):
|
||||||
|
self.assertEqual(collection.buy_pack("tower"), "That pack can't be bought")
|
||||||
|
self.assertEqual(collection.pack_counts().get("tower", 0), 0)
|
||||||
|
self.assertEqual(collection.available_coins(), 500)
|
||||||
|
|
||||||
|
def test_buy_pack_requires_coins(self):
|
||||||
|
data = collection.load()
|
||||||
|
data["coins"] = 30
|
||||||
|
collection.save(data)
|
||||||
|
self.assertEqual(collection.buy_pack("lesser"), "Not enough coins")
|
||||||
|
self.assertEqual(collection.pack_counts().get("lesser", 0), 0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+1
-1
@@ -27,7 +27,7 @@ class ExtractionScreen:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption("Plants — Ability Extraction")
|
pygame.display.set_caption("Flora — Ability Extraction")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 30)
|
self.font = pygame.font.SysFont("segoeui", 30)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
|
|||||||
+155
-20
@@ -4,10 +4,11 @@ from assets import icon, load_sprite
|
|||||||
from engine import collection, runes
|
from engine import collection, runes
|
||||||
from engine.battle import Battle
|
from engine.battle import Battle
|
||||||
from engine.dominance import estimate_dominance, resolve_instantly
|
from engine.dominance import estimate_dominance, resolve_instantly
|
||||||
from engine.plants import PASSIVES, RARITY_COLORS, RARITY_LABELS, plant_rarity
|
from engine.growth import EVOLVE_COST, EVOLVE_STAGE_AT_LEVEL, can_evolve
|
||||||
|
from engine.plants import PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS, plant_rarity
|
||||||
from engine.relics import RELIC_POOL
|
from engine.relics import RELIC_POOL
|
||||||
from engine.skills import EFF_DOT, EFF_HOT, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY
|
from engine.skills import EFF_DOT, EFF_HOT, TARGET_ALLY, TARGET_ANY, TARGET_ENEMY
|
||||||
from engine.types import type_multiplier
|
from engine.types import TYPE_BEATS, type_multiplier
|
||||||
from engine.unit import ROLE_NAMES, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER
|
from engine.unit import ROLE_NAMES, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER
|
||||||
from ui.menu import _bracket_text, _draw_evo_badge
|
from ui.menu import _bracket_text, _draw_evo_badge
|
||||||
from ui.relics_overview import RelicsOverview
|
from ui.relics_overview import RelicsOverview
|
||||||
@@ -19,8 +20,8 @@ TYPE_COLORS = {
|
|||||||
"radiant": (245, 200, 90),
|
"radiant": (245, 200, 90),
|
||||||
"nocturnal": (150, 120, 220),
|
"nocturnal": (150, 120, 220),
|
||||||
"ice": (130, 200, 235),
|
"ice": (130, 200, 235),
|
||||||
"garden": (120, 200, 120),
|
"garden": (235, 130, 170),
|
||||||
"wildflower": (235, 130, 170),
|
"wildflower": (120, 200, 120),
|
||||||
}
|
}
|
||||||
WIN_W = 1040
|
WIN_W = 1040
|
||||||
WIN_H = 720
|
WIN_H = 720
|
||||||
@@ -64,13 +65,13 @@ class Game:
|
|||||||
self.stage = stage
|
self.stage = stage
|
||||||
self.relics = list(relics)
|
self.relics = list(relics)
|
||||||
if tower and stage:
|
if tower and stage:
|
||||||
caption = f"Plants — Tower — Floor {stage}"
|
caption = f"Flora — Tower — Floor {stage}"
|
||||||
elif stage:
|
elif stage:
|
||||||
caption = f"Plants — Floor {stage}"
|
caption = f"Flora — Floor {stage}"
|
||||||
elif tower:
|
elif tower:
|
||||||
caption = "Plants — Tower Battle"
|
caption = "Flora — Tower Battle"
|
||||||
else:
|
else:
|
||||||
caption = "Plants — 4v4 Turn Battle (prototype)"
|
caption = "Flora — 4v4 Turn Battle (prototype)"
|
||||||
pygame.display.set_caption(caption)
|
pygame.display.set_caption(caption)
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.battle = battle
|
self.battle = battle
|
||||||
@@ -108,6 +109,12 @@ class Game:
|
|||||||
self._dt = 0.0
|
self._dt = 0.0
|
||||||
self.back_rect = pygame.Rect(12, 12, 90, 32)
|
self.back_rect = pygame.Rect(12, 12, 90, 32)
|
||||||
self.auto_btn = pygame.Rect(112, 12, 90, 32)
|
self.auto_btn = pygame.Rect(112, 12, 90, 32)
|
||||||
|
# "Back" confirmation modal + transient tank-redirect notice.
|
||||||
|
self.confirm_back = False
|
||||||
|
self.confirm_ok_rect = pygame.Rect(0, 0, 0, 0)
|
||||||
|
self.confirm_no_rect = pygame.Rect(0, 0, 0, 0)
|
||||||
|
self.tank_notice = ""
|
||||||
|
self.tank_notice_until = 0
|
||||||
# Auto-battle unlocks once the player has cleared floor 20 in the
|
# Auto-battle unlocks once the player has cleared floor 20 in the
|
||||||
# normal stage battles (floor_progress counts the next unbeaten floor).
|
# normal stage battles (floor_progress counts the next unbeaten floor).
|
||||||
self.auto_unlocked = collection.floor_progress() > 20
|
self.auto_unlocked = collection.floor_progress() > 20
|
||||||
@@ -206,6 +213,13 @@ class Game:
|
|||||||
running = False
|
running = False
|
||||||
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
||||||
pos = to_logical(event.pos, self.scale)
|
pos = to_logical(event.pos, self.scale)
|
||||||
|
if self.confirm_back:
|
||||||
|
if self.confirm_ok_rect.collidepoint(pos):
|
||||||
|
self.confirm_back = False
|
||||||
|
self.battle.winner = TEAM_ENEMY # insta-lose
|
||||||
|
elif self.confirm_no_rect.collidepoint(pos):
|
||||||
|
self.confirm_back = False
|
||||||
|
continue
|
||||||
if self.auto_unlocked and self.auto_btn.collidepoint(pos):
|
if self.auto_unlocked and self.auto_btn.collidepoint(pos):
|
||||||
self.auto_battle = not self.auto_battle
|
self.auto_battle = not self.auto_battle
|
||||||
continue
|
continue
|
||||||
@@ -219,6 +233,9 @@ class Game:
|
|||||||
self.reward_acknowledged = True
|
self.reward_acknowledged = True
|
||||||
continue
|
continue
|
||||||
if self.back_rect.collidepoint(pos):
|
if self.back_rect.collidepoint(pos):
|
||||||
|
if self.battle.winner is None:
|
||||||
|
self.confirm_back = True
|
||||||
|
else:
|
||||||
outcome = "back"
|
outcome = "back"
|
||||||
running = False
|
running = False
|
||||||
else:
|
else:
|
||||||
@@ -294,18 +311,27 @@ class Game:
|
|||||||
if rect.collidepoint(pos):
|
if rect.collidepoint(pos):
|
||||||
if sk.cooldown_left > 0:
|
if sk.cooldown_left > 0:
|
||||||
return
|
return
|
||||||
|
if cur.acted and not sk.extra_action:
|
||||||
|
return # already acted this turn; only extra-action skills remain
|
||||||
if oblivion and sk is not weakest:
|
if oblivion and sk is not weakest:
|
||||||
return
|
return
|
||||||
if sk.target in (TARGET_ENEMY, TARGET_ALLY, TARGET_ANY):
|
if sk.target in (TARGET_ENEMY, TARGET_ALLY, TARGET_ANY):
|
||||||
|
if self.pending_skill is sk:
|
||||||
|
# Clicking the selected skill AGAIN casts it (even if only
|
||||||
|
# one target is available). With several valid targets the
|
||||||
|
# player still needs to click one.
|
||||||
valid = b.valid_skill_targets(cur, sk)
|
valid = b.valid_skill_targets(cur, sk)
|
||||||
if len(valid) == 1:
|
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.pending_skill = None
|
||||||
self._cast(sk, valid[0])
|
self._cast(sk, valid[0])
|
||||||
else:
|
else:
|
||||||
self.pending_skill = sk if self.pending_skill is not sk else None
|
self.pending_skill = sk
|
||||||
else:
|
else:
|
||||||
|
# First click only SELECTS the skill (it must be clicked
|
||||||
|
# again, or a target clicked, to cast).
|
||||||
|
self.pending_skill = sk
|
||||||
|
else:
|
||||||
|
# No target needed (self / AoE): cast right away.
|
||||||
self.pending_skill = None
|
self.pending_skill = None
|
||||||
self._cast(sk, None)
|
self._cast(sk, None)
|
||||||
return
|
return
|
||||||
@@ -319,8 +345,12 @@ class Game:
|
|||||||
if self.pending_skill:
|
if self.pending_skill:
|
||||||
target = self._resolve_target(unit, self.pending_skill)
|
target = self._resolve_target(unit, self.pending_skill)
|
||||||
if target:
|
if target:
|
||||||
|
if self._was_tank_redirect(unit, self.pending_skill, target):
|
||||||
|
self._set_tank_notice()
|
||||||
self._cast(self.pending_skill, target)
|
self._cast(self.pending_skill, target)
|
||||||
self.pending_skill = None
|
self.pending_skill = None
|
||||||
|
# Invalid target (e.g. clicking an enemy with an ally heal/shield
|
||||||
|
# selected): do nothing and KEEP the skill selected.
|
||||||
return
|
return
|
||||||
if unit.team != TEAM_PLAYER:
|
if unit.team != TEAM_PLAYER:
|
||||||
best = next((s for s in sorted(cur.skills, key=lambda s: s.priority)
|
best = next((s for s in sorted(cur.skills, key=lambda s: s.priority)
|
||||||
@@ -328,8 +358,19 @@ class Game:
|
|||||||
if best:
|
if best:
|
||||||
target = self._resolve_target(unit, best)
|
target = self._resolve_target(unit, best)
|
||||||
if target:
|
if target:
|
||||||
|
if self._was_tank_redirect(unit, best, target):
|
||||||
|
self._set_tank_notice()
|
||||||
self._cast(best, target)
|
self._cast(best, target)
|
||||||
|
|
||||||
|
def _was_tank_redirect(self, unit, skill, target):
|
||||||
|
"""True when `unit` (a backline enemy) was redirected to a tank."""
|
||||||
|
return (skill.target == "enemy" and unit.team != TEAM_PLAYER
|
||||||
|
and unit.alive and unit.role != "tank" and unit is not target)
|
||||||
|
|
||||||
|
def _set_tank_notice(self):
|
||||||
|
self.tank_notice = "A tank is protecting that unit — attack the tank first!"
|
||||||
|
self.tank_notice_until = pygame.time.get_ticks() + 3000
|
||||||
|
|
||||||
def _resolve_target(self, unit, skill):
|
def _resolve_target(self, unit, skill):
|
||||||
b = self.battle
|
b = self.battle
|
||||||
if unit in b.valid_skill_targets(b.current, skill):
|
if unit in b.valid_skill_targets(b.current, skill):
|
||||||
@@ -380,6 +421,8 @@ class Game:
|
|||||||
|
|
||||||
def _update(self, dt):
|
def _update(self, dt):
|
||||||
b = self.battle
|
b = self.battle
|
||||||
|
if self.tank_notice and pygame.time.get_ticks() >= self.tank_notice_until:
|
||||||
|
self.tank_notice = ""
|
||||||
self._consume_events()
|
self._consume_events()
|
||||||
self._advance_hit_queue(dt)
|
self._advance_hit_queue(dt)
|
||||||
if b.winner is not None:
|
if b.winner is not None:
|
||||||
@@ -603,9 +646,9 @@ class Game:
|
|||||||
elif self.stage:
|
elif self.stage:
|
||||||
title_text = f"Floor {self.stage}"
|
title_text = f"Floor {self.stage}"
|
||||||
elif self.tower:
|
elif self.tower:
|
||||||
title_text = "Plants — Tower Battle"
|
title_text = "Flora — Tower Battle"
|
||||||
else:
|
else:
|
||||||
title_text = "Plants — 4v4 Turn Battle"
|
title_text = "Flora — 4v4 Turn Battle"
|
||||||
title = self.font.render(title_text, True, TEXT)
|
title = self.font.render(title_text, True, TEXT)
|
||||||
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 24)))
|
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 24)))
|
||||||
pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6)
|
pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6)
|
||||||
@@ -636,6 +679,75 @@ class Game:
|
|||||||
self._draw_reward_popup()
|
self._draw_reward_popup()
|
||||||
if self.relic_overview is not None:
|
if self.relic_overview is not None:
|
||||||
self.relic_overview.draw(self.mouse_pos)
|
self.relic_overview.draw(self.mouse_pos)
|
||||||
|
if self.tank_notice and pygame.time.get_ticks() < self.tank_notice_until:
|
||||||
|
self._draw_tank_notice()
|
||||||
|
if self.confirm_back:
|
||||||
|
self._draw_confirm_back()
|
||||||
|
|
||||||
|
def _draw_tank_notice(self):
|
||||||
|
t = self.small.render(self.tank_notice, True, (255, 210, 90))
|
||||||
|
bg = pygame.Surface((t.get_width() + 24, t.get_height() + 10), pygame.SRCALPHA)
|
||||||
|
bg.fill((20, 24, 20, 210))
|
||||||
|
rect = t.get_rect(center=(WIN_W // 2, 96))
|
||||||
|
self.screen.blit(bg, (rect.x - 12, rect.y - 5))
|
||||||
|
self.screen.blit(t, rect)
|
||||||
|
|
||||||
|
def _draw_confirm_back(self):
|
||||||
|
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
|
||||||
|
overlay.fill((0, 0, 0, 140))
|
||||||
|
self.screen.blit(overlay, (0, 0))
|
||||||
|
w, h = 460, 170
|
||||||
|
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
|
||||||
|
pygame.draw.rect(self.screen, (32, 38, 32), panel, border_radius=12)
|
||||||
|
pygame.draw.rect(self.screen, (150, 70, 70), panel, 2, border_radius=12)
|
||||||
|
title = self.bold.render("Leave the battle?", True, TEXT)
|
||||||
|
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30)))
|
||||||
|
msg = self.small.render("You will lose this battle.", True, MUTED)
|
||||||
|
self.screen.blit(msg, msg.get_rect(center=(panel.centerx, panel.y + 58)))
|
||||||
|
self.confirm_ok_rect = pygame.Rect(panel.centerx - 120, panel.bottom - 54, 100, 36)
|
||||||
|
self.confirm_no_rect = pygame.Rect(panel.centerx + 20, panel.bottom - 54, 100, 36)
|
||||||
|
pygame.draw.rect(self.screen, (150, 70, 70), self.confirm_ok_rect, border_radius=8)
|
||||||
|
ok = self.bold.render("Leave", True, TEXT)
|
||||||
|
self.screen.blit(ok, ok.get_rect(center=self.confirm_ok_rect.center))
|
||||||
|
pygame.draw.rect(self.screen, BTN, self.confirm_no_rect, border_radius=8)
|
||||||
|
no = self.bold.render("Cancel", True, TEXT)
|
||||||
|
self.screen.blit(no, no.get_rect(center=self.confirm_no_rect.center))
|
||||||
|
|
||||||
|
def _defeat_tips(self):
|
||||||
|
"""Contextual tips for the defeat screen, based on the player's state
|
||||||
|
and the units they just brought into the lost battle."""
|
||||||
|
tips = []
|
||||||
|
if len(collection.owned()) < len(POOL) and sum(collection.pack_counts().values()) > 0:
|
||||||
|
tips.append("Try to open packs to gain more allies!")
|
||||||
|
if collection.available_water() >= 1:
|
||||||
|
tips.append("You can use water to level up your plants and critters.")
|
||||||
|
if collection.free_runes():
|
||||||
|
tips.append("You can use runes to improve your fighting abilities.")
|
||||||
|
used = list(self.battle.players)
|
||||||
|
enemy_types = [e.plant_type for e in self.battle.enemies]
|
||||||
|
for u in used:
|
||||||
|
prog = collection.get_progress(u.name)
|
||||||
|
if can_evolve(prog["level"], prog["stage"], prog.get("duplicates", 0)):
|
||||||
|
tips.append("Try evolving your plants for better stats.")
|
||||||
|
break
|
||||||
|
for u in used:
|
||||||
|
for etype in enemy_types:
|
||||||
|
if TYPE_BEATS.get(etype) == u.plant_type:
|
||||||
|
tips.append(f"Make sure to use the types to your advantage! "
|
||||||
|
f"{u.plant_type} is weak against {etype}.")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
for u in used:
|
||||||
|
prog = collection.get_progress(u.name)
|
||||||
|
target = prog["stage"] + 1
|
||||||
|
if (target in EVOLVE_STAGE_AT_LEVEL
|
||||||
|
and prog["level"] >= EVOLVE_STAGE_AT_LEVEL[target]
|
||||||
|
and prog.get("duplicates", 0) < EVOLVE_COST[target]):
|
||||||
|
tips.append("You can plant seeds from your plants to gain more of them.")
|
||||||
|
break
|
||||||
|
return tips
|
||||||
|
|
||||||
def _draw_reward_popup(self):
|
def _draw_reward_popup(self):
|
||||||
b = self.battle
|
b = self.battle
|
||||||
@@ -656,8 +768,11 @@ class Game:
|
|||||||
kind = it.get("kind", "")
|
kind = it.get("kind", "")
|
||||||
# Display scaled to match the rest of the UI (aspect preserved).
|
# Display scaled to match the rest of the UI (aspect preserved).
|
||||||
img = fit_surface(img, 60 if kind == "rune" else 80)
|
img = fit_surface(img, 60 if kind == "rune" else 80)
|
||||||
tiles.append((img, label, img.get_width(), img.get_height() + 20,
|
# Tile width must also fit the label, or a rune + pack in the
|
||||||
kind == "rune"))
|
# same row overlap (the rune's label spills into the pack tile).
|
||||||
|
label_w = self.bold.size(label)[0]
|
||||||
|
tiles.append((img, label, max(img.get_width(), label_w),
|
||||||
|
img.get_height() + 20, kind == "rune"))
|
||||||
else:
|
else:
|
||||||
tiles.append((None, label, max(90, self.bold.size(label)[0] + 20), 30, False))
|
tiles.append((None, label, max(90, self.bold.size(label)[0] + 20), 30, False))
|
||||||
|
|
||||||
@@ -698,7 +813,9 @@ class Game:
|
|||||||
if target >= max(img.get_width(), img.get_height()):
|
if target >= max(img.get_width(), img.get_height()):
|
||||||
continue
|
continue
|
||||||
img2 = fit_surface(img, target)
|
img2 = fit_surface(img, target)
|
||||||
tiles[i] = (img2, t[1], img2.get_width(), img2.get_height() + 20, t[4])
|
label_w = self.bold.size(t[1])[0]
|
||||||
|
tiles[i] = (img2, t[1], max(img2.get_width(), label_w),
|
||||||
|
img2.get_height() + 20, t[4])
|
||||||
changed = True
|
changed = True
|
||||||
if not changed:
|
if not changed:
|
||||||
break
|
break
|
||||||
@@ -706,6 +823,14 @@ class Game:
|
|||||||
panel_w = max(440, max_row_w + pad * 2)
|
panel_w = max(440, max_row_w + pad * 2)
|
||||||
panel_h = head_h + tiles_h + 46
|
panel_h = head_h + tiles_h + 46
|
||||||
|
|
||||||
|
# Defeat tips: contextual advice shown in the popup's text box.
|
||||||
|
tip_lines = []
|
||||||
|
if b.winner != TEAM_PLAYER:
|
||||||
|
for tip in self._defeat_tips():
|
||||||
|
tip_lines.extend(wrap_text(tip, self.small, panel_w - pad * 2))
|
||||||
|
tips_h = len(tip_lines) * 20 + (14 if tip_lines else 0)
|
||||||
|
panel_h += tips_h
|
||||||
|
|
||||||
panel = pygame.Rect((WIN_W - panel_w) // 2, (WIN_H - panel_h) // 2, panel_w, panel_h)
|
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)
|
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)
|
border = (150, 190, 150) if b.winner == TEAM_PLAYER else (200, 90, 90)
|
||||||
@@ -722,7 +847,8 @@ class Game:
|
|||||||
x = panel.centerx - total // 2
|
x = panel.centerx - total // 2
|
||||||
for img, label, tw, th, _ in r:
|
for img, label, tw, th, _ in r:
|
||||||
if img is not None:
|
if img is not None:
|
||||||
self.screen.blit(img, (x, y))
|
self.screen.blit(img, img.get_rect(center=(x + tw // 2,
|
||||||
|
y + img.get_height() // 2)))
|
||||||
lr = self.bold.render(label, True, HINT)
|
lr = self.bold.render(label, True, HINT)
|
||||||
self.screen.blit(lr, lr.get_rect(center=(x + tw // 2, y + img.get_height() + 10)))
|
self.screen.blit(lr, lr.get_rect(center=(x + tw // 2, y + img.get_height() + 10)))
|
||||||
else:
|
else:
|
||||||
@@ -732,6 +858,12 @@ class Game:
|
|||||||
self.screen.blit(t, t.get_rect(center=box.center))
|
self.screen.blit(t, t.get_rect(center=box.center))
|
||||||
x += tw + gap
|
x += tw + gap
|
||||||
y += row_h[i] + 14
|
y += row_h[i] + 14
|
||||||
|
if tip_lines:
|
||||||
|
ty = panel.y + head_h + tiles_h + 10
|
||||||
|
for line in tip_lines:
|
||||||
|
t = self.small.render(line, True, (215, 220, 215))
|
||||||
|
self.screen.blit(t, (panel.x + pad, ty))
|
||||||
|
ty += 20
|
||||||
hint = self.tiny.render("Click to continue", True, (150, 155, 150))
|
hint = self.tiny.render("Click to continue", True, (150, 155, 150))
|
||||||
self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 16)))
|
self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 16)))
|
||||||
|
|
||||||
@@ -778,6 +910,8 @@ class Game:
|
|||||||
parts.append(f"Heal {sum(a for a, _ in stacks)}/t")
|
parts.append(f"Heal {sum(a for a, _ in stacks)}/t")
|
||||||
if "form" in unit.status:
|
if "form" in unit.status:
|
||||||
parts.append(unit.status["form"].capitalize())
|
parts.append(unit.status["form"].capitalize())
|
||||||
|
if unit.status.get("boss_stacks", 0):
|
||||||
|
parts.append(f"Power {unit.status['boss_stacks']}/3")
|
||||||
return " | ".join(parts)
|
return " | ".join(parts)
|
||||||
|
|
||||||
def _draw_cards(self):
|
def _draw_cards(self):
|
||||||
@@ -804,10 +938,11 @@ class Game:
|
|||||||
bar_color = (80, 220, 90) if pct > 0.5 else (220, 180, 40) if pct > 0.25 else (220, 60, 60)
|
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)
|
fill = int(bar_w * pct)
|
||||||
pygame.draw.rect(self.screen, bar_color, (hp_x, rect.y + 7, fill, 10))
|
pygame.draw.rect(self.screen, bar_color, (hp_x, rect.y + 7, fill, 10))
|
||||||
shield = unit.status.get("shield", 0)
|
shield = sum(amount for amount, _ in unit.status.get("shield", []))
|
||||||
if shield > 0 and fill > 0:
|
if shield > 0 and fill > 0:
|
||||||
|
# The shield bar shows the FULL shield value, independent of the
|
||||||
|
# unit's current HP (a low-HP unit still shows its whole shield).
|
||||||
sh_w = int(bar_w * min(1.0, shield / unit.max_hp))
|
sh_w = int(bar_w * min(1.0, shield / unit.max_hp))
|
||||||
sh_w = min(sh_w, fill)
|
|
||||||
if sh_w > 0:
|
if sh_w > 0:
|
||||||
tint = pygame.Surface((sh_w, 10), pygame.SRCALPHA)
|
tint = pygame.Surface((sh_w, 10), pygame.SRCALPHA)
|
||||||
tint.fill((120, 185, 240, 140))
|
tint.fill((120, 185, 240, 140))
|
||||||
@@ -1046,7 +1181,7 @@ class Game:
|
|||||||
if ready:
|
if ready:
|
||||||
oblivion_skill = min(ready, key=lambda s: s.power)
|
oblivion_skill = min(ready, key=lambda s: s.power)
|
||||||
for i, (sk, rect) in enumerate(self.skill_rects):
|
for i, (sk, rect) in enumerate(self.skill_rects):
|
||||||
ready = sk.cooldown_left <= 0
|
ready = sk.cooldown_left <= 0 and (not unit.acted or sk.extra_action)
|
||||||
blocked = oblivion and sk is not oblivion_skill
|
blocked = oblivion and sk is not oblivion_skill
|
||||||
if sk is self.pending_skill:
|
if sk is self.pending_skill:
|
||||||
bg = (120, 120, 40)
|
bg = (120, 120, 40)
|
||||||
|
|||||||
+194
-71
@@ -4,7 +4,7 @@ import pygame
|
|||||||
|
|
||||||
from assets import icon as icon_path
|
from assets import icon as icon_path
|
||||||
from assets import load_sprite
|
from assets import load_sprite
|
||||||
from engine import collection
|
from engine import bosses, collection
|
||||||
from engine import runes
|
from engine import runes
|
||||||
from engine.growth import MAX_LEVEL, can_evolve, compute_stats, evolve_cost, xp_to_next
|
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,
|
from engine.plants import (HP_MULT, PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS,
|
||||||
@@ -34,8 +34,8 @@ TYPE_COLORS = {
|
|||||||
"radiant": (245, 200, 90),
|
"radiant": (245, 200, 90),
|
||||||
"nocturnal": (150, 120, 220),
|
"nocturnal": (150, 120, 220),
|
||||||
"ice": (130, 200, 235),
|
"ice": (130, 200, 235),
|
||||||
"garden": (120, 200, 120),
|
"garden": (235, 130, 170),
|
||||||
"wildflower": (235, 130, 170),
|
"wildflower": (120, 200, 120),
|
||||||
}
|
}
|
||||||
def _draw_star(screen, center, radius, color):
|
def _draw_star(screen, center, radius, color):
|
||||||
import math
|
import math
|
||||||
@@ -84,7 +84,7 @@ class Menu:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption("Plants")
|
pygame.display.set_caption("Flora")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 36)
|
self.font = pygame.font.SysFont("segoeui", 36)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
@@ -118,6 +118,7 @@ class Menu:
|
|||||||
self.seed_rect = pygame.Rect(250, 352, 220, 30)
|
self.seed_rect = pygame.Rect(250, 352, 220, 30)
|
||||||
self.runes_rect = pygame.Rect(480, 352, 170, 30)
|
self.runes_rect = pygame.Rect(480, 352, 170, 30)
|
||||||
self.auto_farmer_rect = pygame.Rect(0, 0, 0, 0)
|
self.auto_farmer_rect = pygame.Rect(0, 0, 0, 0)
|
||||||
|
self.auto_planter_rect = pygame.Rect(0, 0, 0, 0)
|
||||||
self.runes_deconstruct_rect = pygame.Rect(WIN_W - 220, 16, 110, 34)
|
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_info_rect = pygame.Rect(WIN_W - 100, 16, 84, 34)
|
||||||
self.runes_deconstruct = False
|
self.runes_deconstruct = False
|
||||||
@@ -153,6 +154,11 @@ class Menu:
|
|||||||
self.redeem_rect = pygame.Rect(WIN_W - 250, 50, 234, 30)
|
self.redeem_rect = pygame.Rect(WIN_W - 250, 50, 234, 30)
|
||||||
self.history_rect = pygame.Rect(WIN_W - 250, 84, 234, 26)
|
self.history_rect = pygame.Rect(WIN_W - 250, 84, 234, 26)
|
||||||
self.history_open = False
|
self.history_open = False
|
||||||
|
self.shop_mode = False
|
||||||
|
self.shop_rect = pygame.Rect(WIN_W - 250, 114, 234, 28)
|
||||||
|
self.shop_btns = {}
|
||||||
|
self.shop_msg = ""
|
||||||
|
self.shop_msg_until = 0
|
||||||
self._layout_packs()
|
self._layout_packs()
|
||||||
self.diff_rects = {i: pygame.Rect(220, 130 + (i - 1) * 74, 460, 58) for i in range(1, 6)}
|
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_rects = {i: pygame.Rect(220, 130 + (i - 1) * 74, 460, 58) for i in range(1, 6)}
|
||||||
@@ -178,8 +184,9 @@ class Menu:
|
|||||||
self.coll_sort = None
|
self.coll_sort = None
|
||||||
self.collection_tab = "plants"
|
self.collection_tab = "plants"
|
||||||
self.collection_tab_btns = {
|
self.collection_tab_btns = {
|
||||||
"plants": pygame.Rect(WIN_W // 2 - 170, 100, 150, 30),
|
"plants": pygame.Rect(WIN_W // 2 - 250, 100, 150, 30),
|
||||||
"runes": pygame.Rect(WIN_W // 2 + 20, 100, 150, 30),
|
"bosses": pygame.Rect(WIN_W // 2 - 70, 100, 150, 30),
|
||||||
|
"runes": pygame.Rect(WIN_W // 2 + 110, 100, 150, 30),
|
||||||
}
|
}
|
||||||
self.coll_rune_tier = 0
|
self.coll_rune_tier = 0
|
||||||
self.coll_rune_color = None
|
self.coll_rune_color = None
|
||||||
@@ -206,27 +213,18 @@ class Menu:
|
|||||||
"level": pygame.Rect(767, 50, 56, 28),
|
"level": pygame.Rect(767, 50, 56, 28),
|
||||||
"stage": pygame.Rect(827, 50, 58, 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_msg = None # dict describing a soil popup (harvest result)
|
||||||
self.soil_fert_info = False
|
self.soil_fert_info = False
|
||||||
self.water_info = False
|
self.water_info = False
|
||||||
self.soil_notice = ""
|
self.soil_notice = ""
|
||||||
self.soil_notice_until = 0
|
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()
|
self._layout_buttons()
|
||||||
|
|
||||||
def _reinit(self):
|
def _reinit(self):
|
||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption("Plants")
|
pygame.display.set_caption("Flora")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 36)
|
self.font = pygame.font.SysFont("segoeui", 36)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
@@ -293,16 +291,16 @@ class Menu:
|
|||||||
"Plants": pygame.Rect(x0, y, bw, bh),
|
"Plants": pygame.Rect(x0, y, bw, bh),
|
||||||
"Battle": pygame.Rect(x0 + bw + gap, y, bw, bh),
|
"Battle": pygame.Rect(x0 + bw + gap, y, bw, bh),
|
||||||
}
|
}
|
||||||
# Side buttons (square-ish): Seeds & Runes left, Soil & Collection right.
|
# Side buttons (square-ish): Soil & Seeds left, Runes & Collection right.
|
||||||
# Fitted into the lower half, keeping clear of the bottom row.
|
# Fitted into the lower half, keeping clear of the bottom row.
|
||||||
sq, sq_gap = 104, 16
|
sq, sq_gap = 104, 16
|
||||||
ry = WIN_H - 72 - 2 * sq - sq_gap - 10
|
ry = WIN_H - 72 - 2 * sq - sq_gap - 10
|
||||||
self.side_buttons = {
|
self.side_buttons = {
|
||||||
"Runes": pygame.Rect(34, ry, sq, sq),
|
"Soil": pygame.Rect(34, ry, sq, sq),
|
||||||
"Seeds": pygame.Rect(34, ry + sq + sq_gap, sq, sq),
|
"Seeds": pygame.Rect(34, ry + sq + sq_gap, sq, sq),
|
||||||
}
|
}
|
||||||
rx = WIN_W - 34 - sq
|
rx = WIN_W - 34 - sq
|
||||||
self.side_buttons["Soil"] = pygame.Rect(rx, ry, sq, sq)
|
self.side_buttons["Runes"] = pygame.Rect(rx, ry, sq, sq)
|
||||||
self.side_buttons["Collection"] = pygame.Rect(rx, ry + sq + sq_gap, sq, sq)
|
self.side_buttons["Collection"] = pygame.Rect(rx, ry + sq + sq_gap, sq, sq)
|
||||||
|
|
||||||
def _seed_pack_sprite(self, pt, size=110):
|
def _seed_pack_sprite(self, pt, size=110):
|
||||||
@@ -356,6 +354,7 @@ class Menu:
|
|||||||
collection.accrue_coins()
|
collection.accrue_coins()
|
||||||
collection.accrue_fertilizer()
|
collection.accrue_fertilizer()
|
||||||
collection.auto_farm()
|
collection.auto_farm()
|
||||||
|
collection.auto_plant()
|
||||||
self._passive_check_at = pygame.time.get_ticks() + 1000
|
self._passive_check_at = pygame.time.get_ticks() + 1000
|
||||||
if self.page != self._prev_page:
|
if self.page != self._prev_page:
|
||||||
self.grid_scroll = 0
|
self.grid_scroll = 0
|
||||||
@@ -399,14 +398,16 @@ class Menu:
|
|||||||
if self.type_info is not None:
|
if self.type_info is not None:
|
||||||
self.type_info = None
|
self.type_info = None
|
||||||
return None
|
return None
|
||||||
if self.convert_mode:
|
if self.shop_mode:
|
||||||
for pt, rect in self.convert_pack_btns.items():
|
if self.shop_msg and pygame.time.get_ticks() < self.shop_msg_until:
|
||||||
|
return None # a "bought" notice is showing; ignore clicks briefly
|
||||||
|
for pt, rect in self.shop_btns.items():
|
||||||
if rect.collidepoint(pos):
|
if rect.collidepoint(pos):
|
||||||
if collection.convert_water_to_pack(pt):
|
err = collection.buy_pack(pt)
|
||||||
self.owned = collection.owned()
|
self.shop_msg = err or f"Bought a {pt.capitalize()} pack!"
|
||||||
self.convert_mode = False
|
self.shop_msg_until = pygame.time.get_ticks() + 1800
|
||||||
return None
|
return None
|
||||||
self.convert_mode = False
|
self.shop_mode = False
|
||||||
return None
|
return None
|
||||||
if self.page == "seeds":
|
if self.page == "seeds":
|
||||||
if self.history_open:
|
if self.history_open:
|
||||||
@@ -421,6 +422,9 @@ class Menu:
|
|||||||
if self.redeem_rect.collidepoint(pos):
|
if self.redeem_rect.collidepoint(pos):
|
||||||
collection.redeem_tower_pack()
|
collection.redeem_tower_pack()
|
||||||
return None
|
return None
|
||||||
|
if self.shop_rect.collidepoint(pos):
|
||||||
|
self.shop_mode = True
|
||||||
|
return None
|
||||||
for pt, (card, btn) in self.pack_rects.items():
|
for pt, (card, btn) in self.pack_rects.items():
|
||||||
if btn.move(0, -self.seeds_scroll).collidepoint(pos):
|
if btn.move(0, -self.seeds_scroll).collidepoint(pos):
|
||||||
result = collection.open_pack(pt)
|
result = collection.open_pack(pt)
|
||||||
@@ -453,6 +457,8 @@ class Menu:
|
|||||||
if rect.collidepoint(pos):
|
if rect.collidepoint(pos):
|
||||||
self.collection_tab = key
|
self.collection_tab = key
|
||||||
return None
|
return None
|
||||||
|
if self.collection_tab == "bosses":
|
||||||
|
return None # boss cards are informational only
|
||||||
if self.collection_tab == "runes":
|
if self.collection_tab == "runes":
|
||||||
if self._click_coll_rune_filter(pos):
|
if self._click_coll_rune_filter(pos):
|
||||||
return None
|
return None
|
||||||
@@ -488,9 +494,6 @@ class Menu:
|
|||||||
self.water_mode = not self.water_mode
|
self.water_mode = not self.water_mode
|
||||||
return None
|
return None
|
||||||
if self.water_mode:
|
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):
|
if self.water_exit_btn.collidepoint(pos):
|
||||||
self.water_mode = False
|
self.water_mode = False
|
||||||
return None
|
return None
|
||||||
@@ -592,6 +595,16 @@ class Menu:
|
|||||||
"activated" if active else "deactivated")
|
"activated" if active else "deactivated")
|
||||||
self.soil_notice_until = pygame.time.get_ticks() + 1800
|
self.soil_notice_until = pygame.time.get_ticks() + 1800
|
||||||
return None
|
return None
|
||||||
|
if self.auto_planter_rect.collidepoint(pos):
|
||||||
|
if not collection.auto_planter_owned():
|
||||||
|
reason = collection.buy_auto_planter()
|
||||||
|
self.soil_notice = reason or "Auto planter bought!"
|
||||||
|
else:
|
||||||
|
active = collection.toggle_auto_planter()
|
||||||
|
self.soil_notice = "Auto planter " + (
|
||||||
|
"activated" if active else "deactivated")
|
||||||
|
self.soil_notice_until = pygame.time.get_ticks() + 1800
|
||||||
|
return None
|
||||||
for idx, rect in self.soil_seed_rects:
|
for idx, rect in self.soil_seed_rects:
|
||||||
if rect.collidepoint(pos):
|
if rect.collidepoint(pos):
|
||||||
collection.plant_seed(idx)
|
collection.plant_seed(idx)
|
||||||
@@ -727,8 +740,8 @@ class Menu:
|
|||||||
if self.type_info is not None:
|
if self.type_info is not None:
|
||||||
draw_type_popup(self.screen, WIN_W, WIN_H, self.type_info,
|
draw_type_popup(self.screen, WIN_W, WIN_H, self.type_info,
|
||||||
self.bold, self.small, self.tiny)
|
self.bold, self.small, self.tiny)
|
||||||
if self.convert_mode:
|
if self.shop_mode:
|
||||||
self._draw_convert_pack()
|
self._draw_shop()
|
||||||
if self.tower_info:
|
if self.tower_info:
|
||||||
self._draw_tower_info()
|
self._draw_tower_info()
|
||||||
if self.soil_msg:
|
if self.soil_msg:
|
||||||
@@ -796,7 +809,7 @@ class Menu:
|
|||||||
|
|
||||||
def _draw_menu(self):
|
def _draw_menu(self):
|
||||||
self._draw_resources()
|
self._draw_resources()
|
||||||
title = self.font.render("Plants", True, TEXT)
|
title = self.font.render("Flora", True, TEXT)
|
||||||
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 86)))
|
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 86)))
|
||||||
y = 130
|
y = 130
|
||||||
for line in OVERVIEW:
|
for line in OVERVIEW:
|
||||||
@@ -819,7 +832,7 @@ class Menu:
|
|||||||
else:
|
else:
|
||||||
t = self.bold.render(label, True, TEXT)
|
t = self.bold.render(label, True, TEXT)
|
||||||
self.screen.blit(t, t.get_rect(center=rect.center))
|
self.screen.blit(t, t.get_rect(center=rect.center))
|
||||||
# side buttons: Seeds (left), Soil & Collection (right)
|
# side buttons: Soil & Seeds (left), Runes & Collection (right)
|
||||||
soil_locked = not collection.soil_unlocked()
|
soil_locked = not collection.soil_unlocked()
|
||||||
for label, rect in self.side_buttons.items():
|
for label, rect in self.side_buttons.items():
|
||||||
hover = rect.collidepoint(mouse)
|
hover = rect.collidepoint(mouse)
|
||||||
@@ -857,7 +870,7 @@ class Menu:
|
|||||||
n = collection.SOIL_GATE_PLANTS - len(collection.owned())
|
n = collection.SOIL_GATE_PLANTS - len(collection.owned())
|
||||||
hint = self.tiny.render(f"Own {collection.SOIL_GATE_PLANTS} plants to unlock Soil"
|
hint = self.tiny.render(f"Own {collection.SOIL_GATE_PLANTS} plants to unlock Soil"
|
||||||
f" ({n} to go)", True, (110, 115, 110))
|
f" ({n} to go)", True, (110, 115, 110))
|
||||||
self.screen.blit(hint, hint.get_rect(midright=(self.side_buttons["Soil"].right,
|
self.screen.blit(hint, hint.get_rect(midleft=(self.side_buttons["Soil"].left,
|
||||||
self.side_buttons["Soil"].top - 6)))
|
self.side_buttons["Soil"].top - 6)))
|
||||||
|
|
||||||
def _draw_battle_setup(self):
|
def _draw_battle_setup(self):
|
||||||
@@ -972,6 +985,12 @@ class Menu:
|
|||||||
pygame.draw.rect(self.screen, (150, 190, 150), self.history_rect, 1, 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)
|
ht = self.tiny.render("History (last 10)", True, TEXT)
|
||||||
self.screen.blit(ht, ht.get_rect(center=self.history_rect.center))
|
self.screen.blit(ht, ht.get_rect(center=self.history_rect.center))
|
||||||
|
shov = self.shop_rect.collidepoint(mouse)
|
||||||
|
pygame.draw.rect(self.screen, (90, 120, 90) if shov else BTN,
|
||||||
|
self.shop_rect, border_radius=6)
|
||||||
|
pygame.draw.rect(self.screen, (150, 190, 150), self.shop_rect, 1, border_radius=6)
|
||||||
|
sh = self.tiny.render("Pack Shop", True, TEXT)
|
||||||
|
self.screen.blit(sh, sh.get_rect(center=self.shop_rect.center))
|
||||||
# scrollable grid of packs, each showing its sprite at native size
|
# scrollable grid of packs, each showing its sprite at native size
|
||||||
old_clip = self.screen.get_clip()
|
old_clip = self.screen.get_clip()
|
||||||
self.screen.set_clip(self.seeds_scroll_area)
|
self.screen.set_clip(self.seeds_scroll_area)
|
||||||
@@ -1143,7 +1162,7 @@ class Menu:
|
|||||||
self.screen.blit(rp, (panel.right - 50, y))
|
self.screen.blit(rp, (panel.right - 50, y))
|
||||||
low, high = runes.per_rune_chance(runes.PACK_DROP_CHANCE)
|
low, high = runes.per_rune_chance(runes.PACK_DROP_CHANCE)
|
||||||
sub = self.tiny.render(
|
sub = self.tiny.render(
|
||||||
f"{rpct:.0f}% for any rune — brown is rarer than the others",
|
f"{rpct:.0f}% for any rune — brown & pearl are rarer than the others",
|
||||||
True, MUTED)
|
True, MUTED)
|
||||||
self.screen.blit(sub, (panel.x + 150, y + 22))
|
self.screen.blit(sub, (panel.x + 150, y + 22))
|
||||||
if pt in ALL_TYPES:
|
if pt in ALL_TYPES:
|
||||||
@@ -1152,26 +1171,54 @@ class Menu:
|
|||||||
close = self.tiny.render("Click anywhere to close", True, MUTED)
|
close = self.tiny.render("Click anywhere to close", True, MUTED)
|
||||||
self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 24)))
|
self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 24)))
|
||||||
|
|
||||||
def _draw_convert_pack(self):
|
def _draw_shop(self):
|
||||||
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
|
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
|
||||||
overlay.fill((0, 0, 0, 160))
|
overlay.fill((0, 0, 0, 160))
|
||||||
self.screen.blit(overlay, (0, 0))
|
self.screen.blit(overlay, (0, 0))
|
||||||
w, h = 380, 210
|
w, h = 640, 400
|
||||||
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
|
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, PANEL, panel, border_radius=12)
|
||||||
pygame.draw.rect(self.screen, (60, 70, 60), panel, 2, border_radius=12)
|
pygame.draw.rect(self.screen, ACCENT, panel, 2, border_radius=12)
|
||||||
title = self.bold.render("Convert 5 water → a pack of your choice", True, TEXT)
|
title = self.bold.render("Pack Shop", True, ACCENT)
|
||||||
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 28)))
|
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 26)))
|
||||||
|
coins = collection.available_coins()
|
||||||
|
bal = self.small.render(f"Coins: {coins}", 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 + 52))
|
||||||
|
self.screen.blit(bal, (bx + 20, panel.y + 52))
|
||||||
|
buyable = ("lesser", "general", "radiant", "nocturnal", "ice", "garden", "wildflower")
|
||||||
|
cols = 4
|
||||||
|
cw, chh, gap = 132, 118, 12
|
||||||
|
x0 = panel.x + 24
|
||||||
|
y0 = panel.y + 96
|
||||||
|
self.shop_btns = {}
|
||||||
mouse = self.mouse_pos
|
mouse = self.mouse_pos
|
||||||
for pt, rect in self.convert_pack_btns.items():
|
for i, pt in enumerate(buyable):
|
||||||
|
x = x0 + (i % cols) * (cw + gap)
|
||||||
|
y = y0 + (i // cols) * (chh + gap)
|
||||||
|
rect = pygame.Rect(x, y, cw, chh)
|
||||||
|
cost = collection.PACK_COSTS[pt]
|
||||||
hover = rect.collidepoint(mouse)
|
hover = rect.collidepoint(mouse)
|
||||||
bg = (90, 120, 90) if hover else BTN
|
afford = coins >= cost
|
||||||
pygame.draw.rect(self.screen, bg, rect, border_radius=6)
|
pygame.draw.rect(self.screen, (90, 120, 90) if (hover and afford) else (BTN if afford else (45, 50, 45)),
|
||||||
pygame.draw.rect(self.screen, (150, 190, 150), rect, 1, border_radius=6)
|
rect, border_radius=8)
|
||||||
tcol = TYPE_COLORS.get(pt, TEXT)
|
pygame.draw.rect(self.screen, (150, 190, 150) if afford else (70, 80, 70),
|
||||||
draw_type_icon(self.screen, pt, rect.center, self.tiny, tcol, 26, center=True)
|
rect, 1, border_radius=8)
|
||||||
hint = self.tiny.render("Click elsewhere to cancel", True, MUTED)
|
spr = self._seed_pack_sprite(pt, 48)
|
||||||
self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 18)))
|
if spr:
|
||||||
|
self.screen.blit(spr, spr.get_rect(center=(rect.centerx, rect.y + 30)))
|
||||||
|
nm = self.tiny.render(pt.capitalize(), True, TEXT if afford else MUTED)
|
||||||
|
self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 60)))
|
||||||
|
pr = self.tiny.render(f"{cost} coins", True, HINT if afford else MUTED)
|
||||||
|
self.screen.blit(pr, pr.get_rect(center=(rect.centerx, rect.y + 86)))
|
||||||
|
self.shop_btns[pt] = rect
|
||||||
|
note = self.tiny.render("Tower packs can't be bought.", True, MUTED)
|
||||||
|
self.screen.blit(note, note.get_rect(center=(panel.centerx, panel.bottom - 18)))
|
||||||
|
if self.shop_msg and pygame.time.get_ticks() < self.shop_msg_until:
|
||||||
|
mt = self.small.render(self.shop_msg, True, DANGER if "Not enough" in self.shop_msg else (120, 220, 120))
|
||||||
|
self.screen.blit(mt, mt.get_rect(center=(panel.centerx, panel.y + 356)))
|
||||||
|
|
||||||
def _draw_soil(self):
|
def _draw_soil(self):
|
||||||
self._draw_page("Soil", "Retrieve seeds from plants, plant them, and grow copies.")
|
self._draw_page("Soil", "Retrieve seeds from plants, plant them, and grow copies.")
|
||||||
@@ -1217,6 +1264,26 @@ class Menu:
|
|||||||
f"Buy Auto Farmer — {collection.AUTO_FARMER_COST} coins",
|
f"Buy Auto Farmer — {collection.AUTO_FARMER_COST} coins",
|
||||||
True, TEXT if can_afford else MUTED)
|
True, TEXT if can_afford else MUTED)
|
||||||
self.screen.blit(af_txt, af_txt.get_rect(center=self.auto_farmer_rect.center))
|
self.screen.blit(af_txt, af_txt.get_rect(center=self.auto_farmer_rect.center))
|
||||||
|
ap_owned = collection.auto_planter_owned()
|
||||||
|
ap_active = collection.auto_planter_active()
|
||||||
|
self.auto_planter_rect = pygame.Rect(WIN_W - 270, 180, 250, 28)
|
||||||
|
ap_hover = self.auto_planter_rect.collidepoint(self.mouse_pos)
|
||||||
|
pygame.draw.rect(self.screen,
|
||||||
|
(90, 120, 90) if (ap_active or ap_hover) else BTN,
|
||||||
|
self.auto_planter_rect, border_radius=6)
|
||||||
|
pygame.draw.rect(self.screen,
|
||||||
|
(150, 190, 150) if ap_active else (70, 80, 70),
|
||||||
|
self.auto_planter_rect, 1, border_radius=6)
|
||||||
|
if ap_owned:
|
||||||
|
ap_txt = self.tiny.render(
|
||||||
|
"Auto Planter: active" if ap_active else "Auto Planter: off",
|
||||||
|
True, ACCENT if ap_active else MUTED)
|
||||||
|
else:
|
||||||
|
can_afford = collection.available_coins() >= collection.AUTO_PLANTER_COST
|
||||||
|
ap_txt = self.tiny.render(
|
||||||
|
f"Buy Auto Planter — {collection.AUTO_PLANTER_COST} coins",
|
||||||
|
True, TEXT if can_afford else MUTED)
|
||||||
|
self.screen.blit(ap_txt, ap_txt.get_rect(center=self.auto_planter_rect.center))
|
||||||
if self.soil_notice and pygame.time.get_ticks() < self.soil_notice_until:
|
if self.soil_notice and pygame.time.get_ticks() < self.soil_notice_until:
|
||||||
nt = self.bold.render(self.soil_notice, True, (235, 130, 130))
|
nt = self.bold.render(self.soil_notice, True, (235, 130, 130))
|
||||||
self.screen.blit(nt, nt.get_rect(center=(WIN_W // 2, 148)))
|
self.screen.blit(nt, nt.get_rect(center=(WIN_W // 2, 148)))
|
||||||
@@ -1492,14 +1559,6 @@ class Menu:
|
|||||||
self._draw_grid(owned, reveal_all=True, level_mode=self.water_mode, data=data)
|
self._draw_grid(owned, reveal_all=True, level_mode=self.water_mode, data=data)
|
||||||
if self.water_mode:
|
if self.water_mode:
|
||||||
water = collection.available_water()
|
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, 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)
|
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)
|
et = self.tiny.render("Exit water mode", True, TEXT)
|
||||||
@@ -1574,10 +1633,12 @@ class Menu:
|
|||||||
bcol = DANGER if self.runes_deconstruct_msg else ACCENT
|
bcol = DANGER if self.runes_deconstruct_msg else ACCENT
|
||||||
if self.runes_target is None:
|
if self.runes_target is None:
|
||||||
btxt = self.runes_deconstruct_msg or \
|
btxt = self.runes_deconstruct_msg or \
|
||||||
"Deconstruct — pick a rune to improve, then sacrifice 10 runes"
|
f"Deconstruct — pick a rune to improve, then sacrifice " \
|
||||||
|
f"{runes.RUNE_SACRIFICE_COST} runes"
|
||||||
else:
|
else:
|
||||||
btxt = self.runes_deconstruct_msg or \
|
btxt = self.runes_deconstruct_msg or \
|
||||||
f"Pick 10 runes to sacrifice ({len(self.runes_sacrifice)}/10) — " \
|
f"Pick {runes.RUNE_SACRIFICE_COST} runes to sacrifice " \
|
||||||
|
f"({len(self.runes_sacrifice)}/{runes.RUNE_SACRIFICE_COST}) — " \
|
||||||
"click the target again to change it"
|
"click the target again to change it"
|
||||||
b = self.tiny.render(btxt, True, bcol)
|
b = self.tiny.render(btxt, True, bcol)
|
||||||
self.screen.blit(b, b.get_rect(center=(WIN_W // 2, 124)))
|
self.screen.blit(b, b.get_rect(center=(WIN_W // 2, 124)))
|
||||||
@@ -1609,7 +1670,7 @@ class Menu:
|
|||||||
if ic:
|
if ic:
|
||||||
self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 30)))
|
self.screen.blit(ic, ic.get_rect(center=(rect.centerx, rect.y + 30)))
|
||||||
owner = collection.rune_location(rune["id"])
|
owner = collection.rune_location(rune["id"])
|
||||||
max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX
|
max_pct = runes.max_pct(rune["color"])
|
||||||
is_max = rune.get("pct", 0) >= max_pct
|
is_max = rune.get("pct", 0) >= max_pct
|
||||||
is_target = self.runes_deconstruct and self.runes_target \
|
is_target = self.runes_deconstruct and self.runes_target \
|
||||||
and rune["id"] == self.runes_target["id"]
|
and rune["id"] == self.runes_target["id"]
|
||||||
@@ -1841,7 +1902,7 @@ class Menu:
|
|||||||
if not rune:
|
if not rune:
|
||||||
return
|
return
|
||||||
if self.runes_target is None:
|
if self.runes_target is None:
|
||||||
max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX
|
max_pct = runes.max_pct(rune["color"])
|
||||||
if rune.get("pct", 0) >= max_pct:
|
if rune.get("pct", 0) >= max_pct:
|
||||||
self.runes_deconstruct_msg = f"{rune['name']} is already at max roll"
|
self.runes_deconstruct_msg = f"{rune['name']} is already at max roll"
|
||||||
return
|
return
|
||||||
@@ -1857,13 +1918,14 @@ class Menu:
|
|||||||
return
|
return
|
||||||
if rune_id in self.runes_sacrifice:
|
if rune_id in self.runes_sacrifice:
|
||||||
self.runes_sacrifice.remove(rune_id)
|
self.runes_sacrifice.remove(rune_id)
|
||||||
elif len(self.runes_sacrifice) >= 10:
|
elif len(self.runes_sacrifice) >= runes.RUNE_SACRIFICE_COST:
|
||||||
self.runes_deconstruct_msg = "Already picked 10 runes"
|
self.runes_deconstruct_msg = \
|
||||||
|
f"Already picked {runes.RUNE_SACRIFICE_COST} runes"
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
self.runes_sacrifice.append(rune_id)
|
self.runes_sacrifice.append(rune_id)
|
||||||
self.runes_deconstruct_msg = ""
|
self.runes_deconstruct_msg = ""
|
||||||
if len(self.runes_sacrifice) == 10:
|
if len(self.runes_sacrifice) == runes.RUNE_SACRIFICE_COST:
|
||||||
self._open_runes_confirm()
|
self._open_runes_confirm()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1884,7 +1946,7 @@ class Menu:
|
|||||||
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
|
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
|
||||||
overlay.fill((0, 0, 0, 160))
|
overlay.fill((0, 0, 0, 160))
|
||||||
self.screen.blit(overlay, (0, 0))
|
self.screen.blit(overlay, (0, 0))
|
||||||
w, h = 560, 400
|
w, h = 560, 450
|
||||||
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
|
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, PANEL, panel, border_radius=12)
|
||||||
pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12)
|
pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12)
|
||||||
@@ -1943,11 +2005,12 @@ class Menu:
|
|||||||
pygame.draw.rect(self.screen, DANGER, panel, 2, border_radius=12)
|
pygame.draw.rect(self.screen, DANGER, panel, 2, border_radius=12)
|
||||||
rune = self.runes_confirm["rune"]
|
rune = self.runes_confirm["rune"]
|
||||||
name = rune["name"]
|
name = rune["name"]
|
||||||
max_pct = runes.BROWN_PCT_MAX if rune["color"] == "brown" else runes.PCT_MAX
|
max_pct = runes.max_pct(rune["color"])
|
||||||
current = rune.get("pct", 0)
|
current = rune.get("pct", 0)
|
||||||
title = self.bold.render("Reroll rune?", True, TEXT)
|
title = self.bold.render("Reroll rune?", True, TEXT)
|
||||||
msg = self.small.render(
|
msg = self.small.render(
|
||||||
f"Sacrifice 10 runes to improve {name} to {current + 1}–{max_pct}%?",
|
f"Sacrifice {runes.RUNE_SACRIFICE_COST} runes to improve {name} to "
|
||||||
|
f"{current + 1}–{max_pct}%?",
|
||||||
True, TEXT)
|
True, TEXT)
|
||||||
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 34)))
|
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)))
|
self.screen.blit(msg, msg.get_rect(center=(panel.centerx, panel.y + 60)))
|
||||||
@@ -1979,14 +2042,73 @@ class Menu:
|
|||||||
rect, border_radius=6)
|
rect, border_radius=6)
|
||||||
pygame.draw.rect(self.screen, (150, 190, 150) if active else (70, 80, 70),
|
pygame.draw.rect(self.screen, (150, 190, 150) if active else (70, 80, 70),
|
||||||
rect, 1, border_radius=6)
|
rect, 1, border_radius=6)
|
||||||
t = self.tiny.render("Plants" if key == "plants" else "Runes", True, TEXT)
|
t = self.tiny.render(
|
||||||
|
"Plants" if key == "plants" else "Bosses" if key == "bosses" else "Runes",
|
||||||
|
True, TEXT)
|
||||||
self.screen.blit(t, t.get_rect(center=rect.center))
|
self.screen.blit(t, t.get_rect(center=rect.center))
|
||||||
if self.collection_tab == "runes":
|
if self.collection_tab == "runes":
|
||||||
self._draw_collection_runes()
|
self._draw_collection_runes()
|
||||||
|
elif self.collection_tab == "bosses":
|
||||||
|
self._draw_collection_bosses()
|
||||||
else:
|
else:
|
||||||
self._draw_grid(self.names, reveal_all=False, show_evo=False,
|
self._draw_grid(self.names, reveal_all=False, show_evo=False,
|
||||||
reveal_owned=True, y0=144)
|
reveal_owned=True, y0=144)
|
||||||
|
|
||||||
|
def _draw_collection_bosses(self):
|
||||||
|
mouse = self.mouse_pos
|
||||||
|
fought = collection.bosses_fought()
|
||||||
|
cw, ch, gap = 230, 260, 20
|
||||||
|
cols = 3
|
||||||
|
total = cols * cw + (cols - 1) * gap
|
||||||
|
x0 = (WIN_W - total) // 2
|
||||||
|
y0 = 150
|
||||||
|
self.boss_cards = []
|
||||||
|
for i, (name, spec) in enumerate(bosses.BOSS_SPECS.items()):
|
||||||
|
x = x0 + (i % cols) * (cw + gap)
|
||||||
|
y = y0 + (i // cols) * (ch + gap)
|
||||||
|
rect = pygame.Rect(x, y, cw, ch)
|
||||||
|
self.boss_cards.append((name, rect))
|
||||||
|
known = name in fought
|
||||||
|
pygame.draw.rect(self.screen, PANEL, rect, border_radius=8)
|
||||||
|
pygame.draw.rect(self.screen, (150, 120, 80) if known else (60, 70, 60),
|
||||||
|
rect, 1, border_radius=8)
|
||||||
|
spr = self._boss_sprite(name)
|
||||||
|
sprite_rect = pygame.Rect(rect.centerx - 60, rect.y + 12, 120, 120)
|
||||||
|
if spr:
|
||||||
|
ic = fit_surface(spr, 120)
|
||||||
|
if known:
|
||||||
|
self.screen.blit(ic, ic.get_rect(center=sprite_rect.center))
|
||||||
|
else:
|
||||||
|
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)
|
||||||
|
nm = self.small.render(name if known else "???", True, TEXT if known else MUTED)
|
||||||
|
self.screen.blit(nm, nm.get_rect(center=(rect.centerx, rect.y + 150)))
|
||||||
|
if known:
|
||||||
|
atk, spd, dfn, hp = spec["stats"]
|
||||||
|
st = self.tiny.render(f"Atk {atk} Spd {spd} Def {dfn} HP {int(hp * HP_MULT)}",
|
||||||
|
True, MUTED)
|
||||||
|
self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 176)))
|
||||||
|
lines = wrap_text(spec["desc"], self.tiny, cw - 24)
|
||||||
|
ty = rect.y + 198
|
||||||
|
for ln in lines[:3]:
|
||||||
|
self.screen.blit(self.tiny.render(ln, True, (170, 175, 170)),
|
||||||
|
(rect.x + 12, ty))
|
||||||
|
ty += 16
|
||||||
|
drops = "Drops: " + ", ".join(spec["drop_runes"])
|
||||||
|
dr = self.tiny.render(drops, True, (235, 200, 120))
|
||||||
|
self.screen.blit(dr, dr.get_rect(center=(rect.centerx, rect.bottom - 20)))
|
||||||
|
else:
|
||||||
|
t = self.tiny.render("Not yet fought", True, (110, 115, 110))
|
||||||
|
self.screen.blit(t, t.get_rect(center=(rect.centerx, rect.y + 180)))
|
||||||
|
|
||||||
|
def _boss_sprite(self, name):
|
||||||
|
key = f"boss_{name}"
|
||||||
|
if key not in self.seed_pack_sprites:
|
||||||
|
self.seed_pack_sprites[key] = load_sprite(bosses.boss_sprite(name))
|
||||||
|
return self.seed_pack_sprites[key]
|
||||||
|
|
||||||
def _draw_collection_runes(self):
|
def _draw_collection_runes(self):
|
||||||
mouse = self.mouse_pos
|
mouse = self.mouse_pos
|
||||||
self._draw_coll_rune_filters()
|
self._draw_coll_rune_filters()
|
||||||
@@ -2108,8 +2230,7 @@ class Menu:
|
|||||||
self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 78)))
|
self.screen.blit(ic, ic.get_rect(center=(panel.centerx, panel.y + 78)))
|
||||||
eff = self.small.render(runes.summary(color), True, TEXT)
|
eff = self.small.render(runes.summary(color), True, TEXT)
|
||||||
self.screen.blit(eff, eff.get_rect(center=(panel.centerx, panel.y + 122)))
|
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),
|
dmg = self.tiny.render(runes.effect_text(color, runes.max_pct(color)), True, MUTED)
|
||||||
True, MUTED)
|
|
||||||
self.screen.blit(dmg, dmg.get_rect(center=(panel.centerx, panel.y + 146)))
|
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)}",
|
t1 = self.small.render(f"Tier {runes.tier(color)} · Max roll {runes.roll_range(color)}",
|
||||||
True, HINT)
|
True, HINT)
|
||||||
@@ -2124,8 +2245,7 @@ class Menu:
|
|||||||
if self.coll_rune_sort_best:
|
if self.coll_rune_sort_best:
|
||||||
inv = collection.rune_inventory()
|
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}
|
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),
|
colors.sort(key=lambda c: best[c] / runes.max_pct(c), reverse=True)
|
||||||
reverse=True)
|
|
||||||
return colors
|
return colors
|
||||||
|
|
||||||
def _draw_detail(self):
|
def _draw_detail(self):
|
||||||
@@ -2390,8 +2510,11 @@ class Menu:
|
|||||||
sprite_rect = pygame.Rect(rect.centerx - 44, rect.y + 12, 88, 88)
|
sprite_rect = pygame.Rect(rect.centerx - 44, rect.y + 12, 88, 88)
|
||||||
if spr:
|
if spr:
|
||||||
ic = fit_surface(spr, 88)
|
ic = fit_surface(spr, 88)
|
||||||
|
if found:
|
||||||
self.screen.blit(ic, ic.get_rect(center=sprite_rect.center))
|
self.screen.blit(ic, ic.get_rect(center=sprite_rect.center))
|
||||||
if spr and not found:
|
else:
|
||||||
|
# Dim only the sprite (matches the runes tab): blitting the
|
||||||
|
# full icon first then the dim copy would cancel out.
|
||||||
dim = ic.copy()
|
dim = ic.copy()
|
||||||
dim.set_alpha(70)
|
dim.set_alpha(70)
|
||||||
self.screen.blit(dim, dim.get_rect(center=sprite_rect.center))
|
self.screen.blit(dim, dim.get_rect(center=sprite_rect.center))
|
||||||
|
|||||||
+173
-12
@@ -2,10 +2,11 @@ import pygame
|
|||||||
|
|
||||||
from assets import icon as icon_path
|
from assets import icon as icon_path
|
||||||
from assets import load_sprite
|
from assets import load_sprite
|
||||||
from engine import collection
|
from engine import bosses, collection
|
||||||
from engine.growth import compute_stats
|
from engine.growth import compute_stats
|
||||||
from engine.plants import HP_MULT, PASSIVES, POOL, RARITY_COLORS, RARITY_LABELS, plant_rarity
|
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.roster import floor_enemy_level, roll_enemies
|
||||||
|
from engine.types import ALL_TYPES
|
||||||
from engine.unit import ROLE_ATTACKER, ROLE_ICONS, ROLE_NAMES, ROLE_SUPPORT, ROLE_TANK
|
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.menu import _bracket_text, _draw_evo_badge
|
||||||
from ui.runes import draw_rune_chips
|
from ui.runes import draw_rune_chips
|
||||||
@@ -17,8 +18,8 @@ TYPE_COLORS = {
|
|||||||
"radiant": (245, 200, 90),
|
"radiant": (245, 200, 90),
|
||||||
"nocturnal": (150, 120, 220),
|
"nocturnal": (150, 120, 220),
|
||||||
"ice": (130, 200, 235),
|
"ice": (130, 200, 235),
|
||||||
"garden": (120, 200, 120),
|
"garden": (235, 130, 170),
|
||||||
"wildflower": (235, 130, 170),
|
"wildflower": (120, 200, 120),
|
||||||
}
|
}
|
||||||
|
|
||||||
BG = (24, 30, 24)
|
BG = (24, 30, 24)
|
||||||
@@ -44,7 +45,7 @@ class TeamSelect:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption(header or "Plants — Tower")
|
pygame.display.set_caption(header or "Flora — Tower")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 30)
|
self.font = pygame.font.SysFont("segoeui", 30)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
@@ -68,6 +69,8 @@ class TeamSelect:
|
|||||||
self.pool_names = sorted((n for n in self.names if n in collection.owned()),
|
self.pool_names = sorted((n for n in self.names if n in collection.owned()),
|
||||||
key=lambda n: (n not in self.favs, n))
|
key=lambda n: (n not in self.favs, n))
|
||||||
self.sprites = {n: self._load(n) for n in self.names}
|
self.sprites = {n: self._load(n) for n in self.names}
|
||||||
|
for bname in bosses.BOSS_SPECS:
|
||||||
|
self.sprites[bname] = self._load(bosses.boss_sprite(bname))
|
||||||
self.rune_icons = {}
|
self.rune_icons = {}
|
||||||
self.role_icons = {}
|
self.role_icons = {}
|
||||||
self.allies = []
|
self.allies = []
|
||||||
@@ -82,6 +85,12 @@ class TeamSelect:
|
|||||||
self._layout_enemy_cards()
|
self._layout_enemy_cards()
|
||||||
self._layout_enemy_strip()
|
self._layout_enemy_strip()
|
||||||
self._layout_slots()
|
self._layout_slots()
|
||||||
|
self.pool_filter = None # optional elemental type filter for the ally pool
|
||||||
|
self.pool_exclude_type = None # right-click: show everything BUT this type
|
||||||
|
self.pool_type_rects = []
|
||||||
|
self.pool_role = None # optional role filter for the ally pool
|
||||||
|
self.pool_exclude_role = None # right-click: show everything BUT this role
|
||||||
|
self.pool_role_rects = []
|
||||||
self._layout_pool()
|
self._layout_pool()
|
||||||
self.roll_rect = pygame.Rect(WIN_W // 2 - 260, WIN_H - 60, 170, 40)
|
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.next_rect = pygame.Rect(WIN_W // 2 + 90, WIN_H - 60, 180, 40)
|
||||||
@@ -157,13 +166,28 @@ class TeamSelect:
|
|||||||
sl_h, gap = SLOT_H, SLOT_GAP
|
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)]
|
self.ally_rects = [pygame.Rect(24, 170 + i * (sl_h + gap), SLOT_W, sl_h) for i in range(n)]
|
||||||
|
|
||||||
|
def _display_pool(self):
|
||||||
|
"""The ally pool, filtered by element type and/or role when active.
|
||||||
|
Left-click filters include a type/role; right-click excludes it."""
|
||||||
|
result = self.pool_names
|
||||||
|
if self.pool_filter is not None:
|
||||||
|
result = [n for n in result if POOL[n]["type"] == self.pool_filter]
|
||||||
|
elif self.pool_exclude_type is not None:
|
||||||
|
result = [n for n in result if POOL[n]["type"] != self.pool_exclude_type]
|
||||||
|
if self.pool_role is not None:
|
||||||
|
result = [n for n in result if POOL[n]["role"] == self.pool_role]
|
||||||
|
elif self.pool_exclude_role is not None:
|
||||||
|
result = [n for n in result if POOL[n]["role"] != self.pool_exclude_role]
|
||||||
|
return result
|
||||||
|
|
||||||
def _layout_pool(self):
|
def _layout_pool(self):
|
||||||
self.pool_rects = {}
|
self.pool_rects = {}
|
||||||
cols = min(4, max(1, len(self.pool_names)))
|
shown = self._display_pool()
|
||||||
|
cols = min(4, max(1, len(shown)))
|
||||||
cw, ch, gap = 170, 64, 10
|
cw, ch, gap = 170, 64, 10
|
||||||
x0 = 290
|
x0 = 290
|
||||||
y0 = 170
|
y0 = 170
|
||||||
for i, name in enumerate(self.pool_names):
|
for i, name in enumerate(shown):
|
||||||
x = x0 + (i % cols) * (cw + gap)
|
x = x0 + (i % cols) * (cw + gap)
|
||||||
y = y0 + (i // cols) * (ch + gap)
|
y = y0 + (i // cols) * (ch + gap)
|
||||||
self.pool_rects[name] = pygame.Rect(x, y, cw, ch)
|
self.pool_rects[name] = pygame.Rect(x, y, cw, ch)
|
||||||
@@ -171,7 +195,7 @@ class TeamSelect:
|
|||||||
self.pool_area = pygame.Rect(x0 - 4, y0 - 4,
|
self.pool_area = pygame.Rect(x0 - 4, y0 - 4,
|
||||||
cols * cw + (cols - 1) * gap + 8,
|
cols * cw + (cols - 1) * gap + 8,
|
||||||
WIN_H - y0 - 100)
|
WIN_H - y0 - 100)
|
||||||
rows = (len(self.pool_names) + cols - 1) // cols
|
rows = (len(shown) + cols - 1) // cols
|
||||||
self.pool_scroll_max = max(0, rows * (ch + gap) - gap - self.pool_area.h)
|
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 = getattr(self, "pool_scroll", 0)
|
||||||
self.pool_scroll = max(0, min(self.pool_scroll, self.pool_scroll_max))
|
self.pool_scroll = max(0, min(self.pool_scroll, self.pool_scroll_max))
|
||||||
@@ -190,6 +214,10 @@ class TeamSelect:
|
|||||||
if not self.detail_open:
|
if not self.detail_open:
|
||||||
self.pool_scroll = max(0, min(self.pool_scroll_max,
|
self.pool_scroll = max(0, min(self.pool_scroll_max,
|
||||||
self.pool_scroll - event.y * 40))
|
self.pool_scroll - event.y * 40))
|
||||||
|
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
|
||||||
|
pos = to_logical(event.pos, self.scale)
|
||||||
|
if not self.detail_open:
|
||||||
|
self._click_right(pos)
|
||||||
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
||||||
pos = to_logical(event.pos, self.scale)
|
pos = to_logical(event.pos, self.scale)
|
||||||
if self.detail_open:
|
if self.detail_open:
|
||||||
@@ -269,12 +297,55 @@ class TeamSelect:
|
|||||||
if rect.collidepoint(pos) and i < len(self.enemies):
|
if rect.collidepoint(pos) and i < len(self.enemies):
|
||||||
self._open_detail(self.enemies[i], self.enemies)
|
self._open_detail(self.enemies[i], self.enemies)
|
||||||
return None
|
return None
|
||||||
|
for ptype, rect in self.pool_type_rects:
|
||||||
|
if rect.collidepoint(pos):
|
||||||
|
self.pool_filter = ptype # None = "All", else the chosen type
|
||||||
|
self.pool_exclude_type = None
|
||||||
|
self.pool_scroll = 0
|
||||||
|
self._layout_pool()
|
||||||
|
return None
|
||||||
|
for role, rect in self.pool_role_rects:
|
||||||
|
if rect.collidepoint(pos):
|
||||||
|
self.pool_role = role # None = "All", else the chosen role
|
||||||
|
self.pool_exclude_role = None
|
||||||
|
self.pool_scroll = 0
|
||||||
|
self._layout_pool()
|
||||||
|
return None
|
||||||
for name, rect in self.pool_rects.items():
|
for name, rect in self.pool_rects.items():
|
||||||
if rect.move(0, -self.pool_scroll).collidepoint(pos):
|
if rect.move(0, -self.pool_scroll).collidepoint(pos):
|
||||||
if name in self.allies:
|
if name in self.allies:
|
||||||
self.allies.remove(name) # click a selected plant to unselect it
|
self.allies.remove(name) # click a selected plant to unselect it
|
||||||
else:
|
else:
|
||||||
self._open_detail(name, self.pool_names)
|
self._open_detail(name, self._display_pool())
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _click_right(self, pos):
|
||||||
|
"""Right-click on a filter icon EXCLUDES that type/role (everything but it)."""
|
||||||
|
if self.type_info is not None:
|
||||||
|
self.type_info = None
|
||||||
|
return None
|
||||||
|
for ptype, rect in self.pool_type_rects:
|
||||||
|
if rect.collidepoint(pos):
|
||||||
|
if ptype is None:
|
||||||
|
self.pool_filter = None
|
||||||
|
self.pool_exclude_type = None
|
||||||
|
else:
|
||||||
|
self.pool_exclude_type = ptype
|
||||||
|
self.pool_filter = None
|
||||||
|
self.pool_scroll = 0
|
||||||
|
self._layout_pool()
|
||||||
|
return None
|
||||||
|
for role, rect in self.pool_role_rects:
|
||||||
|
if rect.collidepoint(pos):
|
||||||
|
if role is None:
|
||||||
|
self.pool_role = None
|
||||||
|
self.pool_exclude_role = None
|
||||||
|
else:
|
||||||
|
self.pool_exclude_role = role
|
||||||
|
self.pool_role = None
|
||||||
|
self.pool_scroll = 0
|
||||||
|
self._layout_pool()
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -334,6 +405,10 @@ class TeamSelect:
|
|||||||
return tag
|
return tag
|
||||||
|
|
||||||
def _draw_role_type_tag(self, name, pos, font, center=False, ptype=None):
|
def _draw_role_type_tag(self, name, pos, font, center=False, ptype=None):
|
||||||
|
if bosses.is_boss(name):
|
||||||
|
role_name = "Boss"
|
||||||
|
rs = font.render(role_name, True, (235, 200, 120))
|
||||||
|
else:
|
||||||
role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"])
|
role_name = ROLE_NAMES.get(POOL[name]["role"], POOL[name]["role"])
|
||||||
rs = font.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT))
|
rs = font.render(role_name, True, RARITY_COLORS.get(plant_rarity(name), TEXT))
|
||||||
sep = font.render(" · ", True, MUTED)
|
sep = font.render(" · ", True, MUTED)
|
||||||
@@ -381,7 +456,7 @@ class TeamSelect:
|
|||||||
tag = self._draw_role_type_tag(name, (rect.centerx, rect.y + 178), self.small, center=True,
|
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)
|
ptype=self.enemy_types[i] if i < len(self.enemy_types) else None)
|
||||||
self._draw_enemy_level(tag, i)
|
self._draw_enemy_level(tag, i)
|
||||||
stats = POOL[name]["stats"]
|
stats = bosses.boss_stats(name) if bosses.is_boss(name) else POOL[name]["stats"]
|
||||||
st = self.tiny.render(f"Atk {stats[0]} Spd {stats[1]} Def {stats[2]} HP {int(stats[3] * HP_MULT)}",
|
st = self.tiny.render(f"Atk {stats[0]} Spd {stats[1]} Def {stats[2]} HP {int(stats[3] * HP_MULT)}",
|
||||||
True, MUTED)
|
True, MUTED)
|
||||||
self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 216)))
|
self.screen.blit(st, st.get_rect(center=(rect.centerx, rect.y + 216)))
|
||||||
@@ -414,6 +489,7 @@ class TeamSelect:
|
|||||||
self._draw_enemy_strip()
|
self._draw_enemy_strip()
|
||||||
if self.show_slots:
|
if self.show_slots:
|
||||||
self._draw_slots(self.ally_rects, self.allies, "Your Allies")
|
self._draw_slots(self.ally_rects, self.allies, "Your Allies")
|
||||||
|
self._draw_pool_filters()
|
||||||
self._draw_pool()
|
self._draw_pool()
|
||||||
self._draw_tower_enemies()
|
self._draw_tower_enemies()
|
||||||
for i, rect in enumerate(self.preset_rects):
|
for i, rect in enumerate(self.preset_rects):
|
||||||
@@ -495,6 +571,69 @@ class TeamSelect:
|
|||||||
draw_rune_chips(self.screen, self._rune_icon, rune_colors,
|
draw_rune_chips(self.screen, self._rune_icon, rune_colors,
|
||||||
rect.right - 40 - chip_w, rect.y + 32)
|
rect.right - 40 - chip_w, rect.y + 32)
|
||||||
|
|
||||||
|
def _draw_pool_filters(self):
|
||||||
|
"""Type + role filter row above the ally pool: 'All' plus one icon per
|
||||||
|
element and per role. Left-click highlights green (include only), and
|
||||||
|
right-click highlights red (exclude that type/role)."""
|
||||||
|
self.pool_type_rects = []
|
||||||
|
self.pool_role_rects = []
|
||||||
|
y, h = 142, 24
|
||||||
|
INC = (120, 220, 120)
|
||||||
|
EXC = (235, 110, 110)
|
||||||
|
tl = self.tiny.render("Type:", True, MUTED)
|
||||||
|
self.screen.blit(tl, (290, y + 5))
|
||||||
|
x = 330
|
||||||
|
all_rect = pygame.Rect(x, y, 36, h)
|
||||||
|
active = self.pool_filter is None and self.pool_exclude_type 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.pool_type_rects.append((None, all_rect))
|
||||||
|
x += 42
|
||||||
|
for ptype in ALL_TYPES:
|
||||||
|
rect = pygame.Rect(x, y, 24, h)
|
||||||
|
inc = self.pool_filter == ptype
|
||||||
|
exc = self.pool_exclude_type == ptype
|
||||||
|
pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4)
|
||||||
|
border = INC if inc else EXC if exc else (70, 80, 70)
|
||||||
|
pygame.draw.rect(self.screen, border, rect, 2 if (inc or exc) else 1,
|
||||||
|
border_radius=4)
|
||||||
|
tic = load_type_icon(ptype, 22)
|
||||||
|
if tic:
|
||||||
|
self.screen.blit(tic, tic.get_rect(center=rect.center))
|
||||||
|
self.pool_type_rects.append((ptype, rect))
|
||||||
|
x += 28
|
||||||
|
x += 36 # a little space between the type and role filters
|
||||||
|
rl = self.tiny.render("Role:", True, MUTED)
|
||||||
|
self.screen.blit(rl, (x, y + 5))
|
||||||
|
x += 42
|
||||||
|
all_role = pygame.Rect(x, y, 36, h)
|
||||||
|
active = self.pool_role is None and self.pool_exclude_role is None
|
||||||
|
pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45),
|
||||||
|
all_role, border_radius=4)
|
||||||
|
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
|
||||||
|
all_role, 1, border_radius=4)
|
||||||
|
t = self.tiny.render("All", True, TEXT if active else MUTED)
|
||||||
|
self.screen.blit(t, t.get_rect(center=all_role.center))
|
||||||
|
self.pool_role_rects.append((None, all_role))
|
||||||
|
x += 42
|
||||||
|
for role in (ROLE_ATTACKER, ROLE_SUPPORT, ROLE_TANK):
|
||||||
|
rect = pygame.Rect(x, y, 24, h)
|
||||||
|
inc = self.pool_role == role
|
||||||
|
exc = self.pool_exclude_role == role
|
||||||
|
pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4)
|
||||||
|
border = INC if inc else EXC if exc else (70, 80, 70)
|
||||||
|
pygame.draw.rect(self.screen, border, rect, 2 if (inc or exc) else 1,
|
||||||
|
border_radius=4)
|
||||||
|
ric = self._role_icon(role, 22)
|
||||||
|
if ric:
|
||||||
|
self.screen.blit(ric, ric.get_rect(center=rect.center))
|
||||||
|
self.pool_role_rects.append((role, rect))
|
||||||
|
x += 28
|
||||||
|
|
||||||
def _draw_pool(self):
|
def _draw_pool(self):
|
||||||
old_clip = self.screen.get_clip()
|
old_clip = self.screen.get_clip()
|
||||||
self.screen.set_clip(self.pool_area)
|
self.screen.set_clip(self.pool_area)
|
||||||
@@ -574,7 +713,11 @@ class TeamSelect:
|
|||||||
name = self.detail_name
|
name = self.detail_name
|
||||||
if name is None:
|
if name is None:
|
||||||
return
|
return
|
||||||
spec = POOL[name]
|
is_boss_unit = bosses.is_boss(name)
|
||||||
|
spec = None if is_boss_unit else POOL[name]
|
||||||
|
boss_stats_t = bosses.boss_stats(name) if is_boss_unit else None
|
||||||
|
boss_skills = bosses.make_boss(name, 20).skills if is_boss_unit else None
|
||||||
|
boss_desc = bosses.BOSS_SPECS[name]["desc"] if is_boss_unit else None
|
||||||
shade = pygame.Surface((WIN_W, WIN_H))
|
shade = pygame.Surface((WIN_W, WIN_H))
|
||||||
shade.fill((0, 0, 0))
|
shade.fill((0, 0, 0))
|
||||||
shade.set_alpha(120)
|
shade.set_alpha(120)
|
||||||
@@ -592,6 +735,9 @@ class TeamSelect:
|
|||||||
self.screen.blit(ic, ic.get_rect(center=(self.detail_rect.x + 84, self.detail_rect.y + 100)))
|
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)
|
t = self.bold.render(name, True, TEXT)
|
||||||
self.screen.blit(t, (self.detail_rect.x + 160, self.detail_rect.y + 34))
|
self.screen.blit(t, (self.detail_rect.x + 160, self.detail_rect.y + 34))
|
||||||
|
if is_boss_unit:
|
||||||
|
rt = self.tiny.render("Boss", True, (235, 200, 120))
|
||||||
|
else:
|
||||||
rar = plant_rarity(name)
|
rar = plant_rarity(name)
|
||||||
rt = self.tiny.render(RARITY_LABELS.get(rar, rar), True, RARITY_COLORS.get(rar, TEXT))
|
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))
|
self.screen.blit(rt, (self.detail_rect.x + 160 + t.get_width() + 8, self.detail_rect.y + 38))
|
||||||
@@ -604,19 +750,34 @@ class TeamSelect:
|
|||||||
name, (self.detail_rect.x + 160, self.detail_rect.y + 62), self.small,
|
name, (self.detail_rect.x + 160, self.detail_rect.y + 62), self.small,
|
||||||
ptype=enemy_type)
|
ptype=enemy_type)
|
||||||
|
|
||||||
if not self.detail_is_enemy and name in collection.owned():
|
if not self.detail_is_enemy and not is_boss_unit and name in collection.owned():
|
||||||
prog = collection.get_progress(name)
|
prog = collection.get_progress(name)
|
||||||
lv = self.tiny.render(f"Level {prog['level']} · Stage {prog['stage']}", True, HINT)
|
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))
|
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"])
|
atk, spd, dfn, hp = compute_stats(spec["stats"], prog["level"], prog["stage"])
|
||||||
else:
|
else:
|
||||||
atk, spd, dfn, hp = spec["stats"]
|
atk, spd, dfn, hp = boss_stats_t if is_boss_unit else spec["stats"]
|
||||||
st = self.small.render(f"Atk {atk} Spd {spd}", True, TEXT)
|
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))
|
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)
|
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))
|
self.screen.blit(st, (self.detail_rect.x + 160, self.detail_rect.y + 174))
|
||||||
|
|
||||||
y = self.detail_rect.y + 210
|
y = self.detail_rect.y + 210
|
||||||
|
if is_boss_unit:
|
||||||
|
self.screen.blit(self.bold.render("Boss", True, HINT), (self.detail_rect.x + 24, y))
|
||||||
|
lines = wrap_text(" " + boss_desc, 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 boss_skills:
|
||||||
|
bracket = _bracket_text(sk)
|
||||||
|
label = f"{sk.name} [{bracket}]" if bracket else sk.name
|
||||||
|
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 + 20))
|
||||||
|
y += 42
|
||||||
|
return
|
||||||
p = spec.get("passive", "")
|
p = spec.get("passive", "")
|
||||||
if p:
|
if p:
|
||||||
pname, pdesc = PASSIVES.get(p, (p, ""))
|
pname, pdesc = PASSIVES.get(p, (p, ""))
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@ class PresetScreen:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption("Plants — Team Presets")
|
pygame.display.set_caption("Flora — Team Presets")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 30)
|
self.font = pygame.font.SysFont("segoeui", 30)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
@@ -67,7 +67,7 @@ class PresetScreen:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption("Plants — Team Presets")
|
pygame.display.set_caption("Flora — Team Presets")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 30)
|
self.font = pygame.font.SysFont("segoeui", 30)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ class RelicChoiceScreen:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption("Plants — Blessing")
|
pygame.display.set_caption("Flora — Blessing")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 36)
|
self.font = pygame.font.SysFont("segoeui", 36)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
|
|||||||
+2
-2
@@ -36,7 +36,7 @@ class RuneScreen:
|
|||||||
self.plant = plant
|
self.plant = plant
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption(f"Plants — Runes · {plant}")
|
pygame.display.set_caption(f"Flora — Runes · {plant}")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 30)
|
self.font = pygame.font.SysFont("segoeui", 30)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
@@ -377,7 +377,7 @@ class RuneScreen:
|
|||||||
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
|
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
|
||||||
overlay.fill((0, 0, 0, 160))
|
overlay.fill((0, 0, 0, 160))
|
||||||
self.screen.blit(overlay, (0, 0))
|
self.screen.blit(overlay, (0, 0))
|
||||||
w, h = 560, 400
|
w, h = 560, 450
|
||||||
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
|
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, PANEL, panel, border_radius=12)
|
||||||
pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12)
|
pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12)
|
||||||
|
|||||||
+3
-3
@@ -25,8 +25,8 @@ TYPE_COLORS = {
|
|||||||
"radiant": (245, 200, 90),
|
"radiant": (245, 200, 90),
|
||||||
"nocturnal": (150, 120, 220),
|
"nocturnal": (150, 120, 220),
|
||||||
"ice": (130, 200, 235),
|
"ice": (130, 200, 235),
|
||||||
"garden": (120, 200, 120),
|
"garden": (235, 130, 170),
|
||||||
"wildflower": (235, 130, 170),
|
"wildflower": (120, 200, 120),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class StarterScreen:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption("Plants — Choose your starting pinwheel")
|
pygame.display.set_caption("Flora — Choose your starting pinwheel")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 30)
|
self.font = pygame.font.SysFont("segoeui", 30)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@ class ConfirmScreen:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption("Plants — Tower")
|
pygame.display.set_caption("Flora — Tower")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 30)
|
self.font = pygame.font.SysFont("segoeui", 30)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
@@ -81,7 +81,7 @@ class TowerScreen:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||||
pygame.display.set_caption("Plants — Tower")
|
pygame.display.set_caption("Flora — Tower")
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
self.font = pygame.font.SysFont("segoeui", 36)
|
self.font = pygame.font.SysFont("segoeui", 36)
|
||||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@ TYPE_COLORS = {
|
|||||||
"radiant": (245, 200, 90),
|
"radiant": (245, 200, 90),
|
||||||
"nocturnal": (150, 120, 220),
|
"nocturnal": (150, 120, 220),
|
||||||
"ice": (130, 200, 235),
|
"ice": (130, 200, 235),
|
||||||
"garden": (120, 200, 120),
|
"garden": (235, 130, 170),
|
||||||
"wildflower": (235, 130, 170),
|
"wildflower": (120, 200, 120),
|
||||||
}
|
}
|
||||||
STRONG = (120, 210, 120)
|
STRONG = (120, 210, 120)
|
||||||
WEAK = (225, 130, 130)
|
WEAK = (225, 130, 130)
|
||||||
|
|||||||
Reference in New Issue
Block a user