Portrait 360x480 table with regular-sized flippers
Rotate the playfield to portrait like a real pinball machine: - canvas 480x360 landscape -> 360x480 portrait; playfield 308px wide, ~390px tall, with HUD, title/game-over screens and HUD re-laid out for the narrow format (score/best top row, balls/stars/fever below) - flippers reduced to a regular ~quarter-width size (82px) pivoting at the lower corners, tips meeting over a wide centre drain - table elements re-placed for the taller field: 3 star lanes up top, candy bumpers mid-field, slingshots and floor-level outlane guard posts at the bottom; data-checked with the autoplay bot (~350 bumper hits / 30k frames, centre+side drains, no stuck balls) - probes & autoplay policy updated for the portrait geometry; all 13 pixel-probes pass
@@ -6,8 +6,8 @@ audio asset files**: every sprite, texture, particle, chiptune and sound
|
|||||||
effect is generated by code at runtime.
|
effect is generated by code at runtime.
|
||||||
|
|
||||||
Not web-based: it opens its own native window and renders on a low-res
|
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
|
**portrait** `360×480` virtual canvas — like a proper pinball machine —
|
||||||
authentic retro look.
|
upscaled with crisp nearest-neighbour scaling for an authentic retro look.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ class AutoPlayer:
|
|||||||
|
|
||||||
# flipper policy: catch anything heading down the middle/bottom
|
# flipper policy: catch anything heading down the middle/bottom
|
||||||
b = t.ball
|
b = t.ball
|
||||||
if t.ball_live and b.vy > 0 and b.y > 150:
|
if t.ball_live and b.vy > 0 and b.y > 200:
|
||||||
g.keys["L"] = b.x < 258
|
g.keys["L"] = b.x < 196
|
||||||
g.keys["R"] = b.x > 222
|
g.keys["R"] = b.x > 164
|
||||||
else:
|
else:
|
||||||
if t.ball_live and self.rng.random() < 0.04:
|
if t.ball_live and self.rng.random() < 0.04:
|
||||||
g.keys["L"] = self.rng.random() < 0.5
|
g.keys["L"] = self.rng.random() < 0.5
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import pygame
|
|||||||
from physics import (CV_W, CV_H, PLAY_L, PLAY_R, PLAY_T, PLAY_B, BALL_R,
|
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,
|
CENTER_HOLE, OUTLANE_L, OUTLANE_R, DRAIN_Y, LAUNCH_DIR,
|
||||||
LAUNCH_MIN, LAUNCH_MAX, Table, FLIP_PIVOT_L, FLIP_PIVOT_R,
|
LAUNCH_MIN, LAUNCH_MAX, Table, FLIP_PIVOT_L, FLIP_PIVOT_R,
|
||||||
FLIP_RADIUS, FLIP_LEN, flipper_tip)
|
FLIP_RADIUS, FLIP_LEN, flipper_tip, BUMPERS)
|
||||||
import font as pix
|
import font as pix
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -362,9 +362,9 @@ class Game:
|
|||||||
t = y / CV_H
|
t = y / CV_H
|
||||||
col = tuple(int(a + (b - a) * t) for a, b in zip(C["bg_top"], C["bg_bot"]))
|
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))
|
pygame.draw.line(bg, col, (0, y), (CV_W, y))
|
||||||
# twinkle stars & planets for the background
|
# twinkle stars & planets around the table, drawn over the HUD & margins
|
||||||
for sx, sy, tw, ph in self.stars_bg:
|
for sx, sy, tw, ph in self.stars_bg:
|
||||||
if 0 < sx < 20 or 460 < sx < CV_W or sy < 46:
|
if sx < PLAY_L - 6 or sx > PLAY_R + 6 or sy < 50:
|
||||||
pygame.draw.rect(bg, (230, 225, 255), (sx, sy, 1, 1))
|
pygame.draw.rect(bg, (230, 225, 255), (sx, sy, 1, 1))
|
||||||
self.bg = bg
|
self.bg = bg
|
||||||
|
|
||||||
@@ -382,12 +382,11 @@ class Game:
|
|||||||
PLAY_R - PLAY_L - 8, PLAY_B - PLAY_T - 8), border_radius=5)
|
PLAY_R - PLAY_L - 8, PLAY_B - PLAY_T - 8), border_radius=5)
|
||||||
# candy dot sparkle inside the field
|
# candy dot sparkle inside the field
|
||||||
r = self.rng
|
r = self.rng
|
||||||
for _ in range(46):
|
for _ in range(60):
|
||||||
dx = r.randrange(int(PLAY_L) + 12, int(PLAY_R) - 12)
|
dx = r.randrange(int(PLAY_L) + 12, int(PLAY_R) - 12)
|
||||||
dy = r.randrange(int(PLAY_T) + 14, int(PLAY_B) - 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
|
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},
|
for b in BUMPERS):
|
||||||
{"pos": (240, 240), "r": 23})):
|
|
||||||
continue
|
continue
|
||||||
col = PASTELS[r.randrange(len(PASTELS))]
|
col = PASTELS[r.randrange(len(PASTELS))]
|
||||||
field.fill(tuple(c // 3 for c in col), (dx, dy, 2, 2))
|
field.fill(tuple(c // 3 for c in col), (dx, dy, 2, 2))
|
||||||
@@ -412,8 +411,11 @@ class Game:
|
|||||||
hud.fill(C["hud"])
|
hud.fill(C["hud"])
|
||||||
pygame.draw.line(hud, C["rail_dark"], (0, 50), (CV_W, 50), 2)
|
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.line(hud, C["hud_line"], (0, 47), (CV_W, 47), 1)
|
||||||
pygame.draw.rect(hud, (255, 255, 255), (0, 0, 8, 52))
|
# static labels
|
||||||
pygame.draw.rect(hud, (255, 255, 255), (CV_W - 8, 0, 8, 52))
|
pix.draw_text(hud, 10, 4, "SCORE", C["dim"], 1)
|
||||||
|
pix.draw_text(hud, CV_W - 10, 4, "BEST", C["dim"], 1, align="right")
|
||||||
|
pix.draw_text(hud, 10, 33, "BALLS", C["dim"], 1)
|
||||||
|
pix.draw_text(hud, 120, 33, "STARS", C["dim"], 1)
|
||||||
self.hud = hud
|
self.hud = hud
|
||||||
|
|
||||||
# ------------------------------------------------------------------ game
|
# ------------------------------------------------------------------ game
|
||||||
@@ -471,8 +473,8 @@ class Game:
|
|||||||
self.sfx.play("fever")
|
self.sfx.play("fever")
|
||||||
self.cat_say("CANDY FEVER!", 90, "star")
|
self.cat_say("CANDY FEVER!", 90, "star")
|
||||||
self.shake = 3
|
self.shake = 3
|
||||||
self.add_msg("CANDY FEVER!", CV_W // 2, 150, C["gold"], scale=2)
|
self.add_msg("CANDY FEVER!", CV_W // 2, 210, C["gold"], scale=2)
|
||||||
self.add_msg("DOUBLE SCORE x2", CV_W // 2, 166, C["pink"], scale=1)
|
self.add_msg("DOUBLE SCORE x2", CV_W // 2, 226, C["pink"], scale=1)
|
||||||
for i in range(30):
|
for i in range(30):
|
||||||
self.particles.append(dict(
|
self.particles.append(dict(
|
||||||
x=self.rng.uniform(PLAY_L + 10, PLAY_R - 10), y=self.rng.uniform(60, 120),
|
x=self.rng.uniform(PLAY_L + 10, PLAY_R - 10), y=self.rng.uniform(60, 120),
|
||||||
@@ -645,7 +647,7 @@ class Game:
|
|||||||
self.lives += 1
|
self.lives += 1
|
||||||
self.sfx.play("extra")
|
self.sfx.play("extra")
|
||||||
self.cat_say("EXTRA BALL!", 90, "star")
|
self.cat_say("EXTRA BALL!", 90, "star")
|
||||||
self.add_msg("EXTRA BALL!", CV_W // 2, 120, C["pink"], scale=2)
|
self.add_msg("EXTRA BALL!", CV_W // 2, 200, C["pink"], scale=2)
|
||||||
self.next_extra += 30000
|
self.next_extra += 30000
|
||||||
|
|
||||||
def add_msg(self, text, x, y, color, scale=1):
|
def add_msg(self, text, x, y, color, scale=1):
|
||||||
@@ -741,7 +743,7 @@ class Game:
|
|||||||
# combo indicator
|
# combo indicator
|
||||||
if self.combo >= 2 and self.pending == "live":
|
if self.combo >= 2 and self.pending == "live":
|
||||||
col = (255, 140, 90) if self.combo >= 5 else C["gold"]
|
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")
|
pix.draw_text(c, CV_W // 2, 68, "COMBO x%d!" % self.combo, col, 1, align="center")
|
||||||
|
|
||||||
# particles
|
# particles
|
||||||
for p in self.particles:
|
for p in self.particles:
|
||||||
@@ -772,21 +774,21 @@ class Game:
|
|||||||
# pending banners
|
# pending banners
|
||||||
if self.pending == "ball_banner" and self.pending_t > 40:
|
if self.pending == "ball_banner" and self.pending_t > 40:
|
||||||
self._dim_center(c)
|
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, 255, "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")
|
pix.draw_text(c, CV_W // 2, 300, "GET READY...", C["pink"], 1, align="center")
|
||||||
if self.pending == "drain_pause":
|
if self.pending == "drain_pause":
|
||||||
self._dim_center(c)
|
self._dim_center(c)
|
||||||
pix.draw_text(c, CV_W // 2, 150, "LOST THE BALL!", C["pink"], 2, align="center")
|
pix.draw_text(c, CV_W // 2, 285, "LOST THE BALL!", C["pink"], 2, align="center")
|
||||||
if self.pending == "serve" and not t.ball_live:
|
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")
|
pix.draw_text(c, CV_W // 2, 372, "HOLD SPACE / UP TO CHARGE", C["dim"], 1, align="center")
|
||||||
|
|
||||||
# HUD
|
# HUD
|
||||||
self._render_hud(c)
|
self._render_hud(c)
|
||||||
|
|
||||||
def _dim_center(self, c):
|
def _dim_center(self, c):
|
||||||
self.overlay.fill((0, 0, 0, 0))
|
self.overlay.fill((0, 0, 0, 0))
|
||||||
pygame.draw.rect(self.overlay, (12, 8, 40, 160),
|
pygame.draw.rect(self.overlay, (12, 8, 40, 170),
|
||||||
(CV_W // 2 - 100, 110, 200, 90), border_radius=8)
|
(CV_W // 2 - 100, 230, 200, 120), border_radius=10)
|
||||||
c.blit(self.overlay, (0, 0))
|
c.blit(self.overlay, (0, 0))
|
||||||
|
|
||||||
def _draw_bubble(self, c):
|
def _draw_bubble(self, c):
|
||||||
@@ -801,29 +803,28 @@ class Game:
|
|||||||
|
|
||||||
def _render_hud(self, c):
|
def _render_hud(self, c):
|
||||||
c.blit(self.hud, (0, 0))
|
c.blit(self.hud, (0, 0))
|
||||||
pix.draw_text(c, 14, 4, "SCORE", C["dim"], 1)
|
pix.draw_text(c, 10, 13, "{:,}".format(self.score), C["white"], 2)
|
||||||
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)
|
best = max(self.hi, self.score)
|
||||||
pix.draw_text(c, 170, 13, "{:,}".format(best), C["gold"], 2)
|
pix.draw_text(c, CV_W - 10, 13, "{:,}".format(best), C["gold"], 2, align="right")
|
||||||
# lives hearts
|
# lives hearts (cap the icon row, spill into a +N text)
|
||||||
for i in range(self.lives):
|
hearts = self.lives
|
||||||
draw_heart(c, 336 + i * 14, 14, 2, C["heart"])
|
for i in range(min(hearts, 5)):
|
||||||
pix.draw_text(c, 336, 30, "LIVES", C["dim"], 1)
|
draw_heart(c, 10 + i * 14, 42, 2, C["heart"])
|
||||||
# stars progress
|
if hearts > 5:
|
||||||
pix.draw_text(c, 14, 32, "STARS", C["dim"], 1)
|
pix.draw_text(c, 10 + 5 * 14 + 2, 43, "+%d" % (hearts - 5), C["pink"], 1)
|
||||||
|
# star progress
|
||||||
for i, st in enumerate(self.table.stars):
|
for i, st in enumerate(self.table.stars):
|
||||||
x = 52 + i * 18
|
x = 120 + i * 16
|
||||||
draw_star(c, x, 37, 5 if st["lit"] else 4,
|
draw_star(c, x + 4, 42, 5 if st["lit"] else 4,
|
||||||
C["gold"] if st["lit"] else (90, 78, 150))
|
C["gold"] if st["lit"] else (90, 78, 150))
|
||||||
# fever indicator + bar
|
# fever indicator + bar
|
||||||
if self.fever > 0:
|
if self.fever > 0:
|
||||||
frac = max(0.0, self.fever / (60 * 20))
|
frac = max(0.0, self.fever / (60 * 20))
|
||||||
pygame.draw.rect(c, (60, 46, 110), (260, 30, 70, 6))
|
pix.draw_text(c, 205, 33, "FEVER", C["gold"], 1)
|
||||||
pygame.draw.rect(c, (255, 150, 200), (260, 30, int(70 * frac), 6))
|
pygame.draw.rect(c, (60, 46, 110), (205, 44, 90, 5))
|
||||||
pix.draw_text(c, 260, 38, "FEVER x2", C["gold"], 1)
|
pygame.draw.rect(c, (255, 150, 200), (205, 44, int(90 * frac), 5))
|
||||||
if self.paused:
|
if self.paused:
|
||||||
pix.draw_text(c, CV_W - 40, 40, "PAUSED", C["dim"], 1, align="right")
|
pix.draw_text(c, CV_W - 10, 40, "PAUSED", C["dim"], 1, align="right")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ title
|
# ------------------------------------------------------------------ title
|
||||||
def _title_update(self):
|
def _title_update(self):
|
||||||
@@ -837,12 +838,12 @@ class Game:
|
|||||||
|
|
||||||
def _render_title(self, c):
|
def _render_title(self, c):
|
||||||
c.blit(self.bg, (0, 0))
|
c.blit(self.bg, (0, 0))
|
||||||
# ringed planet
|
# ringed planet top-left, little moon bottom-right
|
||||||
pygame.draw.circle(c, (150, 110, 220), (90, 70), 24)
|
pygame.draw.circle(c, (150, 110, 220), (62, 80), 22)
|
||||||
pygame.draw.circle(c, (190, 160, 245), (90, 70), 24, 2)
|
pygame.draw.circle(c, (190, 160, 245), (62, 80), 22, 2)
|
||||||
pygame.draw.ellipse(c, (255, 200, 220), (58, 64, 64, 18), 2)
|
pygame.draw.ellipse(c, (255, 200, 220), (34, 74, 56, 16), 2)
|
||||||
pygame.draw.circle(c, (110, 90, 200), (300, 320), 10)
|
pygame.draw.circle(c, (110, 90, 200), (310, 380), 12)
|
||||||
pygame.draw.circle(c, (110, 90, 200), (300, 320), 10, 1)
|
pygame.draw.circle(c, (150, 120, 230), (310, 380), 12, 1)
|
||||||
# twinkling stars
|
# twinkling stars
|
||||||
for sx, sy, tw, ph in self.stars_bg:
|
for sx, sy, tw, ph in self.stars_bg:
|
||||||
if (self.frame + ph) % 70 < 6:
|
if (self.frame + ph) % 70 < 6:
|
||||||
@@ -852,57 +853,55 @@ class Game:
|
|||||||
c.fill(p["color"], (int(p["x"]), int(p["y"]), 2, 2))
|
c.fill(p["color"], (int(p["x"]), int(p["y"]), 2, 2))
|
||||||
|
|
||||||
# logo
|
# logo
|
||||||
words = [("COSMIC", 3, 36, C["pink"]), ("CANDY", 3, 62, C["gold"]),
|
y = 44
|
||||||
("PINBALL", 2, 108, C["mint"])]
|
for txt, sc, col in (("COSMIC", 3, C["pink"]), ("CANDY", 3, C["gold"]),
|
||||||
y = 40
|
("PINBALL", 2, C["mint"])):
|
||||||
for txt, sc, _, col in words:
|
|
||||||
tw = pix.text_width(txt, sc)
|
tw = pix.text_width(txt, sc)
|
||||||
x = CV_W // 2 - tw // 2
|
x = CV_W // 2 - tw // 2
|
||||||
for i, ch in enumerate(txt):
|
for i, ch in enumerate(txt):
|
||||||
col2 = col
|
pix.draw_text(c, x, y, ch, col, sc, shadow=(20, 12, 50))
|
||||||
pix.draw_text(c, x, y, ch, col2, sc, shadow=(20, 12, 50))
|
|
||||||
x += (5 + 1) * sc
|
x += (5 + 1) * sc
|
||||||
y += (5 + 2) * sc
|
y += (5 + 2) * sc
|
||||||
# little shooting star occasionally
|
# little shooting star occasionally
|
||||||
if self.frame % 90 < 24:
|
if self.frame % 90 < 24:
|
||||||
t0 = self.frame % 90
|
t0 = self.frame % 90
|
||||||
sx = 330 + t0 * 3
|
sx = 250 + t0 * 2
|
||||||
sy = 40 + t0 * 1.5
|
sy = 130 + t0
|
||||||
pygame.draw.line(c, (255, 255, 255), (int(sx), int(sy)), (int(sx - 8), int(sy - 4)), 1)
|
pygame.draw.line(c, (255, 255, 255), (int(sx), int(sy)), (int(sx - 8), int(sy - 4)), 1)
|
||||||
|
|
||||||
# cat
|
# cat
|
||||||
draw_body(c, CV_W // 2 - 26, 180, 2)
|
draw_body(c, CV_W // 2 - 24, 208, 2)
|
||||||
draw_cat(c, CV_W // 2 - 26, 168, 2, "happy", self.frame)
|
draw_cat(c, CV_W // 2 - 24, 196, 2, "happy", self.frame)
|
||||||
# instructions panel
|
# instructions panel
|
||||||
pygame.draw.rect(c, (22, 15, 56), (70, 240, CV_W - 140, 74), border_radius=8)
|
pygame.draw.rect(c, (22, 15, 56), (30, 262, CV_W - 60, 84), border_radius=8)
|
||||||
pygame.draw.rect(c, C["rail"], (70, 240, CV_W - 140, 74), 2, border_radius=8)
|
pygame.draw.rect(c, C["rail"], (30, 262, CV_W - 60, 84), 2, border_radius=8)
|
||||||
lines = [
|
lines = [
|
||||||
("LEFT / RIGHT ARROWS = FLIPPERS", C["flipper"]),
|
("LEFT / RIGHT ARROWS = FLIPPERS", C["flipper"]),
|
||||||
("HOLD SPACE OR UP = CHARGE CANNON", C["gold"]),
|
("HOLD SPACE OR UP = CHARGE CANNON", C["gold"]),
|
||||||
("M MUTE R RESTART P PAUSE", C["dim"]),
|
("M MUTE R RESTART P PAUSE", C["dim"]),
|
||||||
]
|
]
|
||||||
for i, (txt, col) in enumerate(lines):
|
for i, (txt, col) in enumerate(lines):
|
||||||
pix.draw_text(c, CV_W // 2, 250 + i * 20, txt, col, 1, align="center")
|
pix.draw_text(c, CV_W // 2, 272 + i * 20, txt, col, 1, align="center")
|
||||||
|
|
||||||
if (self.frame // 30) % 2 == 0:
|
if (self.frame // 30) % 2 == 0:
|
||||||
pix.draw_text(c, CV_W // 2, 325, "PRESS ENTER TO START", C["white"], 2, align="center")
|
pix.draw_text(c, CV_W // 2, 378, "PRESS ENTER TO START", C["white"], 2, align="center")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ game over
|
# ------------------------------------------------------------------ game over
|
||||||
def _render_over(self, c):
|
def _render_over(self, c):
|
||||||
self._render_play(c)
|
self._render_play(c)
|
||||||
self.overlay.fill((14, 9, 40, 215))
|
self.overlay.fill((14, 9, 40, 215))
|
||||||
c.blit(self.overlay, (0, 0))
|
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, 70, "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, 128, "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, 140, "{:,}".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, 195, "BEST", C["dim"], 1, align="center")
|
||||||
pix.draw_text(c, CV_W // 2, 170, "{:,}".format(self.hi), C["gold"], 2, align="center")
|
pix.draw_text(c, CV_W // 2, 207, "{:,}".format(self.hi), C["gold"], 2, align="center")
|
||||||
if self.new_hi and (self.frame // 20) % 2 == 0:
|
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")
|
pix.draw_text(c, CV_W // 2, 250, "NEW HI-SCORE!", C["gold"], 1, align="center")
|
||||||
draw_body(c, CV_W // 2 - 20, 240, 2)
|
draw_body(c, CV_W // 2 - 20, 310, 2)
|
||||||
draw_cat(c, CV_W // 2 - 20, 226, 2, "sad", self.frame)
|
draw_cat(c, CV_W // 2 - 20, 296, 2, "sad", self.frame)
|
||||||
if (self.frame // 30) % 2 == 0:
|
if (self.frame // 30) % 2 == 0:
|
||||||
pix.draw_text(c, CV_W // 2, 320, "PRESS ENTER TO PLAY AGAIN", C["white"], 1, align="center")
|
pix.draw_text(c, CV_W // 2, 400, "PRESS ENTER TO PLAY AGAIN", C["white"], 1, align="center")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ input
|
# ------------------------------------------------------------------ input
|
||||||
def event(self, ev):
|
def event(self, ev):
|
||||||
|
|||||||
@@ -1,60 +1,65 @@
|
|||||||
"""Pinball physics: pure logic, no rendering. Testable headless.
|
"""Pinball physics: pure logic, no rendering. Testable headless.
|
||||||
|
|
||||||
Coordinate system: y grows downward. 480x360 virtual canvas.
|
Coordinate system: y grows downward. 360x480 portrait virtual canvas.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Playfield geometry
|
# Playfield geometry (portrait - like a proper pinball machine)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
CV_W, CV_H = 480, 360
|
CV_W, CV_H = 360, 480
|
||||||
PLAY_L, PLAY_R, PLAY_T, PLAY_B = 20.0, 460.0, 52.0, 346.0
|
PLAY_L, PLAY_R, PLAY_T, PLAY_B = 26.0, 334.0, 60.0, 448.0
|
||||||
|
|
||||||
GRAVITY = 0.24 # px/frame^2
|
GRAVITY = 0.24 # px/frame^2
|
||||||
MAX_SPEED = 13.0 # speed cap (prevents tunneling; ~300px of rise)
|
MAX_SPEED = 13.0 # speed cap (prevents tunneling; ~300px of rise)
|
||||||
RESTITUTION = 0.42
|
RESTITUTION = 0.42
|
||||||
BALL_R = 7.0
|
BALL_R = 7.0
|
||||||
|
|
||||||
FLIP_LEN = 154.0
|
# regular-sized flippers (about a quarter of the playfield width each),
|
||||||
FLIP_REST = math.radians(6.0) # tips slightly lowered toward center drain
|
# pivoting near the lower corners with their tips meeting at the centre
|
||||||
FLIP_UP = -math.radians(52.0) # fired position
|
FLIP_LEN = 82.0
|
||||||
FLIP_PIVOT_L = (62.0, 338.0)
|
FLIP_REST = math.radians(5.0) # tips slightly lowered toward the drain
|
||||||
FLIP_PIVOT_R = (418.0, 338.0)
|
FLIP_UP = -math.radians(48.0) # fired position
|
||||||
FLIP_RADIUS = 5.5
|
FLIP_PIVOT_L = (78.0, 430.0)
|
||||||
|
FLIP_PIVOT_R = (282.0, 430.0)
|
||||||
|
FLIP_RADIUS = 5.0
|
||||||
FLIP_SNAP_UP = 0.36 # rad/frame while firing
|
FLIP_SNAP_UP = 0.36 # rad/frame while firing
|
||||||
FLIP_SNAP_DOWN = 0.05 # gentle return speed
|
FLIP_SNAP_DOWN = 0.05 # gentle return speed
|
||||||
FLIP_BOUNCE = 0.12
|
FLIP_BOUNCE = 0.12
|
||||||
|
|
||||||
# elements
|
# elements
|
||||||
BUMPERS = [
|
BUMPERS = [
|
||||||
dict(pos=(150.0, 180.0), r=23.0, color="pink"),
|
dict(pos=(102.0, 205.0), r=22.0, color="pink"),
|
||||||
dict(pos=(330.0, 180.0), r=23.0, color="mint"),
|
dict(pos=(258.0, 205.0), r=22.0, color="mint"),
|
||||||
dict(pos=(240.0, 240.0), r=23.0, color="lemon"),
|
dict(pos=(180.0, 300.0), r=22.0, color="lemon"),
|
||||||
]
|
]
|
||||||
SLING_KICK_L = (0.60, -0.80)
|
SLING_KICK_L = (0.60, -0.80)
|
||||||
SLING_KICK_R = (-0.60, -0.80)
|
SLING_KICK_R = (-0.60, -0.80)
|
||||||
SLINGS = [
|
SLINGS = [
|
||||||
# upper slingshots just below the two outer bumpers - kick toward centre
|
# slingshots in the lower side areas - kick toward the centre
|
||||||
dict(pos=(150.0, 282.0), r=11.0, kick=SLING_KICK_L),
|
dict(pos=(92.0, 336.0), r=11.0, kick=SLING_KICK_L),
|
||||||
dict(pos=(330.0, 282.0), r=11.0, kick=SLING_KICK_R),
|
dict(pos=(268.0, 336.0), r=11.0, kick=SLING_KICK_R),
|
||||||
# guard posts at the outlane mouths: wall-huggers get bounced back into
|
# guard posts above the outlane mouths: wall-huggers bounce back into
|
||||||
# play instead of silently draining - the centre gap is the real risk
|
# play - the centre gap is the real risk
|
||||||
dict(pos=(44.0, 314.0), r=12.0, kick=(0.80, -0.60)),
|
dict(pos=(52.0, 408.0), r=13.0, kick=(0.80, -0.60)),
|
||||||
dict(pos=(436.0, 314.0), r=12.0, kick=(-0.80, -0.60)),
|
dict(pos=(308.0, 408.0), r=13.0, kick=(-0.80, -0.60)),
|
||||||
|
# little floor-level posts that seal the outlane mouths
|
||||||
|
dict(pos=(46.0, 432.0), r=9.0, kick=(0.72, -0.69)),
|
||||||
|
dict(pos=(314.0, 432.0), r=9.0, kick=(-0.72, -0.69)),
|
||||||
]
|
]
|
||||||
STARS = [(168.0, 92.0), (240.0, 92.0), (312.0, 92.0)]
|
STARS = [(120.0, 96.0), (180.0, 96.0), (240.0, 96.0)]
|
||||||
|
|
||||||
CANNON = (440.0, 152.0)
|
CANNON = (300.0, 170.0)
|
||||||
CANNON_R = 17.0
|
CANNON_R = 17.0
|
||||||
LAUNCH_DIR = (-0.94, -0.34)
|
LAUNCH_DIR = (-0.93, -0.36)
|
||||||
LAUNCH_MIN, LAUNCH_MAX = 7.0, 13.5
|
LAUNCH_MIN, LAUNCH_MAX = 7.0, 13.5
|
||||||
|
|
||||||
DRAIN_Y = 362.0
|
DRAIN_Y = 458.0
|
||||||
CENTER_HOLE = (200.0, 280.0)
|
CENTER_HOLE = (150.0, 210.0)
|
||||||
OUTLANE_L = 46.0
|
OUTLANE_L = 44.0
|
||||||
OUTLANE_R = 434.0
|
OUTLANE_R = 316.0
|
||||||
|
|
||||||
|
|
||||||
def clamp(v, lo, hi):
|
def clamp(v, lo, hi):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pixel-probe the rendered frames for layout sanity (since art can't be
|
"""Pixel-probe the rendered frames for layout sanity (since art can't be
|
||||||
viewed directly in this session)."""
|
viewed directly in this session). Portrait 360x480."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -35,43 +35,45 @@ def main():
|
|||||||
st["lit"] = True
|
st["lit"] = True
|
||||||
g.table.ball_live = True
|
g.table.ball_live = True
|
||||||
g.pending = "live"
|
g.pending = "live"
|
||||||
g.table.ball.x, g.table.ball.y = 240, 300
|
g.table.ball.x, g.table.ball.y = 180, 330
|
||||||
g.table.ball.vx, g.table.ball.vy = 0, 3
|
g.table.ball.vx, g.table.ball.vy = 0, 3
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
g.update()
|
g.update()
|
||||||
g.render()
|
g.render()
|
||||||
s = g.canvas
|
s = g.canvas
|
||||||
checks = []
|
checks = []
|
||||||
# HUD white score digits around (14..100, 13..26)
|
# HUD white score digits around (10..120, 13..26)
|
||||||
checks.append(("HUD score digits", count_px(s, (14, 13, 110, 15),
|
checks.append(("HUD score digits", count_px(s, (10, 13, 110, 15),
|
||||||
lambda p: near(p, (255, 255, 255), 40)) > 25))
|
lambda p: near(p, (255, 255, 255), 40)) > 20))
|
||||||
# HUD gold BEST digits near (170..300, 13..26)
|
# HUD gold BEST digits near right (250..350, 13..26)
|
||||||
checks.append(("HUD gold best", count_px(s, (170, 13, 150, 15),
|
checks.append(("HUD gold best", count_px(s, (230, 13, 120, 15),
|
||||||
|
lambda p: near(p, C["gold"], 55)) > 10))
|
||||||
|
# hearts red at (8..90, 40..50)
|
||||||
|
checks.append(("hearts", count_px(s, (8, 40, 90, 12),
|
||||||
|
lambda p: near(p, C["heart"], 50)) > 15))
|
||||||
|
# three gold stars in HUD at (118..175, 36..48)
|
||||||
|
checks.append(("HUD stars lit", count_px(s, (116, 36, 60, 14),
|
||||||
lambda p: near(p, C["gold"], 55)) > 10))
|
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)
|
# playfield interior present (not black)
|
||||||
checks.append(("field fill", near(s.get_at((240, 100)), C["field"], 70)))
|
checks.append(("field fill", near(s.get_at((180, 120)), C["field"], 70)))
|
||||||
# pink rail present on left edge inside play area
|
# left rail pink present inside play area
|
||||||
rail_ok = count_px(s, (14, 70, 12, 20), lambda p: near(p, C["rail"], 60)) > 10
|
rail_ok = count_px(s, (23, 100, 8, 20), lambda p: near(p, C["rail"], 60)) > 5
|
||||||
checks.append(("left rail pink", rail_ok))
|
checks.append(("left rail pink", rail_ok))
|
||||||
# bumper pink at (150,180)
|
# bumper bodies (sample the rim, away from the face)
|
||||||
checks.append(("bumper pink", near(s.get_at((150, 178)), C["pink"], 55)))
|
checks.append(("bumper pink", near(s.get_at((82, 205)), C["pink"], 55)))
|
||||||
# bumper lemon at (240,240)
|
checks.append(("bumper mint", count_px(s, (230, 190, 60, 40),
|
||||||
checks.append(("bumper lemon", near(s.get_at((240, 238)), C["lemon"], 55)))
|
lambda p: near(p, C["mint"], 55) or
|
||||||
# pit dark center at (240, 352)
|
near(p, C["mint_l"], 55)) > 80))
|
||||||
checks.append(("pit center", near(s.get_at((240, 353)), C["pit"], 30)))
|
checks.append(("bumper lemon", near(s.get_at((160, 300)), C["lemon"], 55)))
|
||||||
# flipper blue present around (240, 320)
|
# pit dark center at (180, 455)
|
||||||
checks.append(("flipper blue", count_px(s, (100, 300, 280, 45),
|
checks.append(("pit center", near(s.get_at((180, 455)), C["pit"], 30)))
|
||||||
lambda p: near(p, C["flipper"], 55)) > 30))
|
# flippers present on BOTH sides (pivot ~78 and ~282)
|
||||||
# cannon purple body at (440,152)
|
fl = count_px(s, (60, 395, 260, 50), lambda p: near(p, C["flipper"], 55))
|
||||||
checks.append(("cannon", near(s.get_at((440, 152)), (150, 100, 220), 70)))
|
checks.append(("flipper blue both sides", fl > 60))
|
||||||
# ball near (240,300) is white-ish
|
# cannon purple body around (300,170)
|
||||||
checks.append(("ball", count_px(s, (230, 290, 20, 20),
|
checks.append(("cannon", near(s.get_at((300, 170)), (150, 100, 220), 70)))
|
||||||
|
# ball near (180,330) is white-ish
|
||||||
|
checks.append(("ball", count_px(s, (170, 320, 20, 20),
|
||||||
lambda p: near(p, (245, 250, 255), 40)) > 5))
|
lambda p: near(p, (245, 250, 255), 40)) > 5))
|
||||||
ok = True
|
ok = True
|
||||||
for name, passed in checks:
|
for name, passed in checks:
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ def main():
|
|||||||
t.reset_ball_in_cannon()
|
t.reset_ball_in_cannon()
|
||||||
g.pending = "live"
|
g.pending = "live"
|
||||||
t.ball_live = True
|
t.ball_live = True
|
||||||
t.ball.x, t.ball.y = 415, 60
|
t.ball.x, t.ball.y = 295, 75
|
||||||
t.ball.vx, t.ball.vy = -2.2, 3.1
|
t.ball.vx, t.ball.vy = -2.2, 3.1
|
||||||
for _ in range(70):
|
for _ in range(80):
|
||||||
g.keys["fire"] = False
|
g.keys["fire"] = False
|
||||||
g.update()
|
g.update()
|
||||||
save(g, "3_play", out)
|
save(g, "3_play", out)
|
||||||
@@ -60,13 +60,13 @@ def main():
|
|||||||
g.combo = 4
|
g.combo = 4
|
||||||
g.combo_t = 150
|
g.combo_t = 150
|
||||||
g.fever = 60 * 12
|
g.fever = 60 * 12
|
||||||
g.add_msg("+500", 150, 150, (255, 214, 92))
|
g.add_msg("+500", 180, 150, (255, 214, 92))
|
||||||
for st in t.stars:
|
for st in t.stars:
|
||||||
st["lit"] = True
|
st["lit"] = True
|
||||||
b = t.bumpers[0]
|
b = t.bumpers[0]
|
||||||
b["squash"] = 4
|
b["squash"] = 4
|
||||||
b["face_t"] = 0.0
|
b["face_t"] = 0.0
|
||||||
g.particles.append(dict(x=150, y=150, life=14, kind="ring", color=(255, 255, 255)))
|
g.particles.append(dict(x=180, y=150, life=14, kind="ring", color=(255, 255, 255)))
|
||||||
g.bubble = ["CANDY FEVER!", 90, "star"]
|
g.bubble = ["CANDY FEVER!", 90, "star"]
|
||||||
save(g, "4_fever", out)
|
save(g, "4_fever", out)
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |