.. 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!