ハンバーガーチャレンジのソースコード全文 — JavaScript・HTML・CSS

▶ ハンバーガーチャレンジで遊ぶ 📖 作り方を1から学ぶ

このページでは、ひなテックGamesで実際に動いている「ハンバーガーチャレンジ」のソースコードを丸ごと公開しています。ここに載っているのは説明用に書き直した特別なコードではなく、いまあなたが遊んでいるゲームそのものを動かしている本物のファイルです。

ハンバーガーチャレンジは、お客さんの注文(レシピ)どおりに、上から落ちてくる具材をお皿のバンズの上にキャッチして積み上げるアクションゲームです。注文と同じハンバーガーを完成させるとお客さんに提供でき、行列にならんだ次のお客さんがやってきます。注文にない具材を取ってしまうとスコアが減り、虫を取るとライフが減ります。「落ちてくるものを受け止める」というシンプルな仕組みの中に、当たり判定・配列を使った行列の管理・スコア計算・難易度の調整など、ゲームづくりの基本がぎゅっとつまっています。

コードはコピーして自分のパソコンで自由に動かせます。「どうやって具材が落ちてくるんだろう?」「注文どおりに作れたかはどう判定しているんだろう?」と思ったら、解説を読みながらコードを追いかけてみてください。

ハンバーガーチャレンジは3つのファイルでできている

ハンバーガーチャレンジは、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。

ファイル役割
index.htmlゲーム画面の骨組み。スコア表示・お客さんパネル(注文カードと行列)・ゲーム盤面などの部品をHTMLで配置する
style.css見た目のデザイン。色・大きさ・レイアウト・注文カードのハンバーガーの絵・オーバーレイの表示
game.jsゲームのルールを動かす頭脳。具材の落下・当たり判定・注文の判定・行列の管理・スコア計算・難易度調整など

この「HTMLで構造、CSSで見た目、JavaScriptで動き」という分担は、ほとんどのWebページ・Webゲームに共通する考え方です。

index.html — ゲーム画面の骨組み

index.htmlは、ブラウザが最初に読み込むファイルです。ここにはゲームの「部品」をHTMLで並べているだけで、動きそのものは書かれていません。

大きく分けると、(1)スコアとベスト記録を出すscore-area、(2)対応中のお客さん・注文カード・行列を出すcustomer-panel、(3)ゲーム本体を描く<canvas>、(4)スタートやゲームオーバーを伝えるgame-overlay、でできています。注文カードを表示するorder-cardや、並んでいるお客さんを入れるcustomer-queueは、中身が空っぽのまま置かれていて、JavaScriptがあとから中身を作って入れます。

ゲーム本体は<body>の中から始まります。

※ 下のコードでは、ゲームの動きと関係のない広告・アクセス解析用のタグ(ページ先頭の数行)を省いています。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  <title>ハンバーガーチャレンジ|無料ブラウザゲーム|ひなテックGames</title>
  <meta name="description" content="ハンバーガーチャレンジで遊ぼう!お客さんの注文どおりに、落ちてくる具材をお皿のバンズの上にキャッチして積み上げよう。行列のお客さんを次々さばいてハイスコアを目指そう。PC・スマホ対応の無料ブラウザゲーム。">
  <link rel="canonical" href="https://hinata-ya.tech/games/games/burger-challenge/">

  <!-- OGP -->
  <meta property="og:title" content="ハンバーガーチャレンジ|無料ブラウザゲーム|ひなテックGames">
  <meta property="og:description" content="お客さんの注文どおりに、落ちてくる具材をお皿のバンズの上にキャッチして積み上げよう!行列のお客さんを次々さばこう。">
  <meta property="og:type" content="website">
  <meta property="og:url" content="https://hinata-ya.tech/games/games/burger-challenge/">
  <meta property="og:site_name" content="ひなテックGames">
  <meta property="og:locale" content="ja_JP">

  <!-- 構造化データ -->
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "Game",
    "name": "ハンバーガーチャレンジ",
    "description": "上から落ちてくる具材をバンズでキャッチしてハンバーガーを作るアクションゲーム",
    "url": "https://hinata-ya.tech/games/games/burger-challenge/",
    "genre": ["アクション", "カジュアル"],
    "gamePlatform": "Web Browser",
    "publisher": {
      "@type": "Organization",
      "name": "ひなテック",
      "url": "https://hinata-ya.tech"
    }
  }
  </script>

  <link rel="stylesheet" href="../../css/style.css">
  <link rel="stylesheet" href="../../css/leaderboard.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div id="header"></div>

  <div class="game-page">
    <h1 class="game-page-title">ハンバーガーチャレンジ</h1>
    <div class="game-page-tags">
      <span class="tag">アクション</span>
      <span class="tag">カジュアル</span>
    </div>

    <div class="game-container">
      <!-- スコアエリア -->
      <div class="score-area">
        <div class="score-box">
          <span class="score-label">スコア</span>
          <span class="score-value" id="score">0</span>
        </div>
        <div class="score-box">
          <span class="score-label">ベスト</span>
          <span class="score-value" id="best-score">0</span>
        </div>
        <button class="btn-new-game" id="btn-new-game">New Game</button>
      </div>

      <!-- お客さんパネル -->
      <div class="customer-panel">
        <div class="customer-now">
          <span class="customer-face" id="customer-face">🙂</span>
          <span class="customer-type" id="customer-type">ふつうのおきゃく</span>
        </div>
        <div class="order-card" id="order-card" aria-label="お客さんの注文"></div>
        <div class="customer-side">
          <div class="queue-row">
            <span class="queue-label">つぎ</span>
            <div class="customer-queue" id="customer-queue"></div>
          </div>
          <span class="streak-box">れんぞく<b id="streak">0</b></span>
        </div>
      </div>

      <!-- ゲームボード -->
      <div class="board-wrapper">
        <canvas id="game-canvas"></canvas>

        <!-- ライフ表示 -->
        <div class="lives-display" id="lives-display"></div>

        <!-- ゲームオーバーオーバーレイ -->
        <div class="game-overlay" id="game-over-overlay">
          <div class="overlay-content">
            <h2>ゲームオーバー</h2>
            <p>スコア: <span id="final-score">0</span></p>
            <p class="best-result" id="best-result"></p>
            <button class="btn-primary" id="btn-retry">もう一度遊ぶ</button>
          </div>
        </div>

        <!-- スタートオーバーレイ -->
        <div class="game-overlay active" id="game-start-overlay">
          <div class="overlay-content">
            <h2>ハンバーガーチャレンジ</h2>
            <p>注文どおりに具材をキャッチして<br>ハンバーガーを積み上げよう!</p>
            <button class="btn-primary" id="btn-start">ゲームスタート</button>
          </div>
        </div>
      </div>
    </div>

    <div class="game-instructions">
      <h3>遊び方</h3>
      <ul>
        <li><strong>PC</strong>: マウスを動かす または 左右の矢印キーでお皿を操作</li>
        <li><strong>スマホ</strong>: 画面をタッチ&ドラッグでお皿を操作</li>
        <li>お皿には最初からバンズがのっています。具材をキャッチして積み上げよう!</li>
        <li>お客さんの右上に出る注文(ハンバーガーの絵)どおりの具材を集めよう</li>
        <li>注文にない具材を取るとハンバーガーに入ってしまい、スコアが減ります</li>
        <li>注文を完成させるとお客さんに提供。れんぞくが1つ増えて次のお客さんへ</li>
        <li>いもむし・ハエ・ゴキブリをキャッチするとライフが減ります</li>
        <li>ゴキブリは👑VIPのお客さんのときだけ出現(ライフ-2)</li>
        <li>進むほど注文が長くなり、れんぞくが3増えるごとに落ちるスピードがどんどんアップ!</li>
      </ul>
    </div>

    <div class="game-promo">
      <h3>このゲーム、自分でも作れるよ!</h3>
      <div class="promo-links">
        <a href="tutorial/" class="btn-primary">このゲームの作り方を見る →</a>
        <a href="https://hinata-ya.tech/contact/" class="btn-primary">無料体験に申し込む →</a>
        <a href="source/" class="btn-secondary">ソースコードを見る →</a>
      </div>
    </div>

    <div class="other-games">
      <a href="../../index.html">&larr; 他のゲームも遊ぶ</a>
    </div>
  </div>

  <div id="footer"></div>
  <script src="../../js/common.js" data-base="../../"></script>
  <script src="../../js/leaderboard.js"></script>
  <script src="game.js?v=20260517f"></script>
</body>
</html>

style.css — 見た目とレイアウト

style.cssは、ハンバーガーチャレンジの見た目を作っているファイルです。スコアやベスト記録を入れる.score-box、お客さんの様子を出す.customer-panelなど、画面の上半分の部品のデザインがここに書かれています。

注目してほしいのは.order-cardです。これはお客さんの右上にふきだしで出る「注文カード」で、.ord-bun(バンズ)や.ord-layer(具材1段)といった細いバーを縦に積み重ねて、小さなハンバーガーの絵を作っています。集め終わった具材は.filledクラスで緑のフチが付き、注文がどこまで進んだか分かるようになっています。

.board-wrapperaspect-ratio: 3 / 5という指定は、画面の横幅が変わっても盤面がいつも「縦長の同じ形」になるようにするためのものです。スタート画面やゲームオーバー画面の.game-overlayは、ふだんはdisplay: noneでかくれていて、.activeクラスが付いたときだけ表示されます。

style.css
/* ============================================
   ハンバーガーチャレンジ - 固有スタイル
   ============================================ */

/* スコアエリア */
.score-area {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 0.75rem;
}

.score-box {
  background: #BBADA0;
  border-radius: 8px;
  padding: 0.5rem 1rem;
  text-align: center;
  min-width: 70px;
}

.score-label {
  display: block;
  font-size: 0.7rem;
  color: #EEE4DA;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.score-value {
  display: block;
  font-size: 1.25rem;
  font-weight: 700;
  color: #fff;
}

.btn-new-game {
  margin-left: auto;
  background: #8F7A66;
  color: #fff;
  border: none;
  border-radius: 8px;
  padding: 0.5rem 1.25rem;
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-new-game:hover {
  background: #7A6658;
}

/* お客さんパネル */
.customer-panel {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  max-width: 400px;
  margin: 0 auto 0.75rem;
  background: #EEE4DA;
  border-radius: 8px;
  padding: 0.5rem 0.6rem;
}

/* 対応中のお客さん */
.customer-now {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  flex-shrink: 0;
  width: 58px;
}

.customer-face {
  font-size: 2rem;
  line-height: 1.1;
}

/* VIPのお客さんは顔を金色に光らせて目立たせる */
.customer-now.vip .customer-face {
  filter: drop-shadow(0 0 3px #D4A017);
}

.customer-type {
  font-size: 0.6rem;
  font-weight: 700;
  color: #776E65;
  text-align: center;
  margin-top: 2px;
}

/* VIP表示は金色のピルにして一目でわかるように */
.customer-type.vip {
  color: #fff;
  background: #D4A017;
  padding: 1px 7px;
  border-radius: 8px;
}

/* 注文カード(お客さんの右上に出るハンバーガーの絵) */
.order-card {
  position: relative;
  flex-shrink: 0;
  background: #fff;
  border: 2px solid #D8C5A2;
  border-radius: 10px;
  padding: 6px 8px 5px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.14);
}

.order-card.vip {
  border-color: #D4A017;
}

.order-card::before {
  content: '';
  position: absolute;
  left: -8px;
  top: 50%;
  transform: translateY(-50%);
  border: 6px solid transparent;
  border-right-color: #D8C5A2;
}

.order-card::after {
  content: '';
  position: absolute;
  left: -5px;
  top: 50%;
  transform: translateY(-50%);
  border: 5px solid transparent;
  border-right-color: #fff;
}

.order-card.vip::before {
  border-right-color: #D4A017;
}

.order-burger {
  display: flex;
  flex-direction: column;
  gap: 1px;
  width: 54px;
}

.ord-bun {
  width: 100%;
}

.ord-bun-top {
  height: 13px;
  background: #E8B468;
  border-radius: 9px 9px 4px 4px;
}

.ord-bun-bottom {
  height: 10px;
  background: #E0A85A;
  border-radius: 4px 4px 8px 8px;
}

.ord-layer {
  height: 15px;
  border-radius: 3px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 10.5px;
  box-shadow: inset 0 2px 2px rgba(255, 255, 255, 0.25);
  transition: opacity 0.2s ease;
}

/* まだ集めていない具材もはっきり見えるようにする(注文が読みやすい) */
.ord-layer.empty {
  opacity: 0.85;
}

/* 集め終わった具材は緑のフチで「できた」と分かるようにする */
.ord-layer.filled {
  opacity: 1;
  box-shadow: inset 0 2px 2px rgba(255, 255, 255, 0.25), inset 0 0 0 2px #2ECC71;
}

.order-done {
  font-size: 0.6rem;
  font-weight: 700;
  color: #2ECC71;
  text-align: center;
  margin-top: 3px;
}

/* 行列・れんぞく */
.customer-side {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.4rem;
  flex: 1;
  min-width: 0;
}

.queue-row {
  display: flex;
  align-items: center;
  gap: 0.3rem;
}

.queue-label {
  font-size: 0.6rem;
  font-weight: 700;
  color: #A89C8C;
}

.customer-queue {
  display: flex;
  gap: 0.15rem;
}

.q-face {
  position: relative;
  font-size: 1.2rem;
  line-height: 1;
}

/* 行列のVIPには王冠バッジを付ける */
.q-face.vip {
  filter: drop-shadow(0 0 2px #D4A017);
}

.q-face.vip::before {
  content: '👑';
  position: absolute;
  top: -7px;
  right: -5px;
  font-size: 0.62rem;
}

.streak-box {
  font-size: 0.65rem;
  color: #776E65;
  text-align: center;
  white-space: nowrap;
}

.streak-box b {
  font-size: 1.05rem;
  color: #E67E22;
  margin-left: 0.2rem;
}

/* ゲームボード */
.board-wrapper {
  position: relative;
  width: 100%;
  max-width: 400px;
  margin: 0 auto;
  aspect-ratio: 3 / 5;
  touch-action: none;
}

#game-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  background: #FBF3E4;
  border-radius: 8px;
  display: block;
}

/* ライフ表示 */
.lives-display {
  position: absolute;
  bottom: 8px;
  right: 12px;
  display: flex;
  gap: 4px;
  z-index: 5;
}

.life-icon {
  font-size: 14px;
  line-height: 1;
}

/* ゲームオーバーレイ */
.game-overlay {
  position: absolute;
  inset: 0;
  background: rgba(238, 228, 218, 0.9);
  border-radius: 8px;
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 10;
  animation: overlay-appear 0.3s ease;
}

.game-overlay.active {
  display: flex;
}

@keyframes overlay-appear {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

.overlay-content {
  text-align: center;
  padding: 1.5rem;
}

.overlay-content h2 {
  font-size: 1.6rem;
  color: #776E65;
  margin-bottom: 0.5rem;
}

.overlay-content p {
  font-size: 1.05rem;
  color: #776E65;
  margin-bottom: 0.75rem;
}

.overlay-content .best-result {
  font-size: 0.95rem;
  color: #E67E22;
  font-weight: 600;
  margin-bottom: 1rem;
}

/* レスポンシブ */
@media (max-width: 500px) {
  .board-wrapper { border-radius: 6px; }
  #game-canvas { border-radius: 6px; }
}

@media (max-width: 360px) {
  .score-area { gap: 0.5rem; }
  .score-box { padding: 0.4rem 0.75rem; min-width: 55px; }
  .score-value { font-size: 1rem; }
  .score-label { font-size: 0.6rem; }

  .customer-panel { gap: 0.35rem; padding: 0.45rem 0.45rem; }
  .customer-now { width: 48px; }
  .customer-face { font-size: 1.7rem; }
  .order-burger { width: 46px; }
  .ord-bun-top { height: 11px; }
  .ord-bun-bottom { height: 9px; }
  .ord-layer { height: 13px; font-size: 9px; }
  .q-face { font-size: 1rem; }
}

game.js — ゲームを動かす頭脳

game.jsがこのゲームの心臓部です。全体は(function () { ... })();という「即時実行関数」で囲まれ、変数が他のコードとぶつからないようになっています。

ファイルの先頭にはINGREDIENTS(バンズの中身になる具材)とBAD_ITEMS(拾ってはいけない虫)の2つのリストがあります。具材にはcolor(色)やpts(点数)、虫にはlife(減るライフの数)が決められていて、データと処理を分けているので、種類を増やしたい時はリストに1行足すだけで済みます。

お客さんはmakeCustomer()で作られ、makeRecipe()がそのお客さんの注文(どの具材を何段積むか)を決めます。作られたお客さんはqueueという配列に入って「行列」になり、先頭のqueue[0]がいま対応中のお客さんです。1人に提供するとadvanceQueue()が行列を1つ進め、うしろに新しいお客さんを足します。注文カードの絵はrenderOrderCard()が、行列の顔はrenderQueue()が、HTMLを組み立てて画面に表示しています。

毎フレーム動く中心はgameLoop()です。update()でゲームの状態を計算し、draw()で画面をかきなおし、最後にrequestAnimationFrameで「次のフレームでもまた自分を呼んでね」とお願いしています。これをくり返すことで、なめらかなアニメーションになります。

spawnItem()は具材や虫を画面の上に1つ作る関数です。ときどき虫を出しつつ、具材を出すときはNEEDED_BIASの確率で「いま注文に足りない具材」を選びます。ただし新しいお客さんになった直後の数個(FIRST_RANDOM_SPAWNS)はわざとランダムにして、いきなり注文どおりにそろわないようにしています。

キャッチできたかを決めるのがisCaught()stackTopY()です。stackTopY()は積み上がったハンバーガーの一番上の高さを計算し、落ちてきたアイテムがそこに届いてお皿の上にあれば「キャッチ」と判定します。具材をキャッチしたらplaceIngredient()が注文と照らし合わせ、必要な具材ならハンバーガーに積みます。注文にない具材を取ってしまうと、それもハンバーガーに入ってしまいスコアが減ります。すべての段がそろうとserveCustomer()でボーナス点とれんぞくが入り、次のお客さんへ進みます。

game.js
// ============================================
// ハンバーガーチャレンジ ロジック
// ============================================
// お客さんの注文(レシピ)どおりに、落ちてくる具材を
// お皿のバンズの上にキャッチして積み上げるゲーム。
// お客さんは行列をつくり、1人さばくたびに次のお客さんへ。

(function () {
  'use strict';

  // ============================================
  // 定数
  // ============================================
  var INITIAL_LIVES = 3;
  var VIP_CHANCE = 0.22;              // VIP出現確率
  var BAD_CHANCE = 0.2;               // 落下物が「虫」になる確率
  var NEEDED_BIAS = 0.72;             // 良アイテム生成時、注文中の具材を出す確率
  var FIRST_RANDOM_SPAWNS = 2;        // 新しいお客さんの最初の数個はランダムに出す
  var INITIAL_FALL_SPEED = 2.2;       // 初期落下速度
  var MAX_FALL_SPEED = 11;            // 最大落下速度
  var SPEED_PER_CUSTOMER = 0.16;      // お客さん1人ごとの速度上昇
  var STREAK_SPEED_BONUS = 0.45;      // れんぞく3ごとに加わる落下速度
  var INITIAL_SPAWN_INTERVAL = 64;    // 初期スポーン間隔(フレーム)
  var MIN_SPAWN_INTERVAL = 28;        // 最小スポーン間隔
  var SPAWN_DEC_PER_CUSTOMER = 2;     // お客さん1人ごとのスポーン間隔短縮
  var QUEUE_SIZE = 4;                 // 行列の人数(先頭のお客さん含む)
  var SERVE_ANIM_FRAMES = 34;         // 提供アニメの長さ(フレーム)
  var BUN_SPEED = 8;                  // キーボード操作時のお皿の移動速度

  // 具材(バンズの中身・拾っていいもの)
  var INGREDIENTS = [
    { id: 'patty',   emoji: '🥩', name: 'パティ',   pts: 6, color: '#7A4A28' },
    { id: 'cheese',  emoji: '🧀', name: 'チーズ',   pts: 8, color: '#F3B53C' },
    { id: 'lettuce', emoji: '🥬', name: 'レタス',   pts: 6, color: '#79B83E' },
    { id: 'tomato',  emoji: '🍅', name: 'トマト',   pts: 6, color: '#E0524C' },
    { id: 'bacon',   emoji: '🥓', name: 'ベーコン', pts: 9, color: '#C8533F' },
    { id: 'egg',     emoji: '🍳', name: 'たまご',   pts: 7, color: '#F4DDA0' }
  ];

  function ingById(id) {
    for (var i = 0; i < INGREDIENTS.length; i++) {
      if (INGREDIENTS[i].id === id) return INGREDIENTS[i];
    }
    return null;
  }

  // 虫(拾ってはいけないもの)
  var BAD_ITEMS = [
    { emoji: '🐛', name: 'いもむし', life: 1, vipOnly: false },
    { emoji: '🪰', name: 'ハエ',     life: 1, vipOnly: false },
    { emoji: '🪳', name: 'ゴキブリ', life: 2, vipOnly: true }
  ];

  // お客さんの顔
  // VIPは「着飾った人」の顔だけにして、ふつうのお客さん(動物含む)と
  // 一目で見分けられるようにする
  var NORMAL_FACES = ['🙂', '😀', '😋', '🤓', '😎', '🐱', '🐰', '🐻', '🐧', '🐸'];
  var VIP_FACES = ['🤵', '👸', '🤴', '🧐', '🥸'];

  // バンズ・お皿の色
  var BUN_BOTTOM_COLOR = '#E0A85A';
  var BUN_TOP_COLOR = '#E8B468';
  var PLATE_COLOR = '#E4DED2';
  var PLATE_EDGE = '#C7BFAF';

  // ============================================
  // 状態変数
  // ============================================
  var canvas, ctx;
  var canvasW, canvasH;
  var catcherX;                   // お皿の左端X座標
  var plateW, plateH, burgerW, bunH, layerH, itemRadius;
  var plateCenterY, stackBaseY;   // お皿の中心Y / バンズの底が乗るY

  var items = [];
  var effects = [];
  var score = 0;
  var bestScore = parseInt(localStorage.getItem('bestBurger') || '0', 10);
  var lives = INITIAL_LIVES;
  var streak = 0;
  var customersServed = 0;
  var queue = [];                 // お客さんの行列。queue[0]が対応中
  var customer = null;            // = queue[0]
  var gameRunning = false;
  var gameOverFlag = false;
  var animFrameId = null;
  var spawnCounter = 0;
  var customerSpawnCount = 0;     // 今のお客さんになってからのスポーン数
  var lastIngredientId = null;    // 直前に出した具材(連続で同じものを出さない用)
  var serving = 0;                // >0 の間は提供アニメ中
  var currentFallSpeed = INITIAL_FALL_SPEED;
  var currentSpawnInterval = INITIAL_SPAWN_INTERVAL;

  var keysDown = {};
  var mouseActive = false;
  var mouseX = 0;

  // ============================================
  // DOM要素
  // ============================================
  var scoreEl = document.getElementById('score');
  var bestScoreEl = document.getElementById('best-score');
  var livesDisplay = document.getElementById('lives-display');
  var customerFaceEl = document.getElementById('customer-face');
  var customerTypeEl = document.getElementById('customer-type');
  var orderCardEl = document.getElementById('order-card');
  var queueEl = document.getElementById('customer-queue');
  var streakEl = document.getElementById('streak');
  var gameOverOverlay = document.getElementById('game-over-overlay');
  var gameStartOverlay = document.getElementById('game-start-overlay');
  var finalScoreEl = document.getElementById('final-score');
  var bestResultEl = document.getElementById('best-result');

  canvas = document.getElementById('game-canvas');
  ctx = canvas.getContext('2d');

  // ============================================
  // キャンバスのリサイズ
  // ============================================
  function resizeCanvas() {
    var rect = canvas.getBoundingClientRect();
    var dpr = window.devicePixelRatio || 1;
    canvasW = rect.width;
    canvasH = rect.height;
    canvas.width = canvasW * dpr;
    canvas.height = canvasH * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    plateW = canvasW * 0.34;
    plateH = canvasH * 0.016;
    burgerW = plateW * 0.74;
    bunH = canvasH * 0.05;
    layerH = canvasH * 0.034;
    itemRadius = canvasW * 0.052;
    plateCenterY = canvasH - 14;
    stackBaseY = plateCenterY - 3;

    if (catcherX !== undefined) {
      catcherX = Math.max(0, Math.min(canvasW - plateW, catcherX));
    }
  }

  window.addEventListener('resize', resizeCanvas);

  // ============================================
  // お客さん・レシピの生成
  // ============================================
  function makeRecipe(vip) {
    // 進むほどレシピが長くなる(最初は2段)
    var baseLen = 2 + Math.floor(customersServed / 4);
    var len = vip ? Math.min(baseLen + 1, 5) : Math.min(baseLen, 4);
    var recipe = [];
    for (var i = 0; i < len; i++) {
      recipe.push(INGREDIENTS[Math.floor(Math.random() * INGREDIENTS.length)].id);
    }
    return recipe;
  }

  function makeCustomer() {
    var vip = Math.random() < VIP_CHANCE;
    var faces = vip ? VIP_FACES : NORMAL_FACES;
    var recipe = makeRecipe(vip);
    return {
      vip: vip,
      face: faces[Math.floor(Math.random() * faces.length)],
      recipe: recipe,
      filled: recipe.map(function () { return false; }),
      extras: []                  // まちがえて入れてしまった具材
    };
  }

  function buildQueue() {
    queue = [];
    for (var i = 0; i < QUEUE_SIZE; i++) {
      queue.push(makeCustomer());
    }
    customer = queue[0];
  }

  function isComplete(c) {
    for (var i = 0; i < c.filled.length; i++) {
      if (!c.filled[i]) return false;
    }
    return true;
  }

  function filledCount(c) {
    var n = 0;
    for (var i = 0; i < c.filled.length; i++) {
      if (c.filled[i]) n++;
    }
    return n;
  }

  // 具材をレシピに当てはめる。置けたらtrue
  function placeIngredient(ingId) {
    for (var i = 0; i < customer.recipe.length; i++) {
      if (customer.recipe[i] === ingId && !customer.filled[i]) {
        customer.filled[i] = true;
        return true;
      }
    }
    return false;
  }

  // ============================================
  // 表示更新
  // ============================================
  function updateLives() {
    livesDisplay.innerHTML = '';
    for (var i = 0; i < lives; i++) {
      var icon = document.createElement('span');
      icon.className = 'life-icon';
      icon.textContent = '❤️';
      livesDisplay.appendChild(icon);
    }
  }

  function updateScore() {
    scoreEl.textContent = score;
    if (score > bestScore) {
      bestScore = score;
      localStorage.setItem('bestBurger', bestScore.toString());
    }
    bestScoreEl.textContent = bestScore;
  }

  // お客さんの注文カード(右上のハンバーガーの絵)を描く
  function renderOrderCard() {
    var html = '<div class="order-burger">';
    html += '<div class="ord-bun ord-bun-top"></div>';
    // レシピを上段から順に表示
    for (var i = customer.recipe.length - 1; i >= 0; i--) {
      var ing = ingById(customer.recipe[i]);
      var cls = customer.filled[i] ? 'filled' : 'empty';
      html += '<div class="ord-layer ' + cls + '" style="background:' + ing.color + '">' +
        ing.emoji + '</div>';
    }
    html += '<div class="ord-bun ord-bun-bottom"></div>';
    html += '</div>';
    if (isComplete(customer)) {
      html += '<div class="order-done">できた!</div>';
    }
    orderCardEl.innerHTML = html;
    orderCardEl.classList.toggle('vip', customer.vip);
  }

  // 次に並んでいるお客さんを描く
  function renderQueue() {
    var html = '';
    for (var i = 1; i < queue.length; i++) {
      html += '<span class="q-face' + (queue[i].vip ? ' vip' : '') + '">' +
        queue[i].face + '</span>';
    }
    queueEl.innerHTML = html;
  }

  function updateCustomerPanel() {
    customerFaceEl.textContent = customer.face;
    customerFaceEl.parentElement.classList.toggle('vip', customer.vip);
    customerTypeEl.textContent = customer.vip ? '👑 VIP' : 'ふつう';
    customerTypeEl.className = 'customer-type' + (customer.vip ? ' vip' : '');
    streakEl.textContent = streak;
    renderOrderCard();
    renderQueue();
  }

  // ============================================
  // 難易度更新
  // ============================================
  function updateDifficulty() {
    // れんぞく3ごとに落下速度をどんどん上げる
    var streakBoost = Math.floor(streak / 3) * STREAK_SPEED_BONUS;
    currentFallSpeed = Math.min(
      INITIAL_FALL_SPEED + customersServed * SPEED_PER_CUSTOMER + streakBoost,
      MAX_FALL_SPEED
    );
    currentSpawnInterval = Math.max(
      INITIAL_SPAWN_INTERVAL - customersServed * SPAWN_DEC_PER_CUSTOMER,
      MIN_SPAWN_INTERVAL
    );
  }

  // ============================================
  // アイテム生成
  // ============================================
  function spawnItem() {
    var x = itemRadius + Math.random() * (canvasW - itemRadius * 2);
    var item = { x: x, y: -itemRadius, isBad: false, data: null };
    customerSpawnCount++;

    if (Math.random() < BAD_CHANCE) {
      // 虫: VIP以外のときはvipOnlyを除外
      var pool = BAD_ITEMS.filter(function (b) {
        return !b.vipOnly || customer.vip;
      });
      item.isBad = true;
      item.data = pool[Math.floor(Math.random() * pool.length)];
    } else {
      // 良アイテム: 高確率で「まだ足りない具材」を出す
      var needed = [];
      for (var i = 0; i < customer.recipe.length; i++) {
        if (!customer.filled[i]) needed.push(customer.recipe[i]);
      }
      // 新しいお客さんの最初の数個はわざとランダム(いきなり注文どおりだと面白くない)
      var biased = customerSpawnCount > FIRST_RANDOM_SPAWNS &&
        needed.length && Math.random() < NEEDED_BIAS;
      var poolIds = biased
        ? needed
        : INGREDIENTS.map(function (g) { return g.id; });
      // 直前と同じ具材は出さない
      var fresh = poolIds.filter(function (id) { return id !== lastIngredientId; });
      if (!fresh.length) {
        // 候補が直前の具材だけ(注文がその具材ばかり等)→ 他の具材から選んで連続を断つ
        fresh = INGREDIENTS
          .map(function (g) { return g.id; })
          .filter(function (id) { return id !== lastIngredientId; });
      }
      var pickedId = fresh[Math.floor(Math.random() * fresh.length)];
      item.data = ingById(pickedId);
      lastIngredientId = pickedId;
    }
    items.push(item);
  }

  // ============================================
  // キャッチ判定
  // ============================================
  // 現在のスタックの一番上のY座標(落下物はここで受け止める)
  function stackTopY() {
    var stacked = filledCount(customer) + customer.extras.length;
    return stackBaseY - bunH - stacked * layerH;
  }

  function isCaught(item, topY) {
    if (item.y + itemRadius < topY) return false;          // まだスタックに届いていない
    if (item.x < catcherX - itemRadius * 0.35) return false;
    if (item.x > catcherX + plateW + itemRadius * 0.35) return false;
    return true;
  }

  // ============================================
  // エフェクト
  // ============================================
  function addEffect(x, y, text, color) {
    effects.push({ x: x, y: y, text: text, color: color, alpha: 1, dy: -2 });
  }

  function updateEffects() {
    for (var i = effects.length - 1; i >= 0; i--) {
      effects[i].y += effects[i].dy;
      effects[i].alpha -= 0.025;
      if (effects[i].alpha <= 0) effects.splice(i, 1);
    }
  }

  // ============================================
  // 描画
  // ============================================
  function roundRectPath(x, y, w, h, rTop, rBottom) {
    ctx.beginPath();
    ctx.moveTo(x + rTop, y);
    ctx.lineTo(x + w - rTop, y);
    ctx.quadraticCurveTo(x + w, y, x + w, y + rTop);
    ctx.lineTo(x + w, y + h - rBottom);
    ctx.quadraticCurveTo(x + w, y + h, x + w - rBottom, y + h);
    ctx.lineTo(x + rBottom, y + h);
    ctx.quadraticCurveTo(x, y + h, x, y + h - rBottom);
    ctx.lineTo(x, y + rTop);
    ctx.quadraticCurveTo(x, y, x + rTop, y);
    ctx.closePath();
  }

  // バンズの底([bottomY - h, bottomY]の範囲)
  function drawBottomBun(cx, bottomY) {
    var x = cx - burgerW / 2;
    ctx.fillStyle = BUN_BOTTOM_COLOR;
    roundRectPath(x, bottomY - bunH, burgerW, bunH, 4, bunH * 0.45);
    ctx.fill();
  }

  // 具材1段([bottomY - layerH, bottomY]の範囲)
  function drawLayer(cx, bottomY, color) {
    var x = cx - burgerW / 2;
    ctx.fillStyle = color;
    roundRectPath(x, bottomY - layerH, burgerW, layerH, 3, 3);
    ctx.fill();
    // 上面のハイライト
    ctx.fillStyle = 'rgba(255,255,255,0.18)';
    roundRectPath(x, bottomY - layerH, burgerW, layerH * 0.4, 3, 0);
    ctx.fill();
  }

  // バンズのフタ(ドーム型・[bottomY - h, bottomY]の範囲)
  function drawTopBun(cx, bottomY) {
    var x = cx - burgerW / 2;
    var yb = bottomY;
    var yt = bottomY - bunH;
    ctx.fillStyle = BUN_TOP_COLOR;
    ctx.beginPath();
    ctx.moveTo(x, yb);
    ctx.lineTo(x, yt + bunH * 0.55);
    ctx.quadraticCurveTo(x, yt, x + burgerW * 0.5, yt);
    ctx.quadraticCurveTo(x + burgerW, yt, x + burgerW, yt + bunH * 0.55);
    ctx.lineTo(x + burgerW, yb);
    ctx.closePath();
    ctx.fill();
    // ゴマ
    ctx.fillStyle = '#FBEFD3';
    var sesame = [0.32, 0.5, 0.68];
    for (var i = 0; i < sesame.length; i++) {
      ctx.beginPath();
      ctx.ellipse(x + burgerW * sesame[i], yt + bunH * 0.55, 2.6, 1.6, 0, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // お皿+積み上がったハンバーガー
  function drawCatcher(yOffset, alpha, showTop) {
    var cx = catcherX + plateW / 2;

    // お皿
    ctx.save();
    ctx.fillStyle = PLATE_COLOR;
    ctx.beginPath();
    ctx.ellipse(cx, plateCenterY, plateW / 2, plateH, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = PLATE_EDGE;
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.restore();

    // ハンバーガー本体
    ctx.save();
    ctx.globalAlpha = alpha == null ? 1 : alpha;
    var y = stackBaseY - (yOffset || 0);
    drawBottomBun(cx, y);
    y -= bunH;
    for (var i = 0; i < customer.recipe.length; i++) {
      if (!customer.filled[i]) continue;
      drawLayer(cx, y, ingById(customer.recipe[i]).color);
      y -= layerH;
    }
    // 注文にない具材(まちがえて入れたもの)も積み上がる
    for (var k = 0; k < customer.extras.length; k++) {
      drawLayer(cx, y, customer.extras[k].color);
      y -= layerH;
    }
    if (showTop) {
      drawTopBun(cx, y);
    }
    ctx.restore();
  }

  function draw() {
    ctx.fillStyle = '#FBF3E4';
    ctx.fillRect(0, 0, canvasW, canvasH);

    // カウンター(地面)ライン
    ctx.strokeStyle = '#E8D9BC';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(0, plateCenterY + plateH + 5);
    ctx.lineTo(canvasW, plateCenterY + plateH + 5);
    ctx.stroke();

    // 落下アイテム(絵文字)
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.font = Math.round(itemRadius * 2) + 'px sans-serif';
    for (var i = 0; i < items.length; i++) {
      ctx.fillText(items[i].data.emoji, items[i].x, items[i].y);
    }

    // お皿+ハンバーガー(提供アニメ中は浮き上がって薄くなる)
    if (serving > 0) {
      var p = 1 - serving / SERVE_ANIM_FRAMES;        // 0→1
      var ease = 1 - (1 - p) * (1 - p);
      var lift = ease * canvasH * 0.16;
      var fade = p < 0.45 ? 1 : 1 - (p - 0.45) / 0.55;
      drawCatcher(lift, fade, true);
    } else {
      drawCatcher(0, 1, false);
    }

    // エフェクト
    ctx.font = 'bold ' + Math.round(canvasW * 0.05) + 'px sans-serif';
    for (var j = 0; j < effects.length; j++) {
      var e = effects[j];
      ctx.globalAlpha = e.alpha;
      ctx.fillStyle = e.color;
      ctx.fillText(e.text, e.x, e.y);
    }
    ctx.globalAlpha = 1;
  }

  // ============================================
  // お客さんに提供(注文完成)
  // ============================================
  function serveCustomer() {
    var len = customer.recipe.length;
    var bonus = (customer.vip ? 20 : 12) * len;
    score += bonus;
    customersServed++;
    streak++;
    serving = SERVE_ANIM_FRAMES;
    // 虫だけ片付け、落下中の具材はそのまま次のお客さんへ引き継ぐ
    items = items.filter(function (it) { return !it.isBad; });

    customerFaceEl.textContent = '😋';
    streakEl.textContent = streak;
    addEffect(canvasW / 2, canvasH * 0.4, '+' + bonus + ' 🍔', '#D4A017');

    updateScore();
    updateDifficulty();
  }

  // 提供アニメ終了 → 行列を進める
  function advanceQueue() {
    queue.shift();
    queue.push(makeCustomer());
    customer = queue[0];
    customerSpawnCount = 0;     // 新しいお客さんの最初の数個はランダムに
    updateCustomerPanel();
  }

  // ============================================
  // アイテムの移動・キャッチ処理
  // ============================================
  function moveItems(allowCatch) {
    for (var i = items.length - 1; i >= 0; i--) {
      items[i].y += currentFallSpeed;

      if (allowCatch && isCaught(items[i], stackTopY())) {
        var it = items[i];
        if (it.isBad) {
          lives -= it.data.life;
          updateLives();
          addEffect(it.x, stackTopY(), '-' + it.data.life, '#E74C3C');
          items.splice(i, 1);
          if (lives <= 0) {
            gameOver();
            return;
          }
        } else if (placeIngredient(it.data.id)) {
          // 注文に必要な具材 → 積み上げ成功
          score += it.data.pts;
          addEffect(it.x, stackTopY(), '+' + it.data.pts, '#2ECC71');
          items.splice(i, 1);
          updateScore();
          renderOrderCard();
          if (isComplete(customer)) {
            serveCustomer();
            return;            // 提供開始。このフレームの残りはキャッチしない
          }
        } else {
          // 注文にない具材 → ハンバーガーに入ってしまい、スコアが減る
          customer.extras.push(it.data);
          score = Math.max(0, score - it.data.pts);
          addEffect(it.x, stackTopY(), '-' + it.data.pts, '#E67E22');
          items.splice(i, 1);
          updateScore();
        }
        continue;
      }

      if (items[i] && items[i].y - itemRadius > canvasH) {
        items.splice(i, 1);
      }
    }
  }

  // ============================================
  // 1フレーム更新
  // ============================================
  function update() {
    if (!gameRunning || gameOverFlag) return;

    // お皿の移動
    if (keysDown['ArrowLeft']) catcherX -= BUN_SPEED;
    if (keysDown['ArrowRight']) catcherX += BUN_SPEED;
    if (mouseActive) catcherX = mouseX - plateW / 2;
    catcherX = Math.max(0, Math.min(canvasW - plateW, catcherX));

    if (serving > 0) {
      // 提供アニメ中はキャッチ判定なし
      serving--;
      moveItems(false);
      updateEffects();
      if (serving === 0) advanceQueue();
      return;
    }

    spawnCounter++;
    if (spawnCounter >= currentSpawnInterval) {
      spawnCounter = 0;
      spawnItem();
    }

    moveItems(true);
    updateEffects();
  }

  function gameLoop() {
    update();
    draw();
    if (gameRunning && !gameOverFlag) {
      animFrameId = requestAnimationFrame(gameLoop);
    }
  }

  // ============================================
  // ゲームオーバー
  // ============================================
  function gameOver() {
    gameRunning = false;
    gameOverFlag = true;
    if (animFrameId) {
      cancelAnimationFrame(animFrameId);
      animFrameId = null;
    }
    draw();

    finalScoreEl.textContent = score;
    if (score >= bestScore && score > 0) {
      bestResultEl.textContent = 'ハイスコア更新!';
    } else {
      bestResultEl.textContent = 'ベスト: ' + bestScore;
    }

    setTimeout(function () {
      gameOverOverlay.classList.add('active');
      if (window.Leaderboard && score > 0) Leaderboard.show('burger-challenge', score);
    }, 300);
  }

  // ============================================
  // ゲーム開始
  // ============================================
  function startGame() {
    if (animFrameId) {
      cancelAnimationFrame(animFrameId);
      animFrameId = null;
    }

    score = 0;
    lives = INITIAL_LIVES;
    streak = 0;
    customersServed = 0;
    gameOverFlag = false;
    items = [];
    effects = [];
    spawnCounter = 0;
    customerSpawnCount = 0;
    lastIngredientId = null;
    serving = 0;
    currentFallSpeed = INITIAL_FALL_SPEED;
    currentSpawnInterval = INITIAL_SPAWN_INTERVAL;
    mouseActive = false;

    resizeCanvas();
    catcherX = (canvasW - plateW) / 2;

    buildQueue();
    updateScore();
    updateLives();
    updateCustomerPanel();

    gameOverOverlay.classList.remove('active');
    gameStartOverlay.classList.remove('active');

    gameRunning = true;
    animFrameId = requestAnimationFrame(gameLoop);
  }

  // ============================================
  // キーボード操作
  // ============================================
  document.addEventListener('keydown', function (e) {
    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
      e.preventDefault();
      keysDown[e.key] = true;
      mouseActive = false;
      if (!gameRunning && !gameOverFlag) startGame();
    }
  });

  document.addEventListener('keyup', function (e) {
    delete keysDown[e.key];
  });

  // ============================================
  // マウス・タッチ操作
  // ============================================
  var boardWrapper = canvas.parentElement;

  boardWrapper.addEventListener('mousemove', function (e) {
    if (!gameRunning) return;
    var rect = canvas.getBoundingClientRect();
    mouseX = e.clientX - rect.left;
    mouseActive = true;
  });

  boardWrapper.addEventListener('mousedown', function (e) {
    if (!gameRunning && !gameOverFlag) {
      if (e.target.tagName === 'BUTTON') return;
      var rect = canvas.getBoundingClientRect();
      mouseX = e.clientX - rect.left;
      mouseActive = true;
      startGame();
    }
  });

  boardWrapper.addEventListener('touchstart', function (e) {
    if (e.target.tagName === 'BUTTON') return;
    e.preventDefault();
    if (e.touches.length === 1) {
      var rect = canvas.getBoundingClientRect();
      mouseX = e.touches[0].clientX - rect.left;
      mouseActive = true;
      if (!gameRunning && !gameOverFlag) startGame();
    }
  }, { passive: false });

  boardWrapper.addEventListener('touchmove', function (e) {
    e.preventDefault();
    if (e.touches.length === 1 && gameRunning) {
      var rect = canvas.getBoundingClientRect();
      mouseX = e.touches[0].clientX - rect.left;
      mouseActive = true;
    }
  }, { passive: false });

  boardWrapper.addEventListener('touchend', function () {
    mouseActive = false;
  }, { passive: true });

  // ============================================
  // ボタン
  // ============================================
  document.getElementById('btn-new-game').addEventListener('click', startGame);
  document.getElementById('btn-retry').addEventListener('click', startGame);
  document.getElementById('btn-start').addEventListener('click', startGame);

  document.addEventListener('visibilitychange', function () {
    if (document.hidden && gameRunning) {
      if (animFrameId) {
        cancelAnimationFrame(animFrameId);
        animFrameId = null;
      }
      gameRunning = false;
    }
  });

  // ============================================
  // 初期化
  // ============================================
  resizeCanvas();
  bestScoreEl.textContent = bestScore;
  catcherX = (canvasW - plateW) / 2;
  buildQueue();
  updateCustomerPanel();
  updateLives();
  draw();
})();

このコードで学べるポイント

自分のパソコンで動かすには

上のコードをそれぞれのファイル名で同じフォルダに保存し、index.htmlをブラウザで開けばゲームが動きます。各コードブロックのファイル名バーにある「コピー」ボタンを使うと便利です。

このゲームはサイト全体で共通して使うファイル(共通CSSやヘッダー、ランキング機能)も少し利用しているため、ソースのファイルだけで開くとヘッダーやランキングなどサイト共通の部分は表示されません。でもゲーム本体はちゃんと動くので、仕組みを学んだり改造してためすには十分です。

慣れてきたら、具材を増やしたり点数を変えたり、自由に改造してみてください。コードを「読む」次は「いじってみる」のがいちばんの上達法です。

このコードは使っていいの?

このソースコードは、プログラミングの勉強のために自由に読んだり、コピーして動かしたり、改造したりして大丈夫です。学校の授業や自由研究で参考にするのも歓迎します。ぜひ「自分だったらどう作るか」を考えるきっかけにしてください。

ただし、コードをそのまままるごとコピーして自分の作品やサービスとして公開・配布するのはご遠慮ください。あくまで学習用としてお使いください。

もっと学びたい人へ

コードを読んで「自分でも作ってみたい!」と思ったら、ハンバーガーチャレンジの作り方ガイドがおすすめです。このページが「完成したコード」を見せるのに対して、作り方ガイドは何もない状態から1ステップずつ組み立てていく流れを解説しています。

ひなテックでは、こうしたゲームづくりを先生といっしょに楽しく学べる教室を開いています。「コードを読むだけじゃなく、ちゃんと作れるようになりたい」という人は、無料体験にぜひ来てみてください。