Files
Flora/engine/runes.py
T
Lena feb4500c6c small update
boss prototype, auto planter, visual reworks, bug fixes
2026-08-29 10:45:34 +02:00

217 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", "pearl")
RUNE_NAMES = {
"sapphire": "Sapphire Rune",
"ruby": "Ruby Rune",
"amethyst": "Amethyst Rune",
"jade": "Jade Rune",
"brown": "Brown Rune",
"pearl": "Pearl Rune",
}
# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown & pearl Tier 2.
TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade")
TIER2_COLORS = ("brown", "pearl")
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",
"pearl": "pearl_rune",
}
# 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. Pearl rolls a chunk of
# critical hit chance.
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
"pearl": "crit", # +critical hit chance
}
PCT_MIN = 25
PCT_MAX = 35
BROWN_PCT_MIN = 5
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
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
T1_RUNE_COST = 100
T2_RUNE_COST = 150
# 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, "pearl": 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",
"pearl": "+{pct}% critical chance",
}
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",
"pearl": "Increases critical hit chance",
}
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, pearl 20-40, the rest 25-35."""
if color == "brown":
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)
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:
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). Each color is
compared to its own cap, so a maxed brown or pearl ranks equally with a
maxed ruby even though 15 < 35."""
cap = max_pct(rune.get("color"))
return rune.get("pct", 0) / cap
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}%",
"pearl": "+{pct}% crit",
}
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/pearl are lower)."""
if color == "brown":
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}%"
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