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
+2 -2
View File
@@ -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.
![demo](shots/3_play.png) ![demo](shots/3_play.png)
+3 -3
View File
@@ -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
+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, 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):
+32 -27
View File
@@ -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):
+31 -29
View File
@@ -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:
+4 -4
View File
@@ -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)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB