Статья Автор: Купряшин Саша

1



# zombie_fps_advanced.py
# One-file 3D zombie FPS prototype with:
# - procedural "models" + simple limb animation
# - smarter zombie steering + separation + obstacle sampling
# - inventory + shop
# - perk choice on level up
# - map generation from seed
# - save/load progress (json)

from ursina import *
from ursina.prefabs.first_person_controller import FirstPersonController
import random, math, json, os, sys, time as pytime
from dataclasses import dataclass, asdict

app = Ursina()
window.title = "Zombie FPS Advanced (one-file)"
window.fps_counter.enabled = True
window.exit_button.visible = True

SAVE_PATH = "savegame_zfps.json"

def clamp(v, a, b): return max(a, min(b, v))
def lerp(a, b, t): return a + (b-a)*t

def xp_to_next(level: int) -> int:
    return int(120 * (level ** 1.35))

# -------------------- Game State --------------------
@dataclass
class GameSave:
    seed: int = 1337
    level: int = 1
    xp: int = 0
    xp_next: int = 120
    money: int = 0
    max_hp: int = 120
    hp: float = 120
    speed_base: float = 5.5
    perks: list = None
    weapon_i: int = 0
    ammo: list = None
    weapon_upgrades: dict = None
    inventory: dict = None
    wave: int = 1

def default_save(seed: int) -> GameSave:
    return GameSave(
        seed=seed,
        level=1,
        xp=0,
        xp_next=xp_to_next(1),
        money=0,
        max_hp=120,
        hp=120,
        speed_base=5.5,
        perks=[],
        weapon_i=0,
        ammo=[12, 6, 30],
        weapon_upgrades={
            "Pistol": {"dmg":0, "fir":0, "cap":0, "rel":0},
            "Shotgun":{"dmg":0, "fir":0, "cap":0, "rel":0},
            "Rifle": {"dmg":0, "fir":0, "cap":0, "rel":0},
        },
        inventory={"medkit":1, "grenade":1},
        wave=1,
    )

def load_save():
    if not os.path.exists(SAVE_PATH):
        return None
    try:
        with open(SAVE_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        gs = GameSave(**data)
        if gs.perks is None: gs.perks = []
        if gs.ammo is None: gs.ammo = [12,6,30]
        if gs.inventory is None: gs.inventory = {"medkit":1, "grenade":1}
        if gs.weapon_upgrades is None:
            gs.weapon_upgrades = {
                "Pistol": {"dmg":0, "fir":0, "cap":0, "rel":0},
                "Shotgun":{"dmg":0, "fir":0, "cap":0, "rel":0},
                "Rifle": {"dmg":0, "fir":0, "cap":0, "rel":0},
            }
        if gs.xp_next <= 0: gs.xp_next = xp_to_next(gs.level)
        return gs
    except Exception:
        return None

def save_game():
    if not player:
        return
    data = current_save()
    try:
        with open(SAVE_PATH, "w", encoding="utf-8") as f:
            json.dump(asdict(data), f, ensure_ascii=False, indent=2)
    except Exception:
        pass

def wipe_save():
    try:
        if os.path.exists(SAVE_PATH):
            os.remove(SAVE_PATH)
    except Exception:
        pass

# -------------------- UI Helpers --------------------
def make_ui_bar(parent, y, width=0.62, height=0.022, bg_alpha=0.35):
    bg = Entity(parent=parent, model='quad',
                color=color.rgba(0,0,0,int(255*bg_alpha)),
                scale=(width, height), position=(0, y), origin=(0,0))
    fill = Entity(parent=bg, model='quad', color=color.white,
                  scale=(1, 0.8), position=(-0.5, 0), origin=(-0.5,0))
    return bg, fill

class FloatingText(Text):
    def __init__(self, text, position, clr=color.white, duration=0.7, **kwargs):
        super().__init__(text=text, position=position, origin=(0,0),
                         scale=1.25, color=clr, background=False, **kwargs)
        self.t0 = pytime.time()
        self.duration = duration
        self.start_y = position[1]

    def update(self):
        t = pytime.time() - self.t0
        self.y = self.start_y + t*0.03
        self.alpha = 1 - (t/self.duration)
        if t >= self.duration:
            destroy(self)

# -------------------- Weapon System --------------------
class Weapon:
    def __init__(self, name, damage, fire_rate, ammo_cap, reload_time,
                 spread=0.0, pellets=1, headshot_mult=1.8):
        self.name = name
        self.damage = damage
        self.fire_rate = fire_rate
        self.ammo_cap = ammo_cap
        self.reload_time = reload_time
        self.spread = spread
        self.pellets = pellets
        self.headshot_mult = headshot_mult

# -------------------- Map Generation --------------------
map_obstacles = set()
map_floor = None
map_walls = []
rng = random.Random(1337)

def build_map(seed: int):
    global map_floor, map_walls, map_obstacles, rng
    rng = random.Random(seed)

    # Cleanup previous map
    for e in list(map_obstacles):
        if e: destroy(e)
    map_obstacles.clear()
    for w in map_walls:
        if w: destroy(w)
    map_walls = []
    if map_floor:
        destroy(map_floor)

    # Floor
    map_floor = Entity(model='plane', scale=(80,1,80), texture='white_cube',
                       texture_scale=(40,40), color=color.rgba(55,60,70,255),
                       collider='box', y=0)

    # Boundaries
    size = 38
    wall_h = 4.2
    thickness = 1.0

    def wall(pos, scl):
        e = Entity(model='cube', color=color.rgba(85,85,100,255),
                   position=pos, scale=scl, collider='box')
        map_obstacles.add(e)
        map_walls.append(e)
        return e

    wall((0, wall_h/2,  size), (size*2, wall_h, thickness))
    wall((0, wall_h/2, -size), (size*2, wall_h, thickness))
    wall(( size, wall_h/2, 0), (thickness, wall_h, size*2))
    wall((-size, wall_h/2, 0), (thickness, wall_h, size*2))

    # Procedural "blocks": some bigger rectangles + clutter
    # Big rectangles (like buildings)
    for _ in range(10):
        cx = rng.randint(-28, 28)
        cz = rng.randint(-28, 28)
        w = rng.randint(4, 10)
        d = rng.randint(4, 10)
        if abs(cx) < 7 and abs(cz) < 7:
            continue
        h = rng.choice([2.0, 2.5, 3.0])
        e = Entity(model='cube', color=color.rgba(95,95,115,255),
                   position=(cx, h/2, cz), scale=(w, h, d), collider='box')
        map_obstacles.add(e)

    # Clutter
    for _ in range(110):
        x = rng.randint(-33, 33)
        z = rng.randint(-33, 33)
        if abs(x) < 6 and abs(z) < 6:
            continue
        h = rng.choice([1.2, 1.6, 2.0, 2.6, 3.2])
        s = rng.choice([1.2, 1.5, 2.0, 2.5, 3.0])
        e = Entity(model='cube', color=color.rgba(100,100,120,255),
                   position=(x, h/2, z), scale=(s, h, s), collider='box')
        map_obstacles.add(e)

    # Some "pillars" as landmarks
    for _ in range(8):
        x = rng.randint(-30, 30)
        z = rng.randint(-30, 30)
        if abs(x) < 8 and abs(z) < 8:
            continue
        e = Entity(model='cube', color=color.rgba(70,70,90,255),
                   position=(x, 2.2, z), scale=(1.2, 4.4, 1.2), collider='box')
        map_obstacles.add(e)

# -------------------- Buff Pickup --------------------
class BuffPickup(Entity):
    def __init__(self, buff_type, amount=1.0, duration=8.0, **kwargs):
        self.buff_type = buff_type
        self.amount = amount
        self.duration = duration
        clr = {
            'speed': color.azure,
            'damage': color.orange,
            'regen': color.lime,
        }.get(buff_type, color.white)
        super().__init__(model='sphere', color=clr, scale=0.45,
                         collider='sphere', **kwargs)
        self._bob = rng.uniform(0, math.tau)

    def update(self):
        if game_mode != "play":
            return
        self._bob += time.dt * 2.0
        self.y += math.sin(self._bob) * 0.002
        self.rotation_y += 80 * time.dt

        if player and not player.dead and distance(self.position, player.position) < 1.2:
            player.apply_buff(self.buff_type, self.amount, self.duration)
            destroy(self)

# -------------------- Grenade --------------------
class Grenade(Entity):
    def __init__(self, pos, vel, owner, **kwargs):
        super().__init__(model='sphere', color=color.rgba(220,220,220,255),
                         scale=0.22, position=pos, collider='sphere', **kwargs)
        self.vel = vel
        self.owner = owner
        self.spawn_t = pytime.time()
        self.fuse = 2.1
        self.bounce = 0.45

    def explode(self):
        # Visual explosion
        boom = Entity(model='sphere', color=color.rgba(255,180,60,170),
                      scale=0.2, position=self.position)
        boom.animate_scale(5.0, duration=0.18, curve=curve.out_expo)
        destroy(boom, delay=0.22)

        # Damage zombies in radius
        radius = 6.0
        base = 55.0 * (1.0 + 0.12 * player.perk_levels.get("demo", 0))
        for z in list(zombies):
            if not z or not z.enabled:
                continue
            d = distance(self.position, z.position)
            if d <= radius:
                dmg = base * (1.0 - d/radius)
                z.take_damage(dmg, is_headshot=False, impulse=(z.position - self.position).normalized() * 1.0)

        destroy(self)

    def update(self):
        if game_mode != "play":
            return
        # Fuse
        if pytime.time() - self.spawn_t >= self.fuse:
            self.explode()
            return

        # Physics
        self.vel.y -= 11.0 * time.dt  # gravity
        next_pos = self.position + self.vel * time.dt

        hit = raycast(self.position, self.vel.normalized(), distance=(next_pos - self.position).length(),
                      ignore=(self, player), traverse_target=scene)
        if hit.hit:
            # reflect velocity a bit
            n = hit.world_normal
            v = self.vel
            self.vel = (v - 2 * v.dot(n) * n) * self.bounce
            self.position = hit.world_point + n * 0.05
            if v.length() > 6 and rng.random() < 0.25:
                # random extra spark
                spark = Entity(model='sphere', color=color.rgba(255,255,255,150),
                               scale=0.06, position=self.position)
                destroy(spark, delay=0.08)
        else:
            self.position = next_pos

        self.rotation_y += 220 * time.dt

# -------------------- Zombie (procedural model + better steering) --------------------
class Zombie(Entity):
    def __init__(self, pos, tier=1, **kwargs):
        self.tier = tier
        self.max_hp = 40 + 18*(tier-1)
        self.hp = float(self.max_hp)
        self.speed = 2.1 + 0.22*(tier-1)
        self.dmg = 7 + 2*(tier-1)
        self.attack_cd = 0.85
        self._next_attack = 0.0
        self._phase = rng.uniform(0, math.tau)
        self._last_hurt = 0.0

        # Root collider
        super().__init__(model='cube', color=color.rgba(0,0,0,0),
                         scale=(0.9, 1.8, 0.9),
                         position=pos, collider='box', origin_y=-0.5, **kwargs)

        # Visual body parts (children)
        skin = color.rgba(90, 185, 105, 255)
        cloth = color.rgba(70, 70, 90, 255)

        self.torso = Entity(parent=self, model='cube', color=cloth,
                            scale=(0.8, 0.9, 0.45), position=(0,0.9,0))
        self.pelvis = Entity(parent=self, model='cube', color=cloth.tint(-.2),
                             scale=(0.7, 0.55, 0.4), position=(0,0.45,0))
        self.head = Entity(parent=self, model='cube', color=skin,
                           scale=(0.42,0.42,0.42), position=(0,1.45,0),
                           collider='box')
        self.head.zombie = self

        self.arm_l = Entity(parent=self, model='cube', color=skin.tint(-.1),
                            scale=(0.18, 0.72, 0.18), position=(-0.55,1.0,0))
        self.arm_r = Entity(parent=self, model='cube', color=skin.tint(-.1),
                            scale=(0.18, 0.72, 0.18), position=(0.55,1.0,0))
        self.leg_l = Entity(parent=self, model='cube', color=cloth.tint(-.1),
                            scale=(0.22, 0.8, 0.22), position=(-0.22,0.1,0))
        self.leg_r = Entity(parent=self, model='cube', color=cloth.tint(-.1),
                            scale=(0.22, 0.8, 0.22), position=(0.22,0.1,0))

        # A "body tag" so raycast hits on root also map to zombie
        self.zombie = self

    def take_damage(self, amount, is_headshot=False, impulse=None):
        self.hp -= float(amount)
        self._last_hurt = pytime.time()
        if is_headshot:
            FloatingText("HEAD!", position=(0,0.18), clr=color.red, parent=camera.ui)

        if impulse is not None:
            # small shove
            self.position += impulse * 0.15

        if self.hp <= 0:
            self.die()

    def die(self):
        # Reward
        reward = 18 + 6*(self.tier-1)
        player.money += reward
        player.gain_xp(26 + 9*(self.tier-1))

        # Drop chance
        if rng.random() < 0.20:
            bt = rng.choice(['speed','damage','regen'])
            amt = {'speed':1.25, 'damage':1.35, 'regen':3.0}[bt]
            dur = {'speed':7.0, 'damage':7.0, 'regen':10.0}[bt]
            BuffPickup(bt, amount=amt, duration=dur, position=self.position + Vec3(0,0.6,0))

        destroy(self)

    def _animate(self):
        t = pytime.time() + self._phase
        swing = math.sin(t * (2.6 + 0.2*self.tier))
        step = math.sin(t * (3.0 + 0.2*self.tier))
        hurt_flash = 1.0 if (pytime.time() - self._last_hurt) < 0.12 else 0.0

        # arms
        self.arm_l.rotation_x = 35 * swing
        self.arm_r.rotation_x = -35 * swing
        # legs
        self.leg_l.rotation_x = -25 * step
        self.leg_r.rotation_x = 25 * step
        # head bob
        self.head.rotation_y = 8 * math.sin(t*1.2)

        if hurt_flash > 0:
            self.head.color = color.rgba(255,120,120,255)
        else:
            self.head.color = color.rgba(90,185,105,255)

    def _steer(self, desired: Vec3) -> Vec3:
        """
        Choose a movement direction that roughly follows desired, but avoids obstacles.
        Strategy:
          - sample multiple directions around desired
          - score by alignment minus collision penalty
        """
        if desired.length() < 0.001:
            desired = Vec3(0,0,0)

        # Build candidate directions
        cand = []
        base = desired if desired.length() > 0.001 else Vec3(1,0,0)
        base = base.normalized()
        # sample angles around base in XZ
        angles = [0, 20, -20, 40, -40, 70, -70, 110, -110, 150, -150, 180]
        bx, bz = base.x, base.z
        for a in angles:
            rad = math.radians(a)
            x = bx*math.cos(rad) - bz*math.sin(rad)
            z = bx*math.sin(rad) + bz*math.cos(rad)
            cand.append(Vec3(x,0,z).normalized())

        best_dir = Vec3(0,0,0)
        best_score = -1e9
        origin = self.position + Vec3(0,0.6,0)

        for d in cand:
            # forward ray
            hit = raycast(origin, d, distance=1.4, ignore=(self,), traverse_target=scene)
            blocked = hit.hit and (hit.entity in map_obstacles)
            free_dist = hit.distance if hit.hit else 1.4

            align = d.dot(base)
            penalty = 0.0
            if blocked:
                penalty += 2.0
            penalty += (1.0 - free_dist/1.4) * 1.2  # closer obstacle => worse

            score = align * 1.8 - penalty
            if score > best_score:
                best_score = score
                best_dir = d

        return best_dir.normalized() if best_dir.length() > 0.001 else Vec3(0,0,0)

    def update(self):
        if game_mode != "play":
            return
        if not player or player.dead:
            return

        self._animate()

        to_p = (player.position - self.position)
        dist = to_p.length()
        desired = to_p.normalized() if dist > 0.001 else Vec3(0,0,0)

        # Separation from nearby zombies
        sep = Vec3(0,0,0)
        for z in zombies:
            if z is self or not z or not z.enabled:
                continue
            d = self.position - z.position
            dl = d.length()
            if 0.001 < dl < 1.3:
                sep += d.normalized() * (1.3 - dl)
        if sep.length() > 0.001:
            sep = sep.normalized()

        # Combine desire + separation
        move_dir = (desired * 1.0 + sep * 0.9).normalized() if (desired.length()+sep.length()) > 0.001 else Vec3(0,0,0)

        # Avoid obstacles via sampling
        move_dir = self._steer(move_dir)

        # Move
        self.position += move_dir * self.speed * time.dt

        # Face player (only yaw)
        self.look_at(player.position)
        self.rotation_x = 0
        self.rotation_z = 0

        # Attack
        if dist < 1.45 and pytime.time() >= self._next_attack:
            self._next_attack = pytime.time() + self.attack_cd
            player.take_damage(self.dmg)

# -------------------- Perks --------------------
PERK_POOL = [
    ("tough", "Toughness", "Max HP +25", "max_hp", 25),
    ("sprinter", "Sprinter", "Speed +8%", "speed_mul", 0.08),
    ("gunner", "Gunner", "Damage +10%", "dmg_mul", 0.10),
    ("quickhands", "Quick Hands", "Reload -10%", "reload_mul", -0.10),
    ("magpouch", "Mag Pouch", "Ammo cap +10% (all)", "cap_mul", 0.10),
    ("headhunter", "Headhunter", "Headshot x +12%", "head_mul", 0.12),
    ("demo", "Demolition", "Grenade dmg +12%", "demo", 1),
]

# -------------------- Player --------------------
class Player(FirstPersonController):
    def __init__(self, save: GameSave, **kwargs):
        super().__init__(**kwargs)
        self.cursor.visible = False
        self.mouse_sensitivity = Vec2(45, 45)

        self.dead = False
        self.level = save.level
        self.xp = save.xp
        self.xp_next = save.xp_next
        self.money = save.money

        self.max_hp = save.max_hp
        self.hp = float(save.hp)
        self.speed_base = float(save.speed_base)
        self.speed = self.speed_base

        self.perks = list(save.perks or [])
        self.perk_levels = {p:0 for p,_,_,_,_ in PERK_POOL}
        for p in self.perks:
            if p in self.perk_levels:
                self.perk_levels[p] += 1

        self.buffs = {}  # temporary buffs
        self.damage_mult_tmp = 1.0
        self.regen_tmp = 0.0

        # Permanent perk multipliers
        self.dmg_mul = 1.0 + 0.10 * self.perk_levels.get("gunner", 0)
        self.speed_mul = 1.0 + 0.08 * self.perk_levels.get("sprinter", 0)
        self.reload_mul = 1.0 + (-0.10) * self.perk_levels.get("quickhands", 0)
        self.cap_mul = 1.0 + 0.10 * self.perk_levels.get("magpouch", 0)
        self.head_mul = 1.0 + 0.12 * self.perk_levels.get("headhunter", 0)

        # Weapons base
        self.weapons = [
            Weapon("Pistol", damage=16, fire_rate=3.6, ammo_cap=12, reload_time=1.1, spread=0.012, pellets=1),
            Weapon("Shotgun", damage=10, fire_rate=1.0, ammo_cap=6, reload_time=1.6, spread=0.06, pellets=7),
            Weapon("Rifle", damage=12, fire_rate=8.5, ammo_cap=30, reload_time=1.4, spread=0.016, pellets=1),
        ]
        self.weapon_i = int(save.weapon_i)
        self.ammo = list(save.ammo or [w.ammo_cap for w in self.weapons])
        self.weapon_upgrades = save.weapon_upgrades or {}
        self.inventory = dict(save.inventory or {"medkit":1, "grenade":1})

        self.reloading = False
        self._reload_end = 0.0
        self._next_shot = 0.0

        # Simple gun model
        self.gun = Entity(parent=camera, model='cube', color=color.gray,
                          scale=(0.18, 0.12, 0.35), position=(0.28,-0.25,0.45),
                          rotation=(0, 200, 0))
        self.gun_barrel = Entity(parent=self.gun, model='cube', color=color.dark_gray,
                                 scale=(0.06, 0.05, 0.28), position=(0,0,0.42))
        self._apply_weapon_visual()

    def _apply_weapon_visual(self):
        w = self.weapon
        if w.name == "Shotgun":
            self.gun.scale = (0.22, 0.13, 0.48)
            self.gun.position = (0.30,-0.26,0.48)
        elif w.name == "Rifle":
            self.gun.scale = (0.18, 0.12, 0.58)
            self.gun.position = (0.30,-0.26,0.50)
        else:
            self.gun.scale = (0.18, 0.12, 0.35)
            self.gun.position = (0.28,-0.25,0.45)

    @property
    def weapon(self):
        return self.weapons[self.weapon_i]

    def set_controls(self, enabled: bool):
        if enabled:
            mouse.locked = True
            mouse.visible = False
            try:
                self.enable()
            except:
                pass
        else:
            mouse.locked = False
            mouse.visible = True
            try:
                self.disable()
            except:
                pass

    def apply_buff(self, buff_type, amount, duration):
        self.buffs[buff_type] = {'amount': float(amount), 'end': pytime.time() + float(duration)}
        FloatingText(f"+{buff_type.upper()}", position=(0,0.28), clr=color.yellow, parent=camera.ui)

    def _update_buffs(self):
        now = pytime.time()
        self.damage_mult_tmp = 1.0
        self.regen_tmp = 0.0

        # speed uses FirstPersonController.speed
        self.speed = self.speed_base * self.speed_mul

        expired = []
        for bt, data in self.buffs.items():
            if now >= data['end']:
                expired.append(bt)
                continue
            if bt == 'speed':
                self.speed *= data['amount']
            elif bt == 'damage':
                self.damage_mult_tmp *= data['amount']
            elif bt == 'regen':
                self.regen_tmp += data['amount']

        for bt in expired:
            del self.buffs[bt]

        if self.regen_tmp > 0 and not self.dead:
            self.hp = clamp(self.hp + self.regen_tmp * time.dt, 0, self.max_hp)

    def take_damage(self, amount):
        if self.dead:
            return
        self.hp -= float(amount)
        if self.hp <= 0:
            self.hp = 0
            self.die()

    def die(self):
        global game_mode
        self.dead = True
        self.gun.disable()
        game_mode = "game_over"
        overlay_game_over.enabled = True
        save_game()

    def gain_xp(self, amount):
        if self.dead:
            return
        self.xp += int(amount)
        while self.xp >= self.xp_next:
            self.xp -= self.xp_next
            self.level += 1
            self.xp_next = xp_to_next(self.level)
            open_perk_choice()

    def switch_weapon(self, idx):
        idx = int(idx)
        if 0 <= idx < len(self.weapons):
            self.weapon_i = idx
            self._apply_weapon_visual()

    def reload(self):
        if self.dead or self.reloading:
            return
        if self.ammo[self.weapon_i] >= self._ammo_cap_effective():
            return
        self.reloading = True
        self._reload_end = pytime.time() + self._reload_time_effective()

    def _finish_reload_if_ready(self):
        if self.reloading and pytime.time() >= self._reload_end:
            self.ammo[self.weapon_i] = self._ammo_cap_effective()
            self.reloading = False

    def _upgrade_stats(self, w: Weapon):
        up = self.weapon_upgrades.get(w.name, {"dmg":0,"fir":0,"cap":0,"rel":0})
        dmg = w.damage * (1.0 + 0.10*up["dmg"])
        fir = w.fire_rate * (1.0 + 0.08*up["fir"])
        cap = int(round(w.ammo_cap * (1.0 + 0.12*up["cap"]) * self.cap_mul))
        rel = w.reload_time * (1.0 - 0.08*up["rel"]) * (1.0 + (self.reload_mul - 1.0))
        return dmg, fir, cap, rel

    def _ammo_cap_effective(self):
        _, _, cap, _ = self._upgrade_stats(self.weapon)
        return max(1, int(cap))

    def _reload_time_effective(self):
        _, _, _, rel = self._upgrade_stats(self.weapon)
        return max(0.35, float(rel))

    def _damage_effective(self):
        dmg, _, _, _ = self._upgrade_stats(self.weapon)
        return float(dmg) * self.dmg_mul * self.damage_mult_tmp

    def _fire_rate_effective(self):
        _, fir, _, _ = self._upgrade_stats(self.weapon)
        return max(0.3, float(fir))

    def _headshot_mult_effective(self):
        # base headshot factor plus perk
        return self.weapon.headshot_mult * self.head_mul

    def shoot(self):
        if self.dead or self.reloading or game_mode != "play":
            return
        if pytime.time() < self._next_shot:
            return
        if self.ammo[self.weapon_i] <= 0:
            self.reload()
            return

        w = self.weapon
        self._next_shot = pytime.time() + (1.0 / self._fire_rate_effective())
        self.ammo[self.weapon_i] -= 1

        # muzzle flash
        flash = Entity(parent=camera, model='quad', color=color.rgba(255,255,220,160),
                       scale=(0.15,0.15), position=(0.0,-0.02,0.67))
        flash.rotation_z = rng.uniform(0, 180)
        destroy(flash, delay=0.05)

        # recoil (tiny)
        camera.rotation_x -= 0.25 if w.name != "Shotgun" else 0.6

        # hitscan pellets
        dmg_base = self._damage_effective()
        for _ in range(w.pellets):
            dir = camera.forward
            # spread
            sp = w.spread
            if sp > 0:
                dir = Vec3(
                    dir.x + rng.uniform(-sp, sp),
                    dir.y + rng.uniform(-sp, sp),
                    dir.z + rng.uniform(-sp, sp),
                ).normalized()

            hit = raycast(camera.world_position, dir, distance=70,
                          ignore=(self, self.gun), traverse_target=scene)

            if hit.hit:
                # impact dot
                dot = Entity(model='sphere', color=color.rgba(255,255,255,180),
                             scale=0.06, position=hit.world_point)
                destroy(dot, delay=0.08)

                ent = hit.entity
                # head collider
                if hasattr(ent, "zombie") and isinstance(ent.zombie, Zombie):
                    z = ent.zombie
                    is_head = (ent is z.head)
                    dmg = dmg_base * (self._headshot_mult_effective() if is_head else 1.0)
                    z.take_damage(dmg, is_headshot=is_head)
                elif isinstance(ent, Zombie):
                    z = ent
                    z.take_damage(dmg_base, is_headshot=False)

    def use_medkit(self):
        if self.dead or game_mode != "play":
            return
        if self.inventory.get("medkit", 0) <= 0:
            return
        self.inventory["medkit"] -= 1
        heal = 55 + 10*self.perk_levels.get("tough", 0)
        self.hp = clamp(self.hp + heal, 0, self.max_hp)
        FloatingText("+HP", position=(0,0.23), clr=color.lime, parent=camera.ui)
        save_game()

    def throw_grenade(self):
        if self.dead or game_mode != "play":
            return
        if self.inventory.get("grenade", 0) <= 0:
            return
        self.inventory["grenade"] -= 1
        start = camera.world_position + camera.forward*0.65 + camera.right*0.18 + Vec3(0,-0.10,0)
        vel = camera.forward*16 + Vec3(0, 6.5, 0)
        Grenade(start, vel, owner=self)
        save_game()

    def input(self, key):
        if key == '1': self.switch_weapon(0)
        if key == '2': self.switch_weapon(1)
        if key == '3': self.switch_weapon(2)
        if key == 'r': self.reload()
        if key == 'h': self.use_medkit()
        if key == 'g': self.throw_grenade()
        if key == 'left mouse down':
            self.shoot()

    def update(self):
        if self.dead:
            return
        if game_mode != "play":
            return

        super().update()
        self._update_buffs()
        self._finish_reload_if_ready()

        # Autofire
        if held_keys['left mouse']:
            self.shoot()

# -------------------- Waves & Spawning --------------------
zombies = []
wave = 1
alive_target = 8

def random_spawn_pos():
    ring = 34
    side = rng.choice(['n','s','e','w'])
    if side == 'n': return Vec3(rng.uniform(-ring, ring), 0, ring)
    if side == 's': return Vec3(rng.uniform(-ring, ring), 0, -ring)
    if side == 'e': return Vec3(ring, 0, rng.uniform(-ring, ring))
    return Vec3(-ring, 0, rng.uniform(-ring, ring))

def spawn_zombie():
    tier = 1 + (wave-1)//4
    pos = random_spawn_pos() + Vec3(0,0.01,0)
    z = Zombie(pos=pos, tier=tier)
    zombies.append(z)

def cleanup_zombies():
    global zombies
    zombies = [z for z in zombies if z and z.enabled]

def ensure_wave_population():
    global alive_target, wave
    cleanup_zombies()
    if player.dead:
        return

    if len(zombies) == 0:
        wave += 1
        alive_target = int(8 + (wave-1)*2.3)
        FloatingText(f"WAVE {wave}", position=(0,0.05), clr=color.violet, parent=camera.ui)
        save_game()

    while len(zombies) < min(alive_target, 44):
        spawn_zombie()

# -------------------- Shop --------------------
SHOP_ITEMS = [
    ("Medkit", "medkit", 35, "Buy 1 medkit"),
    ("Grenade", "grenade", 45, "Buy 1 grenade"),
    ("Ammo (current)", "ammo", 22, "Refill current weapon ammo"),
    ("Upgrade DMG", "up_dmg", 80, "+10% weapon dmg (current)"),
    ("Upgrade FIRE", "up_fir", 80, "+8% weapon fire rate (current)"),
    ("Upgrade RELOAD", "up_rel", 80, "-8% reload time (current)"),
]

def shop_buy(item_key: str):
    if game_mode != "shop" or player.dead:
        return
    # find price
    price = None
    for _, k, p, _ in SHOP_ITEMS:
        if k == item_key:
            price = p
            break
    if price is None or player.money < price:
        FloatingText("NO MONEY", position=(0,0.12), clr=color.red, parent=camera.ui)
        return

    # Purchase logic
    if item_key == "medkit":
        player.inventory["medkit"] = player.inventory.get("medkit", 0) + 1
    elif item_key == "grenade":
        player.inventory["grenade"] = player.inventory.get("grenade", 0) + 1
    elif item_key == "ammo":
        player.ammo[player.weapon_i] = player._ammo_cap_effective()
    elif item_key in ("up_dmg","up_fir","up_rel"):
        wname = player.weapon.name
        if wname not in player.weapon_upgrades:
            player.weapon_upgrades[wname] = {"dmg":0,"fir":0,"cap":0,"rel":0}
        up = player.weapon_upgrades[wname]
        if item_key == "up_dmg": up["dmg"] += 1
        if item_key == "up_fir": up["fir"] += 1
        if item_key == "up_rel": up["rel"] += 1
    else:
        return

    player.money -= price
    FloatingText("BOUGHT", position=(0,0.12), clr=color.lime, parent=camera.ui)
    save_game()

def open_shop():
    global game_mode
    if player.dead:
        return
    game_mode = "shop"
    player.set_controls(False)
    overlay_shop.enabled = True
    overlay_shop_text.enabled = True

def close_shop():
    global game_mode
    overlay_shop.enabled = False
    overlay_shop_text.enabled = False
    game_mode = "play"
    player.set_controls(True)

# -------------------- Perk Choice --------------------
perk_choices = []

def open_perk_choice():
    global game_mode, perk_choices
    if player.dead:
        return
    game_mode = "perk"
    player.set_controls(False)

    # choose 3 random perks (avoid repeating same in 3)
    pool = PERK_POOL[:]
    rng.shuffle(pool)
    perk_choices = pool[:3]

    overlay_perk.enabled = True
    overlay_perk_text.enabled = True

    txt = "LEVEL UP! Choose a perk:\n\n"
    for i, (pid, name, desc, _, _) in enumerate(perk_choices, start=1):
        txt += f"{i}) {name} — {desc}\n"
    txt += "\nPress 1/2/3"
    overlay_perk_text.text = txt

def choose_perk(i: int):
    global game_mode
    if game_mode != "perk" or player.dead:
        return
    i = int(i) - 1
    if i < 0 or i >= len(perk_choices):
        return
    pid, name, desc, stat, val = perk_choices[i]

    # Apply perk effect permanently
    player.perks.append(pid)
    player.perk_levels[pid] = player.perk_levels.get(pid, 0) + 1

    if pid == "tough":
        player.max_hp += int(val)
        player.hp = player.max_hp
    elif pid == "sprinter":
        player.speed_mul *= (1.0 + float(val))
    elif pid == "gunner":
        player.dmg_mul *= (1.0 + float(val))
    elif pid == "quickhands":
        # reload multiplier: negative val makes faster
        player.reload_mul *= (1.0 + float(val))
    elif pid == "magpouch":
        player.cap_mul *= (1.0 + float(val))
        # clamp ammo up to new caps
        for wi in range(len(player.weapons)):
            old = player.ammo[wi]
            player.weapon_i = wi
            player.ammo[wi] = min(max(old, 0), player._ammo_cap_effective())
        # restore selection
    elif pid == "headhunter":
        player.head_mul *= (1.0 + float(val))
    elif pid == "demo":
        # handled in grenade damage calc
        pass

    FloatingText(f"+ {name}", position=(0,0.15), clr=color.cyan, parent=camera.ui)

    overlay_perk.enabled = False
    overlay_perk_text.enabled = False
    game_mode = "play"
    player.set_controls(True)
    save_game()

# -------------------- Save snapshot from player --------------------
def current_save() -> GameSave:
    return GameSave(
        seed=SEED,
        level=player.level,
        xp=player.xp,
        xp_next=player.xp_next,
        money=player.money,
        max_hp=player.max_hp,
        hp=player.hp,
        speed_base=player.speed_base,
        perks=list(player.perks),
        weapon_i=player.weapon_i,
        ammo=list(player.ammo),
        weapon_upgrades=dict(player.weapon_upgrades),
        inventory=dict(player.inventory),
        wave=wave,
    )

# -------------------- UI --------------------
ui_root = Entity(parent=camera.ui)

hp_bg, hp_fill = make_ui_bar(ui_root, y=-0.42, height=0.025)
hp_label = Text(parent=ui_root, text="HP", position=(-0.34,-0.445), scale=0.9, color=color.white)

xp_bg, xp_fill = make_ui_bar(ui_root, y=-0.47, height=0.015)
xp_label = Text(parent=ui_root, text="XP", position=(-0.34,-0.487), scale=0.8, color=color.white)

info_text = Text(parent=ui_root, text="", position=(-0.34, 0.46), origin=(0,0), scale=0.9)
right_text = Text(parent=ui_root, text="", position=(0.30, 0.46), origin=(0,0), scale=0.9)

crosshair = Entity(parent=camera.ui, model='quad', color=color.rgba(255,255,255,180), scale=0.012)

overlay_shop = Entity(parent=camera.ui, model='quad', color=color.rgba(0,0,0,190),
                      scale=(1.25,0.9), enabled=False)
overlay_shop_text = Text(parent=overlay_shop, text="", origin=(0,0), scale=1.05,
                         position=(0,0), enabled=False)

overlay_perk = Entity(parent=camera.ui, model='quad', color=color.rgba(0,0,0,200),
                      scale=(1.25,0.9), enabled=False)
overlay_perk_text = Text(parent=overlay_perk, text="", origin=(0,0), scale=1.05,
                         position=(0,0), enabled=False)

overlay_game_over = Text(parent=camera.ui,
                         text="GAME OVER\nPress ESC to exit\nPress N for new run",
                         origin=(0,0), scale=2, color=color.red, enabled=False)

# -------------------- Global Mode --------------------
game_mode = "play"  # play | shop | perk | game_over

# -------------------- Start / New Run --------------------
def new_run(seed=None, from_save=False):
    global SEED, wave, alive_target, game_mode, zombies
    if seed is None:
        seed = int(pytime.time()) & 0x7fffffff

    SEED = int(seed)
    build_map(SEED)

    # cleanup zombies/buffs/grenades from prev run
    for z in list(zombies):
        if z: destroy(z)
    zombies = []
    for e in scene.entities:
        # best-effort cleanup pickups/grenades (avoid deleting player)
        pass

    wave = 1
    alive_target = 8
    game_mode = "play"

    # reset UI overlays
    overlay_shop.enabled = False
    overlay_shop_text.enabled = False
    overlay_perk.enabled = False
    overlay_perk_text.enabled = False
    overlay_game_over.enabled = False

def init_from_save_or_new():
    global player, wave, alive_target, SEED
    sv = load_save()
    if sv is None:
        # seed from cmd line if present
        seed = None
        if len(sys.argv) >= 2:
            try: seed = int(sys.argv[1])
            except: seed = None
        if seed is None:
            seed = int(pytime.time()) & 0x7fffffff
        sv = default_save(seed)

    SEED = int(sv.seed)
    build_map(SEED)

    # Create player
    p = Player(save=sv, position=(0,1,0))
    p.cursor.visible = False
    p.set_controls(True)

    # Waves
    global wave, alive_target
    wave = int(sv.wave or 1)
    alive_target = int(8 + (wave-1)*2.3)

    return p

# -------------------- Lighting / Atmosphere --------------------
DirectionalLight().look_at(Vec3(1,-2,-1))
AmbientLight(color=color.rgba(120,120,130,255))
scene.fog_density = 0.011
scene.fog_color = color.rgba(40,45,55,255)

player = init_from_save_or_new()

# -------------------- Input (global) --------------------
def input(key):
    global game_mode

    if key == 'escape':
        save_game()
        application.quit()

    if key == 'delete':
        wipe_save()
        FloatingText("SAVE DELETED", position=(0,0.06), clr=color.orange, parent=camera.ui)

    if key == 'n':
        wipe_save()
        # hard restart
        # recreate by reloading python state is overkill; just re-init values
        seed = int(pytime.time()) & 0x7fffffff
        # reset player values to default
        sv = default_save(seed)

        # destroy old player safely
        global player, wave, alive_target
        try:
            destroy(player.gun)
            destroy(player)
        except:
            pass

        build_map(seed)
        player = Player(save=sv, position=(0,1,0))
        player.set_controls(True)

        wave = 1
        alive_target = 8
        for z in list(zombies):
            if z: destroy(z)
        zombies.clear()

        overlay_game_over.enabled = False
        game_mode = "play"
        save_game()
        return

    # Shop toggle
    if key == 'b' and not player.dead:
        if game_mode == "shop":
            close_shop()
        elif game_mode == "play":
            open_shop()

    # Shop purchases
    if game_mode == "shop":
        if key in ('1','2','3','4','5','6'):
            idx = int(key) - 1
            if 0 <= idx < len(SHOP_ITEMS):
                shop_buy(SHOP_ITEMS[idx][1])

    # Perk choice
    if game_mode == "perk":
        if key in ('1','2','3'):
            choose_perk(int(key))

# -------------------- Update Loop --------------------
_last_autosave = 0.0

def update():
    global _last_autosave

    # Autosave periodically while playing
    if game_mode == "play" and (pytime.time() - _last_autosave) > 8.0:
        _last_autosave = pytime.time()
        save_game()

    if game_mode == "play":
        ensure_wave_population()

    # UI update
    if player:
        # bars
        hp_ratio = player.hp / max(1, player.max_hp)
        hp_fill.scale_x = clamp(hp_ratio, 0, 1)
        hp_fill.color = color.lime if hp_ratio > 0.55 else (color.orange if hp_ratio > 0.25 else color.red)

        xp_ratio = player.xp / max(1, player.xp_next)
        xp_fill.scale_x = clamp(xp_ratio, 0, 1)
        xp_fill.color = color.cyan

        # Info
        w = player.weapon
        cap = player._ammo_cap_effective()
        ammo = player.ammo[player.weapon_i]
        buffs = ", ".join([k.upper() for k in player.buffs.keys()]) if player.buffs else "—"

        info_text.text = (
            f"Seed: {SEED}\n"
            f"Wave: {wave}\n"
            f"Level: {player.level}  XP: {player.xp}/{player.xp_next}\n"
            f"Weapon: {w.name}  Ammo: {ammo}/{cap}"
            + ("  (RELOADING)" if player.reloading else "")
            + f"\nBuffs: {buffs}"
        )

        right_text.text = (
            f"Money: ${player.money}\n"
            f"Inv: Medkit={player.inventory.get('medkit',0)}  Grenade={player.inventory.get('grenade',0)}\n"
            f"Perks: {len(player.perks)}"
        )

    # Shop UI text
    if game_mode == "shop":
        lines = ["SHOP (press B to close)\n"]
        for i, (name, key, price, desc) in enumerate(SHOP_ITEMS, start=1):
            lines.append(f"{i}) {name} — ${price}  | {desc}")
        lines.append("\nTip: upgrade applies to CURRENT weapon.")
        overlay_shop_text.text = "\n".join(lines)

    # Game over overlay keeps showing
    if game_mode == "game_over":
        mouse.locked = False
        mouse.visible = True

app.run()
Пропустить Навигационные Ссылки.
Чтобы оставить комментарий нужна авторизация
Печать