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.
This commit is contained in:
georgios
2026-09-09 16:18:39 +02:00
commit db49b466c2
25 changed files with 2330 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Python
__pycache__/
*.pyc
# project venv
.venv/
# generated
highscore.json
probe_play.png
+90
View File
@@ -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.
+49
View File
@@ -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()
+151
View File
@@ -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()
+116
View File
@@ -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()
+937
View File
@@ -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]
+34
View File
@@ -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()
+448
View File
@@ -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)
+86
View File
@@ -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())
+4
View File
@@ -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
+68
View File
@@ -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())
+246
View File
@@ -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)
+91
View File
@@ -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()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB