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
This commit is contained in:
georgios
2026-09-09 19:02:11 +02:00
parent c08e22c873
commit cf2c0e4558
18 changed files with 134 additions and 128 deletions
+62 -63
View File
@@ -9,7 +9,7 @@ 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, flipper_tip)
FLIP_RADIUS, FLIP_LEN, flipper_tip, BUMPERS)
import font as pix
# ---------------------------------------------------------------------------
@@ -362,9 +362,9 @@ class Game:
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
# twinkle stars & planets around the table, drawn over the HUD & margins
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))
self.bg = bg
@@ -382,12 +382,11 @@ class Game:
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):
for _ in range(60):
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})):
for b in BUMPERS):
continue
col = PASTELS[r.randrange(len(PASTELS))]
field.fill(tuple(c // 3 for c in col), (dx, dy, 2, 2))
@@ -412,8 +411,11 @@ class Game:
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))
# static labels
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
# ------------------------------------------------------------------ game
@@ -471,8 +473,8 @@ class Game:
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)
self.add_msg("CANDY FEVER!", CV_W // 2, 210, C["gold"], scale=2)
self.add_msg("DOUBLE SCORE x2", CV_W // 2, 226, 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),
@@ -645,7 +647,7 @@ class Game:
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.add_msg("EXTRA BALL!", CV_W // 2, 200, C["pink"], scale=2)
self.next_extra += 30000
def add_msg(self, text, x, y, color, scale=1):
@@ -741,7 +743,7 @@ class Game:
# 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")
pix.draw_text(c, CV_W // 2, 68, "COMBO x%d!" % self.combo, col, 1, align="center")
# particles
for p in self.particles:
@@ -772,21 +774,21 @@ class Game:
# 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")
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, 300, "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")
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:
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
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)
pygame.draw.rect(self.overlay, (12, 8, 40, 170),
(CV_W // 2 - 100, 230, 200, 120), border_radius=10)
c.blit(self.overlay, (0, 0))
def _draw_bubble(self, c):
@@ -801,29 +803,28 @@ class Game:
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)
pix.draw_text(c, 10, 13, "{:,}".format(self.score), C["white"], 2)
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)
pix.draw_text(c, CV_W - 10, 13, "{:,}".format(best), C["gold"], 2, align="right")
# lives hearts (cap the icon row, spill into a +N text)
hearts = self.lives
for i in range(min(hearts, 5)):
draw_heart(c, 10 + i * 14, 42, 2, C["heart"])
if hearts > 5:
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):
x = 52 + i * 18
draw_star(c, x, 37, 5 if st["lit"] else 4,
x = 120 + i * 16
draw_star(c, x + 4, 42, 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)
pix.draw_text(c, 205, 33, "FEVER", C["gold"], 1)
pygame.draw.rect(c, (60, 46, 110), (205, 44, 90, 5))
pygame.draw.rect(c, (255, 150, 200), (205, 44, int(90 * frac), 5))
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
def _title_update(self):
@@ -837,12 +838,12 @@ class Game:
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)
# ringed planet top-left, little moon bottom-right
pygame.draw.circle(c, (150, 110, 220), (62, 80), 22)
pygame.draw.circle(c, (190, 160, 245), (62, 80), 22, 2)
pygame.draw.ellipse(c, (255, 200, 220), (34, 74, 56, 16), 2)
pygame.draw.circle(c, (110, 90, 200), (310, 380), 12)
pygame.draw.circle(c, (150, 120, 230), (310, 380), 12, 1)
# twinkling stars
for sx, sy, tw, ph in self.stars_bg:
if (self.frame + ph) % 70 < 6:
@@ -852,57 +853,55 @@ class Game:
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:
y = 44
for txt, sc, col in (("COSMIC", 3, C["pink"]), ("CANDY", 3, C["gold"]),
("PINBALL", 2, C["mint"])):
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))
pix.draw_text(c, x, y, ch, col, 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
sx = 250 + t0 * 2
sy = 130 + t0
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)
draw_body(c, CV_W // 2 - 24, 208, 2)
draw_cat(c, CV_W // 2 - 24, 196, 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)
pygame.draw.rect(c, (22, 15, 56), (30, 262, CV_W - 60, 84), border_radius=8)
pygame.draw.rect(c, C["rail"], (30, 262, CV_W - 60, 84), 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")
pix.draw_text(c, CV_W // 2, 272 + 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")
pix.draw_text(c, CV_W // 2, 378, "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")
pix.draw_text(c, CV_W // 2, 70, "GAME OVER", C["pink"], 3, align="center")
pix.draw_text(c, CV_W // 2, 128, "SCORE", C["dim"], 1, 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, 195, "BEST", C["dim"], 1, 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:
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)
pix.draw_text(c, CV_W // 2, 250, "NEW HI-SCORE!", C["gold"], 1, align="center")
draw_body(c, CV_W // 2 - 20, 310, 2)
draw_cat(c, CV_W // 2 - 20, 296, 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")
pix.draw_text(c, CV_W // 2, 400, "PRESS ENTER TO PLAY AGAIN", C["white"], 1, align="center")
# ------------------------------------------------------------------ input
def event(self, ev):