TypeScript 入門 第6回:ゲームアプリ開発の基礎オセロアプリを作成しモジュール化を理解しよう

プログラミング


6回目となる今回は、TypeScriptを使ってシンプルなオセロアプリを作成しながら「モジュール化」の基本を学んでいきます。

プログラムを書き進めていくと、特に大きなプロジェクトになってくると、一つのファイルにたくさんのコードが詰まってしまいがちです。

そうなると、「あの処理はどこに書いたっけ?」「ここを修正したら、どこに影響が出るんだろう…」と、コードの迷子になったり、修正が怖くなったりすることがあります。

そこで役立つのが「モジュール化」という考え方です。

「モジュールって何?」「import や export はどう書けばいいの?」
そんな方でも安心して読めるように、やさしく丁寧にステップ解説していきます。

モジュール化とは?

モジュール化とは、大きなプログラムを、機能や役割ごとに小さな部品(モジュール)に分割して管理することです。ちょうど、おもちゃのブロックで作品を作るように、一つ一つのブロック(モジュール)を組み合わせて、大きなシステムを作り上げるイメージです。

今回のオセロアプリで考えてみましょう。もし全ての処理を main.ts という一つのファイルに書いてしまうと、盤面の表示、石を置くルール、勝敗判定など、様々なコードが混在し、あっという間に複雑怪奇なファイルになってしまいます。

そこで、モジュール化を使って、以下のように役割ごとにファイルを分割します。

  • board.ts: オセロの盤面の初期化や、画面にマス目を描画する役割を担当します。
  • logic.ts: 石を置けるかどうかの判定、石をひっくり返す処理など、オセロのゲームロジックを担当します。
  • main.ts: board.tslogic.ts を呼び出して、ゲーム全体の流れをコントロールします。

これが「モジュール化」の基本的な考え方です。

TypeScriptコードをモジュール化するメリット

モジュール化は、読みやすく、メンテナンスしやすく、そして再利用可能なプログラムを作るための、とても重要なテクニックです。

モジュール化のメリットを解説します。

見通しが良くなる

機能ごとにファイルが分かれるので、どこに何が書かれているのか把握しやすくなります。

再利用しやすくなる

例えば、logic.ts で作った「石をひっくり返す処理」は、もしかしたら別のボードゲームアプリでも使えるかもしれません。モジュールとして独立していれば、他のプロジェクトに部品として持っていきやすくなります。

バグが見つけやすくなる・修正しやすくなる

問題が発生したとき、原因となっている箇所が特定のモジュールに限定されやすいため、デバッグが効率的になります。また、修正による影響範囲も小さく抑えられます。

チーム開発がしやすくなる

大規模なプロジェクトでは、複数人で手分けして開発することが一般的です。モジュールごとに担当を分ければ、他の人の作業と衝突しにくくなり、スムーズに開発を進められます。

モジュール化の基本の型を解説:export と import

TypeScriptでモジュール化を実現するためには、主に2つのキーワードが登場します。それが export(エクスポート)と import(インポート)です。

export: 「この関数や変数は、他のファイルからも使えるように公開します!」という宣言です。

import: 「あのファイルで公開されている、あの機能を使わせてください!」という宣言です。他のモジュールが提供する機能を、自分のモジュールで利用するときに使います。

便利な関数を集めた utils.ts モジュールを作る

まず、挨拶をする簡単な関数を持つ utils.ts というファイル(モジュール)を作ってみましょう。

// utils.ts
export function greet(name: string): void {
  console.log(`こんにちは、${name}さん!`);
}

使う側:main.ts

import { ... } の波括弧 {} の中に、利用したい関数名や変数名を指定します。

// main.ts
import { greet } from "./utils.js";

greet("たろう");

これがモジュールの基本的な構文です。

export で機能を公開し、import でそれを利用する。この関係性をしっかり覚えておきましょう。

TypeScriptのコンパイルとローカルサーバーの起動

これまでTypeScriptファイルをJavaScriptに変換(コンパイル)する際には、ターミナルで npx tsc コマンドを実行してきました。

モジュールを使ったプログラムでも、このコマンドは使用します。

しかし、モジュール化されたJavaScriptファイルをブラウザで動かすには、もう一手間必要になります。それが、ローカルWebサーバーの起動です。

以下のコマンドでコンパイラとサーバーの起動を行います。

npx tsc
npx serve .

npx serve . が必要な理由を解説

TypeScriptで作ったモジュールをHTMLから読み込むとき、こう書きます:

<script type="module" src="./dist/main.js"></script>

この type="module" を使うと、ブラウザは「これはモジュールだ」と判断して特別なルールで読み込みます。

ただし、ローカルファイル(file:// で開くHTML)からモジュールを使おうとすると、セキュリティ上の理由でブロックされてしまいます

そこで必要なのが npx serve . です。

これは、あなたのパソコン上に一時的なWebサーバーを立ち上げてくれるコマンドです。

これを使えば、http://localhost:3000 のようなURLからファイルを開けるようになり、モジュールも正常に動作します。

モジュール化を使ってオセロアプリを作成する

それでは、実際にオセロアプリの開発を始めるための準備をしましょう。

フォルダ構成と初期設定

下記コマンドで、必要なフォルダとファイルを作成します。

mkdir ait-ts-othelloapp6
cd ait-ts-othelloapp6
npm init -y
npm install typescript --save-dev
npx tsc --init

🔧 tsconfig.json を編集

生成された tsconfig.json を開き、以下のように編集します。

{
  "compilerOptions": {
    "target": "ES6",
    "module": "ES6",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  }
}

フォルダ&ファイル構成

以下のフォルダとファイルも追加で作成します。ターミナルで以下のコマンドを実行してください。

mkdir src
touch src/main.ts
touch src/board.ts
touch src/logic.ts
touch index.html
touch style.css

今回は、モジュール化のためにsrc配下に3つのファイルを作成しています。

これで、以下のようなフォルダとファイル構成になります。

ts-othello/
├── node_modules/   (npm install で自動生成)
├── dist/           (tsc でコンパイル後に自動生成)
├── src/
│   ├── board.ts  (見た目(UI)を担当するファイル)
│   ├── logic.ts  (ゲームのルール(ロジック)を担当するファイル)
│   └── main.ts    (TypeScriptのメインコード)
├── index.html      (ゲーム画面のHTML)
├── package.json
├── package-lock.json (npm install で自動生成)
├── style.css       (見た目を整えるCSS)
└── tsconfig.json   (TypeScriptの設定ファイル)

ここからは、HTMLで盤面の骨組みを作り、CSSで見た目を整え、そしてTypeScriptでゲームの心臓部であるロジックを実装していきます。

src/borrd.ts

import { handleClick, CellState } from "./logic.js";

export const SIZE = 8;
export let cells: CellState[][] = [];
let currentPlayer: CellState = "black";

export function getCurrentPlayer(): CellState {
  return currentPlayer;
}

export function setCurrentPlayer(player: CellState): void {
  currentPlayer = player;
}


const board = document.getElementById("board") as HTMLDivElement;
const turnDisplay = document.getElementById("turn") as HTMLHeadingElement;
const scoreDisplay = document.getElementById("score") as HTMLParagraphElement;
const messageDisplay = document.getElementById("message") as HTMLParagraphElement;
const resetBtn = document.getElementById("resetBtn") as HTMLButtonElement;

export function initBoard() {
  cells = Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () => "empty")
  );

  const mid = SIZE / 2;
  cells[mid - 1][mid - 1] = "white";
  cells[mid - 1][mid] = "black";
  cells[mid][mid - 1] = "black";
  cells[mid][mid] = "white";

  currentPlayer = "black";
  renderBoard();
}

export function renderBoard() {
  board.innerHTML = "";
  for (let y = 0; y < SIZE; y++) {
    for (let x = 0; x < SIZE; x++) {
      const cell = document.createElement("div");
      cell.className = "cell";
      const state = cells[y][x];
      if (state !== "empty") cell.classList.add(state);
      cell.addEventListener("click", () => handleClick(x, y));
      board.appendChild(cell);
    }
  }
  turnDisplay.textContent = `手番: ${currentPlayer === "black" ? "●" : "◯"}`;
  messageDisplay.textContent = "";
  updateScoreAndCheckWinner();
  updateScore();
}

function updateScore() {
  let black = 0;
  let white = 0;

  for (let row of cells) {
    for (let cell of row) {
      if (cell === "black") black++;
      if (cell === "white") white++;
    }
  }

  scoreDisplay.textContent = `黒: ${black} | 白: ${white}`;
}

function updateScoreAndCheckWinner() {
  let black = 0;
  let white = 0;

  for (let row of cells) {
    for (let cell of row) {
      if (cell === "black") black++;
      if (cell === "white") white++;
    }
  }

  scoreDisplay.textContent = `黒: ${black} | 白: ${white}`;

  const total = black + white;
  if (total === SIZE * SIZE || !hasValidMove()) {
    let result = "";
    if (black > white) result = "黒の勝ち!";
    else if (white > black) result = "白の勝ち!";
    else result = "引き分け!";
    messageDisplay.textContent = `ゲーム終了: ${result}`;
  }
}

function hasValidMove(): boolean {
  return hasValidMoveFor("black") || hasValidMoveFor("white");
}

function hasValidMoveFor(player: CellState): boolean {
  for (let y = 0; y < SIZE; y++) {
    for (let x = 0; x < SIZE; x++) {
      if (isValidMove(x, y, player)) return true;
    }
  }
  return false;
}

resetBtn.addEventListener("click", () => {
  messageDisplay.textContent = "";
  initBoard();
});

import { isValidMove } from "./logic.js";

src/logic.ts

// ゲームのルールと石の反転処理

import {
    cells,
    renderBoard,
    SIZE,
    getCurrentPlayer,
    setCurrentPlayer
  } from "./board.js";
  
  export type CellState = "empty" | "black" | "white";
  
  const directions = [
    [0, -1], [1, -1], [1, 0], [1, 1],
    [0, 1], [-1, 1], [-1, 0], [-1, -1]
  ];
  
  export function handleClick(x: number, y: number) {
    const player = getCurrentPlayer();
  
    if (!isValidMove(x, y, player)) {
      const msg = document.getElementById("message") as HTMLParagraphElement;
      msg.textContent = "そこは置けません";
      return;
    }
  
    const msg = document.getElementById("message") as HTMLParagraphElement;
    msg.textContent = "";
  
    cells[y][x] = player;
    flipStones(x, y, player);
    setCurrentPlayer(player === "black" ? "white" : "black");
    renderBoard();
  }
  
  export function isValidMove(x: number, y: number, player: CellState): boolean {
    if (cells[y][x] !== "empty") return false;
    const opponent = player === "black" ? "white" : "black";
  
    for (const [dx, dy] of directions) {
      let nx = x + dx;
      let ny = y + dy;
      let hasOpponent = false;
  
      while (nx >= 0 && nx < SIZE && ny >= 0 && ny < SIZE) {
        if (cells[ny][nx] === opponent) {
          hasOpponent = true;
        } else if (cells[ny][nx] === player) {
          if (hasOpponent) return true;
          else break;
        } else {
          break;
        }
        nx += dx;
        ny += dy;
      }
    }
  
    return false;
  }
  
  function flipStones(x: number, y: number, player: CellState) {
    const opponent = player === "black" ? "white" : "black";
  
    for (const [dx, dy] of directions) {
      let nx = x + dx;
      let ny = y + dy;
      const path: [number, number][] = [];
  
      while (nx >= 0 && nx < SIZE && ny >= 0 && ny < SIZE) {
        if (cells[ny][nx] === opponent) {
          path.push([nx, ny]);
        } else if (cells[ny][nx] === player) {
          for (const [fx, fy] of path) {
            cells[fy][fx] = player;
          }
          break;
        } else {
          break;
        }
        nx += dx;
        ny += dy;
      }
    }
  }

src/main.ts

// アプリの起点
import { initBoard } from "./board.js";

initBoard();

index.html

ゲームの盤面や情報を表示するためのHTMLファイルを作成します。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>TypeScript Othello</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <h1>AIT TypeScript オセロ</h1>
  <h2 id="turn">手番: ●</h2>
  <p id="score">黒: 2 | 白: 2</p>
  <p id="message"></p>
  <button id="resetBtn">リセット</button>
  <div id="board" class="board"></div>
  {/* JavaScriptファイルをモジュールとして読み込みます */}
  <script type="module" src="./dist/index.js"></script>
</body>
</html>

style.css(シンプル画面構成)

次に、シンプルな盤面と石のスタイルをCSSで定義します。

body {
    font-family: sans-serif;
    text-align: center;
    background-color: #f0f8ff;
  }
  
  .board {
    display: grid; /* グリッドレイアウトでマス目を並べる */
    grid-template-columns: repeat(8, 60px); /* 60px幅の列を8つ作成 */
    grid-template-rows: repeat(8, 60px);    /* 60px高さの行を8つ作成 */
    gap: 0px; /* マス間の隙間なし */
    margin: 20px auto;             /* 上下20pxのマージン、左右は自動で中央寄せ */
    background-color: #228B22;     /* オセロ盤の緑色 */
    border: 6px solid black;       /* 黒い太枠 */
    width: fit-content;            /* 中身のサイズに合わせて幅を調整 */
  }
  
  .cell { /* 各マス目のスタイル */
    width: 60px;
    height: 60px;
    border: 1px solid black; /* マス目を区切る細い黒線 */
    box-sizing: border-box;  /* borderを含めてwidth/heightを指定 */
    position: relative;      /* 石を配置する際の基準点とする */
  }
  
  /* 石のスタイル(::before疑似要素を使って円を描画) */
  .cell::before {
    content: "";
    position: absolute;
    width: 48px;  /* 石の直径 (60px - 枠線等考慮) */
    height: 48px;
    top: 50%;     /* 親要素(セル)の中央に配置 */
    left: 50%;
    transform: translate(-50%, -50%); /* 中央揃えの微調整 */
    border-radius: 50%; /* 円形にする */
    background-color: transparent; /* 初期状態は透明 */
    box-shadow: 0 0 4px rgba(0, 0, 0, 0.5); /* 石に影をつけて立体感を出す */
  }
  
  /* 黒石のスタイル */
  .cell.black::before {
    background-color: black;
  }
  
  /* 白石のスタイル */
  .cell.white::before {
    background-color: white;
  }
  
  /* メッセージ表示エリアのスタイル */
  #message {
    font-weight: bold;
    color: red;
    margin-top: 10px;
    min-height: 1.2em; /* メッセージがないときも高さを確保 */
  }

これで、オセロゲームの基本的な機能が一通り実装できました!

ビルドしてブラウザで確認

ここまで作成したオセロアプリを実際に動かしてみましょう!

npx tsc
npx serve .

ターミナルに Accepting connections at http://localhost:3000 のようなメッセージが表示されたら、サーバーが起動しています。(ポート番号は環境によって異なる場合があります)

ebブラウザを開き、アドレスバーに ターミナルに表示されたアドレス(http://localhost:3000 )と入力してアクセスします。

すると、作成したオセロゲームの画面が表示されるはずです!緑の盤面の真ん中に白黒それぞれ2つずつ配置してあり、交互に黒・白の石が置けるようになります。

また、リセットボタンで初期配置に戻るようにプログラミングもしています。

手番を進めて石を置く場所がなくなると画像のようにゲーム終了:〇〇の勝ち!と表示されます。

まとめ

今回は、TypeScriptを使ってクラシックなオセロゲームを開発しながら、プログラムを部品ごとに整理する「モジュール化」の基本について学びました。

関連する関数やデータを小さなファイル(モジュール)に分割し、それぞれが独立して機能するように設計することで、プログラム全体の見通しが格段に良くなり、機能追加や修正もずっと楽になります。

モジュール化を駆使しながら今後もアプリ開発を楽しんでいきましょう。

プロフィール
TanaT

株式会社あいてぃ所属。
クラウドエンジニア(AWS・Azure)
取得資格:AWS SAP、AZ-104、AZ-305
フロントエンド、バックエンド開発もできるフルスタックエンジニアとして学習中。
「AIとクラウドについて学ぶ」サイトの編集長。

TanaTをフォローする
プログラミング
シェアする

コメント

タイトルとURLをコピーしました