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.
@@ -0,0 +1,10 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# project venv
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# generated
|
||||||
|
highscore.json
|
||||||
|
probe_play.png
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 12 KiB |