메뉴 전체보기

회원메뉴

무선 게임 컨트롤러 V1 > 게시판

본문 바로가기

쇼핑몰 검색

NO. 3435
제목 : 무선 게임 컨트롤러 V1
2026-09-27 10:32

소개: 무선 게임 컨트롤러 V1


소개: 무선 게임 컨트롤러 V1


소개: 무선 게임 컨트롤러 V1


소개: 무선 게임 컨트롤러 V1


소개: 무선 게임 컨트롤러 V1


여러분, 안녕하세요. 다시 찾아주셔서 감사합니다.

이것은 Arduino Nano ESP32를 기반으로 제작한 무선 블루투스 게임 컨트롤러의 프로토타입입니다.

이 프로젝트의 취지는 전자 부품과 PCB부터 외장 케이스, 펌웨어에 이르기까지 모든 요소를 완전히 오픈소스로 공개하여, 누구나 자신만의 게임 컨트롤러를 제작하거나 개조할 수 있게 하고, 이 설계를 바탕으로 자신만의 프로젝트를 시작할 수 있도록 하는 것입니다.

이 시제품은 그 목표를 향한 첫걸음입니다. 최종 하드웨어를 처음부터 설계하기 전에 핵심 전자 부품을 테스트하고, 맞춤형 컨트롤러가 무선으로 작동하는지 확인하고 싶었습니다.

시제품을 만들 때는 제가 이전에 제작한 유선 게임 컨트롤러 의 PCB를 재사용했습니다. 그 보드는 원래 Arduino Pro Micro와 ATmega32U4를 기반으로 설계되었습니다. Pro Micro를 Arduino Nano ESP32로 교체하면 ESP32-S3에 내장된 Bluetooth Low Energy를 활용하여 동일한 기본 컨트롤러 하드웨어를 무선 게임패드로 바꿀 수 있습니다.

이 시제품은 이미 완전히 작동하며, Bluetooth를 통해 컴퓨터에 연결해 일반 게임 컨트롤러처럼 사용할 수 있습니다. 하드웨어와 펌웨어를 테스트하는 동안 이 컨트롤러로 다음과 같은 게임을 플레이해 왔습니다. Cyberpunk 2077 및 NieR .

이것은 아직 최종 컨트롤러가 아닙니다. 최종 버전에는 완전히 맞춤 설계된 PCB와 용도에 맞게 제작된 케이스가 적용되며, 디자인도 훨씬 더 세련되게 다듬어질 것입니다. 현재로서는 이 시제품을 통해 개념이 작동한다는 것을 입증하고 오픈소스 컨트롤러 프로젝트의 기반을 마련하는 것이 목적입니다.

이 글에서는 이 시제품의 전체 제작 과정을 다루고 있으니, 이제 제작을 시작해 보겠습니다.




STEP 1. 준비물


이번 조립에 사용된 부품들은 다음과 같습니다.

  1. 맞춤형 PCB (이전 프로젝트에서 회수한 것)
  2. 아두이노 나노 ESP32 보드
  3. 직각 푸시 버튼
  4. 가로형 푸시 버튼
  5. 아날로그 조이스틱
  6. 휴대용 충전기
  7. USB-C 케이블
  8. 양면 테이프




STEP 2. 하드웨어 - 아두이노 게임 컨트롤러


하드웨어 - 아두이노 게임 컨트롤러


하드웨어 - 아두이노 게임 컨트롤러


아두이노 나노 ESP32가 이 프로젝트의 ‘두뇌’라면, 앞서 제작한 게임 컨트롤러 PCB는 단연코 ‘근력’이라 할 수 있습니다.

이번 프로젝트에서는 이전 아두이노 게임 컨트롤러 프로젝트에서 사용했던 메인 조이스틱 및 버튼 PCB를 재사용하고 있습니다. 원본 버전에서는 아두이노 프로 마이크로를 이 PCB에 연결하여 컨트롤러와 PC 간의 인터페이스 역할을 하게 했습니다. ATmega32U4의 기본 USB HID 지원 기능을 활용하여 게임을 제어하고, 이 보드를 일반 게임 컨트롤러로 사용할 수 있었습니다.

하지만 이 컨트롤러에서 가장 흥미로운 점은 보드에 있는 10개의 버튼을 모두 제어하는 데 단 하나의 I/O 핀만 사용된다는 것입니다. 저는 기발한 저항 분압 회로 구성을 활용하여 이를 구현했는데, 이를 통해 마이크로컨트롤러가 단일 아날로그 핀에서 읽은 서로 다른 전압 레벨을 바탕으로 어떤 버튼이 눌렸는지 식별할 수 있습니다. 다음 단계에서 이 원리에 대해 설명하겠습니다.

또한 이 PCB를 설계할 때 두 개의 썸스틱을 장착할 수 있도록 여유를 두었는데, 이번 무선 버전에서 그 장점을 잘 활용할 수 있었습니다.

원본 컨트롤러의 구조와 그 외 설계 결정 사항에 대해 더 자세히 알아보려면 해당 프로젝트 페이지를 확인해 보세요.

https://www.instructables.com/Arduino-Retro-Game-Controller




STEP 3. PCB 설계


PCB 설계


PCB 설계


PCB 설계


이 프로젝트에서는, 원래 버전이 유선 게임 컨트롤러로 설계되었기 때문에, HID를 지원하는 마이크로컨트롤러의 GPIO 핀에 버튼이 연결된, 꽤 전통적이고 단순한 게임 컨트롤러 PCB를 제작했습니다.

흥미로운 점은 단 하나의 I/O 핀으로 어떻게 10개의 버튼을 연결해 냈는지입니다.

이를 위해 VCC와 버튼 입력 단자 사이에 1 kΩ 저항 12개를 직렬로 연결한 저항 사다리 회로를 구성했습니다. 각 버튼은 저항 사다리의 서로 다른 지점에 연결되어 있으며, 각 스위치의 다른 쪽 끝은 접지에 연결되어 있습니다.

버튼이 눌리면, 저항 사다리 회로 내에서의 위치에 따라 공통 아날로그 입력 단자에 서로 다른 전압이 발생합니다. 마이크로컨트롤러는 ADC를 통해 이 전압을 읽어들이고, 측정된 값을 바탕으로 어떤 버튼이 눌렸는지 판단할 수 있습니다.

이 방식을 사용하면 버튼 하나하나에 별도의 GPIO 핀을 할당할 필요 없이, 단일 아날로그 I/O 핀에 다수의 버튼을 연결할 수 있습니다. 실제로 이 기법을 활용하면 저항 값, ADC 분해능, 그리고 허용 가능한 오차 범위에 따라 단일 입력 핀에 10~20개, 혹은 그 이상의 버튼을 연결할 수 있습니다.




STEP 4. 전압 분배기-버튼 구성 방식의 작동 원리


전압 분배기-버튼 구성 방식의 작동 원리


각 버튼의 값을 읽어오기 위해, 예제 메뉴에 있는 ‘Analog Serial Read’ 스케치를 사용하여 각 버튼의 값을 읽어왔습니다.

void setup() {
Serial.begin(9600);
}
void loop() {
int sensorValue = analogRead(A0);
Serial.println(sensorValue);
delay(1); // delay in between reads for stability
}

A0 아날로그 핀은 버튼이 눌렸을 때 저항 사다리 회로에서 생성되는 다양한 전압 값을 읽어들이는 데 사용됩니다.

예를 들어, ‘위’ 버튼을 누르면 약 0의 아날로그 값이 표시되고, ‘아래’ 버튼을 누르면 약 511의 값이 표시됩니다. 각 버튼마다 고유한 값이 표시됩니다.

코드에서는 각 버튼에 대한 예상값과 약간의 허용 오차 범위를 정의합니다. 컨트롤러는 A0 핀에서 아날로그 값을 지속적으로 읽어와 미리 정의된 값들과 비교합니다. 읽은 값이 특정 버튼의 예상 범위 내에 속하면 해당 버튼이 눌린 것으로 간주하고, 그렇지 않으면 버튼이 놓인 것으로 처리합니다.

이 기능 덕분에 컨트롤러는 단일 아날로그 입력을 통해 여러 개의 버튼을 식별할 수 있습니다.

Pro Micro를 Arduino Nano ESP32로 교체한 후에도, 기존 컨트롤러의 이 부분은 전혀 변함이 없습니다. 저항 사다리와 A0 입력은 여전히 모든 버튼 입력을 처리하며, 단지 해당 입력을 처리하는 마이크로컨트롤러만 변경되었을 뿐입니다.




STEP 5. 하드웨어 - 아두이노 나노 ESP32


하드웨어 - 아두이노 나노 ESP32


하드웨어 - 아두이노 나노 ESP32


이 프로젝트의 핵심은 아두이노 나노 ESP32로, 게임 컨트롤러의 주 마이크로컨트롤러 역할을 합니다.

이 프로토타입에서는 기본적으로 이전 유선 컨트롤러에 사용했던 아두이노 프로 마이크로 클론을 아두이노 나노 ESP32로 교체했습니다. 프로 마이크로는 ATmega32U4를 기반으로 하는 반면, 나노 ESP32는 ESP32-S3를 기반으로 제작되어 컨트롤러의 처리 능력, 메모리 용량, 연결성이 크게 향상되었습니다.

Arduino Pro Micro는 16 MHz로 구동되는 8비트 ATmega32U4 AVR 마이크로컨트롤러를 사용합니다. 이 칩은 32KB의 플래시 메모리, 2.5KB의 SRAM, 1KB의 EEPROM을 갖추고 있습니다. 기존 컨트롤러에 비해 가장 큰 장점은 네이티브 USB 지원으로, 이를 통해 USB HID 장치로서 컴퓨터와 통신할 수 있습니다.

Nano ESP32는 Xtensa LX7 아키텍처를 기반으로 한 32비트 듀얼 코어 마이크로컨트롤러인 ESP32-S3를 사용하며, 클럭 속도는 최대 240 MHz입니다. ESP32-S3에는 512 KB의 SRAM, 384 KB의 ROM 및 추가 RTC 메모리가 포함되어 있습니다. 또한 Nano ESP32 보드는 프로그램 저장을 위한 외부 플래시 메모리를 제공합니다.

하지만 이 프로젝트의 가장 큰 장점은 연결성입니다. ESP32-S3에는 2.4GHz Wi-Fi 무선 통신 기능과 블루투스 저에너지(BLE)가 내장되어 있어 별도의 블루투스 모듈이 필요하지 않습니다.




STEP 6. 배선


배선


배선


배선


이 보드의 배선은 꽤 간단했습니다. 저항 사다리 회로를 사용하여 A, B, X, Y 버튼과 D-패드 입력을 모두 하나의 아날로그 핀으로 처리할 수 있었으며, A0 핀이 이 모든 입력을 통합하여 처리했습니다.

나머지 제어 장치들은 다음과 같이 연결되었습니다.

  1. 오른쪽 조이스틱 Y: A1
  2. 오른쪽 조이스틱 X: A2
  3. 왼쪽 조이스틱 Y: A3
  4. 왼쪽 조이스틱 X: D4
  5. 오른쪽 조이스틱 스위치: D2
  6. 왼쪽 조이스틱 스위치: D3
  7. LT: D6
  8. RT: D7

처음에는 LT와 RT 입력 단자에 약간의 문제가 생겨서, 결국 각각 D6과 D7에 직접 연결했습니다. 연결에는 단심 은도금 구리선을 사용했습니다.




STEP 7. 코드


이 프로젝트에서 제가 사용한 주요 코드는 다음과 같으며, 아주 간단한 코드입니다.

#include <BleGamepad.h>
BleGamepad bleGamepad("Open-Game-Controller", "Arnov", 100);
// ---------------------------------------------------------------- amber LED
const int PIN_LED = 13;
const int LED_PWM_CHANNEL = 0;
const int LED_PWM_FREQ = 5000;
const int LED_PWM_RES = 8;
// ---------------------------------------------------------------- config
#define DEBUG_SERIAL 1
const int MATCH_TOLERANCE = 30;
const int IDLE_THRESHOLD = 1000;
const int SAMPLES = 8;
const int STICK_DEADZONE = 40;
const bool INVERT_LJ_X = false;
const bool INVERT_LJ_Y = true;
const bool INVERT_RJ_X = false;
const bool INVERT_RJ_Y = true;
const uint32_t DEBOUNCE_MS = 25;
// ---------------------------------------------------------------- pins
const int PIN_LADDER = A0;
const int PIN_RJ_Y = A1;
const int PIN_RJ_X = A2;
const int PIN_LJ_Y = A3;
const int PIN_LJ_X = D4;
const int PIN_RJ_SW = D2;
const int PIN_LJ_SW = D3;
const int PIN_BTN_LT = D6;
const int PIN_BTN_RT = D7;
// ---------------------------------------------------------------- mapping
#define XB_A BUTTON_1
#define XB_B BUTTON_2
#define XB_X BUTTON_3
#define XB_Y BUTTON_4
#define XB_LB BUTTON_5
#define XB_RB BUTTON_6
#define XB_LS BUTTON_9
#define XB_RS BUTTON_10
#define XB_UP BUTTON_11
#define XB_DOWN BUTTON_12
#define XB_LEFT BUTTON_13
#define XB_RIGHT BUTTON_14
struct LadderEntry {
int value;
int id;
const char *name;
};
LadderEntry ladder[] = {
{ 0, XB_UP, "UP" },
{ 510, XB_DOWN, "DOWN" },
{ 681, XB_LEFT, "LEFT" },
{ 767, XB_RIGHT, "RIGHT" },
{ 819, XB_Y, "Y" },
{ 852, XB_A, "A" },
{ 877, XB_X, "X" },
{ 900, XB_B, "B" }
};
const int LADDER_COUNT = sizeof(ladder) / sizeof(ladder[0]);
// ---------------------------------------------------------------- state
int ljXCentre = 512, ljYCentre = 512;
int rjXCentre = 512, rjYCentre = 512;
int lastLadderId = -1;
int stableLadderId = -1;
uint32_t ladderChangeMs = 0;
int prevSentId = -1;
bool prevLS = false, prevRS = false;
bool prevLT = false, prevRT = false;
bool prevConnected = false;
// ---------------------------------------------------------------- helpers
int readAveraged(int pin) {
long sum = 0;
for (int i = 0; i < SAMPLES; i++) sum += analogRead(pin);
return sum / SAMPLES;
}
int matchLadder(int value) {
if (value >= IDLE_THRESHOLD) return -1;
int bestId = -1;
int bestDist = MATCH_TOLERANCE + 1;
for (int i = 0; i < LADDER_COUNT; i++) {
int dist = abs(value - ladder[i].value);
if (dist < bestDist) {
bestDist = dist;
bestId = ladder[i].id;
}
}
return bestId;
}
int16_t axisToBle(int raw, int centre, bool invert) {
int delta = raw - centre;
if (abs(delta) < STICK_DEADZONE) return 16384;
delta += (delta > 0) ? -STICK_DEADZONE : STICK_DEADZONE;
int span = (delta > 0) ? (1023 - centre - STICK_DEADZONE)
: (centre - STICK_DEADZONE);
if (span < 1) span = 1;
long scaled = (long)delta * 16383 / span;
if (scaled > 16383) scaled = 16383;
if (scaled < -16383) scaled = -16383;
if (invert) scaled = -scaled;
return (int16_t)(16384 + scaled);
}
float axisMagnitude(int raw, int centre) {
int delta = abs(raw - centre);
if (delta < STICK_DEADZONE) return 0.0f;
delta -= STICK_DEADZONE;
int span = max(1023 - centre, centre) - STICK_DEADZONE;
if (span < 1) span = 1;
float m = (float)delta / (float)span;
if (m > 1.0f) m = 1.0f;
return m;
}
void ledSet(uint8_t brightness) {
ledcWrite(LED_PWM_CHANNEL, brightness);
}
// Common anode: LOW = on, HIGH = off.
void statusLedSet(bool red, bool green, bool blue) {
digitalWrite(LED_RED, red ? LOW : HIGH);
digitalWrite(LED_GREEN, green ? LOW : HIGH);
digitalWrite(LED_BLUE, blue ? LOW : HIGH);
}
// ---------------------------------------------------------------- setup
void setup() {
#if DEBUG_SERIAL
Serial.begin(115200);
#endif
analogReadResolution(10);
analogSetAttenuation(ADC_11db);
pinMode(PIN_LADDER, INPUT);
pinMode(PIN_LJ_SW, INPUT_PULLUP);
pinMode(PIN_RJ_SW, INPUT_PULLUP);
pinMode(PIN_BTN_LT, INPUT_PULLUP);
pinMode(PIN_BTN_RT, INPUT_PULLUP);
ledcSetup(LED_PWM_CHANNEL, LED_PWM_FREQ, LED_PWM_RES);
ledcAttachPin(PIN_LED, LED_PWM_CHANNEL);
ledSet(0);
pinMode(LED_RED, OUTPUT);
pinMode(LED_GREEN, OUTPUT);
pinMode(LED_BLUE, OUTPUT);
statusLedSet(true, false, false); // red at boot, not connected yet
delay(400);
ljXCentre = readAveraged(PIN_LJ_X);
ljYCentre = readAveraged(PIN_LJ_Y);
rjXCentre = readAveraged(PIN_RJ_X);
rjYCentre = readAveraged(PIN_RJ_Y);
BleGamepadConfiguration cfg;
cfg.setAutoReport(false);
cfg.setButtonCount(14);
cfg.setHatSwitchCount(0);
cfg.setWhichAxes(true, true, true, false, false, true, false, false);
cfg.setIncludeStart(true);
cfg.setIncludeSelect(true);
bleGamepad.begin(&cfg);
}
// ---------------------------------------------------------------- loop
void loop() {
bool connected = bleGamepad.isConnected();
if (connected != prevConnected) {
statusLedSet(!connected, connected, false);
prevConnected = connected;
}
if (!connected) {
ledSet(0);
delay(100);
return;
}
int raw = readAveraged(PIN_LADDER);
int id = matchLadder(raw);
if (id != lastLadderId) {
lastLadderId = id;
ladderChangeMs = millis();
}
if (millis() - ladderChangeMs >= DEBOUNCE_MS) {
stableLadderId = id;
}
if (stableLadderId != prevSentId) {
if (prevSentId >= 0) bleGamepad.release(prevSentId);
if (stableLadderId >= 0) bleGamepad.press(stableLadderId);
prevSentId = stableLadderId;
}
bool lt = (digitalRead(PIN_BTN_LT) == LOW);
bool rt = (digitalRead(PIN_BTN_RT) == LOW);
if (lt != prevLT) { lt ? bleGamepad.press(XB_LB) : bleGamepad.release(XB_LB); prevLT = lt; }
if (rt != prevRT) { rt ? bleGamepad.press(XB_RB) : bleGamepad.release(XB_RB); prevRT = rt; }
bool ls = (digitalRead(PIN_LJ_SW) == LOW);
bool rs = (digitalRead(PIN_RJ_SW) == LOW);
if (ls != prevLS) { ls ? bleGamepad.press(XB_LS) : bleGamepad.release(XB_LS); prevLS = ls; }
if (rs != prevRS) { rs ? bleGamepad.press(XB_RS) : bleGamepad.release(XB_RS); prevRS = rs; }
int ljXRaw = readAveraged(PIN_LJ_X);
int ljYRaw = readAveraged(PIN_LJ_Y);
int rjXRaw = readAveraged(PIN_RJ_X);
int rjYRaw = readAveraged(PIN_RJ_Y);
bleGamepad.setX (axisToBle(ljXRaw, ljXCentre, INVERT_LJ_X));
bleGamepad.setY (axisToBle(ljYRaw, ljYCentre, INVERT_LJ_Y));
bleGamepad.setZ (axisToBle(rjXRaw, rjXCentre, INVERT_RJ_X));
bleGamepad.setRZ(axisToBle(rjYRaw, rjYCentre, INVERT_RJ_Y));
bleGamepad.sendReport();
bool anyButtonHeld = (stableLadderId >= 0) || lt || rt || ls || rs;
if (anyButtonHeld) {
ledSet(255);
} else {
float ljMag = max(axisMagnitude(ljXRaw, ljXCentre), axisMagnitude(ljYRaw, ljYCentre));
float rjMag = max(axisMagnitude(rjXRaw, rjXCentre), axisMagnitude(rjYRaw, rjYCentre));
float mag = max(ljMag, rjMag);
ledSet((uint8_t)(mag * 255));
}
#if DEBUG_SERIAL
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 250) {
lastPrint = millis();
Serial.print("ladder "); Serial.print(raw);
Serial.print(" LT "); Serial.print(lt);
Serial.print(" RT "); Serial.print(rt);
Serial.print(" LJ "); Serial.print(ljXRaw); Serial.print("/"); Serial.print(ljYRaw);
Serial.print(" RJ "); Serial.print(rjXRaw); Serial.print("/"); Serial.println(rjYRaw);
}
#endif
delay(10);
}

이 과정은 꽤 간단하며, 블루투스 HID 기능의 대부분은 BleGamepad 라이브러리가 처리합니다.

블루투스 게임패드 라이브러리

#include <BleGamepad.h>

BleGamepad bleGamepad("Open-Game-Controller", "Arnov", 100);

BleGamepad 라이브러리는 프로젝트의 블루투스 HID 부분을 처리하여, Nano ESP32가 무선 게임 컨트롤러로 인식되도록 합니다.

첫 번째 줄에서는 라이브러리를 포함하고, 두 번째 줄에서는 게임패드 인스턴스를 생성합니다. 여기서는 컨트롤러 이름을 ‘Open-Game-Controller’로 지정하고, 제조사 이름을 ‘Arnov’로 설정했으며, 표시되는 배터리 잔량을 100%로 설정했습니다.

컨트롤러 구성

#define DEBUG_SERIAL 1

const int MATCH_TOLERANCE = 30;
const int IDLE_THRESHOLD = 1000;

const int SAMPLES = 8;
const int STICK_DEADZONE = 40;

const bool INVERT_LJ_X = false;
const bool INVERT_LJ_Y = true;
const bool INVERT_RJ_X = false;
const bool INVERT_RJ_Y = true;

const uint32_t DEBOUNCE_MS = 25;

이 변수들은 컨트롤러의 기본적인 동작 중 일부를 정의합니다.

SAMPLES는 아날로그 입력을 읽을 때 ADC 측정값을 몇 개나 채취할지 결정합니다. 저는 8개의 샘플을 사용하고 이를 평균화하여 노이즈를 줄입니다.

STICK_DEADZONE은 조이스틱의 중심 위치 주변에서 무시될 움직임의 범위를 정의합니다. 이를 통해 조이스틱의 미세한 흔들림이 움직임으로 인식되는 것을 방지합니다.

INVERT 변수를 사용하면 조이스틱의 물리적 방향 때문에 움직임이 반대로 해석되는 경우, 해당 축의 방향을 반전시킬 수 있습니다.

MATCH_TOLERANCE는 저항 사다리를 통해 연결된 버튼을 감지하는 데 사용되며, DEBOUNCE_MS는 짧은 변동 현상이 여러 번의 버튼 누름으로 오인되는 것을 방지합니다.

핀 매핑

const int PIN_LADDER = A0;

const int PIN_RJ_Y = A1;
const int PIN_RJ_X = A2;
const int PIN_LJ_Y = A3;
const int PIN_LJ_X = D4;

const int PIN_RJ_SW = D2;
const int PIN_LJ_SW = D3;

const int PIN_BTN_LT = D6;
const int PIN_BTN_RT = D7;

이 섹션에서는 각 물리적 제어 장치가 Nano ESP32의 어느 부분에 연결되는지 설명합니다.

A, B, X, Y 버튼과 D패드는 A0 에 저항 사다리를 통해 연결되어 있습니다. 두 조이스틱은 X축과 Y축에 각각 사용할 4개의 아날로그 입력을 사용합니다.

조이스틱 클릭 스위치인 LT와 RT는 디지털 GPIO 핀에 연결되어 있습니다.

물리적 버튼 매핑하기

#define XB_A BUTTON_1
#define XB_B BUTTON_2
#define XB_X BUTTON_3
#define XB_Y BUTTON_4

#define XB_LB BUTTON_5
#define XB_RB BUTTON_6

#define XB_LS BUTTON_9
#define XB_RS BUTTON_10

#define XB_UP BUTTON_11
#define XB_DOWN BUTTON_12
#define XB_LEFT BUTTON_13
#define XB_RIGHT BUTTON_14

여기서는 물리적 컨트롤을 BleGamepad 라이브러리가 기대하는 버튼 ID에 매핑합니다.

예를 들어, 물리적 A 버튼을 누르면 결국 다음과 같은 결과가 나타납니다:

bleGamepad.press(XB_A);

이는 HID 게임패드 리포트의 BUTTON_1에 해당합니다.

컨트롤러에 Xbox 스타일의 버튼 배치를 사용하고 있어, 표준 게임패드를 지원하는 게임과 호환됩니다.

저항 사다리

struct LadderEntry {
int value;
int id;
const char *name;
};

LadderEntry ladder[] = {
{ 0, XB_UP, "UP" },
{ 510, XB_DOWN, "DOWN" },
{ 681, XB_LEFT, "LEFT" },
{ 767, XB_RIGHT, "RIGHT" },
{ 819, XB_Y, "Y" },
{ 852, XB_A, "A" },
{ 877, XB_X, "X" },
{ 900, XB_B, "B" }
};

바로 이때 저항 사다리가 유용하게 쓰입니다.

모든 버튼에 별도의 GPIO 핀을 할당하는 대신, 각 버튼은 서로 다른 전압을 발생시키며, 이 전압은 단일 아날로그 핀 A0을 통해 측정할 수 있습니다.

예를 들어, 약 852 정도의 ADC 값은 A 버튼을 나타내고, 약 900 정도의 값은 B 버튼을 나타냅니다.

컨트롤러는 측정된 ADC 값을 이 표와 비교하여 가장 근접한 버튼을 선택합니다.

이를 통해 8개의 서로 다른 입력 신호가 하나의 아날로그 핀을 공유할 수 있어, GPIO 수를 상당히 절약할 수 있습니다.

아날로그 측정값의 평균 계산

int readAveraged(int pin) {
long sum = 0;

for (int i = 0; i < SAMPLES; i++)
sum += analogRead(pin);

return sum / SAMPLES;
}

아날로그 측정값은 완벽하게 안정적이지 않으므로, ADC 측정값을 한 번만 읽는 대신 여덟 번 측정하여 그 평균을 구합니다.

예를 들어, ADC가 다음과 같은 값을 반환한다면:

850
853
851
854
852
851
853
852

펌웨어는 이 값들을 사용하기 전에 평균을 구합니다.

이를 통해 조이스틱과 저항 사다리 회로 모두에서 보다 안정적인 측정값을 얻을 수 있습니다.

버튼 래더 감지하기

int matchLadder(int value) {
if (value >= IDLE_THRESHOLD)
return -1;

int bestId = -1;
int bestDist = MATCH_TOLERANCE + 1;

for (int i = 0; i < LADDER_COUNT; i++) {
int dist = abs(value - ladder[i].value);

if (dist < bestDist) {
bestDist = dist;
bestId = ladder[i].id;
}
}

return bestId;
}

이 함수는 A0에서 ADC 값을 읽어와 어떤 버튼이 눌려 있는지 파악합니다.

측정값이 유휴 임계값을 초과하면, 컨트롤러는 어떤 버튼도 눌리지 않은 것으로 간주합니다.

그렇지 않으면, 저항 사다리 표에 있는 모든 값을 확인하며 측정값과 예상값 간의 차이를 계산합니다.

가장 일치하는 항목이 감지된 버튼으로 지정됩니다.

이 허용 오차 값은 전혀 관련이 없는 ADC 측정값이 유효한 버튼 누름으로 인식되는 것을 방지합니다.

조이스틱 입력 변환

int16_t axisToBle(int raw, int centre, bool invert) {
int delta = raw - centre;

if (abs(delta) < STICK_DEADZONE)
return 16384;

delta += (delta > 0)
? -STICK_DEADZONE
: STICK_DEADZONE;

int span = (delta > 0)
? (1023 - centre - STICK_DEADZONE)
: (centre - STICK_DEADZONE);

if (span < 1)
span = 1;

long scaled = (long)delta * 16383 / span;

if (scaled > 16383) scaled = 16383;
if (scaled < -16383) scaled = -16383;

if (invert)
scaled = -scaled;

return (int16_t)(16384 + scaled);
}

이 함수는 조이스틱에서 얻은 원시 ADC 값을 블루투스 HID 게임패드가 기대하는 범위로 변환합니다.

Nano ESP32는 0에서 1023까지의 10비트 ADC 값을 제공하는 반면, HID 축은 더 넓은 부호 있는 범위로 표현됩니다.

이 함수는 먼저 보정된 중심 위치를 빼고, 데드존을 적용한 다음, 남은 이동량을 조정합니다.

그 결과는 대략 다음과 같습니다:

Joystick left → 0
Joystick centre → 16384
Joystick right → 32767

'반전' 매개변수를 사용하면 필요할 때 축의 방향을 반대로 바꿀 수 있습니다.

컨트롤러 초기화

void setup() {
Serial.begin(115200);

analogReadResolution(10);
analogSetAttenuation(ADC_11db);

pinMode(PIN_LADDER, INPUT);

pinMode(PIN_LJ_SW, INPUT_PULLUP);
pinMode(PIN_RJ_SW, INPUT_PULLUP);
pinMode(PIN_BTN_LT, INPUT_PULLUP);
pinMode(PIN_BTN_RT, INPUT_PULLUP);

setup() 함수는 컨트롤러의 전원이 켜질 때 한 번 실행됩니다.

여기서는 디버깅을 위해 시리얼 연결을 초기화하고, ADC를 구성하며, ESP32의 내장 풀업 저항을 사용하여 디지털 버튼 핀을 입력으로 설정합니다.

INPUT_PULLUP을 사용하면 버튼을 놓았을 때 HIGH로, 눌렀을 때 LOW로 감지됩니다.

조이스틱 보정

ljXCentre = readAveraged(PIN_LJ_X);
ljYCentre = readAveraged(PIN_LJ_Y);

rjXCentre = readAveraged(PIN_RJ_X);
rjYCentre = readAveraged(PIN_RJ_Y);

시작 시, 컨트롤러는 4개의 조이스틱 축 모두의 초기 위치를 측정합니다.

조이스틱의 중심이 정확히 512라고 가정하는 대신, 각 조이스틱의 실제 위치를 측정하여 저장합니다.

실제 조이스틱 모듈들은 서로 완전히 동일하지 않고 중심값이 약간씩 다를 수 있기 때문에 이 기능이 유용합니다.

유일한 주의점은 컨트롤러가 부팅되는 동안 조이스틱을 건드리지 않아야 한다는 것입니다.

블루투스 게임패드 설정하기

BleGamepadConfiguration cfg;

cfg.setAutoReport(false);
cfg.setButtonCount(14);
cfg.setHatSwitchCount(0);

cfg.setWhichAxes(
true, true, true, false,
false, true, false, false
);

cfg.setIncludeStart(true);
cfg.setIncludeSelect(true);

bleGamepad.begin(&cfg);

여기서는 블루투스 HID 장치 자체를 설정합니다.

컨트롤러에는 14개의 버튼과 필요한 아날로그 축이 제공됩니다. 또한 setAutoReport(false)를 사용하여 자동 보고 기능을 비활성화합니다.

즉, 개별 입력값이 바뀔 때마다 보고를 보내는 대신, 펌웨어가 현재 컨트롤러 상태를 언제 전송할지 결정한다는 뜻입니다.

모든 설정이 완료되면, bleGamepad.begin()을 호출하여 블루투스 게임패드를 시작합니다.

메인 루프

void loop() {
bool connected = bleGamepad.isConnected();

if (!connected) {
ledSet(0);
delay(100);
return;
}

// Read controller inputs...

bleGamepad.sendReport();

delay(10);
}

loop() 함수는 컨트롤러가 입력 신호를 지속적으로 읽어들이는 곳입니다.

먼저, 블루투스 기기가 연결되어 있는지 확인합니다. 연결된 기기가 없으면 그냥 대기합니다.

연결이 완료되면 펌웨어는 버튼 및 조이스틱 위치를 읽어들이고, 입력을 처리하며, 게임패드 상태를 업데이트한 후, 다음을 사용하여 전체 보고서를 전송합니다:

bleGamepad.sendReport();

그런 다음 이 루프는 대략 10밀리초마다 반복됩니다.

조이스틱 판독하기

int ljXRaw = readAveraged(PIN_LJ_X);
int ljYRaw = readAveraged(PIN_LJ_Y);

int rjXRaw = readAveraged(PIN_RJ_X);
int rjYRaw = readAveraged(PIN_RJ_Y);

bleGamepad.setX(
axisToBle(ljXRaw, ljXCentre, INVERT_LJ_X)
);

bleGamepad.setY(
axisToBle(ljYRaw, ljYCentre, INVERT_LJ_Y)
);

bleGamepad.setZ(
axisToBle(rjXRaw, rjXCentre, INVERT_RJ_X)
);

bleGamepad.setRZ(
axisToBle(rjYRaw, rjYCentre, INVERT_RJ_Y)
);

조이스틱의 4개 축 값을 읽어와 평균을 구한 뒤, HID 값으로 변환하여 해당 게임패드 축에 할당합니다.

왼쪽 조이스틱은 X와 Y를 제어하고, 오른쪽 조이스틱은 Z와 RZ를 담당합니다.

이 단계에서 조이스틱의 물리적 움직임이 블루투스 게임패드가 인식할 수 있는 디지털 값으로 변환되었습니다.

최종 보고서 제출

bleGamepad.sendReport();

마지막으로, 현재의 모든 버튼 및 조이스틱 상태가 블루투스 HID 리포트 형태로 연결된 기기로 전송됩니다.

따라서 컴퓨터는 개별 버튼, 저항 사다리 회로, 조이스틱 ADC 값, 또는 아두이노 자체에 대해 아무것도 알 필요가 없습니다. 운영 체제 입장에서는 단순히 표준 블루투스 게임 컨트롤러와 통신하고 있을 뿐입니다.

전체 과정을 다음과 같이 요약할 수 있습니다.

물리적 제어 → ADC/GPIO → 입력 처리 → HID 값 → BleGamepad → 블루투스 → 컴퓨터

그리고 이것이 바로 이 프로토타입이 여러 개의 버튼과 아날로그 조이스틱을 모아 기능적인 무선 게임 컨트롤러로 만들어내는 기본 원리입니다.

본문이 길어 아래 덧글 1개로 계속됩니다.


원문: https://www.instructables.com/Wireless-Game-Controller-V1/

좋아요 0
게시판

본문 계속 1/1




STEP 8. 전원


전원


펌웨어를 플래시하면 게임 컨트롤러를 바로 사용할 수 있습니다. 단 한 가지 문제가 있는데, 여전히 유선 방식이라는 점입니다.

컨트롤러에 전원을 공급해야 하는데, 현재로서는 USB Type-C 케이블을 Nano ESP32에 연결해야 합니다.

이 일시적인 제약을 해결하기 위해, 저는 MagSafe 파워뱅크를 사용하기로 했습니다. MagSafe 기능이 있거나 그런 화려한 기능이 있어서가 아니라, 그냥 마침 집에 하나 굴러다니고 있었기 때문입니다.

이 파워뱅크는 USB Power Delivery(PD) 출력을 지원할 수 있지만, 컨트롤러에는 그 정도로 복잡한 기능이 필요하지 않습니다. 이 시제품의 경우, 최대 2A까지 공급할 수 있는 일반적인 5V 전원만 있으면 됩니다.

실제로는 컨트롤러의 전류 소모량이 2A보다 훨씬 적어야 합니다. 이 시제품에 사용된 하드웨어를 기준으로 볼 때, 실제 전류 소모량은 작동 조건과 연결된 주변 기기에 따라 수백 밀리암페어 수준이거나, 심지어 200mA 미만이 될 수도 있습니다.

하지만 시제품의 경우, 5V 2A를 공급할 수 있는 파워뱅크만 있어도 여유가 충분합니다.

뒷면에 파워뱅크를 부착함으로써, 이제 우리는 완전히 무선인 컨트롤러를 갖게 되었습니다. 적어도 파워뱅크가 필연적으로 전체 조립체에서 구조적으로 가장 중요한 부분이 되기 전까지는 말이죠.




STEP 9. 조립


조립


조립


조립


조립


조립


  1. 조립 과정은 파워뱅크 뒷면에 양면 폼 테이프를 부착하는 것으로 시작됩니다.
  2. 보호 커버를 제거한 후, 게임 컨트롤러 PCB를 파워뱅크에 대고 두 부품을 단단히 눌러 붙인 다음 제자리에 고정했습니다.
  3. 다음으로, 회로판 반대편에 더 작은 폼 테이프 조각을 붙이고 이를 이용해 Arduino Nano ESP32를 고정했습니다. 약간의 압력만으로도 Arduino 보드와 컨트롤러 PCB를 단단히 고정할 수 있었습니다.
  4. 마지막으로 USB 케이블을 이용해 파워뱅크를 Arduino에 연결했습니다. 케이블의 한쪽 끝은 파워뱅크에 연결하고 다른 쪽 끝은 Arduino의 USB Type-C 포트에 연결했습니다.

이것으로 임시 조립이 완료되었습니다.




STEP 10. 결과


결과


그리고 이 즉흥적인 조립 방식의 최종 결과물은 바로, 솔직히 말해서 제임스 채널에서 만들 법한 모습의, 제대로 작동하는 컨트롤러입니다.

농담은 제쳐두고, 이건 완벽하게 작동하는 무선 게임 컨트롤러 프로젝트입니다. 아직 다듬어지지 않은 부분이 분명 있지만, 이 개념이 실제로 작동한다는 것을 증명해 주었고, 다음 버전을 위한 탄탄한 출발점을 마련해 주었습니다.




STEP 11. 컨트롤러 설정


컨트롤러 설정


컨트롤러 설정


컨트롤러가 실제로 작동하는지 확인하려면, 가장 먼저 컴퓨터와 페어링을 해야 합니다.

블루투스 설정을 열고, ‘Open Game Controller’로 표시될 컨트롤러를 찾습니다. 컨트롤러가 나타나면 컴퓨터와 페어링하고, 연결이 제대로 되었는지 확인합니다.

컨트롤러가 연결이 끊어졌거나 아직 기기에 연결되지 않은 상태에서는 RGB LED가 빨간색으로 켜져 있습니다. 컨트롤러가 블루투스를 통해 연결되면 LED가 녹색으로 바뀌며, 이는 컨트롤러가 연결되어 사용 준비가 되었음을 한눈에 알 수 있게 해줍니다.

그 후, 브라우저로 이동하여 다음을 엽니다. 게임패드 테스터, 이는 게임 컨트롤러를 확인하는 데 정말 유용한 도구입니다. 여기서는 모든 버튼을 하나씩 테스트하여 입력이 올바르게 감지되는지 확인할 수 있습니다. 또한 두 조이스틱을 모두 확인하여 움직임과 작동 범위가 제대로 인식되는지 확인할 수도 있습니다.

모든 버튼과 조이스틱을 점검하고 모든 기능이 정상적으로 작동하는지 확인한 후에는, 다음 단계로 넘어가 Steam에서 컨트롤러를 실제로 사용해 봅니다.




STEP 12. STEAM


STEAM


사실 엄밀히 말해 이 장치는 XInput 컨트롤러가 아니기 때문에, Windows가 이를 바로 정식 Xbox 스타일 게임 컨트롤러로 인식하지는 않을 것입니다. 일부 게임에서는 이 장치를 감지할 수도 있겠지만, 대부분은 그렇지 않을 것입니다.

자, 여기 좀 기발한 해결 방법이 있습니다.

스팀을 빅 픽처 모드로 실행한 뒤, 컨트롤러 설정으로 이동하여 새 컨트롤러를 등록합니다. 그런 다음 스팀에서 입력 매핑을 수동으로 설정할 수 있습니다. 컨트롤을 하나씩 확인하며 각 버튼, 트리거, D-패드 방향, 조이스틱에 기능을 할당합니다.

모든 설정이 완료되면 Steam에서 자동으로 번역을 처리해 주며, 이제 우리 컨트롤러는 표준 컨트롤러 입력을 지원하는 게임에서 사용할 수 있게 됩니다.

완전히 네이티브 XInput은 아니지만, 프로토타입 치고는 제 역할을 꽤 잘 해내고 있습니다.




STEP 13. 게임하기


게임하기


게임하기


게임하기


게임하기


게임하기


게임하기


테스트를 시작하기 위해 다음을 재생합니다. NieR. 딱히 선택한 특별한 이유는 없었어요. 그냥 정말 마음에 들어서요. NieR 그리고 제가 가장 좋아하는 게임 중 하나가 제가 직접 만든 게임 컨트롤러와 어떻게 연동될지 확인해 보고 싶었습니다.

솔직히 말해서, 전혀 문제가 없었어요. 모든 게 반응이 빠르고, 거의 예상한 대로 완벽하게 작동했어요.

Xbox 컨트롤러에 사용된 스틱과 비교했을 때, 이 스틱의 품질에는 확실히 눈에 띄는 차이가 있었습니다. 이 프로토타입에 사용 중인 스틱은 딱히 고급형은 아니기 때문에, 정밀도와 전반적인 조작감은 아직 미흡한 편입니다. 더 정밀한 홀 효과 조이스틱을 사용하면 확실히 개선될 수 있겠지만, 그 부분은 향후 개정판에서 검토해 볼 계획입니다.

다음으로 해본 게임은 사이버펑크 2077, 그리고 바로 이때부터야 비로소 제대로 된 Xbox 컨트롤러가 실제로 얼마나 훌륭한지, 그리고 내가 지금 쓰고 있는 컨트롤러가 얼마나 의심스러운지 진정으로 깨닫기 시작했습니다.

'사이버펑크' 같은 게임에서 이 컨트롤러를 사용해 보니 확실히 불편함이 느껴졌다. 조준을 하기에는 스틱의 정밀도가 부족했고, 적을 정확하게 명중시키거나 추적하는 것도 확실히 어려웠다. 실제 Xbox 컨트롤러에 비해 조작감이 다소 느리고 어색하게 느껴졌다.

하지만 중요한 점은 그것이 효과가 있었다는 것입니다.

컨트롤러가 무선으로 연결되었고, 입력 신호도 제대로 인식되었으며, 실제로 이 컨트롤러로 게임을 끝까지 플레이할 수 있었습니다. 이번 개정판에서는 바로 그 점이 제가 목표로 했던 바였습니다.

컨트롤러가 아직 완벽할 필요는 없습니다. 단지 그 개념이 제대로 작동한다는 것만 증명하면 되는데, 실제로 그렇습니다.




STEP 14. 개요 및 향후 계획


개요 및 향후 계획


자, 이것이 제가 만든 오픈소스 무선 게임 컨트롤러 프로젝트인데, 잘 작동합니다.

이번 버전의 주된 목표는 단순히 작동하는 프로토타입을 만들고, 다음 버전으로 넘어가기 전에 가능한 한 완성도를 높이는 것이었습니다. 하지만 궁극적으로는 이 프로젝트를 더 큰 규모로 발전시켜, 커뮤니티 주도의 오픈소스 게임 컨트롤러 프로젝트로 만들고 싶습니다.

그 아이디어를 어디까지 발전시킬 수 있을지는 아직 확실하지 않지만, 그게 바로 목표입니다. 저는 이 프로젝트가 결국 사람들이 직접 만들고, 수정하고, 기여하며, 함께 더 나은 방향으로 발전시켜 나갈 수 있는 컨트롤러가 되기를 바랍니다. 그리고 버전 2를 통해 그 목표를 향해 나아가겠습니다.

현재 이 프로젝트의 회로 구성은 상당히 간소한 편인데, 저는 이미 이를 처음부터 다시 설계하고 있습니다. 다음 버전에서는 아두이노 보드를 그대로 사용할 수도 있고, 공간을 절약하고 전자 부품을 더 콤팩트하게 만들기 위해 아두이노 보드를 완전히 제거하고 ESP32 모듈로 전환할 수도 있습니다.

컨트롤러의 물리적 디자인도 처음부터 새로 개발하고 있습니다. 디자이너 친구와 협업하며, 컨트롤러 본체에 대해 체계적인 산업 디자인 과정을 거치고 있습니다. 목표는 인체공학적이고 기능적이며, 무엇보다도 누구나 3D 프린팅으로 쉽게 직접 제작할 수 있는 제품을 만드는 것입니다.

그리고 이것이 바로 이 프로젝트의 진정한 목표입니다. 단순히 제가 만든 프로젝트에 그치지 않고, 다른 사람들도 실제로 이를 바탕으로 무언가를 만들어 나갈 수 있는 제대로 된 오픈소스 컨트롤러를 만드는 것입니다.

이 프로젝트가 조금이라도 도움이 되었기를 바랍니다. 도움이 되셨다면 ‘좋아요’ 버튼을 눌러주세요. 그리고 이 프로젝트에 대해 궁금한 점이 있으시면 언제든지 저에게 연락해 주세요. 인스타그램 아니면 여기에 댓글을 남겨주세요.

제 다른 비슷한 프로젝트들도 확인해 보세요—

https://www.instructables.com/Arduino-Retro-Game-Controller

https://www.instructables.com/DIY-XBOX-Controller

https://www.instructables.com/SUPER-Nintendo-Entertainment-System-Controller-XL

현재로서는 이 버전의 프로젝트가 완료되었습니다. 이미 버전 2를 준비 중이니, 앞으로의 소식을 기대해 주세요.

평화를 빕니다.

회사명 공방유니언 주소 각 공방 주소 참조
사업자 등록번호 0000 대표 공방유니언 전화 019-60105846 팩스 0000 이메일 gongbangunion@gmail.com
통신판매업신고번호 0000 개인정보관리책임자 공방유니언
Copyright © 2017 공방유니언. All Rights Reserved.