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

HTML 테트리스 코드 — 캔버스로 조립하는 9단계

블록이 내려오고 쌓이고 한 줄이 지워지는 걸 회전 행렬과 충돌 검사로 계산해 그립니다. 아홉 단계로 조립하는 테트리스 — 라이브러리 없이 캔버스 한 장, 코드는 비밀번호 zip으로 드립니다.

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

데모

아홉 판 전부 index.html 한 파일씩으로 열리자마자 스스로 플레이합니다. 조각은 고정 시드 난수로 뽑고, 배치는 조각이 등장할 때마다 보드를 복제해 구멍 수와 총 높이로 점수를 매기는 계산이 정하며, 사람은 포인터로 누르거나 스페이스·시프트·화살표 키로 같은 계산 경로를 직접 밟을 수 있습니다. 24프레임 녹화에서 여덟 판이 23프레임, 06 고스트·하드드롭만 22프레임 움직였습니다.

24프레임 실측 한눈에

항목 초점 누적면적(%) 강도 평균 움직인프레임
01 보드 격자 10×20 배열 · fillRect · 열 스캔 13.93 125.9 23
02 일곱 조각과 회전 (x,y)→(y,−x) 회전식 · 벽 킥 8.51 77.0 23
03 라인 클리어 빈 칸 0개 줄 검사 · splice 24.71 141.5 23
04 점수와 레벨 100 × n × n 지수 보상 12.37 111.3 23
05 넥스트 큐 가방 7개 섞기 · 미니 캔버스 16.40 134.4 23
06 고스트와 하드드롭 착지행 미리 계산 · 스페이스 27.70 54.4 22
07 홀드 한 번 놓을 때 한 번 규칙 15.66 103.4 23
08 클리어 파티클 v += g·dt 적분 · 궤적 26.84 99.8 23
09 스킨과 최고점 세 팔레트 · localStorage 12.08 142.5 23

낮은 누적면적이 정상인 이유

보드 게임의 화면은 반쯤 고정입니다 — 쌓인 블록이 판의 대부분을 차지한 채 드물게 바뀌고, 매 프레임 움직이는 건 낙하 조각 하나와 보조 패널뿐입니다. 그래서 02 회전 판이 8.51%, 04 점수 판이 12.37%, 09 스킨 판이 12.08% 인 것은 결함이 아니라 구조적 한계입니다. 반대로 06 고스트·하드드롭이 27.70% 로 가장 넓은 것은 조각이 착지점까지 한 번에 쏟아지며 보드 절반 이상을 새로 그리기 때문이고, 08 파티클 26.84%·03 라인 클리어 24.71% 가 뒤따릅니다. 강도 평균은 09 스킨 142.5·03 라인 클리어 141.5 가 가장 밝고 06 이 54.4 로 가장 어두운데, 고스트 윤곽이 점선이라서입니다.

단계별 코드

01보드 격자 — 10×20 칸을 배열로 그리기

보드의 전 상태는 2차원 배열 하나이고 칸마다 fillRect 로 그리면 캔버스 한 장으로 화면 전체가 나옵니다. 조각이 놓이고 줄이 지워질 때마다 이 배열만 고치면 화면은 따라오고, 조각이 스스로 놓일 자리를 찾는 계산도 이 판 안에서 이루어지며 24프레임 녹화에서 누적면적 13.93%·23프레임이 움직였습니다.

보드 격자 — 10×20 칸을 배열로 그리기
새 창에서 열기

01 · 보드 격자 — 10×20 칸을 배열로 그리기 — 측정 누적면적 13.931% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectrequestAnimationFrame

충돌 검사와 착지행 — 아홉 판 전부가 쓰는 두 함수

  function hits(grid, cells, row, col) {
    for (var i = 0; i < cells.length; i++) {
      var x = col + cells[i][0], y = row + cells[i][1];
      if (x < 0 || x >= COLS || y >= ROWS) return true;
      if (y >= 0 && grid[y][x]) return true;
    }
    return false;
  }
  function landingRow(grid, cells, col) {
    var row = -4;
    while (!hits(grid, cells, row + 1, col)) row += 1;
    return row;
  }

조각이 놓이는 순간의 잠금과 줄 검사

  function lock() {
    var rx = Math.round(piece.x), ry = Math.floor(piece.y), dead = false;
    for (var i = 0; i < piece.cells.length; i++) {
      var gx = rx + piece.cells[i][0], gy = ry + piece.cells[i][1];
      if (gy < 0) { dead = true; break; }
      grid[gy][gx] = piece.t;
    }
    if (dead) { grid = emptyGrid(); }
    else {
      var full = [];
      for (var y = 0; y < ROWS; y++) {
        var n = 0;
        for (var x = 0; x < COLS; x++) if (grid[y][x]) n += 1;
        if (n === COLS) full.push(y);
      }
      if (full.length) { flashRows = full; flashT = 0.30; }
    }
    countEl.textContent = String(filledCount());
    spawn();
  }

hits 는 조각의 각 칸에 보드 좌표를 더해 범위 밖이거나 이미 찬 칸이면 참을 돌리는 전부이고, landingRow 는 그 검사를 한 칸씩 내려가며 착지행을 얻어 고스트·하드드롭·배치 계산이 전부 이 한 쌍 위에서 돕니다.

02일곱 조각과 회전 — 행렬 곱으로 도는 모양

일곱 조각은 각각 좌표 배열이고 회전은 (x,y)→(y,−x) 식 한 줄로 90도씩 돕니다. 정지 0.18초 · 회전 0.55초의 리듬으로 도는 이 판의 누적면적 8.51% 는 아홉 판 중 가장 좁은데, 회전 실험이 별도의 상자 안에서 일어나기 때문입니다.

일곱 조각과 회전 — 행렬 곱으로 도는 모양
새 창에서 열기

02 · 일곱 조각과 회전 — 행렬 곱으로 도는 모양 — 측정 누적면적 8.513% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

keydownfillRect

좌표 하나를 90도 돌리는 식

  // 회전 — (x,y) → (y,−x). n×n 상자 안으로 되돌리려면 y 에 n−1 을 더한다.
  // 이 식과 중심 기준 90도 회전 행렬은 같은 결과를 낸다 — 아래 보간이 그 증명이다
  function rotCW(cells, n) {
    var out = [];
    for (var i = 0; i < cells.length; i++) out.push([cells[i][1], n - 1 - cells[i][0]]);
    return out;
  }

돌린 결과가 상자 밖이면 1칸씩 밀어 되돌린다

  function finishTurn() {
    var n = baseN;
    var rotated = rotCW(cells, n);
    // 벽 킥 — 돌린 결과가 상자 밖이면 1칸씩 밀어 되돌린다
    var minX = 9, maxX = -9;
    for (var i = 0; i < rotated.length; i++) {
      var x = rotated[i][0] + offX;
      if (x < minX) minX = x;
      if (x > maxX) maxX = x;
    }
    var kick = 0;
    if (minX < 0) kick = -minX;
    if (maxX > BOXN - 1) kick = BOXN - 1 - maxX;
    if (kick !== 0) { offX += kick; kickFlash = 0.9; kickCount += 1; kickEl.textContent = '벽 킥 1칸'; }
    cells = rotated;
    theta = 0;
    spin += 1;
    degEl.textContent = DEGS[spin % 4];
    countEl.textContent = String(spin);
    phase = 'hold'; phaseT = 0;
    if (spin % 8 === 0) nextPiece();
  }

벽에 붙은 조각은 돌린 뒤 최소·최대 x 를 재고 밖으로 나온 만큼만 옆으로 밀리며, 킥이 일어난 순간은 화면에 벽 킥 1칸 표시와 플래시로 남아 눈으로 셀 수 있습니다.

03라인 클리어 — 꽉 찬 줄을 찾아 지우고 무너뜨리기

매 놓음마다 보드를 한 줄씩 훑어 빈 칸이 0개인 줄만 골라 0.18초 섬광 뒤 splice 로 자르고 위를 내립니다. 누적면적 24.71% 로 아홉 판 중 세 번째로 넓고 강도 141.5 로 두 번째로 밝은데, 줄이 찢어지며 밝아지는 섬광과 붕괴가 판의 절반 가까이를 바꾸기 때문입니다.

라인 클리어 — 꽉 찬 줄을 찾아 지우고 무너뜨리기
새 창에서 열기

03 · 라인 클리어 — 꽉 찬 줄을 찾아 지우고 무너뜨리기 — 측정 누적면적 24.708% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectrequestAnimationFrame

빈 칸 0개인 줄만 골라 섬광 예약한다

  function lock() {
    var rx = Math.round(piece.x), ry = Math.floor(piece.y), dead = false;
    for (var i = 0; i < piece.cells.length; i++) {
      var gx = rx + piece.cells[i][0], gy = ry + piece.cells[i][1];
      if (gy < 0) { dead = true; break; }
      grid[gy][gx] = piece.t;
    }
    if (dead) { grid = emptyGrid(); }
    else {
      // 줄 검사 — 빈 칸 0개인 줄만
      flashRows = [];
      for (var y = 0; y < ROWS; y++) {
        var n = 0;
        for (var x = 0; x < COLS; x++) if (grid[y][x]) n += 1;
        if (n === COLS) flashRows.push(y);
      }
      if (flashRows.length) { flashT = FLASH_S; }
    }
    piece = null;
  }

splice 로 자르고 빈 줄을 맨 위에 얹는다

  function spliceRows() {
    flashRows.sort(function (a, b) { return a - b; });
    cleared += flashRows.length;
    countEl.textContent = String(cleared);
    for (var i = 0; i < flashRows.length; i++) {
      grid.splice(flashRows[i], 1);
      grid.unshift(new Array(COLS).fill(0));
    }
    // 쓰레기 보충 — 판이 비면 다시 아래쪽에 구멍진 줄을 넣어 클리어가 계속 일어나게
    var filled = 0;
    for (var y2 = 0; y2 < ROWS; y2++) for (var x2 = 0; x2 < COLS; x2++) if (grid[y2][x2]) filled += 1;
    if (filled < COLS * 3) {
      for (var gy2 = ROWS - 4; gy2 < ROWS; gy2++) {
        var h1 = Math.floor(rnd() * COLS);
        for (var gx2 = 0; gx2 < COLS; gx2++) grid[gy2][gx2] = gx2 === h1 ? 0 : TYPES[Math.floor(rnd() * 7)];
      }
    }
    flashRows = [];
    dropY = 1; // 붕괴 시각 — 위 칸이 한 칸 내려오는 동안
  }

지우는 동작은 행을 통째로 잘라 같은 수의 빈 행을 맨 위에 unshift 하는 두 호출이고, 아랫줄부터 자르면 윗줄 인덱스가 미끄러지므로 오름차순 정렬이 먼저입니다.

04점수와 레벨 — 동시 클리어에 지수로 보상하기

한 번에 지운 줄 수를 제곱에 비례해 점수를 주고(1·4·9·16배) 누적 줄 수가 네 줄씩 쌓일 때마다 레벨을 올려 낙하 간격을 줄입니다. 누적면적 12.37% 로 좁은 편인데 변화가 점수 패널과 팝 안쪽에 집중되기 때문입니다.

점수와 레벨 — 동시 클리어에 지수로 보상하기
새 창에서 열기

04 · 점수와 레벨 — 동시 클리어에 지수로 보상하기 — 측정 누적면적 12.365% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramelocalStorage

점수 = 100 × n × n — 배율이 그래프에 쌓인다

      if (flashRows.length) {
        // 지수 보상 — 점수 = 100 × n × n (1·2·3·4배)
        var n = flashRows.length;
        var add = 100 * n * n;
        score += add; lines += n;
        scoreEl.textContent = String(score);
        pops.push({ txt: n + '배 +' + add, t: 1.0 });
        history.push(n);
        if (history.length > 26) history.shift();
        var nl = 1 + Math.floor(lines / LINES_PER_LV);
        if (nl !== level) {
          level = nl;
          lvEl.textContent = String(level);
          ivEl.textContent = fmtIv();
          pops.push({ txt: '레벨 ' + level, t: 1.2 });
        }
        saveBest();
        flashT = 0.18;
      }

레벨이 간격을 줄이고 간격이 속도가 된다

      if (Math.abs(dx) < 0.05 && piece.r === piece.plan.r) {
        // 일정 속도 낙하 — 착지 행까지 상속 속도로 내려온 뒤 딱 잠근다
        var ny = piece.y + 42 * dt;
        piece.y = ny >= target ? target : ny;
        if (piece.y === target) lock();
      } else {
        piece.y += 5 * dt;
        var ry0 = Math.floor(piece.y) + 1;
        if (hits(grid, piece.cells, ry0, Math.round(piece.x))) { piece.y = ry0 - 1; lock(); }
      }

낙하는 매 프레임 42 * dt 를 더하는 상수 속도이고, ny >= target ? target : ny 비교가 프레임 간격이 몇 칸치가 되었든 목표행에서 멈추게 합니다.

05넥스트 큐 — 미니 캔버스에 다음 조각 미리 보기

가방 방식으로 일곱 조각을 한 세트 섞어 큐에 채우면 일곱 번 안에는 반드시 모든 조각이 한 번씩 나오면서 편향도 없습니다. 누적면적 16.40%·강도 134.4 로 중간 이상입니다.

넥스트 큐 — 미니 캔버스에 다음 조각 미리 보기
새 창에서 열기

05 · 넥스트 큐 — 미니 캔버스에 다음 조각 미리 보기 — 측정 누적면적 16.403% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRect

가방 채우기 — 일곱 개를 섞어 큐에 댄다

  function refill() {
    bag = TYPES.slice();
    for (var i = bag.length - 1; i > 0; i--) {
      var j = Math.floor(rnd() * (i + 1)), tmp = bag[i]; bag[i] = bag[j]; bag[j] = tmp;
    }
    bagUsed = 0; bagCount += 1;
  }
  refill();
  function nextType() { if (!bag.length) refill(); bagUsed += 1; return bag.pop(); }

  // 큐 — 앞의 NQ 개
  var queue = [];
  function fillQueue() { while (queue.length < NQ + 2) queue.push(nextType()); }
  fillQueue();

미니 캔버스는 메인 보드와 같은 붓질로

    var cells = SHAPES[t];
    // 조각을 슬롯 중앙에 맞춘다
    var minX = 9, maxX = -9, minY = 9, maxY = -9;
    for (var i = 0; i < cells.length; i++) {
      minX = Math.min(minX, cells[i][0]); maxX = Math.max(maxX, cells[i][0]);
      minY = Math.min(minY, cells[i][1]); maxY = Math.max(maxY, cells[i][1]);
    }
    var pw = maxX - minX + 1, ph = maxY - minY + 1;
    var mc = Math.min(w / 4.4, h / 4.4);
    var mx = (w - pw * mc) / 2, my = (h - ph * mc) / 2;
    c.globalAlpha = alpha == null ? 1 : alpha;
    for (var k = 0; k < cells.length; k++) {
      // 칸 밑면 — fillRect 로 깔고 그 위에 테두리 사각을 얹는다 (메인 보드와 같은 순서)
      c.fillStyle = EDGE[t];
      c.globalAlpha = (alpha == null ? 1 : alpha) * 0.25;
      c.fillRect(mx + (cells[k][0] - minX) * mc, my + (cells[k][1] - minY) * mc, mc, mc);
      c.globalAlpha = alpha == null ? 1 : alpha;
      c.beginPath();
      c.roundRect(mx + (cells[k][0] - minX) * mc + 1, my + (cells[k][1] - minY) * mc + 1, mc - 2, mc - 2, Math.max(2, mc * 0.22));
      c.fillStyle = FILL[t]; c.fill();
      c.lineWidth = Math.max(1.5, mc * 0.13);
      c.strokeStyle = EDGE[t]; c.stroke();
    }

TYPES.slice() 로 원본을 복사해 섞으므로 다음 가방도 항상 일곱 개 온전하고, 미니 캔버스는 조각의 폭·높이를 재해 슬롯 중앙에 맞춘 뒤 메인 보드와 같은 순서로 얹습니다.

06고스트와 하드드롭 — 착지점을 미리 계산해 보여주기

현재 조각을 아래로 끝까지 내려봐서 처음 걸리는 y 를 찾으면 그게 착지점이고 점선 윤곽으로 먼저 그린 뒤 스페이스바에 그 지점까지 한 번에 떨어뜨립니다. 누적면적 27.70% 로 아홉 판 중 가장 넓게 화면을 바꾸지만 점선 탓에 강도는 54.4 로 가장 어둡고 24프레임 녹화에서 22프레임만 움직여 유일하게 23을 채우지 못했습니다.

고스트와 하드드롭 — 착지점을 미리 계산해 보여주기
새 창에서 열기

06 · 고스트와 하드드롭 — 착지점을 미리 계산해 보여주기 — 측정 누적면적 27.695% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

keydownrequestAnimationFrame

착지행을 먼저 계산해 점선으로 그린다

      // 고스트 — 착지점 반투명 윤곽 (미리 계산된 값, 매 프레임 다시 계산된다)
      var px = Math.round(piece.x);
      var lr = landingRow(grid, piece.cells, px);
      for (var i = 0; i < piece.cells.length; i++) {
        var gyy = lr + piece.cells[i][1];
        if (gyy >= 0 && gyy !== Math.floor(piece.y) + piece.cells[i][1]) ghostCell(px + piece.cells[i][0], gyy, piece.t);
      }

스페이스 한 번에 착지행까지 떨어뜨린다

  function hardDrop() {
    // 계획대로 맞춰 착지점까지 한 번에 — beam 은 지나간 자리를 0.4초 밝힌다
    piece.x = piece.plan.c;
    piece.cells = shapeRot(piece.t, piece.plan.r);
    piece.r = piece.plan.r;
    var target = landingRow(grid, piece.cells, Math.round(piece.x));
    beamT = 0.4;
    piece.y = target;
    lock();
    drops += 1;
    countEl.textContent = String(drops);
  }

고스트는 01 의 landingRow 를 매 프레임 다시 부를 뿐이고 하드드롭은 낙하를 빨리 돌리는 게 아니라 좌표를 착지행에 바로 옮겨 잠그는 것입니다.

07홀드 — 조각 하나를 저장하고 바꿔치기

시프트 키로 현재 조각을 보관 칸에 넣고 큐의 것과 맞바꾸며, 한 번 놓을 때 한 번만 통과되는 플래그로 무한 바꾸기를 막습니다. 누적면적 15.66%·강도 103.4 인 이 판은 보관 칸과 교환 기록 로그가 옆 패널에서 같이 움직입니다.

홀드 — 조각 하나를 저장하고 바꿔치기
새 창에서 열기

07 · 홀드 — 조각 하나를 저장하고 바꿔치기 — 측정 누적면적 15.657% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

keydownfillRect

한 조각에 한 번 — 플래그가 규칙이다

  function tryHold() {
    if (!piece) return;
    if (!canHold) {
      // 거절 — 규칙 위반 표시
      marks.push({ x: Math.round(piece.x), y: Math.max(0, Math.floor(piece.y)), ok: false, t: 1.0 });
      return;
    }
    var cur = piece.t;
    if (held === null) {
      held = cur;
      piece = null; // 다음 큐 조각으로
      canHold = true; // 새 조각에는 다시 허용? — 아니, 놓기 전까지 한 번. 새 조각이니 허용
    } else {
      var swapIn = held;
      held = cur;
      var plan = choose(grid, swapIn);
      if (!plan) { swapIn = cur; held = cur; plan = piece.plan; }
      piece = { t: swapIn, r: 0, cells: SHAPES[swapIn].slice(), x: 2, y: -0.4, plan: plan, usedHold: true };
    }
    canHold = piece && piece.usedHold ? false : true;
    if (piece === null) canHold = true;
    swaps += 1;
    swapEl.textContent = String(swaps);
    marks.push({ x: piece ? Math.round(piece.x) : 2, y: piece ? Math.max(0, Math.floor(piece.y)) : 0, ok: true, t: 0.8 });
  }

데모는 홀드를 스스로 두 번 시도한다

      var target = landingRow(grid, piece.cells, Math.round(piece.x));
      var aligned = Math.abs(dx) < 0.05 && piece.r === piece.plan.r;
      holdTryT -= dt;
      if (aligned && holdTryN < 2 && holdTryT <= 0) {
        tryHold();       // 1번째는 성공, 2번째는 (아직 안 놓았으니) 거절
        holdTryN += 1;
        holdTryT = 0.4;
      } else {
        // 부유 — 두 번 다 시도했으면 즉시 내려 놓는다 (리듬이 끊기지 않게)
        var ny = piece.y + (holdTryN >= 2 ? 26 : 4) * dt;
        piece.y = ny >= target ? target : ny;
        if (piece.y === target) lock();
      }

거절은 무시가 아니라 보드에 ✕ 표시를 남기는 사건이고, usedHold 플래그가 조각이 놓일 때까지 canHold 를 끄는 상태 계산 하나로 규칙이 완성됩니다.

08클리어 파티클 — 지워진 칸이 조각나 흩어지는 연출

지워진 각 칸을 작은 사각 입자 여섯 개로 바꿔 속도와 중력을 프레임마다 적분하면 라인이 사라지는 게 아니라 튀어 사라집니다. 누적면적 26.84% 로 아홉 판 중 두 번째로 넓습니다.

클리어 파티클 — 지워진 칸이 조각나 흩어지는 연출
새 창에서 열기

08 · 클리어 파티클 — 지워진 칸이 조각나 흩어지는 연출 — 측정 누적면적 26.844% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramefillRect

한 칸이 입자 여섯 개로

  function burst(row, t) {
    // 한 칸 → 여섯 입자. 위치는 칸 안 무작위, 속도는 위쪽 반구로
    for (var k = 0; k < PER_CELL; k++) {
      particles.push({
        x: 0.15 + rnd() * 0.7, y: 0.15 + rnd() * 0.7,   // 칸 단위 상대 위치
        col: 0, row: row,
        vx: (rnd() - 0.5) * 9, vy: -3 - rnd() * 7,
        t: LIFE * (0.6 + rnd() * 0.4),
        c: k % 2 ? FILL[t] : EDGE[t],
        sz: 0.10 + rnd() * 0.16
      });
    }
  }

매 프레임 v += g·dt, p += v·dt

    var dt = Math.min(64, now - last) / 1000; last = now;
    // 입자 적분 — v += g·dt, p += v·dt
    for (var p = particles.length - 1; p >= 0; p--) {
      var pt = particles[p];
      pt.vy += GRAV * dt;
      pt.x += pt.vx * dt;
      pt.y += pt.vy * dt;
      pt.t -= dt;
      if (pt.t <= 0 || pt.y > ROWS + 1) { particles.splice(p, 1); continue; }
      // 궤적 기록 (하늘 패널 좌표)
      if (Math.random() < 0.25) trails.push({ x: Math.min(1, Math.max(0, pt.x / COLS)), y: Math.min(1, Math.max(0, pt.y / ROWS)), a: 1, c: pt.c });
    }

중력 가속 GRAV = 30(칸/s²)을 속도에 더하고 속도를 위치에 더하는 오일러 적분 두 줄이 물리 전부이고, 입자의 시작 위치는 칸 안의 무작위 점에서 위쪽 반구 방향으로 흩어집니다.

09스킨과 최고점 — 파스텔 팔레트로 완성하는 내 테트리스

일곱 조각마다 팔레트 색을 배정해 배경과 대비를 맞추고 최고점은 localStorage 에 남겨 다음 방문에 이어지며, 스킨 세 가지(파스텔·야간·모노)는 버튼으로 그 자리에서 바뀝니다. 강도 평균 142.5 는 아홉 판 중 가장 밝습니다.

스킨과 최고점 — 파스텔 팔레트로 완성하는 내 테트리스
새 창에서 열기

09 · 스킨과 최고점 — 파스텔 팔레트로 완성하는 내 테트리스 — 측정 누적면적 12.078% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectlocalStorage

스킨은 CSS 변수에서 다시 읽는다

  function readSkin(s) {
    // 스킨별 변수 — --p-{스킨}-{종류}
    var f = {}, e = {};
    var types = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];
    for (var i = 0; i < types.length; i++) {
      f[types[i]] = v('--' + s + '-f-' + types[i].toLowerCase());
      e[types[i]] = v('--' + s + '-e-' + types[i].toLowerCase());
    }
    return { fill: f, edge: e, line: v('--' + s + '-line'), flash: v('--' + s + '-flash') };
  }
  var skin = null;
  function applySkin() { skin = readSkin(SKINS[skinIdx]); }

최고점은 저장, 실패는 무시

  function saveBest() {
    if (score > best) {
      best = score;
      bestEl.textContent = String(best);
      try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* 무시 */ }
    }
  }

버튼 세 개는 aria-pressed 로 눌림을 같이 저장한다

  for (var sb = 0; sb < SKINS.length; sb++) {
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'sk__skinBtn';
    b.textContent = SKIN_NAMES[SKINS[sb]];
    b.setAttribute('aria-pressed', sb === skinIdx ? 'true' : 'false');
    (function (idx) {
      b.addEventListener('click', function () {
        skinIdx = idx;
        for (var k = 0; k < skinBtns.length; k++) skinBtns[k].setAttribute('aria-pressed', k === idx ? 'true' : 'false');
        applySkin();
        saveSkin();
      });
    })(sb);
    skinsEl.appendChild(b);
    skinBtns.push(b);
  }

색을 자바스크립트에 두 번 쓰지 않고 CSS 변수에서 읽으며, localStorage 는 예외를 던질 수 있어 try/catch 로 감싸 저장이 안 되어도 게임은 멈추지 않게 합니다.

왜 블록이 벽을 뚫지 않는가 — 회전과 충돌의 계산

블록이 벽이나 쌓인 조각을 뚫지 않는 이유는 그리기 전에 매번 검사하기 때문입니다. 회전은 rotCW 의 좌표 변환식으로 배열을 새로 만들 뿐 화면과 무관하고, 만들어진 결과는 그리기 전에 hits 가 보드 좌표로 시험합니다. 이 판의 회전은 후보 다섯 개(제자리 · ±1칸 · ±2칸)를 차례로 밀어보고 첫 통과 지점에 놓는데, 이 시험이 실패하면 회전 자체가 일어나지 않으니 뚫을 방법이 없습니다.

원본 배열을 오염시키지 않는 회전

  var SHAPES = {
    I: [[0, 1], [1, 1], [2, 1], [3, 1]],
    O: [[1, 1], [2, 1], [1, 2], [2, 2]],
    T: [[1, 0], [0, 1], [1, 1], [2, 1]],
    S: [[1, 0], [2, 0], [0, 1], [1, 1]],
    Z: [[0, 0], [1, 0], [1, 1], [2, 1]],
    J: [[0, 0], [0, 1], [1, 1], [2, 1]],
    L: [[2, 0], [0, 1], [1, 1], [2, 1]]
  };
  var BOX = { I: 4, O: 4, T: 3, S: 3, Z: 3, J: 3, L: 3 };
  var KICKS = [0, -1, 1, -2, 2];

조각을 꺼낼 때는 SHAPES[t].slice() 로 겉만 복사해 쓰고, 회전은 새 배열을 만들어 돌려줍니다 — 원본 표를 직접 고쳐 다음 판의 모양이 뒤틀리는 일이 구조적으로 일어나지 않습니다. 회전 실패 시에도 cells 는 마지막 성공 결과 그대로 남습니다.

튜토리얼로 끝내지 않는다면 이 아홉 판을 어디에 붙이나

01 보드 격자만 떼어 40분짜리 라이브 코딩 강의 자료로 쓰는 경우가 많습니다 — 배열 하나와 hits 검사 하나로 게임이 성립하는 걸 보여주는 데 아홉 판 전부가 필요하지 않기 때문입니다. 포트폴리오 사이트의 프로젝트 섹션에는 09 스킨과 최고점이 붙은 완성판 하나만 iframe 으로 얹어, 방문자가 직접 눌러보게 하는 쪽이 많습니다.

게임잼이나 해커톤에서는 07 홀드처럼 이미 검증된 충돌 로직을 그대로 복사해 시작점으로 삼고, 남은 시간은 새 조각 모양이나 점수 규칙 같은 자신만의 변형에 씁니다.

아홉 단계는 왜 이 순서인가

순서는 완성품을 아래부터 조립하는 서열입니다. 먼저 상태를 담을 배열과 충돌 검사를 정의해 바닥을 깔고(01), 그 위에 조각의 모양과 회전식을 올립니다(02). 세 번째가 쌓인 결과를 지우는 규칙이고(03), 네 번째부터 게임의 격이 올라갑니다 — 점수와 레벨이 속도로 느껴지게(04), 다음 조각이 예고되게(05), 착지점이 보이고 스페이스로 즉시 놓이게(06), 조각 하나를 맡겨 두고 바꾸게(07). 여덟 번째는 지워지는 순간의 연출이고(08), 마지막 아홉 번째가 스킨과 최고점을 붙여 가져다 쓰는 완성판입니다(09). 매 프레임 격자 위에서 충돌을 다시 계산한다는 점에서 모래 알갱이가 아래 칸을 검사해 흐르는 캔버스 낙하 시뮬레이션과 같은 격자 셀 계산의 이웃이고, 손이 가는 작은 완성품을 파일 하나로 준다는 점은 계산기부터 퍼즐까지 브라우저에서 도는 웹 미니게임 모음과도 같은 갈래입니다.

킥 없이 돌리면 왜 벽에 박히는가 — 테트리스의 함정

착지행을 비교 없이 지나치면 바닥을 뚫는다

낙하를 piece.y += speed * dt 한 줄로만 쓰면 프레임 시간이 커지는 순간(탭이 배경으로 가는 등) 조각이 착지행을 건너뜁니다. 아홉 판은 이를 두 갈래로 막는데, 한 갈래(04·05·07·08·09)는 ny >= target ? target : ny 비교로 프레임 간격이 몇 칸치가 되었든 목표행에 비껴 잠그고, 나머지 갈래(01·02·03·06)는 다음 칸 hits 검사에서 걸리는 순간 piece.y = ry - 1 로 한 칸 위에 맞춰 잠급니다 — 두 방식 모두 하는 일은 목표행 딱 한 칸 위에서 멈추는 것입니다. 과거 초안의 "매 프레임 남은 거리의 절반씩 내려오는" 낙하는 이 비교가 없으면 바닥에 영원히 닿지 않는 역설을 만들어 지금의 상수 속도 방식으로 고쳤습니다. 이 아홉 판의 원본 index.html 그대로를 묶은 암호 zip 의 비밀번호는 e64adxes 이고, 풀면 본문의 코드 조각과 줄 단위로 나란히 대조해 보실 수 있습니다.

회전을 킥 없이 적용하면 벽에 박힌다

02 의 킥 검사를 빼고 돌린 결과를 그대로 쓰면, 벽에 붙은 조각은 회전 결과가 보드 밖 좌표를 가져 다음 hits 에서 항상 실패합니다 — 조각이 벽에서 영원히 돌지 않게 되는 것입니다. 01 의 tryRotate 가 후보 다섯 칸을 차례로 시험하는 것도 같은 이유이고, 쌓인 더미 구석에서는 다섯 후보가 전부 실패해 회전이 거부되는 것이 정상 동작입니다.

칸 크기 하나만 바꿔도 접근성까지 달라지는 세 변형

값 하나를 바꾼 세 변형

이름 바꾼 값 느낌
칸 크기 01 cell 24px → 16px 칸이 작아질수록 보드가 멀어져 손이 아니라 눈으로 예측하는 게임이 된다
킥 허용폭 02 kick 1칸 → 2칸 허용폭이 넓을수록 벽 근처 회전이 관대해지지만 쌓인 구석에 끼어드는 느낌도 커진다
낙하 가속 04 레벨당 간격 ×0.85 → ×0.7 가속이 급할수록 레벨 10부터는 계획이 아니라 반사로 플레이하게 된다

동작을 줄인 사용자에게 남기는 장면

아홉 판 모두 시작 시 prefers-reduced-motion: reduce 를 검사해 켜져 있으면 애니메이션 루프 없이 고정 장면 한 장을 그립니다. 01 은 조각 여섯을 미리 놓은 쌓임을, 06 은 고스트가 뚜렷이 보이는 조각을, 08 은 터진 직후의 입자를 정적으로 보이므로 정지 사용자에게도 빈 캔버스가 아니라 게임의 상태가 남습니다.

스크린리더와 키보드로 만지는 아홉 판

판마다 role="application" 과 aria-label · tabindex 가 붙어 포인터 없이 진입할 수 있고, 점수·줄 수·남은 칸·입자 수 같은 핵심 숫자는 aria-live="polite" 로 갱신을 알립니다. 회전은 화살표 키, 하드드롭은 스페이스, 홀드는 시프트로 같은 계산 경로를 밟습니다.

html 테트리스를 직접 짜다 보면 자주 걸리는 질문

자바스크립트 테트리스 만들기를 이 데모로 처음 시작해도 따라갈 수 있나요?

핵심은 01 판의 hits · landingRow 두 함수와 02 판의 회전식 한 줄입니다. zip 안에는 아홉 판을 같은 전제로 다시 짜라고 시키는 지시 문서가 들어 있어 코드 도구에 붙여 넣으면 같은 화면부터 시작하고, 그다음 칸 크기·킥 폭·가속 계수를 하나씩 바꿔 보는 순서가 가장 빠릅니다.

html 테트리스 코드를 통째로 복사해도 바로 돌아가나요?

그냥 두세요. 아홉 판 각각 빌드 도구 없이 index.html 파일 하나로 열리며 CDN 이나 웹폰트, 이미지도 없습니다. zip 을 풀어 원하는 판의 파일을 브라우저에 끌어다 놓거나 서버에 올리면 그대로 도는, 이 사이트의 다른 데모와 같은 구조입니다.

9개 패널을 한 페이지에 다 넣어야 하나요?

아닙니다. 9개는 각각 독립된 단계라 완성 게임에는 09 하나면 충분하고, 나머지는 부품으로 가져다 씁니다 — 점수·레벨 계산만(04), 가방 섞기만(05), 입자 적분만(08) 떼어 내 다른 게임에 얹는 식입니다. 홀드(07)와 넥스트 큐(05)는 함께 쓰면 예측 플레이가 완성되는 조합입니다.

더 읽을거리

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

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