GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

テトリス HTML コード — キャンバスで組む9ステップ

1枚のキャンバスに落ち物パズルを組むテトリス html の実装は、盤面・回転・消去・スコアの4つの計算に分解できます。ブロックが落ちて積まれ、一行消えるまでを回転行列と衝突判定で計算して描きます。9ステップで組み立てるテトリス — ライブラリなしのキャンバス1枚、コードはパスワード付きzipでお渡しします。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

デモ

項目 名前 累積面積% 強度平均 動いたフレーム
01 ボードグリッド 13.931 125.9 23/24
02 7ピースと回転 8.513 77.0 23/24
03 ライン消去 24.708 141.5 23/24
04 スコアとレベル 12.365 111.3 23/24
05 ネクトキュー 16.403 134.4 23/24
06 ゴーストとハードドロップ 27.695 54.4 22/24
07 ホールド 15.657 103.4 23/24
08 消去パーティクル 26.844 99.8 23/24
09 スキンとハイスコア 12.078 142.5 23/24

ステップ別コード

どのステップも同じ輪郭です。2次元配列grid1つが全状態を持ち、requestAnimationFrameのデルタ時間で速度を積分し、衝突判定hitsを通った位置だけを画面に描きます。この記事のコードフェンスはdemo/の実ファイルからそのまま抜き出したもので、テトリス html コードの本体はzipに完全版が入っています。

01ボードグリッド — 配列から10×20マスを描く

1つの2次元配列がボードの全状態です。各マスをfillRectで描けばキャンバス1枚で画面全体が出ます — すべてはここに積み上がります。この盤は自動デモを持ち、(完成行 − 穴 − 高さ) で置き場所を採点して、ピースが登場するたびにその盤面の複製へ候補を試し、落下4.6マス/s・水平9.5マス/sで計画に追従し、累積面積13.931%・強度平均125.9・動いたフレーム23/24です。

ボードグリッド — 配列から10×20マスを描く
新しいタブで開く

01 · ボードグリッド — 配列から10×20マスを描く — 計測 累積面積 13.931% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRectrequestAnimationFrame

衝突判定が壁抜けを防ぐ1つの関数

移動・回転・落下・着地行の計算は、全部この1つの判定を通して決まります。

  // 衝突判定 — ピースの全マスを格子と壁に照合する。この1つが壁抜けを防ぐ
  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 step(now) {
    var dt = Math.min(64, now - last) / 1000; last = now;
    if (flashT > 0) { flashT -= dt; if (flashT <= 0) spliceFull(); }
    if (piece) {
      var plan = piece.plan;
      var dx = plan.c - piece.x;
      piece.x += Math.max(-piece.slide * dt, Math.min(piece.slide * dt, dx));
      if (piece.r !== plan.r && Math.abs(plan.c - piece.x) < 0.6) tryRotate();
      piece.y += piece.drop * dt;
      var rx = Math.round(piece.x), ry = Math.floor(piece.y) + 1;
      if (hits(grid, piece.cells, ry, rx)) {
        piece.y = ry - 1;
        restT += dt;
        var aligned = piece.r === plan.r && rx === plan.c;
        if (aligned || restT > 1.4) { piece.x = plan.c; lock(); }
      } else restT = 0;
    }
    draw(now);
    drawProfile(now);
    raf = requestAnimationFrame(step);
  }

027ピースと回転 — 行列積で回る形

各ピースを座標配列にして (x,y)→(y,−x) の回転式を適用すると90度ずつ形が回ります。壁に当たったら回転後に押し戻す壁キックを0.9秒表示し、静止0.18秒 → 回転0.55秒のサイクルが繰り返す実験盤で、累積面積8.513%・強度平均77.0・動いたフレーム23/24です。

7ピースと回転 — 行列積で回る形
新しいタブで開く

02 · 7ピースと回転 — 行列積で回る形 — 計測 累積面積 8.513% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

keydownfillRect

回した結果が箱の外に出たらその分だけ押し戻す

← → キーでも直接回せます。表示の角度は補間で滑らかに追いつきますが、判定は常に回した後の格子座標で行います。

  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();
  }
  function step(now) {
    var dt = Math.min(64, now - last) / 1000; last = now;
    phaseT += dt;
    if (kickFlash > 0) kickFlash -= dt;

    var th = 0;
    if (phase === 'hold') {
      if (phaseT >= HOLD) startTurn();
    } else {
      var p = Math.min(1, phaseT / TURN);
      var ease = 1 - Math.pow(1 - p, 3); // ease-out — 手首の感覚

03ライン消去 — 埋まった行を見つけて消して崩す

毎フレーム行を走査し、空きマスが0個の行だけをspliceで切り取って上を落とします。0.18秒のフラッシュの後、上のマス全体が0.12秒かけて1段ずつ落ち、累積面積24.708%・強度平均141.5・動いたフレーム23/24です。

ライン消去 — 埋まった行を見つけて消し崩す
新しいタブで開く

03 · ライン消去 — 埋まった行を見つけて消し崩す — 計測 累積面積 24.708% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRectrequestAnimationFrame

空きマスを数えて0個の行だけを選ぶ

穴のある行は絶対に消さないこの1つの条件が、1段消しから連続崩壊まで全部を動かします。

  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;
  }
  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));
    }
  }

04スコアとレベル — 同時消しに指数で報いる

一度に消した行数を指数(1・2・3・4倍)で掛けてスコアにし、累計行数が4行ずつ積もるたびにレベルを上げて落下間隔を縮めます。報酬は100×n²、落下間隔は0.50秒からレベルごとに×0.85で下限0.06秒まで、デモピースは42マス/sで着地行まで降りてきてちょうどロックされ、累積面積12.365%・強度平均111.3・動いたフレーム23/24です。

スコアとレベル — 同時消しに指数で報いる
新しいタブで開く

04 · スコアとレベル — 同時消しに指数で報いる — 計測 累積面積 12.365% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramelocalStorage

倍率は100×n×n、加速は×0.85

1・2・3・4段がそれぞれ100・400・900・1600になる指数曲線です。レベルアップは数字ではなく速度として感じられます。

      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;
      }
      var target = landingRow(grid, piece.cells, Math.round(piece.x));
      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(); }

05ネクトキュー — ミニキャンバスに次のピースを予告

7ピースを1セットでシャッフルするバッグ方式なら次が読めず偏りもありません — 7回以内に必ず全種類が1回ずつ出ます。ミニキャンバスは同じfillRectで描き、先頭の5つを予告して、累積面積16.403%・強度平均134.4・動いたフレーム23/24です。

ネクトキュー — ミニキャンバスに次のピースを予告
新しいタブで開く

05 · ネクトキュー — ミニキャンバスに次のピースを予告 — 計測 累積面積 16.403% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRect

Fisher–Yatesで7個1セットを混ぜる

シード乱数(mulberry32)でシャッフルするので、バッグが空になるたびに同じ並びで詰め直されます。

  var bag = [], bagUsed = 0, bagCount = 0;
  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();
    c.clearRect(0, 0, w, h);
    if (!t) return;
    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;

06ゴーストとハードドロップ — 着地点を事前計算して見せる

現在のピースを最下段まで落として最初に当たるyを探せばそれが着地点 — 半透明で先に描き、スペースキーでそこまで一気に落とします。デモはピースごとに0.20秒観察してから自動ドロップし、累積面積27.695%で9つの盤の中で最も広く画面を変える一方、点線の輪郭のため強度平均は54.4と最も暗く、24フレームの録画で動いたのは22フレームだけで、唯一23に届きませんでした。

ゴーストとハードドロップ — 着地点を事前計算して見せる
新しいタブで開く

06 · ゴーストとハードドロップ — 着地点を事前計算して見せる — 計測 累積面積 27.695% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

keydownrequestAnimationFrame

着地行の反復と即時移動

ゴーストは毎フレーム着地行を計算し直した半透明の輪郭で、ハードドロップはその行への1回の移動です。

      // ゴースト — 着地点の半透明の輪郭 (毎フレーム計算し直す)
      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);
  }

07ホールド — ピースを1つ預けて交換

シフトキーで現在のピースを保管マスに入れ、キューのものと交換します。1ドロップ1回だけ通るフラグで無限交換を防ぐ規則まで状態計算の一部で、デモは0.35秒ごとに試みて2回目は拒否表示、落下は4マス/sから2回後26マス/sへ加速し、累積面積15.657%・強度平均103.4・動いたフレーム23/24です。

ホールド — ピースを1つ預けて交換
新しいタブで開く

07 · ホールド — ピースを1つ預けて交換 — 計測 累積面積 15.657% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

keydownfillRect

canHoldフラグが1ドロップ1回を強制する

ピースを置くまでの1回だけが許され、2回目の要求は赤い拒否マークとして記録に残ります。

  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 });
  }
      holdTryT -= dt;
      if (aligned && holdTryN < 2 && holdTryT <= 0) {
        tryHold();       // 1回目は成功、2回目は (まだ置いていないので) 拒否
        holdTryN += 1;
        holdTryT = 0.4;
      } else {
        // 浮遊 — 2回とも試したらすぐ降ろす (リズムを切らさないため)
        var ny = piece.y + (holdTryN >= 2 ? 26 : 4) * dt;
        piece.y = ny >= target ? target : ny;
        if (piece.y === target) lock();
      }

08消去パーティクル — 消えたマスが砕けて散る演出

消えた各マスを小さい四角の粒子に変え、速度と重力を毎フレーム積分するとラインは消えるのではなく弾けて消えます。1マスから6個、速度はvx ±4.5・vy −3〜−10マス/sに振られ、重力30マス/s²、寿命1.1秒で、累積面積26.844%・強度平均99.8・動いたフレーム23/24です。

消去パーティクル — 消えたマスが砕けて散る演出
新しいタブで開く

08 · 消去パーティクル — 消えたマスが砕けて散る演出 — 計測 累積面積 26.844% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramefillRect

粒子の生成と積分

速度は上半球に振られ、寿命は0.6〜1.0倍の揺らぎを持つので、同じ行が2度と同じ形に散らばりません。

  function burst(row, t) {
    // 1マス → 6個の粒子。位置はマス内の乱数、速度は上半球へ
    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
      });
      particles[particles.length - 1].col = 0;
    }
  }
    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; }

09スキンとハイスコア — パステルパレットで仕上げる自分のテトリス

7ピースそれぞれにパステルパレットの色を割り当てて背景とのコントラストを整え、ハイスコアはlocalStorageに残します。スキン3種をCSS変数から読んで即時に切り替え、キーはp330-09-best・p330-09-skinの2つで、累積面積12.078%・強度平均142.5・動いたフレーム23/24です。

スキンとハイスコア — パステルパレットで仕上げる自分のテトリス
新しいタブで開く

09 · スキンとハイスコア — パステルパレットで仕上げる自分のテトリス — 計測 累積面積 12.078% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRectlocalStorage

スキンは変数を読むだけで切り替わる

色の定義はCSS側にあり、描画コードはskinオブジェクトを参照するだけなので、ロジックを一切汚しません。

  function readSkin(s) {
    // スキン別の変数 — --{スキン}-{種類}
    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) { /* 無視 */ }
    }
  }
  function saveSkin() {
    try { localStorage.setItem(SKIN_KEY, String(skinIdx)); } catch (e) { /* 無視 */ }
  }
  var skinBtns = [];
  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);
  }

この9枚、実際どこで使うか

01のボードグリッドとhits判定だけを切り出せば、40分のライブコーディング講座の教材として十分です — 二次元配列1つと衝突判定1つでゲームが成り立つことを見せるのに、9枚全部は要りません。ポートフォリオサイトのプロジェクト欄には、09のスキンとハイスコアまで揃った完成版だけをiframeで埋め込み、訪問者が実際に触れるようにする例が多いです。

ゲームジャムやハッカソンでは、07のホールド枠のように検証済みの当たり判定をそのままコピーして出発点にし、浮いた時間は自分の駒の形やスコア規則といった独自の変形に回します。

なぜブロックが壁を抜けないのか — 回転と衝突の計算

落ち物パズルの不変条件は「埋まったマスは、盤面の内側にしか存在できない」の1文です。9ステップすべてはこの条件を毎フレーム検査することで成り立っていて、画面に見える滑らかな動きは計算結果に視覚が追いつく飾りにすぎません。ここでは条件を守る3つの計算を見ておきます。

回転は座標配列を(x,y)→(y,−x)で書き換える

ピースの形は7種類すべて座標のリストとして持ち、90度回すとは配列の各要素を (x,y)→(y,−x) で書き換えることです。回転行列そのものを書かなくても、この1行の置き換えが90度回転の全体です。

  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];

キックはKICKSの順に左右へずらして受け入れる

回した直後の判定が壁や積み上がりに引っかかったら、KICKS=[0,−1,1,−2,2] の順に横ずらしてもう一度判定します。どれかが通ればその位置で受け入れ、全部弾かれれば回転自体が失敗という、たった5回の試行で手首の感覚が保たれます。

  function tryRotate() {
    var rc = rotCW(piece.cells, BOX[piece.t]);
    var px = Math.round(piece.x), py = Math.floor(piece.y);
    for (var k = 0; k < KICKS.length; k++) {
      if (!hits(grid, rc, py, px + KICKS[k])) {
        piece.cells = rc; piece.x = px + KICKS[k]; piece.r = (piece.r + 1) % 4;
        return true;
      }
    }
    return false;
  }

落下はデルタ時間の積分とクランプで決まる

落下は毎フレーム speed×dt を足す加算1つで、次のマスが判定に引っかかったら直前のマスへクランプします。60fpsでも30fpsでも同じ速度になるのは、フレーム数ではなく経過時間を見ているからです。

キックなしで回すとなぜ壁にめり込むのか — 落とし穴

座標を足すだけで衝突判定を通さない

x += 1 を書いて描画すると、壁の外や積み上がりの上にピースが重なるフレームが生まれます。移動のたびにhitsを通し、引っかかったら戻すのが正しい形です。

CSSのtransform rotateだけで回したつもりになる

画面上の角度が90度回っても、座標配列は1つも動いていません。判定は常に (x,y)→(y,−x) で回した後の格子座標に対して行い、表示の補間角は飾りにしておきます。

だいたい埋まった行を消して済ませる

空きマスが0個の行だけが消える対象です。穴のある行を「よく埋まっているから」と消すと、上の積みが論理的にあり得ない形で崩れ、プレイヤーの信頼を失います。

毎フレーム乱数を再シードする

mulberry32(3301)から(3309)までのシードは1度だけ作って順に消費するからこそ、デモが毎回同じ局面を再現します。毎フレーム作り直すとバッグの並びが飛び、調整も検証もできなくなります。9種の完全なコードはパスワード付きzipに入っていて、e64adxesを入力すると解凍できます。

9ステップはなぜこの順序か

落ち物パズルは依存関係が一方向のゲームです。盤面なしに回転は定義できず、回転なしに消去は起こらず、消去なしにスコアは伸びません。だから01の2次元配列が土台で、02がその上のピースを回し、03で初めて行が消え、04が消えた行に数字を与えます。「html テトリス コピペ」で探している人は完成品を一度に得たいはずですが、9つに割く理由はここにあります — 前のステップを1つ変えれば後ろ全部が壊れるので、順に積むのが一番速い道だからです。05の予告と06のゴーストは盤面が完成してから意味を持つ補助計算、07のホールドはその予告を操作する規則、08の粒子は消去の結果を受け取る側、09はここまで全部を束ねてlocalStorageに記録する仕上げです。砂の粒が積もって山を築くフォーリングサンドの9つの盤と同じく、この9種も毎フレーム格子を計算し直す描き方をしています。

セルの大きさを1つ変えるとアクセシビリティも一緒に変わる

変形 何を変えるか 効果
01 cell 24pxから16pxへ小さくする 盤が遠の感じになり、手ではなく目で予測するゲームになる
02 キック幅 1マスから2マスへ広げる 壁際の回転が緩くなるが、隅に割り込む手応えが強くなる
04 FACTOR ×0.85から×0.7へ急ぐ レベル10あたりから計画ではなく反射のプレイになる

01のcellを変える

cell定数を16にするだけで盤の見え方が変わり、fillRectも衝突判定も1本のコードをそのまま使えます。

02のキック幅を変える

KICKSに−3と3を足すと許容幅が広がり、積み上がった隅への回転割り込みがぐっと容易になります。

04のFACTORを変える

×0.85を×0.7にするとレベル5で速度が倍近くに達し、04の下限0.06秒に到達するのが早まります。

prefers-reduced-motionでは静かな盤を見せる

OSの「視差効果を減らす」設定が有効なユーザーには、粒子とフラッシュのループを止めて積み上がった盤面1面を固定して見せます。動きを減らす要求は情報を消せという意味ではないので、空の盤に戻さないのがルールです。

テトリスを自作する前によくある質問

キーボード操作はどの項目でできますか

02が← → キーで回転、06がスペースキーでハードドロップ、07がシフトキーでホールドを受け付けます。いずれもkeydownで既定動作を止めて処理しています。

初めて作るならどのステップから始めるのがよいですか

「html テトリス 作り方」で探している人が最初につまずくのは大抵、盤面ではなく衝突判定です。01のhits関数を書いて壁を抜けないことを確かめてから02に進むと、残りは全部その組み合わせで済みます。

デモが毎回同じ局面なのはなぜですか

乱数をmulberry32(3301〜3309)のシードで初期化して順に消費しているためで、バッグの並びも落下の順も完全に再現されます。シードを変えれば別の局面が同じように再現されます。

もっと読む

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。