.. include:: /index.rst :start-after: start_hello_message :end-before: end_hello_message .. _py_whack_a_mole: 4.15 Whack-a-Mole-Spiel ====================================== **Einführung** In dieser Lektion erstellen Sie ein **Whack-a-Mole-Spiel** mit dem Fusion HAT+. Vier PWM-gesteuerte LEDs fungieren als „Maulwürfe“, die in einem 1-Sekunden-Zyklus sanft ein- und ausblenden. Ihr Ziel ist es, den richtigen Button zu drücken, wenn die entsprechende LED aufleuchtet. Ein Summer gibt akustisches Feedback: - **Richtiges Loch → kurzer hoher Piepton** - **Falsches Loch → GAME OVER (tiefer Ton)** Ein SSD1306-OLED-Display zeigt während des Spiels Ihren **aktuellen Punktestand** und bei Spielende den **Endpunktestand** an. Dieses Projekt kombiniert PWM-Helligkeitssteuerung, Flankenerkennung von Tasten, Timing-Schleifen und eine einfache Spielzustandsmaschine. ---------------------------------------------- **Was Sie benötigen** Die folgenden Komponenten werden für dieses Projekt benötigt: .. list-table:: :widths: 30 20 :header-rows: 1 * - KOMPONENTENBESCHREIBUNG - KAUFLINK * - :ref:`cpn_wires` - |link_wires_buy| * - :ref:`cpn_led` - |link_led_buy| * - :ref:`cpn_button` - |link_button_buy| * - :ref:`cpn_buzzer` - |link_passive_buzzer_buy| * - :ref:`cpn_oled` - \- * - :ref:`cpn_fusion_hat` - \- * - Raspberry Pi - \- ---------------------------------------------- **Verdrahtungsdiagramm** Orientieren Sie sich am folgenden Verdrahtungsdiagramm, um die Komponenten zu verbinden: .. image:: img/fzz/4.15_whack_a_mole_bb.png :width: 80% :align: center ---------------------------------------------- **Einrichtungsschritte** #. Installieren Sie die benötigten Bibliotheken: .. raw:: html .. code-block:: shell sudo pip3 install adafruit-circuitpython-ssd1306 --break #. Starten Sie das Spiel aus dem ``ai-lab-kit``-Verzeichnis: .. raw:: html .. code-block:: shell cd ~/ai-lab-kit/python/ sudo python3 4.15_WhackAMole.py #. Wenn das Skript ausgeführt wird: * Eine LED blendet ein und aus (Dreieckswelle). * Drücken Sie **nur die Taste der leuchtenden LED** innerhalb des 1-Sekunden-Fensters: - Richtig → Punktestand erhöht sich, der nächste „Maulwurf“ erscheint - Falsch → Summer gibt Fehlerton aus → **GAME OVER** * Das OLED zeigt während des Spiels nur den aktuellen Punktestand an * Bei Spielende zeigt das OLED „GAME OVER“ und den Endpunktestand * Drücken Sie **Ctrl+C**, um das Programm zu beenden ---------------------------------------------- **Code** Hier ist das vollständige Python-Skript für das Whack-a-Mole-Spiel: .. raw:: html .. code-block:: python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Whack-a-Mole using fusion_hat: - 4 PWM LEDs (smooth fade in/out; whole cycle = 1.0s) - 4 buttons via fusion_hat.Pin (PULL_DOWN; pressed=1) - Buzzer via fusion_hat.Buzzer(PWM(...)) for short feedback tone - SSD1306 OLED shows only the current hit count during game - If a non-lit button is pressed -> GAME OVER with final score """ import time import random from typing import List, Optional # ---- fusion_hat GPIO / PWM / Buzzer ---- from fusion_hat.pwm import PWM from fusion_hat.pin import Pin, Mode, Pull from fusion_hat.modules import Buzzer # ---- OLED ---- from PIL import Image, ImageDraw, ImageFont import adafruit_ssd1306 import board # ===================== Port map (EDIT HERE) ===================== # 4 PWM LED ports of Fusion HAT+ LED_PORTS = ['P0', 'P1', 'P2', 'P3'] # 4 button pins (BCM numbering). Example here uses 17/4/27/22. # Buttons are configured as PULL_DOWN, so pressed -> value==1 BTN_PINS = [17, 4, 27, 22] # Buzzer on a PWM-capable Fusion HAT+ port BUZZER_PORT = 'P4' # =============================================================== # ===================== OLED setup ===================== WIDTH, HEIGHT = 128, 64 i2c = board.I2C() oled = adafruit_ssd1306.SSD1306_I2C(WIDTH, HEIGHT, i2c, addr=0x3C) oled.fill(0) oled.show() image = Image.new("1", (WIDTH, HEIGHT)) draw = ImageDraw.Draw(image) font = ImageFont.load_default() def draw_score(score: int): """Render in-game screen: only the current score.""" draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0) msg = f"Score: {score}" tw, th = font.getbbox(msg)[2:] draw.text(((WIDTH - tw) // 2, (HEIGHT - th) // 2), msg, font=font, fill=255) oled.image(image) oled.show() def draw_game_over(score: int): """Render GAME OVER screen with final score.""" draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0) title = "GAME OVER" tw, th = font.getbbox(title)[2:] draw.text(((WIDTH - tw) // 2, 8), title, font=font, fill=255) line = f"Score: {score}" lw, lh = font.getbbox(line)[2:] draw.text(((WIDTH - lw) // 2, 8 + th + 8), line, font=font, fill=255) oled.image(image) oled.show() # ===================== Hardware objects ===================== # LEDs (PWM) leds = [PWM(p) for p in LED_PORTS] def led_set(idx: int, duty_pct: float): """Set LED brightness (0..100%).""" duty = max(0.0, min(100.0, float(duty_pct))) leds[idx].pulse_width_percent(duty) def all_leds_off(): for i in range(len(leds)): leds[i].pulse_width_percent(0) # Buttons (Pin, PULL_DOWN => pressed=1) buttons = [Pin(pin, mode=Mode.IN, pull=Pull.DOWN) for pin in BTN_PINS] def read_buttons() -> List[int]: """Read all buttons; return their current logic levels (0/1).""" return [b.value() if callable(getattr(b, "value", None)) else int(b.value) for b in buttons] # Buzzer (tonal) tb = Buzzer(PWM(BUZZER_PORT)) def beep_hit(): """Short bright tone for a correct hit.""" tb.play('A5', 0.08) # A5 ~ 880 Hz short chirp tb.off() def beep_miss(): """Lower/longer tone for a miss (game over).""" tb.play('E4', 0.25) # E4 ~ 330 Hz tb.off() # ===================== LED fade (1s total) ===================== def triangle01(u: float) -> float: """ Triangle wave in [0,1], rising 0->1 over first half, then 1->0. u in [0,1]. """ return 2*u if u < 0.5 else 2*(1.0 - u) def fade_led_with_button_check(active_idx: int, prev_states: List[int], duration: float = 1.0, step: float = 0.01, gamma: float = 1.8) -> (bool, Optional[List[int]]): """ Fade one LED (active_idx) with triangle brightness over 'duration' seconds. While fading, poll buttons at 'step' interval: - If matching button pressed (rising edge) -> immediately turn off LED, return (True, new_states). - If non-matching button pressed (rising edge) -> game over: return (False, new_states) and let caller handle. If no press -> return (None, new_states) to continue game. Returns: (hit, states) hit=True -> correct hit hit=False -> wrong button (game over) hit=None -> no button pressed in this cycle """ t0 = time.monotonic() states = prev_states[:] while True: t = time.monotonic() - t0 if t > duration: break # Triangle brightness 0..1 then 1..0 u = triangle01(t / duration) # Gamma correction for perceived linearity level = u ** gamma duty = 3 + 97 * level # keep a tiny floor so LED is visible at the start led_set(active_idx, duty) # Poll buttons and detect rising edges curr = read_buttons() for i, (p, c) in enumerate(zip(states, curr)): if p == 0 and c == 1: # rising edge = just pressed if i == active_idx: # Correct hit all_leds_off() beep_hit() return True, curr else: # Wrong hole -> game over all_leds_off() beep_miss() return False, curr states = curr time.sleep(step) # Finished fade with no press all_leds_off() return None, states # ===================== Game loop ===================== def pick_next(prev_idx: Optional[int]) -> int: """Pick a random index different from prev_idx.""" choices = list(range(4)) if prev_idx in choices: choices.remove(prev_idx) return random.choice(choices) def main(): random.seed(time.time()) score = 0 last_idx = None states = read_buttons() # baseline for edge detection draw_score(score) while True: # Choose which LED lights next idx = pick_next(last_idx) last_idx = idx # Run a 1.0s fade cycle, checking button presses hit, states = fade_led_with_button_check(idx, states, duration=1.0, step=0.01, gamma=1.8) if hit is False: # Wrong button pressed -> Game Over draw_game_over(score) break elif hit is True: # Correct hit -> score++ score += 1 draw_score(score) else: # No press during this mole -> no score change; continue pass # stop all outputs all_leds_off() if __name__ == "__main__": try: main() except KeyboardInterrupt: all_leds_off() oled.fill(0) oled.show() tb.off() print("\nExited.") ---------------------------------------------- **Code verstehen** 1. **LED-Fade-Animation** Jede LED führt eine 1-Sekunden-Dreieckswellen-Fade-Animation aus: - Die Helligkeit steigt von 0 → 100 % - Danach fällt sie von 100 % → 0 - Eine Gamma-Korrektur (``u ** gamma``) verbessert die visuelle Linearität .. code-block:: python def fade_led_with_button_check(active_idx: int, prev_states: List[int], duration: float = 1.0, step: float = 0.01, gamma: float = 1.8) -> (bool, Optional[List[int]]): ... 2. **Tasten-Flankenerkennung** ``read_buttons()`` liest alle vier Tasten aus. Eine **steigende Flanke** (``previous=0, current=1``) bedeutet „Taste wurde gerade gedrückt“. - Wenn die gedrückte Taste zur leuchtenden LED passt → **richtiger Treffer** - Andernfalls → **falscher Treffer → GAME OVER** .. code-block:: python def read_buttons() -> List[int]: """Liest alle Tasten und gibt deren aktuelle Logikpegel (0/1) zurück.""" return [b.value() if callable(getattr(b, "value", None)) else int(b.value) for b in buttons] 3. **Buzzer-Rückmeldung** - ``A5`` kurzer hoher Ton = Erfolg - ``E4`` längerer Ton = Fehlversuch / Spielende .. code-block:: python # Buzzer (Tonsteuerung) tb = Buzzer(PWM(BUZZER_PORT)) def beep_hit(): """Kurzer heller Ton für einen korrekten Treffer.""" tb.play('A5', 0.08) # A5 ~ 880 Hz kurzer Piepton tb.off() def beep_miss(): """Tieferer/längerer Ton für einen Fehlversuch (Game Over).""" tb.play('E4', 0.25) # E4 ~ 330 Hz tb.off() 4. **Spielschleife** - Wählt zufällig eine von vier LEDs aus (niemals zweimal hintereinander dieselbe) - Führt einen Fade-Zyklus aus und prüft dabei Tastendrücke - Aktualisiert den Punktestand oder beendet das Spiel .. code-block:: python while True: # Bestimmen, welche LED als Nächstes aufleuchtet idx = pick_next(last_idx) last_idx = idx # 1,0-s-Fade-Zyklus ausführen und Tastendrücke prüfen hit, states = fade_led_with_button_check(idx, states, duration=1.0, step=0.01, gamma=1.8) ... 5. **OLED-Anzeige** - Zeigt während des Spiels nur den aktuellen Punktestand - Zeigt bei Spielende „GAME OVER“ und den Endpunktestand .. code-block:: python def draw_score(score: int): """Zeigt während des Spiels nur den aktuellen Punktestand an.""" draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0) msg = f"Score: {score}" tw, th = font.getbbox(msg)[2:] draw.text(((WIDTH - tw) // 2, (HEIGHT - th) // 2), msg, font=font, fill=255) oled.image(image) oled.show() ... ---------------------------------------------- **Fehlerbehebung** - **Tastendrücke werden nicht erkannt** - Stellen Sie sicher, dass die Tasten korrekt mit BCM-Pinnummerierung verbunden sind - Bestätigen Sie, dass ``PULL_DOWN`` verwendet wird, sodass der gedrückte Zustand = 1 ist - Überprüfen Sie die korrekte Masseverbindung - **LEDs flackern statt sanft zu dimmen** - Verringern Sie die ``step``-Zeit für flüssigere Updates (z. B. 0.005) - Stellen Sie sicher, dass die PWM-Ports ordnungsgemäße Duty-Cycle-Aktualisierungen unterstützen - **Das Spiel endet sofort ohne Tastendruck** - Ein schwebender Tasten-Pin kann als 1 gelesen werden - Stellen Sie sicher, dass Pull-Down-Widerstände korrekt konfiguriert sind - **Der Punktestand erhöht sich nicht** - Überprüfen Sie, ob der LED-Index mit dem Tasten-Index übereinstimmt - Stellen Sie sicher, dass LED-Ports und Tasten-Pins in der richtigen Reihenfolge angeordnet sind ---------------------------------------------- **Probieren Sie es selbst aus** 1. **Modus mit variabler Geschwindigkeit** Verkürzen Sie die Fade-Dauer schrittweise, um das Spiel schwieriger zu machen. 2. **Multi-LED-Modus** Lassen Sie zwei LEDs gleichzeitig aufleuchten – der Spieler muss eine von beiden treffen. 3. **Lebenssystem** Erlauben Sie drei Fehler, bevor das Spiel endet. 4. **Highscore-Speicherung** Speichern Sie den höchsten Punktestand in einer Datei und zeigen Sie ihn beim Start an. 5. **Animierter Startbildschirm** Fügen Sie einen Startbildschirm hinzu, bevor das Spiel beginnt. Diese Ideen können Whack-a-Mole in ein vollständiges Arcade-Spiel verwandeln!