"""Cosmic Candy Pinball - game logic + procedural pixel-art rendering.""" import math import os import random import pygame from physics import (CV_W, CV_H, PLAY_L, PLAY_R, PLAY_T, PLAY_B, BALL_R, CENTER_HOLE, OUTLANE_L, OUTLANE_R, DRAIN_Y, LAUNCH_DIR, LAUNCH_MIN, LAUNCH_MAX, Table, FLIP_PIVOT_L, FLIP_PIVOT_R, FLIP_RADIUS, FLIP_LEN, flipper_tip) import font as pix # --------------------------------------------------------------------------- # palette # --------------------------------------------------------------------------- C = dict( bg_top=(42, 26, 96), bg_bot=(14, 9, 42), hud=(22, 15, 56), hud_line=(255, 120, 190), field=(64, 50, 140), field2=(70, 54, 150), rail_dark=(22, 14, 52), rail=(255, 150, 200), rail_hi=(255, 210, 235), floor=(176, 140, 255), floor_hi=(226, 205, 255), floor_dark=(70, 50, 150), pit=(8, 5, 26), pit_glint=(60, 40, 120), white=(255, 255, 255), ink=(26, 18, 60), pink=(255, 138, 196), pink_d=(200, 82, 150), pink_l=(255, 200, 230), mint=(110, 240, 200), mint_d=(40, 170, 140), mint_l=(200, 255, 240), lemon=(255, 214, 92), lemon_d=(214, 150, 40), lemon_l=(255, 244, 190), candy=(200, 120, 255), candy_d=(140, 70, 210), flipper=(120, 205, 255), flipper_d=(50, 120, 210), flipper_l=(215, 245, 255), gold=(255, 214, 92), gold_d=(200, 130, 40), heart=(255, 90, 130), dim=(180, 170, 220), fur=(255, 230, 225), fur_d=(150, 110, 130), ear=(255, 130, 180), blush=(255, 140, 170), eye=(40, 30, 70), bubble=(30, 22, 78), bubble_b=(255, 150, 200), ) PASTELS = [(255, 158, 205), (255, 226, 140), (140, 245, 215), (165, 190, 255), (240, 165, 255), (255, 255, 255)] def star_pts(cx, cy, ro, ri, n=5, rot=-90.0): pts = [] for i in range(n * 2): r = ro if i % 2 == 0 else ri a = math.radians(rot + i * 180.0 / n) pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) return pts def draw_star(surf, cx, cy, ro, color, ri=None, outline=None): ri = ri if ri is not None else ro * 0.45 if outline: pygame.draw.polygon(surf, outline, star_pts(cx, cy, ro + 1, ri + 1)) pygame.draw.polygon(surf, color, star_pts(cx, cy, ro, ri)) pygame.draw.polygon(surf, (255, 255, 255), star_pts(cx, cy, ro * 0.4, ri * 0.5)) def draw_heart(surf, x, y, s, color): m = [" .##. ", "##### ", "######", "##### ", " .##. "] for row, line in enumerate(m): for col, ch in enumerate(line): if ch == "#": surf.fill(color, (x + col * s, y + row * s, s, s)) CAT_HEAD = [ "..##....##..", ".##.#..#.##.", "##..####..##", "##....##..##", "##..####..##", "###########.", ".#########..", "..#######...", "..###.###...", "..##...##...", ] BODY = [ "..#####...", ".#######..", ".#######..", "##########", ".#######..", "...##.##..", ] def draw_cat(surf, x, y, s, expr="happy", t=0.0): """Pixel cat: x,y = head center (top-ish). s = pixel scale.""" head_w = 12 * s hx = int(x - head_w // 2) hy = int(y - head_w // 2) for row, line in enumerate(CAT_HEAD): for col, ch in enumerate(line): if ch == "#": surf.fill(C["fur"], (hx + col * s, hy + row * s, s, s)) # eyes (blink on a cycle), mouth, blush blink = (int(t * 2.2) % 70) > 63 eye_y = hy + 4 * s ex_l = hx + 3 * s ex_r = hx + 8 * s if expr == "happy": pygame.draw.rect(surf, C["eye"], (ex_l, eye_y + s, s, s)) pygame.draw.rect(surf, C["eye"], (ex_r, eye_y + s, s, s)) elif expr == "sad": pygame.draw.rect(surf, C["eye"], (ex_l, eye_y + 2 * s, s, s)) pygame.draw.rect(surf, C["eye"], (ex_r, eye_y + 2 * s, s, s)) elif expr == "star": # wide amazed eyes pygame.draw.rect(surf, C["eye"], (ex_l - s, eye_y, 2 * s, 2 * s)) pygame.draw.rect(surf, C["eye"], (ex_r, eye_y, 2 * s, 2 * s)) else: if blink: pygame.draw.rect(surf, C["eye"], (ex_l, eye_y + s, 2 * s, s)) pygame.draw.rect(surf, C["eye"], (ex_r, eye_y + s, 2 * s, s)) else: pygame.draw.rect(surf, C["eye"], (ex_l, eye_y + s, s, s)) pygame.draw.rect(surf, C["eye"], (ex_r, eye_y + s, s, s)) # mouth my = hy + 6 * s pygame.draw.rect(surf, C["eye"], (hx + 5 * s, my, s, s)) # blush surf.fill(C["blush"], (hx + s, hy + 5 * s, 2 * s, s)) surf.fill(C["blush"], (hx + 9 * s, hy + 5 * s, 2 * s, s)) def draw_body(surf, x, y, s): w = 10 * s bx = int(x - w // 2) by = int(y) for row, line in enumerate(BODY): for col, ch in enumerate(line): if ch == "#": surf.fill(C["fur"], (bx + col * s, by + row * s, s, s)) pygame.draw.rect(surf, C["fur_d"], (bx + 2 * s, by + 2 * s, w - 4 * s, s)) # little tail curl tx, ty = bx + w + 1, by + 3 * s for i in range(4): pygame.draw.rect(surf, C["fur"], (tx + i, ty - i, s, s)) pygame.draw.rect(surf, C["ear"], (tx + 4, ty - 4, s, s)) def draw_bumper(surf, b, face_t, blink_t): cx, cy, r = b["x"], b["y"], b["r"] color_map = {"pink": (C["pink"], C["pink_d"], C["pink_l"]), "mint": (C["mint"], C["mint_d"], C["mint_l"]), "lemon": (C["lemon"], C["lemon_d"], C["lemon_l"])} base, dark, light = color_map[b["color"]] squash = max(0, b["squash"]) rr = int(r - (squash * 0.6)) pygame.draw.circle(surf, (30, 20, 66), (int(cx) + 2, int(cy) + 3), rr) # shadow pygame.draw.circle(surf, dark, (int(cx), int(cy)), rr) pygame.draw.circle(surf, base, (int(cx), int(cy)), rr - 2) pygame.draw.circle(surf, light, (int(cx), int(cy)), rr - 6) # gloss pixels for dx in (-3, -2, -1): for dy in (-4, -3): surf.fill((255, 255, 255), (int(cx) + dx, int(cy) + dy, 1, 1)) # candy dots around rim for i in range(8): a = i * math.pi / 4 + 0.35 surf.fill(dark, (int(cx + (rr - 3) * math.cos(a)), int(cy + (rr - 3) * math.sin(a)), 2, 2)) # face surprised = face_t < 9 blink = blink_t > 0 ex = 6 ey = -2 pygame.draw.circle(surf, C["eye"], (int(cx - ex), int(cy + ey)), 2 if surprised else 1) pygame.draw.circle(surf, C["eye"], (int(cx + ex), int(cy + ey)), 2 if surprised else 1) if blink and not surprised: surf.fill(C["eye"], (int(cx - ex) - 1, int(cy + ey), 3, 1)) surf.fill(C["eye"], (int(cx + ex) - 1, int(cy + ey), 3, 1)) # mouth if surprised: pygame.draw.circle(surf, C["eye"], (int(cx), int(cy + 6)), 2) else: surf.fill(C["eye"], (int(cx) - 2, int(cy + 5), 5, 1)) # blush surf.fill(C["blush"], (int(cx - ex) - 4, int(cy + 3), 2, 1)) surf.fill(C["blush"], (int(cx + ex) + 2, int(cy + 3), 2, 1)) def draw_sling(surf, s): x, y = int(s["x"]), int(s["y"]) flash = s["flash"] > 0 pygame.draw.circle(surf, (40, 30, 80), (x + 2, y + 3), 11) pygame.draw.circle(surf, C["candy_d"], (x, y), 11) body = (255, 255, 255) if flash else C["candy"] pygame.draw.circle(surf, body, (x, y), 8) pygame.draw.circle(surf, (255, 255, 255), (x, y), 3) # swirl stripe pygame.draw.arc(surf, C["candy_d"], (x - 8, y - 8, 16, 16), 0.4, 2.0, 2) def draw_flipper(surf, f, side): px, py = f["pivot"] tipx, tipy = flipper_tip(f) p0 = (int(px), int(py)) p1 = (int(tipx), int(tipy)) # shadow pygame.draw.line(surf, (24, 16, 50), (int(px), int(py) + 4), (int(tipx), int(tipy) + 4), 12) # outline, body, highlight pygame.draw.line(surf, C["flipper_d"], p0, p1, 12) pygame.draw.line(surf, C["flipper"], p0, p1, 8) # rounded ends for (ex, ey) in (p0, p1): pygame.draw.circle(surf, C["flipper_d"], (ex, ey), 6) pygame.draw.circle(surf, C["flipper"], (ex, ey), 4) # cute stripe near the top edge a = f["angle"] side_sign = -1.0 if side == "R" else 1.0 nx, ny = -math.sin(a) * side_sign, math.cos(a) hx, hy = int(px + nx * 3), int(py + ny * 3) tx, ty = int(tipx + nx * 3), int(tipy + ny * 3) pygame.draw.line(surf, C["flipper_l"], (hx, hy), (tx, ty), 2) def draw_cannon(surf, cx, cy, charge, charging, firing_flash): x, y = int(cx), int(cy) pygame.draw.circle(surf, (26, 18, 58), (x + 2, y + 2), 20) # shadow pygame.draw.circle(surf, (90, 60, 160), (x, y), 19) # rim pygame.draw.circle(surf, (150, 100, 220), (x, y), 15) # body pygame.draw.circle(surf, (210, 170, 255), (x, y), 15, 2) # barrel mouth pointing up-left along LAUNCH_DIR mx, my = LAUNCH_DIR px, py = x + mx * 8, y + my * 8 pygame.draw.line(surf, (70, 45, 130), (x + mx * 2, y + my * 2), (px, py), 12) pygame.draw.line(surf, (190, 150, 250), (x + mx * 2, y + my * 2), (px, py), 6) pygame.draw.circle(surf, (60, 38, 110), (int(px), int(py)), 7) pygame.draw.circle(surf, (30, 18, 60), (int(px), int(py)), 4) # charge glow / sparkle if charging: glow = 1.0 elif firing_flash > 0: glow = min(1.0, firing_flash / 5.0) else: glow = 0.0 if glow > 0: rr = int(4 + (1 - charge) * 3 + (1 - glow) * 0) for i in range(3): a = 0.9 + i * 2.2 + charge * 0.5 sx = px + math.cos(a) * (7 + charge * 9) sy = py + math.sin(a) * (7 + charge * 9) surf.fill((255, 240, 170), (int(sx), int(sy), 2, 2)) def draw_ball(surf, bx, by, vx, vy, waiting): x, y = int(bx), int(by) pygame.draw.circle(surf, (120, 130, 170), (x, y), BALL_R) # base shade pygame.draw.circle(surf, (245, 250, 255), (x, y), BALL_R - 1) # look direction sp = math.hypot(vx, vy) if sp > 0.4 and not waiting: lx, ly = vx / sp, vy / sp else: lx, ly = -0.4, -0.92 # looking up px, py = -ly, lx # perpendicular ex = x + lx * 3 ey = y + ly * 3 s1 = 4 pygame.draw.circle(surf, C["eye"], (int(ex - px * s1), int(ey - py * s1)), 1) pygame.draw.circle(surf, C["eye"], (int(ex + px * s1), int(ey + py * s1)), 1) pygame.draw.circle(surf, C["blush"], (int(x + px * (s1 + 1) - lx), int(y + py * (s1 + 1) - ly + 2)), 1) pygame.draw.circle(surf, C["blush"], (int(x - px * (s1 + 1) - lx), int(y - py * (s1 + 1) - ly + 2)), 1) pygame.draw.circle(surf, (255, 255, 255), (x - 2, y - 3), 2) # shine def draw_ball_shadow(surf, bx, by, on_floor_y=PLAY_B): x, y = int(bx), int(by) k = min(1.0, max(0.15, 1.0 - (y - (on_floor_y - 40)) / 60.0)) pygame.draw.circle(surf, (20, 14, 48), (x + 2, int(on_floor_y) + 4), int(6 * k)) class Game: def __init__(self, screen_size=None): pygame.init() pygame.display.set_caption("Cosmic Candy Pinball") icon = pygame.Surface((16, 16)) icon.fill(C["bg_top"]) for row, line in enumerate(CAT_HEAD): for col, ch in enumerate(line): if ch == "#": icon.fill(C["fur"], (int(col * 16 / 12), int(row * 16 / 10), 2, 2)) pygame.display.set_icon(icon) if screen_size is None: info = pygame.display.Info() scale = max(1, min(4, info.current_w // CV_W, info.current_h // CV_H)) screen_size = (CV_W * scale, CV_H * scale) self.scale = screen_size[0] // CV_W self.win = pygame.display.set_mode(screen_size) self.canvas = pygame.Surface((CV_W, CV_H)) self.overlay = pygame.Surface((CV_W, CV_H), pygame.SRCALPHA) from sfx import Synth self.sfx = Synth() self.muted = False self.rng = random.Random() self.table = Table(seed=self.rng.randrange(1 << 30)) self.keys = {"L": False, "R": False, "fire": False} self.was_charging = False self.state = "title" # title | play | over self.frame = 0 self.stars_bg = [] for _ in range(120): self.stars_bg.append([self.rng.randrange(CV_W), self.rng.randrange(CV_H), self.rng.random(), self.rng.randrange(40)]) self._build_static() # run state self.score = 0 self.hi = self._load_hi() self.lives = 3 self.ball_no = 1 self.new_hi = False self.combo = 0 self.combo_t = 0 self.fever = 0.0 self.next_extra = 50000 self.messages = [] # (text,color,scale,x,y,life) self.particles = [] self.bubble = None # (text, life) self.shake = 0.0 self.charge = 0.0 self.charging = False self.pending = "serve" # serve | drain_pause | ball_banner | over_banner self.pending_t = 0 self.over_t = 0 self.confetti_t = 0 self.wall_cool = 0 self.enter_was_down = False self.paused = False self.firing_flash = 0 self.sfx.start_menu_music() # ------------------------------------------------------------------ files def _hi_path(self): d = os.path.dirname(os.path.abspath(__file__)) return os.path.join(d, "highscore.json") def _load_hi(self): try: import json with open(self._hi_path()) as fh: return int(json.load(fh).get("hi", 0)) except Exception: return 0 def _save_hi(self): try: import json with open(self._hi_path(), "w") as fh: json.dump({"hi": self.hi}, fh) except Exception: pass # ------------------------------------------------------------------ static def _build_static(self): bg = pygame.Surface((CV_W, CV_H)) for y in range(CV_H): t = y / CV_H col = tuple(int(a + (b - a) * t) for a, b in zip(C["bg_top"], C["bg_bot"])) pygame.draw.line(bg, col, (0, y), (CV_W, y)) # twinkle stars & planets for the background for sx, sy, tw, ph in self.stars_bg: if 0 < sx < 20 or 460 < sx < CV_W or sy < 46: pygame.draw.rect(bg, (230, 225, 255), (sx, sy, 1, 1)) self.bg = bg field = pygame.Surface((CV_W, CV_H), pygame.SRCALPHA) # wall frame (thick rounded frame forming the rails) field.fill((0, 0, 0, 0)) rect = (PLAY_L - 3, PLAY_T - 3, PLAY_R - PLAY_L + 6, PLAY_B - PLAY_T + 6) pygame.draw.rect(field, C["rail_dark"], rect, border_radius=10) pygame.draw.rect(field, C["rail"], rect, width=3, border_radius=10) pygame.draw.rect(field, C["rail_hi"], rect, width=1, border_radius=10) # playfield interior inner = (PLAY_L, PLAY_T, PLAY_R - PLAY_L, PLAY_B - PLAY_T) pygame.draw.rect(field, C["field"], inner, border_radius=7) pygame.draw.rect(field, C["field2"], (PLAY_L + 4, PLAY_T + 4, PLAY_R - PLAY_L - 8, PLAY_B - PLAY_T - 8), border_radius=5) # candy dot sparkle inside the field r = self.rng for _ in range(46): dx = r.randrange(int(PLAY_L) + 12, int(PLAY_R) - 12) dy = r.randrange(int(PLAY_T) + 14, int(PLAY_B) - 12) if any((dx - b["pos"][0]) ** 2 + (dy - b["pos"][1]) ** 2 < (b["r"] + 4) ** 2 for b in ({"pos": (150, 180), "r": 23}, {"pos": (330, 180), "r": 23}, {"pos": (240, 240), "r": 23})): continue col = PASTELS[r.randrange(len(PASTELS))] field.fill(tuple(c // 3 for c in col), (dx, dy, 2, 2)) # floor ledges for x0, x1 in ((OUTLANE_L, CENTER_HOLE[0]), (CENTER_HOLE[1], OUTLANE_R)): pygame.draw.line(field, C["floor_dark"], (x0, PLAY_B + 1), (x1, PLAY_B + 1), 5) pygame.draw.line(field, C["floor"], (x0, PLAY_B), (x1, PLAY_B), 3) pygame.draw.line(field, C["floor_hi"], (x0, PLAY_B - 1), (x1, PLAY_B - 1), 1) # pits (drains) pits = [(PLAY_L, OUTLANE_L), (CENTER_HOLE[0], CENTER_HOLE[1]), (OUTLANE_R, PLAY_R)] for x0, x1 in pits: pygame.draw.rect(field, C["pit"], (x0, PLAY_B, x1 - x0, DRAIN_Y - PLAY_B + 6), border_radius=4) pygame.draw.line(field, C["pit_glint"], (x0 + 3, PLAY_B + 3), (x0 + 3, PLAY_B + 9)) # little post feet under the flipper pivots for (px, py) in (FLIP_PIVOT_L, FLIP_PIVOT_R): pygame.draw.circle(field, C["floor_dark"], (int(px), int(py) - 2), 7) pygame.draw.circle(field, (150, 120, 255), (int(px), int(py) - 2), 5) pygame.draw.circle(field, (220, 205, 255), (int(px), int(py) - 3), 2) self.field = field hud = pygame.Surface((CV_W, 52)) hud.fill(C["hud"]) pygame.draw.line(hud, C["rail_dark"], (0, 50), (CV_W, 50), 2) pygame.draw.line(hud, C["hud_line"], (0, 47), (CV_W, 47), 1) pygame.draw.rect(hud, (255, 255, 255), (0, 0, 8, 52)) pygame.draw.rect(hud, (255, 255, 255), (CV_W - 8, 0, 8, 52)) self.hud = hud # ------------------------------------------------------------------ game def new_game(self): self.table = Table(seed=self.rng.randrange(1 << 30)) self.score = 0 self.lives = 3 self.ball_no = 1 self.new_hi = False self.combo = 0 self.combo_t = 0 self.fever = 0.0 self.next_extra = 50000 self.messages = [] self.particles = [] self.bubble = None self.shake = 0.0 self.charge = 0.0 self.charging = False self.firing_flash = 0 self.pending = "ball_banner" self.pending_t = 90 self.sfx.start_play_music() self.sfx.play("start") def _ball_lost(self, x, y): self.sfx.play("drain") self.particles += [dict(x=x, y=y, vx=r.uniform(-1.5, 1.5), vy=r.uniform(-2, 0.5), life=30, kind="spark", color=(255, 255, 255)) for r in (self.rng,) for _ in range(8)] self.shake = 4 self.combo = 0 self.cat_say("OOF!", 60, "sad") self.lives -= 1 if self.lives > 0: self.pending = "drain_pause" self.pending_t = 55 else: self.pending = "over_banner" self.pending_t = 10 if self.score > self.hi: self.hi = self.score self.new_hi = True self._save_hi() self.sfx.play("hi") def _serve_next(self): self.ball_no += 1 self.table.reset_ball_in_cannon() self.fever = 0.0 self.pending = "serve" def _start_fever(self): self.fever = 60 * 20 self.sfx.play("fever") self.cat_say("CANDY FEVER!", 90, "star") self.shake = 3 self.add_msg("CANDY FEVER!", CV_W // 2, 150, C["gold"], scale=2) self.add_msg("DOUBLE SCORE x2", CV_W // 2, 166, C["pink"], scale=1) for i in range(30): self.particles.append(dict( x=self.rng.uniform(PLAY_L + 10, PLAY_R - 10), y=self.rng.uniform(60, 120), vx=self.rng.uniform(-0.4, 0.4), vy=self.rng.uniform(0.4, 1.2), life=80, kind="confetti", color=PASTELS[self.rng.randrange(len(PASTELS))])) # ------------------------------------------------------------------ cat def cat_say(self, text, frames, expr): self.bubble = [text, frames, expr] # ------------------------------------------------------------------ update def update(self): self.frame += 1 if self.paused: return if self.state == "title": self._title_update() return if self.state == "over": self._update_particles() self._update_messages() self.pending_t -= 1 return # --- PLAY --- for f in self.table.flippers: self.table.press("L", self.keys["L"]) self.table.press("R", self.keys["R"]) if self.pending == "ball_banner": self.pending_t -= 1 if self.pending_t <= 30: self.pending = "serve" elif self.pending == "drain_pause": self.pending_t -= 1 if self.pending_t <= 0: self._serve_next() elif self.pending == "over_banner": self.pending_t -= 1 if self.pending_t <= 0: self.state = "over" self.sfx.start_menu_music() return elif self.pending == "serve": if self.table.ball_live: self.pending = "live" else: self.charging = self.keys["fire"] if self.charging: self.was_charging = True self.charge = min(1.0, self.charge + 0.022) if self.frame % 5 == 0: self.sfx.play("tick", vol=0.3 + self.charge * 0.5) if self.charge >= 1.0: self._fire() else: if self.was_charging and self.charge >= 0.06: self._fire() self.charge = 0.0 self.was_charging = False elif self.pending == "live": events = self.table.update() self._handle_events(events) if self.pending != "live": return # a drain just happened # timers if self.combo > 0: self.combo_t -= 1 if self.combo_t <= 0: self.combo = 0 if self.fever > 0: self.fever -= 1 if self.confetti_t <= 0: self.confetti_t = 4 self.particles.append(dict( x=self.rng.uniform(PLAY_L + 20, PLAY_R - 20), y=PLAY_T + 2, vx=self.rng.uniform(-0.5, 0.5), vy=self.rng.uniform(0.3, 1.0), life=70, kind="confetti", color=PASTELS[self.rng.randrange(len(PASTELS))])) if self.confetti_t > 0: self.confetti_t -= 1 if self.wall_cool > 0: self.wall_cool -= 1 if self.shake > 0: self.shake = max(0.0, self.shake - 0.25) if self.bubble: self.bubble[1] -= 1 if self.bubble[1] <= 0: self.bubble = None if self.firing_flash > 0: self.firing_flash -= 1 self._update_particles() self._update_messages() def _fire(self): power = LAUNCH_MIN + (LAUNCH_MAX - LAUNCH_MIN) * self.charge self.sfx.play("launch", vol=0.6 + 0.4 * self.charge) ev = self.table.launch(power) self.firing_flash = 6 self.pending = "live" self.was_charging = False self.charge = 0.0 self.add_msg("FIRE!", ev.x, ev.y, C["gold"], scale=1) # ------------------------------------------------------------------ events def _handle_events(self, events): mult = 2 if self.fever > 0 else 1 for e in events: if e.kind == "bumper": self.combo = min(self.combo + 1, 9) self.combo_t = 150 pts = 250 * self.combo * mult self._add_score(pts, e.x, e.y) self.sfx.play("bump", pitch_var=min(9, self.combo), vol=0.8) b = e.data self.particles += [ dict(x=e.x, y=e.y, vx=self.rng.uniform(-1.2, 1.2), vy=self.rng.uniform(-1.8, -0.2), life=22, kind="spark", color=(255, 255, 255)), dict(x=e.x, y=e.y, life=16, kind="ring", color=(255, 255, 255)), ] self.shake = max(self.shake, 1.6) if self.combo == 5: self.sfx.play("combo") self.cat_say("COMBO x5!", 60, "happy") elif e.kind == "sling": self.combo = min(self.combo + 1, 9) self.combo_t = 150 pts = 150 * self.combo * mult self._add_score(pts, e.x, e.y) self.sfx.play("sling", vol=0.8) self.particles.append(dict(x=e.x, y=e.y, life=12, kind="ring", color=(255, 255, 255))) self.shake = max(self.shake, 1.0) elif e.kind == "star_lit": lit = sum(1 for s in self.table.stars if s["lit"]) pts = 100 * mult self._add_score(pts, e.x, e.y) self.sfx.play("starLit" if lit == len(self.table.stars) else "star") if lit == len(self.table.stars): self._start_fever() else: self.cat_say(["MEOW!", "NYA!", "PURR!"][lit - 1], 45, "happy") elif e.kind == "star_again": pts = (100 if self.fever > 0 else 25) * mult self._add_score(pts, e.x, e.y, small=True) if self.fever > 0: self.fever = min(60 * 40, self.fever + 60 * 3) elif e.kind == "drain": self._ball_lost(e.x, e.y) elif e.kind == "nudge": self.sfx.play("tick", vol=0.2) elif e.kind == "wall_hit": if self.wall_cool <= 0 and e.data > 9.5: self.sfx.play("wall", vol=min(0.5, (e.data - 9) / 5)) self.wall_cool = 5 elif e.kind == "flipper_hit": if self.rng.random() < 0.3: self.sfx.play("tick", vol=0.25) elif e.kind == "launch": pass def _add_score(self, pts, x, y, small=False): self.score += pts self.add_msg("+%d" % pts, x, y, C["gold"] if pts >= 500 else C["white"], scale=1) # milestone extra lives while self.score >= self.next_extra: self.lives += 1 self.sfx.play("extra") self.cat_say("EXTRA BALL!", 90, "star") self.add_msg("EXTRA BALL!", CV_W // 2, 120, C["pink"], scale=2) self.next_extra += 30000 def add_msg(self, text, x, y, color, scale=1): if len(self.messages) < 26: self.messages.append([text, color, scale, float(x), float(y), 60]) # ------------------------------------------------------------------ fx def _update_messages(self): for m in self.messages[:]: m[5] -= 1 m[3] += self.rng.uniform(-0.05, 0.05) m[4] -= 0.5 if m[5] <= 0: self.messages.remove(m) def _update_particles(self): for p in self.particles[:]: p["life"] -= 1 if p["life"] <= 0: self.particles.remove(p) continue if p["kind"] == "confetti": p["vy"] += 0.02 p["x"] += p["vx"] p["y"] += p["vy"] elif p["kind"] == "spark": p["vy"] += 0.06 p["x"] += p["vx"] p["y"] += p["vy"] elif p["kind"] == "ring": pass # ------------------------------------------------------------------ render def render(self): c = self.canvas if self.state == "title": self._render_title(c) elif self.state == "over": self._render_over(c) else: self._render_play(c) if self.shake > 0.5: tmp = c.copy() c.fill((0, 0, 0)) sx = self.rng.randint(-2, 2) sy = self.rng.randint(-2, 2) c.blit(tmp, (int(sx * self.shake * 0.5), int(sy * self.shake * 0.5))) if self.paused and self.state == "play": self.overlay.fill((10, 8, 40, 170)) c.blit(self.overlay, (0, 0)) pix.draw_text(c, CV_W // 2, CV_H // 2 - 20, "PAUSED", C["white"], 3, align="center") pix.draw_text(c, CV_W // 2, CV_H // 2 + 16, "PRESS P TO RESUME", C["dim"], 1, align="center") pygame.transform.scale(c, self.win.get_size(), self.win) pygame.display.flip() # ------------------------------------------------------------------ play render def _render_play(self, c): c.blit(self.bg, (0, 0)) c.blit(self.field, (0, 0)) t = self.table # slings for s in t.slings: draw_sling(c, s) # flippers for f in t.flippers: draw_flipper(c, f, f["side"]) # star lanes for i, st in enumerate(t.stars): lit = st["lit"] pulse = st["pulse"] > 0 ro = 8 + (2 if pulse else 0) color = C["gold"] if lit else (110, 95, 175) o = (200, 130, 40) if lit else (70, 60, 120) draw_star(c, st["x"], st["y"], ro, color, outline=o) # cannon draw_cannon(c, t.cannon["x"], t.cannon["y"], self.charge, self.charging, self.firing_flash) # bumpers for b in t.bumpers: draw_bumper(c, b, b["face_t"], b["blink"]) # ball ball = t.ball if not t.ball_live: draw_ball(c, ball.x, ball.y - 4, 0, 0, True) else: if ball.speed > 7: for i, (px, py) in enumerate(t.trail[:-1]): r = max(1, 4 - i) c.fill((220, 210, 255), (int(px) - r // 2, int(py) - r // 2, r, r)) draw_ball_shadow(c, ball.x, ball.y) draw_ball(c, ball.x, ball.y, ball.vx, ball.vy, False) # combo indicator if self.combo >= 2 and self.pending == "live": col = (255, 140, 90) if self.combo >= 5 else C["gold"] pix.draw_text(c, CV_W // 2, 56, "COMBO x%d!" % self.combo, col, 1, align="center") # particles for p in self.particles: x, y = int(p["x"]), int(p["y"]) if p["kind"] == "confetti": c.fill(p["color"], (x, y, 2, 2)) elif p["kind"] == "spark": c.fill(p["color"], (x, y, 2, 2)) elif p["kind"] == "ring": age = 16 - p["life"] pygame.draw.circle(c, p["color"], (x, y), min(14, 2 + age), 1) # fever edge glow if self.fever > 0: col = C["pink"] if self.fever < 120 else C["gold"] for i in range(0, 8): pygame.draw.rect(c, col, (PLAY_L - 3 + i * 3, PLAY_T - 6, 1, 3)) # floating score text for m in self.messages: if m[5] > 10: pix.draw_text(c, m[3], m[4], m[0], m[1], m[2], align="center") # cat bubble if self.bubble: self._draw_bubble(c) # pending banners if self.pending == "ball_banner" and self.pending_t > 40: self._dim_center(c) pix.draw_text(c, CV_W // 2, 140, "BALL %d" % self.ball_no, C["white"], 3, align="center") pix.draw_text(c, CV_W // 2, 170, "GET READY...", C["pink"], 1, align="center") if self.pending == "drain_pause": self._dim_center(c) pix.draw_text(c, CV_W // 2, 150, "LOST THE BALL!", C["pink"], 2, align="center") if self.pending == "serve" and not t.ball_live: pix.draw_text(c, CV_W // 2, 300, "HOLD SPACE / UP TO CHARGE", C["dim"], 1, align="center") # HUD self._render_hud(c) def _dim_center(self, c): self.overlay.fill((0, 0, 0, 0)) pygame.draw.rect(self.overlay, (12, 8, 40, 160), (CV_W // 2 - 100, 110, 200, 90), border_radius=8) c.blit(self.overlay, (0, 0)) def _draw_bubble(self, c): text, life, expr = self.bubble x0, y0 = 30, 70 w = pix.text_width(text, 1) + 14 pygame.draw.rect(c, C["bubble"], (x0, y0, w, 14), border_radius=4) pygame.draw.rect(c, C["bubble_b"], (x0, y0, w, 14), 1, border_radius=4) pix.draw_text(c, x0 + 7, y0 + 5, text, C["white"], 1) # little cat reacting inside a corner draw_cat(c, x0 + w + 14, y0 + 2, 1, expr, self.frame) def _render_hud(self, c): c.blit(self.hud, (0, 0)) pix.draw_text(c, 14, 4, "SCORE", C["dim"], 1) pix.draw_text(c, 14, 13, "{:,}".format(self.score), C["white"], 2) pix.draw_text(c, 170, 4, "BEST", C["dim"], 1) best = max(self.hi, self.score) pix.draw_text(c, 170, 13, "{:,}".format(best), C["gold"], 2) # lives hearts for i in range(self.lives): draw_heart(c, 336 + i * 14, 14, 2, C["heart"]) pix.draw_text(c, 336, 30, "LIVES", C["dim"], 1) # stars progress pix.draw_text(c, 14, 32, "STARS", C["dim"], 1) for i, st in enumerate(self.table.stars): x = 52 + i * 18 draw_star(c, x, 37, 5 if st["lit"] else 4, C["gold"] if st["lit"] else (90, 78, 150)) # fever indicator + bar if self.fever > 0: frac = max(0.0, self.fever / (60 * 20)) pygame.draw.rect(c, (60, 46, 110), (260, 30, 70, 6)) pygame.draw.rect(c, (255, 150, 200), (260, 30, int(70 * frac), 6)) pix.draw_text(c, 260, 38, "FEVER x2", C["gold"], 1) if self.paused: pix.draw_text(c, CV_W - 40, 40, "PAUSED", C["dim"], 1, align="right") # ------------------------------------------------------------------ title def _title_update(self): self._update_particles() if self.frame % 30 == 0 and self.state == "title": self.particles.append(dict( x=self.rng.uniform(30, CV_W - 30), y=CV_H + 4, vx=self.rng.uniform(-0.3, 0.3), vy=self.rng.uniform(-0.7, -0.3), life=300, kind="spark", color=(255, 240, 200))) self.sfx if False else None def _render_title(self, c): c.blit(self.bg, (0, 0)) # ringed planet pygame.draw.circle(c, (150, 110, 220), (90, 70), 24) pygame.draw.circle(c, (190, 160, 245), (90, 70), 24, 2) pygame.draw.ellipse(c, (255, 200, 220), (58, 64, 64, 18), 2) pygame.draw.circle(c, (110, 90, 200), (300, 320), 10) pygame.draw.circle(c, (110, 90, 200), (300, 320), 10, 1) # twinkling stars for sx, sy, tw, ph in self.stars_bg: if (self.frame + ph) % 70 < 6: pygame.draw.rect(c, (255, 255, 255), (sx, sy, 1, 1)) # particles for p in self.particles: c.fill(p["color"], (int(p["x"]), int(p["y"]), 2, 2)) # logo words = [("COSMIC", 3, 36, C["pink"]), ("CANDY", 3, 62, C["gold"]), ("PINBALL", 2, 108, C["mint"])] y = 40 for txt, sc, _, col in words: tw = pix.text_width(txt, sc) x = CV_W // 2 - tw // 2 for i, ch in enumerate(txt): col2 = col pix.draw_text(c, x, y, ch, col2, sc, shadow=(20, 12, 50)) x += (5 + 1) * sc y += (5 + 2) * sc # little shooting star occasionally if self.frame % 90 < 24: t0 = self.frame % 90 sx = 330 + t0 * 3 sy = 40 + t0 * 1.5 pygame.draw.line(c, (255, 255, 255), (int(sx), int(sy)), (int(sx - 8), int(sy - 4)), 1) # cat draw_body(c, CV_W // 2 - 26, 180, 2) draw_cat(c, CV_W // 2 - 26, 168, 2, "happy", self.frame) # instructions panel pygame.draw.rect(c, (22, 15, 56), (70, 240, CV_W - 140, 74), border_radius=8) pygame.draw.rect(c, C["rail"], (70, 240, CV_W - 140, 74), 2, border_radius=8) lines = [ ("LEFT / RIGHT ARROWS = FLIPPERS", C["flipper"]), ("HOLD SPACE OR UP = CHARGE CANNON", C["gold"]), ("M MUTE R RESTART P PAUSE", C["dim"]), ] for i, (txt, col) in enumerate(lines): pix.draw_text(c, CV_W // 2, 250 + i * 20, txt, col, 1, align="center") if (self.frame // 30) % 2 == 0: pix.draw_text(c, CV_W // 2, 325, "PRESS ENTER TO START", C["white"], 2, align="center") # ------------------------------------------------------------------ game over def _render_over(self, c): self._render_play(c) self.overlay.fill((14, 9, 40, 215)) c.blit(self.overlay, (0, 0)) pix.draw_text(c, CV_W // 2, 60, "GAME OVER", C["pink"], 3, align="center") pix.draw_text(c, CV_W // 2, 105, "SCORE", C["dim"], 1, align="center") pix.draw_text(c, CV_W // 2, 115, "{:,}".format(self.score), C["white"], 3, align="center") pix.draw_text(c, CV_W // 2, 160, "BEST", C["dim"], 1, align="center") pix.draw_text(c, CV_W // 2, 170, "{:,}".format(self.hi), C["gold"], 2, align="center") if self.new_hi and (self.frame // 20) % 2 == 0: pix.draw_text(c, CV_W // 2, 205, "NEW HI-SCORE!", C["gold"], 1, align="center") draw_body(c, CV_W // 2 - 20, 240, 2) draw_cat(c, CV_W // 2 - 20, 226, 2, "sad", self.frame) if (self.frame // 30) % 2 == 0: pix.draw_text(c, CV_W // 2, 320, "PRESS ENTER TO PLAY AGAIN", C["white"], 1, align="center") # ------------------------------------------------------------------ input def event(self, ev): if ev.type == pygame.KEYDOWN: if ev.key == pygame.K_ESCAPE: return False if ev.key == pygame.K_m: self.muted = not self.muted self.sfx.set_muted(self.muted) self.sfx.set_sfx_muted(self.muted) if ev.key == pygame.K_p and self.state == "play": self.paused = not self.paused if ev.key == pygame.K_r and self.state == "play": self.new_game() if ev.key in (pygame.K_RETURN, pygame.K_KP_ENTER, pygame.K_SPACE): if ev.key in (pygame.K_RETURN, pygame.K_KP_ENTER): self.enter_was_down = True if self.state == "title": self.state = "play" self.new_game() elif self.state == "over": self.state = "play" self.new_game() if ev.type == pygame.QUIT: return False return True def set_keys(self): k = pygame.key.get_pressed() self.keys["L"] = k[pygame.K_LEFT] or k[pygame.K_z] or k[pygame.K_j] self.keys["R"] = k[pygame.K_RIGHT] or k[pygame.K_SLASH] or k[pygame.K_LCTRL] self.keys["fire"] = k[pygame.K_SPACE] or k[pygame.K_UP] or k[pygame.K_DOWN]