-
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathPinball2.py
More file actions
4652 lines (4150 loc) · 168 KB
/
Copy pathPinball2.py
File metadata and controls
4652 lines (4150 loc) · 168 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# =====================================================================================
# PINBALL 2 — Gottlieb "Central Park" (1966) inspired EM layout
#
# Real-world reference (IPDB #481, Gottlieb, June 1966):
# Flippers (2), Pop bumpers (4), Slingshots (2), Standup targets (12),
# Left and right dual outlanes. Single-player electromechanical.
# Famous for mechanical backbox animation (monkey ringing a bell).
#
# This module recreates the *playfield vocabulary* on the LED matrix:
# bottom Italian outlane/inlane + flippers + slings → midfield standups →
# four thumper bumpers under the top curve → right plunger lane.
#
# Outside the playfield (unchanged presentation):
# - Top: 5-digit flip-clock score (scrolls with camera)
# - Bottom apron: 7-seg time + drain score-reveal anim
#
# Engine forked from Pinball.py (physics, AI, LEDsim canvas path).
# =====================================================================================
from __future__ import annotations
import LEDarcade as LED
LED.Initialize()
import copy
import math
import time
import random
try:
import pygame
HAS_PYGAME = True
except Exception:
HAS_PYGAME = False
# ---------------- Configuration ----------------
WIDTH = int(LED.HatWidth)
HEIGHT = int(LED.HatHeight)
# World is one screen wide. Central Park is a compact single-level EM —
# short playfield: toys pack from the top arc down to the slings (no blank
# mid band above the flippers). Scale 3 ≈ three screens of height.
MAP_SCALE_Y = 3
TABLE_EXTRA_H = 0
MAP_W = WIDTH
MAP_H = HEIGHT * MAP_SCALE_Y + TABLE_EXTRA_H
TARGET_FPS = 40
PHYSICS_SUBSTEPS = 3 # smoother collision / less tunneling & jitter
GRAVITY = 0.085
AIR_DRAG = 0.9992 # slightly less drag per full frame via substeps
BOUNCE = 0.72
MAX_SPEED = 6.5 * 0.75 # 25% slower cap (was 6.5)
BALL_RADIUS = 1.15 # collision radius in world pixels
# Keep the ball on-screen, clear of top/bottom view edges
VIEW_EDGE_MARGIN = 6 # min pixels between ball and top/bottom of view
CAMERA_FOLLOW = 0.14 # smooth pan (lower = less jitter)
CAMERA_CATCHUP = 0.42 # only when ball near view edge
# Extra world padding so camera can track without pinning the ball to the rim
WORLD_TOP_PAD = 4
WORLD_BOTTOM_PAD = 4
# Curved top rail — ball stays on the *inside* and rolls along it
ARC_RGB = (90, 100, 120)
ARC_HIGHLIGHT = (140, 155, 180)
ARC_BOUNCE = 0.55 # soft so it doesn't buzz on the curve
ARC_TANGENT_FRICTION = 0.997
ARC_SLOP = 0.08 # penetration tolerance (reduces micro-jitter)
# Flippers — classic bottom layout with a clear center drain between tips.
#
# Standard lower playfield (Italian bottom / EM era), left → right:
# wall | OUTLANE | SLING | inlane → FLIPPER | D R A I N | FLIPPER ← inlane | SLING | OUTLANE | wall | plunger
#
# Flippers sit at the very bottom; rest tips aim gently down-center but leave
# a gap wide enough for the ball (plus flipper collision thickness) to fall through.
FLIPPER_LEN = max(10, int(WIDTH * 0.17))
FLIPPER_THICK = 1.4
FLIPPER_PAD = 0.55
# Hit envelope half-width of a resting bat (ball center must clear both tips)
_FLIPPER_HIT_R = BALL_RADIUS + FLIPPER_THICK * 0.5 + FLIPPER_PAD
# Tip-to-tip open gap at rest — roomy center drain (classic ~1.5–2 ball widths
# of free air; we size by collision radii so the ball never wedges)
FLIPPER_DRAIN_GAP = max(6.5, 2.0 * _FLIPPER_HIT_R + 1.6)
# Pivot height above the bottom edge (world y = MAP_H - this)
FLIPPER_BOTTOM_INSET = 3.5 + 10
# Rest: tips point gently down-center; active: tips rise (classic bat swing)
LEFT_REST = 0.38
LEFT_ACTIVE = -0.95
RIGHT_REST = math.pi - 0.38
RIGHT_ACTIVE = math.pi + 0.95
# Snap open fast so the bat fully travels while the ball is still on it
# (~1.33 rad rest→active; 0.48 ≈ 3 frames to full raise on LED).
FLIPPER_SWING_SPEED = 0.48
FLIPPER_RETURN_SCALE = 0.70 # return to rest a bit slower (solenoid feel)
FLIPPER_POWER = 2.85
# Skill AI: hold ball on rest blade, flip at a chosen position along the flipper
FLIP_HOLD_SPEED = 0.95
FLIP_CATCH_DIST = 2.4
FLIP_BASE_T = 0.28
FLIP_MID_T = 0.55
FLIP_TIP_T = 0.82
# Frames bat stays raised after a shot (must cover full snap + brief hold)
FLIP_HOLD_FRAMES = 9
# Short post-shot cooldown so AI can re-catch quickly
FLIP_COOLDOWN_FRAMES = 3
# Central Park has only the two bottom flippers (no upper pair on the real table)
ENABLE_UPPER_FLIPPERS = False
UPPER_FLIPPER_LEN = max(8, int(FLIPPER_LEN * 0.90))
UPPER_FLIPPER_SIDE_INSET = 2.2
UPPER_FLIPPER_Y_FRAC = 0.26
UPPER_FLIPPER_SWING_SPEED = FLIPPER_SWING_SPEED * 1.55
UPPER_FLIPPER_POWER = FLIPPER_POWER * 1.35
UPPER_FLIP_CATCH_DIST = FLIP_CATCH_DIST * 1.55
UPPER_FLIP_LOOKAHEAD = 10.0
UPPER_FLIP_REACT_Y = 22.0
# Plunger lane (right side — traditional pinball launch)
PLUNGER_LANE_W = 3 # width of the right-hand lane
# Full pull hard into the top arc; every launch is floored so the ball
# always clears the chute into the playfield (strength only varies exit speed).
PLUNGER_POWER = 6.6 # nominal full upward launch speed
PLUNGER_CHARGE_FRAMES = 22 # frames for a full visual pull-back
PLUNGER_RELOAD_FRAMES = 40 # delay after drain before next shot
# After a drain: apron anim (clock → score → clock) then plunger
# Camera stays put; only the bottom apron content scrolls in place.
APRON_SCROLL_SECONDS = 0.45 # clock down / score in (and reverse)
APRON_SCORE_HOLD_SECONDS = 1.0 # score stays before clock returns (mid-game drains)
# 5-ball game → game over sequence after the last drain
BALLS_PER_GAME = 5
GAME_OVER_SCORE_HOLD_SECONDS = 3.0 # apron score holds while "Game Over" glows
GAME_OVER_GLOW_SECONDS = 1.6 # time for text to fully appear
GAME_OVER_FADE_SECONDS = 1.25 # full-screen fade to black before next table
GAME_OVER_RGB = (255, 70, 55)
GAME_OVER_GLOW_RGB = (255, 160, 90)
# Auto-play: each launch picks a random pull strength in this range
# (soft = just clears chute; full = hard skill-shot crank)
PLUNGER_STRENGTH_MIN = 0.35
PLUNGER_STRENGTH_MAX = 1.0
# Safety net if a launch still stalls in the lane (should be rare)
PLUNGER_RETRY_STRENGTH_BOOST = 0.12
PLUNGER_RETRY_POWER_SCALE = 1.12
PLUNGER_RETRY_MAX_POWER = PLUNGER_POWER * 1.20
# Chute launches must exceed playfield MAX_SPEED or they never clear the lane
PLUNGER_LANE_MAX_SPEED = PLUNGER_RETRY_MAX_POWER
PLUNGER_CLIMB_CLEAR = 10.0
PLUNGER_RETURN_Y = 6.5
PLUNGER_RETURN_SPEED = 0.35
PLUNGER_RETRY_MIN_FRAMES = 12
# Lane opens into the playfield *inside* the top arc (not outside the curve)
PLUNGER_EXIT_INSET = 2.2 # how far inside the arc the ball is delivered
# --- Gottlieb Central Park (1966) layout vocabulary ---
# Bottom → top:
# dual outlanes + 2 flippers + 2 slingshots
# → 12 standup targets (midfield)
# → 4 pop (thumper) bumpers under the top curve
# → right-hand plunger into the top arc
# No skill ramps / multi-saucer network (not on this title).
# One top-of-table spinner sits on the plunger launch corridor (see toys).
#
# Pop bumpers (active "thumper" bumpers) — Central Park has FOUR
BUMPER_RGB = (50, 90, 200)
BUMPER_LIT_RGB = (120, 180, 255)
BUMPER_R = 2.4
BUMPER_KICK = 0.72
ENABLE_SKILL_RAMPS = False
ENABLE_SPINNERS = False # midfield multi-spinner cluster (off)
ENABLE_TOP_LAUNCH_SPINNER = True # single reel under the arc / launch path
ENABLE_MULTI_SAUCERS = False
ENABLE_DROP_TARGETS = False # CP uses standups, not drop banks
ENABLE_UPPER_TRI = False
# Top launch spinner — high gain so a plunger shot rips the reel
TOP_SPINNER_R = 2.15
TOP_SPINNER_SPIN_GAIN = 1.15 # impulse scale (default collide uses 0.35)
TOP_SPINNER_MAX_OMEGA = 3.8 # rad/frame cap (default ~1.0–1.4)
# Slingshots — just above and to either side of the flippers (classic EM)
# Tall wall, short base; long rubber faces the playfield. Apexes stay
# outside the center drain corridor so the ball can always fall middle.
SLING_KICK = 2.35 * 0.5 # 50% weaker pop (was 2.35)
SLING_RGB = (160, 40, 40)
SLING_RUBBER = (230, 85, 65)
SLING_LIT_RGB = (255, 190, 90)
SLING_PAD = 0.95 # collision half-width (body + rubber solid)
# Upper-center bounce triangle (disabled for Central Park layout)
UPPER_TRI_SIDE = 10.0
UPPER_TRI_KICK = SLING_KICK * 0.45 # bounce pop, weaker than slingshots
UPPER_TRI_RGB = (140, 150, 165)
UPPER_TRI_EDGE = (200, 210, 225)
UPPER_TRI_PAD = 0.9
SLING_HEIGHT = 13.0 # tall relative to short base
SLING_BASE = 4.2 # short base toward center — never closes the drain
# Extra open space between left/right sling apexes (flippers stay put)
SLING_GAP_WIDEN = 4.0 # total pixels — 2px each side, away from center
# Whole sling shifted outward (left← / right→) and up; flippers unchanged
SLING_OUTWARD_SHIFT = 2.0 + 3.0 # prior 2 + move over 3 more
SLING_UP_SHIFT = 3.0 # entire shape raised (smaller y)
# Narrow outlane between side wall and sling outer face (~1 ball + margin)
OUTLANE_W = max(2.8, BALL_RADIUS * 2.4)
# Outlane / inlane guides: vertical midway wall↔sling, curve to the flipper
# wall | OUTLANE | guide | inlane → flipper | DRAIN | …
OUTLANE_GUIDE_RGB = (100, 110, 130)
OUTLANE_GUIDE_THICK = 0.5 # collision half-width for a true 1px rail
# Vertical top sits this many px above the slingshot top
OUTLANE_GUIDE_ABOVE_SLING = 2.0
# Drop-target banks — vertical columns along the sides (room behind for ball)
DROP_COUNT = 7 # targets per outer vertical bank
DROP_INNER_COUNT = 5 # second column each side (slightly inboard)
DROP_W = 2.4 # face width (toward center)
DROP_H = 2.4 # each target height in the stack
DROP_GAP = 0.75
DROP_BEHIND_GAP = max(3.4, BALL_RADIUS * 2.8) # wall → bank gap (ball fits)
DROP_COL_GAP = 3.2 # gap between outer and inner columns
# Horizontal row flanking the bottom eject hole (10 px below the hole)
DROP_BOTTOM_LINE_EACH = 3 # targets left of hole + targets right of hole
DROP_BOTTOM_LINE_BELOW = 10.0 # px further down than the bottom saucer
DROP_BOTTOM_LINE_GAP = 3.2 # clear space left/right of the hole center
DROP_RGB = (200, 160, 40)
DROP_EDGE = (120, 90, 20)
DROP_DOWN_RGB = (35, 30, 15)
DROP_RESET_SECONDS = 4.5
# Stand-up targets (single posts — don't drop)
STANDUP_RGB = (180, 60, 160)
STANDUP_LIT = (255, 140, 230)
STANDUP_R = 1.35
# Passive / guide posts (passive, light bounce)
POST_RGB = (90, 95, 110)
POST_R = 1.15
# Center skill ramp — flipper aim target; launches ball into upper bumper cluster
RAMP_BASE_W = 6.0 # wide end (faces flippers / down-table)
RAMP_TOP_W = 2.0 # narrow lip (faces up-table)
RAMP_H = 8.0
# Silver metal look (body / edge / bright lip)
RAMP_RGB = (165, 172, 185)
RAMP_EDGE = (210, 218, 230)
RAMP_LIP = (245, 248, 255)
RAMP_SHADE = (110, 118, 130)
RAMP_LAUNCH = 3.55 # upward speed when leaving the lip
RAMP_CLIMB = 0.14 # extra climb assist while riding up
RAMP_CENTER_PULL = 0.10 # keep ball on the ramp face
# After leaving the lip, ball flies over mid toys (drops/posts/spinners/etc.)
RAMP_AIRBORNE_SECONDS = 0.42
# Spinners (EM reels — spin when hit; multiple sizes on the table)
SPINNER_RGB = (200, 200, 80)
SPINNER_LIT = (255, 255, 140)
SPINNER_R = 2.0 # default / medium
SPINNER_FRICTION = 0.965
# Top rollover lane switches (3)
ROLLOVER_RGB = (40, 80, 140)
ROLLOVER_LIT = (80, 180, 255)
# Saucer / kick-out holes (network: enter one → vanish → pan → exit another)
SAUCER_RGB = (30, 30, 40)
SAUCER_RIM = (100, 100, 120)
SAUCER_KICK = 2.8
# Tiny trajectory jitter on eject (radians / relative speed)
SAUCER_EJECT_ANGLE_JITTER = 0.18 # ± ~10° around the base kick aim
SAUCER_EJECT_SPEED_JITTER = 0.08 # ±8% kick speed
SAUCER_CAPTURE_R = 2.0
SAUCER_TOP_BELOW_APEX = 5.0 + 3.0 # top hole: was apex+5, moved 3 px lower
SCORE_SAUCER = 100 # enter any hole → points + teleport to another
SAUCER_VANISH_SECONDS = 0.50 # fade out at entry hole
SAUCER_PAN_SECONDS = 0.45 # camera pan to exit (ball invisible)
# Bottom apron clock — red 7-segment LED digits (medium), world-space strip
# below the flippers (scrolls with the playfield camera)
CLOCK_RGB = (255, 36, 28) # lit LED red
CLOCK_DIM = (55, 12, 10) # unlit segment ghost
CLOCK_DIGIT_W = 5
CLOCK_DIGIT_H = 9
CLOCK_THICK = 1
CLOCK_GAP = 1
CLOCK_COLON_W = 2
CLOCK_APRON_H = CLOCK_DIGIT_H + 3 # world strip height below the playfield
CLOCK_APRON_RGB = (12, 8, 10) # dark apron panel behind the digits
CLOCK_APRON_EDGE = (50, 25, 28) # thin red-ish rail above apron
# Score — 70s flip-clock style 5-digit display at the top (scrolls with table)
SCORE_DIGITS = 5
SCORE_MAX = 10 ** SCORE_DIGITS - 1
SCORE_BUMPER = 10
SCORE_SLING = 5
SCORE_TARGET = 1 # rollovers, drop targets, standups
# SCORE_SAUCER defined with saucer constants
# Flip-card face (mirrors LEDarcade GenerateFlipClockImage look)
FLIP_CARD_BG = (0, 0, 0)
FLIP_CARD_FRAME = (40, 40, 40)
FLIP_CARD_SEAM = (96, 96, 96)
FLIP_CARD_LOWER = (8, 8, 8)
FLIP_DIGIT_RGB = (255, 255, 255)
FLIP_DIGIT_W = 3 # LEDarcade DigitList is 3×5
FLIP_DIGIT_H = 5
FLIP_CARD_PAD_X = 1
FLIP_CARD_PAD_Y = 1
FLIP_CARD_GAP = 1
FLIP_SCORE_Y = 1 - 3 # world y of score strip (moved 3 px up)
# 7-segment masks: bit0=A top, B UR, C LR, D bot, E LL, F UL, G mid
_SEG_A, _SEG_B, _SEG_C, _SEG_D = 0x01, 0x02, 0x04, 0x08
_SEG_E, _SEG_F, _SEG_G = 0x10, 0x20, 0x40
_SEG_DIGIT_MASKS = (
0x3F, # 0 ABCDEF
0x06, # 1 BC
0x5B, # 2 ABDEG
0x4F, # 3 ABCDG
0x66, # 4 BCFG
0x6D, # 5 ACDFG
0x7D, # 6 ACDEFG
0x07, # 7 ABC
0x7F, # 8 ABCDEFG
0x6F, # 9 ABCDFG
)
# Colors
BG = (0, 0, 0)
WALL_RGB = (40, 45, 55)
RAIL_RGB = (70, 80, 100)
FLOOR_RGB = (25, 20, 30)
BALL_CORE = (220, 225, 235)
BALL_HIGH = (255, 255, 255)
BALL_SHADE = (110, 120, 140)
FLIPPER_RGB = (200, 40, 40)
FLIPPER_EDGE_RGB = (160, 30, 30) # darker rim for AA soft edge
FLIPPER_LIP = (255, 110, 85)
DECOR_RGB = (30, 50, 70)
PLUNGER_RGB = (160, 160, 170)
PLUNGER_SPRING = (100, 100, 110)
ScrollSleep = 0.02
def _stop(StopEvent):
try:
return StopEvent is not None and StopEvent.is_set()
except Exception:
return False
# ---------------- Geometry helpers ----------------
def _clamp(v, lo, hi):
return lo if v < lo else hi if v > hi else v
def _lerp_angle(a, b, t):
"""Shortest-path angle lerp."""
d = (b - a + math.pi) % (2 * math.pi) - math.pi
return a + d * _clamp(t, 0.0, 1.0)
def _segment_points(px, py, angle, length):
tx = px + math.cos(angle) * length
ty = py + math.sin(angle) * length
return px, py, tx, ty
def _dist_point_segment(px, py, x1, y1, x2, y2):
"""Distance from point to segment; returns (dist, nearest_x, nearest_y, t)."""
dx = x2 - x1
dy = y2 - y1
den = dx * dx + dy * dy
if den < 1e-9:
return math.hypot(px - x1, py - y1), x1, y1, 0.0
t = ((px - x1) * dx + (py - y1) * dy) / den
t = _clamp(t, 0.0, 1.0)
nx = x1 + t * dx
ny = y1 + t * dy
return math.hypot(px - nx, py - ny), nx, ny, t
def _draw_line(canvas, x0, y0, x1, y1, rgb, camera_y, thick=0):
"""Bresenham line in world space → screen via camera_y (solid, no AA)."""
x0, y0, x1, y1 = int(round(x0)), int(round(y0)), int(round(x1)), int(round(y1))
dx = abs(x1 - x0)
dy = -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
r, g, b = rgb
set_px = canvas.SetPixel
while True:
sx_s = x0
sy_s = y0 - int(round(camera_y))
if 0 <= sx_s < WIDTH and 0 <= sy_s < HEIGHT:
set_px(sx_s, sy_s, r, g, b)
if thick:
for ox, oy in ((1, 0), (0, 1), (-1, 0), (0, -1)):
xx, yy = sx_s + ox, sy_s + oy
if 0 <= xx < WIDTH and 0 <= yy < HEIGHT:
set_px(xx, yy, r, g, b)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def _ipart(x):
return int(math.floor(x))
def _fpart(x):
return x - math.floor(x)
def _rfpart(x):
return 1.0 - _fpart(x)
# Global draw brightness (1 = full, 0 = black) — used for game-over screen fade
_SCREEN_BRIGHTNESS = 1.0
def _set_screen_brightness(b):
global _SCREEN_BRIGHTNESS
_SCREEN_BRIGHTNESS = _clamp(float(b), 0.0, 1.0)
def _plot_aa(canvas, sx, sy, rgb, cover):
"""Blend rgb onto canvas at integer screen pixel with coverage 0..1."""
if cover <= 0.02 or not (0 <= sx < WIDTH and 0 <= sy < HEIGHT):
return
cover = _clamp(cover, 0.0, 1.0) * _SCREEN_BRIGHTNESS
# Soft AA: scale color toward black (panel is black bg) — good enough on LED
r = int(rgb[0] * cover)
g = int(rgb[1] * cover)
b = int(rgb[2] * cover)
if r | g | b:
canvas.SetPixel(int(sx), int(sy), r, g, b)
def _draw_aa_line_screen(canvas, x0, y0, x1, y1, rgb):
"""
Xiaolin Wu anti-aliased line in *screen* pixel space.
Softens flipper edges on the low-res LED panel.
"""
steep = abs(y1 - y0) > abs(x1 - x0)
if steep:
x0, y0 = y0, x0
x1, y1 = y1, x1
if x0 > x1:
x0, x1 = x1, x0
y0, y1 = y1, y0
dx = x1 - x0
dy = y1 - y0
gradient = dy / dx if abs(dx) > 1e-9 else 1.0
# First endpoint
xend = round(x0)
yend = y0 + gradient * (xend - x0)
xgap = _rfpart(x0 + 0.5)
xpxl1 = int(xend)
ypxl1 = _ipart(yend)
if steep:
_plot_aa(canvas, ypxl1, xpxl1, rgb, _rfpart(yend) * xgap)
_plot_aa(canvas, ypxl1 + 1, xpxl1, rgb, _fpart(yend) * xgap)
else:
_plot_aa(canvas, xpxl1, ypxl1, rgb, _rfpart(yend) * xgap)
_plot_aa(canvas, xpxl1, ypxl1 + 1, rgb, _fpart(yend) * xgap)
intery = yend + gradient
# Second endpoint
xend = round(x1)
yend = y1 + gradient * (xend - x1)
xgap = _fpart(x1 + 0.5)
xpxl2 = int(xend)
ypxl2 = _ipart(yend)
if steep:
_plot_aa(canvas, ypxl2, xpxl2, rgb, _rfpart(yend) * xgap)
_plot_aa(canvas, ypxl2 + 1, xpxl2, rgb, _fpart(yend) * xgap)
else:
_plot_aa(canvas, xpxl2, ypxl2, rgb, _rfpart(yend) * xgap)
_plot_aa(canvas, xpxl2, ypxl2 + 1, rgb, _fpart(yend) * xgap)
# Main loop
if steep:
for x in range(xpxl1 + 1, xpxl2):
y = _ipart(intery)
_plot_aa(canvas, y, x, rgb, _rfpart(intery))
_plot_aa(canvas, y + 1, x, rgb, _fpart(intery))
intery += gradient
else:
for x in range(xpxl1 + 1, xpxl2):
y = _ipart(intery)
_plot_aa(canvas, x, y, rgb, _rfpart(intery))
_plot_aa(canvas, x, y + 1, rgb, _fpart(intery))
intery += gradient
def _draw_aa_flipper_blade(canvas, x0, y0, x1, y1, rgb, edge_rgb, camera_y, half_width=1.15):
"""
Anti-aliased thick flipper blade: core line + parallel AA edges.
half_width is in world/screen pixels (≈1–1.5 on 64×32).
"""
# World → screen
sx0 = float(x0)
sy0 = float(y0) - float(camera_y)
sx1 = float(x1)
sy1 = float(y1) - float(camera_y)
dx = sx1 - sx0
dy = sy1 - sy0
length = math.hypot(dx, dy) or 1.0
# Perpendicular unit normal
nx = -dy / length
ny = dx / length
# Soft outer edges (darker) then bright core
for offset, col, in (
(-half_width, edge_rgb),
(half_width, edge_rgb),
(-half_width * 0.45, rgb),
(half_width * 0.45, rgb),
(0.0, rgb),
):
_draw_aa_line_screen(
canvas,
sx0 + nx * offset, sy0 + ny * offset,
sx1 + nx * offset, sy1 + ny * offset,
col,
)
def _draw_world_pixel(canvas, wx, wy, rgb, camera_y):
sx = int(round(wx))
sy = int(round(wy - camera_y))
if 0 <= sx < WIDTH and 0 <= sy < HEIGHT:
k = _SCREEN_BRIGHTNESS
canvas.SetPixel(
sx, sy,
int(rgb[0] * k), int(rgb[1] * k), int(rgb[2] * k),
)
def _draw_aa_disk(canvas, wx, wy, radius, rgb, camera_y):
"""Soft circular blob (pivot / tip highlights) with radial falloff."""
cx = wx
cy = wy - camera_y
r = max(0.8, float(radius))
r2 = r * r
x0 = int(math.floor(cx - r - 1))
x1 = int(math.ceil(cx + r + 1))
y0 = int(math.floor(cy - r - 1))
y1 = int(math.ceil(cy + r + 1))
for sy in range(y0, y1 + 1):
for sx in range(x0, x1 + 1):
d2 = (sx + 0.5 - cx) ** 2 + (sy + 0.5 - cy) ** 2
if d2 > r2:
continue
# Smooth coverage at the rim
d = math.sqrt(d2)
cover = 1.0 if d <= r - 0.65 else _clamp(1.0 - (d - (r - 0.65)) / 0.65, 0.0, 1.0)
_plot_aa(canvas, sx, sy, rgb, cover)
# ---------------- Flipper ----------------
class Flipper:
def __init__(
self, pivot_x, pivot_y, rest_angle, active_angle, length, side,
swing_speed=None, power=None,
):
self.px = float(pivot_x)
self.py = float(pivot_y)
self.rest = float(rest_angle)
self.active = float(active_angle)
self.length = float(length)
self.side = side # "left" or "right"
self.swing_speed = float(
FLIPPER_SWING_SPEED if swing_speed is None else swing_speed
)
self.power = float(FLIPPER_POWER if power is None else power)
self.angle = float(rest_angle)
self.target = float(rest_angle)
self.omega = 0.0 # rad/frame (for hit impulse)
self.pressed = False
def set_pressed(self, pressed):
self.pressed = bool(pressed)
self.target = self.active if self.pressed else self.rest
def update(self):
prev = self.angle
# Move toward target with capped step — snap up fast when firing so
# the blade visibly travels while the ball is flung; return slower.
d = (self.target - self.angle + math.pi) % (2 * math.pi) - math.pi
speed = self.swing_speed if self.pressed else self.swing_speed * FLIPPER_RETURN_SCALE
step = _clamp(d, -speed, speed)
self.angle += step
self.omega = self.angle - prev
def endpoints(self):
return _segment_points(self.px, self.py, self.angle, self.length)
def tip(self):
_, _, tx, ty = self.endpoints()
return tx, ty
def draw(self, canvas, camera_y):
x1, y1, x2, y2 = self.endpoints()
# Anti-aliased thick blade (core + soft edges)
_draw_aa_flipper_blade(
canvas, x1, y1, x2, y2,
FLIPPER_RGB, FLIPPER_EDGE_RGB, camera_y,
half_width=1.25,
)
# Soft pivot + tip caps
_draw_aa_disk(canvas, x1, y1, 1.35, FLIPPER_LIP, camera_y)
_draw_aa_disk(canvas, x2, y2, 1.05, FLIPPER_LIP, camera_y)
# ---------------- Ball ----------------
class Ball:
def __init__(self, x, y, vx=0.0, vy=0.0):
self.x = float(x)
self.y = float(y)
self.vx = float(vx)
self.vy = float(vy)
self.alive = True
self.in_plunger = True # right-hand launch lane
self.visible = 1.0 # 0..1 (saucer vanish fade)
self.airborne = 0 # frames of ramp-jump (skip mid toys)
def speed(self):
return math.hypot(self.vx, self.vy)
def place_in_plunger(self):
"""Seat the ball at the bottom of the right-hand plunger lane."""
lane_x = MAP_W - 1.5
self.x = float(lane_x)
self.y = float(_playfield_bottom() - 6.0)
self.vx = 0.0
self.vy = 0.0
self.alive = True
self.in_plunger = True
self.visible = 1.0
self.airborne = 0
def fire_plunger(self, power=None):
"""Launch up the plunger lane (traditional plunger)."""
p = PLUNGER_POWER if power is None else float(power)
# Stable launch — tiny horizontal only (no random power jitter)
self.vx = -0.02
self.vy = -abs(p)
self.in_plunger = True
self.alive = True
self.visible = 1.0
self.airborne = 0
def is_airborne(self):
"""True while jumping off a ramp (ignores nearby obstacles)."""
return int(getattr(self, "airborne", 0)) > 0
def launch(self, x=None, y=None):
"""Compatibility: seat for plunger (fire happens in main loop)."""
self.place_in_plunger()
def integrate(self, dt_scale=1.0):
"""Advance physics by dt_scale (1.0 = one full frame; use 1/N in substeps)."""
if self.in_plunger and abs(self.vy) < 0.01 and abs(self.vx) < 0.01:
# Sitting on the plunger — no free motion until fired
return
s = float(dt_scale)
self.vy += GRAVITY * s
# Air drag per substep so full-frame damping stays similar
drag = AIR_DRAG ** s
self.vx *= drag
self.vy *= drag
sp = self.speed()
# Playfield is capped at MAX_SPEED; plunger lane allows full launch power
# so the ball can still clear the chute after the 25% playfield slowdown.
speed_cap = PLUNGER_LANE_MAX_SPEED if self.in_plunger else MAX_SPEED
if sp > speed_cap:
k = speed_cap / sp
self.vx *= k
self.vy *= k
self.x += self.vx * s
self.y += self.vy * s
def draw(self, canvas, camera_y):
"""Draw ball in view; never place pixels outside the panel."""
vis = float(getattr(self, "visible", 1.0))
if vis <= 0.02:
return
# Use rounded world→screen without fighting the camera (camera already tracks)
cx = int(round(self.x))
cy = int(round(self.y - camera_y))
# Safety only — should rarely engage if camera is correct
cy = _clamp(cy, VIEW_EDGE_MARGIN, HEIGHT - 1 - VIEW_EDGE_MARGIN)
cx = _clamp(cx, 1, WIDTH - 2)
# 3×3 silver blob with highlight / shade (dimmed by visible for saucer vanish)
offsets = (
(0, 0, BALL_CORE),
(1, 0, BALL_CORE),
(-1, 0, BALL_SHADE),
(0, 1, BALL_SHADE),
(0, -1, BALL_HIGH),
(1, -1, BALL_HIGH),
(-1, 1, BALL_SHADE),
)
set_px = canvas.SetPixel
for ox, oy, rgb in offsets:
sx, sy = cx + ox, cy + oy
if 0 <= sx < WIDTH and 0 <= sy < HEIGHT:
set_px(
sx, sy,
int(rgb[0] * vis),
int(rgb[1] * vis),
int(rgb[2] * vis),
)
# ---------------- World / camera ----------------
def _lane_left_x():
"""World x of the wall separating main table from the plunger lane."""
return float(MAP_W - PLUNGER_LANE_W - 1)
def _top_arc():
"""
Circular arc ceiling over the main playfield (not the plunger lane).
Center is below the apex so the ball rolls on the *inside* of the upper
semicircle. Returns (cx, cy, radius, left_x, right_x).
"""
left_x = 1.0
right_x = _lane_left_x() - 0.5
span = max(8.0, right_x - left_x)
cx = 0.5 * (left_x + right_x)
# Full semicircle from left wall to right wall (equator at side posts)
radius = span * 0.5 + 0.35
# Apex of the arc at WORLD_TOP_PAD
cy = float(WORLD_TOP_PAD) + radius
return cx, cy, radius, left_x, right_x
def _plunger_exit_pose():
"""
World point where the plunger lane feeds the ball into the playfield.
Chosen on the *inside* of the top arc (right side, slightly below apex
toward the equator) so the ball never pops outside the curve.
"""
cx, cy, radius, left_x, right_x = _top_arc()
# Angle from center: 0 = right equator, -pi/2 = apex. ~-0.55 rad is inside.
a = -0.55
inset = BALL_RADIUS + PLUNGER_EXIT_INSET
ex = cx + (radius - inset) * math.cos(a)
ey = cy + (radius - inset) * math.sin(a)
# Keep left of the lane wall so we are clearly in the main field
lane_l = _lane_left_x()
ex = min(ex, lane_l - BALL_RADIUS - 0.6)
ex = max(ex, left_x + BALL_RADIUS + 0.5)
return float(ex), float(ey)
def _plunger_exit_y():
"""Y threshold: ball high enough in the lane to transfer into the field."""
_ex, ey = _plunger_exit_pose()
# Start the handoff a bit below the final pose so motion eases in
return float(ey + 3.5)
def plunger_min_exit_power():
"""
Minimum upward launch speed so the ball reaches the chute exit under
gravity + air drag (with a small safety margin). Every fire is floored
at this value so launches always leave the plunger lane.
"""
start_y = float(_playfield_bottom() - 4.0)
climb = max(8.0, start_y - _plunger_exit_y())
# Ideal ballistic: v = sqrt(2 g h); pad for drag / substep integration
base = math.sqrt(max(0.0, 2.0 * GRAVITY * climb))
# ~6.0 clears a 7×-tall 64px table in sim; keep a modest cushion
return float(max(base * 1.06 + 0.20, PLUNGER_POWER * 0.92))
def plunger_power_for_strength(strength, floor_power=None):
"""
Map pull strength [0..1] → launch speed.
Soft pulls just clear the chute; full pulls hit PLUNGER_POWER (and a
little beyond on retries). Never returns below the exit floor.
"""
s = _clamp(float(strength), 0.0, 1.0)
floor_p = plunger_min_exit_power() if floor_power is None else float(floor_power)
top_p = max(floor_p, PLUNGER_POWER * 1.05)
return float(floor_p + (top_p - floor_p) * s)
def _ball_y_min():
"""Highest the ball may go — just under the arc apex (inside the curve)."""
cx, cy, radius, _, _ = _top_arc()
return float(cy - radius + BALL_RADIUS + 0.5)
def _playfield_bottom():
"""
World y of the bottom of the active playfield (top edge of the clock apron).
The apron strip [playfield_bottom .. MAP_H) is display-only and scrolls
with the camera; the ball drains at this line rather than into the digits.
"""
return float(MAP_H - CLOCK_APRON_H)
def _ball_y_max():
"""Lowest the ball may go before drain logic (main field)."""
return float(_playfield_bottom() - WORLD_BOTTOM_PAD - BALL_RADIUS - 1.0)
def _arc_inside_limit():
"""Max distance from arc center for the ball center (inside surface)."""
_cx, _cy, radius, _, _ = _top_arc()
return radius - BALL_RADIUS
def ensure_ball_inside_arc(ball):
"""
Soft constraint: while near the top arc, keep the ball on the inside.
Does not yank the ball from deep in the playfield (only upper region).
"""
if ball.in_plunger:
return
cx, cy, radius, left_x, right_x = _top_arc()
if ball.y > cy + 1.0:
return
if ball.x < left_x - 1.0 or ball.x > right_x + 1.5:
return
dx = ball.x - cx
dy = ball.y - cy
dist = math.hypot(dx, dy)
if dist < 1e-6:
return
limit = radius - BALL_RADIUS
if dist > limit + ARC_SLOP:
# Project smoothly onto the inner surface
nx, ny = dx / dist, dy / dist
ball.x = cx + nx * limit
ball.y = cy + ny * limit
vn = ball.vx * nx + ball.vy * ny
if vn > 0.0:
# Kill outward component only (no hard bounce impulse here)
ball.vx -= vn * nx
ball.vy -= vn * ny
def collide_top_arc(ball):
"""
Ball rolls on the *inside* of the top arc.
Soft projection + remove outward velocity + keep tangential roll.
Tuned for low jitter (slop + gentle restitution).
"""
if ball.in_plunger:
return False
cx, cy, radius, left_x, right_x = _top_arc()
# Upper half of the circle only (ceiling)
if ball.y > cy + 1.5:
return False
if ball.x < left_x - 1.0 or ball.x > right_x + 1.5:
return False
limit = radius - BALL_RADIUS
hit = False
for _ in range(3):
dx = ball.x - cx
dy = ball.y - cy
dist = math.hypot(dx, dy)
if dist < 1e-6:
break
nx, ny = dx / dist, dy / dist
# Inside with slop — only strip outward velocity if pressing into the rail
if dist <= limit + ARC_SLOP:
vn = ball.vx * nx + ball.vy * ny
if vn > 0.0:
ball.vx -= vn * nx * (0.85 + 0.15 * ARC_BOUNCE)
ball.vy -= vn * ny * (0.85 + 0.15 * ARC_BOUNCE)
hit = True
# Light tangent damping while in contact band
if dist >= limit - ARC_SLOP:
tx, ty = -ny, nx
vt = ball.vx * tx + ball.vy * ty
vn2 = ball.vx * nx + ball.vy * ny
if vn2 > 0.0:
vn2 = 0.0
ball.vx = nx * vn2 + tx * vt * ARC_TANGENT_FRICTION
ball.vy = ny * vn2 + ty * vt * ARC_TANGENT_FRICTION
break
# Outside the inner surface — project back inside
ball.x = cx + nx * limit
ball.y = cy + ny * limit
hit = True
vn = ball.vx * nx + ball.vy * ny
if vn > 0.0:
# Soft bounce (not 1+e full reflection — avoids buzz)
ball.vx -= (1.0 + ARC_BOUNCE * 0.65) * vn * nx
ball.vy -= (1.0 + ARC_BOUNCE * 0.65) * vn * ny
tx, ty = -ny, nx
vt = ball.vx * tx + ball.vy * ty
vn2 = ball.vx * nx + ball.vy * ny
if vn2 > 0.0:
vn2 = 0.0
ball.vx = nx * vn2 + tx * vt * ARC_TANGENT_FRICTION
ball.vy = ny * vn2 + ty * vt * ARC_TANGENT_FRICTION
return hit
def force_plunger_exit(ball, retain_speed=True):
"""
Always hand the ball out of the chute into the playfield (inside the
top arc). Used for normal high-lane exits and as a guarantee when the
ball crests in the lane without quite hitting the soft threshold.
"""
if not ball.in_plunger:
return False
ex, ey = _plunger_exit_pose()
# Blend toward the exit pose so it doesn't teleport hard
ball.x += (ex - ball.x) * 0.70
ball.y += (ey - ball.y) * 0.65
# Snap if still far (guarantees leave-chute even from a short crest)
if abs(ball.x - ex) > 2.5:
ball.x = ex
if abs(ball.y - ey) > 3.5:
ball.y = ey
ball.in_plunger = False
# Feed into the field along the inside of the arc (left + slight up)
if retain_speed:
ball.vx = min(ball.vx, 0.0) - 0.85
# Keep some upward momentum if still climbing; else a gentle feed
if ball.vy < -0.2:
ball.vy = min(ball.vy, -0.4)
else:
ball.vy = min(ball.vy, 0.0) - 0.55
else:
ball.vx = -0.9
ball.vy = -0.6
ensure_ball_inside_arc(ball)
collide_top_arc(ball)
return True
def try_plunger_exit(ball):
"""
When the ball reaches the top of the plunger lane, hand it off into the
main playfield at a point *inside* the top arc (never outside the curve).
"""
if not ball.in_plunger:
return False
exit_y = _plunger_exit_y()
# Soft window: at / above exit, or cresting just below it
near_exit = ball.y <= exit_y + 2.5
cresting = ball.vy >= -0.05 and ball.y <= exit_y + 8.0
if not near_exit and not cresting:
return False
# Reject only if still deep in the lane and falling hard (not at exit)
if ball.y > exit_y + 2.5 and ball.vy > 0.55:
return False
return force_plunger_exit(ball, retain_speed=True)
def draw_top_arc(canvas, camera_y):
"""Draw the curved top rail (AA samples along the upper semicircle)."""
cx, cy, radius, left_x, right_x = _top_arc()
# Angles: pi (left) → 3π/2 (apex) → 2π (right) — upper semicircle only
# Point: (cx + R*cos(a), cy + R*sin(a))
a0 = math.pi
a1 = 2.0 * math.pi
steps = max(28, int(radius * 2.8))
prev = None
for i in range(steps + 1):
t = i / float(steps)
a = a0 + (a1 - a0) * t
wx = cx + radius * math.cos(a)
wy = cy + radius * math.sin(a)
if left_x - 0.5 <= wx <= right_x + 0.5:
if prev is not None:
_draw_aa_flipper_blade(
canvas, prev[0], prev[1], wx, wy,
ARC_RGB, (ARC_RGB[0] // 2, ARC_RGB[1] // 2, ARC_RGB[2] // 2),
camera_y, half_width=0.85,
)
prev = (wx, wy)
else:
prev = None
# Apex highlight
apex_x, apex_y = cx, cy - radius
_draw_aa_disk(canvas, apex_x, apex_y, 1.1, ARC_HIGHLIGHT, camera_y)
def camera_for_ball(ball_y, prev_camera=None):
"""
Vertical camera so the ball stays on-screen and clear of top/bottom edges.