잇창명 개발 블로그

게임메이커 8.x / GMS 2.3+ 소소한 꿀팁

이 글은 단축링크 https://eatch.dev/s/gms로도 들어올 수 있습니다.

구 PlayGM에 멍멍이님이 작성하셨던 겜메 팁1을 감명 깊게 읽었던 적이 있습니다. 그동안 게임메이커 스튜디오나, 굳이 겜메가 아니더라도 개발을 하면서 익힌 꿀팁...이라기보다는 꼼수나 그런 것들을 감히 저 글의 포맷을 빌려 공유해보려고 합니다.

KGMC에 원본 글을 쓰면서 저렇게 운을 뗀 게 벌써 2년 전이네요. 그동안 겜스1이 완전히 고장나고 대신 겜스2 라이선스를 받는 등 많은 변화가 있었고, 글도 많은 수정을 거쳤습니다. 지금도 쓸만한 소재를 찾으면 종종 업데이트하고 있으니 자주 찾아와 주세요. 댓글창으로 제보도 받아요!

이 글에는 회원만 열람할 수 있는 웹사이트의 글 일부가 이미지로 올라와 있습니다. 해당 내용의 삭제를 원하신다면 dlaud5379 [at] naver.com이나 댓글창으로 연락해 주세요.


음수의 나머지는?

mod%(GM:S 1~) 연산자로 나머지를 구할 수 있습니다. 그런데 나뉘는 수가 음수면 결과도 음수가 됩니다. 나누는 수의 부호는 무관합니다. 겜메/겜스뿐만 아니라 웬만한 언어의 나머지 연산이 이렇습니다.

나뉘는 수가 양수든 음수든 상관없이 양수인 결과가 나오게 하고 싶다면 ((a%b)+b)%b를 하면 됩니다(출처). 이렇게 나머지가 항상 음이 아닌 나눗셈을 유클리드 나눗셈이라고 합니다.

angle_difference()

GM:S 1부터는 angle_difference(ang1, ang2)라는 함수가 추가되어 두 각 사이의 차를 쉽게 계산할 수 있습니다. 이 함수가 무슨 함수냐면, ang2 - ang1-180도와 +180도 사이로 알아서 변환해줍니다.

사실 도움말에도 당당히 올라와 있는 함수라서 꼼수...를 올리려고 쓰는 이 글과는 맞지 않는 점이 있긴 하지만 홍보가 안 됐는지 아시는 분이 거의 없어서 씁니다.

정확한 시간 측정

게임이 켜진 이후 흐른 시간을 측정하는 방법이 2가지 있습니다. 하나는 current_time, 다른 하나는 get_timer()(GM:S 1~)인데, 전자는 밀리초 단위(1000이 1초), 후자는 마이크로초 단위(1000000이 1초)입니다. 또 GM:S 1부터는 delta_time도 있는데, 말 그대로 \(\Delta t\)이고 이전 프레임과 현재 프레임 사이에 흐른 시간을 마이크로초 단위로 측정해줍니다. 렉이 심하거나 (리듬게임처럼) 시간 측정이 중요한 게임이라면 이 기능을 쓰는 것도 생각해볼 수 있겠습니다.

위의 모든 변수/함수는 게임이 시작될 때 0부터 시작한다는 보장이 없습니다. 필요하다면 첫 프레임에 시간을 재서 기준값으로 삼는 것이 좋습니다.

룸 스피드 저리가

사실 정확한 시간 측정도 여기 올릴 만한 내용은 아닌데 올린 이유가 있습니다. 바로 게임 내 프레임 체계를 아예 get_timer()로 대체해버릴 수 있다는 것입니다. 즉, 스텝은 게임 상태를 갱신할 목적으로만 돌리고 실제 코드에는 get_timer()만 써서 렉이 있든 없든 일정한 속도로 게임을 돌릴 수 있습니다.

겜메/겜스의 경우 그 특유의 체계 때문에 완전히 갈아치우는 것은 무리일 수도 있겠지만, 개인적으로 이미 이렇게 돌아가는 게임을 몇 개 만들어본 입장으로서꼰대 고수분들이라면 도전해보는 것도 나쁘지 않을 것 같습니다무책임. 덤으로 이렇게 만들면 룸 스피드를 무턱대고 9999까지 올려도 문제가 없다는 장점이 있습니다. 그럴 이유는 딱히 없지만요. 있구나

Sleep의 부활

GM8에 있던 Sleep 액션이 겜스에 들어와서는 사라졌는데, 다행히 이 액션에 해당하는 코드가 있습니다.

var t = get_timer();
while(get_timer() - t < /*잠들어있을 시간 */) {
    // ...
}

한 프레임 내에 도저히 실행할 수 없는 무거운 작업이 있는데 그동안 화면 업데이트는 꾸준히 해야겠다면, 잠들어있을 시간을 1000000 / room_speed로 설정하고 대괄호 안에 해당하는 작업을 여러 스텝에 걸쳐 해결할 수 있도록 변형해 넣는 트릭도 있습니다.

while;;;;;;;;;;

Sleep의 부활에 대한 여담이지만, 다른 언어에서는 ; 하나만 써놔도 한 문장으로 인식해서 대괄호 대신 세미콜론을 쓴 while(...);이 잘 실행되는데(무한루프는 제쳐두고), GM8에서는 Statement expected("문장이 필요합니다")라면서 런타임 오류를, GM:S 1과 GMS2에서는 malformed while statement("잘못된 while문")라면서 컴파일 오류를 뱉어버립니다. while이 함수인 줄 알고 끝에 세미콜론을 붙여버리는 사람들을 위한 조치인 듯합니다.

실제로 아무 것도 안 하는 while문을 만들고 싶다면 while(...) {}을 하면 됩니다.

반올림은 은행원에게

겜메/겜스는 반올림을 할 때 가까운 짝수로 반올림(Round half to even)이라는 특수한 방법을 사용합니다. 은행원의 반올림(Banker's rounding)이라고도 하는데, 이 방법을 써서 \(x\)를 반올림하면...

예를 들어 round(0.5) = 0(1이 아님에 주의)이고 round(1.5) = 2입니다. 사실 겜스만 이런 건 아니고, Python 등 반올림 함수가 이런 언어가 또 있습니다.

가끔씩 올라오는 "스프라이트가 깨져요" 문제도 이것이 원인일 수도 있습니다. 직접 재현에 실패해서 이것이 원인이다라고 확답을 할 수는 없으니 혹시 걱정된다면 그리기 함수를 쓸 때 좌표에 꼭 round를 씌워주세요.

헷갈리게 쓰인 도움말

개인적으로 겜메/겜스의 도움말은 엣지 케이스에 대한 문서화가 빈약하다고 느껴집니다. 개인적으로 참고하려고 적자면...

형형색색

모든 버전의 게임메이커에서 make_colour_rgb() 대신 $BBGGRR 문법으로 색상을 설정할 수 있습니다. $ 자체는 다음에 오는 것을 16진수로 인식하는 문법이며, RR, GG, BB에는 RGB 값을 16진수로 넣어주시면 됩니다.

혹시 위 문단이 이해가 안 되신다면 여기서 원하는 색을 뽑고 나서 아래쪽에 나오는 '#ab94fc' 형태의 값을 2자리씩 끊고 뒤집어서 입력($fc94ab)하면 됩니다. 가끔씩 6자리가 아닌 3자리('#a9f')가 나오는 경우가 있는데, '#aa99ff'처럼 2번씩 쓴 뒤 그대로 진행하시면 됩니다.

게임메이커 2022.2.0.614부터는 거의 색상 전용 문법인 #RRGGBB 문법이 생겼습니다. 이 경우에는 색상 선택기에 나오는 6자리 값을 그대로 복붙하면 됩니다.

참고로 GM:S 1.4.9999에는 색상값과 관련된 심각한 버그가 있었습니다.

문자열이 못 말려

GM:S 1부터는 개별 문자 처리가 비효율적입니다. 예를 들어, string_char_at(n)의 처리 시간은 n에 비례해서 길어집니다(\(O(n)\)).

여기에는 사실 그럴 만한 이유가 있습니다. 이때부터는 문자열을 UTF-8로 저장하는데, 유니코드의 모든 문자를 표현할 수 있고 영문자를 더 작은 공간에 저장할 수 있는 대신 크기가 1바이트에서 4바이트까지 들쭉날쭉합니다. 즉, \(n\)번째 글자가 어디 있는지 확인하려면 문자열을 처음부터 훑어보는 것밖에는 달리 방법이 없다는 의미입니다.

이 현상은 100글자 정도 되는 짧은 문자열에서는 그냥 무시해도 상관이 없고, 그 이상의 긴 문자열을 효율적으로 처리하려면 적당한 길이(1000글자 정도)로 잘라놓거나 버퍼를 이용할 수 있습니다.

여담으로, 안전성을 중시하는 프로그래밍 언어인 Rust에서도 똑같이 UTF-8을 쓰고 "n번째 문자" 문제가 발생하는데, 여기서는 애초에 n번째 문자를 직접 읽지 못하도록 하고 첫 문자부터 차례대로 읽는 방법을 사용해야 합니다.

알람의 정수

KGMC Budgerigar님의 제보입니다.

알람을 소수로 설정할 수 없습니다. alarm[0] += 1.5;를 하려고 하면 1.5를 버림한 값인 1로 인식됩니다.

보통 알람이 0일 때가 알람이 실행되는 시점인데, 소수 알람이 가능했다면 0.5에서 바로 -0.5로 건너뛰어버려서 곤란한 상황이 생기기 때문으로 추정됩니다. 그래도 왜 굳이 이렇게 했는지는 모르겠네요.

속도를 논하는 두 가지 방법

hspeed/vspeedspeed/direction은 서로에게 즉시 영향을 미칩니다. 즉, 스텝이나 이벤트가 끝날 때 일괄 변환되는 것이 아니라 변수 값이 바뀌자마자 다른 변수가 같이 바뀝니다.

show_debug_message([speed, direction]); // [ 0, 0 ]
hspeed = 3;
vspeed = 4;
show_debug_message([speed, direction]); // [ 5, 306.87 ]

show_debug_message([hspeed, vspeed]); // [ 3, 4 ]
speed = 10;
show_debug_message([hspeed, vspeed]); // [ 6, 8 ]

혹시 저 네 변수를 쓰지 않는다면 직교좌표/극좌표 변환을 저걸로 해보는 것도 괜찮을 것 같네요.

깊이의 수렁

KGMC Paragon님의 제보입니다.

GMS2 도움말에 따르면 깊이 값이 ±16000을 넘어가는 레이어는 화면에 표시되지 않는다고 합니다.

... if you have layers that have a depth ouside of the legal range (-16000 to 16000) then they won't be rendered, ...

IDE 2.3.3.574, 런타임 2.3.3.437 환경에서 테스트해본 결과 이 문제는 발생하지 않지만, 혹시 실제로 이 문제를 맞닥뜨린다면 layer_force_draw_depth(force, depth)로 해결할 수 있습니다. forcetrue를 넣고 depth에 0 정도의 적당한 값을 넣으면 거기에 해당하는 z값에 모든 레이어를 때려박아버립니다. 물론 드로우 순서는 depth 내림차순으로 유지됩니다.

GMS2 도움말에서는 이 함수를 이전 버전에서 불러온 프로젝트에만 사용할 것을 권장하고 있습니다.

Note that this is generally only for use with legacy projects from previous version of GameMaker where you could have draw depths higher or lower than the permitted layer range.

이 함수를 쓰기가 찜찜하다면 게임 로직상으로 depth의 범위를 줄여주면 되는데, 예를 들어 기존에 depth = -y;를 사용했다면 depth = -(y * 100/room_height);으로 0~100 사이로 바꾸는 것도 방법입니다.

레이어끼리너무촘촘하게붙어있는데요

KGMC dot cat님의 제보입니다.

실제로 테스트해보지는 않았지만, 깊이 값을 소수로 지정하면 정확한 드로우 우선순위 지정에 애로사항이 생기는 것 같습니다. 위에서처럼 depth = -(y * 100/room_height);을 쓰면 깊이를 101단계로밖에 지정할 수 없는데, 환경설정에서 룸 에디터 - 레이어의 깊이 간격(영문판의 경우 Room Editor - Default layer depth spacing)을 바꾸면 새 레이어를 만들 때 깊이 간격이 넉넉히 벌어져 더 많은 단계의 깊이를 쓸 수 있습니다.

환경 설정의 '룸 편집기' 화면에서 '레이어의 깊이 간격' 설정이 100으로 맞추어져 있다.

이전 버전에서는 어땠을까?

GM8에서는 화면에 표시되는 깊이 제한은 없지만, ±9,223,372,036,854,770,000 언저리를 넘어가면 게임이 튕깁니다. 부호 있는 64비트 정수형의 최댓값이 \(2^{63}-1 = 9\,223\,372\,036\,854\,775\,807\)인 것과 관련이 있어 보입니다.

GM:S 1 역시 화면에 표시되는 깊이 제한이 없으며, 심지어 저 값을 넘어가도 잘 돌아갑니다. 화면에 depth 값을 띄워놓고 자체적으로 테스트를 해본 결과 부호 무관하게 특정한 값(\(10^{18}\) 초과)을 넘으면 정상적인 수 대신 이렇게 표시되는 것 같습니다.

1.
J

네. 1.(줄바꿈)J입니다. 도대체 왜 저러는지는 모르겠네요. 지수 표기법으로 표기하려다가 뭔가 삐끗한 것 같기도 하고요.

주제와는 무관하지만, GMS2에서는 위의 "1.J" 버그가 해결되었고 크기 제한 이내의 모든 수가 정상적으로 표시됩니다. 단, 지나치게 큰 수의 경우 정밀도 문제로 인해 입력한 것과 다른 값이 표시될 수 있습니다.

목표점에 도달하면 그대로 멈춰라

게임을 만들다 보면 원하는 점으로 움직이다가 목표점에 도착하면 지나치지 않고 정확히 그 점에서 멈춰야 할 일이 생깁니다. 이때는 개인적으로 이 코드를 유용하게 쓰고 있습니다.

x = median(y, x - dx, x + dx); //GM8
x = clamp(y, x - dx, x + dx); //GM:S 1~

xy를 향해 한 스텝에 최대 dx까지 움직일 수 있는 상황에 위 코드를 사용하면 y 주변에서 떨림 현상 없이 정확히 y 위치에서 멈춥니다.

참고로 dx는 정의상 양수이지만, 실제로는 부호 상관없이 써도(clamp를 쓸 때는 부호를 따져야 합니다), 심지어 0을 넣어도 괜찮습니다.

참고로 GM:S 1에서 테스트해본 결과 median보다 clamp가 20% 정도 빨랐습니다. 실제 개발 환경에서는 무시할 수 있을 정도라고 생각합니다.

default:의 반란

switch문을 쓸 때 default를 맨 뒤에 쓰지 않아도 됩니다. 중간에 끼워넣을 일이 거의 없긴 하지만 다른 case 구문처럼 맨 뒤가 아니라 중간에 끼워넣을 수도 있고, break;를 빼먹으면 다음 케이스로 넘어가는 것도 똑같습니다. 예를 들면,

switch(t) {
    default:
        show_message("Default!");
    case 5:
        show_message("5");
    break;
}

이런 게 가능합니다. t = 5일 때는 메시지 상자 5만 표시되고, 그렇지 않을 때는 Default! 5가 차례대로 표시됩니다.

저는 default:가 다른 위치에 올 수 있다는 걸 마인크래프트 자바 에디션의 코드를 뜯어보면서 처음 알았고, C/C++나 JavaScript 등 switch문을 지원하는 몇몇 언어에서도 역시 지원합니다.

사느냐 죽느냐 그것이 문제로다

GM:S 1부터는 Short-circuit evaluation을 지원합니다(GM8은 안 됩니다). 이 용어를 '단축 평가'로 번역하는 글이 있는 것 같으니 여기서도 '단축 평가'라고 하겠습니다. 이게 뭐냐면, 이 코드를 생각해 봅시다.

if(true || false) { /*...*/ }

||/or는 양쪽 중 하나라도 참일 경우 식 전체가 참이 되는 연산자입니다. 이미 왼쪽이 true라 오른쪽의 false는 볼 필요가 없는데, 이런 상황에서 실제로 오른쪽을 실행하지 않는 것이 단축 평가입니다. ||뿐만 아니라 &&/and도 지원하며, 이쪽은 왼쪽이 거짓일 때 오른쪽을 무시합니다.

단축 평가를 잘 쓰면 코드 길이 최적화에 많은 도움이 되는데, 당장 예시가 잘 생각나지 않네요. 일단 생각나는 건 대충 이렇습니다.

if(instance_exists(oPlayer) && oPlayer.hp <= 0)
    gameOver();

이렇게 짜면 oPlayer가 없어도 instance_exists()에서 걸러지기 때문에 오류가 나지 않으며, if문을 2번 쓸 필요도 없습니다.

아니면 극단적인 예시긴 한데, PHP 스타일로는 대충 이런 코드를 짤 수 있습니다.

var t = instance_find(oVeryImportantObject, 0) or die();

말 그대로 객체 oVeryImportantObject의 인스턴스를 못 찾았으면 죽으라는(...) 뜻입니다. die()는 PHP에 있는 그대로 쓴 것이고 겜스에 내장된 함수가 아닌 점은 주의해 주세요.

성공했으면 1 이상을, 실패했으면 0 이하를 리턴하는 아무 함수/스크립트나 쓸 수 있지만 생각해보니 그런 내장함수가 별로 없는 것 같네요... 이렇게 쓸 때는 언어적 한계 때문에 A or B 부분만 따로 쓸 수는 없고, var t = 를 붙이든지 해야 합니다.

다만 코드를 줄인다고 if문 대신에 단축 평가만 남용하는 것은 좋지 않으며, 오히려 나중에 코드를 읽기가 어려워질 수 있습니다.

아쉬운 조건 연산자

if문과 가독성 얘기를 하니 마침 생각난 내용이 있네요.

GMS2에 조건 연산자가 추가되면서 if문 없이도 조건문을 쓸 수 있게 됐는데, 괄호 없이 조건 연산자를 여러 번 쓸 수는 없습니다.

var a = 1 ? 2 : 3; // OK
var b = 1 ? 2 : 3 ? 4 : 5; // unexpected symbol "?" in expression
var c = 1 ? 2 : (3 ? 4 : 5); // OK
var d = 1 ? 2 ? 3 : 4 : 5; // got '?' expected ':'
var e = 1 ? (2 ? 3 : 4) : 5; // OK

참고로 ?: 조건 연산자를 지원하는 거의 모든 언어에서 bc의 동작이 같고 de의 동작이 같습니다. 도대체 왜 이런 결정을 내린 건지 모르겠네요. 겜스넘구데기

조건 연산자 깔끔하게 작성하기

진짜잠깐만 삼천포로 빠질게요!!

조건 연산자를 괄호 없이 여러 번 쓰면 코드를 읽기 어려워지는데(당장 바로 위의 코드만 해도 그렇습니다), 저는 개인적으로 가독성 향상을 위해 if-else문처럼 들여쓰기를 선호합니다. 좌우를 비교하면서 읽어 주세요.

var b =
    1
        ? 2
    : 3
        ? 4

        : 5;
var d =
    1
        ? 2
            ? 3

            : 4
        
        : 5;
// var b =
if(1)
    2;
else if(3)
    4;
else
    5;
// var d =
if(1)
    if(2)
        3;
    else
        4;
else
    5;

로꾸거 로꾸거 로꾸거 선언해선언

KGMC Paragon님의 제보입니다.

GM:S 1이나 GMS2에서 배열을 선언할 때는 마지막부터 선언하거나 array_create로 선언하는 게 빠릅니다. 예를 들어 길이가 1000인 배열 arr을 선언하려면 무작정 arr[999] = 0;부터 때려넣거나, arr = array_create(1000);을 하면 됩니다. 0부터 차례대로 넣지만 않으면 됩니다.

보통 배열에 값을 넣을 때는 아무 생각 없이 순서대로 넣게 되는데, 이렇게 하면 값을 하나 넣을 때마다 배열의 길이를 늘여야 해서 속도가 느려집니다. GMS2 매뉴얼에도 좋은 프로그래밍 습관으로 나와 있는데, 실제로 테스트해본 결과는 다음과 같습니다.

참고로 GMS2와 같은 동적 배열을 지원하는 다른 언어에서는 배열에 값을 순서대로 넣어도 이렇게까지 속도 차이가 나지 않고, 나더라도 길이에 따라 성능이 위와 같이 극단적으로 달라지지는 않습니다. 겜스넘구데기

브라우저 함정

HTML5 라이선스가 없어서 직접 실험은 못 해봤지만, 매뉴얼에 따르면 HTML5에서는 오히려 0부터 차례대로 넣어야 한다고 합니다.

NOTE: On the HTML5 target assigning arrays like this does not apply and your arrays should be initialised from 0 for this target! ...

한 번 더 외쳐보겠습니다. 겜스넘구데기

그럼 GM8에서는?

아쉽게도 선언 순서에 따라 소요 시간이 유의미하게 늘거나 줄지는 않았습니다. 실험 중에 배열 길이가 32000을 넘어갔다고 오류를 띄우는 걸 보면 길이 32000짜리 배열을 미리 선언해놓는 것일지도 모르겠네요(단순 추측).

모든 것은 수

겜메/겜스의 모든 리소스는 수입니다. 말 그대로 스프라이트, 사운드, 백그라운드, 패스, 스크립트, 셰이더, 폰트, 타임라인, 오브젝트, 룸 전부 다요. 즉, 코드에 instance_create(40, 80, oPlayer);와 같은 것을 적을 때 oPlayer는 내부적으로 0이든 3이든 5든 정해진 수로 취급합니다. 다만 스크립트를 실행한다고 냅다 3("foobar"); 같은 걸 쓰면 문법 오류를 뱉고 컴파일을 안 시켜줍니다.2

GM:S 1까지는 번호가 붙는 일정한 규칙이 있었는데, 리소스 트리(왼쪽에 폴더 구조로 있는 그거)상에서 각 종류의 리소스마다 폴더는 무시하고 위쪽부터 차례대로 0, 1, 2, 3, ...이 할당됩니다. 예를 들어 맨 위에 있는 스프라이트는 0, 위에서 3번째에 있는 룸은 2입니다. GMS2에서는 특별히 번호가 붙는 규칙을 찾지 못했습니다.

오묘한 함수형 프로그래밍의 세계

스크립트 이름 대신에 냅다 숫자만 쓸 수는 없지만, 그런 역할을 대신 해주는 내장함수는 있습니다. 원글1에서도 34번에 언급된 내용인데,

34. 의외로 쓸만해보이는, 그러나 어떻게 써야할 지 모를

script_execute(스크립트명, 인자1, 인자2.. ) 함수의 진정한 의의는 스크립트의 '이름'만 적어도 된다는겁니다. 무슨 의미냐하면

script_execute(choose(Scr_up, Scr_down, Scr_left, Scr_right), 3)

이런 게 가능하다는 이야기입니다. 물론 '스크립트' 한정이고, 내장 함수는 불가능합니다.

그런데, 이걸 위에서 언급했던 모든 것은 수와 연결해서 생각해보면 간접적인 방법이긴 하지만 함수의 인자로 함수를 넣을 수 있고, 함수가 함수를 반환할 수도 있습니다. GMS 2.3 이전까지는 함수가 일급 객체(숫자나 문자열처럼 냅다 변수에 대입하거나, 별도의 처리 없이 함수에 인자로 전달하고 반환받을 수 있는 것)가 아니고, 이미 있는 스크립트를 조작할 수도 없었지만 그래도 이 정도면 매우 기초적인 함수형 프로그래밍이 가능합니다.

한편, GMS2가 2.3으로 업데이트되면서 함수를 일급 객체로 사용할 수 있게 되었습니다. 여기에 흑마술을 어느 정도 곁들이면 진짜로 함수형 프로그래밍을 할 수 있습니다.

GML로 수를 인자로 받아 그 수를 더하는 함수를 반환하는 함수를 작성하였다.

겜스에서의 수 처리

GM:S 1부터는 모든 수를 배정밀도 부동소숫점으로 저장합니다. C/C++ 같은 언어를 써보셨다면 이미 double로 익숙한 자료형이실 테고, 모른다고 하더라도 겁먹지 않으셔도 됩니다. 기술적인 이야기를 조금만 더 하자면, 이 자료형은 IEEE 754 표준이라서 겜스 말고도 웬만한 언어에서는 모두 지원하고 있습니다. 나쁜 소식이 있다면, 여느 기술이 다 그렇듯이 사소한 곳에서 자주 터진다는 것입니다. 아래에서 설명드리겠습니다.

GM8은 수 처리를 어떻게 하는지 잘 모르겠네요. 일단 똑같이 배정밀도 부동소숫점으로 처리하는 것 같긴 한데 이전 버전에서는 어땠을까?에서 언급했듯이 900경이 넘어가면 이유 없이 갑자기 터지거든요...

\(0.1 + 0.2 \neq 0.3\)

충격적이게도 0.1 + 0.2는 0.3이 아닙니다. 겜스처럼 배정밀도 부동소숫점을 기본으로 하는 JavaScript로 확인해 보면 이렇습니다.

0.3: 0.3, 0.1 + 0.2: 0.30000000000000004, 0.1 + 0.2 == 0.3: false

이 문제는 정말 유명해서 아예 이 문제를 소재로 https://0.30000000000000004.com이라는 웹사이트까지 개설되어 있습니다.

한편 GM:S 1에서는 0.1 + 0.2 == 0.3이 "잘" 돌아가는데, 이건 엔진 내부에서 math_set_epsilon()으로 땜빵한 결과입니다. 간단히 말해서, 별다른 처리가 없는 상태에서는 오차가 0.000001 이하면 그냥 씹어먹고 true를 띄워줍니다. 부동소숫점 오차를 다 신경써야 되는 레벨이 아니라면 걱정 없이 개발해도 될 것 같습니다.

그래도 못 믿으시겠다면 string_format()으로 소숫점 아래 17자리까지 띄워보시는 걸 권장드립니다. 아래 이미지는 math_set_epsilon(0)(= 오차를 허용하지 않음)을 추가로 적용한 상태로 찍었습니다.

string_format(0.1 + 0.2, 1, 17) = 0.30000000000000004, string_format(0.3, 1, 17) = 0.29999999999999999, 0.1 + 0.2 == 0.3: 0

오차 보정이 왜이래

GMS2에서는 math_set_epsilon() 오차 보정이 조금 이상하게 작동합니다.

var foo = 0.1 + 0.2;
show_debug_message(0.1 + 0.2 == 0.3); // 0
show_debug_message(foo == 0.3); // 1

겜스넘구데기

잃어버린 정밀도

여기서 배정밀도 어쩌구의 작동 원리를 자세히 설명드리지는 않겠습니다만, 이론상 최대 약 \(1.8 \times 10^{308}\)까지의 값을 담을 수 있습니다. 이외에 IEEE 754 표준에서는 무한대와 NaN(Not a Number, "수가 아님")을 추가로 정의하고 있는데, GMS2에서도 각각 infinity, NaN을 쓸 수 있습니다.

그렇다고 해서 저 \(1.8 \times 10^{308}\)까지의 값을 모두 정확히 쓸 수 있는 것은 아닙니다. 부동소숫점은 소숫점이 둥둥 떠다니는("floating") 것이 기본 원리이기 때문에 수가 커질수록 소숫점 아래의 정밀도가 점점 떨어지며, ±9,007,199,254,740,991을 넘어가면 정수도 정확하게 표현할 수 없게 됩니다.

max_safe_integer - 5 = 9007199254740986, max_safe_integer - 4 = 9007199254740987, max_safe_integer - 3 = 9007199254740988, max_safe_integer - 2 = 9007199254740989, max_safe_integer - 1 = 9007199254740990, max_safe_integer + 0 = 9007199254740991, max_safe_integer + 1 = 9007199254740992, max_safe_integer + 2 = 9007199254740992, max_safe_integer + 3 = 9007199254740994, max_safe_integer + 4 = 9007199254740996, max_safe_integer + 5 = 9007199254740996

물론 이 뒤로도 수가 2배가 될 때마다 정밀도도 2배씩 줄어듭니다. 위 이미지는 GM:S 1에서 찍었지만 GMS2에도 똑같이 적용됩니다.

이것보다 더 큰 정수를 정확히 표현해야 한다면 마켓플레이스에서 BigNum.gml 등의(KGMC Paragon님의 제보) 큰 수 전용 라이브러리를 찾아보는 것도 좋습니다. 이런 식으로 큰 수를 표현하는 라이브러리를 다른 언어에서는 보통 BigIntBigInteger라고 하는 것 같습니다.

여담이지만, "부동소숫점"은 원래 의미와 정반대로 소숫점이 고정돼있는 것처럼 들리기 때문에 "둥둥소숫점"으로 바꿔 부르는 사람도 있습니다.

문자열이 끝나지 않아

모든 버전의 게임메이커에서 문자열의 길이에는 메모리가 부족해서 터지는 것 이외의 제한이 딱히 없습니다. 아래 이미지는 각각 GM8, GM:S 1, GMS2에서 str = "a";로 설정해놓은 뒤 매 스텝마다 str += str;를 돌리면서 테스트해본 결과입니다. 왼쪽 위에 표시되는 수가 str의 길이입니다.

GM8: "game.exe이(가) 응답하지 않습니다. 프로그램을 닫으면 정보를 잃을 수 있습니다." (536870912)

GM:S 1: "Fatal Memory Error: Out of Memory!" (1073741824)

GM:S 1: "Memory allocation failed: Attempting to allocate 2147483649 bytes" (1073741824)

이벤트가 있는데 없어졌습니다

GM:S 1까지는 이벤트 안에 아무런 액션이나 코드도 없을 경우에는 그 이벤트가 존재하지 않는 것으로 취급했었습니다. alarm[x] 자동 감산이나 물리엔진 충돌 판정 등 특정 이벤트가 있어야 실행되는 것들이 가끔 있는데, 딱히 넣을 만한 내용이 없다면 Comment 액션이나 주석만 있는 코드라도 추가해야 올바르게 동작합니다.

GMS2에서는 빈 이벤트도 있는 것으로 취급해 정상적으로 작동합니다.

누가 그걸 그렇게 써요

= 대신 :=, {...} 대신 begin...end를 쓸 수 있습니다. 저는 := 형태를 쓸 수 있다는 것부터가 놀라웠는데, 여기서 끝이 아닙니다.

degtorad를 멀리하고 dsin을 가까이

굳이 게임메이커 맥락이 아니더라도 삼각함수(\(\sin\), \(\cos\), ...)는 별 말이 없으면 육십분법 대신 호도법을 씁니다. 육십분법은 \(360^\circ\)가 한 바퀴인, 우리가 일상적으로 사용하는 각도 체계고, 호도법은 \(2\pi \, \mathrm{rad}\)가 한 바퀴인 각도 체계입니다.

게임메이커의 삼각함수(sin, cos, tan, arcsin, arccos, arctan, arctan2)는 전부 호도법을 쓰는데, 이들 7개 함수 전부 앞에 d를 붙이면(dsin, dcos, dtan, darcsin, darccos, darctan, darctan2) 육십분법을 쓰는 함수가 됩니다(GM:S 1~). 즉, sin(pi), sin(degtorad(180)), dsin(180)은 전부 같은 의미입니다.

너랑 나

GMS2 구조체 안에서는 self가 구조체 자신, other가 구조체 바로 바깥의 객체 혹은 구조체를 가리킵니다.

구조체 foo의 속성 foo가 자기 자신인 foo를 가리키고 있다.

GMS2 매뉴얼에서 한때 이런 동작에 대해 틀린 내용을 담았던 적이 있고, 이를 버그로 제보한 적이 있었는데 무려 9개월 뒤에야 답장이 왔습니다. 매뉴얼 내용은 한참 전부터 수정되어 있는 상태입니다.

... The keyword self may also be used within structs to refer to the current instance that is running the entire code block. For example, in an instance referencing nested struct member variables, self will refer to the instance regardless of how deeply nested the struct is.

지역변수가 흘러나와요!

var로 선언하는 지역 변수3는 어디서 선언했는지와 상관없이 이벤트나 함수가 종료될 때까지 남아 있습니다. 게다가 var로 선언했던 변수를 다시 선언해도 그 변수가 초기화되지 않습니다.

var foo = 5;
show_debug_message(foo); // 5

for(var i = 0; i < 3; i++) {
	var foo, bar; // foo가 초기화되지 않습니다.
	show_debug_message(foo); // 5, 5, 5
	bar = i;
}

// bar가 없어지지 않습니다.
show_debug_message(bar); // 2

var bar; // bar가 초기화되지 않습니다.
show_debug_message(bar); // 2

조건문/반복문 안팎에 같은 이름의 지역 변수를 선언하고 초기화를 하지 않으면 끔찍한 일이 발생할 수도 있으니 선언할 때마다 다른 이름을 사용하고, 초기화를 꼭 하는 것이 좋습니다. 초기화할 값이 없다면 undefined라도 써 보세요. 저도 최근에 이것 때문에 크게 데인 적이 있습니다.

  1. KGMC 아카이브(구 PlayGM)의 게시물로, 해당 카페의 회원만 읽을 수 있습니다.  2

  2. GMS2부터는 위의 문법이 컴파일은 되지만, 실행하면 무슨 수를 "호출"했는지와 상관없이 camera_create로 인식되는 것 같습니다. 

  3. 국내에서는 흔히 "임시 변수"로 번역하지만, 이 글에서는 게임메이커 매뉴얼에서 공식적으로 사용하는 "local variable"에 맞추어 "지역 변수"로 번역합니다. 

게임메이커 8.x / GMS 2.3+ 소소한 꿀팁