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

css snake game 9パターン — キー操作なしで経路を探して餌を食べるヘビ

css snake game を開くと、キーに触れる前にヘビが餌へ向きを変える — 01番の最短経路と02番のハミルトン閉路がその一手を計算で決める。九つの盤はどれも、人が操作しなくてもヘビが次の一手を自分で選んで動きます。

九つのヘビゲームを画面でそのまま動かしてみる

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

css snake game 9パターン — 経路探索・閉路・補間でヘビが自分で動く

盤の絵はキャンバスの計算で描き、画面の枠は CSS が受け持ちます。マスとヘビと餌を塗って次の一手を決める仕事は index.html の JavaScript の担当で、CSS が決めるのは舞台の大きさと、その上に重なる状態表示・キャプション・ボタン、キーボードフォーカスの輪郭線です。舞台の幅は min(92vw, calc(160vh - 12.8vw))、比率は480:300、角の丸みは16pxです。

キャンバスの内部解像度は480×300に devicePixelRatio(最大2)を掛けた値なので、ウィンドウの大きさが変わっても盤のマス数は変わりません。マスの数は makeBoard(GRID_N) が決め、行数が16なら1マス18.75pxで横25×縦16の400マス、12なら25pxの228マス、8なら37.5pxの96マスになります。

時間は16msの固定ステップで進みます。場面ごとにステップをためて TICK_MS に届いたとき論理ティックを一つ回し、絵は1フレームに一度だけ描きます。乱数は種を固定した lcg だけなので餌の位置は毎回同じで、01・02・03・05・06・07・08の七つは隠しウォームアップのティックを先に回して中盤の状態から始まります。01は長さ54、02は91/96マス、07は長さ11、08は得点0070・長さ10で、04と09は最初の状態がそのまま見えます。

九つはヘビが何を計算するかで分けてあります。01・02は次の一マスを選ぶ判断、03・04は同じ移動を胴体としてどう描くかの二通り、05・07・09はマスや体に値を塗る場面、06は盤の形を変える場面、08は餌を食べた瞬間の反応を足した場面です。

どこから開くかは使い道で決めると早く済みます。自動プレイのデモやAI対戦のプレビューなら01、終わりのない待機画面なら02、動きのなめらかさが大事なモバイル画面なら03、自由に動く虫のキャラクターなら04です。プレイ履歴の可視化は05、壁のないマップは06、スキンショップのプレビューは07、得点の報酬を見せる HUD は08、経路探索の解説画面は09から始めてください。

人が方向キーで操作する側が先に要るなら、HTML CSS JS ゲーム 9種 — 電卓・タイマー・パズルのコピペやCSS ゲーム 作成 — CSSだけで動くミニゲーム9選が向いています。この記事は、人が触れなくてもヘビが自分で道を探す側を扱います。

snake game using html css and javascript を技法別のコードで見る

各項目のコードは index.html の <script> から連続した行をそのまま切り出したもので、一つの断片は25行を超えません。snake game using html css and javascript と聞くと方向キーの入力や衝突判定から探しがちですが、ここの九つは入力の代わりに、判断と描画の計算をコードで見せます。

01幅優先探索で餌までの最短経路を取り、行き止まりなら尾を追うヘビ

毎ティック、餌から幅優先で全マスの距離を埋め、頭がたどる最短の道を取り出します。simulate がその道を最後まで歩いた仮の体を作り、tailReachable が仮の頭から仮の尾へ届くかを確かめて、届かなければその道を捨てて(ピンクの線)尾を追います。キャプチャ24枚で測った値は累積面積49.8%・平均強度114.0・動いたフレーム23/23でした。

幅優先探索で餌までの最短経路を取り、行き止まりなら尾を追うヘビ
新しいタブで開く

01 · 幅優先探索で餌までの最短経路を取り、行き止まりなら尾を追うヘビ — 計測 累積面積 49.751% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TICK_MSGRID_NSAFE_CHECKREJECT_LIFE
        if (g.food >= 0) {
            const dist = distField(b, g.food, blocked, g.wrap);
            const path = pathFrom(b, dist, head, g.wrap);
            plan.path = path;
            if (path.length > 0) {
                if (!safeOn) {
                    plan.next = path[0];
                    return plan;
                }
                const v = simulate(g.body, path);
                if (tailReachable(b, v, g.wrap, plan.trap)) {
                    plan.next = path[0];
                    return plan;
                }
                plan.rejected = path;
            }
        }

02盤全体を一周するハミルトン閉路で盤面を最後まで埋めるヘビ

盤の全マスを一度ずつ通る固定の閉路に ord で番号を振り、隣のマスのうち餌に近くて尾を追い越さないものがあれば、閉路の順番を飛ばして近道します。始まりは隠しティックで91/96マスを埋めた状態で、約10ティックで盤が満杯になり、14ティック止まってから新しい盤が開きます。累積面積は61.4%、平均強度は90.0で、動いたフレームが17/23なのは、満杯の盤が止まっている間は画面が変わらないからです。

盤全体を一周するハミルトン閉路で盤面を最後まで埋めるヘビ
新しいタブで開く

02 · 盤全体を一周するハミルトン閉路で盤面を最後まで埋めるヘビ — 計測 累積面積 61.428% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRID_NSHORTCUT_MARGINTICK_MSWARM_FILL
            let best = cyc[(ord[head] + 1) % N];
            let bestD = 1;
            for (let d = 0; d < 4; d += 1) {
                const nb = neighborCell(b, head, d, false);
                if (nb < 0 || occupied[nb] === 1 || nb === tail) {
                    continue;
                }
                const dc = cd(head, nb);
                if (dc > bestD && dc <= foodD && dc < tailD - SHORTCUT_MARGIN) {
                    best = nb;
                    bestD = dc;
                }
            }
            if (bestD > 1) {
                cuts += 1;
                jump = 6;
            }
            moveSnake(g, best);

03ティックの間を補間し、マス単位でなくピクセル単位で滑る胴体

ロジックは100msのティックでマスを一つずつ動かし、描く位置 px・py は16msのステップごとに論理マスの中心へ BODY_LERP 0.35の割合だけ近づきます。タップすると補間が切れて胴体がマスの中心へ直接貼りつくので、クリーム色の枠の四角(論理マス)と太い胴体を見比べられます。この場面の値は累積面積18.5%・平均強度78.6・動いたフレーム23/23です。

ティックの間を補間し、マス単位でなくピクセル単位で滑る胴体
新しいタブで開く

03 · ティックの間を補間し、マス単位でなくピクセル単位で滑る胴体 — 計測 累積面積 18.462% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BODY_LERPTICK_MSTUBE_FRACTRAIL_LEN
                // 描く位置が論理マスの位置を BODY_LERP の割合ずつ追いかける
                for (let i = 0; i < g.body.length; i += 1) {
                    const tx = cellX(b, g.body[i]);
                    const ty = cellY(b, g.body[i]);
                    if (smoothOn) {
                        px[i] = lerp(px[i], tx, BODY_LERP);
                        py[i] = lerp(py[i], ty, BODY_LERP);
                    }
                    else {
                        px[i] = tx;
                        py[i] = ty;
                    }
                }

04マスなしで頭に距離制約で引かれる鎖状の胴体のヘビ

マスはなく、頭だけが ang の向きへ直接動きます。頭の後ろに続く節(最初は25節で、餌を食べると増えます)は、前の位置との差(慣性)に DAMP 0.97を掛けて進めたあと、隣の節との間隔を9pxへ戻す制約を4回解きます。累積面積は7.7%で九つのうち最も小さく、平均強度は50.4、動いたフレームは23/23です。

マスなしで頭に距離制約で引かれる鎖状の胴体のヘビ
新しいタブで開く

04 · マスなしで頭に距離制約で引かれる鎖状の胴体のヘビ — 計測 累積面積 7.728% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SEG_GAPSTEER_RATESEG_COUNTFOLLOW_ITER
                // 距離制約: 隣の節との間隔を SEG_GAP に戻す
                for (let it = 0; it < FOLLOW_ITER; it += 1) {
                    for (let i = 1; i < count; i += 1) {
                        const dx = px[i] - px[i - 1];
                        const dy = py[i] - py[i - 1];
                        const d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
                        const k = ((d - SEG_GAP) / d) * FOLLOW_STIFF;
                        if (i === 1) {
                            px[i] -= dx * k;
                            py[i] -= dy * k;
                        }
                        else {
                            px[i] -= dx * k * 0.5;
                            py[i] -= dy * k * 0.5;
                            px[i - 1] += dx * k * 0.5;
                            py[i - 1] += dy * k * 0.5;
                        }
                    }
                }

05通ったマスに熱が残って冷めながら軌跡を描くヘビ

頭が新しいマスに着くとそのマスの heat に1を足し(上限1.4)、16msのステップごとに全マスへ0.988を掛けて冷まします。掛け算一つなので半減期は約57ステップ(0.92秒)で、heatColor が値を7段階に区切って紫からクリーム色まで塗ります。累積面積22.8%・平均強度62.6・動いたフレーム23/23と測れました。

通ったマスに熱が残って冷めながら軌跡を描くヘビ
新しいタブで開く

05 · 通ったマスに熱が残って冷めながら軌跡を描くヘビ — 計測 累積面積 22.775% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HEAT_DECAYHEAT_GAINGRID_NPALETTE_STEPS
    function heatColor(h, steps) {
        let v = clamp(h, 0, 1);
        if (steps > 0) {
            v = Math.floor(v * steps) / steps;
        }
        const f = v * (RAMP.length - 1);
        const i = Math.min(RAMP.length - 2, Math.floor(f));
        return mix(RAMP[i], RAMP[i + 1], f - i);
    }

06壁を抜けると反対側から出て、境目で体が二つに描かれるヘビ

隣のマスと距離は剰余で計算するので盤から壁が消え、節どうしの移動は wrapDelta が短いほうの向きを選ぶため、盤の端から反対側へ跳ねません。絵は節を3×3か所に複製して盤の四角で切り抜き、継ぎ目をつなぎます。タップすると複製が切れて体が片側だけ見えます。測った値は累積面積20.9%・平均強度95.3・動いたフレーム23/23です。

壁を抜けると反対側から出て、境目で体が二つに描かれるヘビ
新しいタブで開く

06 · 壁を抜けると反対側から出て、境目で体が二つに描かれるヘビ — 計測 累積面積 20.9% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WRAP_MODEGRID_NSEAM_GHOSTSTICK_MS
    function wrapDelta(a, c, n, wrap) {
        let d = c - a;
        if (wrap) {
            if (d > n / 2) {
                d -= n;
            }
            if (d < -n / 2) {
                d += n;
            }
        }
        return d;
    }

07胴体に沿ってうろこ模様と明るさの波が流れるヘビの皮

節の番号 k に sin(k·SCALE_FREQ − phase) の波を掛け、hash2 のノイズを NOISE_AMP 0.28の割合で混ぜてうろこの明るさを決めます。phase はステップごとに0.07ずつ増えるので、波が胴体を伝って流れます。節一つがうろこ4枚で、背景の円360個も同じ波を斜めに受けるため、盤全体がうろこの壁紙になり、累積面積41.1%・平均強度59.9・動いたフレーム23/23でした。

胴体に沿ってうろこ模様と明るさの波が流れるヘビの皮
新しいタブで開く

07 · 胴体に沿ってうろこ模様と明るさの波が流れるヘビの皮 — 計測 累積面積 41.128% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SCALE_FREQWAVE_SPEEDNOISE_AMPSKIN_MIX
        // うろこ一枚の明るさ: 番号にかけた正弦波 + マスごとのハッシュノイズ
        function scaleLight(k, seedA, seedB) {
            const wave = 0.5 + 0.5 * Math.sin(k * SCALE_FREQ - phase);
            const noise = (hash2(seedA, seedB, 455) - 0.5) * 2 * NOISE_AMP;
            return clamp(wave * (1 - NOISE_AMP * 0.5) + noise * 0.5 + 0.25, 0, 1);
        }

08餌を食べた瞬間に重力を受ける粒子が弾け、得点の数字が跳ね上がるヘビ

食べた瞬間に餌のマスから粒子28個が放射状に飛び、GRAVITY 0.075で落ちながら寿命が0.017ずつ減って、およそ0.56〜0.94秒で消えます。得点は4桁で、変わった桁ごとに上向き-4.2の速度をもらい、ばねで元の位置へ戻ります。測定値は累積面積20.1%・平均強度100.2・動いたフレーム23/23です。

餌を食べた瞬間に重力を受ける粒子が弾け、得点の数字が跳ね上がるヘビ
新しいタブで開く

08 · 餌を食べた瞬間に重力を受ける粒子が弾け、得点の数字が跳ね上がるヘビ — 計測 累積面積 20.14% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BURST_COUNTGRAVITYLIFE_DECAYDIGIT_STIFF
        function burst(x, y) {
            for (let i = 0; i < BURST_COUNT; i += 1) {
                const a = rnd() * PI2;
                const sp = 0.9 + rnd() * 2.7;
                sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 1.2, life: 0.6 + rnd() * 0.4, c: BURST_COLORS[i % BURST_COLORS.length] });
            }
            rings.push({ x: x, y: y, r: 4, life: 1 });
        }

09餌から広がる距離場の波と、その斜面を下っていくヘビ

餌から幅優先で全マスの距離を埋め、その値で波の色を塗って、ヘビは頭の隣のうち最も低い値(lowestNeighbor)へ下っていきます。方向キーは、餌の隣が空きマスのときだけ餌を一マス動かし、タップは波を止めて同じ値のマスが帯になって残ります。盤全体を距離の色で覆うため累積面積は73.6%で九つのうち最大、平均強度は67.0、動いたフレームは23/23です。

餌から広がる距離場の波と、その斜面を下っていくヘビ
新しいタブで開く

09 · 餌から広がる距離場の波と、その斜面を下っていくヘビ — 計測 累積面積 73.622% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WAVE_SPEEDGRID_NDIST_COLOR_STEPSTICK_MS
        function refreshField() {
            const blocked = new Uint8Array(b.n);
            for (let i = 0; i < g.body.length - 1; i += 1) {
                blocked[g.body[i]] = 1;
            }
            if (g.food >= 0) {
                dist = distField(b, g.food, blocked, false);
            }
            stepTo = lowestNeighbor(b, dist, g.body[0], false);
        }

値がヘビの印象を変えるところ — ティック間隔・盤の大きさ・胴体の補間

値を一つ変えるだけでヘビの見え方が大きく変わる場所は三つあります。この節の数字はブラウザのキャプチャではなく、ソースの関数をノードで直接呼んで数えた値なので、下の表のレンダー測定とは別に読んでください。

ティック間隔を90msから50msに縮めると同じ10秒で餌を3倍食べる

01の TICK_MS だけを90から50に変え、同じ状態から10秒回すと、90msでは長さが54から57に、50msでは54から63に伸びます。餌が3個から9個に増えた計算です。捨てた道はどちらの設定でも2件で、判断はそのまま、速さだけが変わります。

盤を8行から12行に広げると埋め尽くすのに5.9倍かかる

02の GRID_N を8から12へ上げると96マスが228マスになります。最初から満杯にするには1,818ティックではなく10,665ティックが必要で、60msのティックに換算すると109秒が640秒です。近道は171回から811回に増えますが、閉路が一周する長さが延びる影響のほうが大きく出ます。

シードを六つ回してもすべて最初から満杯になり、かかったティックは1,818〜2,113でした。近道を切ると(マージン999)2,311ティックで27%増え、SHORTCUT_MARGIN を0・2・6にすると1,948・1,818・1,885ティックなので、初期値の2が最も短くなりました。

補間の割合を0.35から0.12に下げると胴体が一マス遅れてくる

03の BODY_LERP が0.35のとき、描いた節が論理マスの中心から遅れる平均距離は8.5px、マスの幅(25px)の0.34マスです。0.12に下げると26.8px、1.07マスとなり、胴体が頭よりほぼ一マス遅れてついてきます。1にすると px[i] = tx と同じになり、補間のない絵と同じものが出ます。

九つをレンダーして測った値は次の表のとおりです。

項目 累積面積 平均強度 動いたフレーム
01 安全検査つき自動操縦 49.8% 114.0 23/23
02 ハミルトン閉路 61.4% 90.0 17/23
03 補間する胴体 18.5% 78.6 23/23
04 鎖状のひも 7.7% 50.4 23/23
05 熱の軌跡 22.8% 62.6 23/23
06 輪環 20.9% 95.3 23/23
07 うろこの皮 41.1% 59.9 23/23
08 餌の爆発 20.1% 100.2 23/23
09 距離場の波 73.6% 67.0 23/23

どこで壊れるか — 盤が埋まるとき・壁抜け・動きを減らす設定

検査を切ると同じシードで600秒に6回閉じ込められる

01の SAFE_CHECK を入れたまま600秒(長さ209、捨てた道44件)回すと閉じ込めは0回で、切ると同じシードで6回になります。ただし検査は最初の餌を食べたあとに尾へ届くかまでしか見ず、その次の餌は先読みしません。0回はこのシードと600秒で出た実測で、閉じ込められないことの証明ではありません。検査なしで距離場だけをたどる09も600秒で6回閉じ込められ、03と08は0回でした。

閉路は行数が偶数のときだけ閉じる

02の閉路は一番上の行を横切り、残りの行をジグザグに回って左の列を上がるように作られます。7行の盤(11×7=77マス)に変えて閉路を作ると、一か所が隣り合わないマスへつながり、ゲームは動き続けるものの頭がそこで飛びます。行数が偶数の盤を使う理由です。

制約の反復が足りないと鎖が伸びる

04の FOLLOW_ITER が4なら隣の節との間隔は平均9.04px・最大10.46pxで、1に減らすと平均9.18px・最大12.67pxと、目標の9pxの1.4倍近くまで伸びます。鎖どうしがぶつかる検査はなく、体が自分の体を通り抜けても止めません。

得点欄は4桁なので10000で0000に戻る

08の digits() は末尾の4文字だけを切り出すため、得点が10000になるとマスには0000が表示され、状態の行には10000と出ます。得点の上限を決めるか桁数を増やすかは、使う側の判断です。

動きを減らす設定では入力があるまで画面が変わらない

prefers-reduced-motion がオンのとき、九つとも requestAnimationFrame のループを始めず、1,400ms先へ進めた絵を一枚だけ描きます。02は満杯の盤をそのまま残し、タップでだけ新しい盤が開きます。

ここまで読んで値を自分で書き換えてみたくなったら、下のカードから圧縮ファイルを受け取ってください。開くときのパスワードは 9tzpwtwu です。場面ごとに HTML と SCSS が一組ずつ、それにリアクト版が入っています。

変形三つとアクセシビリティ

名前 値 感じ
ティック間隔を変える 01 TICK_MS 90 → 50 同じ状態から10秒回すと、90msは長さ54→57、50msは54→63で、餌を3倍食べます。捨てた道はどちらも2件なので、判断は同じで速さだけが変わります。
盤の大きさを変える 02 GRID_N 8 → 12 盤が96マスから228マスに広がると、最初から埋め尽くすのに1,818ティックではなく10,665ティック(約5.9倍)かかります。60msのティックなら109秒が640秒になります。
胴体の補間の強さを変える 03 BODY_LERP 0.35 → 0.12 描いた胴体が論理マスの中心から遅れる平均距離は、8.5px(1マス25pxの0.34マス)から26.8px(1.07マス)に広がり、胴体が頭よりほぼ1マス遅れて引かれます。

三つの値も、上の値の節と同じくソースの関数をノードで呼んで数えたものです。

操作は九つのファイルが同じ骨組みを共有しています。場面ごとに <button> が一つあり、キャンバスは tabindex="0" と aria-label を持ちます。Enter・Space・ポインターの押下は場面のタップ動作につながり、preventDefault で Space がページをスクロールさせないようにしています。方向キーを使うのは04と09だけで、この二つでしか方向キーの既定動作を止めません。残りの七つはキーを横取りしないので、Tab でキャンバスからボタンへ移れます。状態表示は aria-live="polite" で、長さ・捨てた道・得点が変わったときだけ更新されます。

prefers-reduced-motion がオンのときは、ボタンが 次の場面 に変わり、押すたびに900ms先へ進めます。Enter と Space は九つとも、その場面のタップ動作を一回実行してから128ms進めて描き直します。

snake game code html css js はライブラリなしで足りるか?

snake game code html css js を探す人が見るソースにしては珍しく、この記事の九つのファイルにはライブラリも CDN も WebGL もなく、Math.random・Date・fetch・localStorage も使っていません。経路探索・閉路・補間・粒子はすべて requestAnimationFrame とキャンバス2Dだけで動くので、ヘビが自分で動く場面までなら素の JavaScript で足ります。

足りないのは、ゲームをサービスとして仕上げる部分です。最高記録の保存や効果音はなく、人が方向キーで操作する入力も04と09の簡単な形だけです。ブロックが壊れる反射角まで進むならjavascript ブロック崩し 反射角で変わる9パターンが、ブロックを積むルールまで進むならテトリス HTML コード — キャンバスで組む9ステップが次の一歩です。重力と衝突の物理がもっと知りたければ、跳ねるボール アニメーション 9選 — 衝突と重力の物理計算を見てください。

snake game html css js github のコードを使い回す前によくある質問

snake game html css js github のリポジトリにこのソースをそのまま上げてもいい?

外部のコードを引いていないので、上げるときについてくる依存の一覧がありません。ファイルごとに index.html 一つへスタイルとスクリプトが入っているので、貼れば動き、乱数が種で固定されているため同じ画面が再現されます。ライセンスの書き方やリポジトリの決まりは、上げる側で別に決める必要があります。

餌の位置を毎回変えるにはどこを書き換える?

01は newGame(b, false, 455101) の最後の数字が lcg の種で、02・03・05・06も同じ形なので、数字を変えれば別の盤になります。毎回ランダムにしたければその場所に入れる値を自分で作る必要があり、そうすると同じ画面は再現されなくなります。

盤をもっと大きくすると重くなる?

02は盤が大きくなると満杯にするティックが5.9倍に増え、1ティックで回るのは閉路の位置の比較と隣の四マスの検査だけです。01は毎ティックマスの数だけ幅優先探索をやり直すので、マスが増えるほど1ティックの仕事も増えると考えられます。大きくした盤でフレーム時間は測っていません。

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

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