Files
ds4.1-flipper/game.py
T
georgios 973dd870fd Fix right flipper: mirror its geometry and sweep it per-substep
The right flipper's arm was computed with the same +cos(angle) direction
as the left one, so it pointed into the right wall, off the 480px canvas
- only the left paddle was ever visible/playable.

- add physics.flipper_tip() which mirrors the right flipper (and use it
  in both collision and rendering)
- derive the flipper's surface velocity from actual tip displacement
  (scaled by contact radius), instead of a hand-signed angular formula
- advance flipper angles inside the collision substeps (6/frame while
  pressed) so a fast sweep can't jump past a resting ball; resting balls
  now get a real 'scoop' from either paddle
- verify: pixel probe shows both paddles on screen (L=1007, R=999 blue
  px); physics probe confirms each paddle launches a resting ball
2026-09-09 16:46:07 +02:00

938 lines
38 KiB
Python

"""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]