GAME

ブラウザ版ソリティアの実装を解説

カードの初期配置から場札・組札への移動、ドラッグ操作、Undo、ヒント、自動完成まで、ゲームページの実装を順番に紹介します。

ソリティアは、7列の場札に赤と黒を交互に並べながら、すべてのカードを4つの組札へ移動するゲームです。カードを表示するだけでなく、移動できる組み合わせの判定、裏返しカードの公開、山札の循環、操作履歴の保存など、状態管理を学べる題材になっています。

今回のゲームは、PHPのページにHTML、CSS、JavaScriptを記述し、ゲームの進行はブラウザ上で完結させています。カードの状態をJavaScriptの配列で管理し、DOMはその状態を描画する役割に分けています。

52枚のカードをオブジェクトで表す

カードは、マーク、数字、表向きかどうか、カードを識別するIDを持つオブジェクトにしています。マークは0から3、数字はAを1、Kを13として保存すると、移動条件を数値の比較で書けます。

カードを作成するJAVASCRIPT
const SUITS = ['♠', '♥', '♣', '♦'];
const deck = [];

for (let suit = 0; suit < 4; suit++) {
    for (let rank = 1; rank <= 13; rank++) {
        deck.push({
            s: suit, r: rank, up: false,
            id: `${suit}-${rank}`
        });
    }
}

upfalseのカードは裏向きで、場札の一番上に置かれたカードだけを最初から表向きにします。idを持たせておくと、ドラッグ中のカードや表示要素を元のデータと対応させやすくなります。

シャッフルして7列へ配る

デッキを作ったら、Fisher-Yates法で後ろからカードを交換してシャッフルします。その後、1列目に1枚、2列目に2枚というように、7列へ順番に配ります。各列の最後のカードだけを表向きにすると、クロンダイクの初期盤面になります。

シャッフルと初期配置JAVASCRIPT
for (let i = deck.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [deck[i], deck[j]] = [deck[j], deck[i]];
}

tableau = Array.from({ length: 7 }, () => []);
for (let column = 0; column < 7; column++) {
    for (let row = 0; row <= column; row++) {
        const card = deck.pop();
        card.up = row === column;
        tableau[column].push(card);
    }
}

場札をtableauという二次元配列に分けることで、列ごとの一番上のカードや、選択した位置から下のカードを簡単に取得できます。残ったカードはstock、めくったカードはwasteとして別に管理します。

場札と組札への移動を判定する

場札には、赤と黒を交互にしながら数字を大きい順に重ねます。空の列にはKだけを置けます。組札はマークごとにAからKまで順番に積み、こちらへ移せるのは1枚だけです。

移動条件を関数にするJAVASCRIPT
function canTableau(card, pile) {
    const top = pile.at(-1);
    if (!top) return card.r === 13;
    const differentColor = RED.has(SUITS[top.s]) !== RED.has(SUITS[card.s]);
    return top.up && top.r === card.r + 1 && differentColor;
}

function canFoundation(card, index) {
    const foundation = foundations[index];
    return card.s === index && card.r === foundation.length + 1;
}

複数枚のカードをまとめて動かす場合は、選択したカード列が「数字が1つずつ下がり、赤黒が交互」という正しい並びかも確認します。判定を関数へ集約すると、クリック操作とドラッグ操作で同じルールを使えます。

カードを動かした後に裏返しを公開する

カードを場札や組札から取り除いた後、元の場札の一番上に裏向きカードが残っていれば表向きにします。公開時にはスコアも加算します。

裏向きカードを公開するJAVASCRIPT
function reveal(column) {
    const top = tableau[column].at(-1);
    if (top && !top.up) {
        top.up = true;
        score += 5;
    }
}

function moveToPile(index) {
    const cards = getSelectedCards();
    if (!cards.length || !canTableau(cards[0], tableau[index])) return;
    save();
    const source = selected.source;
    tableau[index].push(...removeSelected());
    if (source.startsWith('tableau-')) reveal(Number(source.split('-')[1]));
    moves++;
    selected = null;
    render();
}

getSelectedCardsは、捨て札なら最後の1枚、場札なら選択位置から下のカード列を返します。移動元から取り除く処理をremoveSelectedへまとめているため、移動元が場札、捨て札、組札のどれでも状態を更新できます。

クリックとドラッグを同じ処理につなぐ

クリック操作では、カードを選択してから移動先の列や組札をクリックします。ドラッグ操作では、ポインターの移動中に仮のカードを表示し、ドロップした場所をelementFromPointで調べて移動先を決めます。

移動先にはdrop-targetクラスを付け、枠を光らせます。ドロップ後はクリックと同じmoveToPilemoveToFoundationを呼び出すため、操作方法によってルールが変わりません。スマートフォンではポインターイベントを使うことで、マウスとタッチを同じ仕組みで扱えます。

山札と捨て札を循環させる

山札をクリックすると一番上のカードを捨て札へ移し、山札が空になったら捨て札を逆順にして山札へ戻します。戻すときに裏向きへ戻すことで、山札を繰り返しめくれるようにしています。

山札をめくるJAVASCRIPT
if (stock.length) {
    const card = stock.pop();
    card.up = true;
    waste.push(card);
} else {
    stock = waste.reverse();
    stock.forEach(card => card.up = false);
    waste = [];
}
moves++;
begin();
render();

山札を戻す操作も履歴へ保存します。カードの移動だけでなく、めくる操作もUndoで元に戻せるようにするためです。

Undo、ヒント、自動完成を実装する

Undoは、変更前の山札、捨て札、場札、組札、手数、スコアをJSON文字列として履歴へ保存しています。Undo時は最後の履歴を取り出してJSONから復元します。

操作履歴を保存するJAVASCRIPT
const cloneState = () => JSON.stringify({
    stock, waste, tableau, foundations, moves, score
});

function save() {
    history.push(cloneState());
    if (history.length > 100) history.shift();
}

const state = JSON.parse(history.pop());
({ stock, waste, tableau, foundations, moves, score } = state);
render();

ヒントは、捨て札と表向きの場札を候補として調べ、組札または別の場札へ移せるカードを見つけます。山札が空で裏向きカードも残っていない場合は、各列と捨て札の一番上を組札へ移せるか繰り返し確認し、自動完成を開始します。

状態から画面を描画する

renderでは、現在の配列をもとに山札、捨て札、組札、7列の場札を毎回描画します。カードの表裏や選択状態はクラスで表し、CSSの重なり幅を使って場札のカードを縦に並べています。

カードの表裏を表示するJAVASCRIPT
function cardEl(card, source, index) {
    const element = document.createElement('div');
    element.className = 'card ' + (card.up ? 'face-up' : 'face-down');
    element.dataset.source = source;
    element.dataset.index = index;
    if (card.up) element.textContent = names[card.r - 1] + SUITS[card.s];
    return element;
}

実際の画面では、盤面の親要素でクリックやポインターイベントを受け取っています。これをイベント委譲と呼び、再描画後のカードにも同じ操作を適用できます。ゲームの正しい状態を配列に集約することで、Undoや新しいゲームでも表示の不整合が起きにくくなります。

タイマーと効果音をユーザー操作に合わせる

タイマーは最初のカード操作で開始し、クリア時に停止します。効果音は音声ファイルを読み込まず、Web Audio APIの発振器とゲインを使って、カードをめくる音や移動音を短く生成しています。AudioContextはブラウザの自動再生制限に合わせて、ユーザー操作の中で初期化します。

最初の操作でタイマーを開始するJAVASCRIPT
function begin() {
    if (started) return;
    started = true;
    timerId = setInterval(() => {
        seconds++;
        timer.textContent = formatTime(seconds);
    }, 1000);
}

新しいゲームではタイマーを停止して秒数を0へ戻し、履歴やスコアも初期化します。初期化処理をnewGameへ集めることで、NEW GAMEボタンとクリア後の「もう一度遊ぶ」の両方から同じ状態へ戻せます。

実装して分かったこと

ソリティアでは、カードを動かす見た目よりも、移動前後の状態を一貫して管理することが重要です。カードの配列と表示を分け、場札と組札の判定を関数へ集約し、クリックとドラッグから同じ移動処理を呼び出すことで、操作方法が増えてもルールがずれにくくなります。

ゲームを遊ぶときは、実際のソリティアのページでカードをクリック、またはドラッグして試せます。コードを読むときは、まずnewGamecanTableaumoveToPilerenderの4つを追うと、ゲーム全体の流れをつかみやすくなります。