GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

2048 게임 만들기 9종 — 합치기 규칙부터 2048 게임 ai 까지

2048 게임 만들기는 같은 숫자 타일을 밀어 합치는 4×4 퍼즐을 웹 화면에 직접 세우는 일이다. 2·2·4 가 놓인 줄을 왼쪽으로 밀면 8 이 아니라 4·4 가 남는다.

숫자를 더하는 게임이 아니라 줄을 접는 게임 — 2048 의 한 수를 정하는 것은 한 줄 압축 규칙

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

판 위의 열여섯 칸을 한꺼번에 계산하려 들면 방향마다 다른 반복문이 네 벌 생긴다. 이 편의 아홉 장면은 그 대신 타일을 미는 이동을 줄 하나를 벽 쪽으로 접는 함수 compressLine 하나로 처리하고, 위·오른쪽·아래로 밀 때는 칸 번호 표를 돌려서 그 방향의 벽이 왼쪽에 오게 만든다. 접는 규칙은 빈칸을 걷어 내고 앞에서부터 같은 값 이웃을 한 번씩 합치는 것이 전부인데, 이 짧은 규칙에서 한 조건이 빠지면 판 전체의 결과가 달라진다.

잠금 조건 하나가 가르는 수

잠금은 이번 수에 합쳐 생긴 타일이 같은 수 안에서 또 합쳐지지 못하게 막는 표시다. 빈칸이 약 8분의 3인 시드 판 2,000개에서 움직이는 수 7,978개를 잠금 있는 판과 없는 판으로 두 번씩 밀어 보니 661개(8.3%)의 결과가 달랐고, 그런 수가 하나라도 있는 판은 563개(28%)였다. 열두 수에 하나 꼴이라, 몇 줄 밀어 보는 손 시험만으로는 놓치기 쉬운 것으로 보인다.

같은 판 게임과 다른 점

같은 game 기둥의 삼목 게임 판은 상대가 가장 아픈 수를 둔다고 보고 칸마다 점수를 매기고, 스스로 길을 찾는 뱀 게임은 먹이까지 이어지는 경로를 찾는다. 2048 의 상대는 사람이 아니라 빈칸에 떨어지는 2 와 4 라서, 07·09 는 가장 나쁜 경우 대신 확률로 가중한 평균을 셈한다. 조각이 서로 맞물리는 쪽은 곡선 조각 직소 퍼즐에서 따로 다뤘다.

2048 게임 룰을 코드로 옮기면 한 번 민 판에서 무엇이 먼저 정해질까?

먼저 정해지는 것은 숫자가 아니라 읽는 순서다. slideBoard 는 방향 번호로 순서표 ORDERS[dir] 를 꺼내 네 줄을 왼쪽 벽부터 읽고, 줄마다 compressLine 을 부른 뒤 결과를 원래 칸 번호로 되돌려 적는다. 그다음에야 미끄럼(출발 칸 → 도착 칸)·합침(부모 번호 → 새 번호)·점수(합친 값의 합)가 기록되고, playMove 는 판이 실제로 움직였을 때만 빈칸 하나에 새 타일을 놓는다.

한 수가 지나가는 네 단계

단계 함수 정해지는 것
1 읽는 순서 pushOrder·ORDERS 왼쪽 0,1,2,3 · 위 3,7,11,15 · 오른쪽 15,14,13,12 · 아래 12,8,4,0 (첫 줄)
2 줄 접기 compressLine 빈칸 걷기 → 같은 값 이웃 합침, 잠금이면 합친 타일은 다시 합치지 않음
3 기록 slideBoard 미끄럼·합침·점수, 01 첫 수 왼쪽은 합침 3번에 +16
4 새 타일 spawnTile 시드 난수로 빈칸 하나, 0.1 확률로 4 · 나머지는 2
끝 판정 equalPairs·countEmpty 맞닿은 같은 값 짝과 빈칸이 둘 다 0 이면 끝

안 움직이는 방향으로 민 것은 수로 치지 않는다. 그래서 01 의 시연 순서표(왼쪽·위·오른쪽·아래·왼쪽·아래·오른쪽·위)는 막힌 방향을 만나면 다음 방향으로 넘어가고, 한 바퀴 동안 10수에 점수 64 를 쌓는다.

이 판을 붙일 만한 화면

가장 흔한 자리는 사내 이벤트 페이지나 포트폴리오에 넣는 미니게임이다. 규칙이 단순해 안내 문구가 한 줄이면 되고, 판 하나가 가로세로 비 480:300 으로 고정된 상자 안에 들어가 본문 사이에 끼워 넣기 쉽다. 판 옆에 2048 게임 팁 한 줄을 띄우는 이벤트 페이지라면 07 의 네 방향 막대를 그대로 힌트 상자로 쓸 수 있다.

학습 앱이라면 05 의 단계 색을 진도 타일로, 08 의 무너지는 판을 게임 끝 화면과 다시 하기 연출로 떼어 쓸 수 있다. 09 는 손대지 않아도 스스로 두어 가는 판이라 랜딩 페이지 첫 화면에 걸어 두기 좋고, 방문자가 판을 건드리는 순간 자동 시연을 멈추고 사람 판으로 넘어간다.

2048 게임 만들기 9종 — 한 줄 압축으로 미는 이동과 합친 타일 잠금·이동 칸 수에 맞춰 늘었다 줄어드는 미끄러짐·대각선 쓸기도 축 하나로 가르는 방향 판정·합친 타일이 다시 갈라지는 되감기·2의 거듭제곱 단계로 고르는 색과 글자 크기 맞춤·합칠 때 튀는 조각과 연속 합치기 흔들림·빈칸에 떨어질 수까지 셈하는 자동 플레이·합칠 짝이 다 떨어지면 무너져 내리는 판·첫 화면에서 스스로 두어 가는 2048 판 장면

01~04 는 한 수를 받아 판을 바꾸는 규칙과 입력, 05·06 은 합침을 눈에 보이게 하는 색과 손맛, 07·08 은 판 전체를 읽는 계산, 09 는 그 여덟 가지를 한 판에 모은 첫 화면 장면이다. 아홉 장면은 캔버스 2D 하나에 기기 글꼴로만 그리고, 라이브러리·이미지 파일·웹폰트 없이 시드 고정 난수 makeRng 하나로 열 때마다 같은 시연을 낸다.

2초 동안 24장을 찍어 이웃한 장끼리 견준 값

장면 누적면적 움직인 프레임 강도 평균
01 한 줄 압축과 합친 타일 잠금 30.4% 23/23 47.1
02 칸 수에 맞춘 늘임 미끄러짐 32.1% 23/23 48.8
03 축 하나로 가르는 쓸기 판정 29.3% 21/23 69.7
04 갈라지는 되감기 36.7% 21/23 51.2
05 단계 색과 글자 크기 맞춤 24.8% 22/23 52.4
06 합침 조각과 연속 흔들림 29.2% 23/23 44.4
07 떨어질 수까지 셈하는 자동 플레이 29.2% 23/23 60.7
08 짝이 떨어지면 무너지는 판 34.7% 23/23 46.6
09 스스로 두어 가는 첫 화면 25.4% 23/23 53.6

판이 앞으로 세 수, 뒤로 세 수를 오가는 04 가 36.7% 로 가장 넓게 움직인다. 03·04 가 21/23 에 그친 것은 궤적과 궤적 사이, 되돌리기 사이에 판이 잠깐 멈춰 서는 틈이 2초 창에 걸린 탓으로 보인다.

01한 줄 압축으로 미는 이동과 합친 타일 잠금

사내 이벤트 페이지의 숫자 합치기 미니 게임처럼 규칙이 틀리면 바로 티가 나는 판의 바탕으로, 시작 판 「2 2 4 _ / _ 4 4 8 / 2 _ 2 _ / _ _ _ 2」 에서 0.48초마다 한 수를 두고 오른쪽 아래 작은 판이 그 수의 돌림 횟수만큼 시계 방향으로 돌았다 되돌아온다. 오른쪽 줄 비교는 같은 「2 2 4 _」 를 1.2초마다 밀어 잠금 있는 줄은 「4 4」(+4), 잠금 없는 줄은 부모 셋이 한 타일이 된 「8」(+8)로 끝난다. 합친 타일은 크기 스프링(단단함 420·감쇠 18)을 타고 최고 1.159배까지 부풀었다가 0.279초에 ±2% 안으로 앉는다.

한 줄 압축으로 미는 이동과 합친 타일 잠금
새 창에서 열기

01 · 한 줄 압축으로 미는 이동과 합친 타일 잠금 — 측정 누적면적 30.423% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TURNSFOUR_CHANCEPOP_KICK
    const ORDERS = [0, 1, 2, 3].map(pushOrder);
    function compressLine(line, lock) {
        const out = [];
        for (const t of line) {
            if (!t) {
                continue;
            }
            const last = out.length ? out[out.length - 1] : null;
            if (last && last.v === t.v && !(lock && last.merged)) {
                last.v *= 2;
                last.ids.push(t.id);
                last.merged = true;
            }
            else {
                out.push({ ids: [t.id], v: t.v, merged: false });
            }
        }
        return out;
    }

02이동 칸 수에 맞춰 늘었다 줄어드는 미끄러짐

숫자 퍼즐 앱의 타일 이동 연출에 쓰는 장면으로, 미끄러지는 시간을 0.16초 + 칸마다 0.04초로 잡고 진행 몫을 u² 로 가속해 3칸 타일은 닿는 순간 초속 25칸에 늘임 1.3배가 된다. 옆 축 배율은 늘임의 역수라 한 바퀴 동안 가로 × 세로 배율 곱의 오차가 1.1e-16 에 그치고, 닿을 때는 진행 축이 0.843배까지 눌렸다가 스프링(600·16)으로 0.242초 만에 돌아온다. 오른쪽 작은 판은 같은 입력 9개를 대기열 없이 받아 그중 넷을 버리는 비교판이다.

이동 칸 수에 맞춰 늘었다 줄어드는 미끄러짐
새 창에서 열기

02 · 이동 칸 수에 맞춰 늘었다 줄어드는 미끄러짐 — 측정 누적면적 32.073% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SLIDE_PER_CELL_SSTRETCH_PER_SPEEDINPUT_QUEUE_MAX
    // 늘임 배율 — 진행 축 배율 × 옆 축 배율 = 1
    function stretchOf(speed) { return Math.min(STRETCH_MAX, 1 + STRETCH_PER_SPEED * speed); }
    function axisScales(s, speed) {
        const along = stretchOf(speed) * Math.max(0.55, 1 + s.squash.x);
        const across = 1 / along;
        return s.axis === 0 ? [along, across] : [across, along];
    }
    function feed02(dir) {
        const dropped = dropper02.dropped;
        pushInput(dropper02, dir, sceneT);
        fates02.push(dropper02.dropped > dropped ? 1 : 0);
        const mv = pushInput(queued02, dir, sceneT);
        if (mv) {
            simStatus = moveStatus(queued02, mv);
        }
    }

03대각선 쓸기도 축 하나로 가르는 방향 판정

모바일 웹 게임의 쓸기 조작 영역에 붙일 판정기로, 궤적의 시작점과 끝점만으로 길이·빠르기·각을 재고 경계(45°) 양옆 10° 안과 24px 보다 짧은 궤적은 버리되 초속 450px 를 넘는 튕김은 8px 부터 받는다. 시연 궤적 7개 가운데 52°·228° 는 경계까지 7°·3° 라 대각선으로, 16px·140ms 궤적은 짧음으로 버려지고 18px·30ms 튕김은 왼쪽으로 받혀 한 바퀴에 받음 4·버림 3 이 된다. 궤적이 가운데에서 옆으로 휘어도 판정은 두 끝점을 잇는 곧은 점선 하나로만 한다.

대각선 쓸기도 축 하나로 가르는 방향 판정
새 창에서 열기

03 · 대각선 쓸기도 축 하나로 가르는 방향 판정 — 측정 누적면적 29.258% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWIPE_MIN_PXFLICK_SPEED_PX_SDEAD_ZONE_DEG
    // 화면 각(0° 오른쪽, 90° 아래)을 부채꼴로 — 가장 가까운 90° 배수가 방향
    const ANGLE_TO_DIR = [DIR_RIGHT, DIR_DOWN, DIR_LEFT, DIR_UP];
    function judgeSwipe(dx, dy, ms) {
        const length = Math.sqrt(dx * dx + dy * dy);
        const speed = length / Math.max(1, ms) * 1000;
        const angle = (deg(Math.atan2(dy, dx)) + 360) % 360;
        const base = { dir: -1, angle: angle, length: length, speed: speed, reason: VERDICT_SHORT };
        const flick = length >= FLICK_MIN_PX && speed >= FLICK_SPEED_PX_S;
        if (length < SWIPE_MIN_PX && !flick) {
            return base;
        }
        // 경계까지 남은 각 — 90° 마다 되풀이되는 자리에서 45° 와의 차
        const toBorder = Math.abs((angle % 90) - 45);
        if (toBorder < DEAD_ZONE_DEG) {
            base.reason = VERDICT_DIAGONAL;
            return base;
        }
        base.dir = ANGLE_TO_DIR[Math.round(angle / 90) % 4];
        base.reason = length < SWIPE_MIN_PX ? VERDICT_FLICK : VERDICT_OK;
        return base;
    }

04합친 타일이 다시 갈라지는 되감기

학습용 퍼즐 사이트의 한 수 무르기처럼, 수마다 타일 번호와 합친 부모 번호를 적어 두었다가 1.45·1.85·2.25초에 거꾸로 읽어 합친 타일은 부모 둘로 갈라져 출발 칸으로 가고 새 타일은 0.22초 동안 오그라들어 사라진다. 세 번 무르면 마지막 왼쪽 수에서 갈라짐 4번과 함께 점수가 0 으로 돌아가고 판이 시작 판과 똑같아진다. 미끄러지는 도중에 무르면 지금 판을 먼저 내려놓아 같은 타일이 두 장 보이지 않게 하고, 판에 포커스가 있으면 백스페이스도 되돌리기로 받는다.

합친 타일이 다시 갈라지는 되감기
새 창에서 열기

04 · 합친 타일이 다시 갈라지는 되감기 — 측정 누적면적 36.685% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FORWARD_ATUNDO_ATSHRINK_S
    // 되돌리기 — 기록 맨 위를 꺼내 판을 수 앞 판으로, 점수에서 그 수에 얻은 점수를 뺀다
    function undo04() {
        const mv = board04.history.pop();
        if (!mv) {
            simStatus = STATUS_NOTHING;
            return;
        }
        // 미끄러지는 도중이면 먼저 지금 판 그대로 놓고 되감는다 — 같은 타일이 두 장 보이지 않게
        if (sceneT < board04.busyUntil) {
            settleView(board04.view, board04.game.tiles);
        }
        board04.game.tiles = copyTiles(mv.before);
        board04.game.score -= mv.gain;
        board04.busyUntil = animateUndo(board04.view, mv, sceneT);
        board04.queue = [];
        undone04.push(mv);
        simStatus = STATUS_UNDO.replace('{d}', DIR_NAMES[mv.dir]).replace('{m}', String(mv.merges.length)).replace('{s}', String(board04.game.score));
    }

052의 거듭제곱 단계로 고르는 색과 글자 크기 맞춤

단계별 등급을 보여 주는 학습 진도 타일에 맞는 장면으로, 값이 2를 몇 번 곱한 수인지(log2)를 단계로 삼아 기준색 7개를 1·3·5·7·9·11·13단계에 두고 사이를 oklab 세 값으로 선형 보간하며, 값이 0.15초마다 두 배가 되는 동안 보이는 단계가 한 단계 차의 95% 를 0.214초에 따라잡는다. 글자는 칸 폭의 절반 크기에서 시작해 잰 숫자 폭이 칸의 80% 를 넘으면 그 비율로 줄여 3자리는 92%, 4자리는 69% 크기가 되고 칸 86 에서 4자리 숫자는 29.7px 로 앉는다. 위아래 두 띠는 oklab 섞기와 sRGB 섞기를 나란히 둔 것인데, 이 파스텔 기준색들 사이에서는 RGB 로 최대 1 차이라 색 차이를 보여 주기보다 두 섞는 식을 견주는 자리로 보인다.

2의 거듭제곱 단계로 고르는 색과 글자 크기 맞춤
새 창에서 열기

05 · 2의 거듭제곱 단계로 고르는 색과 글자 크기 맞춤 — 측정 누적면적 24.76% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ANCHOR_STEPSDIGIT_FILLCOLOR_RATE
    // 숫자 크기 맞춤 — 칸 폭 × DIGIT_PX_RATIO 로 시작해, 잰 숫자 폭이 칸 폭 × DIGIT_FILL 을 넘으면 그 비율만큼 줄인다
    const DIGIT_PX_RATIO = 0.5;
    const DIGIT_FILL = 0.8;
    const DIGIT_WEIGHT = 800;
    function fitDigits(text, cell, m) {
        const px0 = cell * DIGIT_PX_RATIO;
        const raw = textWidth(text, px0, DIGIT_WEIGHT, m);
        const room = cell * DIGIT_FILL;
        const scale = raw > room ? room / raw : 1;
        return { px: px0 * scale, width: raw * scale, rawWidth: raw, scale: scale };
    }

06합칠 때 튀는 조각과 연속 합치기 흔들림

앱 출시 기념 페이지의 점수 게임처럼 합침에 손맛이 필요할 때 쓰는 장면으로, 합친 값의 단계 × 3 개 조각을 단계 × 34 × 0.55~1 의 초속으로 뿌리고 중력 520·저항 2.4 로 떨어뜨리며, 첫 수 왼쪽 한 번에 합침 7번이 겹쳐 조각 63개와 흔들림 11.2px 가 한꺼번에 난다. 흔들림은 합침마다 1.6px 를 남은 세기에 더한 뒤 e^(−6t) 로 줄어 0.116초마다 절반이 되고, 그동안 16Hz 로 떨린다. 초속 170px 로 옆으로 나간 조각은 저항 때문에 0.8초 수명 동안 60.1px 만 가는데, 저항이 없으면 136px 를 간다.

합칠 때 튀는 조각과 연속 합치기 흔들림
새 창에서 열기

06 · 합칠 때 튀는 조각과 연속 합치기 흔들림 — 측정 누적면적 29.176% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BURST_PER_STEPBURST_DRAGSHAKE_PER_MERGE
    // 지금 흔들림 세기(px) — 마지막으로 정한 세기 shakeAmp0 이 그 뒤 e^(−SHAKE_DECAY·t) 로 준 값
    function shakeAmp(t) { return t < shakeAt ? 0 : shakeAmp0 * Math.exp(-SHAKE_DECAY * (t - shakeAt)); }
    function shakeOffset(t) {
        const a = shakeAmp(t);
        const ph = TAU * SHAKE_HZ * (t - shakeAt);
        return [a * Math.sin(ph), a * 0.6 * Math.cos(ph * 1.3)];
    }
    // 판 왼쪽 위 기준 칸 가운데 — 조각이 터지는 자리(화면 쪽 F06 과 같은 값)
    const F06_X = 24;
    const F06_Y = 22;
    const F06_CELL = 46;
    const F06_GAP = 6;
    function burst(cell, v, t) {
        const k = stepOf(v);
        const n = BURST_PER_STEP * k;
        const cx = F06_X + F06_GAP + colOf(cell) * (F06_CELL + F06_GAP) + F06_CELL / 2;
        const cy = F06_Y + F06_GAP + rowOf(cell) * (F06_CELL + F06_GAP) + F06_CELL / 2;
        for (let i = 0; i < n; i += 1) {
            const a = burstRng() * TAU;
            const sp = BURST_SPEED_PER_STEP * k * (0.55 + 0.45 * burstRng());
            shards.push({ x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - BURST_LIFT, born: t, rot: burstRng() * TAU, spin: (burstRng() - 0.5) * 14, size: 3 + 3 * burstRng(), c: tileColor(k) });
        }
        return n;
    }

07빈칸에 떨어질 수까지 셈하는 자동 플레이

퍼즐 앱의 다음 수 힌트처럼 사람에게 방향 하나를 권할 때 쓰는 장면으로, 네 방향을 밀어 본 판마다 빈칸 하나하나에 2(확률 0.9)와 4(0.1)를 놓아 본 평가를 평균 내고 그 위에서 한 번 더 내 차례와 떨어질 차례를 셈하는 두 층 탐색이다. 첫 판 「2 _ _ 2 / 4 2 _ _ / 8 16 4 2 / 64 32 8 4」 에서 왼쪽 8.5·위 1.98·오른쪽 9.43·아래 9.6 이 나와 아래를 고르고, 그 한 번에 평가한 끝판은 1,660개다. 막대가 0.3초 동안 자란 뒤 수를 두고, 고른 방향으로 밀어 본 판(새 타일이 떨어지기 전)을 아래에 흐리게 미리 보여 준다.

빈칸에 떨어질 수까지 셈하는 자동 플레이
새 창에서 열기

07 · 빈칸에 떨어질 수까지 셈하는 자동 플레이 — 측정 누적면적 29.16% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SEARCH_DEPTHEMPTY_WEIGHTTHINK_S
    // 떨어질 차례 — 빈칸마다 2(확률 1 − FOUR_CHANCE)·4(FOUR_CHANCE)를 놓아 본 값의 평균. 빈칸 하나하나는 같은 확률로 뽑힌다
    function chanceNode(vals, depth) {
        const empty = [];
        for (let i = 0; i < CELLS; i += 1) {
            if (!vals[i]) {
                empty.push(i);
            }
        }
        if (!empty.length) {
            return depth > 1 ? moveNode(vals, depth - 1) : evaluate(vals);
        }
        let total = 0;
        for (const cell of empty) {
            for (const v of [2, 4]) {
                const p = v === 2 ? 1 - FOUR_CHANCE : FOUR_CHANCE;
                const next = vals.slice();
                next[cell] = v;
                total += p * (depth > 1 ? moveNode(next, depth - 1) : evaluate(next));
            }
        }
        return total / empty.length;
    }

08합칠 짝이 다 떨어지면 무너져 내리는 판

게임 끝 화면과 다시 하기 연출에 쓰는 판으로, 매 수 빈칸과 가로·세로로 맞닿은 같은 값 짝을 세어 남은 짝을 보라 선으로 잇다가 둘 다 0 이 되는 순간 판이 끝난 것으로 본다. 시작 판에서 왼쪽으로 두 번 밀자 짝이 0 이 되어 0.967초에 무너짐이 시작되고, 타일 16개가 0~0.3초씩 늦춰 중력 1600px/s² 로 떨어져 반발 0.42 로 모두 56번 튄 뒤 2.25초에 멈춘다. 그다음 처음 판 타일이 아래 줄부터 0.035초 간격으로 120px 위에서 떨어져 쌓이고, 3.588초에 새 판이 된다.

합칠 짝이 다 떨어지면 무너져 내리는 판
새 창에서 열기

08 · 합칠 짝이 다 떨어지면 무너져 내리는 판 — 측정 누적면적 34.712% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FALL_GRAVITYBOUNCECOLLAPSE_SPREAD_S
    function equalPairs(vals) {
        const out = [];
        for (let r = 0; r < SIZE; r += 1) {
            for (let c = 0; c < SIZE; c += 1) {
                const i = r * SIZE + c;
                if (!vals[i]) {
                    continue;
                }
                if (c + 1 < SIZE && vals[i + 1] === vals[i]) {
                    out.push({ a: i, b: i + 1 });
                }
                if (r + 1 < SIZE && vals[i + SIZE] === vals[i]) {
                    out.push({ a: i, b: i + SIZE });
                }
            }
        }
        return out;
    }
    function countEmpty(vals) { let n = 0; for (const v of vals) {
        if (!v) {
            n += 1;
        }
    } return n; }
    function isStuck(vals) { return countEmpty(vals) === 0 && equalPairs(vals).length === 0; }

09첫 화면에서 스스로 두어 가는 2048 판 장면

숫자 학습 앱 랜딩 페이지 첫 화면처럼 손대지 않아도 판이 살아 있어야 하는 자리에서, 07 의 두 층 탐색이 고른 방향으로 70px·130ms 흉내 쓸기를 그려 03 의 judgeSwipe 로 판정한 뒤 02 의 늘임, 06 의 조각과 흔들림, 08 의 무너짐을 같은 상수로 이어 붙인다. 판이 막히거나 16수를 두면 무너졌다 처음으로 돌아가는데, 시연은 16수 뒤 6.646초에 점수 368·가장 큰 타일 256 으로 한 바퀴를 끝내고 8.946초에 처음 판으로 돌아온다. 한 바퀴 수 ROUND_MOVES 를 따로 둔 까닭은 탐색이 막힘을 잘 피해서, 이 값을 없애면 40초 동안 95수를 두고도 판이 막히지 않기 때문이다.

첫 화면에서 스스로 두어 가는 2048 판 장면
새 창에서 열기

09 · 첫 화면에서 스스로 두어 가는 2048 판 장면 — 측정 누적면적 25.374% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ROUND_MOVESSWIPE_MSMOVE_GAP_09
    function afterMove9(mv) {
        if (!mv || !mv.moved) {
            return;
        }
        moves9 += 1;
        simStatus = STATUS_HERO.replace('{d}', DIR_NAMES[mv.dir]).replace('{m}', String(mv.merges.length)).replace('{s}', String(hero.game.score));
        let first = NO_HIDE;
        for (const s of hero.view.sprites) {
            for (const m of mv.merges) {
                if (s.id === m.id) {
                    first = Math.min(first, s.show);
                    pend9.push({ at: s.show, cell: m.cell, v: m.v });
                }
            }
        }
        if (mv.merges.length) {
            shakeAmp9 = shake9(first) + SHAKE_PER_MERGE * mv.merges.length;
            shakeAt9 = first;
        }
        stuckReal9 = isStuck(valuesOf(hero.game.tiles));
        if (stuckReal9 || moves9 >= ROUND_MOVES) {
            stuckAt9 = hero.busyUntil + STUCK_HOLD_S;
        }
    }

판을 돌려 한 방향만 풀고, 쓸기는 부채꼴로 가르고, 다음 수는 빈칸의 기대값으로 — 한 수가 화면에 닿기까지 거치는 세 계산

시계 방향으로 몇 번 돌리면 그 벽이 왼쪽에 오나

rotateCW 는 칸 번호 표를 시계 방향으로 한 번 돌려 새 (행 r, 열 c) 자리에 원래 (행 3 − c, 열 r) 칸을 놓는다. 방향마다 돌리는 횟수 TURNS 는 왼쪽 0 · 위 3 · 오른쪽 2 · 아래 1 이라, 위로 밀 때 돌린 판의 첫 줄을 왼쪽부터 읽으면 맨 오른쪽 열을 위에서 아래로 읽은 3,7,11,15 가 된다. 판을 실제로 돌리는 것이 아니라 읽는 순서만 네 벌 미리 만들어 두므로, 타일 이동의 잠금 규칙은 compressLine 한 곳에 모인다. 다만 07·09 의 탐색은 값 배열만 밀어 보는 slideValues 에 잠금(locked)을 따로 두었으니, 규칙을 바꾸면 그쪽도 함께 고쳐야 한다. 01 오른쪽 아래의 작은 판이 돌림 각을 스프링(260·24)으로 TURNS × 90° 까지 돌렸다가 0° 로 돌아오는 것이 이 표를 눈으로 보여 주는 장치다.

부채꼴 하나가 받아들이는 몫과 튕김 문턱

03 은 각을 90° 로 나눈 나머지가 45° 에서 10° 안쪽이면 버리므로, 한 부채꼴 90° 가운데 받아들이는 각은 70° 로 (90 − 2 × 10) ÷ 90 = 77.8% 다. 길이 문턱 24px 를 채우지 못한 궤적도 빠르기가 초속 450px 이상이면 받는데, 24px 를 그 빠르기로 그으려면 24 ÷ 450 × 1000 = 53.3ms 안에 끝내야 한다. 길이 4~120px · 30~260ms · 각 무작위인 시드 궤적 1,000개를 넣으면 받음 627(곧음 624 · 튕김 3) · 짧음 180 · 대각선 193 으로 나뉜다. 손가락이 짧고 빠르게 튕기는 동작을 살리는 장치는 이 문턱이고, 쓸기로 장면을 넘기는 다른 화면은 이미지 슬라이드 편에서 다뤘다.

빈칸의 기대값과 세 항짜리 평가식

chanceNode 는 빈칸마다 2 를 놓은 값에 0.9, 4 를 놓은 값에 0.1 을 곱해 더한 뒤 빈칸 수로 나눈다. 빈칸이 하나도 없으면 바로 다음 내 차례로 넘어가고, 층이 다 내려가면 evaluate 가 판에 점수를 매긴다. 평가식은 빈칸 × 2.7 + 단조성 × 1.0 + 매끄러움 × 0.1 인데, 07 첫 판은 빈칸 4 · 단조성 −3 · 매끄러움 −20 이라 10.8 − 3 − 2 = 5.8 이 나온다. 빈칸을 넉넉히 남기고 큰 수를 한쪽 줄로 몰아 두라는, 흔히 2048 게임 잘하는 법으로 꼽는 요령을 세 항으로 옮긴 식으로 보인다. 두 층에서 끝판 1,660개를 보던 셈은 한 층을 더하면 66,350개로 불어나니, 깊이는 화면이 버티는 만큼만 올린다.

2048 판이 깨지는 곳 — 한 수에 두 번 합쳐지는 타일·미끄러지는 도중에 들어온 다음 쓸기·네 자리 숫자에서 칸을 넘치는 글자

잠금 없는 줄에서 부모가 셋, 넷이 되는 타일

compressLine([2,2,4,_]) 을 잠금 없이 부르면 2·2 가 4 가 된 뒤 그 4 가 다음 4 와 또 합쳐져 부모 셋짜리 8 하나가 남는다. 「2 2 4 8」 은 잠금이 있으면 「4 · 4 · 8」 이지만 없으면 부모 넷이 한 번에 모인 「16」 이 되고, 「4 4 8 _」 은 「8 · 8」 대신 「16」 이 된다. 「2 2 2 2」 는 앞에서부터 짝을 지으므로 잠금과 상관없이 두 판 모두 「4 · 4」 라, 손으로 몇 줄 밀어 보는 시험에서는 이 버그가 숨어 버린다. 고칠 곳은 !(lock && last.merged) 한 조건이다.

미끄러지는 0.2초 안에 들어온 쓸기

02 의 시연 입력 9개 가운데 넷은 앞 수가 미끄러지기 시작한 지 0.1초 뒤에 붙어 들어온다. 대기열 한 칸(INPUT_QUEUE_MAX 1)을 둔 판은 아홉을 다 두어 점수 48 에 이르지만, 대기열이 없는 판은 0.3·1.0·2.3·3.0초에 들어온 둘째 입력 넷을 버려 다섯 수만 두고 점수 16 에 멈춘다. 끝 판도 서로 달라져서, 빠르게 연달아 쓰는 사람에게는 판이 입력을 씹는 것처럼 느껴질 수 있다. 대기열을 한 칸에서 멈추고 꽉 찬 뒤의 입력을 버리는 것은, 손을 뗀 뒤에도 판이 혼자 몇 수씩 따라 움직이지 않게 하려는 선택으로 보인다.

칸 폭의 115.9% 가 되는 네 자리 숫자

이 글꼴의 숫자 0~9 는 굵기 800 에서 모두 같은 폭이라 숫자 폭은 자릿수로만 정해진다. 글자 크기를 칸 × 0.5 로 고정하면 4자리 숫자 폭은 4 × 0.5796 × 0.5 = 칸의 115.9% 라, 판 칸 46 에서 7.3px, 큰 칸 86 에서 13.7px 를 넘친다. 05 폴더의 index.html 에서 fitDigits 가 고르는 scale 을 1 로 묶어 넘치는 타일을 큰 칸으로 직접 견주려면, 압축을 풀 때 암호 칸에 j4u24xfe 을 넣으면 된다. 맞춤을 켜 두면 칸 × 0.8 을 넘는 3자리부터 줄어들어, 칸 150 에서도 23.9px 넘칠 4자리 숫자가 칸 안에 들어간다.

4 가 더 많이 떨어지면 판은 얼마나 빨리 막힐까 — 4 등장 확률·탐색 깊이·쓸기 최소 길이 세 값 견주기

이름 바꾼 값 느낌
4 가 더 많이 떨어지게 01 FOUR_CHANCE 0.1 → 0.25 새 타일 1만 개 중 4 가 1,001 → 2,523개, 평균값 2.2 → 2.505. 같은 고정 정책으로 시드 판 300개를 두면 막히기까지 평균 200.9수 → 155.3수
한 수 더 멀리 보기 07 SEARCH_DEPTH 2 → 3 첫 판 끝판 1,660 → 66,350개, 40수 뒤 점수 560 → 620. 깊이 3 은 한 수 평균 189.7ms · 가장 긴 수 339.4ms(개발 시점 실측)
쓸기 최소 길이를 길게 03 SWIPE_MIN_PX 24 → 48 궤적 1,000개 중 받음 627 → 504, 짧아서 버림 180 → 344, 튕김으로 받음 3 → 45

값을 바꿀 때 먼저 볼 숫자

4 의 확률을 0.25 로 올리면 막힘이 평균 45수쯤 앞당겨지고 끝판의 가장 큰 타일 평균도 181.3 에서 153.6 으로 내려가, 난이도를 올리는 손잡이로 쓰기 좋다. 탐색 깊이를 3 으로 올리면 점수는 60 더 얻지만 끝판 수가 40배로 늘고, 탐색이 한 프레임 안에서 한 번에 돌기 때문에 가장 긴 수에서 화면이 그만큼 멈칫한다. ms 값은 기계마다 달라서 비교는 끝판 개수로 하는 편이 낫다. 쓸기 문턱을 48px 로 늘려도 튕김 문턱(초속 450px)은 그대로라 106.7ms 안에 그은 짧은 궤적이 튕김으로 더 많이 들어오고, 시연 궤적 7개는 짧은 두 개가 16·18px 라 결과가 같다.

화살표 키로도 같은 한 수 — 이동 결과를 읽어 주는 문장·일시정지 버튼·움직임 줄이기의 접근성

판에 포커스가 있을 때만 받는 키

판 상자는 tabindex="0" 과 role="application" 에 「누른 뒤 화살표 키나 쓸기로 한 수」 라는 이름을 달고, 방향키는 이 상자의 keydown 에서 Alt·Ctrl·Meta 가 눌리지 않았을 때만 preventDefault() 한다. 판 밖에서 누른 화살표는 아홉 장면 모두 기본 동작이 그대로라 페이지 스크롤을 빼앗지 않고, 04 는 백스페이스로 무르기를, 05 는 위·오른쪽으로 단계 올리기와 아래·왼쪽으로 내리기를 이름에 덧붙였다. 상태 줄은 aria-live="off" 로 시작해 처음 입력이 들어오면 polite 로 바뀌고, 수마다 「방향 · 합침 m · 점수 s」 를 한 문장으로 읽어 준다. 알림 문장을 짧게 띄우는 다른 쓰임은 토스트 알림 편에 있다.

움직임 줄이기와 일시정지

prefers-reduced-motion: reduce 에서는 루프를 멈추고 정지 판 한 장을 그려 0.4초와 1.2초 화면의 화소 차가 0 이고, 이때 키를 누르면 미끄러짐 없이 결과 판으로 바로 바꿔 한 번 그린다. 조각·흔들림·무너짐은 정지 판에 그리지 않고, 설정을 풀면 다시 돈다. 장면마다 있는 일시정지 <button> 은 누르면 aria-pressed="true" 가 되고, 탭이 숨으면 멈췄다가 돌아오면 이어 간다.

2048 게임 만들기에서 자주 묻는 것 — 판 크기·이어 하기 저장·휴대폰 쓸기

판을 5×5 로 키우려면 어디를 고치나요

규칙 쪽은 SIZE 하나다. CELLS·rotateCW·pushOrder·slideBoard·equalPairs 가 모두 SIZE 로 칸을 세므로 4 를 5 로 바꾸면 읽는 순서표와 짝 세기가 따라온다. 다만 그리는 쪽은 장면마다 칸 크기를 상수(06 의 F06_CELL 46 처럼)로 잡아 두었으니 480×300 안에 들어가게 칸과 간격을 다시 정해야 하고, 빈칸이 늘면 07 탐색의 끝판 수도 함께 커진다.

이어 하기 저장을 붙이면 2048 게임 룰대로 같은 다음 타일이 나오나요

데모는 저장소 API 를 하나도 쓰지 않아서 새로 열면 처음 판부터다. 타일(번호·값·칸)과 점수, 다음 타일 번호를 저장하면 판은 되살릴 수 있지만, makeRng 가 난수 상태를 함수 안에 감춰 두므로 다음에 떨어질 타일까지 같게 하려면 그 상태를 꺼내 저장할 수 있게 고쳐야 할 것으로 보인다.

휴대폰에서 판을 쓸면 페이지도 같이 스크롤되지 않나요

touch-action: none 은 판에 포커스가 있을 때(.cf__board:focus)만 걸리므로, 판을 한 번 눌러 포커스를 준 뒤의 쓸기는 판이 받고 포커스 밖에서는 터치 스크롤이 페이지로 간다. 쓸기는 pointerdown·pointerup 의 좌표와 시간 차를 03 과 같은 judgeSwipe 에 넣어 아홉 장면이 같은 기준으로 판정한다. 스크롤 위치 자체를 계산에 쓰는 화면은 pdf 미리보기 뷰어 편을 참고하면 된다. 포인터 입력의 동작 순서는 MDN Pointer events에, 색 섞기 공간은 MDN oklab()에 정리되어 있다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.