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

2048 ゲーム 作り方 9種 — 合体の決まりから自動プレイまで

2048 ゲーム 作り方の芯は、数を足す処理ではなく一列をたたむ決まりにあります。2・2・4 と並んだ列を左へ寄せると、残るのは 8 ではなく 4・4 です。01番は一度合わさったタイルをその手のあいだ固定し、03番はスワイプの軌跡が長く通った軸で向きを決め、07番は空きマスに落ちる 2 と 4

2048 は数を足すゲームではなく列をたたむゲームです — 一手を決めるのは一列の圧縮の決まりです

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

十六マスを一度に計算しようとすると、上下左右の四方向ぶん、同じループを四回書くことになります。ここの九つの場面では、一列を壁の側へたたむ関数 compressLine 一つで画面のタイルを寄せ(07番と 09番の探索だけは別の slideValues で数を寄せます)、上・右・下へ寄せるときはマス番号の表のほうを回して、その向きの壁が左に来るようにしています。たたみ方は、空きマスを抜いてから壁に近い側から順に同じ数の隣どうしを一回ずつ合わせるだけですが、この決まりから条件を一つ落とすと盤全体の結果が変わります。

ロックの条件を一つ外すと何が変わるか

ロックとは、その手の中で合体して生まれたタイルに印を付け、同じ手のあいだはもう合わさらないようにする仕組みです。空きがおよそ 8 マスに 3 マスあるシード固定の盤 2,000 面で、実際に何かが動く 7,978 手をロックありとなしで一回ずつ打ち比べると、結果が分かれた手は 661 手(8.3%)、そういう手を一つ以上含む盤は 563 面(28%)でした。およそ 12 手に 1 手の割合なので、手で何列か寄せて試すだけでは見落としやすいと考えられます。

このサイトのほかの盤ゲームとのちがい

同じゲーム分類の三目並べの盤は相手がいちばん困る手を打つと仮定してマスに点を付け、自分で道を探すスネークは餌までの経路を探します。2048 の相手は人ではなく空きマスに落ちてくる 2 か 4 なので、07番と 09番は最悪の場合に身構えるのではなく、起こりうる結果を確率で重みづけして平均します。ピースどうしがかみ合う仕組みはジグソーパズルの場面で別に扱っています。

2048 ゲーム 攻略を自動プレイに置きかえると、何を点数にすればよいのでしょうか?

2048 ゲーム コツとしてよく挙がるのは、空きマスを残しておくこと、大きな数を一方の端へ順に並べること、隣どうしの数の差を小さく保つことの三つです。07番と 09番はこの三つをそのまま点数の三つの項にして、空きマス × 2.7 + 並びの単調さ × 1.0 + なめらかさ × 0.1 で盤を評価します。ただ、点数を付ける前に一手の動きそのものがコードで決まっていなければならないので、まず寄せる順番から見ていきます。

一手が通る四つの段階

数が動く前に、まず読む順番が決まります。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 になります。決まりの説明ではなくコードの単位で 2048 を知りたいなら、この表の五行でひととおり足ります。

この盤が合う画面

置き場所としていちばん多いのは、社内イベントページやポートフォリオのミニゲームです。決まりは一行の説明で済み、盤は 480:300 の比率を保つ箱に収まるので、段落のあいだにも差し込みやすい形です。盤の横にヒントを出すイベントページなら、07番の四本の向きの棒をそのままヒント欄に使えます。

学習アプリでは 05番の段階の色を進み具合のタイルに、08番の崩れる盤をゲーム終了と再挑戦の画面に流用できます。09番は入力なしでひとりでに進むのでランディングページのファーストビュー向きで、訪問者が盤に触れた瞬間に自動デモは手を止め、その盤は触った人のものになります。

2048 ゲーム 作り方 9種 — 一列の圧縮で寄せる移動と合体したタイルのロック・移動したマス数に合わせて伸び縮みするスライド・斜めのスワイプも一つの軸に決める方向判定・合体したタイルがふたたび分かれる巻き戻し・2のべき乗の段階で選ぶ色と文字サイズの合わせ込み・合体で飛び散るかけらと連続合体の揺れ・空きマスに落ちる数まで計算する自動プレイ・合わせられる組がなくなると崩れ落ちる盤・ファーストビューでひとりでに進む 2048 の盤の場面

2048 javascript code として中を開くと、各場面は script 一つを含む index.html と style.scss だけでできていて、ライブラリ・Web フォント・画像ファイルを使わずに 2D キャンバスへ描いています。

2秒ぶんの24コマを隣どうし比べた値

各場面を 480×300 で 2 秒間描いて 24 コマ撮り、隣り合うコマの差 23 個から出したのが次の表です。

場面 動いた面積 動いたコマ 強さの平均
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 秒ごとに一手を打ち、右下の小さな盤がその手の回転回数ぶん時計回りに回ってから戻り、右側の列比べでは 1.2 秒ごとに同じ「2 2 4 _」を寄せて、ロックありは「4 4」(+4)、ロックなしは親三つの「8」(+8)で終わります。合体したタイルは大きさのばね(硬さ 420・減衰 18)で 1.159 倍までふくらみ、0.279 秒で ±2% の内側に落ち着きます。

一列の圧縮で寄せる移動と合体したタイルのロック
新しいタブで開く

01 · 一列の圧縮で寄せる移動と合体したタイルのロック — 計測 累積面積 30.423% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 秒に 2 マス目から 1 マスごとに 0.04 秒を足したもの、進み具合は u² で加速するので、3 マス動くタイルは 1 秒に 25 マスの速さで着き、そのとき 1.3 倍に伸びています。横の軸は伸びの逆数をとるので、一周のあいだ横と縦の倍率の積は 1 から最大でも 1.1e-16 しかずれず、着地では進む軸が 0.843 倍までつぶれてから、ばね(600・16)で 0.242 秒かけて戻ります。右の小さな盤は同じ入力 9 個を待ち行列なしで受け、そのうち 4 個を捨てます。

移動したマス数に合わせて伸び縮みするスライド
新しいタブで開く

02 · 移動したマス数に合わせて伸び縮みするスライド — 計測 累積面積 32.073% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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斜めのスワイプも一つの軸に決める方向判定

モバイル Web ゲームのスワイプ操作エリア向けの判定で、始点と終点だけから長さ・速さ・角度を測り、45° の境目から 10° 以内か 24px 未満の軌跡は捨て、それでも 8px 以上で 1 秒に 450px より速いはじきは受け入れます。デモの軌跡 7 本のうち 52° と 228° は境目から 7° と 3° なので斜めとして捨て、16px・140ms は短いとして捨て、18px・30ms のはじきは左として受け入れて、一周で受け入れ 4・捨てる 3 になります。軌跡は途中で横へ曲がって描かれますが、判定に使うのは両端を結んだ点線の直線だけです。

斜めのスワイプも一つの軸に決める方向判定
新しいタブで開く

03 · 斜めのスワイプも一つの軸に決める方向判定 — 計測 累積面積 29.258% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 に戻って盤は開始盤と同じになります。すべっている途中で戻したときはまず今の盤を置いてから巻き戻すので同じタイルが二枚見えることはなく、盤にフォーカスがあれば Backspace でも戻せます。

合体したタイルがふたたび分かれる巻き戻し
新しいタブで開く

04 · 合体したタイルがふたたび分かれる巻き戻し — 計測 累積面積 36.685% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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)で決め、段階 1・3・5・7・9・11・13 に置いた基準色 7 つのあいだを oklab の三つの値ごとに線形に混ぜ、見える段階は 0.15 秒ごとに倍になる値を追って一段の差の 95% を 0.214 秒で詰めます。数字はマス幅の半分の大きさで始まり、測った幅がマスの 80% を超えるとその比率で縮むので、3 桁は 92%、4 桁は 69% で描かれ、86px のマスでは 4 桁が 29.7px の文字になります。上下の二本の帯は oklab で混ぜた色と sRGB で混ぜた色を並べたもので、このパステルの基準色のあいだでは RGB のどのチャンネルでも差が最大 1 なので、帯は色の差を見せるというより二つの計算式を並べて比べる役目と考えられます。

2のべき乗の段階で選ぶ色と文字サイズの合わせ込み
新しいタブで開く

05 · 2のべき乗の段階で選ぶ色と文字サイズの合わせ込み — 計測 累積面積 24.76% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 個のかけらを 1 秒に段階 × 34 × 0.55〜1 px の速さで飛ばし、重力 520 と空気抵抗 2.4 で引き戻します。最初の左寄せでは合体 7 回が一度に重なってかけら 63 個・揺れ 11.2px になり、揺れは合体一つごとに 1.6px を残りの強さへ足してから e^(−6t) で減って、1 秒に 16 回ふるえながら 0.116 秒ごとに半分になります。横へ 1 秒に 170px で飛び出したかけらは、抵抗のせいで寿命 0.8 秒のあいだに 60.1px しか進まず、抵抗がなければ 136px 進みます。

合体で飛び散るかけらと連続合体の揺れ
新しいタブで開く

06 · 合体で飛び散るかけらと連続合体の揺れ — 計測 累積面積 29.176% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 秒に崩れ始めて、0〜0.3 秒ずつ遅れた 16 枚が重力 1600px/s² で落ち、はね返り係数 0.42 で合わせて 56 回弾んでから 2.25 秒に止まります。そのあと最初の盤のタイルが下の列から 0.035 秒間隔で 120px 上から落ちてきて、3.588 秒に新しい盤が始まります。

合わせられる組がなくなると崩れ落ちる盤
新しいタブで開く

08 · 合わせられる組がなくなると崩れ落ちる盤 — 計測 累積面積 34.712% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 手(ROUND_MOVES)を打つと崩れて最初からやり直し、デモは 6.646 秒に点数 368・いちばん大きいタイル 256 で一周を終えて、8.946 秒に最初の盤へ戻ります。ROUND_MOVES を置いたのは探索が行き止まりをうまく避けるためで、これがないと盤は 40 秒で 95 手を打ってもまだ詰まりません。

ファーストビューでひとりでに進む 2048 の盤の場面
新しいタブで開く

09 · ファーストビューでひとりでに進む 2048 の盤の場面 — 計測 累積面積 25.374% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 はマス番号の表を時計回りに一回まわし、元の 3 − c 行 r 列のマスを新しい r 行 c 列に置きます。TURNS の回数は左 0・上 3・右 2・下 1 なので、上へ寄せるときは、まわした表の最初の列を左から読むと元の右端の列を上から読んだ 3, 7, 11, 15 になります。四回ぶん用意するのは読む順番だけで盤そのものは動かさないため、画面のタイルについては、ロックのような決まりは compressLine の一か所にまとまります。ただし 07番と 09番の探索は compressLine を使わず、数だけの配列を扱う別の slideValues で寄せていて、こちらは自前の locked でロックを持っているので、決まりを変えるときはそちらもいっしょに直してください。01番の右下でばね(260・24)で TURNS × 90° まで回ってから 0° に戻る小さな盤は、この表を目に見える形にしたものです。

一つの扇形が受け入れる割合とはじきの基準

03番は角度を 90° で割った余りが 45° から 10° 以内なら捨てるので、90° の扇形一つのうち受け入れるのは 70° 分、(90 − 2 × 10) ÷ 90 = 77.8% です。24px に届かない軌跡も 1 秒に 450px 以上なら数に入り、その速さで 24px を引くなら 24 ÷ 450 × 1000 = 53.3ms 以内に引き終えることになります。シードで作った軌跡 1,000 本(4〜120px・30〜260ms・角度はランダム)を入れると、受け入れ 627 本(まっすぐ 624・はじき 3)、短い 180 本、斜め 193 本でした。スワイプで動かす別の画面は画像スライダーの記事で扱っています。

空きマスの期待値と三つの項の点数

chanceNode は空きマスごとに 2 を置いた結果に 0.9、4 を置いた結果に 0.1 の重みを掛けて足し、空きマスの数で割ります。空きマスがなければそのまま次の自分の手の層へ進み、いちばん下の層では evaluate が盤に点数を付けます。07番の最初の盤は空き 4・単調さ −3・なめらかさ −20 なので、4 × 2.7 − 3 × 1.0 − 20 × 0.1 = 10.8 − 3 − 2 = 5.8 です。二層では末端の盤 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」になり、手で軽く試すだけではこの不具合が隠れてしまいます。直すのは compressLine の !(lock && last.merged) という条件一つです。

すべっている0.2秒のあいだに届くスワイプ

02番のデモ入力 9 個のうち 4 個は、前の手がすべり始めて 0.1 秒後に届きます。待ち行列が 1 つ(INPUT_QUEUE_MAX 1)の盤は 9 手すべてを打って点数 48 に達し、待ち行列のない盤は 0.3・1.0・2.3・3.0 秒の二つ目の入力を捨てて 5 手しか打てず、点数 16 で止まります。最後の盤の形もちがうので、続けて速くスワイプする人には入力を飲みこまれたように感じられるかもしれません。待ち行列を 1 つに限っていっぱいなら捨てるのは、指を離したあとに盤が勝手に何手も進まないようにするためと考えられます。

四桁の数がマス幅の115.9%になる

このフォントの数字 0〜9 は太さ 800 でどれも同じ幅なので、数の幅は桁数だけで決まります。文字の大きさをマス × 0.5 に固定すると 4 桁は 4 × 0.5796 × 0.5 でマスの 115.9% になり、46px の盤のマスから 7.3px、86px のマスから 13.7px はみ出します。05番フォルダーの index.html で fitDigits が選ぶ scale を 1 に固定し、大きなマスではみ出したタイルを自分で見比べたいときは、パスワード j4u24xfe で圧縮ファイルを開いてください。合わせ込みを残しておけば 3 桁からマス × 0.8 を超えた分だけ縮むので、150px のマスでも 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 にしてもはじきの基準は 1 秒に 450px のままなので、106.7ms 以内に引いた短い軌跡がはじきとして入ることが増え、デモの軌跡 7 本は短い二本が 16px と 18px なので結果は変わりません。

矢印キーでも同じ一手が打てます — 動いた結果を読み上げる文・一時停止ボタン・動きを減らす設定でアクセシビリティを守ります

盤にフォーカスがあるときだけ効く矢印キー

盤の箱は tabindex="0" と role="application" を持ち、名前は「2048 の盤 — 押してから矢印キーかスワイプで一手」で、keydown は Alt・Ctrl・Meta が押されていないときだけ矢印キーの preventDefault() を呼びます。盤の外で押した矢印キーは九つの場面のどれでも既定の動きのままなのでページのスクロールは邪魔されず、04番は名前に「・Backspace で戻す」が加わり、05番は「上・右の矢印で段階を上げ、下・左で下げる」と案内します。状態の行は aria-live="off" で始まって最初の入力のあと polite に切り替わり、一手ごとに「{d}・合体 {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 は乱数の状態をクロージャーの中に持っているので、次に落ちるタイルまでそろえるにはその状態を保存できる形で外に出す必要があると考えられます。04番の巻き戻しにも同じすき間があり、undo04 はタイルと点数を戻しても g.rng は戻さないので、同じ向きにもう一度打つと別の新しいタイルが落ちることがあります。

スマートフォンで盤をスワイプするとページもスクロールしますか?

touch-action: none が効くのは盤にフォーカスがあるあいだ(.cf__board:focus)だけなので、一度タップしてフォーカスを移せばスワイプは盤へ、フォーカスがなければタッチのスクロールはページへ行きます。スワイプは pointerdown と pointerup の座標と時間差を 03番と同じ judgeSwipe に入れるので、九つの場面が一つの決まりで判定します。スクロール位置そのものを計算に使う画面はPDF ビューアーの記事を参考にしてください。ポインター入力のイベントの順番は MDN Pointer events に、色を混ぜるときに使う色空間は MDN oklab() にまとまっています。

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

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