ImageEditor9은 모바일 전용 자바스크립트 이미지 편집기로,

모바일의 특징을 고려하여 모바일에서 손쉽게 이미지를 편집할 수 있도록 제작하였다.

소스는 github에서 받을 수 있고, 데모도 확인할 수 있다.

여기에서는 ImageEditor9의 주요 이미지 편집 기능과 자바 스크립트로 실행하는 방법을 정리한다.

 

1. 이미지 편집 기능

모바일용 이미지 편집기이기 때문에 기본적인 편집 기능은 손가락 터치를 활용한 편집 기능이다.

이미지를 한 손가락으로 누른 체 이동하면 이미지의 위치를 옮길 수 있다.

손가락 두 개를 터치한 경우에는 상황에 따라 확대/축소를 하거나 회전하도록 구현하였다.

두 손가락을 대고 좁히거나 넓히면 확대 / 축소를,

두 손가락을 옆으로 움직이면 이미지를 회전 시킬 수 있다

(구현 알고리즘은 기울기를 계산하여 수직 이동에 가까우면 확대 축소를, 수평이면 회전시키는 방식이다.)

 

메뉴를 이용한 편집 기능은 메인 메뉴를 클릭하면 위 그림처럼 세로로 메뉴가 나타난다.

제공되는 메뉴는 다음과 같고, 순서데로 가로/세로 크기변경, 좌우 회전, 확대/축소의 편집기능과 나가기, 저장 기능이 있다.

손가락을 이용해서 사용하는 회전과 크기 조정 기능을 메뉴를 이용하여 사용할 수도 있다.

회전 기능은 왼쪽과 오른쪽으로 10도씩 이동할 수 있다.

크기 조정은 10% 단위로 크기로 확대하거나 축소할 수 있다.

 

비슷한 회전 기능으로 이미지의 크기를 변경하면서 90도로 회전하는 가로/세로 크기변경 기능이 있다.

즉, 앞서의 첫 그림과 같이 가로와 세로의 크기가 바뀌는 기능을 (오른쪽 그림) 제공한다.

모바일은 기기를 회전 시켜서 사진을 찍는 경우가 많아서 구현한 기능이다.

 

편집한 이미지는 저장하거나, 저장없이 프로그램을 종료할 수 있다.

 

2. 사용방법

이러한 모바일 전용 이미지 편집기 ImageEditor9은 자바스크립트(JavaScript) 라이브러로, 다음과 같이 자바스크립트로 간단하게 실행 할 수 있다.

먼저, 사용하고자 하는 HTML 파일에서 ImageEditor9 라이브러리(ImageEditor9.js)와 디자인(ImageEditor9.css) 파일을 포함한다.

<link rel="stylesheet" href="imageEditor9.css">
<script src="imageEditor9.js"></script>

 

ImageEditor9은 이미지 편집을 하려고 할 때 생성해도 되고,

다음과 같이 HTML 문서가 로딩되고(onload) 나면 미리 생성해도(new) 된다.

  window.onload = function() { 
    img = document.getElementById('img1');
    imageEditor = new ImageEditor9 (document.getElementById("imageEditor9"));
    imageEditor.onSave = function() {
      img.src = imageEditor.toDataURL();
    }
  }

ImageEditor9을 생성할 때(new) 이미지 편집기능을 구현한 div 태그(다음 코드)를 매개 변수로 지정한다.
( document.getElementById("imageEditor9") )

이 div 태그 안에 ImageEditor9에 필요한 내용들을 생성해서 편집 기능을 사용한다.

 

ImageEditor9을 생성하고 나면,

ImageEditor9에서 편집한 이미지를 받아서 처리할 저장(onSave) 이벤트를 지정해야 한다.

여기에서는 편집한 이미지를 Base64로 받아서(toDataURL), 원본 이미지(img) 태그의 이미지를(src) 교체한다.

실제 사용할 경우에는 편집한 이미지 정보를 서버로 전송해서 저장하는 형태로 사용할 것이다.

<body>
  <div id="imageEditor9"></div>
</body>

이렇게 생성한 ImageEditor9은 showFullscreen() 함수로 화면에 보이도록 실행한다.

그리고, 편집할 이미지 원본을 지정하면(setImageSource), ImageEditor9에서 해당 이미지를 편집 할 수 있다.

  function ev_fullEditor(){
    imageEditor.showFullscreen();
    imageEditor.setImageSource(img);
  }
  

showFullscreen() 함수외에도 showFulldocument()함수가 제공된다.

showFullscreen() 함수는 모바일의 작은 화면을 최대한 활용하기 위해,

스크린 전체를 편집화면으로(requestFullscreen()) 사용하는 기능으로

웹브라우저에서 F11을 눌렀을때, 전체 화면으로 표시되는 기능을 이용한 것이다.

 

이 기능은 애플의 사파리(Safari)에서 동작하지 않기 때문에 showFulldocument() 함수를 제작하였다.

showFullscreen() 함수로 실행해도 애플의 사파리(Safari)에서는 showFulldocument()로 실행된다.

showFulldocument()는 스크린이 아닌 문서에 꽉찬 실행으로 팝업 다이얼로그와 같은 방식으로 구현했다.

 

이상으로 ImageEditor9은 모바일 전용 자바스크립트 이미지 편집기의 편집 기능과 사용방법에 대해서 정리하였다.

ImageEditor9은

모바일의 특징인 손가락을 이용한 편집 기능과 

모바일의 작은 화면을 극복하기 위해 전체 화면으로 이미지를 편집할 수 있는 기능에 맞추어 제작하였다.

 

gu-upload는 웹 사이트에서 파일을 업로드하는 HTML5 기반 JavaScript 라이브러리로 

2014년에 처음 제작하여 현재까지 조금씩 수정하여 사용하고 있다.

2016년에 수정하고 작성한 문서는 여기서 확인할 수 있고

그 동안 사용하면서 불편하거나 문제가 된 사항을 이번에 수정하면서 새롭게 문서를 작성한다.

 

gu-upload는 기본적으로 HTML5 기반이라 IE(Edge), Firefox, Chrome에서 실행된다.

그리고, IE 9 이전의 웹 브라우저에서는 플래시로 제작된 SWFUpload가 실행된다.

아직도 옛 버전의 IE를 사용하는 사람과 기입들이 있어서 기능 수정 없이

같이 제공하고 있으며, 몇 년 뒤에는 제외할 예정이다.

 

주요 기능은 다음과 같다.

  1. 탐색기에서 드래그 앤 드롭(Drag & Drop)으로 파일을 추가하거나, gu-upload를 더블 클릭하여 파일 추가.
  2. 여러 개의 파일을 선택해서 업로드 및 전송 상태 표시.
  3. 추가한 파일을 리스트나 미리보기(이미지용)로 설정 사용
  4. 기타 파일 크기나 개수 제한 기능 등

 

gu-upload의 소스는 github에서 받을 수 있고,

gu-upload을 사용하는 Java(Spring) 예제도 여기에서 받을 수 있다.

PHP등의 다른 개발 언어도 전송 받은 파일을 저장하는 부분만 다를 뿐,

gu-upload의 사용법은 모두 동일하다.

여기에서는 gu-upload을 사용하는 Java(Spring) 예제를 중심으로 gu-upload의 사용법을 정리한다.



먼저, gu-upload를 사용하기 위해서는 github에서 소스를 받아서 웹 프로젝트의 자바 스크립트 폴더에 넣어주면 된다.

( Java(Spring) 예제를 Eclipse에서 받아서 전체 코드와, 블로그의 내용을 같이 보는 것이 이해하는데 좋을 것 같다.)

guupload폴더에 있는 guuploadManager.js와 css 폴더에 있는 guupload.css가 기본 파일로

gu-upload를 사용하려는 웹 페이지에 다음과 같이 각 파일을 포함해서 사용한다.

<link rel="stylesheet" type="text/css" href="js/gu-upload/css/guupload.css"/>
<script type="text/javascript" src="js/gu-upload/guuploadManager.js"></script>

guupload.css는 gu-upload에서 사용하는 모든 디자인(CSS) 클래스를 모아놓은 파일로

gu-upload를 사용하는 사람의 취향에 맞추어 수정해서 사용하면 된다.

guuploadManager.js는 gu-upload와 SWFUpload를 관리하는 부분으로,

사용하려는 웹 페이지에 이 두 개의 파일을 포함하고, 다음과 같이 사용하면 된다.

var guManager=null;

window.onload = function() {
	var option = {
		fileid: "attachFile",
		uploadURL: "upload",
		maxFileSize: 100,
		maxFileCount: 12,
		useButtons: false,
		afterFileTransfer: afterFileTransfer
	}
	guManager = new guUploadManager(option);
}

전체 소스 코드

웹 페이지 실행이 완료된 후(onload),

guUploadManager를 적절한 옵션으로 생성(new)하면 된다.

옵션 설명
fileid gu-upload를 생성할 태그의 ID (주로 div)
listtype 첨부할 파일을 보여주는 방식으로, 리스트와 미리보기(이미지) 방식이 있다.
별도로 지정하지 않으면 리스트 방식이고, 
thumbnail로 지정하면 미리보기 기능을 이용할 수 있다.
uploadURL gu-upload로 전송한 파일을 서버에서 받아서 저장할 컨트롤 주소
maxFileSize 전송할 파일의 최대 크기. 기본값은 20M.
maxFileCount 한번에 전송할 수 있는 파일의 최대 개수. 기본값은 10개.
useButtons 파일 찾아보기 버튼 사용여부. 기본값은 true
afterFileTransfer 파일을 전송한 후 호출할 콜백 함수.

fileid, uploadURL, afterFileTransfer는 반드시 지정해야 하는 옵션이고,

나머지 옵션은 지정하지 않는 경우 기본값이 적용된다.

 

먼저, fileid는 gu-upload를 생성할 태그의 ID로 주로 div 태그를 다음과 사용한다.

        <tr>
            <td>Contents</td>
            <td><textarea name="brd_contents" cols="55" rows="5" style="width: 500px;"></textarea></td>
        </tr> 
        <tr>
            <td>Attach File</td>
            <td>
                <div id="attachFile" style="width: 500px;"></div>
            </td>
        </tr>

전체 소스 코드

div 태그의 id 로 'attachFile'을 지정하였고, fileid 옵션에 'attachFile'을 지정하다.

지정된 이 div 안에 gu-upload가 생성되어 실행된다.

 

listtype은 첨부할 파일을 보여주는 방식으로, 

단순하게 리스트로 보여주는 방식과 이미지 파일을 위한 미리보기 방식이 있다.

별도로 지정하지 않으면 리스트 방식이고, 

thumbnail로 지정하면 미리보기 기능을 이용할 수 있다.

예제 소스 보기

예제 소스 보기

uploadURL은 gu-upload가 전송한 파일을 받아서, 서버에 파일로 저장하는 컨트롤을 지정하는 부분으로

각각의 서버 언어에 맞는 url을 지정하면 된다.

gu-upload 예제에서는 다음과 같이 Spring으로 제작하였고

url이 upload이기 때문에 uploadURL에 upload로 지정하였다.

	@RequestMapping(value = "/upload")
	public void upload(HttpServletResponse response, HttpServletRequest request, @RequestParam("Filedata") MultipartFile Filedata) {
	   	SimpleDateFormat df = new SimpleDateFormat("yyyyMMddHHmmssSSS");
	   	String newfilename = df.format(new Date()) + Integer.toString((int) (Math.random()*10));
	   	
		File f = new File("d:\\" + newfilename);
		try { 
			Filedata.transferTo(f);
		   	response.getWriter().write(newfilename);
		} catch (IllegalStateException | IOException e) {
			e.printStackTrace();
		}
	}	

소스코드

서버에 파일을 저장하는 부분은 gu-upload와 관련이 없는 부분으로,

(gu-upload는 파일을 서버로 전송하는 부분까지만 담당)

개발환경에 따라 upload.asp, upload.php, upload.do 등을 지정해서 사용하면 된다.

파일을 저장하고 나면, 반드시 반환 값(response.getWriter().write)으로 저장한 파일명을 넘겨야 한다.

파일명 중복, 보안등의 이유로 첨부 파일을 다른 이름으로 저장하는데,

이 파일 명을 반환해야 gu-upload가 저장한 파일명(filename), 파일 크기(filesize)와 조합하여 개발자가 사용할 수 있다.

 

옵션 중 가장 어려운 것이 afterFileTransfer으로 콜백함수를 지정해서 사용하는 것일 것이다.

gu-upload는 파일을 먼저 전송하고 사용자가 입력한 값을 전송하기 때문에

그림과 같이 일반적인 파일 업로드 프로그래밍 방식과 제법 큰 차이가 있다.

일반적인 웹 페이지에서는 사용자가 입력한 값들(Text)과 첨부 파일(file)을 같이 전송한다.

게시판을 예로 하면 제목, 글 내용등의 값(Text)와 첨부 파일(file)을 한 번에 서버로 전송한 뒤,

파일을 저장하고, 파일명(파일크기등)과 제목, 글 내용등을 데이터베이스에 저장한다.

 

gu-upload를 사용하는 경우에는

1. 첨부파일을 먼저 서버에 전송해서 저장하고

2. 서버에서 저장한 실제 파일명(realname)을 받아서, gu-upload가 가지고 있는 파일명(filename), 파일크기(filesize)을 조합해서 gu-upload 생성시 옵션으로 지정한 콜백함수(afterFileTransfer)로 넘겨 준다.

3. 콜백함수에서는 넘겨 받은 파일 정보를 사용자가 입력한 값과 같이 서버에 넘겨서 데이터 베이스에 저장한다.

 

따라서 일반적인 웹 페이지에서는 사용자가 저장 버튼을 선택하면,

지정된 함수(formSubmit)가 호출되어 사용자가 입력한 값을 확인하고,

form의 submit을 호출하여 입력값과 파일을 서버로 전송한다.

function formSubmit(){
	var brd_title = document.getElementById( 'brd_title' );
	if (brd_title.value==="") {
		alert("input!");
		return;
	}
	
	document.form1.submit();
}

전체 소스 코드

gu-upload를 사용하는 경우에는, 웹 페이지에서 사용자가 저장 버튼을 선택하면,

지정된 함수(formSubmit)함수가 호출되어 사용자가 입력한 값을 확인하고,

uploadFiles()를 호출하여 파일을 먼저 서버에 전송해서 저장한다.

function formSubmit(){
	var brd_title = document.getElementById( 'brd_title' );
	if (brd_title.value==="") {
		alert("input!");
		return;
	}
	guManager.uploadFiles();
}

function afterFileTransfer(realname, filename, filesize){
	var realname9 = document.getElementById( 'realname' );
	var filename9 = document.getElementById( 'filename' );
	var filesize9 = document.getElementById( 'filesize' );
	
	realname9.value = realname;
	filename9.value = filename;
	filesize9.value = filesize;
	
	document.form1.submit();
}

전체 소스 코드

파일 전송이 끝나면, 앞서 afterFileTransfer옵션으로 지정한 콜백함수(afterFileTransfer)가 실행된다.

파라미터로 전송한 파일의 실제 파일명(realname), 파일명(filename), 파일크기(filesize)가 넘어오고,

이 값을 다른 값들과 같이 서버에 전송한다(submit).

같이 전송하기 위해 다음과 같이 실제 파일명(realname), 파일명(filename), 파일크기(filesize)를 저장할 태그가 생성되어 있어야 한다.

    <form id="form1" name="form1" action="upload_save.jsp" method="post">
        ~~ 생략 ~~
        <input type="button" value="Submit" onclick='formSubmit()'  />
        <input type="hidden" id="realname" name="realname"/>
        <input type="hidden" id="filename" name="filename"/>
        <input type="hidden" id="filesize" name="filesize"/>        
    </form>

전체 소스 코드

예제에서는 저장할 데이터 베이스가 없기 때문에

클라이언트에서 전송한 값을 받아서 화면에 출력한다.

전송 파일은 2개 이상일 경우가 많기 때문에

파일을 전송한 후 콜백함수(afterFileTransfer)를 호출할때 각 파라미터의 값은 콤마(,)로 구분되어 저장된다.

<% 
String filename = request.getParameter("filename");
String realname = request.getParameter("realname");
String filesize = request.getParameter("filesize");
String[] reallist = realname.split(",");
String[] filelist = filename.split(",");
String[] sizelist = filesize.split(",");

for (int i=0; i<filelist.length; i++) {
	out.println(filelist[i] + " : " + reallist[i] + " : " + sizelist[i] + "<br/>");
}				
%>

전체 소스 코드

서버에서 이 값을 받아 사용할 경우에는

위 코드와 같이 파일명, 파일크기등의 각 값을 splite로 분할해서 사용하면 된다.

 

이상으로 gu-upload을 사용하는 Java(Spring) 예제를 중심으로 gu-upload의 사용법을 정리하였다.

향후에는 상태바, 파일 확장자 제한 기능 등을 추가할 예정이다.

 

퇴사하면서,

내가 만든 웹 에디터(damoEDITOR)가 잘되었으면 하는 마음으로 이 글을 작성한다.

영업자의 마인드로 작성된 소개서가 있지만

여기서는 개발자의 마인드로, 한가지 차이점을 중심으로 정리한다.

웹 에디터는 웹 브라우저에서 별도의 설치 없이 문서를 작성하게 하는 프로그램으로,

제법 많은 제품들이 있지만

오픈 소스로는 CKEditor와 NicEditor등이 대표적이고,

상용 제품으로 나모, Dext5, 태그프리 등의 3개 기업 제품이 있다.

NicEditor는 본 블로그에서 정리한 적이 있지만, 현재는 관리되지 않는 것 같다.

다양한 기능을 제공하는 CKEditor와 tinyMCE등의 오픈 소스 제품이 무료로 제공되어 폭넓게 사용되지만,

국내 공공 기관이나 대기업들에서는 상용 제품을 많이 사용한다.

상용이 조금 더 많은 기능을 제공하는 것도 있겠지만 기술 지원 등의 문제로 선호하는 것 같다.

상용은 나모, Dext5, 태그프리의 3개 기업 제품이 주도하는데,

아주 뒤늦게 우리 회사도 웹 에디터 시장의 한 귀퉁이를 파고 들면서 판매를 시작하였다.

(실제로는 자사 기업용 솔루션에서 사용하는 웹 에디터로 사용하였다.)

 

이 제품의 이름은 damoEDITOR (이름은 별로..)로 기존 상용 제품들과 거의 동일한 기능을 제공한다.

(기본 정보는 여기에서 확인할 수 있다.)

기존 상용 제품과 비교해 핵심 기능들은 동일하지만, 부가적인 기능에서 2% 부족하다.

이미지(IMG) 태그를 예로 들면,

기존 제품은 이미지 추가시 이미지 크기를 같이 입력해서 지정하지만,

damoEDITOR는 이미지를 추가하고 난 뒤에 마우스로 조절한다.

 

하지만 damoEDITOR는 한가지 차이를 가지고 제작되었고, 이 차이로 인해 몇 가지 장점이 있다.

 

대부분의 웹 에디터는(특히 상용은 모두) IFrame을 이용해서 제작되었다.

IFrame은 Inline Frame이라는 의미로 하나의 HTML문서내에서 다른 HTML문서를 보여주기 위해 사용한다.

IFrame은 웹 보안등의 문제로 사용을 제한하는 추세이지만,

오래 전에 개발된 웹 에디터는 IFrame으로 제작하는 것 외에는 방법이 없었다.

 

최근에는 IFrame 대신에 DIV로 개발하는 것이 추세가 되고 있다.

DIV로 제작할 경우 반응형 웹(Responsive Web) 페이지에 대응할 수 있는 장점이 있다.

이것이 damoEDITOR의 첫번째 장점이다.

웹 페이지(웹브라우저)의 크기에 따라 웹 에디터의 크기가 변하게 제작할 수 있다.

반면, IFrame을 기반으로 제작된 웹 에디터들은 크기 변화에 대응하는 것이 쉽지 않다.

최근(?)에 제작된 오픈소스 웹 에디터는 DIV를 이용하여 반응형 웹용으로 많이 제작되었다.

다만, 이 웹 에디터들은 제공되는 기능이 몇 가지 없다.

damoEDITOR는 다음 그림(우측 상단)에서 보는 것처럼

웹 브라우저의 크기에 따라 변할 뿐 아니라 제공되는 기능 버튼의 위치까지 변한다.

웹 에디터의 너비가 작아지면서 가려지는 버튼들을 보조 툴바를 이용해서 메뉴처럼 보여준다.

다음 그림과 같이 더 작아지면 메인 툴바를 한줄만 남기고,

가려진 버튼들은 모두 보조 툴바에서 사용 할 수 있다.

그림과 같이 모바일 전용으로 사용할 수도 있다.

 

두 번째 장점은 작성한 데로 보는 것이다.

IFrame내에서 호출되는 HTML은 IFrame 외부의 HTML과 격리되어 있다.

이 두 개의 HTML은 별개의 페이지로 전혀 다른 CSS가 사용된다.

즉, IFrame 외부의 HTML은 웹 에디터를 구매해서 사용하려는 기업의 웹 페이지이고,

IFrame 내부의 HTML은 웹 에디터의 영역으로 독립적인 영역이다.

따라서 두 개의 CSS가 사용되어 사용자에게 혼란을 야기한다.

 

HTML <a> 태그를 예로 하면,

웹 에디터에서 한 문장을 <a> 태그로 지정하면 밑줄(underline)이 생긴다.

만약, 사이트 전체에 <a> 태그의 CSS를 밑줄이 아닌 색상을 사용하도록 지정하면 

웹 에디터로 작성한 내용을 글 읽기로 볼 때, 다음 그림과 같이 다른 화면을 보게 된다.

(웹 에디터가 사용되던 초기에 이러한 문제로 클레임이 많았던 것으로 기억한다.)

위 그림은 일반 텍스트는 회색, 링크는 검은색으로 지정해서 사용한 사이트의 예제이다.



DIV로 개발한 제품은 

사용하는 웹 페이지의 CSS를 상속받아서 사용하기 때문에 이러한 문제가 생기지 않는다.

작성 화면과 조회 화면이 동일하게 보이는 것이다.

기존 제품들은 이문제를 해결하기 위해 웹에디터의 CSS에 자사 웹 페이지의 CSS를 수정해야 한다.

 

고려하지 못한 유사한 문제가 생길 경우

damoEDITOR는 damoEditor.css에서 수정하면 해당 기업의 CSS와 동일하게 맞출수 있다.

사용하는 웹 페이지가 <a> 태그에 밑줄도 색상도 아무것도 지정하지 않으면

웹 에디터 작성시 <a> 태그에 아무것도 나오지 않아 문제가 되기도 했다.

(링크에 아무런 표시를 하지 않는 사이트라면 UI에 대해서 고민을...)

이 경우 damoEditor.css에서 다음과 같이 지정해 주면 간단하게 해결된다.

.damoEditor a {
    text-decoration: underline 
}

이렇게 처리하고, 글읽기가 있는 경우 이상의 코드를 관련 부분에 수정해서 넣어줘야 한다.

그렇지 않을 경우, 글작성(웹에디터 사용)시에는 링크에 밑줄이 있는데,

글읽기에서는 없어서 혼란을 줄 수 있다.

 

다른 웹 에디터를 많이 사용해본 사람들에게는

이러한 기능이 오히려 혼동을 주기도 해서

기존 방식처럼 CSS가 별도로 운영되는 IFrame 방식도 제공한다. 

 

세 번째 장점은 제품에 포함된 damoEditor.css 파일을 수정해서

damoEDITOR를 사용하는 기업에서 마음 데로 디자인을 수정하고 개선할 수 있다는 것이다.

이것은 damoEDITOR만의 고유한 기능으로 CSS를 조금이라도 아는 사람(퍼블리셔)이 있다면,

자신의 웹 사이트에 맞춰서 다양하게 정의해서 사용할 수 있다.

이것은 damoEDITOR를 구성하는 거의 모든 디자인이 damoEditor.css에 있기 때문에 가능하다.

대부분의 웹 에디터에서 테마를 제공하지만, 

몇 가지 색상만 제공하기 때문에 

사이트 디자인과 웹에디터의 디자인이 맞지 않는 문제가 많이 있다.

 

더욱이 웹 에디터들은 딱딱한 기본 구성으로 제공되는데,

damoEDITOR는 사용하는 기업에 맞춰서 다양하게 사용할 수 있다.

 

다음 그림은 기본제공되는 사각형 색상 팔렛트를 간단한 CSS 수정을 통하여 원형으로 바꿔서 사용한 예제이다.

사용법은 개발자 메뉴얼에 정리되어 있다.

차후에는 이렇게 사용자 정의로 작성된 다양한 디자인을 정리해서 판매 옵션으로 넣는다면 좋을 것 같다.

 

이상으로 damoEDITOR의 세가지 장점을 정리하였다.

제품에 대한 설명과 체험은 여기에서 할 수 있다.

나름 GS 인증까지 받은 이 제품이 정말 많이 팔리기를 기원한다.

 

이미지 편집기 PaintWeb은 웹에서 이미지를 편집할 수 있는 웹 어플리케이션으로

Mihai Şucan이 Javascript로 제작하였다.

윈도우의 그림판을 웹으로 구현했다고 할 수 있다.

라이브러리데모만 제공되고,

사용법(특히 서버에서 이미지 저장하는 방법)에 대한 설명이 부족해서,

Spring4 기반으로 이미지를 저장하고 관리하는 예제(github)와 설정 파일 수정하는 방법(블로그) 등을 이전에 정리하였다.


이후 필요하여, 이미지 회전, 텍스트 입력, 확대/축소 후 저장 기능 등을 추가 구현하였다.

이미지 회전은 90도 단위로 이미지를 회전하는 기능으로 새로 추가하였다.

텍스트 입력은 기존에 있던 기능으로 불편하게 구현된 것을

기존의 그림판처럼 그림(canvas) 위에서 문자를 입력하도록 구현하였다.

확대/축소 후 프로그램을 종료하면 저장되지 않던 것을 저장되도록 변경하였다.

해당 예제는 기존의 github에서 받을 수 있고,

github에서 다운 받아 실행하는 방법은 다른 문서에 정리되어 있다.

이 문서에서 github 주소와 다운받을 폴더명만 적절하게 바꾸어 주면 된다.

설치 후 실행해서 확인 할 수 있고,

저장기능을 제외한 기능을 데모 페이지에서 확인할 수 있다.


github에서 다운 받으면 다음 그림과 같이 파일들이 저장된 것을 볼 수 있다.

webapp > js > paintweb 폴더가 PaintWeb 라이브러리 폴더로

사용하고자 하는 프로젝트의 폴더에 복사해서 사용하면 된다.

webapp > WEB-INF > jsp > imageEditor.jsp 파일이 PaintWeb을 실행하는 파일로 작성된 코드는 다음과 같다.

먼저, PaintWeb 라이브러리를 사용하기 위해서는 Paintweb.js 파일을 가져와야 한다 [라인7].

이외에도 html2canvas.min.js를 사용하는데 [라인 8],

html2canvas는 html을 이미지로 변환하는 라이브러리로

사용자가 입력한 텍스트를(HTML) 이미지로 변환하기 위해 사용하였다.

Chrome에서는 문제가 없지만, 그 외의 웹 브라우저에서는 속도가 조금 떨어지는 현상이 발생한다.


PaintWeb은 이미지 태그(editableImage [라인 11])의 내용을 읽어서[라인 34] 편집한다.

기본적으로 제공되는 예제는 메인 페이지에서 이미지(uploadImage)를 지정하고,

별창에서 PaintWeb을 실행하는 방식으로 구현했다.

(PaintWeb 관련 코드만 보이기 위해서 별창으로 구현했다.)

따라서 별창에서 메인 페이지에 있는 이미지(uploadImage)를 가지고 와서 [라인 19]

별창의 이미지 태그(editableImage)에 넣어주고 [라인 22]

이 이미지를 PaintWeb이 사용하도록 코드를 작성하였다 [라인 34].

메인 페이지의 이미지를 바로 지정하지 않고 이렇게 한 것은 PaintWeb이 부모(opener)의 이미지를 인식하지 못해서 그런 것으로,

중요한 버그가 아니라 수정하지 않았다.


PaintWeb을 생성하고 [라인 31],

각자의 환경에 맞추어 설정을 한 후, 실행하면 된다 [라인 39].

설정과 관련된 보다 자세한 설명은 이전 문서를 참고하면 되고,

새로 추가한 imageSaveURL, imageDownloadURL, afterImageSave를 정리하면 [라인 36~38]

imageSaveURL은 PaintWeb에서 저장한 이미지를 서버에서 받아서 저장할 URL을 지정한다.

저장할 이미지는 Base64로 전송이 되기 때문에 이 내용을 받아서 파일로 저장하는 코드가 있는 URL (스프링의 경우 컨트롤)을 지정하면 된다.

스프링으로 작성된 saveImage의 코드는 여기서 확인할 수 있다.

imageDownloadURL은 이미지를 저장하고, 다시 받아오기 위한 URL로 imageSaveURL과 마찬가지로 ImageEditor.java 파일에 구현되어 있고 github에서 확인할 수 있다.

afterImageSave는 저장 후 호출되는 함수로,

여기서는 이미지 저장 후 별창을 닫도록 하였다(close) [라인 43].


이외의 설정이나 상세한 설명은 이전 문서를 참고하면 된다.


이상으로 수정된 PaintWeb에 대하여 정리하였고,

저장기능을 제외한 기능을 설치없이 데모 페이지에서 확인할 수 있다.




구글에서 Javascript Image Editor로 검색하면, 제법 쓸만한 HTML5 기반의 유/무료 이미지 편집기가 조회된다.

이중에서 윈도우에서 제공하는 그림판과 유사하고 간단하게 사용할 수 있는 PaintWeb을 선호한다.

PaintWeb의 소스는 Github에서 다운 받을 수 있고, 웹 사이트에서 데모를 실행해 볼 수 있다.

하지만, PaintWeb을 Java, PHP, 닷넷(.Net) 등의 프로젝트에 넣어서 사용하는 방법에 대한 설명이 없다.

여기에서는 PaintWeb을 Java(Spring) 프로젝트에 넣어서 사용하는 데 필요한 사항을 2 가지로 정리하였다.


먼저, PaintWeb 라이브러리를 호출하여 실행하는 방법을 정리하였다.

PaintWeb을 실행하는 방법은 다운 받은 예제나 데모에서 확인 할 수 있지만, 작성된 코드가 제법 복잡해서 여기에 간단하게 정리하였다.


다음으로 작업한 이미지를 서버로 전송하여 저장하는 방법에 대하여 정리하였다.

작업한 이미지를 서버로 전송하여 저장하는 방법이 제공되지 않아서 Java로 제작하였고, 제작한 코드에 대하여 간단하게 정리하였다.

따라서, 첫 번째 내용만 조금 이해하면 PaintWeb을 자신의 프로젝트에 넣어서 사용할 수 있다.


이렇게 정리한 예제(image Editor)는 Github에서 다운받을 수 있다.


먼저 실행하는 방법을 정리한다.

다운 받은 소스에서 demos 폴더에 있는 데모(demo1.html)를 실행하면 Ajax 사용 등의 문제로 실행이 되지 않지만, 웹 프로젝트에 넣어서 경로 설정을 해주면 쉽게 사용할 수 있다.

다운 받은 파일 중 src 폴더가 PaintWeb의 라이브러리이므로, 사용할 웹 프로젝트의 자바 스크립트 폴더에 src 폴더를 paintweb 폴더라는 이름으로 복사한다.

demos 폴더에 있는 demo1.html을 웹 파일이 있는 폴더에 적절한 이름(demo1.jsp)으로 넣고 실행하면 된다.

demo1.html 파일을 열어서 PaintWeb 라이브러리 경로를 다음과 같이 수정한다.

수정 전   <script type="text/javascript" src="../build/paintweb.js"></script>
수정 후   <script type="text/javascript" src="js/paintweb/paintweb.js"></script>

demo1.html을 수정해서 실행시켜도 되고, 본 예제에서는 demo1.html 의 코드를 정리해서 index.jsp(실제로는 imageEditor.jsp)로 작성하였다.


예제로 작성한 프로젝트(imageEditor)는 Github에서 다운 받을 수 있다.

imageEditor에서는 PaintWeb라이브러리를 js 폴더에 넣어서 사용한다.

이외에 index.jsp와 ImageEditor.java, ImageEditor.jsp 파일이 예제로 작성된 파일이다.


index.jsp 은 편집할 이미지를 선택하는 페이지로 스프링(Spring) 프레임워크가 아닌 JSP 파일로 작성했다.

사용자가 편집할 이미지를 선택하면 해당 이미지를 서버로 전송하고(fileUpload)

이 이미지를 다시 다운 받아서 (fileDownload)

HTML 이미지 태그(img)에서 보여 주게 된다.

index.jsp에서는 이 기능을 구현해 두었고,

fileUpload, fileDownload 컨트롤을 ImageEditor.java에 작성하였다.


톰캣에서 다운 받은 예제를 실행한 후 웹 브라우저로 다음 주소를 입력하여 index.jsp의 실행을 확인할 수 있다.

http://localhost:8080/imageEditor/

화면 하단에 있는 [Load Image]을 클릭하여 편집할 이미지를 선택하면 다음 그림과 같이 수정(Modify) 하거나 삭제(Delete) 할수 있는 메뉴가 나타난다.

index.jsp에서 수정(Modify) 기능을 선택하면 PaintWeb 기반의 이미지 편집기가 실행된다.

이미지 편집기의 컨트롤(url)은 imageEditor로 Java로 작성된 코드는 없지만 imageEditor.jsp 파일을 뷰로 사용하여 이미지 편집을 할 수 있도록 하였다.

imageEditor.jsp 파일에서 PaintWeb를 실행하여 이미지를 편집하고, 이미지를 서버로 전송(saveImage)하여 저장한다.

saveImage 컨트롤도 ImageEditor.java에 작성되어 있다.


이상의 흐름을 순서대로 정리하면 다음과 같다.


demo1.html 파일의 PaintWeb 실행 코드를 보면 실행 시간을 측정하는 코드 등이 있어서 아주 복잡하게 보인다.

이 코드는 다음과 같이 단순화 해서 사용해도 된다.

단순화한 실제 코드는 PaintWeb을 Java에서 사용하기 위한 예제로 작성한 프로젝트 파일 중 imageEditor.jsp의 코드를 참조하면 된다.

    pw = new PaintWeb();
    pw.config.guiPlaceholder = document.getElementById('PaintWebTarget');
    pw.config.imageSaveTo   = imageSaveTo;
    pw.config.imageLoad      = document.getElementById('editableImage');
    pw.config.configFile        = 'config-example.json';
    pw.init();


이상으로 PaintWeb을 사용하는 방법과 예제 프로젝트의 전체 구조를 정리하였다.

추가적으로 PaintWeb의 속성과 사용법 몇 가지를 더 정리하였다.


guiPlaceholder는 이미지 편집기인 PaintWeb를 생성할 Div를 의미한다.

지정된 div의 자식으로 PaintWeb의 편집 도구들이 생성된다.

imageLoad는 편집할 이미지 태그(img)를 의미한다.

PaintWeb은 HTML 이미지 태그(img)의 이미지를 가져와서 편집한다.

configFile은 PaintWeb을 실행할 설정 파일로 제법 다양한 기능을 지정할 수 있다.

Init() 함수는 지정된 설정에 따라 PaintWeb을 실행하는 메소드이고,

파라미터로 지정된 pwInit 는 콜백(callback) 함수로 PaintWeb의 실행 결과를 반환한다.

생략해도 된다.

생략할 경우 편집할 이미지 태그를 숨기는 코드를 (display = 'none') 적당한 곳에 작성해야 한다.


configFile은 config-example.json이 예제로 제공된다.

소스는 JSon으로 작성하는데, 주요한 몇 가지 설정을 정리하면 다음과 같다.

속성

설명

lang

사용할 언어를 지정한다.

viewportWidth

이미지 편집 영역의 너비를 지정한다.

viewportHeight

이미지 편집 영역의 높이를 지정한다.

fillStyle

도형의 배경색을 지정한다.

strokeStyle

도형의 전경색을 지정한다.

tools

편집기에 사용할 도구들을 선택한다.

toolDefault

편집기가 실행되면 기본으로 사용될 도구를 지정한다.

fontFamilies

문자열(text) 입력시 사용할 폰트 리스트를 지정한다.

fontFamily

폰트 리스트 중 기본으로 사용할 폰트를 지정한다.


먼저 언어는 영어(en)가 기본으로 지정되어 있다.

한국어(ko)는 개인적인 필요로 몇 가지만 번역해서 github에 공유하였다.

이 파일을 PaintWeb의 lang 폴더에 넣고 configFile(config-example.json)의 lang 속성을 ko로 수정하면 한국어로 사용할 수 있다.



viewportWidth와 viewportHeight는 이미지를 편집할 영역의 너비와 높이를 지정하고,

fillStyle은 도형을 그릴 때 사용할 배경색, strokeStyle은 전경색의 기본값을 의미한다.

문자열(Text)일 경우 폰트 색을 나타내는 데, fillStyle와 strokeStyle와 구분되어 다소 이상하게 출력된다.

Tools는 PaintWeb에서 제공하는 편집 도구들로, tools 옾션에서는 자신에게 필요한 도구들만 선택해서 사용할 수 있다.

예로 ["bcurve", "cbucket", "cpicker"]를 지정하면 이 3가지 도구만 나타난다.

toolDefault는 편집기가 실행되면 기본으로 사용될 도구를 지정하는 것으로 선(line) 도형이 지정되어 있다.

그림판처럼 selection을 지정해서 사용해도 좋을 것이다.

fontFamilies은 문자열(text) 입력의 세부 옵션으로 이미지 편집기에서 사용할 폰트들을 배열로 지정하면 된다.

기본적으로 영어 폰트들이 지정되어 있고, [‘굴림체’, ‘바탕체’]처럼 한글 폰트를 지정해서 사용할 수 있다.


이상으로 PaintWeb을 사용하는 예제에 대해서 정리하였다.

다음으로 PaintWeb에서 편집한 이미지를 서버에 전송해서 저장하고,

이 이미지를 원래 이미지 태그(img)에 갱신하여 보여주기 위해 작성한 코드에 대해서 정리한다.

PaintWeb을 Java에서 사용하기 위한 예제로 작성한 프로젝트는

웹 페이지에서 이미지를 지정하고,

지정된 이미지를 PaintWeb로 편집하고,

편집된 이미지를 서버에 저장하고

처음에 지정된 이미지를 갱신해서 보여주도록 제작되었다.


웹 페이지(index.jsp)에서 이미지를 지정하고,

지정된 이미지를 PaintWeb으로 편집하고(action-imageEditor),

편집된 이미지를 서버에 저장하고(Ajax-imageSaveTo())

처음에 지정된 이미지를 갱신(# + 시간)해서 보여주도록 제작되었다.


웹 페이지에서 작성한 이미지를 서버로 전송하는 방법은 이미지를 일종의 문자열 값인 base64 코드로 변환하여 전송하는 방식을 이용한다.

PaintWeb의 저장 도구()를 클릭하면 이미지를 base64로 변환해서 작성자의 PC에 저장하는 기능이 PaintWeb에 구현되어(imageSave()) 있다.

base64로 변환된 값을 파일로 저장하는 대신 Ajax를 이용하여 서버로 전송하도록 작성하면 된다.

PaintWeb의 imageSave() 함수 중간에 별도의 함수로 imageSave()를 호출하도록 작성하였다.

PaintWeb의 imageSave()에서 작성해도 되지만 가급적 기존 코드를 손대기 않기 위해서 별도의 함수로 작성했다.

imageSave() 함수의 내용이 복잡해 보이지만 이미지 저장시 전송율을 보여주기 위해 진행 상태를 %로 보여주는 기능으로 코드가 복잡해 보일 뿐 실제 코드는 단순하다.

다음 코드는 전송율을 보여주기 위한 코드를 생략하고 작성한 코드이다.

 

Ajax를 이용하여 전송(send)이 완료되면 (onload) [라인 13]

이미지 태그를 생성하여 [라인 14]

전송한 이미지를 다시 다운로드 받아서(fileDownload) 넣어주고 [라인 19],

이렇게 다운 받은 이미지 주소를 기존 이미지에 지정하여 새롭게 갱신(reload)한다 [라인 16].

다운 받을 때 다른 파일(URL)이라는 의미로 파일명 뒤에 “#img”와 시간을 붙여서 사용한다 [라인 16].

이렇게 복잡하게 작성한 이유는 웹 브라우저가 기존의 이미지를 캐시(cache)로 가지고 있어서 이미지 갱신이 잘 안되기 때문이다.


이미지 갱신(reload)이 제대로 되니 위해서는 이상과 같이 자바 스크립트뿐만 아니라 파일을 다운로드 하는 Java에서도 다음 코드를 추가해 주어야 한다.

public void fileDownload(HttpServletResponse response, String path, String filename) {
    ~~ 생략 ~~
    response.setHeader("Pragma", "no-cache");
    response.setHeader("Cache-Control", "no-cache, must-revalidate");
    response.setDateHeader("Expires", 0);
    ~~ 생략 ~~
}   

이 코드가 없으면 IE에서 제대로 갱신되지 않는다.


Ajax에서 서버로 이미지를 전송하면(saveImage) [라인 5]

서버(java - saveImage)에서는 전송 받은 이미지 정보를 BASE64Decoder를 이용하여 이미지로 변환해서[라인 15] 지정된 경로에 파일로 저장한다.




많은 사람들이 좋아하는 테트리스 게임을

두 가지 방법으로 개발하면서

하나의 제품이나 기능을 다양하게 개발하는 방법을 정리하였다.

덤으로 간단한 게임 개발법과

자바스크립트 Canvas 사용법도 익힐 수 있다.


자바 스크립트를 이용하여 구현하고 정리하였지만

여기서 정리하는 주요 개념에 대한 내용은

개발 언어에 관계없이 동일하게 사용할 수 있다.

시각적인 표현에 조금 차이가 있을 수 있지만

대부분의 개발언어에서 동일하게 구현할 수 있다.


인터넷을 검색해 보면

여러가지 방법으로 개발된 소스를 찾을 수 있다.

하나의 많이 참고되는 방식이 있고,

다소 비효율적이긴 하지만

재미난 방식으로 개발한 소스들도 있다.

비효율적으로 작성된 코드가 쉽게 작성되어 개념을 익히기 좋은 장점이 있고,

모든 코드가 비효율적이 것은 아니기 때문에,

남의 소스를 많이 보고 다양하게 개발해 보는 것이

중요하다고 생각하여 두가지 방식으로 정리하였다.


테트리스를 제작하는 방법에 대한 설명과 예제는

다음 사이트에 잘 정리되어 있다 (영어).



이 내용에서 첫 사이트의 예제와

나머지 사이트의 예제로 나눌수 있고,

이 게임들은 두가지 방식으로 개발되었다.

근본적인 차이는 테트리스 도형의 회전 방식의 차이이다.

테트리스 도형에 대한 용어가 사람에 따라 조금씩 차이가 있는데

Shape, Piece, Block, Tetromino등으로 사용한다.

여기서는 도형, Shape, Block을 같은 의미로 사용한다.


테트리스는 O, I, T, S, X, J, L의 7개 도형을

특정 버튼이나 키보드를 이용하여 오른쪽으로 90도씩 회전시키고

한 라인을 모두 채우면 사라지는 게임이다.

출처: Code in Complete


이 도형을 회전 시키는

첫번째 방법은 각 7 개의 도형에 대한 정보를 배열에 넣고

회전 명령에 따라

그때 그때 90 도씩 계산하여 회전시키는 것이다.


또다른 방법은

7 개 도형의 회전 후 모습을

모두 배열에 담아서 저장하고

상황에 맞추어 보여주는 방식이다.

다음 그림과 같이 90 도로 회전하는 4 개의 도형 정보를 저장한다.

이 방식은 제법 많은 데이터를 저장 하기 때문에

데이터를 16 진수로 변환해서 사용한다.

출처: Code in Complete

편의상,

앞서 7개의 도형을 배열에 저장하고

동적으로 회전을 계산하여 구현하는 것을 배열 개발법 (또는 배열법),

두번째의 모든 도형 모습을 저장하여 사용하는

개발 방법을 Bitmask개발법(또는 bitmask법)으로 호칭한다.

Bitmask로 개발하는 방법은 Code in Complete에 잘 설명되어있다.


이해를 쉽게 하기 위해

다음과 같이 단계별로 정리한다.

1. 배열 방식의 도형 회전

2. Bitmask 방식의 도형 회전

3. 배열기반 회전 방식의 테트리스

4. Bitmask기반 회전 방식의 테트리스

5. requestAnimationFrame




테트리스 게임을 두 가지 방법으로 개발하면서

하나의 제품이나 기능을 다양하게 개발하는 방법을

다음과 같이 단계별로 정리하였다.

1. 개요

2. 배열 방식의 도형 회전

3. Bitmask 방식의 도형 회전

4. 배열기반 회전 방식의 테트리스

5. Bitmask기반 회전 방식의 테트리스

6. requestAnimationFrame


다소 개념이 쉬운 배열을 이용한 방식을 먼저 정리한다.

배열 방식은 이 사이트에서 제공하는 방식으로

해당 소스가 제법 복잡해서

테트리스 도형의 회전과 관련된 부분만

추출해서 다음과 같이 정리하였다.

배열을 사용한 것 외에

테트리스 도형들을 HTML DIV로 보여준다는 특징이있다.

보통 게임(개발 언어에 관계없이)은 Canvas에 그려서 제작하는데

DIV을 동적으로 생성하고 삭제하는 식으로 구현하였다.

추천하지 않는 방식이지만

시각적인 부분을 제외하면 쉽고 일반적인 개발 방법을 사용하였다.

이 부분은 이 개발자만의 특정이 아니라

많은 개발자가 사용하는 일반적인 방법이다.

(짧은 시간 찾다 보니 다른 소스를 못 찾았음).


먼저 테트리스 도형을 출력할 canvas [라인 2]와

회전 방향키[라인 3, 4],

도형의 종류를 순서 데로 보여줄 HTML 버튼을 생성한다 [라인 5].

자바 스크립트에서

shapes라는 3차원 배열 변수로 도형을 저장한다 [라인 13].

3차원은 도형 리스트,

2차원은 도형 하나에 대한 정보

1차원은 도형을 구성하는 셀 정보이다.

1차원에 있는 각 셀의 정보는

해당 위치에 도형을 구성할 정보가 있으면 1,

그냥 빈 공간으로 정보가 없으면 0을 가진다.

각 도형은 이어진 4 개의 셀로 모양이 구성되고

이 배열에서 1의 값 위치를 이어서 보면

주석으로 지정된 문자(O, I, T, S, X, J, L)와 일치하는 것을 알 수 있다 (표참조).

0 은 빈(Null)값을 의미한다.


추가적으로

3차원 배열은 7개의 2차원 배열을

2차원 배열은 4개의 1차원 배열을

1차원 배열은 4개의 원소를 가진다.

3차원 배열은 도형의 개수만큼 가지는 것이고,

1, 2차원은 도형의 모양을 x, y축으로 가지는 것이다.

각각 4개씩 4 * 4로 가지고 있다.

그리고, 각 도형은 4개의 셀(도형-사각형)로 구성되기 때문에

각 도형은 4개의 1을 가진다.


도형들(shapes) 중에서 화면에 출력할 도형을 curShape에 보관하고,

현재 도형의 종류를 curShapeType 변수에 저장한다 [라인 44].

도형의 종류는 배열 변수 shapes의 차원 위치이다.

shapes는 3차원 배열로 7개의 2차원을 가지고 있다.

이 7개의 순서(배열이라 0~6)이 도형의 종류(curShapeType )가 된다.


가장 기본적인 함수는 그리기(draw)함수 이다.

저장된 데이터를 상황에 맞추어 그리는 것이 주요 내용이기 때문이다.

draw()함수에서

canvas의 색을 흰색(white)으로 지정하고 [라인 49],

canvas의 크기(100*100)만큼 사각형(fillrect)을 그린다 [라인 50].

canvas 크기의 사각형을 그린다는 것은

canvas의 모든 내용을 지운다는 의미이다.

회전 버튼을 누를 때 마다,

매번 전체를 지우고 회전된 도형의 모양을 새로 그린다.

다음으로 색상을 검은색(black)으로 지정하고,

테트리스 도형(shape)을 그린다.

배열의 값에서 1 이 있으면 (if (curShape[y][x]) – 라인 54)

작은 도형(20*20)을 그린다 [라인 55].

자바 스크립트의 특성으로

변수의 값이 0 이거나 null, undefined 이면

IF문에서 false가 반환되어

if (curShape[y][x]===0) 과 같이 작성하지 않아도 된다.

따라서, curShape[y][x]의 값이 1 일때만

테트리스 도형을 그리기 위한 하나의 사각형을 그린다.


현재 출력하려는 도형의 정보를 가진 변수 curShape은

앞서 curShape=shapes[0]으로 [라인 44]

3차원 shapes에서 2차원을 배정 받았다.

즉, 하나의 도형 4 * 4 크기의

2 차원 배열에 대한 값을 가지고 있기 때문에

두 개의 for문을 이용하여 값을 확인한다 [라인 52, 53].


실행 화면에서 Next Shape 버튼을 클릭하면,

새로운 도형 종류를 curShapeType 변수에 저장하고

도형들(shapes) 중에서 curShapeType에 맞는 도형을 curShape에 저장한다 [라인 61].

도형 종류는 도형 정보를 가지고 있는 shapes의

색인 값(순서)을 의미하기 때문에

새로운 도형 종류는 curShapeType변수의 값에 1 더한 값으로 지정한다.

다만, 도형이 7 (배열이라 0~6)개이기 때문에

curShapeType의 값이 7 이되면 다시 0 이 되게 한다.

if문을 사용할 수 있겠지만 ( if (curShapeType===7) curShapeType=0)

나머지(%)를 구하는 방식으로 구현하였다 [라인 62].


좌우 버튼을 클릭하면

현재 도형을 회전시켜서 출력한다.

rotateLeft(), rotateRight() 함수에서 회전한 값을 반환 받아

curShape 변수에 저장하고 [라인 68, 73]

그리고, 이것을 화면에 출력한다 (draw).

코드로는 회전 방법에 대해서 이해하기 어려울 수 있다 [라인 77, 86].

먼저, 왼쪽으로 회전하는 코드를

다음 표와 같이 정리 할 수 있다.

[참고] 다음 표의 좌표는 (Y, X)로 지정되었다.

원본
왼쪽으로 회전

0123

0123
00, 00, 10, 20, 3
00, 31, 32, 33, 3
11, 01, 11, 21, 3
10, 21, 22, 23, 2
22, 02, 12, 22, 3
20, 11, 12, 13, 1
33, 03, 13, 23, 3
30, 01, 02, 03, 0

표에서 왼쪽에 있는 것이

각 도형에 대한 4 *4의 크기를 가진 2차원 배열 정보이다.

이 배열을 왼쪽으로 이동하는 것은

각 행을 열로(90도 회전) 변환하는 것을 의미한다 [오른쪽 표].

표에서 노란색으로 표시한 셀처럼,

왼쪽의 표에서 하나의 행이 그대로 90도 회전해서

오른쪽 표가 만들어 진 것을 볼 수 있다.

즉, 우측에 있는 표처럼

0, 0 의 위치에 기존의 0, 3 에 있는 데이터를

0, 1 의 위치에 기존의 0, 2 에 있는 데이터를

.....

배치하는 식으로 코드를 구성하여

새로운 배열을 생성한다 [라인 79, 88].

이상의 코드에서는

이 표의 내용을 그대로 코드로 작성하였다 [라인 79, 88].

오른쪽으로 회전하는 것은 다음 표와 같이 반대로 처리한다.

원본
오른쪽으로 회전

0123

0123
00, 00, 10, 20, 3
03, 02, 01, 00, 0
11, 01, 11, 21, 3
13, 12, 11, 10, 1
22, 02, 12, 22, 3
23, 22, 21, 20, 2
33, 03, 13, 23, 3
33, 32, 31, 30, 3

이상으로 테트리스 도형을 관리하고 회전하는 방법을 정리하였다.

테트리스에서 사용하는 7가지 도형을

    1. 3차원 배열에 저장하고

    2. 배열 값의 위치를 조작하여 회전시킨다.

마지막으로 이 배열의 값을(값이 1 이면) 이용하여

3. Canvas에 사각형을 그려서 테트리스 도형을 그려준다.

코드 양이 많아 보이지만

이 세가지의 간단한 구조로 되어 있으니 잘 기억하고

충분히 이해한 뒤에 다음 내용들을 확인하는 것이 좋다.


Bitmask 방식을 정리하기 전에

배열 방식의 회전에 대한 몇 가지 보강을 정리한다.

먼저, 이상의 코드에서 사용한 것과 같이 [라인 78, 87]

배열의 위치를 직접 지정해서 구현하는 것은 가변성이 떨어진다.

즉, 4 * 4로 고정된 경우에는 문제가 없지만

5 * 5나 3 * 3으로 바꾸어 처리할 경우 쉽게 수정할 수 없다.

이러한 직접적인 코드보다는 for문을 이용하는 것이 더 좋을 수 있다.


앞서서 정리했던 회전 관련 표와 동일한 다음 표에서

빨간색으로 표시된 값을 자세히 보면 공통점을 찾을 수 있다.

원본
왼쪽으로 회전

0123

0123
00, 00, 10, 20, 3
00, 31, 32, 33, 3
11, 01, 11, 21, 3
10, 21, 22, 23, 2
22, 02, 12, 22, 3
20, 11, 12, 13, 1
33, 03, 13, 23, 3
30, 01, 02, 03, 0

위 표에서 

빨간색과 파란색으로 표시된 값의 변화를 보면

회전 후의 Y값이 0 으로 고정일 때 원본의 Y값은 0~3 까지의 값

회전 후의 X값이 0~3 까지의 값일 때 원본의 X값은 3 으로 고정되어 있다.

이것을 X축과 Y축 각각에 대한 for 문을 이용하여

다음과 같이 작성할 수 있다.

(코드를 보기전 위 개념을 직접 구현해 보는 것이 좋다)

function rotateLeft(piece) {
    var ret = [];
    for (var y = 0; y < 4; y++) {
        ret[y] = piece[y].slice();
    }
   
    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            ret[y][x] = piece[x][3 - y];
        }
    }
    return ret;
}

예제 1-2

앞서의 문장을 다음과 같이 공식화 할 수 있다.

회전 후의 Y값이 0으로 고정일 때 원본의 Y값은 0~3까지의 값

회전 [0] = 원본 [0~3]

회전 후의 X값이 0~3까지의 값일 때 원본의 X값은 3으로 고정되어

회전 [0][0~3] = 원본 [0~3][3]

으로 정리할 수 있다.

이 코드는 가독성이 떨어지는데, 0~3을 x로 치환하면

회전 [0][x] = 원본 [x][3]

이 된다.

이 식을 Y값이 0 이상일 경우의 값을

표에서 확인하면 다음과 같다.

회전 [0][x] = 원본 [x][3]
회전 [1][x] = 원본 [x][2]
회전 [2][x] = 원본 [x][1]
회전 [3][x] = 원본 [x][0]

숫자 0부터 3까지의 값을 y라고 하면

회전 [y][x] = 원본 [x][3-y]

가 되고, 이것을 2개의 For문을 이용하여

이상의 코드와 같이 작성하였다.

실행시켜 보면,

기존의 코드와 동일하게 작동하는 것을 알 수 있다.


이상의 코드에서 회색으로 처리한 For문은

2차원 배열을 초기화 하는 자바스크립트 방법이다.

회전할 데이터를 저장할 ret 변수를 1차원으로 선언하고

For문을 이용하여 4개의 요인을 추가한다.

추가할 때 4개의 요인을 가진 새로운 1차원 배열을 지정하면

2차원 배열이 되는데,

여기에서는 Slice 함수를 이용하여

기존 배열에서 복사하는 방법을 사용하였다.

앞서 참고한 사이트에서 사용한 방식으로

2차원 배열을 초기화하여 사용할 때 많이 사용한다.

다음 코드가 좀더 일반적인 방식이다.

    for (var y = 0; y < 4; y++) {
        ret[y] = [];
        for (var x = 0; x < 4; x++) {
            ret[y][x] = 0;
        }
    }


오른쪽으로 회전하는 것도 왼쪽과 동일한 방법으로 구현한다.

다음 표를 자세히 보면,

회전 후의 Y값이 0 으로 고정일 때 원본의 Y값은 3~0 까지의 값

회전 후의 X값이 0~3 까지의 값일 때 원본의 X값은 0 으로 고정되어 있다.

왼쪽과 동일한 방법으로 구현해 보길 바란다.

원본
오른쪽으로 회전

0123

0123
00, 00, 10, 20, 3
03, 02, 01, 00, 0
11, 01, 11, 21, 3
13, 12, 11, 10, 1
22, 02, 12, 22, 3
23, 22, 21, 20, 2
33, 03, 13, 23, 3
33, 32, 31, 30, 3


function rotateRight(piece) {
    var ret = [];
    for (var y = 0; y < 4; y++) {
        ret[y] = piece[y].slice();
    }
   
    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            ret[y][x] = piece[3 - x][y];
        }
    }
    return ret;
}

예제 1-2


기존 소스에 다음과 같이 코드를 추가한 뒤 실행해 본다.

function draw() {
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, 100, 100);
    ctx.fillStyle = 'black';
    for (var x = 0; x <= 100; x+=20) {
        ctx.beginPath();
        ctx.moveTo(x,0);
        ctx.lineTo(x,100);
        ctx.stroke();
    }
    for (var y = 0; y <= 100; y+=20) {
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(100, y);
        ctx.stroke();
    }

    ~~ 생략 ~~

이 코드는 테트리스 도형을 그리는

Canvas에 배경선을 그리는 코드로

다음과 같은 화면을 볼 수 있다.

처음에 있는 I 자는 문제가 없어 보이지만

두 번째에 있는 T 자의 경우

세 번째와 네 번째의 도형이

한쪽으로 치우친 것을 볼 수 있다.

첫 번째와 두 번째의 T 자는 3 *3의 범위에 있지만

세 번째와 네 번째는 4 * 4의 범위에 있다.

I 자는 전체 도형이 네 칸을 사용하기 때문에 문제가 없지만

T 자는 실제 크기는 세 칸인데,

정보를 저장한 배열의 크기는 4칸(4*4)이라

회전할 때 위치가 틀어지는 문제가 발생한다.

문제 해결과 관련된 토론은

인터넷에서 제법 많은 토론이 이루어 지니 찾아보길 바란다.

여기에서는 2가지 방법으로 해결해 본다.


첫 번째 방법은 bitmask에서 언급했던

모든 회전 모양을 저장하는 방법이다.

두 번째 방법은 앞서의 방식에서

각 도형의 크기만큼만 회전에 참여시키는 것이다.

새로운 방법인 bitmask는 뒤에서 따로 정리하고

먼저, 각 도형의 크기만큼만 회전시키는 방법을 정리한다.


O 자는 2 * 2, I 자는 4 * 4의 크기를 가지고

나머지 도형은 모두 3 * 3의 크기를 가진다.

따라서, 이 정보를 저장하도록 하고

회전할 때 이 크기만큼만 회전하게 수정하면 된다.

var shapes = [
   [[1, 1, 0, 0],        // 'O'
    [1, 1, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]],
   [[0, 0, 1, 0],        // 'I'
    [0, 0, 1, 0],
    [0, 0, 1, 0],
    [0, 0, 1, 0]],
   [[1, 1, 1, 0],        // 'T'
    [0, 1, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]],
   [[0, 1, 1, 0],        // 'S'
    [1, 1, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]],
   [[1, 1, 0, 0],        // 'Z'
    [0, 1, 1, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]],
   [[0, 1, 0, 0],        // 'J'
    [0, 1, 0, 0],
    [1, 1, 0, 0],
    [0, 0, 0, 0]],
   [[1, 0, 0, 0],        // 'L'
    [1, 0, 0, 0],
    [1, 1, 0, 0],
    [0, 0, 0, 0]]
];

var shapeSize = [2,4,3,3,3,3,3];

function rotateLeft(piece) {
    ~~ 생략 ~~
   
    var size = shapeSize[curShapeType];
    for (var y = 0; y < size; y++) {
        for (var x = 0; x < size; x++) {
            ret[y][x] = piece[x][(size-1) - y];
        }
    }
    return ret;
}

function rotateRight(piece) {
    ~~ 생략 ~~
   
    var size = shapeSize[curShapeType];
    for (var y = 0; y < size; y++) {
        for (var x = 0; x < size; x++) {
            ret[y][x] = piece[(size-1) - x][y];
        }
    }
    return ret;
}

예제 1-3

먼저, 도형 I를 제외하고

shapes에 저장된 도형의 위치를 모두 수정한다.

도형 정보가 배열 중앙에 있는데,

다음 표 처럼 0, 0 에서 시작하도록 이동 시켜준다.


도형의 크기를 shapeSize에 배열로 저장하고,

현재 도형의 종류(curShapeType)에 따라

적절한 크기를 사용한다.

도형의 크기 만큼만 회전에 참여 시킨다의 의미는

For문의 최종값을 도형의 크기로 지정한다는 것이다.


실행후, 다음 그림과 같이 출력되는 것을 확인할 수 있다.

위의 T 자가 수정전, 아래 T자가 수정 후 그림으로

제대로 회전되는 것을 볼 수 있다.

마지막으로 다음 그림을 보면

3 * 3 의 도형들은 빨간색으로 표시한

중심이 존재하게 된다.

S 자처럼 위를 중심으로 잡아도 되고

Z 자처럼 아래를 중심으로 잡아도 된다.

가급적 둘다 같은 방식으로 중심으로 잡아주는 것이 좋다.

다만, L 자는

왼쪽으로 치우쳐있다.

공백 부분을 중심으로 회전하는 데,

오른쪽으로 한칸 이동시켜 J자와 일치 시켜주는 것이 좋을 수 있다.


코드 양이 많아서 복잡해 보이지만

실제로는 간단하게 작성된

테트리스 도형 회전에 대한 배열법을 정리하였다.

다음으로 조금 어려운 개념을 사용한

Bitmask 방식에 대하여 정리하였다.


문제

정리하지 않았지만,

도형의 회전시 새로운 배열(ret)을 생성하여 사용하였다.

자바 스크립트 배열은

기본적으로 참조(Refence)의 개념을 가지고 있기 때문이다.

대부분의 게임에서 배열이 매우 중요한 개념이니 찾아 보길 바란다.



테트리스 게임을 두 가지 방법으로 개발하면서

하나의 제품이나 기능을 다양하게 개발하는 방법을

다음과 같이 단계별로 정리하였다.

1. 개요

2. 배열 방식의 도형 회전

3. Bitmask 방식의 도형 회전

4. 배열기반 회전 방식의 테트리스

5. Bitmask기반 회전 방식의 테트리스

6. requestAnimationFrame


이번에는 Bitmask를 이용한 테트리스 도형 회전에 대하여 정리한다.

Bitmask를 이용한 테트리스 도형 회전은

7개의 테트리스 도형에 대한 모든 회전(4가지) 정보를 가지고 있는 것으로

데이터 양을 줄이고 관리를 편리하게 하기 위해

도형 정보를 bit로 변환해서 저장한다.


그림과 같이 하나의 행이 하나의 워드(word - 4bit)가 되고,

색이 있는 부분을 1 로 표기하고

색이 없는 부분은 0 으로 지정하면,

2 진수로 처리되어 오른쪽 표와 같이 된다.

즉, L 자 도형은

2 진수로 0100 0100 0110 0000 이 되고

16 진수로 4460 이 된다 (진수 변환은 본문 마지막의 해설 참조).


이렇게 모든 도형과 회전후 도형 모습을

다음과 같이 2 진수로 처리하여 16진수 값으로 저장한다.

출처: Code in Complete

데이터를 저장하는 방법을 이해하면

다음 코드와 같이 간단하게 구현할 수 있다.

위 그림의 계산 값과 다음 코드의 계산값(shapes)에 차이가 있다.

둘다 방식은 같은데 도형의 배열내 위치가 조금씩 다르기 때문이다.

이미지와 기타 코드는 Code in Complete에서 가져왔고

다음 샘플은 stackoverflow에서 가져와서 수정했기 때문에 차이가 있다.


이상의 코드에서 7 개의 도형(행)과 4 개의 각도(열)를

2 차원 배열로 구성해서 shapes 변수에 저장한다.

배열법보다 상당히 줄어든 코드를 볼 수 있다 [라인 13].

배열법에서는 현재 도형의 모양을 별도의 변수에서 저장했지만

Bitmask에서는 도형의 모양 (curShapeType – 행 위치)과

도형의 현재 각도(curRotation)에 대한 정보 [라인 23]만 있으면 된다.

curRotation 변수에 현재 각도에 대한

배열의 위치 (열 위치)를 다음과 같이 저장한다.

0 이면 기본 각도 (0 or 360)

1 이면 90 도

2 이면 180 도

3 이면 270 도를 의미하는 숫자가 된다.

즉 왼쪽으로 회전하는 것은

앞으로 가야 하기 때문에 curRotation 값에 -1을 계산하고 [라인 46]

오른쪽으로 회전하는 것은

뒤로 가야 하기 때문에 curRotation 값에 +1 계산한다 [라인 52].

다만, 왼쪽으로 회전해서 0 보다 작으면

다시 3 부터 시작하게 되고 [라인 47],

오른쪽으로 회전해서 3 보다 크면 0 부터 시작하도록 처리한다 [라인 52].


마지막으로 가장 중요한 도형을 그리는 방법을 정리한다 [라인 33].

이 부분이 가장 어렵고 중요한데,

데이터 저장을 2 진수로 저장하는 일종의 암호화를 하고,

이것을 다시 쉽게 풀어 쓰는 복호화를 하기 때문이다.

비트 연산을 잘하는 사람이 아닌 이상

33 라인을 이해하기 어렵다.

어렵다기 보다는 개발 중에 비트 연산을 보는 경우가 드물어

익숙하지 않기 때문에 어렵게 느껴질 수 있다.

기본 개념은 앞서 2 진수로 계산한 값을

기본 값과 이진 연산을 통해서 값이 있으면 (1 이면)

테트리스 도형을 그리기 위한 사각형을 그린다 [라인 34].

예로, L 자 도형은 16 진수 0x4460 으로 저장되어 있다.

이 값을 다시 2 진수 0100 0100 0110 0000 로 변환해서

하나의 문자(bit)가 1 이면 사각형을 출력하도록 하면 된다.

다만, 16 진수 값을 2 진수의 문자열로 바꾸는 것이 까다롭고 복잡해서

비트 연산(bitmask)을 통해서 간단하게 구현한다.


먼저 두 개의 for문을 사용하면

0 부터 15 (4 * 4 = 16)까지의 값을 구할 수 있다 [라인 31, 32].

즉, 16 번의 비트 이동(>>)을 하게 된다.


16 진수 0x8000은

2 진수로 1000 0000 0000 0000 이다.

이것을 16 번 비트 이동(>>)하면

0x8000 >> 0  : 1000 0000 0000 0000
0x8000 >> 1  : 0100 0000 0000 0000
0x8000 >> 2  : 0010 0000 0000 0000
0x8000 >> 3  : 0001 0000 0000 0000
….
0x8000 >> 15 : 0000 0000 0000 0001 이 된다.

이 각각의 값을

L 자 도형의 2진수인

0100 0100 0110 0000 과 & (and) 비트 연산을 하면

두 개의 값이 1일때만 1 이 반환되기 때문에

이때만 사각형을 그리도록 한다 [라인 33].


예로 처음에는 다음과 같이 0 이 반환된다.

0x8000 >> 0  : 1000 0000 0000 0000
0x4460       : 0100 0100 0110 0000
-------------------------------
& 결과         0000 0000 0000 0000  => 0x0000

두 번째에는 1 이 반환된다.

0x8000 >> 1  : 0100 0000 0000 0000
0x4460       : 0100 0100 0110 0000
-------------------------------
& 결과         0100 0000 0000 0000  => 0x4000

이렇게 16번을 비교하면

테트리스 도형을 그리기 위한 4개의 사각형을 얻을 수 있다.


16 진수로 비트 연산(>>, &)을 하면

자바 스크립트 내부에서

위와 같은 이진 연산을 알아서 처리해준다.


지금까지 테트리스의 도형을

회전시키는 2가지 방법에 대해서 정리하였다.

배열법은 코드 양이 많은 것 같지만 단순한 원리가 적용되었고,

Bitmask 방법은 코드 양이 적지만

익숙하지 않은 bit에 대한 개념과 사용법을 알아야 한다.

둘 다 장단점이 있고,

인터넷으로 검색해 보면 Bitmask로 개발한 예제가 더 많고

더 나은 방법이라는 글을 많이 볼 수 있다.


개인적으로도 Bitmask가 더 좋은 방식이라고 생각하지만

개발을 시작하는 사람들에게는

배열법도 게임에 대한 이해를 돕기에 좋은 방식이라고 생각한다.

더우기 배열법 예제에서 나타난 문제를 해결하면서

실력향상을 도모할 수 있다.

그리고, 실제 게임을 제작하면

Bitmask는 복잡한 방식으로 구현되기 때문에

초보자가 이해하기 어렵다 (Code in Complete참조).

배열법이 코드양이 많아 보여서 복잡하게 느껴질 수 있지만

단순한 구조라 실제 계발에서도 직관적으로 구조를 파악할 수 있다.

(여기서 참고한 예제는 DIV를 사용해서 조금 더 어려울 수 있다.)


이렇게 테트리스의 도형을 회전시키는 방법을 통해

다양한 방식으로 개발할 수 있다는 것을 맛보고,

이것을 좀더 심화해서 간단하지만 즐길 수 있는

실제 게임을 제작하면서 어떤 차이가 있는지 정리한다.


2진수를 16진수로 변환하는 방법

출처: Code in Complete

이 그림에서 보듯이 가장 오른쪽 부터

2 ^ 0 으로 1

2 ^ 1 으로 2

2 ^ 2 으로 4

2 ^ 3 으로 8 의 값이 된다.

해당 자리에 값이 지정되면 (2진수 이므로 1 이면)

1, 2, 4, 8의 숫자가 해당 위치의 값이 되어 사용된다.


예로,

2진수 1000 은 오른쪽에서 4번째에 값이 있기 때문에 2 ^ 3 으로 16 진수 8 이 된다.

2진수 1100 은 2 ^ 3 과 2 ^ 2 에 값이 있으니 8 + 4 로 16진수 C (십진수 12) 가 된다.



테트리스 게임을 두 가지 방법으로 개발하면서

하나의 제품이나 기능을 다양하게 개발하는 방법을

다음과 같이 단계별로 정리하였다.

1. 개요

2. 배열 방식의 도형 회전

3. Bitmask 방식의 도형 회전

4. 배열기반 회전 방식의 테트리스

5. Bitmask기반 회전 방식의 테트리스

6. requestAnimationFrame


앞서 정리했던 두 가지 회전 방법 중

배열을 이용한 회전 방법을 기초로

테트리스를 구현하는 방법(4 번)에 대하여 정리한다.

테트리스 구현을 위해 가장 중요한 개념은 회전으로,

이 개념에 몇 가지 게임 관련 개념을 더 하여

실제 사용 가능한 테트리스를 구현한다.


이해를 쉽게 하기 위해

테트리스를 개발하는 과정을 다음과 같이 세분화하여 구현하면서 정리한다.


1. 하나의 도형 움직이기 (down)

2. 도형들 움직이기와 게임 종료

3. 이동 중에 도형 회전

4. 채워진 행 제거

5. 키보드 조작과 도형별 색깔 지정



개발의 첫 단계로

테트리스 도형이 위에서 아래로 내려오는 기능을 구현한다.

단순한 도형 이동이지만

구현을 위해 알아야 할 게임의 기본 개념이 있다.


게임도 동영상처럼

시간의 흐름에 따라 그림을 그려서 움직이게 보인다.

동영상에서 초당 24 프레임으로 제작되었다는 등의 말을 하듯이

게임에서도 초당 몇 번 화면을 그릴 것인지를(프레임) 결정해야 한다.

그린다(draw)는 말은

앞서 회전에서 도형을 그리는 것처럼

Canvas에 도형(rectangle)을 그리는 것을 의미한다.


테트리스 도형이 위에서 아래로 내려오는 것은

시간의 흐름(초당 몇 번)에 따라

도형을 이동 시키고 새로 그려준다는 말이 된다.

도형을 이동 시킨다는 말은 현재 그림을 그린 좌표(x,y) 값을

시간의 흐름에 따라 증가시킨다는 의미이다.

시간의 흐름을 따른다는 것은

쓰레드(Thread)나 타이머(Timer)로

지정된 시간마다 특정 함수나 작업을 호출한다는 의미이다.


정리하면,

지정된 시간마다 도형의 좌표를 증가시켜서

지정된 위치에 도형을 그리면 이동하는 것처럼 보이게 된다.


단순한 개념이지만

처음 개발하는 사람에게는 쉽지 않은 개념일 수 있다.

그래도, 배열법에서 정리한 마지막 코드

이 개념에 대한 구현을 시도해 보길 바란다.


이 코드가 제법 복잡해 보이지만

배열법에서 정리한 마지막 코드

다음의 코드를 추가하거나 수정한 것이다.

<canvas id="tetrisCanvas" width="200" height="400"></canvas>

<script>
~~ 생략 ~~
var sPos = {x:0, y:0};

var intervalHandler = setInterval(
    function () {
          draw();
    }, 400
);   

function draw() {
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, 200, 400);
    ctx.rect(0, 0, 200, 400);
    ctx.strokeStyle="blue";
    ctx.stroke();
   
    ctx.fillStyle = 'black';

    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            if (curShape[y][x]) {
                ctx.fillRect((sPos.x+x) * 20, (sPos.y+y) * 20, 19, 19);
            }
        }
    }
    if (sPos.y++ === 18){
        clearInterval(intervalHandler);
    }
}
</script>

테트리스 도형을 그릴 Canvas의 크기를

100 * 100 에서 200 * 400 으로 수정하였다.

위에서 아래로 내려올(도형이 움직일) 공간을 주기 위해

높이(height)를 400 으로 지정하였다 [라인 5].


이 크기 변경에 따라 Canvas를 새로 그리기 위해 지우는

도형의 크기도 200 * 400 으로 수정하였다 [라인 50].

추가적으로, 도형이 움직이는 공간을 조금 명확하게 하기 위해

(게임판을 구별하기 위해)

Canvas 외곽선을 파란(blue)색으로 표시하였다 [라인 51~53].


자바 스크립트에는 시간이 되면 지정된 함수를 호출하는 타이머로

setInterval() 함수가 제공된다.

setInterval() 함수는 42 라인에서 작성한 것처럼

400 ms (0.4초) 마다 콜백(callback) 함수를 호출하고

콜백 함수에서는 테트리스 도형을 그리는 draw()함수를 호출한다.

즉, 1 초에 2.5번 도형을 그린다.


도형의 위치(x, y)를 지정하기 위해

X와 Y 좌표를 Json으로 가지는 sPos 변수를 사용했다 [라인 40].

도형이 위에서 아래로 이동할 것이기 때문에 Y 좌표만 있어도 되지만

뒤이어 보강할 것이라 미리 선언해 두었다.


이 sPos.y 변수는 두 군데에서 사용된다.

시간이 흐를 때 마다 (draw()함수가 호출될 때 마다)

sPos.y 변수의 값을 1 씩 증가시킨다 [라인 64].

1 씩 증가하다 18 이 되면 타이머를 중지 시킨다 (clearInterval).

18 이 되면 중지하는 것은 도형이 무한이 떨어지는 것을 막기 위한 것으로

다음 예제에서 삭제할 것이니 그냥 넘어간다.


Canvas의 크기는 200 * 400 이다.

테트리스 도형을 구성하는 사각형의 크기는 20 * 20 의 크기를 가진다.

즉, sPos.y 변수의 값 1 은

Canvas에서 사각형 하나의 크기인 20 을 가지게 되고,

Canvas의 높이가 400 이니

sPos.y 변수의 값은 최대 20 (=400 / 20)을 가지게 된다.

즉, 테트리스 게임판은 높이가 20 칸(너비는 10칸)으로

구성된 바둑판 (10 * 20) 같은 형태가 된다.

그리고, 테트리스 도형은 이 바둑판 위를 한칸씩 아래로 이동(sPos.y++)하게 된다.


다음의 코드를 draw() 함수에 추가하여 실행한다.

    for (var x = 0; x <= 200; x+=20) {
        ctx.beginPath();
        ctx.moveTo(x,0);
        ctx.lineTo(x,400);
        ctx.stroke();
    }
    for (var y = 0; y <= 400; y+=20) {
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(200, y);
        ctx.stroke();
    }

Canvas에 20 * 20 크기의 격자를 그리는 코드로

10 * 20 개의 격자가 생긴 것을 알 수 있다.


이상의 코드는 타이머 사용방법과 [라인 42, 65]

위치 변수(sPos)의 값 1 이

Canvas에서 20의 크기를 가진다는 것이 핵심 개념으로 [라인 60],

앞서의 개념에 더하여 정리하면,

지정된 시간마다(setInterval = 400) 도형의 좌표(sPos.y)를 증가시켜서(+1)

지정된 위치(sPos.y)에 도형을 구성하는 사각형을 그리면(fillRect)

이동하는 것처럼 보이게 된다.


두번째로,

하나의 도형이 떨어져 바닥에 쌓이고

또 다른 도형이 떨어져 그 위에 쌓이는 기능을 구현한다.


이 기능을 구현하기 위해서 필요한 개념은

하나의 도형이 이동할 때 마다,

다음 행에 먼저 떨어진 다른 도형이 있는지 확인하는 기능이다.

한 칸씩 이동할 때 마다 다른 도형이 있는지 확인해서

다른 도형이 없으면 이동하고 (y++),

다른 도형이 있으면 멈추게 된다.

다음 행(정확하게는 칸)에 도형이 있는지 확인하려면

앞서 내려간 도형들에 대한 정보를 알아야 한다.

즉, 게임판 (10 * 20) 전체에 대한 정보를 가지고 있어야 한다.

게임판의 크기는 10 * 20 의 2 차원 배열로 Canvas에 그려진 격자의 갯수이다.


그림과 같이 첫 번째 도형이 내려와 바닥에 닿으면,

이 위치 정보가 어딘가(변수)에 저장되어 있어야 한다.

그리고, 하나의 도형이 이동할 때 마다 게임판을 새로 그리기 때문에

저장한 정보를 이용하여 쌓여진 도형들도 같이 그려줘야 한다.


도형의 정보를 저장하는 shapes 변수의 내용은 생략하였고 [라인 7],

앞서의 코드보다 양이 제법 많아 보이지만

핵심 코드는

게임판의 정보를 저장하는 gamePanel 배열 변수이다 [라인 12].

대부분의 코드가 이 변수를 처리하기 위해 추가 / 변경되었다.

gamePanel 배열 변수는 다음 그림과 같이

왼쪽의 게임판을

우측과 같은 2차원 배열 변수에

도형(실제로는 도형을 구성하는 사각형)이 있으면 1을 [라인 31]

없으면 0 으로 채워서 구성한다.


12 라인부터 18 라인은

gamePanel를 2차원 배열로 선언하고 초기화하는 코드이다.

앞서 2차원 배열을 slice() 함수를 이용하여 초기화 했는데

이와 같이 다소 불편하게 생성 및 초기화하는 것이

자바스크립트의 기본 특징이다.


다음으로 신경 써야 하는 것은 intersects(y, x) 함수로

다음 행(칸)에 값이 있는지 없는지를 확인한다.

파라미터로 다음 행(sPos.y+1)에 대한 정보를 받아서 확인한다.

아직 좌우 이동은 하지 않기 때문에

x좌표는 고정할 수도 있지만 일단 변수로 지정한다.

(개념상 같이 처리되어 자연스럽게 구현된다.)


intersects() 함수에서 for문이 두 번 사용되어

하나의 도형을 구성하는 세부 도형(rectangle)이 있는지 확인하고 [라인 49],

있으면 게임판 변수(gamePanel)에서

먼저 떨어진 다른 도형의 세부 도형의 정보가 있는지 확인한다 [라인 50].

다른 정보가 있으면 이동하지 말라는 의미로 true를 반환한다.

intersects() 함수는 앞서의 예제에서 사용했던,

이동 위치가 18 이면 중지하도록 작성한 코드를 제대로 구현한 코드이다 (sPos.y++ === 18).


이 문장에 3개의 조건이 더 있는 것은 [라인 50]

이동하려는 위치가 바닥을 벗어나거나 (y+i >= 20)

좌우 이동시 왼쪽으로 벗어나거나(x+j < 0)

오른쪽으로 벗어나도(x+j >= 10)

이동하지 말라는 의미로 true를 반환한다.


이전과 다르게 타이머에서 draw() 함수가 아닌

playingTetris() 함수를 호출한다 [라인 51].

playingTetris() 함수는 게임을 컨트롤 하는 함수로

적절한 작업을 처리하고,

Canvas에 도형들을 그리는 draw() 함수를 호출한다 [라인 55].


playingTetris() 함수에서는

다음 위치(sPos.y + 1)에 다른 도형이 있는지

intersects() 함수를 호출하여 확인하여 없으면 [라인 27]

현재 위치를 다음 위치로 바꾼 뒤(sPos.y++) 게임판을 다시 그린다 [라인 41, 43].


반대로 다음 위치에 도형이 있으면 [라인 28],

게임판(gamePanel) 변수에

현재 도형을 채운다는 의미로 현재 도형의 정보(1)를 채운다 [라인 31].

현재 도형(curShape)의 정보를 찾기 위해

2 개의 for문을 이용하여

4 * 4 의 2 차원 배열 정보를 모두 찾아 본 후,

배열(curShape) 값이 1 이면 [라인 30]

도형의 위치 정보(sPos)와 for 문의 인자로

gamePanel 변수에서 채워야 할 위치를 ( [sPos.y+i][sPos.x+j] ) 계산하여

도형이 있다는 의미로 1 을 지정한다 [라인 31].

하나의 도형은 4개의 1 (=사각형)로 구성되기 때문이다.

(이해가 되지 않을 경우 회전 참조)


getNextShape() 함수로 다음 도형을 구하고 [라인 34],

위치 정보(sPos)를 초기화(0, 0) 한다 [라인 35].

그리고 다시 새로운 도형과 좌표에 대하여

intersects() 함수를 호출하여 게임 종료를 확인한다 [라인 36].

다음 위치(sPos)가 0, 0 인데 true가 반환되면

게임판이 모두 찼다는 의미로 게임을 종료하게 된다 [라인 37].


draw() 함수에서는 게임판(gamePanel)의 정보가 채워져 있으면 [라인 55]

해당 위치(x * 20, y * 20)에

사각형을 그려서 도형이 있다는 것을 나타낸다[라인 66].

이것은 현재 도형을 그리는 것과 변수만 다를 뿐 같은 방식이다 [라인 71~78].


회전에서 사용했던

getNextShape() 함수를 이용하여 다음 도형을 지정한다 [라인 80].


마지막으로 위치 정보를 저장하는 변수의 초기값을 지정할 때,

x 값을 0 으로 지정하였다 [라인 11, 35].

이 값 때문에 그림과 같이 도형들이 왼쪽에 붙어서 떨어진다.

x 값을 3 (= (10 - 4) / 2) 으로 수정하여

다음 그림과 같이 현재 도형이 게임판 중앙에 오도록 수정한다.


정리하면, 게임판(canvas)에 대한 정보를 

10 * 20의 2 차원 변수 gamePanel에 저장하고,

현재 도형(curShape)을 이동할 때 마다 (sPos.y++)

gamePanel에서 해당 위치에 먼저 도착한 도형이 있는지 확인한다.

도형이 있으면, 현재 도형의 정보를 gamePanel에 넣어 주고

새로운 도형을 무작위로 지정한 뒤 위치 정보를 초기화 한다.

첫 위치(0, 0)에서 이동할 수 없으면

해당 열에 도형이 꽉 찬 것으로 게임을 종료한다.


세번째로, 내려오는 도형을 버튼을 이용하여 조작하는 기능을 구현한다.

도형을 오른쪽으로 회전시키거나

왼쪽이나 오른쪽으로 이동시키거나, 그냥 떨어지도록 구현한다.

먼저, 도형을 왼쪽 / 오른쪽 (X)으로 이동시키는 것은 간단하다.

좌우 이동은 도형을 아래로(Y)로 이동시키는 코드에서

이미 같이 구현하였기 때문에,

왼쪽은 현재 X축 변수(sPos.x) 값을 -1 하고,

오른쪽은 현재 X축 변수(sPos.x) 값을 +1 한 후,

게임판을 새로 그려주면 된다.


그냥 떨어지는 기능은

타이머를 사용하지 않고,

현재 위치 (sPos.y)에서 바닥(20)에 닿을 때 까지

1 씩 증가시키면서 게임판을 계속 새로 그려준다.


회전은 앞서서 정리한 내용중

오른쪽으로 회전하는 개념을 적용하면 된다.

<canvas id="tetrisCanvas" width="200" height="400"></canvas>
<button onclick="moveShape(-1)">Left</button>
<button onclick="rotateShape()">Rotate</button>
<button onclick="moveShape(1)">Right</button>
<button onclick="dropShape()">Drop</button>

<script>
~~ 생략 ~~
function getNextShape() {
    curShapeType = Math.floor((Math.random() * 7));
    return shapes[curShapeType]
}
~~ 생략 ~~
function moveShape(value) {
    if ( !intersects(sPos.y, sPos.x+value)) {
        sPos.x += value;
        draw();
    }
}

function dropShape() {
    for (var y=sPos.y; y<20; y++) {
        if ( !intersects(sPos.y+1, sPos.x)) {
            sPos.y++;
            draw();
        }
    }
}

function rotateShape() {
    var ret = [];
    for (var y = 0; y < 4; y++) {
        ret[y] = curShape[y].slice();
    }
   
    var size = shapeSize[curShapeType];
    for (var y = 0; y < size; y++) {
        for (var x = 0; x < size; x++) {
            ret[y][x] = curShape[(size-1) - x][y];
        }
    }
    curShape = ret;
    draw();
}

</script>

전체코드

먼저 그림과 같이 도형을 조작하는데 사용할

HTML 버튼(button) 태그를 기능에 맞추어 4 개 생성한다.


왼쪽(Left)과 오른쪽은(Right) 버튼은

-1 또는 +1 을 파라미터로 moveShape() 함수를 호출한다.

moveShape() 함수에서는 파라미터(value)로 받은 값을

도형의 위치에 더해준 뒤 (sPos.x += value),

게임판을 새로 그린다 (draw).


떨어지는(Drop) 버튼은 dropShape() 함수를 호출한다.

dropShape() 함수에서는

현재 위치에서 바닥(20)에 닿을 때까지(y=sPos.y; y<20; y++)

1씩 증가시키면서 게임판을 계속 새로 그린다 (draw).


moveShape()와 dropShape() 함수에서 공통으로

이동 전에 이동이 가능한지 확인하는

intersects() 함수를 사용하였다.

도형을 이동시킬때 마다 이동이 가능한지 확인해야 한다.

예로, 도형이 이미 왼쪽 끝(0)에 있는데,

계속 왼쪽 이동 버튼을 누르면 아무 것도 하지 않도록 한다.


회전(rotate) 버튼은 rotateShape() 함수를 호출하고,

rotateShape() 함수에서는 앞서 작성한 회전(오른쪽) 코드를 그대로 사용한다.

다만, 회전한 결과(ret)를 다시 현재 도형 변수 curShape에 넣어주고

게임판을 새로 그린다 (draw).


구현 기능이 많고 어려운 것 같지만

앞서 정리한 개념과 코드가 거의 비슷하게 작성되어

쉽게 구현할 수 있다.


이상의 코드에 버그는 아니지만 조금 부족한 코드가 있다.

해답을 설명하지 않으니 찾아서 수정해 보길 바라고,

다음 예제에 수정되어 있다. (힌트 dropShape)


네 번째는 다음 표의 19 행처럼

게임판의 한 행이 도형의 사각형으로 다 찼을 경우,

해당 행을 제거하는 기능을 구현한다.


구현하는 방법은 게임판의 정보를 가지고 있는 2차원 배열,

즉, gamePanel의 모든 행을 검사해서

하나의 행에 대한 모든 열의 값이 1 이면 해당 행을 제거한다.

해당 행을 제거하는 방법,

즉 2차원 배열에서 1차원을 제거하는 방법이 핵심이고

구현 방법도 다양하다.

다음 코드는 배열을 이용하여 구현한 예제에서 발췌한 코드이다.

var ROW_CNT = 20;
var COL_CNT = 10;

function removeRow() {
    var newRows = [];
    var k = ROW_CNT;
    for (var y = ROW_CNT-1; y>=0; y--) {
        for (var x = 0; x < COL_CNT; x++) {
            if (!gamePanel[y][x]) {
                newRows[--k] = gamePanel[y].slice();
                break;
            }
        }
    }
    for (var y = 0; y < k; y++) {
        newRows[y] = [];
        for (var x = 0; x < COL_CNT; x++)
            newRows[y][x] = 0;
    }
    return newRows;
}

전체코드

먼저, 행의 개수와 열의 개수를

숫자에서 상수로 바꾸어 작성하였다.

가급적 고정된 값은 상수로 처리하는 것이 일반적이고,

여기에서는 20 이란 숫자가 행의 개수와

도형을 구성하는 사각형의 크기를 의미하여,

두 가지로 사용되기 때문에 상수로 작성해야 한다.

지금까지는 개념을 먼저 알아야 해서 무시하고 작성했다.


현재 도형을 이동시킬 때,

다음 위치에 다른 도형이 있을 경우(intersects===true)

게임판에 현재 도형 정보를 넘긴 뒤에

게임판의 모든 행(Y축)을 검사한다 (removeRow).

즉, 여기서는 생략했지만 playingTetris() 함수에서

removeRow() 함수를 호출해서 사용하니 전체 코드에서 확인하길 바란다.


게임판(gamePanel)의 모든 행을 검사하기 위해,

행의 개수만큼 반복하면서 (for)

각 행의 열의 개수만큼 반복해서 값이 있는지 확인한다 (if).

여기에 조금의 스킬과 개념이 필요하다.


배열을 이용하여 구현한 예제에서 사용한 코드를 참고한 것으로

새로운 게임판 배열 변수(newRows)를 생성하고,

기존의 게임판 배열 변수의 각 행을 하나씩 복제(slice)해서 넣는다.

즉, 각 행의 열을 검사하다

열에서 값이 없는 열이 없으면 (if !) 해당 열을 복제한다.

값이 있을 때는 복제하지 않기 때문에

해당 행이 빠져서 삭제하는 것처럼 구현된다.


이때 새로운 게임판 배열 변수(newRows)의 행 변화를 나타내는 변수 k와

기존의 게임판 배열 변수(gamePanel)의 행 변화를 나타내는 변수 y의

변화를 잘 알아야 한다.

기존의 게임판 배열 변수(gamePanel)의 모든 행을 검사하기 때문에

y는 0 부터 개수(20)만큼 반복한다.

새로운 배열 변수 newRows는 각 행의 열 값 중

빈 값이 있을 때만 변화(감소)시키기 때문에

gamePanel의 크기(y)보다 작다.

즉 두 값의 (y-k) 차이가 삭제된 행의 개수가 된다.


다만, 검사 순서를 앞 (0)이 아닌 뒤 (19)에서 시작한다.

행이 삭제될 때,

뒤의 행이 앞으로 당겨지는 것이 아니고

앞의 행이 뒤로 당겨지는 것이기 때문이다.

다음 그림과 같이 19 행이 다 찬 상태에서

18 행이 19 행을 차지하게 된다.

이런 상태에서는 뒤에서부터 처리하는 것이 편리하지만

개인의 공부를 위해 어려운 길(?)로 가보는 것도 좋다.

앞에서부터 이동하는 것을 구현해 보길 바란다.


따라서 이렇게 복제를 한 뒤에

삭제된 행의 개수만큼 newRows의 앞부분에 추가하여

전체 개수를 맞추어 새로운 게임판 변수를 만든다.


여기서 마지막으로 알아야 할 개념은 if문의 사용이다.

하나의 행에 대하여 모든 열을 검사하는 것이 아니고,

중간에 하나라도 빈 값이 있으면 더 이상 검사하지 않고 중지한다 (break).

10 개의 모든 열을 검사하는 것보다

앞의 몇 개로 검사를 끝나는 경우가 많기 때문에

사소하지만 최적화를 위한 첫 걸음과 같은 코드 작성법이다.


마지막으로 떨어지는 현재 도형을

버튼이 아닌 키보드로 제어하는 것과

각 도형별로 다른 색상을 지정하는 기능을 구현한다.


키보드 제어는 HTML 버튼의 클릭이벤트 대신에

웹 문서의 키보드 이벤트를 이용하여 각 함수를 호출하면 된다.


도형별 색상은 구현 방법이 다양한데

여기서는 간단하게 구현한다.

게임판의 정보를 저장할 때, 도형의 종류를 저장하고

각 도형의 종류에 따라서 다른 색으로 도형을 그리면 된다.

다만, 도형에 대한 정보를 저장한다고 말하지만

도형의 정보가 아닌 도형을 구성하는 사각형의 정보가 저장된다.

    ~~ 생략 ~~

var KEY = { ESC: 27, SPACE: 32, LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40 };
var shapeColor = ["red", "orange", "yellow", "green", "blue", "indigo", "violet"];

    ~~ 생략 ~~
   
function playingTetris() {
    if ( intersects(sPos.y + 1, sPos.x)) {
        for (var i = 0; i < curShapeSize; i++)
            for (var j = 0; j < curShapeSize; j++)
                if (curShape[i][j]) {
                    gamePanel[sPos.y+i][sPos.x+j] = curShapeType+1;
                }
       
        curShape = getNextShape();       
        sPos = {x: (COL_CNT-4) / 2, y:0};
        if ( intersects(sPos.y, sPos.x)) {
            clearInterval(intervalHandler);
            alert("Game Over");
        }
        gamePanel = removeRow();       
    } else {
        sPos.y++;
    }
    draw();
}

function draw() {
    ~~ 생략 ~~
                           
    for (var y = 0; y < gamePanel.length; y++) {
        for (var x = 0; x < gamePanel[y].length; x++) {
            if (gamePanel[y][x]) {
                ctx.fillStyle = shapeColor[gamePanel[y][x]-1];
                ctx.fillRect(x * 20, y * 20, 19, 19);
            }
        }
    }
    ctx.fillStyle = shapeColor[curShapeType];
    for (var y = 0; y < curShapeSize; y++) {
        for (var x = 0; x < curShapeSize; x++) {
            if (curShape[y][x]) {
                ctx.fillRect((sPos.x+x) * 20, (sPos.y+y) * 20, 19, 19);
            }
        }
    }
}

function keydown(ev) {
    var handled = false;
    switch(ev.keyCode) {
        case KEY.LEFT:   moveShape(-1); handled = true; break;
        case KEY.RIGHT:  moveShape( 1); handled = true; break;
        case KEY.UP:     rotateShape(); handled = true; break;
        case KEY.DOWN:   dropShape();      handled = true; break;
        case KEY.ESC:    clearInterval(intervalHandler); handled = true; break;
    }

    if (handled) {
        ev.preventDefault();
    }
}
   
document.addEventListener('keydown', keydown, false);

</script>

전체코드

기능을 구현하기 위해 기존의 코드에 추가된 코드의 양이

위 코드에 표시한 것과 같이 얼마 되지 않는다.


먼저, 키보드 제어부터 정리하면

addEventListener를 이용하여

웹 페이지 문서(document)에서 발생하는 모든 키보드 입력을 가져오도록 한다.

Keydown 이벤트에 keydown 함수(이벤트 핸들러)를 지정하였다.


이때, 눌러진 키보드 키 값(keyCode)으로

어떤 키가 눌러졌는지 확인해서

버튼에서 호출한 것과 같이 적절한 함수를 호출한다.

지정된 키가 눌러진 경우(handled = true),

preventDefault()를 호출해서 이벤트 진행을 중지 시킨다.

이미 게임에서 처리했기 때문에

해당 키를 눌렀을 때 발생할 일을 하지 않도록 하는 것이다.

예로, 웹 페이지가 디자인 된 상태에서 테트리스가 운영될 경우

상하 스크롤이 생길 수 있다.

이 경우 down 버튼을 누르면 도형이 그냥 떨어져야 하는데

페이지가 아래로 스크롤 될 수 있다.

이것을 방지하기 위해 preventDefault()를 호출한다.


제어용으로 사용할 키들은 값으로 사용하지 않고,

Json 변수 KEY를 상수처럼 사용하였다.

상수를 여러 개 선언하여 사용하는 것보다 JSon으로 사용하는 것이

쉽게 사용할 수 있고, 객체지향적인 개발 느낌도 나서 좋다.


다음으로 위 코드에서 빨간색으로 표시한 코드가

도형별로 다른 색상을 출력한다.


도형의 종류는 모든 도형 정보를 가지는 shapes 에서

보관하는 도형의 순서(O, I, T, S, X, J, L)를 의미하는 것으로,

현재 도형의 종류를 curShapeType에 0 부터 6까지의 숫자로 보관한다.

다음 그림과 같이 도형 L 은 6, S는 3 , T는 2 의 값을 가진다.

이것을 이용해서 7 가지의 HTML 색상(red, orange 등) 정보를 저장하는

배열 변수(shapeColor)에 curShapeType을 지정해서

해당 도형의 색상을 찾을 수 있다.

즉, 색상("red", "orange", "yellow", "green", "blue", "indigo", "violet")의 순서와

도형의 순서(O, I, T, S, X, J, L)를 연결하여 색상을 지정한다.

그리고, 도형을 그리기 전에 이 색을 지정하고 (fillStyle)

도형을 그리면 된다 (fillRect).


다만, 떨어지는 도형은 이렇게 그릴 수 있지만

이미 떨어진 도형, 즉 게임판에 저장된 도형은 다른 처리를 해야 한다.

기존에는 게임판에 도형에 대한 정보를 저장할 때

값이 있다는 의미로 1 을 지정했다.

이번에는 현재 도형의 종류(curShapeType)를 지정한다.

다만, curShapeType은 배열이라 0 부터 시작하는데

0 은 게임판에서 도형이 없다는 의미라

1 부터 시작하도록 curShapeType에 +1 을 해주고

그릴 때는 -1 을 해서 처리하였다.

즉, 도형 L 은 7 (6), S는 4 (3) , T는 3 (2) 의 값을 가진다.

그림으로 정리하면,

L 자 도형의 경우 떨어질 때는 6 으로,

게임판에 저장할 때는 7 로 저장하여 구현한다.

테트리스 도형을 이동할 경우와

gamePanel에 저장할 때로 나누어서 정리하였는데

유사한 기능을 2 가지로 구현해서 혼동할 수 있어 다시 정리한다.


도형이 이동할 경우에는 도형의 종류(curShapeType)가 0 부터 시작되고,

ctx.fillStyle = shapeColor[curShapeType];


도형의 정보를 저장할 때는 +1, 그릴 때는 -1로 계산해서 구현한다.

gamePanel[sPos.y+i][sPos.x+j] = curShapeType+1;

ctx.fillStyle = shapeColor[gamePanel[y][x]-1];



지금까지 배열을 이용한 회전에 몇 가지 기능을 구현하면서

간단한 테트리스를 제작하는 방법을 정리하였다.


이 과정에서 중요한 개념은 배열의 사용이다.

화면에 보여주기 위한 게임판(canvas)에 대한 정보를

어떻게 저장하고 관리하는 지가 핵심 기술이랄 수 있다.

즉, 2 차원 배열 사용법이 핵심이다.


구현한지가 오래되어서 정확하지 않지만

제법 많은 게임들(특히 보드게임)을 배열을 이용하여 개발하였던 것으로 기억하니

잘 익혀 두길 바란다.







테트리스 게임을 두 가지 방법으로 개발하면서

하나의 제품이나 기능을 다양하게 개발하는 방법을

다음과 같이 단계별로 정리하였다.

1. 개요

2. 배열 방식의 도형 회전

3. Bitmask 방식의 도형 회전

4. 배열기반 회전 방식의 테트리스

5. Bitmask기반 회전 방식의 테트리스

6. requestAnimationFrame


앞서 정리했던 두 가지 회전 방법 중

Bitmask를 이용한 회전 방법을 기초로

테트리스를 구현하는 방법 (5 번)에 대하여 정리한다.


개발 단계는 앞서 정리한 배열법을 이용한 테트리스와 동일하게

개발하는 과정을 다음과 같이 세분화하여 구현하면서 정리한다.


1. 하나의 도형 움직이기 (down)

2. 도형들 움직이기와 게임 종료

3. 이동 중에 도형 회전

4. 채워진 행 제거

5. 키보드 조작과 도형별 색깔 지정



구현에 대한 개념은 앞서 앞서 정리한 배열법과 동일하다.

구현된 코드도 거의 유사하여,

차이나는 부분에 대해서만 간단하게 정리한다.


개발의 첫 단계로

테트리스 도형이 위에서 아래로 내려오는 기능을 구현한다.

구현 방법은

시간의 흐름(setInterval)에 따라 도형의 좌표(x, y)를 바꾸어 주고,

해당 좌표를 기준으로 도형을 구성하는 4 개의 사각형을 그려준다.


이상의 코드를 배열법으로 구현한 코드와 비교해 보면

회전에서 정리한 것과 같이

7 개의 도형을 저장하는 방식만 차이가 있고

거의 동일한 것을 알 수 있다.

즉, 배열법은 0 과 1 의 값을 가지는 3 차원 배열로 저장하고(shapes)

Bitmask는 16진수로 된 2 차원 배열로 저장하는 차이가 있다 [라인 8].


테트리스 도형 정보를 저장하는 방식외에도

복원한 정보에 맞추어 (0x8000 >> (y * 4 + x))

도형을 구성하는 사각형을 그리는 방식도 차이가 있다 [라인 40].


앞서 작성한 Bitmask 회전 예제 코드에,

시간의 흐름 (setInterval)에 따른 [라인 21]

위치 변경(sPos.y++)을 추가하여 작성하였다 [라인 45].

도형을 그리는 위치도 현재 위치(sPos.y)에

도형을 구성하는 사각형들의 위치를 계산하여 그려준다 [라인 41].


두번째로,

하나의 도형이 떨어져 바닥에 쌓이고

또 다른 도형이 떨어져 그 위에 쌓이는 기능을 구현한다.

하나의 도형이 이동할 때 마다,

다음 행에 다른 도형이 있는지 확인하는 기능을 구현하는 것이 핵심으로

한 칸씩 이동할 때 마다 다른 도형이 있는지 확인해서

다른 도형이 없으면 이동하고 (y++),

다른 도형이 있으면 멈추게 된다.


다음 행(정확하게는 칸)에 도형이 있는지 확인하기 위해,

게임판 (10 * 20) 전체에 대한 정보를 gamePanel에 저장해서 사용한다.


이러한 처리는 배열법이든 Bitmask든 동일하지만

도형 정보를 처리하는 데서 발생하는 차이를 구현해야 한다.

var gamePanel = [];
~~ 생략 ~~
function playingTetris() {
    if ( intersects(sPos.y + 1, sPos.x)) {
        for (var i = 0; i < 4; i++)
            for (var j = 0; j < 4; j++)
                if ((curShape & (0x8000 >> (i * 4 + j))) && gamePanel[sPos.y+i]) {
                    gamePanel[sPos.y+i][sPos.x+j] = 1;
                }
       
        curShape = getNextShape();       
        sPos = {x:0, y:0};
        if ( intersects(sPos.y, sPos.x)) {
            clearInterval(intervalHandler);
            alert("Game Over");
        }
    } else {
        sPos.y++;
    }
    draw();
}   

function intersects(y, x) {
    for (var i = 0; i < 4; i++)
        for (var j = 0; j < 4; j++)
            if (curShape & (0x8000 >> (i * 4 + j)))
                if (y+i >= 20 || x+j < 0 || x+j >= 10 || gamePanel[y+i][x+j])
                    return true;
    return false;
}

function draw() {
    ~~ 생략 ~~
    for (var y = 0; y < gamePanel.length; y++) {
        for (var x = 0; x < gamePanel[y].length; x++) {
            if (gamePanel[y][x]) {
                ctx.fillRect(x * 20, y * 20, 19, 19);
            }
        }
    }
   
    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            if (curShape & (0x8000 >> (y * 4 + x))) {
                ctx.fillRect((sPos.x+x) * 20, (sPos.y+y) * 20, 19, 19);
            }
        }
    }
}

전체 코드

이상의 코드는 배열법에서 작성한 것과 거의 동일하고

빨간색으로 표시한 코드만 차이가 있다 (0x8000 >> (i * 4 + j).

Bitmask법은 데이터를 2 진수(bit)로 계산해서

16 진수로 줄여서 저장하고,

비트 연산(>>, &)을 통해서 복원해서 사용한다.

복원하는 방법은 Bitmask회전에 정리하였다.


playingTetris() 함수에서

도형을 gamePanel에 도형 정보를 저장하는 부분은 참고 예제와 차이가 있다.

여기에서는 gamePanel에 1을 저장했지만

gamePanel[sPos.y+i][sPos.x+j] = 1;

참고 예제에서는 도형 정보를 하나의 클래스(Json)로 보고,

다음과 같이 해당 클래스를 넣어준다.

gamePanel[sPos.y+i][sPos.x+j] = curShape;

하나의 도형은 4 개의 사각형을 가지니

gamePanel에 4 개의 동일한 도형 정보를 중복하여 저장한다.


배열법과 Bitmask법으로 구현할 때의 차이를 강조하고

소스를 단순화하여 쉽게 설명하기 위해

이상과 같이 가급적 같은 코드로 작성했지만

참고 예제처럼 작성하는 것도 좋은 방법이니 소스를 확인해 보길 바란다.

참고 예제에서는 도형 정보들을

다음과 같이 도형의 모습에 대한 정보(blocks)와 색상(color)을

하나의 클래스(Json)으로 저장해서 사용하였다.

var i = { blocks: [0x0F00, 0x2222, 0x00F0, 0x4444], color: 'cyan'   };
var j = { blocks: [0x44C0, 0x8E00, 0x6440, 0x0E20], color: 'blue'   };
var l = { blocks: [0x4460, 0x0E80, 0xC440, 0x2E00], color: 'orange' };
var o = { blocks: [0xCC00, 0xCC00, 0xCC00, 0xCC00], color: 'yellow' };
var s = { blocks: [0x06C0, 0x8C40, 0x6C00, 0x4620], color: 'green'  };
var t = { blocks: [0x0E40, 0x4C40, 0x4E00, 0x4640], color: 'purple' };
var z = { blocks: [0x0C60, 0x4C80, 0xC600, 0x2640], color: 'red'    };

또 다른 참고 예제와의 차이는

도형 정보를 저장하는 shapes(참고 예제는 blocks)에 있다.

본 예제는 행을 Y축, 열을 X축으로 사용하여 shapes[y][x]로 사용하나

참고 예제는 행을 X축, 열을 Y축으로 사용하여 shapes[x][y]로 사용한다.

즉, 참고 예제는 다음 그림의 오른쪽과 같은 방식으로 사용되었다.

위의 그림에서

첫 그림이 테트리스 도형이 떨어지는 화면이다.

이 화면을 본 예제에서는 왼쪽 그림처럼 게임판(gamePanel)을 저장하고

참고한 예제에서는 오른쪽처럼 게임판(blicks)을 저장한다.


일반적으로 X축과 Y축을 표현할 때, X축이 먼저 나오도록 작성한다.

즉, X * Y로 표현하는데, 이 표현법을 그대로 배열로 작성한 것 같다.

본 예제에서는 Y * X로 사용하였다.


세번째로, 내려오는 도형을 버튼을 이용하여 조작하는 기능을 구현한다.

도형을 오른쪽으로 회전시키거나

왼쪽이나 오른쪽으로 이동시키거나, 그냥 떨어지도록 구현한다.

도형을 왼쪽 / 오른쪽 (X)으로 이동시키는 것은

왼쪽은 X축 변수(sPos.x) 값을 -1 하고,

오른쪽은 X축 변수(sPos.x) 값을 +1 한 후,

게임판을 새로 그려서 구현한다.


그냥 떨어지는 기능은

타이머를 사용하지 않고,

현재 위치 (sPos.y)에서 바닥(20)에 닿을 때 까지

1씩 증가시키면서 게임판을 계속 새로 그려서 구현한다.


회전은 앞서서 정리한 내용 중

오른쪽으로 회전하는 개념을 적용하면 된다.

function moveShape(value) {
    if ( !intersects(sPos.y, sPos.x+value)) {
        sPos.x += value;
        draw();
    }
}
function dropShape() {
    for (var y=sPos.y; y<ROW_CNT; y++) {
        if ( !intersects(sPos.y+1, sPos.x)) {
            sPos.y++;
            draw();
        } else {
            break;
        }
    }
}
function rotateShape() {
    curRotation = (curRotation + 1) % 4;
    curShape = shapes[curShapeType][curRotation];
    draw();
}

전체코드

이동 함수는 직접 도형 데이터를 조절하는 것이 아니라서

배열법과 Bitmask 법에서 동일하게 구현된다.

rotateShape()함수에서 구현된 도형의 회전만 차이가 있다.


Bitmask에서는 회전된 도형에 대한 정보를 가지고 있기 때문에

회전 각도를 4 개의 숫자를 가지는

변수(curRotation)의 값을 1 씩(90 도씩) 증가 시켜서

도형의 정보를 가르키도록 구현한다.


네 번째는 다음 표의 19 행처럼

게임판의 한 행이 도형의 사각형으로 다 찼을 경우,

해당 행을 제거하는 기능을 구현한다.


구현하는 방법은 게임판의 정보를 가지고 있는 2차원 배열,

gamePanel의 모든 행을 검사해서

각 행의 모든 열의 값이 1 이면 해당 행을 제거한다.

function removeRow() {
    var newRows = [];
    var k = ROW_CNT;
    for (var y = ROW_CNT-1; y>=0; y--) {
        for (var x = 0; x < COL_CNT; x++) {
            if (!gamePanel[y][x]) {
                newRows[--k] = gamePanel[y].slice();
                break;
            }
        }
    }
    for (var y = 0; y < k; y++) {
        newRows[y] = [];
        for (var x = 0; x < COL_CNT; x++)
            newRows[y][x] = 0;
    }
    return newRows;
}

전체코드

행 제거도 도형 정보를 건드리지 않기 때문에

배열법과 차이가 없다.


다만, 배열의 한 행을 제거하는 방법이

참고 예제는 다음과 같이 단순한 방법으로 구현되었다.

    function removeLines() {
        var x, y, complete, n = 0;
        for(y = ny ; y > 0 ; --y) {
            complete = true;
            for(x = 0 ; x < nx ; ++x) {
                if (!getBlock(x, y))
                    complete = false;
            }
            if (complete) {
                removeLine(y);
                y = y + 1; // recheck same line
                n++;
            }
        }
        if (n > 0) {
        addRows(n);
        addScore(100*Math.pow(2,n-1)); // 1: 100, 2: 200, 3: 400, 4: 800
      }
    }
    function removeLine(n) {
        var x, y;
        for(y = n ; y >= 0 ; --y) {
            for(x = 0 ; x < nx ; ++x)
                setBlock(x, y, (y == 0) ? null : getBlock(x, y-1));
        }
    }

removeLines() 함수에서 한 행이 모두 채워졌으면 (complete==true),

removeLine() 함수를 호출해서 해당 행을 삭제한다.

행을 삭제하는 방법은

해당 행(n) 부터 0 번째 행까지 반복하면서

현재 행(y)에 이전 행(y-1)의 값(getBlock)을 채워 넣는 것(setBlock)이다.

즉, 한 행씩 뒤로 당겨서 삭제하는 방식을 사용하였다.

(본 예제와 행열 구조가 반대라서, 참고 예제에서는 한 열을 뒤로 당기도록 구현)


그리고, 행을 삭제한 개수(n)만큼

게임판 정보 배열(blocks)의 앞에 행을 추가한다 (addRows).


이러한 방식은 앞서 작성한 방식보다 비효율적이다.

즉, 배열을 이용하여 구현한 예제에서 발췌한 방식이 조금 더 낫다.

배열법 방식은 Y축에 대해서 for문을 한번만 사용하지만

Bitmask 참고 예제에서 사용한 방식은 Y축에 대해서 for 문을 여러 번 사용한다.

알고리즘의 속도 표기법으로 설명 가능하니 생각해 보길 바란다.

배열법 방식은 새로운 배열을 추가로 사용하기 때문에 메모리를 더 사용한다.


마지막으로 떨어지는 현재 도형을

버튼이 아닌 키보드로 제어하는 것과

각 도형별로 다른 색상을 지정하는 기능을 구현한다.


키보드 제어는 HTML 버튼의 클릭이벤트 대신에

웹 문서의 키보드 이벤트를 이용하여 각 함수를 호출하면 된다.


도형별 색상은 게임판의 정보를 저장할 때, 도형의 종류를 저장하고

각 도형의 종류에 따라서 다른 색으로 도형을 그리면 된다.

다만, 도형에 대한 정보를 저장한다고 말하지만

도형의 정보가 아닌 도형을 구성하는 사각형의 정보가 저장된다.

 ~~ 생략 ~~
 
var KEY = { ESC: 27, SPACE: 32, LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40 };
var shapeColor = ["red", "orange", "yellow", "green", "blue", "indigo", "violet"];

 ~~ 생략 ~~

function playingTetris() {
    if ( intersects(sPos.y + 1, sPos.x)) {
        for (var i = 0; i < 4; i++)
            for (var j = 0; j < 4; j++)
                if ((curShape & (0x8000 >> (i * 4 + j))) && gamePanel[sPos.y+i]) {
                    gamePanel[sPos.y+i][sPos.x+j] = curShapeType+1;
                }
       
        curShape = getNextShape();       
        sPos = {x: (COL_CNT-4) / 2, y:0};
        if (intersects(sPos.y, sPos.x)) {
            clearInterval(intervalHandler);
            alert("Game Over");
        }
        gamePanel = removeRow();
    } else {
        sPos.y++;
    }
    draw();
}   

function draw() {
    ~~ 생략 ~~
 
    ctx.fillStyle = 'black';
    for (var y = 0; y < gamePanel.length; y++) {
        for (var x = 0; x < gamePanel[y].length; x++) {
            if (gamePanel[y][x]) {
                ctx.fillStyle = shapeColor[gamePanel[y][x]-1];
                ctx.fillRect(x * 20, y * 20, 19, 19);
            }
        }
    }
   
    ctx.fillStyle = shapeColor[curShapeType];
    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            if (curShape & (0x8000 >> (y * 4 + x))) {
                ctx.fillRect((sPos.x+x) * 20, (sPos.y+y) * 20, 19, 19);
            }
        }
    }
}

function keydown(ev) {
    var handled = false;
    switch(ev.keyCode) {
        case KEY.LEFT:   moveShape(-1); handled = true; break;
        case KEY.RIGHT:  moveShape( 1); handled = true; break;
        case KEY.UP:     rotateShape(); handled = true; break;
        case KEY.DOWN:   dropShape();      handled = true; break;
        case KEY.ESC:    clearInterval(intervalHandler); handled = true; break;
    }

    if (handled) {
        ev.preventDefault();
    }
}
   
document.addEventListener('keydown', keydown, false);

</script>

전체코드

키보드 제어와 도형별 색상도

배열법과 Bitmask를 이용한 코드를 모두 동일하게 구현하였다.

앞 서와 마찬가지로 빨간색으로 표시한 코드만 차이가 있다 (0x8000 >> (i * 4 + j).


이상의 코드를 정리하면

배열법과 Bitmask법 둘다 개발하는 코드는 동일한 것을 알 수 있다.

차이는 데이터를 저장한 방법(shapes)과

데이터를 복원하기 위한 코드(0x8000 >> (i * 4 + j)만 차이가 있다.


테트리스 개발 과정을 정리한 이유는

다양하게 개발하는 방법을 이상과 같이 정리하기 위한 것도 있지만

어느 한 소스만 좋을 것이라는 편견을 갖지 않기 위한 의미도 있다.

개념이나 방식들은 Bitmask법의 예제가 월등해 보이나 지나치게 어렵게 작성하였고,

배열법은 다소 이상한 방식(div 사용)으로 구현하였지만

쉽고 편리하게 사용할 수 있도록 작성되었다.

이 둘의 모든 코드를 정리하지 못하였으니,

잘 살펴보면 실력향상에 많은 도움이 될 것이다.




+ Recent posts