Initial commit: Plants turn-based battle game

This commit is contained in:
2026-08-28 14:26:07 +02:00
commit 6840b715de
158 changed files with 13352 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
import random
# Rune kinds (gems). Each rune is an item equipped into a plant's slots.
RUNE_COLORS = ("sapphire", "ruby", "amethyst", "jade", "brown")
RUNE_NAMES = {
"sapphire": "Sapphire Rune",
"ruby": "Ruby Rune",
"amethyst": "Amethyst Rune",
"jade": "Jade Rune",
"brown": "Brown Rune",
}
# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown is Tier 2.
TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade")
TIER2_COLORS = ("brown",)
RUNE_TIERS = {c: 1 for c in TIER1_COLORS}
RUNE_TIERS.update({c: 2 for c in TIER2_COLORS})
# Sprite file (without extension) per rune color.
RUNE_ICON_FILES = {
"sapphire": "sapphire_rune",
"ruby": "ruby_rune",
"jade": "jade_rune",
"amethyst": "amethyst_rune",
"brown": "rune_brown",
}
# Old color names kept in earlier saves, mapped to the current gem names.
RUNE_COLOR_ALIASES = {
"blue": "sapphire",
"red": "ruby",
"white": "amethyst",
"yellow": "jade",
}
def canonical_color(color: str) -> str:
"""Map an old rune color name (blue/red/white/yellow) to the current gem."""
return RUNE_COLOR_ALIASES.get(color, color)
# 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
# turns part of the damage this plant deals into a Wilt.
RUNE_EFFECTS = {
"ruby": "damage", # +damage dealt
"sapphire": "shield", # +shielding
"jade": "healing", # +healing received
"amethyst": "damage_taken", # -damage taken
"brown": "wilt", # damage dealt also Wils the target
}
PCT_MIN = 25
PCT_MAX = 35
BROWN_PCT_MIN = 5
BROWN_PCT_MAX = 15
# a plant can't stack more than this many runes of the same type
MAX_RUNES_PER_TYPE = 2
# coin costs to buy a random rune of a tier in the runes shop
T1_RUNE_COST = 100
T2_RUNE_COST = 150
# brown drops 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}
_EFFECT_TEXTS = {
"ruby": "+{pct}% damage dealt",
"sapphire": "+{pct}% shielding",
"jade": "+{pct}% healing",
"amethyst": "-{pct}% damage taken",
"brown": "deals {pct}% of damage as Wilt",
}
RUNE_SUMMARIES = {
"ruby": "Increases damage dealt",
"sapphire": "Increases shielding",
"jade": "Increases healing received",
"amethyst": "Reduces damage taken",
"brown": "Deals a portion of damage as Wilt",
}
MAX_RUNE_SLOTS = 3
# Drop chances (test build)
BATTLE_DROP_CHANCE = 0.15 # rare rune from a normal floor battle win
PACK_DROP_CHANCE = 0.05 # rare additional rune from a seed pack
def random_color() -> str:
"""Roll a rune color; brown is weighted rarer than the others."""
weights = [RUNE_WEIGHTS.get(c, 1.0) for c in RUNE_COLORS]
return random.choices(RUNE_COLORS, weights=weights, k=1)[0]
def roll_pct(color: str = None) -> int:
"""Roll a rune's stat percentage. Brown rolls 5-15; the rest 25-35."""
if color == "brown":
return random.randint(BROWN_PCT_MIN, BROWN_PCT_MAX)
return random.randint(PCT_MIN, PCT_MAX)
def name(color: str) -> str:
return RUNE_NAMES.get(color, "Rune")
def tier(color: str) -> int:
return RUNE_TIERS.get(color, 1)
def roll_ratio(rune) -> float:
"""How close a rune's roll is to its own maximum (0..1). Brown's 5-15 range
is compared to its 15 cap, so a maxed brown ranks equally with a maxed
yellow even though 15 < 35."""
max_pct = BROWN_PCT_MAX if rune.get("color") == "brown" else PCT_MAX
return rune.get("pct", 0) / max_pct
def effect_text(color: str, pct: int) -> str:
"""Human-readable effect for a rune of `color` with rolled stat `pct`."""
return _EFFECT_TEXTS.get(color, "").format(pct=pct)
_CARD_TEXTS = {
"ruby": "+{pct}% dmg",
"sapphire": "+{pct}% shield",
"jade": "+{pct}% heal",
"amethyst": "-{pct}% taken",
"brown": "Wilt {pct}%",
}
def card_text(color: str, pct: int) -> str:
"""Compact effect label that fits a small card (e.g. 'Wilt 12%')."""
return _CARD_TEXTS.get(color, "").format(pct=pct)
def effect_for(color: str) -> str:
return RUNE_EFFECTS.get(color, "")
_EFFECT_COLORS = {v: k for k, v in RUNE_EFFECTS.items()}
def color_for_effect(effect: str) -> str:
"""The rune colour whose effect is `effect` (for showing enemy runes)."""
return _EFFECT_COLORS.get(effect, "")
def summary(color: str) -> str:
return RUNE_SUMMARIES.get(color, "")
def roll_range(color: str) -> str:
"""The stat range a rune of `color` rolls between (brown is lower)."""
if color == "brown":
return f"{BROWN_PCT_MIN}–{BROWN_PCT_MAX}%"
return f"{PCT_MIN}–{PCT_MAX}%"
def slots_for_max_stage(max_stage: int) -> int:
"""Permanent rune slots unlocked by the highest evolution stage a plant has
ever reached (stage 1 -> 1 slot, stage 2 -> 2, stage 3 -> 3)."""
return min(MAX_RUNE_SLOTS, max(1, max_stage))
def enemy_rune_count(stage: int) -> int:
"""How many runes a tower enemy carries at `stage` (more as the tower ramps)."""
if stage < 3:
return 0
return min(3, 1 + (stage - 3) // 5)
def roll_enemy_runes(stage: int) -> list:
"""Roll the runes a single tower enemy carries at `stage`. Returns a list of
(effect_key, pct_fraction) pairs ready for `Unit.runes`. At most
MAX_RUNES_PER_TYPE runes of the same type are rolled (same rule as players)."""
count = enemy_rune_count(stage)
runes_list = []
counts = {}
while len(runes_list) < count:
color = random_color()
if counts.get(color, 0) >= MAX_RUNES_PER_TYPE:
continue # type already at the cap; roll another colour
counts[color] = counts.get(color, 0) + 1
runes_list.append((effect_for(color), roll_pct(color) / 100.0))
return runes_list
def per_rune_chance(source_chance: float) -> tuple:
"""Effective drop chance (low, high) for a specific rune colour from a drop
source, where brown's lower weight makes it the low end."""
weights = [RUNE_WEIGHTS.get(c, 1.0) for c in RUNE_COLORS]
total = sum(weights)
return source_chance * min(weights) / total, source_chance * max(weights) / total