import json import math import os import random import pygame from OpenGL.GL import * from OpenGL.GLU import gluPerspective WIDTH, HEIGHT = 800, 600 FIELD_X, FIELD_Y = 23.0, 16.0 HIGH_SCORE_FILE = "high_scores.json" LATEST_SCORE_FILE = "/tmp/alpaca_score.json" def load_scores(path): try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) if not isinstance(data, list): return [] result = [] for item in data: if not isinstance(item, dict): continue name = item.get("initials", item.get("name", "")) score = item.get("score") if isinstance(name, str) and isinstance(score, int) and score >= 0: result.append({"initials": name[:12], "score": score}) return sorted(result, key=lambda item: item["score"], reverse=True)[:5] except (OSError, ValueError, TypeError): return [] def load_latest(): try: with open(LATEST_SCORE_FILE, "r", encoding="utf-8") as f: item = json.load(f) if (isinstance(item, dict) and isinstance(item.get("initials"), str) and isinstance(item.get("score"), int)): return item except (OSError, ValueError, TypeError): pass return None def write_json(path, data): temporary = path + ".tmp" with open(temporary, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) f.flush() os.fsync(f.fileno()) os.replace(temporary, path) def normalize(v): length = math.sqrt(sum(x * x for x in v)) return tuple(x / length for x in v) if length else (0, 0, 1) def triangle(a, b, c): u = tuple(b[i] - a[i] for i in range(3)) v = tuple(c[i] - a[i] for i in range(3)) n = normalize((u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0])) glNormal3f(*n) glVertex3f(*a) glVertex3f(*b) glVertex3f(*c) def make_mesh(rough=False): top, bottom = (0, 0, 1), (0, 0, -1) ring = [(1, 0, 0), (0, 1, 0), (-1, 0, 0), (0, -1, 0)] faces = [] for i in range(4): a, b = ring[i], ring[(i + 1) % 4] faces.extend([(top, a, b), (bottom, b, a)]) refined = [] for a, b, c in faces: ab = normalize(tuple(a[i] + b[i] for i in range(3))) bc = normalize(tuple(b[i] + c[i] for i in range(3))) ca = normalize(tuple(c[i] + a[i] for i in range(3))) refined.extend([(a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)]) vertices = {} mesh = [] for face in refined: result = [] for vertex in face: if vertex not in vertices: factor = random.uniform(0.78, 1.18) if rough else 1.0 vertices[vertex] = tuple(x * factor for x in vertex) result.append(vertices[vertex]) mesh.append(tuple(result)) return mesh def draw_mesh(mesh): glBegin(GL_TRIANGLES) for face in mesh: triangle(*face) glEnd() def wrap(value, bound): return (value + bound) % (2 * bound) - bound def distance_squared(a, b): dx = wrap(a[0] - b[0], FIELD_X) dy = wrap(a[1] - b[1], FIELD_Y) return dx * dx + dy * dy class Asteroid: def __init__(self, size=3, position=None, velocity=None): self.size = size self.radius = {1: 0.62, 2: 1.12, 3: 1.95}[size] self.position = list(position or ( random.uniform(-FIELD_X, FIELD_X), random.uniform(-FIELD_Y, FIELD_Y), random.uniform(-0.9, 0.9))) speed = random.uniform(1.3, 2.6) * (1.5 - size * 0.15) angle = random.uniform(0, math.tau) self.velocity = list(velocity or ( math.cos(angle) * speed, math.sin(angle) * speed, random.uniform(-0.22, 0.22))) self.rotation = [random.uniform(0, 360) for _ in range(3)] self.spin = [random.uniform(-38, 38) for _ in range(3)] self.mesh = make_mesh(True) shade = random.uniform(0.5, 0.78) self.color = (shade, shade * 0.82, shade * 0.64) def update(self, dt): for i in range(3): self.position[i] += self.velocity[i] * dt self.rotation[i] += self.spin[i] * dt self.position[0] = wrap(self.position[0], FIELD_X) self.position[1] = wrap(self.position[1], FIELD_Y) if abs(self.position[2]) > 1.0: self.velocity[2] = -math.copysign(abs(self.velocity[2]), self.position[2]) def draw(self): glPushMatrix() glTranslatef(*self.position) for angle, axis in zip(self.rotation, ((1, 0, 0), (0, 1, 0), (0, 0, 1))): glRotatef(angle, *axis) glScalef(self.radius, self.radius, self.radius) glColor3f(*self.color) draw_mesh(self.mesh) glPopMatrix() class Game: def __init__(self): pygame.init() pygame.display.set_mode((800, 600), pygame.OPENGL | pygame.DOUBLEBUF) pygame.display.set_caption("Maelstrom Drift - 3D Asteroids") self.clock = pygame.time.Clock() self.fonts = { "small": pygame.font.Font(None, 23), "normal": pygame.font.Font(None, 29), "large": pygame.font.Font(None, 58), } self.text_cache = {} self.scores = load_scores(HIGH_SCORE_FILE) self.latest = load_latest() self.state = "title" self.running = True self.quit_after_entry = False self.initials = "" self.save_message = "" self.score = 0 self.lives = 3 self.wave = 0 self.time = 0 self.ship_position = [0.0, 0.0, 0.0] self.ship_velocity = [0.0, 0.0] self.ship_angle = 0.0 self.invulnerable = 0 self.cooldown = 0 self.thrusting = False self.bullets = [] self.asteroids = [Asteroid() for _ in range(9)] self.bullet_mesh = make_mesh() self.stars = [ (random.uniform(-65, 65), random.uniform(-45, 45), random.uniform(-35, -9), random.uniform(0.35, 1)) for _ in range(260) ] glClearColor(0.008, 0.012, 0.03, 1) glEnable(GL_DEPTH_TEST) glEnable(GL_NORMALIZE) glEnable(GL_LIGHTING) glEnable(GL_LIGHT0) glEnable(GL_COLOR_MATERIAL) glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE) glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.95, 0.95, 1.0, 1)) glLightModelfv(GL_LIGHT_MODEL_AMBIENT, (0.24, 0.25, 0.32, 1)) glShadeModel(GL_FLAT) glPixelStorei(GL_UNPACK_ALIGNMENT, 1) def new_game(self): self.score = 0 self.lives = 3 self.wave = 0 self.bullets = [] self.asteroids = [] self.initials = "" self.save_message = "" self.quit_after_entry = False self.reset_ship() self.next_wave() self.state = "playing" def reset_ship(self): self.ship_position = [0.0, 0.0, 0.0] self.ship_velocity = [0.0, 0.0] self.ship_angle = 0.0 self.invulnerable = 3.0 self.cooldown = 0.25 self.thrusting = False def next_wave(self): self.wave += 1 for _ in range(min(4 + self.wave, 15)): asteroid = Asteroid() while distance_squared(asteroid.position, self.ship_position) < 64: asteroid.position[0] = random.uniform(-FIELD_X, FIELD_X) asteroid.position[1] = random.uniform(-FIELD_Y, FIELD_Y) self.asteroids.append(asteroid) def finish_run(self): if self.state != "playing": return self.state = "entry" self.thrusting = False self.initials = "" pygame.key.start_text_input() def record_run(self): if self.state != "entry": return name = self.initials.strip() or "AAA" entry = {"initials": name, "score": int(self.score)} self.latest = entry self.scores.append(entry) self.scores = sorted( self.scores, key=lambda item: item["score"], reverse=True)[:5] errors = [] for path, data in ((HIGH_SCORE_FILE, self.scores), (LATEST_SCORE_FILE, entry)): try: write_json(path, data) except OSError as exc: errors.append(os.path.basename(path)) print("Could not save", path, ":", exc) self.save_message = ( "Save failed: " + ", ".join(errors) if errors else "Score saved.") pygame.key.stop_text_input() self.state = "gameover" if self.quit_after_entry: self.running = False def events(self): for event in pygame.event.get(): if event.type == pygame.QUIT: if self.state == "playing": self.quit_after_entry = True self.finish_run() elif self.state == "entry": self.quit_after_entry = True self.record_run() else: self.running = False elif event.type == pygame.TEXTINPUT and self.state == "entry": clean = "".join(c for c in event.text.upper() if c.isascii() and (c.isalnum() or c in "_-")) self.initials = (self.initials + clean)[:12] elif event.type == pygame.KEYDOWN: if self.state == "entry": if event.key == pygame.K_BACKSPACE: self.initials = self.initials[:-1] elif event.key in (pygame.K_RETURN, pygame.K_KP_ENTER): self.record_run() elif event.key == pygame.K_ESCAPE: self.record_run() elif self.state in ("title", "gameover"): if event.key in (pygame.K_RETURN, pygame.K_KP_ENTER): self.new_game() elif event.key == pygame.K_ESCAPE: self.running = False elif event.key == pygame.K_ESCAPE: self.finish_run() def shoot(self): angle = math.radians(self.ship_angle) direction = (-math.sin(angle), math.cos(angle)) self.bullets.append({ "position": [ self.ship_position[0] + direction[0] * 1.25, self.ship_position[1] + direction[1] * 1.25, 0.0], "velocity": [ self.ship_velocity[0] + direction[0] * 28, self.ship_velocity[1] + direction[1] * 28], "life": 1.35, }) self.cooldown = 0.16 def update(self, dt): self.time += dt if self.state in ("entry", "gameover"): return for asteroid in self.asteroids: asteroid.update(dt) if self.state != "playing": return keys = pygame.key.get_pressed() turn = int(keys[pygame.K_LEFT] or keys[pygame.K_a]) turn -= int(keys[pygame.K_RIGHT] or keys[pygame.K_d]) self.ship_angle += turn * 205 * dt self.thrusting = bool(keys[pygame.K_UP] or keys[pygame.K_w]) if self.thrusting: angle = math.radians(self.ship_angle) self.ship_velocity[0] -= math.sin(angle) * 13 * dt self.ship_velocity[1] += math.cos(angle) * 13 * dt speed = math.hypot(*self.ship_velocity) if speed > 15: self.ship_velocity = [v * 15 / speed for v in self.ship_velocity] for i, bound in enumerate((FIELD_X, FIELD_Y)): self.ship_velocity[i] *= math.exp(-0.16 * dt) self.ship_position[i] = wrap( self.ship_position[i] + self.ship_velocity[i] * dt, bound) self.invulnerable = max(0, self.invulnerable - dt) self.cooldown -= dt if keys[pygame.K_SPACE] and self.cooldown <= 0: self.shoot() surviving_bullets = [] for bullet in self.bullets: bullet["life"] -= dt if bullet["life"] <= 0: continue for i, bound in enumerate((FIELD_X, FIELD_Y)): bullet["position"][i] = wrap( bullet["position"][i] + bullet["velocity"][i] * dt, bound) hit = next((a for a in self.asteroids if distance_squared(a.position, bullet["position"]) < (a.radius + 0.18) ** 2), None) if hit is None: surviving_bullets.append(bullet) continue self.score += {3: 20, 2: 50, 1: 100}[hit.size] self.asteroids.remove(hit) if hit.size > 1: angle = random.uniform(0, math.tau) for sign in (-1, 1): dx, dy = math.cos(angle) * sign, math.sin(angle) * sign position = [hit.position[0] + dx * 0.4, hit.position[1] + dy * 0.4, hit.position[2]] velocity = [hit.velocity[0] + dx * 2.8, hit.velocity[1] + dy * 2.8, random.uniform(-0.2, 0.2)] self.asteroids.append(Asteroid(hit.size - 1, position, velocity)) self.bullets = surviving_bullets if self.invulnerable <= 0: for asteroid in self.asteroids: if distance_squared(asteroid.position, self.ship_position) < ( asteroid.radius + 0.55) ** 2: self.lives -= 1 if self.lives <= 0: self.finish_run() else: self.reset_ship() break if self.state == "playing" and not self.asteroids: self.score += 250 * self.wave self.next_wave() def draw_ship(self): if self.state == "playing" and self.invulnerable > 0: if int(self.time * 12) % 2: return glPushMatrix() glTranslatef(*self.ship_position) glRotatef(self.ship_angle, 0, 0, 1) nose = (0, 1.15, 0) left = (-0.78, -0.72, -0.18) right = (0.78, -0.72, -0.18) ridge = (0, -0.25, 0.42) underside = (0, -0.4, -0.32) glBegin(GL_TRIANGLES) glColor3f(0.3, 0.8, 1) triangle(nose, left, ridge) glColor3f(0.8, 0.9, 1) triangle(nose, ridge, right) glColor3f(0.16, 0.35, 0.55) triangle(nose, underside, left) triangle(nose, right, underside) triangle(left, right, ridge) triangle(left, underside, right) if self.thrusting: flame = (0, -1.6 - 0.35 * math.sin(self.time * 55), 0) glColor3f(1, 0.4, 0.04) triangle((-0.3, -0.65, 0), flame, (0, -0.65, 0.25)) glColor3f(1, 0.85, 0.1) triangle((0, -0.65, 0.25), flame, (0.3, -0.65, 0)) glEnd() glPopMatrix() def text(self, value, x, y, size="normal", color=(225, 237, 255), center=False): key = (value, size, color) if key not in self.text_cache: if len(self.text_cache) > 256: self.text_cache.clear() surface = self.fonts[size].render(value, True, color) self.text_cache[key] = ( surface.get_width(), surface.get_height(), pygame.image.tostring(surface, "RGBA", True)) width, height, pixels = self.text_cache[key] if center: x -= width // 2 glRasterPos2f(x, HEIGHT - y - height) glDrawPixels(width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels) def panel(self, bottom=545): glColor4f(0.015, 0.025, 0.06, 0.9) glBegin(GL_QUADS) glVertex2f(95, HEIGHT - 65) glVertex2f(705, HEIGHT - 65) glVertex2f(705, HEIGHT - bottom) glVertex2f(95, HEIGHT - bottom) glEnd() def score_table(self, y): self.text("PERSISTENT TOP 5", 400, y, color=(255, 208, 105), center=True) for i in range(5): if i < len(self.scores): item = self.scores[i] name, score = item["initials"], str(item["score"]) else: name, score = "---", "---" self.text(f"{i + 1}. {name}", 220, y + 37 + i * 29) self.text(score, 520, y + 37 + i * 29) def draw_ui(self): glDisable(GL_LIGHTING) glDisable(GL_DEPTH_TEST) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glMatrixMode(GL_PROJECTION) glPushMatrix() glLoadIdentity() glOrtho(0, WIDTH, 0, HEIGHT, -1, 1) glMatrixMode(GL_MODELVIEW) glPushMatrix() glLoadIdentity() if self.state == "playing": self.text(f"SCORE {self.score}", 18, 15) self.text(f"LIVES {self.lives}", 325, 15) self.text(f"WAVE {self.wave}", 660, 15) self.text("Arrows/WAD: fly SPACE: fire ESC: finish run", 400, 573, "small", center=True) elif self.state == "entry": self.panel(470) self.text("RUN FINISHED", 400, 96, "large", center=True) self.text(f"Final score: {self.score}", 400, 175, center=True) self.text("Enter your name / initials (12 characters)", 400, 239, center=True) cursor = "_" if int(self.time * 2) % 2 == 0 else " " self.text(self.initials + cursor, 400, 290, "large", (255, 208, 105), True) self.text("ENTER saves Backspace edits Blank name becomes AAA", 400, 375, "small", center=True) if self.quit_after_entry: self.text("Your score will be saved before closing.", 400, 415, "small", center=True) else: self.panel() title = "MAELSTROM DRIFT" if self.state == "title" else "GAME OVER" self.text(title, 400, 86, "large", center=True) subtitle = ("Primitive geometry. Endless space. Three lives." if self.state == "title" else f"Final score: {self.score} {self.save_message}") self.text(subtitle, 400, 148, "small", center=True) self.score_table(198) if self.latest: self.text( f"Latest run: {self.latest['initials'][:12]} " f"{self.latest['score']}", 400, 399, "small", center=True) self.text("ENTER: new game ESC: quit", 400, 440, color=(120, 230, 255), center=True) self.text("Left/Right or A/D: rotate Up/W: thrust Space: fire", 400, 484, "small", center=True) self.text("Rocks: 20 / 50 / 100 pts Clear wave: 250 x wave", 400, 510, "small", center=True) glPopMatrix() glMatrixMode(GL_PROJECTION) glPopMatrix() glMatrixMode(GL_MODELVIEW) glDisable(GL_BLEND) glEnable(GL_DEPTH_TEST) glEnable(GL_LIGHTING) def draw(self): glViewport(0, 0, WIDTH, HEIGHT) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(50, WIDTH / HEIGHT, 0.1, 160) glMatrixMode(GL_MODELVIEW) glLoadIdentity() glTranslatef(0, 0, -49) glRotatef(8 + math.sin(self.time * 0.23) * 3, 1, 0, 0) glRotatef(math.sin(self.time * 0.19) * 5, 0, 1, 0) glRotatef(math.sin(self.time * 0.13) * 3, 0, 0, 1) glLightfv(GL_LIGHT0, GL_POSITION, (-12, 18, 24, 1)) glDisable(GL_LIGHTING) glPointSize(2) glBegin(GL_POINTS) for x, y, z, brightness in self.stars: glColor3f(brightness * 0.8, brightness * 0.88, brightness) glVertex3f(x, y, z) glEnd() glEnable(GL_LIGHTING) for asteroid in self.asteroids: asteroid.draw() if self.lives > 0 or self.state == "title": self.draw_ship() glDisable(GL_LIGHTING) glColor3f(0.5, 1, 0.85) for bullet in self.bullets: glPushMatrix() glTranslatef(*bullet["position"]) glScalef(0.15, 0.15, 0.3) draw_mesh(self.bullet_mesh) glPopMatrix() glEnable(GL_LIGHTING) self.draw_ui() pygame.display.flip() def run(self): try: while self.running: dt = min(self.clock.tick(60) / 1000.0, 0.033) self.events() if not self.running: break self.update(dt) self.draw() finally: pygame.quit() if __name__ == "__main__": Game().run()