diff --git a/main.py b/main.py index b90b317..103062e 100644 --- a/main.py +++ b/main.py @@ -43,12 +43,25 @@ # Windows voraus (Startmenue-Suche) und dass der Pico gerade am PC steckt, # dessen Fokus auf dem Desktop liegt (kein Textfeld o.ae. offen), sonst # landet der getippte Text an der falschen Stelle. +# +# Eigene Profile direkt am Geraet: letztes Profil "Neu" (per Encoder +# erreichbar) -> Taste1 = neues Profil anlegen (Name + 4 Tastenaktionen +# ueber Encoder/Taster zusammenklicken), Taste2 = eigenes Profil wieder +# loeschen. Landet persistent in CUSTOM_PROFILES_FILE im Flash-Speicher des +# Pico und uebersteht einen Neustart - kein PC/Thonny noetig. Siehe die +# Kommentare beim "Profil-Editor" weiter unten im Code fuer die genaue +# Bedienung. import time import random from array import array from machine import Pin, I2C, PWM +try: + import ujson as json # aeltere MicroPython-Firmware kennt nur "ujson" +except ImportError: + import json + import usb.device from usb.device.keyboard import KeyboardInterface, KeyCode from usb.device.hid import HIDInterface @@ -128,7 +141,33 @@ SCREENSAVER_ENABLED = True SCREENSAVER_TIMEOUT_MS = 30000 # 30s Inaktivitaet bis der Screensaver startet SCREENSAVER_FRAME_MS = 80 # Bildwiederholrate der Animation SCREENSAVER_ANIMATION = "stars" # Startwert - "stars"|"pacman"|"matrix"|"bounce" -PACMAN_SPEED_PX = 1 # Pac-Man Bewegung pro Bild in Pixel - kleiner = langsamer +PACMAN_SPEED_PX = 3 # Pac-Man Bewegung pro Bild in Pixel - kleiner = langsamer + +# --- Mini-Spiele ("Games"-Profil, Taste 1-4 startet direkt ein Spiel) --- +# Verlassen jederzeit moeglich: Encoder-Taster druecken. +GAME_FRAME_MS = 60 # Bildrate fuer Dino/Flappy (ms zwischen Bildern) +GAME_DINO_GRAVITY = 1 # Fallbeschleunigung pro Bild +GAME_DINO_JUMP_VELOCITY = -7 # Sprungimpuls (negativ = nach oben) +GAME_DINO_SPEED_START = 3 # Anfangsgeschwindigkeit der Hindernisse (px/Bild) +GAME_FLAPPY_GRAVITY = 1 # Fallbeschleunigung pro Bild +GAME_FLAPPY_FLAP_VELOCITY = -3 # Aufwaerts-Impuls bei Tastendruck +GAME_FLAPPY_GAP_H = 13 # Luecke zwischen oberem/unterem Rohr (px) +GAME_FLAPPY_SPEED = 2 # Rohr-Geschwindigkeit (px/Bild) +GAME_TETRIS_COLS = 10 # Spielfeldbreite in Zellen +GAME_TETRIS_ROWS = 16 # Spielfeldhoehe in Zellen +GAME_TETRIS_CELL_PX = 2 # Zellengroesse in Pixel +GAME_TETRIS_DROP_MS_START = 500 # Anfangs-Falldauer pro Reihe (ms) +GAME_PACMAN_COLS = 32 # Labyrinth-Breite in Zellen +GAME_PACMAN_ROWS = 8 # Labyrinth-Hoehe in Zellen +GAME_PACMAN_CELL_PX = 4 # Zellengroesse in Pixel +GAME_PACMAN_MOVE_MS = 160 # Bewegungstakt Spieler (ms pro Feld) +GAME_PACMAN_GHOST_MOVE_EVERY = 2 # Geist bewegt sich nur bei jedem n-ten Takt + +# --- Eigene Profile (direkt am Geraet anlegen, siehe "Neu"-Profil) --- +# Landen persistent (ueberleben einen Neustart) in dieser Datei im Flash- +# Speicher des Pico. Die festen Profile oben in PROFILES bleiben davon +# unberuehrt - geloescht werden kann nur, was man selbst angelegt hat. +CUSTOM_PROFILES_FILE = "/custom_profiles.json" # --------------------------------------------------------------------------- @@ -305,6 +344,12 @@ def do_action(action): set_led_mode(action["mode"]) elif kind == "screensaver": set_screensaver_animation(action["animation"]) + elif kind == "game": + run_game(action["game"]) + elif kind == "new_profile": + wizard_create_profile() + elif kind == "delete_profile": + wizard_delete_profile() # "none" -> nichts tun @@ -355,7 +400,7 @@ PROFILES = [ {"type": "open", "query": "Opera GX"}, {"type": "open", "query": "Visual Studio Code"}, {"type": "open", "query": "Thunderbird"}, - {"type": "open", "query": "Bitwarden"}, + {"type": "open", "query": "Vaultwarden"}, ], }, { @@ -392,8 +437,72 @@ PROFILES = [ {"type": "screensaver", "animation": "bounce"}, ], }, + { + # Mini-Spiele: Taste druecken startet direkt das jeweilige Spiel. + # Steuerung siehe Kommentar am Anfang des jeweiligen Spiels weiter + # unten im Code. Encoder-Taster druecken beendet ein Spiel jederzeit. + "name": "Games", + "labels": ["Dino", "Flpy", "Tetrs", "Pacmn"], + "actions": [ + {"type": "game", "game": "dino"}, + {"type": "game", "game": "flappy"}, + {"type": "game", "game": "tetris"}, + {"type": "game", "game": "pacman"}, + ], + }, ] +# --------------------------------------------------------------------------- +# Eigene Profile: persistentes Laden von Flash + "Neu"-Menuepunkt anhaengen. +# BUILTIN_PROFILE_COUNT merkt sich, wo die festen Profile aufhoeren, damit +# der Loeschen-Dialog nur selbst angelegte Profile zur Auswahl anbietet. +# --------------------------------------------------------------------------- +BUILTIN_PROFILE_COUNT = len(PROFILES) + + +def _load_custom_profiles(): + try: + with open(CUSTOM_PROFILES_FILE) as f: + data = json.load(f) + if isinstance(data, list): + return data + except Exception: + pass + return [] + + +def _save_custom_profiles(profiles): + try: + with open(CUSTOM_PROFILES_FILE, "w") as f: + json.dump(profiles, f) + return True + except Exception: + return False + + +def _custom_profiles_list(): + """Aktuell selbst angelegte Profile (ohne feste Profile, ohne den + 'Neu'-Menuepunkt) - das wird auf Flash gespeichert.""" + return PROFILES[BUILTIN_PROFILE_COUNT:-1] + + +NEW_PROFILE_MENU_ENTRY = { + # Bleibt immer der letzte Eintrag in PROFILES (siehe wizard_create_profile, + # die neue Profile davor einfuegt). + "name": "Neu", + "labels": ["Erstl", "Losch", "-", "-"], + "actions": [ + {"type": "new_profile"}, + {"type": "delete_profile"}, + NONE_ACTION, + NONE_ACTION, + ], +} + +for _cp in _load_custom_profiles(): + PROFILES.append(_cp) +PROFILES.append(NEW_PROFILE_MENU_ENTRY) + # --------------------------------------------------------------------------- # Hardware: Taster # --------------------------------------------------------------------------- @@ -550,10 +659,17 @@ _encoder_last_change_ms = 0 _encoder_step_accum = 0 profile_dirty = True # erzwingt initiales Display-Zeichnen +# Waehrend der Profil-Editor ("Neu"-Menue) laeuft, soll das Drehen des +# Encoders NICHT durch die Profile blaettern, sondern Listen im Editor +# scrollen. _encoder_mode steuert das um, _wiz_encoder_delta sammelt die +# Schritte fuer den Editor (siehe Mini-Spiele/Profil-Editor weiter unten). +_encoder_mode = "profile" # "profile" | "wizard" +_wiz_encoder_delta = 0 + def _encoder_irq(pin): global profile_index, _encoder_last_change_ms, profile_dirty, _encoder_step_accum - global _last_activity_ms, _screensaver_active + global _last_activity_ms, _screensaver_active, _wiz_encoder_delta now = time.ticks_ms() if time.ticks_diff(now, _encoder_last_change_ms) < ENCODER_DEBOUNCE_MS: return @@ -573,13 +689,19 @@ def _encoder_irq(pin): _encoder_step_accum += 1 if forward else -1 if _encoder_step_accum >= ENCODER_STEPS_PER_CLICK: - profile_index = (profile_index + 1) % len(PROFILES) _encoder_step_accum = 0 - profile_dirty = True + if _encoder_mode == "wizard": + _wiz_encoder_delta += 1 + else: + profile_index = (profile_index + 1) % len(PROFILES) + profile_dirty = True elif _encoder_step_accum <= -ENCODER_STEPS_PER_CLICK: - profile_index = (profile_index - 1) % len(PROFILES) _encoder_step_accum = 0 - profile_dirty = True + if _encoder_mode == "wizard": + _wiz_encoder_delta -= 1 + else: + profile_index = (profile_index - 1) % len(PROFILES) + profile_dirty = True encoder_clk.irq(trigger=Pin.IRQ_FALLING, handler=_encoder_irq) @@ -781,6 +903,714 @@ def _wake_screensaver(): draw_profile() +# --------------------------------------------------------------------------- +# Mini-Spiele (ueber das "Games"-Profil startbar) +# --------------------------------------------------------------------------- +# Alle vier Spiele teilen sich zwei Grundregeln: +# - Encoder-Taster druecken beendet das laufende Spiel sofort. +# - Nach "Game Over" (oder Sieg) einmal eine beliebige Taste druecken, um +# zurueck ins "Games"-Profil zu kommen. +# Waehrend ein Spiel laeuft, pausieren LED-Animation und Bildschirmschoner - +# die Hauptschleife wartet ja auf die Rueckkehr aus run_game(). +# --------------------------------------------------------------------------- + + +def _game_exit_requested(): + """True, sobald der Encoder-Taster gedrueckt wird (jederzeit abbrechen).""" + return encoder_sw.value() == 0 + + +def _game_any_button(): + """True, wenn irgendeine der 4 Tasten gerade gedrueckt ist.""" + for btn in buttons: + if btn.value() == 0: + return True + return False + + +def _game_over_screen(title, score): + """Zeigt Titel + Punktestand und wartet auf Tastendruck oder Encoder.""" + oled.fill(0) + oled.text(title, 0, 4) + oled.text("Score: " + str(score), 0, 18) + oled.show() + # Erst warten, bis alles losgelassen ist (sonst fuehrt der Tastendruck, + # der zum Game Over gefuehrt hat, sofort wieder aus dem Bildschirm raus) + while _game_any_button() or _game_exit_requested(): + time.sleep_ms(20) + while not (_game_any_button() or _game_exit_requested()): + time.sleep_ms(20) + + +# --- Spiel 1: Dino-Runner - Taste 1 = Springen ------------------------------ +def _game_dino(): + ground_y = 30 + dino_x = 8 + dino_w = 6 + dino_h = 8 + dino_y = ground_y - dino_h + vel_y = 0 + on_ground = True + + obst_x = 127 + obst_w = 3 + obst_h = random.randint(6, 10) + + speed = GAME_DINO_SPEED_START + score = 0 + + while True: + if _game_exit_requested(): + return + + if buttons[0].value() == 0 and on_ground: + vel_y = GAME_DINO_JUMP_VELOCITY + on_ground = False + + vel_y += GAME_DINO_GRAVITY + dino_y += vel_y + if dino_y >= ground_y - dino_h: + dino_y = ground_y - dino_h + vel_y = 0 + on_ground = True + + obst_x -= speed + if obst_x < -obst_w: + obst_x = 127 + obst_h = random.randint(6, 10) + score += 1 + if score % 5 == 0: + speed += 1 + + if dino_x < obst_x + obst_w and dino_x + dino_w > obst_x: + if dino_y + dino_h > ground_y - obst_h: + _game_over_screen("GAME OVER", score) + return + + oled.fill(0) + oled.hline(0, ground_y, 128, 1) + oled.fill_rect(dino_x, dino_y, dino_w, dino_h, 1) + oled.fill_rect(obst_x, ground_y - obst_h, obst_w, obst_h, 1) + oled.text(str(score), 100, 0) + oled.show() + + time.sleep_ms(GAME_FRAME_MS) + + +# --- Spiel 2: Floppy Fly - Taste 1 = Fluegelschlag -------------------------- +def _game_flappy(): + bird_x = 20 + bird_y = 16 + vel_y = 0 + + pipe_x = 127 + pipe_w = 4 + gap_h = GAME_FLAPPY_GAP_H + gap_y = random.randint(2, 32 - gap_h - 2) + + score = 0 + scored_this_pipe = False + + while True: + if _game_exit_requested(): + return + + if buttons[0].value() == 0: + vel_y = GAME_FLAPPY_FLAP_VELOCITY + + vel_y += GAME_FLAPPY_GRAVITY + bird_y += vel_y + + pipe_x -= GAME_FLAPPY_SPEED + if pipe_x < -pipe_w: + pipe_x = 127 + gap_y = random.randint(2, 32 - gap_h - 2) + scored_this_pipe = False + + if not scored_this_pipe and pipe_x + pipe_w < bird_x: + score += 1 + scored_this_pipe = True + + crashed = bird_y < 0 or bird_y > 31 + if pipe_x < bird_x + 2 and pipe_x + pipe_w > bird_x - 2: + if bird_y < gap_y or bird_y > gap_y + gap_h: + crashed = True + + if crashed: + _game_over_screen("GAME OVER", score) + return + + oled.fill(0) + oled.fill_rect(pipe_x, 0, pipe_w, gap_y, 1) + oled.fill_rect(pipe_x, gap_y + gap_h, pipe_w, 32 - (gap_y + gap_h), 1) + oled.fill_rect(bird_x - 2, int(bird_y) - 2, 4, 4, 1) + oled.text(str(score), 100, 0) + oled.show() + + time.sleep_ms(GAME_FRAME_MS) + + +# --- Spiel 3: Mini-Tetris ---------------------------------------------------- +# Taste1=Links, Taste2=Rechts, Taste3=Drehen, Taste4=Runter (Soft-Drop) +_TETRIS_SHAPES = { + "I": [[(0, 1), (1, 1), (2, 1), (3, 1)], [(2, 0), (2, 1), (2, 2), (2, 3)]], + "O": [[(1, 0), (2, 0), (1, 1), (2, 1)]], + "T": [ + [(1, 0), (0, 1), (1, 1), (2, 1)], + [(1, 0), (1, 1), (2, 1), (1, 2)], + [(0, 1), (1, 1), (2, 1), (1, 2)], + [(1, 0), (0, 1), (1, 1), (1, 2)], + ], + "S": [[(1, 0), (2, 0), (0, 1), (1, 1)], [(1, 0), (1, 1), (2, 1), (2, 2)]], + "Z": [[(0, 0), (1, 0), (1, 1), (2, 1)], [(2, 0), (1, 1), (2, 1), (1, 2)]], + "L": [ + [(0, 0), (0, 1), (1, 1), (2, 1)], + [(1, 0), (2, 0), (1, 1), (1, 2)], + [(0, 1), (1, 1), (2, 1), (2, 2)], + [(1, 0), (1, 1), (0, 2), (1, 2)], + ], + "J": [ + [(2, 0), (0, 1), (1, 1), (2, 1)], + [(1, 0), (1, 1), (1, 2), (2, 2)], + [(0, 1), (1, 1), (2, 1), (0, 2)], + [(0, 0), (1, 0), (1, 1), (1, 2)], + ], +} +_TETRIS_NAMES = list(_TETRIS_SHAPES.keys()) + + +def _tetris_cells(shape, rot, px, py): + rots = _TETRIS_SHAPES[shape] + dxdy = rots[rot % len(rots)] + return [(px + dx, py + dy) for dx, dy in dxdy] + + +def _tetris_valid(cells, grid, cols, rows): + for cx, cy in cells: + if cx < 0 or cx >= cols or cy < 0 or cy >= rows: + return False + if grid[cy][cx]: + return False + return True + + +def _game_tetris(): + cols = GAME_TETRIS_COLS + rows = GAME_TETRIS_ROWS + cell = GAME_TETRIS_CELL_PX + grid = [[0] * cols for _ in range(rows)] + + def spawn(): + s = random.choice(_TETRIS_NAMES) + return s, 0, cols // 2 - 2, 0 + + shape, rot, px, py = spawn() + score = 0 + drop_ms = GAME_TETRIS_DROP_MS_START + last_drop = time.ticks_ms() + btn_last = [1, 1, 1, 1] + + while True: + if _game_exit_requested(): + return + + now = time.ticks_ms() + for i in range(4): + state = buttons[i].value() + if state == 0 and btn_last[i] == 1: + if i == 0: + cells = _tetris_cells(shape, rot, px - 1, py) + if _tetris_valid(cells, grid, cols, rows): + px -= 1 + elif i == 1: + cells = _tetris_cells(shape, rot, px + 1, py) + if _tetris_valid(cells, grid, cols, rows): + px += 1 + elif i == 2: + new_rot = rot + 1 + if _tetris_valid(_tetris_cells(shape, new_rot, px, py), grid, cols, rows): + rot = new_rot + elif _tetris_valid(_tetris_cells(shape, new_rot, px - 1, py), grid, cols, rows): + rot = new_rot + px -= 1 + elif _tetris_valid(_tetris_cells(shape, new_rot, px + 1, py), grid, cols, rows): + rot = new_rot + px += 1 + elif i == 3: + cells = _tetris_cells(shape, rot, px, py + 1) + if _tetris_valid(cells, grid, cols, rows): + py += 1 + btn_last[i] = state + + if time.ticks_diff(now, last_drop) >= drop_ms: + last_drop = now + cells = _tetris_cells(shape, rot, px, py + 1) + if _tetris_valid(cells, grid, cols, rows): + py += 1 + else: + for cx, cy in _tetris_cells(shape, rot, px, py): + if 0 <= cy < rows and 0 <= cx < cols: + grid[cy][cx] = 1 + cleared = 0 + new_grid = [] + for r in range(rows): + if all(grid[r]): + cleared += 1 + else: + new_grid.append(grid[r]) + for _ in range(cleared): + new_grid.insert(0, [0] * cols) + grid = new_grid + if cleared: + score += cleared * 10 + drop_ms = max(120, drop_ms - cleared * 15) + shape, rot, px, py = spawn() + if not _tetris_valid(_tetris_cells(shape, rot, px, py), grid, cols, rows): + _game_over_screen("GAME OVER", score) + return + + oled.fill(0) + for r in range(rows): + for c in range(cols): + if grid[r][c]: + oled.fill_rect(c * cell, r * cell, cell, cell, 1) + for cx, cy in _tetris_cells(shape, rot, px, py): + if 0 <= cy < rows and 0 <= cx < cols: + oled.fill_rect(cx * cell, cy * cell, cell, cell, 1) + oled.text("SCR", cols * cell + 4, 2) + oled.text(str(score), cols * cell + 4, 12) + oled.show() + time.sleep_ms(30) + + +# --- Spiel 4: Mini-Pacman ----------------------------------------------------- +# Taste1=Hoch, Taste2=Runter, Taste3=Links, Taste4=Rechts. Ein Geist jagt dich, +# links/rechts gibt es einen Tunnel-Wrap wie im Original. +def _game_pacman_maze(cols, rows, tunnel_row): + grid = [[0] * cols for _ in range(rows)] + for r in range(rows): + for c in range(cols): + if r == 0 or r == rows - 1: + grid[r][c] = 1 + elif c == 0 or c == cols - 1: + grid[r][c] = 0 if r == tunnel_row else 1 + elif r in (2, rows - 3) and c % 4 in (1, 2): + grid[r][c] = 1 + return grid + + +def _game_pacman(): + cols = GAME_PACMAN_COLS + rows = GAME_PACMAN_ROWS + cell = GAME_PACMAN_CELL_PX + tunnel_row = rows // 2 + + walls = _game_pacman_maze(cols, rows, tunnel_row) + dots = set() + for r in range(rows): + for c in range(cols): + if not walls[r][c]: + dots.add((c, r)) + + px, py = 1, 1 + dots.discard((px, py)) + pdx, pdy = 1, 0 + + gx, gy = cols - 2, rows - 2 + gdx, gdy = -1, 0 + + score = 0 + tick = 0 + last_move = time.ticks_ms() + btn_last = [1, 1, 1, 1] + + def can_move(x, y, dx, dy): + ny = y + dy + if ny < 0 or ny >= rows: + return False + nx = (x + dx) % cols + return not walls[ny][nx] + + while True: + if _game_exit_requested(): + return + + for i in range(4): + state = buttons[i].value() + if state == 0 and btn_last[i] == 1: + if i == 0: + pdx, pdy = 0, -1 + elif i == 1: + pdx, pdy = 0, 1 + elif i == 2: + pdx, pdy = -1, 0 + elif i == 3: + pdx, pdy = 1, 0 + btn_last[i] = state + + now = time.ticks_ms() + if time.ticks_diff(now, last_move) >= GAME_PACMAN_MOVE_MS: + last_move = now + tick += 1 + + if can_move(px, py, pdx, pdy): + px = (px + pdx) % cols + py += pdy + if (px, py) in dots: + dots.discard((px, py)) + score += 1 + + if tick % GAME_PACMAN_GHOST_MOVE_EVERY == 0: + best = None + best_dist = None + for ddx, ddy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + if can_move(gx, gy, ddx, ddy): + nx = (gx + ddx) % cols + ny = gy + ddy + dist = abs(nx - px) + abs(ny - py) + if best_dist is None or dist < best_dist: + best_dist = dist + best = (ddx, ddy) + if best is None: + best = (0, 0) + gdx, gdy = best + gx = (gx + gdx) % cols + gy += gdy + + if (px, py) == (gx, gy): + _game_over_screen("GAME OVER", score) + return + + if not dots: + _game_over_screen("CLEAR!", score) + return + + oled.fill(0) + for (dx_, dy_) in dots: + oled.pixel(dx_ * cell + cell // 2, dy_ * cell + cell // 2, 1) + for r in range(rows): + for c in range(cols): + if walls[r][c]: + oled.fill_rect(c * cell, r * cell, cell, cell, 1) + oled.fill_rect(px * cell, py * cell, cell, cell, 1) + oled.rect(gx * cell, gy * cell, cell, cell, 1) + oled.show() + time.sleep_ms(20) + + +# --------------------------------------------------------------------------- +# Profil-Editor: eigene Profile direkt am Geraet anlegen und wieder loeschen +# --------------------------------------------------------------------------- +# Erreichbar ueber das feste "Neu"-Profil (letzter Eintrag, per Encoder +# erreichbar): Taste 1 = neues Profil erstellen, Taste 2 = eigenes Profil +# loeschen. Neue Profile landen in CUSTOM_PROFILES_FILE im Flash-Speicher +# des Pico und ueberleben damit einen Neustart; die festen Profile weiter +# oben im Code sind davon nicht betroffen. +# +# Bedienung beim Erstellen: +# 1) Name eingeben: Encoder dreht durch die Zeichen, Taste1 uebernimmt das +# angezeigte Zeichen, Taste2 loescht das letzte Zeichen, Taste3 = fertig +# (Name abschliessen), Taste4 fuegt direkt ein Leerzeichen ein. +# 2) Fuer jede der 4 Tasten des neuen Profils: erst Art waehlen (Tasten- +# Kombi / Medien-Taste / Keine), bei Tasten-Kombi zuerst die Modifier +# (Strg/Shift/Alt/Win) mit Taste 1-4 an/ausschalten und mit kurzem +# Encoder-Druck bestaetigen, danach die Basistaste aus einer Liste +# waehlen (Encoder dreht, Taste1 bestaetigt). +# In jeder Liste bricht ein Druck auf den Encoder-Taster den kompletten +# Vorgang ab (bei den Modifiern: erst ein LANGER Druck). +# --------------------------------------------------------------------------- + + +def _wiz_read_delta(): + """Liest die seit dem letzten Aufruf aufgelaufenen Encoder-Schritte im + Wizard-Modus und setzt den Zaehler zurueck.""" + global _wiz_encoder_delta + d = _wiz_encoder_delta + _wiz_encoder_delta = 0 + return d + + +def _wiz_wait_release(): + """Wartet, bis alle 4 Taster UND der Encoder-Taster losgelassen sind.""" + while _game_any_button() or encoder_sw.value() == 0: + time.sleep_ms(20) + + +def _wiz_message(line1, line2="", wait_ms=900): + oled.fill(0) + oled.text(line1[:16], 0, 8) + if line2: + oled.text(line2[:16], 0, 18) + oled.show() + time.sleep_ms(wait_ms) + + +def _wiz_select(title, options, start=0): + """Scrollbare Liste: Encoder dreht, Taste1 bestaetigt (gibt den Index + zurueck), Encoder-Taster druecken bricht ab (gibt None zurueck).""" + idx = start % len(options) + _wiz_read_delta() + last_draw = None + while True: + if idx != last_draw: + oled.fill(0) + oled.text(title[:16], 0, 0) + oled.text("> " + options[idx][:14], 0, 12) + nxt = options[(idx + 1) % len(options)] + oled.text(" " + nxt[:14], 0, 22) + oled.show() + last_draw = idx + if encoder_sw.value() == 0: + _wiz_wait_release() + return None + if buttons[0].value() == 0: + _wiz_wait_release() + return idx + d = _wiz_read_delta() + if d: + idx = (idx + d) % len(options) + time.sleep_ms(20) + + +_WIZ_NAME_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + +def _wiz_enter_name(): + """Zeichen-fuer-Zeichen-Namenseingabe (max. 10 Zeichen). Gibt den Namen + zurueck, oder None bei Abbruch (Encoder-Taster).""" + chars = _WIZ_NAME_CHARS + ci = 0 + name = "" + _wiz_read_delta() + last_draw = None + while True: + state = (ci, name) + if state != last_draw: + oled.fill(0) + oled.text("Name:" + name, 0, 0) + oled.text("<" + chars[ci] + ">", 0, 12) + oled.text("1:OK 2:Del 3:Fin", 0, 22) + oled.show() + last_draw = state + if encoder_sw.value() == 0: + _wiz_wait_release() + return None + if buttons[0].value() == 0: + if len(name) < 10: + name += chars[ci] + _wiz_wait_release() + last_draw = None + elif buttons[1].value() == 0: + name = name[:-1] + _wiz_wait_release() + last_draw = None + elif buttons[2].value() == 0: + if name: + _wiz_wait_release() + return name + _wiz_wait_release() + elif buttons[3].value() == 0: + if len(name) < 10: + name += " " + _wiz_wait_release() + last_draw = None + d = _wiz_read_delta() + if d: + ci = (ci + d) % len(chars) + time.sleep_ms(20) + + +def _wiz_pick_modifiers(title): + """Taste 1-4 schalten Strg/Shift/Alt/Win an/aus. Kurzer Encoder-Druck + uebernimmt die Auswahl, ein LANGER Encoder-Druck bricht ab.""" + mods = [False, False, False, False] + names = ["C", "S", "A", "W"] + btn_last = [1, 1, 1, 1] + sw_last = 1 + sw_down_start = None + last_draw = None + while True: + state_key = tuple(mods) + if state_key != last_draw: + line = " ".join(names[i] + ("*" if mods[i] else "_") for i in range(4)) + oled.fill(0) + oled.text(title[:16], 0, 0) + oled.text(line[:16], 0, 12) + oled.text("1-4=Mod Enc=weit", 0, 22) + oled.show() + last_draw = state_key + for i in range(4): + s = buttons[i].value() + if s == 0 and btn_last[i] == 1: + mods[i] = not mods[i] + last_draw = None + btn_last[i] = s + sw = encoder_sw.value() + if sw == 0 and sw_last == 1: + sw_down_start = time.ticks_ms() + elif sw == 1 and sw_last == 0 and sw_down_start is not None: + held = time.ticks_diff(time.ticks_ms(), sw_down_start) + sw_down_start = None + if held >= ENCODER_SW_LONG_PRESS_MS: + return None + return mods + sw_last = sw + time.sleep_ms(20) + + +_WIZ_BASE_KEYS = ( + [(c, c) for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"] + + [(str(d), "N" + str(d)) for d in range(10)] + + [("F" + str(n), "F" + str(n)) for n in range(1, 13)] + + [ + ("ENTER", "ENTER"), + ("SPACE", "SPACE"), + ("TAB", "TAB"), + ("ESC", "ESCAPE"), + ("BKSP", "BACKSPACE"), + ("DEL", "DELETE"), + ("UP", "UP"), + ("DOWN", "DOWN"), + ("LEFT", "LEFT"), + ("RIGHT", "RIGHT"), + ("HOME", "HOME"), + ("END", "END"), + ] +) + +_WIZ_MEDIA_KEYS = [ + ("Vol+", "VOLUME_UP"), + ("Vol-", "VOLUME_DOWN"), + ("Mute", "MUTE"), + ("Play", "PLAY_PAUSE"), + ("Stop", "STOP"), + ("Next", "NEXT_TRACK"), + ("Prev", "PREV_TRACK"), +] + +_WIZ_ACTION_CATEGORIES = ["Tasten-Kombi", "Medien-Taste", "Keine"] + +_WIZ_MOD_ATTRS = ["LEFT_CTRL", "LEFT_SHIFT", "LEFT_ALT", "LEFT_UI"] +_WIZ_MOD_SYMS = ["^", "+", "~", "#"] + + +def _wiz_pick_keys_action(title): + mods = _wiz_pick_modifiers(title) + if mods is None: + return None, None + key_labels = [lbl for lbl, _ in _WIZ_BASE_KEYS] + ki = _wiz_select(title, key_labels) + if ki is None: + return None, None + key_label, key_attr = _WIZ_BASE_KEYS[ki] + keys = [] + mod_label = "" + for i in range(4): + if mods[i]: + keys.append(getattr(K, _WIZ_MOD_ATTRS[i])) + mod_label += _WIZ_MOD_SYMS[i] + keys.append(getattr(K, key_attr)) + label = (mod_label + key_label)[:5] or "-" + return label, {"type": "keys", "keys": keys} + + +def _wiz_pick_action(slot): + """Fragt die Aktion fuer eine der 4 Tasten des neuen Profils ab. Gibt + (label, action) zurueck, oder (None, None) bei Abbruch.""" + title = "Taste %d/4" % (slot + 1) + cat_idx = _wiz_select(title, _WIZ_ACTION_CATEGORIES) + if cat_idx is None: + return None, None + cat = _WIZ_ACTION_CATEGORIES[cat_idx] + if cat == "Keine": + return "-", {"type": "none"} + if cat == "Medien-Taste": + names = [n for n, _ in _WIZ_MEDIA_KEYS] + mi = _wiz_select(title, names) + if mi is None: + return None, None + label, attr = _WIZ_MEDIA_KEYS[mi] + return label[:5], {"type": "media", "code": getattr(M, attr)} + return _wiz_pick_keys_action(title) + + +def wizard_create_profile(): + """Legt ueber die Geraete-Bedienung ein neues Profil an und speichert es + persistent (siehe CUSTOM_PROFILES_FILE).""" + global PROFILES, profile_index, profile_dirty, _encoder_mode + _encoder_mode = "wizard" + _wiz_wait_release() # die Taste, die "Erstl" ausgeloest hat, nicht mitzaehlen + try: + name = _wiz_enter_name() + if name is None: + return + labels = [] + actions = [] + for slot in range(4): + label, action = _wiz_pick_action(slot) + if label is None: + _wiz_message("Abgebrochen") + return + labels.append(label) + actions.append(action) + new_profile = {"name": name, "labels": labels, "actions": actions} + insert_at = len(PROFILES) - 1 # vor dem "Neu"-Menuepunkt einfuegen + PROFILES.insert(insert_at, new_profile) + profile_index = insert_at + profile_dirty = True + if _save_custom_profiles(_custom_profiles_list()): + _wiz_message("Gespeichert!", name) + else: + _wiz_message("Nur temporaer!", "Speichern fehlg.") + finally: + _encoder_mode = "profile" + + +def wizard_delete_profile(): + """Loescht ein selbst erstelltes Profil wieder (feste Profile stehen + nicht zur Auswahl).""" + global PROFILES, profile_index, profile_dirty, _encoder_mode + _encoder_mode = "wizard" + _wiz_wait_release() + try: + customs = _custom_profiles_list() + if not customs: + _wiz_message("Keine eigenen", "Profile vorhanden") + return + names = [p["name"] for p in customs] + idx = _wiz_select("Loeschen?", names) + if idx is None: + return + target = customs[idx] + real_index = PROFILES.index(target) + del PROFILES[real_index] + if profile_index >= len(PROFILES): + profile_index = len(PROFILES) - 1 + profile_dirty = True + if _save_custom_profiles(_custom_profiles_list()): + _wiz_message("Geloescht:", target["name"]) + else: + _wiz_message("Geloescht,", "Speichern fehlg.") + finally: + _encoder_mode = "profile" +def run_game(name): + """Startet eines der 4 Mini-Spiele (siehe "Games"-Profil).""" + oled.fill(0) + oled.text("Los geht's!", 10, 12) + oled.show() + time.sleep_ms(400) + # Sicherstellen, dass die Taste, die das Spiel gestartet hat, nicht noch + # gedrueckt ist - sonst wuerde z.B. Dino sofort beim Start hochspringen. + while _game_any_button(): + time.sleep_ms(20) + if name == "dino": + _game_dino() + elif name == "flappy": + _game_flappy() + elif name == "tetris": + _game_tetris() + elif name == "pacman": + _game_pacman() # --------------------------------------------------------------------------- # Hauptschleife # --------------------------------------------------------------------------- @@ -847,4 +1677,4 @@ while True: else: _do_encoder_sw_action(ENCODER_SW_SHORT_ACTION) - time.sleep_ms(5) + time.sleep_ms(5) \ No newline at end of file