単語探しのソースコード全文 — JavaScript・HTML・CSS

▶ 単語探しで遊ぶ 📖 作り方を1から学ぶ

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

単語探しは、10×10の文字グリッドにかくれた英単語をドラッグで見つけ出すパズルです。「マス目に単語をうまく配置する」「ドラッグでなぞった範囲を計算する」「なぞった文字が単語と合っているか調べる」という、ちょっと高度なプログラムの考え方が学べる題材です。

コードはコピーして自分のパソコンで自由に動かせます。「単語がどうやってかくされているんだろう?」と思ったら、解説を読みながらコードを追いかけてみてください。

単語探しは3つのファイルでできている

単語探しは、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。

ファイル役割
index.htmlゲーム画面の骨組み。盤面・スコア表示・ボタンなどの部品をHTMLで配置する
style.css見た目のデザイン。色・大きさ・レイアウト・アニメーション
game.jsゲームのルールを動かす頭脳。操作の受け付け・判定・スコア計算など

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

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

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

大きく分けると、(1)タイムとベストを出すscore-area、(2)文字をならべるletter-grid、(3)さがす単語の一覧word-list、でできています。注目してほしいのはletter-gridの中が空っぽなことです。100個の文字マスも、さがす単語のリストも、毎回ちがう内容になるのでJavaScriptがあとから作って入れます。クリア画面はgame-overlayとして重ねてあります。

ゲーム本体は<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="単語探し(ワードサーチ)ゲーム。10x10のグリッドに隠された英単語を見つけよう!PC・スマホ対応の無料ブラウザゲーム。">
  <link rel="canonical" href="https://hinata-ya.tech/games/games/word-search/">
  <!-- OGP -->
  <meta property="og:title" content="単語探し|無料ブラウザゲーム|ひなテックGames">
  <meta property="og:description" content="10x10のグリッドに隠された英単語を見つけよう!PC・スマホ対応の無料ブラウザゲーム。">
  <meta property="og:type" content="website">
  <meta property="og:url" content="https://hinata-ya.tech/games/games/word-search/">
  <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": "10x10のグリッドに隠された英単語を見つけるパズルゲーム",
    "url": "https://hinata-ya.tech/games/games/word-search/",
    "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="style.css">
  <link rel="stylesheet" href="../../css/leaderboard.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="timer">0:00</span>
        </div>
        <div class="score-box">
          <span class="score-label">ベスト</span>
          <span class="score-value" id="best-time">-</span>
        </div>
        <button class="btn-new-game" id="btn-new-game">New Game</button>
      </div>

      <!-- ゲームボード -->
      <div class="board-container" id="board-container">
        <!-- レターグリッド -->
        <div class="letter-grid" id="letter-grid">
          <!-- セルはJSで生成 -->
        </div>

        <!-- 単語リスト -->
        <div class="word-list" id="word-list">
          <h3 class="word-list-title">単語リスト</h3>
          <div class="word-list-items" id="word-list-items">
            <!-- 単語はJSで生成 -->
          </div>
        </div>

        <!-- ゲームクリアオーバーレイ -->
        <div class="game-overlay" id="game-complete-overlay">
          <div class="overlay-content">
            <h2>クリア!</h2>
            <p class="result-text">
              タイム: <span id="final-time">0:00</span>
            </p>
            <p class="best-text" id="best-text"></p>
            <button class="btn-primary" id="btn-play-again">もう一度遊ぶ</button>
          </div>
        </div>
      </div>
    </div>

    <div class="game-instructions">
      <h3>遊び方</h3>
      <ul>
        <li>10x10のグリッドの中に英単語が隠されています</li>
        <li>単語は横(左から右)または縦(上から下)に配置されています</li>
        <li>最初の文字をクリック(タップ)してから、最後の文字までドラッグして単語を選択します</li>
        <li>正しい単語を見つけると、リストにチェックが入ります</li>
        <li>すべての単語を見つけたらクリア!</li>
        <li>できるだけ早く見つけよう!</li>
      </ul>
    </div>

    <div class="game-promo">
      <h3>このゲーム、自分でも作れるよ!</h3>
      <p>
        単語探しは2次元配列のグリッド操作と<br>
        ドラッグ選択の座標計算で作られています。<br><br>
        ひなテックでは、こうしたゲームの<br>
        作り方を楽しく学べます!
      </p>
      <div class="promo-links">
        <a href="tutorial/" class="btn-primary">このゲームの作り方を見る →</a>
        <a href="https://hinata-ya.tech/contact/" class="btn-primary">無料体験に申し込む →</a>
        <a href="https://github.com/mooosung/hinatech-games/tree/main/games/word-search" 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?v=20260317" data-base="../../"></script>
  <script src="../../js/leaderboard.js"></script>
  <script src="game.js"></script>
</body>
</html>

style.css — 見た目とアニメーション

style.cssは、単語探しの見た目を作っているファイルです。100個の文字マスはdisplay:gridで10×10にきれいに並べています。

マスの色は状態によって変わります。ドラッグでなぞっている最中のマスには.selecting、見つけた単語のマスには.foundクラスが付き、それぞれ色が変わります。まちがった選択をしたときに一瞬赤くなるのは.invalidクラスです。

単語を見つけた瞬間にマスがパッと光るのは.just-foundのアニメーションで、さがす単語のリストでも見つけた単語は.foundクラスでチェックが付きます。

style.css
/* ============================================
   単語探し(ワードサーチ)ゲーム - 固有スタイル
   ============================================ */

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

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

.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 {
  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;
  white-space: nowrap;
  font-family: inherit;
}

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

/* ゲームボード */
.board-container {
  position: relative;
  width: 100%;
  max-width: 500px;
  margin: 0 auto;
}

/* レターグリッド */
.letter-grid {
  display: grid;
  grid-template-columns: repeat(10, 1fr);
  gap: 3px;
  padding: 10px;
  background: #BBADA0;
  border-radius: 12px;
  user-select: none;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  touch-action: none;
}

/* レターセル */
.letter-cell {
  aspect-ratio: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #EEE4DA;
  border-radius: 6px;
  font-size: 1.1rem;
  font-weight: 700;
  color: #776E65;
  cursor: pointer;
  transition: background 0.15s, color 0.15s, transform 0.1s;
  -webkit-tap-highlight-color: transparent;
  position: relative;
}

/* ホバー(デスクトップ) */
@media (hover: hover) {
  .letter-cell:not(.found):hover {
    background: #D6C4B4;
    transform: scale(1.05);
  }
}

/* 選択中のセル */
.letter-cell.selecting {
  background: #F39C12;
  color: #fff;
  transform: scale(1.08);
}

/* 見つかった単語のセル */
.letter-cell.found {
  background: #E67E22;
  color: #fff;
  cursor: default;
}

/* 無効な選択時 */
.letter-cell.invalid {
  background: #E74C3C;
  color: #fff;
}

@keyframes cell-found-pop {
  0% { transform: scale(1); }
  40% { transform: scale(1.2); }
  100% { transform: scale(1); }
}

.letter-cell.just-found {
  animation: cell-found-pop 0.4s ease;
}

/* 単語リスト */
.word-list {
  margin-top: 1rem;
  background: #fff;
  border-radius: 12px;
  padding: 1rem 1.25rem;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.word-list-title {
  font-size: 0.9rem;
  color: #776E65;
  margin-bottom: 0.75rem;
  font-weight: 600;
}

.word-list-items {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.word-item {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.35rem 0.75rem;
  border-radius: 100px;
  font-size: 0.85rem;
  font-weight: 600;
  background: #F5F0EB;
  color: #776E65;
  transition: background 0.3s, color 0.3s;
  letter-spacing: 0.05em;
}

.word-item .word-check {
  display: none;
  color: #27AE60;
  font-weight: 700;
}

.word-item.found {
  background: #E8F8F0;
  color: #27AE60;
  text-decoration: line-through;
}

.word-item.found .word-check {
  display: inline;
}

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

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

@keyframes overlay-appear {
  0% {
    opacity: 0;
    transform: scale(0.9);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}

.overlay-content {
  text-align: center;
  padding: 2rem 1.5rem;
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
  max-width: 300px;
  width: 90%;
}

.overlay-content h2 {
  font-size: 1.75rem;
  color: #E67E22;
  margin-bottom: 0.75rem;
}

.result-text {
  font-size: 1.05rem;
  color: #776E65;
  margin-bottom: 0.5rem;
  line-height: 1.8;
}

.best-text {
  font-size: 0.85rem;
  color: #E67E22;
  font-weight: 600;
  margin-bottom: 1rem;
  min-height: 1.2em;
}

.overlay-content .btn-primary {
  margin-top: 0.5rem;
}

/* 選択ライン表示用SVG */
.selection-line-svg {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 5;
}

.selection-line-svg line {
  stroke: rgba(243, 156, 18, 0.5);
  stroke-width: 3;
  stroke-linecap: round;
}

/* レスポンシブ */
@media (max-width: 500px) {
  .letter-grid {
    gap: 2px;
    padding: 8px;
  }

  .letter-cell {
    font-size: 0.95rem;
    border-radius: 4px;
  }

  .word-list {
    padding: 0.75rem 1rem;
  }

  .word-item {
    font-size: 0.8rem;
    padding: 0.3rem 0.6rem;
  }
}

@media (max-width: 400px) {
  .letter-grid {
    gap: 2px;
    padding: 6px;
  }

  .letter-cell {
    font-size: 0.8rem;
    border-radius: 3px;
  }

  .score-box {
    padding: 0.4rem 0.5rem;
    min-width: 55px;
  }

  .score-value {
    font-size: 1rem;
  }

  .word-item {
    font-size: 0.75rem;
    padding: 0.25rem 0.5rem;
  }
}

@media (max-width: 350px) {
  .letter-cell {
    font-size: 0.7rem;
  }
}

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

game.jsがこのゲームの心臓部です。少し長いですが、やっていることを順番に追えば必ず理解できます。全体は(function () { ... })();という「即時実行関数」で囲まれています。

このゲームでいちばん面白いのが、毎回ちがうパズルを作るgenerateGrid()です。まずWORD_POOLから単語をshuffle()(フィッシャー・イェーツのシャッフル)で混ぜて何語か選びます。次に1語ずつ、たて向き・よこ向き・置く場所をランダムに試しながら、canPlaceWord()で「そこに置けるか」を確かめてplaceWord()でグリッドに書きこみます。canPlaceWord()は、はみ出さないかと、すでにある文字とぶつからないか(同じ文字なら重ねてOK)を調べます。うまく置けないこともあるので、最大100回までやり直す作りになっているのが堅実なところです。最後に、文字が入っていない空マスをrandomLetter()でランダムなアルファベットで埋めれば、単語がまぎれて見えなくなります。

もう1つの主役がドラッグ選択です。onPointerDownでなぞり始め、onPointerMoveでなぞるあいだ、onPointerUpで指を離したときの3つで処理します。なぞった範囲を計算するのがgetLineCells()です。始点と終点の行・列をくらべて、同じ行ならよこ一直線、同じ列ならたて一直線のマスを並べます。ななめは単語が置かれていないので、空っぽを返してわざと無効にしています。

指を離すとcheckSelection()が、なぞった文字をつなげた言葉が単語リストにあるか調べます。文字が合うだけでなくcellsMatch()でマスの位置までぴったり一致するかを確かめているので、たまたま同じ文字が並んでいるだけでは正解になりません。正解ならmarkWordFound()がマスを光らせ、すべての単語を見つけるとhandleGameComplete()でクリアです。マウスとタッチの両方のイベントをonPointerDownなどで共通に受けているので、PCでもスマホでも同じように遊べます。タイムはsetIntervalで数え、ベストタイムはlocalStorageに保存されます。

game.js
// ============================================
// 単語探し(ワードサーチ)ゲームロジック
// ============================================

(function () {
  'use strict';

  // グリッドサイズ
  var GRID_SIZE = 10;

  // 単語プール(約20語)
  var WORD_POOL = [
    'GAME', 'CODE', 'HTML', 'JAVA', 'PYTHON',
    'REACT', 'MOUSE', 'CLICK', 'PIXEL', 'DEBUG',
    'ARRAY', 'CLASS', 'STYLE', 'LINUX', 'INPUT',
    'STACK', 'QUERY', 'SWIFT', 'LOGIC', 'PARSE'
  ];

  // ゲームごとに選ぶ単語数
  var WORDS_PER_GAME_MIN = 6;
  var WORDS_PER_GAME_MAX = 8;

  // ゲーム状態
  var grid = [];           // 10x10の2次元配列
  var placedWords = [];    // 配置済みの単語リスト
  var foundWords = [];     // 見つけた単語リスト
  var timerInterval = null;
  var elapsedSeconds = 0;
  var gameStarted = false;
  var gameFinished = false;

  // ドラッグ選択の状態
  var isDragging = false;
  var dragStartCell = null;  // {row, col}
  var dragEndCell = null;    // {row, col}
  var selectedCells = [];    // [{row, col}, ...]

  // DOM要素
  var letterGrid = document.getElementById('letter-grid');
  var wordListItems = document.getElementById('word-list-items');
  var timerEl = document.getElementById('timer');
  var bestTimeEl = document.getElementById('best-time');
  var completeOverlay = document.getElementById('game-complete-overlay');
  var finalTimeEl = document.getElementById('final-time');
  var bestTextEl = document.getElementById('best-text');

  // ============================================
  // ユーティリティ
  // ============================================

  // 配列をシャッフル(Fisher-Yates)
  function shuffle(array) {
    var arr = array.slice();
    for (var i = arr.length - 1; i > 0; i--) {
      var j = Math.floor(Math.random() * (i + 1));
      var temp = arr[i];
      arr[i] = arr[j];
      arr[j] = temp;
    }
    return arr;
  }

  // 秒数をm:ss形式にフォーマット
  function formatTime(seconds) {
    var m = Math.floor(seconds / 60);
    var s = seconds % 60;
    return m + ':' + (s < 10 ? '0' : '') + s;
  }

  // ランダムな大文字アルファベット
  function randomLetter() {
    return String.fromCharCode(65 + Math.floor(Math.random() * 26));
  }

  // ============================================
  // localStorage(ベストタイム)
  // ============================================

  function getBestTime() {
    var val = localStorage.getItem('bestWordSearch');
    if (val !== null) {
      var parsed = parseInt(val, 10);
      if (!isNaN(parsed)) return parsed;
    }
    return null;
  }

  function saveBestTime(seconds) {
    var current = getBestTime();
    if (current === null || seconds < current) {
      localStorage.setItem('bestWordSearch', String(seconds));
      return true;
    }
    return false;
  }

  function updateBestDisplay() {
    var best = getBestTime();
    if (best !== null) {
      bestTimeEl.textContent = formatTime(best);
    } else {
      bestTimeEl.textContent = '-';
    }
  }

  // ============================================
  // タイマー
  // ============================================

  function startTimer() {
    if (timerInterval) return;
    timerInterval = setInterval(function () {
      elapsedSeconds++;
      timerEl.textContent = formatTime(elapsedSeconds);
    }, 1000);
  }

  function stopTimer() {
    if (timerInterval) {
      clearInterval(timerInterval);
      timerInterval = null;
    }
  }

  function resetTimer() {
    stopTimer();
    elapsedSeconds = 0;
    timerEl.textContent = '0:00';
  }

  // ============================================
  // グリッド生成・単語配置
  // ============================================

  // 空のグリッドを生成
  function createEmptyGrid() {
    var g = [];
    for (var r = 0; r < GRID_SIZE; r++) {
      var row = [];
      for (var c = 0; c < GRID_SIZE; c++) {
        row.push('');
      }
      g.push(row);
    }
    return g;
  }

  // 単語がグリッドに配置できるかチェック
  // direction: 'horizontal' or 'vertical'
  function canPlaceWord(g, word, row, col, direction) {
    var len = word.length;

    if (direction === 'horizontal') {
      if (col + len > GRID_SIZE) return false;
      for (var i = 0; i < len; i++) {
        var existing = g[row][col + i];
        if (existing !== '' && existing !== word[i]) return false;
      }
    } else {
      if (row + len > GRID_SIZE) return false;
      for (var i = 0; i < len; i++) {
        var existing = g[row + i][col];
        if (existing !== '' && existing !== word[i]) return false;
      }
    }
    return true;
  }

  // 単語をグリッドに配置
  function placeWord(g, word, row, col, direction) {
    var cells = [];
    if (direction === 'horizontal') {
      for (var i = 0; i < word.length; i++) {
        g[row][col + i] = word[i];
        cells.push({ row: row, col: col + i });
      }
    } else {
      for (var i = 0; i < word.length; i++) {
        g[row + i][col] = word[i];
        cells.push({ row: row + i, col: col });
      }
    }
    return cells;
  }

  // グリッドに単語群を配置(ランダムに試行)
  function generateGrid() {
    var maxAttempts = 100;

    for (var attempt = 0; attempt < maxAttempts; attempt++) {
      var g = createEmptyGrid();
      var shuffled = shuffle(WORD_POOL);
      var wordCount = WORDS_PER_GAME_MIN + Math.floor(Math.random() * (WORDS_PER_GAME_MAX - WORDS_PER_GAME_MIN + 1));
      var candidates = shuffled.slice(0, Math.min(wordCount + 4, shuffled.length)); // 少し多めに試す
      var placed = [];

      for (var w = 0; w < candidates.length; w++) {
        if (placed.length >= wordCount) break;

        var word = candidates[w];
        var directions = shuffle(['horizontal', 'vertical']);
        var wordPlaced = false;

        for (var d = 0; d < directions.length; d++) {
          if (wordPlaced) break;
          var dir = directions[d];

          // ランダムな位置を試す
          var positions = [];
          for (var r = 0; r < GRID_SIZE; r++) {
            for (var c = 0; c < GRID_SIZE; c++) {
              positions.push({ row: r, col: c });
            }
          }
          positions = shuffle(positions);

          for (var p = 0; p < positions.length; p++) {
            if (canPlaceWord(g, word, positions[p].row, positions[p].col, dir)) {
              var cells = placeWord(g, word, positions[p].row, positions[p].col, dir);
              placed.push({
                word: word,
                cells: cells,
                direction: dir,
                startRow: positions[p].row,
                startCol: positions[p].col
              });
              wordPlaced = true;
              break;
            }
          }
        }
      }

      if (placed.length >= WORDS_PER_GAME_MIN) {
        // 空セルをランダムな文字で埋める
        for (var r = 0; r < GRID_SIZE; r++) {
          for (var c = 0; c < GRID_SIZE; c++) {
            if (g[r][c] === '') {
              g[r][c] = randomLetter();
            }
          }
        }
        return { grid: g, words: placed };
      }
    }

    // フォールバック(ほぼ起こらないが安全のため)
    var g = createEmptyGrid();
    for (var r = 0; r < GRID_SIZE; r++) {
      for (var c = 0; c < GRID_SIZE; c++) {
        g[r][c] = randomLetter();
      }
    }
    return { grid: g, words: [] };
  }

  // ============================================
  // 描画
  // ============================================

  function renderBoard() {
    var result = generateGrid();
    grid = result.grid;
    placedWords = result.words;
    foundWords = [];

    // レターグリッドを生成
    letterGrid.innerHTML = '';
    for (var r = 0; r < GRID_SIZE; r++) {
      for (var c = 0; c < GRID_SIZE; c++) {
        var cell = document.createElement('div');
        cell.className = 'letter-cell';
        cell.textContent = grid[r][c];
        cell.setAttribute('data-row', r);
        cell.setAttribute('data-col', c);
        letterGrid.appendChild(cell);
      }
    }

    // 単語リストを生成
    wordListItems.innerHTML = '';
    // アルファベット順にソート
    var sortedWords = placedWords.slice().sort(function (a, b) {
      return a.word.localeCompare(b.word);
    });
    for (var i = 0; i < sortedWords.length; i++) {
      var item = document.createElement('span');
      item.className = 'word-item';
      item.setAttribute('data-word', sortedWords[i].word);
      item.innerHTML = '<span class="word-check">\u2713</span>' + sortedWords[i].word;
      wordListItems.appendChild(item);
    }
  }

  // ============================================
  // セル選択ロジック
  // ============================================

  // 2つのセルの間に有効な直線があるか(水平か垂直のみ)
  function getLineCells(startCell, endCell) {
    if (!startCell || !endCell) return [];

    var r1 = startCell.row;
    var c1 = startCell.col;
    var r2 = endCell.row;
    var c2 = endCell.col;

    var cells = [];

    if (r1 === r2) {
      // 水平
      var minC = Math.min(c1, c2);
      var maxC = Math.max(c1, c2);
      for (var c = minC; c <= maxC; c++) {
        cells.push({ row: r1, col: c });
      }
    } else if (c1 === c2) {
      // 垂直
      var minR = Math.min(r1, r2);
      var maxR = Math.max(r1, r2);
      for (var r = minR; r <= maxR; r++) {
        cells.push({ row: r, col: c1 });
      }
    }
    // 斜めなどは空配列を返す

    return cells;
  }

  // 選択されたセルのハイライトを更新
  function updateSelectionHighlight() {
    // すべてのselecting/invalidクラスを除去
    var allCells = letterGrid.querySelectorAll('.letter-cell');
    for (var i = 0; i < allCells.length; i++) {
      allCells[i].classList.remove('selecting');
      allCells[i].classList.remove('invalid');
    }

    if (selectedCells.length === 0) return;

    // 選択されたセルをハイライト
    for (var i = 0; i < selectedCells.length; i++) {
      var sc = selectedCells[i];
      var cellEl = getCellElement(sc.row, sc.col);
      if (cellEl && !cellEl.classList.contains('found')) {
        cellEl.classList.add('selecting');
      }
    }
  }

  // 特定のセルのDOM要素を取得
  function getCellElement(row, col) {
    return letterGrid.querySelector('[data-row="' + row + '"][data-col="' + col + '"]');
  }

  // 選択範囲からテキストを取得
  function getSelectedText(cells) {
    var text = '';
    for (var i = 0; i < cells.length; i++) {
      text += grid[cells[i].row][cells[i].col];
    }
    return text;
  }

  // 選択が有効な単語かチェック
  function checkSelection(cells) {
    if (cells.length < 2) return null;

    var selectedText = getSelectedText(cells);

    for (var i = 0; i < placedWords.length; i++) {
      var pw = placedWords[i];
      if (foundWords.indexOf(pw.word) !== -1) continue;

      if (selectedText === pw.word) {
        // セルの位置も一致するかチェック
        if (cellsMatch(cells, pw.cells)) {
          return pw;
        }
      }
    }

    return null;
  }

  // セル配列が一致するか
  function cellsMatch(cells1, cells2) {
    if (cells1.length !== cells2.length) return false;
    for (var i = 0; i < cells1.length; i++) {
      if (cells1[i].row !== cells2[i].row || cells1[i].col !== cells2[i].col) {
        return false;
      }
    }
    return true;
  }

  // 単語を見つけた処理
  function markWordFound(wordData) {
    foundWords.push(wordData.word);

    // セルにfoundクラスを追加
    for (var i = 0; i < wordData.cells.length; i++) {
      var cellEl = getCellElement(wordData.cells[i].row, wordData.cells[i].col);
      if (cellEl) {
        cellEl.classList.remove('selecting');
        cellEl.classList.add('found');
        cellEl.classList.add('just-found');
        // アニメーション後にjust-foundを除去
        (function (el) {
          setTimeout(function () {
            el.classList.remove('just-found');
          }, 500);
        })(cellEl);
      }
    }

    // 単語リストの更新
    var wordItem = wordListItems.querySelector('[data-word="' + wordData.word + '"]');
    if (wordItem) {
      wordItem.classList.add('found');
    }

    // 全単語発見チェック
    if (foundWords.length === placedWords.length) {
      setTimeout(function () {
        handleGameComplete();
      }, 600);
    }
  }

  // ============================================
  // イベントハンドラ(マウス & タッチ)
  // ============================================

  function getCellFromEvent(e) {
    var target;
    if (e.touches && e.touches.length > 0) {
      var touch = e.touches[0];
      target = document.elementFromPoint(touch.clientX, touch.clientY);
    } else {
      target = e.target;
    }

    if (!target || !target.classList || !target.classList.contains('letter-cell')) {
      return null;
    }

    return {
      row: parseInt(target.getAttribute('data-row'), 10),
      col: parseInt(target.getAttribute('data-col'), 10)
    };
  }

  function onPointerDown(e) {
    if (gameFinished) return;
    e.preventDefault();

    var cell = getCellFromEvent(e);
    if (!cell) return;

    // タイマー開始
    if (!gameStarted) {
      gameStarted = true;
      startTimer();
    }

    isDragging = true;
    dragStartCell = cell;
    dragEndCell = cell;
    selectedCells = [cell];
    updateSelectionHighlight();
  }

  function onPointerMove(e) {
    if (!isDragging || gameFinished) return;
    e.preventDefault();

    var cell = getCellFromEvent(e);
    if (!cell) return;

    // 同じセルなら何もしない
    if (dragEndCell && cell.row === dragEndCell.row && cell.col === dragEndCell.col) return;

    dragEndCell = cell;
    selectedCells = getLineCells(dragStartCell, dragEndCell);

    if (selectedCells.length === 0) {
      // 斜めなど無効な方向 - 開始セルのみ表示
      selectedCells = [dragStartCell];
    }

    updateSelectionHighlight();
  }

  function onPointerUp(e) {
    if (!isDragging || gameFinished) return;
    e.preventDefault();

    isDragging = false;

    // 選択が有効な単語かチェック
    if (selectedCells.length >= 2) {
      var wordData = checkSelection(selectedCells);
      if (wordData) {
        markWordFound(wordData);
      } else {
        // 無効な選択 - フラッシュ
        flashInvalid(selectedCells);
      }
    }

    // 選択をクリア
    selectedCells = [];
    dragStartCell = null;
    dragEndCell = null;
    updateSelectionHighlight();
  }

  // 無効な選択時の一瞬のフラッシュ
  function flashInvalid(cells) {
    for (var i = 0; i < cells.length; i++) {
      var cellEl = getCellElement(cells[i].row, cells[i].col);
      if (cellEl && !cellEl.classList.contains('found')) {
        cellEl.classList.remove('selecting');
        cellEl.classList.add('invalid');
      }
    }
    setTimeout(function () {
      var allCells = letterGrid.querySelectorAll('.letter-cell.invalid');
      for (var i = 0; i < allCells.length; i++) {
        allCells[i].classList.remove('invalid');
      }
    }, 300);
  }

  // イベントリスナーの登録
  function attachGridEvents() {
    // マウスイベント
    letterGrid.addEventListener('mousedown', onPointerDown);
    letterGrid.addEventListener('mousemove', onPointerMove);
    document.addEventListener('mouseup', onPointerUp);

    // タッチイベント
    letterGrid.addEventListener('touchstart', onPointerDown, { passive: false });
    letterGrid.addEventListener('touchmove', onPointerMove, { passive: false });
    document.addEventListener('touchend', onPointerUp);
  }

  // ============================================
  // ゲームクリア
  // ============================================

  function handleGameComplete() {
    stopTimer();
    gameFinished = true;
    if (window.Leaderboard) Leaderboard.show('word-search', elapsedSeconds, { order: 'asc', format: 'time' });

    var isNewBest = saveBestTime(elapsedSeconds);

    finalTimeEl.textContent = formatTime(elapsedSeconds);

    if (isNewBest) {
      bestTextEl.textContent = '\uD83C\uDF89 \u65B0\u8A18\u9332\uFF01';
    } else {
      var best = getBestTime();
      if (best !== null) {
        bestTextEl.textContent = '\u30D9\u30B9\u30C8: ' + formatTime(best);
      } else {
        bestTextEl.textContent = '';
      }
    }

    updateBestDisplay();
    completeOverlay.classList.add('active');
  }

  // ============================================
  // ゲーム初期化
  // ============================================

  function initGame() {
    // 状態のリセット
    gameStarted = false;
    gameFinished = false;
    isDragging = false;
    dragStartCell = null;
    dragEndCell = null;
    selectedCells = [];

    resetTimer();
    completeOverlay.classList.remove('active');
    updateBestDisplay();

    // ボード生成
    renderBoard();
  }

  // ============================================
  // イベントリスナー
  // ============================================

  // New Gameボタン
  document.getElementById('btn-new-game').addEventListener('click', initGame);

  // もう一度遊ぶボタン
  document.getElementById('btn-play-again').addEventListener('click', initGame);

  // グリッドイベント
  attachGridEvents();

  // ============================================
  // ゲーム開始
  // ============================================

  initGame();

})();

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

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

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

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

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

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

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

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

もっと学びたい人へ

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

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