[Arduino X C#] LED 테트리스 게임기[Arduino X C#] LED 테트리스 게임기

Posted at 2015/05/21 10:14 | Posted in 프로그래밍

2013년 8-11월, 태어나서 납 연기를 가장 많이 마신 달이라 생각한다. 납 연기가 맛있어서 그런 게 아니라,, LED 테트리스 게임기의 하드웨어를 제작할 때 손가락 끝에 굳은살 박으면서 같이 마셔버렸다. (뜬금 없지만) 원래 컨셉은 LED 큐브였으나 3D 프로그래밍이 두려워 2D로 바꿨다는 건 절대 말 못해! 하여튼, 소프트웨어 프로그래밍 후기를 적기 전에 하드웨어 제작 과정을 정리해본다!

왜 만들었나?

2012년 학교 축제에 등교 시간 자유권을 끊은 고3 선배가 동아리 부스에 느닷없이 찾아와 LED 큐브를 던지고 가셨다. 4x4x4(가로x세로x높이)의 3D LED 큐브인데, http://www.makeuseof.com/tag/how-to-make-a-pulsating-arduino-led-cube-that-looks-like-it-came-from-the-future/ 이 링크에 나오는 것처럼 생겼다. 반짝반짝한 게 켜졌다 꺼지기를 반복하니 얼마나 예쁜지 동아리 여자 부원(1명밖에 없지만)이 극찬했고, 2013년 대 프로젝트로 16x16x16의 3D LED 큐브가 올라왔다. 하지만,

4*4*4는 2^(2*3)이니까 2^6이고, 2^6은 64니까 64개의 LED가 필요하구나!
그럼, 16*16*16은 2^(4*3)이니까 2^12... 2^10이 1024니까,, 2^12는...

제정 문제로 이 프로젝트는 21*35의 LED 테트리스 게임기를 만드는 걸로 바뀌었다. 동아리 지원금이 100만 원인데 이걸 한 프로젝트에 다 쏟을 수는 없지. 암.

제작 과정

LED의 음극(cathode)을 구부리고 서로 연결하면 1차 준비 완료! 저렇게 하면 왼쪽으로 갈수록 빛이 약해지지 않을까 생각하기 쉬운데, 전류 받는 곳은 양극(anode)이고 양극 하나하나에 전기를 넣어주기 때문에 상관없다. 위로 많이 연결하면 문제가 생기지만... 더 깊이 들어가면 잘못된 정보가 튀어나오니 여기서 컷.

음극끼리 연결이 끝났으면 양극끼리 연결할 차례! 양극끼리 세로로 연결하고 맨 밑줄엔 저항을 달고 아누이노와 연결하면 준비 완료!

... 올리려 보니 정작 중요한 제작 과정은 안 찍고 반짝이는 사진만 찍어놔서 글이 매우 짧다. 매우 난감하다. 그런데 뭐, 하는 건 극끼리 연결하는 것밖에 없으니까... 그래도 궁금하면 위에 엄청 긴 링크를 참고하면 된다. 어떻게 연결하는지 매우 자세히 나와 있다.

프로그래밍 연습 - 초시계 만들기

솔직히 말해서 게임 만드는 건 이때가 처음이었다. 그래서 누구든 프로그래밍에 발들일 때 만든다는 테트리스를 만든 것이고, 아두이노도 모터밖에 돌려본 적이 없어서 감 잡기로 초시계를 만들어봤다.

led.h
// (0, 0) -> (top, left)
#define xLength 21
int x[xLength] = {12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 31, 30, 14, 15, 16, 17, 18, 19, 20, 21};
#define yLength 35
int y[yLength] = {A12, A13, A14, A15, 52, 53, 50, 51, 48, 49, 46, 47, 45, 44, 43, 42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 32, 29, 13, 28, 27, 26, 24, 25, 23, 22};

#define xOn(a) digitalWrite(a, HIGH);
#define xOff(a) digitalWrite(a, LOW);
#define yOn(a) digitalWrite(a, LOW);
#define yOff(a) digitalWrite(a, HIGH);

배열 x는 저항이 달린 맨 밑줄에 연결된 각각의 세로줄을 나타내고, 양극, 즉 전류를 넣어줄지 말지를 선택할 수 있다.
배열 y는 가로줄을 나타낸다. 음극을 조절하며, 전류를 넣으면 LED가 꺼진다.
각 배열에 든 정수는 아두이노의 어느 핀에 꽂았는지를 나타낸다.

block.h
void copyBlock(int destination[][xLength], int block[][xLength], int xp, int yp, int w, int h)
{
	for(int i = 0; i < h; i++)
	{
		int iyp = i + yp;
		if(iyp < 0 || iyp >= yLength) continue;

		for(int j = 0; j < w; j++)
		{
			int jxp = j + xp;
			if(jxp < 0 || jxp >= xLength) continue;

			destination[iyp][jxp] = block[i][j];
		}
	}
}

int numberBlock[10][5][xLength] = {
	{
		{1, 1, 1},
		{1, 0, 1},
		{1, 0, 1},
		{1, 0, 1},
		{1, 1, 1},
	}, {
		{0, 0, 1},
		{0, 0, 1},
		{0, 0, 1},
		{0, 0, 1},
		{0, 0, 1},
	}, {
		{1, 1, 1},
		{0, 0, 1},
		{1, 1, 1},
		{1, 0, 0},
		{1, 1, 1},
	}, {
		{1, 1, 1},
		{0, 0, 1},
		{1, 1, 1},
		{0, 0, 1},
		{1, 1, 1},
	}, {
		{1, 0, 1},
		{1, 0, 1},
		{1, 1, 1},
		{0, 0, 1},
		{0, 0, 1},
	}, {
		{1, 1, 1},
		{1, 0, 0},
		{1, 1, 1},
		{0, 0, 1},
		{1, 1, 1},
	}, {
		{1, 0, 0},
		{1, 0, 0},
		{1, 1, 1},
		{1, 0, 1},
		{1, 1, 1},
	}, {
		{1, 1, 1},
		{0, 0, 1},
		{0, 0, 1},
		{0, 0, 1},
		{0, 0, 1},
	}, {
		{1, 1, 1},
		{1, 0, 1},
		{1, 1, 1},
		{1, 0, 1},
		{1, 1, 1},
	}, {
		{1, 1, 1},
		{1, 0, 1},
		{1, 1, 1},
		{0, 0, 1},
		{0, 0, 1},
	},
};

void printNum(int destination[][xLength], long number, int xp, int yp, bool ltr = true, int distance = 3)
{
	int digit = floor(log10(number));
	copyBlock(destination, numberBlock[number % 10], xp + (ltr ? digit * distance : -3), yp, 3, 5);
	if(digit > 0)
	{
		printNum(destination, floor(number / 10), xp + (ltr ? 0 : -1 * distance), yp, ltr, distance);
	}
}

xp는 x Position, yp는 y Position, ltr은 left 2 right(글자 정렬), distance는 글자 간격으로 이해하면 되겠다. LED 꺼짐은 0, 켜짐은 1이다.

초시계 따위를 만들면서 가장 애먹은 부분이다. 화면에 숫자를 출력하는 부분인데, 터미널이면 printf()면 끝날 일이지만 아두이노는 터미널이 아니므로 꽤 삽질을 해야 했다. 하루 동안 동아리 부원들과 고민했으나 아무도 기발한 생각을 해내지 못했으니 말 다 했지.

그러다 결국 해산했는데, 집에서 곰곰이 생각하다 보니 수1 시간에 배운 로그 함수가 떠올랐다. 지표+1이 변수의 자릿수가 된다 던가... 첫째 자리의 수를 구하고 출력한 후 다음 자리의 수를 구하고 출력하는 식으로 코딩했더니 원하는 대로 잘 작동했다. 역시 배워서 나쁠 건 없군!

tetris.ino
#include "led.h"
#include "block.h"

void setup()
{
	for(int i = 0; i < xLength; i++)
	{
		pinMode(x[i], OUTPUT);
		xOff(x[i]);
	}
	for(int i = 0; i < yLength; i++)
	{
		pinMode(y[i], OUTPUT);
		yOff(y[i]);
	}
}


void loop()
{
	int frame[yLength][xLength] = {};
	printNum(frame, floor(millis() / 1000.0), 0, 0);

	// apply frame
	for(int i = 0; i < yLength; i++)
	{
		yOn(y[i]);
		bool applied = false;
		while(true)
		{
			for(int j = 0; j < xLength; j++)
			{
				if(frame[i][j] == 0 || applied)
				{
					xOff(x[j]);
				}
				else
				{
					xOn(x[j]);
				}
			}
			if(applied) break;
			applied = true;
		}
		yOff(y[i]);
	}
}

millis()는 아두이노의 작동 시간을 밀리 초 단위로 알려준다.

위 두 코드를 보고 벌써 눈치챈 사람도 있을 것이다. 아두이노는 C++로 어느 정도 프로그래밍할 수 있다. IDE가 제대로 지원해 줄 때의 얘기지만, 어쨌든 할 수 있다. 네임스페이스, 클래스. 모두 다 된다. 하지만 아두이노의 진입점은 main()이 아니라 setup()이고, setup()에서 필요한 작업을 모두 마치면 loop()에서 신호를 기다리며 작업을 처리한다는 점이 조금 다르다.

이 코드의 setup()에서 각 LED에 pinMode를 설정함으로써 digitalWrite()로 LED를 조정할 수 있게 된다.
loop()의 마지막 부분은 위에서 임시 저장한 화면을 적용하는 부분이다. 다른 식으로도 작성할 수 있다고 생각할 수도 있겠지만, 저게 최선이었다. 나한테는. 저렇게 하지 않으면 여러 가지의 물질을 섞은 방출 스펙트럼같이 윗줄에 꺼져야 할 LED가 밑줄 따라 켜지더라...

후기로, "내가 배운 게 쓸모없는 게 아니었어!"하고 깨닫게 돼서 완성하고 엄청 좋아했는데, 정작 찍은 영상이 없다. 허무하다.

1. 대문 만들기

숨겨진 도트 찍기 실력을 보여줄 절호의 기회가 왔다. 어디 사는 아무개 씨가 대문에 쓰일 로고를 만들면서 예전에 gif 사용자이미지 만든다고 이미지레디(Adobe)로 삽질하던 때가 생각나서 흐뭇하게 작업했다고... 그림은 말보다 보는 게 빠르다. 로고를 보자

tetris.ino
// logo "TETRIS"
int logo[11][xLength] = {
	{1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1},
	{1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0},
	{0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0},
	{0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0},
	{0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0},
	{0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
	{0, 0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 1},
	{0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0},
	{0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1},
	{0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1},
	{0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1},
};
copyBlock(frame, logo, 3, 1, 14, 11);

보이는가! TETRIS!! 1과 0으로 이루어진 배열로 적당히 그림을 그린 후 화면에 적용하면 로고가 완성된다. 참 쉽다! 헌데, 지금 만들고 있는 것은 대문이다. 로고만 표시해서 뭘 하겠나.

메뉴

이제 메뉴를 만들어야 하는데, 메뉴를 선택하려면 사용자 입력을 받아들일 수 있어야 한다. 그러기 위해 수제 조종기를 만들거나 조이스틱을 사서 사용하는 방법을 생각해봤으나 결국엔 있는 걸 썼다. 조종기로 사용된 부품은 "숫자 키패드"다. 싸고, 다루기 쉬우며, 익숙하다.

그런데 아두이노 보드를 만져보면 USB 포트가 없다는 것을 알 수 있다. 아니, 있긴 한데 USB를 꽂을 수는 없다. 그래서 두 기기를 연결해줄 호스트로 컴퓨터를 사용했다. 컴퓨터 프로그램으로 조종기가 눌릴 때의 키 값을 받아서 아두이노로 보내는 것이다. 호스트와 데이터를 주고받기 위해 아두이노 프로그램에 코드를 추가했다.

state.h
namespace State
{
  enum State
  {
    lobby = 1,
    game,
    score,
  };
}

현재 어떤 화면에 있는지 나타낸다. 다시보니 매크로로 정의했으면 어땠을까.. 싶다. 지금은 대문을 만들고 있으니까 lobby에 있다고 약속하자.

key.h
namespace Key
{
	enum Key
	{
		L = 1,
		R,
		left,
		right,
		up,
		down,
		ok,
		cancel,
	};
}

조종기에서 어떤 키가 눌렸는지 나타낸다. 키는 7이 L, 9가 R, 4가 left, 6이 right, 8이 up, 2가 down, 5가 ok, 백 스페이스가 cancel이다.

tetris.ino
...
#include "state.h"
#include "key.h"

void setup()
{
	Serial.begin(9600);
	...
}

State::State state = State::lobby;
bool stateChanged = false;

long playerId = 0;

void loop()
{
	int frame[yLength][xLength] = {};
	int receivedAction = 0;

	if(Serial.available() > 0)
	{
		receivedAction = Serial.read();
	}

	if(stateChanged)
	{
		stateChanged = false;
		playerId = 0;
	}

	switch(state)
	{
		case State::lobby:
		{
			// logo "TETRIS"
			...

			// menu
			int menu[11][xLength] = {
				{1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1},
				{1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1},
				{1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0},
				{1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0},
				{1, 0, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 0, 1, 0},
				{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
				{1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1},
				{1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0},
				{1, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 0},
				{1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0},
				{1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1},
			};
			/// display menu to the middle of free space
			int yp = 12 + floor((yLength - 12 - 11) / 2.0);
			copyBlock(frame, menu, 3, yp, 15, 11);
			/// change state
			if(receivedAction == Key::ok)
			{
				if(playerId <= State::lobby || playerId > State::score)
				{
					playerId = State::game;
				}
				state = (State::State)playerId;
				stateChanged = true;
			}
			/// use playerId to indicate hovered item
			else if(receivedAction != 0)
			{
				/// left side key=>top; right side key=>bottom;
				playerId = receivedAction & 1 == 1 || receivedAction == Key::cancel ? State::game : State::score;
			}
			int bar[3][xLength] = {
				{1},
				{1},
				{1},
			};
			if(playerId == State::score)
			{
				yp += 6;
			}
			copyBlock(frame, bar, 1, ++yp, 2, 3);
			copyBlock(frame, bar, xLength - 2, yp, 2, 3);

			break;
		}
	}

	...
}

Serial.begin()으로 호스트와 통신할 수 있게 설정한다. Serial.available()은 데이터를 받으면 0보다 큰 값이 된다.
그리고 또 도트질이 나왔다. menu의 윗줄이 PLAY, 밑줄이 RANK(원래는 SCORE였으나 공간부족)이다.
... 로 축약한 코드는 연습용으로 만든 초시계와 로고에 있는 코드다.

코드를 보다가 변수명과 역할이 적절하지 않다고 느끼는 건 정상이다. 변수명에 어색한 새로운 역할을 주석으로 달아놨으니까 이해할 거로 생각하고 패스. 코드를 자세히 살펴보면 뼈와 살이 들어간 삶의 코드라는 것을 알고 감동할지도 모른다.

조종기로 선택된 메뉴를 조종하는 69번째 줄을 보면, 왼편의 키가 PLAY를 선택하고 오른편의 키가 RANK를 선택한다고 되어 있다. 비트 연산을 통해 키 값의 2진수 첫째 자리가 1이거나(키 값이 홀수이거나) 취소 키를 누르면 PLAY를 선택한다. if(receivedAction == Key::left || receivedAction == Key::L || ...) 보다 훨씬 짧고 좋다. 암.

밑은 대문 만들기를 마친 LED 테트리스 게임기의 영상이다.

2. 플레이어 아이디 설정하기

대문의 메뉴에서 PLAY를 선택하면 게임 전에 플레이어 아이디를 설정할 것이다. 이는 RANK에서 순위 매길 때 필요한 요소로, 순위 필요 없으면 하지 말자.


완성

이렇게 차근차근 만드는 과정을 설명하려 했으나,,,, 까먹었으므로 소스라도 올린다..
시간이 조금 흐른 뒤 보니 코딩 스타일도 많이 바꼈고, 다음에 만들면 메모리 절약하자는 생각이 물씬...

ledTetris.zip

다시 읽어보니 중요한 설명 들어가다가 글을 끝내버렸다. 게임 프로그래밍은 회전변환이나 로그 등 나름 머리써서 했다고 좋아했는데...

저작자 표시 비영리

http://blog.bloodcat.com/trackback/272 관련글 쓰기

  1. 버들서
    뭘 검색했는데 우연히 또 들어왔네요 잘 지내시는지요?

Name __

Password __

Link (Your Website)

Comment

1 2 3 4 5 ... 176