흔들어서 작동하는 아두이노 주사위 – LED 매트릭스 탑재
필요한 부품 및 소모품
| 부품 |
수량 |
| 아두이노 나노 R3 (Arduino Nano R3) |
1 |
| 3mm 8x8 도트 매트릭스 MAX7219 |
1 |
| 틸트 스위치 모듈 (디지털 인터페이스) |
1 |
| 슬라이드 스위치 |
1 |
| MT3608 DC 2A 스텝업 부스트 컨버터 (2V-24V → 5V/9V/12V/28V) |
1 |
필요한 도구
앱 및 온라인 서비스
- 아두이노 IDE
- 오토데스크 퓨전 360 (Autodesk Fusion 360) – 케이스 설계용
프로젝트 소개
약 1년 전, 아들과 함께 아두이노와 몇 개의 LED로 전자 주사위를 만들었습니다. 여전히 초보자에게 이상적인 프로젝트죠. 그간 많은 것을 배웠으니 업그레이드할 때가 되었습니다.
버전 2.0
이 주사위는 여러 분야를 연결하는 훌륭한 발판입니다. 요즘은 거의 모든 부품이 모듈 형태로 나와 있어, LED, 버튼, 각종 센서가 기판에 실장된 채로 몇 개의 선만으로 GPIO에 연결해 사용할 수 있습니다.
140유로 미만의 입문용 3D 프린터도 쉽게 구할 수 있어, 브라켓, 마운트, 케이스 등을 직접 출력할 수 있습니다. 이 프로젝트인 '주사위 2.0'은 그런 가능성을 보여주는 예시입니다.
틸트 센서 (Tilt Sensors)
이번엔 완전히 다른 방식으로 주사위를 구현했습니다. 공간 제약으로 아두이노 나노를 사용했고, 버튼 대신 흔들기로 작동합니다.
틸트 센서나 진동 센서를 사용합니다. 유리관 속 금속 볼이 움직이다 접점에 닿으면 회로가 닫히는 원리로, 버튼처럼 작동합니다. 이 짧은 접점을 신호로 이용합니다. 금속 볼이 흔들릴 때 나는 소리로도 동작을 확인할 수 있습니다.
일반적으로 틸트 센서는 설치 각도가 중요하지만, 흔들기 감지용이라면 방향과 관계없이 신호가 발생합니다. 신뢰성을 위해 두 개의 센서를 90° 간격으로 배치해 어느 방향으로 흔들든 확실하게 감지되게 했습니다.
인터럽트 (Interrupts)
흔들림을 감지하려면 틸트 센서의 입력 핀을 계속 폴링해야 하지만, 타이밍과 다른 처리에 따라 이벤트를 놓칠 수 있습니다.
더 나은 방법은 하드웨어 인터럽트를 사용하는 것입니다. attachInterrupt 함수로 신호 발생 시 호출될 서브루틴을 지정합니다. 아두이노는 D2, D3 두 핀에서 하드웨어 인터럽트를 지원합니다.
디스플레이
7개 LED로 주사위 눈을 표시할 수도 있지만, MAX7219 드라이버 IC가 내장된 8x8 LED 매트릭스를 사용하면 더 흥미롭습니다. 크기가 작고 저렴하며 코딩도 쉽습니다.
구매처에 따라 부품을 직접 납땜해 조립해야 할 수 있습니다. 유일한 실수는 LED 매트릭스를 소켓에 거꾸로 꽂는 것인데, 이때는 매트릭스를 뽑아 180° 돌려 꽂으면 됩니다.
물리 시뮬레이션으로 생동감 더하기
LED 매트릭스는 단순히 결과를 보여주는 데 그치지 않고, 쇼 효과를 제공합니다. 여섯 개의 주사위 눈이 매트릭스 위에서 튕기고 가장자리에서 반사되며 점점 속도를 잃습니다.
두 틸트 센서로 흔들림의 정도를 파악해, 그 정보를 주사위 눈의 '초기 속도'로 전달합니다. 결과 표시 시에도 눈이 무작위 위치에서 올바른 주사위 눈 위치로 굴러가며 정렬됩니다.
전원 공급
처음엔 공간 절약을 위해 CR2032 코인 전지 두 개를 사용했습니다. 처음에는 잘 작동했지만 몇 분 뒤 전압이 급격히 떨어졌습니다. 매번 껐다 켜면 쓸 만하지만 불완전해, AAA 배터리 두 개 + 스텝업 컨버터(MT3608)로 5V를 만들어 아두이노 VIN 핀에 공급하는 방식으로 변경했습니다.
케이스 설계
직접 설계해 3D 프린팅하는 것이 최선입니다. 오토데스크 퓨전 360을 사용했으며, Print Studio 소프트웨어와 연동이 잘 됩니다.
케이스는 3부분으로 구성됩니다:
- 윗케이스
- AAA 배터리 홀더가 내장된 아랫케이스
- 투명 아크릴 커버 (선택 사항)
아크릴 커버를 매트릭스 앞에 대면 꺼진 LED가 안 보여 화면이 더 선명해집니다.
스케치 (코드)
LED 매트릭스 제어를 위해 LedControl 라이브러리가 필요합니다. 아두이노 플레이그라운드(링크)에서 다운로드하세요.
주사위 눈의 좌표를 배열로 정의합니다. 각 눈은 4개 LED 점으로 구성되며, 배열에는 왼쪽 위 모서리 좌표를 저장합니다(-1은 화면 밖).
int DicePic[8][6][2] = {
{ // 빈 매트릭스/시작 위치
{9,9}, {9,8}, {9,7}, {9,6}, {9,5}, {9,4}
},
{ // 1
{4,4}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}
},
{ // 2
{2,2}, {6,6}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}
},
// ... 3~6, 시작 위치 등 생략
};
눈 모양은 자유롭게 커스터마이징 가능합니다.
조립
아두이노를 소켓 스트립에 꽂아 실드처럼 쓰고, LED 매트릭스 5핀도 같은 방식으로 연결합니다. 아두이노를 중앙에 두면 공간 효율이 좋습니다. 매트릭스의 CS, CLK, DIN을 핀 5, 6, 7에 연결하고 5V, GND는 점퍼로 연결합니다.
틸트 센서 두 개도 짧은 선으로 핀 2, 3과 5V, GND에 연결합니다.
아크릴 커버와 LED 매트릭스를 글루건으로 고정하고, 온오프 스위치를 장착해 배선합니다.
전원부 장착
다 쓴 배터리 홀더에서 스프링과 핀을 떼어 와이어와 글루건으로 아랫케이스에 고정합니다. 부스트 컨버터는 장착 전 나사를 돌려 약 5V로 조정합니다.
윗케이스와 아랫케이스를 결합합니다(0.1mm 공차로 나사 없이도 단단히 결합되며, 배터리 교체 시 분해 가능).
이제 흔들기 주사위 2.0 완성입니다!
필요한 것 요약
- 아두이노 나노 (또는 ESP8266)
- 틸트 센서 2개 (내장 저항 포함)
- MAX7219 내장 8x8 LED 매트릭스
- 소켓 스트립
- 온오프 스위치
- PCB
- AAA 배터리 2개
더 자세한 정보
블로그 techpluscode.de/arduino-wuerfel-2-0 참고 (독일어만 제공).
코드: wuerfel8x8.ino (Arduino)
/* Blog-Artikel: Schüttel-Würfel mit LED Matrix und Bewegungssimulation
https://techpluscode.de/schuettel-wuerfel-mit-led-matrix-und-bewegungssimulation/
techpluscode.de Copyright 2019 von Thomas Angielsky */
#include "LedControl.h"
int PinTiltX = 2;
int PinTiltY = 3;
int PinCLK = 7;
int PinCS = 6;
int PinDIN = 5;
LedControl lc = LedControl(PinDIN, PinCLK, PinCS, 1);
int DicePic[8][6][2] = {
{ {9,9}, {9,8}, {9,7}, {9,6}, {9,5}, {9,4} },
{ {4,4}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1} },
{ {2,2}, {6,6}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1} },
{ {2,6}, {6,2}, {4,4}, {-1,-1}, {-1,-1}, {-1,-1} },
{ {2,2}, {2,6}, {6,2}, {6,6}, {-1,-1}, {-1,-1} },
{ {2,2}, {2,6}, {6,2}, {6,6}, {4,4}, {-1,-1} },
{ {2,1}, {2,4}, {2,7}, {6,1}, {6,4}, {6,7} },
{ {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1} }
};
float DiceXpos[6], DiceYpos[6];
float DiceXdir[6], DiceYdir[6];
volatile byte DiceXspeed[6], DiceYspeed[6];
int DiceValue;
unsigned long timestamp;
byte Mode;
volatile int shakes;
int ShakesPerSecond;
int step;
void InterruptChecks() {
shakes++;
timestamp = millis();
}
void SetSpeedX() {
if (Mode == 0) {
for (int i = 0; i < 6; i++) {
if (DiceXspeed[i] < 255) DiceXspeed[i] += 5;
}
}
InterruptChecks();
}
void SetSpeedY() {
if (Mode == 0) {
for (int i = 0; i < 6; i++) {
if (DiceYspeed[i] < 255) DiceYspeed[i] += 5;
}
}
InterruptChecks();
}
void ShowLed(int x, int y, bool onoff) {
if (x < 8 && y < 8 && x >= 0 && y >= 0) lc.setLed(0, x, y, onoff);
}
void ShowDot(int x, int y, bool onoff) {
ShowLed(x-1, y-1, onoff);
ShowLed(x, y-1, onoff);
ShowLed(x-1, y, onoff);
ShowLed(x, y, onoff);
}
void ShowDicePic(int value) {
boolean done;
for (int i = 0; i < 6; i++) {
DiceXspeed[i] = 100; DiceYspeed[i] = 100;
DiceXdir[i] = 0;
if ((int)DiceXpos[i] > DicePic[value][i][0]) DiceXdir[i] = -1;
else if ((int)DiceXpos[i] < DicePic[value][i][0]) DiceXdir[i] = 1;
DiceYdir[i] = 0;
if ((int)DiceYpos[i] > DicePic[value][i][1]) DiceYdir[i] = -1;
else if ((int)DiceYpos[i] < DicePic[value][i][1]) DiceYdir[i] = 1;
}
do {
for (int i = 0; i < 6; i++) {
if ((int)DiceXpos[i] != DicePic[value][i][0])
DoStep(DiceXpos[i], DiceXdir[i], DiceXspeed[i], false);
if ((int)DiceYpos[i] != DicePic[value][i][1])
DoStep(DiceYpos[i], DiceYdir[i], DiceYspeed[i], false);
}
lc.clearDisplay(0);
for (int i = 0; i < 6; i++)
ShowDot((int)DiceXpos[i], (int)DiceYpos[i], true);
delay(50);
done = true;
for (int i = 0; i < 6; i++) {
if ((int)DiceXpos[i] != DicePic[value][i][0]) done = false;
if ((int)DiceYpos[i] != DicePic[value][i][1]) done = false;
}
} while (!done);
lc.clearDisplay(0);
for (int i = 0; i < 6; i++)
ShowDot(DicePic[value][i][0], DicePic[value][i][1], true);
}
void DoStep(float &pos, float &dir, volatile byte &sp, bool check) {
pos += float(sp) / 255 * dir;
if (check) {
if (pos > 7) { pos = 7; dir *= -1; }
if (pos < 1) { pos = 1; dir *= -1; }
}
if (sp > 0) sp--;
}
void MoveDots() {
for (int i = 0; i < 6; i++) {
DoStep(DiceXpos[i], DiceXdir[i], DiceXspeed[i], true);
DoStep(DiceYpos[i], DiceYdir[i], DiceYspeed[i], true);
}
lc.clearDisplay(0);
for (int i = 0; i < 6; i++)
ShowDot((int)DiceXpos[i], (int)DiceYpos[i], true);
}
void setup() {
lc.shutdown(0, false);
lc.setIntensity(0, 8);
lc.clearDisplay(0);
randomSeed(analogRead(0));
DiceValue = 0;
for (int i = 0; i < 6; i++) {
DiceXpos[i] = DicePic[7][i][0];
DiceYpos[i] = DicePic[7][i][1];
DiceXdir[i] = random(3) - 1;
DiceYdir[i] = random(3) - 1;
DiceXspeed[i] = random(126) + 120;
DiceYspeed[i] = random(126) + 120;
}
pinMode(PinTiltX, INPUT_PULLUP);
pinMode(PinTiltY, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(PinTiltX), SetSpeedX, CHANGE);
attachInterrupt(digitalPinToInterrupt(PinTiltY), SetSpeedY, CHANGE);
lc.clearDisplay(0);
timestamp = millis();
Mode = 1;
ShowDicePic(6);
delay(1000);
lc.clearDisplay(0);
Mode = 0;
Serial.begin(9600);
step = 0;
shakes = 0;
}
void loop() {
delay(50);
step++;
if (step > 20) {
step = 0;
ShakesPerSecond = shakes;
shakes = 0;
}
if (Mode == 0) {
MoveDots();
if (millis() - timestamp > 2000) {
Mode = 1;
DiceValue = random(6) + 1;
ShowDicePic(DiceValue);
}
}
if (ShakesPerSecond > 5) {
Mode = 0;
}
}
회로도
페이지 하단의 회로도 이미지 참고.