forked from keithtanner037/merlin
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhot_potato.py
More file actions
680 lines (585 loc) · 22.9 KB
/
Copy pathhot_potato.py
File metadata and controls
680 lines (585 loc) · 22.9 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
# hot_potato.py — Master Merlin "Hot Potato" for Adafruit MacroPad
# CircuitPython 8.x / 9.x compatible, non-blocking animations
# Written by Iain Bennett — 2025
# Inspired by Keith Tanner's Merlin for the Macropad
#
# License:
# Released under the CC0 1.0 Universal (Public Domain Dedication).
# You can copy, modify, distribute, and perform the work, even for commercial purposes,
# all without asking permission. Attribution is appreciated but not required.
#
# Hot Potato is a party guessing game: there’s a secret number (0–99). Players
# take turns guessing; Merlin shows HI or LO after each guess. Whoever guesses
# the hot number “gets burned” (explosion!) and everyone else wins.
#
# This implementation keeps the simple one/two-digit entry + ENTER (K11)
# from Hi/Lo, but also shows the *allowed tens* as guidance: after a guess,
# keys whose digits (0..9) match possible tens for the next guess will glow.
#
# Controls:
# • K0..K8 – digits 1 to 9
# • K10 – digit 0
# • K11 – Enter: submit the guess
# • K9 – New game (allowed during play or after a win)
#
# Features:
# • Startup explosion preview
# • HI/LO arrow flash after each valid guess
# • Allowed-range guidance: tens keys pulse to hint valid tens (e.g., 3..9)
# • Invalid/repeat guess: red “X” flash and error tone; turn does not advance
# • Tracks player_count and per-player turn counts (default 2 players)
# • Non-blocking explosion effect on win
#
# Design Notes / Key Features
# • Non‑blocking effects: All animations (guidance pulse, arrows, explosion)
# are time‑based using time.monotonic(); the main loop stays responsive.
# • Simple numeric entry: Players press digit keys (K0–K8 for 1‑9, K10 for 0)
# up to two digits, then confirm with Enter (K11). Excess digits shift.
# • Allowed‑tens guidance: When no digits are typed, keys whose digit matches
# a valid tens value in the current [low_bound…high_bound] flash brighter.
# • Range enforcement and duplicate checking: Guesses outside the current
# bounds or repeats prompt a red “X” flash and an error tone; turn is lost.
# • HI/LO feedback: A correct guess triggers a non‑blocking explosion.
# Higher guesses flash a red “up” arrow; lower guesses flash a red “down”.
# • Multi‑player support: Tracks `player_count` (minimum 2) and per‑player
# turn counts; automatically advances the turn index after each valid guess.
# • UI caching: Uses a “dirty” flag (`_ui_dirty`) to avoid repainting static
# keypad state on every tick, improving performance. Dynamic effects
# (e.g., pulsing Enter key) are updated each frame.
# • Cleanup: `cleanup()` restores pixels and auto_write state when exiting.
#
import time, math, random
import displayio, terminalio
from adafruit_display_text import label
class hot_potato:
def __init__(self, macropad, tones):
self.mac = macropad
self.tones = tones
# Colors
self.BRIGHT = 0.30
self.C_BG = 0x000000
self.C_NUM_IDLE = 0x102040 # dim steel-blue for number keys (idle)
self.C_NUM_ACTIVE = 0x3060A0 # brighter hint for allowed tens
self.C_ENTER = 0x00AA00 # green for K11
self.C_NEW = 0x202020 # dim white for K9
self.C_HINT = 0xFF0000 # red arrows (HI/LO)
self.C_ERR = 0xFF0000 # red 'X' for invalid
# Buttons
self.K_NEW = 9
self.K_ZERO = 10
self.K_ENTER = 11
self.NUM_KEYS = tuple(range(0, 9)) + (self.K_ZERO,) # K0..K8, K10
# Arrow shapes (as key indexes)
self.ARROW_UP = {1, 3, 4, 5, 7, 10}
self.ARROW_DOWN = {1, 4, 6, 7, 8, 10}
# Game state
self.mode = "entry" # "entry" | "hint" | "won" | "preview"
self.entry_digits = [] # up to 2 digits
self.target = 0
self.low_bound = 0 # inclusive
self.high_bound = 99 # inclusive
self.used = set()
self.player_count = 2
self.turn_index = 0 # 0..player_count-1
self.turns_per_player = [0] * self.player_count
self.hint_until = 0.0
# --- UI cache / dirtiness ---
self._ui_dirty = True # when True, repaint the static entry UI
# LED driver
try:
self.mac.pixels.auto_write = False
except AttributeError:
pass
self.mac.pixels.brightness = self.BRIGHT
# Explosion state
self._init_explosion_state()
# Display
self._build_display()
# ---------- Public ----------
def new_game(self):
# Startup explosion preview
self._lights_off()
self._set_title("Hot Potato")
self._set_status("Get ready…")
self._show()
self.start_explosion()
self.mode = "preview"
# Initialize a new round
self.target = random.randint(0, 99)
self.low_bound = 0
self.high_bound = 99
self.used.clear()
self.entry_digits = []
self.turn_index = 0
self.player_count = max(2, self.player_count)
self.turns_per_player = [0] * self.player_count
def button(self, key):
now = time.monotonic()
# Allow New anytime
if key == self.K_NEW:
self._click(key)
self.new_game()
return
# Ignore input while explosion or hint is active
if self.expl_active:
return
if self.mode == "hint" and now < self.hint_until:
return
if self.mode == "won":
return
# Entry mode
if self.mode == "entry":
if key == self.K_ENTER:
self._click(key)
self._commit_guess()
return
d = self._digit_for_key(key)
if d is not None:
self._click(key)
self._press_digit_feedback(key)
# Maintain a 1–2 digit buffer
if len(self.entry_digits) >= 2:
self.entry_digits = [self.entry_digits[-1], d]
else:
self.entry_digits.append(d)
# Update text; defer LED repaint to tick()
self._update_status_for_entry()
self._mark_entry_dirty()
return
def tick(self):
now = time.monotonic()
# Explosion animation owns LEDs while active
if self.expl_active:
self._explosion_frame()
# When preview explosion finishes, transition to entry UI on next frame
if (not self.expl_active) and self.mode == "preview":
self.mode = "entry"
self._update_status_for_entry()
self._mark_entry_dirty() # defer repaint to the next tick
self._show()
return
# After hint flash, return to entry UI and defer repaint
if self.mode == "hint" and now >= self.hint_until:
self.mode = "entry"
self._update_status_for_entry()
self._mark_entry_dirty()
return
# After win: freeze LEDs (K9 shown by _paint_won_ui); wait for New
if self.mode == "won":
return
# Entry mode: repaint static UI only when dirty; always pulse Enter
if self.mode == "entry":
if self._ui_dirty:
self._paint_entry_static() # heavy work only when needed
# Cheap per-frame pulse for K11
pulse = 0.35 + 0.65 * (0.5 + 0.5 * math.cos(now * 2 * math.pi * 0.9))
self._paint_entry_dynamic(pulse)
return
def cleanup(self):
if getattr(self, "_cleaned", False):
return
self._cleaned = True
# 0) Stop transient effects/sounds
try: self.expl_active = False
except Exception: pass
try:
if hasattr(self.mac, "stop_tone"):
self.mac.stop_tone()
except Exception:
pass
try:
spk = getattr(self.mac, "speaker", None)
if spk is not None:
spk.enable = False
except Exception:
pass
# 1) LEDs: restore immediate writes and turn everything off
try:
px = getattr(self.mac, "pixels", None)
if px:
try: px.auto_write = True
except Exception: pass
for i in range(12):
px[i] = 0x000000
try: px.show()
except Exception: pass
except Exception:
pass
# 2) Detach our display group and push a clean refresh
try:
disp = getattr(self.mac, "display", None)
if disp:
try: disp.auto_refresh = False
except Exception: pass
try:
# CP 9.x:
if getattr(disp, "root_group", None) is getattr(self, "group", None):
try: disp.root_group = None
except Exception:
# CP 8.x:
try: disp.show(None)
except Exception: pass
# force a clean frame so the launcher gets a blank canvas
try: disp.refresh(minimum_frames_per_second=0)
except TypeError:
disp.refresh()
except Exception:
pass
try: disp.auto_refresh = True
except Exception: pass
except Exception:
pass
# 3) Reset small UI flags and drop big refs; GC
try: self._ui_dirty = True
except Exception: pass
try: self.group = None
except Exception: pass
try:
import gc
gc.collect()
except Exception:
pass
# ---------- Internals ----------
def _digit_for_key(self, k):
if 0 <= k <= 8: # 1..9
return k + 1
if k == self.K_ZERO:
return 0
return None
def _commit_guess(self):
# No digits typed?
if not self.entry_digits:
self._set_status(self._turn_prefix() + "Enter a number")
return
# Parse 1–2 digit number, clamp 0..99
guess = self.entry_digits[0] if len(self.entry_digits) == 1 else (self.entry_digits[0]*10 + self.entry_digits[1])
guess = max(0, min(99, guess))
# Validate: within current bounds and unused
if (guess < self.low_bound) or (guess > self.high_bound) or (guess in self.used):
self._sound_error()
self._flash_invalid_x() # does its own brief paint/show
self._update_status_for_entry()
self._mark_entry_dirty() # ensure entry UI refreshes after the X
return
# Valid turn
self.used.add(guess)
self.turns_per_player[self.turn_index] += 1
if guess < self.target:
# Higher: tighten lower bound, show HI arrow, next player
self.low_bound = max(self.low_bound, guess + 1)
self._flash_arrow(self.ARROW_UP, self.C_HINT, 0.9)
self._advance_turn()
self._mark_entry_dirty() # allowed tens changed
elif guess > self.target:
# Lower: tighten upper bound, show LO arrow, next player
self.high_bound = min(self.high_bound, guess - 1)
self._flash_arrow(self.ARROW_DOWN, self.C_HINT, 0.9)
self._advance_turn()
self._mark_entry_dirty() # allowed tens changed
else:
# Boom! Guesser is burned; everyone else wins.
self.mode = "won" # set first so post-explosion paint shows K9
self._set_status(f"Player {self.turn_index+1} burned!")
self.start_explosion() # explosion temporarily owns LEDs
# Reset entry buffer for next input/turn
self.entry_digits = []
def _advance_turn(self):
self.turn_index = (self.turn_index + 1) % self.player_count
self._update_status_for_entry()
# ---------- UI paint ----------
def _allowed_tens(self):
"""Return a set of allowed tens (0..9) given [low_bound..high_bound]."""
lo_t, hi_t = self.low_bound // 10, self.high_bound // 10
return set(range(lo_t, hi_t + 1))
def _key_for_digit(self, d):
"""Map digit 0..9 to key index used by this layout."""
if d == 0: return self.K_ZERO
if 1 <= d <= 9: return d - 1 # digit 1..9 -> K0..K8
return None
def _press_digit_feedback(self, key):
# brief bright flash on the pressed number key
old = self.mac.pixels[key]
self.mac.pixels[key] = self.C_NUM_ACTIVE
try: self.mac.pixels.show()
except AttributeError: pass
time.sleep(0.05)
self.mac.pixels[key] = old
try: self.mac.pixels.show()
except AttributeError: pass
def _flash_arrow(self, keys_set, color, dur):
# Clear, draw only the arrow, hold for dur
for i in range(12):
self.mac.pixels[i] = 0x000000
for k in keys_set:
self.mac.pixels[k] = color
try: self.mac.pixels.show()
except AttributeError: pass
self.mode = "hint"
self.hint_until = time.monotonic() + dur
def _flash_invalid_x(self):
lit = {0, 2, 4, 6, 8}
for i in range(12):
self.mac.pixels[i] = 0x000000
for i in lit:
self.mac.pixels[i] = self.C_ERR
try: self.mac.pixels.show()
except AttributeError: pass
time.sleep(0.35)
self._mark_entry_dirty() # <- let tick repaint
# ---------- Display ----------
def _build_display(self):
W, H = self.mac.display.width, self.mac.display.height
g = displayio.Group()
# Background
bg = displayio.Bitmap(W, H, 1)
pal = displayio.Palette(1); pal[0] = self.C_BG
g.append(displayio.TileGrid(bg, pixel_shader=pal))
# Logo (optional)
y0 = 0
try:
bmp = displayio.OnDiskBitmap("MerlinChrome.bmp")
x = max(0, (W - bmp.width)//2)
g.append(displayio.TileGrid(
bmp,
pixel_shader=getattr(bmp, "pixel_shader", displayio.ColorConverter()),
x=x, y=0
))
y0 = bmp.height + 2
except Exception:
y0 = 0
self.title = label.Label(
terminalio.FONT, text="Hot Potato",
color=0xFFFFFF, anchor_point=(0.5, 0.0),
anchored_position=(W//2, y0)
)
g.append(self.title)
self.status = label.Label(
terminalio.FONT, text="",
color=0xA0A0A0, anchor_point=(0.5, 0.0),
anchored_position=(W//2, y0 + 14)
)
g.append(self.status)
self.group = g
def _show(self):
try:
self.mac.display.show(self.group) # CP 8.x
except AttributeError:
self.mac.display.root_group = self.group # CP 9.x
def _set_title(self, s):
if hasattr(self, "title") and self.title:
self.title.text = s
def _set_status(self, s):
if hasattr(self, "status") and self.status:
self.status.text = s
def _turn_prefix(self):
return f"P{self.turn_index+1}: "
def _update_status_for_entry(self):
# Example: "P1: 0–99" or "P2: 36–99" etc.
if not self.entry_digits:
self._set_status(self._turn_prefix() + f"{self.low_bound} to {self.high_bound}")
elif len(self.entry_digits) == 1:
self._set_status(self._turn_prefix() + f"Guess: {self.entry_digits[0]}")
else:
val = self.entry_digits[0]*10 + self.entry_digits[1]
self._set_status(self._turn_prefix() + f"Guess: {val}")
# ---------- Explosion (non-blocking) ----------
def _init_explosion_state(self):
self.expl_active = False
self.expl_t0 = 0.0
self.expl_ring_speed = 3.2 # LEDs per second (wavefront speed)
self.expl_hot_time = 0.38 # per-key hot window
self.expl_total_time = 1.1 # overall effect duration
self.expl_ignite_at = [-1.0] * 12
self._grid_r = [((i%3 - 1)**2 + (i//3 - 1)**2) ** 0.5 for i in range(9)]
self._spark_keys = []
def start_explosion(self):
self.expl_active = True
self.expl_t0 = time.monotonic()
self.expl_ignite_at = [-1.0] * 12
try:
for f, d in ((660, 0.04), (880, 0.04), (220, 0.10)):
self.mac.play_tone(f, d)
except Exception:
pass
pool = [9, 10, 11]
try:
self._spark_keys = self._rand_sample2(pool)
except Exception:
self._spark_keys = pool[:2]
def _explosion_frame(self):
now = time.monotonic()
t = now - self.expl_t0
if t >= self.expl_total_time:
self.expl_active = False
if self.mode == "won":
self._paint_won_ui()
else:
self._lights_off()
try: self.mac.pixels.show()
except AttributeError: pass
return
R = t * self.expl_ring_speed
px = self.mac.pixels # local for speed
ignite = self.expl_ignite_at
hot_time = self.expl_hot_time
inv_hot = 1.0 / hot_time
grid_r = self._grid_r
# clear all
for i in range(12):
px[i] = 0x000000
# animate K0..K8
for i in range(9):
r = grid_r[i]
if ignite[i] < 0 and R >= (r - 0.05):
ignite[i] = now
t0 = ignite[i]
if t0 >= 0:
age = now - t0
u = age * inv_hot
if u <= 0.0:
continue
if u >= 1.0:
u = 1.0
heat = 1.0 - self._ease_cos(u)
# color ramp
if u < 0.15:
col = self._blend(0xFFFFFF, 0xFFD040, u / 0.15)
else:
v = (u - 0.15) * (1.0 / 0.85)
if v > 1.0: v = 1.0
hue = 0.13 * (1.0 - v)
if hue < 0.0: hue = 0.0
col = self._hsv_to_rgb(hue, 1.0, 1.0)
px[i] = self._scale(col, 0.25 + 0.75 * heat)
# center pop
c = 4
tc = ignite[c]
if tc >= 0:
if (now - tc) < (hot_time * 0.7):
px[c] = self._blend(px[c], 0xFFFFFF, 0.5)
# quick sparks on K9..K11
if t < 0.45:
for k in self._spark_keys:
if random.random() < 0.35:
px[k] = 0xFFFFFF
try:
px.show()
except AttributeError:
pass
# ---------- tiny helpers ----------
def _lights_off(self):
for i in range(12):
self.mac.pixels[i] = 0x000000
def _rand_sample2(self, seq):
n = len(seq)
if n == 0:
return []
if n == 1:
return [seq[0]]
i = random.randrange(n)
j = random.randrange(n - 1)
if j >= i:
j += 1
return [seq[i], seq[j]]
def _scale(self, color, s):
# Accept either 24-bit int or (r,g,b) tuple
if isinstance(color, tuple):
r, g, b = color
else:
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
r = int(r * s); g = int(g * s); b = int(b * s)
return (r << 16) | (g << 8) | b
def _hsv_to_rgb(self, h, s, v):
i = int(h * 6.0) % 6
f = (h * 6.0) - i
p = int(255 * v * (1 - s))
q = int(255 * v * (1 - f * s))
t = int(255 * v * (1 - (1 - f) * s))
vv = int(255 * v)
if i == 0: r, g, b = vv, t, p
elif i == 1: r, g, b = q, vv, p
elif i == 2: r, g, b = p, vv, t
elif i == 3: r, g, b = p, q, vv
elif i == 4: r, g, b = t, p, vv
else: r, g, b = vv, p, q
return (r << 16) | (g << 8) | b
def _ease_cos(self, u):
if u <= 0: return 0.0
if u >= 1: return 1.0
return 0.5 - 0.5 * math.cos(math.pi * u)
def _blend(self, c1, c2, t):
# Accept either 24-bit int or (r,g,b) tuple for c1/c2
if isinstance(c1, tuple):
r1, g1, b1 = c1
else:
r1, g1, b1 = (c1 >> 16) & 255, (c1 >> 8) & 255, c1 & 255
if isinstance(c2, tuple):
r2, g2, b2 = c2
else:
r2, g2, b2 = (c2 >> 16) & 255, (c2 >> 8) & 255, c2 & 255
# clamp t just in case
if t <= 0: return (r1 << 16) | (g1 << 8) | b1
if t >= 1: return (r2 << 16) | (g2 << 8) | b2
r = int(r1 + (r2 - r1) * t)
g = int(g1 + (g2 - g1) * t)
b = int(b1 + (b2 - b1) * t)
return (r << 16) | (g << 8) | b
# ---------- sounds ----------
def _play(self, freq, dur):
try:
self.mac.play_tone(freq, dur)
except Exception:
pass
def _click(self, key=None):
f = 523 # default C5
if key == self.K_NEW:
f = 440
elif key == self.K_ENTER:
f = 659
elif key is not None:
try:
if isinstance(self.tones, (list, tuple)) and len(self.tones) > 0:
f = self.tones[key % len(self.tones)]
except Exception:
pass
self._play(f, 0.03)
def _sound_error(self):
self._play(220, 0.05)
def _paint_won_ui(self):
# turn everything off first
for i in range(12):
self.mac.pixels[i] = 0x000000
# show K9 (New) as a dim white hint
self.mac.pixels[self.K_NEW] = 0x202020
try: self.mac.pixels.show()
except AttributeError: pass
def _mark_entry_dirty(self):
self._ui_dirty = True
def _paint_entry_static(self):
"""Heavywork: draw keypad + allowed tens only when something changed."""
# Base all-off
for i in range(12):
self.mac.pixels[i] = 0x000000
# Number keys idle
for k in self.NUM_KEYS:
self.mac.pixels[k] = self.C_NUM_IDLE
# Guidance: allowed tens when no digits typed
if not self.entry_digits:
allowed = self._allowed_tens()
for td in allowed:
key = self._key_for_digit(td)
if key is not None:
self.mac.pixels[key] = self.C_NUM_ACTIVE
# Controls (K9 dim; K11 dynamic in tick)
self.mac.pixels[self.K_NEW] = self.C_NEW
try: self.mac.pixels.show()
except AttributeError: pass
# cache what we drew
self._ui_dirty = False
def _paint_entry_dynamic(self, pulse):
"""Cheap per-frame update for K11 pulse only."""
self.mac.pixels[self.K_ENTER] = self._scale(self.C_ENTER, pulse)
try: self.mac.pixels.show()
except AttributeError: pass