# The code for the actual project and the code here do not match 1-to-1.
# This was required because Wokwi does not have the same MicroPython
# version as the physical project, and so requires changes in how
# the code was written.
from machine import Pin, PWM, SPI, time_pulse_us
import time
import random
from st7735 import TFT
from sysfont import sysfont
class Sensor():
def read(self):
raise NotImplementedError
def triggered(self):
raise NotImplementedError
class Actuator():
def activate(self, *args, **kwargs):
raise NotImplementedError
def stop(self):
raise NotImplementedError
class UltrasonicSensor(Sensor):
_TIMEOUT_US = 30000
_CM_PER_US = 0.0343
def __init__(self, trig_pin, echo_pin, trigger_distance_cm, label="sensor"):
self._trig = Pin(trig_pin, Pin.OUT)
self._echo = Pin(echo_pin, Pin.IN)
self._trigger_distance_cm = trigger_distance_cm
self._label = label
self._last_reading = None
def label(self):
return self._label
def last_reading(self):
return self._last_reading
def read(self):
self._trig.low()
time.sleep_us(2)
self._trig.high()
time.sleep_us(10)
self._trig.low()
duration = time_pulse_us(self._echo, 1, self._TIMEOUT_US)
self._last_reading = None if duration < 0 else (duration * self._CM_PER_US) / 2
return self._last_reading
def triggered(self):
distance = self.read()
return distance is not None and distance < self._trigger_distance_cm
class Servo(Actuator):
_MIN_US = 500
_MAX_US = 2500
_PERIOD_US = 20000
def __init__(self, pin, freq=50):
self._pwm = PWM(Pin(pin))
self._pwm.freq(freq)
self._angle = None
def angle(self):
return self._angle
def activate(self, angle):
angle = max(0, min(180, angle))
pulse_us = self._MIN_US + (angle / 180) * (self._MAX_US - self._MIN_US)
duty = int((pulse_us / self._PERIOD_US) * 65535)
self._pwm.duty_u16(duty)
self._angle = angle
def stop(self):
self._pwm.duty_u16(0)
class Buzzer(Actuator):
def __init__(self, pin):
self._pwm = PWM(Pin(pin))
self._pwm.duty_u16(0)
def activate(self, freq_hz, ms):
self._pwm.freq(max(1, freq_hz))
self._pwm.duty_u16(32768)
time.sleep_ms(ms)
self._pwm.duty_u16(0)
def stop(self):
self._pwm.duty_u16(0)
def shot(self):
for f in range(2200, 500, -120):
self._pwm.freq(f)
self._pwm.duty_u16(32768)
time.sleep_ms(8)
self._pwm.duty_u16(0)
def false_start(self):
for f in (300, 220, 150):
self.activate(f, 120)
time.sleep_ms(30)
def success_chime(self):
for note in (523, 659, 784, 1046):
self.activate(note, 110)
time.sleep_ms(20)
self.activate(1046, 250)
def match_win_fanfare(self):
for note in (523, 659, 784, 1046, 784, 1046, 1318):
self.activate(note, 130)
time.sleep_ms(15)
self.activate(1318, 400)
class Player:
def __init__(self, player_id, trig_pin, echo_pin, trigger_distance_cm):
self._id = player_id
self._sensor = UltrasonicSensor(
trig_pin, echo_pin, trigger_distance_cm, label="player{}".format(player_id)
)
def id(self):
return self._id
def has_shot(self):
return self._sensor.triggered()
class Scoreboard:
def __init__(self, player_ids):
self._scores = {}
for pid in player_ids:
self._scores[pid] = 0
def add_point(self, player_id):
self._scores[player_id] = self._scores.get(player_id, 0) + 1
def score_for(self, player_id):
return self._scores.get(player_id, 0)
def leader_at_or_above(self, threshold):
for pid, score in self._scores.items():
if score >= threshold:
return pid
return None
def as_text(self):
parts = []
for pid, score in self._scores.items():
parts.append("P{}:{}".format(pid, score))
return " ".join(parts)
def reset(self):
for pid in self._scores:
self._scores[pid] = 0
class Display:
_NEUTRAL_ROTATION = 0
_PLAYER_ROTATION = {1: 1, 2: 3}
def __init__(self, sck, mosi, dc, reset, cs, baudrate=20000000):
self._spi = SPI(0, baudrate=baudrate, sck=Pin(sck), mosi=Pin(mosi))
self._tft = TFT(self._spi, dc, reset, cs)
self._tft.initr()
self._tft.rgb(True)
self._tft.rotation(self._NEUTRAL_ROTATION)
self.clear()
def clear(self, color=TFT.BLACK):
self._tft.fill(color)
def _rotate_neutral(self):
self._tft.rotation(self._NEUTRAL_ROTATION)
def _rotate_to_player(self, player_id):
self._tft.rotation(self._PLAYER_ROTATION.get(player_id, self._NEUTRAL_ROTATION))
def _centered_text(self, text, color, bg, size=2):
char_w = sysfont["Width"] * size
char_h = sysfont["Height"] * size
screen_w, screen_h = self._tft.size()
x = max(0, (screen_w - len(text) * char_w) // 2)
y = max(0, (screen_h - char_h) // 2)
self._tft.text((x, y), text, color, sysfont, size, bg, nowrap=True)
def _draw_char_rot90(self, ch, x, y, color, bg, size):
start, end = sysfont["Start"], sysfont["End"]
ci = ord(ch)
if not (start <= ci <= end):
return
font_w, font_h = sysfont["Width"], sysfont["Height"]
offset = (ci - start) * font_w
columns = sysfont["Data"][offset:offset + font_w]
for col in range(font_w):
bits = columns[col]
for row in range(font_h):
on = (bits >> row) & 1
px = x + (font_h - 1 - row) * size
py = y + col * size
if on:
self._tft.fillrect((px, py), (size, size), color)
elif bg is not None:
self._tft.fillrect((px, py), (size, size), bg)
def _draw_char_rot270(self, ch, x, y, color, bg, size):
start, end = sysfont["Start"], sysfont["End"]
ci = ord(ch)
if not (start <= ci <= end):
return
font_w, font_h = sysfont["Width"], sysfont["Height"]
offset = (ci - start) * font_w
columns = sysfont["Data"][offset:offset + font_w]
for col in range(font_w):
bits = columns[col]
for row in range(font_h):
on = (bits >> row) & 1
px = x + row * size
py = y + (font_w - 1 - col) * size
if on:
self._tft.fillrect((px, py), (size, size), color)
elif bg is not None:
self._tft.fillrect((px, py), (size, size), bg)
def _draw_text_rot90(self, text, x, y, color, bg, size=2):
char_h = sysfont["Width"] * size
cursor_y = y
for ch in text:
self._draw_char_rot90(ch, x, cursor_y, color, bg, size)
cursor_y += char_h
def _draw_text_rot270(self, text, x, y, color, bg, size=2):
char_h = sysfont["Width"] * size
cursor_y = y
for ch in reversed(text):
self._draw_char_rot270(ch, x, cursor_y, color, bg, size)
cursor_y += char_h
def show_boot(self):
self._rotate_neutral()
self.clear(TFT.BLACK)
self._centered_text("BOOTING", TFT.WHITE, TFT.BLACK)
def show_win(self, player_id):
self._rotate_to_player(player_id)
self.clear(TFT.GREEN)
self._centered_text("P{} WINS".format(player_id), TFT.BLACK, TFT.GREEN)
def show_lose(self, player_id):
self._rotate_to_player(player_id)
self.clear(TFT.RED)
self._centered_text("P{} LOSES".format(player_id), TFT.WHITE, TFT.RED)
def show_false_start(self, player_id):
self._rotate_to_player(player_id)
self.clear(TFT.MAROON)
self._centered_text("FALSE START", TFT.WHITE, TFT.MAROON)
def show_idle(self):
self._rotate_neutral()
self.clear(TFT.NAVY)
self._centered_text("WAITING", TFT.CYAN, TFT.NAVY)
def show_match_winner(self, player_id):
self._rotate_to_player(player_id)
self.clear(TFT.YELLOW)
self._centered_text("P{} WINS MATCH".format(player_id), TFT.BLACK, TFT.YELLOW)
def show_scoreboard(self, scoreboard):
self._rotate_neutral()
self.clear(TFT.BLACK)
screen_w, screen_h = self._tft.size()
mid_x = screen_w // 2
self._tft.vline((mid_x, 0), screen_h, TFT.GRAY)
size = 2
column_w = sysfont["Height"] * size # width a rotated column takes up
char_h = sysfont["Width"] * size # vertical space each character takes
p1_text = "P1:{}".format(scoreboard.score_for(1))
p2_text = "P2:{}".format(scoreboard.score_for(2))
p1_x = max(0, (mid_x - column_w) // 2)
p1_y = max(0, (screen_h - len(p1_text) * char_h) // 2)
self._draw_text_rot90(p1_text, p1_x, p1_y, TFT.WHITE, TFT.BLACK, size)
p2_x = mid_x + max(0, (mid_x - column_w) // 2)
p2_y = max(0, (screen_h - len(p2_text) * char_h) // 2)
self._draw_text_rot270(p2_text, p2_x, p2_y, TFT.WHITE, TFT.BLACK, size)
class ShootoffGame:
MIN_WAIT = 2.0
MAX_WAIT = 5.0
SERVO_READY = 140
FLAG_DROP_ANGLE = 90
SERVO_WIN_ANGLE = {1: 0, 2: 175}
MATCH_WIN_SCORE = 5
def __init__(self, players, servo, buzzer, display):
self._players = players
self._servo = servo
self._buzzer = buzzer
self._display = display
player_ids = []
for p in players:
player_ids.append(p.id())
self._scoreboard = Scoreboard(player_ids)
def _other_player(self, player):
for p in self._players:
if p.id() != player.id():
return p
return None
def _wait_for_ready(self):
self._servo.activate(self.SERVO_READY)
self._display.show_scoreboard(self._scoreboard)
time.sleep(1)
def _watch_for_false_start(self, wait_time):
start = time.ticks_ms()
wait_ms = int(wait_time * 1000)
while time.ticks_diff(time.ticks_ms(), start) < wait_ms:
for player in self._players:
if player.has_shot():
return player
return None
def _drop_flag_and_wait_for_shot(self):
self._servo.activate(self.FLAG_DROP_ANGLE)
while True:
for player in self._players:
if player.has_shot():
return player
def _celebrate(self, winner):
self._scoreboard.add_point(winner.id())
self._buzzer.shot()
time.sleep_ms(150)
self._servo.activate(self.SERVO_WIN_ANGLE[winner.id()])
self._buzzer.success_chime()
self._display.show_win(winner.id())
loser = self._other_player(winner)
if loser:
time.sleep(1)
self._display.show_lose(loser.id())
self._servo.stop()
time.sleep(2)
def _celebrate_match_win(self, player_id):
self._display.show_match_winner(player_id)
self._buzzer.match_win_fanfare()
time.sleep(3)
self._scoreboard.reset()
def _foul(self, player):
self._buzzer.shot()
time.sleep_ms(150)
self._buzzer.false_start()
self._display.show_false_start(player.id())
time.sleep(1.5)
def play_round(self):
print("play")
self._wait_for_ready()
wait_time = random.uniform(self.MIN_WAIT, self.MAX_WAIT)
early = self._watch_for_false_start(wait_time)
if early:
print("foul")
self._foul(early)
return
print("drop")
winner = self._drop_flag_and_wait_for_shot()
print("win")
self._celebrate(winner)
match_winner_id = self._scoreboard.leader_at_or_above(self.MATCH_WIN_SCORE)
if match_winner_id is not None:
self._celebrate_match_win(match_winner_id)
def run(self):
self._display.show_boot()
self._servo.activate(self.SERVO_READY)
time.sleep(1)
print("ready")
self._servo.stop()
try:
while True:
self.play_round()
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
self._servo.stop()
self._buzzer.stop()
def main():
print("ok")
display = Display(sck=18, mosi=19, dc=20, reset=21, cs=17)
players = [
Player(1, trig_pin=15, echo_pin=14, trigger_distance_cm=10),
Player(2, trig_pin=13, echo_pin=12, trigger_distance_cm=10),
]
servo = Servo(pin=16)
buzzer = Buzzer(pin=2)
game = ShootoffGame(players, servo, buzzer, display)
game.run()
main()The LCD isn't included in this simulation because
there is no matching LCD component in Wokwi