[+] Mai touch taiko controller
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
# Requirements: pyserial, hypy_utils, pygame
|
||||
import ctypes
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
from typing import NamedTuple, Literal
|
||||
|
||||
import pygame
|
||||
import serial
|
||||
from hypy_utils.logging_utils import setup_logger
|
||||
|
||||
log = setup_logger()
|
||||
|
||||
|
||||
class TouchArea(NamedTuple):
|
||||
category: Literal['A', 'B', 'C', 'D', 'E']
|
||||
index: int
|
||||
|
||||
def __str__(self):
|
||||
return self.category + str(self.index)
|
||||
|
||||
|
||||
touch_areas = (
|
||||
[TouchArea('A', i + 1) for i in range(8)] +
|
||||
[TouchArea('B', i + 1) for i in range(8)] +
|
||||
[TouchArea('C', i + 1) for i in range(2)] +
|
||||
[TouchArea('D', i + 1) for i in range(8)] +
|
||||
[TouchArea('E', i + 1) for i in range(8)]
|
||||
)
|
||||
|
||||
|
||||
def convert(d: str) -> list[TouchArea]:
|
||||
"""
|
||||
Convert maimai touch data string into touch areas activated.
|
||||
|
||||
The maimai touch data is a letter representation of bitwise data.
|
||||
|
||||
When nothing is touched, d = "@@@@@@@"
|
||||
When A1 is touched, d = "A@@@@@@"
|
||||
When A2 is touched, d = "B@@@@@@"
|
||||
When A3 is touched, d = "D@@@@@@"
|
||||
When both A1 and A2 are touched, d = "C@@@@@@" (A + B = C)
|
||||
When both A1 and A3 are touched, d = "E@@@@@@" (A + D = E)
|
||||
When all three are touched, d = "G@@@@@@" (A + B + D = G)
|
||||
|
||||
Raw letter orders are A (1) B (10) D (100) H (1000) P (10000)
|
||||
|
||||
:param d: Maimai touch data (single frame)
|
||||
:return: List of touch areas activated
|
||||
"""
|
||||
# Loop through each digit
|
||||
for ci, c in enumerate(d):
|
||||
if c == "@":
|
||||
continue
|
||||
|
||||
# Convert it to ordinal number (A = 1)
|
||||
# The binary of oi will show 5 bits, each representing if the touch area is activated.
|
||||
oi = ord(c) - ord('A') + 1
|
||||
|
||||
# Find bits activated
|
||||
for i in range(5):
|
||||
if oi & (1 << i):
|
||||
yield touch_areas[ci * 5 + i]
|
||||
|
||||
|
||||
def serial_listen(callback: callable, port: str = 'COM3'):
|
||||
"""
|
||||
Listen for serial data from mai touch device.
|
||||
|
||||
Official port: COM3
|
||||
Baud rate: 9600
|
||||
Frame format: (@@@@@@@)
|
||||
|
||||
:param callback: Callback function when touch data is received
|
||||
:param port: Serial port to listen to
|
||||
"""
|
||||
# Open serial
|
||||
ser = serial.Serial(port, 9600)
|
||||
|
||||
# Align frame
|
||||
while ser.read(1).decode('utf-8') != ')':
|
||||
pass
|
||||
|
||||
# Loop forever
|
||||
frame = ''
|
||||
while True:
|
||||
# Read a frame
|
||||
frame += ser.read(9).decode('utf-8')
|
||||
log.debug(f"Received frame: {frame}")
|
||||
|
||||
assert frame[0] == '('
|
||||
assert frame[-1] == ')'
|
||||
assert len(frame) == 9
|
||||
|
||||
# Parse
|
||||
ta = list(convert(frame[1:-1]))
|
||||
frame = ''
|
||||
|
||||
# Callback
|
||||
callback(ta)
|
||||
|
||||
|
||||
def serial_listen_btn(callback: callable, port: str = 'COM25'):
|
||||
"""
|
||||
Listen for serial data from mai_pico io4 buttons.
|
||||
|
||||
Frame format: BTN 000000000000;\n
|
||||
|
||||
:param callback: Callback function when button state changes
|
||||
:param port: Serial port to listen to
|
||||
"""
|
||||
# Open serial
|
||||
ser = serial.Serial(port, 9600)
|
||||
|
||||
# Loop forever
|
||||
while True:
|
||||
ser.read_until(b'BTN ')
|
||||
frame = ser.read_until(b';').decode('utf-8')
|
||||
log.debug(f"Received frame: {frame}")
|
||||
|
||||
# Callback
|
||||
callback(frame.strip(';'))
|
||||
|
||||
|
||||
def test_callback(ta: list[TouchArea]):
|
||||
log.info(f"Received touch areas: {' '.join(map(str, ta))}")
|
||||
|
||||
|
||||
def test_callback_btn(btn: str):
|
||||
log.info(f"Received button state: {btn}")
|
||||
|
||||
|
||||
# Configuration: VK codes for DFJK
|
||||
TAIKO_KEYS = [0x44, 0x46, 0x4A, 0x4B]
|
||||
SCAN_CODES = [ctypes.windll.user32.MapVirtualKeyA(k, 0) for k in TAIKO_KEYS]
|
||||
|
||||
# State and macros
|
||||
TAIKO_STATES = [False] * len(TAIKO_KEYS)
|
||||
TAIKO_LEFT_KA = 0
|
||||
TAIKO_LEFT_DON = 1
|
||||
TAIKO_RIGHT_DON = 2
|
||||
TAIKO_RIGHT_KA = 3
|
||||
|
||||
|
||||
def taiko_callback(ta: list[TouchArea]):
|
||||
"""
|
||||
Convert mai touch to taiko keyboard input
|
||||
|
||||
D1, D5, E1, E5 are ignored (they're located in the center
|
||||
|
||||
A1-4, B1-4, D2-4, E2-4, C1 are right Don
|
||||
Others are left Don
|
||||
"""
|
||||
states = [False] * len(TAIKO_KEYS)
|
||||
for t in ta:
|
||||
if str(t) in ('D1', 'D5', 'E1', 'E5'):
|
||||
continue
|
||||
if (t.category in 'ABDE' and t.index in (1, 2, 3, 4)) or \
|
||||
(t.category == 'C' and t.index == 1):
|
||||
states[TAIKO_RIGHT_DON] = True
|
||||
else:
|
||||
states[TAIKO_LEFT_DON] = True
|
||||
taiko_after_update(states)
|
||||
|
||||
|
||||
def taiko_after_update(states: list[bool], is_btn: bool = False):
|
||||
# Check state changes
|
||||
for i, (old, new) in enumerate(zip(TAIKO_STATES, states)):
|
||||
if ((is_btn and i in (TAIKO_LEFT_DON, TAIKO_RIGHT_DON)) or
|
||||
(not is_btn and i in (TAIKO_LEFT_KA, TAIKO_RIGHT_KA))):
|
||||
continue
|
||||
if old == new:
|
||||
continue
|
||||
|
||||
TAIKO_STATES[i] = new
|
||||
# Send key up/down
|
||||
ctypes.windll.user32.keybd_event(TAIKO_KEYS[i], SCAN_CODES[i], 0 if new else 2, 0)
|
||||
|
||||
|
||||
def taiko_callback_btn(btn: str):
|
||||
"""
|
||||
Convert mai_pico io4 buttons to taiko keyboard input
|
||||
|
||||
Button index 0-3 are right Ka, 4-7 are left Ka
|
||||
"""
|
||||
print(btn)
|
||||
states = [False] * len(TAIKO_KEYS)
|
||||
states[TAIKO_RIGHT_KA] = '1' in btn[0:4]
|
||||
states[TAIKO_LEFT_KA] = '1' in btn[4:8]
|
||||
taiko_after_update(states, True)
|
||||
|
||||
|
||||
def start_serial_threads():
|
||||
thread1 = threading.Thread(target=serial_listen, args=(taiko_callback, 'COM3'))
|
||||
thread2 = threading.Thread(target=serial_listen_btn, args=(taiko_callback_btn, 'COM25'))
|
||||
thread1.start()
|
||||
thread2.start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Default window location at bottom
|
||||
os.environ['SDL_VIDEO_WINDOW_POS'] = f'0,{1920-1080}'
|
||||
|
||||
# Start pygame window
|
||||
pygame.init()
|
||||
W = 1080
|
||||
CW = 0.8
|
||||
pygame.display.set_mode((W, W))
|
||||
pygame.display.set_caption("MaiTouch")
|
||||
|
||||
# Start serial threads
|
||||
start_serial_threads()
|
||||
|
||||
COLOR_DON_OFF = '#fc9f9f'
|
||||
COLOR_DON_ON = '#ff4242'
|
||||
COLOR_KA_OFF = '#9fe3fc'
|
||||
COLOR_KA_ON = '#0f68f7'
|
||||
DELTA_DEG = 0.1
|
||||
|
||||
def color(idx):
|
||||
if idx in (TAIKO_LEFT_KA, TAIKO_RIGHT_KA):
|
||||
return COLOR_KA_ON if TAIKO_STATES[idx] else COLOR_KA_OFF
|
||||
return COLOR_DON_ON if TAIKO_STATES[idx] else COLOR_DON_OFF
|
||||
|
||||
# Game loop
|
||||
try:
|
||||
while True:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
os._exit(0)
|
||||
|
||||
# White background
|
||||
pygame.display.get_surface().fill((255, 255, 255))
|
||||
# Taiko Don (two red half-circles in the center)
|
||||
mar = (W * (1 - CW)) / 2
|
||||
pygame.draw.arc(pygame.display.get_surface(), color(TAIKO_LEFT_DON), (mar, mar, W * CW, W * CW), math.pi / 2, - math.pi / 2, 5000)
|
||||
pygame.draw.arc(pygame.display.get_surface(), color(TAIKO_RIGHT_DON), (mar, mar, W * CW, W * CW), - math.pi / 2, math.pi / 2, 5000)
|
||||
# Taiko Ka (two blue arches (empty half-circles) on the sides)
|
||||
pygame.draw.arc(pygame.display.get_surface(), color(TAIKO_LEFT_KA), (0, 0, W, W), math.pi / 2, - math.pi / 2, 80)
|
||||
pygame.draw.arc(pygame.display.get_surface(), color(TAIKO_RIGHT_KA), (0, 0, W, W), - math.pi / 2, math.pi / 2, 80)
|
||||
# Thin rectangle dividing the middle
|
||||
rw = 20
|
||||
pygame.draw.rect(pygame.display.get_surface(), '#FFFFFF', (W / 2 - rw / 2, 0, rw, W))
|
||||
pygame.display.flip()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
os._exit(0)
|
||||
Reference in New Issue
Block a user