import json import math import os import random from pathlib import Path import pygame WIDTH, HEIGHT = 900, 700 HORIZON, GROUND = 170, 635 VIEW_DISTANCE = 180.0 PLAYER_Z = 7.0 HIGH_SCORE_FILE = Path.cwd() / "high_scores.json" LATEST_RUN_FILE = Path("/tmp/alpaca_score.json") pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("Temple Trail") clock = pygame.time.Clock() FONT = pygame.font.Font(None, 30) SMALL = pygame.font.Font(None, 23) LARGE = pygame.font.Font(None, 62) MEDIUM = pygame.font.Font(None, 40) def clean_name(value): return "".join( c for c in str(value).upper() if c.isascii() and (c.isalnum() or c in " _-") ).strip()[:12] or "RUNNER" def load_scores(): try: data = json.loads(HIGH_SCORE_FILE.read_text(encoding="utf-8")) if not isinstance(data, list): return [] scores = [] for item in data: if not isinstance(item, dict): continue score = item.get("score") if isinstance(score, bool) or not isinstance(score, (int, float)): continue if not math.isfinite(score) or score < 0: continue scores.append({ "initials": clean_name(item.get("initials", item.get("name", "RUNNER"))), "score": int(score), }) return sorted(scores, key=lambda entry: entry["score"], reverse=True)[:5] except (OSError, ValueError, TypeError, OverflowError): return [] def write_json(path, value): temporary = path.with_name(path.name + ".tmp") try: path.parent.mkdir(parents=True, exist_ok=True) temporary.write_text(json.dumps(value, indent=2), encoding="utf-8") os.replace(temporary, path) return True except OSError: return False def text(message, x, y, font=FONT, color=(245, 240, 217), center=False): image = font.render(str(message), True, color) rect = image.get_rect(center=(x, y)) if center else image.get_rect(topleft=(x, y)) screen.blit(image, rect) class Game: def __init__(self): self.high_scores = load_scores() self.state = "title" self.save_warning = "" self.initials = "" self.saved = False self.reset_world() def reset_world(self): self.distance = 0.0 self.score = 0 self.bonus = 0 self.coins = 0 self.speed = 27.0 self.elapsed = 0.0 self.lane = 1 self.lane_position = 1.0 self.jump_height = 0.0 self.jump_velocity = 0.0 self.objects = [] self.spawn_timer = 0.45 def start(self): self.reset_world() self.initials = "" self.saved = False self.save_warning = "" self.state = "playing" pygame.key.stop_text_input() def finish(self): if self.state != "playing": return self.state = "name" self.initials = "" self.saved = False pygame.key.start_text_input() def record_run(self): if self.saved: return entry = {"initials": clean_name(self.initials), "score": int(self.score)} self.high_scores.append(entry) self.high_scores.sort(key=lambda item: item["score"], reverse=True) self.high_scores = self.high_scores[:5] board_ok = write_json(HIGH_SCORE_FILE, self.high_scores) latest_ok = write_json(LATEST_RUN_FILE, entry) if not board_ok or not latest_ok: self.save_warning = "Could not save: " + ", ".join( label for ok, label in ( (board_ok, "high_scores.json"), (latest_ok, "/tmp/alpaca_score.json"), ) if not ok ) self.saved = True pygame.key.stop_text_input() self.state = "over" def project(self, lane, z): depth = max(0.0, min(1.0, 1.0 - z / VIEW_DISTANCE)) perspective = depth ** 1.65 scale = 0.11 + 0.89 * perspective bend = ( math.sin(self.distance * 0.007 + z * 0.012) - math.sin(self.distance * 0.007) ) * 78 * (1.0 - perspective) center = WIDTH / 2 + bend return center + (lane - 1) * 147 * scale, HORIZON + (GROUND - HORIZON) * perspective, scale def spawn_row(self): blocked_count = 2 if random.random() < 0.42 else 1 blocked = random.sample([0, 1, 2], blocked_count) for lane in blocked: kind = "pillar" if random.random() < 0.28 else "barrier" self.objects.append({ "lane": lane, "z": VIEW_DISTANCE, "kind": kind, "resolved": False }) safe_lane = random.choice([lane for lane in range(3) if lane not in blocked]) for offset in (0, 8, 16): self.objects.append({ "lane": safe_lane, "z": VIEW_DISTANCE + offset, "kind": "coin", "resolved": False, }) def update(self, dt): if self.state != "playing": return self.elapsed += dt self.speed = min(76.0, 27.0 + self.elapsed * 0.65) travel = self.speed * dt self.distance += travel self.lane_position += (self.lane - self.lane_position) * min(1.0, dt * 15) if self.jump_height > 0 or self.jump_velocity > 0: self.jump_velocity -= 24.0 * dt self.jump_height += self.jump_velocity * dt if self.jump_height <= 0: self.jump_height = 0.0 self.jump_velocity = 0.0 self.spawn_timer -= dt if self.spawn_timer <= 0: self.spawn_row() self.spawn_timer = random.uniform(1.20, 1.65) for obj in self.objects: previous_z = obj["z"] obj["z"] -= travel if obj["resolved"] or not (obj["z"] <= PLAYER_Z < previous_z): continue obj["resolved"] = True same_lane = abs(self.lane_position - obj["lane"]) < 0.58 if obj["kind"] == "coin": if same_lane: self.bonus += 25 self.coins += 1 else: clears = obj["kind"] == "barrier" and self.jump_height > 1.35 if same_lane and not clears: self.score = int(self.distance) + self.bonus self.finish() return self.bonus += 10 self.objects = [obj for obj in self.objects if obj["z"] > -12] self.score = int(self.distance) + self.bonus def draw_background(self): screen.fill((43, 77, 91)) pygame.draw.circle(screen, (250, 188, 96), (700, 91), 43) pygame.draw.polygon(screen, (40, 67, 70), [ (0, 190), (0, 117), (120, 62), (230, 165), (345, 79), (470, 183), (600, 104), (790, 175), (900, 86), (900, 215) ]) pygame.draw.rect(screen, (27, 65, 50), (0, HORIZON, WIDTH, HEIGHT)) for segment in range(35, 0, -1): far_z = segment * VIEW_DISTANCE / 35 near_z = (segment - 1) * VIEW_DISTANCE / 35 left_far = self.project(-0.65, far_z)[:2] right_far = self.project(2.65, far_z)[:2] left_near = self.project(-0.65, near_z)[:2] right_near = self.project(2.65, near_z)[:2] band = int((near_z + self.distance) / 12) % 2 color = (126, 107, 78) if band else (137, 116, 84) pygame.draw.polygon(screen, color, [left_far, right_far, right_near, left_near]) for edge in (-0.65, 2.65): a = self.project(edge, far_z) b = self.project(edge, near_z) pygame.draw.line(screen, (220, 182, 107), a[:2], b[:2], max(1, int(9 * b[2]))) for lane_edge in (0.5, 1.5): for index in range(19): z = index * 12 - (self.distance % 12) if z < 0 or z + 6 > VIEW_DISTANCE: continue a = self.project(lane_edge, z) b = self.project(lane_edge, z + 6) pygame.draw.line(screen, (202, 174, 120), a[:2], b[:2], max(1, int(3 * a[2]))) for index in range(12): z = index * 18 - (self.distance % 18) if z < 0: continue for side in (-1.25, 3.25): x, y, scale = self.project(side, z) w, h = 34 * scale, 128 * scale pygame.draw.rect(screen, (44, 49, 39), (int(x - w / 2), int(y - h), max(1, int(w)), int(h))) pygame.draw.rect(screen, (71, 80, 52), (int(x - w * 0.75), int(y - h), int(w * 1.5), max(2, int(13 * scale)))) def draw_object(self, obj): if obj["z"] > VIEW_DISTANCE or (obj["kind"] == "coin" and obj["resolved"]): return x, y, scale = self.project(obj["lane"], obj["z"]) if obj["kind"] == "coin": radius = max(3, int(13 * scale)) center = (int(x), int(y - 36 * scale)) pygame.draw.circle(screen, (255, 202, 38), center, radius) pygame.draw.circle(screen, (255, 244, 146), center, radius, max(1, int(3 * scale))) return width = 88 * scale height = (137 if obj["kind"] == "pillar" else 43) * scale depth = 17 * scale pygame.draw.ellipse(screen, (66, 62, 46), (int(x - width * 0.7), int(y - 7 * scale), int(width * 1.4), max(2, int(17 * scale)))) rect = pygame.Rect(int(x - width / 2), int(y - height), int(width), int(height)) color = (89, 103, 100) if obj["kind"] == "pillar" else (162, 64, 43) pygame.draw.rect(screen, color, rect) pygame.draw.polygon(screen, (191, 169, 116), [ (rect.left, rect.top), (rect.left + depth, rect.top - depth), (rect.right + depth, rect.top - depth), (rect.right, rect.top) ]) pygame.draw.polygon(screen, (60, 65, 57), [ (rect.right, rect.top), (rect.right + depth, rect.top - depth), (rect.right + depth, rect.bottom - depth), (rect.right, rect.bottom) ]) if obj["kind"] == "barrier": pygame.draw.rect(screen, (244, 186, 66), (rect.left, rect.top + int(12 * scale), rect.width, max(2, int(8 * scale)))) else: pygame.draw.line(screen, (144, 155, 136), (int(x), rect.top + 15 * scale), (int(x), rect.bottom - 15 * scale), max(2, int(5 * scale))) def draw_player(self): x, ground_y, scale = self.project(self.lane_position, PLAYER_Z) y = ground_y - self.jump_height * 43 * scale shadow_width = int(57 * scale / (1 + self.jump_height * 0.15)) pygame.draw.ellipse(screen, (58, 54, 42), (int(x - shadow_width / 2), int(ground_y - 6), shadow_width, 13)) stride = math.sin(self.elapsed * 18) * 13 if self.jump_height == 0 else 8 thick = max(3, int(10 * scale)) hip = (int(x), int(y - 34 * scale)) for side in (-1, 1): foot = (int(x + side * 13 * scale), int(y - max(0, side * stride) * scale)) pygame.draw.line(screen, (35, 43, 69), hip, foot, thick) pygame.draw.line(screen, (36, 30, 27), foot, (foot[0] + int(side * 7 * scale), foot[1]), thick) pygame.draw.rect(screen, (37, 166, 183), (int(x - 17 * scale), int(y - 76 * scale), int(34 * scale), int(45 * scale)), border_radius=6) for side in (-1, 1): shoulder = (int(x + side * 18 * scale), int(y - 69 * scale)) hand = (int(x + side * 26 * scale), int(y - (43 + side * stride * 0.6) * scale)) pygame.draw.line(screen, (237, 178, 122), shoulder, hand, max(3, int(8 * scale))) pygame.draw.circle(screen, (234, 177, 119), (int(x), int(y - 90 * scale)), max(4, int(15 * scale))) pygame.draw.rect(screen, (102, sixty := 60, thirty := 30), (int(x - 21 * scale), int(y - 100 * scale), int(42 * scale), max(3, int(8 * scale)))) pygame.draw.rect(screen, (133, 86, 40), (int(x - 12 * scale), int(y - 113 * scale), int(24 * scale), int(17 * scale))) def draw_board(self, top): text("TOP 5 RUNNERS", WIDTH / 2, top, MEDIUM, (250, 208, 108), True) if not self.high_scores: text("No scores yet — make your first run!", WIDTH / 2, top + 45, SMALL, center=True) for index, entry in enumerate(self.high_scores): y = top + 38 + index * 29 text(f"{index + 1}. {entry['initials']}", 275, y, SMALL) image = SMALL.render(f"{entry['score']:,}", True, (245, 240, 217)) screen.blit(image, (625 - image.get_width(), y)) def draw(self): self.draw_background() distant = [obj for obj in self.objects if obj["z"] >= PLAYER_Z] close = [obj for obj in self.objects if obj["z"] < PLAYER_Z] for obj in sorted(distant, key=lambda obj: obj["z"], reverse=True): self.draw_object(obj) self.draw_player() for obj in sorted(close, key=lambda obj: obj["z"], reverse=True): self.draw_object(obj) pygame.draw.rect(screen, (20, 32, 34), (0, 0, WIDTH, 68)) text(f"SCORE {self.score:,}", 22, 12) text(f"{int(self.distance)} m", 345, 12) text(f"COINS {self.coins}", 545, 12) text(f"{self.speed:.0f} m/s", 770, 12) text("LEFT / RIGHT: switch lanes UP: jump Gold +25 Dodge +10 Distance +1/m", WIDTH / 2, 53, SMALL, (189, 206, 197), True) if self.state == "playing": return veil = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA) veil.fill((0, 0, 0, 145)) screen.blit(veil, (0, 0)) pygame.draw.rect(screen, (23, 38, 42), (170, 95, 560, 555), border_radius=18) pygame.draw.rect(screen, (181, 151, 88), (170, 95, 560, 555), 2, border_radius=18) if self.state == "title": text("TEMPLE TRAIL", WIDTH / 2, 145, LARGE, (255, 211, 108), True) text("Three lanes. One endless escape.", WIDTH / 2, 196, FONT, center=True) text("LEFT / RIGHT to move • UP to jump", WIDTH / 2, 233, SMALL, center=True) text("Jump red barriers; dodge tall stone pillars.", WIDTH / 2, 263, SMALL, center=True) text("Collect gold in any jump position.", WIDTH / 2, 291, SMALL, center=True) self.draw_board(345) text("ENTER or SPACE to run", WIDTH / 2, 588, MEDIUM, (116, 229, 194), True) else: text("RUN FINISHED", WIDTH / 2, 145, LARGE, (255, 211, 108), True) text(f"Score {self.score:,} • Distance {int(self.distance)} m", WIDTH / 2, 196, FONT, center=True) if self.state == "name": text("Enter name / initials, then press ENTER:", WIDTH / 2, 240, SMALL, center=True) pygame.draw.rect(screen, (10, 24, 28), (275, 261, 350, 45), border_radius=6) cursor = "_" if pygame.time.get_ticks() % 1000 < 500 else "" text(self.initials + cursor, WIDTH / 2, 284, MEDIUM, (116, 229, 194), True) text("Blank name defaults to RUNNER.", WIDTH / 2, 326, SMALL, center=True) else: text(f"Recorded as {clean_name(self.initials)}", WIDTH / 2, 258, FONT, (116, 229, 194), True) self.draw_board(370) if self.state == "over": text("ENTER / SPACE: new run ESC: exit", WIDTH / 2, 586, SMALL, center=True) else: text("ENTER: save score ESC: save as RUNNER", WIDTH / 2, 586, SMALL, center=True) if self.save_warning: text("Save failed; check console for details.", WIDTH / 2, 620, SMALL, (255, 151, 125), True) def main(): game = Game() running = True while running: dt = min(clock.tick(60) / 1000.0, 0.04) for event in pygame.event.get(): if event.type == pygame.QUIT: if game.state == "playing": game.finish() if game.state == "name": game.record_run() running = False break if event.type == pygame.TEXTINPUT and game.state == "name": for character in event.text.upper(): if character.isascii() and (character.isalnum() or character in " _-"): game.initials = (game.initials + character)[:12] if event.type != pygame.KEYDOWN: continue if game.state == "playing": if event.key == pygame.K_LEFT: game.lane = max(0, game.lane - 1) elif event.key == pygame.K_RIGHT: game.lane = min(2, game.lane + 1) elif event.key == pygame.K_UP and game.jump_height == 0: game.jump_velocity = 10.5 elif event.key == pygame.K_ESCAPE: game.finish() elif game.state == "name": if event.key == pygame.K_BACKSPACE: game.initials = game.initials[:-1] elif event.key in (pygame.K_RETURN, pygame.K_KP_ENTER, pygame.K_ESCAPE): if event.key == pygame.K_ESCAPE: game.initials = "" game.record_run() if game.save_warning: print(game.save_warning) elif event.key in (pygame.K_RETURN, pygame.K_KP_ENTER, pygame.K_SPACE): game.start() elif event.key == pygame.K_ESCAPE: running = False break if running: game.update(dt) game.draw() pygame.display.flip() if game.save_warning: print(game.save_warning) pygame.quit() if __name__ == "__main__": main()