# 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()