forked from keithtanner037/merlin
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpatterns.py
More file actions
476 lines (427 loc) · 19.9 KB
/
Copy pathpatterns.py
File metadata and controls
476 lines (427 loc) · 19.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
# patterns.py — Master Merlin "Patterns" for Adafruit MacroPad
# CircuitPython 8.x / 9.x, non-blocking LEDs/animation
# Written by Iain Bennett — 2025
#
# 🎮 GAMEPLAY
# ░ Classic Merlin “Patterns” re-imagined on a 3×3 MacroPad grid.
# ░ The device streams a sequence of LED grids — one is the target pattern.
# ░ In 1-Player mode, stop the stream when the MATCH appears.
# ░ In 2-Player mode, buzz in first to claim the match — fastest wins!
# ░ Higher levels add rotations, extra cells, and blinking blue tricksters.
# ░ First to 4 points in 2P mode wins the crown 👑.
#
# 🌈 VISUALS
# ░ Red = solid target cells
# ░ Blue = blinking cells (level 4+)
# ░ White = UI hints & prompts
# ░ Green = score “pips” (2P mode)
#
# 🎵 AUDIO
# ░ Quick arpeggios for wins (660–880–990 Hz).
# ░ Low “buzz” for wrong answers (196 Hz / 150 Hz).
#
# 📜 LICENSE
# Released under the CC0 1.0 Universal (Public Domain Dedication).
import time, math, random
import displayio, terminalio
from adafruit_display_text import label
class patterns:
# ---------- LED constants ----------
BRIGHT = 0.30
COLOR_BG = 0x000000
COLOR_SOLID = 0xFF0000 # red = solid light
COLOR_BLINK = 0x00A0FF # blue = blinking light (level 4, blinkers)
COLOR_HINT = 0x00FF00 # pulsing prompts in menus
COLOR_UI = 0xFFFFFF
# keys
CELLS = tuple(range(9)) # 0..8 grid
# 0 1 2
# 3 4 5
# 6 7 8
K_NEW = 10 # 2P: New Game/Back on K10
K_NEW_1P = 9 # 1P: New Game/Back on K9
K_COMP = 11 # Computer Turn (start stream / stop in 1P)
P1_BUZZ = 9 # 2P: Player 1 buzzer (K9)
P2_BUZZ = 11 # 2P: Player 2 buzzer (K11)
# timings
LED_FRAME_DT = 1/30
BLINK_HZ = 1.0
STREAM_SHOW = 1.00
STREAM_GAP = 0.35
# 2P match
POINTS_TO_WIN = 4
def __init__(self, macropad, tones):
self.mac = macropad
self.tones = tones
try:
self.mac.pixels.auto_write = False
except AttributeError:
pass
self.mac.pixels.brightness = self.BRIGHT
self._led = [0]*12
self._led_dirty = False
self._last_led_show = 0.0
self._build_display()
self._to_mode_select()
try:
self.mac.display.auto_refresh = True
self.mac.display.refresh(minimum_frames_per_second=0)
except Exception:
pass
# ---------- Display / HUD ----------
def _build_display(self):
W, H = self.mac.display.width, self.mac.display.height
g = displayio.Group()
# Merlin chrome background if available
try:
bmp = displayio.OnDiskBitmap("MerlinChrome.bmp")
tile = displayio.TileGrid(
bmp, pixel_shader=getattr(bmp, "pixel_shader", displayio.ColorConverter())
)
g.append(tile)
except Exception:
bg = displayio.Bitmap(W, H, 1)
pal = displayio.Palette(1)
pal[0] = self.COLOR_BG
g.append(displayio.TileGrid(bg, pixel_shader=pal))
# Two compact lines under the logo
self.line1 = label.Label(
terminalio.FONT, text="",
color=0xFFFFFF, anchor_point=(0.5, 0.0),
anchored_position=(W//2, 31)
)
self.line2 = label.Label(
terminalio.FONT, text="",
color=0xAAAAAA, anchor_point=(0.5, 0.0),
anchored_position=(W//2, 45)
)
g.append(self.line1)
g.append(self.line2)
self._t_cache = ("","")
self.group = g
def _set_hud(self, l1=None, l2=None):
old_l1, old_l2 = self._t_cache
if l1 is not None and l1 != old_l1:
self.line1.text = l1; old_l1 = l1
if l2 is not None and l2 != old_l2:
self.line2.text = l2; old_l2 = l2
self._t_cache = (old_l1, old_l2)
# ---------- Public API ----------
def new_game(self): self._to_mode_select()
def cleanup(self):
if getattr(self, "_cleaned", False): return
self._cleaned = True
self.mode = "idle"
self._stopped = True
self._in_gap = False
self.stream_idx = -1
self._gap_until = 0.0
self._show_until = 0.0
try:
if hasattr(self.mac, "stop_tone"): self.mac.stop_tone()
except Exception: pass
try:
self.mac.pixels.fill(0x000000)
self.mac.pixels.show()
try: self.mac.pixels.auto_write = True
except Exception: pass
except Exception: pass
try:
disp = self.mac.display
try: disp.auto_refresh = False
except Exception: pass
root = getattr(disp, "root_group", None)
if root is self.group:
try: disp.root_group = None
except Exception:
try: disp.show(None)
except Exception: pass
try:
if hasattr(self, "line1"): self.line1.text = ""
if hasattr(self, "line2"): self.line2.text = ""
except Exception: pass
try: disp.auto_refresh = True
except Exception: pass
except Exception: pass
try:
self.group = None; self.line1 = None; self.line2 = None
except Exception: pass
try:
import gc; gc.collect()
except Exception: pass
# ---------- Key handling ----------
def _key_same(self): return self.K_NEW_1P if self.players == 1 else self.K_NEW
def button(self, key):
if self.mode == "mode":
if key == 3: self.players = 1; self._to_level_select()
elif key == 5: self.players = 2; self._to_level_select()
return
if self.mode == "level":
if key in (0,1,2,3): self.level = key + 1; self._start_round()
elif key == self._key_same(): self._to_mode_select()
return
if key == self._key_same():
if self.players == 1: self.streak = 0
self._to_level_select(); return
if self.mode == "preview":
if (self.players == 2 and key in (self.P1_BUZZ, self.P2_BUZZ)) \
or (self.players == 1 and key == self.K_COMP): self._start_stream(); return
return
if self.mode == "stream":
if self.players == 2:
if key == self.P1_BUZZ: self._on_stop(buzzer=1); return
elif key == self.P2_BUZZ: self._on_stop(buzzer=2); return
else:
if key == self.K_COMP: self._on_stop(buzzer=None); return
return
if self.mode == "result":
if self.players == 2 and key in (self.P1_BUZZ, self.P2_BUZZ, self.K_COMP): self._start_round(); return
if self.players == 1 and key == self.K_COMP: self._start_round(); return
def button_up(self, key): return
def encoderChange(self, new, old): return
# ---------- Main loop tick ----------
def tick(self):
now = time.monotonic()
if self.mode in ("mode","level"): self._render_ui(now); return
if self.mode == "preview": self._render_preview(now); return
if self.mode == "stream": self._advance_stream(now); self._render_stream(now); return
if self.mode == "result": self._render_result(now); return
# ---------- State transitions ----------
def _to_mode_select(self):
self.mode="mode"; self.players=None; self.level=None; self.streak=0; self.p1=0; self.p2=0
self._led_fill(0); self._led_show(); self._set_hud("1P 2P","Select Mode")
def _to_level_select(self):
self.mode="level"; self.level=None
self._led_fill(0); self._led_show(); self._set_hud("Choose Level","L1 L2 L3 L4")
def _start_round(self):
self._stopped=False; self._gen_target_for_level(); self._build_stream()
self.mode="preview"; self._led_fill(0); self._led_show(); self._hud_play()
def _start_stream(self):
self._stopped=False; self.mode="stream"; self.stream_idx=-1
now=time.monotonic(); self._in_gap=True; self._gap_until=now; self._show_until=0
self._led_fill(0); self._led_show(); self._hud_play(streaming=True)
def _to_result(self,outcome,buzzer=None):
self.mode="result"; self._result=outcome; self._result_buzzer=buzzer
if self.players==1:
if outcome=="correct": self.streak+=1; self._sound_win()
else: self._sound_lose(); self.streak=0
else:
if buzzer is None: self._sound_lose()
else:
if outcome=="correct":
if buzzer==1: self.p1+=1
else: self.p2+=1
self._sound_win()
else:
if buzzer==1 and self.p1>0: self.p1-=1
if buzzer==2 and self.p2>0: self.p2-=1
self._sound_lose()
if self.players==2 and (self.p1>=self.POINTS_TO_WIN or self.p2>=self.POINTS_TO_WIN):
self._win_game_player=1 if self.p1>=self.POINTS_TO_WIN else 2
else: self._win_game_player=None
self._hud_result()
# ---------- Pattern utilities ----------
_ROT90={0:2,1:5,2:8,3:1,4:4,5:7,6:0,7:3,8:6}
def _rot_set(self,cells,k):
out=set(cells)
for _ in range(k%4): out={self._ROT90[i] for i in out}
return out
def _sample_unique(self,pool,k):
lst=list(pool); n=len(lst)
if k>=n: return lst[:]
for i in range(k):
j=random.randint(i,n-1); lst[i],lst[j]=lst[j],lst[i]
return lst[:k]
def _gen_target_for_level(self):
lvl=self.level
if lvl==1: n=random.choice((2,3)); rot_ok=False; blinking=False
elif lvl==2: n=random.choice((4,5)); rot_ok=False; blinking=False
elif lvl==3: n=random.choice((4,5)); rot_ok=True; blinking=False
else: n=random.choice((5,6,7)); rot_ok=True; blinking=True
cells=set(self._sample_unique(self.CELLS,n)); blink=set()
if blinking:
max_blink=max(1,min(3,n-1)); k=random.randint(1,max_blink)
blink=set(self._sample_unique(cells,k)); cells=cells-blink
self.target_solid=set(cells); self.target_blink=set(blink); self.rot_ok=rot_ok
if self.rot_ok:
cands=[]
for r in range(4):
cands.append((tuple(sorted(self._rot_set(self.target_solid,r))),
tuple(sorted(self._rot_set(self.target_blink,r)))))
self._canon=min(cands)
else: self._canon=(tuple(sorted(self.target_solid)),tuple(sorted(self.target_blink)))
def _equal_match(self,solid,blink):
if not self.rot_ok: return (tuple(sorted(solid)),tuple(sorted(blink)))==self._canon
for r in range(4):
if (tuple(sorted(self._rot_set(solid,r))),
tuple(sorted(self._rot_set(blink,r))))==self._canon: return True
return False
def _nearby_variant(self,base_s,base_b,rot_choices):
s=set(base_s); b=set(base_b); tweaks=random.choice((1,1,2))
pool_on=s|b; pool_off=set(self.CELLS)-pool_on
for _ in range(tweaks):
if pool_on and pool_off and random.random()<0.7:
off=random.choice(tuple(pool_off)); on=random.choice(tuple(pool_on))
if on in s: s.remove(on); s.add(off)
else: b.remove(on); b.add(off)
pool_on=s|b; pool_off=set(self.CELLS)-pool_on
else:
if b and s and random.random()<0.5: x=random.choice(tuple(b)); b.remove(x); s.add(x)
elif b: x=random.choice(tuple(b)); b.remove(x); s.add(x)
elif s and self.level==4 and random.random()<0.3: x=random.choice(tuple(s)); s.remove(x); b.add(x)
if rot_choices:
r=random.choice(rot_choices); s=self._rot_set(s,r); b=self._rot_set(b,r)
return s,b
def _build_stream(self):
L=random.randint(7,11); self.stream=[]
correct_idx=random.randint(2,L-2) if L>=5 else random.randint(1,L-1)
rot_choices=[0] if not self.rot_ok else [0,1,2,3]
match_rot=0 if not self.rot_ok else random.choice([0,1,2,3])
match_s=self._rot_set(self.target_solid,match_rot); match_b=self._rot_set(self.target_blink,match_rot)
for i in range(L):
if i==correct_idx: self.stream.append((set(match_s),set(match_b),True))
else:
while True:
s,b=self._nearby_variant(self.target_solid,self.target_blink,
rot_choices if self.rot_ok else [0])
if not self._equal_match(s,b): self.stream.append((s,b,False)); break
# ---------- Streaming logic ----------
def _advance_stream(self,now):
if self._in_gap:
if now>=self._gap_until:
self.stream_idx+=1
if self.stream_idx>=len(self.stream):
self._to_result("timeout",buzzer=None); return
self._in_gap=False; self._show_until=now+self.STREAM_SHOW
return
if now>=self._show_until:
self._in_gap=True; self._gap_until=now+self.STREAM_GAP
def _on_stop(self,buzzer):
if self._in_gap and self.stream_idx<0: return
if getattr(self,"_stopped",False): return
self._stopped=True
idx=max(0,self.stream_idx)
if 0<=idx<len(self.stream): _,_,is_match=self.stream[idx]; outcome="correct" if is_match else "wrong"
else: outcome="wrong"
self._to_result(outcome,buzzer=buzzer)
# ---------- Rendering / HUD / LEDs ----------
def _render_ui(self,now):
self._led_fill(0); pulse=self._pulse(now)
if self.mode=="mode":
self._led_set(3,self._scale(self.COLOR_HINT,0.25+0.60*pulse))
self._led_set(5,self._scale(self.COLOR_HINT,0.25+0.60*pulse))
self._set_hud("1P 2P","Select Mode")
elif self.mode=="level":
for k in (0,1,2,3): self._led_set(k,self._scale(self.COLOR_UI,0.15+0.70*pulse))
self._led_set(self._key_same(),self._scale(self.COLOR_UI,0.10))
if self.players==2:
dim=self._scale(self.COLOR_UI,0.10)
self._led_set(self.P1_BUZZ,dim); self._led_set(self.P2_BUZZ,dim)
self._set_hud("Choose Level","L1 L2 L3 L4")
self._led_show()
def _render_preview(self,now):
self._led_fill(0); blink_on=self._blink_on(now)
for i in self.target_solid: self._led_set(i,self.COLOR_SOLID)
for i in self.target_blink: self._led_set(i,self.COLOR_BLINK if blink_on else 0x000000)
if self.players==2:
self._led_set(self.P1_BUZZ,self._scale(self.COLOR_UI,0.10))
self._led_set(self.P2_BUZZ,self._scale(self.COLOR_UI,0.10))
else:
self._led_set(self.K_COMP,self._scale(self.COLOR_UI,0.12+0.30*self._pulse(now)))
self._led_set(self._key_same(),self._scale(self.COLOR_UI,0.10))
self._led_show(); self._hud_play(streaming=False)
def _render_stream(self,now):
self._led_fill(0)
if self.stream and (0<=self.stream_idx<len(self.stream)) and (not self._in_gap):
s,b,_=self.stream[self.stream_idx]; blink_on=self._blink_on(now)
for i in s: self._led_set(i,self.COLOR_SOLID)
for i in b: self._led_set(i,self.COLOR_BLINK if blink_on else 0x000000)
if self.players==1:
self._led_set(self.K_COMP,self._scale(self.COLOR_UI,0.18+0.35*self._pulse(now)))
else:
pulse=self._pulse(now); glow=self._scale(self.COLOR_UI,0.12+0.30*pulse)
self._led_set(self.P1_BUZZ,glow); self._led_set(self.P2_BUZZ,glow)
self._led_set(self._key_same(),self._scale(self.COLOR_UI,0.10))
self._led_show(); self._hud_play(streaming=True)
def _render_result(self,now):
self._led_fill(0)
if self.players==2 and self._win_game_player:
pulse=0.30+0.60*self._pulse(now); gold=self._scale(0xFFD200,pulse)
for i in range(9): self._led_set(i,0)
for i in (1,3,4,5,7): self._led_set(i,gold)
win_bz=self.P1_BUZZ if self._win_game_player==1 else self.P2_BUZZ
lose_bz=self.P2_BUZZ if win_bz==self.P1_BUZZ else self.P1_BUZZ
self._led_set(win_bz,gold); self._led_set(lose_bz,self._scale(self.COLOR_UI,0.12))
self._led_set(self._key_same(),self._scale(self.COLOR_UI,0.10))
self._led_set(self.K_COMP,self._scale(self.COLOR_UI,0.12+0.30*self._pulse(now)))
self._led_show(); return
if self.players==1:
pulse=0.55+0.35*self._pulse(now*0.6)
for i in self.target_solid: self._led_set(i,self._scale(self.COLOR_SOLID,pulse))
if self._blink_on(now):
for i in self.target_blink: self._led_set(i,self._scale(self.COLOR_BLINK,pulse))
self._led_set(self._key_same(),self._scale(self.COLOR_UI,0.10))
self._led_set(self.K_COMP,self._scale(self.COLOR_UI,0.12+0.30*self._pulse(now)))
self._led_show(); return
else:
for i in range(9): self._led_set(i,0)
for i in range(min(self.p1,3)): self._led_set(6+i,0x00FF40)
for i in range(min(self.p2,3)): self._led_set(0+i,0x00FF40)
if self.p1>=4: self._led_set(5,0x00FF40)
if self.p2>=4: self._led_set(3,0x00FF40)
pulse=self._pulse(now); next_glow=self._scale(self.COLOR_UI,0.12+0.30*pulse)
self._led_set(self.P1_BUZZ,next_glow); self._led_set(self.P2_BUZZ,next_glow)
self._led_set(self._key_same(),self._scale(self.COLOR_UI,0.10))
self._led_set(self.K_COMP,self._scale(self.COLOR_UI,0.12+0.30*pulse))
self._led_show()
# ---------- HUD helpers ----------
def _hud_play(self,streaming=False):
if self.players==1:
if streaming: self._set_hud("Find the Match","New Stop")
else: self._set_hud("Memorize Pattern","New Start")
else:
if streaming: self._set_hud("Buzz on the Match","P1 New P2")
else: self._set_hud("Memorize Pattern","P1 New P2")
def _hud_result(self):
if self.players==1:
if self._result=="correct": self._set_hud(f"Correct — Streak {self.streak}","New Next")
elif self._result=="timeout": self._set_hud("Timeout — Streak 0","New Next")
else: self._set_hud("Wrong — Streak 0","New Next")
else:
score=f"P1 {self.p1} P2 {self.p2}"
if self._win_game_player: self._set_hud(f"Winner: P{self._win_game_player}",score)
else:
if self._result=="timeout": self._set_hud("No Buzz",score)
else:
side=f"P{self._result_buzzer}"; verdict="Correct" if self._result=="correct" else "Wrong"
self._set_hud(f"{side}: {verdict}",score)
# ---------- Sound helpers ----------
def _play(self,f,d):
try: self.mac.play_tone(f,d)
except Exception: pass
def _sound_win(self):
for f in (660,880,990): self._play(f,0.05)
def _sound_lose(self):
for f in (196,150): self._play(f,0.07)
# ---------- LED helpers ----------
def _led_set(self,idx,color):
if 0<=idx<12 and self._led[idx]!=color: self._led[idx]=color; self._led_dirty=True
def _led_fill(self,color):
ch=False
for i in range(12):
if self._led[i]!=color: self._led[i]=color; ch=True
if ch: self._led_dirty=True
def _led_show(self):
now=time.monotonic()
if not self._led_dirty or (now-self._last_led_show)<self.LED_FRAME_DT: return
for i,c in enumerate(self._led): self.mac.pixels[i]=c
self._last_led_show=now; self._led_dirty=False
try: self.mac.pixels.show()
except AttributeError: pass
def _blink_on(self,now): return ((now*self.BLINK_HZ)%1.0)<0.5
def _pulse(self,now): return 0.5+0.5*math.cos(now*2*math.pi*0.8)
def _scale(self,color,s):
if s<=0: return 0
if s>=1: return color
r=(color>>16)&0xFF; g=(color>>8)&0xFF; b=color&0xFF
return ((int(r*s)<<16)|(int(g*s)<<8)|(int(b*s)))