Text Share Online

hellooo chatgpt

import sys, math, random, time
import numpy as np

import pygame
from pygame.locals import *
from OpenGL.GL import *
from OpenGL.GLU import *

# ── Sound synthesis (no external files needed) ────────────────────────────────
def _make_sound(sample_func, duration=0.3, volume=0.4, rate=22050):
“””Generate a mono pygame Sound from a sample function f(t) -> [-1,1].”””
n = int(rate * duration)
t = np.linspace(0, duration, n, endpoint=False)
wave = np.clip(sample_func(t), -1, 1)
# Fade out last 10%
fade = np.ones(n)
fade_start = int(n * 0.9)
fade[fade_start:] = np.linspace(1, 0, n – fade_start)
wave *= fade * volume
samples = (wave * 32767).astype(np.int16)
stereo = np.column_stack([samples, samples])
return pygame.sndarray.make_sound(stereo)

# ── Initialize Pygame and create a resizable OpenGL window ─────────────────
pygame.init()

# Initial window size
width, height = 800, 600

# Make window resizable (maximize button will now work)
screen = pygame.display.set_mode((width, height), DOUBLEBUF | OPENGL | RESIZABLE)
pygame.display.set_caption(“Penguin Knockout”)

# OpenGL setup function
def setup_opengl(w, h):
glViewport(0, 0, w, h)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
gluPerspective(45, w / h, 0.1, 50.0)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()

setup_opengl(width, height)

# ── Main loop ────────────────────────────────────────────────────────────────
running = True
while running:
for event in pygame.event.get():
if event.type == QUIT:
running = False
elif event.type == VIDEORESIZE:
# Update window size and OpenGL viewport
width, height = event.size
screen = pygame.display.set_mode((width, height), DOUBLEBUF | OPENGL | RESIZABLE)
setup_opengl(width, height)

# Clear screen
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

# — Your game rendering goes here —
# Replace this comment with your penguin game rendering logic

pygame.display.flip()
pygame.time.wait(10)

pygame.quit()
sys.exit()

# ── Sound synthesis (no external files needed) ────────────────────────────────
def _make_sound(sample_func, duration=0.3, volume=0.4, rate=22050):
“””Generate a mono pygame Sound from a sample function f(t) -> [-1,1].”””
n = int(rate * duration)
t = np.linspace(0, duration, n, endpoint=False)
wave = np.clip(sample_func(t), -1, 1)
# Fade out last 10%
fade = np.ones(n)
fade_start = int(n * 0.9)
fade[fade_start:] = np.linspace(1, 0, n – fade_start)
wave *= fade * volume
samples = (wave * 32767).astype(np.int16)
stereo = np.column_stack([samples, samples])
return pygame.sndarray.make_sound(stereo)

def build_sounds():
“””Build and return a dict of all game sounds.”””
sounds = {}
# Launch whoosh – descending noise burst
def whoosh(t):
return (np.random.rand(len(t)) * 2 – 1) * np.exp(-t * 6) * np.sin(2*np.pi*(300 – 200*t)*t)
sounds[“launch”] = _make_sound(whoosh, duration=0.35, volume=0.55)

# Collision thud – low thump
def thud(t):
return np.sin(2*np.pi*80*t) * np.exp(-t*18)
sounds[“hit”] = _make_sound(thud, duration=0.25, volume=0.7)

# Power change click – short tick
def tick(t):
return np.sin(2*np.pi*900*t) * np.exp(-t*60)
sounds[“tick”] = _make_sound(tick, duration=0.06, volume=0.4)

# Lock in confirm – rising two-tone
def lock(t):
return (np.sin(2*np.pi*440*t) * 0.5 + np.sin(2*np.pi*660*t) * 0.5) * np.exp(-t*5)
sounds[“lock”] = _make_sound(lock, duration=0.3, volume=0.55)

# Countdown beep – short sine blip
def beep(t):
return np.sin(2*np.pi*520*t) * np.exp(-t*12)
sounds[“beep”] = _make_sound(beep, duration=0.18, volume=0.45)

# Go! beep – higher pitch
def beep_go(t):
return np.sin(2*np.pi*880*t) * np.exp(-t*10)
sounds[“beep_go”] = _make_sound(beep_go, duration=0.22, volume=0.55)

# Knocked out – low sad descend
def knocked(t):
freq = 300 * np.exp(-t * 3)
return np.sin(2*np.pi*freq*t) * np.exp(-t*4)
sounds[“knocked”] = _make_sound(knocked, duration=0.6, volume=0.6)

# Win fanfare – cheerful ascending arp
def win(t):
notes = [261, 329, 392, 523]
out = np.zeros_like(t)
seg = len(t) // len(notes)
for i, freq in enumerate(notes):
sl = slice(i*seg, (i+1)*seg)
tt = t[sl] – t[sl][0]
out[sl] = np.sin(2*np.pi*freq*tt) * np.exp(-tt*5)
return out
sounds[“win”] = _make_sound(win, duration=0.7, volume=0.6)

# Sliding scrape – filtered noise (ice)
def scrape(t):
return (np.random.rand(len(t))*2-1) * np.exp(-t*3) * np.sin(2*np.pi*150*t)
sounds[“slide”] = _make_sound(scrape, duration=0.5, volume=0.3)

# Water splash – layered noise burst with low rumble
def splash(t):
noise = (np.random.rand(len(t))*2-1) * np.exp(-t*5)
bubble = np.sin(2*np.pi*120*t) * np.exp(-t*8) * 0.4
low = np.sin(2*np.pi*60*t) * np.exp(-t*3) * 0.3
return noise + bubble + low
sounds[“splash”] = _make_sound(splash, duration=0.8, volume=0.75)

return sounds

SFX = {} # filled in main() after pygame.mixer.init

# ── Tunables ─────────────────────────────────────────────────────────────────
SCREEN_W, SCREEN_H = 1024, 720
FPS = 60
PENGUIN_COLORS = [
(0.18, 0.18, 0.18), # player – overridden by save
(0.85, 0.18, 0.18), # red
(0.18, 0.60, 0.92), # blue
(0.20, 0.82, 0.35), # green
(0.92, 0.55, 0.10), # orange
(0.75, 0.20, 0.80), # purple
(0.90, 0.85, 0.15), # yellow
(0.15, 0.80, 0.75), # teal
(0.90, 0.35, 0.60), # pink
(0.55, 0.35, 0.15), # brown
(0.50, 0.50, 0.55), # silver
]

BOT_NAMES = [
“Waddles”,”Chilly”,”Flipper”,”Iceberg”,”Blizzard”,”Snowball”,
“Frosty”,”Tuxedo”,”Pengsworth”,”Slippy”,”Arctic”,”Glacier”,
“Puddle”,”Brrr”,”Nibbles”,”Colonel”,”Pebble”,”Drifty”,
“Slushy”,”Icecap”,”Shiver”,”Chomper”,”Biscuit”,”Toboggan”,
]
BELLY_COLOR = (1.0, 1.0, 1.0)
BEAK_COLOR = (1.0, 0.75, 0.0)
EYE_COLOR = (1.0, 1.0, 1.0)
PUPIL_COLOR = (0.0, 0.0, 0.0)

PLATFORM_SHRINK_INTERVAL = 3 # shrink every N rounds
PLATFORM_SHRINK_AMOUNT = 1.8
PLATFORM_MIN_SIZE = 6.0

LAUNCH_MIN_SPEED = 6.0
LAUNCH_MAX_SPEED = 22.0

GRAVITY = -18.0
BOUNCE_DAMP = 0.35
FRICTION = 0.72
SLIDE_DAMP = 0.985

AIM_TIME = 5.0 # seconds for the aiming phase
SETTLE_SPEED = 0.4 # penguins below this speed are “stopped”

ARENA_COLOR = (0.05, 0.25, 0.55) # deep ocean blue (unused directly)
# Ice platform
PLATFORM_COLOR = (0.72, 0.92, 1.00) # pale icy blue-white
PLATFORM_EDGE_CLR = (0.50, 0.78, 0.95) # slightly deeper ice edge
ICE_LINE_COLOR = (0.85, 0.97, 1.00) # bright crack/highlight lines
# Sky / ocean
SKY_TOP = (0.28, 0.52, 0.82) # arctic sky blue
SKY_BOTTOM = (0.55, 0.80, 0.95) # horizon haze
# Water surface (drawn as a big flat quad at y=-0.02)
WATER_COLOR_NEAR = (0.05, 0.30, 0.65)
WATER_COLOR_FAR = (0.02, 0.18, 0.45)

# ── Math helpers ──────────────────────────────────────────────────────────────
def clamp(v, lo, hi): return max(lo, min(hi, v))
def v3len(v): return math.sqrt(v[0]**2 + v[1]**2 + v[2]**2)
def v3norm(v):
l = v3len(v)
return (v[0]/l, v[1]/l, v[2]/l) if l > 1e-6 else (0,0,0)

# ── OpenGL helpers ────────────────────────────────────────────────────────────
def gl_sphere(r, slices=24, stacks=16):
quad = gluNewQuadric()
gluQuadricNormals(quad, GLU_SMOOTH)
gluSphere(quad, r, slices, stacks)
gluDeleteQuadric(quad)

def gl_cylinder(r1, r2, h, slices=20):
quad = gluNewQuadric()
gluQuadricNormals(quad, GLU_SMOOTH)
gluCylinder(quad, r1, r2, h, slices, 1)
gluDeleteQuadric(quad)

def set_material(color, shininess=60, spec_strength=0.5):
shininess = min(shininess, 128)
# Set both glColor (for COLOR_MATERIAL) and explicit material params
glColor3f(*color)
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, [*color, 1.0])
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, [spec_strength]*3 + [1.0])
glMaterialf (GL_FRONT_AND_BACK, GL_SHININESS, shininess)

def draw_penguin(color, wobble=0.0, knocked=False):
“””High-quality penguin with smooth normals and specular highlights.”””
glPushMatrix()
if knocked:
glRotatef(90, 1, 0, 0)
else:
glRotatef(math.degrees(wobble) * 8, 0, 0, 1)

# Body
glPushMatrix()
glScalef(0.55, 0.65, 0.50)
set_material(color, shininess=80, spec_strength=0.35)
gl_sphere(1.0, 24, 16)
glPopMatrix()

# Belly patch
glPushMatrix()
glTranslatef(0, 0, 0.30)
glScalef(0.32, 0.45, 0.25)
set_material(BELLY_COLOR, shininess=40, spec_strength=0.6)
gl_sphere(1.0, 20, 14)
glPopMatrix()

# Head
glPushMatrix()
glTranslatef(0, 0.72, 0)
glScalef(0.42, 0.42, 0.40)
set_material(color, shininess=80, spec_strength=0.35)
gl_sphere(1.0, 20, 14)
glPopMatrix()

# Eyes (white + pupil)
for sx in (-1, 1):
glPushMatrix()
glTranslatef(sx * 0.17, 0.82, 0.28)
glScalef(0.10, 0.10, 0.08)
set_material(EYE_COLOR, shininess=120, spec_strength=0.9)
gl_sphere(1.0, 14, 10)
glPopMatrix()
glPushMatrix()
glTranslatef(sx * 0.175, 0.825, 0.345)
glScalef(0.055, 0.055, 0.05)
set_material(PUPIL_COLOR, shininess=140, spec_strength=1.0)
gl_sphere(1.0, 10, 8)
glPopMatrix()

# Beak
glPushMatrix()
glTranslatef(0, 0.72, 0.44)
set_material(BEAK_COLOR, shininess=50, spec_strength=0.4)
glScalef(1.1, 0.5, 1.0)
gl_sphere(0.10, 14, 10)
glPopMatrix()

# Flippers
for sx in (-1, 1):
glPushMatrix()
glTranslatef(sx * 0.55, 0.08, 0)
glRotatef(sx * 30, 0, 0, 1)
glScalef(0.15, 0.40, 0.10)
set_material(color, shininess=60, spec_strength=0.25)
gl_sphere(1.0, 14, 10)
glPopMatrix()

# Feet
for sx in (-1, 1):
glPushMatrix()
glTranslatef(sx * 0.18, -0.68, 0.12)
set_material(BEAK_COLOR, shininess=30, spec_strength=0.2)
glScalef(0.18, 0.10, 0.28)
gl_sphere(1.0, 12, 8)
glPopMatrix()

glPopMatrix()

# ── Platform ──────────────────────────────────────────────────────────────────
class Platform:
def __init__(self, size=20.0):
self.size = size
self.target = size
self.y = 0.0 # top surface y

def shrink(self, amount):
self.target = max(PLATFORM_MIN_SIZE, self.size – amount)

def update(self, dt):
if self.size > self.target:
self.size = max(self.target, self.size – 4.0 * dt)

def draw(self):
h = 0.8
s = self.size / 2

# Disable COLOR_MATERIAL so our explicit glMaterialfv calls take effect
glDisable(GL_COLOR_MATERIAL)

# Ice top surface
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, [*PLATFORM_COLOR, 1.0])
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, [0.9, 0.97, 1.0, 1.0])
glMaterialf (GL_FRONT_AND_BACK, GL_SHININESS, 110)
glNormal3f(0, 1, 0)
glBegin(GL_QUADS)
glVertex3f(-s, 0, s)
glVertex3f( s, 0, s)
glVertex3f( s, 0, -s)
glVertex3f(-s, 0, -s)
glEnd()

# Ice crack lines (no lighting)
glDisable(GL_LIGHTING)
glLineWidth(1.2)
glColor3f(*ICE_LINE_COLOR)
glBegin(GL_LINES)
step = max(2.0, self.size / 6)
x = -s + step
while x < s:
glVertex3f(x, 0.01, s); glVertex3f(x, 0.01, -s)
x += step
z = -s + step
while z < s:
glVertex3f(-s, 0.01, z); glVertex3f(s, 0.01, z)
z += step
glVertex3f(-s*0.9, 0.01, -s*0.3); glVertex3f(s*0.2, 0.01, s*0.7)
glVertex3f( s*0.6, 0.01, -s*0.8); glVertex3f(-s*0.3, 0.01, s*0.5)
glVertex3f(-s*0.4, 0.01, s*0.1); glVertex3f( s*0.5, 0.01, -s*0.4)
glEnd()
glLineWidth(1.0)
glEnable(GL_LIGHTING)

# Ice block sides
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, [*PLATFORM_EDGE_CLR, 1.0])
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, [0.5, 0.7, 0.9, 1.0])
glMaterialf (GL_FRONT_AND_BACK, GL_SHININESS, 60)
sides = [
((-s,s),(s,s), (0,0,1)),
((s,s),(s,-s), (1,0,0)),
((s,-s),(-s,-s),(0,0,-1)),
((-s,-s),(-s,s),(-1,0,0)),
]
glBegin(GL_QUADS)
for (x1,z1),(x2,z2),(nx,ny,nz) in sides:
glNormal3f(nx, ny, nz)
glVertex3f(x1, 0, z1)
glVertex3f(x2, 0, z2)
glVertex3f(x2, -h, z2)
glVertex3f(x1, -h, z1)
glEnd()

# Restore COLOR_MATERIAL for everything else
glEnable(GL_COLOR_MATERIAL)

def in_bounds(self, x, z):
s = self.size / 2
return -s <= x <= s and -s <= z <= s

def draw_water(cam_px, cam_pz):
“””Draw a large animated water plane around the ice platform.”””
t = time.time()
W = 120.0 # water extent each side
wy = -0.15 # water surface Y

glDisable(GL_LIGHTING)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

# Animate with a subtle wave shimmer via vertex color variation
glBegin(GL_QUADS)
def wcol(x, z):
wave = 0.04 * math.sin(x * 0.4 + t * 1.5) + 0.03 * math.cos(z * 0.35 + t * 1.1)
r = clamp(WATER_COLOR_NEAR[0] + wave, 0, 1)
g = clamp(WATER_COLOR_NEAR[1] + wave, 0, 1)
b = clamp(WATER_COLOR_NEAR[2] + wave * 0.5, 0, 1)
return r, g, b

step = 8.0
x = -W
while x < W:
z = -W
while z < W:
glColor4f(*wcol(x, z ), 0.88); glVertex3f(x, wy, z )
glColor4f(*wcol(x+step,z ), 0.88); glVertex3f(x+step, wy, z )
glColor4f(*wcol(x+step,z+step),0.88); glVertex3f(x+step, wy, z+step )
glColor4f(*wcol(x, z+step),0.88); glVertex3f(x, wy, z+step )
z += step
x += step
glEnd()

# Specular glint dots
glPointSize(3.0)
glBegin(GL_POINTS)
rng = random.Random(int(t * 2)) # slowly changing seed
for _ in range(40):
gx = rng.uniform(-W, W)
gz = rng.uniform(-W, W)
brightness = 0.6 + 0.4 * math.sin(gx * 0.7 + t * 3)
glColor4f(brightness, brightness, 1.0, 0.7)
glVertex3f(gx, wy + 0.02, gz)
glEnd()
glPointSize(1.0)

glDisable(GL_BLEND)
glEnable(GL_LIGHTING)

# ── Penguin entity ────────────────────────────────────────────────────────────
class PenguinEntity:
RADIUS = 0.55

def __init__(self, x, z, color, is_player=False):
self.color = color
self.is_player = is_player
self.name = “???”
self.pos = [x, 1.0, z]
self.vel = [0.0, 0.0, 0.0]
self.alive = True
self.knocked = False
self.splashed = False # water splash already played?
self.wobble = 0.0
self.yaw = 0.0
self.on_ground = True
self.chosen_yaw = 0.0
self.chosen_power = 0.5
self._just_knocked = False
self.accessory = None # hat id string, set after spawn

# Physics ─────────────────────────────────────────
def update(self, dt, platform):
if not self.alive:
return

if not self.on_ground:
self.vel[1] += GRAVITY * dt

self.pos[0] += self.vel[0] * dt
self.pos[1] += self.vel[1] * dt
self.pos[2] += self.vel[2] * dt

if self.pos[1] <= 1.0:
self.pos[1] = 1.0
if self.vel[1] < -1.0:
self.vel[1] = -self.vel[1] * BOUNCE_DAMP
else:
self.vel[1] = 0.0
self.on_ground = True
self.vel[0] *= FRICTION
self.vel[2] *= FRICTION
else:
self.on_ground = False

self.vel[0] *= SLIDE_DAMP
self.vel[2] *= SLIDE_DAMP

# Wobble proportional to speed
spd = math.sqrt(self.vel[0]**2 + self.vel[2]**2)
self.wobble = math.sin(time.time() * 8) * clamp(spd / 10.0, 0, 1)

# Update visual yaw to face velocity direction while moving
if spd > 0.5:
self.yaw = math.degrees(math.atan2(self.vel[0], self.vel[2]))

# Fall off platform? → knocked, then sink into water
if not platform.in_bounds(self.pos[0], self.pos[2]):
if self.pos[1] <= 1.0 and not self.knocked:
self.knocked = True
self._just_knocked = True # signal for coin award
# Hit the water surface → splash sound once
if self.knocked and not self.splashed and self.pos[1] <= -0.1:
self.splashed = True
SFX.get(“splash”) and SFX[“splash”].play()
# Sink and die when deep enough
if self.pos[1] < -6.0:
self.alive = False

def is_settled(self):
spd = math.sqrt(self.vel[0]**2 + self.vel[2]**2)
return self.on_ground and spd < SETTLE_SPEED

def launch(self):
“””Launch using stored chosen_yaw / chosen_power.”””
rad = math.radians(self.chosen_yaw)
speed = LAUNCH_MIN_SPEED + self.chosen_power * (LAUNCH_MAX_SPEED – LAUNCH_MIN_SPEED)
self.vel[0] = math.sin(rad) * speed
self.vel[1] = speed * 0.25
self.vel[2] = math.cos(rad) * speed
self.on_ground = False
self.yaw = self.chosen_yaw # snap face direction at launch moment

def bot_choose(self, penguins, diff_mult):
“””Silently pick a target direction & power (called during aiming phase).”””
others = [p for p in penguins if p is not self and p.alive and not p.knocked]
if others:
tgt = min(others, key=lambda p: (p.pos[0]-self.pos[0])**2+(p.pos[2]-self.pos[2])**2)
dx = tgt.pos[0] – self.pos[0]
dz = tgt.pos[2] – self.pos[2]
noise = random.uniform(-25, 25) * (1.0 – 0.3 * diff_mult)
self.chosen_yaw = math.degrees(math.atan2(dx, dz)) + noise
self.chosen_power = random.uniform(0.4, 0.95)
else:
self.chosen_yaw = random.uniform(0, 360)
self.chosen_power = 0.5

# Collision with another penguin ──────────────────
def collide(self, other):
dx = self.pos[0] – other.pos[0]
dz = self.pos[2] – other.pos[2]
dist = math.sqrt(dx*dx + dz*dz)
min_d = self.RADIUS + other.RADIUS
if dist < min_d and dist > 1e-4:
nx, nz = dx/dist, dz/dist
rv = (self.vel[0]-other.vel[0])*nx + (self.vel[2]-other.vel[2])*nz
if rv < 0:
imp = -rv * 0.8
self.vel[0] += nx * imp
self.vel[2] += nz * imp
other.vel[0] -= nx * imp
other.vel[2] -= nz * imp
overlap = (min_d – dist) / 2
self.pos[0] += nx * overlap
self.pos[2] += nz * overlap
other.pos[0] -= nx * overlap
other.pos[2] -= nz * overlap

# Draw ────────────────────────────────────────────
def draw(self):
if not self.alive:
return

# Soft blob shadow on ice (only when on/near the platform)
if self.pos[1] < 2.5 and not self.knocked:
sx, sz = self.pos[0], self.pos[2]
shadow_y = 0.015
r_shadow = 0.55 + (self.pos[1] – 1.0) * 0.1
glDisable(GL_LIGHTING)
glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glColor4f(0.0, 0.05, 0.15, 0.30)
segs = 18
glBegin(GL_TRIANGLE_FAN)
glVertex3f(sx, shadow_y, sz)
for i in range(segs + 1):
a = 2 * math.pi * i / segs
glVertex3f(sx + math.cos(a)*r_shadow, shadow_y, sz + math.sin(a)*r_shadow*0.4)
glEnd()
glDisable(GL_BLEND)
glEnable(GL_LIGHTING)

glPushMatrix()
glTranslatef(*self.pos)
glRotatef(-self.yaw, 0, 1, 0)
draw_penguin(self.color, self.wobble, self.knocked)
if self.accessory and not self.knocked:
_draw_accessory(self.accessory)
glPopMatrix()

# ── Arrow indicator ───────────────────────────────────────────────────────────
def draw_arrow(yaw_deg, power):
“””Draw a flat arrow on the ground in front of the player.”””
rad = math.radians(yaw_deg)
length = 1.5 + power * 3.0
dx, dz = math.sin(rad), math.cos(rad)
rx, rz = math.cos(rad) * 0.18, -math.sin(rad) * 0.18

tip = (dx*length, 1.02, dz*length)
bl = (-rx + dx*0.2, 1.02, -rz + dz*0.2)
br = ( rx + dx*0.2, 1.02, rz + dz*0.2)

# Pulsing alpha
pulse = 0.55 + 0.45 * math.sin(time.time() * 5)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glDisable(GL_DEPTH_TEST)
glBegin(GL_TRIANGLES)
glColor4f(1.0, 0.9, 0.1, pulse)
glVertex3f(*tip)
glVertex3f(*bl)
glVertex3f(*br)
glEnd()
# Shaft
sw = 0.07
glBegin(GL_QUADS)
glColor4f(1.0, 0.9, 0.1, pulse * 0.7)
glVertex3f(-rx*sw, 1.02, -rz*sw )
glVertex3f( rx*sw, 1.02, rz*sw )
glVertex3f( rx*sw+dx*0.2,1.02, rz*sw+dz*0.2)
glVertex3f(-rx*sw+dx*0.2,1.02, -rz*sw+dz*0.2)
glEnd()
glEnable(GL_DEPTH_TEST)
glDisable(GL_BLEND)

# ── Sky background ────────────────────────────────────────────────────────────
def draw_sky():
“””Rich 3-band arctic sky gradient: deep blue top → pale horizon → warm haze.”””
glMatrixMode(GL_PROJECTION); glPushMatrix(); glLoadIdentity()
glOrtho(0, 1, 0, 1, -1, 1)
glMatrixMode(GL_MODELVIEW); glPushMatrix(); glLoadIdentity()
glDisable(GL_DEPTH_TEST)
glBegin(GL_QUADS)
# Bottom – warm horizon haze
glColor3f(0.78, 0.91, 1.00); glVertex2f(0, 0); glVertex2f(1, 0)
# Mid – pale arctic blue
glColor3f(0.52, 0.78, 0.97); glVertex2f(1, 0.45); glVertex2f(0, 0.45)
glEnd()
glBegin(GL_QUADS)
glColor3f(0.52, 0.78, 0.97); glVertex2f(0, 0.45); glVertex2f(1, 0.45)
# Top – deep polar sky
glColor3f(0.18, 0.42, 0.80); glVertex2f(1, 1); glVertex2f(0, 1)
glEnd()
glEnable(GL_DEPTH_TEST)
glPopMatrix()
glMatrixMode(GL_PROJECTION); glPopMatrix()
glMatrixMode(GL_MODELVIEW)

# ── 2D HUD helpers ────────────────────────────────────────────────────────────
def begin_2d():
glMatrixMode(GL_PROJECTION); glPushMatrix(); glLoadIdentity()
glOrtho(0, SCREEN_W, SCREEN_H, 0, -1, 1)
glMatrixMode(GL_MODELVIEW); glPushMatrix(); glLoadIdentity()
glDisable(GL_DEPTH_TEST)

def end_2d():
glPopMatrix(); glMatrixMode(GL_PROJECTION); glPopMatrix()
glMatrixMode(GL_MODELVIEW)
glEnable(GL_DEPTH_TEST)

def draw_rect_2d(x, y, w, h, color, alpha=1.0):
glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glColor4f(*color, alpha)
glBegin(GL_QUADS)
glVertex2f(x,y); glVertex2f(x+w,y); glVertex2f(x+w,y+h); glVertex2f(x,y+h)
glEnd()
glDisable(GL_BLEND)

def render_text(font, text, x, y, color=(255,255,255), surface=None):
“””Blit pygame text onto a temporary surface, upload as texture.”””
surf = font.render(text, True, color)
tw, th = surf.get_size()
data = pygame.image.tostring(surf, “RGBA”, True)
tex = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, tex)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, tw, th, 0, GL_RGBA, GL_UNSIGNED_BYTE, data)
glEnable(GL_TEXTURE_2D); glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glColor4f(1,1,1,1)
glBegin(GL_QUADS)
glTexCoord2f(0,0); glVertex2f(x, y+th)
glTexCoord2f(1,0); glVertex2f(x+tw, y+th)
glTexCoord2f(1,1); glVertex2f(x+tw, y )
glTexCoord2f(0,1); glVertex2f(x, y )
glEnd()
glDisable(GL_TEXTURE_2D); glDisable(GL_BLEND)
glDeleteTextures([tex])

# ── Save system ───────────────────────────────────────────────────────────────
import os, json

# When frozen by PyInstaller, save next to the .exe, not inside the temp bundle
if getattr(sys, ‘frozen’, False):
_BASE_DIR = os.path.dirname(sys.executable)
else:
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SAVES_DIR = os.path.join(_BASE_DIR, “saves”)
SAVE_VERSION = 2 # bump this to invalidate old saves
_SAVE_MAGIC = b”PKO2″ # 4-byte header for v2 files

import zlib, base64, struct

# ── Localisation ──────────────────────────────────────────────────────────────
# Keys are English strings used as fallback. Each language dict only needs to
# override keys it translates — missing keys fall back to English.
_TRANSLATIONS = {
“en”: {}, # English is the fallback — empty dict = use key as-is
“fr”: {
# Menus
“PENGUIN KNOCKOUT 3D”:”PINGOUIN KNOCKOUT 3D”,
“Last penguin standing wins!”:”Dernier pingouin debout gagne !”,
“PLAY”:”JOUER”,”SETTINGS”:”PARAMÈTRES”,”CUSTOMIZE”:”PERSONNALISER”,
“SHOP”:”BOUTIQUE”,”EXIT”:”QUITTER”,
“↑↓ navigate ENTER select”:”↑↓ naviguer ENTRÉE sélectionner”,
# Account screen
“Select Account”:”Choisir un compte”,
“+ New Account”:”+ Nouveau compte”,
“── or load existing ──”:”── ou charger existant ──”,
“No saves found. Create one!”:”Aucune sauvegarde. Créez-en une !”,
“Enter your name:”:”Entrez votre nom :”,
“ENTER = confirm ESC = back”:”ENTRÉE = confirmer ÉCHAP = retour”,
“Name cannot be empty!”:”Le nom ne peut pas être vide !”,
“That name already exists!”:”Ce nom existe déjà !”,
“Name too long (max 16)!”:”Nom trop long (max 16) !”,
“Old save format detected!”:”Format de sauvegarde obsolète !”,
“This file must be converted to”:”Ce fichier doit être converti”,
“the new secure format to play.”:”au nouveau format sécurisé.”,
“ENTER = Convert & Play”:”ENTRÉE = Convertir & Jouer”,
“ESC = Cancel”:”ÉCHAP = Annuler”,
“[OLD FORMAT]”:”[ANCIEN FORMAT]”,
“formatted successfully!”:”converti avec succès !”,
“Format failed:”:”Échec de conversion :”,
# Settings
“SETTINGS”:”PARAMÈTRES”,
“Bots:”:”Bots :”,”Difficulty:”:”Difficulté :”,
“Easy”:”Facile”,”Normal”:”Normal”,”Hard”:”Difficile”,
“FPS Limit:”:”Limite FPS :”,”Unlimited”:”Illimité”,
“Invert Mouse:”:”Inverser souris :”,”ON”:”OUI”,”OFF”:”NON”,
“Language:”:”Langue :”,”BACK”:”RETOUR”,
# Gamemode
“SELECT MODE”:”SÉLECTIONNER MODE”,
“⚡ ARCADE”:”⚡ ARCADE”,”🏆 WORLD CUP”:”🏆 COUPE DU MONDE”,
“Win rounds to keep playing”:”Gagnez les manches pour continuer”,
“Win to earn +50 bonus coins!”:”Gagnez +50 pièces bonus !”,
“~100 penguins • Massive arena • 1 round only”:”~100 pingouins • Grande arène • 1 manche”,
“Classic shrinking platform”:”Plateforme classique qui rétrécit”,
# Shop
“SHOP”:”BOUTIQUE”,”Coins:”:”Pièces :”,”Earn 5 coins per knockout”:”Gagnez 5 pièces par élimination”,
“🎩 Hats”:”🎩 Chapeaux”,”🎨 Colors”:”🎨 Couleurs”,
“Unlock extra penguin colors:”:”Débloquez des couleurs supplémentaires :”,
“Buy”:”Acheter”,”EQUIP”:”ÉQUIPER”,”UNEQUIP”:”DÉSÉQUIPER”,”✓ Owned”:”✓ Possédé”,
“Cowboy Hat”:”Chapeau de cowboy”,”Top Hat”:”Chapeau haut de forme”,
“Party Hat”:”Chapeau de fête”,”Crown”:”Couronne”,”Santa Hat”:”Chapeau de Noël”,
# Customize
“CUSTOMIZE”:”PERSONNALISER”,”Colors”:”Couleurs”,”Accessories”:”Accessoires”,
“Preview”:”Aperçu”,”Selected:”:”Sélectionné :”,”Buy more colors in Shop”:”Achetez des couleurs en boutique”,
“Buy in Shop”:”Acheter en boutique”,”✓ ON”:”✓ ACTIVÉ”,”equip”:”équiper”,
# In-game HUD
“Round”:”Manche”,”Penguins left:”:”Pingouins restants :”,
“Ice size”:”Taille de la glace”,”GO!”:”GO !”,
“Aim locked! Waiting for others…”:”Visée verrouillée ! En attente…”,
“Power:”:”Puissance :”,”Mouse = aim”:”Souris = visée”,
“Q / E = power -/+5%”:”Q / E = puissance -/+5%”,
“R = lock in aim”:”R = verrouiller la visée”,
“Sliding…”:”Glissement…”,”FREE CAM”:”CAMÉRA LIBRE”,
“SPECTATING BOT”:”SPECTATEUR BOT”,
“WASD = move SPACE/SHIFT = up/down F = exit freecam Q = leave”:
“WASD = déplacer ESPACE/MAJ = haut/bas F = quitter caméra Q = partir”,
“TAB = next bot F = free camera Q = leave spectate”:
“TAB = bot suivant F = caméra libre Q = quitter”,
“ROUND CLEARED!”:”MANCHE TERMINÉE !”,”Next round starting…”:”Prochaine manche…”,
“WORLD CUP WINNER!”:”VAINQUEUR DE LA COUPE !”,”+50 coins rewarded!”:”+50 pièces récompensées !”,
“YOU WIN!”:”VOUS GAGNEZ !”,”KNOCKED OUT!”:”ÉLIMINÉ !”,
“Press R to restart | ESC for menu”:”R = rejouer | ÉCHAP = menu”,
“R = restart | ESC = menu”:”R = rejouer | ÉCHAP = menu”,
“You”:”Vous”,
“Account:”:”Compte :”,
},
“es”: {
“PENGUIN KNOCKOUT 3D”:”PINGÜINO KNOCKOUT 3D”,
“Last penguin standing wins!”:”¡El último pingüino en pie gana!”,
“PLAY”:”JUGAR”,”SETTINGS”:”AJUSTES”,”CUSTOMIZE”:”PERSONALIZAR”,
“SHOP”:”TIENDA”,”EXIT”:”SALIR”,
“↑↓ navigate ENTER select”:”↑↓ navegar ENTER seleccionar”,
“Select Account”:”Seleccionar cuenta”,”+ New Account”:”+ Nueva cuenta”,
“── or load existing ──”:”── o cargar existente ──”,
“No saves found. Create one!”:”Sin guardados. ¡Crea uno!”,
“Enter your name:”:”Ingresa tu nombre:”,
“ENTER = confirm ESC = back”:”ENTER = confirmar ESC = volver”,
“Name cannot be empty!”:”¡El nombre no puede estar vacío!”,
“That name already exists!”:”¡Ese nombre ya existe!”,”Name too long (max 16)!”:”Nombre demasiado largo (máx 16)!”,
“Old save format detected!”:”¡Formato de guardado obsoleto!”,
“This file must be converted to”:”Este archivo debe convertirse”,
“the new secure format to play.”:”al nuevo formato seguro.”,
“ENTER = Convert & Play”:”ENTER = Convertir y Jugar”,”ESC = Cancel”:”ESC = Cancelar”,
“[OLD FORMAT]”:”[FORMATO ANTIGUO]”,”formatted successfully!”:”¡convertido con éxito!”,
“SETTINGS”:”AJUSTES”,”Bots:”:”Bots:”,”Difficulty:”:”Dificultad:”,
“Easy”:”Fácil”,”Normal”:”Normal”,”Hard”:”Difícil”,
“FPS Limit:”:”Límite FPS:”,”Unlimited”:”Ilimitado”,
“Invert Mouse:”:”Invertir ratón:”,”ON”:”SÍ”,”OFF”:”NO”,
“Language:”:”Idioma:”,”BACK”:”ATRÁS”,
“SELECT MODE”:”SELECCIONAR MODO”,”⚡ ARCADE”:”⚡ ARCADE”,”🏆 WORLD CUP”:”🏆 COPA MUNDIAL”,
“Win rounds to keep playing”:”Gana rondas para continuar”,
“Win to earn +50 bonus coins!”:”¡Gana +50 monedas de bonificación!”,
“~100 penguins • Massive arena • 1 round only”:”~100 pingüinos • Arena masiva • 1 ronda”,
“Classic shrinking platform”:”Plataforma clásica que se reduce”,
“SHOP”:”TIENDA”,”Coins:”:”Monedas:”,”Earn 5 coins per knockout”:”Gana 5 monedas por eliminación”,
“🎩 Hats”:”🎩 Sombreros”,”🎨 Colors”:”🎨 Colores”,
“Unlock extra penguin colors:”:”Desbloquea colores extra:”,
“Buy”:”Comprar”,”EQUIP”:”EQUIPAR”,”UNEQUIP”:”DESEQUIPAR”,”✓ Owned”:”✓ Poseído”,
“Cowboy Hat”:”Sombrero vaquero”,”Top Hat”:”Sombrero de copa”,
“Party Hat”:”Sombrero de fiesta”,”Crown”:”Corona”,”Santa Hat”:”Gorro de Santa”,
“CUSTOMIZE”:”PERSONALIZAR”,”Colors”:”Colores”,”Accessories”:”Accesorios”,
“Preview”:”Vista previa”,”Selected:”:”Seleccionado:”,”Buy more colors in Shop”:”Compra más colores en la tienda”,
“Buy in Shop”:”Comprar en tienda”,”✓ ON”:”✓ PUESTO”,”equip”:”equipar”,
“Round”:”Ronda”,”Penguins left:”:”Pingüinos restantes:”,
“Ice size”:”Tamaño del hielo”,”GO!”:”¡YA!”,
“Aim locked! Waiting for others…”:”¡Puntería bloqueada! Esperando…”,
“Power:”:”Potencia:”,”Mouse = aim”:”Ratón = apuntar”,
“Q / E = power -/+5%”:”Q / E = potencia -/+5%”,”R = lock in aim”:”R = bloquear puntería”,
“Sliding…”:”Deslizando…”,”FREE CAM”:”CÁMARA LIBRE”,”SPECTATING BOT”:”ESPECTADOR BOT”,
“WASD = move SPACE/SHIFT = up/down F = exit freecam Q = leave”:
“WASD = mover ESPACIO/MAYÚS = arriba/abajo F = salir cámara Q = salir”,
“TAB = next bot F = free camera Q = leave spectate”:
“TAB = siguiente bot F = cámara libre Q = salir”,
“ROUND CLEARED!”:”¡RONDA SUPERADA!”,”Next round starting…”:”Iniciando ronda…”,
“WORLD CUP WINNER!”:”¡GANADOR DE LA COPA!”,”+50 coins rewarded!”:”¡+50 monedas!”,
“YOU WIN!”:”¡GANAS!”,”KNOCKED OUT!”:”¡ELIMINADO!”,
“Press R to restart | ESC for menu”:”R = reiniciar | ESC = menú”,
“R = restart | ESC = menu”:”R = reiniciar | ESC = menú”,”You”:”Tú”,
“Account:”:”Cuenta:”,
},
“it”: {
“PENGUIN KNOCKOUT 3D”:”PINGUINO KNOCKOUT 3D”,
“Last penguin standing wins!”:”L’ultimo pinguino in piedi vince!”,
“PLAY”:”GIOCA”,”SETTINGS”:”IMPOSTAZIONI”,”CUSTOMIZE”:”PERSONALIZZA”,
“SHOP”:”NEGOZIO”,”EXIT”:”ESCI”,
“↑↓ navigate ENTER select”:”↑↓ naviga INVIO seleziona”,
“Select Account”:”Seleziona account”,”+ New Account”:”+ Nuovo account”,
“── or load existing ──”:”── o carica esistente ──”,
“No saves found. Create one!”:”Nessun salvataggio. Creane uno!”,
“Enter your name:”:”Inserisci il tuo nome:”,
“ENTER = confirm ESC = back”:”INVIO = conferma ESC = indietro”,
“Name cannot be empty!”:”Il nome non può essere vuoto!”,
“That name already exists!”:”Quel nome esiste già!”,”Name too long (max 16)!”:”Nome troppo lungo (max 16)!”,
“Old save format detected!”:”Formato di salvataggio obsoleto!”,
“This file must be converted to”:”Questo file deve essere convertito”,
“the new secure format to play.”:”nel nuovo formato sicuro.”,
“ENTER = Convert & Play”:”INVIO = Converti e Gioca”,”ESC = Cancel”:”ESC = Annulla”,
“[OLD FORMAT]”:”[VECCHIO FORMATO]”,”formatted successfully!”:”convertito con successo!”,
“SETTINGS”:”IMPOSTAZIONI”,”Bots:”:”Bot:”,”Difficulty:”:”Difficoltà:”,
“Easy”:”Facile”,”Normal”:”Normale”,”Hard”:”Difficile”,
“FPS Limit:”:”Limite FPS:”,”Unlimited”:”Illimitato”,
“Invert Mouse:”:”Inverti mouse:”,”ON”:”SÌ”,”OFF”:”NO”,
“Language:”:”Lingua:”,”BACK”:”INDIETRO”,
“SELECT MODE”:”SELEZIONA MODALITÀ”,”⚡ ARCADE”:”⚡ ARCADE”,”🏆 WORLD CUP”:”🏆 COPPA DEL MONDO”,
“Win rounds to keep playing”:”Vinci i round per continuare”,
“Win to earn +50 bonus coins!”:”Vinci +50 monete bonus!”,
“~100 penguins • Massive arena • 1 round only”:”~100 pinguini • Arena enorme • 1 round”,
“Classic shrinking platform”:”Piattaforma classica che si restringe”,
“SHOP”:”NEGOZIO”,”Coins:”:”Monete:”,”Earn 5 coins per knockout”:”Guadagna 5 monete per eliminazione”,
“🎩 Hats”:”🎩 Cappelli”,”🎨 Colors”:”🎨 Colori”,
“Unlock extra penguin colors:”:”Sblocca colori extra:”,
“Buy”:”Acquista”,”EQUIP”:”INDOSSA”,”UNEQUIP”:”RIMUOVI”,”✓ Owned”:”✓ Posseduto”,
“Cowboy Hat”:”Cappello da cowboy”,”Top Hat”:”Cappello a cilindro”,
“Party Hat”:”Cappello da festa”,”Crown”:”Corona”,”Santa Hat”:”Cappello di Babbo Natale”,
“CUSTOMIZE”:”PERSONALIZZA”,”Colors”:”Colori”,”Accessories”:”Accessori”,
“Preview”:”Anteprima”,”Selected:”:”Selezionato:”,”Buy more colors in Shop”:”Acquista più colori nel negozio”,
“Buy in Shop”:”Acquista nel negozio”,”✓ ON”:”✓ ATTIVO”,”equip”:”indossa”,
“Round”:”Round”,”Penguins left:”:”Pinguini rimasti:”,
“Ice size”:”Dimensione ghiaccio”,”GO!”:”VIA!”,
“Aim locked! Waiting for others…”:”Mira bloccata! Aspettando…”,
“Power:”:”Potenza:”,”Mouse = aim”:”Mouse = punta”,
“Q / E = power -/+5%”:”Q / E = potenza -/+5%”,”R = lock in aim”:”R = blocca mira”,
“Sliding…”:”Scivolando…”,”FREE CAM”:”TELECAMERA LIBERA”,”SPECTATING BOT”:”SPETTATORE BOT”,
“WASD = move SPACE/SHIFT = up/down F = exit freecam Q = leave”:
“WASD = muovi SPAZIO/MAIUSC = su/giù F = esci cam Q = esci”,
“TAB = next bot F = free camera Q = leave spectate”:
“TAB = bot successivo F = telecamera libera Q = esci”,
“ROUND CLEARED!”:”ROUND SUPERATO!”,”Next round starting…”:”Prossimo round…”,
“WORLD CUP WINNER!”:”VINCITORE DELLA COPPA!”,”+50 coins rewarded!”:”+50 monete guadagnate!”,
“YOU WIN!”:”HAI VINTO!”,”KNOCKED OUT!”:”ELIMINATO!”,
“Press R to restart | ESC for menu”:”R = ricomincia | ESC = menù”,
“R = restart | ESC = menu”:”R = ricomincia | ESC = menù”,”You”:”Tu”,
“Account:”:”Account:”,
},
“uk”: {
“PENGUIN KNOCKOUT 3D”:”ПІНГВІН НОКАУТ 3D”,
“Last penguin standing wins!”:”Останній пінгвін виграє!”,
“PLAY”:”ГРАТИ”,”SETTINGS”:”НАЛАШТУВАННЯ”,”CUSTOMIZE”:”НАЛАШТУВАТИ”,
“SHOP”:”МАГАЗИН”,”EXIT”:”ВИЙТИ”,
“↑↓ navigate ENTER select”:”↑↓ навігація ENTER вибір”,
“Select Account”:”Вибрати акаунт”,”+ New Account”:”+ Новий акаунт”,
“── or load existing ──”:”── або завантажити ──”,
“No saves found. Create one!”:”Збережень немає. Створіть!”,
“Enter your name:”:”Введіть ім’я:”,
“ENTER = confirm ESC = back”:”ENTER = підтвердити ESC = назад”,
“Name cannot be empty!”:”Ім’я не може бути порожнім!”,
“That name already exists!”:”Таке ім’я вже існує!”,”Name too long (max 16)!”:”Ім’я занадто довге (макс 16)!”,
“Old save format detected!”:”Виявлено старий формат збереження!”,
“This file must be converted to”:”Цей файл потрібно конвертувати”,
“the new secure format to play.”:”у новий захищений формат.”,
“ENTER = Convert & Play”:”ENTER = Конвертувати і Грати”,”ESC = Cancel”:”ESC = Скасувати”,
“[OLD FORMAT]”:”[СТАРИЙ ФОРМАТ]”,”formatted successfully!”:”конвертовано успішно!”,
“SETTINGS”:”НАЛАШТУВАННЯ”,”Bots:”:”Боти:”,”Difficulty:”:”Складність:”,
“Easy”:”Легко”,”Normal”:”Нормально”,”Hard”:”Важко”,
“FPS Limit:”:”Ліміт FPS:”,”Unlimited”:”Необмежено”,
“Invert Mouse:”:”Інверт. миша:”,”ON”:”ВКЛ”,”OFF”:”ВИКЛ”,
“Language:”:”Мова:”,”BACK”:”НАЗАД”,
“SELECT MODE”:”ВИБРАТИ РЕЖИМ”,”⚡ ARCADE”:”⚡ АРКАДА”,”🏆 WORLD CUP”:”🏆 КУБОК СВІТУ”,
“Win rounds to keep playing”:”Виграйте раунди щоб продовжити”,
“Win to earn +50 bonus coins!”:”Виграйте +50 бонусних монет!”,
“~100 penguins • Massive arena • 1 round only”:”~100 пінгвінів • Велика арена • 1 раунд”,
“Classic shrinking platform”:”Класична платформа що зменшується”,
“SHOP”:”МАГАЗИН”,”Coins:”:”Монети:”,”Earn 5 coins per knockout”:”5 монет за кожне нокаут”,
“🎩 Hats”:”🎩 Капелюхи”,”🎨 Colors”:”🎨 Кольори”,
“Unlock extra penguin colors:”:”Розблокуйте кольори:”,
“Buy”:”Купити”,”EQUIP”:”НАДЯГНУТИ”,”UNEQUIP”:”ЗНЯТИ”,”✓ Owned”:”✓ Придбано”,
“Cowboy Hat”:”Ковбойський капелюх”,”Top Hat”:”Циліндр”,
“Party Hat”:”Святковий капелюх”,”Crown”:”Корона”,”Santa Hat”:”Шапка Санти”,
“CUSTOMIZE”:”НАЛАШТУВАТИ”,”Colors”:”Кольори”,”Accessories”:”Аксесуари”,
“Preview”:”Перегляд”,”Selected:”:”Вибрано:”,”Buy more colors in Shop”:”Купіть кольори в магазині”,
“Buy in Shop”:”Купити в магазині”,”✓ ON”:”✓ ОДЯГНУТО”,”equip”:”надягнути”,
“Round”:”Раунд”,”Penguins left:”:”Пінгвінів залишилось:”,
“Ice size”:”Розмір льоду”,”GO!”:”РУШ!”,
“Aim locked! Waiting for others…”:”Ціль заблокована! Очікування…”,
“Power:”:”Сила:”,”Mouse = aim”:”Миша = прицілювання”,
“Q / E = power -/+5%”:”Q / E = сила -/+5%”,”R = lock in aim”:”R = зафіксувати”,
“Sliding…”:”Ковзання…”,”FREE CAM”:”ВІЛЬНА КАМЕРА”,”SPECTATING BOT”:”ГЛЯДАЧ БОТА”,
“WASD = move SPACE/SHIFT = up/down F = exit freecam Q = leave”:
“WASD = рух ПРОБІЛ/SHIFT = вгору/вниз F = вийти з камери Q = вийти”,
“TAB = next bot F = free camera Q = leave spectate”:
“TAB = наступний бот F = вільна камера Q = вийти”,
“ROUND CLEARED!”:”РАУНД ПРОЙДЕНО!”,”Next round starting…”:”Наступний раунд…”,
“WORLD CUP WINNER!”:”ПЕРЕМОЖЕЦЬ КУБКУ!”,”+50 coins rewarded!”:”+50 монет нагорода!”,
“YOU WIN!”:”ВИ ВИГРАЛИ!”,”KNOCKED OUT!”:”ВИБИТО!”,
“Press R to restart | ESC for menu”:”R = повтор | ESC = меню”,
“R = restart | ESC = menu”:”R = повтор | ESC = меню”,”You”:”Ви”,
“Account:”:”Акаунт:”,
},
“ru”: {
“PENGUIN KNOCKOUT 3D”:”ПИНГВИН НОКАУТ 3D”,
“Last penguin standing wins!”:”Последний пингвин побеждает!”,
“PLAY”:”ИГРАТЬ”,”SETTINGS”:”НАСТРОЙКИ”,”CUSTOMIZE”:”НАСТРОИТЬ”,
“SHOP”:”МАГАЗИН”,”EXIT”:”ВЫЙТИ”,
“↑↓ navigate ENTER select”:”↑↓ навигация ENTER выбор”,
“Select Account”:”Выбрать аккаунт”,”+ New Account”:”+ Новый аккаунт”,
“── or load existing ──”:”── или загрузить ──”,
“No saves found. Create one!”:”Сохранений нет. Создайте!”,
“Enter your name:”:”Введите имя:”,
“ENTER = confirm ESC = back”:”ENTER = подтвердить ESC = назад”,
“Name cannot be empty!”:”Имя не может быть пустым!”,
“That name already exists!”:”Такое имя уже есть!”,”Name too long (max 16)!”:”Имя слишком длинное (макс 16)!”,
“Old save format detected!”:”Обнаружен старый формат сохранения!”,
“This file must be converted to”:”Этот файл нужно конвертировать”,
“the new secure format to play.”:”в новый защищённый формат.”,
“ENTER = Convert & Play”:”ENTER = Конвертировать и играть”,”ESC = Cancel”:”ESC = Отмена”,
“[OLD FORMAT]”:”[СТАРЫЙ ФОРМАТ]”,”formatted successfully!”:”конвертировано успешно!”,
“SETTINGS”:”НАСТРОЙКИ”,”Bots:”:”Боты:”,”Difficulty:”:”Сложность:”,
“Easy”:”Легко”,”Normal”:”Нормально”,”Hard”:”Сложно”,
“FPS Limit:”:”Лимит FPS:”,”Unlimited”:”Без лимита”,
“Invert Mouse:”:”Инверт. мышь:”,”ON”:”ВКЛ”,”OFF”:”ВЫКЛ”,
“Language:”:”Язык:”,”BACK”:”НАЗАД”,
“SELECT MODE”:”ВЫБРАТЬ РЕЖИМ”,”⚡ ARCADE”:”⚡ АРКАДА”,”🏆 WORLD CUP”:”🏆 КУБОК МИРА”,
“Win rounds to keep playing”:”Выигрывайте раунды для продолжения”,
“Win to earn +50 bonus coins!”:”Выиграйте +50 бонусных монет!”,
“~100 penguins • Massive arena • 1 round only”:”~100 пингвинов • Большая арена • 1 раунд”,
“Classic shrinking platform”:”Классическая уменьшающаяся платформа”,
“SHOP”:”МАГАЗИН”,”Coins:”:”Монеты:”,”Earn 5 coins per knockout”:”5 монет за нокаут”,
“🎩 Hats”:”🎩 Шляпы”,”🎨 Colors”:”🎨 Цвета”,
“Unlock extra penguin colors:”:”Разблокируйте цвета:”,
“Buy”:”Купить”,”EQUIP”:”НАДЕТЬ”,”UNEQUIP”:”СНЯТЬ”,”✓ Owned”:”✓ Куплено”,
“Cowboy Hat”:”Ковбойская шляпа”,”Top Hat”:”Цилиндр”,
“Party Hat”:”Праздничная шляпа”,”Crown”:”Корона”,”Santa Hat”:”Шапка Санты”,
“CUSTOMIZE”:”НАСТРОИТЬ”,”Colors”:”Цвета”,”Accessories”:”Аксессуары”,
“Preview”:”Предпросмотр”,”Selected:”:”Выбрано:”,”Buy more colors in Shop”:”Купите цвета в магазине”,
“Buy in Shop”:”Купить в магазине”,”✓ ON”:”✓ НАДЕТО”,”equip”:”надеть”,
“Round”:”Раунд”,”Penguins left:”:”Пингвинов осталось:”,
“Ice size”:”Размер льда”,”GO!”:”ВПЕРЁД!”,
“Aim locked! Waiting for others…”:”Прицел заблокирован! Ожидание…”,
“Power:”:”Сила:”,”Mouse = aim”:”Мышь = прицел”,
“Q / E = power -/+5%”:”Q / E = сила -/+5%”,”R = lock in aim”:”R = зафиксировать”,
“Sliding…”:”Скольжение…”,”FREE CAM”:”СВОБОДНАЯ КАМЕРА”,”SPECTATING BOT”:”НАБЛЮДАТЕЛЬ БОТА”,
“WASD = move SPACE/SHIFT = up/down F = exit freecam Q = leave”:
“WASD = движение ПРОБЕЛ/SHIFT = вверх/вниз F = выйти из камеры Q = выйти”,
“TAB = next bot F = free camera Q = leave spectate”:
“TAB = следующий бот F = свободная камера Q = выйти”,
“ROUND CLEARED!”:”РАУНД ПРОЙДЕН!”,”Next round starting…”:”Следующий раунд…”,
“WORLD CUP WINNER!”:”ПОБЕДИТЕЛЬ КУБКА!”,”+50 coins rewarded!”:”+50 монет награда!”,
“YOU WIN!”:”ВЫ ПОБЕДИЛИ!”,”KNOCKED OUT!”:”ВЫБИТ!”,
“Press R to restart | ESC for menu”:”R = повтор | ESC = меню”,
“R = restart | ESC = menu”:”R = повтор | ESC = меню”,”You”:”Вы”,
“Account:”:”Аккаунт:”,
},
“tl”: {
“PENGUIN KNOCKOUT 3D”:”PENGUIN KNOCKOUT 3D”,
“Last penguin standing wins!”:”Ang huling nakatayong penguin ay mananalo!”,
“PLAY”:”MAGLARO”,”SETTINGS”:”MGA SETTING”,”CUSTOMIZE”:”I-CUSTOMIZE”,
“SHOP”:”TINDAHAN”,”EXIT”:”LUMABAS”,
“↑↓ navigate ENTER select”:”↑↓ mag-navigate ENTER piliin”,
“Select Account”:”Pumili ng account”,”+ New Account”:”+ Bagong account”,
“── or load existing ──”:”── o i-load ang umiiral ──”,
“No saves found. Create one!”:”Walang save. Gumawa ng isa!”,
“Enter your name:”:”Ilagay ang iyong pangalan:”,
“ENTER = confirm ESC = back”:”ENTER = kumpirmahin ESC = bumalik”,
“Name cannot be empty!”:”Hindi maaaring walang laman ang pangalan!”,
“That name already exists!”:”Mayroon nang ganoong pangalan!”,”Name too long (max 16)!”:”Masyadong mahaba ang pangalan (max 16)!”,
“Old save format detected!”:”Lumang format ng save ang natukoy!”,
“This file must be converted to”:”Ang file na ito ay kailangang i-convert”,
“the new secure format to play.”:”sa bagong secure na format.”,
“ENTER = Convert & Play”:”ENTER = I-convert at Maglaro”,”ESC = Cancel”:”ESC = Kanselahin”,
“[OLD FORMAT]”:”[LUMANG FORMAT]”,”formatted successfully!”:”matagumpay na na-convert!”,
“SETTINGS”:”MGA SETTING”,”Bots:”:”Mga Bot:”,”Difficulty:”:”Kahirapan:”,
“Easy”:”Madali”,”Normal”:”Normal”,”Hard”:”Mahirap”,
“FPS Limit:”:”Limitasyon ng FPS:”,”Unlimited”:”Walang limitasyon”,
“Invert Mouse:”:”I-invert ang Mouse:”,”ON”:”BUKAS”,”OFF”:”SARADO”,
“Language:”:”Wika:”,”BACK”:”BUMALIK”,
“SELECT MODE”:”PUMILI NG MODE”,”⚡ ARCADE”:”⚡ ARCADE”,”🏆 WORLD CUP”:”🏆 WORLD CUP”,
“Win rounds to keep playing”:”Manalo ng mga round para magpatuloy”,
“Win to earn +50 bonus coins!”:”Manalo para makakuha ng +50 bonus na barya!”,
“~100 penguins • Massive arena • 1 round only”:”~100 penguin • Malaking arena • 1 round”,
“Classic shrinking platform”:”Klasikong lumiit na platform”,
“SHOP”:”TINDAHAN”,”Coins:”:”Mga barya:”,”Earn 5 coins per knockout”:”5 barya para sa bawat knockout”,
“🎩 Hats”:”🎩 Mga Sombrero”,”🎨 Colors”:”🎨 Mga Kulay”,
“Unlock extra penguin colors:”:”I-unlock ang mga dagdag na kulay:”,
“Buy”:”Bilhin”,”EQUIP”:”ISUOT”,”UNEQUIP”:”TANGGALIN”,”✓ Owned”:”✓ Pag-aari”,
“Cowboy Hat”:”Cowboy Hat”,”Top Hat”:”Top Hat”,
“Party Hat”:”Party Hat”,”Crown”:”Korona”,”Santa Hat”:”Santa Hat”,
“CUSTOMIZE”:”I-CUSTOMIZE”,”Colors”:”Mga Kulay”,”Accessories”:”Mga Aksesorya”,
“Preview”:”Preview”,”Selected:”:”Napili:”,”Buy more colors in Shop”:”Bumili ng kulay sa tindahan”,
“Buy in Shop”:”Bumili sa tindahan”,”✓ ON”:”✓ NAKASUOT”,”equip”:”isuot”,
“Round”:”Round”,”Penguins left:”:”Mga penguin na natitira:”,
“Ice size”:”Sukat ng yelo”,”GO!”:”HANDA NA!”,
“Aim locked! Waiting for others…”:”Naka-lock ang target! Naghihintay…”,
“Power:”:”Lakas:”,”Mouse = aim”:”Mouse = target”,
“Q / E = power -/+5%”:”Q / E = lakas -/+5%”,”R = lock in aim”:”R = i-lock ang target”,
“Sliding…”:”Nagsisid…”,”FREE CAM”:”LIBRENG CAMERA”,”SPECTATING BOT”:”NAGMAMASID SA BOT”,
“WASD = move SPACE/SHIFT = up/down F = exit freecam Q = leave”:
“WASD = gumalaw SPACE/SHIFT = taas/baba F = lumabas sa camera Q = lumabas”,
“TAB = next bot F = free camera Q = leave spectate”:
“TAB = susunod na bot F = libreng camera Q = lumabas”,
“ROUND CLEARED!”:”TAPOS NA ANG ROUND!”,”Next round starting…”:”Susunod na round…”,
“WORLD CUP WINNER!”:”PANALO SA WORLD CUP!”,”+50 coins rewarded!”:”+50 barya ang premyo!”,
“YOU WIN!”:”NANALO KA!”,”KNOCKED OUT!”:”NATALO!”,
“Press R to restart | ESC for menu”:”R = ulit | ESC = menu”,
“R = restart | ESC = menu”:”R = ulit | ESC = menu”,”You”:”Ikaw”,
“Account:”:”Account:”,
},
}

LANG_CODES = [“en”,”fr”,”es”,”it”,”uk”,”ru”,”tl”]
LANG_NAMES = [“English”,”Français”,”Español”,”Italiano”,”Українська”,”Русский”,”Filipino”]

def _detect_os_lang():
“””Best-effort OS language detection → returns a code from LANG_CODES.”””
try:
import locale
loc = locale.getdefaultlocale()[0] or “”
loc = loc.lower().replace(“-“,”_”)
if loc.startswith(“fr”): return “fr”
if loc.startswith(“es”): return “es”
if loc.startswith(“it”): return “it”
if loc.startswith(“uk”): return “uk”
if loc.startswith(“ru”): return “ru”
if loc.startswith(“tl”) or loc.startswith(“fil”): return “tl”
except Exception:
pass
return “en”

# Global active language — changed by settings, read by T()
_ACTIVE_LANG = _detect_os_lang()

def T(key):
“””Translate key using the active language, fall back to key itself.”””
d = _TRANSLATIONS.get(_ACTIVE_LANG, {})
return d.get(key, key)

def set_lang(code):
global _ACTIVE_LANG
_ACTIVE_LANG = code if code in LANG_CODES else “en”

def _encode_save(data_dict):
“””Serialize dict → compressed+obfuscated binary, returned as bytes.”””
raw = json.dumps(data_dict, separators=(‘,’,’:’)).encode()
comp = zlib.compress(raw, level=9)
# Simple XOR obfuscation with a fixed key derived from magic bytes
key = bytes([0x4B, 0xA3, 0x7F, 0x21, 0xCC, 0x58, 0x90, 0xE4])
xored = bytes(b ^ key[i % len(key)] for i, b in enumerate(comp))
# Pack: magic(4) + version(1) + length(4) + xored payload
out = _SAVE_MAGIC + struct.pack(“>BI”, SAVE_VERSION, len(xored)) + xored
return base64.b85encode(out)

def _decode_save(raw_bytes):
“””Decode bytes → dict. Raises ValueError on bad format or old version.”””
try:
blob = base64.b85decode(raw_bytes.strip())
except Exception:
raise ValueError(“old_format”)
if len(blob) < 9 or blob[:4] != _SAVE_MAGIC:
raise ValueError(“old_format”)
version = blob[4]
if version != SAVE_VERSION:
raise ValueError(“old_format”)
length = struct.unpack(“>I”, blob[5:9])[0]
xored = blob[9:9+length]
key = bytes([0x4B, 0xA3, 0x7F, 0x21, 0xCC, 0x58, 0x90, 0xE4])
comp = bytes(b ^ key[i % len(key)] for i, b in enumerate(xored))
raw = zlib.decompress(comp)
return json.loads(raw.decode())

class SaveData:
def __init__(self, name=”Player”):
self.name = name
self.coins = 0
self.color_idx = 0
self.owned_accs = []
self.equipped_acc= None
self.owned_colors= [0, 1, 2] # free colors unlocked by default

def to_dict(self):
return {“name”: self.name, “coins”: self.coins,
“color_idx”: self.color_idx,
“owned_accs”: self.owned_accs,
“equipped_acc”: self.equipped_acc,
“owned_colors”: self.owned_colors,
“_v”: SAVE_VERSION}

@staticmethod
def from_dict(d):
s = SaveData(d.get(“name”,”Player”))
s.coins = d.get(“coins”, 0)
s.color_idx = d.get(“color_idx”, 0)
s.owned_accs = d.get(“owned_accs”, [])
s.equipped_acc = d.get(“equipped_acc”, None)
s.owned_colors = d.get(“owned_colors”, [0, 1, 2])
return s

def save(self):
os.makedirs(SAVES_DIR, exist_ok=True)
path = os.path.join(SAVES_DIR, f”{self.name}.pko”)
encoded = _encode_save(self.to_dict())
with open(path, “wb”) as f:
f.write(encoded)

@staticmethod
def list_saves():
“””Returns list of (name, is_old_format) tuples.”””
if not os.path.isdir(SAVES_DIR):
return []
results = []
for fname in os.listdir(SAVES_DIR):
if fname.endswith(“.pko”):
results.append((fname[:-4], False))
elif fname.endswith(“.json”):
results.append((fname[:-5], True))
return results

@staticmethod
def load(name):
“””Load by name. Returns SaveData or raises ValueError(‘old_format’).”””
# Try new format first
path_new = os.path.join(SAVES_DIR, f”{name}.pko”)
if os.path.exists(path_new):
with open(path_new, “rb”) as f:
raw = f.read()
d = _decode_save(raw) # may raise ValueError
return SaveData.from_dict(d)
# Old json format — raise so caller can show upgrade message
path_old = os.path.join(SAVES_DIR, f”{name}.json”)
if os.path.exists(path_old):
raise ValueError(“old_format”)
return SaveData(name)

# ── Shop accessories ──────────────────────────────────────────────────────────
ACCESSORIES = [
{“id”: “cowboy_hat”, “name”: “Cowboy Hat”, “price”: 40, “color”: (0.55,0.30,0.05)},
{“id”: “top_hat”, “name”: “Top Hat”, “price”: 60, “color”: (0.10,0.10,0.10)},
{“id”: “party_hat”, “name”: “Party Hat”, “price”: 30, “color”: (0.90,0.20,0.70)},
{“id”: “crown”, “name”: “Crown”, “price”: 120, “color”: (1.00,0.85,0.00)},
{“id”: “santa_hat”, “name”: “Santa Hat”, “price”: 50, “color”: (0.85,0.05,0.05)},
]

# Colors: first 3 are free, rest must be bought
PLAYER_COLORS = [
((0.18, 0.18, 0.18), “Classic”, 0, “free”),
((0.10, 0.50, 0.90), “Blue”, 0, “free”),
((0.20, 0.72, 0.30), “Green”, 0, “free”),
((0.70, 0.10, 0.70), “Purple”, 35, “buy”),
((0.90, 0.40, 0.10), “Orange”, 35, “buy”),
((0.85, 0.15, 0.15), “Red”, 35, “buy”),
((0.10, 0.70, 0.70), “Teal”, 50, “buy”),
((0.90, 0.80, 0.10), “Gold”, 50, “buy”),
((0.90, 0.90, 0.90), “White”, 50, “buy”),
((0.05, 0.05, 0.40), “Navy”, 60, “buy”),
((0.55, 0.10, 0.10), “Crimson”, 60, “buy”),
((0.40, 0.10, 0.55), “Violet”, 75, “buy”),
]

# ── Account / login screen ────────────────────────────────────────────────────
class AccountScreen:
“””Shown at startup: create new or pick existing save.”””
def __init__(self):
self.mode = “choose” # choose | new_name | confirm_format
self.saves = SaveData.list_saves() # list of (name, is_old) tuples
self.scroll = 0
self.typing = “”
self.error = “”
self.status_msg = “” # green success message
self.pending_format = None # name of old-format save awaiting ENTER
self.done = False
self.save_data = None

def _try_format(self, name):
“””Read the old JSON save, re-save in new encrypted format, refresh list.”””
import os
old_path = os.path.join(SAVES_DIR, f”{name}.json”)
try:
with open(old_path) as f:
d = json.load(f)
sd = SaveData.from_dict(d)
sd.name = name
sd.save() # writes new .pko
os.remove(old_path) # delete old .json
self.saves = SaveData.list_saves() # refresh
self.status_msg = f'”{name}” {T(“formatted successfully!”)}’
except Exception as e:
self.status_msg = f”{T(‘Format failed:’)} {e}”
self.pending_format = None
self.mode = “choose”

def handle(self, event, fonts):
if self.mode == “confirm_format”:
if event.type == KEYDOWN:
if event.key == K_RETURN:
self._try_format(self.pending_format)
elif event.key == K_ESCAPE:
self.pending_format = None
self.mode = “choose”
return

if self.mode == “choose”:
if event.type == KEYDOWN:
if event.key == K_ESCAPE:
pygame.quit(); sys.exit()
else:
self.status_msg = “”
if event.type == MOUSEBUTTONDOWN:
mx, my = pygame.mouse.get_pos()
self.status_msg = “”
# “New account” button
bx, by = SCREEN_W//2-140, 180
if bx <= mx <= bx+280 and by <= my <= by+50:
self.mode = “new_name”
self.typing = “”
self.error = “”
# Existing save buttons
for i, (name, is_old) in enumerate(self.saves):
sy2 = 270 + i * 60
if SCREEN_W//2-140 <= mx <= SCREEN_W//2+140 and sy2 <= my <= sy2+48:
if is_old:
self.pending_format = name
self.mode = “confirm_format”
else:
try:
self.save_data = SaveData.load(name)
self.done = True
except ValueError:
self.pending_format = name
self.mode = “confirm_format”

elif self.mode == “new_name”:
if event.type == KEYDOWN:
if event.key == K_ESCAPE:
self.mode = “choose”
elif event.key == K_BACKSPACE:
self.typing = self.typing[:-1]
elif event.key == K_RETURN:
name = self.typing.strip()
existing_names = [n for n, _ in self.saves]
if not name:
self.error = T(“Name cannot be empty!”)
elif name in existing_names:
self.error = T(“That name already exists!”)
elif len(name) > 16:
self.error = T(“Name too long (max 16)!”)
else:
sd = SaveData(name)
sd.save()
self.save_data = sd
self.done = True
else:
ch = event.unicode
if ch.isprintable() and len(self.typing) < 16:
self.typing += ch

def draw(self, fonts):
title_f, btn_f, small_f = fonts
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
draw_sky()
begin_2d()
draw_rect_2d(SCREEN_W//2-220, 60, 440, SCREEN_H-120, (0.04,0.06,0.18), 0.88)
render_text(title_f, T(“PENGUIN KNOCKOUT 3D”), SCREEN_W//2-230, 80, (220,240,255))

if self.mode in (“choose”, “confirm_format”):
render_text(btn_f, T(“Select Account”), SCREEN_W//2-120, 140, (180,220,255))
draw_rect_2d(SCREEN_W//2-140, 180, 280, 50, (0.15,0.45,0.20), 0.9)
render_text(btn_f, T(“+ New Account”), SCREEN_W//2-110, 188, (200,255,200))
if self.saves:
render_text(small_f, T(“── or load existing ──”), SCREEN_W//2-120, 242, (140,170,220))
for i, (name, is_old) in enumerate(self.saves):
sy2 = 270 + i * 60
btn_col = (0.35,0.12,0.08) if is_old else (0.12,0.25,0.50)
draw_rect_2d(SCREEN_W//2-140, sy2, 280, 48, btn_col, 0.9)
render_text(btn_f, name, SCREEN_W//2-120, sy2+8, (255,255,255))
if is_old:
render_text(small_f, T(“[OLD FORMAT]”), SCREEN_W//2+20, sy2+14, (255,160,60))
else:
render_text(small_f, T(“No saves found. Create one!”), SCREEN_W//2-140, 270, (180,180,220))

if self.status_msg:
draw_rect_2d(SCREEN_W//2-200, SCREEN_H-120, 400, 42, (0.05,0.25,0.08), 0.95)
render_text(small_f, self.status_msg, SCREEN_W//2-188, SCREEN_H-113, (120,255,140))

if self.mode == “confirm_format” and self.pending_format:
draw_rect_2d(0, 0, SCREEN_W, SCREEN_H, (0,0,0), 0.45)
bw, bh = 460, 195
bx, by = SCREEN_W//2 – bw//2, SCREEN_H//2 – bh//2
draw_rect_2d(bx, by, bw, bh, (0.08,0.10,0.28), 0.97)
draw_rect_2d(bx, by, bw, 4, (0.90,0.55,0.10), 1.0)
render_text(btn_f, T(“Old save format detected!”), bx+28, by+18, (255,200,80))
render_text(small_f, f'{T(“Account:”)} “{self.pending_format}”‘, bx+28, by+62, (220,230,255))
render_text(small_f, T(“This file must be converted to”), bx+28, by+90, (200,215,255))
render_text(small_f, T(“the new secure format to play.”), bx+28, by+114, (200,215,255))
draw_rect_2d(bx+28, by+148, 185, 36, (0.12,0.45,0.18), 0.95)
render_text(small_f, T(“ENTER = Convert & Play”), bx+34, by+154, (160,255,180))
draw_rect_2d(bx+248, by+148, 180, 36, (0.35,0.12,0.08), 0.95)
render_text(small_f, T(“ESC = Cancel”), bx+274, by+154, (255,180,160))

elif self.mode == “new_name”:
render_text(btn_f, T(“Enter your name:”), SCREEN_W//2-140, 200, (220,240,255))
draw_rect_2d(SCREEN_W//2-140, 260, 280, 48, (0.10,0.15,0.35), 0.95)
display = self.typing + (“|” if int(time.time()*2)%2==0 else “”)
render_text(btn_f, display, SCREEN_W//2-130, 268, (255,255,180))
render_text(small_f, T(“ENTER = confirm ESC = back”), SCREEN_W//2-130, 330, (160,190,230))
if self.error:
render_text(small_f, self.error, SCREEN_W//2-140, 380, (255,80,80))

end_2d()
pygame.display.flip()

# ── Menus ─────────────────────────────────────────────────────────────────────
def _draw_accessory(acc_id):
“””Draw a hat accessory on the penguin at origin (call inside penguin matrix).”””
acc = next((a for a in ACCESSORIES if a[“id”] == acc_id), None)
if not acc: return
c = acc[“color”]
glDisable(GL_COLOR_MATERIAL)
if acc_id == “cowboy_hat”:
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, [*c, 1.0])
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, [0.3,0.2,0.1,1.0])
glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, 20)
# Brim
glPushMatrix(); glTranslatef(0,1.18,0); glScalef(0.7,0.06,0.7); gl_sphere(1.0,16,8); glPopMatrix()
# Crown
glPushMatrix(); glTranslatef(0,1.32,0); glScalef(0.38,0.30,0.38); gl_sphere(1.0,14,10); glPopMatrix()
elif acc_id == “top_hat”:
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, [*c, 1.0])
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, [0.5,0.5,0.5,1.0])
glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, 80)
glPushMatrix(); glTranslatef(0,1.16,0); glScalef(0.55,0.05,0.55); gl_sphere(1.0,16,8); glPopMatrix()
glPushMatrix(); glTranslatef(0,1.36,0); glScalef(0.34,0.38,0.34); gl_sphere(1.0,14,10); glPopMatrix()
elif acc_id == “party_hat”:
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, [*c, 1.0])
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, [0.6,0.6,0.6,1.0])
glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, 50)
glPushMatrix(); glTranslatef(0,1.22,0); glScalef(0.28,0.52,0.28); gl_sphere(1.0,12,10); glPopMatrix()
elif acc_id == “crown”:
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, [*c, 1.0])
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, [1.0,0.9,0.2,1.0])
glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, 120)
for ang in (0,72,144,216,288):
rad = math.radians(ang)
glPushMatrix(); glTranslatef(math.sin(rad)*0.30, 1.20, math.cos(rad)*0.30)
glScalef(0.10,0.20,0.10); gl_sphere(1.0,8,6); glPopMatrix()
glPushMatrix(); glTranslatef(0,1.14,0); glScalef(0.38,0.08,0.38); gl_sphere(1.0,16,6); glPopMatrix()
elif acc_id == “santa_hat”:
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, [*c, 1.0])
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, [0.4,0.1,0.1,1.0])
glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, 30)
glPushMatrix(); glTranslatef(0,1.20,0); glScalef(0.30,0.45,0.30); gl_sphere(1.0,12,10); glPopMatrix()
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, [1.0,1.0,1.0,1.0])
glPushMatrix(); glTranslatef(0,1.14,0); glScalef(0.40,0.08,0.40); gl_sphere(1.0,14,6); glPopMatrix()
glPushMatrix(); glTranslatef(0,1.62,0); glScalef(0.10,0.10,0.10); gl_sphere(1.0,8,6); glPopMatrix()
glEnable(GL_COLOR_MATERIAL)

class Menu:
OPTIONS = [“PLAY”, “SETTINGS”, “CUSTOMIZE”, “SHOP”, “EXIT”]

def __init__(self, save_data):
self.save = save_data
self.selected = 0
self.choice = None
self.bot_count = 3
self._difficulty = 1
self.invert_mouse = False
self.gamemode = 0 # 0=Arcade 1=World Cup
self.screen_mode = “main”
self._preview_yaw = 0.0
self._shop_tab = 0 # 0=Hats 1=Colors
self._cust_tab = 0 # 0=Colors 1=Accessories
self.fps_idx = 3 # default index → 60 fps
# FPS options: 0=15 1=30 2=45 3=60 4=75 5=120 6=Unlimited
self.FPS_OPTIONS = [15, 30, 45, 60, 75, 120, 0]
self.FPS_LABELS = [“15″,”30″,”45″,”60″,”75″,”120″,”Unlimited”]
# Language — init from auto-detected OS lang
self.lang_idx = LANG_CODES.index(_ACTIVE_LANG) if _ACTIVE_LANG in LANG_CODES else 0

@property
def difficulty(self): return self._difficulty

def get_player_color(self):
return PLAYER_COLORS[self.save.color_idx % len(PLAYER_COLORS)][0]

# ── Main handle dispatch ──────────────────────────
def handle(self, event):
m = self.screen_mode
if m == “settings”: return self._handle_settings(event)
if m == “customize”: return self._handle_customize(event)
if m == “shop”: return self._handle_shop(event)
if m == “gamemode”: return self._handle_gamemode(event)
if event.type == KEYDOWN:
if event.key == K_UP: self.selected = (self.selected-1) % len(self.OPTIONS)
if event.key == K_DOWN: self.selected = (self.selected+1) % len(self.OPTIONS)
if event.key in (K_RETURN, K_SPACE): self._activate(self.OPTIONS[self.selected])
if event.key == K_ESCAPE: self.choice = “exit”
if event.type == MOUSEBUTTONDOWN:
mx, my = pygame.mouse.get_pos()
for i, opt in enumerate(self.OPTIONS):
bx, by = SCREEN_W//2-150, 230 + i*74
if bx <= mx <= bx+300 and by <= my <= by+62: self._activate(opt)

def _activate(self, opt):
if opt == “EXIT”: self.choice = “exit”
elif opt == “PLAY”: self.screen_mode = “gamemode”
elif opt == “SETTINGS”: self.screen_mode = “settings”
elif opt == “CUSTOMIZE”: self.screen_mode = “customize”
elif opt == “SHOP”: self.screen_mode = “shop”

# ── Gamemode ──────────────────────────────────────
def _handle_gamemode(self, event):
if event.type == KEYDOWN and event.key == K_ESCAPE: self.screen_mode = “main”
if event.type == MOUSEBUTTONDOWN:
mx, my = pygame.mouse.get_pos()
if SCREEN_W//2-190 <= mx <= SCREEN_W//2+190:
if 250 <= my <= 365: self.gamemode = 0; self.choice = “play”
if 385 <= my <= 500: self.gamemode = 1; self.choice = “play”
if SCREEN_W//2-80 <= mx <= SCREEN_W//2+80 and 530 <= my <= 574: self.screen_mode = “main”

def _draw_gamemode(self, fonts):
title_f, btn_f, small_f = fonts
render_text(title_f, T(“SELECT MODE”), SCREEN_W//2-160, 140, (220,240,255))
draw_rect_2d(SCREEN_W//2-190, 250, 380, 115, (0.10,0.28,0.58), 0.94)
render_text(btn_f, T(“⚡ ARCADE”), SCREEN_W//2-80, 262, (255,255,255))
render_text(small_f, f”Up to {self.bot_count} bots • {T(‘Classic shrinking platform’)}”, SCREEN_W//2-170, 302, (180,215,255))
render_text(small_f, T(“Win rounds to keep playing”), SCREEN_W//2-115, 326, (160,195,240))
draw_rect_2d(SCREEN_W//2-190, 385, 380, 115, (0.40,0.18,0.04), 0.94)
render_text(btn_f, T(“🏆 WORLD CUP”), SCREEN_W//2-100, 397, (255,225,100))
render_text(small_f, T(“~100 penguins • Massive arena • 1 round only”), SCREEN_W//2-185, 437, (255,210,130))
render_text(small_f, T(“Win to earn +50 bonus coins!”), SCREEN_W//2-130, 461, (230,180,80))
draw_rect_2d(SCREEN_W//2-80, 530, 160, 42, (0.22,0.32,0.22), 0.9)
render_text(btn_f, T(“BACK”), SCREEN_W//2-32, 537, (200,255,200))

# ── Settings ──────────────────────────────────────
def _handle_settings(self, event):
if event.type == KEYDOWN and event.key == K_ESCAPE: self.screen_mode = “main”
if event.type == MOUSEBUTTONDOWN:
mx, my = pygame.mouse.get_pos()
LB, RB = SCREEN_W//2-175, SCREEN_W//2+105 # left/right arrow button x
ROW_H = 74
BASE_Y = 220
# Row 0: Bots
if BASE_Y <= my <= BASE_Y+ROW_H:
if LB <= mx <= LB+56: self.bot_count = max(1, self.bot_count-1)
if RB <= mx <= RB+56: self.bot_count = min(10, self.bot_count+1)
# Row 1: Difficulty
if BASE_Y+ROW_H <= my <= BASE_Y+2*ROW_H:
if LB <= mx <= LB+56: self._difficulty = max(0, self._difficulty-1)
if RB <= mx <= RB+56: self._difficulty = min(2, self._difficulty+1)
# Row 2: FPS
if BASE_Y+2*ROW_H <= my <= BASE_Y+3*ROW_H:
if LB <= mx <= LB+56: self.fps_idx = max(0, self.fps_idx-1)
if RB <= mx <= RB+56: self.fps_idx = min(len(self.FPS_OPTIONS)-1, self.fps_idx+1)
# Row 3: Language
if BASE_Y+3*ROW_H <= my <= BASE_Y+4*ROW_H:
if LB <= mx <= LB+56:
self.lang_idx = (self.lang_idx-1) % len(LANG_CODES)
set_lang(LANG_CODES[self.lang_idx])
if RB <= mx <= RB+56:
self.lang_idx = (self.lang_idx+1) % len(LANG_CODES)
set_lang(LANG_CODES[self.lang_idx])
# Row 4: Invert mouse (toggle anywhere in row)
if BASE_Y+4*ROW_H <= my <= BASE_Y+5*ROW_H:
if SCREEN_W//2-175 <= mx <= SCREEN_W//2+160:
self.invert_mouse = not self.invert_mouse
# Back
if SCREEN_W//2-80 <= mx <= SCREEN_W//2+80 and BASE_Y+5*ROW_H+10 <= my <= BASE_Y+5*ROW_H+54:
self.screen_mode = “main”

def _draw_settings(self, fonts):
title_f, btn_f, small_f = fonts
diff_names = [T(“Easy”), T(“Normal”), T(“Hard”)]
fps_lbl = T(“Unlimited”) if self.FPS_LABELS[self.fps_idx]==”Unlimited” else self.FPS_LABELS[self.fps_idx]
lang_name = LANG_NAMES[self.lang_idx]

render_text(title_f, T(“SETTINGS”), SCREEN_W//2-120, 95, (220,240,255))

# Layout constants
LB = SCREEN_W//2-175 # left arrow btn x
RB = SCREEN_W//2+105 # right arrow btn x
VX = SCREEN_W//2-155 # value text x
ROW_H= 74
BASE = 220

rows = [
(T(“Bots:”), str(self.bot_count)),
(T(“Difficulty:”), diff_names[self._difficulty]),
(T(“FPS Limit:”), fps_lbl),
(T(“Language:”), lang_name),
]

for i, (label, value) in enumerate(rows):
y = BASE + i*ROW_H
# Label
render_text(small_f, label, VX, y+4, (180,210,255))
# Value centred between arrows
render_text(btn_f, value, VX+120, y+4, (255,255,255))
# Arrows
draw_rect_2d(LB, y, 56, 38, (0.14,0.24,0.52), 0.9)
render_text(btn_f, “<“, LB+14, y+6, (255,255,255))
draw_rect_2d(RB, y, 56, 38, (0.14,0.24,0.52), 0.9)
render_text(btn_f, “>”, RB+14, y+6, (255,255,255))

# Invert mouse toggle (full-width button)
iy = BASE + 4*ROW_H
inv_col = (0.08,0.50,0.14) if self.invert_mouse else (0.32,0.10,0.10)
draw_rect_2d(SCREEN_W//2-175, iy, 335, 40, inv_col, 0.92)
inv_val = T(“ON”) if self.invert_mouse else T(“OFF”)
render_text(btn_f, f”{T(‘Invert Mouse:’)} {inv_val}”, SCREEN_W//2-165, iy+7, (255,255,255))

# Back
by = BASE + 5*ROW_H + 10
draw_rect_2d(SCREEN_W//2-80, by, 160, 42, (0.28,0.48,0.28), 0.9)
render_text(btn_f, T(“BACK”), SCREEN_W//2-32, by+8, (255,255,255))

# ── Customize ─────────────────────────────────────
def _handle_customize(self, event):
PX = SCREEN_W//2 – 210
CX = PX + 10
if event.type == KEYDOWN and event.key == K_ESCAPE:
self.screen_mode = “main”; self.save.save()
if event.type == MOUSEBUTTONDOWN:
mx, my = pygame.mouse.get_pos()
# Tab switch
if 175 <= my <= 211:
if CX <= mx <= CX+135: self._cust_tab = 0
if CX+142 <= mx <= CX+287: self._cust_tab = 1
if self._cust_tab == 0:
# Color grid: 3 cols, sw=58, gap=6
cols_per_row = 3; sw = 58; gap = 6
for ci in range(len(PLAYER_COLORS)):
_, _, price, kind = PLAYER_COLORS[ci]
owned = (kind == “free”) or (ci in self.save.owned_colors)
row, col2 = divmod(ci, cols_per_row)
cx = CX + col2*(sw+gap)
cy = 220 + row * (sw+gap)
if cx <= mx <= cx+sw and cy <= my <= cy+sw:
if owned:
self.save.color_idx = ci; self.save.save()
else:
# Accessory list
for i, acc in enumerate(ACCESSORIES):
ay = 220 + i * 66
owned = acc[“id”] in self.save.owned_accs
if owned and CX <= mx <= CX+210 and ay <= my <= ay+54:
self.save.equipped_acc = None if self.save.equipped_acc == acc[“id”] else acc[“id”]
self.save.save()
# Back
if SCREEN_W//2-80 <= mx <= SCREEN_W//2+80 and SCREEN_H-88 <= my <= SCREEN_H-48:
self.screen_mode = “main”; self.save.save()

def _draw_customize(self, fonts, dt=0.016):
title_f, btn_f, small_f = fonts
self._preview_yaw += 55 * dt

# Panel left edge and widths
PX = SCREEN_W//2 – 210 # left edge of panel
PW = 420 # panel width
# Left column: content starts at PX+10
CX = PX + 10
# Preview column: right portion
PREV_X = SCREEN_W//2 + 10
PREV_W = 130
PREV_Y_screen = 230 # top of preview in screen coords (Y=0 top)
PREV_H = 195

# ── Full 2D pass ──────────────────────────────
begin_2d()
draw_rect_2d(PX, 50, PW, SCREEN_H-100, (0.04,0.06,0.18), 0.86)
render_text(title_f, T(“CUSTOMIZE”), SCREEN_W//2-130, 62, (220,240,255))
render_text(btn_f, f”{T(‘Coins:’)} {self.save.coins}”, SCREEN_W//2-62, 118, (255,215,0))
tc0 = (0.18,0.40,0.80) if self._cust_tab==0 else (0.10,0.18,0.40)
tc1 = (0.18,0.40,0.80) if self._cust_tab==1 else (0.10,0.18,0.40)
draw_rect_2d(CX, 175, 135, 36, tc0, 0.95)
draw_rect_2d(CX+142, 175, 145, 36, tc1, 0.95)
render_text(btn_f, T(“Colors”), CX+8, 181, (255,255,255))
render_text(btn_f, T(“Accessories”), CX+148, 181, (255,255,255))
draw_rect_2d(PREV_X, PREV_Y_screen, PREV_W, PREV_H, (0.06,0.10,0.24), 0.95)
render_text(small_f, T(“Preview”), PREV_X+22, PREV_Y_screen+6, (100,130,190))
if self._cust_tab == 0:
cols_per_row = 3
sw = 58
gap = 6
for ci in range(len(PLAYER_COLORS)):
clr, cname, price, kind = PLAYER_COLORS[ci]
owned = (kind == “free”) or (ci in self.save.owned_colors)
row, col2 = divmod(ci, cols_per_row)
cx = CX + col2*(sw+gap)
cy = 220 + row * (sw+gap)
selected = (ci == self.save.color_idx)
bdr = (0.0,0.9,0.3) if selected else ((0.62,0.62,0.62) if owned else (0.26,0.26,0.26))
draw_rect_2d(cx-2, cy-2, sw+2, sw+2, bdr, 1.0)
draw_rect_2d(cx, cy, sw-2, sw-2, clr, 1.0 if owned else 0.28)
if not owned:
render_text(small_f, f”{price}c”, cx+3, cy+sw-24, (255,215,0))
owned_name = PLAYER_COLORS[self.save.color_idx][1]
render_text(small_f, f”{T(‘Selected:’)} {owned_name}”, CX, SCREEN_H-125, (170,210,255))
render_text(small_f, T(“Buy more colors in Shop”), CX, SCREEN_H-100, (130,160,220))
else:
for i, acc in enumerate(ACCESSORIES):
ay = 220 + i * 66
owned = acc[“id”] in self.save.owned_accs
equipped= self.save.equipped_acc == acc[“id”]
rc = (0.08,0.32,0.16) if equipped else ((0.08,0.18,0.38) if owned else (0.06,0.08,0.18))
draw_rect_2d(CX, ay, 210, 54, rc, 0.90)
draw_rect_2d(CX+8, ay+10, 26, 34, acc[“color”], 1.0)
render_text(btn_f, T(acc[“name”]), CX+42, ay+9, (255,255,255))
if not owned:
render_text(small_f, T(“Buy in Shop”), CX+42, ay+32, (130,130,165))
elif equipped:
render_text(small_f, T(“✓ ON”), CX+130, ay+18, (100,255,150))
else:
render_text(small_f, T(“equip”), CX+130, ay+18, (180,200,255))
draw_rect_2d(SCREEN_W//2-80, SCREEN_H-88, 160, 40, (0.28,0.48,0.28), 0.9)
render_text(btn_f, T(“BACK”), SCREEN_W//2-30, SCREEN_H-82, (255,255,255))
end_2d()

# ── 3D penguin preview rendered LAST ──────────
color = self.get_player_color()
glMatrixMode(GL_PROJECTION); glLoadIdentity()
gluPerspective(38, SCREEN_W/SCREEN_H, 0.1, 100)
glMatrixMode(GL_MODELVIEW); glLoadIdentity()
gluLookAt(0, 1.8, 7, 0, 0.4, 0, 0, 1, 0)
glEnable(GL_LIGHTING); glEnable(GL_LIGHT0)
glLightfv(GL_LIGHT0, GL_POSITION, [4, 8, 5, 1])
glLightfv(GL_LIGHT0, GL_DIFFUSE, [1.1, 1.0, 0.9, 1])
glLightfv(GL_LIGHT0, GL_SPECULAR, [0.7, 0.7, 0.7, 1])
glLightfv(GL_LIGHT0, GL_AMBIENT, [0.3, 0.35, 0.45, 1])
glDisable(GL_COLOR_MATERIAL)
gl_sy = SCREEN_H – PREV_Y_screen – PREV_H # convert to GL bottom-left coords
glEnable(GL_SCISSOR_TEST)
glScissor(PREV_X, gl_sy, PREV_W, PREV_H)
glClear(GL_DEPTH_BUFFER_BIT)
glPushMatrix()
glTranslatef(0.9, 0, 0)
glRotatef(self._preview_yaw, 0, 1, 0)
draw_penguin(color, 0.0, False)
if self.save.equipped_acc:
_draw_accessory(self.save.equipped_acc)
glPopMatrix()
glDisable(GL_SCISSOR_TEST)
glDisable(GL_LIGHTING)
glEnable(GL_COLOR_MATERIAL)
glMatrixMode(GL_PROJECTION); glLoadIdentity()
glMatrixMode(GL_MODELVIEW); glLoadIdentity()
glEnable(GL_DEPTH_TEST)

# ── Shop (two tabs: Hats / Colors) ────────────────
def _handle_shop(self, event):
if event.type == KEYDOWN and event.key == K_ESCAPE:
self.screen_mode = “main”; self.save.save()
if event.type == MOUSEBUTTONDOWN:
mx, my = pygame.mouse.get_pos()
# Tab switch
if 160 <= my <= 205:
if SCREEN_W//2-180 <= mx <= SCREEN_W//2-10: self._shop_tab = 0
if SCREEN_W//2+10 <= mx <= SCREEN_W//2+180: self._shop_tab = 1
if self._shop_tab == 0:
# Hats
for i, acc in enumerate(ACCESSORIES):
ay = 218 + i*68
owned = acc[“id”] in self.save.owned_accs
# Buy button
if SCREEN_W//2+50 <= mx <= SCREEN_W//2+162 and ay+10 <= my <= ay+50:
if not owned and self.save.coins >= acc[“price”]:
self.save.coins -= acc[“price”]
self.save.owned_accs.append(acc[“id”])
self.save.save()
# Equip/Unequip
if owned and SCREEN_W//2-155 <= mx <= SCREEN_W//2+45 and ay+10 <= my <= ay+50:
self.save.equipped_acc = None if self.save.equipped_acc == acc[“id”] else acc[“id”]
self.save.save()
else:
# Colors
for ci in range(len(PLAYER_COLORS)):
_, _, price, kind = PLAYER_COLORS[ci]
if kind == “free”: continue
owned = ci in self.save.owned_colors
row = ci – 3 # skip 3 free
ay = 218 + row * 62
if SCREEN_W//2+50 <= mx <= SCREEN_W//2+162 and ay+8 <= my <= ay+46:
if not owned and self.save.coins >= price:
self.save.coins -= price
self.save.owned_colors.append(ci)
self.save.save()
# Back
if SCREEN_W//2-80 <= mx <= SCREEN_W//2+80 and SCREEN_H-95 <= my <= SCREEN_H-55:
self.screen_mode = “main”; self.save.save()

def _draw_shop(self, fonts):
title_f, btn_f, small_f = fonts
render_text(title_f, T(“SHOP”), SCREEN_W//2-55, 100, (220,240,255))
render_text(btn_f, f”{T(‘Coins:’)} {self.save.coins}”, SCREEN_W//2-75, 148, (255,215,0))
t0c = (0.18,0.40,0.80) if self._shop_tab==0 else (0.10,0.18,0.40)
t1c = (0.18,0.40,0.80) if self._shop_tab==1 else (0.10,0.18,0.40)
draw_rect_2d(SCREEN_W//2-180, 163, 168, 40, t0c, 0.95)
draw_rect_2d(SCREEN_W//2+12, 163, 168, 40, t1c, 0.95)
render_text(btn_f, T(“🎩 Hats”), SCREEN_W//2-165, 169, (255,255,255))
render_text(btn_f, T(“🎨 Colors”), SCREEN_W//2+20, 169, (255,255,255))
if self._shop_tab == 0:
for i, acc in enumerate(ACCESSORIES):
ay = 218 + i*68
owned = acc[“id”] in self.save.owned_accs
equipped= self.save.equipped_acc == acc[“id”]
rc = (0.08,0.30,0.16) if owned else (0.08,0.18,0.40)
draw_rect_2d(SCREEN_W//2-165, ay, 330, 56, rc, 0.90)
draw_rect_2d(SCREEN_W//2-155, ay+10, 28, 36, acc[“color”], 1.0)
render_text(btn_f, T(acc[“name”]), SCREEN_W//2-118, ay+8, (255,255,255))
if owned:
elbl = T(“UNEQUIP”) if equipped else T(“EQUIP”)
ec = (0.48,0.10,0.10) if equipped else (0.12,0.42,0.12)
draw_rect_2d(SCREEN_W//2-50, ay+14, 90, 28, ec, 0.9)
render_text(small_f, elbl, SCREEN_W//2-44, ay+19, (255,255,255))
else:
can = self.save.coins >= acc[“price”]
bc = (0.35,0.25,0.04) if can else (0.18,0.18,0.18)
draw_rect_2d(SCREEN_W//2+52, ay+10, 108, 36, bc, 0.92)
render_text(small_f, f”{T(‘Buy’)} {acc[‘price’]}c”, SCREEN_W//2+58, ay+19, (255,215,0) if can else (100,100,100))
else:
render_text(small_f, T(“Unlock extra penguin colors:”), SCREEN_W//2-145, 212, (180,215,255))
buyable = [(ci, pc) for ci, pc in enumerate(PLAYER_COLORS) if pc[3] == “buy”]
for row, (ci, (clr, cname, price, _)) in enumerate(buyable):
ay = 218 + row*62
owned = ci in self.save.owned_colors
rc = (0.08,0.30,0.16) if owned else (0.08,0.18,0.40)
draw_rect_2d(SCREEN_W//2-165, ay, 330, 52, rc, 0.90)
draw_rect_2d(SCREEN_W//2-155, ay+8, 30, 36, clr, 1.0)
render_text(btn_f, cname, SCREEN_W//2-115, ay+8, (255,255,255))
if owned:
render_text(small_f, T(“✓ Owned”), SCREEN_W//2+40, ay+16, (100,255,150))
else:
can = self.save.coins >= price
bc = (0.35,0.25,0.04) if can else (0.18,0.18,0.18)
draw_rect_2d(SCREEN_W//2+52, ay+10, 108, 32, bc, 0.92)
render_text(small_f, f”{T(‘Buy’)} {price}c”, SCREEN_W//2+58, ay+16, (255,215,0) if can else (100,100,100))
draw_rect_2d(SCREEN_W//2-80, SCREEN_H-95, 160, 40, (0.28,0.48,0.28), 0.9)
render_text(btn_f, T(“BACK”), SCREEN_W//2-32, SCREEN_H-89, (255,255,255))

# ── Main draw dispatch ────────────────────────────
def draw(self, fonts, dt=0.016):
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
draw_sky()
if self.screen_mode == “customize”:
self._draw_customize(fonts, dt)
else:
begin_2d()
draw_rect_2d(SCREEN_W//2-215, 45, 430, SCREEN_H-90, (0.04,0.06,0.18), 0.86)
if self.screen_mode == “main”: self._draw_main(fonts)
elif self.screen_mode == “settings”: self._draw_settings(fonts)
elif self.screen_mode == “shop”: self._draw_shop(fonts)
elif self.screen_mode == “gamemode”: self._draw_gamemode(fonts)
end_2d()
pygame.display.flip()

def _draw_main(self, fonts):
title_f, btn_f, small_f = fonts
render_text(title_f, T(“PENGUIN KNOCKOUT 3D”), SCREEN_W//2-230, 70, (220,240,255))
render_text(small_f, T(“Last penguin standing wins!”), SCREEN_W//2-140, 132, (160,195,240))
draw_rect_2d(SCREEN_W//2-190, 158, 380, 52, (0.10,0.18,0.40), 0.88)
render_text(btn_f, f”👤 {self.save.name}”, SCREEN_W//2-170, 164, (255,255,255))
render_text(btn_f, f”🪙 {self.save.coins}”, SCREEN_W//2+40, 164, (255,215,80))
for i, opt in enumerate(self.OPTIONS):
bx, by = SCREEN_W//2-150, 228 + i*74
is_sel = (i == self.selected)
draw_rect_2d(bx, by, 300, 62, (0.14,0.32,0.72) if is_sel else (0.08,0.16,0.42), 0.94)
if is_sel:
draw_rect_2d(bx, by, 5, 62, (0.40,0.80,1.00), 1.0)
render_text(btn_f, T(opt), bx+22, by+14, (255,255,255))
render_text(small_f, T(“↑↓ navigate ENTER select”), SCREEN_W//2-118, SCREEN_H-74, (130,160,205))

# ── Free camera (spectate mode) ───────────────────────────────────────────────
class FreeCam:
“””WASD + mouse-look free camera for spectate mode.”””
SPEED = 12.0
MOUSE_SENS = 0.20

def __init__(self, pos=(0, 14, 22)):
self.x, self.y, self.z = pos
self.pitch = -28.0 # look down a bit at start
self.yaw = 0.0
self.active = False # True when freecam is enabled

def activate(self):
self.active = True
pygame.event.set_grab(True)
pygame.mouse.set_visible(False)

def deactivate(self):
self.active = False
pygame.event.set_grab(False)
pygame.mouse.set_visible(True)

def update(self, dt):
if not self.active:
return
# Mouse look
dx, dy = pygame.mouse.get_rel()
self.yaw += dx * self.MOUSE_SENS
self.pitch = clamp(self.pitch – dy * self.MOUSE_SENS, -89, 89)

# WASD movement in camera-local space
# Forward = -Z in OpenGL, so negate fwd for W key
keys = pygame.key.get_pressed()
spd = self.SPEED * dt
yrad = math.radians(self.yaw)
# fwd points INTO the screen (negative Z world direction when yaw=0)
fwd = (-math.sin(yrad), 0, -math.cos(yrad))
right = ( math.cos(yrad), 0, -math.sin(yrad))

if keys[K_w]: self.x += fwd[0]*spd; self.z += fwd[2]*spd
if keys[K_s]: self.x -= fwd[0]*spd; self.z -= fwd[2]*spd
if keys[K_a]: self.x -= right[0]*spd; self.z -= right[2]*spd
if keys[K_d]: self.x += right[0]*spd; self.z += right[2]*spd
if keys[K_SPACE]: self.y += spd
if keys[K_LSHIFT]: self.y -= spd

def apply(self):
“””Set the OpenGL modelview for this camera.
Apply yaw first in code so it acts in world-Y space,
then pitch so it tilts the already-yawed local X axis.
“””
glRotatef(-self.pitch, 1, 0, 0) # tilt up/down in local space
glRotatef(-self.yaw, 0, 1, 0) # rotate left/right around world Y
glTranslatef(-self.x, -self.y, -self.z)

# ── Game scene ────────────────────────────────────────────────────────────────
class GameScene:
# States:
# “intro_count” – 3-2-1 countdown before very first round
# “aiming” – 5 s aiming phase, everyone frozen
# “sliding” – all launched, waiting for everyone to settle
# “spectate” – player knocked out, watching bots finish
# “round_over” – player won the round
# “game_over” – player lost and chose to leave spectate

INTRO_TIME = 3.0

def __init__(self, menu, fonts):
self.menu = menu
self.fonts = fonts
self.reset()

def reset(self):
wc = hasattr(self, ‘menu’) and self.menu.gamemode == 1
self.platform = Platform(60.0 if wc else 20.0)
self.round_num = 1
self.turns_this_round = 0
self.state = “intro_count”
self.intro_timer = self.INTRO_TIME
self.aim_timer = AIM_TIME
self.player_yaw = 0.0
self.player_power = 0.5
self.player_locked = False
self.drag_slider = False
self.result_timer = 3.0
self.spectate_target = 0
self.spectate_sub = “spec_sliding”
self.spectate_timer = AIM_TIME
self.freecam = FreeCam(pos=(0, 18, 28))
self.freecam_enabled = False
self._spawn_penguins()

def _spawn_penguins(self):
self.penguins = []
wc = (self.menu.gamemode == 1)
if wc:
plat_size = 60.0
self.platform = Platform(plat_size)
n_bots = 99
else:
plat_size = self.platform.size
n_bots = self.menu.bot_count

p = PenguinEntity(0, 0, self.menu.get_player_color(), is_player=True)
p.name = self.menu.save.name
p.chosen_power = self.player_power
p.accessory = self.menu.save.equipped_acc # apply equipped hat
self.penguins.append(p)

used_names = set()
angles = [i * (360 / n_bots) for i in range(n_bots)]
for i, ang in enumerate(angles):
rad = math.radians(ang)
r = plat_size * 0.30
x = math.sin(rad) * r + random.uniform(-1, 1)
z = math.cos(rad) * r + random.uniform(-1, 1)
color = PENGUIN_COLORS[1 + (i % (len(PENGUIN_COLORS)-1))]
bot = PenguinEntity(x, z, color)
# Unique random name
available = [n for n in BOT_NAMES if n not in used_names]
if not available: available = BOT_NAMES
bot.name = random.choice(available)
used_names.add(bot.name)
self.penguins.append(bot)

@property
def player(self):
return self.penguins[0]

def alive_count(self):
return sum(1 for p in self.penguins if p.alive and not p.knocked)

# ── Input ─────────────────────────────────────────
def handle(self, event):
if event.type == KEYDOWN and event.key == K_ESCAPE:
pygame.event.set_grab(False)
pygame.mouse.set_visible(True)
return “menu”

# Spectate mode controls
if self.state == “spectate”:
if event.type == KEYDOWN:
if event.key == K_TAB and not self.freecam_enabled:
alive_bots = [p for p in self.penguins if not p.is_player and p.alive and not p.knocked]
if alive_bots:
self.spectate_target = (self.spectate_target + 1) % len(alive_bots)
elif event.key == K_f:
self.freecam_enabled = not self.freecam_enabled
if self.freecam_enabled:
# Snap freecam to current follow-cam position
alive_bots = [p for p in self.penguins if not p.is_player and p.alive and not p.knocked]
tgt = alive_bots[0] if alive_bots else self.penguins[0]
self.freecam.x, self.freecam.y, self.freecam.z = tgt.pos[0], tgt.pos[1]+14, tgt.pos[2]+20
self.freecam.activate()
else:
self.freecam.deactivate()
elif event.key in (K_q, K_ESCAPE):
self.freecam_enabled = False
self.freecam.deactivate()
self.state = “game_over”
self.result_timer = 99
return None

# Game-over: R restarts
if self.state == “game_over”:
if event.type == KEYDOWN and event.key == K_r:
self.reset()
return None

if self.state == “aiming” and not self.player_locked:
if event.type == MOUSEBUTTONDOWN:
mx, my = pygame.mouse.get_pos()
sx, sy, sw, sh = self._slider_rect()
if sx <= mx <= sx+sw and sy <= my <= sy+sh:
self.drag_slider = True
self.player_power = clamp((mx – sx) / sw, 0, 1)
SFX.get(“tick”) and SFX[“tick”].play()
if event.type == MOUSEBUTTONUP:
self.drag_slider = False
if event.type == MOUSEMOTION and self.drag_slider:
mx, _ = pygame.mouse.get_pos()
sx, sy, sw, sh = self._slider_rect()
self.player_power = clamp((mx – sx) / sw, 0, 1)

if event.type == KEYDOWN:
if event.key == K_q:
self.player_power = clamp(self.player_power – 0.05, 0.0, 1.0)
SFX.get(“tick”) and SFX[“tick”].play()
elif event.key == K_e:
self.player_power = clamp(self.player_power + 0.05, 0.0, 1.0)
SFX.get(“tick”) and SFX[“tick”].play()
elif event.key in (K_r, K_SPACE, K_RETURN):
self.player_locked = True
self.player.chosen_yaw = self.player_yaw
self.player.chosen_power = self.player_power
SFX.get(“lock”) and SFX[“lock”].play()
pygame.event.set_grab(False)
pygame.mouse.set_visible(True)

# Arrow keys rotate aim continuously (held down)
if self.state == “aiming” and not self.player_locked:
keys = pygame.key.get_pressed()
turn_speed = 90 * (1/60) # degrees per frame at 60fps, scaled by dt handled in update
self._arrow_key_yaw = 0.0
if keys[K_LEFT]: self._arrow_key_yaw = -1.0
if keys[K_RIGHT]: self._arrow_key_yaw = 1.0

return None

def _slider_rect(self):
return (SCREEN_W//2 – 150, SCREEN_H – 100, 300, 30)

# ── Launch all ────────────────────────────────────
def _do_launch_all(self):
“””Snap all penguins to face their chosen direction, then launch.”””
diff_mult = [0.7, 1.0, 1.4][self.menu.difficulty]
for p in self.penguins:
if not p.alive or p.knocked:
continue
if p.is_player:
p.chosen_yaw = self.player_yaw
p.chosen_power = self.player_power
p.yaw = p.chosen_yaw
for p in self.penguins:
if p.alive and not p.knocked:
p.launch()
SFX.get(“launch”) and SFX[“launch”].play()
SFX.get(“slide”) and SFX[“slide”].play()

# ── Launch bots only (during spectate) ───────────
def _do_launch_all_bots(self):
for p in self.penguins:
if not p.is_player and p.alive and not p.knocked:
p.yaw = p.chosen_yaw
p.launch()
SFX.get(“launch”) and SFX[“launch”].play()
SFX.get(“slide”) and SFX[“slide”].play()

# ── Update ────────────────────────────────────────
def update(self, dt):
diff_mult = [0.7, 1.0, 1.4][self.menu.difficulty]

if self.state == “intro_count”:
prev = math.ceil(self.intro_timer)
self.intro_timer -= dt
curr = math.ceil(self.intro_timer)
# Beep on each whole-second tick
if curr != prev and curr > 0:
SFX.get(“beep”) and SFX[“beep”].play()
if self.intro_timer <= 0:
SFX.get(“beep_go”) and SFX[“beep_go”].play()
self._begin_aim_phase()

elif self.state == “aiming”:
# ── Capture mouse for smooth yaw control ──────────────────────
if not self.player_locked:
pygame.event.set_grab(True)
pygame.mouse.set_visible(False)
dx, _ = pygame.mouse.get_rel()
invert = -1 if self.menu.invert_mouse else 1
self.player_yaw += dx * 0.5 * invert
# Arrow key rotation (held keys, smooth)
self.player_yaw += getattr(self, ‘_arrow_key_yaw’, 0.0) * 90.0 * dt
self.player.chosen_yaw = self.player_yaw
self.player.chosen_power = self.player_power

# Beep on final 3 seconds
prev_t = self.aim_timer
self.aim_timer -= dt
for tick_t in (3.0, 2.0, 1.0):
if prev_t > tick_t >= self.aim_timer:
SFX.get(“beep”) and SFX[“beep”].play()

if self.aim_timer <= 0:
self._do_launch_all()
self.state = “sliding”
self.player_locked = False
pygame.event.set_grab(False)
pygame.mouse.set_visible(True)

elif self.state == “sliding”:
# Physics
for p in self.penguins:
p.update(dt, self.platform)

# Collisions + hit sound
plist = [p for p in self.penguins if p.alive]
for i in range(len(plist)):
for j in range(i+1, len(plist)):
spd_before = math.sqrt(
(plist[i].vel[0]-plist[j].vel[0])**2 +
(plist[i].vel[2]-plist[j].vel[2])**2)
plist[i].collide(plist[j])
if spd_before > 2.0:
SFX.get(“hit”) and SFX[“hit”].play()

self.platform.update(dt)

# Award coins for knockouts caused this frame
for p in self.penguins:
if not p.is_player and p._just_knocked:
p._just_knocked = False
self.menu.save.coins += 5
self.menu.save.save()

# Win / loss check — run every frame during sliding
alive = [p for p in self.penguins if p.alive and not p.knocked]
player_out = not self.player.alive or self.player.knocked
if player_out:
SFX.get(“knocked”) and SFX[“knocked”].play()
alive_bots = [p for p in self.penguins if not p.is_player and p.alive and not p.knocked]
if alive_bots:
self.state = “spectate”
self.spectate_target = 0
self.spectate_sub = “spec_sliding”
else:
self.state = “game_over”
self.result_timer = 99
return
# Player alive and only one left (or last standing) → win
if len(alive) <= 1:
SFX.get(“win”) and SFX[“win”].play()
wc = (self.menu.gamemode == 1)
if wc:
# World Cup: reward coins, go straight to game_over
self.menu.save.coins += 50
self.menu.save.save()
self.state = “game_over”
self.result_timer = 99
else:
self.round_num += 1
self.state = “round_over”
self.result_timer = 2.5
return

# Check if everyone has settled → start next aim phase
active = [p for p in self.penguins if p.alive and not p.knocked]
if all(p.is_settled() for p in active):
self.turns_this_round += 1
if self.turns_this_round % PLATFORM_SHRINK_INTERVAL == 0:
self.platform.shrink(PLATFORM_SHRINK_AMOUNT * diff_mult)
self._begin_aim_phase()

elif self.state == “spectate”:
# Freecam update
if self.freecam_enabled:
self.freecam.update(dt)

# Physics + collisions (same as sliding)
for p in self.penguins:
p.update(dt, self.platform)
plist = [p for p in self.penguins if p.alive]
for i in range(len(plist)):
for j in range(i+1, len(plist)):
spd_before = math.sqrt(
(plist[i].vel[0]-plist[j].vel[0])**2 +
(plist[i].vel[2]-plist[j].vel[2])**2)
plist[i].collide(plist[j])
if spd_before > 2.0:
SFX.get(“hit”) and SFX[“hit”].play()
self.platform.update(dt)

alive_bots = [p for p in self.penguins if not p.is_player and p.alive and not p.knocked]

# Last bot standing → game over (wait until 0 remain, not 1)
if len(alive_bots) == 0:
self.freecam_enabled = False
self.freecam.deactivate()
self.state = “game_over”
self.result_timer = 99
return

# Sub-states inside spectate: “spec_sliding” → “spec_aiming”
if self.spectate_sub == “spec_sliding”:
active = [p for p in alive_bots if p.alive and not p.knocked]
if active and all(p.is_settled() for p in active):
# Everyone settled → start spectate aim timer
self.turns_this_round += 1
if self.turns_this_round % PLATFORM_SHRINK_INTERVAL == 0:
self.platform.shrink(PLATFORM_SHRINK_AMOUNT * diff_mult)
for p in active:
p.bot_choose(self.penguins, diff_mult)
p.yaw = 0.0
self.spectate_sub = “spec_aiming”
self.spectate_timer = AIM_TIME

elif self.spectate_sub == “spec_aiming”:
self.spectate_timer -= dt
if self.spectate_timer <= 0:
self._do_launch_all_bots()
self.spectate_sub = “spec_sliding”

elif self.state in (“round_over”, “game_over”):
self.result_timer -= dt
if self.result_timer <= 0 and self.state == “round_over”:
self._next_round()

def _begin_aim_phase(self):
self.state = “aiming”
self.aim_timer = AIM_TIME
self.player_locked = False
self.drag_slider = False
# Bots silently choose their move now
diff_mult = [0.7, 1.0, 1.4][self.menu.difficulty]
for p in self.penguins:
if not p.is_player and p.alive and not p.knocked:
p.bot_choose(self.penguins, diff_mult)
# All penguins face forward (yaw=0) so nobody telegraphs
for p in self.penguins:
if p.alive and not p.knocked:
p.yaw = 0.0

def _next_round(self):
wc = (self.menu.gamemode == 1)
if wc:
self.platform = Platform(60.0)
else:
old_size = max(PLATFORM_MIN_SIZE, self.platform.size – PLATFORM_SHRINK_AMOUNT)
self.platform = Platform(old_size)
self.turns_this_round = 0
self._spawn_penguins()
self._begin_aim_phase()

# ── Draw ──────────────────────────────────────────
def draw(self, fonts):
title_f, btn_f, small_f = fonts
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
draw_sky()

# ── 3-D setup ─────────────────────────────────
glMatrixMode(GL_PROJECTION); glLoadIdentity()
gluPerspective(58, SCREEN_W/SCREEN_H, 0.1, 400)
glMatrixMode(GL_MODELVIEW); glLoadIdentity()

# ── Camera ────────────────────────────────────
if self.state == “spectate” and self.freecam_enabled:
self.freecam.apply()
px, py, pz = self.freecam.x, self.freecam.y, self.freecam.z
elif self.state == “spectate”:
alive_bots = [p for p in self.penguins if not p.is_player and p.alive and not p.knocked]
tgt = alive_bots[self.spectate_target % len(alive_bots)] if alive_bots else self.penguins[0]
px, py, pz = tgt.pos
gluLookAt(px, py + 10, pz + 16,
px, py + 1.0, pz,
0, 1, 0)
else:
px, py, pz = self.player.pos
gluLookAt(px, py + 10, pz + 16,
px, py + 1.0, pz,
0, 1, 0)

# ── Atmospheric fog ───────────────────────────
glEnable(GL_FOG)
glFogi(GL_FOG_MODE, GL_LINEAR)
glFogfv(GL_FOG_COLOR, [0.72, 0.88, 1.0, 1.0])
glFogf(GL_FOG_START, 60.0)
glFogf(GL_FOG_END, 180.0)

# ── Multi-light setup ─────────────────────────
glEnable(GL_LIGHTING)
glLightModelfv(GL_LIGHT_MODEL_AMBIENT, [0.18, 0.22, 0.30, 1.0])

# Key light – warm sun from upper-right
glEnable(GL_LIGHT0)
glLightfv(GL_LIGHT0, GL_POSITION, [8, 20, 10, 1])
glLightfv(GL_LIGHT0, GL_DIFFUSE, [1.10, 1.05, 0.90, 1])
glLightfv(GL_LIGHT0, GL_SPECULAR, [1.00, 0.98, 0.88, 1])

# Fill light – cool blue from opposite side
glEnable(GL_LIGHT1)
glLightfv(GL_LIGHT1, GL_POSITION, [-10, 8, -6, 1])
glLightfv(GL_LIGHT1, GL_DIFFUSE, [0.25, 0.35, 0.55, 1])
glLightfv(GL_LIGHT1, GL_SPECULAR, [0.0, 0.0, 0.0, 1])

# Rim light – icy bounce from below-front
glEnable(GL_LIGHT2)
glLightfv(GL_LIGHT2, GL_POSITION, [0, -4, 14, 1])
glLightfv(GL_LIGHT2, GL_DIFFUSE, [0.10, 0.20, 0.35, 1])
glLightfv(GL_LIGHT2, GL_SPECULAR, [0.0, 0.0, 0.0, 1])

glEnable(GL_COLOR_MATERIAL)
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)

# ── Scene objects ─────────────────────────────
draw_water(px, pz)
self.platform.draw()

# Arrow (only during aiming for the player)
if self.state == “aiming” and self.player.alive and not self.player.knocked:
glDisable(GL_LIGHTING)
glPushMatrix()
glTranslatef(*self.player.pos)
draw_arrow(self.player_yaw, self.player_power)
glPopMatrix()
glEnable(GL_LIGHTING)

for p in self.penguins:
p.draw()

# ── Nametags (project 3D → 2D) ────────────────
glDisable(GL_LIGHTING)
glDisable(GL_FOG)
# Capture projection matrices while still in 3D mode
mv = glGetDoublev(GL_MODELVIEW_MATRIX)
prj = glGetDoublev(GL_PROJECTION_MATRIX)
vp = glGetIntegerv(GL_VIEWPORT)
begin_2d()
title_f2, btn_f2, small_f2 = fonts
for p in self.penguins:
if not p.alive or p.knocked: continue
wx, wy, wz = p.pos[0], p.pos[1]+1.8, p.pos[2]
try:
sx, sy, sz = gluProject(wx, wy, wz, mv, prj, vp)
except Exception:
continue
if sz < 0 or sz > 1: continue
screen_x = int(sx) – 20
screen_y = int(SCREEN_H – sy) – 14
if p.is_player:
render_text(small_f2, T(“You”), screen_x, screen_y, (255, 60, 60))
else:
render_text(small_f2, p.name, screen_x, screen_y, (15, 15, 15))
end_2d()

# ── HUD ──────────────────────────────────────────────────────────────
begin_2d()

render_text(btn_f, f”{T(‘Round’)} {self.round_num}”, 20, 20, (255,255,255))
render_text(small_f, f”{T(‘Penguins left:’)} {self.alive_count()}”, 20, 60, (220,240,255))

# Platform size bar
ratio = clamp((self.platform.size – PLATFORM_MIN_SIZE) / (20 – PLATFORM_MIN_SIZE), 0, 1)
bar_w = 160
draw_rect_2d(SCREEN_W-bar_w-20, 20, bar_w, 16, (0.1,0.1,0.1), 0.7)
draw_rect_2d(SCREEN_W-bar_w-20, 20, int(bar_w*ratio),16, (0.3,0.85,1.0), 0.9)
render_text(small_f, T(“Ice size”), SCREEN_W-bar_w-20, 38, (180,235,255))

# ── State overlays ────────────────────────────
if self.state == “intro_count”:
n = math.ceil(self.intro_timer)
render_text(title_f, str(n) if n > 0 else T(“GO!”),
SCREEN_W//2 – 40, SCREEN_H//2 – 60, (255,220,80))

elif self.state == “aiming”:
secs_left = max(0.0, self.aim_timer)
urgency = secs_left < 2.0
t_col = (255, 80, 80) if urgency else (255, 220, 80)
render_text(title_f, f”{secs_left:.1f}”, SCREEN_W//2 – 45, 18, t_col)

if self.player_locked:
render_text(btn_f, T(“Aim locked! Waiting for others…”),
SCREEN_W//2 – 210, SCREEN_H//2 – 30, (100, 255, 100))
elif self.player.alive and not self.player.knocked:
sx, sy, sw, sh = self._slider_rect()
draw_rect_2d(sx-2, sy-2, sw+4, sh+4, (0.1,0.1,0.1), 0.85)
draw_rect_2d(sx, sy, sw, sh, (0.15,0.20,0.35), 0.9)
fill = int(sw * self.player_power)
r = 0.2 + self.player_power * 0.8
g = 0.8 – self.player_power * 0.6
draw_rect_2d(sx, sy, fill, sh, (r, g, 0.1), 1.0)
render_text(small_f, f”{T(‘Power:’)} {int(self.player_power*100)}%”,
sx, sy – 24, (255,255,255))
render_text(small_f, T(“Mouse = aim”), sx – 160, sy + 38, (200,220,255))
render_text(small_f, T(“Q / E = power -/+5%”), sx – 160, sy + 62, (200,220,255))
render_text(small_f, T(“R = lock in aim”), sx – 160, sy + 86, (255,210,80))

elif self.state == “sliding”:
render_text(small_f, T(“Sliding…”), SCREEN_W//2 – 50, 20, (200,235,255))

elif self.state == “spectate”:
draw_rect_2d(0, 0, SCREEN_W, 52, (0.0,0.0,0.0), 0.55)
alive_bots = [p for p in self.penguins if not p.is_player and p.alive and not p.knocked]
if self.freecam_enabled:
render_text(btn_f, T(“FREE CAM”), SCREEN_W//2 – 70, 8, (100, 220, 255))
else:
idx = (self.spectate_target % len(alive_bots)) if alive_bots else 0
render_text(btn_f, f”{T(‘SPECTATING BOT’)} {idx+1}”, SCREEN_W//2 – 130, 8, (255,200,80))
if self.spectate_sub == “spec_aiming” and not self.freecam_enabled:
secs = max(0.0, self.spectate_timer)
col = (255,80,80) if secs < 2.0 else (255,220,80)
render_text(title_f, f”{secs:.1f}”, SCREEN_W//2 + 130, 8, col)
elif not self.freecam_enabled:
render_text(small_f, T(“Sliding…”), SCREEN_W//2 + 105, 16, (180,220,255))
draw_rect_2d(0, SCREEN_H-54, SCREEN_W, 54, (0.0,0.0,0.0), 0.55)
if self.freecam_enabled:
render_text(small_f, T(“WASD = move SPACE/SHIFT = up/down F = exit freecam Q = leave”),
SCREEN_W//2 – 310, SCREEN_H – 42, (200,220,255))
else:
render_text(small_f, T(“TAB = next bot F = free camera Q = leave spectate”),
SCREEN_W//2 – 220, SCREEN_H – 42, (200,220,255))

elif self.state == “round_over”:
draw_rect_2d(SCREEN_W//2-220, SCREEN_H//2-60, 440, 100, (0.05,0.20,0.35), 0.88)
render_text(title_f, T(“ROUND CLEARED!”), SCREEN_W//2-190, SCREEN_H//2-50, (100,230,255))
render_text(small_f, T(“Next round starting…”), SCREEN_W//2-100, SCREEN_H//2+10, (180,235,255))

elif self.state == “game_over”:
wc = (self.menu.gamemode == 1)
if self.player.alive and not self.player.knocked:
if wc:
msg, col = T(“WORLD CUP WINNER!”), (255, 220, 50)
sub = T(“+50 coins rewarded!”)
else:
msg, col = T(“YOU WIN!”), (255, 220, 50)
sub = T(“Press R to restart | ESC for menu”)
else:
msg, col = T(“KNOCKED OUT!”), (255, 80, 80)
sub = T(“Press R to restart | ESC for menu”)
draw_rect_2d(SCREEN_W//2-260, SCREEN_H//2-80, 520, 130, (0.03,0.05,0.18), 0.92)
render_text(title_f, msg, SCREEN_W//2-180, SCREEN_H//2-70, col)
render_text(small_f, sub, SCREEN_W//2-180, SCREEN_H//2+10, (200,230,255))
render_text(small_f, T(“R = restart | ESC = menu”), SCREEN_W//2-130, SCREEN_H//2+38, (160,180,220))

end_2d()
pygame.display.flip()

# ── Main ──────────────────────────────────────────────────────────────────────
def main():
pygame.init()
pygame.mixer.init(frequency=22050, size=-16, channels=2, buffer=512)
pygame.display.gl_set_attribute(GL_MULTISAMPLEBUFFERS, 1)
pygame.display.gl_set_attribute(GL_MULTISAMPLESAMPLES, 4)
pygame.display.gl_set_attribute(GL_DEPTH_SIZE, 24)
pygame.display.set_caption(“Penguin Knockout 3D”)
screen = pygame.display.set_mode(
(SCREEN_W, SCREEN_H),
DOUBLEBUF | OPENGL
)
clock = pygame.time.Clock()

# Build sounds now that mixer is ready
global SFX
try:
SFX = build_sounds()
except Exception as e:
print(f”[Sound] Could not build sounds: {e}”)
SFX = {}

glEnable(GL_DEPTH_TEST)
glEnable(GL_NORMALIZE)
glShadeModel(GL_SMOOTH)
glEnable(GL_MULTISAMPLE)
glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST)
glHint(GL_LINE_SMOOTH_HINT, GL_NICEST)
glEnable(GL_LINE_SMOOTH)
glClearColor(0.72, 0.88, 1.0, 1) # matches fog/horizon colour

title_f = pygame.font.SysFont(“Arial”, 52, bold=True)
btn_f = pygame.font.SysFont(“Arial”, 32, bold=True)
small_f = pygame.font.SysFont(“Arial”, 22)
fonts = (title_f, btn_f, small_f)

# ── Account / login screen ──────────────────────────
acct = AccountScreen()
while not acct.done:
dt = clock.tick(60) / 1000.0
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit(); sys.exit()
acct.handle(event, fonts)
acct.draw(fonts)

save_data = acct.save_data
menu = Menu(save_data)
scene = None
mode = “menu”

while True:
# Respect FPS setting from menu (0 = unlimited)
target_fps = menu.FPS_OPTIONS[menu.fps_idx]
dt = clock.tick(target_fps) / 1000.0
dt = min(dt, 0.1) # cap delta to avoid physics explosion at low FPS

for event in pygame.event.get():
if event.type == QUIT:
pygame.event.set_grab(False)
pygame.quit(); sys.exit()

if mode == “menu”:
menu.handle(event)
if menu.choice == “exit”:
pygame.quit(); sys.exit()
if menu.choice == “play”:
scene = GameScene(menu, fonts)
mode = “game”
menu.choice = None

elif mode == “game”:
result = scene.handle(event)
if result == “menu”:
pygame.event.set_grab(False)
pygame.mouse.set_visible(True)
mode = “menu”
menu.choice = None
menu.screen_mode = “main”

if mode == “menu”:
menu.draw(fonts, dt)
elif mode == “game”:
scene.update(dt)
scene.draw(fonts)

if __name__ == “__main__”:
main()

Share This: