임베디드 입력·화면 상태

버튼 3개와 OLED: 메뉴와 실행 상태를 나누는 법

메뉴에서 누른 SELECT가 다음 화면에서도 눌린 상태라면, 항목을 고르자마자 실행까지 넘어갈 수 있습니다. 버튼 수를 늘리기 전에 현재 화면의 상태와 한 번의 입력이 허용하는 일을 정합니다.

같은 버튼이라도 현재 상태에 따라 역할이 달라집니다

상태 머신(state machine)은 장치가 지금 어떤 단계에 있는지 저장하고, 허용된 입력을 받았을 때 정해진 다음 단계로 옮기는 구조입니다. 여기서는 메뉴(MENU), 시작 전 준비(READY), 실행(PLAY), 결과(RESULT) 네 상태를 예로 듭니다. 메뉴에서 LEFT·RIGHT는 선택 항목을 바꾸고, 실행 중에는 이동 방향을 바꿉니다. SELECT의 의미도 현재 상태가 결정합니다.

현재 상태LEFT·RIGHTSELECT그 밖의 전이
MENU새로 누를 때 한 항목 이동선택한 항목의 READY로없음
READY무시새로 누르면 PLAY로없음
PLAY누르는 동안 방향 입력이 예시에서는 무시실행부의 완료 신호로 RESULT
RESULT무시새로 누르면 MENU로없음
MENU에서 SELECT로 READY, 다시 SELECT로 PLAY, 완료 신호로 RESULT, SELECT로 MENU에 돌아온다. 각 상태 진입 후 모든 버튼을 놓아야 다음 동작을 받는다.
이 글에서 만든 설명용 상태 전이도입니다. 원본 제작자의 펌웨어나 게임 규칙을 재현한 그림이 아닙니다.

눌림 이벤트와 유지 입력을 구분합니다

눌림 이벤트(press event)는 안정된 입력이 ‘놓음’에서 ‘누름’으로 바뀌는 순간입니다. 유지 입력(held input)은 지금도 누르고 있는지를 뜻합니다. 메뉴를 한 칸 이동할 때는 이벤트를, 패들을 계속 움직일 때는 유지 입력을 사용할 수 있습니다. 화면을 여러 번 그렸다는 이유로 메뉴가 여러 칸 움직이면 표시와 입력 처리가 섞인 것입니다.

기계식 버튼은 접점이 튀므로 먼저 바운스를 제거해 안정된 입력을 만드는 단계가 필요합니다. 안정화와 이벤트 검출은 서로 다른 일입니다. Arduino의 공식 Debounce 예시는 입력 변화 뒤 안정 시간을 확인하고, StateChangeDetection 예시는 이전 입력과 현재 입력을 비교합니다. 이 글은 그 예시의 핀 번호·저항·시간 값을 ESP32 배선이나 모든 버튼의 권장값으로 옮기지 않습니다.

상태가 바뀌면 모든 버튼을 놓을 때까지 다음 명령을 받지 않는 규칙을 둡니다. MENU에서 SELECT를 길게 눌러도 READY까지만 이동하며, 놓고 다시 눌러야 PLAY가 됩니다. 부팅 때 버튼이 눌려 있어도 첫 해제를 기다립니다. 디바운스가 되어 있어도 필요한 별도의 규칙입니다.

입력 한 번을 두 상태에서 소비하지 않습니다

아래는 GPIO나 OLED 드라이버와 분리한 설명용 JavaScript 모델입니다. 전체 상태 모델에서 LEFT=1, SELECT=2, RIGHT=4 비트를 사용합니다. held는 같은 시점에 읽고 안정화한 세 버튼의 스냅샷이어야 합니다. 하드웨어를 읽거나 화면을 그리는 코드는 포함하지 않았으며, 실제 ESP32 펌웨어가 아닙니다.

import {createUI, stepUI, SELECT} from './ui-state.mjs';
let ui = createUI(3);
ui = stepUI(ui, 0);       // boot: release all buttons
ui = stepUI(ui, SELECT);  // MENU -> READY
ui = stepUI(ui, SELECT);  // still READY
ui = stepUI(ui, 0);       // release before next action
ui = stepUI(ui, SELECT);  // READY -> PLAY
ui = stepUI(ui, 0);       // release entry gate
ui = stepUI(ui, 0, true); // game finished -> RESULT

한 번의 호출에서는 호출 전 상태 하나만 판정합니다. 메뉴·준비·결과에서 둘 이상의 버튼을 동시에 누르면 명령을 무시하고, 남은 버튼이 계속 눌려 있는 것만으로 새 명령을 만들지 않습니다. 실행 중에는 LEFT만 누르면 방향 -1, RIGHT만 누르면 +1, 둘 다 누르거나 둘 다 놓으면 0입니다. SELECT에는 실행 중 동작을 배정하지 않았습니다. 완료 신호는 PLAY에서만 받으며, 그 순간 눌려 있던 버튼은 결과 화면의 새 명령으로 넘기지 않습니다.

방향 값은 픽셀 이동량이나 속도가 아닙니다. 실제 이동량은 실행부에서 경과 시간과 경계 조건으로 계산해야 합니다. 게임 종료 판정, 일시정지, 길게 누름 반복, 뒤로 가기 같은 기능도 별도 설계 대상입니다. 필요하면 상태·이벤트 표와 검사를 함께 확장합니다.

화면은 상태를 바꾸지 않고 읽어서 표시합니다

루프의 순서는 입력 수집과 안정화 → 상태 갱신 한 번 → 같은 상태의 화면 그리기로 정합니다. 상태·선택 번호·점수 등을 화면용 스냅샷으로 고정하고, 그리기 함수 안에서 항목을 이동시키거나 게임을 진행하지 않습니다.

U8g2의 페이지 버퍼 방식은 firstPage()/nextPage() 루프 안에서 그리기 코드를 여러 번 호출할 수 있습니다. 매번 동일한 화면 상태를 그려야 합니다. 전체 버퍼용 생성자라면 clearBuffer()·그리기·sendBuffer() 흐름을 사용합니다. 두 방식을 섞지 말고 선택한 생성자와 버퍼 방식에 맞춥니다.

화면 전송이 느려지면 버튼을 읽는 주기도 영향을 받을 수 있습니다. OLED 전송 전후의 입력 누락과 반응 시간을 실제 보드에서 확인합니다. 128×64라는 해상도만으로 전송 주기, 프레임률, 버튼 반응 속도를 보장할 수 없습니다.

실제 보드에서는 입력과 표시를 나누어 확인합니다

  1. 먼저 버튼의 실제 연결과 활성 레벨, 입력 전압, 필요한 풀업·풀다운을 확인합니다. OLED의 전원·통신은 패널과 모듈의 접속 조건에 따라 확인합니다. 사진만 보고 GPIO나 I²C 주소를 정하지 않습니다.
  2. 버튼을 놓고 부팅한 경우와 누른 채 부팅한 경우를 각각 기록합니다. 길게 누른 SELECT 한 번으로 MENU에서 PLAY까지 건너뛰지 않아야 합니다.
  3. 짧게 누름, 계속 누름, 놓았다 다시 누름, 좌우 동시 누름, 여러 버튼을 순서대로 놓는 경우를 시험합니다. 상태·안정된 입력·검출 이벤트를 함께 기록하면 중복 소비를 찾기 쉽습니다.
  4. 화면 갱신만 반복했을 때 선택 번호나 실행 상태가 바뀌지 않는지 확인합니다. 표시 속도를 바꿔도 입력 규칙이 같아야 합니다.
  5. 실행 완료와 SELECT 입력이 겹쳐도 RESULT가 건너뛰어지지 않는지 확인합니다. 소프트웨어 모델 검사를 통과했다고 해서 실제 접점 바운스·배선·화면 전송 검사를 생략할 수는 없습니다.

근거와 예시의 범위

LionCircuits #2017의 보존된 문자 기록과 제작자 캡션에는 ESP32 DevKit V1, SSD1306 128×64 화면, U8g2, LEFT·SELECT·RIGHT 버튼, 메뉴에서 게임을 선택하는 흐름이 설명되어 있습니다. 이것은 제작자의 설명이며, 원본 영상·음원을 직접 재생하거나 해당 보드를 시험한 결과가 아닙니다. 배선, GPIO, 디바운스 구현과 실제 전체 펌웨어는 확인되지 않았습니다.

위 네 상태, 해제 대기 규칙, 동시 입력 정책과 JavaScript 코드는 이 글의 독립적인 설계 예시입니다. 원본 게임의 실행 성능이나 알고리즘을 복원한 것이 아닙니다. 일반적인 입력 처리와 표시 루프 설명은 본문에 연결한 Arduino·U8g2 공식 자료와 구분해 제시했습니다.