HTML CSS JavaScriptのコード紹介です

天秤の皿に左右に配置されたアイテム画像をドラッグ&ドロップしてグラム(重さ)が一致したら完成です

どちらか片方だけアイテムを載せても動きません。両方載せると動作います。また、一度載せたアイテムは元には戻せません。

アイテム画像はその都度シャッフルされ、1グラムから60グラムまで変化します。天秤の目盛りは目盛り1つ=2gで、左右の差が100g以上でゲームオーバーとなり、また天秤の目盛りが50を指したら終了です。

各コードの修正・追加・変更・デザインの変更等は制限なく使用できます。また、管理者がデモ用として公開していますので、丸ごとコピペしてWEB公開する事は許可致しませんが、起動時のUI(ユーザーインターフェイス)を変更すればこの限りではありません。

アンティーク天秤パズルでは参考用として40以上の画像を使用しています。画像をダウンロードして個人で使用する限りでは制約はありませんが、一般公開するに当たってはMOMO企画のクレジット表記が必要です。

また、各コードに関しては行ごとに細かくコメントしましたので分かりやすいと思います。

各ファイルは同じフォルダーに入れてください。画像フォルダーはimages/です。

構成
tenbin/
 |
 |--index.html
 |--script.js
 |--style.css
 |
 |--images/
   |
   |--0.png
   |--1.png
   |--~
   |--39.png
   |--balance.png
   |--balance-cp-left.png
   |--balance-cp-right.png
   |--balance-needle.png
   |--balance-rod.png
画像は基本的に各自用意してください

index.html
<!doctype html>
<!-- HTML5形式の文書であることをブラウザに伝える -->

<html lang="ja">
<!-- HTML文書の開始。ページの主な言語を日本語に設定 -->

<head>
  <!-- ページの設定情報を記述する領域 -->

  <meta charset="utf-8">
  <!-- 文字コードをUTF-8に設定し、日本語の文字化けを防ぐ -->

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <!-- スマートフォンなどの画面幅に合わせて表示するための設定 -->

  <title>アンティーク天秤パズル</title>
  <!-- ブラウザのタブなどに表示されるページタイトル -->

  <link rel="stylesheet" href="style.css">
  <!-- 外部CSSファイルを読み込み、ページのデザインを適用する -->

</head>
<!-- head領域の終了 -->


<body>
<!-- ブラウザに表示されるページ本体の開始 -->


  <!-- ============================== -->
  <!-- ヘッダー:タイトルと再スタートボタン -->
  <!-- ============================== -->

  <header class="header">
  <!-- ページ上部のヘッダー領域 -->

    <div class="brand">
    <!-- タイトルと説明文をまとめる領域 -->

      <h1>ANTIQUE BALANCE</h1>
      <!-- ゲームのメインタイトル -->

      <p>アイテムを左右の皿に置いて、天秤をつり合わせよう</p>
      <!-- ゲームの遊び方を簡単に説明する文章 -->

    </div>
    <!-- タイトルと説明文の領域の終了 -->


    <button id="restartButton" type="button">
      シャッフルして再スタート
    </button>
    <!-- ゲームを最初からやり直すためのボタン
         idはJavaScriptからボタンを取得するために使用する
         type="button"はフォーム送信などの動作をさせない指定 -->

  </header>
  <!-- ヘッダー領域の終了 -->


  <!-- ============================== -->
  <!-- 状態表示:左右の重さ・差・メッセージ -->
  <!-- ============================== -->

  <section class="status" aria-live="polite">
  <!-- ゲームの状況を表示する領域
       aria-live="polite"は、内容が変更されたときに
       支援技術が適切なタイミングで変更を伝えられるようにする -->

    <div class="stat">
    <!-- 左側の重さを表示する部品 -->

      <span>左の重さ</span>
      <!-- 左側の表示名 -->

      <strong id="leftTotal">0 g</strong>
      <!-- 左側に置かれたアイテムの合計重量
           JavaScriptがidを使って数値を書き換える -->

    </div>
    <!-- 左側の重さ表示の終了 -->


    <div class="stat">
    <!-- 右側の重さを表示する部品 -->

      <span>右の重さ</span>
      <!-- 右側の表示名 -->

      <strong id="rightTotal">0 g</strong>
      <!-- 右側に置かれたアイテムの合計重量
           JavaScriptがidを使って数値を書き換える -->

    </div>
    <!-- 右側の重さ表示の終了 -->


    <div class="stat">
    <!-- 左右の重量差を表示する部品 -->

      <span>左右の差</span>
      <!-- 重量差の表示名 -->

      <strong id="difference">—</strong>
      <!-- 左右の重量差を表示する場所
           JavaScriptが計算結果を設定する -->

    </div>
    <!-- 重量差表示の終了 -->


    <p id="message">
      アイテムをドラッグして、左右の皿に置いてください。
    </p>
    <!-- ゲームの説明、進行状況、完成やゲームオーバーの結果などを表示
         JavaScriptが状況に応じて文章を書き換える -->

  </section>
  <!-- 状態表示領域の終了 -->


  <!-- ============================== -->
  <!-- ゲーム本体:天秤とアイテムの配置場所 -->
  <!-- ============================== -->

  <main id="gameBoard" class="game-board">
  <!-- ゲーム全体を囲む領域
       id="gameBoard"はJavaScriptからゲーム領域を取得するために使用する
       class="game-board"はCSSで見た目や配置を設定するために使用する -->


    <!-- ============================== -->
    <!-- 左側のアイテム置き場 -->
    <!-- ============================== -->

    <div class="item-frame left-frame">
    <!-- 左側のコレクション枠 -->

      <div class="frame-title">COLLECTION</div>
      <!-- 左側の枠に表示するタイトル -->

    </div>
    <!-- 左側のアイテム置き場の終了 -->


    <!-- ============================== -->
    <!-- 右側のアイテム置き場 -->
    <!-- ============================== -->

    <div class="item-frame right-frame">
    <!-- 右側のコレクション枠 -->

      <div class="frame-title">COLLECTION</div>
      <!-- 右側の枠に表示するタイトル -->

    </div>
    <!-- 右側のアイテム置き場の終了 -->


    <!-- ============================== -->
    <!-- 天秤の各画像 -->
    <!-- ============================== -->

    <div id="scaleAssembly" class="scale-assembly"
         aria-label="アンティーク天秤">
    <!-- 天秤を構成する画像をまとめる領域
         CSSで各画像の位置を整える
         aria-labelは、この領域を説明するためのラベル -->


      <img class="balance-body"
           src="images/balance.png"
           alt="天秤本体">
      <!-- 天秤の土台や支柱など、本体部分の画像
           classはCSSでデザインを設定するために使用する
           srcは画像ファイルの保存場所
           altは画像の内容を説明する文章 -->


      <img id="balanceRod"
           class="balance-rod"
           src="images/balance-rod.png"
           alt="">
      <!-- 左右の皿を支える横棒の画像
           id="balanceRod"はJavaScriptから横棒を取得するために使用する
           JavaScriptが回転角度を変更する
           alt=""は装飾的な画像として扱うための指定 -->


      <img id="balanceNeedle"
           class="balance-needle"
           src="images/balance-needle.png"
           alt="">
      <!-- 重さの差を示す針の画像
           JavaScriptが回転角度を変更し、重量差を表現する -->


      <img id="leftCup"
           class="cup left-cup"
           src="images/balance-cp-left.png"
           alt="">
      <!-- 左側の皿の画像
           id="leftCup"はJavaScriptから左の皿を取得するために使用する
           重量差に応じて上下位置を変更する -->


      <img id="rightCup"
           class="cup right-cup"
           src="images/balance-cp-right.png"
           alt="">
      <!-- 右側の皿の画像
           id="rightCup"はJavaScriptから右の皿を取得するために使用する
           重量差に応じて上下位置を変更する -->

    </div>
    <!-- 天秤を構成する領域の終了 -->


    <!-- ============================== -->
    <!-- アイテムを配置するレイヤー -->
    <!-- ============================== -->

    <div id="itemsLayer" class="items-layer"></div>
    <!-- JavaScriptがアイテム画像を生成し、ここに追加する
         40個のアイテムを管理するための領域
         ドラッグ操作や、皿に置いたアイテムの配置にも使用する -->


    <!-- ============================== -->
    <!-- 操作方法の案内 -->
    <!-- ============================== -->

    <div class="hint">
      アイテムを皿へドラッグ&ドロップ。皿の外へ戻すと置き場に戻ります。
    </div>
    <!-- ゲームの操作方法を画面内に表示する案内文 -->

  </main>
  <!-- ゲーム本体の領域の終了 -->


  <!-- ============================== -->
  <!-- フッター:ルールと補足情報 -->
  <!-- ============================== -->

  <footer>
  <!-- ページ下部の説明領域 -->

    <span>目盛り1つ=2g</span>
    <!-- 天秤の目盛りと重量の関係を説明する -->

    <span>左右の差が100g以上でゲームオーバー</span>
    <!-- ゲームオーバーになる条件を表示する -->

    <span>画像は images フォルダに配置してください</span>
    <!-- 使用する画像ファイルの保存場所を案内する -->

  </footer>
  <!-- フッターの終了 -->


  <!-- ============================== -->
  <!-- JavaScriptの読み込み -->
  <!-- ============================== -->

  <script src="script.js"></script>
  <!-- ゲームの動作を制御するJavaScriptファイルを読み込む
       アイテムの生成、ドラッグ操作、重量計算、針の回転、
       完成判定、ゲームオーバー判定などを処理する
       bodyの末尾に置くことで、HTML要素を記述した後に読み込む -->

</body>
<!-- ページ本体の終了 -->

</html>
<!-- HTML文書の終了 -->
script.js
// ============================================================
// 1. ゲーム全体の処理
// ============================================================

// 即時実行関数(IIFE)
// 関数を定義すると同時に実行する。
// 変数や関数をこの中にまとめることで、
// 他のJavaScriptコードと名前が衝突しにくくする。
(() => {

  // strictモード(厳格モード)を有効にする。
  // JavaScriptの一部の曖昧な書き方を禁止し、
  // 間違いを発見しやすくする。
  "use strict";


  // ==========================================================
  // 2. ゲームの設定値
  // ==========================================================

  // アイテムに割り当てる重さの種類(単位:g)。
  // 1g、5g、10g、15g、30g、45g、50g、60gの8種類。
  const WEIGHTS = [1, 5, 10, 15, 30, 45, 50, 60];

  // それぞれの重さのアイテムを何個用意するか。
  // 5個ずつ用意する。
  const COPIES = 5;

  // 左右の重さの差が100g以上になるとゲームオーバー。
  const GAME_OVER_GRAMS = 100;

  // 針が回転する最大角度(度)。
  const NEEDLE_MAX_ANGLE = 49;

  // 天秤の横棒が回転する最大角度(度)。
  const ROD_MAX_ANGLE = 3;

  // 皿の初期位置を設定する。
  // CSSのtopと同様に、上端からの距離をピクセルで指定する。
  const CUP_BASE_TOP = 265;


  // ==========================================================
  // 3. HTML要素の取得
  // ==========================================================

  // 天秤ゲーム全体の領域を取得する。
  const board = document.getElementById("gameBoard");

  // アイテムを配置するための領域を取得する。
  const layer = document.getElementById("itemsLayer");

  // 天秤の横棒を取得する。
  const balanceRod = document.getElementById("balanceRod");

  // 天秤の針を取得する。
  const balanceNeedle = document.getElementById("balanceNeedle");

  // 左側の皿を取得する。
  const leftCup = document.getElementById("leftCup");

  // 右側の皿を取得する。
  const rightCup = document.getElementById("rightCup");


  // 左側の合計重量を表示するHTML要素を取得する。
  const leftTotalEl = document.getElementById("leftTotal");

  // 右側の合計重量を表示するHTML要素を取得する。
  const rightTotalEl = document.getElementById("rightTotal");

  // 左右の重量差を表示するHTML要素を取得する。
  const differenceEl = document.getElementById("difference");

  // ゲームのメッセージを表示するHTML要素を取得する。
  const messageEl = document.getElementById("message");

  // 再スタートボタンを取得する。
  const restartButton = document.getElementById("restartButton");


  // ==========================================================
  // 4. ゲームの状態を保存する変数
  // ==========================================================

  // 全40個のアイテム情報を保存する配列。
  // 各アイテムの番号、重さ、配置先、HTML要素などを管理する。
  let items = [];

  // 左右の重さを比較する段階に入ったかどうか。
  // 左右両方の皿にアイテムが置かれるとtrueになる。
  let startedMoving = false;

  // ゲームが終了したかどうか。
  // 完成またはゲームオーバーになるとtrueになる。
  let finished = false;

  // 現在ドラッグしているアイテムの情報。
  // ドラッグ中でなければnull。
  let drag = null;


  // ==========================================================
  // 5. 配列をランダムな順番に並べ替える関数
  // ==========================================================

  // 配列aを受け取り、要素の順番をランダムに並べ替える。
  const shuffle = a => {

    // 配列の最後から順番に処理する。
    // iは、現在入れ替えの対象にしている位置。
    for (let i = a.length - 1; i > 0; i--) {

      // 0からiまでの整数をランダムに選ぶ。
      // Math.random()は0以上1未満の乱数を返す。
      // Math.floor()で小数点以下を切り捨てる。
      const j = Math.floor(Math.random() * (i + 1));

      // 配列のi番目とj番目の要素を入れ替える。
      // [a[i], a[j]] = [a[j], a[i]] は分割代入を使った書き方。
      [a[i], a[j]] = [a[j], a[i]];
    }

    // 並べ替えた配列を呼び出し元に返す。
    return a;
  };


  // ==========================================================
  // 6. アイテムの重さ一覧を作る関数
  // ==========================================================

  function makeWeightList() {

    // 重さを格納するための空の配列を作る。
    const a = [];

    // WEIGHTS配列から重さを1種類ずつ取り出す。
    WEIGHTS.forEach(w => {

      // 同じ重さをCOPIESの数だけ配列に追加する。
      // 今回は各重さを5個ずつ追加する。
      for (let i = 0; i < COPIES; i++) {

        // 重さwを配列の末尾に追加する。
        a.push(w);
      }
    });

    // 作成した重さ一覧をランダムに並べ替えて返す。
    // 重さは8種類×5個なので、合計40個になる。
    return shuffle(a);
  }


  // ==========================================================
  // 7. 画像が読み込めない場合の代替画像を作る関数
  // ==========================================================

  function placeholder(id) {

    // SVG形式で簡単な代替画像を作成する。
    // 円の中にアイテム番号を表示するデザイン。
    const svg = `
      <svg xmlns="http://www.w3.org/2000/svg"
           width="100"
           height="100">

        <!-- アイテム本体となる円 -->
        <circle
          cx="50"
          cy="42"
          r="29"
          fill="#c8a15d"
          stroke="#735126"
          stroke-width="3"
        />

        <!-- アイテム番号を表示する文字 -->
        <text
          x="50"
          y="83"
          text-anchor="middle"
          font-family="sans-serif"
          font-size="18"
          fill="#f4e8cd"
        >${id}</text>

      </svg>`;

    // SVGの文字列をURLとして使用できる形式に変換する。
    // encodeURIComponent()は、特殊文字などをURL用に変換する。
    // data:image/svg+xmlを指定すると、SVGを画像として表示できる。
    return "data:image/svg+xml;charset=utf-8,"
      + encodeURIComponent(svg);
  }


  // ==========================================================
  // 8. ゲームを作成する関数
  // ==========================================================

  function createGame() {

    // itemsLayer内のHTMLをすべて削除する。
    // 前のゲームで作成したアイテムを取り除く。
    layer.innerHTML = "";

    // アイテム情報を保存する配列を空に戻す。
    items = [];

    // 40個分の重さを作成し、ランダムに並べ替える。
    const weights = makeWeightList();

    // アイテム番号0~39を使って、40個のアイテムを作成する。
    for (let id = 0; id < 40; id++) {

      // img要素を新しく作成する。
      // HTMLに<img>タグを追加するのと同じ。
      const el = document.createElement("img");

      // CSSでデザインを指定するためのクラス名を設定する。
      el.className = "item";

      // アイテム番号に対応する画像ファイルを指定する。
      // 例:idが0ならimages/0.png。
      el.src = `images/${id}.png`;

      // 画像が表示されない場合などに使う代替テキストを設定する。
      el.alt = `アイテム ${id}`;

      // ブラウザー標準の画像ドラッグを無効にする。
      // 独自のポインター操作でアイテムを動かすため。
      el.draggable = false;

      // HTMLのdata-id属性にアイテム番号を保存する。
      // 後でどのアイテムが操作されたのかを判別するために使う。
      el.dataset.id = id;

      // 画像の読み込みに失敗した場合の処理を登録する。
      el.addEventListener("error", () => {

        // 代替画像への切り替えをまだ行っていない場合だけ処理する。
        if (!el.dataset.fallback) {

          // 代替画像への切り替え済みであることを記録する。
          // 同じ画像のエラー処理が繰り返されるのを防ぐ。
          el.dataset.fallback = "1";

          // SVGで作成した代替画像を表示する。
          el.src = placeholder(id);
        }
      });

      // ポインターが押されたときにpointerDown()を呼び出す。
      // マウスやタッチ操作によるドラッグの開始処理。
      el.addEventListener("pointerdown", pointerDown);

      // アイテムの情報をオブジェクトとして作成する。
      items.push({

        // アイテムを識別する番号。
        id,

        // ランダムに割り当てられた重さ。
        weight: weights[id],

        // アイテムの配置先。
        // nullは、まだ左右どちらの皿にも置かれていない状態。
        side: null,

        // アイテムのHTML要素そのものを保存する。
        el,

        // 置き場に戻す際に使う初期の横位置。
        homeX: 0,

        // 置き場に戻す際に使う初期の縦位置。
        homeY: 0
      });

      // 作成した画像要素をitemsLayerの中に追加する。
      layer.appendChild(el);
    }

    // アイテムを左右の置き場に整列させる。
    layoutPoolItems();

    // 左右の合計重量や天秤の状態を更新する。
    // falseは、完成判定を行わないことを意味する。
    updateReadout(false);
  }


  // ==========================================================
  // 9. 置き場にあるアイテムを整列させる関数
  // ==========================================================

  function layoutPoolItems() {

    // 画面幅が760px以下かどうかを判定する。
    // 条件に一致すればmobileはtrueになる。
    // CSSのメディアクエリーとは別に、JavaScript側でも画面幅を判定する。
    const mobile = matchMedia("(max-width:760px)").matches;

    // items配列のアイテムを1個ずつ処理する。
    // itemはアイテム情報、iは配列内の位置を表す番号。
    items.forEach((item, i) => {

      // すでに皿に置かれているアイテムは処理しない。
      // 置き場にあるアイテムだけを整列させる。
      if (item.side) return;


      // 左右交互にアイテムを振り分ける。
      // 配列の偶数番目は左、奇数番目は右にする。
      // これにより、左右それぞれ20個になる。
      const side = i % 2 === 0 ? "left" : "right";

      // 左右それぞれの中で何番目のアイテムかを求める。
      // 左側では0、1、2……19。
      // 右側でも0、1、2……19になる。
      const index = side === "left" ? i / 2 : (i - 1) / 2;


      // 横方向の列番号を求める。
      // %は余りを求める演算子。
      // indexを3で割った余りは0、1、2を繰り返す。
      // そのため、3列に配置される。
      const col = index % 3;

      // 縦方向の行番号を求める。
      // indexを3で割り、小数点以下を切り捨てる。
      // そのため、3個ごとに行番号が1つ増える。
      const row = Math.floor(index / 3);


      // アイテムの横位置を計算する。
      const x = side === "left"

        // 左側の置き場では、3%の位置から6%ずつ離して配置する。
        ? 3 + col * 6

        // 右側の置き場では、81%の位置から6%ずつ離して配置する。
        : 81 + col * 6;


      // アイテムの縦位置を計算する。
      const y = mobile

        // 画面幅が760px以下の場合は、9%ずつ下へ配置する。
        ? 8 + row * 9

        // それ以外では、10.5%ずつ下へ配置する。
        : 8 + row * 10.5;


      // アイテムの初期位置を保存する。
      // 後でアイテムを置き場に戻すときに使用する。
      item.homeX = x;
      item.homeY = y;


      // CSSのleftプロパティに横位置を設定する。
      // %指定なので、基準となる要素の幅に対する割合になる。
      item.el.style.left = x + "%";

      // CSSのtopプロパティに縦位置を設定する。
      // %指定なので、基準となる要素の高さに対する割合になる。
      item.el.style.top = y + "%";


      // アイテムの幅をCSSの指定値に戻す。
      // 空文字を設定すると、JavaScriptによる直接指定が解除される。
      item.el.style.width = "";

      // アイテムの高さもCSSの指定値に戻す。
      item.el.style.height = "";
    });
  }


  // ==========================================================
  // 10. ドラッグを開始する関数
  // ==========================================================

  function pointerDown(e) {

    // ゲーム終了後は操作できないようにする。
    // すでに別のアイテムをドラッグしている場合も処理しない。
    if (finished || drag) return;


    // イベントが発生したHTML要素を取得する。
    const el = e.currentTarget;

    // HTML要素のdata-idからアイテム番号を取得し、
    // items配列から対応するアイテム情報を取り出す。
    // +演算子で文字列を数値に変換する。
    const item = items[+el.dataset.id];

    // ゲーム領域の位置と大きさを取得する。
    const br = board.getBoundingClientRect();

    // 操作対象のアイテムの位置と大きさを取得する。
    const ir = el.getBoundingClientRect();


    // すでに皿に置かれたアイテムは動かせないようにする。
    // sideがnull以外なら、左または右の皿に配置済み。
    if (item.side !== null) return;


    // ブラウザー標準のポインター操作を抑制する。
    // ドラッグ中の不要な選択や既定動作を防ぐため。
    e.preventDefault();


    // 現在ドラッグしているアイテムの情報を保存する。
    drag = {

      // アイテムのデータ。
      item,

      // 操作対象のHTML要素。
      el,

      // 操作を開始したポインターの識別番号。
      // 複数の指などを区別するために使う。
      pointerId: e.pointerId,

      // ポインター位置とアイテム左端との横方向の距離。
      // アイテムの中央などをつかんでも、位置がずれないようにする。
      offsetX: e.clientX - ir.left,

      // ポインター位置とアイテム上端との縦方向の距離。
      offsetY: e.clientY - ir.top
    };


    // draggingクラスを追加する。
    // CSSでドラッグ中の見た目を変更できる。
    el.classList.add("dragging");

    // この要素がポインター操作を受け取り続けるようにする。
    // ポインターが要素の外へ移動しても操作を追跡しやすくする。
    el.setPointerCapture(e.pointerId);

    // ポインターが移動したときの処理を登録する。
    el.addEventListener("pointermove", pointerMove);

    // ポインターを離したときの処理を登録する。
    // once:trueにより、このイベントは1回実行すると自動解除される。
    el.addEventListener("pointerup", pointerUp, {
      once: true
    });

    // ポインター操作がキャンセルされたときの処理を登録する。
    // こちらも1回実行すると自動解除される。
    el.addEventListener("pointercancel", pointerCancel, {
      once: true
    });


    // アイテムの位置をゲーム領域の左上を基準にした座標へ変換する。
    // ir.leftとbr.leftの差が、ゲーム領域内での横位置になる。
    el.style.left = (ir.left - br.left) + "px";

    // 縦方向も同じように、ゲーム領域内での位置に変換する。
    el.style.top = (ir.top - br.top) + "px";


    // 元のコードにある条件。
    // 現在の処理では、上の判定で未配置のアイテムだけが通過するため、
    // item.sideはnullであり、この条件は通常成立しない。
    // そのため、この部分は現在の動作では実質的に使用されていない。
    if (item.side) {
      el.style.width = "38px";
      el.style.height = "38px";
    }
  }


  // ==========================================================
  // 11. ドラッグ中にアイテムを移動させる関数
  // ==========================================================

  function pointerMove(e) {

    // ドラッグ中でない場合は何もしない。
    // また、操作中のポインターと異なる場合も処理しない。
    if (!drag || e.pointerId !== drag.pointerId) return;

    // ブラウザー標準の動作を抑制する。
    e.preventDefault();

    // ゲーム領域の位置を取得する。
    const r = board.getBoundingClientRect();

    // ポインターの横位置からゲーム領域の左端とつかんだ位置を引く。
    // これにより、アイテムの左端の位置を計算する。
    drag.el.style.left =
      (e.clientX - r.left - drag.offsetX) + "px";

    // 縦方向も同様に計算し、アイテムの上端を移動させる。
    drag.el.style.top =
      (e.clientY - r.top - drag.offsetY) + "px";
  }


  // ==========================================================
  // 12. アイテムが左右どちらの皿に置かれたかを判定する関数
  // ==========================================================

  function getDropSide(x, y) {

    // 左の皿、右の皿の順番に判定する。
    for (const side of ["left", "right"]) {

      // 判定対象の皿を取得する。
      const el = side === "left" ? leftCup : rightCup;

      // 皿の画面上の位置と大きさを取得する。
      const r = el.getBoundingClientRect();


      // ポインターの位置が皿の指定範囲内にあるかを確認する。
      // 横方向は、皿の左右端から幅の5%内側までを有効範囲にする。
      // 縦方向は、皿の上端から高さの55%下の位置以降を有効にする。
      // これにより、皿の上側の広い範囲を誤ってドロップ先と判定しにくくする。
      if (
        x >= r.left + r.width * 0.05 &&
        x <= r.right - r.width * 0.05 &&
        y >= r.top + r.height * 0.55 &&
        y <= r.bottom
      ) {

        // 条件を満たした皿の名前を返す。
        // 左の皿なら"left"、右の皿なら"right"。
        return side;
      }
    }

    // どちらの皿にも入らなかった場合はnullを返す。
    // 呼び出し元では、置き場へ戻す処理として扱われる。
    return null;
  }


  // ==========================================================
  // 13. ポインターを離したときの関数
  // ==========================================================

  function pointerUp(e) {

    // ドラッグ中であり、操作中のポインターと一致している場合だけ処理する。
    if (drag && e.pointerId === drag.pointerId) {

      // ポインターを離した位置から、ドロップ先の皿を判定する。
      // 判定結果をfinishDrag()に渡す。
      finishDrag(getDropSide(e.clientX, e.clientY));
    }
  }


  // ==========================================================
  // 14. ポインター操作がキャンセルされたときの関数
  // ==========================================================

  function pointerCancel() {

    // ドラッグ中なら、ドロップ先をnullとして終了する。
    // nullは皿に置かなかった状態なので、置き場へ戻る。
    if (drag) finishDrag(null);
  }


  // ==========================================================
  // 15. ドラッグ終了後の処理
  // ==========================================================

  function finishDrag(side) {

    // 現在のドラッグ情報を変数dに保存する。
    const d = drag;

    // ドラッグ情報がなければ処理を終了する。
    if (!d) return;

    // ドラッグ中に登録した移動イベントを解除する。
    // ドラッグが終わった後に移動処理が実行されないようにする。
    d.el.removeEventListener("pointermove", pointerMove);

    // draggingクラスを削除し、ドラッグ中の見た目を解除する。
    d.el.classList.remove("dragging");

    // アイテムの配置先を更新する。
    // sideが"left"または"right"なら、その皿に配置する。
    // sideがnullなら、まだ皿に配置されていない状態にする。
    d.item.side = side || null;

    // ドラッグ情報を空にする。
    // これにより、次のドラッグ操作が可能になる。
    drag = null;

    // 置き場と皿のアイテムを正しい位置に並べ直す。
    arrangeItems();

    // 重量表示、針、横棒、皿、勝敗メッセージなどを更新する。
    // trueは、完成条件の判定を行うことを意味する。
    updateReadout(true);
  }


  // ==========================================================
  // 16. アイテムを置き場や皿に整列させる関数
  // ==========================================================

  function arrangeItems() {

    // ゲーム領域の位置と大きさを取得する。
    // アイテムの配置位置をゲーム領域基準で計算するために使う。
    const br = board.getBoundingClientRect();


    // --------------------------------------------------------
    // 16-1. まだ皿に置かれていないアイテムを置き場へ戻す
    // --------------------------------------------------------

    // sideが設定されていないアイテムだけを抽出する。
    items.filter(i => !i.side).forEach(i => {

      // 皿に置かれたアイテム用のCSSクラスを削除する。
      i.el.classList.remove("placed");

      // 幅をCSSの通常指定に戻す。
      i.el.style.width = "";

      // 高さもCSSの通常指定に戻す。
      i.el.style.height = "";

      // 保存しておいた初期の横位置へ戻す。
      i.el.style.left = i.homeX + "%";

      // 保存しておいた初期の縦位置へ戻す。
      i.el.style.top = i.homeY + "%";

      // 重なり順の指定を解除する。
      i.el.style.zIndex = "";
    });


    // --------------------------------------------------------
    // 16-2. 左右の皿に置かれたアイテムを並べる
    // --------------------------------------------------------

    // 左の皿、右の皿の順に処理する。
    for (const side of ["left", "right"]) {

      // 現在の皿に置かれているアイテムだけを取り出す。
      const list = items.filter(i => i.side === side);

      // 配置対象となる皿のHTML要素を取得する。
      const cup = side === "left" ? leftCup : rightCup;

      // 皿の画面上の位置と大きさを取得する。
      const r = cup.getBoundingClientRect();


      // 皿に置かれたアイテムを1個ずつ配置する。
      // itemはアイテム情報、nはその皿の中での順番。
      list.forEach((item, n) => {

        // 横方向の列番号を求める。
        // n % 4は0、1、2、3を繰り返すため、4列になる。
        const col = n % 4;

        // 縦方向の行番号を求める。
        // 4個ごとに行番号が1つ増える。
        const row = Math.floor(n / 4);


        // placedクラスを追加する。
        // CSS側で、皿に置かれたアイテムの大きさなどを設定できる。
        item.el.classList.add("placed");


        // ----------------------------------------------------
        // アイテムの大きさを取得する
        // ----------------------------------------------------

        // アイテムの幅をピクセル単位で取得する。
        // この値を使って、アイテム同士の間隔や配置範囲を計算する。
        const size = item.el.offsetWidth;


        // ----------------------------------------------------
        // 皿の内側とみなす範囲を計算する
        // ----------------------------------------------------

        // 皿の左端から幅の33%の位置を、配置範囲の左端にする。
        // r.leftは画面上の座標なので、br.leftを引いて
        // ゲーム領域内での座標に変換する。
        const cupLeft = r.left - br.left + r.width * 0.33;

        // 皿の左端から幅の82%の位置を、配置範囲の右端にする。
        const cupRight = r.left - br.left + r.width * 0.82;

        // 皿の上端から高さの48%の位置を、配置範囲の上端にする。
        const cupTop = r.top - br.top + r.height * 0.48;

        // 皿の上端から高さの88%の位置を、配置範囲の下端にする。
        const cupBottom = r.top - br.top + r.height * 0.88;


        // ----------------------------------------------------
        // アイテムの横位置と縦位置を計算する
        // ----------------------------------------------------

        // 横位置を計算する。
        // cupLeftを基準に、列番号に応じて右へ移動させる。
        // size * 0.45は、アイテムの幅の45%分だけ間隔を空ける指定。
        // アイテムの幅より間隔が狭いため、アイテム同士が重なる。
        let x = cupLeft + col * size * 0.45;

        // 縦位置を計算する。
        // cupBottomからアイテム1個分の高さを引き、下端を基準にする。
        // rowが増えると、size * 0.52ずつ上へ移動する。
        // そのため、上の段ほど上側に配置され、アイテム同士が重なる。
        let y = cupBottom - size - row * size * 0.52;


        // ----------------------------------------------------
        // 配置範囲を超えないように位置を制限する
        // ----------------------------------------------------

        // 横位置をcupLeft以上、cupRight-size以下に制限する。
        // Math.min()は2つの値のうち小さい方を返す。
        // Math.max()は2つの値のうち大きい方を返す。
        // これにより、計算したxが指定範囲を超えないようにする。
        x = Math.max(cupLeft, Math.min(x, cupRight - size));

        // 縦位置も同じ考え方で制限する。
        // yをcupTop以上、cupBottom-size以下に収める。
        y = Math.max(cupTop, Math.min(y, cupBottom - size));


        // ----------------------------------------------------
        // 計算した位置をアイテムに反映する
        // ----------------------------------------------------

        // 横位置をピクセル単位で設定する。
        item.el.style.left = x + "px";

        // 縦位置をピクセル単位で設定する。
        item.el.style.top = y + "px";

        // 重なり順を設定する。
        // nが大きいアイテムほど大きな値になるため、
        // 同じ位置で重なった場合、後のアイテムが手前に表示される。
        item.el.style.zIndex = String(7 + n);

      });
    }
  }


  // ==========================================================
  // 17. 左右の合計重量を計算する関数
  // ==========================================================

  function sum(side) {

    // items配列のすべてのアイテムを順番に調べ、
    // 指定した皿に置かれているアイテムの重さだけを合計する。
    return items.reduce((n, i) =>

      // アイテムの配置先が引数sideと一致する場合、
      // 現在の合計nにアイテムの重さi.weightを加える。
      // 一致しない場合は、合計nをそのまま維持する。
      n + (i.side === side ? i.weight : 0),

      // 合計の初期値は0g。
      0
    );
  }


  // ==========================================================
  // 18. 重量表示・天秤・勝敗判定を更新する関数
  // ==========================================================

  function updateReadout(checkWin) {

    // 左の皿に置かれたアイテムの合計重量を計算する。
    const l = sum("left");

    // 右の皿に置かれたアイテムの合計重量を計算する。
    const r = sum("right");

    // 左の合計から右の合計を引き、重量差を計算する。
    // 正の値なら左が重く、負の値なら右が重い。
    // 0なら左右が同じ重さ。
    const diff = l - r;

    // 左右両方の皿に1個以上アイテムがあるかを調べる。
    // some()は、条件に一致する要素が1つでもあればtrueを返す。
    const both =
      items.some(i => i.side === "left") &&
      items.some(i => i.side === "right");


    // 左の合計重量をHTMLに表示する。
    // 例:合計が15なら「15 g」と表示する。
    leftTotalEl.textContent = `${l} g`;

    // 右の合計重量をHTMLに表示する。
    rightTotalEl.textContent = `${r} g`;

    // 左右両方の皿にアイテムがある場合だけ、重量差を表示する。
    differenceEl.textContent = both

      // 正の数なら先頭に「+」を付ける。
      // 負の数ならdiff自体に「-」が含まれる。
      // 例:左が重い場合は「+15 g」、右が重い場合は「-15 g」。
      ? `${diff > 0 ? "+" : ""}${diff} g`

      // 左右両方にアイテムがない場合などは横棒を表示する。
      : "—";


    // 左右両方にアイテムが置かれたら、天秤を動かす段階に入る。
    // 一度trueになると、再スタートするまでtrueのまま。
    if (both) startedMoving = true;


    // --------------------------------------------------------
    // 18-1. 重量差から天秤の傾きを計算する
    // --------------------------------------------------------

    // 重量差を100gで割り、傾きを決めるための比率を計算する。
    // startedMovingがfalseなら、比率は0にする。
    // Math.min()とMath.max()によって、比率を-1~1に制限する。
    // 例えば重量差が50gなら0.5、100g以上なら1になる。
    const ratio = startedMoving
      ? Math.max(-1, Math.min(1, diff / GAME_OVER_GRAMS))
      : 0;


    // 針の回転角度を計算する。
    // ratioにマイナスを掛けることで、左右の重さに応じて
    // 針が反対方向へ回転するようにする。
    const needleAngle = -ratio * NEEDLE_MAX_ANGLE;

    // 横棒の回転角度を計算する。
    // 針より小さい角度で回転させる。
    const rodAngle = -ratio * ROD_MAX_ANGLE;


    // --------------------------------------------------------
    // 18-2. 針と横棒を回転させる
    // --------------------------------------------------------

    // 針のCSS transformを設定する。
    // translateX(-50%)で横方向の位置を中央に調整し、
    // rotate()で計算した角度だけ回転させる。
    balanceNeedle.style.transform =
      `translateX(-50%) rotate(${needleAngle}deg)`;

    // 横棒も同じように中央位置を調整して回転させる。
    balanceRod.style.transform =
      `translateX(-50%) rotate(${rodAngle}deg)`;


    // --------------------------------------------------------
    // 18-3. 皿の上下位置を計算する
    // --------------------------------------------------------

    // 横棒の中心から皿までのおよその距離を設定する。
    // この値を使って、横棒の回転による皿の上下移動を計算する。
    const halfSpan = 281;

    // 横棒の角度をラジアンに変換し、sin()で上下方向の変化を求める。
    // JavaScriptのsin()は角度をラジアンで受け取るため、
    // 度数にMath.PI / 180を掛けて変換する。
    const delta = halfSpan * Math.sin(rodAngle * Math.PI / 180);


    // 左の皿の上端位置を設定する。
    // deltaを引くため、横棒の角度に応じて上下に移動する。
    leftCup.style.top = (CUP_BASE_TOP - delta) + "px";

    // 右の皿の上端位置を設定する。
    // 左とは反対にdeltaを加え、左右が逆方向に動くようにする。
    rightCup.style.top = (CUP_BASE_TOP + delta) + "px";


    // アイテムが存在し、ドラッグ中でない場合は配置を更新する。
    // 皿が上下に動いた場合も、アイテムの位置を計算し直す。
    if (items.length && !drag) arrangeItems();


    // すでにゲームが終了している場合は、以降の判定を行わない。
    if (finished) return;


    // --------------------------------------------------------
    // 18-4. ゲームオーバーの判定
    // --------------------------------------------------------

    // 天秤が動き始めていて、左右の重量差の絶対値が100g以上なら、
    // ゲームオーバーにする。
    // Math.abs()は、数値の絶対値を返す。
    // 例えば-100の絶対値は100。
    if (
      startedMoving &&
      Math.abs(diff) >= GAME_OVER_GRAMS
    ) {

      // ゲーム終了状態にする。
      finished = true;

      // ゲームオーバーのメッセージを表示する。
      messageEl.textContent =
        "ゲームオーバー! 針が50目盛りに達しました。再スタートしてください。";

      // 以降の処理を行わず、この関数を終了する。
      return;
    }


    // --------------------------------------------------------
    // 18-5. 完成の判定
    // --------------------------------------------------------

    // 次の条件がすべて成立した場合に完成とする。
    // checkWinがtrue:完成判定を行うタイミングである。
    // startedMovingがtrue:天秤が動き始めている。
    // bothがtrue:左右両方の皿にアイテムがある。
    // diff===0:左右の合計重量が完全に一致している。
    if (checkWin && startedMoving && both && diff === 0) {

      // ゲーム終了状態にする。
      finished = true;

      // 完成メッセージを表示する。
      messageEl.textContent =
        "完成! 左右の重さがぴったり一致しました。おめでとうございます!";

      // 以降の処理を行わず、この関数を終了する。
      return;
    }


    // --------------------------------------------------------
    // 18-6. 通常時のメッセージを表示する
    // --------------------------------------------------------

    // 天秤が動き始めていれば、重量を調整するメッセージを表示する。
    // まだ動き始めていなければ、左右に1個ずつ置くよう案内する。
    messageEl.textContent = startedMoving

      ? "左右の重さを調整してください。0で完成、50目盛りでゲームオーバーです。"

      : "まず左右の皿に1個ずつ置いてください。最初の片側だけでは針は動きません。";
  }


  // ==========================================================
  // 19. ゲームを最初からやり直す関数
  // ==========================================================

  function restart() {

    // 天秤が動き始めたかどうかを初期状態に戻す。
    startedMoving = false;

    // ゲーム終了状態を解除する。
    finished = false;

    // ドラッグ情報を初期化する。
    drag = null;


    // 横棒の回転角度を0度に戻す。
    // translateX(-50%)は、横棒の中央位置を維持するための指定。
    balanceRod.style.transform =
      "translateX(-50%) rotate(0deg)";

    // 針の回転角度も0度に戻す。
    balanceNeedle.style.transform =
      "translateX(-50%) rotate(0deg)";


    // 左の皿を初期の高さに戻す。
    leftCup.style.top = CUP_BASE_TOP + "px";

    // 右の皿も初期の高さに戻す。
    rightCup.style.top = CUP_BASE_TOP + "px";


    // アイテムを作り直す。
    // 重さを再抽選し、40個のアイテムを置き場に並べる。
    createGame();

    // 再スタート後の案内メッセージを表示する。
    messageEl.textContent =
      "アイテムをドラッグして、左右の皿に置いてください。";
  }


  // ==========================================================
  // 20. イベントの登録
  // ==========================================================

  // 再スタートボタンがクリックされたらrestart()を実行する。
  restartButton.addEventListener("click", restart);


  // ウィンドウのサイズが変わったときの処理を登録する。
  window.addEventListener("resize", () => {

    // アイテムが存在し、ドラッグ中でない場合だけ処理する。
    if (items.length && !drag) {

      // 画面幅に合わせて、置き場のアイテムを整列し直す。
      layoutPoolItems();

      // 皿のアイテムも位置を計算し直す。
      arrangeItems();

      // 重量表示や天秤の状態も更新する。
      // falseなので、この処理では完成判定を行わない。
      updateReadout(false);
    }
  });


  // ==========================================================
  // 21. ゲーム開始
  // ==========================================================

  // JavaScriptが実行されたとき、最初にゲームを開始する。
  // 初期状態の設定とアイテムの作成を行う。
  restart();

})();
style.css
/* ==========================================================
   1. 全要素の基本設定
   ========================================================== */

/*
  * は、ページ内のすべてのHTML要素を対象にするセレクター。

  box-sizing: border-box;
  widthやheightに、paddingとborderの幅を含めて計算する。

  例えばwidth: 100pxの場合、
  paddingやborderを追加しても、全体の幅は基本的に100pxになる。

  要素の大きさを管理しやすくするための設定。
*/
* {
  box-sizing: border-box;
}


/* ==========================================================
   2. CSS変数の定義
   ========================================================== */

/*
  :root は、HTML文書全体で参照できるCSS変数を定義する場所。

  --gold などの名前で色を保存しておくと、
  複数の場所で同じ色を使用できる。

  色を変更したい場合も、ここを変更するだけでよい。
*/
:root {

  /* 金色。枠線や装飾などに使用する。 */
  --gold: #d7b66f;

  /* 明るい金色。タイトルなどを目立たせるために使用する。 */
  --gold-light: #f5dfa7;

  /* 基本となる文字色。 */
  --text: #f4e8cd;
}


/* ==========================================================
   3. ページ全体の設定
   ========================================================== */

body {

  /* ブラウザー標準の外側の余白を0にする。 */
  margin: 0;

  /* ページの最低高さを画面の高さ以上にする。
     vhはビューポートの高さを表す単位。
     100vhは画面の高さの100%を意味する。 */
  min-height: 100vh;

  /* 文字色に、先ほど定義したCSS変数を使用する。 */
  color: var(--text);

  /* 使用するフォントを指定する。
     左から順番に、利用可能なフォントが選ばれる。 */
  font-family:
    Georgia,
    "Yu Mincho",
    "Hiragino Mincho ProN",
    serif;

  /*
    背景を複数のグラデーションで構成する。

    radial-gradient():
    円形または楕円形に色が変化するグラデーション。

    linear-gradient():
    指定した方向に色が変化するグラデーション。

    背景を重ねることで、アンティーク調の
    暗い茶色と金色の雰囲気を作る。
  */
  background:
    radial-gradient(
      ellipse at 50% 18%,
      rgba(135, 86, 37, .38),
      transparent 52%
    ),
    linear-gradient(
      135deg,
      #1c120c,
      #49301d 50%,
      #1a100a
    );
}


/* ==========================================================
   4. ヘッダー
   ========================================================== */

/* ヘッダー全体の配置とデザインを設定する。 */
.header {

  /*
    display: flex;
    子要素をフレックスボックスで配置する。

    横方向に並べたり、余白を調整したりできる。
  */
  display: flex;

  /*
    子要素を左右に振り分ける。
    左側にタイトル、右側にボタンなどを配置する場合に便利。
  */
  justify-content: space-between;

  /* 子要素を縦方向の中央にそろえる。 */
  align-items: center;

  /* 子要素同士の間隔を18pxにする。 */
  gap: 18px;

  /*
    上下の余白は14px。
    左右の余白はclamp()で決定する。

    clamp(最小値, 推奨値, 最大値):
    画面幅に応じて値を変化させつつ、
    最小値と最大値を超えないようにする。

    3vwは画面幅の3%。
    画面が狭くても左右の余白は14px未満にならず、
    広くても42pxを超えない。
  */
  padding: 14px clamp(14px, 3vw, 42px);

  /* 透明度を持つ暗い背景色を設定する。
     rgba()の最後の値は透明度で、0が透明、1が不透明。 */
  background: rgba(13, 8, 5, .78);

  /* ヘッダー下部に薄い金色の境界線を付ける。 */
  border-bottom: 1px solid rgba(215, 182, 111, .4);
}


/* ページの見出し。 */
h1 {

  /* 見出しの標準的な上下余白をなくす。 */
  margin: 0;

  /* タイトルの文字色を明るい金色にする。 */
  color: var(--gold-light);

  /* 画面幅に応じて文字サイズを調整する。 */
  font-size: clamp(20px, 2.5vw, 30px);

  /* 文字同士の間隔を文字サイズの0.14倍にする。 */
  letter-spacing: .14em;
}


/* ヘッダー内の説明文。 */
.brand p {

  /* 上側に5px、左右と下側に0pxの余白を設定する。 */
  margin: 5px 0 0;

  /* 説明文を薄い金色にする。 */
  color: #d7c29d;

  /* 文字サイズを13pxにする。 */
  font-size: 13px;
}


/* ボタン全体の共通デザイン。 */
button {

  /* ボタン内部の余白を設定する。
     上下11px、左右15px。 */
  padding: 11px 15px;

  /* 金色の境界線を付ける。 */
  border: 1px solid #e0c17d;

  /* 角を5px丸くする。 */
  border-radius: 5px;

  /* 上下方向に色が変化する背景を設定する。 */
  background: linear-gradient(#f4dda1, #b68a40);

  /* ボタンの文字を暗い茶色にする。 */
  color: #25180b;

  /* 文字を太字にする。 */
  font-weight: bold;

  /* マウスポインターを手の形にする。 */
  cursor: pointer;

  /* ボタンの下に影を付け、立体感を出す。
     #0007は、黒色に約44%の不透明度を指定する書き方。 */
  box-shadow: 0 3px 9px #0007;
}


/* ==========================================================
   5. ステータス表示
   ========================================================== */

/* 左右の合計重量、重量差、メッセージをまとめる領域。 */
.status {

  /* 横幅の最大値を1500pxに制限する。 */
  max-width: 1500px;

  /* 上下に12pxの余白を付け、左右は自動調整する。
     左右のautoにより、領域が中央に配置される。 */
  margin: 12px auto;

  /* 左右に14pxの内側余白を付ける。 */
  padding: 0 14px;

  /*
    CSS Gridを有効にする。
    行と列を使って、子要素を表のように配置できる。
  */
  display: grid;

  /*
    4列のグリッドを作る。

    repeat(3, minmax(90px, 145px)):
    最初の3列は、それぞれ最低90px、最大145px。

    1fr:
    残りの幅を4列目に割り当てる。
    ここにはゲームのメッセージを配置する。
  */
  grid-template-columns:
    repeat(3, minmax(90px, 145px)) 1fr;

  /* グリッドの列や行の間隔を9pxにする。 */
  gap: 9px;

  /* 各子要素の高さ方向の配置をそろえる。 */
  align-items: stretch;
}


/*
  .statクラスの要素と、idがmessageの要素に
  共通のデザインを設定する。
*/
.stat,
#message {

  /* 最低の高さを58pxにする。 */
  min-height: 58px;

  /* 内側の余白を設定する。
     上下8px、左右12px。 */
  padding: 8px 12px;

  /* 半透明の金色で境界線を付ける。 */
  border: 1px solid rgba(215, 182, 111, .35);

  /* 角を6px丸くする。 */
  border-radius: 6px;

  /* 暗い背景色を設定する。 */
  background: rgba(24, 16, 10, .9);
}


/* ステータス欄の説明文字。 */
.stat span {

  /* span要素をブロック要素として表示する。
     これにより、前後の文字と改行して表示される。 */
  display: block;

  /* 説明文字を少し暗い金色にする。 */
  color: #cdb78d;

  /* 文字サイズを12pxにする。 */
  font-size: 12px;
}


/* ステータス欄の重量など、強調する文字。 */
.stat strong {

  /* strong要素をブロック要素として表示する。 */
  display: block;

  /* 説明文字との間に3pxの余白を付ける。 */
  margin-top: 3px;

  /* 強調する文字を明るい金色にする。 */
  color: #ffe6aa;

  /* 文字サイズを21pxにする。 */
  font-size: 21px;
}


/* ゲームの案内や結果を表示する領域。 */
#message {

  /* Flexboxを使い、メッセージを配置する。 */
  display: flex;

  /* メッセージを縦方向の中央に配置する。 */
  align-items: center;

  /* 他の要素と共通のmarginを個別に調整する。 */
  margin: 0;

  /* 文字サイズを13pxにする。 */
  font-size: 13px;

  /* 行の高さを文字サイズの1.5倍にする。 */
  line-height: 1.5;
}


/* ==========================================================
   6. ゲーム画面
   ========================================================== */

.game-board {

  /*
    position: relative;
    この要素を、内部のabsolute要素の位置決めの基準にする。

    例えば、内部の要素にleft: 50%と指定すると、
    原則としてこのゲーム画面を基準に位置が計算される。
  */
  position: relative;

  /*
    isolation: isolate;
    この要素の中に独立した重なり順の範囲を作る。

    内部のz-indexを管理しやすくするために使う。
  */
  isolation: isolate;

  /* 領域からはみ出した内容を隠す。
     アイテムなどがゲーム画面の外側に表示されるのを防ぐ。 */
  overflow: hidden;

  /* 横幅は1500px以下で、親要素の幅を超えないようにする。 */
  width: min(1500px, 100%);

  /* ゲーム画面の高さを760pxにする。 */
  height: 760px;

  /* ゲーム画面を中央に配置する。 */
  margin: 0 auto;

  /* 収納フレームの幅をゲーム画面の19%に設定する。
     子要素からvar(--frame-width)で参照できる。 */
  --frame-width: 19%;

  /* 背景を複数のグラデーションで構成する。 */
  background:
    radial-gradient(
      ellipse at 50% 30%,
      rgba(164, 111, 48, .14),
      transparent 58%
    ),
    linear-gradient(
      180deg,
      rgba(9, 6, 4, .08),
      rgba(8, 5, 3, .35)
    );

  /* 上部に薄い金色の境界線を付ける。 */
  border-top: 1px solid rgba(215, 182, 111, .15);

  /* 下部にも薄い金色の境界線を付ける。 */
  border-bottom: 1px solid rgba(215, 182, 111, .2);
}


/* ==========================================================
   7. 左右の収納フレーム
   ========================================================== */

/* 左右のアイテム置き場に共通する設定。 */
.item-frame {

  /*
    position: absolute;
    親要素を基準に、自由な位置へ配置する。

    この場合、position: relativeが指定された
    .game-boardが位置決めの基準になる。
  */
  position: absolute;

  /* 重なり順を0にする。
     天秤やアイテムより後ろに配置するための設定。 */
  z-index: 0;

  /* ゲーム画面の上端から3%下に配置する。 */
  top: 3%;

  /* フレームの横幅をCSS変数で設定する。 */
  width: var(--frame-width);

  /* ゲーム画面の高さの91%にする。 */
  height: 91%;

  /* フレームの内側に8pxの余白を付ける。 */
  padding: 8px;

  /* フレームからはみ出した内容を隠す。 */
  overflow: hidden;

  /* 外枠を3pxの金色にする。 */
  border: 3px solid #b58b45;

  /* outlineはborderの外側に描かれる線。
     枠の内側に見えるよう、位置を調整する。 */
  outline: 1px solid #60401d;
  outline-offset: -7px;

  /* 角を5px丸くする。 */
  border-radius: 5px;

  /* 暗い茶色を中心としたグラデーションを設定する。 */
  background: linear-gradient(
    135deg,
    #604025,
    #29180e 35%,
    #49301b 65%,
    #21130b
  );

  /*
    複数の影を重ねて立体感を出す。

    inset:
    要素の内側に影を描く。

    0 5px 14px #0009:
    下方向に影を付け、フレームが浮いているように見せる。
  */
  box-shadow:
    inset 0 0 0 2px #6f4d27,
    inset 0 0 18px #100904,
    0 5px 14px #0009;
}


/* 収納フレーム内部の装飾線。 */
.item-frame::before {

  /* 疑似要素を表示するための必須指定。
     空文字を設定すると、文字を表示しない装飾要素になる。 */
  content: "";

  /* 親フレームを基準に絶対配置する。 */
  position: absolute;

  /* 上下左右10pxの位置に配置する。 */
  inset: 10px;

  /* 内側に細い金色の枠線を描く。 */
  border: 1px solid #c5a05c;

  /* 内側に薄い枠状の影を付ける。 */
  box-shadow: inset 0 0 0 2px rgba(197, 160, 92, .18);

  /* 装飾要素がマウス操作の妨げにならないようにする。 */
  pointer-events: none;
}


/* 収納フレーム左上の星形装飾。 */
.item-frame::after {

  /* 星の文字を表示する。 */
  content: "✦";

  /* フレームを基準に絶対配置する。 */
  position: absolute;

  /* 上端から12pxの位置に配置する。 */
  top: 12px;

  /* 左端から12pxの位置に配置する。 */
  left: 12px;

  /* 星を明るい金色にする。 */
  color: #e1c27d;

  /* 星の大きさを17pxにする。 */
  font-size: 17px;

  /* 星の周囲に光るような影を付ける。 */
  text-shadow: 0 0 5px #9d7135;

  /* 星がマウス操作の妨げにならないようにする。 */
  pointer-events: none;
}


/* 左側の収納フレーム。 */
.left-frame {

  /* ゲーム画面の左端から1%の位置に配置する。 */
  left: 1%;
}


/* 右側の収納フレーム。 */
.right-frame {

  /* ゲーム画面の右端から1%の位置に配置する。 */
  right: 1%;
}


/* 収納フレームのタイトル。 */
.frame-title {

  /* フレームを基準に絶対配置する。 */
  position: absolute;

  /* フレームの上端から17pxの位置に配置する。 */
  top: 17px;

  /* 左端を0にする。 */
  left: 0;

  /* タイトルの表示幅をフレーム全体にする。 */
  width: 100%;

  /* 文字を中央揃えにする。 */
  text-align: center;

  /* 文字色を金色にする。 */
  color: #e4c788;

  /* 画面幅に応じて文字サイズを調整する。 */
  font-size: clamp(9px, 1vw, 13px);

  /* 文字同士の間隔を少し広げる。 */
  letter-spacing: .16em;

  /* 文字に暗い影を付け、背景から読み取りやすくする。 */
  text-shadow: 0 1px 3px #000;
}


/* ==========================================================
   8. 天秤本体の配置
   ========================================================== */

/* 天秤の画像をまとめて配置する領域。 */
.scale-assembly {

  /* ゲーム画面を基準に絶対配置する。 */
  position: absolute;

  /* 重なり順を1にする。
     収納フレームより手前、アイテムより後ろに配置する。 */
  z-index: 1;

  /* ゲーム画面の左端から中央へ50%移動した位置に配置する。 */
  left: 50%;

  /* ゲーム画面の上端から8pxの位置に配置する。 */
  top: 8px;

  /* 天秤をまとめる領域の幅を580pxにする。 */
  width: 580px;

  /* 天秤をまとめる領域の高さを650pxにする。 */
  height: 650px;

  /*
    自身の幅の50%だけ左へ移動する。
    left: 50%と組み合わせることで中央揃えになる。
  */
  transform: translateX(-50%);

  /* この領域自体はマウスやタッチ操作を受け取らない。
     下にあるアイテムの操作を妨げないための設定。 */
  pointer-events: none;
}


/* 天秤の本体画像。 */
.balance-body {

  /* 親要素を基準に絶対配置する。 */
  position: absolute;

  /* 親要素の横幅の中央に配置する。 */
  left: 50%;

  /* 親要素の上端に配置する。 */
  top: 0;

  /* 画像の高さを650pxにする。 */
  height: 650px;

  /* 幅は高さと画像本来の比率に応じて自動計算する。 */
  width: auto;

  /* 親要素の幅によって画像を縮小しないようにする。 */
  max-width: none;

  /* 画像の中心を親要素の中央に合わせる。 */
  transform: translateX(-50%);

  /* 画像の表示方法を指定する。
     containは、指定領域内に画像全体が収まるようにする。 */
  object-fit: contain;
}


/* ==========================================================
   9. 天秤の横棒
   ========================================================== */

.balance-rod {

  /* 絶対配置する。 */
  position: absolute;

  /* 本体より手前に表示する。 */
  z-index: 1;

  /* 親要素の横方向の中央に配置する。 */
  left: 50%;

  /* 親要素の上端から220pxの位置に配置する。 */
  top: 220px;

  /* 横棒の幅を580pxにする。 */
  width: 580px;

  /* 高さは画像本来の比率に合わせる。 */
  height: auto;

  /* 親要素の幅による自動縮小を防ぐ。 */
  max-width: none;

  /*
    横棒を中央に合わせ、回転角度を0度にする。

    JavaScriptは、このtransformを変更して
    横棒の傾きを表現する。
  */
  transform: translateX(-50%) rotate(0deg);

  /*
    回転の中心を、画像の横方向50%、縦方向50%にする。
    つまり、画像の中央を中心として回転する。
  */
  transform-origin: 50% 50%;

  /*
    transformが変更されたとき、0.38秒かけて滑らかに動かす。

    cubic-bezier()は、アニメーションの速度変化を指定する。
    数値によって、動き始めや止まるときの加減速が変わる。
  */
  transition: transform .38s cubic-bezier(.2, .75, .25, 1);
}


/* ==========================================================
   10. 天秤の針
   ========================================================== */

.balance-needle {

  /* 絶対配置する。 */
  position: absolute;

  /* 横棒より手前に表示する。 */
  z-index: 4;

  /* 親要素の横方向の50.2%の位置に配置する。 */
  /* 50%ではなく50.2%にして、画像の見た目に合わせている。 */
  left: 50.2%;

  /* 親要素の上端から62pxの位置に配置する。 */
  top: 62px;

  /* 針の画像の幅を29pxにする。 */
  width: 29px;

  /* 針の画像の高さを230pxにする。 */
  height: 230px;

  /* 親要素の幅による自動縮小を防ぐ。 */
  max-width: none;

  /* 針を中央に合わせ、回転角度を0度にする。 */
  transform: translateX(-50%) rotate(0deg);

  /*
    針の回転中心を設定する。

    50%は横方向の中央。
    82%は上端から高さの82%の位置。

    画像の中央ではなく下寄りを回転の中心にすることで、
    針の根元を中心に回転しているように見せる。
  */
  transform-origin: 50% 82%;

  /* 針の回転を0.38秒かけて滑らかにする。 */
  transition: transform .38s cubic-bezier(.2, .75, .25, 1);
}


/* ==========================================================
   11. 左右の皿
   ========================================================== */

/* 左右の皿に共通する設定。 */
.cup {

  /* 親要素を基準に絶対配置する。 */
  position: absolute;

  /* 横棒より手前に表示する。 */
  z-index: 2;

  /* 親要素の幅の37%を皿の幅にする。 */
  width: 37%;

  /* 高さは画像本来の縦横比に合わせる。 */
  height: auto;

  /* 親要素の幅による自動縮小を防ぐ。 */
  max-width: none;

  /* 画像全体が収まるように表示する。 */
  object-fit: contain;

  /*
    横方向の位置を、自身の幅の50%だけ左へ移動する。
    leftで指定した位置を画像の中央に合わせるための指定。

    縦方向は0px移動する。
  */
  transform: translate(-50%, 0);

  /* topの値が変更されたとき、0.38秒かけて滑らかに移動する。 */
  transition: top .38s cubic-bezier(.2, .75, .25, 1);
}


/* 左側の皿。 */
.left-cup {

  /*
    親要素の中央から270px左に配置する。

    calc()は、CSSで計算式を使うための関数。
    50% - 270pxという計算で位置を決める。
  */
  left: calc(50% - 270px);

  /* 親要素の上端から265pxの位置に配置する。 */
  top: 265px;
}


/* 右側の皿。 */
.right-cup {

  /* 親要素の中央から270px右に配置する。 */
  left: calc(50% + 270px);

  /* 親要素の上端から265pxの位置に配置する。 */
  top: 265px;
}


/* ==========================================================
   12. アイテム配置レイヤー
   ========================================================== */

.items-layer {

  /*
    絶対配置を有効にする。

    この要素はゲーム画面全体に重ねて配置するための領域。
    内部のアイテムは、この領域を基準に位置を指定できる。
  */
  position: absolute;

  /* 天秤本体より手前に配置する。 */
  z-index: 5;

  /*
    inset: 0は、top、right、bottom、leftを
    すべて0にする省略記法。

    そのため、親要素の上下左右いっぱいに広がる。
  */
  inset: 0;
}


/* ==========================================================
   13. アイテムの基本設定
   ========================================================== */

/* items-layerの中にあるitemクラスの要素を対象にする。 */
.items-layer .item {

  /* アイテムを自由な位置に配置できるようにする。 */
  position: absolute;

  /*
    アイテムの幅を画面幅に応じて調整する。

    clamp(25px, 3.5vw, 48px):
    最小25px、推奨値は画面幅の3.5%、最大48px。

    画面が狭くなると小さくなり、
    画面が広くなると大きくなる。
  */
  width: clamp(25px, 3.5vw, 48px);

  /* 高さも幅と同じ基準で調整する。 */
  height: clamp(25px, 3.5vw, 48px);

  /* アイテムの内側の余白をなくす。 */
  padding: 0;

  /* アイテムの外側の余白をなくす。 */
  margin: 0;

  /*
    画像全体が指定された幅と高さの中に収まるようにする。
    縦横比を維持したまま表示する。
  */
  object-fit: contain;

  /* 画像に境界線を付けない。 */
  border: 0;

  /* 角を丸くしない。 */
  border-radius: 0;

  /* アイテムの背景を透明にする。 */
  background: transparent;

  /* アイテム自体に影を付けない。 */
  box-shadow: none;

  /* 操作できるアイテムであることを示すため、
     マウスポインターを手の形にする。 */
  cursor: grab;

  /*
    touch-action: none;
    タッチ操作の標準的なスクロールや拡大操作を抑制する。

    アイテムを指でドラッグするときに、
    ページのスクロールなどが起きにくくなる。
  */
  touch-action: none;

  /* 文字や画像を選択しにくくする。 */
  user-select: none;

  /* WebKit系ブラウザーでの画像の標準ドラッグを無効にする。 */
  -webkit-user-drag: none;
}


/* 皿に置かれていないアイテムに影を付ける。 */
.items-layer .item:not(.placed) {

  /*
    :not(.placed)は、placedクラスを持たない要素を対象にする。

    drop-shadow()は、画像の不透明部分の形に沿って影を付ける。
    画像の周囲に影を付けることで、置き場の背景から見分けやすくする。
  */
  filter: drop-shadow(0 2px 2px #0009);
}


/* アイテムを操作しているときのマウスポインター。 */
.items-layer .item:active {

  /* 押している間は、つかんでいるようなカーソルにする。 */
  cursor: grabbing;
}


/* 皿に置かれたアイテム。 */
.items-layer .item.placed {

  /* 皿の上では幅を38pxにする。 */
  width: 38px;

  /* 皿の上では高さを38pxにする。 */
  height: 38px;
}


/* ドラッグ中のアイテム。 */
.items-layer .item.dragging {

  /*
    重なり順を30にする。

    通常のアイテムより大きい値にすることで、
    ドラッグ中のアイテムを手前に表示する。
  */
  z-index: 30;

  /* 少し透明にする。 */
  opacity: .94;

  /* ドラッグ中のアイテムを1.05倍に拡大する。
     1が元の大きさ、1.05が5%拡大。 */
  transform: scale(1.05);
}


/* ==========================================================
   14. ゲーム画面内の説明文
   ========================================================== */

.hint {

  /* 絶対配置する。 */
  position: absolute;

  /* 重なり順を8にし、他の要素より手前に表示する。 */
  z-index: 8;

  /* 親要素の横方向の中央に配置する。 */
  left: 50%;

  /* ゲーム画面の下端から8pxの位置に配置する。 */
  bottom: 8px;

  /* 自身の幅の50%だけ左へ移動し、中央揃えにする。 */
  transform: translateX(-50%);

  /*
    文字の幅に合わせて要素の幅を決める。

    max-widthと組み合わせることで、
    長い文章が画面からはみ出すのを抑える。
  */
  width: max-content;

  /* 最大幅を親要素の96%に制限する。 */
  max-width: 96%;

  /* 文字を中央揃えにする。 */
  text-align: center;

  /* 文字色を薄い金色にする。 */
  color: #d7c19a;

  /*
    fontの省略記法。

    12px:文字サイズ。
    1.5:行の高さ。
    sans-serif:ゴシック系のフォント。
  */
  font: 12px/1.5 sans-serif;

  /* 暗い影を付け、背景から文字を読みやすくする。 */
  text-shadow: 0 1px 3px #000;
}


/* ==========================================================
   15. フッター
   ========================================================== */

footer {

  /* Flexboxを有効にする。 */
  display: flex;

  /* 子要素を横方向の中央に配置する。 */
  justify-content: center;

  /*
    子要素が1行に収まらない場合は、次の行に折り返す。
  */
  flex-wrap: wrap;

  /*
    要素同士の間隔を設定する。

    10px:行と行の間隔。
    25px:横方向の間隔。
  */
  gap: 10px 25px;

  /* フッターの内側に14pxの余白を付ける。 */
  padding: 14px;

  /* 文字色を設定する。 */
  color: #bda780;

  /* 文字サイズ12px、フォントはsans-serif。 */
  font: 12px sans-serif;
}


/* ==========================================================
   16. タブレット・スマートフォン向けの設定
   ========================================================== */

/*
  メディアクエリー。

  画面幅が760px以下の場合に、
  この中のCSS設定を適用する。

  PCとスマートフォンで表示を切り替えるために使う。
*/
@media (max-width: 760px) {

  /* --------------------------------------------------------
     ヘッダー
     -------------------------------------------------------- */

  .header {

    /* 子要素を縦方向に並べる。 */
    align-items: flex-start;

    /* 横並びから縦並びに変更する。 */
    flex-direction: column;
  }


  /* --------------------------------------------------------
     ステータス表示
     -------------------------------------------------------- */

  .status {

    /* 3列を均等な幅で配置する。 */
    grid-template-columns: repeat(3, 1fr);

    /* 左右の余白を8pxにする。 */
    padding: 0 8px;
  }


  /* メッセージ欄。 */
  #message {

    /*
      グリッドの1列目から最終列までを使う。

      1 / -1は、最初のグリッド線から最後のグリッド線まで
      広げる指定。
    */
    grid-column: 1 / -1;

    /* メッセージ欄の最低高さを44pxにする。 */
    min-height: 44px;
  }


  /* --------------------------------------------------------
     ゲーム画面
     -------------------------------------------------------- */

  .game-board {

    /* スマートフォンでは縦方向の高さを900pxにする。 */
    height: 900px;

    /* 収納フレームの幅をゲーム画面の25%にする。 */
    --frame-width: 25%;
  }


  /* --------------------------------------------------------
     左右の収納フレーム
     -------------------------------------------------------- */

  .item-frame {

    /* 上端から2%の位置に配置する。 */
    top: 2%;

    /* 高さをゲーム画面の94%にする。 */
    height: 94%;

    /* 内側の余白を3pxに減らす。 */
    padding: 3px;

    /* 枠線を2pxに細くする。 */
    border-width: 2px;
  }


  /* 収納フレームの内側の装飾線。 */
  .item-frame::before {

    /* 上下左右の余白を5pxにする。 */
    inset: 5px;
  }


  /* 星形装飾。 */
  .item-frame::after {

    /* 上端から6pxに配置する。 */
    top: 6px;

    /* 左端から6pxに配置する。 */
    left: 6px;

    /* 星のサイズを12pxにする。 */
    font-size: 12px;
  }


  /* 収納フレームのタイトル。 */
  .frame-title {

    /* 上端から12pxの位置にする。 */
    top: 12px;

    /* 文字サイズを8pxにする。 */
    font-size: 8px;

    /* 文字間隔を小さくする。 */
    letter-spacing: .04em;
  }


  /* --------------------------------------------------------
     天秤本体
     -------------------------------------------------------- */

  .scale-assembly {

    /* 天秤をまとめる領域の幅を580pxに維持する。 */
    width: 580px;

    /*
      translateX(-50%)で中央に配置し、
      scale(.62)で全体を62%の大きさに縮小する。

      scale()の値が1なら元の大きさ、
      0.5なら半分、0.62なら元の62%になる。
    */
    transform: translateX(-50%) scale(.62);

    /*
      縮小の基準位置を上端の中央にする。

      top centerは、横方向の中央・縦方向の上端を意味する。
      これにより、縮小しても上端を基準に配置できる。
    */
    transform-origin: top center;
  }


  /* --------------------------------------------------------
     アイテム
     -------------------------------------------------------- */

  .items-layer .item {

    /* スマートフォンでは幅を24~32pxの範囲で調整する。 */
    width: clamp(24px, 4vw, 32px);

    /* 高さも同じ範囲で調整する。 */
    height: clamp(24px, 4vw, 32px);
  }


  /* 皿に置かれたアイテム。 */
  .items-layer .item.placed {

    /* 皿の上では幅を30pxにする。 */
    width: 30px;

    /* 皿の上では高さを30pxにする。 */
    height: 30px;
  }
}


/* ==========================================================
   17. 小型スマートフォン向けの設定
   ========================================================== */

/*
  画面幅が480px以下の場合に適用する。

  760px以下の設定に加えて、さらに細かい調整を行う。
*/
@media (max-width: 480px) {

  /* --------------------------------------------------------
     ゲーム画面
     -------------------------------------------------------- */

  .game-board {

    /* 収納フレームの幅を27%に広げる。 */
    --frame-width: 27%;
  }


  /* --------------------------------------------------------
     置き場のアイテム
     -------------------------------------------------------- */

  .items-layer .item {

    /* 小型スマートフォンでは幅を24pxに固定する。 */
    width: 24px;

    /* 高さも24pxに固定する。 */
    height: 24px;
  }


  /* --------------------------------------------------------
     皿に置かれたアイテム
     -------------------------------------------------------- */

  .items-layer .item.placed {

    /* 皿の上では幅を26pxにする。 */
    width: 26px;

    /* 皿の上では高さを26pxにする。 */
    height: 26px;
  }


  /* --------------------------------------------------------
     収納フレームのタイトル
     -------------------------------------------------------- */

  .frame-title {

    /* 文字サイズを8pxにする。 */
    font-size: 8px;

    /* 文字間隔を0にし、狭い画面でも収まりやすくする。 */
    letter-spacing: 0;
  }
}

画像の扱いについて
本ゲームで使用している画像素材には、著作権その他の権利が存在する場合があります。
画像素材の著作権は、原則として各素材の著作者または正当な権利者に帰属します。本ゲームでの使用は、画像素材の著作権を取得したことを意味するものではありません。
画像素材の複製、転載、再配布、販売、改変、他の作品への転用などを行う場合は、各素材の権利者および配布元が定める利用規約を確認し、必要に応じて許諾を得てください。
また、画像素材を利用・公開・配布する際は、各素材に適用されるライセンス条件やクレジット表記の要否を確認してください。
本ゲームに含まれる画像素材の権利および利用条件は、素材ごとに異なる場合があります。利用にあたっては、それぞれの配布元の規約をご確認ください。

デモを見てみる