Cosmic Candy Pinball: procedural 8-bit pinball game

Native pygame-ce game with fully procedural pixel art and chiptune
audio (no asset files). Pure-logic physics module with flipper angular
momentum, plus headless autoplay/screenshot/probe dev tools.
This commit is contained in:
georgios
2026-09-09 16:18:39 +02:00
commit db49b466c2
25 changed files with 2330 additions and 0 deletions
+246
View File
@@ -0,0 +1,246 @@
"""Procedural chiptune SFX & music synthesis. No audio files - everything is
generated into PCM buffers at startup."""
import array
import math
import random
import pygame
SR = 22050 # sample rate
# note -> frequency
NOTE = {
"C": 261.63, "C#": 277.18, "D": 293.66, "D#": 311.13, "E": 329.63,
"F": 349.23, "F#": 369.99, "G": 392.00, "G#": 415.30, "A": 440.00,
"A#": 466.16, "B": 493.88,
}
def freq(note, octave):
return NOTE[note] * (2 ** (octave - 4))
class Synth:
"""Builds Sound objects through pygame.mixer (falls back silently if no audio)."""
def __init__(self):
self.ok = False
self.sounds = {}
try:
if not pygame.mixer.get_init():
pygame.mixer.init(SR, -16, 1, 512)
self.ok = True
except Exception:
return
self._rng = random.Random(7)
self._build_all()
self.music_channel = pygame.mixer.Channel(0)
self.sfx_channels = [pygame.mixer.Channel(i) for i in range(1, 6)]
self._ch_i = 0
# ---- sample builders -------------------------------------------------
@staticmethod
def _env(n, attack=0.005, release=0.25):
"""Apply simple AD envelope to n samples."""
a = max(1, int(attack * SR))
r = max(1, int(release * SR))
out = []
for i in range(n):
if i < a:
v = i / a
elif i > n - r:
v = max(0.0, (n - i) / r)
else:
v = 1.0
out.append(v)
return out
@staticmethod
def _wave(shape, phase, duty=0.5):
p = phase % 1.0
if shape == "square":
return 1.0 if p < duty else -1.0
if shape == "saw":
return 2.0 * p - 1.0
if shape == "tri":
return 4.0 * abs(p - 0.5) - 1.0
if shape == "noise":
return 1.0
return math.sin(2 * math.pi * p)
def tone(self, f, dur, vol=0.5, shape="square", slide=0.0, duty=0.5):
"""slide: freq multiplier change over duration (e.g. 0.7 sweeps down)."""
n = max(1, int(SR * dur))
env = self._env(n)
out = array.array("h")
phase = 0.0
for i in range(n):
t = i / n
ff = f * (1.0 + (slide - 1.0) * t)
phase += ff / SR
v = self._wave(shape, phase, duty) * env[i] * vol
out.append(int(max(-1.0, min(1.0, v)) * 32000))
return out
def noise(self, dur, vol=0.35, lp=0.3, slide=1.0):
"""Filtered-ish noise burst. lp = lowpass smoothing (0..1)."""
n = max(1, int(SR * dur))
env = self._env(n)
out = array.array("h")
acc = 0.0
for i in range(n):
acc += (self._rng.uniform(-1, 1) - acc) * lp
v = acc * env[i] * vol
out.append(int(max(-1.0, min(1.0, v)) * 32000))
return out
@staticmethod
def _mix(*arrays):
ln = max(len(a) for a in arrays)
acc = [0] * ln
for a in arrays:
for i, v in enumerate(a):
acc[i] += v
peak = max(1, max(abs(v) for v in acc))
scale = 32000.0 / peak if peak > 32000 else 1.0
return array.array("h", (int(v * scale) for v in acc))
def _snd(self, name, samples):
try:
self.sounds[name] = pygame.mixer.Sound(buffer=samples.tobytes())
except Exception:
self.sounds[name] = None
# ---- all effects -----------------------------------------------------
def _build_all(self):
self._snd("flipL", self.tone(180, 0.07, 0.5, "square", slide=0.7))
self._snd("flipR", self.tone(210, 0.07, 0.5, "square", slide=0.7))
self._snd("wall", self.tone(90, 0.045, 0.18, "square", duty=0.3))
self._snd("launch", self._mix(
self.noise(0.28, 0.5, lp=0.25),
self.tone(160, 0.3, 0.35, "saw", slide=1.9)))
self._snd("drain", self.tone(500, 0.55, 0.5, "saw", slide=0.18))
self._snd("tick", self.tone(2400, 0.015, 0.12, "square"))
# bumpers: pitched by combo (choose at play time via 'bump' variants)
for i in range(12):
f = 300 * (1.08 ** i)
self._snd("bump%d" % i, self.tone(f, 0.16, 0.5, "square", slide=0.85))
self._snd("sling", self._mix(
self.tone(700, 0.05, 0.4, "square", slide=0.5),
self.tone(350, 0.09, 0.4, "saw", slide=0.8)))
self._snd("star", self.tone(880, 0.12, 0.42, "tri", slide=1.5))
self._snd("starLit", self._mix(
self.tone(660, 0.09, 0.4, "tri"), self.tone(990, 0.16, 0.4, "tri")))
# arpeggio helpers
def arp(notes, step=0.11, vol=0.42):
parts = []
for i, (nm, oc) in enumerate(notes):
parts.append(self.tone(freq(nm, oc), step * 1.6, vol, "square", slide=1.1))
if i < len(notes) - 1:
parts.append(array.array("h", [0]) * int(SR * step * 0.55))
return self._mix(*parts) if parts else array.array("h", [0])
self._snd("fever", arp([("C", 5), ("E", 5), ("G", 5), ("C", 6), ("E", 6), ("G", 6)], 0.09, 0.45))
self._snd("extra", arp([("C", 4), ("C", 5), ("G", 5), ("E", 5), ("C", 6)], 0.13, 0.5))
self._snd("milestone", arp([("C", 5), ("E", 5), ("A", 5), ("E", 6)], 0.1, 0.45))
self._snd("combo", arp([("A", 4), ("C", 5), ("E", 5)], 0.06, 0.35))
def seq(note_spec, step=0.13, vol=0.4, shape="square"):
parts = []
for nm, oc in note_spec:
parts.append(self.tone(freq(nm, oc), step * 1.35, vol, shape))
return self._mix(*parts)
self._snd("gameover", seq(
[("E", 5), ("C", 5), ("A", 4), ("F", 4), ("C", 4)], 0.24, 0.4, "saw"))
self._snd("start", seq(
[("C", 4), ("E", 4), ("G", 4), ("C", 5), ("E", 5), ("G", 5)], 0.09, 0.45))
self._snd("hi", seq([("C", 6), ("E", 6), ("G", 6), ("C", 7)], 0.08, 0.3, "tri"))
self._build_music()
def _build_music(self):
"""Two short generative loops: a dreamy menu loop and a bouncy play loop."""
# menu loop: soft arpeggio C - Am - F - G
chords = [["C", "E", "G"], ["A", "C", "E"], ["F", "A", "C"], ["G", "B", "D"]]
bass = [("C", 2), ("A", 2), ("F", 2), ("G", 2)]
beat = 0.26
parts = []
for ci in range(4):
n = int(SR * beat * 2)
parts.append(self.tone(freq(bass[ci][0], bass[ci][1]), beat * 2.1, 0.22, "tri"))
# melodic ripple over the chord
for k in range(4):
note = chords[ci][k % 3]
f0 = freq(note, 4 if k < 2 else 5)
parts.append(self.tone(f0, beat * 0.8, 0.14, "square", duty=0.4))
parts.append(array.array("h", [0]) * int(SR * beat * 0.2))
self._menu_music = self._mix(*parts)
# play loop: bouncy 8th-note line C-C-G-G-A-A-G
line = [("C", 4), ("C", 4), ("G", 4), ("G", 4), ("A", 4), ("A", 4), ("G", 4), ("E", 4)]
bassp = [("C", 2), ("C", 2), ("G", 2), ("G", 2), ("A", 2), ("A", 2), ("E", 2), ("E", 2)]
step = 0.16
parts = []
for i in range(16):
nm, oc = line[i % 8]
bn, bo = bassp[i % 8]
parts.append(self.tone(freq(bn, bo), step * 1.8, 0.28, "tri"))
parts.append(self.tone(freq(nm, oc), step * 1.3, 0.18, "square", duty=0.4))
if i % 2 == 1:
parts.append(self.noise(0.03, 0.10, lp=0.15))
else:
parts.append(array.array("h", [0]) * int(SR * step * 0.3))
self._play_music = self._mix(*parts)
self._menu = pygame.mixer.Sound(buffer=self._menu_music.tobytes())
self._play = pygame.mixer.Sound(buffer=self._play_music.tobytes())
self._menu.set_volume(0.5)
self._play.set_volume(0.45)
# ---- playback --------------------------------------------------------
def play(self, name, vol=1.0, pitch_var=0):
if not self.ok:
return
key = name
if pitch_var:
key = "%s%d" % (name, pitch_var)
s = self.sounds.get(key)
if s is None:
s = self.sounds.get(name)
if s is None:
return
ch = self.sfx_channels[self._ch_i]
self._ch_i = (self._ch_i + 1) % len(self.sfx_channels)
ch.set_volume(min(1.0, vol))
ch.play(s)
def start_menu_music(self):
if not self.ok:
return
self.music_channel.set_volume(0.8)
self.music_channel.play(self._menu, loops=-1)
def start_play_music(self):
if not self.ok:
return
self.music_channel.set_volume(0.8)
self.music_channel.play(self._play, loops=-1)
def stop_music(self):
if self.ok:
self.music_channel.stop()
def set_muted(self, m):
self.muted = m
if self.ok:
self.music_channel.set_volume(0.0 if m else 0.8)
def set_sfx_muted(self, m):
self.sfx_muted = m
if self.ok:
for ch in self.sfx_channels:
ch.set_volume(0.0 if m else 1.0)