commit db49b466c2f5c33464ba8389415e5cc1ab0f3c19 Author: georgios Date: Wed Sep 9 16:18:39 2026 +0200 Cosmic Candy Pinball: procedural 8-bit pinball game Native pygame-ce game with fully procedural pixel art and chiptune audio (no asset files). Pure-logic physics module with flipper angular momentum, plus headless autoplay/screenshot/probe dev tools. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec8aab7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python +__pycache__/ +*.pyc + +# project venv +.venv/ + +# generated +highscore.json +probe_play.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..f05b403 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# 🍬 Cosmic Candy Pinball + +A cute, 8-bit, pixel-art **pinball** game, written from scratch in Python with +[pygame-ce](https://pygame.org). 100% procedural β€” there are **no image or +audio asset files**: every sprite, texture, particle, chiptune and sound +effect is generated by code at runtime. + +Not web-based: it opens its own native window and renders on a low-res +`480Γ—360` virtual canvas, upscaled with crisp nearest-neighbour scaling for an +authentic retro look. + +![demo](shots/3_play.png) + +## Running it + +Fastest (uv, already used in this project): + +```bash +uv venv .venv --python 3.12 # once +uv pip install --python .venv/bin/python -r requirements.txt # once +.venv/bin/python main.py +``` + +Or with plain venv + pip (Python 3.10+): + +```bash +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python main.py +``` + +> Note: the dependency is **`pygame-ce`**, not `pygame` (the community edition +> supports newer Python versions and is what this was built against). + +## Controls + +| Key | Action | +| --- | --- | +| `←` `β†’` (or `Z` / `/`) | Flippers | +| hold `Space` / `↑` | Charge the **Candy Cannon** β€” release to fire | +| `M` | Mute | +| `P` | Pause | +| `R` | Restart game | +| `Esc` | Quit | +| `Enter` | Start / play again | + +## How to play + +- Light the **3 star lanes** up top β†’ **CANDY FEVER!** (double points for + 20s β€” keep hitting stars to extend it). +- Bounce off the three kawaii candy bumpers and the two slingshots. Every hit + inside 2.5s builds your **combo multiplier** (up to Γ—9). +- Watch the bottom-centre drain and the two outlanes… or lose the ball. +- Every **50,000 points** earns an extra ball. +- Squirrel says encouraging things. Blush at the cat in the corner. + +## Project layout + +| file | purpose | +| --- | --- | +| `main.py` | entry point / main loop | +| `game.py` | game states, scoring, juice, all rendering (pixel art) | +| `physics.py` | pure pinball physics (flippers, bumpers, slingshots, drains) β€” no rendering | +| `font.py` | hand-made 5Γ—5 pixel bitmap font | +| `sfx.py` | procedural chiptune + music synthesis (pure code, no samples) | +| `highscore.json` | best score (written at game over) | + +Dev / test tools (run headless, no window needed): + +```bash +.venv/bin/python autotest.py 40000 # auto-playing bot, prints game-health stats +.venv/bin/python runtest.py 900 # simulated session incl. rendering +.venv/bin/python shots.py shots # dumps screenshots of every state +.venv/bin/python probe.py # pixel-probes the rendered layout +``` + +## Design notes + +- **Physics**: fixed 60 Hz logic with up to 6 collision substeps so the ball + never tunnels; kinematic flippers whose angular velocity is transferred to + the ball (real "scoop" feel); inward-tilting floor funnels; centre drain + + guarded outlanes; a softlock rescue nudge if a ball ever parks too long. +- **Fun tuning was data-driven**: an `autotest.py` bot plays thousands of + frames and reports hit/drain rates per table element, so bumper, slingshot + and flipper geometry was tuned against measured behaviour, not guesswork. +- **Audio**: square/triangle/noise oscillators -> PCM at startup; distinct + pitched bump sounds that climb with your combo, plus two generative music + loops (menu + play). + +Licence: MIT β€” go wild. diff --git a/asciipic.py b/asciipic.py new file mode 100644 index 0000000..cb4388d --- /dev/null +++ b/asciipic.py @@ -0,0 +1,49 @@ +"""Downsample a screenshot to coarse ASCII so art can be sanity-checked in a +terminal. Usage: python asciipic.py shots/3_play_2x.png""" + +import sys +import pygame + +NAMES = { + "P": (255, 130, 195), "G": (255, 214, 92), "M": (110, 240, 200), + "W": (240, 240, 250), "B": (120, 205, 255), "f": (64, 50, 140), + "b": (22, 15, 56), "d": (14, 9, 42), "V": (200, 120, 255), + "K": (255, 230, 225), "r": (255, 90, 130), "c": (90, 70, 160), +} + + +def main(): + path = sys.argv[1] + surf = pygame.image.load(path) + w, h = surf.get_size() + cw, ch = 6, 7 + print("image", w, h) + keys = list(NAMES) + for row in range(0, h, ch): + line = [] + for col in range(0, w, cw): + # average color in cell + acc = [0, 0, 0] + n = 0 + for y in range(row, min(row + ch, h), 2): + for x in range(col, min(col + cw, w), 2): + px = surf.get_at((x, y)) + acc[0] += px[0]; acc[1] += px[1]; acc[2] += px[2] + n += 1 + if n == 0: + line.append("?") + continue + avg = tuple(a / n for a in acc) + # nearest named color, weighted + best, bd = "?", 1e9 + for k, c in NAMES.items(): + d = (avg[0] - c[0]) ** 2 * 1.2 + (avg[1] - c[1]) ** 2 + (avg[2] - c[2]) ** 2 + if d < bd: + bd, best = d, k + # dark cells just show a dot of their tone + line.append(best) + print("".join(line)) + + +if __name__ == "__main__": + main() diff --git a/autotest.py b/autotest.py new file mode 100644 index 0000000..1755931 --- /dev/null +++ b/autotest.py @@ -0,0 +1,151 @@ +"""Headless autoplay test: drive the game for N frames, report health stats. + +Usage: python autotest.py [frames] [seed] +""" + +import os +import sys + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + +import pygame # noqa: E402 +from game import Game # noqa: E402 + + +class AutoPlayer: + def __init__(self, g, seed=3): + self.g = g + self.rng = __import__("random").Random(seed) + self.charge_until = 0 + self.prev_events = None + self.stats = dict(launches=0, drains=0, bumpers=0, slings=0, + star_lit=0, fever=0, games=0, extra=0, + d_center=0, d_side=0) + self.prev = dict(hits=0, slingflash=0, lit=0, lives=3, next_extra=30000) + self.stuck = 0 + self.prev_pos = None + + def tick(self): + g = self.g + t = g.table + + if g.state == "over": + g.state = "play" + g.new_game() + self.stats["games"] += 1 + self.prev["lives"] = 3 + self.prev["next_extra"] = 30000 + + # charge then release on a schedule + if g.pending == "serve" and not t.ball_live: + if self.charge_until <= 0: + self.charge_until = self.rng.randint(24, 70) + g.keys["fire"] = True + self.charge_until -= 1 + if self.charge_until <= 0: + g.keys["fire"] = False + else: + g.keys["fire"] = False + self.charge_until = 0 + + was_live = t.ball_live + self.prev_frame = getattr(self, "prev_frame", None) + + # flipper policy: catch anything heading down the middle/bottom + b = t.ball + if t.ball_live and b.vy > 0 and b.y > 150: + g.keys["L"] = b.x < 258 + g.keys["R"] = b.x > 222 + else: + if t.ball_live and self.rng.random() < 0.04: + g.keys["L"] = self.rng.random() < 0.5 + g.keys["R"] = not g.keys["L"] + else: + g.keys["L"] = g.keys["R"] = False + + g.update() + + b = t.ball + if not was_live and t.ball_live: + self.stats["launches"] += 1 + + # count events that fired this frame (only when physics actually ran) + if t.events is not self.prev_events: + self.prev_events = t.events + for e in t.events: + if e.kind == "bumper": + self.stats["bumpers"] += 1 + elif e.kind == "sling": + self.stats["slings"] += 1 + elif e.kind == "star_lit": + self.stats["star_lit"] += 1 + if sum(s["lit"] for s in t.stars) == len(t.stars): + self.stats["fever"] += 1 + elif e.kind == "drain": + if 180 < e.x < 300: + self.stats["d_center"] += 1 + else: + self.stats["d_side"] += 1 + + if b.speed > 0.8: + self.stuck = 0 + elif t.ball_live: + self.stuck += 1 + if self.stuck > 120: + print(" !! ball appears stuck at (%.0f, %.0f)" % (b.x, b.y)) + self.stuck = 0 + b.vx = 2 + b.vy = -2 + else: + self.stuck = 0 + if g.lives < self.prev["lives"]: + self.stats["drains"] += self.prev["lives"] - g.lives + self.prev["lives"] = g.lives + if g.lives > self.prev["lives"]: + self.stats["extra"] += g.lives - self.prev["lives"] + self.prev["lives"] = g.lives + + def report(self): + s = self.stats + print("=== AUTOPLAY REPORT ===") + print("games played :", s["games"]) + print("launches :", s["launches"]) + print("drains :", s["drains"]) + print("bumper hits :", s["bumpers"]) + print("sling hits :", s["slings"]) + print("stars lit :", s["star_lit"]) + print("fevers :", s["fever"]) + print("extra lives :", s["extra"]) + print("final score :", self.g.score) + print("hi :", self.g.hi) + print("avg drain/ball ok" if s["drains"] > 0 else "!! NO DRAINS SEEN") + print("drains: center=%d side=%d" % (s["d_center"], s["d_side"])) + if s["bumpers"] == 0: + print("!! BUMPERS NEVER HIT") + if s["slings"] == 0: + print("!! SLINGS NEVER HIT") + if s["star_lit"] == 0: + print("!! STARS NEVER LIT") + return (s["bumpers"], s["slings"], s["star_lit"], s["drains"]) + + +def main(): + frames = int(sys.argv[1]) if len(sys.argv) > 1 else 40000 + seed = int(sys.argv[2]) if len(sys.argv) > 2 else 3 + g = Game() + g.state = "play" + g.new_game() + ap = AutoPlayer(g, seed) + clock = pygame.time.Clock() + for i in range(frames): + ap.tick() + if i % 2000 == 0: + print("frame %d score=%d lives=%d ball_live=%s pending=%s" % + (i, g.score, g.lives, g.table.ball_live, g.pending)) + ap.report() + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/font.py b/font.py new file mode 100644 index 0000000..6082737 --- /dev/null +++ b/font.py @@ -0,0 +1,116 @@ +"""Tiny hand-rolled 5x5 pixel bitmap font, rendered chunky for an 8-bit look.""" + +import pygame + + +GLYPHS = { + "A": [".###.", "#...#", "#####", "#...#", "#...#"], + "B": ["####.", "#...#", "####.", "#...#", "####."], + "C": [".###.", "#....", "#....", "#....", ".###."], + "D": ["####.", "#...#", "#...#", "#...#", "####."], + "E": ["#####", "#....", "####.", "#....", "#####"], + "F": ["#####", "#....", "####.", "#....", "#...."], + "G": [".###.", "#....", "#.###", "#...#", ".###."], + "H": ["#...#", "#...#", "#####", "#...#", "#...#"], + "I": ["#####", "..#..", "..#..", "..#..", "#####"], + "J": ["..###", "...#.", "...#.", "#..#.", ".##.."], + "K": ["#...#", "#..#.", "###..", "#..#.", "#...#"], + "L": ["#....", "#....", "#....", "#....", "#####"], + "M": ["#...#", "##.##", "#.#.#", "#...#", "#...#"], + "N": ["#...#", "##..#", "#.#.#", "#..##", "#...#"], + "O": [".###.", "#...#", "#...#", "#...#", ".###."], + "P": ["####.", "#...#", "####.", "#....", "#...."], + "Q": [".###.", "#...#", "#...#", "#..#.", ".##.#"], + "R": ["####.", "#...#", "####.", "#..#.", "#...#"], + "S": [".###.", "#....", ".###.", "....#", "####."], + "T": ["#####", "..#..", "..#..", "..#..", "..#.."], + "U": ["#...#", "#...#", "#...#", "#...#", ".###."], + "V": ["#...#", "#...#", "#...#", ".#.#.", "..#.."], + "W": ["#...#", "#...#", "#.#.#", "##.##", "#...#"], + "X": ["#...#", ".#.#.", "..#..", ".#.#.", "#...#"], + "Y": ["#...#", ".#.#.", "..#..", "..#..", "..#.."], + "Z": ["#####", "...#.", "..#..", ".#...", "#####"], + "0": [".###.", "#..##", "#.#.#", "##..#", ".###."], + "1": ["..#..", ".##..", "..#..", "..#..", ".###."], + "2": ["####.", "....#", ".###.", "#....", "#####"], + "3": ["####.", "....#", ".###.", "....#", "####."], + "4": ["#...#", "#...#", "#####", "....#", "....#"], + "5": ["#####", "#....", "####.", "....#", "####."], + "6": [".###.", "#....", "####.", "#...#", ".###."], + "7": ["#####", "....#", "...#.", "..#..", ".#..."], + "8": [".###.", "#...#", ".###.", "#...#", ".###."], + "9": [".###.", "#...#", ".####", "....#", ".###."], + " ": [".....", ".....", ".....", ".....", "....."], + ".": [".....", ".....", ".....", "..##.", "..##."], + ",": [".....", ".....", "..#..", "..#..", ".#..."], + "!": ["..#..", "..#..", "..#..", ".....", "..#.."], + "?": [".###.", "#...#", "..#..", ".....", "..#.."], + "'": ["..#..", "..#..", ".....", ".....", "....."], + '"': [".#.#.", ".#.#.", ".....", ".....", "....."], + ":": [".....", "..#..", ".....", "..#..", "....."], + ";": [".....", "..#..", ".....", "..#..", ".#..."], + "-": [".....", ".....", "#####", ".....", "....."], + "+": ["..#..", "..#..", "#####", "..#..", "..#.."], + "*": ["#...#", ".#.#.", "..#..", ".#.#.", "#...#"], + "/": ["....#", "...#.", "..#..", ".#...", "#...."], + "=": [".....", "#####", ".....", "#####", "....."], + "<": ["...#.", "..#..", ".#...", "..#..", "...#."], + ">": [".#...", "..#..", "...#.", "..#..", ".#..."], + "(": ["...#.", "..#..", ".#...", "..#..", "...#."], + ")": [".#...", "..#..", "...#.", "..#..", ".#..."], + "~": [".....", ".#.#.", ".#.#.", ".....", "....."], + "|": ["..#..", "..#..", "..#..", "..#..", "..#.."], + "%": ["##..#", "##.#.", "..#..", ".#.##", "#..##"], + "&": [".##..", "#..#.", ".#...", "#.#.#", ".##.#"], + "@": [".###.", "#.#.#", "#.###", "#....", ".###."], + "#": [".#.#.", "#####", ".#.#.", "#####", ".#.#."], + "^": ["..#..", ".#.#.", "#...#", ".....", "....."], + "_": [".....", ".....", ".....", ".....", "#####"], +} + +_text_cache = {} + + +def text_width(text, scale=1, spacing=1): + if not text: + return 0 + return (len(text) * 5 + (len(text) - 1) * spacing) * scale + + +def render_text(text, color, scale=1, spacing=1, shadow=None): + """Return a Surface with the pixel text drawn.""" + key = (text, color, scale, spacing, shadow) + if key in _text_cache: + return _text_cache[key] + w = text_width(text, scale, spacing) + h = 5 * scale + surf = pygame.Surface((w, h), pygame.SRCALPHA) + if shadow is not None: + _draw_into(surf, text, shadow, scale, spacing, (scale, scale)) + _draw_into(surf, text, color, scale, spacing, (0, 0)) + _text_cache[key] = surf + return surf + + +def _draw_into(surf, text, color, scale, spacing, off): + x0 = off[0] + for ch in text.upper(): + g = GLYPHS.get(ch, GLYPHS[" "]) + for row in range(5): + for col in range(5): + if g[row][col] == "#": + surf.fill(color, (x0 + col * scale, off[1] + row * scale, scale, scale)) + x0 += (5 + spacing) * scale + + +def draw_text(surf, x, y, text, color, scale=1, spacing=1, align="left", shadow=(20, 12, 40, 180)): + """Draw text. align in {'left','center','right'}. Shadow color or None.""" + t = render_text(text, color, scale, spacing, shadow) + if align == "center": + x -= t.get_width() // 2 + elif align == "right": + x -= t.get_width() + surf.blit(t, (round(x), round(y))) + return t.get_width(), t.get_height() + + diff --git a/game.py b/game.py new file mode 100644 index 0000000..516a68e --- /dev/null +++ b/game.py @@ -0,0 +1,937 @@ +"""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) +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"] + a = f["angle"] + tipx = px + math.cos(a) * f["length"] + tipy = py + math.sin(a) * f["length"] + 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 + nx, ny = -math.sin(a), 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] diff --git a/main.py b/main.py new file mode 100644 index 0000000..b492e27 --- /dev/null +++ b/main.py @@ -0,0 +1,34 @@ +"""Cosmic Candy Pinball - entry point. + +Controls: + Left/Right arrows (or Z//) .... flippers + Hold Space / Up / Down ........ charge the candy cannon, release to fire + M ................................ mute + P ................................ pause + R ................................ restart the game + Esc .............................. quit +""" + +import pygame + +from game import Game + + +def main(): + g = Game() + clock = pygame.time.Clock() + running = True + while running: + for ev in pygame.event.get(): + if not g.event(ev): + running = False + if running: + g.set_keys() + g.update() + g.render() + clock.tick(60) + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/physics.py b/physics.py new file mode 100644 index 0000000..8ea44a2 --- /dev/null +++ b/physics.py @@ -0,0 +1,448 @@ +"""Pinball physics: pure logic, no rendering. Testable headless. + +Coordinate system: y grows downward. 480x360 virtual canvas. +""" + +import math +import random + +# --------------------------------------------------------------------------- +# Playfield geometry +# --------------------------------------------------------------------------- +CV_W, CV_H = 480, 360 +PLAY_L, PLAY_R, PLAY_T, PLAY_B = 20.0, 460.0, 52.0, 346.0 + +GRAVITY = 0.24 # px/frame^2 +MAX_SPEED = 13.0 # speed cap (prevents tunneling; ~300px of rise) +RESTITUTION = 0.42 +BALL_R = 7.0 + +FLIP_LEN = 170.0 +FLIP_REST = math.radians(6.0) # tips slightly lowered toward center drain +FLIP_UP = -math.radians(52.0) # fired position +FLIP_PIVOT_L = (62.0, 338.0) +FLIP_PIVOT_R = (418.0, 338.0) +FLIP_RADIUS = 5.5 +FLIP_SNAP_UP = 0.40 # rad/frame while firing +FLIP_SNAP_DOWN = 0.05 # gentle return speed +FLIP_BOUNCE = 0.12 + +# elements +BUMPERS = [ + dict(pos=(150.0, 180.0), r=23.0, color="pink"), + dict(pos=(330.0, 180.0), r=23.0, color="mint"), + dict(pos=(240.0, 240.0), r=23.0, color="lemon"), +] +SLING_KICK_L = (0.60, -0.80) +SLING_KICK_R = (-0.60, -0.80) +SLINGS = [ + # upper slingshots by the lower bumpers - kick toward table center + dict(pos=(150.0, 282.0), r=11.0, kick=SLING_KICK_L), + dict(pos=(330.0, 282.0), r=11.0, kick=SLING_KICK_R), + # guard posts at the outlane mouths - protect the corners + dict(pos=(44.0, 314.0), r=12.0, kick=(0.80, -0.60)), + dict(pos=(436.0, 314.0), r=12.0, kick=(-0.80, -0.60)), +] +STARS = [(168.0, 92.0), (240.0, 92.0), (312.0, 92.0)] + +CANNON = (440.0, 152.0) +CANNON_R = 17.0 +LAUNCH_DIR = (-0.94, -0.34) +LAUNCH_MIN, LAUNCH_MAX = 7.0, 13.5 + +DRAIN_Y = 362.0 +CENTER_HOLE = (200.0, 280.0) +OUTLANE_L = 46.0 +OUTLANE_R = 434.0 + + +def clamp(v, lo, hi): + return lo if v < lo else hi if v > hi else v + + +def seg_info(px, py, ax, ay, bx, by): + """Closest point on segment ab to p; returns (t, cx, cy, dist2).""" + abx, aby = bx - ax, by - ay + l2 = abx * abx + aby * aby + if l2 == 0.0: + return 0.0, ax, ay, (px - ax) ** 2 + (py - ay) ** 2 + t = clamp(((px - ax) * abx + (py - ay) * aby) / l2, 0.0, 1.0) + cx, cy = ax + abx * t, ay + aby * t + dx, dy = px - cx, py - cy + return t, cx, cy, dx * dx + dy * dy + + +class Ball: + __slots__ = ("x", "y", "vx", "vy", "rad", "prev_x", "prev_y") + + def __init__(self, x, y): + self.x, self.y = float(x), float(y) + self.vx = self.vy = 0.0 + self.rad = BALL_R + self.prev_x, self.prev_y = x, y + + @property + def speed(self): + return math.hypot(self.vx, self.vy) + + +class Event: + __slots__ = ("kind", "x", "y", "data") + + def __init__(self, kind, x=0.0, y=0.0, data=None): + self.kind, self.x, self.y, self.data = kind, x, y, data + + def __repr__(self): + return "Event(%s)" % self.kind + + +class Table: + def __init__(self, seed=1): + self.rng = random.Random(seed) + self.events = [] + self.frame = 0 + + # static walls / floors: list of (a, b, kind) where kind in + # ("rail", "floor") - rails keep balls in, floors hold them up + self.walls = [ + ((PLAY_L, PLAY_T), (PLAY_L, PLAY_B), "rail"), + ((PLAY_R, PLAY_T), (PLAY_R, PLAY_B), "rail"), + ((PLAY_L, PLAY_T), (PLAY_R, PLAY_T), "rail"), + # floors tilt gently down toward the center so balls funnel inward + ((OUTLANE_L, PLAY_B - 8), (CENTER_HOLE[0], PLAY_B), "floor"), + ((CENTER_HOLE[1], PLAY_B), (OUTLANE_R, PLAY_B - 8), "floor"), + ((CENTER_HOLE[0], PLAY_B), (CENTER_HOLE[0], DRAIN_Y), "hole"), + ((CENTER_HOLE[1], PLAY_B), (CENTER_HOLE[1], DRAIN_Y), "hole"), + ((OUTLANE_L, PLAY_B), (OUTLANE_L, DRAIN_Y), "hole"), + ((OUTLANE_R, PLAY_B), (OUTLANE_R, DRAIN_Y), "hole"), + ] + + self.bumpers = [ + dict(x=b["pos"][0], y=b["pos"][1], r=b["r"], color=b["color"], + cool=0, hit=0, squash=0, face_t=0.0, blink=0.0) + for b in BUMPERS] + self.slings = [ + dict(x=s["pos"][0], y=s["pos"][1], r=s["r"], cool=0, flash=0, + kick=s["kick"], guard=(i >= 2)) + for i, s in enumerate(SLINGS)] + self.stars = [dict(x=x, y=y, r=13.0, lit=False, cool=0, pulse=0) + for x, y in STARS] + self.flippers = [ + dict(side="L", pivot=FLIP_PIVOT_L, angle=FLIP_REST, rest=FLIP_REST, + up=FLIP_UP, length=FLIP_LEN, pressed=False, w=0.0, contacting=False), + dict(side="R", pivot=FLIP_PIVOT_R, angle=FLIP_REST, rest=FLIP_REST, + up=FLIP_UP, length=FLIP_LEN, pressed=False, w=0.0, contacting=False), + ] + self.cannon = dict(x=CANNON[0], y=CANNON[1], r=CANNON_R) + + self.ball = Ball(CANNON[0], CANNON[1]) + self.ball_live = False + self.charge = 0.0 + self.stuck_frames = 0 + self.trail = [] + + # ------------------------------------------------------------- input + def press(self, side, down): + f = self.flippers[0] if side == "L" else self.flippers[1] + f["pressed"] = down + + # ------------------------------------------------------------- setup + def reset_ball_in_cannon(self): + self.charge = 0.0 + self.ball_live = False + self.ball.x, self.ball.y = CANNON[0] + 6, CANNON[1] + self.ball.vx = self.ball.vy = 0.0 + self.ball.prev_x, self.ball.prev_y = self.ball.x, self.ball.y + self.stuck_frames = 0 + self.trail = [] + + def launch(self, power): + self.ball_live = True + mx, my = CANNON + off = CANNON_R + self.ball.rad + 2.0 + self.ball.x = mx + LAUNCH_DIR[0] * off + self.ball.y = my + LAUNCH_DIR[1] * off + self.ball.vx = LAUNCH_DIR[0] * power + self.ball.vy = LAUNCH_DIR[1] * power + self.ball.prev_x, self.ball.prev_y = self.ball.x, self.ball.y + self.trail = [(self.ball.x, self.ball.y)] + return Event("launch", self.ball.x, self.ball.y) + + # ------------------------------------------------------------- frame + def update(self): + self.events = [] + self.frame += 1 + + # decay timers + for b in self.bumpers: + if b["cool"] > 0: + b["cool"] -= 1 + if b["squash"] > 0: + b["squash"] -= 1 + b["face_t"] += 1 + if b["blink"] > 0: + b["blink"] -= 1 + elif self.rng.random() < 0.0025: + b["blink"] = 16 + for s in self.slings: + if s["cool"] > 0: + s["cool"] -= 1 + if s["flash"] > 0: + s["flash"] -= 1 + for st in self.stars: + if st["cool"] > 0: + st["cool"] -= 1 + if st["pulse"] > 0: + st["pulse"] -= 1 + + if not self.ball_live: + return self.events + if self.ball.y > DRAIN_Y + 30 and not self._in_drain(self.ball.x, self.ball.y): + # lost below the table somehow - force a drain + self.events.append(Event("drain", self.ball.x, self.ball.y)) + return self.events + + ball = self.ball + + # flipper angle update (kinematic; per-frame, then substepped) + for f in self.flippers: + target = f["up"] if f["pressed"] else f["rest"] + rate = FLIP_SNAP_UP if f["pressed"] else FLIP_SNAP_DOWN + d = target - f["angle"] + f["angle"] = target if abs(d) < rate else f["angle"] + (rate if d > 0 else -rate) + f["w"] = f["angle"] - (f.get("prev_angle", f["angle"])) + f["prev_angle"] = f["angle"] + f["contacting"] = False + + speed = ball.speed + n_sub = 1 if speed <= 0.0 else min(6, max(1, int(math.ceil(speed / 4.0)))) + for _ in range(n_sub): + self._substep(ball, 1.0 / n_sub) + + self.trail.append((ball.x, ball.y)) + if len(self.trail) > 9: + self.trail.pop(0) + + if ball.speed < 0.55: + self.stuck_frames += 1 + if self.stuck_frames > 60 and self.stuck_frames % 14 == 0: + ang = self.rng.uniform(0.0, math.tau) + ball.vx += math.cos(ang) * 1.3 + ball.vy += math.sin(ang) * 1.3 - 1.1 + self.events.append(Event("nudge", ball.x, ball.y)) + else: + self.stuck_frames = 0 + + if self._in_drain(ball.x, ball.y): + self.events.append(Event("drain", ball.x, ball.y)) + return self.events + + # ------------------------------------------------------------- helpers + def _in_drain(self, x, y): + if y < DRAIN_Y - 2: + return False + if CENTER_HOLE[0] - 6 < x < CENTER_HOLE[1] + 6: + return True + if x < OUTLANE_L - 4 or x > OUTLANE_R + 4: + return True + if y > DRAIN_Y + 8: + return x < OUTLANE_L + 10 or x > OUTLANE_R - 10 + return False + + # ------------------------------------------------------------- substep + def _substep(self, ball, dt): + ball.vy += GRAVITY * dt + ball.vx *= (1.0 - 0.014 * dt) + ball.vy *= (1.0 - 0.005 * dt) + ball.prev_x, ball.prev_y = ball.x, ball.y + ball.x += ball.vx * dt + ball.y += ball.vy * dt + + # static walls / floors + for (ax, ay), (bx, by), kind in self.walls: + t, cx, cy, d2 = seg_info(ball.x, ball.y, ax, ay, bx, by) + if d2 >= ball.rad * ball.rad: + continue + if t <= 0.0 or t >= 1.0: + continue + dist = math.sqrt(d2) + nx = (ball.x - cx) / dist + ny = (ball.y - cy) / dist + # one-way-ish: never fling a ball that has dropped past a floor plane + if kind == "floor" and ball.y > PLAY_B + ball.rad * 0.5 and ny < -0.3: + continue + ball.x = cx + nx * ball.rad + ball.y = cy + ny * ball.rad + vn = ball.vx * nx + ball.vy * ny + if vn < 0.0: + e = RESTITUTION if kind == "floor" else 0.5 + ball.vx -= (1.0 + e) * vn * nx + ball.vy -= (1.0 + e) * vn * ny + ball.vx *= 0.985 + if kind == "rail" and ball.speed > 8.0: + self.events.append(Event("wall_hit", ball.x, ball.y, ball.speed)) + + # bumpers + for b in self.bumpers: + self._bumper_collide(ball, b) + + # slingshots + for s in self.slings: + self._sling_collide(ball, s) + + # flippers + for f in self.flippers: + self._flipper_collide(ball, f) + + # cannon deflector + self._circle_push(ball, self.cannon["x"], self.cannon["y"], + self.cannon["r"], 0.5) + + # star lanes + for st in self.stars: + dx = ball.x - st["x"] + dy = ball.y - st["y"] + rr = st["r"] + ball.rad + if dx * dx + dy * dy < rr * rr and st["cool"] <= 0: + st["cool"] = 26 + st["pulse"] = 14 + if not st["lit"]: + st["lit"] = True + self.events.append(Event("star_lit", st["x"], st["y"], st)) + else: + self.events.append(Event("star_again", st["x"], st["y"], st)) + + self._cap(ball) + + @staticmethod + def _cap(ball): + sp = ball.speed + if sp > MAX_SPEED: + k = MAX_SPEED / sp + ball.vx *= k + ball.vy *= k + + def _circle_push(self, ball, cx, cy, cr, e, impulse=None, cool_key=None): + """Generic circle collider. impulse=(ix,iy,strength) overrides bounce.""" + dx = ball.x - cx + dy = ball.y - cy + rr = cr + ball.rad + d2 = dx * dx + dy * dy + if d2 >= rr * rr or d2 < 0.0001: + return False + d = math.sqrt(d2) + nx, ny = dx / d, dy / d + ball.x = cx + nx * rr + ball.y = cy + ny * rr + if impulse is not None: + kx, ky, sp = impulse + ball.vx = kx * sp + ball.vy = ky * sp + return True + vn = ball.vx * nx + ball.vy * ny + if vn < 0.0: + ball.vx -= (1.0 + e) * vn * nx + ball.vy -= (1.0 + e) * vn * ny + return True + + def _bumper_collide(self, ball, b): + dx = ball.x - b["x"] + dy = ball.y - b["y"] + rr = b["r"] + ball.rad + d2 = dx * dx + dy * dy + if d2 >= rr * rr or d2 < 0.0001: + return + d = math.sqrt(d2) + nx, ny = dx / d, dy / d + ball.x = b["x"] + nx * rr + ball.y = b["y"] + ny * rr + if b["cool"] > 0: + return + b["cool"] = 6 + b["hit"] += 1 + b["squash"] = 5 + b["face_t"] = 0.0 + kick = max(6.0, ball.speed * 0.72 + 4.2) + ball.vx = nx * kick + ball.vy = ny * kick + self.events.append(Event("bumper", b["x"] + nx * b["r"], b["y"] + ny * b["r"], b)) + + def _sling_collide(self, ball, s): + dx = ball.x - s["x"] + dy = ball.y - s["y"] + rr = s["r"] + ball.rad + d2 = dx * dx + dy * dy + if d2 >= rr * rr or d2 < 0.0001: + return + d = math.sqrt(d2) + nx, ny = dx / d, dy / d + ball.x = s["x"] + nx * rr + ball.y = s["y"] + ny * rr + if s["cool"] > 0: + return + s["cool"] = 4 + s["flash"] = 7 + kx, ky = s["kick"] + ball.vx = kx * 9.6 + ball.vy = ky * 9.6 + self.events.append(Event("sling", s["x"] + kx * 22, s["y"] + ky * 22, s)) + + def _flipper_collide(self, ball, f): + px, py = f["pivot"] + a = f["angle"] + tipx = px + math.cos(a) * f["length"] + tipy = py + math.sin(a) * f["length"] + t, cx, cy, d2 = seg_info(ball.x, ball.y, px, py, tipx, tipy) + rad = FLIP_RADIUS + ball.rad + if d2 >= rad * rad: + return + # endpoint (pivot) -> small circle collider; tip end -> still a segment + if t <= 0.0 or t >= 1.0: + ex, ey = (px, py) if t <= 0.0 else (tipx, tipy) + exx, eyy = ball.x - ex, ball.y - ey + ed = math.hypot(exx, eyy) + if ed < rad: + nx, ny = exx / ed, eyy / ed + ball.x = ex + nx * rad + ball.y = ey + ny * rad + self._flipper_impulse(ball, f, nx, ny) + return + dist = math.sqrt(d2) + nx = (ball.x - cx) / dist + ny = (ball.y - cy) / dist + ball.x = cx + nx * rad + ball.y = cy + ny * rad + self._flipper_impulse(ball, f, nx, ny) + + def _flipper_impulse(self, ball, f, nx, ny): + """Reflect relative velocity across the moving flipper surface.""" + w = f["w"] + px, py = f["pivot"] + # surface velocity of the flipper at the contact point, in px/frame + # rotating by angle w (screen-clockwise positive) => v = w * (-ry, rx) + rx, ry = ball.x - px, ball.y - py + svx = -ry * w + svy = rx * w + rvx = ball.vx - svx + rvy = ball.vy - svy + vn = rvx * nx + rvy * ny + + fresh = not f["contacting"] + f["contacting"] = True + if vn < -0.9: + if fresh: + j = -(1.0 + FLIP_BOUNCE) * vn + else: + j = -vn # inelastic follow-through while pressing + ball.vx += j * nx + ball.vy += j * ny + elif fresh and vn > 0.0: + # tiny push to keep the ball from sinking into a resting flipper + pass + # tangential traction: transfer some of the flipper's swipe to the ball + tx, ty = -ny, nx + vt = rvx * tx + rvy * ty + traction = 0.35 if abs(w) > 0.01 else 0.0 + ball.vx += vt * tx * -traction + svx * 0.0 + ball.vy += vt * ty * -traction + self._cap(ball) diff --git a/probe.py b/probe.py new file mode 100644 index 0000000..16d713f --- /dev/null +++ b/probe.py @@ -0,0 +1,86 @@ +"""Pixel-probe the rendered frames for layout sanity (since art can't be +viewed directly in this session).""" + +import os + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + +import pygame # noqa: E402 +from game import Game, C # noqa: E402 +from physics import CV_W, CV_H # noqa: E402 + + +def count_px(surf, rect, pred): + n = 0 + for y in range(rect[1], rect[1] + rect[3]): + for x in range(rect[0], rect[0] + rect[2]): + px = surf.get_at((x, y)) + if pred(px): + n += 1 + return n + + +def near(c, t, tol=60): + return abs(c[0] - t[0]) <= tol and abs(c[1] - t[1]) <= tol and abs(c[2] - t[2]) <= tol + + +def main(): + g = Game() + g.state = "play" + g.new_game() + g.score = 123456 + g.lives = 3 + for st in g.table.stars: + st["lit"] = True + g.table.ball_live = True + g.pending = "live" + g.table.ball.x, g.table.ball.y = 240, 300 + g.table.ball.vx, g.table.ball.vy = 0, 3 + for _ in range(3): + g.update() + g.render() + s = g.canvas + checks = [] + # HUD white score digits around (14..100, 13..26) + checks.append(("HUD score digits", count_px(s, (14, 13, 110, 15), + lambda p: near(p, (255, 255, 255), 40)) > 25)) + # HUD gold BEST digits near (170..300, 13..26) + checks.append(("HUD gold best", count_px(s, (170, 13, 150, 15), + lambda p: near(p, C["gold"], 55)) > 10)) + # hearts red at (336..390, 14..30) + checks.append(("hearts", count_px(s, (336, 12, 60, 22), + lambda p: near(p, C["heart"], 50)) > 20)) + # three gold stars in HUD at (52..112, 30..44) + checks.append(("HUD stars lit", count_px(s, (44, 28, 90, 18), + lambda p: near(p, C["gold"], 55)) > 15)) + # playfield interior present (not black) + checks.append(("field fill", near(s.get_at((240, 100)), C["field"], 70))) + # pink rail present on left edge inside play area + rail_ok = count_px(s, (14, 70, 12, 20), lambda p: near(p, C["rail"], 60)) > 10 + checks.append(("left rail pink", rail_ok)) + # bumper pink at (150,180) + checks.append(("bumper pink", near(s.get_at((150, 178)), C["pink"], 55))) + # bumper lemon at (240,240) + checks.append(("bumper lemon", near(s.get_at((240, 238)), C["lemon"], 55))) + # pit dark center at (240, 352) + checks.append(("pit center", near(s.get_at((240, 353)), C["pit"], 30))) + # flipper blue present around (240, 320) + checks.append(("flipper blue", count_px(s, (100, 300, 280, 45), + lambda p: near(p, C["flipper"], 55)) > 30)) + # cannon purple body at (440,152) + checks.append(("cannon", near(s.get_at((440, 152)), (150, 100, 220), 70))) + # ball near (240,300) is white-ish + checks.append(("ball", count_px(s, (230, 290, 20, 20), + lambda p: near(p, (245, 250, 255), 40)) > 5)) + ok = True + for name, passed in checks: + print(("%s %s" % ("PASS" if passed else "FAIL", name))) + ok = ok and passed + pygame.image.save(s, "probe_play.png") + pygame.quit() + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..48f0979 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# Runtime dependency (the only one!). Developed and tested on: +# CPython 3.12, pygame-ce 2.5.8 (pygame-ce, not pygame). +# Should also work on Python 3.10+. +pygame-ce==2.5.8 diff --git a/runtest.py b/runtest.py new file mode 100644 index 0000000..8b6a1ae --- /dev/null +++ b/runtest.py @@ -0,0 +1,68 @@ +"""Simulate a real play session: posts real pygame events through Game.event, +drives keys, renders every frame. Headless. Usage: python runtest.py [frames]""" + +import os +import sys +import random + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + +import pygame # noqa: E402 +from game import Game # noqa: E402 + +pygame.init() +pygame.display.init() +KEYS = [pygame.K_LEFT, pygame.K_RIGHT, pygame.K_SPACE, + pygame.K_UP, pygame.K_z, pygame.K_j, pygame.K_SLASH] + + +def main(): + frames = int(sys.argv[1]) if len(sys.argv) > 1 else 900 + rng = random.Random(11) + g = Game() + # press Enter on the title + pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=pygame.K_RETURN)) + clock = pygame.time.Clock() + held = set() + start = pygame.time.get_ticks() + for i in range(frames): + # occasionally press/release flipper & launch keys for real + if rng.random() < 0.2: + k = rng.choice(KEYS) + if k in held: + held.discard(k) + pygame.event.post(pygame.event.Event(pygame.KEYUP, key=k)) + else: + held.add(k) + pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=k)) + if i % 700 == 350: + pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=pygame.K_p)) # pause test + if i % 700 == 520: + pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=pygame.K_p)) + for ev in pygame.event.get(): + if not g.event(ev): + print("quit requested") + pygame.quit() + return 0 + g.set_keys() + g.update() + g.render() + clock.tick(240) + dt = (pygame.time.get_ticks() - start) / 1000.0 + print("ran %d frames in %.1fs state=%s score=%d lives=%d" % + (frames, dt, g.state, g.score, g.lives)) + # throw a couple of state transitions: force game over to check the screen + g.lives = 1 + g.pending = "drain_pause" + for _ in range(200): + g.set_keys() + g.update() + g.render() + print("after forced drain state=%s over_t=%s" % (g.state, g.pending)) + pygame.quit() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sfx.py b/sfx.py new file mode 100644 index 0000000..22e15d1 --- /dev/null +++ b/sfx.py @@ -0,0 +1,246 @@ +"""Procedural chiptune SFX & music synthesis. No audio files - everything is +generated into PCM buffers at startup.""" + +import array +import math +import random + +import pygame +SR = 22050 # sample rate + +# note -> frequency +NOTE = { + "C": 261.63, "C#": 277.18, "D": 293.66, "D#": 311.13, "E": 329.63, + "F": 349.23, "F#": 369.99, "G": 392.00, "G#": 415.30, "A": 440.00, + "A#": 466.16, "B": 493.88, +} + + +def freq(note, octave): + return NOTE[note] * (2 ** (octave - 4)) + + +class Synth: + """Builds Sound objects through pygame.mixer (falls back silently if no audio).""" + + def __init__(self): + self.ok = False + self.sounds = {} + try: + if not pygame.mixer.get_init(): + pygame.mixer.init(SR, -16, 1, 512) + self.ok = True + except Exception: + return + self._rng = random.Random(7) + self._build_all() + self.music_channel = pygame.mixer.Channel(0) + self.sfx_channels = [pygame.mixer.Channel(i) for i in range(1, 6)] + self._ch_i = 0 + + # ---- sample builders ------------------------------------------------- + @staticmethod + def _env(n, attack=0.005, release=0.25): + """Apply simple AD envelope to n samples.""" + a = max(1, int(attack * SR)) + r = max(1, int(release * SR)) + out = [] + for i in range(n): + if i < a: + v = i / a + elif i > n - r: + v = max(0.0, (n - i) / r) + else: + v = 1.0 + out.append(v) + return out + + @staticmethod + def _wave(shape, phase, duty=0.5): + p = phase % 1.0 + if shape == "square": + return 1.0 if p < duty else -1.0 + if shape == "saw": + return 2.0 * p - 1.0 + if shape == "tri": + return 4.0 * abs(p - 0.5) - 1.0 + if shape == "noise": + return 1.0 + return math.sin(2 * math.pi * p) + + def tone(self, f, dur, vol=0.5, shape="square", slide=0.0, duty=0.5): + """slide: freq multiplier change over duration (e.g. 0.7 sweeps down).""" + n = max(1, int(SR * dur)) + env = self._env(n) + out = array.array("h") + phase = 0.0 + for i in range(n): + t = i / n + ff = f * (1.0 + (slide - 1.0) * t) + phase += ff / SR + v = self._wave(shape, phase, duty) * env[i] * vol + out.append(int(max(-1.0, min(1.0, v)) * 32000)) + return out + + def noise(self, dur, vol=0.35, lp=0.3, slide=1.0): + """Filtered-ish noise burst. lp = lowpass smoothing (0..1).""" + n = max(1, int(SR * dur)) + env = self._env(n) + out = array.array("h") + acc = 0.0 + for i in range(n): + acc += (self._rng.uniform(-1, 1) - acc) * lp + v = acc * env[i] * vol + out.append(int(max(-1.0, min(1.0, v)) * 32000)) + return out + + @staticmethod + def _mix(*arrays): + ln = max(len(a) for a in arrays) + acc = [0] * ln + for a in arrays: + for i, v in enumerate(a): + acc[i] += v + peak = max(1, max(abs(v) for v in acc)) + scale = 32000.0 / peak if peak > 32000 else 1.0 + return array.array("h", (int(v * scale) for v in acc)) + + def _snd(self, name, samples): + try: + self.sounds[name] = pygame.mixer.Sound(buffer=samples.tobytes()) + except Exception: + self.sounds[name] = None + + # ---- all effects ----------------------------------------------------- + def _build_all(self): + self._snd("flipL", self.tone(180, 0.07, 0.5, "square", slide=0.7)) + self._snd("flipR", self.tone(210, 0.07, 0.5, "square", slide=0.7)) + self._snd("wall", self.tone(90, 0.045, 0.18, "square", duty=0.3)) + self._snd("launch", self._mix( + self.noise(0.28, 0.5, lp=0.25), + self.tone(160, 0.3, 0.35, "saw", slide=1.9))) + self._snd("drain", self.tone(500, 0.55, 0.5, "saw", slide=0.18)) + self._snd("tick", self.tone(2400, 0.015, 0.12, "square")) + + # bumpers: pitched by combo (choose at play time via 'bump' variants) + for i in range(12): + f = 300 * (1.08 ** i) + self._snd("bump%d" % i, self.tone(f, 0.16, 0.5, "square", slide=0.85)) + + self._snd("sling", self._mix( + self.tone(700, 0.05, 0.4, "square", slide=0.5), + self.tone(350, 0.09, 0.4, "saw", slide=0.8))) + self._snd("star", self.tone(880, 0.12, 0.42, "tri", slide=1.5)) + self._snd("starLit", self._mix( + self.tone(660, 0.09, 0.4, "tri"), self.tone(990, 0.16, 0.4, "tri"))) + + # arpeggio helpers + def arp(notes, step=0.11, vol=0.42): + parts = [] + for i, (nm, oc) in enumerate(notes): + parts.append(self.tone(freq(nm, oc), step * 1.6, vol, "square", slide=1.1)) + if i < len(notes) - 1: + parts.append(array.array("h", [0]) * int(SR * step * 0.55)) + return self._mix(*parts) if parts else array.array("h", [0]) + + self._snd("fever", arp([("C", 5), ("E", 5), ("G", 5), ("C", 6), ("E", 6), ("G", 6)], 0.09, 0.45)) + self._snd("extra", arp([("C", 4), ("C", 5), ("G", 5), ("E", 5), ("C", 6)], 0.13, 0.5)) + self._snd("milestone", arp([("C", 5), ("E", 5), ("A", 5), ("E", 6)], 0.1, 0.45)) + self._snd("combo", arp([("A", 4), ("C", 5), ("E", 5)], 0.06, 0.35)) + + def seq(note_spec, step=0.13, vol=0.4, shape="square"): + parts = [] + for nm, oc in note_spec: + parts.append(self.tone(freq(nm, oc), step * 1.35, vol, shape)) + return self._mix(*parts) + + self._snd("gameover", seq( + [("E", 5), ("C", 5), ("A", 4), ("F", 4), ("C", 4)], 0.24, 0.4, "saw")) + self._snd("start", seq( + [("C", 4), ("E", 4), ("G", 4), ("C", 5), ("E", 5), ("G", 5)], 0.09, 0.45)) + self._snd("hi", seq([("C", 6), ("E", 6), ("G", 6), ("C", 7)], 0.08, 0.3, "tri")) + + self._build_music() + + def _build_music(self): + """Two short generative loops: a dreamy menu loop and a bouncy play loop.""" + # menu loop: soft arpeggio C - Am - F - G + chords = [["C", "E", "G"], ["A", "C", "E"], ["F", "A", "C"], ["G", "B", "D"]] + bass = [("C", 2), ("A", 2), ("F", 2), ("G", 2)] + beat = 0.26 + parts = [] + for ci in range(4): + n = int(SR * beat * 2) + parts.append(self.tone(freq(bass[ci][0], bass[ci][1]), beat * 2.1, 0.22, "tri")) + # melodic ripple over the chord + for k in range(4): + note = chords[ci][k % 3] + f0 = freq(note, 4 if k < 2 else 5) + parts.append(self.tone(f0, beat * 0.8, 0.14, "square", duty=0.4)) + parts.append(array.array("h", [0]) * int(SR * beat * 0.2)) + self._menu_music = self._mix(*parts) + + # play loop: bouncy 8th-note line C-C-G-G-A-A-G + line = [("C", 4), ("C", 4), ("G", 4), ("G", 4), ("A", 4), ("A", 4), ("G", 4), ("E", 4)] + bassp = [("C", 2), ("C", 2), ("G", 2), ("G", 2), ("A", 2), ("A", 2), ("E", 2), ("E", 2)] + step = 0.16 + parts = [] + for i in range(16): + nm, oc = line[i % 8] + bn, bo = bassp[i % 8] + parts.append(self.tone(freq(bn, bo), step * 1.8, 0.28, "tri")) + parts.append(self.tone(freq(nm, oc), step * 1.3, 0.18, "square", duty=0.4)) + if i % 2 == 1: + parts.append(self.noise(0.03, 0.10, lp=0.15)) + else: + parts.append(array.array("h", [0]) * int(SR * step * 0.3)) + self._play_music = self._mix(*parts) + self._menu = pygame.mixer.Sound(buffer=self._menu_music.tobytes()) + self._play = pygame.mixer.Sound(buffer=self._play_music.tobytes()) + self._menu.set_volume(0.5) + self._play.set_volume(0.45) + + # ---- playback -------------------------------------------------------- + def play(self, name, vol=1.0, pitch_var=0): + if not self.ok: + return + key = name + if pitch_var: + key = "%s%d" % (name, pitch_var) + s = self.sounds.get(key) + if s is None: + s = self.sounds.get(name) + if s is None: + return + ch = self.sfx_channels[self._ch_i] + self._ch_i = (self._ch_i + 1) % len(self.sfx_channels) + ch.set_volume(min(1.0, vol)) + ch.play(s) + + def start_menu_music(self): + if not self.ok: + return + self.music_channel.set_volume(0.8) + self.music_channel.play(self._menu, loops=-1) + + def start_play_music(self): + if not self.ok: + return + self.music_channel.set_volume(0.8) + self.music_channel.play(self._play, loops=-1) + + def stop_music(self): + if self.ok: + self.music_channel.stop() + + def set_muted(self, m): + self.muted = m + if self.ok: + self.music_channel.set_volume(0.0 if m else 0.8) + + def set_sfx_muted(self, m): + self.sfx_muted = m + if self.ok: + for ch in self.sfx_channels: + ch.set_volume(0.0 if m else 1.0) + diff --git a/shots.py b/shots.py new file mode 100644 index 0000000..d026e86 --- /dev/null +++ b/shots.py @@ -0,0 +1,91 @@ +"""Render screenshots of key game states (headless) so we can inspect art. + +Usage: python shots.py [outdir] +""" + +import os +import sys + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + +import pygame # noqa: E402 +from game import Game # noqa: E402 + + +def save(g, name, out): + g.render() + path = os.path.join(out, name + ".png") + pygame.image.save(g.canvas, path) + # also save an upscaled preview + big = pygame.transform.scale(g.canvas, (CV_W * 2, CV_H * 2)) + pygame.image.save(big, os.path.join(out, name + "_2x.png")) + print("saved", path) + + +from physics import CV_W, CV_H # noqa: E402 + + +def main(): + out = sys.argv[1] if len(sys.argv) > 1 else "shots" + os.makedirs(out, exist_ok=True) + g = Game() + + # ---- title ---- + for _ in range(50): + g.update() + save(g, "1_title", out) + + # ---- serve hint ---- + g.state = "play" + g.new_game() + for _ in range(140): # past the ball banner + g.update() + save(g, "2_serve", out) + + # ---- mid play, staged action ---- + t = g.table + # force a live ball drifting toward the bumpers + t.reset_ball_in_cannon() + g.pending = "live" + t.ball_live = True + t.ball.x, t.ball.y = 415, 60 + t.ball.vx, t.ball.vy = -2.2, 3.1 + for _ in range(70): + g.keys["fire"] = False + g.update() + save(g, "3_play", out) + + # manually trigger some juice: bumper hit + combo + fever + g.combo = 4 + g.combo_t = 150 + g.fever = 60 * 12 + g.add_msg("+500", 150, 150, (255, 214, 92)) + for st in t.stars: + st["lit"] = True + b = t.bumpers[0] + b["squash"] = 4 + b["face_t"] = 0.0 + g.particles.append(dict(x=150, y=150, life=14, kind="ring", color=(255, 255, 255))) + g.bubble = ["CANDY FEVER!", 90, "star"] + save(g, "4_fever", out) + + # ---- drain overlay ---- + g.pending = "drain_pause" + g.pending_t = 40 + save(g, "5_drain", out) + + # ---- game over ---- + g.pending = "over_banner" + g.pending_t = 1 + g.lives = 0 + g.update() + for _ in range(80): + g.update() + save(g, "6_gameover", out) + + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/shots/1_title.png b/shots/1_title.png new file mode 100644 index 0000000..a8b96cb Binary files /dev/null and b/shots/1_title.png differ diff --git a/shots/1_title_2x.png b/shots/1_title_2x.png new file mode 100644 index 0000000..0bef325 Binary files /dev/null and b/shots/1_title_2x.png differ diff --git a/shots/2_serve.png b/shots/2_serve.png new file mode 100644 index 0000000..fe6d12c Binary files /dev/null and b/shots/2_serve.png differ diff --git a/shots/2_serve_2x.png b/shots/2_serve_2x.png new file mode 100644 index 0000000..2a0a536 Binary files /dev/null and b/shots/2_serve_2x.png differ diff --git a/shots/3_play.png b/shots/3_play.png new file mode 100644 index 0000000..5242145 Binary files /dev/null and b/shots/3_play.png differ diff --git a/shots/3_play_2x.png b/shots/3_play_2x.png new file mode 100644 index 0000000..b41b23e Binary files /dev/null and b/shots/3_play_2x.png differ diff --git a/shots/4_fever.png b/shots/4_fever.png new file mode 100644 index 0000000..5c595b2 Binary files /dev/null and b/shots/4_fever.png differ diff --git a/shots/4_fever_2x.png b/shots/4_fever_2x.png new file mode 100644 index 0000000..94d0fb7 Binary files /dev/null and b/shots/4_fever_2x.png differ diff --git a/shots/5_drain.png b/shots/5_drain.png new file mode 100644 index 0000000..4f00805 Binary files /dev/null and b/shots/5_drain.png differ diff --git a/shots/5_drain_2x.png b/shots/5_drain_2x.png new file mode 100644 index 0000000..74385c9 Binary files /dev/null and b/shots/5_drain_2x.png differ diff --git a/shots/6_gameover.png b/shots/6_gameover.png new file mode 100644 index 0000000..5cc14e3 Binary files /dev/null and b/shots/6_gameover.png differ diff --git a/shots/6_gameover_2x.png b/shots/6_gameover_2x.png new file mode 100644 index 0000000..aa15109 Binary files /dev/null and b/shots/6_gameover_2x.png differ