pinball.py

../_images/pinball.png

Minimal pinball game

Minimal pinball game in MicroPython based on code from Ten Minute Physics Tutorial “How to write a pinball simulation.”

Tutorial Links: https://matthias-research.github.io/pages/tenMinutePhysics/ https://youtu.be/NhVUCsXp-Uo

Gameplay Video: https://youtu.be/y0B3i_UmEU8

Requires:

tft_config.py for display configuration. See examples/configs tft_buttons.py for button configuration. See examples/configs OR modify the code for your own display and buttons.

This file incorporates work covered by the following copyright and permission notice. Modifications and additions Copyright (c) 2022 Russ Hughes and released under the same terms as the original code.

Copyright 2021 Matthias Müller - Ten Minute Physics

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

  1"""
  2pinball.py
  3==========
  4
  5.. figure:: /_static/pinball.png
  6  :align: center
  7
  8  Minimal pinball game
  9
 10Minimal pinball game in MicroPython based on code from Ten Minute Physics Tutorial "How to write a
 11pinball simulation."
 12
 13Tutorial Links:
 14https://matthias-research.github.io/pages/tenMinutePhysics/
 15https://youtu.be/NhVUCsXp-Uo
 16
 17Gameplay Video:
 18https://youtu.be/y0B3i_UmEU8
 19
 20Requires:
 21    tft_config.py for display configuration. See examples/configs
 22    tft_buttons.py for button configuration. See examples/configs
 23    OR modify the code for your own display and buttons.
 24
 25This file incorporates work covered by the following copyright and permission notice.
 26Modifications and additions Copyright (c) 2022 Russ Hughes and released under the same
 27terms as the original code.
 28
 29
 30Copyright 2021 Matthias Müller - Ten Minute Physics
 31
 32Permission is hereby granted, free of charge, to any person obtaining a copy of
 33this software and associated documentation files (the "Software"), to deal in
 34the Software without restriction, including without limitation the rights to
 35use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
 36of the Software, and to permit persons to whom the Software is furnished to do
 37so, subject to the following conditions:
 38
 39The above copyright notice and this permission notice shall be included in all
 40copies or substantial portions of the Software.
 41
 42THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 43IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 44FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 45AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 46LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 47OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 48SOFTWARE.
 49"""
 50
 51# pylint: disable=missing-function-docstring, missing-class-docstring, invalid-name
 52
 53import time
 54import random
 55import math
 56import vga1_8x8 as font
 57import vga1_bold_16x32 as bold_font
 58import gc9a01
 59import tft_config
 60import tft_buttons
 61
 62
 63# ------------ set constants  ------------
 64
 65#
 66#   Adjust these constants to change the game play
 67#
 68
 69FLIPPER_SPEED = const(12)  # flipper speed
 70GRAVITY = -0.8  # gravity (negative y direction)
 71
 72# If the ball passes thru your flippers you may need to chage
 73# the FPS value. You can uncomment the print statement in the
 74# start_game() method to see how many frames per second your
 75# device is running.
 76
 77FPS = 20  # frames per second
 78
 79BALL_SLOW = const(2)  # launch speed for slow ball
 80BALL_FAST = const(3)  # launch speed for fast ball
 81
 82BACKGROUND = gc9a01.BLUE  # My favorite color
 83MULTIBALL_SCORE = const(30)  # points to start multiball
 84PRESSED = 0  # value when button is pressed
 85
 86# ------------ end constants ------------
 87
 88
 89def color_wheel(WheelPos):
 90    """returns a 565 color from the given position of the color wheel"""
 91    WheelPos = (255 - WheelPos) % 255
 92
 93    if WheelPos < 85:
 94        return gc9a01.color565(255 - WheelPos * 3, 0, WheelPos * 3)
 95
 96    if WheelPos < 170:
 97        WheelPos -= 85
 98        return gc9a01.color565(0, WheelPos * 3, 255 - WheelPos * 3)
 99
100    WheelPos -= 170
101    return gc9a01.color565(WheelPos * 3, 255 - WheelPos * 3, 0)
102
103
104def text_color():
105    """return the next color from the color wheel"""
106    color = 0
107    while True:
108        yield color_wheel(color)
109        color = (color + 1) % 255
110
111
112def scale_x(pos):
113    """scale position to gc9a01.en x coordinate"""
114    return int(pos.x * SCALE_X) + HOFS_X
115
116
117def scale_y(pos):
118    """scale position to gc9a01.en y coordinate"""
119    return int(HEIGHT - pos.y * SCALE_Y) + HOFS_Y
120
121
122def pos_to_tuple(pos):
123    """convert pos to gc9a01.en (x,y) coordinate tuple"""
124    return (scale_x(pos), scale_y(pos))
125
126
127# vector math ---------------------------------------
128
129
130class Vector2:
131    def __init__(self, x=0.0, y=0.0, c=gc9a01.WHITE):
132        """create a new vector"""
133        self.x = x
134        self.y = y
135        self.color = c
136
137    def __str__(self):
138        """return a string representation of this vector"""
139        return f"({self.x}, {self.y})"
140
141    def set(self, v):
142        """set this vector to the values of another vector"""
143        self.x = v.x
144        self.y = v.y
145        self.color = v.color
146
147    def clone(self):
148        """return a copy of this vector"""
149        return Vector2(self.x, self.y, self.color)
150
151    def add(self, v, s=1.0):
152        """add a vector scaled by s to this vector"""
153        self.x += v.x * s
154        self.y += v.y * s
155        return self
156
157    def add_vectors(self, a, b):
158        """add two vectors"""
159        self.x = a.x + b.x
160        self.y = a.y + b.y
161        return self
162
163    def subtract(self, v, s=1.0):
164        """subtract a vector scaled by s from this vector"""
165        self.x -= v.x * s
166        self.y -= v.y * s
167        return self
168
169    def subtract_vectors(self, a, b):
170        """subtract two vectors"""
171        self.x = a.x - b.x
172        self.y = a.y - b.y
173        return self
174
175    def length(self):
176        """return the length of this vector"""
177        return math.sqrt(self.x * self.x + self.y * self.y)
178
179    def scale(self, s):
180        """scale this vector by s"""
181        self.x *= s
182        self.y *= s
183        return self
184
185    def dot(self, v):
186        """return the dot product of this vector and another vector"""
187        return self.x * v.x + self.y * v.y
188
189    def perp(self):
190        """return a perpendicular vector"""
191        return Vector2(-self.y, self.x, self.color)
192
193
194# ----------------------------------------------
195
196
197def closest_point_on_segment(p, a, b):
198    """return the closest point on the segment ab to point p"""
199    ab = Vector2()
200    ab.subtract_vectors(b, a)
201    t = ab.dot(ab)
202    if t == 0.0:
203        return a.clone()
204    t = max(0.0, min(1.0, (p.dot(ab) - a.dot(ab)) / t))
205    closest = a.clone()
206    return closest.add(ab, t)
207
208
209# object classes ---------------------------------------
210
211
212class Ball:
213    """Class to track a ball"""
214
215    def __init__(self, radius, mass, pos, vel, restitution):
216        """create a new ball"""
217        self.radius = radius
218        self.mass = mass
219        self.restitution = restitution
220        self.pos = pos.clone()
221        self.last = pos.clone()
222        self.vel = vel.clone()
223        self.size = int(radius * SCALE_RADIUS)
224        self.wheel = 0
225
226    def simulate(self, dt, gravity):
227        """update the ball position in dt seconds"""
228        self.last.set(self.pos)
229        self.vel.add(gravity, dt)
230        self.pos.add(self.vel, dt)
231
232
233class Obstacle:
234    """Class to contain an obstacle"""
235
236    def __init__(self, radius, pos, pushVel):
237        """create a new obstacle"""
238        self.radius = radius
239        self.size = int(radius * SCALE_RADIUS)
240        self.pos = pos.clone()
241        self.pushVel = pushVel
242
243
244class Flipper:
245    """Class to contain a flipper"""
246
247    def __init__(self, radius, pos, length, restAngle, maxRotation, angularVelocity):
248        """create a new flipper"""
249
250        # fixed
251        self.radius = radius
252        self.size = int(radius * SCALE_RADIUS)
253        self.pos = pos.clone()
254        self.length = length
255        self.restAngle = restAngle
256        self.maxRotation = abs(maxRotation)
257        self.sign = math.copysign(1, maxRotation)
258        self.angularVelocity = angularVelocity
259
260        # variable
261        self.rotation = 0.0
262        self.prevRotation = 0.0
263        self.currentAngularVelocity = 0.0
264        self.pressed = False
265
266    def simulate(self, dt):
267        """update the flipper position in dt seconds"""
268        self.prevRotation = self.rotation
269        if self.pressed:
270            self.rotation = min(
271                self.rotation + dt * self.angularVelocity, self.maxRotation
272            )
273        else:
274            self.rotation = max(self.rotation - dt * self.angularVelocity, 0.0)
275        self.currentAngularVelocity = (
276            self.sign * (self.rotation - self.prevRotation) / dt
277        )
278
279    def select(self, pos):
280        """return True if pos is within the flipper"""
281        d = Vector2()
282        d.subtract_vectors(self.pos, pos)
283        return d.length() < self.length
284
285    def getTip(self, rotation=None):
286        """return the tip position of the flipper"""
287        if rotation is None:
288            rotation = self.rotation
289        angle = self.restAngle + self.sign * rotation
290        direction = Vector2(math.cos(angle), math.sin(angle))
291        tip = self.pos.clone()
292        return tip.add(direction, self.length)
293
294    def rot_pos_in_dir(self, pos, direction):
295        """rotate pos in direction"""
296        pos.add(direction, self.length)
297        result = self.pos.clone()
298        result.add(Vector2(0.0, self.radius))
299        return result
300
301    def direction_from_rot(self, rotation):
302        """return the direction of the flipper at rotation"""
303        if rotation is None:
304            rotation = self.rotation
305        angle = self.restAngle + self.sign * rotation
306        cos_angle = math.cos(angle)
307        sin_angle = math.sin(angle)
308        return Vector2(cos_angle, sin_angle)
309
310    def draw(self, rotation=None, color=gc9a01.WHITE):
311        """draw the flipper"""
312        if rotation is None:
313            rotation = self.rotation
314        angle = self.restAngle + self.sign * rotation
315        direction = Vector2(math.cos(angle), math.sin(angle))
316        tip = self.pos.clone()
317        tip.add(direction, self.length)
318
319        direction = self.direction_from_rot(rotation)
320        tip = self.pos.clone()
321        tip_top = self.rot_pos_in_dir(tip, direction)
322        tip_top.add(direction, self.length)
323
324        tip_bot = self.pos.clone()
325        tip_bot.add(Vector2(0.0, -self.radius))
326        base_top = self.rot_pos_in_dir(tip_bot, direction)
327        base_top.add(direction, 0)
328
329        base_bot = self.pos.clone()
330        base_bot.add(Vector2(0.0, -self.radius))
331        base_bot.add(direction, 0)
332
333        tft.fill_circle(scale_x(self.pos), scale_y(self.pos), self.size, color)
334
335        tft.line(
336            scale_x(base_top),
337            scale_y(base_top),
338            scale_x(tip_top),
339            scale_y(tip_top),
340            color,
341        )
342
343        tft.line(
344            scale_x(self.pos), scale_y(self.pos), scale_x(tip), scale_y(tip), color
345        )
346
347        tft.line(
348            scale_x(base_bot),
349            scale_y(base_bot),
350            scale_x(tip_bot),
351            scale_y(tip_bot),
352            color,
353        )
354
355        tft.fill_circle(scale_x(tip), scale_y(tip), self.size, color)
356
357
358class Table:
359    """Class to contain the table"""
360
361    def __init__(self):
362        """create a new table"""
363        self.gravity = Vector2(0.0, GRAVITY)
364        self.dt = 1.0 / FPS
365        self.ticks = int(self.dt * 1000)
366        self.game_over = False
367        self.score = 0
368        self.multiball = 0
369        self.ball = 3
370        self.gutter = 0
371        self.balls = []
372
373        self.border = [Vector2(0.74, 0.25), Vector2(0.995, 0.4), Vector2(0.995, 1.4)]
374
375        # draw arc from left wall to right wall using line segments
376        arcRadius = 0.5
377        arcCenter = Vector2(0.5, 1.375)
378        arcStartAngle = 0
379        arcEndAngle = math.pi
380        arcSegments = 11
381        arcAngleStep = (arcEndAngle - arcStartAngle) / arcSegments
382        for i in range(arcSegments + 1):
383            angle = arcStartAngle + i * arcAngleStep
384            x = arcCenter.x + arcRadius * math.cos(angle)
385            y = arcCenter.y + arcRadius * math.sin(angle)
386            self.border.append(Vector2(x, y))
387
388        self.border.append(Vector2(0, 0.4))
389        self.border.append(Vector2(0.26, 0.25))
390        self.gutter = len(self.border)
391        self.border.append(Vector2(0.26, 0.0))  # gutter floor
392        self.border.append(Vector2(0.74, 0.0))  # gutter floor
393
394        # convert border to scaled polygon
395        self.wall = list(map(pos_to_tuple, self.border))
396        self.wall.append(pos_to_tuple(self.border[0]))
397
398        # obstacles
399        self.obstacles = [
400            Obstacle(0.04, Vector2(0.10, 1.68), 0.8),
401            Obstacle(0.08, Vector2(0.5, 1.45, gc9a01.RED), 1.5),
402            Obstacle(0.08, Vector2(0.74, 1.2, gc9a01.RED), 1.5),
403            Obstacle(0.08, Vector2(0.26, 1.2, gc9a01.RED), 1.5),
404            Obstacle(0.08, Vector2(0.5, 0.95, gc9a01.RED), 1.5),
405            Obstacle(0.04, Vector2(0.13, 0.8, gc9a01.YELLOW), 1.3),
406            Obstacle(0.04, Vector2(0.87, 0.8, gc9a01.YELLOW), 1.3),
407            Obstacle(0.04, Vector2(0.15, 0.6, gc9a01.GREEN), 1.2),
408            Obstacle(0.04, Vector2(0.85, 0.6, gc9a01.GREEN), 1.2),
409        ]
410
411        # flippers
412        radius = 0.035
413        length = 0.185
414        maxRotation = 1.0
415        restAngle = 0.5
416        angularVelocity = FLIPPER_SPEED
417
418        left_pos = Vector2(0.26, 0.22)
419        right_pos = Vector2(0.74, 0.22)
420
421        self.flippers = [
422            Flipper(radius, left_pos, length, -restAngle, maxRotation, angularVelocity),
423            Flipper(
424                radius,
425                right_pos,
426                length,
427                math.pi + restAngle,
428                -maxRotation,
429                angularVelocity,
430            ),
431        ]
432
433    def reset(self):
434        """reset the table"""
435        self.game_over = False
436        self.ball = 3
437        self.score = 0
438
439        print_right("M  ", 0.25)
440
441        update_score()
442        update_ball()
443
444    def add_ball(self):
445        """add a ball to the table"""
446        radius = 0.03
447        mass = math.pi * radius * radius
448        pos = Vector2(0.95, 0.5, gc9a01.WHITE)
449        vel = Vector2(0, random.uniform(BALL_SLOW, BALL_FAST))
450        self.balls.append(Ball(radius, mass, pos, vel, 0.2))
451
452    def ball_reset(self):
453        """reset the ball"""
454        table.multiball = 0
455        self.draw_border()
456        self.draw_obstacles()
457        self.draw_flippers()
458
459        update_ball()
460        update_score()
461        ball_countdown()
462        self.add_ball()
463
464    def draw_border(self):
465        """draw the walls of the table"""
466        tft.polygon(self.wall, 0, 0, gc9a01.WHITE)
467
468    def draw_balls(self):
469        """draw the balls on the table"""
470        for ball in self.balls:
471            x = scale_x(ball.pos)
472            y = scale_y(ball.pos)
473
474            tft.fill_circle(
475                scale_x(ball.last), scale_y(ball.last), ball.size, BACKGROUND
476            )
477
478            tft.fill_circle(x, y, ball.size, ball.pos.color)
479
480    def draw_obstacles(self):
481        """draw the obstacles on the table"""
482        for obstacle in self.obstacles:
483            tft.fill_circle(
484                scale_x(obstacle.pos),
485                scale_y(obstacle.pos),
486                obstacle.size,
487                obstacle.pos.color,
488            )
489
490    def draw_flippers(self):
491        """draw the flippers on the table"""
492        for flipper in self.flippers:
493            if flipper.currentAngularVelocity != 0:
494                flipper.draw(flipper.prevRotation, BACKGROUND)
495            flipper.draw()
496
497    def simulate(self):
498        """simulate the table"""
499        for flipper in self.flippers:
500            flipper.simulate(self.dt)
501
502        for i, ball in enumerate(self.balls):
503            ball.simulate(self.dt, self.gravity)
504
505            for j in range(i + 1, len(self.balls)):
506                ball2 = self.balls[j]
507                handle_ball_ball_collision(ball, ball2)
508
509            for obstacle in self.obstacles:
510                handle_ball_obstacle_collision(ball, obstacle)
511
512            for flipper in self.flippers:
513                handle_ball_flipper_collision(ball, flipper)
514
515            if handle_ball_border_collision(ball, self.border) == self.gutter:
516                tft.fill_circle(
517                    scale_x(ball.last), scale_y(ball.last), ball.size, BACKGROUND
518                )
519
520                balls = len(self.balls)
521                if balls == 1:
522                    self.ball -= 1
523                    update_ball()
524
525                if self.ball > 0:
526                    self.balls.remove(ball)
527                    if balls == 1:
528                        self.ball_reset()
529                else:
530                    self.game_over = True
531
532
533# ------------ collision handling ------------
534
535
536def handle_ball_ball_collision(ball1, ball2):
537    """handle a collision between two balls"""
538    restitution = min(ball1.restitution, ball2.restitution)
539    direction = Vector2()
540    direction.subtract_vectors(ball2.pos, ball1.pos)
541    d = direction.length()
542    if d == 0.0 or d >= ball1.radius + ball2.radius:
543        return
544
545    tft.fill_circle(scale_x(ball1.pos), scale_y(ball1.pos), ball1.size, BACKGROUND)
546    tft.fill_circle(scale_x(ball2.pos), scale_y(ball2.pos), ball2.size, BACKGROUND)
547
548    direction.scale(1.0 / d)
549
550    corr = (ball1.radius + ball2.radius - d) / 2.0
551    ball1.pos.add(direction, -corr)
552    ball2.pos.add(direction, corr)
553
554    v1 = ball1.vel.dot(direction)
555    v2 = ball2.vel.dot(direction)
556
557    m1 = ball1.mass
558    m2 = ball2.mass
559
560    newV1 = (m1 * v1 + m2 * v2 - m2 * (v1 - v2) * restitution) / (m1 + m2)
561    newV2 = (m1 * v1 + m2 * v2 - m1 * (v2 - v1) * restitution) / (m1 + m2)
562
563    ball1.vel.add(direction, newV1 - v1)
564    ball2.vel.add(direction, newV2 - v2)
565
566
567# ------------------------
568
569
570def handle_ball_obstacle_collision(ball, obstacle):
571    """handle a collision between a ball and an obstacle"""
572    direction = Vector2()
573    direction.subtract_vectors(ball.pos, obstacle.pos)
574    d = direction.length()
575    if d == 0.0 or d >= ball.radius + obstacle.radius:
576        return
577
578    direction.scale(1.0 / d)
579
580    corr = ball.radius + obstacle.radius - d
581    ball.pos.add(direction, corr)
582
583    v = ball.vel.dot(direction)
584    ball.vel.add(direction, obstacle.pushVel - v)
585
586    table.score += 1
587
588    ball_count = len(table.balls)
589    if ball_count == 1:
590        table.multiball += 1
591        if table.multiball == MULTIBALL_SCORE:
592            table.multiball = 0
593            table.add_ball()
594
595
596# ------------------------
597
598
599def handle_ball_flipper_collision(ball, flipper):
600    """handle a collision between a ball and a flipper"""
601    closest = closest_point_on_segment(ball.pos, flipper.pos, flipper.getTip())
602    direction = Vector2()
603    direction.subtract_vectors(ball.pos, closest)
604    d = direction.length()
605    if d == 0.0 or d >= ball.radius + flipper.radius:
606        return
607
608    direction.scale(1.0 / d)
609
610    corr = ball.radius + flipper.radius - d
611    ball.pos.add(direction, corr)
612
613    # update velocitiy
614    radius = closest.clone()
615    radius.add(direction, flipper.radius)
616    radius.subtract(flipper.pos)
617    surfaceVel = radius.perp()
618    surfaceVel.scale(flipper.currentAngularVelocity)
619    v = ball.vel.dot(direction)
620    vnew = surfaceVel.dot(direction)
621    ball.vel.add(direction, vnew - v)
622
623
624# ------------------------
625
626
627def handle_ball_border_collision(ball, border):
628    """handle a collision between a ball and a border"""
629    # find closest segment
630    d = Vector2()
631    closest = Vector2()
632    ab = Vector2()
633    normal = Vector2()
634    wall = 0
635    minDist = 0.0
636
637    for i, a in enumerate(border):
638        b = border[(i + 1) % len(border)]
639        c = closest_point_on_segment(ball.pos, a, b)
640        d.subtract_vectors(ball.pos, c)
641        dist = d.length()
642        if i == 0 or dist < minDist:
643            wall = i
644            minDist = dist
645            closest.set(c)
646            ab.subtract_vectors(b, a)
647            normal = ab.perp()
648
649    # push out
650    d.subtract_vectors(ball.pos, closest)
651    dist = d.length()
652    if dist == 0.0:
653        d.set(normal)
654        dist = normal.length()
655
656    d.scale(1.0 / dist)
657
658    if d.dot(normal) >= 0.0:
659        if dist > ball.radius:
660            return 0
661        ball.pos.add(d, ball.radius - dist)
662
663    else:
664        ball.pos.add(d, -(dist + ball.radius))
665
666    # update velocity
667    v = ball.vel.dot(d)
668    vnew = abs(v) * ball.restitution
669
670    ball.vel.add(d, vnew - v)
671    return wall
672
673
674# ------------ Text routines ------------
675
676
677def center_on(text, y, color=gc9a01.WHITE, fnt=font):
678    """center text on y"""
679    x = (WIDTH >> 1) - ((fnt.WIDTH * len(text)) >> 1)
680    tft.text(
681        fnt, text, x + HOFS_X, int(HEIGHT - y * SCALE_Y) + HOFS_Y, color, BACKGROUND
682    )
683
684
685def print_at(text, x, y, color=gc9a01.WHITE, fnt=font):
686    """print text at x,y"""
687    tft.text(
688        fnt,
689        text,
690        int(x * SCALE_X) + HOFS_X,
691        int(HEIGHT - y * SCALE_Y) + HOFS_Y,
692        color,
693        BACKGROUND,
694    )
695
696
697def print_right(text, y, color=gc9a01.WHITE, fnt=font):
698    """print text right aligned at y"""
699    x = WIDTH - (fnt.WIDTH * len(text))
700    tft.text(
701        fnt, text, x + HOFS_X, int(HEIGHT - y * SCALE_Y) + HOFS_Y, color, BACKGROUND
702    )
703
704
705def update_score():
706    """update the score"""
707    print_right(f"{MULTIBALL_SCORE-table.multiball:2}", 0.25)
708    print_right(f"{table.score:04}", 0.08)
709
710
711def update_ball():
712    """update the ball count"""
713    print_at(f"B {table.ball}", 0.0, 0.08)
714
715
716def ball_countdown():
717    """countdown before the ball is released"""
718    for i in range(3, 0, -1):
719        center_on(f"{i}", 1.3, gc9a01.WHITE, bold_font)
720        time.sleep(1)
721
722    center_on(" ", 1.3, gc9a01.WHITE, bold_font)
723    table.draw_border()
724    table.draw_obstacles()
725
726
727def print_game_over(color=gc9a01.RED):
728    """print game over"""
729    center_on("GAME", 1.65, color, bold_font)
730    center_on("OVER", 1.30, color, bold_font)
731    center_on(" Press ", 0.82, color, font)
732    center_on(" Button ", 0.72, color, font)
733    center_on(" To ", 0.62, color, font)
734    center_on(" Start ", 0.52, color, font)
735
736
737def game_over():
738    """game over, man, game over"""
739    print_game_over()
740    time.sleep(1)
741
742    # wait for buttons to be released
743    if left_flipper and right_flipper:
744        while left_flipper.value() == PRESSED or right_flipper.value() == PRESSED:
745            time.sleep(0.1)
746
747    # Color cycle game over message while waitiing for a button to be pressed
748    color = text_color()
749
750    if left_flipper and right_flipper:
751        while left_flipper.value() != PRESSED and right_flipper.value() != PRESSED:
752            print_game_over(next(color))
753            time.sleep(0.01)
754
755    tft.fill(BACKGROUND)
756
757
758# ------------ the big show ------------
759
760
761def start_game():
762    """start the game and loop"""
763    try:
764        while True:
765            table.reset()
766            table.ball_reset()
767
768            while table.game_over is False:
769                last = time.ticks_ms()
770
771                table.flippers[0].pressed = (
772                    left_flipper and left_flipper.value() == PRESSED
773                )
774                table.flippers[1].pressed = (
775                    right_flipper and right_flipper.value() == PRESSED
776                )
777
778                table.simulate()
779
780                table.draw_border()
781                table.draw_obstacles()
782
783                table.draw_flippers()
784                table.draw_balls()
785                update_score()
786
787                # print("FPS: ", 1000 / (time.ticks_ms() - last))
788                if time.ticks_ms() - last < table.ticks:
789                    time.sleep_ms(table.ticks - (time.ticks_ms() - last))
790
791            game_over()
792
793    finally:
794        if hasattr(tft, "deinit") and callable(tft.deinit):
795            tft.deinit()
796
797
798# ------------ set up the buttons ------------
799
800OFS_WIDTH = 92
801OFS_HEIGHT = 48
802
803buttons = tft_buttons.Buttons()
804
805left_flipper = buttons.left
806right_flipper = buttons.right
807
808# ------------ set up the display ------------
809
810tft = tft_config.config()
811tft.init()
812tft.fill(BACKGROUND)
813
814HEIGHT = tft.height() - OFS_HEIGHT
815WIDTH = tft.width() - OFS_WIDTH
816MAX_HEIGHT = 1.88
817HOFS_X = OFS_WIDTH >> 1
818HOFS_Y = OFS_HEIGHT >> 1
819SCALE_X = WIDTH
820SCALE_Y = HEIGHT / MAX_HEIGHT
821SCALE_RADIUS = min(SCALE_X, SCALE_Y)
822
823# ------- set up the table and start game ---------
824
825table = Table()
826start_game()