ReactとDraggableを使ったサッカー戦術アプリの作り方

サムネイル
               

サッカー戦術の理解やコーチングをサポートするために、ビジュアル的に戦術を表現できるアプリを作りたいと考えたことはありませんか?

 

今回の記事では、Reactとreact-draggableを使って、サッカー戦術アプリを構築する手順を詳しく解説していきます。

 

 

このアプリでは、ドラッグ可能なプレイヤーやサッカーボールをフィールド上に配置し、戦術を視覚的にシミュレーションできる機能を実装しています。

 

 

 

 

ReactとDraggableを使ったサッカー戦術アプリの作り方

react-draggableとは?

react-draggableは、Reactコンポーネントをドラッグ可能にするための軽量ライブラリです。このライブラリを使うと、マウスやタッチ操作で要素を自由に動かすことができるようになります。具体的には、以下のような機能を提供します。

 

  • 要素のドラッグ操作: コンポーネントをドラッグして、画面上の任意の位置に移動できる。
  • 位置の制御: ドラッグ可能な領域や移動範囲を指定可能。
  • イベントハンドリング: ドラッグ開始、ドラッグ中、ドラッグ終了時にカスタムイベントを処理できる。

 

このライブラリを使用することで、直感的なユーザーインターフェースを作成することができ、特にビジュアル的なシミュレーションやインタラクティブなツールの構築に適しています

 

アプリのファイル構造

soccer-tactics-app
│
├── src
│ ├── app
│ │ ├── components
│ │ │ ├── Ball.tsx
│ │ │ ├── Field
│ │ │ │ ├── Field.tsx
│ │ │ └── Player.tsx
│ │ └── page.tsx (または index.tsx、ルートページ)
│ └── styles (必要に応じて)
│
├── public
│ ├── soccer-ball.png (サッカーボールの画像)
│ └── (他の静的ファイル)
│
├── .gitignore
├── package.json
├── tsconfig.json
└── (その他設定ファイル)

 

 

Fieldコンポーネント

まず、Field.tsxでは、フィールド上に配置するプレイヤーとボールのデータを管理し、それぞれをレンダリングしています。

"use client";


import React, { useState, useRef } from "react";
import Draggable from "react-draggable";
import { FaBars } from "react-icons/fa";
import Player from "../Player";
import Ball from "../Ball";


const createPlayers = (
  team: "red" | "blue",
  gk: number,
  df: number,
  mf: number,
  fw: number,
  totalPlayers: number,
  offsetY: number // Y座標のオフセットを追加
) => {
  const positions = ["GK", ...Array(df).fill("DF"), ...Array(mf).fill("MF"), ...Array(fw).fill("FW")];


  const PLAYER_SPACING_Y = 20; // プレイヤー間のY座標の間隔
  const FIELD_CENTER_X = 400; // フィールドの中央のX座標


  const offsetX = team === "red" ? -430 : 10; // 赤チームは左、青チームは右に配置
  const baseY = 100 + offsetY; // Y座標の基準値にoffsetYを加える
  return Array.from({ length: totalPlayers }, (_, i) => ({
    initialX: FIELD_CENTER_X + offsetX, // チームごとのX座標に配置
    initialY: baseY + i * PLAYER_SPACING_Y, // プレイヤーのY座標を順に配置
    initialNumber: i + 1,
    initialPosition: positions[i] || "FW", // ポジションを動的に設定
    team: team,
  }));
};


function Field() {
  const [displayMode, setDisplayMode] = useState<"number" | "position">("number"); // "name" を削除
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const [redDf, setRedDf] = useState(4);
  const [redMf, setRedMf] = useState(4);
  const [redFw, setRedFw] = useState(2);
  const [blueDf, setBlueDf] = useState(4);
  const [blueMf, setBlueMf] = useState(4);
  const [blueFw, setBlueFw] = useState(2);
  const [teamSize, setTeamSize] = useState<"11" | "8">("11"); // 11人制か8人制かを管理


  const draggableRefsRed = useRef<Draggable[]>([]);
  const draggableRefsBlue = useRef<Draggable[]>([]);
  const ballRef = useRef<Draggable | null>(null);


  const handleTeamSizeChange = (size: "11" | "8") => {
    setTeamSize(size);
    if (size === "8") {
      // 8人制に合わせて初期値を変更
      setRedDf(3);
      setRedMf(3);
      setRedFw(1);
      setBlueDf(3);
      setBlueMf(3);
      setBlueFw(1);
    } else {
      // 11人制に合わせて初期値を変更
      setRedDf(4);
      setRedMf(4);
      setRedFw(2);
      setBlueDf(4);
      setBlueMf(4);
      setBlueFw(2);
    }
  };


  const totalPlayersRed = teamSize === "11" ? 11 : 8;
  const totalPlayersBlue = teamSize === "11" ? 11 : 8;


  // 赤チームと青チームのY座標オフセットを設定
  const offsetYRed = -90;
  const offsetYBlue = -90;


  const playersRed = createPlayers("red", 1, redDf, redMf, redFw, totalPlayersRed, offsetYRed);
  const playersBlue = createPlayers("blue", 1, blueDf, blueMf, blueFw, totalPlayersBlue, offsetYBlue);


  const toggleMenu = () => {
    setIsMenuOpen(!isMenuOpen);
  };


  const handleDisplayChange = (mode: "number" | "position") => {
    setDisplayMode(mode);
    setIsMenuOpen(false); // メニューを閉じる
  };


  const handleFormationChange = () => {
    // 新しいプレイヤー配置を反映する
    setIsMenuOpen(false); // メニューを閉じる
  };


  const handleReset = () => {
    // ドラッグ可能な要素を初期位置にリセットする
    draggableRefsRed.current.forEach((ref, index) => {
      if (ref && playersRed[index] && playersRed[index].initialX !== undefined && playersRed[index].initialY !== undefined) {
        ref.setState({ x: playersRed[index].initialX, y: playersRed[index].initialY });
      }
    });
   
    draggableRefsBlue.current.forEach((ref, index) => {
      if (ref && playersBlue[index] && playersBlue[index].initialX !== undefined && playersBlue[index].initialY !== undefined) {
        ref.setState({ x: playersBlue[index].initialX, y: playersBlue[index].initialY });
      }
    });
   
    if (ballRef.current) {
      ballRef.current.setState({ x: 390, y: 230 }); // ボールをフィールド中央にリセット
    }
  };


  return (
    <div style={{ position: "relative", width: "800px", height: "500px", background: "green", border: "2px solid white" }}>
      <button
        style={{
          position: "absolute",
          top: "10px",
          right: "10px",
          backgroundColor: "gray",
          color: "white",
          border: "none",
          padding: "10px",
          cursor: "pointer",
          zIndex: 20,
        }}
        onClick={toggleMenu}
      >
        <FaBars size={24} />
      </button>
      {isMenuOpen && (
        <div
          style={{
            position: "absolute",
            top: "50px",
            right: "10px",
            backgroundColor: "white",
            color: "black",
            border: "1px solid gray",
            padding: "10px",
            zIndex: 15,
          }}
        >
          <button
            style={{
              display: "block",
              marginBottom: "10px",
              backgroundColor: "transparent",
              border: "none",
              color: "black",
              cursor: "pointer",
            }}
            onClick={() => handleDisplayChange("number")}
          >
            ナンバー
          </button>
          <button
            style={{
              display: "block",
              marginBottom: "10px",
              backgroundColor: "transparent",
              border: "none",
              color: "black",
              cursor: "pointer",
            }}
            onClick={() => handleDisplayChange("position")}
          >
            ポジション
          </button>


          {/* チームサイズ選択 */}
          <div style={{ marginTop: "20px" }}>
            <label>
              <input type="radio" value="11" checked={teamSize === "11"} onChange={() => handleTeamSizeChange("11")} />
              11人制
            </label>
            <label style={{ marginLeft: "10px" }}>
              <input type="radio" value="8" checked={teamSize === "8"} onChange={() => handleTeamSizeChange("8")} />
              8人制
            </label>
          </div>


          {/* フォーメーション設定フォーム */}
          <div style={{ marginTop: "20px" }}>
            <h4>Red Team</h4>
            <label>
              DF:
              <input type="number" value={redDf} onChange={(e) => setRedDf(Number(e.target.value))} />
            </label>
            <label>
              MF:
              <input type="number" value={redMf} onChange={(e) => setRedMf(Number(e.target.value))} />
            </label>
            <label>
              FW:
              <input type="number" value={redFw} onChange={(e) => setRedFw(Number(e.target.value))} />
            </label>


            <h4>Blue Team</h4>
            <label>
              DF:
              <input type="number" value={blueDf} onChange={(e) => setBlueDf(Number(e.target.value))} />
            </label>
            <label>
              MF:
              <input type="number" value={blueMf} onChange={(e) => setBlueMf(Number(e.target.value))} />
            </label>
            <label>
              FW:
              <input type="number" value={blueFw} onChange={(e) => setBlueFw(Number(e.target.value))} />
            </label>


            <button onClick={handleFormationChange} style={{ marginTop: "10px" }}>
              フォーメーション更新
            </button>
          </div>
        </div>
      )}
      {/* ゴールエリア */}
      <div style={{ position: "absolute", top: "50%", left: "0", width: "10px", height: "100px", background: "white", transform: "translateY(-50%)" }} />
      <div style={{ position: "absolute", top: "50%", right: "0", width: "10px", height: "100px", background: "white", transform: "translateY(-50%)" }} />
      {/* 中央ライン */}
      <div style={{ position: "absolute", top: "0", left: "50%", width: "2px", height: "100%", background: "white", transform: "translateX(-50%)" }} />
      {/* 中央円 */}
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: "50%",
          width: "100px",
          height: "100px",
          border: "2px solid white",
          borderRadius: "50%",
          transform: "translate(-50%, -50%)",
        }}
      />
      {/* リセットボタン */}
      <button
        onClick={handleReset}
        style={{
          position: "absolute",
          bottom: "0px",
          left: "-100px",
          backgroundColor: "#FF5722",
          color: "white",
          border: "none",
          padding: "10px 15px",
          borderRadius: "5px",
          cursor: "pointer",
          boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
          zIndex: 20,
        }}
      >
        {" "}
        リセット
      </button>
      {/* 赤チームのプレイヤー */}
      {playersRed.map((player, index) => (
        <Draggable
          key={`red-${index}`}
          defaultPosition={{ x: player.initialX, y: player.initialY }}
          ref={(ref) => {
            if (ref !== null) {
              draggableRefsRed.current[index] = ref;
            }
          }}
        >
          <div style={{ position: "absolute", zIndex: 10 }}>
            <Player {...player} displayMode={displayMode} />
          </div>
        </Draggable>
      ))}
      {/* 青チームのプレイヤー */}
      {playersBlue.map((player, index) => (
        <Draggable
          key={`blue-${index}`}
          defaultPosition={{ x: player.initialX, y: player.initialY }}
          ref={(ref) => {
            if (ref !== null) {
              draggableRefsBlue.current[index] = ref;
            }
          }}
        >
          <div style={{ position: "absolute", zIndex: 10 }}>
            <Player {...player} displayMode={displayMode} />
          </div>
        </Draggable>
      ))}
      {/* サッカーボール */}
      <Draggable defaultPosition={{ x: 400, y: 250 }}>
        <Ball x={400} y={250} />
      </Draggable>{" "}
    </div>
  );
}


export default Field;

このコードは、サッカー戦術アプリの「Field」コンポーネントを定義しており、フィールド上にプレイヤーやボールを配置し、それらをドラッグ&ドロップで自由に動かせるようにしています。以下、コードの主要な部分をわかりやすく解説します。

 

インポート

import React, { useState, useRef } from "react";
import Draggable from "react-draggable";
import { FaBars } from "react-icons/fa";
import Player from "../Player";
import Ball from "../Ball";

まず、ReactやDraggableといった必要なモジュールをインポートします。FaBarsは、ハンバーガーメニューアイコンを表示するためのものです。

 

プレイヤーの配置を決定する関数

const createPlayers = (
team: "red" | "blue",
gk: number,
df: number,
mf: number,
fw: number,
totalPlayers: number,
offsetY: number
) => {
// プレイヤーのポジションを決定
const positions = ["GK", ...Array(df).fill("DF"), ...Array(mf).fill("MF"), ...Array(fw).fill("FW")];

// プレイヤー間の間隔やフィールドの中心を設定
const PLAYER_SPACING_Y = 20;
const FIELD_CENTER_X = 400;

// チームごとのX座標のオフセットを設定
const offsetX = team === "red" ? -430 : 10;
const baseY = 100 + offsetY;

// プレイヤーの配置を決定して配列で返す
return Array.from({ length: totalPlayers }, (_, i) => ({
initialX: FIELD_CENTER_X + offsetX,
initialY: baseY + i * PLAYER_SPACING_Y,
initialNumber: i + 1,
initialPosition: positions[i] || "FW",
team: team,
}));
};

この関数は、赤チームまたは青チームのプレイヤーをどこに配置するかを決定します。X座標はチームごとに異なり、Y座標はプレイヤー間の間隔に基づいて配置されます。

 

Fieldコンポーネント

function Field() {
const [displayMode, setDisplayMode] = useState<"number" | "position">("number");
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [redDf, setRedDf] = useState(4);
const [redMf, setRedMf] = useState(4);
const [redFw, setRedFw] = useState(2);
const [blueDf, setBlueDf] = useState(4);
const [blueMf, setBlueMf] = useState(4);
const [blueFw, setBlueFw] = useState(2);
const [teamSize, setTeamSize] = useState<"11" | "8">("11");

const draggableRefsRed = useRef<Draggable[]>([]);
const draggableRefsBlue = useRef<Draggable[]>([]);
const ballRef = useRef<Draggable | null>(null);

// プレイヤーとボールの配置をリセットする関数
const handleReset = () => {
draggableRefsRed.current.forEach((ref, index) => {
if (ref && playersRed[index]) {
ref.setState({ x: playersRed[index].initialX, y: playersRed[index].initialY });
}
});
draggableRefsBlue.current.forEach((ref, index) => {
if (ref && playersBlue[index]) {
ref.setState({ x: playersBlue[index].initialX, y: playersBlue[index].initialY });
}
});
if (ballRef.current) {
ballRef.current.setState({ x: 390, y: 230 });
}
};

// フォーメーション変更などの他のハンドラーもここに記述
}

Fieldコンポーネントでは、プレイヤーの表示モードやチームのフォーメーションを管理するための状態を定義し、リセットボタンやメニューの操作を管理する関数を持っています。

 

 

ドラッグ可能なプレイヤーとボール

return (
<div style={{ position: "relative", width: "800px", height: "500px", background: "green", border: "2px solid white" }}>
{/* メニューやリセットボタンのUI */}
{/* 赤チームのプレイヤー */}
{playersRed.map((player, index) => (
<Draggable
key={`red-${index}`}
defaultPosition={{ x: player.initialX, y: player.initialY }}
ref={(ref) => {
if (ref !== null) {
draggableRefsRed.current[index] = ref;
}
}}
>
<div style={{ position: "absolute", zIndex: 10 }}>
<Player {...player} displayMode={displayMode} />
</div>
</Draggable>
))}
{/* 青チームのプレイヤー */}
{playersBlue.map((player, index) => (
<Draggable
key={`blue-${index}`}
defaultPosition={{ x: player.initialX, y: player.initialY }}
ref={(ref) => {
if (ref !== null) {
draggableRefsBlue.current[index] = ref;
}
}}
>
<div style={{ position: "absolute", zIndex: 10 }}>
<Player {...player} displayMode={displayMode} />
</div>
</Draggable>
))}
{/* サッカーボール */}
<Draggable defaultPosition={{ x: 400, y: 250 }}>
<Ball x={400} y={250} />
</Draggable>
</div>
);

この部分では、Draggableコンポーネントを使って、プレイヤーやボールをドラッグできるように設定しています。プレイヤーは赤チームと青チームに分かれて配置され、ボールはフィールドの中央に置かれています。

 

 

 

 

 

 

 

Playerコンポーネント

次に、個々のプレイヤーを表示するためのPlayer.tsxを紹介します。

import React from 'react';
import Draggable from 'react-draggable';


interface PlayerProps {
  initialX: number;
  initialY: number;
  initialNumber: number;
  initialPosition: string;
  team: 'red' | 'blue';
  displayMode: 'number' | 'position'; // 名前モードを削除
}


const Player: React.FC<PlayerProps> = ({ initialX, initialY, initialNumber, initialPosition, team, displayMode }) => {
  const displayValue = () => {
    switch (displayMode) {
      case 'position':
        return initialPosition;
      case 'number':
      default:
        return initialNumber;
    }
  };


  return (
    <Draggable defaultPosition={{ x: initialX, y: initialY }}>
      <div
        style={{
          width: '30px',
          height: '30px',
          borderRadius: '50%',
          backgroundColor: team === 'red' ? 'red' : 'blue',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
          fontWeight: 'bold',
          cursor: 'pointer',
        }}
      >
        {displayValue()}
      </div>
    </Draggable>
  );
};


export default Player;

このコードは、サッカー戦術アプリにおいてプレイヤーを表示するための`Player`コンポーネントを定義しています。`Player`コンポーネントは、プレイヤーの初期位置、番号、ポジション、チーム(赤または青)、および表示モード(番号またはポジション)に基づいてプレイヤーをフィールド上に表示します。

 

Propsの定義 (`PlayerProps`)

  •  `initialX`: プレイヤーの初期X座標。
  • `initialY`: プレイヤーの初期Y座標。
  •  `initialNumber`: プレイヤーの番号(背番号など)。
  •  `initialPosition`: プレイヤーのポジション(GK, DF, MF, FWなど)。
  •  `team`: プレイヤーが所属するチーム。’red’(赤チーム)または ‘blue’(青チーム)。
  •  `displayMode`: プレイヤーが表示するモードを指定します。’number’の場合は番号を表示し、’position’の場合はポジションを表示します。

`displayValue`関数

  • `displayMode`によってプレイヤーが表示する内容を決定します。
  • `displayMode`が’position’の場合、`initialPosition`(ポジション)を表示します。
  • `displayMode`が’number’の場合、`initialNumber`(番号)を表示します。

`Draggable`コンポーネント

  • `Draggable`コンポーネントは、プレイヤーをドラッグ可能にするために使用されます。
  • `defaultPosition`プロパティを使って、プレイヤーの初期位置を設定します。この値は、`initialX`と`initialY`を使用して指定されています。

プレイヤーのスタイル (`div`内の`style`プロパティ)

  • `width`と`height`で、プレイヤーの円の大きさを30pxに設定しています。
  • `borderRadius`を`50%`にすることで、プレイヤーを丸い形にしています。
  • `backgroundColor`は、`team`が’red’なら赤、’blue’なら青に設定されています。
  • `display`, `alignItems`, `justifyContent`で、プレイヤー番号やポジションが円の中央に表示されるように調整されています。
  • `color`は白色、`fontWeight`は太字に設定され、視認性を高めています。
  • `cursor`は’pointer’に設定されており、プレイヤーがクリック可能であることを示します。

`export default Player;`

  • `Player`コンポーネントを他のファイルで使用できるようにエクスポートしています。

このコンポーネントは、サッカーフィールド上でプレイヤーの位置をドラッグ&ドロップで移動させ、表示モードに応じて背番号やポジションを表示するために使用されます。

 

Ballコンポーネント

最後に、ドラッグ可能なサッカーボールを表示するBall.tsxです。

 

import React from 'react';
import Draggable from 'react-draggable';



const Ball = ({ x, y }: { x: number; y: number }) => {
  return (
    <Draggable defaultPosition={{ x, y }}>
      <div
        className="absolute"
        style={{
          width: '25px',
          height: '25px',
          borderRadius: '50%',
          backgroundImage: 'url(/soccer-ball.png)',
          backgroundSize: 'cover',
          backgroundPosition: 'center',
        }}
      />
    </Draggable>
  );
};


export default Ball;

このコードは、サッカー戦術アプリでボールを表示するための `Ball` コンポーネントを定義しています。`Ball` コンポーネントは、指定された位置にサッカーボールの画像を表示し、それをドラッグ&ドロップで移動できるようにします。

 

コンポーネントの定義

  • `Ball` は関数型コンポーネントとして定義されています。これは、Reactコンポーネントを関数の形で記述するもので、軽量でシンプルなコンポーネントを作るのに適しています。
  • `Ball` コンポーネントは `x` と `y` という2つのプロパティ(props)を受け取ります。これらはボールの初期位置(X座標とY座標)を指定します。

`Draggable`コンポーネント

  • `Draggable` コンポーネントは、ボールをドラッグ可能にするために使用されます。
  • `defaultPosition` プロパティを使って、ボールの初期位置を指定します。この位置は `x` と `y` の値に基づいて設定されます。

ボールのスタイル (`div`内の`style`プロパティ)

  • `width` と `height` で、ボールのサイズを25px四方に設定しています。
  • `borderRadius` を `50%` に設定することで、ボールを丸い形にしています。
  • `backgroundImage` には、`/soccer-ball.png` というパスのサッカーボール画像が使用されています。これにより、ボールの見た目がサッカーボールになります。
  • `backgroundSize: ‘cover’` は、ボールの背景画像が要素全体をカバーするように設定するスタイルです。
  • `backgroundPosition: ‘center’` は、ボールの背景画像が中央に配置されるように設定するスタイルです。

`className=”absolute”`

`className=”absolute”` は、ボールの位置を絶対位置で指定するために使用されています。これにより、ボールが他の要素に影響されずに指定された位置に配置されます。

`export default Ball;`

  • `Ball` コンポーネントを他のファイルで使用できるようにエクスポートしています。これにより、サッカーフィールド上でボールを表示するために、このコンポーネントを再利用できます。