Spaces:
Running on Zero
Running on Zero
| """Authoritative co-op arena simulation. | |
| One shared arena: a boss in the center, minions that spawn, and up to | |
| MAX_PLAYERS wizards. The engine ticks on the CPU at TICK_HZ and produces a | |
| JSON-serialisable snapshot that is broadcast to every connected client. | |
| The engine knows nothing about networking or the LLM. The server drives it: | |
| - feed inputs via `set_input` / `add_player` / `remove_player` | |
| - call `tick(dt)` every frame | |
| - when `status == "intermission"`, ask the Game Master for a decision and pass | |
| it to `apply_gm_decision`, then call `start_round`. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import random | |
| import time | |
| from dataclasses import dataclass, field | |
| from game import skills as skillmod | |
| ARENA_W = 1280 | |
| ARENA_H = 720 | |
| CENTER = (ARENA_W / 2, ARENA_H / 2) | |
| MAX_PLAYERS = 8 | |
| TICK_HZ = 20 | |
| # Upgrade definitions: id -> (label, cost). Cost is legacy (gold shop); the | |
| # survivors-mode progression hands these out as level-up cards instead. | |
| UPGRADES = { | |
| "damage": ("Spell Power", 30), | |
| "attack_speed": ("Cast Speed", 30), | |
| "move_speed": ("Boots", 25), | |
| "max_hp": ("Vitality", 25), | |
| "multishot": ("Multishot", 60), | |
| } | |
| # Level-up cards. id -> (label, description, rarity). The stat ids above plus a | |
| # few survivors-flavored extras. The Game Master picks which cards are offered. | |
| CARDS = { | |
| # --- originals --- | |
| "auto_attack": ("Auto-Cast", "Attack automatically — no need to hold Space", "common"), | |
| "damage": ("Spell Power", "+6 spell damage", "common"), | |
| "attack_speed": ("Quick Cast", "Cast faster", "common"), | |
| "move_speed": ("Swift Boots", "+22 move speed", "common"), | |
| "max_hp": ("Vitality", "+25 max HP (and heal)", "common"), | |
| "multishot": ("Multishot", "+1 projectile", "rare"), | |
| "pierce": ("Piercing Bolt", "Shots pierce +1 enemy", "rare"), | |
| "regen": ("Regeneration", "+2 HP/sec", "uncommon"), | |
| "projectile_speed": ("Arcane Velocity", "Faster, longer shots", "uncommon"), | |
| "aoe": ("Arcane Nova", "Periodic burst damages nearby foes", "rare"), | |
| "summoner": ("Spirit Caller", "Summon spirits that fight for you", "rare"), | |
| # --- 20 new base power-ups --- | |
| "crit": ("Critical Strike", "+8% chance to deal double damage", "uncommon"), | |
| "lifesteal": ("Vampirism", "Heal for a % of damage dealt", "uncommon"), | |
| "armor": ("Iron Hide", "-2 flat damage taken", "common"), | |
| "dodge": ("Evasion", "+5% chance to avoid a hit", "uncommon"), | |
| "thorns": ("Spiked Aura", "Melee attackers take damage back", "uncommon"), | |
| "magnet": ("Lodestone", "Pull XP gems from much farther", "common"), | |
| "xp_boost": ("Quick Study", "+15% XP from gems", "uncommon"), | |
| "gold_boost": ("Prosperity", "+20% gold from rewards", "common"), | |
| "knockback": ("Force Bolt", "Shots knock enemies back", "common"), | |
| "explosive": ("Explosive Shots", "Shots burst for splash damage", "rare"), | |
| "burn": ("Ignite", "Shots set enemies on fire (damage over time)", "uncommon"), | |
| "chain": ("Chain Spark", "Hits arc to a nearby enemy", "rare"), | |
| "big_shots": ("Heavy Orbs", "Bigger, easier-to-land projectiles", "common"), | |
| "homing": ("Seeker Bolts", "Shots curve toward enemies", "rare"), | |
| "giant": ("Titan Growth", "+30 max HP and you grow larger", "uncommon"), | |
| "glass_cannon": ("Glass Cannon", "+12 damage but -15 max HP", "rare"), | |
| "berserker": ("Berserker", "Big damage boost while below half HP", "uncommon"), | |
| "shield": ("Aegis", "Start each wave with a damage-absorbing shield", "uncommon"), | |
| "slow_aura": ("Frost Presence", "Nearby enemies are slowed", "uncommon"), | |
| "fortune": ("Lucky Charm", "Better gem tiers and rarer drops", "rare"), | |
| # --- boss-exclusive (only offered during the matching boss; see THEME_CARDS) --- | |
| "war_drums": ("War Drums", "Periodically summon a warband", "rare"), | |
| "savage_roar": ("Savage Roar", "Each kill erupts in an AoE blast", "rare"), | |
| "iron_nova": ("Iron Detonation", "Heavy slowing nova every few casts", "rare"), | |
| "bulwark": ("Bulwark", "Periodically restore a chunk of HP", "rare"), | |
| "impaling_charge": ("Impaling Charge", "Burst of piercing lances", "rare"), | |
| "arrow_storm": ("Arrow Storm", "A constant rain of arrows", "rare"), | |
| } | |
| ALL_CARD_IDS = list(CARDS.keys()) | |
| # Cards that grant a runtime skill spec rather than a stat point. | |
| SKILL_CARDS = set(skillmod.CARD_SKILLS.keys()) | |
| # One-time cards excluded from draws once owned. | |
| # (auto_attack handled below in BINARY_CARDS) | |
| # Boss-exclusive cards: each is ONLY offered while its boss theme is active. | |
| # theme index = (round-1)//5 -> Orc / Iron Legion / Lancer / Archer (then cycles) | |
| THEME_CARDS = { | |
| 0: ["war_drums", "savage_roar"], # Orc Horde | |
| 1: ["iron_nova", "bulwark"], # Iron Legion | |
| 2: ["impaling_charge"], # Lancer Host | |
| 3: ["arrow_storm"], # Archer Coven | |
| } | |
| # Every theme-exclusive id, so they can be filtered out of normal draws. | |
| THEME_ONLY = {cid for ids in THEME_CARDS.values() for cid in ids} | |
| def theme_cards_for(theme: int) -> list[str]: | |
| return THEME_CARDS.get(theme % len(THEME_CARDS), []) | |
| # One-time cards excluded from draws once owned: Auto-Cast and every skill card | |
| # (re-granting a skill would just duplicate it). | |
| BINARY_CARDS = {"auto_attack"} | SKILL_CARDS | |
| # XP gem tiers 1..7 -> XP value (increasing). The client colors/sizes by tier. | |
| GEM_TIER_XP = {1: 1, 2: 2, 3: 4, 4: 7, 5: 12, 6: 20, 7: 35} | |
| # Timed AURAS — ONLY found as floor pickups (never cards). Each lasts dur seconds | |
| # and applies temporary multipliers. "sheet" picks a Retro Impact effect sheet. | |
| AURAS = { | |
| "inferno": {"label": "Inferno Aura", "dur": 40, "sheet": "a", "color": "#ff7a4a", | |
| "dmg": 1.6}, | |
| "frost": {"label": "Frost Aura", "dur": 50, "sheet": "b", "color": "#7ee0ff", | |
| "enemy_slow": 0.45, "move": 1.15}, | |
| "haste": {"label": "Haste Aura", "dur": 35, "sheet": "c", "color": "#a6ff8c", | |
| "atk": 0.6, "move": 1.4}, | |
| "vampire": {"label": "Vampiric Aura", "dur": 40, "sheet": "d", "color": "#ff6e6e", | |
| "lifesteal": 0.18}, | |
| "warding": {"label": "Warding Aura", "dur": 55, "sheet": "e", "color": "#ffd45e", | |
| "dmg_red": 0.45}, | |
| "fury": {"label": "Fury Aura", "dur": 30, "sheet": "f", "color": "#c08cff", | |
| "dmg": 1.35, "atk": 0.7}, | |
| } | |
| ALL_AURA_IDS = list(AURAS.keys()) | |
| PENDING_AUTOPICK_SEC = 15.0 # auto-choose a card if the player dithers | |
| # Special boss encounters. They replace the themed boss on certain waves, tint the | |
| # whole screen, attack with a unique projectile style, and — when defeated — grant | |
| # every surviving wizard the boss's own attack power. | |
| SPECIAL_BOSSES = { | |
| "aegis": {"name": "AEGIS", "hue": "red", "hp_mult": 2.3, "dmg_mult": 1.3, | |
| "style": "holy", "reward": "holy_judgment"}, | |
| "toaster": {"name": "TOASTER BOT", "hue": "green", "hp_mult": 1.9, "dmg_mult": 1.15, | |
| "style": "glitch", "reward": "glitch_rift"}, | |
| } | |
| def special_boss_for(rnd: int) -> str | None: | |
| """Aegis on waves 3,9,15… Toaster Bot on waves 5,11,17…""" | |
| if rnd >= 3 and (rnd - 3) % 6 == 0: | |
| return "aegis" | |
| if rnd >= 5 and (rnd - 5) % 6 == 0: | |
| return "toaster" | |
| return None | |
| # Enemy archetypes: id -> stat multipliers/overrides relative to the base minion. | |
| MINION_TYPES = { | |
| "grunt": {"hp": 1.0, "speed": 1.0, "dmg": 1.0, "scale": 1.0}, | |
| "fast": {"hp": 0.6, "speed": 1.8, "dmg": 0.7, "scale": 0.85}, | |
| "tank": {"hp": 2.6, "speed": 0.6, "dmg": 1.6, "scale": 1.3}, | |
| } | |
| # Boss attack patterns — the Game Master picks one per round. Multipliers scale | |
| # how often each attack fires (>1 = more often); flags switch firing styles. | |
| BOSS_PATTERNS = { | |
| "balanced": {"shoot": 1.0, "nova": 1.0, "charge": 1.0, "spawn": 1.0, | |
| "label": ""}, | |
| "artillery": {"shoot": 0.6, "nova": 2.0, "charge": 0.4, "spawn": 0.8, | |
| "label": "ARTILLERY", "slow_heavy": True}, | |
| "sniper": {"shoot": 1.5, "nova": 0.45, "charge": 0.6, "spawn": 0.7, | |
| "label": "SNIPER", "snipe": True}, | |
| "swarm": {"shoot": 0.55, "nova": 0.6, "charge": 0.5, "spawn": 2.4, | |
| "label": "SWARMLORD"}, | |
| "berserker": {"shoot": 0.7, "nova": 0.8, "charge": 2.6, "spawn": 0.7, | |
| "label": "BERSERKER"}, | |
| } | |
| ALL_PATTERN_IDS = list(BOSS_PATTERNS.keys()) | |
| # Blessings the Game Master may bestow on individual wizards between rounds: | |
| # any timed aura, an extra life, or a full heal. Applied at next round start. | |
| BLESSINGS = ["extra_life", "full_heal"] + ALL_AURA_IDS | |
| MAX_LIVES = 5 | |
| def _clamp(v, lo, hi): | |
| return lo if v < lo else hi if v > hi else v | |
| class Projectile: | |
| x: float | |
| y: float | |
| vx: float | |
| vy: float | |
| dmg: float | |
| owner: str # player id, or "boss"/"minion" | |
| hostile: bool # True = damages players, False = damages enemies | |
| ttl: float = 2.5 | |
| radius: float = 10.0 | |
| pierce: int = 0 # remaining extra enemies this shot can pass through | |
| hit_ids: set = field(default_factory=set) # minion uids already hit (for pierce) | |
| # power-up effect payload (player shots only) | |
| homing: bool = False | |
| burn: float = 0.0 # burn dps applied on hit (Ignite) | |
| explode: float = 0.0 # AoE radius on hit (Explosive) | |
| knock: float = 0.0 # knockback impulse (Force Bolt) | |
| chain: int = 0 # extra chain hits (Chain Spark) | |
| crit: bool = False # was a critical hit (visual) | |
| style: str = "" # visual style: "" | "holy" | "glitch" | |
| def to_dict(self): | |
| d = {"x": round(self.x, 1), "y": round(self.y, 1), | |
| "hostile": self.hostile, "r": self.radius} | |
| if self.style: | |
| d["s"] = self.style | |
| return d | |
| class Minion: | |
| x: float | |
| y: float | |
| hp: float | |
| max_hp: float | |
| speed: float | |
| dmg: float | |
| uid: int = 0 # stable id so the client can track hit/death effects | |
| kind: str = "grunt" # grunt | fast | tank | |
| scale: float = 1.0 | |
| attack_cd: float = 0.0 | |
| hurt: float = 0.0 | |
| burn: float = 0.0 # burn damage-per-second (Ignite) | |
| burn_t: float = 0.0 # burn time remaining | |
| alive: bool = True | |
| def to_dict(self): | |
| return {"id": self.uid, | |
| "x": round(self.x, 1), "y": round(self.y, 1), | |
| "hp": self.hp, "max_hp": self.max_hp, "kind": self.kind, | |
| "scale": self.scale, "hurt": self.hurt > 0, | |
| "burn": self.burn_t > 0} | |
| class Ally: | |
| """A summoned spirit that fights for a player (from a summon_ally skill).""" | |
| x: float | |
| y: float | |
| owner: str # player id (kills/damage credit the summoner) | |
| uid: int = 0 | |
| hp: float = 30.0 | |
| max_hp: float = 30.0 | |
| speed: float = 150.0 | |
| dmg: float = 12.0 | |
| attack_cd: float = 0.0 | |
| ttl: float = 14.0 | |
| color: str = "#a6ff8c" | |
| def to_dict(self): | |
| return {"id": self.uid, "x": round(self.x, 1), "y": round(self.y, 1), | |
| "hp": self.hp, "max_hp": self.max_hp, "color": self.color} | |
| class Pickup: | |
| """A rare floor power-up. kind 'card' grants a permanent card; kind 'aura' | |
| grants a timed aura buff. Walk over it to claim.""" | |
| x: float | |
| y: float | |
| card: str # card id (kind=card) or aura id (kind=aura) | |
| kind: str = "card" # card | aura | |
| uid: int = 0 | |
| ttl: float = 22.0 | |
| def to_dict(self): | |
| d = {"id": self.uid, "x": round(self.x, 1), "y": round(self.y, 1), | |
| "card": self.card, "kind": self.kind} | |
| if self.kind == "aura": | |
| d["color"] = AURAS.get(self.card, {}).get("color", "#fff") | |
| d["sheet"] = AURAS.get(self.card, {}).get("sheet", "a") | |
| return d | |
| class Gem: | |
| x: float | |
| y: float | |
| value: int = 1 | |
| tier: int = 1 | |
| ttl: float = 18.0 | |
| def to_dict(self): | |
| return {"x": round(self.x, 1), "y": round(self.y, 1), | |
| "v": self.value, "t": self.tier} | |
| class Boss: | |
| hp: float = 1000 | |
| max_hp: float = 1000 | |
| dmg: float = 12 | |
| x: float = CENTER[0] | |
| y: float = CENTER[1] | |
| shoot_cd: float = 0.0 | |
| spawn_cd: float = 0.0 | |
| spawn_interval: float = 4.0 | |
| hurt: float = 0.0 | |
| # attack state machine | |
| state: str = "idle" # idle | telegraph | charge | |
| state_t: float = 0.0 # time remaining in the current state | |
| charge_cd: float = 6.0 # until next charge | |
| nova_cd: float = 5.0 # until next ground shockwave | |
| cvx: float = 0.0 | |
| cvy: float = 0.0 | |
| aggro: float = 1.0 # Director-tunable attack frequency multiplier | |
| special: str = "" # "" | "aegis" | "toaster" | |
| name: str = "" # display name (special bosses) | |
| special_cd: float = 4.0 # until next signature attack | |
| pattern: str = "balanced" # Director-chosen attack pattern | |
| atk_speed: float = 1.0 # Director-chosen attack cadence multiplier | |
| def to_dict(self): | |
| return {"x": round(self.x, 1), "y": round(self.y, 1), | |
| "hp": self.hp, "max_hp": self.max_hp, | |
| "hurt": self.hurt > 0, "state": self.state, | |
| "special": self.special, "name": self.name, | |
| "pattern": self.pattern, "atk_speed": self.atk_speed, | |
| "pattern_label": BOSS_PATTERNS.get(self.pattern, {}).get("label", ""), | |
| "phase": 1 if self.hp > self.max_hp * 0.5 else 2} | |
| class Player: | |
| pid: str | |
| name: str | |
| x: float = 200.0 | |
| y: float = 360.0 | |
| hp: float = 100.0 | |
| max_hp: float = 100.0 | |
| speed: float = 180.0 | |
| facing: float = 0.0 # radians, last aim direction | |
| alive: bool = True | |
| gold: int = 0 | |
| level: int = 1 | |
| # input | |
| up: bool = False | |
| down: bool = False | |
| left: bool = False | |
| right: bool = False | |
| attacking: bool = False | |
| attack_timer: float = 0.0 | |
| moving: bool = False | |
| hurt: float = 0.0 | |
| # upgrade levels (gained via level-up cards) | |
| up_damage: int = 0 | |
| up_attack_speed: int = 0 | |
| up_move_speed: int = 0 | |
| up_max_hp: int = 0 | |
| up_multishot: int = 0 | |
| up_pierce: int = 0 | |
| up_regen: int = 0 | |
| up_projectile_speed: int = 0 | |
| up_auto_attack: int = 0 | |
| # new base power-ups | |
| up_crit: int = 0 | |
| up_lifesteal: int = 0 | |
| up_armor: int = 0 | |
| up_dodge: int = 0 | |
| up_thorns: int = 0 | |
| up_magnet: int = 0 | |
| up_xp_boost: int = 0 | |
| up_gold_boost: int = 0 | |
| up_knockback: int = 0 | |
| up_explosive: int = 0 | |
| up_burn: int = 0 | |
| up_chain: int = 0 | |
| up_big_shots: int = 0 | |
| up_homing: int = 0 | |
| up_giant: int = 0 | |
| up_glass_cannon: int = 0 | |
| up_berserker: int = 0 | |
| up_shield: int = 0 | |
| up_slow_aura: int = 0 | |
| up_fortune: int = 0 | |
| shield: float = 0.0 # current absorb (from Aegis) | |
| flash: str = "" # transient pickup/announce text for this player | |
| flash_t: float = 0.0 | |
| # lives / character / timed auras | |
| lives: int = 3 | |
| char: str = "warrior" # chosen character sprite set | |
| respawn_t: float = 0.0 # >0 while waiting to respawn after a death | |
| auras: list = field(default_factory=list) # active timed auras [{type,t}] | |
| pending_until: float = 0.0 # auto-pick deadline for pending_cards | |
| # XP / character level | |
| xp: int = 0 | |
| xp_needed: int = 5 | |
| pending_cards: list = field(default_factory=list) # card ids offered now | |
| skill_cards: set = field(default_factory=set) # owned skill-card ids | |
| # runtime skill specs (from cards or Game-Master-authored requests) + state | |
| skills: list = field(default_factory=list) | |
| _attack_count: int = 0 | |
| # per-round stats | |
| dmg_dealt: float = 0.0 | |
| kills: int = 0 | |
| # ---- timed-aura helpers ---- | |
| def _aura_prod(self, key: str, default: float = 1.0) -> float: | |
| v = default | |
| for a in self.auras: | |
| spec = AURAS.get(a["type"], {}) | |
| if key in spec: | |
| v *= spec[key] | |
| return v | |
| def _aura_max(self, key: str) -> float: | |
| v = 0.0 | |
| for a in self.auras: | |
| spec = AURAS.get(a["type"], {}) | |
| if key in spec: | |
| v = max(v, spec[key]) | |
| return v | |
| # derived stats | |
| def damage(self) -> float: | |
| # base + Spell Power + Glass Cannon; Berserker adds more while wounded | |
| d = 14 + self.up_damage * 6 + self.up_glass_cannon * 12 | |
| if self.up_berserker and self.hp < self.max_hp * 0.5: | |
| d *= 1 + 0.15 * self.up_berserker | |
| return d * self._aura_prod("dmg") | |
| def size_mult(self) -> float: | |
| # Wizards start minion-sized (0.55) and grow a step every level, capped | |
| # a touch above boss size (2.9) — a long run literally makes you a | |
| # bigger, harder-to-dodge-with target. Titan Growth stacks on top. | |
| return min(2.9, 0.55 + 0.12 * (self.level - 1)) * (1 + self.up_giant * 0.18) | |
| def hit_radius(self) -> float: | |
| # Server-authoritative hurtbox — grows with size, so dodging gets | |
| # harder as you level (the client renders at the same scale). | |
| return 10.0 + 12.0 * self.size_mult | |
| def attack_interval(self) -> float: | |
| return max(0.12, (0.55 - self.up_attack_speed * 0.05) * self._aura_prod("atk")) | |
| def move_speed(self) -> float: | |
| return (180 + self.up_move_speed * 22) * self._aura_prod("move") | |
| def shots(self) -> int: | |
| return 1 + self.up_multishot | |
| def projectile_speed(self) -> float: | |
| return 520 + self.up_projectile_speed * 90 | |
| def projectile_ttl(self) -> float: | |
| return 2.5 + self.up_projectile_speed * 0.6 | |
| def regen(self) -> float: | |
| return self.up_regen * 2.0 # hp per second | |
| def to_dict(self): | |
| return { | |
| "id": self.pid, "name": self.name, | |
| "x": round(self.x, 1), "y": round(self.y, 1), | |
| "hp": round(self.hp, 1), "max_hp": self.max_hp, | |
| "facing": round(self.facing, 3), "alive": self.alive, | |
| "gold": self.gold, "level": self.level, "moving": self.moving, | |
| "attacking": self.attack_timer > 0, "hurt": self.hurt > 0, | |
| "xp": self.xp, "xp_needed": self.xp_needed, | |
| "pending_cards": list(self.pending_cards), | |
| "pending_left": round(max(0.0, self.pending_until - time.time()), 1) if self.pending_cards else 0, | |
| "lives": self.lives, "char": self.char, | |
| "auras": [{"type": a["type"], "t": round(a["t"], 1), | |
| "color": AURAS.get(a["type"], {}).get("color", "#fff"), | |
| "label": AURAS.get(a["type"], {}).get("label", a["type"])} | |
| for a in self.auras], | |
| "size": round(self.size_mult, 2), | |
| "shield": round(self.shield, 1), | |
| "flash": self.flash if self.flash_t > 0 else "", | |
| "skills": [{"name": s["name"], "effect": s["effect"], "color": s.get("color", "#b48cff")} | |
| for s in self.skills], | |
| # owned level per card (skill cards report 1 when owned) | |
| "upgrades": {k: (1 if k in self.skill_cards else getattr(self, f"up_{k}", 0)) | |
| for k in CARDS}, | |
| "stats": {"dmg": round(self.dmg_dealt), "kills": self.kills}, | |
| } | |
| class Engine: | |
| def __init__(self): | |
| self.players: dict[str, Player] = {} | |
| self.minions: list[Minion] = [] | |
| self._minion_seq = 0 | |
| self.projectiles: list[Projectile] = [] | |
| self.gems: list[Gem] = [] | |
| self.allies: list[Ally] = [] | |
| self._ally_seq = 0 | |
| self.pickups: list[Pickup] = [] | |
| self._pickup_seq = 0 | |
| self.boss = Boss() | |
| self.round = 0 | |
| self.status = "lobby" # lobby | active | intermission | |
| self.status_msg = "Waiting for wizards to join..." | |
| self.gm_message = "" | |
| self.intermission_until = 0.0 | |
| self.round_started_at = 0.0 | |
| self.skill_request_pid: str | None = None | |
| self.skill_request_used = False | |
| # Cards the Game Master is currently offering on level-up (ids). Empty => | |
| # the full set is available (deterministic default). | |
| self.card_pool: list[str] = list(ALL_CARD_IDS) | |
| # config applied each round (mutated by the Game Master) | |
| self.cfg = { | |
| "boss_hp": 1000, "boss_damage": 12, | |
| "minion_hp": 40, "minion_count": 6, "spawn_interval": 4.0, | |
| "boss_aggro": 1.0, # Director-tunable attack frequency | |
| "boss_attack_speed": 1.0, # Director-tunable cadence (mercy-floored) | |
| "boss_pattern": "balanced", # Director-chosen attack pattern | |
| # wave composition: relative weights of each enemy archetype | |
| "wave": {"grunt": 1.0, "fast": 0.0, "tank": 0.0}, | |
| } | |
| # one-shot blessings the GM granted for the coming round: {pid: blessing} | |
| self.pending_blessings: dict[str, str] = {} | |
| # transient FX events for the client (drained each snapshot) | |
| self._events: list[dict] = [] | |
| def _event(self, fx: str, x: float, y: float, **kw): | |
| """Queue a one-shot visual event for the next snapshot broadcast.""" | |
| if len(self._events) < 40: | |
| self._events.append({"fx": fx, "x": round(x, 1), "y": round(y, 1), **kw}) | |
| # ---- lobby management ------------------------------------------------- | |
| def active_players(self) -> list[Player]: | |
| return list(self.players.values()) | |
| def can_join(self) -> bool: | |
| return len(self.players) < MAX_PLAYERS | |
| def add_player(self, pid: str, name: str, char: str = "warrior") -> Player: | |
| spawn_angle = random.uniform(0, math.tau) | |
| p = Player( | |
| pid=pid, name=name[:16] or "Wizard", char=char or "soldier", | |
| x=CENTER[0] + math.cos(spawn_angle) * 420, | |
| y=CENTER[1] + math.sin(spawn_angle) * 280, | |
| ) | |
| p.x = _clamp(p.x, 40, ARENA_W - 40) | |
| p.y = _clamp(p.y, 40, ARENA_H - 40) | |
| # if joining mid-wave, jump straight into the fight | |
| if self.status == "active": | |
| p.hp = p.max_hp | |
| self.players[pid] = p | |
| if self.status == "lobby" and len(self.players) >= 1: | |
| self.status_msg = "Press START or wait for more wizards." | |
| return p | |
| def remove_player(self, pid: str): | |
| self.players.pop(pid, None) | |
| def set_input(self, pid: str, data: dict): | |
| p = self.players.get(pid) | |
| if not p: | |
| return | |
| p.up = bool(data.get("up")) | |
| p.down = bool(data.get("down")) | |
| p.left = bool(data.get("left")) | |
| p.right = bool(data.get("right")) | |
| p.attacking = bool(data.get("attack")) | |
| def _apply_card(self, p: Player, key: str): | |
| """Apply a chosen level-up card's effect to a player.""" | |
| if key in SKILL_CARDS: | |
| self._add_skill(p, skillmod.CARD_SKILLS[key]) | |
| p.skill_cards.add(key) | |
| return | |
| setattr(p, f"up_{key}", getattr(p, f"up_{key}") + 1) | |
| if key in ("max_hp", "giant", "glass_cannon"): | |
| self._recompute_max_hp(p) | |
| if key == "max_hp": | |
| p.hp = min(p.max_hp, p.hp + 25) | |
| if key == "shield": | |
| p.shield = max(p.shield, 20 * p.up_shield) | |
| def _recompute_max_hp(self, p: Player): | |
| p.max_hp = max(20, 100 + p.up_max_hp * 25 + p.up_giant * 30 - p.up_glass_cannon * 15) | |
| p.hp = min(p.hp, p.max_hp) | |
| def _add_skill(self, p: Player, spec: dict) -> dict | None: | |
| """Validate and attach a skill spec to a player (caps the count).""" | |
| safe = skillmod.validate_skill(spec) | |
| if not safe: | |
| return None | |
| safe = dict(safe) | |
| safe["_cd"] = safe.get("interval", 0.0) # periodic cooldown timer | |
| if len(p.skills) >= skillmod.MAX_SKILLS_PER_PLAYER: | |
| p.skills.pop(0) | |
| p.skills.append(safe) | |
| return safe | |
| def add_runtime_skill(self, pid: str, spec: dict) -> dict | None: | |
| """Hot-plug a Game-Master-authored skill onto a player (the LLM flow).""" | |
| p = self.players.get(pid) | |
| if not p: | |
| return None | |
| return self._add_skill(p, spec) | |
| def top_player_id(self) -> str | None: | |
| """Highest-damage player of the round just ended (gets the skill request).""" | |
| if not self.players: | |
| return None | |
| return max(self.players.values(), key=lambda p: p.dmg_dealt).pid | |
| def choose_card(self, pid: str, key: str) -> bool: | |
| """Player picks one of the cards currently offered to them.""" | |
| p = self.players.get(pid) | |
| if not p or key not in CARDS or key not in p.pending_cards: | |
| return False | |
| self._apply_card(p, key) | |
| p.pending_cards = [] # one pick per level-up | |
| p.pending_until = 0.0 | |
| return True | |
| def buy_upgrade(self, pid: str, key: str) -> bool: | |
| """Legacy gold shop (kept for compatibility; survivors mode uses cards).""" | |
| p = self.players.get(pid) | |
| if not p or key not in UPGRADES: | |
| return False | |
| if self.status not in ("intermission", "lobby"): | |
| return False | |
| _, cost = UPGRADES[key] | |
| if p.gold < cost: | |
| return False | |
| p.gold -= cost | |
| self._apply_card(p, key) | |
| return True | |
| def _owns(self, p: Player, cid: str) -> bool: | |
| """Whether a one-time card is already taken (stat binary or a skill card).""" | |
| if cid in SKILL_CARDS: | |
| return cid in p.skill_cards | |
| return bool(getattr(p, f"up_{cid}", 0)) | |
| def _draw_cards(self, p: Player, n: int = 3) -> list[str]: | |
| """Draw up to n distinct card ids for a player, honoring boss-exclusive gating.""" | |
| theme = max(0, (self.round - 1) // 5) | |
| theme_cards = theme_cards_for(theme) | |
| pool = [c for c in self.card_pool if c in CARDS] or list(ALL_CARD_IDS) | |
| # boss-exclusive cards: only the current boss's; drop all other themes' | |
| pool = [c for c in pool if c not in THEME_ONLY or c in theme_cards] | |
| # ensure this boss's exclusives are eligible even if the Director omitted them | |
| for tc in theme_cards: | |
| if tc not in pool: | |
| pool.append(tc) | |
| # drop one-time cards already owned | |
| pool = [c for c in pool if not (c in BINARY_CARDS and self._owns(p, c))] | |
| chosen: list[str] = [] | |
| # guarantee Auto-Cast is offered early so it doesn't get lost in RNG | |
| if not p.up_auto_attack and p.level <= 3: | |
| chosen.append("auto_attack") | |
| rest = [c for c in pool if c not in chosen] | |
| random.shuffle(rest) | |
| # bias one slot toward a boss-exclusive card when available (feels special) | |
| avail_theme = [c for c in theme_cards if c in rest] | |
| if avail_theme and len(chosen) < n: | |
| pick = random.choice(avail_theme) | |
| chosen.append(pick); rest.remove(pick) | |
| for c in rest: | |
| if len(chosen) >= n: | |
| break | |
| chosen.append(c) | |
| return chosen[:n] | |
| def _grant_xp(self, p: Player, amount: int): | |
| if not p.alive: | |
| return | |
| p.xp += amount | |
| leveled = False | |
| while p.xp >= p.xp_needed: | |
| p.xp -= p.xp_needed | |
| p.level += 1 | |
| p.xp_needed = int(p.xp_needed * 1.45) + 3 | |
| leveled = True | |
| if leveled and not p.pending_cards: | |
| p.pending_cards = self._draw_cards(p, 3) | |
| p.pending_until = time.time() + PENDING_AUTOPICK_SEC | |
| # ---- round lifecycle -------------------------------------------------- | |
| def start_round(self): | |
| if not self.players: | |
| self.status = "lobby" | |
| return | |
| self.round += 1 | |
| self.minions.clear() | |
| self.projectiles.clear() | |
| self.gems.clear() | |
| self.allies.clear() | |
| self.pickups.clear() | |
| self.boss = Boss( | |
| hp=self.cfg["boss_hp"], max_hp=self.cfg["boss_hp"], | |
| dmg=self.cfg["boss_damage"], spawn_interval=self.cfg["spawn_interval"], | |
| aggro=self.cfg.get("boss_aggro", 1.0), | |
| pattern=self.cfg.get("boss_pattern", "balanced"), | |
| atk_speed=self.cfg.get("boss_attack_speed", 1.0), | |
| ) | |
| # special boss encounter? (Aegis / Toaster Bot) — buff and rename it | |
| sp = special_boss_for(self.round) | |
| if sp: | |
| cfg = SPECIAL_BOSSES[sp] | |
| self.boss.special = sp | |
| self.boss.name = cfg["name"] | |
| hp = int(self.cfg["boss_hp"] * cfg["hp_mult"]) | |
| self.boss.hp = self.boss.max_hp = hp | |
| self.boss.dmg = int(self.cfg["boss_damage"] * cfg["dmg_mult"]) | |
| # XP / level / cards persist across the run; only revive & reset per-round | |
| # combat stats. Pending card picks carry over so a level-up isn't lost. | |
| for p in self.players.values(): | |
| self._recompute_max_hp(p) | |
| p.hp = p.max_hp | |
| p.alive = p.lives > 0 # eliminated wizards stay down (awaiting removal) | |
| p.respawn_t = 0.0 | |
| p.dmg_dealt = 0.0 | |
| p.kills = 0 | |
| p.shield = 20 * p.up_shield # Aegis refreshes each wave | |
| ang = random.uniform(0, math.tau) | |
| p.x = _clamp(CENTER[0] + math.cos(ang) * 420, 40, ARENA_W - 40) | |
| p.y = _clamp(CENTER[1] + math.sin(ang) * 280, 40, ARENA_H - 40) | |
| self._apply_blessings() | |
| self.status = "active" | |
| if self.boss.special: | |
| self.status_msg = f"Wave {self.round} — {self.boss.name} has arrived!" | |
| else: | |
| self.status_msg = f"Wave {self.round} — survive and slay the boss!" | |
| self.round_started_at = time.time() | |
| def _grant_boss_reward(self, special: str): | |
| """All living wizards gain the defeated special boss's signature attack.""" | |
| cfg = SPECIAL_BOSSES.get(special, {}) | |
| reward = cfg.get("reward") | |
| spec = skillmod.CARD_SKILLS.get(reward) | |
| if not spec: | |
| return | |
| name = spec.get("name", reward) | |
| for p in self.players.values(): | |
| if p.alive and reward not in p.skill_cards: | |
| self._add_skill(p, spec) | |
| p.skill_cards.add(reward) | |
| p.flash = f"Gained {name} from {cfg.get('name', 'the boss')}!" | |
| p.flash_t = 3.0 | |
| def _apply_blessings(self): | |
| """Consume the Game Master's one-shot blessings at round start.""" | |
| for pid, blessing in self.pending_blessings.items(): | |
| p = self.players.get(pid) | |
| if not p or blessing not in BLESSINGS: | |
| continue | |
| if blessing == "extra_life": | |
| p.lives = min(MAX_LIVES, p.lives + 1) | |
| if p.lives > 0 and not p.alive and p.respawn_t <= 0: | |
| p.alive = True | |
| p.hp = p.max_hp | |
| p.flash = "🙏 Blessed: an extra life!" | |
| elif blessing == "full_heal": | |
| p.hp = p.max_hp | |
| p.flash = "🙏 Blessed: fully healed!" | |
| else: # a timed aura | |
| spec = AURAS.get(blessing, {}) | |
| existing = next((a for a in p.auras if a["type"] == blessing), None) | |
| if existing: | |
| existing["t"] = spec.get("dur", 40) | |
| else: | |
| p.auras.append({"type": blessing, "t": spec.get("dur", 40)}) | |
| p.flash = f"🙏 Blessed: {spec.get('label', blessing)}!" | |
| p.flash_t = 4.0 | |
| self._event("bless", p.x, p.y, color="#ffe9a0") | |
| self.pending_blessings = {} | |
| def _respawn(self, p: Player): | |
| p.alive = True | |
| p.respawn_t = 0.0 | |
| p.hp = p.max_hp | |
| p.hurt = 1.0 # brief invuln flicker | |
| ang = random.uniform(0, math.tau) | |
| p.x = _clamp(CENTER[0] + math.cos(ang) * 420, 40, ARENA_W - 40) | |
| p.y = _clamp(CENTER[1] + math.sin(ang) * 280, 40, ARENA_H - 40) | |
| def round_summary(self) -> dict: | |
| """Compact, model-friendly summary of the round that just ended.""" | |
| won = self.boss.hp <= 0 | |
| return { | |
| "round": self.round, | |
| "result": "victory" if won else "defeat", | |
| "duration_sec": round(time.time() - self.round_started_at, 1), | |
| "boss_max_hp": self.boss.max_hp, | |
| "players": [ | |
| { | |
| "id": p.pid, "name": p.name, "level": p.level, | |
| "survived": p.alive, | |
| "damage_dealt": round(p.dmg_dealt), | |
| "kills": p.kills, "gold": p.gold, | |
| "lives_left": p.lives, | |
| "hp_pct": round(100 * p.hp / p.max_hp) if p.alive else 0, | |
| } | |
| for p in self.players.values() | |
| ], | |
| } | |
| def apply_gm_decision(self, decision: dict): | |
| """Apply a (validated) Game Master decision to rewards + next config.""" | |
| rewards = decision.get("rewards", {}) or {} | |
| for pid, amount in rewards.items(): | |
| p = self.players.get(pid) | |
| if p: | |
| amt = int(_clamp(int(amount), 0, 1000)) * (1 + 0.2 * p.up_gold_boost) # Prosperity | |
| p.gold += int(amt) | |
| nxt = decision.get("next_round", {}) or {} | |
| self.cfg["boss_hp"] = int(_clamp(int(nxt.get("boss_hp", self.cfg["boss_hp"])), 300, 100000)) | |
| self.cfg["boss_damage"] = int(_clamp(int(nxt.get("boss_damage", self.cfg["boss_damage"])), 4, 80)) | |
| self.cfg["minion_hp"] = int(_clamp(int(nxt.get("minion_hp", self.cfg["minion_hp"])), 15, 2000)) | |
| self.cfg["minion_count"] = int(_clamp(int(nxt.get("minion_count", self.cfg["minion_count"])), 0, 40)) | |
| self.cfg["spawn_interval"] = float(_clamp(float(nxt.get("spawn_interval", self.cfg["spawn_interval"])), 1.0, 10.0)) | |
| self.cfg["boss_aggro"] = float(_clamp(float(nxt.get("boss_aggro", self.cfg["boss_aggro"])), 0.6, 3.0)) | |
| self.cfg["boss_attack_speed"] = float(_clamp(float(nxt.get("boss_attack_speed", self.cfg["boss_attack_speed"])), 0.5, 2.0)) | |
| # boss attack pattern for the coming round | |
| pattern = nxt.get("boss_pattern", decision.get("boss_pattern")) | |
| if pattern in BOSS_PATTERNS: | |
| self.cfg["boss_pattern"] = pattern | |
| # per-player blessings (validated against roster + known blessings) | |
| blessings = decision.get("blessings") | |
| if isinstance(blessings, dict): | |
| self.pending_blessings = { | |
| pid: b for pid, b in blessings.items() | |
| if pid in self.players and b in BLESSINGS | |
| } | |
| # wave composition (enemy archetype weights) | |
| wave = nxt.get("wave") | |
| if isinstance(wave, dict): | |
| clean = {} | |
| for k in MINION_TYPES: | |
| try: | |
| clean[k] = max(0.0, float(wave.get(k, 0.0))) | |
| except Exception: | |
| clean[k] = 0.0 | |
| if sum(clean.values()) > 0: | |
| self.cfg["wave"] = clean | |
| # offered level-up card pool | |
| pool = decision.get("card_pool") | |
| if isinstance(pool, list): | |
| valid = [c for c in pool if c in CARDS] | |
| self.card_pool = valid if valid else list(ALL_CARD_IDS) | |
| self.gm_message = str(decision.get("message", ""))[:240] | |
| def _pick_minion_kind(self) -> str: | |
| weights = self.cfg.get("wave") or {"grunt": 1.0} | |
| kinds = [k for k in MINION_TYPES if weights.get(k, 0) > 0] or ["grunt"] | |
| w = [weights.get(k, 0) for k in kinds] | |
| return random.choices(kinds, weights=w, k=1)[0] | |
| def begin_intermission(self, seconds: float = 12.0): | |
| self.status = "intermission" | |
| self.intermission_until = time.time() + seconds | |
| won = self.boss.hp <= 0 | |
| self.status_msg = ("Victory! " if won else "Defeated... ") + \ | |
| "The Game Master is deciding rewards & the next round." | |
| # the round's top damage-dealer may ask the GM for a custom power-up | |
| self.skill_request_pid = self.top_player_id() | |
| self.skill_request_used = False | |
| # ---- simulation ------------------------------------------------------- | |
| def _spawn_minion(self): | |
| # Minions are summoned by the boss: they emerge from around it. | |
| ang = random.uniform(0, math.tau) | |
| dist = random.uniform(50, 95) | |
| x = _clamp(self.boss.x + math.cos(ang) * dist, 30, ARENA_W - 30) | |
| y = _clamp(self.boss.y + math.sin(ang) * dist, 30, ARENA_H - 30) | |
| kind = self._pick_minion_kind() | |
| t = MINION_TYPES[kind] | |
| hp = self.cfg["minion_hp"] * t["hp"] | |
| self._minion_seq += 1 | |
| self.minions.append(Minion( | |
| x=x, y=y, hp=hp, max_hp=hp, | |
| speed=(70 + self.round * 4) * t["speed"], | |
| dmg=(6 + self.round) * t["dmg"], | |
| uid=self._minion_seq, kind=kind, scale=t["scale"], | |
| )) | |
| def _nearest_player(self, x, y): | |
| best, bd = None, 1e18 | |
| for p in self.players.values(): | |
| if not p.alive: | |
| continue | |
| d = (p.x - x) ** 2 + (p.y - y) ** 2 | |
| if d < bd: | |
| best, bd = p, d | |
| return best | |
| def _nearest_enemy(self, x, y): | |
| """Closest minion, else the boss.""" | |
| best, bd = None, 1e18 | |
| for m in self.minions: | |
| if not m.alive: | |
| continue | |
| d = (m.x - x) ** 2 + (m.y - y) ** 2 | |
| if d < bd: | |
| best, bd = m, d | |
| if self.boss.hp > 0: | |
| d = (self.boss.x - x) ** 2 + (self.boss.y - y) ** 2 | |
| if d < bd or best is None: | |
| best, bd = self.boss, d | |
| return best | |
| def tick(self, dt: float): | |
| if self.status != "active": | |
| return | |
| for p in self.players.values(): | |
| p.hurt = max(0.0, p.hurt - dt) | |
| p.attack_timer = max(0.0, p.attack_timer - dt) | |
| if p.flash_t > 0: | |
| p.flash_t -= dt | |
| # timed auras count down and expire | |
| if p.auras: | |
| for a in p.auras: | |
| a["t"] -= dt | |
| p.auras = [a for a in p.auras if a["t"] > 0] | |
| # auto-pick a pending card if the player dithers past the deadline | |
| if p.pending_cards and time.time() >= p.pending_until: | |
| self.choose_card(p.pid, p.pending_cards[0]) | |
| if not p.alive: | |
| # respawn after a short delay while lives remain | |
| if p.lives > 0 and p.respawn_t > 0: | |
| p.respawn_t -= dt | |
| if p.respawn_t <= 0: | |
| self._respawn(p) | |
| continue | |
| if p.regen and p.hp < p.max_hp: | |
| p.hp = min(p.max_hp, p.hp + p.regen * dt) | |
| # periodic skills | |
| for sk in p.skills: | |
| if sk["trigger"] == "periodic": | |
| sk["_cd"] = sk.get("_cd", sk.get("interval", 5.0)) - dt | |
| if sk["_cd"] <= 0: | |
| sk["_cd"] = sk.get("interval", 5.0) | |
| self._fire_skill(p, sk) | |
| dx = (1 if p.right else 0) - (1 if p.left else 0) | |
| dy = (1 if p.down else 0) - (1 if p.up else 0) | |
| p.moving = dx != 0 or dy != 0 | |
| if p.moving: | |
| inv = 1.0 / math.hypot(dx, dy) | |
| p.x = _clamp(p.x + dx * inv * p.move_speed * dt, 24, ARENA_W - 24) | |
| p.y = _clamp(p.y + dy * inv * p.move_speed * dt, 24, ARENA_H - 24) | |
| # aim at nearest enemy for satisfying co-op feel | |
| tgt = self._nearest_enemy(p.x, p.y) | |
| if tgt is not None: | |
| p.facing = math.atan2(tgt.y - p.y, tgt.x - p.x) | |
| # attack (Auto-Cast fires without holding the key) | |
| if (p.attacking or p.up_auto_attack) and p.attack_timer <= 0: | |
| self._player_shoot(p) | |
| p.attack_timer = p.attack_interval | |
| self._tick_minions(dt) | |
| self._tick_boss(dt) | |
| self._tick_allies(dt) | |
| self._tick_projectiles(dt) | |
| self._tick_gems(dt) | |
| self._tick_pickups(dt) | |
| self.minions = [m for m in self.minions if m.alive] | |
| self.allies = [a for a in self.allies if a.ttl > 0 and a.hp > 0] | |
| # round end conditions | |
| if self.boss.hp <= 0: | |
| # boss drops a shower of high-tier gems | |
| for _ in range(10): | |
| ang = random.uniform(0, math.tau) | |
| d = random.uniform(20, 140) | |
| gx = _clamp(self.boss.x + math.cos(ang) * d, 30, ARENA_W - 30) | |
| gy = _clamp(self.boss.y + math.sin(ang) * d, 30, ARENA_H - 30) | |
| g = self._make_gem(gx, gy, "tank") | |
| g.tier = int(_clamp(g.tier + 2, 1, 7)) | |
| g.value = GEM_TIER_XP[g.tier] | |
| self.gems.append(g) | |
| # special boss defeated -> every survivor gains its attack power | |
| if self.boss.special: | |
| self._grant_boss_reward(self.boss.special) | |
| self.begin_intermission() | |
| elif self.players and all(not p.alive and p.respawn_t <= 0 for p in self.players.values()): | |
| # every wizard is down with no respawn pending (all eliminated) | |
| self.begin_intermission() | |
| def _tick_allies(self, dt: float): | |
| """Summoned spirits chase the nearest enemy and fire bolts at it.""" | |
| for a in self.allies: | |
| a.ttl -= dt | |
| a.attack_cd = max(0.0, a.attack_cd - dt) | |
| tgt = self._nearest_enemy(a.x, a.y) | |
| if tgt is None: | |
| continue | |
| dist = math.hypot(tgt.x - a.x, tgt.y - a.y) | |
| ang = math.atan2(tgt.y - a.y, tgt.x - a.x) | |
| if dist > 180: | |
| a.x += math.cos(ang) * a.speed * dt | |
| a.y += math.sin(ang) * a.speed * dt | |
| if a.attack_cd <= 0: | |
| self.projectiles.append(Projectile( | |
| x=a.x, y=a.y, vx=math.cos(ang) * 480, vy=math.sin(ang) * 480, | |
| dmg=a.dmg, owner=a.owner, hostile=False, radius=7, ttl=1.5, | |
| )) | |
| a.attack_cd = 0.8 | |
| def _tick_gems(self, dt: float): | |
| """XP gems drift toward nearby living players and are auto-collected.""" | |
| alive = [] | |
| for g in self.gems: | |
| g.ttl -= dt | |
| if g.ttl <= 0: | |
| continue | |
| tgt = self._nearest_player(g.x, g.y) | |
| collected = False | |
| if tgt is not None: | |
| pull = 130.0 + tgt.up_magnet * 55 # Lodestone | |
| grab = 26.0 + tgt.up_magnet * 10 | |
| d = math.hypot(tgt.x - g.x, tgt.y - g.y) | |
| if d <= grab: | |
| amount = g.value * (1 + 0.15 * tgt.up_xp_boost) # Quick Study | |
| self._grant_xp(tgt, max(1, round(amount))) | |
| collected = True | |
| elif d <= pull: | |
| ang = math.atan2(tgt.y - g.y, tgt.x - g.x) | |
| g.x += math.cos(ang) * 240 * dt | |
| g.y += math.sin(ang) * 240 * dt | |
| if not collected: | |
| alive.append(g) | |
| self.gems = alive | |
| def _tick_pickups(self, dt: float): | |
| """Floor power-ups: walk over one to claim the card instantly.""" | |
| alive = [] | |
| for pk in self.pickups: | |
| pk.ttl -= dt | |
| if pk.ttl <= 0: | |
| continue | |
| taken = False | |
| for p in self.players.values(): | |
| if p.alive and (p.x - pk.x) ** 2 + (p.y - pk.y) ** 2 < 34 ** 2: | |
| if pk.kind == "aura": | |
| spec = AURAS.get(pk.card, {}) | |
| # refresh if already active, else add | |
| existing = next((a for a in p.auras if a["type"] == pk.card), None) | |
| if existing: | |
| existing["t"] = spec.get("dur", 40) | |
| else: | |
| p.auras.append({"type": pk.card, "t": spec.get("dur", 40)}) | |
| p.flash = f"{spec.get('label', pk.card)}!" | |
| else: | |
| self._apply_card(p, pk.card) | |
| p.flash = f"Power-up: {CARDS.get(pk.card, (pk.card,))[0]}!" | |
| p.flash_t = 2.5 | |
| taken = True | |
| break | |
| if not taken: | |
| alive.append(pk) | |
| self.pickups = alive | |
| def _player_shoot(self, p: Player): | |
| spread = 0.18 | |
| n = p.shots | |
| base = p.facing | |
| radius = 9 + p.up_big_shots * 4 | |
| for i in range(n): | |
| off = 0 if n == 1 else (i - (n - 1) / 2) * spread | |
| ang = base + off | |
| dmg = p.damage | |
| crit = p.up_crit and random.random() < min(0.6, 0.08 * p.up_crit) | |
| if crit: | |
| dmg *= 2 | |
| self.projectiles.append(Projectile( | |
| x=p.x, y=p.y, | |
| vx=math.cos(ang) * p.projectile_speed, | |
| vy=math.sin(ang) * p.projectile_speed, | |
| dmg=dmg, owner=p.pid, hostile=False, radius=radius, | |
| ttl=p.projectile_ttl, pierce=p.up_pierce, | |
| homing=p.up_homing > 0, burn=p.up_burn * 4.0, | |
| explode=60.0 if p.up_explosive else 0.0, | |
| knock=p.up_knockback * 9.0, chain=p.up_chain, crit=bool(crit), | |
| )) | |
| # attack-triggered skills | |
| p._attack_count += 1 | |
| for sk in p.skills: | |
| if sk["trigger"] == "every_n_attacks" and p._attack_count % sk.get("n", 5) == 0: | |
| self._fire_skill(p, sk) | |
| # ---- skill effects ---------------------------------------------------- | |
| def _fire_skill(self, p: Player, sk: dict): | |
| eff = sk["effect"] | |
| color = sk.get("color", "#b48cff") | |
| if eff == "aoe_damage": | |
| self._event("aoe_frost" if sk.get("slow") else "aoe", p.x, p.y, | |
| r=sk["radius"], color=color) | |
| r2 = sk["radius"] ** 2 | |
| for m in self.minions: | |
| if m.alive and (m.x - p.x) ** 2 + (m.y - p.y) ** 2 <= r2: | |
| m.hp -= sk["damage"]; m.hurt = 0.15 | |
| if sk.get("slow"): | |
| m.speed *= (1 - sk["slow"]) | |
| p.dmg_dealt += sk["damage"] | |
| if m.hp <= 0: | |
| m.alive = False | |
| self._on_minion_killed(m, p) | |
| if self.boss.hp > 0 and (self.boss.x - p.x) ** 2 + (self.boss.y - p.y) ** 2 <= r2: | |
| self.boss.hp -= sk["damage"]; self.boss.hurt = 0.1; p.dmg_dealt += sk["damage"] | |
| elif eff == "projectile_nova": | |
| self._event("nova", p.x, p.y, color=color) | |
| cnt = sk["count"] | |
| for k in range(cnt): | |
| ang = k * math.tau / cnt | |
| self.projectiles.append(Projectile( | |
| x=p.x, y=p.y, vx=math.cos(ang) * 460, vy=math.sin(ang) * 460, | |
| dmg=sk["damage"], owner=p.pid, hostile=False, radius=8, ttl=1.8, | |
| )) | |
| elif eff == "heal": | |
| self._event("heal", p.x, p.y, color=color) | |
| p.hp = min(p.max_hp, p.hp + sk["amount"]) | |
| elif eff == "shield": | |
| self._event("shield", p.x, p.y, color=color) | |
| p.shield = min(120.0, p.shield + sk["amount"]) | |
| elif eff == "summon_ally": | |
| self._event("summon", p.x, p.y, color=color) | |
| for _ in range(sk["count"]): | |
| self._summon_ally(p) | |
| def _summon_ally(self, p: Player): | |
| if len(self.allies) >= 24: | |
| return | |
| self._ally_seq += 1 | |
| ang = random.uniform(0, math.tau) | |
| self.allies.append(Ally( | |
| x=_clamp(p.x + math.cos(ang) * 40, 24, ARENA_W - 24), | |
| y=_clamp(p.y + math.sin(ang) * 40, 24, ARENA_H - 24), | |
| owner=p.pid, uid=self._ally_seq, | |
| hp=30 + p.level * 3, max_hp=30 + p.level * 3, | |
| dmg=10 + p.up_damage * 3, | |
| )) | |
| def _tick_minions(self, dt: float): | |
| for m in self.minions: | |
| m.hurt = max(0.0, m.hurt - dt) | |
| m.attack_cd = max(0.0, m.attack_cd - dt) | |
| # Ignite: burn damage over time | |
| if m.burn_t > 0: | |
| m.burn_t -= dt | |
| m.hp -= m.burn * dt | |
| if m.hp <= 0: | |
| m.alive = False | |
| self._on_minion_killed(m, None) | |
| continue | |
| tgt = self._nearest_player(m.x, m.y) | |
| if tgt is None: | |
| continue | |
| # Frost Presence card + Frost Aura: nearby enemies move slower | |
| speed = m.speed | |
| slow = 0.12 * tgt.up_slow_aura + tgt._aura_max("enemy_slow") | |
| if slow and (tgt.x - m.x) ** 2 + (tgt.y - m.y) ** 2 < 150 ** 2: | |
| speed *= max(0.25, 1 - slow) | |
| ang = math.atan2(tgt.y - m.y, tgt.x - m.x) | |
| dist = math.hypot(tgt.x - m.x, tgt.y - m.y) | |
| reach = 20 + tgt.hit_radius # bigger wizards are easier to reach | |
| if dist > reach: | |
| m.x += math.cos(ang) * speed * dt | |
| m.y += math.sin(ang) * speed * dt | |
| elif m.attack_cd <= 0: | |
| self._damage_player(tgt, m.dmg) | |
| # Spiked Aura: reflect damage to the attacker | |
| if tgt.up_thorns: | |
| m.hp -= 5 * tgt.up_thorns | |
| m.hurt = 0.15 | |
| if m.hp <= 0: | |
| m.alive = False | |
| self._on_minion_killed(m, tgt) | |
| m.attack_cd = 1.0 | |
| def _tick_boss(self, dt: float): | |
| b = self.boss | |
| if b.hp <= 0: | |
| return | |
| b.hurt = max(0.0, b.hurt - dt) | |
| b.shoot_cd = max(0.0, b.shoot_cd - dt) | |
| b.spawn_cd = max(0.0, b.spawn_cd - dt) | |
| b.charge_cd = max(0.0, b.charge_cd - dt) | |
| b.nova_cd = max(0.0, b.nova_cd - dt) | |
| phase2 = b.hp <= b.max_hp * 0.5 | |
| # combined attack cadence: aggro (difficulty) x atk_speed (GM mercy knob) | |
| agg = b.aggro * (1.3 if phase2 else 1.0) * _clamp(b.atk_speed, 0.5, 2.0) | |
| pat = BOSS_PATTERNS.get(b.pattern, BOSS_PATTERNS["balanced"]) | |
| # ---- charge attack: telegraph, then rush a player and melee hard ---- | |
| if b.state == "telegraph": | |
| b.state_t -= dt | |
| if b.state_t <= 0: | |
| b.state = "charge" | |
| b.state_t = 0.55 | |
| return # boss is winding up, no other actions | |
| if b.state == "charge": | |
| b.state_t -= dt | |
| b.x = _clamp(b.x + b.cvx * dt, 40, ARENA_W - 40) | |
| b.y = _clamp(b.y + b.cvy * dt, 40, ARENA_H - 40) | |
| for p in self.players.values(): | |
| if p.alive and (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < (54 + p.hit_radius) ** 2: | |
| self._damage_player(p, b.dmg * 2.5) # massive melee | |
| if b.state_t <= 0: | |
| b.state = "idle" | |
| b.charge_cd = max(2.0, 8.0 / (agg * pat["charge"])) | |
| return | |
| # drift back toward the centre when idle | |
| b.x += (CENTER[0] - b.x) * min(1.0, dt * 0.6) | |
| b.y += (CENTER[1] - b.y) * min(1.0, dt * 0.6) | |
| # decide whether to start a charge (hunts down players who keep distance) | |
| if b.charge_cd <= 0: | |
| tgt = self._nearest_player(b.x, b.y) | |
| if tgt: | |
| ang = math.atan2(tgt.y - b.y, tgt.x - b.x) | |
| speed = 620 + self.round * 8 | |
| b.cvx, b.cvy = math.cos(ang) * speed, math.sin(ang) * speed | |
| b.state = "telegraph" | |
| b.state_t = 0.7 | |
| return | |
| # everything below creates boss projectiles; tag them with the boss's | |
| # visual style (holy / glitch) at the end. | |
| n0 = len(self.projectiles) | |
| bstyle = SPECIAL_BOSSES.get(b.special, {}).get("style", "") | |
| # spawn minions (the SWARMLORD pattern leans hard on this) | |
| if b.spawn_cd <= 0 and len(self.minions) < 30: | |
| burst = max(1, round((self.cfg["minion_count"] / 3) * pat["spawn"])) | |
| for _ in range(burst): | |
| self._spawn_minion() | |
| b.spawn_cd = b.spawn_interval | |
| # ---- special boss signature attacks ---- | |
| if b.special: | |
| b.special_cd = max(0.0, b.special_cd - dt) | |
| if b.special_cd <= 0: | |
| self._boss_signature(b) | |
| b.special_cd = max(2.5, 5.0 / agg) | |
| # ---- ground shockwave: dense ring that punishes standing still ---- | |
| if b.nova_cd <= 0: | |
| heavy = pat.get("slow_heavy") | |
| n = 20 if phase2 else 14 | |
| spd, rad, dmul = (160, 18, 1.3) if heavy else (200, 13, 1.0) | |
| for k in range(n): | |
| ang = k * math.tau / n | |
| self.projectiles.append(Projectile( | |
| x=b.x, y=b.y, vx=math.cos(ang) * spd, vy=math.sin(ang) * spd, | |
| dmg=b.dmg * dmul, owner="boss", hostile=True, ttl=4.5, radius=rad, | |
| )) | |
| b.nova_cd = max(1.8, 6.0 / (agg * pat["nova"])) | |
| # ---- aimed spread / radial bursts (style depends on the pattern) ---- | |
| if b.shoot_cd <= 0: | |
| tgt = self._nearest_player(b.x, b.y) | |
| if pat.get("snipe") and tgt: | |
| # SNIPER: one fast, painful, well-aimed bolt | |
| ang = math.atan2(tgt.y - b.y, tgt.x - b.x) | |
| self.projectiles.append(Projectile( | |
| x=b.x, y=b.y, vx=math.cos(ang) * 540, vy=math.sin(ang) * 540, | |
| dmg=b.dmg * 1.8, owner="boss", hostile=True, ttl=3.5, radius=9, | |
| )) | |
| b.shoot_cd = 1.3 / (agg * pat["shoot"]) | |
| elif pat.get("slow_heavy") and tgt: | |
| # ARTILLERY: lob big slow orbs at the target | |
| ang = math.atan2(tgt.y - b.y, tgt.x - b.x) + random.uniform(-0.15, 0.15) | |
| self.projectiles.append(Projectile( | |
| x=b.x, y=b.y, vx=math.cos(ang) * 170, vy=math.sin(ang) * 170, | |
| dmg=b.dmg * 1.4, owner="boss", hostile=True, ttl=5.5, radius=20, | |
| )) | |
| b.shoot_cd = 1.6 / (agg * pat["shoot"]) | |
| elif phase2: | |
| for k in range(12): | |
| ang = k * math.tau / 12 + random.uniform(-0.1, 0.1) | |
| self.projectiles.append(Projectile( | |
| x=b.x, y=b.y, vx=math.cos(ang) * 240, vy=math.sin(ang) * 240, | |
| dmg=b.dmg, owner="boss", hostile=True, ttl=4, radius=12, | |
| )) | |
| b.shoot_cd = 2.2 / (agg * pat["shoot"]) | |
| elif tgt: | |
| ang = math.atan2(tgt.y - b.y, tgt.x - b.x) | |
| for off in (-0.22, -0.11, 0, 0.11, 0.22): | |
| self.projectiles.append(Projectile( | |
| x=b.x, y=b.y, | |
| vx=math.cos(ang + off) * 280, vy=math.sin(ang + off) * 280, | |
| dmg=b.dmg, owner="boss", hostile=True, ttl=4, radius=12, | |
| )) | |
| b.shoot_cd = 1.8 / (agg * pat["shoot"]) | |
| # apply the boss's projectile style to everything it just fired | |
| if bstyle: | |
| for pr in self.projectiles[n0:]: | |
| pr.style = bstyle | |
| def _boss_signature(self, b: Boss): | |
| """A unique telegraphed attack per special boss.""" | |
| if b.special == "aegis": | |
| # Holy Cross: a rotating + and x of holy bolts that punish all angles | |
| base = random.uniform(0, math.tau) | |
| for k in range(8): | |
| ang = base + k * math.tau / 8 | |
| self.projectiles.append(Projectile( | |
| x=b.x, y=b.y, vx=math.cos(ang) * 260, vy=math.sin(ang) * 260, | |
| dmg=b.dmg * 1.2, owner="boss", hostile=True, ttl=4.5, radius=16, | |
| )) | |
| # plus aimed holy spears at the nearest two players | |
| for p in list(self.players.values())[:2]: | |
| if p.alive: | |
| ang = math.atan2(p.y - b.y, p.x - b.x) | |
| self.projectiles.append(Projectile( | |
| x=b.x, y=b.y, vx=math.cos(ang) * 360, vy=math.sin(ang) * 360, | |
| dmg=b.dmg * 1.6, owner="boss", hostile=True, ttl=4, radius=18, | |
| )) | |
| elif b.special == "toaster": | |
| # Glitch Portals: open rifts near players that spit projectiles outward | |
| for _ in range(3): | |
| tgt = self._nearest_player(b.x, b.y) | |
| px = random.uniform(150, ARENA_W - 150) | |
| py = random.uniform(120, ARENA_H - 120) | |
| if tgt and random.random() < 0.6: | |
| px, py = tgt.x + random.uniform(-80, 80), tgt.y + random.uniform(-80, 80) | |
| for k in range(6): | |
| ang = k * math.tau / 6 | |
| self.projectiles.append(Projectile( | |
| x=px, y=py, vx=math.cos(ang) * 210, vy=math.sin(ang) * 210, | |
| dmg=b.dmg, owner="boss", hostile=True, ttl=3.5, radius=14, | |
| )) | |
| def _tick_projectiles(self, dt: float): | |
| alive = [] | |
| for pr in self.projectiles: | |
| # Seeker Bolts: steer toward the nearest enemy | |
| if pr.homing and not pr.hostile: | |
| tgt = self._nearest_enemy(pr.x, pr.y) | |
| if tgt is not None: | |
| sp = math.hypot(pr.vx, pr.vy) or 1.0 | |
| want = math.atan2(tgt.y - pr.y, tgt.x - pr.x) | |
| cur = math.atan2(pr.vy, pr.vx) | |
| d = (want - cur + math.pi) % math.tau - math.pi | |
| cur += max(-6 * dt, min(6 * dt, d)) | |
| pr.vx, pr.vy = math.cos(cur) * sp, math.sin(cur) * sp | |
| pr.x += pr.vx * dt | |
| pr.y += pr.vy * dt | |
| pr.ttl -= dt | |
| if pr.ttl <= 0 or pr.x < -40 or pr.x > ARENA_W + 40 or pr.y < -40 or pr.y > ARENA_H + 40: | |
| continue | |
| hit = False | |
| if pr.hostile: | |
| for p in self.players.values(): | |
| if p.alive and (p.x - pr.x) ** 2 + (p.y - pr.y) ** 2 < (pr.radius + p.hit_radius) ** 2: | |
| self._damage_player(p, pr.dmg) | |
| hit = True | |
| break | |
| else: | |
| owner = self.players.get(pr.owner) | |
| for m in self.minions: | |
| if not m.alive or m.uid in pr.hit_ids: | |
| continue | |
| if (m.x - pr.x) ** 2 + (m.y - pr.y) ** 2 < (pr.radius + 22) ** 2: | |
| m.hp -= pr.dmg | |
| m.hurt = 0.15 | |
| pr.hit_ids.add(m.uid) | |
| if owner: | |
| owner.dmg_dealt += pr.dmg | |
| self._apply_hit_effects(pr, m, owner) | |
| if m.hp <= 0: | |
| m.alive = False | |
| self._on_minion_killed(m, owner) | |
| hit = True | |
| break | |
| if not hit and self.boss.hp > 0 and \ | |
| (self.boss.x - pr.x) ** 2 + (self.boss.y - pr.y) ** 2 < (pr.radius + 70) ** 2: | |
| self.boss.hp -= pr.dmg | |
| self.boss.hurt = 0.1 | |
| if owner: | |
| owner.dmg_dealt += pr.dmg | |
| if owner.up_lifesteal: | |
| self._heal(owner, pr.dmg * 0.03 * owner.up_lifesteal) | |
| hit = True | |
| # pierce: a player shot can pass through extra enemies before dying | |
| if hit and not pr.hostile and pr.pierce > 0: | |
| pr.pierce -= 1 | |
| hit = False | |
| if not hit: | |
| alive.append(pr) | |
| self.projectiles = alive | |
| def _heal(self, p: Player, amt: float): | |
| p.hp = min(p.max_hp, p.hp + amt) | |
| def _apply_hit_effects(self, pr: Projectile, m: Minion, owner: "Player | None"): | |
| """Run on a player shot striking a minion: lifesteal/burn/knock/explode/chain.""" | |
| if owner: | |
| ls = 0.03 * owner.up_lifesteal + owner._aura_max("lifesteal") | |
| if ls: | |
| self._heal(owner, pr.dmg * ls) | |
| if pr.burn: | |
| m.burn = max(m.burn, pr.burn) | |
| m.burn_t = 3.0 | |
| if pr.knock: | |
| ang = math.atan2(m.y - pr.y, m.x - pr.x) | |
| m.x = _clamp(m.x + math.cos(ang) * pr.knock, 20, ARENA_W - 20) | |
| m.y = _clamp(m.y + math.sin(ang) * pr.knock, 20, ARENA_H - 20) | |
| if pr.explode: | |
| r2 = pr.explode ** 2 | |
| for o in self.minions: | |
| if o is m or not o.alive or o.uid in pr.hit_ids: | |
| continue | |
| if (o.x - m.x) ** 2 + (o.y - m.y) ** 2 <= r2: | |
| o.hp -= pr.dmg * 0.5 | |
| o.hurt = 0.15 | |
| if owner: | |
| owner.dmg_dealt += pr.dmg * 0.5 | |
| if o.hp <= 0: | |
| o.alive = False | |
| self._on_minion_killed(o, owner) | |
| if pr.chain: | |
| hit_extra = 0 | |
| for o in sorted(self.minions, key=lambda q: (q.x - m.x) ** 2 + (q.y - m.y) ** 2): | |
| if hit_extra >= pr.chain: | |
| break | |
| if o is m or not o.alive or o.uid in pr.hit_ids: | |
| continue | |
| if (o.x - m.x) ** 2 + (o.y - m.y) ** 2 <= 170 ** 2: | |
| o.hp -= pr.dmg * 0.5 | |
| o.hurt = 0.15 | |
| pr.hit_ids.add(o.uid) | |
| hit_extra += 1 | |
| if owner: | |
| owner.dmg_dealt += pr.dmg * 0.5 | |
| if o.hp <= 0: | |
| o.alive = False | |
| self._on_minion_killed(o, owner) | |
| def _on_minion_killed(self, m: Minion, owner: "Player | None"): | |
| fortune = owner.up_fortune if owner else 0 | |
| if owner: | |
| owner.kills += 1 | |
| for sk in owner.skills: | |
| if sk["trigger"] == "on_kill": | |
| self._fire_skill(owner, sk) | |
| self.gems.append(self._make_gem(m.x, m.y, m.kind, fortune)) | |
| # ~1/100 chance a minion drops a power-up card (Lucky Charm raises it) | |
| if random.random() < 0.01 * (1 + 0.5 * fortune): | |
| self._drop_pickup(m.x, m.y) | |
| # rarer: a timed aura drops on the floor (~1/200, Lucky Charm raises it) | |
| if random.random() < 0.005 * (1 + 0.5 * fortune): | |
| self._drop_aura(m.x, m.y) | |
| def _drop_pickup(self, x: float, y: float): | |
| pool = [c for c in ALL_CARD_IDS | |
| if c not in THEME_ONLY and c not in SKILL_CARDS and c != "auto_attack"] | |
| if not pool: | |
| return | |
| self._pickup_seq += 1 | |
| self.pickups.append(Pickup(x=x, y=y, card=random.choice(pool), uid=self._pickup_seq)) | |
| def _drop_aura(self, x: float, y: float): | |
| self._pickup_seq += 1 | |
| self.pickups.append(Pickup(x=x, y=y, card=random.choice(ALL_AURA_IDS), | |
| kind="aura", uid=self._pickup_seq, ttl=18.0)) | |
| def _make_gem(self, x: float, y: float, kind: str, fortune: int = 0) -> Gem: | |
| """Pick a gem tier (1..7) by enemy type + wave, with a little variance.""" | |
| base = {"grunt": 1, "fast": 2, "tank": 4}.get(kind, 1) | |
| tier = base + (1 if random.random() < 0.3 else 0) + self.round // 6 + fortune | |
| tier = int(_clamp(tier, 1, 7)) | |
| return Gem(x=x, y=y, tier=tier, value=GEM_TIER_XP[tier]) | |
| def _damage_player(self, p: Player, dmg: float): | |
| if not p.alive or p.hurt > 0: | |
| return | |
| # Evasion: chance to avoid the hit entirely | |
| if p.up_dodge and random.random() < min(0.5, 0.05 * p.up_dodge): | |
| p.hurt = 0.2 | |
| return | |
| # Iron Hide: flat damage reduction | |
| if p.up_armor: | |
| dmg = max(1.0, dmg - 2 * p.up_armor) | |
| # Warding aura: percent damage reduction | |
| red = p._aura_max("dmg_red") | |
| if red: | |
| dmg *= (1 - red) | |
| # Aegis: shield absorbs first | |
| if p.shield > 0: | |
| absorbed = min(p.shield, dmg) | |
| p.shield -= absorbed | |
| dmg -= absorbed | |
| p.hp -= dmg | |
| p.hurt = 0.4 | |
| if p.hp <= 0: | |
| p.hp = 0 | |
| p.alive = False | |
| p.lives -= 1 # spend a life; >0 respawns, 0 = eliminated | |
| if p.lives > 0: | |
| p.respawn_t = 3.0 | |
| p.auras = [] # auras fade on death | |
| # ---- snapshot --------------------------------------------------------- | |
| def snapshot(self) -> dict: | |
| events, self._events = self._events, [] | |
| return { | |
| "events": events, | |
| "t": round(time.time(), 2), | |
| "round": self.round, | |
| "status": self.status, | |
| "status_msg": self.status_msg, | |
| "gm_message": self.gm_message, | |
| "intermission_left": max(0, round(self.intermission_until - time.time(), 1)) | |
| if self.status == "intermission" else 0, | |
| "boss": self.boss.to_dict() if self.boss.hp > 0 or self.status != "lobby" else None, | |
| "theme": max(0, (self.round - 1) // 5), # enemy skin set, swaps every 5 waves | |
| "hue": SPECIAL_BOSSES[self.boss.special]["hue"] | |
| if self.boss.special and self.boss.hp > 0 else None, | |
| "players": [p.to_dict() for p in self.players.values()], | |
| "minions": [m.to_dict() for m in self.minions], | |
| "allies": [a.to_dict() for a in self.allies], | |
| "projectiles": [pr.to_dict() for pr in self.projectiles], | |
| "gems": [g.to_dict() for g in self.gems], | |
| "pickups": [pk.to_dict() for pk in self.pickups], | |
| "arena": {"w": ARENA_W, "h": ARENA_H}, | |
| "max_players": MAX_PLAYERS, | |
| "upgrades": {k: {"label": v[0], "cost": v[1]} for k, v in UPGRADES.items()}, | |
| "cards": {k: {"label": v[0], "desc": v[1], "rarity": v[2]} | |
| for k, v in CARDS.items()}, | |
| "card_pool": list(self.card_pool), | |
| "skill_request": ( | |
| {"pid": self.skill_request_pid, "used": self.skill_request_used} | |
| if self.status == "intermission" else None | |
| ), | |
| } | |